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