M3: Face-aware OpenCV preprocessing and automatic importance maps (I4, I5)¶
Status: done on the 4 sample images; the dataset evaluation is part of M6 ·
Commit: da3f38e
Goal¶
Make the target and the weights carry the work, with no per-image tuning. Faces must come out recognizable.
What was built¶
| Module | Content |
|---|---|
models.py |
On-demand download of YuNet (0.23 MB ONNX) and LBF (56 MB) into models/ (gitignored). stringart fetch-models |
face.py |
detect_faces: YuNet box and 5 points (input downscaled to ≤ 800 px), plus LBF 68 landmarks. Missing model → [] (graceful fallback) |
preprocess.py |
prepare(): face-centred crop (side = 1.8 × face height, centre shifted down 0.15 h, clamped to the image) → resize → grayscale → auto level stretch → bilateral filter → CLAHE → optional GrabCut background fade → white outside the frame. Also returns plain (the unprocessed crop, used as the metric reference), the faces in canvas coordinates, and the crop box. PreprocessConfig.legacy() = the M1/M2 chain |
importance.py |
W = floor + (1−floor)·clip(1.0·face + 0.5·edges + 0.25·saliency). Face map from the 68 landmarks (oval 0.5; eyes, brows, nose, mouth 1.0, feathered) or the 5-point fallback. Edges = Scharr magnitude at its 99th percentile, with the frame rim excluded. Saliency = spectral residual. auto_weights: W only when a face is found |
metrics.py |
roi= → ssim_roi_sK, psnr_roi_sK (face oval) |
| CLI | --crop face/center, --face-zoom, --importance auto/on/off, --importance-floor, --background none/fade, --legacy-prep; reports metrics vs target and vs the plain photo |
Why no negative weights? LessWrong used ω⁻ to keep threads out of eye whites. Our error is symmetric, so a high weight on a light region already penalizes darkening it. The feature mask therefore protects eye whites and teeth automatically.
Results¶
From experiments/ablation_m3.py. Metrics are vs the plain photo of each variant's own
crop, because preprocessing changes the target itself. Greedy solver, pixel objective.
| image | variant | faces | lines | time s | SSIM σ2 | SSIM σ4 | PSNR σ2 | face SSIM σ2 | face PSNR σ2 |
|---|---|---|---|---|---|---|---|---|---|
| astronaut | legacy | 0 | 2774 | 3.1 | 0.517 | 0.681 | 16.54 | ||
| astronaut | prep | 1 | 2869 | 3.0 | 0.564 | 0.719 | 17.33 | 0.639 | 17.72 |
| astronaut | prep+imp | 1 | 2876 | 2.9 | 0.544 | 0.706 | 16.62 | 0.683 | 18.54 |
| astronaut | prep+imp+bg | 1 | 2392 | 2.1 | 0.521 | 0.681 | 15.89 | 0.672 | 18.30 |
| camera | legacy | 0 | 2476 | 2.3 | 0.593 | 0.770 | 17.22 | ||
| camera | prep | 1 | 3939 | 4.1 | 0.659 | 0.796 | 19.04 | 0.627 | 19.12 |
| camera | prep+imp | 1 | 3865 | 3.5 | 0.640 | 0.782 | 18.54 | 0.715 | 20.27 |
| camera | prep+imp+bg | 1 | 1358 | 1.2 | 0.402 | 0.544 | 8.24 | 0.634 | 15.07 |
| coffee | legacy | 0 | 3535 | 3.3 | 0.669 | 0.830 | 19.13 | ||
| coffee | prep | 0 | 3563 | 3.2 | 0.673 | 0.833 | 19.18 | ||
| coffee | prep+imp | 0 | 3500 | 3.1 | 0.639 | 0.814 | 18.59 | ||
| chelsea | legacy | 0 | 2448 | 2.4 | 0.672 | 0.870 | 20.98 | ||
| chelsea | prep | 0 | 2463 | 2.4 | 0.671 | 0.869 | 21.07 | ||
| chelsea | prep+imp | 0 | 2480 | 2.5 | 0.660 | 0.866 | 21.44 |
(prep+imp forces importance on, to show its effect. The default auto mode would use
uniform weights for coffee and chelsea. +bg is a no-op without a face.)
Tuning that led to the defaults, on astronaut with background fade:
| setting | lines | SSIM σ2 | face SSIM σ2 |
|---|---|---|---|
| face_zoom 2.6, floor 0.25 | 1471 | 0.516 | 0.636 |
| floor 0.1 | 1520 | 0.499 | 0.659 |
| floor 0.05, face weight 2 | 1593 | 0.492 | 0.693 |
| zoom 2.0, floor 0.1 | 1920 | 0.501 | 0.667 |
| zoom 1.7, floor 0.1 | 2290 | 0.526 | 0.677 |
| zoom 1.7, floor 0.1, opacity 0.15 | 3073 | 0.564 | 0.719 |
Findings¶
- The face-centred crop is the largest single visible improvement in the project so far. It adds +0.046 / +0.066 SSIM σ2 and +0.8 / +1.8 dB on the face images, and eyes, nose and mouth become legible. YuNet also found the cameraman's face in profile.
- Importance buys +0.045 / +0.088 face SSIM for about −0.02 global SSIM, a deliberate
trade. On face-less images the edge/saliency weights did not help (coffee −0.034), so the
default
automode applies W only when a face is found. - The level stretch hurt the well-exposed cat (−0.036 SSIM, −2.9 dB). Its p1–p99 range is 147 (20–167). The stretch is now auto, applied only when p99−p1 < 100, i.e. genuinely poor exposure. Caveat: fidelity-to-photo metrics penalize any tonal change by construction, so the stretch's value has to be judged on badly exposed inputs (M6).
- Bilateral vs Gaussian smoothing: no measurable difference. Bilateral is kept for edge preservation.
- GrabCut background fade lowers fidelity everywhere (aesthetic option only). On the cameraman it also faded his dark coat (8.2 dB). Seeding GrabCut from saliency on face-less images faded most of the subject (coffee and cat dropped to ~800 lines), so the fade now requires a face.
- Thinner thread (lower opacity) clearly helps faces: at opacity 0.15, SSIM σ2 rose from 0.526 to 0.564. Opacity is physical (thread width / pixel size), so this is a hardware recommendation rather than a default change.
- LBF landmarks are good on eyes, nose and mouth, but the jaw contour is loose. The oval is built from the convex hull of jaw and brows, so that error is tolerable.
- Bug caught by a test: the frame rim (photo next to white) registered as a strong edge and gave the rim importance. Fixed by eroding the mask before the edge map.
Revised in M6: the auto-stretch trigger is now narrow range (< 115) or no black point (p1 > 64) or no white point (p99 < 128). The range-only rule missed underexposed and washed-out inputs. See M6.
Tests¶
13 new tests (48 total):
- face and landmark sanity
- face crop centres the face
- missing models degrade to a centre crop
- crop clamping
- the legacy chain is bit-compatible with M2
- a flat image is stable (no NaNs, no rim edge)
- auto-stretch fires only for poor exposure
- auto_weights follows faces
- the fade needs a face
- 5-point face map
- importance range and focus
- ROI metrics
- CLI writes importance.png and both metric sets
Reproduce¶
uv run stringart fetch-models
uv run python experiments/ablation_m3.py
uv run stringart run sample:astronaut && uv run stringart viz outputs/astronaut_greedy --grid 300,1000,2000,3000