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