double-exposure / tests /test_intake.py
Eddie Faillace
double-exposure app deploy snapshot 2026-07-21 (WP-24 calibration pass)
7dff04f
Raw
History Blame Contribute Delete
16.5 kB
"""WP-11 real-scan intake tests (Fixes A–D).
Every assert compares two independently computed quantities.
Teeth constructions prove the naive/old path fails the bar.
"""
from __future__ import annotations
import io
import warnings
import numpy as np
import pytest
from PIL import Image, ImageOps
from app.preprocessing import (
INTAKE_MAX_MEGAPIXELS,
_detect_negative_inversion,
_to_float_rgb,
preprocess_negative,
trim_uniform_border,
)
def _solid_pil(val: float, size: int = 32) -> Image.Image:
"""Solid gray RGB PIL image with luminance ≈ val (sRGB display value)."""
arr = np.full((size, size, 3), int(np.clip(val, 0, 1) * 255), dtype=np.uint8)
return Image.fromarray(arr, mode="RGB")
# ---------------------------------------------------------------------------
# Fix A — loader (16-bit + EXIF + size guard)
# ---------------------------------------------------------------------------
def test_i16_preserves_precision_beyond_8bit_teeth():
"""I;16 mid-gray levels that collapse under convert('RGB')/255 stay distinct.
Independent: high-bit loader values vs 8-bit-truncated naive values.
Teeth: old convert("RGB") path fails to distinguish the levels.
"""
# Two mid-gray levels that map to the same 8-bit code after /255 truncation
# 32768 and 32895 differ by 127 in 16-bit; after //256 both become 128 → same 8-bit
v1, v2 = 32768, 32895
assert (v1 // 256) == (v2 // 256), "setup: levels must collapse under 8-bit"
arr1 = np.full((8, 8), v1, dtype=np.uint16)
arr2 = np.full((8, 8), v2, dtype=np.uint16)
img1 = Image.fromarray(arr1, mode="I;16")
img2 = Image.fromarray(arr2, mode="I;16")
# Independent high-bit load
hi1 = float(_to_float_rgb(img1).mean())
hi2 = float(_to_float_rgb(img2).mean())
# Independent naive 8-bit path (the old convert("RGB")/255)
naive1 = float(np.asarray(img1.convert("RGB"), dtype=np.float32).mean() / 255.0)
naive2 = float(np.asarray(img2.convert("RGB"), dtype=np.float32).mean() / 255.0)
# Teeth: naive collapses
assert abs(naive1 - naive2) < 1e-6, "teeth: old convert('RGB') must collapse levels"
# High-bit path preserves distinction
assert abs(hi1 - hi2) > 1e-4, f"16-bit path must distinguish levels (Δ={abs(hi1-hi2):.6f})"
# Sanity: values near 0.5
assert 0.4 < hi1 < 0.6 and 0.4 < hi2 < 0.6
def test_exif_orientation_transposes_teeth():
"""EXIF orientation tag rotates content; without transpose, layout differs.
Independent: content after exif_transpose vs raw array layout.
Teeth: skipping exif_transpose yields a different spatial arrangement.
"""
# Build a 2×4 image with unique pixel sequence so orientation is observable
# Row-major: [[0,1,2,3],[4,5,6,7]] → after rotate-90-CW via EXIF tag 6 → 4×2
raw = np.arange(8, dtype=np.uint8).reshape(2, 4)
rgb = np.stack([raw, raw, raw], axis=-1)
img = Image.fromarray(rgb, mode="RGB")
# Attach EXIF Orientation=6 (rotate 90 CW)
# Minimal EXIF with Orientation tag
exif = img.getexif()
exif[274] = 6 # Orientation
buf = io.BytesIO()
img.save(buf, format="JPEG", exif=exif.tobytes(), quality=95)
buf.seek(0)
loaded = Image.open(buf)
# Independent: apply transpose (as preprocess does) vs not
transposed = ImageOps.exif_transpose(loaded)
arr_with = np.asarray(transposed.convert("RGB"))
arr_without = np.asarray(loaded.convert("RGB")) # no transpose
# Teeth: without transpose shape/content differs from transposed
assert arr_with.shape != arr_without.shape or not np.array_equal(arr_with, arr_without), (
"teeth: without exif_transpose the image must differ"
)
# With transpose: orientation applied → 4×2
assert arr_with.shape[0] == 4 and arr_with.shape[1] == 2, (
f"expected 4×2 after orientation-6, got {arr_with.shape}"
)
def test_size_guard_downscales_over_50mp():
"""Uploads beyond INTAKE_MAX_MEGAPIXELS are downscaled (with warning)."""
# Simulate a large logical size without allocating 50MP of pixels:
# monkey the size check via a real modest image whose .size we override
# by resizing a small image UP then relying on the guard.
# Allocate ~2.5 MP and temporarily lower the constant via a unit that
# still exercises the path: create 3000×3000 (~9 MP) and lower?
# Spec: beyond 50 MP. Creating 50MP is heavy (~150 MB RGB). Use a
# synthetic path that triggers _guard_intake_size with a temporarily
# patched constant.
from app import preprocessing as prep
small = Image.fromarray(np.zeros((100, 100, 3), dtype=np.uint8), mode="RGB")
# Temporarily set a tiny cap so 100×100 exceeds it
old = prep.INTAKE_MAX_MEGAPIXELS
try:
prep.INTAKE_MAX_MEGAPIXELS = 0.005 # 0.005 MP = 5000 px; 100×100=10000 > cap
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
out = prep._guard_intake_size(small)
assert out.size[0] * out.size[1] < 100 * 100
assert any("intake cap" in str(x.message) for x in w)
finally:
prep.INTAKE_MAX_MEGAPIXELS = old
def test_8bit_path_byte_identical_to_legacy():
"""Ordinary RGB load must match convert('RGB')/255 exactly (synthetic path)."""
rng = np.random.default_rng(0)
arr = rng.integers(0, 256, (32, 32, 3), dtype=np.uint8)
img = Image.fromarray(arr, mode="RGB")
legacy = np.asarray(img.convert("RGB"), dtype=np.float32) / 255.0
modern = _to_float_rgb(img)
assert np.array_equal(legacy, modern)
# ---------------------------------------------------------------------------
# Fix B — inversion detection + scan_type override
# ---------------------------------------------------------------------------
def test_two_stat_heuristic_bright_negative():
"""Synthetic bright negative (high mean AND median) → detected as negative."""
# Independent construction: solid bright field
lum = np.full((64, 64), 0.70, dtype=np.float32)
assert float(lum.mean()) > 0.55 and float(np.median(lum)) > 0.50
assert _detect_negative_inversion(lum) is True
def test_two_stat_heuristic_normal_positive():
"""Normal-key positive (mid tones) → not inverted."""
lum = np.full((64, 64), 0.40, dtype=np.float32)
assert float(lum.mean()) <= 0.55 or float(np.median(lum)) <= 0.50
assert _detect_negative_inversion(lum) is False
def test_two_stat_rejects_bright_outlier_only():
"""Bright mean from outliers but median dim → not negative (two-stat)."""
lum = np.full((64, 64), 0.30, dtype=np.float32)
# Spike enough pixels so mean > 0.55 while median stays at 0.30
# 40% of pixels at 1.0: mean = 0.3*0.6 + 1.0*0.4 = 0.58
lum[:26, :] = 1.0
mean_l, med_l = float(lum.mean()), float(np.median(lum))
assert mean_l > 0.55 and med_l <= 0.50, f"setup mean={mean_l:.3f} med={med_l:.3f}"
# Independent: old single-stat would say True; new rule says False
old_single = mean_l > 0.55
new_two = _detect_negative_inversion(lum)
assert old_single is True
assert new_two is False
def test_scan_type_override_wins_over_heuristic():
"""scan_type='positive'/'negative' force outcome regardless of luminance.
Two independent constructions: a bright field (heuristic → negative) forced
positive, and a dark field (heuristic → positive) forced negative.
"""
bright = _solid_pil(0.80) # heuristic would invert
dark = _solid_pil(0.30) # heuristic would not invert
# Independent: auto outcomes
auto_bright = preprocess_negative(bright, scan_type="auto")
auto_dark = preprocess_negative(dark, scan_type="auto")
assert auto_bright.was_inverted is True
assert auto_dark.was_inverted is False
# Override wins
force_pos = preprocess_negative(bright, scan_type="positive")
force_neg = preprocess_negative(dark, scan_type="negative")
assert force_pos.was_inverted is False, "override positive must win over bright heuristic"
assert force_neg.was_inverted is True, "override negative must win over dark heuristic"
# ---------------------------------------------------------------------------
# Fix C — auto_exposed densitometry anchor (load-bearing)
# ---------------------------------------------------------------------------
def test_auto_exposed_anchor_beats_linear_on_brightness_shift():
"""On a brightness-shifted scan, auto_exposed density is closer to un-shifted truth.
Truth = density of the ORIGINAL un-shifted fixture (independent densitometry call).
Linear anchor on shifted scan is measurably wrong (teeth).
Auto_exposed on shifted is closer to truth than linear on shifted.
"""
from pathlib import Path
from densitometry import scan_to_density
from film_physics import get_film_curve
fix = sorted(Path("synth/fixtures").glob("case_*.npz"))[0]
data = np.load(fix)
scan = data["scan"].astype(np.float32) # un-shifted truth source
stock = str(data["stock"]) if "stock" in data else "Generic"
curve = get_film_curve(stock)
d_min = float(curve.d_min.item())
# Independent truth density on the un-shifted scan (linear path = synthetic truth)
d_truth, _ = scan_to_density(scan, stock=stock)
# Simulate auto-exposure shifting the white point (global brightness scale)
k = 0.55
shifted = np.clip(scan * k, 0.0, 1.0).astype(np.float32)
# Independent: linear anchor on shifted
d_linear, _ = scan_to_density(shifted, stock=stock)
# Independent: auto_exposed anchor on shifted
d_auto, _ = scan_to_density(shifted, stock=stock, d_min_override=d_min)
# Median absolute error vs truth (VALID-ish: use all finite pixels)
err_linear = float(np.median(np.abs(d_linear - d_truth)))
err_auto = float(np.median(np.abs(d_auto - d_truth)))
# Teeth: linear anchor is measurably wrong on the shifted scan
assert err_linear > 0.05, (
f"teeth: linear anchor must be wrong on brightness-shifted scan "
f"(err={err_linear:.4f})"
)
# Auto_exposed closer to un-shifted truth
assert err_auto < err_linear, (
f"auto_exposed err {err_auto:.4f} must beat linear err {err_linear:.4f}"
)
def test_linear_calibration_default_unchanged_on_fixture():
"""Default scan_calibration='linear' keeps densitometry path on fixtures."""
from pathlib import Path
fix = sorted(Path("synth/fixtures").glob("case_*.npz"))[0]
data = np.load(fix)
scan = (data["scan"] * 255).clip(0, 255).astype(np.uint8)
pil = Image.fromarray(scan)
# Default path (linear) must populate density
pre = preprocess_negative(pil, stock="Generic", scan_calibration="linear")
assert pre.density is not None
# auto_exposed also populates (different anchor)
pre_ae = preprocess_negative(pil, stock="Generic", scan_calibration="auto_exposed")
assert pre_ae.density is not None
# ---------------------------------------------------------------------------
# Fix D — uniform-border trim (opt-in, default OFF)
# ---------------------------------------------------------------------------
def test_trim_uniform_border_removes_black_border():
"""Image with black uniform border → trimmed to content bbox."""
# Content 20×20 gray at 0.5, surrounded by 10 px black border → 40×40 total
canvas = np.zeros((40, 40, 3), dtype=np.float32)
canvas[10:30, 10:30] = 0.5
cropped, bbox = trim_uniform_border(canvas, tol=1e-3, max_frac=0.25)
top, bottom, left, right = bbox
# Independent expected content region
assert top == 10 and left == 10 and bottom == 30 and right == 30, (
f"expected content bbox (10,30,10,30), got {bbox}"
)
assert cropped.shape[:2] == (20, 20)
assert float(cropped.mean()) > 0.4
def test_trim_uniform_border_identity_no_border():
"""No uniform border → returned unchanged (identity)."""
rng = np.random.default_rng(7)
content = rng.uniform(0.2, 0.8, (32, 32, 3)).astype(np.float32)
cropped, bbox = trim_uniform_border(content, tol=1e-3, max_frac=0.25)
# Independent: shape matches original; pixel values equal
assert cropped.shape == content.shape
assert bbox == (0, 32, 0, 32)
assert np.allclose(cropped, content)
def test_trim_max_frac_cap_never_empty():
"""All-uniform pathological input: max_frac cap respected, never empty."""
solid = np.zeros((40, 40, 3), dtype=np.float32)
cropped, bbox = trim_uniform_border(solid, tol=1e-3, max_frac=0.25)
top, bottom, left, right = bbox
# Independent: remaining region non-empty; each edge trim ≤ max_frac
assert cropped.size > 0
assert bottom > top and right > left
assert top <= int(40 * 0.25)
assert (40 - bottom) <= int(40 * 0.25)
assert left <= int(40 * 0.25)
assert (40 - right) <= int(40 * 0.25)
def test_auto_trim_default_off_preserves_size():
"""auto_trim=False (default) does not crop a bordered image."""
canvas = np.zeros((40, 40, 3), dtype=np.uint8)
canvas[10:30, 10:30] = 128
pil = Image.fromarray(canvas, mode="RGB")
pre_off = preprocess_negative(pil, auto_trim=False)
pre_on = preprocess_negative(pil, auto_trim=True)
# Independent: off keeps full size; on is smaller
assert pre_off.rgb.shape[0] == 40 and pre_off.rgb.shape[1] == 40
assert pre_on.rgb.shape[0] < 40 or pre_on.rgb.shape[1] < 40
# trim_bbox_frac contract: None when off, fractions of pre-trim size when on
assert pre_off.trim_bbox_frac is None
tf, bf, lf, rf = pre_on.trim_bbox_frac
# Independent expected fractions from the known 10-px border on 40 px
assert abs(tf - 10 / 40) < 1e-6 and abs(bf - 30 / 40) < 1e-6
assert abs(lf - 10 / 40) < 1e-6 and abs(rf - 30 / 40) < 1e-6
# ---------------------------------------------------------------------------
# WP-11 post-review — full-res export must match the intake pipeline geometry
# ---------------------------------------------------------------------------
def _fixture_scan_uint8() -> np.ndarray:
from pathlib import Path
fix = sorted(Path("synth/fixtures").glob("case_*.npz"))[0]
scan = np.load(fix)["scan"].astype(np.float32)
return (np.clip(scan, 0.0, 1.0) * 255).astype(np.uint8)
def test_fullres_export_follows_exif_orientation_teeth():
"""Full-res A/B must have the EXIF-transposed geometry, not the raw one.
The working images come from the exif_transpose'd pipeline (Fix A); the
full-res guide is built from the ORIGINAL upload. Independent expectation:
a (48h, 64w) raw with Orientation=6 displays as (64h, 48w), so full-res
output must be 48×64 (PIL w×h). Teeth: pre-fix the raw orientation leaked
through and the output was 64×48.
"""
from app.main import process_negative
scan8 = _fixture_scan_uint8()[:48, :64] # non-square: h=48, w=64
img = Image.fromarray(scan8, mode="RGB")
exif = img.getexif()
exif[274] = 6 # Orientation: rotate 90 CW on display
buf = io.BytesIO()
img.save(buf, format="JPEG", exif=exif.tobytes(), quality=95)
buf.seek(0)
upload = Image.open(buf)
out = process_negative(upload, "Generic", 1.0, 0.5, 3, False, True)
status, full_a = out[4], out[9]
assert "Full-res export failed" not in status
assert full_a is not None
# Independent: expected dims from raw dims + orientation-6 transpose rule
assert full_a.size == (48, 64), (
f"full-res must follow EXIF-transposed geometry (48w,64h), got {full_a.size}"
)
def test_fullres_export_uses_trimmed_region_teeth():
"""With auto_trim on, full-res must span the trimmed content, not the border.
Independent expectation: 64×64 content + 12 px black border = 88×88 upload;
trim recovers the 64×64 content, so full-res output must be 64×64.
Teeth: pre-fix the untrimmed original leaked through and output was 88×88.
"""
from app.main import process_negative
content = _fixture_scan_uint8() # 64×64
canvas = np.zeros((88, 88, 3), dtype=np.uint8)
canvas[12:76, 12:76] = content
upload = Image.fromarray(canvas, mode="RGB")
out = process_negative(
upload, "Generic", 1.0, 0.5, 3, False, True, auto_trim=True
)
status, full_a = out[4], out[9]
assert "Full-res export failed" not in status
assert full_a is not None
assert full_a.size == (64, 64), (
f"full-res must span the trimmed 64×64 content, got {full_a.size}"
)