@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.
- package/LICENSE +21 -0
- package/README.md +230 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.esm.js +1184 -0
- package/dist/src/coarse-estimation/estimate-coarse.use-case.d.ts +43 -0
- package/dist/src/coarse-estimation/index.d.ts +4 -0
- package/dist/src/feature-matching/detect-features.use-case.d.ts +72 -0
- package/dist/src/feature-matching/index.d.ts +6 -0
- package/dist/src/feature-matching/match-features.use-case.d.ts +38 -0
- package/dist/src/index.d.ts +28 -0
- package/dist/src/phase-correlation/index.d.ts +4 -0
- package/dist/src/phase-correlation/phase-correlate.use-case.d.ts +34 -0
- package/dist/src/scan-alignment/align-pages.use-case.d.ts +22 -0
- package/dist/src/scan-alignment/align-result.contract.d.ts +116 -0
- package/dist/src/scan-alignment/align-scan.use-case.d.ts +59 -0
- package/dist/src/scan-alignment/alignment-referee.use-case.d.ts +19 -0
- package/dist/src/scan-alignment/feature-refinement.use-case.d.ts +52 -0
- package/dist/src/scan-alignment/index.d.ts +9 -0
- package/dist/src/scan-alignment/model-selection.policy.d.ts +34 -0
- package/dist/src/scan-alignment/polish-translation.use-case.d.ts +10 -0
- package/dist/src/transform-fitting/fit-transform.use-case.d.ts +41 -0
- package/dist/src/transform-fitting/index.d.ts +6 -0
- package/dist/src/transform-fitting/ransac.use-case.d.ts +36 -0
- package/package.json +50 -0
|
@@ -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
|