Text capture: read printed labels that have no barcode - #7
Merged
Conversation
Text capture was not good enough to use. Two causes, and measurement separated them: the camera was handing the engine a soft picture, and the engine was being asked to read a whole label when the user only wanted one line off it. **Focus.** The camera opened at 720p and took whatever focus the device happened to be in. That is fine for barcodes — ZXing reads a soft 640px frame — but a product key slightly out of focus is not a harder read, it is a different string, and nothing downstream recovers it. camera.ts now asks for continuous autofocus explicitly rather than trusting the device default, adds tap-to-focus in both modes, opens at 1080p, and raises the stream to the sharpest mode the device offers when text capture starts. Every control degrades to a no-op where it is unsupported. **A band, not the frame.** Text capture reads one full-width strip about a seventh of the frame tall, drawn as brackets over the preview. It is a boundary rather than a hint, so it is never on screen beside the corner guide, which is a hint. The drawn rectangle and the cropped region come from one constant and are never allowed to differ — not even by a forgiving margin that would read a little more than the brackets promise. A clipped line is something the user can see and fix; a band that quietly reads more than it shows is the phone lying about what it saw. Text mode gives up the full-bleed preview to make that visible: under cover on a portrait phone both ends of a wide key sit outside the preview. **Reading it three times.** Rescaling the same captured band perturbs the engine enough that its mistakes stop lining up, so each candidate now carries how many reads produced it — 3/3, 1/3 — which is a far better guide than the engine's own confidence, reported just as brightly for a line it got wrong. Reads differing only in whitespace count towards the whitespace-free form, since the most common failure was a space beside a hyphen with every character correct. Both forms stay in the list; ranking never rewrites. Measured over six strings and six camera-like conditions: the exact string went from never being the first candidate, and in the list 36% of the time, to first 39% and in the list 50%. Measured and rejected rather than assumed: greyscale normalisation and local thresholding (no gain, glare got worse — Tesseract's own binarisation is better), multi-frame voting on consecutive camera frames (accuracy went down; a steady phone gives near-identical frames and the engine repeats its errors exactly), a character whitelist, the 11 MB full-precision language model, and raw-line page segmentation (catastrophic). None changed a single result except the last, which broke everything. The remaining gap is documented rather than papered over: a random key carries no word the engine can lean on. The docs say what the numbers are and name an edit step as the next thing worth building. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012ooGSXdKf1ZKVKSiTycKFs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds a Text mode to the phone scanner, for the serial on a chassis label, the Wi-Fi password on a router, the product key on a card with no barcode beside it.
Two commits: the feature, then a rework after it was tried on a real phone and found not good enough.
Scope
OCR was on the explicitly out-of-scope list in
docs/roadmap.md. It is the one item that moved, and it is worth a deliberate look before merging. The reasoning: it does not pull against the product's single job — it is that job applied to a label with no barcode. The roadmap now records the move and why.The rule everything follows
A decode is exact; a transcription is a guess. So text capture:
OCR_TEXT, so the receiver captions it as a transcription rather than passing it off as a decodeGetting a readable frame
The camera opened at 720p and took whatever focus the device was in. Fine for barcodes — ZXing reads a soft 640px frame — but a product key slightly out of focus is not a harder read, it is a different string.
New
camera.ts: continuous autofocus asked for explicitly rather than trusting the device default, tap-to-focus in both modes, 1080p at open, and the sharpest mode the device offers once text capture starts. Every control degrades to a no-op where unsupported.The band
Text capture reads one full-width strip, about a seventh of the frame tall, drawn as brackets with everything outside dimmed. A boundary, not a hint — which is why it is never on screen beside the corner guide, which is a hint.
The drawn rectangle and the cropped region come from one constant and are never allowed to differ, not even by a forgiving margin that would read slightly more than the brackets promise. A clipped line is something the user can see and fix; a band that quietly reads more than it shows is the phone lying about what it saw. Verified in-browser to match to three decimal places at 16:9 and 4:3, with text above and below the band correctly excluded.
Text mode gives up the full-bleed preview to make that visible: under
coveron a portrait phone, both ends of a wide key sit outside the preview.Reading it three times
Rescaling the same captured band perturbs the engine enough that its mistakes stop lining up. Each candidate carries how many reads produced it —
3/3,1/3— which is a far better guide than the engine's own confidence, reported just as brightly for a line it got wrong.The most common failure was not a misread character: it was a space appearing beside a hyphen with every character correct. Ranking counts whitespace-only differences as agreement. Both forms stay in the list; ranking never rewrites.
Measured
Six strings × six camera-like conditions (soft focus, glare, tilt, sensor noise, small text, sans-serif). Exact full-string recovery:
Measured and rejected rather than assumed:
Where this leaves it
Still not good enough to call solved, and the docs say so rather than dressing it up: a random product key carries no word the engine can lean on. The next step is named in the roadmap — an edit step for the one wrong character, since the diagnosis showed the failures really are that shallow.
All of the above is the WASM engine, which is what a headless browser exposes. Android's
TextDetectoris ML Kit and should be better on exactly this workload, so the primary target is likely better than these numbers. Worth confirming on real hardware — the focus fix in particular is untestable here and may be doing most of the work.Cost
tesseract.jsassets are staged fromnode_modulesintopublic/ocrat build time and served from our own origin, never the CDN the library defaults to. ~14 MB indist(git-ignored, regenerated per build); a device on the fallback path fetches ~4 MB core plus ~3 MB language model once. Phones with nativeTextDetectorfetch none of it.workerBlobURL: falseloads the worker from its path rather than ablob:URL, so no CSP relaxation was needed.Verification
Sentonly afterscan_ack→ receiver shows the value byte-identical, captionedOCR TEXTformat=OCR_TEXT length=29and nothing elsedetect()in flight when the decode loop stops could resolve afterwards, sending one last barcode or leaving two loops running🤖 Generated with Claude Code
https://claude.ai/code/session_012ooGSXdKf1ZKVKSiTycKFs
Generated by Claude Code