@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,716 @@
1
+ import { forgetNeighbourSteps } from "./color-step.js";
2
+ import { estimateTilt, straighten } from "./deskew.js";
3
+ import { blockPartition, MAX_RECOVERED_GRID_SIZE } from "./grid.js";
4
+ import { beginAcRlConsensusJob, beginAcRlConsensusJobAsync, finishAcRlConsensusJob, finishAcRlConsensusJobAsync, } from "./grid-ac-rl-consensus.js";
5
+ import { arbitrateGrids, GridEvidenceReader, octaveShortfall, phaseScore, twinCuts, } from "./grid-arbitrate.js";
6
+ import { CutModel, describeImage, descriptionLength, } from "./grid-description-length.js";
7
+ import { detectMeshGrid, detectUniformGrid } from "./grid-detect.js";
8
+ import { coherentGrids } from "./grid-edge-coherence.js";
9
+ import { edgePathGrids } from "./grid-edge-path.js";
10
+ import { detectFrameLattices } from "./grid-frame-lattice.js";
11
+ import { detectBoundaryPeriodGrids } from "./grid-period-boundary.js";
12
+ import { combCountGrids } from "./grid-period-comb.js";
13
+ import { distanceCountGrids } from "./grid-period-distance.js";
14
+ import { pitchLattices } from "./grid-pitch-lattice.js";
15
+ import { recoverGrid } from "./grid-sample.js";
16
+ import { prepareGridSampling } from "./grid-sampling.js";
17
+ import { resizeBox } from "./resize.js";
18
+ import { CellColorPicker, PixelationAlgorithm, } from "./types.js";
19
+ const unreliable = (image) => ({
20
+ width: 1,
21
+ height: 1,
22
+ cellWidth: image.width,
23
+ cellHeight: image.height,
24
+ offsetX: 0,
25
+ offsetY: 0,
26
+ confidence: 0,
27
+ reliable: false,
28
+ algorithm: PixelationAlgorithm.Resize,
29
+ });
30
+ /**
31
+ * One detection call's shared work: Auto runs several grid algorithms on the same image, and each
32
+ * straightens it the same way (the tilt and the edge analysis are cached per image). Kept only
33
+ * while the call runs; recovery straightens its own copy.
34
+ */
35
+ let detection = null;
36
+ function uprightOf(image, angle) {
37
+ if (detection?.image !== image)
38
+ return straighten(image, angle);
39
+ if (!detection.upright)
40
+ detection.upright = straighten(image, angle);
41
+ return detection.upright;
42
+ }
43
+ /**
44
+ * Every proposer Auto runs. Each proposer runs on each evaluation view; their candidates compete
45
+ * equally in arbitration.
46
+ */
47
+ const BOUNDARY_PROPOSERS = [
48
+ (image) => {
49
+ const grid = detectUniformGrid(image);
50
+ return grid.reliable
51
+ ? [{ ...grid, algorithm: PixelationAlgorithm.UniformGrid, reliable: true }]
52
+ : [];
53
+ },
54
+ (image) => {
55
+ const grid = detectMeshGrid(image);
56
+ return grid.reliable
57
+ ? [{ ...grid, algorithm: PixelationAlgorithm.EdgeGrid, reliable: true }]
58
+ : [];
59
+ },
60
+ ];
61
+ const PROPOSERS = [
62
+ ...BOUNDARY_PROPOSERS,
63
+ detectFrameLattices,
64
+ pitchLattices,
65
+ combCountGrids,
66
+ detectBoundaryPeriodGrids,
67
+ distanceCountGrids,
68
+ ];
69
+ /**
70
+ * Detect on a straightened copy when the image carries a convincing tilt; the grid then records
71
+ * the rotation so recovery straightens the same way. A tilt beyond the search range, or a
72
+ * straightened image that still has no reliable grid, is refused rather than guessed from the
73
+ * tilted pixels (those yield confident but wrong grids). Used for manual algorithm selection.
74
+ */
75
+ const deskewed = (algorithm, detect) => (image) => {
76
+ const tilt = estimateTilt(image);
77
+ if (tilt.extreme)
78
+ return unreliable(image);
79
+ if (!tilt.angle)
80
+ return { ...detect(image), algorithm };
81
+ const found = detect(uprightOf(image, tilt.angle));
82
+ return found.reliable ? { ...found, rotation: tilt.angle, algorithm } : unreliable(image);
83
+ };
84
+ /**
85
+ * One of Auto's multi-candidate proposers on its own: its candidates are read on the evidence Auto
86
+ * uses and the best reading wins. The user chose this proposer, so the passthrough does not compete.
87
+ */
88
+ const bestOf = (propose) => (image) => {
89
+ const grids = propose(image).filter((grid) => grid.reliable);
90
+ if (!grids.length)
91
+ return unreliable(image);
92
+ let best = null;
93
+ for (const reading of new GridEvidenceReader(image).read(grids).readings) {
94
+ const candidate = scoredReading(reading);
95
+ if (betterReading(candidate, best))
96
+ best = candidate;
97
+ }
98
+ return best?.grid ?? unreliable(image);
99
+ };
100
+ const FALLBACK = PixelationAlgorithm.Resize;
101
+ const DEFAULT_CUSTOM_SIZE = 64;
102
+ /** Even blocks of the requested size; without one, the size a photo is shrunk to by default. */
103
+ const customSize = (image, size) => {
104
+ const scale = Math.min(1, DEFAULT_CUSTOM_SIZE / Math.max(image.width, image.height));
105
+ return {
106
+ ...blockPartition(image, size?.width ?? image.width * scale, size?.height ?? image.height * scale),
107
+ confidence: 1,
108
+ reliable: true,
109
+ };
110
+ };
111
+ /**
112
+ * Minimum number of proposers that must agree on a lattice size for corroborated admission.
113
+ * When arbitration refuses (passthrough wins), if this many proposers agree on a strict lattice,
114
+ * admit the best-phaseScore corroborated lattice instead of falling through to passthrough.
115
+ */
116
+ const MIN_CORROBORATORS = 2;
117
+ /**
118
+ * The AC+RL consensus counts cells across the whole image. A sprite exported on a clear canvas
119
+ * has soft edges against the clear margin, which shift that count by one cell, so on such an
120
+ * image (at least CLEAR_MARGIN_SHARE of its pixels clear) a consensus within one cell of the
121
+ * arbitrated winner does not replace it. A farther consensus still does.
122
+ */
123
+ const CLEAR_MARGIN_SHARE = 0.25;
124
+ const ALPHA_CHANNEL = 3;
125
+ const STRIDE = 4;
126
+ const clearMargins = new WeakMap();
127
+ function hasClearMargin(image) {
128
+ let known = clearMargins.get(image);
129
+ if (known === undefined) {
130
+ let clear = 0;
131
+ for (let o = ALPHA_CHANNEL; o < image.data.length; o += STRIDE)
132
+ if (!image.data[o])
133
+ clear++;
134
+ known = clear >= CLEAR_MARGIN_SHARE * image.width * image.height;
135
+ clearMargins.set(image, known);
136
+ }
137
+ return known;
138
+ }
139
+ /** Whether the consensus counts replace the arbitrated winner (see CLEAR_MARGIN_SHARE). */
140
+ function consensusOverrides(image, winner, consensus) {
141
+ const columns = Math.abs(consensus.cols - winner.width), rows = Math.abs(consensus.rows - winner.height);
142
+ if (!columns && !rows)
143
+ return false;
144
+ return !hasClearMargin(image) || columns > 1 || rows > 1;
145
+ }
146
+ /**
147
+ * Maximum candidates to pass to arbitration. Description-length scoring scales O(pixels),
148
+ * and each candidate requires strip computation + phase field. Pruning reduces latency
149
+ * while preserving high-confidence and diverse-dimension candidates.
150
+ */
151
+ const MAX_ARBITRATION_CANDIDATES = 10;
152
+ /**
153
+ * Exact A/B: pass-through (no prune). Peak d698be86 scored without pruning;
154
+ * tip prune path (39ba4659) is bypassed so arbitration sees the full candidate list.
155
+ * MAX_ARBITRATION_CANDIDATES / wasmConsensus args retained for call-site compatibility.
156
+ */
157
+ function pruneCandidatesBeforeArbitration(candidates, _wasmConsensus) {
158
+ void MAX_ARBITRATION_CANDIDATES;
159
+ return candidates;
160
+ }
161
+ /** A dimension-only consensus describes one complete, uniformly partitioned frame. */
162
+ function consensusLattice(image, columns, rows) {
163
+ if (!Number.isSafeInteger(columns) ||
164
+ !Number.isSafeInteger(rows) ||
165
+ Math.min(columns, rows) < 2 ||
166
+ columns > Math.min(image.width, MAX_RECOVERED_GRID_SIZE) ||
167
+ rows > Math.min(image.height, MAX_RECOVERED_GRID_SIZE))
168
+ return null;
169
+ return {
170
+ ...blockPartition(image, columns, rows),
171
+ confidence: 0,
172
+ reliable: true,
173
+ algorithm: PixelationAlgorithm.UniformGrid,
174
+ };
175
+ }
176
+ function scoredReading(reading) {
177
+ const { cutsX, cutsY } = reading.grid;
178
+ const score = phaseScore(reading) + octaveShortfall(reading.frame(), cutsX, cutsY);
179
+ const share = reading.twin > 0 ? Math.max(0, Math.min(1, (reading.twin - reading.length) / reading.twin)) : 0;
180
+ return {
181
+ grid: {
182
+ ...reading.grid,
183
+ confidence: Math.max(reading.grid.confidence, share),
184
+ reliable: true,
185
+ },
186
+ length: reading.length,
187
+ score,
188
+ };
189
+ }
190
+ const geometryKey = (grid) => `${grid.algorithm}:${grid.cutsX?.join(",")}:${grid.cutsY?.join(",")}`;
191
+ /** A deterministic tie keeps proposer ordering out of model/geometry selection. */
192
+ function betterReading(candidate, current) {
193
+ return (!current ||
194
+ candidate.score < current.score ||
195
+ (candidate.score === current.score &&
196
+ (candidate.length < current.length ||
197
+ (candidate.length === current.length &&
198
+ geometryKey(candidate.grid) < geometryKey(current.grid)))));
199
+ }
200
+ function consensusWinner(evidence, image, candidates, consensus) {
201
+ const matching = candidates.filter((grid) => grid.width === consensus.cols && grid.height === consensus.rows && grid.reliable);
202
+ let readings;
203
+ if (matching.length) {
204
+ readings = evidence.read(matching).readings;
205
+ }
206
+ else {
207
+ // A dimension-only consensus carries no placement or local-deformation evidence. Score
208
+ // its whole-frame partition on the original view and keep that exact geometry for coloring.
209
+ const grid = consensusLattice(image, consensus.cols, consensus.rows);
210
+ if (!grid)
211
+ return null;
212
+ const described = describeImage(image);
213
+ const { cutsX, cutsY } = grid;
214
+ readings = [
215
+ {
216
+ grid,
217
+ length: descriptionLength(described, {
218
+ cutsX: cutsX,
219
+ cutsY: cutsY,
220
+ model: CutModel.Lattice,
221
+ }),
222
+ twin: descriptionLength(described, {
223
+ cutsX: twinCuts(cutsX),
224
+ cutsY: twinCuts(cutsY),
225
+ model: CutModel.Lattice,
226
+ }),
227
+ chance: 0,
228
+ frame: () => described,
229
+ },
230
+ ];
231
+ }
232
+ let best = null;
233
+ for (const reading of readings) {
234
+ if (reading.grid.width !== consensus.cols || reading.grid.height !== consensus.rows)
235
+ continue;
236
+ const candidate = scoredReading(reading);
237
+ if (betterReading(candidate, best))
238
+ best = candidate;
239
+ }
240
+ return best;
241
+ }
242
+ /**
243
+ * Every proposer's grids for one view, with the edge paths at the pitches the view's edges keep
244
+ * (see grid-edge-path) as one more proposer's output, less the grids the edges reject.
245
+ */
246
+ function proposalsOf(image, outputs) {
247
+ const proposed = PROPOSERS.map((proposer, index) => outputs?.[index] ?? proposer(image));
248
+ const paths = edgePathGrids(image, coherentGrids(image, proposed).flat());
249
+ return coherentGrids(image, [...proposed, paths]);
250
+ }
251
+ /**
252
+ * Auto: every proposer runs on each evaluation view (扶正 and/or 原图), their candidates compete
253
+ * in one arbitration per view (see grid-arbitrate), and the passthrough is one more candidate in
254
+ * each. When the image carries a tilt, the upright view is tried. Without boundary or independent
255
+ * count support there, corroborated readings of the original view may also compete.
256
+ *
257
+ * Performance: Call preloadWasm() during app/worker initialization to prewarm WASM module.
258
+ * On Node, AC+RL WASM detect starts on a worker_threads worker before proposers run on this
259
+ * thread (begin/finish sync join). Product Auto uses autoGridAsync + browser nested Worker
260
+ * Promise join (no COOP/COEP SAB). Exact semantics identical.
261
+ */
262
+ function autoGrid(image) {
263
+ const tilt = estimateTilt(image);
264
+ if (tilt.extreme)
265
+ return unreliable(image);
266
+ // Determine evaluation views
267
+ const rings = [];
268
+ if (tilt.angle) {
269
+ const upright = uprightOf(image, tilt.angle);
270
+ const wasmJob = beginAcRlConsensusJob(upright);
271
+ const outputs = PROPOSERS.map((proposer) => proposer(upright));
272
+ rings.push({ image: upright, rotation: tilt.angle, outputs, wasmJob });
273
+ if (!outputs.some((grids) => grids.some((grid) => grid.reliable)))
274
+ rings.push({ image, rotation: 0 });
275
+ }
276
+ else {
277
+ rings.push({ image, rotation: 0 });
278
+ }
279
+ const corroboratingProposers = [
280
+ detectFrameLattices,
281
+ pitchLattices,
282
+ combCountGrids,
283
+ detectBoundaryPeriodGrids,
284
+ distanceCountGrids,
285
+ ];
286
+ // Arbitrate each view: all proposers run on this view
287
+ let best = null;
288
+ for (const ring of rings) {
289
+ // Overlap: start WASM AC+RL on a Node worker (no-op→sync fallback in browser) while proposers run
290
+ const wasmJob = ring.wasmJob ?? beginAcRlConsensusJob(ring.image);
291
+ // Run each proposer once and track outputs with their corroboration index
292
+ const proposerOutputs = proposalsOf(ring.image, ring.outputs).map((grids, index) => ({
293
+ grids,
294
+ corrobIndex: corroboratingProposers.indexOf(PROPOSERS[index]),
295
+ }));
296
+ // Build candidates list from all proposer outputs
297
+ const candidates = proposerOutputs.flatMap(({ grids }) => grids.map((grid) => (ring.rotation ? { ...grid, rotation: ring.rotation } : grid)));
298
+ // Tag candidates from corroborating proposers (reuse outputs, no re-run)
299
+ const tagged = proposerOutputs
300
+ .filter(({ corrobIndex }) => corrobIndex >= 0)
301
+ .flatMap(({ grids, corrobIndex }) => grids.map((grid) => ({
302
+ grid: ring.rotation ? { ...grid, rotation: ring.rotation } : grid,
303
+ proposerIndex: corrobIndex,
304
+ })));
305
+ // Join WASM consensus (already running during proposers when worker overlap is active)
306
+ const consensus = finishAcRlConsensusJob(wasmJob);
307
+ // Preserve the original view when rotation has neither boundary support nor independent
308
+ // count consensus; fitted lattices alone do not establish the rotation.
309
+ if (ring.rotation &&
310
+ !consensus &&
311
+ !rings.some((view) => view.rotation === 0) &&
312
+ !proposerOutputs
313
+ .slice(0, BOUNDARY_PROPOSERS.length)
314
+ .some(({ grids }) => grids.some((grid) => grid.reliable)))
315
+ rings.push({ image, rotation: 0, requiresCountAgreement: true });
316
+ // WASM-first short-circuit: when consensus fires and candidate@G exists, skip expensive full arb
317
+ const evidence = new GridEvidenceReader(ring.image);
318
+ let winner = null;
319
+ if (consensus) {
320
+ const candidatesAtG = candidates.filter((c) => c.width === consensus.cols && c.height === consensus.rows && c.reliable);
321
+ if (candidatesAtG.length) {
322
+ // Score just the G candidate + near-G alternatives (±1 dimension) for safety
323
+ const nearGCandidates = candidates.filter((c) => c.reliable &&
324
+ Math.abs(c.width - consensus.cols) <= 1 &&
325
+ Math.abs(c.height - consensus.rows) <= 1);
326
+ // Short-circuit: arbitrate only near-G subset (much cheaper than full candidate list)
327
+ winner = arbitrateGrids(ring.image, nearGCandidates.length > 0 ? nearGCandidates : candidatesAtG, evidence);
328
+ }
329
+ }
330
+ // Fallback: if WASM didn't fire or no G candidate, run full arbitration
331
+ if (!winner) {
332
+ const prunedCandidates = pruneCandidatesBeforeArbitration(candidates, consensus);
333
+ winner = arbitrateGrids(ring.image, prunedCandidates, evidence);
334
+ }
335
+ // Prefer corroborated near-count with strict gates: winner corrob=1, challenger≥2,
336
+ // bd_dist in challenger proposers, phaseScore margin ≤0.40
337
+ if (winner) {
338
+ const preferred = preferCorroboratedNearCountMarginBdDist(evidence, winner, tagged);
339
+ if (preferred)
340
+ winner = preferred;
341
+ }
342
+ if (winner && consensus && consensusOverrides(ring.image, winner.grid, consensus)) {
343
+ const replacement = consensusWinner(evidence, ring.image, candidates, consensus);
344
+ if (replacement)
345
+ winner = replacement;
346
+ }
347
+ if (winner && ring.requiresCountAgreement) {
348
+ // Reuse the existing independent-proposer quorum for this additional evaluation view.
349
+ const supporters = new Set(tagged
350
+ .filter(({ grid }) => grid.width === winner.grid.width && grid.height === winner.grid.height)
351
+ .map(({ proposerIndex }) => proposerIndex));
352
+ if (supporters.size < MIN_CORROBORATORS)
353
+ winner = null;
354
+ }
355
+ if (winner && (!best || winner.score < best.score))
356
+ best = { ...winner, rotation: ring.rotation };
357
+ // Corroborated admission: when arbitration refuses, check if multiple proposers agree
358
+ if (!winner && !best) {
359
+ const corroborated = admitCorroboratedLattice(evidence, tagged, ring.rotation);
360
+ if (corroborated)
361
+ best = corroborated;
362
+ }
363
+ }
364
+ if (!best)
365
+ return unreliable(image);
366
+ return best.rotation ? { ...best.grid, rotation: best.rotation } : best.grid;
367
+ }
368
+ /**
369
+ * Async Auto: same Exact logic as autoGrid, but joins WASM via Promise so a browser nested
370
+ * Worker can overlap proposers without COOP/COEP / SharedArrayBuffer sync-join.
371
+ */
372
+ async function autoGridAsync(image) {
373
+ const tilt = estimateTilt(image);
374
+ if (tilt.extreme)
375
+ return unreliable(image);
376
+ const rings = [];
377
+ if (tilt.angle) {
378
+ const upright = uprightOf(image, tilt.angle);
379
+ const wasmJob = await beginAcRlConsensusJobAsync(upright);
380
+ const outputs = PROPOSERS.map((proposer) => proposer(upright));
381
+ rings.push({ image: upright, rotation: tilt.angle, outputs, wasmJob });
382
+ if (!outputs.some((grids) => grids.some((grid) => grid.reliable)))
383
+ rings.push({ image, rotation: 0 });
384
+ }
385
+ else {
386
+ rings.push({ image, rotation: 0 });
387
+ }
388
+ const corroboratingProposers = [
389
+ detectFrameLattices,
390
+ pitchLattices,
391
+ combCountGrids,
392
+ detectBoundaryPeriodGrids,
393
+ distanceCountGrids,
394
+ ];
395
+ let best = null;
396
+ for (const ring of rings) {
397
+ const wasmJob = ring.wasmJob ?? (await beginAcRlConsensusJobAsync(ring.image));
398
+ const proposerOutputs = proposalsOf(ring.image, ring.outputs).map((grids, index) => ({
399
+ grids,
400
+ corrobIndex: corroboratingProposers.indexOf(PROPOSERS[index]),
401
+ }));
402
+ const candidates = proposerOutputs.flatMap(({ grids }) => grids.map((grid) => (ring.rotation ? { ...grid, rotation: ring.rotation } : grid)));
403
+ const tagged = proposerOutputs
404
+ .filter(({ corrobIndex }) => corrobIndex >= 0)
405
+ .flatMap(({ grids, corrobIndex }) => grids.map((grid) => ({
406
+ grid: ring.rotation ? { ...grid, rotation: ring.rotation } : grid,
407
+ proposerIndex: corrobIndex,
408
+ })));
409
+ const consensus = await finishAcRlConsensusJobAsync(wasmJob);
410
+ // Preserve the original view when rotation has neither boundary support nor independent
411
+ // count consensus; fitted lattices alone do not establish the rotation.
412
+ if (ring.rotation &&
413
+ !consensus &&
414
+ !rings.some((view) => view.rotation === 0) &&
415
+ !proposerOutputs
416
+ .slice(0, BOUNDARY_PROPOSERS.length)
417
+ .some(({ grids }) => grids.some((grid) => grid.reliable)))
418
+ rings.push({ image, rotation: 0, requiresCountAgreement: true });
419
+ const evidence = new GridEvidenceReader(ring.image);
420
+ let winner = null;
421
+ if (consensus) {
422
+ const candidatesAtG = candidates.filter((c) => c.width === consensus.cols && c.height === consensus.rows && c.reliable);
423
+ if (candidatesAtG.length) {
424
+ const nearGCandidates = candidates.filter((c) => c.reliable &&
425
+ Math.abs(c.width - consensus.cols) <= 1 &&
426
+ Math.abs(c.height - consensus.rows) <= 1);
427
+ winner = arbitrateGrids(ring.image, nearGCandidates.length > 0 ? nearGCandidates : candidatesAtG, evidence);
428
+ }
429
+ }
430
+ if (!winner) {
431
+ const prunedCandidates = pruneCandidatesBeforeArbitration(candidates, consensus);
432
+ winner = arbitrateGrids(ring.image, prunedCandidates, evidence);
433
+ }
434
+ if (winner) {
435
+ const preferred = preferCorroboratedNearCountMarginBdDist(evidence, winner, tagged);
436
+ if (preferred)
437
+ winner = preferred;
438
+ }
439
+ if (winner && consensus && consensusOverrides(ring.image, winner.grid, consensus)) {
440
+ const replacement = consensusWinner(evidence, ring.image, candidates, consensus);
441
+ if (replacement)
442
+ winner = replacement;
443
+ }
444
+ if (winner && ring.requiresCountAgreement) {
445
+ // Reuse the existing independent-proposer quorum for this additional evaluation view.
446
+ const supporters = new Set(tagged
447
+ .filter(({ grid }) => grid.width === winner.grid.width && grid.height === winner.grid.height)
448
+ .map(({ proposerIndex }) => proposerIndex));
449
+ if (supporters.size < MIN_CORROBORATORS)
450
+ winner = null;
451
+ }
452
+ if (winner && (!best || winner.score < best.score))
453
+ best = { ...winner, rotation: ring.rotation };
454
+ if (!winner && !best) {
455
+ const corroborated = admitCorroboratedLattice(evidence, tagged, ring.rotation);
456
+ if (corroborated)
457
+ best = corroborated;
458
+ }
459
+ }
460
+ if (!best)
461
+ return unreliable(image);
462
+ return best.rotation ? { ...best.grid, rotation: best.rotation } : best.grid;
463
+ }
464
+ /**
465
+ * After arbitration picks a winner, check if near-count readings (±1 cell per axis) have
466
+ * strictly higher corroboration from period proposers. Only override when ALL gates hold:
467
+ * 1. Winner corroboration = 1 (singleton period proposer)
468
+ * 2. Challenger corroboration ≥ 2
469
+ * 3. bd_dist (distanceCountGrids, index 4) in challenger proposers
470
+ * 4. phaseScore(challenger) - phaseScore(winner) ≤ 0.40 nats
471
+ *
472
+ * Stricter than reverted ungated version to prevent drift cliff (game1x −0.233pp, drift −20pp).
473
+ */
474
+ function preferCorroboratedNearCountMarginBdDist(evidence, winner, tagged) {
475
+ const BD_DIST_INDEX = 4; // distanceCountGrids in corroboratingProposers
476
+ const MAX_PHASE_SCORE_DELTA = 0.4;
477
+ const winnerWidth = winner.grid.width;
478
+ const winnerHeight = winner.grid.height;
479
+ // Group tagged grids by size and track proposers
480
+ const bySize = new Map();
481
+ for (const entry of tagged) {
482
+ const key = `${entry.grid.width}x${entry.grid.height}`;
483
+ const group = bySize.get(key) ?? { proposers: new Set(), grids: [] };
484
+ group.proposers.add(entry.proposerIndex);
485
+ group.grids.push(entry);
486
+ bySize.set(key, group);
487
+ }
488
+ // Count winner's corroboration
489
+ const winnerKey = `${winnerWidth}x${winnerHeight}`;
490
+ const winnerCorroboration = bySize.get(winnerKey)?.proposers.size ?? 0;
491
+ // Gate 1: only apply when winner has corroboration=1 (singleton period proposer)
492
+ if (winnerCorroboration !== 1)
493
+ return null;
494
+ // Read the winner only if a corroborated near-count challenger can compete.
495
+ let winnerPhaseScore;
496
+ // Find near-count candidates (±1 cell per axis)
497
+ let bestChallenger = null;
498
+ for (const [key, group] of bySize) {
499
+ const [w, h] = key.split("x").map(Number);
500
+ const deltaW = Math.abs(w - winnerWidth);
501
+ const deltaH = Math.abs(h - winnerHeight);
502
+ // Must be ±1 cell per axis
503
+ if (deltaW > 1 || deltaH > 1 || (deltaW === 0 && deltaH === 0))
504
+ continue;
505
+ const corroboration = group.proposers.size;
506
+ // Gate 2: challenger must have corroboration ≥ 2
507
+ if (corroboration < 2)
508
+ continue;
509
+ // Gate 3: bd_dist must be in challenger's proposers
510
+ if (!group.proposers.has(BD_DIST_INDEX))
511
+ continue;
512
+ // Must have strictly higher corroboration than winner
513
+ if (corroboration <= winnerCorroboration)
514
+ continue;
515
+ if (winnerPhaseScore === undefined) {
516
+ const { readings } = evidence.read([winner.grid]);
517
+ if (readings.length === 0)
518
+ return null;
519
+ winnerPhaseScore = phaseScore(readings[0]);
520
+ }
521
+ const { readings } = evidence.read(group.grids.map(({ grid }) => grid));
522
+ for (const reading of readings) {
523
+ // Gate 4 applies to each geometry/model, rather than whichever proposer arrived first.
524
+ if (phaseScore(reading) - winnerPhaseScore > MAX_PHASE_SCORE_DELTA)
525
+ continue;
526
+ const challenger = scoredReading(reading);
527
+ // Corroboration admits a size; it does not calibrate the proposer's local-correction
528
+ // confidence. Keep that confidence so admission cannot activate a different coloring map.
529
+ challenger.grid = { ...challenger.grid, confidence: reading.grid.confidence };
530
+ if (!bestChallenger ||
531
+ corroboration > bestChallenger.corroboration ||
532
+ (corroboration === bestChallenger.corroboration &&
533
+ betterReading(challenger, bestChallenger)))
534
+ bestChallenger = { ...challenger, corroboration };
535
+ }
536
+ }
537
+ if (!bestChallenger)
538
+ return null;
539
+ return {
540
+ grid: bestChallenger.grid,
541
+ length: bestChallenger.length,
542
+ score: bestChallenger.score,
543
+ };
544
+ }
545
+ /**
546
+ * When arbitration refuses (passthrough wins), check if multiple corroborating proposers agree
547
+ * on a strict lattice size. If ≥MIN_CORROBORATORS agree, admit the best-phaseScore among them.
548
+ */
549
+ function admitCorroboratedLattice(evidence, tagged, rotation) {
550
+ // Group by strict lattice size (width × height)
551
+ const bySize = new Map();
552
+ for (const entry of tagged) {
553
+ const key = `${entry.grid.width}x${entry.grid.height}`;
554
+ const group = bySize.get(key) ?? [];
555
+ group.push(entry);
556
+ bySize.set(key, group);
557
+ }
558
+ // Find sizes corroborated by ≥MIN_CORROBORATORS distinct proposers
559
+ let bestCorroborated = null;
560
+ let bestScore = Infinity;
561
+ for (const [, group] of bySize) {
562
+ // Count distinct proposers in this group
563
+ const proposerIndices = new Set(group.map((entry) => entry.proposerIndex));
564
+ if (proposerIndices.size < MIN_CORROBORATORS)
565
+ continue;
566
+ // Among corroborated grids of this size, find best phaseScore
567
+ const { readings } = evidence.read(group.map((e) => e.grid));
568
+ for (const reading of readings) {
569
+ const phase = phaseScore(reading);
570
+ if (phase < bestScore ||
571
+ (phase === bestScore &&
572
+ bestCorroborated &&
573
+ geometryKey(reading.grid) < geometryKey(bestCorroborated))) {
574
+ bestScore = phase;
575
+ bestCorroborated = reading.grid;
576
+ }
577
+ }
578
+ }
579
+ if (!bestCorroborated)
580
+ return null;
581
+ return {
582
+ score: bestScore,
583
+ grid: rotation ? { ...bestCorroborated, rotation } : bestCorroborated,
584
+ rotation,
585
+ };
586
+ }
587
+ /** Algorithm table. Auto runs pluggable proposers and falls back to FALLBACK as data. */
588
+ const TABLE = {
589
+ [PixelationAlgorithm.Auto]: {
590
+ label: "import.algorithm.auto",
591
+ listed: true,
592
+ detect: autoGrid,
593
+ detectAsync: autoGridAsync,
594
+ run: (image, grid, picker) => TABLE[grid.algorithm ?? FALLBACK].run(image, grid, picker),
595
+ },
596
+ [PixelationAlgorithm.UniformGrid]: {
597
+ label: "import.algorithm.uniform-grid",
598
+ listed: true,
599
+ detect: deskewed(PixelationAlgorithm.UniformGrid, detectUniformGrid),
600
+ run: recoverGrid,
601
+ },
602
+ [PixelationAlgorithm.EdgeGrid]: {
603
+ label: "import.algorithm.edge-grid",
604
+ listed: true,
605
+ detect: deskewed(PixelationAlgorithm.EdgeGrid, detectMeshGrid),
606
+ run: recoverGrid,
607
+ },
608
+ [PixelationAlgorithm.FrameLattice]: {
609
+ label: "import.algorithm.frame-lattice",
610
+ listed: false,
611
+ detect: deskewed(PixelationAlgorithm.FrameLattice, bestOf(detectFrameLattices)),
612
+ run: recoverGrid,
613
+ },
614
+ [PixelationAlgorithm.PitchLattice]: {
615
+ label: "import.algorithm.pitch-lattice",
616
+ listed: false,
617
+ detect: deskewed(PixelationAlgorithm.PitchLattice, bestOf(pitchLattices)),
618
+ run: recoverGrid,
619
+ },
620
+ [PixelationAlgorithm.ColorPeriod]: {
621
+ label: "import.algorithm.color-period",
622
+ listed: false,
623
+ detect: deskewed(PixelationAlgorithm.ColorPeriod, bestOf(combCountGrids)),
624
+ run: recoverGrid,
625
+ },
626
+ [PixelationAlgorithm.BorderRuns]: {
627
+ label: "import.algorithm.border-runs",
628
+ listed: false,
629
+ detect: deskewed(PixelationAlgorithm.BorderRuns, bestOf(detectBoundaryPeriodGrids)),
630
+ run: recoverGrid,
631
+ },
632
+ [PixelationAlgorithm.EdgeSpacing]: {
633
+ label: "import.algorithm.edge-spacing",
634
+ listed: false,
635
+ detect: deskewed(PixelationAlgorithm.EdgeSpacing, bestOf(distanceCountGrids)),
636
+ run: recoverGrid,
637
+ },
638
+ [PixelationAlgorithm.CustomSize]: {
639
+ label: "import.algorithm.custom-size",
640
+ listed: true,
641
+ detect: (image, size) => ({
642
+ ...customSize(image, size),
643
+ algorithm: PixelationAlgorithm.CustomSize,
644
+ }),
645
+ run: recoverGrid,
646
+ },
647
+ [PixelationAlgorithm.Resize]: {
648
+ label: "import.algorithm.resize",
649
+ listed: false,
650
+ detect: unreliable,
651
+ run: (image, grid) => resizeBox(image, grid.width, grid.height, "premultiplied"),
652
+ },
653
+ };
654
+ export function getAlgorithmEntry(algorithm) {
655
+ const entry = TABLE[algorithm];
656
+ if (!entry)
657
+ throw new RangeError(`Unknown pixelation algorithm: ${algorithm}`);
658
+ return entry;
659
+ }
660
+ export function getAllAlgorithms() {
661
+ return Object.keys(TABLE);
662
+ }
663
+ /** Entries the import dialog offers, in registry order. */
664
+ export function getListedAlgorithms() {
665
+ return getAllAlgorithms().filter((id) => TABLE[id].listed);
666
+ }
667
+ /** What Fixed grid reads its lattice from: its own edge fit first, then Auto's single proposers. */
668
+ const GRID_SOURCES = [
669
+ PixelationAlgorithm.UniformGrid,
670
+ PixelationAlgorithm.FrameLattice,
671
+ PixelationAlgorithm.PitchLattice,
672
+ PixelationAlgorithm.ColorPeriod,
673
+ PixelationAlgorithm.BorderRuns,
674
+ PixelationAlgorithm.EdgeSpacing,
675
+ ];
676
+ export function getGridSources() {
677
+ return GRID_SOURCES;
678
+ }
679
+ /** The "Find pixels by" entry an algorithm belongs to: a grid source is a kind of Fixed grid. */
680
+ export function pixelationMethodOf(algorithm) {
681
+ return GRID_SOURCES.includes(algorithm) ? PixelationAlgorithm.UniformGrid : algorithm;
682
+ }
683
+ export function detectWithAlgorithm(image, algorithm = PixelationAlgorithm.Auto, size) {
684
+ const entry = getAlgorithmEntry(algorithm);
685
+ if (detection)
686
+ return entry.detect(image, size);
687
+ detection = { image };
688
+ try {
689
+ return entry.detect(image, size);
690
+ }
691
+ finally {
692
+ detection = null;
693
+ forgetNeighbourSteps();
694
+ }
695
+ }
696
+ /**
697
+ * Async detect for product Auto path: uses detectAsync when present (WASM||proposers
698
+ * overlap via nested Worker Promise join). Other algorithms resolve sync detect.
699
+ */
700
+ export async function detectWithAlgorithmAsync(image, algorithm = PixelationAlgorithm.Auto, size) {
701
+ const entry = getAlgorithmEntry(algorithm);
702
+ const run = () => entry.detectAsync ? entry.detectAsync(image, size) : Promise.resolve(entry.detect(image, size));
703
+ if (detection)
704
+ return run();
705
+ detection = { image };
706
+ try {
707
+ return await run();
708
+ }
709
+ finally {
710
+ detection = null;
711
+ forgetNeighbourSteps();
712
+ }
713
+ }
714
+ export function recoverWithGrid(image, grid, picker = CellColorPicker.MostCommon) {
715
+ return getAlgorithmEntry(grid.algorithm ?? PixelationAlgorithm.EdgeGrid).run(prepareGridSampling(image, grid), grid, picker);
716
+ }