@scanmate/image-fix 0.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +291 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.esm.js +2786 -0
- package/dist/index.esm.js.map +1 -0
- package/dist/src/index.d.ts +58 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/lib/align.d.ts +136 -0
- package/dist/src/lib/align.d.ts.map +1 -0
- package/dist/src/lib/analysis/content.d.ts +50 -0
- package/dist/src/lib/analysis/content.d.ts.map +1 -0
- package/dist/src/lib/analysis/score.d.ts +18 -0
- package/dist/src/lib/analysis/score.d.ts.map +1 -0
- package/dist/src/lib/estimate/coarse.d.ts +43 -0
- package/dist/src/lib/estimate/coarse.d.ts.map +1 -0
- package/dist/src/lib/estimate/features.d.ts +72 -0
- package/dist/src/lib/estimate/features.d.ts.map +1 -0
- package/dist/src/lib/estimate/match.d.ts +38 -0
- package/dist/src/lib/estimate/match.d.ts.map +1 -0
- package/dist/src/lib/estimate/models.d.ts +41 -0
- package/dist/src/lib/estimate/models.d.ts.map +1 -0
- package/dist/src/lib/estimate/phaseCorrelation.d.ts +34 -0
- package/dist/src/lib/estimate/phaseCorrelation.d.ts.map +1 -0
- package/dist/src/lib/estimate/ransac.d.ts +36 -0
- package/dist/src/lib/estimate/ransac.d.ts.map +1 -0
- package/dist/src/lib/image/codec.d.ts +30 -0
- package/dist/src/lib/image/codec.d.ts.map +1 -0
- package/dist/src/lib/image/gray.d.ts +67 -0
- package/dist/src/lib/image/gray.d.ts.map +1 -0
- package/dist/src/lib/image/raster.d.ts +19 -0
- package/dist/src/lib/image/raster.d.ts.map +1 -0
- package/dist/src/lib/image/resize.d.ts +32 -0
- package/dist/src/lib/image/resize.d.ts.map +1 -0
- package/dist/src/lib/image/warp.d.ts +28 -0
- package/dist/src/lib/image/warp.d.ts.map +1 -0
- package/dist/src/lib/math/fft.d.ts +15 -0
- package/dist/src/lib/math/fft.d.ts.map +1 -0
- package/dist/src/lib/math/linalg.d.ts +34 -0
- package/dist/src/lib/math/linalg.d.ts.map +1 -0
- package/dist/src/lib/math/matrix.d.ts +75 -0
- package/dist/src/lib/math/matrix.d.ts.map +1 -0
- package/dist/src/lib/math/random.d.ts +13 -0
- package/dist/src/lib/math/random.d.ts.map +1 -0
- package/dist/src/lib/regions.d.ts +75 -0
- package/dist/src/lib/regions.d.ts.map +1 -0
- package/dist/src/lib/testing/synthetic.d.ts +73 -0
- package/dist/src/lib/testing/synthetic.d.ts.map +1 -0
- package/dist/src/lib/types.d.ts +86 -0
- package/dist/src/lib/types.d.ts.map +1 -0
- package/package.json +59 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Eduardo Russo
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
# @scanmate/image-fix
|
|
2
|
+
|
|
3
|
+
Put a scanned page back on top of the page it came from.
|
|
4
|
+
|
|
5
|
+
You render page 1 of a PDF, you send the document out, and a photograph or a
|
|
6
|
+
flatbed scan of it comes back — turned a couple of degrees, at some other
|
|
7
|
+
resolution, cropped differently, under a shadow. Two questions are then almost
|
|
8
|
+
impossible to answer:
|
|
9
|
+
|
|
10
|
+
1. **Was any of the printed text changed?** OCR both and diff, in principle.
|
|
11
|
+
In practice, an OCR engine reads a crooked page at a different scale as a
|
|
12
|
+
different document: line grouping differs, so the diff is noise.
|
|
13
|
+
2. **Was the box at (x, y) signed?** That question is about a fixed rectangle,
|
|
14
|
+
and a rectangle only means something once both images agree where (x, y) is.
|
|
15
|
+
|
|
16
|
+
This library answers the geometry so you can answer those. Give it the original
|
|
17
|
+
and the scan; it gives you the scan resampled onto the original's canvas, plus
|
|
18
|
+
the transform it used and how much it trusts it.
|
|
19
|
+
|
|
20
|
+
**Pure JavaScript. No native bindings.** OpenCV and `sharp` are both off the
|
|
21
|
+
table when the target is an Azure Function app: they compile per platform, so
|
|
22
|
+
the bytes that work on your laptop are not the bytes that run in the cloud, and
|
|
23
|
+
a deployment that skips the rebuild fails at *import* time — after the cold
|
|
24
|
+
start, in production. Everything here runs the same everywhere.
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
npm install @scanmate/image-fix
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Quick start
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
import { readFile } from 'node:fs/promises'
|
|
36
|
+
import { alignScan, compareRegions } from '@scanmate/image-fix'
|
|
37
|
+
|
|
38
|
+
const original = await readFile('contract.page1.png') // rendered from the PDF
|
|
39
|
+
const scanned = await readFile('returned.jpg') // what came back
|
|
40
|
+
|
|
41
|
+
const result = alignScan(original, scanned)
|
|
42
|
+
|
|
43
|
+
console.log(result.confidence) // 0.96
|
|
44
|
+
console.log(result.transform.rotationDeg) // -2.68
|
|
45
|
+
console.log(result.transform.scaleX) // 1.449
|
|
46
|
+
|
|
47
|
+
// result.raster is on the original's canvas, so PDF coordinates still hold.
|
|
48
|
+
const [signature] = compareRegions(original, result.raster, [
|
|
49
|
+
{ id: 'signature', rect: { x: 76, y: 905, width: 420, height: 78 } },
|
|
50
|
+
])
|
|
51
|
+
|
|
52
|
+
console.log(signature.filled) // true
|
|
53
|
+
console.log(signature.added) // 0.056 - 5.6% of the box is ink that is not in the original
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`result.image` is the aligned page as PNG bytes, ready to hand to an OCR
|
|
57
|
+
engine. `result.raster` is the same thing undecoded, if your OCR engine takes
|
|
58
|
+
raw RGBA.
|
|
59
|
+
|
|
60
|
+
## How it works
|
|
61
|
+
|
|
62
|
+
```text
|
|
63
|
+
decode ─► ink ─► coarse guess ─► rough warp ─► ORB features ─► RANSAC ─► warp
|
|
64
|
+
scale + skew + matching + model
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
> What follows is the reasoning. For the full algorithm - the formulae, every
|
|
68
|
+
> constant, the coordinate-frame algebra and the measured error - see
|
|
69
|
+
> [`documentation/fix.md`](https://github.com/russoedu/scanmate/blob/main/documentation/fix.md).
|
|
70
|
+
|
|
71
|
+
### 1. Ink, not greyscale
|
|
72
|
+
|
|
73
|
+
A scan differs from its source in ways that have nothing to do with geometry:
|
|
74
|
+
the lamp is brighter in the middle, the phone cast a shadow down one side, the
|
|
75
|
+
JPEG quantiser smeared the strokes. So nothing downstream looks at greyscale.
|
|
76
|
+
It looks at **ink**: greyscale divided by its own slowly varying background,
|
|
77
|
+
then inverted. Near zero on paper, near one on print, whatever the lighting did.
|
|
78
|
+
|
|
79
|
+
Division, not subtraction, because illumination is multiplicative — a shadow
|
|
80
|
+
halves what reaches the sensor, it does not subtract a constant. Think of it as
|
|
81
|
+
reading the page through tracing paper: you lose the tint of the paper and the
|
|
82
|
+
angle of the lamp, and you keep the writing.
|
|
83
|
+
|
|
84
|
+
### 2. A coarse guess, because descriptors are not scale invariant
|
|
85
|
+
|
|
86
|
+
Binary descriptors compare pixels at fixed offsets, so a corner at 300 dpi and
|
|
87
|
+
the same corner at 150 dpi produce two unrelated bit strings. Something has to
|
|
88
|
+
establish roughly how big the scan is before matching can work, and nothing in
|
|
89
|
+
a JPEG header says.
|
|
90
|
+
|
|
91
|
+
So the library guesses three ways and lets the pixels judge:
|
|
92
|
+
|
|
93
|
+
| strategy | assumption | when it wins |
|
|
94
|
+
| --- | --- | --- |
|
|
95
|
+
| `frame` | the scan is the whole page, so the frames correspond | edge-to-edge scans |
|
|
96
|
+
| `content` | the *printing* corresponds | scans with different margins |
|
|
97
|
+
| `deskew` | measure each page's own skew, then match the printing straight | usually |
|
|
98
|
+
|
|
99
|
+
Skew is measured by rotating until the rows of text stack up: at the right
|
|
100
|
+
angle every line falls into one bin of the projection histogram and the profile
|
|
101
|
+
is a comb of tall spikes; a degree off and each line smears across several.
|
|
102
|
+
|
|
103
|
+
Each guess gets a phase-correlation nudge for leftover translation, and all of
|
|
104
|
+
them are warped and scored on ink correlation. Guessing several times and
|
|
105
|
+
measuring beats one clever guess, and at 512 px each attempt is nearly free.
|
|
106
|
+
|
|
107
|
+
### 3. Features, on the *corrected* scan
|
|
108
|
+
|
|
109
|
+
FAST corners, oriented by the intensity centroid of their patch, described by
|
|
110
|
+
256 steered BRIEF bits — an ORB, written out, because the native one is
|
|
111
|
+
unavailable here.
|
|
112
|
+
|
|
113
|
+
Running this *after* the coarse warp is what makes it work. The two images are
|
|
114
|
+
now at the same scale and nearly the same angle, so a fixed-offset descriptor
|
|
115
|
+
describes the same thing on both, and any correspondence that jumps across the
|
|
116
|
+
page can be thrown out on sight. RANSAC then recovers only the small residual,
|
|
117
|
+
which is composed onto the coarse transform.
|
|
118
|
+
|
|
119
|
+
RANSAC is not optional. A page of text is full of things that genuinely look
|
|
120
|
+
identical — every lowercase "e", every corner of every table cell — so matching
|
|
121
|
+
produces a lot of confident nonsense, and least squares over all of it is
|
|
122
|
+
dragged wherever the wrong matches point. RANSAC ignores the average: it fits
|
|
123
|
+
the smallest sample that determines a transform and counts how many of the rest
|
|
124
|
+
agree. Measured on the test suite, it recovers the right answer with 60% of the
|
|
125
|
+
matches wrong.
|
|
126
|
+
|
|
127
|
+
If the feature stage comes up short — a near-blank form has few corners — the
|
|
128
|
+
coarse estimate is returned alone and `result.method` says `'coarse'`.
|
|
129
|
+
|
|
130
|
+
### 4. The warp
|
|
131
|
+
|
|
132
|
+
Inverse mapping: walk each output pixel and reach back into the scan, rather
|
|
133
|
+
than pushing scan pixels forward, which leaves the output full of pinholes
|
|
134
|
+
wherever the transform stretches, like spray-painting through a rotated
|
|
135
|
+
stencil. Minification pre-filters first, so shrinking a 300 dpi scan averages
|
|
136
|
+
the strokes instead of beating against them into moiré.
|
|
137
|
+
|
|
138
|
+
## Choosing a model
|
|
139
|
+
|
|
140
|
+
The `model` option is a bet about what the scan physically went through. Fewer
|
|
141
|
+
degrees of freedom is harder to fool; more is more expressive.
|
|
142
|
+
|
|
143
|
+
| model | DOF | use when |
|
|
144
|
+
| --- | --- | --- |
|
|
145
|
+
| `similarity` (default) | 4 | a flatbed or sheet-fed scan. The page is flat, so it can only be turned, resized and moved. |
|
|
146
|
+
| `affine` | 6 | one axis is stretched — a feed roller slipping. |
|
|
147
|
+
| `homography` | 8 | a photograph taken off-axis, where the far edge of the page really is smaller than the near one. |
|
|
148
|
+
|
|
149
|
+
Use the simplest one the physical situation allows. A homography fitted to a
|
|
150
|
+
flatbed scan has four extra parameters to spend on fitting noise.
|
|
151
|
+
|
|
152
|
+
## API
|
|
153
|
+
|
|
154
|
+
### `alignScan(original, scanned, options?): AlignResult`
|
|
155
|
+
|
|
156
|
+
Synchronous, deliberately. All of it is CPU-bound with no I/O to wait on; an
|
|
157
|
+
`async` signature would imply the event loop is free during the call, and it is
|
|
158
|
+
not. To align several pages at once, put it in a worker thread.
|
|
159
|
+
|
|
160
|
+
Inputs are PNG or JPEG bytes (`Buffer`, `Uint8Array`, `ArrayBuffer`) or an
|
|
161
|
+
already-decoded `Raster`, so you can decode once and align one page against
|
|
162
|
+
several scans.
|
|
163
|
+
|
|
164
|
+
```ts
|
|
165
|
+
interface AlignResult {
|
|
166
|
+
raster: Raster // the scan, on the original's canvas
|
|
167
|
+
image: Uint8Array | null // encoded per options.output
|
|
168
|
+
matrix: Matrix3 // original coordinates -> scanned coordinates
|
|
169
|
+
inverse: Matrix3 // scanned -> original
|
|
170
|
+
transform: TransformSummary // scale, rotation, shear, translation
|
|
171
|
+
confidence: number // 0..1, from ink correlation after warping
|
|
172
|
+
method: 'features' | 'coarse'
|
|
173
|
+
diagnostics: AlignDiagnostics
|
|
174
|
+
}
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
`confidence` measures agreement in the *output*, not confidence in the process.
|
|
178
|
+
Above ~0.6 is a solid match on a printed page; below ~0.3, treat the alignment
|
|
179
|
+
as failed and do not trust any region report built on it.
|
|
180
|
+
|
|
181
|
+
Useful options (all optional):
|
|
182
|
+
|
|
183
|
+
| option | default | what it does |
|
|
184
|
+
| --- | --- | --- |
|
|
185
|
+
| `model` | `'similarity'` | see above |
|
|
186
|
+
| `workingSize` | `1400` | longest side for feature detection. Bigger is more precise and quadratically slower |
|
|
187
|
+
| `coarseSize` | `512` | longest side for the coarse guess |
|
|
188
|
+
| `maxFeatures` | `1200` | keypoint budget per image |
|
|
189
|
+
| `ransacThreshold` | `3` | inlier radius, in working-resolution pixels |
|
|
190
|
+
| `minInliers` | `12` | below this the feature stage is not trusted |
|
|
191
|
+
| `maxSkewDeg` | `12` | largest per-page skew considered |
|
|
192
|
+
| `interpolation` | `'bilinear'` | or `'bicubic'` (sharper strokes when upscaling), `'nearest'` |
|
|
193
|
+
| `background` | white | RGBA fill where the scan does not cover the canvas |
|
|
194
|
+
| `output` | `'png'` | `'jpeg'` or `'none'`. Encoding is most of the cost on a big page |
|
|
195
|
+
| `seed` | fixed | seeds RANSAC and the descriptor pattern, so the same bytes give the same matrix |
|
|
196
|
+
|
|
197
|
+
### `compareRegions(original, aligned, regions, options?): RegionReport[]`
|
|
198
|
+
|
|
199
|
+
Answers "was this box filled in?" as arithmetic: count the ink inside the
|
|
200
|
+
rectangle that is in the scan and not in the original.
|
|
201
|
+
|
|
202
|
+
```ts
|
|
203
|
+
const reports = compareRegions(original, result.raster, [
|
|
204
|
+
{ id: 'signature', rect: { x: 76, y: 905, width: 420, height: 78 } },
|
|
205
|
+
{ id: 'consent', rect: { x: 76, y: 800, width: 18, height: 18 }, threshold: 0.05 },
|
|
206
|
+
])
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
Rectangles are in the **original's** coordinates — which is the whole point of
|
|
210
|
+
aligning first. Passing a raw scan produces confident nonsense, so the function
|
|
211
|
+
refuses two images on different canvases outright.
|
|
212
|
+
|
|
213
|
+
The `tolerance` option (default 2 px) fattens the original's ink before
|
|
214
|
+
diffing. Alignment is good to about a pixel, never to zero, and printed text is
|
|
215
|
+
mostly edges, so without it a half-pixel shift lights up the outline of every
|
|
216
|
+
character as new ink — the way a proofreader ignores a letter sitting a hair
|
|
217
|
+
off the baseline. What it cannot absorb is a signature, which is ink where the
|
|
218
|
+
original has none.
|
|
219
|
+
|
|
220
|
+
`filled` is `added >= threshold` (default 2% of the region). `score` is `added`
|
|
221
|
+
as a multiple of that threshold, clamped to 1, if you want a number rather than
|
|
222
|
+
a boolean.
|
|
223
|
+
|
|
224
|
+
### `diffDocument(original, aligned, regions?, options?)`
|
|
225
|
+
|
|
226
|
+
Page-wide added/removed ink plus the same per-region detail, in one pass.
|
|
227
|
+
A useful pre-check before OCR: if `added` across the whole page is under ~2%,
|
|
228
|
+
nothing was written on it.
|
|
229
|
+
|
|
230
|
+
### `renderDiff(original, aligned, options?): Raster`
|
|
231
|
+
|
|
232
|
+
An RGBA overlay to look at with your own eyes. Red is ink the scan added, blue
|
|
233
|
+
is ink it lost, grey is ink both agree on. A correctly aligned signed form is
|
|
234
|
+
almost entirely grey with a red signature; a misaligned one is red and blue
|
|
235
|
+
confetti along every stroke — the fastest way to tell those two failures apart.
|
|
236
|
+
|
|
237
|
+
### Building blocks
|
|
238
|
+
|
|
239
|
+
The stages are exported individually, for pipelines that need to stop part way:
|
|
240
|
+
`decodeImage`, `encodeImage`, `toGrayscale`, `inkMap`, `binarize`, `dilate`,
|
|
241
|
+
`estimateSkew`, `contentExtent`, `estimateCoarse`, `detectAndDescribe`,
|
|
242
|
+
`matchFeatures`, `fitSimilarity` / `fitAffine` / `fitHomography`, `ransac`,
|
|
243
|
+
`phaseCorrelate`, `warpRaster`, `warpGray`, and the `Matrix3` helpers.
|
|
244
|
+
|
|
245
|
+
### Test fixtures
|
|
246
|
+
|
|
247
|
+
`createSyntheticDocument`, `simulateScan`, `drawSignature` and `drawTick`
|
|
248
|
+
generate a printed form and a realistically bad scan of it, with the ground
|
|
249
|
+
truth matrix returned alongside. They are exported rather than kept in the test
|
|
250
|
+
folder because the same trick smoke-tests a deployment without shipping sample
|
|
251
|
+
scans:
|
|
252
|
+
|
|
253
|
+
```ts
|
|
254
|
+
import { alignScan, createSyntheticDocument, simulateScan } from '@scanmate/image-fix'
|
|
255
|
+
|
|
256
|
+
const page = createSyntheticDocument()
|
|
257
|
+
const scan = simulateScan(page.raster, { rotationDeg: -2.7, scale: 1.45, noise: 0.02 })
|
|
258
|
+
const result = alignScan(page.raster, scan.raster, { output: 'none' })
|
|
259
|
+
|
|
260
|
+
// result.matrix should agree with scan.matrix to about a pixel.
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
## Limits
|
|
264
|
+
|
|
265
|
+
- **One page at a time.** It aligns a scan to a page, not a multi-page PDF to a
|
|
266
|
+
multi-page scan. Split them first.
|
|
267
|
+
- **The page must be recognisably the same page.** It is registration, not
|
|
268
|
+
retrieval: it will happily align the wrong document badly and tell you so
|
|
269
|
+
through a low `confidence`. Check that number.
|
|
270
|
+
- **Flat pages only.** A creased or curled page needs a non-rigid warp;
|
|
271
|
+
`homography` will get the plane right and leave the curl.
|
|
272
|
+
- **Not scale-free.** `maxScaleRatio` (default 6) bounds how far apart the two
|
|
273
|
+
resolutions may be. Beyond that, resample first.
|
|
274
|
+
- **Blank pages degrade gracefully, not magically.** With almost no printing
|
|
275
|
+
there is nothing to register on; you get `method: 'coarse'` and a low
|
|
276
|
+
confidence, which is the honest answer.
|
|
277
|
+
|
|
278
|
+
## Performance
|
|
279
|
+
|
|
280
|
+
Single-threaded, on one core: a 850x1100 original against a 1400x1800 scan runs
|
|
281
|
+
in roughly 1.6 s, of which the feature stage is about half. Lower `workingSize`
|
|
282
|
+
to trade accuracy for speed; set `output: 'none'` if you are going to hand
|
|
283
|
+
`raster` straight to something else, since PNG encoding a big page is a
|
|
284
|
+
substantial share of the total.
|
|
285
|
+
|
|
286
|
+
Memory peaks at a few copies of the largest image. Nothing here streams, so a
|
|
287
|
+
100 megapixel scan will hurt.
|
|
288
|
+
|
|
289
|
+
## Licence
|
|
290
|
+
|
|
291
|
+
MIT
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./src/index.js";
|