Skip to content

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

Merged
Barnabas A Nsoh (ayinloya) merged 193 commits into
mainfrom
feat/mobile-capture-adaptive-canny
Jun 26, 2026
Merged

feat(document): adaptive contour-Canny for low-contrast backgrounds#673
Barnabas A Nsoh (ayinloya) merged 193 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.
    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
    

    Barnabas A Nsoh (ayinloya) and others added 11 commits June 25, 2026 15:03
    …rash
    
    Rounding the downscaled ROI x and width independently could push x+width one
    pixel past the dsCanvas when the guide box sits flush against the frame edge,
    tripping the OpenCV matrix.cpp assertion
    (-215: 0 <= roi.x && roi.x + roi.width <= m.cols) and surfacing as a recurring
    "CV Errors: retrying". Clamp x/y to the canvas first, then size width/height to
    the remaining span so x+w <= cols and y+h <= rows always hold.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
    …early-out
    
    Three regression-safe detection improvements for hard backgrounds, all gated and
    one-directional (cannot newly reject a previously-working capture):
    
    - Seam-rejection clutter guard (seamMaxHoughLines, default 60): skip the
      through-line seam test when HoughLinesP floods the map (>60 lines). A woven
      fabric/carpet produces 400+ lines so every real card has edges on overshooting
      lines and was wrongly rejected; a parquet shows only a handful, so the guard
      leaves the seam test active where it works.
    
    - Clutter-adaptive Canny high-threshold floor (lowClutterEdgeDensity 2,
      cannyHighMinLowClutter 40): on a near-empty scene drop the 60 floor to 40 so a
      faint pale-card-on-pale-wood border can still form a quad. Busy/high-contrast
      scenes keep the proven 60 floor, so the working path is byte-identical.
    
    - Gate-0 grid coverage relaxed to an early-out (captureGridMinCells, default 4):
      the capture-phase 7/9 bar false-rejected low-contrast cards on plain
      backgrounds (only the printed center cells carry edges) before the contour
      pass ran. Distance is already enforced downstream by docFillPercent >=
      minFillPercent (65%); synthetic-fallback eligibility keeps its own 7/9 signal.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
    Reduce per-frame capture flicker (feedback ping-ponging between "Align" and
    "Hold Still" and the progress ring draining) on the slightest movement, and
    surface which gate is blocking capture in the tuning panel.
    
    Anti-flicker (mobile only, gated by gateDecayEnabled; desktop unchanged via the
    hook's ?? fallbacks):
    - softFailStability(): generalize the blur/glare decay-within-tolerance pattern
      to the distance / chroma-content / desktop-overflow gates and the soft Gate-0
      (coverage-flicker) case. On a transient miss the stability count decays by 1
      (within BEST_FRAME_MISS_TOLERANCE) and the displayed state holds, instead of
      zeroing the count and downgrading the state. It can only ever decrement or
      hard-reset the counter, never increment, so capture cannot fire on a
      non-compliant frame. Genuine "document gone" exits (off-guide, no-contour past
      tolerance, Gate-0 absent, lifecycle reset) still hard-reset.
    - EMA on docFillPercent (docFillEmaAlpha 0.3) + asymmetric hysteresis deadband
      (fillHysteresis 3 pts) so a hand hovering on the 65%/95% fill thresholds
      doesn't toggle the distance gate. EMA reset on every "document gone" path.
    - Route the desktop video-border color and the two desktop progress feeds
      through the already-debounced visibleComplianceState (mobile already did).
    
    Diagnostics (debug-only, gated by IS_DEBUG_MODE):
    - Every detection terminal now emits a rejectReason; the tuning panel shows a
      "Blocking check" readout (with [held] when a transient miss was absorbed) plus
      sliders for the seam/clutter/grid and anti-flicker knobs.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
    …ground
    
    When no 4-corner quad forms in luminance, segment a strongly coloured card
    (e.g. a green/yellow ID) from a near-neutral background (grey fabric) by chroma
    magnitude: threshold |a-128|+|b-128| on the Lab a/b channels, take the largest
    blob, and accept it as a real contour only if it passes the same coverage-band /
    4-vertex / fill / aspect / wall-hug gates. Gated to mobile (needs the chroma
    fusion path) behind chromaMaskFallback; tunable via chromaMaskThreshold /
    chromaMaskMinFrac / chromaMaskMaxFrac and exposed in the tuning panel with a live
    metric.
    
    KNOWN LIMITATION: a colourful rug/carpet patch is classically indistinguishable
    from a card (geometry, fill, aspect and internal-edge density all overlap) and
    can pass this path, so it can auto-capture a rug. Toggle off via the panel or
    chromaMaskFallback if it false-captures; the long-term fix for this class is ML
    segmentation.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
    Throttle the heavy CV pipeline to a configurable processing rate (default 30fps)
    to cut ~2x CPU/battery/thermal on mobile and steady the cadence:
    - Time-based gate in processFrame (performance.now delta), placed before any
      Mat/canvas/state work, so skipped ticks cost nothing and the UI holds. Chosen
      over frameCount % N because rAF runs at the display refresh (60/90/120Hz/
      adaptive), so a modulo would make the real rate — and every frame-count
      constant — device-dependent; the time gate self-corrects under thermal load.
    - targetProcessingFps tunable (SHARED_DEFAULTS 30) + panel slider (60 = off) +
      a live "Proc FPS" debug metric.
    - Light re-tune for the 2x cadence drift: docFillEmaAlpha 0.3 -> 0.45 (EMA now
      updates half as often) and DISCOVERY_TIMEOUT_FRAMES 60 -> 30 (~1s at 30fps).
    
    Also fix the guide border not turning green during the Hold-Still (STABLE)
    phase: that phase is only ~5 frames, shorter than the 150ms compliance debounce,
    so STABLE never surfaced before CAPTURING preempted it. Make the debounce
    asymmetric — snap immediately into the green states (STABLE/CAPTURING/SUCCESS),
    keep the 150ms trailing debounce only when downgrading to DETECTING/IDLE so a
    transient miss doesn't flash the border back to amber.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

    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.

    LGTM

    @ayinloya Barnabas A Nsoh (ayinloya) merged commit 3a46441 into main Jun 26, 2026
    17 checks passed
    @ayinloya Barnabas A Nsoh (ayinloya) deleted the feat/mobile-capture-adaptive-canny branch June 26, 2026 14:36
    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.

    5 participants