@scanmate/image-fix 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +291 -0
  3. package/dist/index.d.ts +1 -0
  4. package/dist/index.esm.js +2786 -0
  5. package/dist/index.esm.js.map +1 -0
  6. package/dist/src/index.d.ts +58 -0
  7. package/dist/src/index.d.ts.map +1 -0
  8. package/dist/src/lib/align.d.ts +136 -0
  9. package/dist/src/lib/align.d.ts.map +1 -0
  10. package/dist/src/lib/analysis/content.d.ts +50 -0
  11. package/dist/src/lib/analysis/content.d.ts.map +1 -0
  12. package/dist/src/lib/analysis/score.d.ts +18 -0
  13. package/dist/src/lib/analysis/score.d.ts.map +1 -0
  14. package/dist/src/lib/estimate/coarse.d.ts +43 -0
  15. package/dist/src/lib/estimate/coarse.d.ts.map +1 -0
  16. package/dist/src/lib/estimate/features.d.ts +72 -0
  17. package/dist/src/lib/estimate/features.d.ts.map +1 -0
  18. package/dist/src/lib/estimate/match.d.ts +38 -0
  19. package/dist/src/lib/estimate/match.d.ts.map +1 -0
  20. package/dist/src/lib/estimate/models.d.ts +41 -0
  21. package/dist/src/lib/estimate/models.d.ts.map +1 -0
  22. package/dist/src/lib/estimate/phaseCorrelation.d.ts +34 -0
  23. package/dist/src/lib/estimate/phaseCorrelation.d.ts.map +1 -0
  24. package/dist/src/lib/estimate/ransac.d.ts +36 -0
  25. package/dist/src/lib/estimate/ransac.d.ts.map +1 -0
  26. package/dist/src/lib/image/codec.d.ts +30 -0
  27. package/dist/src/lib/image/codec.d.ts.map +1 -0
  28. package/dist/src/lib/image/gray.d.ts +67 -0
  29. package/dist/src/lib/image/gray.d.ts.map +1 -0
  30. package/dist/src/lib/image/raster.d.ts +19 -0
  31. package/dist/src/lib/image/raster.d.ts.map +1 -0
  32. package/dist/src/lib/image/resize.d.ts +32 -0
  33. package/dist/src/lib/image/resize.d.ts.map +1 -0
  34. package/dist/src/lib/image/warp.d.ts +28 -0
  35. package/dist/src/lib/image/warp.d.ts.map +1 -0
  36. package/dist/src/lib/math/fft.d.ts +15 -0
  37. package/dist/src/lib/math/fft.d.ts.map +1 -0
  38. package/dist/src/lib/math/linalg.d.ts +34 -0
  39. package/dist/src/lib/math/linalg.d.ts.map +1 -0
  40. package/dist/src/lib/math/matrix.d.ts +75 -0
  41. package/dist/src/lib/math/matrix.d.ts.map +1 -0
  42. package/dist/src/lib/math/random.d.ts +13 -0
  43. package/dist/src/lib/math/random.d.ts.map +1 -0
  44. package/dist/src/lib/regions.d.ts +75 -0
  45. package/dist/src/lib/regions.d.ts.map +1 -0
  46. package/dist/src/lib/testing/synthetic.d.ts +73 -0
  47. package/dist/src/lib/testing/synthetic.d.ts.map +1 -0
  48. package/dist/src/lib/types.d.ts +86 -0
  49. package/dist/src/lib/types.d.ts.map +1 -0
  50. package/package.json +59 -0
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
@@ -0,0 +1 @@
1
+ export * from "./src/index.js";