@smileid/web-components 11.4.5 → 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.
Files changed (132) hide show
  1. package/dist/esm/DocumentCaptureScreens-DjSTdVP-.js +5398 -0
  2. package/dist/esm/DocumentCaptureScreens-DjSTdVP-.js.map +1 -0
  3. package/dist/esm/{Navigation-Bb7MPLE8.js → Navigation-6DH3vF4-.js} +28 -22
  4. package/dist/esm/Navigation-6DH3vF4-.js.map +1 -0
  5. package/dist/esm/{PoweredBySmileId-CxbaihMu.js → PoweredBySmileId-DoKwoPUd.js} +424 -6
  6. package/dist/esm/PoweredBySmileId-DoKwoPUd.js.map +1 -0
  7. package/dist/esm/SelfieCaptureScreens-CtX-4Tco.js +11470 -0
  8. package/dist/esm/SelfieCaptureScreens-CtX-4Tco.js.map +1 -0
  9. package/dist/esm/combobox.js +1 -1
  10. package/dist/esm/document.js +1 -1
  11. package/dist/esm/end-user-consent.js +713 -2
  12. package/dist/esm/end-user-consent.js.map +1 -1
  13. package/dist/esm/index-BqyuTk9f.js +1366 -0
  14. package/dist/esm/{index-C4RTMbgw.js.map → index-BqyuTk9f.js.map} +1 -1
  15. package/dist/esm/localisation.js +1 -1
  16. package/dist/esm/main.js +14 -14
  17. package/dist/esm/navigation.js +1 -1
  18. package/dist/esm/package-CjZI-cNQ.js +2540 -0
  19. package/dist/esm/package-CjZI-cNQ.js.map +1 -0
  20. package/dist/esm/selfie.js +1 -1
  21. package/dist/esm/smart-camera-web.js +81 -37
  22. package/dist/esm/smart-camera-web.js.map +1 -1
  23. package/dist/esm/totp-consent.js +731 -2
  24. package/dist/esm/totp-consent.js.map +1 -1
  25. package/dist/esm/validate.js +31 -0
  26. package/dist/esm/validate.js.map +1 -0
  27. package/dist/smart-camera-web.js +1513 -383
  28. package/dist/smart-camera-web.js.map +1 -1
  29. package/dist/types/main.d.ts +18 -1
  30. package/dist/types/validate.d.ts +21 -0
  31. package/lib/components/document/src/DocumentCaptureScreens.js +97 -18
  32. package/lib/components/document/src/assets/lottie.d.ts +12 -0
  33. package/lib/components/document/src/assets/svg-inline.d.ts +8 -0
  34. package/lib/components/document/src/document-auto-capture/DocumentAutoCapture.stories.js +75 -0
  35. package/lib/components/document/src/document-auto-capture/DocumentAutoCapture.tsx +1458 -0
  36. package/lib/components/document/src/document-auto-capture/README.md +73 -0
  37. package/lib/components/document/src/document-auto-capture/assets/Greenbook_Shimmer.svg +42 -0
  38. package/lib/components/document/src/document-auto-capture/assets/ID_Back_Shimmer.svg +8 -0
  39. package/lib/components/document/src/document-auto-capture/assets/ID_Front_Shimmer.svg +20 -0
  40. package/lib/components/document/src/document-auto-capture/assets/Passport-Shimmer.svg +143 -0
  41. package/lib/components/document/src/document-auto-capture/assets/shimmers.ts +21 -0
  42. package/lib/components/document/src/document-auto-capture/assets/svg-raw.d.ts +4 -0
  43. package/lib/components/document/src/document-auto-capture/components/CaptureButton.tsx +122 -0
  44. package/lib/components/document/src/document-auto-capture/components/Overlay.tsx +167 -0
  45. package/lib/components/document/src/document-auto-capture/components/TuningPanel.tsx +856 -0
  46. package/lib/components/document/src/document-auto-capture/constants/captureLayout.ts +58 -0
  47. package/lib/components/document/src/document-auto-capture/detection/cvErrorRecovery.ts +40 -0
  48. package/lib/components/document/src/document-auto-capture/detection/documentAspect.ts +20 -0
  49. package/lib/components/document/src/document-auto-capture/detection/qualityScoring.ts +35 -0
  50. package/lib/components/document/src/document-auto-capture/detection/seamRejection.ts +209 -0
  51. package/lib/components/document/src/document-auto-capture/detection/synthesisTiming.ts +10 -0
  52. package/lib/components/document/src/document-auto-capture/hooks/useCamera.ts +117 -0
  53. package/lib/components/document/src/document-auto-capture/hooks/useCardDetection.ts +3059 -0
  54. package/lib/components/document/src/document-auto-capture/index.ts +4 -0
  55. package/lib/components/document/src/document-auto-capture/theme.ts +40 -0
  56. package/lib/components/document/src/document-auto-capture/utils/debug.ts +25 -0
  57. package/lib/components/document/src/document-auto-capture/utils/opencvLoader.ts +86 -0
  58. package/lib/components/document/src/document-capture-instructions/DocumentCaptureInstructions.tsx +327 -244
  59. package/lib/components/document/src/document-capture-review/DocumentCaptureReview.js +153 -189
  60. package/lib/components/document/src/document-capture-submission/DocumentCaptureSubmission.tsx +432 -0
  61. package/lib/components/document/src/document-capture-submission/index.js +3 -0
  62. package/lib/components/navigation/src/Navigation.js +27 -8
  63. package/lib/components/selfie/README.md +13 -0
  64. package/lib/components/selfie/src/SelfieCaptureScreens.js +56 -8
  65. package/lib/components/selfie/src/enhanced-smartselfie-capture/EnhancedSmartSelfieCapture.tsx +684 -0
  66. package/lib/components/selfie/src/enhanced-smartselfie-capture/EnhancedSmartSelfieConsent.tsx +71 -0
  67. package/lib/components/selfie/src/enhanced-smartselfie-capture/EnhancedSmartSelfieSubmission.tsx +181 -0
  68. package/lib/components/selfie/src/enhanced-smartselfie-capture/OvalProgress.tsx +87 -0
  69. package/lib/components/selfie/src/enhanced-smartselfie-capture/assets/Icon.svg +8 -0
  70. package/lib/components/selfie/src/enhanced-smartselfie-capture/assets/accessories.svg +77 -0
  71. package/lib/components/selfie/src/enhanced-smartselfie-capture/assets/active_liveness_animation.lottie +0 -0
  72. package/lib/components/selfie/src/enhanced-smartselfie-capture/assets/device.svg +12 -0
  73. package/lib/components/selfie/src/enhanced-smartselfie-capture/assets/device_orientation.lottie +0 -0
  74. package/lib/components/selfie/src/enhanced-smartselfie-capture/assets/good.svg +52 -0
  75. package/lib/components/selfie/src/enhanced-smartselfie-capture/assets/id-card.svg +9 -0
  76. package/lib/components/selfie/src/enhanced-smartselfie-capture/assets/illustrations.tsx +852 -0
  77. package/lib/components/selfie/src/enhanced-smartselfie-capture/assets/instructions-img.svg +3 -0
  78. package/lib/components/selfie/src/enhanced-smartselfie-capture/assets/multiple-faces.svg +69 -0
  79. package/lib/components/selfie/src/enhanced-smartselfie-capture/assets/person.svg +6 -0
  80. package/lib/components/selfie/src/enhanced-smartselfie-capture/assets/phone.svg +8 -0
  81. package/lib/components/selfie/src/enhanced-smartselfie-capture/assets/poor-lighting.svg +53 -0
  82. package/lib/components/selfie/src/enhanced-smartselfie-capture/assets/too_dark_animation.lottie +0 -0
  83. package/lib/components/selfie/src/enhanced-smartselfie-capture/components/ActiveLivenessOverlay.tsx +226 -0
  84. package/lib/components/selfie/src/enhanced-smartselfie-capture/components/AlertDisplay.tsx +38 -0
  85. package/lib/components/selfie/src/enhanced-smartselfie-capture/components/BackNavigation.tsx +45 -0
  86. package/lib/components/selfie/src/enhanced-smartselfie-capture/components/CameraPreview.tsx +96 -0
  87. package/lib/components/selfie/src/enhanced-smartselfie-capture/components/CaptureControls.tsx +97 -0
  88. package/lib/components/selfie/src/enhanced-smartselfie-capture/components/CaptureGuidelines.tsx +374 -0
  89. package/lib/components/selfie/src/enhanced-smartselfie-capture/components/ConsentView.tsx +460 -0
  90. package/lib/components/selfie/src/enhanced-smartselfie-capture/components/SubmissionView.tsx +426 -0
  91. package/lib/components/selfie/src/enhanced-smartselfie-capture/components/index.ts +3 -0
  92. package/lib/components/selfie/src/enhanced-smartselfie-capture/constants.ts +23 -0
  93. package/lib/components/selfie/src/enhanced-smartselfie-capture/hooks/index.ts +2 -0
  94. package/lib/components/selfie/src/enhanced-smartselfie-capture/hooks/useCamera.ts +238 -0
  95. package/lib/components/selfie/src/enhanced-smartselfie-capture/hooks/useFaceCapture.ts +1075 -0
  96. package/lib/components/selfie/src/enhanced-smartselfie-capture/index.ts +1 -0
  97. package/lib/components/selfie/src/enhanced-smartselfie-capture/utils/alertMessages.ts +20 -0
  98. package/lib/components/selfie/src/enhanced-smartselfie-capture/utils/canvas.ts +108 -0
  99. package/lib/components/selfie/src/enhanced-smartselfie-capture/utils/faceDetection.ts +545 -0
  100. package/lib/components/selfie/src/enhanced-smartselfie-capture/utils/imageCapture.ts +66 -0
  101. package/lib/components/selfie/src/enhanced-smartselfie-capture/utils/imageQuality.ts +151 -0
  102. package/lib/components/selfie/src/enhanced-smartselfie-capture/utils/index.ts +5 -0
  103. package/lib/components/selfie/src/enhanced-smartselfie-capture/utils/mediapipeManager.ts +215 -0
  104. package/lib/components/selfie/src/selfie-capture-wrapper/SelfieCaptureWrapper.tsx +24 -1
  105. package/lib/components/selfie/src/smartselfie-capture/SmartSelfieCapture.tsx +2 -2
  106. package/lib/components/selfie/src/smartselfie-capture/hooks/useFaceCapture.ts +15 -7
  107. package/lib/components/selfie/src/smartselfie-capture/utils/canvas.ts +4 -6
  108. package/lib/components/signature-pad/package.json +1 -1
  109. package/lib/components/smart-camera-web/src/README.md +11 -0
  110. package/lib/components/smart-camera-web/src/SmartCameraWeb.js +89 -8
  111. package/lib/components/totp-consent/src/TotpConsent.js +1 -1
  112. package/lib/domain/localisation/index.js +2 -2
  113. package/package.json +9 -5
  114. package/dist/esm/DocumentCaptureScreens-D2G0NOQr.js +0 -4147
  115. package/dist/esm/DocumentCaptureScreens-D2G0NOQr.js.map +0 -1
  116. package/dist/esm/EndUserConsent-uHfA3txP.js +0 -717
  117. package/dist/esm/EndUserConsent-uHfA3txP.js.map +0 -1
  118. package/dist/esm/Navigation-Bb7MPLE8.js.map +0 -1
  119. package/dist/esm/PoweredBySmileId-CxbaihMu.js.map +0 -1
  120. package/dist/esm/SelfieCaptureScreens-Dr7VzON7.js +0 -7651
  121. package/dist/esm/SelfieCaptureScreens-Dr7VzON7.js.map +0 -1
  122. package/dist/esm/TotpConsent-Depzg0ti.js +0 -734
  123. package/dist/esm/TotpConsent-Depzg0ti.js.map +0 -1
  124. package/dist/esm/index-C4RTMbgw.js +0 -1360
  125. package/dist/esm/package-D6YrpMcO.js +0 -565
  126. package/dist/esm/package-D6YrpMcO.js.map +0 -1
  127. package/dist/esm/styles-BTEClL7R.js +0 -419
  128. package/dist/esm/styles-BTEClL7R.js.map +0 -1
  129. /package/lib/components/document/src/assets/lottie/{taking photo of green book passport.lottie → greenbook.lottie} +0 -0
  130. /package/lib/components/document/src/assets/lottie/{taking photo of ID FLIP 2D.lottie → id-card-flip.lottie} +0 -0
  131. /package/lib/components/document/src/assets/lottie/{taking photo of ID.lottie → id-card.lottie} +0 -0
  132. /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
+ }