@scanmate/image-fix 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +291 -0
  3. package/dist/index.d.ts +1 -0
  4. package/dist/index.esm.js +2786 -0
  5. package/dist/index.esm.js.map +1 -0
  6. package/dist/src/index.d.ts +58 -0
  7. package/dist/src/index.d.ts.map +1 -0
  8. package/dist/src/lib/align.d.ts +136 -0
  9. package/dist/src/lib/align.d.ts.map +1 -0
  10. package/dist/src/lib/analysis/content.d.ts +50 -0
  11. package/dist/src/lib/analysis/content.d.ts.map +1 -0
  12. package/dist/src/lib/analysis/score.d.ts +18 -0
  13. package/dist/src/lib/analysis/score.d.ts.map +1 -0
  14. package/dist/src/lib/estimate/coarse.d.ts +43 -0
  15. package/dist/src/lib/estimate/coarse.d.ts.map +1 -0
  16. package/dist/src/lib/estimate/features.d.ts +72 -0
  17. package/dist/src/lib/estimate/features.d.ts.map +1 -0
  18. package/dist/src/lib/estimate/match.d.ts +38 -0
  19. package/dist/src/lib/estimate/match.d.ts.map +1 -0
  20. package/dist/src/lib/estimate/models.d.ts +41 -0
  21. package/dist/src/lib/estimate/models.d.ts.map +1 -0
  22. package/dist/src/lib/estimate/phaseCorrelation.d.ts +34 -0
  23. package/dist/src/lib/estimate/phaseCorrelation.d.ts.map +1 -0
  24. package/dist/src/lib/estimate/ransac.d.ts +36 -0
  25. package/dist/src/lib/estimate/ransac.d.ts.map +1 -0
  26. package/dist/src/lib/image/codec.d.ts +30 -0
  27. package/dist/src/lib/image/codec.d.ts.map +1 -0
  28. package/dist/src/lib/image/gray.d.ts +67 -0
  29. package/dist/src/lib/image/gray.d.ts.map +1 -0
  30. package/dist/src/lib/image/raster.d.ts +19 -0
  31. package/dist/src/lib/image/raster.d.ts.map +1 -0
  32. package/dist/src/lib/image/resize.d.ts +32 -0
  33. package/dist/src/lib/image/resize.d.ts.map +1 -0
  34. package/dist/src/lib/image/warp.d.ts +28 -0
  35. package/dist/src/lib/image/warp.d.ts.map +1 -0
  36. package/dist/src/lib/math/fft.d.ts +15 -0
  37. package/dist/src/lib/math/fft.d.ts.map +1 -0
  38. package/dist/src/lib/math/linalg.d.ts +34 -0
  39. package/dist/src/lib/math/linalg.d.ts.map +1 -0
  40. package/dist/src/lib/math/matrix.d.ts +75 -0
  41. package/dist/src/lib/math/matrix.d.ts.map +1 -0
  42. package/dist/src/lib/math/random.d.ts +13 -0
  43. package/dist/src/lib/math/random.d.ts.map +1 -0
  44. package/dist/src/lib/regions.d.ts +75 -0
  45. package/dist/src/lib/regions.d.ts.map +1 -0
  46. package/dist/src/lib/testing/synthetic.d.ts +73 -0
  47. package/dist/src/lib/testing/synthetic.d.ts.map +1 -0
  48. package/dist/src/lib/types.d.ts +86 -0
  49. package/dist/src/lib/types.d.ts.map +1 -0
  50. package/package.json +59 -0
@@ -0,0 +1,2786 @@
1
+ import { decode, encode } from 'jpeg-js';
2
+ import { PNG } from 'pngjs';
3
+
4
+ function _extends() {
5
+ _extends = Object.assign || function assign(target) {
6
+ for(var i = 1; i < arguments.length; i++){
7
+ var source = arguments[i];
8
+ for(var key in source)if (Object.prototype.hasOwnProperty.call(source, key)) target[key] = source[key];
9
+ }
10
+ return target;
11
+ };
12
+ return _extends.apply(this, arguments);
13
+ }
14
+
15
+ /**
16
+ * How well two ink images actually overlap.
17
+ *
18
+ * Every stage of the pipeline proposes a transform; this is the referee. It
19
+ * has to be a *correlation*, not a difference: the scan is darker, or fainter,
20
+ * or contrast-stretched by the scanner's own firmware, and a sum of absolute
21
+ * differences would rank a badly aligned pale scan above a well aligned dark
22
+ * one. Zero-mean normalised cross correlation is invariant to both of those —
23
+ * it only asks whether the ink rises and falls in the same places.
24
+ */ /** Zero-mean normalised cross correlation of two equally sized images, in `[-1, 1]`. */ function correlation(a, b) {
25
+ if (a.width !== b.width || a.height !== b.height) throw new Error('correlation needs two images of the same size');
26
+ const n = a.data.length;
27
+ if (n === 0) return 0;
28
+ let sumA = 0;
29
+ let sumB = 0;
30
+ for(let i = 0; i < n; i++){
31
+ sumA += a.data[i];
32
+ sumB += b.data[i];
33
+ }
34
+ const meanA = sumA / n;
35
+ const meanB = sumB / n;
36
+ let cov = 0;
37
+ let varA = 0;
38
+ let varB = 0;
39
+ for(let i = 0; i < n; i++){
40
+ const da = a.data[i] - meanA;
41
+ const db = b.data[i] - meanB;
42
+ cov += da * db;
43
+ varA += da * da;
44
+ varB += db * db;
45
+ }
46
+ const denom = Math.sqrt(varA * varB);
47
+ return denom > 1e-12 ? cov / denom : 0;
48
+ }
49
+ /** Intersection over union of two masks. The pixel-level version of "did it land on top of it". */ function intersectionOverUnion(a, b) {
50
+ if (a.width !== b.width || a.height !== b.height) throw new Error('intersectionOverUnion needs two masks of the same size');
51
+ let intersection = 0;
52
+ let union = 0;
53
+ for(let i = 0; i < a.data.length; i++){
54
+ const hit = a.data[i] | b.data[i];
55
+ union += hit;
56
+ intersection += a.data[i] & b.data[i];
57
+ }
58
+ return union > 0 ? intersection / union : 0;
59
+ }
60
+ /** Mean of a single channel image. */ function mean(image) {
61
+ if (image.data.length === 0) return 0;
62
+ let total = 0;
63
+ for (const value of image.data)total += value;
64
+ return total / image.data.length;
65
+ }
66
+
67
+ /** Allocate an opaque RGBA raster, filled with `fill` (white by default). */ function createRaster(width, height, fill = [
68
+ 255,
69
+ 255,
70
+ 255,
71
+ 255
72
+ ]) {
73
+ assertDimensions(width, height);
74
+ const data = new Uint8ClampedArray(width * height * 4);
75
+ const [r, g, b, a] = fill;
76
+ for(let i = 0; i < data.length; i += 4){
77
+ data[i] = r;
78
+ data[i + 1] = g;
79
+ data[i + 2] = b;
80
+ data[i + 3] = a;
81
+ }
82
+ return {
83
+ width,
84
+ height,
85
+ data
86
+ };
87
+ }
88
+ function createGray(width, height) {
89
+ assertDimensions(width, height);
90
+ return {
91
+ width,
92
+ height,
93
+ data: new Float32Array(width * height)
94
+ };
95
+ }
96
+ function createBinary(width, height) {
97
+ assertDimensions(width, height);
98
+ return {
99
+ width,
100
+ height,
101
+ data: new Uint8Array(width * height)
102
+ };
103
+ }
104
+ function cloneRaster(image) {
105
+ return {
106
+ width: image.width,
107
+ height: image.height,
108
+ data: Uint8ClampedArray.from(image.data)
109
+ };
110
+ }
111
+ /**
112
+ * True when the value is already a decoded raster.
113
+ *
114
+ * The check is structural rather than `instanceof` because a raster is a plain
115
+ * object on purpose: callers should be able to hand us a canvas `ImageData`,
116
+ * or something they built themselves, without importing anything from here.
117
+ */ function isRaster(value) {
118
+ if (typeof value !== 'object' || value === null) return false;
119
+ const candidate = value;
120
+ return typeof candidate.width === 'number' && typeof candidate.height === 'number' && ArrayBuffer.isView(candidate.data) && candidate.data.byteLength === candidate.width * candidate.height * 4;
121
+ }
122
+ /** Narrow any accepted input to the bytes of an encoded image, or `null` if it is already decoded. */ function toBytes(input) {
123
+ if (isRaster(input)) return null;
124
+ if (input instanceof ArrayBuffer) return new Uint8Array(input);
125
+ if (ArrayBuffer.isView(input)) return new Uint8Array(input.buffer, input.byteOffset, input.byteLength);
126
+ throw new TypeError('expected a Raster, a Uint8Array/Buffer, or an ArrayBuffer');
127
+ }
128
+ /** Wrap bytes as a `Uint8ClampedArray` without copying when the alignment allows it. */ function asClamped(data) {
129
+ return data instanceof Uint8ClampedArray ? data : new Uint8ClampedArray(data.buffer, data.byteOffset, data.byteLength);
130
+ }
131
+ function assertDimensions(width, height) {
132
+ if (!Number.isSafeInteger(width) || !Number.isSafeInteger(height) || width <= 0 || height <= 0) throw new RangeError(`image dimensions must be positive integers, got ${width}x${height}`);
133
+ }
134
+
135
+ const PNG_MAGIC = [
136
+ 0x89,
137
+ 0x50,
138
+ 0x4E,
139
+ 0x47,
140
+ 0x0D,
141
+ 0x0A,
142
+ 0x1A,
143
+ 0x0A
144
+ ];
145
+ const JPEG_MAGIC = [
146
+ 0xFF,
147
+ 0xD8,
148
+ 0xFF
149
+ ];
150
+ /** Identify a buffer by its magic bytes. Returns `null` when it is neither PNG nor JPEG. */ function sniffFormat(bytes) {
151
+ if (startsWith(bytes, PNG_MAGIC)) return 'png';
152
+ if (startsWith(bytes, JPEG_MAGIC)) return 'jpeg';
153
+ return null;
154
+ }
155
+ /**
156
+ * Decode PNG or JPEG bytes to RGBA, or pass a {@link Raster} straight through.
157
+ *
158
+ * Passing a raster through untouched is what makes it cheap to align a page
159
+ * against several scans: decode once, reuse.
160
+ */ function decodeImage(input) {
161
+ if (isRaster(input)) return input;
162
+ const bytes = toBytes(input);
163
+ if (bytes === null || bytes.length === 0) throw new Error('cannot decode an empty image buffer');
164
+ const format = sniffFormat(bytes);
165
+ if (format === 'png') {
166
+ const png = PNG.sync.read(Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength));
167
+ return {
168
+ width: png.width,
169
+ height: png.height,
170
+ data: asClamped(png.data)
171
+ };
172
+ }
173
+ if (format === 'jpeg') {
174
+ const jpeg = decode(bytes, {
175
+ useTArray: true,
176
+ formatAsRGBA: true,
177
+ tolerantDecoding: true
178
+ });
179
+ return {
180
+ width: jpeg.width,
181
+ height: jpeg.height,
182
+ data: asClamped(jpeg.data)
183
+ };
184
+ }
185
+ throw new Error('unsupported image format: expected PNG or JPEG');
186
+ }
187
+ /** Encode a raster. PNG by default, because a scan re-encoded as JPEG is a scan with new artefacts. */ function encodeImage(image, options = {}) {
188
+ const { format = 'png', quality = 92 } = options;
189
+ if (format === 'jpeg') {
190
+ const encoded = encode({
191
+ width: image.width,
192
+ height: image.height,
193
+ data: new Uint8Array(image.data.buffer, image.data.byteOffset, image.data.byteLength)
194
+ }, quality);
195
+ return Uint8Array.from(encoded.data);
196
+ }
197
+ const png = new PNG({
198
+ width: image.width,
199
+ height: image.height
200
+ });
201
+ png.data = Buffer.from(image.data.buffer, image.data.byteOffset, image.data.byteLength);
202
+ return Uint8Array.from(PNG.sync.write(png));
203
+ }
204
+ function startsWith(bytes, magic) {
205
+ if (bytes.length < magic.length) return false;
206
+ for (const [i, byte] of magic.entries())if (bytes[i] !== byte) return false;
207
+ return true;
208
+ }
209
+
210
+ /**
211
+ * Turning a photograph of paper into something two images can be compared on.
212
+ *
213
+ * A scan differs from its source in ways that have nothing to do with where
214
+ * the page is: the lamp is brighter in the middle, the phone cast a shadow
215
+ * down one side, the JPEG quantiser smeared the strokes. Comparing raw
216
+ * greyscale means comparing all of that too. So every stage below the codec
217
+ * works on **ink**: greyscale divided by its own slowly-varying background and
218
+ * inverted, which is near zero on paper and near one on print no matter what
219
+ * the lighting did.
220
+ *
221
+ * Think of it as reading a page through a sheet of tracing paper — you lose
222
+ * the tint of the paper and the angle of the lamp, and keep the writing.
223
+ */ /** Rec. 601 luminance, alpha composited over white, scaled to `[0, 1]`. */ function toGrayscale(image) {
224
+ const out = createGray(image.width, image.height);
225
+ const src = image.data;
226
+ const dst = out.data;
227
+ for(let i = 0, p = 0; p < dst.length; i += 4, p++){
228
+ const a = src[i + 3] / 255;
229
+ const r = src[i] * a + 255 * (1 - a);
230
+ const g = src[i + 1] * a + 255 * (1 - a);
231
+ const b = src[i + 2] * a + 255 * (1 - a);
232
+ dst[p] = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
233
+ }
234
+ return out;
235
+ }
236
+ /** Render a single channel back to RGBA, for debugging and diff overlays. */ function grayToRaster(image) {
237
+ const data = new Uint8ClampedArray(image.width * image.height * 4);
238
+ for(let p = 0, i = 0; p < image.data.length; p++, i += 4){
239
+ const v = image.data[p] * 255;
240
+ data[i] = v;
241
+ data[i + 1] = v;
242
+ data[i + 2] = v;
243
+ data[i + 3] = 255;
244
+ }
245
+ return {
246
+ width: image.width,
247
+ height: image.height,
248
+ data
249
+ };
250
+ }
251
+ /** Summed-area table with a zero first row and column, so a window sum is four lookups. */ function integralImage(image) {
252
+ const { width, height, data } = image;
253
+ const stride = width + 1;
254
+ const sum = new Float64Array(stride * (height + 1));
255
+ for(let y = 0; y < height; y++){
256
+ let rowSum = 0;
257
+ const srcRow = y * width;
258
+ const dstRow = (y + 1) * stride;
259
+ const prevRow = y * stride;
260
+ for(let x = 0; x < width; x++){
261
+ rowSum += data[srcRow + x];
262
+ sum[dstRow + x + 1] = sum[prevRow + x + 1] + rowSum;
263
+ }
264
+ }
265
+ return sum;
266
+ }
267
+ /**
268
+ * Mean over a `(2 * radius + 1)` square, in time independent of the radius.
269
+ *
270
+ * Border windows are clipped and divided by their real area rather than padded,
271
+ * so the blur never invents dark paper outside the page.
272
+ */ function boxBlur(image, radius) {
273
+ const r = Math.max(0, Math.round(radius));
274
+ if (r === 0) return _extends({}, image, {
275
+ data: Float32Array.from(image.data)
276
+ });
277
+ const { width, height } = image;
278
+ const sum = integralImage(image);
279
+ const stride = width + 1;
280
+ const out = createGray(width, height);
281
+ for(let y = 0; y < height; y++){
282
+ const y0 = Math.max(0, y - r);
283
+ const y1 = Math.min(height, y + r + 1);
284
+ const top = y0 * stride;
285
+ const bottom = y1 * stride;
286
+ for(let x = 0; x < width; x++){
287
+ const x0 = Math.max(0, x - r);
288
+ const x1 = Math.min(width, x + r + 1);
289
+ const total = sum[bottom + x1] - sum[bottom + x0] - sum[top + x1] + sum[top + x0];
290
+ out.data[y * width + x] = total / ((y1 - y0) * (x1 - x0));
291
+ }
292
+ }
293
+ return out;
294
+ }
295
+ /**
296
+ * Greyscale to ink: divide out the local background, invert, clip the noise floor.
297
+ *
298
+ * Division rather than subtraction because illumination is multiplicative —
299
+ * a shadow halves what reaches the sensor, it does not subtract a constant —
300
+ * so dividing restores the same contrast in the shadow as in the light.
301
+ */ function inkMap(gray, options = {}) {
302
+ const { backgroundFraction = 1 / 16, floor = 0.06 } = options;
303
+ const radius = Math.max(4, Math.round(Math.min(gray.width, gray.height) * backgroundFraction));
304
+ const background = boxBlur(gray, radius);
305
+ const out = createGray(gray.width, gray.height);
306
+ for(let p = 0; p < out.data.length; p++){
307
+ const bg = Math.max(background.data[p], 1e-3);
308
+ const ratio = gray.data[p] / bg;
309
+ const ink = 1 - Math.min(1, ratio);
310
+ out.data[p] = ink < floor ? 0 : Math.min(1, (ink - floor) / (1 - floor));
311
+ }
312
+ return out;
313
+ }
314
+ /** Otsu's threshold over a 256-bin histogram of `[0, 1]` values. */ function otsuThreshold(image) {
315
+ const bins = 256;
316
+ const histogram = new Float64Array(bins);
317
+ for (const value of image.data){
318
+ const bin = Math.min(bins - 1, Math.max(0, Math.round(value * (bins - 1))));
319
+ histogram[bin]++;
320
+ }
321
+ const total = image.data.length;
322
+ let sumAll = 0;
323
+ for(let i = 0; i < bins; i++)sumAll += i * histogram[i];
324
+ let sumBackground = 0;
325
+ let weightBackground = 0;
326
+ let best = 0;
327
+ let bestVariance = -1;
328
+ for(let t = 0; t < bins; t++){
329
+ weightBackground += histogram[t];
330
+ if (weightBackground === 0) continue;
331
+ const weightForeground = total - weightBackground;
332
+ if (weightForeground === 0) break;
333
+ sumBackground += t * histogram[t];
334
+ const meanBackground = sumBackground / weightBackground;
335
+ const meanForeground = (sumAll - sumBackground) / weightForeground;
336
+ const between = weightBackground * weightForeground * (meanBackground - meanForeground) ** 2;
337
+ if (between > bestVariance) {
338
+ bestVariance = between;
339
+ best = t;
340
+ }
341
+ }
342
+ return best / (bins - 1);
343
+ }
344
+ /**
345
+ * Ink to a binary mask.
346
+ *
347
+ * `threshold` defaults to Otsu's, with a floor: a page that is genuinely blank
348
+ * has no bimodal split to find, and Otsu will happily cut its noise in half
349
+ * and report that 50% of the paper is ink.
350
+ */ function binarize(image, threshold) {
351
+ const t = Math.max(threshold != null ? threshold : otsuThreshold(image), 0.12);
352
+ const out = createBinary(image.width, image.height);
353
+ for(let p = 0; p < out.data.length; p++)out.data[p] = image.data[p] > t ? 1 : 0;
354
+ return out;
355
+ }
356
+ /**
357
+ * Morphological dilation by a square, done as two 1D max passes.
358
+ *
359
+ * Used to give the original's ink a tolerance band before asking what is new
360
+ * in the scan: without it, alignment that is half a pixel off reports the edge
361
+ * of every printed character as freshly written.
362
+ */ function dilate(mask, radius) {
363
+ const r = Math.max(0, Math.round(radius));
364
+ if (r === 0) return _extends({}, mask, {
365
+ data: Uint8Array.from(mask.data)
366
+ });
367
+ const { width, height } = mask;
368
+ const horizontal = new Uint8Array(width * height);
369
+ for(let y = 0; y < height; y++){
370
+ const row = y * width;
371
+ for(let x = 0; x < width; x++){
372
+ let hit = 0;
373
+ const from = Math.max(0, x - r);
374
+ const to = Math.min(width - 1, x + r);
375
+ for(let k = from; k <= to; k++)if (mask.data[row + k] === 1) {
376
+ hit = 1;
377
+ break;
378
+ }
379
+ horizontal[row + x] = hit;
380
+ }
381
+ }
382
+ const out = createBinary(width, height);
383
+ for(let y = 0; y < height; y++){
384
+ const from = Math.max(0, y - r);
385
+ const to = Math.min(height - 1, y + r);
386
+ for(let x = 0; x < width; x++){
387
+ let hit = 0;
388
+ for(let k = from; k <= to; k++)if (horizontal[k * width + x] === 1) {
389
+ hit = 1;
390
+ break;
391
+ }
392
+ out.data[y * width + x] = hit;
393
+ }
394
+ }
395
+ return out;
396
+ }
397
+ /** Fraction of pixels set in `mask`, restricted to a rectangle when one is given. */ function coverage(mask, x0 = 0, y0 = 0, x1 = mask.width, y1 = mask.height) {
398
+ const left = Math.max(0, Math.floor(x0));
399
+ const top = Math.max(0, Math.floor(y0));
400
+ const right = Math.min(mask.width, Math.ceil(x1));
401
+ const bottom = Math.min(mask.height, Math.ceil(y1));
402
+ if (right <= left || bottom <= top) return 0;
403
+ let hits = 0;
404
+ for(let y = top; y < bottom; y++){
405
+ const row = y * mask.width;
406
+ for(let x = left; x < right; x++)hits += mask.data[row + x];
407
+ }
408
+ return hits / ((right - left) * (bottom - top));
409
+ }
410
+
411
+ /**
412
+ * Separable resampling: area-average going down, bilinear going up.
413
+ *
414
+ * Going down matters more than it sounds. Point-sampling a 300 dpi scan to
415
+ * half size drops every other row, and on a page of 9pt text that deletes
416
+ * roughly half the strokes — the thumbnail the matcher sees is not a smaller
417
+ * version of the page, it is a different page. Averaging over the exact source
418
+ * footprint of each destination pixel is what keeps the ink where it was.
419
+ */ /** Resize a single channel image to exactly `width x height`. */ function resizeGray(src, width, height) {
420
+ if (width === src.width && height === src.height) return _extends({}, src, {
421
+ data: Float32Array.from(src.data)
422
+ });
423
+ const horizontal = new Float32Array(width * src.height);
424
+ resampleRows(src.data, src.width, src.height, horizontal, width);
425
+ const out = createGray(width, height);
426
+ resampleColumns(horizontal, width, src.height, out.data, height);
427
+ return out;
428
+ }
429
+ /**
430
+ * Shrink so the longer side is at most `maxDimension`.
431
+ *
432
+ * Returns the factor applied, because every coordinate the caller recovers in
433
+ * this smaller frame has to be scaled back up by it.
434
+ */ function downscaleGray(src, maxDimension) {
435
+ const longest = Math.max(src.width, src.height);
436
+ if (longest <= maxDimension) return {
437
+ image: _extends({}, src, {
438
+ data: Float32Array.from(src.data)
439
+ }),
440
+ scale: 1
441
+ };
442
+ const scale = maxDimension / longest;
443
+ const width = Math.max(1, Math.round(src.width * scale));
444
+ const height = Math.max(1, Math.round(src.height * scale));
445
+ // The realised scale is what the rounded pixel counts imply, not the request.
446
+ return {
447
+ image: resizeGray(src, width, height),
448
+ scale: width / src.width
449
+ };
450
+ }
451
+ /**
452
+ * Box blur an RGBA raster with two sliding-window passes.
453
+ *
454
+ * Lives here rather than with the other blurs because its only caller is the
455
+ * warp prefilter, and because it has to work in bytes: a summed-area table over
456
+ * four channels of a 12 megapixel scan is 400 MB, which is not a thing to
457
+ * allocate inside a function app.
458
+ */ function boxBlurRaster(src, radius) {
459
+ const r = Math.max(0, Math.round(radius));
460
+ if (r === 0) return _extends({}, src, {
461
+ data: Uint8ClampedArray.from(src.data)
462
+ });
463
+ const { width, height } = src;
464
+ const temp = new Uint8ClampedArray(width * height * 4);
465
+ const sums = new Int32Array(4);
466
+ for(let y = 0; y < height; y++){
467
+ const row = y * width * 4;
468
+ sums.fill(0);
469
+ let count = 0;
470
+ for(let x = 0; x <= Math.min(r, width - 1); x++, count++)for(let c = 0; c < 4; c++)sums[c] += src.data[row + x * 4 + c];
471
+ for(let x = 0; x < width; x++){
472
+ for(let c = 0; c < 4; c++)temp[row + x * 4 + c] = sums[c] / count;
473
+ const leaving = x - r;
474
+ const entering = x + r + 1;
475
+ if (leaving >= 0) {
476
+ for(let c = 0; c < 4; c++)sums[c] -= src.data[row + leaving * 4 + c];
477
+ count--;
478
+ }
479
+ if (entering < width) {
480
+ for(let c = 0; c < 4; c++)sums[c] += src.data[row + entering * 4 + c];
481
+ count++;
482
+ }
483
+ }
484
+ }
485
+ const out = new Uint8ClampedArray(width * height * 4);
486
+ const columnSums = new Int32Array(width * 4);
487
+ let count = 0;
488
+ for(let y = 0; y <= Math.min(r, height - 1); y++, count++)for(let i = 0; i < width * 4; i++)columnSums[i] += temp[y * width * 4 + i];
489
+ for(let y = 0; y < height; y++){
490
+ const row = y * width * 4;
491
+ for(let i = 0; i < width * 4; i++)out[row + i] = columnSums[i] / count;
492
+ const leaving = y - r;
493
+ const entering = y + r + 1;
494
+ if (leaving >= 0) {
495
+ for(let i = 0; i < width * 4; i++)columnSums[i] -= temp[leaving * width * 4 + i];
496
+ count--;
497
+ }
498
+ if (entering < height) {
499
+ for(let i = 0; i < width * 4; i++)columnSums[i] += temp[entering * width * 4 + i];
500
+ count++;
501
+ }
502
+ }
503
+ return {
504
+ width,
505
+ height,
506
+ data: out
507
+ };
508
+ }
509
+ function resampleRows(src, srcWidth, rows, dst, dstWidth) {
510
+ const ratio = srcWidth / dstWidth;
511
+ if (ratio > 1) {
512
+ for(let y = 0; y < rows; y++){
513
+ const srcRow = y * srcWidth;
514
+ const dstRow = y * dstWidth;
515
+ for(let x = 0; x < dstWidth; x++){
516
+ const start = x * ratio;
517
+ const end = start + ratio;
518
+ dst[dstRow + x] = areaAverage(src, srcRow, srcWidth, start, end);
519
+ }
520
+ }
521
+ return;
522
+ }
523
+ for(let y = 0; y < rows; y++){
524
+ const srcRow = y * srcWidth;
525
+ const dstRow = y * dstWidth;
526
+ for(let x = 0; x < dstWidth; x++){
527
+ const pos = clamp$1((x + 0.5) * ratio - 0.5, 0, srcWidth - 1);
528
+ const i0 = Math.floor(pos);
529
+ const i1 = Math.min(srcWidth - 1, i0 + 1);
530
+ const frac = pos - i0;
531
+ dst[dstRow + x] = src[srcRow + i0] * (1 - frac) + src[srcRow + i1] * frac;
532
+ }
533
+ }
534
+ }
535
+ function resampleColumns(src, width, srcHeight, dst, dstHeight) {
536
+ const ratio = srcHeight / dstHeight;
537
+ const column = new Float32Array(srcHeight);
538
+ for(let x = 0; x < width; x++){
539
+ for(let y = 0; y < srcHeight; y++)column[y] = src[y * width + x];
540
+ if (ratio > 1) for(let y = 0; y < dstHeight; y++){
541
+ const start = y * ratio;
542
+ dst[y * width + x] = areaAverage(column, 0, srcHeight, start, start + ratio);
543
+ }
544
+ else for(let y = 0; y < dstHeight; y++){
545
+ const pos = clamp$1((y + 0.5) * ratio - 0.5, 0, srcHeight - 1);
546
+ const i0 = Math.floor(pos);
547
+ const i1 = Math.min(srcHeight - 1, i0 + 1);
548
+ const frac = pos - i0;
549
+ dst[y * width + x] = column[i0] * (1 - frac) + column[i1] * frac;
550
+ }
551
+ }
552
+ }
553
+ /** Mean of `src[offset + start .. offset + end)`, weighting the two partially covered ends. */ function areaAverage(src, offset, length, start, end) {
554
+ const from = Math.max(0, Math.floor(start));
555
+ const to = Math.min(length, Math.ceil(end));
556
+ let total = 0;
557
+ let weight = 0;
558
+ for(let i = from; i < to; i++){
559
+ const w = Math.min(end, i + 1) - Math.max(start, i);
560
+ if (w <= 0) continue;
561
+ total += src[offset + i] * w;
562
+ weight += w;
563
+ }
564
+ return weight > 0 ? total / weight : 0;
565
+ }
566
+ function clamp$1(value, min, max) {
567
+ return value < min ? min : Math.min(value, max);
568
+ }
569
+
570
+ /** Warp an RGBA raster onto a `width x height` canvas. */ function warpRaster(source, matrix, width, height, options = {}) {
571
+ const { background = [
572
+ 255,
573
+ 255,
574
+ 255,
575
+ 255
576
+ ], interpolation = 'bilinear', prefilter = true } = options;
577
+ const src = prefilter ? applyPrefilter(source, matrix) : source;
578
+ const data = new Uint8ClampedArray(width * height * 4);
579
+ const [m0, m1, m2, m3, m4, m5, m6, m7, m8] = matrix;
580
+ for(let y = 0; y < height; y++){
581
+ const dy = y + 0.5;
582
+ let i = y * width * 4;
583
+ for(let x = 0; x < width; x++, i += 4){
584
+ const dx = x + 0.5;
585
+ const w = m6 * dx + m7 * dy + m8;
586
+ if (w === 0) {
587
+ writePixel(data, i, background);
588
+ continue;
589
+ }
590
+ // Continuous source coordinate, then index space: pixel k spans [k, k+1)
591
+ // with its centre at k + 0.5, so the sample index is the centre minus a half.
592
+ const u = (m0 * dx + m1 * dy + m2) / w - 0.5;
593
+ const v = (m3 * dx + m4 * dy + m5) / w - 0.5;
594
+ if (u < -1 || v < -1 || u > src.width || v > src.height) {
595
+ writePixel(data, i, background);
596
+ continue;
597
+ }
598
+ sampleRaster(src, u, v, interpolation, data, i);
599
+ }
600
+ }
601
+ return {
602
+ width,
603
+ height,
604
+ data
605
+ };
606
+ }
607
+ /** Warp a single channel image. Used for scoring, where colour is noise. */ function warpGray(source, matrix, width, height, fill = 0) {
608
+ const out = createGray(width, height);
609
+ const [m0, m1, m2, m3, m4, m5, m6, m7, m8] = matrix;
610
+ for(let y = 0; y < height; y++){
611
+ const dy = y + 0.5;
612
+ const row = y * width;
613
+ for(let x = 0; x < width; x++){
614
+ const dx = x + 0.5;
615
+ const w = m6 * dx + m7 * dy + m8;
616
+ if (w === 0) {
617
+ out.data[row + x] = fill;
618
+ continue;
619
+ }
620
+ const u = (m0 * dx + m1 * dy + m2) / w - 0.5;
621
+ const v = (m3 * dx + m4 * dy + m5) / w - 0.5;
622
+ out.data[row + x] = sampleGrayBilinear(source, u, v, fill);
623
+ }
624
+ }
625
+ return out;
626
+ }
627
+ /** Bilinear read at a fractional index, returning `fill` outside the image. */ function sampleGrayBilinear(image, u, v, fill = 0) {
628
+ const { width, height, data } = image;
629
+ if (u <= -1 || v <= -1 || u >= width || v >= height) return fill;
630
+ const x0 = Math.floor(u);
631
+ const y0 = Math.floor(v);
632
+ const fx = u - x0;
633
+ const fy = v - y0;
634
+ const x1 = x0 + 1;
635
+ const y1 = y0 + 1;
636
+ const cx0 = clampIndex(x0, width);
637
+ const cx1 = clampIndex(x1, width);
638
+ const cy0 = clampIndex(y0, height);
639
+ const cy1 = clampIndex(y1, height);
640
+ const p00 = data[cy0 * width + cx0];
641
+ const p10 = data[cy0 * width + cx1];
642
+ const p01 = data[cy1 * width + cx0];
643
+ const p11 = data[cy1 * width + cx1];
644
+ return p00 * (1 - fx) * (1 - fy) + p10 * fx * (1 - fy) + p01 * (1 - fx) * fy + p11 * fx * fy;
645
+ }
646
+ /**
647
+ * Blur the source when the warp is a minification.
648
+ *
649
+ * `sqrt(|det|)` of the linear part is how many source pixels land on one
650
+ * destination pixel along an average direction; when that is comfortably above
651
+ * one, a point sample is reading one of them and discarding the rest.
652
+ */ function applyPrefilter(source, matrix) {
653
+ const det = Math.abs(matrix[0] * matrix[4] - matrix[1] * matrix[3]);
654
+ const scale = Math.sqrt(det);
655
+ if (!Number.isFinite(scale) || scale <= 1.25) return source;
656
+ return boxBlurRaster(source, (scale - 1) / 2);
657
+ }
658
+ function sampleRaster(src, u, v, interpolation, out, at) {
659
+ if (interpolation === 'nearest') {
660
+ const x = clampIndex(Math.round(u), src.width);
661
+ const y = clampIndex(Math.round(v), src.height);
662
+ const i = (y * src.width + x) * 4;
663
+ out[at] = src.data[i];
664
+ out[at + 1] = src.data[i + 1];
665
+ out[at + 2] = src.data[i + 2];
666
+ out[at + 3] = src.data[i + 3];
667
+ return;
668
+ }
669
+ if (interpolation === 'bicubic') {
670
+ sampleBicubic(src, u, v, out, at);
671
+ return;
672
+ }
673
+ const x0 = Math.floor(u);
674
+ const y0 = Math.floor(v);
675
+ const fx = u - x0;
676
+ const fy = v - y0;
677
+ const cx0 = clampIndex(x0, src.width);
678
+ const cx1 = clampIndex(x0 + 1, src.width);
679
+ const cy0 = clampIndex(y0, src.height);
680
+ const cy1 = clampIndex(y0 + 1, src.height);
681
+ const i00 = (cy0 * src.width + cx0) * 4;
682
+ const i10 = (cy0 * src.width + cx1) * 4;
683
+ const i01 = (cy1 * src.width + cx0) * 4;
684
+ const i11 = (cy1 * src.width + cx1) * 4;
685
+ const w00 = (1 - fx) * (1 - fy);
686
+ const w10 = fx * (1 - fy);
687
+ const w01 = (1 - fx) * fy;
688
+ const w11 = fx * fy;
689
+ for(let c = 0; c < 4; c++)out[at + c] = src.data[i00 + c] * w00 + src.data[i10 + c] * w10 + src.data[i01 + c] * w01 + src.data[i11 + c] * w11;
690
+ }
691
+ /** Catmull-Rom over a 4x4 neighbourhood: sharper strokes than bilinear when upscaling. */ function sampleBicubic(src, u, v, out, at) {
692
+ const x0 = Math.floor(u);
693
+ const y0 = Math.floor(v);
694
+ const fx = u - x0;
695
+ const fy = v - y0;
696
+ const wx = catmullRomWeights(fx);
697
+ const wy = catmullRomWeights(fy);
698
+ for(let c = 0; c < 4; c++){
699
+ let total = 0;
700
+ for(let j = 0; j < 4; j++){
701
+ const y = clampIndex(y0 - 1 + j, src.height);
702
+ const row = y * src.width;
703
+ let rowTotal = 0;
704
+ for(let i = 0; i < 4; i++){
705
+ const x = clampIndex(x0 - 1 + i, src.width);
706
+ rowTotal += src.data[(row + x) * 4 + c] * wx[i];
707
+ }
708
+ total += rowTotal * wy[j];
709
+ }
710
+ out[at + c] = total;
711
+ }
712
+ }
713
+ function catmullRomWeights(t) {
714
+ const t2 = t * t;
715
+ const t3 = t2 * t;
716
+ return [
717
+ 0.5 * (-t3 + 2 * t2 - t),
718
+ 0.5 * (3 * t3 - 5 * t2 + 2),
719
+ 0.5 * (-3 * t3 + 4 * t2 + t),
720
+ 0.5 * (t3 - t2)
721
+ ];
722
+ }
723
+ function writePixel(data, at, rgba) {
724
+ data[at] = rgba[0];
725
+ data[at + 1] = rgba[1];
726
+ data[at + 2] = rgba[2];
727
+ data[at + 3] = rgba[3];
728
+ }
729
+ function clampIndex(value, length) {
730
+ return value < 0 ? 0 : value >= length ? length - 1 : value;
731
+ }
732
+
733
+ /**
734
+ * 3x3 homogeneous matrix helpers.
735
+ *
736
+ * ## The one convention that matters
737
+ *
738
+ * Every matrix in this library maps **original coordinates to scanned
739
+ * coordinates**, never the other way round. That reads backwards the first
740
+ * time: we are producing an image on the original's canvas, so we walk the
741
+ * output pixel by pixel and ask "where in the scan does this come from?".
742
+ * Inverse mapping is what stops the output having holes — forward-splatting a
743
+ * rotated source leaves gaps between the splats, like spray-painting through a
744
+ * rotated stencil.
745
+ *
746
+ * Coordinates are continuous, with the centre of pixel `(i, j)` at
747
+ * `(i + 0.5, j + 0.5)`. Sticking to that is what makes {@link conjugateScale}
748
+ * a plain scale conjugation instead of a scale plus a half-pixel fudge.
749
+ */ const IDENTITY = [
750
+ 1,
751
+ 0,
752
+ 0,
753
+ 0,
754
+ 1,
755
+ 0,
756
+ 0,
757
+ 0,
758
+ 1
759
+ ];
760
+ /** `a * b` — the transform that applies `b` first, then `a`. */ function multiply(a, b) {
761
+ return [
762
+ a[0] * b[0] + a[1] * b[3] + a[2] * b[6],
763
+ a[0] * b[1] + a[1] * b[4] + a[2] * b[7],
764
+ a[0] * b[2] + a[1] * b[5] + a[2] * b[8],
765
+ a[3] * b[0] + a[4] * b[3] + a[5] * b[6],
766
+ a[3] * b[1] + a[4] * b[4] + a[5] * b[7],
767
+ a[3] * b[2] + a[4] * b[5] + a[5] * b[8],
768
+ a[6] * b[0] + a[7] * b[3] + a[8] * b[6],
769
+ a[6] * b[1] + a[7] * b[4] + a[8] * b[7],
770
+ a[6] * b[2] + a[7] * b[5] + a[8] * b[8]
771
+ ];
772
+ }
773
+ function determinant(m) {
774
+ return m[0] * (m[4] * m[8] - m[5] * m[7]) - m[1] * (m[3] * m[8] - m[5] * m[6]) + m[2] * (m[3] * m[7] - m[4] * m[6]);
775
+ }
776
+ /** Throws when `m` is singular — a transform that collapses the page to a line is never a usable answer. */ function invert(m) {
777
+ const det = determinant(m);
778
+ if (!Number.isFinite(det) || Math.abs(det) < 1e-12) throw new Error('matrix is singular and cannot be inverted');
779
+ const inv = 1 / det;
780
+ return [
781
+ (m[4] * m[8] - m[5] * m[7]) * inv,
782
+ (m[2] * m[7] - m[1] * m[8]) * inv,
783
+ (m[1] * m[5] - m[2] * m[4]) * inv,
784
+ (m[5] * m[6] - m[3] * m[8]) * inv,
785
+ (m[0] * m[8] - m[2] * m[6]) * inv,
786
+ (m[2] * m[3] - m[0] * m[5]) * inv,
787
+ (m[3] * m[7] - m[4] * m[6]) * inv,
788
+ (m[1] * m[6] - m[0] * m[7]) * inv,
789
+ (m[0] * m[4] - m[1] * m[3]) * inv
790
+ ];
791
+ }
792
+ /** Divide through by `m8` so two matrices describing the same transform compare equal. */ function normalize(m) {
793
+ const s = m[8];
794
+ if (s === 0 || s === 1) return m;
795
+ return [
796
+ m[0] / s,
797
+ m[1] / s,
798
+ m[2] / s,
799
+ m[3] / s,
800
+ m[4] / s,
801
+ m[5] / s,
802
+ m[6] / s,
803
+ m[7] / s,
804
+ 1
805
+ ];
806
+ }
807
+ function applyPoint(m, x, y) {
808
+ const w = m[6] * x + m[7] * y + m[8];
809
+ const iw = w === 0 ? 0 : 1 / w;
810
+ return {
811
+ x: (m[0] * x + m[1] * y + m[2]) * iw,
812
+ y: (m[3] * x + m[4] * y + m[5]) * iw
813
+ };
814
+ }
815
+ function translation(tx, ty) {
816
+ return [
817
+ 1,
818
+ 0,
819
+ tx,
820
+ 0,
821
+ 1,
822
+ ty,
823
+ 0,
824
+ 0,
825
+ 1
826
+ ];
827
+ }
828
+ function scaling(sx, sy = sx) {
829
+ return [
830
+ sx,
831
+ 0,
832
+ 0,
833
+ 0,
834
+ sy,
835
+ 0,
836
+ 0,
837
+ 0,
838
+ 1
839
+ ];
840
+ }
841
+ /**
842
+ * Scale `s` and rotation `angleRad` about `pivot`, then land that pivot on `target`.
843
+ *
844
+ * This is the shape the coarse stage produces: "the middle of the original's
845
+ * printed content is the middle of the scan's printed content, turned by this
846
+ * much and this many times bigger".
847
+ */ function similarity(s, angleRad, pivot, target) {
848
+ const c = Math.cos(angleRad) * s;
849
+ const k = Math.sin(angleRad) * s;
850
+ return [
851
+ c,
852
+ -k,
853
+ target.x - c * pivot.x + k * pivot.y,
854
+ k,
855
+ c,
856
+ target.y - k * pivot.x - c * pivot.y,
857
+ 0,
858
+ 0,
859
+ 1
860
+ ];
861
+ }
862
+ /**
863
+ * Re-express `m` in a coordinate frame scaled by `k`.
864
+ *
865
+ * Fitting runs on downscaled copies because matching 3000x4000 images is a
866
+ * waste; the matrix that comes back speaks in those small pixels. `k` is
867
+ * `working / full`, and the result speaks in full-resolution pixels.
868
+ */ function conjugateScale(m, k) {
869
+ return multiply(scaling(1 / k), multiply(m, scaling(k)));
870
+ }
871
+ /**
872
+ * Re-express a matrix whose two frames were scaled by different factors.
873
+ *
874
+ * The coarse stage measures on two independently shrunk copies - the original
875
+ * and the scan rarely have the same pixel count, so they rarely shrink by the
876
+ * same factor. `sourceScale` and `targetScale` are each `working / full` for
877
+ * their own side, and the result speaks full-resolution pixels on both.
878
+ */ function rebase(m, sourceScale, targetScale) {
879
+ return multiply(scaling(1 / targetScale), multiply(m, scaling(sourceScale)));
880
+ }
881
+ /** The four corners of `rect` mapped through `m`, clockwise from the top-left. */ function mapRectCorners(m, rect) {
882
+ const { x, y, width, height } = rect;
883
+ return [
884
+ applyPoint(m, x, y),
885
+ applyPoint(m, x + width, y),
886
+ applyPoint(m, x + width, y + height),
887
+ applyPoint(m, x, y + height)
888
+ ];
889
+ }
890
+ /**
891
+ * Pull a matrix apart into scale, rotation and shear.
892
+ *
893
+ * The 2x2 linear part is factored as `R(theta) * [[sx, k], [0, sy]]`, which is
894
+ * the order a scanner actually applies them: the page is stretched on the
895
+ * glass, then the whole thing sits at an angle.
896
+ */ function decompose(m, model) {
897
+ const [a, b, , d, e] = m;
898
+ const scaleX = Math.hypot(a, d);
899
+ const det = a * e - b * d;
900
+ const scaleY = scaleX === 0 ? 0 : det / scaleX;
901
+ const shear = scaleX === 0 ? 0 : (a * b + d * e) / scaleX;
902
+ const origin = applyPoint(m, 0, 0);
903
+ return {
904
+ model,
905
+ scaleX,
906
+ scaleY,
907
+ rotationDeg: Math.atan2(d, a) * 180 / Math.PI,
908
+ shearDeg: Math.atan2(shear, scaleY || 1) * 180 / Math.PI,
909
+ translation: origin,
910
+ perspective: {
911
+ x: m[6],
912
+ y: m[7]
913
+ }
914
+ };
915
+ }
916
+ /** Euclidean distance between `m * source` and `target`, in target pixels. */ function reprojectionError(m, source, target) {
917
+ const p = applyPoint(m, source.x, source.y);
918
+ return Math.hypot(p.x - target.x, p.y - target.y);
919
+ }
920
+ /**
921
+ * True when `m` is a plausible page-to-page transform rather than numerical debris.
922
+ *
923
+ * RANSAC on a minimal sample of near-collinear points loves to return a
924
+ * matrix that folds the page in half. Cheaper to reject it here than to
925
+ * discover it in the output.
926
+ */ function isPlausible(m, maxScaleRatio = 8) {
927
+ if (m.some((v)=>!Number.isFinite(v))) return false;
928
+ const [a, b, , d, e] = m;
929
+ const det = a * e - b * d;
930
+ if (Math.abs(det) <= 1e-9) return false;
931
+ // A mirrored page is never a scan of the same page.
932
+ if (det < 0) return false;
933
+ const sx = Math.hypot(a, d);
934
+ if (sx < 1 / maxScaleRatio || sx > maxScaleRatio) return false;
935
+ const sy = Math.hypot(b, e);
936
+ return !(sy < 1 / maxScaleRatio || sy > maxScaleRatio);
937
+ }
938
+
939
+ /**
940
+ * Extent of the ink along axes rotated by `angle`, trimming outliers.
941
+ *
942
+ * `trim` is a fraction of the total ink discarded from each end of each axis.
943
+ * A scanner that clips a black strip down one edge, or a speck of dust, would
944
+ * otherwise set the page boundary — and since this measurement becomes the
945
+ * scale estimate, a 2% error here is a 2% error in every coordinate downstream.
946
+ */ function contentExtent(ink, angle = 0, trim = 0.004) {
947
+ const { width, height, data } = ink;
948
+ const cos = Math.cos(angle);
949
+ const sin = Math.sin(angle);
950
+ const corners = [
951
+ {
952
+ x: 0,
953
+ y: 0
954
+ },
955
+ {
956
+ x: width,
957
+ y: 0
958
+ },
959
+ {
960
+ x: 0,
961
+ y: height
962
+ },
963
+ {
964
+ x: width,
965
+ y: height
966
+ }
967
+ ];
968
+ let minU = Infinity;
969
+ let maxU = -Infinity;
970
+ let minV = Infinity;
971
+ let maxV = -Infinity;
972
+ for (const c of corners){
973
+ const u = c.x * cos + c.y * sin;
974
+ const v = -c.x * sin + c.y * cos;
975
+ minU = Math.min(minU, u);
976
+ maxU = Math.max(maxU, u);
977
+ minV = Math.min(minV, v);
978
+ maxV = Math.max(maxV, v);
979
+ }
980
+ const uBins = new Float64Array(Math.ceil(maxU - minU) + 2);
981
+ const vBins = new Float64Array(Math.ceil(maxV - minV) + 2);
982
+ let total = 0;
983
+ for(let y = 0; y < height; y++){
984
+ const row = y * width;
985
+ const yCos = (y + 0.5) * cos;
986
+ const ySin = (y + 0.5) * sin;
987
+ for(let x = 0; x < width; x++){
988
+ const value = data[row + x];
989
+ if (value <= 0) continue;
990
+ const px = x + 0.5;
991
+ // floor, not round: a pixel centre at x + 0.5 belongs to bucket x.
992
+ uBins[Math.floor(px * cos + ySin - minU)] += value;
993
+ vBins[Math.floor(-px * sin + yCos - minV)] += value;
994
+ total += value;
995
+ }
996
+ }
997
+ if (total <= 0) {
998
+ // Nothing printed: report the whole frame rather than an empty box, so the
999
+ // caller falls back to fitting the page frame instead of dividing by zero.
1000
+ return {
1001
+ width,
1002
+ height,
1003
+ center: {
1004
+ x: width / 2,
1005
+ y: height / 2
1006
+ },
1007
+ angle,
1008
+ density: 0
1009
+ };
1010
+ }
1011
+ const [u0, u1] = trimmedSpan(uBins, total, trim);
1012
+ const [v0, v1] = trimmedSpan(vBins, total, trim);
1013
+ const centerU = minU + (u0 + u1) / 2;
1014
+ const centerV = minV + (v0 + v1) / 2;
1015
+ return {
1016
+ width: Math.max(1, u1 - u0),
1017
+ height: Math.max(1, v1 - v0),
1018
+ center: {
1019
+ x: centerU * cos - centerV * sin,
1020
+ y: centerU * sin + centerV * cos
1021
+ },
1022
+ angle,
1023
+ density: total / (width * height)
1024
+ };
1025
+ }
1026
+ /**
1027
+ * The page's own skew, in radians, from the sharpness of its ink profile.
1028
+ *
1029
+ * Rotate the page until the rows of text stack up: at the right angle every
1030
+ * line of type falls into one bin of the projection histogram and the profile
1031
+ * is a comb of tall spikes; a degree off and each line smears across several
1032
+ * bins. Sum of squares rewards exactly that concentration — same total ink,
1033
+ * fewer bins, bigger number. Searched coarse to fine so the cost stays flat.
1034
+ */ function estimateSkew(ink, options = {}) {
1035
+ const { maxAngleDeg = 12 } = options;
1036
+ const toRad = Math.PI / 180;
1037
+ let best = 0;
1038
+ let bestScore = -Infinity;
1039
+ for(let deg = -maxAngleDeg; deg <= maxAngleDeg; deg += 1){
1040
+ const score = profileSharpness(ink, deg * toRad);
1041
+ if (score > bestScore) {
1042
+ bestScore = score;
1043
+ best = deg;
1044
+ }
1045
+ }
1046
+ for (const [span, step] of [
1047
+ [
1048
+ 1,
1049
+ 0.2
1050
+ ],
1051
+ [
1052
+ 0.2,
1053
+ 0.04
1054
+ ]
1055
+ ]){
1056
+ let localBest = best;
1057
+ for(let deg = best - span; deg <= best + span + 1e-9; deg += step){
1058
+ const score = profileSharpness(ink, deg * toRad);
1059
+ if (score > bestScore) {
1060
+ bestScore = score;
1061
+ localBest = deg;
1062
+ }
1063
+ }
1064
+ best = localBest;
1065
+ }
1066
+ return best * toRad;
1067
+ }
1068
+ /** Sum of squares of the ink profile projected onto the axis perpendicular to `angle`. */ function profileSharpness(ink, angle) {
1069
+ const { width, height, data } = ink;
1070
+ const cos = Math.cos(angle);
1071
+ const sin = Math.sin(angle);
1072
+ const offset = Math.max(0, -width * sin);
1073
+ const bins = new Float64Array(Math.ceil(height * cos + width * Math.abs(sin)) + 2);
1074
+ for(let y = 0; y < height; y++){
1075
+ const row = y * width;
1076
+ const yCos = (y + 0.5) * cos + offset;
1077
+ for(let x = 0; x < width; x++){
1078
+ const value = data[row + x];
1079
+ if (value <= 0) continue;
1080
+ const bin = Math.floor(yCos - (x + 0.5) * sin);
1081
+ if (bin >= 0 && bin < bins.length) bins[bin] += value;
1082
+ }
1083
+ }
1084
+ let score = 0;
1085
+ for (const value of bins)score += value * value;
1086
+ return score;
1087
+ }
1088
+ /** First and last bin holding all but `trim` of the mass at each end. */ function trimmedSpan(bins, total, trim) {
1089
+ const cutoff = total * trim;
1090
+ let accumulated = 0;
1091
+ let low = 0;
1092
+ for(; low < bins.length; low++){
1093
+ accumulated += bins[low];
1094
+ if (accumulated > cutoff) break;
1095
+ }
1096
+ accumulated = 0;
1097
+ let high = bins.length - 1;
1098
+ for(; high > low; high--){
1099
+ accumulated += bins[high];
1100
+ if (accumulated > cutoff) break;
1101
+ }
1102
+ return [
1103
+ low,
1104
+ high + 1
1105
+ ];
1106
+ }
1107
+
1108
+ /**
1109
+ * In-place radix-2 Cooley-Tukey FFT, real and imaginary parts in separate arrays.
1110
+ *
1111
+ * Only used by phase correlation, which needs a *global* translation estimate
1112
+ * that no amount of local feature matching can produce on a page with almost
1113
+ * nothing printed on it. Sizes must be powers of two; {@link nextPowerOfTwo}
1114
+ * and the caller's zero padding see to that.
1115
+ */ function nextPowerOfTwo(n) {
1116
+ let p = 1;
1117
+ while(p < n)p *= 2;
1118
+ return p;
1119
+ }
1120
+ function isPowerOfTwo(n) {
1121
+ return n > 0 && (n & n - 1) === 0;
1122
+ }
1123
+ /** Transform `re`/`im` of length `n` in place. `inverse` also divides by `n`. */ function fft1d(re, im, inverse = false) {
1124
+ const n = re.length;
1125
+ if (!isPowerOfTwo(n)) throw new Error(`fft length must be a power of two, got ${n}`);
1126
+ if (n === 1) return;
1127
+ // Bit-reversal permutation: the decimation-in-time butterflies below expect
1128
+ // the input already shuffled into the order the recursion would have left it.
1129
+ for(let i = 1, j = 0; i < n; i++){
1130
+ let bit = n >> 1;
1131
+ for(; (j & bit) !== 0; bit >>= 1)j ^= bit;
1132
+ j ^= bit;
1133
+ if (i < j) {
1134
+ let t = re[i];
1135
+ re[i] = re[j];
1136
+ re[j] = t;
1137
+ t = im[i];
1138
+ im[i] = im[j];
1139
+ im[j] = t;
1140
+ }
1141
+ }
1142
+ const sign = inverse ? 1 : -1;
1143
+ for(let len = 2; len <= n; len <<= 1){
1144
+ const angle = sign * 2 * Math.PI / len;
1145
+ const wRe = Math.cos(angle);
1146
+ const wIm = Math.sin(angle);
1147
+ for(let start = 0; start < n; start += len){
1148
+ let curRe = 1;
1149
+ let curIm = 0;
1150
+ const half = len >> 1;
1151
+ for(let k = 0; k < half; k++){
1152
+ const i = start + k;
1153
+ const j = i + half;
1154
+ const evenRe = re[i];
1155
+ const evenIm = im[i];
1156
+ const oddRe = re[j] * curRe - im[j] * curIm;
1157
+ const oddIm = re[j] * curIm + im[j] * curRe;
1158
+ re[i] = evenRe + oddRe;
1159
+ im[i] = evenIm + oddIm;
1160
+ re[j] = evenRe - oddRe;
1161
+ im[j] = evenIm - oddIm;
1162
+ const nextRe = curRe * wRe - curIm * wIm;
1163
+ curIm = curRe * wIm + curIm * wRe;
1164
+ curRe = nextRe;
1165
+ }
1166
+ }
1167
+ }
1168
+ if (inverse) for(let i = 0; i < n; i++){
1169
+ re[i] /= n;
1170
+ im[i] /= n;
1171
+ }
1172
+ }
1173
+ /** 2D transform of a `width x height` row-major complex image, rows then columns. */ function fft2d(re, im, width, height, inverse = false) {
1174
+ const rowRe = new Float64Array(width);
1175
+ const rowIm = new Float64Array(width);
1176
+ for(let y = 0; y < height; y++){
1177
+ const off = y * width;
1178
+ rowRe.set(re.subarray(off, off + width));
1179
+ rowIm.set(im.subarray(off, off + width));
1180
+ fft1d(rowRe, rowIm, inverse);
1181
+ re.set(rowRe, off);
1182
+ im.set(rowIm, off);
1183
+ }
1184
+ const colRe = new Float64Array(height);
1185
+ const colIm = new Float64Array(height);
1186
+ for(let x = 0; x < width; x++){
1187
+ for(let y = 0; y < height; y++){
1188
+ colRe[y] = re[y * width + x];
1189
+ colIm[y] = im[y * width + x];
1190
+ }
1191
+ fft1d(colRe, colIm, inverse);
1192
+ for(let y = 0; y < height; y++){
1193
+ re[y * width + x] = colRe[y];
1194
+ im[y * width + x] = colIm[y];
1195
+ }
1196
+ }
1197
+ }
1198
+
1199
+ /**
1200
+ * Correlate two equally sized images.
1201
+ *
1202
+ * Both are Hann-windowed first. Without it the FFT sees the frame edges as a
1203
+ * hard discontinuity repeating forever, and that cross pattern in the spectrum
1204
+ * can be a stronger signal than the page.
1205
+ */ function phaseCorrelate(a, b) {
1206
+ if (a.width !== b.width || a.height !== b.height) throw new Error('phaseCorrelate needs two images of the same size');
1207
+ const width = nextPowerOfTwo(a.width);
1208
+ const height = nextPowerOfTwo(a.height);
1209
+ const size = width * height;
1210
+ const aRe = new Float64Array(size);
1211
+ const aIm = new Float64Array(size);
1212
+ const bRe = new Float64Array(size);
1213
+ const bIm = new Float64Array(size);
1214
+ const windowX = hann(a.width);
1215
+ const windowY = hann(a.height);
1216
+ for(let y = 0; y < a.height; y++){
1217
+ const src = y * a.width;
1218
+ const dst = y * width;
1219
+ for(let x = 0; x < a.width; x++){
1220
+ const w = windowX[x] * windowY[y];
1221
+ aRe[dst + x] = a.data[src + x] * w;
1222
+ bRe[dst + x] = b.data[src + x] * w;
1223
+ }
1224
+ }
1225
+ fft2d(aRe, aIm, width, height);
1226
+ fft2d(bRe, bIm, width, height);
1227
+ // Cross-power spectrum of b against a, normalised to unit magnitude so that
1228
+ // every frequency contributes its phase and nothing else.
1229
+ for(let i = 0; i < size; i++){
1230
+ const re = bRe[i] * aRe[i] + bIm[i] * aIm[i];
1231
+ const im = bIm[i] * aRe[i] - bRe[i] * aIm[i];
1232
+ const magnitude = Math.hypot(re, im);
1233
+ if (magnitude < 1e-12) {
1234
+ bRe[i] = 0;
1235
+ bIm[i] = 0;
1236
+ } else {
1237
+ bRe[i] = re / magnitude;
1238
+ bIm[i] = im / magnitude;
1239
+ }
1240
+ }
1241
+ fft2d(bRe, bIm, width, height, true);
1242
+ let peakIndex = 0;
1243
+ let peakValue = -Infinity;
1244
+ for(let i = 0; i < size; i++)if (bRe[i] > peakValue) {
1245
+ peakValue = bRe[i];
1246
+ peakIndex = i;
1247
+ }
1248
+ const px = peakIndex % width;
1249
+ const py = Math.floor(peakIndex / width);
1250
+ const dx = wrap(px + parabolic(sample(bRe, width, height, px - 1, py), peakValue, sample(bRe, width, height, px + 1, py)), width);
1251
+ const dy = wrap(py + parabolic(sample(bRe, width, height, px, py - 1), peakValue, sample(bRe, width, height, px, py + 1)), height);
1252
+ return {
1253
+ dx,
1254
+ dy,
1255
+ peak: peakValue
1256
+ };
1257
+ }
1258
+ /**
1259
+ * Sub-pixel offset of a peak, by fitting a parabola through it and its neighbours.
1260
+ *
1261
+ * The correlation surface is sampled on the pixel grid, but the true offset is
1262
+ * not a whole number of pixels. Three samples determine a parabola, and its
1263
+ * vertex is a better estimate than the middle sample - typically to about a
1264
+ * tenth of a pixel.
1265
+ */ function parabolic(left, center, right) {
1266
+ const denominator = left - 2 * center + right;
1267
+ if (Math.abs(denominator) < 1e-12) return 0;
1268
+ const offset = 0.5 * (left - right) / denominator;
1269
+ return Math.abs(offset) < 1 ? offset : 0;
1270
+ }
1271
+ function sample(data, width, height, x, y) {
1272
+ const cx = (x % width + width) % width;
1273
+ const cy = (y % height + height) % height;
1274
+ return data[cy * width + cx];
1275
+ }
1276
+ /** Map an index in `[0, n)` onto a signed shift in `[-n/2, n/2)`. */ function wrap(value, n) {
1277
+ return value > n / 2 ? value - n : value;
1278
+ }
1279
+ function hann(n) {
1280
+ const w = new Float64Array(n);
1281
+ if (n === 1) {
1282
+ w[0] = 1;
1283
+ return w;
1284
+ }
1285
+ for(let i = 0; i < n; i++)w[i] = 0.5 * (1 - Math.cos(2 * Math.PI * i / (n - 1)));
1286
+ return w;
1287
+ }
1288
+
1289
+ function estimateCoarse(originalInk, scannedInk, options = {}) {
1290
+ const { workingSize = 512, maxSkewDeg = 12, maxScaleRatio = 6 } = options;
1291
+ const original = downscaleGray(originalInk, workingSize);
1292
+ const scanned = downscaleGray(scannedInk, workingSize);
1293
+ const originalSkew = estimateSkew(original.image, {
1294
+ maxAngleDeg: maxSkewDeg
1295
+ });
1296
+ const scannedSkew = estimateSkew(scanned.image, {
1297
+ maxAngleDeg: maxSkewDeg
1298
+ });
1299
+ const originalFlat = contentExtent(original.image, 0);
1300
+ const scannedFlat = contentExtent(scanned.image, 0);
1301
+ const originalTilted = contentExtent(original.image, originalSkew);
1302
+ const scannedTilted = contentExtent(scanned.image, scannedSkew);
1303
+ const candidates = [];
1304
+ const frameScale = geometricMean(scanned.image.width / original.image.width, scanned.image.height / original.image.height);
1305
+ push(candidates, 'frame', similarity(frameScale, 0, {
1306
+ x: original.image.width / 2,
1307
+ y: original.image.height / 2
1308
+ }, {
1309
+ x: scanned.image.width / 2,
1310
+ y: scanned.image.height / 2
1311
+ }), maxScaleRatio);
1312
+ if (originalFlat.density > 0 && scannedFlat.density > 0) push(candidates, 'content', fromExtents(originalFlat, scannedFlat, 0), maxScaleRatio);
1313
+ if (originalTilted.density > 0 && scannedTilted.density > 0) push(candidates, 'deskew', fromExtents(originalTilted, scannedTilted, scannedSkew - originalSkew), maxScaleRatio);
1314
+ let bestMatrix = similarity(Number.isFinite(frameScale) ? frameScale : 1, 0, {
1315
+ x: original.image.width / 2,
1316
+ y: original.image.height / 2
1317
+ }, {
1318
+ x: scanned.image.width / 2,
1319
+ y: scanned.image.height / 2
1320
+ });
1321
+ let bestScore = -Infinity;
1322
+ let bestStrategy = 'fallback';
1323
+ for (const candidate of candidates){
1324
+ for (const variant of withTranslationPolish(candidate, original.image, scanned.image)){
1325
+ const warped = warpGray(scanned.image, variant.matrix, original.image.width, original.image.height, 0);
1326
+ const score = correlation(original.image, warped);
1327
+ if (score > bestScore) {
1328
+ bestMatrix = variant.matrix;
1329
+ bestScore = score;
1330
+ bestStrategy = variant.strategy;
1331
+ }
1332
+ }
1333
+ }
1334
+ return {
1335
+ // Measured on two independently shrunk copies; hand back full-resolution pixels.
1336
+ matrix: rebase(bestMatrix, original.scale, scanned.scale),
1337
+ score: bestScore === -Infinity ? 0 : bestScore,
1338
+ strategy: bestStrategy,
1339
+ skew: {
1340
+ original: originalSkew,
1341
+ scanned: scannedSkew
1342
+ }
1343
+ };
1344
+ }
1345
+ /** The coarse matrix, plus a copy nudged by whatever phase correlation says is left over. */ function withTranslationPolish(candidate, original, scanned) {
1346
+ const warped = warpGray(scanned, candidate.matrix, original.width, original.height, 0);
1347
+ let shift;
1348
+ try {
1349
+ shift = phaseCorrelate(original, warped);
1350
+ } catch (unused) {
1351
+ return [
1352
+ candidate
1353
+ ];
1354
+ }
1355
+ if (!Number.isFinite(shift.dx) || !Number.isFinite(shift.dy)) return [
1356
+ candidate
1357
+ ];
1358
+ if (Math.abs(shift.dx) < 0.25 && Math.abs(shift.dy) < 0.25) return [
1359
+ candidate
1360
+ ];
1361
+ // `warped` sits in the original's frame, so a residual shift of d means the
1362
+ // original at p matches the warp at p + d: sample d further along.
1363
+ return [
1364
+ candidate,
1365
+ {
1366
+ strategy: `${candidate.strategy}+phase`,
1367
+ matrix: multiply(candidate.matrix, translation(shift.dx, shift.dy))
1368
+ }
1369
+ ];
1370
+ }
1371
+ function fromExtents(original, scanned, angle) {
1372
+ const scale = geometricMean(scanned.width / original.width, scanned.height / original.height);
1373
+ return similarity(scale, angle, original.center, scanned.center);
1374
+ }
1375
+ function push(into, strategy, matrix, maxScaleRatio) {
1376
+ if (isPlausible(matrix, maxScaleRatio)) into.push({
1377
+ strategy,
1378
+ matrix
1379
+ });
1380
+ }
1381
+ /**
1382
+ * Geometric rather than arithmetic mean of the two axis ratios.
1383
+ *
1384
+ * The quantity is a ratio, and the mean of a ratio and its reciprocal should be
1385
+ * one. Arithmetic mean says 1.25.
1386
+ */ function geometricMean(a, b) {
1387
+ if (Number.isNaN(a) || Number.isNaN(b) || a <= 0 || b <= 0) return NaN;
1388
+ return Math.sqrt(a * b);
1389
+ }
1390
+
1391
+ /**
1392
+ * A seeded PRNG, so that two runs on the same bytes give the same matrix.
1393
+ *
1394
+ * RANSAC samples at random and the BRIEF pattern is drawn at random; with
1395
+ * `Math.random` the library would return a slightly different answer every
1396
+ * time, which makes a regression test a coin toss and a production bug
1397
+ * impossible to reproduce from the inputs alone. mulberry32 is 32 bits of
1398
+ * state and passes the statistical tests that matter at this scale.
1399
+ */ function createRandom(seed) {
1400
+ let state = seed >>> 0;
1401
+ return ()=>{
1402
+ state = state + 0x6D2B79F5 >>> 0;
1403
+ let t = state;
1404
+ t = Math.imul(t ^ t >>> 15, t | 1);
1405
+ t ^= t + Math.imul(t ^ t >>> 7, t | 61);
1406
+ return ((t ^ t >>> 14) >>> 0) / 4294967296;
1407
+ };
1408
+ }
1409
+ /** Box-Muller, used to draw the BRIEF sampling pattern from a Gaussian around the patch centre. */ function gaussian(random) {
1410
+ const u = Math.max(random(), Number.EPSILON);
1411
+ const v = random();
1412
+ return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v);
1413
+ }
1414
+
1415
+ const DESCRIPTOR_WORDS = 8;
1416
+ const DESCRIPTOR_BITS = DESCRIPTOR_WORDS * 32;
1417
+ /** Rotation bins for the steered pattern. 32 bins is 11.25 degrees, finer than ORB's own 12. */ const ANGLE_BINS = 32;
1418
+ /** The Bresenham circle of radius 3, clockwise from the top. */ const CIRCLE = [
1419
+ [
1420
+ 0,
1421
+ -3
1422
+ ],
1423
+ [
1424
+ 1,
1425
+ -3
1426
+ ],
1427
+ [
1428
+ 2,
1429
+ -2
1430
+ ],
1431
+ [
1432
+ 3,
1433
+ -1
1434
+ ],
1435
+ [
1436
+ 3,
1437
+ 0
1438
+ ],
1439
+ [
1440
+ 3,
1441
+ 1
1442
+ ],
1443
+ [
1444
+ 2,
1445
+ 2
1446
+ ],
1447
+ [
1448
+ 1,
1449
+ 3
1450
+ ],
1451
+ [
1452
+ 0,
1453
+ 3
1454
+ ],
1455
+ [
1456
+ -1,
1457
+ 3
1458
+ ],
1459
+ [
1460
+ -2,
1461
+ 2
1462
+ ],
1463
+ [
1464
+ -3,
1465
+ 1
1466
+ ],
1467
+ [
1468
+ -3,
1469
+ 0
1470
+ ],
1471
+ [
1472
+ -3,
1473
+ -1
1474
+ ],
1475
+ [
1476
+ -2,
1477
+ -2
1478
+ ],
1479
+ [
1480
+ -1,
1481
+ -3
1482
+ ]
1483
+ ];
1484
+ const ARC = 9;
1485
+ const COMPASS_MINIMUM = Math.floor((ARC - 1) / 4);
1486
+ function detectAndDescribe(image, options = {}) {
1487
+ const { maxFeatures = 1200, fastThreshold = 0.08, levels = 3, scaleFactor = 1.3, patchSize = 31, gridSize = 8, seed = 0xB81EF } = options;
1488
+ const patterns = steeredPatterns(patchSize, seed);
1489
+ const halfPatch = (patchSize - 1) / 2;
1490
+ const border = Math.ceil(halfPatch * Math.SQRT2) + 2;
1491
+ const keypoints = [];
1492
+ const descriptorChunks = [];
1493
+ const perLevel = Math.ceil(maxFeatures / levels);
1494
+ for(let level = 0; level < levels; level++){
1495
+ const levelScale = scaleFactor ** level;
1496
+ const width = Math.round(image.width / levelScale);
1497
+ const height = Math.round(image.height / levelScale);
1498
+ if (width < border * 2 + 8 || height < border * 2 + 8) break;
1499
+ const levelImage = level === 0 ? image : resizeGray(image, width, height);
1500
+ // BRIEF compares single pixels, so it is exquisitely sensitive to noise;
1501
+ // the smoothing is part of the descriptor, not a preprocessing nicety.
1502
+ const smoothed = boxBlur(levelImage, 2);
1503
+ const found = detectFast(levelImage, fastThreshold, border);
1504
+ const kept = distribute(found, width, height, gridSize, perLevel);
1505
+ for (const corner of kept){
1506
+ const angle = orientation(levelImage, corner.x, corner.y, halfPatch);
1507
+ const bin = angleBin(angle);
1508
+ const descriptor = describe(smoothed, corner.x, corner.y, patterns[bin]);
1509
+ if (descriptor === null) continue;
1510
+ descriptorChunks.push(descriptor);
1511
+ keypoints.push({
1512
+ x: (corner.x + 0.5) * levelScale,
1513
+ y: (corner.y + 0.5) * levelScale,
1514
+ score: corner.score,
1515
+ angle,
1516
+ level,
1517
+ size: patchSize * levelScale
1518
+ });
1519
+ }
1520
+ }
1521
+ const descriptors = new Uint32Array(descriptorChunks.length * DESCRIPTOR_WORDS);
1522
+ for (const [i, chunk] of descriptorChunks.entries())descriptors.set(chunk, i * DESCRIPTOR_WORDS);
1523
+ return {
1524
+ keypoints,
1525
+ descriptors
1526
+ };
1527
+ }
1528
+ /** FAST-9 with a 3x3 non-maximum suppression pass over the corner scores. */ function detectFast(image, threshold, border) {
1529
+ const { width, height, data } = image;
1530
+ const scores = new Float32Array(width * height);
1531
+ const ring = new Float64Array(16);
1532
+ for(let y = border; y < height - border; y++){
1533
+ for(let x = border; x < width - border; x++){
1534
+ const center = data[y * width + x];
1535
+ const high = center + threshold;
1536
+ const low = center - threshold;
1537
+ // Cheap rejection on the four compass points, which sit 4 apart on the
1538
+ // ring. Any run of ARC consecutive pixels must contain at least
1539
+ // floor((ARC - 1) / 4) of them, so fewer than that cannot be a corner.
1540
+ // For ARC = 9 that bound is 2, not the 3 that the widely quoted FAST-12
1541
+ // version of this test uses - requiring 3 here silently discards real
1542
+ // corners, among them the corner of a plain filled rectangle.
1543
+ let bright = 0;
1544
+ let dark = 0;
1545
+ for (const k of [
1546
+ 0,
1547
+ 4,
1548
+ 8,
1549
+ 12
1550
+ ]){
1551
+ const value = data[(y + CIRCLE[k][1]) * width + x + CIRCLE[k][0]];
1552
+ if (value > high) bright++;
1553
+ else if (value < low) dark++;
1554
+ }
1555
+ if (bright < COMPASS_MINIMUM && dark < COMPASS_MINIMUM) continue;
1556
+ for(let k = 0; k < 16; k++)ring[k] = data[(y + CIRCLE[k][1]) * width + x + CIRCLE[k][0]];
1557
+ if (!hasArc(ring, high, low)) continue;
1558
+ let brightSum = 0;
1559
+ let darkSum = 0;
1560
+ for(let k = 0; k < 16; k++){
1561
+ if (ring[k] > high) brightSum += ring[k] - high;
1562
+ else if (ring[k] < low) darkSum += low - ring[k];
1563
+ }
1564
+ scores[y * width + x] = Math.max(brightSum, darkSum);
1565
+ }
1566
+ }
1567
+ const corners = [];
1568
+ for(let y = border; y < height - border; y++){
1569
+ for(let x = border; x < width - border; x++){
1570
+ const score = scores[y * width + x];
1571
+ if (score <= 0) continue;
1572
+ let isPeak = true;
1573
+ for(let dy = -1; isPeak && dy <= 1; dy++)for(let dx = -1; dx <= 1; dx++){
1574
+ if (dx === 0 && dy === 0) continue;
1575
+ if (scores[(y + dy) * width + x + dx] > score) {
1576
+ isPeak = false;
1577
+ break;
1578
+ }
1579
+ }
1580
+ if (isPeak) corners.push({
1581
+ x,
1582
+ y,
1583
+ score
1584
+ });
1585
+ }
1586
+ }
1587
+ return corners;
1588
+ }
1589
+ /** True when 9 consecutive ring pixels (wrapping) are all above `high` or all below `low`. */ function hasArc(ring, high, low) {
1590
+ let runBright = 0;
1591
+ let runDark = 0;
1592
+ for(let k = 0; k < 16 + ARC - 1; k++){
1593
+ const value = ring[k % 16];
1594
+ runBright = value > high ? runBright + 1 : 0;
1595
+ runDark = value < low ? runDark + 1 : 0;
1596
+ if (runBright >= ARC || runDark >= ARC) return true;
1597
+ }
1598
+ return false;
1599
+ }
1600
+ /**
1601
+ * Keep the strongest corners, but spread over the page.
1602
+ *
1603
+ * Score alone concentrates every keypoint in the densest block of text, and a
1604
+ * transform fitted to correspondences from one corner of the page extrapolates
1605
+ * badly to the other three. Filling a grid first, then topping up from what is
1606
+ * left, buys coverage without throwing away the best corners.
1607
+ */ function distribute(corners, width, height, gridSize, budget) {
1608
+ if (corners.length <= budget) return corners;
1609
+ const cells = new Map();
1610
+ const cellWidth = width / gridSize;
1611
+ const cellHeight = height / gridSize;
1612
+ for (const corner of corners){
1613
+ const cx = Math.min(gridSize - 1, Math.floor(corner.x / cellWidth));
1614
+ const cy = Math.min(gridSize - 1, Math.floor(corner.y / cellHeight));
1615
+ const key = cy * gridSize + cx;
1616
+ const bucket = cells.get(key);
1617
+ if (bucket === undefined) cells.set(key, [
1618
+ corner
1619
+ ]);
1620
+ else bucket.push(corner);
1621
+ }
1622
+ const perCell = Math.max(1, Math.floor(budget / Math.max(1, cells.size)));
1623
+ const kept = [];
1624
+ const leftovers = [];
1625
+ for (const bucket of cells.values()){
1626
+ bucket.sort((a, b)=>b.score - a.score);
1627
+ kept.push(...bucket.slice(0, perCell));
1628
+ leftovers.push(...bucket.slice(perCell));
1629
+ }
1630
+ if (kept.length < budget) {
1631
+ leftovers.sort((a, b)=>b.score - a.score);
1632
+ kept.push(...leftovers.slice(0, budget - kept.length));
1633
+ }
1634
+ // With more cells than budget the floor above rounds to one per cell, which
1635
+ // can overshoot; the budget is a promise, so trim by score.
1636
+ if (kept.length > budget) {
1637
+ kept.sort((a, b)=>b.score - a.score);
1638
+ return kept.slice(0, budget);
1639
+ }
1640
+ return kept;
1641
+ }
1642
+ /**
1643
+ * Angle from the patch centre to its centre of intensity mass.
1644
+ *
1645
+ * On an ink image the mass is the writing, so the angle turns with the page —
1646
+ * which is the entire trick that makes a binary descriptor rotation invariant.
1647
+ */ function orientation(image, cx, cy, radius) {
1648
+ const { width, height, data } = image;
1649
+ const r = Math.floor(radius);
1650
+ let m10 = 0;
1651
+ let m01 = 0;
1652
+ for(let dy = -r; dy <= r; dy++){
1653
+ const y = cy + dy;
1654
+ if (y < 0 || y >= height) continue;
1655
+ const span = Math.floor(Math.sqrt(r * r - dy * dy));
1656
+ const row = y * width;
1657
+ for(let dx = -span; dx <= span; dx++){
1658
+ const x = cx + dx;
1659
+ if (x < 0 || x >= width) continue;
1660
+ const value = data[row + x];
1661
+ m10 += dx * value;
1662
+ m01 += dy * value;
1663
+ }
1664
+ }
1665
+ return Math.atan2(m01, m10);
1666
+ }
1667
+ function angleBin(angle) {
1668
+ const twoPi = Math.PI * 2;
1669
+ const normalized = (angle % twoPi + twoPi) % twoPi;
1670
+ return Math.floor(normalized / twoPi * ANGLE_BINS) % ANGLE_BINS;
1671
+ }
1672
+ function describe(image, cx, cy, pattern) {
1673
+ const { width, height, data } = image;
1674
+ const out = new Uint32Array(DESCRIPTOR_WORDS);
1675
+ for(let bit = 0; bit < DESCRIPTOR_BITS; bit++){
1676
+ const base = bit * 4;
1677
+ const x1 = cx + pattern[base];
1678
+ const y1 = cy + pattern[base + 1];
1679
+ const x2 = cx + pattern[base + 2];
1680
+ const y2 = cy + pattern[base + 3];
1681
+ if (x1 < 0 || y1 < 0 || x2 < 0 || y2 < 0 || x1 >= width || y1 >= height || x2 >= width || y2 >= height) return null;
1682
+ if (data[y1 * width + x1] < data[y2 * width + x2]) out[bit >> 5] |= 1 << (bit & 31);
1683
+ }
1684
+ return out;
1685
+ }
1686
+ const patternCache = new Map();
1687
+ /**
1688
+ * One integer sampling pattern per rotation bin, built once and cached.
1689
+ *
1690
+ * Rotating 256 point pairs per keypoint would mean a thousand trig calls each;
1691
+ * quantising the angle instead turns the whole thing into a table lookup, at a
1692
+ * cost of at most half a bin of angular error.
1693
+ */ function steeredPatterns(patchSize, seed) {
1694
+ const key = `${patchSize}:${seed}`;
1695
+ const cached = patternCache.get(key);
1696
+ if (cached !== undefined) return cached;
1697
+ const half = (patchSize - 1) / 2;
1698
+ const sigma = patchSize / 5;
1699
+ const random = createRandom(seed);
1700
+ const base = new Float64Array(DESCRIPTOR_BITS * 4);
1701
+ for(let i = 0; i < DESCRIPTOR_BITS * 4; i++)base[i] = clamp(Math.round(gaussian(random) * sigma), -half, half);
1702
+ const patterns = [];
1703
+ for(let bin = 0; bin < ANGLE_BINS; bin++){
1704
+ const angle = bin / ANGLE_BINS * Math.PI * 2;
1705
+ const cos = Math.cos(angle);
1706
+ const sin = Math.sin(angle);
1707
+ const rotated = new Int32Array(DESCRIPTOR_BITS * 4);
1708
+ for(let i = 0; i < DESCRIPTOR_BITS * 4; i += 2){
1709
+ const x = base[i];
1710
+ const y = base[i + 1];
1711
+ rotated[i] = Math.round(cos * x - sin * y);
1712
+ rotated[i + 1] = Math.round(sin * x + cos * y);
1713
+ }
1714
+ patterns.push(rotated);
1715
+ }
1716
+ patternCache.set(key, patterns);
1717
+ return patterns;
1718
+ }
1719
+ function clamp(value, min, max) {
1720
+ return value < min ? min : Math.min(value, max);
1721
+ }
1722
+
1723
+ /**
1724
+ * "No candidate yet", as a value an Int32Array can actually hold.
1725
+ *
1726
+ * `Number.MAX_SAFE_INTEGER` cannot: writing it to an Int32Array truncates it to
1727
+ * -1, and every subsequent "is this closer?" comparison then answers no.
1728
+ */ const UNSET = 0x7FFFFFFF;
1729
+ function matchFeatures(source, target, options = {}) {
1730
+ const { ratio = 0.8, maxDistance = 96, crossCheck = true, maxDisplacement = Infinity } = options;
1731
+ const n = source.keypoints.length;
1732
+ const m = target.keypoints.length;
1733
+ if (n === 0 || m === 0) return [];
1734
+ const bestForSource = new Int32Array(n).fill(-1);
1735
+ const bestDistance = new Int32Array(n).fill(UNSET);
1736
+ const secondDistance = new Int32Array(n).fill(UNSET);
1737
+ const bestForTarget = new Int32Array(m).fill(-1);
1738
+ const bestTargetDistance = new Int32Array(m).fill(UNSET);
1739
+ const gated = Number.isFinite(maxDisplacement);
1740
+ const gate = maxDisplacement * maxDisplacement;
1741
+ for(let i = 0; i < n; i++){
1742
+ const a = source.keypoints[i];
1743
+ const offsetA = i * DESCRIPTOR_WORDS;
1744
+ let first = UNSET;
1745
+ let second = UNSET;
1746
+ let firstIndex = -1;
1747
+ for(let j = 0; j < m; j++){
1748
+ const b = target.keypoints[j];
1749
+ if (gated) {
1750
+ const dx = a.x - b.x;
1751
+ const dy = a.y - b.y;
1752
+ if (dx * dx + dy * dy > gate) continue;
1753
+ }
1754
+ const distance = hamming(source.descriptors, offsetA, target.descriptors, j * DESCRIPTOR_WORDS);
1755
+ if (distance < first) {
1756
+ second = first;
1757
+ first = distance;
1758
+ firstIndex = j;
1759
+ } else if (distance < second) {
1760
+ second = distance;
1761
+ }
1762
+ if (distance < bestTargetDistance[j]) {
1763
+ bestTargetDistance[j] = distance;
1764
+ bestForTarget[j] = i;
1765
+ }
1766
+ }
1767
+ bestForSource[i] = firstIndex;
1768
+ bestDistance[i] = first;
1769
+ secondDistance[i] = second;
1770
+ }
1771
+ const matches = [];
1772
+ for(let i = 0; i < n; i++){
1773
+ const j = bestForSource[i];
1774
+ if (j < 0) continue;
1775
+ if (bestDistance[i] > maxDistance) continue;
1776
+ if (secondDistance[i] !== UNSET && bestDistance[i] > ratio * secondDistance[i]) continue;
1777
+ if (crossCheck && bestForTarget[j] !== i) continue;
1778
+ const a = source.keypoints[i];
1779
+ const b = target.keypoints[j];
1780
+ matches.push({
1781
+ source: {
1782
+ x: a.x,
1783
+ y: a.y
1784
+ },
1785
+ target: {
1786
+ x: b.x,
1787
+ y: b.y
1788
+ },
1789
+ distance: bestDistance[i]
1790
+ });
1791
+ }
1792
+ return matches;
1793
+ }
1794
+ /** Hamming distance between two 256-bit descriptors. */ function hamming(a, offsetA, b, offsetB) {
1795
+ let total = 0;
1796
+ for(let k = 0; k < DESCRIPTOR_WORDS; k++)total += popcount(a[offsetA + k] ^ b[offsetB + k]);
1797
+ return total;
1798
+ }
1799
+ /** SWAR bit count: pair off, then nibble off, then one multiply to sum the bytes. */ function popcount(value) {
1800
+ let v = value - (value >> 1 & 0x55555555);
1801
+ v = (v & 0x33333333) + (v >> 2 & 0x33333333);
1802
+ v = v + (v >> 4) & 0x0F0F0F0F;
1803
+ return Math.imul(v, 0x01010101) >> 24 & 0xFF;
1804
+ }
1805
+
1806
+ /**
1807
+ * The two dense solvers the estimators need, and nothing more.
1808
+ *
1809
+ * Both work on plain row-major `Float64Array`s of a fixed, tiny size (n <= 9),
1810
+ * so there is no pivoting strategy worth agonising over and no allocation
1811
+ * pressure worth caring about.
1812
+ */ /**
1813
+ * Solve `A x = b` by Gaussian elimination with partial pivoting.
1814
+ *
1815
+ * `a` is destroyed. Returns `null` when the system is singular, which for the
1816
+ * affine fit means the sample points were collinear — a real, common case, not
1817
+ * an exceptional one, so it is a return value rather than a throw.
1818
+ */ function solve(a, b, n) {
1819
+ const m = Float64Array.from(a);
1820
+ const x = Float64Array.from(b);
1821
+ for(let col = 0; col < n; col++){
1822
+ let pivot = col;
1823
+ let best = Math.abs(m[col * n + col]);
1824
+ for(let row = col + 1; row < n; row++){
1825
+ const v = Math.abs(m[row * n + col]);
1826
+ if (v > best) {
1827
+ best = v;
1828
+ pivot = row;
1829
+ }
1830
+ }
1831
+ if (best < 1e-12) return null;
1832
+ if (pivot !== col) {
1833
+ for(let k = 0; k < n; k++){
1834
+ const t = m[col * n + k];
1835
+ m[col * n + k] = m[pivot * n + k];
1836
+ m[pivot * n + k] = t;
1837
+ }
1838
+ const t = x[col];
1839
+ x[col] = x[pivot];
1840
+ x[pivot] = t;
1841
+ }
1842
+ const diag = m[col * n + col];
1843
+ for(let row = col + 1; row < n; row++){
1844
+ const factor = m[row * n + col] / diag;
1845
+ if (factor === 0) continue;
1846
+ for(let k = col; k < n; k++)m[row * n + k] -= factor * m[col * n + k];
1847
+ x[row] -= factor * x[col];
1848
+ }
1849
+ }
1850
+ for(let row = n - 1; row >= 0; row--){
1851
+ let sum = x[row];
1852
+ for(let k = row + 1; k < n; k++)sum -= m[row * n + k] * x[k];
1853
+ x[row] = sum / m[row * n + row];
1854
+ }
1855
+ return x;
1856
+ }
1857
+ /**
1858
+ * Eigen-decompose a symmetric matrix with the cyclic Jacobi method.
1859
+ *
1860
+ * Jacobi is the right tool at this size: it is a dozen lines, it is
1861
+ * unconditionally stable for symmetric input, and it gives eigenvectors for
1862
+ * free. The homography fit needs the eigenvector of `A^T A` belonging to the
1863
+ * smallest eigenvalue — the direction the data constrains least, which is the
1864
+ * null-space direction we are after.
1865
+ *
1866
+ * @param input Row-major, `n * n`, symmetric. Not modified.
1867
+ * @returns `values[i]` paired with column `i` of `vectors` (`vectors[row * n + i]`).
1868
+ */ function jacobiEigen(input, n, maxSweeps = 60) {
1869
+ const a = Float64Array.from(input);
1870
+ const v = new Float64Array(n * n);
1871
+ for(let i = 0; i < n; i++)v[i * n + i] = 1;
1872
+ for(let sweep = 0; sweep < maxSweeps; sweep++){
1873
+ let off = 0;
1874
+ for(let p = 0; p < n; p++)for(let q = p + 1; q < n; q++)off += a[p * n + q] * a[p * n + q];
1875
+ if (off < 1e-24) break;
1876
+ for(let p = 0; p < n; p++){
1877
+ for(let q = p + 1; q < n; q++){
1878
+ const apq = a[p * n + q];
1879
+ if (Math.abs(apq) < 1e-18) continue;
1880
+ const theta = (a[q * n + q] - a[p * n + p]) / (2 * apq);
1881
+ const t = Math.sign(theta || 1) / (Math.abs(theta) + Math.sqrt(theta * theta + 1));
1882
+ const c = 1 / Math.sqrt(t * t + 1);
1883
+ const s = t * c;
1884
+ for(let k = 0; k < n; k++){
1885
+ const akp = a[k * n + p];
1886
+ const akq = a[k * n + q];
1887
+ a[k * n + p] = c * akp - s * akq;
1888
+ a[k * n + q] = s * akp + c * akq;
1889
+ }
1890
+ for(let k = 0; k < n; k++){
1891
+ const apk = a[p * n + k];
1892
+ const aqk = a[q * n + k];
1893
+ a[p * n + k] = c * apk - s * aqk;
1894
+ a[q * n + k] = s * apk + c * aqk;
1895
+ }
1896
+ for(let k = 0; k < n; k++){
1897
+ const vkp = v[k * n + p];
1898
+ const vkq = v[k * n + q];
1899
+ v[k * n + p] = c * vkp - s * vkq;
1900
+ v[k * n + q] = s * vkp + c * vkq;
1901
+ }
1902
+ }
1903
+ }
1904
+ }
1905
+ const values = new Float64Array(n);
1906
+ for(let i = 0; i < n; i++)values[i] = a[i * n + i];
1907
+ return {
1908
+ values,
1909
+ vectors: v
1910
+ };
1911
+ }
1912
+ /** The unit eigenvector of a symmetric `n x n` matrix belonging to its smallest eigenvalue. */ function smallestEigenvector(input, n) {
1913
+ const { values, vectors } = jacobiEigen(input, n);
1914
+ let best = 0;
1915
+ for(let i = 1; i < n; i++)if (values[i] < values[best]) best = i;
1916
+ const out = new Float64Array(n);
1917
+ let norm = 0;
1918
+ for(let row = 0; row < n; row++){
1919
+ out[row] = vectors[row * n + best];
1920
+ norm += out[row] * out[row];
1921
+ }
1922
+ norm = Math.sqrt(norm);
1923
+ if (norm > 0) for(let row = 0; row < n; row++)out[row] /= norm;
1924
+ return out;
1925
+ }
1926
+
1927
+ /** How many correspondences the model needs before it is determined at all. */ function minimumSamples(model) {
1928
+ switch(model){
1929
+ case 'similarity':
1930
+ {
1931
+ return 2;
1932
+ }
1933
+ case 'affine':
1934
+ {
1935
+ return 3;
1936
+ }
1937
+ case 'homography':
1938
+ {
1939
+ return 4;
1940
+ }
1941
+ }
1942
+ }
1943
+ function fitModel(model, matches, indices) {
1944
+ switch(model){
1945
+ case 'similarity':
1946
+ {
1947
+ return fitSimilarity(matches, indices);
1948
+ }
1949
+ case 'affine':
1950
+ {
1951
+ return fitAffine(matches, indices);
1952
+ }
1953
+ case 'homography':
1954
+ {
1955
+ return fitHomography(matches, indices);
1956
+ }
1957
+ }
1958
+ }
1959
+ /**
1960
+ * Least-squares similarity, in closed form.
1961
+ *
1962
+ * No iteration and no matrix inverse: centre both point sets, and the rotation
1963
+ * and scale fall out of two dot products. That closed form is why similarity
1964
+ * survives a RANSAC sample that affine would choke on.
1965
+ */ function fitSimilarity(matches, indices) {
1966
+ const picked = select(matches, indices);
1967
+ if (picked.length < 2) return null;
1968
+ let sx = 0;
1969
+ let sy = 0;
1970
+ let tx = 0;
1971
+ let ty = 0;
1972
+ for (const m of picked){
1973
+ sx += m.source.x;
1974
+ sy += m.source.y;
1975
+ tx += m.target.x;
1976
+ ty += m.target.y;
1977
+ }
1978
+ const n = picked.length;
1979
+ sx /= n;
1980
+ sy /= n;
1981
+ tx /= n;
1982
+ ty /= n;
1983
+ let dot = 0;
1984
+ let cross = 0;
1985
+ let norm = 0;
1986
+ for (const m of picked){
1987
+ const px = m.source.x - sx;
1988
+ const py = m.source.y - sy;
1989
+ const qx = m.target.x - tx;
1990
+ const qy = m.target.y - ty;
1991
+ dot += px * qx + py * qy;
1992
+ cross += px * qy - py * qx;
1993
+ norm += px * px + py * py;
1994
+ }
1995
+ if (norm < 1e-12) return null;
1996
+ const a = dot / norm;
1997
+ const b = cross / norm;
1998
+ if (Math.hypot(a, b) < 1e-9) return null;
1999
+ return [
2000
+ a,
2001
+ -b,
2002
+ tx - a * sx + b * sy,
2003
+ b,
2004
+ a,
2005
+ ty - b * sx - a * sy,
2006
+ 0,
2007
+ 0,
2008
+ 1
2009
+ ];
2010
+ }
2011
+ /** Least-squares affine: two independent 3x3 normal systems sharing one matrix. */ function fitAffine(matches, indices) {
2012
+ const picked = select(matches, indices);
2013
+ if (picked.length < 3) return null;
2014
+ const m = new Float64Array(9);
2015
+ const bx = new Float64Array(3);
2016
+ const by = new Float64Array(3);
2017
+ for (const match of picked){
2018
+ const { x, y } = match.source;
2019
+ const { x: u, y: v } = match.target;
2020
+ m[0] += x * x;
2021
+ m[1] += x * y;
2022
+ m[2] += x;
2023
+ m[4] += y * y;
2024
+ m[5] += y;
2025
+ m[8] += 1;
2026
+ bx[0] += x * u;
2027
+ bx[1] += y * u;
2028
+ bx[2] += u;
2029
+ by[0] += x * v;
2030
+ by[1] += y * v;
2031
+ by[2] += v;
2032
+ }
2033
+ m[3] = m[1];
2034
+ m[6] = m[2];
2035
+ m[7] = m[5];
2036
+ const row0 = solve(m, bx, 3);
2037
+ const row1 = solve(m, by, 3);
2038
+ if (row0 === null || row1 === null) return null;
2039
+ return [
2040
+ row0[0],
2041
+ row0[1],
2042
+ row0[2],
2043
+ row1[0],
2044
+ row1[1],
2045
+ row1[2],
2046
+ 0,
2047
+ 0,
2048
+ 1
2049
+ ];
2050
+ }
2051
+ /**
2052
+ * Direct Linear Transform with Hartley normalisation.
2053
+ *
2054
+ * The normalisation is not optional polish. Raw pixel coordinates put entries
2055
+ * like `x * u` (order 10^6) next to a constant 1 in the same row, and the
2056
+ * eigen solve then answers a question dominated by the big column. Centring
2057
+ * each point set and scaling it to a mean radius of `sqrt(2)` puts every
2058
+ * column on the same footing; the result is mapped back afterwards.
2059
+ */ function fitHomography(matches, indices) {
2060
+ const picked = select(matches, indices);
2061
+ if (picked.length < 4) return null;
2062
+ const sourceNorm = normalizer(picked.map((m)=>m.source));
2063
+ const targetNorm = normalizer(picked.map((m)=>m.target));
2064
+ if (sourceNorm === null || targetNorm === null) return null;
2065
+ // Accumulate A^T A directly: 9x9 regardless of how many points there are.
2066
+ const ata = new Float64Array(81);
2067
+ const row = new Float64Array(9);
2068
+ for (const match of picked){
2069
+ const p = apply(sourceNorm, match.source.x, match.source.y);
2070
+ const q = apply(targetNorm, match.target.x, match.target.y);
2071
+ row.set([
2072
+ -p.x,
2073
+ -p.y,
2074
+ -1,
2075
+ 0,
2076
+ 0,
2077
+ 0,
2078
+ q.x * p.x,
2079
+ q.x * p.y,
2080
+ q.x
2081
+ ]);
2082
+ accumulate(ata, row);
2083
+ row.set([
2084
+ 0,
2085
+ 0,
2086
+ 0,
2087
+ -p.x,
2088
+ -p.y,
2089
+ -1,
2090
+ q.y * p.x,
2091
+ q.y * p.y,
2092
+ q.y
2093
+ ]);
2094
+ accumulate(ata, row);
2095
+ }
2096
+ const h = smallestEigenvector(ata, 9);
2097
+ const normalized = [
2098
+ h[0],
2099
+ h[1],
2100
+ h[2],
2101
+ h[3],
2102
+ h[4],
2103
+ h[5],
2104
+ h[6],
2105
+ h[7],
2106
+ h[8]
2107
+ ];
2108
+ let denormalized;
2109
+ try {
2110
+ denormalized = multiply(invert(targetNorm), multiply(normalized, sourceNorm));
2111
+ } catch (unused) {
2112
+ return null;
2113
+ }
2114
+ const scale = denormalized[8];
2115
+ if (!Number.isFinite(scale) || Math.abs(scale) < 1e-12) return null;
2116
+ return denormalized.map((v)=>v / scale);
2117
+ }
2118
+ function accumulate(ata, row) {
2119
+ for(let i = 0; i < 9; i++){
2120
+ const vi = row[i];
2121
+ if (vi === 0) continue;
2122
+ for(let j = 0; j < 9; j++)ata[i * 9 + j] += vi * row[j];
2123
+ }
2124
+ }
2125
+ /** Translate to the centroid and scale so the mean distance from it is `sqrt(2)`. */ function normalizer(points) {
2126
+ let cx = 0;
2127
+ let cy = 0;
2128
+ for (const p of points){
2129
+ cx += p.x;
2130
+ cy += p.y;
2131
+ }
2132
+ cx /= points.length;
2133
+ cy /= points.length;
2134
+ let distance = 0;
2135
+ for (const p of points)distance += Math.hypot(p.x - cx, p.y - cy);
2136
+ distance /= points.length;
2137
+ // Explicit about NaN: a degenerate point set must fail, not divide.
2138
+ if (Number.isNaN(distance) || distance <= 1e-9) return null;
2139
+ const s = Math.SQRT2 / distance;
2140
+ return [
2141
+ s,
2142
+ 0,
2143
+ -s * cx,
2144
+ 0,
2145
+ s,
2146
+ -s * cy,
2147
+ 0,
2148
+ 0,
2149
+ 1
2150
+ ];
2151
+ }
2152
+ function apply(m, x, y) {
2153
+ return {
2154
+ x: m[0] * x + m[1] * y + m[2],
2155
+ y: m[3] * x + m[4] * y + m[5]
2156
+ };
2157
+ }
2158
+ function select(matches, indices) {
2159
+ return indices === undefined ? matches : indices.map((i)=>matches[i]);
2160
+ }
2161
+
2162
+ function ransac(matches, options) {
2163
+ var _options_minInliers;
2164
+ const { model, threshold, maxIterations = 2000, confidence = 0.995, seed = 0x5CA7F1 } = options;
2165
+ const sampleSize = minimumSamples(model);
2166
+ const minInliers = (_options_minInliers = options.minInliers) != null ? _options_minInliers : Math.max(sampleSize + 2, Math.ceil(matches.length * 0.08));
2167
+ if (matches.length < Math.max(sampleSize, minInliers)) return null;
2168
+ const random = createRandom(seed);
2169
+ const sample = Array.from({
2170
+ length: sampleSize
2171
+ }, ()=>0);
2172
+ let bestInliers = [];
2173
+ let bestMatrix = null;
2174
+ let limit = maxIterations;
2175
+ let iterations = 0;
2176
+ for(; iterations < limit && iterations < maxIterations; iterations++){
2177
+ drawSample(sample, matches.length, random);
2178
+ const candidate = fitModel(model, matches, sample);
2179
+ if (candidate === null || !isPlausible(candidate)) continue;
2180
+ const inliers = findInliers(matches, candidate, threshold);
2181
+ if (inliers.length <= bestInliers.length) continue;
2182
+ bestInliers = inliers;
2183
+ bestMatrix = candidate;
2184
+ // Adaptive stopping: once a large fraction agrees, the chance that more
2185
+ // draws find something better collapses, and so does the budget.
2186
+ const ratio = inliers.length / matches.length;
2187
+ if (ratio > 0 && ratio < 1) {
2188
+ const denominator = Math.log(1 - ratio ** sampleSize);
2189
+ if (denominator < 0) limit = Math.min(maxIterations, Math.ceil(Math.log(1 - confidence) / denominator) + 1);
2190
+ } else if (ratio >= 1) {
2191
+ limit = iterations + 1;
2192
+ }
2193
+ }
2194
+ if (bestMatrix === null || bestInliers.length < minInliers) return null;
2195
+ // Re-fit on every inlier. The minimal sample only ever located the consensus;
2196
+ // the accurate transform comes from all of it.
2197
+ let refined = fitModel(model, matches, bestInliers);
2198
+ if (refined !== null && isPlausible(refined)) {
2199
+ const refinedInliers = findInliers(matches, refined, threshold);
2200
+ if (refinedInliers.length >= bestInliers.length) bestInliers = refinedInliers;
2201
+ else refined = bestMatrix;
2202
+ } else {
2203
+ refined = bestMatrix;
2204
+ }
2205
+ const matrix = refined != null ? refined : bestMatrix;
2206
+ let total = 0;
2207
+ for (const i of bestInliers)total += reprojectionError(matrix, matches[i].source, matches[i].target);
2208
+ return {
2209
+ matrix,
2210
+ inliers: bestInliers,
2211
+ inlierRatio: bestInliers.length / matches.length,
2212
+ iterations,
2213
+ error: bestInliers.length > 0 ? total / bestInliers.length : Infinity
2214
+ };
2215
+ }
2216
+ function findInliers(matches, matrix, threshold) {
2217
+ const inliers = [];
2218
+ for (const [i, match] of matches.entries())if (reprojectionError(matrix, match.source, match.target) <= threshold) inliers.push(i);
2219
+ return inliers;
2220
+ }
2221
+ /** Distinct indices, drawn without replacement. */ function drawSample(into, count, random) {
2222
+ for(let i = 0; i < into.length; i++){
2223
+ let candidate = 0;
2224
+ for(let attempt = 0; attempt < 32; attempt++){
2225
+ candidate = Math.min(count - 1, Math.floor(random() * count));
2226
+ let duplicate = false;
2227
+ for(let j = 0; j < i; j++)if (into[j] === candidate) {
2228
+ duplicate = true;
2229
+ break;
2230
+ }
2231
+ if (!duplicate) break;
2232
+ }
2233
+ into[i] = candidate;
2234
+ }
2235
+ }
2236
+
2237
+ function alignScan(original, scanned, options = {}) {
2238
+ const startedAt = Date.now();
2239
+ const { model = 'similarity', workingSize = 1400, coarseSize = 512, maxFeatures = 1200, ransacThreshold = 3, minInliers = 12, maxSkewDeg = 12, maxScaleRatio = 6, maxDisplacementRatio = 0.12, ink, interpolation = 'bilinear', background = [
2240
+ 255,
2241
+ 255,
2242
+ 255,
2243
+ 255
2244
+ ], output = 'png', quality = 92, seed = 0x5CA7F1 } = options;
2245
+ const originalRaster = decodeImage(original);
2246
+ const scannedRaster = decodeImage(scanned);
2247
+ const originalInk = inkMap(toGrayscale(originalRaster), ink);
2248
+ const scannedInk = inkMap(toGrayscale(scannedRaster), ink);
2249
+ const coarse = estimateCoarse(originalInk, scannedInk, {
2250
+ workingSize: coarseSize,
2251
+ maxSkewDeg,
2252
+ maxScaleRatio
2253
+ });
2254
+ const refined = refineWithFeatures(originalInk, scannedInk, coarse, {
2255
+ model,
2256
+ workingSize,
2257
+ maxFeatures,
2258
+ ransacThreshold,
2259
+ minInliers,
2260
+ maxDisplacementRatio,
2261
+ seed
2262
+ });
2263
+ const matrix = refined.matrix;
2264
+ const raster = warpRaster(scannedRaster, matrix, originalRaster.width, originalRaster.height, {
2265
+ background,
2266
+ interpolation,
2267
+ prefilter: true
2268
+ });
2269
+ const agreement = measure(originalInk, scannedInk, matrix, workingSize);
2270
+ return {
2271
+ raster,
2272
+ image: output === 'none' ? null : encodeImage(raster, {
2273
+ format: output,
2274
+ quality
2275
+ }),
2276
+ width: raster.width,
2277
+ height: raster.height,
2278
+ matrix,
2279
+ inverse: invert(matrix),
2280
+ transform: decompose(matrix, model),
2281
+ confidence: Math.max(0, Math.min(1, agreement.correlation)),
2282
+ method: refined.method,
2283
+ diagnostics: {
2284
+ coarseScore: coarse.score,
2285
+ coarseStrategy: coarse.strategy,
2286
+ skewDeg: {
2287
+ original: coarse.skew.original * 180 / Math.PI,
2288
+ scanned: coarse.skew.scanned * 180 / Math.PI
2289
+ },
2290
+ features: refined.features,
2291
+ matches: refined.matches,
2292
+ inliers: refined.inliers,
2293
+ inlierRatio: refined.inlierRatio,
2294
+ reprojectionError: refined.reprojectionError,
2295
+ correlation: agreement.correlation,
2296
+ intersectionOverUnion: agreement.iou,
2297
+ durationMs: Date.now() - startedAt
2298
+ }
2299
+ };
2300
+ }
2301
+ /**
2302
+ * Match features between the original and the *coarsely corrected* scan.
2303
+ *
2304
+ * Doing it after the coarse warp rather than before is what makes the whole
2305
+ * thing work. The two images now sit at the same scale and nearly the same
2306
+ * angle, so a fixed-offset binary descriptor describes the same thing on both,
2307
+ * and a correspondence that jumps across the page can be rejected on sight.
2308
+ * What RANSAC recovers is only the small residual, which is then composed onto
2309
+ * the coarse transform.
2310
+ */ function refineWithFeatures(originalInk, scannedInk, coarse, options) {
2311
+ const fallback = {
2312
+ matrix: coarse.matrix,
2313
+ method: 'coarse',
2314
+ features: {
2315
+ original: 0,
2316
+ scanned: 0
2317
+ },
2318
+ matches: 0,
2319
+ inliers: 0,
2320
+ inlierRatio: 0,
2321
+ reprojectionError: NaN
2322
+ };
2323
+ const original = downscaleGray(originalInk, options.workingSize);
2324
+ const scanned = downscaleGray(scannedInk, options.workingSize);
2325
+ // The coarse matrix speaks full-resolution pixels; restate it between the two
2326
+ // working frames, which were shrunk by different amounts.
2327
+ const coarseWork = rebase(coarse.matrix, 1 / original.scale, 1 / scanned.scale);
2328
+ const rough = warpGray(scanned.image, coarseWork, original.image.width, original.image.height, 0);
2329
+ const originalFeatures = detectAndDescribe(original.image, {
2330
+ maxFeatures: options.maxFeatures,
2331
+ seed: options.seed
2332
+ });
2333
+ const scannedFeatures = detectAndDescribe(rough, {
2334
+ maxFeatures: options.maxFeatures,
2335
+ seed: options.seed
2336
+ });
2337
+ const counts = {
2338
+ original: originalFeatures.keypoints.length,
2339
+ scanned: scannedFeatures.keypoints.length
2340
+ };
2341
+ const diagonal = Math.hypot(original.image.width, original.image.height);
2342
+ const matches = matchFeatures(originalFeatures, scannedFeatures, {
2343
+ maxDisplacement: diagonal * options.maxDisplacementRatio
2344
+ });
2345
+ const consensus = ransac(matches, {
2346
+ model: options.model,
2347
+ threshold: options.ransacThreshold,
2348
+ minInliers: options.minInliers,
2349
+ seed: options.seed
2350
+ });
2351
+ if (consensus === null) return _extends({}, fallback, {
2352
+ features: counts,
2353
+ matches: matches.length
2354
+ });
2355
+ // RANSAC's matrix maps the original's working frame onto the rough warp,
2356
+ // which lives in that same frame. Scale it back up, then compose: original ->
2357
+ // rough -> scan.
2358
+ const residual = conjugateScale(consensus.matrix, original.scale);
2359
+ return {
2360
+ matrix: multiply(coarse.matrix, residual),
2361
+ method: 'features',
2362
+ features: counts,
2363
+ matches: matches.length,
2364
+ inliers: consensus.inliers.length,
2365
+ inlierRatio: consensus.inlierRatio,
2366
+ reprojectionError: consensus.error
2367
+ };
2368
+ }
2369
+ /** Ink correlation and mask overlap after warping, computed at a modest resolution. */ function measure(originalInk, scannedInk, matrix, workingSize) {
2370
+ const original = downscaleGray(originalInk, Math.min(workingSize, 800));
2371
+ const scanned = downscaleGray(scannedInk, Math.min(workingSize, 800));
2372
+ const work = rebase(matrix, 1 / original.scale, 1 / scanned.scale);
2373
+ const warped = warpGray(scanned.image, work, original.image.width, original.image.height, 0);
2374
+ return {
2375
+ correlation: correlation(original.image, warped),
2376
+ iou: intersectionOverUnion(binarize(original.image), binarize(warped))
2377
+ };
2378
+ }
2379
+ /**
2380
+ * Nudge an existing transform by whatever residual translation is still measurable.
2381
+ *
2382
+ * Exposed because it is occasionally useful on its own: if you already know the
2383
+ * transform from a previous page of the same batch, this re-seats it on the
2384
+ * current page for a fraction of the cost of a full alignment.
2385
+ */ function polishTranslation(originalInk, scannedInk, matrix, workingSize = 512) {
2386
+ const original = downscaleGray(originalInk, workingSize);
2387
+ const scanned = downscaleGray(scannedInk, workingSize);
2388
+ const work = rebase(matrix, 1 / original.scale, 1 / scanned.scale);
2389
+ const warped = warpGray(scanned.image, work, original.image.width, original.image.height, 0);
2390
+ const shift = phaseCorrelate(original.image, warped);
2391
+ if (!Number.isFinite(shift.dx) || !Number.isFinite(shift.dy)) return matrix;
2392
+ const corrected = multiply(work, translation(shift.dx, shift.dy));
2393
+ const candidate = rebase(corrected, original.scale, scanned.scale);
2394
+ const before = correlation(original.image, warped);
2395
+ const after = correlation(original.image, warpGray(scanned.image, corrected, original.image.width, original.image.height, 0));
2396
+ return after > before ? candidate : matrix;
2397
+ }
2398
+
2399
+ /**
2400
+ * Compare an aligned scan against its original over a set of known rectangles.
2401
+ *
2402
+ * `aligned` must be the output of `alignScan` - or anything else already on the
2403
+ * original's canvas. Feeding a raw scan in produces confident nonsense, because
2404
+ * every rectangle then names a different part of the page in each image.
2405
+ */ function compareRegions(original, aligned, regions, options = {}) {
2406
+ const { tolerance = 2, threshold = 0.02, ink } = options;
2407
+ const masks = buildMasks(original, aligned, ink, tolerance);
2408
+ return regions.map((region)=>report(region, masks, threshold));
2409
+ }
2410
+ /** Page-wide added/removed ink, plus per-region detail for any regions supplied. */ function diffDocument(original, aligned, regions = [], options = {}) {
2411
+ const { tolerance = 2, threshold = 0.02, ink } = options;
2412
+ const masks = buildMasks(original, aligned, ink, tolerance);
2413
+ const full = {
2414
+ x: 0,
2415
+ y: 0,
2416
+ width: masks.width,
2417
+ height: masks.height
2418
+ };
2419
+ const whole = report({
2420
+ id: '__document__',
2421
+ rect: full
2422
+ }, masks, threshold);
2423
+ return {
2424
+ added: whole.added,
2425
+ removed: whole.removed,
2426
+ regions: regions.map((region)=>report(region, masks, threshold))
2427
+ };
2428
+ }
2429
+ /**
2430
+ * An RGBA overlay of the comparison, for looking at with your own eyes.
2431
+ *
2432
+ * Red is ink the scan added, blue is ink it lost, grey is ink both agree on.
2433
+ * A correctly aligned pair of a signed form is almost entirely grey with a red
2434
+ * signature; a misaligned one is red and blue confetti along every stroke,
2435
+ * which is the fastest way to tell the two failures apart.
2436
+ */ function renderDiff(original, aligned, options = {}) {
2437
+ const { tolerance = 2, ink } = options;
2438
+ const masks = buildMasks(original, aligned, ink, tolerance);
2439
+ const { width, height } = masks;
2440
+ const data = new Uint8ClampedArray(width * height * 4);
2441
+ for(let i = 0, p = 0; p < width * height; p++, i += 4){
2442
+ const inOriginal = masks.original.data[p] === 1;
2443
+ const inScan = masks.scan.data[p] === 1;
2444
+ const nearOriginal = masks.originalDilated.data[p] === 1;
2445
+ let r = 255;
2446
+ let g = 255;
2447
+ let b = 255;
2448
+ if (inScan && !nearOriginal) {
2449
+ r = 220;
2450
+ g = 30;
2451
+ b = 40;
2452
+ } else if (inOriginal && masks.scanDilated.data[p] === 0) {
2453
+ r = 40;
2454
+ g = 90;
2455
+ b = 220;
2456
+ } else if (inOriginal || inScan) {
2457
+ r = 110;
2458
+ g = 110;
2459
+ b = 110;
2460
+ }
2461
+ data[i] = r;
2462
+ data[i + 1] = g;
2463
+ data[i + 2] = b;
2464
+ data[i + 3] = 255;
2465
+ }
2466
+ return {
2467
+ width,
2468
+ height,
2469
+ data
2470
+ };
2471
+ }
2472
+ function buildMasks(original, aligned, ink, tolerance) {
2473
+ const originalRaster = decodeImage(original);
2474
+ const alignedRaster = decodeImage(aligned);
2475
+ if (originalRaster.width !== alignedRaster.width || originalRaster.height !== alignedRaster.height) throw new Error(`compareRegions needs both images on the same canvas: got ${originalRaster.width}x${originalRaster.height} and ${alignedRaster.width}x${alignedRaster.height}. Align the scan first.`);
2476
+ const originalMask = binarize(inkMap(toGrayscale(originalRaster), ink));
2477
+ const scanMask = binarize(inkMap(toGrayscale(alignedRaster), ink));
2478
+ return {
2479
+ width: originalRaster.width,
2480
+ height: originalRaster.height,
2481
+ original: originalMask,
2482
+ scan: scanMask,
2483
+ originalDilated: dilate(originalMask, tolerance),
2484
+ scanDilated: dilate(scanMask, tolerance)
2485
+ };
2486
+ }
2487
+ function report(region, masks, defaultThreshold) {
2488
+ var _region_threshold;
2489
+ const { x, y, width, height } = region.rect;
2490
+ const left = Math.max(0, Math.floor(x));
2491
+ const top = Math.max(0, Math.floor(y));
2492
+ const right = Math.min(masks.width, Math.ceil(x + width));
2493
+ const bottom = Math.min(masks.height, Math.ceil(y + height));
2494
+ if (right <= left || bottom <= top) return {
2495
+ id: region.id,
2496
+ rect: region.rect,
2497
+ originalInk: 0,
2498
+ scanInk: 0,
2499
+ added: 0,
2500
+ removed: 0,
2501
+ filled: false,
2502
+ score: 0
2503
+ };
2504
+ const threshold = (_region_threshold = region.threshold) != null ? _region_threshold : defaultThreshold;
2505
+ let added = 0;
2506
+ let removed = 0;
2507
+ for(let row = top; row < bottom; row++){
2508
+ const offset = row * masks.width;
2509
+ for(let column = left; column < right; column++){
2510
+ const p = offset + column;
2511
+ if (masks.scan.data[p] === 1 && masks.originalDilated.data[p] === 0) added++;
2512
+ if (masks.original.data[p] === 1 && masks.scanDilated.data[p] === 0) removed++;
2513
+ }
2514
+ }
2515
+ const area = (right - left) * (bottom - top);
2516
+ const addedRatio = added / area;
2517
+ return {
2518
+ id: region.id,
2519
+ rect: region.rect,
2520
+ originalInk: coverage(masks.original, left, top, right, bottom),
2521
+ scanInk: coverage(masks.scan, left, top, right, bottom),
2522
+ added: addedRatio,
2523
+ removed: removed / area,
2524
+ filled: addedRatio >= threshold,
2525
+ score: threshold > 0 ? Math.min(1, addedRatio / threshold) : 0
2526
+ };
2527
+ }
2528
+
2529
+ /**
2530
+ * A plausible printed form: header rule, paragraphs, a table, tick boxes, a
2531
+ * signature box. Deterministic for a given seed.
2532
+ */ function createSyntheticDocument(options = {}) {
2533
+ var _options_signatureBox;
2534
+ const { width = 850, height = 1100, seed = 42 } = options;
2535
+ const random = createRandom(seed);
2536
+ const page = {
2537
+ width,
2538
+ height,
2539
+ data: new Uint8ClampedArray(width * height * 4).fill(255)
2540
+ };
2541
+ // The layout is written once against a nominal 850x1100 page and scaled to
2542
+ // whatever was asked for. Laying it out in absolute pixels instead means a
2543
+ // smaller page silently loses its last few elements off the bottom edge -
2544
+ // including, on a form, the signature box.
2545
+ const scale = Math.min(width / 850, height / 1100);
2546
+ const unit = (value)=>value * scale;
2547
+ const margin = Math.round(unit(76));
2548
+ const right = width - margin;
2549
+ const lineHeight = Math.max(4, unit(21));
2550
+ const textHeight = Math.max(2, Math.round(unit(9)));
2551
+ let y = margin;
2552
+ fillRect(page, {
2553
+ x: margin,
2554
+ y,
2555
+ width: Math.round((right - margin) * 0.44),
2556
+ height: Math.max(3, unit(26))
2557
+ }, 20);
2558
+ y += unit(54);
2559
+ drawLine(page, margin, y, right, y, Math.max(1, unit(3)), 40);
2560
+ y += unit(34);
2561
+ // Fixed line counts rather than random ones, so the page always fits.
2562
+ for (const lines of [
2563
+ 3,
2564
+ 4,
2565
+ 5,
2566
+ 6
2567
+ ]){
2568
+ for(let line = 0; line < lines; line++){
2569
+ drawTextLine(page, margin, y, right, textHeight, random);
2570
+ y += lineHeight;
2571
+ }
2572
+ y += unit(18);
2573
+ }
2574
+ const tableTop = y;
2575
+ const rows = 5;
2576
+ const columns = 4;
2577
+ const rowHeight = Math.max(6, unit(30));
2578
+ const columnWidth = (right - margin) / columns;
2579
+ const ruleWidth = Math.max(1, unit(2));
2580
+ for(let r = 0; r <= rows; r++)drawLine(page, margin, tableTop + r * rowHeight, right, tableTop + r * rowHeight, ruleWidth, 60);
2581
+ for(let c = 0; c <= columns; c++)drawLine(page, margin + c * columnWidth, tableTop, margin + c * columnWidth, tableTop + rows * rowHeight, ruleWidth, 60);
2582
+ for(let r = 0; r < rows; r++)for(let c = 0; c < columns; c++)drawTextLine(page, margin + c * columnWidth + unit(8), tableTop + r * rowHeight + rowHeight * 0.36, margin + (c + 1) * columnWidth - unit(8), textHeight, random);
2583
+ y = tableTop + rows * rowHeight + unit(46);
2584
+ const regions = {};
2585
+ const boxSize = Math.max(6, Math.round(unit(18)));
2586
+ for(let i = 0; i < 3; i++){
2587
+ const box = {
2588
+ x: margin + i * unit(150),
2589
+ y,
2590
+ width: boxSize,
2591
+ height: boxSize
2592
+ };
2593
+ strokeRect(page, box, Math.max(1, unit(2)), 30);
2594
+ regions[`tick-${i + 1}`] = box;
2595
+ }
2596
+ y += unit(70);
2597
+ const signature = (_options_signatureBox = options.signatureBox) != null ? _options_signatureBox : {
2598
+ x: margin,
2599
+ y,
2600
+ width: Math.round((right - margin) * 0.55),
2601
+ height: Math.max(12, Math.round(unit(78)))
2602
+ };
2603
+ strokeRect(page, signature, Math.max(1, unit(2)), 30);
2604
+ regions.signature = signature;
2605
+ regions.stamp = {
2606
+ x: right - Math.round(unit(150)),
2607
+ y,
2608
+ width: Math.round(unit(150)),
2609
+ height: signature.height
2610
+ };
2611
+ return {
2612
+ raster: page,
2613
+ regions
2614
+ };
2615
+ }
2616
+ /** Scribble inside a rectangle, the way a signature crosses a signature box. */ function drawSignature(page, box, seed = 7) {
2617
+ const random = createRandom(seed);
2618
+ const points = 9;
2619
+ const baseline = box.y + box.height * 0.62;
2620
+ let previousX = box.x + box.width * 0.06;
2621
+ let previousY = baseline;
2622
+ for(let i = 1; i <= points; i++){
2623
+ const x = box.x + box.width * (0.06 + 0.86 * i / points);
2624
+ const y = baseline - box.height * (0.05 + random() * 0.42) * (i % 2 === 0 ? 1 : -0.45);
2625
+ drawLine(page, previousX, previousY, x, y, 3, 25);
2626
+ previousX = x;
2627
+ previousY = y;
2628
+ }
2629
+ }
2630
+ /** Fill a tick box, the way a pen does. */ function drawTick(page, box) {
2631
+ const { x, y, width, height } = box;
2632
+ drawLine(page, x + width * 0.15, y + height * 0.5, x + width * 0.42, y + height * 0.82, 3, 20);
2633
+ drawLine(page, x + width * 0.42, y + height * 0.82, x + width * 0.88, y + height * 0.12, 3, 20);
2634
+ }
2635
+ /**
2636
+ * Put a page through everything a scanner does to it, and report the matrix used.
2637
+ *
2638
+ * Order matters and mirrors the physical one: the page is placed on the glass
2639
+ * somewhere, at some angle, and sampled at some resolution (the geometry);
2640
+ * then the lamp falls off towards one corner, the optics blur, and the sensor
2641
+ * adds noise (the photometry). Estimators that only ever see clean geometric
2642
+ * distortion pass tests and fail on real scans.
2643
+ */ function simulateScan(page, options = {}) {
2644
+ var _options_canvas;
2645
+ const { rotationDeg = 0, scale = 1, translateX = 0, translateY = 0, noise = 0, blur = 0, illumination = 0, seed = 1234 } = options;
2646
+ const canvas = (_options_canvas = options.canvas) != null ? _options_canvas : {
2647
+ width: Math.max(8, Math.round(page.width * scale)),
2648
+ height: Math.max(8, Math.round(page.height * scale))
2649
+ };
2650
+ const angle = rotationDeg * Math.PI / 180;
2651
+ const cos = Math.cos(angle);
2652
+ const sin = Math.sin(angle);
2653
+ const cx = page.width / 2;
2654
+ const cy = page.height / 2;
2655
+ // Rotate about the page centre, scale, then place that centre in the middle
2656
+ // of the scan canvas plus the requested offset.
2657
+ const rotateAboutCentre = [
2658
+ cos,
2659
+ -sin,
2660
+ cx - cos * cx + sin * cy,
2661
+ sin,
2662
+ cos,
2663
+ cy - sin * cx - cos * cy,
2664
+ 0,
2665
+ 0,
2666
+ 1
2667
+ ];
2668
+ const place = translation(canvas.width / 2 - cx * scale + translateX, canvas.height / 2 - cy * scale + translateY);
2669
+ const forward = multiply(place, multiply(scaling(scale), rotateAboutCentre));
2670
+ let raster = warpRaster(page, invert(forward), canvas.width, canvas.height, {
2671
+ background: [
2672
+ 255,
2673
+ 255,
2674
+ 255,
2675
+ 255
2676
+ ],
2677
+ interpolation: 'bilinear',
2678
+ prefilter: true
2679
+ });
2680
+ if (blur > 0) raster = boxBlurRaster(raster, blur);
2681
+ if (illumination > 0) applyIllumination(raster, illumination);
2682
+ if (noise > 0) applyNoise(raster, noise, seed);
2683
+ return {
2684
+ raster,
2685
+ matrix: forward
2686
+ };
2687
+ }
2688
+ /** A diagonal ramp plus a soft corner shadow: the two things a phone camera always adds. */ function applyIllumination(raster, strength) {
2689
+ const { width, height, data } = raster;
2690
+ for(let y = 0; y < height; y++){
2691
+ for(let x = 0; x < width; x++){
2692
+ const u = x / width;
2693
+ const v = y / height;
2694
+ const ramp = 1 - strength * (0.35 * u + 0.65 * v);
2695
+ const corner = 1 - strength * 0.8 * Math.max(0, 1 - Math.hypot(u, v) * 1.3);
2696
+ const factor = ramp * corner;
2697
+ const i = (y * width + x) * 4;
2698
+ data[i] *= factor;
2699
+ data[i + 1] *= factor;
2700
+ data[i + 2] *= factor;
2701
+ }
2702
+ }
2703
+ }
2704
+ function applyNoise(raster, sigma, seed) {
2705
+ const random = createRandom(seed);
2706
+ const amplitude = sigma * 255;
2707
+ for(let i = 0; i < raster.data.length; i += 4){
2708
+ const n = (random() + random() + random() - 1.5) * 2 * amplitude;
2709
+ raster.data[i] += n;
2710
+ raster.data[i + 1] += n;
2711
+ raster.data[i + 2] += n;
2712
+ }
2713
+ }
2714
+ /** One line of "text": dark blocks of word-ish widths with gaps between them. */ function drawTextLine(page, x0, y, x1, height, random) {
2715
+ const unit = height / 9;
2716
+ let x = x0;
2717
+ while(x < x1 - 12 * unit){
2718
+ const word = (14 + Math.floor(random() * 46)) * unit;
2719
+ const end = Math.min(x1, x + word);
2720
+ fillRect(page, {
2721
+ x,
2722
+ y,
2723
+ width: end - x,
2724
+ height
2725
+ }, 35 + Math.floor(random() * 40));
2726
+ x = end + (6 + Math.floor(random() * 6)) * unit;
2727
+ }
2728
+ }
2729
+ function fillRect(page, rect, value) {
2730
+ const left = Math.max(0, Math.round(rect.x));
2731
+ const top = Math.max(0, Math.round(rect.y));
2732
+ const right = Math.min(page.width, Math.round(rect.x + rect.width));
2733
+ const bottom = Math.min(page.height, Math.round(rect.y + rect.height));
2734
+ for(let y = top; y < bottom; y++){
2735
+ let i = (y * page.width + left) * 4;
2736
+ for(let x = left; x < right; x++, i += 4){
2737
+ page.data[i] = value;
2738
+ page.data[i + 1] = value;
2739
+ page.data[i + 2] = value;
2740
+ page.data[i + 3] = 255;
2741
+ }
2742
+ }
2743
+ }
2744
+ function strokeRect(page, rect, thickness, value) {
2745
+ const { x, y, width, height } = rect;
2746
+ fillRect(page, {
2747
+ x,
2748
+ y,
2749
+ width,
2750
+ height: thickness
2751
+ }, value);
2752
+ fillRect(page, {
2753
+ x,
2754
+ y: y + height - thickness,
2755
+ width,
2756
+ height: thickness
2757
+ }, value);
2758
+ fillRect(page, {
2759
+ x,
2760
+ y,
2761
+ width: thickness,
2762
+ height
2763
+ }, value);
2764
+ fillRect(page, {
2765
+ x: x + width - thickness,
2766
+ y,
2767
+ width: thickness,
2768
+ height
2769
+ }, value);
2770
+ }
2771
+ function drawLine(page, x0, y0, x1, y1, thickness, value) {
2772
+ const steps = Math.ceil(Math.hypot(x1 - x0, y1 - y0)) + 1;
2773
+ const half = thickness / 2;
2774
+ for(let i = 0; i <= steps; i++){
2775
+ const t = i / steps;
2776
+ fillRect(page, {
2777
+ x: x0 + (x1 - x0) * t - half,
2778
+ y: y0 + (y1 - y0) * t - half,
2779
+ width: thickness,
2780
+ height: thickness
2781
+ }, value);
2782
+ }
2783
+ }
2784
+
2785
+ export { IDENTITY, alignScan, applyPoint, binarize, boxBlur, boxBlurRaster, cloneRaster, compareRegions, conjugateScale, contentExtent, correlation, coverage, createBinary, createGray, createRaster, createSyntheticDocument, decodeImage, decompose, detectAndDescribe, determinant, diffDocument, dilate, downscaleGray, drawSignature, drawTick, encodeImage, estimateCoarse, estimateSkew, findInliers, fitAffine, fitHomography, fitModel, fitSimilarity, grayToRaster, hamming, inkMap, intersectionOverUnion, invert, isPlausible, isRaster, mapRectCorners, matchFeatures, mean, minimumSamples, multiply, normalize, otsuThreshold, phaseCorrelate, polishTranslation, popcount, ransac, rebase, renderDiff, reprojectionError, resizeGray, sampleGrayBilinear, scaling, similarity, simulateScan, sniffFormat, toGrayscale, translation, warpGray, warpRaster };
2786
+ //# sourceMappingURL=index.esm.js.map