@scanmate/align 0.0.2

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.
@@ -0,0 +1,1184 @@
1
+ import { nextPowerOfTwo, fft2d, downscaleGray, estimateSkew, contentExtent, similarity, warpGray, correlation, rebase, isPlausible, multiply, translation, binarize, intersectionOverUnion, resizeGray, boxBlur, createRandom, gaussian, solve, smallestEigenvector, invert, reprojectionError, conjugateScale, decodeImage, inkMap, toGrayscale, warpRaster, decompose, encodeImage } from '@scanmate/ink';
2
+
3
+ /**
4
+ * Correlate two equally sized images.
5
+ *
6
+ * Both are Hann-windowed first. Without it the FFT sees the frame edges as a
7
+ * hard discontinuity repeating forever, and that cross pattern in the spectrum
8
+ * can be a stronger signal than the page.
9
+ */
10
+ function phaseCorrelate(a, b) {
11
+ if (a.width !== b.width || a.height !== b.height) throw new Error('phaseCorrelate needs two images of the same size');
12
+ const width = nextPowerOfTwo(a.width);
13
+ const height = nextPowerOfTwo(a.height);
14
+ const size = width * height;
15
+ const aRe = new Float64Array(size);
16
+ const aIm = new Float64Array(size);
17
+ const bRe = new Float64Array(size);
18
+ const bIm = new Float64Array(size);
19
+ const windowX = hann(a.width);
20
+ const windowY = hann(a.height);
21
+ for (let y = 0; y < a.height; y++) {
22
+ const src = y * a.width;
23
+ const dst = y * width;
24
+ for (let x = 0; x < a.width; x++) {
25
+ const w = windowX[x] * windowY[y];
26
+ aRe[dst + x] = a.data[src + x] * w;
27
+ bRe[dst + x] = b.data[src + x] * w;
28
+ }
29
+ }
30
+ fft2d(aRe, aIm, width, height);
31
+ fft2d(bRe, bIm, width, height);
32
+ // Cross-power spectrum of b against a, normalised to unit magnitude so that
33
+ // every frequency contributes its phase and nothing else.
34
+ for (let i = 0; i < size; i++) {
35
+ const re = bRe[i] * aRe[i] + bIm[i] * aIm[i];
36
+ const im = bIm[i] * aRe[i] - bRe[i] * aIm[i];
37
+ const magnitude = Math.hypot(re, im);
38
+ if (magnitude < 1e-12) {
39
+ bRe[i] = 0;
40
+ bIm[i] = 0;
41
+ } else {
42
+ bRe[i] = re / magnitude;
43
+ bIm[i] = im / magnitude;
44
+ }
45
+ }
46
+ fft2d(bRe, bIm, width, height, true);
47
+ let peakIndex = 0;
48
+ let peakValue = -Infinity;
49
+ for (let i = 0; i < size; i++) if (bRe[i] > peakValue) {
50
+ peakValue = bRe[i];
51
+ peakIndex = i;
52
+ }
53
+ const px = peakIndex % width;
54
+ const py = Math.floor(peakIndex / width);
55
+ const dx = wrap(px + parabolic(sample(bRe, width, height, px - 1, py), peakValue, sample(bRe, width, height, px + 1, py)), width);
56
+ const dy = wrap(py + parabolic(sample(bRe, width, height, px, py - 1), peakValue, sample(bRe, width, height, px, py + 1)), height);
57
+ return {
58
+ dx,
59
+ dy,
60
+ peak: peakValue
61
+ };
62
+ }
63
+ /**
64
+ * Sub-pixel offset of a peak, by fitting a parabola through it and its neighbours.
65
+ *
66
+ * The correlation surface is sampled on the pixel grid, but the true offset is
67
+ * not a whole number of pixels. Three samples determine a parabola, and its
68
+ * vertex is a better estimate than the middle sample - typically to about a
69
+ * tenth of a pixel.
70
+ */
71
+ function parabolic(left, center, right) {
72
+ const denominator = left - 2 * center + right;
73
+ if (Math.abs(denominator) < 1e-12) return 0;
74
+ const offset = 0.5 * (left - right) / denominator;
75
+ return Math.abs(offset) < 1 ? offset : 0;
76
+ }
77
+ function sample(data, width, height, x, y) {
78
+ const cx = (x % width + width) % width;
79
+ const cy = (y % height + height) % height;
80
+ return data[cy * width + cx];
81
+ }
82
+ /** Map an index in `[0, n)` onto a signed shift in `[-n/2, n/2)`. */
83
+ function wrap(value, n) {
84
+ return value > n / 2 ? value - n : value;
85
+ }
86
+ function hann(n) {
87
+ const w = new Float64Array(n);
88
+ if (n === 1) {
89
+ w[0] = 1;
90
+ return w;
91
+ }
92
+ for (let i = 0; i < n; i++) w[i] = 0.5 * (1 - Math.cos(2 * Math.PI * i / (n - 1)));
93
+ return w;
94
+ }
95
+
96
+ function estimateCoarse(originalInk, scannedInk, options = {}) {
97
+ const {
98
+ workingSize = 512,
99
+ maxSkewDeg = 12,
100
+ maxScaleRatio = 6
101
+ } = options;
102
+ const original = downscaleGray(originalInk, workingSize);
103
+ const scanned = downscaleGray(scannedInk, workingSize);
104
+ const originalSkew = estimateSkew(original.image, {
105
+ maxAngleDeg: maxSkewDeg
106
+ });
107
+ const scannedSkew = estimateSkew(scanned.image, {
108
+ maxAngleDeg: maxSkewDeg
109
+ });
110
+ const originalFlat = contentExtent(original.image, 0);
111
+ const scannedFlat = contentExtent(scanned.image, 0);
112
+ const originalTilted = contentExtent(original.image, originalSkew);
113
+ const scannedTilted = contentExtent(scanned.image, scannedSkew);
114
+ const candidates = [];
115
+ const frameScale = geometricMean(scanned.image.width / original.image.width, scanned.image.height / original.image.height);
116
+ push(candidates, 'frame', similarity(frameScale, 0, {
117
+ x: original.image.width / 2,
118
+ y: original.image.height / 2
119
+ }, {
120
+ x: scanned.image.width / 2,
121
+ y: scanned.image.height / 2
122
+ }), maxScaleRatio);
123
+ if (originalFlat.density > 0 && scannedFlat.density > 0) push(candidates, 'content', fromExtents(originalFlat, scannedFlat, 0), maxScaleRatio);
124
+ if (originalTilted.density > 0 && scannedTilted.density > 0) push(candidates, 'deskew', fromExtents(originalTilted, scannedTilted, scannedSkew - originalSkew), maxScaleRatio);
125
+ let bestMatrix = similarity(Number.isFinite(frameScale) ? frameScale : 1, 0, {
126
+ x: original.image.width / 2,
127
+ y: original.image.height / 2
128
+ }, {
129
+ x: scanned.image.width / 2,
130
+ y: scanned.image.height / 2
131
+ });
132
+ let bestScore = -Infinity;
133
+ let bestStrategy = 'fallback';
134
+ for (const candidate of candidates) {
135
+ for (const variant of withTranslationPolish(candidate, original.image, scanned.image)) {
136
+ const warped = warpGray(scanned.image, variant.matrix, original.image.width, original.image.height, 0);
137
+ const score = correlation(original.image, warped);
138
+ if (score > bestScore) {
139
+ bestMatrix = variant.matrix;
140
+ bestScore = score;
141
+ bestStrategy = variant.strategy;
142
+ }
143
+ }
144
+ }
145
+ return {
146
+ // Measured on two independently shrunk copies; hand back full-resolution pixels.
147
+ matrix: rebase(bestMatrix, original.scale, scanned.scale),
148
+ score: bestScore === -Infinity ? 0 : bestScore,
149
+ strategy: bestStrategy,
150
+ skew: {
151
+ original: originalSkew,
152
+ scanned: scannedSkew
153
+ }
154
+ };
155
+ }
156
+ /** The coarse matrix, plus a copy nudged by whatever phase correlation says is left over. */
157
+ function withTranslationPolish(candidate, original, scanned) {
158
+ const warped = warpGray(scanned, candidate.matrix, original.width, original.height, 0);
159
+ let shift;
160
+ try {
161
+ shift = phaseCorrelate(original, warped);
162
+ } catch {
163
+ return [candidate];
164
+ }
165
+ if (!Number.isFinite(shift.dx) || !Number.isFinite(shift.dy)) return [candidate];
166
+ if (Math.abs(shift.dx) < 0.25 && Math.abs(shift.dy) < 0.25) return [candidate];
167
+ // `warped` sits in the original's frame, so a residual shift of d means the
168
+ // original at p matches the warp at p + d: sample d further along.
169
+ return [candidate, {
170
+ strategy: `${candidate.strategy}+phase`,
171
+ matrix: multiply(candidate.matrix, translation(shift.dx, shift.dy))
172
+ }];
173
+ }
174
+ function fromExtents(original, scanned, angle) {
175
+ const scale = geometricMean(scanned.width / original.width, scanned.height / original.height);
176
+ return similarity(scale, angle, original.center, scanned.center);
177
+ }
178
+ function push(into, strategy, matrix, maxScaleRatio) {
179
+ if (isPlausible(matrix, maxScaleRatio)) into.push({
180
+ strategy,
181
+ matrix
182
+ });
183
+ }
184
+ /**
185
+ * Geometric rather than arithmetic mean of the two axis ratios.
186
+ *
187
+ * The quantity is a ratio, and the mean of a ratio and its reciprocal should be
188
+ * one. Arithmetic mean says 1.25.
189
+ */
190
+ function geometricMean(a, b) {
191
+ if (Number.isNaN(a) || Number.isNaN(b) || a <= 0 || b <= 0) return NaN;
192
+ return Math.sqrt(a * b);
193
+ }
194
+
195
+ /** Longest side the referee judges at. Enough to see a stroke line up; cheap enough to call per model. */
196
+ const REFEREE_SIZE = 800;
197
+ function createReferee(originalInk, scannedInk, workingSize) {
198
+ const size = Math.min(workingSize, REFEREE_SIZE);
199
+ const original = downscaleGray(originalInk, size);
200
+ const scanned = downscaleGray(scannedInk, size);
201
+ const originalMask = binarize(original.image);
202
+ return matrix => {
203
+ const work = rebase(matrix, 1 / original.scale, 1 / scanned.scale);
204
+ const warped = warpGray(scanned.image, work, original.image.width, original.image.height, 0);
205
+ return {
206
+ correlation: correlation(original.image, warped),
207
+ iou: intersectionOverUnion(originalMask, binarize(warped))
208
+ };
209
+ };
210
+ }
211
+ /** A correlation as a confidence: clamped to `[0, 1]`, because anti-correlated ink is no alignment at all. */
212
+ function toConfidence(agreement) {
213
+ return Math.max(0, Math.min(1, agreement.correlation));
214
+ }
215
+
216
+ const DESCRIPTOR_WORDS = 8;
217
+ const DESCRIPTOR_BITS = DESCRIPTOR_WORDS * 32;
218
+ /** Rotation bins for the steered pattern. 32 bins is 11.25 degrees, finer than ORB's own 12. */
219
+ const ANGLE_BINS = 32;
220
+ /** The Bresenham circle of radius 3, clockwise from the top. */
221
+ const CIRCLE = [[0, -3], [1, -3], [2, -2], [3, -1], [3, 0], [3, 1], [2, 2], [1, 3], [0, 3], [-1, 3], [-2, 2], [-3, 1], [-3, 0], [-3, -1], [-2, -2], [-1, -3]];
222
+ const ARC = 9;
223
+ const COMPASS_MINIMUM = Math.floor((ARC - 1) / 4);
224
+ function detectAndDescribe(image, options = {}) {
225
+ const {
226
+ maxFeatures = 1200,
227
+ fastThreshold = 0.08,
228
+ levels = 3,
229
+ scaleFactor = 1.3,
230
+ patchSize = 31,
231
+ gridSize = 8,
232
+ seed = 0xB81EF
233
+ } = options;
234
+ const patterns = steeredPatterns(patchSize, seed);
235
+ const halfPatch = (patchSize - 1) / 2;
236
+ const border = Math.ceil(halfPatch * Math.SQRT2) + 2;
237
+ const keypoints = [];
238
+ const descriptorChunks = [];
239
+ const perLevel = Math.ceil(maxFeatures / levels);
240
+ for (let level = 0; level < levels; level++) {
241
+ const levelScale = scaleFactor ** level;
242
+ const width = Math.round(image.width / levelScale);
243
+ const height = Math.round(image.height / levelScale);
244
+ if (width < border * 2 + 8 || height < border * 2 + 8) break;
245
+ const levelImage = level === 0 ? image : resizeGray(image, width, height);
246
+ // BRIEF compares single pixels, so it is exquisitely sensitive to noise;
247
+ // the smoothing is part of the descriptor, not a preprocessing nicety.
248
+ const smoothed = boxBlur(levelImage, 2);
249
+ const found = detectFast(levelImage, fastThreshold, border);
250
+ const kept = distribute(found, width, height, gridSize, perLevel);
251
+ for (const corner of kept) {
252
+ const angle = orientation(levelImage, corner.x, corner.y, halfPatch);
253
+ const bin = angleBin(angle);
254
+ const descriptor = describe(smoothed, corner.x, corner.y, patterns[bin]);
255
+ if (descriptor === null) continue;
256
+ descriptorChunks.push(descriptor);
257
+ keypoints.push({
258
+ x: (corner.x + 0.5) * levelScale,
259
+ y: (corner.y + 0.5) * levelScale,
260
+ score: corner.score,
261
+ angle,
262
+ level,
263
+ size: patchSize * levelScale
264
+ });
265
+ }
266
+ }
267
+ const descriptors = new Uint32Array(descriptorChunks.length * DESCRIPTOR_WORDS);
268
+ for (const [i, chunk] of descriptorChunks.entries()) descriptors.set(chunk, i * DESCRIPTOR_WORDS);
269
+ return {
270
+ keypoints,
271
+ descriptors
272
+ };
273
+ }
274
+ /** FAST-9 with a 3x3 non-maximum suppression pass over the corner scores. */
275
+ function detectFast(image, threshold, border) {
276
+ const {
277
+ width,
278
+ height,
279
+ data
280
+ } = image;
281
+ const scores = new Float32Array(width * height);
282
+ const ring = new Float64Array(16);
283
+ for (let y = border; y < height - border; y++) {
284
+ for (let x = border; x < width - border; x++) {
285
+ const center = data[y * width + x];
286
+ const high = center + threshold;
287
+ const low = center - threshold;
288
+ // Cheap rejection on the four compass points, which sit 4 apart on the
289
+ // ring. Any run of ARC consecutive pixels must contain at least
290
+ // floor((ARC - 1) / 4) of them, so fewer than that cannot be a corner.
291
+ // For ARC = 9 that bound is 2, not the 3 that the widely quoted FAST-12
292
+ // version of this test uses - requiring 3 here silently discards real
293
+ // corners, among them the corner of a plain filled rectangle.
294
+ let bright = 0;
295
+ let dark = 0;
296
+ for (const k of [0, 4, 8, 12]) {
297
+ const value = data[(y + CIRCLE[k][1]) * width + x + CIRCLE[k][0]];
298
+ if (value > high) bright++;else if (value < low) dark++;
299
+ }
300
+ if (bright < COMPASS_MINIMUM && dark < COMPASS_MINIMUM) continue;
301
+ for (let k = 0; k < 16; k++) ring[k] = data[(y + CIRCLE[k][1]) * width + x + CIRCLE[k][0]];
302
+ if (!hasArc(ring, high, low)) continue;
303
+ let brightSum = 0;
304
+ let darkSum = 0;
305
+ for (let k = 0; k < 16; k++) {
306
+ if (ring[k] > high) brightSum += ring[k] - high;else if (ring[k] < low) darkSum += low - ring[k];
307
+ }
308
+ scores[y * width + x] = Math.max(brightSum, darkSum);
309
+ }
310
+ }
311
+ const corners = [];
312
+ for (let y = border; y < height - border; y++) {
313
+ for (let x = border; x < width - border; x++) {
314
+ const score = scores[y * width + x];
315
+ if (score <= 0) continue;
316
+ let isPeak = true;
317
+ for (let dy = -1; isPeak && dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) {
318
+ if (dx === 0 && dy === 0) continue;
319
+ if (scores[(y + dy) * width + x + dx] > score) {
320
+ isPeak = false;
321
+ break;
322
+ }
323
+ }
324
+ if (isPeak) corners.push({
325
+ x,
326
+ y,
327
+ score
328
+ });
329
+ }
330
+ }
331
+ return corners;
332
+ }
333
+ /** True when 9 consecutive ring pixels (wrapping) are all above `high` or all below `low`. */
334
+ function hasArc(ring, high, low) {
335
+ let runBright = 0;
336
+ let runDark = 0;
337
+ for (let k = 0; k < 16 + ARC - 1; k++) {
338
+ const value = ring[k % 16];
339
+ runBright = value > high ? runBright + 1 : 0;
340
+ runDark = value < low ? runDark + 1 : 0;
341
+ if (runBright >= ARC || runDark >= ARC) return true;
342
+ }
343
+ return false;
344
+ }
345
+ /**
346
+ * Keep the strongest corners, but spread over the page.
347
+ *
348
+ * Score alone concentrates every keypoint in the densest block of text, and a
349
+ * transform fitted to correspondences from one corner of the page extrapolates
350
+ * badly to the other three. Filling a grid first, then topping up from what is
351
+ * left, buys coverage without throwing away the best corners.
352
+ */
353
+ function distribute(corners, width, height, gridSize, budget) {
354
+ if (corners.length <= budget) return corners;
355
+ const cells = new Map();
356
+ const cellWidth = width / gridSize;
357
+ const cellHeight = height / gridSize;
358
+ for (const corner of corners) {
359
+ const cx = Math.min(gridSize - 1, Math.floor(corner.x / cellWidth));
360
+ const cy = Math.min(gridSize - 1, Math.floor(corner.y / cellHeight));
361
+ const key = cy * gridSize + cx;
362
+ const bucket = cells.get(key);
363
+ if (bucket === undefined) cells.set(key, [corner]);else bucket.push(corner);
364
+ }
365
+ const perCell = Math.max(1, Math.floor(budget / Math.max(1, cells.size)));
366
+ const kept = [];
367
+ const leftovers = [];
368
+ for (const bucket of cells.values()) {
369
+ bucket.sort((a, b) => b.score - a.score);
370
+ kept.push(...bucket.slice(0, perCell));
371
+ leftovers.push(...bucket.slice(perCell));
372
+ }
373
+ if (kept.length < budget) {
374
+ leftovers.sort((a, b) => b.score - a.score);
375
+ kept.push(...leftovers.slice(0, budget - kept.length));
376
+ }
377
+ // With more cells than budget the floor above rounds to one per cell, which
378
+ // can overshoot; the budget is a promise, so trim by score.
379
+ if (kept.length > budget) {
380
+ kept.sort((a, b) => b.score - a.score);
381
+ return kept.slice(0, budget);
382
+ }
383
+ return kept;
384
+ }
385
+ /**
386
+ * Angle from the patch centre to its centre of intensity mass.
387
+ *
388
+ * On an ink image the mass is the writing, so the angle turns with the page —
389
+ * which is the entire trick that makes a binary descriptor rotation invariant.
390
+ */
391
+ function orientation(image, cx, cy, radius) {
392
+ const {
393
+ width,
394
+ height,
395
+ data
396
+ } = image;
397
+ const r = Math.floor(radius);
398
+ let m10 = 0;
399
+ let m01 = 0;
400
+ for (let dy = -r; dy <= r; dy++) {
401
+ const y = cy + dy;
402
+ if (y < 0 || y >= height) continue;
403
+ const span = Math.floor(Math.sqrt(r * r - dy * dy));
404
+ const row = y * width;
405
+ for (let dx = -span; dx <= span; dx++) {
406
+ const x = cx + dx;
407
+ if (x < 0 || x >= width) continue;
408
+ const value = data[row + x];
409
+ m10 += dx * value;
410
+ m01 += dy * value;
411
+ }
412
+ }
413
+ return Math.atan2(m01, m10);
414
+ }
415
+ function angleBin(angle) {
416
+ const twoPi = Math.PI * 2;
417
+ const normalized = (angle % twoPi + twoPi) % twoPi;
418
+ return Math.floor(normalized / twoPi * ANGLE_BINS) % ANGLE_BINS;
419
+ }
420
+ function describe(image, cx, cy, pattern) {
421
+ const {
422
+ width,
423
+ height,
424
+ data
425
+ } = image;
426
+ const out = new Uint32Array(DESCRIPTOR_WORDS);
427
+ for (let bit = 0; bit < DESCRIPTOR_BITS; bit++) {
428
+ const base = bit * 4;
429
+ const x1 = cx + pattern[base];
430
+ const y1 = cy + pattern[base + 1];
431
+ const x2 = cx + pattern[base + 2];
432
+ const y2 = cy + pattern[base + 3];
433
+ if (x1 < 0 || y1 < 0 || x2 < 0 || y2 < 0 || x1 >= width || y1 >= height || x2 >= width || y2 >= height) return null;
434
+ if (data[y1 * width + x1] < data[y2 * width + x2]) out[bit >> 5] |= 1 << (bit & 31);
435
+ }
436
+ return out;
437
+ }
438
+ const patternCache = new Map();
439
+ /**
440
+ * One integer sampling pattern per rotation bin, built once and cached.
441
+ *
442
+ * Rotating 256 point pairs per keypoint would mean a thousand trig calls each;
443
+ * quantising the angle instead turns the whole thing into a table lookup, at a
444
+ * cost of at most half a bin of angular error.
445
+ */
446
+ function steeredPatterns(patchSize, seed) {
447
+ const key = `${patchSize}:${seed}`;
448
+ const cached = patternCache.get(key);
449
+ if (cached !== undefined) return cached;
450
+ const half = (patchSize - 1) / 2;
451
+ const sigma = patchSize / 5;
452
+ const random = createRandom(seed);
453
+ const base = new Float64Array(DESCRIPTOR_BITS * 4);
454
+ for (let i = 0; i < DESCRIPTOR_BITS * 4; i++) base[i] = clamp(Math.round(gaussian(random) * sigma), -half, half);
455
+ const patterns = [];
456
+ for (let bin = 0; bin < ANGLE_BINS; bin++) {
457
+ const angle = bin / ANGLE_BINS * Math.PI * 2;
458
+ const cos = Math.cos(angle);
459
+ const sin = Math.sin(angle);
460
+ const rotated = new Int32Array(DESCRIPTOR_BITS * 4);
461
+ for (let i = 0; i < DESCRIPTOR_BITS * 4; i += 2) {
462
+ const x = base[i];
463
+ const y = base[i + 1];
464
+ rotated[i] = Math.round(cos * x - sin * y);
465
+ rotated[i + 1] = Math.round(sin * x + cos * y);
466
+ }
467
+ patterns.push(rotated);
468
+ }
469
+ patternCache.set(key, patterns);
470
+ return patterns;
471
+ }
472
+ function clamp(value, min, max) {
473
+ return value < min ? min : Math.min(value, max);
474
+ }
475
+
476
+ /**
477
+ * "No candidate yet", as a value an Int32Array can actually hold.
478
+ *
479
+ * `Number.MAX_SAFE_INTEGER` cannot: writing it to an Int32Array truncates it to
480
+ * -1, and every subsequent "is this closer?" comparison then answers no.
481
+ */
482
+ const UNSET = 0x7FFFFFFF;
483
+ function matchFeatures(source, target, options = {}) {
484
+ const {
485
+ ratio = 0.8,
486
+ maxDistance = 96,
487
+ crossCheck = true,
488
+ maxDisplacement = Infinity
489
+ } = options;
490
+ const n = source.keypoints.length;
491
+ const m = target.keypoints.length;
492
+ if (n === 0 || m === 0) return [];
493
+ const bestForSource = new Int32Array(n).fill(-1);
494
+ const bestDistance = new Int32Array(n).fill(UNSET);
495
+ const secondDistance = new Int32Array(n).fill(UNSET);
496
+ const bestForTarget = new Int32Array(m).fill(-1);
497
+ const bestTargetDistance = new Int32Array(m).fill(UNSET);
498
+ const gated = Number.isFinite(maxDisplacement);
499
+ const gate = maxDisplacement * maxDisplacement;
500
+ for (let i = 0; i < n; i++) {
501
+ const a = source.keypoints[i];
502
+ const offsetA = i * DESCRIPTOR_WORDS;
503
+ let first = UNSET;
504
+ let second = UNSET;
505
+ let firstIndex = -1;
506
+ for (let j = 0; j < m; j++) {
507
+ const b = target.keypoints[j];
508
+ if (gated) {
509
+ const dx = a.x - b.x;
510
+ const dy = a.y - b.y;
511
+ if (dx * dx + dy * dy > gate) continue;
512
+ }
513
+ const distance = hamming(source.descriptors, offsetA, target.descriptors, j * DESCRIPTOR_WORDS);
514
+ if (distance < first) {
515
+ second = first;
516
+ first = distance;
517
+ firstIndex = j;
518
+ } else if (distance < second) {
519
+ second = distance;
520
+ }
521
+ if (distance < bestTargetDistance[j]) {
522
+ bestTargetDistance[j] = distance;
523
+ bestForTarget[j] = i;
524
+ }
525
+ }
526
+ bestForSource[i] = firstIndex;
527
+ bestDistance[i] = first;
528
+ secondDistance[i] = second;
529
+ }
530
+ const matches = [];
531
+ for (let i = 0; i < n; i++) {
532
+ const j = bestForSource[i];
533
+ if (j < 0) continue;
534
+ if (bestDistance[i] > maxDistance) continue;
535
+ if (secondDistance[i] !== UNSET && bestDistance[i] > ratio * secondDistance[i]) continue;
536
+ if (crossCheck && bestForTarget[j] !== i) continue;
537
+ const a = source.keypoints[i];
538
+ const b = target.keypoints[j];
539
+ matches.push({
540
+ source: {
541
+ x: a.x,
542
+ y: a.y
543
+ },
544
+ target: {
545
+ x: b.x,
546
+ y: b.y
547
+ },
548
+ distance: bestDistance[i]
549
+ });
550
+ }
551
+ return matches;
552
+ }
553
+ /** Hamming distance between two 256-bit descriptors. */
554
+ function hamming(a, offsetA, b, offsetB) {
555
+ let total = 0;
556
+ for (let k = 0; k < DESCRIPTOR_WORDS; k++) total += popcount(a[offsetA + k] ^ b[offsetB + k]);
557
+ return total;
558
+ }
559
+ /** SWAR bit count: pair off, then nibble off, then one multiply to sum the bytes. */
560
+ function popcount(value) {
561
+ let v = value - (value >> 1 & 0x55555555);
562
+ v = (v & 0x33333333) + (v >> 2 & 0x33333333);
563
+ v = v + (v >> 4) & 0x0F0F0F0F;
564
+ return Math.imul(v, 0x01010101) >> 24 & 0xFF;
565
+ }
566
+
567
+ /** How many correspondences the model needs before it is determined at all. */
568
+ function minimumSamples(model) {
569
+ switch (model) {
570
+ case 'similarity':
571
+ {
572
+ return 2;
573
+ }
574
+ case 'affine':
575
+ {
576
+ return 3;
577
+ }
578
+ case 'homography':
579
+ {
580
+ return 4;
581
+ }
582
+ }
583
+ }
584
+ function fitModel(model, matches, indices) {
585
+ switch (model) {
586
+ case 'similarity':
587
+ {
588
+ return fitSimilarity(matches, indices);
589
+ }
590
+ case 'affine':
591
+ {
592
+ return fitAffine(matches, indices);
593
+ }
594
+ case 'homography':
595
+ {
596
+ return fitHomography(matches, indices);
597
+ }
598
+ }
599
+ }
600
+ /**
601
+ * Least-squares similarity, in closed form.
602
+ *
603
+ * No iteration and no matrix inverse: centre both point sets, and the rotation
604
+ * and scale fall out of two dot products. That closed form is why similarity
605
+ * survives a RANSAC sample that affine would choke on.
606
+ */
607
+ function fitSimilarity(matches, indices) {
608
+ const picked = select(matches, indices);
609
+ if (picked.length < 2) return null;
610
+ let sx = 0;
611
+ let sy = 0;
612
+ let tx = 0;
613
+ let ty = 0;
614
+ for (const m of picked) {
615
+ sx += m.source.x;
616
+ sy += m.source.y;
617
+ tx += m.target.x;
618
+ ty += m.target.y;
619
+ }
620
+ const n = picked.length;
621
+ sx /= n;
622
+ sy /= n;
623
+ tx /= n;
624
+ ty /= n;
625
+ let dot = 0;
626
+ let cross = 0;
627
+ let norm = 0;
628
+ for (const m of picked) {
629
+ const px = m.source.x - sx;
630
+ const py = m.source.y - sy;
631
+ const qx = m.target.x - tx;
632
+ const qy = m.target.y - ty;
633
+ dot += px * qx + py * qy;
634
+ cross += px * qy - py * qx;
635
+ norm += px * px + py * py;
636
+ }
637
+ if (norm < 1e-12) return null;
638
+ const a = dot / norm;
639
+ const b = cross / norm;
640
+ if (Math.hypot(a, b) < 1e-9) return null;
641
+ return [a, -b, tx - a * sx + b * sy, b, a, ty - b * sx - a * sy, 0, 0, 1];
642
+ }
643
+ /** Least-squares affine: two independent 3x3 normal systems sharing one matrix. */
644
+ function fitAffine(matches, indices) {
645
+ const picked = select(matches, indices);
646
+ if (picked.length < 3) return null;
647
+ const m = new Float64Array(9);
648
+ const bx = new Float64Array(3);
649
+ const by = new Float64Array(3);
650
+ for (const match of picked) {
651
+ const {
652
+ x,
653
+ y
654
+ } = match.source;
655
+ const {
656
+ x: u,
657
+ y: v
658
+ } = match.target;
659
+ m[0] += x * x;
660
+ m[1] += x * y;
661
+ m[2] += x;
662
+ m[4] += y * y;
663
+ m[5] += y;
664
+ m[8] += 1;
665
+ bx[0] += x * u;
666
+ bx[1] += y * u;
667
+ bx[2] += u;
668
+ by[0] += x * v;
669
+ by[1] += y * v;
670
+ by[2] += v;
671
+ }
672
+ m[3] = m[1];
673
+ m[6] = m[2];
674
+ m[7] = m[5];
675
+ const row0 = solve(m, bx, 3);
676
+ const row1 = solve(m, by, 3);
677
+ if (row0 === null || row1 === null) return null;
678
+ return [row0[0], row0[1], row0[2], row1[0], row1[1], row1[2], 0, 0, 1];
679
+ }
680
+ /**
681
+ * Direct Linear Transform with Hartley normalisation.
682
+ *
683
+ * The normalisation is not optional polish. Raw pixel coordinates put entries
684
+ * like `x * u` (order 10^6) next to a constant 1 in the same row, and the
685
+ * eigen solve then answers a question dominated by the big column. Centring
686
+ * each point set and scaling it to a mean radius of `sqrt(2)` puts every
687
+ * column on the same footing; the result is mapped back afterwards.
688
+ */
689
+ function fitHomography(matches, indices) {
690
+ const picked = select(matches, indices);
691
+ if (picked.length < 4) return null;
692
+ const sourceNorm = normalizer(picked.map(m => m.source));
693
+ const targetNorm = normalizer(picked.map(m => m.target));
694
+ if (sourceNorm === null || targetNorm === null) return null;
695
+ // Accumulate A^T A directly: 9x9 regardless of how many points there are.
696
+ const ata = new Float64Array(81);
697
+ const row = new Float64Array(9);
698
+ for (const match of picked) {
699
+ const p = apply(sourceNorm, match.source.x, match.source.y);
700
+ const q = apply(targetNorm, match.target.x, match.target.y);
701
+ row.set([-p.x, -p.y, -1, 0, 0, 0, q.x * p.x, q.x * p.y, q.x]);
702
+ accumulate(ata, row);
703
+ row.set([0, 0, 0, -p.x, -p.y, -1, q.y * p.x, q.y * p.y, q.y]);
704
+ accumulate(ata, row);
705
+ }
706
+ const h = smallestEigenvector(ata, 9);
707
+ const normalized = [h[0], h[1], h[2], h[3], h[4], h[5], h[6], h[7], h[8]];
708
+ let denormalized;
709
+ try {
710
+ denormalized = multiply(invert(targetNorm), multiply(normalized, sourceNorm));
711
+ } catch {
712
+ return null;
713
+ }
714
+ const scale = denormalized[8];
715
+ if (!Number.isFinite(scale) || Math.abs(scale) < 1e-12) return null;
716
+ return denormalized.map(v => v / scale);
717
+ }
718
+ function accumulate(ata, row) {
719
+ for (let i = 0; i < 9; i++) {
720
+ const vi = row[i];
721
+ if (vi === 0) continue;
722
+ for (let j = 0; j < 9; j++) ata[i * 9 + j] += vi * row[j];
723
+ }
724
+ }
725
+ /** Translate to the centroid and scale so the mean distance from it is `sqrt(2)`. */
726
+ function normalizer(points) {
727
+ let cx = 0;
728
+ let cy = 0;
729
+ for (const p of points) {
730
+ cx += p.x;
731
+ cy += p.y;
732
+ }
733
+ cx /= points.length;
734
+ cy /= points.length;
735
+ let distance = 0;
736
+ for (const p of points) distance += Math.hypot(p.x - cx, p.y - cy);
737
+ distance /= points.length;
738
+ // Explicit about NaN: a degenerate point set must fail, not divide.
739
+ if (Number.isNaN(distance) || distance <= 1e-9) return null;
740
+ const s = Math.SQRT2 / distance;
741
+ return [s, 0, -s * cx, 0, s, -s * cy, 0, 0, 1];
742
+ }
743
+ function apply(m, x, y) {
744
+ return {
745
+ x: m[0] * x + m[1] * y + m[2],
746
+ y: m[3] * x + m[4] * y + m[5]
747
+ };
748
+ }
749
+ function select(matches, indices) {
750
+ return indices === undefined ? matches : indices.map(i => matches[i]);
751
+ }
752
+
753
+ function ransac(matches, options) {
754
+ const {
755
+ model,
756
+ threshold,
757
+ maxIterations = 2000,
758
+ confidence = 0.995,
759
+ seed = 0x5CA7F1
760
+ } = options;
761
+ const sampleSize = minimumSamples(model);
762
+ const minInliers = options.minInliers ?? Math.max(sampleSize + 2, Math.ceil(matches.length * 0.08));
763
+ if (matches.length < Math.max(sampleSize, minInliers)) return null;
764
+ const random = createRandom(seed);
765
+ const sample = Array.from({
766
+ length: sampleSize
767
+ }, () => 0);
768
+ let bestInliers = [];
769
+ let bestMatrix = null;
770
+ let limit = maxIterations;
771
+ let iterations = 0;
772
+ for (; iterations < limit && iterations < maxIterations; iterations++) {
773
+ drawSample(sample, matches.length, random);
774
+ const candidate = fitModel(model, matches, sample);
775
+ if (candidate === null || !isPlausible(candidate)) continue;
776
+ const inliers = findInliers(matches, candidate, threshold);
777
+ if (inliers.length <= bestInliers.length) continue;
778
+ bestInliers = inliers;
779
+ bestMatrix = candidate;
780
+ // Adaptive stopping: once a large fraction agrees, the chance that more
781
+ // draws find something better collapses, and so does the budget.
782
+ const ratio = inliers.length / matches.length;
783
+ if (ratio > 0 && ratio < 1) {
784
+ const denominator = Math.log(1 - ratio ** sampleSize);
785
+ if (denominator < 0) limit = Math.min(maxIterations, Math.ceil(Math.log(1 - confidence) / denominator) + 1);
786
+ } else if (ratio >= 1) {
787
+ limit = iterations + 1;
788
+ }
789
+ }
790
+ if (bestMatrix === null || bestInliers.length < minInliers) return null;
791
+ // Re-fit on every inlier. The minimal sample only ever located the consensus;
792
+ // the accurate transform comes from all of it.
793
+ let refined = fitModel(model, matches, bestInliers);
794
+ if (refined !== null && isPlausible(refined)) {
795
+ const refinedInliers = findInliers(matches, refined, threshold);
796
+ if (refinedInliers.length >= bestInliers.length) bestInliers = refinedInliers;else refined = bestMatrix;
797
+ } else {
798
+ refined = bestMatrix;
799
+ }
800
+ const matrix = refined ?? bestMatrix;
801
+ let total = 0;
802
+ for (const i of bestInliers) total += reprojectionError(matrix, matches[i].source, matches[i].target);
803
+ return {
804
+ matrix,
805
+ inliers: bestInliers,
806
+ inlierRatio: bestInliers.length / matches.length,
807
+ iterations,
808
+ error: bestInliers.length > 0 ? total / bestInliers.length : Infinity
809
+ };
810
+ }
811
+ function findInliers(matches, matrix, threshold) {
812
+ const inliers = [];
813
+ for (const [i, match] of matches.entries()) if (reprojectionError(matrix, match.source, match.target) <= threshold) inliers.push(i);
814
+ return inliers;
815
+ }
816
+ /** Distinct indices, drawn without replacement. */
817
+ function drawSample(into, count, random) {
818
+ for (let i = 0; i < into.length; i++) {
819
+ let candidate = 0;
820
+ for (let attempt = 0; attempt < 32; attempt++) {
821
+ candidate = Math.min(count - 1, Math.floor(random() * count));
822
+ let duplicate = false;
823
+ for (let j = 0; j < i; j++) if (into[j] === candidate) {
824
+ duplicate = true;
825
+ break;
826
+ }
827
+ if (!duplicate) break;
828
+ }
829
+ into[i] = candidate;
830
+ }
831
+ }
832
+
833
+ function prepareMatches(originalInk, scannedInk, coarse, options) {
834
+ const original = downscaleGray(originalInk, options.workingSize);
835
+ const scanned = downscaleGray(scannedInk, options.workingSize);
836
+ // The coarse matrix speaks full-resolution pixels; restate it between the two
837
+ // working frames, which were shrunk by different amounts.
838
+ const coarseWork = rebase(coarse.matrix, 1 / original.scale, 1 / scanned.scale);
839
+ const rough = warpGray(scanned.image, coarseWork, original.image.width, original.image.height, 0);
840
+ const originalFeatures = detectAndDescribe(original.image, {
841
+ maxFeatures: options.maxFeatures,
842
+ seed: options.seed
843
+ });
844
+ const scannedFeatures = detectAndDescribe(rough, {
845
+ maxFeatures: options.maxFeatures,
846
+ seed: options.seed
847
+ });
848
+ const diagonal = Math.hypot(original.image.width, original.image.height);
849
+ const matches = matchFeatures(originalFeatures, scannedFeatures, {
850
+ maxDisplacement: diagonal * options.maxDisplacementRatio
851
+ });
852
+ return {
853
+ matches,
854
+ features: {
855
+ original: originalFeatures.keypoints.length,
856
+ scanned: scannedFeatures.keypoints.length
857
+ },
858
+ scale: original.scale
859
+ };
860
+ }
861
+ /** Fit one model to the shared matches, or `null` when RANSAC finds no consensus worth trusting. */
862
+ function fitResidual(prepared, coarse, model, options) {
863
+ const consensus = ransac(prepared.matches, {
864
+ model,
865
+ threshold: options.ransacThreshold,
866
+ minInliers: options.minInliers,
867
+ seed: options.seed
868
+ });
869
+ if (consensus === null) return null;
870
+ // RANSAC's matrix maps the original's working frame onto the rough warp,
871
+ // which lives in that same frame. Scale it back up, then compose: original ->
872
+ // rough -> scan.
873
+ const residual = conjugateScale(consensus.matrix, prepared.scale);
874
+ return {
875
+ matrix: multiply(coarse.matrix, residual),
876
+ inliers: consensus.inliers.length,
877
+ inlierRatio: consensus.inlierRatio,
878
+ reprojectionError: consensus.error
879
+ };
880
+ }
881
+
882
+ /**
883
+ * Which transform to believe, when more than one fits.
884
+ *
885
+ * More degrees of freedom always fit at least as well, so a plain "highest
886
+ * confidence wins" would drift towards the most flexible model on every input.
887
+ * On a flatbed scan that is wrong in a way nothing downstream would notice: a
888
+ * homography fitted to a flat page bends slightly to follow the page's own noise,
889
+ * beats the similarity on ink correlation by a hair, passes `isPlausible`, and
890
+ * puts every region a pixel or two off. So a more complex model has to *earn* its
891
+ * extra parameters by a margin, and a simpler one within that margin is kept.
892
+ */
893
+ /** Ascending cost, ascending fragility: the order a sweep should try them in. */
894
+ const DEFAULT_MODELS = ['similarity', 'affine', 'homography'];
895
+ const DEGREES_OF_FREEDOM = {
896
+ similarity: 4,
897
+ affine: 6,
898
+ homography: 8
899
+ };
900
+ /**
901
+ * Should `candidate` replace `incumbent` as the answer?
902
+ *
903
+ * - A more complex candidate must beat the incumbent by more than `margin`.
904
+ * - A simpler candidate wins if it comes within `margin` of the incumbent.
905
+ * - An equally complex one simply has to do better.
906
+ *
907
+ * Symmetric on purpose, so the answer does not depend on the order the models
908
+ * were tried in: whatever order `models` names, the simplest model within the
909
+ * margin of the best is the one returned.
910
+ */
911
+ function prefers(candidate, incumbent, margin) {
912
+ if (incumbent === null) return true;
913
+ const extra = DEGREES_OF_FREEDOM[candidate.model] - DEGREES_OF_FREEDOM[incumbent.model];
914
+ if (extra > 0) return candidate.confidence > incumbent.confidence + margin;
915
+ if (extra < 0) return candidate.confidence >= incumbent.confidence - margin;
916
+ return candidate.confidence > incumbent.confidence;
917
+ }
918
+ /** The models to sweep, in the order given, each once. Throws on an empty list rather than silently fitting nothing. */
919
+ function sweepOrder(models) {
920
+ const unique = [...new Set(models)];
921
+ if (unique.length === 0) throw new RangeError('models must name at least one transform model');
922
+ return unique;
923
+ }
924
+
925
+ /**
926
+ * Align a scan onto the page it was made from.
927
+ *
928
+ * ## What this is for
929
+ *
930
+ * Two questions about a returned form are easy to answer once the scan sits
931
+ * exactly on top of the original, and near-impossible before:
932
+ *
933
+ * 1. *Was anything in the printed text changed?* Run OCR on both and diff.
934
+ * That only works if the two are the same page at the same size, otherwise
935
+ * the OCR engine's own layout analysis is comparing different documents.
936
+ * 2. *Was the box at (x, y) signed?* That is a question about a fixed
937
+ * rectangle, and a fixed rectangle only means something once both images
938
+ * agree on where (x, y) is. See `@scanmate/diff`.
939
+ *
940
+ * ## The pipeline
941
+ *
942
+ * ```text
943
+ * decode ─► ink ─► coarse guess ─► rough warp ─► features ─┬─► RANSAC(similarity) ─► score ─┐
944
+ * (scale/skew) (ORB) ├─► RANSAC(affine) ─► score ─┼─► warp
945
+ * └─► RANSAC(homography) ─► score ─┘
946
+ * └──────────────────── once, whatever the model ───────────┘ └──── per model, cheap ────┘
947
+ * ```
948
+ *
949
+ * The coarse guess exists to make the feature stage possible at all: binary
950
+ * descriptors compare fixed pixel offsets, so they only match between images
951
+ * at comparable scale, and nothing in a JPEG tells you what dpi it was scanned
952
+ * at. Once the scan has been resampled to roughly the right size, matching is
953
+ * easy and RANSAC can throw away the inevitable wrong matches - a page of text
954
+ * is full of genuinely identical-looking corners.
955
+ *
956
+ * ## Choosing the model
957
+ *
958
+ * With `model: 'all'`, the default, everything left of the fork is done once:
959
+ * decoding, ink separation, the coarse search, ORB on both pages and matching
960
+ * are nearly all of the cost and do not depend on the transform family. Only
961
+ * RANSAC and one scoring warp run per model, and neither is expensive - RANSAC
962
+ * touches no pixels. The sweep tries models cheapest first, stops as soon as one
963
+ * reaches `confidenceTarget`, and a more complex model must beat a simpler one by
964
+ * `modelPreferenceMargin` to replace it. The full-resolution warp and the
965
+ * encode happen once, for the winner.
966
+ *
967
+ * If no model finds a consensus (a nearly blank form has few corners to find),
968
+ * the coarse estimate is returned on its own, and `method` says so.
969
+ *
970
+ * ## Why it is asynchronous
971
+ *
972
+ * The estimator is CPU-bound with no I/O to wait on, and an earlier version of
973
+ * this function was synchronous to say so. The codec changed that: decoding and
974
+ * encoding now run in libvips on libuv's threadpool, roughly an order of
975
+ * magnitude faster than the pure-JavaScript codec they replaced, and during
976
+ * those two stages the event loop genuinely is free. Between them it is not -
977
+ * the coarse search, ORB and RANSAC all run to completion on this thread - so
978
+ * to align several pages at once, still put this in a worker thread.
979
+ */
980
+ async function alignScan(original, scanned, options = {}) {
981
+ const startedAt = Date.now();
982
+ const {
983
+ model = 'all',
984
+ confidenceTarget = 0.9,
985
+ models = DEFAULT_MODELS,
986
+ modelPreferenceMargin = 0.02,
987
+ workingSize = 1400,
988
+ coarseSize = 512,
989
+ maxFeatures = 1200,
990
+ ransacThreshold = 3,
991
+ minInliers = 12,
992
+ maxSkewDeg = 12,
993
+ maxScaleRatio = 6,
994
+ maxDisplacementRatio = 0.12,
995
+ ink,
996
+ interpolation = 'bilinear',
997
+ background = [255, 255, 255, 255],
998
+ output = 'png',
999
+ quality = 92,
1000
+ seed = 0x5CA7F1
1001
+ } = options;
1002
+ const candidates = model === 'all' ? sweepOrder(models) : [model];
1003
+ const originalRaster = await decodeImage(original);
1004
+ const scannedRaster = await decodeImage(scanned);
1005
+ const originalInk = inkMap(toGrayscale(originalRaster), ink);
1006
+ const scannedInk = inkMap(toGrayscale(scannedRaster), ink);
1007
+ // --- Model-independent, and nearly all of the cost: done once. ---
1008
+ const coarse = estimateCoarse(originalInk, scannedInk, {
1009
+ workingSize: coarseSize,
1010
+ maxSkewDeg,
1011
+ maxScaleRatio
1012
+ });
1013
+ const prepared = prepareMatches(originalInk, scannedInk, coarse, {
1014
+ workingSize,
1015
+ maxFeatures,
1016
+ maxDisplacementRatio,
1017
+ seed
1018
+ });
1019
+ const judge = createReferee(originalInk, scannedInk, workingSize);
1020
+ // --- Per model: RANSAC, one scoring warp. ---
1021
+ const attempts = [];
1022
+ let best = null;
1023
+ for (const candidate of candidates) {
1024
+ const fit = fitResidual(prepared, coarse, candidate, {
1025
+ ransacThreshold,
1026
+ minInliers,
1027
+ seed
1028
+ });
1029
+ if (fit === null) {
1030
+ attempts.push({
1031
+ model: candidate,
1032
+ confidence: null,
1033
+ inliers: 0,
1034
+ inlierRatio: 0,
1035
+ reprojectionError: NaN,
1036
+ rejected: true,
1037
+ selected: false
1038
+ });
1039
+ continue;
1040
+ }
1041
+ const agreement = judge(fit.matrix);
1042
+ const confidence = toConfidence(agreement);
1043
+ const attempt = {
1044
+ model: candidate,
1045
+ confidence,
1046
+ inliers: fit.inliers,
1047
+ inlierRatio: fit.inlierRatio,
1048
+ reprojectionError: fit.reprojectionError,
1049
+ rejected: false,
1050
+ selected: false
1051
+ };
1052
+ attempts.push(attempt);
1053
+ if (prefers({
1054
+ model: candidate,
1055
+ confidence
1056
+ }, best, modelPreferenceMargin)) best = {
1057
+ model: candidate,
1058
+ fit,
1059
+ agreement,
1060
+ confidence,
1061
+ attempt
1062
+ };
1063
+ if (best !== null && best.confidence >= confidenceTarget) break;
1064
+ }
1065
+ // --- Once, for the winner: the full-resolution warp and the encode. ---
1066
+ const matrix = best === null ? coarse.matrix : best.fit.matrix;
1067
+ const agreement = best === null ? judge(matrix) : best.agreement;
1068
+ // The coarse estimate is a similarity; that is what it reports when it stands alone.
1069
+ const selectedModel = best === null ? 'similarity' : best.model;
1070
+ if (best !== null) best.attempt.selected = true;
1071
+ const raster = warpRaster(scannedRaster, matrix, originalRaster.width, originalRaster.height, {
1072
+ background,
1073
+ interpolation,
1074
+ prefilter: true
1075
+ });
1076
+ return {
1077
+ raster,
1078
+ image: output === 'none' ? null : await encodeImage(raster, {
1079
+ format: output,
1080
+ quality
1081
+ }),
1082
+ width: raster.width,
1083
+ height: raster.height,
1084
+ matrix,
1085
+ inverse: invert(matrix),
1086
+ transform: decompose(matrix, selectedModel),
1087
+ confidence: toConfidence(agreement),
1088
+ method: best === null ? 'coarse' : 'features',
1089
+ diagnostics: {
1090
+ coarseScore: coarse.score,
1091
+ coarseStrategy: coarse.strategy,
1092
+ skewDeg: {
1093
+ original: coarse.skew.original * 180 / Math.PI,
1094
+ scanned: coarse.skew.scanned * 180 / Math.PI
1095
+ },
1096
+ features: prepared.features,
1097
+ matches: prepared.matches.length,
1098
+ inliers: best?.fit.inliers ?? 0,
1099
+ inlierRatio: best?.fit.inlierRatio ?? 0,
1100
+ reprojectionError: best?.fit.reprojectionError ?? NaN,
1101
+ correlation: agreement.correlation,
1102
+ intersectionOverUnion: agreement.iou,
1103
+ selectedModel,
1104
+ attempts,
1105
+ durationMs: Date.now() - startedAt
1106
+ }
1107
+ };
1108
+ }
1109
+
1110
+ /**
1111
+ * Align every page pair a document produced - typically the output of
1112
+ * `@scanmate/extract` - and hand each back with its alignment attached.
1113
+ *
1114
+ * Pages run one after another, not concurrently. The estimator is CPU-bound and
1115
+ * synchronous between decode and encode, so starting several on one thread only
1116
+ * interleaves them and makes each slower; real parallelism needs worker threads,
1117
+ * which is a decision for the caller or an orchestrator, not for a library call.
1118
+ *
1119
+ * Every page passes through untouched with `aligned` added, and the types say
1120
+ * so: whatever else the producer attached - `@scanmate/extract`'s per-page
1121
+ * `metadata`, the dpi each side was rendered at, the encoded bytes - is still
1122
+ * there, and still typed, on the way out.
1123
+ */
1124
+ async function alignPages(pages, options = {}) {
1125
+ const {
1126
+ onProgress,
1127
+ ...alignOptions
1128
+ } = options;
1129
+ const aligned = [];
1130
+ for (const [position, page] of pages.entries()) {
1131
+ const index = position + 1;
1132
+ onProgress?.({
1133
+ stage: 'align',
1134
+ phase: 'start',
1135
+ page: page.page,
1136
+ index,
1137
+ total: pages.length
1138
+ });
1139
+ const result = await alignScan(page.original.raster, page.scanned.raster, alignOptions);
1140
+ aligned.push({
1141
+ ...page,
1142
+ aligned: result
1143
+ });
1144
+ onProgress?.({
1145
+ stage: 'align',
1146
+ phase: 'done',
1147
+ page: page.page,
1148
+ index,
1149
+ total: pages.length,
1150
+ durationMs: result.diagnostics.durationMs,
1151
+ detail: {
1152
+ confidence: result.confidence,
1153
+ model: result.diagnostics.selectedModel,
1154
+ method: result.method,
1155
+ attempts: result.diagnostics.attempts.length
1156
+ }
1157
+ });
1158
+ }
1159
+ return aligned;
1160
+ }
1161
+
1162
+ /**
1163
+ * Nudge an existing transform by whatever residual translation is still measurable.
1164
+ *
1165
+ * Exposed because it is occasionally useful on its own: if you already know the
1166
+ * transform from a previous page of the same batch, this re-seats it on the
1167
+ * current page for a fraction of the cost of a full alignment.
1168
+ */
1169
+ function polishTranslation(originalInk, scannedInk, matrix, workingSize = 512) {
1170
+ const original = downscaleGray(originalInk, workingSize);
1171
+ const scanned = downscaleGray(scannedInk, workingSize);
1172
+ const work = rebase(matrix, 1 / original.scale, 1 / scanned.scale);
1173
+ const warped = warpGray(scanned.image, work, original.image.width, original.image.height, 0);
1174
+ const shift = phaseCorrelate(original.image, warped);
1175
+ if (!Number.isFinite(shift.dx) || !Number.isFinite(shift.dy)) return matrix;
1176
+ const corrected = multiply(work, translation(shift.dx, shift.dy));
1177
+ const candidate = rebase(corrected, original.scale, scanned.scale);
1178
+ const before = correlation(original.image, warped);
1179
+ const after = correlation(original.image, warpGray(scanned.image, corrected, original.image.width, original.image.height, 0));
1180
+ return after > before ? candidate : matrix;
1181
+ }
1182
+
1183
+ export { DEFAULT_MODELS, alignPages, alignScan, detectAndDescribe, estimateCoarse, findInliers, fitAffine, fitHomography, fitModel, fitSimilarity, hamming, matchFeatures, minimumSamples, phaseCorrelate, polishTranslation, popcount, prefers, ransac };
1184
+ //# sourceMappingURL=index.esm.js.map