@mrbilit/mrz-ocr 2.0.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 ADDED
@@ -0,0 +1,145 @@
1
+ # @mrbilit/mrz-ocr
2
+
3
+ MRZ character recognition using a small ONNX CNN. Replaces the HOG+SVM
4
+ pipeline in the original mrz-detection project. Runs on `onnxruntime-web`
5
+ (WASM) in the browser and can use `onnxruntime-node` in Node.js.
6
+
7
+ This package is part of [mrbilit/mrz-scanner](https://github.com/mrbilit/mrz-scanner),
8
+ a fork of [alsenet-labs/mrz-scanner](https://github.com/alsenet-labs/mrz-scanner).
9
+
10
+ ## Install
11
+
12
+ For browser applications:
13
+
14
+ ```bash
15
+ npm install @mrbilit/mrz-ocr
16
+ ```
17
+
18
+ For Node.js applications, also install the native ONNX Runtime:
19
+
20
+ ```bash
21
+ npm install @mrbilit/mrz-ocr onnxruntime-node
22
+ ```
23
+
24
+ ## Usage (Browser)
25
+
26
+ ```ts
27
+ import { decode } from 'image-js';
28
+ import { MrzOcr } from '@mrbilit/mrz-ocr';
29
+
30
+ const ocr = new MrzOcr({
31
+ modelPath: '/mrz-cnn.onnx', // served as a static asset
32
+ });
33
+
34
+ await ocr.init();
35
+
36
+ const image = decode(bytes);
37
+ const { lines, confidence } = await ocr.recognize(image);
38
+ ```
39
+
40
+ ## Usage (Node.js)
41
+
42
+ ```ts
43
+ import { MrzOcr } from '@mrbilit/mrz-ocr';
44
+ import * as ort from 'onnxruntime-node';
45
+
46
+ const ocr = new MrzOcr({
47
+ modelPath: './models/mrz-cnn.onnx',
48
+ ort, // required in Node.js; browser builds default to onnxruntime-web
49
+ });
50
+
51
+ await ocr.init();
52
+ ```
53
+
54
+ ## Recognition pipeline
55
+
56
+ `recognize(image)` assumes the input is a tight crop of the MRZ band (what
57
+ `@mrbilit/mrz-detection` produces):
58
+
59
+ 1. Greyscale + Otsu threshold on the crop.
60
+ 2. Connected-component ROIs; try black-pixel ROIs first, fall back to white.
61
+ 3. Filter by aspect ratio (0.3–3.0), cluster into lines by center-Y
62
+ (line height ≈ `image.height / 6`).
63
+ 4. Drop short lines (`minCharsPerLine`, default 5) and keep the last
64
+ `maxLines` (default 3) — covers TD1 (3 lines) and TD2/TD3 (2 lines).
65
+ 5. Sort each line left-to-right, resize each character to 20×20, normalize
66
+ to `[0, 1]`, and batch into a single `(N, 1, 20, 20)` tensor.
67
+ 6. Run the ONNX session; apply softmax over the 37 output logits; emit the
68
+ argmax character and its probability as confidence.
69
+
70
+ ## Model
71
+
72
+ - Architecture: `Conv(1→32, 3×3)→ReLU→MaxPool→Conv(32→64, 3×3)→ReLU→MaxPool→
73
+ Flatten→Dense(128)→ReLU→Dropout(0.3)→Dense(37)`.
74
+ - ≈75 K parameters, ≈917 KB ONNX (opset 17).
75
+ - 37 classes: `0-9`, `A-Z`, `<`.
76
+ - Shipped at `models/mrz-cnn.onnx` with a `models/mrz-cnn.json` sidecar
77
+ recording the symbol table and last test accuracy.
78
+
79
+ ## Retraining
80
+
81
+ From `packages/mrz-ocr/training`:
82
+
83
+ ```bash
84
+ cd packages/mrz-ocr/training
85
+ pip install -r requirements.txt
86
+
87
+ python extract_ocrb.py
88
+ python train_cnn.py --data-dir ./ocrb_chars --output ../models/mrz-cnn.onnx
89
+
90
+ # Or synthetic data only:
91
+ python train_cnn.py --generate --output ../models/mrz-cnn.onnx
92
+ ```
93
+
94
+ After retraining, rebuild any consumer that bundles the model asset.
95
+
96
+ ## Exports
97
+
98
+ ```ts
99
+ interface MrzOcrOptions {
100
+ modelPath?: string; // default 'mrz-cnn.onnx'
101
+ minCharsPerLine?: number; // default 5
102
+ maxLines?: number; // default 3
103
+ ort?: OrtModule; // pass onnxruntime-node in Node.js
104
+ }
105
+
106
+ interface MrzOcrResult {
107
+ lines: string[];
108
+ confidence: number[][]; // per-character softmax probabilities
109
+ }
110
+
111
+ class MrzOcr {
112
+ constructor(options?: MrzOcrOptions);
113
+ init(): Promise<void>;
114
+ recognize(image: Image): Promise<MrzOcrResult>;
115
+ }
116
+
117
+ const MRZ_SYMBOLS: readonly string[];
118
+ ```
119
+
120
+ ## Security
121
+
122
+ Constructor inputs are validated:
123
+
124
+ - `modelPath` must end in `.onnx`; `..` is rejected; only `http(s)://` URLs
125
+ and local paths are allowed (no `file://`, `data:`, `ftp://`).
126
+ - `recognize()` refuses images above 20 megapixels to bound memory.
127
+
128
+ ## Development
129
+
130
+ From the monorepo root:
131
+
132
+ ```bash
133
+ yarn install
134
+ yarn workspace @mrbilit/mrz-ocr build
135
+ yarn workspace @mrbilit/mrz-ocr typecheck
136
+ ```
137
+
138
+ ## License
139
+
140
+ This project is distributed under the GNU Affero General Public License,
141
+ version 3 or later (AGPL-3.0-or-later).
142
+
143
+ The original project and source code are Copyright © 2018-2025 ALSENET SA.
144
+
145
+ See [LICENSE](../../LICENSE) for the full license text.
@@ -0,0 +1,78 @@
1
+ import { Image as Image_2 } from 'image-js';
2
+
3
+ export declare const MRZ_SYMBOLS: readonly string[];
4
+
5
+ /**
6
+ * ONNX-based MRZ OCR engine.
7
+ *
8
+ * Usage:
9
+ * const ocr = new MrzOcr({ modelPath: './mrz-cnn.onnx' });
10
+ * await ocr.init();
11
+ * const result = await ocr.recognize(croppedMrzImage);
12
+ */
13
+ export declare class MrzOcr {
14
+ private session;
15
+ private ort;
16
+ private options;
17
+ private ortOverride;
18
+ constructor(options?: MrzOcrOptions);
19
+ /**
20
+ * Initialize the ONNX inference session.
21
+ * Must be called before recognize().
22
+ *
23
+ * In the browser, onnxruntime-web is imported automatically.
24
+ * In Node.js, pass the ort module via the constructor:
25
+ * new MrzOcr({ ort: await import('onnxruntime-node') })
26
+ */
27
+ init(): Promise<void>;
28
+ /**
29
+ * Recognize MRZ text from a cropped MRZ image.
30
+ * The image should contain just the MRZ region (output of detection stage).
31
+ */
32
+ recognize(image: Image_2): Promise<MrzOcrResult>;
33
+ /**
34
+ * Segment the MRZ image into individual character images.
35
+ * Uses Otsu thresholding and connected component analysis.
36
+ */
37
+ private segmentCharacters;
38
+ /**
39
+ * Run batch inference on character images.
40
+ * Preprocesses all characters into a single tensor for efficient inference.
41
+ */
42
+ private predictBatch;
43
+ }
44
+
45
+ export declare interface MrzOcrOptions {
46
+ /** URL or file path to the ONNX model */
47
+ modelPath?: string;
48
+ /** Minimum characters per line to be considered valid */
49
+ minCharsPerLine?: number;
50
+ /** Maximum number of MRZ lines to keep */
51
+ maxLines?: number;
52
+ /** Provide the ONNX Runtime module directly (for Node.js, pass `import('onnxruntime-node')`) */
53
+ ort?: OrtModule;
54
+ }
55
+
56
+ export declare interface MrzOcrResult {
57
+ lines: string[];
58
+ confidence: number[][];
59
+ }
60
+
61
+ declare interface OrtModule {
62
+ InferenceSession: {
63
+ create(path: string, options?: {
64
+ executionProviders?: string[];
65
+ }): Promise<OrtSession>;
66
+ };
67
+ Tensor: new (type: string, data: Float32Array, dims: number[]) => OrtTensor;
68
+ }
69
+
70
+ declare interface OrtSession {
71
+ run(feeds: Record<string, OrtTensor>): Promise<Record<string, OrtTensor>>;
72
+ }
73
+
74
+ declare interface OrtTensor {
75
+ data: Float32Array;
76
+ }
77
+
78
+ export { }
package/dist/index.js ADDED
@@ -0,0 +1,130 @@
1
+ import { crop as e, fromMask as t, getRois as n, grey as r, resize as i, threshold as a } from "image-js";
2
+ //#region src/symbols.ts
3
+ var o = [..."0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ<"], s = 2e7;
4
+ function c(e) {
5
+ if (e.includes("..")) throw Error("Invalid model path: path traversal (..) not allowed");
6
+ if (e.startsWith("http://") || e.startsWith("https://")) {
7
+ if (!e.endsWith(".onnx")) throw Error("Invalid model URL: must end with .onnx");
8
+ return;
9
+ }
10
+ if (/^[a-z]+:\/\//i.test(e)) throw Error("Invalid model path: only http(s) URLs or local paths allowed");
11
+ if (!e.endsWith(".onnx")) throw Error("Invalid model path: must end with .onnx");
12
+ }
13
+ var l = {
14
+ positive: !0,
15
+ negative: !1,
16
+ minSurface: 5,
17
+ minRatio: .3,
18
+ maxRatio: 3,
19
+ algorithm: "otsu"
20
+ }, u = class {
21
+ session = null;
22
+ ort = null;
23
+ options;
24
+ ortOverride;
25
+ constructor(e = {}) {
26
+ let t = e.modelPath ?? "mrz-cnn.onnx";
27
+ c(t), this.options = {
28
+ modelPath: t,
29
+ minCharsPerLine: e.minCharsPerLine ?? 5,
30
+ maxLines: e.maxLines ?? 3
31
+ }, this.ortOverride = e.ort;
32
+ }
33
+ async init() {
34
+ this.ort = this.ortOverride ? this.ortOverride : await import("onnxruntime-web"), this.session = await this.ort.InferenceSession.create(this.options.modelPath, { executionProviders: ["wasm"] });
35
+ }
36
+ async recognize(e) {
37
+ if (e.width * e.height > s) throw Error(`Image too large (${e.width}x${e.height}). Max ${s} pixels.`);
38
+ let { charImages: t, lineBreaks: n } = this.segmentCharacters(e);
39
+ if (t.length === 0) return {
40
+ lines: [],
41
+ confidence: []
42
+ };
43
+ let r = await this.predictBatch(t), i = [], a = [], o = "", c = [], l = 0;
44
+ for (let e = 0; e < r.length; e++) n.includes(e) && o.length > 0 && (i.push(o), a.push(c), o = "", c = []), o += r[l].char, c.push(r[l].confidence), l++;
45
+ return o.length > 0 && (i.push(o), a.push(c)), {
46
+ lines: i,
47
+ confidence: a
48
+ };
49
+ }
50
+ segmentCharacters(i) {
51
+ let o = i.colorModel === "GREY" ? i : r(i), s = a(o, { algorithm: l.algorithm }), c = t(s), u = n(c, {
52
+ minSurface: l.minSurface,
53
+ kind: "black"
54
+ });
55
+ if (u.length === 0 && (u = n(c, {
56
+ minSurface: l.minSurface,
57
+ kind: "white"
58
+ })), u.length === 0) return {
59
+ charImages: [],
60
+ lineBreaks: []
61
+ };
62
+ let d = u.map((e) => ({
63
+ roi: e,
64
+ minX: e.origin.column,
65
+ minY: e.origin.row,
66
+ maxX: e.origin.column + e.width,
67
+ maxY: e.origin.row + e.height,
68
+ width: e.width,
69
+ height: e.height,
70
+ centerY: e.origin.row + e.height / 2
71
+ })).filter((e) => {
72
+ let t = e.width / e.height;
73
+ return t >= l.minRatio && t <= l.maxRatio;
74
+ }), f = i.height / 6, p = [], m = [], h = [...d].sort((e, t) => e.centerY - t.centerY);
75
+ for (let e of h) m.length === 0 || Math.abs(e.centerY - m[0].centerY) < f ? m.push(e) : (p.push(m), m = [e]);
76
+ m.length > 0 && p.push(m);
77
+ let g = p.filter((e) => e.length >= this.options.minCharsPerLine).slice(-this.options.maxLines), _ = [], v = [];
78
+ for (let t of g) {
79
+ v.push(_.length);
80
+ let n = t.sort((e, t) => e.minX - t.minX);
81
+ for (let t of n) {
82
+ let n = e(o, {
83
+ origin: {
84
+ column: t.minX,
85
+ row: t.minY
86
+ },
87
+ width: t.width,
88
+ height: t.height
89
+ });
90
+ _.push(n);
91
+ }
92
+ }
93
+ return {
94
+ charImages: _,
95
+ lineBreaks: v
96
+ };
97
+ }
98
+ async predictBatch(e) {
99
+ if (!this.session || !this.ort) throw Error("MrzOcr not initialized. Call init() first.");
100
+ let t = this.ort, n = e.length, a = new Float32Array(n * 1 * 20 * 20);
101
+ for (let t = 0; t < e.length; t++) {
102
+ let n = i(e[t], {
103
+ width: 20,
104
+ height: 20
105
+ }), o = n.colorModel === "GREY" ? n : r(n), s = t * 20 * 20;
106
+ for (let e = 0; e < 20; e++) for (let t = 0; t < 20; t++) a[s + e * 20 + t] = o.getValue(t, e, 0) / 255;
107
+ }
108
+ let s = new t.Tensor("float32", a, [
109
+ n,
110
+ 1,
111
+ 20,
112
+ 20
113
+ ]), c = await this.session.run({ input: s }), l = c[Object.keys(c)[0]].data, u = o.length, d = [];
114
+ for (let e = 0; e < n; e++) {
115
+ let t = e * u, n = 0, r = -Infinity, i = 0, a = new Float32Array(u);
116
+ for (let e = 0; e < u; e++) a[e] = Math.exp(l[t + e]), i += a[e];
117
+ for (let e = 0; e < u; e++) {
118
+ let t = a[e] / i;
119
+ t > r && (r = t, n = e);
120
+ }
121
+ d.push({
122
+ char: o[n],
123
+ confidence: r
124
+ });
125
+ }
126
+ return d;
127
+ }
128
+ };
129
+ //#endregion
130
+ export { o as MRZ_SYMBOLS, u as MrzOcr };
@@ -0,0 +1,44 @@
1
+ {
2
+ "symbols": [
3
+ "0",
4
+ "1",
5
+ "2",
6
+ "3",
7
+ "4",
8
+ "5",
9
+ "6",
10
+ "7",
11
+ "8",
12
+ "9",
13
+ "A",
14
+ "B",
15
+ "C",
16
+ "D",
17
+ "E",
18
+ "F",
19
+ "G",
20
+ "H",
21
+ "I",
22
+ "J",
23
+ "K",
24
+ "L",
25
+ "M",
26
+ "N",
27
+ "O",
28
+ "P",
29
+ "Q",
30
+ "R",
31
+ "S",
32
+ "T",
33
+ "U",
34
+ "V",
35
+ "W",
36
+ "X",
37
+ "Y",
38
+ "Z",
39
+ "<"
40
+ ],
41
+ "input_size": 20,
42
+ "num_classes": 37,
43
+ "accuracy": 1.0
44
+ }
Binary file
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@mrbilit/mrz-ocr",
3
+ "version": "2.0.0",
4
+ "description": "MRZ character recognition using ONNX CNN model",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
13
+ },
14
+ "files": ["dist", "models"],
15
+ "scripts": {
16
+ "build": "vite build",
17
+ "dev": "vite build --watch",
18
+ "typecheck": "tsc --noEmit"
19
+ },
20
+ "dependencies": {
21
+ "image-js": "^1.5.0",
22
+ "onnxruntime-web": "^1.24.0"
23
+ },
24
+ "devDependencies": {
25
+ "typescript": "^5.8.0",
26
+ "vite": "^8.0.0",
27
+ "vite-plugin-dts": "^4.5.0"
28
+ },
29
+ "license": "AGPL-3.0-or-later"
30
+ }