diff --git a/docs/PIPELINE.md b/docs/PIPELINE.md index 75d119f27..e77bfca46 100644 --- a/docs/PIPELINE.md +++ b/docs/PIPELINE.md @@ -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)}$$ @@ -104,7 +104,7 @@ 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$). @@ -112,7 +112,7 @@ Pixel values are linear transmittance. The source corrections run in that domain * **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. diff --git a/negpy/features/exposure/auto_sliders.py b/negpy/features/exposure/auto_sliders.py index 86ffb7d3e..517c267d9 100644 --- a/negpy/features/exposure/auto_sliders.py +++ b/negpy/features/exposure/auto_sliders.py @@ -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, @@ -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]: diff --git a/negpy/features/exposure/logic.py b/negpy/features/exposure/logic.py index 067dcdcf0..ad1d3a824 100644 --- a/negpy/features/exposure/logic.py +++ b/negpy/features/exposure/logic.py @@ -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) @@ -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), @@ -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 @@ -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]: @@ -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 @@ -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"]))) @@ -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"]))) @@ -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"] @@ -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 @@ -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 @@ -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) diff --git a/negpy/features/exposure/models.py b/negpy/features/exposure/models.py index 29760db4e..c9ddd5326 100644 --- a/negpy/features/exposure/models.py +++ b/negpy/features/exposure/models.py @@ -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, diff --git a/negpy/features/transparency/logic.py b/negpy/features/transparency/logic.py index 450ce0e72..3e03a3ff4 100644 --- a/negpy/features/transparency/logic.py +++ b/negpy/features/transparency/logic.py @@ -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. @@ -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 ( @@ -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 diff --git a/negpy/services/assets/thumbnail_fingerprint.py b/negpy/services/assets/thumbnail_fingerprint.py index 6e603c555..31bb0dbad 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 = 15 +THUMBNAIL_RENDER_VERSION = 16 # Marks a thumbnail made from the source preview, which runs none of the frame's settings. QUICK = "quick" diff --git a/negpy/services/rendering/gpu_engine.py b/negpy/services/rendering/gpu_engine.py index 336e0fa24..a515ceb4a 100644 --- a/negpy/services/rendering/gpu_engine.py +++ b/negpy/services/rendering/gpu_engine.py @@ -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. diff --git a/tests/test_auto_sliders.py b/tests/test_auto_sliders.py index 5c3aa973f..8edf4d52f 100644 --- a/tests/test_auto_sliders.py +++ b/tests/test_auto_sliders.py @@ -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 ( @@ -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) @@ -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)) diff --git a/tests/test_characteristic_curve.py b/tests/test_characteristic_curve.py index 88b70dfd6..b56fcf3b5 100644 --- a/tests/test_characteristic_curve.py +++ b/tests/test_characteristic_curve.py @@ -346,10 +346,10 @@ def test_chart_mirror_parity(self): class TestPivotAndGrade(unittest.TestCase): def test_reference_prints_at_target_grade_invariant(self): - """Reference tone lands on anchor_target_density for any grade (rotation center).""" + """Reference tone lands on reference_print_density for any grade (rotation center).""" d_min = EXPOSURE_CONSTANTS["d_min"] x_ref = EXPOSURE_CONSTANTS["assumed_anchor"] - target = EXPOSURE_CONSTANTS["anchor_target_density"] + target = EXPOSURE_CONSTANTS["reference_print_density"] ref_img = np.full((4, 4, 3), x_ref, dtype=np.float32) for grade in (60.0, 115.0, 170.0): slope = grade_to_slope(grade, 1.3) diff --git a/tests/test_crossover_trims.py b/tests/test_crossover_trims.py index 7138e3b36..92ac68819 100644 --- a/tests/test_crossover_trims.py +++ b/tests/test_crossover_trims.py @@ -18,7 +18,7 @@ from negpy.features.exposure.models import EXPOSURE_CONSTANTS _ANCHOR = EXPOSURE_CONSTANTS["assumed_anchor"] -_TARGET = EXPOSURE_CONSTANTS["anchor_target_density"] +_TARGET = EXPOSURE_CONSTANTS["reference_print_density"] def _densities(value: float, slopes, pivots, curvatures) -> np.ndarray: diff --git a/tests/test_exposure_pivot.py b/tests/test_exposure_pivot.py index 006bcc492..3824d0542 100644 --- a/tests/test_exposure_pivot.py +++ b/tests/test_exposure_pivot.py @@ -21,12 +21,12 @@ def _density_at(x_ref, slope, pivot, d_min): class TestComputePivot(unittest.TestCase): def test_reference_tone_prints_at_target(self): - """The assumed reference tone must land on anchor_target_density.""" + """The assumed reference tone must land on reference_print_density.""" x_ref = EXPOSURE_CONSTANTS["assumed_anchor"] slope = grade_to_slope(110.0, 1.3) pivot = compute_pivot(slope, density=1.0) d = _density_at(x_ref, slope, pivot, 0.0) - self.assertAlmostEqual(d, EXPOSURE_CONSTANTS["anchor_target_density"], places=3) + self.assertAlmostEqual(d, EXPOSURE_CONSTANTS["reference_print_density"], places=3) def test_reference_tone_prints_at_target_with_dmin(self): """The Dmin floor must not shift the reference tone off target.""" @@ -35,7 +35,7 @@ def test_reference_tone_prints_at_target_with_dmin(self): slope = grade_to_slope(110.0, 1.3) pivot = compute_pivot(slope, density=1.0, d_min=d_min) d = _density_at(x_ref, slope, pivot, d_min) - self.assertAlmostEqual(d, EXPOSURE_CONSTANTS["anchor_target_density"], places=3) + self.assertAlmostEqual(d, EXPOSURE_CONSTANTS["reference_print_density"], places=3) def test_grade_does_not_shift_reference_tone(self): """Grade changes rotate around the assumed reference tone.""" @@ -74,7 +74,7 @@ def test_reference_pixel_prints_at_target_brightness(self): x_ref = EXPOSURE_CONSTANTS["assumed_anchor"] img = np.full((8, 8, 3), x_ref, dtype=np.float32) # Linear reflectance: reference tone lands at transmittance 10^-target. - expected = 10.0 ** -EXPOSURE_CONSTANTS["anchor_target_density"] + expected = 10.0 ** -EXPOSURE_CONSTANTS["reference_print_density"] for grade in (130.0, 110.0, 70.0): res = PhotometricProcessor(replace(config, grade=grade)).process(img, ctx) diff --git a/tests/test_exposure_targets.py b/tests/test_exposure_targets.py index 9e783f8ee..b04e81fea 100644 --- a/tests/test_exposure_targets.py +++ b/tests/test_exposure_targets.py @@ -1,10 +1,11 @@ import unittest +from dataclasses import replace import numpy as np from negpy.domain.models import WorkspaceConfig from negpy.features.exposure import models as exposure_models -from negpy.features.exposure.logic import effective_grade_range +from negpy.features.exposure.logic import CharacteristicCurve, compute_pivot, effective_grade_range, grade_to_slope from negpy.features.exposure.models import ( DEFAULT_TARGETS, EXPOSURE_CONSTANTS, @@ -83,6 +84,23 @@ def test_metering_band_still_clamps(self): assumed = float(EXPOSURE_CONSTANTS["assumed_anchor"]) self.assertLessEqual(abs(self._anchor() - assumed), band + 1e-6) + def test_print_density_target_moves_the_metered_anchor(self): + apply_targets({"anchor_target_density": 0.95}) + slope = grade_to_slope(115.0, 1.3) + for anchor in (0.40, 0.52): + curve = CharacteristicCurve(contrast=slope, pivot=compute_pivot(slope, 1.0, anchor=anchor)) + self.assertAlmostEqual(float(curve(np.array([[anchor]], dtype=np.float32))[0, 0]), 0.95, places=3) + + def test_print_density_target_is_inert_without_auto_density(self): + engine = DarkroomEngine() + rng = np.random.default_rng(0) + img = rng.random((64, 64, 3), dtype=np.float32) * 0.5 + 0.25 + settings = WorkspaceConfig() + settings = replace(settings, exposure=replace(settings.exposure, auto_exposure=False)) + before = engine.process(img, settings, source_hash="manual") + apply_targets({"anchor_target_density": DEFAULT_TARGETS["anchor_target_density"] + 0.25}) + np.testing.assert_array_equal(engine.process(img, settings, source_hash="manual"), before) + class TestRenderCacheInvalidation(TargetsTestCase): """Retuned targets aren't in any config hash — the render must still refresh.""" diff --git a/tests/test_paper_scurve.py b/tests/test_paper_scurve.py index da0e020fb..82249623a 100644 --- a/tests/test_paper_scurve.py +++ b/tests/test_paper_scurve.py @@ -20,9 +20,9 @@ def _slope_pivot(self): return slope, pivot def test_anchor_preserved(self): - # The reference tone must still print at anchor_target_density with the + # The reference tone must still print at reference_print_density with the # S-curve on (it is centred on the reference value, shape(0)=0). - target = EXPOSURE_CONSTANTS["anchor_target_density"] + target = EXPOSURE_CONSTANTS["reference_print_density"] slope, pivot = self._slope_pivot() curve = CharacteristicCurve(contrast=slope, pivot=pivot) anchor = EXPOSURE_CONSTANTS["assumed_anchor"] diff --git a/tests/test_transparency_transfer.py b/tests/test_transparency_transfer.py index 6bf85e648..7a7e838f3 100644 --- a/tests/test_transparency_transfer.py +++ b/tests/test_transparency_transfer.py @@ -374,7 +374,7 @@ def test_zone_density_matches_the_print_by_tonal_position_not_raw_density(self): sh_c, hi_c, k = zone_geometry() d_min, span = float(C["d_min"]), float(C["d_max"]) - float(C["d_min"]) - anchor = float(C["anchor_target_density"]) + anchor = float(C["reference_print_density"]) for got, print_density in ( (sh_c, anchor + float(C["zone_density_shadow_offset"])), (hi_c, anchor + float(C["zone_density_highlight_offset"])), @@ -448,7 +448,7 @@ def test_shadow_highlight_wb_geometry_matches_the_print_by_tonal_position(self): centre, k = wb_split_geometry() d_min, span = float(C["d_min"]), float(C["d_max"]) - float(C["d_min"]) - anchor = float(C["anchor_target_density"]) + anchor = float(C["reference_print_density"]) self.assertAlmostEqual(centre / TRANSFER_DENSITY_RANGE, (anchor - d_min) / span, places=6) self.assertAlmostEqual(k * TRANSFER_DENSITY_RANGE / span, 3.0, places=6) diff --git a/tests/thumbnail_fingerprint_defaults.json b/tests/thumbnail_fingerprint_defaults.json index dc327d5b1..f7713be56 100644 --- a/tests/thumbnail_fingerprint_defaults.json +++ b/tests/thumbnail_fingerprint_defaults.json @@ -207,5 +207,5 @@ "vanadium_strength": "0.0" } }, - "render_version": 15 + "render_version": 16 }