File size: 4,312 Bytes
7dff04f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
"""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