加载中…
加载中…
A small image model that looks at a scanned page before OCR and answers two questions:
no_text)lo_blank_write, lo_trunc, lo_loop)Use it to skip pages with nothing to read, to send risky pages to a different OCR setup, or to explain bad OCR output after the fact. It is for libraries, archives and anyone else running OCR over a large number of page scans. It was trained mostly on 19th- and 20th-century printed books from the Biodiversity Heritage Library (BHL), so check it on a sample of your own pages before you rely on it.
MobileNetV4-conv-small, 2.5M parameters, 384 × 384 input, about 50 pages per second on one CPU thread. For the stricter question "is this a page of body text?", use the companion model text-page-filter.
A score near 1 means no text. The picture page is a miss: most labelled picture-only pages score as text.
Seven independent sigmoid outputs (multi-label, not a softmax):
| Output | Meaning | Use |
|---|---|---|
no_text |
No text to transcribe on this side of the leaf: blank paper, show-through only, a library stamp only | Skip OCR, or treat OCR output as suspect |
lo_blank_write |
Page has no text, but LightOnOCR-3 writes text anyway (usually it reads show-through) | Explain invented output |
lo_trunc |
LightOnOCR-3 reaches the 4,096-token output limit we used (long tables, or repetition) | Route to a larger token budget or another engine |
lo_loop |
LightOnOCR-3 repeats itself (dot leaders, repeated lines) | Route or re-run |
table, dense |
Page has a table / dense text (soft teacher answers) | Routing hints |
faint |
Experimental, do not use (see limitations) | – |
import timm, torch
from PIL import Image
model = timm.create_model("hf-hub:small-models-for-glam/page-ocr-flags", pretrained=True).eval()
cfg = timm.data.resolve_data_config({}, model=model) # 384 x 384, squash (no crop), ImageNet mean/std
transform = timm.data.create_transform(**cfg)
labels = model.pretrained_cfg["label_names"]
page = Image.open("page.jpg").convert("L").convert("RGB") # trained on grey pages
with torch.inference_mode():
probs = torch.sigmoid(model(transform(page).unsqueeze(0)))[0]
print(dict(zip(labels, probs.round(decimals=3).tolist())))
Convert pages to grey first, as in the example: the model was trained on grey pages.
no_text| Score | Decision | On the 250 hand-labelled hard pages |
|---|---|---|
| > 0.8 | No text: skip, or flag any OCR output as invented | 67/67 blank, 83/90 show-through, 1/54 text |
| 0.2 – 0.8 | Unsure: route or check | a mix: 5 faint, 5 show-through, 4 text, 3 stamp, 1 picture |
| < 0.2 | Text: OCR normally | 49/54 text |
import numpy as np, timm, torch
from nvidia.dali import fn, pipeline_def, types # pip install nvidia-dali-cuda130 --extra-index-url https://pypi.nvidia.com
from torch.utils.dlpack import from_dlpack
BATCH = 64
@pipeline_def(batch_size=BATCH, num_threads=4, device_id=0)
def pages():
enc = fn.external_source(name="enc", dtype=types.UINT8)
img = fn.decoders.image(enc, device="mixed", output_type=types.GRAY) # decode on the GPU
return fn.resize(img, resize_x=384, resize_y=384, interp_type=types.INTERP_LINEAR, antialias=True)
pipe = pages(); pipe.build()
model = timm.create_model("hf-hub:small-models-for-glam/page-ocr-flags", pretrained=True).eval().cuda().half()
mean = torch.tensor([0.485, 0.456, 0.406], device="cuda").view(1, 3, 1, 1)
std = torch.tensor([0.229, 0.224, 0.225], device="cuda").view(1, 3, 1, 1)
def score(jpeg_bytes): # a list of BATCH encoded JPEGs
pipe.feed_input("enc", [np.frombuffer(b, dtype=np.uint8) for b in jpeg_bytes])
out, = pipe.run()
x = from_dlpack(out.as_tensor()).permute(0, 3, 1, 2).float() / 255 # (B, 1, 384, 384)
x = ((x.expand(-1, 3, -1, -1) - mean) / std).half()
with torch.inference_mode():
return torch.sigmoid(model(x).float()).cpu()
| Test set | Result |
|---|---|
| 2,165 BHL pages with ground-truth transcriptions | no_text at 0.5 keeps 0.999 of text pages and removes 0.994 of empty pages |
| 250 hand-labelled hard pages (one labeller; chosen deliberately, so not a random sample) | no_text AUROC 0.970 (round 1) and 0.935 (round 2) |
| 2,086 pages from 133 books not used in training, scored against LightOnOCR-3-0.8B's real outputs | AUROC 0.941 (lo_blank_write), 0.936 (lo_trunc), 0.930 (lo_loop) |
Numbers are for the released checkpoint (seed 1), plus mean ± SD over three training seeds where shown.
Pages with ground-truth transcriptions: 2,165 BHL pages. Text = at least 80 characters of ground truth.
| Result | |
|---|---|
no_text AUROC, text pages vs empty pages |
1.000 |
| Text pages kept at 0.5 | 0.999 (1,736 / 1,737) |
| Empty pages removed at 0.5 | 0.994 |
| Pages with 1–79 characters (mostly plates with captions) | kept as text (removed: 0.047) |
Hand-labelled hard pages: 250 pages, one labeller, chosen where page models disagreed or flagged something, plus random audits.
| AUROC, no text vs text | |
|---|---|
| Round 1, 78 pages (BHL, Library of Congress and London Medical Officer of Health reports) | 0.970 (3 seeds: 0.976 ± 0.005) |
| Round 2, 172 pages (BHL and Medical Officer of Health reports; books not used in training) | 0.935 (3 seeds: 0.923 ± 0.015) |
| Label | < 0.2 | 0.2 – 0.8 | > 0.8 |
|---|---|---|---|
| text | 49 | 4 | 1 |
| faint text | 6 | 5 | 2 |
| stamp only | 4 | 3 | 14 |
| picture, no text | 4 | 1 | 0 |
| blank | 0 | 0 | 67 |
| show-through only | 2 | 5 | 83 |
LightOnOCR-3 failures: 2,086 pages from 133 books not used in training.
| Output | Positives | AUROC | Precision @0.5 | Recall @0.5 | AUROC, 3 seeds |
|---|---|---|---|---|---|
lo_blank_write |
323 | 0.941 | 0.64 | 0.83 | 0.941 ± 0.003 |
lo_trunc |
141 | 0.936 | 0.43 | 0.72 | 0.930 ± 0.007 |
lo_loop |
143 | 0.930 | 0.44 | 0.72 | 0.922 ± 0.007 |
The loop reference is a simple rule (tail compression, repeated characters). It misses some loops the model flags, so some "false positives" are real failures.
Decoding the page image usually takes longer than running the model.
| Setup | Pages per second |
|---|---|
| Model only, 1 CPU thread (PyTorch / ONNX Runtime, fp32) | ~50 / ~85 |
| Model only, NVIDIA L4 (fp16, batch 64) | ~3,000–4,000 |
| JPEG in, decoded on the GPU with NVIDIA DALI (L4 / RTX PRO 6000) | ~300 / ~430 |
| JPEG 2000 in, decoded on the CPU (Pillow/OpenJPEG) | ~1.5–3 per CPU core |
| Setup | Pages per second |
|---|---|
Model only, NVIDIA RTX PRO 6000 (fp16, torch.compile) |
~40,000 |
| JPEG derivative, 1,024 px, decoded with DALI (A10G) | ~2,000 |
| JPEG 2000, decoded on the GPU with nvImageCodec (nvJPEG2000) (L4 / RTX PRO 6000) | ~80 / ~120 |
FF D9), and decode on the CPU any page the GPU returns empty.faint. It copies the teacher's "faded" answer, which fires on faded paper and show-through, so it is not a faint-text detector.no_text, table, dense and faint, plus the real outputs of LightOnOCR-3-0.8B (Apache-2.0) on every training page for the lo_* outputs.This model was built for printed books. If a more general version (manuscripts, newspapers, modern documents) would be useful to you, feel free to open a discussion in the Community tab. Or, even better, train one yourself! The Training section is the starting point. For a different OCR model, use its outputs on your training pages as the lo_* targets.