File size: 15,725 Bytes
fed954e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
311
312
313
# ─────────────────────────────────────────────────────────────────────────────
# face_age_filter.py — age-classification pre-filter (no face detection deps).
#
# This rewrite drops facenet-pytorch / MTCNN entirely — they pull torchvision
# which collides with Colab's current Pillow (the classic "_util.is_directory"
# ImportError). The project CLAUDE.md flags this exact failure mode.
#
# Strategy:
#   - The age classifier (HF nateraw/vit-age-classifier) runs on PIL images
#     directly. For datasets where the image IS a centered face (FFHQ) or
#     where face bbox coords are provided (IMDB has `rect` in its CSV), no
#     face detector is needed.
#   - For deepfashion (face position unknown, possibly cropped out) we'll add
#     a lightweight detector later — a separate concern.
#
# Threshold logic unchanged from the previous draft:
#   reject if expected age < 24  OR  P(0-2)+P(3-9)+P(10-19) > 0.20
#
# Paste this cell ONCE per Colab session, after super_dataset_lib.py.
# ─────────────────────────────────────────────────────────────────────────────


# ═════════════════════════════════════════════════════════════════════════════
# 1. ENSURE DEPS (no force-upgrades — Colab's stock transformers/torch/PIL
#    are kept untouched to avoid the torchvision↔Pillow ImportError chain
#    documented in the project CLAUDE.md).
# ═════════════════════════════════════════════════════════════════════════════

import importlib, subprocess, sys

def _ensure(pkg_spec: str, import_name: str | None = None):
    name = import_name or pkg_spec.split(">=")[0].split("==")[0].split("<")[0]
    try:
        importlib.import_module(name)
    except ImportError:
        print(f"  installing missing dep: {pkg_spec}")
        subprocess.check_call([sys.executable, "-m", "pip", "install", "-q", pkg_spec])

_ensure("transformers")
_ensure("torch")
print("face_age_filter deps OK (no force-upgrades).")


# ═════════════════════════════════════════════════════════════════════════════
# 2. IMPORTS  +  MODEL CONFIG
# ═════════════════════════════════════════════════════════════════════════════

from dataclasses import dataclass
from typing import Optional

import numpy as np
import torch
from PIL import Image as _PILImage
from transformers import AutoImageProcessor, AutoModelForImageClassification

AGE_MODEL_ID    = "nateraw/vit-age-classifier"
AGE_THRESHOLD   = 24.0
MINOR_MASS_MAX  = 0.20

# Device selection.
#   DEVICE_OVERRIDE = None   → auto-detect, GPU-test-then-fallback (default)
#   DEVICE_OVERRIDE = "cuda" → force GPU (ignore warnings)
#   DEVICE_OVERRIDE = "cpu"  → force CPU (~10× slower but always works)
#
# Auto-detect catches the case where the installed PyTorch's bundled CUDA
# kernels don't include your GPU's compute capability (e.g. stock Colab torch
# topping out at sm_90 vs an RTX 6000 Blackwell at sm_120). We detect by
# running a tiny model forward; if it crashes, fall back to CPU.
DEVICE_OVERRIDE = None


def _select_device() -> str:
    if DEVICE_OVERRIDE in ("cpu", "cuda"):
        return DEVICE_OVERRIDE
    if not torch.cuda.is_available():
        return "cpu"
    # Check that the GPU's capability is in torch's compiled-for list.
    try:
        cap_major, cap_minor = torch.cuda.get_device_capability(0)
        my_sm = f"sm_{cap_major}{cap_minor}"
        # Some torch builds expose get_arch_list, some don't.
        arch_list = getattr(torch.cuda, "get_arch_list", lambda: [])()
        # arch_list entries look like "sm_80" / "compute_80"; normalize.
        compiled_sm = {a.replace("compute_", "sm_") for a in arch_list}
        if compiled_sm and my_sm not in compiled_sm:
            print(f"  GPU is {my_sm} but PyTorch was compiled for {sorted(compiled_sm)}.")
            print(f"  Trying GPU anyway — if forward fails we'll fall back to CPU.")
    except Exception:
        pass
    return "cuda"


DEVICE = _select_device()

# Bucket → midpoint mapping. Multiplied by per-bucket probability to get a
# continuous expected age estimate.
AGE_BUCKETS = [
    ("0-2",         1.0),
    ("3-9",         6.0),
    ("10-19",      14.0),
    ("20-29",      24.0),
    ("30-39",      34.0),
    ("40-49",      44.0),
    ("50-59",      54.0),
    ("60-69",      64.0),
    ("more than 70", 75.0),
]
MINOR_BUCKETS = {"0-2", "3-9", "10-19"}


# ═════════════════════════════════════════════════════════════════════════════
# 3. MODEL LOAD  (singleton)
# ═════════════════════════════════════════════════════════════════════════════

print(f"Loading age classifier {AGE_MODEL_ID} ({DEVICE}) …")
# Fast (Rust-backed) image preprocessing is the default in current transformers;
# passing use_fast= now deprecation-warns, so we pass nothing.
_AGE_PROCESSOR = AutoImageProcessor.from_pretrained(AGE_MODEL_ID)
_AGE_MODEL     = AutoModelForImageClassification.from_pretrained(AGE_MODEL_ID).to(DEVICE).eval()

_MODEL_LABELS = [_AGE_MODEL.config.id2label[i] for i in range(_AGE_MODEL.config.num_labels)]
_LABEL_TO_MIDPOINT = dict(AGE_BUCKETS)

_missing = [lbl for lbl, _ in AGE_BUCKETS if lbl not in _MODEL_LABELS]
if _missing:
    print(f"  WARNING — model labels don't include AGE_BUCKETS entries: {_missing}")
    print(f"  model labels: {_MODEL_LABELS}")

# GPU smoke test: run a tiny zero-tensor forward to confirm the GPU kernels
# actually execute on this device. If PyTorch was compiled without our SM
# version (Blackwell sm_120 on stock Colab torch) this fails immediately
# rather than crashing mid-ingest.
if DEVICE == "cuda":
    try:
        with torch.no_grad():
            _test_in = torch.zeros(1, 3, 224, 224, device=DEVICE)
            _ = _AGE_MODEL(_test_in)
        print(f"  GPU smoke test passed.  VRAM: {torch.cuda.memory_allocated()/1024**3:.2f} GB")
    except RuntimeError as e:
        msg = str(e).splitlines()[0]
        print(f"  GPU smoke test FAILED ({msg!r}) — falling back to CPU.")
        DEVICE = "cpu"
        _AGE_MODEL = _AGE_MODEL.to(DEVICE)
        print(f"  age model relocated to CPU.")
else:
    print(f"  running on CPU (slower, but compatible).")


# ═════════════════════════════════════════════════════════════════════════════
# 4. RESULT TYPE
# ═════════════════════════════════════════════════════════════════════════════

@dataclass
class FaceCheckResult:
    """Outcome of running the age filter on ONE image."""
    decision: str                # "pass" | "fail"
    expected_age: float          # continuous age estimate
    minor_mass: float            # P(0-2)+P(3-9)+P(10-19)
    most_likely_bucket: str      # argmax bucket label
    most_likely_prob: float      # probability of argmax bucket
    reasons: list                # human-readable reasons for fail

    def to_audit(self) -> dict:
        return {
            "decision":          self.decision,
            "expected_age":      round(self.expected_age, 1),
            "minor_mass":        round(self.minor_mass, 3),
            "most_likely":       f"{self.most_likely_bucket} ({self.most_likely_prob:.2f})",
            "reasons":           self.reasons,
        }


# ═════════════════════════════════════════════════════════════════════════════
# 5. FaceAgeFilter — age-classifier-only variant
# ═════════════════════════════════════════════════════════════════════════════

class FaceAgeFilter:
    """Runs the age classifier over images (or pre-cropped face regions).

    Entry points:
      .check_one(pil, bbox=None)        — single image (optional face bbox crop)
      .check_batch(pils, bboxes=None)   — N images, batched on GPU

    `bbox` (if provided) is an (x1, y1, x2, y2) tuple in pixel coords —
    the image is cropped to that region before classification. Useful for
    IMDB where the CSV provides face bbox coords. For FFHQ leave bbox=None
    and the whole image is classified (each FFHQ image is a centered face crop).

    decision_mode controls how strict the reject rule is:
      "strict"   — fail if expected_age < age_threshold OR minor_mass > minor_mass_max
                   (catches every borderline; gives ~30-40% reject rate on FFHQ)
      "balanced" — fail only if most_likely bucket is a minor bucket OR minor_mass > 0.40
                   (single-bucket-argmax + relaxed mass; ~10-20% reject rate)
      "loose"    — fail only if most_likely bucket is a minor bucket
                   (most permissive; only rejects model-confident minors)
    """

    def __init__(self,
                 age_threshold: float = AGE_THRESHOLD,
                 minor_mass_max: float = MINOR_MASS_MAX,
                 decision_mode: str = "strict",       # "strict" | "balanced" | "loose"
                 batch_size: int = 32):
        assert decision_mode in ("strict", "balanced", "loose")
        self.age_threshold = age_threshold
        self.minor_mass_max = minor_mass_max
        self.decision_mode = decision_mode
        self.batch_size = batch_size

    # ── core ────────────────────────────────────────────────────────────────

    def _prep_one(self, img: _PILImage.Image,
                  bbox: Optional[tuple] = None) -> _PILImage.Image:
        if img.mode != "RGB":
            img = img.convert("RGB")
        if bbox is not None:
            x1, y1, x2, y2 = bbox
            W, H = img.size
            x1, y1 = max(0, int(x1)), max(0, int(y1))
            x2, y2 = min(W, int(x2)), min(H, int(y2))
            if x2 > x1 and y2 > y1:
                img = img.crop((x1, y1, x2, y2))
        return img

    def _classify_batch(self, crops: list) -> tuple:
        """Returns (expected_ages, minor_masses, most_likely_buckets, most_likely_probs)
        per crop. Each is a list aligned with `crops`."""
        if not crops:
            return [], [], [], []
        inputs = _AGE_PROCESSOR(images=crops, return_tensors="pt").to(DEVICE)
        with torch.no_grad():
            logits = _AGE_MODEL(**inputs).logits
        probs = torch.softmax(logits, dim=-1).cpu().numpy()
        expected_ages, minor_masses = [], []
        most_likely_buckets, most_likely_probs = [], []
        for row in probs:
            exp_age, minor_mass = 0.0, 0.0
            for i, label in enumerate(_MODEL_LABELS):
                p = float(row[i])
                exp_age += p * _LABEL_TO_MIDPOINT.get(label, 0.0)
                if label in MINOR_BUCKETS:
                    minor_mass += p
            expected_ages.append(exp_age)
            minor_masses.append(minor_mass)
            mli = int(row.argmax())
            most_likely_buckets.append(_MODEL_LABELS[mli])
            most_likely_probs.append(float(row[mli]))
        return expected_ages, minor_masses, most_likely_buckets, most_likely_probs

    def _decide(self, exp_age: float, minor_mass: float,
                most_likely_bucket: str, most_likely_prob: float) -> tuple:
        reasons = []
        mode = self.decision_mode
        if mode == "strict":
            if exp_age < self.age_threshold:
                reasons.append(f"expected_age={exp_age:.1f} < {self.age_threshold}")
            if minor_mass > self.minor_mass_max:
                reasons.append(f"minor_mass={minor_mass:.2f} > {self.minor_mass_max}")
        elif mode == "balanced":
            if most_likely_bucket in MINOR_BUCKETS:
                reasons.append(f"most_likely={most_likely_bucket} ({most_likely_prob:.2f}) is minor bucket")
            elif minor_mass > 0.40:
                reasons.append(f"minor_mass={minor_mass:.2f} > 0.40")
        elif mode == "loose":
            if most_likely_bucket in MINOR_BUCKETS:
                reasons.append(f"most_likely={most_likely_bucket} ({most_likely_prob:.2f}) is minor bucket")
        return (("fail", reasons) if reasons else ("pass", []))

    # ── public ──────────────────────────────────────────────────────────────

    def check_one(self, img: _PILImage.Image,
                  bbox: Optional[tuple] = None) -> FaceCheckResult:
        prepped = self._prep_one(img, bbox)
        ea, mm, mlb, mlp = self._classify_batch([prepped])
        decision, reasons = self._decide(ea[0], mm[0], mlb[0], mlp[0])
        return FaceCheckResult(
            decision=decision, expected_age=ea[0], minor_mass=mm[0],
            most_likely_bucket=mlb[0], most_likely_prob=mlp[0],
            reasons=reasons,
        )

    def check_batch(self, images: list,
                    bboxes: Optional[list] = None) -> list:
        """Process N images. `bboxes`, if given, must have same length as `images`
        (use None for items where no crop should happen)."""
        if not images:
            return []
        if bboxes is None:
            bboxes = [None] * len(images)
        assert len(bboxes) == len(images), "bboxes and images must align"

        prepped = [self._prep_one(im, bb) for im, bb in zip(images, bboxes)]

        all_exp, all_mm, all_mlb, all_mlp = [], [], [], []
        bs = self.batch_size
        for start in range(0, len(prepped), bs):
            ea, mm, mlb, mlp = self._classify_batch(prepped[start:start + bs])
            all_exp.extend(ea); all_mm.extend(mm)
            all_mlb.extend(mlb); all_mlp.extend(mlp)

        results = []
        for ea, mm, mlb, mlp in zip(all_exp, all_mm, all_mlb, all_mlp):
            decision, reasons = self._decide(ea, mm, mlb, mlp)
            results.append(FaceCheckResult(
                decision=decision, expected_age=ea, minor_mass=mm,
                most_likely_bucket=mlb, most_likely_prob=mlp,
                reasons=reasons,
            ))
        return results


print(f"face_age_filter loaded. threshold={AGE_THRESHOLD}, "
      f"minor_mass_max={MINOR_MASS_MAX}, batch={32}")