Skip to content
Closed
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 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.

Expand Down
26 changes: 19 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,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]),
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
55 changes: 38 additions & 17 deletions tests/test_same_pixel_bounds.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)],
)


Expand All @@ -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():
Expand All @@ -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
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