Skip to content

4. Path #5 — in-app diagnostic for the Frame cell ​

Published on 2026-05-12

Fourth page in the Path #5 « Frame » series. Current state: the Frame cell is shipped, but it fails systematically with no-quad-client on the user's real captures. This page documents the diagnostic mechanism we put in place inside the app itself to understand why, without relying on adb logcat.

Context ​

Three previous pages cover the Path #5 delivery:

PageWhat
#001Backend /scan-frame (Fastify route)
#002Native warpQuad (client-side perspective warp)
#0035th Frame cell in the mobile fan-out

First on-device A/B attempt → cell always returns no-quad-client. Two successive fix PRs:

PRWhatOutcome
#157Widen OpenCV filters (area 5%→2%, aspect [0.65,0.80]→[0.55,0.95]) + add Kotlin Log.dFilters widened but not enough. The Log.d lines are invisible on the user's workflow (no adb).
#158 (this page)Dedicated native method that always returns the funnel stats; the message lands directly in the cellNo more guessing — the user sees the exact cause in the app.

The problem: the user has no adb logcat ​

The user's normal workflow: build the APK on a Windows PC, install on an Android phone over USB, then unplug and scan from the phone alone. logcat is not accessible in that flow.

Consequence: the Log.d(TAG, "...") lines we added in PR #157 land in the phone's logcat buffer, but nobody ever reads them. For diag we needed the stats to appear directly in the UI.

The solution: detectQuadDiagnostic ​

New Kotlin native method that runs the exact same pipeline as detectQuad but always resolves an object with per-stage funnel counters — even when nothing passes.

kotlin
@ReactMethod
fun detectQuadDiagnostic(photoUri: String, promise: Promise) {
  val result = Arguments.createMap()
  result.putBoolean("openCvOk", OpenCVLoader.initDebug())
  // … bitmap decode, OpenCV pipeline, per-stage counting …
  promise.resolve(result)
}

Fields:

KeyTypeMeaning
openCvOkboolOpenCV initialised cleanly
bitmapDecodedboolBitmapFactory could read the URI
bitmapWidth / bitmapHeightintDecoded photo dimensions
contoursTotalintHow many contours findContours produced
areaPassedintHow many cleared the 2 % image-area floor
polyRejectedintOf those, how many approxPolyDP rejected (≠ 4 vertices)
convexRejectedintOf the 4-vertex ones, how many failed isContourConvex
aspectRejectedintOf the convex ones, how many fell outside [0.55, 0.95]
acceptedintPassed every filter
bestConfidencedoubleTop score among accepted candidates

Internal refactor: pickBestCardQuad now returns PickResult(best, FunnelStats) instead of CardQuad?, shared between detectQuad (takes .best) and detectQuadDiagnostic (serialises .stats).

The formatQuadDiagnostic helper ​

On the JS side, a pure formatter produces a one-liner that fits the cell's numberOfLines={3} block:

ocv=ok bmp=1920x1080 contours=213 area-passed=4 rej(poly=3,convex=0,aspect=0) accepted=1

Degraded modes:

CaseOutput
OpenCV failedocv=FAIL
Bitmap decode failedocv=ok bmp=NOT-DECODED
Native wrapper unavailablediagnostic unavailable

Fan-out wiring ​

The mode === 'frame' branch in ScannerScreen.tsx:

ts
if (mode === 'frame') {
  const warpedPath = await detectAndWarp(captured.path);
  if (warpedPath === null) {
    const diagnostic = await detectQuadDiagnostic(captured.path);
    return {
      mode,
      result: {
        ok: false,
        reason: 'no-quad-client',
        message: formatQuadDiagnostic(diagnostic),
      },
    };
  }
  // … else, normal POST to /scan-frame …
}

The cell renderer (MultiResultRow) already displayed result.message on failure via:

jsx
<Text style={[styles.debugLine, { color: '#ff9999' }]} numberOfLines={3}>
  {result.message}
</Text>

No new components were created — we just swapped a generic message for a rich diagnostic one.

Reading the output ​

On device, after a scan, open Scan Debug and look at the FAIL line on the Frame cell. The message tells you exactly which funnel stage rejected your card contour:

SymptomLikely causeAction
ocv=FAILOpenCV AAR doesn't load on this deviceCheck the phone's ABI vs gradle.properties reactNativeArchitectures
bmp=NOT-DECODEDBitmapFactory.decodeFile fails on the VisionCamera URISuspect a content:// scheme instead of file://
area-passed=0All contours below the 2 % floorRelax MIN_AREA_RATIO, or the card is too small in the frame
poly=N non-zero, convex/aspect/accepted=0All contours rejected at approxPolyDP (≠ 4 vertices)Relax APPROX_EPSILON (0.02 → 0.04)
convex=N non-zeroOblique view breaks isContourConvexAdd a near-convex recovery heuristic
aspect=N non-zeroOriented ratio outside [0.55, 0.95]Widen the envelope
accepted > 0Detection actually worked, we're here by bugBug in the detectAndWarp wiring

Project state ​

At the end of this page:

  • Code: the 5th Frame cell is shipped and instrumented. Filters were widened once (#157). The in-app diagnostic now lets us see why detection fails on real captures, without adb.
  • Measurement: not yet. The cell still fails, but the next iteration is data-driven: the user reads the funnel in the app, shares it, we know exactly which constant to tune.
  • A/B /scan-lab vs /scan-frame: not yet evaluable — the Frame cell needs to match something first. That's the original goal of Path #5, still pending real numbers.

Further reading ​