From c948329fe5ff3dbad506c76488bd537960971c6d Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Mon, 12 Oct 2026 18:37:58 +0200 Subject: [PATCH] fix(exposure): reach past the color refs by one fraction of each span 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, and a negative Luma Range Clip tints the frame as it widens the window. The reach is now one shared fraction of each channel's own span: a ratio of sums, sum(l_c - r_c) / sum(s_c). A mean of per-channel ratios (#1366) lets a near-flat channel inflate the reach for all three; the ratio of sums keeps the old mean log reach and widens every channel alike. Measured on 264 color frames from 14 rolls (preview decode, saved crop and unmix): gray tilt against each frame's own gray axis drops from 0.110/0.138 to 0.079/0.116 (R/B, log), the error on a synthetically faded copy from 0.074 to 0.049, and the channel spread of the window growth at Luma Range Clip -0.3 from 1.19 to 1.03. 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. Co-authored-by: Sean Harding --- docs/PIPELINE.md | 2 +- negpy/features/exposure/normalization.py | 28 +++++-- .../services/assets/thumbnail_fingerprint.py | 2 +- tests/test_same_pixel_bounds.py | 75 ++++++++++++++----- tests/thumbnail_fingerprint_defaults.json | 4 +- 5 files changed, 84 insertions(+), 27 deletions(-) diff --git a/docs/PIPELINE.md b/docs/PIPELINE.md index dbffd49e5..f034f967d 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 one shared fraction of each channel's own span $s_c$ between its references: $f_c = r_c^f + (h_f + k)\,s_c$ with $h_f = \sum_c (l_c^f - r_c^f) / \sum_c s_c$, and the ceiling alike. A film whose channels differ in density range, a faded negative for example, keeps a gray scale neutral, and a negative Luma Range Clip widens every channel by the same fraction. $k$ is the base's mean-minus-median channel offset over the mean span. Spans of mixed sign take one log reach for all channels. 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..e7af4b96b 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,24 @@ 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)] + total = sum(spans) + if all(s * total > 0 for s in spans): + # The luma window reaches past the color refs by one shared fraction of each channel's own + # span: a reach in log units tints a film whose channels differ in density range. A ratio + # of sums, not a mean of per-channel ratios, so a near-flat channel cannot inflate it. + reach_f = sum(floors[ch] - c_floors[ch] for ch in range(3)) / total + reach_c = sum(ceils[ch] - c_ceils[ch] for ch in range(3)) / total + # 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]) / total + 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..03f3cf677 100644 --- a/tests/test_same_pixel_bounds.py +++ b/tests/test_same_pixel_bounds.py @@ -11,6 +11,7 @@ """ import numpy as np +import pytest from negpy.features.exposure.normalization import ( _sample_log_bounds, @@ -30,17 +31,20 @@ 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)] + total = sum(spans) + reach_f = sum(floors[ch] - c_floors[ch] for ch in range(3)) / total + reach_c = sum(ceils[ch] - c_ceils[ch] for ch in range(3)) / total + shift = (sum(c_ceils) - 3.0 * sorted(c_ceils)[1]) / total 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 +67,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 +91,53 @@ 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 _graded(gammas: tuple) -> np.ndarray: + """One gray scale per channel at its own density range, with speculars past it.""" + E = np.linspace(0.0, 1.0, _H, dtype=np.float32) + 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 + return log + + +def test_unequal_channel_ranges_keep_a_gray_scale_neutral(): + # A film whose channels differ in density range: every gray step must normalize to the + # same value in all three channels. + log = _graded((0.3, 0.65, 0.2)) + 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 + + +def test_headroom_margin_widens_every_channel_alike(): + # A negative Luma Range Clip widens each window by one fraction of its own span, and by the + # margin on average in log units, even with one channel near flat. + log = _graded((0.6, 0.65, 0.05)) + margin = 0.1 + at_zero = analyze_log_exposure_bounds_from_log(log, percentile_clip=0.0, color_clip=1.0) + widened = analyze_log_exposure_bounds_from_log(log, percentile_clip=-margin, color_clip=1.0) + w0 = np.subtract(at_zero.ceils, at_zero.floors) + w1 = np.subtract(widened.ceils, widened.floors) + growth = w1 / w0 + assert np.ptp(growth) < 1e-6 + assert np.mean(w1 - w0) == pytest.approx(2 * margin, abs=1e-6) 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 }