# -*- coding: utf-8 -*- """ Prefetch downloader for TACO manifests: - Reads "\t" (or comma CSV) from --manifest - Downloads images into a URL-based cache (--cache_dir; hashed filename with image suffix) - Links/copies them into --dataset_images_dir (per split) Improvements: - Parallel downloads (--workers) - Optional HEAD checks (Content-Type/Status) before GET (--no-head-check to disable) - Image sanity (PIL.verify) to filter broken responses (--no-image-verify to disable) - Exponential backoff in http_get (retries) - Missing report as CSV next to the manifest - Link mode: symlink (Unix) or copy (Windows default) via --link-mode {auto,copy,symlink} - Limit/shuffle preserved """ import os import sys import io import csv import time import hashlib import argparse import random from pathlib import Path from typing import List, Tuple, Optional from urllib.parse import urlparse from concurrent.futures import ThreadPoolExecutor, as_completed from collections import defaultdict import requests from PIL import Image # --------------------------- Path/manifest helpers --------------------------- def url_to_cache_path(url: str, cache_root: Path) -> Path: h = hashlib.sha1(url.encode("utf-8")).hexdigest()[:20] # Derive suffix from URL, else default to .jpg suf = "" for s in [".jpg", ".jpeg", ".png", ".webp", ".bmp", ".gif"]: if url.lower().split("?")[0].endswith(s): suf = s break if not suf: suf = ".jpg" return cache_root / f"{h}{suf}" def load_manifest(path: Path) -> List[Tuple[str, str]]: """ Expected lines: "\t". Falls back to comma CSV if no tab is present. Ignores empty and malformed lines. """ out: List[Tuple[str, str]] = [] txt = path.read_text(encoding="utf-8") # Erlaubt gemischte Delimiter (Tab bevorzugt) for raw in txt.splitlines(): line = raw.strip() if not line: continue parts: List[str] if "\t" in line: parts = [p.strip() for p in line.split("\t")] else: # CSV-Fallback (einfach): nur 2 Spalten relevant parts = [p.strip() for p in line.split(",")] if len(parts) < 2: continue file_name, url = parts[0], parts[1] if not file_name or not url: continue out.append((file_name, url)) return out # --------------------------- Network/download helpers ------------------------ def domain_of(url: str) -> str: try: return urlparse(url).netloc.lower() except Exception: return "unknown" def head_ok(url: str, timeout: int = 10) -> bool: """ Quick HEAD validation: - HTTP status < 400 - Content-Type contains 'image' (or empty = accept) """ try: r = requests.head(url, timeout=timeout, allow_redirects=True) if r.status_code >= 400: return False ct = r.headers.get("Content-Type", "") return ("image" in ct.lower()) or (ct.strip() == "") except Exception: return False def http_get_verified( url: str, dst: Path, retries: int = 3, timeout: int = 25, verify_image: bool = True, backoff_base: float = 0.6, ) -> Tuple[bool, str]: """ GET with retries + optional image verification (PIL.verify()). Writes to 'dst' only on success. Returns (ok, errmsg). """ last_err = "" for attempt in range(retries): try: r = requests.get(url, timeout=timeout, allow_redirects=True) r.raise_for_status() data = r.content if verify_image: try: Image.open(io.BytesIO(data)).verify() except Exception as e: last_err = f"invalid image bytes: {e}" # Backoff vor erneutem Versuch time.sleep(backoff_base * (attempt + 1)) continue dst.parent.mkdir(parents=True, exist_ok=True) with dst.open("wb") as f: f.write(data) return True, "" except Exception as e: last_err = str(e) time.sleep(backoff_base * (attempt + 1)) return False, last_err or "unknown error" # ------------------------------- Linking ------------------------------------ def link_into_dataset(cache_path: Path, dataset_img_path: Path, link_mode: str = "auto") -> None: """ Link/copy from cache into the dataset path. link_mode: - auto -> Windows = copy, otherwise symlink - copy -> always copy - symlink -> always symlink (may fail on Windows without privileges) Existing destination file is replaced (Unix: unlink + symlink). """ dataset_img_path.parent.mkdir(parents=True, exist_ok=True) effective_mode = link_mode if link_mode == "auto": effective_mode = "copy" if os.name == "nt" else "symlink" if effective_mode == "copy": if not dataset_img_path.exists(): import shutil shutil.copy2(cache_path, dataset_img_path) else: # destination exists -> overwrite import shutil shutil.copy2(cache_path, dataset_img_path) else: # symlink if dataset_img_path.exists() or dataset_img_path.is_symlink(): try: dataset_img_path.unlink() except FileNotFoundError: pass dataset_img_path.symlink_to(cache_path.resolve()) # ------------------------------- Worker ------------------------------------- def process_one( file_name: str, url: str, cache_dir: Path, dataset_images_dir: Path, do_head_check: bool, verify_image: bool, link_mode: str, retries: int, timeout: int, ) -> Tuple[str, str, bool, str]: """ Process one (file_name, url): write cache file, link into dataset. Returns: (file_name, url, ok, error_msg) """ cache_path = url_to_cache_path(url, cache_dir) # Optional: HEAD check (quick fail-fast for 404 or non-image Content-Type) if do_head_check and not cache_path.exists(): if not head_ok(url): return file_name, url, False, "HEAD check failed" # Download falls im Cache fehlend if not cache_path.exists(): ok, msg = http_get_verified( url, cache_path, retries=retries, timeout=timeout, verify_image=verify_image ) if not ok: return file_name, url, False, msg # Link/copy into dataset under the desired name dataset_img_path = dataset_images_dir / Path(file_name).name try: link_into_dataset(cache_path, dataset_img_path, link_mode=link_mode) except Exception as e: return file_name, url, False, f"link failed: {e}" return file_name, url, True, "" # --------------------------------- Main ------------------------------------- def main(): ap = argparse.ArgumentParser() ap.add_argument("--manifest", required=True, type=Path, help="Path to train/val/test manifest .txt") ap.add_argument("--cache_dir", required=True, type=Path, help="e.g., data/cache/images") ap.add_argument("--dataset_images_dir", required=True, type=Path, help="ml/datasets/taco/images/") ap.add_argument("--limit", type=int, default=0, help="optional: only warm-up N images (0=all)") ap.add_argument("--shuffle", action="store_true", help="randomize manifest order") ap.add_argument("--workers", type=int, default=min(8, max(2, (os.cpu_count() or 4) // 2)), help="parallel downloads (default: half of CPU cores, max 8)") ap.add_argument("--retries", type=int, default=3, help="HTTP retries per file") ap.add_argument("--timeout", type=int, default=25, help="HTTP timeout seconds") ap.add_argument("--no-head-check", action="store_true", help="disable HEAD check before GET") ap.add_argument("--no-image-verify", action="store_true", help="disable PIL.verify() for received bytes") ap.add_argument("--link-mode", choices=["auto", "copy", "symlink"], default="auto", help="dataset link behavior (default auto: Windows=copy, else=symlink)") args = ap.parse_args() pairs = load_manifest(args.manifest) if not pairs: print(f"[ERROR] Manifest empty or invalid: {args.manifest}", file=sys.stderr) sys.exit(1) if args.shuffle: random.shuffle(pairs) if args.limit and args.limit > 0: pairs = pairs[:args.limit] args.cache_dir.mkdir(parents=True, exist_ok=True) args.dataset_images_dir.mkdir(parents=True, exist_ok=True) do_head_check = not args.no_head_check verify_image = not args.no_image_verify # Parallel abarbeiten ok = fail = 0 failures: List[Tuple[str, str, str]] = [] # (file_name, url, error) total = len(pairs) print(f"[INFO] prefetch start: total={total}, workers={args.workers}, " f"head_check={do_head_check}, img_verify={verify_image}, link_mode={args.link_mode}") with ThreadPoolExecutor(max_workers=max(1, args.workers)) as ex: futs = [ ex.submit( process_one, file_name, url, args.cache_dir, args.dataset_images_dir, do_head_check, verify_image, args.link_mode, args.retries, args.timeout, ) for (file_name, url) in pairs ] for i, fut in enumerate(as_completed(futs), 1): file_name, url, success, err = fut.result() if success: ok += 1 if ok % 100 == 0 or ok == 1: print(f"[OK] {ok}/{total} {file_name}") else: fail += 1 failures.append((file_name, url, err)) print(f"[WARN] {file_name} -> {err}", file=sys.stderr) # Missing report (CSV) next to the manifest if failures: miss_path = args.manifest.parent / f"missing_{args.manifest.name.replace('.txt','')}.csv" try: with miss_path.open("w", encoding="utf-8", newline="") as f: w = csv.writer(f) w.writerow(["file_name", "url", "error"]) for fn, u, e in failures: w.writerow([fn, u, e]) print(f"[INFO] missing report: {miss_path} (fail={fail})") except Exception as e: print(f"[WARN] missing report write failed: {e}", file=sys.stderr) print(f"[INFO] prefetch finished. ok={ok}, fail={fail}") # Do not hard-fail even if there were errors (keep behavior) if fail > 0: sys.exit(0) if __name__ == "__main__": main()