feat(document): adaptive contour-Canny for low-contrast backgrounds#673
feat(document): adaptive contour-Canny for low-contrast backgrounds#673Barnabas A Nsoh (ayinloya) wants to merge 183 commits into
Conversation
…deploy-preview (#586) * feat: add manual workflow_dispatch trigger with skip_tests option to deploy-preview * feat: add manual workflow_dispatch trigger with skip_tests option to deploy-preview * fix: remove unnecessary if condition from share-preview-url step * refactor: rename step id set_dest_dir_hosted_web to set_dest_dir_embed for clarity * feat: add manual workflow_dispatch trigger to destroy-preview with safe branch handling * chore: limit preview comment step to pull_request events * fix: use safe inputs expression for skip_tests across all trigger types
…oup across 1 directory (#582) chore(deps-dev): bump vite in the npm_and_yarn group across 1 directory Bumps the npm_and_yarn group with 1 update in the / directory: [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite). Updates `vite` from 7.2.2 to 7.3.2 - [Release notes](https://github.com/vitejs/vite/releases) - [Changelog](https://github.com/vitejs/vite/blob/v7.3.2/packages/vite/CHANGELOG.md) - [Commits](https://github.com/vitejs/vite/commits/v7.3.2/packages/vite) --- updated-dependencies: - dependency-name: vite dependency-version: 7.3.2 dependency-type: direct:development dependency-group: npm_and_yarn ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Barnabas A Nsoh <banasco@gmail.com>
The new Preact-based component was missing shadow DOM, which caused
Cypress tests to fail when looking for .back-button in the shadow root.
The original JS component used attachShadow() but preact-custom-element
defaults to no shadow DOM. Adding { shadow: true } option fixes the test.
The embed tests expect an element with id="take-photo" in the document capture instructions component. The new Preact component was missing this ID, causing test failures.
* feat(web-components): update Navigation to match new design - Replace circular colored icon buttons with minimal 40x40px semi-transparent buttons - Use simple white arrow icon for back button (no text label) - Use simple white X icon for close button - Add hover and focus-visible states - Move button labels to aria-label for accessibility - Update stories with dark background to visualize white icons * Update navigation to Figma tokens and parent-controlled padding * Fix dependency * fix(web-components): address PR review comments on Navigation * fix(web-components): improve Navigation hover performance and story visibility * fix(web-components): add appearance resets and decouple theme-color to icon
…ated Side-mounted capture/gallery buttons were keyed on useLandscapeUi, which stays true for landscape doc types (id-card, passport) even on desktop where rotation is suppressed. Switch to shouldRotateUi so the bottom row renders the buttons whenever the UI isn't actually rotated.
Add https local host for mobile testing Show guide throughout capture
Add focusMode continuous as constraints Comment out 4k resolution
* feat(web-components): new document capture instructions screen
* fix(web-components): enable shadow DOM for DocumentCaptureInstructions
The new Preact-based component was missing shadow DOM, which caused
Cypress tests to fail when looking for .back-button in the shadow root.
The original JS component used attachShadow() but preact-custom-element
defaults to no shadow DOM. Adding { shadow: true } option fixes the test.
* fix(web-components): add take-photo id to start button for test compat
The embed tests expect an element with id="take-photo" in the document
capture instructions component. The new Preact component was missing
this ID, causing test failures.
Adopt the Stripe/Persona client-side capture discipline on the classical OpenCV pipeline (ML detection deferred to a future spike): - Composite per-frame quality score (frameQualityScore + QUALITY_WEIGHTS): blend sharpness, glare, fill-framing, aspect-match, contour confidence and (mobile) chroma into a 0-1 readability score; pick the best frame of the stability window by composite, not raw Laplacian variance. - Robust rolling best-frame selection (BEST_FRAME_MISS_TOLERANCE): a transient blur/glare frame softens the stability count instead of nulling a captured candidate, so tilted/laminated cards and passports that flicker in/out of a clean contour stop losing their best frame. - Finish the rolling-average chroma-content gate (chromaWindowRef): gate on a smoothed average of the selected candidate's bbox chroma rather than the noisy per-frame value; selection on both real and synthetic paths is geometry-only. - Consolidate getOptimalDefaults() into SHARED_DEFAULTS + MOBILE/DESKTOP overrides (value-identical, removes duplicate drift). - Surface the composite Quality metric in the TuningPanel. Verified: typecheck + eslint clean; composite-scoring and miss-tolerance logic checked deterministically; real headless OpenCV.js harness confirms false-positive safety (empty scene, keyboard, background rectangle rejected). Live on-camera regression across id-card/passport/greenbook on mobile + laptop remains the manual sign-off. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The document-capture-new-flow merge replaced FrontInstructionsLayout (which used `rootRef: Ref<HTMLDivElement>`) with a new Props interface, leaving the `Ref` type import unused and failing the web-components lint CI job. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Persistent review updated to latest commit c8c804d |
| if (chromaFusionOn) { | ||
| try { | ||
| contourRgb = new cv.Mat(); | ||
| cv.cvtColor(contourFull, contourRgb, cv.COLOR_RGBA2RGB, 0); | ||
| contourFull.delete(); | ||
| contourFull = null; | ||
|
|
||
| contourLab = new cv.Mat(); | ||
| cv.cvtColor(contourRgb, contourLab, cv.COLOR_RGB2Lab, 0); | ||
| contourRgb.delete(); | ||
| contourRgb = null; | ||
|
|
||
| labPlanes = new cv.MatVector(); | ||
| cv.split(contourLab, labPlanes); // [0]=L, [1]=a, [2]=b | ||
| contourLab.delete(); | ||
| contourLab = null; | ||
|
|
||
| // Borrowed views — owned by labPlanes, never deleted directly. | ||
| const aPlane = labPlanes.get(1); | ||
| const bPlane = labPlanes.get(2); | ||
| const chromaK = new cv.Size(7, 7); // chroma is noisier than luma | ||
| aBlur = new cv.Mat(); | ||
| bBlur = new cv.Mat(); | ||
| cv.GaussianBlur(aPlane, aBlur, chromaK, 0, 0, cv.BORDER_DEFAULT); | ||
| cv.GaussianBlur(bPlane, bBlur, chromaK, 0, 0, cv.BORDER_DEFAULT); | ||
|
|
||
| // Per-pixel chroma magnitude |a-128| + |b-128| (Lab neutral = | ||
| // 128). Near 0 for a neutral white/gray object (keyboard, paper, | ||
| // desk); high where a colour ID has a photo/printing. Kept alive | ||
| // for the Level 2 content gate, measured per detected rectangle | ||
| // below so background colour can't mask a monochrome object. | ||
| const aAbs = new cv.Mat(); | ||
| const bAbs = new cv.Mat(); | ||
| cv.convertScaleAbs(aPlane, aAbs, 1, -128); | ||
| cv.convertScaleAbs(bPlane, bAbs, 1, -128); | ||
| chromaMag = new cv.Mat(); | ||
| cv.addWeighted(aAbs, 1, bAbs, 1, 0, chromaMag); | ||
| aAbs.delete(); | ||
| bAbs.delete(); | ||
|
|
||
| const chromaLow = settingsRef.current.chromaCannyLow ?? 15; | ||
| const chromaHigh = settingsRef.current.chromaCannyHigh ?? 40; | ||
| aEdges = new cv.Mat(); | ||
| bEdges = new cv.Mat(); | ||
| cv.Canny(aBlur, aEdges, chromaLow, chromaHigh); | ||
| cv.Canny(bBlur, bEdges, chromaLow, chromaHigh); | ||
|
|
||
| cv.bitwise_or(edges, aEdges, edges); | ||
| cv.bitwise_or(edges, bEdges, edges); | ||
| edgeSource = 'lum+chroma'; | ||
|
|
||
| labPlanes.delete(); // frees L/a/b incl. borrowed aPlane/bPlane | ||
| labPlanes = null; | ||
| aBlur.delete(); | ||
| aBlur = null; | ||
| bBlur.delete(); | ||
| bBlur = null; | ||
| aEdges.delete(); | ||
| aEdges = null; | ||
| bEdges.delete(); | ||
| bEdges = null; | ||
| } catch { | ||
| // Lab path failed on this device — disable for the session and | ||
| // continue with the luminance edges already in `edges`. | ||
| chromaUnavailableRef.current = true; | ||
| edgeSource = 'lum'; | ||
| } |
There was a problem hiding this comment.
Suggestion: If the catch block fires after some Mats were allocated (e.g. contourRgb succeeded but contourLab threw), those intermediate Mats leak because the catch only sets a flag. The shared finally cleanup only frees variables that are still non-null, but contourRgb is explicitly set to null after delete(). However, if the throw happens between allocation and the delete() call (e.g. cv.cvtColor for Lab throws after contourRgb was created), contourRgb is still non-null and will be cleaned up by finally — but contourFull was already deleted and set to null before the throw, so it's fine. The real risk is labPlanes: if cv.split succeeds but a later line throws, labPlanes is non-null and the finally block calls delete() on it — but aPlane/bPlane (borrowed views from labPlanes.get()) are never explicitly deleted in the catch path. Since labPlanes.delete() should free its children, this is likely OK. More critically, aAbs and bAbs are local variables inside the try block and are NOT in the shared finally cleanup list — if the throw happens after their allocation but before their delete() calls, they will leak permanently. [possible issue, importance: 6]
| if (chromaFusionOn) { | |
| try { | |
| contourRgb = new cv.Mat(); | |
| cv.cvtColor(contourFull, contourRgb, cv.COLOR_RGBA2RGB, 0); | |
| contourFull.delete(); | |
| contourFull = null; | |
| contourLab = new cv.Mat(); | |
| cv.cvtColor(contourRgb, contourLab, cv.COLOR_RGB2Lab, 0); | |
| contourRgb.delete(); | |
| contourRgb = null; | |
| labPlanes = new cv.MatVector(); | |
| cv.split(contourLab, labPlanes); // [0]=L, [1]=a, [2]=b | |
| contourLab.delete(); | |
| contourLab = null; | |
| // Borrowed views — owned by labPlanes, never deleted directly. | |
| const aPlane = labPlanes.get(1); | |
| const bPlane = labPlanes.get(2); | |
| const chromaK = new cv.Size(7, 7); // chroma is noisier than luma | |
| aBlur = new cv.Mat(); | |
| bBlur = new cv.Mat(); | |
| cv.GaussianBlur(aPlane, aBlur, chromaK, 0, 0, cv.BORDER_DEFAULT); | |
| cv.GaussianBlur(bPlane, bBlur, chromaK, 0, 0, cv.BORDER_DEFAULT); | |
| // Per-pixel chroma magnitude |a-128| + |b-128| (Lab neutral = | |
| // 128). Near 0 for a neutral white/gray object (keyboard, paper, | |
| // desk); high where a colour ID has a photo/printing. Kept alive | |
| // for the Level 2 content gate, measured per detected rectangle | |
| // below so background colour can't mask a monochrome object. | |
| const aAbs = new cv.Mat(); | |
| const bAbs = new cv.Mat(); | |
| cv.convertScaleAbs(aPlane, aAbs, 1, -128); | |
| cv.convertScaleAbs(bPlane, bAbs, 1, -128); | |
| chromaMag = new cv.Mat(); | |
| cv.addWeighted(aAbs, 1, bAbs, 1, 0, chromaMag); | |
| aAbs.delete(); | |
| bAbs.delete(); | |
| const chromaLow = settingsRef.current.chromaCannyLow ?? 15; | |
| const chromaHigh = settingsRef.current.chromaCannyHigh ?? 40; | |
| aEdges = new cv.Mat(); | |
| bEdges = new cv.Mat(); | |
| cv.Canny(aBlur, aEdges, chromaLow, chromaHigh); | |
| cv.Canny(bBlur, bEdges, chromaLow, chromaHigh); | |
| cv.bitwise_or(edges, aEdges, edges); | |
| cv.bitwise_or(edges, bEdges, edges); | |
| edgeSource = 'lum+chroma'; | |
| labPlanes.delete(); // frees L/a/b incl. borrowed aPlane/bPlane | |
| labPlanes = null; | |
| aBlur.delete(); | |
| aBlur = null; | |
| bBlur.delete(); | |
| bBlur = null; | |
| aEdges.delete(); | |
| aEdges = null; | |
| bEdges.delete(); | |
| bEdges = null; | |
| } catch { | |
| // Lab path failed on this device — disable for the session and | |
| // continue with the luminance edges already in `edges`. | |
| chromaUnavailableRef.current = true; | |
| edgeSource = 'lum'; | |
| } | |
| if (chromaFusionOn) { | |
| let aAbs: any = null; | |
| let bAbs: any = null; | |
| try { | |
| contourRgb = new cv.Mat(); | |
| cv.cvtColor(contourFull, contourRgb, cv.COLOR_RGBA2RGB, 0); | |
| contourFull.delete(); | |
| contourFull = null; | |
| contourLab = new cv.Mat(); | |
| cv.cvtColor(contourRgb, contourLab, cv.COLOR_RGB2Lab, 0); | |
| contourRgb.delete(); | |
| contourRgb = null; | |
| labPlanes = new cv.MatVector(); | |
| cv.split(contourLab, labPlanes); // [0]=L, [1]=a, [2]=b | |
| contourLab.delete(); | |
| contourLab = null; | |
| const aPlane = labPlanes.get(1); | |
| const bPlane = labPlanes.get(2); | |
| const chromaK = new cv.Size(7, 7); | |
| aBlur = new cv.Mat(); | |
| bBlur = new cv.Mat(); | |
| cv.GaussianBlur(aPlane, aBlur, chromaK, 0, 0, cv.BORDER_DEFAULT); | |
| cv.GaussianBlur(bPlane, bBlur, chromaK, 0, 0, cv.BORDER_DEFAULT); | |
| aAbs = new cv.Mat(); | |
| bAbs = new cv.Mat(); | |
| cv.convertScaleAbs(aPlane, aAbs, 1, -128); | |
| cv.convertScaleAbs(bPlane, bAbs, 1, -128); | |
| chromaMag = new cv.Mat(); | |
| cv.addWeighted(aAbs, 1, bAbs, 1, 0, chromaMag); | |
| aAbs.delete(); aAbs = null; | |
| bAbs.delete(); bAbs = null; | |
| const chromaLow = settingsRef.current.chromaCannyLow ?? 15; | |
| const chromaHigh = settingsRef.current.chromaCannyHigh ?? 40; | |
| aEdges = new cv.Mat(); | |
| bEdges = new cv.Mat(); | |
| cv.Canny(aBlur, aEdges, chromaLow, chromaHigh); | |
| cv.Canny(bBlur, bEdges, chromaLow, chromaHigh); | |
| cv.bitwise_or(edges, aEdges, edges); | |
| cv.bitwise_or(edges, bEdges, edges); | |
| edgeSource = 'lum+chroma'; | |
| labPlanes.delete(); labPlanes = null; | |
| aBlur.delete(); aBlur = null; | |
| bBlur.delete(); bBlur = null; | |
| aEdges.delete(); aEdges = null; | |
| bEdges.delete(); bEdges = null; | |
| } catch { | |
| chromaUnavailableRef.current = true; | |
| edgeSource = 'lum'; | |
| if (aAbs) { try { aAbs.delete(); } catch {} } | |
| if (bAbs) { try { bAbs.delete(); } catch {} } | |
| } | |
| } |
| const sobelX = new cv.Mat(); | ||
| const sobelY = new cv.Mat(); | ||
| cv.Sobel(blurred, sobelX, cv.CV_32F, 1, 0, 3); | ||
| cv.Sobel(blurred, sobelY, cv.CV_32F, 0, 1, 3); | ||
| const gradMag = new cv.Mat(); | ||
| cv.magnitude(sobelX, sobelY, gradMag); | ||
| sobelX.delete(); | ||
| sobelY.delete(); | ||
| const gradMean = new cv.Mat(); | ||
| const gradStdDev = new cv.Mat(); | ||
| cv.meanStdDev(gradMag, gradMean, gradStdDev); | ||
| const gMean = gradMean.doubleAt(0, 0); | ||
| const gStd = gradStdDev.doubleAt(0, 0); | ||
| gradMag.delete(); | ||
| gradMean.delete(); | ||
| gradStdDev.delete(); |
There was a problem hiding this comment.
Suggestion: The Sobel/magnitude computation allocates 5 temporary Mats (sobelX, sobelY, gradMag, gradMean, gradStdDev) every frame. If any OpenCV call between allocations throws, the already-allocated Mats leak because they are local variables not tracked in the shared finally cleanup. These should either be added to the outer cleanup list or wrapped in a local try/finally. Given this runs every frame, even a single leaked Mat per session accumulates significant WASM heap pressure. [general, importance: 5]
| const sobelX = new cv.Mat(); | |
| const sobelY = new cv.Mat(); | |
| cv.Sobel(blurred, sobelX, cv.CV_32F, 1, 0, 3); | |
| cv.Sobel(blurred, sobelY, cv.CV_32F, 0, 1, 3); | |
| const gradMag = new cv.Mat(); | |
| cv.magnitude(sobelX, sobelY, gradMag); | |
| sobelX.delete(); | |
| sobelY.delete(); | |
| const gradMean = new cv.Mat(); | |
| const gradStdDev = new cv.Mat(); | |
| cv.meanStdDev(gradMag, gradMean, gradStdDev); | |
| const gMean = gradMean.doubleAt(0, 0); | |
| const gStd = gradStdDev.doubleAt(0, 0); | |
| gradMag.delete(); | |
| gradMean.delete(); | |
| gradStdDev.delete(); | |
| // Declare at the outer scope alongside other cleanup-tracked Mats | |
| let sobelX: any = null; | |
| let sobelY: any = null; | |
| let gradMag: any = null; | |
| let gradMean: any = null; | |
| let gradStdDev: any = null; | |
| // ... then in the computation block: | |
| sobelX = new cv.Mat(); | |
| sobelY = new cv.Mat(); | |
| cv.Sobel(blurred, sobelX, cv.CV_32F, 1, 0, 3); | |
| cv.Sobel(blurred, sobelY, cv.CV_32F, 0, 1, 3); | |
| gradMag = new cv.Mat(); | |
| cv.magnitude(sobelX, sobelY, gradMag); | |
| sobelX.delete(); sobelX = null; | |
| sobelY.delete(); sobelY = null; | |
| gradMean = new cv.Mat(); | |
| gradStdDev = new cv.Mat(); | |
| cv.meanStdDev(gradMag, gradMean, gradStdDev); | |
| const gMean = gradMean.doubleAt(0, 0); | |
| const gStd = gradStdDev.doubleAt(0, 0); | |
| gradMag.delete(); gradMag = null; | |
| gradMean.delete(); gradMean = null; | |
| gradStdDev.delete(); gradStdDev = null; |
| const cRect = cv.boundingRect(bestContour); | ||
| const cx = Math.max(0, cRect.x); | ||
| const cy = Math.max(0, cRect.y); | ||
| const cw = Math.min(chromaMag.cols - cx, cRect.width); | ||
| const ch = Math.min(chromaMag.rows - cy, cRect.height); |
There was a problem hiding this comment.
Suggestion: If cRect.x or cRect.y is negative (possible with contour bounding rects near edges), cx/cy become 0 but cw/ch are computed from chromaMag.cols - 0 and cRect.width — the width could exceed the actual available region. Also, if chromaMag.cols - cx is negative (shouldn't happen with the max(0) clamp, but cRect.width could be larger than chromaMag.cols), cw could be negative, causing cv.Rect to throw or produce undefined behavior. Clamp cw and ch to be non-negative before the cw > 0 && ch > 0 check. [possible issue, importance: 6]
| const cRect = cv.boundingRect(bestContour); | |
| const cx = Math.max(0, cRect.x); | |
| const cy = Math.max(0, cRect.y); | |
| const cw = Math.min(chromaMag.cols - cx, cRect.width); | |
| const ch = Math.min(chromaMag.rows - cy, cRect.height); | |
| const cRect = cv.boundingRect(bestContour); | |
| const cx = Math.max(0, Math.min(cRect.x, chromaMag.cols - 1)); | |
| const cy = Math.max(0, Math.min(cRect.y, chromaMag.rows - 1)); | |
| const cw = Math.max(0, Math.min(chromaMag.cols - cx, cRect.width)); | |
| const ch = Math.max(0, Math.min(chromaMag.rows - cy, cRect.height)); |
| inGuideDetectedRef.current = false; | ||
| // No candidate this frame — drop the chroma history so a stale | ||
| // average can't carry over to the next object entering the frame. | ||
| chromaWindowRef.current = []; |
There was a problem hiding this comment.
Suggestion: When no candidate is found, the chroma window is cleared. However, winnerGeomRef.current retains stale geometry from the previous frame's winner. If a new candidate appears next frame and immediately reaches the quality-score section, it will use the stale winnerGeomRef values (from a different object) for one frame before being overwritten. Reset winnerGeomRef.current alongside the chroma window to avoid scoring a new candidate with the previous object's geometry. [general, importance: 5]
| chromaWindowRef.current = []; | |
| chromaWindowRef.current = []; | |
| winnerGeomRef.current = { aspect: 0, fillRatio: 0, synthetic: false }; |
When an OpenCV operation threw inside the detection loop, the catch surfaced "Processing failed — please try again" but left isCapturingRef set, so the rescheduler (which only runs when not capturing) never fired and detection froze until a manual page refresh. Clear the flag in the catch so the loop self-recovers on the next frame. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…stance On desktop the card is detected via the synthetic fallback, whose bbox was the absolute union of all significant contours — so the hand/arm holding the card inflated it, over-reading docFill and auto-capturing from too far with off distance guidance. Build the synthetic box from a percentile envelope (10th–90th) of the content contours on desktop, trimming sparse outliers (hand, stray background contours) so the box hugs the document cluster and distance reads accurately. Mobile keeps the absolute union.
|
Persistent review updated to latest commit 55f094d |
Extract the identical getDocPreviewDataUri, showDocSubmission, and setDocSubmissionState helpers from doc-verification.js and enhanced-document-verification.js into a single doc-submission.js module, so fixes only happen once. Also drop the dead PNG/JPEG MIME sniff in getDocPreviewDataUri — every published image is re-encoded to JPEG upstream, so the preview URI is always image/jpeg. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reject card-shaped quads framed by straight background lines (parquet floors, slatted tables) rather than a real document border. HoughLinesP runs lazily on the closed contour edge map; a candidate is rejected when >= 2 of its edges sit on "through-lines" that overshoot its corners. - New pure helper detection/seamRejection.ts (classifyEdgesOnThroughLines / isSeamFalseQuad) + unit spec — no OpenCV dependency, mirrors qualityScoring.ts. - Wired into the candidate-acceptance gate in useCardDetection.ts; gated on settings, additive (off => byte-identical behaviour). - Tunable via TuningPanel (seamRejectEnabled / houghThreshold / houghMinLengthRatio / houghMaxLineGap) with Hough Lines / Seam Rejected debug metrics. Verified: opencv.js 4.8.0 build ships HoughLinesP (runtime-confirmed callable). Unit + routing + detection specs green; lint + type-check clean.
|
User description
Why
Document auto-capture on mobile only fires reliably when the document sits on a high-contrast surface (e.g. a card on metal) and stalls on general backgrounds (wood, matte desk, similar-toned paper).
Root cause: the contour gate — the stage that actually drives capture, since the distance/shape/fill checks all depend on finding the 4-corner outline — used a fixed
cv.Canny(blurred, edges, 50, 150). That fixed pair needs a strong brightness gradient at the document border; on low-contrast backgrounds the gradient is too weak, the contour is never found, and capture never triggers.What
Make the contour-stage Canny thresholds adaptive per frame, derived from the frame's own gradient-magnitude statistics:
blurredimage →mean,stddev.high = clamp(mean + autoCannySigma·stddev, 60, 150),low = max(15, 0.4·high).cv.Cannykeeps its default gradient norm, so behaviour at the cap is unchanged.The 150 ceiling is the previously-fixed value, so the high-contrast/metallic path that already works is byte-identical — this change only relaxes detection for low contrast, never tightens it. The 60 floor lets faint borders on plain backgrounds produce a contour so distance/shape gating can succeed.
Why gradient-stats, not pure-median auto-Canny
A raw-median anchor tracks brightness, so a bright-but-plain desk would push thresholds up and worsen low-contrast detection. Canny thresholds operate on gradient magnitude, so anchoring on the gradient distribution is what actually relaxes detection when the border is faint.
Tuning
autoCannySigmasetting (default1.0, mobile + desktop).mergeDebugInfohelper) so the resolved thresholds are visible per surface.Files
hooks/useCardDetection.ts—CANNY_HIGH_MAX/MINconstants; adaptive Canny in the contour pass.DocumentAutoCapture.tsx—autoCannySigmadefault.components/TuningPanel.tsx— slider + metric.Testing
type-check, ESLint, Prettier all clean.[60,150]band andσ=1.0are reasoned defaults, not measured. Open ⚙️ Settings on a phone, watch Canny (lo/hi) on plain vs. metallic surfaces, and tune σ so plain backgrounds capture without false-firing on busy ones. The winning σ should be locked intogetOptimalDefaultsbefore this leaves draft.🤖 Generated with Claude Code
PR Type
Enhancement, Bug fix
Description
Adaptive per-frame Canny thresholds from gradient statistics for low-contrast backgrounds
Chroma (Lab a/b) edge fusion to detect luminance-invisible card borders
Tilt-robust shape gates using
cv.minAreaRectinstead of axis-aligned bboxComposite quality scoring, chroma-content gate, and transient-miss tolerance
Diagram Walkthrough
File Walkthrough
DocumentAutoCapture.tsx
Refactor settings into shared defaults with device overridespackages/web-components/lib/components/document/src/document-auto-capture/DocumentAutoCapture.tsx
SHARED_DEFAULTS) and per-device overrides(
MOBILE_OVERRIDES,DESKTOP_OVERRIDES) into named constantsautoCannySigma,chromaEdgeFusion,chromaCannyLow/High,mobileRegionFallback,idAspectTolerance,bookDocAspectTolerance,minFillRatio,chromaContentGate,minChromaContentgetOptimalDefaults()to spread shared + device-specificoverrides
TuningPanel.tsx
Extend tuning panel with adaptive Canny and chroma controlspackages/web-components/lib/components/document/src/document-auto-capture/components/TuningPanel.tsx
aspect tolerance, fill ratio, and content gate
chroma, and quality score
ratio, chroma Canny thresholds, and min chroma content
fallback, and chroma content gate
useCardDetection.ts
Adaptive Canny, chroma fusion, tilt-robust gates, and quality scoringpackages/web-components/lib/components/document/src/document-auto-capture/hooks/useCardDetection.ts
gradient magnitude statistics (mean + σ·stddev, clamped to [60, 150])
luminance edge map for low-contrast borders
boundingRectwithcv.minAreaRectfortilt-invariant aspect ratio and fill ratio measurement
near-monochrome objects (keyboards, blank paper)
aspect, contour, chroma) replacing sharpness-only best-frame selection
BEST_FRAME_MISS_TOLERANCE)to avoid discarding good captures on momentary bad frames
tunable settings
DocumentCaptureInstructions.tsx
Remove unused Ref importpackages/web-components/lib/components/document/src/document-capture-instructions/DocumentCaptureInstructions.tsx
Reftype import from preact