From e116d28495ec7df51e21a9980280877a09242977 Mon Sep 17 00:00:00 2001 From: Sean Harding Date: Sun, 11 Oct 2026 15:22:10 -0500 Subject: [PATCH] fix(exposure): scale the luma window's reach by each channel's own range The bounds analysis widened every channel's color refs to the luma window by the same amount in log units. On a negative whose channels differ in density range, that reach is a different fraction of each channel's span, so a gray scale normalizes to a different value per channel. A faded 126 negative showed it once the sensor unmix was on: unmixed, its green range is about 3.5 times blue's (0.67 against 0.19 log), and the frame rendered yellow-green. With the unmix off, the crosstalk blends the channels and hides the difference. The reach is now a fraction of each channel's span between its color refs, averaged over the channels. The window keeps its mean-minus-median base offset, so the tone-reproduction numbers do not move. Real frames, whose channel ranges are never exactly equal, shift by a few levels out of 255 when their bounds are next measured; saved edits keep their stored bounds. Bumps THUMBNAIL_RENDER_VERSION; recording its golden also picked up the autocrop_square and autocrop_squared defaults it was missing. --- docs/PIPELINE.md | 2 +- negpy/features/exposure/normalization.py | 26 ++++++--- .../services/assets/thumbnail_fingerprint.py | 2 +- tests/test_same_pixel_bounds.py | 55 +++++++++++++------ tests/thumbnail_fingerprint_defaults.json | 4 +- 5 files changed, 62 insertions(+), 27 deletions(-) diff --git a/docs/PIPELINE.md b/docs/PIPELINE.md index c750421e2..721086a16 100644 --- a/docs/PIPELINE.md +++ b/docs/PIPELINE.md @@ -66,7 +66,7 @@ These steps run in order. Each stage passes its buffer to the next. A helper nam * **Negative (Color Negative / B&W Negative)**: raw low signal (dense highlights) maps to Floor ($0.0$). Raw high signal (film base, shadows) maps to Ceiling ($1.0$). Range: 0.01% to 99.99%. * **Transparency** takes none of this metering. See *Transparency transfer* in ยง3. - Bounds are sampled on **two independent axes** (`_sample_log_bounds`). A **luma** pass fixes the floor and ceiling mean (center plus span), and a **color** pass fixes each channel's deviation from it, so color balance is tunable without compressing the luminance range. Mono gives zero deviation at any clip. + Bounds are sampled on **two independent axes** (`_sample_log_bounds`). A **color** pass reads each channel's floor and ceiling reference, and a **luma** pass sets how far the window reaches past them, so color balance is tunable without compressing the luminance range. The reach is a fraction of each channel's own span $s_c$ between its references, averaged over the channels: $f_c = r_c^f + (\bar h_f + k)\,s_c$, with $\bar h_f$ the mean of $(l_c^f - r_c^f)/s_c$ and the ceiling alike. A film whose channels differ in density range, a faded negative for example, keeps a gray scale neutral. $k$ is the base's mean-minus-median channel offset over the mean span. Mono gives zero deviation at any clip. * **Luma Range Clip** (`luma_range_clip`): the *luminance* percentile window. **Positive** values tighten it symmetrically. **Zero** uses outlier-resistant extremes: a block-median prefilter rejects dust and speculars, and a small base clip excludes tiny outlier populations. **Negative** values push the bounds *outward*, leaving lifted blacks and unclipped highlights as headroom. * **Color Clip** (`color_range_clip`): the sampling depth for per-channel color deviation. A larger value samples deeper for a more outlier-resistant balance; a smaller value samples nearer the extremes. The neutral default is `base_color_clip` ($1.0$); the slider spans log-interpolated values either side. diff --git a/negpy/features/exposure/normalization.py b/negpy/features/exposure/normalization.py index fd0566539..fc2004622 100644 --- a/negpy/features/exposure/normalization.py +++ b/negpy/features/exposure/normalization.py @@ -919,8 +919,8 @@ def analyze_log_exposure_bounds( color deviation (white balance / orange-mask cast). A tighter (larger) clip gives a more robust channel balance; a gentler (smaller) clip samples nearer the extremes. Default neutral is base_color_clip. - The luminance centre+span comes from the luma sampling, the per-channel color - offsets from the color sampling, so the cast clip is tunable without compressing + The per-channel refs come from the color sampling, and the luma sampling sets how + far past them the window reaches, so the cast clip is tunable without compressing highlights. Identical channels (mono) give zero deviation at any clip. """ img_log = to_log_density(image) @@ -959,7 +959,7 @@ def analyze_log_exposure_bounds_from_log( floors, ceils = _sample_log_bounds(img_log, percentile_clip, base_luma, sorted_grid) - # Color pass: per-channel deviations recombined onto the luma mean centre and span. The + # Color pass: per-channel refs, widened to the luma window. The # ceils (thin end, base-anchored) come from per-channel percentiles at color_clip. The # floors (dense end, scene content) prefer the same-pixel chroma-gated band refs and fall # back to the percentile pass when the band holds no trustworthy neutrals, and always for @@ -969,10 +969,22 @@ def analyze_log_exposure_bounds_from_log( sp = _same_pixel_color_floor_refs(img_log, floors, ceils, (c_ceils[0], c_ceils[1], c_ceils[2]), color_clip) if sp is not None: c_floors = [sp[0], sp[1], sp[2]] - mean_lf, mean_lc = sum(floors) / 3.0, sum(ceils) / 3.0 - mean_cf, mean_cc = sorted(c_floors)[1], sorted(c_ceils)[1] - floors = [mean_lf + (c_floors[ch] - mean_cf) for ch in range(3)] - ceils = [mean_lc + (c_ceils[ch] - mean_cc) for ch in range(3)] + spans = [c_floors[ch] - c_ceils[ch] for ch in range(3)] + if min(abs(s) for s in spans) > 1e-6: + # The luma window's reach past the color refs, in units of each channel's own span. A + # reach in log units would tint a film whose channels differ in density range. + reach_f = sum((floors[ch] - c_floors[ch]) / spans[ch] for ch in range(3)) / 3.0 + reach_c = sum((ceils[ch] - c_ceils[ch]) / spans[ch] for ch in range(3)) / 3.0 + # The window sits off the luma mean by the base's mean-minus-median channel offset, the + # placement the print constants are tuned to. + shift = (sum(c_ceils) / 3.0 - sorted(c_ceils)[1]) / (sum(spans) / 3.0) + floors = [c_floors[ch] + (reach_f + shift) * spans[ch] for ch in range(3)] + ceils = [c_ceils[ch] + (reach_c + shift) * spans[ch] for ch in range(3)] + else: + mean_lf, mean_lc = sum(floors) / 3.0, sum(ceils) / 3.0 + mean_cf, mean_cc = sorted(c_floors)[1], sorted(c_ceils)[1] + floors = [mean_lf + (c_floors[ch] - mean_cf) for ch in range(3)] + ceils = [mean_lc + (c_ceils[ch] - mean_cc) for ch in range(3)] return LogNegativeBounds( (floors[0], floors[1], floors[2]), diff --git a/negpy/services/assets/thumbnail_fingerprint.py b/negpy/services/assets/thumbnail_fingerprint.py index adaa890cf..4deee3700 100644 --- a/negpy/services/assets/thumbnail_fingerprint.py +++ b/negpy/services/assets/thumbnail_fingerprint.py @@ -12,7 +12,7 @@ from negpy.domain.models import WorkspaceConfig # Bump in any change that alters rendered pixels for unchanged settings, a changed default included. -THUMBNAIL_RENDER_VERSION = 3 +THUMBNAIL_RENDER_VERSION = 4 # Marks a thumbnail made from the source preview, which runs none of the frame's settings. QUICK = "quick" diff --git a/tests/test_same_pixel_bounds.py b/tests/test_same_pixel_bounds.py index c9cbbfa22..1fbe955ac 100644 --- a/tests/test_same_pixel_bounds.py +++ b/tests/test_same_pixel_bounds.py @@ -30,17 +30,19 @@ def _ramp() -> np.ndarray: return log -def _old_color_recombined(img_log: np.ndarray, color_clip: float = 1.0): - """The pre-change recombination (independent per-channel percentiles).""" +def _percentile_recombined(img_log: np.ndarray, color_clip: float = 1.0): + """The recombination on independent per-channel percentiles at both ends.""" from negpy.features.exposure.models import EXPOSURE_CONSTANTS floors, ceils = _sample_log_bounds(img_log, 0.0, float(EXPOSURE_CONSTANTS["base_luma_clip"])) c_floors, c_ceils = _sample_log_bounds(img_log, color_clip, 0.0) - mean_lf, mean_lc = sum(floors) / 3.0, sum(ceils) / 3.0 - mean_cf, mean_cc = sorted(c_floors)[1], sorted(c_ceils)[1] + spans = [c_floors[ch] - c_ceils[ch] for ch in range(3)] + reach_f = sum((floors[ch] - c_floors[ch]) / spans[ch] for ch in range(3)) / 3.0 + reach_c = sum((ceils[ch] - c_ceils[ch]) / spans[ch] for ch in range(3)) / 3.0 + shift = (sum(c_ceils) / 3.0 - sorted(c_ceils)[1]) / (sum(spans) / 3.0) return ( - [mean_lf + (c_floors[ch] - mean_cf) for ch in range(3)], - [mean_lc + (c_ceils[ch] - mean_cc) for ch in range(3)], + [c_floors[ch] + (reach_f + shift) * spans[ch] for ch in range(3)], + [c_ceils[ch] + (reach_c + shift) * spans[ch] for ch in range(3)], ) @@ -63,17 +65,17 @@ def test_colored_dense_content_no_longer_reads_as_cast(): new_err = np.abs(_dev(bounds.floors) - truth).max() assert new_err < 0.02, f"same-pixel floors polluted (err={new_err:.3f})" - old_floors, _ = _old_color_recombined(log) - old_err = np.abs(_dev(old_floors) - truth).max() - assert old_err > 0.10, "fixture must actually break the percentile pass" + pct_floors, _ = _percentile_recombined(log) + pct_err = np.abs(_dev(pct_floors) - truth).max() + assert pct_err > 0.10, "fixture must actually break the percentile pass" def test_neutral_frame_matches_percentile_pass(): # With neutral extremes both estimators must agree (no invented tint). log = _ramp() bounds = analyze_log_exposure_bounds_from_log(log, color_clip=1.0) - old_floors, _ = _old_color_recombined(log) - np.testing.assert_allclose(_dev(bounds.floors), _dev(old_floors), atol=5e-3) + pct_floors, _ = _percentile_recombined(log) + np.testing.assert_allclose(_dev(bounds.floors), _dev(pct_floors), atol=5e-3) def test_no_neutral_dense_end_falls_back_bit_exact(): @@ -87,16 +89,35 @@ def test_no_neutral_dense_end_falls_back_bit_exact(): log[dense, 1::2, 2] -= 0.65 bounds = analyze_log_exposure_bounds_from_log(log, color_clip=1.0) - old_floors, old_ceils = _old_color_recombined(log) - np.testing.assert_allclose(bounds.floors, old_floors, atol=1e-9) - np.testing.assert_allclose(bounds.ceils, old_ceils, atol=1e-9) + pct_floors, pct_ceils = _percentile_recombined(log) + np.testing.assert_allclose(bounds.floors, pct_floors, atol=1e-9) + np.testing.assert_allclose(bounds.ceils, pct_ceils, atol=1e-9) def test_thin_end_stays_percentile_based(): - # Ceiling refs must be untouched by the same-pixel change (base is anchored). + # The ceiling color refs are the percentile ones (base is anchored); the dense refs reach + # the ceiling only through the span that scales its luma reach. log = _ramp() block = slice(0, int(0.08 * _W)) log[:, block, 0] = _BASE[0] - 0.95 bounds = analyze_log_exposure_bounds_from_log(log, color_clip=1.0) - _, old_ceils = _old_color_recombined(log) - np.testing.assert_allclose(bounds.ceils, old_ceils, atol=1e-9) + _, pct_ceils = _percentile_recombined(log) + np.testing.assert_allclose(bounds.ceils, pct_ceils, atol=5e-3) + + +def test_unequal_channel_ranges_keep_a_gray_scale_neutral(): + # One gray scale on a film whose channels differ in density range, with speculars past + # it: every step must normalize to the same value in all three channels. + E = np.linspace(0.0, 1.0, _H, dtype=np.float32) + gammas = (0.3, 0.65, 0.2) + log = np.empty((_H, _W, 3), np.float32) + for ch in range(3): + log[:, :, ch] = (_BASE[ch] - gammas[ch] * E)[:, None] + log[-1, :8, ch] = _BASE[ch] - gammas[ch] * 1.6 + + bounds = analyze_log_exposure_bounds_from_log(log, color_clip=1.0) + norm = np.stack( + [(log[: _H - 1, 0, ch] - bounds.floors[ch]) / (bounds.ceils[ch] - bounds.floors[ch]) for ch in range(3)], + axis=-1, + ) + assert np.ptp(norm, axis=1).max() < 5e-3 diff --git a/tests/thumbnail_fingerprint_defaults.json b/tests/thumbnail_fingerprint_defaults.json index e2cfddf8f..f281eeaa8 100644 --- a/tests/thumbnail_fingerprint_defaults.json +++ b/tests/thumbnail_fingerprint_defaults.json @@ -98,6 +98,8 @@ "autocrop_offset": "0", "autocrop_ratio": "\"Free\"", "autocrop_rebate_trim": "1.0", + "autocrop_square": "false", + "autocrop_squared": "false", "converge_h": "0.0", "converge_v": "0.0", "crop_detect_key": "\"\"", @@ -203,5 +205,5 @@ "vanadium_strength": "0.0" } }, - "render_version": 3 + "render_version": 4 }