ibteda.pt: the deployed geometry model, with its card
Browse files
README.md
CHANGED
|
@@ -45,148 +45,204 @@ audit regenerates this card and fails on any difference. -->
|
|
| 45 |
|
| 46 |
# PageImprover Geometry Model
|
| 47 |
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
margins, and a review queue sends the least trustworthy page sides to a person. It was trained and measured on one
|
| 51 |
-
archive's books and is built to be pointed at anyone's; the calibration is what carries it to a book it has never
|
| 52 |
-
seen. On its own it does not produce a finished page, and the figures below only mean anything as a trio.
|
| 53 |
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
*
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
calibration state and the evidence behind it. A build gate refuses any figure that drifts from that ledger.
|
| 61 |
-
|
| 62 |
-
## What it takes as input
|
| 63 |
-
|
| 64 |
-
| | works | does not work |
|
| 65 |
-
|---|---|---|
|
| 66 |
-
| Capture | one photograph per spread, both facing pages in the frame | one page per photograph: the model predicts two crops per image and has nothing to predict on a single leaf |
|
| 67 |
-
| Material | printed books | handwritten manuscripts, a documented blind spot: 0.0727<!--claim:manuscript_blindspot--> calibrated pass@80 |
|
| 68 |
-
| Framing | the paper filling most of the frame, roughly centred, as in the training captures | a small paper block in a wide surround, which the release measures and reports but does not correct |
|
| 69 |
-
| Image size and format | any size; the package letterboxes the image to the model's 704<!--claim:model_input--> px tile itself; JPEG, PNG, TIFF and WebP | |
|
| 70 |
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
## Getting the model
|
| 75 |
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
package: it is a TorchScript archive carrying its own preprocessing contract, not a state dict.
|
| 79 |
|
| 80 |
-
|
|
|
|
| 81 |
|
| 82 |
-
##
|
| 83 |
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
|
|
|
|
|
|
|
|
|
| 88 |
|
| 89 |
-
|
| 90 |
-
80<!--claim:pass_bar--> px of the operator's crop, measured in the fixed 2048×2897 finishing frame. This is
|
| 91 |
-
`metrics.PASS_PX`, a geometry rule and nothing more: not an OCR score, not an aesthetic judgement, and not
|
| 92 |
-
"accuracy" in any sense a reader should generalise.
|
| 93 |
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
(`calibrate/pooled_median.py`). The offset is applied only when its magnitude reaches 40<!--claim:cal_gate-->
|
| 98 |
-
canonical px (`refusals.CALIBRATION_GATE_PX`); below that it is indistinguishable from an operator's own
|
| 99 |
-
self-consistency and can only add noise.
|
| 100 |
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
operator pages.
|
| 106 |
|
| 107 |
-
|
| 108 |
-
and flags a side when the two disagree by more than 87<!--claim:route_threshold--> canonical px
|
| 109 |
-
(`review.absolute`). Those sides go to a person; the rest ship.
|
| 110 |
|
| 111 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
|
| 113 |
-
|
| 114 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
|
| 116 |
| stage | sealed cohort | confirmation cohort |
|
| 117 |
|---|---|---|
|
| 118 |
| model alone | 0.6770<!--claim:geo_raw_sealed--> | — |
|
| 119 |
| + per-book calibration | 0.8278<!--claim:route_sealed_baseline--> | 0.8334<!--claim:route_conf_baseline--> |
|
| 120 |
-
| + review of the routed sides | 0.9151<!--claim:route_sealed_routed--> | 0.9248<!--claim:route_conf_routed-->, with 28.5%<!--claim:route_conf_share--> of
|
| 121 |
|
| 122 |
-
The
|
| 123 |
-
0.7107<!--claim:cal_baseline--> uncalibrated to 0.8271<!--claim:cal_pooled--> with the single pooled offset, a gain
|
| 124 |
-
bought with five spreads of operator work per book.
|
| 125 |
-
|
| 126 |
-
The review share is an in-archive operating point, not a portable promise; a different collection's distribution
|
| 127 |
-
moves it. Reaching a higher 0.930 target under a coverage *sweep*, which is explicitly not the deployed rule, needs
|
| 128 |
-
34.5%<!--claim:route_sweep_930_sealed--> review on sealed and 30.5%<!--claim:route_sweep_930_conf--> on
|
| 129 |
-
confirmation. A sweep point and the deployed point answer different questions, and the two were once mixed to
|
| 130 |
-
produce a claim that had to be withdrawn.
|
| 131 |
|
| 132 |
## Using it on another collection
|
| 133 |
|
| 134 |
The model works on printed-book spreads from other scanning setups, on three conditions.
|
| 135 |
|
| 136 |
-
- **Calibrate every book.**
|
| 137 |
-
the
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
- **Do not carry the review numbers across.** The disagreement score's level does not transfer, its ordering was
|
| 147 |
-
found not to either, and the deployed threshold and review share describe one archive. Read the routed queue as
|
| 148 |
-
a list of sides to look at, not as a forecast of operator workload.
|
| 149 |
-
|
| 150 |
-
## Known failure modes
|
| 151 |
-
|
| 152 |
-
**Handwritten manuscripts.** The deployed model reads printed material well at correct framing and reads
|
| 153 |
-
handwritten pages badly: 0.0727<!--claim:manuscript_blindspot--> calibrated pass@80 on the manuscript sides
|
| 154 |
-
evaluated, with the routing signal actively anti-ranked rather than merely uninformative. A model built to fix this
|
| 155 |
-
was measured and refused: it learned its manuscripts almost perfectly and still failed both foreign reads, so this
|
| 156 |
-
model ships with the blind spot named rather than papered over. Anything quoting this model as adequate for
|
| 157 |
-
manuscript work should quote that number instead.
|
| 158 |
-
|
| 159 |
-
**The disagreement score away from the archive.** On foreign captures the flip-consistency gap is large, signed
|
| 160 |
-
and material-dependent: it has overstated a small error by more than an order of magnitude on one book and tracked
|
| 161 |
-
a large median on another, and on a fourth book its ranking put the two most nearly perfect sides last.
|
| 162 |
-
`review.score.disagreement_px` is a routing signal, not a page error, and away from the archive it must not be read
|
| 163 |
-
as one in either direction.
|
| 164 |
|
| 165 |
-
**
|
| 166 |
-
fair chance. The release reports framing; it does not fix it.
|
| 167 |
|
| 168 |
## Training data and attribution
|
| 169 |
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
|
| 175 |
-
|
| 176 |
-
2048×2897 Photoshop template, and the export round-trip reconstructs that historical output to a median residual
|
| 177 |
-
of 3.0<!--claim:canvas_residual--> px. The high share is a fact about one archive's finishing habit, not a universal
|
| 178 |
-
page ratio, and the residual describes the convention's reproducibility, not the model's quality.
|
| 179 |
|
| 180 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 181 |
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
|
|
|
|
|
|
| 185 |
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 190 |
|
| 191 |
## Licence and authorization
|
| 192 |
|
|
|
|
| 45 |
|
| 46 |
# PageImprover Geometry Model
|
| 47 |
|
| 48 |
+
PageImprover is a ResNet-34 regression model that takes one photograph of an open book and returns the crop
|
| 49 |
+
rectangles of its two pages, built to run inside a calibrate-and-review workflow on printed books.
|
|
|
|
|
|
|
|
|
|
| 50 |
|
| 51 |
+
- **Model:** `ibteda.pt`, a TorchScript archive that carries its own preprocessing contract
|
| 52 |
+
- **Input:** one photograph with both facing pages in frame, any size or format
|
| 53 |
+
- **Output:** a left and a right page rectangle, from a direct pass and a flipped pass
|
| 54 |
+
- **Trained on:** printed books from the Ibteda corpus
|
| 55 |
+
- **Metric:** pass@80, scored per page side
|
| 56 |
+
- **Needed to be useful:** a per-book calibration and a review step; the model is stage one of three
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
|
| 58 |
+
<img src="https://ibteda.org/journey/assets/full/bahar-correspondence-spread.jpg" width="480"
|
| 59 |
+
alt="A photographed spread from the Ibteda archive on a dark table, with the operators' finished page crops projected back onto it">
|
|
|
|
|
|
|
| 60 |
|
| 61 |
+
*One photograph, two page crops. The rectangles are the operators' finished pages projected back onto the spread:
|
| 62 |
+
the target the model is trained to reproduce.*
|
|
|
|
| 63 |
|
| 64 |
+
Every figure in this card is a row in [`docs/claims.md`](https://github.com/ibteda-org/pageimprover/blob/main/docs/claims.md), which names its cohort, calibration state
|
| 65 |
+
and evidence. A build gate rejects any figure that drifts from that ledger.
|
| 66 |
|
| 67 |
+
## Quick start
|
| 68 |
|
| 69 |
+
```sh
|
| 70 |
+
git clone https://github.com/ibteda-org/pageimprover && cd pageimprover
|
| 71 |
+
uv sync --extra inference # the app and the model runtime; no GPU needed
|
| 72 |
+
uv run pageimprover fetch model # downloads ibteda.pt and checks its digest; nothing else ever downloads
|
| 73 |
+
uv run pageimprover sample # a public book to try it on, or: add NAME FOLDER for your own photographs
|
| 74 |
+
uv run pageimprover studio # process, calibrate, review and export in the browser
|
| 75 |
+
```
|
| 76 |
|
| 77 |
+
From Python, with the `inference` extra installed:
|
|
|
|
|
|
|
|
|
|
| 78 |
|
| 79 |
+
```python
|
| 80 |
+
import cv2
|
| 81 |
+
from pageimprover import model
|
|
|
|
|
|
|
|
|
|
| 82 |
|
| 83 |
+
geometry = model.load() # the verified ibteda.pt, refused if the digest differs
|
| 84 |
+
prediction = geometry.predict(cv2.imread("spread.jpg")) # any size; letterboxed to the model's tile internally
|
| 85 |
+
left, right = prediction.side("left"), prediction.side("right") # each has .direct and .mirrored rectangles
|
| 86 |
+
```
|
|
|
|
| 87 |
|
| 88 |
+
## Input and output
|
|
|
|
|
|
|
| 89 |
|
| 90 |
+
| | works | does not work |
|
| 91 |
+
|---|---|---|
|
| 92 |
+
| 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 |
|
| 93 |
+
| Material | printed books | handwritten manuscripts: 0.0727<!--claim:manuscript_blindspot--> calibrated pass@80, a documented blind spot |
|
| 94 |
+
| 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 |
|
| 95 |
+
| Image | any size; the package letterboxes it to the model's 704<!--claim:model_input--> px tile | |
|
| 96 |
+
| Format | JPEG, PNG, TIFF, WebP | |
|
| 97 |
+
|
| 98 |
+
The contract, as the artifact itself declares it:
|
| 99 |
+
|
| 100 |
+
```text
|
| 101 |
+
photograph → 1500×1000 working tile, letterboxed to 704×704
|
| 102 |
+
→ 24 numbers: for each of {left, right} × {direct pass, flipped pass},
|
| 103 |
+
centre x, centre y, log width, log height, sin θ, cos θ
|
| 104 |
+
→ two page rectangles in the photograph's own pixels
|
| 105 |
+
```
|
| 106 |
+
|
| 107 |
+
Closed covers are recognised by the framing check and cropped from the detected paper block as single pages. The
|
| 108 |
+
model is not asked about them.
|
| 109 |
|
| 110 |
+
## The three stages
|
| 111 |
+
|
| 112 |
+
```text
|
| 113 |
+
photographed spread
|
| 114 |
+
│
|
| 115 |
+
▼
|
| 116 |
+
1. geometry model proposes the two page crops
|
| 117 |
+
│
|
| 118 |
+
▼
|
| 119 |
+
2. per-book calibration five spreads, ten operator crops, one pooled offset
|
| 120 |
+
│
|
| 121 |
+
▼
|
| 122 |
+
3. review routing the least trustworthy page sides go to a person
|
| 123 |
+
│
|
| 124 |
+
▼
|
| 125 |
+
finished page crops
|
| 126 |
+
```
|
| 127 |
+
|
| 128 |
+
**1. The model** predicts the left and right page rectangles as one fixed-length vector. Geometric regression, not
|
| 129 |
+
detection: no anchors, no proposals, no class labels. Its 704 px input was chosen to fit a T4's memory, not for
|
| 130 |
+
accuracy, and has never been tuned against the acceptance bar.
|
| 131 |
+
|
| 132 |
+
**2. Calibration** corrects the one thing no photograph shows: how tightly this book's pages were finished. The
|
| 133 |
+
tool picks five spreads spanning the book, skipping the front matter, and shows them one at a time. On each you
|
| 134 |
+
adjust the left and right page crops. That is 10<!--claim:cal_crops--> crops from k=5<!--claim:k_default-->
|
| 135 |
+
spreads, pooled into a single offset for the whole book (`calibrate/pooled_median.py`). The offset is applied only
|
| 136 |
+
when it reaches 40<!--claim:cal_gate--> canonical px (`refusals.CALIBRATION_GATE_PX`); a smaller one is within an
|
| 137 |
+
operator's own self-consistency and is not applied.
|
| 138 |
+
|
| 139 |
+
**3. Review** runs the model on the spread and on a horizontally flipped copy. A page side whose two answers
|
| 140 |
+
disagree by more than 87<!--claim:route_threshold--> canonical px (`review.absolute`) goes to a person; the rest
|
| 141 |
+
ship. The disagreement is a routing signal, not a page error.
|
| 142 |
+
|
| 143 |
+
## Performance
|
| 144 |
+
|
| 145 |
+
pass@80: a page side passes when the worst of its four corners lands within 80<!--claim:pass_bar--> px of the
|
| 146 |
+
operator's crop in the fixed 2048×2897 finishing frame (`metrics.PASS_PX`). A geometry rule only: not OCR accuracy,
|
| 147 |
+
not aesthetics, not "accuracy" in any general sense.
|
| 148 |
+
|
| 149 |
+
Two held-out cohorts inside the training archive, measured over **page sides**. Sealed: 54 books, 1,626 sides,
|
| 150 |
+
held back from every design decision. Confirmation: 192 books, 5,636 sides, unexposed during development.
|
| 151 |
|
| 152 |
| stage | sealed cohort | confirmation cohort |
|
| 153 |
|---|---|---|
|
| 154 |
| model alone | 0.6770<!--claim:geo_raw_sealed--> | — |
|
| 155 |
| + per-book calibration | 0.8278<!--claim:route_sealed_baseline--> | 0.8334<!--claim:route_conf_baseline--> |
|
| 156 |
+
| + review of the routed sides | 0.9151<!--claim:route_sealed_routed--> | 0.9248<!--claim:route_conf_routed-->, with 28.5%<!--claim:route_conf_share--> of sides reviewed |
|
| 157 |
|
| 158 |
+
The review share is an operating point inside one archive, not a portable promise.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
|
| 160 |
## Using it on another collection
|
| 161 |
|
| 162 |
The model works on printed-book spreads from other scanning setups, on three conditions.
|
| 163 |
|
| 164 |
+
- **Calibrate every book.** Away from the training archive this is not optional: the calibration is what adapts
|
| 165 |
+
the model to a binding and a finishing convention it has never seen. On a public seventeenth-century atlas
|
| 166 |
+
photographed by another collection, the model found the text block on every spread and the remaining error was
|
| 167 |
+
one consistent margin offset, which is what the ten crops remove. A worked example, not a measurement: no
|
| 168 |
+
accuracy figure is claimed for other collections.
|
| 169 |
+
- **Frame the paper as the training captures did.** Most of the frame, roughly centred. A wide surround needs
|
| 170 |
+
cropping before inference.
|
| 171 |
+
- **Do not carry the review numbers across.** The disagreement score's level and ordering do not transfer, and
|
| 172 |
+
the threshold and review share describe one archive. Read the routed queue as sides to look at, not as a
|
| 173 |
+
workload forecast.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
|
| 175 |
+
**Do not use it on handwritten manuscripts.** See the evaluation notes.
|
|
|
|
| 176 |
|
| 177 |
## Training data and attribution
|
| 178 |
|
| 179 |
+
Trained on completed page crops from the **Ibteda corpus**: books finished by hand by the archive's operators,
|
| 180 |
+
whose crops are the target. The model reproduces a finishing convention; it does not invent one. Any use should
|
| 181 |
+
credit the Ibteda corpus and its operators as the source of the training crops.
|
| 182 |
+
|
| 183 |
+
The convention is narrow: 98.7%<!--claim:canvas_share--> of finished pages used one 2048×2897 Photoshop template,
|
| 184 |
+
and the export round-trip reconstructs that historical output to a median residual of
|
| 185 |
+
3.0<!--claim:canvas_residual--> px. Both are facts about one archive's finishing habit, not about the model.
|
| 186 |
+
|
| 187 |
+
## Evaluation notes
|
| 188 |
|
| 189 |
+
Methodological detail behind the figures above. Skip it to use the model; read it before quoting a number.
|
|
|
|
|
|
|
|
|
|
| 190 |
|
| 191 |
+
**Calibration design.** The calibration gain was measured on its own, larger cohort: held-out pass@80 rose from
|
| 192 |
+
0.7107<!--claim:cal_baseline--> uncalibrated to 0.8271<!--claim:cal_pooled--> with the single pooled offset. Two
|
| 193 |
+
choices are recorded as costs, not victories. Pooled, not per-side: splitting the same ten crops by page side
|
| 194 |
+
harmed 8<!--claim:cal_harm_perside--> held-out books; pooling harmed 2<!--claim:cal_harm_pooled-->, same crops,
|
| 195 |
+
estimator the only difference. Five spreads, not seven: seven beat five by +0.0104<!--claim:k7_delta--> pass@80,
|
| 196 |
+
decisively (p<0.0001), and was declined because at corpus scale that point costs thousands of extra operator
|
| 197 |
+
pages.
|
| 198 |
|
| 199 |
+
**The review sweep, and a withdrawn claim.** Reaching a higher 0.930 target under a coverage *sweep*, which is not
|
| 200 |
+
the deployed rule, needs 34.5%<!--claim:route_sweep_930_sealed--> review on sealed and
|
| 201 |
+
30.5%<!--claim:route_sweep_930_conf--> on confirmation. A sweep point and the deployed point answer different
|
| 202 |
+
questions; the two were once mixed to produce a claim that had to be withdrawn, which is why this card keeps them
|
| 203 |
+
apart.
|
| 204 |
|
| 205 |
+
**Manuscripts.** The deployed model reads handwritten pages badly: 0.0727<!--claim:manuscript_blindspot-->
|
| 206 |
+
calibrated pass@80 on the manuscript sides evaluated, with the routing signal actively anti-ranked rather than
|
| 207 |
+
merely uninformative. A model built to fix this was measured and refused: it learned its manuscripts almost
|
| 208 |
+
perfectly and still failed both foreign reads. This model ships with the blind spot named rather than papered over.
|
| 209 |
+
|
| 210 |
+
**The disagreement score away from the archive.** On foreign captures the flip-consistency gap is large, signed
|
| 211 |
+
and material-dependent: it overstated a small error by more than an order of magnitude on one book, tracked a large
|
| 212 |
+
median on another, and on a fourth book ranked the two most nearly perfect sides last.
|
| 213 |
+
`review.score.disagreement_px` must not be read as a page error in either direction.
|
| 214 |
+
|
| 215 |
+
**Untested levers.** A larger input than 704 px, and letterboxing versus centring for a frame with no surround.
|
| 216 |
+
Both are unmeasured, not rejected.
|
| 217 |
+
|
| 218 |
+
## Reproducibility and versions
|
| 219 |
+
|
| 220 |
+
- File: `ibteda.pt`
|
| 221 |
+
- SHA-256: `359ae48b6a846e01d70a21c2c49426125f3b8950ae7b4736ba8cf954af60babe`
|
| 222 |
+
- Pinned by: pageimprover 0.1.0
|
| 223 |
+
- Code, tests and the claim ledger: <https://github.com/ibteda-org/pageimprover>
|
| 224 |
+
- Model identity, from the artifact's embedded contract: ResNet-34 trunk, checkpoint `best_pass80.pt` at epoch 59,
|
| 225 |
+
704 px letterboxed input built from a 1500×1000 working tile, 24 output numbers as declared above. The package
|
| 226 |
+
exposes this contract and refuses an artifact whose contract disagrees with the code.
|
| 227 |
+
- Hub revision: pinned in `pageimprover.config.ARTIFACT` inside the package, never in this card, because the
|
| 228 |
+
revision exists only after the commit that publishes it. `pageimprover fetch model` requests that revision alone.
|
| 229 |
+
- Evidence: [`docs/claims.md`](https://github.com/ibteda-org/pageimprover/blob/main/docs/claims.md) is derived from the research project's evidence ledger, committed
|
| 230 |
+
2026-09-04 as `1a259aaeeb8d05524695dba7a2a969e395a0e482`, and cites it by commit. The release audit reproduces each
|
| 231 |
+
value against that commit's retained result files.
|
| 232 |
+
- Public benchmark: `pageimprover fetch benchmark coronelli_1693` from a checkout downloads a twelve-spread
|
| 233 |
+
seventeenth-century atlas with a digest per frame and reproduces the recorded framing measurements.
|
| 234 |
+
|
| 235 |
+
## Citation
|
| 236 |
+
|
| 237 |
+
```bibtex
|
| 238 |
+
@software{pageimprover_2026,
|
| 239 |
+
title = {PageImprover: page geometry for photographed book spreads},
|
| 240 |
+
author = {{Ibteda Digital Library}},
|
| 241 |
+
year = {2026},
|
| 242 |
+
url = {https://github.com/ibteda-org/pageimprover},
|
| 243 |
+
note = {Model artifact ibteda.pt (CC BY 4.0); code Apache-2.0}
|
| 244 |
+
}
|
| 245 |
+
```
|
| 246 |
|
| 247 |
## Licence and authorization
|
| 248 |
|