|
| 1 | +# Plan: replace YIQ with OKLab as the perceptual color difference metric |
| 2 | + |
| 3 | +## Motivation |
| 4 | + |
| 5 | +The current metric (Kotsarenko/Ramos YIQ Euclidean distance) operates on gamma-encoded sRGB |
| 6 | +with a purely linear transform, so it structurally cannot account for the nonlinearity of |
| 7 | +human color perception — no re-tuning of the Y/I/Q weights can fix it. Concrete failures |
| 8 | +from [#127](https://github.com/mapbox/pixelmatch/issues/127), with distances normalized so |
| 9 | +black↔white = 1.0 (default threshold is 0.1): |
| 10 | + |
| 11 | +| case | YIQ (current) | OKLab | OKLab HyAB | CIEDE2000 | human verdict | |
| 12 | +|---|---|---|---|---|---| |
| 13 | +| rgb(41,56,157) vs rgb(41,56,0) | 0.199 | 0.247 | 0.315 | 0.582 | very different | |
| 14 | +| rgb(0,254,252) vs rgb(94,254,252) | **0.200** | 0.025 | 0.034 | 0.024 | nearly identical | |
| 15 | +| rgb(39,44,92) vs rgb(39,44,14) | 0.099 | 0.136 | 0.167 | 0.391 | clearly different | |
| 16 | +| rgb(0,254,252) vs rgb(47,254,252) | 0.100 | 0.007 | 0.009 | 0.006 | imperceptible | |
| 17 | + |
| 18 | +YIQ assigns the same distance (0.199 vs 0.200) to a pair that is "very different" and a pair |
| 19 | +that is "nearly identical". OKLab separates them 10x and agrees with CIEDE2000 on every ranking. |
| 20 | + |
| 21 | +## Why OKLab (and not CIEDE2000, redmean, ICtCp) |
| 22 | + |
| 23 | +- **OKLab** (Ottosson, 2020; adopted by CSS Color 4) was explicitly fitted so that plain |
| 24 | + Euclidean distance approximates CIEDE2000 predictions. The transform is cheap: |
| 25 | + sRGB→linear (256-entry LUT) → 3×3 matrix → cbrt per channel → 3×3 matrix. |
| 26 | + It keeps pixelmatch's squared-distance trick (no sqrt; black↔white squared distance is |
| 27 | + exactly 1.0) and the brighter/darker sign convention falls out of ΔL. |
| 28 | +- **CIEDE2000** is ~60x slower per pair (~2.5–4x end-to-end), and was fitted on *small* |
| 29 | + near-JND differences (textile/patch studies) — a regime pixelmatch doesn't operate in; |
| 30 | + it's known to be non-uniform for large differences. Not worth it. |
| 31 | +- **redmean** is cheap but shares YIQ's structural flaw (scores the "nearly identical" |
| 32 | + cyan pair at 0.182 — nearly as bad as YIQ). |
| 33 | +- **ICtCp/ΔEITP** is comparable to OKLab in cost/quality but designed for HDR; no advantage |
| 34 | + for 8-bit sRGB. |
| 35 | + |
| 36 | +## Measured performance and diff counts |
| 37 | + |
| 38 | +Per-pair (random colors): YIQ 250M pairs/s; naive OKLab (`Math.cbrt`) 22M; OKLab with a |
| 39 | +4096-entry interpolated cbrt LUT 37M (max distance error 2.6e-4 — negligible vs any |
| 40 | +threshold). Per-pair cost is heavily amortized end-to-end because `colorDelta` only runs for |
| 41 | +differing pixels; the identical-pixel u32 scan and AA detection dominate. Memoizing |
| 42 | +conversions per unique color does not help (JS Map overhead ≈ the compute itself). |
| 43 | + |
| 44 | +End-to-end on `bench.js` (real fixtures, 100 iterations), plus per-fixture diff counts at |
| 45 | +threshold 0.1: |
| 46 | + |
| 47 | +threshold 0.1 (timings from isolated node processes, best of 5 — mixing variants in one |
| 48 | +process pollutes the JIT and skews results by ~10%): |
| 49 | + |
| 50 | +| variant | bench.js | fx1 | fx2 | fx3 | fx4 | fx5 | fx6 | fx7 | |
| 51 | +|---|---|---|---|---|---|---|---|---| |
| 52 | +| YIQ (current) | 799 ms | 106 | 9730 | 178 | 6889 | 6 | 51 | 2448 | |
| 53 | +| OKLab Euclidean | 950 ms (**+19%**) | 111 | 9148 | 164 | 4760 | 6 | 38 | 2330 | |
| 54 | +| OKLab HyAB | 1060 ms (**+33%**) | 116 | 10155 | 187 | 6942 | 6 | 38 | 2348 | |
| 55 | +| OKLab HyAB, sqrt-free | 991 ms (**+24%**) | (identical to HyAB) | | | | | | | |
| 56 | + |
| 57 | +### HyAB optimization: sqrt-free thresholding — measured |
| 58 | + |
| 59 | +`colorDelta`'s return value is only ever used two ways: compared against `maxDelta`, and its |
| 60 | +sign checked for `diffColorAlt`. So the HyAB sqrt can be eliminated by moving the threshold |
| 61 | +test inside `colorDelta`: `|ΔL| + √(Δa²+Δb²) > t` ⇔ `|ΔL| > t`, or else |
| 62 | +`Δa²+Δb² > (t − |ΔL|)²`. Bonus: when `|ΔL|` alone exceeds the threshold, Δa/Δb aren't |
| 63 | +computed at all. `colorDelta(…, maxDelta)` then returns just `0 / +1 / −1` (sign = darker |
| 64 | +or brighter). Verified to produce bit-identical diff counts on all fixtures. |
| 65 | + |
| 66 | +This recovers over half of HyAB's overhead: **+33% → +24%** vs YIQ (~4% over Euclidean). |
| 67 | +Also tested: folding the LMS matrix into nine premultiplied 256-entry LUTs (dropping 18 |
| 68 | +multiplies per pair) — ~1% gain, within noise; not worth the ugliness. The remaining |
| 69 | +regression is dominated by the six cbrt-LUT interpolations, which are inherent to OKLab. |
| 70 | + |
| 71 | +### Euclidean vs HyAB (`|ΔL| + √(Δa²+Δb²)`, Abasi et al. 2019) — measured, decision pending |
| 72 | + |
| 73 | +- HyAB is reported to track perceived *large* color differences (pixelmatch's regime) better |
| 74 | + than Euclidean, and separates the #127 cases slightly better (0.315 vs 0.247 for the "very |
| 75 | + different" pair). |
| 76 | +- Cost: +14% over Euclidean naively, reduced to ~4% with the sqrt-free thresholding below. |
| 77 | +- Since HyAB ≥ Euclidean for the same pair (L1 vs L2 norm), it is strictly more sensitive at |
| 78 | + the same threshold — its fixture counts land much closer to current YIQ behavior |
| 79 | + (fx4: 6942 vs YIQ's 6889, where Euclidean drops to 4760). Less churn for users' existing |
| 80 | + thresholds/baselines is an argument *for* HyAB despite the extra 14%. |
| 81 | +- **Decision criterion:** eyeball fixture diff outputs with both; pick HyAB if its extra |
| 82 | + sensitivity looks like signal rather than noise. Default leaning: HyAB — better literature |
| 83 | + support for large differences and smoother migration, at +24% total vs +19%. |
| 84 | + |
| 85 | +## `brightnessDelta` (AA detection): keep gamma luma — measured |
| 86 | + |
| 87 | +The AA detector uses a brightness-only delta (gamma-space Rec.601 luma). Should it become |
| 88 | +OKLab ΔL for consistency? **No.** Measured with a ΔL-based `brightnessDelta` variant: |
| 89 | + |
| 90 | +- **Accuracy of the scalar itself:** gamma luma and OKLab L do disagree — mean |Δluma − ΔL| |
| 91 | + is 0.086 over random pairs (max 0.28), and pairs exist with equal luma but ΔL up to 0.055 |
| 92 | + (e.g. dark red vs dark green). So ΔL is the *better lightness estimate* in the abstract. |
| 93 | +- **But AA detection doesn't need a perceptual lightness estimate.** It needs a cheap, |
| 94 | + consistent scalar to find the direction of the intensity ramp (darkest/brightest neighbor, |
| 95 | + equal-sibling counting). Along a real antialiasing ramp (blend between two edge colors), |
| 96 | + both scalars are monotonic and produce the same min/max neighbors. |
| 97 | +- **Measured impact of switching:** bench.js 956 → 1396 ms (**+46%**, because the detector |
| 98 | + calls `brightnessDelta` up to 16× per candidate pixel), and the AA-heavy fixture 5 |
| 99 | + regressed 6 → 53 diff pixels (9x more AA false positives — ΔL's compression of dark-region |
| 100 | + contrasts breaks the darkest-neighbor selection there). Fixture 6 changed 38 → 29. |
| 101 | +- **Conclusion:** keep `brightnessDelta` on gamma luma (also keeps `drawGrayPixel` untouched). |
| 102 | + Cheap, and empirically better for this heuristic. Document the intentional inconsistency in |
| 103 | + a comment. |
| 104 | + |
| 105 | +## AA algorithm review: is Vysniauskas 2009 still the right choice? |
| 106 | + |
| 107 | +Current algorithm: a differing pixel is excluded as AA if, in either image, its 3×3 |
| 108 | +neighborhood has ≤2 equal siblings, both a darker and a brighter neighbor exist, and the |
| 109 | +darkest or brightest neighbor sits in a flat region (3+ identical siblings) in *both* images. |
| 110 | + |
| 111 | +Known failure modes (from the issue tracker and code reading): |
| 112 | + |
| 113 | +1. **Thin shapes / 1px lines / rings** ([#74](https://github.com/mapbox/pixelmatch/issues/74)): |
| 114 | + a true AA pixel next to a 1px-wide stroke has no extreme neighbor with 3+ equal siblings, |
| 115 | + so it's counted as a real diff (false positive). Reported repeatedly for font smoothing. |
| 116 | +2. **Extreme-neighbor ties** (noted by a porter in #74): among equally-darkest (or brightest) |
| 117 | + neighbors only the first encountered is tested with `hasManySiblings`; a tied candidate |
| 118 | + that would pass is ignored → unstable, orientation-dependent results. |
| 119 | +3. **Gradients / noise / dithering:** `hasManySiblings` requires exact u32 equality, so AA on |
| 120 | + a smooth gradient background never finds a "flat" extreme → false positives. Conversely |
| 121 | + subpixel-AA color fringes (ClearType) differ in chroma more than luma and the |
| 122 | + brightness-only delta can under-detect the ramp. |
| 123 | +4. The `zeroes > 2` early-exit means a pixel on a flat-color boundary is never AA — usually |
| 124 | + right, occasionally wrong for 2px-wide AA ramps. |
| 125 | + |
| 126 | +Options, cheapest first: |
| 127 | + |
| 128 | +- **(a) Fix the tie handling:** collect all tied darkest/brightest neighbors and pass if any |
| 129 | + satisfies `hasManySiblings`. Small, local, strictly reduces false positives. Low risk — |
| 130 | + worth doing in the same major. |
| 131 | +- **(b) Relax the flatness requirement for one extreme** (proposed by the #74 reporter): |
| 132 | + require 3+ equal siblings for only one of darkest/brightest, with a weaker condition |
| 133 | + (e.g. 3+ *similar* siblings under some small OKLab delta) for the other. Directly targets |
| 134 | + the thin-stroke failure. Needs test fixtures (the #74 thread has example images) — worth an |
| 135 | + experiment, medium risk of admitting real 1px changes as AA. |
| 136 | +- **(c) Blend-ramp test:** a true AA pixel is a mixture of its two edge sides, so its color |
| 137 | + should lie near the OKLab segment between the darkest and brightest neighbors, in both |
| 138 | + images with consistent direction. More principled than sibling-counting and only runs for |
| 139 | + above-threshold pixels (cheap in aggregate). Novel — needs a fixture corpus to validate. |
| 140 | +- **(d) Shift-tolerance semantics** (resemble.js-style "does the other image have a matching |
| 141 | + pixel within 1px"): catches subpixel layout shifts, but changes what pixelmatch *means* — |
| 142 | + better as a separate opt-in option than a replacement, if ever. |
| 143 | + |
| 144 | +**Recommendation:** don't replace the algorithm wholesale in this release — the metric change |
| 145 | +is the well-evidenced part; AA rework without a failure-case corpus is guesswork. Do (a) now |
| 146 | +(safe, fixes a real reported instability while we're breaking things anyway), prototype (b) |
| 147 | +and (c) against fixtures built from #74-style thin-stroke/text cases, and fold whichever wins |
| 148 | +into the same major if ready in time — otherwise ship the metric change alone. |
| 149 | + |
| 150 | +## Prototypes |
| 151 | + |
| 152 | +- `index-oklab.js` (repo root) — OKLab Euclidean `colorDelta`, cbrt LUT, `maxDelta = t²`. |
| 153 | +- `index-hyab.js` (repo root) — plain HyAB variant (`maxDelta = t`, sqrt per differing pixel). |
| 154 | +- `index-hyab-fast.js` (repo root) — HyAB with sqrt-free thresholding; `colorDelta` takes |
| 155 | + `maxDelta` and returns `0 / ±1`. Identical output to `index-hyab.js`, +24% vs YIQ. |
| 156 | +- Scratchpad (session): `index-oklab-dl.js` (ΔL brightness, rejected), |
| 157 | + `metrics.mjs` / `fastoklab.mjs` / `bench-all.mjs` / `bench-one.mjs` (all numbers above). |
| 158 | + |
| 159 | +## Implementation steps |
| 160 | + |
| 161 | +1. **Port the prototype into `index.js`** |
| 162 | + - Replace the YIQ math in `colorDelta`: blend against background if semi-transparent |
| 163 | + (produce blended colors rather than channel deltas), then sRGB→linear LUT → LMS matrix → |
| 164 | + cbrt LUT → OKLab distance (Euclidean or HyAB per the decision above). |
| 165 | + - `maxDelta = threshold * threshold` (Euclidean) or `threshold` (HyAB); black↔white = 1.0 |
| 166 | + replaces the magic 35215. If HyAB: use the sqrt-free thresholding from |
| 167 | + `index-hyab-fast.js` (`colorDelta` takes `maxDelta`, returns `0 / ±1`). |
| 168 | + - Sign convention: negative when img2's pixel is brighter (ΔL > 0), as before with Y. |
| 169 | + - Module-load-time LUTs: `LIN` (256 × Float64) and `CBRT` (4098 × Float64). |
| 170 | + - `brightnessDelta` and `drawGrayPixel` stay on gamma luma (see above). |
| 171 | +2. **Fix the blending precision issue** (known prototype shortcut): blended channel values are |
| 172 | + fractional, but `LIN` is indexed by integers; the prototype truncates with `| 0`. |
| 173 | + Interpolate: `lin = LIN[i] + (LIN[i+1] - LIN[i]) * frac` (pad `LIN` to 257 entries), or |
| 174 | + evaluate the sRGB curve directly on the (rare) blended path. |
| 175 | +3. **AA fix (a):** handle darkest/brightest ties in `antialiased()`; add a regression fixture. |
| 176 | +4. **Recalibrate / regenerate fixtures** |
| 177 | + - Inspect diff outputs visually to confirm changes are improvements (fewer false positives |
| 178 | + on chroma-only deltas, new detections on dark-blue-vs-black cases); decide Euclidean vs |
| 179 | + HyAB here. |
| 180 | + - Regenerate expected diff PNGs and counts in `test/test.js`. |
| 181 | + - Add regression tests for the four #127 color pairs (small synthetic images; assert |
| 182 | + diff/no-diff at default threshold). |
| 183 | +5. **Benchmark:** run `bench.js` before/after in isolated processes; target ≤20% (Euclidean) |
| 184 | + / ≤25% (HyAB sqrt-free) regression. If needed: try Float32Array LUTs, check `colorDelta` |
| 185 | + inlining. (Already ruled out: premultiplied LMS LUTs, per-color memoization.) |
| 186 | +6. **(time-permitting) AA experiments (b)/(c)** against a thin-stroke/text fixture corpus; |
| 187 | + include only if fixtures show clear improvement. |
| 188 | +7. **Docs and release** |
| 189 | + - README: document the new metric; note that `threshold` semantics changed and users may |
| 190 | + need to re-tune thresholds and regenerate stored diff baselines. |
| 191 | + - Update `colorDelta` JSDoc (cite Ottosson's OKLab post instead of Kotsarenko/Ramos). |
| 192 | + - Release as **semver-major**; respond to #127 (and #74 if AA fixes land) with the |
| 193 | + evidence tables. |
| 194 | + |
| 195 | +## Open questions |
| 196 | + |
| 197 | +- **Euclidean vs HyAB:** measured above; decide during fixture regeneration (step 4). |
| 198 | + Leaning HyAB. |
| 199 | +- **Default threshold:** 0.1 maps to similar sensitivity for achromatic deltas (gray Δ10: |
| 200 | + ~0.034 OKLab vs ~0.038 YIQ) but Euclidean is less sensitive to large chroma-only deltas |
| 201 | + that YIQ over-reported. If HyAB is chosen this mostly resolves itself (counts stay close to |
| 202 | + current); if Euclidean, evaluate whether the default should drop to ~0.08. |
| 203 | +- **cbrt LUT size/accuracy:** 4096 entries → 2.6e-4 max distance error. Keep the LUT, or use |
| 204 | + `Math.cbrt` for simplicity (~40% slower colorDelta)? Leaning: keep the LUT. |
| 205 | +- **Alpha blending space:** blending stays in gamma sRGB (matches how browsers composite by |
| 206 | + default), then convert the blended color. Physically-correct blending would be in linear |
| 207 | + space. Leaning: keep gamma-space blending for compatibility with how images were rendered. |
| 208 | +- **How far to take AA changes in this major:** (a) is in; (b)/(c) depend on building a |
| 209 | + failure-case corpus (thin strokes, font smoothing — see #74 thread for example images). |
0 commit comments