File size: 14,950 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
363
364
365
366
367
368
369
370
371
372
373
374
375
"""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")