Appearance
Scan paths comparison — Lab / Compat / Tormknd / Combined
The mobile app sends every capture in parallel to the four raw-photo scan paths below, plus the separate Path #5 Frame cell documented in the scan-frame series. Results come back side-by-side on the Scanner screen and in the Scan Debug history. This document details the strengths, weaknesses, and differences of the four raw-photo paths.
Canonical technical reference: the full operational matrix lives in the backend repo →
tcg-collector-backend/docs/scanner/SCAN_PATHS.md. This document is the comparison-oriented summary.
TL;DR
Lab: our main path (Node + pgvector + HNSW). 5 TCGs covered, the fastest, the most up-to-date pre-processing-wise. Single-signal embedding.Compat: a fork oftgc-appwe modified and embedded in the stack. Limited coverage (Pokémon + One Piece + a bit of Lorcana). Exists for fast rollback and regression bisecting. Single-signal embedding.Tormknd: the unmodified code of the friend'stgc-app(Tormknd). A/B reference to check whether our fork helped or broke something. Single-signal embedding.Combined: the historical/scanendpoint on the Node API. Multi-signal: combines pHash + Tesseract OCR + DINOv2 in a unifieddecideMatch. Stricter — can returnno-matchwhen the other three see a card.
Single-signal vs multi-signal — why it matters
A signal is a way of looking at the card. Each signal has its strengths and blind spots.
| Signal | What it is | Strength | Weakness |
|---|---|---|---|
| pHash (perceptual hash) | 64-bit hash that summarises the global visual structure | Ultra-fast (< 1 ms), excellent on duplicates | Broken by angle, flash, crop, colour shifts |
| OCR (Tesseract) | Reads the printed text: set code + collector number + HP + name | Near-infallible when it reads cleanly (numbers don't lie) | Fails on blurry / mis-oriented / foreign-language photos |
| Embedding (DINOv2) | 384-dim neural-network vector measuring learned visual similarity | Robust to angle / flash / crop | Precision ceiling on visually-close cards (e.g. Pawmi vs Panpour) |
Single-signal (Lab / Compat / Tormknd) only relies on the DINOv2 embedding. More forgiving but can accept a card on visual similarity alone, so more prone to false-positives on layout-similar cards.
Multi-signal (Combined) demands consensus across signals via decideMatch:
- If pHash finds a near-exact copy → match immediately (high confidence; same image).
- Else, if OCR returns a
set + collectormatching exactly one DB candidate → match (high confidence; the printed identifier decides). - Else, the embedding's top-1 must clear a similarity threshold AND not have a too-close runner-up → match.
- Else →
no-matchreasonlow-confidence.
That's why Combined can reject a card the three single-signal paths all accept: if the embedding is confident but neither pHash nor OCR confirm, decideMatch distrusts. See the sv01-003 example below.
Comparison table
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 |
| Started by default | ✅ yes | ✅ yes | ❌ no (--profile tormknd) | ✅ yes |
| Code | Node + Fastify (OURS) | Python — fork of tgc-app that has diverged | Python — friend's tgc-app, unmodified | Node + Fastify (OURS) |
| Pipeline type | Single-signal embedding | Single-signal embedding | Single-signal embedding | Multi-signal: pHash + OCR + embedding |
| Vector store | PostgreSQL + pgvector HNSW | FAISS IndexFlatIP (in-memory) | FAISS IndexFlatIP (in-memory) | PostgreSQL + pgvector (reuses Lab's store) |
| Distance | cosine (embedding <=> $1::vector) | inner product (cosine via L2-norm) | inner product (cosine via L2-norm) | cosine + Hamming pHash + exact OCR match |
| 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) |
| Index source | card_embeddings_lab table, populated by embedCatalogLab.ts | services/tcg-vision/data/faiss_index.bin | tgc-app/data/faiss_index.bin | card_embeddings_lab (since 2026-05-10) + cards.phash + cards.printed_* |
| Cardinality | 143,452 vectors (5 TCGs) | ~30,370 vectors | ~30,370 vectors (same upstream dataset) | ~143k cards with pHash + metadata |
| TCGs covered | Pokémon, One Piece, Magic, Lorcana, FAB | Pokémon (FR/EN/JA), One Piece, Lorcana | identical to Compat | Pokémon, One Piece, Magic, Lorcana, FAB |
| Pre-ANN filter by TCG/set/lang | ✅ native SQL JOIN | ❌ post-fetch Python filter | ❌ post-fetch Python filter | ✅ native SQL JOIN |
| Rich metadata in response | ✅ SQL JOIN to cards | ✅ adapter calling our api for non-Pokémon | ❌ raw upstream response | ✅ SQL JOIN to cards + OCR/pHash signals exposed |
| Behaviour on ambiguous photo | accepts if embedding > threshold | accepts if embedding > threshold | accepts if embedding > threshold | rejects if pHash/OCR don't confirm |
Scan Lab
What it is
Our home-grown path. When you take a photo, the Node API:
- POSTs the image to the
embeddings(Python) sidecar → receives a 384-d DINOv2 vector. - Runs
SELECT … ORDER BY embedding <=> $1::vector LIMIT 5filtered by TCG/set if requested. - Re-ranks the top-5 and returns enriched JSON (TCGdex metadata, Cardmarket prices, etc.).
Strengths
- Widest coverage: 5 TCGs (Pokémon + OP + Magic + Lorcana + FAB), 143,452 vectors.
- SQL pre-filter: scope the search to
tcg=pokemon AND lang=frbefore the ANN — something FAISS can't do natively. - Reproducible index: rebuild with
embedCatalogLab.ts FORCE=true, idempotent, transactional. - Always-fresh metadata: SQL JOIN to
cards→ image, rarity, price, etc. - Fastest server-side: ~30-60 ms.
Weaknesses
- Depends on Postgres + pgvector. If the DB goes down, the path is dead.
- Indexed only with what we've embedded: a freshly-ingested card not yet embedded won't appear until the next re-embed.
- HNSW is approximate: top-1 isn't always the exact best match. Recall is very good but not 100%-guaranteed (negligible at 144k vectors in practice).
- Single-signal: the embedding alone can be tricked on visually-close cards.
Scan Compat
What it is
A fork of the friend's tgc-app we embedded in our Docker stack and let diverge. The sidecar runs in Python (FastAPI) and loads a prebuilt faiss_index.bin into RAM.
Strengths
- Battle-tested code: it's the historical path before the flip to Lab on 2026-05-07. Stable.
- Zero Postgres dependency: if pgvector explodes, this path keeps serving results.
- Frozen index: reproducible, no "what changed between two scans?" question.
- Bonus metadata: we added an adapter that calls our API to enrich non-Pokémon TCGs (One Piece especially) from our Postgres.
Weaknesses
- Limited coverage: only Pokémon (FR/EN/JA) + One Piece + a bit of Lorcana. No Magic, no Flesh and Blood. An MTG scan returns a Pokémon false-positive.
.binfrozen in time: reflects the pre-processing pipeline at the time of the last rebuild. The warp-fix#135(2026-05-08) was ported into the code path via#138, so it's active at scan time, but the bin itself wasn't rebuilt (not necessary —build_index.pydoesn't callcorrect_perspective).- No SQL pre-ANN filter: TCG/set filters are applied post-fetch in Python — slower and less precise than a SQL JOIN.
- Single-signal: same false-positive limit as Lab.
- Fork divergence: we added three things over vanilla Tormknd:
/scan-compatadapter that re-shapes the response to match the/scan-labcontract.LOCAL_IMAGE_BASE_URLthat rewrites image URLs to our nginx instead of the TCGdex CDN.BACKEND_API_URLthat calls our API to enrich non-Pokémon TCGs (otherwise just returns acard_id).
Scan Tormknd
What it is
The friend's tgc-app (Tormknd), built as-is from ../../tgc-app/ (the local repo checkout). Unmodified code, original index, original mapping.
Strengths
- Perfect A/B reference: it's exactly what the friend's app would return. Better result on Compat → our fork improved. Worse → we broke. Tormknd same as Compat → fork divergence isn't the cause.
- Zero contamination from our stack: no Postgres, no warp-fix, no re-embed, no nginx. It's the friend's code and data, period.
- Reproducible upstream: if the friend rebuilds his index with a new backbone or a new fix, we integrate by re-checking out his repo and
docker compose build tormknd.
Weaknesses
- Off by default: gated behind
--profile tormkndto keep the dailyuplight (first build = 5-10 min: torch hub + faiss-cpu install). - Limited coverage: identical to Compat (Pokémon + OP + Lorcana). No MTG, no FAB.
- No enriched metadata: the response is whatever the friend decided to return. No price, no rarity, no image rewriter.
- Single-signal: only the embedding drives the decision upstream.
- Evolves independently of our stack: if the friend pushes a fix into
tgc-app, we need a localgit pull+ container rebuild to pick it up.
Scan Combined
What it is
The historical /scan endpoint on the same Node API as Lab (port 8080). Before the flip to Lab on 2026-05-07, it was the mobile app's default path. It combines several independent signals through a unified decideMatch function:
- pHash (64-bit perceptual hash) computed on the photo, compared via Hamming distance to the pre-computed pHashes of catalog cards. If an indexed card is below a Hamming threshold → immediate match (the photo and catalog image are near-identical).
- Tesseract OCR reads known text regions: collector number (bottom-right), set code (top-right or top-left depending on the TCG), name, HP. If OCR returns a
set + collectormatching exactly onecard_idin the DB → match (high confidence; the printed identifier decides). - DINOv2 embedding proposes its top-1 + runner-up. To be accepted, similarity must clear a threshold AND the runner-up must be sufficiently far away (else it's ambiguous).
- If no signal clears its threshold →
no-matchwithreason: "low-confidence".
Strengths
- Most precise on easy cases: an exact duplicate or readable photo passes pHash or OCR with confidence ~1.0. Faster AND more precise than the embedding alone.
- Most robust on tricky cases: on two visually-close Pokémon (Pawmi vs Panpour), embedding alone can be wrong, but OCR of the collector number decides.
- Coverage identical to Lab: 5 TCGs, 143,452 vectors. Since 2026-05-10 Combined queries the same
card_embeddings_labtable as Lab — both paths see the exact same vectors, including the warp-fix #135 re-embed. Combined addscards.phashandcards.printed_*on top for the pHash and OCR signals. - Signals exposed in the response:
match.phash,ocr.collectorNumber,ocr.totalNumber,ocr.hp,embedding.cardId,embedding.similarity,runnerUp. It's the only path that gives you everything it saw, signal by signal. - Ideal for debug: a Combined cell returning
no-matchwhile Lab/Compat/Tormknd match tells you the non-visual signals disagree — often a hint that the photo itself is bad.
Weaknesses
- The strictest: can return
no-matchon photos where the three single-signal paths see a card. ThedecideMatchcascade requires at least one signal to clear its threshold; if pHash (Hamming distance), OCR (no exact set+collector match), and embedding (similarity below threshold) all fail, the cascade falls through tolow-confidence. - Slower than Lab alone: Tesseract OCR costs ~150-300 ms on CPU, even when pHash has already decided. Optimisation possible (early-return) not implemented.
- pHash threshold tuning still pending: post-2026-05-10 fix smoke surfaced cases where pHash short-circuits the cascade with similarity ~0.75 to a wrong card (typically a same-set neighbour), preventing the embedding from voting. The cascade is "first match wins"; a future migration to a weighted rerank (top-50 ANN → fused score across signals) would let the embedding correct pHash misfires.
- Depends on Postgres + pgvector like Lab.
Historical note — the broken wiring before 2026-05-10
Before 2026-05-10, /scan (Combined) read from a separate card_embeddings (legacy) table populated by embedCatalog.ts — distinct from embedCatalogLab.ts. The 4-button fan-out smoke revealed that table was empty (0 rows) — the legacy ingest job hadn't run for a long time, since embedCatalogLab.ts had taken over for the Lab A/B phase. Consequence: Combined was running with no embedding signal at all — pHash + OCR only — and was silently returning low-confidence no-match on most photos.
1-line fix in src/api/src/scan/embeddings.ts: point the query at card_embeddings_lab. No re-embed needed (the target table was already populated), no waiting. The legacy card_embeddings table stays in the schema but unused — can be dropped in a separate migration.
An earlier version of this doc described Combined as using "pre-warp-fix vectors" — that was inaccurate. Combined was using non-existent vectors. The fix is now applied, Combined consults the same vectors as Lab.
Which path to use when
| Use case | Recommended path |
|---|---|
| Production scan / end-user app | Lab (max coverage, fastest) or Combined (stricter, fewer false-positives) |
| Verify a card hasn't regressed after a re-embed | Lab + compare with Compat |
| Bisect a fork-specific regression | Compat vs Tormknd side-by-side |
| Test a Magic, Lorcana or Flesh and Blood card | Lab or Combined (the other two don't cover them) |
| Diagnose a sketchy Python sidecar response | Tormknd (eliminates our fork) |
| Understand why a photo is ambiguous | Combined — its ocr.* and match.phash fields tell you which signal did (or didn't) confirm |
| Demo / presentation / project sharing | Lab or Combined (both cover all 5 TCGs) |
Current state and known degradations
This section drifts over time — at best it's a snapshot. For live state, look at the container dashboards and the re-embed position in
/tmp/embed_overnight2.log.
- Lab: ✅ re-embed completed 2026-05-10, 143,452 vectors across 5 TCGs with warp-fix #135.
?tcg=lorcanaand?tcg=fabhonoured since #138. - Compat: ✅ warp-fix #135 ported into
services/tcg-vision/src/vision_service.pyvia #138 —/scan-compatnow rejects sub-region quads (artwork window, text box) at scan time. No FAISS rebuild needed (build_index.pydoesn't callcorrect_perspective, so the bin stays consistent with the unchanged index-time pipeline). Thesv01-003 → xy1-37regression is resolved:sv01-003 → sv01-003sim 0.876. - Tormknd: 🟡 state depends on what the friend last built. Intentionally not affected by our warp-fixes — it's the immutable reference. To start:
docker compose --env-file .env -f docker/compose.yml --profile tormknd up -d tormknd. - Combined: ✅ uses
card_embeddings_labsince the 2026-05-10 fix insrc/api/src/scan/embeddings.ts. It now consults the same populated vector table as Lab; the legacycard_embeddingstable remains unused and can be dropped in a separate migration. pHash and OCR remain independent signals on top of the shared embedding.
See also
tcg-collector-backend/docs/scanner/SCAN_PATHS.md— canonical technical reference with detailed operations.- Architecture overview — section "Scan paths".
- Plan — pgvector validation via /scan-lab — the original 2026-05-07 flip plan.
- Modern TCG scanner architecture (2026) — improvement priorities beyond the 4 current paths.