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:

  1. dark_channel() takes the per-pixel min(R, G, B), then applies a separable 15 x 15 min filter. The Rust path uses an O(n) monotonic deque in min_filter_1d() for each row and column; the GPU path splits the same work across dehaze_pixel_min.wgsl and dehaze_min_filter.wgsl.
  2. estimate_airlight() looks at the top 0.1% brightest values in the dark channel and, among those candidate pixels, picks the original RGB pixel with the highest r + g + b. That gives a scene-specific airlight color instead of assuming neutral gray.
  3. For positive dehaze only, the image is normalized by A and clamped to a minimum denominator of 0.01 per channel to avoid unstable division. The normalized dark channel drives the raw transmission estimate t_raw = 1 - omega * dc_norm, where omega = amount / 100.
  4. guided_filter() refines t_raw with a grayscale guide derived from Rec.709 luminance. The filter computes local linear coefficients a and b, box-filters them, and reconstructs t_refined = mean(a) * guide + mean(b). This removes the blockiness from the patch min filter without washing transmission across hard edges.
  5. The recovery step applies J = (I - A) / max(t, 0.1) + A, then clamps each channel back to [0, 1]. The 0.1 floor 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.

ConstantValueRoleSensitivity
DEHAZE_AMOUNT_MIN-100.0Lowest accepted slider valueSchema bound; widening it would require retuning the strength mapping.
DEHAZE_AMOUNT_MAX100.0Highest accepted slider valueSame — ±100 already saturates omega to 1.0.
Neutral amount0.0Skips the pass entirely via is_neutral()Hard zero; any non-zero value runs the full pipeline.
PATCH_SIZE15Dark-channel patch width and heightSmaller patches follow local detail tighter but produce noisier transmission; larger patches over-smooth and miss fine haze transitions.
AIRLIGHT_PERCENTILE0.001Top 0.1% of dark-channel samples used for airlight candidatesDeliberately 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_RADIUS40Radius of the guided filter box windowsHas 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_EPSILON0.001Regularizer for the guided filter coefficientsLarger 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 floor0.01Minimum A component when normalizing I / ADefensive — prevents division blowup on tiny airlight components. Raising it desaturates the recovered image; lowering it can cause speckle on near-black scenes.
T_MIN0.1Minimum transmission during recoveryCaps 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 rangeHard clamp; not a tuning knob.
omega / fog strength ceiling1.0Caps amount / 100 and -amount / 100Hard saturation at the slider extremes; not user-tunable.
Rayon chunk size1024Work scheduling chunk for parallel pixel loopsAffects 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:

  • 0 is neutral and skips the pass.
  • Positive values map linearly to omega = amount / 100.0, so 100 means "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

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

1

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

2

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