Spaces:
Running on Zero
Running on Zero
| """ | |
| Tests for WP-2: densitometry module. | |
| Covers: | |
| - sRGB round-trip identity | |
| - PiecewiseFilmCurve.inverse() round-trip error < 0.01 D | |
| - scan_to_density smoke test | |
| - density_to_h_total: confidence mask values | |
| - Fixture validation: Ĥ_total matches GT h_total within 5% median relative | |
| error on the VALID mask, up to one global scale | |
| """ | |
| from __future__ import annotations | |
| import math | |
| from pathlib import Path | |
| import numpy as np | |
| import pytest | |
| from densitometry import ( | |
| TOE, | |
| VALID, | |
| SHOULDER, | |
| srgb_to_linear, | |
| linear_to_srgb, | |
| scan_to_density, | |
| density_to_h_total, | |
| scan_to_density_rgb, | |
| density_to_h_total_rgb, | |
| combine_confidence_rgb, | |
| ) | |
| from film_physics import get_film_curve | |
| # --------------------------------------------------------------------------- | |
| # Fixtures | |
| # --------------------------------------------------------------------------- | |
| FIXTURES_DIR = Path(__file__).parent.parent / "synth" / "fixtures" | |
| def generic_curve(): | |
| return get_film_curve("Generic") | |
| def all_fixtures(): | |
| paths = sorted(FIXTURES_DIR.glob("case_*.npz")) | |
| if not paths: | |
| pytest.skip("No fixture cases found — run WP-1 generator first") | |
| cases = [] | |
| for p in paths: | |
| d = np.load(p, allow_pickle=False) | |
| cases.append({ | |
| "scan": d["scan"], | |
| "h_total": d["h_total"], | |
| "stock": str(d["stock"]), | |
| }) | |
| return cases | |
| # --------------------------------------------------------------------------- | |
| # sRGB round-trip | |
| # --------------------------------------------------------------------------- | |
| class TestSRGBRoundtrip: | |
| def test_linear_to_srgb_to_linear(self): | |
| rng = np.random.default_rng(0) | |
| x = rng.uniform(0.0, 1.0, (64, 64, 3)).astype(np.float32) | |
| recovered = srgb_to_linear(linear_to_srgb(x)) | |
| np.testing.assert_allclose(recovered, x, atol=1e-5) | |
| def test_srgb_to_linear_to_srgb(self): | |
| rng = np.random.default_rng(1) | |
| x = rng.uniform(0.0, 1.0, (64, 64, 3)).astype(np.float32) | |
| recovered = linear_to_srgb(srgb_to_linear(x)) | |
| np.testing.assert_allclose(recovered, x, atol=1e-5) | |
| def test_boundary_values(self): | |
| # Endpoints must be exact | |
| x = np.array([[[0.0, 0.0, 0.0], [1.0, 1.0, 1.0]]], dtype=np.float32) | |
| lin = srgb_to_linear(x) | |
| assert float(lin[0, 0, 0]) == pytest.approx(0.0, abs=1e-7) | |
| assert float(lin[0, 1, 0]) == pytest.approx(1.0, abs=1e-7) | |
| # --------------------------------------------------------------------------- | |
| # PiecewiseFilmCurve.inverse() round-trip | |
| # --------------------------------------------------------------------------- | |
| class TestCurveInverse: | |
| def test_roundtrip_below_threshold(self, generic_curve): | |
| d_min = float(generic_curve.d_min) | |
| d_max = float(generic_curve.d_max) | |
| # Sample D values strictly inside the valid range | |
| d_test = np.linspace(d_min + 0.05, d_max - 0.05, 500).astype(np.float32) | |
| log_h = generic_curve.inverse(d_test) | |
| import torch | |
| with torch.no_grad(): | |
| d_recovered = generic_curve.forward(torch.from_numpy(log_h)).numpy() | |
| max_err = float(np.max(np.abs(d_recovered - d_test))) | |
| assert max_err < 0.01, f"Max round-trip error {max_err:.5f} D ≥ 0.01" | |
| def test_inverse_monotone(self, generic_curve): | |
| d_min = float(generic_curve.d_min) | |
| d_max = float(generic_curve.d_max) | |
| d_vals = np.linspace(d_min + 0.01, d_max - 0.01, 200) | |
| log_h = generic_curve.inverse(d_vals) | |
| diffs = np.diff(log_h) | |
| assert np.all(diffs >= -1e-6), "inverse() must be monotone non-decreasing" | |
| def test_all_stocks(self): | |
| from film_physics import list_film_stocks | |
| for stock in list_film_stocks(): | |
| curve = get_film_curve(stock) | |
| d_min = float(curve.d_min) | |
| d_max = float(curve.d_max) | |
| d_test = np.linspace(d_min + 0.05, d_max - 0.05, 200).astype(np.float32) | |
| log_h = curve.inverse(d_test) | |
| import torch | |
| with torch.no_grad(): | |
| d_back = curve.forward(torch.from_numpy(log_h)).numpy() | |
| err = float(np.max(np.abs(d_back - d_test))) | |
| assert err < 0.01, f"{stock}: round-trip error {err:.5f} D ≥ 0.01" | |
| def test_2d_input_shape(self, generic_curve): | |
| d_2d = np.full((8, 8), 0.5, dtype=np.float32) | |
| log_h = generic_curve.inverse(d_2d) | |
| assert log_h.shape == (8, 8) | |
| # --------------------------------------------------------------------------- | |
| # scan_to_density smoke tests | |
| # --------------------------------------------------------------------------- | |
| class TestScanToDensity: | |
| def test_output_shapes(self, generic_curve): | |
| rng = np.random.default_rng(42) | |
| scan = rng.uniform(0.4, 0.9, (32, 32, 3)).astype(np.float32) | |
| d, lin = scan_to_density(scan, stock="Generic") | |
| assert d.shape == (32, 32) | |
| assert lin.shape == (32, 32, 3) | |
| def test_density_nonnegative(self): | |
| rng = np.random.default_rng(7) | |
| scan = rng.uniform(0.3, 1.0, (32, 32, 3)).astype(np.float32) | |
| d, _ = scan_to_density(scan) | |
| assert float(d.min()) >= -1e-4, "Density should be non-negative" | |
| def test_white_level_override(self, generic_curve): | |
| rng = np.random.default_rng(3) | |
| scan = rng.uniform(0.5, 0.9, (32, 32, 3)).astype(np.float32) | |
| d1, _ = scan_to_density(scan) | |
| d2, _ = scan_to_density(scan, white_level=float(np.percentile(scan.mean(-1), 99.5))) | |
| # Both should be finite | |
| assert np.all(np.isfinite(d1)) | |
| assert np.all(np.isfinite(d2)) | |
| def test_synthetic_roundtrip(self, generic_curve): | |
| """Forward-model scan → scan_to_density should recover D close to original.""" | |
| from densitometry import linear_to_srgb as l2s, srgb_to_linear as s2l | |
| rng = np.random.default_rng(99) | |
| # Build a simple test: constant H_total at midtone | |
| h = np.full((32, 32), 0.4, dtype=np.float32) | |
| import torch | |
| log_h = np.log10(h) | |
| with torch.no_grad(): | |
| d_gt = generic_curve.forward(torch.from_numpy(log_h)).numpy() | |
| t = np.power(10.0, -d_gt) | |
| scan_gray = l2s(t) | |
| scan_rgb = np.stack([scan_gray] * 3, axis=-1) | |
| d_rec, _ = scan_to_density(scan_rgb, stock="Generic") | |
| # Allow for white-point estimation uncertainty (< 0.05 D) | |
| err = float(np.median(np.abs(d_rec - d_gt))) | |
| assert err < 0.05, f"Median density recovery error {err:.4f} ≥ 0.05" | |
| # --------------------------------------------------------------------------- | |
| # density_to_h_total — confidence mask | |
| # --------------------------------------------------------------------------- | |
| class TestDensityToHTotalMask: | |
| def test_mask_values_valid(self, generic_curve): | |
| d_min = float(generic_curve.d_min) | |
| d_max = float(generic_curve.d_max) | |
| mid = (d_min + d_max) / 2.0 | |
| d_arr = np.full((10, 10), mid, dtype=np.float32) | |
| _, mask = density_to_h_total(d_arr, generic_curve) | |
| assert np.all(mask == VALID) | |
| def test_mask_values_toe(self, generic_curve): | |
| d_min = float(generic_curve.d_min) | |
| d_arr = np.full((10, 10), d_min, dtype=np.float32) | |
| _, mask = density_to_h_total(d_arr, generic_curve) | |
| assert np.all(mask == TOE) | |
| def test_mask_values_shoulder(self, generic_curve): | |
| d_max = float(generic_curve.d_max) | |
| d_arr = np.full((10, 10), d_max, dtype=np.float32) | |
| _, mask = density_to_h_total(d_arr, generic_curve) | |
| assert np.all(mask == SHOULDER) | |
| def test_h_total_positive(self, generic_curve): | |
| d_min = float(generic_curve.d_min) | |
| d_max = float(generic_curve.d_max) | |
| d_arr = np.linspace(d_min, d_max, 100).reshape(10, 10).astype(np.float32) | |
| h, _ = density_to_h_total(d_arr, generic_curve) | |
| assert np.all(h > 0), "H_total must be strictly positive" | |
| def test_h_total_monotone_in_density(self, generic_curve): | |
| d_min = float(generic_curve.d_min) | |
| d_max = float(generic_curve.d_max) | |
| d_arr = np.linspace(d_min + 0.01, d_max - 0.01, 100).astype(np.float32) | |
| h, _ = density_to_h_total(d_arr, generic_curve) | |
| diffs = np.diff(h) | |
| assert np.all(diffs >= -1e-6), "H_total must increase monotonically with D" | |
| # --------------------------------------------------------------------------- | |
| # Fixture validation: 5% median relative error on VALID mask (up to one scale) | |
| # --------------------------------------------------------------------------- | |
| class TestFixtureValidation: | |
| def test_h_total_recovery_error(self, all_fixtures): | |
| """ | |
| Ĥ_total from the densitometry pipeline must match GT h_total within 5% | |
| median relative error on the VALID mask pixels, up to one global scale. | |
| """ | |
| errors_per_case: list[float] = [] | |
| for case in all_fixtures: | |
| scan = case["scan"] # (H, W, 3) float32 sRGB | |
| h_gt = case["h_total"] # (H, W) float32 ground-truth linear exposure | |
| stock = case["stock"] | |
| curve = get_film_curve(stock) | |
| d_physical, _ = scan_to_density(scan, stock=stock) | |
| h_rec, conf_mask = density_to_h_total(d_physical, curve) | |
| # Restrict to VALID pixels only | |
| valid = conf_mask == VALID | |
| if valid.sum() < 10: | |
| continue # Skip degenerate cases (K=1 with nearly all toe/shoulder) | |
| h_gt_v = h_gt[valid].astype(np.float64) | |
| h_rec_v = h_rec[valid].astype(np.float64) | |
| # Optimal scale (least-squares): g = (h_rec · h_gt) / ||h_rec||² | |
| denom = float(np.dot(h_rec_v, h_rec_v)) | |
| if denom < 1e-12: | |
| continue | |
| g = float(np.dot(h_rec_v, h_gt_v)) / denom | |
| h_scaled = g * h_rec_v | |
| rel_err = np.abs(h_scaled - h_gt_v) / (h_gt_v + 1e-8) | |
| median_err = float(np.median(rel_err)) | |
| errors_per_case.append(median_err) | |
| assert errors_per_case, "No cases had enough VALID pixels to evaluate" | |
| overall_median = float(np.median(errors_per_case)) | |
| assert overall_median < 0.05, ( | |
| f"Median relative Ĥ_total error {overall_median:.4f} ≥ 0.05 " | |
| f"(individual case medians: {[f'{e:.4f}' for e in errors_per_case]})" | |
| ) | |
| class TestAppPathDensitometry: | |
| """Regression: densitometry must survive the app preprocessing path. | |
| preprocess_negative once fed densitometry a contrast-stretched (and | |
| possibly display-inverted) rgb, destroying density information (~90% | |
| H_total error) while the isolated densitometry tests kept passing. | |
| The pipeline must run on the pristine scan. | |
| """ | |
| def test_h_total_accuracy_via_preprocess_negative(self): | |
| import numpy as np | |
| from PIL import Image | |
| from synth.generate import generate_case | |
| from densitometry import VALID | |
| from app.preprocessing import preprocess_negative | |
| errs = [] | |
| for seed in (3, 11, 42): | |
| case = generate_case(seed=seed, ratio=2.5, size=128) | |
| pil = Image.fromarray((case["scan"] * 255).astype(np.uint8)) | |
| pre = preprocess_negative(pil, stock=case["stock"]) | |
| assert pre.h_total is not None, "densitometry fields not populated" | |
| m = pre.confidence_mask == VALID | |
| assert m.any() | |
| a, b = pre.h_total[m], case["h_total"][m] | |
| g = float(np.sum(a * b) / (np.sum(a * a) + 1e-12)) | |
| errs.append(float(np.median(np.abs(g * a - b) / (np.abs(b) + 1e-9)))) | |
| # 8-bit PIL round-trip adds ~1-2% on top of the direct-path ~2% | |
| assert max(errs) < 0.10, f"app-path H_total errors too high: {errs}" | |
| class TestColorDensitometryRGB: | |
| """WP-8: rgb densitometry functions (additive; loop scalar).""" | |
| def test_scan_to_density_rgb_shape_and_nonneg(self): | |
| rng = np.random.default_rng(0) | |
| scan = rng.uniform(0.1, 0.9, (32, 32, 3)).astype(np.float32) | |
| d_rgb = scan_to_density_rgb(scan, "Portra 400 (C-41 color)") | |
| assert d_rgb.shape == (32, 32, 3) | |
| assert (d_rgb >= 0).all() | |
| def test_density_to_h_total_rgb_loops_scalar(self, generic_curve): | |
| # Use scalar curve for all channels to check loop | |
| from film_physics import ColorNegativeCurves | |
| d_rgb = np.full((8, 8, 3), 0.5, dtype=np.float32) | |
| curves = ColorNegativeCurves(generic_curve, generic_curve, generic_curve, (0.,0.,0.)) | |
| h_rgb, conf_rgb = density_to_h_total_rgb(d_rgb, curves) | |
| assert h_rgb.shape == (8, 8, 3) | |
| assert conf_rgb.shape == (8, 8, 3) | |
| # All channels should give identical since same curve | |
| assert np.allclose(h_rgb[..., 0], h_rgb[..., 1]) | |
| assert np.allclose(h_rgb[..., 1], h_rgb[..., 2]) | |
| def test_combine_confidence_rgb_truth_table(self): | |
| mask = np.zeros((2, 2, 3), dtype=np.uint8) | |
| mask[0, 0] = [0, 1, 1] # has TOE | |
| mask[0, 1] = [1, 1, 1] | |
| mask[1, 0] = [1, 1, 2] # has SHOULDER | |
| mask[1, 1] = [1, 1, 1] | |
| out = combine_confidence_rgb(mask) | |
| assert out[0, 0] == TOE | |
| assert out[1, 0] == SHOULDER | |
| assert out[1, 1] == VALID | |
| def test_color_fixtures_roundtrip_and_mask_offset(self): | |
| """Rebuilt per WP-8.1 Fix 2: per-ch median rel Ĥ err (one global scale per ch) on VALID, | |
| bars r,g<=0.05 b<=0.12, VALID frac >=0.5 per ch, on both committed fixtures. | |
| """ | |
| from pathlib import Path | |
| from synth.generate import load_case, generate_case | |
| from densitometry import scan_to_density_rgb, density_to_h_total_rgb, VALID | |
| from film_physics import get_color_curves | |
| fixtures_dir = Path(__file__).parent.parent / "synth" / "fixtures" | |
| for seed in [0, 1]: | |
| # load scan from committed fixture | |
| case_fx = load_case(fixtures_dir / f"color_case_{seed:03d}.npz") | |
| scan = case_fx["scan"] | |
| stock = "Portra 400 (C-41 color)" | |
| d_rgb = scan_to_density_rgb(scan, stock) | |
| curves = get_color_curves(stock) | |
| h_from, conf = density_to_h_total_rgb(d_rgb, curves) | |
| # independent ref h_gt: re-generate case with same seed (deterministic) | |
| case_ref = generate_case(seed=seed, stock=stock, ratio=2.0, size=64, halation=False, jpeg=False) | |
| h_gt = case_ref["h_total_rgb"] | |
| # per ch, find VALID, fit one global scale, compute median rel err | |
| for c, ch_name in enumerate(["r", "g", "b"]): | |
| m = (conf[..., c] == VALID) | |
| if not m.any(): | |
| continue | |
| a = h_from[..., c][m] | |
| b = h_gt[..., c][m] | |
| g = float(np.sum(a * b) / (np.sum(a * a) + 1e-12)) # one global scale | |
| err = float(np.median(np.abs(g * a - b) / (np.abs(b) + 1e-9))) | |
| vfrac = float(m.mean()) | |
| if ch_name == "r" or ch_name == "g": | |
| assert err <= 0.05, f"{ch_name} err {err} > 0.05 on seed {seed}" | |
| else: | |
| assert err <= 0.12, f"{ch_name} err {err} > 0.12 on seed {seed}" | |
| assert vfrac >= 0.5, f"{ch_name} VALID frac {vfrac} < 0.5 on seed {seed}" | |
| def test_gray_input_equivalence(self): | |
| """Gray-input equivalence (spec test 2, real): mid-gray (0.5); two INDEPENDENT computations. | |
| color path green (via curves=) vs scalar B&W path; atol=1e-5. | |
| """ | |
| from film_physics import FilmCurveParams, PiecewiseFilmCurve, ColorNegativeCurves | |
| p = FilmCurveParams() # identical for r g b | |
| curve = PiecewiseFilmCurve(p) | |
| curves = ColorNegativeCurves(curve, curve, curve, (0., 0., 0.)) | |
| gray = np.full((16, 16, 3), 0.5, dtype=np.float32) | |
| # independent color path using curves kwarg (bypasses stock lookup) | |
| d_green_from_color = scan_to_density_rgb(gray, "Portra 400 (C-41 color)", curves=curves)[..., 1] | |
| # independent scalar B&W path | |
| d_scalar = scan_to_density(gray, stock="Generic")[0] | |
| assert np.allclose(d_green_from_color, d_scalar, atol=1e-5) | |
| def test_app_level_color(self): | |
| """App-level color test (spec test 5): goes through process_negative (UI entry) itself.""" | |
| from PIL import Image | |
| from synth.generate import generate_case | |
| from app.main import process_negative | |
| case = generate_case(seed=0, stock="Portra 400 (C-41 color)", size=64) | |
| pil = Image.fromarray((case["scan"] * 255).astype(np.uint8)) | |
| # call UI entry point with color stock | |
| ret = process_negative(pil, film_stock="Portra 400 (C-41 color)", physics_weight=1.0, perceptual_weight=0.5, num_candidates=3) | |
| best_state = ret[5] # from the return tuple | |
| assert best_state.get("is_color") is True | |
| # also the preprocessed path inside would have set it | |
| class TestSlopeMask: | |
| """Reliability-push mask fix: slope-based VALID band (app opt-in).""" | |
| def test_default_unchanged_and_slope_narrows(self): | |
| import numpy as np | |
| from film_physics import get_film_curve | |
| from densitometry import density_to_h_total, _slope_valid_bounds, VALID | |
| curve = get_film_curve("Portra 400") | |
| d = np.linspace(float(curve.d_min), float(curve.d_max), 512).astype(np.float32).reshape(16, 32) | |
| h_def, m_def = density_to_h_total(d, curve) | |
| h_exp, m_exp = density_to_h_total(d, curve, mask_mode="density_margin") | |
| assert np.array_equal(m_def, m_exp) and np.allclose(h_def, h_exp) | |
| _h, m_slope = density_to_h_total(d, curve, mask_mode="slope") | |
| # slope-VALID is a strict subset of margin-VALID on this sweep | |
| assert np.all((m_slope == VALID) <= (m_def == VALID)) | |
| assert (m_slope == VALID).sum() < (m_def == VALID).sum() | |
| # Independent bound check: the H-span of the slope band is far narrower. | |
| d_lo, d_hi = _slope_valid_bounds(curve) | |
| h_lo = 10 ** float(curve.inverse(np.array([d_lo], np.float32))[0]) | |
| h_hi = 10 ** float(curve.inverse(np.array([d_hi], np.float32))[0]) | |
| margin = 0.05 * (float(curve.d_max) - float(curve.d_min)) | |
| hm_lo = 10 ** float(curve.inverse(np.array([float(curve.d_min) + margin], np.float32))[0]) | |
| hm_hi = 10 ** float(curve.inverse(np.array([float(curve.d_max) - margin], np.float32))[0]) | |
| stops_slope = np.log2(h_hi / h_lo) | |
| stops_margin = np.log2(hm_hi / hm_lo) | |
| assert stops_slope < 8.0 < stops_margin # measured: 6.0 vs 11.1 on Portra | |