double-exposure / tests /test_densitometry.py
Eddie Faillace
double-exposure app deploy snapshot 2026-07-21 (WP-24 calibration pass)
7dff04f
Raw
History Blame Contribute Delete
18.7 kB
"""
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"
@pytest.fixture(scope="module")
def generic_curve():
return get_film_curve("Generic")
@pytest.fixture(scope="module")
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