Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/PIPELINE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
28 changes: 21 additions & 7 deletions negpy/features/exposure/normalization.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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]),
Expand Down
2 changes: 1 addition & 1 deletion negpy/services/assets/thumbnail_fingerprint.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
75 changes: 58 additions & 17 deletions tests/test_same_pixel_bounds.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"""

import numpy as np
import pytest

from negpy.features.exposure.normalization import (
_sample_log_bounds,
Expand All @@ -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)],
)


Expand All @@ -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():
Expand All @@ -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)
4 changes: 3 additions & 1 deletion tests/thumbnail_fingerprint_defaults.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "\"\"",
Expand Down Expand Up @@ -203,5 +205,5 @@
"vanadium_strength": "0.0"
}
},
"render_version": 3
"render_version": 4
}
Loading