Appearance
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:
| Page | What |
|---|---|
| #001 | Backend /scan-frame (Fastify route) |
| #002 | Native warpQuad (client-side perspective warp) |
| #003 | 5th Frame cell in the mobile fan-out |
First on-device A/B attempt → cell always returns no-quad-client. Two successive fix PRs:
| PR | What | Outcome |
|---|---|---|
| #157 | Widen OpenCV filters (area 5%→2%, aspect [0.65,0.80]→[0.55,0.95]) + add Kotlin Log.d | Filters 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 cell | No 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:
| Key | Type | Meaning |
|---|---|---|
openCvOk | bool | OpenCV initialised cleanly |
bitmapDecoded | bool | BitmapFactory could read the URI |
bitmapWidth / bitmapHeight | int | Decoded photo dimensions |
contoursTotal | int | How many contours findContours produced |
areaPassed | int | How many cleared the 2 % image-area floor |
polyRejected | int | Of those, how many approxPolyDP rejected (≠ 4 vertices) |
convexRejected | int | Of the 4-vertex ones, how many failed isContourConvex |
aspectRejected | int | Of the convex ones, how many fell outside [0.55, 0.95] |
accepted | int | Passed every filter |
bestConfidence | double | Top 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=1Degraded modes:
| Case | Output |
|---|---|
| OpenCV failed | ocv=FAIL |
| Bitmap decode failed | ocv=ok bmp=NOT-DECODED |
| Native wrapper unavailable | diagnostic 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:
| Symptom | Likely cause | Action |
|---|---|---|
ocv=FAIL | OpenCV AAR doesn't load on this device | Check the phone's ABI vs gradle.properties reactNativeArchitectures |
bmp=NOT-DECODED | BitmapFactory.decodeFile fails on the VisionCamera URI | Suspect a content:// scheme instead of file:// |
area-passed=0 | All contours below the 2 % floor | Relax MIN_AREA_RATIO, or the card is too small in the frame |
poly=N non-zero, convex/aspect/accepted=0 | All contours rejected at approxPolyDP (≠ 4 vertices) | Relax APPROX_EPSILON (0.02 → 0.04) |
convex=N non-zero | Oblique view breaks isContourConvex | Add a near-convex recovery heuristic |
aspect=N non-zero | Oriented ratio outside [0.55, 0.95] | Widen the envelope |
accepted > 0 | Detection actually worked, we're here by bug | Bug 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-labvs/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
- #001 — Backend /scan-frame
- #002 — Mobile warpQuad
- #003 — 5th Frame cell
- Path #5 « Frame » — alternatives considered — the original design
poc-tcg-collector#158— source PR