Appearance
2. Mobile warpQuad — perspective warp natif OpenCV
Publié le 2026-05-12
Suite de Backend /scan-frame. Le backend attend un crop déjà rectifié ; cette page documente le côté client qui produit ce crop.
Contexte
Path #5 « Frame » sur mobile est livré en plusieurs PR :
| PR | Quoi | État |
|---|---|---|
| 2a | Stub natif detectQuad(photoUri) qui retourne null | mergé 2026-05-12 (#152) |
| 2b | Vraie pipeline OpenCV derrière detectQuad | mergé 2026-05-12 (#153) |
| 3 | Backend /scan-frame | mergé 2026-05-12 (tcg-collector-backend#148) |
| 4a | warpQuad(photoUri, corners) natif + JS wrapper | PR en cours |
| 4b | 5ème cellule « Frame » dans le fan-out mobile | à venir |
PR 4a fournit la pièce manquante côté natif : étant donné les coins renvoyés par detectQuad, dérouler la carte hors du JPEG capturé et écrire le résultat comme un JPEG dans le cache de l'app. La cellule Frame (PR 4b) chaînera detectQuad → warpQuad puis enverra le crop résultant à /scan-frame.
Pourquoi split en 4a / 4b
PR 4b se concentre sur le câblage de la nouvelle modalité dans le fan-out existant — 90 % du diff est du plumbing, peu de risque natif. En isolant warpQuad dans PR 4a, on fait reviewer en autonomie la surface natif (gestion mémoire, contrat d'erreur, format du fichier de sortie) avant de l'intégrer.
Pipeline natif
Le module Kotlin QuadDetectorModule (Android) gagne une nouvelle méthode @ReactMethod warpQuad(photoUri, cornersMap, promise) qui exécute :
- Validation du
cornersMap— attendu :{ topLeft: [x,y], topRight: [x,y], bottomRight: [x,y], bottomLeft: [x,y] }. Champ manquant ou mal formé → résoutnull. - Décodage du JPEG à
photoUri(file://,content://, chemin brut — même loader quedetectQuad). Utils.bitmapToMat→MatOpenCV.Imgproc.getPerspectiveTransform(srcCorners, dstCorners)oùdstCorners = [(0,0), (W-1,0), (W-1,H-1), (0,H-1)],W=600,H=825.Imgproc.warpPerspectivesur la matrice 3×3 obtenue.Utils.matToBitmap→Bitmap.compress(JPEG, q=85)→FileOutputStreamdansreactApplicationContext.cacheDir/scan-frame-warp-<timestamp>.jpg.- Résout
"file://<chemin absolu>".
Pourquoi 600 × 825
Le ratio carte Pokémon TCG est 63 × 88 mm = 0.716. La paire (600, 825) donne 0.7273, plus proche entier disponible. Le backend redimensionne ensuite à 518 × 518 avant l'embedding DINOv2 — ce qui compte ici n'est pas la résolution absolue mais l'aspect, calé pour éviter les artefacts de stretching qui dégraderaient la similarité.
Qualité JPEG 85
Empiriquement le sweet spot taille/qualité pour un crop de carte : upload ≤ 100 KB, pas d'artefacts de bloc visibles dans les chiffres de similarité A/B vs /scan-lab.
Gestion mémoire
Tous les Mat, MatOfPoint2f, transform Mat, output Bitmap sont libérés / recyclés dans le finally. Sans ça, le tas natif d'OpenCV n'est jamais récupéré par le GC de la JVM — l'appareil OOM en quelques dizaines de scans.
kotlin
} finally {
outBitmap?.recycle()
transform?.release()
srcPoints?.release()
dstPoints?.release()
warped.release()
src.release()
}Wrapper JS
src/scan/quadDetector/warpQuad.ts est une fonction asynchrone fine au-dessus de NativeModules.QuadDetector.warpQuad. Signature :
ts
export async function warpQuad(
photoUri: string,
corners: QuadCorners,
): Promise<string | null>Défensif sur trois couches :
- Module natif absent →
null(vieil APK / iOS). - Le natif rejette →
null(exception OpenCV imprévue). - Le natif résout autre chose qu'une chaîne non-vide →
null(payload mal formé suite à un futur changement natif).
Aucun as, aucun !, aucun any — pure type-guard chain. Les coins ({x, y} côté JS) sont mappés vers les paires [x, y] que le natif lit.
TDD
7 tests unitaires dans __tests__/scan/quadDetector/warpQuad.test.ts :
- Module absent
- Le natif résout
null - Le natif résout un URI fichier (cas heureux)
- Le natif résout une chaîne vide
- Le natif résout un objet non-string
- Le natif rejette
- Format de payload forwarding correct
Résultat : 331 / 331 jest + tsc --noEmit clean.
Limites connues
- Pas validé sur device : PR 4a livre la mécanique mais ne l'appelle pas encore. PR 4b ajoutera la cellule qui exerce
warpQuaden chaîne avecdetectQuad. La validation visuelle (regarder le crop résultant) viendra à ce moment. - iOS pas couvert : le projet est Android-only (CLAUDE.md). Le wrapper TS dégrade gracieusement (module absent →
null) si on porte un jour.
Pour aller plus loin
- Backend /scan-frame — endpoint pour crops pré-warpés — la moitié serveur
- Path #5 « Frame » — alternatives considérées — la décision d'origine
poc-tcg-collector#XXX— PR 4a (lien à remplacer après ouverture)