Skip to content

feat: card scanner Phase 1 — live capture and re-measure (007) - #8

Merged
plainprogrammer merged 44 commits into
mainfrom
007-card-scanner-live-capture
Oct 2, 2026
Merged

plainprogrammer merged 44 commits into
mainfrom
007-card-scanner-live-capture

Conversation

@plainprogrammer

@plainprogrammer plainprogrammer commented Oct 1, 2026 •

Copy link
Copy Markdown
Owner

Card scanner Phase 1: live capture and re-measure (007)

Spec: spec.md (v2.1.1) · Plan: plan.md · Findings: research.md · ADRs 0001–0003 are now Accepted.

Spec v2.0.0: the 50 Phase 0 cards were borrowed and returned, so they couldn't be re-captured live. The measured run replays their Phase 0 photos through the shipped photo-picker path instead. The four tuning rounds' live iPhone captures are labelled as biased (AC-6.8).

Spec v2.1.0/v2.1.1: after the maintainer's ruling, live accuracy was re-measured on a new corpus of 49 cards the maintainer owns, none used for tuning or in Phase 0's corpus, at the frozen settings, with an unguided photo of each card as a photo-path baseline (AC-6.9, research.md §5).

What's in it

  • Scanner page at /scanner for signed-in users; nothing links to it yet.
    • Live rear camera with a 63:88 card guide, a torch where the camera has one, and a photo picker as the fallback.
    • OCR runs on the device, and only the recognised text is sent to the app.
    • The page shows what was read and up to 3 candidate printings, with no add button.
  • Self-hosted OCR engine.
    • bin/fetch-ocr-engine, run by bin/setup and the Dockerfile, downloads Tesseract.js 7.0.0, its 3 LSTM core builds and the eng data (14.8 MB), checking every file against a pinned SHA-256.
    • OcrAssetsController serves the files from /ocr/v7.0.0/ with immutable caching, and answers conditional requests with 304.
  • Content-Security-Policy on scanner pages only. It uses a per-request nonce, 'wasm-unsafe-eval' and blob: workers. Every other page still sends no policy.
  • Matching.
    • MTG::CollectorLine handles Phase 0's failures: a glued rarity letter, a lost slash, a missing rarity letter, and "one" in rules text being taken for a set code.
    • A core card-name index (catalog_names with FTS5 trigrams), rebuilt by Catalog::Refresh.
    • MTG::Reading ranks candidates, with a collector-line match first.
  • Development-only measurement mode at /scanner/measurement: captures, retakes, skips, a desktop replay, and a replay of corpus photos through the photo picker.
  • Findings tooling. bin/rails scanner:findings scores runs with spec 005's definitions and reproduces Phase 0's numbers exactly.
  • HTTPS on the local network. bin/dev-certificate makes a self-signed certificate and prints the Puma ssl:// bind command. The README covers the phone and self-hosting routes.

Results

Evidence Top 3, name only Final ranking, top 1 Exact printing
Phase 0 26/50 — 7/45
Phase 0's OCR text, new matcher 27/50 33/50 15/45
Photo replay of the 50 Phase 0 photos 14/50 23/50 16/45
Live captures, tuning rounds 3 / 4 (biased) 11/12 / 10/12 10/12 / 9/12 6/9 / 6/9
Live captures, new corpus (unbiased) 39/49 42/49 (top 3: 45/49) 34/44
Photos of the new corpus, no guide 0/49 1/49 (top 3: 2/49) 2/44
  • On-device recognition: median 168 ms per capture on the new corpus (about 185 ms over the tuning rounds), against Phase 0's 626 ms.
  • Known weak spots (new corpus): in 3 of 49 a misread collector number matched another card's real printing and outranked the right name; foils' exact printing 4/10. The photo path depends on framing: the new photos fill the frame beyond the guide.
  • Downloads: a cold load is 7,050,153 bytes with one core build; a warm load re-downloads nothing.
  • Device checks: all five passed on the iPhone.
  • Next spec: the findings carry options for the maintainer's decision (build the confirm flow, bring card detection forward, or stop; research.md §13) and items for the next spec.

Verification

  • bin/ci passes: RuboCop, Brakeman (3 justified ignores, all send_file behind an allowlist), bundler-audit, the importmap audit, and RSpec with 601 examples and 0 failures.
  • The camera system specs run real OCR on a synthetic card in headless Firefox.
  • Fable reviews: the spec twice, the plan, and the implementation (Mode B), which returned SPEC-ALIGNED. Its should-fix items are applied in 5d22545 and 871331a. A second Mode B review of the re-measure (AC-6.1, AC-6.4–AC-6.9) also returned SPEC-ALIGNED; its fixes are in 887898b.
  • Every deviation from the plan is recorded as a Ruling: line in its commit body.

Notes for review

  • Migration: 20260930170000_create_catalog_names only creates tables. The index fills on the next refresh: an applied run, or a scheduled run skipped while the index is empty.
  • Images: no image, certificate or key is committed. Measurement data stays in ~/card-scanner-corpus/.

🤖 Generated with Claude Code

@plainprogrammer
plainprogrammer marked this pull request as ready for review October 2, 2026 14:35
plainprogrammer added a commit that referenced this pull request Oct 2, 2026
Memories: card scanner status after Phase 1 (PR #8, corpus cards returned,
tuning cards biased, go/no-go pending); check that physical corpus items will
be available before specifying a re-measure; background dev servers stop at
the 2-hour task limit.
Lessons: tune OCR on stored strips (desktop replays equal the device's text);
framing dominates scanner accuracy, and a collector-line match can be wrong.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
plainprogrammer and others added 27 commits October 2, 2026 17:00
Card scanner Phase 1: live capture and re-measure. Spec v1.1.1 (approved,
reviewed twice on Fable); plan revised after its Fable review.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Each ADR moves from Proposed to Accepted and records how it changed
from its Proposed text.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…rd set codes

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…name fallbacks

catalog_names holds every name a catalog identity is known by (identity
names from the core, face names from MTG via the optional
.alternate_names source contract method), with an external-content FTS5
trigram index over the normalised name. Catalog::NameIndex rebuilds it
in full and searches it: query cleaning (AC-3.4), OR-ed trigrams
shortlisted by bm25 and re-ranked by Jaro-Winkler, one candidate per
identity, plus the within-one-edit and exact-raw fallbacks (AC-3.5).

Ruling: the retired-card example asserts the retired card is absent
rather than that the result is empty — OR-ed trigrams return "Fire // Ice"
(score 0.44) on the shared trigram "ire", and the spec sets no score
floor — cost if wrong: a floor is wanted after all and gets added during
tuning (Phase 10), with this assertion tightened then.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…when it's empty

Catalog::Refresh rebuilds the type's name index in full before finishing
an applied run, and a scheduled run skipped for an already-applied
version rebuilds it when it is empty, noting the count in the run's
message (spec 007 AC-3.9, FR-5). db:prepare never backfills it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d candidates

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…he image build

bin/fetch-ocr-engine downloads the three pinned npm tarballs (tesseract.js,
tesseract.js-core and eng 4.0.0_best_int), checks each tarball's and each
file's SHA-256, and unpacks the six LSTM-only engine files into the ignored
vendor/ocr/v7.0.0/. bin/setup (so also bin/ci) and the Dockerfile build stage
run it (spec 007 AC-7.3, ADR 0001).

Ruling: chore instead of build — build isn't an allowed type in docs/git-convention.md — none
Ruling: spec's `contents` is a helper method, not a let — RuboCop's RSpec/MultipleMemoizedHelpers caps a group at 5 lets — none

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
OcrAssetsController serves only the pinned engine files at
/ocr/v7.0.0/*path, with Cache-Control max-age=31536000, public, immutable,
a fixed ETag and Last-Modified, and 304 for If-None-Match or
If-Modified-Since. Anything else is 404 (spec 007 AC-2.2, ADR 0001).

Ruling: Brakeman's SendFile warning is ignored in config/brakeman.ignore — the path comes from the FILES allowlist via path_for, never from the request — a future edit that bypasses path_for would be hidden by the fingerprint only if the send_file call stays identical
Ruling: ::Collector::OcrEngine instead of Collector::OcrEngine — inside a controller a bare Collector resolves to ActionController::MimeResponds::Collector — none
Ruling: skip_forgery_protection on the controller — Rails' cross-origin JavaScript check answered every .js GET with 422; the action is GET-only and touches no session state — none
Ruling: expires_in 365.days instead of 1.year — 1.year is 365.2425 days (max-age=31556952), the plan and spec ask for 31536000 — none

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…oint

GET /scanner renders the camera page (disabled, busy shutter; Phase 6 brings
it to life) with a scanner-only Content-Security-Policy and a per-request
nonce. POST /scanner/readings turns the recognised text into the result HTML
as a Turbo Stream update of #scanner_result. Other pages send no policy and
no nonce; nothing links to the scanner yet.

Ruling: ScannersHelper#ocr_engine_path uses ::Collector::OcrEngine — a bare
Collector can resolve to ActionController::MimeResponds::Collector in view
contexts — none if wrong, the leading :: is merely redundant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Camera and card-reader Stimulus controllers over scanner/geometry and
scanner/recognition; system specs draw a synthetic card into a substituted
getUserMedia stream (ADR 0002) and run real Tesseract on it. Scanner spec
green 10 runs in a row.

Ruling: `.c-scanner [hidden] { display:none; }` replaces the per-part
[hidden] rules — `.c-btn`'s display:inline-flex overrode the hidden
attribute, so the Torch, retry and Sign in buttons showed while hidden
(caught by the torch and HTTPS specs) — cost if wrong: none beyond the
rule; hidden is meant to hide.
Ruling: the 360px spec visits the collection page before opening the
narrow frame — the scanner page's own frame-src 'none' (AC-2.4) blocks it
from hosting the iframe, so the frame stayed about:blank — cost if wrong:
none; the framed page is still the scanner with its policy.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…kips and desktop replay

Measurement mode (spec 007 Story 5, FR-6) exists only when
config.x.scanner_measurement is set (development by default). The
manifest row is chosen before the shutter; each capture's text and both
strip PNGs go to <dir>/<file>/capture-NNN*, outside the repository. The
first capture of a row is measured, later ones are retakes; rows can be
skipped. A local-only, sign-in-free replay page re-reads stored strips
through scanner/recognition, driven by script/scanner/replay.rb.

Ruling: #measurement_panel [hidden] { display:none; } in additions.css,
documented in components/Scanner.md — the panel sits outside .c-scanner,
so c-btn's inline-flex would otherwise show its hidden retry button —
cost if wrong: one id-scoped rule to fold into a c-* class later.
Ruling: Brakeman SendFile warning on StripsController#show ignored in
config/brakeman.ignore — the path comes from a manifest row matching
FILE_NAME plus STRIPS.include?, never raw request input, and the route is
local-only in development — cost if wrong: a path traversal on a dev
server, bounded by those allowlists.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e sizes in development

Ports spec 005's scoring into Collector::ScannerFindings so Phase 0, Phase 0's
text through Phase 1's matcher, and the live run are rated the same way, with
a Report for the findings Markdown and text-only fixtures, the
scanner:findings and scanner:ground_truth tasks, and a development-only
Collector::RequestLog middleware (COLLECTOR_REQUEST_LOG=1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Draft findings for spec 007: Phase 0's committed OCR text read again through
MTG::Reading. The Phase 0 column reproduces research.md §3 (top 3 name only
26/50, exact printing 7/45); with Phase 1's matcher, exact printing is 15/45
and top 3 final ranking 33/50. The live run follows in Phase 11.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ement mode

COLLECTOR_HTTPS=true now sets assume_ssl in development too, so a phone reaching the
dev server through an HTTPS tunnel (hosts allowed via Rails' RAILS_DEVELOPMENT_HOSTS)
passes CSRF origin checks. The README covers the dev tunnel, measurement mode, and
HTTPS for self-hosters on Compose and Kamal; CLAUDE.md records the scanner's facts.

Ruling: Kamal wording says "uncomment" proxy/ssl/host and COLLECTOR_HTTPS rather than
"set" — config/deploy.yml already ships them as commented lines — cost if wrong: a
self-hoster adds duplicate keys instead of uncommenting.
Ruling: the Compose bullet points to "HTTPS under Docker Compose" instead of "HTTPS
above" — the HTTPS note is a bullet inside the Compose section, not its own heading —
cost if wrong: none beyond wording.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The rebase onto main (spec 006) put Scanner at the end of spec 004's list;
give it its own line like 006's additions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Cloudflare tunnel returned 502 with no request reaching Rails; a
self-signed certificate for the LAN IP served by Puma's ssl:// bind works,
and the iPhone (as a Continuity Camera in desktop Brave) scanned two cards.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Collector::DevCertificate creates (or reuses) a self-signed CA certificate
for the machine's LAN addresses outside the repository, and serves only the
certificate over plain HTTP so the phone can install and trust it. Puma
serves the pair directly with an ssl:// bind; no app change.

Ruling: self-signed certificate + Puma ssl:// bind instead of a tunnel — the maintainer's Cloudflare tunnel returned 502 with no request reaching Rails; this route was verified on 2026-10-01 — the iPhone must trust the certificate once

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The README's phone section now leads with bin/dev-certificate, the
bin/dev ssl:// bind, bin/dev-certificate --serve and the iPhone trust
steps; a tunnel stays as the alternative. CLAUDE.md points to the same
route, and the plan records the amendment for Phases 10-11.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tch strips (tuning round 1)

Tuning round 1 (10 non-corpus cards, iPhone Brave, runs/tuning-1): name read
0/10, top 3 final ranking 6/10, exact printing 5/7, collector lookups one 5 /
none 2. Name strips sat too high and clipped the name on every card; grey
collector lines on dark borders read empty.

Round 2 settings: name strip { x: 0.05, y: 0.055, w: 0.75, h: 0.11 };
every strip drawn at STRIP_SCALE (2x), then grayscale with a per-strip
contrast stretch. Recognition settings unchanged (name psm 7, collector psm 6).

Ruling: feat instead of tune — tune isn't an allowed type — none
Ruling: name strip y 0.03→0.055, h 0.085→0.11; strips drawn at 2× with grayscale contrast stretch — round 1's name strips clipped the name on every card and low-contrast collector lines read empty — wider name strip may take in more art on some frames, which round 2 will show

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…v server

SCANNER_URL overrides the replay's base URL and Firefox accepts the
self-signed certificate, so script/scanner/replay.rb runs against the
HTTPS dev server the phone uses. README notes the override.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…r strip (tuning round 2)

Round 2 (10 non-corpus tuning cards, iPhone Brave, runs/tuning-2) with
round 1's settings: name read 0/10, top 3 name-only 4/10, top 1 and
top 3 final ranking 8/10, exact printing 6/7 (lookups one 6, none 1).
The names were inside the strip, but page segmentation 7 garbled them
amid frame lines and borders. T009 (Vrondiss, AFC) was held slightly
larger than the guide and its "AFC • EN" line was cut off at the
collector strip's bottom.

Round 2's stored strips replayed on the desktop (script/scanner/replay.rb;
desktop text matched the iPhone's exactly), varying only the name strip's
page segmentation:

| name psm    | top 3 name-only | top 1 final | top 3 final | exact printing |
|-------------|-----------------|-------------|-------------|----------------|
| 7 (round 1) | 4/10            | 8/10        | 8/10        | 6/7            |
| 6           | 10/10           | 10/10       | 10/10       | 6/7            |
| 11          | 4/10            | 7/10        | 7/10        | 6/7            |
| 13          | 1/10            | 6/10        | 6/10        | 6/7            |

The collector strip now runs to the card's bottom edge. The synthetic
test card sizes its text from the card instead of the strips.

Ruling: name strip page segmentation 7→6 — on round 2's stored strips replayed on the desktop, psm 6 put every card's name first (10/10 vs 4/10) — chosen on the same 10 tuning cards it was measured on, so round 3 captures check it on fresh photos
Ruling: collector strip y 0.905→0.89, h 0.085→0.11 — T009's set line was cut off when the card was held slightly larger than the guide — untested until round 3
Ruling: feat instead of tune — tune isn't an allowed type — none
Ruling: the synthetic test card now sizes and places its text from the card rather than the strips — sizing from the strip made the test text grow with the strip and OCR misread it (R0O123) — none for real cards; the test is closer to them

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ing round 3)

Round 3 (12 fresh tuning captures, settings of 7d4f09d): top 3 name-only
11/12, top 1 final 10/12, top 3 final 11/12, exact printing 6/9 (lookups
one 7, none 2). The collector misses were small light-on-dark text in the
card's black border: a faint foil line read "J"; "FRA" read "ERA";
"178/184 C" lost "178/" and parsed as a different real printing (AER 184),
which then ranked first ahead of the correct name candidate.

After the contrast stretch, cropStrips now inverts every pixel row of the
collector strip whose mean luminance is below 128, so the light-on-dark
collector line becomes dark-on-light while the bright text-box rows stay
as they are. Which strips get it is the INVERT_DARK_ROWS tuning setting.
Measurement mode stores the strips exactly as OCR read them.

Replay of the stored strips of rounds 2 and 3 (22 captures; desktop
replay text equals the device's exactly), variants applied before OCR:

| variant (collector strip unless stated) | round 2 printing | round 3 printing | round 3 top 1 final |
|---|---|---|---|
| none (current) | 6/7 | 6/9 | 10/12 |
| invert dark rows | 6/7 | 8/9 | 11/12 |
| invert dark rows + Otsu binarize | 4/7 | 5/9 | 10/12 |
| invert dark rows, psm 4 | 6/7 | 7/9 | 11/12 |
| invert dark rows, psm 11 | 6/7 | 8/9 | 11/12 |
| invert dark rows on name strip too | name top 3 fell to 9/10 and 9/12 | 8/9 | 11/12 |

Ruling: invert dark rows of the collector strip only — on the 22 stored captures of rounds 2 and 3 it lifted round 3's exact printing 6/9→8/9 and changed nothing in round 2; binarizing and other page-segmentation modes were no better, and inverting the name strip hurt names — chosen on the same tuning cards it was measured on, so the round 4 captures check it on fresh photos
Ruling: no set-code correction — one-substitution neighbours are ambiguous (ERA → EMA, FRA) and would invent matches for about a quarter of random misreads — T012-style misreads stay unmatched by the collector line and fall back to the name
Ruling: feat instead of tune — tune isn't an allowed type — none

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d run

Frozen after 4 tuning rounds on 12 English cards outside the 50-card corpus
(3 pre-M15, 7 M15–ONE, 2 MOM+; 3 foils), captured in Brave on the iPhone
(spec 007 AC-6.1). No setting changes until the findings are written.

Final settings:
- GUIDE: height 0.8 of the 3:4 stage, max width 0.9 (unchanged)
- STRIPS.name: x 0.05, y 0.055, w 0.75, h 0.11; page segmentation 6
- STRIPS.collector: x 0.03, y 0.89, w 0.55, h 0.11; page segmentation 6
- STRIP_SCALE 2, grayscale min–max contrast stretch on both strips
- INVERT_DARK_ROWS: collector only

Rounds (fresh captures; top 3 name-only / final top 1 / final top 3 / exact printing):
- 1 (initial settings, 10 cards): 1/10, 6/10, 6/10, 5/7
- 2 (ca27148, 10 cards): 4/10, 8/10, 8/10, 6/7
- 3 (7d4f09d, 12 cards): 11/12, 10/12, 11/12, 6/9
- 4 (ec71cd9, 12 cards): 10/12, 9/12, 10/12, 6/9
Between rounds, desktop replays of stored strips (identical to the device's
text) chose page segmentation 6 and collector dark-row inversion.

Ruling: freeze after round 4 — rounds 3 and 4 differ only by photo-to-photo
OCR variance, and more tuning on 12 cards would chase it — the measured run
may show weaknesses these 12 cards didn't.
Ruling: a "leading words" name query was tried on every run and dropped — no
gain in tuning, 3 fewer first places on Phase 0's text — none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…007 v2.0.0)

The 50 corpus cards were borrowed and returned, so they can't be re-captured
live. Maintainer ruling (2026-10-01): replay the 50 Phase 0 photos through the
shipped photo-picker path as the measured run; report the four tuning rounds'
live iPhone captures as the only live-alignment evidence, labelled as biased
(new AC-6.8); take on-device timings from the tuning rounds and download sizes
from an iPhone load test. MAJOR: rewrites Goals and AC-6.2–AC-6.6. Plan Phase
11 rewritten (11a harness, 11b runs and findings). No FR changes; no built code
is invalidated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rement mode

Scanner::MeasurementRun#photo_path finds a row's photo beside the manifest.
GET /scanner/measurement/photos/:id serves it to this machine only (local,
development-only, unauthenticated like the strips). script/scanner/photo_run.rb
signs in, opens /scanner/measurement in headless Firefox and hands each pending
row's photo to the real picker, waiting for the panel to report it stored.

Ruling: the photo's content type comes from Marcel (magic bytes, then name) —
the corpus is JPEG but a tuning folder could hold HEIC or PNG — cost if wrong:
a wrong type header on a local-only route; the picker reads the blob anyway.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
plainprogrammer and others added 16 commits October 2, 2026 17:00
Collector::ScannerFindings::Report takes label: (the third column and the
misses heading; default "Phase 1 live") and prefix: (the fixture file names;
default "phase1"). scanner:findings passes RUN_LABEL and FIXTURES_PREFIX.

Ruling: the fixtures' "run" field is now the label ("Phase 1 live" by default,
was "live") — the plan says run is set from the label, and no phase1 fixture is
committed yet — cost if wrong: a reader of format_version 2 expecting "live".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Text only, format_version 2, keyed by manifest file (spec 007 AC-6.6, v2.0.0):
the 50 Phase 0 photos replayed through the photo-picker path at the frozen
settings (c68ffbd), and tuning round 4's 12 live iPhone captures.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Photo replay of the 50 Phase 0 photos through the photo-picker path, the
four tuning rounds' live iPhone captures (labelled as biased: they also
chose the settings), Phase 0's text re-scored with Phase 1's matcher, the
iPhone load test, device checks, replays, coverage, fixtures, findings for
the next spec and the maintainer's options (spec 007 v2.0.0, AC-5.4–AC-5.6,
AC-6.2–AC-6.8). No pass threshold.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From the Fable Mode B review (SPEC-ALIGNED, 3 should-fix):
- research.md: report the camera resolution (FR-2) — requested 1920×1080
  ideal; a 1080×1920 portrait frame inferred from round 1's strip sizes, not
  logged; say "upper median" for even n; note lookup timings vary per run;
  state that no lookup was ambiguous (AC-6.2).
- data-model.md: the fixture names are phase1_photos_* and phase1_tuning4_*.
- plan.md: the Goal paragraph reflects spec v2.0.0; the load-test snippet's
  first /scanner row can be a signed-out 302.
- ADR 0001: Consequences now match its Changes section (14,828,864 bytes,
  fetched, not committed).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Memories: card scanner status after Phase 1 (PR #8, corpus cards returned,
tuning cards biased, go/no-go pending); check that physical corpus items will
be available before specifying a re-measure; background dev servers stop at
the 2-hour task limit.
Lessons: tune OCR on stored strips (desktop replays equal the device's text);
framing dominates scanner accuracy, and a collector-line match can be wrong.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The maintainer ruled on research.md §12: re-measure live first, on 50 cards
they own, none among Phase 0's 50 or the 12 tuning cards, at the frozen
settings (c68ffbd), with one unguided photo per card replayed through the
photo picker as a baseline on the same cards. New AC-6.9; AC-6.1 and
AC-6.4–AC-6.8 amended to cover it. No pass threshold, no FR changes.

The plan gains Phase 11c for the run, scoring and findings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two of the maintainer's rows repeated Phase 0 printings (hob 225, wot 49)
and were dropped so AC-6.9's "none among Phase 0's cards" holds. List
reprints and a retro-frame card print no set code, so the manifest gives
them the pre-M15 era. Section references follow research.md's renumbering.

Ruling: drop the two overlapping rows — maintainer's choice, keeps the
Phase 0 comparison clean — costs one card's worth of sample size.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Text only, format_version 2, keyed by the new corpus's manifest file:
49 live iPhone captures and the same 49 cards' photos through the photo
picker (spec 007 AC-6.6, AC-6.9). No images.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
49 cards outside tuning and Phase 0, captured live on the iPhone at the
frozen settings (c68ffbd): right card first for 42/49, top 3 for 45/49,
exact printing for 34/44 set-line cards; median recognition 168 ms. The
same cards' photos, framed tighter than the guide, reached 2/49. Three
misread collector numbers matched real printings of other cards and
outranked the right name. New section 5; later sections renumbered;
summary, findings and options updated (AC-6.4–AC-6.9).

Ruling: IMG_6763's manifest row was corrected from mul 71 to mat 71 after
capture — its strip and photo show Leyline Immersion — costs nothing if
wrong beyond one row's ground truth.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From the Fable Mode B review (SPEC-ALIGNED): research.md names spec
v2.1.1, says the catalog-name read matches the front-face table, compares
both photo runs on the top 3, marks the framing estimate as estimated, and
reports the photo run's lookup time. The spec's Problem Statement, Goals
and superseded decision, and the plan's Phase 11 note and self-review,
match the 49-card corpus and Phase 11c.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Confirm flow on live capture next; a strong name match outranks a
disagreeing collector-line match; the photo picker stays with framing
advice; card detection later.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@plainprogrammer
plainprogrammer force-pushed the 007-card-scanner-live-capture branch from 1447cb4 to 366afec Compare October 2, 2026 21:06
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@plainprogrammer
plainprogrammer merged commit 730dfe4 into main Oct 2, 2026
1 check passed
@plainprogrammer
plainprogrammer deleted the 007-card-scanner-live-capture branch October 2, 2026 21:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant