@xprite/pixelate 0.0.0-stage → 0.1.1

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 +270 -2
  4. package/README.zh-CN.md +240 -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,56 @@
1
+ import { rememberPixeloe, sourceLineMask, } from "./line-guided/pixeloe-entry.js";
2
+ import { pixeloePrepass } from "./pixeloe-prepass.js";
3
+ import { CellColorPicker, LineGuideMode, OutlinePrepass, PixelationAlgorithm, PixelationMode, } from "./types.js";
4
+ /**
5
+ * Whether these options run stock PixelOE end to end (its contrast downscale and colour match to the
6
+ * output size) instead of a pre-pass followed by the picker: a PixelOE pre-pass with the Contrast
7
+ * picker, when the cells are not a grid recovered from the source (a plain convert, or Custom size's
8
+ * even blocks). This is the PixelOE reference output.
9
+ */
10
+ export function runsStockPixeloe(options, grid) {
11
+ if ((options.prepass ?? OutlinePrepass.None) === OutlinePrepass.None)
12
+ return false;
13
+ if (options.colorPicker !== CellColorPicker.Contrast)
14
+ return false;
15
+ if (options.mode === PixelationMode.Convert)
16
+ return true;
17
+ return grid?.algorithm === PixelationAlgorithm.CustomSize;
18
+ }
19
+ /**
20
+ * The image -> image pre-pass (the original PixelOE pre-pass, optionally line-guided). Recent
21
+ * results are reused, so a colour or picker change on the same image does not rerun it.
22
+ */
23
+ export function applyOutlinePrepass(image, cell, prepass, lineGuide = DEFAULT_LINE_GUIDE) {
24
+ if (prepass === OutlinePrepass.None)
25
+ return image;
26
+ // The pre-pass reads the cell size rounded to whole pixels only.
27
+ const variant = `prepass:${prepass}:${Math.round(cell)}:${prepass === OutlinePrepass.Pixeloe ? "" : lineGuide}`;
28
+ return rememberPixeloe(image, variant, () => {
29
+ if (prepass === OutlinePrepass.Pixeloe)
30
+ return pixeloePrepass(image, cell);
31
+ const mask = sourceLineMask(image);
32
+ return pixeloePrepass(image, cell, mask ? { mask, mode: lineGuide } : undefined);
33
+ });
34
+ }
35
+ /**
36
+ * The pre-pass every source opens with, whether or not a grid is found: PixelOE + lines (with the
37
+ * Contrast picker). None, the plain grid recovery, is one choice away in the controls.
38
+ */
39
+ export const DEFAULT_OUTLINE_PREPASS = OutlinePrepass.PixeloeLines;
40
+ /**
41
+ * The line guide PixelOE + lines starts with: Lines only (the line mask replaces PixelOE's outline
42
+ * weight). Add to outlines barely differs from plain PixelOE on line art, while Lines only keeps
43
+ * continuous drawn lines (e.g. an eye line) through the downscale.
44
+ */
45
+ export const DEFAULT_LINE_GUIDE = LineGuideMode.Replace;
46
+ /**
47
+ * A pre-pass from saved settings: the current value, or the old `pixeloePrepass` checkbox
48
+ * (true -> PixelOE, false -> None).
49
+ */
50
+ export function readOutlinePrepass(saved) {
51
+ if (Object.values(OutlinePrepass).includes(saved.prepass))
52
+ return saved.prepass;
53
+ if (typeof saved.pixeloePrepass === "boolean")
54
+ return saved.pixeloePrepass ? OutlinePrepass.Pixeloe : OutlinePrepass.None;
55
+ return undefined;
56
+ }
@@ -0,0 +1,4 @@
1
+ import type { PixelBuffer } from "./base.ts";
2
+ import { type PaletteSnapOptions } from "./types.ts";
3
+ /** The recovered image with its colours snapped; the source is never modified. */
4
+ export declare function snapPalette(image: PixelBuffer, options: PaletteSnapOptions): PixelBuffer;
@@ -0,0 +1,371 @@
1
+ import { rgbToLab, labToRgb } from "./color-lab.js";
2
+ import { PixelationDither } from "./types.js";
3
+ /**
4
+ * Optional "snap palette" step for a recovered (small) image: collapse the many near-shades that
5
+ * AI art and JPEG sources leave behind into a clean, flat palette, or snap to a palette the user
6
+ * supplies. Rare-but-important colours (skin tones, highlights) are kept by two rules:
7
+ * - only near-duplicates (within `MERGE_DELTA` in CIE Lab) are ever merged without a budget, and
8
+ * - k-means is seeded by weighted farthest-point selection, so a colour far from everything else
9
+ * gets its own palette entry before a frequent shade that is only a little different.
10
+ * Alpha is never clustered: each pixel keeps its own opacity and fully transparent pixels stay put.
11
+ * When Limit colors dithering is on, the built palette is applied per pixel with ordered
12
+ * (Bayer 8×8 → palette, ported from tensorhead/dithering MIT) or Floyd–Steinberg error
13
+ * diffusion instead of a flat shade remap.
14
+ */
15
+ const MIN_COLORS = 2;
16
+ const MAX_COLORS = 256;
17
+ const DEFAULT_COLORS = 16;
18
+ /** CIE76 distance below which two colours are the same shade for practical purposes. */
19
+ const MERGE_DELTA = 3;
20
+ /** Seeding score = distance * count^SEED_WEIGHT: mostly distance, a little frequency. */
21
+ const SEED_WEIGHT = 0.25;
22
+ /** Cluster pull = count^PULL_WEIGHT, so large flat areas do not drag centroids off rare colours. */
23
+ const PULL_WEIGHT = 0.5;
24
+ const MAX_ITERATIONS = 32;
25
+ const CHANNELS = 4;
26
+ /**
27
+ * Bayer 8×8 indices from tensorhead/dithering (MIT). Thresholds use (index + 0.5) / 64.
28
+ * See https://github.com/tensorhead/dithering — matrices.rs / ordered.rs ordered_palette.
29
+ */
30
+ const BAYER_8 = [
31
+ 0, 32, 8, 40, 2, 34, 10, 42, 48, 16, 56, 24, 50, 18, 58, 26, 12, 44, 4, 36, 14, 46, 6, 38, 60, 28,
32
+ 52, 20, 62, 30, 54, 22, 3, 35, 11, 43, 1, 33, 9, 41, 51, 19, 59, 27, 49, 17, 57, 25, 15, 47, 7,
33
+ 39, 13, 45, 5, 37, 63, 31, 55, 23, 61, 29, 53, 21,
34
+ ];
35
+ const distance = (a, b) => Math.sqrt((a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2 + (a[2] - b[2]) ** 2);
36
+ const clampByte = (value) => Math.max(0, Math.min(255, Math.round(value)));
37
+ function histogram(image) {
38
+ const { data } = image;
39
+ const pixels = data.length / CHANNELS;
40
+ const seen = new Map();
41
+ const shades = [];
42
+ const index = new Int32Array(pixels).fill(-1);
43
+ for (let i = 0; i < pixels; i++) {
44
+ const o = i * CHANNELS;
45
+ if (data[o + 3] === 0)
46
+ continue;
47
+ const key = (data[o] << 16) | (data[o + 1] << 8) | data[o + 2];
48
+ let at = seen.get(key);
49
+ if (at === undefined) {
50
+ at = shades.length;
51
+ seen.set(key, at);
52
+ shades.push({
53
+ rgb: [data[o], data[o + 1], data[o + 2]],
54
+ lab: rgbToLab(data[o], data[o + 1], data[o + 2]),
55
+ count: 0,
56
+ });
57
+ }
58
+ shades[at].count += 1;
59
+ index[i] = at;
60
+ }
61
+ return { shades, index };
62
+ }
63
+ /** Greedy leader clustering, heaviest first: only colours within MERGE_DELTA share a group. */
64
+ function mergeNearDuplicates(shades) {
65
+ const order = shades.map((_, i) => i).sort((a, b) => shades[b].count - shades[a].count || a - b);
66
+ const groups = [];
67
+ for (const i of order) {
68
+ let best = -1, bestDistance = MERGE_DELTA;
69
+ for (let g = 0; g < groups.length; g++) {
70
+ const d = distance(shades[i].lab, shades[groups[g][0]].lab);
71
+ if (d <= bestDistance) {
72
+ bestDistance = d;
73
+ best = g;
74
+ }
75
+ }
76
+ if (best < 0)
77
+ groups.push([i]);
78
+ else
79
+ groups[best].push(i);
80
+ }
81
+ return groups;
82
+ }
83
+ function toPoints(shades, groups) {
84
+ return groups.map((members) => {
85
+ let count = 0;
86
+ const sum = [0, 0, 0];
87
+ for (const m of members) {
88
+ count += shades[m].count;
89
+ for (let c = 0; c < 3; c++)
90
+ sum[c] += shades[m].lab[c] * shades[m].count;
91
+ }
92
+ return {
93
+ lab: [sum[0] / count, sum[1] / count, sum[2] / count],
94
+ count,
95
+ members,
96
+ };
97
+ });
98
+ }
99
+ /** Weighted farthest-point seeding: start from the commonest colour, then keep adding the colour
100
+ * that is farthest from every seed so far (lightly favouring frequent ones). */
101
+ function seed(points, k) {
102
+ let first = 0;
103
+ for (let i = 1; i < points.length; i++)
104
+ if (points[i].count > points[first].count)
105
+ first = i;
106
+ const seeds = [points[first].lab];
107
+ const nearest = points.map((p) => distance(p.lab, seeds[0]));
108
+ while (seeds.length < k) {
109
+ let pick = -1, pickScore = 0;
110
+ for (let i = 0; i < points.length; i++) {
111
+ const score = nearest[i] * points[i].count ** SEED_WEIGHT;
112
+ if (score > pickScore) {
113
+ pickScore = score;
114
+ pick = i;
115
+ }
116
+ }
117
+ if (pick < 0)
118
+ break;
119
+ seeds.push(points[pick].lab);
120
+ for (let i = 0; i < points.length; i++)
121
+ nearest[i] = Math.min(nearest[i], distance(points[i].lab, points[pick].lab));
122
+ }
123
+ return seeds;
124
+ }
125
+ function cluster(points, k) {
126
+ const centers = seed(points, k);
127
+ const assign = new Int32Array(points.length).fill(-1);
128
+ for (let iteration = 0; iteration < MAX_ITERATIONS; iteration++) {
129
+ let changed = false;
130
+ for (let i = 0; i < points.length; i++) {
131
+ let best = 0, bestDistance = Infinity;
132
+ for (let c = 0; c < centers.length; c++) {
133
+ const d = distance(points[i].lab, centers[c]);
134
+ if (d < bestDistance) {
135
+ bestDistance = d;
136
+ best = c;
137
+ }
138
+ }
139
+ if (assign[i] !== best) {
140
+ assign[i] = best;
141
+ changed = true;
142
+ }
143
+ }
144
+ if (!changed)
145
+ break;
146
+ const sums = centers.map(() => [0, 0, 0, 0]);
147
+ for (let i = 0; i < points.length; i++) {
148
+ const weight = points[i].count ** PULL_WEIGHT;
149
+ const s = sums[assign[i]];
150
+ for (let c = 0; c < 3; c++)
151
+ s[c] += points[i].lab[c] * weight;
152
+ s[3] += weight;
153
+ }
154
+ for (let c = 0; c < centers.length; c++)
155
+ if (sums[c][3] > 0)
156
+ centers[c] = [sums[c][0] / sums[c][3], sums[c][1] / sums[c][3], sums[c][2] / sums[c][3]];
157
+ }
158
+ return Array.from(assign);
159
+ }
160
+ function paletteLabs(palette) {
161
+ const visible = palette.filter((color) => color[3] > 0);
162
+ if (!visible.length)
163
+ throw new RangeError("Choose a palette containing a visible color.");
164
+ return {
165
+ labs: visible.map((c) => rgbToLab(c[0], c[1], c[2])),
166
+ rgb: visible.map((c) => c),
167
+ };
168
+ }
169
+ function nearestPaletteRgb(lab, labs, rgb) {
170
+ let best = 0, bestDistance = Infinity;
171
+ for (let i = 0; i < labs.length; i++) {
172
+ const d = distance(lab, labs[i]);
173
+ if (d < bestDistance) {
174
+ bestDistance = d;
175
+ best = i;
176
+ }
177
+ }
178
+ return [rgb[best][0], rgb[best][1], rgb[best][2]];
179
+ }
180
+ /**
181
+ * Per-channel palette spread used by ordered Bayer→palette (tensorhead/dithering):
182
+ * (max − min) / (distinct − 1) in each channel; 0 when the channel is constant.
183
+ */
184
+ function paletteSpread(palette) {
185
+ const spread = [0, 0, 0];
186
+ for (let channel = 0; channel < 3; channel++) {
187
+ const values = [];
188
+ for (const color of palette) {
189
+ const value = color[channel];
190
+ if (!values.includes(value))
191
+ values.push(value);
192
+ }
193
+ values.sort((a, b) => a - b);
194
+ if (values.length > 1)
195
+ spread[channel] = (values[values.length - 1] - values[0]) / (values.length - 1);
196
+ }
197
+ return spread;
198
+ }
199
+ /** Nearest palette entry by Euclidean RGB distance (working space of tensorhead/dithering). */
200
+ function nearestPaletteRgbEuclidean(r, g, b, palette) {
201
+ let best = 0, bestDistance = Infinity;
202
+ for (let i = 0; i < palette.length; i++) {
203
+ const dr = r - palette[i][0];
204
+ const dg = g - palette[i][1];
205
+ const db = b - palette[i][2];
206
+ const d = dr * dr + dg * dg + db * db;
207
+ if (d < bestDistance) {
208
+ bestDistance = d;
209
+ best = i;
210
+ }
211
+ }
212
+ return [palette[best][0], palette[best][1], palette[best][2]];
213
+ }
214
+ /**
215
+ * Ordered Bayer 8×8 → palette. Port of tensorhead/dithering `ordered_palette` (MIT):
216
+ * threshold offset (t − 0.5) scaled by each channel's palette spread, then RGB nearest.
217
+ * Unlike ±RGB Bayer then Lab nearest, this still dithers when every pixel is already a palette colour.
218
+ */
219
+ function orderedBayer8ToPalette(image, palette) {
220
+ const spread = paletteSpread(palette);
221
+ const output = new Uint8ClampedArray(image.data);
222
+ const { width, height, data } = image;
223
+ for (let y = 0; y < height; y++) {
224
+ for (let x = 0; x < width; x++) {
225
+ const at = (y * width + x) * CHANNELS;
226
+ if (data[at + 3] === 0)
227
+ continue;
228
+ const t = (BAYER_8[(y & 7) * 8 + (x & 7)] + 0.5) / 64 - 0.5;
229
+ const chosen = nearestPaletteRgbEuclidean(data[at] + t * spread[0], data[at + 1] + t * spread[1], data[at + 2] + t * spread[2], palette);
230
+ output[at] = chosen[0];
231
+ output[at + 1] = chosen[1];
232
+ output[at + 2] = chosen[2];
233
+ }
234
+ }
235
+ return { width, height, data: output };
236
+ }
237
+ /**
238
+ * Floyd–Steinberg error diffusion to `palette` in Lab-nearest space.
239
+ * Transparent pixels are left alone.
240
+ */
241
+ function floydSteinbergToPalette(image, palette) {
242
+ const { labs, rgb } = paletteLabs(palette);
243
+ const output = new Uint8ClampedArray(image.data);
244
+ const { width, height, data } = image;
245
+ const error = new Float32Array(width * height * 3);
246
+ for (let y = 0; y < height; y++) {
247
+ for (let x = 0; x < width; x++) {
248
+ const i = y * width + x;
249
+ const at = i * CHANNELS;
250
+ if (data[at + 3] === 0)
251
+ continue;
252
+ const sample = [
253
+ clampByte(data[at] + error[i * 3]),
254
+ clampByte(data[at + 1] + error[i * 3 + 1]),
255
+ clampByte(data[at + 2] + error[i * 3 + 2]),
256
+ ];
257
+ const chosen = nearestPaletteRgb(rgbToLab(sample[0], sample[1], sample[2]), labs, rgb);
258
+ output[at] = chosen[0];
259
+ output[at + 1] = chosen[1];
260
+ output[at + 2] = chosen[2];
261
+ for (let channel = 0; channel < 3; channel++) {
262
+ const delta = sample[channel] - chosen[channel];
263
+ if (x + 1 < width)
264
+ error[(i + 1) * 3 + channel] += (delta * 7) / 16;
265
+ if (y + 1 < height) {
266
+ if (x > 0)
267
+ error[(i + width - 1) * 3 + channel] += (delta * 3) / 16;
268
+ error[(i + width) * 3 + channel] += (delta * 5) / 16;
269
+ if (x + 1 < width)
270
+ error[(i + width + 1) * 3 + channel] += delta / 16;
271
+ }
272
+ }
273
+ }
274
+ }
275
+ return { width, height, data: output };
276
+ }
277
+ function ditherToPalette(image, palette, dither) {
278
+ return dither === PixelationDither.Ordered
279
+ ? orderedBayer8ToPalette(image, palette)
280
+ : floydSteinbergToPalette(image, palette);
281
+ }
282
+ /** Build the reduced RGB palette (k-means or the given list); null when the image already fits. */
283
+ function buildPalette(shades, options) {
284
+ if (options.palette)
285
+ return paletteLabs(options.palette).rgb;
286
+ const groups = mergeNearDuplicates(shades);
287
+ const colors = options.adaptive
288
+ ? Math.min(groups.length, MAX_COLORS)
289
+ : Math.max(MIN_COLORS, Math.min(MAX_COLORS, Math.round(options.colors ?? DEFAULT_COLORS)));
290
+ if (shades.length <= colors)
291
+ return null;
292
+ const points = toPoints(shades, groups);
293
+ const assignment = points.length <= colors ? points.map((_, i) => i) : cluster(points, colors);
294
+ const sums = new Map();
295
+ points.forEach((point, i) => {
296
+ const entry = sums.get(assignment[i]) ?? { lab: [0, 0, 0], count: 0 };
297
+ for (let c = 0; c < 3; c++)
298
+ entry.lab[c] += point.lab[c] * point.count;
299
+ entry.count += point.count;
300
+ sums.set(assignment[i], entry);
301
+ });
302
+ const palette = [];
303
+ for (const { lab, count } of sums.values()) {
304
+ const [r, g, b] = labToRgb(lab[0] / count, lab[1] / count, lab[2] / count);
305
+ palette.push([r, g, b, 255]);
306
+ }
307
+ return palette;
308
+ }
309
+ /** Flat nearest-shade remap (no positional dither). */
310
+ function remapShades(image, shades, index, options) {
311
+ const output = new Uint8ClampedArray(image.data);
312
+ let target;
313
+ if (options.palette) {
314
+ const { labs, rgb } = paletteLabs(options.palette);
315
+ target = shades.map((shade) => nearestPaletteRgb(shade.lab, labs, rgb));
316
+ }
317
+ else {
318
+ const groups = mergeNearDuplicates(shades);
319
+ const colors = options.adaptive
320
+ ? Math.min(groups.length, MAX_COLORS)
321
+ : Math.max(MIN_COLORS, Math.min(MAX_COLORS, Math.round(options.colors ?? DEFAULT_COLORS)));
322
+ if (shades.length <= colors)
323
+ return { width: image.width, height: image.height, data: output };
324
+ const points = toPoints(shades, groups);
325
+ const assignment = points.length <= colors ? points.map((_, i) => i) : cluster(points, colors);
326
+ const sums = new Map();
327
+ points.forEach((point, i) => {
328
+ const entry = sums.get(assignment[i]) ?? { lab: [0, 0, 0], count: 0 };
329
+ for (let c = 0; c < 3; c++)
330
+ entry.lab[c] += point.lab[c] * point.count;
331
+ entry.count += point.count;
332
+ sums.set(assignment[i], entry);
333
+ });
334
+ const colorOf = new Map();
335
+ for (const [id, { lab, count }] of sums)
336
+ colorOf.set(id, labToRgb(lab[0] / count, lab[1] / count, lab[2] / count));
337
+ target = Array.from({ length: shades.length }, () => [0, 0, 0]);
338
+ points.forEach((point, i) => {
339
+ for (const member of point.members)
340
+ target[member] = colorOf.get(assignment[i]);
341
+ });
342
+ }
343
+ for (let i = 0; i < index.length; i++) {
344
+ if (index[i] < 0)
345
+ continue;
346
+ const color = target[index[i]];
347
+ output[i * CHANNELS] = color[0];
348
+ output[i * CHANNELS + 1] = color[1];
349
+ output[i * CHANNELS + 2] = color[2];
350
+ }
351
+ return { width: image.width, height: image.height, data: output };
352
+ }
353
+ /** The recovered image with its colours snapped; the source is never modified. */
354
+ export function snapPalette(image, options) {
355
+ const dither = Object.values(PixelationDither).includes(options.dither)
356
+ ? options.dither
357
+ : PixelationDither.None;
358
+ const { shades, index } = histogram(image);
359
+ if (dither === PixelationDither.None)
360
+ return remapShades(image, shades, index, options);
361
+ // Ordered / Floyd–Steinberg need a concrete palette, then per-pixel Lab nearest.
362
+ const palette = buildPalette(shades, options);
363
+ if (!palette) {
364
+ // Already within budget and no user palette: dither among the image's own shades.
365
+ if (!shades.length)
366
+ return { width: image.width, height: image.height, data: new Uint8ClampedArray(image.data) };
367
+ const own = shades.map((shade) => [shade.rgb[0], shade.rgb[1], shade.rgb[2], 255]);
368
+ return ditherToPalette(image, own, dither);
369
+ }
370
+ return ditherToPalette(image, palette, dither);
371
+ }
@@ -0,0 +1,21 @@
1
+ import type { PixelBuffer, Rgba } from "./base.ts";
2
+ import type { ExtractPaletteOptions, PaletteEntry } from "./types.ts";
3
+ /**
4
+ * Extract a deterministic, bounded palette. Exact colors are retained for
5
+ * ordinary sprites; a fixed 32x32x32x16 histogram is used for noisy images so
6
+ * memory stays bounded instead of growing with every photograph pixel.
7
+ */
8
+ export declare function extractPalette(image: PixelBuffer, options?: ExtractPaletteOptions): PaletteEntry[];
9
+ /** Count distinct RGBA values with a bounded result for large images. */
10
+ export declare function countDistinctColors(image: PixelBuffer, limit?: number): number;
11
+ /** Perceptual matching with exact RGBA cache keys; colors within a coarse bin may differ. */
12
+ export declare function reduceToPalette(image: PixelBuffer, palette: readonly Rgba[], options?: {
13
+ preserveAlpha?: boolean;
14
+ }): PixelBuffer;
15
+ /** Palette generation counts visible RGB independently of coverage. */
16
+ export declare function extractPixelationPalette(image: PixelBuffer, options?: ExtractPaletteOptions): PaletteEntry[];
17
+ /** Compact mapped-RGB groups for the bounded Structure/TwoStage palettes. */
18
+ export declare function reduceToPixelationLabels(image: PixelBuffer, palette: readonly Rgba[]): {
19
+ data: Uint8Array<ArrayBuffer>;
20
+ keys: number[];
21
+ };