File size: 4,888 Bytes
f655146
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
# finetune/preprocess.py
"""Stage 0.5 — clean the collected image pool into a training-ready set.

Sweeps finetune/data/images/**, and for each image:
  1. optional fixed fractional crop (per source) — strips page furniture like the
     "Kye Drawing for student" header / signature footer on the village scans;
  2. auto-trim near-uniform borders (white margins on doodles & scans);
  3. resize so the longest side ≤ MAX_SIZE;
  4. RGB-normalise and re-encode;
  5. drop near-duplicates via a 64-bit average hash (Hamming distance).

Outputs finetune/data/processed/<source>/<name>.jpg + a manifest.json. The
labeler (gen_labels.py) can then point at finetune/data/processed.

Run:
    uv run python finetune/preprocess.py            # process everything
    uv run python finetune/preprocess.py --max-size 1024 --dedupe-distance 4
"""

import argparse
import json
from pathlib import Path

from PIL import Image, ImageChops

ROOT = Path(__file__).resolve().parent
SRC = ROOT / "data" / "images"
DST = ROOT / "data" / "processed"

# Per-source fractional crop (top, bottom, left, right) to remove page furniture.
# "root" = images placed directly in data/images/ (the village drawing-book scans).
CROP_FRACTIONS: dict[str, tuple[float, float, float, float]] = {
    "root": (0.07, 0.06, 0.02, 0.02),  # trim header text + signature/date footer
}


def _source_of(path: Path) -> str:
    rel = path.relative_to(SRC)
    return rel.parts[0] if len(rel.parts) > 1 else "root"


def _frac_crop(img: Image.Image, fracs: tuple[float, float, float, float]) -> Image.Image:
    t, b, l, r = fracs
    w, h = img.size
    box = (int(w * l), int(h * t), int(w * (1 - r)), int(h * (1 - b)))
    return img.crop(box) if box[2] > box[0] and box[3] > box[1] else img


def _autotrim(img: Image.Image, tol: int = 18) -> Image.Image:
    """Trim a near-uniform border using the top-left pixel as the background."""
    bg = Image.new("RGB", img.size, img.getpixel((0, 0)))
    diff = ImageChops.difference(img, bg).convert("L").point(lambda p: 255 if p > tol else 0)
    bbox = diff.getbbox()
    return img.crop(bbox) if bbox else img


def _resize(img: Image.Image, max_size: int) -> Image.Image:
    w, h = img.size
    scale = max_size / max(w, h)
    if scale < 1:
        img = img.resize((max(1, int(w * scale)), max(1, int(h * scale))), Image.LANCZOS)
    return img


def _ahash(img: Image.Image) -> int:
    """64-bit average hash for near-duplicate detection (no extra deps)."""
    small = img.convert("L").resize((8, 8), Image.LANCZOS)
    px = list(small.getdata())
    avg = sum(px) / len(px)
    bits = 0
    for i, p in enumerate(px):
        if p >= avg:
            bits |= 1 << i
    return bits


def _hamming(a: int, b: int) -> int:
    return bin(a ^ b).count("1")


def process(max_size: int, dedupe_distance: int) -> None:
    exts = {".png", ".jpg", ".jpeg", ".webp"}
    paths = sorted(p for p in SRC.rglob("*") if p.suffix.lower() in exts)
    if not paths:
        raise SystemExit(f"No images under {SRC}")

    DST.mkdir(parents=True, exist_ok=True)
    hashes: list[int] = []
    manifest, kept, dups, errors = [], 0, 0, 0

    for p in paths:
        source = _source_of(p)
        try:
            img = Image.open(p).convert("RGB")
            img = _frac_crop(img, CROP_FRACTIONS.get(source, (0, 0, 0, 0)))
            img = _autotrim(img)
            img = _resize(img, max_size)
        except Exception as e:  # noqa: BLE001
            print(f"  skip {p.name}: {e}")
            errors += 1
            continue

        h = _ahash(img)
        if any(_hamming(h, prev) <= dedupe_distance for prev in hashes):
            dups += 1
            continue
        hashes.append(h)

        out_dir = DST / source
        out_dir.mkdir(parents=True, exist_ok=True)
        out_path = out_dir / f"{p.stem}.jpg"
        img.save(out_path, "JPEG", quality=90)
        manifest.append({"processed": str(out_path.relative_to(ROOT)),
                         "source_image": str(p.relative_to(ROOT)), "source": source})
        kept += 1

    (DST / "manifest.json").write_text(
        json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8"
    )
    print(f"\nProcessed {len(paths)} → kept {kept}, dropped {dups} dups, {errors} errors")
    by_src: dict[str, int] = {}
    for m in manifest:
        by_src[m["source"]] = by_src.get(m["source"], 0) + 1
    for s, c in sorted(by_src.items()):
        print(f"  {s}: {c}")
    print(f"Output: {DST}  (+ manifest.json)")


def main() -> None:
    ap = argparse.ArgumentParser()
    ap.add_argument("--max-size", type=int, default=1024)
    ap.add_argument("--dedupe-distance", type=int, default=4,
                    help="max aHash Hamming distance to treat as duplicate (0=exact)")
    args = ap.parse_args()
    process(args.max_size, args.dedupe_distance)


if __name__ == "__main__":
    main()