No sliders. No presets. Just math that listens to your image.
If you've ever used CLAHE (Contrast Limited Adaptive Histogram Equalization) in OpenCV, you've probably written something like this:
clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
And then spent the next hour tweaking those two magic numbers until the result looked "good enough."
PACE doesn't do that.
PACE is a perceptual image enhancement pipeline I've been building that analyses each image's statistical fingerprint and derives its own clipLimit and tileSize — before a single pixel of CLAHE is applied. This post is a deep dive into exactly how that works.
The Two Parameters That Define CLAHE
Before getting into the adaptive logic, let's establish what these parameters actually control:
clipLimit — The clipping threshold applied to each tile's histogram before redistribution. Higher values allow more aggressive contrast stretching. Lower values keep the enhancement conservative. Set it too high and you get halos and noise amplification. Too low and you might as well not bother.
tileSize — The spatial granularity of the local enhancement. Smaller tiles capture fine local structure but risk over-enhancing noise. Larger tiles are smoother but might miss subtle detail.
The challenge: the "right" value for both of these is image-dependent. A low-key portrait shot needs very different treatment than a high-contrast landscape or a noisy night photograph.
Step 1: Reading the Image
PACE starts by extracting a statistical fingerprint from the luminance channel (after converting to OKLab). This happens in extractGlobalFeatures.js and produces:
{
distribution: {
mean, // average luminance
variance, // spread of tones
entropy, // information density (normalized to [0,1])
dynamicRange, // p95 - p5 percentile range
shadowRatio, // fraction of pixels below 0.2
highlightRatio, // fraction of pixels above 0.8
skewness,
kurtosis
},
structure: {
edgeDensity, // noise-adjusted, soft-normalised edge signal
textureIndex // gradient variance / mean gradient
},
noise: {
noiseRatio, // local deviation vs global variance
microContrast
}
}
This isn't just metadata. These values are the inputs to a small analytical engine that computes the CLAHE parameters.
Step 2: Computing clipLimit
Inside computeControlParams(), the clip limit is derived from a single intermediate called structureConfidence:
const structureConfidence = edgeDensity / (1 + noiseRatio);
This is a signal-to-noise ratio for spatial structure. It asks: how much of the gradient energy in this image is real structure, as opposed to noise?
- A sharp, textured image (high
edgeDensity, lownoiseRatio) → highstructureConfidence - A soft or noisy image → low
structureConfidence
Then:
const clipLimit = 0.02 + 0.08 * structureConfidence;
This maps structureConfidence ∈ [0, 1] to clipLimit ∈ [0.02, 0.10].
Why this makes sense:
When structure confidence is high, the image genuinely has strong local contrast variation that CLAHE should bring out. A higher clip limit allows the histogram redistribution to be more aggressive — safe to do because the edges are real.
When the image is noisy or flat, a conservative clip limit prevents the CLAHE from amplifying noise into artificial "texture." The floor of 0.02 ensures some enhancement always happens.
Step 3: Computing tileSize
The tile size logic introduces one more intermediate — granularity:
const granularity = structureConfidence - 0.5 * noiseRatio;
This refines the structure signal by penalising noise more directly. Even if edgeDensity is high, if a significant chunk of it is noise-driven, we want larger tiles.
Then:
let tileSize = 32 - 16 * granularity;
tileSize = Math.max(8, Math.min(64, tileSize));
tileSize = Math.round(tileSize / 8) * 8;
Reading this:
granularity |
Raw tileSize
|
Meaning |
|---|---|---|
| 1.0 (sharp, clean) | 16px | Fine-grained — trust local structure |
| 0.5 (balanced) | 24px | Moderate |
| 0.0 (flat/noisy) | 32px | Coarser — smooth out noise |
| -0.5 (very noisy) | 40px | Conservative |
The clamp to [8, 64] prevents pathological cases. The Math.round(.../ 8) * 8 snaps to multiples of 8 — a practical optimisation since tile boundaries align better with typical image dimensions and cache lines.
The Full computeControlParams Function
Here's the complete logic, stripped down:
function computeControlParams(features) {
const { entropy, dynamicRange, shadowRatio, highlightRatio } = features.distribution;
const { edgeDensity } = features.structure;
const { noiseRatio } = features.noise;
// How much contrast improvement does this image need?
const contrastNeed = (1 - entropy) * (1 - dynamicRange);
// How much can we trust the edge signal?
const structureConfidence = edgeDensity / (1 + noiseRatio);
// Tonal imbalance (is one end of the histogram overloaded?)
const imbalance = Math.abs(shadowRatio - highlightRatio);
// --- Global Alpha (blending weight) ---
const alphaRaw =
0.5 * imbalance +
0.3 * contrastNeed +
0.4 * structureConfidence;
const globalAlpha = alphaRaw / (alphaRaw + 0.5);
// --- Tile Size ---
const granularity = structureConfidence - 0.5 * noiseRatio;
let tileSize = 32 - 16 * granularity;
tileSize = Math.max(8, Math.min(64, tileSize));
tileSize = Math.round(tileSize / 8) * 8;
// --- Clip Limit ---
const clipLimit = 0.02 + 0.08 * structureConfidence;
return { globalAlpha, tileSize, clipLimit };
}
Notice that globalAlpha — which controls how strongly the CLAHE result is blended into the final output — is computed from the same intermediates. The whole parameter set is internally consistent. An image that earns a high structureConfidence gets a higher clip limit and a finer tile size and a stronger blend weight. The enhancement scales coherently.
Step 4: The CLAHE Implementation
PACE implements CLAHE from scratch in CLAHE.js. The core is a two-phase process:
Phase 1 — Build tile LUTs:
function buildTileLUTs(gray, w, h, tileSize, clipLimit) {
// For each tile:
// 1. Build a 256-bin histogram
// 2. Clip the histogram at: Math.floor(clipLimit * tileArea)
// 3. Redistribute the clipped excess uniformly across all bins
// 4. Compute CDF → LUT mapping [0..255] → [0..255]
}
The clip threshold is computed as clipLimit * tileArea — so the same clipLimit value naturally scales with tile size. A 16×16 tile has 256 pixels; a 32×32 tile has 1024. The clip is proportional, not absolute.
Phase 2 — Bilinear interpolation:
Rather than applying one tile's LUT per pixel (which creates visible block boundaries), PACE maps each pixel into the tile grid and bilinearly interpolates between the four surrounding tile LUTs:
const v_tl = luts[ty][tx][g];
const v_tr = luts[ty][tx1][g];
const v_bl = luts[ty1][tx][g];
const v_br = luts[ty1][tx1][g];
out[y * w + x] =
v_tl * (1 - fx) * (1 - fy) +
v_tr * fx * (1 - fy) +
v_bl * (1 - fx) * fy +
v_br * fx * fy;
This is the standard CLAHE bilinear interpolation scheme. The output is smooth regardless of tile boundaries.
A Worked Example
Say you feed PACE a foggy landscape — low contrast, flat histogram, minimal noise:
| Feature | Value |
|---|---|
entropy |
~0.85 (information-rich despite low contrast) |
dynamicRange |
~0.3 (compressed tones) |
edgeDensity |
~0.25 (soft, diffuse edges) |
noiseRatio |
~0.1 (relatively clean) |
Computing:
structureConfidence = 0.25 / (1 + 0.1) ≈ 0.23-
clipLimit = 0.02 + 0.08 × 0.23 ≈ 0.038→ conservative stretching granularity = 0.23 - 0.5 × 0.1 = 0.18tileSize = 32 - 16 × 0.18 ≈ 29 → rounded to 32
Now feed it a sharp architectural photo — strong edges, clean capture:
| Feature | Value |
|---|---|
edgeDensity |
~0.72 |
noiseRatio |
~0.05 |
Computing:
structureConfidence = 0.72 / 1.05 ≈ 0.69-
clipLimit = 0.02 + 0.08 × 0.69 ≈ 0.075→ more aggressive granularity = 0.69 - 0.025 = 0.665tileSize = 32 - 16 × 0.665 ≈ 21 → rounded to 24
The architecture shot gets a higher clip limit (more contrast punch) and a smaller tile size (finer local detail). The fog shot gets conservative treatment with larger tiles that smooth over the low-signal regions. No manual tuning. Just the image telling PACE what it needs.
Here is the visual as well as quantitative comparison between original image(input) and CLAHE image(output)
Visual comparison
Quantitative comparison
Why Not Just Use a Global Histogram Equalization?
Because global equalization is blind to spatial structure. It will happily blow out a bright sky to recover shadow detail in a corner, creating unnatural tonal shifts. CLAHE's tile-based approach means the histogram is equalized locally, so regions with different tonal characteristics are treated independently.
But static CLAHE parameters mean you're still guessing — just at a coarser level. PACE's adaptive approach closes the loop: the image's own statistics define the operating point for CLAHE, which then feeds into a broader perceptual blending pipeline (Retinex detail, Laplacian texture, edge gain, halo suppression) that further sculpts the final output.
What's Next
The current structureConfidence formulation is relatively simple — a ratio of edge density to noise. There's room to refine this further:
- Using the
textureIndex(gradient variance / mean gradient) to distinguish between fine texture and strong structural edges - Feeding
kurtosisinto the clip limit to handle bimodal histograms (silhouette shots, backlit subjects) - A per-channel confidence estimate rather than a single luminance-based signal
The structureConfidence file already hints at a more sophisticated version:
function computeStructureConfidence(edgeDensity, noiseRatio) {
const edgeSignal = 1 - Math.exp(-3.0 * edgeDensity);
const noiseSuppression = 1 / (1 + 2.5 * noiseRatio);
const baseline = 0.55;
return baseline + (1 - baseline) * edgeSignal * noiseSuppression;
}
This exponential formulation gives a softer, more robust curve than the current linear ratio — less sensitive to extreme values of edgeDensity. It's on the roadmap.
Wrapping Up
The key insight driving all of this is that CLAHE parameters aren't free variables — they're functions of image structure. Edge density, noise ratio, and tonal distribution each contain actionable information about how aggressively the enhancement should operate and at what spatial scale.
PACE makes this explicit: extract the features, compute the parameters analytically, apply consistently. The result is an enhancement pipeline that adapts to each image without needing a human in the loop to dial in settings.
If you're building image processing tools and you're still hardcoding clip limits, try deriving them. Your images will tell you what they need.
PACE is an open research project. The full source including the adaptive parameter controller, CLAHE implementation, and perceptual blending pipeline is on GitHub.



Top comments (0)