@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,62 @@
1
+ import { UINT8_MAX } from "./base.js";
2
+ const CHANNELS = 4;
3
+ /** `m / 255` for every whole channel difference m, as doubles and as float32. */
4
+ export const OPAQUE_STEPS = Float64Array.from({ length: UINT8_MAX + 1 }, (_, m) => m / UINT8_MAX);
5
+ export const OPAQUE_STEPS_32 = Float32Array.from(OPAQUE_STEPS);
6
+ /** Step code of a pair that is not opaque on both sides: its step is computed in full. */
7
+ export const TRANSLUCENT = UINT8_MAX + 1;
8
+ /**
9
+ * The colour step (0..1) between two RGBA pixels: the largest premultiplied channel or alpha
10
+ * difference.
11
+ */
12
+ export function colorStep(data, first, second) {
13
+ const a = data[first + 3] / UINT8_MAX, b = data[second + 3] / UINT8_MAX;
14
+ let difference = Math.abs(a - b);
15
+ for (let c = 0; c < CHANNELS - 1; c++)
16
+ difference = Math.max(difference, Math.abs(data[first + c] * a - data[second + c] * b) / UINT8_MAX);
17
+ return difference;
18
+ }
19
+ let last = null;
20
+ export function neighbourSteps(image) {
21
+ if (last?.image === image)
22
+ return last;
23
+ const { width, height, data } = image;
24
+ const right = new Uint16Array(width * height), down = new Uint16Array(width * height);
25
+ const code = (first, second) => data[first + 3] === UINT8_MAX && data[second + 3] === UINT8_MAX
26
+ ? Math.max(Math.abs(data[first] - data[second]), Math.abs(data[first + 1] - data[second + 1]), Math.abs(data[first + 2] - data[second + 2]))
27
+ : TRANSLUCENT;
28
+ const below = width * CHANNELS;
29
+ for (let y = 0; y < height; y++) {
30
+ const row = y * width;
31
+ for (let p = row; p < row + width - 1; p++)
32
+ right[p] = code(p * CHANNELS, p * CHANNELS + CHANNELS);
33
+ if (y + 1 < height)
34
+ for (let p = row; p < row + width; p++)
35
+ down[p] = code(p * CHANNELS, p * CHANNELS + below);
36
+ }
37
+ last = { image, right, down };
38
+ return last;
39
+ }
40
+ /** Drop the kept codes once the image's edge analysis is done. */
41
+ export function releaseNeighbourSteps(image) {
42
+ if (last?.image === image)
43
+ last = null;
44
+ }
45
+ /** Drop the kept codes, whichever image they belong to. */
46
+ export function forgetNeighbourSteps() {
47
+ last = null;
48
+ }
49
+ /** The step (a double, as {@link colorStep}) of the pair from pixel p to p + offset. */
50
+ export function stepOf(codes, data, p, offset) {
51
+ const code = codes[p];
52
+ return code < TRANSLUCENT
53
+ ? OPAQUE_STEPS[code]
54
+ : colorStep(data, p * CHANNELS, (p + offset) * CHANNELS);
55
+ }
56
+ /** The same step rounded to float32. */
57
+ export function step32Of(codes, data, p, offset) {
58
+ const code = codes[p];
59
+ return code < TRANSLUCENT
60
+ ? OPAQUE_STEPS_32[code]
61
+ : Math.fround(colorStep(data, p * CHANNELS, (p + offset) * CHANNELS));
62
+ }
@@ -0,0 +1,4 @@
1
+ import type { PixelBuffer } from "./base.ts";
2
+ /** Independent thin-ridge filter: opposite neighbors identify narrow light/dark features.
3
+ * Only RGB is blended into existing visible pixels; the alpha footprint never grows. */
4
+ export declare function protectPixelationContours(image: PixelBuffer, strength: number): PixelBuffer;
@@ -0,0 +1,66 @@
1
+ import { UINT8_MAX } from "./base.js";
2
+ const CHANNELS = 4;
3
+ const COLOR_CHANNELS = 3;
4
+ const LUMA = [0.2126, 0.7152, 0.0722];
5
+ const MIN_CONTRAST = 0.14;
6
+ const BACKGROUND_TOLERANCE = 0.12;
7
+ const MAX_BLEND = 0.65;
8
+ const PERCENT = 100;
9
+ /** Independent thin-ridge filter: opposite neighbors identify narrow light/dark features.
10
+ * Only RGB is blended into existing visible pixels; the alpha footprint never grows. */
11
+ export function protectPixelationContours(image, strength) {
12
+ if (!strength)
13
+ return image;
14
+ const count = image.width * image.height;
15
+ const luminance = new Float32Array(count);
16
+ const features = new Float32Array(count);
17
+ const source = image.data;
18
+ for (let i = 0; i < count; i++) {
19
+ const offset = i * CHANNELS;
20
+ luminance[i] =
21
+ (source[offset] * LUMA[0] + source[offset + 1] * LUMA[1] + source[offset + 2] * LUMA[2]) /
22
+ UINT8_MAX;
23
+ }
24
+ const ridge = (center, first, second) => {
25
+ if (!source[first * CHANNELS + COLOR_CHANNELS] || !source[second * CHANNELS + COLOR_CHANNELS])
26
+ return 0;
27
+ const left = luminance[center] - luminance[first], right = luminance[center] - luminance[second];
28
+ if (left * right <= 0 || Math.abs(luminance[first] - luminance[second]) > BACKGROUND_TOLERANCE)
29
+ return 0;
30
+ return Math.min(Math.abs(left), Math.abs(right));
31
+ };
32
+ for (let y = 1; y < image.height - 1; y++)
33
+ for (let x = 1; x < image.width - 1; x++) {
34
+ const i = y * image.width + x;
35
+ if (!source[i * CHANNELS + COLOR_CHANNELS])
36
+ continue;
37
+ features[i] = Math.max(ridge(i, i - 1, i + 1), ridge(i, i - image.width, i + image.width));
38
+ }
39
+ const data = new Uint8ClampedArray(source);
40
+ for (let y = 0; y < image.height; y++)
41
+ for (let x = 0; x < image.width; x++) {
42
+ const i = y * image.width + x, offset = i * CHANNELS;
43
+ if (!source[offset + COLOR_CHANNELS] || features[i] >= MIN_CONTRAST)
44
+ continue;
45
+ let best = i, score = MIN_CONTRAST;
46
+ for (let dy = -1; dy <= 1; dy++)
47
+ for (let dx = -1; dx <= 1; dx++) {
48
+ const nx = x + dx, ny = y + dy;
49
+ if ((!dx && !dy) || nx < 0 || ny < 0 || nx >= image.width || ny >= image.height)
50
+ continue;
51
+ const neighbor = ny * image.width + nx;
52
+ const coverage = source[neighbor * CHANNELS + COLOR_CHANNELS] / UINT8_MAX;
53
+ const candidate = (features[neighbor] * coverage) / Math.max(1, Math.abs(dx) + Math.abs(dy));
54
+ if (candidate > score) {
55
+ score = candidate;
56
+ best = neighbor;
57
+ }
58
+ }
59
+ if (best === i)
60
+ continue;
61
+ const amount = (strength / PERCENT) * MAX_BLEND;
62
+ for (let c = 0; c < COLOR_CHANNELS; c++)
63
+ data[offset + c] = Math.round(source[offset + c] * (1 - amount) + source[best * CHANNELS + c] * amount);
64
+ }
65
+ return { width: image.width, height: image.height, data };
66
+ }
@@ -0,0 +1,3 @@
1
+ import type { PixelBuffer } from "./base.ts";
2
+ /** Light edge-preserving smoothing on the working grid; opacity and contrasting details stay intact. */
3
+ export declare function smoothPixelation(image: PixelBuffer): PixelBuffer;
@@ -0,0 +1,37 @@
1
+ const CHANNELS = 4;
2
+ const COLOR_CHANNELS = 3;
3
+ const SIMILARITY_LIMIT = 36;
4
+ const CENTER_WEIGHT = 2;
5
+ /** Light edge-preserving smoothing on the working grid; opacity and contrasting details stay intact. */
6
+ export function smoothPixelation(image) {
7
+ const data = new Uint8ClampedArray(image.data);
8
+ for (let y = 0; y < image.height; y++)
9
+ for (let x = 0; x < image.width; x++) {
10
+ const offset = (y * image.width + x) * CHANNELS;
11
+ if (!image.data[offset + COLOR_CHANNELS])
12
+ continue;
13
+ const sums = [0, 0, 0];
14
+ let total = CENTER_WEIGHT;
15
+ for (let channel = 0; channel < COLOR_CHANNELS; channel++)
16
+ sums[channel] = image.data[offset + channel] * CENTER_WEIGHT;
17
+ for (let dy = -1; dy <= 1; dy++)
18
+ for (let dx = -1; dx <= 1; dx++) {
19
+ const nx = x + dx, ny = y + dy;
20
+ if ((!dx && !dy) || nx < 0 || ny < 0 || nx >= image.width || ny >= image.height)
21
+ continue;
22
+ const neighbor = (ny * image.width + nx) * CHANNELS;
23
+ let distance = 0;
24
+ for (let channel = 0; channel < CHANNELS; channel++)
25
+ distance = Math.max(distance, Math.abs(image.data[offset + channel] - image.data[neighbor + channel]));
26
+ if (distance >= SIMILARITY_LIMIT)
27
+ continue;
28
+ const weight = 1 - distance / SIMILARITY_LIMIT;
29
+ total += weight;
30
+ for (let channel = 0; channel < COLOR_CHANNELS; channel++)
31
+ sums[channel] += image.data[neighbor + channel] * weight;
32
+ }
33
+ for (let channel = 0; channel < COLOR_CHANNELS; channel++)
34
+ data[offset + channel] = Math.round(sums[channel] / total);
35
+ }
36
+ return { ...image, data };
37
+ }
@@ -0,0 +1,24 @@
1
+ import type { PixelBuffer } from "./base.ts";
2
+ /** Largest tilt searched (degrees, either direction). Beyond it the image is refused. */
3
+ export declare const MAX_TILT_DEGREES = 10;
4
+ /** Tilts below this are noise or a perfectly aligned image: the image is left untouched. */
5
+ export declare const MIN_TILT_DEGREES = 0.15;
6
+ export interface TiltEstimate {
7
+ /** Degrees to rotate the content by to straighten it; 0 when it needs none. */
8
+ angle: number;
9
+ /** Sharpness at the best angle divided by sharpness unrotated (1 = no evidence of tilt). */
10
+ gain: number;
11
+ /** The grid looks tilted by more than the search range: refuse instead of guessing. */
12
+ extreme: boolean;
13
+ }
14
+ /**
15
+ * Estimate how far the pixel grid in `image` is tilted. Returns an upright estimate when there is
16
+ * no convincing evidence, so images without a grid (photos) and aligned images are never touched.
17
+ */
18
+ export declare function estimateTilt(image: PixelBuffer): TiltEstimate;
19
+ /**
20
+ * Rotate `image` about its centre by `degrees` so a grid tilted by that angle becomes axis
21
+ * aligned. Same canvas size; samples outside the source repeat the nearest border pixel, and
22
+ * colour is interpolated with premultiplied alpha.
23
+ */
24
+ export declare function straighten(image: PixelBuffer, degrees: number): PixelBuffer;
package/dist/deskew.js ADDED
@@ -0,0 +1,202 @@
1
+ import { neighbourSteps, stepOf } from "./color-step.js";
2
+ /**
3
+ * Tilt estimation and straightening for pixel grids that were photographed, scanned or rotated a
4
+ * little before they reached the importer. The tilt is the angle that makes the projection
5
+ * profiles of the vertical and horizontal edges sharpest: for the right angle every grid line
6
+ * collapses into one histogram bin, for any other angle the lines smear across many bins.
7
+ */
8
+ const CHANNELS = 4;
9
+ /** Largest tilt searched (degrees, either direction). Beyond it the image is refused. */
10
+ export const MAX_TILT_DEGREES = 10;
11
+ /** Tilts below this are noise or a perfectly aligned image: the image is left untouched. */
12
+ export const MIN_TILT_DEGREES = 0.15;
13
+ /** The tilted profile must be at least this much sharper than the unrotated one. */
14
+ const MIN_SHARPNESS_GAIN = 1.25;
15
+ /** Colour step (0..1) that counts as an edge; keeps JPEG noise and soft gradients out. */
16
+ const EDGE_STEP = 0.1;
17
+ const MAX_POINTS = 90_000;
18
+ const COARSE_SIZE = 512;
19
+ const COARSE_STEP = 0.1;
20
+ const FINE_SPAN = 0.15;
21
+ const DEGREES = Math.PI / 180;
22
+ const MIN_EDGE_POINTS = 64;
23
+ /** A best angle this close to the search limit means the real tilt is probably outside it. */
24
+ const LIMIT_MARGIN = 0.35;
25
+ const UPRIGHT = { angle: 0, gain: 1, extreme: false };
26
+ const VERTICAL_EDGE = 1;
27
+ const HORIZONTAL_EDGE = 2;
28
+ /**
29
+ * Every `stride`-th of the `count` pixels flagged with `bit` (stride chosen to keep at most `limit`),
30
+ * in row order, as x, y pairs offset by (dx, dy).
31
+ */
32
+ function thin(flags, width, bit, count, limit, dx, dy) {
33
+ const stride = Math.max(1, Math.ceil(count / limit));
34
+ const kept = new Float32Array(2 * Math.ceil(count / stride));
35
+ for (let p = 0, seen = 0, n = 0; n < kept.length; p++) {
36
+ if (!(flags[p] & bit))
37
+ continue;
38
+ if (seen++ % stride)
39
+ continue;
40
+ kept[n++] = (p % width) + dx;
41
+ kept[n++] = Math.floor(p / width) + dy;
42
+ }
43
+ return kept;
44
+ }
45
+ function collectEdges(image) {
46
+ const { width, height, data } = image;
47
+ const { right, down } = neighbourSteps(image);
48
+ const flags = new Uint8Array(width * height);
49
+ let vertical = 0, horizontal = 0;
50
+ for (let y = 0, p = 0; y < height; y++)
51
+ for (let x = 0; x < width; x++, p++) {
52
+ if (x + 1 < width && stepOf(right, data, p, 1) > EDGE_STEP) {
53
+ flags[p] |= VERTICAL_EDGE;
54
+ vertical++;
55
+ }
56
+ if (y + 1 < height && stepOf(down, data, p, width) > EDGE_STEP) {
57
+ flags[p] |= HORIZONTAL_EDGE;
58
+ horizontal++;
59
+ }
60
+ }
61
+ return {
62
+ vertical: thin(flags, width, VERTICAL_EDGE, vertical, MAX_POINTS, 1, 0.5),
63
+ horizontal: thin(flags, width, HORIZONTAL_EDGE, horizontal, MAX_POINTS, 0.5, 1),
64
+ };
65
+ }
66
+ /**
67
+ * Sum of squared histogram counts of the edge positions projected on the rotated axes. A larger
68
+ * value means the edges line up into fewer, taller bars.
69
+ */
70
+ function sharpness(points, degrees, scale, bins, offset) {
71
+ const cos = Math.cos(degrees * DEGREES) * scale, sin = Math.sin(degrees * DEGREES) * scale;
72
+ const { vertical, horizontal } = points;
73
+ // Vertical edges project on u = x cos + y sin, horizontal edges on v = -x sin + y cos.
74
+ bins.fill(0);
75
+ for (let i = 0; i < vertical.length; i += 2)
76
+ bins[Math.floor(vertical[i] * cos + vertical[i + 1] * sin + offset)]++;
77
+ let total = squaredCounts(bins);
78
+ bins.fill(0);
79
+ for (let i = 0; i < horizontal.length; i += 2)
80
+ bins[Math.floor(-horizontal[i] * sin + horizontal[i + 1] * cos + offset)]++;
81
+ total += squaredCounts(bins);
82
+ return total / Math.max(1, (vertical.length + horizontal.length) / 2);
83
+ }
84
+ function squaredCounts(bins) {
85
+ let sum = 0;
86
+ for (let i = 0; i < bins.length; i++)
87
+ sum += bins[i] * bins[i];
88
+ return sum;
89
+ }
90
+ function scaled(points, scale) {
91
+ if (scale === 1)
92
+ return points;
93
+ return {
94
+ vertical: points.vertical.map((value) => value * scale),
95
+ horizontal: points.horizontal.map((value) => value * scale),
96
+ };
97
+ }
98
+ const cache = new WeakMap();
99
+ /**
100
+ * Estimate how far the pixel grid in `image` is tilted. Returns an upright estimate when there is
101
+ * no convincing evidence, so images without a grid (photos) and aligned images are never touched.
102
+ */
103
+ export function estimateTilt(image) {
104
+ const known = cache.get(image);
105
+ if (known)
106
+ return known;
107
+ const result = measureTilt(image);
108
+ cache.set(image, result);
109
+ return result;
110
+ }
111
+ function measureTilt(image) {
112
+ const longest = Math.max(image.width, image.height);
113
+ if (longest < 16)
114
+ return UPRIGHT;
115
+ const edges = collectEdges(image);
116
+ if (edges.vertical.length / 2 + edges.horizontal.length / 2 < MIN_EDGE_POINTS)
117
+ return UPRIGHT;
118
+ const bins = new Int32Array(Math.ceil(3 * longest) + 8);
119
+ const offset = longest;
120
+ const evaluate = (points, degrees, scale) => sharpness(points, degrees, scale, bins, offset * scale);
121
+ // Coarse pass on a shrunken copy of the coordinates so the narrow peak is not stepped over.
122
+ const coarseScale = Math.min(1, COARSE_SIZE / longest);
123
+ const coarsePoints = scaled(edges, coarseScale);
124
+ let best = 0, bestScore = -Infinity;
125
+ const scores = new Map();
126
+ for (let step = -MAX_TILT_DEGREES / COARSE_STEP; step <= MAX_TILT_DEGREES / COARSE_STEP; step++) {
127
+ const degrees = Math.round(step * COARSE_STEP * 1000) / 1000;
128
+ const score = evaluate(coarsePoints, degrees, coarseScale);
129
+ scores.set(degrees, score);
130
+ if (score > bestScore) {
131
+ bestScore = score;
132
+ best = degrees;
133
+ }
134
+ }
135
+ // Fine pass at full resolution: one pixel of drift across the image per step.
136
+ const fineStep = Math.max(0.005, Math.min(0.05, 1 / longest / DEGREES / 2));
137
+ let fine = best, fineScore = -Infinity;
138
+ for (let degrees = best - FINE_SPAN; degrees <= best + FINE_SPAN + 1e-9; degrees += fineStep) {
139
+ const score = evaluate(edges, degrees, 1);
140
+ if (score > fineScore) {
141
+ fineScore = score;
142
+ fine = degrees;
143
+ }
144
+ }
145
+ const upright = evaluate(edges, 0, 1);
146
+ const gain = upright > 0 ? fineScore / upright : 1;
147
+ const angle = Math.round(fine * 1000) / 1000;
148
+ if (Math.abs(angle) < MIN_TILT_DEGREES || gain < MIN_SHARPNESS_GAIN)
149
+ return { angle: 0, gain, extreme: false };
150
+ if (Math.abs(angle) > MAX_TILT_DEGREES - LIMIT_MARGIN)
151
+ return { angle: 0, gain, extreme: true };
152
+ return { angle, gain, extreme: false };
153
+ }
154
+ /**
155
+ * Rotate `image` about its centre by `degrees` so a grid tilted by that angle becomes axis
156
+ * aligned. Same canvas size; samples outside the source repeat the nearest border pixel, and
157
+ * colour is interpolated with premultiplied alpha.
158
+ */
159
+ export function straighten(image, degrees) {
160
+ if (!degrees)
161
+ return image;
162
+ const { width, height, data } = image;
163
+ const out = new Uint8ClampedArray(data.length);
164
+ const cos = Math.cos(degrees * DEGREES), sin = Math.sin(degrees * DEGREES);
165
+ const cx = width / 2, cy = height / 2;
166
+ const pick = (x, y) => (Math.max(0, Math.min(height - 1, y)) * width + Math.max(0, Math.min(width - 1, x))) * CHANNELS;
167
+ const corners = new Int32Array(4);
168
+ const weights = new Float64Array(4);
169
+ for (let y = 0; y < height; y++)
170
+ for (let x = 0; x < width; x++) {
171
+ const u = x + 0.5 - cx, v = y + 0.5 - cy;
172
+ const sx = u * cos - v * sin + cx - 0.5, sy = u * sin + v * cos + cy - 0.5;
173
+ const x0 = Math.floor(sx), y0 = Math.floor(sy);
174
+ const fx = sx - x0, fy = sy - y0;
175
+ // The four corners in order, accumulated from zero.
176
+ corners[0] = pick(x0, y0);
177
+ corners[1] = pick(x0 + 1, y0);
178
+ corners[2] = pick(x0, y0 + 1);
179
+ corners[3] = pick(x0 + 1, y0 + 1);
180
+ weights[0] = (1 - fx) * (1 - fy);
181
+ weights[1] = fx * (1 - fy);
182
+ weights[2] = (1 - fx) * fy;
183
+ weights[3] = fx * fy;
184
+ let alpha = 0, r = 0, g = 0, b = 0;
185
+ for (let corner = 0; corner < 4; corner++) {
186
+ const at = corners[corner];
187
+ const a = data[at + 3] * weights[corner];
188
+ alpha += a;
189
+ r += data[at] * a;
190
+ g += data[at + 1] * a;
191
+ b += data[at + 2] * a;
192
+ }
193
+ const o = (y * width + x) * CHANNELS;
194
+ if (alpha > 0) {
195
+ out[o] = r / alpha;
196
+ out[o + 1] = g / alpha;
197
+ out[o + 2] = b / alpha;
198
+ out[o + 3] = alpha;
199
+ }
200
+ }
201
+ return { width, height, data: out };
202
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * AC+RL consensus detector via Fixer Rust WASM.
3
+ *
4
+ * Returns grid dimensions when autocorrelation and run-lengths agree
5
+ * on pixel grid size (consensus="fast:ac+rl(S)").
6
+ *
7
+ * Overlap modes:
8
+ * - Node (pixelbench / Vite SSR): worker_threads — beginAcRlConsensusJob + sync
9
+ * finishAcRlConsensusJob via receiveMessageOnPort (existing Exact harness path).
10
+ * - Browser product (import-worker): nested Worker + Promise join via
11
+ * beginAcRlConsensusJobAsync / finishAcRlConsensusJobAsync — no COOP/COEP /
12
+ * SharedArrayBuffer required (sync Atomics.wait join is what needed isolation).
13
+ * - Fallback: on-thread detectAcRlConsensus (sync job).
14
+ *
15
+ * Set XPRITE_AC_RL_WORKER=0 to force sync detect on Node. Product registers a
16
+ * browser worker factory with setAcRlBrowserWorkerFactory from import-worker.
17
+ */
18
+ /**
19
+ * Minimal messaging / Node shapes used by this file only.
20
+ * editor-core tsconfig keeps lib=["ES2020"] and types=[] (no DOM / @types/node),
21
+ * so we must not rely on ambient MessagePort/Worker/process names.
22
+ */
23
+ type AcRlTransferable = ArrayBufferLike | AcRlMessagePort;
24
+ type AcRlMessagePort = {
25
+ onmessage: ((ev: {
26
+ data: unknown;
27
+ }) => void) | null;
28
+ postMessage(message: unknown, transfer?: AcRlTransferable[]): void;
29
+ start?: () => void;
30
+ close?: () => void;
31
+ on?: (event: "message", cb: (msg: unknown) => void) => void;
32
+ off?: (event: "message", cb: (msg: unknown) => void) => void;
33
+ };
34
+ type Consensus = {
35
+ cols: number;
36
+ rows: number;
37
+ };
38
+ export type AcRlConsensusJob = {
39
+ mode: "worker";
40
+ port1: {
41
+ onmessage: ((ev: {
42
+ data: unknown;
43
+ }) => void) | null;
44
+ postMessage(message: unknown, transfer?: AcRlTransferable[]): void;
45
+ start?: () => void;
46
+ close?: () => void;
47
+ on?: (event: "message", cb: (msg: unknown) => void) => void;
48
+ off?: (event: "message", cb: (msg: unknown) => void) => void;
49
+ };
50
+ } | {
51
+ mode: "sync";
52
+ imageData: {
53
+ data: Uint8ClampedArray | Uint8Array;
54
+ width: number;
55
+ height: number;
56
+ };
57
+ };
58
+ export type AcRlConsensusAsyncJob = {
59
+ mode: "async";
60
+ promise: Promise<Consensus | null>;
61
+ } | {
62
+ mode: "sync";
63
+ imageData: {
64
+ data: Uint8ClampedArray | Uint8Array;
65
+ width: number;
66
+ height: number;
67
+ };
68
+ };
69
+ /**
70
+ * Product import-worker: register nested Worker factory (Vite `new URL(..., import.meta.url)`).
71
+ * Param is `() => unknown` so DOM `Worker` from apps/editor assigns without sharing DOM libs.
72
+ */
73
+ export declare function setAcRlBrowserWorkerFactory(factory: (() => unknown) | null): void;
74
+ /**
75
+ * Preload WASM module asynchronously (call during app/worker initialization).
76
+ * After this completes, detectAcRlConsensus will work synchronously without load overhead.
77
+ *
78
+ * Best practice: Call this during app startup or worker ready event, NOT on first recover call.
79
+ * Reduces first-image latency by ~5-10ms and eliminates async overhead from hot path.
80
+ *
81
+ * Also warms the Node AC+RL detect worker when worker_threads overlap is available,
82
+ * and the browser nested Worker when a factory was registered.
83
+ *
84
+ * @example
85
+ * // In worker initialization:
86
+ * await preloadWasm();
87
+ *
88
+ * // Or in app init:
89
+ * preloadWasm().catch(console.error);
90
+ */
91
+ export declare function preloadWasm(): Promise<void>;
92
+ /**
93
+ * Start AC+RL detect on a Node worker_threads worker (copy + postMessage).
94
+ * Run proposers on the calling thread, then finishAcRlConsensusJob.
95
+ * Falls back to deferred sync detect when workers are unavailable (browser / disable flag).
96
+ */
97
+ export declare function beginAcRlConsensusJob(imageData: {
98
+ data: Uint8ClampedArray | Uint8Array;
99
+ width: number;
100
+ height: number;
101
+ }): AcRlConsensusJob;
102
+ /**
103
+ * Join a job started by beginAcRlConsensusJob. Same consensus semantics as detectAcRlConsensus.
104
+ */
105
+ export declare function finishAcRlConsensusJob(job: AcRlConsensusJob): Consensus | null;
106
+ /**
107
+ * Start AC+RL detect for async Auto: Node worker_threads or browser nested Worker.
108
+ * Promise is attached before proposers run; finishAcRlConsensusJobAsync awaits it
109
+ * (no SAB / COOP/COEP). Falls back to deferred sync detect when no worker is available.
110
+ * Async so browser nested Worker can be warmed on first use.
111
+ */
112
+ export declare function beginAcRlConsensusJobAsync(imageData: {
113
+ data: Uint8ClampedArray | Uint8Array;
114
+ width: number;
115
+ height: number;
116
+ }): Promise<AcRlConsensusAsyncJob>;
117
+ /**
118
+ * Await a job from beginAcRlConsensusJobAsync. Same consensus semantics as detectAcRlConsensus.
119
+ */
120
+ export declare function finishAcRlConsensusJobAsync(job: AcRlConsensusAsyncJob): Promise<Consensus | null>;
121
+ /** Kick off browser nested Worker warm-up after setAcRlBrowserWorkerFactory (non-blocking OK). */
122
+ export declare function warmAcRlBrowserWorker(): Promise<boolean>;
123
+ /** Test/harness: whether Node worker overlap is active after preload. */
124
+ export declare function acRlWorkerOverlapActive(): boolean;
125
+ /** Test/harness: whether browser nested-Worker async overlap is ready. */
126
+ export declare function acRlBrowserWorkerOverlapActive(): boolean;
127
+ export {};