From 1f10229e698c12631313d17f050aedc1c012136d Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Sat, 8 Aug 2026 22:36:21 +0200 Subject: [PATCH] fix(sharpen): sharpen the pixels being exported, not the preview's The unsharp mask multiplied its radius by the pipeline scale factor, so a 1 px radius became a ~6 px blur on a 24 MP export. The boost peaked at 8 px structure and read as contrast rather than sharpness at 1:1. Radius is now in output pixels on both the CPU and GPU paths, moving the peak to Nyquist. The L* noise gate came with it: at a true 1 px radius |L - blur| tops out near 1.0, so the old 1.5/2.0 closed the gate completely. Rescaled by the same factor it had been calibrated against. Export downscales now use INTER_AREA. OpenCV gives INTER_LANCZOS4 no prefilter on a shrink, so the CPU path was already producing plain bilinear and the GPU tiled path asked for it outright; neither was area-correct. The preview no longer shows export acutance faithfully at fit-to-window. Judge sharpening at 1:1, as in Lightroom. --- docs/PIPELINE.md | 12 +++---- docs/USER_GUIDE.md | 2 +- negpy/features/lab/logic.py | 25 ++++++++------ negpy/features/lab/processor.py | 1 - negpy/features/lab/shaders/lab.wgsl | 8 ++--- negpy/services/export/print.py | 9 ++++- negpy/services/rendering/gpu_engine.py | 18 ++++++---- tests/test_lab_logic.py | 48 +++++++++++++++++++++----- tests/test_local_grade.py | 5 ++- tests/test_print_service.py | 19 ++++++++++ tests/test_scene_linear_relocation.py | 16 ++++----- 11 files changed, 116 insertions(+), 47 deletions(-) diff --git a/docs/PIPELINE.md b/docs/PIPELINE.md index 2a016b91..65112d81 100644 --- a/docs/PIPELINE.md +++ b/docs/PIPELINE.md @@ -283,22 +283,22 @@ This mimics what lab scanners like Frontier or Noritsu do automatically. For max Chroma is only ever reduced, never added. That is what keeps `saturation = 0.0` reaching true grey without a special case (the boost-only asymmetry the previous band needed), and what makes it safe to run after the gamut knee — a smaller chroma cannot overshoot a gamut the full-strength push already fitted inside. -4. **Sharpening**: A **Method** selector picks Unsharp Mask or Deconvolution; both share the Amount/Radius/Masking controls and the same $\text{radius} \cdot \text{scale factor}$ Gaussian taps from `gaussian_kernel_1d` (uploaded to the `sharpen_k` storage buffer, convolved identically on CPU `cv2.sepFilter2D` and the separable WGSL passes), so CPU and GPU match bit-for-bit. +4. **Sharpening**: A **Method** selector picks Unsharp Mask or Deconvolution; both share the Amount/Radius/Masking controls and the same $\text{radius}$ Gaussian taps from `gaussian_kernel_1d` (uploaded to the `sharpen_k` storage buffer, convolved identically on CPU `cv2.sepFilter2D` and the separable WGSL passes), so CPU and GPU match bit-for-bit. **Unsharp Mask**, on the Lightness channel ($L$) in LAB space, with halo suppression (`lab_sharpen_h/v.wgsl`): - $$L_{diff} = L - \text{blur}(L, \sigma), \qquad \sigma = \text{radius} \cdot \text{scale factor}$$ - $$\text{gain} = \text{amount} \cdot 2.5 \cdot \text{smoothstep}(1.5, 2.0, |L_{diff}|) \cdot m$$ + $$L_{diff} = L - \text{blur}(L, \sigma), \qquad \sigma = \text{radius}$$ + $$\text{gain} = \text{amount} \cdot 2.5 \cdot \text{smoothstep}(0.25, 0.33, |L_{diff}|) \cdot m$$ $$L_{final} = \text{clamp}\big(L + L_{diff}\cdot\text{gain},\; L_{min}-2,\; L_{max}+1\big)$$ - * **Radius** (px): blur $\sigma$, scaled to the render size so preview and export match. + * **Radius** (px): blur $\sigma$, in output pixels. Acutance belongs to the pixels being written, so the preview under-represents it at fit-to-window; judge sharpening at 1:1. * **Masking** ($m$): edge mask from the boxed $|\nabla L|$, $\text{smoothstep}(0.5t, t, |\nabla L|)$ with $t = 10\cdot\text{masking}$. It protects flat areas (sky, skin, grain), and is off at 0. - * A smoothstep noise gate over $[1.5, 2.0]$ replaces a hard threshold. The overshoot clamp to the local $3\times3$ range ($L_{min}, L_{max}$) kills halos, tighter above (+1) than below (−2) because $L^{\ast}$-domain USM exaggerates light halos. + * A smoothstep noise gate over $[0.25, 0.33]$ replaces a hard threshold; it is sized against $|L_{diff}|$ at a 1 px radius, which tops out near 1.0. The overshoot clamp to the local $3\times3$ range ($L_{min}, L_{max}$) kills halos, tighter above (+1) than below (−2) because $L^{\ast}$-domain USM exaggerates light halos. **Deconvolution**, Richardson-Lucy on linear luminance $Y$ (Gaussian PSF), reversing the scanner's optical blur (`rl_*.wgsl`). It runs on $Y$, not $L^{\ast}$: optical blur is physical, so the model must live in linear light. $$\hat{o}_{n+1} = \hat{o}_n \cdot \left(K \otimes \frac{o}{\max(K \otimes \hat{o}_n,\ \epsilon)}\right), \qquad \hat{o}_0 = o = Y$$ - Iterations are fixed by radius, $\text{clamp}(\text{round}(10\cdot\text{radius}), 5, 20)$. That is a function of the *user* radius, never the scaled $\sigma$, so preview and export run identical counts. There is no per-pixel early stop and no damping; the edge mask alone governs grain, matching RawTherapee. The result is applied as an RGB ratio (chroma-preserving), gated by the same $L^{\ast}$ edge mask $m$: + Iterations are fixed by radius, $\text{clamp}(\text{round}(10\cdot\text{radius}), 5, 20)$. That is a function of the radius alone, so preview and export run identical counts. There is no per-pixel early stop and no damping; the edge mask alone governs grain, matching RawTherapee. The result is applied as an RGB ratio (chroma-preserving), gated by the same $L^{\ast}$ edge mask $m$: $$\mathrm{RGB}_{out} = \mathrm{clamp}\left(\mathrm{RGB} \cdot \max\left(1 + \left(\frac{\hat{o}_N}{\max(o,\epsilon)} - 1\right) \cdot \mathrm{amount} \cdot m,\ 0\right),\ 0,\ 1\right)$$ diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index b5000cd9..0c49ae95 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -420,7 +420,7 @@ Mimics what a lab scanner (Frontier/Noritsu) does automatically. Colour controls * **Method**: *Unsharp Mask* (boosts edge contrast) or *Deconvolution* (Richardson–Lucy, which reverses the scanner's optical blur; set Radius to the scan's blur width). * **Sharpening** (0.0 to 1.0): amount, on the L (lightness) channel so there are no colour halos. -* **Radius** (0.5 to 3.0 px): blur width, small for fine grain and larger for soft scans. Scaled to render size so preview matches export. +* **Radius** (0.5 to 3.0 px): blur width in output pixels, small for fine grain and larger for soft scans. Sharpening acts on the pixels of the exported file, so a fit-to-window preview shows less of it than the export carries — judge it at 1:1 with the loupe or at 100% zoom. * **Masking** (0.0 to 1.0): restrict sharpening to edges, which protects flat areas like sky, skin and grain. **Detail:** diff --git a/negpy/features/lab/logic.py b/negpy/features/lab/logic.py index de57be01..ac97bf24 100644 --- a/negpy/features/lab/logic.py +++ b/negpy/features/lab/logic.py @@ -82,8 +82,10 @@ def apply_clahe(img: ImageBuffer, strength: float) -> ImageBuffer: # Sharpen constants — mirrored as WGSL consts in shaders/lab.wgsl. -SHARPEN_GATE_LO = 1.5 -SHARPEN_GATE_HI = 2.0 +# Gate separating grain from detail in L*; sized against |L - blur| at a 1 px +# radius, which tops out near 1.0. +SHARPEN_GATE_LO = 0.25 +SHARPEN_GATE_HI = 0.33 # L*-domain USM exaggerates light halos, so overshoot above the local max is # clamped tighter than undershoot below the local min. SHARPEN_OVERSHOOT_LIGHT = 1.0 @@ -121,7 +123,7 @@ def _lab_l_from_y(y: np.ndarray) -> np.ndarray: return (np.float32(116.0) * f - np.float32(16.0)).astype(np.float32) -def _edge_mask(l_chan: np.ndarray, masking: float, scale_factor: float) -> np.ndarray: +def _edge_mask(l_chan: np.ndarray, masking: float) -> np.ndarray: """Boxed |∇L*| edge mask (smoothstep over 0.5t..t, t=10·masking); shared by both sharpen methods. Mirrors the WGSL boxed-gradient loop.""" lp = np.pad(l_chan, 1, mode="edge") @@ -129,7 +131,7 @@ def _edge_mask(l_chan: np.ndarray, masking: float, scale_factor: float) -> np.nd gy = (lp[2:, 1:-1] - lp[:-2, 1:-1]) * np.float32(0.5) grad = cv2.blur(np.hypot(gx, gy).astype(np.float32), (3, 3), borderType=cv2.BORDER_REPLICATE) t = SHARPEN_MASK_T_HI * masking - return _smoothstep(0.5 * t, t, grad * np.float32(scale_factor)) + return _smoothstep(0.5 * t, t, grad) def rl_iterations(radius: float) -> int: @@ -141,7 +143,6 @@ def rl_iterations(radius: float) -> int: def apply_output_sharpening( img: ImageBuffer, amount: float, - scale_factor: float = 1.0, radius: float = 1.0, masking: float = 0.0, ) -> ImageBuffer: @@ -149,6 +150,9 @@ def apply_output_sharpening( L-channel unsharp mask; mirrors lab_sharpen_h/v.wgsl + the lab.wgsl sharpen block. Soft-gated USM with an overshoot clamp to the local 3x3 range (halo suppression) and an optional edge mask (boxed |∇L|) protecting flat areas. + + Radius is in output pixels, so the 1600 px preview under-represents export + acutance; judge sharpening at 1:1. """ if amount <= 0: return img @@ -156,14 +160,14 @@ def apply_output_sharpening( lab = rgb_to_lab_working(img.astype(np.float32)) l_chan, a, b = cv2.split(lab) - k = gaussian_kernel_1d(radius * scale_factor) + k = gaussian_kernel_1d(radius) l_blur = cv2.sepFilter2D(l_chan, -1, k, k, borderType=cv2.BORDER_REFLECT_101) diff = l_chan - l_blur gain = np.float32(amount * 2.5) * _smoothstep(SHARPEN_GATE_LO, SHARPEN_GATE_HI, np.abs(diff)) if masking > 0.0: - gain = gain * _edge_mask(l_chan, masking, scale_factor) + gain = gain * _edge_mask(l_chan, masking) kern3 = np.ones((3, 3), np.uint8) l_min = cv2.erode(l_chan, kern3, borderType=cv2.BORDER_REPLICATE) @@ -182,7 +186,6 @@ def apply_output_sharpening( def apply_rl_sharpening( img: ImageBuffer, amount: float, - scale_factor: float = 1.0, radius: float = 1.0, masking: float = 0.0, ) -> ImageBuffer: @@ -191,6 +194,8 @@ def apply_rl_sharpening( as an RGB ratio so chroma is preserved. Mirrors rl_*.wgsl. Iterations are fixed by radius (rl_iterations); no per-pixel early stop or damping — the edge mask governs grain, matching RawTherapee's shipped configuration. + + Radius is the PSF width in output pixels — see apply_output_sharpening. """ if amount <= 0: return img @@ -202,7 +207,7 @@ def apply_rl_sharpening( + np.maximum(rgb[..., 2], 0.0) * np.float32(LUM_B) ).astype(np.float32) - k = gaussian_kernel_1d(radius * scale_factor) + k = gaussian_kernel_1d(radius) est = obs.copy() for _ in range(rl_iterations(radius)): blurred = cv2.sepFilter2D(est, -1, k, k, borderType=cv2.BORDER_REFLECT_101) @@ -212,7 +217,7 @@ def apply_rl_sharpening( ratio = est / np.maximum(obs, np.float32(RL_EPS)) gain = np.float32(amount) if masking > 0.0: - gain = gain * _edge_mask(_lab_l_from_y(obs), masking, scale_factor) + gain = gain * _edge_mask(_lab_l_from_y(obs), masking) factor = np.maximum(np.float32(1.0) + (ratio - np.float32(1.0)) * gain, 0.0) out = rgb * factor[..., np.newaxis] diff --git a/negpy/features/lab/processor.py b/negpy/features/lab/processor.py index 51165a90..d0faa7af 100644 --- a/negpy/features/lab/processor.py +++ b/negpy/features/lab/processor.py @@ -33,7 +33,6 @@ def process(self, image: ImageBuffer, context: PipelineContext) -> ImageBuffer: img = sharpen( img, self.config.sharpen, - context.scale_factor, radius=self.config.sharpen_radius, masking=self.config.sharpen_masking, ) diff --git a/negpy/features/lab/shaders/lab.wgsl b/negpy/features/lab/shaders/lab.wgsl index 42e0fb3f..78214a72 100644 --- a/negpy/features/lab/shaders/lab.wgsl +++ b/negpy/features/lab/shaders/lab.wgsl @@ -23,8 +23,8 @@ struct LabUniforms { @group(0) @binding(3) var sharpen_tex: texture_2d; // Sharpen constants — mirrored from features/lab/logic.py. -const SHARPEN_GATE_LO = 1.5; -const SHARPEN_GATE_HI = 2.0; +const SHARPEN_GATE_LO = 0.25; +const SHARPEN_GATE_HI = 0.33; const SHARPEN_OVERSHOOT_LIGHT = 1.0; const SHARPEN_OVERSHOOT_DARK = 2.0; const SHARPEN_MASK_T_HI = 10.0; @@ -396,7 +396,7 @@ fn main(@builtin(global_invocation_id) gid: vec3) { // raises the bar, protecting flat areas (sky, skin, grain). if (params.sharpen_masking > 0.0) { let t = SHARPEN_MASK_T_HI * params.sharpen_masking; - gain = gain * smoothstep(0.5 * t, t, (grad_box / 9.0) * params.scale_factor); + gain = gain * smoothstep(0.5 * t, t, grad_box / 9.0); } // Overshoot clamp to the local range kills halos; tighter above than @@ -426,7 +426,7 @@ fn main(@builtin(global_invocation_id) gid: vec3) { } } let t = SHARPEN_MASK_T_HI * params.sharpen_masking; - gain = gain * smoothstep(0.5 * t, t, (grad_box / 9.0) * params.scale_factor); + gain = gain * smoothstep(0.5 * t, t, grad_box / 9.0); } let ratio = d.x / max(d.y, RL_EPS); color = color * max(1.0 + (ratio - 1.0) * gain, 0.0); diff --git a/negpy/services/export/print.py b/negpy/services/export/print.py index a5ce2d48..8b1978eb 100644 --- a/negpy/services/export/print.py +++ b/negpy/services/export/print.py @@ -232,7 +232,14 @@ def apply_layout( target_h = max_content_h target_w = int(target_h * img_aspect) - img_scaled = cv2.resize(img, (target_w, target_h), interpolation=cv2.INTER_LANCZOS4) + # OpenCV gives INTER_LANCZOS4 no prefilter on a shrink, so it degrades to + # bilinear and aliases; INTER_AREA is the only area-correct downscale here. + shrinking = target_w < img_w or target_h < img_h + img_scaled = cv2.resize( + img, + (target_w, target_h), + interpolation=cv2.INTER_AREA if shrinking else cv2.INTER_LANCZOS4, + ) color_hex = border_color.lstrip("#") r, g, b = tuple(int(color_hex[i : i + 2], 16) / 255.0 for i in (0, 2, 4)) diff --git a/negpy/services/rendering/gpu_engine.py b/negpy/services/rendering/gpu_engine.py index 0263f0ac..846dd630 100644 --- a/negpy/services/rendering/gpu_engine.py +++ b/negpy/services/rendering/gpu_engine.py @@ -1415,9 +1415,9 @@ def _upload_unified_uniforms( # need the half-width. Derived from the array itself so support matches. sharpen_radius_px = 0 if lab.sharpen > 0: - kernel = gaussian_kernel_1d(lab.sharpen_radius * scale_factor) + kernel = gaussian_kernel_1d(lab.sharpen_radius) sharpen_radius_px = len(kernel) // 2 - kernel_key = (float(lab.sharpen_radius), float(scale_factor)) + kernel_key = (float(lab.sharpen_radius),) if self._sharpen_kernel_key != kernel_key: self._buffers["sharpen_k"].upload(kernel) self._sharpen_kernel_key = kernel_key @@ -1977,12 +1977,12 @@ def _analyze_global_bounds() -> LogNegativeBounds: # Legacy spots get a golden-angle fallback offset of 2.6·size px. halo = max(halo, int(np.ceil(size * (ref_scale * 0.5 + 2.6))) + rim_px + 2) # The sharpen blur reads ±kernel-radius px, which outgrows TILE_HALO at - # export scale factors — without this, tile seams show in the USM band. + # large radii — without this, tile seams show in the USM band. # RL's influence spreads with iterations but decays geometrically; 6× the # kernel radius covers it in practice (widen if seams appear at extreme - # radius×scale). Capped by the 512 ceiling below. + # radius). Capped by the 512 ceiling below. if settings.lab.sharpen > 0: - k_radius = len(gaussian_kernel_1d(settings.lab.sharpen_radius * scale_factor)) // 2 + k_radius = len(gaussian_kernel_1d(settings.lab.sharpen_radius)) // 2 mult = 6 if settings.lab.sharpen_method == SharpenMethod.RL else 1 halo = max(halo, k_radius * mult) # Glow/halation taps reach up to their radius (max(., . * scale_factor) in @@ -2022,8 +2022,14 @@ def _analyze_global_bounds() -> LogNegativeBounds: ) full_source_res[ty : ty + th, tx : tx + tw] = self._readback_downsampled(tile_res)[oy : oy + th, ox : ox + tw] + # Mirrors PrintService.apply_layout: only INTER_AREA is area-correct on a shrink. + shrinking = content_w < crop_w or content_h < crop_h scaled_content = ( - cv2.resize(full_source_res, (content_w, content_h), interpolation=cv2.INTER_LINEAR) + cv2.resize( + full_source_res, + (content_w, content_h), + interpolation=cv2.INTER_AREA if shrinking else cv2.INTER_LANCZOS4, + ) if (content_w != crop_w or content_h != crop_h) else full_source_res ) diff --git a/tests/test_lab_logic.py b/tests/test_lab_logic.py index ca174619..faf94b1d 100644 --- a/tests/test_lab_logic.py +++ b/tests/test_lab_logic.py @@ -72,7 +72,7 @@ def test_output_sharpening(self) -> None: img = np.zeros((100, 100, 3), dtype=np.float32) img[25:75, 25:75, :] = 0.5 - res = apply_output_sharpening(img, amount=1.0, scale_factor=1.0) + res = apply_output_sharpening(img, amount=1.0) # Sharpening should increase variance on edges self.assertGreater(np.var(res), np.var(img)) @@ -95,7 +95,7 @@ def test_sharpen_no_overshoot_on_step(self) -> None: img = np.zeros((40, 40, 3), dtype=np.float32) img[:, 20:] = 0.8 - res = apply_output_sharpening(img, amount=1.0, scale_factor=1.0) + res = apply_output_sharpening(img, amount=1.0) l_in = rgb_to_lab_working(img)[..., 0] l_out = rgb_to_lab_working(res.astype(np.float32))[..., 0] @@ -107,7 +107,7 @@ def test_sharpen_flat_below_gate_passthrough(self) -> None: rng = np.random.default_rng(3) img = np.clip(0.5 + rng.normal(0, 0.001, (64, 64, 3)), 0.0, 1.0).astype(np.float32) - res = apply_output_sharpening(img, amount=1.0, scale_factor=1.0) + res = apply_output_sharpening(img, amount=1.0) l_in = rgb_to_lab_working(img)[..., 0] l_out = rgb_to_lab_working(res.astype(np.float32))[..., 0] @@ -122,8 +122,8 @@ def test_sharpen_masking_protects_flat_texture(self) -> None: img[:, 32:] = 0.8 img = np.clip(img + rng.normal(0, 0.02, img.shape), 0.0, 1.0).astype(np.float32) - res_open = apply_output_sharpening(img, amount=1.0, scale_factor=1.0, masking=0.0) - res_masked = apply_output_sharpening(img, amount=1.0, scale_factor=1.0, masking=1.0) + res_open = apply_output_sharpening(img, amount=1.0, masking=0.0) + res_masked = apply_output_sharpening(img, amount=1.0, masking=1.0) l_in = rgb_to_lab_working(img)[..., 0] l_open = rgb_to_lab_working(res_open.astype(np.float32))[..., 0] @@ -157,7 +157,7 @@ def test_rl_recovers_blurred_edge(self) -> None: axis=-1, ).astype(np.float32) - res = apply_rl_sharpening(blurred, amount=1.0, scale_factor=1.0, radius=1.0) + res = apply_rl_sharpening(blurred, amount=1.0, radius=1.0) step_y, blur_y, res_y = self._luminance(img), self._luminance(blurred), self._luminance(res.astype(np.float32)) self.assertLess(float(np.abs(res_y - step_y).mean()), float(np.abs(blur_y - step_y).mean())) @@ -170,8 +170,8 @@ def test_rl_masking_protects_flat_texture(self) -> None: img[:, 32:] = 0.8 img = np.clip(img + rng.normal(0, 0.02, img.shape), 0.0, 1.0).astype(np.float32) - res_open = apply_rl_sharpening(img, amount=1.0, scale_factor=1.0, radius=1.0, masking=0.0) - res_masked = apply_rl_sharpening(img, amount=1.0, scale_factor=1.0, radius=1.0, masking=1.0) + res_open = apply_rl_sharpening(img, amount=1.0, radius=1.0, masking=0.0) + res_masked = apply_rl_sharpening(img, amount=1.0, radius=1.0, masking=1.0) y_in, y_open, y_masked = ( self._luminance(img), @@ -190,7 +190,7 @@ def test_rl_preserves_chroma(self) -> None: img[:, :5] = [0.6, 0.2, 0.1] img[:, 5:] = [0.1, 0.5, 0.3] - res = apply_rl_sharpening(img, amount=1.0, scale_factor=1.0, radius=1.0) + res = apply_rl_sharpening(img, amount=1.0, radius=1.0) mask = img.min(axis=-1) > 0.01 cross = np.abs(res[..., 0] * img[..., 1] - res[..., 1] * img[..., 0]) @@ -588,3 +588,33 @@ def test_combined_brighter_than_individual(self) -> None: if __name__ == "__main__": unittest.main() + + +class SharpenScaleTests(unittest.TestCase): + """Radius is in output pixels, so the USM must act on fine detail rather than on + image-relative structure — a mid-frequency boost reads as contrast, not sharpness.""" + + @staticmethod + def _band_gain(before: np.ndarray, after: np.ndarray, period: int) -> float: + n = before.shape[1] + col = int(round(n / period)) + f_in = np.abs(np.fft.rfft(before.mean(axis=0)))[col] + f_out = np.abs(np.fft.rfft(after.mean(axis=0)))[col] + return float(f_out / max(f_in, 1e-9)) + + def test_usm_boosts_fine_detail_more_than_coarse(self) -> None: + x = np.arange(256, dtype=np.float32) + fine = 0.10 * np.sin(2 * np.pi * x / 3.0) + coarse = 0.10 * np.sin(2 * np.pi * x / 24.0) + img = np.repeat((0.5 + fine + coarse)[None, :], 64, axis=0) + img = np.repeat(img[..., None], 3, axis=2).astype(np.float32) + + res = np.asarray(apply_output_sharpening(img, amount=1.0, radius=1.0), dtype=np.float32) + + l_in = rgb_to_lab_working(img)[..., 0] + l_out = rgb_to_lab_working(res)[..., 0] + fine_gain = self._band_gain(l_in, l_out, 3) + coarse_gain = self._band_gain(l_in, l_out, 24) + + self.assertGreater(fine_gain, 1.2) + self.assertGreater(fine_gain, coarse_gain) diff --git a/tests/test_local_grade.py b/tests/test_local_grade.py index 2e6623c0..ed1597ae 100644 --- a/tests/test_local_grade.py +++ b/tests/test_local_grade.py @@ -8,6 +8,7 @@ from negpy.domain.models import WorkspaceConfig from negpy.features.exposure.logic import apply_characteristic_curve, local_grade_factor_map from negpy.features.exposure.models import EXPOSURE_CONSTANTS +from negpy.features.lab.models import LabConfig from negpy.features.local.models import LocalAdjustmentsConfig, LocalMask from negpy.services.rendering.engine import DarkroomEngine @@ -87,7 +88,9 @@ def _frame(self) -> np.ndarray: def _config(self, grade_delta: float) -> WorkspaceConfig: mask = LocalMask(vertices=((0.0, 0.0), (1.0, 0.0), (1.0, 0.5), (0.0, 0.5)), stops=0.0, feather=0.0, grade=grade_delta) - return WorkspaceConfig(local=LocalAdjustmentsConfig(masks=(mask,))) + # Sharpen off: its ±3 px support carries the mask edge a few rows past the + # boundary, which would hide the leak this test looks for. + return WorkspaceConfig(local=LocalAdjustmentsConfig(masks=(mask,)), lab=LabConfig(sharpen=0.0)) def _render(self, grade_delta: float) -> np.ndarray: return np.asarray(DarkroomEngine().process(self._frame(), self._config(grade_delta), source_hash=f"local-grade-{grade_delta}")) diff --git a/tests/test_print_service.py b/tests/test_print_service.py index 6be9f15b..d461cf1d 100644 --- a/tests/test_print_service.py +++ b/tests/test_print_service.py @@ -149,3 +149,22 @@ def test_target_px_ignores_print_size_and_dpi(): out_b, _ = PrintService.apply_layout(img, b) assert out_a.shape == out_b.shape assert max(out_a.shape[:2]) == 800 + + +def test_apply_layout_downscale_is_area_averaged(): + """A shrink must go through INTER_AREA. INTER_LANCZOS4/CUBIC silently degrade to + bilinear on an OpenCV downscale, which aliases and drops detail.""" + import cv2 + + rng = np.random.default_rng(11) + img = rng.random((900, 900, 3)).astype(np.float32) + config = ExportConfig( + export_target_long_edge_px=300, + paper_aspect_ratio="1:1", + export_resolution_mode=ExportResolutionMode.TARGET_PX.value, + ) + + result, (x, y, w, h) = PrintService.apply_layout(img, config) + + expected = cv2.resize(img, (w, h), interpolation=cv2.INTER_AREA) + np.testing.assert_allclose(result[y : y + h, x : x + w], expected, atol=1e-6) diff --git a/tests/test_scene_linear_relocation.py b/tests/test_scene_linear_relocation.py index baf2c217..a16b9ab3 100644 --- a/tests/test_scene_linear_relocation.py +++ b/tests/test_scene_linear_relocation.py @@ -19,29 +19,29 @@ # dye_separation 1.0). _GOLDEN = { "default": [ - (0.084367, 0.956394, 0.937821), - (0.084937, 0.947674, 0.950774), + (0.095545, 0.959373, 0.940689), + (0.084938, 0.947674, 0.950774), (0.277507, 0.270613, 0.268520), (0.204215, 0.208390, 0.207317), (0.084829, 0.083255, 0.938062), - (0.934362, 0.950343, 0.084608), + (0.932800, 0.948873, 0.081130), ], "expo_dark": [ (0.345343, 0.994696, 0.994101), (0.347270, 0.994423, 0.994522), (0.816296, 0.807605, 0.804870), (0.695260, 0.704071, 0.701831), - (0.346906, 0.341589, 0.994109), + (0.346328, 0.341166, 0.993554), (0.993984, 0.994508, 0.346161), ], # WB CMY sliders are absolute CC density (divided by the stretch range). "expo_cmy": [ - (0.065895, 0.966940, 0.879480), - (0.066364, 0.960373, 0.903259), - (0.228474, 0.307962, 0.194076), + (0.065897, 0.966940, 0.879480), + (0.066365, 0.960373, 0.903259), + (0.226797, 0.306151, 0.192477), (0.167046, 0.237717, 0.148179), (0.066275, 0.097582, 0.879914), - (0.901708, 0.962389, 0.055862), + (0.898741, 0.959553, 0.044090), ], }