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
12 changes: 6 additions & 6 deletions docs/PIPELINE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)$$

Expand Down
2 changes: 1 addition & 1 deletion docs/USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:**
Expand Down
25 changes: 15 additions & 10 deletions negpy/features/lab/logic.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -121,15 +123,15 @@ 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")
gx = (lp[1:-1, 2:] - lp[1:-1, :-2]) * np.float32(0.5)
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:
Expand All @@ -141,29 +143,31 @@ 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:
"""
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

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)
Expand All @@ -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:
Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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]
Expand Down
1 change: 0 additions & 1 deletion negpy/features/lab/processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down
8 changes: 4 additions & 4 deletions negpy/features/lab/shaders/lab.wgsl
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ struct LabUniforms {
@group(0) @binding(3) var sharpen_tex: texture_2d<f32>;

// 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;
Expand Down Expand Up @@ -396,7 +396,7 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
// 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
Expand Down Expand Up @@ -426,7 +426,7 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
}
}
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);
Expand Down
9 changes: 8 additions & 1 deletion negpy/services/export/print.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
18 changes: 12 additions & 6 deletions negpy/services/rendering/gpu_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
)
Expand Down
48 changes: 39 additions & 9 deletions tests/test_lab_logic.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -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]
Expand All @@ -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]
Expand All @@ -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]
Expand Down Expand Up @@ -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()))
Expand All @@ -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),
Expand All @@ -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])
Expand Down Expand Up @@ -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)
Loading
Loading