@trustchex/react-native-sdk 1.472.0 → 1.475.1
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/android/build.gradle +3 -3
- package/android/src/main/java/com/trustchex/reactnativesdk/camera/TrustchexCameraView.kt +147 -9
- package/ios/Camera/TrustchexCameraView.swift +92 -19
- package/lib/module/Screens/Debug/MRZTestScreen.js +121 -147
- package/lib/module/Screens/Static/ResultScreen.js +5 -0
- package/lib/module/Shared/Components/IdentityDocumentCamera.js +38 -4
- package/lib/module/Shared/Libs/MRZ_KNOWN_ISSUES.md +112 -0
- package/lib/module/Shared/Libs/mrz.utils.js +639 -16
- package/lib/module/Shared/Libs/mrzFrameAggregator.js +301 -0
- package/lib/module/Shared/Libs/mrzOcrIntegration.js +109 -0
- package/lib/module/version.js +1 -1
- package/lib/typescript/src/Screens/Debug/MRZTestScreen.d.ts.map +1 -1
- package/lib/typescript/src/Screens/Static/ResultScreen.d.ts.map +1 -1
- package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.d.ts.map +1 -1
- package/lib/typescript/src/Shared/Libs/mrz.utils.d.ts +8 -0
- package/lib/typescript/src/Shared/Libs/mrz.utils.d.ts.map +1 -1
- package/lib/typescript/src/Shared/Libs/mrzFrameAggregator.d.ts +76 -0
- package/lib/typescript/src/Shared/Libs/mrzFrameAggregator.d.ts.map +1 -0
- package/lib/typescript/src/Shared/Libs/mrzOcrIntegration.d.ts +73 -0
- package/lib/typescript/src/Shared/Libs/mrzOcrIntegration.d.ts.map +1 -0
- package/lib/typescript/src/version.d.ts +1 -1
- package/package.json +15 -10
- package/src/Screens/Debug/MRZTestScreen.tsx +137 -166
- package/src/Screens/Static/ResultScreen.tsx +5 -0
- package/src/Shared/Components/IdentityDocumentCamera.tsx +46 -6
- package/src/Shared/Libs/MRZ_KNOWN_ISSUES.md +112 -0
- package/src/Shared/Libs/mrz.utils.ts +704 -11
- package/src/Shared/Libs/mrzFrameAggregator.ts +370 -0
- package/src/Shared/Libs/mrzOcrIntegration.ts +175 -0
- package/src/version.ts +1 -1
|
@@ -4,99 +4,80 @@ import React, { useState, useRef, useCallback } from 'react';
|
|
|
4
4
|
import { View, StyleSheet, Text, ScrollView, StatusBar, TouchableOpacity } from 'react-native';
|
|
5
5
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
|
6
6
|
import { TrustchexCamera } from "../../Shared/Components/TrustchexCamera.js";
|
|
7
|
-
import
|
|
7
|
+
import { MRZFrameAggregator } from "../../Shared/Libs/mrzFrameAggregator.js";
|
|
8
8
|
import { useKeepAwake } from "../../Shared/Libs/native-keep-awake.utils.js";
|
|
9
|
+
|
|
10
|
+
// All per-frame display data lives in ONE state object so a processed frame
|
|
11
|
+
// triggers a SINGLE re-render instead of 7 separate setState calls.
|
|
9
12
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
13
|
+
const EMPTY_VIEW = {
|
|
14
|
+
raw: '',
|
|
15
|
+
voted: 'Voting…',
|
|
16
|
+
valid: false,
|
|
17
|
+
frames: 0,
|
|
18
|
+
stable: false
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// Process the live OCR stream at this cadence. The camera delivers frames faster
|
|
22
|
+
// than the MRZ validation/voting can run; gating here keeps the JS thread free to
|
|
23
|
+
// service touches (so the buttons stay responsive) while still converging fast.
|
|
24
|
+
const MRZ_PROCESS_INTERVAL_MS = 400;
|
|
10
25
|
const MRZTestScreen = () => {
|
|
11
26
|
useKeepAwake();
|
|
12
27
|
const cameraRef = useRef(null);
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
const [
|
|
16
|
-
const [rawHistory, setRawHistory] = useState([]);
|
|
28
|
+
|
|
29
|
+
// Single consolidated view-state; one setState per processed frame.
|
|
30
|
+
const [view, setView] = useState(EMPTY_VIEW);
|
|
17
31
|
const [isPaused, setIsPaused] = useState(false);
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
});
|
|
27
|
-
const handleCameraLayout = useCallback(e => {
|
|
28
|
-
const {
|
|
29
|
-
width,
|
|
30
|
-
height
|
|
31
|
-
} = e.nativeEvent.layout;
|
|
32
|
-
setCameraLayout({
|
|
33
|
-
width,
|
|
34
|
-
height
|
|
35
|
-
});
|
|
36
|
-
}, []);
|
|
32
|
+
|
|
33
|
+
// Refs for values the frame handler reads but that must NOT re-create it:
|
|
34
|
+
// keeping handleFrame identity-stable avoids re-subscribing the camera and
|
|
35
|
+
// keeps it cheap.
|
|
36
|
+
const pausedRef = useRef(false);
|
|
37
|
+
const lastProcessedAt = useRef(0);
|
|
38
|
+
const aggregator = useRef(new MRZFrameAggregator({
|
|
39
|
+
stabilityTarget: 2
|
|
40
|
+
}));
|
|
37
41
|
const handleFrame = useCallback(event => {
|
|
38
|
-
if (
|
|
39
|
-
|
|
40
|
-
|
|
42
|
+
if (pausedRef.current) return;
|
|
43
|
+
|
|
44
|
+
// Throttle the ENTIRE handler. Drop frames that arrive inside the window
|
|
45
|
+
// before doing ANY work (no parsing, no setState) so touch events land.
|
|
46
|
+
const now = Date.now();
|
|
47
|
+
if (now - lastProcessedAt.current < MRZ_PROCESS_INTERVAL_MS) return;
|
|
41
48
|
const frame = event.nativeEvent.frame;
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
};
|
|
57
|
-
});
|
|
58
|
-
setOverlayBlocks(blocks);
|
|
59
|
-
} else {
|
|
60
|
-
setOverlayBlocks([]);
|
|
61
|
-
}
|
|
62
|
-
if (frame.resultText) {
|
|
63
|
-
setRawHistory(prev => {
|
|
64
|
-
if (prev[0] === frame.resultText) {
|
|
65
|
-
return prev;
|
|
66
|
-
}
|
|
67
|
-
const next = [frame.resultText, ...prev];
|
|
68
|
-
return next.slice(0, 20);
|
|
69
|
-
});
|
|
70
|
-
}
|
|
71
|
-
if (frame.resultText && frame.resultText.includes('<')) {
|
|
72
|
-
// Extract MRZ-only text from detected blocks
|
|
73
|
-
let mrzOnlyText = '';
|
|
74
|
-
if (frame.textBlocks && frame.textBlocks.length > 0) {
|
|
75
|
-
// Filter blocks that look like MRZ (contain < and are in bottom half of frame)
|
|
76
|
-
const frameHeight = frame.height;
|
|
77
|
-
const bottomThreshold = frameHeight * 0.5;
|
|
78
|
-
const mrzBlocks = frame.textBlocks.filter(block => {
|
|
79
|
-
const blockText = block.text || '';
|
|
80
|
-
const hasFillers = blockText.includes('<');
|
|
81
|
-
const isInBottomArea = block.blockFrame ? block.blockFrame.y > bottomThreshold : false;
|
|
82
|
-
const isLongEnough = blockText.length >= 12;
|
|
83
|
-
return hasFillers && isInBottomArea && isLongEnough;
|
|
84
|
-
});
|
|
85
|
-
if (mrzBlocks.length > 0) {
|
|
86
|
-
mrzOnlyText = mrzBlocks.map(b => b.text).join('\n');
|
|
87
|
-
}
|
|
88
|
-
}
|
|
49
|
+
const resultText = frame.resultText;
|
|
50
|
+
if (!resultText || !resultText.includes('<')) return;
|
|
51
|
+
lastProcessedAt.current = now;
|
|
52
|
+
|
|
53
|
+
// Collect every MRZ-looking block (contains a filler) regardless of
|
|
54
|
+
// position/length — the band is often fragmented and may not sit in the
|
|
55
|
+
// bottom half. Reconstruction downstream isolates it from front-of-card text.
|
|
56
|
+
const mrzBlocks = (frame.textBlocks ?? []).filter(b => (b.text || '').includes('<'));
|
|
57
|
+
const mrzOnlyText = mrzBlocks.length ? mrzBlocks.map(b => b.text).join('\n') : resultText;
|
|
58
|
+
const bandHeight = mrzBlocks.length ? Math.max(0, ...mrzBlocks.map(b => b.blockFrame?.height ?? 0)) : 0;
|
|
59
|
+
const consensus = aggregator.current.addFrame({
|
|
60
|
+
text: `${mrzOnlyText}\n${resultText}`,
|
|
61
|
+
weight: bandHeight > 0 ? bandHeight : 1
|
|
62
|
+
});
|
|
89
63
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
64
|
+
// One setState → one re-render.
|
|
65
|
+
setView({
|
|
66
|
+
raw: mrzOnlyText,
|
|
67
|
+
voted: consensus.mrz ?? `Voting… (${consensus.frames} frames)`,
|
|
68
|
+
valid: !!consensus.validation?.valid,
|
|
69
|
+
frames: consensus.frames,
|
|
70
|
+
stable: consensus.stable
|
|
71
|
+
});
|
|
72
|
+
}, []);
|
|
73
|
+
const togglePause = useCallback(() => {
|
|
74
|
+
pausedRef.current = !pausedRef.current;
|
|
75
|
+
setIsPaused(pausedRef.current);
|
|
76
|
+
}, []);
|
|
77
|
+
const resetVoting = useCallback(() => {
|
|
78
|
+
aggregator.current.reset();
|
|
79
|
+
setView(EMPTY_VIEW);
|
|
80
|
+
}, []);
|
|
100
81
|
return /*#__PURE__*/_jsxs(View, {
|
|
101
82
|
style: styles.container,
|
|
102
83
|
children: [/*#__PURE__*/_jsx(StatusBar, {
|
|
@@ -105,7 +86,6 @@ const MRZTestScreen = () => {
|
|
|
105
86
|
translucent: true
|
|
106
87
|
}), /*#__PURE__*/_jsxs(View, {
|
|
107
88
|
style: styles.cameraWrapper,
|
|
108
|
-
onLayout: handleCameraLayout,
|
|
109
89
|
children: [/*#__PURE__*/_jsx(TrustchexCamera, {
|
|
110
90
|
ref: cameraRef,
|
|
111
91
|
style: StyleSheet.absoluteFill,
|
|
@@ -115,28 +95,15 @@ const MRZTestScreen = () => {
|
|
|
115
95
|
enableTextRecognition: true,
|
|
116
96
|
enableBarcodeScanning: false,
|
|
117
97
|
includeBase64: false,
|
|
118
|
-
targetFps:
|
|
98
|
+
targetFps: 5,
|
|
119
99
|
onFrameAvailable: handleFrame
|
|
120
|
-
}),
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
left: bf.x * scaleX,
|
|
128
|
-
top: bf.y * scaleY,
|
|
129
|
-
width: bf.width * scaleX,
|
|
130
|
-
height: bf.height * scaleY
|
|
131
|
-
}],
|
|
132
|
-
children: block.isMrz && /*#__PURE__*/_jsx(View, {
|
|
133
|
-
style: styles.blockLabel,
|
|
134
|
-
children: /*#__PURE__*/_jsx(Text, {
|
|
135
|
-
style: styles.blockLabelText,
|
|
136
|
-
children: "MRZ"
|
|
137
|
-
})
|
|
138
|
-
})
|
|
139
|
-
}, i);
|
|
100
|
+
}), /*#__PURE__*/_jsx(View, {
|
|
101
|
+
pointerEvents: "none",
|
|
102
|
+
style: styles.scanGuide,
|
|
103
|
+
children: /*#__PURE__*/_jsx(Text, {
|
|
104
|
+
style: styles.scanGuideLabel,
|
|
105
|
+
children: "Align MRZ inside this frame"
|
|
106
|
+
})
|
|
140
107
|
})]
|
|
141
108
|
}), /*#__PURE__*/_jsx(SafeAreaView, {
|
|
142
109
|
style: styles.mrzPanel,
|
|
@@ -145,40 +112,47 @@ const MRZTestScreen = () => {
|
|
|
145
112
|
style: styles.scrollView,
|
|
146
113
|
children: /*#__PURE__*/_jsxs(View, {
|
|
147
114
|
style: styles.panelContent,
|
|
148
|
-
children: [/*#__PURE__*/
|
|
149
|
-
style: styles.
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
children:
|
|
154
|
-
|
|
115
|
+
children: [/*#__PURE__*/_jsxs(View, {
|
|
116
|
+
style: styles.buttonRow,
|
|
117
|
+
children: [/*#__PURE__*/_jsx(TouchableOpacity, {
|
|
118
|
+
style: styles.pauseButton,
|
|
119
|
+
onPress: togglePause,
|
|
120
|
+
children: /*#__PURE__*/_jsx(Text, {
|
|
121
|
+
style: styles.pauseButtonText,
|
|
122
|
+
children: isPaused ? 'Resume Processing' : 'Pause Processing'
|
|
123
|
+
})
|
|
124
|
+
}), /*#__PURE__*/_jsx(TouchableOpacity, {
|
|
125
|
+
style: styles.pauseButton,
|
|
126
|
+
onPress: resetVoting,
|
|
127
|
+
children: /*#__PURE__*/_jsx(Text, {
|
|
128
|
+
style: styles.pauseButtonText,
|
|
129
|
+
children: "Reset Voting"
|
|
130
|
+
})
|
|
131
|
+
})]
|
|
155
132
|
}), /*#__PURE__*/_jsx(Text, {
|
|
156
133
|
style: styles.title,
|
|
157
134
|
children: "MRZ Text Read"
|
|
158
|
-
}), /*#__PURE__*/
|
|
159
|
-
style: styles.sectionTitle,
|
|
160
|
-
children: "MRZ Raw Text"
|
|
161
|
-
}), /*#__PURE__*/_jsx(Text, {
|
|
162
|
-
style: styles.mrzText,
|
|
163
|
-
children: mrzRawText ? mrzRawText.split('\n').map((line, i) => `Line ${i + 1}: ${line} (${line.length} chars)`).join('\n') : 'No MRZ raw text'
|
|
164
|
-
}), /*#__PURE__*/_jsx(Text, {
|
|
135
|
+
}), /*#__PURE__*/_jsxs(Text, {
|
|
165
136
|
style: styles.sectionTitle,
|
|
166
|
-
children: "MRZ
|
|
137
|
+
children: ["\u2605 Multi-frame Voted MRZ \u2014 ", view.frames, " frames", view.valid ? ' · VALID' : '', view.stable ? ' · STABLE' : '']
|
|
167
138
|
}), /*#__PURE__*/_jsx(Text, {
|
|
168
|
-
style: [styles.mrzText,
|
|
169
|
-
children:
|
|
139
|
+
style: [styles.mrzText, view.valid ? styles.mrzValid : null],
|
|
140
|
+
children: formatLines(view.voted)
|
|
170
141
|
}), /*#__PURE__*/_jsx(Text, {
|
|
171
142
|
style: styles.sectionTitle,
|
|
172
|
-
children: "Raw Text
|
|
143
|
+
children: "MRZ Raw Text (this frame)"
|
|
173
144
|
}), /*#__PURE__*/_jsx(Text, {
|
|
174
145
|
style: styles.mrzText,
|
|
175
|
-
children:
|
|
146
|
+
children: view.raw ? formatLines(view.raw) : 'No MRZ raw text'
|
|
176
147
|
})]
|
|
177
148
|
})
|
|
178
149
|
})
|
|
179
150
|
})]
|
|
180
151
|
});
|
|
181
152
|
};
|
|
153
|
+
|
|
154
|
+
/** Render MRZ text as "Line N: <chars> (N chars)" rows. */
|
|
155
|
+
const formatLines = text => text.split('\n').map((line, i) => `Line ${i + 1}: ${line} (${line.length} chars)`).join('\n');
|
|
182
156
|
const styles = StyleSheet.create({
|
|
183
157
|
container: {
|
|
184
158
|
flex: 1,
|
|
@@ -188,31 +162,26 @@ const styles = StyleSheet.create({
|
|
|
188
162
|
flex: 2,
|
|
189
163
|
position: 'relative'
|
|
190
164
|
},
|
|
191
|
-
|
|
165
|
+
scanGuide: {
|
|
192
166
|
position: 'absolute',
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
boundingBoxMrz: {
|
|
199
|
-
borderColor: '#FFA500',
|
|
167
|
+
// Mirrors the native OCR crop: top 25% / bottom 12% / 4% side margins.
|
|
168
|
+
top: '25%',
|
|
169
|
+
bottom: '12%',
|
|
170
|
+
left: '4%',
|
|
171
|
+
right: '4%',
|
|
200
172
|
borderWidth: 2,
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
top: -14,
|
|
206
|
-
left: 0,
|
|
207
|
-
backgroundColor: '#FFA500',
|
|
208
|
-
paddingHorizontal: 4,
|
|
209
|
-
paddingVertical: 1,
|
|
210
|
-
borderRadius: 2
|
|
173
|
+
borderColor: '#00E5FF',
|
|
174
|
+
borderRadius: 6,
|
|
175
|
+
justifyContent: 'flex-start',
|
|
176
|
+
alignItems: 'center'
|
|
211
177
|
},
|
|
212
|
-
|
|
213
|
-
color: '#
|
|
214
|
-
fontSize:
|
|
215
|
-
fontWeight: 'bold'
|
|
178
|
+
scanGuideLabel: {
|
|
179
|
+
color: '#00E5FF',
|
|
180
|
+
fontSize: 10,
|
|
181
|
+
fontWeight: 'bold',
|
|
182
|
+
marginTop: -16,
|
|
183
|
+
backgroundColor: 'rgba(0,0,0,0.5)',
|
|
184
|
+
paddingHorizontal: 4
|
|
216
185
|
},
|
|
217
186
|
mrzPanel: {
|
|
218
187
|
flex: 1,
|
|
@@ -226,6 +195,11 @@ const styles = StyleSheet.create({
|
|
|
226
195
|
panelContent: {
|
|
227
196
|
padding: 10
|
|
228
197
|
},
|
|
198
|
+
buttonRow: {
|
|
199
|
+
flexDirection: 'row',
|
|
200
|
+
justifyContent: 'center',
|
|
201
|
+
gap: 8
|
|
202
|
+
},
|
|
229
203
|
pauseButton: {
|
|
230
204
|
alignSelf: 'center',
|
|
231
205
|
borderWidth: 1,
|
|
@@ -70,10 +70,15 @@ const ResultScreen = () => {
|
|
|
70
70
|
}
|
|
71
71
|
}, []);
|
|
72
72
|
const formatGender = useCallback(code => {
|
|
73
|
+
// The eID/NFC path stores sex as "M"/"F" (ICAO getStrCode), while the
|
|
74
|
+
// camera MRZ path (the `mrz` npm package) stores "male"/"female". Accept
|
|
75
|
+
// both so the ID scan shows the gender instead of "unspecified".
|
|
73
76
|
switch (code?.toUpperCase()) {
|
|
74
77
|
case 'M':
|
|
78
|
+
case 'MALE':
|
|
75
79
|
return t('eidScannerScreen.genderMale');
|
|
76
80
|
case 'F':
|
|
81
|
+
case 'FEMALE':
|
|
77
82
|
return t('eidScannerScreen.genderFemale');
|
|
78
83
|
default:
|
|
79
84
|
return t('eidScannerScreen.genderUnspecified');
|
|
@@ -8,6 +8,7 @@ import { TrustchexCamera } from "./TrustchexCamera.js";
|
|
|
8
8
|
import { NativeModules } from 'react-native';
|
|
9
9
|
import PermissionManager from "../Libs/permissions.utils.js";
|
|
10
10
|
import mrzUtils from "../Libs/mrz.utils.js";
|
|
11
|
+
import { MRZFrameAggregator } from "../Libs/mrzFrameAggregator.js";
|
|
11
12
|
import { useKeepAwake } from "../Libs/native-keep-awake.utils.js";
|
|
12
13
|
import { useIsFocused } from '@react-navigation/native';
|
|
13
14
|
import { useTranslation } from 'react-i18next';
|
|
@@ -70,6 +71,12 @@ const IdentityDocumentCamera = ({
|
|
|
70
71
|
const lastValidMRZText = useRef(null);
|
|
71
72
|
const lastValidMRZFields = useRef(null);
|
|
72
73
|
const validMRZConsecutiveCount = useRef(0);
|
|
74
|
+
// Multi-frame per-character voting: fuses OCR across frames so a single-frame
|
|
75
|
+
// misread (B↔8, S↔5, <↔K, dropped/inserted digit) is outvoted instead of
|
|
76
|
+
// resetting progress. Drives the same `parsedMRZData`/stability the flow uses.
|
|
77
|
+
const mrzAggregator = useRef(new MRZFrameAggregator({
|
|
78
|
+
stabilityTarget: 2
|
|
79
|
+
}));
|
|
73
80
|
|
|
74
81
|
// Document type stability tracking - require consistent detections from good quality frames
|
|
75
82
|
const lastDetectedDocType = useRef('UNKNOWN');
|
|
@@ -149,6 +156,7 @@ const IdentityDocumentCamera = ({
|
|
|
149
156
|
lastValidMRZText.current = null;
|
|
150
157
|
lastValidMRZFields.current = null;
|
|
151
158
|
validMRZConsecutiveCount.current = 0;
|
|
159
|
+
mrzAggregator.current.reset();
|
|
152
160
|
cachedBarcode.current = null;
|
|
153
161
|
isCompletionCallbackInvoked.current = false;
|
|
154
162
|
isStepTransitionInProgress.current = false;
|
|
@@ -376,6 +384,8 @@ const IdentityDocumentCamera = ({
|
|
|
376
384
|
if (nextStepType === 'SCAN_ID_BACK') {
|
|
377
385
|
lastValidMRZText.current = null;
|
|
378
386
|
lastValidMRZFields.current = null;
|
|
387
|
+
// New MRZ expected on the back — start voting fresh.
|
|
388
|
+
mrzAggregator.current.reset();
|
|
379
389
|
}
|
|
380
390
|
validMRZConsecutiveCount.current = 0;
|
|
381
391
|
cachedBarcode.current = null; // Clear cached barcode on step change
|
|
@@ -538,13 +548,33 @@ const IdentityDocumentCamera = ({
|
|
|
538
548
|
// Prefer MRZ-only text if available (from detected MRZ blocks),
|
|
539
549
|
// otherwise fall back to all text (for backward compatibility)
|
|
540
550
|
const textForValidation = scannedText?.mrzOnlyText || text;
|
|
541
|
-
|
|
551
|
+
|
|
552
|
+
// Multi-frame per-character voting: feed this frame's OCR into the
|
|
553
|
+
// aggregator (weighted by the MRZ band's pixel height as a sharpness/
|
|
554
|
+
// closeness proxy — a taller band means more pixels per character, which
|
|
555
|
+
// ML Kit reads more reliably) and prefer the fused consensus. This outvotes
|
|
556
|
+
// single-frame OCR errors (B↔8, S↔5, <↔K, dropped/inserted digit) instead
|
|
557
|
+
// of letting one noisy frame reset progress.
|
|
558
|
+
const mrzBandHeight = scannedText?.blocks?.length ? Math.max(0, ...scannedText.blocks.map(b => b.blockFrame?.height ?? 0)) : 0;
|
|
559
|
+
const frameWeight = mrzBandHeight > 0 ? mrzBandHeight : 1;
|
|
560
|
+
const consensus = mrzAggregator.current.addFrame({
|
|
561
|
+
text: textForValidation,
|
|
562
|
+
weight: frameWeight
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
// Use the fused consensus when it is valid; otherwise fall back to a
|
|
566
|
+
// single-frame validation so document-type detection still works on the
|
|
567
|
+
// early frames. Only run the (expensive) single-frame pass when the
|
|
568
|
+
// consensus has NOT yet validated — once it has, the per-frame validation
|
|
569
|
+
// is redundant work that would needlessly load the JS thread.
|
|
570
|
+
const mrzValidationResult = consensus.validation?.valid && consensus.validation.fields ? consensus.validation : mrzUtils.validateMRZWithCorrections(textForValidation);
|
|
542
571
|
const parsedMRZData = {
|
|
543
572
|
valid: mrzValidationResult.valid,
|
|
544
573
|
fields: mrzValidationResult.fields || null
|
|
545
574
|
};
|
|
546
|
-
//
|
|
547
|
-
|
|
575
|
+
// Raw MRZ lines: prefer the fused/corrected consensus when valid, else the
|
|
576
|
+
// lightweight per-frame clean. correctedMrz is the noise-free recovered MRZ.
|
|
577
|
+
const mrzText = parsedMRZData.valid ? mrzValidationResult.correctedMrz ?? mrzUtils.fixMRZ(text) : null;
|
|
548
578
|
|
|
549
579
|
// CRITICAL: Only detect document type during initial scan step
|
|
550
580
|
// For SCAN_HOLOGRAM and beyond, use the locked detectedDocumentType to avoid
|
|
@@ -645,7 +675,11 @@ const IdentityDocumentCamera = ({
|
|
|
645
675
|
// else: Invalid/no MRZ - skip frame without resetting (allows temporary OCR noise)
|
|
646
676
|
|
|
647
677
|
// Check if we have enough consistent valid reads
|
|
648
|
-
const mrzStableAndValid =
|
|
678
|
+
const mrzStableAndValid =
|
|
679
|
+
// Either: the multi-frame voted consensus has converged (valid + stable),
|
|
680
|
+
consensus.stable && parsedMRZData?.valid === true ||
|
|
681
|
+
// or the legacy N-identical-reads path is satisfied.
|
|
682
|
+
validMRZConsecutiveCount.current >= REQUIRED_CONSISTENT_MRZ_READS && parsedMRZData?.valid === true && areMRZFieldsEqual(lastValidMRZFields.current, parsedMRZData?.fields);
|
|
649
683
|
|
|
650
684
|
// ============================================================================
|
|
651
685
|
// SCAN_ID_BACK STEP - Validate MRZ + barcode on back of ID card
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# MRZ Reading & Correction — Known Issues & Limitations
|
|
2
|
+
|
|
3
|
+
This documents the **inherent limitations** and **deliberate trade-offs** of the
|
|
4
|
+
MRZ camera scanner (`mrz.utils.ts`, `mrzFrameAggregator.ts`, and the native
|
|
5
|
+
`TrustchexCameraView` scan-frame OCR). These are not bugs — they are constraints
|
|
6
|
+
of the ICAO 9303 standard and OCR, and the design decisions made around them.
|
|
7
|
+
|
|
8
|
+
For the camera/OCR pipeline overview, see the doc comments in
|
|
9
|
+
`mrzFrameAggregator.ts` and `mrz.utils.ts`.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## 1. Check-digit MOD-10 collisions: `1↔L` and `6↔G` are indistinguishable
|
|
14
|
+
|
|
15
|
+
**Issue.** ICAO's 7-3-1 check digit weights letters by value (`A`=10 … `Z`=35),
|
|
16
|
+
then takes the result mod 10. Some OCR look-alikes are **mod-10 equal** to the
|
|
17
|
+
digit they resemble:
|
|
18
|
+
|
|
19
|
+
| Look-alike | Letter value | Digit | mod 10 |
|
|
20
|
+
| --- | --- | --- | --- |
|
|
21
|
+
| `L` ↔ `1` | 21 | 1 | both ≡ **1** |
|
|
22
|
+
| `G` ↔ `6` | 16 | 6 | both ≡ **6** |
|
|
23
|
+
|
|
24
|
+
So a document number read as `SPECL2345` produces the **exact same check digit**
|
|
25
|
+
as the correct `SPEC12345`. The MRZ **validates either way** — the check digit
|
|
26
|
+
cannot tell them apart.
|
|
27
|
+
|
|
28
|
+
**Impact.** If OCR reads a `1` as `L` (or `6` as `G`) in a *numeric* position of a
|
|
29
|
+
field that also accepts letters (e.g. an alphanumeric document number), the MRZ
|
|
30
|
+
will validate but may carry the letter form.
|
|
31
|
+
|
|
32
|
+
**Mitigation.**
|
|
33
|
+
|
|
34
|
+
- **Multi-frame voting** (`mrzFrameAggregator`) usually resolves it: across frames
|
|
35
|
+
the correct glyph wins the per-character vote.
|
|
36
|
+
- **NFC chip read (eID)** is authoritative — when available it overrides the
|
|
37
|
+
camera reading.
|
|
38
|
+
- Other digit↔letter confusions (`0↔O/Q/D`, `2↔Z`, `5↔S`, `8↔B`, `4↔A`, `1↔I`) are
|
|
39
|
+
**not** mod-10 collisions and *are* recovered to the correct digit.
|
|
40
|
+
|
|
41
|
+
Covered by `mrz.ambiguous.test.ts` ("documents the … MOD-10 collision").
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
## 2. Unprotected fields (TD1 optional / national-ID) can't be self-corrected
|
|
46
|
+
|
|
47
|
+
**Issue.** TD1's optional-data field (which on Turkish IDs holds the 11-digit
|
|
48
|
+
national ID / TC number) has **no per-field check digit** — it is only covered by
|
|
49
|
+
the composite check digit. If OCR corrupts the optional field **and** the
|
|
50
|
+
composite digit is also dropped/misread (common on glossy cards), there is no
|
|
51
|
+
independent anchor to detect the error.
|
|
52
|
+
|
|
53
|
+
**Mitigation.** High-confidence numeric normalisation (`B→8`, `S→5`, `O→0`, …) is
|
|
54
|
+
applied to the all-digit optional field, and **multi-frame voting** is the
|
|
55
|
+
primary safeguard. A single noisy frame is not trusted; the consensus is.
|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
## 3. Filler-letter normalisation is conservative in name fields (by design)
|
|
60
|
+
|
|
61
|
+
**Issue.** The `<` filler is frequently OCR'd as a letter (`K`, and under glare
|
|
62
|
+
`C`/`E`/`G`). We normalise these back to `<` **only** in structurally
|
|
63
|
+
unambiguous positions — the trailing filler run, the TD1 issuing-state field, and
|
|
64
|
+
the document-code filler.
|
|
65
|
+
|
|
66
|
+
**Deliberate trade-off.** A `K`/`C`/`E`/`G` **inside the name field** is *not*
|
|
67
|
+
normalised, because it cannot be distinguished from a real name letter (surnames
|
|
68
|
+
like `AKTAS`, `KAYA`, `CETIN`, or a 2-letter `AK` start with these). Corrupting a
|
|
69
|
+
real name is worse than a cosmetic filler glyph, so:
|
|
70
|
+
|
|
71
|
+
- Names are **preserved verbatim** — `AKTAS` stays `AKTAS`.
|
|
72
|
+
- A genuine filler misread *in the middle of a name region* may remain as a
|
|
73
|
+
letter (cosmetic only; the name field has no check digit, so validity is
|
|
74
|
+
unaffected, and the document number / dates — the identity-critical fields —
|
|
75
|
+
are always cleaned).
|
|
76
|
+
|
|
77
|
+
Covered by the "real letters in names are not eaten" tests in `mrz.utils.test.ts`.
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
## 4. OCR throughput / image-quality dependence
|
|
82
|
+
|
|
83
|
+
- **Resolution.** ML Kit needs ~16 px per character. A full-frame capture leaves a
|
|
84
|
+
30–44-char MRZ line below that floor, so the native pipeline **crops to the
|
|
85
|
+
on-screen scan frame and upscales + grayscale/contrast-enhances** before OCR.
|
|
86
|
+
The MRZ band must be reasonably filled within the on-screen guide.
|
|
87
|
+
- **Glare / lamination.** Heavy glare on a glossy laminated card still degrades
|
|
88
|
+
OCR; the enhancement helps but does not eliminate it. Steadying the card and
|
|
89
|
+
reducing direct glare improves convergence.
|
|
90
|
+
- **Throughput.** OCR runs at a few frames/sec; the JS handler is throttled
|
|
91
|
+
(~400 ms) and the recovery search is time-bounded so the UI stays responsive.
|
|
92
|
+
A pathological/garbage frame fails fast (recovery budget) rather than blocking.
|
|
93
|
+
|
|
94
|
+
---
|
|
95
|
+
|
|
96
|
+
## 5. Structurally broken frames are (correctly) rejected
|
|
97
|
+
|
|
98
|
+
Truncated or wrong-length line-sets that cannot be repaired by ambiguous-char
|
|
99
|
+
swaps or ±1 indel are reported **invalid** — the flow will not advance on a
|
|
100
|
+
guess. This is intentional: a wrong-but-plausible reconstruction must never leak
|
|
101
|
+
through. Multi-frame scanning accumulates until a check-digit-valid consensus is
|
|
102
|
+
reached.
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
## 6. Platform parity
|
|
107
|
+
|
|
108
|
+
The scan-frame ROI crop + grayscale/contrast enhancement exists on **both Android
|
|
109
|
+
(Kotlin) and iOS (Swift / Core Image)**. The JS recovery, voting, and field
|
|
110
|
+
mapping are shared, so behaviour is consistent across platforms. Debug builds
|
|
111
|
+
require Metro to be reachable; build a **Release** variant for untethered device
|
|
112
|
+
testing.
|