Loading…
Loading…
A small image model that answers one question about a scanned page: is this a page of body text?
It keeps pages with running text, lists and tables, and drops pages without it: blank paper, show-through from the other side of the leaf, library stamps, plates with a short caption, and pages with only a few lines. Use it to build text corpora from page scans, or to pick the pages worth sending to an expensive OCR model. 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 looser question "is there any text here?" (captions and page numbers count) plus OCR-risk flags, use the companion model page-ocr-flags.
The model has one sigmoid output, not_proper_text: the probability that the page has no body text. Keep pages with not_proper_text < 0.5.
import timm, torch
from PIL import Image
model = timm.create_model("hf-hub:small-models-for-glam/text-page-filter", 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)
page = Image.open("page.jpg").convert("L").convert("RGB") # trained on grey pages
with torch.inference_mode():
p = torch.sigmoid(model(transform(page).unsqueeze(0)))[0, 0].item()
print("body text" if p < 0.5 else "no body text", round(p, 3))
Convert pages to grey first, as in the example: the model was trained on grey pages.
Batch inference on the GPU (NVIDIA DALI, JPEG bytes in)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/text-page-filter", 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()
A page counts as body text when a page-question model says it has text worth transcribing and LightOnOCR-3-0.8B writes at least 100 characters on it. So:
| Page | Decision (by the target definition; not measured per type) |
|---|---|
| Running text, lists, indexes, tables | kept |
| Blank paper, show-through only, stamp only | dropped |
| Plate with a short caption (about 100 characters or less) | dropped |
| Page number, running head, half-title only | dropped |
| Test set, cut at 0.5 | Result |
|---|---|
| 2,165 BHL pages with ground-truth transcriptions: text pages (≥ 80 characters) kept | 0.941 |
| Same pages: empty and few-line pages (under 80 characters) removed | 0.986 (mean of 3 training runs) |
| 250 hand-labelled hard pages (one labeller; chosen deliberately, so not a random sample): non-text pages removed | 0.951 (mean of 3 training runs) |
Almost all the text pages it drops are plates with captions: 88 of the 102 come from one volume of shell plates (median 113 characters of ground truth). This is the intended behaviour, but check it fits your definition of a text page.
Full evaluation tablesNumbers 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.
| Result | |
|---|---|
| AUROC, text pages (≥ 80 characters) vs empty pages | 0.999 |
| Pages under 80 characters (empty and few-line) removed at 0.5 | 0.986 ± 0.002 (3 seeds) |
| Text pages kept at 0.5 | 0.941 (3 seeds: 0.943 ± 0.010) |
Hand-labelled hard pages: 250 pages, one labeller. Text = the labeller's "text" class, which includes short pages and captions, so this definition is looser than the model's.
| Result | |
|---|---|
| AUROC, text vs everything else | 0.952 (round 1, 78 pages), 0.935 (round 2, 172 pages) |
| Non-text pages removed at 0.5 (3 seeds) | 0.951 ± 0.006 |
| Labelled text pages kept at 0.5 (3 seeds) | 0.735 ± 0.028 (short pages and captions are dropped by design) |
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.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.
timm/mobilenetv4_conv_small.e2400_r224_in1k