@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,40 @@
1
+ import type { PixelBuffer, Rgba } from "./base.ts";
2
+ export declare enum RemoveBackgroundMode {
3
+ Auto = "auto",
4
+ Manual = "manual"
5
+ }
6
+ export interface RemoveBackgroundOptions {
7
+ mode?: RemoveBackgroundMode;
8
+ /** Euclidean RGB distance; Manual default 32. Auto ignores this and picks adaptively. */
9
+ tolerance?: number;
10
+ /** Manual key color (RGB used; alpha ignored). Null falls back to Auto border key. */
11
+ keyColor?: Rgba | null;
12
+ }
13
+ export interface RemoveBackgroundSettings {
14
+ enabled: boolean;
15
+ mode: RemoveBackgroundMode;
16
+ tolerance: number;
17
+ keyColor: Rgba | null;
18
+ }
19
+ export declare const DEFAULT_REMOVE_BACKGROUND: RemoveBackgroundSettings;
20
+ /** Euclidean RGB distance between a pixel and a key color. */
21
+ export declare function colorKeyDistance(r: number, g: number, b: number, key: readonly [number, number, number] | Rgba): number;
22
+ /**
23
+ * Most common quantized border color (top/bottom/left/right edges). Matches the research
24
+ * classical Auto key used on flat sprite backgrounds.
25
+ */
26
+ export declare function modeBorderColor(image: PixelBuffer): Rgba;
27
+ /**
28
+ * Edge-seeded flood: candidates within tolerance of `key` that connect to the image border
29
+ * become transparent. Enclosed subject holes (letter O, etc.) stay opaque.
30
+ */
31
+ export declare function edgeSeededFloodAlpha(image: PixelBuffer, key: readonly [number, number, number] | Rgba, tolerance: number): Uint8Array;
32
+ /** Pick tolerance by scoring edge-flood masks (Auto path). */
33
+ export declare function adaptiveTolerance(image: PixelBuffer, key: readonly [number, number, number] | Rgba): number;
34
+ /**
35
+ * Classical Track B remove-background: edge-seeded flood + color tolerance.
36
+ * Auto samples the mode border color and chooses tolerance; Manual uses key + tolerance.
37
+ */
38
+ export declare function removeBackground(image: PixelBuffer, options?: RemoveBackgroundOptions): PixelBuffer;
39
+ /** Sample one pixel as an RGBA key for Manual mode (transparent pixels ignored by caller). */
40
+ export declare function sampleKeyColor(image: PixelBuffer, x: number, y: number): Rgba | null;
@@ -0,0 +1,229 @@
1
+ const CHANNELS = 4;
2
+ const DEFAULT_TOLERANCE = 32;
3
+ const BORDER_QUANT = 8;
4
+ /** Candidate tolerances for Auto adaptive scoring (inclusive of Ryan's ~24–48 band). */
5
+ const AUTO_TOLERANCES = [16, 24, 32, 40, 48, 64, 80, 100];
6
+ export var RemoveBackgroundMode;
7
+ (function (RemoveBackgroundMode) {
8
+ RemoveBackgroundMode["Auto"] = "auto";
9
+ RemoveBackgroundMode["Manual"] = "manual";
10
+ })(RemoveBackgroundMode || (RemoveBackgroundMode = {}));
11
+ export const DEFAULT_REMOVE_BACKGROUND = {
12
+ enabled: false,
13
+ mode: RemoveBackgroundMode.Auto,
14
+ tolerance: DEFAULT_TOLERANCE,
15
+ keyColor: null,
16
+ };
17
+ /** Euclidean RGB distance between a pixel and a key color. */
18
+ export function colorKeyDistance(r, g, b, key) {
19
+ const dr = r - key[0];
20
+ const dg = g - key[1];
21
+ const db = b - key[2];
22
+ return Math.sqrt(dr * dr + dg * dg + db * db);
23
+ }
24
+ /**
25
+ * Most common quantized border color (top/bottom/left/right edges). Matches the research
26
+ * classical Auto key used on flat sprite backgrounds.
27
+ */
28
+ export function modeBorderColor(image) {
29
+ const { width, height, data } = image;
30
+ const counts = new Map();
31
+ const bump = (x, y) => {
32
+ const i = (y * width + x) * CHANNELS;
33
+ if (data[i + 3] === 0)
34
+ return;
35
+ const qr = (Math.floor(data[i] / BORDER_QUANT) * BORDER_QUANT + (BORDER_QUANT >> 1)) & 255;
36
+ const qg = (Math.floor(data[i + 1] / BORDER_QUANT) * BORDER_QUANT + (BORDER_QUANT >> 1)) & 255;
37
+ const qb = (Math.floor(data[i + 2] / BORDER_QUANT) * BORDER_QUANT + (BORDER_QUANT >> 1)) & 255;
38
+ const key = (qr << 16) | (qg << 8) | qb;
39
+ counts.set(key, (counts.get(key) ?? 0) + 1);
40
+ };
41
+ for (let x = 0; x < width; x++) {
42
+ bump(x, 0);
43
+ if (height > 1)
44
+ bump(x, height - 1);
45
+ }
46
+ for (let y = 1; y < height - 1; y++) {
47
+ bump(0, y);
48
+ if (width > 1)
49
+ bump(width - 1, y);
50
+ }
51
+ let bestKey = 0;
52
+ let bestCount = -1;
53
+ for (const [key, count] of counts) {
54
+ if (count > bestCount) {
55
+ bestCount = count;
56
+ bestKey = key;
57
+ }
58
+ }
59
+ if (bestCount < 0)
60
+ return [0, 0, 0, 255];
61
+ return [(bestKey >> 16) & 255, (bestKey >> 8) & 255, bestKey & 255, 255];
62
+ }
63
+ /**
64
+ * Edge-seeded flood: candidates within tolerance of `key` that connect to the image border
65
+ * become transparent. Enclosed subject holes (letter O, etc.) stay opaque.
66
+ */
67
+ export function edgeSeededFloodAlpha(image, key, tolerance) {
68
+ const { width, height, data } = image;
69
+ const n = width * height;
70
+ const alpha = new Uint8Array(n);
71
+ alpha.fill(255);
72
+ if (n === 0)
73
+ return alpha;
74
+ const candidate = new Uint8Array(n);
75
+ for (let i = 0, p = 0; i < n; i++, p += CHANNELS) {
76
+ if (data[p + 3] === 0) {
77
+ candidate[i] = 1;
78
+ continue;
79
+ }
80
+ if (colorKeyDistance(data[p], data[p + 1], data[p + 2], key) <= tolerance)
81
+ candidate[i] = 1;
82
+ }
83
+ const stack = new Int32Array(n);
84
+ let top = 0;
85
+ const pushBorder = (i) => {
86
+ if (!candidate[i] || alpha[i] === 0)
87
+ return;
88
+ alpha[i] = 0;
89
+ stack[top++] = i;
90
+ };
91
+ for (let x = 0; x < width; x++) {
92
+ pushBorder(x);
93
+ if (height > 1)
94
+ pushBorder((height - 1) * width + x);
95
+ }
96
+ for (let y = 1; y < height - 1; y++) {
97
+ pushBorder(y * width);
98
+ if (width > 1)
99
+ pushBorder(y * width + (width - 1));
100
+ }
101
+ while (top > 0) {
102
+ const i = stack[--top];
103
+ const x = i % width;
104
+ const y = (i / width) | 0;
105
+ if (x > 0)
106
+ pushBorder(i - 1);
107
+ if (x + 1 < width)
108
+ pushBorder(i + 1);
109
+ if (y > 0)
110
+ pushBorder(i - width);
111
+ if (y + 1 < height)
112
+ pushBorder(i + width);
113
+ }
114
+ return alpha;
115
+ }
116
+ function scoreMask(alpha, width, height, dist) {
117
+ const n = width * height;
118
+ if (n === 0)
119
+ return 0;
120
+ let removed = 0;
121
+ let borderClear = 0;
122
+ let borderCount = 0;
123
+ let centerKeep = 0;
124
+ let centerCount = 0;
125
+ let leftoverBg = 0;
126
+ let holes = 0;
127
+ const cy0 = (height / 4) | 0;
128
+ const cy1 = ((3 * height) / 4) | 0;
129
+ const cx0 = (width / 4) | 0;
130
+ const cx1 = ((3 * width) / 4) | 0;
131
+ for (let y = 0; y < height; y++) {
132
+ for (let x = 0; x < width; x++) {
133
+ const i = y * width + x;
134
+ const clear = alpha[i] === 0;
135
+ if (clear)
136
+ removed++;
137
+ const onBorder = y === 0 || y === height - 1 || x === 0 || x === width - 1;
138
+ if (onBorder) {
139
+ borderCount++;
140
+ if (clear)
141
+ borderClear++;
142
+ }
143
+ const inCenter = y >= cy0 && y < cy1 && x >= cx0 && x < cx1;
144
+ if (inCenter) {
145
+ centerCount++;
146
+ if (!clear)
147
+ centerKeep++;
148
+ if (clear && dist[i] > 40)
149
+ holes++;
150
+ }
151
+ if (!clear && dist[i] <= 18)
152
+ leftoverBg++;
153
+ }
154
+ }
155
+ const removedRatio = removed / n;
156
+ const borderRatio = borderCount ? borderClear / borderCount : 0;
157
+ const centerRatio = centerCount ? centerKeep / centerCount : 1;
158
+ const leftoverRatio = leftoverBg / n;
159
+ const holesRatio = centerCount ? holes / centerCount : 0;
160
+ return (0.35 * borderRatio +
161
+ 0.35 * centerRatio +
162
+ 0.15 * Math.min(removedRatio / 0.45, 1) -
163
+ 0.25 * leftoverRatio -
164
+ 0.4 * holesRatio);
165
+ }
166
+ function distanceField(image, key) {
167
+ const { width, height, data } = image;
168
+ const dist = new Float32Array(width * height);
169
+ for (let i = 0, p = 0; i < dist.length; i++, p += CHANNELS) {
170
+ if (data[p + 3] === 0) {
171
+ dist[i] = 0;
172
+ continue;
173
+ }
174
+ dist[i] = colorKeyDistance(data[p], data[p + 1], data[p + 2], key);
175
+ }
176
+ return dist;
177
+ }
178
+ /** Pick tolerance by scoring edge-flood masks (Auto path). */
179
+ export function adaptiveTolerance(image, key) {
180
+ const dist = distanceField(image, key);
181
+ let bestTol = AUTO_TOLERANCES[0];
182
+ let bestScore = Number.NEGATIVE_INFINITY;
183
+ for (const tol of AUTO_TOLERANCES) {
184
+ const alpha = edgeSeededFloodAlpha(image, key, tol);
185
+ const score = scoreMask(alpha, image.width, image.height, dist);
186
+ if (score > bestScore) {
187
+ bestScore = score;
188
+ bestTol = tol;
189
+ }
190
+ }
191
+ return bestTol;
192
+ }
193
+ /** Clears every removed pixel, colour included, so the old background leaves no hidden pattern. */
194
+ function applyAlpha(image, alpha) {
195
+ const data = new Uint8ClampedArray(image.data);
196
+ for (let i = 0, p = 0; i < alpha.length; i++, p += CHANNELS) {
197
+ if (alpha[i] === 0)
198
+ data.fill(0, p, p + CHANNELS);
199
+ }
200
+ return { width: image.width, height: image.height, data };
201
+ }
202
+ /**
203
+ * Classical Track B remove-background: edge-seeded flood + color tolerance.
204
+ * Auto samples the mode border color and chooses tolerance; Manual uses key + tolerance.
205
+ */
206
+ export function removeBackground(image, options = {}) {
207
+ const mode = options.mode ?? RemoveBackgroundMode.Auto;
208
+ const key = mode === RemoveBackgroundMode.Manual && options.keyColor
209
+ ? options.keyColor
210
+ : modeBorderColor(image);
211
+ const tolerance = mode === RemoveBackgroundMode.Auto
212
+ ? adaptiveTolerance(image, key)
213
+ : Number.isFinite(options.tolerance)
214
+ ? Math.max(0, options.tolerance)
215
+ : DEFAULT_TOLERANCE;
216
+ return applyAlpha(image, edgeSeededFloodAlpha(image, key, tolerance));
217
+ }
218
+ /** Sample one pixel as an RGBA key for Manual mode (transparent pixels ignored by caller). */
219
+ export function sampleKeyColor(image, x, y) {
220
+ const px = Math.floor(x);
221
+ const py = Math.floor(y);
222
+ if (px < 0 || py < 0 || px >= image.width || py >= image.height)
223
+ return null;
224
+ const i = (py * image.width + px) * CHANNELS;
225
+ const a = image.data[i + 3];
226
+ if (a === 0)
227
+ return null;
228
+ return [image.data[i], image.data[i + 1], image.data[i + 2], 255];
229
+ }
@@ -0,0 +1,3 @@
1
+ import type { PixelBuffer } from "./base.ts";
2
+ export declare function resizeNearest(image: PixelBuffer, width: number, height: number): PixelBuffer;
3
+ export declare function resizeBox(image: PixelBuffer, width: number, height: number, alphaMode: "straight" | "premultiplied"): PixelBuffer;
package/dist/resize.js ADDED
@@ -0,0 +1,86 @@
1
+ import { UINT8_MAX } from "./base.js";
2
+ import { assertDimension, assertPixelCount, clampByte } from "./base.js";
3
+ export function resizeNearest(image, width, height) {
4
+ assertDimension(width, "width");
5
+ assertDimension(height, "height");
6
+ assertPixelCount(width, height);
7
+ const output = new Uint8ClampedArray(width * height * 4);
8
+ const source = image.data;
9
+ for (let y = 0; y < height; y += 1) {
10
+ const sourceY = Math.min(image.height - 1, Math.floor((y * image.height) / height));
11
+ for (let x = 0; x < width; x += 1) {
12
+ const sourceX = Math.min(image.width - 1, Math.floor((x * image.width) / width));
13
+ const sourceOffset = (sourceY * image.width + sourceX) * 4;
14
+ const outputOffset = (y * width + x) * 4;
15
+ output[outputOffset] = source[sourceOffset];
16
+ output[outputOffset + 1] = source[sourceOffset + 1];
17
+ output[outputOffset + 2] = source[sourceOffset + 2];
18
+ output[outputOffset + 3] = source[sourceOffset + 3];
19
+ }
20
+ }
21
+ return { width, height, data: output };
22
+ }
23
+ export function resizeBox(image, width, height, alphaMode) {
24
+ assertDimension(width, "width");
25
+ assertDimension(height, "height");
26
+ assertPixelCount(width, height);
27
+ const output = new Uint8ClampedArray(width * height * 4);
28
+ const source = image.data;
29
+ for (let y = 0; y < height; y += 1) {
30
+ const sourceTop = (y * image.height) / height;
31
+ const sourceBottom = ((y + 1) * image.height) / height;
32
+ const firstY = Math.floor(sourceTop);
33
+ const lastY = Math.min(image.height - 1, Math.ceil(sourceBottom) - 1);
34
+ for (let x = 0; x < width; x += 1) {
35
+ const sourceLeft = (x * image.width) / width;
36
+ const sourceRight = ((x + 1) * image.width) / width;
37
+ const firstX = Math.floor(sourceLeft);
38
+ const lastX = Math.min(image.width - 1, Math.ceil(sourceRight) - 1);
39
+ let weightTotal = 0;
40
+ let red = 0;
41
+ let green = 0;
42
+ let blue = 0;
43
+ let alpha = 0;
44
+ for (let sourceY = firstY; sourceY <= lastY; sourceY += 1) {
45
+ const yWeight = Math.min(sourceBottom, sourceY + 1) - Math.max(sourceTop, sourceY);
46
+ if (yWeight <= 0)
47
+ continue;
48
+ for (let sourceX = firstX; sourceX <= lastX; sourceX += 1) {
49
+ const weight = yWeight * (Math.min(sourceRight, sourceX + 1) - Math.max(sourceLeft, sourceX));
50
+ if (weight <= 0)
51
+ continue;
52
+ const sourceOffset = (sourceY * image.width + sourceX) * 4;
53
+ const sourceAlpha = source[sourceOffset + 3];
54
+ weightTotal += weight;
55
+ alpha += sourceAlpha * weight;
56
+ if (alphaMode === "premultiplied") {
57
+ const alphaWeight = sourceAlpha / UINT8_MAX;
58
+ red += source[sourceOffset] * alphaWeight * weight;
59
+ green += source[sourceOffset + 1] * alphaWeight * weight;
60
+ blue += source[sourceOffset + 2] * alphaWeight * weight;
61
+ }
62
+ else {
63
+ red += source[sourceOffset] * weight;
64
+ green += source[sourceOffset + 1] * weight;
65
+ blue += source[sourceOffset + 2] * weight;
66
+ }
67
+ }
68
+ }
69
+ const outputOffset = (y * width + x) * 4;
70
+ const averageAlpha = weightTotal === 0 ? 0 : alpha / weightTotal;
71
+ output[outputOffset + 3] = clampByte(averageAlpha);
72
+ if (alphaMode === "premultiplied") {
73
+ const alphaTotal = alpha / UINT8_MAX;
74
+ output[outputOffset] = clampByte(alphaTotal === 0 ? 0 : red / alphaTotal);
75
+ output[outputOffset + 1] = clampByte(alphaTotal === 0 ? 0 : green / alphaTotal);
76
+ output[outputOffset + 2] = clampByte(alphaTotal === 0 ? 0 : blue / alphaTotal);
77
+ }
78
+ else {
79
+ output[outputOffset] = clampByte(weightTotal === 0 ? 0 : red / weightTotal);
80
+ output[outputOffset + 1] = clampByte(weightTotal === 0 ? 0 : green / weightTotal);
81
+ output[outputOffset + 2] = clampByte(weightTotal === 0 ? 0 : blue / weightTotal);
82
+ }
83
+ }
84
+ }
85
+ return { width, height, data: output };
86
+ }
@@ -0,0 +1,7 @@
1
+ import type { PixelBuffer } from "./base.ts";
2
+ import { PixelationMethod } from "./types.ts";
3
+ /** RGB is taken from the winning region. Conversion keeps area alpha; recovery keeps the chosen region’s original alpha. */
4
+ export declare function samplePixelation(image: PixelBuffer, width: number, height: number, method: PixelationMethod, cuts?: {
5
+ x: readonly number[];
6
+ y: readonly number[];
7
+ }): PixelBuffer;
@@ -0,0 +1,301 @@
1
+ import { UINT8_MAX } from "./base.js";
2
+ import { assertDimension, assertPixelCount, clampByte } from "./base.js";
3
+ import { extractPixelationPalette, reduceToPixelationLabels, } from "./palette.js";
4
+ import { paletteCoordinates } from "./quantization.js";
5
+ import { PixelationMethod } from "./types.js";
6
+ const CHANNELS = 4;
7
+ const COLOR_CHANNELS = 3;
8
+ const CLUSTERS = 2;
9
+ const ITERATIONS = 6;
10
+ const CENTER_FLOOR = 0.25;
11
+ const STRUCTURE_COLORS = 48;
12
+ const TWO_STAGE_MIN_COLORS = 16;
13
+ const TWO_STAGE_MAX_COLORS = 48;
14
+ const TWO_STAGE_COLOR_BINS = 16 ** COLOR_CHANNELS;
15
+ const CONVERGENCE = 1e-7;
16
+ const MAX_COORDINATE_COLORS = 65_536;
17
+ function cell(image, left, top, right, bottom, recovery) {
18
+ left = Math.max(0, left);
19
+ top = Math.max(0, top);
20
+ right = Math.min(image.width, right);
21
+ bottom = Math.min(image.height, bottom);
22
+ const samples = [];
23
+ let area = 0, alphaSum = 0;
24
+ for (let y = Math.floor(top); y < Math.ceil(bottom); y++)
25
+ for (let x = Math.floor(left); x < Math.ceil(right); x++) {
26
+ const coverage = (Math.min(right, x + 1) - Math.max(left, x)) * (Math.min(bottom, y + 1) - Math.max(top, y));
27
+ if (coverage <= 0)
28
+ continue;
29
+ const index = y * image.width + x;
30
+ const alpha = image.data[index * CHANNELS + COLOR_CHANNELS];
31
+ area += coverage;
32
+ alphaSum += coverage * alpha;
33
+ if (!alpha && !recovery)
34
+ continue;
35
+ const fx = (Math.max(left, x) + Math.min(right, x + 1)) / 2;
36
+ const fy = (Math.max(top, y) + Math.min(bottom, y + 1)) / 2;
37
+ const horizontal = 1 - Math.abs((2 * (fx - left)) / (right - left) - 1);
38
+ const vertical = 1 - Math.abs((2 * (fy - top)) / (bottom - top) - 1);
39
+ const weight = coverage * (CENTER_FLOOR + (1 - CENTER_FLOOR) * horizontal * vertical);
40
+ samples.push({
41
+ index,
42
+ weight,
43
+ visibleWeight: (weight * alpha) / UINT8_MAX,
44
+ voteWeight: recovery ? weight : (weight * alpha) / UINT8_MAX,
45
+ });
46
+ }
47
+ return { samples, alpha: area ? clampByte(alphaSum / area) : 0 };
48
+ }
49
+ /**
50
+ * Compute adaptive K for TwoStage method (Fixer-aligned).
51
+ * Counts 4-bit RGB bins (R/16, G/16, B/16) with share ≥ 0.003 of opaque pixels.
52
+ * Clamps to [TWO_STAGE_MIN_COLORS, TWO_STAGE_MAX_COLORS].
53
+ */
54
+ function adaptiveTwoStageK(image) {
55
+ const bins = new Uint32Array(TWO_STAGE_COLOR_BINS);
56
+ let opaqueCount = 0;
57
+ for (let i = 0; i < image.data.length; i += CHANNELS) {
58
+ const alpha = image.data[i + COLOR_CHANNELS];
59
+ if (alpha === 0)
60
+ continue;
61
+ opaqueCount++;
62
+ // 4-bit RGB: divide each channel by 16
63
+ const r4 = image.data[i] >> 4;
64
+ const g4 = image.data[i + 1] >> 4;
65
+ const b4 = image.data[i + 2] >> 4;
66
+ const key = (r4 << 8) | (g4 << 4) | b4;
67
+ bins[key] += 1;
68
+ }
69
+ if (opaqueCount === 0)
70
+ return TWO_STAGE_MIN_COLORS;
71
+ const threshold = opaqueCount * 0.003;
72
+ let count = 0;
73
+ for (const binCount of bins) {
74
+ if (binCount >= threshold)
75
+ count++;
76
+ }
77
+ return Math.max(TWO_STAGE_MIN_COLORS, Math.min(TWO_STAGE_MAX_COLORS, count));
78
+ }
79
+ function coordinateLookup(image, axes) {
80
+ const cache = new Map();
81
+ return (sample, local) => {
82
+ const offset = sample.index * CHANNELS;
83
+ const alpha = axes === CHANNELS ? image.data[offset + COLOR_CHANNELS] : UINT8_MAX;
84
+ const key = alpha
85
+ ? (image.data[offset] << 24) |
86
+ (image.data[offset + 1] << 16) |
87
+ (image.data[offset + 2] << 8) |
88
+ alpha
89
+ : 0;
90
+ const cached = cache.get(key) ?? local.get(key);
91
+ if (cached)
92
+ return cached;
93
+ const value = paletteCoordinates([
94
+ image.data[offset],
95
+ image.data[offset + 1],
96
+ image.data[offset + 2],
97
+ alpha,
98
+ ]);
99
+ if (cache.size < MAX_COORDINATE_COLORS)
100
+ cache.set(key, value);
101
+ else
102
+ local.set(key, value);
103
+ return value;
104
+ };
105
+ }
106
+ function distance(sample, center) {
107
+ let sum = 0;
108
+ for (let axis = 0; axis < center.length; axis++)
109
+ sum += (sample.point[axis] - center[axis]) ** 2;
110
+ return sum;
111
+ }
112
+ function dominant(samples, axes) {
113
+ if (samples.length < CLUSTERS)
114
+ return samples;
115
+ const mean = Array.from({ length: axes }, () => 0);
116
+ let total = 0;
117
+ for (const sample of samples) {
118
+ total += sample.voteWeight;
119
+ for (let c = 0; c < axes; c++)
120
+ mean[c] += sample.point[c] * sample.voteWeight;
121
+ }
122
+ for (let c = 0; c < axes; c++)
123
+ mean[c] /= total;
124
+ const farthest = (center) => samples.reduce((best, point) => distance(point, center) > distance(best, center) ? point : best);
125
+ const first = farthest(mean);
126
+ const center = (sample) => sample.point.slice(0, axes);
127
+ const centers = [center(first), center(farthest(center(first)))];
128
+ let groups = [[], []];
129
+ for (let iteration = 0; iteration < ITERATIONS; iteration++) {
130
+ groups = [[], []];
131
+ for (const sample of samples)
132
+ groups[distance(sample, centers[0]) <= distance(sample, centers[1]) ? 0 : 1].push(sample);
133
+ let movement = 0;
134
+ for (let cluster = 0; cluster < CLUSTERS; cluster++) {
135
+ const values = Array.from({ length: axes }, () => 0);
136
+ let weight = 0;
137
+ for (const sample of groups[cluster]) {
138
+ weight += sample.voteWeight;
139
+ for (let c = 0; c < axes; c++)
140
+ values[c] += sample.point[c] * sample.voteWeight;
141
+ }
142
+ if (!weight)
143
+ continue;
144
+ for (let c = 0; c < axes; c++) {
145
+ values[c] /= weight;
146
+ movement += (values[c] - centers[cluster][c]) ** 2;
147
+ }
148
+ centers[cluster] = values;
149
+ }
150
+ if (movement <= CONVERGENCE)
151
+ break;
152
+ }
153
+ const weight = (group) => group.reduce((sum, sample) => sum + sample.voteWeight, 0);
154
+ return weight(groups[0]) >= weight(groups[1]) ? groups[0] : groups[1];
155
+ }
156
+ /** Reuse one bounded set of statistics for every cell, in the original sample order. */
157
+ function labelSampler(image, labels, recovery, triangular) {
158
+ const groups = labels.keys.length;
159
+ const touched = new Uint8Array(groups);
160
+ const active = new Uint16Array(groups);
161
+ let activeCount = 0;
162
+ const weights = new Float64Array(groups);
163
+ const alphaSums = new Float64Array(groups);
164
+ const visibleWeights = new Float64Array(groups);
165
+ const redSums = new Float64Array(groups);
166
+ const greenSums = new Float64Array(groups);
167
+ const blueSums = new Float64Array(groups);
168
+ return (left, top, right, bottom, output, outputOffset) => {
169
+ for (let index = 0; index < activeCount; index++) {
170
+ const group = active[index];
171
+ touched[group] = 0;
172
+ weights[group] = 0;
173
+ alphaSums[group] = 0;
174
+ visibleWeights[group] = 0;
175
+ redSums[group] = 0;
176
+ greenSums[group] = 0;
177
+ blueSums[group] = 0;
178
+ }
179
+ activeCount = 0;
180
+ left = Math.max(0, left);
181
+ top = Math.max(0, top);
182
+ right = Math.min(image.width, right);
183
+ bottom = Math.min(image.height, bottom);
184
+ let area = 0, alphaSum = 0;
185
+ for (let y = Math.floor(top); y < Math.ceil(bottom); y++)
186
+ for (let x = Math.floor(left); x < Math.ceil(right); x++) {
187
+ const coverage = (Math.min(right, x + 1) - Math.max(left, x)) *
188
+ (Math.min(bottom, y + 1) - Math.max(top, y));
189
+ if (coverage <= 0)
190
+ continue;
191
+ const index = y * image.width + x;
192
+ const offset = index * CHANNELS;
193
+ const alpha = image.data[offset + COLOR_CHANNELS];
194
+ area += coverage;
195
+ alphaSum += coverage * alpha;
196
+ if (!alpha && !recovery)
197
+ continue;
198
+ const fx = (Math.max(left, x) + Math.min(right, x + 1)) / 2;
199
+ const fy = (Math.max(top, y) + Math.min(bottom, y + 1)) / 2;
200
+ const horizontal = 1 - Math.abs((2 * (fx - left)) / (right - left) - 1);
201
+ const vertical = 1 - Math.abs((2 * (fy - top)) / (bottom - top) - 1);
202
+ const weight = triangular
203
+ ? coverage * horizontal * vertical
204
+ : coverage * (CENTER_FLOOR + (1 - CENTER_FLOOR) * horizontal * vertical);
205
+ const visibleWeight = (weight * alpha) / UINT8_MAX;
206
+ // Label zero also contains opaque black, matching the original RGB grouping.
207
+ const group = labels.data[index];
208
+ if (!touched[group]) {
209
+ touched[group] = 1;
210
+ active[activeCount++] = group;
211
+ }
212
+ weights[group] += weight;
213
+ alphaSums[group] += weight * alpha;
214
+ visibleWeights[group] += visibleWeight;
215
+ redSums[group] += image.data[offset] * visibleWeight;
216
+ greenSums[group] += image.data[offset + 1] * visibleWeight;
217
+ blueSums[group] += image.data[offset + 2] * visibleWeight;
218
+ }
219
+ const alpha = area ? clampByte(alphaSum / area) : 0;
220
+ output[outputOffset + COLOR_CHANNELS] = alpha;
221
+ if (!alpha)
222
+ return;
223
+ let winner = -1, vote = -1;
224
+ for (let index = 0; index < activeCount; index++) {
225
+ const group = active[index];
226
+ if (visibleWeights[group] > vote ||
227
+ (visibleWeights[group] === vote && labels.keys[group] < labels.keys[winner])) {
228
+ winner = group;
229
+ vote = visibleWeights[group];
230
+ }
231
+ }
232
+ if (winner < 0)
233
+ return;
234
+ if (recovery)
235
+ output[outputOffset + COLOR_CHANNELS] = clampByte(weights[winner] ? alphaSums[winner] / weights[winner] : 0);
236
+ const visible = visibleWeights[winner];
237
+ output[outputOffset] = clampByte(visible ? redSums[winner] / visible : 0);
238
+ output[outputOffset + 1] = clampByte(visible ? greenSums[winner] / visible : 0);
239
+ output[outputOffset + 2] = clampByte(visible ? blueSums[winner] / visible : 0);
240
+ };
241
+ }
242
+ /** RGB is taken from the winning region. Conversion keeps area alpha; recovery keeps the chosen region’s original alpha. */
243
+ export function samplePixelation(image, width, height, method, cuts) {
244
+ assertDimension(width, "width");
245
+ assertDimension(height, "height");
246
+ assertPixelCount(width, height);
247
+ const recovery = cuts !== undefined;
248
+ const axes = recovery ? CHANNELS : COLOR_CHANNELS;
249
+ const points = method === PixelationMethod.Dominant ? coordinateLookup(image, axes) : undefined;
250
+ const useStructure = method === PixelationMethod.Structure;
251
+ const useTwoStage = method === PixelationMethod.TwoStage;
252
+ const palette = useStructure
253
+ ? extractPixelationPalette(image, {
254
+ maxColors: STRUCTURE_COLORS,
255
+ }).map((entry) => entry.color)
256
+ : useTwoStage
257
+ ? extractPixelationPalette(image, {
258
+ maxColors: adaptiveTwoStageK(image),
259
+ }).map((entry) => entry.color)
260
+ : undefined;
261
+ const labels = palette ? reduceToPixelationLabels(image, palette) : undefined;
262
+ const data = new Uint8ClampedArray(width * height * CHANNELS);
263
+ const sampleLabel = labels ? labelSampler(image, labels, recovery, useTwoStage) : undefined;
264
+ for (let y = 0; y < height; y++)
265
+ for (let x = 0; x < width; x++) {
266
+ const left = cuts?.x[x] ?? (x * image.width) / width;
267
+ const top = cuts?.y[y] ?? (y * image.height) / height;
268
+ const right = cuts?.x[x + 1] ?? ((x + 1) * image.width) / width;
269
+ const bottom = cuts?.y[y + 1] ?? ((y + 1) * image.height) / height;
270
+ const offset = (y * width + x) * CHANNELS;
271
+ if (sampleLabel) {
272
+ sampleLabel(left, top, right, bottom, data, offset);
273
+ continue;
274
+ }
275
+ const current = cell(image, left, top, right, bottom, recovery);
276
+ data[offset + COLOR_CHANNELS] = current.alpha;
277
+ if (!current.alpha || !current.samples.length)
278
+ continue;
279
+ if (points) {
280
+ const local = new Map();
281
+ for (const sample of current.samples)
282
+ sample.point = points(sample, local);
283
+ }
284
+ const selected = points ? dominant(current.samples, axes) : current.samples;
285
+ if (recovery) {
286
+ const area = selected.reduce((sum, sample) => sum + sample.weight, 0);
287
+ const alpha = selected.reduce((sum, sample) => sum + sample.weight * image.data[sample.index * CHANNELS + COLOR_CHANNELS], 0);
288
+ data[offset + COLOR_CHANNELS] = clampByte(area ? alpha / area : 0);
289
+ }
290
+ const sums = [0, 0, 0];
291
+ let weight = 0;
292
+ for (const sample of selected) {
293
+ weight += sample.visibleWeight;
294
+ for (let c = 0; c < COLOR_CHANNELS; c++)
295
+ sums[c] += image.data[sample.index * CHANNELS + c] * sample.visibleWeight;
296
+ }
297
+ for (let c = 0; c < COLOR_CHANNELS; c++)
298
+ data[offset + c] = clampByte(weight ? sums[c] / weight : 0);
299
+ }
300
+ return { width, height, data };
301
+ }