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