double-exposure / tests /test_double_dip.py
Eddie Faillace
double-exposure app deploy snapshot 2026-07-21 (WP-24 calibration pass)
7dff04f
Raw
History Blame Contribute Delete
4.31 kB
"""Fast offline tests for WP-6 Double-DIP baseline.
- Smoke: tiny iters + 32x32 crop → valid SeparationResult + final loss < initial (teeth-proven).
- Registration: include_deep_prior=True yields method=="deep_prior" candidate that ranks.
- density=None path is skipped (no candidate added).
Full 2000-iter bench is CLI-only and marked slow (not run in CI).
"""
from __future__ import annotations
import numpy as np
import pytest
from PIL import Image
from app.api_client import SeparationResult
from app.preprocessing import preprocess_negative
from app.scoring import rank_candidates
from film_physics import get_film_curve
# Direct import for core tests (fast path)
from baselines.double_dip import DoubleDIPConfig, double_dip_separate
def _load_fixture_as_pil(idx: int = 0) -> Image.Image:
from pathlib import Path
fix = sorted(Path("synth/fixtures").glob("case_*.npz"))[idx]
d = np.load(fix)
arr = (d["scan"] * 255).clip(0, 255).astype(np.uint8)
return Image.fromarray(arr)
def test_double_dip_smoke():
"""Tiny config returns valid shapes/dtypes/ranges and improves loss (teeth: fails if opt disabled)."""
# Use full small fixture (64x64); DIP downs internally via max_side. Crop was causing densitometry soft-fail on tiny input.
pil = _load_fixture_as_pil(0)
pre = preprocess_negative(pil)
curve = get_film_curve("Generic")
cfg = DoubleDIPConfig(iterations=30, max_side=64, seed=0)
res = double_dip_separate(
pre.rgb, pre.log_exposure, pre.density, pre.confidence_mask, curve, cfg
)
assert res is not None
assert isinstance(res, SeparationResult)
assert res.method == "deep_prior"
assert res.candidate_id.startswith("dip_i")
assert res.image_a.shape == res.image_b.shape == pre.rgb.shape
assert res.image_a.dtype == res.image_b.dtype == np.float32
assert 0.0 <= float(res.image_a.min()) <= float(res.image_a.max()) <= 1.0
assert 0.0 <= float(res.image_b.min()) <= float(res.image_b.max()) <= 1.0
# Structured diagnostics (not message parsing) carry the loss improvement
assert res.diagnostics is not None
init_l = res.diagnostics["init_loss"]
best_l = res.diagnostics["best_loss"]
assert best_l < init_l, f"DIP did not improve loss: init={init_l} best={best_l}"
def test_double_dip_registration(monkeypatch):
"""The REAL integration surface: generate_candidates(include_deep_prior=True) registers a
deep_prior candidate (tiny config monkeypatched in) that rank_candidates then scores.
"""
import baselines.double_dip as dd
from app.api_client import generate_candidates
pil = _load_fixture_as_pil(0)
pre = preprocess_negative(pil)
curve = get_film_curve("Generic")
# Tiny config so the wiring test stays fast; the helper's lazy import picks this up.
monkeypatch.setattr(
dd, "DoubleDIPConfig", lambda **kw: DoubleDIPConfig(iterations=12, max_side=64, seed=7)
)
cands, mode = generate_candidates(
pre.rgb,
num_candidates=2,
h_total=pre.h_total,
confidence_mask=pre.confidence_mask,
density=pre.density,
log_exposure=pre.log_exposure,
include_deep_prior=True,
film_curve=curve,
)
assert mode == "demo"
dips = [c for c in cands if c.method == "deep_prior"]
assert dips, "generate_candidates(include_deep_prior=True) did not register a deep_prior candidate"
# The mixed pool participates in normal ranking
ranked = rank_candidates(
candidates=cands,
observed_log_exposure=pre.log_exposure,
observed_rgb=pre.rgb,
film_curve=curve,
physics_weight=1.0,
perceptual_weight=0.0,
density=pre.density,
confidence_mask=pre.confidence_mask,
)
assert any(r.separation.method == "deep_prior" for r in ranked)
def test_double_dip_skips_without_density():
"""density=None (or conf=None) produces no deep_prior candidate (same rule as demix)."""
pil = _load_fixture_as_pil(0)
pre = preprocess_negative(pil)
# Direct path (generate wiring with the kw args lands in commit 2)
res = double_dip_separate(
pre.rgb, pre.log_exposure, density=None, confidence_mask=pre.confidence_mask, film_curve=get_film_curve("Generic")
)
assert res is None