double-exposure / baselines /double_dip.py
Eddie Faillace
double-exposure app deploy snapshot 2026-07-21 (WP-24 calibration pass)
7dff04f
Raw
History Blame Contribute Delete
15.8 kB
"""Double-DIP baseline (WP-6).
Two small untrained hourglass CNNs (deep image priors) map fixed noise to the
two layers. Optimized per-image against the film forward model using
HybridFilmLoss(..., perceptual_weight=0) so the objective is exactly the
masked density residual + exclusion + balance + naturalness (DIP arch provides
the naturalness prior; LPIPS is deliberately not used in the inner loop).
Binding per MASTERPLAN WP-6 execution spec.
"""
from __future__ import annotations
import argparse
import time
from dataclasses import dataclass, field
from typing import Optional, Tuple
import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
from PIL import Image
from app.api_client import SeparationResult
from app.preprocessing import preprocess_negative
from film_physics import PiecewiseFilmCurve, get_film_curve
from hybrid_loss import HybridFilmLoss
from scoring_policy import ScoringPolicy, DEFAULT_POLICY, loss_kwargs
# Reuse (do not reimplement) the established downscale/pad/device helpers from latent opt.
from latent_optimizer import (
downscale_to_max_side as _downscale,
pad_to_multiple as _pad_to_multiple,
pick_device as _pick_device,
rgb_numpy_to_tensor as _rgb_numpy_to_tensor,
tensor_to_rgb_numpy as _tensor_to_rgb_numpy,
)
@dataclass
class DoubleDIPConfig:
"""Config for Double-DIP separation (all defaults per binding spec)."""
iterations: int = 2000
lr: float = 0.01
max_side: int = 256
reg_noise_std: float = 1.0 / 30.0
seed: int = 0
# WP-13.1: scoring policy (DEFAULT = byte-identical bench path; app passes APP_POLICY)
policy: ScoringPolicy = field(default_factory=lambda: DEFAULT_POLICY)
# WP-13.1 F: L2 warm-start iterations before physics phase (opt-in via warm_start arg)
warm_iters: int = 300
def _build_dip_net() -> nn.Module:
"""Small hourglass CNN (3 levels, channels 16/32/64, refl pad, LeakyReLU, bilinear up, sigmoid).
Input: (B, 32, H, W) noise in ~[0, 0.1]. Output: (B, 3, H, W) RGB in [0, 1].
Additive skips at matching decoder stages for a standard DIP hourglass shape.
"""
class Hourglass(nn.Module):
def __init__(self) -> None:
super().__init__()
# Encoder stages (input noise depth = 32)
self.e0 = nn.Sequential(
nn.ReflectionPad2d(1),
nn.Conv2d(32, 16, 3),
nn.LeakyReLU(0.2, inplace=True),
)
self.e1 = nn.Sequential(
nn.ReflectionPad2d(1),
nn.Conv2d(16, 16, 3),
nn.LeakyReLU(0.2, inplace=True),
)
self.down1 = nn.Sequential(
nn.ReflectionPad2d(1),
nn.Conv2d(16, 32, 3, stride=2),
nn.LeakyReLU(0.2, inplace=True),
)
self.e2 = nn.Sequential(
nn.ReflectionPad2d(1),
nn.Conv2d(32, 32, 3),
nn.LeakyReLU(0.2, inplace=True),
)
self.down2 = nn.Sequential(
nn.ReflectionPad2d(1),
nn.Conv2d(32, 64, 3, stride=2),
nn.LeakyReLU(0.2, inplace=True),
)
self.e3 = nn.Sequential(
nn.ReflectionPad2d(1),
nn.Conv2d(64, 64, 3),
nn.LeakyReLU(0.2, inplace=True),
)
# Decoder: bilinear up + conv; additive skips from encoder features
self.up2 = lambda x: F.interpolate(
x, scale_factor=2, mode="bilinear", align_corners=False
)
self.d2 = nn.Sequential(
nn.ReflectionPad2d(1),
nn.Conv2d(64, 32, 3),
nn.LeakyReLU(0.2, inplace=True),
)
self.up1 = lambda x: F.interpolate(
x, scale_factor=2, mode="bilinear", align_corners=False
)
self.d1 = nn.Sequential(
nn.ReflectionPad2d(1),
nn.Conv2d(32, 16, 3),
nn.LeakyReLU(0.2, inplace=True),
)
self.d0 = nn.Sequential(
nn.ReflectionPad2d(1),
nn.Conv2d(16, 3, 3),
nn.Sigmoid(),
)
def forward(self, z: torch.Tensor) -> torch.Tensor:
x0 = self.e0(z)
x1 = self.e1(x0)
x = self.down1(x1)
x2 = self.e2(x)
x = self.down2(x2)
x3 = self.e3(x)
x = self.up2(x3)
x = self.d2(x) + F.interpolate(
x2, size=x.shape[-2:], mode="bilinear", align_corners=False
)
x = self.up1(x)
x = self.d1(x) + F.interpolate(
x1, size=x.shape[-2:], mode="bilinear", align_corners=False
)
x = self.d0(x)
return x
return Hourglass()
def double_dip_separate(
rgb: np.ndarray,
log_exposure: torch.Tensor,
density: Optional[np.ndarray],
confidence_mask: Optional[np.ndarray],
film_curve: PiecewiseFilmCurve,
config: Optional[DoubleDIPConfig] = None,
warm_start: Optional[Tuple[np.ndarray, np.ndarray]] = None,
) -> Optional[SeparationResult]:
"""Run Double-DIP separation.
Consumes PreprocessedNegative fields (rgb, log_exposure, density, confidence_mask).
If density is None the source is skipped (return None) — never fall back to legacy.
Uses HybridFilmLoss(physics=1, perceptual=0) + DIP architecture as naturalness prior.
Returns one SeparationResult at original resolution (float32 RGB [0,1]).
WP-13.1 F: optional ``warm_start=(A, B)`` runs ``config.warm_iters`` pure-L2
reconstruction steps first, then the standard physics objective. Best-snapshot
is taken only over the physics phase.
"""
if density is None or confidence_mask is None:
return None
if config is None:
config = DoubleDIPConfig()
torch.manual_seed(config.seed)
device = _pick_device()
orig_h, orig_w = rgb.shape[:2]
# Downscale observed targets (same pattern as latent_optimizer.refine)
obs_rgb_t = _downscale(_rgb_numpy_to_tensor(rgb).to(device), config.max_side)
obs_log_t = _downscale(log_exposure.float().to(device), config.max_side)
pre_h, pre_w = obs_rgb_t.shape[-2:]
# Density / mask downscale + pad exactly as refine does
dens_t = (
torch.from_numpy(density.astype(np.float32))
.unsqueeze(0)
.unsqueeze(0)
.to(device)
)
dens_t = F.interpolate(dens_t, size=(pre_h, pre_w), mode="bilinear", antialias=True)
conf_t = (
torch.from_numpy(confidence_mask.astype(np.float32))
.unsqueeze(0)
.unsqueeze(0)
.to(device)
)
conf_t = F.interpolate(conf_t, size=(pre_h, pre_w), mode="nearest").round().to(torch.long)
# Pad working tensors
obs_rgb_t, unpadded = _pad_to_multiple(obs_rgb_t)
obs_log_t, _ = _pad_to_multiple(obs_log_t)
dens_t, _ = _pad_to_multiple(dens_t)
conf_t, _ = _pad_to_multiple(conf_t, value=0, mode="constant")
h_work, w_work = obs_rgb_t.shape[-2:]
# Optional warm-start targets (downscale + pad to work size)
warm_a_t: Optional[torch.Tensor] = None
warm_b_t: Optional[torch.Tensor] = None
warm_iters = 0
if warm_start is not None:
wa_np, wb_np = warm_start
wa_t = _downscale(_rgb_numpy_to_tensor(wa_np.astype(np.float32)).to(device), config.max_side)
wb_t = _downscale(_rgb_numpy_to_tensor(wb_np.astype(np.float32)).to(device), config.max_side)
# Match spatial size of obs work grid before pad
if wa_t.shape[-2:] != (pre_h, pre_w):
wa_t = F.interpolate(wa_t, size=(pre_h, pre_w), mode="bilinear", align_corners=False)
wb_t = F.interpolate(wb_t, size=(pre_h, pre_w), mode="bilinear", align_corners=False)
warm_a_t, _ = _pad_to_multiple(wa_t)
warm_b_t, _ = _pad_to_multiple(wb_t)
warm_iters = int(config.warm_iters)
# Two independent nets + fixed noise inputs
net_a = _build_dip_net().to(device)
net_b = _build_dip_net().to(device)
z_a = (torch.rand(1, 32, h_work, w_work, device=device) * 0.1).detach()
z_b = (torch.rand(1, 32, h_work, w_work, device=device) * 0.1).detach()
# Objective: reuse forward_tensor with perceptual=0 (LPIPS skipped; exact WP-3 terms).
# Deep-copy the curve: .to(device) moves nn.Module state in place, and the caller's
# curve is shared with CPU-side ranking (mutating it would crash rank_candidates).
import copy
# Policy-driven objective (WP-13.1): same loss_kwargs as ranking when APP_POLICY.
loss_fn = HybridFilmLoss(
film_curve=copy.deepcopy(film_curve),
physics_weight=1.0,
perceptual_weight=0.0,
**loss_kwargs(config.policy),
).to(device)
# Snapshot best (standard DIP: return lowest-loss iterate, not final)
with torch.no_grad():
ia = net_a(z_a)
ib = net_b(z_b)
init_loss = float(
loss_fn.forward_tensor(
obs_log_t, obs_rgb_t, ia, ib, density=dens_t, confidence_mask=conf_t
).item()
)
optimizer = torch.optim.Adam(
list(net_a.parameters()) + list(net_b.parameters()), lr=config.lr
)
# Best-snapshot only over the physics phase (WP-13.1 F).
if warm_iters > 0:
best_loss = float("inf")
best_a = ia.detach().clone()
best_b = ib.detach().clone()
else:
best_loss = init_loss
best_a = ia.detach().clone()
best_b = ib.detach().clone()
reg = float(config.reg_noise_std)
total_iters = int(config.iterations)
t0 = time.time()
for it in range(total_iters):
optimizer.zero_grad()
za = z_a + reg * torch.randn_like(z_a)
zb = z_b + reg * torch.randn_like(z_b)
ia = net_a(za)
ib = net_b(zb)
if warm_a_t is not None and it < warm_iters:
# Pure L2 reconstruction of warm pair (no physics)
loss = ((ia - warm_a_t) ** 2).mean() + ((ib - warm_b_t) ** 2).mean()
loss.backward()
optimizer.step()
continue
loss = loss_fn.forward_tensor(
obs_log_t, obs_rgb_t, ia, ib, density=dens_t, confidence_mask=conf_t
)
loss.backward()
optimizer.step()
lv = float(loss.item())
if lv < best_loss:
best_loss = lv
best_a = ia.detach().clone()
best_b = ib.detach().clone()
dt = time.time() - t0
# Unpad then bilinear-upscale outputs to original input resolution
with torch.no_grad():
ha, wa = unpadded
ba = best_a[..., :ha, :wa]
bb = best_b[..., :ha, :wa]
if (ha, wa) != (orig_h, orig_w):
ba = F.interpolate(ba, size=(orig_h, orig_w), mode="bilinear", align_corners=False)
bb = F.interpolate(bb, size=(orig_h, orig_w), mode="bilinear", align_corners=False)
a_np = _tensor_to_rgb_numpy(ba)
b_np = _tensor_to_rgb_numpy(bb)
warm_note = f", warm_iters={warm_iters}" if warm_iters > 0 else ""
msg = (
f"Double-DIP baseline ({config.iterations} iters{warm_note}, {dt:.1f}s, "
f"best_loss={best_loss:.6f}, init_loss={init_loss:.6f}, device={device.type})"
)
cid = f"dip_i{config.iterations}"
if warm_iters > 0:
cid = f"dip_i{config.iterations}_w{warm_iters}"
return SeparationResult(
image_a=a_np.astype(np.float32),
image_b=b_np.astype(np.float32),
method="deep_prior",
message=msg,
candidate_id=cid,
diagnostics={
"init_loss": init_loss,
"best_loss": best_loss,
"runtime_s": dt,
"device": device.type,
"iterations": int(config.iterations),
"warm_iters": warm_iters,
},
)
def run_bench(iterations: int = 2000, fixtures_dir: str | None = None, limit: int = 6) -> None:
"""Real bench used for WP-6 accept: DIP vs demo heuristics on the K=2 fixtures (up to `limit`).
Invoked via: python -m baselines.double_dip --bench [--iters N] [--fixtures-dir DIR]
Output table is pasted verbatim into MASTERPLAN WP-6 Result note; the note must
state the iteration count so the table is reproducible from committed code.
"""
from synth.evaluation import score_pair
from synth.generate import load_fixtures, select_k2_cases
from app.api_client import generate_demo_candidates
dir_note = f" --fixtures-dir {fixtures_dir}" if fixtures_dir else ""
print(f"Double-DIP --bench ({iterations} iters{dir_note}, limit {limit}): DIP vs demo heuristics on the K=2 fixtures")
cases = load_fixtures(fixtures_dir)
k2_cases = select_k2_cases(cases, limit)
print(f"Using {len(k2_cases)} K=2 fixtures from {fixtures_dir or 'synth/fixtures'}")
rows: list[tuple] = []
for idx, case in enumerate(k2_cases):
scan = case["scan"]
pil = Image.fromarray((scan * 255.0).clip(0, 255).astype(np.uint8))
pre = preprocess_negative(pil)
if pre.density is None or pre.confidence_mask is None:
print(f" case {idx} (seed {case['seed']}): skipped — no density/confidence")
continue
stock = case.get("stock", "Generic")
curve = get_film_curve(stock)
t0 = time.time()
cfg = DoubleDIPConfig(iterations=iterations, seed=42 + idx)
res = double_dip_separate(
pre.rgb, pre.log_exposure, pre.density, pre.confidence_mask, curve, cfg
)
dt = time.time() - t0
if res is None:
continue
dip_sc = score_pair(case["gt_a"], case["gt_b"], res.image_a, res.image_b)
dip_lp = float(dip_sc.get("lpips", float("nan")))
# Demo heuristics (same positive_rgb fed to generate_demo_candidates)
demos = generate_demo_candidates(pre.rgb, num_candidates=5)
heu_lp = float("inf")
for d in demos:
sc = score_pair(case["gt_a"], case["gt_b"], d.image_a, d.image_b)
l = float(sc.get("lpips", float("inf")))
if l < heu_lp:
heu_lp = l
rows.append((idx, case["seed"], dip_lp, heu_lp, dt))
print(f"case{idx} seed{case['seed']}: DIP={dip_lp:.4f} heu_best={heu_lp:.4f} t={dt:.1f}s")
print("\nPer-case table (DIP vs best-of-heuristics demo):")
print("| case | seed | DIP LPIPS | best-heu LPIPS | runtime_s |")
print("|------|------|-----------|----------------|-----------|")
for r in rows:
print(f"| {r[0]} | {r[1]} | {r[2]:.4f} | {r[3]:.4f} | {r[4]:.1f} |")
if rows:
md = np.mean([r[2] for r in rows])
mh = np.mean([r[3] for r in rows])
print(f"\nMean per-layer LPIPS: DIP={md:.4f} best-heu={mh:.4f}")
print("Bench complete.")
def main() -> None:
parser = argparse.ArgumentParser(description="Double-DIP baseline (WP-6)")
parser.add_argument(
"--bench", action="store_true", help="Run on the K=2 fixtures (up to --limit) and compare LPIPS to demo heuristics"
)
parser.add_argument(
"--iters", type=int, default=2000, help="DIP iterations per case for the bench (default: spec 2000)"
)
parser.add_argument(
"--fixtures-dir", type=str, default=None, dest="fixtures_dir",
help="Directory with case_*.npz fixtures (default: synth/fixtures; for 256px re-measure)"
)
parser.add_argument(
"--limit", type=int, default=6, help="Max K=2 cases to process (default 6; for 50-case bench)"
)
args = parser.parse_args()
if args.bench:
run_bench(iterations=args.iters, fixtures_dir=args.fixtures_dir, limit=args.limit)
else:
print("Double-DIP baseline ready. Use --bench for the fixture comparison (slow).")
if __name__ == "__main__":
main()