@scanmate/diff 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,783 @@
1
+ import { decodeImage, binarize, inkMap, toGrayscale, otsuThreshold, dilate, coverage, createRaster, encodeImage } from '@scanmate/ink';
2
+
3
+ const IDENTIFIED = [30, 160, 70, 255];
4
+ const NOT_IDENTIFIED = [230, 150, 20, 255];
5
+ const UNEXPECTED = [200, 30, 190, 255];
6
+ const MISSING = [20, 90, 230, 255];
7
+ function annotateOverlay(overlay, annotations, thickness = 2) {
8
+ for (const {
9
+ rect,
10
+ color
11
+ } of annotations) {
12
+ const {
13
+ x,
14
+ y,
15
+ width,
16
+ height
17
+ } = rect;
18
+ fill(overlay, {
19
+ x,
20
+ y,
21
+ width,
22
+ height: thickness
23
+ }, color);
24
+ fill(overlay, {
25
+ x,
26
+ y: y + height - thickness,
27
+ width,
28
+ height: thickness
29
+ }, color);
30
+ fill(overlay, {
31
+ x,
32
+ y,
33
+ width: thickness,
34
+ height
35
+ }, color);
36
+ fill(overlay, {
37
+ x: x + width - thickness,
38
+ y,
39
+ width: thickness,
40
+ height
41
+ }, color);
42
+ }
43
+ }
44
+ /** Fill a rectangle, clipped to the raster - an outline grown past the page edge draws what fits. */
45
+ function fill(raster, rect, color) {
46
+ const left = Math.max(0, Math.round(rect.x));
47
+ const top = Math.max(0, Math.round(rect.y));
48
+ const right = Math.min(raster.width, Math.round(rect.x + rect.width));
49
+ const bottom = Math.min(raster.height, Math.round(rect.y + rect.height));
50
+ for (let y = top; y < bottom; y++) for (let x = left, i = (y * raster.width + left) * 4; x < right; x++, i += 4) {
51
+ raster.data[i] = color[0];
52
+ raster.data[i + 1] = color[1];
53
+ raster.data[i + 2] = color[2];
54
+ raster.data[i + 3] = color[3];
55
+ }
56
+ }
57
+
58
+ function connectedComponents(mask, options = {}) {
59
+ return labelComponents(mask, options).components;
60
+ }
61
+ /** {@link connectedComponents}, keeping the label image the second pass resolves anyway. */
62
+ function labelComponents(mask, options = {}) {
63
+ const {
64
+ connectivity = 8
65
+ } = options;
66
+ const {
67
+ width,
68
+ height,
69
+ data
70
+ } = mask;
71
+ const labels = new Int32Array(width * height);
72
+ // Label 0 is background; provisional labels start at 1. At most one per
73
+ // two pixels can be provisional, which bounds the parent table.
74
+ const parent = new Int32Array(Math.floor(width * height / 2) + 2);
75
+ const size = new Int32Array(parent.length);
76
+ let next = 1;
77
+ const find = label => {
78
+ let root = label;
79
+ while (parent[root] !== root) {
80
+ parent[root] = parent[parent[root]];
81
+ root = parent[root];
82
+ }
83
+ return root;
84
+ };
85
+ const union = (a, b) => {
86
+ let ra = find(a);
87
+ let rb = find(b);
88
+ if (ra === rb) return ra;
89
+ if (size[ra] < size[rb]) [ra, rb] = [rb, ra];
90
+ parent[rb] = ra;
91
+ size[ra] += size[rb];
92
+ return ra;
93
+ };
94
+ for (let y = 0; y < height; y++) {
95
+ const row = y * width;
96
+ for (let x = 0; x < width; x++) {
97
+ const p = row + x;
98
+ if (data[p] === 0) continue;
99
+ let label = 0;
100
+ const consider = q => {
101
+ const l = labels[q];
102
+ if (l === 0) return;
103
+ label = label === 0 ? l : union(label, l);
104
+ };
105
+ if (x > 0) consider(p - 1);
106
+ if (y > 0) {
107
+ consider(p - width);
108
+ if (connectivity === 8) {
109
+ if (x > 0) consider(p - width - 1);
110
+ if (x < width - 1) consider(p - width + 1);
111
+ }
112
+ }
113
+ if (label === 0) {
114
+ if (next >= parent.length) throw new RangeError('connected-component label table overflow');
115
+ label = next++;
116
+ parent[label] = label;
117
+ size[label] = 1;
118
+ }
119
+ labels[p] = label;
120
+ }
121
+ }
122
+ // Second pass: resolve to roots and accumulate each root's box.
123
+ const index = new Int32Array(next).fill(-1);
124
+ const boxes = [];
125
+ for (let y = 0; y < height; y++) {
126
+ const row = y * width;
127
+ for (let x = 0; x < width; x++) {
128
+ const l = labels[row + x];
129
+ if (l === 0) continue;
130
+ const root = find(l);
131
+ let i = index[root];
132
+ if (i === -1) {
133
+ i = boxes.length;
134
+ index[root] = i;
135
+ boxes.push({
136
+ minX: x,
137
+ minY: y,
138
+ maxX: x,
139
+ maxY: y,
140
+ pixels: 0
141
+ });
142
+ }
143
+ labels[row + x] = i + 1;
144
+ const box = boxes[i];
145
+ if (x < box.minX) box.minX = x;
146
+ if (x > box.maxX) box.maxX = x;
147
+ if (y > box.maxY) box.maxY = y;
148
+ box.pixels++;
149
+ }
150
+ }
151
+ const components = boxes.map(b => ({
152
+ x: b.minX,
153
+ y: b.minY,
154
+ width: b.maxX - b.minX + 1,
155
+ height: b.maxY - b.minY + 1,
156
+ pixels: b.pixels
157
+ }));
158
+ return {
159
+ components,
160
+ labels
161
+ };
162
+ }
163
+
164
+ /** Default for {@link buildMasks}' `faintInk`, measured against real scans. */
165
+ const FAINT_INK = 0.25;
166
+ async function buildMasks(original, aligned, ink, tolerance, faintInk = FAINT_INK) {
167
+ const originalRaster = await decodeImage(original);
168
+ const alignedRaster = await decodeImage(aligned);
169
+ 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.`);
170
+ const originalMask = binarize(inkMap(toGrayscale(originalRaster), ink));
171
+ const scanInk = inkMap(toGrayscale(alignedRaster), ink);
172
+ const scanMask = binarize(scanInk);
173
+ const scanFaint = above(scanInk, faintInk * Math.max(otsuThreshold(scanInk), MIN_THRESHOLD));
174
+ return {
175
+ width: originalRaster.width,
176
+ height: originalRaster.height,
177
+ original: originalMask,
178
+ scan: scanMask,
179
+ scanFaint,
180
+ originalDilated: dilate(originalMask, tolerance),
181
+ scanDilated: dilate(scanFaint, tolerance)
182
+ };
183
+ }
184
+ /** `binarize`'s own floor on the Otsu threshold, so the faint bar scales from the same number. */
185
+ const MIN_THRESHOLD = 0.12;
186
+ /**
187
+ * Threshold without `binarize`'s floor: the faint bar is meant to sit below it.
188
+ * `inkMap` has already zeroed paper noise, so nothing here reads blank paper as ink.
189
+ */
190
+ function above(image, cut) {
191
+ const data = new Uint8Array(image.data.length);
192
+ for (let p = 0; p < data.length; p++) data[p] = image.data[p] > cut ? 1 : 0;
193
+ return {
194
+ width: image.width,
195
+ height: image.height,
196
+ data
197
+ };
198
+ }
199
+
200
+ /**
201
+ * What changed, and where.
202
+ *
203
+ * Once the scan sits on the original's canvas, "was this box signed?" stops
204
+ * being an image problem and becomes arithmetic: count the ink inside the
205
+ * rectangle that is present in the scan and absent from the original.
206
+ *
207
+ * The one subtlety is the tolerance band. Alignment is good to a pixel or so,
208
+ * never to zero, and printed text is mostly edges — so a half-pixel shift
209
+ * lights up the outline of every character as "new ink". Dilating the
210
+ * original's mask first (fattening every stroke by a couple of pixels) absorbs
211
+ * that, the way a proofreader ignores a letter sitting a hair off the baseline.
212
+ * What it cannot absorb is a signature, which is ink in places the original has
213
+ * none.
214
+ */
215
+ /**
216
+ * Compare an aligned scan against its original over a set of known rectangles.
217
+ *
218
+ * `aligned` must be the output of `alignScan` - or anything else already on the
219
+ * original's canvas. Feeding a raw scan in produces confident nonsense, because
220
+ * every rectangle then names a different part of the page in each image.
221
+ */
222
+ async function compareRegions(original, aligned, regions, options = {}) {
223
+ const {
224
+ tolerance = 2,
225
+ threshold = 0.02,
226
+ ink,
227
+ faintInk
228
+ } = options;
229
+ const masks = await buildMasks(original, aligned, ink, tolerance, faintInk);
230
+ return regions.map(region => measureRegion(region, masks, threshold));
231
+ }
232
+ /** Page-wide added/removed ink, plus per-region detail for any regions supplied. */
233
+ async function diffDocument(original, aligned, regions = [], options = {}) {
234
+ const {
235
+ tolerance = 2,
236
+ threshold = 0.02,
237
+ ink,
238
+ faintInk
239
+ } = options;
240
+ const masks = await buildMasks(original, aligned, ink, tolerance, faintInk);
241
+ const full = {
242
+ x: 0,
243
+ y: 0,
244
+ width: masks.width,
245
+ height: masks.height
246
+ };
247
+ const whole = measureRegion({
248
+ id: '__document__',
249
+ rect: full
250
+ }, masks, threshold);
251
+ return {
252
+ added: whole.added,
253
+ removed: whole.removed,
254
+ regions: regions.map(region => measureRegion(region, masks, threshold))
255
+ };
256
+ }
257
+ /** One region's added and removed ink, from masks already built. */
258
+ function measureRegion(region, masks, defaultThreshold) {
259
+ const {
260
+ x,
261
+ y,
262
+ width,
263
+ height
264
+ } = region.rect;
265
+ const left = Math.max(0, Math.floor(x));
266
+ const top = Math.max(0, Math.floor(y));
267
+ const right = Math.min(masks.width, Math.ceil(x + width));
268
+ const bottom = Math.min(masks.height, Math.ceil(y + height));
269
+ if (right <= left || bottom <= top) return {
270
+ id: region.id,
271
+ rect: region.rect,
272
+ originalInk: 0,
273
+ scanInk: 0,
274
+ added: 0,
275
+ removed: 0,
276
+ filled: false,
277
+ score: 0
278
+ };
279
+ const threshold = region.threshold ?? defaultThreshold;
280
+ let added = 0;
281
+ let removed = 0;
282
+ for (let row = top; row < bottom; row++) {
283
+ const offset = row * masks.width;
284
+ for (let column = left; column < right; column++) {
285
+ const p = offset + column;
286
+ if (masks.scan.data[p] === 1 && masks.originalDilated.data[p] === 0) added++;
287
+ if (masks.original.data[p] === 1 && masks.scanDilated.data[p] === 0) removed++;
288
+ }
289
+ }
290
+ const area = (right - left) * (bottom - top);
291
+ const addedRatio = added / area;
292
+ return {
293
+ id: region.id,
294
+ rect: region.rect,
295
+ originalInk: coverage(masks.original, left, top, right, bottom),
296
+ scanInk: coverage(masks.scan, left, top, right, bottom),
297
+ added: addedRatio,
298
+ removed: removed / area,
299
+ filled: addedRatio >= threshold,
300
+ score: threshold > 0 ? Math.min(1, addedRatio / threshold) : 0
301
+ };
302
+ }
303
+
304
+ /**
305
+ * An RGBA overlay of the comparison, for looking at with your own eyes.
306
+ *
307
+ * Red is ink the scan added, blue is ink it lost, grey is ink both agree on.
308
+ * A correctly aligned pair of a signed form is almost entirely grey with a red
309
+ * signature; a misaligned one is red and blue confetti along every stroke,
310
+ * which is the fastest way to tell the two failures apart.
311
+ */
312
+ async function renderDiff(original, aligned, options = {}) {
313
+ const {
314
+ tolerance = 2,
315
+ ink,
316
+ faintInk
317
+ } = options;
318
+ const masks = await buildMasks(original, aligned, ink, tolerance, faintInk);
319
+ return paintOverlay(masks);
320
+ }
321
+ /** The overlay, from masks already built. Red added, blue lost, grey agreed, white paper. */
322
+ function paintOverlay(masks) {
323
+ const {
324
+ width,
325
+ height
326
+ } = masks;
327
+ const data = new Uint8ClampedArray(width * height * 4);
328
+ for (let i = 0, p = 0; p < width * height; p++, i += 4) {
329
+ const inOriginal = masks.original.data[p] === 1;
330
+ const inScan = masks.scan.data[p] === 1;
331
+ const nearOriginal = masks.originalDilated.data[p] === 1;
332
+ let r = 255;
333
+ let g = 255;
334
+ let b = 255;
335
+ if (inScan && !nearOriginal) {
336
+ r = 220;
337
+ g = 30;
338
+ b = 40;
339
+ } else if (inOriginal && masks.scanDilated.data[p] === 0) {
340
+ r = 40;
341
+ g = 90;
342
+ b = 220;
343
+ } else if (inOriginal || inScan) {
344
+ r = 110;
345
+ g = 110;
346
+ b = 110;
347
+ }
348
+ data[i] = r;
349
+ data[i + 1] = g;
350
+ data[i + 2] = b;
351
+ data[i + 3] = 255;
352
+ }
353
+ return {
354
+ width,
355
+ height,
356
+ data
357
+ };
358
+ }
359
+
360
+ function mergeBoxes(components, gap) {
361
+ let boxes = components.map(c => ({
362
+ ...c,
363
+ components: 1
364
+ }));
365
+ if (boxes.length < 2) return boxes;
366
+ // Each pass unions everything that touches; a merged box can newly reach
367
+ // another, so repeat until a pass merges nothing.
368
+ for (;;) {
369
+ const merged = mergeOnce(boxes, gap);
370
+ if (merged.length === boxes.length) return merged;
371
+ boxes = merged;
372
+ }
373
+ }
374
+ function mergeOnce(boxes, gap) {
375
+ const parent = boxes.map((_, i) => i);
376
+ const find = i => {
377
+ while (parent[i] !== i) {
378
+ parent[i] = parent[parent[i]];
379
+ i = parent[i];
380
+ }
381
+ return i;
382
+ };
383
+ const cell = Math.max(16, gap * 2);
384
+ const grid = new Map();
385
+ for (const [i, box] of boxes.entries()) {
386
+ for (const key of cellsOf(box, gap, cell)) {
387
+ const bucket = grid.get(key);
388
+ if (bucket === undefined) grid.set(key, [i]);else bucket.push(i);
389
+ }
390
+ }
391
+ for (const bucket of grid.values()) for (let a = 0; a < bucket.length; a++) for (let b = a + 1; b < bucket.length; b++) {
392
+ const i = bucket[a];
393
+ const j = bucket[b];
394
+ if (find(i) !== find(j) && near(boxes[i], boxes[j], gap)) parent[find(j)] = find(i);
395
+ }
396
+ const groups = new Map();
397
+ for (const [i, box] of boxes.entries()) {
398
+ const root = find(i);
399
+ const group = groups.get(root);
400
+ if (group === undefined) {
401
+ groups.set(root, {
402
+ ...box
403
+ });
404
+ continue;
405
+ }
406
+ const right = Math.max(group.x + group.width, box.x + box.width);
407
+ const bottom = Math.max(group.y + group.height, box.y + box.height);
408
+ group.x = Math.min(group.x, box.x);
409
+ group.y = Math.min(group.y, box.y);
410
+ group.width = right - group.x;
411
+ group.height = bottom - group.y;
412
+ group.pixels += box.pixels;
413
+ group.components += box.components;
414
+ }
415
+ return [...groups.values()];
416
+ }
417
+ /** Do two boxes come within `gap` pixels of each other? Touching or overlapping counts. */
418
+ function near(a, b, gap) {
419
+ return a.x - gap <= b.x + b.width && b.x - gap <= a.x + a.width && a.y - gap <= b.y + b.height && b.y - gap <= a.y + a.height;
420
+ }
421
+ /** Grid cells covered by a box inflated by the gap. */
422
+ function cellsOf(box, gap, cell) {
423
+ const x0 = Math.floor((box.x - gap) / cell);
424
+ const y0 = Math.floor((box.y - gap) / cell);
425
+ const x1 = Math.floor((box.x + box.width + gap) / cell);
426
+ const y1 = Math.floor((box.y + box.height + gap) / cell);
427
+ const keys = [];
428
+ for (let cy = y0; cy <= y1; cy++) for (let cx = x0; cx <= x1; cx++) keys.push(`${cx},${cy}`);
429
+ return keys;
430
+ }
431
+
432
+ const NONE = {
433
+ pixels: 0,
434
+ changes: 0,
435
+ largest: 0,
436
+ bounds: null,
437
+ edgeTouch: 0,
438
+ fill: 0
439
+ };
440
+ /** Analyse `added` (the page's new-ink mask) inside `rect` (page pixels). */
441
+ function measureRegionInk(added, rect, options) {
442
+ const left = Math.max(0, Math.floor(rect.x));
443
+ const top = Math.max(0, Math.floor(rect.y));
444
+ const right = Math.min(added.width, Math.ceil(rect.x + rect.width));
445
+ const bottom = Math.min(added.height, Math.ceil(rect.y + rect.height));
446
+ const width = right - left;
447
+ const height = bottom - top;
448
+ if (width <= 0 || height <= 0) return {
449
+ ...NONE,
450
+ formLines: 0
451
+ };
452
+ const crop = new Uint8Array(width * height);
453
+ for (let y = 0; y < height; y++) crop.set(added.data.subarray((top + y) * added.width + left, (top + y) * added.width + right), y * width);
454
+ const {
455
+ components,
456
+ labels
457
+ } = labelComponents({
458
+ width,
459
+ height,
460
+ data: crop
461
+ });
462
+ const isRule = c => {
463
+ const horizontal = c.width >= options.lineSpan * width && c.height <= Math.max(options.lineThickness, options.lineThicknessRatio * height);
464
+ const vertical = c.height >= options.lineSpan * height && c.width <= Math.max(options.lineThickness, options.lineThicknessRatio * width);
465
+ return horizontal || vertical;
466
+ };
467
+ const pieces = components.filter(c => c.pixels >= 2);
468
+ const rules = pieces.filter(c => isRule(c));
469
+ const kept = pieces.filter(c => !isRule(c));
470
+ const changes = mergeBoxes(kept, options.mergeGap).filter(box => box.pixels >= options.minChangePixels);
471
+ if (changes.length === 0) return {
472
+ ...NONE,
473
+ formLines: rules.length
474
+ };
475
+ // A kept piece belongs to the change whose box holds it: a change's box is the
476
+ // union of its pieces, and any piece inside it would have been merged into it.
477
+ const counts = new Uint8Array(components.length);
478
+ for (const [i, c] of components.entries()) {
479
+ if (c.pixels < 2 || isRule(c)) continue;
480
+ counts[i] = changes.some(box => c.x >= box.x && c.y >= box.y && c.x + c.width <= box.x + box.width && c.y + c.height <= box.y + box.height) ? 1 : 0;
481
+ }
482
+ const band = Math.max(1, Math.round(Math.min(width, height) * options.edgeBand));
483
+ let pixels = 0;
484
+ let edge = 0;
485
+ for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) {
486
+ const label = labels[y * width + x];
487
+ if (label === 0 || counts[label - 1] === 0) continue;
488
+ pixels++;
489
+ if (x < band || y < band || x >= width - band || y >= height - band) edge++;
490
+ }
491
+ const minX = Math.min(...changes.map(b => b.x));
492
+ const minY = Math.min(...changes.map(b => b.y));
493
+ const maxX = Math.max(...changes.map(b => b.x + b.width));
494
+ const maxY = Math.max(...changes.map(b => b.y + b.height));
495
+ return {
496
+ pixels,
497
+ changes: changes.length,
498
+ largest: Math.max(...changes.map(b => b.pixels)),
499
+ bounds: {
500
+ x: left + minX,
501
+ y: top + minY,
502
+ width: maxX - minX,
503
+ height: maxY - minY
504
+ },
505
+ edgeTouch: pixels > 0 ? edge / pixels : 0,
506
+ fill: pixels / (width * height),
507
+ formLines: rules.length
508
+ };
509
+ }
510
+
511
+ /**
512
+ * The original and the aligned scan next to each other, with the same boxes on
513
+ * both: what was expected, what changed, what went missing.
514
+ *
515
+ * The overlay answers "which pixels changed"; this answers "show me", for the
516
+ * person who has to agree with the verdict. Because the scan is aligned onto
517
+ * the original's canvas, a box drawn at the same place on each half surrounds
518
+ * the same part of the page in both, so the eye goes straight from the empty
519
+ * field on the left to the signature on the right.
520
+ */
521
+ /** Separator between the halves: mid grey, so it shows against white paper and a grey scan alike. */
522
+ const GUTTER = [150, 150, 150, 255];
523
+ function composeSideBySide(original, aligned, annotations, thickness, gutter) {
524
+ const offset = original.width + gutter;
525
+ const result = createRaster(offset + aligned.width, Math.max(original.height, aligned.height));
526
+ paste(result, original, 0);
527
+ paste(result, aligned, offset);
528
+ for (let y = 0; y < result.height; y++) for (let x = original.width; x < offset; x++) result.data.set(GUTTER, (y * result.width + x) * 4);
529
+ annotateOverlay(result, annotations, thickness);
530
+ annotateOverlay(result, annotations.map(a => ({
531
+ ...a,
532
+ rect: {
533
+ ...a.rect,
534
+ x: a.rect.x + offset
535
+ }
536
+ })), thickness);
537
+ return result;
538
+ }
539
+ function paste(target, source, left) {
540
+ const rowBytes = source.width * 4;
541
+ for (let y = 0; y < source.height; y++) target.data.set(source.data.subarray(y * rowBytes, (y + 1) * rowBytes), (y * target.width + left) * 4);
542
+ }
543
+
544
+ /**
545
+ * What changed on each page, and whether it was supposed to.
546
+ *
547
+ * The overlay is the detection: the original's ink, fattened by a tolerance band,
548
+ * subtracted from the aligned scan, leaves the ink the scan added. What this adds
549
+ * is the reporting. What changed is grouped into changes - labelled, merged
550
+ * across small gaps, filtered below a physical size. A change whose ink lies
551
+ * mostly in an expected region belongs to that region; every other change is
552
+ * unexpected. Each region is also analysed on its own - its new ink grouped,
553
+ * its printed rules discarded, the rest measured - and is identified once that
554
+ * ink adds up to `minFillArea` without covering more than `maxFill` of it. The
555
+ * same grouping is done for ink the scan lost.
556
+ *
557
+ * Masks are built once per page and read four ways: the overlay, the expected
558
+ * regions, the added changes and the missing ones.
559
+ */
560
+ async function diffPages(pages, expected = [], options = {}) {
561
+ const {
562
+ onProgress
563
+ } = options;
564
+ const results = [];
565
+ for (const [position, page] of pages.entries()) {
566
+ const index = position + 1;
567
+ const started = Date.now();
568
+ onProgress?.({
569
+ stage: 'diff',
570
+ phase: 'start',
571
+ page: page.page,
572
+ index,
573
+ total: pages.length
574
+ });
575
+ const result = await diffPage(page, expected.filter(e => e.page === page.page), options);
576
+ results.push(result);
577
+ onProgress?.({
578
+ stage: 'diff',
579
+ phase: 'done',
580
+ page: page.page,
581
+ index,
582
+ total: pages.length,
583
+ durationMs: Date.now() - started,
584
+ detail: {
585
+ ...result.summary,
586
+ truncated: result.truncated
587
+ }
588
+ });
589
+ }
590
+ return results;
591
+ }
592
+ /** One page. `expected` should already be the regions for this page. */
593
+ async function diffPage(page, expected = [], options = {}) {
594
+ const {
595
+ units = 'points',
596
+ tolerance = 2,
597
+ minFillArea = 2,
598
+ maxFill = 0.5,
599
+ formLineSpan = 0.9,
600
+ formLineThickness = 0.6,
601
+ minChangeArea = 1,
602
+ minMissingArea = 4,
603
+ faintInk,
604
+ mergeGap = 3,
605
+ assumeDpi = 150,
606
+ regionOverlap = 0.5,
607
+ maxChanges = 50,
608
+ output = 'png',
609
+ annotate = false,
610
+ sideBySide = false,
611
+ ink
612
+ } = options;
613
+ const dpi = page.original.dpi ?? assumeDpi;
614
+ const toPixels = units === 'points' ? dpi / 72 : 1;
615
+ const pixelsPerMm = dpi / 25.4;
616
+ const mm2PerPixel = 1 / (pixelsPerMm * pixelsPerMm);
617
+ const masks = await buildMasks(page.original.raster, page.aligned.raster, ink, tolerance, faintInk);
618
+ const regions = expected.map(e => ({
619
+ id: e.id,
620
+ rect: scaleRect(e, toPixels)
621
+ }));
622
+ const findChanges = (mask, minArea) => {
623
+ const components = connectedComponents(mask).filter(c => c.pixels >= 2);
624
+ const merged = mergeBoxes(components, Math.round(mergeGap * pixelsPerMm));
625
+ return merged.filter(box => box.pixels * mm2PerPixel >= minArea).toSorted((a, b) => b.pixels - a.pixels);
626
+ };
627
+ const addedMask = difference(masks.scan, masks.originalDilated);
628
+ const added = findChanges(addedMask, minChangeArea);
629
+ const outside = added.filter(box => regions.every(r => inkShareInside(box, r.rect, masks) < regionOverlap));
630
+ const lost = findChanges(difference(masks.original, masks.scanDilated), minMissingArea);
631
+ const expectedResults = regions.map((region, i) => {
632
+ const measured = measureRegionInk(addedMask, region.rect, {
633
+ mergeGap: Math.round(mergeGap * pixelsPerMm),
634
+ minChangePixels: minChangeArea / mm2PerPixel,
635
+ lineSpan: formLineSpan,
636
+ lineThickness: formLineThickness * pixelsPerMm,
637
+ lineThicknessRatio: 0.04,
638
+ edgeBand: 0.03
639
+ });
640
+ const addedInk = measured.pixels * mm2PerPixel;
641
+ const overfilled = measured.fill > maxFill;
642
+ const {
643
+ removed
644
+ } = measureRegion(region, masks, 0);
645
+ const bounds = measured.bounds && scaleRect(measured.bounds, 1 / toPixels);
646
+ return {
647
+ id: region.id,
648
+ identified: addedInk >= minFillArea && !overfilled,
649
+ x: expected[i].x,
650
+ y: expected[i].y,
651
+ width: expected[i].width,
652
+ height: expected[i].height,
653
+ addedInk,
654
+ removedInk: removed * pixelArea(region.rect, masks) * mm2PerPixel,
655
+ score: minFillArea > 0 ? Math.min(1, addedInk / minFillArea) : 1,
656
+ overfilled,
657
+ ink: {
658
+ changes: measured.changes,
659
+ largestArea: measured.largest * mm2PerPixel,
660
+ bounds,
661
+ widthRatio: bounds ? bounds.width / expected[i].width : 0,
662
+ heightRatio: bounds ? bounds.height / expected[i].height : 0,
663
+ edgeTouch: measured.edgeTouch,
664
+ fill: measured.fill,
665
+ formLines: measured.formLines
666
+ }
667
+ };
668
+ });
669
+ const truncated = outside.length > maxChanges || lost.length > maxChanges;
670
+ const toChange = box => ({
671
+ x: box.x / toPixels,
672
+ y: box.y / toPixels,
673
+ width: box.width / toPixels,
674
+ height: box.height / toPixels,
675
+ inkArea: box.pixels * mm2PerPixel,
676
+ pixels: box.pixels
677
+ });
678
+ const unexpected = outside.slice(0, maxChanges).map(box => toChange(box));
679
+ const missing = lost.slice(0, maxChanges).map(box => toChange(box));
680
+ const reported = [...regions.map((region, i) => ({
681
+ rect: grow(region.rect, 2),
682
+ color: expectedResults[i].identified ? IDENTIFIED : NOT_IDENTIFIED
683
+ })), ...outside.slice(0, maxChanges).map(box => ({
684
+ rect: grow(box, 4),
685
+ color: UNEXPECTED
686
+ }))];
687
+ const diffRaster = paintOverlay(masks);
688
+ if (annotate) annotateOverlay(diffRaster, reported);
689
+ // Lines about a point thick at any dpi, so the boxes read the same on every page.
690
+ const sideBySideRaster = sideBySide ? composeSideBySide(page.original.raster, page.aligned.raster, [...reported, ...lost.slice(0, maxChanges).map(box => ({
691
+ rect: grow(box, 4),
692
+ color: MISSING
693
+ }))], Math.max(2, Math.round(dpi / 72)), Math.max(4, Math.round(dpi / 12))) : null;
694
+ const whole = measureRegion({
695
+ id: '__page__',
696
+ rect: {
697
+ x: 0,
698
+ y: 0,
699
+ width: masks.width,
700
+ height: masks.height
701
+ }
702
+ }, masks, 0);
703
+ const identified = expectedResults.filter(r => r.identified).length;
704
+ return {
705
+ page: page.page,
706
+ diffRaster,
707
+ diffImage: output === 'none' ? null : await encodeImage(diffRaster, {
708
+ format: output
709
+ }),
710
+ sideBySideRaster,
711
+ sideBySideImage: sideBySideRaster === null || output === 'none' ? null : await encodeImage(sideBySideRaster, {
712
+ format: output
713
+ }),
714
+ expected: expectedResults,
715
+ unexpected,
716
+ missing,
717
+ truncated,
718
+ summary: {
719
+ addedInk: whole.added,
720
+ removedInk: whole.removed,
721
+ identified,
722
+ notIdentified: expectedResults.length - identified,
723
+ unexpected: unexpected.length,
724
+ missing: missing.length
725
+ }
726
+ };
727
+ }
728
+ /** Set where `a` is set and `b` is not. */
729
+ function difference(a, b) {
730
+ const data = new Uint8Array(a.data.length);
731
+ for (let p = 0; p < data.length; p++) data[p] = a.data[p] === 1 && b.data[p] === 0 ? 1 : 0;
732
+ return {
733
+ width: a.width,
734
+ height: a.height,
735
+ data
736
+ };
737
+ }
738
+ /**
739
+ * Share of a change's new ink that falls inside a region.
740
+ *
741
+ * Measured on ink, not on box area: a signature that overflows its box by a
742
+ * flourish is still mostly inside it, while its bounding box may not be.
743
+ */
744
+ function inkShareInside(box, region, masks) {
745
+ const left = Math.max(box.x, Math.floor(region.x));
746
+ const top = Math.max(box.y, Math.floor(region.y));
747
+ const right = Math.min(box.x + box.width, Math.ceil(region.x + region.width));
748
+ const bottom = Math.min(box.y + box.height, Math.ceil(region.y + region.height));
749
+ if (right <= left || bottom <= top) return 0;
750
+ let inside = 0;
751
+ for (let y = top; y < bottom; y++) {
752
+ const row = y * masks.width;
753
+ for (let x = left; x < right; x++) if (masks.scan.data[row + x] === 1 && masks.originalDilated.data[row + x] === 0) inside++;
754
+ }
755
+ // The count here includes isolated pixels the component filter dropped from
756
+ // box.pixels, so it can nudge past one.
757
+ return box.pixels > 0 ? Math.min(1, inside / box.pixels) : 0;
758
+ }
759
+ /** Pixels of a region that lie on the page - what `measureRegion`'s shares are shares of. */
760
+ function pixelArea(rect, masks) {
761
+ const width = Math.min(masks.width, Math.ceil(rect.x + rect.width)) - Math.max(0, Math.floor(rect.x));
762
+ const height = Math.min(masks.height, Math.ceil(rect.y + rect.height)) - Math.max(0, Math.floor(rect.y));
763
+ return Math.max(0, width) * Math.max(0, height);
764
+ }
765
+ function scaleRect(rect, factor) {
766
+ return {
767
+ x: rect.x * factor,
768
+ y: rect.y * factor,
769
+ width: rect.width * factor,
770
+ height: rect.height * factor
771
+ };
772
+ }
773
+ function grow(rect, by) {
774
+ return {
775
+ x: rect.x - by,
776
+ y: rect.y - by,
777
+ width: rect.width + 2 * by,
778
+ height: rect.height + 2 * by
779
+ };
780
+ }
781
+
782
+ export { IDENTIFIED, MISSING, NOT_IDENTIFIED, UNEXPECTED, annotateOverlay, buildMasks, compareRegions, composeSideBySide, connectedComponents, diffDocument, diffPage, diffPages, labelComponents, measureRegion, measureRegionInk, mergeBoxes, paintOverlay, renderDiff };
783
+ //# sourceMappingURL=index.esm.js.map