File size: 10,769 Bytes
76838d6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
# -*- coding: utf-8 -*-
"""
Prefetch downloader for TACO manifests:
- Reads "<file_name>\t<url>" (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: "<file_name>\t<url>".
    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/<split>")
    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()