Spaces:
Running on Zero
Running on Zero
| """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}" | |
| ) | |