Text page filter

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.

Example pages with the model's not_proper_text score and the ground-truth character count

How to use

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()

What counts as "body text"

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

Evaluation

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 tables

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.

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)

Speed

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
More speed figures and tips for large runs
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
  • JPEG derivatives: for repeated runs over one collection, store small JPEG derivatives once. At 1,024 px (long side) they give the same decisions as full-size input. 512 px is about twice as fast but agrees slightly less.
  • GPU preprocessing matches the reference: the same decision on every test page with DALI and JPEG input (largest score difference 0.015), and on 99.6% of pages with nvImageCodec, JPEG 2000 input and an area resize.
  • JPEG 2000 on the GPU: nvJPEG2000 (via nvImageCodec or DALI) decoded all 60 BHL files we tested. Before batching, check that each file is complete (a complete codestream ends with FF D9), and decode on the CPU any page the GPU returns empty.
  • ONNX: fp32 export gives identical scores. Static int8 quantisation changed the scores a lot in our test, so we do not recommend it.

Limitations

  • Domain: mostly 19th–20th-century printed books. Manuscripts, newspapers and modern documents are untested.
  • The 100-character line is a choice. Pages near it (a short poem, a chapter opening, a caption-heavy figure page) can go either way.
  • Show-through is the hardest case. The model removes most show-through pages, but some with strong mirrored text can pass as text.

Training

  • Data: 17,653 training pages from about 3,000 public-domain BHL books. Books with hand-labelled pages are held out.
  • Target: a VLM page-question teacher (openjev 4B, MIT, with a LoRA adapter) combined with the length of the output of LightOnOCR-3-0.8B (Apache-2.0), as described above.
  • Recipe: MobileNetV4-conv-small from ImageNet, grey pages squashed to 384 × 384, BCE on soft targets, 8 epochs, 104 s on one A100.
  • Teacher provenance: the openjev page-question adapter was trained on DocLayNet, BHL page types and RVL-CDIP pages. RVL-CDIP has unclear licence terms. This model was trained on BHL images and the teacher's answers only.

Other kinds of material

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.

Downloads last month
7
Safetensors
Model size
2.52M params
Tensor type
F32
·
Inference Providers NEW
This model isn't deployed by any Inference Provider. 🙋 Ask for provider support

Model tree for small-models-for-glam/text-page-filter

Finetuned
(9)
this model