double-exposure / app /preprocessing.py
Eddie Faillace
double-exposure app deploy snapshot 2026-07-21 (WP-24 calibration pass)
7dff04f
Raw
History Blame Contribute Delete
15 kB
"""Preprocessing pipeline for scanned film negatives."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Optional, Tuple
import numpy as np
import torch
import warnings
from PIL import Image, ImageOps
# WP-11 Fix A: oversized-upload cap (50 MP) — enforced locally in
# _guard_intake_size. PIL's own decompression-bomb limit (~178 MP error) is
# left at its default: mutating Image.MAX_IMAGE_PIXELS at import would be
# process-global state, and the removed line actually *raised* PIL's
# threshold. Uploads beyond PIL's limit raise DecompressionBombError, which
# process_negative's try/except turns into a clean error message.
INTAKE_MAX_MEGAPIXELS: float = 50.0
@dataclass
class PreprocessedNegative:
"""Container for a preprocessed negative scan."""
# --- Existing fields (do not remove or rename) ---
rgb: np.ndarray # float32, shape (H, W, 3), range [0, 1]
luminance: np.ndarray # float32, shape (H, W), range [0, 1]
log_exposure: torch.Tensor # shape (1, 1, H, W)
was_inverted: bool
original_size: Tuple[int, int]
# --- WP-2 densitometry fields (None when densitometry unavailable) ---
# NOTE: outputs are RELATIVE — see densitometry.py module docstring.
density: Optional[np.ndarray] = field(default=None) # (H, W) float32, D_physical
h_total: Optional[np.ndarray] = field(default=None) # (H, W) float32, linear exposure
confidence_mask: Optional[np.ndarray] = field(default=None) # (H, W) uint8, TOE=0/VALID=1/SHOULDER=2
# --- WP-8 color fields (additive) ---
density_rgb: Optional[np.ndarray] = field(default=None) # (H, W, 3)
h_total_rgb: Optional[np.ndarray] = field(default=None) # (H, W, 3)
confidence_mask_rgb: Optional[np.ndarray] = field(default=None) # (H, W, 3)
is_color: bool = field(default=False)
# --- WP-11 post-review: fraction bbox of the auto_trim crop, relative to
# the pre-trim working image (top, bottom, left, right in [0,1]). None when
# auto_trim is off or trimmed nothing. Consumers that re-derive geometry
# from the ORIGINAL upload (WP-12 full-res export) must apply this crop,
# or their guide image will include the border the working images lost.
trim_bbox_frac: Optional[Tuple[float, float, float, float]] = field(default=None)
# --- WP-11.1: densitometry anchor actually used (None in linear mode).
# Consumed by full-res export so work-res and full-res share the same anchor.
d_min_override_used: Optional[float] = field(default=None)
# --- WP-11.1 post-review: physics polarity of the upload. True ONLY when
# the user explicitly declared scan_type="positive" — never set by the
# display heuristic (was_inverted is a separate, display-only concern).
# Consumed by full-res export so all densitometry entry points agree.
physics_is_positive: bool = field(default=False)
_HIGH_BIT_MODES = frozenset({"I;16", "I;16B", "I;16L", "I;16N", "I", "F"})
def _to_float_rgb(image: Image.Image) -> np.ndarray:
"""Load PIL image to float32 RGB in [0, 1], preserving 16-bit/float precision.
8-bit path (RGB/L/etc.) stays byte-identical to convert("RGB")/255.
16-bit int modes scale by 65535; float mode normalizes by its own max.
"""
mode = image.mode
if mode in _HIGH_BIT_MODES:
arr = np.asarray(image)
if arr.ndim == 2:
# Single-channel high-bit → replicate to RGB
if mode == "F":
mx = float(np.max(arr)) if arr.size else 1.0
mx = mx if mx > 1e-12 else 1.0
ch = np.clip(arr.astype(np.float32) / mx, 0.0, 1.0)
else:
# 16-bit / 32-bit int modes
ch = np.clip(arr.astype(np.float32) / 65535.0, 0.0, 1.0)
return np.stack([ch, ch, ch], axis=-1).astype(np.float32)
# Multi-channel rare high-bit path
if mode == "F":
mx = float(np.max(arr)) if arr.size else 1.0
mx = mx if mx > 1e-12 else 1.0
out = np.clip(arr.astype(np.float32) / mx, 0.0, 1.0)
else:
out = np.clip(arr.astype(np.float32) / 65535.0, 0.0, 1.0)
if out.ndim == 2:
out = np.stack([out, out, out], axis=-1)
elif out.shape[-1] == 1:
out = np.repeat(out, 3, axis=-1)
elif out.shape[-1] > 3:
out = out[..., :3]
return out.astype(np.float32)
# 8-bit and ordinary modes — byte-identical to previous convert("RGB")/255
arr = np.asarray(image.convert("RGB"), dtype=np.float32) / 255.0
return arr
def _guard_intake_size(image: Image.Image) -> Image.Image:
"""Downscale uploads beyond INTAKE_MAX_MEGAPIXELS (never OOM). Warns once."""
w, h = image.size
mp = (w * h) / 1_000_000.0
if mp <= INTAKE_MAX_MEGAPIXELS:
return image
scale = (INTAKE_MAX_MEGAPIXELS * 1_000_000.0 / (w * h)) ** 0.5
nw, nh = max(1, int(w * scale)), max(1, int(h * scale))
warnings.warn(
f"Upload {w}×{h} ({mp:.1f} MP) exceeds intake cap "
f"{INTAKE_MAX_MEGAPIXELS:.0f} MP; downscaling to {nw}×{nh}."
)
return image.resize((nw, nh), Image.Resampling.LANCZOS)
def _rgb_to_luminance(rgb: np.ndarray) -> np.ndarray:
# Rec. 709 luma
return (
0.2126 * rgb[..., 0] + 0.7152 * rgb[..., 1] + 0.0722 * rgb[..., 2]
).astype(np.float32)
def trim_uniform_border(
rgb: np.ndarray,
tol: float = 1e-3,
max_frac: float = 0.25,
) -> Tuple[np.ndarray, Tuple[int, int, int, int]]:
"""Drop near-uniform outer rows/cols (rebate / letterbox), capped by max_frac.
A row/col is "uniform" if its per-channel std is below ``tol``. Never removes
more than ``max_frac`` of each edge. Returns (cropped_rgb, (top, bottom, left, right)
where bottom/right are exclusive indices into the original).
Conservative and opt-in (WP-11 Fix D). Default preprocess path never calls this.
"""
h, w = rgb.shape[:2]
max_t = int(h * max_frac)
max_b = int(h * max_frac)
max_l = int(w * max_frac)
max_r = int(w * max_frac)
def _row_uniform(i: int) -> bool:
return float(np.std(rgb[i])) < tol
def _col_uniform(j: int) -> bool:
return float(np.std(rgb[:, j])) < tol
top = 0
while top < max_t and top < h - 1 and _row_uniform(top):
top += 1
bottom = h
while (h - bottom) < max_b and bottom > top + 1 and _row_uniform(bottom - 1):
bottom -= 1
left = 0
while left < max_l and left < w - 1 and _col_uniform(left):
left += 1
right = w
while (w - right) < max_r and right > left + 1 and _col_uniform(right - 1):
right -= 1
# Never return empty
if bottom <= top or right <= left:
return rgb, (0, h, 0, w)
return rgb[top:bottom, left:right].copy(), (top, bottom, left, right)
def _detect_negative_inversion(luminance: np.ndarray) -> bool:
"""Two-statistic negative-scan heuristic (WP-11 Fix B).
Treat as negative if both:
- mean(lum) > 0.55 (bright overall — orange mask / inverted image)
- median(lum) > 0.50 (mass in the bright half, not just a bright outlier)
Pure function of the luminance array. Manual ``scan_type`` override is
preferred when the user knows; this heuristic only runs for ``"auto"``.
"""
mean_l = float(luminance.mean())
med_l = float(np.median(luminance))
return mean_l > 0.55 and med_l > 0.50
def _normalize01(arr: np.ndarray, percentile: float = 99.5) -> np.ndarray:
lo = float(np.percentile(arr, 0.5))
hi = float(np.percentile(arr, percentile))
if hi - lo < 1e-6:
return np.clip(arr, 0.0, 1.0)
out = (arr - lo) / (hi - lo)
return np.clip(out, 0.0, 1.0).astype(np.float32)
def luminance_to_log_exposure(luminance: np.ndarray) -> torch.Tensor:
"""
Map normalized luminance (proxy for transmitted light) to log-exposure.
For a negative, darker areas = more exposure on film. After inversion
during preprocessing, higher luminance ≈ more original scene exposure.
"""
exposure = np.clip(luminance, 1e-4, 1.0)
log_h = np.log10(exposure)
tensor = torch.from_numpy(log_h).float().unsqueeze(0).unsqueeze(0)
return tensor
def preprocess_negative(
image: Image.Image,
max_side: int = 1536,
stock: str = "Generic",
scan_type: str = "auto",
scan_calibration: str = "linear",
auto_trim: bool = False,
mask_mode: str = "density_margin",
) -> PreprocessedNegative:
"""
Load, optionally resize, detect inversion, normalize, and build exposure map.
Also runs the WP-2 densitometry pipeline to populate ``density``,
``h_total``, and ``confidence_mask`` on the returned object. These
fields are set to None on any error so the UI keeps working.
Args:
image: PIL image from user upload.
max_side: Longest edge after resize (preserves aspect ratio).
stock: Film stock preset name (used for D_min fallback).
scan_type: ``"auto"`` (heuristic), ``"positive"`` (never invert),
``"negative"`` (always invert). Default ``"auto"``.
scan_calibration: ``"linear"`` (default — synthetic fixtures, white-point
estimate cancels) or ``"auto_exposed"`` (pass stock D_min as
``d_min_override`` for real auto-exposed scanners).
auto_trim: If True, trim uniform border (default False — never silent crop).
mask_mode: confidence-mask criterion, forwarded to densitometry —
"density_margin" (default, legacy WP-2 contract) or "slope" (VALID
reflects actual H reliability; the app path opts in).
Returns:
PreprocessedNegative ready for physics scoring and API calls.
"""
# WP-11 Fix A: honor EXIF orientation first, then size-guard oversized uploads
image = ImageOps.exif_transpose(image) or image
image = _guard_intake_size(image)
original_size = image.size
w, h = image.size
scale = min(1.0, max_side / max(w, h))
if scale < 1.0:
image = image.resize(
(int(w * scale), int(h * scale)), Image.Resampling.LANCZOS
)
rgb = _to_float_rgb(image)
# WP-11 Fix D: opt-in uniform border trim (default OFF)
trim_bbox_frac: Optional[Tuple[float, float, float, float]] = None
if auto_trim:
pre_h, pre_w = rgb.shape[:2]
rgb, bbox = trim_uniform_border(rgb)
if bbox != (0, pre_h, 0, pre_w):
top, bottom, left, right = bbox
trim_bbox_frac = (
top / pre_h, bottom / pre_h, left / pre_w, right / pre_w
)
# Pristine scan for densitometry: density must be computed from the raw
# sRGB scan, BEFORE inversion/normalization (MASTERPLAN Part I.3 / red
# flag (d) — percentile stretching destroys density information).
raw_scan_rgb = rgb
luminance = _rgb_to_luminance(rgb)
# WP-11 Fix B: scan_type override wins over heuristic
st = (scan_type or "auto").lower()
# WP-11.1: physics polarity comes ONLY from the explicit declaration
physics_is_positive = st == "positive"
if st == "positive":
was_inverted = False
elif st == "negative":
was_inverted = True
else:
was_inverted = _detect_negative_inversion(luminance)
if was_inverted:
rgb = 1.0 - rgb
luminance = 1.0 - luminance
rgb = _normalize01(rgb)
luminance = _normalize01(luminance)
log_exposure = luminance_to_log_exposure(luminance)
# --- WP-2: densitometry (soft — falls back gracefully) ---
density_map: Optional[np.ndarray] = None
h_total_map: Optional[np.ndarray] = None
conf_mask: Optional[np.ndarray] = None
density_rgb: Optional[np.ndarray] = None
h_total_rgb: Optional[np.ndarray] = None
conf_mask_rgb: Optional[np.ndarray] = None
is_c: bool = False
d_min_override_used: Optional[float] = None
try:
from densitometry import (
scan_to_density,
density_to_h_total,
scan_to_density_rgb,
density_to_h_total_rgb,
combine_confidence_rgb,
prepare_densitometry_input,
)
from film_physics import get_film_curve, get_color_curves, COLOR_STOCK_PRESETS
is_c = stock in COLOR_STOCK_PRESETS
# WP-11 Fix C: auto_exposed pins D_min to stock preset (real scanners)
d_min_override: Optional[float] = None
cal = (scan_calibration or "linear").lower()
if cal == "auto_exposed":
if is_c:
d_min_override = float(get_color_curves(stock).g.d_min.item())
else:
d_min_override = float(get_film_curve(stock).d_min.item())
d_min_override_used = d_min_override
# WP-11.1: canonical physics-polarity policy (one place: densitometry).
# auto/negative return raw_scan_rgb unchanged — byte-identical to today.
dens_scan_rgb, dens_curves = prepare_densitometry_input(
raw_scan_rgb, stock, positive_source=physics_is_positive
)
if is_c:
density_map_rgb = scan_to_density_rgb(
dens_scan_rgb, stock, d_min_override=d_min_override, curves=dens_curves
)
h_total_map_rgb, conf_mask_rgb = density_to_h_total_rgb(
density_map_rgb, dens_curves, mask_mode=mask_mode
)
conf_mask = combine_confidence_rgb(conf_mask_rgb)
density_map = density_map_rgb[..., 1]
h_total_map = h_total_map_rgb[..., 1]
density_rgb = density_map_rgb
h_total_rgb = h_total_map_rgb
conf_mask_rgb = conf_mask_rgb
else:
curve = get_film_curve(stock)
density_map, _ = scan_to_density(
dens_scan_rgb, stock=stock, d_min_override=d_min_override
)
h_total_map, conf_mask = density_to_h_total(density_map, curve, mask_mode=mask_mode)
except Exception as exc:
warnings.warn(f"densitometry failed: {exc}") # Fix 3: surface degraded mode
# Keep existing fields working; densitometry fields stay None (no dead pass)
return PreprocessedNegative(
rgb=rgb,
luminance=luminance,
log_exposure=log_exposure,
was_inverted=was_inverted,
original_size=original_size,
density=density_map,
h_total=h_total_map,
confidence_mask=conf_mask,
density_rgb=density_rgb,
h_total_rgb=h_total_rgb,
confidence_mask_rgb=conf_mask_rgb,
is_color=is_c,
trim_bbox_frac=trim_bbox_frac,
d_min_override_used=d_min_override_used,
physics_is_positive=physics_is_positive,
)
def to_pil(rgb: np.ndarray) -> Image.Image:
"""Convert float RGB [0,1] to PIL Image."""
arr = (np.clip(rgb, 0.0, 1.0) * 255.0).astype(np.uint8)
return Image.fromarray(arr, mode="RGB")