Spaces:
Running on Zero
Running on Zero
| """Gradient-based refinement of separation candidates in VAE latent space.""" | |
| from __future__ import annotations | |
| from dataclasses import dataclass, field | |
| from typing import Callable, List, Optional, Tuple | |
| import numpy as np | |
| import torch | |
| import torch.nn as nn | |
| import torch.nn.functional as F | |
| from torch.optim.lr_scheduler import CosineAnnealingLR | |
| from hybrid_loss import HybridFilmLoss, GAIN_GRID_RANGE | |
| from densitometry import VALID, srgb_to_linear, luminance_from_linear | |
| DEFAULT_VAE_ID = "stabilityai/sd-vae-ft-mse" | |
| _VAE_CACHE: dict[str, nn.Module] = {} | |
| def _get_shared_vae(model_id: str) -> nn.Module: | |
| """Return a cached, frozen ``AutoencoderKL`` (lazy-loaded once per id).""" | |
| if model_id not in _VAE_CACHE: | |
| from diffusers import AutoencoderKL | |
| vae = AutoencoderKL.from_pretrained(model_id) | |
| vae.eval() | |
| vae.requires_grad_(False) | |
| _VAE_CACHE[model_id] = vae | |
| return _VAE_CACHE[model_id] | |
| def _pick_device() -> torch.device: | |
| if torch.cuda.is_available(): | |
| return torch.device("cuda") | |
| if getattr(torch.backends, "mps", None) is not None and torch.backends.mps.is_available(): | |
| return torch.device("mps") | |
| return torch.device("cpu") | |
| def _rgb_numpy_to_tensor(rgb: np.ndarray) -> torch.Tensor: | |
| """Float RGB (H, W, 3) in [0, 1] -> (1, 3, H, W) tensor.""" | |
| return torch.from_numpy(rgb).float().permute(2, 0, 1).unsqueeze(0) | |
| def _tensor_to_rgb_numpy(t: torch.Tensor) -> np.ndarray: | |
| """(1, 3, H, W) tensor in [0, 1] -> float RGB (H, W, 3).""" | |
| return t.squeeze(0).permute(1, 2, 0).clamp(0.0, 1.0).cpu().numpy().astype(np.float32) | |
| def _downscale(t: torch.Tensor, max_side: int) -> torch.Tensor: | |
| h, w = t.shape[-2:] | |
| scale = min(1.0, max_side / max(h, w)) | |
| if scale >= 1.0: | |
| return t | |
| size = (max(1, round(h * scale)), max(1, round(w * scale))) | |
| return F.interpolate(t, size=size, mode="bilinear", align_corners=False, antialias=True) | |
| def _pad_to_multiple(t: torch.Tensor, multiple: int = 8, *, value: int = 0, mode: str = "replicate") -> Tuple[torch.Tensor, Tuple[int, int]]: | |
| """Right/bottom pad so H and W are multiples of ``multiple``. | |
| mode: 'replicate' (default) or 'constant' (uses value for pad, e.g. TOE=0 for mask). | |
| """ | |
| h, w = t.shape[-2:] | |
| ph = (multiple - h % multiple) % multiple | |
| pw = (multiple - w % multiple) % multiple | |
| if ph or pw: | |
| if mode == "constant": | |
| t = F.pad(t, (0, pw, 0, ph), mode="constant", value=value) | |
| else: | |
| t = F.pad(t, (0, pw, 0, ph), mode="replicate") | |
| return t, (h, w) | |
| class RefinementResult: | |
| """Output of a latent-space refinement run.""" | |
| refined_a: np.ndarray # float RGB [0, 1], original resolution | |
| refined_b: np.ndarray | |
| initial_loss: float | |
| final_loss: float | |
| steps_run: int | |
| used_vae: bool | |
| loss_history: List[float] = field(default_factory=list) | |
| used_density: bool = False # Fix 3 | |
| g_final: float = 1.0 | |
| early_stopped: bool = False | |
| degeneracy_aborted: bool = False | |
| def improved(self) -> bool: | |
| return self.final_loss < self.initial_loss | |
| class LatentSpaceOptimizer: | |
| """ | |
| Refines a candidate scene pair by gradient descent on the hybrid loss. | |
| Both reconstructions are encoded into the latent space of a pretrained | |
| VAE (from ``diffusers``); Adam then optimizes the latents so that the | |
| decoded pair better explains the observed double exposure under the | |
| differentiable film-curve forward model plus LPIPS. | |
| If the VAE cannot be loaded (no ``diffusers`` install or no network for | |
| the initial weight download), the optimizer transparently falls back to | |
| pixel-space optimization with a sigmoid parameterization, so demo mode | |
| keeps working offline. | |
| """ | |
| def __init__( | |
| self, | |
| hybrid_loss: HybridFilmLoss, | |
| vae_id: str = DEFAULT_VAE_ID, | |
| steps: int = 40, | |
| lr: float = 0.05, | |
| max_side: int = 512, | |
| device: Optional[torch.device] = None, | |
| ): | |
| self.hybrid_loss = hybrid_loss | |
| self.vae_id = vae_id | |
| self.steps = max(1, min(int(steps), 300)) | |
| self.lr = lr | |
| self.max_side = max_side | |
| self.device = device or _pick_device() | |
| # ------------------------------------------------------------------ | |
| # Latent <-> image codecs | |
| # ------------------------------------------------------------------ | |
| def _try_load_vae(self) -> Optional[nn.Module]: | |
| try: | |
| return _get_shared_vae(self.vae_id).to(self.device) | |
| except Exception: | |
| return None | |
| def _vae_encode(vae: nn.Module, image01: torch.Tensor) -> torch.Tensor: | |
| with torch.no_grad(): | |
| return vae.encode(image01 * 2.0 - 1.0).latent_dist.mean | |
| def _vae_decode(vae: nn.Module, latents: torch.Tensor) -> torch.Tensor: | |
| return ((vae.decode(latents).sample + 1.0) * 0.5).clamp(0.0, 1.0) | |
| def _pixel_encode(image01: torch.Tensor) -> torch.Tensor: | |
| return torch.logit(image01.clamp(1e-3, 1.0 - 1e-3)) | |
| def _pixel_decode(params: torch.Tensor) -> torch.Tensor: | |
| return torch.sigmoid(params) | |
| # ------------------------------------------------------------------ | |
| # Refinement | |
| # ------------------------------------------------------------------ | |
| def refine( | |
| self, | |
| recon_a: np.ndarray, | |
| recon_b: np.ndarray, | |
| observed_log_exposure: torch.Tensor, | |
| observed_rgb: np.ndarray, | |
| progress_callback: Optional[Callable[[int, int, float], None]] = None, | |
| observed_density: Optional[np.ndarray] = None, | |
| confidence_mask: Optional[np.ndarray] = None, | |
| ) -> RefinementResult: | |
| """ | |
| Refine a scene pair to minimize the hybrid physics + perceptual loss. | |
| Args: | |
| recon_a: Candidate scene A, float RGB (H, W, 3) in [0, 1]. | |
| recon_b: Candidate scene B, same format. | |
| observed_log_exposure: (1, 1, H, W) observed log-exposure map | |
| (``PreprocessedNegative.log_exposure``). | |
| observed_rgb: Observed positive scan, float RGB (H, W, 3) in [0, 1]. | |
| progress_callback: Optional ``(step, total_steps, loss)`` hook. | |
| observed_density: Optional measured density map from densitometry (WP-3). | |
| confidence_mask: Optional confidence mask (VALID etc). | |
| Returns: | |
| ``RefinementResult`` with refined images at the input resolution. | |
| """ | |
| device = self.device | |
| orig_h, orig_w = recon_a.shape[:2] | |
| # Work at reduced resolution for tractable LPIPS + VAE gradients. | |
| a = _downscale(_rgb_numpy_to_tensor(recon_a).to(device), self.max_side) | |
| b = _downscale(_rgb_numpy_to_tensor(recon_b).to(device), self.max_side) | |
| obs_rgb = _downscale(_rgb_numpy_to_tensor(observed_rgb).to(device), self.max_side) | |
| obs_log_h = _downscale(observed_log_exposure.float().to(device), self.max_side) | |
| # Pre-pad working size for density/mask downscale (Fix 1) | |
| pre_h, pre_w = a.shape[-2:] | |
| dens_t = None | |
| conf_t = None | |
| if observed_density is not None: | |
| d_t = torch.from_numpy(observed_density.astype(np.float32)).unsqueeze(0).unsqueeze(0).to(device) | |
| dens_t = F.interpolate(d_t, size=(pre_h, pre_w), mode="bilinear", antialias=True) | |
| if confidence_mask is not None: | |
| m_t = torch.from_numpy(confidence_mask.astype(np.float32)).unsqueeze(0).unsqueeze(0).to(device) | |
| m_down = F.interpolate(m_t, size=(pre_h, pre_w), mode="nearest") | |
| conf_t = m_down.round().to(torch.long) | |
| else: | |
| # synthesize all-VALID so pad region can be excluded | |
| conf_t = torch.full((1, 1, pre_h, pre_w), VALID, dtype=torch.long, device=device) | |
| elif confidence_mask is not None: | |
| # unlikely but handle | |
| m_t = torch.from_numpy(confidence_mask.astype(np.float32)).unsqueeze(0).unsqueeze(0).to(device) | |
| conf_t = F.interpolate(m_t, size=(pre_h, pre_w), mode="nearest").round().to(torch.long) | |
| a, unpadded = _pad_to_multiple(a) | |
| b, _ = _pad_to_multiple(b) | |
| obs_rgb, _ = _pad_to_multiple(obs_rgb) | |
| obs_log_h, _ = _pad_to_multiple(obs_log_h) | |
| # Pad dens (replicate) and mask with constant TOE=0 (using extended _pad_to_multiple) | |
| if dens_t is not None: | |
| dens_t, _ = _pad_to_multiple(dens_t) | |
| if conf_t is not None: | |
| conf_t, _ = _pad_to_multiple(conf_t, value=0, mode="constant") | |
| # K-sel + initial share for degeneracy guard (I.6, on input pair) — do on CPU before moving loss/curve | |
| with torch.no_grad(): | |
| init_bd = self.hybrid_loss.evaluate( | |
| observed_log_exposure, | |
| observed_rgb, | |
| recon_a, | |
| recon_b, | |
| density=observed_density, | |
| confidence_mask=confidence_mask, | |
| ) | |
| k_sel = float(init_bd.k_selection_score) | |
| la0 = luminance_from_linear(srgb_to_linear(recon_a)) | |
| lb0 = luminance_from_linear(srgb_to_linear(recon_b)) | |
| ea0 = float(np.mean(la0) + 1e-12) | |
| eb0 = float(np.mean(lb0) + 1e-12) | |
| s0 = min(ea0, eb0) / (ea0 + eb0) | |
| loss_fn = self.hybrid_loss.to(device) | |
| vae = self._try_load_vae() | |
| used_vae = vae is not None | |
| if used_vae: | |
| lat_a = self._vae_encode(vae, a).clone().requires_grad_(True) | |
| lat_b = self._vae_encode(vae, b).clone().requires_grad_(True) | |
| decode = lambda lat: self._vae_decode(vae, lat) # noqa: E731 | |
| else: | |
| lat_a = self._pixel_encode(a).clone().requires_grad_(True) | |
| lat_b = self._pixel_encode(b).clone().requires_grad_(True) | |
| decode = self._pixel_decode | |
| # --- WP-7 g (learnable nuisance gain) --- | |
| log10_g = torch.zeros((), device=device, requires_grad=True) | |
| def compute_loss(g_t: torch.Tensor) -> torch.Tensor: | |
| dec_a = decode(lat_a) | |
| dec_b = decode(lat_b) | |
| return loss_fn.forward_tensor(obs_log_h, obs_rgb, dec_a, dec_b, density=dens_t, confidence_mask=conf_t, g=g_t) | |
| with torch.no_grad(): | |
| g_init = torch.pow(10.0, log10_g) | |
| initial_loss = float(compute_loss(g_init).item()) | |
| # Adam over latents + log10_g (so g is optimized jointly) | |
| optimizer = torch.optim.Adam([lat_a, lat_b, log10_g], lr=self.lr) | |
| scheduler = CosineAnnealingLR(optimizer, T_max=self.steps) | |
| history: List[float] = [] | |
| best_loss = initial_loss | |
| best_state = (lat_a.detach().clone(), lat_b.detach().clone()) | |
| best_g_log = log10_g.detach().clone() | |
| # Early stop on *masked physics residual* | |
| patience = 30 | |
| min_delta = 1e-5 | |
| best_phys = float("inf") | |
| no_improve_phys = 0 | |
| early_stopped = False | |
| degeneracy_aborted = False | |
| # local torch linearize + lum for current share (no extra import) | |
| def _lin_lum_t(rgb01: torch.Tensor) -> torch.Tensor: | |
| t = rgb01.clamp(0.0, 1.0) | |
| lin = torch.where( | |
| t <= 0.04045, | |
| t / 12.92, | |
| ((t + 0.055) / 1.055) ** 2.4, | |
| ) | |
| return 0.2126 * lin[:, 0:1] + 0.7152 * lin[:, 1:2] + 0.0722 * lin[:, 2:3] | |
| for step in range(self.steps): | |
| optimizer.zero_grad() | |
| g = torch.pow(10.0, log10_g) | |
| loss = compute_loss(g) | |
| # Snapshot BEFORE stepping: this loss belongs to the CURRENT latents/g, | |
| # so the stored best state is exactly the state that earned the loss. | |
| # (Snapshotting after step() captured a different — possibly collapsed — | |
| # state under a stale good loss, defeating the guard's rollback.) | |
| loss_val = float(loss.item()) | |
| history.append(loss_val) | |
| if loss_val < best_loss: | |
| best_loss = loss_val | |
| best_state = (lat_a.detach().clone(), lat_b.detach().clone()) | |
| best_g_log = log10_g.detach().clone() | |
| loss.backward() | |
| optimizer.step() | |
| scheduler.step() | |
| # clamp log10_g after step (keeps g in documented range) | |
| with torch.no_grad(): | |
| log10_g.data.clamp_(GAIN_GRID_RANGE[0], GAIN_GRID_RANGE[1]) | |
| # physics residual for early stop: the RAW masked physics term of the | |
| # post-step state, independent of the caller's term weights | |
| with torch.no_grad(): | |
| g_now = torch.pow(10.0, log10_g) | |
| dec_a = decode(lat_a) | |
| dec_b = decode(lat_b) | |
| weights = ("physics_weight", "perceptual_weight", "exclusivity_weight", "balance_weight", "naturalness_weight") | |
| saved = {w: getattr(loss_fn, w) for w in weights} | |
| try: | |
| for w in weights: | |
| setattr(loss_fn, w, 0.0) | |
| loss_fn.physics_weight = 1.0 | |
| phys_val = float(compute_loss(g_now).item()) | |
| finally: | |
| for w, v in saved.items(): | |
| setattr(loss_fn, w, v) | |
| if phys_val + min_delta < best_phys: | |
| best_phys = phys_val | |
| no_improve_phys = 0 | |
| else: | |
| no_improve_phys += 1 | |
| if no_improve_phys >= patience: | |
| early_stopped = True | |
| break | |
| # Degeneracy guard on the post-step state with the post-step g | |
| # (only when K=2 hypothesis and started balanced) | |
| if k_sel >= 0.5 and s0 >= 0.10: | |
| with torch.no_grad(): | |
| la = _lin_lum_t(dec_a) | |
| lb = _lin_lum_t(dec_b) | |
| ea = float((g_now * la).mean().item() + 1e-12) | |
| eb = float(((1.0 / g_now) * lb).mean().item() + 1e-12) | |
| share = min(ea, eb) / (ea + eb) | |
| if share < 0.05: | |
| degeneracy_aborted = True | |
| break | |
| if progress_callback is not None: | |
| progress_callback(step + 1, self.steps, loss_val) | |
| steps_run = (step + 1) if (early_stopped or degeneracy_aborted) else self.steps | |
| with torch.no_grad(): | |
| # Last-chance snapshot: the post-loop state was stepped but never scored. | |
| # Skipped on abort so the rollback contract (return pre-collapse best) holds. | |
| if not degeneracy_aborted: | |
| tail_val = float(compute_loss(torch.pow(10.0, log10_g)).item()) | |
| if tail_val < best_loss: | |
| best_loss = tail_val | |
| best_state = (lat_a.detach().clone(), lat_b.detach().clone()) | |
| best_g_log = log10_g.detach().clone() | |
| # Restore the best state into the latents so compute_loss (whose closure | |
| # decodes lat_a/lat_b) scores exactly the images we return. | |
| lat_a.data.copy_(best_state[0]) | |
| lat_b.data.copy_(best_state[1]) | |
| log10_g.data.copy_(best_g_log) | |
| g_final = float(10.0 ** best_g_log) | |
| dec_a = decode(lat_a) | |
| dec_b = decode(lat_b) | |
| final_loss = float( | |
| compute_loss(torch.tensor(g_final, device=device)).item() | |
| ) | |
| h, w = unpadded | |
| dec_a = dec_a[..., :h, :w] | |
| dec_b = dec_b[..., :h, :w] | |
| if (h, w) != (orig_h, orig_w): | |
| dec_a = F.interpolate( | |
| dec_a, size=(orig_h, orig_w), mode="bilinear", align_corners=False | |
| ) | |
| dec_b = F.interpolate( | |
| dec_b, size=(orig_h, orig_w), mode="bilinear", align_corners=False | |
| ) | |
| return RefinementResult( | |
| refined_a=_tensor_to_rgb_numpy(dec_a), | |
| refined_b=_tensor_to_rgb_numpy(dec_b), | |
| initial_loss=initial_loss, | |
| final_loss=final_loss, | |
| steps_run=steps_run, | |
| used_vae=used_vae, | |
| loss_history=history, | |
| used_density=(observed_density is not None), | |
| g_final=g_final, | |
| early_stopped=early_stopped, | |
| degeneracy_aborted=degeneracy_aborted, | |
| ) | |
| # Public aliases for the working-resolution helpers so other optimization-style | |
| # modules (baselines/double_dip.py) can reuse the exact downscale/pad/device | |
| # contract without depending on private names. | |
| pick_device = _pick_device | |
| rgb_numpy_to_tensor = _rgb_numpy_to_tensor | |
| tensor_to_rgb_numpy = _tensor_to_rgb_numpy | |
| downscale_to_max_side = _downscale | |
| pad_to_multiple = _pad_to_multiple | |