double-exposure / synth /generate.py
Eddie Faillace
double-exposure app deploy snapshot 2026-07-21 (WP-24 calibration pass)
7dff04f
Raw
History Blame Contribute Delete
19.3 kB
"""
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()