@scanmate/align 0.0.2

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.
@@ -0,0 +1,43 @@
1
+ import type { GrayImage, Matrix3 } from '@scanmate/ink';
2
+ /**
3
+ * A first, cheap answer good enough to make the expensive one possible.
4
+ *
5
+ * Descriptor matching has a blind spot: BRIEF compares fixed pixel offsets, so
6
+ * a scan at 300 dpi and a page rendered at 150 describe the same corner with
7
+ * two unrelated bit strings. Something has to establish roughly how big the
8
+ * scan is before the matcher runs, and nothing in the file says.
9
+ *
10
+ * So guess, several ways, and let the pixels judge:
11
+ *
12
+ * - **frame** — assume the scan is the whole page, so the frames correspond.
13
+ * - **content** — assume the *printing* corresponds. Robust to a scan with
14
+ * wider margins, which the frame guess gets badly wrong.
15
+ * - **deskew** — measure each page's own skew, and match the printing in the
16
+ * frame where each sits straight. This is the one that usually wins.
17
+ *
18
+ * Each gets a phase-correlation nudge, then all of them are warped and scored
19
+ * on ink correlation. Guessing several times and measuring is far more robust
20
+ * than one clever guess, and at this resolution each attempt costs very little.
21
+ */
22
+ export interface CoarseOptions {
23
+ /** Longest side of the images the search runs on. */
24
+ workingSize?: number;
25
+ /** Largest per-page skew considered, in degrees. */
26
+ maxSkewDeg?: number;
27
+ /** Largest scale ratio between the two images that will be entertained. */
28
+ maxScaleRatio?: number;
29
+ }
30
+ export interface CoarseResult {
31
+ /** Maps full-resolution original coordinates to full-resolution scan coordinates. */
32
+ matrix: Matrix3;
33
+ /** Ink correlation achieved by this transform, in `[-1, 1]`. */
34
+ score: number;
35
+ /** Which guess won, for diagnostics. */
36
+ strategy: string;
37
+ skew: {
38
+ original: number;
39
+ scanned: number;
40
+ };
41
+ }
42
+ export declare function estimateCoarse(originalInk: GrayImage, scannedInk: GrayImage, options?: CoarseOptions): CoarseResult;
43
+ //# sourceMappingURL=estimate-coarse.use-case.d.ts.map
@@ -0,0 +1,4 @@
1
+ /** A first, cheap scale-and-skew guess, good enough to make feature matching possible. */
2
+ export { estimateCoarse } from './estimate-coarse.use-case.js';
3
+ export type { CoarseOptions, CoarseResult } from './estimate-coarse.use-case.js';
4
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,72 @@
1
+ import type { GrayImage } from '@scanmate/ink';
2
+ /**
3
+ * FAST corners with steered BRIEF descriptors — an ORB, written out.
4
+ *
5
+ * This is the piece OpenCV would normally hand you, and it is here because the
6
+ * deployment target rules out a native binding. The three parts each answer a
7
+ * separate question:
8
+ *
9
+ * - **FAST** answers *where*. A pixel is a corner when a contiguous arc of the
10
+ * 16 pixels on a circle around it is all clearly brighter, or all clearly
11
+ * darker, than it is. On a page that fires on stroke ends, serifs, and the
12
+ * corners of rules and boxes — landmarks that survive being rescanned.
13
+ * - **The intensity centroid** answers *which way up*. The vector from the
14
+ * patch's centre to its centre of mass is a direction the ink itself defines,
15
+ * so it turns with the page.
16
+ * - **BRIEF** answers *what it looks like*, as 256 yes/no questions of the form
17
+ * "is this pixel darker than that one?", asked at positions rotated by that
18
+ * angle. Comparing two of those is one XOR and a bit count, which is why
19
+ * brute-force matching thousands of them is affordable.
20
+ *
21
+ * The descriptor is not scale invariant on its own, hence the pyramid: the same
22
+ * corner is described at several sizes so a scan at a different dpi still
23
+ * matches.
24
+ */
25
+ export interface Keypoint {
26
+ /** Coordinates in the *input* image, pixel centres, regardless of the level found at. */
27
+ x: number;
28
+ y: number;
29
+ score: number;
30
+ /** Dominant ink direction in radians. */
31
+ angle: number;
32
+ level: number;
33
+ /** Pixel size of the patch described, in input pixels. */
34
+ size: number;
35
+ }
36
+ export interface FeatureSet {
37
+ keypoints: Keypoint[];
38
+ /** 8 x 32 bits per keypoint, laid out contiguously. */
39
+ descriptors: Uint32Array;
40
+ }
41
+ export interface FeatureOptions {
42
+ maxFeatures?: number;
43
+ /** Contrast a circle pixel must clear to count, in `[0, 1]` ink units. */
44
+ fastThreshold?: number;
45
+ /** Pyramid levels, including the original. */
46
+ levels?: number;
47
+ /** Ratio between consecutive levels. */
48
+ scaleFactor?: number;
49
+ /** Side of the described patch, in pixels of its own level. */
50
+ patchSize?: number;
51
+ /** Cells per axis used to spread keypoints over the page instead of over its densest paragraph. */
52
+ gridSize?: number;
53
+ seed?: number;
54
+ }
55
+ export declare const DESCRIPTOR_WORDS = 8;
56
+ export declare function detectAndDescribe(image: GrayImage, options?: FeatureOptions): FeatureSet;
57
+ interface Corner {
58
+ x: number;
59
+ y: number;
60
+ score: number;
61
+ }
62
+ /** FAST-9 with a 3x3 non-maximum suppression pass over the corner scores. */
63
+ export declare function detectFast(image: GrayImage, threshold: number, border: number): Corner[];
64
+ /**
65
+ * Angle from the patch centre to its centre of intensity mass.
66
+ *
67
+ * On an ink image the mass is the writing, so the angle turns with the page —
68
+ * which is the entire trick that makes a binary descriptor rotation invariant.
69
+ */
70
+ export declare function orientation(image: GrayImage, cx: number, cy: number, radius: number): number;
71
+ export {};
72
+ //# sourceMappingURL=detect-features.use-case.d.ts.map
@@ -0,0 +1,6 @@
1
+ /** ORB features on both pages, and the Hamming matcher that pairs them. */
2
+ export { detectAndDescribe } from './detect-features.use-case.js';
3
+ export type { FeatureOptions, FeatureSet, Keypoint } from './detect-features.use-case.js';
4
+ export { hamming, matchFeatures, popcount } from './match-features.use-case.js';
5
+ export type { MatchOptions } from './match-features.use-case.js';
6
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,38 @@
1
+ import type { PointMatch } from '@scanmate/ink';
2
+ import type { FeatureSet } from './detect-features.use-case.js';
3
+ /**
4
+ * Brute-force descriptor matching.
5
+ *
6
+ * Brute force is the right algorithm here, not a concession. A page yields
7
+ * around a thousand keypoints per side; a million 256-bit comparisons is eight
8
+ * million XOR-and-popcount operations, which is milliseconds. Building an index
9
+ * to avoid that would cost more than it saves and would only return
10
+ * approximate neighbours.
11
+ *
12
+ * The filters matter more than the search does:
13
+ *
14
+ * - **Ratio test.** Keep a match only when the best candidate is clearly better
15
+ * than the runner-up. On a page of repeated letterforms the nearest neighbour
16
+ * is often meaningless, and the giveaway is that the second nearest is just
17
+ * as close.
18
+ * - **Cross-check.** Both sides must name each other. One-directional bests are
19
+ * not symmetric, and the asymmetric ones are usually wrong.
20
+ * - **Displacement gate.** The images are already roughly aligned when this
21
+ * runs, so a correspondence that jumps half the page is not a correspondence.
22
+ */
23
+ export interface MatchOptions {
24
+ /** Lowe's ratio. Lower is stricter. */
25
+ ratio?: number;
26
+ /** Reject matches further apart than this many bits out of 256. */
27
+ maxDistance?: number;
28
+ /** Require both descriptors to pick each other. */
29
+ crossCheck?: boolean;
30
+ /** Reject correspondences that move further than this, in pixels. `Infinity` disables the gate. */
31
+ maxDisplacement?: number;
32
+ }
33
+ export declare function matchFeatures(source: FeatureSet, target: FeatureSet, options?: MatchOptions): PointMatch[];
34
+ /** Hamming distance between two 256-bit descriptors. */
35
+ export declare function hamming(a: Uint32Array, offsetA: number, b: Uint32Array, offsetB: number): number;
36
+ /** SWAR bit count: pair off, then nibble off, then one multiply to sum the bytes. */
37
+ export declare function popcount(value: number): number;
38
+ //# sourceMappingURL=match-features.use-case.d.ts.map
@@ -0,0 +1,28 @@
1
+ /**
2
+ * `@scanmate/align` - put a scanned page back on top of the page it came from.
3
+ *
4
+ * ```ts
5
+ * import { decodeImage } from '@scanmate/ink'
6
+ * import { alignScan } from '@scanmate/align'
7
+ *
8
+ * const result = await alignScan(await decodeImage('page1.png'), await decodeImage('returned.jpg'))
9
+ * console.log(result.confidence, result.diagnostics.selectedModel, result.transform.rotationDeg)
10
+ * ```
11
+ *
12
+ * `result.raster` sits on the original's canvas, at the original's width and
13
+ * height, so every coordinate known from the PDF still means what it meant.
14
+ */
15
+ export { alignPages, alignScan, polishTranslation } from './scan-alignment/index.js';
16
+ export type { AlignDiagnostics, AlignOptions, AlignPagesOptions, AlignResult, ModelAttempt } from './scan-alignment/index.js';
17
+ /** The model-selection rule `model: 'all'` applies, for callers running their own sweep. */
18
+ export { DEFAULT_MODELS, prefers } from './scan-alignment/index.js';
19
+ export type { ScoredModel } from './scan-alignment/index.js';
20
+ export { estimateCoarse } from './coarse-estimation/index.js';
21
+ export type { CoarseOptions, CoarseResult } from './coarse-estimation/index.js';
22
+ export { detectAndDescribe, hamming, matchFeatures, popcount } from './feature-matching/index.js';
23
+ export type { FeatureOptions, FeatureSet, Keypoint, MatchOptions } from './feature-matching/index.js';
24
+ export { phaseCorrelate } from './phase-correlation/index.js';
25
+ export type { PhaseCorrelationResult } from './phase-correlation/index.js';
26
+ export { findInliers, fitAffine, fitHomography, fitModel, fitSimilarity, minimumSamples, ransac } from './transform-fitting/index.js';
27
+ export type { Correspondence, RansacOptions, RansacResult } from './transform-fitting/index.js';
28
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,4 @@
1
+ /** Sub-pixel translation between two images, from the FFT. */
2
+ export { phaseCorrelate } from './phase-correlate.use-case.js';
3
+ export type { PhaseCorrelationResult } from './phase-correlate.use-case.js';
4
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,34 @@
1
+ import type { GrayImage } from '@scanmate/ink';
2
+ /**
3
+ * Global translation from the Fourier shift theorem.
4
+ *
5
+ * Shifting an image does not change the magnitude of its spectrum, only the
6
+ * phase — and it changes the phase by an amount proportional to the shift. So
7
+ * divide out the magnitudes entirely, keep the phase difference, transform
8
+ * back, and what comes out is a single spike at the offset between the two
9
+ * images.
10
+ *
11
+ * Its value here is that it does not care what is *on* the page. Feature
12
+ * matching needs corners to match; a mostly blank form does not have enough of
13
+ * them, and neither does a page that scanned faint. Phase correlation uses
14
+ * every pixel at once, which makes it the fallback when features fail, and a
15
+ * good final polish when they succeed.
16
+ *
17
+ * It only finds translation. Rotation and scale have to be dealt with first.
18
+ */
19
+ export interface PhaseCorrelationResult {
20
+ /** Shift that takes `a` onto `b`: a feature at `p` in `a` sits at `p + (dx, dy)` in `b`. */
21
+ dx: number;
22
+ dy: number;
23
+ /** Height of the correlation spike. Near 1 is a clean single answer; near 0 is noise. */
24
+ peak: number;
25
+ }
26
+ /**
27
+ * Correlate two equally sized images.
28
+ *
29
+ * Both are Hann-windowed first. Without it the FFT sees the frame edges as a
30
+ * hard discontinuity repeating forever, and that cross pattern in the spectrum
31
+ * can be a stronger signal than the page.
32
+ */
33
+ export declare function phaseCorrelate(a: GrayImage, b: GrayImage): PhaseCorrelationResult;
34
+ //# sourceMappingURL=phase-correlate.use-case.d.ts.map
@@ -0,0 +1,22 @@
1
+ import type { AlignedPage, ProgressCallback, ScanPage } from '@scanmate/ink';
2
+ import type { AlignOptions, AlignResult } from './align-result.contract.js';
3
+ export interface AlignPagesOptions extends AlignOptions {
4
+ /** Called before and after each page. The only logging seam - see `StageEvent` in `@scanmate/ink`. */
5
+ onProgress?: ProgressCallback;
6
+ }
7
+ /**
8
+ * Align every page pair a document produced - typically the output of
9
+ * `@scanmate/extract` - and hand each back with its alignment attached.
10
+ *
11
+ * Pages run one after another, not concurrently. The estimator is CPU-bound and
12
+ * synchronous between decode and encode, so starting several on one thread only
13
+ * interleaves them and makes each slower; real parallelism needs worker threads,
14
+ * which is a decision for the caller or an orchestrator, not for a library call.
15
+ *
16
+ * Every page passes through untouched with `aligned` added, and the types say
17
+ * so: whatever else the producer attached - `@scanmate/extract`'s per-page
18
+ * `metadata`, the dpi each side was rendered at, the encoded bytes - is still
19
+ * there, and still typed, on the way out.
20
+ */
21
+ export declare function alignPages<Page extends ScanPage>(pages: readonly Page[], options?: AlignPagesOptions): Promise<Array<Page & AlignedPage<AlignResult>>>;
22
+ //# sourceMappingURL=align-pages.use-case.d.ts.map
@@ -0,0 +1,116 @@
1
+ import type { AlignedImage, ImageFormat, InkOptions, Interpolation, TransformModel, TransformSummary } from '@scanmate/ink';
2
+ export interface AlignOptions {
3
+ /**
4
+ * Transform family to fit, or `'all'` to let the page decide.
5
+ *
6
+ * `'all'` (the default) tries the families in {@link AlignOptions.models}
7
+ * order, cheapest and most robust first, and stops at the first one that
8
+ * reaches {@link AlignOptions.confidenceTarget}. Name one family to fit only
9
+ * that: `similarity` for a flatbed or sheet-fed scan, which can only turn,
10
+ * resize and move a flat page; `affine` when one axis is stretched;
11
+ * `homography` for a photograph taken off-axis, where the far edge of the page
12
+ * is genuinely smaller than the near one.
13
+ */
14
+ model?: TransformModel | 'all';
15
+ /**
16
+ * With `model: 'all'`, stop trying further models once one reaches this
17
+ * confidence, in `[0, 1]`. A value above 1 is never reached, so every model
18
+ * is tried. Default `0.9`.
19
+ */
20
+ confidenceTarget?: number;
21
+ /** With `model: 'all'`, which families to try and in what order. */
22
+ models?: readonly TransformModel[];
23
+ /**
24
+ * With `model: 'all'`, how much a more complex model must beat a simpler one
25
+ * by to replace it. Without it, a homography wins on a flat page by fitting
26
+ * the page's noise. Default `0.02`.
27
+ */
28
+ modelPreferenceMargin?: number;
29
+ /** Longest side used for feature detection. Bigger is more precise and quadratically slower. */
30
+ workingSize?: number;
31
+ /** Longest side used for the coarse guess. */
32
+ coarseSize?: number;
33
+ maxFeatures?: number;
34
+ /** Inlier radius for RANSAC, in working-resolution pixels. */
35
+ ransacThreshold?: number;
36
+ /** Fewer surviving correspondences than this and the feature stage is not trusted. */
37
+ minInliers?: number;
38
+ /** Largest per-page skew the coarse stage considers, in degrees. */
39
+ maxSkewDeg?: number;
40
+ /** Cap on how much bigger or smaller the scan may be than the original. */
41
+ maxScaleRatio?: number;
42
+ /** How far a correspondence may move, as a fraction of the page diagonal, after the coarse warp. */
43
+ maxDisplacementRatio?: number;
44
+ /** Background/ink separation. The defaults suit printed pages on white. */
45
+ ink?: InkOptions;
46
+ interpolation?: Interpolation;
47
+ /** RGBA fill where the scan does not cover the original's canvas. */
48
+ background?: [number, number, number, number];
49
+ /** Encoding of `result.image`. `'none'` skips encoding, which is most of the cost on a big page. */
50
+ output?: ImageFormat | 'none';
51
+ /** Quality when `output` is a lossy format. */
52
+ quality?: number;
53
+ /** Seeds RANSAC and the descriptor pattern, so the same input gives the same matrix. */
54
+ seed?: number;
55
+ }
56
+ /** One model the sweep tried, and how it did. */
57
+ export interface ModelAttempt {
58
+ model: TransformModel;
59
+ /** `null` when RANSAC found no consensus, so the model was never scored. */
60
+ confidence: number | null;
61
+ inliers: number;
62
+ inlierRatio: number;
63
+ /** Mean RANSAC reprojection error over the inliers, in working-resolution pixels. `NaN` when rejected. */
64
+ reprojectionError: number;
65
+ /** RANSAC found no consensus worth trusting for this model. */
66
+ rejected: boolean;
67
+ /** This attempt's transform is the one returned. */
68
+ selected: boolean;
69
+ }
70
+ export interface AlignDiagnostics {
71
+ coarseScore: number;
72
+ coarseStrategy: string;
73
+ /** Each page's own skew, in degrees, as measured independently. */
74
+ skewDeg: {
75
+ original: number;
76
+ scanned: number;
77
+ };
78
+ features: {
79
+ original: number;
80
+ scanned: number;
81
+ };
82
+ matches: number;
83
+ /** Inliers behind the returned transform. Zero when the coarse estimate was returned. */
84
+ inliers: number;
85
+ inlierRatio: number;
86
+ /** Mean RANSAC reprojection error over the returned model's inliers, in working-resolution pixels. */
87
+ reprojectionError: number;
88
+ /** Ink correlation after alignment, in `[-1, 1]`. */
89
+ correlation: number;
90
+ /** Ink mask overlap after alignment, in `[0, 1]`. */
91
+ intersectionOverUnion: number;
92
+ /**
93
+ * The family of the returned transform. The coarse estimate is itself a
94
+ * similarity, so a coarse fallback reports `similarity` - `method` says which.
95
+ */
96
+ selectedModel: TransformModel;
97
+ /** Every model tried, in order. Its length says whether the sweep stopped early. */
98
+ attempts: ModelAttempt[];
99
+ /** Milliseconds spent, end to end. */
100
+ durationMs: number;
101
+ }
102
+ export interface AlignResult extends AlignedImage {
103
+ transform: TransformSummary;
104
+ /**
105
+ * How much to trust the result, in `[0, 1]`.
106
+ *
107
+ * Derived from ink correlation after warping, so it measures agreement in the
108
+ * output rather than confidence in the process. Above ~0.6 is a solid match on
109
+ * a printed page; below ~0.3 treat the alignment as failed.
110
+ */
111
+ confidence: number;
112
+ /** `features` when RANSAC found a consensus, `coarse` when the coarse estimate had to stand alone. */
113
+ method: 'features' | 'coarse';
114
+ diagnostics: AlignDiagnostics;
115
+ }
116
+ //# sourceMappingURL=align-result.contract.d.ts.map
@@ -0,0 +1,59 @@
1
+ import type { ImageInput } from '@scanmate/ink';
2
+ import type { AlignOptions, AlignResult } from './align-result.contract.js';
3
+ /**
4
+ * Align a scan onto the page it was made from.
5
+ *
6
+ * ## What this is for
7
+ *
8
+ * Two questions about a returned form are easy to answer once the scan sits
9
+ * exactly on top of the original, and near-impossible before:
10
+ *
11
+ * 1. *Was anything in the printed text changed?* Run OCR on both and diff.
12
+ * That only works if the two are the same page at the same size, otherwise
13
+ * the OCR engine's own layout analysis is comparing different documents.
14
+ * 2. *Was the box at (x, y) signed?* That is a question about a fixed
15
+ * rectangle, and a fixed rectangle only means something once both images
16
+ * agree on where (x, y) is. See `@scanmate/diff`.
17
+ *
18
+ * ## The pipeline
19
+ *
20
+ * ```text
21
+ * decode ─► ink ─► coarse guess ─► rough warp ─► features ─┬─► RANSAC(similarity) ─► score ─┐
22
+ * (scale/skew) (ORB) ├─► RANSAC(affine) ─► score ─┼─► warp
23
+ * └─► RANSAC(homography) ─► score ─┘
24
+ * └──────────────────── once, whatever the model ───────────┘ └──── per model, cheap ────┘
25
+ * ```
26
+ *
27
+ * The coarse guess exists to make the feature stage possible at all: binary
28
+ * descriptors compare fixed pixel offsets, so they only match between images
29
+ * at comparable scale, and nothing in a JPEG tells you what dpi it was scanned
30
+ * at. Once the scan has been resampled to roughly the right size, matching is
31
+ * easy and RANSAC can throw away the inevitable wrong matches - a page of text
32
+ * is full of genuinely identical-looking corners.
33
+ *
34
+ * ## Choosing the model
35
+ *
36
+ * With `model: 'all'`, the default, everything left of the fork is done once:
37
+ * decoding, ink separation, the coarse search, ORB on both pages and matching
38
+ * are nearly all of the cost and do not depend on the transform family. Only
39
+ * RANSAC and one scoring warp run per model, and neither is expensive - RANSAC
40
+ * touches no pixels. The sweep tries models cheapest first, stops as soon as one
41
+ * reaches `confidenceTarget`, and a more complex model must beat a simpler one by
42
+ * `modelPreferenceMargin` to replace it. The full-resolution warp and the
43
+ * encode happen once, for the winner.
44
+ *
45
+ * If no model finds a consensus (a nearly blank form has few corners to find),
46
+ * the coarse estimate is returned on its own, and `method` says so.
47
+ *
48
+ * ## Why it is asynchronous
49
+ *
50
+ * The estimator is CPU-bound with no I/O to wait on, and an earlier version of
51
+ * this function was synchronous to say so. The codec changed that: decoding and
52
+ * encoding now run in libvips on libuv's threadpool, roughly an order of
53
+ * magnitude faster than the pure-JavaScript codec they replaced, and during
54
+ * those two stages the event loop genuinely is free. Between them it is not -
55
+ * the coarse search, ORB and RANSAC all run to completion on this thread - so
56
+ * to align several pages at once, still put this in a worker thread.
57
+ */
58
+ export declare function alignScan(original: ImageInput, scanned: ImageInput, options?: AlignOptions): Promise<AlignResult>;
59
+ //# sourceMappingURL=align-scan.use-case.d.ts.map
@@ -0,0 +1,19 @@
1
+ import type { GrayImage, Matrix3 } from '@scanmate/ink';
2
+ /**
3
+ * How well a proposed transform actually lines the two pages up.
4
+ *
5
+ * Every candidate in a model sweep is judged the same way, on the same two
6
+ * downscaled ink maps, so the downscales - and the original's mask - are done
7
+ * once when the referee is created rather than once per candidate. What is left
8
+ * per call is one warp and two scores.
9
+ */
10
+ export interface Agreement {
11
+ /** Ink correlation after warping, in `[-1, 1]`. */
12
+ correlation: number;
13
+ /** Ink mask overlap after warping, in `[0, 1]`. */
14
+ iou: number;
15
+ }
16
+ export declare function createReferee(originalInk: GrayImage, scannedInk: GrayImage, workingSize: number): (matrix: Matrix3) => Agreement;
17
+ /** A correlation as a confidence: clamped to `[0, 1]`, because anti-correlated ink is no alignment at all. */
18
+ export declare function toConfidence(agreement: Agreement): number;
19
+ //# sourceMappingURL=alignment-referee.use-case.d.ts.map
@@ -0,0 +1,52 @@
1
+ import type { GrayImage, Matrix3, PointMatch, TransformModel } from '@scanmate/ink';
2
+ import type { CoarseResult } from '../coarse-estimation/index.js';
3
+ /**
4
+ * Feature refinement, in two halves so that trying several models costs one
5
+ * feature pass rather than one per model.
6
+ *
7
+ * Matching is done between the original and the *coarsely corrected* scan, and
8
+ * that is what makes the whole thing work: the two now sit at the same scale and
9
+ * nearly the same angle, so a fixed-offset binary descriptor describes the same
10
+ * thing on both, and a correspondence that jumps across the page can be rejected
11
+ * on sight. What RANSAC recovers is only the small residual, which is then
12
+ * composed onto the coarse transform.
13
+ *
14
+ * Everything in {@link prepareMatches} - two downscales, a rough warp, ORB on
15
+ * both pages and the brute-force matcher - is independent of which transform
16
+ * family is about to be fitted, and it is nearly all of the cost. Only
17
+ * {@link fitResidual} depends on the model, and it touches no pixels at all.
18
+ */
19
+ export interface MatchingOptions {
20
+ workingSize: number;
21
+ maxFeatures: number;
22
+ maxDisplacementRatio: number;
23
+ seed: number;
24
+ }
25
+ /** Everything the model fits share. Compute once, fit many. */
26
+ export interface PreparedMatches {
27
+ matches: PointMatch[];
28
+ features: {
29
+ original: number;
30
+ scanned: number;
31
+ };
32
+ /** How much the original was shrunk to its working frame; lifts a residual back to full resolution. */
33
+ scale: number;
34
+ }
35
+ export declare function prepareMatches(originalInk: GrayImage, scannedInk: GrayImage, coarse: CoarseResult, options: MatchingOptions): PreparedMatches;
36
+ export interface FittingOptions {
37
+ ransacThreshold: number;
38
+ minInliers: number;
39
+ seed: number;
40
+ }
41
+ /** One model's answer: the full transform, and how much of the evidence agreed with it. */
42
+ export interface ResidualFit {
43
+ /** Maps full-resolution original coordinates to full-resolution scan coordinates. */
44
+ matrix: Matrix3;
45
+ inliers: number;
46
+ inlierRatio: number;
47
+ /** Mean RANSAC reprojection error over the inliers, in working-resolution pixels. */
48
+ reprojectionError: number;
49
+ }
50
+ /** Fit one model to the shared matches, or `null` when RANSAC finds no consensus worth trusting. */
51
+ export declare function fitResidual(prepared: PreparedMatches, coarse: CoarseResult, model: TransformModel, options: FittingOptions): ResidualFit | null;
52
+ //# sourceMappingURL=feature-refinement.use-case.d.ts.map
@@ -0,0 +1,9 @@
1
+ /** The whole alignment: decode, estimate, pick a model, warp, and say how far to trust it. */
2
+ export { alignPages } from './align-pages.use-case.js';
3
+ export type { AlignPagesOptions } from './align-pages.use-case.js';
4
+ export type { AlignDiagnostics, AlignOptions, AlignResult, ModelAttempt } from './align-result.contract.js';
5
+ export { alignScan } from './align-scan.use-case.js';
6
+ export { DEFAULT_MODELS, prefers } from './model-selection.policy.js';
7
+ export type { ScoredModel } from './model-selection.policy.js';
8
+ export { polishTranslation } from './polish-translation.use-case.js';
9
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,34 @@
1
+ import type { TransformModel } from '@scanmate/ink';
2
+ /**
3
+ * Which transform to believe, when more than one fits.
4
+ *
5
+ * More degrees of freedom always fit at least as well, so a plain "highest
6
+ * confidence wins" would drift towards the most flexible model on every input.
7
+ * On a flatbed scan that is wrong in a way nothing downstream would notice: a
8
+ * homography fitted to a flat page bends slightly to follow the page's own noise,
9
+ * beats the similarity on ink correlation by a hair, passes `isPlausible`, and
10
+ * puts every region a pixel or two off. So a more complex model has to *earn* its
11
+ * extra parameters by a margin, and a simpler one within that margin is kept.
12
+ */
13
+ /** Ascending cost, ascending fragility: the order a sweep should try them in. */
14
+ export declare const DEFAULT_MODELS: readonly TransformModel[];
15
+ /** A fitted model and how well it did. */
16
+ export interface ScoredModel {
17
+ model: TransformModel;
18
+ confidence: number;
19
+ }
20
+ /**
21
+ * Should `candidate` replace `incumbent` as the answer?
22
+ *
23
+ * - A more complex candidate must beat the incumbent by more than `margin`.
24
+ * - A simpler candidate wins if it comes within `margin` of the incumbent.
25
+ * - An equally complex one simply has to do better.
26
+ *
27
+ * Symmetric on purpose, so the answer does not depend on the order the models
28
+ * were tried in: whatever order `models` names, the simplest model within the
29
+ * margin of the best is the one returned.
30
+ */
31
+ export declare function prefers(candidate: ScoredModel, incumbent: ScoredModel | null, margin: number): boolean;
32
+ /** The models to sweep, in the order given, each once. Throws on an empty list rather than silently fitting nothing. */
33
+ export declare function sweepOrder(models: readonly TransformModel[]): TransformModel[];
34
+ //# sourceMappingURL=model-selection.policy.d.ts.map
@@ -0,0 +1,10 @@
1
+ import type { GrayImage, Matrix3 } from '@scanmate/ink';
2
+ /**
3
+ * Nudge an existing transform by whatever residual translation is still measurable.
4
+ *
5
+ * Exposed because it is occasionally useful on its own: if you already know the
6
+ * transform from a previous page of the same batch, this re-seats it on the
7
+ * current page for a fraction of the cost of a full alignment.
8
+ */
9
+ export declare function polishTranslation(originalInk: GrayImage, scannedInk: GrayImage, matrix: Matrix3, workingSize?: number): Matrix3;
10
+ //# sourceMappingURL=polish-translation.use-case.d.ts.map
@@ -0,0 +1,41 @@
1
+ import type { Matrix3, PointMatch, TransformModel } from '@scanmate/ink';
2
+ /**
3
+ * Fitting a transform to a set of correspondences.
4
+ *
5
+ * Three models, and the choice between them is a bet about what the scan went
6
+ * through. A flatbed scanner moves the page in one plane, so a **similarity**
7
+ * (turn it, resize it, slide it) is the whole story and its four parameters
8
+ * are pinned down by very few points — which is exactly what you want when
9
+ * most of your matches are wrong. A sheet-fed scanner can stretch one axis;
10
+ * that needs **affine**. A photograph taken at an angle needs the full
11
+ * **homography**, and pays for those eight parameters by being far easier to
12
+ * fit to nonsense.
13
+ *
14
+ * Prefer the simplest model the physical situation allows.
15
+ */
16
+ /** Correspondences the fitters consume. `distance` is ignored here. */
17
+ export type Correspondence = Pick<PointMatch, 'source' | 'target'>;
18
+ /** How many correspondences the model needs before it is determined at all. */
19
+ export declare function minimumSamples(model: TransformModel): number;
20
+ export declare function fitModel(model: TransformModel, matches: readonly Correspondence[], indices?: readonly number[]): Matrix3 | null;
21
+ /**
22
+ * Least-squares similarity, in closed form.
23
+ *
24
+ * No iteration and no matrix inverse: centre both point sets, and the rotation
25
+ * and scale fall out of two dot products. That closed form is why similarity
26
+ * survives a RANSAC sample that affine would choke on.
27
+ */
28
+ export declare function fitSimilarity(matches: readonly Correspondence[], indices?: readonly number[]): Matrix3 | null;
29
+ /** Least-squares affine: two independent 3x3 normal systems sharing one matrix. */
30
+ export declare function fitAffine(matches: readonly Correspondence[], indices?: readonly number[]): Matrix3 | null;
31
+ /**
32
+ * Direct Linear Transform with Hartley normalisation.
33
+ *
34
+ * The normalisation is not optional polish. Raw pixel coordinates put entries
35
+ * like `x * u` (order 10^6) next to a constant 1 in the same row, and the
36
+ * eigen solve then answers a question dominated by the big column. Centring
37
+ * each point set and scaling it to a mean radius of `sqrt(2)` puts every
38
+ * column on the same footing; the result is mapped back afterwards.
39
+ */
40
+ export declare function fitHomography(matches: readonly Correspondence[], indices?: readonly number[]): Matrix3 | null;
41
+ //# sourceMappingURL=fit-transform.use-case.d.ts.map
@@ -0,0 +1,6 @@
1
+ /** Fitting a similarity, affine or homography to correspondences, and RANSAC to find which ones to trust. */
2
+ export { fitAffine, fitHomography, fitModel, fitSimilarity, minimumSamples } from './fit-transform.use-case.js';
3
+ export type { Correspondence } from './fit-transform.use-case.js';
4
+ export { findInliers, ransac } from './ransac.use-case.js';
5
+ export type { RansacOptions, RansacResult } from './ransac.use-case.js';
6
+ //# sourceMappingURL=index.d.ts.map