Spaces:
Running on Zero
Running on Zero
File size: 14,143 Bytes
7dff04f | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 | """
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
|