<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Building a Zero-Config Image Enhancer in JavaScript (Runs Fully in the Browser)]]></title><description><![CDATA[PACE is a browser-based image enhancement system that analyzes contrast, noise, and structure to adaptively enhance images using JavaScript—no server or uploads required.]]></description><link>https://pacejs.hashnode.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/69f8b75046610fd606f6c8d8/bfd475a6-868a-433c-b934-cc6ed4ed075f.png</url><title>Building a Zero-Config Image Enhancer in JavaScript (Runs Fully in the Browser)</title><link>https://pacejs.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Wed, 14 Oct 2026 06:23:17 GMT</lastBuildDate><atom:link href="https://pacejs.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[How to Auto-Compute CLAHE Parameters Intelligently for Every Image.]]></title><description><![CDATA[Most CLAHE implementations ship with hardcoded clipLimit = 2.0 and tileSize = 8. This works acceptably on some images and badly on many. PACE takes a different approach: it reads the image first, then]]></description><link>https://pacejs.hashnode.dev/how-to-auto-compute-clahe-parameters-intelligently-for-every-image</link><guid isPermaLink="true">https://pacejs.hashnode.dev/how-to-auto-compute-clahe-parameters-intelligently-for-every-image</guid><category><![CDATA[JavaScript]]></category><category><![CDATA[Web Development]]></category><category><![CDATA[Computer Vision]]></category><category><![CDATA[image processing]]></category><category><![CDATA[Open Source]]></category><category><![CDATA[algorithms]]></category><dc:creator><![CDATA[muhammed shahid]]></dc:creator><pubDate>Wed, 06 May 2026 14:52:25 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/69f8b75046610fd606f6c8d8/e27f5601-9a85-4641-90e7-12208a45ec17.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<blockquote>
<p>Most CLAHE implementations ship with hardcoded <code>clipLimit = 2.0</code> and <code>tileSize = 8</code>. This works acceptably on some images and badly on many. PACE takes a different approach: it reads the image first, then computes the right parameters for <em>that specific image</em>. Here's exactly how it does it.</p>
</blockquote>
<p><strong>GitHub:</strong> <a href="https://github.com/muhammedshahid/pace">github.com/muhammedshahid/pace</a><br /><strong>Paper preprint:</strong> <a href="https://dx.doi.org/10.2139/ssrn.6661421">dx.doi.org/10.2139/ssrn.6661421</a><br /><strong>Archived source:</strong> <a href="https://zenodo.org/doi/10.5281/zenodo.19203394">zenodo.org/doi/10.5281/zenodo.19203394</a><br /><strong>Demo:</strong> <a href="https://muhammedshahid.github.io/pace-beta/src/">https://muhammedshahid.github.io/pace-beta/src/</a></p>
<hr />
<h2>The Problem With Fixed CLAHE Parameters</h2>
<p>CLAHE (Contrast Limited Adaptive Histogram Equalization) is one of the most effective local contrast enhancement algorithms ever designed. It's used in medical imaging, satellite photography, computational photography, and a hundred other fields.</p>
<p>It also has two parameters that most people never think carefully about, and that profoundly affect the output:</p>
<p><code>clipLimit</code> — the threshold at which histogram bins are clipped before equalization. A low clip limit produces conservative enhancement. A high clip limit produces aggressive enhancement that amplifies noise.</p>
<p><code>tileSize</code> — the size of the local regions over which independent histograms are built. Small tiles capture fine local detail but are sensitive to noise. Large tiles behave more like global histogram equalization.</p>
<p>The problem is that the right values for these parameters are <em>image-dependent</em>. A noisy underexposed photo needs a low clip limit (to avoid amplifying grain) and a large tile size (for stability). A crisp high-detail architectural photo can tolerate a high clip limit and benefits from a small tile size to capture local contrast at the edge level.</p>
<p>Using a single fixed pair of values is a compromise that serves no image particularly well.</p>
<p>The question PACE is designed to answer is: <strong>can we compute the right parameters automatically, directly from the image's own statistics?</strong></p>
<p>The answer is yes. Here's how.</p>
<hr />
<h2>The Two Parameters, Perceptually Explained</h2>
<p>Before looking at the math, it's worth being precise about what each parameter actually controls, because the names are somewhat misleading.</p>
<h3>clipLimit — What It Really Controls</h3>
<p>The clip limit is usually described as "the maximum value of a histogram bin before clipping." But what it's <em>really</em> controlling is the <strong>maximum local contrast amplification ratio</strong>.</p>
<p>A higher clip limit allows more redistribution of histogram probability mass, which produces stronger local contrast. But it also amplifies whatever is in the local region — including noise. For a noisy image, a high clip limit produces a result that looks like enhanced noise. For a smooth image, it produces the strong local contrast expansion you actually wanted.</p>
<p>The clip limit should therefore scale with the image's <strong>structure-to-noise ratio</strong>: high when the image has clear, coherent structure; low when it doesn't.</p>
<h3>tileSize — What It Really Controls</h3>
<p>The tile size controls the <strong>spatial scale of the adaptation</strong>. A tile of 8×8 pixels means CLAHE is building a separate histogram for every tiny 8×8 block — it's adapting to very local intensity statistics. A tile of 64×64 is adapting to broader regional patterns.</p>
<p>Small tiles are appropriate when the image has fine, high-frequency structure that needs local contrast adaptation. Large tiles are appropriate for smooth or noisy images where you want a more global, stable enhancement.</p>
<p>The tile size should therefore scale inversely with the image's <strong>structural granularity</strong>: small tiles for high-detail coherent images, large tiles for smooth or noisy ones.</p>
<p>Both parameters, in other words, depend on the same underlying question: <em>how much coherent structure does this image have relative to its noise floor?</em></p>
<p>That's the key insight PACE is built on.</p>
<hr />
<h2>Stage 1: Measuring the Image — Feature Extraction</h2>
<p>PACE doesn't start with enhancement. It starts with measurement. Before any CLAHE parameters are chosen, the pipeline extracts a statistical fingerprint from the luminance plane.</p>
<p>The two measurements that drive CLAHE parameter selection are <strong>edge density</strong> and <strong>noise ratio</strong>.</p>
<h3>Measuring Edge Density</h3>
<p>Edge density captures how much coherent structure the image contains. PACE computes it in three steps.</p>
<p>First, compute the raw gradient mean using forward differences across every pixel:</p>
<pre><code class="language-js">// computeStructure() in extractGlobalFeatures2.js
for (let y = 1; y &lt; height; y++) {
    for (let x = 1; x &lt; width; x++) {
        const i = y * width + x;

        const gx = L[i] - L[i - 1];
        const gy = L[i] - L[i - width];

        // Alpha-Max + Beta-Min approximation: avoids Math.sqrt, within 4% of L2 norm
        const ax = Math.abs(gx);
        const ay = Math.abs(gy);
        const max = ax &gt; ay ? ax : ay;
        const min = ax &gt; ay ? ay : ax;

        const grad = max + 0.25 * min; // ≈ √(gx² + gy²)

        sumGrad += grad;
        sumGradSq += grad * grad;
        count++;
    }
}

const meanGradient = sumGrad / count;
const gradientVariance = (sumGradSq / count) - (meanGradient * meanGradient);
const stdGradient = Math.sqrt(gradientVariance);
</code></pre>
<p>The <code>Alpha-Max + Beta-Min</code> approximation (<code>max(|gx|,|gy|) + 0.25 × min(|gx|,|gy|)</code>) is a classic trick for approximating Euclidean gradient magnitude without a square root. In a tight inner loop over every pixel of an image, eliminating <code>Math.sqrt</code> is meaningful.</p>
<p>Second, adjust the raw gradient mean by the noise level (computed in the next section):</p>
<pre><code class="language-js">const noiseAdjusted = meanGradient / (1 + 0.5 * noiseRatio);
</code></pre>
<p>This is important. A noisy image has a high <code>meanGradient</code> — noise creates high-frequency gradients everywhere. Dividing by a noise-adjusted factor prevents the algorithm from misreading noise energy as real structural content.</p>
<p>Third, soft-normalize into a [0, 1] value using an adaptive denominator:</p>
<pre><code class="language-js">// k adapts to the gradient's own spread, not a fixed constant
const k = 0.3 * stdGradient + 0.02;
const edgeDensity = noiseAdjusted / (noiseAdjusted + k);
</code></pre>
<p>The denominator <code>k = 0.3σ_gradient + 0.02</code> is what makes this "adaptive" rather than just a sigmoid. When the gradient distribution has high variance (lots of both strong and weak gradients — typical of complex scenes), <code>k</code> grows, which softens the normalization and produces a more conservative edge density estimate. When the gradient distribution is narrow (either uniformly flat or uniformly textured), <code>k</code> is small and the normalization is sharper.</p>
<p>The result: <code>edgeDensity ∈ [0, 1]</code>, where 0 is a completely flat image and 1 is a maximally structured one.</p>
<h3>Measuring Noise: The MAD Estimator</h3>
<p>The noise estimator is the most statistically careful part of PACE, and the most important for getting CLAHE parameters right.</p>
<p>The naive approach — using image variance as a noise proxy — fails because texture and noise are both high-variance. A detailed brick wall has high variance but zero noise. Treating it as noisy causes the algorithm to over-suppress enhancement on exactly the images that would benefit most.</p>
<p>PACE instead uses a <strong>Laplacian MAD estimator</strong> — the same principle underlying Donoho's wavelet-domain denoising (1994). The key insight is that the <em>median</em> of the absolute Laplacian response is robust to structured edges and textures in a way the mean is not.</p>
<p>Here's the implementation:</p>
<pre><code class="language-js">// computeNoise() in extractGlobalFeatures2.js

const NOISE_BINS = 512;
const NOISE_MAX = 0.5;          // natural images rarely exceed 0.5 Laplacian
const NOISE_INV_MAX = NOISE_BINS / NOISE_MAX;
const MAD_SCALE = 1.4826;       // standard MAD→σ factor for Gaussian distributions

const hist = new Uint32Array(NOISE_BINS);
const STRIDE = 2; // subsample: every other pixel in each axis

for (let y = 1; y &lt; height - 1; y += STRIDE) {
    const row = y * width;
    for (let x = 1; x &lt; width - 1; x += STRIDE) {
        const i = row + x;

        // 4-neighbour discrete Laplacian
        const lap = Math.abs(
            L[i - 1] + L[i + 1] +
            L[i - width] + L[i + width] -
            4 * L[i]
        );

        const bin = lap &gt;= NOISE_MAX
            ? NOISE_BINS - 1
            : (lap * NOISE_INV_MAX) | 0;

        hist[bin]++;
    }
}
</code></pre>
<p>The discrete Laplacian operator (<code>L[left] + L[right] + L[top] + L[bottom] - 4×L[center]</code>) measures the second derivative of luminance. On smooth regions, this is close to zero. On real edges, it's large. On noise, it's large <em>and incoherent</em> — it points every direction randomly.</p>
<p>The median of this distribution separates noise from edges because edges are sparse (they affect a small fraction of pixels) but noise is dense (it affects every pixel uniformly). The median ignores the sparse large outliers and settles on the bulk of the distribution — which is the noise floor.</p>
<pre><code class="language-js">// Histogram median — O(NOISE_BINS), no sort required
const half = count * 0.5;
let cumulative = 0;
let median = 0;

for (let b = 0; b &lt; NOISE_BINS; b++) {
    const prev = cumulative;
    cumulative += hist[b];

    if (cumulative &gt;= half) {
        // Sub-bin linear interpolation for accuracy
        const frac = hist[b] &gt; 0 ? (half - prev) / hist[b] : 0;
        median = ((b + frac) / NOISE_BINS) * NOISE_MAX;
        break;
    }
}

// Convert MAD to Gaussian sigma
const sigma = median * MAD_SCALE;
</code></pre>
<p>The <code>1.4826</code> constant is the exact scale factor that converts median absolute deviation to standard deviation for a Gaussian distribution (<code>1 / Φ^{-1}(0.75)</code>). This gives a physically meaningful noise estimate in the same units as the luminance plane.</p>
<p>Finally, convert sigma to a structure-aware ratio:</p>
<pre><code class="language-js">// Self-damping: prevents runaway ratios in extremely noisy images
const noiseRatio = sigma / (meanGradient + 0.02 + 0.5 * sigma);
</code></pre>
<p>The denominator has three terms. <code>meanGradient</code> normalizes noise relative to actual signal strength — an image with strong gradients can tolerate more absolute noise. The constant <code>0.02</code> prevents division explosion on completely flat images. The <code>0.5 × sigma</code> term is self-damping: when sigma is large, the denominator grows with it, preventing <code>noiseRatio</code> from going to infinity on pathologically noisy inputs.</p>
<p>The result: <code>noiseRatio ∈ [0, ~1]</code>, where values near 0 mean the image is clean relative to its structure, and values near 1 mean noise dominates.</p>
<hr />
<h2>Stage 2: Structure Confidence — The Unified Signal</h2>
<p><code>edgeDensity</code> and <code>noiseRatio</code> are now combined into a single <strong>structure confidence</strong> value that drives both CLAHE parameters:</p>
<pre><code class="language-js">// computeStructureConfidence() in computeAdaptiveParams.js

function computeStructureConfidence(edgeDensity, noiseRatio) {

    // Edge signal: saturates smoothly as edges become denser
    // exp(-3x) decays to near-zero by x=1, giving good dynamic range
    const edgeSignal = 1 - Math.exp(-3.0 * edgeDensity);

    // Noise suppression: high noise → strong suppression
    // 2.5 coefficient calibrated so noiseRatio=0.4 halves the confidence
    const noiseSuppression = 1 / (1 + 2.5 * noiseRatio);

    // Product: confidence is high only when BOTH conditions hold
    // (strong edges AND low noise)
    const confidence = edgeSignal * noiseSuppression;

    return Math.min(1, Math.max(0, confidence));
}
</code></pre>
<p>This is an elegant formulation. The product of <code>edgeSignal</code> and <code>noiseSuppression</code> means <em>both</em> conditions have to be true for confidence to be high:</p>
<ul>
<li><p>High edge density alone isn't enough — a noisy image can have high apparent edge density (noise creates gradients everywhere), but <code>noiseSuppression</code> will pull confidence down.</p>
</li>
<li><p>Low noise alone isn't enough — a clean but completely flat image has near-zero edge density, so <code>edgeSignal</code> stays near zero.</p>
</li>
</ul>
<p>Structure confidence is high <strong>only when the image has genuine coherent structure that isn't explained by noise</strong>. That's exactly the condition under which aggressive CLAHE is safe and productive.</p>
<p>Let's trace through two example images to see how this plays out:</p>
<p><strong>Example A: Clean, sharp landscape photo</strong></p>
<ul>
<li><p><code>meanGradient ≈ 0.045</code> (clear edges)</p>
</li>
<li><p><code>sigma ≈ 0.004</code> (low noise)</p>
</li>
<li><p><code>noiseRatio ≈ 0.004 / (0.045 + 0.02 + 0.002) ≈ 0.060</code></p>
</li>
<li><p><code>edgeDensity ≈ 0.045 / (0.045 + 0.015) ≈ 0.75</code> (using typical k value)</p>
</li>
<li><p><code>edgeSignal = 1 - exp(-3.0 × 0.75) ≈ 0.895</code></p>
</li>
<li><p><code>noiseSuppression = 1 / (1 + 2.5 × 0.060) ≈ 0.870</code></p>
</li>
<li><p><code>structureConfidence ≈ 0.779</code> — high</p>
</li>
</ul>
<p><strong>Example B: Noisy underexposed indoor photo</strong></p>
<ul>
<li><p><code>meanGradient ≈ 0.030</code> (moderate, partially noise-driven)</p>
</li>
<li><p><code>sigma ≈ 0.018</code> (high noise)</p>
</li>
<li><p><code>noiseRatio ≈ 0.018 / (0.030 + 0.02 + 0.009) ≈ 0.305</code></p>
</li>
<li><p><code>edgeDensity ≈ 0.026 / (0.026 + 0.018) ≈ 0.59</code> (noise-adjusted gradient)</p>
</li>
<li><p><code>edgeSignal = 1 - exp(-3.0 × 0.59) ≈ 0.834</code></p>
</li>
<li><p><code>noiseSuppression = 1 / (1 + 2.5 × 0.305) ≈ 0.567</code></p>
</li>
<li><p><code>structureConfidence ≈ 0.473</code> — moderate-low</p>
</li>
</ul>
<p>Same CLAHE algorithm, same codebase, but two very different parameter sets. Here's how.</p>
<hr />
<h2>Stage 3: Computing clipLimit From Structure Confidence</h2>
<pre><code class="language-js">// computeControlParams() in computeAdaptiveParams.js

const clipLimit = 0.02 + 0.08 * structureConfidence;
</code></pre>
<p>That's it. One line. But every number in it is deliberate.</p>
<p><code>0.02</code> <strong>— the minimum floor.</strong> Even a completely flat, noisy image should get <em>some</em> CLAHE enhancement. Zero clip limit would mean no equalization at all. The floor ensures there's always a minimal enhancement pass.</p>
<p><code>0.08 × structureConfidence</code> <strong>— the adaptive range.</strong> At maximum confidence (≈1.0), clip limit reaches 0.10. At minimum confidence (≈0), it stays at 0.02. The total range <code>[0.02, 0.10]</code> was calibrated against the 256-bin histogram that PACE uses internally — these are fractional values relative to the reference tile area, not raw bin counts.</p>
<p>Tracing through our examples:</p>
<table>
<thead>
<tr>
<th>Image</th>
<th>structureConfidence</th>
<th>clipLimit</th>
</tr>
</thead>
<tbody><tr>
<td>Clean landscape</td>
<td>0.779</td>
<td>0.02 + 0.08 × 0.779 = <strong>0.082</strong></td>
</tr>
<tr>
<td>Noisy indoor</td>
<td>0.473</td>
<td>0.02 + 0.08 × 0.473 = <strong>0.058</strong></td>
</tr>
</tbody></table>
<p>The clean landscape gets a clip limit 41% higher than the noisy photo — allowing correspondingly more aggressive local contrast expansion where the image can support it.</p>
<hr />
<h2>Stage 4: Computing tileSize From Structure Confidence and Noise</h2>
<p>The tile size calculation has one more ingredient: the noise ratio feeds in directly alongside structure confidence.</p>
<pre><code class="language-js">// computeControlParams() in computeAdaptiveParams.js

// Granularity: positive when structured, negative when noisy
const granularity = structureConfidence - 0.5 * noiseRatio;

// Linear interpolation: 32px baseline, ±16px range
let tileSize = 32 - 16 * granularity;

// Hard clamp to [8, 64]
tileSize = Math.max(8, Math.min(64, tileSize));

// Round to multiple of 8 (efficient memory alignment + bilateral symmetry)
tileSize = Math.round(tileSize / 8) * 8;
</code></pre>
<p>The <code>granularity</code> signal combines both axes:</p>
<ul>
<li><p>High structure confidence <strong>and</strong> low noise → high granularity → <strong>smaller tiles</strong> (fine local adaptation where the image can support it)</p>
</li>
<li><p>Low structure confidence <strong>or</strong> high noise → low or negative granularity → <strong>larger tiles</strong> (stable, regional adaptation)</p>
</li>
</ul>
<p>The <code>0.5 × noiseRatio</code> coefficient in the granularity formula means noise has <em>half</em> the influence of structure confidence. This is intentional: noise should pull toward larger tiles, but not so aggressively that a moderately noisy-but-detailed image ends up with huge tiles that miss its fine structure.</p>
<p>The <code>32 - 16 × granularity</code> formula means:</p>
<ul>
<li><p>When <code>granularity = 0</code> (balanced): tileSize = 32 (the natural midpoint)</p>
</li>
<li><p>When <code>granularity = 1</code> (maximum structure, no noise): tileSize = 16 (fine adaptation)</p>
</li>
<li><p>When <code>granularity = -1</code> (no structure, maximum noise): tileSize = 48 → clamped to 48</p>
</li>
</ul>
<p>After rounding to the nearest multiple of 8:</p>
<table>
<thead>
<tr>
<th>Image</th>
<th>granularity</th>
<th>Raw tileSize</th>
<th>Final tileSize</th>
</tr>
</thead>
<tbody><tr>
<td>Clean landscape</td>
<td>0.779 − 0.5×0.060 = 0.749</td>
<td>32 − 16×0.749 = 20.0</td>
<td><strong>24</strong></td>
</tr>
<tr>
<td>Noisy indoor</td>
<td>0.473 − 0.5×0.305 = 0.321</td>
<td>32 − 16×0.321 = 26.9</td>
<td><strong>32</strong></td>
</tr>
</tbody></table>
<p>The clean landscape gets 24px tiles — fine enough to capture local edge contrast. The noisy photo gets 32px tiles — stable enough to average over enough pixels that noise doesn't dominate each tile's histogram.</p>
<hr />
<h2>The CLAHE Implementation: Why 12-Bit Precision Matters</h2>
<p>With parameters computed, PACE runs its own CLAHE implementation rather than a stock one. The key upgrade over standard implementations is <strong>12-bit internal precision</strong>.</p>
<p>Standard CLAHE: float → 8-bit (255 levels) → LUT → 8-bit output → float. PACE CLAHE: float → 12-bit (4095 levels) → LUT (8-bit bins, 12-bit output) → float.</p>
<p>Here's why this matters. CLAHE works by remapping each pixel through a LUT derived from the local histogram CDF. If the input is quantized to 8 bits before entering the LUT, you have only 256 distinct input values. In regions with gentle gradients — clear sky, smooth skin, shallow depth-of-field backgrounds — this 256-level quantization creates visible stairstepping in what should be smooth tonal transitions.</p>
<p>12-bit quantization gives 4096 input levels. The staircase artifact essentially disappears.</p>
<pre><code class="language-js">// applyCLAHE() in newCLAHE.js

const bins = 256;     // histogram stays 8-bit — memory efficient
const scale = 4095;   // but float quantization is 12-bit

// Float → 12-bit Uint16
const L = new Uint16Array(size);
for (let i = 0; i &lt; size; i++) {
    const v = Lfloat[i] * scale;
    L[i] = v &lt; 0 ? 0 : v &gt; scale ? scale : v | 0;
}
</code></pre>
<p>The trick is that the histogram bins stay 8-bit (256 bins). Building a 12-bit histogram would require 4096 bins per tile — for 32×32 tiles at 16 tiles per axis, that's 16×16×4096×4 bytes ≈ 16MB just for histograms. Instead, PACE bins the 12-bit input values into 8-bit histogram slots by right-shifting 4 bits:</p>
<pre><code class="language-js">// buildTileLUTs() in newCLAHE.js

for (let y = y0; y &lt; y1; y++) {
    const row = y * w;
    for (let x = x0; x &lt; x1; x++) {
        // shift 12-bit value right by 4 → 8-bit bin index
        hist[gray[row + x] &gt;&gt; 4]++;
    }
}
</code></pre>
<p>And the LUT outputs are stored in <code>Uint16</code> at 12-bit resolution:</p>
<pre><code class="language-js">// LUT output: 12-bit range [0, 4095]
const luts = Array.from({ length: tilesY }, () =&gt;
    Array.from({ length: tilesX }, () =&gt; new Uint16Array(bins))
);

// CDF → LUT scaled to 12-bit output
let acc = 0;
const cdfScale = outputScale / tileArea; // outputScale = 4095
for (let i = 0; i &lt; bins; i++) {
    acc += hist[i];
    luts[ty][tx][i] = Math.min(outputScale, Math.round(acc * cdfScale));
}
</code></pre>
<p>The result: 8-bit memory cost for histograms, 12-bit precision for the actual tonal remapping. It's a genuine free lunch.</p>
<h3>Bilinear Tile Interpolation</h3>
<p>At tile boundaries, naive CLAHE produces visible block artifacts — the local histogram statistics jump discontinuously from one tile to the next, and you see a grid pattern in smooth regions of the enhanced image.</p>
<p>PACE eliminates this with bilinear interpolation across the four nearest tile LUTs for every pixel:</p>
<pre><code class="language-js">// claheGrayBilinear() in newCLAHE.js

for (let y = 0; y &lt; h; y++) {
    // Map pixel into fractional tile-grid coordinates
    const gy = (y + 0.5) / tileSize - 0.5;
    let ty = Math.floor(gy);
    const fy = gy - ty; // fractional position within tile cell

    ty = Math.max(0, Math.min(ty, tilesY - 1));
    const ty1 = Math.min(ty + 1, tilesY - 1);

    for (let x = 0; x &lt; w; x++) {
        const gx = (x + 0.5) / tileSize - 0.5;
        let tx = Math.floor(gx);
        const fx = gx - tx;
        tx = Math.max(0, Math.min(tx, tilesX - 1));
        const tx1 = Math.min(tx + 1, tilesX - 1);

        // 8-bit bin lookup from 12-bit input
        const bin = gray[y * w + x] &gt;&gt; 4;

        // Four corner LUT values
        const v_tl = luts[ty][tx][bin];
        const v_tr = luts[ty][tx1][bin];
        const v_bl = luts[ty1][tx][bin];
        const v_br = luts[ty1][tx1][bin];

        // Bilinear blend
        out[y * w + x] = Math.round(
            v_tl * (1 - fx) * (1 - fy) +
            v_tr *      fx  * (1 - fy) +
            v_bl * (1 - fx) *      fy  +
            v_br *      fx  *      fy
        );
    }
}
</code></pre>
<p>The <code>(y + 0.5) / tileSize - 0.5</code> mapping centers each pixel within its tile cell rather than aligning to the tile origin. This ensures the interpolation is symmetric and that the center of each tile gets the pure LUT value without any blending contribution from neighboring tiles.</p>
<h3>Bias-Free Histogram Redistribution</h3>
<p>Standard CLAHE redistributes clipped histogram excess uniformly: divide by 256, add to every bin. This creates a systematic bias — the first bins always get slightly more than the last when <code>excess % bins ≠ 0</code>, because the remainder gets distributed to the leading bins.</p>
<p>PACE eliminates this by rotating the starting position of the remainder distribution:</p>
<pre><code class="language-js">// Bias-free redistribution in buildTileLUTs()

const redist = Math.floor(excess / bins);
const remainder = excess % bins;

// Every bin gets the base redistribution
for (let i = 0; i &lt; bins; i++) hist[i] += redist;

// Remainder starts at a tile-dependent offset — no systematic bias
const start = (tx * tilesX + ty) % bins;
for (let i = 0; i &lt; remainder; i++) hist[(start + i) % bins]++;
</code></pre>
<p>The <code>start</code> offset varies by tile position. Over the full image, the remainder gets distributed without systematic preference to any particular intensity range.</p>
<hr />
<h2>The Complete Parameter Flow</h2>
<p>Putting it all together, here's the complete chain from raw pixels to CLAHE parameters:</p>
<pre><code class="language-plaintext">Raw luminance pixels (Float32Array)
    ↓
computeStructure()
    meanGradient, stdGradient
    ↓
computeNoise()  [MAD estimator on subsampled Laplacian]
    sigma → noiseRatio
    ↓
edgeDensity = noiseAdjustedGradient / (noiseAdjustedGradient + adaptiveK)
    ↓
computeStructureConfidence(edgeDensity, noiseRatio)
    = (1 - exp(-3·edgeDensity)) × (1 / (1 + 2.5·noiseRatio))
    ↓
clipLimit  = 0.02 + 0.08 × structureConfidence
tileSize   = round8(clamp(32 - 16 × (structureConfidence - 0.5·noiseRatio), 8, 64))
    ↓
applyCLAHE(L, width, height, tileSize, clipLimit)
</code></pre>
<p>Every step is deterministic and derived from the image's own pixel data. No user input. No presets. No scene classification. The image tells the algorithm what it needs.</p>
<hr />
<h2>What This Looks Like in Practice</h2>
<p>To give a sense of the parameter space PACE explores automatically:</p>
<table>
<thead>
<tr>
<th>Scene type</th>
<th>structureConfidence</th>
<th>clipLimit</th>
<th>tileSize</th>
</tr>
</thead>
<tbody><tr>
<td>Crisp daylight landscape</td>
<td>~0.82</td>
<td>~0.086</td>
<td>16</td>
</tr>
<tr>
<td>Low-light indoor (clean camera)</td>
<td>~0.61</td>
<td>~0.069</td>
<td>24</td>
</tr>
<tr>
<td>Noisy mobile night shot</td>
<td>~0.38</td>
<td>~0.050</td>
<td>40</td>
</tr>
<tr>
<td>Overexposed flat sky</td>
<td>~0.22</td>
<td>~0.038</td>
<td>48</td>
</tr>
<tr>
<td>High-ISO underexposed portrait</td>
<td>~0.45</td>
<td>~0.056</td>
<td>32</td>
</tr>
<tr>
<td>Medical grayscale scan</td>
<td>~0.70</td>
<td>~0.076</td>
<td>24</td>
</tr>
</tbody></table>
<p>The algorithm naturally gravitates toward conservative parameters for difficult images and aggressive parameters for images that can support it — without any explicit scene labels or conditional logic.</p>
<hr />
<h2>Using PACE in Your Project</h2>
<pre><code class="language-js">import { applyPACE } from 'https://raw.githubusercontent.com/muhammedshahid/pace/main/dist/pace.esm.js';

// Fully automatic — parameters computed per-image
const enhanced = await applyPACE(imageData);

// Inspect what parameters were computed (via override + debug)
const enhanced = await applyPACE(imageData, {
    debug: true,  // logs each stage including computed params
    strength: 1.0
});

// Override specific parameters for experimentation
const enhanced = await applyPACE(imageData, {
    override: {
        controlParams: {
            clipLimit: 0.04,   // fixed, bypasses auto-computation
            tileSize: 16       // fixed tile size
        }
    }
});
</code></pre>
<p>The library runs entirely in a Web Worker — the image never leaves the browser.</p>
<hr />
<h2>Open Questions</h2>
<p>The parameter computation described here is deliberately hand-crafted: the coefficients (<code>0.02</code>, <code>0.08</code>, <code>3.0</code>, <code>2.5</code>, <code>0.5</code>, <code>16</code>, <code>32</code>) were derived from empirical calibration against a range of test images. They work well, but they're not optimal in any formal sense.</p>
<p>The natural next step is replacing the hand-crafted controller with a small learned model — a ~500KB MLP trained on human-rated image pairs, predicting <code>clipLimit</code> and <code>tileSize</code> directly from the 12-dimensional feature vector that PACE already computes. This would preserve the zero-config, browser-native design while replacing engineering judgment with data-driven optimization.</p>
<p>If you've worked on preference learning for image quality metrics or have thoughts on the MAD estimator design, I'd genuinely like to hear them in the comments.</p>
<hr />
<h2>Links</h2>
<table>
<thead>
<tr>
<th>Resource</th>
<th>URL</th>
</tr>
</thead>
<tbody><tr>
<td>GitHub (MIT licensed)</td>
<td><a href="https://github.com/muhammedshahid/pace">github.com/muhammedshahid/pace</a></td>
</tr>
<tr>
<td>Paper preprint (SSRN)</td>
<td><a href="https://dx.doi.org/10.2139/ssrn.6661421">dx.doi.org/10.2139/ssrn.6661421</a></td>
</tr>
<tr>
<td>Archived source (Zenodo)</td>
<td><a href="https://zenodo.org/doi/10.5281/zenodo.19203394">zenodo.org/doi/10.5281/zenodo.19203394</a></td>
</tr>
<tr>
<td>Demo</td>
<td><a href="https://muhammedshahid.github.io/pace-beta/src/">https://muhammedshahid.github.io/pace-beta/src/</a></td>
</tr>
</tbody></table>
<hr />
<p><em>PACE is submitted to Elsevier SoftwareX and is currently under peer review. All source code is available under the MIT license. If you use PACE in research, the Zenodo DOI provides a permanent citable reference.</em></p>
]]></content:encoded></item><item><title><![CDATA[PACE a Zero-Config Image Enhancer That Runs Entirely in Your Browser — Here's Every Technical Decision I Made]]></title><description><![CDATA[PACE (Perceptual Adaptive Contrast Enhancement) is an open-source image enhancement library that analyzes and enhances images fully client-side — no server, no upload, no API key. Here's the full engi]]></description><link>https://pacejs.hashnode.dev/pace-a-zero-config-image-enhancer-that-runs-entirely-in-your-browser-here-s-every-technical-decision-i-made</link><guid isPermaLink="true">https://pacejs.hashnode.dev/pace-a-zero-config-image-enhancer-that-runs-entirely-in-your-browser-here-s-every-technical-decision-i-made</guid><category><![CDATA[JavaScript]]></category><category><![CDATA[Computer Vision]]></category><category><![CDATA[image processing]]></category><category><![CDATA[Open Source]]></category><category><![CDATA[Web Development]]></category><category><![CDATA[algorithms]]></category><dc:creator><![CDATA[muhammed shahid]]></dc:creator><pubDate>Wed, 06 May 2026 14:45:28 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/69f8b75046610fd606f6c8d8/eba53c89-f32b-4717-951e-bb223dc3e1ea.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<blockquote>
<p><strong>PACE</strong> (Perceptual Adaptive Contrast Enhancement) is an open-source image enhancement library that analyzes and enhances images fully client-side — no server, no upload, no API key. Here's the full engineering story behind it.</p>
</blockquote>
<p><strong>GitHub:</strong> <a href="https://github.com/muhammedshahid/pace">github.com/muhammedshahid/pace</a><br /><strong>Paper (preprint):</strong> <a href="https://dx.doi.org/10.2139/ssrn.6661421">dx.doi.org/10.2139/ssrn.6661421</a><br /><strong>Archived source (Zenodo):</strong> <a href="https://zenodo.org/doi/10.5281/zenodo.19203394">zenodo.org/doi/10.5281/zenodo.19203394</a><br /><strong>Live Demo:</strong><a href="https://muhammedshahid.github.io/pace-beta/src/">https://muhammedshahid.github.io/pace-beta/src/</a></p>
<hr />
<h2>Why I Built This</h2>
<p>Most browser-side image enhancement libraries are, if you look past the marketing, a <code>CanvasRenderingContext2D</code> call with hardcoded brightness and contrast values. They don't <em>look</em> at the image. They apply a fixed transform and call it done.</p>
<p>That always bothered me. A dark underexposed photo and an overexposed washed-out photo need opposite treatments. A noisy image needs conservative clipping to avoid amplifying grain. A high-detail architectural shot deserves aggressive texture sharpening that would destroy a portrait. One size never fits all.</p>
<p>I wanted a library that actually <em>analyzed</em> the image before touching it — one that adapted its enhancement to what was in the image, automatically, without the user adjusting a single slider.</p>
<p>The additional constraint I set for myself: it had to run entirely in the browser. No server round-trip. The image never leaves the device. This isn't just a privacy story — it's an engineering constraint that forces you to be serious about performance in JavaScript, which turns out to be a very interesting problem.</p>
<p>The result is PACE. It's been submitted to <strong>Elsevier SoftwareX</strong> (currently under peer review) and is fully open-source under the MIT license.</p>
<hr />
<h2>The Pipeline at a Glance</h2>
<p>PACE runs a 7-stage pipeline, all inside a Web Worker to keep the main thread unblocked:</p>
<pre><code class="language-plaintext">RGB Input
    ↓
Stage 1: OKLab Color Conversion
    ↓
Stage 2: Global Feature Extraction
    ↓
Stage 3: Adaptive Parameter Computation
    ↓
Stage 4: CLAHE (12-bit precision)
    ↓
Stage 5: Perceptual Control Maps
    ↓
Stage 6: Multi-Signal Blending
    ↓
OKLab → RGB Reconstruction
</code></pre>
<p>Each stage feeds the next. Stages 2 and 3 are the brain — everything downstream is the execution. Let me walk through the decisions that weren't obvious.</p>
<hr />
<h2>Stage 1: Why OKLab Instead of HSL or YCbCr</h2>
<p>The classic approach to luminance-only enhancement is to convert to HSL or HSV, manipulate the L channel, and convert back. It's simple and fast. It's also perceptually wrong.</p>
<p>HSL is not perceptually uniform. A 10% increase in L at hue 240° (blue) looks like a massive change; the same increase at hue 60° (yellow) barely registers. When you enhance luminance in a non-uniform space, you inadvertently shift perceived hue and saturation in ways that look unnatural — the classic "colors look weird" complaint about automated enhancement.</p>
<p><strong>OKLab</strong> (Björn Ottosson, 2020) is a modern perceptually uniform color space designed specifically to fix this. The L channel in OKLab corresponds closely to perceived lightness. When you only touch L and leave the <code>a</code> and <code>b</code> chrominance channels alone, the colors genuinely don't shift.</p>
<p>The conversion itself uses lookup tables for the sRGB gamma correction (the expensive <code>Math.pow</code> call):</p>
<pre><code class="language-js">// Precomputed LUT: sRGB uint8 [0-255] → linear float [0-1]
const SRGB_TO_LINEAR = new Float32Array(256);
for (let i = 0; i &lt; 256; i++) {
    const c = i / 255;
    SRGB_TO_LINEAR[i] = c &lt;= 0.04045
        ? c / 12.92
        : Math.pow((c + 0.055) / 1.055, 2.4);
}
</code></pre>
<p>And the inner loop processes <strong>two pixels per iteration</strong> to give the JS JIT compiler enough to work with:</p>
<pre><code class="language-js">// Process 2 pixels per loop for better JIT optimization
for (; j &lt; end; j += 2, i += 8) {
    // Pixel 1
    const lr1 = srgbToLinear[src[i]];
    const lg1 = srgbToLinear[src[i + 1]];
    const lb1 = srgbToLinear[src[i + 2]];
    // ... OKLab matrix multiply ...

    // Pixel 2
    const lr2 = srgbToLinear[src[i + 4]];
    // ...
}
</code></pre>
<hr />
<h2>Stage 2: Feature Extraction — What the Algorithm Actually Measures</h2>
<p>Before any enhancement happens, PACE extracts a 12-dimensional feature vector from the luminance plane:</p>
<pre><code class="language-plaintext">[mean, variance, skewness, kurtosis, entropy, dynamicRange,
 shadowRatio, highlightRatio, edgeDensity, textureIndex,
 noiseRatio, microContrast]
</code></pre>
<p>This is the image's "fingerprint." Let me explain the less obvious ones.</p>
<h3>Dynamic Range via Percentiles, Not Min/Max</h3>
<p>Raw min/max dynamic range is dominated by outliers — a single blown-out pixel or a crushed shadow pixel skews the whole measurement. Instead, PACE computes the P5–P95 range:</p>
<pre><code class="language-js">// Walk the histogram CDF to find percentile positions
if (!p5 &amp;&amp; cumulative &gt;= 0.05) {
    p5 = i / (HIST_BINS - 1);
}
if (!p95 &amp;&amp; cumulative &gt;= 0.95) {
    p95 = i / (HIST_BINS - 1);
}
const dynamicRange = p95 - p5;
</code></pre>
<p>A low <code>dynamicRange</code> means the image is flat and needs aggressive contrast expansion. A high value means it already uses the tonal range well and needs a lighter touch.</p>
<h3>Noise Estimation: MAD Over Mean</h3>
<p>The noise estimator is the part of PACE I'm most invested in technically, and the one I'm most open to critique on.</p>
<p>The naive approach — divide mean absolute Laplacian by image variance — conflates noise with texture. A sharply-detailed brick wall has high variance <em>and</em> high Laplacian response, but it's not noisy. Treating it as noisy causes the algorithm to under-enhance exactly the images that benefit most from it.</p>
<p>Instead, PACE uses a <strong>histogram-median Laplacian estimator</strong>, which is the same statistical principle underlying Donoho's wavelet-domain denoising (1994):</p>
<pre><code class="language-js">// 4-neighbour discrete Laplacian
const lap = Math.abs(
    L[i - 1] + L[i + 1] +
    L[i - width] + L[i + width] -
    4 * L[i]
);

// Accumulate into histogram bins
const bin = lap &gt;= NOISE_MAX
    ? NOISE_BINS - 1
    : (lap * NOISE_INV_MAX) | 0;
hist[bin]++;
</code></pre>
<p>Then find the median of that histogram:</p>
<pre><code class="language-js">const half = count * 0.5;
let cumulative = 0;
let median = 0;

for (let b = 0; b &lt; NOISE_BINS; b++) {
    const prev = cumulative;
    cumulative += hist[b];
    if (cumulative &gt;= half) {
        const frac = hist[b] &gt; 0 ? (half - prev) / hist[b] : 0;
        median = ((b + frac) / NOISE_BINS) * NOISE_MAX;
        break;
    }
}

// MAD → σ conversion for Gaussian noise
const sigma = median * 1.4826;
</code></pre>
<p>The median is robust to outliers (edges, textures) in a way the mean is not. The <code>1.4826</code> constant is the standard MAD→σ scale factor for Gaussian distributions. The result is a <code>noiseRatio</code> that correctly separates "this image has sensor noise" from "this image has a lot of detail."</p>
<p><strong>Subsampling</strong> (stride = 2) drops computation to O(n/4) with negligible accuracy loss since the median of a representative sample converges quickly.</p>
<hr />
<h2>Stage 3: Adaptive Parameter Computation — The Control Theory Layer</h2>
<p>This is where the feature vector gets translated into actual enhancement parameters. The goal was to make every parameter derivable from the image statistics, with no magic numbers that a user needs to touch.</p>
<h3>Alpha (Enhancement Strength)</h3>
<p><code>globalAlpha</code> controls how aggressively the final blending step applies enhancement. It's derived from three signals:</p>
<pre><code class="language-js">// How much enhancement is needed?
const alphaNeed =
    0.5 * imbalance +          // tonal imbalance (shadow vs highlight ratio)
    0.3 * contrastNeed +       // low entropy × low dynamic range
    0.4 * structureConfidence; // edge signal, noise-suppressed

// What's the safe baseline?
const alphaBase =
    0.08 +                     // unconditional minimum
    0.10 * safety +            // clear structure → safer to enhance
    0.02 * textureConfidence;  // tonally rich images respond predictably

// Final alpha: need-driven, bounded, never collapses to zero
const globalAlpha =
    alphaBase + (alphaNeed / (alphaNeed + 0.5)) * (1 - alphaBase);
</code></pre>
<p>The soft normalization <code>x / (x + 0.5)</code> is used throughout the parameter controller. It maps <code>[0, ∞)</code> to <code>[0, 1)</code> smoothly without any hard clamps that create discontinuities.</p>
<h3>Beta (Highlight/Shadow Protection)</h3>
<p>Rather than a single <code>beta</code> scalar that either protects highlights or shadows, PACE computes independent <code>betaHighlight</code> and <code>betaShadow</code> values with adaptive coupling:</p>
<pre><code class="language-js">// Scene-aware coupling reduces dual activation
const couplingStrength =
    0.6 * dynamicRange +
    0.4 * Math.abs(highlightRatio - shadowRatio);

const k = couplingStrength / (1 + couplingStrength);

betaHighlight = betaHighlight / (1 + k * total);
betaShadow = betaShadow / (1 + k * total);
</code></pre>
<p>This prevents the pathological case where both highlight and shadow protection activate simultaneously and cancel each other out.</p>
<hr />
<h2>Stage 4: CLAHE With 12-Bit Internal Precision</h2>
<p>Contrast Limited Adaptive Histogram Equalization (CLAHE) is the workhorse of the enhancement pipeline. The standard implementation is 8-bit throughout. PACE uses <strong>12-bit internal precision</strong> — the input is quantized to 4096 levels, histograms are built with 256 bins (8-bit bin index for memory efficiency), and LUT outputs are stored in <code>Uint16</code> at 12-bit resolution.</p>
<pre><code class="language-js">const bins = 256;     // histogram bin count (8-bit)
const scale = 4095;   // output precision (12-bit)

// Float → 12-bit
const L = new Uint16Array(size);
for (let i = 0; i &lt; size; i++) {
    const v = Lfloat[i] * scale;
    L[i] = v &lt; 0 ? 0 : v &gt; scale ? scale : v | 0;
}
</code></pre>
<p>The payoff is smoother tonal gradations in the equalized output — the 8-bit staircase artifact in smooth gradients is eliminated without the memory cost of a full 12-bit histogram (which would be 4096 bins × tile count × 2 bytes).</p>
<p><strong>Bilinear tile interpolation</strong> is non-negotiable for avoiding the blocky artifact that makes naive CLAHE implementations look terrible at tile boundaries:</p>
<pre><code class="language-js">const v_tl = luts[ty][tx][bin];
const v_tr = luts[ty][tx1][bin];
const v_bl = luts[ty1][tx][bin];
const v_br = luts[ty1][tx1][bin];

out[y * w + x] = Math.round(
    v_tl * (1 - fx) * (1 - fy) +
    v_tr * fx * (1 - fy) +
    v_bl * (1 - fx) * fy +
    v_br * fx * fy
);
</code></pre>
<hr />
<h2>Stage 5: Perceptual Control Maps</h2>
<p>Four spatial maps modulate how enhancement is applied pixel-by-pixel:</p>
<p><strong>Edge map</strong> — gradient magnitude using the Alpha-Max + Beta-Min approximation instead of Euclidean distance. <code>max(|gx|, |gy|) + 0.25 × min(|gx|, |gy|)</code> approximates the L2 norm to within 4% without a <code>Math.sqrt</code> call in the inner loop.</p>
<p><strong>Structure mask</strong> — normalized gradient magnitude. High-structure regions (sharp edges, detailed textures) receive more aggressive enhancement.</p>
<p><strong>Skin damping map</strong> — a Gaussian bell centered at luminance 0.5 that softly suppresses enhancement in mid-tone regions likely to contain skin. No face detection, just a perceptual heuristic that works surprisingly well in practice.</p>
<p><strong>Local alpha map</strong> — per-tile alpha values computed from gradient coherence and noise. Tiles with coherent gradients (real structure) get higher alpha; tiles with incoherent high-frequency response (noise) get suppressed.</p>
<p>The local alpha map is then passed through <strong>guided alpha smoothing</strong> — a bilateral-filter-inspired 3×3 pass where neighbor weights are determined by luminance similarity:</p>
<pre><code class="language-js">const idx = Math.min(
    Math.abs(L[neighbor] - centerL) * lutScale | 0,
    lutScale
);
const w = weightLUT[idx]; // precomputed Gaussian LUT
</code></pre>
<p>This prevents alpha map discontinuities at edges without blurring across them.</p>
<hr />
<h2>Stage 6: Multi-Signal Blending With Nonlinear Stability</h2>
<p>The final enhancement delta combines three signals:</p>
<ol>
<li><p><strong>CLAHE delta</strong> — <code>Lclahe[i] - L[i]</code>, the contrast equalization component</p>
</li>
<li><p><strong>Retinex detail</strong> — <code>log(Lsmall) - log(Lmedium)</code>, the illumination-invariant reflectance estimate</p>
</li>
<li><p><strong>Laplacian texture</strong> — <code>(Lsmall[i] - localMean) × textureMask</code>, the local detail sharpening</p>
</li>
</ol>
<pre><code class="language-js">let delta =
    deltaClahe +
    0.45 * deltaDetail * detailMask * skinDampMap[i]
    * structureBoost * edgeAdaptive;
</code></pre>
<p>That combined delta is then passed through a chain of compressors to prevent the two most common enhancement artifacts — halos and blown highlights:</p>
<pre><code class="language-js">// Halo suppression: high delta gets progressively compressed
const deltaStable = delta / (1 + 2 * Math.abs(delta) + EPS);

// Tone limiter: delta is bounded relative to current luminance
const deltaLimited = deltaStable /
    (1 + Math.abs(deltaStable) / (tau * (0.5 + Li)));

// Nonlinear stability model: the core PACE contribution
const compressed = deltaLimited / (1 + lambda * Math.abs(deltaLimited));
</code></pre>
<p>The final expression <code>Δ / (1 + λ|Δ|)</code> is the key formula. It behaves linearly for small deltas (good), saturates smoothly for large ones (prevents clipping), and the <code>lambda</code> parameter is derived from image statistics so the saturation point adapts to each image's content.</p>
<hr />
<h2>Stage 7: Reconstructing OKLab to RGB</h2>
<p>All decisions come together here, transforming processed luminance into the final enhanced image.</p>
<img src="https://muhammedshahid.github.io/public_images/hashnode/ezgif-68956988d52f27be.gif" alt="PACE output" style="display:block;margin:0 auto" />

<h2>Performance in JavaScript: What Actually Works</h2>
<p>Running a 7-stage image processing pipeline in JS on a 12MP image is not trivial. Things that made a real difference:</p>
<p><strong>Lookup tables everywhere.</strong> <code>Math.pow</code>, <code>Math.log</code>, <code>Math.cbrt</code>, <code>Math.exp</code> are expensive in tight loops. PACE precomputes LUTs for sRGB gamma, linear-to-sRGB, OKLab cube roots, log values, and the power functions used in edge gain. The LUT pattern is always the same:</p>
<pre><code class="language-js">const LUT = new Float32Array(LUT_SIZE);
for (let i = 0; i &lt; LUT_SIZE; i++) {
    LUT[i] = expensiveFunction(i / (LUT_SIZE - 1));
}

// At runtime:
const result = LUT[(x * (LUT_SIZE - 1)) | 0];
</code></pre>
<p><strong>Separable passes for blur.</strong> All spatial filtering (smooth3x3, smooth5x5, smooth7x7) uses horizontal + vertical passes rather than 2D convolution, dropping complexity from O(n×k²) to O(n×k).</p>
<p><strong>Zero-copy transfer to Web Worker.</strong> The ImageData buffer is transferred (not cloned) to the worker:</p>
<pre><code class="language-js">worker.postMessage({ imgData, options }, [imgData.data.buffer]);
</code></pre>
<p>After transfer, <code>imgData.data.buffer</code> is detached in the main thread. The worker owns the memory. On completion the result is transferred back the same way.</p>
<p><strong>Row-cached indexing.</strong> Inner loops precompute <code>const row = y * width</code> outside the x-loop to eliminate a multiply per pixel.</p>
<hr />
<h2>Using PACE</h2>
<pre><code class="language-js">import { applyPACE } from "https://raw.githubusercontent.com/muhammedshahid/pace/main/dist/pace.esm.js";

// Basic usage — fully automatic
const enhanced = await applyPACE(imageData);

// With strength control (1.0 = default, &gt;1.0 = aggressive)
const enhanced = await applyPACE(imageData, { strength: 0.8 });

// With parameter override for research/experimentation
const enhanced = await applyPACE(imageData, {
    override: {
        controlParams: { clipLimit: 0.04, tileSize: 16 },
        perceptualParams: { tau: 0.6, lambda: 0.4 }
    }
});

// With progress callback (useful for progress bars on large images)
const enhanced = await applyPACE(imageData, {}, (progress) =&gt; {
    console.log(`\({progress.stage}: \){progress.progressPercent.toFixed(0)}%`);
});
</code></pre>
<hr />
<h2>What's Next</h2>
<p>The feature vector that stage 2 produces is richer than what the hand-crafted parameter controller in stage 3 can fully exploit. The natural next step is training a small MLP on human-rated image pairs to predict the parameter vector directly from the feature vector — a ~500KB ONNX model deployable via ONNX Runtime Web, staying entirely browser-native. If anyone has done this kind of preference-learning pipeline for image quality I'd genuinely like to compare notes.</p>
<p>Longer term: a WebAssembly port of the inner loops would unlock 12MP+ images without worker timeout pressure, and temporal coherence across video frames is an interesting research direction that hasn't been touched yet.</p>
<hr />
<h2>Links</h2>
<table>
<thead>
<tr>
<th>Resource</th>
<th>Link</th>
</tr>
</thead>
<tbody><tr>
<td>GitHub (MIT licensed)</td>
<td><a href="https://github.com/muhammedshahid/pace">github.com/muhammedshahid/pace</a></td>
</tr>
<tr>
<td>Paper preprint (SSRN)</td>
<td><a href="https://dx.doi.org/10.2139/ssrn.6661421">dx.doi.org/10.2139/ssrn.6661421</a></td>
</tr>
<tr>
<td>Archived source (Zenodo)</td>
<td><a href="https://zenodo.org/doi/10.5281/zenodo.19203394">zenodo.org/doi/10.5281/zenodo.19203394</a></td>
</tr>
<tr>
<td>Live Demo</td>
<td><a href="https://muhammedshahid.github.io/pace-beta/src/">https://muhammedshahid.github.io/pace-beta/src/</a></td>
</tr>
</tbody></table>
<hr />
<p><em>PACE is submitted to Elsevier SoftwareX and is currently under peer review. All source code is available on GitHub under the MIT license.</em></p>
<p><em>If you use PACE in your research, you can cite the Zenodo archive directly — it has a permanent DOI.</em></p>
]]></content:encoded></item><item><title><![CDATA[I Built a Zero-Config Image Enhancer That Runs Entirely in Your Browser
]]></title><description><![CDATA[GitHub: https://github.com/muhammedshahid/pacePaper (preprint): https://dx.doi.org/10.2139/ssrn.6661421Demo: https://muhammedshahid.github.io/pace-beta/src/

PACE runs entirely in the browser with no ]]></description><link>https://pacejs.hashnode.dev/i-built-a-zero-config-image-enhancer-that-runs-entirely-in-your-browser</link><guid isPermaLink="true">https://pacejs.hashnode.dev/i-built-a-zero-config-image-enhancer-that-runs-entirely-in-your-browser</guid><category><![CDATA[JavaScript]]></category><category><![CDATA[Computer Vision]]></category><category><![CDATA[image processing]]></category><category><![CDATA[Web Development]]></category><category><![CDATA[algorithms]]></category><category><![CDATA[Open Source]]></category><dc:creator><![CDATA[muhammed shahid]]></dc:creator><pubDate>Wed, 06 May 2026 14:37:03 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/69f8b75046610fd606f6c8d8/8d30236a-2632-40e1-88fa-3920b62f61eb.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><strong>GitHub:</strong> <a href="https://github.com/muhammedshahid/pace">https://github.com/muhammedshahid/pace</a><br /><strong>Paper (preprint):</strong> <a href="https://dx.doi.org/10.2139/ssrn.6661421">https://dx.doi.org/10.2139/ssrn.6661421</a><br /><strong>Demo:</strong> <a href="https://muhammedshahid.github.io/pace-beta/src/">https://muhammedshahid.github.io/pace-beta/src/</a></p>
<blockquote>
<p>PACE runs entirely in the browser with no heavy dependencies, keeping the install footprint minimal.</p>
</blockquote>
<img src="https://packagephobia.com/badge?p=@shahid-labs/pace" alt="install size" style="display:block;margin:0 auto" />

<hr />
<h2>Why I Built This</h2>
<p>Most browser image “enhancers” don’t actually look at the image.</p>
<p>They apply fixed brightness/contrast transforms and call it done.</p>
<p>That breaks immediately in real cases:</p>
<ul>
<li><p>Underexposed → needs expansion</p>
</li>
<li><p>Overexposed → needs compression</p>
</li>
<li><p>Noisy → needs restraint</p>
</li>
<li><p>High-detail scenes → benefit from strong local contrast</p>
</li>
</ul>
<p>A single static transform can’t solve all of these.</p>
<p>So I built something different:</p>
<blockquote>
<p>A system that first <strong>understands the image</strong>, then decides <strong>how to enhance it</strong>.</p>
</blockquote>
<p>And I constrained it deliberately:</p>
<blockquote>
<p>It must run entirely in the browser.</p>
</blockquote>
<p>No server. No uploads. Just JavaScript.</p>
<hr />
<h2>How PACE Thinks (in 20 seconds)</h2>
<p>At a high level, PACE does three things:</p>
<ol>
<li><p><strong>Understand the image</strong> — contrast, noise, structure, tone</p>
</li>
<li><p><strong>Decide behavior</strong> — how aggressive, where to protect</p>
</li>
<li><p><strong>Apply enhancement selectively</strong> — not globally</p>
</li>
</ol>
<p>Everything else is implementation detail.</p>
<hr />
<h2>The Pipeline (High Level)</h2>
<pre><code class="language-plaintext">RGB Input
    ↓
OKLab Conversion
    ↓
Feature Extraction
    ↓
Adaptive Control (the brain)
    ↓
Local Contrast (CLAHE)
    ↓
Spatial Maps
    ↓
Perceptual Blending
    ↓
Final Image
</code></pre>
<p>Two stages matter most:</p>
<ul>
<li><p><strong>Feature Extraction</strong> → what the image is</p>
</li>
<li><p><strong>Adaptive Control</strong> → what to do about it</p>
</li>
</ul>
<p>Everything after executes those decisions.</p>
<hr />
<h2>Why Not HSL / HSV?</h2>
<p>The common approach is:</p>
<blockquote>
<p>Convert to HSL → tweak L → convert back</p>
</blockquote>
<p>It’s simple. It’s also perceptually wrong.</p>
<p>A small luminance change affects colors unevenly across hues. Blues shift heavily, yellows barely move.</p>
<p>PACE uses <strong>OKLab</strong> instead:</p>
<ul>
<li><p>Perceptually uniform</p>
</li>
<li><p>L ≈ perceived lightness</p>
</li>
<li><p>Changing L preserves color relationships</p>
</li>
</ul>
<p>Result:</p>
<blockquote>
<p>You can enhance brightness without breaking colors.</p>
</blockquote>
<hr />
<h2>What the System Measures (Intuition)</h2>
<p>Before modifying anything, PACE extracts a set of signals from the image:</p>
<ul>
<li><p><strong>Contrast</strong> → how spread out tones are</p>
</li>
<li><p><strong>Dynamic range</strong> → how much of the tonal space is used</p>
</li>
<li><p><strong>Tone balance</strong> → shadows vs highlights</p>
</li>
<li><p><strong>Structure</strong> → edges and textures</p>
</li>
<li><p><strong>Noise</strong> → unstable high-frequency signal</p>
</li>
</ul>
<p>This acts as the image’s <strong>fingerprint</strong>.</p>
<h3>Important detail</h3>
<p>Dynamic range is measured using <strong>percentiles (P5–P95)</strong>, not min/max.</p>
<p>Why?</p>
<p>Because outliers lie.</p>
<p>A single bright pixel shouldn’t define the entire image.</p>
<hr />
<h2>Noise vs Texture (Why This Matters)</h2>
<p>A common mistake:</p>
<blockquote>
<p>Treating all high-frequency detail as noise</p>
</blockquote>
<p>That causes systems to:</p>
<ul>
<li><p>suppress real texture (brick, fabric, hair)</p>
</li>
<li><p>under-enhance detailed images</p>
</li>
</ul>
<p>PACE uses a <strong>robust, median-based approach</strong> to separate:</p>
<ul>
<li><p>consistent variation → noise</p>
</li>
<li><p>structured variation → detail</p>
</li>
</ul>
<p>This is critical for safe enhancement.</p>
<p><em>(I’ll break down the exact method in Part 2.)</em></p>
<hr />
<h2>The Brain: Adaptive Control</h2>
<p>This is where PACE becomes a system, not just an algorithm.</p>
<p>Instead of fixed parameters, everything is derived from the image.</p>
<p>Example idea:</p>
<ul>
<li><p>Flat image → increase contrast</p>
</li>
<li><p>Noisy image → reduce aggressiveness</p>
</li>
<li><p>Structured image → allow stronger enhancement</p>
</li>
</ul>
<p>These signals are combined into a <strong>bounded control value</strong>.</p>
<p>Important property:</p>
<blockquote>
<p>The system never collapses to zero and never explodes.</p>
</blockquote>
<p>It stays <strong>alive but controlled</strong>.</p>
<hr />
<h2>Local Contrast (CLAHE)</h2>
<p>PACE uses CLAHE (Contrast Limited Adaptive Histogram Equalization) as a base signal.</p>
<blockquote>
<p>PACE auto compute <code>clipLimit</code> and <code>tileSize</code>. For more details checkout <a href="https://pacejs.hashnode.dev/how-to-auto-compute-clahe-parameters-intelligently-for-every-image">CLAHE Auto-Compute Parameters</a> article.</p>
</blockquote>
<p>What it does:</p>
<ul>
<li><p>Enhances contrast locally (tile-based)</p>
</li>
<li><p>Prevents over-amplification using clipping</p>
</li>
<li><p>Interpolates between regions to avoid seams</p>
</li>
</ul>
<p>On its own, CLAHE can look harsh (see CLAHE output).</p>
<p>PACE doesn’t use it directly — it uses it as <strong>one signal among many</strong>.</p>
<hr />
<h2>Spatial Intelligence (Why Global Filters Fail)</h2>
<p>Global filters apply the same change everywhere.</p>
<p>PACE builds <strong>spatial maps</strong> that vary across the image:</p>
<ul>
<li><p>Where edges exist → enhance more</p>
</li>
<li><p>Where noise dominates → suppress</p>
</li>
<li><p>Where tones are sensitive → protect</p>
</li>
</ul>
<p>This shifts control from:</p>
<blockquote>
<p>global decisions → local decisions</p>
</blockquote>
<hr />
<h2>Final Blending</h2>
<p>Instead of a single transformation, PACE combines multiple signals:</p>
<ul>
<li><p>Local contrast (CLAHE)</p>
</li>
<li><p>Structural detail</p>
</li>
<li><p>Tone protection</p>
</li>
</ul>
<p>These compete under perceptual constraints.</p>
<blockquote>
<p>Enhancement is not applied — it is negotiated.</p>
</blockquote>
<hr />
<h2>Visual Walkthrough</h2>
<h3>1. Input</h3>
<img src="https://muhammedshahid.github.io/public_images/hashnode/input.jpg" alt="input" style="display:block;margin:0 auto" />

<h3>2. Enhancement Map</h3>
<img src="https://muhammedshahid.github.io/public_images/hashnode/enhancement_map.png" alt="enhancement map" style="display:block;margin:0 auto" />

<h3>3. CLAHE Output</h3>
<img src="https://muhammedshahid.github.io/public_images/hashnode/clahe.png" alt="CLAHE Output" style="display:block;margin:0 auto" />

<h3>4. Final Output</h3>
<img src="https://muhammedshahid.github.io/public_images/hashnode/PACE_Enhanced.png" alt="PACE Output" style="display:block;margin:0 auto" />

<h3>5. Comparative Visualization</h3>
<img src="https://muhammedshahid.github.io/public_images/hashnode/ezgif-68956988d52f27be.gif" alt="comparison" style="display:block;margin:0 auto" />

<hr />
<h2>Why This Works (In One Line)</h2>
<p>Most libraries apply fixed transforms.</p>
<p>PACE:</p>
<blockquote>
<p>analyzes → decides → applies selectively</p>
</blockquote>
<p>That difference is everything.</p>
<hr />
<h2>What’s Next (Part 2)</h2>
<p>This post focused on <strong>how the system behaves</strong>.</p>
<p>In Part 2, I’ll break down:</p>
<ul>
<li><p>Noise modeling (Laplacian + MAD)</p>
</li>
<li><p>Adaptive control math (alpha, beta, lambda)</p>
</li>
<li><p>CLAHE precision and clipLimit issues</p>
</li>
<li><p>Failure modes and how they were fixed</p>
</li>
</ul>
<hr />
<h2>Final Thoughts</h2>
<p>PACE isn’t a filter.</p>
<p>It’s a decision system for image enhancement.</p>
<p>And building it in the browser forced every part of it to be:</p>
<ul>
<li><p>efficient</p>
</li>
<li><p>adaptive</p>
</li>
<li><p>perceptually grounded</p>
</li>
</ul>
<p>If you’ve built something similar — or see flaws — I’d like to hear your take.</p>
]]></content:encoded></item></channel></rss>