@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 +58 -158
- package/dist/index.esm.js +226 -74
- package/dist/src/change-detection/annotate-overlay.use-case.d.ts +24 -6
- package/dist/src/change-detection/index.d.ts +5 -2
- package/dist/src/change-detection/page-diff.contract.d.ts +52 -1
- package/dist/src/change-detection/probe-ink.use-case.d.ts +32 -0
- package/dist/src/change-detection/side-by-side.use-case.d.ts +21 -1
- package/dist/src/index.d.ts +4 -4
- package/dist/src/region-comparison/index.d.ts +1 -1
- package/dist/src/region-comparison/render-diff.use-case.d.ts +19 -6
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -1,183 +1,83 @@
|
|
|
1
|
-

|
|
1
|
+

|
|
2
2
|
|
|
3
3
|
# `@scanmate/diff`
|
|
4
4
|
|
|
5
|
-
|
|
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
|
-
|
|
7
|
+

|
|
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
|
-
##
|
|
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
|
-
|
|
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 {
|
|
40
|
-
import {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
const
|
|
46
|
-
{ id: 'signature',
|
|
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
|
-
|
|
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
|
-
|
|
45
|
+
## Deciding a region
|
|
154
46
|
|
|
155
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
81
|
+
## How it decides
|
|
182
82
|
|
|
183
|
-
|
|
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,
|
|
1
|
+
import { createRaster, decodeImage, binarize, inkMap, toGrayscale, otsuThreshold, dilate, coverage, encodeImage } from '@scanmate/ink';
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
const
|
|
5
|
-
const
|
|
6
|
-
|
|
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
|
-
*
|
|
308
|
-
*
|
|
309
|
-
*
|
|
310
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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),
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
}))
|
|
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
|
|
826
|
+
const losses = lost.slice(0, maxChanges).map(box => ({
|
|
691
827
|
rect: grow(box, 4),
|
|
692
828
|
color: MISSING
|
|
693
|
-
}))
|
|
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,
|
|
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
|
-
|
|
752
|
-
|
|
753
|
-
|
|
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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
package/dist/src/index.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
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
|
|
48
|
+
"@scanmate/align": "^0.1.0"
|
|
49
49
|
}
|
|
50
50
|
}
|