Detail pass

The detail pass is AgX's three-slider neighborhood stage. It runs on the gamma Rec.2020 working-space buffer after the per-pixel tone, HSL, color grading, and LUT work, but before the final conversion back to linear RGB. The pass covers sharpening, clarity, and texture with one common idea: build a blurred version of the image, subtract it from the original to isolate a frequency band, then add some of that band back in. All three controls work on luminance only, so the code can change local detail without pulling color channels apart and creating colored halos.

How it works

The shared machinery is a separable Gaussian blur plus a luminance-only unsharp mask:

  1. Convert each RGB pixel to luminance with the Rec. 709 weights stored in the parent adjust module as LUMA_R, LUMA_G, and LUMA_B (referenced as super::LUMA_R etc. inside detail.rs).
  2. Build a 1D Gaussian kernel whose half-width is ceil(3 * sigma).
  3. Blur horizontally, then vertically, clamping sample coordinates at the image edges.
  4. Compute high_freq = luminance - blurred_luminance.
  5. Add strength * high_freq back into R, G, and B equally.

That shared structure is what keeps the three sliders consistent. The pass applies the controls sequentially to the evolving buffer in this order: texture, then clarity, then sharpening. That ordering matters: later sliders see the result of earlier ones instead of always sampling the original image. The only thing that changes between the three sub-passes is the blur scale and, for sharpening, the extra threshold/masking gates.

Texture

Texture targets fine detail. In the current implementation it uses a fixed sigma of 3.0, so it reacts to the highest-frequency visible structure in the image: pores, fabric weave, leaf edges, and other small surface variation. Positive values add that fine-scale contrast back in; negative values soften it.

Because texture is just the shared unsharp-mask pipeline with a small blur radius, it stays local. It does not try to infer semantic regions or preserve edges differently from texture. It simply works at the finest band the pass exposes.

Clarity

Clarity uses the same unsharp-mask math, but with a much broader fixed sigma of 20.0. That moves the effect into the mid-frequency range: larger local transitions, broad texture, and the kind of tonal modulation that reads as "punch" rather than crisp edge sharpening.

Positive clarity strengthens that medium-scale contrast. Negative clarity softens it. The slider is therefore symmetric in sign but not in scale: it is a single frequency-band adjustment, not a separate contrast system.

Sharpening

Sharpening is the most controlled of the three. It uses the user-facing radius slider as the Gaussian sigma, with a floor of 0.1 so the kernel stays well-defined. That makes the control act on the lower end of the fine-detail range: edge crispness, micro-contrast, and the smallest recoverable structures.

Sharpening adds two gates on top of the basic unsharp mask:

  • threshold removes low-magnitude high-frequency differences before they get amplified. In code, the slider is converted with threshold / 255.0, and pixels below that absolute luminance delta are left unchanged. Note the slider feels inverted relative to intuition: a higher threshold value protects more pixels from sharpening (only strong edges pass through), so it suppresses sharpening; a lower value lets even subtle detail get sharpened.
  • masking computes a simple edge map from luminance gradients and uses smoothstep (a Hermite interpolation that smoothly transitions from 0.0 to 1.0 as the input crosses a band) to limit sharpening to stronger edges. The edge map is normalized with the fixed EDGE_SCALE = 4.0 constant so the slider behaves consistently across images. GPU caveat: the GPU dispatcher currently hard-codes detail_masking = 0.0, so this gate is CPU-only today (see Source below).

The result is a conventional sharpening control with a little more protection against noise and smooth-surface artifacts than a plain unsharp mask.

Why we chose it

AgX uses a multi-scale unsharp-mask model because it fits photo-editing expectations well: texture, clarity, and sharpening map cleanly to increasing spatial frequency bands, and the behavior is easy to reason about from preset values alone.

AgX also keeps the implementation conservative on purpose. Texture and clarity use fixed blur scales, while sharpening keeps only one user radius. That makes presets portable: a value means the same thing across images instead of depending on a learned model or per-photo tuning.

The neutral case is equally important. DetailParams::is_neutral() checks only the active effect fields - sharpening amount, clarity, and texture. Radius, threshold, and masking are ignored when sharpening amount is zero, so the pass can short-circuit completely when the detail panel is effectively off.

Parameters and constants

The public sliders live on DetailParams and SharpeningParams. The rest of the numbers below are fixed in code.

ControlRangeDefaultRole
sharpening.amount0.0..=100.00.0Sharpening strength
sharpening.radius0.5..=3.01.0Sharpening blur sigma
sharpening.threshold0.0..=100.025.0Hard cutoff for low-magnitude detail
sharpening.masking0.0..=100.00.0Edge-aware sharpening gate (CPU-only — GPU path hard-codes 0.0)
clarity-100.0..=100.00.0Mid-frequency local contrast
texture-100.0..=100.00.0Fine-frequency local contrast
ConstantValueRoleSensitivity
TEXTURE_SIGMA3.0Fixed blur scale for textureDefines what "fine" means for the texture slider. Halving it makes texture target even finer detail (single-pixel structure); doubling pushes texture into the same band as clarity.
CLARITY_SIGMA20.0Fixed blur scale for clarityDefines what "mid-frequency" means. Smaller values make clarity behave like a stronger texture; larger values push it toward global tonal contrast.
SHARPEN_RADIUS_DEFAULT1.0Default sharpening radiusSets the "no radius specified" baseline.
SHARPEN_THRESHOLD_DEFAULT25.0Default sharpening thresholdSets the "no threshold specified" baseline. The chosen value is conservative enough that sharpening rarely amplifies smooth-area noise.
SHARPEN_RADIUS_MIN / MAX0.5 / 3.0Sharpening radius boundsSchema bounds; widening would let users pick blurs so wide the result reads as halos rather than crispness.
SHARPEN_THRESHOLD_MIN / MAX0.0 / 100.0Sharpening threshold boundsSchema bounds.
SHARPEN_MASKING_MIN / MAX0.0 / 100.0Sharpening masking boundsSchema bounds.
DETAIL_SLIDER_MIN / MAX-100.0 / 100.0Clarity and texture boundsSchema bounds; the slider feel was calibrated against this range.
EDGE_SCALE4.0Fixed gradient normalization for maskingCalibrates how the masking slider feels across images. Smaller values make masking gate harder (only the strongest edges sharpen); larger values let masking pass more of the image through.

Beyond the expected range: preset validation rejects out-of-range values for every public slider — sharpening.amount, threshold, masking, and clarity / texture are all hard-clamped at 0.0..=100.0 (or ±100.0 for clarity / texture) before the algorithm runs, and sharpening.radius is rejected outside 0.5..=3.0. The internal constants are not user-addressable.

Preset-slider mapping

In preset TOML, the detail pass lives under one [detail] block, with sharpening nested underneath:

[detail]
clarity = 30.0
texture = 15.0

[detail.sharpening]
amount = 40.0
radius = 1.0
threshold = 25.0
masking = 50.0

That maps directly to DetailParams and SharpeningParams. Missing fields fall back to the defaults above, and an entirely absent [detail] section materializes as a neutral detail pass.

Source

The CPU path implements the full threshold and masking behavior. The current GPU dispatcher runs the same three sequential passes, but it sets detail_masking = 0.0 today, so the masking gate is not yet part of the GPU path.

References

No canonical external paper applies — the unsharp-mask construction is the standard photo-editing formulation, and AgX's three-band split plus the EDGE_SCALE = 4.0 calibration are AgX-specific design choices recorded inline in the source.

See also