Appearance
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 detgc-appqu'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é dutgc-appdu 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/scanhistorique du Node API. Multi-signal : combine pHash + OCR Tesseract + DINOv2 dans undecideMatchunifié. Plus strict — peut retournerno-matchquand 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.
| Signal | C'est quoi | Force | Faiblesse |
|---|---|---|---|
| pHash (perceptual hash) | Hash 64-bit qui résume la structure visuelle globale | Ultra-rapide (< 1 ms), excellent pour les duplicates | Cassé par l'angle, le flash, le crop, les variations de couleur |
| OCR (Tesseract) | Lit le texte imprimé : code de set + numéro collector + HP + nom | Quasi-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 apprise | Robuste à l'angle / flash / crop | Plafond 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 :
- Si pHash trouve une copie quasi-exacte → match immédiat (haute confiance, c'est la même image).
- Sinon, si OCR donne un set + collector qui matche un seul candidat dans la DB → match (haute confiance, l'identifiant imprimé tranche).
- Sinon, l'embedding propose le top-1 mais doit dépasser un seuil ET ne pas avoir de runner-up trop proche → match.
- Sinon →
no-matchreasonlow-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 Lab | Scan Compat | Scan Tormknd | Scan Combined | |
|---|---|---|---|---|
| Endpoint | /scan-lab | /scan-compat | /scan | /scan |
| Port | 8080 | 8000 | 8002 | 8080 |
| Container | tcg-collector-api-1 | tcg-vision-api | tormknd-api | tcg-collector-api-1 |
| Démarrage par défaut | ✅ oui | ✅ oui | ❌ non (profile --profile tormknd) | ✅ oui |
| Code | Node + Fastify (à NOUS) | Python — fork divergé de tgc-app | Python — tgc-app du pote, non modifié | Node + Fastify (à NOUS) |
| Type de pipeline | Mono-signal embedding | Mono-signal embedding | Mono-signal embedding | Multi-signal : pHash + OCR + embedding |
| Vector store | PostgreSQL + pgvector HNSW | FAISS IndexFlatIP (in-memory) | FAISS IndexFlatIP (in-memory) | PostgreSQL + pgvector (réutilise le store de Lab) |
| Distance | cosinus (embedding <=> $1::vector) | inner product (cosine via L2-norm) | inner product (cosine via L2-norm) | cosinus + Hamming pHash + match exact OCR |
| Embeddings | DINOv2 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'index | table card_embeddings_lab, peuplée par embedCatalogLab.ts | services/tcg-vision/data/faiss_index.bin | tgc-app/data/faiss_index.bin | card_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 couverts | Pokémon, One Piece, Magic, Lorcana, FAB | Pokémon (FR/EN/JA), One Piece, Lorcana | identique à Compat | Poké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 > seuil | accepte si embedding > seuil | accepte si embedding > seuil | rejette si pHash/OCR ne confirment pas |
Scan Lab
Ce que c'est
Notre chemin maison. Quand tu prends une photo, l'API Node :
- POST l'image au sidecar
embeddings(Python) → reçoit un vecteur 384-d DINOv2. - Fait un
SELECT … ORDER BY embedding <=> $1::vector LIMIT 5filtré par TCG/set si demandé. - 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=fravant 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.
.binfigé 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.pyn'appelle pascorrect_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 :
- Adapter
/scan-compatqui re-shape la réponse pour matcher le contrat de/scan-lab. LOCAL_IMAGE_BASE_URLqui réécrit les URLs d'images vers notre nginx au lieu du CDN TCGdex.BACKEND_API_URLqui appelle notre API pour enrichir les TCGs non-Pokémon (sinon retourne juste uncard_id).
- Adapter
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 tormkndpour ne pas alourdir leupde 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 ungit pullcô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 :
- 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).
- 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 + collectorqui matche un seulcard_iddans la DB → match (haute confiance, l'identifiant imprimé tranche). - 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).
- Si aucun signal ne franchit son seuil →
no-matchavecreason: "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_labque Lab — donc les deux paths voient strictement les mêmes vecteurs, y compris le re-embed warp-fix #135. Combined ajoute par-dessus les colonnescards.phashetcards.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-matchalors 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-matchsur des photos où les trois mono-signal voient une carte. La cascadedecideMatchexige 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 enlow-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'usage | Path recommandé |
|---|---|
| Scan en production / app utilisateur final | Lab (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-embed | Lab + comparer avec Compat |
| Bisect d'une régression spécifique au fork | Compat vs Tormknd côte-à-côte |
| Tester une carte Magic, Lorcana ou Flesh and Blood | Lab ou Combined (les deux autres ne couvrent pas) |
| Diagnostiquer une réponse louche du sidecar Python | Tormknd (é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 projet | Lab 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=lorcanaet?tcg=fabhonorés depuis #138. - Compat : ✅ warp-fix #135 porté dans
services/tcg-vision/src/vision_service.pyvia #138 —/scan-compatrejette désormais les sous-régions (illustration / encart de texte) au scan-time. Pas de rebuild FAISS nécessaire (build_index.pyn'appelle pascorrect_perspective, le bin reste cohérent avec le pipeline d'indexation inchangé). Régressionsv01-003 → xy1-37résolue :sv01-003 → sv01-003sim 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_labdepuis le fix du 2026-05-10 danssrc/api/src/scan/embeddings.ts. Il consulte maintenant la même table de vecteurs peuplée que Lab ; la table legacycard_embeddingsreste 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
tcg-collector-backend/docs/scanner/SCAN_PATHS.md— référence technique canonique avec opérations détaillées.- Vue d'ensemble de l'architecture — section "Les paths de scan".
- Plan — validation pgvector via /scan-lab — le plan original du flip 2026-05-07.
- Architecture moderne d'un scanner TCG (2026) — priorités d'amélioration au-delà des 4 paths actuels.