@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,8 @@
1
+ import type { PixelBuffer } from "./base.ts";
2
+ import type { PixelGridAnalysis } from "./types.ts";
3
+ /** A cell cropped to less than half a pitch at the image border joins its neighbour. */
4
+ export declare function mergeSlivers(cuts: number[], pitch: number): number[];
5
+ /** One lattice (step + offset per axis) fitted to all edges; precise when the grid really is uniform. */
6
+ export declare function detectUniformGrid(image: PixelBuffer): PixelGridAnalysis;
7
+ /** Cuts anchored on edges, so irregular or slightly distorted cell sizes survive. */
8
+ export declare function detectMeshGrid(image: PixelBuffer): PixelGridAnalysis;
@@ -0,0 +1,665 @@
1
+ import { UINT8_MAX } from "./base.js";
2
+ import { assertPixelBuffer } from "./base.js";
3
+ import { colorStep, neighbourSteps, OPAQUE_STEPS, OPAQUE_STEPS_32, releaseNeighbourSteps, step32Of, stepOf, TRANSLUCENT, } from "./color-step.js";
4
+ import { MAX_GRID_CELL_SIZE, MAX_RECOVERED_GRID_SIZE, pixelGridCuts, } from "./grid.js";
5
+ import { fitWarp, warpAt, warpInverse, warpPitch, } from "./grid-warp.js";
6
+ /**
7
+ * Edge-projection grid detection. The mesh idea (anchor cuts on projected edges, fill the gaps with
8
+ * an even pitch) follows KennethJAllen/proper-pixel-art (MIT); its quantise-first step is not used.
9
+ * Soft edge ramps are merged into one weighted-centre edge, the pitch is refined by least squares
10
+ * on those centres, and a grid is accepted only when its cuts sit on sharp edges.
11
+ */
12
+ const CHANNELS = 4;
13
+ const MIN_PITCH = 2;
14
+ const MIN_CELLS = 4;
15
+ const MIN_GRID_CELLS = 16;
16
+ const EDGE_PEAK_MULTIPLIER = 4;
17
+ const EDGE_FLOOR = 0.002;
18
+ const PITCH_STEP = 0.02;
19
+ const PITCH_MODE_FRACTION = 0.6;
20
+ const CONSISTENT_GAP = 0.2;
21
+ const DEDUPE_FRACTION = 0.45;
22
+ const REFINE_PASSES = 6;
23
+ const SHARP_WINDOW = 1;
24
+ const MIN_SHARP_SHARE = 0.4;
25
+ const MIN_CONTRAST = 2;
26
+ const CONTRAST_FLOOR = 1e-4;
27
+ const FLAT_RANGE = 0.25;
28
+ const MIN_FLAT_SHARE = 0.8;
29
+ const MIN_LATTICE_SHARE = 0.85;
30
+ const MAX_LATTICE_RESIDUAL = 0.12;
31
+ const FLAT_SAMPLES = 5;
32
+ const HISTOGRAM_BINS = 256;
33
+ const MIN_STEP = 12 / UINT8_MAX;
34
+ const NOISE_MULTIPLIER = 2.5;
35
+ const SLIVER_FRACTION = 0.5;
36
+ const SQUARE_TOLERANCE = 1.3;
37
+ const SQUARE_SUPPORT = 0.3;
38
+ /** Pixels an edge may sit from the warped lattice's cut and still count as explained. */
39
+ const WARP_TOLERANCE = 0.75;
40
+ /** Variance in px² of a crisp edge about its true cut: the cut rounded to the raster. */
41
+ const ROUNDING_VARIANCE = 1 / 12;
42
+ /** Cells inward from the outermost edge over which a flat margin's pitch is averaged. */
43
+ const BORDER_CELLS = 6;
44
+ /** Weight of the pitch next to a flat margin against the mean pitch, for the margin's pitch. */
45
+ const LOCAL_MARGIN_WEIGHT = 0.7;
46
+ /** Share of the edge weight the warped lattice must explain on both axes for a mesh grid. */
47
+ const MIN_WARP_SHARE = 0.8;
48
+ /** Share of the edge energy in isolated one-pixel steps that marks a crisp profile. */
49
+ const CRISP_SHARE = 0.9;
50
+ /** A crisp edge's strength against the mean profile, below which it is ignored. */
51
+ const CRISP_FLOOR = 0.05;
52
+ /** Longest run of changed lines in a fine (below 2x) nearest-neighbour upscale. */
53
+ const FINE_RUN = 2;
54
+ /** Share of repeated lines that marks a fine upscale: a 1.5x upscale repeats a third. */
55
+ const MIN_REPEAT_SHARE = 0.25;
56
+ /** Smallest pitch of a fine upscale, where one-pixel cells start to meet. */
57
+ const FINE_MIN_PITCH = 1.5;
58
+ /**
59
+ * profile[i] is the mean colour step above the noise floor between pixel i-1 and pixel i, taken
60
+ * across the whole cross axis. The floor is twice the median step, so JPEG-like noise and smooth
61
+ * gradients contribute nothing while real edges do.
62
+ */
63
+ function edgeProfile(image, horizontal) {
64
+ const { width, height, data } = image;
65
+ const length = horizontal ? width : height;
66
+ const cross = horizontal ? height : width;
67
+ // Both directions walk the pixels row by row: the step into pixel (x, y) from its left (or upper)
68
+ // neighbour q is the pair code at q. Opaque pairs are counted by code and looked up; the profile
69
+ // sums every position's steps in line order.
70
+ const codes = horizontal ? neighbourSteps(image).right : neighbourSteps(image).down;
71
+ const neighbour = horizontal ? 1 : width;
72
+ const firstY = horizontal ? 0 : 1, firstX = horizontal ? 1 : 0;
73
+ const histogram = new Uint32Array(HISTOGRAM_BINS);
74
+ const byCode = new Uint32Array(TRANSLUCENT + 1);
75
+ for (let y = firstY; y < height; y++)
76
+ for (let q = y * width + firstX - neighbour, end = y * width + width - neighbour; q < end; q++)
77
+ byCode[codes[q]]++;
78
+ const bin = (step) => Math.min(HISTOGRAM_BINS - 1, Math.floor(step * HISTOGRAM_BINS));
79
+ for (let code = 0; code < TRANSLUCENT; code++)
80
+ histogram[bin(OPAQUE_STEPS[code])] += byCode[code];
81
+ if (byCode[TRANSLUCENT])
82
+ for (let y = firstY; y < height; y++)
83
+ for (let x = firstX, q = y * width + x - neighbour; x < width; x++, q++)
84
+ if (codes[q] === TRANSLUCENT)
85
+ histogram[bin(stepOf(codes, data, q, neighbour))]++;
86
+ let seen = 0;
87
+ let median = 0;
88
+ const half = (cross * (length - 1)) / 2;
89
+ for (let bin = 0; bin < HISTOGRAM_BINS; bin++) {
90
+ seen += histogram[bin];
91
+ if (seen >= half) {
92
+ median = (bin + 1) / HISTOGRAM_BINS;
93
+ break;
94
+ }
95
+ }
96
+ const floor = Math.max(MIN_STEP, NOISE_MULTIPLIER * median);
97
+ // The profile reads the steps rounded to float32, as they were once stored. A step at or below
98
+ // the floor adds zero, which leaves the (positive) sums unchanged.
99
+ const above = new Float64Array(TRANSLUCENT + 1);
100
+ for (let code = 0; code < TRANSLUCENT; code++) {
101
+ const step = OPAQUE_STEPS_32[code];
102
+ above[code] = step > floor ? step - floor : 0;
103
+ }
104
+ const translucent = byCode[TRANSLUCENT] > 0;
105
+ const contribution = (q) => {
106
+ const code = codes[q];
107
+ if (code < TRANSLUCENT || !translucent)
108
+ return above[code];
109
+ const step = step32Of(codes, data, q, neighbour);
110
+ return step > floor ? step - floor : 0;
111
+ };
112
+ const profile = new Float64Array(length);
113
+ if (horizontal)
114
+ for (let y = 0; y < height; y++)
115
+ for (let x = 1, q = y * width; x < width; x++, q++)
116
+ profile[x] += contribution(q);
117
+ else
118
+ for (let y = 1; y < height; y++) {
119
+ let sum = 0;
120
+ for (let q = (y - 1) * width, end = q + width; q < end; q++)
121
+ sum += contribution(q);
122
+ profile[y] = sum;
123
+ }
124
+ for (let i = 0; i < length; i++)
125
+ profile[i] /= cross;
126
+ return profile;
127
+ }
128
+ /** changed[i] is 1 when pixel line i differs anywhere from line i - 1 (changed[0] is 0). */
129
+ function lineChanges(image, horizontal) {
130
+ const { width, height, data } = image;
131
+ const steps = neighbourSteps(image);
132
+ const codes = horizontal ? steps.right : steps.down;
133
+ const neighbour = horizontal ? 1 : width;
134
+ const changed = new Uint8Array(horizontal ? width : height);
135
+ for (let y = horizontal ? 0 : 1; y < height; y++)
136
+ for (let x = horizontal ? 1 : 0; x < width; x++) {
137
+ const line = horizontal ? x : y;
138
+ if (changed[line])
139
+ continue;
140
+ const q = y * width + x - neighbour;
141
+ const code = codes[q];
142
+ if (code === TRANSLUCENT ? stepOf(codes, data, q, neighbour) > 0 : code > 0)
143
+ changed[line] = 1;
144
+ }
145
+ return changed;
146
+ }
147
+ function mergeEdges(profile) {
148
+ const length = profile.length;
149
+ let mean = 0;
150
+ for (const value of profile)
151
+ mean += value;
152
+ mean /= length;
153
+ const floor = Math.max(mean, EDGE_FLOOR);
154
+ const edges = [];
155
+ let start = -1, weight = 0, moment = 0, peak = 0;
156
+ const close = () => {
157
+ if (start >= 0 && peak >= EDGE_PEAK_MULTIPLIER * floor)
158
+ edges.push({ position: moment / weight, weight });
159
+ start = -1;
160
+ weight = moment = peak = 0;
161
+ };
162
+ for (let i = 1; i < length; i++) {
163
+ if (profile[i] > floor) {
164
+ if (start < 0)
165
+ start = i;
166
+ weight += profile[i];
167
+ moment += profile[i] * i;
168
+ peak = Math.max(peak, profile[i]);
169
+ }
170
+ else
171
+ close();
172
+ }
173
+ close();
174
+ return edges;
175
+ }
176
+ /**
177
+ * On a crisp (nearest-neighbour) upscale every colour change sits on a cut, one pixel wide, with
178
+ * nothing in between: then each change is an edge, however faint, instead of only the strong ones
179
+ * mergeEdges keeps. Null when the profile is not crisp.
180
+ */
181
+ function crispEdges(profile) {
182
+ const length = profile.length;
183
+ let total = 0, isolated = 0;
184
+ const value = (i) => (i >= 1 && i < length ? profile[i] : 0);
185
+ for (let i = 1; i < length; i++) {
186
+ total += profile[i];
187
+ if (value(i - 1) === 0 && value(i + 1) === 0)
188
+ isolated += profile[i];
189
+ }
190
+ if (!total || isolated < CRISP_SHARE * total)
191
+ return null;
192
+ const edges = [];
193
+ for (let i = 1; i < length; i++)
194
+ if (profile[i] > CRISP_FLOOR * (total / length))
195
+ edges.push({ position: i, weight: profile[i] });
196
+ return edges;
197
+ }
198
+ /**
199
+ * Below 2x a nearest-neighbour upscale has cells of one and two pixels, so changes come in pairs
200
+ * around one-pixel cells and crispEdges declines. Such an upscale still repeats most lines exactly:
201
+ * a line inside a cell equals its predecessor, and no two one-pixel cells meet above 1.5x, so every
202
+ * run of changed lines is at most two long. Art drawn at 1x, or a blurred upscale, changes almost
203
+ * every line. Null unless both hold; otherwise every changed line is an edge.
204
+ */
205
+ function fineEdges(profile, changed) {
206
+ const length = profile.length;
207
+ const first = changed.indexOf(1), last = changed.lastIndexOf(1);
208
+ if (first < 0 || last <= first)
209
+ return null;
210
+ let total = 0;
211
+ for (let i = 1; i < length; i++)
212
+ total += profile[i];
213
+ if (!total)
214
+ return null;
215
+ let changes = 0, paired = 0;
216
+ for (let i = first; i <= last;) {
217
+ if (!changed[i]) {
218
+ i++;
219
+ continue;
220
+ }
221
+ let end = i;
222
+ while (end + 1 <= last && changed[end + 1])
223
+ end++;
224
+ const run = end - i + 1;
225
+ changes += run;
226
+ if (run <= FINE_RUN)
227
+ paired += run;
228
+ i = end + 1;
229
+ }
230
+ const span = last - first + 1;
231
+ if ((span - changes) / span < MIN_REPEAT_SHARE || paired < CRISP_SHARE * changes)
232
+ return null;
233
+ const floor = CRISP_FLOOR * (total / length);
234
+ const edges = [];
235
+ for (let i = 1; i < length; i++)
236
+ if (changed[i])
237
+ edges.push({ position: i, weight: Math.max(profile[i], floor) });
238
+ return edges;
239
+ }
240
+ function edgeGaps(edges) {
241
+ const gaps = [];
242
+ for (let i = 1; i < edges.length; i++)
243
+ gaps.push({
244
+ gap: edges[i].position - edges[i - 1].position,
245
+ weight: Math.min(edges[i].weight, edges[i - 1].weight),
246
+ });
247
+ return gaps;
248
+ }
249
+ function pitchScore(gaps, pitch) {
250
+ const sigma = 0.08 * pitch + 0.25;
251
+ let score = 0;
252
+ for (const { gap, weight } of gaps) {
253
+ const z = (gap - pitch) / sigma;
254
+ if (Math.abs(z) < 4)
255
+ score += weight * Math.exp(-0.5 * z * z);
256
+ }
257
+ return score;
258
+ }
259
+ /** Most common edge-to-edge gap, preferring the smallest mode so multiples of the pitch lose. */
260
+ function estimatePitch(gaps, length, minPitch) {
261
+ const max = Math.min(MAX_GRID_CELL_SIZE, length / MIN_CELLS);
262
+ const scores = [];
263
+ const candidates = [];
264
+ for (let pitch = minPitch; pitch <= max; pitch += PITCH_STEP) {
265
+ candidates.push(pitch);
266
+ scores.push(pitchScore(gaps, pitch));
267
+ }
268
+ const best = Math.max(...scores, 0);
269
+ if (best <= 0)
270
+ return 0;
271
+ for (let i = 1; i < scores.length - 1; i++)
272
+ if (scores[i] >= PITCH_MODE_FRACTION * best &&
273
+ scores[i] >= scores[i - 1] &&
274
+ scores[i] > scores[i + 1])
275
+ return candidates[i];
276
+ return candidates[scores.indexOf(best)];
277
+ }
278
+ function refinePitch(edges, initial) {
279
+ let pitch = initial;
280
+ for (let pass = 0; pass < REFINE_PASSES; pass++) {
281
+ let gapSum = 0, cellSum = 0;
282
+ for (let i = 1; i < edges.length; i++) {
283
+ const gap = edges[i].position - edges[i - 1].position;
284
+ const cells = Math.round(gap / pitch);
285
+ if (cells >= 1 && Math.abs(gap - cells * pitch) <= CONSISTENT_GAP * pitch) {
286
+ gapSum += gap;
287
+ cellSum += cells;
288
+ }
289
+ }
290
+ if (!cellSum)
291
+ break;
292
+ pitch = gapSum / cellSum;
293
+ }
294
+ return pitch;
295
+ }
296
+ /**
297
+ * Weighted least-squares lattice position = offset + k * step over edges that agree with it. Crisp
298
+ * edges agree within rounding to the raster, and their residual discounts that rounding.
299
+ */
300
+ function fitLattice(edges, pitch, warp, crisp) {
301
+ if (edges.length < 3)
302
+ return null;
303
+ const tolerance = (step) => Math.max(CONSISTENT_GAP * step, crisp ? WARP_TOLERANCE : 0);
304
+ let step = pitch;
305
+ let offset = edges[0].position;
306
+ // Index the edges by the warped lattice (or, without one, by chaining neighbouring edges) first,
307
+ // so a small pitch error cannot drift across the image.
308
+ const index = Array.from({ length: edges.length }, () => 0);
309
+ for (let pass = 0; pass < REFINE_PASSES; pass++) {
310
+ if (pass === 0 && warp)
311
+ for (let i = 0; i < edges.length; i++)
312
+ index[i] = Math.round(warpAt(warp, edges[i].position));
313
+ else if (pass === 0)
314
+ for (let i = 1; i < edges.length; i++)
315
+ index[i] =
316
+ index[i - 1] +
317
+ Math.max(1, Math.round((edges[i].position - edges[i - 1].position) / step));
318
+ else
319
+ for (let i = 0; i < edges.length; i++)
320
+ index[i] = Math.round((edges[i].position - offset) / step);
321
+ let sw = 0, sk = 0, sp = 0, skk = 0, skp = 0;
322
+ for (let i = 0; i < edges.length; i++) {
323
+ const residual = edges[i].position - (offset + index[i] * step);
324
+ if (pass > 0 && Math.abs(residual) > tolerance(step))
325
+ continue;
326
+ const w = edges[i].weight;
327
+ sw += w;
328
+ sk += w * index[i];
329
+ sp += w * edges[i].position;
330
+ skk += w * index[i] * index[i];
331
+ skp += w * index[i] * edges[i].position;
332
+ }
333
+ const denominator = sw * skk - sk * sk;
334
+ if (!sw || Math.abs(denominator) < 1e-9)
335
+ return null;
336
+ step = (sw * skp - sk * sp) / denominator;
337
+ offset = (sp - step * sk) / sw;
338
+ }
339
+ let total = 0, fit = 0, squares = 0;
340
+ for (const edge of edges) {
341
+ const k = Math.round((edge.position - offset) / step);
342
+ const residual = edge.position - (offset + k * step);
343
+ total += edge.weight;
344
+ if (Math.abs(residual) <= tolerance(step)) {
345
+ fit += edge.weight;
346
+ squares += edge.weight * residual * residual;
347
+ }
348
+ }
349
+ if (!fit)
350
+ return null;
351
+ return {
352
+ step,
353
+ offset,
354
+ share: fit / total,
355
+ residual: Math.sqrt(Math.max(0, squares / fit - (crisp ? ROUNDING_VARIANCE : 0))) / step,
356
+ };
357
+ }
358
+ /**
359
+ * Cuts anchored on every edge. A gap spanning several cells is split evenly in the warped lattice's
360
+ * index, so its cell count follows the local pitch where the cell size drifts.
361
+ */
362
+ function buildMesh(edges, pitch, length, warp) {
363
+ const anchors = [];
364
+ for (const edge of edges) {
365
+ const last = anchors[anchors.length - 1];
366
+ const local = warp ? Math.min(pitch, warpPitch(warp, edge.position)) : pitch;
367
+ if (last && edge.position - last.position < DEDUPE_FRACTION * local) {
368
+ if (edge.weight > last.weight)
369
+ anchors[anchors.length - 1] = edge;
370
+ }
371
+ else
372
+ anchors.push(edge);
373
+ }
374
+ const marks = [0, ...anchors.map((edge) => edge.position), length];
375
+ const cuts = [0];
376
+ for (let i = 1; i < marks.length; i++) {
377
+ const left = marks[i - 1], right = marks[i];
378
+ const border = i === 1 || i === marks.length - 1;
379
+ const borderPitch = warp && border
380
+ ? marginPitch(warp, i === 1 ? right : left, i === 1, length, marks[1], marks[marks.length - 2])
381
+ : 0;
382
+ if (borderPitch) {
383
+ const cells = Math.max(1, Math.round((right - left) / borderPitch));
384
+ for (let n = 1; n < cells; n++)
385
+ pushCut(cuts, left + ((right - left) * n) / cells, length);
386
+ }
387
+ else if (warp) {
388
+ const from = warpAt(warp, left), to = warpAt(warp, right);
389
+ const cells = Math.max(1, Math.round(to - from));
390
+ for (let n = 1; n < cells; n++) {
391
+ const cut = warpInverse(warp, from + ((to - from) * n) / cells, left, right);
392
+ if (Number.isFinite(cut))
393
+ pushCut(cuts, cut, length);
394
+ }
395
+ }
396
+ else {
397
+ const cells = Math.max(1, Math.round((right - left) / pitch));
398
+ for (let n = 1; n < cells; n++)
399
+ pushCut(cuts, left + ((right - left) * n) / cells, length);
400
+ }
401
+ if (i < marks.length - 1)
402
+ pushCut(cuts, right, length);
403
+ }
404
+ cuts.push(length);
405
+ return mergeSlivers(cuts, warp ? Math.min(pitch, warpPitch(warp, 0), warpPitch(warp, length)) : pitch);
406
+ }
407
+ /**
408
+ * Mean pitch over BORDER_CELLS cells inward from the outermost edge `anchor`. Past that edge u is
409
+ * extrapolated from the last knot interval, whose slope a few rounded edges set; a longer span
410
+ * counts a flat margin more reliably. 0 when the lattice does not reach that far inward.
411
+ */
412
+ function innerPitch(warp, anchor, leading, length) {
413
+ const index = warpAt(warp, anchor);
414
+ const inner = leading
415
+ ? warpInverse(warp, index + BORDER_CELLS, anchor, length)
416
+ : warpInverse(warp, index - BORDER_CELLS, 0, anchor);
417
+ return Number.isFinite(inner) ? Math.abs(inner - anchor) / BORDER_CELLS : 0;
418
+ }
419
+ /**
420
+ * Pitch across a flat margin beyond the outermost edge `anchor`, whose cells show no edges. The
421
+ * pitch next to the margin predicts it, but less the wider the margin, so the estimate is shrunk
422
+ * toward the mean pitch between the outermost edges `first` and `last`.
423
+ */
424
+ function marginPitch(warp, anchor, leading, length, first, last) {
425
+ const local = innerPitch(warp, anchor, leading, length);
426
+ if (!local)
427
+ return 0;
428
+ const cells = warpAt(warp, last) - warpAt(warp, first);
429
+ const mean = cells > 0 ? (last - first) / cells : local;
430
+ return mean + LOCAL_MARGIN_WEIGHT * (local - mean);
431
+ }
432
+ /** Share of the edge weight within rounding of an integer index of the warped lattice. */
433
+ function warpShare(edges, warp) {
434
+ let total = 0, fit = 0;
435
+ for (const edge of edges) {
436
+ const u = warpAt(warp, edge.position);
437
+ const pitch = warpPitch(warp, edge.position);
438
+ const pixels = Math.abs(u - Math.round(u)) * pitch;
439
+ total += edge.weight;
440
+ if (pixels <= Math.max(WARP_TOLERANCE, CONSISTENT_GAP * pitch))
441
+ fit += edge.weight;
442
+ }
443
+ return total ? fit / total : 0;
444
+ }
445
+ /** A cell cropped to less than half a pitch at the image border joins its neighbour. */
446
+ export function mergeSlivers(cuts, pitch) {
447
+ const merged = [...cuts];
448
+ if (merged.length > 2 && merged[1] - merged[0] < SLIVER_FRACTION * pitch)
449
+ merged.splice(1, 1);
450
+ const n = merged.length;
451
+ if (n > 2 && merged[n - 1] - merged[n - 2] < SLIVER_FRACTION * pitch)
452
+ merged.splice(n - 2, 1);
453
+ return merged;
454
+ }
455
+ function pushCut(cuts, position, length) {
456
+ const cut = Math.round(position);
457
+ if (cut > cuts[cuts.length - 1] && cut < length)
458
+ cuts.push(cut);
459
+ }
460
+ function draftAxis(image, horizontal) {
461
+ const length = horizontal ? image.width : image.height;
462
+ const profile = edgeProfile(image, horizontal);
463
+ const crisp = crispEdges(profile);
464
+ const fine = crisp ? null : fineEdges(profile, lineChanges(image, horizontal));
465
+ const edges = crisp ?? fine ?? mergeEdges(profile);
466
+ if (edges.length < MIN_CELLS)
467
+ return null;
468
+ const gaps = edgeGaps(edges);
469
+ const minPitch = fine ? FINE_MIN_PITCH : MIN_PITCH;
470
+ const pitch = estimatePitch(gaps, length, minPitch);
471
+ if (!pitch)
472
+ return null;
473
+ return {
474
+ length,
475
+ profile,
476
+ edges,
477
+ gaps,
478
+ pitch,
479
+ best: pitchScore(gaps, pitch),
480
+ minPitch,
481
+ crisp: !!(crisp ?? fine),
482
+ };
483
+ }
484
+ function finishAxis(draft, requested) {
485
+ const refined = refinePitch(draft.edges, requested);
486
+ const { minPitch, crisp } = draft;
487
+ const seed = Number.isFinite(refined) && refined >= minPitch ? refined : requested;
488
+ const warp = fitWarp(draft.edges, draft.length, seed, minPitch, crisp);
489
+ const lattice = Number.isFinite(refined) ? fitLattice(draft.edges, refined, warp, crisp) : null;
490
+ // A true lattice supplies its own exact pitch; otherwise keep the shared square-cell estimate.
491
+ const pitch = lattice && lattice.share >= MIN_LATTICE_SHARE ? lattice.step : requested;
492
+ if (!Number.isFinite(pitch) || pitch < minPitch || pitch > MAX_GRID_CELL_SIZE)
493
+ return null;
494
+ return {
495
+ length: draft.length,
496
+ profile: draft.profile,
497
+ edges: draft.edges,
498
+ pitch,
499
+ lattice,
500
+ meshCuts: buildMesh(draft.edges, pitch, draft.length, warp),
501
+ warpShare: warp ? warpShare(draft.edges, warp) : 0,
502
+ };
503
+ }
504
+ /**
505
+ * Pixel-art cells are square, so both axes vote on one pitch. An axis keeps its own estimate when
506
+ * that agrees with the pooled one, and otherwise adopts the pooled pitch if its edges support it.
507
+ */
508
+ function sharedPitches(x, y) {
509
+ const pooled = estimatePitch([...x.gaps, ...y.gaps], Math.max(x.length, y.length), Math.max(x.minPitch, y.minPitch));
510
+ if (!pooled)
511
+ return [x.pitch, y.pitch];
512
+ const pick = (axis) => Math.max(axis.pitch, pooled) / Math.min(axis.pitch, pooled) <= SQUARE_TOLERANCE ||
513
+ pitchScore(axis.gaps, pooled) < SQUARE_SUPPORT * axis.best
514
+ ? axis.pitch
515
+ : pooled;
516
+ return [pick(x), pick(y)];
517
+ }
518
+ /**
519
+ * How sharply the cuts sit on edges: the share of all edge energy within one pixel of a cut, and
520
+ * the peak at the cuts against the energy at the cell middles (1 means edges ignore the grid).
521
+ */
522
+ function sharpness(axis, cuts) {
523
+ let total = 0;
524
+ for (const value of axis.profile)
525
+ total += value;
526
+ if (!total)
527
+ return { share: 0, contrast: 0 };
528
+ const value = (i) => (i >= 1 && i < axis.length ? axis.profile[i] : 0);
529
+ let peakSum = 0, midSum = 0, near = 0, mids = 0;
530
+ const covered = new Set();
531
+ for (let i = 0; i < cuts.length; i++) {
532
+ const peak = Math.max(value(cuts[i] - 1), value(cuts[i]), value(cuts[i] + 1));
533
+ peakSum += peak;
534
+ for (let d = -SHARP_WINDOW; d <= SHARP_WINDOW; d++)
535
+ if (!covered.has(cuts[i] + d)) {
536
+ covered.add(cuts[i] + d);
537
+ near += value(cuts[i] + d);
538
+ }
539
+ if (i + 1 < cuts.length) {
540
+ midSum += value(Math.floor((cuts[i] + cuts[i + 1]) / 2));
541
+ mids++;
542
+ }
543
+ }
544
+ const peakMean = peakSum / cuts.length;
545
+ const midMean = midSum / Math.max(1, mids);
546
+ return {
547
+ share: Math.min(1, near / total),
548
+ contrast: peakMean / Math.max(midMean, peakMean * 1e-3, CONTRAST_FLOOR),
549
+ };
550
+ }
551
+ /** Share of cells whose central region is one flat colour. */
552
+ function flatShare(image, xCuts, yCuts) {
553
+ const width = xCuts.length - 1, height = yCuts.length - 1;
554
+ const budget = 4096;
555
+ const stride = Math.max(1, Math.ceil((width * height) / budget));
556
+ let flat = 0, measured = 0;
557
+ for (let cell = 0; cell < width * height; cell += stride) {
558
+ const cx = cell % width, cy = Math.floor(cell / width);
559
+ const x0 = xCuts[cx], x1 = xCuts[cx + 1], y0 = yCuts[cy], y1 = yCuts[cy + 1];
560
+ const mx = (x1 - x0) / 4, my = (y1 - y0) / 4;
561
+ let reference = -1, ok = true;
562
+ for (let j = 0; j < FLAT_SAMPLES && ok; j++)
563
+ for (let i = 0; i < FLAT_SAMPLES && ok; i++) {
564
+ const sx = Math.min(image.width - 1, Math.floor(x0 + mx + ((x1 - x0 - 2 * mx) * i) / (FLAT_SAMPLES - 1) - 1e-6));
565
+ const sy = Math.min(image.height - 1, Math.floor(y0 + my + ((y1 - y0 - 2 * my) * j) / (FLAT_SAMPLES - 1) - 1e-6));
566
+ const index = (Math.max(0, sy) * image.width + Math.max(0, sx)) * CHANNELS;
567
+ if (reference < 0)
568
+ reference = index;
569
+ else if (colorStep(image.data, reference, index) > FLAT_RANGE)
570
+ ok = false;
571
+ }
572
+ measured++;
573
+ if (ok)
574
+ flat++;
575
+ }
576
+ return measured ? flat / measured : 0;
577
+ }
578
+ const NO_GRID = (image) => ({
579
+ width: 1,
580
+ height: 1,
581
+ cellWidth: image.width,
582
+ cellHeight: image.height,
583
+ offsetX: 0,
584
+ offsetY: 0,
585
+ confidence: 0,
586
+ reliable: false,
587
+ });
588
+ const cache = new WeakMap();
589
+ function analysisOf(image) {
590
+ assertPixelBuffer(image);
591
+ let found = cache.get(image);
592
+ if (!found) {
593
+ const x = draftAxis(image, true), y = draftAxis(image, false);
594
+ if (x && y) {
595
+ const [px, py] = sharedPitches(x, y);
596
+ found = { x: finishAxis(x, px), y: finishAxis(y, py) };
597
+ }
598
+ else
599
+ found = { x: null, y: null };
600
+ releaseNeighbourSteps(image);
601
+ cache.set(image, found);
602
+ }
603
+ return found;
604
+ }
605
+ function judge(image, x, y, xCuts, yCuts, extra) {
606
+ const width = xCuts.length - 1, height = yCuts.length - 1;
607
+ const sx = sharpness(x, xCuts), sy = sharpness(y, yCuts);
608
+ const sharp = Math.min(sx.share, sy.share);
609
+ const flat = flatShare(image, xCuts, yCuts);
610
+ const confidence = sharp * flat * extra;
611
+ const reliable = width >= MIN_CELLS &&
612
+ height >= MIN_CELLS &&
613
+ width * height >= MIN_GRID_CELLS &&
614
+ width <= MAX_RECOVERED_GRID_SIZE &&
615
+ height <= MAX_RECOVERED_GRID_SIZE &&
616
+ sx.share >= MIN_SHARP_SHARE &&
617
+ sy.share >= MIN_SHARP_SHARE &&
618
+ sx.contrast >= MIN_CONTRAST &&
619
+ sy.contrast >= MIN_CONTRAST &&
620
+ flat >= MIN_FLAT_SHARE;
621
+ return { confidence, reliable };
622
+ }
623
+ /** One lattice (step + offset per axis) fitted to all edges; precise when the grid really is uniform. */
624
+ export function detectUniformGrid(image) {
625
+ const { x, y } = analysisOf(image);
626
+ if (!x?.lattice || !y?.lattice)
627
+ return NO_GRID(image);
628
+ const lx = x.lattice, ly = y.lattice;
629
+ const xCuts = mergeSlivers(pixelGridCuts(image.width, lx.step, lx.offset), lx.step), yCuts = mergeSlivers(pixelGridCuts(image.height, ly.step, ly.offset), ly.step);
630
+ const consistency = Math.min(lx.share, ly.share);
631
+ const residual = Math.max(lx.residual, ly.residual);
632
+ const verdict = judge(image, x, y, xCuts, yCuts, consistency);
633
+ return {
634
+ width: xCuts.length - 1,
635
+ height: yCuts.length - 1,
636
+ cellWidth: lx.step,
637
+ cellHeight: ly.step,
638
+ offsetX: lx.offset,
639
+ offsetY: ly.offset,
640
+ cutsX: xCuts,
641
+ cutsY: yCuts,
642
+ confidence: verdict.confidence,
643
+ reliable: verdict.reliable && consistency >= MIN_LATTICE_SHARE && residual <= MAX_LATTICE_RESIDUAL,
644
+ };
645
+ }
646
+ /** Cuts anchored on edges, so irregular or slightly distorted cell sizes survive. */
647
+ export function detectMeshGrid(image) {
648
+ const { x, y } = analysisOf(image);
649
+ if (!x || !y)
650
+ return NO_GRID(image);
651
+ const verdict = judge(image, x, y, x.meshCuts, y.meshCuts, 1);
652
+ const explained = Math.min(x.warpShare, y.warpShare) >= MIN_WARP_SHARE;
653
+ return {
654
+ width: x.meshCuts.length - 1,
655
+ height: y.meshCuts.length - 1,
656
+ cellWidth: image.width / (x.meshCuts.length - 1),
657
+ cellHeight: image.height / (y.meshCuts.length - 1),
658
+ offsetX: 0,
659
+ offsetY: 0,
660
+ cutsX: x.meshCuts,
661
+ cutsY: y.meshCuts,
662
+ confidence: verdict.confidence,
663
+ reliable: verdict.reliable && explained,
664
+ };
665
+ }
@@ -0,0 +1,4 @@
1
+ import type { PixelBuffer } from "./base.ts";
2
+ import type { PixelGridAnalysis } from "./types.ts";
3
+ /** The proposals Auto reads, per proposer: those the image's edges do not reject (see above). */
4
+ export declare function coherentGrids(image: PixelBuffer, proposals: readonly (readonly PixelGridAnalysis[])[]): PixelGridAnalysis[][];