Spaces:
Running on Zero
Running on Zero
| """ | |
| Densitometry pipeline for scanned film negatives (MASTERPLAN WP-2 / Part III L0). | |
| Converts an sRGB-encoded film scan into optical density D and from there into | |
| linear total exposure H_total = 10^(f⁻¹(D)). | |
| IMPORTANT — outputs are RELATIVE, not absolute: | |
| The scanner has an unknown tone curve and the Callier effect introduces a | |
| per-setup gain/gamma shift between collimated (densitometer) and diffuse | |
| (scanner) illumination. Absolute calibration (nuisance gains g₁, g₂) is | |
| deferred to WP-3. Every quantity returned here is accurate only up to a | |
| global scale factor. | |
| """ | |
| from __future__ import annotations | |
| from dataclasses import dataclass | |
| from typing import Tuple | |
| import numpy as np | |
| from film_physics import ( | |
| PiecewiseFilmCurve, | |
| get_film_curve, | |
| get_color_curves, | |
| ColorNegativeCurves, | |
| COLOR_STOCK_PRESETS, | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # Mask label constants | |
| # --------------------------------------------------------------------------- | |
| TOE: int = 0 # Near D_min — noisy, H only a lower bound | |
| VALID: int = 1 # Reliable region — curve well-constrained | |
| SHOULDER: int = 2 # Near D_max — saturated, H only a lower bound | |
| # --------------------------------------------------------------------------- | |
| # sRGB transfer functions (canonical — synth/generate.py imports from here) | |
| # --------------------------------------------------------------------------- | |
| def srgb_to_linear(img: np.ndarray) -> np.ndarray: | |
| """Inverse sRGB EOTF (IEC 61966-2-1). Input/output in [0, 1].""" | |
| img = np.clip(img, 0.0, 1.0) | |
| return np.where( | |
| img <= 0.04045, | |
| img / 12.92, | |
| ((img + 0.055) / 1.055) ** 2.4, | |
| ).astype(np.float32) | |
| def linear_to_srgb(img: np.ndarray) -> np.ndarray: | |
| """sRGB forward EOTF. Input/output in [0, 1].""" | |
| img = np.clip(img, 0.0, 1.0) | |
| return np.where( | |
| img <= 0.0031308, | |
| 12.92 * img, | |
| 1.055 * img ** (1.0 / 2.4) - 0.055, | |
| ).astype(np.float32) | |
| def luminance_from_linear(lin_rgb: np.ndarray) -> np.ndarray: | |
| """Rec. 709 luminance from linear RGB (H, W, 3) → (H, W) float32.""" | |
| return ( | |
| 0.2126 * lin_rgb[..., 0] | |
| + 0.7152 * lin_rgb[..., 1] | |
| + 0.0722 * lin_rgb[..., 2] | |
| ).astype(np.float32) | |
| def phi_display(rgb: np.ndarray) -> np.ndarray: | |
| """φ = luminance_from_linear(srgb_to_linear(·)) — canonical display→exposure proxy. | |
| Single public φ used by demix, asymmetric recovery, and fullres (WP-14.1 P1). | |
| """ | |
| return luminance_from_linear(srgb_to_linear(np.asarray(rgb, dtype=np.float32))) | |
| # --------------------------------------------------------------------------- | |
| # scan_to_density | |
| # --------------------------------------------------------------------------- | |
| def scan_to_density( | |
| scan_srgb: np.ndarray, | |
| stock: str = "Generic", | |
| white_level: float | None = None, | |
| d_min_override: float | None = None, | |
| ) -> Tuple[np.ndarray, np.ndarray]: | |
| """ | |
| Convert an sRGB-encoded negative scan to optical density. | |
| Algorithm: | |
| 1. Linearise via inverse sRGB EOTF. | |
| 2. D_physical = −log₁₀(T_lum), assuming a calibrated linear scan where | |
| pixel value 1.0 = open-gate (no film) light. | |
| NOTE (found in Fable review): with the default estimated white point, | |
| D_obs + D_white = −log₁₀(lum/T_white) − log₁₀(T_white) = −log₁₀(lum) — | |
| the white-point term cancels by construction. That is correct for | |
| calibrated linear scans (incl. the WP-1 synthetic fixtures). Real | |
| auto-exposed scanners need a different anchor: pass ``d_min_override`` | |
| (typically the stock's preset D_min) to pin the clearest film area, | |
| which does NOT cancel. WP-11 (real-scan intake) should choose the mode | |
| from scan metadata/heuristics. | |
| Args: | |
| scan_srgb: (H, W, 3) float32 sRGB-encoded scan in [0, 1]. | |
| stock: Film stock name (for preset D_min fallback). | |
| white_level: If provided, use as T_white directly (skip estimation). | |
| d_min_override: If provided, use as D_min instead of the white-point | |
| estimate (useful for bench-calibrated scanners). | |
| Returns: | |
| d_physical: (H, W) float32 — optical density in D units. | |
| scan_linear: (H, W, 3) float32 — linearised scan (for diagnostics). | |
| Note: outputs are RELATIVE (see module docstring). | |
| """ | |
| scan_linear = srgb_to_linear(scan_srgb) # (H, W, 3) | |
| lum = luminance_from_linear(scan_linear) # (H, W) | |
| # --- White-point estimation --- | |
| if white_level is None: | |
| flat = lum[lum > 1e-6] | |
| if flat.size == 0: | |
| flat = lum.ravel() | |
| t_white = float(np.percentile(flat, 99.5)) | |
| # Sanity check: if nearly all pixels are very dark, fall back to preset | |
| if t_white < 0.05: | |
| curve_preset = get_film_curve(stock) | |
| t_white = float(10.0 ** (-float(curve_preset.d_min))) | |
| else: | |
| t_white = float(white_level) | |
| t_white = max(t_white, 1e-6) | |
| # --- Density from white-point-normalised transmittance --- | |
| d_white: float | |
| if d_min_override is not None: | |
| d_white = float(d_min_override) | |
| else: | |
| d_white = float(-np.log10(t_white)) # estimated D_min from the scan | |
| t_norm = np.clip(lum / t_white, 1e-8, 1.0) | |
| d_obs = -np.log10(t_norm) # relative density [0, ...] | |
| d_physical = (d_obs + d_white).astype(np.float32) | |
| return d_physical, scan_linear | |
| # --------------------------------------------------------------------------- | |
| # density_to_h_total | |
| # --------------------------------------------------------------------------- | |
| # Slope fraction below which the curve no longer meaningfully encodes exposure: | |
| # where dD/dlogH < SLOPE_VALID_FRAC * gamma, a density step of one 8-bit JPEG code | |
| # maps to a multi-stop H error, so the pixel belongs in TOE/SHOULDER, not VALID. | |
| SLOPE_VALID_FRAC = 0.25 | |
| _SLOPE_BOUNDS_CACHE: dict = {} | |
| def _slope_valid_bounds(curve: PiecewiseFilmCurve) -> Tuple[float, float]: | |
| """(D_lo, D_hi) between which the curve's local slope >= SLOPE_VALID_FRAC*gamma. | |
| Cached on the curve object. Fixes the density-margin mask defect (2026-07-16 | |
| finding): 5%-of-density-range margins leave "VALID" spanning ~11 stops on | |
| Portra because the curve is asymptotically flat near d_max — D=1.25 -> H=19.7 | |
| but D=1.2855 -> H=69.9. Slope is the honest reliability criterion. | |
| """ | |
| # WP-18 D3b: get_film_curve constructs a FRESH curve per call, so a per-object | |
| # attribute cache never hits. Key the cache on the curve's parameter tuple — | |
| # shared across all instances of the same preset (and immune to threading: | |
| # worst case two threads compute the same value once). | |
| key = ( | |
| float(curve.gamma), float(curve.d_min), float(curve.d_max), | |
| float(curve.toe_strength), float(curve.shoulder_strength), | |
| float(curve.toe_width), float(curve.shoulder_width), | |
| ) | |
| cached = _SLOPE_BOUNDS_CACHE.get(key) | |
| if cached is not None: | |
| return cached | |
| import torch | |
| log_h = torch.linspace(-4.5, 3.5, 2048) | |
| with torch.no_grad(): | |
| d = curve.forward(log_h).cpu().numpy().astype(np.float64) | |
| slope = np.gradient(d, log_h.numpy().astype(np.float64)) | |
| ok = slope >= SLOPE_VALID_FRAC * float(curve.gamma) | |
| if ok.any(): | |
| idx = np.nonzero(ok)[0] | |
| bounds = (float(d[idx[0]]), float(d[idx[-1]])) | |
| else: # degenerate curve: fall back to the full density range | |
| bounds = (float(curve.d_min), float(curve.d_max)) | |
| _SLOPE_BOUNDS_CACHE[key] = bounds | |
| return bounds | |
| def density_to_h_total( | |
| d_physical: np.ndarray, | |
| curve: PiecewiseFilmCurve, | |
| mask_mode: str = "density_margin", | |
| ) -> Tuple[np.ndarray, np.ndarray]: | |
| """ | |
| Convert optical density to linear total exposure via the inverse H-D curve. | |
| Args: | |
| d_physical: (H, W) float32 optical density array. | |
| curve: Film characteristic curve (must have ``inverse()`` method). | |
| mask_mode: "density_margin" (default — WP-2 contract, 5% margins of the | |
| density range, byte-identical legacy behavior) or "slope" | |
| (VALID only where the curve's local slope >= 25% of gamma, so | |
| the mask reflects actual H reliability; the app path opts in). | |
| Returns: | |
| h_total: (H, W) float32 — linear exposure (relative, up to global scale). | |
| confidence_mask: (H, W) uint8 — TOE=0 / VALID=1 / SHOULDER=2. | |
| Note: outputs are RELATIVE (see module docstring). | |
| """ | |
| d_min = float(curve.d_min) | |
| d_max = float(curve.d_max) | |
| # Invert the characteristic curve | |
| d_clamped = np.clip(d_physical, d_min, d_max).astype(np.float32) | |
| log_h = curve.inverse(d_clamped) # (H, W) float32 | |
| h_total = np.power(10.0, log_h).astype(np.float32) | |
| if mask_mode == "slope": | |
| d_lo, d_hi = _slope_valid_bounds(curve) | |
| else: | |
| # Confidence mask — 5% margins from d_min / d_max (legacy WP-2 contract) | |
| margin = 0.05 * (d_max - d_min) | |
| d_lo, d_hi = d_min + margin, d_max - margin | |
| mask = np.where( | |
| d_physical < d_lo, TOE, | |
| np.where(d_physical > d_hi, SHOULDER, VALID), | |
| ).astype(np.uint8) | |
| return h_total, mask | |
| # --------------------------------------------------------------------------- | |
| # Physics polarity — WP-11.1 post-review, the ONE place the policy lives | |
| # --------------------------------------------------------------------------- | |
| def prepare_densitometry_input( | |
| scan_srgb: np.ndarray, | |
| stock: str, | |
| positive_source: bool = False, | |
| ) -> Tuple[np.ndarray, ColorNegativeCurves | None]: | |
| """Canonical physics-polarity contract for every densitometry entry point. | |
| Densitometry assumes NEGATIVE polarity (dark = dense = high exposure). A | |
| lab-inverted positive is un-inverted in sRGB display space — the lab | |
| inversion is an involution, so ``1 − pos`` reconstructs the negative scan | |
| byte-exactly on uint8 (WP-11.1). Color positives already lack the orange | |
| mask (the lab removed it), so the returned curves carry zero mask offsets. | |
| For negative-polarity input the array is returned UNCHANGED (the same | |
| object), keeping the default synthetic path byte-identical. | |
| Returns: | |
| (dens_scan, curves) — ``curves`` is None for B&W stocks; for color | |
| stocks it is the object to pass as ``curves=`` to | |
| ``scan_to_density_rgb`` / ``density_to_h_total_rgb``. | |
| """ | |
| dens_scan = (1.0 - scan_srgb) if positive_source else scan_srgb | |
| curves: ColorNegativeCurves | None = None | |
| if stock in COLOR_STOCK_PRESETS: | |
| curves = get_color_curves(stock) | |
| if positive_source: | |
| curves = ColorNegativeCurves( | |
| r=curves.r, g=curves.g, b=curves.b, | |
| mask_offset_rgb=(0.0, 0.0, 0.0), | |
| ) | |
| return dens_scan, curves | |
| # --------------------------------------------------------------------------- | |
| # RGB (color negative) densitometry — WP-8, ADDITIVE ONLY | |
| # Existing scalar functions and signatures untouched. | |
| # RGB versions LOOP the scalar ones per channel; no reimplementation of math. | |
| # --------------------------------------------------------------------------- | |
| def scan_to_density_rgb( | |
| scan_srgb: np.ndarray, | |
| color_stock: str, | |
| white_level: float | None = None, | |
| curves: ColorNegativeCurves | None = None, | |
| d_min_override: float | None = None, | |
| ) -> np.ndarray: | |
| """Per-channel density for color negative. | |
| Default (``d_min_override is None``): absolute density path | |
| lin = srgb_to_linear(scan) | |
| d_abs_c = −log10(clip(lin_c, 1e-6, 1.0)) | |
| d_phys_c = clip(d_abs_c − mask_offset_c, 0, None) | |
| When ``d_min_override`` is set (WP-11 auto_exposed): relative densitometry | |
| per channel with the stock D_min anchor (same cancel-safe path as scalar | |
| ``scan_to_density``), then subtract mask offsets. Signature-compatible; | |
| default path is byte-identical when override is None. | |
| """ | |
| if curves is None: | |
| curves = get_color_curves(color_stock) | |
| offsets = curves.mask_offset_rgb | |
| d_rgb = np.zeros(scan_srgb.shape, dtype=np.float32) | |
| if d_min_override is not None: | |
| # Auto-exposed scanners: per-channel relative density + stock D_min | |
| for c in range(3): | |
| mono = np.stack([scan_srgb[..., c]] * 3, axis=-1) | |
| d_c, _ = scan_to_density( | |
| mono, | |
| stock=color_stock, | |
| white_level=white_level, | |
| d_min_override=float(d_min_override), | |
| ) | |
| d_rgb[..., c] = np.clip(d_c - offsets[c], 0.0, None).astype(np.float32) | |
| return d_rgb | |
| # Default absolute path (unchanged for synthetic / linear calibration) | |
| scan_linear = srgb_to_linear(scan_srgb) # (H, W, 3) | |
| for c in range(3): | |
| lin_c = scan_linear[..., c] | |
| d_abs_c = -np.log10(np.clip(lin_c, 1e-6, 1.0)) | |
| d_phys_c = np.clip(d_abs_c - offsets[c], 0.0, None) | |
| d_rgb[..., c] = d_phys_c.astype(np.float32) | |
| return d_rgb | |
| def density_to_h_total_rgb( | |
| d_rgb: np.ndarray, | |
| curves: ColorNegativeCurves, | |
| mask_mode: str = "density_margin", | |
| ) -> Tuple[np.ndarray, np.ndarray]: | |
| """Per-channel h_total and confidence (loops existing density_to_h_total).""" | |
| h_list = [] | |
| conf_list = [] | |
| for c, curve in enumerate([curves.r, curves.g, curves.b]): | |
| h_c, m_c = density_to_h_total(d_rgb[..., c], curve, mask_mode=mask_mode) | |
| h_list.append(h_c) | |
| conf_list.append(m_c) | |
| h_total_rgb = np.stack(h_list, axis=2).astype(np.float32) | |
| conf_rgb = np.stack(conf_list, axis=2).astype(np.uint8) | |
| return h_total_rgb, conf_rgb | |
| def combine_confidence_rgb(mask_rgb: np.ndarray) -> np.ndarray: | |
| """Scalar confidence: TOE if any channel TOE; SHOULDER if any SHOULDER; else VALID.""" | |
| has_toe = np.any(mask_rgb == TOE, axis=2) | |
| has_shoulder = np.any(mask_rgb == SHOULDER, axis=2) | |
| out = np.full(mask_rgb.shape[:2], VALID, dtype=np.uint8) | |
| out[has_toe] = TOE | |
| out[has_shoulder & ~has_toe] = SHOULDER | |
| return out | |