@xprite/pixelate 0.0.0-stage → 0.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.
Files changed (110) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE.md +12 -0
  3. package/README.md +238 -2
  4. package/README.zh-CN.md +209 -0
  5. package/dist/analyze.d.ts +8 -0
  6. package/dist/analyze.js +217 -0
  7. package/dist/base.d.ts +18 -0
  8. package/dist/base.js +42 -0
  9. package/dist/cell-color-picker.d.ts +14 -0
  10. package/dist/cell-color-picker.js +278 -0
  11. package/dist/color-lab.d.ts +3 -0
  12. package/dist/color-lab.js +31 -0
  13. package/dist/color-step.d.ts +33 -0
  14. package/dist/color-step.js +62 -0
  15. package/dist/contours.d.ts +4 -0
  16. package/dist/contours.js +66 -0
  17. package/dist/denoise.d.ts +3 -0
  18. package/dist/denoise.js +37 -0
  19. package/dist/deskew.d.ts +24 -0
  20. package/dist/deskew.js +202 -0
  21. package/dist/grid-ac-rl-consensus.d.ts +127 -0
  22. package/dist/grid-ac-rl-consensus.js +497 -0
  23. package/dist/grid-arbitrate.d.ts +52 -0
  24. package/dist/grid-arbitrate.js +366 -0
  25. package/dist/grid-cut-snap.d.ts +15 -0
  26. package/dist/grid-cut-snap.js +77 -0
  27. package/dist/grid-description-length.d.ts +47 -0
  28. package/dist/grid-description-length.js +697 -0
  29. package/dist/grid-detect.d.ts +8 -0
  30. package/dist/grid-detect.js +665 -0
  31. package/dist/grid-edge-coherence.d.ts +4 -0
  32. package/dist/grid-edge-coherence.js +158 -0
  33. package/dist/grid-edge-path.d.ts +9 -0
  34. package/dist/grid-edge-path.js +201 -0
  35. package/dist/grid-frame-lattice.d.ts +25 -0
  36. package/dist/grid-frame-lattice.js +420 -0
  37. package/dist/grid-frequency-spectrum.d.ts +11 -0
  38. package/dist/grid-frequency-spectrum.js +89 -0
  39. package/dist/grid-period-boundary.d.ts +13 -0
  40. package/dist/grid-period-boundary.js +255 -0
  41. package/dist/grid-period-comb.d.ts +4 -0
  42. package/dist/grid-period-comb.js +230 -0
  43. package/dist/grid-period-distance.d.ts +7 -0
  44. package/dist/grid-period-distance.js +233 -0
  45. package/dist/grid-phase-field.d.ts +11 -0
  46. package/dist/grid-phase-field.js +446 -0
  47. package/dist/grid-pitch-lattice.d.ts +4 -0
  48. package/dist/grid-pitch-lattice.js +255 -0
  49. package/dist/grid-pitch-search.d.ts +11 -0
  50. package/dist/grid-pitch-search.js +233 -0
  51. package/dist/grid-sample.d.ts +4 -0
  52. package/dist/grid-sample.js +164 -0
  53. package/dist/grid-sampling.d.ts +13 -0
  54. package/dist/grid-sampling.js +132 -0
  55. package/dist/grid-strip-shift.d.ts +44 -0
  56. package/dist/grid-strip-shift.js +223 -0
  57. package/dist/grid-test-images.d.ts +69 -0
  58. package/dist/grid-test-images.js +547 -0
  59. package/dist/grid-warp.d.ts +38 -0
  60. package/dist/grid-warp.js +252 -0
  61. package/dist/grid.d.ts +8 -0
  62. package/dist/grid.js +43 -0
  63. package/dist/index.d.ts +16 -0
  64. package/dist/index.js +14 -0
  65. package/dist/line-guided/cv-lab.d.ts +4 -0
  66. package/dist/line-guided/cv-lab.js +134 -0
  67. package/dist/line-guided/cv-ops.d.ts +29 -0
  68. package/dist/line-guided/cv-ops.js +759 -0
  69. package/dist/line-guided/pixeloe-entry.d.ts +15 -0
  70. package/dist/line-guided/pixeloe-entry.js +122 -0
  71. package/dist/line-guided/pixeloe-legacy.d.ts +31 -0
  72. package/dist/line-guided/pixeloe-legacy.js +301 -0
  73. package/dist/line-guided/xdog.d.ts +7 -0
  74. package/dist/line-guided/xdog.js +84 -0
  75. package/dist/most-frequent.d.ts +3 -0
  76. package/dist/most-frequent.js +56 -0
  77. package/dist/outline-prepass.d.ts +37 -0
  78. package/dist/outline-prepass.js +56 -0
  79. package/dist/palette-snap.d.ts +4 -0
  80. package/dist/palette-snap.js +371 -0
  81. package/dist/palette.d.ts +21 -0
  82. package/dist/palette.js +280 -0
  83. package/dist/pixelate.d.ts +15 -0
  84. package/dist/pixelate.js +214 -0
  85. package/dist/pixelation-algorithm-registry.d.ts +34 -0
  86. package/dist/pixelation-algorithm-registry.js +716 -0
  87. package/dist/pixeloe-prepass.d.ts +12 -0
  88. package/dist/pixeloe-prepass.js +527 -0
  89. package/dist/quantization.d.ts +6 -0
  90. package/dist/quantization.js +145 -0
  91. package/dist/remove-background.d.ts +40 -0
  92. package/dist/remove-background.js +229 -0
  93. package/dist/resize.d.ts +3 -0
  94. package/dist/resize.js +86 -0
  95. package/dist/sampling.d.ts +7 -0
  96. package/dist/sampling.js +301 -0
  97. package/dist/settings.d.ts +36 -0
  98. package/dist/settings.js +181 -0
  99. package/dist/types.d.ts +255 -0
  100. package/dist/types.js +87 -0
  101. package/licenses/PixelOE-Apache-2.0.txt +201 -0
  102. package/licenses/pixel-art-fixer-MIT.txt +21 -0
  103. package/licenses/proper-pixel-art-MIT.txt +21 -0
  104. package/licenses/tensorhead-dithering-MIT.txt +21 -0
  105. package/package.json +62 -4
  106. package/wasm/ac-rl-detect-browser-worker.js +45 -0
  107. package/wasm/ac-rl-detect-worker.mjs +51 -0
  108. package/wasm/pixelfixer_detect.d.ts +65 -0
  109. package/wasm/pixelfixer_detect.js +334 -0
  110. package/wasm/pixelfixer_detect_bg.wasm +0 -0
@@ -0,0 +1,12 @@
1
+ import type { PixelBuffer } from "./base.ts";
2
+ import { LineGuideMode } from "./types.ts";
3
+ /** A line mask at the image size (1 = line) and how it changes the expansion weight. */
4
+ export interface PrepassGuide {
5
+ mask: Float32Array;
6
+ mode: LineGuideMode;
7
+ }
8
+ /**
9
+ * Full pre-pass: outline expansion, then colour match to the original. `pixelSize` is the cell size
10
+ * in source pixels. Without `guide` this is the original pre-pass, unchanged.
11
+ */
12
+ export declare function pixeloePrepass(image: PixelBuffer, pixelSize: number, guide?: PrepassGuide): PixelBuffer;
@@ -0,0 +1,527 @@
1
+ import { LineGuideMode } from "./types.js";
2
+ /**
3
+ * PixelOE pre-pass (Apache-2.0, KohakuBlueleaf/PixelOE, `torch/outline.py` + `torch/color.py`), ported:
4
+ * contrast-aware outline expansion (erode/dilate blend weighted by local brightness polarity, then an
5
+ * erode-dilate-erode clean-up) followed by colour matching (global Lab mean/std transfer + 5-level
6
+ * wavelet low-frequency swap from the source). It is a standalone image -> image step: the cell colour
7
+ * picker and the grid stay independent of it.
8
+ * Deliberate faithful quirks: zero padding in unfold-style windows, lower median, Lab stats over all
9
+ * three channels together, kornia's Lab constants. Not replicated: replicate-padding to a multiple of
10
+ * the pixel size, fp16 Gaussian kernel, unclamped intermediates (output is 8-bit), alpha (kept as is).
11
+ */
12
+ const PIXELOE_THICKNESS = 3;
13
+ const WAVELET_LEVELS = 5;
14
+ function circleKernel(realR) {
15
+ const r = Math.trunc(realR);
16
+ const n = 2 * r + 1;
17
+ const k = new Float64Array(n * n);
18
+ const offs = [
19
+ [-0.5, -0.5],
20
+ [-0.5, 0.5],
21
+ [0.5, -0.5],
22
+ [0.5, 0.5],
23
+ [0, 0.5],
24
+ [0, -0.5],
25
+ [0.5, 0],
26
+ [-0.5, 0],
27
+ ];
28
+ for (let i = 0; i < n; i++)
29
+ for (let j = 0; j < n; j++) {
30
+ let mx = -Infinity, mn = Infinity;
31
+ for (const [a, b] of offs) {
32
+ const d = Math.hypot(i + a - r, j + b - r);
33
+ mx = Math.max(mx, d);
34
+ mn = Math.min(mn, d);
35
+ }
36
+ if (mx <= realR)
37
+ k[i * n + j] = 1;
38
+ else if (mn <= realR)
39
+ k[i * n + j] = (realR - mn) / (mx - mn);
40
+ }
41
+ return k;
42
+ }
43
+ /** KERNELS[3] = circle(2)[1:4,1:4]; KERNELS[2] = circle(1.5); both 3x3. */
44
+ function kernelFor(iters) {
45
+ if (iters === 2)
46
+ return { k: circleKernel(1.5), n: 3 };
47
+ if (iters === 3) {
48
+ const c = circleKernel(2);
49
+ const k = new Float64Array(9);
50
+ for (let i = 0; i < 3; i++)
51
+ for (let j = 0; j < 3; j++)
52
+ k[i * 3 + j] = c[(i + 1) * 5 + j + 1];
53
+ return { k, n: 3 };
54
+ }
55
+ throw new RangeError("Unsupported outline thickness.");
56
+ }
57
+ // kornia sRGB <-> Lab (D65), floats, no input clamp.
58
+ const lin = (v) => (v > 0.04045 ? ((v + 0.055) / 1.055) ** 2.4 : v / 12.92);
59
+ /** Channels read from 8-bit pixels are one of 256 float32 values; their `lin` is looked up. */
60
+ const BYTE_LEVELS = Float32Array.from({ length: 256 }, (_, i) => i / 255);
61
+ const BYTE_LIN = Float64Array.from(BYTE_LEVELS, lin);
62
+ function linear(v) {
63
+ const level = Math.round(v * 255);
64
+ return level >= 0 && level <= 255 && BYTE_LEVELS[level] === v ? BYTE_LIN[level] : lin(v);
65
+ }
66
+ const unlin = (v) => v > 0.0031308 ? 1.055 * Math.max(v, 0) ** (1 / 2.4) - 0.055 : v * 12.92;
67
+ const f = (t) => (t > 0.008856 ? Math.cbrt(Math.max(t, 0.008856)) : 7.787 * t + 4 / 29);
68
+ function toLab(r, g, b) {
69
+ const n = r.length;
70
+ const L = new Float32Array(n), A = new Float32Array(n), B = new Float32Array(n);
71
+ for (let i = 0; i < n; i++) {
72
+ const lr = linear(r[i]), lg = linear(g[i]), lb = linear(b[i]);
73
+ const x = f((0.412453 * lr + 0.35758 * lg + 0.180423 * lb) / 0.95047);
74
+ const y = f(0.212671 * lr + 0.71516 * lg + 0.072169 * lb);
75
+ const z = f((0.019334 * lr + 0.119193 * lg + 0.950227 * lb) / 1.08883);
76
+ L[i] = 116 * y - 16;
77
+ A[i] = 500 * (x - y);
78
+ B[i] = 200 * (y - z);
79
+ }
80
+ return [L, A, B];
81
+ }
82
+ function fromLab(L, A, B) {
83
+ const n = L.length;
84
+ const R = new Float32Array(n), G = new Float32Array(n), Bl = new Float32Array(n);
85
+ const inv = (v) => (v > 0.2068966 ? v ** 3 : (v - 4 / 29) / 7.787);
86
+ for (let i = 0; i < n; i++) {
87
+ const fy = (L[i] + 16) / 116;
88
+ const fx = A[i] / 500 + fy;
89
+ const fz = Math.max(0, fy - B[i] / 200);
90
+ const x = inv(fx) * 0.95047, y = inv(fy), z = inv(fz) * 1.08883;
91
+ const cl = (v) => Math.min(1, Math.max(0, v));
92
+ R[i] = cl(unlin(3.2404813432 * x - 1.5371515163 * y - 0.4985363262 * z));
93
+ G[i] = cl(unlin(-0.96925495 * x + 1.8759900015 * y + 0.0415559266 * z));
94
+ Bl[i] = cl(unlin(0.0556466391 * x - 0.2040413384 * y + 1.0573110696 * z));
95
+ }
96
+ return [R, G, Bl];
97
+ }
98
+ /** k-th smallest (0-based) by quickselect; reorders `a`. */
99
+ function selectNth(a, k) {
100
+ let lo = 0, hi = a.length - 1;
101
+ while (lo < hi) {
102
+ const pivot = a[(lo + hi) >> 1];
103
+ let i = lo, j = hi;
104
+ while (i <= j) {
105
+ while (a[i] < pivot)
106
+ i++;
107
+ while (a[j] > pivot)
108
+ j--;
109
+ if (i <= j) {
110
+ const t = a[i];
111
+ a[i] = a[j];
112
+ a[j] = t;
113
+ i++;
114
+ j--;
115
+ }
116
+ }
117
+ if (k <= j)
118
+ hi = j;
119
+ else if (k >= i)
120
+ lo = i;
121
+ else
122
+ return a[k];
123
+ }
124
+ return a[k];
125
+ }
126
+ /** torch unfold(k, stride, pad k//2) -> per-patch stat -> fold, averaged by coverage. Zero padding included. */
127
+ function localStat(src, w, h, kernel, stride, stat) {
128
+ const pad = kernel >> 1;
129
+ const nx = Math.floor((w + 2 * pad - kernel) / stride) + 1;
130
+ const ny = Math.floor((h + 2 * pad - kernel) / stride) + 1;
131
+ const out = new Float32Array(w * h);
132
+ const div = new Float32Array(w * h);
133
+ const buf = new Float32Array(kernel * kernel);
134
+ for (let py = 0; py < ny; py++)
135
+ for (let px = 0; px < nx; px++) {
136
+ const x0 = px * stride - pad, y0 = py * stride - pad;
137
+ let m = 0;
138
+ let lo = Infinity, hi = -Infinity;
139
+ for (let y = y0; y < y0 + kernel; y++)
140
+ for (let x = x0; x < x0 + kernel; x++) {
141
+ const v = x >= 0 && y >= 0 && x < w && y < h ? src[y * w + x] : 0;
142
+ if (stat === "median")
143
+ buf[m++] = v;
144
+ else if (v < lo)
145
+ lo = v;
146
+ if (v > hi)
147
+ hi = v;
148
+ }
149
+ let value;
150
+ if (stat === "median") {
151
+ value = selectNth(buf, (buf.length - 1) >> 1);
152
+ }
153
+ else
154
+ value = stat === "min" ? lo : hi;
155
+ const xa = Math.max(0, x0), xb = Math.min(w, x0 + kernel), ya = Math.max(0, y0), yb = Math.min(h, y0 + kernel);
156
+ for (let y = ya; y < yb; y++)
157
+ for (let x = xa; x < xb; x++) {
158
+ out[y * w + x] += value;
159
+ div[y * w + x] += 1;
160
+ }
161
+ }
162
+ for (let i = 0; i < out.length; i++)
163
+ out[i] /= div[i] + 1e-8;
164
+ return out;
165
+ }
166
+ function expansionWeight(L, w, h, k) {
167
+ const stride = k >> 1;
168
+ const lum = L.map((v) => v / 100);
169
+ const med = localStat(lum, w, h, k * 2, stride, "median");
170
+ const mn = localStat(lum, w, h, k, stride, "min");
171
+ const mx = localStat(lum, w, h, k, stride, "max");
172
+ const out = new Float32Array(w * h);
173
+ let lo = Infinity, hi = -Infinity;
174
+ for (let i = 0; i < out.length; i++) {
175
+ const bright = mx[i] - med[i];
176
+ const dark = med[i] - mn[i];
177
+ const s = (med[i] - 0.5) * 10 + (dark - bright) * 3;
178
+ const v = 1 / (1 + Math.exp(-s));
179
+ out[i] = v;
180
+ if (v < lo)
181
+ lo = v;
182
+ if (v > hi)
183
+ hi = v;
184
+ }
185
+ for (let i = 0; i < out.length; i++)
186
+ out[i] = (out[i] - lo) / (hi - lo + 1e-8);
187
+ return out;
188
+ }
189
+ /** Continuous erode/dilate with zero padding, as unfold-based torch ops. */
190
+ function morph(src, w, h, kern, erode, iterations) {
191
+ let cur = src;
192
+ for (let it = 0; it < iterations; it++) {
193
+ const out = new Float32Array(w * h);
194
+ if (kern.n === 3 && w > 2 && h > 2) {
195
+ morphInterior3(cur, out, w, h, kern.k, erode);
196
+ morphBorder(cur, out, w, h, kern, erode, true);
197
+ }
198
+ else
199
+ morphBorder(cur, out, w, h, kern, erode, false);
200
+ cur = out;
201
+ }
202
+ return cur;
203
+ }
204
+ /** One 3x3 pass over the pixels whose window lies inside the image. */
205
+ function morphInterior3(cur, out, w, h, k, erode) {
206
+ // Per tap, the value added to the neighbour: erode takes min(v - k + 1), dilate max(v + k - 1).
207
+ const offsets = [-w - 1, -w, -w + 1, -1, 0, 1, w - 1, w, w + 1];
208
+ const [o0, o1, o2, o3, o4, o5, o6, o7, o8] = offsets;
209
+ const [k0, k1, k2, k3, k4, k5, k6, k7, k8] = k;
210
+ for (let y = 1; y < h - 1; y++) {
211
+ const end = y * w + w - 1;
212
+ if (erode)
213
+ for (let o = y * w + 1; o < end; o++) {
214
+ let best = Infinity, c;
215
+ if ((c = cur[o + o0] - k0 + 1) < best)
216
+ best = c;
217
+ if ((c = cur[o + o1] - k1 + 1) < best)
218
+ best = c;
219
+ if ((c = cur[o + o2] - k2 + 1) < best)
220
+ best = c;
221
+ if ((c = cur[o + o3] - k3 + 1) < best)
222
+ best = c;
223
+ if ((c = cur[o + o4] - k4 + 1) < best)
224
+ best = c;
225
+ if ((c = cur[o + o5] - k5 + 1) < best)
226
+ best = c;
227
+ if ((c = cur[o + o6] - k6 + 1) < best)
228
+ best = c;
229
+ if ((c = cur[o + o7] - k7 + 1) < best)
230
+ best = c;
231
+ if ((c = cur[o + o8] - k8 + 1) < best)
232
+ best = c;
233
+ out[o] = best;
234
+ }
235
+ else
236
+ for (let o = y * w + 1; o < end; o++) {
237
+ let best = -Infinity, c;
238
+ if ((c = cur[o + o0] + k0 - 1) > best)
239
+ best = c;
240
+ if ((c = cur[o + o1] + k1 - 1) > best)
241
+ best = c;
242
+ if ((c = cur[o + o2] + k2 - 1) > best)
243
+ best = c;
244
+ if ((c = cur[o + o3] + k3 - 1) > best)
245
+ best = c;
246
+ if ((c = cur[o + o4] + k4 - 1) > best)
247
+ best = c;
248
+ if ((c = cur[o + o5] + k5 - 1) > best)
249
+ best = c;
250
+ if ((c = cur[o + o6] + k6 - 1) > best)
251
+ best = c;
252
+ if ((c = cur[o + o7] + k7 - 1) > best)
253
+ best = c;
254
+ if ((c = cur[o + o8] + k8 - 1) > best)
255
+ best = c;
256
+ out[o] = Math.min(1, Math.max(0, best));
257
+ }
258
+ }
259
+ }
260
+ /** The generic window pass, over the border pixels only when the interior is done already. */
261
+ function morphBorder(cur, out, w, h, kern, erode, borderOnly) {
262
+ const p = kern.n >> 1;
263
+ for (let y = 0; y < h; y++)
264
+ for (let x = 0; x < w; x++) {
265
+ if (borderOnly && y > 0 && y < h - 1 && x > 0 && x < w - 1) {
266
+ x = w - 2;
267
+ continue;
268
+ }
269
+ let best = erode ? Infinity : -Infinity;
270
+ for (let j = 0; j < kern.n; j++)
271
+ for (let i = 0; i < kern.n; i++) {
272
+ const xx = x + i - p, yy = y + j - p;
273
+ const v = xx >= 0 && yy >= 0 && xx < w && yy < h ? cur[yy * w + xx] : 0;
274
+ const kv = kern.k[j * kern.n + i];
275
+ const c = erode ? v - kv + 1 : v + kv - 1;
276
+ if (erode ? c < best : c > best)
277
+ best = c;
278
+ }
279
+ out[y * w + x] = erode ? best : Math.min(1, Math.max(0, best));
280
+ }
281
+ }
282
+ /**
283
+ * Symmetric taps along a row: `out[base + x]` (a float32 store) is `taps[0] * row[x + radius]` plus
284
+ * `taps[t] * (row[x + radius - t] + row[x + radius + t])` for t = 1..radius, summed in doubles in
285
+ * that order. Eight pixels run side by side.
286
+ */
287
+ function blurRow(row, taps, radius, out, base, w) {
288
+ const centre = taps[0];
289
+ let x = 0;
290
+ for (; x + 7 < w; x += 8) {
291
+ const c = x + radius;
292
+ let s0 = centre * row[c], s1 = centre * row[c + 1], s2 = centre * row[c + 2], s3 = centre * row[c + 3], s4 = centre * row[c + 4], s5 = centre * row[c + 5], s6 = centre * row[c + 6], s7 = centre * row[c + 7];
293
+ for (let t = 1; t <= radius; t++) {
294
+ const wt = taps[t], l = c - t, r = c + t;
295
+ s0 += wt * (row[l] + row[r]);
296
+ s1 += wt * (row[l + 1] + row[r + 1]);
297
+ s2 += wt * (row[l + 2] + row[r + 2]);
298
+ s3 += wt * (row[l + 3] + row[r + 3]);
299
+ s4 += wt * (row[l + 4] + row[r + 4]);
300
+ s5 += wt * (row[l + 5] + row[r + 5]);
301
+ s6 += wt * (row[l + 6] + row[r + 6]);
302
+ s7 += wt * (row[l + 7] + row[r + 7]);
303
+ }
304
+ const o = base + x;
305
+ out[o] = s0;
306
+ out[o + 1] = s1;
307
+ out[o + 2] = s2;
308
+ out[o + 3] = s3;
309
+ out[o + 4] = s4;
310
+ out[o + 5] = s5;
311
+ out[o + 6] = s6;
312
+ out[o + 7] = s7;
313
+ }
314
+ for (; x < w; x++) {
315
+ const c = x + radius;
316
+ let acc = centre * row[c];
317
+ for (let t = 1; t <= radius; t++)
318
+ acc += taps[t] * (row[c - t] + row[c + t]);
319
+ out[base + x] = acc;
320
+ }
321
+ }
322
+ /**
323
+ * Symmetric taps down a column: the centre row times `taps[0]`, then each pair of rows added in,
324
+ * rounding to float32 after every step (as accumulating in a float32 image). Eight pixels at once.
325
+ */
326
+ function blurColumn(tmp, taps, radius, ups, downs, out, dst, w) {
327
+ const f32 = Math.fround;
328
+ const centre = taps[0];
329
+ let x = 0;
330
+ for (; x + 7 < w; x += 8) {
331
+ const c = dst + x;
332
+ let s0 = f32(centre * tmp[c]), s1 = f32(centre * tmp[c + 1]), s2 = f32(centre * tmp[c + 2]), s3 = f32(centre * tmp[c + 3]), s4 = f32(centre * tmp[c + 4]), s5 = f32(centre * tmp[c + 5]), s6 = f32(centre * tmp[c + 6]), s7 = f32(centre * tmp[c + 7]);
333
+ for (let t = 1; t <= radius; t++) {
334
+ const wt = taps[t], u = ups[t] + x, d = downs[t] + x;
335
+ s0 = f32(s0 + wt * (tmp[u] + tmp[d]));
336
+ s1 = f32(s1 + wt * (tmp[u + 1] + tmp[d + 1]));
337
+ s2 = f32(s2 + wt * (tmp[u + 2] + tmp[d + 2]));
338
+ s3 = f32(s3 + wt * (tmp[u + 3] + tmp[d + 3]));
339
+ s4 = f32(s4 + wt * (tmp[u + 4] + tmp[d + 4]));
340
+ s5 = f32(s5 + wt * (tmp[u + 5] + tmp[d + 5]));
341
+ s6 = f32(s6 + wt * (tmp[u + 6] + tmp[d + 6]));
342
+ s7 = f32(s7 + wt * (tmp[u + 7] + tmp[d + 7]));
343
+ }
344
+ out[c] = s0;
345
+ out[c + 1] = s1;
346
+ out[c + 2] = s2;
347
+ out[c + 3] = s3;
348
+ out[c + 4] = s4;
349
+ out[c + 5] = s5;
350
+ out[c + 6] = s6;
351
+ out[c + 7] = s7;
352
+ }
353
+ for (; x < w; x++) {
354
+ let s = f32(centre * tmp[dst + x]);
355
+ for (let t = 1; t <= radius; t++)
356
+ s = f32(s + taps[t] * (tmp[ups[t] + x] + tmp[downs[t] + x]));
357
+ out[dst + x] = s;
358
+ }
359
+ }
360
+ function gaussianBlur(src, w, h, radius) {
361
+ const taps = new Float32Array(radius + 1);
362
+ let sum = 0;
363
+ for (let i = -radius; i <= radius; i++)
364
+ sum += Math.exp(-(i * i) / (2 * radius * radius));
365
+ for (let i = 0; i <= radius; i++)
366
+ taps[i] = Math.exp(-(i * i) / (2 * radius * radius)) / sum;
367
+ const reflectOk = h > radius && w > radius;
368
+ const indices = (n) => {
369
+ const table = new Int32Array(n + 2 * radius);
370
+ for (let i = -radius; i < n + radius; i++) {
371
+ let v = i;
372
+ if (reflectOk) {
373
+ if (v < 0)
374
+ v = -v;
375
+ if (v >= n)
376
+ v = 2 * (n - 1) - v;
377
+ }
378
+ table[i + radius] = Math.min(n - 1, Math.max(0, v));
379
+ }
380
+ return table;
381
+ };
382
+ // Horizontal pass on a reflect-padded row, symmetric taps.
383
+ const columns = indices(w);
384
+ const row = new Float32Array(w + 2 * radius);
385
+ const tmp = new Float32Array(w * h);
386
+ for (let y = 0; y < h; y++) {
387
+ const base = y * w;
388
+ for (let i = 0; i < row.length; i++)
389
+ row[i] = src[base + columns[i]];
390
+ blurRow(row, taps, radius, tmp, base, w);
391
+ }
392
+ // Vertical pass.
393
+ const lines = indices(h);
394
+ const out = new Float32Array(w * h);
395
+ const ups = new Int32Array(radius + 1), downs = new Int32Array(radius + 1);
396
+ for (let y = 0; y < h; y++) {
397
+ for (let t = 1; t <= radius; t++) {
398
+ ups[t] = lines[y - t + radius] * w;
399
+ downs[t] = lines[y + t + radius] * w;
400
+ }
401
+ blurColumn(tmp, taps, radius, ups, downs, out, y * w, w);
402
+ }
403
+ return out;
404
+ }
405
+ /** Box-reduce by `f`, then blur there and bilinearly enlarge: used for the widest (smoothest) wavelet levels. */
406
+ function blurReduced(src, w, h, radius, f) {
407
+ const dw = Math.ceil(w / f), dh = Math.ceil(h / f);
408
+ const small = new Float32Array(dw * dh);
409
+ for (let y = 0; y < dh; y++)
410
+ for (let x = 0; x < dw; x++) {
411
+ let sum = 0, n = 0;
412
+ for (let yy = y * f; yy < Math.min(h, y * f + f); yy++)
413
+ for (let xx = x * f; xx < Math.min(w, x * f + f); xx++) {
414
+ sum += src[yy * w + xx];
415
+ n++;
416
+ }
417
+ small[y * dw + x] = sum / n;
418
+ }
419
+ const blurred = gaussianBlur(small, dw, dh, radius / f);
420
+ const out = new Float32Array(w * h);
421
+ for (let y = 0; y < h; y++) {
422
+ const fy = Math.min(dh - 1, Math.max(0, (y + 0.5) / f - 0.5));
423
+ const y0 = Math.floor(fy), y1 = Math.min(dh - 1, y0 + 1), ty = fy - y0;
424
+ for (let x = 0; x < w; x++) {
425
+ const fx = Math.min(dw - 1, Math.max(0, (x + 0.5) / f - 0.5));
426
+ const x0 = Math.floor(fx), x1 = Math.min(dw - 1, x0 + 1), tx = fx - x0;
427
+ out[y * w + x] =
428
+ (blurred[y0 * dw + x0] * (1 - tx) + blurred[y0 * dw + x1] * tx) * (1 - ty) +
429
+ (blurred[y1 * dw + x0] * (1 - tx) + blurred[y1 * dw + x1] * tx) * ty;
430
+ }
431
+ }
432
+ return out;
433
+ }
434
+ const blurLevel = (src, w, h, radius) => radius >= 16 && Math.min(w, h) >= 128
435
+ ? blurReduced(src, w, h, radius, 4)
436
+ : gaussianBlur(src, w, h, radius);
437
+ function waveletColorFix(inp, target, w, h) {
438
+ const high = new Float32Array(inp.length);
439
+ let x = inp;
440
+ let t = target;
441
+ for (let i = 1; i <= WAVELET_LEVELS; i++) {
442
+ const low = blurLevel(x, w, h, 2 ** i);
443
+ for (let j = 0; j < high.length; j++)
444
+ high[j] += x[j] - low[j];
445
+ x = low;
446
+ t = blurLevel(t, w, h, 2 ** i);
447
+ }
448
+ for (let j = 0; j < high.length; j++)
449
+ high[j] += t[j];
450
+ return high;
451
+ }
452
+ /** Outline expansion only. */
453
+ function outlineExpansion(planes, w, h, pixelSize, guide, thickness = PIXELOE_THICKNESS) {
454
+ // PixelOE + lines: dark spreads along the drawn lines too (union) or only there (replace). Lines
455
+ // only replaces the contrast weight outright, so it is not computed at all then.
456
+ let weight;
457
+ if (guide?.mode === LineGuideMode.Replace)
458
+ weight = guide.mask;
459
+ else {
460
+ weight = expansionWeight(toLab(...planes)[0], w, h, pixelSize);
461
+ if (guide)
462
+ for (let i = 0; i < weight.length; i++)
463
+ weight[i] = Math.max(weight[i], guide.mask[i]);
464
+ }
465
+ const kt = kernelFor(thickness);
466
+ const kc = kernelFor(2);
467
+ return planes.map((p) => {
468
+ const e = morph(p, w, h, kt, true, 1);
469
+ const d = morph(p, w, h, kt, false, 1);
470
+ const mixed = new Float32Array(p.length);
471
+ for (let i = 0; i < p.length; i++)
472
+ mixed[i] = e[i] * weight[i] + d[i] * (1 - weight[i]);
473
+ return morph(morph(morph(mixed, w, h, kc, true, 1), w, h, kc, false, 2), w, h, kc, true, 1);
474
+ });
475
+ }
476
+ /** Colour match of `source` to `target` (global Lab statistics, then wavelet low-frequency swap). */
477
+ function colorMatch(source, target, w, h) {
478
+ const sl = toLab(...source);
479
+ const tl = toLab(...target);
480
+ const stats = (lab) => {
481
+ const n = lab[0].length * 3;
482
+ let mean = 0;
483
+ for (const c of lab)
484
+ for (let i = 0; i < c.length; i++)
485
+ mean += c[i];
486
+ mean /= n;
487
+ let v = 0;
488
+ for (const c of lab)
489
+ for (let i = 0; i < c.length; i++)
490
+ v += (c[i] - mean) ** 2;
491
+ return { mean, std: Math.sqrt(v / (n - 1)) };
492
+ };
493
+ const s = stats(sl), t = stats(tl);
494
+ const shifted = sl.map((c) => {
495
+ const plane = new Float32Array(c.length);
496
+ for (let i = 0; i < c.length; i++)
497
+ plane[i] = ((c[i] - s.mean) / s.std) * t.std + t.mean;
498
+ return plane;
499
+ });
500
+ const rgb = fromLab(shifted[0], shifted[1], shifted[2]);
501
+ return rgb.map((p, i) => waveletColorFix(p, target[i], w, h));
502
+ }
503
+ /**
504
+ * Full pre-pass: outline expansion, then colour match to the original. `pixelSize` is the cell size
505
+ * in source pixels. Without `guide` this is the original pre-pass, unchanged.
506
+ */
507
+ export function pixeloePrepass(image, pixelSize, guide) {
508
+ const size = Math.round(pixelSize);
509
+ if (size < 2 || image.width < 2 || image.height < 2)
510
+ return image;
511
+ const { width: w, height: h, data } = image;
512
+ const planes = [
513
+ new Float32Array(w * h),
514
+ new Float32Array(w * h),
515
+ new Float32Array(w * h),
516
+ ];
517
+ for (let i = 0; i < w * h; i++)
518
+ for (let c = 0; c < 3; c++)
519
+ planes[c][i] = data[i * 4 + c] / 255;
520
+ const expanded = outlineExpansion(planes, w, h, size, guide);
521
+ const matched = colorMatch(expanded, planes, w, h);
522
+ const out = new Uint8ClampedArray(data);
523
+ for (let i = 0; i < w * h; i++)
524
+ for (let c = 0; c < 3; c++)
525
+ out[i * 4 + c] = Math.round(matched[c][i] * 255);
526
+ return { width: w, height: h, data: out };
527
+ }
@@ -0,0 +1,6 @@
1
+ import type { Rgba } from "./base.ts";
2
+ import type { PaletteEntry } from "./types.ts";
3
+ /** Oklab plus opacity; invisible RGB never participates in matching. */
4
+ export declare function paletteCoordinates(color: Rgba): Rgba;
5
+ /** Variance-driven color partitioning keeps accents instead of selecting only frequent shades. */
6
+ export declare function optimizePalette(entries: PaletteEntry[], limit: number): PaletteEntry[];
@@ -0,0 +1,145 @@
1
+ import { UINT8_MAX } from "./base.js";
2
+ import { clampByte } from "./base.js";
3
+ const CHANNELS = 4;
4
+ const COLOR_CHANNELS = 3;
5
+ const LINEAR_THRESHOLD = 0.04045;
6
+ const LINEAR_DIVISOR = 12.92;
7
+ const GAMMA_OFFSET = 0.055;
8
+ const GAMMA_SCALE = 1.055;
9
+ const GAMMA_EXPONENT = 2.4;
10
+ const linear = (byte) => {
11
+ const value = byte / UINT8_MAX;
12
+ return value <= LINEAR_THRESHOLD
13
+ ? value / LINEAR_DIVISOR
14
+ : ((value + GAMMA_OFFSET) / GAMMA_SCALE) ** GAMMA_EXPONENT;
15
+ };
16
+ const LINEAR = Float64Array.from({ length: UINT8_MAX + 1 }, (_, byte) => linear(byte));
17
+ const linearChannel = (byte) => (byte === 0 ? byte : (LINEAR[byte] ?? linear(byte)));
18
+ // Oklab matrices published by Björn Ottosson (public domain):
19
+ // https://bottosson.github.io/posts/oklab/
20
+ const LINEAR_SRGB_TO_LMS = [
21
+ [0.4122214708, 0.5363325363, 0.0514459929],
22
+ [0.2119034982, 0.6806995451, 0.1073969566],
23
+ [0.0883024619, 0.2817188376, 0.6299787005],
24
+ ];
25
+ const LMS_TO_OKLAB = [
26
+ [0.2104542553, 0.793617785, -0.0040720468],
27
+ [1.9779984951, -2.428592205, 0.4505937099],
28
+ [0.0259040371, 0.7827717662, -0.808675766],
29
+ ];
30
+ function dot(row, a, b, c) {
31
+ return row[0] * a + row[1] * b + row[2] * c;
32
+ }
33
+ /** Oklab plus opacity; invisible RGB never participates in matching. */
34
+ export function paletteCoordinates(color) {
35
+ const opacity = color[3] / UINT8_MAX;
36
+ const r = linearChannel(color[0]);
37
+ const g = linearChannel(color[1]);
38
+ const b = linearChannel(color[2]);
39
+ const l = Math.cbrt(dot(LINEAR_SRGB_TO_LMS[0], r, g, b));
40
+ const m = Math.cbrt(dot(LINEAR_SRGB_TO_LMS[1], r, g, b));
41
+ const s = Math.cbrt(dot(LINEAR_SRGB_TO_LMS[2], r, g, b));
42
+ return [
43
+ dot(LMS_TO_OKLAB[0], l, m, s) * opacity,
44
+ dot(LMS_TO_OKLAB[1], l, m, s) * opacity,
45
+ dot(LMS_TO_OKLAB[2], l, m, s) * opacity,
46
+ opacity,
47
+ ];
48
+ }
49
+ function box(samples) {
50
+ const sum = [0, 0, 0, 0], squares = [0, 0, 0, 0];
51
+ let count = 0;
52
+ for (const sample of samples) {
53
+ count += sample.entry.count;
54
+ for (let axis = 0; axis < CHANNELS; axis++) {
55
+ sum[axis] += sample.coordinates[axis] * sample.entry.count;
56
+ squares[axis] += sample.coordinates[axis] ** 2 * sample.entry.count;
57
+ }
58
+ }
59
+ let axis = 0, error = 0;
60
+ for (let channel = 0; channel < CHANNELS; channel++) {
61
+ const variance = squares[channel] - sum[channel] ** 2 / count;
62
+ if (variance > error) {
63
+ error = variance;
64
+ axis = channel;
65
+ }
66
+ }
67
+ return { samples, axis, error: samples.length > 1 ? error : 0 };
68
+ }
69
+ function split(current) {
70
+ const samples = [...current.samples].sort((a, b) => a.coordinates[current.axis] - b.coordinates[current.axis]);
71
+ const totals = [0, 0, 0, 0], partial = [0, 0, 0, 0];
72
+ let totalCount = 0, leftCount = 0;
73
+ for (const sample of samples) {
74
+ totalCount += sample.entry.count;
75
+ for (let channel = 0; channel < CHANNELS; channel++)
76
+ totals[channel] += sample.coordinates[channel] * sample.entry.count;
77
+ }
78
+ let bestGain = -1, boundary = 1;
79
+ for (let index = 1; index < samples.length; index++) {
80
+ const sample = samples[index - 1];
81
+ leftCount += sample.entry.count;
82
+ let gain = 0;
83
+ for (let channel = 0; channel < CHANNELS; channel++) {
84
+ partial[channel] += sample.coordinates[channel] * sample.entry.count;
85
+ const difference = partial[channel] / leftCount -
86
+ (totals[channel] - partial[channel]) / (totalCount - leftCount);
87
+ gain += (difference ** 2 * leftCount * (totalCount - leftCount)) / totalCount;
88
+ }
89
+ if (gain > bestGain) {
90
+ bestGain = gain;
91
+ boundary = index;
92
+ }
93
+ }
94
+ return [box(samples.slice(0, boundary)), box(samples.slice(boundary))];
95
+ }
96
+ /** Variance-driven color partitioning keeps accents instead of selecting only frequent shades. */
97
+ export function optimizePalette(entries, limit) {
98
+ if (entries.length <= limit)
99
+ return entries;
100
+ const transparent = entries.find((entry) => entry.color[3] === 0);
101
+ const visible = entries.filter((entry) => entry.color[3] !== 0);
102
+ const reserveTransparency = !!transparent && limit > 1;
103
+ const boxes = [
104
+ box((reserveTransparency ? visible : entries).map((entry) => ({
105
+ entry,
106
+ coordinates: paletteCoordinates(entry.color),
107
+ }))),
108
+ ];
109
+ while (boxes.length < limit - Number(reserveTransparency)) {
110
+ let index = -1, error = 0;
111
+ for (let i = 0; i < boxes.length; i++)
112
+ if (boxes[i].error > error) {
113
+ index = i;
114
+ error = boxes[i].error;
115
+ }
116
+ if (index < 0)
117
+ break;
118
+ boxes.splice(index, 1, ...split(boxes[index]));
119
+ }
120
+ const result = boxes.map((current) => {
121
+ let count = 0, alpha = 0, coverage = 0;
122
+ const sums = [0, 0, 0];
123
+ for (const { entry } of current.samples) {
124
+ count += entry.count;
125
+ coverage += entry.coverage;
126
+ const weight = entry.count * entry.color[3];
127
+ alpha += weight;
128
+ for (let channel = 0; channel < COLOR_CHANNELS; channel++)
129
+ sums[channel] += entry.color[channel] * weight;
130
+ }
131
+ return {
132
+ color: [
133
+ clampByte(alpha ? sums[0] / alpha : 0),
134
+ clampByte(alpha ? sums[1] / alpha : 0),
135
+ clampByte(alpha ? sums[2] / alpha : 0),
136
+ clampByte(alpha / count),
137
+ ],
138
+ count,
139
+ coverage,
140
+ };
141
+ });
142
+ if (reserveTransparency)
143
+ result.push(transparent);
144
+ return result.sort((a, b) => b.count - a.count);
145
+ }