@scanmate/align 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 +65 -199
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,230 +1,96 @@
|
|
|
1
|
-

|
|
1
|
+

|
|
2
2
|
|
|
3
3
|
# `@scanmate/align`
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Puts a scanned or photographed page back onto the original it came from: deskewed, rescaled and registered, so a rectangle in PDF points means the same place on both.
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+

|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
*Left: the returned scan, turned 1.4° and 3% small. Right: the same pixels on the original's canvas. 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
|
-
- 🎯 **Coordinate Preservation**: Resamples scanned pages directly onto the original PDF canvas dimensions, ensuring bounding box coordinates $(x, y, w, h)$ remain 1:1 comparable.
|
|
14
|
-
- 🚀 **Multi-Hypothesis Coarse Estimation**: Guesses transformation candidates via frame matching, content bounding-box matching, and projection-histogram deskewing at low resolution (512px).
|
|
15
|
-
- ⚡ **FFT Phase Correlation**: Fine-tunes 2D translational offsets using frequency-domain cross-power spectral density peaks.
|
|
16
|
-
- 🔬 **Steered BRIEF & FAST Corners (ORB)**: Detects corners with intensity centroids for scale/rotation invariance without native OpenCV dependencies.
|
|
17
|
-
- 🛡️ **RANSAC Model Fitting**: Filters up to 60%+ false matches (e.g. repeated table cells, identical letterforms) using RANdom SAmple Consensus.
|
|
18
|
-
- 📐 **Multiple Transformation Models**: Supports `similarity` (4-DOF), `affine` (6-DOF), and `homography` (8-DOF perspective transform).
|
|
19
|
-
|
|
20
|
-
---
|
|
21
|
-
|
|
22
|
-
## Installation
|
|
11
|
+
## Install
|
|
23
12
|
|
|
24
13
|
```bash
|
|
25
|
-
|
|
26
|
-
npm install @scanmate/align @scanmate/ink
|
|
27
|
-
|
|
28
|
-
# Using pnpm
|
|
29
|
-
pnpm add @scanmate/align @scanmate/ink
|
|
30
|
-
|
|
31
|
-
# Using yarn
|
|
32
|
-
yarn add @scanmate/align @scanmate/ink
|
|
14
|
+
npm install @scanmate/align @scanmate/extract
|
|
33
15
|
```
|
|
34
16
|
|
|
35
|
-
---
|
|
36
|
-
|
|
37
|
-
## Quick Start
|
|
38
|
-
|
|
39
17
|
```ts
|
|
40
|
-
import { alignScan } from '@scanmate/align'
|
|
41
|
-
import {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
const
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
18
|
+
import { alignPages, alignScan } from '@scanmate/align'
|
|
19
|
+
import { extractPair } from '@scanmate/extract'
|
|
20
|
+
|
|
21
|
+
// A pair of documents, page by page:
|
|
22
|
+
const { pages } = await extractPair({ original: 'fw9-issued.pdf', scanned: 'fw9-returned.pdf' })
|
|
23
|
+
const aligned = await alignPages(pages)
|
|
24
|
+
|
|
25
|
+
aligned[0].scanned.raster // left above: the scan as it came back
|
|
26
|
+
aligned[0].aligned.raster // right: the same pixels on the original's canvas
|
|
27
|
+
aligned[0].aligned.confidence // 0-1: how well the ink agrees after warping
|
|
28
|
+
aligned[0].aligned.transform // model, scale, rotation, shear, translation
|
|
29
|
+
aligned[0].aligned.matrix // original coordinates -> scan coordinates
|
|
30
|
+
aligned[0].aligned.inverse // and back
|
|
31
|
+
|
|
32
|
+
// Or two images on their own:
|
|
33
|
+
const result = await alignScan('fw9-page-1.png', 'fw9-returned.jpg')
|
|
53
34
|
```
|
|
54
35
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
## Architecture & Algorithm Deep-Dive
|
|
58
|
-
|
|
59
|
-
### 1. Multi-Stage Coarse-to-Fine Pipeline
|
|
60
|
-
|
|
61
|
-
```mermaid
|
|
62
|
-
flowchart TD
|
|
63
|
-
A["Original Template & Scanned Image"] --> B["Decode to Rasters & Ink Normalization"]
|
|
64
|
-
B --> C["Coarse Estimator (512px downsample)"]
|
|
65
|
-
|
|
66
|
-
subgraph CoarseStrategy["Coarse Estimation Hypotheses"]
|
|
67
|
-
C1["Hypothesis 1: Frame Matching"]
|
|
68
|
-
C2["Hypothesis 2: Content Bounding-Box"]
|
|
69
|
-
C3["Hypothesis 3: Projection Histogram Deskew"]
|
|
70
|
-
end
|
|
71
|
-
|
|
72
|
-
C --> CoarseStrategy
|
|
73
|
-
CoarseStrategy --> D["Score Candidates via Ink Correlation"]
|
|
74
|
-
D --> E["Best Coarse Transform H_coarse"]
|
|
75
|
-
E --> F["2D FFT Phase Correlation<br/>Fine translation shift (dx, dy)"]
|
|
76
|
-
F --> G["Coarse-Warped Scan"]
|
|
77
|
-
G --> H["ORB Feature Detection (1400px)<br/>FAST Corners + Steered BRIEF"]
|
|
78
|
-
H --> I["Hamming Distance Matching & Mutual Filter"]
|
|
79
|
-
I --> J["RANSAC Iterative Inlier Estimation"]
|
|
80
|
-
J --> K{"Inlier count >= minInliers?"}
|
|
81
|
-
K -- Yes --> L["Fit Final Homography H_fine<br/>Compose: H_total = H_coarse * H_fine"]
|
|
82
|
-
K -- No --> M["Fallback to H_coarse (method: 'coarse')"]
|
|
83
|
-
L --> N["Inverse Mapping Resample onto Original Canvas"]
|
|
84
|
-
M --> N
|
|
85
|
-
N --> O["Aligned Output Raster & Confidence Diagnostics"]
|
|
86
|
-
```
|
|
87
|
-
|
|
88
|
-
---
|
|
89
|
-
|
|
90
|
-
### 2. Feature Matching & RANSAC Alignment Sequence
|
|
91
|
-
|
|
92
|
-
```mermaid
|
|
93
|
-
sequenceDiagram
|
|
94
|
-
autonumber
|
|
95
|
-
participant App as Application
|
|
96
|
-
participant Align as alignScan()
|
|
97
|
-
participant ORB as Feature Matching (ORB)
|
|
98
|
-
participant RANSAC as RANSAC Solver
|
|
99
|
-
participant Kernel as @scanmate/ink Warp
|
|
100
|
-
|
|
101
|
-
App->>Align: Execute alignScan(original, scanned)
|
|
102
|
-
Align->>Align: Run Coarse Estimation & FFT Phase Correlation
|
|
103
|
-
Align->>ORB: detectAndDescribe(originalInk) & detectAndDescribe(scannedInk)
|
|
104
|
-
ORB->>ORB: Compute FAST corners & 256-bit steered BRIEF descriptors
|
|
105
|
-
Align->>ORB: matchFeatures(setA, setB)
|
|
106
|
-
ORB->>ORB: Calculate Hamming Bit Distances & Mutual Nearest Neighbor
|
|
107
|
-
ORB-->>Align: Return candidate PointMatch correspondences
|
|
108
|
-
Align->>RANSAC: ransac(matches, { model, threshold })
|
|
109
|
-
loop Random Sample Consensuses (N iterations)
|
|
110
|
-
RANSAC->>RANSAC: Sample minimal point set (e.g. 2 for Similarity, 4 for Homography)
|
|
111
|
-
RANSAC->>RANSAC: Solve model parameter matrix H_candidate via SVD
|
|
112
|
-
RANSAC->>RANSAC: Count inliers where distance(H * p_src, p_dst) < threshold
|
|
113
|
-
end
|
|
114
|
-
RANSAC->>RANSAC: Refit optimal model H_final on all consensus inliers
|
|
115
|
-
RANSAC-->>Align: Return RansacResult (inliers, matrix)
|
|
116
|
-
Align->>Kernel: warpRaster(scanned, outputCanvas, H_total_inv)
|
|
117
|
-
Kernel-->>Align: Aligned Raster
|
|
118
|
-
Align-->>App: Return AlignResult
|
|
119
|
-
```
|
|
120
|
-
|
|
121
|
-
---
|
|
122
|
-
|
|
123
|
-
## Comprehensive API Reference
|
|
124
|
-
|
|
125
|
-
### 1. Primary Alignment Functions
|
|
126
|
-
|
|
127
|
-
#### `alignScan(original: ImageInput | Raster, scanned: ImageInput | Raster, options?: AlignOptions): Promise<AlignResult>`
|
|
128
|
-
Main entry point to align a single scanned image back onto the original template canvas.
|
|
129
|
-
- **Parameters**:
|
|
130
|
-
- `original`: Original template image (file `Buffer`, `Uint8Array`, or decoded `Raster`).
|
|
131
|
-
- `scanned`: Scanned page image (file `Buffer`, `Uint8Array`, or decoded `Raster`).
|
|
132
|
-
- `options` *(optional)*: `AlignOptions` object (see detailed breakdown below).
|
|
133
|
-
- **Returns**: `Promise<AlignResult>` containing resampled `raster`, transformation matrices, transform summary, `confidence` score (0.0 to 1.0), `method` (`'features'` | `'coarse'`), and diagnostics.
|
|
134
|
-
|
|
135
|
-
##### Detailed Options Explanation (`AlignOptions`):
|
|
136
|
-
|
|
137
|
-
| Option | Type | Default | Description & Impact |
|
|
138
|
-
|---|---|---|---|
|
|
139
|
-
| `model` | `'similarity' \| 'affine' \| 'homography'` | `'similarity'` | Mathematical transformation model to fit. `similarity` (4-DOF) solves scale, rotation, translation; `affine` (6-DOF) adds axis shear; `homography` (8-DOF) solves perspective tilt. |
|
|
140
|
-
| `workingSize` | `number` | `1400` | Maximum dimension (width/height in px) to downscale images for feature detection. Higher values increase accuracy for faint text but increase execution time quadratically. |
|
|
141
|
-
| `coarseSize` | `number` | `512` | Maximum dimension for the fast initial coarse estimation sweep. |
|
|
142
|
-
| `maxFeatures` | `number` | `1200` | Maximum budget of ORB keypoints to detect per image. |
|
|
143
|
-
| `ransacThreshold` | `number` | `3.0` | Maximum reprojection distance in working-resolution pixels to consider a feature match an inlier during RANSAC. |
|
|
144
|
-
| `minInliers` | `number` | `12` | Minimum required RANSAC inlier matches. If inlier count is below this, feature stage is rejected and method falls back to `'coarse'`. |
|
|
145
|
-
| `maxSkewDeg` | `number` | `12.0` | Maximum page tilt angle considered during projection histogram deskewing. |
|
|
146
|
-
| `interpolation` | `'bilinear' \| 'bicubic' \| 'nearest'` | `'bilinear'` | Sub-pixel interpolation kernel used when inverse warping the scan onto the output canvas. |
|
|
147
|
-
| `background` | `Rgba` (`[r,g,b,a]`) | `[255,255,255,255]` | RGBA background fill color for canvas areas not covered by the warped scan. |
|
|
148
|
-
| `output` | `'png' \| 'jpeg' \| 'none'` | `'png'` | Format for encoded output bytes in `result.image`. Set to `'none'` if consuming `result.raster` directly to save PNG encoding overhead. |
|
|
149
|
-
| `seed` | `number` | `42` | PRNG seed for RANSAC sampling and steered BRIEF patterns, ensuring deterministic output across runs. |
|
|
150
|
-
|
|
151
|
-
---
|
|
36
|
+
## How it works, briefly
|
|
152
37
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
- **Returns**: `Promise<AlignedPage[]>`
|
|
38
|
+
1. **Ink separation** on both pages, so shadows, grey lids and uneven light do not take part in anything.
|
|
39
|
+
2. **A coarse guess** of scale, rotation and shift, tried three ways — frame to frame, content to content, and after deskewing each page — nudged by FFT phase correlation and judged on ink correlation. Descriptors only match between images of comparable size, and nothing in a scan says what resolution it is.
|
|
40
|
+
3. **ORB features**: FAST-9 corners, steered BRIEF descriptors, brute-force Hamming matching with a ratio test and a displacement filter.
|
|
41
|
+
4. **RANSAC** per transform family, re-fitted on its inliers. A page of text is full of identical-looking corners, so wrong matches are the normal case, not an accident.
|
|
42
|
+
5. **One warp for the winner**, at full resolution.
|
|
159
43
|
|
|
160
|
-
|
|
161
|
-
Fine-tunes residual 1-2 pixel translational shifts between original and aligned rasters using phase correlation.
|
|
44
|
+
Everything above the per-model step is done once, so trying three models costs about 1.2× trying one.
|
|
162
45
|
|
|
163
|
-
|
|
46
|
+
## Choosing the model
|
|
164
47
|
|
|
165
|
-
|
|
48
|
+
`model: 'all'` (the default) sweeps `similarity` → `affine` → `homography`, cheapest first, and stops as soon as one reaches `confidenceTarget`. A freer model must beat the best simpler one by `modelPreferenceMargin` to replace it: eight degrees of freedom will happily overfit a flatbed scan's noise, winning on correlation by a hair while being geometrically wrong.
|
|
166
49
|
|
|
167
|
-
|
|
168
|
-
Evaluates triple-hypothesis coarse transformation candidates (`frame`, `content`, `deskew`) at low resolution (`coarseSize`).
|
|
169
|
-
- **`options`**:
|
|
170
|
-
- `coarseSize` *(default: 512)*: Downscaled image dimension.
|
|
171
|
-
- `maxSkewDeg` *(default: 12)*: Max search skew angle.
|
|
172
|
-
- **Returns**: `Promise<CoarseResult>` with best matrix, candidate scores, and downscaled warped gray images.
|
|
50
|
+
If no model finds a consensus — a nearly blank form has few corners — the coarse estimate is returned, scored the same way, with `method: 'coarse'`. There is always a transform and always a number saying how much to trust it.
|
|
173
51
|
|
|
174
|
-
|
|
175
|
-
Detects FAST corners and computes 256-bit steered BRIEF binary descriptors (ORB).
|
|
176
|
-
- **`options`**:
|
|
177
|
-
- `maxFeatures` *(default: 1200)*: Keypoint budget cap.
|
|
178
|
-
- `fastThreshold` *(default: 20)*: FAST corner detector intensity difference threshold.
|
|
179
|
-
- **Returns**: `FeatureSet` containing `keypoints` (position, angle, response) and `descriptors` (`Uint8Array` of size $N \times 32$).
|
|
52
|
+
## Reading the confidence
|
|
180
53
|
|
|
181
|
-
|
|
182
|
-
Matches binary BRIEF descriptors between two feature sets using Hamming distance.
|
|
183
|
-
- **`options`**:
|
|
184
|
-
- `maxDistance` *(default: 64)*: Maximum acceptable Hamming bit error distance (0-256).
|
|
185
|
-
- `crossCheck` *(default: true)*: Enforces mutual nearest-neighbor filter (A must choose B and B must choose A).
|
|
186
|
-
- **Returns**: Array of `PointMatch` (`{ src: Point, dst: Point, distance: number }`).
|
|
54
|
+
`confidence` is the ink correlation after warping, clamped to `[0, 1]`. Measured on real returned documents:
|
|
187
55
|
|
|
188
|
-
|
|
189
|
-
|
|
56
|
+
| | ink correlation |
|
|
57
|
+
|---|---|
|
|
58
|
+
| aligned to the wrong page | 0.01 – 0.30 |
|
|
59
|
+
| correctly aligned, **signed** page | 0.80 – 0.84 |
|
|
60
|
+
| correctly aligned, ordinary page | 0.92 – 0.97 |
|
|
190
61
|
|
|
191
|
-
|
|
192
|
-
Computes 2D FFT cross-power spectrum between two gray images to find global translation vector $(dx, dy)$ and correlation peak height.
|
|
62
|
+
A signed page scores lower because it was signed: the signature is ink the original has not, and correlation counts it as disagreement. A threshold has to leave room for that.
|
|
193
63
|
|
|
194
|
-
|
|
64
|
+
## Options
|
|
195
65
|
|
|
196
|
-
|
|
66
|
+
| option | default | |
|
|
67
|
+
|---|---|---|
|
|
68
|
+
| `model` | `'all'` | `'similarity' \| 'affine' \| 'homography' \| 'all'`. |
|
|
69
|
+
| `confidenceTarget` | `0.9` | Stop the sweep here. |
|
|
70
|
+
| `models` | all three | Restrict or reorder the sweep. |
|
|
71
|
+
| `modelPreferenceMargin` | `0.02` | How much better a freer model must be. |
|
|
72
|
+
| `workingSize` | `1400` | Longest side for features and matching. |
|
|
73
|
+
| `coarseSize` | `512` | Longest side for the coarse search. |
|
|
74
|
+
| `maxFeatures` | `1200` | Corners kept per image. |
|
|
75
|
+
| `ransacThreshold` | `3` px | Inlier distance. |
|
|
76
|
+
| `minInliers` | `12` | Below this there is no consensus. |
|
|
77
|
+
| `maxSkewDeg` | `12` | Largest per-page skew considered. |
|
|
78
|
+
| `maxScaleRatio` | `6` | Largest size ratio entertained. |
|
|
79
|
+
| `maxDisplacementRatio` | `0.12` | A match may not move further than this. |
|
|
80
|
+
| `interpolation` | `'bilinear'` | `'nearest'` for masks. |
|
|
81
|
+
| `output` | `'png'` | `'none'` keeps the raster only. |
|
|
82
|
+
| `seed` | fixed | The same input gives the same matrix. |
|
|
197
83
|
|
|
198
|
-
|
|
199
|
-
Iterative RANSAC solver for filtering false feature matches and fitting geometric transformation parameters.
|
|
200
|
-
- **`options`**:
|
|
201
|
-
- `model` *(default: `'similarity'`)*: `'similarity'`, `'affine'`, or `'homography'`.
|
|
202
|
-
- `threshold` *(default: 3.0)*: Inlier reprojection error threshold in pixels.
|
|
203
|
-
- `maxIterations` *(default: 2000)*: Max consensus iteration trials.
|
|
204
|
-
- `confidence` *(default: 0.99)*: Theoretical probability of finding optimal inlier set.
|
|
205
|
-
- `seed` *(default: 42)*: PRNG seed.
|
|
206
|
-
- **Returns**: `RansacResult` (`{ matrix: Matrix3, inliers: PointMatch[], iterations: number, rmsError: number }`).
|
|
84
|
+
`alignPages` adds `onProgress`, and carries each page's metadata through untouched.
|
|
207
85
|
|
|
208
|
-
|
|
209
|
-
Direct non-iterative model fitting solver for specified `model` type on a set of point matches.
|
|
86
|
+
## Diagnostics
|
|
210
87
|
|
|
211
|
-
|
|
212
|
-
Fits 4-DOF Similarity matrix (Scale, Rotation, Translation) from 2+ point matches using least squares.
|
|
88
|
+
`AlignResult.diagnostics` reports the coarse score and strategy, each page's measured skew, feature and match counts, inliers and inlier ratio, reprojection error, final correlation and overlap, the selected model, every attempt, and the time taken. A low score is then a thing you can read rather than a mystery.
|
|
213
89
|
|
|
214
|
-
|
|
215
|
-
Fits 6-DOF Affine matrix (Scale X/Y, Rotation, Shear, Translation) from 3+ point matches.
|
|
90
|
+
## Building blocks
|
|
216
91
|
|
|
217
|
-
|
|
218
|
-
Fits 8-DOF Homography matrix ($3 \times 3$ perspective transformation) from 4+ point matches using SVD / eigenvector solver.
|
|
92
|
+
`estimateCoarse`, `detectAndDescribe`, `matchFeatures`, `phaseCorrelate`, `ransac`, `fitSimilarity`, `fitAffine`, `fitHomography`, `findInliers` and `polishTranslation` are exported for callers who want a stage on its own.
|
|
219
93
|
|
|
220
|
-
|
|
221
|
-
Filters point matches returning only those whose reprojection error under `matrix` is less than `threshold`.
|
|
94
|
+
## How it decides
|
|
222
95
|
|
|
223
|
-
|
|
224
|
-
Returns minimum required point correspondences to solve model: `similarity` = 2, `affine` = 3, `homography` = 4.
|
|
225
|
-
|
|
226
|
-
---
|
|
227
|
-
|
|
228
|
-
## License
|
|
229
|
-
|
|
230
|
-
MIT © [ScanMate Team](https://github.com/russoedu/scanmate)
|
|
96
|
+
[`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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@scanmate/align",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.1.0",
|
|
4
4
|
"description": "Put a scanned or photographed page back onto the original it came from: deskew, rescale and register, trying transform models until one is good enough.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Eduardo Russo",
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
"!**/*.js.map"
|
|
43
43
|
],
|
|
44
44
|
"dependencies": {
|
|
45
|
-
"@scanmate/ink": "^0.0
|
|
45
|
+
"@scanmate/ink": "^0.1.0"
|
|
46
46
|
},
|
|
47
47
|
"publishConfig": {
|
|
48
48
|
"access": "public"
|