@zakkster/lite-color-engine 1.0.2 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +112 -0
- package/README.md +48 -2
- package/index.d.ts +20 -0
- package/index.js +2 -0
- package/llms.txt +77 -3
- package/package.json +33 -10
- package/src/Gamut.d.ts +37 -0
- package/src/Gamut.js +228 -0
- package/src/Remap.d.ts +87 -0
- package/src/Remap.js +306 -0
- package/src/delta.js +37 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `@zakkster/lite-color-engine` are documented in this file.
|
|
4
|
+
|
|
5
|
+
The format loosely follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project follows [Semantic Versioning](https://semver.org/).
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
## [1.1.0] — Unreleased
|
|
9
|
+
|
|
10
|
+
### Added — MINDE gamut mapping, ΔE-OK, and SoA remap kernels
|
|
11
|
+
|
|
12
|
+
Two new sub-exports (`/gamut`, `/remap`) plus one core function. All follow the buffer-and-offset API and the zero-allocation-on-hot-path convention from v1.0.
|
|
13
|
+
|
|
14
|
+
#### Core
|
|
15
|
+
|
|
16
|
+
- **`deltaEOK(bufA, oA, bufB, oB): number`** — ΔE-OK color difference (Euclidean distance in OKLab) between two buffered OKLCH colors. Ten-line pure function, zero allocations. Consumed internally by the `/gamut` and `/remap` sub-exports and available for downstream packages.
|
|
17
|
+
|
|
18
|
+
#### `/gamut` sub-export
|
|
19
|
+
|
|
20
|
+
```js
|
|
21
|
+
import {
|
|
22
|
+
gamutMapToSrgbBuffer,
|
|
23
|
+
packOklchBufferToUint32MINDE,
|
|
24
|
+
} from '@zakkster/lite-color-engine/gamut';
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
- **`gamutMapToSrgbBuffer(inBuf, inOffset, outBuf, outOffset)`** — CSS Color 4 MINDE chroma reduction. Reduces chroma at fixed L and H until the color is in-gamut, using bisection with a JND (0.02) threshold. Source and destination may alias. All scratch is module-level; zero allocation on the hot path.
|
|
28
|
+
- **`packOklchBufferToUint32MINDE(buf, offset, alpha?)`** — accurate sibling of the v1.0 packer. Eliminates the hue-shift artifacts of the hard channel clamp near the gamut boundary. Returns unsigned RGBA-LE Uint32 (matches v1.0 convention). Signature-compatible with the core packer, so it can be passed directly as the `packer` argument to `bakeGradientToUint32`.
|
|
29
|
+
|
|
30
|
+
#### `/remap` sub-export
|
|
31
|
+
|
|
32
|
+
```js
|
|
33
|
+
import {
|
|
34
|
+
sRgba8ToOklabBuffer,
|
|
35
|
+
oklchToOklabBuffer,
|
|
36
|
+
oklabToOklchBuffer,
|
|
37
|
+
nearestPaletteIndexBuffer,
|
|
38
|
+
remapPixelsToPalette,
|
|
39
|
+
} from '@zakkster/lite-color-engine/remap';
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
- **`sRgba8ToOklabBuffer(inU8, outLab, pixelCount)`** — batch RGBA8 → OKLab. Alpha discarded.
|
|
43
|
+
- **`oklchToOklabBuffer` / `oklabToOklchBuffer`** — batch polar/cartesian conversions between OKLCH and OKLab. Hue canonicalized to `[0, 360)`.
|
|
44
|
+
- **`nearestPaletteIndexBuffer(pixelsLab, paletteLab, indicesOut, pixelCount, paletteCount, opts?)`** — core search kernel. For each pixel, writes the index of the nearest palette color by squared Euclidean distance in OKLab. Tie-break: lowest index wins. Accepts any typed-array index output (`Uint32Array`, `Uint16Array`, `Uint8Array`). Option `preserveLightness`: distance uses `(a, b)` only.
|
|
45
|
+
- **`remapPixelsToPalette(inU8, paletteLch, outU32, pixelCount, paletteCount, opts?)`** — one-shot end-to-end: RGBA8 → nearest palette color → RGBA-LE Uint32. Alpha byte passes through unchanged. Palette OKLCH → OKLab conversion happens internally on module-level scratch (grows monotonically, never shrinks). Option `preserveLightness`: synthesizes output per pixel as `(pixel.L, palette.a, palette.b)` — retains shading structure under recolor. ~40% slower than the fast path.
|
|
46
|
+
|
|
47
|
+
### Alignment with `V1.1-PLAN.md`
|
|
48
|
+
|
|
49
|
+
- **P0/#1** (MINDE gamut mapping) — shipped as `/gamut`.
|
|
50
|
+
- **P1/#5** (ΔE-OK) — shipped in core.
|
|
51
|
+
- **P0/#2** (alpha-aware LUT baker), **P0/#3** (ARGB byte-order variant), **P0/#4** (hue interpolation modes), **P1/#6** (gamma LUT packer), **P1/#7** (CSS Color 5 relative-color syntax) — not in this release, tracked for follow-up.
|
|
52
|
+
|
|
53
|
+
### New in this release, not in the plan
|
|
54
|
+
|
|
55
|
+
- **`/remap` sub-export** — added as the engine underlayment for `lite-hueforge v1.3` `remapImageToPalette`. Standalone value: palette quantization for retro / palette-cycling render effects, dithering targets, and demoscene-style palette animations.
|
|
56
|
+
|
|
57
|
+
### Design notes
|
|
58
|
+
|
|
59
|
+
- **Sub-exports are self-contained.** `Gamut.js` and `Remap.js` each carry their own local converters (`_oklchToLinearRgb`, `_linearRgbToOklch`, `_srgbGammaEncode`, `_linearizeSrgbByte`) rather than importing from core. Costs a small amount of duplicated math across the two files, buys loadability in isolation and independence from any future refactor of the internal converters.
|
|
60
|
+
- **Search in OKLab, not OKLCH.** Distance in OKLab is Euclidean; distance in OKLCH would need cos/sin per comparison. Palette is authored in OKLCH (human-natural), converted once at call entry.
|
|
61
|
+
- **Grow-only scratch.** `remapPixelsToPalette` grows its internal palette scratch when a larger palette is passed; it never shrinks. This matches the ecosystem's zero-GC contract — allocation happens at edge cases, not per frame.
|
|
62
|
+
- **Alpha semantics.** In `remapPixelsToPalette`, the palette is pre-packed opaque; input alpha is OR-composited into the output. This lets the palette live alongside a per-pixel alpha channel without double-clamping.
|
|
63
|
+
|
|
64
|
+
### Performance
|
|
65
|
+
|
|
66
|
+
Measured on Node 22 in a conservative sandbox (no JIT warm-up budget); V8 in browsers is typically 2–3× faster. 5-color palette, noise input:
|
|
67
|
+
|
|
68
|
+
| Resolution | Fast path | preserveLightness |
|
|
69
|
+
| ---------- | --------- | ----------------- |
|
|
70
|
+
| 256 × 256 | 60+ fps | 40 fps |
|
|
71
|
+
| 512 × 512 | 16 fps | 10 fps |
|
|
72
|
+
| 1024 × 1024| 4 fps | 2.5 fps |
|
|
73
|
+
|
|
74
|
+
Interpretation: the fast path is real-time up to ~512² in browser conditions; `preserveLightness` is real-time up to ~256². For larger canvases (2048² live recolor), a WebGL shader path is the intended next-generation kernel — flagged as a v1.2 candidate. WASM SIMD is another option worth benching.
|
|
75
|
+
|
|
76
|
+
For the near-term demo, downscale the preview canvas to 512² and upscale for final render — a standard pattern in image-editing tools.
|
|
77
|
+
|
|
78
|
+
### Tests
|
|
79
|
+
|
|
80
|
+
50 new deterministic tests across three files. All 43 existing tests continue to pass; no behavioural changes to v1.0 exports.
|
|
81
|
+
|
|
82
|
+
| File | Tests | Coverage |
|
|
83
|
+
| ---- | ----- | -------- |
|
|
84
|
+
| `gamut.test.js` | 19 | MINDE identity on in-gamut / chroma reduction on out-of-gamut / L endpoint collapse / C=0 achromatic / aliasing safety / arbitrary offsets / determinism / hue sweep in-gamut invariant; packMINDE unsigned uint32 return / default-alpha behaviour / alpha clamping / endpoint byte encoding / no hue-shift on out-of-gamut / bit-for-bit parity with core packer on in-gamut input / drop-in usable with `bakeGradientToUint32` / hot-path smoothness |
|
|
85
|
+
| `remap.test.js` | 21 | OKLCH↔OKLab round-trip within 1e-5 / hue canonicalization; sRgba8ToOklab endpoint correctness / batch stride / plain `Uint8Array` acceptance; nearestPaletteIndex exact match / lowest-index tie-break / preserveLightness path separation / explicit `false` matches default / batch correctness / typed-array flexibility (`Uint32Array`, `Uint16Array`, `Uint8Array`); remapPixels solid-fill correctness / primary-color mapping / preserveLightness output darkness / alpha passthrough (both paths) / determinism / scratch-grow safety |
|
|
86
|
+
| `delta.test.js` | 10 | Identity / symmetry / non-negativity / large ΔE between visually distant colors / small ΔE between near-identical colors / offset correctness / hue wraparound (1° vs 359°) / monotonic growth with diverging chroma / determinism |
|
|
87
|
+
|
|
88
|
+
### Non-breaking
|
|
89
|
+
|
|
90
|
+
Additive only. No existing API surface changed. v1.0 consumers continue to work identically at `1.1.0`.
|
|
91
|
+
|
|
92
|
+
### Packaging
|
|
93
|
+
|
|
94
|
+
- Added sub-export entries for `./gamut` and `./remap` in `package.json` `exports`, with `types`/`node`/`import`/`default` conditions.
|
|
95
|
+
- Added `CHANGELOG.md` to the `files` list.
|
|
96
|
+
- Fixed a pre-existing typo: `files` listed `LICENSE.txt` where the actual file is `LICENSE.md`.
|
|
97
|
+
|
|
98
|
+
### Downstream migrations enabled
|
|
99
|
+
|
|
100
|
+
- `@zakkster/lite-hueforge` can replace its local gamut mapper (if present) with an import from `@zakkster/lite-color-engine/gamut`.
|
|
101
|
+
- `@zakkster/lite-gradient-studio` same.
|
|
102
|
+
- `@zakkster/lite-color-lerp` can finally fulfil the README's "gamut-mapped variants … a future addition" promise via the engine.
|
|
103
|
+
- `@zakkster/lite-hueforge v1.3` `remapImageToPalette` sits directly on top of `remapPixelsToPalette`.
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
## [1.0.4] — Prior
|
|
107
|
+
|
|
108
|
+
Baseline v1.0 surface: authoring (`parseCSSColor` and format-specific parsers), runtime (`lerpOklchBuffer`, `packOklchBufferToUint32`, `packOklchBufferToUint32Fast`, `sampleColorLUT`), LUT (`bakeGradientToUint32`), convert (`sRgbToOklchBuffer`).
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
MIT © Zahary Shinikchiev.
|
package/README.md
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
# @zakkster/lite-color-engine
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/@zakkster/lite-color-engine)
|
|
4
|
+
[](https://github.com/sponsors/PeshoVurtoleta)
|
|
4
5
|
[](https://bundlephobia.com/result?p=@zakkster/lite-color-engine)
|
|
5
6
|
[](https://www.npmjs.com/package/@zakkster/lite-color-engine)
|
|
7
|
+
[](https://www.npmjs.com/package/@zakkster/lite-color-engine)
|
|
6
8
|

|
|
7
9
|

|
|
8
|
-
[](./LICENSE.
|
|
10
|
+
[](./LICENSE.md)
|
|
9
11
|
|
|
10
12
|
## 🎨 What is lite-color-engine?
|
|
11
13
|
|
|
@@ -277,6 +279,50 @@ Bakes a multi-stop OKLCH gradient into a `Uint32Array` of packed RGBA-LE colors.
|
|
|
277
279
|
|
|
278
280
|
**Throws** if `numStops < 2` or `resolution < 2`.
|
|
279
281
|
|
|
282
|
+
### Difference
|
|
283
|
+
|
|
284
|
+
#### `deltaEOK(bufA, offsetA, bufB, offsetB): number`
|
|
285
|
+
|
|
286
|
+
ΔE-OK color difference — Euclidean distance in OKLab between two buffered OKLCH colors. Zero allocations, pure primitive math.
|
|
287
|
+
|
|
288
|
+
Typical scale: `0.02` indistinguishable, `0.05` subtle, `0.15+` unambiguously different. Use for palette dedupe, nearest-color lookup, and contrast checks.
|
|
289
|
+
|
|
290
|
+
## 🧭 Sub-exports (v1.1)
|
|
291
|
+
|
|
292
|
+
The core surface stays sub-2 KB. Heavier optional kernels ship as separate entry points so you only pay for what you import.
|
|
293
|
+
|
|
294
|
+
### `@zakkster/lite-color-engine/gamut` — MINDE gamut mapping
|
|
295
|
+
|
|
296
|
+
CSS Color 4 chroma-reduction gamut mapping. Reduces chroma at fixed L and H until the color is inside the sRGB gamut, using bisection with a JND threshold. Fixes the visible hue shifts that the hard channel clamp produces near the gamut boundary.
|
|
297
|
+
|
|
298
|
+
```js
|
|
299
|
+
import {
|
|
300
|
+
gamutMapToSrgbBuffer,
|
|
301
|
+
packOklchBufferToUint32MINDE,
|
|
302
|
+
} from '@zakkster/lite-color-engine/gamut';
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
- **`gamutMapToSrgbBuffer(inBuf, inOffset, outBuf, outOffset)`** — writes a gamut-mapped `[L, C', H]` triplet. Source and destination may alias. Zero allocations on the hot path.
|
|
306
|
+
- **`packOklchBufferToUint32MINDE(buf, offset, alpha?)`** — accurate sibling of the core packer. `~30×` slower — belongs at LUT-build time, not per-frame. Signature-compatible with `packer` in `bakeGradientToUint32`.
|
|
307
|
+
|
|
308
|
+
### `@zakkster/lite-color-engine/remap` — palette-remap kernels
|
|
309
|
+
|
|
310
|
+
Nearest-palette-index search in OKLab, plus a one-shot RGBA8-to-recolored-Uint32 pipeline. The engine underlayment for `lite-hueforge`'s `remapImageToPalette`, and standalone-useful for retro palette quantization and palette-cycling render effects.
|
|
311
|
+
|
|
312
|
+
```js
|
|
313
|
+
import {
|
|
314
|
+
sRgba8ToOklabBuffer,
|
|
315
|
+
oklchToOklabBuffer,
|
|
316
|
+
oklabToOklchBuffer,
|
|
317
|
+
nearestPaletteIndexBuffer,
|
|
318
|
+
remapPixelsToPalette,
|
|
319
|
+
} from '@zakkster/lite-color-engine/remap';
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
- **Batch converters** — `sRgba8ToOklabBuffer`, `oklchToOklabBuffer`, `oklabToOklchBuffer`. SoA, stride-3 in/out, hue canonicalized to `[0, 360)` where applicable.
|
|
323
|
+
- **`nearestPaletteIndexBuffer(...)`** — for each pixel in `pixelsLab`, write the index of the nearest palette color into `indicesOut` (accepts `Uint32Array`, `Uint16Array`, or `Uint8Array`). Tie-break: lowest index wins.
|
|
324
|
+
- **`remapPixelsToPalette(inU8, paletteLch, outU32, pixelCount, paletteCount, opts?)`** — end-to-end pipe. Alpha byte passes through unchanged. `opts.preserveLightness` searches by `(a, b)` only and synthesizes output as `(pixel.L, palette.a, palette.b)` — retains shading structure under recolor.
|
|
325
|
+
|
|
280
326
|
## ⚡ Performance characteristics
|
|
281
327
|
|
|
282
328
|
| Operation | Cost |
|
|
@@ -334,4 +380,4 @@ See `llms.txt` for a structured reference designed for AI coding assistants. Pub
|
|
|
334
380
|
|
|
335
381
|
## License
|
|
336
382
|
|
|
337
|
-
See [`LICENSE.
|
|
383
|
+
See [`LICENSE.md`](./LICENSE.md). © Zahary Shinikchiev
|
package/index.d.ts
CHANGED
|
@@ -200,3 +200,23 @@ export function bakeGradientToUint32(
|
|
|
200
200
|
easeFn?: (t: number) => number,
|
|
201
201
|
packer?: OklchPackerFn
|
|
202
202
|
): Uint32Array;
|
|
203
|
+
|
|
204
|
+
// ============================================================================
|
|
205
|
+
// Difference (ΔE-OK)
|
|
206
|
+
// ============================================================================
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* ΔE-OK color difference: Euclidean distance in OKLab between two buffered
|
|
210
|
+
* OKLCH colors. Zero-allocation.
|
|
211
|
+
*
|
|
212
|
+
* Typical scale: `0.02` indistinguishable, `0.05` subtle, `0.15+` unambiguous.
|
|
213
|
+
*
|
|
214
|
+
* Use for palette dedupe, nearest-color lookup, contrast checks, and the
|
|
215
|
+
* CVD-audit workflow in `lite-hueforge/colorways`.
|
|
216
|
+
*/
|
|
217
|
+
export function deltaEOK(
|
|
218
|
+
bufA: Float32Array,
|
|
219
|
+
offsetA: number,
|
|
220
|
+
bufB: Float32Array,
|
|
221
|
+
offsetB: number
|
|
222
|
+
): number;
|
package/index.js
CHANGED
package/llms.txt
CHANGED
|
@@ -86,6 +86,76 @@ packOklchBufferToUint32Fast(buf: Float32Array, offset: number, alpha?: number):
|
|
|
86
86
|
sampleColorLUT(lut: Uint32Array, t: number): number;
|
|
87
87
|
```
|
|
88
88
|
|
|
89
|
+
### Difference (ΔE-OK)
|
|
90
|
+
|
|
91
|
+
```js
|
|
92
|
+
import { deltaEOK } from '@zakkster/lite-color-engine';
|
|
93
|
+
|
|
94
|
+
deltaEOK(bufA: Float32Array, offsetA: number,
|
|
95
|
+
bufB: Float32Array, offsetB: number): number;
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Euclidean distance in OKLab between two buffered OKLCH colors. Zero allocations. Typical scale: `0.02` indistinguishable, `0.05` subtle, `0.15+` unambiguously different.
|
|
99
|
+
|
|
100
|
+
### Gamut sub-export (`/gamut`)
|
|
101
|
+
|
|
102
|
+
```js
|
|
103
|
+
import {
|
|
104
|
+
gamutMapToSrgbBuffer,
|
|
105
|
+
packOklchBufferToUint32MINDE,
|
|
106
|
+
} from '@zakkster/lite-color-engine/gamut';
|
|
107
|
+
|
|
108
|
+
gamutMapToSrgbBuffer(
|
|
109
|
+
inBuf: Float32Array, inOffset: number,
|
|
110
|
+
outBuf: Float32Array, outOffset: number
|
|
111
|
+
): void;
|
|
112
|
+
|
|
113
|
+
packOklchBufferToUint32MINDE(
|
|
114
|
+
buf: Float32Array, offset: number, alpha?: number
|
|
115
|
+
): number;
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
CSS Color 4 MINDE chroma-reduction gamut mapping. Bisection on chroma at fixed L and H with a JND threshold. `packOklchBufferToUint32MINDE` is signature-compatible with the core packer, so you can pass it directly as the `packer` argument to `bakeGradientToUint32` to bake gamut-accurate LUTs.
|
|
119
|
+
|
|
120
|
+
~30× slower than the core packer. Belongs at LUT-build or authoring time, not per-frame.
|
|
121
|
+
|
|
122
|
+
### Remap sub-export (`/remap`)
|
|
123
|
+
|
|
124
|
+
```js
|
|
125
|
+
import {
|
|
126
|
+
sRgba8ToOklabBuffer,
|
|
127
|
+
oklchToOklabBuffer,
|
|
128
|
+
oklabToOklchBuffer,
|
|
129
|
+
nearestPaletteIndexBuffer,
|
|
130
|
+
remapPixelsToPalette,
|
|
131
|
+
} from '@zakkster/lite-color-engine/remap';
|
|
132
|
+
|
|
133
|
+
sRgba8ToOklabBuffer(
|
|
134
|
+
inU8: Uint8Array | Uint8ClampedArray,
|
|
135
|
+
outLab: Float32Array, pixelCount: number
|
|
136
|
+
): void;
|
|
137
|
+
|
|
138
|
+
oklchToOklabBuffer(inLch: Float32Array, outLab: Float32Array, pixelCount: number): void;
|
|
139
|
+
oklabToOklchBuffer(inLab: Float32Array, outLch: Float32Array, pixelCount: number): void;
|
|
140
|
+
|
|
141
|
+
nearestPaletteIndexBuffer(
|
|
142
|
+
pixelsLab: Float32Array, paletteLab: Float32Array,
|
|
143
|
+
indicesOut: Uint32Array | Uint16Array | Uint8Array,
|
|
144
|
+
pixelCount: number, paletteCount: number,
|
|
145
|
+
opts?: { preserveLightness?: boolean }
|
|
146
|
+
): void;
|
|
147
|
+
|
|
148
|
+
remapPixelsToPalette(
|
|
149
|
+
inU8: Uint8Array | Uint8ClampedArray,
|
|
150
|
+
paletteLch: Float32Array,
|
|
151
|
+
outU32: Uint32Array,
|
|
152
|
+
pixelCount: number, paletteCount: number,
|
|
153
|
+
opts?: { preserveLightness?: boolean }
|
|
154
|
+
): void;
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
SoA palette-remap kernels. Search happens in OKLab (Euclidean distance). Palette is authored in OKLCH and converted to OKLab once per call. `remapPixelsToPalette` uses grow-only module-level scratch for the palette conversion — no per-frame allocation. Alpha byte from the input passes through unchanged in the output. `preserveLightness` searches by `(a, b)` only and synthesizes output as `(pixel.L, palette.a, palette.b)` — retains shading structure under recolor.
|
|
158
|
+
|
|
89
159
|
### LUT (gradient baking)
|
|
90
160
|
|
|
91
161
|
```js
|
|
@@ -177,7 +247,7 @@ ctx.putImageData(imgData, 0, 0);
|
|
|
177
247
|
## What this library is NOT
|
|
178
248
|
|
|
179
249
|
- It is not a general-purpose color manipulation library. No `darken`, `lighten`, `mix`, `saturate`, `complement`, etc. methods. Compose your own from the buffer math primitives.
|
|
180
|
-
-
|
|
250
|
+
- The core `packOklchBufferToUint32` uses a hard channel clamp for its runtime fast path. Accurate CSS Color 4 MINDE chroma-reduction gamut mapping ships in the `/gamut` sub-export (see below) — use that at LUT-build or authoring time when the hue-shift artifacts of the hard clamp matter.
|
|
181
251
|
- It is not a CSS parser for everything. It does CSS Color Level 4 colors. It does not handle `color()`, `color-mix()`, relative color syntax, or system colors.
|
|
182
252
|
- It is not async. There are no Promises. Every function is synchronous and side-effect-free (writes only to its `outBuf` argument).
|
|
183
253
|
|
|
@@ -196,6 +266,10 @@ Absolute numbers depend on hardware. Relative ratios are stable.
|
|
|
196
266
|
|
|
197
267
|
## Versioning
|
|
198
268
|
|
|
199
|
-
`1.0.x` — the locked public API surface
|
|
269
|
+
`1.0.x` — the locked core public API surface. No breaking changes within the major. Patch releases for bug fixes; minor releases may **add** functions but not remove or rename.
|
|
270
|
+
|
|
271
|
+
`1.1.0` — adds `deltaEOK` in core, plus two additive sub-exports:
|
|
272
|
+
- `@zakkster/lite-color-engine/gamut` — CSS Color 4 MINDE gamut mapping and an accurate packer.
|
|
273
|
+
- `@zakkster/lite-color-engine/remap` — SoA batch converters and palette-remap kernels.
|
|
200
274
|
|
|
201
|
-
|
|
275
|
+
No breaking changes; the v1.0 surface is untouched. See `CHANGELOG.md` for full details.
|
package/package.json
CHANGED
|
@@ -1,18 +1,31 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zakkster/lite-color-engine",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"author": "Zahary Shinikchiev <shinikchiev@yahoo.com>",
|
|
5
|
-
"description": "Zero-GC, data-oriented OKLCH color engine and WebGL/Canvas
|
|
5
|
+
"description": "Zero-GC, data-oriented OKLCH color engine with MINDE gamut mapping and palette-remap kernels for WebGL/Canvas pipelines.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"sideEffects": false,
|
|
8
|
-
"main": "index.js",
|
|
9
|
-
"module": "index.js",
|
|
10
|
-
"types": "index.d.ts",
|
|
8
|
+
"main": "./index.js",
|
|
9
|
+
"module": "./index.js",
|
|
10
|
+
"types": "./index.d.ts",
|
|
11
11
|
"exports": {
|
|
12
12
|
".": {
|
|
13
|
-
"types": "index.d.ts",
|
|
14
|
-
"
|
|
15
|
-
"
|
|
13
|
+
"types": "./index.d.ts",
|
|
14
|
+
"node": "./index.js",
|
|
15
|
+
"import": "./index.js",
|
|
16
|
+
"default": "./index.js"
|
|
17
|
+
},
|
|
18
|
+
"./gamut": {
|
|
19
|
+
"types": "./src/Gamut.d.ts",
|
|
20
|
+
"node": "./src/Gamut.js",
|
|
21
|
+
"import": "./src/Gamut.js",
|
|
22
|
+
"default": "./src/Gamut.js"
|
|
23
|
+
},
|
|
24
|
+
"./remap": {
|
|
25
|
+
"types": "./src/Remap.d.ts",
|
|
26
|
+
"node": "./src/Remap.js",
|
|
27
|
+
"import": "./src/Remap.js",
|
|
28
|
+
"default": "./src/Remap.js"
|
|
16
29
|
}
|
|
17
30
|
},
|
|
18
31
|
"files": [
|
|
@@ -20,13 +33,18 @@
|
|
|
20
33
|
"index.js",
|
|
21
34
|
"index.d.ts",
|
|
22
35
|
"README.md",
|
|
36
|
+
"CHANGELOG.md",
|
|
23
37
|
"llms.txt",
|
|
24
|
-
"LICENSE.
|
|
38
|
+
"LICENSE.md"
|
|
25
39
|
],
|
|
26
40
|
"keywords": [
|
|
27
41
|
"color",
|
|
28
42
|
"oklch",
|
|
29
43
|
"oklab",
|
|
44
|
+
"gamut-mapping",
|
|
45
|
+
"minde",
|
|
46
|
+
"palette",
|
|
47
|
+
"remap",
|
|
30
48
|
"zero-gc",
|
|
31
49
|
"webgl",
|
|
32
50
|
"canvas",
|
|
@@ -34,6 +52,7 @@
|
|
|
34
52
|
"interpolation",
|
|
35
53
|
"gradient",
|
|
36
54
|
"color-space",
|
|
55
|
+
"delta-e",
|
|
37
56
|
"dod",
|
|
38
57
|
"data-oriented",
|
|
39
58
|
"high-performance",
|
|
@@ -58,7 +77,11 @@
|
|
|
58
77
|
"url": "https://github.com/PeshoVurtoleta/lite-color-engine/issues",
|
|
59
78
|
"email": "shinikchiev@yahoo.com"
|
|
60
79
|
},
|
|
80
|
+
"funding": {
|
|
81
|
+
"type": "github",
|
|
82
|
+
"url": "https://github.com/sponsors/PeshoVurtoleta"
|
|
83
|
+
},
|
|
61
84
|
"engines": {
|
|
62
85
|
"node": ">=18"
|
|
63
86
|
}
|
|
64
|
-
}
|
|
87
|
+
}
|
package/src/Gamut.d.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @zakkster/lite-color-engine/gamut
|
|
3
|
+
*
|
|
4
|
+
* CSS Color 4 MINDE chroma-reduction gamut mapping, plus an accurate packer.
|
|
5
|
+
* Sub-export so the bisection loop does not bloat the core bundle.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* CSS Color 4 MINDE chroma reduction. Reduces chroma at fixed L and H until
|
|
10
|
+
* the color is inside the sRGB gamut, using bisection with a JND threshold.
|
|
11
|
+
* Zero allocations on the hot path. Source and destination may alias.
|
|
12
|
+
*
|
|
13
|
+
* Writes `[L, C', H]` at `outBuf[outOffset..outOffset+2]`.
|
|
14
|
+
*/
|
|
15
|
+
export function gamutMapToSrgbBuffer(
|
|
16
|
+
inBuf: Float32Array,
|
|
17
|
+
inOffset: number,
|
|
18
|
+
outBuf: Float32Array,
|
|
19
|
+
outOffset: number
|
|
20
|
+
): void;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Accurate sibling of `packOklchBufferToUint32`. Runs MINDE gamut mapping
|
|
24
|
+
* before packing, eliminating the hue-shift artifacts of the hard channel
|
|
25
|
+
* clamp near the sRGB gamut boundary.
|
|
26
|
+
*
|
|
27
|
+
* ~30x slower than the core packer — use at LUT build time or authoring
|
|
28
|
+
* time, not per-frame. Returns an unsigned RGBA-LE Uint32.
|
|
29
|
+
*
|
|
30
|
+
* Signature-compatible with `bakeGradientToUint32`'s `packer` argument, so
|
|
31
|
+
* you can bake gamut-accurate LUTs by passing this as the packer.
|
|
32
|
+
*/
|
|
33
|
+
export function packOklchBufferToUint32MINDE(
|
|
34
|
+
buf: Float32Array,
|
|
35
|
+
offset: number,
|
|
36
|
+
alpha?: number
|
|
37
|
+
): number;
|
package/src/Gamut.js
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// -----------------------------------------------------------------------------
|
|
2
|
+
// @zakkster/lite-color-engine/gamut
|
|
3
|
+
//
|
|
4
|
+
// CSS Color 4 MINDE gamut mapping (chroma reduction in OKLab).
|
|
5
|
+
//
|
|
6
|
+
// Reference: https://www.w3.org/TR/css-color-4/#binsearch-gamut-mapping
|
|
7
|
+
// V1.1-PLAN P0/#1.
|
|
8
|
+
//
|
|
9
|
+
// Self-contained module: local internal converters, no cross-file imports.
|
|
10
|
+
// This costs ~40 lines of duplicated math but keeps the sub-export loadable
|
|
11
|
+
// in isolation and independent of any future refactor of src/converters.js.
|
|
12
|
+
// Fits the ecosystem's "duplicate small math, share only what earns it" rule.
|
|
13
|
+
//
|
|
14
|
+
// Trust model matches v1.0: buffers assumed well-shaped; L, C, H bounds
|
|
15
|
+
// enforced at write time (canonical hue in [0, 360), L clamped to [0, 1]).
|
|
16
|
+
// -----------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
const _RAD = Math.PI / 180;
|
|
19
|
+
const _DEG = 180 / Math.PI;
|
|
20
|
+
|
|
21
|
+
// Module-level scratch. Never allocated during a call.
|
|
22
|
+
const _rgb = new Float32Array(3);
|
|
23
|
+
const _clipLch = new Float32Array(3);
|
|
24
|
+
|
|
25
|
+
// CSS Color 4 constants.
|
|
26
|
+
const JND = 0.02;
|
|
27
|
+
const EPSILON = 0.0001;
|
|
28
|
+
const MAX_ITER = 8;
|
|
29
|
+
|
|
30
|
+
// OKLCH → linear sRGB, writes 3 floats to `out`.
|
|
31
|
+
function _oklchToLinearRgb(L, C, H, out) {
|
|
32
|
+
const hRad = H * _RAD;
|
|
33
|
+
const a = C * Math.cos(hRad);
|
|
34
|
+
const b = C * Math.sin(hRad);
|
|
35
|
+
const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
|
|
36
|
+
const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
|
|
37
|
+
const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
|
|
38
|
+
const l = l_ * l_ * l_;
|
|
39
|
+
const m = m_ * m_ * m_;
|
|
40
|
+
const s = s_ * s_ * s_;
|
|
41
|
+
out[0] = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s;
|
|
42
|
+
out[1] = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s;
|
|
43
|
+
out[2] = -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Linear sRGB → OKLCH, writes 3 floats [L, C, H] with H in [0, 360).
|
|
47
|
+
function _linearRgbToOklch(r, g, b, out, off) {
|
|
48
|
+
const l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b;
|
|
49
|
+
const m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b;
|
|
50
|
+
const s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b;
|
|
51
|
+
const l_ = Math.cbrt(l);
|
|
52
|
+
const m_ = Math.cbrt(m);
|
|
53
|
+
const s_ = Math.cbrt(s);
|
|
54
|
+
const L = 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_;
|
|
55
|
+
const A = 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_;
|
|
56
|
+
const B = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_;
|
|
57
|
+
const C = Math.sqrt(A * A + B * B);
|
|
58
|
+
let H = Math.atan2(B, A) * _DEG;
|
|
59
|
+
if (H < 0) H += 360;
|
|
60
|
+
out[off] = L;
|
|
61
|
+
out[off + 1] = C;
|
|
62
|
+
out[off + 2] = H;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// sRGB gamma encode a linear channel to gamma-encoded [0, 1].
|
|
66
|
+
function _srgbGammaEncode(v) {
|
|
67
|
+
if (v <= 0) return 0;
|
|
68
|
+
if (v >= 1) return 1;
|
|
69
|
+
return v <= 0.0031308 ? 12.92 * v : 1.055 * Math.pow(v, 1 / 2.4) - 0.055;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Pre-declared bindings for tight ΔE-OK loops.
|
|
73
|
+
function _deltaOKFromLchLinearRgb(L, C, H, cR, cG, cB) {
|
|
74
|
+
// Original OKLab (a, b) from OKLCH
|
|
75
|
+
const hRad = H * _RAD;
|
|
76
|
+
const oA = C * Math.cos(hRad);
|
|
77
|
+
const oB = C * Math.sin(hRad);
|
|
78
|
+
// Clipped in-gamut linear RGB → OKLab
|
|
79
|
+
const l = 0.4122214708 * cR + 0.5363325363 * cG + 0.0514459929 * cB;
|
|
80
|
+
const m = 0.2119034982 * cR + 0.6806995451 * cG + 0.1073969566 * cB;
|
|
81
|
+
const s = 0.0883024619 * cR + 0.2817188376 * cG + 0.6299787005 * cB;
|
|
82
|
+
const l_ = Math.cbrt(l);
|
|
83
|
+
const m_ = Math.cbrt(m);
|
|
84
|
+
const s_ = Math.cbrt(s);
|
|
85
|
+
const cL = 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_;
|
|
86
|
+
const cA = 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_;
|
|
87
|
+
const cBB = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_;
|
|
88
|
+
const dL = L - cL;
|
|
89
|
+
const dA = oA - cA;
|
|
90
|
+
const dB = oB - cBB;
|
|
91
|
+
return Math.sqrt(dL * dL + dA * dA + dB * dB);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
// -----------------------------------------------------------------------------
|
|
96
|
+
// gamutMapToSrgbBuffer
|
|
97
|
+
// -----------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* CSS Color 4 chroma-reduction gamut mapping. Reduces chroma at fixed L and H
|
|
101
|
+
* until the color is inside the sRGB gamut, using bisection with a JND
|
|
102
|
+
* threshold. Zero allocations on the hot path.
|
|
103
|
+
*
|
|
104
|
+
* Writes gamut-mapped [L, C', H] at `outBuf[outOffset..outOffset+2]`.
|
|
105
|
+
* Source and destination may alias.
|
|
106
|
+
*
|
|
107
|
+
* @param {Float32Array} inBuf
|
|
108
|
+
* @param {number} inOffset
|
|
109
|
+
* @param {Float32Array} outBuf
|
|
110
|
+
* @param {number} outOffset
|
|
111
|
+
*/
|
|
112
|
+
export function gamutMapToSrgbBuffer(inBuf, inOffset, outBuf, outOffset) {
|
|
113
|
+
const L = inBuf[inOffset];
|
|
114
|
+
const C = inBuf[inOffset + 1];
|
|
115
|
+
const H = inBuf[inOffset + 2];
|
|
116
|
+
|
|
117
|
+
// Endpoints: L outside [0, 1] collapses to black or white in destination.
|
|
118
|
+
if (L >= 1) {
|
|
119
|
+
outBuf[outOffset] = 1;
|
|
120
|
+
outBuf[outOffset + 1] = 0;
|
|
121
|
+
outBuf[outOffset + 2] = H;
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (L <= 0) {
|
|
125
|
+
outBuf[outOffset] = 0;
|
|
126
|
+
outBuf[outOffset + 1] = 0;
|
|
127
|
+
outBuf[outOffset + 2] = H;
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (C <= 0) {
|
|
131
|
+
outBuf[outOffset] = L;
|
|
132
|
+
outBuf[outOffset + 1] = 0;
|
|
133
|
+
outBuf[outOffset + 2] = H;
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// In-gamut? Emit unchanged.
|
|
138
|
+
_oklchToLinearRgb(L, C, H, _rgb);
|
|
139
|
+
if (
|
|
140
|
+
_rgb[0] >= -EPSILON && _rgb[0] <= 1 + EPSILON &&
|
|
141
|
+
_rgb[1] >= -EPSILON && _rgb[1] <= 1 + EPSILON &&
|
|
142
|
+
_rgb[2] >= -EPSILON && _rgb[2] <= 1 + EPSILON
|
|
143
|
+
) {
|
|
144
|
+
outBuf[outOffset] = L;
|
|
145
|
+
outBuf[outOffset + 1] = C;
|
|
146
|
+
outBuf[outOffset + 2] = H;
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Full-chroma clip. If ΔE < JND, we're done — return the OKLCH of the clip.
|
|
151
|
+
let cR = _rgb[0] < 0 ? 0 : _rgb[0] > 1 ? 1 : _rgb[0];
|
|
152
|
+
let cG = _rgb[1] < 0 ? 0 : _rgb[1] > 1 ? 1 : _rgb[1];
|
|
153
|
+
let cB = _rgb[2] < 0 ? 0 : _rgb[2] > 1 ? 1 : _rgb[2];
|
|
154
|
+
if (_deltaOKFromLchLinearRgb(L, C, H, cR, cG, cB) < JND) {
|
|
155
|
+
_linearRgbToOklch(cR, cG, cB, outBuf, outOffset);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Bisection on chroma at fixed L, H.
|
|
160
|
+
let low = 0;
|
|
161
|
+
let high = C;
|
|
162
|
+
for (let iter = 0; iter < MAX_ITER; iter++) {
|
|
163
|
+
if (high - low < EPSILON) break;
|
|
164
|
+
const mid = (low + high) * 0.5;
|
|
165
|
+
_oklchToLinearRgb(L, mid, H, _rgb);
|
|
166
|
+
const inGamut = (
|
|
167
|
+
_rgb[0] >= -EPSILON && _rgb[0] <= 1 + EPSILON &&
|
|
168
|
+
_rgb[1] >= -EPSILON && _rgb[1] <= 1 + EPSILON &&
|
|
169
|
+
_rgb[2] >= -EPSILON && _rgb[2] <= 1 + EPSILON
|
|
170
|
+
);
|
|
171
|
+
if (inGamut) {
|
|
172
|
+
low = mid;
|
|
173
|
+
} else {
|
|
174
|
+
cR = _rgb[0] < 0 ? 0 : _rgb[0] > 1 ? 1 : _rgb[0];
|
|
175
|
+
cG = _rgb[1] < 0 ? 0 : _rgb[1] > 1 ? 1 : _rgb[1];
|
|
176
|
+
cB = _rgb[2] < 0 ? 0 : _rgb[2] > 1 ? 1 : _rgb[2];
|
|
177
|
+
if (_deltaOKFromLchLinearRgb(L, mid, H, cR, cG, cB) < JND) {
|
|
178
|
+
_linearRgbToOklch(cR, cG, cB, outBuf, outOffset);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
high = mid;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Converged: `low` is the largest in-gamut chroma at this (L, H).
|
|
186
|
+
outBuf[outOffset] = L;
|
|
187
|
+
outBuf[outOffset + 1] = low;
|
|
188
|
+
outBuf[outOffset + 2] = H;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
// -----------------------------------------------------------------------------
|
|
193
|
+
// packOklchBufferToUint32MINDE
|
|
194
|
+
// -----------------------------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Drop-in accurate sibling of `packOklchBufferToUint32`. Runs MINDE gamut
|
|
198
|
+
* mapping before packing, eliminating the visible hue shifts that the
|
|
199
|
+
* hard channel-clamp produces near the sRGB gamut boundary.
|
|
200
|
+
*
|
|
201
|
+
* ~30x slower than the core packer — belongs at LUT build time or authoring
|
|
202
|
+
* time, not in per-frame interpolation. Zero allocations.
|
|
203
|
+
*
|
|
204
|
+
* @param {Float32Array} buf
|
|
205
|
+
* @param {number} offset
|
|
206
|
+
* @param {number} [alpha=1] alpha in [0, 1]; packed into the high byte
|
|
207
|
+
* @returns {number} RGBA-LE Uint32
|
|
208
|
+
*/
|
|
209
|
+
export function packOklchBufferToUint32MINDE(buf, offset, alpha) {
|
|
210
|
+
gamutMapToSrgbBuffer(buf, offset, _clipLch, 0);
|
|
211
|
+
const L = _clipLch[0];
|
|
212
|
+
const C = _clipLch[1];
|
|
213
|
+
const H = _clipLch[2];
|
|
214
|
+
_oklchToLinearRgb(L, C, H, _rgb);
|
|
215
|
+
// Guard-clamp: post-MINDE the values should be in [0, 1] modulo epsilon,
|
|
216
|
+
// but the raw matrix output can still be a hair outside due to rounding.
|
|
217
|
+
let r = _srgbGammaEncode(_rgb[0]);
|
|
218
|
+
let g = _srgbGammaEncode(_rgb[1]);
|
|
219
|
+
let b = _srgbGammaEncode(_rgb[2]);
|
|
220
|
+
const R = (r * 255 + 0.5) | 0;
|
|
221
|
+
const G = (g * 255 + 0.5) | 0;
|
|
222
|
+
const B = (b * 255 + 0.5) | 0;
|
|
223
|
+
const a = alpha == null ? 1 : (alpha < 0 ? 0 : alpha > 1 ? 1 : alpha);
|
|
224
|
+
const A = (a * 255 + 0.5) | 0;
|
|
225
|
+
// RGBA-LE: byte order R, G, B, A. `>>> 0` normalizes to unsigned uint32
|
|
226
|
+
// so the value is comparable and matches v1.0 packer conventions.
|
|
227
|
+
return (((A & 0xFF) << 24) | ((B & 0xFF) << 16) | ((G & 0xFF) << 8) | (R & 0xFF)) >>> 0;
|
|
228
|
+
}
|
package/src/Remap.d.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @zakkster/lite-color-engine/remap
|
|
3
|
+
*
|
|
4
|
+
* SoA remap kernels: nearest-palette-index search in OKLab, plus a one-shot
|
|
5
|
+
* pixel-remap function for image recoloring. The engine layer that
|
|
6
|
+
* `lite-hueforge v1.3 remapImageToPalette` sits on top of.
|
|
7
|
+
*
|
|
8
|
+
* Search happens in OKLab (Euclidean distance). Palette is authored in OKLCH
|
|
9
|
+
* and converted to OKLab once at call entry.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Batch RGBA8 → OKLab. Reads 4 bytes per pixel, writes 3 floats per pixel.
|
|
14
|
+
* Alpha is discarded; carry it separately if needed.
|
|
15
|
+
*/
|
|
16
|
+
export function sRgba8ToOklabBuffer(
|
|
17
|
+
inU8: Uint8Array | Uint8ClampedArray,
|
|
18
|
+
outLab: Float32Array,
|
|
19
|
+
pixelCount: number
|
|
20
|
+
): void;
|
|
21
|
+
|
|
22
|
+
/** Batch OKLCH → OKLab. Both buffers stride 3. */
|
|
23
|
+
export function oklchToOklabBuffer(
|
|
24
|
+
inLch: Float32Array,
|
|
25
|
+
outLab: Float32Array,
|
|
26
|
+
pixelCount: number
|
|
27
|
+
): void;
|
|
28
|
+
|
|
29
|
+
/** Batch OKLab → OKLCH. Hue canonicalized to `[0, 360)`. Both buffers stride 3. */
|
|
30
|
+
export function oklabToOklchBuffer(
|
|
31
|
+
inLab: Float32Array,
|
|
32
|
+
outLch: Float32Array,
|
|
33
|
+
pixelCount: number
|
|
34
|
+
): void;
|
|
35
|
+
|
|
36
|
+
export interface NearestPaletteOptions {
|
|
37
|
+
/**
|
|
38
|
+
* If true, distance uses (a, b) only — matches by chroma coords and
|
|
39
|
+
* ignores lightness. Enables the shading-preserve remap trick.
|
|
40
|
+
* Default: false.
|
|
41
|
+
*/
|
|
42
|
+
preserveLightness?: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* For each pixel in `pixelsLab`, write the index of the nearest palette
|
|
47
|
+
* color in `paletteLab` (squared Euclidean distance in OKLab) into
|
|
48
|
+
* `indicesOut`. Tie-break: lowest index wins. Deterministic.
|
|
49
|
+
*/
|
|
50
|
+
export function nearestPaletteIndexBuffer(
|
|
51
|
+
pixelsLab: Float32Array,
|
|
52
|
+
paletteLab: Float32Array,
|
|
53
|
+
indicesOut: Uint32Array | Uint16Array | Uint8Array,
|
|
54
|
+
pixelCount: number,
|
|
55
|
+
paletteCount: number,
|
|
56
|
+
opts?: NearestPaletteOptions
|
|
57
|
+
): void;
|
|
58
|
+
|
|
59
|
+
export interface RemapPixelsOptions {
|
|
60
|
+
/**
|
|
61
|
+
* If true, search by (a, b) only and synthesize output per pixel using
|
|
62
|
+
* the pixel's original L and the palette color's (a, b). Preserves
|
|
63
|
+
* shading structure under recolor — the AI-motif recolor look.
|
|
64
|
+
* ~40% slower than the fast path. Default: false.
|
|
65
|
+
*/
|
|
66
|
+
preserveLightness?: boolean;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* One-shot end-to-end: read RGBA8 pixels, find nearest palette color per
|
|
71
|
+
* pixel by ΔE-OK in OKLab, write RGBA-LE Uint32 result. Handles palette
|
|
72
|
+
* OKLCH→OKLab conversion internally. Alpha byte passes through unchanged.
|
|
73
|
+
*
|
|
74
|
+
* Fast path (`preserveLightness=false`): pre-packs palette to Uint32 once,
|
|
75
|
+
* per-pixel is convert-search-gather.
|
|
76
|
+
*
|
|
77
|
+
* Preserve path (`preserveLightness=true`): synthesizes each output pixel
|
|
78
|
+
* from `(pixel.L, palette.a, palette.b)` — shading is retained.
|
|
79
|
+
*/
|
|
80
|
+
export function remapPixelsToPalette(
|
|
81
|
+
inU8: Uint8Array | Uint8ClampedArray,
|
|
82
|
+
paletteLch: Float32Array,
|
|
83
|
+
outU32: Uint32Array,
|
|
84
|
+
pixelCount: number,
|
|
85
|
+
paletteCount: number,
|
|
86
|
+
opts?: RemapPixelsOptions
|
|
87
|
+
): void;
|
package/src/Remap.js
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
// -----------------------------------------------------------------------------
|
|
2
|
+
// @zakkster/lite-color-engine/remap
|
|
3
|
+
//
|
|
4
|
+
// SoA remap kernels: nearest-palette-index search in OKLab, and a one-shot
|
|
5
|
+
// pixel-remap function for image recoloring. The engine layer that
|
|
6
|
+
// `lite-hueforge v1.3 remapImageToPalette` sits on top of.
|
|
7
|
+
//
|
|
8
|
+
// Design opinions:
|
|
9
|
+
// 1. Search happens in OKLab, not OKLCH. Distance in OKLab is Euclidean;
|
|
10
|
+
// distance in OKLCH needs a cos/sin per comparison. Palette is authored
|
|
11
|
+
// in OKLCH (what humans think in), converted to OKLab once at call
|
|
12
|
+
// entry, searched forever.
|
|
13
|
+
// 2. Palette is limited to whatever fits typical use (hundreds of colors).
|
|
14
|
+
// Scratch buffers grow monotonically inside the module — never shrink.
|
|
15
|
+
// Zero allocations on the hot per-pixel loop.
|
|
16
|
+
// 3. `preserveLightness` is the shading-preservation trick: search by (a, b)
|
|
17
|
+
// only, keep the original pixel's L, and synthesize the output color
|
|
18
|
+
// from (pixel.L, palette.a, palette.b). This is what makes recolored
|
|
19
|
+
// AI motifs still look *shaded*, not posterized.
|
|
20
|
+
// 4. Alpha byte from the input passes through unchanged in the output.
|
|
21
|
+
//
|
|
22
|
+
// Trust model: hot path trusts input shapes. Buffer sizes assumed correct.
|
|
23
|
+
// -----------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
const _RAD = Math.PI / 180;
|
|
26
|
+
const _DEG = 180 / Math.PI;
|
|
27
|
+
|
|
28
|
+
// Grow-only module-level scratch for remapPixelsToPalette.
|
|
29
|
+
let _paletteLab = new Float32Array(0);
|
|
30
|
+
let _paletteU32 = new Uint32Array(0);
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
// -----------------------------------------------------------------------------
|
|
34
|
+
// Batch converters
|
|
35
|
+
// -----------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
function _linearizeSrgbByte(b) {
|
|
38
|
+
const v = b / 255;
|
|
39
|
+
return v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function _srgbGammaEncode(v) {
|
|
43
|
+
if (v <= 0) return 0;
|
|
44
|
+
if (v >= 1) return 1;
|
|
45
|
+
return v <= 0.0031308 ? 12.92 * v : 1.055 * Math.pow(v, 1 / 2.4) - 0.055;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Batch RGBA8 → OKLab conversion. Reads 4 bytes per pixel from `inU8`
|
|
50
|
+
* (Uint8ClampedArray or Uint8Array), writes 3 floats per pixel to `outLab`.
|
|
51
|
+
* Alpha is discarded; carry it separately if needed.
|
|
52
|
+
*/
|
|
53
|
+
export function sRgba8ToOklabBuffer(inU8, outLab, pixelCount) {
|
|
54
|
+
for (let i = 0; i < pixelCount; i++) {
|
|
55
|
+
const inOff = i * 4;
|
|
56
|
+
const outOff = i * 3;
|
|
57
|
+
const r = _linearizeSrgbByte(inU8[inOff]);
|
|
58
|
+
const g = _linearizeSrgbByte(inU8[inOff + 1]);
|
|
59
|
+
const b = _linearizeSrgbByte(inU8[inOff + 2]);
|
|
60
|
+
const l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b;
|
|
61
|
+
const m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b;
|
|
62
|
+
const s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b;
|
|
63
|
+
const l_ = Math.cbrt(l);
|
|
64
|
+
const m_ = Math.cbrt(m);
|
|
65
|
+
const s_ = Math.cbrt(s);
|
|
66
|
+
outLab[outOff] = 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_;
|
|
67
|
+
outLab[outOff + 1] = 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_;
|
|
68
|
+
outLab[outOff + 2] = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Batch OKLCH → OKLab conversion. Both buffers stride 3.
|
|
74
|
+
*/
|
|
75
|
+
export function oklchToOklabBuffer(inLch, outLab, pixelCount) {
|
|
76
|
+
for (let i = 0; i < pixelCount; i++) {
|
|
77
|
+
const off = i * 3;
|
|
78
|
+
const L = inLch[off];
|
|
79
|
+
const C = inLch[off + 1];
|
|
80
|
+
const H = inLch[off + 2];
|
|
81
|
+
const hRad = H * _RAD;
|
|
82
|
+
outLab[off] = L;
|
|
83
|
+
outLab[off + 1] = C * Math.cos(hRad);
|
|
84
|
+
outLab[off + 2] = C * Math.sin(hRad);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Batch OKLab → OKLCH conversion. Both buffers stride 3.
|
|
90
|
+
* Hue canonicalized to [0, 360).
|
|
91
|
+
*/
|
|
92
|
+
export function oklabToOklchBuffer(inLab, outLch, pixelCount) {
|
|
93
|
+
for (let i = 0; i < pixelCount; i++) {
|
|
94
|
+
const off = i * 3;
|
|
95
|
+
const L = inLab[off];
|
|
96
|
+
const a = inLab[off + 1];
|
|
97
|
+
const b = inLab[off + 2];
|
|
98
|
+
const C = Math.sqrt(a * a + b * b);
|
|
99
|
+
let H = Math.atan2(b, a) * _DEG;
|
|
100
|
+
if (H < 0) H += 360;
|
|
101
|
+
outLch[off] = L;
|
|
102
|
+
outLch[off + 1] = C;
|
|
103
|
+
outLch[off + 2] = H;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
// -----------------------------------------------------------------------------
|
|
109
|
+
// nearestPaletteIndexBuffer
|
|
110
|
+
// -----------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* For each pixel in `pixelsLab`, write the index of the nearest palette
|
|
114
|
+
* color in `paletteLab` (by squared Euclidean distance in OKLab) into
|
|
115
|
+
* `indicesOut`.
|
|
116
|
+
*
|
|
117
|
+
* Tie-break: lowest index wins. Deterministic across runs.
|
|
118
|
+
*
|
|
119
|
+
* @param {Float32Array} pixelsLab stride 3 [L, a, b]
|
|
120
|
+
* @param {Float32Array} paletteLab stride 3 [L, a, b]
|
|
121
|
+
* @param {Uint32Array|Uint16Array|Uint8Array} indicesOut length ≥ pixelCount
|
|
122
|
+
* @param {number} pixelCount
|
|
123
|
+
* @param {number} paletteCount
|
|
124
|
+
* @param {object} [opts]
|
|
125
|
+
* @param {boolean} [opts.preserveLightness=false] if true, distance uses (a, b) only
|
|
126
|
+
*/
|
|
127
|
+
export function nearestPaletteIndexBuffer(pixelsLab, paletteLab, indicesOut, pixelCount, paletteCount, opts) {
|
|
128
|
+
const preserveLightness = opts != null && opts.preserveLightness === true;
|
|
129
|
+
for (let i = 0; i < pixelCount; i++) {
|
|
130
|
+
const pOff = i * 3;
|
|
131
|
+
const pL = pixelsLab[pOff];
|
|
132
|
+
const pA = pixelsLab[pOff + 1];
|
|
133
|
+
const pB = pixelsLab[pOff + 2];
|
|
134
|
+
let bestIdx = 0;
|
|
135
|
+
let bestD = Infinity;
|
|
136
|
+
for (let k = 0; k < paletteCount; k++) {
|
|
137
|
+
const kOff = k * 3;
|
|
138
|
+
const dA = pA - paletteLab[kOff + 1];
|
|
139
|
+
const dB = pB - paletteLab[kOff + 2];
|
|
140
|
+
let d = dA * dA + dB * dB;
|
|
141
|
+
if (!preserveLightness) {
|
|
142
|
+
const dL = pL - paletteLab[kOff];
|
|
143
|
+
d += dL * dL;
|
|
144
|
+
}
|
|
145
|
+
if (d < bestD) {
|
|
146
|
+
bestD = d;
|
|
147
|
+
bestIdx = k;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
indicesOut[i] = bestIdx;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
// -----------------------------------------------------------------------------
|
|
156
|
+
// remapPixelsToPalette — one-shot end-to-end
|
|
157
|
+
// -----------------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* One-shot: read RGBA8 pixels, find nearest palette color per pixel, write
|
|
161
|
+
* RGBA-LE Uint32 result. Handles palette OKLCH→OKLab conversion internally.
|
|
162
|
+
* Alpha byte is passed through unchanged from input.
|
|
163
|
+
*
|
|
164
|
+
* Two code paths:
|
|
165
|
+
* - preserveLightness=false: pre-packs palette to U32 once, per-pixel is
|
|
166
|
+
* convert-search-gather. Fast enough for live drag at moderate image
|
|
167
|
+
* sizes (~500x500 comfortably at 60fps on modern V8).
|
|
168
|
+
* - preserveLightness=true: per-pixel synthesizes (pixel.L, palette.a,
|
|
169
|
+
* palette.b), converts back through OKLab → linear sRGB → gamma → pack.
|
|
170
|
+
* ~3x slower but produces shading-preserved output — the AI motif
|
|
171
|
+
* recolor look.
|
|
172
|
+
*
|
|
173
|
+
* @param {Uint8Array|Uint8ClampedArray} inU8 RGBA8 pixel buffer
|
|
174
|
+
* @param {Float32Array} paletteLch palette OKLCH, stride 3
|
|
175
|
+
* @param {Uint32Array} outU32 output Uint32 pixels, length ≥ pixelCount
|
|
176
|
+
* @param {number} pixelCount
|
|
177
|
+
* @param {number} paletteCount
|
|
178
|
+
* @param {object} [opts]
|
|
179
|
+
* @param {boolean} [opts.preserveLightness=false]
|
|
180
|
+
*/
|
|
181
|
+
export function remapPixelsToPalette(inU8, paletteLch, outU32, pixelCount, paletteCount, opts) {
|
|
182
|
+
const preserveLightness = opts != null && opts.preserveLightness === true;
|
|
183
|
+
|
|
184
|
+
// Grow scratch if needed. Monotonic — never shrinks.
|
|
185
|
+
const needLab = paletteCount * 3;
|
|
186
|
+
if (_paletteLab.length < needLab) {
|
|
187
|
+
_paletteLab = new Float32Array(needLab);
|
|
188
|
+
}
|
|
189
|
+
if (_paletteU32.length < paletteCount) {
|
|
190
|
+
_paletteU32 = new Uint32Array(paletteCount);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Palette OKLCH → OKLab + pre-pack to opaque U32 (alpha comes from input pixels).
|
|
194
|
+
for (let k = 0; k < paletteCount; k++) {
|
|
195
|
+
const kOff = k * 3;
|
|
196
|
+
const L = paletteLch[kOff];
|
|
197
|
+
const C = paletteLch[kOff + 1];
|
|
198
|
+
const H = paletteLch[kOff + 2];
|
|
199
|
+
const hRad = H * _RAD;
|
|
200
|
+
const a = C * Math.cos(hRad);
|
|
201
|
+
const b = C * Math.sin(hRad);
|
|
202
|
+
_paletteLab[kOff] = L;
|
|
203
|
+
_paletteLab[kOff + 1] = a;
|
|
204
|
+
_paletteLab[kOff + 2] = b;
|
|
205
|
+
|
|
206
|
+
// Pack once (opaque; alpha comes from input pixels).
|
|
207
|
+
const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
|
|
208
|
+
const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
|
|
209
|
+
const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
|
|
210
|
+
const l3 = l_ * l_ * l_;
|
|
211
|
+
const m3 = m_ * m_ * m_;
|
|
212
|
+
const s3 = s_ * s_ * s_;
|
|
213
|
+
const rL = 4.0767416621 * l3 - 3.3077115913 * m3 + 0.2309699292 * s3;
|
|
214
|
+
const gL = -1.2684380046 * l3 + 2.6097574011 * m3 - 0.3413193965 * s3;
|
|
215
|
+
const bL = -0.0041960863 * l3 - 0.7034186147 * m3 + 1.7076147010 * s3;
|
|
216
|
+
const gR = _srgbGammaEncode(rL);
|
|
217
|
+
const gG = _srgbGammaEncode(gL);
|
|
218
|
+
const gB = _srgbGammaEncode(bL);
|
|
219
|
+
const R = (gR * 255 + 0.5) | 0;
|
|
220
|
+
const G = (gG * 255 + 0.5) | 0;
|
|
221
|
+
const B = (gB * 255 + 0.5) | 0;
|
|
222
|
+
_paletteU32[k] = (B << 16) | (G << 8) | R;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (!preserveLightness) {
|
|
226
|
+
// Fast path: pre-packed gather.
|
|
227
|
+
for (let i = 0; i < pixelCount; i++) {
|
|
228
|
+
const inOff = i * 4;
|
|
229
|
+
const r = _linearizeSrgbByte(inU8[inOff]);
|
|
230
|
+
const g = _linearizeSrgbByte(inU8[inOff + 1]);
|
|
231
|
+
const b = _linearizeSrgbByte(inU8[inOff + 2]);
|
|
232
|
+
const l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b;
|
|
233
|
+
const m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b;
|
|
234
|
+
const s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b;
|
|
235
|
+
const l_ = Math.cbrt(l);
|
|
236
|
+
const m_ = Math.cbrt(m);
|
|
237
|
+
const s_ = Math.cbrt(s);
|
|
238
|
+
const pL = 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_;
|
|
239
|
+
const pA = 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_;
|
|
240
|
+
const pB = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_;
|
|
241
|
+
|
|
242
|
+
let bestIdx = 0;
|
|
243
|
+
let bestD = Infinity;
|
|
244
|
+
for (let k = 0; k < paletteCount; k++) {
|
|
245
|
+
const kOff = k * 3;
|
|
246
|
+
const dL = pL - _paletteLab[kOff];
|
|
247
|
+
const dA = pA - _paletteLab[kOff + 1];
|
|
248
|
+
const dB = pB - _paletteLab[kOff + 2];
|
|
249
|
+
const d = dL * dL + dA * dA + dB * dB;
|
|
250
|
+
if (d < bestD) { bestD = d; bestIdx = k; }
|
|
251
|
+
}
|
|
252
|
+
const A = inU8[inOff + 3];
|
|
253
|
+
outU32[i] = (A << 24) | _paletteU32[bestIdx];
|
|
254
|
+
}
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Preserve-L path: search by (a, b), synthesize per pixel.
|
|
259
|
+
for (let i = 0; i < pixelCount; i++) {
|
|
260
|
+
const inOff = i * 4;
|
|
261
|
+
const r = _linearizeSrgbByte(inU8[inOff]);
|
|
262
|
+
const g = _linearizeSrgbByte(inU8[inOff + 1]);
|
|
263
|
+
const b = _linearizeSrgbByte(inU8[inOff + 2]);
|
|
264
|
+
const l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b;
|
|
265
|
+
const m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b;
|
|
266
|
+
const s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b;
|
|
267
|
+
const l_ = Math.cbrt(l);
|
|
268
|
+
const m_ = Math.cbrt(m);
|
|
269
|
+
const s_ = Math.cbrt(s);
|
|
270
|
+
const pL = 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_;
|
|
271
|
+
const pA = 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_;
|
|
272
|
+
const pB = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_;
|
|
273
|
+
|
|
274
|
+
let bestIdx = 0;
|
|
275
|
+
let bestD = Infinity;
|
|
276
|
+
for (let k = 0; k < paletteCount; k++) {
|
|
277
|
+
const kOff = k * 3;
|
|
278
|
+
const dA = pA - _paletteLab[kOff + 1];
|
|
279
|
+
const dB = pB - _paletteLab[kOff + 2];
|
|
280
|
+
const d = dA * dA + dB * dB;
|
|
281
|
+
if (d < bestD) { bestD = d; bestIdx = k; }
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Synthesize (pixel L, palette a, palette b) → linear sRGB → gamma → pack.
|
|
285
|
+
const kOff = bestIdx * 3;
|
|
286
|
+
const nA = _paletteLab[kOff + 1];
|
|
287
|
+
const nB = _paletteLab[kOff + 2];
|
|
288
|
+
const nl_ = pL + 0.3963377774 * nA + 0.2158037573 * nB;
|
|
289
|
+
const nm_ = pL - 0.1055613458 * nA - 0.0638541728 * nB;
|
|
290
|
+
const ns_ = pL - 0.0894841775 * nA - 1.2914855480 * nB;
|
|
291
|
+
const nl = nl_ * nl_ * nl_;
|
|
292
|
+
const nm = nm_ * nm_ * nm_;
|
|
293
|
+
const ns = ns_ * ns_ * ns_;
|
|
294
|
+
const nr = 4.0767416621 * nl - 3.3077115913 * nm + 0.2309699292 * ns;
|
|
295
|
+
const ng = -1.2684380046 * nl + 2.6097574011 * nm - 0.3413193965 * ns;
|
|
296
|
+
const nb = -0.0041960863 * nl - 0.7034186147 * nm + 1.7076147010 * ns;
|
|
297
|
+
const gR = _srgbGammaEncode(nr);
|
|
298
|
+
const gG = _srgbGammaEncode(ng);
|
|
299
|
+
const gB = _srgbGammaEncode(nb);
|
|
300
|
+
const R = (gR * 255 + 0.5) | 0;
|
|
301
|
+
const G = (gG * 255 + 0.5) | 0;
|
|
302
|
+
const B = (gB * 255 + 0.5) | 0;
|
|
303
|
+
const A = inU8[inOff + 3];
|
|
304
|
+
outU32[i] = (A << 24) | (B << 16) | (G << 8) | R;
|
|
305
|
+
}
|
|
306
|
+
}
|
package/src/delta.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
const _RAD = Math.PI / 180;
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ΔE-OK color difference: Euclidean distance in OKLab between two buffered
|
|
5
|
+
* OKLCH colors. Zero-alloc, primitive math only.
|
|
6
|
+
*
|
|
7
|
+
* Typical scale: 0.00–0.02 indistinguishable, 0.02–0.05 subtle,
|
|
8
|
+
* 0.05–0.15 clearly different, 0.15+ unambiguously different.
|
|
9
|
+
*
|
|
10
|
+
* Use for: palette dedupe, contrast checks, "is this close enough"
|
|
11
|
+
* assertions, nearest-color lookup, and the CVD-audit workflow in
|
|
12
|
+
* lite-hueforge/colorways.
|
|
13
|
+
*
|
|
14
|
+
* @param {Float32Array} bufA
|
|
15
|
+
* @param {number} offsetA
|
|
16
|
+
* @param {Float32Array} bufB
|
|
17
|
+
* @param {number} offsetB
|
|
18
|
+
* @returns {number}
|
|
19
|
+
*/
|
|
20
|
+
export const deltaEOK = (bufA, offsetA, bufB, offsetB) => {
|
|
21
|
+
const La = bufA[offsetA];
|
|
22
|
+
const Ca = bufA[offsetA + 1];
|
|
23
|
+
const Ha = bufA[offsetA + 2];
|
|
24
|
+
const Lb = bufB[offsetB];
|
|
25
|
+
const Cb = bufB[offsetB + 1];
|
|
26
|
+
const Hb = bufB[offsetB + 2];
|
|
27
|
+
const hARad = Ha * _RAD;
|
|
28
|
+
const hBRad = Hb * _RAD;
|
|
29
|
+
const aA = Ca * Math.cos(hARad);
|
|
30
|
+
const bA = Ca * Math.sin(hARad);
|
|
31
|
+
const aB = Cb * Math.cos(hBRad);
|
|
32
|
+
const bB = Cb * Math.sin(hBRad);
|
|
33
|
+
const dL = La - Lb;
|
|
34
|
+
const dA = aA - aB;
|
|
35
|
+
const dB = bA - bB;
|
|
36
|
+
return Math.sqrt(dL * dL + dA * dA + dB * dB);
|
|
37
|
+
};
|