""" Synthetic double-exposure benchmark generator (MASTERPLAN WP-1 / Part IV). Forward model (B&W, single emulsion): 1. Generate two procedural scene images directly in linear light (real-photo sources would require inverse-sRGB linearization here). 2. Scale by exposure ratio r; normalize H_total to the curve's useful range. 3. Optional halation: Gaussian blur on H_total before the curve. 4. Apply PiecewiseFilmCurve → optical density D. 5. Add density-dependent grain (σ ∝ √(D–D_min)). 6. Simulate transmittance T = 10^(−D) + sensor noise; sRGB-encode → raw scan. 7. Optional JPEG round-trip at quality 85–95. 8. Ground-truth positives = per-scene transmittance inverted and sRGB-encoded. Outputs are dicts saved as .npz with keys: scan (H,W,3) float32 sRGB — the raw negative scan (to upload to the app) gt_a (H,W,3) float32 sRGB — GT positive of scene A gt_b (H,W,3) float32 sRGB — GT positive of scene B (zeros for K=1) h_a (H,W) float32 — linear exposure map, scene A h_b (H,W) float32 — linear exposure map, scene B h_total (H,W) float32 — linear exposure total (H_a + H_b; halation-blurred if enabled) ratio scalar float — H_a / H_b (large number for K=1) stock str seed int k1 bool """ from __future__ import annotations import argparse import os from io import BytesIO from pathlib import Path from typing import List import numpy as np import torch from densitometry import srgb_to_linear, linear_to_srgb, luminance_from_linear from film_physics import get_film_curve, get_color_curves, COLOR_STOCK_PRESETS, PiecewiseFilmCurve def _luminance(lin_rgb: np.ndarray) -> np.ndarray: """Rec. 709 luminance from linear RGB (H, W, 3) → (H, W).""" return luminance_from_linear(lin_rgb) # --------------------------------------------------------------------------- # Procedural scene generation # --------------------------------------------------------------------------- def _make_scene(rng: np.random.Generator, size: int) -> np.ndarray: """ Generate a procedural grayscale scene (H, W) in linear light [0, 1]. Uses a random mixture of gradient, blob, and noise primitives so that each seed produces a structurally distinct image. """ h = w = size y_idx, x_idx = np.mgrid[:h, :w] scene_type = int(rng.integers(0, 4)) if scene_type == 0: # radial gradient with random center cx = float(rng.uniform(0.2, 0.8)) * w cy = float(rng.uniform(0.2, 0.8)) * h r = np.sqrt((x_idx - cx) ** 2 + (y_idx - cy) ** 2) lum = np.clip(1.0 - r / (0.7 * min(h, w)), 0.0, 1.0) elif scene_type == 1: # linear gradient (random angle) angle = float(rng.uniform(0, np.pi)) coord = x_idx * np.cos(angle) + y_idx * np.sin(angle) lo, hi = coord.min(), coord.max() lum = (coord - lo) / (hi - lo + 1e-8) if rng.integers(0, 2): lum = 1.0 - lum elif scene_type == 2: # random Gaussian blobs lum = np.zeros((h, w), dtype=np.float32) n_blobs = int(rng.integers(2, 6)) for _ in range(n_blobs): cx = float(rng.uniform(0.1, 0.9)) * w cy = float(rng.uniform(0.1, 0.9)) * h sigma = float(rng.uniform(0.08, 0.25)) * min(h, w) blob = np.exp(-((x_idx - cx) ** 2 + (y_idx - cy) ** 2) / (2 * sigma ** 2)) lum += blob * float(rng.uniform(0.3, 1.0)) lum = np.clip(lum / (lum.max() + 1e-8), 0.0, 1.0) else: # smooth noise via sum of low-frequency sinusoids lum = np.zeros((h, w), dtype=np.float32) for _ in range(int(rng.integers(3, 8))): fx = float(rng.uniform(0.5, 3.0)) / w fy = float(rng.uniform(0.5, 3.0)) / h phase = float(rng.uniform(0, 2 * np.pi)) amp = float(rng.uniform(0.1, 0.5)) lum += amp * np.sin(2 * np.pi * (fx * x_idx + fy * y_idx) + phase) lum = (lum - lum.min()) / (lum.max() - lum.min() + 1e-8) # Slight overall brightness variation so scenes differ in key lum = lum * float(rng.uniform(0.5, 1.0)) + float(rng.uniform(0.0, 0.15)) return np.clip(lum, 0.0, 1.0).astype(np.float32) # --------------------------------------------------------------------------- # Film curve helpers # --------------------------------------------------------------------------- def _apply_curve(h: np.ndarray, curve: PiecewiseFilmCurve) -> np.ndarray: """Apply PiecewiseFilmCurve to a linear exposure map (H, W) → density (H, W).""" log_h = np.log10(np.clip(h, 1e-8, None)) log_h_t = torch.from_numpy(log_h).float().unsqueeze(0).unsqueeze(0) with torch.no_grad(): d_t = curve(log_h_t) return d_t.squeeze().numpy().astype(np.float32) def _transmittance_to_positive(t: np.ndarray, srgb: bool = True) -> np.ndarray: """ Convert linear transmittance T = 10^(-D) to a positive image. Follows the same inversion the app applies to a negative scan: positive_display = 1 − sRGB_encode(T) so that bright scene exposure → bright positive pixel. """ t_srgb = linear_to_srgb(t) if srgb else t return np.clip(1.0 - t_srgb, 0.0, 1.0).astype(np.float32) # --------------------------------------------------------------------------- # Core case generator # --------------------------------------------------------------------------- def generate_case( seed: int, stock: str = "Generic", ratio: float = 1.0, k1: bool = False, halation: bool = False, jpeg: bool = False, size: int = 64, ) -> dict: """ Generate one synthetic double-exposure case. Args: seed: RNG seed (determines scenes; reproducible for same args). stock: Film stock preset name. ratio: H_a / H_b exposure ratio (≥1; K=1 uses ratio=1000). k1: If True, scene B is black (K=1 single-exposure control). halation: Apply Gaussian halation blur to H_total before the curve. jpeg: Apply JPEG round-trip (quality 85–95) to the raw scan. size: Square image size in pixels. Returns: Dict with keys: scan, gt_a, gt_b, h_a, h_b, h_total, ratio, stock, seed, k1. """ rng = np.random.default_rng(seed) is_color = stock in COLOR_STOCK_PRESETS if is_color: color_curves = get_color_curves(stock) mask_offsets = color_curves.mask_offset_rgb # per-channel curves for forward; scalar curve still used for compatibility keys below curve = get_film_curve("Generic") # placeholder; per-ch used explicitly d_min = float(color_curves.g.d_min) # for grain range, use g as rep d_max = float(color_curves.g.d_max) else: curve = get_film_curve(stock) d_min = float(curve.d_min) d_max = float(curve.d_max) # --- Generate scenes (linear light) --- lum_a = _make_scene(rng, size) # (H, W) linear [0, 1] if k1: lum_b = np.zeros_like(lum_a) ratio = 1000.0 # sentinel for K=1 else: lum_b = _make_scene(rng, size) # --- Scale to exposure ratio and normalize --- # H_a = ratio/(1+ratio) * scale, H_b = 1/(1+ratio) * scale h_a = lum_a * (ratio / (1.0 + ratio)) h_b = lum_b * (1.0 / (1.0 + ratio)) h_total_raw = h_a + h_b # Normalize so 95th-percentile of H_total sits at 0.8 (well inside curve's useful range) p95 = float(np.percentile(h_total_raw[h_total_raw > 1e-6], 95)) if h_total_raw.max() > 1e-6 else 1.0 scale = 0.8 / (p95 + 1e-10) h_a = (h_a * scale).astype(np.float32) h_b = (h_b * scale).astype(np.float32) h_total_raw = (h_total_raw * scale).astype(np.float32) # --- Optional halation (blur H_total before the curve) --- if halation: from scipy.ndimage import gaussian_filter h_total = gaussian_filter(h_total_raw, sigma=max(1.0, size * 0.03)).astype(np.float32) else: h_total = h_total_raw # --- Apply film curve to get density (color: per-channel + mask offset) --- if is_color: h_a_rgb = np.stack([h_a] * 3, axis=-1) h_b_rgb = np.stack([h_b] * 3, axis=-1) h_total_raw_rgb = np.stack([h_total_raw] * 3, axis=-1) h_total_rgb = h_total_raw_rgb if halation: from scipy.ndimage import gaussian_filter h_total_rgb = gaussian_filter(h_total_rgb, sigma=max(1.0, size * 0.03)).astype(np.float32) d_obs_rgb = np.zeros_like(h_total_rgb) for c, ch_curve in enumerate([color_curves.r, color_curves.g, color_curves.b]): d_ch = _apply_curve(h_total_rgb[..., c], ch_curve) d_ch = d_ch + mask_offsets[c] d_obs_rgb[..., c] = d_ch d_obs = d_obs_rgb[..., 1] # green for scalar compatibility # grain per channel d_noisy_rgb = np.zeros_like(d_obs_rgb) for c in range(3): d_ch = d_obs_rgb[..., c] d_range = np.clip((d_ch - d_min) / (d_max - d_min + 1e-8), 0.0, 1.0) grain_sigma = 0.015 * np.sqrt(d_range) grain = rng.normal(0.0, 1.0, d_ch.shape).astype(np.float32) * grain_sigma d_noisy_rgb[..., c] = np.clip(d_ch + grain, d_min, d_max) t_obs_rgb = np.power(10.0, -d_noisy_rgb).astype(np.float32) t_obs_rgb += rng.normal(0.0, 0.003, t_obs_rgb.shape).astype(np.float32) t_obs_rgb = np.clip(t_obs_rgb, 0.0, 1.0) scan = np.stack([linear_to_srgb(t_obs_rgb[..., c]) for c in range(3)], axis=-1).astype(np.float32) else: d_obs = _apply_curve(h_total, curve) # D of combined negative # --- Add density-dependent grain --- d_range = np.clip((d_obs - d_min) / (d_max - d_min + 1e-8), 0.0, 1.0) grain_sigma = 0.015 * np.sqrt(d_range) grain = rng.normal(0.0, 1.0, d_obs.shape).astype(np.float32) * grain_sigma d_noisy = np.clip(d_obs + grain, d_min, d_max) # --- Transmittance + sensor noise → raw scan (sRGB, negative appearance) --- t_obs = np.power(10.0, -d_noisy).astype(np.float32) t_obs += rng.normal(0.0, 0.003, t_obs.shape).astype(np.float32) t_obs = np.clip(t_obs, 0.0, 1.0) raw_scan_linear = t_obs # (H, W) linear transmittance raw_scan_srgb = linear_to_srgb(raw_scan_linear) # brighter where unexposed # Stack to 3-channel grayscale (B&W benchmark; color extension in WP-8) scan = np.stack([raw_scan_srgb] * 3, axis=-1).astype(np.float32) # --- Optional JPEG round-trip --- if jpeg: from PIL import Image quality = int(rng.integers(85, 96)) pil = Image.fromarray((scan * 255.0).clip(0, 255).astype(np.uint8)) buf = BytesIO() pil.save(buf, format="JPEG", quality=quality) buf.seek(0) pil = Image.open(buf) scan = np.asarray(pil, dtype=np.float32) / 255.0 # --- Ground-truth positives (per-scene negative inverted to positive) --- # Same pipeline as the scan but using per-scene H (no halation, no grain/noise for clean GT) if is_color: d_a_rgb = np.zeros((size, size, 3), dtype=np.float32) for c, ch_curve in enumerate([color_curves.r, color_curves.g, color_curves.b]): d_a_rgb[..., c] = _apply_curve(h_a, ch_curve) + mask_offsets[c] t_a_rgb = np.power(10.0, -d_a_rgb).astype(np.float32) gt_a = np.stack([_transmittance_to_positive(t_a_rgb[..., c]) for c in range(3)], axis=-1).astype(np.float32) if k1: gt_b = np.zeros((size, size, 3), dtype=np.float32) else: d_b_rgb = np.zeros((size, size, 3), dtype=np.float32) for c, ch_curve in enumerate([color_curves.r, color_curves.g, color_curves.b]): d_b_rgb[..., c] = _apply_curve(h_b, ch_curve) + mask_offsets[c] t_b_rgb = np.power(10.0, -d_b_rgb).astype(np.float32) gt_b = np.stack([_transmittance_to_positive(t_b_rgb[..., c]) for c in range(3)], axis=-1).astype(np.float32) # scalar keys from green for compatibility h_a = h_a # already h_b = h_b h_total = h_total # add rgb keys extra = { "h_a_rgb": h_a_rgb, "h_b_rgb": h_b_rgb, "h_total_rgb": h_total_rgb, "is_color": True, } else: d_a = _apply_curve(h_a, curve) t_a = np.power(10.0, -d_a).astype(np.float32) gt_a_gray = _transmittance_to_positive(t_a) gt_a = np.stack([gt_a_gray] * 3, axis=-1).astype(np.float32) if k1: gt_b = np.zeros((size, size, 3), dtype=np.float32) else: d_b = _apply_curve(h_b, curve) t_b = np.power(10.0, -d_b).astype(np.float32) gt_b_gray = _transmittance_to_positive(t_b) gt_b = np.stack([gt_b_gray] * 3, axis=-1).astype(np.float32) extra = {} ret = { "scan": scan, "gt_a": gt_a, "gt_b": gt_b, "h_a": h_a, "h_b": h_b, "h_total": h_total, "ratio": float(ratio), "stock": stock, "seed": int(seed), "k1": bool(k1), } ret.update(extra) return ret # --------------------------------------------------------------------------- # Dataset generator (batch) # --------------------------------------------------------------------------- def generate_dataset( n: int = 8, stock: str = "Generic", ratio_min: float = 1.0, ratio_max: float = 8.0, k1_fraction: float = 0.25, halation: bool = False, jpeg: bool = False, seed: int = 42, size: int = 64, ) -> List[dict]: """ Generate ``n`` synthetic cases seeded deterministically. K=1 cases (proportion ``k1_fraction``) are placed at the end so they have consistent indices regardless of total n. """ rng = np.random.default_rng(seed) n_k1 = max(0, round(n * k1_fraction)) n_k2 = n - n_k1 cases = [] # K=2 cases with log-uniform ratio sampling for i in range(n_k2): case_seed = int(rng.integers(0, 2**31)) log_ratio = rng.uniform(np.log(ratio_min), np.log(ratio_max)) ratio = float(np.exp(log_ratio)) cases.append( generate_case( seed=case_seed, stock=stock, ratio=ratio, k1=False, halation=halation, jpeg=jpeg, size=size, ) ) # K=1 control cases for _ in range(n_k1): case_seed = int(rng.integers(0, 2**31)) cases.append( generate_case( seed=case_seed, stock=stock, ratio=1000.0, k1=True, halation=halation, jpeg=jpeg, size=size, ) ) return cases # --------------------------------------------------------------------------- # Persistence # --------------------------------------------------------------------------- def save_case(case: dict, path: str | Path) -> None: """Save a case dict to a .npz file. For color cases, include per-channel rgb keys.""" path = Path(path) path.parent.mkdir(parents=True, exist_ok=True) kwargs = { "scan": case["scan"], "gt_a": case["gt_a"], "gt_b": case["gt_b"], "h_a": case["h_a"], "h_b": case["h_b"], "h_total": case["h_total"], "ratio": np.float32(case["ratio"]), "stock": np.str_(case["stock"]), "seed": np.int64(case["seed"]), "k1": np.bool_(case["k1"]), } if "h_a_rgb" in case: kwargs["h_a_rgb"] = case["h_a_rgb"] kwargs["h_b_rgb"] = case["h_b_rgb"] kwargs["h_total_rgb"] = case["h_total_rgb"] np.savez_compressed(path, **kwargs) def load_case(path: str | Path) -> dict: """Load a case dict from a .npz file. Color cases include per-channel rgb keys if present.""" data = np.load(path, allow_pickle=False) case = { "scan": data["scan"], "gt_a": data["gt_a"], "gt_b": data["gt_b"], "h_a": data["h_a"], "h_b": data["h_b"], "h_total": data["h_total"], "ratio": float(data["ratio"]), "stock": str(data["stock"]), "seed": int(data["seed"]), "k1": bool(data["k1"]), } if "h_a_rgb" in data: case["h_a_rgb"] = data["h_a_rgb"] case["h_b_rgb"] = data["h_b_rgb"] case["h_total_rgb"] = data["h_total_rgb"] return case def load_fixtures(fixtures_dir: str | Path | None = None) -> List[dict]: """Load all .npz fixture cases from the fixtures directory.""" if fixtures_dir is None: fixtures_dir = Path(__file__).parent / "fixtures" fixtures_dir = Path(fixtures_dir) paths = sorted(fixtures_dir.glob("case_*.npz")) if not paths: raise FileNotFoundError(f"No fixture cases found in {fixtures_dir}") return [load_case(p) for p in paths] def select_k2_cases(cases: List[dict], limit: int | None = None) -> List[dict]: """The working set for a benchmark run: the K=2 cases, optionally capped to `limit`. Canonical home for the K2-filter + cap that both benches (double_dip, refine_bench) consume — added in WP-1.2 so the two harnesses share one selection policy. """ k2 = [c for c in cases if not c.get("k1", False)] if limit is not None and len(k2) > limit: k2 = k2[:limit] return k2 # --------------------------------------------------------------------------- # CLI # --------------------------------------------------------------------------- def _cli() -> None: parser = argparse.ArgumentParser( description="Generate synthetic double-exposure benchmark cases (WP-1)." ) parser.add_argument("--n", type=int, default=50, help="Number of cases to generate") parser.add_argument("--stock", type=str, default="Generic", help="Film stock preset") parser.add_argument("--ratio-min", type=float, default=1.0, dest="ratio_min") parser.add_argument("--ratio-max", type=float, default=8.0, dest="ratio_max") parser.add_argument("--k1-fraction", type=float, default=0.1, dest="k1_fraction", help="Fraction of cases that are K=1 single-exposure controls") parser.add_argument("--halation", action="store_true") parser.add_argument("--jpeg", action="store_true") parser.add_argument("--seed", type=int, default=42) parser.add_argument("--size", type=int, default=256, help="Image size (pixels, square)") parser.add_argument("--output-dir", type=str, default="synth/data", dest="output_dir") args = parser.parse_args() cases = generate_dataset( n=args.n, stock=args.stock, ratio_min=args.ratio_min, ratio_max=args.ratio_max, k1_fraction=args.k1_fraction, halation=args.halation, jpeg=args.jpeg, seed=args.seed, size=args.size, ) out_dir = Path(args.output_dir) out_dir.mkdir(parents=True, exist_ok=True) for i, case in enumerate(cases): save_case(case, out_dir / f"case_{i:03d}.npz") n_k1 = sum(1 for c in cases if c["k1"]) print( f"Generated {len(cases)} cases ({len(cases)-n_k1} K=2, {n_k1} K=1) " f"→ {out_dir}/" ) if __name__ == "__main__": _cli()