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
6 changes: 3 additions & 3 deletions docs/PIPELINE.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ Pixel values are linear transmittance. The source corrections run in that domain
**Code**: `negpy.features.exposure`

* **B&W Negative**: the normalized signal collapses to one density (its luminance) before the curve.
* **Color timing**: subtractive CMY in log space, on Global, Shadows or Highlights. Regional offsets weigh by $w_{sh} = \sigma(3 \cdot (v - z))$ about the midtone zone center $z$ (`anchor_target_density`). Temperature, the WB picker and the temperature roll-lock act on the selected region's M/Y pair.
* **Color timing**: subtractive CMY in log space, on Global, Shadows or Highlights. Regional offsets weigh by $w_{sh} = \sigma(3 \cdot (v - z))$ about the midtone zone center $z$ (`reference_print_density`). Temperature, the WB picker and the temperature roll-lock act on the selected region's M/Y pair.
* **The H&D curve**: a toe-linear-shoulder curve in density. A line of slope $k$ through the pivot is bounded by two independent softplus knees. With $v = k \cdot (x_{adj} - x_0)$:
$$v_1 = D_{min} + \frac{\text{softplus}\big(a_{hl} (v - D_{min})\big)}{a_{hl}} \qquad \text{(shoulder → paper white)}$$
$$D = D_{max} - \frac{\text{softplus}\big(a_{sh} (D_{max} - v_1)\big)}{a_{sh}} \qquad \text{(toe → paper black)}$$
Expand Down Expand Up @@ -104,15 +104,15 @@ Pixel values are linear transmittance. The source corrections run in that domain

* **Auto Density** (`auto_exposure`, on): the metered tone $m$ averages the trimmed window's mean and midpoint ($P_5$ to $P_{95}$ of textured cells; Boyack & Juenger). The anchor is a partial pull from `assumed_anchor` ($0.46$):
$$\text{anchor} = \text{assumed} + s \cdot (m - \text{assumed}), \quad \text{clamped to } \pm b$$
with $s$ = `anchor_meter_strength` ($0.2$), $b$ = `anchor_meter_band` ($0.12$). It prints at `anchor_target_density` ($0.75$).
with $s$ = `anchor_meter_strength` ($0.2$), $b$ = `anchor_meter_band` ($0.12$). It prints at `anchor_target_density` ($0.75$, Print Density Target). With Auto Density off, the reference tone prints at the fixed `reference_print_density` ($0.75$).
* **Auto Grade** (`auto_normalize_contrast`, on): a partial ISO R match (Alkofer, US 4,731,671) on the textural range $t$ ($P_{10}$ to $P_{90}$, log D). With $r$ the floor-to-ceil range, the effective range is
$$K \cdot r \cdot \min\!\Big((1 - s) + s \cdot \frac{n}{t},\; c \cdot \frac{n}{t}\Big)$$
$K$ = `auto_grade_target` ($0.85$), $n$ = `auto_grade_nominal_range` ($0.9$), $s$ = `auto_grade_strength` ($0.4$), $c$ = `auto_grade_max_overfill` ($1.2$).
* **Shadow reach** (Gindele, US 7,113,649): the darkest textured tone ($P_{99}$) must print at least `shadow_reach_density` ($1.9$, straight-line); a short grade is steepened, never flattened.
* **Highlight hold** (Agfa, US 4,104,069): the brightest textured tone ($P_2$) must print at least `highlight_hold_density` ($0.10$; 0 off). Otherwise an automatic burn joins the Zone Density highlight term, capped at `highlight_hold_max` ($0.5$). Highlights Grade is not the actuator, because it pivots where these tones sit.
* **Shadow hold**: the Zone Density shadow term gets $-\min\big(h \cdot (v - v_r - o),\; m\big)$ when the tail's value $v > v_r + o$, with $v_r$ the value that prints `shadow_reach_density`, $h$ = `shadow_hold_strength` ($0.40$; 0 off), $o$ = `shadow_hold_overshoot` ($0.15$), $m$ = `shadow_hold_max` ($0.3$).
* All three aim through the midtone gamma (the stock's plus Snap). Shadow reach and the preflash threshold invert it, and the holds read the tail's $v$ after it, where the Zone Density terms add.
* **On the sliders** (`features/exposure/auto_sliders.py`; transfer twin in `features/transparency/logic.py`): stored values are offsets on the meter. Density shifts by $(\text{anchor} - \text{assumed})/$`density_multiplier`, Grade is the ISO R of the auto slope on the frame's range (`slope_to_grade`), and the zone densities add the holds. The meters come from the frame's last plain render (`desktop/auto_sliders.py`).
* **On the sliders** (`features/exposure/auto_sliders.py`; transfer twin in `features/transparency/logic.py`): stored values are offsets on the meter. Density shifts by the gap between the metered and the reference pivot at green's slope, over `density_multiplier`, Grade is the ISO R of the auto slope on the frame's range (`slope_to_grade`), and the zone densities add the holds. The meters come from the frame's last plain render (`desktop/auto_sliders.py`).
* **Set Targets**: eight app-global aim values (`TUNABLE_TARGETS`), overlaid on `EXPOSURE_CONSTANTS` by `apply_targets()` and saved as `exposure_targets`. No `WorkspaceConfig` hash sees them, so both engines fold `TARGETS_REVISION` into their cache keys (CPU base stage; GPU analysis key and exposure diff). A target read outside those stages needs its own invalidation.
* **Cast Removal** (`cast_removal_strength`; 1.0 on a negative, 0 on a slide): balances each layer against the frame's grays from shadows to highlights, at `confidence × slider` (`effective_cast_strength`). `cast_removal_for_mode` is the one place the per-mode default swap lives; it rewrites only the other mode's default, so a chosen strength survives a mode switch. It is a roll default.

Expand Down
12 changes: 9 additions & 3 deletions negpy/features/exposure/auto_sliders.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
auto_base_slope,
auto_highlight_from_metrics,
auto_shadow_from_metrics,
compute_pivot,
curve_params_from_metrics,
effective_grade_range,
effective_midtone_gamma,
grade_to_slope,
Expand All @@ -28,12 +30,16 @@ def _paper(exposure: ExposureConfig, process_mode: Optional[str]) -> Tuple[Any,


def _density_shift(exposure: ExposureConfig, process_mode: Optional[str], metrics: Mapping[str, Any]) -> Optional[float]:
"""Print Density the metered anchor is worth: compute_pivot's pivot is linear in both."""
"""Print Density the metered anchor is worth: the gap between the metered and the
reference pivot at green's slope, as compute_pivot is linear in density."""
anchor = metrics.get("metered_anchor")
if not exposure.auto_exposure or anchor is None:
return None
c = effective_constants(_paper(exposure, process_mode)[0])
return (float(anchor) - float(c["assumed_anchor"])) / float(c["density_multiplier"])
profile, d_min = _paper(exposure, process_mode)
slope = curve_params_from_metrics(exposure, process_mode, metrics)[0][1]
mg = effective_midtone_gamma(profile, exposure.midtone_gamma)
gap = compute_pivot(slope, 1.0, d_min, float(anchor), profile, mg) - compute_pivot(slope, 1.0, d_min, paper=profile)
return gap / float(effective_constants(profile)["density_multiplier"])


def _auto_slope(exposure: ExposureConfig, process_mode: Optional[str], metrics: Mapping[str, Any]) -> Optional[float]:
Expand Down
61 changes: 39 additions & 22 deletions negpy/features/exposure/logic.py
Original file line number Diff line number Diff line change
Expand Up @@ -468,8 +468,8 @@ def __init__(
self.d_min = float(d_min)
self.v_star = _reference_linear_value(d_min, paper)
self.gamma_width = float(c["paper_gamma_width"])
self.zone_sh_center = float(c["anchor_target_density"]) + float(c["zone_density_shadow_offset"])
self.zone_hi_center = float(c["anchor_target_density"]) + float(c["zone_density_highlight_offset"])
self.zone_sh_center = float(c["reference_print_density"]) + float(c["zone_density_shadow_offset"])
self.zone_hi_center = float(c["reference_print_density"]) + float(c["zone_density_highlight_offset"])
self.zone_k = float(c["zone_density_sharpness"])
self.shadow_density = float(shadow_density)
self.highlight_density = float(highlight_density)
Expand Down Expand Up @@ -834,13 +834,13 @@ def apply_characteristic_curve(
width_ref=float(c["toeshoulder_width_ref"]),
toe_height=float(c["toe_height"]),
sh_height=float(c["shoulder_height"]),
zone_center=float(c["anchor_target_density"]),
zone_center=float(c["reference_print_density"]),
shadow_density=float(shadow_density),
highlight_density=float(highlight_density),
shadow_grade=np.array(shadow_grade_deltas, dtype=np.float64),
highlight_grade=np.array(highlight_grade_deltas, dtype=np.float64),
zone_sh_center=float(c["anchor_target_density"]) + float(c["zone_density_shadow_offset"]),
zone_hi_center=float(c["anchor_target_density"]) + float(c["zone_density_highlight_offset"]),
zone_sh_center=float(c["reference_print_density"]) + float(c["zone_density_shadow_offset"]),
zone_hi_center=float(c["reference_print_density"]) + float(c["zone_density_highlight_offset"]),
zone_k=float(c["zone_density_sharpness"]),
v_star=float(v_star),
midtone_gamma=np.array([float(midtone_gamma) + snap_trims[ch] for ch in range(3)], dtype=np.float64),
Expand Down Expand Up @@ -1108,12 +1108,12 @@ def effective_grade_range(
def _reference_linear_value(d_min: float = 0.0, paper: Optional[PaperProfile] = None, target: Optional[float] = None) -> float:
"""
Straight-line density value v* that the base shoulder+toe bounds map onto `target`
(default anchor_target_density). The reference tone is placed here so it
(default reference_print_density). The reference tone is placed here so it
prints at target, and the paper S-curve is centred here so the anchor is
preserved. Closed form via inverse softplus at the base toe/shoulder sharpness.
"""
c = effective_constants(paper)
t = float(c["anchor_target_density"]) if target is None else float(target)
t = float(c["reference_print_density"]) if target is None else float(target)
d_max = float(c["d_max"])
a_hl = float(c["shoulder_sharpness_base"]) # highlight (lower) bound
a_sh = float(c["toe_sharpness_base"]) # shadow (upper) bound
Expand Down Expand Up @@ -1152,6 +1152,16 @@ def _straight_line_target(
return 0.5 * (lo + hi)


def _anchor_linear_value(
d_min: float = 0.0, paper: Optional[PaperProfile] = None, metered: bool = False, midtone_gamma: Optional[float] = None
) -> float:
"""Straight-line value the anchor prints from: a metered anchor lands on
anchor_target_density through the midtone gamma, an unmetered one on v*."""
if not metered:
return _reference_linear_value(d_min, paper)
return _straight_line_target(d_min, paper, float(effective_constants(paper)["anchor_target_density"]), midtone_gamma)


def preflash_params(
grade: float, d_min: float = 0.0, paper: Optional[PaperProfile] = None, midtone_gamma: Optional[float] = None
) -> Tuple[float, float]:
Expand Down Expand Up @@ -1180,6 +1190,7 @@ def shadow_reach_slope(
d_min: float = 0.0,
paper: Optional[PaperProfile] = None,
midtone_gamma: Optional[float] = None,
metered: bool = False,
) -> float:
"""
Auto Grade floor on the slope: the textured dark tail at `shadow_point` must print
Expand All @@ -1192,7 +1203,7 @@ def shadow_reach_slope(
if span <= 1e-6:
return slope
v_black = _straight_line_target(d_min, paper, float(c["shadow_reach_density"]), midtone_gamma)
needed = (v_black - _reference_linear_value(d_min, paper)) / span
needed = (v_black - _anchor_linear_value(d_min, paper, metered, midtone_gamma)) / span
return float(min(max(slope, needed), float(c["slope_max"])))


Expand Down Expand Up @@ -1225,7 +1236,7 @@ def highlight_hold_offset(
v_hold = _reference_linear_value(d_min, paper, target=target)
if v >= v_hold:
return 0.0
z_hi = float(c["anchor_target_density"]) + float(c["zone_density_highlight_offset"])
z_hi = float(c["reference_print_density"]) + float(c["zone_density_highlight_offset"])
w = 1.0 - _fast_sigmoid_cached(float(c["zone_density_sharpness"]) * (v - z_hi))
return float(min((v_hold - v) / max(w, 1e-6), float(c["highlight_hold_max"])))

Expand Down Expand Up @@ -1290,20 +1301,24 @@ def auto_highlight_from_metrics(exposure: Any, process_mode: Optional[str], metr


def compute_pivot(
slope: float, density: float, d_min: float = 0.0, anchor: Optional[float] = None, paper: Optional[PaperProfile] = None
slope: float,
density: float,
d_min: float = 0.0,
anchor: Optional[float] = None,
paper: Optional[PaperProfile] = None,
midtone_gamma: Optional[float] = None,
) -> float:
"""
Fixed calibrated exposure: solve the curve pivot so the reference tone
prints at anchor_target_density for the current effective slope — grade
changes rotate around that reference tone instead of shifting brightness.
The density slider offsets exposure around it. The reference tone defaults
to assumed_anchor (a typical negative's normalized median); pass `anchor`
to use a per-frame metered median (auto-exposure) instead.
(assumed_anchor) prints at reference_print_density for the current effective
slope, so grade changes rotate around it instead of shifting brightness.
The density slider offsets exposure around it. A metered `anchor` (Auto
Density) prints at anchor_target_density instead.
"""
c = effective_constants(paper)
ref = c["assumed_anchor"] if anchor is None else anchor
v_star = _reference_linear_value(d_min, paper)
base = ref - v_star / slope
v = _anchor_linear_value(d_min, paper, anchor is not None, midtone_gamma)
base = ref - v / slope
return base + (1.0 - density) * c["density_multiplier"]


Expand Down Expand Up @@ -1370,7 +1385,9 @@ def auto_base_slope(
if auto_normalize_contrast and shadow_point is not None:
c = effective_constants(paper)
ref = float(c["assumed_anchor"]) if anchor is None else float(anchor)
slope = shadow_reach_slope(slope, ref, shadow_point, d_min=d_min, paper=paper, midtone_gamma=midtone_gamma)
slope = shadow_reach_slope(
slope, ref, shadow_point, d_min=d_min, paper=paper, midtone_gamma=midtone_gamma, metered=anchor is not None
)
return slope


Expand Down Expand Up @@ -1427,7 +1444,7 @@ def per_channel_curve_params(
curv_lim = float(c["neutral_axis_curv_max_ratio"])
m_g, s_g = float(mid_norm[1]), float(sh_norm[1])
slope_g = min(max(base_slope * cg[1], slope_min), slope_max)
pivot_g = compute_pivot(slope_g, density, d_min=d_min, anchor=anchor, paper=paper)
pivot_g = compute_pivot(slope_g, density, d_min=d_min, anchor=anchor, paper=paper, midtone_gamma=midtone_gamma)
target = lambda g: slope_g * (g - pivot_g) # noqa: E731 green's core at a green ref
t_m, t_s = target(m_g), target(s_g)
h_g = float(hl_norm[1]) if hl_norm is not None else None
Expand Down Expand Up @@ -1470,9 +1487,9 @@ def per_channel_curve_params(
s0 = min(max(base_slope * cg[0], slope_min), slope_max)
s1 = min(max(base_slope * cg[1], slope_min), slope_max)
s2 = min(max(base_slope * cg[2], slope_min), slope_max)
p0 = compute_pivot(s0, density, d_min=d_min, anchor=anchor, paper=paper)
p1 = compute_pivot(s1, density, d_min=d_min, anchor=anchor, paper=paper)
p2 = compute_pivot(s2, density, d_min=d_min, anchor=anchor, paper=paper)
p0 = compute_pivot(s0, density, d_min=d_min, anchor=anchor, paper=paper, midtone_gamma=midtone_gamma)
p1 = compute_pivot(s1, density, d_min=d_min, anchor=anchor, paper=paper, midtone_gamma=midtone_gamma)
p2 = compute_pivot(s2, density, d_min=d_min, anchor=anchor, paper=paper, midtone_gamma=midtone_gamma)
return (s0, s1, s2), (p0, p1, p2), (0.0, 0.0, 0.0)


Expand Down
7 changes: 5 additions & 2 deletions negpy/features/exposure/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,10 +127,13 @@ def __post_init__(self) -> None:
"cmy_max_density": 0.2,
# Scales the density slider's effect on the exposure pivot.
"density_multiplier": 0.2,
# Density the reference tone (assumed_anchor) prints at.
# Density the reference tone (assumed_anchor) prints at. The paper S-curve, the zone
# weights and the regional CMY split centre on it.
"reference_print_density": 0.75,
# Auto Density: density the metered anchor prints at (Print Density Target).
"anchor_target_density": 0.75,
# Zone Density (ΔD) weights: mid-sparing sigmoids centred in the three-quarter/
# quarter tones (offsets from anchor_target_density), so midtones get neither
# quarter tones (offsets from reference_print_density), so midtones get neither
# offset. Mirrored as literals in exposure.wgsl: change both together.
"zone_density_sharpness": 4.0,
"zone_density_shadow_offset": 0.75,
Expand Down
6 changes: 3 additions & 3 deletions negpy/features/transparency/logic.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

The paper H&D curve cannot serve this role. Its print character is structural, not
parametric — `d_max` floors the blacks whatever the toe slider says,
`anchor_target_density` places mid-grey at a print's mid-tone, and the midtone snap
`reference_print_density` places mid-grey at a print's mid-tone, and the midtone snap
and paper-white reference stay live at neutral settings. Neutralizing it is not
reachable from a paper profile, so a positive gets its own curve.

Expand Down Expand Up @@ -108,7 +108,7 @@ def zone_geometry() -> Tuple[float, float, float]:
c = EXPOSURE_CONSTANTS
d_min, d_max = float(c["d_min"]), float(c["d_max"])
span = d_max - d_min
anchor = float(c["anchor_target_density"])
anchor = float(c["reference_print_density"])
shadow = (anchor + float(c["zone_density_shadow_offset"]) - d_min) / span
highlight = (anchor + float(c["zone_density_highlight_offset"]) - d_min) / span
return (
Expand All @@ -129,7 +129,7 @@ def wb_split_geometry() -> Tuple[float, float]:
c = EXPOSURE_CONSTANTS
d_min, d_max = float(c["d_min"]), float(c["d_max"])
span = d_max - d_min
anchor = float(c["anchor_target_density"])
anchor = float(c["reference_print_density"])
centre = (anchor - d_min) / span * TRANSFER_DENSITY_RANGE
sharpness = 3.0 * span / TRANSFER_DENSITY_RANGE
return centre, sharpness
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 = 15
THUMBNAIL_RENDER_VERSION = 16

# Marks a thumbnail made from the source preview, which runs none of the frame's settings.
QUICK = "quick"
Expand Down
2 changes: 1 addition & 1 deletion negpy/services/rendering/gpu_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -2188,7 +2188,7 @@ def _upload_unified_uniforms(
sep_damping,
pc["toe_height"],
pc["shoulder_height"],
pc["anchor_target_density"],
pc["reference_print_density"],
_sw3[1],
# Separation Damping's per-layer k, red (ex-surround_gamma slot). Green and
# blue ride the split_sh/split_hi w-lanes.
Expand Down
15 changes: 14 additions & 1 deletion tests/test_auto_sliders.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
auto_shadow_from_metrics,
curve_params_from_metrics,
)
from negpy.features.exposure.models import ExposureConfig
from negpy.features.exposure.models import DEFAULT_TARGETS, ExposureConfig, apply_targets
from negpy.features.exposure.normalization import LogNegativeBounds
from negpy.features.process.models import ProcessMode
from negpy.features.transparency.logic import (
Expand Down Expand Up @@ -50,6 +50,13 @@ def _printed(exposure, metrics):
return np.array([*slopes, *pivots, *curvs, hl, sh])


@pytest.fixture
def retuned_target():
apply_targets({"anchor_target_density": 0.95})
yield
apply_targets(DEFAULT_TARGETS)


@pytest.mark.parametrize("shadow_point", [None, 0.93, 0.99])
def test_shown_values_are_what_prints(shadow_point):
metrics = _metrics(shadow_point=shadow_point)
Expand All @@ -62,6 +69,12 @@ def test_shown_values_are_what_prints(shadow_point):
assert set(shown) == expected


@pytest.mark.usefixtures("retuned_target")
@pytest.mark.parametrize("shadow_point", [None, 0.93])
def test_shown_values_are_what_prints_at_a_retuned_target(shadow_point):
test_shown_values_are_what_prints(shadow_point)


def test_shown_values_follow_the_meter():
exposure = ExposureConfig()
dense = print_shown_values(exposure, MODE, _metrics(metered_anchor=0.56))
Expand Down
Loading
Loading