@scanmate/diff 0.0.2 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,183 +1,83 @@
1
- ![scanmate diff](./scanmate-diff.svg)
1
+ ![scanmate diff](./assets/scanmate-diff.svg)
2
2
 
3
3
  # `@scanmate/diff`
4
4
 
5
- > Visual change detection, form field verification, and unexpected modification analysis for aligned document pairs.
5
+ What changed between an original and its aligned scan: which expected regions were filled in, what ink was added where nothing was expected, what printed ink the scan lost and a picture of all three.
6
6
 
7
- `@scanmate/diff` analyzes differences between original digital document templates and aligned scanned pages. It verifies whether expected form regions (such as signature blocks, checkboxes, and fillable fields) were completed, measures added/removed ink quantities, isolates unexpected handwritten marks or edits using 2-pass connected components analysis, and renders color-coded visual difference overlays.
7
+ ![the original and the returned scan side by side, the signer fields outlined](./assets/side-by-side.jpg)
8
8
 
9
- ---
9
+ *Green: a field that was filled in. Orange: the band around it where ink still counts as that field's. Made from the [IRS Form W-9](https://www.irs.gov/pub/irs-pdf/fw9.pdf) (a work of the United States government, in the public domain): filled in as a generator would, printed, signed by hand and scanned crooked.*
10
10
 
11
- ## Features
12
-
13
- - ✍️ **Form Region Verification (`compareRegions`)**: Quantifies added ink inside specific bounding boxes in original canvas coordinates.
14
- - 🔍 **Sub-Pixel Dilation Tolerance**: Fattens original ink boundaries before subtraction to eliminate false-positive edge noise caused by minor printing/scanning shifts.
15
- - 🎨 **Color-Coded Visual Overlay (`renderDiff`)**: Produces RGBA difference overlays (Red = added ink / signature, Blue = removed ink, Grey = matching ink).
16
- - 🧩 **Unexpected Mark Isolation (`diffPage`)**: Uses 8-connectivity Connected Component Analysis (CCL) to group un-matched ink pixels into isolated bounding boxes.
17
- - 📦 **Automated Box Merging**: Consolidates adjacent connected components to present clean, readable change boxes around handwritten notes or stamps.
18
-
19
- ---
20
-
21
- ## Installation
11
+ ## Install
22
12
 
23
13
  ```bash
24
- # Using npm
25
- npm install @scanmate/diff @scanmate/ink
26
-
27
- # Using pnpm
28
- pnpm add @scanmate/diff @scanmate/ink
29
-
30
- # Using yarn
31
- yarn add @scanmate/diff @scanmate/ink
14
+ npm install @scanmate/diff @scanmate/extract @scanmate/align
32
15
  ```
33
16
 
34
- ---
35
-
36
- ## Quick Start
37
-
38
17
  ```ts
39
- import { compareRegions } from '@scanmate/diff'
40
- import { decodeImage } from '@scanmate/ink'
41
-
42
- const original = await decodeImage(originalBuffer)
43
- const aligned = await decodeImage(alignedBuffer)
44
-
45
- const reports = compareRegions(original, aligned, [
46
- { id: 'signature', rect: { x: 100, y: 750, width: 350, height: 80 } },
47
- ])
18
+ import { alignPages } from '@scanmate/align'
19
+ import { diffPages } from '@scanmate/diff'
20
+ import { extractPair } from '@scanmate/extract'
21
+
22
+ const { pages } = await extractPair({ original: 'fw9-issued.pdf', scanned: 'fw9-returned.pdf' })
23
+ // The W-9's signature row, measured off the form in points from the page's top-left.
24
+ const changes = await diffPages(await alignPages(pages), [
25
+ { page: 1, id: 'signature', x: 120, y: 577, width: 262, height: 22 },
26
+ { page: 1, id: 'date', x: 404, y: 577, width: 171, height: 22 },
27
+ ], { sideBySide: true })
28
+
29
+ changes[0].expected // [{ id, identified, addedInk, overfilled, ink: { ... } }]
30
+ changes[0].unexpected // [{ x, y, width, height, inkArea, pixels }]
31
+ changes[0].missing // printed ink the scan lost
32
+ changes[0].sideBySideImage // original and scan, boxed alike
33
+ changes[0].diffImage // the overlay: violet where the ink differs, grey where it agrees
48
34
  ```
49
35
 
50
- ---
51
-
52
- ## Architecture & Algorithm Deep-Dive
53
-
54
- ### 1. Dilation Masking & Sub-Pixel Tolerance
55
-
56
- Even when a scan is perfectly aligned, real-world printing and scanning artifacts (ink bleed, scanner MTF blur, rasterization anti-aliasing) create sub-pixel outline differences along text character edges. Subtracting raw ink maps directly produces false-positive "halos" around every letter on the page.
57
-
58
- To prevent this, `@scanmate/diff` applies **morphological dilation** with radius $r$ (default 2px) to the original template's ink map $M_{orig}$:
59
-
60
- $$M_{orig, dilated} = \text{dilate}(M_{orig}, r)$$
61
-
62
- $$\text{Ink}_{added}(x, y) = \max\left(0, \text{Ink}_{scan}(x, y) - M_{orig, dilated}(x, y)\right)$$
63
-
64
- ```mermaid
65
- flowchart TD
66
- A["Original Ink Map"] --> B["Morphological Dilation (Radius r = 2px)"]
67
- B --> C["Dilated Original Mask M_dilated"]
68
- D["Aligned Scan Ink Map M_scan"] --> E["Ink Subtraction:<br/>Added = max(0, M_scan - M_dilated)"]
69
- C --> E
70
- E --> F["Clean Added Ink Map<br/>(Character outline noise suppressed,<br/>Signatures & Checkmarks retained)"]
71
- ```
72
-
73
- ---
74
-
75
- ### 2. Connected Component Analysis & Unexpected Mark Grouping
76
-
77
- ```mermaid
78
- sequenceDiagram
79
- autonumber
80
- participant Diff as diffPage()
81
- participant Mask as Mask Engine
82
- participant CCL as Connected Components
83
- participant Merge as Box Merger
84
-
85
- Diff->>Mask: Compute Added Ink Map & Mask expected regions
86
- Mask-->>Diff: Un-matched Added Ink Map
87
- Diff->>CCL: connectedComponents(binaryInkMap)
88
- CCL->>CCL: Pass 1: Label 8-connected pixel clusters & track equivalences
89
- CCL->>CCL: Pass 2: Resolve label equivalences & calculate component stats
90
- CCL-->>Diff: Return raw pixel blob Components
91
- Diff->>Merge: mergeBoxes(components, { maxGap: 15px })
92
- Merge->>Merge: Calculate bounding box overlaps & expand by maxGap
93
- Merge->>Merge: Merge intersecting bounding boxes into unified regions
94
- Merge-->>Diff: Return MergedBox array
95
- Diff-->>Diff: Annotate overlay & produce PageDiff report
96
- ```
97
-
98
- ---
99
-
100
- ## Comprehensive API Reference
101
-
102
- ### 1. Region Comparison & Form Verification
103
-
104
- #### `compareRegions(original: Raster, aligned: Raster, regions: Region[], options?: RegionOptions): RegionReport[]`
105
- Evaluates specific rectangular form regions to check if signatures, checkboxes, or text boxes were filled in.
106
- - **Parameters**:
107
- - `original`: Original template `Raster`.
108
- - `aligned`: Aligned scan `Raster` (must match `original` canvas width/height).
109
- - `regions`: Array of `Region` objects (`{ id: string, rect: Rect, threshold?: number }`).
110
- - `options` *(optional)*: `RegionOptions` object (see breakdown below).
111
- - **Returns**: Array of `RegionReport` (`{ id, rect, filled, score, added, removed, addedPixels, totalPixels }`).
112
-
113
- ##### Detailed Options Explanation (`RegionOptions`):
114
-
115
- | Option | Type | Default | Description & Impact |
116
- |---|---|---|---|
117
- | `tolerance` | `number` | `2` | Morphological dilation radius in pixels applied to original ink before subtraction. Absorbs minor sub-pixel rendering shifts. |
118
- | `threshold` | `number` | `0.02` | Ink ratio threshold (2% of region area) above which `filled` is set to `true`. |
119
- | `addedColor` | `Rgba` | `[239, 68, 68, 255]` | RGBA color (Red) for added ink in diff overlays. |
120
- | `removedColor` | `Rgba` | `[59, 130, 246, 255]` | RGBA color (Blue) for removed ink in diff overlays. |
121
- | `matchedColor` | `Rgba` | `[156, 163, 175, 255]`| RGBA color (Grey) for matching ink in diff overlays. |
122
-
123
- ---
124
-
125
- #### `diffDocument(original: Raster, aligned: Raster, regions?: Region[], options?: RegionOptions): DocumentDiff`
126
- Computes whole-page added/removed ink statistics plus per-region details in a single efficient pass.
127
- - **Returns**: `DocumentDiff` (`{ overallAdded, overallRemoved, overallAddedPixels, overallTotalPixels, regions: RegionReport[] }`).
128
-
129
- #### `renderDiff(original: Raster, aligned: Raster, options?: RegionOptions): Raster`
130
- Generates a 4-color RGBA overlay `Raster` suitable for visual inspection (Red = scan additions, Blue = template deletions, Grey = matched ink, White = paper background).
131
-
132
- ---
133
-
134
- ### 2. High-Level Page & Document Diffing
135
-
136
- #### `diffPage(options: DiffOptions): Promise<PageDiff>`
137
- Full change detection pipeline for a single page, matching expected form regions and isolating unexpected handwritten edits using connected component analysis.
138
- - **Parameters (`DiffOptions`)**:
139
- - `page`: Page number index.
140
- - `original`: Original template `Raster`.
141
- - `aligned`: Aligned scan `Raster`.
142
- - `expectedRegions` *(optional)*: Array of expected form field bounding boxes.
143
- - `minChangePixels` *(default: 20)*: Minimum area in pixels to consider a connected component a valid unexpected change box.
144
- - `tolerance` *(default: 2)*: Dilation tolerance radius.
145
- - `addedColor` / `removedColor`: Visual overlay colors.
146
- - **Returns**: `Promise<PageDiff>` (`{ page, expected: ExpectedResult[], unexpected: UnexpectedChange[], overlay: Raster }`).
36
+ Rectangles are in PDF points from the page's top-left by default, the same frame `@scanmate/extract` reports text in; `units: 'pixels'` switches to the original's rendered pixels.
147
37
 
148
- #### `diffPages(alignedPages: AlignedPage[], expectedRegions: ExpectedRegion[], options?: DiffOptions): Promise<PageDiff[]>`
149
- Batch page diffing for multi-page document collections.
38
+ ## What it measures
150
39
 
151
- ---
40
+ - **Ink, not brightness.** Both pages are divided by their own local background before anything is compared, so a shadow or a grey scanner lid takes no part.
41
+ - **In square millimetres, not shares of a box.** A signature is a few tens of mm² whether its box is a stamp or the width of the page, and the same mark is four times the pixels at twice the resolution.
42
+ - **Two thresholds on the scan.** A normal one for what counts as *added*, and a faint one at a quarter of it for what is *still there at all*. Printed ink counts as lost only where the scan shows nothing even at the lower bar: a pale photocopy has lighter ink, not missing ink.
43
+ - **Tolerance for the alignment.** The original's ink is fattened by `tolerance` (2 px) before subtraction, so stroke edges do not become a halo of confetti. A change that stays inside that band cannot be seen here — a digit swapped for another digit is exactly such a change, which is why `@scanmate/ocr` matches figures glyph by glyph.
152
44
 
153
- ### 3. Pipeline Building Blocks & Connected Components
45
+ ## Deciding a region
154
46
 
155
- #### `buildMasks(original: Raster, aligned: Raster, options?: RegionOptions): Masks`
156
- Computes intermediate Float32 ink maps and binary addition/subtraction masks.
157
- - **Returns**: `Masks` (`{ originalInk, alignedInk, addedMask, removedMask, width, height }`).
47
+ A region is **identified** when it has at least `minFillArea` (2 mm²) of new ink and is not **overfilled** — covered or struck through, which `maxFill` (0.5) draws the line on. Form rules showing through a slight misregistration are discounted: a component spanning 90% of the region and no thicker than 0.6 mm is the box's own printed line.
158
48
 
159
- #### `measureRegion(masks: Masks, region: Region, options?: RegionOptions): RegionReport`
160
- Measures ink statistics inside a single `Region` using pre-computed `Masks`.
49
+ People sign past the box they are given, so each region also claims the ink within `expectedMargin` (6 points) of it, and the regions claim it **together**, so one stroke running through two fields is not left over as an unexpected mark. What a region reports is still the rectangle it was given; the band is drawn in pink.
161
50
 
162
- #### `paintOverlay(masks: Masks, options?: RegionOptions): Raster`
163
- Paints RGBA overlay `Raster` from pre-computed `Masks`.
51
+ Each region reports its shape too — how many separate changes, the largest, the bounds as a share of the box, how much ink touches the border — so a signature can be told from a stray line without looking at the picture.
164
52
 
165
- #### `connectedComponents(binary: BinaryImage, options?: ComponentOptions): Component[]`
166
- Executes 2-pass 8-connectivity Connected Component Analysis (CCL) to extract disjoint pixel blobs.
167
- - **Options**:
168
- - `minPixels` *(default: 1)*: Ignore components with pixel count below this limit.
169
- - **Returns**: Array of `Component` (`{ id, minX, minY, maxX, maxY, pixelCount, width, height }`).
53
+ ## Options
170
54
 
171
- #### `mergeBoxes(boxes: MergedBox[], options?: MergeOptions): MergedBox[]`
172
- Consolidates overlapping or closely adjacent bounding boxes.
173
- - **Options**:
174
- - `maxGap` *(default: 15)*: Maximum distance in pixels between box boundaries to trigger a box merge.
55
+ | option | default | |
56
+ |---|---|---|
57
+ | `units` | `'points'` | Or `'pixels'`. |
58
+ | `tolerance` | `2` px | How much misregistration is forgiven. |
59
+ | `faintInk` | `0.25` | Fraction of the normal threshold for "still there". |
60
+ | `minFillArea` | `2` mm² | New ink a region needs. |
61
+ | `maxFill` | `0.5` | Above this the region is covered, not filled. |
62
+ | `expectedMargin` | `6` pt | How far outside a region its ink may lie. |
63
+ | `formLineSpan` | `0.9` | Span that makes a component a printed rule... |
64
+ | `formLineThickness` | `0.6` mm | ...if it is no thicker than this. |
65
+ | `minChangeArea` | `1` mm² | Smallest change reported. |
66
+ | `minMissingArea` | `4` mm² | Smallest loss reported. |
67
+ | `mergeGap` | `3` mm | Boxes closer than this become one. |
68
+ | `regionOverlap` | `0.5` | Share of a change's ink that must fall inside a region. |
69
+ | `maxChanges` | `50` | Cap on a confetti page; the report says it was capped. |
70
+ | `assumeDpi` | `150` | Used when the page does not say. |
71
+ | `output` | `'png'` | `'none'` keeps rasters only. |
72
+ | `annotate` | `false` | Draw the report onto the overlay. |
73
+ | `sideBySide` | `false` | Also compose the two pages side by side. |
74
+ | `probes` | none | Rectangles to measure the ink at, changed or not - added, lost and shared, in mm². |
75
+ | `keepMasks` | `false` | Keep the ink masks on the result, so `probeInk` can ask about places found later. |
175
76
 
176
- #### `annotateOverlay(overlay: Raster, annotations: Annotation[], options?: LabelOptions): Raster`
177
- Draws bounding box rectangles and text labels onto a diff overlay `Raster`.
77
+ ## Building blocks
178
78
 
179
- ---
79
+ `buildMasks`, `connectedComponents`, `labelComponents`, `mergeBoxes`, `measureRegionInk`, `annotateOverlay`, `composeSideBySide`, `compareRegions`, `diffDocument` and `renderDiff` are exported for callers who want a stage on its own, along with the annotation colours.
180
80
 
181
- ## License
81
+ ## How it decides
182
82
 
183
- MIT © [ScanMate Team](https://github.com/russoedu/scanmate)
83
+ [`documentation/algorithms.md`](./documentation/algorithms.md) has the algorithms in full: what each step measures, the decision flows, every constant with the measurement behind it, and what the package deliberately does not do.
package/dist/index.esm.js CHANGED
@@ -1,9 +1,18 @@
1
- import { decodeImage, binarize, inkMap, toGrayscale, otsuThreshold, dilate, coverage, createRaster, encodeImage } from '@scanmate/ink';
1
+ import { createRaster, decodeImage, binarize, inkMap, toGrayscale, otsuThreshold, dilate, coverage, encodeImage } from '@scanmate/ink';
2
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];
3
+ /** The area in question, drawn on the original: a statement of where, not of what. */
4
+ const REFERENCE = [0, 23, 252, 255];
5
+ const IDENTIFIED = [0, 252, 17, 255];
6
+ /** Empty, covered, or changed: the answers that need a person. */
7
+ const NOT_IDENTIFIED = [252, 0, 39, 255];
8
+ /**
9
+ * Neither comparison could settle it. Not red, because red is a statement that
10
+ * something is wrong and this is a statement that nobody knows.
11
+ */
12
+ const UNSETTLED = [200, 160, 0, 255];
13
+ const EXPECTED_MARGIN = [245, 0, 252, 255];
14
+ const UNEXPECTED = [255, 138, 0, 255];
15
+ const MISSING = [0, 200, 252, 255];
7
16
  function annotateOverlay(overlay, annotations, thickness = 2) {
8
17
  for (const {
9
18
  rect,
@@ -55,6 +64,70 @@ function fill(raster, rect, color) {
55
64
  }
56
65
  }
57
66
 
67
+ /**
68
+ * The original and the aligned scan next to each other, boxed.
69
+ *
70
+ * The overlay answers "which pixels changed"; this answers "show me", for the
71
+ * person who has to agree with the verdict. Because the scan is aligned onto
72
+ * the original's canvas, a box drawn at the same place on each half surrounds
73
+ * the same part of the page in both, so the eye goes straight from the empty
74
+ * field on the left to the signature on the right.
75
+ *
76
+ * The two halves can say different things, and usually should: the original's
77
+ * half states *where* the question is, in one colour, and the scan's half
78
+ * states the answer, in the colour of that answer.
79
+ */
80
+ /** Separator between the halves: mid grey, so it shows against white paper and a grey scan alike. */
81
+ const GUTTER = [150, 150, 150, 255];
82
+ /**
83
+ * Several images in a row on one canvas, each with its own boxes, separated by
84
+ * a grey gutter - the original, the scan, and the overlay of the two, say.
85
+ *
86
+ * @param panels - The images, left to right.
87
+ * @param thickness - Outline thickness, in pixels.
88
+ * @param gutter - Pixels between panels.
89
+ * @returns One image, as wide as the panels and their gutters.
90
+ */
91
+ function composePanels(panels, thickness, gutter) {
92
+ const width = panels.reduce((sum, panel) => sum + panel.raster.width, 0) + gutter * Math.max(0, panels.length - 1);
93
+ const height = Math.max(...panels.map(panel => panel.raster.height));
94
+ const result = createRaster(width, height);
95
+ let left = 0;
96
+ for (const [index, panel] of panels.entries()) {
97
+ paste(result, panel.raster, left);
98
+ annotateOverlay(result, (panel.annotations ?? []).map(a => ({
99
+ ...a,
100
+ rect: {
101
+ ...a.rect,
102
+ x: a.rect.x + left
103
+ }
104
+ })), thickness);
105
+ left += panel.raster.width;
106
+ if (index < panels.length - 1) {
107
+ for (let y = 0; y < height; y++) for (let x = left; x < left + gutter; x++) result.data.set(GUTTER, (y * result.width + x) * 4);
108
+ left += gutter;
109
+ }
110
+ }
111
+ return result;
112
+ }
113
+ function composeSideBySide(original, aligned, annotations, thickness, gutter) {
114
+ const sides = Array.isArray(annotations) ? {
115
+ original: annotations,
116
+ scanned: annotations
117
+ } : annotations;
118
+ return composePanels([{
119
+ raster: original,
120
+ annotations: sides.original
121
+ }, {
122
+ raster: aligned,
123
+ annotations: sides.scanned
124
+ }], thickness, gutter);
125
+ }
126
+ function paste(target, source, left) {
127
+ const rowBytes = source.width * 4;
128
+ for (let y = 0; y < source.height; y++) target.data.set(source.data.subarray(y * rowBytes, (y + 1) * rowBytes), (y * target.width + left) * 4);
129
+ }
130
+
58
131
  function connectedComponents(mask, options = {}) {
59
132
  return labelComponents(mask, options).components;
60
133
  }
@@ -161,6 +234,73 @@ function labelComponents(mask, options = {}) {
161
234
  };
162
235
  }
163
236
 
237
+ /**
238
+ * What the ink does inside places the caller names, changed or not.
239
+ *
240
+ * The rest of this subfeature starts from a change and asks where it is. This
241
+ * asks the opposite question - *how much ink is here?* - and it exists to
242
+ * settle an argument. A reading that disagrees with the original is not by
243
+ * itself a change: OCR misreads small, faint and sideways print, and identical
244
+ * ink under a word means identical characters whatever they were read as.
245
+ *
246
+ * It is separate from `diffPage` because the places worth asking about are
247
+ * usually not known until the page has been read, which happens alongside the
248
+ * pixel comparison rather than before it.
249
+ *
250
+ * @param masks - The ink masks of the pair, from `diffPage` or `buildMasks`.
251
+ * @param rects - Where to measure, in `units`.
252
+ * @param options - How to read `rects`, and the resolution they are in.
253
+ * @returns One probe per rectangle, in the same order, in square millimetres.
254
+ */
255
+ function probeInk(masks, rects, options = {}) {
256
+ const {
257
+ dpi = 150,
258
+ units = 'points'
259
+ } = options;
260
+ const toPixels = units === 'points' ? dpi / 72 : 1;
261
+ const pixelsPerMm = dpi / 25.4;
262
+ const mm2PerPixel = 1 / (pixelsPerMm * pixelsPerMm);
263
+ return rects.map(rect => {
264
+ const ink = inkWithin({
265
+ x: rect.x * toPixels,
266
+ y: rect.y * toPixels,
267
+ width: rect.width * toPixels,
268
+ height: rect.height * toPixels
269
+ }, masks);
270
+ return {
271
+ rect,
272
+ addedInk: ink.added * mm2PerPixel,
273
+ lostInk: ink.lost * mm2PerPixel,
274
+ sharedInk: ink.shared * mm2PerPixel
275
+ };
276
+ });
277
+ }
278
+ /** Added, lost and shared ink inside one rectangle of the page, in pixels. */
279
+ function inkWithin(rect, masks) {
280
+ const left = Math.max(0, Math.floor(rect.x));
281
+ const top = Math.max(0, Math.floor(rect.y));
282
+ const right = Math.min(masks.width, Math.ceil(rect.x + rect.width));
283
+ const bottom = Math.min(masks.height, Math.ceil(rect.y + rect.height));
284
+ let added = 0;
285
+ let lost = 0;
286
+ let shared = 0;
287
+ for (let y = top; y < bottom; y++) {
288
+ const row = y * masks.width;
289
+ for (let x = left; x < right; x++) {
290
+ const scan = masks.scan.data[row + x] === 1;
291
+ const print = masks.original.data[row + x] === 1;
292
+ if (scan && masks.originalDilated.data[row + x] === 0) added++;
293
+ if (print && masks.scanDilated.data[row + x] === 0) lost++;
294
+ if (scan && print) shared++;
295
+ }
296
+ }
297
+ return {
298
+ added,
299
+ lost,
300
+ shared
301
+ };
302
+ }
303
+
164
304
  /** Default for {@link buildMasks}' `faintInk`, measured against real scans. */
165
305
  const FAINT_INK = 0.25;
166
306
  async function buildMasks(original, aligned, ink, tolerance, faintInk = FAINT_INK) {
@@ -304,10 +444,15 @@ function measureRegion(region, masks, defaultThreshold) {
304
444
  /**
305
445
  * An RGBA overlay of the comparison, for looking at with your own eyes.
306
446
  *
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.
447
+ * Violet is ink the two pages do not share - added by the scan or lost from the
448
+ * print, which the picture does not distinguish; grey is ink they agree on. A
449
+ * correctly aligned pair of a signed form is almost entirely grey with a violet
450
+ * signature; a misaligned one is violet confetti along every stroke, which is
451
+ * the fastest way to tell the two failures apart.
452
+ *
453
+ * Which side the ink came from is a question for the report, where it is
454
+ * measured separately and in millimetres. Here it would be a second colour
455
+ * carrying a distinction the eye does not need at this zoom.
311
456
  */
312
457
  async function renderDiff(original, aligned, options = {}) {
313
458
  const {
@@ -318,7 +463,15 @@ async function renderDiff(original, aligned, options = {}) {
318
463
  const masks = await buildMasks(original, aligned, ink, tolerance, faintInk);
319
464
  return paintOverlay(masks);
320
465
  }
321
- /** The overlay, from masks already built. Red added, blue lost, grey agreed, white paper. */
466
+ /**
467
+ * Ink the two pages do not share. Violet rather than red, because red means
468
+ * "changed" on every annotated page in the pipeline and one colour cannot mean
469
+ * two things.
470
+ */
471
+ const OVERLAY_DIFFERENT = [127, 0, 252, 255];
472
+ /** Ink the two pages agree on. */
473
+ const OVERLAY_SHARED = [110, 110, 110, 255];
474
+ /** The overlay, from masks already built. Violet where they differ, grey where they agree, white paper. */
322
475
  function paintOverlay(masks) {
323
476
  const {
324
477
  width,
@@ -332,19 +485,9 @@ function paintOverlay(masks) {
332
485
  let r = 255;
333
486
  let g = 255;
334
487
  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
- }
488
+ const added = inScan && !nearOriginal;
489
+ const lost = inOriginal && masks.scanDilated.data[p] === 0;
490
+ if (added || lost) [r, g, b] = OVERLAY_DIFFERENT;else if (inOriginal || inScan) [r, g, b] = OVERLAY_SHARED;
348
491
  data[i] = r;
349
492
  data[i + 1] = g;
350
493
  data[i + 2] = b;
@@ -508,39 +651,6 @@ function measureRegionInk(added, rect, options) {
508
651
  };
509
652
  }
510
653
 
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
654
  /**
545
655
  * What changed on each page, and whether it was supposed to.
546
656
  *
@@ -572,7 +682,10 @@ async function diffPages(pages, expected = [], options = {}) {
572
682
  index,
573
683
  total: pages.length
574
684
  });
575
- const result = await diffPage(page, expected.filter(e => e.page === page.page), options);
685
+ const result = await diffPage(page, expected.filter(e => e.page === page.page), {
686
+ ...options,
687
+ probes: (options.probes ?? []).filter(probe => probe.page === undefined || probe.page === page.page)
688
+ });
576
689
  results.push(result);
577
690
  onProgress?.({
578
691
  stage: 'diff',
@@ -604,7 +717,10 @@ async function diffPage(page, expected = [], options = {}) {
604
717
  mergeGap = 3,
605
718
  assumeDpi = 150,
606
719
  regionOverlap = 0.5,
720
+ expectedMargin = 6,
607
721
  maxChanges = 50,
722
+ probes = [],
723
+ keepMasks = false,
608
724
  output = 'png',
609
725
  annotate = false,
610
726
  sideBySide = false,
@@ -615,9 +731,12 @@ async function diffPage(page, expected = [], options = {}) {
615
731
  const pixelsPerMm = dpi / 25.4;
616
732
  const mm2PerPixel = 1 / (pixelsPerMm * pixelsPerMm);
617
733
  const masks = await buildMasks(page.original.raster, page.aligned.raster, ink, tolerance, faintInk);
734
+ // People sign past the box they are given, so each region claims the ink a little
735
+ // way outside it too; what it reports is still the region it was given.
618
736
  const regions = expected.map(e => ({
619
737
  id: e.id,
620
- rect: scaleRect(e, toPixels)
738
+ rect: scaleRect(e, toPixels),
739
+ claim: scaleRect(grow(e, expectedMargin), toPixels)
621
740
  }));
622
741
  const findChanges = (mask, minArea) => {
623
742
  const components = connectedComponents(mask).filter(c => c.pixels >= 2);
@@ -626,10 +745,11 @@ async function diffPage(page, expected = [], options = {}) {
626
745
  };
627
746
  const addedMask = difference(masks.scan, masks.originalDilated);
628
747
  const added = findChanges(addedMask, minChangeArea);
629
- const outside = added.filter(box => regions.every(r => inkShareInside(box, r.rect, masks) < regionOverlap));
748
+ // Taken together, since one stroke can run through two fields at once.
749
+ const outside = added.filter(box => inkShareInside(box, regions.map(r => r.claim), masks) < regionOverlap);
630
750
  const lost = findChanges(difference(masks.original, masks.scanDilated), minMissingArea);
631
751
  const expectedResults = regions.map((region, i) => {
632
- const measured = measureRegionInk(addedMask, region.rect, {
752
+ const measured = measureRegionInk(addedMask, region.claim, {
633
753
  mergeGap: Math.round(mergeGap * pixelsPerMm),
634
754
  minChangePixels: minChangeArea / mm2PerPixel,
635
755
  lineSpan: formLineSpan,
@@ -666,6 +786,11 @@ async function diffPage(page, expected = [], options = {}) {
666
786
  }
667
787
  };
668
788
  });
789
+ // What the ink does where the caller asked, changed or not.
790
+ const measured = probeInk(masks, probes, {
791
+ dpi,
792
+ units
793
+ });
669
794
  const truncated = outside.length > maxChanges || lost.length > maxChanges;
670
795
  const toChange = box => ({
671
796
  x: box.x / toPixels,
@@ -677,20 +802,37 @@ async function diffPage(page, expected = [], options = {}) {
677
802
  });
678
803
  const unexpected = outside.slice(0, maxChanges).map(box => toChange(box));
679
804
  const missing = lost.slice(0, maxChanges).map(box => toChange(box));
680
- const reported = [...regions.map((region, i) => ({
805
+ // The band first, so a region's own outline draws over it where they meet.
806
+ const margins = expectedMargin > 0 ? regions.map(region => ({
807
+ rect: region.claim,
808
+ color: EXPECTED_MARGIN
809
+ })) : [];
810
+ const verdicts = regions.map((region, i) => ({
681
811
  rect: grow(region.rect, 2),
682
812
  color: expectedResults[i].identified ? IDENTIFIED : NOT_IDENTIFIED
683
- })), ...outside.slice(0, maxChanges).map(box => ({
813
+ }));
814
+ const reported = [...margins, ...verdicts, ...outside.slice(0, maxChanges).map(box => ({
684
815
  rect: grow(box, 4),
685
816
  color: UNEXPECTED
686
817
  }))];
818
+ // On the original, every region is simply the area in question; the answers belong to the scan.
819
+ const asAsked = regions.map(region => ({
820
+ rect: grow(region.rect, 2),
821
+ color: REFERENCE
822
+ }));
687
823
  const diffRaster = paintOverlay(masks);
688
824
  if (annotate) annotateOverlay(diffRaster, reported);
689
825
  // 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 => ({
826
+ const losses = lost.slice(0, maxChanges).map(box => ({
691
827
  rect: grow(box, 4),
692
828
  color: MISSING
693
- }))], Math.max(2, Math.round(dpi / 72)), Math.max(4, Math.round(dpi / 12))) : null;
829
+ }));
830
+ const sideBySideRaster = sideBySide ? composeSideBySide(page.original.raster, page.aligned.raster, {
831
+ // Left: where the questions are, and the ink the scan lost, which is the original's.
832
+ original: [...asAsked, ...losses],
833
+ // Right: the answers.
834
+ scanned: [...reported, ...losses]
835
+ }, Math.max(2, Math.round(dpi / 72)), Math.max(4, Math.round(dpi / 12))) : null;
694
836
  const whole = measureRegion({
695
837
  id: '__page__',
696
838
  rect: {
@@ -712,6 +854,8 @@ async function diffPage(page, expected = [], options = {}) {
712
854
  format: output
713
855
  }),
714
856
  expected: expectedResults,
857
+ probes: measured,
858
+ masks: keepMasks ? masks : null,
715
859
  unexpected,
716
860
  missing,
717
861
  truncated,
@@ -741,16 +885,24 @@ function difference(a, b) {
741
885
  * Measured on ink, not on box area: a signature that overflows its box by a
742
886
  * flourish is still mostly inside it, while its bounding box may not be.
743
887
  */
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;
888
+ function inkShareInside(box, regions, masks) {
750
889
  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++;
890
+ const counted = new Set();
891
+ for (const region of regions) {
892
+ const left = Math.max(box.x, Math.floor(region.x));
893
+ const top = Math.max(box.y, Math.floor(region.y));
894
+ const right = Math.min(box.x + box.width, Math.ceil(region.x + region.width));
895
+ const bottom = Math.min(box.y + box.height, Math.ceil(region.y + region.height));
896
+ if (right <= left || bottom <= top) continue;
897
+ for (let y = top; y < bottom; y++) {
898
+ const row = y * masks.width;
899
+ for (let x = left; x < right; x++) {
900
+ // Regions may overlap once grown, and a pixel belongs to the box only once.
901
+ if (masks.scan.data[row + x] !== 1 || masks.originalDilated.data[row + x] !== 0 || counted.has(row + x)) continue;
902
+ counted.add(row + x);
903
+ inside++;
904
+ }
905
+ }
754
906
  }
755
907
  // The count here includes isolated pixels the component filter dropped from
756
908
  // box.pixels, so it can nudge past one.
@@ -779,5 +931,5 @@ function grow(rect, by) {
779
931
  };
780
932
  }
781
933
 
782
- export { IDENTIFIED, MISSING, NOT_IDENTIFIED, UNEXPECTED, annotateOverlay, buildMasks, compareRegions, composeSideBySide, connectedComponents, diffDocument, diffPage, diffPages, labelComponents, measureRegion, measureRegionInk, mergeBoxes, paintOverlay, renderDiff };
934
+ export { EXPECTED_MARGIN, IDENTIFIED, MISSING, NOT_IDENTIFIED, OVERLAY_DIFFERENT, OVERLAY_SHARED, REFERENCE, UNEXPECTED, UNSETTLED, annotateOverlay, buildMasks, compareRegions, composePanels, composeSideBySide, connectedComponents, diffDocument, diffPage, diffPages, labelComponents, measureRegion, measureRegionInk, mergeBoxes, paintOverlay, probeInk, renderDiff };
783
935
  //# sourceMappingURL=index.esm.js.map
@@ -1,14 +1,32 @@
1
- import type { Raster, Rect } from '@scanmate/ink';
1
+ import type { Raster, Rect, Rgba } from '@scanmate/ink';
2
2
  /**
3
3
  * Outline what the report says onto the overlay, so the picture and the numbers
4
- * can be checked against each other at a glance: green for an expected region
5
- * that was filled in, amber for one that was not, magenta around every change
6
- * nobody expected, and blue - the overlay's colour for lost ink - around ink
7
- * that went missing.
4
+ * can be checked against each other at a glance.
5
+ *
6
+ * One colour per answer, and the answers are few:
7
+ *
8
+ * | colour | |
9
+ * |---|---|
10
+ * | blue `#0017FC` | the area being asked about, as the original has it |
11
+ * | green `#00FC11` | an expected region that was filled in |
12
+ * | red `#FC0027` | one left empty, covered, or content that changed |
13
+ * | pink `#F500FC` | the band around a region where ink still counts as its own |
14
+ * | orange `#FF8A00` | ink added where nothing was expected |
15
+ * | cyan `#00C8FC` | printed ink the scan lost |
16
+ * | olive `#C8A000` | a disagreement nothing could settle |
8
17
  */
9
- export type Rgba = readonly [number, number, number, number];
18
+ export type { Rgba } from '@scanmate/ink';
19
+ /** The area in question, drawn on the original: a statement of where, not of what. */
20
+ export declare const REFERENCE: Rgba;
10
21
  export declare const IDENTIFIED: Rgba;
22
+ /** Empty, covered, or changed: the answers that need a person. */
11
23
  export declare const NOT_IDENTIFIED: Rgba;
24
+ /**
25
+ * Neither comparison could settle it. Not red, because red is a statement that
26
+ * something is wrong and this is a statement that nobody knows.
27
+ */
28
+ export declare const UNSETTLED: Rgba;
29
+ export declare const EXPECTED_MARGIN: Rgba;
12
30
  export declare const UNEXPECTED: Rgba;
13
31
  export declare const MISSING: Rgba;
14
32
  export interface Annotation {
@@ -1,12 +1,15 @@
1
1
  /** What changed on a page and whether it was supposed to: changed pixels grouped into reportable regions. */
2
- export { annotateOverlay, IDENTIFIED, MISSING, NOT_IDENTIFIED, UNEXPECTED } from './annotate-overlay.use-case.js';
2
+ export { annotateOverlay, EXPECTED_MARGIN, IDENTIFIED, MISSING, NOT_IDENTIFIED, REFERENCE, UNEXPECTED, UNSETTLED } from './annotate-overlay.use-case.js';
3
+ export { composePanels } from './side-by-side.use-case.js';
4
+ export type { Panel, SideAnnotations } from './side-by-side.use-case.js';
3
5
  export type { Annotation, Rgba } from './annotate-overlay.use-case.js';
4
6
  export { connectedComponents, labelComponents } from './connected-components.use-case.js';
7
+ export { inkWithin, probeInk } from './probe-ink.use-case.js';
5
8
  export type { Component, LabelledComponents, LabelOptions } from './connected-components.use-case.js';
6
9
  export { diffPage, diffPages } from './diff-pages.use-case.js';
7
10
  export { mergeBoxes } from './merge-boxes.use-case.js';
8
11
  export type { MergedBox } from './merge-boxes.use-case.js';
9
- export type { Change, CoordinateUnits, DiffOptions, ExpectedChange, ExpectedResult, PageDiff, RegionInkMetrics } from './page-diff.contract.js';
12
+ export type { Change, CoordinateUnits, DiffOptions, ExpectedChange, ExpectedResult, InkProbe, PageDiff, ProbeRect, RegionInkMetrics } from './page-diff.contract.js';
10
13
  export { measureRegionInk } from './region-ink.use-case.js';
11
14
  export type { RegionInk, RegionInkOptions } from './region-ink.use-case.js';
12
15
  export { composeSideBySide } from './side-by-side.use-case.js';
@@ -1,4 +1,5 @@
1
1
  import type { ImageFormat, InkOptions, ProgressCallback, Raster, Rect } from '@scanmate/ink';
2
+ import type { Masks } from '../region-comparison/index.js';
2
3
  /**
3
4
  * Coordinates for regions going in and changes coming out.
4
5
  *
@@ -22,6 +23,37 @@ export interface ExpectedChange {
22
23
  export interface DiffOptions {
23
24
  /** Units of `ExpectedChange` rectangles and of every rectangle reported back. Default `'points'`. */
24
25
  units?: CoordinateUnits;
26
+ /**
27
+ * Places to measure the ink at, whether or not anything changed there.
28
+ *
29
+ * A reading that disagrees with the original is not by itself a change: OCR
30
+ * misreads small print, and sideways print, and print on a shaded bar. Asking
31
+ * what the ink does at the very place the reading disagrees settles it - ink
32
+ * that is identical there means the characters are identical, whatever was
33
+ * read. `@scanmate/audit` passes every text difference through here.
34
+ */
35
+ probes?: readonly ProbeRect[];
36
+ /**
37
+ * Keep the ink masks on the result, so the caller can probe places it does
38
+ * not know about yet.
39
+ *
40
+ * The places worth probing are the ones the reading disputes, and the reading
41
+ * runs alongside this rather than before it - so `probes` cannot name them.
42
+ * Masks are four binary images the size of the page, about 9 MB for A4 at
43
+ * 150 dpi, so a caller is expected to drop them as soon as it has asked.
44
+ */
45
+ keepMasks?: boolean;
46
+ /**
47
+ * How far outside an expected region its ink may still lie, in `units`. Default `6`
48
+ * (2 mm at 72 points to the inch).
49
+ *
50
+ * People sign past the box they are given - a descender below the rule, a flourish
51
+ * out to the side - and that is the signature, not a mark someone made elsewhere. The
52
+ * region claims the ink within this band and measures it, while still reporting the
53
+ * rectangle it was given. Ink inside the band of any expected region counts towards
54
+ * them all, so one stroke crossing two fields is not left over as unexpected.
55
+ */
56
+ expectedMargin?: number;
25
57
  /** Pixels the original's ink is fattened by before diffing, to absorb sub-pixel misalignment. Default `2`. */
26
58
  tolerance?: number;
27
59
  /**
@@ -163,6 +195,21 @@ export interface RegionInkMetrics {
163
195
  formLines: number;
164
196
  }
165
197
  /** A change found where nothing was expected, or ink that went missing. */
198
+ /** A place to measure the ink at; `page` selects the page when several are compared. */
199
+ export type ProbeRect = Rect & {
200
+ page?: number;
201
+ };
202
+ /** What the ink does inside one place that was asked about. */
203
+ export interface InkProbe {
204
+ /** The place asked about, in `units`. */
205
+ rect: Rect;
206
+ /** New ink there, in square millimetres. */
207
+ addedInk: number;
208
+ /** Printed ink lost there, in square millimetres. */
209
+ lostInk: number;
210
+ /** Ink the two pages agree on there, in square millimetres. */
211
+ sharedInk: number;
212
+ }
166
213
  export interface Change {
167
214
  /** Bounding box, in the requested units. */
168
215
  x: number;
@@ -176,13 +223,17 @@ export interface Change {
176
223
  }
177
224
  export interface PageDiff {
178
225
  page: number;
179
- /** The overlay: red added, blue lost, grey agreed - annotated when asked. */
226
+ /** The overlay: violet where the ink differs, grey where it agrees - annotated when asked. */
180
227
  diffRaster: Raster;
181
228
  diffImage: Uint8Array | null;
182
229
  /** Original and aligned scan side by side with the report boxed on both, when `sideBySide` was asked for. */
183
230
  sideBySideRaster: Raster | null;
184
231
  sideBySideImage: Uint8Array | null;
185
232
  expected: ExpectedResult[];
233
+ /** The ink at each place `probes` asked about, in the same order. */
234
+ probes: InkProbe[];
235
+ /** The ink masks, when `keepMasks` asked for them: for `probeInk`, then dropped. */
236
+ masks: Masks | null;
186
237
  /** New ink outside every expected region, merged into one box per change. */
187
238
  unexpected: Change[];
188
239
  /**
@@ -0,0 +1,32 @@
1
+ import type { Rect } from '@scanmate/ink';
2
+ import type { Masks } from '../region-comparison/index.js';
3
+ import type { CoordinateUnits, InkProbe } from './page-diff.contract.js';
4
+ /**
5
+ * What the ink does inside places the caller names, changed or not.
6
+ *
7
+ * The rest of this subfeature starts from a change and asks where it is. This
8
+ * asks the opposite question - *how much ink is here?* - and it exists to
9
+ * settle an argument. A reading that disagrees with the original is not by
10
+ * itself a change: OCR misreads small, faint and sideways print, and identical
11
+ * ink under a word means identical characters whatever they were read as.
12
+ *
13
+ * It is separate from `diffPage` because the places worth asking about are
14
+ * usually not known until the page has been read, which happens alongside the
15
+ * pixel comparison rather than before it.
16
+ *
17
+ * @param masks - The ink masks of the pair, from `diffPage` or `buildMasks`.
18
+ * @param rects - Where to measure, in `units`.
19
+ * @param options - How to read `rects`, and the resolution they are in.
20
+ * @returns One probe per rectangle, in the same order, in square millimetres.
21
+ */
22
+ export declare function probeInk(masks: Masks, rects: readonly Rect[], options?: {
23
+ dpi?: number;
24
+ units?: CoordinateUnits;
25
+ }): InkProbe[];
26
+ /** Added, lost and shared ink inside one rectangle of the page, in pixels. */
27
+ export declare function inkWithin(rect: Rect, masks: Masks): {
28
+ added: number;
29
+ lost: number;
30
+ shared: number;
31
+ };
32
+ //# sourceMappingURL=probe-ink.use-case.d.ts.map
@@ -1,4 +1,24 @@
1
1
  import type { Raster } from '@scanmate/ink';
2
2
  import type { Annotation } from './annotate-overlay.use-case.js';
3
- export declare function composeSideBySide(original: Raster, aligned: Raster, annotations: readonly Annotation[], thickness: number, gutter: number): Raster;
3
+ /** What to draw on each half, when the two differ. An array draws the same on both. */
4
+ export interface SideAnnotations {
5
+ original?: readonly Annotation[];
6
+ scanned?: readonly Annotation[];
7
+ }
8
+ /** One panel of a composition: an image, and what to draw on it. */
9
+ export interface Panel {
10
+ raster: Raster;
11
+ annotations?: readonly Annotation[];
12
+ }
13
+ /**
14
+ * Several images in a row on one canvas, each with its own boxes, separated by
15
+ * a grey gutter - the original, the scan, and the overlay of the two, say.
16
+ *
17
+ * @param panels - The images, left to right.
18
+ * @param thickness - Outline thickness, in pixels.
19
+ * @param gutter - Pixels between panels.
20
+ * @returns One image, as wide as the panels and their gutters.
21
+ */
22
+ export declare function composePanels(panels: readonly Panel[], thickness: number, gutter: number): Raster;
23
+ export declare function composeSideBySide(original: Raster, aligned: Raster, annotations: readonly Annotation[] | SideAnnotations, thickness: number, gutter: number): Raster;
4
24
  //# sourceMappingURL=side-by-side.use-case.d.ts.map
@@ -15,11 +15,11 @@
15
15
  * different part of the page in each image.
16
16
  */
17
17
  export { diffPage, diffPages } from './change-detection/index.js';
18
- export type { Change, CoordinateUnits, DiffOptions, ExpectedChange, ExpectedResult, PageDiff, RegionInkMetrics } from './change-detection/index.js';
18
+ export type { Change, CoordinateUnits, DiffOptions, ExpectedChange, ExpectedResult, InkProbe, PageDiff, ProbeRect, RegionInkMetrics } from './change-detection/index.js';
19
19
  export { compareRegions, diffDocument, renderDiff } from './region-comparison/index.js';
20
20
  export type { DocumentDiff, Region, RegionOptions, RegionReport } from './region-comparison/index.js';
21
- export { annotateOverlay, composeSideBySide, connectedComponents, IDENTIFIED, labelComponents, measureRegionInk, mergeBoxes, MISSING, NOT_IDENTIFIED, UNEXPECTED } from './change-detection/index.js';
22
- export type { Annotation, Component, LabelledComponents, LabelOptions, MergedBox, RegionInk, RegionInkOptions, Rgba } from './change-detection/index.js';
23
- export { buildMasks, measureRegion, paintOverlay } from './region-comparison/index.js';
21
+ export { annotateOverlay, composePanels, composeSideBySide, connectedComponents, EXPECTED_MARGIN, IDENTIFIED, labelComponents, measureRegionInk, mergeBoxes, MISSING, NOT_IDENTIFIED, probeInk, REFERENCE, UNEXPECTED, UNSETTLED } from './change-detection/index.js';
22
+ export type { Annotation, Component, Panel, LabelledComponents, LabelOptions, MergedBox, RegionInk, RegionInkOptions, Rgba } from './change-detection/index.js';
23
+ export { buildMasks, measureRegion, OVERLAY_DIFFERENT, OVERLAY_SHARED, paintOverlay } from './region-comparison/index.js';
24
24
  export type { Masks } from './region-comparison/index.js';
25
25
  //# sourceMappingURL=index.d.ts.map
@@ -4,5 +4,5 @@ export { compareRegions, diffDocument, measureRegion } from './compare-regions.u
4
4
  export { buildMasks } from './ink-masks.use-case.js';
5
5
  export type { Masks } from './ink-masks.use-case.js';
6
6
  export type { DocumentDiff, Region, RegionOptions, RegionReport } from './region.model.js';
7
- export { paintOverlay, renderDiff } from './render-diff.use-case.js';
7
+ export { OVERLAY_DIFFERENT, OVERLAY_SHARED, paintOverlay, renderDiff } from './render-diff.use-case.js';
8
8
  //# sourceMappingURL=index.d.ts.map
@@ -1,15 +1,28 @@
1
- import type { ImageInput, Raster } from '@scanmate/ink';
1
+ import type { ImageInput, Raster, Rgba } from '@scanmate/ink';
2
2
  import type { Masks } from './ink-masks.use-case.js';
3
3
  import type { RegionOptions } from './region.model.js';
4
4
  /**
5
5
  * An RGBA overlay of the comparison, for looking at with your own eyes.
6
6
  *
7
- * Red is ink the scan added, blue is ink it lost, grey is ink both agree on.
8
- * A correctly aligned pair of a signed form is almost entirely grey with a red
9
- * signature; a misaligned one is red and blue confetti along every stroke,
10
- * which is the fastest way to tell the two failures apart.
7
+ * Violet is ink the two pages do not share - added by the scan or lost from the
8
+ * print, which the picture does not distinguish; grey is ink they agree on. A
9
+ * correctly aligned pair of a signed form is almost entirely grey with a violet
10
+ * signature; a misaligned one is violet confetti along every stroke, which is
11
+ * the fastest way to tell the two failures apart.
12
+ *
13
+ * Which side the ink came from is a question for the report, where it is
14
+ * measured separately and in millimetres. Here it would be a second colour
15
+ * carrying a distinction the eye does not need at this zoom.
11
16
  */
12
17
  export declare function renderDiff(original: ImageInput, aligned: ImageInput, options?: RegionOptions): Promise<Raster>;
13
- /** The overlay, from masks already built. Red added, blue lost, grey agreed, white paper. */
18
+ /**
19
+ * Ink the two pages do not share. Violet rather than red, because red means
20
+ * "changed" on every annotated page in the pipeline and one colour cannot mean
21
+ * two things.
22
+ */
23
+ export declare const OVERLAY_DIFFERENT: Rgba;
24
+ /** Ink the two pages agree on. */
25
+ export declare const OVERLAY_SHARED: Rgba;
26
+ /** The overlay, from masks already built. Violet where they differ, grey where they agree, white paper. */
14
27
  export declare function paintOverlay(masks: Masks): Raster;
15
28
  //# sourceMappingURL=render-diff.use-case.d.ts.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scanmate/diff",
3
- "version": "0.0.2",
3
+ "version": "0.1.0",
4
4
  "description": "What changed between an original and its aligned scan: expected regions filled in, unexpected marks, lost ink, and a side-by-side evidence image.",
5
5
  "license": "MIT",
6
6
  "author": "Eduardo Russo",
@@ -39,12 +39,12 @@
39
39
  "!**/*.js.map"
40
40
  ],
41
41
  "dependencies": {
42
- "@scanmate/ink": "^0.0.2"
42
+ "@scanmate/ink": "^0.1.0"
43
43
  },
44
44
  "publishConfig": {
45
45
  "access": "public"
46
46
  },
47
47
  "devDependencies": {
48
- "@scanmate/align": "^0.0.2"
48
+ "@scanmate/align": "^0.1.0"
49
49
  }
50
50
  }