Dehaze
Pipeline
flowchart TD
I["Input I (linear RGB)"] --> DC["dark_channel(I) -- 15x15 patch min"]
DC --> A["estimate_airlight -- top 0.1% of dark channel"]
A --> Branch{"amount sign"}
Branch -- negative --> Blend["I*(1 - s) + A*s, s = -amount/100"]
Branch -- positive --> Norm["normalize: I / max(A, 0.01)"]
Norm --> DCN["dark_channel(I/A)"]
DCN --> Traw["t_raw = 1 - omega * dc_norm, omega = amount/100"]
I --> Guide["luma guide (Rec. 709)"]
Guide --> GF["guided_filter -- radius 40, eps 0.001"]
Traw --> GF
GF --> Tref["t (refined)"]
A --> Rec
Tref --> Rec["recover: J = (I - A) / max(t, 0.1) + A"]
Rec --> Out["Output J, clamp 0..1"]
Blend --> Out
Positive amount runs the full Dark Channel Prior recovery path; negative amount reuses the airlight estimate to add scene-aware fog and skips the transmission and guided-filter stages.
Haze lowers contrast because distant scene light is attenuated on the way to the camera and mixed with a veil of atmospheric light. AgX's dehaze pass models that veil explicitly: positive values estimate how much airlight was added and subtract it back out, while negative values do the inverse and re-introduce haze. That negative path matters in practice because it gives preset authors a scene-aware way to pair a softer atmospheric look with stronger contrast, clarity, or color treatments, instead of settling for a flatter-looking image.
How it works
The implementation follows the Dark Channel Prior pipeline from He, Sun, and Tang1, but swaps the original soft-matting refinement for a guided filter in the later He, Sun, and Tang formulation2. In AgX the pass runs on linear RGB data after white balance and exposure, so the haze model operates on physically meaningful intensities before the later gamma-space tonal work.
The haze model is:
I(x) = J(x) * t(x) + A * (1 - t(x))
where I is the observed hazy image, J is the recovered scene
radiance, A is the global atmospheric light color, and t(x) is the
per-pixel transmission. The core idea behind the dark channel prior is
that, in most non-sky outdoor patches, at least one RGB channel gets
very close to zero somewhere in the patch. Haze lifts those dark values
toward the airlight color, so the local minimum becomes a useful haze
estimate.
AgX computes that estimate in five stages for positive values:
dark = dark_channel(I)
A = brightest original pixel among top 0.1% of dark-channel values
if amount < 0:
strength = clamp(-amount / 100, 0, 1)
return clamp(I * (1 - strength) + A * strength, 0, 1)
dc_norm = dark_channel(I / max(A, 0.01))
t_raw = 1 - omega * dc_norm
guide = luma(I)
t = guided_filter(guide, t_raw)
J = clamp((I - A) / max(t, 0.1) + A, 0, 1)
Stage by stage:
dark_channel()takes the per-pixelmin(R, G, B), then applies a separable15 x 15min filter. The Rust path uses an O(n) monotonic deque inmin_filter_1d()for each row and column; the GPU path splits the same work acrossdehaze_pixel_min.wgslanddehaze_min_filter.wgsl.estimate_airlight()looks at the top0.1%brightest values in the dark channel and, among those candidate pixels, picks the original RGB pixel with the highestr + g + b. That gives a scene-specific airlight color instead of assuming neutral gray.- For positive dehaze only, the image is normalized by
Aand clamped to a minimum denominator of0.01per channel to avoid unstable division. The normalized dark channel drives the raw transmission estimatet_raw = 1 - omega * dc_norm, whereomega = amount / 100. guided_filter()refinest_rawwith a grayscale guide derived from Rec.709 luminance. The filter computes local linear coefficientsaandb, box-filters them, and reconstructst_refined = mean(a) * guide + mean(b). This removes the blockiness from the patch min filter without washing transmission across hard edges.- The recovery step applies
J = (I - A) / max(t, 0.1) + A, then clamps each channel back to[0, 1]. The0.1floor prevents very small transmission values from exploding noise and halos in dense haze.
Negative values reuse only the first two stages. AgX still estimates the
dark channel and atmospheric light so the added haze is colored by the
image's own airlight, then skips transmission estimation and guided
filtering entirely and blends linearly toward A. That is why negative
dehaze behaves like a scene-aware fog control rather than a generic gray
overlay.
Why we chose it
Dark Channel Prior is a good fit for AgX's constraints: one slider, no per-image tuning, strong results on the outdoor scenes where users expect dehaze to help, and a physically interpretable negative mode that can add haze as well as remove it. AgX intentionally keeps the user model simple. There is only one public strength control; patch size, transmission floor, guided filter radius, and the airlight percentile stay fixed so presets remain portable and predictable.
The same design also chose guided filtering over the original soft-matting refinement. Soft matting is higher ceremony and more expensive for this use case; guided filtering gives the edge-aware transmission cleanup the algorithm needs while staying O(N) and much easier to implement consistently on CPU and GPU.
AgX also keeps the later performance work separate from the image model. Dehaze had become the main CPU bottleneck at large resolutions, so the implementation parallelizes the row and column passes of the separable min and box filters and the embarrassingly parallel pixel loops. The important point for this explanation is that those throughput changes do not change the math: they only change how the same dark-channel, guided-filter, and recovery steps are scheduled.
Parameters and constants
The user-facing control is DehazeParams.amount. Everything below is
fixed in code.
| Constant | Value | Role | Sensitivity |
|---|---|---|---|
DEHAZE_AMOUNT_MIN | -100.0 | Lowest accepted slider value | Schema bound; widening it would require retuning the strength mapping. |
DEHAZE_AMOUNT_MAX | 100.0 | Highest accepted slider value | Same — ±100 already saturates omega to 1.0. |
| Neutral amount | 0.0 | Skips the pass entirely via is_neutral() | Hard zero; any non-zero value runs the full pipeline. |
PATCH_SIZE | 15 | Dark-channel patch width and height | Smaller patches follow local detail tighter but produce noisier transmission; larger patches over-smooth and miss fine haze transitions. |
AIRLIGHT_PERCENTILE | 0.001 | Top 0.1% of dark-channel samples used for airlight candidates | Deliberately tiny so airlight comes from the haziest pixels, not ordinary bright surfaces. Raising it 10× picks sunlit objects; lowering it makes the estimate brittle on small images. |
GUIDED_FILTER_RADIUS | 40 | Radius of the guided filter box windows | Has to be much larger than the min-filter window to smooth its artifacts. Halving leaves visible patch texture in the transmission; doubling over-smooths and bleeds across edges. |
GUIDED_FILTER_EPSILON | 0.001 | Regularizer for the guided filter coefficients | Larger values flatten local contrast; smaller values hug edges tighter but preserve more noise. A 10× change is visibly different in haze-edge fidelity. |
| Airlight denominator floor | 0.01 | Minimum A component when normalizing I / A | Defensive — prevents division blowup on tiny airlight components. Raising it desaturates the recovered image; lowering it can cause speckle on near-black scenes. |
T_MIN | 0.1 | Minimum transmission during recovery | Caps recovery gain at 1 / T_MIN = 10×. Lower values restore more in dense haze but can overshoot to white; higher values leave dense haze visibly under-recovered. |
| Output clamp | [0.0, 1.0] | Keeps recovered pixels in valid linear RGB range | Hard clamp; not a tuning knob. |
omega / fog strength ceiling | 1.0 | Caps amount / 100 and -amount / 100 | Hard saturation at the slider extremes; not user-tunable. |
| Rayon chunk size | 1024 | Work scheduling chunk for parallel pixel loops | Affects only CPU thread scheduling — output is identical regardless. Tiny chunks add overhead; very large chunks reduce parallelism. |
Beyond the expected range: preset validation rejects amount
outside -100.0..=100.0, so out-of-range values never reach the
algorithm. The internal constants above are not user-addressable.
Preset-slider mapping
In preset TOML, dehaze is a single-field block:
[dehaze]
amount = 40.0
That serialized amount maps directly to DehazeParams.amount, with
validation at -100.0..=100.0 and a default of 0.0 when the field or
section is absent.
The slider semantics are intentionally simple:
0is neutral and skips the pass.- Positive values map linearly to
omega = amount / 100.0, so100means "use the full transmission estimate" and smaller values back the effect off proportionally. - Negative values map linearly to
strength = -amount / 100.0, but they do not run the positive recovery equation with a negative sign. They take the scene-aware airlight estimate and blend toward it, which is why negative dehaze feels like adding atmosphere instead of merely lowering contrast.
In other words, the slider is symmetric in range but asymmetric in behavior: positive values solve the haze model, negative values reuse the haze color estimate to synthesize fog.
Source
- CPU (Rust):
crates/agx/src/adjust/dehaze.rs - GPU (dehaze-specific WGSL kernels):
The Rust file above is the canonical CPU implementation. The WGSL list here is limited to the dehaze-specific kernels that implement the GPU side of the same stages; shared supporting shader infrastructure lives elsewhere in the render pipeline.
References
Kaiming He, Jian Sun, and Xiaoou Tang (2009). Single Image Haze Removal Using Dark Channel Prior. CVPR 2009. DOI: https://doi.org/10.1109/CVPR.2009.5206515 PDF: https://people.csail.mit.edu/kaiming/publications/cvpr09.pdf
Kaiming He, Jian Sun, and Xiaoou Tang (2013, online-first 2012). Guided Image Filtering. IEEE Trans. Pattern Anal. Mach. Intell. 35(6): 1397–1409. DOI: https://doi.org/10.1109/TPAMI.2012.213 PDF: https://people.csail.mit.edu/kaiming/publications/pami12guidedfilter.pdf
See also
- Concept references: Detail (dehaze entry)
- API references: dehaze
- Related explanations: Detail pass, Noise reduction
- How-tos: Write your own preset