Skip to content

3. Mobile — 5ème cellule « Frame » dans le fan-out ​

Publié le 2026-05-12

Aboutissement de Path #5 « Frame » côté mobile. Première fois aujourd'hui qu'un utilisateur voit un changement concret dans l'app.

Avant / après ​

Avant aujourd'huiÀ partir d'aujourd'hui
Capture1 photo1 photo
Backends interrogés4 (Lab / Compat / Tormknd / Combined)5 (+ Frame)
Cellules affichées45
Pre-processing côté clientaucundetectQuad + warpQuad pour la cellule Frame

Comment marche la cellule Frame ​

L'écran Scanner fait toujours Promise.allSettled sur tous les modes. La nouveauté : pour le mode 'frame', le map callback fait un détour avant l'envoi réseau.

captured.path  ──▶  detectQuad(uri)
                       │
                ┌──────┴──────┐
                ▼             ▼
              null         { corners, confidence, ts }
                │             │
                │             ▼
                │        warpQuad(uri, corners)
                │             │
                │      ┌──────┴──────┐
                │      ▼             ▼
                │    null          "file:///cache/scan-frame-warp-<ts>.jpg"
                │      │             │
                ▼      ▼             ▼
       { ok: false, reason: 'no-quad-client' }   scanViaBackend({ path: warped, … },
                                                                 { endpointMode: 'frame',
                                                                   url: /scan-frame })

Pourquoi pas de fallback sur la photo brute ​

Quand detectAndWarp rend null (carte non détectée ou warp KO), on n'envoie rien à /scan-frame. Une cellule vide étiquetée no-quad-client apparaît à la place.

Raison : si on retombait sur la photo brute, on perdrait la comparaison A/B nette. On veut savoir précisément quand le warp client améliore le matching et quand il rate. Une cellule qui se rabat silencieusement biaise les chiffres.

Les 4 autres cellules continuent de partir avec la photo brute en parallèle, donc l'utilisateur a toujours un résultat (sauf si tous les backends sont en panne — auquel cas le fallback on-device existant prend le relais).

Implémentation ​

Nouveau type de failure ​

ts
export type BackendScanFailure = {
  ok: false;
  reason:
    | 'network'
    | 'timeout'
    | 'http-4xx'
    | 'http-5xx'
    | 'malformed-response'
    | 'aborted'
    | 'no-quad-client';  // ← nouveau
  // …
};

Distinct de 'malformed-response' (le serveur a renvoyé du JSON imbuvable) et d'un ok: true, match: null (le serveur a regardé l'embedding et n'a rien matché). Les trois cas ont des correctifs différents — il est précieux de les distinguer côté UI debug.

Nouvel orchestrateur ​

src/scan/quadDetector/detectAndWarp.ts :

ts
export async function detectAndWarp(photoUri: string): Promise<string | null> {
  const detection = await detectQuad(photoUri);
  if (detection === null) return null;
  return warpQuad(photoUri, detection.corners);
}

Pas de try/catch — les deux wrappers absorbent déjà leurs propres erreurs en null (pattern défensif documenté en PR 2a / 4a).

Ordre dans le fan-out ​

ts
const SCAN_MODES: readonly ScanEndpointMode[] = [
  'lab', 'compat', 'tormknd', 'combined', 'frame',
];

Les 4 cellules « photo brute » d'abord, la cellule « warp client » en dernier. Comme ça l'A/B est visuel : sur une même capture, on lit ligne par ligne ce que chaque backend a fait de la photo brute, puis ce que /scan-frame a fait du crop rectifié.

TDD ​

  • 4 nouveaux tests pour detectAndWarp (detectQuad null skip, warpQuad null bubble, happy path, payload forwarding).
  • backendConfig.test.ts étendu pour asserter l'URL + le port du mode 'frame'.
  • scanDebugFormat.test.ts étendu pour asserter le label 'Frame'.

335 / 335 jest, tsc --noEmit clean.

Ce que tu verras sur device ​

  1. Capture une carte → 5 cellules apparaissent dans le summary, ordonnées Lab → Compat → Tormknd → Combined → Frame.

  2. Cellule Frame réussie → similarité affichée, image de la carte matchée à côté. Devrait être ≥ celle de /scan-lab si le crop client est correct.

  3. Cellule Frame en échec → label "no-quad-client". Causes typiques :

    • Bord de carte mal détecté (contraste insuffisant)
    • Carte trop près d'un bord du cadre photo
    • Bougé / flou de mouvement
    • Angle trop oblique (au-delà de ~25-30°)

    Les 4 autres cellules continuent de fonctionner normalement dans tous ces cas.

  4. Scan Debug affiche désormais 5 lignes par capture, groupées par inputPhotoPath.

Métrique à surveiller ​

Le ratio « Frame matche le même card_id que Lab » sur les vraies photos de terrain. C'est l'indicateur direct de :

  • la qualité de la détection OpenCV côté client (detectQuad)
  • la qualité du warp perspective (warpQuad)
  • la robustesse de DINOv2 aux variations de crop

Si le ratio est élevé, on peut envisager de retirer le détecteur de bords côté serveur (detectCardBounds dans _detect_quad_in_edges) sur les autres routes — économie de CPU non négligeable sur la backend.

Pour aller plus loin ​