Spaces:
Running on Zero
Running on Zero
| """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 | |
| 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") | |