@smileid/web-components 11.5.0 → 11.6.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/dist/esm/DocumentCaptureScreens-DjSTdVP-.js +5398 -0
- package/dist/esm/DocumentCaptureScreens-DjSTdVP-.js.map +1 -0
- package/dist/esm/{Navigation-Xg565kcu.js → Navigation-6DH3vF4-.js} +2 -2
- package/dist/esm/{Navigation-Xg565kcu.js.map → Navigation-6DH3vF4-.js.map} +1 -1
- package/dist/esm/{PoweredBySmileId-CxbaihMu.js → PoweredBySmileId-DoKwoPUd.js} +424 -6
- package/dist/esm/PoweredBySmileId-DoKwoPUd.js.map +1 -0
- package/dist/esm/{SelfieCaptureScreens-D3KuMzZA.js → SelfieCaptureScreens-CtX-4Tco.js} +5 -6
- package/dist/esm/SelfieCaptureScreens-CtX-4Tco.js.map +1 -0
- package/dist/esm/combobox.js +1 -1
- package/dist/esm/document.js +1 -1
- package/dist/esm/end-user-consent.js +713 -2
- package/dist/esm/end-user-consent.js.map +1 -1
- package/dist/esm/index-BqyuTk9f.js +1366 -0
- package/dist/esm/{index-CUwa6MPI.js.map → index-BqyuTk9f.js.map} +1 -1
- package/dist/esm/localisation.js +1 -1
- package/dist/esm/main.js +14 -14
- package/dist/esm/navigation.js +1 -1
- package/dist/esm/{package-BmVbDNny.js → package-CjZI-cNQ.js} +177 -172
- package/dist/esm/{package-BmVbDNny.js.map → package-CjZI-cNQ.js.map} +1 -1
- package/dist/esm/selfie.js +1 -1
- package/dist/esm/smart-camera-web.js +32 -18
- package/dist/esm/smart-camera-web.js.map +1 -1
- package/dist/esm/totp-consent.js +731 -2
- package/dist/esm/totp-consent.js.map +1 -1
- package/dist/esm/validate.js +31 -0
- package/dist/esm/validate.js.map +1 -0
- package/dist/smart-camera-web.js +696 -321
- package/dist/smart-camera-web.js.map +1 -1
- package/dist/types/main.d.ts +7 -1
- package/dist/types/validate.d.ts +21 -0
- package/lib/components/document/src/DocumentCaptureScreens.js +97 -18
- package/lib/components/document/src/assets/lottie.d.ts +12 -0
- package/lib/components/document/src/assets/svg-inline.d.ts +8 -0
- package/lib/components/document/src/document-auto-capture/DocumentAutoCapture.stories.js +75 -0
- package/lib/components/document/src/document-auto-capture/DocumentAutoCapture.tsx +1458 -0
- package/lib/components/document/src/document-auto-capture/README.md +73 -0
- package/lib/components/document/src/document-auto-capture/assets/Greenbook_Shimmer.svg +42 -0
- package/lib/components/document/src/document-auto-capture/assets/ID_Back_Shimmer.svg +8 -0
- package/lib/components/document/src/document-auto-capture/assets/ID_Front_Shimmer.svg +20 -0
- package/lib/components/document/src/document-auto-capture/assets/Passport-Shimmer.svg +143 -0
- package/lib/components/document/src/document-auto-capture/assets/shimmers.ts +21 -0
- package/lib/components/document/src/document-auto-capture/assets/svg-raw.d.ts +4 -0
- package/lib/components/document/src/document-auto-capture/components/CaptureButton.tsx +122 -0
- package/lib/components/document/src/document-auto-capture/components/Overlay.tsx +167 -0
- package/lib/components/document/src/document-auto-capture/components/TuningPanel.tsx +856 -0
- package/lib/components/document/src/document-auto-capture/constants/captureLayout.ts +58 -0
- package/lib/components/document/src/document-auto-capture/detection/cvErrorRecovery.ts +40 -0
- package/lib/components/document/src/document-auto-capture/detection/documentAspect.ts +20 -0
- package/lib/components/document/src/document-auto-capture/detection/qualityScoring.ts +35 -0
- package/lib/components/document/src/document-auto-capture/detection/seamRejection.ts +209 -0
- package/lib/components/document/src/document-auto-capture/detection/synthesisTiming.ts +10 -0
- package/lib/components/document/src/document-auto-capture/hooks/useCamera.ts +117 -0
- package/lib/components/document/src/document-auto-capture/hooks/useCardDetection.ts +3059 -0
- package/lib/components/document/src/document-auto-capture/index.ts +4 -0
- package/lib/components/document/src/document-auto-capture/theme.ts +40 -0
- package/lib/components/document/src/document-auto-capture/utils/debug.ts +25 -0
- package/lib/components/document/src/document-auto-capture/utils/opencvLoader.ts +86 -0
- package/lib/components/document/src/document-capture-instructions/DocumentCaptureInstructions.tsx +327 -244
- package/lib/components/document/src/document-capture-review/DocumentCaptureReview.js +153 -189
- package/lib/components/document/src/document-capture-submission/DocumentCaptureSubmission.tsx +432 -0
- package/lib/components/document/src/document-capture-submission/index.js +3 -0
- package/lib/components/selfie/README.md +13 -0
- package/lib/components/signature-pad/package.json +1 -1
- package/lib/components/smart-camera-web/src/README.md +11 -0
- package/lib/components/smart-camera-web/src/SmartCameraWeb.js +25 -1
- package/lib/components/totp-consent/src/TotpConsent.js +1 -1
- package/package.json +8 -4
- package/dist/esm/DocumentCaptureScreens-ucJDu5nH.js +0 -2232
- package/dist/esm/DocumentCaptureScreens-ucJDu5nH.js.map +0 -1
- package/dist/esm/EndUserConsent-CsiwoThZ.js +0 -717
- package/dist/esm/EndUserConsent-CsiwoThZ.js.map +0 -1
- package/dist/esm/PoweredBySmileId-CxbaihMu.js.map +0 -1
- package/dist/esm/SelfieCaptureScreens-D3KuMzZA.js.map +0 -1
- package/dist/esm/TotpConsent-CRtmtudl.js +0 -734
- package/dist/esm/TotpConsent-CRtmtudl.js.map +0 -1
- package/dist/esm/index-CUwa6MPI.js +0 -1363
- package/dist/esm/styles-BTEClL7R.js +0 -419
- package/dist/esm/styles-BTEClL7R.js.map +0 -1
- /package/lib/components/document/src/assets/lottie/{taking photo of green book passport.lottie → greenbook.lottie} +0 -0
- /package/lib/components/document/src/assets/lottie/{taking photo of ID FLIP 2D.lottie → id-card-flip.lottie} +0 -0
- /package/lib/components/document/src/assets/lottie/{taking photo of ID.lottie → id-card.lottie} +0 -0
- /package/lib/components/document/src/assets/lottie/{taking photo of passport 2.lottie → passport.lottie} +0 -0
|
@@ -0,0 +1,3059 @@
|
|
|
1
|
+
import { useState, useEffect, useRef, useMemo } from 'preact/hooks';
|
|
2
|
+
import { isDebugEnabled } from '../utils/debug';
|
|
3
|
+
import {
|
|
4
|
+
translate,
|
|
5
|
+
getCurrentLocale,
|
|
6
|
+
} from '../../../../../domain/localisation';
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
clamp01,
|
|
10
|
+
frameQualityScore,
|
|
11
|
+
SYNTHETIC_CONTOUR_CONFIDENCE,
|
|
12
|
+
} from '../detection/qualityScoring';
|
|
13
|
+
import {
|
|
14
|
+
ASPECT_RATIOS,
|
|
15
|
+
classifyDiscoveryAspect,
|
|
16
|
+
isAspectKey,
|
|
17
|
+
type AspectKey,
|
|
18
|
+
type DiscoveryVote,
|
|
19
|
+
} from '../detection/documentAspect';
|
|
20
|
+
import {
|
|
21
|
+
isSeamFalseQuad,
|
|
22
|
+
type Corner as SeamCorner,
|
|
23
|
+
type Segment as SeamSegment,
|
|
24
|
+
} from '../detection/seamRejection';
|
|
25
|
+
|
|
26
|
+
// eslint-disable-next-line import/extensions
|
|
27
|
+
import { nextCvErrorRecoveryAction } from '../detection/cvErrorRecovery.ts';
|
|
28
|
+
// eslint-disable-next-line import/extensions
|
|
29
|
+
import { isSyntheticBridgeRecent } from '../detection/synthesisTiming.ts';
|
|
30
|
+
|
|
31
|
+
declare const cv: any;
|
|
32
|
+
|
|
33
|
+
// Internal debug flag: emit verbose detection telemetry only in dev + preview
|
|
34
|
+
// builds (compiled-in via __SMILE_DEBUG__; off in production). Same switch that
|
|
35
|
+
// gates the tuning panel. Evaluated once at module load.
|
|
36
|
+
const IS_DEBUG_MODE = isDebugEnabled();
|
|
37
|
+
|
|
38
|
+
// Helper to safely release a list of OpenCV Mats. Mats not yet allocated or
|
|
39
|
+
// already deleted are skipped. Used in `finally` blocks to avoid a wall of
|
|
40
|
+
// repetitive `if (m && !m.isDeleted()) m.delete();` lines.
|
|
41
|
+
const safeDelete = (
|
|
42
|
+
...mats: Array<
|
|
43
|
+
{ isDeleted?: () => boolean; delete: () => void } | null | undefined
|
|
44
|
+
>
|
|
45
|
+
) => {
|
|
46
|
+
mats.forEach((m) => {
|
|
47
|
+
try {
|
|
48
|
+
if (m && !m.isDeleted?.()) m.delete();
|
|
49
|
+
} catch {
|
|
50
|
+
// best-effort; continue releasing remaining mats
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const formatDebugError = (err: unknown) => {
|
|
56
|
+
if (err instanceof Error) {
|
|
57
|
+
return err.message ? `${err.name}: ${err.message}` : err.name;
|
|
58
|
+
}
|
|
59
|
+
if (typeof err === 'number' && Number.isFinite(err)) {
|
|
60
|
+
const cvAny = typeof cv === 'undefined' ? null : (cv as any);
|
|
61
|
+
if (cvAny && typeof cvAny.exceptionFromPtr === 'function') {
|
|
62
|
+
try {
|
|
63
|
+
const ex = cvAny.exceptionFromPtr(err);
|
|
64
|
+
const msg =
|
|
65
|
+
ex?.msg || ex?.what || ex?.message || ex?.toString?.() || null;
|
|
66
|
+
if (msg) return `OpenCV(${err}): ${msg}`;
|
|
67
|
+
} catch {
|
|
68
|
+
// Best-effort decode only; fall through to numeric fallback.
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return `OpenCV/WASM code: ${err}`;
|
|
72
|
+
}
|
|
73
|
+
if (typeof err === 'string') return err;
|
|
74
|
+
try {
|
|
75
|
+
return JSON.stringify(err);
|
|
76
|
+
} catch {
|
|
77
|
+
return String(err);
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export const COMPLIANCE_STATES = {
|
|
82
|
+
IDLE: 'idle', // Searching for a card
|
|
83
|
+
DETECTING: 'detecting', // Found a candidate, checking quality
|
|
84
|
+
STABLE: 'stable', // Quality passes, checking stability
|
|
85
|
+
CAPTURING: 'capturing', // Stability passed, capturing
|
|
86
|
+
SUCCESS: 'success', // Captured
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
// Phase 1 → Phase 2 detection states
|
|
90
|
+
const DETECTION_PHASE = {
|
|
91
|
+
DISCOVERY: 'discovery', // Phase 1: identify document type via aspect ratio, when documentType prop is not provided
|
|
92
|
+
CAPTURE: 'capture', // Phase 2: quality gating with locked guide box
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
// Number of agreeing frames required to lock document type.
|
|
96
|
+
// Lowered from 10 → 6: laminated/hand-held cards produce intermittent detections
|
|
97
|
+
// so a shorter streak is needed to reach consensus before votes are wiped.
|
|
98
|
+
const DISCOVERY_CONSENSUS_THRESHOLD = 6;
|
|
99
|
+
|
|
100
|
+
// If contour detection can't classify within this many PROCESSED frames, default
|
|
101
|
+
// to id-card. 30 ≈ 1s at the default 30fps processing throttle.
|
|
102
|
+
const DISCOVERY_TIMEOUT_FRAMES = 30;
|
|
103
|
+
|
|
104
|
+
// How many consecutive frames without a detected rectangle before resetting votes.
|
|
105
|
+
// Raised from 5 → 20: laminated cards and slight hand movement cause many gap
|
|
106
|
+
// frames between successful detections. A higher tolerance keeps accumulated votes
|
|
107
|
+
// alive long enough for the streak to complete.
|
|
108
|
+
const DISCOVERY_MISS_TOLERANCE = 20;
|
|
109
|
+
|
|
110
|
+
// Distance guidance: document fill percentage relative to ROI
|
|
111
|
+
// Below MIN_FILL → too far (card is tiny). Above MAX_FILL → too close (edges clipped).
|
|
112
|
+
// Min 65% ensures the document occupies ≥65-70% of the final captured image,
|
|
113
|
+
// satisfying the product requirement of a clear, readable scan.
|
|
114
|
+
const MIN_FILL_PERCENT = 65;
|
|
115
|
+
const MAX_FILL_PERCENT = 95;
|
|
116
|
+
// Minimum contour area to even consider (5% — catches far-away documents)
|
|
117
|
+
const MIN_CONTOUR_AREA_PERCENT = 0.05;
|
|
118
|
+
// During discovery, require at least this many grid cells to pass (out of 9).
|
|
119
|
+
// Less strict than full allQuadrantsPass (9/9) but still filters empty scenes.
|
|
120
|
+
const MIN_DISCOVERY_GRID_CELLS = 3;
|
|
121
|
+
|
|
122
|
+
// Adaptive contour-Canny high-threshold band (see the Sobel/magnitude block in
|
|
123
|
+
// the contour pass). CANNY_HIGH_MAX is the previously-fixed value: high-contrast
|
|
124
|
+
// scenes still cap here so the working metallic/high-contrast path cannot
|
|
125
|
+
// regress. CANNY_HIGH_MIN is the relaxed floor that lets faint document borders
|
|
126
|
+
// on plain backgrounds be detected. The low threshold is 40% of the resolved
|
|
127
|
+
// high threshold.
|
|
128
|
+
const CANNY_HIGH_MAX = 150;
|
|
129
|
+
const CANNY_HIGH_MIN = 60;
|
|
130
|
+
|
|
131
|
+
// --- Seam / straight-line rejection (parquet floors, slatted tables) ---
|
|
132
|
+
// HoughLinesP detects long straight background lines; a candidate quad whose
|
|
133
|
+
// edges sit on lines that overshoot its corners is a seam artifact, not a card
|
|
134
|
+
// (see detection/seamRejection.ts). Only the Hough acquisition knobs are
|
|
135
|
+
// tunable via settings; the geometric tolerances live in the helper.
|
|
136
|
+
const HOUGH_RHO = 1; // px distance resolution
|
|
137
|
+
const HOUGH_THETA = Math.PI / 180; // 1° angle resolution
|
|
138
|
+
|
|
139
|
+
// --- Contour rejection thresholds (shared by the in-guide pass and the
|
|
140
|
+
// off-guide detector) ---
|
|
141
|
+
// A real card border survives approxPolyDP with little perimeter loss;
|
|
142
|
+
// jagged background-texture paths compress 4-10×.
|
|
143
|
+
const PERI_COMPRESSION_MAX = 3.5;
|
|
144
|
+
// Minimum contour-area / bounding-box-area ratio for a card-shaped contour.
|
|
145
|
+
const MIN_RECT_FILL_RATIO = 0.65;
|
|
146
|
+
// Mobile content-region fallback (Fix 3): a low-contrast/tilted id-card that
|
|
147
|
+
// never forms a clean 4-corner quad can still be captured from the combined
|
|
148
|
+
// content bbox, but only after the region candidate has persisted this many
|
|
149
|
+
// consecutive frames — a transient blob must not trigger a capture.
|
|
150
|
+
const MOBILE_REGION_STABILITY_FRAMES = 8;
|
|
151
|
+
|
|
152
|
+
// --- Chroma-content gate (rolling average) ---
|
|
153
|
+
// A white keyboard / blank paper is rectangular, card-aspect and fills its
|
|
154
|
+
// rotated rect, so geometry alone can't reject it — but it has almost no
|
|
155
|
+
// colour. The per-frame chroma reading over the selected candidate's bbox is
|
|
156
|
+
// too noisy (AWB/exposure/contour jitter) to gate on directly, so we average
|
|
157
|
+
// the last CHROMA_AVG_WINDOW frames and only act once CHROMA_MIN_SAMPLES have
|
|
158
|
+
// accumulated (capture is still blocked by the stability counter meanwhile).
|
|
159
|
+
const CHROMA_AVG_WINDOW = 6;
|
|
160
|
+
const CHROMA_MIN_SAMPLES = 4;
|
|
161
|
+
// How many consecutive blur/glare misses to tolerate before discarding an
|
|
162
|
+
// already-captured best frame. Mobile cameras drop 1–2 frames to motion blur or
|
|
163
|
+
// AWB; nulling the candidate on the first stumble throws away a good capture and
|
|
164
|
+
// restarts the stability climb. Mirrors DISCOVERY_MISS_TOLERANCE in spirit.
|
|
165
|
+
const BEST_FRAME_MISS_TOLERANCE = 3;
|
|
166
|
+
|
|
167
|
+
// --- Distance metric source ---
|
|
168
|
+
// When true, compute docFillPercent from the presence edge map (independent of
|
|
169
|
+
// RETR_EXTERNAL). Set to false to revert to the legacy combined-contour metric.
|
|
170
|
+
const USE_PRESENCE_FILL_METRIC = true;
|
|
171
|
+
|
|
172
|
+
const getAutoCaptureFeedback = () => ({
|
|
173
|
+
positionDocument: translate('document.autoCapture.feedback.positionDocument'),
|
|
174
|
+
alignDocument: translate('document.autoCapture.feedback.alignDocument'),
|
|
175
|
+
placeDocument: translate('document.autoCapture.feedback.placeDocument'),
|
|
176
|
+
ensureDocumentVisible: translate(
|
|
177
|
+
'document.autoCapture.feedback.ensureDocumentVisible',
|
|
178
|
+
),
|
|
179
|
+
moveDocumentCloser: translate(
|
|
180
|
+
'document.autoCapture.feedback.moveDocumentCloser',
|
|
181
|
+
),
|
|
182
|
+
moveDocumentFurtherAway: translate(
|
|
183
|
+
'document.autoCapture.feedback.moveDocumentFurtherAway',
|
|
184
|
+
),
|
|
185
|
+
holdSteady: translate('document.autoCapture.feedback.holdSteady'),
|
|
186
|
+
detectingDocumentType: translate(
|
|
187
|
+
'document.autoCapture.feedback.detectingDocumentType',
|
|
188
|
+
),
|
|
189
|
+
processingFailed: translate('document.autoCapture.feedback.processingFailed'),
|
|
190
|
+
autoDetectionUnavailableRetry: translate(
|
|
191
|
+
'document.autoCapture.feedback.autoDetectionUnavailableRetry',
|
|
192
|
+
),
|
|
193
|
+
autoDetectionUnavailableManual: translate(
|
|
194
|
+
'document.autoCapture.feedback.autoDetectionUnavailableManual',
|
|
195
|
+
),
|
|
196
|
+
captured: translate('document.autoCapture.feedback.captured'),
|
|
197
|
+
captureFailed: translate('document.autoCapture.feedback.captureFailed'),
|
|
198
|
+
tooBlurry: translate('document.autoCapture.feedback.tooBlurry'),
|
|
199
|
+
glareDetectedAdjustLighting: translate(
|
|
200
|
+
'document.autoCapture.feedback.glareDetectedAdjustLighting',
|
|
201
|
+
),
|
|
202
|
+
holdStill: translate('document.autoCapture.feedback.holdStill'),
|
|
203
|
+
capturingDocument: translate(
|
|
204
|
+
'document.autoCapture.feedback.capturingDocument',
|
|
205
|
+
),
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// --- Off-guide detection (desktop / wide layouts) ---
|
|
209
|
+
const OFF_GUIDE_CHECK_INTERVAL = 5;
|
|
210
|
+
const OFF_GUIDE_DOWNSCALE_WIDTH = 320;
|
|
211
|
+
const OFF_GUIDE_MIN_MARGIN_X_CSS = 120;
|
|
212
|
+
const OFF_GUIDE_MIN_MARGIN_Y_CSS = 80;
|
|
213
|
+
|
|
214
|
+
// Downscale width for OpenCV detection. All CV ops run on this resolution;
|
|
215
|
+
// the full-res canvas is only read for the final captured image.
|
|
216
|
+
const PROCESS_WIDTH = 640;
|
|
217
|
+
|
|
218
|
+
function detectCardOutsideGuide(
|
|
219
|
+
video: HTMLVideoElement,
|
|
220
|
+
guideRectVideo: { x: number; y: number; w: number; h: number },
|
|
221
|
+
expectedAspect: number | null,
|
|
222
|
+
scratchCanvas: HTMLCanvasElement,
|
|
223
|
+
): boolean {
|
|
224
|
+
if (typeof cv === 'undefined' || !cv.Mat) return false;
|
|
225
|
+
const sw = OFF_GUIDE_DOWNSCALE_WIDTH;
|
|
226
|
+
const sh = Math.max(
|
|
227
|
+
1,
|
|
228
|
+
Math.round((video.videoHeight / video.videoWidth) * sw),
|
|
229
|
+
);
|
|
230
|
+
if (scratchCanvas.width !== sw) scratchCanvas.width = sw;
|
|
231
|
+
if (scratchCanvas.height !== sh) scratchCanvas.height = sh;
|
|
232
|
+
const sctx = scratchCanvas.getContext('2d', { willReadFrequently: true });
|
|
233
|
+
if (!sctx) return false;
|
|
234
|
+
sctx.drawImage(video, 0, 0, sw, sh);
|
|
235
|
+
|
|
236
|
+
const scaleX = video.videoWidth / sw;
|
|
237
|
+
const scaleY = video.videoHeight / sh;
|
|
238
|
+
|
|
239
|
+
let mat = null;
|
|
240
|
+
let gray = null;
|
|
241
|
+
let blurred = null;
|
|
242
|
+
let edges = null;
|
|
243
|
+
let contours = null;
|
|
244
|
+
let hierarchy = null;
|
|
245
|
+
let foundOutside = false;
|
|
246
|
+
try {
|
|
247
|
+
mat = cv.imread(scratchCanvas);
|
|
248
|
+
gray = new cv.Mat();
|
|
249
|
+
cv.cvtColor(mat, gray, cv.COLOR_RGBA2GRAY, 0);
|
|
250
|
+
blurred = new cv.Mat();
|
|
251
|
+
cv.GaussianBlur(gray, blurred, new cv.Size(5, 5), 0, 0, cv.BORDER_DEFAULT);
|
|
252
|
+
edges = new cv.Mat();
|
|
253
|
+
cv.Canny(blurred, edges, 50, 150);
|
|
254
|
+
contours = new cv.MatVector();
|
|
255
|
+
hierarchy = new cv.Mat();
|
|
256
|
+
cv.findContours(
|
|
257
|
+
edges,
|
|
258
|
+
contours,
|
|
259
|
+
hierarchy,
|
|
260
|
+
cv.RETR_EXTERNAL,
|
|
261
|
+
cv.CHAIN_APPROX_SIMPLE,
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
const minArea = sw * sh * 0.03;
|
|
265
|
+
let bestArea = 0;
|
|
266
|
+
let bestBR = null;
|
|
267
|
+
for (let i = 0; i < contours.size(); i++) {
|
|
268
|
+
const cnt = contours.get(i);
|
|
269
|
+
const area = cv.contourArea(cnt);
|
|
270
|
+
if (area > minArea && area > bestArea) {
|
|
271
|
+
const peri = cv.arcLength(cnt, true);
|
|
272
|
+
const approx = new cv.Mat();
|
|
273
|
+
cv.approxPolyDP(cnt, approx, 0.04 * peri, true);
|
|
274
|
+
if (approx.rows === 4) {
|
|
275
|
+
const br = cv.boundingRect(approx);
|
|
276
|
+
// Tilt-invariant aspect + fill from the rotated rect (see in-guide
|
|
277
|
+
// pass); br is kept only to report the off-guide card position.
|
|
278
|
+
const minRect = cv.minAreaRect(approx);
|
|
279
|
+
const rotW = minRect.size.width;
|
|
280
|
+
const rotH = minRect.size.height;
|
|
281
|
+
const aspect =
|
|
282
|
+
rotW > 0 && rotH > 0 ? Math.max(rotW / rotH, rotH / rotW) : 0;
|
|
283
|
+
const aspectOk = expectedAspect
|
|
284
|
+
? Math.abs(aspect - expectedAspect) / expectedAspect < 0.25
|
|
285
|
+
: aspect >= 1.15 && aspect <= 2.0;
|
|
286
|
+
// Same texture rejection as the in-guide pass: background quads are
|
|
287
|
+
// jagged paths that approxPolyDP compresses heavily and that fill
|
|
288
|
+
// their bounding box poorly. Without these gates a textured backdrop
|
|
289
|
+
// outside the guide fires false "Align document in frame" prompts.
|
|
290
|
+
const approxPeri = cv.arcLength(approx, true);
|
|
291
|
+
const isCompact =
|
|
292
|
+
approxPeri > 0 && peri / approxPeri < PERI_COMPRESSION_MAX;
|
|
293
|
+
const fillRatio = rotW > 0 && rotH > 0 ? area / (rotW * rotH) : 0;
|
|
294
|
+
if (aspectOk && isCompact && fillRatio > MIN_RECT_FILL_RATIO) {
|
|
295
|
+
bestArea = area;
|
|
296
|
+
bestBR = br;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
approx.delete();
|
|
300
|
+
}
|
|
301
|
+
cnt.delete();
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (bestBR) {
|
|
305
|
+
const cxVideo = (bestBR.x + bestBR.width / 2) * scaleX;
|
|
306
|
+
const cyVideo = (bestBR.y + bestBR.height / 2) * scaleY;
|
|
307
|
+
if (
|
|
308
|
+
cxVideo < guideRectVideo.x ||
|
|
309
|
+
cxVideo > guideRectVideo.x + guideRectVideo.w ||
|
|
310
|
+
cyVideo < guideRectVideo.y ||
|
|
311
|
+
cyVideo > guideRectVideo.y + guideRectVideo.h
|
|
312
|
+
) {
|
|
313
|
+
foundOutside = true;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
} catch {
|
|
317
|
+
// best-effort
|
|
318
|
+
} finally {
|
|
319
|
+
safeDelete(mat, gray, blurred, edges, contours, hierarchy);
|
|
320
|
+
}
|
|
321
|
+
return foundOutside;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export function useCardDetection(
|
|
325
|
+
videoRef: { current: HTMLVideoElement | null },
|
|
326
|
+
settings: Record<string, any>,
|
|
327
|
+
options: Record<string, any> = {},
|
|
328
|
+
) {
|
|
329
|
+
// Translated feedback strings. Memoized on the active locale so the ~18
|
|
330
|
+
// translate() lookups aren't rebuilt on every render (this hook re-renders
|
|
331
|
+
// on each setFeedback/setCaptureProgress/setComplianceState during capture).
|
|
332
|
+
const autoCaptureFeedback = useMemo(getAutoCaptureFeedback, [
|
|
333
|
+
getCurrentLocale(),
|
|
334
|
+
]);
|
|
335
|
+
const {
|
|
336
|
+
variant = 'fullscreen',
|
|
337
|
+
documentType = null,
|
|
338
|
+
captureMode = 'autoCapture',
|
|
339
|
+
autoCaptureTimeout = 20_000,
|
|
340
|
+
captureOrientation = 'landscape',
|
|
341
|
+
shouldRotateUi = false,
|
|
342
|
+
syncRoiToGuide = false,
|
|
343
|
+
skipGridCheck = false,
|
|
344
|
+
} = options;
|
|
345
|
+
// captureMode: 'autoCapture' | 'autoCaptureOnly' | 'manualCaptureOnly'
|
|
346
|
+
const autoCaptureTimeoutMs = Math.max(
|
|
347
|
+
3000,
|
|
348
|
+
Math.min(30000, autoCaptureTimeout),
|
|
349
|
+
);
|
|
350
|
+
const orientation =
|
|
351
|
+
captureOrientation === 'portrait' ? 'portrait' : 'landscape';
|
|
352
|
+
const orientAspect = (ratio: number) =>
|
|
353
|
+
orientation === 'portrait' ? 1 / ratio : ratio;
|
|
354
|
+
|
|
355
|
+
// If documentType is provided and valid, skip discovery entirely.
|
|
356
|
+
const providedDocType: AspectKey | null = isAspectKey(documentType)
|
|
357
|
+
? documentType
|
|
358
|
+
: null;
|
|
359
|
+
const initialPhase = providedDocType
|
|
360
|
+
? DETECTION_PHASE.CAPTURE
|
|
361
|
+
: DETECTION_PHASE.DISCOVERY;
|
|
362
|
+
const initialAspect = orientAspect(
|
|
363
|
+
providedDocType ? ASPECT_RATIOS[providedDocType] : ASPECT_RATIOS.passport,
|
|
364
|
+
);
|
|
365
|
+
|
|
366
|
+
const [feedback, setFeedback] = useState(
|
|
367
|
+
autoCaptureFeedback.positionDocument,
|
|
368
|
+
);
|
|
369
|
+
const [captureProgress, setCaptureProgress] = useState(0);
|
|
370
|
+
const [capturedImage, setCapturedImage] = useState<string | null>(null);
|
|
371
|
+
const [previewImage, setPreviewImage] = useState<string | null>(null);
|
|
372
|
+
const [complianceState, setComplianceState] = useState<string>(
|
|
373
|
+
COMPLIANCE_STATES.IDLE,
|
|
374
|
+
);
|
|
375
|
+
const [debugPath, setDebugPath] = useState<any>(null); // For drawing the green box on overlay
|
|
376
|
+
// Debug-only: the active detection ROI mapped to the video element's CSS
|
|
377
|
+
// box, for drawing an on-screen outline. Updated only when the rect
|
|
378
|
+
// actually changes (keyed via ref) to avoid a setState per frame.
|
|
379
|
+
const [debugRoi, setDebugRoi] = useState<{
|
|
380
|
+
x: number;
|
|
381
|
+
y: number;
|
|
382
|
+
w: number;
|
|
383
|
+
h: number;
|
|
384
|
+
} | null>(null);
|
|
385
|
+
const debugRoiKeyRef = useRef('');
|
|
386
|
+
const [debugInfo, setDebugInfo] = useState<Record<string, any>>({}); // For tuning panel
|
|
387
|
+
const debugInfoRef = useRef<Record<string, any>>({});
|
|
388
|
+
// Merge debug fields rather than replace: each gate emits only the values
|
|
389
|
+
// it computed, so the panel keeps the last-known docFill / grid / blur /
|
|
390
|
+
// glare visible together instead of blanking whichever the current frame's
|
|
391
|
+
// early-return path didn't include. Debug-only; setDebugInfo identity is
|
|
392
|
+
// stable so this needs no memoisation. Outside debug mode this is a no-op;
|
|
393
|
+
// inside debug mode it also skips patches that don't change displayed values.
|
|
394
|
+
const mergeDebugInfo = (patch: Record<string, unknown>) => {
|
|
395
|
+
if (!IS_DEBUG_MODE) return;
|
|
396
|
+
|
|
397
|
+
const { current } = debugInfoRef;
|
|
398
|
+
const hasChanged = Object.entries(patch).some(
|
|
399
|
+
([key, value]) => current[key] !== value,
|
|
400
|
+
);
|
|
401
|
+
if (!hasChanged) return;
|
|
402
|
+
|
|
403
|
+
const next = { ...current, ...patch };
|
|
404
|
+
debugInfoRef.current = next;
|
|
405
|
+
setDebugInfo(next);
|
|
406
|
+
};
|
|
407
|
+
const updateDebugPath = (path: any) => {
|
|
408
|
+
if (IS_DEBUG_MODE) setDebugPath(path);
|
|
409
|
+
};
|
|
410
|
+
// Latest distance fill %, stashed each frame so debug payloads emitted
|
|
411
|
+
// AFTER the contour block (blur/glare/capture gates) can still report it.
|
|
412
|
+
const latestDocFillRef = useRef(0);
|
|
413
|
+
// EMA of docFillPercent. Smooths distance jitter so a hand hovering near the
|
|
414
|
+
// fill thresholds doesn't toggle the "move closer/further" gate frame-to-frame.
|
|
415
|
+
// null until the first measurement; reset to null whenever the document is
|
|
416
|
+
// declared gone so a re-acquired doc doesn't inherit a stale average.
|
|
417
|
+
const docFillEmaRef = useRef<number | null>(null);
|
|
418
|
+
const [detectedDocType, setDetectedDocType] = useState<AspectKey | null>(
|
|
419
|
+
providedDocType,
|
|
420
|
+
); // null = not yet classified
|
|
421
|
+
const [guideAspectRatio, setGuideAspectRatio] = useState(initialAspect);
|
|
422
|
+
|
|
423
|
+
// Refs for loop management to avoid stale closures
|
|
424
|
+
const settingsRef = useRef(settings);
|
|
425
|
+
const stabilityRef = useRef<{
|
|
426
|
+
count: number;
|
|
427
|
+
lastCenter: { x: number; y: number } | null;
|
|
428
|
+
}>({ count: 0, lastCenter: null });
|
|
429
|
+
// Tracks the sharpest frame during the stability window. We keep both the
|
|
430
|
+
// full-frame submission image and an optional cropped preview so the review
|
|
431
|
+
// screen can show the cropped region while the API still receives the full
|
|
432
|
+
// frame.
|
|
433
|
+
const bestFrameRef = useRef<{
|
|
434
|
+
image: string | null;
|
|
435
|
+
preview: string | null;
|
|
436
|
+
score: number;
|
|
437
|
+
}>({ image: null, preview: null, score: 0 });
|
|
438
|
+
const isCapturingRef = useRef(false);
|
|
439
|
+
const canvasRef = useRef<
|
|
440
|
+
(HTMLCanvasElement & { _roiLogged?: boolean }) | null
|
|
441
|
+
>(null);
|
|
442
|
+
const detectionCanvasRef = useRef<HTMLCanvasElement | null>(null);
|
|
443
|
+
// Full-resolution crop of just the guide-box ROI. Contour detection runs
|
|
444
|
+
// here at native pixel fidelity (the 640px dsCanvas loses the card border).
|
|
445
|
+
const contourCanvasRef = useRef<HTMLCanvasElement | null>(null);
|
|
446
|
+
const detectionPhaseRef = useRef(initialPhase);
|
|
447
|
+
const discoveryRef = useRef<{
|
|
448
|
+
votes: DiscoveryVote[];
|
|
449
|
+
docType: AspectKey | null;
|
|
450
|
+
frameCount: number;
|
|
451
|
+
consecutiveMisses: number;
|
|
452
|
+
}>({
|
|
453
|
+
votes: [],
|
|
454
|
+
docType: providedDocType,
|
|
455
|
+
frameCount: 0,
|
|
456
|
+
consecutiveMisses: 0,
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
useEffect(() => {
|
|
460
|
+
settingsRef.current = settings;
|
|
461
|
+
}, [settings]);
|
|
462
|
+
|
|
463
|
+
const [captureOrigin, setCaptureOrigin] = useState<string | null>(null); // 'camera_auto_capture' | 'camera_manual_capture'
|
|
464
|
+
const [manualFallbackActive, setManualFallbackActive] = useState(false);
|
|
465
|
+
const [cvLoadFailed, setCvLoadFailed] = useState(false);
|
|
466
|
+
|
|
467
|
+
// Stores the most recent ROI coordinates so triggerManualCapture can crop on demand.
|
|
468
|
+
const latestCropCoordsRef = useRef<{
|
|
469
|
+
clampedX: number;
|
|
470
|
+
clampedY: number;
|
|
471
|
+
clampedW: number;
|
|
472
|
+
clampedH: number;
|
|
473
|
+
} | null>(null);
|
|
474
|
+
// Off-guide detection (desktop only): low-res scratch canvas + frame counter +
|
|
475
|
+
// last-known in-guide state to skip the scan once a card is locked in.
|
|
476
|
+
const offGuideCanvasRef = useRef<HTMLCanvasElement | null>(null);
|
|
477
|
+
const offGuideFrameCounterRef = useRef(0);
|
|
478
|
+
const inGuideDetectedRef = useRef(false);
|
|
479
|
+
// Consecutive frames in CAPTURE phase with no valid in-guide contour.
|
|
480
|
+
// Used to absorb intermittent detection misses (especially on book-style
|
|
481
|
+
// documents whose spine/page edges can momentarily break) before flipping
|
|
482
|
+
// the user-facing prompt to "Align document in frame".
|
|
483
|
+
const captureMissCounterRef = useRef(0);
|
|
484
|
+
// Timestamp (performance.now) of the last frame we actually ran detection on,
|
|
485
|
+
// and the timestamp before that — used to throttle the heavy CV pipeline to
|
|
486
|
+
// settingsRef.targetProcessingFps and to report the live processing rate.
|
|
487
|
+
const lastProcessedRef = useRef(0);
|
|
488
|
+
const prevProcessedRef = useRef(0);
|
|
489
|
+
// Set true if Lab chroma conversion is unavailable/throws on this device, so
|
|
490
|
+
// the chroma-fusion path (Fix 2) disables itself for the session and falls
|
|
491
|
+
// back to luminance-only edges.
|
|
492
|
+
const chromaUnavailableRef = useRef(false);
|
|
493
|
+
// Consecutive per-frame CV errors (outer catch). A persistent throw is almost
|
|
494
|
+
// always the optional chroma path leaving a malformed edge map that the
|
|
495
|
+
// downstream findContours/morphology then rejects every frame — which strands
|
|
496
|
+
// detection on "Processing failed". After a few in a row we disable chroma for
|
|
497
|
+
// the session so detection self-heals onto the luminance-only path. Reset on
|
|
498
|
+
// any successful frame.
|
|
499
|
+
const cvErrorStreakRef = useRef(0);
|
|
500
|
+
// When CV keeps throwing after optional chroma has been disabled, pause the
|
|
501
|
+
// hot detection loop so repeated state updates do not freeze the page.
|
|
502
|
+
const autoDetectionSuspendedRef = useRef(false);
|
|
503
|
+
// Consecutive frames a mobile content-region candidate has qualified (Fix 3).
|
|
504
|
+
// Gates the mobile region fallback so a transient blob can't trigger capture.
|
|
505
|
+
const regionStabilityRef = useRef(0);
|
|
506
|
+
// Rolling window of the selected candidate's mean bbox chroma (last
|
|
507
|
+
// CHROMA_AVG_WINDOW frames). Smooths the noisy per-frame reading so the
|
|
508
|
+
// chroma-content gate acts on a stable average. Cleared when no candidate.
|
|
509
|
+
const chromaWindowRef = useRef<number[]>([]);
|
|
510
|
+
// Geometry of the candidate selected as bestContour this frame, captured at
|
|
511
|
+
// the selection site (deep in the contour pass) so the composite quality
|
|
512
|
+
// score can read it at the later blur/glare/stability gates, where the
|
|
513
|
+
// contour-scope locals are out of scope. `aspect` is the normalized (>=1)
|
|
514
|
+
// rotated-rect aspect; `fillRatio` is the rotated-rect fill of a real quad
|
|
515
|
+
// (0 for synthetic); `synthetic` flags the inferred fallback rect.
|
|
516
|
+
const winnerGeomRef = useRef<{
|
|
517
|
+
aspect: number;
|
|
518
|
+
fillRatio: number;
|
|
519
|
+
synthetic: boolean;
|
|
520
|
+
}>({ aspect: 0, fillRatio: 0, synthetic: false });
|
|
521
|
+
// Consecutive blur/glare misses while a best frame is already held. Lets a
|
|
522
|
+
// transient bad frame pass without discarding the captured candidate
|
|
523
|
+
// (see BEST_FRAME_MISS_TOLERANCE). Reset once a frame reaches the stability
|
|
524
|
+
// section cleanly.
|
|
525
|
+
const bestFrameMissRef = useRef(0);
|
|
526
|
+
// Soften a transient gate failure instead of nuking capture progress: while a
|
|
527
|
+
// best frame is held and we're within BEST_FRAME_MISS_TOLERANCE, decay the
|
|
528
|
+
// stability count by 1 (the same pattern the blur/glare gates already use) so
|
|
529
|
+
// a single jittery frame doesn't drain the ring or flip the compliance state.
|
|
530
|
+
// Returns true when the failure was ABSORBED (decayed); false when tolerance
|
|
531
|
+
// is exceeded and the candidate is hard-reset/discarded.
|
|
532
|
+
const softFailStability = (): boolean => {
|
|
533
|
+
if (
|
|
534
|
+
bestFrameRef.current.image &&
|
|
535
|
+
bestFrameMissRef.current < BEST_FRAME_MISS_TOLERANCE
|
|
536
|
+
) {
|
|
537
|
+
bestFrameMissRef.current += 1;
|
|
538
|
+
stabilityRef.current.count = Math.max(0, stabilityRef.current.count - 1);
|
|
539
|
+
return true;
|
|
540
|
+
}
|
|
541
|
+
bestFrameMissRef.current = 0;
|
|
542
|
+
stabilityRef.current.count = 0;
|
|
543
|
+
bestFrameRef.current = { image: null, preview: null, score: 0 };
|
|
544
|
+
return false;
|
|
545
|
+
};
|
|
546
|
+
// Timestamp for the last genuine (non-synthetic) 4-corner card validation.
|
|
547
|
+
// Gates the desktop id-card synthetic fallback so it only bridges brief
|
|
548
|
+
// dropouts of a card that WAS being detected, rather than synthesizing one
|
|
549
|
+
// from background contours.
|
|
550
|
+
const lastRealCardAtRef = useRef<number | null>(null);
|
|
551
|
+
// Last detected card bounding rect in CANVAS coords. Updated whenever the
|
|
552
|
+
// contour-detection pass produces a 4-point card. Sticky across frames so
|
|
553
|
+
// intermittent contour misses don't fall back to the looser guide rect.
|
|
554
|
+
const latestCardRectRef = useRef<{
|
|
555
|
+
x: number;
|
|
556
|
+
y: number;
|
|
557
|
+
w: number;
|
|
558
|
+
h: number;
|
|
559
|
+
} | null>(null);
|
|
560
|
+
// Mirror of captureMode for access inside the processFrame closure.
|
|
561
|
+
const captureModeRef = useRef(captureMode);
|
|
562
|
+
useEffect(() => {
|
|
563
|
+
captureModeRef.current = captureMode;
|
|
564
|
+
}, [captureMode]);
|
|
565
|
+
|
|
566
|
+
// Configurable fallback: surface manual button if auto-capture hasn't fired yet.
|
|
567
|
+
useEffect(() => {
|
|
568
|
+
setManualFallbackActive(false);
|
|
569
|
+
if (captureMode !== 'autoCapture') return undefined;
|
|
570
|
+
const timer = setTimeout(
|
|
571
|
+
() => setManualFallbackActive(true),
|
|
572
|
+
autoCaptureTimeoutMs,
|
|
573
|
+
);
|
|
574
|
+
return () => clearTimeout(timer);
|
|
575
|
+
}, [captureMode, autoCaptureTimeoutMs]);
|
|
576
|
+
|
|
577
|
+
// 20-second OpenCV load timeout.
|
|
578
|
+
useEffect(() => {
|
|
579
|
+
const timer = setTimeout(() => {
|
|
580
|
+
if (typeof cv === 'undefined' || !cv.Mat) setCvLoadFailed(true);
|
|
581
|
+
}, 20_000);
|
|
582
|
+
return () => clearTimeout(timer);
|
|
583
|
+
}, []);
|
|
584
|
+
|
|
585
|
+
useEffect(() => {
|
|
586
|
+
let animationFrameId: number;
|
|
587
|
+
|
|
588
|
+
const processFrame = () => {
|
|
589
|
+
// 0. Stop if capturing or video not ready
|
|
590
|
+
if (isCapturingRef.current) return;
|
|
591
|
+
if (autoDetectionSuspendedRef.current) return;
|
|
592
|
+
if (!videoRef.current) {
|
|
593
|
+
animationFrameId = requestAnimationFrame(processFrame);
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
const video = videoRef.current;
|
|
598
|
+
if (video.readyState !== 4 || typeof cv === 'undefined' || !cv.Mat) {
|
|
599
|
+
animationFrameId = requestAnimationFrame(processFrame);
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// Throttle the heavy CV pipeline to a target processing rate (default
|
|
604
|
+
// 30fps). rAF fires at the display refresh rate (60/90/120Hz/adaptive), so
|
|
605
|
+
// a time-based gate keeps the real detection rate — and every frame-count
|
|
606
|
+
// constant tuned against it — consistent across devices and under thermal
|
|
607
|
+
// load. Skipped ticks reschedule and return BEFORE any Mat/canvas/state
|
|
608
|
+
// work, so they cost nothing and the UI naturally holds its last state.
|
|
609
|
+
const nowTs = performance.now();
|
|
610
|
+
const targetFps = settingsRef.current.targetProcessingFps ?? 30;
|
|
611
|
+
// ~4ms slack so a 33ms target doesn't beat against 16.7ms vsync into 20fps.
|
|
612
|
+
const minInterval = 1000 / targetFps - 4;
|
|
613
|
+
if (nowTs - lastProcessedRef.current < minInterval) {
|
|
614
|
+
animationFrameId = requestAnimationFrame(processFrame);
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
prevProcessedRef.current = lastProcessedRef.current;
|
|
618
|
+
lastProcessedRef.current = nowTs;
|
|
619
|
+
if (prevProcessedRef.current > 0) {
|
|
620
|
+
const dt = nowTs - prevProcessedRef.current;
|
|
621
|
+
if (dt > 0) mergeDebugInfo({ procFps: Math.round(1000 / dt) });
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// 1. Setup CV structs
|
|
625
|
+
let fullFrame: any = null;
|
|
626
|
+
let src: any = null;
|
|
627
|
+
let gray: any = null;
|
|
628
|
+
let contourFull: any = null;
|
|
629
|
+
let contourGray: any = null;
|
|
630
|
+
let blurred: any = null;
|
|
631
|
+
let edges: any = null;
|
|
632
|
+
let presenceEdges: any = null;
|
|
633
|
+
let presenceBlurred: any = null;
|
|
634
|
+
let contours: any = null;
|
|
635
|
+
let hierarchy: any = null;
|
|
636
|
+
let laplacian: any = null;
|
|
637
|
+
let mean: any = null;
|
|
638
|
+
let stdDev: any = null;
|
|
639
|
+
let glareMask: any = null;
|
|
640
|
+
// Chroma-fusion Mats (Fix 2). Declared here so the shared finally frees
|
|
641
|
+
// them even if a gate returns mid-pipeline.
|
|
642
|
+
let contourRgb: any = null;
|
|
643
|
+
let contourLab: any = null;
|
|
644
|
+
let labPlanes: any = null;
|
|
645
|
+
let aPlane: any = null;
|
|
646
|
+
let bPlane: any = null;
|
|
647
|
+
let aBlur: any = null;
|
|
648
|
+
let bBlur: any = null;
|
|
649
|
+
let aEdges: any = null;
|
|
650
|
+
let bEdges: any = null;
|
|
651
|
+
// Per-pixel chroma magnitude, kept alive past the chroma block for the
|
|
652
|
+
// Level 2 content gate (measured per detected rectangle).
|
|
653
|
+
let chromaMag: any = null;
|
|
654
|
+
|
|
655
|
+
// Inner function so each early `return` inside the detection pipeline
|
|
656
|
+
// exits only this helper (then falls through to the shared finally
|
|
657
|
+
// cleanup below). Splitting the body out of the surrounding try keeps
|
|
658
|
+
// the per-function code-path graph small enough that the
|
|
659
|
+
// `no-useless-return` ESLint rule does not exceed Node's call stack.
|
|
660
|
+
const runDetection = () => {
|
|
661
|
+
const frameTimeMs = performance.now();
|
|
662
|
+
|
|
663
|
+
if (!canvasRef.current) {
|
|
664
|
+
canvasRef.current = document.createElement('canvas');
|
|
665
|
+
}
|
|
666
|
+
const canvas = canvasRef.current;
|
|
667
|
+
const ctx = canvas.getContext('2d', { willReadFrequently: true })!;
|
|
668
|
+
|
|
669
|
+
// Sync canvas size
|
|
670
|
+
if (canvas.width !== video.videoWidth) canvas.width = video.videoWidth;
|
|
671
|
+
if (canvas.height !== video.videoHeight)
|
|
672
|
+
canvas.height = video.videoHeight;
|
|
673
|
+
|
|
674
|
+
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
|
675
|
+
|
|
676
|
+
// Downscaled canvas for OpenCV (all CV ops run here; full-res canvas is
|
|
677
|
+
// only used for the final captured image).
|
|
678
|
+
if (!detectionCanvasRef.current) {
|
|
679
|
+
detectionCanvasRef.current = document.createElement('canvas');
|
|
680
|
+
}
|
|
681
|
+
const dsCanvas = detectionCanvasRef.current;
|
|
682
|
+
if (!video.videoWidth) {
|
|
683
|
+
animationFrameId = requestAnimationFrame(processFrame);
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
const dsW = Math.min(PROCESS_WIDTH, video.videoWidth);
|
|
687
|
+
const dsH = Math.round(video.videoHeight * (dsW / video.videoWidth));
|
|
688
|
+
if (dsCanvas.width !== dsW) dsCanvas.width = dsW;
|
|
689
|
+
if (dsCanvas.height !== dsH) dsCanvas.height = dsH;
|
|
690
|
+
const dsCtx = dsCanvas.getContext('2d', { willReadFrequently: true });
|
|
691
|
+
if (!dsCtx) {
|
|
692
|
+
animationFrameId = requestAnimationFrame(processFrame);
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
dsCtx.drawImage(video, 0, 0, dsW, dsH);
|
|
696
|
+
const dsScale = dsW / video.videoWidth;
|
|
697
|
+
|
|
698
|
+
// The cheap presence gate runs on the 640px dsCanvas, so its blur
|
|
699
|
+
// kernel is scaled with dsScale to keep the physical blur radius
|
|
700
|
+
// constant (a fixed 5px kernel would over-smooth at 640px and bridge
|
|
701
|
+
// background texture gaps like carpet lines).
|
|
702
|
+
// `| 1` ensures the kernel size is always odd (OpenCV requirement).
|
|
703
|
+
const rawKernel = Math.round(5 * dsScale);
|
|
704
|
+
const blurKernel = Math.max(
|
|
705
|
+
3,
|
|
706
|
+
rawKernel % 2 === 0 ? rawKernel + 1 : rawKernel,
|
|
707
|
+
);
|
|
708
|
+
|
|
709
|
+
// 2. Define ROI (Region of Interest)
|
|
710
|
+
// The guide box is rendered in CSS space (Overlay.jsx) but detection
|
|
711
|
+
// runs on the native-resolution canvas. With objectFit:'cover' on the
|
|
712
|
+
// video element, CSS pixels ≠ video pixels. We must map the CSS guide
|
|
713
|
+
// box into video coordinates so the detection/crop region matches what
|
|
714
|
+
// the user actually sees on screen.
|
|
715
|
+
//
|
|
716
|
+
// Use getBoundingClientRect() — more reliable than clientWidth/clientHeight
|
|
717
|
+
// for absolutely-positioned elements, especially on mobile where layout
|
|
718
|
+
// may not be settled when the detection loop first starts.
|
|
719
|
+
const videoRect = video.getBoundingClientRect();
|
|
720
|
+
const displayW = videoRect.width > 0 ? videoRect.width : canvas.width;
|
|
721
|
+
const displayH =
|
|
722
|
+
videoRect.height > 0 ? videoRect.height : canvas.height;
|
|
723
|
+
|
|
724
|
+
// Skip frame if display dimensions aren't available yet (layout not settled)
|
|
725
|
+
if (videoRect.width === 0 || videoRect.height === 0) {
|
|
726
|
+
animationFrameId = requestAnimationFrame(processFrame);
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
const videoW = canvas.width; // native video width
|
|
730
|
+
const videoH = canvas.height; // native video height
|
|
731
|
+
|
|
732
|
+
// objectFit: cover scaling — video scales to fill, excess clipped
|
|
733
|
+
const videoAspect = videoW / videoH;
|
|
734
|
+
const displayAspect = displayW / displayH;
|
|
735
|
+
let coverScale;
|
|
736
|
+
let offsetX;
|
|
737
|
+
let offsetY;
|
|
738
|
+
|
|
739
|
+
if (videoAspect > displayAspect) {
|
|
740
|
+
// Video wider than display → height fills, sides cropped
|
|
741
|
+
coverScale = displayH / videoH;
|
|
742
|
+
offsetX = (videoW - displayW / coverScale) / 2;
|
|
743
|
+
offsetY = 0;
|
|
744
|
+
} else {
|
|
745
|
+
// Video taller → width fills, top/bottom cropped
|
|
746
|
+
coverScale = displayW / videoW;
|
|
747
|
+
offsetX = 0;
|
|
748
|
+
offsetY = (videoH - displayH / coverScale) / 2;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
// Guide box in CSS space.
|
|
752
|
+
// Fullscreen: matches Overlay.jsx (90% width, max 600px, dynamic aspect ratio)
|
|
753
|
+
// Card: the entire video container IS the detection area (100%)
|
|
754
|
+
// In discovery phase, use the wider passport ratio to fit both doc types.
|
|
755
|
+
// After classification, lock to the detected document's aspect ratio.
|
|
756
|
+
const currentAspect =
|
|
757
|
+
detectionPhaseRef.current === DETECTION_PHASE.DISCOVERY
|
|
758
|
+
? orientAspect(ASPECT_RATIOS.passport) // Wider — accommodates both ID and passport
|
|
759
|
+
: orientAspect(
|
|
760
|
+
discoveryRef.current.docType === 'passport' ||
|
|
761
|
+
discoveryRef.current.docType === 'greenbook'
|
|
762
|
+
? ASPECT_RATIOS.passport
|
|
763
|
+
: ASPECT_RATIOS['id-card'],
|
|
764
|
+
);
|
|
765
|
+
const isCard = variant === 'card';
|
|
766
|
+
// When the UI overlay is rotated 90° CW (portrait phone, landscape doc),
|
|
767
|
+
// the visible guide-box lives in the rotated overlay's local coords.
|
|
768
|
+
// The video element underneath is NOT rotated, so its CSS axes are
|
|
769
|
+
// swapped relative to the overlay. We compute the guide in the
|
|
770
|
+
// overlay's frame, then map back to video-CSS via the inverse rotation.
|
|
771
|
+
let guideWidthCSS;
|
|
772
|
+
let guideHeightCSS;
|
|
773
|
+
let guideXCSS;
|
|
774
|
+
let guideYCSS;
|
|
775
|
+
if (isCard) {
|
|
776
|
+
guideWidthCSS = displayW;
|
|
777
|
+
guideHeightCSS = displayH;
|
|
778
|
+
guideXCSS = 0;
|
|
779
|
+
guideYCSS = 0;
|
|
780
|
+
} else if (shouldRotateUi) {
|
|
781
|
+
// Overlay-local dimensions are swapped: ovW = displayH, ovH = displayW.
|
|
782
|
+
// Inset = 16rem (256px) when rotated.
|
|
783
|
+
const ovW = displayH;
|
|
784
|
+
const ovH = displayW;
|
|
785
|
+
const inset = syncRoiToGuide ? 256 : Math.max(0, ovW * 0.1);
|
|
786
|
+
const guideOvW = Math.min(Math.max(0, ovW - inset), 480);
|
|
787
|
+
const guideOvH = guideOvW / currentAspect;
|
|
788
|
+
const ovX = (ovW - guideOvW) / 2;
|
|
789
|
+
const ovY = (ovH - guideOvH) / 2;
|
|
790
|
+
// Map (xL, yL) overlay-local → video-CSS (W - yL, xL) where W = ovH = displayW.
|
|
791
|
+
// The rotated guide-rect's video-CSS bbox:
|
|
792
|
+
guideXCSS = ovH - ovY - guideOvH; // = (displayW - guideOvH) / 2
|
|
793
|
+
guideYCSS = ovX; // = (displayH - guideOvW) / 2
|
|
794
|
+
guideWidthCSS = guideOvH; // narrow in unrotated video
|
|
795
|
+
guideHeightCSS = guideOvW; // tall in unrotated video
|
|
796
|
+
} else if (skipGridCheck) {
|
|
797
|
+
// Desktop: the video container IS the visible guide (it's already
|
|
798
|
+
// sized to the card aspect ratio and bordered). Use almost the whole
|
|
799
|
+
// displayed box as the ROI — only a small 4% margin so the card's
|
|
800
|
+
// corners aren't clipped at the edge — so "card fills the on-screen
|
|
801
|
+
// box" == "card fills the detection region". The fixed 64px inset
|
|
802
|
+
// below is for the mobile overlay; on a ≤480px desktop box it shrank
|
|
803
|
+
// the (invisible) ROI to ~75% of the box and made a full-looking card
|
|
804
|
+
// read as too far away.
|
|
805
|
+
const marginFrac = 0.04;
|
|
806
|
+
guideWidthCSS = displayW * (1 - marginFrac * 2);
|
|
807
|
+
guideHeightCSS = displayH * (1 - marginFrac * 2);
|
|
808
|
+
guideXCSS = (displayW - guideWidthCSS) / 2;
|
|
809
|
+
guideYCSS = (displayH - guideHeightCSS) / 2;
|
|
810
|
+
} else {
|
|
811
|
+
// Unrotated: original behaviour.
|
|
812
|
+
const insetPx = syncRoiToGuide ? 64 : 0;
|
|
813
|
+
guideWidthCSS = syncRoiToGuide
|
|
814
|
+
? Math.min(Math.max(0, displayW - insetPx), 480)
|
|
815
|
+
: Math.min(displayW * 0.9, 480);
|
|
816
|
+
guideHeightCSS = guideWidthCSS / currentAspect;
|
|
817
|
+
guideXCSS = (displayW - guideWidthCSS) / 2;
|
|
818
|
+
guideYCSS = (displayH - guideHeightCSS) / 2;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
// Map CSS → video native coordinates
|
|
822
|
+
const guideWidth = Math.round(guideWidthCSS / coverScale);
|
|
823
|
+
const guideHeight = Math.round(guideHeightCSS / coverScale);
|
|
824
|
+
const startX = Math.round(guideXCSS / coverScale + offsetX);
|
|
825
|
+
const startY = Math.round(guideYCSS / coverScale + offsetY);
|
|
826
|
+
|
|
827
|
+
// Clamp to canvas bounds
|
|
828
|
+
const clampedX = Math.max(0, Math.min(startX, videoW - guideWidth));
|
|
829
|
+
const clampedY = Math.max(0, Math.min(startY, videoH - guideHeight));
|
|
830
|
+
const clampedW = Math.min(guideWidth, videoW - clampedX);
|
|
831
|
+
|
|
832
|
+
// Log ROI mapping once for diagnostics
|
|
833
|
+
if (IS_DEBUG_MODE && !canvasRef.current._roiLogged) {
|
|
834
|
+
canvasRef.current._roiLogged = true;
|
|
835
|
+
console.info(
|
|
836
|
+
'[ROI] display:',
|
|
837
|
+
`${displayW}x${displayH}`,
|
|
838
|
+
'| video:',
|
|
839
|
+
`${videoW}x${videoH}`,
|
|
840
|
+
'| scale:',
|
|
841
|
+
coverScale.toFixed(3),
|
|
842
|
+
'| offset:',
|
|
843
|
+
`${Math.round(offsetX)},${Math.round(offsetY)}`,
|
|
844
|
+
'| guideCSS:',
|
|
845
|
+
`${Math.round(guideWidthCSS)}x${Math.round(guideHeightCSS)}`,
|
|
846
|
+
'at',
|
|
847
|
+
`${Math.round(guideXCSS)},${Math.round(guideYCSS)}`,
|
|
848
|
+
'| ROI:',
|
|
849
|
+
`${clampedX},${clampedY}`,
|
|
850
|
+
`${clampedW}x${Math.min(guideHeight, videoH - clampedY)}`,
|
|
851
|
+
);
|
|
852
|
+
}
|
|
853
|
+
const clampedH = Math.min(guideHeight, videoH - clampedY);
|
|
854
|
+
|
|
855
|
+
// Downscaled ROI coords — used for all OpenCV ops below. Clamp to the
|
|
856
|
+
// dsCanvas bounds (= fullFrame dims): rounding clampedX and clampedW
|
|
857
|
+
// independently can push x+w one pixel past dsW when the ROI sits flush
|
|
858
|
+
// against the frame edge, tripping the cv.Mat roi assertion
|
|
859
|
+
// (0 <= roi.x && roi.x + roi.width <= m.cols). Clamp x/y first, then size
|
|
860
|
+
// to the remaining span so x+w <= dsW and y+h <= dsH always hold.
|
|
861
|
+
const dsClampedX = Math.min(Math.round(clampedX * dsScale), dsW - 1);
|
|
862
|
+
const dsClampedY = Math.min(Math.round(clampedY * dsScale), dsH - 1);
|
|
863
|
+
const dsClampedW = Math.max(
|
|
864
|
+
1,
|
|
865
|
+
Math.min(Math.round(clampedW * dsScale), dsW - dsClampedX),
|
|
866
|
+
);
|
|
867
|
+
const dsClampedH = Math.max(
|
|
868
|
+
1,
|
|
869
|
+
Math.min(Math.round(clampedH * dsScale), dsH - dsClampedY),
|
|
870
|
+
);
|
|
871
|
+
|
|
872
|
+
// Store current ROI coords for on-demand manual capture (zero cost — no canvas ops).
|
|
873
|
+
latestCropCoordsRef.current = {
|
|
874
|
+
clampedX,
|
|
875
|
+
clampedY,
|
|
876
|
+
clampedW,
|
|
877
|
+
clampedH,
|
|
878
|
+
};
|
|
879
|
+
|
|
880
|
+
// Debug overlay: publish the clamped ROI mapped back to the video
|
|
881
|
+
// element's CSS box (inverse of the CSS → native mapping above).
|
|
882
|
+
if (IS_DEBUG_MODE) {
|
|
883
|
+
const roiCss = {
|
|
884
|
+
x: Math.round((clampedX - offsetX) * coverScale),
|
|
885
|
+
y: Math.round((clampedY - offsetY) * coverScale),
|
|
886
|
+
w: Math.round(clampedW * coverScale),
|
|
887
|
+
h: Math.round(clampedH * coverScale),
|
|
888
|
+
};
|
|
889
|
+
const roiKey = `${roiCss.x},${roiCss.y},${roiCss.w},${roiCss.h}`;
|
|
890
|
+
if (debugRoiKeyRef.current !== roiKey) {
|
|
891
|
+
debugRoiKeyRef.current = roiKey;
|
|
892
|
+
setDebugRoi(roiCss);
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
// --- Off-guide detection ---
|
|
897
|
+
// Active on every layout that has spare margin around the visible
|
|
898
|
+
// guide. In CAPTURE phase the gate runs unconditionally on the 5-frame
|
|
899
|
+
// interval — a stale `inGuideDetectedRef` from a prior frame must not
|
|
900
|
+
// suppress an authoritative "card outside the guide" signal. In
|
|
901
|
+
// DISCOVERY phase we still skip while a card is locked in to avoid
|
|
902
|
+
// contending with the per-frame contour pass.
|
|
903
|
+
const isCardVariant = variant === 'card';
|
|
904
|
+
// Book-style documents (passport, greenbook) open to two pages: the
|
|
905
|
+
// off-page legitimately sits outside the guide while the bio-data
|
|
906
|
+
// page is aligned, so the off-guide detector would produce false
|
|
907
|
+
// "Align document in frame" prompts. Skip it for these doc types.
|
|
908
|
+
const lockedDocTypeForGate = discoveryRef.current.docType;
|
|
909
|
+
const isBookDoc =
|
|
910
|
+
lockedDocTypeForGate === 'passport' ||
|
|
911
|
+
lockedDocTypeForGate === 'greenbook';
|
|
912
|
+
const hasMargin =
|
|
913
|
+
displayW - guideWidthCSS > OFF_GUIDE_MIN_MARGIN_X_CSS ||
|
|
914
|
+
displayH - guideHeightCSS > OFF_GUIDE_MIN_MARGIN_Y_CSS;
|
|
915
|
+
offGuideFrameCounterRef.current =
|
|
916
|
+
(offGuideFrameCounterRef.current + 1) % OFF_GUIDE_CHECK_INTERVAL;
|
|
917
|
+
const isCapturePhase =
|
|
918
|
+
detectionPhaseRef.current === DETECTION_PHASE.CAPTURE;
|
|
919
|
+
const shouldRunOffGuide =
|
|
920
|
+
!isCardVariant &&
|
|
921
|
+
!skipGridCheck &&
|
|
922
|
+
!isBookDoc &&
|
|
923
|
+
hasMargin &&
|
|
924
|
+
(isCapturePhase || !inGuideDetectedRef.current) &&
|
|
925
|
+
offGuideFrameCounterRef.current === 0;
|
|
926
|
+
|
|
927
|
+
if (shouldRunOffGuide) {
|
|
928
|
+
if (!offGuideCanvasRef.current) {
|
|
929
|
+
offGuideCanvasRef.current = document.createElement('canvas');
|
|
930
|
+
}
|
|
931
|
+
const lockedDocType = discoveryRef.current.docType;
|
|
932
|
+
const expectedAspect = lockedDocType
|
|
933
|
+
? ASPECT_RATIOS[lockedDocType]
|
|
934
|
+
: null;
|
|
935
|
+
const cardOutside = detectCardOutsideGuide(
|
|
936
|
+
video,
|
|
937
|
+
{ x: clampedX, y: clampedY, w: clampedW, h: clampedH },
|
|
938
|
+
expectedAspect,
|
|
939
|
+
offGuideCanvasRef.current,
|
|
940
|
+
);
|
|
941
|
+
if (cardOutside) {
|
|
942
|
+
setFeedback(autoCaptureFeedback.alignDocument);
|
|
943
|
+
setComplianceState(COMPLIANCE_STATES.IDLE);
|
|
944
|
+
stabilityRef.current.count = 0;
|
|
945
|
+
bestFrameRef.current = { image: null, preview: null, score: 0 };
|
|
946
|
+
docFillEmaRef.current = null;
|
|
947
|
+
inGuideDetectedRef.current = false;
|
|
948
|
+
mergeDebugInfo({ rejectReason: 'off-guide (card outside guide)' });
|
|
949
|
+
return;
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
// Crop ROI for the cheap gates from the downscaled canvas (presence,
|
|
954
|
+
// texture, grid, blur, glare all run at PROCESS_WIDTH).
|
|
955
|
+
fullFrame = cv.imread(dsCanvas);
|
|
956
|
+
const rect = new cv.Rect(
|
|
957
|
+
dsClampedX,
|
|
958
|
+
dsClampedY,
|
|
959
|
+
dsClampedW,
|
|
960
|
+
dsClampedH,
|
|
961
|
+
);
|
|
962
|
+
src = fullFrame.roi(rect);
|
|
963
|
+
fullFrame.delete();
|
|
964
|
+
fullFrame = null;
|
|
965
|
+
|
|
966
|
+
// 3. Pre-processing
|
|
967
|
+
gray = new cv.Mat();
|
|
968
|
+
cv.cvtColor(src, gray, cv.COLOR_RGBA2GRAY, 0);
|
|
969
|
+
|
|
970
|
+
// --- Gate 0: Document Presence (Edge Density + Texture + Coverage) ---
|
|
971
|
+
// A document has text, borders, patterns = high edge density + texture.
|
|
972
|
+
// Low-contrast cards (e.g. ECOWAS Ghana ID) have subtle watermarks/guilloché
|
|
973
|
+
// that need lower Canny thresholds to detect.
|
|
974
|
+
// A bare desk/surface has very few edges AND low texture variance.
|
|
975
|
+
presenceBlurred = new cv.Mat();
|
|
976
|
+
cv.GaussianBlur(
|
|
977
|
+
gray,
|
|
978
|
+
presenceBlurred,
|
|
979
|
+
new cv.Size(blurKernel, blurKernel),
|
|
980
|
+
0,
|
|
981
|
+
0,
|
|
982
|
+
cv.BORDER_DEFAULT,
|
|
983
|
+
);
|
|
984
|
+
presenceEdges = new cv.Mat();
|
|
985
|
+
cv.Canny(presenceBlurred, presenceEdges, 20, 80); // Low thresholds to catch subtle patterns
|
|
986
|
+
|
|
987
|
+
const edgePixels = cv.countNonZero(presenceEdges);
|
|
988
|
+
const roiPixels = gray.rows * gray.cols;
|
|
989
|
+
const edgeDensity = (edgePixels / roiPixels) * 100;
|
|
990
|
+
|
|
991
|
+
// Texture check: stddev of pixel intensity across the ROI
|
|
992
|
+
// Documents have varied content (text, photos, patterns) = high stddev
|
|
993
|
+
// Blank surfaces are uniform = low stddev
|
|
994
|
+
const texMean = new cv.Mat();
|
|
995
|
+
const texStdDev = new cv.Mat();
|
|
996
|
+
cv.meanStdDev(gray, texMean, texStdDev);
|
|
997
|
+
const textureScore = texStdDev.doubleAt(0, 0);
|
|
998
|
+
texMean.delete();
|
|
999
|
+
texStdDev.delete();
|
|
1000
|
+
|
|
1001
|
+
const edgeThreshold = settingsRef.current.edgeDensityThreshold || 3;
|
|
1002
|
+
// A doc is present if EITHER edge density is high enough OR texture is rich enough
|
|
1003
|
+
// Texture > 30 is typical for any printed document; bare surfaces are ~5-15
|
|
1004
|
+
const hasDocument = edgeDensity >= edgeThreshold || textureScore > 30;
|
|
1005
|
+
|
|
1006
|
+
// Coverage grid: check the inner 80% of the ROI (inset by 10% on each side)
|
|
1007
|
+
// so edge cells aren't penalized for containing desk/background margin.
|
|
1008
|
+
// The 3x3 grid over this inner region catches occlusion (hand covering 1/3).
|
|
1009
|
+
const insetFrac = 0.1; // 10% inset on each side
|
|
1010
|
+
const insetX = Math.floor(presenceEdges.cols * insetFrac);
|
|
1011
|
+
const insetY = Math.floor(presenceEdges.rows * insetFrac);
|
|
1012
|
+
const innerW = presenceEdges.cols - insetX * 2;
|
|
1013
|
+
const innerH = presenceEdges.rows - insetY * 2;
|
|
1014
|
+
const cols3 = 3;
|
|
1015
|
+
const rows3 = 3;
|
|
1016
|
+
const cellW = Math.floor(innerW / cols3);
|
|
1017
|
+
const cellH = Math.floor(innerH / rows3);
|
|
1018
|
+
const cellPixels = cellW * cellH;
|
|
1019
|
+
// Each cell needs a minimum % of the overall edge threshold.
|
|
1020
|
+
// Mobile uses 50% (lenient for low-contrast cards like ECOWAS Ghana ID).
|
|
1021
|
+
// Desktop uses 70% to prevent capturing far-away cards.
|
|
1022
|
+
const cellRatio = settingsRef.current.gridCellRatio || 0.5;
|
|
1023
|
+
const cellMin = edgeThreshold * cellRatio;
|
|
1024
|
+
const quadDensities = [];
|
|
1025
|
+
let passingCells = 0;
|
|
1026
|
+
for (let row = 0; row < rows3; row++) {
|
|
1027
|
+
for (let col = 0; col < cols3; col++) {
|
|
1028
|
+
const cRect = new cv.Rect(
|
|
1029
|
+
insetX + col * cellW,
|
|
1030
|
+
insetY + row * cellH,
|
|
1031
|
+
cellW,
|
|
1032
|
+
cellH,
|
|
1033
|
+
);
|
|
1034
|
+
const cRoi = presenceEdges.roi(cRect);
|
|
1035
|
+
const cDensity = (cv.countNonZero(cRoi) / cellPixels) * 100;
|
|
1036
|
+
quadDensities.push(cDensity.toFixed(1));
|
|
1037
|
+
if (cDensity >= cellMin) {
|
|
1038
|
+
passingCells++;
|
|
1039
|
+
}
|
|
1040
|
+
cRoi.delete();
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
// Grid coverage gating:
|
|
1045
|
+
// - Card variant: skip entirely — ROI is the full video frame, card can't fill 100%
|
|
1046
|
+
// - Discovery phase: relaxed — require MIN_DISCOVERY_GRID_CELLS (3/9) cells.
|
|
1047
|
+
// - Capture phase (fullscreen): require 7/9 cells. Allows up to ~2 cells
|
|
1048
|
+
// of background (face, hand, etc.) peeking into the guide without
|
|
1049
|
+
// blocking capture. 9/9 was too strict for hand-held framing.
|
|
1050
|
+
const isDiscoveryPhase =
|
|
1051
|
+
detectionPhaseRef.current === DETECTION_PHASE.DISCOVERY;
|
|
1052
|
+
let gridCheckFails;
|
|
1053
|
+
if (isCard || skipGridCheck) {
|
|
1054
|
+
gridCheckFails = false; // Skip grid check (card variant or desktop fullscreen)
|
|
1055
|
+
} else if (isDiscoveryPhase) {
|
|
1056
|
+
gridCheckFails = passingCells < MIN_DISCOVERY_GRID_CELLS; // Relaxed: 3/9 cells
|
|
1057
|
+
} else {
|
|
1058
|
+
// Early-out only: bail just on a near-empty grid. The real "document
|
|
1059
|
+
// fills the box / is close enough" check is docFillPercent >=
|
|
1060
|
+
// minFillPercent (65%) in the contour pass below. A 7/9 bar here
|
|
1061
|
+
// false-rejected low-contrast cards on plain backgrounds (only the
|
|
1062
|
+
// printed center cells carry edges; the plain outer cells read ~0)
|
|
1063
|
+
// before the contour pass — incl. the clutter-adaptive Canny floor
|
|
1064
|
+
// for faint borders — ever ran. Synthetic-fallback eligibility still
|
|
1065
|
+
// requires the strong passingCells >= 7 signal separately below.
|
|
1066
|
+
gridCheckFails =
|
|
1067
|
+
passingCells < (settingsRef.current.captureGridMinCells ?? 4);
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
if (!hasDocument || gridCheckFails) {
|
|
1071
|
+
const totalCells = rows3 * cols3; // 9
|
|
1072
|
+
const noDocumentPresent =
|
|
1073
|
+
!hasDocument || passingCells < Math.ceil(totalCells * 0.45);
|
|
1074
|
+
const reason = noDocumentPresent
|
|
1075
|
+
? autoCaptureFeedback.placeDocument
|
|
1076
|
+
: autoCaptureFeedback.ensureDocumentVisible;
|
|
1077
|
+
// Document truly absent → hard reset. Document present but coverage
|
|
1078
|
+
// momentarily dipped ("fully visible") → soften so a flickered cell
|
|
1079
|
+
// doesn't drain progress (mobile gateDecayEnabled only).
|
|
1080
|
+
let gate0Absorbed = false;
|
|
1081
|
+
if (noDocumentPresent) {
|
|
1082
|
+
docFillEmaRef.current = null;
|
|
1083
|
+
stabilityRef.current.count = 0;
|
|
1084
|
+
bestFrameMissRef.current = 0;
|
|
1085
|
+
bestFrameRef.current = { image: null, preview: null, score: 0 };
|
|
1086
|
+
} else {
|
|
1087
|
+
gate0Absorbed =
|
|
1088
|
+
settingsRef.current.gateDecayEnabled === true &&
|
|
1089
|
+
softFailStability();
|
|
1090
|
+
}
|
|
1091
|
+
if (!gate0Absorbed) {
|
|
1092
|
+
setFeedback(reason);
|
|
1093
|
+
setComplianceState(COMPLIANCE_STATES.IDLE);
|
|
1094
|
+
}
|
|
1095
|
+
updateDebugPath(null);
|
|
1096
|
+
mergeDebugInfo({
|
|
1097
|
+
blur: 0,
|
|
1098
|
+
glare: 0,
|
|
1099
|
+
edgeDensity: edgeDensity.toFixed(1),
|
|
1100
|
+
texture: Math.round(textureScore),
|
|
1101
|
+
quadrants: quadDensities.join('/'),
|
|
1102
|
+
rejectReason: noDocumentPresent
|
|
1103
|
+
? 'Gate0: no document present'
|
|
1104
|
+
: `Gate0: grid coverage (${passingCells}/9)${gate0Absorbed ? ' [held]' : ''}`,
|
|
1105
|
+
});
|
|
1106
|
+
return;
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
// --- Phase 1 / Dynamic Border Detection ---
|
|
1110
|
+
// During discovery phase: ALWAYS run contour detection to classify document type.
|
|
1111
|
+
// During capture phase: ALWAYS run contour detection for distance guidance.
|
|
1112
|
+
// The useDynamicBorder setting only controls whether the green overlay is drawn.
|
|
1113
|
+
const isDiscovery =
|
|
1114
|
+
detectionPhaseRef.current === DETECTION_PHASE.DISCOVERY;
|
|
1115
|
+
const shouldRunContour = true;
|
|
1116
|
+
|
|
1117
|
+
// Discovery timeout: moved AFTER contour detection (see below).
|
|
1118
|
+
// Only increments when a bestContour is actually found, so empty
|
|
1119
|
+
// scenes (textured desk, no document) never trigger the timeout.
|
|
1120
|
+
|
|
1121
|
+
if (shouldRunContour) {
|
|
1122
|
+
// Contour detection runs at FULL resolution on a crop of just the
|
|
1123
|
+
// guide-box ROI. The 640px dsCanvas collapses the card border to a
|
|
1124
|
+
// ~1px line that Canny/findContours can't recover unless the card is
|
|
1125
|
+
// large in frame — running here at native fidelity restores reliable
|
|
1126
|
+
// border detection while staying cheap (the ROI is a fraction of the
|
|
1127
|
+
// frame, and this pass is gated behind the presence/grid check above).
|
|
1128
|
+
if (!contourCanvasRef.current) {
|
|
1129
|
+
contourCanvasRef.current = document.createElement('canvas');
|
|
1130
|
+
}
|
|
1131
|
+
const contourCanvas = contourCanvasRef.current;
|
|
1132
|
+
if (contourCanvas.width !== clampedW) contourCanvas.width = clampedW;
|
|
1133
|
+
if (contourCanvas.height !== clampedH)
|
|
1134
|
+
contourCanvas.height = clampedH;
|
|
1135
|
+
const contourCtx = contourCanvas.getContext('2d', {
|
|
1136
|
+
willReadFrequently: true,
|
|
1137
|
+
});
|
|
1138
|
+
if (!contourCtx) {
|
|
1139
|
+
animationFrameId = requestAnimationFrame(processFrame);
|
|
1140
|
+
return;
|
|
1141
|
+
}
|
|
1142
|
+
// Draw only the ROI sub-rect from the full-res canvas at native size
|
|
1143
|
+
// (avoids cv.imread over the whole multi-megapixel canvas).
|
|
1144
|
+
contourCtx.drawImage(
|
|
1145
|
+
canvas,
|
|
1146
|
+
clampedX,
|
|
1147
|
+
clampedY,
|
|
1148
|
+
clampedW,
|
|
1149
|
+
clampedH,
|
|
1150
|
+
0,
|
|
1151
|
+
0,
|
|
1152
|
+
clampedW,
|
|
1153
|
+
clampedH,
|
|
1154
|
+
);
|
|
1155
|
+
contourFull = cv.imread(contourCanvas);
|
|
1156
|
+
contourGray = new cv.Mat();
|
|
1157
|
+
cv.cvtColor(contourFull, contourGray, cv.COLOR_RGBA2GRAY, 0);
|
|
1158
|
+
// Fix 2: keep the RGBA crop (contourFull) alive for chroma fusion
|
|
1159
|
+
// when enabled; otherwise free it immediately as before so the
|
|
1160
|
+
// luminance-only path is byte-identical.
|
|
1161
|
+
const chromaFusionOn =
|
|
1162
|
+
settingsRef.current.chromaEdgeFusion === true &&
|
|
1163
|
+
!chromaUnavailableRef.current &&
|
|
1164
|
+
typeof cv.COLOR_RGB2Lab !== 'undefined';
|
|
1165
|
+
if (!chromaFusionOn) {
|
|
1166
|
+
contourFull.delete();
|
|
1167
|
+
contourFull = null;
|
|
1168
|
+
}
|
|
1169
|
+
let edgeSource = 'lum';
|
|
1170
|
+
|
|
1171
|
+
blurred = new cv.Mat();
|
|
1172
|
+
cv.GaussianBlur(
|
|
1173
|
+
contourGray,
|
|
1174
|
+
blurred,
|
|
1175
|
+
new cv.Size(5, 5),
|
|
1176
|
+
0,
|
|
1177
|
+
0,
|
|
1178
|
+
cv.BORDER_DEFAULT,
|
|
1179
|
+
);
|
|
1180
|
+
|
|
1181
|
+
// Adaptive Canny thresholds (anchored on the frame's own gradient
|
|
1182
|
+
// distribution) instead of a fixed 50/150. The fixed pair needs a
|
|
1183
|
+
// strong brightness gradient at the document border, so capture only
|
|
1184
|
+
// fires reliably on high-contrast surfaces (e.g. a card on metal) and
|
|
1185
|
+
// stalls on general backgrounds (wood, matte desk, similar-toned
|
|
1186
|
+
// paper) where the boundary gradient is weak.
|
|
1187
|
+
//
|
|
1188
|
+
// Canny thresholds are compared against gradient magnitude, so derive
|
|
1189
|
+
// them from the magnitude statistics: high ≈ mean + sigma·stddev. A
|
|
1190
|
+
// plain background yields a small mean/stddev → lower thresholds →
|
|
1191
|
+
// the faint border is still detected. A busy/cluttered background
|
|
1192
|
+
// yields large stats → thresholds stay high → spurious edges are
|
|
1193
|
+
// suppressed. The magnitude here is just a per-frame anchor for
|
|
1194
|
+
// choosing the threshold; cv.Canny keeps its default gradient norm
|
|
1195
|
+
// (unchanged from before), so at the cap the behaviour is identical.
|
|
1196
|
+
//
|
|
1197
|
+
// high is clamped to [CANNY_HIGH_MIN, CANNY_HIGH_MAX]; the ceiling is
|
|
1198
|
+
// the proven fixed value so the high-contrast path that already works
|
|
1199
|
+
// cannot regress — this only *relaxes* detection for low contrast.
|
|
1200
|
+
const sobelX = new cv.Mat();
|
|
1201
|
+
const sobelY = new cv.Mat();
|
|
1202
|
+
cv.Sobel(blurred, sobelX, cv.CV_32F, 1, 0, 3);
|
|
1203
|
+
cv.Sobel(blurred, sobelY, cv.CV_32F, 0, 1, 3);
|
|
1204
|
+
const gradMag = new cv.Mat();
|
|
1205
|
+
cv.magnitude(sobelX, sobelY, gradMag);
|
|
1206
|
+
sobelX.delete();
|
|
1207
|
+
sobelY.delete();
|
|
1208
|
+
const gradMean = new cv.Mat();
|
|
1209
|
+
const gradStdDev = new cv.Mat();
|
|
1210
|
+
cv.meanStdDev(gradMag, gradMean, gradStdDev);
|
|
1211
|
+
const gMean = gradMean.doubleAt(0, 0);
|
|
1212
|
+
const gStd = gradStdDev.doubleAt(0, 0);
|
|
1213
|
+
gradMag.delete();
|
|
1214
|
+
gradMean.delete();
|
|
1215
|
+
gradStdDev.delete();
|
|
1216
|
+
|
|
1217
|
+
const cannySigma = settingsRef.current.autoCannySigma ?? 1.0;
|
|
1218
|
+
// Clutter-adaptive high-threshold floor. On a featureless surface
|
|
1219
|
+
// (pale ID on pale wood) the card border gradient is faint and the
|
|
1220
|
+
// fixed CANNY_HIGH_MIN=60 floor pins too high → no quad forms. But a
|
|
1221
|
+
// genuinely empty scene (Gate-0 edgeDensity ~0) has no background
|
|
1222
|
+
// texture to turn into false edges, so the floor can safely drop to
|
|
1223
|
+
// recover the faint border. Busy scenes keep the proven 60 floor, so
|
|
1224
|
+
// the working high-contrast/metallic path cannot regress.
|
|
1225
|
+
const lowClutter =
|
|
1226
|
+
edgeDensity < (settingsRef.current.lowClutterEdgeDensity ?? 2);
|
|
1227
|
+
const cannyHighMin = lowClutter
|
|
1228
|
+
? (settingsRef.current.cannyHighMinLowClutter ?? 40)
|
|
1229
|
+
: CANNY_HIGH_MIN;
|
|
1230
|
+
const highThreshold = Math.min(
|
|
1231
|
+
CANNY_HIGH_MAX,
|
|
1232
|
+
Math.max(cannyHighMin, gMean + cannySigma * gStd),
|
|
1233
|
+
);
|
|
1234
|
+
const lowThreshold = Math.max(15, highThreshold * 0.4);
|
|
1235
|
+
mergeDebugInfo({
|
|
1236
|
+
canny: `${Math.round(lowThreshold)}/${Math.round(highThreshold)}`,
|
|
1237
|
+
});
|
|
1238
|
+
|
|
1239
|
+
edges = new cv.Mat();
|
|
1240
|
+
cv.Canny(blurred, edges, lowThreshold, highThreshold);
|
|
1241
|
+
|
|
1242
|
+
// Fix 2: chroma-aware edge fusion. A card whose border has near-zero
|
|
1243
|
+
// LUMINANCE gradient against the background (e.g. a beige ID on light
|
|
1244
|
+
// wood) is invisible to the grayscale Canny above, so no 4-corner
|
|
1245
|
+
// quad forms. The same boundary is strong in CHROMA, which grayscale
|
|
1246
|
+
// discards. Convert the colour crop to Lab, run Canny on the a/b
|
|
1247
|
+
// chroma channels, and OR those edges into `edges`. findContours is
|
|
1248
|
+
// RETR_EXTERNAL, so the extra interior chroma edges cannot corrupt
|
|
1249
|
+
// the outer-contour search — only the (now reinforced) outer boundary
|
|
1250
|
+
// matters. Falls back to luminance-only if Lab is unavailable.
|
|
1251
|
+
if (chromaFusionOn) {
|
|
1252
|
+
try {
|
|
1253
|
+
contourRgb = new cv.Mat();
|
|
1254
|
+
cv.cvtColor(contourFull, contourRgb, cv.COLOR_RGBA2RGB, 0);
|
|
1255
|
+
contourFull.delete();
|
|
1256
|
+
contourFull = null;
|
|
1257
|
+
|
|
1258
|
+
contourLab = new cv.Mat();
|
|
1259
|
+
cv.cvtColor(contourRgb, contourLab, cv.COLOR_RGB2Lab, 0);
|
|
1260
|
+
contourRgb.delete();
|
|
1261
|
+
contourRgb = null;
|
|
1262
|
+
|
|
1263
|
+
labPlanes = new cv.MatVector();
|
|
1264
|
+
cv.split(contourLab, labPlanes); // [0]=L, [1]=a, [2]=b
|
|
1265
|
+
contourLab.delete();
|
|
1266
|
+
contourLab = null;
|
|
1267
|
+
|
|
1268
|
+
// OpenCV.js MatVector.get() returns Mats that must be released.
|
|
1269
|
+
aPlane = labPlanes.get(1);
|
|
1270
|
+
bPlane = labPlanes.get(2);
|
|
1271
|
+
const chromaK = new cv.Size(7, 7); // chroma is noisier than luma
|
|
1272
|
+
aBlur = new cv.Mat();
|
|
1273
|
+
bBlur = new cv.Mat();
|
|
1274
|
+
cv.GaussianBlur(aPlane, aBlur, chromaK, 0, 0, cv.BORDER_DEFAULT);
|
|
1275
|
+
cv.GaussianBlur(bPlane, bBlur, chromaK, 0, 0, cv.BORDER_DEFAULT);
|
|
1276
|
+
|
|
1277
|
+
// Per-pixel chroma magnitude |a-128| + |b-128| (Lab neutral =
|
|
1278
|
+
// 128). Near 0 for a neutral white/gray object (keyboard, paper,
|
|
1279
|
+
// desk); high where a colour ID has a photo/printing. Kept alive
|
|
1280
|
+
// for the Level 2 content gate, measured per detected rectangle
|
|
1281
|
+
// below so background colour can't mask a monochrome object.
|
|
1282
|
+
const aAbs = new cv.Mat();
|
|
1283
|
+
const bAbs = new cv.Mat();
|
|
1284
|
+
cv.convertScaleAbs(aPlane, aAbs, 1, -128);
|
|
1285
|
+
cv.convertScaleAbs(bPlane, bAbs, 1, -128);
|
|
1286
|
+
chromaMag = new cv.Mat();
|
|
1287
|
+
cv.addWeighted(aAbs, 1, bAbs, 1, 0, chromaMag);
|
|
1288
|
+
aAbs.delete();
|
|
1289
|
+
bAbs.delete();
|
|
1290
|
+
|
|
1291
|
+
const chromaLow = settingsRef.current.chromaCannyLow ?? 15;
|
|
1292
|
+
const chromaHigh = settingsRef.current.chromaCannyHigh ?? 40;
|
|
1293
|
+
aEdges = new cv.Mat();
|
|
1294
|
+
bEdges = new cv.Mat();
|
|
1295
|
+
cv.Canny(aBlur, aEdges, chromaLow, chromaHigh);
|
|
1296
|
+
cv.Canny(bBlur, bEdges, chromaLow, chromaHigh);
|
|
1297
|
+
|
|
1298
|
+
cv.bitwise_or(edges, aEdges, edges);
|
|
1299
|
+
cv.bitwise_or(edges, bEdges, edges);
|
|
1300
|
+
edgeSource = 'lum+chroma';
|
|
1301
|
+
|
|
1302
|
+
safeDelete(aPlane, bPlane);
|
|
1303
|
+
aPlane = null;
|
|
1304
|
+
bPlane = null;
|
|
1305
|
+
|
|
1306
|
+
labPlanes.delete(); // frees L/a/b incl. borrowed aPlane/bPlane
|
|
1307
|
+
labPlanes = null;
|
|
1308
|
+
aBlur.delete();
|
|
1309
|
+
aBlur = null;
|
|
1310
|
+
bBlur.delete();
|
|
1311
|
+
bBlur = null;
|
|
1312
|
+
aEdges.delete();
|
|
1313
|
+
aEdges = null;
|
|
1314
|
+
bEdges.delete();
|
|
1315
|
+
bEdges = null;
|
|
1316
|
+
} catch (chromaErr) {
|
|
1317
|
+
// Lab path failed on this device — disable for the session and
|
|
1318
|
+
// continue with the luminance edges already in `edges`.
|
|
1319
|
+
chromaUnavailableRef.current = true;
|
|
1320
|
+
edgeSource = 'lum';
|
|
1321
|
+
mergeDebugInfo({
|
|
1322
|
+
chromaError: formatDebugError(chromaErr),
|
|
1323
|
+
chromaStatus: 'disabled',
|
|
1324
|
+
});
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
mergeDebugInfo({ contourSource: edgeSource });
|
|
1328
|
+
|
|
1329
|
+
// Bridge gaps in the card border caused by lamination glare or finger
|
|
1330
|
+
// occlusion. At full resolution the card border is crisp and well
|
|
1331
|
+
// separated from the background, so a fixed 2-iteration close is safe.
|
|
1332
|
+
const closingKernel = cv.getStructuringElement(
|
|
1333
|
+
cv.MORPH_RECT,
|
|
1334
|
+
new cv.Size(3, 3),
|
|
1335
|
+
);
|
|
1336
|
+
const closedEdges = new cv.Mat();
|
|
1337
|
+
cv.morphologyEx(
|
|
1338
|
+
edges,
|
|
1339
|
+
closedEdges,
|
|
1340
|
+
cv.MORPH_CLOSE,
|
|
1341
|
+
closingKernel,
|
|
1342
|
+
new cv.Point(-1, -1),
|
|
1343
|
+
2,
|
|
1344
|
+
);
|
|
1345
|
+
closingKernel.delete();
|
|
1346
|
+
edges.delete();
|
|
1347
|
+
edges = closedEdges;
|
|
1348
|
+
|
|
1349
|
+
contours = new cv.MatVector();
|
|
1350
|
+
hierarchy = new cv.Mat();
|
|
1351
|
+
cv.findContours(
|
|
1352
|
+
edges,
|
|
1353
|
+
contours,
|
|
1354
|
+
hierarchy,
|
|
1355
|
+
cv.RETR_EXTERNAL,
|
|
1356
|
+
cv.CHAIN_APPROX_SIMPLE,
|
|
1357
|
+
);
|
|
1358
|
+
|
|
1359
|
+
let maxArea = 0;
|
|
1360
|
+
// Aspect of the largest 4-corner candidate this frame (debug only):
|
|
1361
|
+
// lets on-device tuning read why a keyboard/screen passed or failed
|
|
1362
|
+
// the aspect gate. 0 when no 4-corner candidate was evaluated.
|
|
1363
|
+
let lastCandidateAspect = 0;
|
|
1364
|
+
// Mean chroma magnitude of the largest candidate's bbox (debug); -1
|
|
1365
|
+
// when not measured (gate off or chroma unavailable).
|
|
1366
|
+
let bestContour: any = null;
|
|
1367
|
+
// True when bestContour is the synthesized book-doc fallback rect.
|
|
1368
|
+
// Its bbox covers inner content (photo/text/MRZ), not the full page,
|
|
1369
|
+
// so distance gates that compare bbox/ROI are unreliable for it.
|
|
1370
|
+
let bestContourIsSynthetic = false;
|
|
1371
|
+
// Desktop only: a card-shaped quad passed every shape gate EXCEPT
|
|
1372
|
+
// the ROI wall-hug check this frame — i.e. a real card, too close.
|
|
1373
|
+
// Consumed in the no-contour handling below to show distance
|
|
1374
|
+
// guidance instead of the dead-end "Align document in frame".
|
|
1375
|
+
let wallHugRejectedCardThisFrame = false;
|
|
1376
|
+
// Track the combined bounding box of ALL significant contours for distance guidance.
|
|
1377
|
+
// Single contour area fails when fingers break card edges into many small contours.
|
|
1378
|
+
// The combined bounding box captures the document's full spatial extent.
|
|
1379
|
+
let combinedMinX = Infinity;
|
|
1380
|
+
let combinedMinY = Infinity;
|
|
1381
|
+
let combinedMaxX = -Infinity;
|
|
1382
|
+
let combinedMaxY = -Infinity;
|
|
1383
|
+
let hasSignificantContour = false;
|
|
1384
|
+
// Per-contour boxes (full-res ROI px), collected so the synthetic
|
|
1385
|
+
// fallback can build a CARD-FOCUSED bbox via outlier trimming instead
|
|
1386
|
+
// of the absolute union — a hand/arm entering the frame is a sparse
|
|
1387
|
+
// outlier that would otherwise inflate the box and over-read distance.
|
|
1388
|
+
const contourBoxes: Array<{
|
|
1389
|
+
x: number;
|
|
1390
|
+
y: number;
|
|
1391
|
+
r: number;
|
|
1392
|
+
b: number;
|
|
1393
|
+
}> = [];
|
|
1394
|
+
// All contour-pass geometry is in full-res ROI pixels.
|
|
1395
|
+
const minContourPixels = clampedW * clampedH * 0.005; // 0.5% — catches small text fragments
|
|
1396
|
+
|
|
1397
|
+
// Seam rejection: straight background lines from the closed `edges`
|
|
1398
|
+
// map. Computed lazily (once per frame, only when a 4-corner
|
|
1399
|
+
// candidate actually reaches the acceptance gate) so empty / no-card
|
|
1400
|
+
// frames pay nothing. Cached in a frame-local; the transient `lines`
|
|
1401
|
+
// Mat is released immediately after conversion to a plain array.
|
|
1402
|
+
const seamRejectEnabled =
|
|
1403
|
+
settingsRef.current.seamRejectEnabled !== false;
|
|
1404
|
+
let houghSegments: SeamSegment[] | null = null;
|
|
1405
|
+
const getHoughSegments = (): SeamSegment[] => {
|
|
1406
|
+
if (houghSegments) return houghSegments;
|
|
1407
|
+
const found: SeamSegment[] = [];
|
|
1408
|
+
const lines = new cv.Mat();
|
|
1409
|
+
try {
|
|
1410
|
+
const houghThreshold = settingsRef.current.houghThreshold ?? 40;
|
|
1411
|
+
const minLenRatio =
|
|
1412
|
+
settingsRef.current.houghMinLengthRatio ?? 0.3;
|
|
1413
|
+
const maxGap = settingsRef.current.houghMaxLineGap ?? 10;
|
|
1414
|
+
const minLineLen = Math.max(
|
|
1415
|
+
10,
|
|
1416
|
+
Math.round(minLenRatio * Math.min(clampedW, clampedH)),
|
|
1417
|
+
);
|
|
1418
|
+
cv.HoughLinesP(
|
|
1419
|
+
edges,
|
|
1420
|
+
lines,
|
|
1421
|
+
HOUGH_RHO,
|
|
1422
|
+
HOUGH_THETA,
|
|
1423
|
+
houghThreshold,
|
|
1424
|
+
minLineLen,
|
|
1425
|
+
maxGap,
|
|
1426
|
+
);
|
|
1427
|
+
for (let li = 0; li < lines.rows; li++) {
|
|
1428
|
+
found.push({
|
|
1429
|
+
x1: lines.data32S[li * 4],
|
|
1430
|
+
y1: lines.data32S[li * 4 + 1],
|
|
1431
|
+
x2: lines.data32S[li * 4 + 2],
|
|
1432
|
+
y2: lines.data32S[li * 4 + 3],
|
|
1433
|
+
});
|
|
1434
|
+
}
|
|
1435
|
+
} catch {
|
|
1436
|
+
// best-effort: on any failure, treat as "no seam lines found"
|
|
1437
|
+
// so the gate never blocks capture on its own error.
|
|
1438
|
+
} finally {
|
|
1439
|
+
safeDelete(lines);
|
|
1440
|
+
}
|
|
1441
|
+
houghSegments = found;
|
|
1442
|
+
return houghSegments;
|
|
1443
|
+
};
|
|
1444
|
+
|
|
1445
|
+
for (let i = 0; i < contours.size(); ++i) {
|
|
1446
|
+
const cnt = contours.get(i);
|
|
1447
|
+
const area = cv.contourArea(cnt);
|
|
1448
|
+
|
|
1449
|
+
// Expand combined bounding box with any non-trivial contour
|
|
1450
|
+
if (area > minContourPixels) {
|
|
1451
|
+
hasSignificantContour = true;
|
|
1452
|
+
const br = cv.boundingRect(cnt);
|
|
1453
|
+
combinedMinX = Math.min(combinedMinX, br.x);
|
|
1454
|
+
combinedMinY = Math.min(combinedMinY, br.y);
|
|
1455
|
+
combinedMaxX = Math.max(combinedMaxX, br.x + br.width);
|
|
1456
|
+
combinedMaxY = Math.max(combinedMaxY, br.y + br.height);
|
|
1457
|
+
contourBoxes.push({
|
|
1458
|
+
x: br.x,
|
|
1459
|
+
y: br.y,
|
|
1460
|
+
r: br.x + br.width,
|
|
1461
|
+
b: br.y + br.height,
|
|
1462
|
+
});
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
if (area > clampedW * clampedH * MIN_CONTOUR_AREA_PERCENT) {
|
|
1466
|
+
const peri = cv.arcLength(cnt, true);
|
|
1467
|
+
let approx = new cv.Mat();
|
|
1468
|
+
cv.approxPolyDP(cnt, approx, 0.04 * peri, true);
|
|
1469
|
+
|
|
1470
|
+
// Laminated cards held in hand often produce 5-7 vertices because
|
|
1471
|
+
// fingers or glare break a corner into two points. Retry with a
|
|
1472
|
+
// wider epsilon to collapse back to a 4-point polygon.
|
|
1473
|
+
if (approx.rows > 4 && approx.rows <= 7) {
|
|
1474
|
+
approx.delete();
|
|
1475
|
+
approx = new cv.Mat();
|
|
1476
|
+
cv.approxPolyDP(cnt, approx, 0.07 * peri, true);
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
if (approx.rows === 4 && area > maxArea) {
|
|
1480
|
+
// --- Rectangularity check ---
|
|
1481
|
+
// Reject contours that aren't proper rectangles (e.g. faces).
|
|
1482
|
+
// Measure fill against the MINIMUM-AREA (rotated) rect, not the
|
|
1483
|
+
// axis-aligned bbox: a real card fills its rotated rect ~fully
|
|
1484
|
+
// at ANY tilt, whereas the axis-aligned bbox is inflated by
|
|
1485
|
+
// rotation and wrongly fails a tilted card (a ~15° tilt drops
|
|
1486
|
+
// axis-aligned fill to ~0.64). bRect is still used for the
|
|
1487
|
+
// wall-hug check below.
|
|
1488
|
+
const bRect = cv.boundingRect(approx);
|
|
1489
|
+
const minRect = cv.minAreaRect(approx);
|
|
1490
|
+
const rotW = minRect.size.width;
|
|
1491
|
+
const rotH = minRect.size.height;
|
|
1492
|
+
const fillRatio =
|
|
1493
|
+
rotW > 0 && rotH > 0 ? area / (rotW * rotH) : 0;
|
|
1494
|
+
|
|
1495
|
+
// --- ROI-boundary check ---
|
|
1496
|
+
// Reject contours that hug the ROI walls: background pattern
|
|
1497
|
+
// rectangles fill the entire ROI and touch all 4 walls
|
|
1498
|
+
// simultaneously. Mobile rejects at 3+ touches (a real card at
|
|
1499
|
+
// correct distance leaves space on at least 2 sides). Desktop
|
|
1500
|
+
// requires all 4 — its ROI is the visible bordered box, which
|
|
1501
|
+
// users naturally fill, so 3 touches is common for legitimate
|
|
1502
|
+
// off-center hand-held cards.
|
|
1503
|
+
const wallMargin = Math.round(
|
|
1504
|
+
Math.min(clampedW, clampedH) * 0.04,
|
|
1505
|
+
);
|
|
1506
|
+
const wallTouches =
|
|
1507
|
+
(bRect.x <= wallMargin ? 1 : 0) +
|
|
1508
|
+
(bRect.y <= wallMargin ? 1 : 0) +
|
|
1509
|
+
(bRect.x + bRect.width >= clampedW - wallMargin ? 1 : 0) +
|
|
1510
|
+
(bRect.y + bRect.height >= clampedH - wallMargin ? 1 : 0);
|
|
1511
|
+
const roiWallHug = wallTouches >= (skipGridCheck ? 4 : 3);
|
|
1512
|
+
|
|
1513
|
+
// --- Aspect-ratio gate ---
|
|
1514
|
+
// Reject candidates whose shape doesn't match a document.
|
|
1515
|
+
// During discovery, allow anything in the union of passport
|
|
1516
|
+
// (1.42) and ID (1.585) ranges with ±20% slack. After the
|
|
1517
|
+
// doc type is locked, gate tightly against the expected ratio.
|
|
1518
|
+
// Use the rotated-rect dimensions so the aspect is the card's
|
|
1519
|
+
// TRUE aspect, not the tilt-skewed axis-aligned bbox aspect
|
|
1520
|
+
// (which drifts toward 1.0 as the card rotates).
|
|
1521
|
+
const detectedAspect = rotH > 0 ? rotW / rotH : 0;
|
|
1522
|
+
const normalizedAspect = Math.max(
|
|
1523
|
+
detectedAspect,
|
|
1524
|
+
detectedAspect > 0 ? 1 / detectedAspect : 0,
|
|
1525
|
+
);
|
|
1526
|
+
if (area > maxArea) lastCandidateAspect = normalizedAspect;
|
|
1527
|
+
const lockedDocType = discoveryRef.current.docType;
|
|
1528
|
+
const expectedAspect = lockedDocType
|
|
1529
|
+
? ASPECT_RATIOS[lockedDocType]
|
|
1530
|
+
: null;
|
|
1531
|
+
const isBookDocAspect =
|
|
1532
|
+
lockedDocType === 'passport' || lockedDocType === 'greenbook';
|
|
1533
|
+
// minAreaRect gives the document's TRUE aspect (tilt-invariant),
|
|
1534
|
+
// so both windows can be tight. id-card: 1.585 ± 12%. Passport
|
|
1535
|
+
// (1.42) ± 10% = [1.278, 1.562] — excludes ID cards (1.585),
|
|
1536
|
+
// 16:9/16:10 monitors and phones that the old ±0.35 admitted.
|
|
1537
|
+
// Both tunable for on-device dialing.
|
|
1538
|
+
const aspectTolerance = isBookDocAspect
|
|
1539
|
+
? (settingsRef.current.bookDocAspectTolerance ?? 0.1)
|
|
1540
|
+
: (settingsRef.current.idAspectTolerance ?? 0.12);
|
|
1541
|
+
const aspectOk = expectedAspect
|
|
1542
|
+
? Math.abs(normalizedAspect - expectedAspect) /
|
|
1543
|
+
expectedAspect <
|
|
1544
|
+
aspectTolerance
|
|
1545
|
+
: normalizedAspect >= 1.18 && normalizedAspect <= 1.95;
|
|
1546
|
+
|
|
1547
|
+
let anglesOk = true;
|
|
1548
|
+
for (let j = 0; j < 4; j++) {
|
|
1549
|
+
const p0x = approx.data32S[j * 2];
|
|
1550
|
+
const p0y = approx.data32S[j * 2 + 1];
|
|
1551
|
+
const p1x = approx.data32S[((j + 1) % 4) * 2];
|
|
1552
|
+
const p1y = approx.data32S[((j + 1) % 4) * 2 + 1];
|
|
1553
|
+
const p2x = approx.data32S[((j + 2) % 4) * 2];
|
|
1554
|
+
const p2y = approx.data32S[((j + 2) % 4) * 2 + 1];
|
|
1555
|
+
const v1x = p0x - p1x;
|
|
1556
|
+
const v1y = p0y - p1y;
|
|
1557
|
+
const v2x = p2x - p1x;
|
|
1558
|
+
const v2y = p2y - p1y;
|
|
1559
|
+
const dot = v1x * v2x + v1y * v2y;
|
|
1560
|
+
const mag1 = Math.sqrt(v1x * v1x + v1y * v1y);
|
|
1561
|
+
const mag2 = Math.sqrt(v2x * v2x + v2y * v2y);
|
|
1562
|
+
if (mag1 === 0 || mag2 === 0) {
|
|
1563
|
+
anglesOk = false;
|
|
1564
|
+
break;
|
|
1565
|
+
}
|
|
1566
|
+
const cosAngle = dot / (mag1 * mag2);
|
|
1567
|
+
const angle =
|
|
1568
|
+
Math.acos(Math.max(-1, Math.min(1, cosAngle))) *
|
|
1569
|
+
(180 / Math.PI);
|
|
1570
|
+
if (angle < 60 || angle > 120) {
|
|
1571
|
+
anglesOk = false;
|
|
1572
|
+
break;
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
const minFillRatio =
|
|
1577
|
+
settingsRef.current.minFillRatio ?? MIN_RECT_FILL_RATIO;
|
|
1578
|
+
|
|
1579
|
+
// Seam rejection: a candidate that passes every shape gate and
|
|
1580
|
+
// is the new largest is tested against the frame's straight
|
|
1581
|
+
// background lines. If >= 2 of its edges sit on through-lines
|
|
1582
|
+
// that overshoot its corners, it is framed by seams (parquet /
|
|
1583
|
+
// slatted table), not a card — reject. Evaluated here (gated on
|
|
1584
|
+
// passesShape && area > maxArea) so the lazy Hough pass runs at
|
|
1585
|
+
// most once per frame and only when a real candidate appears.
|
|
1586
|
+
const passesShape =
|
|
1587
|
+
fillRatio > minFillRatio &&
|
|
1588
|
+
anglesOk &&
|
|
1589
|
+
aspectOk &&
|
|
1590
|
+
!roiWallHug;
|
|
1591
|
+
let seamReject = false;
|
|
1592
|
+
if (passesShape && area > maxArea && seamRejectEnabled) {
|
|
1593
|
+
const corners: SeamCorner[] = [];
|
|
1594
|
+
for (let c = 0; c < 4; c++) {
|
|
1595
|
+
corners.push({
|
|
1596
|
+
x: approx.data32S[c * 2],
|
|
1597
|
+
y: approx.data32S[c * 2 + 1],
|
|
1598
|
+
});
|
|
1599
|
+
}
|
|
1600
|
+
const lineSegments = getHoughSegments();
|
|
1601
|
+
// Clutter guard: on a high-frequency texture (woven fabric,
|
|
1602
|
+
// carpet) HoughLinesP returns hundreds of long lines, so a
|
|
1603
|
+
// real card always has >=2 edges sitting on overshooting
|
|
1604
|
+
// through-lines and would be wrongly rejected. The seam
|
|
1605
|
+
// discriminator only holds on LOW-clutter surfaces (a parquet
|
|
1606
|
+
// shows a handful of lines, a fabric ~400+), so skip the gate
|
|
1607
|
+
// entirely once the line count is implausibly high.
|
|
1608
|
+
const seamMaxLines =
|
|
1609
|
+
settingsRef.current.seamMaxHoughLines ?? 60;
|
|
1610
|
+
const tooCluttered = lineSegments.length > seamMaxLines;
|
|
1611
|
+
seamReject =
|
|
1612
|
+
!tooCluttered &&
|
|
1613
|
+
isSeamFalseQuad(corners, lineSegments, {
|
|
1614
|
+
roiW: clampedW,
|
|
1615
|
+
roiH: clampedH,
|
|
1616
|
+
});
|
|
1617
|
+
mergeDebugInfo({
|
|
1618
|
+
houghLines: lineSegments.length,
|
|
1619
|
+
seamRejected: seamReject,
|
|
1620
|
+
seamClutter: tooCluttered,
|
|
1621
|
+
});
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
// Chroma-content gate is applied AFTER selection, on a rolling
|
|
1625
|
+
// average of the chosen candidate's chroma (see below) — the
|
|
1626
|
+
// per-frame value is too noisy (AWB/exposure/contour jitter) to
|
|
1627
|
+
// gate on directly. Geometry selects the candidate here.
|
|
1628
|
+
if (
|
|
1629
|
+
fillRatio > minFillRatio &&
|
|
1630
|
+
anglesOk &&
|
|
1631
|
+
aspectOk &&
|
|
1632
|
+
!roiWallHug &&
|
|
1633
|
+
area > maxArea &&
|
|
1634
|
+
!seamReject
|
|
1635
|
+
) {
|
|
1636
|
+
maxArea = area;
|
|
1637
|
+
if (bestContour) bestContour.delete();
|
|
1638
|
+
bestContour = approx;
|
|
1639
|
+
// Record the winner's true geometry for the composite quality
|
|
1640
|
+
// score read later at the stability gate (out of scope there).
|
|
1641
|
+
winnerGeomRef.current = {
|
|
1642
|
+
aspect: normalizedAspect,
|
|
1643
|
+
fillRatio,
|
|
1644
|
+
synthetic: false,
|
|
1645
|
+
};
|
|
1646
|
+
// Genuine 4-corner card validated — open the synthetic
|
|
1647
|
+
// fallback's bridge window and reset the mobile-region streak.
|
|
1648
|
+
lastRealCardAtRef.current = frameTimeMs;
|
|
1649
|
+
regionStabilityRef.current = 0;
|
|
1650
|
+
} else {
|
|
1651
|
+
// Desktop: the quad failed ONLY the wall-hug check — every
|
|
1652
|
+
// shape gate (rectangularity, angles, aspect) says this is
|
|
1653
|
+
// a real card, just too close.
|
|
1654
|
+
if (
|
|
1655
|
+
skipGridCheck &&
|
|
1656
|
+
roiWallHug &&
|
|
1657
|
+
fillRatio > minFillRatio &&
|
|
1658
|
+
anglesOk &&
|
|
1659
|
+
aspectOk
|
|
1660
|
+
) {
|
|
1661
|
+
wallHugRejectedCardThisFrame = true;
|
|
1662
|
+
}
|
|
1663
|
+
approx.delete();
|
|
1664
|
+
}
|
|
1665
|
+
} else {
|
|
1666
|
+
approx.delete();
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1669
|
+
cnt.delete();
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
mergeDebugInfo({ aspect: lastCandidateAspect.toFixed(2) });
|
|
1673
|
+
|
|
1674
|
+
// --- Desktop overflow detection ---
|
|
1675
|
+
// A card pushed too close overflows the ROI: its outer edges leave
|
|
1676
|
+
// the frame, so no 4-corner quad forms (and the wall-hug "too
|
|
1677
|
+
// close" signal above never fires — it needs a complete quad).
|
|
1678
|
+
// What remains are inner-content contours CLIPPED at the ROI
|
|
1679
|
+
// walls. A combined bbox touching 2+ walls with capture-grade grid
|
|
1680
|
+
// coverage means the document overflows the box; a genuinely far
|
|
1681
|
+
// card produces a small, centered bbox touching nothing.
|
|
1682
|
+
// Require an OPPOSITE wall pair (left+right or top+bottom): a card
|
|
1683
|
+
// overflowing the box spans the ROI along an axis, while the hand
|
|
1684
|
+
// holding it intrudes from one side or corner — adjacent touches —
|
|
1685
|
+
// and must not read as overflow at an otherwise good distance.
|
|
1686
|
+
let combinedBboxOverflow = false;
|
|
1687
|
+
if (skipGridCheck && hasSignificantContour) {
|
|
1688
|
+
const cbMargin = Math.round(Math.min(clampedW, clampedH) * 0.04);
|
|
1689
|
+
const touchesLeft = combinedMinX <= cbMargin;
|
|
1690
|
+
const touchesTop = combinedMinY <= cbMargin;
|
|
1691
|
+
const touchesRight = combinedMaxX >= clampedW - cbMargin;
|
|
1692
|
+
const touchesBottom = combinedMaxY >= clampedH - cbMargin;
|
|
1693
|
+
combinedBboxOverflow =
|
|
1694
|
+
((touchesLeft && touchesRight) ||
|
|
1695
|
+
(touchesTop && touchesBottom)) &&
|
|
1696
|
+
passingCells >= 7;
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
// --- Chroma-mask fallback (colored card on neutral background) ---
|
|
1700
|
+
// A strongly COLOURED card (e.g. a green/yellow ID) on a near-neutral
|
|
1701
|
+
// surface (grey fabric) has almost no LUMINANCE border, and its chroma
|
|
1702
|
+
// edges are swamped by the background texture, so no 4-corner quad
|
|
1703
|
+
// forms above. Segment the card by CHROMA MAGNITUDE instead: threshold
|
|
1704
|
+
// |a-128|+|b-128| (already computed as chromaMag), clean up, take the
|
|
1705
|
+
// largest blob. Heavily gated — a chromatic BACKGROUND (wood/parquet)
|
|
1706
|
+
// fills the ROI and is caught by the coverage/wall-hug gates.
|
|
1707
|
+
// KNOWN LIMITATION: a colourful rug/carpet patch is classically
|
|
1708
|
+
// indistinguishable from a card and CAN pass this path (fill, aspect
|
|
1709
|
+
// and internal-edge density all overlap). Flag-gated (chromaMaskFallback,
|
|
1710
|
+
// default on for mobile) so it can be disabled live if it regresses.
|
|
1711
|
+
const chromaMaskOn =
|
|
1712
|
+
settingsRef.current.chromaMaskFallback === true &&
|
|
1713
|
+
!!chromaMag &&
|
|
1714
|
+
!skipGridCheck;
|
|
1715
|
+
if (!bestContour && chromaMaskOn) {
|
|
1716
|
+
let mask: any = null;
|
|
1717
|
+
let maskContours: any = null;
|
|
1718
|
+
let maskHierarchy: any = null;
|
|
1719
|
+
let maskKernel: any = null;
|
|
1720
|
+
let maskBest: any = null;
|
|
1721
|
+
let maskApprox: any = null;
|
|
1722
|
+
try {
|
|
1723
|
+
const roiPix = clampedW * clampedH;
|
|
1724
|
+
mask = new cv.Mat();
|
|
1725
|
+
const maskThresh = settingsRef.current.chromaMaskThreshold ?? 18;
|
|
1726
|
+
cv.threshold(chromaMag, mask, maskThresh, 255, cv.THRESH_BINARY);
|
|
1727
|
+
maskKernel = cv.getStructuringElement(
|
|
1728
|
+
cv.MORPH_RECT,
|
|
1729
|
+
new cv.Size(7, 7),
|
|
1730
|
+
);
|
|
1731
|
+
cv.morphologyEx(
|
|
1732
|
+
mask,
|
|
1733
|
+
mask,
|
|
1734
|
+
cv.MORPH_CLOSE,
|
|
1735
|
+
maskKernel,
|
|
1736
|
+
new cv.Point(-1, -1),
|
|
1737
|
+
2,
|
|
1738
|
+
);
|
|
1739
|
+
cv.morphologyEx(
|
|
1740
|
+
mask,
|
|
1741
|
+
mask,
|
|
1742
|
+
cv.MORPH_OPEN,
|
|
1743
|
+
maskKernel,
|
|
1744
|
+
new cv.Point(-1, -1),
|
|
1745
|
+
1,
|
|
1746
|
+
);
|
|
1747
|
+
const maskFrac = cv.countNonZero(mask) / roiPix;
|
|
1748
|
+
maskContours = new cv.MatVector();
|
|
1749
|
+
maskHierarchy = new cv.Mat();
|
|
1750
|
+
cv.findContours(
|
|
1751
|
+
mask,
|
|
1752
|
+
maskContours,
|
|
1753
|
+
maskHierarchy,
|
|
1754
|
+
cv.RETR_EXTERNAL,
|
|
1755
|
+
cv.CHAIN_APPROX_SIMPLE,
|
|
1756
|
+
);
|
|
1757
|
+
let maskBestArea = 0;
|
|
1758
|
+
for (let mi = 0; mi < maskContours.size(); mi++) {
|
|
1759
|
+
const c = maskContours.get(mi);
|
|
1760
|
+
const a = cv.contourArea(c);
|
|
1761
|
+
if (a > maskBestArea) {
|
|
1762
|
+
maskBestArea = a;
|
|
1763
|
+
if (maskBest) maskBest.delete();
|
|
1764
|
+
maskBest = c;
|
|
1765
|
+
} else {
|
|
1766
|
+
c.delete();
|
|
1767
|
+
}
|
|
1768
|
+
}
|
|
1769
|
+
const areaFrac = maskBestArea / roiPix;
|
|
1770
|
+
const maskMaxFrac = settingsRef.current.chromaMaskMaxFrac ?? 0.7;
|
|
1771
|
+
const maskMinFrac = settingsRef.current.chromaMaskMinFrac ?? 0.08;
|
|
1772
|
+
// Coverage band: too small => noise; > maxFrac => a chromatic
|
|
1773
|
+
// background spanning the whole ROI, not a card.
|
|
1774
|
+
if (
|
|
1775
|
+
maskBest &&
|
|
1776
|
+
areaFrac >= maskMinFrac &&
|
|
1777
|
+
maskFrac <= maskMaxFrac
|
|
1778
|
+
) {
|
|
1779
|
+
const peri = cv.arcLength(maskBest, true);
|
|
1780
|
+
maskApprox = new cv.Mat();
|
|
1781
|
+
cv.approxPolyDP(maskBest, maskApprox, 0.04 * peri, true);
|
|
1782
|
+
if (maskApprox.rows > 4 && maskApprox.rows <= 7) {
|
|
1783
|
+
maskApprox.delete();
|
|
1784
|
+
maskApprox = new cv.Mat();
|
|
1785
|
+
cv.approxPolyDP(maskBest, maskApprox, 0.07 * peri, true);
|
|
1786
|
+
}
|
|
1787
|
+
if (maskApprox.rows === 4) {
|
|
1788
|
+
const mRect = cv.minAreaRect(maskApprox);
|
|
1789
|
+
const mw = mRect.size.width;
|
|
1790
|
+
const mh = mRect.size.height;
|
|
1791
|
+
const mFill = mw > 0 && mh > 0 ? maskBestArea / (mw * mh) : 0;
|
|
1792
|
+
const mAsp = mh > 0 ? mw / mh : 0;
|
|
1793
|
+
const mNorm = Math.max(mAsp, mAsp > 0 ? 1 / mAsp : 0);
|
|
1794
|
+
const mBr = cv.boundingRect(maskApprox);
|
|
1795
|
+
const wm = Math.round(Math.min(clampedW, clampedH) * 0.04);
|
|
1796
|
+
const mTouches =
|
|
1797
|
+
(mBr.x <= wm ? 1 : 0) +
|
|
1798
|
+
(mBr.y <= wm ? 1 : 0) +
|
|
1799
|
+
(mBr.x + mBr.width >= clampedW - wm ? 1 : 0) +
|
|
1800
|
+
(mBr.y + mBr.height >= clampedH - wm ? 1 : 0);
|
|
1801
|
+
// Same aspect windows as the real-contour path: tight when the
|
|
1802
|
+
// doc type is locked, the passport∪ID discovery window otherwise.
|
|
1803
|
+
const maskDocType = discoveryRef.current.docType;
|
|
1804
|
+
const maskExpected = maskDocType
|
|
1805
|
+
? ASPECT_RATIOS[maskDocType]
|
|
1806
|
+
: null;
|
|
1807
|
+
const maskIsBookDoc =
|
|
1808
|
+
maskDocType === 'passport' || maskDocType === 'greenbook';
|
|
1809
|
+
const maskAspectTol = maskIsBookDoc
|
|
1810
|
+
? (settingsRef.current.bookDocAspectTolerance ?? 0.1)
|
|
1811
|
+
: (settingsRef.current.idAspectTolerance ?? 0.12);
|
|
1812
|
+
const maskAspectOk = maskExpected
|
|
1813
|
+
? Math.abs(mNorm - maskExpected) / maskExpected <
|
|
1814
|
+
maskAspectTol
|
|
1815
|
+
: mNorm >= 1.18 && mNorm <= 1.95;
|
|
1816
|
+
const maskFillOk =
|
|
1817
|
+
mFill >
|
|
1818
|
+
(settingsRef.current.minFillRatio ?? MIN_RECT_FILL_RATIO);
|
|
1819
|
+
const maskWallOk = mTouches < (skipGridCheck ? 4 : 3);
|
|
1820
|
+
mergeDebugInfo({
|
|
1821
|
+
chromaMaskFrac: Math.round(maskFrac * 100),
|
|
1822
|
+
chromaMaskArea: Math.round(areaFrac * 100),
|
|
1823
|
+
chromaMaskFill: mFill.toFixed(2),
|
|
1824
|
+
chromaMaskAspect: mNorm.toFixed(2),
|
|
1825
|
+
chromaMaskWall: mTouches,
|
|
1826
|
+
});
|
|
1827
|
+
if (maskAspectOk && maskFillOk && maskWallOk) {
|
|
1828
|
+
// Genuine full-card quad — treat as a real contour (NOT
|
|
1829
|
+
// synthetic): distance/fill gating applies normally below.
|
|
1830
|
+
bestContour = maskApprox;
|
|
1831
|
+
maskApprox = null; // ownership transferred to bestContour
|
|
1832
|
+
winnerGeomRef.current = {
|
|
1833
|
+
aspect: mNorm,
|
|
1834
|
+
fillRatio: mFill,
|
|
1835
|
+
synthetic: false,
|
|
1836
|
+
};
|
|
1837
|
+
edgeSource = 'chroma-mask';
|
|
1838
|
+
mergeDebugInfo({ contourSource: edgeSource });
|
|
1839
|
+
}
|
|
1840
|
+
}
|
|
1841
|
+
}
|
|
1842
|
+
} catch (maskErr) {
|
|
1843
|
+
mergeDebugInfo({ chromaMaskError: formatDebugError(maskErr) });
|
|
1844
|
+
} finally {
|
|
1845
|
+
safeDelete(
|
|
1846
|
+
mask,
|
|
1847
|
+
maskContours,
|
|
1848
|
+
maskHierarchy,
|
|
1849
|
+
maskKernel,
|
|
1850
|
+
maskBest,
|
|
1851
|
+
maskApprox,
|
|
1852
|
+
);
|
|
1853
|
+
}
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1856
|
+
// --- Book-doc fallback ---
|
|
1857
|
+
// Passport/greenbook frequently fail the strict 4-vertex check
|
|
1858
|
+
// because the binding seam at the top is low-contrast and breaks
|
|
1859
|
+
// the outer contour. When no 4-corner card is found in CAPTURE
|
|
1860
|
+
// phase for a book doc, synthesize an axis-aligned rect from the
|
|
1861
|
+
// combined bbox of all significant contours (photo, text, MRZ,
|
|
1862
|
+
// page edges) if the aspect roughly matches the locked doc type.
|
|
1863
|
+
if (!bestContour && hasSignificantContour && !isDiscovery) {
|
|
1864
|
+
const lockedDocTypeForFallback = discoveryRef.current.docType;
|
|
1865
|
+
// Synthetic-fallback eligibility, shared by id-card AND book docs.
|
|
1866
|
+
// Either a genuine 4-corner quad was seen moments ago and briefly
|
|
1867
|
+
// dropped out (finger/glare broke an edge), OR the scene passes the
|
|
1868
|
+
// 7/9 grid-coverage bar (a document fills the box; a document-free
|
|
1869
|
+
// scene — face, furniture, window frames — leaves blank cells).
|
|
1870
|
+
// Without this gate the combined bbox of background contours passes
|
|
1871
|
+
// the aspect/area checks below and auto-captures a non-document.
|
|
1872
|
+
// An overflowing card must never synthesize: its bbox covers only
|
|
1873
|
+
// the visible (clipped) content, so the fill metric underestimates
|
|
1874
|
+
// distance and a capture would clip the card's edges anyway.
|
|
1875
|
+
const synthCoverageEligible =
|
|
1876
|
+
!combinedBboxOverflow &&
|
|
1877
|
+
(isSyntheticBridgeRecent(
|
|
1878
|
+
lastRealCardAtRef.current,
|
|
1879
|
+
frameTimeMs,
|
|
1880
|
+
) ||
|
|
1881
|
+
passingCells >= 7);
|
|
1882
|
+
// Passports/greenbooks rarely form a clean 4-corner quad (the spine
|
|
1883
|
+
// breaks the outer contour), so they depend on this synthetic path
|
|
1884
|
+
// — but it MUST be gated like id-card. Previously book docs
|
|
1885
|
+
// synthesized on aspect+area alone, so an empty desktop scene
|
|
1886
|
+
// synthesized a passport from background contours and auto-captured.
|
|
1887
|
+
const isBookDocFallback =
|
|
1888
|
+
(lockedDocTypeForFallback === 'passport' ||
|
|
1889
|
+
lockedDocTypeForFallback === 'greenbook') &&
|
|
1890
|
+
synthCoverageEligible;
|
|
1891
|
+
const idCardSynthEligible = synthCoverageEligible;
|
|
1892
|
+
// Fix 3: mobile content-region fallback. Mirror the desktop id-card
|
|
1893
|
+
// synthetic on mobile (where skipGridCheck is false), but gate it on
|
|
1894
|
+
// sustained presence: a candidate must persist
|
|
1895
|
+
// MOBILE_REGION_STABILITY_FRAMES frames before it can synthesize a
|
|
1896
|
+
// card. The aspect/area gates below and the synthetic id-card fill
|
|
1897
|
+
// enforcement downstream still apply, so distance/shape safety holds.
|
|
1898
|
+
const mobileRegionCandidate =
|
|
1899
|
+
settingsRef.current.mobileRegionFallback === true &&
|
|
1900
|
+
!skipGridCheck &&
|
|
1901
|
+
lockedDocTypeForFallback === 'id-card' &&
|
|
1902
|
+
passingCells >= 7 &&
|
|
1903
|
+
!combinedBboxOverflow;
|
|
1904
|
+
if (mobileRegionCandidate) {
|
|
1905
|
+
regionStabilityRef.current += 1;
|
|
1906
|
+
} else {
|
|
1907
|
+
regionStabilityRef.current = 0;
|
|
1908
|
+
}
|
|
1909
|
+
const mobileRegionEligible =
|
|
1910
|
+
mobileRegionCandidate &&
|
|
1911
|
+
regionStabilityRef.current >= MOBILE_REGION_STABILITY_FRAMES;
|
|
1912
|
+
const isIdCardFallback =
|
|
1913
|
+
(skipGridCheck &&
|
|
1914
|
+
lockedDocTypeForFallback === 'id-card' &&
|
|
1915
|
+
idCardSynthEligible) ||
|
|
1916
|
+
mobileRegionEligible;
|
|
1917
|
+
if (isBookDocFallback || isIdCardFallback) {
|
|
1918
|
+
// Card-focused bbox (A): the document is a dense cluster of content
|
|
1919
|
+
// contours; a hand/arm entering the frame is a sparse outlier that
|
|
1920
|
+
// the absolute union (combinedMin/Max) lets inflate the box, so
|
|
1921
|
+
// distance over-reads and it captures from too far. On desktop,
|
|
1922
|
+
// with enough contours, take a percentile envelope (10th–90th) so
|
|
1923
|
+
// 1–2 outlier contours are trimmed and the box hugs the card.
|
|
1924
|
+
// Mobile keeps the absolute union unchanged.
|
|
1925
|
+
let cMinX = combinedMinX;
|
|
1926
|
+
let cMinY = combinedMinY;
|
|
1927
|
+
let cMaxX = combinedMaxX;
|
|
1928
|
+
let cMaxY = combinedMaxY;
|
|
1929
|
+
if (skipGridCheck && contourBoxes.length >= 8) {
|
|
1930
|
+
const pctl = (vals: number[], p: number) => {
|
|
1931
|
+
const s = vals.slice().sort((a, b) => a - b);
|
|
1932
|
+
return s[Math.round(p * (s.length - 1))];
|
|
1933
|
+
};
|
|
1934
|
+
cMinX = pctl(
|
|
1935
|
+
contourBoxes.map((c) => c.x),
|
|
1936
|
+
0.1,
|
|
1937
|
+
);
|
|
1938
|
+
cMinY = pctl(
|
|
1939
|
+
contourBoxes.map((c) => c.y),
|
|
1940
|
+
0.1,
|
|
1941
|
+
);
|
|
1942
|
+
cMaxX = pctl(
|
|
1943
|
+
contourBoxes.map((c) => c.r),
|
|
1944
|
+
0.9,
|
|
1945
|
+
);
|
|
1946
|
+
cMaxY = pctl(
|
|
1947
|
+
contourBoxes.map((c) => c.b),
|
|
1948
|
+
0.9,
|
|
1949
|
+
);
|
|
1950
|
+
}
|
|
1951
|
+
const bw = cMaxX - cMinX;
|
|
1952
|
+
const bh = cMaxY - cMinY;
|
|
1953
|
+
if (bw > 0 && bh > 0) {
|
|
1954
|
+
const expectedAspect = ASPECT_RATIOS[lockedDocTypeForFallback];
|
|
1955
|
+
const rawAspect = bw / bh;
|
|
1956
|
+
const normalizedAspect = Math.max(rawAspect, 1 / rawAspect);
|
|
1957
|
+
// Use the same tight aspect windows as the real-contour path so
|
|
1958
|
+
// the fallback can't accept an off-aspect rectangle the strict
|
|
1959
|
+
// path would reject (16:9 screen, ID card in passport flow…).
|
|
1960
|
+
const aspectTol = isBookDocFallback
|
|
1961
|
+
? (settingsRef.current.bookDocAspectTolerance ?? 0.1)
|
|
1962
|
+
: (settingsRef.current.idAspectTolerance ?? 0.12);
|
|
1963
|
+
const aspectOk =
|
|
1964
|
+
Math.abs(normalizedAspect - expectedAspect) / expectedAspect <
|
|
1965
|
+
aspectTol;
|
|
1966
|
+
const minArea =
|
|
1967
|
+
guideWidth * guideHeight * MIN_CONTOUR_AREA_PERCENT;
|
|
1968
|
+
// The chroma-content gate is NOT applied here — selection on
|
|
1969
|
+
// both the real and synthetic paths is geometry-only. The
|
|
1970
|
+
// synthesized contour is gated downstream on the rolling chroma
|
|
1971
|
+
// average alongside the real-contour winner (see below), so a
|
|
1972
|
+
// near-monochrome object (white keyboard) that only ever forms
|
|
1973
|
+
// a synthetic rect is still rejected, without double-gating on
|
|
1974
|
+
// the noisy per-frame value.
|
|
1975
|
+
if (aspectOk && bw * bh > minArea) {
|
|
1976
|
+
// For id-card synthetics, the combined bbox covers only the
|
|
1977
|
+
// inner printed content (text, photo, header band). Real cards
|
|
1978
|
+
// have a ~10-15% margin from card edge to first element, so
|
|
1979
|
+
// the raw bbox understates the true card extent. Expand from
|
|
1980
|
+
// the bbox center by a card-margin factor and clamp to ROI.
|
|
1981
|
+
let minX = cMinX;
|
|
1982
|
+
let minY = cMinY;
|
|
1983
|
+
let maxX = cMaxX;
|
|
1984
|
+
let maxY = cMaxY;
|
|
1985
|
+
if (isIdCardFallback) {
|
|
1986
|
+
const SYNTH_EXPAND = 1.15;
|
|
1987
|
+
const cx = (cMinX + cMaxX) / 2;
|
|
1988
|
+
const cy = (cMinY + cMaxY) / 2;
|
|
1989
|
+
const halfW = (bw * SYNTH_EXPAND) / 2;
|
|
1990
|
+
const halfH = (bh * SYNTH_EXPAND) / 2;
|
|
1991
|
+
minX = Math.max(0, Math.round(cx - halfW));
|
|
1992
|
+
minY = Math.max(0, Math.round(cy - halfH));
|
|
1993
|
+
maxX = Math.min(clampedW, Math.round(cx + halfW));
|
|
1994
|
+
maxY = Math.min(clampedH, Math.round(cy + halfH));
|
|
1995
|
+
}
|
|
1996
|
+
const synth = new cv.Mat(4, 1, cv.CV_32SC2);
|
|
1997
|
+
synth.data32S[0] = minX;
|
|
1998
|
+
synth.data32S[1] = minY;
|
|
1999
|
+
synth.data32S[2] = maxX;
|
|
2000
|
+
synth.data32S[3] = minY;
|
|
2001
|
+
synth.data32S[4] = maxX;
|
|
2002
|
+
synth.data32S[5] = maxY;
|
|
2003
|
+
synth.data32S[6] = minX;
|
|
2004
|
+
synth.data32S[7] = maxY;
|
|
2005
|
+
bestContour = synth;
|
|
2006
|
+
bestContourIsSynthetic = true;
|
|
2007
|
+
// Synthetic rect: record its aspect; fillRatio is not
|
|
2008
|
+
// meaningful (the rect is inferred), so the quality score
|
|
2009
|
+
// applies a fixed lower confidence (SYNTHETIC_CONTOUR_*).
|
|
2010
|
+
winnerGeomRef.current = {
|
|
2011
|
+
aspect: normalizedAspect,
|
|
2012
|
+
fillRatio: 0,
|
|
2013
|
+
synthetic: true,
|
|
2014
|
+
};
|
|
2015
|
+
if (mobileRegionEligible) {
|
|
2016
|
+
edgeSource = 'region';
|
|
2017
|
+
mergeDebugInfo({ contourSource: edgeSource });
|
|
2018
|
+
}
|
|
2019
|
+
}
|
|
2020
|
+
}
|
|
2021
|
+
}
|
|
2022
|
+
}
|
|
2023
|
+
|
|
2024
|
+
// --- Distance guidance (combined bounding box of all contours) ---
|
|
2025
|
+
// The combined bbox captures the document's full extent even when edges
|
|
2026
|
+
// are broken into many fragments by fingers, glare, etc.
|
|
2027
|
+
// Skip in card variant: the ROI is the full video frame, so background
|
|
2028
|
+
// contours (wall, desk, etc.) inflate the bbox to ~100% always.
|
|
2029
|
+
// docFillPercent is a ratio, so each branch divides by the ROI area
|
|
2030
|
+
// in ITS OWN coordinate space: full-res for the contour-pass geometry,
|
|
2031
|
+
// 640px for the presenceEdges fallback.
|
|
2032
|
+
const roiArea = clampedW * clampedH; // full-res ROI (contour pass)
|
|
2033
|
+
const dsRoiArea = dsClampedW * dsClampedH; // 640px ROI (presence map)
|
|
2034
|
+
let docFillPercent = 0;
|
|
2035
|
+
|
|
2036
|
+
if (bestContour) {
|
|
2037
|
+
const cardRect = cv.boundingRect(bestContour);
|
|
2038
|
+
docFillPercent =
|
|
2039
|
+
((cardRect.width * cardRect.height) / roiArea) * 100;
|
|
2040
|
+
} else if (hasSignificantContour) {
|
|
2041
|
+
const cbw = combinedMaxX - combinedMinX;
|
|
2042
|
+
const cbh = combinedMaxY - combinedMinY;
|
|
2043
|
+
docFillPercent = ((cbw * cbh) / roiArea) * 100;
|
|
2044
|
+
} else if (USE_PRESENCE_FILL_METRIC) {
|
|
2045
|
+
let nz = null;
|
|
2046
|
+
try {
|
|
2047
|
+
nz = new cv.Mat();
|
|
2048
|
+
cv.findNonZero(presenceEdges, nz);
|
|
2049
|
+
if (nz.rows > 0) {
|
|
2050
|
+
const br = cv.boundingRect(nz);
|
|
2051
|
+
docFillPercent = ((br.width * br.height) / dsRoiArea) * 100;
|
|
2052
|
+
}
|
|
2053
|
+
} catch {
|
|
2054
|
+
// fall through with docFillPercent = 0
|
|
2055
|
+
} finally {
|
|
2056
|
+
if (nz) nz.delete();
|
|
2057
|
+
}
|
|
2058
|
+
}
|
|
2059
|
+
// Smooth the fill % with an EMA so distance jitter near the gate
|
|
2060
|
+
// thresholds doesn't toggle "move closer/further" frame-to-frame.
|
|
2061
|
+
// alpha = 1 disables smoothing (desktop default via the ?? fallback).
|
|
2062
|
+
const fillAlpha = settingsRef.current.docFillEmaAlpha ?? 1;
|
|
2063
|
+
docFillEmaRef.current =
|
|
2064
|
+
docFillEmaRef.current == null
|
|
2065
|
+
? docFillPercent
|
|
2066
|
+
: fillAlpha * docFillPercent +
|
|
2067
|
+
(1 - fillAlpha) * docFillEmaRef.current;
|
|
2068
|
+
const smoothedDocFill = docFillEmaRef.current;
|
|
2069
|
+
latestDocFillRef.current = smoothedDocFill;
|
|
2070
|
+
|
|
2071
|
+
// Active whenever we have a real contour to measure against.
|
|
2072
|
+
// Skip distance gating when the contour is the synthetic book-doc
|
|
2073
|
+
// fallback: its bbox reflects inner content, not the full page,
|
|
2074
|
+
// so fill% is structurally low and would block capture forever.
|
|
2075
|
+
// ID-card synthetics still enforce fill — the combined bbox reflects
|
|
2076
|
+
// the card's actual extent so the distance signal is meaningful.
|
|
2077
|
+
const isIdCardSynthetic =
|
|
2078
|
+
bestContourIsSynthetic &&
|
|
2079
|
+
discoveryRef.current.docType === 'id-card';
|
|
2080
|
+
const fillCheckActive =
|
|
2081
|
+
!!bestContour && (!bestContourIsSynthetic || isIdCardSynthetic);
|
|
2082
|
+
|
|
2083
|
+
// Book-style docs (passport/greenbook) frequently yield a contour
|
|
2084
|
+
// covering only the bio-data page rather than the full guide rect,
|
|
2085
|
+
// so we relax the minimum fill threshold for them.
|
|
2086
|
+
// ID-card synthetics use the standard floor — their bbox has been
|
|
2087
|
+
// pre-expanded above to approximate the true card edge.
|
|
2088
|
+
// Per-device overrides can be supplied via settings (minFillPercent /
|
|
2089
|
+
// maxFillPercent). Desktop uses 70 / 98 (measured against the visible
|
|
2090
|
+
// box, which IS the desktop ROI); mobile keeps 75 / 95.
|
|
2091
|
+
const lockedDocTypeForFill = discoveryRef.current.docType;
|
|
2092
|
+
const isBookDocFill =
|
|
2093
|
+
lockedDocTypeForFill === 'passport' ||
|
|
2094
|
+
lockedDocTypeForFill === 'greenbook';
|
|
2095
|
+
const minFillPercent = isBookDocFill
|
|
2096
|
+
? 20
|
|
2097
|
+
: (settingsRef.current.minFillPercent ?? MIN_FILL_PERCENT);
|
|
2098
|
+
const maxFillPercent =
|
|
2099
|
+
settingsRef.current.maxFillPercent ?? MAX_FILL_PERCENT;
|
|
2100
|
+
|
|
2101
|
+
// Distance guidance only applies during capture phase (after doc type is locked).
|
|
2102
|
+
// During discovery, the guide box uses the wider passport ratio and distance
|
|
2103
|
+
// checks would block voting with misleading feedback.
|
|
2104
|
+
// Hysteresis deadband (pct points): trip the distance gate only when
|
|
2105
|
+
// the smoothed fill is clearly out of band, so a hand hovering on the
|
|
2106
|
+
// threshold doesn't toggle. 0 disables (desktop default).
|
|
2107
|
+
const fillBand = settingsRef.current.fillHysteresis ?? 0;
|
|
2108
|
+
const gateDecayOn = settingsRef.current.gateDecayEnabled === true;
|
|
2109
|
+
if (
|
|
2110
|
+
!isCard &&
|
|
2111
|
+
!isDiscovery &&
|
|
2112
|
+
fillCheckActive &&
|
|
2113
|
+
smoothedDocFill < minFillPercent - fillBand
|
|
2114
|
+
) {
|
|
2115
|
+
// Soften instead of nuking progress on a transient dip; only
|
|
2116
|
+
// downgrade the displayed state when the failure isn't absorbed.
|
|
2117
|
+
const absorbed = gateDecayOn && softFailStability();
|
|
2118
|
+
if (!absorbed) {
|
|
2119
|
+
setFeedback(autoCaptureFeedback.moveDocumentCloser);
|
|
2120
|
+
setComplianceState(COMPLIANCE_STATES.DETECTING);
|
|
2121
|
+
}
|
|
2122
|
+
if (bestContour) bestContour.delete();
|
|
2123
|
+
mergeDebugInfo({
|
|
2124
|
+
docFill: Math.round(smoothedDocFill),
|
|
2125
|
+
edgeDensity: edgeDensity.toFixed(1),
|
|
2126
|
+
texture: Math.round(textureScore),
|
|
2127
|
+
rejectReason: `fill too small (${Math.round(smoothedDocFill)}% < ${minFillPercent}%)${absorbed ? ' [held]' : ''}`,
|
|
2128
|
+
});
|
|
2129
|
+
return;
|
|
2130
|
+
}
|
|
2131
|
+
if (
|
|
2132
|
+
!isCard &&
|
|
2133
|
+
!isDiscovery &&
|
|
2134
|
+
fillCheckActive &&
|
|
2135
|
+
smoothedDocFill > maxFillPercent + fillBand
|
|
2136
|
+
) {
|
|
2137
|
+
const absorbed = gateDecayOn && softFailStability();
|
|
2138
|
+
if (!absorbed) {
|
|
2139
|
+
setFeedback(autoCaptureFeedback.moveDocumentFurtherAway);
|
|
2140
|
+
setComplianceState(COMPLIANCE_STATES.DETECTING);
|
|
2141
|
+
}
|
|
2142
|
+
if (bestContour) bestContour.delete();
|
|
2143
|
+
mergeDebugInfo({
|
|
2144
|
+
docFill: Math.round(smoothedDocFill),
|
|
2145
|
+
edgeDensity: edgeDensity.toFixed(1),
|
|
2146
|
+
texture: Math.round(textureScore),
|
|
2147
|
+
rejectReason: `fill too large (${Math.round(smoothedDocFill)}% > ${maxFillPercent}%)${absorbed ? ' [held]' : ''}`,
|
|
2148
|
+
});
|
|
2149
|
+
return;
|
|
2150
|
+
}
|
|
2151
|
+
|
|
2152
|
+
// --- Chroma-content gate (rolling average, post-selection) ---
|
|
2153
|
+
// Geometry just selected the best candidate (real quad or synthetic
|
|
2154
|
+
// rect). A white keyboard / blank paper passes every shape gate, so
|
|
2155
|
+
// reject near-monochrome winners by colour content. The per-frame
|
|
2156
|
+
// chroma is noisy, so we average it over the candidate's bbox across
|
|
2157
|
+
// the last few frames and only gate once the window has filled
|
|
2158
|
+
// (capture is still blocked by the stability counter until then).
|
|
2159
|
+
// Only active when chroma fusion built chromaMag (mobile) and on.
|
|
2160
|
+
if (
|
|
2161
|
+
bestContour &&
|
|
2162
|
+
chromaMag &&
|
|
2163
|
+
settingsRef.current.chromaContentGate === true
|
|
2164
|
+
) {
|
|
2165
|
+
const cRect = cv.boundingRect(bestContour);
|
|
2166
|
+
const cx = Math.max(0, cRect.x);
|
|
2167
|
+
const cy = Math.max(0, cRect.y);
|
|
2168
|
+
const cw = Math.min(chromaMag.cols - cx, cRect.width);
|
|
2169
|
+
const ch = Math.min(chromaMag.rows - cy, cRect.height);
|
|
2170
|
+
let candChroma = 0;
|
|
2171
|
+
if (cw > 0 && ch > 0) {
|
|
2172
|
+
const chRoi = chromaMag.roi(new cv.Rect(cx, cy, cw, ch));
|
|
2173
|
+
[candChroma] = cv.mean(chRoi);
|
|
2174
|
+
chRoi.delete();
|
|
2175
|
+
}
|
|
2176
|
+
const win = chromaWindowRef.current;
|
|
2177
|
+
win.push(candChroma);
|
|
2178
|
+
if (win.length > CHROMA_AVG_WINDOW) win.shift();
|
|
2179
|
+
const avgChroma = win.reduce((sum, v) => sum + v, 0) / win.length;
|
|
2180
|
+
mergeDebugInfo({ chroma: Math.round(avgChroma) });
|
|
2181
|
+
if (
|
|
2182
|
+
win.length >= CHROMA_MIN_SAMPLES &&
|
|
2183
|
+
avgChroma < (settingsRef.current.minChromaContent ?? 13)
|
|
2184
|
+
) {
|
|
2185
|
+
const absorbed = gateDecayOn && softFailStability();
|
|
2186
|
+
if (!absorbed) {
|
|
2187
|
+
setFeedback(autoCaptureFeedback.positionDocument);
|
|
2188
|
+
setComplianceState(COMPLIANCE_STATES.DETECTING);
|
|
2189
|
+
}
|
|
2190
|
+
bestContour.delete();
|
|
2191
|
+
mergeDebugInfo({
|
|
2192
|
+
rejectReason: `chroma content low (${Math.round(avgChroma)} < ${settingsRef.current.minChromaContent ?? 13})${absorbed ? ' [held]' : ''}`,
|
|
2193
|
+
});
|
|
2194
|
+
return;
|
|
2195
|
+
}
|
|
2196
|
+
}
|
|
2197
|
+
|
|
2198
|
+
if (bestContour) {
|
|
2199
|
+
// Reset consecutive miss counter on successful detection
|
|
2200
|
+
if (isDiscovery) discoveryRef.current.consecutiveMisses = 0;
|
|
2201
|
+
captureMissCounterRef.current = 0;
|
|
2202
|
+
// Mark in-guide hit so the off-guide scan stays paused while
|
|
2203
|
+
// a card is locked in.
|
|
2204
|
+
inGuideDetectedRef.current = true;
|
|
2205
|
+
|
|
2206
|
+
// Cache the card's bounding rect in canvas coords for tight
|
|
2207
|
+
// crop-to-contour at capture time. Contour points are ROI-relative,
|
|
2208
|
+
// so translate by the ROI origin. For the synthetic book-doc
|
|
2209
|
+
// fallback the contour bbox covers only inner content (photo,
|
|
2210
|
+
// text, MRZ) — expand it to the guide rect so the preview shows
|
|
2211
|
+
// the whole bio page rather than just the inner cluster.
|
|
2212
|
+
if (bestContourIsSynthetic) {
|
|
2213
|
+
latestCardRectRef.current = {
|
|
2214
|
+
x: clampedX,
|
|
2215
|
+
y: clampedY,
|
|
2216
|
+
w: clampedW,
|
|
2217
|
+
h: clampedH,
|
|
2218
|
+
};
|
|
2219
|
+
} else {
|
|
2220
|
+
// Contour coords are already in full-res ROI space, so only the
|
|
2221
|
+
// ROI origin offset is needed for capture cropping
|
|
2222
|
+
// (triggerManualCapture + bestFrame).
|
|
2223
|
+
const cardRect = cv.boundingRect(bestContour);
|
|
2224
|
+
latestCardRectRef.current = {
|
|
2225
|
+
x: clampedX + cardRect.x,
|
|
2226
|
+
y: clampedY + cardRect.y,
|
|
2227
|
+
w: cardRect.width,
|
|
2228
|
+
h: cardRect.height,
|
|
2229
|
+
};
|
|
2230
|
+
}
|
|
2231
|
+
|
|
2232
|
+
// Store points relative to the ROI for overlay drawing (only if dynamic border is on)
|
|
2233
|
+
if (settingsRef.current.useDynamicBorder) {
|
|
2234
|
+
const points: Array<{ x: number; y: number }> & {
|
|
2235
|
+
roiWidth?: number;
|
|
2236
|
+
roiHeight?: number;
|
|
2237
|
+
} = [];
|
|
2238
|
+
for (let i = 0; i < 4; i++) {
|
|
2239
|
+
points.push({
|
|
2240
|
+
x: bestContour.data32S[i * 2],
|
|
2241
|
+
y: bestContour.data32S[i * 2 + 1],
|
|
2242
|
+
});
|
|
2243
|
+
}
|
|
2244
|
+
points.roiWidth = clampedW;
|
|
2245
|
+
points.roiHeight = clampedH;
|
|
2246
|
+
updateDebugPath(points);
|
|
2247
|
+
}
|
|
2248
|
+
setComplianceState(COMPLIANCE_STATES.DETECTING);
|
|
2249
|
+
|
|
2250
|
+
// --- Phase 1: Classify document type from detected contour ---
|
|
2251
|
+
if (isDiscovery) {
|
|
2252
|
+
const bRect = cv.boundingRect(bestContour);
|
|
2253
|
+
|
|
2254
|
+
// --- Discovery timeout ---
|
|
2255
|
+
// Only increments when a valid rectangle is found (large enough to be a document).
|
|
2256
|
+
// This prevents empty scenes from triggering the timeout fallback.
|
|
2257
|
+
discoveryRef.current.frameCount++;
|
|
2258
|
+
if (discoveryRef.current.frameCount >= DISCOVERY_TIMEOUT_FRAMES) {
|
|
2259
|
+
const fallbackType = 'id-card';
|
|
2260
|
+
discoveryRef.current.docType = fallbackType;
|
|
2261
|
+
detectionPhaseRef.current = DETECTION_PHASE.CAPTURE;
|
|
2262
|
+
setDetectedDocType(fallbackType);
|
|
2263
|
+
setGuideAspectRatio(orientAspect(ASPECT_RATIOS[fallbackType]));
|
|
2264
|
+
console.info(
|
|
2265
|
+
`[Discovery] Timeout after ${discoveryRef.current.frameCount} frames — defaulting to: ${fallbackType}`,
|
|
2266
|
+
);
|
|
2267
|
+
setFeedback(autoCaptureFeedback.holdSteady);
|
|
2268
|
+
if (canvasRef.current) canvasRef.current._roiLogged = false;
|
|
2269
|
+
bestContour.delete();
|
|
2270
|
+
mergeDebugInfo({
|
|
2271
|
+
rejectReason: 'discovery: timeout → id-card',
|
|
2272
|
+
});
|
|
2273
|
+
return;
|
|
2274
|
+
}
|
|
2275
|
+
|
|
2276
|
+
const detectedRatio = bRect.width / bRect.height;
|
|
2277
|
+
// Normalize orientation so portrait-held docs still classify correctly.
|
|
2278
|
+
// Prefer the rotated-rect aspect computed during contour
|
|
2279
|
+
// acceptance: unlike boundingRect, it is stable when the card is
|
|
2280
|
+
// tilted in-plane. Fall back to boundingRect only if the winner
|
|
2281
|
+
// geometry is unavailable.
|
|
2282
|
+
const normalizedRatio =
|
|
2283
|
+
winnerGeomRef.current.aspect > 0
|
|
2284
|
+
? winnerGeomRef.current.aspect
|
|
2285
|
+
: Math.max(detectedRatio, 1 / detectedRatio);
|
|
2286
|
+
const vote = classifyDiscoveryAspect(normalizedRatio);
|
|
2287
|
+
|
|
2288
|
+
discoveryRef.current.votes.push(vote);
|
|
2289
|
+
|
|
2290
|
+
// Keep only the last N votes to prevent stale history
|
|
2291
|
+
if (
|
|
2292
|
+
discoveryRef.current.votes.length >
|
|
2293
|
+
DISCOVERY_CONSENSUS_THRESHOLD * 2
|
|
2294
|
+
) {
|
|
2295
|
+
discoveryRef.current.votes = discoveryRef.current.votes.slice(
|
|
2296
|
+
-DISCOVERY_CONSENSUS_THRESHOLD,
|
|
2297
|
+
);
|
|
2298
|
+
}
|
|
2299
|
+
|
|
2300
|
+
// Check for consensus: last N votes must all agree
|
|
2301
|
+
const recentVotes = discoveryRef.current.votes.slice(
|
|
2302
|
+
-DISCOVERY_CONSENSUS_THRESHOLD,
|
|
2303
|
+
);
|
|
2304
|
+
const allAgree =
|
|
2305
|
+
recentVotes.length >= DISCOVERY_CONSENSUS_THRESHOLD &&
|
|
2306
|
+
recentVotes.every((v) => v === recentVotes[0]);
|
|
2307
|
+
|
|
2308
|
+
setFeedback(autoCaptureFeedback.detectingDocumentType);
|
|
2309
|
+
mergeDebugInfo({
|
|
2310
|
+
blur: 0,
|
|
2311
|
+
glare: 0,
|
|
2312
|
+
edgeDensity: edgeDensity.toFixed(1),
|
|
2313
|
+
texture: Math.round(textureScore),
|
|
2314
|
+
quadrants: quadDensities.join('/'),
|
|
2315
|
+
detectedRatio: normalizedRatio.toFixed(3),
|
|
2316
|
+
votes: `${recentVotes.filter((v) => v === 'id-card').length}id / ${recentVotes.filter((v) => v === 'passport').length}pp`,
|
|
2317
|
+
rejectReason: allAgree
|
|
2318
|
+
? 'discovery: type locked'
|
|
2319
|
+
: 'discovery: detecting type',
|
|
2320
|
+
});
|
|
2321
|
+
|
|
2322
|
+
if (allAgree) {
|
|
2323
|
+
const classifiedType = recentVotes[0];
|
|
2324
|
+
discoveryRef.current.docType = classifiedType;
|
|
2325
|
+
detectionPhaseRef.current = DETECTION_PHASE.CAPTURE;
|
|
2326
|
+
setDetectedDocType(classifiedType);
|
|
2327
|
+
setGuideAspectRatio(
|
|
2328
|
+
orientAspect(ASPECT_RATIOS[classifiedType]),
|
|
2329
|
+
);
|
|
2330
|
+
console.info(
|
|
2331
|
+
`[Discovery] Document classified as: ${classifiedType} (ratio: ${normalizedRatio.toFixed(3)})`,
|
|
2332
|
+
);
|
|
2333
|
+
setFeedback(autoCaptureFeedback.holdSteady);
|
|
2334
|
+
// Force ROI recalculation on next frame by clearing the log flag
|
|
2335
|
+
if (canvasRef.current) canvasRef.current._roiLogged = false;
|
|
2336
|
+
}
|
|
2337
|
+
|
|
2338
|
+
bestContour.delete();
|
|
2339
|
+
// During discovery, skip quality gates — just loop
|
|
2340
|
+
return;
|
|
2341
|
+
}
|
|
2342
|
+
|
|
2343
|
+
bestContour.delete();
|
|
2344
|
+
} else {
|
|
2345
|
+
updateDebugPath(null);
|
|
2346
|
+
// No card inside the guide → re-enable off-guide scanning.
|
|
2347
|
+
inGuideDetectedRef.current = false;
|
|
2348
|
+
// No candidate this frame — drop the chroma history so a stale
|
|
2349
|
+
// average can't carry over to the next object entering the frame.
|
|
2350
|
+
chromaWindowRef.current = [];
|
|
2351
|
+
// During discovery, no contour found — keep waiting
|
|
2352
|
+
if (isDiscovery) {
|
|
2353
|
+
// A wall-hug-rejected card means the user is too close, not
|
|
2354
|
+
// absent. Without this hint a too-close card deadlocks
|
|
2355
|
+
// discovery: the timeout counter only advances on valid
|
|
2356
|
+
// contours, so no fallback classification ever fires.
|
|
2357
|
+
setFeedback(
|
|
2358
|
+
skipGridCheck &&
|
|
2359
|
+
(wallHugRejectedCardThisFrame || combinedBboxOverflow)
|
|
2360
|
+
? autoCaptureFeedback.moveDocumentFurtherAway
|
|
2361
|
+
: autoCaptureFeedback.positionDocument,
|
|
2362
|
+
);
|
|
2363
|
+
// Tolerate a few consecutive misses before resetting votes.
|
|
2364
|
+
// Mobile cameras drop detections for 1-2 frames due to motion blur,
|
|
2365
|
+
// auto-exposure changes, etc. Hard-resetting on every miss prevents
|
|
2366
|
+
// consensus from ever being reached.
|
|
2367
|
+
discoveryRef.current.consecutiveMisses++;
|
|
2368
|
+
if (
|
|
2369
|
+
discoveryRef.current.consecutiveMisses >=
|
|
2370
|
+
DISCOVERY_MISS_TOLERANCE
|
|
2371
|
+
) {
|
|
2372
|
+
discoveryRef.current.votes = [];
|
|
2373
|
+
}
|
|
2374
|
+
mergeDebugInfo({
|
|
2375
|
+
edgeDensity: edgeDensity.toFixed(1),
|
|
2376
|
+
texture: Math.round(textureScore),
|
|
2377
|
+
quadrants: quadDensities.join('/'),
|
|
2378
|
+
misses: discoveryRef.current.consecutiveMisses,
|
|
2379
|
+
votes: `${discoveryRef.current.votes.filter((v) => v === 'id-card').length}id / ${discoveryRef.current.votes.filter((v) => v === 'passport').length}pp`,
|
|
2380
|
+
rejectReason: 'discovery: no card contour',
|
|
2381
|
+
});
|
|
2382
|
+
return;
|
|
2383
|
+
}
|
|
2384
|
+
// CAPTURE phase, desktop: the card is real but too close —
|
|
2385
|
+
// either a card-shaped quad was rejected solely for hugging the
|
|
2386
|
+
// ROI walls, or the card overflows the box entirely (no quad can
|
|
2387
|
+
// form; inner content is clipped at 2+ ROI walls with full grid
|
|
2388
|
+
// coverage). Surface distance guidance immediately instead of
|
|
2389
|
+
// letting the miss counter drift to "Align document in frame".
|
|
2390
|
+
// Mirrors the maxFillPercent branch above, which cannot run here
|
|
2391
|
+
// because distance gating requires a bestContour.
|
|
2392
|
+
if (
|
|
2393
|
+
skipGridCheck &&
|
|
2394
|
+
(wallHugRejectedCardThisFrame || combinedBboxOverflow)
|
|
2395
|
+
) {
|
|
2396
|
+
captureMissCounterRef.current = 0;
|
|
2397
|
+
const absorbed = gateDecayOn && softFailStability();
|
|
2398
|
+
if (!absorbed) {
|
|
2399
|
+
setFeedback(autoCaptureFeedback.moveDocumentFurtherAway);
|
|
2400
|
+
setComplianceState(COMPLIANCE_STATES.DETECTING);
|
|
2401
|
+
}
|
|
2402
|
+
mergeDebugInfo({
|
|
2403
|
+
edgeDensity: edgeDensity.toFixed(1),
|
|
2404
|
+
texture: Math.round(textureScore),
|
|
2405
|
+
quadrants: quadDensities.join('/'),
|
|
2406
|
+
rejectReason: `fill too large (card overflows ROI)${absorbed ? ' [held]' : ''}`,
|
|
2407
|
+
});
|
|
2408
|
+
return;
|
|
2409
|
+
}
|
|
2410
|
+
// CAPTURE phase: no validated card-shaped contour inside the
|
|
2411
|
+
// ROI. Tolerate a few consecutive misses before changing the
|
|
2412
|
+
// prompt — book-style documents (passport, greenbook) frequently
|
|
2413
|
+
// drop the contour for 1–3 frames as the spine flexes or fingers
|
|
2414
|
+
// cross the edge, and flipping between "Move closer" and "Align
|
|
2415
|
+
// document" on every miss is jarring.
|
|
2416
|
+
captureMissCounterRef.current += 1;
|
|
2417
|
+
const CAPTURE_MISS_TOLERANCE = 20;
|
|
2418
|
+
if (captureMissCounterRef.current < CAPTURE_MISS_TOLERANCE) {
|
|
2419
|
+
mergeDebugInfo({
|
|
2420
|
+
edgeDensity: edgeDensity.toFixed(1),
|
|
2421
|
+
texture: Math.round(textureScore),
|
|
2422
|
+
quadrants: quadDensities.join('/'),
|
|
2423
|
+
missStreak: captureMissCounterRef.current,
|
|
2424
|
+
rejectReason: `no card contour (miss ${captureMissCounterRef.current}/${CAPTURE_MISS_TOLERANCE})`,
|
|
2425
|
+
});
|
|
2426
|
+
return;
|
|
2427
|
+
}
|
|
2428
|
+
setFeedback(autoCaptureFeedback.alignDocument);
|
|
2429
|
+
setComplianceState(COMPLIANCE_STATES.IDLE);
|
|
2430
|
+
stabilityRef.current.count = 0;
|
|
2431
|
+
bestFrameRef.current = { image: null, preview: null, score: 0 };
|
|
2432
|
+
docFillEmaRef.current = null;
|
|
2433
|
+
mergeDebugInfo({
|
|
2434
|
+
edgeDensity: edgeDensity.toFixed(1),
|
|
2435
|
+
texture: Math.round(textureScore),
|
|
2436
|
+
quadrants: quadDensities.join('/'),
|
|
2437
|
+
missStreak: captureMissCounterRef.current,
|
|
2438
|
+
rejectReason: 'no card contour (no 4-corner quad formed)',
|
|
2439
|
+
});
|
|
2440
|
+
return;
|
|
2441
|
+
}
|
|
2442
|
+
}
|
|
2443
|
+
|
|
2444
|
+
// --- Gate 1: Sharpness / Blur ---
|
|
2445
|
+
laplacian = new cv.Mat();
|
|
2446
|
+
cv.Laplacian(gray, laplacian, cv.CV_64F, 1, 1, 0, cv.BORDER_DEFAULT);
|
|
2447
|
+
mean = new cv.Mat();
|
|
2448
|
+
stdDev = new cv.Mat();
|
|
2449
|
+
cv.meanStdDev(laplacian, mean, stdDev);
|
|
2450
|
+
const variance = stdDev.doubleAt(0, 0) ** 2;
|
|
2451
|
+
|
|
2452
|
+
if (variance < settingsRef.current.blurThreshold) {
|
|
2453
|
+
setFeedback(autoCaptureFeedback.tooBlurry);
|
|
2454
|
+
setComplianceState(COMPLIANCE_STATES.DETECTING);
|
|
2455
|
+
// Tolerate a transient blurry frame while a best frame is already
|
|
2456
|
+
// held — mobile cameras drop 1–2 frames to motion blur / AWB. Soften
|
|
2457
|
+
// the stability count instead of discarding the captured candidate.
|
|
2458
|
+
if (
|
|
2459
|
+
bestFrameRef.current.image &&
|
|
2460
|
+
bestFrameMissRef.current < BEST_FRAME_MISS_TOLERANCE
|
|
2461
|
+
) {
|
|
2462
|
+
bestFrameMissRef.current += 1;
|
|
2463
|
+
stabilityRef.current.count = Math.max(
|
|
2464
|
+
0,
|
|
2465
|
+
stabilityRef.current.count - 1,
|
|
2466
|
+
);
|
|
2467
|
+
} else {
|
|
2468
|
+
bestFrameMissRef.current = 0;
|
|
2469
|
+
stabilityRef.current.count = 0;
|
|
2470
|
+
bestFrameRef.current = { image: null, preview: null, score: 0 };
|
|
2471
|
+
}
|
|
2472
|
+
mergeDebugInfo({
|
|
2473
|
+
blur: Math.round(variance),
|
|
2474
|
+
glare: 0,
|
|
2475
|
+
rejectReason: `Gate1: too blurry (${Math.round(variance)} < ${settingsRef.current.blurThreshold})`,
|
|
2476
|
+
});
|
|
2477
|
+
return;
|
|
2478
|
+
}
|
|
2479
|
+
|
|
2480
|
+
// --- Gate 2: Glare ---
|
|
2481
|
+
glareMask = new cv.Mat();
|
|
2482
|
+
cv.threshold(gray, glareMask, 240, 255, cv.THRESH_BINARY);
|
|
2483
|
+
const glarePixels = cv.countNonZero(glareMask);
|
|
2484
|
+
const totalPixels = gray.rows * gray.cols;
|
|
2485
|
+
const glarePercent = (glarePixels / totalPixels) * 100;
|
|
2486
|
+
|
|
2487
|
+
mergeDebugInfo({
|
|
2488
|
+
blur: Math.round(variance),
|
|
2489
|
+
glare: glarePercent.toFixed(1),
|
|
2490
|
+
edgeDensity: edgeDensity.toFixed(1),
|
|
2491
|
+
texture: Math.round(textureScore),
|
|
2492
|
+
quadrants: quadDensities.join('/'),
|
|
2493
|
+
docFill: Math.round(latestDocFillRef.current),
|
|
2494
|
+
});
|
|
2495
|
+
|
|
2496
|
+
if (glarePercent > settingsRef.current.glareThreshold) {
|
|
2497
|
+
setFeedback(autoCaptureFeedback.glareDetectedAdjustLighting);
|
|
2498
|
+
setComplianceState(COMPLIANCE_STATES.DETECTING);
|
|
2499
|
+
// Same transient-miss tolerance as the blur gate above.
|
|
2500
|
+
if (
|
|
2501
|
+
bestFrameRef.current.image &&
|
|
2502
|
+
bestFrameMissRef.current < BEST_FRAME_MISS_TOLERANCE
|
|
2503
|
+
) {
|
|
2504
|
+
bestFrameMissRef.current += 1;
|
|
2505
|
+
stabilityRef.current.count = Math.max(
|
|
2506
|
+
0,
|
|
2507
|
+
stabilityRef.current.count - 1,
|
|
2508
|
+
);
|
|
2509
|
+
} else {
|
|
2510
|
+
bestFrameMissRef.current = 0;
|
|
2511
|
+
stabilityRef.current.count = 0;
|
|
2512
|
+
bestFrameRef.current = { image: null, preview: null, score: 0 };
|
|
2513
|
+
}
|
|
2514
|
+
mergeDebugInfo({
|
|
2515
|
+
rejectReason: `Gate2: glare (${glarePercent.toFixed(1)}% > ${settingsRef.current.glareThreshold}%)`,
|
|
2516
|
+
});
|
|
2517
|
+
return;
|
|
2518
|
+
}
|
|
2519
|
+
|
|
2520
|
+
// --- Gate 3: Stability (track best frame) ---
|
|
2521
|
+
// Clean frame — clear the transient-miss streak.
|
|
2522
|
+
bestFrameMissRef.current = 0;
|
|
2523
|
+
stabilityRef.current.count++;
|
|
2524
|
+
const progress = Math.min(
|
|
2525
|
+
100,
|
|
2526
|
+
(stabilityRef.current.count /
|
|
2527
|
+
settingsRef.current.stabilityThreshold) *
|
|
2528
|
+
100,
|
|
2529
|
+
);
|
|
2530
|
+
|
|
2531
|
+
// --- Composite per-frame quality score ---
|
|
2532
|
+
// Blend the metrics already computed this frame into one 0–1
|
|
2533
|
+
// readability score and keep the highest-scoring frame of the window
|
|
2534
|
+
// (Stripe/Persona-style), rather than the merely-sharpest one. Sub-
|
|
2535
|
+
// scores: sharpness vs blur threshold; inverse glare; framing (distance
|
|
2536
|
+
// from the center of the accepted fill band); aspect closeness to the
|
|
2537
|
+
// doc-type ratio; contour confidence (real quad fill, synthetic capped);
|
|
2538
|
+
// and colour content on mobile.
|
|
2539
|
+
const sharpScore = clamp01(
|
|
2540
|
+
variance / (2 * settingsRef.current.blurThreshold),
|
|
2541
|
+
);
|
|
2542
|
+
const glareLimit = settingsRef.current.glareThreshold || 1;
|
|
2543
|
+
const glareScore = clamp01(1 - glarePercent / glareLimit);
|
|
2544
|
+
// Recompute the accepted fill band (same basis as the distance gate's
|
|
2545
|
+
// minFillPercent/maxFillPercent, which are out of scope here).
|
|
2546
|
+
const qDocType = discoveryRef.current.docType;
|
|
2547
|
+
const qIsBookDoc = qDocType === 'passport' || qDocType === 'greenbook';
|
|
2548
|
+
const qMinFill = qIsBookDoc
|
|
2549
|
+
? 20
|
|
2550
|
+
: (settingsRef.current.minFillPercent ?? MIN_FILL_PERCENT);
|
|
2551
|
+
const qMaxFill = settingsRef.current.maxFillPercent ?? MAX_FILL_PERCENT;
|
|
2552
|
+
const fillCenter = (qMinFill + qMaxFill) / 2;
|
|
2553
|
+
const fillHalf = Math.max(1, (qMaxFill - qMinFill) / 2);
|
|
2554
|
+
const fillScore = clamp01(
|
|
2555
|
+
1 - Math.abs(latestDocFillRef.current - fillCenter) / fillHalf,
|
|
2556
|
+
);
|
|
2557
|
+
const expectedAspect = isAspectKey(qDocType)
|
|
2558
|
+
? ASPECT_RATIOS[qDocType]
|
|
2559
|
+
: null;
|
|
2560
|
+
const qAspectTol = qIsBookDoc
|
|
2561
|
+
? (settingsRef.current.bookDocAspectTolerance ?? 0.1)
|
|
2562
|
+
: (settingsRef.current.idAspectTolerance ?? 0.12);
|
|
2563
|
+
const aspectScore =
|
|
2564
|
+
expectedAspect && winnerGeomRef.current.aspect > 0
|
|
2565
|
+
? clamp01(
|
|
2566
|
+
1 -
|
|
2567
|
+
Math.abs(winnerGeomRef.current.aspect - expectedAspect) /
|
|
2568
|
+
(expectedAspect * qAspectTol),
|
|
2569
|
+
)
|
|
2570
|
+
: null;
|
|
2571
|
+
const contourScore = winnerGeomRef.current.synthetic
|
|
2572
|
+
? SYNTHETIC_CONTOUR_CONFIDENCE
|
|
2573
|
+
: clamp01((winnerGeomRef.current.fillRatio - 0.5) / 0.5);
|
|
2574
|
+
let chromaScore: number | null = null;
|
|
2575
|
+
const chromaWin = chromaWindowRef.current;
|
|
2576
|
+
if (
|
|
2577
|
+
settingsRef.current.chromaContentGate === true &&
|
|
2578
|
+
chromaWin.length
|
|
2579
|
+
) {
|
|
2580
|
+
const chromaAvg =
|
|
2581
|
+
chromaWin.reduce((sum, v) => sum + v, 0) / chromaWin.length;
|
|
2582
|
+
const minChroma = settingsRef.current.minChromaContent ?? 13;
|
|
2583
|
+
chromaScore = clamp01(chromaAvg / (2 * minChroma));
|
|
2584
|
+
}
|
|
2585
|
+
const composite = frameQualityScore({
|
|
2586
|
+
sharpness: sharpScore,
|
|
2587
|
+
glare: glareScore,
|
|
2588
|
+
fill: fillScore,
|
|
2589
|
+
aspect: aspectScore,
|
|
2590
|
+
contour: contourScore,
|
|
2591
|
+
chroma: chromaScore,
|
|
2592
|
+
});
|
|
2593
|
+
mergeDebugInfo({ quality: composite.toFixed(2) });
|
|
2594
|
+
|
|
2595
|
+
// Keep the most readable frame (highest composite) of the window.
|
|
2596
|
+
if (composite > bestFrameRef.current.score) {
|
|
2597
|
+
bestFrameRef.current.score = composite;
|
|
2598
|
+
|
|
2599
|
+
// Submitted image: full frame, or guide-rect crop when cropToCard
|
|
2600
|
+
// is enabled (original behavior). Padded by `cropPadding` (default 10%).
|
|
2601
|
+
// Crop in unrotated native-pixel space; any UI rotation is applied
|
|
2602
|
+
// to the cropped output below.
|
|
2603
|
+
const fullDataUrl = canvas.toDataURL('image/jpeg', 0.95);
|
|
2604
|
+
let submittedDataUrl = fullDataUrl;
|
|
2605
|
+
if (settingsRef.current.cropToCard) {
|
|
2606
|
+
const submitPad =
|
|
2607
|
+
(settingsRef.current.cropPadding == null
|
|
2608
|
+
? 10
|
|
2609
|
+
: settingsRef.current.cropPadding) / 100;
|
|
2610
|
+
const sPadX = clampedW * submitPad;
|
|
2611
|
+
const sPadY = clampedH * submitPad;
|
|
2612
|
+
const scx = Math.max(0, Math.floor(clampedX - sPadX));
|
|
2613
|
+
const scy = Math.max(0, Math.floor(clampedY - sPadY));
|
|
2614
|
+
const scw = Math.min(
|
|
2615
|
+
canvas.width - scx,
|
|
2616
|
+
Math.ceil(clampedW + sPadX * 2),
|
|
2617
|
+
);
|
|
2618
|
+
const sch = Math.min(
|
|
2619
|
+
canvas.height - scy,
|
|
2620
|
+
Math.ceil(clampedH + sPadY * 2),
|
|
2621
|
+
);
|
|
2622
|
+
const submitCanvas = document.createElement('canvas');
|
|
2623
|
+
submitCanvas.width = scw;
|
|
2624
|
+
submitCanvas.height = sch;
|
|
2625
|
+
submitCanvas
|
|
2626
|
+
.getContext('2d')!
|
|
2627
|
+
.drawImage(canvas, scx, scy, scw, sch, 0, 0, scw, sch);
|
|
2628
|
+
submittedDataUrl = submitCanvas.toDataURL('image/jpeg', 0.95);
|
|
2629
|
+
}
|
|
2630
|
+
|
|
2631
|
+
// Preview: tighter crop using the detected card's contour when
|
|
2632
|
+
// available. Padded by `previewCropPadding` (default 2%). Crop runs in
|
|
2633
|
+
// unrotated native-pixel space; rotation is applied to the result below.
|
|
2634
|
+
let croppedDataUrl = null;
|
|
2635
|
+
if (settingsRef.current.cropToCard) {
|
|
2636
|
+
const useContour =
|
|
2637
|
+
settingsRef.current.cropToContour !== false &&
|
|
2638
|
+
latestCardRectRef.current;
|
|
2639
|
+
const sourceX = useContour
|
|
2640
|
+
? latestCardRectRef.current!.x
|
|
2641
|
+
: clampedX;
|
|
2642
|
+
const sourceY = useContour
|
|
2643
|
+
? latestCardRectRef.current!.y
|
|
2644
|
+
: clampedY;
|
|
2645
|
+
const sourceW = useContour
|
|
2646
|
+
? latestCardRectRef.current!.w
|
|
2647
|
+
: clampedW;
|
|
2648
|
+
const sourceH = useContour
|
|
2649
|
+
? latestCardRectRef.current!.h
|
|
2650
|
+
: clampedH;
|
|
2651
|
+
const padPct = settingsRef.current.previewCropPadding;
|
|
2652
|
+
const pad = (padPct == null ? 2 : padPct) / 100;
|
|
2653
|
+
const padX = sourceW * pad;
|
|
2654
|
+
const padY = sourceH * pad;
|
|
2655
|
+
const cx = Math.max(0, Math.floor(sourceX - padX));
|
|
2656
|
+
const cy = Math.max(0, Math.floor(sourceY - padY));
|
|
2657
|
+
const cw = Math.min(
|
|
2658
|
+
canvas.width - cx,
|
|
2659
|
+
Math.ceil(sourceW + padX * 2),
|
|
2660
|
+
);
|
|
2661
|
+
const ch = Math.min(
|
|
2662
|
+
canvas.height - cy,
|
|
2663
|
+
Math.ceil(sourceH + padY * 2),
|
|
2664
|
+
);
|
|
2665
|
+
|
|
2666
|
+
const cropCanvas = document.createElement('canvas');
|
|
2667
|
+
cropCanvas.width = cw;
|
|
2668
|
+
cropCanvas.height = ch;
|
|
2669
|
+
cropCanvas
|
|
2670
|
+
.getContext('2d')!
|
|
2671
|
+
.drawImage(canvas, cx, cy, cw, ch, 0, 0, cw, ch);
|
|
2672
|
+
croppedDataUrl = cropCanvas.toDataURL('image/jpeg', 0.95);
|
|
2673
|
+
}
|
|
2674
|
+
|
|
2675
|
+
bestFrameRef.current.image = submittedDataUrl;
|
|
2676
|
+
bestFrameRef.current.preview = croppedDataUrl;
|
|
2677
|
+
}
|
|
2678
|
+
|
|
2679
|
+
setFeedback(autoCaptureFeedback.holdStill);
|
|
2680
|
+
setCaptureProgress(Math.round(progress));
|
|
2681
|
+
setComplianceState(COMPLIANCE_STATES.STABLE);
|
|
2682
|
+
mergeDebugInfo({
|
|
2683
|
+
rejectReason: `Gate3: stabilizing (${stabilityRef.current.count}/${settingsRef.current.stabilityThreshold})`,
|
|
2684
|
+
});
|
|
2685
|
+
|
|
2686
|
+
if (
|
|
2687
|
+
stabilityRef.current.count >= settingsRef.current.stabilityThreshold
|
|
2688
|
+
) {
|
|
2689
|
+
if (captureModeRef.current !== 'manualCaptureOnly') {
|
|
2690
|
+
if (IS_DEBUG_MODE) {
|
|
2691
|
+
console.info('--- AUTO CAPTURE TRIGGERED ---');
|
|
2692
|
+
console.info(
|
|
2693
|
+
'Document Type:',
|
|
2694
|
+
discoveryRef.current.docType || 'unknown',
|
|
2695
|
+
);
|
|
2696
|
+
console.info(
|
|
2697
|
+
'Edge Density:',
|
|
2698
|
+
`${edgeDensity.toFixed(1)}%`,
|
|
2699
|
+
'| Quadrants:',
|
|
2700
|
+
quadDensities.join('/'),
|
|
2701
|
+
);
|
|
2702
|
+
console.info(
|
|
2703
|
+
'Blur Variance:',
|
|
2704
|
+
Math.round(variance),
|
|
2705
|
+
'(threshold:',
|
|
2706
|
+
`${settingsRef.current.blurThreshold})`,
|
|
2707
|
+
);
|
|
2708
|
+
console.info(
|
|
2709
|
+
'Glare:',
|
|
2710
|
+
`${glarePercent.toFixed(1)}%`,
|
|
2711
|
+
'(threshold:',
|
|
2712
|
+
`${settingsRef.current.glareThreshold}%)`,
|
|
2713
|
+
);
|
|
2714
|
+
console.info(
|
|
2715
|
+
'Stability Frames:',
|
|
2716
|
+
stabilityRef.current.count,
|
|
2717
|
+
'/',
|
|
2718
|
+
settingsRef.current.stabilityThreshold,
|
|
2719
|
+
);
|
|
2720
|
+
console.info(
|
|
2721
|
+
'Best Frame Quality:',
|
|
2722
|
+
bestFrameRef.current.score.toFixed(2),
|
|
2723
|
+
);
|
|
2724
|
+
}
|
|
2725
|
+
setFeedback(autoCaptureFeedback.capturingDocument);
|
|
2726
|
+
setComplianceState(COMPLIANCE_STATES.CAPTURING);
|
|
2727
|
+
mergeDebugInfo({ rejectReason: '✓ capturing' });
|
|
2728
|
+
isCapturingRef.current = true;
|
|
2729
|
+
setCaptureOrigin('camera_auto_capture');
|
|
2730
|
+
// Use the sharpest frame captured during stability
|
|
2731
|
+
const bestFrameUrl = bestFrameRef.current.image;
|
|
2732
|
+
const bestPreviewUrl = bestFrameRef.current.preview;
|
|
2733
|
+
// Rotate if UI was rotated during capture
|
|
2734
|
+
if (shouldRotateUi && bestFrameUrl) {
|
|
2735
|
+
const rotateDataUrl = (srcUrl: string) =>
|
|
2736
|
+
new Promise<string>((resolve, reject) => {
|
|
2737
|
+
const img = new Image();
|
|
2738
|
+
img.onload = () => {
|
|
2739
|
+
const rotated = document.createElement('canvas');
|
|
2740
|
+
rotated.width = img.height;
|
|
2741
|
+
rotated.height = img.width;
|
|
2742
|
+
const rctx = rotated.getContext('2d');
|
|
2743
|
+
if (!rctx) {
|
|
2744
|
+
reject(new Error('2d context unavailable'));
|
|
2745
|
+
return;
|
|
2746
|
+
}
|
|
2747
|
+
rctx.translate(rotated.width / 2, rotated.height / 2);
|
|
2748
|
+
rctx.rotate(-Math.PI / 2);
|
|
2749
|
+
rctx.drawImage(img, -img.width / 2, -img.height / 2);
|
|
2750
|
+
resolve(rotated.toDataURL('image/jpeg', 0.95));
|
|
2751
|
+
};
|
|
2752
|
+
img.onerror = reject;
|
|
2753
|
+
img.src = srcUrl;
|
|
2754
|
+
});
|
|
2755
|
+
|
|
2756
|
+
Promise.all([
|
|
2757
|
+
rotateDataUrl(bestFrameUrl),
|
|
2758
|
+
bestPreviewUrl
|
|
2759
|
+
? rotateDataUrl(bestPreviewUrl)
|
|
2760
|
+
: Promise.resolve<string | null>(null),
|
|
2761
|
+
])
|
|
2762
|
+
.then(([rotatedFull, rotatedPreview]) => {
|
|
2763
|
+
setCapturedImage(rotatedFull);
|
|
2764
|
+
setPreviewImage(rotatedPreview);
|
|
2765
|
+
setComplianceState(COMPLIANCE_STATES.SUCCESS);
|
|
2766
|
+
})
|
|
2767
|
+
.catch(() => {
|
|
2768
|
+
isCapturingRef.current = false;
|
|
2769
|
+
stabilityRef.current.count = 0;
|
|
2770
|
+
bestFrameRef.current = {
|
|
2771
|
+
image: null,
|
|
2772
|
+
preview: null,
|
|
2773
|
+
score: 0,
|
|
2774
|
+
};
|
|
2775
|
+
setComplianceState(COMPLIANCE_STATES.IDLE);
|
|
2776
|
+
setFeedback(autoCaptureFeedback.captureFailed);
|
|
2777
|
+
animationFrameId = requestAnimationFrame(processFrame);
|
|
2778
|
+
});
|
|
2779
|
+
} else {
|
|
2780
|
+
setCapturedImage(bestFrameUrl);
|
|
2781
|
+
setPreviewImage(bestPreviewUrl);
|
|
2782
|
+
setComplianceState(COMPLIANCE_STATES.SUCCESS);
|
|
2783
|
+
}
|
|
2784
|
+
}
|
|
2785
|
+
// manualCaptureOnly: quality threshold met — leave state as STABLE so
|
|
2786
|
+
// the green indicator persists until the user taps the capture button.
|
|
2787
|
+
}
|
|
2788
|
+
};
|
|
2789
|
+
|
|
2790
|
+
try {
|
|
2791
|
+
runDetection();
|
|
2792
|
+
// Clean frame — clear the error streak so a later one-off blip doesn't
|
|
2793
|
+
// trip the circuit breaker below.
|
|
2794
|
+
cvErrorStreakRef.current = 0;
|
|
2795
|
+
} catch (err: any) {
|
|
2796
|
+
console.error('CV Error:', err);
|
|
2797
|
+
const recoveryAction = nextCvErrorRecoveryAction({
|
|
2798
|
+
errorStreak: cvErrorStreakRef.current,
|
|
2799
|
+
chromaUnavailable: chromaUnavailableRef.current,
|
|
2800
|
+
});
|
|
2801
|
+
cvErrorStreakRef.current = recoveryAction.nextErrorStreak;
|
|
2802
|
+
if (recoveryAction.shouldDisableChroma) {
|
|
2803
|
+
chromaUnavailableRef.current = true;
|
|
2804
|
+
}
|
|
2805
|
+
if (recoveryAction.shouldActivateFallback) {
|
|
2806
|
+
autoDetectionSuspendedRef.current =
|
|
2807
|
+
recoveryAction.shouldSuspendDetection;
|
|
2808
|
+
setManualFallbackActive(true);
|
|
2809
|
+
setCvLoadFailed(true);
|
|
2810
|
+
}
|
|
2811
|
+
let nextFeedback = autoCaptureFeedback.processingFailed;
|
|
2812
|
+
if (recoveryAction.shouldActivateFallback) {
|
|
2813
|
+
nextFeedback =
|
|
2814
|
+
captureModeRef.current === 'autoCaptureOnly'
|
|
2815
|
+
? autoCaptureFeedback.autoDetectionUnavailableRetry
|
|
2816
|
+
: autoCaptureFeedback.autoDetectionUnavailableManual;
|
|
2817
|
+
} else if (recoveryAction.shouldClearProcessingError) {
|
|
2818
|
+
nextFeedback = autoCaptureFeedback.positionDocument;
|
|
2819
|
+
}
|
|
2820
|
+
setFeedback(nextFeedback);
|
|
2821
|
+
setComplianceState(
|
|
2822
|
+
recoveryAction.shouldClearProcessingError
|
|
2823
|
+
? COMPLIANCE_STATES.DETECTING
|
|
2824
|
+
: COMPLIANCE_STATES.IDLE,
|
|
2825
|
+
);
|
|
2826
|
+
stabilityRef.current.count = 0;
|
|
2827
|
+
bestFrameRef.current = { image: null, preview: null, score: 0 };
|
|
2828
|
+
// Never let a per-frame CV error freeze the loop: clearing the
|
|
2829
|
+
// capturing flag guarantees the rescheduler below runs, so detection
|
|
2830
|
+
// self-recovers on the next frame instead of getting stuck on
|
|
2831
|
+
// "Processing failed" until a manual page refresh.
|
|
2832
|
+
isCapturingRef.current = false;
|
|
2833
|
+
let cvRecovery = 'retrying';
|
|
2834
|
+
if (recoveryAction.shouldActivateFallback) {
|
|
2835
|
+
cvRecovery = 'suspended';
|
|
2836
|
+
} else if (recoveryAction.shouldDisableChroma) {
|
|
2837
|
+
cvRecovery = 'disabled chroma';
|
|
2838
|
+
}
|
|
2839
|
+
mergeDebugInfo({
|
|
2840
|
+
cvError: formatDebugError(err),
|
|
2841
|
+
cvErrors: recoveryAction.nextErrorStreak,
|
|
2842
|
+
cvRecovery,
|
|
2843
|
+
rejectReason: `CV error (${cvRecovery})`,
|
|
2844
|
+
});
|
|
2845
|
+
} finally {
|
|
2846
|
+
// Clean Memory
|
|
2847
|
+
safeDelete(
|
|
2848
|
+
fullFrame,
|
|
2849
|
+
src,
|
|
2850
|
+
contourFull,
|
|
2851
|
+
contourGray,
|
|
2852
|
+
presenceBlurred,
|
|
2853
|
+
presenceEdges,
|
|
2854
|
+
gray,
|
|
2855
|
+
blurred,
|
|
2856
|
+
edges,
|
|
2857
|
+
contours,
|
|
2858
|
+
hierarchy,
|
|
2859
|
+
laplacian,
|
|
2860
|
+
mean,
|
|
2861
|
+
stdDev,
|
|
2862
|
+
glareMask,
|
|
2863
|
+
contourRgb,
|
|
2864
|
+
contourLab,
|
|
2865
|
+
labPlanes,
|
|
2866
|
+
aPlane,
|
|
2867
|
+
bPlane,
|
|
2868
|
+
aBlur,
|
|
2869
|
+
bBlur,
|
|
2870
|
+
aEdges,
|
|
2871
|
+
bEdges,
|
|
2872
|
+
chromaMag,
|
|
2873
|
+
);
|
|
2874
|
+
|
|
2875
|
+
// Loop
|
|
2876
|
+
if (!isCapturingRef.current && !autoDetectionSuspendedRef.current) {
|
|
2877
|
+
animationFrameId = requestAnimationFrame(processFrame);
|
|
2878
|
+
}
|
|
2879
|
+
}
|
|
2880
|
+
};
|
|
2881
|
+
|
|
2882
|
+
const timeoutId = setTimeout(processFrame, 100); // 1s warm up
|
|
2883
|
+
|
|
2884
|
+
return () => {
|
|
2885
|
+
clearTimeout(timeoutId);
|
|
2886
|
+
cancelAnimationFrame(animationFrameId);
|
|
2887
|
+
};
|
|
2888
|
+
}, [videoRef, variant, shouldRotateUi]);
|
|
2889
|
+
|
|
2890
|
+
// Helper to rotate a canvas 90° counter-clockwise with dimension swap.
|
|
2891
|
+
// Must match the auto-capture rotation direction (-π/2) so manual and auto
|
|
2892
|
+
// captures produce identically-oriented previews.
|
|
2893
|
+
const rotateCanvas90CCW = (canvas: HTMLCanvasElement) => {
|
|
2894
|
+
const rotated = document.createElement('canvas');
|
|
2895
|
+
rotated.width = canvas.height;
|
|
2896
|
+
rotated.height = canvas.width;
|
|
2897
|
+
const ctx = rotated.getContext('2d');
|
|
2898
|
+
if (!ctx) throw new Error('2d context unavailable');
|
|
2899
|
+
ctx.translate(0, rotated.height);
|
|
2900
|
+
ctx.rotate(-Math.PI / 2);
|
|
2901
|
+
ctx.drawImage(canvas, 0, 0);
|
|
2902
|
+
return rotated;
|
|
2903
|
+
};
|
|
2904
|
+
|
|
2905
|
+
const triggerManualCapture = () => {
|
|
2906
|
+
if (isCapturingRef.current) return;
|
|
2907
|
+
const canvas = canvasRef.current;
|
|
2908
|
+
const coords = latestCropCoordsRef.current;
|
|
2909
|
+
if (!canvas || !coords) return;
|
|
2910
|
+
const { clampedX, clampedY, clampedW, clampedH } = coords;
|
|
2911
|
+
const s = settingsRef.current;
|
|
2912
|
+
|
|
2913
|
+
try {
|
|
2914
|
+
// Submitted image: full frame, or guide-rect crop when cropToCard is on
|
|
2915
|
+
// (original behavior, padded by `cropPadding`).
|
|
2916
|
+
let submitCaptureCanvas: HTMLCanvasElement = canvas;
|
|
2917
|
+
let previewCaptureCanvas: HTMLCanvasElement | null = null;
|
|
2918
|
+
|
|
2919
|
+
// Crop in unrotated native-pixel space. If the UI is rotated, the
|
|
2920
|
+
// cropped canvas is rotated CCW below to match the on-screen orientation.
|
|
2921
|
+
if (s.cropToCard) {
|
|
2922
|
+
// Submitted: guide-rect crop with cropPadding.
|
|
2923
|
+
const submitPad = (s.cropPadding == null ? 10 : s.cropPadding) / 100;
|
|
2924
|
+
const sPadX = clampedW * submitPad;
|
|
2925
|
+
const sPadY = clampedH * submitPad;
|
|
2926
|
+
const scx = Math.max(0, Math.floor(clampedX - sPadX));
|
|
2927
|
+
const scy = Math.max(0, Math.floor(clampedY - sPadY));
|
|
2928
|
+
const scw = Math.min(
|
|
2929
|
+
canvas.width - scx,
|
|
2930
|
+
Math.ceil(clampedW + sPadX * 2),
|
|
2931
|
+
);
|
|
2932
|
+
const sch = Math.min(
|
|
2933
|
+
canvas.height - scy,
|
|
2934
|
+
Math.ceil(clampedH + sPadY * 2),
|
|
2935
|
+
);
|
|
2936
|
+
const submitCanvas = document.createElement('canvas');
|
|
2937
|
+
submitCanvas.width = scw;
|
|
2938
|
+
submitCanvas.height = sch;
|
|
2939
|
+
const submitCtx = submitCanvas.getContext('2d');
|
|
2940
|
+
if (!submitCtx) throw new Error('2d context unavailable');
|
|
2941
|
+
submitCtx.drawImage(canvas, scx, scy, scw, sch, 0, 0, scw, sch);
|
|
2942
|
+
submitCaptureCanvas = submitCanvas;
|
|
2943
|
+
|
|
2944
|
+
// Preview: tighter contour crop with previewCropPadding.
|
|
2945
|
+
const useContour =
|
|
2946
|
+
s.cropToContour !== false && latestCardRectRef.current;
|
|
2947
|
+
const sourceX = useContour ? latestCardRectRef.current!.x : clampedX;
|
|
2948
|
+
const sourceY = useContour ? latestCardRectRef.current!.y : clampedY;
|
|
2949
|
+
const sourceW = useContour ? latestCardRectRef.current!.w : clampedW;
|
|
2950
|
+
const sourceH = useContour ? latestCardRectRef.current!.h : clampedH;
|
|
2951
|
+
const padPct = s.previewCropPadding;
|
|
2952
|
+
const pad = (padPct == null ? 2 : padPct) / 100;
|
|
2953
|
+
const padX = sourceW * pad;
|
|
2954
|
+
const padY = sourceH * pad;
|
|
2955
|
+
const cx = Math.max(0, Math.floor(sourceX - padX));
|
|
2956
|
+
const cy = Math.max(0, Math.floor(sourceY - padY));
|
|
2957
|
+
const cw = Math.min(canvas.width - cx, Math.ceil(sourceW + padX * 2));
|
|
2958
|
+
const ch = Math.min(canvas.height - cy, Math.ceil(sourceH + padY * 2));
|
|
2959
|
+
const cropCanvas = document.createElement('canvas');
|
|
2960
|
+
cropCanvas.width = cw;
|
|
2961
|
+
cropCanvas.height = ch;
|
|
2962
|
+
const cropCtx = cropCanvas.getContext('2d');
|
|
2963
|
+
if (!cropCtx) throw new Error('2d context unavailable');
|
|
2964
|
+
cropCtx.drawImage(canvas, cx, cy, cw, ch, 0, 0, cw, ch);
|
|
2965
|
+
previewCaptureCanvas = cropCanvas;
|
|
2966
|
+
}
|
|
2967
|
+
|
|
2968
|
+
// Rotate both outputs if UI was rotated during capture.
|
|
2969
|
+
if (shouldRotateUi) {
|
|
2970
|
+
submitCaptureCanvas = rotateCanvas90CCW(submitCaptureCanvas);
|
|
2971
|
+
if (previewCaptureCanvas) {
|
|
2972
|
+
previewCaptureCanvas = rotateCanvas90CCW(previewCaptureCanvas);
|
|
2973
|
+
}
|
|
2974
|
+
}
|
|
2975
|
+
|
|
2976
|
+
const fullDataUrl = submitCaptureCanvas.toDataURL('image/jpeg', 0.95);
|
|
2977
|
+
const previewDataUrl = previewCaptureCanvas
|
|
2978
|
+
? previewCaptureCanvas.toDataURL('image/jpeg', 0.95)
|
|
2979
|
+
: null;
|
|
2980
|
+
|
|
2981
|
+
if (IS_DEBUG_MODE) {
|
|
2982
|
+
console.info('--- MANUAL CAPTURE TRIGGERED ---');
|
|
2983
|
+
}
|
|
2984
|
+
setCaptureOrigin('camera_manual_capture');
|
|
2985
|
+
setCapturedImage(fullDataUrl);
|
|
2986
|
+
setPreviewImage(previewDataUrl);
|
|
2987
|
+
setComplianceState(COMPLIANCE_STATES.SUCCESS);
|
|
2988
|
+
setFeedback(autoCaptureFeedback.captured);
|
|
2989
|
+
isCapturingRef.current = true;
|
|
2990
|
+
} catch (err) {
|
|
2991
|
+
console.error('Manual capture failed:', err);
|
|
2992
|
+
setComplianceState(COMPLIANCE_STATES.IDLE);
|
|
2993
|
+
setFeedback(autoCaptureFeedback.captureFailed);
|
|
2994
|
+
isCapturingRef.current = false;
|
|
2995
|
+
}
|
|
2996
|
+
};
|
|
2997
|
+
|
|
2998
|
+
const resetCapture = () => {
|
|
2999
|
+
setCapturedImage(null);
|
|
3000
|
+
setPreviewImage(null);
|
|
3001
|
+
setCaptureOrigin(null);
|
|
3002
|
+
setComplianceState(COMPLIANCE_STATES.IDLE);
|
|
3003
|
+
setFeedback(autoCaptureFeedback.positionDocument);
|
|
3004
|
+
updateDebugPath(null);
|
|
3005
|
+
isCapturingRef.current = false;
|
|
3006
|
+
autoDetectionSuspendedRef.current = false;
|
|
3007
|
+
stabilityRef.current.count = 0;
|
|
3008
|
+
stabilityRef.current.lastCenter = null;
|
|
3009
|
+
bestFrameRef.current = { image: null, preview: null, score: 0 };
|
|
3010
|
+
latestCardRectRef.current = null;
|
|
3011
|
+
docFillEmaRef.current = null;
|
|
3012
|
+
lastProcessedRef.current = 0;
|
|
3013
|
+
prevProcessedRef.current = 0;
|
|
3014
|
+
captureMissCounterRef.current = 0;
|
|
3015
|
+
cvErrorStreakRef.current = 0;
|
|
3016
|
+
lastRealCardAtRef.current = null;
|
|
3017
|
+
regionStabilityRef.current = 0;
|
|
3018
|
+
// If documentType was provided, keep it locked; otherwise re-enter discovery
|
|
3019
|
+
if (providedDocType) {
|
|
3020
|
+
setDetectedDocType(providedDocType);
|
|
3021
|
+
setGuideAspectRatio(orientAspect(ASPECT_RATIOS[providedDocType]));
|
|
3022
|
+
detectionPhaseRef.current = DETECTION_PHASE.CAPTURE;
|
|
3023
|
+
discoveryRef.current = {
|
|
3024
|
+
votes: [],
|
|
3025
|
+
docType: providedDocType,
|
|
3026
|
+
frameCount: 0,
|
|
3027
|
+
consecutiveMisses: 0,
|
|
3028
|
+
};
|
|
3029
|
+
} else {
|
|
3030
|
+
setDetectedDocType(null);
|
|
3031
|
+
setGuideAspectRatio(orientAspect(ASPECT_RATIOS.passport));
|
|
3032
|
+
detectionPhaseRef.current = DETECTION_PHASE.DISCOVERY;
|
|
3033
|
+
discoveryRef.current = {
|
|
3034
|
+
votes: [],
|
|
3035
|
+
docType: null,
|
|
3036
|
+
frameCount: 0,
|
|
3037
|
+
consecutiveMisses: 0,
|
|
3038
|
+
};
|
|
3039
|
+
}
|
|
3040
|
+
};
|
|
3041
|
+
|
|
3042
|
+
return {
|
|
3043
|
+
feedback,
|
|
3044
|
+
captureProgress,
|
|
3045
|
+
capturedImage,
|
|
3046
|
+
previewImage,
|
|
3047
|
+
captureOrigin,
|
|
3048
|
+
complianceState,
|
|
3049
|
+
debugPath,
|
|
3050
|
+
debugInfo,
|
|
3051
|
+
debugRoi,
|
|
3052
|
+
detectedDocType,
|
|
3053
|
+
guideAspectRatio,
|
|
3054
|
+
manualFallbackActive,
|
|
3055
|
+
cvLoadFailed,
|
|
3056
|
+
triggerManualCapture,
|
|
3057
|
+
resetCapture,
|
|
3058
|
+
};
|
|
3059
|
+
}
|