Skip to content

Sharpen the pixels being exported, not the preview's - #780

Merged
marcinz606 merged 1 commit into
mainfrom
fix/export-sharpness
Aug 9, 2026
Merged

Sharpen the pixels being exported, not the preview's#780
marcinz606 merged 1 commit into
mainfrom
fix/export-sharpness

Conversation

@marcinz606

@marcinz606 marcinz606 commented Aug 8, 2026

Copy link
Copy Markdown
Owner

Root cause: sharpening landed on the wrong spatial frequency

apply_output_sharpening multiplied the USM radius by scale_factor = long_edge / 1600, so sharpen_radius = 1.0 became a σ ≈ 6 px blur on a 24 MP export. Measured through the real export path (61 MP frame, GPU tiled), as gain over the unsharpened buffer:

f (cyc/px) 0.05 0.125 0.25 0.375 0.475
before 1.78 2.14 1.58 1.59 1.59
after 1.05 1.27 1.83 2.10 2.19

The peak moved from 8 px structure to Nyquist. The export was never under-sharpened — it was sharpened at the wrong scale for its own pixels, which reads as contrast rather than sharpness at 1:1.

Radius is now in output pixels on both paths (lab/logic.py, gpu_engine.py, lab.wgsl), sharing gaussian_kernel_1d as before so CPU and GPU stay bit-matched.

The gate had to come with it

The L* noise gate was 1.5–2.0. At a true 1 px radius |L - blur| tops out near 1.0 (p50 0.20, p99 1.03 on a real scan), so the gate closed completely and the first cut of this change produced no sharpening at all. Rescaled to 0.25/0.33 — the old value over the ~6× it had been implicitly calibrated against. Picked against measured alternatives:

gate Nyquist gain grain gain
(0.50, 0.67) 1.81 1.18
(0.25, 0.33) 2.36 1.32
(0.15, 0.25) 2.47 1.34

Best detail-to-grain trade of the three.

Second fix: resized exports were not area-correct

The GPU tiled path downscaled with INTER_LINEAR, and the CPU path's INTER_LANCZOS4 is bit-identical to bilinear on an OpenCV downscale (measured maxdiff = 0.0 on 4.13 — no prefilter on a shrink). Both now pick INTER_AREA when shrinking. Affects PRINT / TARGET_PX only; the ORIGINAL default never resampled.

Ruled out

No hidden blur at defaults (only the lab USM touches pixels; CLAHE, chroma denoise, glow, halation, IR/dust, flat-field and carrier are all gated off). No textureSample anywhere on GPU. No proxy leaking into export — the source cache key includes fast_decode. Raw decode is clean: 16-bit, no FBDD, no median passes, no wavelet denoise, half_size never set on export. JPEG is explicitly 4:4:4.

Left open

Demosaic is AHD for Bayer, the softest option in the build (DCB, AAHD, VNG also available). A laplacian-variance sweep favoured AAHD ~2.6×, but that metric cannot separate detail from grain on a film scan and a flat-versus-detailed patch split did not resolve it. Wants a slanted-edge target before anything changes.

Also noted, not touched: _to_uint8_jit / _to_uint16_jit truncate instead of rounding, while the luma variants round. Half-LSB downward bias on RGB export, unrelated to sharpness.

Tests

make all green, 3613 passed. Two regression tests added — the USM must boost fine detail over coarse (test_lab_logic.py), and a layout downscale must match INTER_AREA (test_print_service.py). The relocation golden was regenerated; the characteristic-curve golden did not move, which is consistent since sharpen is not in the exposure kernel.

test_unmasked_half_is_bit_identical now pins sharpening off: a genuine ±3 px USM support carries the mask edge a few rows past the boundary on a 48 px test frame, which would hide the leak that test looks for.

Docs updated in the same change — PIPELINE.md (radius units, gate values) and USER_GUIDE.md (the preview no longer shows export acutance at fit-to-window; judge at 1:1).

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.
@marcinz606
marcinz606 marked this pull request as ready for review August 9, 2026 05:39
@marcinz606
marcinz606 merged commit c20951a into main Aug 9, 2026
1 check passed
@marcinz606
marcinz606 deleted the fix/export-sharpness branch August 9, 2026 05:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant