What a card feeder broke in my Lorcana scanner
The last article ended on a line I liked: none of the interface work touched the matcher, all of it decided whether anyone else could use it.
This one ends the same way, for a less comfortable reason. I changed the rig: a phone on a 3D-printed stand, and a machine that turns the cards. The matcher carried on being fine. The descriptor is untouched at 288 bytes, still matching in 7.5ms. Everything that broke was in the thin layer that decides when to look, and it broke thoroughly, because all of it had been written on the quiet assumption that a hand was holding the card.
The rig
A phone in a stand, pointed down at a fixed spot. A feeder that slides cards onto that spot, one at a time.
The interesting difference isn’t the automation. It’s that the card never leaves. A feeder doesn’t take a card away and present the next one, it drops the next one on top of the last. The stack under the lens only ever grows.
The old scanner waited for a card to disappear before it would accept another. On this rig nothing ever disappears, so it scanned one card and then sat there politely forever.
The camera is always landscape
Continuity Camera publishes a landscape frame no matter which way the phone is physically mounted, and there is no rotation setting anywhere. Not in the OS, not in the browser. Mount a phone upright in a stand and you get a perfectly good picture of a card lying on its side.
The fix is one rotation, once, at the top of the pipeline. A canvas that stands the frame up before anything else looks at it:
const frame = tool.upright(element, videoWidth, videoHeight, quarters)
const guide = guideRect(frame.width, frame.height, fill)
The guide, edge-snap and the fingerprint all carry on assuming an upright card, and none of them had to learn about orientation. At zero quarter-turns it hands the original frame straight back, so the ordinary path costs nothing.
What I hadn’t expected is that rotating makes the match better. The guide is the largest card-shaped rectangle that fits in the frame. In a 1920x1080 landscape frame that rectangle is limited by the height, giving a card 348px across. Rotate the frame to 1080x1920 and the same rule becomes limited by the width instead: 486 px across, about 40% more pixels for the fingerprint to read.
Portrait mounting isn’t a compromise I make for the stand. It’s the better way to point a camera at a card, and I’d been leaving it on the table the whole time.
Nothing ever leaves the frame
From part one, on re-arming, which is deciding when the next card is allowed to be accepted:
So re-arm needs two consecutive frames scoring below threshold, meaning the card physically left the frame, plus a 600ms cooldown.
That rule exists because of the playset trap. Four identical cards in a row is the normal case when you’re sorting a collection, so “re-arm when the top match changes” silently swallows three of them. Absence was the only honest signal available.
On a feeder there is no absence. And the obvious alternative, “the match changed”, is still exactly as wrong as it was before.
The signal that does work is the landing itself. A card dropping onto the stack disturbs a large part of the frame for a moment, and then everything goes still again. That reads as a new card whether or not it happens to be identical to the one underneath, which is precisely the thing the match can never tell you.
So the locate step reports one more number: the mean grayscale change since the previous frame, measured on the fixed 192x268 sample around the guide rather than on the snapped crop. That distinction matters more than it sounds. Measured on the crop, a card sitting perfectly still while edge-snap wobbles by a pixel reads as movement.
Sometimes it worked. Sometimes it didn’t, and I couldn’t see why.
Fixed thresholds could not have worked
I had picked the thresholds by feel: a landing is a reading above 11, settled is below 5. Then I put the number on screen and watched it.
| mean change per pixel | |
|---|---|
| desk at rest | 3 |
| feeder running, between cards | 18 |
| the instant a card lands | 40 |
Both numbers wrong, and wrong in a way no amount of nudging would fix. The working state of the machine, 18, sits above my “a card landed” threshold of 11, so the feeder simply running was indistinguishable from a card arriving. And “settled, below 5” only ever happened if you stopped feeding entirely.
The mistake wasn’t the values. It was believing a fixed number could exist. A phone on a still desk idles at 3. This rig idles at 18. Both mean nothing is happening.
So the trigger became a multiple of whatever that camera normally shows, against a rolling baseline: 1.8x the baseline for a landing, 1.3x for settled, with an absolute floor of 6 so that noise on a very still rig can’t multiply itself into a trigger. The baseline follows slowly upward, 0.03 per frame, so a landing barely moves it. Downward it moves quickly, 0.2, so the end of a busy run registers within a second.
On my rig that same code resolves to a trigger of about 32 while the feeder runs, and about 6 when it pauses.
Lesson: if you’re tuning a constant against one machine, the constant is probably the wrong shape. Measure what “normal” is on the machine you’re actually on.
There’s a second re-arm path as well, because motion can’t always see a gentle landing: a different card, read clearly, two frames running. It stays deliberately quiet for a second copy of the same card. That’s the playset case, and it belongs to the motion path.
Three wrong turns
1. A pause that was really a stop
Roughly 18% of Lorcana cards share their artwork with a reprint: 577 cards across 279 groups. When one turns up, scanning stops and asks which set it is. That pause was implemented by flipping the flag that enables the scan loop.
The flag was an effect dependency. Answering the question flipped it back, React re-ran the effect, and the effect body opens like this:
history.current = []
armed.current = true // the card you just filed is still under the lens
So it came back fully armed, saw the same card, and counted it again. Worse for a reprint specifically, because the answer is remembered for the rest of the session: the second pass didn’t even stop to ask. It just quietly added a duplicate.
A pause and a stop are different things, and they have different inputs now. Enabled means the camera is running. Paused means hold still for an answer. The loop is never torn down for a pause, so the arming state outlives the question.
Lesson: “stop doing the thing” and “unmount the thing that does the thing” look identical right up until the thing owns state you needed to keep.
2. The tail of a landing looks exactly like the next landing
With the adaptive trigger in, I replayed my measured levels through the state
machine as scripted frames. The continuously-running case went from A to
A B C. Fixed. But the paced case, where the feeder pauses between cards, now
produced A B B B2.
B twice. The new logic had introduced a duplicate that the old broken thresholds never had.
A landing isn’t instant. It spikes to 40, then spends a moment settling around 18 before dropping back to rest. The faster re-arm was confirming the card during that tail. And the rest of the tail, now measured against a baseline that had fallen back to 3, looked exactly like a fresh card arriving.
A landing now requires a calm frame first, and confirming a card clears that flag. Whatever movement is still dying down belongs to the card that was just counted.
3. Silence, with no error anywhere
The scanner plays a tone per card so you can watch the cards instead of the screen. That was the whole point of the audio, and on the feeder rig it stopped making any sound at all. Nothing threw. Nothing logged.
I spent an embarrassing number of rounds reasoning about it from the code. Suspended contexts, autoplay policy, output routing. All plausible. None of them verifiable from where I was sitting.
What actually ended it was putting three words on screen: the audio context’s state
and a count of cues attempted. 1 cues said the app had tried exactly once and
then never again, which pointed at the scan loop, not at the audio. Later, audio muted said the rest.
The mute button was labelled with its state (Sound on), which reads like an
action, so clicking it to “turn the sound on” turned it off. It says Muted in
amber now, and enabling it plays the confirm tone immediately: the gesture that
enables audio is also the one that proves it works.
Two real bugs fell out on the way past. Cues read the clock off a possibly-suspended
context, and a suspended context freezes currentTime. So the whole gain envelope
landed in the past and finished before it started. A note built correctly, throwing
nothing, silent. And the notes were pure sine waves at 12% gain, which is a fine
choice in a quiet room and completely inaudible next to a running feeder. They’re
triangle waves at roughly triple that now.
Lesson: I can reason about a bug I can’t observe for a very long time. Three words of instrumentation ended it in one round. Instrument first, theorise second.
Correcting a bad pick without stopping
When the scanner picks the wrong printing of a card, the fix used to be: notice, delete the row, type the card in by hand.
The index already knew the answer. It carries a map of artwork id to card indices, built when the index loads and, until now, used only for matching. Two new worker messages later, any scanned row can offer its own alternatives:
largest group (4 printings):
P1 · #3 Elsa
1 · #41 Elsa
9 · #53 Elsa
9 · #210 Elsa
Click the right one and the row is corrected. No network, because it’s all sitting in an index that’s already in memory. There’s a manual set-and-number entry beside it for when the matcher read the wrong card entirely, rather than the wrong printing of the right one.
The part that matters is what correcting does to the session’s memory. It remembers your reprint answers so it stops asking, which means a wrong answer would otherwise repeat itself on every subsequent copy. Correcting a row overwrites the answer too.
The question nobody saw
The reprint question used to render below the camera panel, which on a laptop is
below the fold. Meanwhile the diagnostics readout said votes 0/3, because a
paused loop reports its idle state, and idle means armed.
So: a live camera, a readout claiming everything was normal, a scanner that had silently stopped, and the one thing that would restart it sitting off-screen. Every individual piece was behaving correctly.
It’s a modal now, over the top of everything, with the cards shown large enough to
actually tell apart. The keyboard shortcut stands down while it’s open, the guide
frame turns amber, and the readout says paused · waiting for your answer. There’s
a skip, because nothing has been filed at that point. The card can just go past
the camera again.
Deleting a row asks first now, in a dialog that shows the art. Part one made the
list editable because confidently wrong is 0.4%, which is small and isn’t zero. But
during a feeder run the list is moving, and “Remove?” on its own asks you to trust
that the row you clicked is the row you meant. The art answers that. The row then
slides out and collapses so the list closes behind it, animating grid-template-rows
from 1fr to 0fr, which finds the row’s real height without anyone having to
guess at it.
The numbers
| card width in the guide, landscape | 348px |
| card width in the guide, rotated | 486px |
| motion at rest / feeding / landing | 3 / 18 / 40 |
| landing trigger | 1.8x baseline, floor 6 |
| baseline response, up / down | 0.03 / 0.2 per frame |
| artwork groups with more than one printing | 279 (577 cards, 18.1%) |
| changes to the matcher | 0 |
What’s still unproven
The motion thresholds are ratios rather than constants, which makes them portable in principle. In practice they’ve been checked against exactly one feeder, one phone and one desk, and the ratios are a guess informed by three measurements.
Two known gaps. A second copy of the same card, landing almost pixel-perfectly on top of the first, is genuinely indistinguishable from nothing happening. The match can’t help, and the movement is too small to trip anything. On a real feeder the cards land slightly offset and the one underneath still shows at the edges, so it should be rare. I can’t say it’s impossible.
And none of the feeder logic is covered by the synthetic-camera harness from part one. The state machine has been replayed against scripted frame sequences at the levels I measured, which is how both duplicate bugs got caught, but a Y4M feed of an actual landing doesn’t exist yet. That’s the next thing to build, and it’s the piece that would turn “observed working on my desk” into something I’d defend.