Appearance
2. Mobile warpQuad — native OpenCV perspective warp
Published on 2026-05-12
Companion to Backend /scan-frame. The backend expects an already-rectified crop; this page documents the client-side piece that produces it.
Context
Path #5 « Frame » on mobile ships across several PRs:
| PR | What | Status |
|---|---|---|
| 2a | Native detectQuad(photoUri) stub returning null | merged 2026-05-12 (#152) |
| 2b | Real OpenCV pipeline behind detectQuad | merged 2026-05-12 (#153) |
| 3 | Backend /scan-frame | merged 2026-05-12 (tcg-collector-backend#148) |
| 4a | warpQuad(photoUri, corners) native + JS wrapper | this PR |
| 4b | 5th "Frame" cell in the mobile fan-out | upcoming |
PR 4a fills the remaining native gap: given the corners returned by detectQuad, warp the card out of the captured JPEG and save the result as a JPEG in the app cache. The Frame cell (PR 4b) will chain detectQuad → warpQuad and upload the resulting crop to /scan-frame.
Why split 4a / 4b
PR 4b focuses on wiring the new mode into the existing fan-out machinery — 90 % of the diff is plumbing, low native risk. Isolating warpQuad in PR 4a lets the native surface (memory hygiene, error contract, output file format) get reviewed on its own before integration.
Native pipeline
The Kotlin module QuadDetectorModule (Android) gains a new @ReactMethod warpQuad(photoUri, cornersMap, promise) that runs:
- Validate
cornersMapshape — expects{ topLeft: [x,y], topRight: [x,y], bottomRight: [x,y], bottomLeft: [x,y] }. Missing or malformed field → resolvenull. - Decode the JPEG at
photoUri(file://,content://, bare path — same loader asdetectQuad). Utils.bitmapToMat→ OpenCVMat.Imgproc.getPerspectiveTransform(srcCorners, dstCorners)withdstCorners = [(0,0), (W-1,0), (W-1,H-1), (0,H-1)],W=600,H=825.Imgproc.warpPerspectiveusing the 3×3 matrix.Utils.matToBitmap→Bitmap.compress(JPEG, q=85)→FileOutputStreamintoreactApplicationContext.cacheDir/scan-frame-warp-<timestamp>.jpg.- Resolve
"file://<absolute path>".
Why 600 × 825
Pokémon TCG card ratio is 63 × 88 mm = 0.716. The pair (600, 825) gives 0.7273, the closest integer-sized canvas. The backend resizes to 518 × 518 before DINOv2 embedding — what matters here is the aspect ratio, sized to avoid stretching artefacts that would degrade similarity scores.
JPEG quality 85
Empirical sweet spot for card crops: upload ≤ 100 KB, no visible block artefacts in the A/B similarity numbers vs /scan-lab.
Memory hygiene
Every Mat, MatOfPoint2f, transform Mat, output Bitmap is released/recycled in finally. Otherwise the OpenCV native heap is never reclaimed by the JVM GC — the device OOMs after a few dozen scans.
kotlin
} finally {
outBitmap?.recycle()
transform?.release()
srcPoints?.release()
dstPoints?.release()
warped.release()
src.release()
}JS wrapper
src/scan/quadDetector/warpQuad.ts is a thin async function over NativeModules.QuadDetector.warpQuad. Signature:
ts
export async function warpQuad(
photoUri: string,
corners: QuadCorners,
): Promise<string | null>Defensive on three layers:
- Native module absent →
null(older APK / iOS). - Native rejection →
null(unexpected OpenCV throwable). - Native resolves anything that isn't a non-empty string →
null(malformed payload from a future native change).
No as, no !, no any — pure type-guard chain. Corners ({x, y} on the JS side) are mapped to the [x, y] pairs the native side reads.
TDD
7 unit tests in __tests__/scan/quadDetector/warpQuad.test.ts:
- Module absent
- Native resolves
null - Native resolves a file URI (happy path)
- Native resolves an empty string
- Native resolves a non-string object
- Native rejects
- Payload forwarding shape
Result: 331 / 331 jest + tsc --noEmit clean.
Known limitations
- Not validated on device yet: PR 4a ships the mechanism but nothing calls it yet. PR 4b will add the cell that exercises
warpQuadchained withdetectQuad. Visual validation (look at the resulting crop) lands then. - iOS not covered: the project is Android-only (CLAUDE.md). The TS wrapper degrades gracefully (module absent →
null) if iOS is ever ported.
Further reading
- Backend /scan-frame — endpoint for client-side-warped crops — the server-side half
- Path #5 « Frame » — alternatives considered — the original decision
poc-tcg-collector#XXX— PR 4a (link to be replaced after opening)