Skip to content

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.bin qu'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 + table card_embeddings pgvector + 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 :

  1. Lancer embedCatalog.ts pendant ~80 minutes pour peupler card_embeddings
  2. Modifier BACKEND_SCAN_URL dans l'app pour pointer sur /scan (api) au lieu de /scan-compat (tcg-vision)
  3. 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-vision utilise FAISS IndexFlatIP (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_id est PK seul, donc OP14-119_p1 et OP14-119_p2 collapsent à 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) ​

  1. ✅ Migration 0006_card_embeddings_lab.sql — table identique à card_embeddings (mêmes colonnes, même index HNSW), nom différent. Idempotente.

  2. ✅ src/ingest/embedCatalogLab.ts — fork de embedCatalog.ts. Différences :

    • Écrit dans card_embeddings_lab
    • Sélectionne n'importe quelle qualité d'image (préfère low, fallback full puis high) pour gérer One Piece (single full) et Pokémon (low/high) avec le même code
    • INGEST_LAB_TCG (pokemon / onepiece) et INGEST_LAB_SETS (comma-separated) pour scoper des smoke runs rapides
  3. ✅ 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és match.id, match.name, cardDetails, etc.) pour A/B 1:1
    • auth.ts : /scan-lab ajouté à PUBLIC_POST_PATHS (lecture seule malgré le POST)
  4. ✅ docker/compose.yml — pas de nouveau service. Le sidecar embeddings: est mutualisé (stateless, model-agnostic).

  5. 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.

  1. ✅ Migration 0007_card_variants.sql — ajoute deux colonnes purement additives :
    • cards.base_id : la carte logique. Backfill base_id = id sur les rows existantes ; les futures variantes partageront un base_id mais auront un id distinct (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 + contrainte UNIQUE (base_id, variant). cards.id reste PK et unique → toutes les queries existantes continuent.
  2. Choix de naming _v2 / _v3 plutô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.
  3. Vérifié post-apply — 25 294 cards backfillées (23 159 pokemon + 2 135 onepiece), toutes en variant='base' et base_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.

  1. ✅ assignVariantIds() dans src/ingest/onepiece.ts — groupe les entrées OPTCG par card_set_id, trie chaque groupe par URL de card_image en ordre ASCII (pas localeCompare — 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' / ...
  2. Trap ICU/locale — sous Alpine + ICU slim (notre image Docker), String#localeCompare classe _ avant ., l'inverse de glibc. Pendant le smoke initial ça a fait que les variantes Box-Topper ont volé l'id de 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 (.jpg vs _p1.jpg).
  3. 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.
  4. 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-077 retourne "Perona" avec l'URL base ; /cards/OP01-077_v2 retourne "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.

  1. ✅ 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.
  2. Couverture — toutes les rows cards ayant un card_images.status='ok' ont été traitées : Pokémon (~22k) + OP base + variantes (~2.5k). Le sélecteur d'embedCatalogLab choisit l'image dans l'ordre de préférence low → full → high, donc OP (single full jpg) et Pokémon (low/high split) marchent avec le même code.
  3. 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-lab croppait l'upload via detectCardBounds, mais embedCatalogLab.ts envoie l'image brute. Les deux pipelines doivent appliquer le même préprocessing ou les vecteurs ne sont pas comparables. Fix : /scan-lab no-crop par défaut, ?crop=1 opt-in.
  4. 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}')"
done

Critères de succès :

  • 100% des scans testés retournent le même card_id que /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é :

RepoBrancheContenu
tcg-collector-backendfeat/scan-lab-pgvectorMigration 0006 + embedCatalogLab.ts + routeLab.ts + tests
tcg-collector-backenddocs/scan-lab-changelog (séparée)Entrée CHANGELOG.md sous [Unreleased]
documentationdocs/scan-lab-resultsMise à 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 à forker
  • tcg-collector-backend/src/api/src/scan/embeddings.ts — branche embedding existante à forker
  • tcg-collector-backend/src/ingest/migrations/0004_card_embeddings.sql — schema de référence pour la nouvelle table