Skip to content

Comparaison des paths de scan — Lab / Compat / Tormknd / Combined ​

L'app mobile envoie chaque capture en parallèle vers les quatre paths raw-photo ci-dessous, plus la cellule Path #5 Frame documentée dans la série scan-frame. Les résultats reviennent côte-à-côte sur l'écran Scanner ainsi que dans l'historique Scan Debug. Ce document détaille les forces, faiblesses, et différences des quatre paths raw-photo.

Référence technique canonique : la matrice opérationnelle complète vit dans le repo backend → tcg-collector-backend/docs/scanner/SCAN_PATHS.md. Le présent document est le résumé orienté comparaison.

TL;DR ​

  • Lab : notre chemin principal (Node + pgvector + HNSW). 5 TCGs couverts, le plus rapide, le plus à jour côté pré-traitement. Mono-signal embedding.
  • Compat : un fork de tgc-app qu'on a modifié et embarqué dans la stack. Couverture limitée (Pokémon + One Piece + un peu de Lorcana). Existe pour rollback rapide et bisect de régression. Mono-signal embedding.
  • Tormknd : le code non modifié du tgc-app du pote (Tormknd). Référence A/B pour vérifier si notre fork a aidé ou cassé quelque chose. Mono-signal embedding.
  • Combined : l'endpoint /scan historique du Node API. Multi-signal : combine pHash + OCR Tesseract + DINOv2 dans un decideMatch unifié. Plus strict — peut retourner no-match quand les trois autres voient une carte.

Mono-signal vs multi-signal — pourquoi c'est important ​

Un signal est une façon de regarder la carte. Chaque signal a ses forces et ses angles morts.

SignalC'est quoiForceFaiblesse
pHash (perceptual hash)Hash 64-bit qui résume la structure visuelle globaleUltra-rapide (< 1 ms), excellent pour les duplicatesCassé par l'angle, le flash, le crop, les variations de couleur
OCR (Tesseract)Lit le texte imprimé : code de set + numéro collector + HP + nomQuasi-infaillible quand ça lit bien (les chiffres ne mentent pas)Échoue sur photos floues / mal orientées / cartes étrangères
Embedding (DINOv2)Vecteur 384-dim d'un réseau de neurones, mesure la similarité visuelle appriseRobuste à l'angle / flash / cropPlafond de précision sur cartes visuellement proches (ex : Pawmi vs Panpour)

Mono-signal (Lab / Compat / Tormknd) ne s'appuie que sur l'embedding DINOv2. Plus indulgent mais peut accepter une carte sur la base d'une similarité visuelle seule, donc plus à risque de faux-positif sur des cartes au layout proche.

Multi-signal (Combined) demande un consensus entre plusieurs signaux dans decideMatch :

  1. Si pHash trouve une copie quasi-exacte → match immédiat (haute confiance, c'est la même image).
  2. Sinon, si OCR donne un set + collector qui matche un seul candidat dans la DB → match (haute confiance, l'identifiant imprimé tranche).
  3. Sinon, l'embedding propose le top-1 mais doit dépasser un seuil ET ne pas avoir de runner-up trop proche → match.
  4. Sinon → no-match reason low-confidence.

C'est pour ça que Combined peut rejeter une carte que les trois mono-signal acceptent : si l'embedding est confiant mais ni pHash ni OCR ne confirment, le decideMatch se méfie. Voir l'exemple sv01-003 plus bas.

Tableau comparatif ​

Scan LabScan CompatScan TormkndScan Combined
Endpoint/scan-lab/scan-compat/scan/scan
Port8080800080028080
Containertcg-collector-api-1tcg-vision-apitormknd-apitcg-collector-api-1
Démarrage par défaut✅ oui✅ oui❌ non (profile --profile tormknd)✅ oui
CodeNode + Fastify (à NOUS)Python — fork divergé de tgc-appPython — tgc-app du pote, non modifiéNode + Fastify (à NOUS)
Type de pipelineMono-signal embeddingMono-signal embeddingMono-signal embeddingMulti-signal : pHash + OCR + embedding
Vector storePostgreSQL + pgvector HNSWFAISS IndexFlatIP (in-memory)FAISS IndexFlatIP (in-memory)PostgreSQL + pgvector (réutilise le store de Lab)
Distancecosinus (embedding <=> $1::vector)inner product (cosine via L2-norm)inner product (cosine via L2-norm)cosinus + Hamming pHash + match exact OCR
EmbeddingsDINOv2 ViT-S/14 (384-d)DINOv2 ViT-S/14 (384-d)DINOv2 ViT-S/14 (384-d)DINOv2 ViT-S/14 (384-d)
Source de l'indextable card_embeddings_lab, peuplée par embedCatalogLab.tsservices/tcg-vision/data/faiss_index.bintgc-app/data/faiss_index.bincard_embeddings_lab (depuis 2026-05-10) + cards.phash + cards.printed_*
Cardinalité143 452 vecteurs (5 TCGs)~30 370 vecteurs~30 370 vecteurs (même dataset upstream)~143 k cartes avec pHash + métadonnées
TCGs couvertsPokémon, One Piece, Magic, Lorcana, FABPokémon (FR/EN/JA), One Piece, Lorcanaidentique à CompatPokémon, One Piece, Magic, Lorcana, FAB
Filtre pre-ANN par TCG/set/langue✅ JOIN SQL natif❌ filtre post-fetch en Python❌ filtre post-fetch en Python✅ JOIN SQL natif
Métadonnées riches dans la réponse✅ JOIN SQL vers cards✅ adapter qui rappelle notre api pour les non-Pokémon❌ réponse brute du upstream✅ JOIN SQL vers cards + signaux OCR/pHash exposés
Comportement sur photo ambiguëaccepte si embedding > seuilaccepte si embedding > seuilaccepte si embedding > seuilrejette si pHash/OCR ne confirment pas

Scan Lab ​

Ce que c'est ​

Notre chemin maison. Quand tu prends une photo, l'API Node :

  1. POST l'image au sidecar embeddings (Python) → reçoit un vecteur 384-d DINOv2.
  2. Fait un SELECT … ORDER BY embedding <=> $1::vector LIMIT 5 filtré par TCG/set si demandé.
  3. Re-rank les top-5 et retourne un JSON enrichi (TCGdex metadata, prix Cardmarket, etc.).

Forces ​

  • Couverture la plus large : 5 TCGs (Pokémon + OP + Magic + Lorcana + FAB), 143 452 vecteurs.
  • Pre-filter SQL : on peut scoper la recherche à tcg=pokemon AND lang=fr avant l'ANN, ce que FAISS ne sait pas faire nativement.
  • Index reproductible : tu rebuilds avec embedCatalogLab.ts FORCE=true, idempotent, transactionnel.
  • Métadonnées toujours fraîches : JOIN SQL vers cards → image, rareté, prix, etc.
  • Le plus rapide côté serveur : ~30-60 ms.

Faiblesses ​

  • Dépend de Postgres + pgvector. Si le DB tombe, le path est mort.
  • Indexé seulement avec ce qu'on a embed : si une nouvelle carte est ingérée mais pas encore embed, elle n'apparaîtra jamais dans les résultats jusqu'au prochain re-embed.
  • HNSW est approximatif : top-1 pas toujours = best exact match. Recall très bon mais pas garanti à 100 % (à 144 k vecteurs c'est négligeable en pratique).
  • Mono-signal : l'embedding seul peut se faire piéger sur des cartes visuellement proches.

Scan Compat ​

Ce que c'est ​

Un fork du tgc-app du pote, qu'on a embarqué dans notre Docker stack et qu'on a fait diverger. La sidecar tourne en Python (FastAPI) et charge en RAM un fichier faiss_index.bin pré-construit.

Forces ​

  • Code éprouvé : c'est l'historique avant le flip vers Lab le 2026-05-07. Stable.
  • Zéro dépendance Postgres : si pgvector pète, ce path continue de servir des résultats.
  • Index figé : reproductible, pas de "qu'est-ce qui a changé entre deux scans ?".
  • Bonus métadonnées : on a ajouté un adapter qui rappelle notre API pour enrichir les non-Pokémon (One Piece notamment) depuis notre Postgres.

Faiblesses ​

  • Couverture limitée : seulement Pokémon (FR/EN/JA) + One Piece + un peu de Lorcana. Pas de Magic, pas de Flesh and Blood. Une scan MTG retournera un faux positif Pokémon.
  • .bin figé dans le temps : reflète le pipeline de pré-traitement au moment de la dernière reconstruction. Le warp-fix #135 (2026-05-08) a été porté côté code via #138, donc actif au scan-time, mais le bin lui-même n'a pas été reconstruit (pas nécessaire — build_index.py n'appelle pas correct_perspective).
  • Pas de filtre SQL pre-ANN : les filtres TCG/set se font après coup en Python, plus lent et moins précis que JOIN SQL.
  • Mono-signal : même limite que Lab côté faux-positifs visuels.
  • Divergence du fork : on a ajouté trois choses par rapport à Tormknd vanilla :
    1. Adapter /scan-compat qui re-shape la réponse pour matcher le contrat de /scan-lab.
    2. LOCAL_IMAGE_BASE_URL qui réécrit les URLs d'images vers notre nginx au lieu du CDN TCGdex.
    3. BACKEND_API_URL qui appelle notre API pour enrichir les TCGs non-Pokémon (sinon retourne juste un card_id).

Scan Tormknd ​

Ce que c'est ​

Le tgc-app du pote (Tormknd), buildé tel quel depuis ../../tgc-app/ (la copie locale du repo). Code non modifié, index original, mapping original.

Forces ​

  • Référence A/B parfaite : c'est exactement ce que l'app du pote retournerait. Si tu vois un meilleur résultat sur Compat → notre fork a amélioré. Si pire → on a cassé. Si Tormknd retourne pareil que Compat → la divergence du fork n'est pas la cause d'un éventuel comportement différent.
  • Aucune contamination de notre stack : pas de Postgres, pas de notre warp-fix, pas de notre re-embed, pas de notre nginx. C'est le code et les données du pote, point.
  • Reproductible côté pote : si lui rebuild son index avec un nouveau backbone ou une nouvelle correction, on intègre en re-checkout son repo et docker compose build tormknd.

Faiblesses ​

  • Off par défaut : gated derrière le profile --profile tormknd pour ne pas alourdir le up de tous les jours (premier build = 5-10 min : torch hub + faiss-cpu install).
  • Couverture limitée : identique à Compat (Pokémon + OP + Lorcana). Pas de MTG, pas de FAB.
  • Aucune métadonnée enrichie : la réponse est ce que le pote a décidé de retourner. Pas de prix, pas de rareté, pas d'image rewriter.
  • Mono-signal : seul l'embedding pilote la décision côté upstream.
  • Évolue indépendamment de notre stack : si lui pousse un fix dans tgc-app, faut faire un git pull côté local + rebuild du container pour le récupérer.

Scan Combined ​

Ce que c'est ​

L'endpoint /scan historique sur le même Node API que Lab (port 8080). Avant le flip vers Lab le 2026-05-07, c'était le path par défaut de l'app mobile. Il combine plusieurs signaux indépendants à travers une fonction decideMatch unifiée :

  1. pHash (perceptual hash 64-bit) calculé sur la photo, comparé en distance de Hamming aux pHashes pré-calculés des cartes du catalogue. Si une carte indexée est en dessous d'un seuil de Hamming → match immédiat (la photo et l'image catalogue sont quasi-identiques).
  2. OCR Tesseract lit les zones de texte connues : numéro collector (en bas à droite), code de set (en haut à droite ou à gauche selon le TCG), nom, HP. Si l'OCR retourne un set + collector qui matche un seul card_id dans la DB → match (haute confiance, l'identifiant imprimé tranche).
  3. Embedding DINOv2 propose son top-1 + runner-up. Pour qu'il soit accepté, il faut que la similarité dépasse un seuil ET que le runner-up soit suffisamment loin (sinon ambigu).
  4. Si aucun signal ne franchit son seuil → no-match avec reason: "low-confidence".

Forces ​

  • Le plus précis sur les cas faciles : un duplicate exact ou une photo lisible passe par pHash ou OCR avec confiance ~1.0. Plus rapide ET plus précis que l'embedding seul.
  • Le plus robuste sur les cas piégeux : sur deux Pokémon visuellement proches (Pawmi vs Panpour), l'embedding seul peut se tromper, mais l'OCR du numéro collector tranche.
  • Couverture identique à Lab : 5 TCGs, 143 452 vecteurs. Depuis 2026-05-10, Combined consulte la même table card_embeddings_lab que Lab — donc les deux paths voient strictement les mêmes vecteurs, y compris le re-embed warp-fix #135. Combined ajoute par-dessus les colonnes cards.phash et cards.printed_* pour les signaux pHash et OCR.
  • Signaux exposés dans la réponse : match.phash, ocr.collectorNumber, ocr.totalNumber, ocr.hp, embedding.cardId, embedding.similarity, runnerUp. C'est le seul path qui te donne tout ce qu'il a vu, signal par signal.
  • Idéal pour le debug : une cellule Combined qui retourne no-match alors que Lab/Compat/Tormknd matchent te dit que les signaux non-visuels désaccordent — souvent un signal que la photo elle-même est mauvaise.

Faiblesses ​

  • Le plus strict : peut retourner no-match sur des photos où les trois mono-signal voient une carte. La cascade decideMatch exige qu'au moins un signal franchisse son seuil ; si pHash (distance Hamming), OCR (pas de match exact set+collector), et embedding (similarité sous le seuil) ratent tous, on tombe en low-confidence.
  • Plus lent que Lab seul : Tesseract OCR coûte ~150-300 ms côté CPU, même quand pHash a déjà tranché. Optimisation possible (early-return) pas implémentée.
  • Tuning des seuils pHash encore à faire : le smoke post-fix 2026-05-10 a révélé des cas où pHash court-circuite la cascade avec une similarité ~0.75 vers une mauvaise carte (typiquement un voisin du même set), bloquant le vote de l'embedding. La cascade est "premier match gagne" ; une migration future vers un rerank pondéré (top-50 ANN → score fusionné sur les signaux) laisserait l'embedding corriger les misfires de pHash.
  • Dépend de Postgres + pgvector comme Lab.

Note historique — le câblage cassé d'avant 2026-05-10 ​

Avant la 2026-05-10, /scan (Combined) lisait depuis une table séparée card_embeddings (legacy, peuplée par embedCatalog.ts — distinct de embedCatalogLab.ts). Le smoke du 4-bouton fan-out a révélé que cette table était vide (0 ligne) — l'ingest job legacy n'avait pas tourné depuis longtemps, alors que embedCatalogLab.ts l'avait remplacé pour la phase A/B Lab. Conséquence : Combined fonctionnait sans signal embedding du tout — il faisait pHash + OCR uniquement et retournait low-confidence sur la plupart des photos.

Fix 1-line dans src/api/src/scan/embeddings.ts : pointer la query vers card_embeddings_lab. Pas besoin de re-embed (la table cible était déjà peuplée), pas besoin d'attente. La table legacy card_embeddings reste en schema mais inutilisée — peut être droppée dans une migration séparée.

J'avais d'abord écrit dans cette doc que Combined utilisait des "vecteurs pré-warp-fix" → c'était inexact. Combined utilisait des vecteurs inexistants. La correction est appliquée maintenant, Combined consulte les mêmes vecteurs que Lab.


Quel path utiliser quand ​

Cas d'usagePath recommandé
Scan en production / app utilisateur finalLab (couverture maximum, plus rapide) ou Combined (plus strict, moins de faux-positifs)
Vérifier qu'une carte n'a pas régressé après un re-embedLab + comparer avec Compat
Bisect d'une régression spécifique au forkCompat vs Tormknd côte-à-côte
Tester une carte Magic, Lorcana ou Flesh and BloodLab ou Combined (les deux autres ne couvrent pas)
Diagnostiquer une réponse louche du sidecar PythonTormknd (élimine notre fork)
Comprendre pourquoi une photo est ambiguëCombined — ses champs ocr.* et match.phash te disent quel signal a (ou pas) confirmé
Démo / présentation / partage du projetLab ou Combined (les deux montrent les 5 TCGs)

État actuel et dégradations connues ​

Cette section bouge dans le temps — au pire, elle est un instantané. Pour l'état live, regarder les dashboards container et la position du re-embed dans /tmp/embed_overnight2.log.

  • Lab : ✅ re-embed terminé 2026-05-10, 143 452 vecteurs sur 5 TCGs avec le warp-fix #135. ?tcg=lorcana et ?tcg=fab honorés depuis #138.
  • Compat : ✅ warp-fix #135 porté dans services/tcg-vision/src/vision_service.py via #138 — /scan-compat rejette désormais les sous-régions (illustration / encart de texte) au scan-time. Pas de rebuild FAISS nécessaire (build_index.py n'appelle pas correct_perspective, le bin reste cohérent avec le pipeline d'indexation inchangé). Régression sv01-003 → xy1-37 résolue : sv01-003 → sv01-003 sim 0.876.
  • Tormknd : 🟡 état dépend de ce que le pote a buildé en dernier. Pas affecté par nos warp-fixes (intentionnellement — c'est l'étalon immuable). Pour démarrer : docker compose --env-file .env -f docker/compose.yml --profile tormknd up -d tormknd.
  • Combined : ✅ utilise card_embeddings_lab depuis le fix du 2026-05-10 dans src/api/src/scan/embeddings.ts. Il consulte maintenant la même table de vecteurs peuplée que Lab ; la table legacy card_embeddings reste inutilisée et peut être droppée dans une migration séparée. pHash et OCR restent des signaux indépendants par-dessus l'embedding partagé.

Voir aussi ​