Appearance
Plan — endpoint /scan-lab pour valider pgvector sans casser l'existant
Plan d'attaque pour valider notre propre stack de scan (pgvector + DINOv2) sans toucher au pipeline tcg-vision actuel utilisé par l'app RN.
Date : 2026-05-06. Statut : Les 6 phases shippées (A-D le 2026-05-06, E-F le 2026-05-07). L'app RN scanne via /scan-lab pour les 3 TCG (Pokémon + OP + MTG).
Pourquoi ce plan
tcg-vision (sidecar buildé depuis le repo de Tormknd, hébergé chez nous) sert le scan en production via /scan-compat. Il marche très bien, mais :
- Le
faiss_index.binqu'il utilise est généré offline par Tormknd ; on ne sait pas le régénérer nous-mêmes - Pour ajouter Magic ou tout nouveau TCG sans le couvrir, il faut soit lui demander, soit construire notre propre index
- On a déjà le code pour le faire nous-mêmes (
embedCatalog.ts+ tablecard_embeddingspgvector + branche embedding dans/scan), mais c'est dormant et non testé
Voir Architecture du scan de cartes pour le contexte complet.
Le risque
Activer le pipeline pgvector existant tel quel, c'est :
- Lancer
embedCatalog.tspendant ~80 minutes pour peuplercard_embeddings - Modifier
BACKEND_SCAN_URLdans l'app pour pointer sur/scan(api) au lieu de/scan-compat(tcg-vision) - Croiser les doigts pour que la qualité soit équivalente
Plusieurs inconnues invalident ce plan :
- Le code n'a jamais tourné (table
card_embeddingsà 0 ligne) - Différence d'index :
tcg-visionutilise FAISSIndexFlatIP(recall 100%), pgvector utilise HNSW (recall ~95-99%) - Préprocessing image potentiellement différent (rectification / crop / resize)
- Variantes d'art parallèle :
card_embeddings.card_idest PK seul, doncOP14-119_p1etOP14-119_p2collapsent à une ligne
Le plan — isolation totale, zéro risque
L'idée est d'ajouter un endpoint parallèle /scan-lab qui interroge une nouvelle table card_embeddings_lab isolée du chemin de prod. On peut alors A/B tester sans toucher à rien d'existant.
Avant Après
───── ─────
┌── tcg-vision :8000 ─────┐ ┌── tcg-vision :8000 ─────┐
│ /scan-compat (prod app) │ │ /scan-compat (prod app) │ ← inchangé
└─────────────────────────┘ └─────────────────────────┘
┌── api :8080 ─────┐ ┌── api :8080 ──────────────┐
│ /scan (dormant) │ │ /scan (dormant) │ ← inchangé
└──────────────────┘ │ /scan-lab (NOUVEAU) │
└─────┬─────────────────────┘
│ query
▼
┌── postgres ─────────────────┐
│ card_embeddings (vide) │ ← inchangé
│ card_embeddings_lab (NEW) │ ← nouveau
└─────┬───────────────────────┘
▲
│ peuplé par
┌── ingest job ──────────────┐
│ embedCatalogLab.ts (NEW) │
│ → embeddings: sidecar │ ← mutualisé, inchangé
└────────────────────────────┘Aucun container modifié. Aucun endpoint existant touché. App RN inchangée.
Découpage technique
Backend (tcg-collector-backend) — Phase A shippée (PR #103)
✅ Migration
0006_card_embeddings_lab.sql— table identique àcard_embeddings(mêmes colonnes, même index HNSW), nom différent. Idempotente.✅
src/ingest/embedCatalogLab.ts— fork deembedCatalog.ts. Différences :- Écrit dans
card_embeddings_lab - Sélectionne n'importe quelle qualité d'image (préfère
low, fallbackfullpuishigh) pour gérer One Piece (singlefull) et Pokémon (low/high) avec le même code INGEST_LAB_TCG(pokemon/onepiece) etINGEST_LAB_SETS(comma-separated) pour scoper des smoke runs rapides
- Écrit dans
✅
src/api/src/scan/routeLab.ts— endpoint/scan-lab:- Multipart photo → JPEG decode → crop bounds → DINOv2 sidecar → pgvector ANN sur
card_embeddings_lab - Pure embedding signal (pas de pHash, OCR, known-cards) pour mesurer la qualité brute vs Tormknd
- Réponse au format
/scan-compat(mêmes clésmatch.id,match.name,cardDetails, etc.) pour A/B 1:1 auth.ts:/scan-labajouté àPUBLIC_POST_PATHS(lecture seule malgré le POST)
- Multipart photo → JPEG decode → crop bounds → DINOv2 sidecar → pgvector ANN sur
✅
docker/compose.yml— pas de nouveau service. Le sidecarembeddings:est mutualisé (stateless, model-agnostic).Tests — au minimum un test unitaire sur la requête SQL générée, pas d'intégration test (l'embedding sidecar n'est pas dispo dans CI). 40/40 tests d'ingest passent.
Schéma variant-aware (tcg-collector-backend) — Phase B shippée (PR #105)
Le pré-requis pour pouvoir distinguer les variantes au scan : casser le collapse card_set_id → 1 row.
- ✅ Migration
0007_card_variants.sql— ajoute deux colonnes purement additives :cards.base_id: la carte logique. Backfillbase_id = idsur les rows existantes ; les futures variantes partageront unbase_idmais auront uniddistinct (ex.id="OP01-077_v2",base_id="OP01-077").cards.variant: le tag de print.'base'pour la principale,'v2'/'v3'pour les autres.- Index sur
base_id+ contrainteUNIQUE (base_id, variant).cards.idreste PK et unique → toutes les queries existantes continuent.
- Choix de naming
_v2/_v3plutôt que_pN/_rN— Phase D construit notre propre index d'embeddings depuis nos propres rows d'images, donc pas besoin de matcher la convention upstream de Tormknd. Numerotation séquentielle (ordre d'apparition dans la réponse OPTCG), déterministe, simple. - Vérifié post-apply — 25 294 cards backfillées (23 159 pokemon + 2 135 onepiece), toutes en
variant='base'etbase_id=id./cards/:id,/scan-lab,/scan-compat: aucun changement de comportement.
Re-ingest OP variant-aware (tcg-collector-backend) — Phase C shippée (PR #107)
Le ingest OP exploite maintenant le nouveau schéma pour stocker chaque print comme sa propre ligne.
- ✅
assignVariantIds()danssrc/ingest/onepiece.ts— groupe les entrées OPTCG parcard_set_id, trie chaque groupe par URL decard_imageen ordre ASCII (paslocaleCompare— voir trap ci-dessous), puis assigne :- 1ère entrée →
cards.id = card_set_id,variant = 'base' - Suivantes →
cards.id = ${baseId}_v${N},variant = 'v2' / 'v3' / ...
- 1ère entrée →
- Trap ICU/locale — sous Alpine + ICU slim (notre image Docker),
String#localeCompareclasse_avant., l'inverse de glibc. Pendant le smoke initial ça a fait que les variantes Box-Topper ont volé l'idde base. Fix : comparaison ASCII directe via</>. Test de régression dédié pinné dans la suite (tests/optcgapi.test.mjs) avec les vraies URLs OPTCG (.jpgvs_p1.jpg). - Layout disque par id variant-suffixé —
OP-01/077/full.jpg(base),OP-01/077_v2/full.jpg(variante). Les fichiers existants pour la base ne bougent pas. - Vérifié post-ingest — 3 330 entrées OPTCG → 2 135 base + 832 variantes = 2 967 cartes OP (vs 2 135 collapse avant). Distribution : 2 135 base / 674 v2 / 120 v3 / 34 v4 / 4 v5. Pokémon (23 159) intact.
/cards/OP01-077retourne "Perona" avec l'URL base ;/cards/OP01-077_v2retourne "Perona (Box Topper)". 46/46 tests d'ingest passent (6 nouveaux).
Embed du catalogue dans card_embeddings_lab — Phase D shippée (PR #109 fix de préprocessing)
Premier run réel d'embedCatalogLab.ts contre le DB de prod.
- ✅ 24 539 lignes embeddées en 33 minutes, ~12 cards/s sur le sidecar DINOv2-small (CPU only). 0 failures. Modèle
facebook/dinov2-small, dim 384, le tout sous un seul nom de modèle. - Couverture — toutes les rows
cardsayant uncard_images.status='ok'ont été traitées : Pokémon (~22k) + OP base + variantes (~2.5k). Le sélecteur d'embedCatalogLabchoisit l'image dans l'ordre de préférencelow→full→high, donc OP (singlefulljpg) et Pokémon (low/high split) marchent avec le même code. - Fix #109 (post-Phase D) — un test self-recall (envoyer l'image indexée d'OP01-077 dans
/scan-lab) retournait la mauvaise carte avec sim ≈ 0.85. Diagnostic :/scan-labcroppait l'upload viadetectCardBounds, maisembedCatalogLab.tsenvoie l'image brute. Les deux pipelines doivent appliquer le même préprocessing ou les vecteurs ne sont pas comparables. Fix :/scan-labno-crop par défaut,?crop=1opt-in. - Smoke après fix — self-recall OP01-077 : 0.85 → 0.9990. Le scan Cardmarket de portgas (ST13-010) reste un mismatch car ST decks ne sont pas indexés (revert #101) — c'est un trou de couverture, pas un bug pipeline.
Documentation
Cette page reste comme référence du plan. Une fois validé ou abandonné, elle sera convertie en :
- "Stack pgvector indépendante (en prod)" — si succès
- "Tentative pgvector indépendante (abandonnée)" avec les leçons apprises — si échec
Procédure de smoke validation
Smoke "table vide" (vérifié en Phase A)
bash
curl -X POST -F "[email protected];type=image/jpeg" http://localhost:8080/scan-lab
# → { "ok": true, "match": null, "source": "no-match",
# "reason": "no-match-after-filter", "similarity": 0, "ms": 723 }L'inference DINOv2 (~250ms) et la query pgvector tournent ; aucune ligne ne ressort. Le format de réponse mirror celui de /scan-compat pour faciliter le diff.
Renommage du reason (PR #138, 2026-05-10)
Le literal était auparavant lab-table-empty, ce qui était trompeur en production où la table contient 143 k+ lignes. Le code path est en fait atteint quand les 40 candidats ANN par défaut de HNSW sont tous filtrés par le WHERE c.tcg=$3 actif — typiquement quand on scanne une non-carte dont l'embedding tombe dans une zone dominée par un autre TCG. Le nouveau nom décrit ce qui se passe vraiment.
Smoke "table peuplée" (vérifié en Phase D)
bash
cd tcg-collector-backend
# 1. Embed un seul set OP-01 (~154 images, ~7 minutes CPU au lieu de ~80 min full)
sudo docker compose --env-file .env -f docker/compose.yml run --rm \
-e INGEST_LAB_TCG=onepiece -e INGEST_LAB_SETS=OP-01 \
--entrypoint "node dist/embedCatalogLab.js" ingest
# 2. Vérifier que la table se peuple
sudo docker compose --env-file .env -f docker/compose.yml exec -T postgres \
psql -U tcg -d tcg -c "SELECT COUNT(*) FROM card_embeddings_lab;"
# Attendu : ~150-154 (selon variantes collapsées)
# 3. Comparer 5-10 cartes : /scan-lab (notre stack) vs /scan-compat (tcg-vision)
for img in /tmp/op-test-*.jpg; do
echo "=== $img ==="
curl -s -X POST -F "photo=@$img;type=image/jpeg" http://localhost:8080/scan-lab \
| python3 -c "import json,sys;d=json.load(sys.stdin);m=d.get('match');print(f'lab : {m[\"id\"] if m else None} sim={d[\"similarity\"]:.4f}')"
curl -s -X POST -F "photo=@$img" http://localhost:8000/scan-compat \
| python3 -c "import json,sys;d=json.load(sys.stdin);m=d.get('match');print(f'compat : {m[\"id\"] if m else None} sim={d[\"similarity\"]:.4f}')"
doneCritères de succès :
- 100% des scans testés retournent le même
card_idque/scan-compat - Latence comparable (~250ms inférence + qq ms ANN, total <500ms)
- Pas de crash sur les inputs edge (image floue, partiel, mauvaise rotation)
Si KO : on creuse pour identifier le gap (préprocessing ? recall HNSW ? format image ?), on fixe, on recommence le smoke. Le full embed des 25k cartes attend la validation.
Workflow PR
Une fois implémenté :
| Repo | Branche | Contenu |
|---|---|---|
tcg-collector-backend | feat/scan-lab-pgvector | Migration 0006 + embedCatalogLab.ts + routeLab.ts + tests |
tcg-collector-backend | docs/scan-lab-changelog (séparée) | Entrée CHANGELOG.md sous [Unreleased] |
documentation | docs/scan-lab-results | Mise à jour de cette page avec les résultats du smoke |
Voir aussi
- Architecture du scan de cartes — vue d'ensemble du pipeline
- Rollback — intégration One Piece — précédent rollback, mêmes patterns
tcg-collector-backend/src/ingest/src/embedCatalog.ts— script existant à forkertcg-collector-backend/src/api/src/scan/embeddings.ts— branche embedding existante à forkertcg-collector-backend/src/ingest/migrations/0004_card_embeddings.sql— schema de référence pour la nouvelle table