Skip to content

feat(document): adaptive contour-Canny for low-contrast backgrounds#673

Open
Barnabas A Nsoh (ayinloya) wants to merge 183 commits into
mainfrom
feat/mobile-capture-adaptive-canny
Open

feat(document): adaptive contour-Canny for low-contrast backgrounds#673
Barnabas A Nsoh (ayinloya) wants to merge 183 commits into
mainfrom
feat/mobile-capture-adaptive-canny

Conversation

@ayinloya

@ayinloya Barnabas A Nsoh (ayinloya) commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator

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:

  • Compute Sobel gradient magnitude over the (full-res, guide-box-cropped) blurred image → mean, stddev.
  • high = clamp(mean + autoCannySigma·stddev, 60, 150), low = max(15, 0.4·high).
  • cv.Canny keeps 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

  • New autoCannySigma setting (default 1.0, mobile + desktop).
  • TuningPanel: Edge Sensitivity (σ) slider + live Canny (lo/hi) metric (via the existing mergeDebugInfo helper) so the resolved thresholds are visible per surface.

Files

  • hooks/useCardDetection.tsCANNY_HIGH_MAX/MIN constants; adaptive Canny in the contour pass.
  • DocumentAutoCapture.tsxautoCannySigma default.
  • components/TuningPanel.tsx — slider + metric.

Testing

  • type-check, ESLint, Prettier all clean.
  • ⚠️ Needs on-device validation (the real test): the [60,150] band and σ=1.0 are 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 into getOptimalDefaults before this leaves draft.

Base is document-capture-new-flow (not main) — this builds on the new capture flow.

🤖 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.minAreaRect instead of axis-aligned bbox

  • Composite quality scoring, chroma-content gate, and transient-miss tolerance


Diagram Walkthrough

flowchart LR
  A["Frame input"] -- "Sobel gradient stats" --> B["Adaptive Canny thresholds"]
  B -- "Luminance edges" --> C["Edge map"]
  D["Lab a/b channels"] -- "Chroma Canny" --> E["Chroma edges"]
  E -- "bitwise_or" --> C
  C -- "findContours" --> F["minAreaRect shape gates"]
  F -- "Winner geometry" --> G["Chroma content gate"]
  G -- "Composite quality score" --> H["Best frame selection"]
Loading

File Walkthrough

Relevant files
Enhancement
DocumentAutoCapture.tsx
Refactor settings into shared defaults with device overrides

packages/web-components/lib/components/document/src/document-auto-capture/DocumentAutoCapture.tsx

  • Extracted shared settings (SHARED_DEFAULTS) and per-device overrides
    (MOBILE_OVERRIDES, DESKTOP_OVERRIDES) into named constants
  • Added new settings: autoCannySigma, chromaEdgeFusion,
    chromaCannyLow/High, mobileRegionFallback, idAspectTolerance,
    bookDocAspectTolerance, minFillRatio, chromaContentGate,
    minChromaContent
  • Simplified getOptimalDefaults() to spread shared + device-specific
    overrides
+89/-38 
TuningPanel.tsx
Extend tuning panel with adaptive Canny and chroma controls

packages/web-components/lib/components/document/src/document-auto-capture/components/TuningPanel.tsx

  • Added new tuning interface fields for adaptive Canny, chroma fusion,
    aspect tolerance, fill ratio, and content gate
  • Added debug info display for Canny thresholds, edge source, aspect,
    chroma, and quality score
  • Added UI sliders for edge sensitivity (σ), aspect tolerances, min fill
    ratio, chroma Canny thresholds, and min chroma content
  • Added toggle checkboxes for chroma edge fusion, mobile region
    fallback, and chroma content gate
+182/-0 
useCardDetection.ts
Adaptive Canny, chroma fusion, tilt-robust gates, and quality scoring

packages/web-components/lib/components/document/src/document-auto-capture/hooks/useCardDetection.ts

  • Implemented adaptive Canny thresholds derived from per-frame Sobel
    gradient magnitude statistics (mean + σ·stddev, clamped to [60, 150])
  • Added chroma edge fusion: Lab a/b channel Canny edges OR'd into the
    luminance edge map for low-contrast borders
  • Replaced axis-aligned boundingRect with cv.minAreaRect for
    tilt-invariant aspect ratio and fill ratio measurement
  • Added chroma-content gate with rolling average to reject
    near-monochrome objects (keyboards, blank paper)
  • Added mobile content-region fallback with stability frame counter
  • Implemented composite per-frame quality score (sharpness, glare, fill,
    aspect, contour, chroma) replacing sharpness-only best-frame selection
  • Added transient blur/glare miss tolerance (BEST_FRAME_MISS_TOLERANCE)
    to avoid discarding good captures on momentary bad frames
  • Tightened aspect tolerance windows (id-card ±12%, passport ±10%) using
    tunable settings
+536/-47
Formatting
DocumentCaptureInstructions.tsx
Remove unused Ref import                                                                 

packages/web-components/lib/components/document/src/document-capture-instructions/DocumentCaptureInstructions.tsx

  • Removed unused Ref type import from preact
+1/-1     


Need help?
  • Type /help how to ... in the comments thread for any questions about PR-Agent usage.
  • Check out the documentation for more information.
  • …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>
    @ayinloya Barnabas A Nsoh (ayinloya) marked this pull request as ready for review June 22, 2026 10:55
    @prfectionist

    prfectionist Bot commented Jun 22, 2026

    Copy link
    Copy Markdown
    Contributor

    Persistent review updated to latest commit c8c804d

    Comment on lines +1081 to +1147
    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';
    }

    Copy link
    Copy Markdown
    Contributor

    Choose a reason for hiding this comment

    The reason will be displayed to describe this comment to others. Learn more.

    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]

    Suggested change
    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 {} }
    }
    }

    Comment on lines +1042 to +1057
    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();

    Copy link
    Copy Markdown
    Contributor

    Choose a reason for hiding this comment

    The reason will be displayed to describe this comment to others. Learn more.

    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]

    Suggested change
    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;

    Comment on lines +1667 to +1671
    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);

    Copy link
    Copy Markdown
    Contributor

    Choose a reason for hiding this comment

    The reason will be displayed to describe this comment to others. Learn more.

    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]

    Suggested change
    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 = [];

    Copy link
    Copy Markdown
    Contributor

    Choose a reason for hiding this comment

    The reason will be displayed to describe this comment to others. Learn more.

    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]

    Suggested change
    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.
    @prfectionist

    prfectionist Bot commented Jun 22, 2026

    Copy link
    Copy Markdown
    Contributor

    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>
    Base automatically changed from document-capture-new-flow to main June 23, 2026 10:06
    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.
    @ayinloya

    Copy link
    Copy Markdown
    Collaborator Author

    This branch has been deployed to s3 / cloudfront.

    ✅ Preview URL for Smart Camera Web:

    https://cdn.smileidentity.com/js/preview-feat/mobile-capture-adaptive-canny/smart-camera-web.js
    

    ✅ Preview URL for Embed:

    https://cdn.smileidentity.com/inline/preview-feat/mobile-capture-adaptive-canny/js/script.min.js
    

    ✅ Preview URL for Web Client (Sandbox):

    https://d3qr3ogefp3sxy.cloudfront.net
    

    ✅ Preview URL for Web Client (Production):

    https://d2zrugva4pgdqs.cloudfront.net
    

    Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

    Projects

    None yet

    Development

    Successfully merging this pull request may close these issues.

    3 participants