Skip to content

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 :

PRQuoiÉtat
2aStub natif detectQuad(photoUri) qui retourne nullmergé 2026-05-12 (#152)
2bVraie pipeline OpenCV derrière detectQuadmergé 2026-05-12 (#153)
3Backend /scan-framemergé 2026-05-12 (tcg-collector-backend#148)
4awarpQuad(photoUri, corners) natif + JS wrapperPR en cours
4b5è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 :

  1. Validation du cornersMap — attendu : { topLeft: [x,y], topRight: [x,y], bottomRight: [x,y], bottomLeft: [x,y] }. Champ manquant ou mal formé → résout null.
  2. Décodage du JPEG à photoUri (file://, content://, chemin brut — même loader que detectQuad).
  3. Utils.bitmapToMat → Mat OpenCV.
  4. Imgproc.getPerspectiveTransform(srcCorners, dstCorners) où dstCorners = [(0,0), (W-1,0), (W-1,H-1), (0,H-1)], W=600, H=825.
  5. Imgproc.warpPerspective sur la matrice 3×3 obtenue.
  6. Utils.matToBitmap → Bitmap.compress(JPEG, q=85) → FileOutputStream dans reactApplicationContext.cacheDir/scan-frame-warp-<timestamp>.jpg.
  7. 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 :

  1. Module natif absent → null (vieil APK / iOS).
  2. Le natif rejette → null (exception OpenCV imprévue).
  3. 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 warpQuad en chaîne avec detectQuad. 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 ​