"""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}" )