PageImprover Geometry Model
PageImprover is a ResNet-34 regression model that takes a single photograph of an open book and predicts the crop rectangles of its two facing pages. It is designed as the geometry stage of a calibrate-and-review workflow for printed books: the model proposes crops, per-book calibration adapts them to the volume, and review routes uncertain page sides to a human.
| Model | ibteda.pt, a TorchScript archive that carries its own preprocessing contract |
| Input | one photograph with both facing pages in frame, any size or format |
| Output | a left and a right page rectangle, from a direct pass and a flipped pass |
| Trained on | printed books from the Ibteda corpus |
| Metric | pass@80, scored per page side |
| Not standalone | useful deployment requires per-book calibration and a review step; this model is stage one of three |
Every figure in this card is a row in docs/claims.md, which names its cohort, calibration state
and evidence. A build gate rejects any figure that drifts from that ledger.
Quick start
git clone https://github.com/ibteda-org/pageimprover && cd pageimprover
uv sync --extra inference # the app and the model runtime; no GPU needed
uv run pageimprover fetch model # downloads ibteda.pt and checks its digest; nothing else ever downloads
uv run pageimprover sample # a public book to try it on, or: add NAME FOLDER for your own photographs
uv run pageimprover studio # process, calibrate, review and export in the browser
From Python, with the inference extra installed:
import cv2
from pageimprover import model
geometry = model.load() # the verified ibteda.pt, refused if the digest differs
prediction = geometry.predict(cv2.imread("spread.jpg")) # any size; letterboxed to the model's tile internally
# "left" and "right" are the photograph's left and right pages, not recto and verso: reading order is applied
# later, from the book's recorded direction. Each side has .direct and .mirrored rectangles, one per pass.
left, right = prediction.side("left"), prediction.side("right")
Input and output
| works | does not work | |
|---|---|---|
| Capture | one photograph per spread, both facing pages in frame | one page per photograph: the model predicts two crops and has nothing to predict on a single leaf |
| Material | printed books | handwritten manuscripts: 0.0727 calibrated pass@80, a documented blind spot |
| Framing | the paper filling most of the frame, roughly centred, as in the training captures | a small paper block in a wide surround; the release measures framing but does not correct it, so crop upstream |
| Image | any size; the package letterboxes it to the model's 704 px tile | |
| Format | JPEG, PNG, TIFF, WebP |
The model's input/output contract is embedded in the artifact itself:
photograph → 1500×1000 working tile, letterboxed to 704×704
→ 24 numbers: for each of {left, right} × {direct pass, flipped pass},
centre x, centre y, log width, log height, sin θ, cos θ
→ two page rectangles in the photograph's own pixels
Closed covers are handled by the upstream framing check: they are cropped from the detected paper block as single pages, and the geometry model is not run on them.

One photograph, two page crops. The rectangles are the operators' finished pages projected back onto the spread: the target the model is trained to reproduce.
The three stages
photographed spread
│
â–¼
1. geometry model proposes the two page crops
│
â–¼
2. per-book calibration five spreads, one crop per page side adjusted on each, one pooled offset
│
â–¼
3. review routing the most disagreement-prone page sides go to a person
│
â–¼
finished page crops
1. The model predicts the left and right page rectangles as one fixed-length vector. Geometric regression, not detection: no anchors, no proposals, no class labels. Its 704 px input was chosen to fit a T4's memory, not for accuracy, and has never been tuned against the acceptance bar.
2. Calibration corrects the one thing no photograph shows: how tightly this book's pages were finished. The
tool picks k=5 spreads spanning the book, skipping the front matter, and shows them one at a
time. On each spread you adjust one left-page and one right-page crop: 10 crop adjustments in
total, pooled into one volume-wide offset (calibrate/pooled_median.py). The offset is applied only when it reaches
40 canonical px (refusals.CALIBRATION_GATE_PX); a smaller one is below the calibration gate
and is not applied.
3. Review runs the model on the spread and on a horizontally flipped copy. A page side whose two answers
disagree by more than 87 canonical px (review.absolute) goes to a person; the rest
ship. The disagreement is a routing signal, not a page error.
Performance
pass@80: a page side passes when the worst of its four corners lands within 80 px of the
operator's crop in the fixed 2048×2897 finishing frame (metrics.PASS_PX). A geometry rule only: not OCR accuracy,
not aesthetics, not "accuracy" in any general sense.
Two held-out cohorts inside the training archive, measured over page sides. Sealed: 54 books, 1,626 sides, held back from every design decision. Confirmation: 192 books, 5,636 sides, unexposed during development.
| stage | sealed cohort | confirmation cohort |
|---|---|---|
| model alone | 0.6770 | — |
| + per-book calibration | 0.8278 | 0.8334 |
| + calibration + human review | 0.9151 | 0.9248, with 28.5% of sides reviewed |
The after-review figures are workflow outcomes, not autonomous model accuracy: every routed side is counted as passing on the assumption that the reviewer corrects it, while unrouted sides retain the model's calibrated crop. The five calibration spreads are excluded from routing, since an operator has already drawn them. The review share is an operating point inside one archive, not a portable promise.
Using it on another collection
The model can be applied to printed-book spreads from other scanning setups, subject to three conditions.
- Calibrate every book. Away from the training archive this is not optional: the calibration is what adapts the model to a binding and a finishing convention it has never seen. On a public seventeenth-century atlas photographed by another collection, the model found the text block on all twelve spreads of the benchmark, and the remaining observed error was a consistent margin offset, which is what the ten crops remove. A worked example, not a measurement: no accuracy figure is claimed for other collections.
- Frame the paper as the training captures did. Most of the frame, roughly centred. A wide surround needs cropping before inference.
- Do not carry the review numbers across. The disagreement score's level and ordering do not transfer, and the threshold and review share describe one archive. Read the routed queue as sides to look at, not as a workload forecast.
Do not use it on handwritten manuscripts. See the evaluation notes.
Training data and attribution
Trained on completed page crops from the Ibteda corpus (https://ibteda.org): books finished by hand by the archive's operators, whose crops are the target. The model reproduces a finishing convention; it does not invent one. Any use should credit the Ibteda corpus and its operators as the source of the training crops.
The convention is narrow: 98.7% of finished pages used one 2048×2897 Photoshop template, and the export round-trip reconstructs that historical output to a median residual of 3.0 px. Both are facts about one archive's finishing habit, not about the model.
Evaluation notes
Methodological detail behind the figures above. Skip it to use the model; read it before quoting a number.
Calibration design. The calibration gain was measured on its own, larger cohort: held-out pass@80 rose from 0.7107 uncalibrated to 0.8271 with the single pooled offset. Two choices are recorded as costs, not victories. Pooled, not per-side: using the same ten crops, a per-side estimator harmed 8 held-out books, while the pooled estimator harmed 2; the estimator was the only difference. Five spreads, not seven: seven beat five by +0.0104 pass@80, decisively (p<0.0001), and was declined because at corpus scale that point costs thousands of extra operator pages.
The review sweep, and a withdrawn claim. Reaching a higher 0.930 target under a coverage sweep, which is not the deployed rule, needs 34.5% review on sealed and 30.5% on confirmation. A sweep point and the deployed point answer different questions; the two were once mixed to produce a claim that had to be withdrawn, which is why this card keeps them apart.
Manuscripts. The deployed model reads handwritten pages badly: 0.0727 calibrated pass@80 on the manuscript sides evaluated, with the routing signal actively anti-ranked rather than merely uninformative. A model built to fix this was measured and refused: it learned its manuscripts almost perfectly and still failed both foreign reads. This model ships with the blind spot named rather than papered over.
The disagreement score away from the archive. On foreign captures the flip-consistency gap is large, signed
and material-dependent: it overstated a small error by more than an order of magnitude on one book, tracked a large
median on another, and on a fourth book ranked the two most nearly perfect sides last.
review.score.disagreement_px must not be read as a page error in either direction.
Untested levers. A larger input than 704 px, and letterboxing versus centring for a frame with no surround. Both are unmeasured, not rejected.
Reproducibility and versions
- File:
ibteda.pt - SHA-256:
359ae48b6a846e01d70a21c2c49426125f3b8950ae7b4736ba8cf954af60babe - Pinned by: pageimprover 0.1.0
- Code, tests and the claim ledger: https://github.com/ibteda-org/pageimprover
- Model identity, from the artifact's embedded contract: ResNet-34 trunk, checkpoint
best_pass80.ptat epoch 59, 704 px letterboxed input built from a 1500×1000 working tile, 24 output numbers as declared above. The package exposes this contract and refuses an artifact whose contract disagrees with the code. - Hub revision: pinned in
pageimprover.config.ARTIFACTinside the package, never in this card, because the revision exists only after the commit that publishes it.pageimprover fetch modelrequests that revision alone. - Evidence:
docs/claims.mdis derived from the research project's evidence ledger, committed 2026-09-04 as1a259aaeeb8d05524695dba7a2a969e395a0e482, and cites it by commit. The release audit reproduces each value against that commit's retained result files. - Public benchmark:
pageimprover fetch benchmark coronelli_1693from a checkout downloads a twelve-spread seventeenth-century atlas with a digest per frame and reproduces the recorded framing measurements.
Citation
@software{pageimprover_2026,
title = {PageImprover: page geometry for photographed book spreads},
author = {{Ibteda Digital Library}},
year = {2026},
url = {https://ibteda.org},
note = {Model artifact ibteda.pt (CC BY 4.0); code Apache-2.0 at https://github.com/ibteda-org/pageimprover}
}
Licence and authorization
The code is Apache-2.0. The model artifact ibteda.pt is published under CC BY 4.0, with attribution to the
Ibteda Digital Library. The required credit line is:
Trained on the Ibteda Digital Library corpus; the page crops are the work of its operators.
A written authorization from the rights holder, dated 2026-09-04, covers the code, the weights and the licence
they are published under, the benchmark metadata, this attribution, and the measurements derived from the
training data that appear in this card and in docs/claims.md. The authorization is
correspondence and is retained with the project's private records; this repository states only that it exists
and what it covers.
Evaluation results
- raw pass@80 - sealed sensitivity test, 54 books, 1,626 routed sides (calibration pages excluded) - uncalibrated, unrouted [claim geo_raw_sealed] on Ibteda Digital Library held-out cohorts (in-archive; each metric names its cohort)self-reported0.677
- held-out pass@80, uncalibrated - calibration confirmation, 249 books, 5,022 held-out sides per repeat, 20 repeats - uncalibrated [claim cal_baseline] on Ibteda Digital Library held-out cohorts (in-archive; each metric names its cohort)self-reported0.711
- held-out pass@80, one pooled 10-crop book offset - calibration confirmation, 249 books - calibrated (pooled, 10 crops) [claim cal_pooled] on Ibteda Digital Library held-out cohorts (in-archive; each metric names its cohort)self-reported0.827
- calibrated pass@80, before review - routing confirmation, 192 books, 5,636 held-out sides - calibrated, unrouted [claim route_conf_baseline] on Ibteda Digital Library held-out cohorts (in-archive; each metric names its cohort)self-reported0.833
- pass@80 at the deployed review rule - routing confirmation, 192 books, 5,636 sides - calibrated + routed (28.5% review) [claim route_conf_routed] on Ibteda Digital Library held-out cohorts (in-archive; each metric names its cohort)self-reported0.925
- calibrated pass@80, before review - sealed sensitivity, 54 books, 1,626 sides - calibrated, unrouted [claim route_sealed_baseline] on Ibteda Digital Library held-out cohorts (in-archive; each metric names its cohort)self-reported0.828
- pass@80 the deployed review rule delivers - sealed sensitivity, 54 books, 1,626 sides - calibrated + routed (deployed rule) [claim route_sealed_routed] on Ibteda Digital Library held-out cohorts (in-archive; each metric names its cohort)self-reported0.915