加载中…
加载中…
A lightweight binary classifier that tells whether a document region crop is legible enough to
be sent to an OCR model. It labels each crop good or bad.
It is meant to sit between a layout detector and a (vision-language) OCR model. Crops are produced
by a detector such as Inceptive/PP-DocLayoutV3-ONNX,
and this model flags the ones that are too blurry, pixelated or compression-damaged. The pipeline can
then skip those regions instead of running expensive inference on them, since unreadable regions
are a common source of hallucinated OCR output.
The previous release, Inceptive/document-gate-keeper-v0,
stays available unchanged. v0.5 expects different preprocessing, so do not reuse v0's code (see
Note for v0 users).
| Task | Binary image classification (bad / good) |
| Format | ONNX, opset 17, single file (no external-data sidecar) |
| Size | 1.5 MB, 390,370 parameters |
| Input | input_IMAGE, float32[N, 3, 640, 640], NCHW, RGB, normalized |
| Output | output_0, float32[N, 2], softmax probabilities, order [bad, good] |
| Runtime | Any ONNX Runtime execution provider (CPU is fast enough for per-crop use) |
| Test F1 | 0.9857 on 1,396 held-out crops (see Evaluation) |
input_IMAGEfloat32 tensor of shape [N, 3, 640, 640], with a dynamic batch dimension N. The image must be
preprocessed exactly as during training:
(255, 255, 255). Padding happens in pixel space, before normalization.[0, 1] (divide by 255).mean = [0.485, 0.456, 0.406],
std = [0.229, 0.224, 0.225], per channel.Do not stretch crops to 640 × 640. A plain resize distorts the aspect ratio, which the model never saw during training, and shifts its scores.
The same contract is published in config.json (resize_mode, allow_upscale,
pad_value, pad_position, normalization_mean, normalization_std, classes, …), and the
snippets below read it from there.
v0.5's preprocessing differs from v0's in two ways:
| v0 | v0.5 | |
|---|---|---|
| Padding color | black (0, 0, 0) |
white (255, 255, 255) |
| Crops smaller than 640 px | never upscaled | upscaled so the longest side is 640 px |
Feeding v0.5 with v0's preprocess() gives it inputs it was not trained on.
output_0float32 tensor of shape [N, 2], holding class probabilities in the order ["bad", "good"]. The
graph already ends with a Softmax, so do not apply another one.
The decision rule we use is P(good) < 0.6 → low quality. Raise the threshold to skip more regions
(fewer hallucinations, more missed text), or lower it to keep more.
Install the dependencies:
pip install onnxruntime numpy pillow huggingface_hub
Classify one crop:
import json
import numpy as np
import onnxruntime as ort
from huggingface_hub import hf_hub_download
from PIL import Image
REPO_ID = "Inceptive/document-gate-keeper-v0.5"
# config.json holds the preprocessing contract used during training.
with open(hf_hub_download(repo_id=REPO_ID, filename="config.json")) as f:
config = json.load(f)
SIZE = config["input_size"][0]
PAD_VALUE = tuple(config["pad_value"])
MEAN = np.array(config["normalization_mean"], dtype=np.float32)
STD = np.array(config["normalization_std"], dtype=np.float32)
CLASSES = tuple(config["classes"])
def preprocess(image: Image.Image) -> np.ndarray:
"""PIL image -> float32 [1, 3, 640, 640] tensor, as used during training."""
image = image.convert("RGB")
# Resize so that the longest side is exactly 640 px, keeping the aspect ratio (small crops are upscaled).
w, h = image.size
scale = SIZE / max(w, h)
image = image.resize((max(1, round(w * scale)), max(1, round(h * scale))), Image.BILINEAR)
# Center the image on a white 640x640 canvas: padding happens in pixel space, before normalization.
canvas = Image.new("RGB", (SIZE, SIZE), PAD_VALUE)
canvas.paste(image, ((SIZE - image.width) // 2, (SIZE - image.height) // 2))
x = np.asarray(canvas, dtype=np.float32) / 255.0 # HWC, [0, 1]
x = (x - MEAN) / STD # ImageNet normalization
return x.transpose(2, 0, 1)[np.newaxis] # NCHW
model_path = hf_hub_download(repo_id=REPO_ID, filename=config["model_file"])
session = ort.InferenceSession(
model_path,
providers=["CUDAExecutionProvider", "CPUExecutionProvider"], # falls back to CPU
)
image = Image.open("crop.png")
probs = session.run([config["output_name"]], {config["input_name"]: preprocess(image)})[0][0] # already softmaxed
p_bad, p_good = float(probs[0]), float(probs[1])
print(f"P(bad)={p_bad:.3f} P(good)={p_good:.3f} -> {CLASSES[int(probs.argmax())]}")
GOOD_QUALITY_THRESHOLD = config["good_threshold"]
if p_good < GOOD_QUALITY_THRESHOLD:
print("Low-quality crop: skip OCR or flag it for review.")
Classify several crops in one call (the batch dimension is dynamic):
paths = ["region_0.png", "region_1.png", "region_2.png"]
batch = np.concatenate([preprocess(Image.open(p)) for p in paths]) # [N, 3, 640, 640]
probs = session.run([config["output_name"]], {config["input_name"]: batch})[0] # [N, 2]
for path, (p_bad, p_good) in zip(paths, probs):
print(path, "good" if p_good >= GOOD_QUALITY_THRESHOLD else "bad", f"(P(good)={p_good:.3f})")
Each crop is padded to 640 × 640, so a batch of N crops costs N × 3 × 640 × 640 × 4 bytes of
input (≈ 4.9 MB per crop). Batch in moderate chunks.
With a layout detector: run Inceptive/PP-DocLayoutV3-ONNX
on the page, crop each detected box from the page image at its original resolution, and pass each
crop through preprocess above. Do not run the classifier on the detector's own resized input.
The model was evaluated on a held-out test split (10% of the dataset): 1,396 crops, 688 bad and
708 good.
| Class | Precision | Recall | F1 | Error rate | Crops |
|---|---|---|---|---|---|
bad |
0.9744 | 0.9971 | 0.9856 | 0.29% | 688 |
good |
0.9971 | 0.9746 | 0.9857 | 2.5% | 708 |
| Overall | 0.9857 | 0.9857 | 0.9857 | 1.4% | 1,396 |
Confusion matrix (rows: true class, columns: predicted class):
predicted bad |
predicted good |
|
|---|---|---|
bad |
686 | 2 |
good |
18 | 690 |
That is 20 errors out of 1,396 crops, an accuracy of 98.57%. The per-class error rate is
1 − recall. The overall precision and recall are micro-averaged, so they both equal the accuracy,
and the overall F1 is the macro average of the two class F1 scores. The average test loss is 0.2581.
These crops come from the same synthetic pipeline as the training crops (see Training data). The numbers measure how well the model detects resampling and JPEG damage, not how it performs on real-world scans. Tune the threshold on your own data.
A small convolutional network:
| Stage | Layers | Output channels |
|---|---|---|
| Block 1 | Conv 3×3 (stride 1, padding 1, no bias) → BatchNorm → ReLU → MaxPool 2×2 | 32 |
| Block 2 | Conv 3×3 → BatchNorm → ReLU → MaxPool 2×2 | 64 |
| Block 3 | Conv 3×3 → BatchNorm → ReLU → MaxPool 2×2 | 128 |
| Block 4 | Conv 3×3 → BatchNorm → ReLU → MaxPool 2×2 | 256 |
| Head | Global average pooling → Flatten → Dropout (p = 0.3) → Linear (256 → 2) → Softmax | 2 |
The 390,370 parameters are 389,410 trainable weights plus the 960 BatchNorm running statistics.
The preprocessing contract is published in config.json, and its basic fields are
also embedded in the ONNX file's metadata_props:
import onnx
print({p.key: p.value for p in onnx.load(model_path).metadata_props})
The dataset holds 13,947 crops, split 90% / 10% into about 12,522 training and 1,396 test crops. It is synthetic and was built from a single public source:
Construction:
Inceptive/PP-DocLayoutV3-ONNX, and each
detected layout region was cropped. The classifier therefore sees the same kind of input it gets
at inference time in an OCR pipeline.good. Degraded crops are labelled bad.Unlike v0, no FUNSD data is used.
| Setting | Value |
|---|---|
| Epochs | 10 |
| Batch size | 16 |
| Optimizer | AdamW, weight decay 1e-4 |
| Learning rate | 5e-4, constant |
| Loss | Cross-entropy with label smoothing 0.1 |
| Input | 640 × 640 × 3: RGB, aspect-preserving resize (upscaling allowed), centered white padding, ImageNet normalization |
The final training loss is 0.2581 (test: 0.2589). With label smoothing 0.1 and two classes, the lowest reachable loss is about 0.199, so both values are close to that floor despite the ~98.5% accuracy.
bad
even when it is clean. Use it on tight layout-detector crops, not on arbitrary rectangles.P(good) threshold on your own data if the
cost of skipping a region differs from the cost of OCR'ing a bad one.This model is released under the Apache License 2.0, which allows commercial use.
Training data attribution: the training crops are derived from the page images of olmOCR-mix-0225, which AI2 releases under ODC-BY 1.0. ODC-BY requires attribution (see Citation). The dataset's GPT-4o-generated transcriptions were not used to train this model.
If you use this model, please also cite the dataset it was trained on:
@misc{poznanski2025olmocr,
title = {olmOCR: Unlocking Trillions of Tokens in PDFs with Vision Language Models},
author = {Jake Poznanski and Jon Borchardt and Jason Dunkelberger and Regan Huff and Daniel Lin and Aman Rangapur and Christopher Wilhelm and Kyle Lo and Luca Soldaini},
year = {2025},
eprint = {2502.18443},
archivePrefix = {arXiv},
primaryClass = {cs.CL}
}