@scanmate/image-fix 0.0.3 → 0.0.5

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 (48) hide show
  1. package/README.md +115 -200
  2. package/dist/index.esm.js +3 -2785
  3. package/dist/index.esm.js.map +1 -1
  4. package/dist/src/index.d.ts +17 -51
  5. package/dist/src/index.d.ts.map +1 -1
  6. package/package.json +5 -7
  7. package/dist/src/lib/align.d.ts +0 -136
  8. package/dist/src/lib/align.d.ts.map +0 -1
  9. package/dist/src/lib/analysis/content.d.ts +0 -50
  10. package/dist/src/lib/analysis/content.d.ts.map +0 -1
  11. package/dist/src/lib/analysis/score.d.ts +0 -18
  12. package/dist/src/lib/analysis/score.d.ts.map +0 -1
  13. package/dist/src/lib/estimate/coarse.d.ts +0 -43
  14. package/dist/src/lib/estimate/coarse.d.ts.map +0 -1
  15. package/dist/src/lib/estimate/features.d.ts +0 -72
  16. package/dist/src/lib/estimate/features.d.ts.map +0 -1
  17. package/dist/src/lib/estimate/match.d.ts +0 -38
  18. package/dist/src/lib/estimate/match.d.ts.map +0 -1
  19. package/dist/src/lib/estimate/models.d.ts +0 -41
  20. package/dist/src/lib/estimate/models.d.ts.map +0 -1
  21. package/dist/src/lib/estimate/phaseCorrelation.d.ts +0 -34
  22. package/dist/src/lib/estimate/phaseCorrelation.d.ts.map +0 -1
  23. package/dist/src/lib/estimate/ransac.d.ts +0 -36
  24. package/dist/src/lib/estimate/ransac.d.ts.map +0 -1
  25. package/dist/src/lib/image/codec.d.ts +0 -30
  26. package/dist/src/lib/image/codec.d.ts.map +0 -1
  27. package/dist/src/lib/image/gray.d.ts +0 -67
  28. package/dist/src/lib/image/gray.d.ts.map +0 -1
  29. package/dist/src/lib/image/raster.d.ts +0 -19
  30. package/dist/src/lib/image/raster.d.ts.map +0 -1
  31. package/dist/src/lib/image/resize.d.ts +0 -32
  32. package/dist/src/lib/image/resize.d.ts.map +0 -1
  33. package/dist/src/lib/image/warp.d.ts +0 -28
  34. package/dist/src/lib/image/warp.d.ts.map +0 -1
  35. package/dist/src/lib/math/fft.d.ts +0 -15
  36. package/dist/src/lib/math/fft.d.ts.map +0 -1
  37. package/dist/src/lib/math/linalg.d.ts +0 -34
  38. package/dist/src/lib/math/linalg.d.ts.map +0 -1
  39. package/dist/src/lib/math/matrix.d.ts +0 -75
  40. package/dist/src/lib/math/matrix.d.ts.map +0 -1
  41. package/dist/src/lib/math/random.d.ts +0 -13
  42. package/dist/src/lib/math/random.d.ts.map +0 -1
  43. package/dist/src/lib/regions.d.ts +0 -75
  44. package/dist/src/lib/regions.d.ts.map +0 -1
  45. package/dist/src/lib/testing/synthetic.d.ts +0 -73
  46. package/dist/src/lib/testing/synthetic.d.ts.map +0 -1
  47. package/dist/src/lib/types.d.ts +0 -86
  48. package/dist/src/lib/types.d.ts.map +0 -1
package/README.md CHANGED
@@ -1,69 +1,40 @@
1
- # @scanmate/image-fix
1
+ # `@scanmate/image-fix`
2
2
 
3
- Put a scanned page back on top of the page it came from.
3
+ > **Deprecated Facade Package.** `@scanmate/image-fix` has been split into focused, modular packages. This package remains available as a backward-compatible facade re-exporting all core APIs.
4
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:
5
+ ---
9
6
 
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.
7
+ ## Modern Package Structure
15
8
 
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.
9
+ For all new projects and upgrades, prefer importing directly from the focused packages:
19
10
 
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.
11
+ | You used in `@scanmate/image-fix` | Now lives in package | Primary Purpose |
12
+ |---|---|---|
13
+ | `alignScan`, `alignPages`, `polishTranslation`, estimators | **[`@scanmate/align`](../align)** | Geometric registration & alignment |
14
+ | `compareRegions`, `diffDocument`, `renderDiff`, `diffPage` | **[`@scanmate/diff`](../diff)** | Visual change detection & form region verification |
15
+ | `decodeImage`, `encodeImage`, `inkMap`, `warpRaster`, `Matrix3` | **[`@scanmate/ink`](../ink)** | Low-level pixel kernel, illumination division & geometry |
16
+ | `extractPair`, `extractPages`, `inspectPage`, `renderPage` | **[`@scanmate/extract`](../extract)** | PDF document rendering, inspection & DPI auto-resolution |
25
17
 
26
- ## Install
18
+ ---
27
19
 
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
20
+ ## Architectural Changes & Breaking Notes
46
21
 
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
- ])
22
+ 1. **Asynchronous Codec Operations**: All functions that encode or decode images (`decodeImage`, `encodeImage`, `alignScan`, `compareRegions`, `diffDocument`, `renderDiff`) are now **asynchronous** (`Promise`-based) because the image codec migrated to libvips via `sharp`. This yields a **~20x speedup** when encoding full-page PNGs and enables native support for TIFF, HEIF, WebP, and AVIF formats.
23
+ 2. **Metadata Inspection**: `sniffFormat` has been replaced by `readImageMetadata` in `@scanmate/ink`.
51
24
 
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
- ```
25
+ ---
55
26
 
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.
27
+ ## Installation
59
28
 
60
- ## How it works
29
+ ```bash
30
+ # Using npm
31
+ npm install @scanmate/image-fix
61
32
 
62
- ```text
63
- decode ─► ink ─► coarse guess ─► rough warp ─► ORB features ─► RANSAC ─► warp
64
- scale + skew + matching + model
33
+ # Or install the modular packages directly:
34
+ npm install @scanmate/align @scanmate/diff @scanmate/ink @scanmate/extract
65
35
  ```
66
36
 
37
+ ---
67
38
  > What follows is the reasoning. For the full algorithm - the formulae, every
68
39
  > constant, the coordinate-frame algebra and the measured error - see
69
40
  > [`documentation/fix.md`](https://github.com/russoedu/scanmate/blob/main/documentation/fix.md).
@@ -110,182 +81,126 @@ FAST corners, oriented by the intensity centroid of their patch, described by
110
81
  256 steered BRIEF bits — an ORB, written out, because the native one is
111
82
  unavailable here.
112
83
 
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
84
+ ## Quick Start
139
85
 
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
86
+ ```ts
87
+ import { alignScan, compareRegions, decodeImage } from '@scanmate/image-fix'
88
+ import { readFile } from 'node:fs/promises'
153
89
 
154
- ### `alignScan(original, scanned, options?): AlignResult`
90
+ const original = await decodeImage(await readFile('contract_p1.png'))
91
+ const scanned = await decodeImage(await readFile('returned_scan.jpg'))
155
92
 
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.
93
+ const result = await alignScan(original, scanned, { model: 'similarity' })
159
94
 
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.
95
+ const [sigReport] = compareRegions(original, result.raster, [
96
+ { id: 'signature', rect: { x: 76, y: 905, width: 420, height: 78 } },
97
+ ])
98
+ ```
163
99
 
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
- }
100
+ ---
101
+
102
+ ## Facade Architecture & Dependency Topology
103
+
104
+ ```mermaid
105
+ flowchart TD
106
+ App["Application / Legacy Caller"] --> Facade["@scanmate/image-fix<br/>(Backward Compatibility Facade)"]
107
+
108
+ subgraph ModularPackages["ScanMate Core Ecosystem"]
109
+ Align["@scanmate/align<br/>Registration & RANSAC Engine"]
110
+ Diff["@scanmate/diff<br/>Change Detection & Region Reports"]
111
+ Extract["@scanmate/extract<br/>PDF Parsing & Skia Rendering"]
112
+ Ink["@scanmate/ink<br/>Pixel Kernel & Sharp Codec"]
113
+ end
114
+
115
+ Facade --> Align
116
+ Facade --> Diff
117
+ Facade --> Extract
118
+ Facade --> Ink
119
+
120
+ Align --> Ink
121
+ Diff --> Ink
122
+ Extract --> Ink
123
+ Diff -. optional .-> Align
124
+ Extract -. optional .-> Align
175
125
  ```
176
126
 
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.
127
+ ---
180
128
 
181
- Useful options (all optional):
129
+ ## Comprehensive Re-Export API Reference
182
130
 
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.
131
+ ### 1. Alignment Methods (Delegates to [`@scanmate/align`](../align))
201
132
 
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
- ```
133
+ #### `alignScan(original, scanned, options?: AlignOptions): Promise<AlignResult>`
134
+ Resamples scanned page onto original canvas.
135
+ - **Options**:
136
+ - `model` (`'similarity'` | `'affine'` | `'homography'`, default: `'similarity'`): Geometric model.
137
+ - `workingSize` (default: 1400): Longest dimension for feature matching.
138
+ - `coarseSize` (default: 512): Initial coarse sweep resolution.
139
+ - `maxFeatures` (default: 1200): ORB keypoint cap per image.
140
+ - `ransacThreshold` (default: 3.0): Inlier reprojection error bound in px.
141
+ - `minInliers` (default: 12): Minimum inliers needed to trust feature stage.
142
+ - `interpolation` (`'bilinear'` | `'bicubic'` | `'nearest'`): Resampling kernel.
143
+ - `output` (`'png'` | `'jpeg'` | `'none'`): Encoded output format.
208
144
 
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.
145
+ #### `alignPages(pages, options?: AlignPagesOptions): Promise<AlignedPage[]>`
146
+ Multi-page document batch alignment handler.
212
147
 
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.
148
+ ---
219
149
 
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.
150
+ ### 2. Diffing & Change Detection (Delegates to [`@scanmate/diff`](../diff))
223
151
 
224
- ### `diffDocument(original, aligned, regions?, options?)`
152
+ #### `compareRegions(original, aligned, regions, options?: RegionOptions): RegionReport[]`
153
+ Evaluates form fields to check if signatures or checkboxes were filled.
154
+ - **Options**:
155
+ - `tolerance` (default: 2): Morphological dilation radius in pixels.
156
+ - `threshold` (default: 0.02): Added ink ratio for `filled: true`.
225
157
 
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.
158
+ #### `diffDocument(original, aligned, regions?, options?: RegionOptions): DocumentDiff`
159
+ Computes whole-page and per-region ink addition/subtraction metrics in one pass.
229
160
 
230
- ### `renderDiff(original, aligned, options?): Raster`
161
+ #### `renderDiff(original, aligned, options?: RegionOptions): Raster`
162
+ Renders 4-color RGBA visual overlay (Red = scan additions, Blue = template deletions, Grey = matched ink).
231
163
 
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.
164
+ #### `diffPage(options: DiffOptions): Promise<PageDiff>`
165
+ Full page change detection isolating unexpected handwritten edits via 2-pass connected component analysis.
236
166
 
237
- ### Building blocks
167
+ ---
238
168
 
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.
169
+ ### 3. Kernel & Pixel Operations (Delegates to [`@scanmate/ink`](../ink))
244
170
 
245
- ### Test fixtures
171
+ #### `decodeImage(buffer, options?: DecodeOptions): Promise<Raster>`
172
+ Decodes image bytes to RGBA `Raster` via `sharp`.
173
+ - **Options**: `pageNumber` (for multi-page TIFF), `maxWidth`, `maxHeight`.
246
174
 
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:
175
+ #### `encodeImage(raster, options?: EncodeOptions): Promise<Uint8Array>`
176
+ Encodes RGBA `Raster` to PNG/JPEG bytes via `sharp`.
177
+ - **Options**: `format` (`'png'`, `'jpeg'`, `'webp'`, `'avif'`), `quality` (1-100), `compression` (0-9).
252
178
 
253
- ```ts
254
- import { alignScan, createSyntheticDocument, simulateScan } from '@scanmate/image-fix'
179
+ #### `inkMap(gray, options?: InkOptions): GrayImage`
180
+ Performs background division ($I_{ink} = 1 - I / I_{bg}$).
181
+ - **Options**: `blurRadius` (default: 25), `invert` (default: true).
255
182
 
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' })
183
+ #### `warpRaster(raster, output, invMatrix, options?: WarpOptions): void`
184
+ Resamples input `Raster` using inverse 3x3 homography matrix.
185
+ - **Options**: `interpolation` (`'bilinear'`, `'bicubic'`, `'nearest'`), `background` (`RGBA`).
259
186
 
260
- // result.matrix should agree with scan.matrix to about a pixel.
261
- ```
187
+ ---
188
+
189
+ ### 4. PDF Extraction & Inspection (Delegates to [`@scanmate/extract`](../extract))
262
190
 
263
- ## Limits
191
+ #### `extractPair(options: ExtractPairOptions): Promise<PairedDocument>`
192
+ Parses, auto-detects scan DPI, and renders paired original/scanned PDF pages.
193
+ - **Options**: `original`, `scanned`, `dpi` (`'native'` or fixed number), `pageSelection`.
264
194
 
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.
195
+ #### `inspectPage(pdf, pageIndex): Promise<PageMetadata>`
196
+ Inspects PDF operator streams and classifies page as `'scanned'`, `'born-digital'`, or `'mixed'`.
277
197
 
278
- ## Performance
198
+ ---
279
199
 
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.
200
+ ## License
285
201
 
286
- Memory peaks at a few copies of the largest image. Nothing here streams, so a
287
- 100 megapixel scan will hurt.
202
+ MIT © [ScanMate Team](https://github.com/russoedu/scanmate)
288
203
 
289
- ## Licence
204
+ ## Where the algorithms went
290
205
 
291
- MIT
206
+ [`documentation/algorithms.md`](./documentation/algorithms.md) maps every algorithm this package used to carry to the package that documents it now.