It reads · entirely in your browser

A 3-billion-parameter
OCR model. In this tab.

franken_ocr hand-ports vision-language models into memory-safe Rust with model-specific CPU kernels — no PyTorch, no Python, no CUDA, no GPU, no FFI at inference. The same code that ships as the focr CLI compiles to WebAssembly, so Baidu's Unlimited-OCR document model and the TrOMR sheet-music model run entirely on your CPU, in this page. Your documents never leave it — the CSP's connect-src 'self' makes that a browser-enforced promise rather than a privacy-policy one.

Try it in your browser Install the CLI

100% Rust 6.67 GB bf16 → 3.00 GB int4 58 MB sheet-music model, phones included 80–250 s per document page in-tab 0 bytes uploaded
The franken_ocr monster at a reading desk, stitching scanned pages into text

Live · in this tab

Run it right now.

Two models, both executed by hand-written Rust kernels compiled to wasm, entirely on your CPU. TrOMR (58 MB) turns sheet-music scans into MusicXML and runs anywhere, phones included. Baidu Unlimited-OCR (2.8 GB int4, desktop Chrome) parses document pages into Markdown. Every download is SHA-256-verified against a pinned manifest and cached for next time. There is no server behind this panel and no mocked demo.

01 · The specimen

int8 route:

Starting engine…

02 · The page

Drop an image here (PNG or JPEG), click to pick a file, or paste one from the clipboard.

Nothing here is uploaded. The image is read into this tab's memory, handed to a worker, and recognized by wasm on your own cores.

03 · The transcription

Output appears here: MusicXML for a staff, Markdown for a document page — the same bytes the CLI writes, downloadable with one click.

What to expect. TrOMR takes roughly a second or two per staff. Unlimited-OCR is a different animal: on the seven-page measurement corpus it took 80 to 250 seconds per page in Chromium, with a cold model load of about 95 seconds on top. On a cross-origin-isolated Chrome the decode spreads across eight worker threads; everywhere else it runs the serial simd128 build.

The 2.8 GB artifact needs roughly 3.5 GB of memory to hydrate, which is why it is gated to desktop browsers. If your device cannot hold it, TrOMR still will.

The anatomy

What a page becomes.

Unlimited-OCR is not an OCR box with a language model bolted on. It is one forward pass that turns pixels into 256 visual tokens and then writes the page as Markdown, one token at a time, out of a 129,280-entry vocabulary. Every shape below is fixed at compile time in this port, which is exactly why the kernels can be hand-written.

One forward pass, left to right

  1. The page image

    Decoded in-tab, resized and normalized by the same preprocessing code the native CLI runs — certified exact against the Python reference before any kernel work was allowed to start. Nothing is uploaded; the bytes go from your file picker into a worker.

  2. SAM-ViT-B — the layout reader

    A 16×16 patch embedding turns the page into a 64×64 grid of 4,096 tokens at width 768, then twelve transformer blocks run windowed attention (window 14) with global attention at blocks 2, 5, 8 and 11, plus decomposed relative-position bias. This is the stage that understands where things are on the page: columns, tables, margins.

  3. The 16× token compressor

    A convolutional neck — 1×1, layernorm, 3×3, layernorm, then two stride-2 convolutions — collapses 4,096 tokens into 256 at width 1024. Sixteen times fewer tokens reach the decoder, and the decoder's attention cost is what dominates a long page. This single block is the reason a whole scanned page fits in one pass.

  4. CLIP-L/14 — the semantic reader

    Twenty-four layers at width 1024 with sixteen heads, fused directly onto the SAM output: CLIP's embedding layer takes SAM's feature map as its patch embeddings instead of re-reading the image. SAM contributes where, CLIP contributes what.

  5. Concatenate, then project

    The two towers are concatenated channel-wise into 2,048 dimensions and a single linear layer maps them to the decoder's 1,280-wide residual stream. One matrix — and the exact concatenation order was resolved from the reference implementation rather than guessed, because getting it backwards produces plausible-looking garbage.

  6. The DeepSeek-V2 mixture-of-experts decoder

    Twelve layers, hidden size 1,280, ten attention heads of dimension 128. Layer 0 is a dense SwiGLU MLP; layers 1 through 11 are mixture-of-experts: 64 routed experts plus 2 fused shared experts, with a softmax router picking the top 6 greedily and no renormalization. Attention is R-SWA — a read-only reference block holding the whole visual prefill, plus a 128-slot ring that slides. Memory stays flat while the page grows.

  7. Out comes Markdown

    The final RMSNorm feeds an lm_head GEMV from 1,280 to 129,280 logits, and greedy decoding writes the page: headings, paragraphs, tables, LaTeX, layout boxes. The browser writes exactly what the CLI writes, from exactly the same artifact — that equality is measured, not assumed.

Why 3.3 billion parameters fit in a tab at all

A mixture-of-experts layer stores 64 expert MLPs and runs six of them. The router is a single f32 matrix that is never quantized — quantizing it drifts expert selection, and a wrong expert is not a rounding error, it is a different answer. Watch which six light up as tokens stream past.

01

Every dimension is a constant

A general framework pays a generality tax on every op: dynamic dtype dispatch, arbitrary shapes, autograd bookkeeping. Here the shapes — 1,280 hidden, 10 heads, 64 experts, window 128, vocab 129,280 — are compile-time constants, so each kernel is written for its exact case. There is no generic runtime underneath.

02

Correctness before speed, always

Every model is certified against its Python reference through a parity ladder: preprocessing exact, per-op cosine ≈ 1.0, per-layer hidden states, logits, decoded tokens, end-to-end error rates. A faster kernel that drifts the output is reverted and written into a negative-evidence ledger rather than shipped.

03

Every fast kernel has a slow twin

Each accelerated int8 kernel ships with a bit-identical scalar oracle, and focr robot selftest re-proves the dispatched kernel on your CPU — including the browser's, where the wasm build reports its route the same way the playground above prints it.

The diet

6.67 GB down to 3.00 GB.

The browser gets its own artifact, produced by a wasm-only recipe that no native user ever receives. Nothing here is a guess about what "should" be safe: the recipe was calibrated on real scanned pages and then measured page by page against the bf16 reference decode.

Four bits, one shared scale

A bf16 weight spends 16 bits on itself. An int4 weight spends 4 bits, as a two's-complement nibble, and shares one f32 scale with the rest of its group. The group size is the whole trade: small groups follow local outliers closely and pay more metadata; large groups are cheap and blur very differently-sized weights together. Drag it and watch the cost.

Where the 3.00 GB actually goes

Read straight out of the shipped artifact's own tensor table. Grey is what the component weighed in bf16; green is what it weighs now. The vision tower does not move at all — it is 27% of the file and stays bf16 because it is the stage with no error budget to spend.

Component bytes, bf16 source versus the shipped wasm artifact. Measured.
Componentbf16ShippedFormat
routed experts4.841.72int4 g16/g32
vision tower0.800.80bf16
lm_head0.330.17int8 per-channel
embeddings0.330.17int8 per-channel
attention0.160.08int8 per-channel
shared experts0.150.05int4 g16/g32
dense MLP + router0.050.02int4, router bf16

What calibration changed

Round-to-nearest is the naive recipe: clip each group at its own maximum and hope. The shipped artifact instead runs an importance-weighted clip search — candidate clipping ranges scored by how much they disturb the activations that actually flow through, using statistics gathered from a 13-page run over real scans — plus an AWQ-style scale fold into down_proj. Coverage: 2,148 of 2,148 int4 tensors.

It bought a measured 10.9% relative reduction in character error on the hardest page and did not regress a single page. That is the honest size of the win: a real improvement, not a fix.

What stays in high precision, and why

The router stays bf16 — a drifted gate picks a different expert, and that is a different answer, not a small one. The vision tower stays bf16: every downstream token is conditioned on its 256 outputs, so error there is not local. Layernorms stay bf16 because they are free. Attention, lm_head and the embedding table go to int8 per-channel, where measurement said it was safe.

A note on units, because they matter here: the artifact is 3,003,988,117 bytes — 3.00 GB decimal, which your browser will report as 2.8 GB because it divides by 1024. Both numbers describe the same file.

The browser port

Five walls, in the order we hit them.

The engine's inference core is pure synchronous compute over byte buffers, so it compiles to wasm32-unknown-unknown with the async runtime, SQL run-store and CLI feature-gated away. One code path serves native and wasm. Getting a 3.3-billion-parameter model to actually run under those rules took five separate fixes, each found the hard way.

  1. The artifact did not fit the address space

    The CLI's Unlimited-OCR artifact is 4,157,448,783 bytes. wasm32 addresses at most 4 GiB — 4,294,967,296 bytes — of linear memory, total, for everything.

    That leaves about 137 MB for the engine, the activations, the KV ring and the vision scratch buffers. It is not a tight fit; it is not a fit.

    A wasm-only quantization recipe: int4 on the expert bulk, int8 on attention, lm_head and embeddings, bf16 on the vision tower and the router. 6.67 GB of bf16 source becomes a 3.00 GB artifact that the native resolver refuses to serve as a default — this file exists for one lane only.

  2. Fitting on disk is not fitting in memory

    Even with the smaller artifact, peak resident memory during a page measured 5.7 GB. Browsers do not hand you that.

    Hydration widened the whole vision tower at once, kept the embedding table in f32, and copied weight bytes into owned buffers on the way in.

    Vision weights hydrate per block and stream; the embedding table stays int8-resident and dequantizes per row on access; the hot paths borrow the staged bytes zero-copy instead of owning copies. Peak fell to 3.6 GB, which is why the playground gates the model to desktop.

  3. One allocation cannot exceed 2 GiB

    wasm32 caps a single allocation at 2 GiB. A 3.00 GB blob has nowhere to land as one object, however much total memory is free.

    The obvious workaround — chop it every 1 GiB — cuts tensors in half, and a tensor that spans two allocations cannot be borrowed as one slice by a kernel.

    The staging planner reads the artifact's own header out of the first 4 MiB of the stream, then cuts the blob into ≤1 GiB segments at tensor boundaries. Every tensor stays contiguous inside exactly one segment, so every kernel still sees one slice.

  4. Chrome refuses a 3 GB ArrayBuffer

    Buffering the download to hash it before handing it over fails outright: Array buffer allocation failed, measured at 3.0 GB.

    Verification appears to need the whole file at once — you cannot check a SHA-256 you have not finished reading — and WebCrypto's digest API cannot be streamed.

    The weight bytes are never materialized JavaScript-side. Each fetch chunk is fed to an incremental SHA-256, pushed into the wasm staging sink, and teed into the Cache API in the same pass. Peak JS residency is one chunk plus a 4 MiB header prefix. The digest is checked per part and across the whole stream; a failure deletes the cache entry rather than hydrating unverified bytes.

  5. Threads are a link-time decision

    wasm threads need a shared memory and TLS exports chosen when the module is linked, not a flag you pass at runtime.

    And feature detection is not enough: SharedArrayBuffer also exists on iOS under COOP/COEP, where growing a shared memory toward 2 GB reclaims the tab.

    Two modules ship — serial, and shared-memory with atomics — chosen by an allow-list: cross-origin-isolated Blink only. A byte-sized blob worker proves the CSP permits the rayon pool's spawn path before a single weight byte downloads, the instantiated memory is checked to be genuinely shared, and the pool is armed only after hydration, because a worker parked in Atomics.wait blocks the memory growth that hydration is made of.

Verified, every visit

Size and full SHA-256 are checked against a pinned manifest on the first download and on every warm start from cache. The wasm engine refuses a mismatched byte count; the loader refuses a mismatched digest. A truncated or tampered download can never hydrate.

Crash breadcrumbs

The worker posts a stage name before every dangerous step, and the page commits it synchronously to storage — so an out-of-memory kill still leaves evidence. A breadcrumb naming a hydration stage suppresses auto-load on the next visit and offers a reset page instead of a silent second crash.

Split at the 2 GiB release cap

GitHub caps a release asset at 2 GiB, so the artifact ships as ordered byte-split parts. The loader streams them as one logical byte stream and pins each part's digest as well as the whole file's — the split is a delivery detail that never reaches the engine.

Measured, including the part that hurts

Exactly what degrades, and where.

The corpus is seven pages: three repository fixtures and four public-domain archive.org scans from 1873 and 1886. Everything below is on those pages, with true Levenshtein distances. The full entry, including what the reference is and is not, lives in the repository's discrepancies ledger as DISC-005.

4/7

Byte-identical to the CLI

Same artifact, browser versus native: four pages match byte for byte apart from the CLI's trailing newline, and two more differ by three characters or fewer.

3/7

Pages int4 left exact

Quantization fidelity against the bf16 reference decode is exactly zero on three of seven pages and at most 0.0006 on two more. Clean type survives int4 intact.

0.156

…and 0.156 on the worst page

Dense 1886 newsprint is where int4 costs something real. We are not going to average that away: it is a sixth of the characters on that page.

Quantization fidelity, page by page

Character error rate of the int4 artifact against the bf16 reference decode, per page. Grey is the naive round-to-nearest recipe; green is the calibrated one that ships. The scale is linear and shared, which is why six of the seven bars are almost invisible and the seventh is not.

Character error rate versus the bf16 reference decode. Lower is better; 0 is exact.
PageRTN (v1)Calibrated (shipped)
1886 newsprint0.17530.1562
GOT sample text0.01550.0155
1873 guide p30.00110.0006
1873 guide p40.00130.0005
1873 guide p20.00000.0000
GOT formula0.00000.0000
GOT table0.00000.0000

Corpus micro-averaged CER: 0.0962 round-to-nearest → 0.0856 calibrated. The reference is a proxy: the certified native bf16-source decode, not a human transcription. The archive scans have no ground truth, and the bf16 decode is itself imperfect OCR.

Browser versus native, same artifact

Character distance between the wasm decode and the native CLI decode running the exact same file. The int8 kernels are bit-identical by the selftest contract; the drift source is autovectorized f32 glue codegen, proven by forcing the scalar route natively and reproducing the native answer exactly.

PageDistanceVerdict
GOT formula1trailing newline only
GOT table1trailing newline only
1873 guide p21trailing newline only
1873 guide p31trailing newline only
GOT sample text33 characters
1873 guide p433 characters
1886 newsprint4819repetition loop, unguarded

The one page that fell over

Unguarded, the hardest page tipped the wasm decode into a degenerate repetition loop — CER 0.3489 against the native int4 decode. The playground applies the repetition guard the CLI manual already documents (no_repeat_ngram = 20), set from the model manifest and labeled there, and a native user can reproduce the browser's behaviour with the identical knob. It is a mitigation, not a repair, and it is written down as one.

Where the receipts live

Every claim on this page traces to docs/DISCREPANCIES.md, which records the model commit, the fixture hashes, the CPU feature string, the exact command, what the reference behaviour was, what ours is, the kill switch, and a review date. Negative results get entries too — that is the point of keeping the file.

The zoo

Five models, one binary.

Each one is a separate hand-port with its own kernels and its own parity ladder. The CLI carries all five; the browser carries the two that fit its rules.

Sizes are the default pulled artifact. The browser column says where a model runs in the playground above.
ModelTaskSizeCLIBrowser
Unlimited-OCR Baidu General document OCR → Markdown, tables, LaTeX, layout boxes 4.16 GB
3.00 GB int4 in-browser
default desktop
Polyphonic-TrOMR NetEase Sheet music → MusicXML, full page or staff crop 58 MB yes everywhere
GOT-OCR2 Formulas, tables, charts, geometry, molecular structures 776 MB yes possible next
SmolVLM2 Photo description and visual question answering 1.01 GB yes
OneChart Chart → structured data 346 MB yes

Take it home

One static binary.

The full product — all five models, PDFs, batching, robot/agent mode — with no runtime, no Python, and nothing to configure.

# install
curl -fsSL https://raw.githubusercontent.com/Dicklesworthstone/franken_ocr/main/install.sh | bash

# document OCR, the default model
focr pull
focr ocr scan.png -o page.md

# sheet music
focr pull tromr
focr ocr --model tromr.int8.focrq --task music score.png -o score.musicxml

# prove the dispatched kernels on your own CPU
focr robot selftest

macOS (Apple Silicon and Intel), Linux (x86-64 and ARM64), Windows (x86-64 and ARM64) — six platforms, each with a SHA-256 sidecar. Runtime ISA dispatch picks NEON, SDOT, SMMLA, AVX2, AVX-VNNI or AVX-512-VNNI per host, so there is one binary per architecture rather than one per microarchitecture. The CLI is faster than the tab and certified deeper; the tab needs no installation and sends nothing anywhere.