double-exposure / tests /test_scoring_wp13.py
Eddie Faillace
double-exposure app deploy snapshot 2026-07-21 (WP-24 calibration pass)
7dff04f
Raw
History Blame Contribute Delete
7.99 kB
"""WP-13 D3/D4: ranking reweight + flat-candidate guard (independent-reference asserts)."""
from __future__ import annotations
import numpy as np
import torch
import pytest
from densitometry import VALID
from film_physics import get_film_curve
from hybrid_loss import HybridFilmLoss
from app.api_client import SeparationResult
from app.scoring import rank_candidates, score_separation, RankingResult, _is_flat_pair
def _load_fixture0():
d = np.load("synth/fixtures/case_000.npz")
return d
def _hard_p50_split(rgb: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
"""Structured hard-threshold split (demo-style)."""
lum = 0.2126 * rgb[..., 0] + 0.7152 * rgb[..., 1] + 0.0722 * rgb[..., 2]
thr = float(np.percentile(lum, 50.0))
ma = (lum <= thr).astype(np.float32)[..., None]
mb = 1.0 - ma
a = (rgb * ma).astype(np.float32)
b = (rgb * mb).astype(np.float32)
return a, b
def _flat_pair(h: int, w: int) -> tuple[np.ndarray, np.ndarray]:
"""Solid-red + solid-white degenerate pair (the lab-data failure mode)."""
red = np.zeros((h, w, 3), dtype=np.float32)
red[..., 0] = 0.9
white = np.full((h, w, 3), 0.95, dtype=np.float32)
return red, white
class TestRankingIntegration:
"""Honest-fix assert: lab-warped fixture ranking (spec test 3)."""
def test_lab_warped_ranking_affine_beats_flat_and_guard(self):
"""Build lab-warped D_obs; flat must rank below structured under full new scoring;
with flat_guard the flat pair is rejected (count==1).
"""
data = _load_fixture0()
scan = data["scan"].astype(np.float32)
# Working size: fixture is already small (64×64)
h, w = scan.shape[:2]
curve = get_film_curve("Generic")
# Density from true fixture path when present; else synthesize via curve of scan lum
if "density" in data.files:
d_true = data["density"].astype(np.float32)
else:
# Fall back: preprocess densitometry
from app.preprocessing import preprocess_negative
from PIL import Image
pil = Image.fromarray((scan * 255).clip(0, 255).astype(np.uint8))
pre = preprocess_negative(pil, stock="Generic")
d_true = pre.density.astype(np.float32)
scan = pre.rgb.astype(np.float32)
h, w = scan.shape[:2]
# Lab warp: D_obs = 0.7·D + 0.3 (systematic tone curve)
d_warped = (0.7 * d_true + 0.3).astype(np.float32)
mask = np.full(d_warped.shape, VALID, dtype=np.uint8)
# Confidence mask from fixture if available
if "confidence_mask" in data.files:
mask = data["confidence_mask"].astype(np.uint8)
struct_a, struct_b = _hard_p50_split(scan)
flat_a, flat_b = _flat_pair(h, w)
log_exp = torch.full((1, 1, h, w), -0.3)
# (i) discrimination: affine-mode physics ratio flat/structured ≥ 3× pointwise ratio
loss_pt = HybridFilmLoss(
film_curve=curve,
physics_weight=1.0,
perceptual_weight=0.0,
exclusivity_weight=0.0,
balance_weight=0.0,
naturalness_weight=0.0,
calibration="none",
)
loss_af = HybridFilmLoss(
film_curve=curve,
physics_weight=1.0,
perceptual_weight=0.0,
exclusivity_weight=0.0,
balance_weight=0.0,
naturalness_weight=0.0,
calibration="affine",
)
bd_flat_pt = loss_pt.evaluate(log_exp, scan, flat_a, flat_b, density=d_warped, confidence_mask=mask)
bd_str_pt = loss_pt.evaluate(log_exp, scan, struct_a, struct_b, density=d_warped, confidence_mask=mask)
bd_flat_af = loss_af.evaluate(log_exp, scan, flat_a, flat_b, density=d_warped, confidence_mask=mask)
bd_str_af = loss_af.evaluate(log_exp, scan, struct_a, struct_b, density=d_warped, confidence_mask=mask)
ratio_pt = bd_flat_pt.physics_loss / max(bd_str_pt.physics_loss, 1e-12)
ratio_af = bd_flat_af.physics_loss / max(bd_str_af.physics_loss, 1e-12)
assert ratio_af >= 3.0 * ratio_pt, (
f"affine discrimination {ratio_af:.3f} not ≥ 3× pointwise {ratio_pt:.3f}"
)
# (ii) FULL new ranking (affine + grad 0.5 + reweight, guard OFF): flat ranks BELOW structured
flat_c = SeparationResult(
image_a=flat_a, image_b=flat_b, method="flat", message="", candidate_id="flat_red_white"
)
struct_c = SeparationResult(
image_a=struct_a, image_b=struct_b, method="hard_p50", message="", candidate_id="hard_p50"
)
ranked = rank_candidates(
candidates=[flat_c, struct_c],
observed_log_exposure=log_exp,
observed_rgb=scan,
film_curve=curve,
physics_weight=1.0,
perceptual_weight=0.5,
density=d_warped,
confidence_mask=mask,
calibration="affine",
physics_grad_weight=0.5,
flat_guard=False,
)
ids = [r.candidate_id for r in ranked]
assert ids.index("hard_p50") < ids.index("flat_red_white"), (
f"structured must rank above flat without guard; order={ids}; "
f"scores={[ (r.candidate_id, r.score.total_loss) for r in ranked ]}"
)
# (iii) flat_guard=True → flat rejected, rejected_count == 1
ranked_g = rank_candidates(
candidates=[flat_c, struct_c],
observed_log_exposure=log_exp,
observed_rgb=scan,
film_curve=curve,
density=d_warped,
confidence_mask=mask,
calibration="affine",
physics_grad_weight=0.5,
flat_guard=True,
)
assert isinstance(ranked_g, RankingResult)
assert ranked_g.rejected_count == 1, f"expected 1 rejected, got {ranked_g.rejected_count}"
assert all(r.candidate_id != "flat_red_white" for r in ranked_g.ranked)
assert len(ranked_g.ranked) == 1
assert ranked_g.ranked[0].candidate_id == "hard_p50"
class TestFlatGuardFallback:
"""Spec test 4: all-flat list → unfiltered fallback, non-empty."""
def test_all_flat_fallback(self):
h, w = 32, 32
obs = np.random.default_rng(0).uniform(0.2, 0.8, (h, w, 3)).astype(np.float32)
fa, fb = _flat_pair(h, w)
# Second flat pair (different solid colors, still flat)
fa2 = np.zeros((h, w, 3), dtype=np.float32)
fa2[..., 2] = 0.8
fb2 = np.full((h, w, 3), 0.5, dtype=np.float32)
cands = [
SeparationResult(fa, fb, "flat", "", "flat1"),
SeparationResult(fa2, fb2, "flat", "", "flat2"),
]
curve = get_film_curve("Generic")
log_exp = torch.full((1, 1, h, w), -0.3)
ranked = rank_candidates(
candidates=cands,
observed_log_exposure=log_exp,
observed_rgb=obs,
film_curve=curve,
flat_guard=True,
)
assert len(ranked) >= 1, "all-flat must fall back to non-empty unfiltered list"
assert ranked.rejected_count == 2
# Independent check: both pairs really are flat under the guard definition
assert _is_flat_pair(fa, fb, obs)
assert _is_flat_pair(fa2, fb2, obs)
class TestDefaultsByteIdentical:
"""Defaults must not change scoring behavior for existing call sites."""
def test_score_separation_defaults_match_class_weights(self):
data = _load_fixture0()
scan = data["scan"].astype(np.float32)
h, w = scan.shape[:2]
a, b = _hard_p50_split(scan)
curve = get_film_curve("Generic")
log_exp = torch.full((1, 1, h, w), -0.3)
# No density → legacy path; just ensure no crash and finite
bd = score_separation(log_exp, scan, a, b, curve)
assert np.isfinite(bd.total_loss)
assert bd.affine_a == 1.0 # default unset path