tidyscan 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tidyscan-0.3.0/CHANGELOG.md +25 -0
- tidyscan-0.3.0/LICENSE +28 -0
- tidyscan-0.3.0/PKG-INFO +202 -0
- tidyscan-0.3.0/README.md +172 -0
- tidyscan-0.3.0/pyproject.toml +73 -0
- tidyscan-0.3.0/src/tidyscan/__init__.py +156 -0
- tidyscan-0.3.0/src/tidyscan/__main__.py +227 -0
- tidyscan-0.3.0/src/tidyscan/_budget.py +36 -0
- tidyscan-0.3.0/src/tidyscan/_page.py +28 -0
- tidyscan-0.3.0/src/tidyscan/hocr.py +146 -0
- tidyscan-0.3.0/src/tidyscan/models.py +170 -0
- tidyscan-0.3.0/src/tidyscan/ocr.py +140 -0
- tidyscan-0.3.0/src/tidyscan/ocrmypdf_paddle.py +122 -0
- tidyscan-0.3.0/src/tidyscan/orientation.py +225 -0
- tidyscan-0.3.0/src/tidyscan/paddle.py +193 -0
- tidyscan-0.3.0/src/tidyscan/pdf.py +179 -0
- tidyscan-0.3.0/src/tidyscan/py.typed +0 -0
- tidyscan-0.3.0/src/tidyscan/skew.py +264 -0
- tidyscan-0.3.0/src/tidyscan/watch.py +332 -0
- tidyscan-0.3.0/tests/__init__.py +0 -0
- tidyscan-0.3.0/tests/eval_combos.py +179 -0
- tidyscan-0.3.0/tests/eval_corpus.py +163 -0
- tidyscan-0.3.0/tests/eval_deskew-requirements.txt +451 -0
- tidyscan-0.3.0/tests/eval_deskew.Dockerfile +27 -0
- tidyscan-0.3.0/tests/eval_deskew.py +1273 -0
- tidyscan-0.3.0/tests/eval_ocr.py +1260 -0
- tidyscan-0.3.0/tests/test_tidyscan.py +1285 -0
- tidyscan-0.3.0/tools/convert-requirements.txt +22 -0
- tidyscan-0.3.0/tools/models-licence-apache-2.0.txt +202 -0
- tidyscan-0.3.0/tools/models.py +203 -0
- tidyscan-0.3.0/tools/paddle-requirements.txt +8 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.3.0 (2026-11-06)
|
|
4
|
+
|
|
5
|
+
First public release.
|
|
6
|
+
|
|
7
|
+
- **Upright**: Tesseract's orientation detection, used only with enough
|
|
8
|
+
confidence and an allowed script; an unsure upside-down page, or a page
|
|
9
|
+
that lies sideways, is settled by reading it both ways round.
|
|
10
|
+
- **Straight**: skew up to 45 degrees, from coarse projection candidates and
|
|
11
|
+
Tesseract's text-line baselines, checked by measuring again; at most 5
|
|
12
|
+
minutes of Tesseract per page.
|
|
13
|
+
- **Lossless PDF**: JPEG pages as they are, 1-bit pages Flate-compressed;
|
|
14
|
+
turns as `/Rotate`, skew as a drawing matrix.
|
|
15
|
+
- **OCR** through OCRmyPDF: Tesseract by default, PaddleOCR PP-OCRv5 on
|
|
16
|
+
OpenVINO (CPU) for receipts, photo captions and tables; the OCRmyPDF plugin
|
|
17
|
+
`tidyscan.ocrmypdf_paddle` works with OCRmyPDF 16 and 17. PDF/A-2b on
|
|
18
|
+
request.
|
|
19
|
+
- `tidyscan models fetch`: the PaddleOCR models from this project's release,
|
|
20
|
+
checked by sha256.
|
|
21
|
+
- `tidyscan watch IN OUT`: document folders (as scankeyd writes them) into
|
|
22
|
+
PDFs, as a service, with settings from the environment and a status file
|
|
23
|
+
for a monitor.
|
|
24
|
+
- Container images: `base` (pages to PDF, standard library), the default
|
|
25
|
+
`full` (Tesseract and OCRmyPDF) and, on request, `paddle`.
|
tidyscan-0.3.0/LICENSE
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026, Luuk Visser
|
|
4
|
+
|
|
5
|
+
Redistribution and use in source and binary forms, with or without
|
|
6
|
+
modification, are permitted provided that the following conditions are met:
|
|
7
|
+
|
|
8
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
9
|
+
list of conditions and the following disclaimer.
|
|
10
|
+
|
|
11
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
12
|
+
this list of conditions and the following disclaimer in the documentation
|
|
13
|
+
and/or other materials provided with the distribution.
|
|
14
|
+
|
|
15
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
16
|
+
contributors may be used to endorse or promote products derived from
|
|
17
|
+
this software without specific prior written permission.
|
|
18
|
+
|
|
19
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
20
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
21
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
22
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
23
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
24
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
25
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
26
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
27
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
28
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
tidyscan-0.3.0/PKG-INFO
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tidyscan
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Auto-rotate and deskew scanned pages into a lossless PDF, with an optional OCR text layer.
|
|
5
|
+
Keywords: scan,deskew,orientation,pdf,ocr,tesseract,paddleocr
|
|
6
|
+
Author: Luuk Visser
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
License-Expression: BSD-3-Clause
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
13
|
+
Classifier: Topic :: Multimedia :: Graphics :: Graphics Conversion
|
|
14
|
+
Classifier: Topic :: Scientific/Engineering :: Image Recognition
|
|
15
|
+
License-File: LICENSE
|
|
16
|
+
Requires-Dist: Pillow>=10 ; extra == "ocr"
|
|
17
|
+
Requires-Dist: ocrmypdf>=16 ; extra == "ocr"
|
|
18
|
+
Requires-Dist: Pillow>=10 ; extra == "paddle"
|
|
19
|
+
Requires-Dist: ocrmypdf>=16,<18 ; extra == "paddle"
|
|
20
|
+
Requires-Dist: rapidocr>=3.10,<4 ; extra == "paddle"
|
|
21
|
+
Requires-Dist: openvino>=2025.1 ; extra == "paddle"
|
|
22
|
+
Requires-Dist: Pillow>=10 ; extra == "straighten"
|
|
23
|
+
Project-URL: Changelog, https://github.com/luukvisser/tidyscan/blob/main/CHANGELOG.md
|
|
24
|
+
Project-URL: Issues, https://github.com/luukvisser/tidyscan/issues
|
|
25
|
+
Project-URL: Source, https://github.com/luukvisser/tidyscan
|
|
26
|
+
Provides-Extra: ocr
|
|
27
|
+
Provides-Extra: paddle
|
|
28
|
+
Provides-Extra: straighten
|
|
29
|
+
|
|
30
|
+
# tidyscan
|
|
31
|
+
|
|
32
|
+
Scanned pages turned upright and straightened into a PDF, losslessly, and
|
|
33
|
+
optionally made searchable.
|
|
34
|
+
|
|
35
|
+
- **Upright**: Tesseract's orientation detection decides the quarter turn,
|
|
36
|
+
only when it is sure and the script is one you allow (Latin by default); an
|
|
37
|
+
unsure upside-down page is settled by reading it both ways round.
|
|
38
|
+
- **Straight**: a page laid crooked on the glass (up to 45 degrees) is
|
|
39
|
+
measured on its text lines and checked by measuring again.
|
|
40
|
+
- **Lossless**: JPEG pages go into the PDF byte for byte, 1-bit pages
|
|
41
|
+
Flate-compressed; the turn is the page's `/Rotate`, the skew a drawing
|
|
42
|
+
matrix. Nothing is resampled. Standard library only.
|
|
43
|
+
- **Searchable** (optional): a text layer by OCRmyPDF, read by Tesseract, or by
|
|
44
|
+
PaddleOCR (PP-OCRv5 on OpenVINO, CPU) for receipts.
|
|
45
|
+
|
|
46
|
+
When anything is unsure, the page is kept as scanned. Written for the scans of
|
|
47
|
+
[scankeyd](https://github.com/luukvisser/scankeyd) (the Scan key of Brother
|
|
48
|
+
network printers to a folder), and usable for any scanned pages.
|
|
49
|
+
|
|
50
|
+
## Install
|
|
51
|
+
|
|
52
|
+
pip install tidyscan # pages to PDF only
|
|
53
|
+
pip install 'tidyscan[straighten]' # plus Pillow: turning upright and straightening
|
|
54
|
+
pip install 'tidyscan[ocr]' # plus OCRmyPDF: the text layer
|
|
55
|
+
pip install 'tidyscan[paddle]' # plus RapidOCR and OpenVINO: PaddleOCR
|
|
56
|
+
|
|
57
|
+
Python 3.11 or newer, on Linux. Turning and straightening also need the
|
|
58
|
+
`tesseract` program with its `osd` data, OCR its language data (Debian:
|
|
59
|
+
`tesseract-ocr tesseract-ocr-osd tesseract-ocr-nld ...`). PaddleOCR needs its
|
|
60
|
+
models once (checked by sha256, never downloaded by themselves):
|
|
61
|
+
|
|
62
|
+
tidyscan models fetch # into ~/.local/share/tidyscan/models
|
|
63
|
+
tidyscan models path # where they are looked up, and whether they are there
|
|
64
|
+
|
|
65
|
+
## Use
|
|
66
|
+
|
|
67
|
+
tidyscan --ocr eng+nld -o out.pdf page1.jpg page2.pbm
|
|
68
|
+
tidyscan --ocr nld --engine paddle -o receipt.pdf receipt.jpg
|
|
69
|
+
|
|
70
|
+
JPEG and PBM pages go in as they are. Other formats (PNG, TIFF, every page of
|
|
71
|
+
a multi-page TIFF) are read with Pillow and converted first, to PBM when
|
|
72
|
+
black-and-white, else to JPEG (quality 95): for those the PDF holds the
|
|
73
|
+
conversion. Options: `--dpi` (300), `--no-rotate`, `--no-deskew`,
|
|
74
|
+
`--min-confidence` (3), `--engine`, `-q`. Exit code 0, or 1 for a page that is
|
|
75
|
+
not a JPEG or PBM; an OCR failure writes the PDF without text and says so.
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
import tidyscan
|
|
79
|
+
from tidyscan import ocr, pdf
|
|
80
|
+
|
|
81
|
+
pages = [open("page1.jpg", "rb").read()]
|
|
82
|
+
turns, skews = zip(*(tidyscan.straighten(p, 300) for p in pages))
|
|
83
|
+
data = pdf.build(pages, 300, list(turns), list(skews))
|
|
84
|
+
data = ocr.add_text_layer(data, "eng+nld") # raises ocr.OcrError; engine="tesseract" (default), "paddle", "auto"
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
A page is JPEG bytes, binary PBM (P4) bytes, or an object with `to_pbm()`.
|
|
88
|
+
`straighten()` never raises and logs to the `tidyscan` logger; `turn=` takes a
|
|
89
|
+
turn already known (from the scanner), `budget=` the seconds of Tesseract it
|
|
90
|
+
may spend on a page (300; what is not measured by then is kept as scanned).
|
|
91
|
+
|
|
92
|
+
### Which OCR engine
|
|
93
|
+
|
|
94
|
+
| engine | words found | words right | 1 page | each further page | memory |
|
|
95
|
+
|---|---|---|---|---|---|
|
|
96
|
+
| **Tesseract 5.5** (`--engine tesseract`, the default) | 90% | 87% | 3.6 s | 2.7 s | 0.15 GB |
|
|
97
|
+
| PaddleOCR PP-OCRv5 (`--engine paddle`) | 83% | 92% | 6.1 s | 9.9 s | 0.9 GB |
|
|
98
|
+
|
|
99
|
+
Tesseract reads letters, documents, forms and book pages better, faster and
|
|
100
|
+
in less memory. PaddleOCR reads receipts, photo captions and tables better
|
|
101
|
+
(it adds far fewer wrong words on tables), and phone photos of receipts that
|
|
102
|
+
Tesseract cannot read at all. `--engine auto` takes PaddleOCR when it is
|
|
103
|
+
installed and the languages are Latin-script ones, else Tesseract; Tesseract
|
|
104
|
+
also takes over a document PaddleOCR fails on. PaddleOCR's Latin recogniser
|
|
105
|
+
reads Dutch, English and some 30 other languages at once. Measured on 45 pages
|
|
106
|
+
in 188 scan-like variants, through the whole pipeline, on 2 cores of an Intel
|
|
107
|
+
N150: [docs/benchmarks.md](docs/benchmarks.md).
|
|
108
|
+
|
|
109
|
+
## As a service: `tidyscan watch`
|
|
110
|
+
|
|
111
|
+
`tidyscan watch IN OUT` turns document folders into PDFs, oldest first: a
|
|
112
|
+
folder `IN/<function>/<name>/` with the pages (`page-0001.jpg`,
|
|
113
|
+
`page-0002.pbm`, ...) and a `document.json` (optional `"resolution"`, default
|
|
114
|
+
300; `"pages"`, default the page files in order; `"ocr": true` for a text
|
|
115
|
+
layer), as [scankeyd](https://github.com/luukvisser/scankeyd) writes them.
|
|
116
|
+
Each becomes `OUT/<name>.pdf`, written as a hidden `.part` file and renamed;
|
|
117
|
+
then the folder goes. A document that cannot be read moves to `IN/.failed/`;
|
|
118
|
+
when OCR fails, the PDF is written without text. IN and its function folders
|
|
119
|
+
must be writable for this user, as each folder goes once its PDF is there.
|
|
120
|
+
|
|
121
|
+
| Option | Environment | Default | |
|
|
122
|
+
|---|---|---|---|
|
|
123
|
+
| `--ocr LANGS` | `OCR_LANGUAGES` | `eng` | text layer languages, e.g. `eng+nld` |
|
|
124
|
+
| `--engine` | `OCR_ENGINE` | `tesseract` | `tesseract`, `paddle` or `auto` |
|
|
125
|
+
| `--ocr-folders` | `OCR_FOLDERS` | `ocr` | for documents without an `"ocr"` field: the function folders that get a text layer (comma list, `all`, or empty for none) |
|
|
126
|
+
| `--no-rotate`, `--no-deskew` | `ROTATE`, `DESKEW` | `auto` | `on`, `off` or `auto` (on when Tesseract is there) |
|
|
127
|
+
| `--pdfa` | `PDFA` | `off` | PDF/A-2b for the documents with a text layer (still lossless: 1-bit pages become CCITT G4) |
|
|
128
|
+
| `--status FILE` | `STATUS_FILE` | none | a JSON status file for a monitor: `when`, `last_ok`, `last_file`, `last_error`, `failed`, `waiting`, and the counts `pdfs`, `unreadable`, `no_text` |
|
|
129
|
+
| `--keep-folders` | | off | `OUT/<function>/<name>.pdf` |
|
|
130
|
+
| `--interval` | | 5 | seconds between looks at IN |
|
|
131
|
+
| `--once` | | | convert what is there, then stop |
|
|
132
|
+
|
|
133
|
+
On/off settings take `on`/`off`, `true`/`false`, `yes`/`no` or `1`/`0`; any
|
|
134
|
+
other value stops the start with a message.
|
|
135
|
+
|
|
136
|
+
**Docker**: three images, built from this repository (`docker build ...`):
|
|
137
|
+
`--target base` (pages to PDF only, ~77 MB, no copyleft software), the default
|
|
138
|
+
`full` (with Tesseract and OCRmyPDF, ~480 MB) and `--target paddle` (with
|
|
139
|
+
PaddleOCR, ~1 GB, amd64 only). They run `tidyscan watch /in /out` as uid 1000:
|
|
140
|
+
|
|
141
|
+
```sh
|
|
142
|
+
docker build -t tidyscan .
|
|
143
|
+
docker run -d --network none --read-only --tmpfs /tmp --env-file scan.env \
|
|
144
|
+
-v ./scans/new:/in -v ./scans/pdf:/out tidyscan
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
OCR languages for the image: `--build-arg OCR_LANGS="eng nld deu fra"`.
|
|
148
|
+
|
|
149
|
+
## Limits
|
|
150
|
+
|
|
151
|
+
- Turning upright only for scripts you allow (Latin by default); skew up to 45
|
|
152
|
+
degrees. Photos and nearly blank pages are kept as scanned, and so are phone
|
|
153
|
+
photos of receipts (Tesseract finds no text lines on them).
|
|
154
|
+
- At most 5 minutes of Tesseract per page; a page above 200 million pixels is
|
|
155
|
+
not read at all.
|
|
156
|
+
- OCRmyPDF gets 600 s per document: a long document read by PaddleOCR (about
|
|
157
|
+
10 s a page) may run out and is then read by Tesseract.
|
|
158
|
+
- PaddleOCR: Latin script only; OCRmyPDF must run in the same Python as
|
|
159
|
+
tidyscan and RapidOCR (the engine is the OCRmyPDF plugin
|
|
160
|
+
`tidyscan.ocrmypdf_paddle`, also usable on its own: `ocrmypdf --plugin
|
|
161
|
+
tidyscan.ocrmypdf_paddle`). `TIDYSCAN_PADDLE_THREADS` sets its threads.
|
|
162
|
+
|
|
163
|
+
## How well it works
|
|
164
|
+
|
|
165
|
+
Measured with `tests/eval_deskew.py` on 1946 cases from 69 pages, each turned,
|
|
166
|
+
tilted or mirrored by a known amount (06-11-2026):
|
|
167
|
+
|
|
168
|
+
- **Upright**: of 381 text pages that needed a turn, 96% upright and none
|
|
169
|
+
turned the wrong way (OCRmyPDF's `--rotate-pages`: 29%; Tesseract OSD alone:
|
|
170
|
+
77%, with 53 wrong turns). No upright page was turned away.
|
|
171
|
+
- **Straight**: within 0.5 degrees on 100%, 100% and 96% of the pages tilted
|
|
172
|
+
slightly, medium and steeply. jdeskew with `angle_max=45` measures as well
|
|
173
|
+
but turns no page upright; it, OCRmyPDF, ImageMagick, unpaper and Leptonica
|
|
174
|
+
straighten by rotating the image, which resamples it.
|
|
175
|
+
|
|
176
|
+
Details, the other tools and the pages: [docs/benchmarks.md](docs/benchmarks.md).
|
|
177
|
+
|
|
178
|
+
## Development
|
|
179
|
+
|
|
180
|
+
With [uv](https://docs.astral.sh/uv/):
|
|
181
|
+
|
|
182
|
+
uv sync
|
|
183
|
+
uv run ruff check && uv run ruff format --check && uv run ty check
|
|
184
|
+
uv run python -m unittest tests.test_tidyscan
|
|
185
|
+
|
|
186
|
+
Tests that need Tesseract, OCRmyPDF or the PaddleOCR models are skipped when
|
|
187
|
+
those are missing. The benchmarks (`tests/eval_*.py`) download their pages and
|
|
188
|
+
are not part of the unit tests.
|
|
189
|
+
|
|
190
|
+
## Licence
|
|
191
|
+
|
|
192
|
+
BSD-3-Clause, see [LICENSE](LICENSE); the repository follows
|
|
193
|
+
[REUSE](https://reuse.software/). Written by Luuk Visser with Claude Code
|
|
194
|
+
(Claude Opus 5.5).
|
|
195
|
+
|
|
196
|
+
What the optional parts use is not included in this package: OCRmyPDF
|
|
197
|
+
(MPL-2.0), Tesseract, RapidOCR, OpenVINO, OpenCV and PaddleOCR's PP-OCRv5
|
|
198
|
+
models (Apache-2.0; the models come with their own notice). The `full` and
|
|
199
|
+
`paddle` images add Debian packages under their own licences, among them
|
|
200
|
+
Ghostscript (AGPL-3.0, used by OCRmyPDF); the `base` image has no copyleft
|
|
201
|
+
software.
|
|
202
|
+
|
tidyscan-0.3.0/README.md
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# tidyscan
|
|
2
|
+
|
|
3
|
+
Scanned pages turned upright and straightened into a PDF, losslessly, and
|
|
4
|
+
optionally made searchable.
|
|
5
|
+
|
|
6
|
+
- **Upright**: Tesseract's orientation detection decides the quarter turn,
|
|
7
|
+
only when it is sure and the script is one you allow (Latin by default); an
|
|
8
|
+
unsure upside-down page is settled by reading it both ways round.
|
|
9
|
+
- **Straight**: a page laid crooked on the glass (up to 45 degrees) is
|
|
10
|
+
measured on its text lines and checked by measuring again.
|
|
11
|
+
- **Lossless**: JPEG pages go into the PDF byte for byte, 1-bit pages
|
|
12
|
+
Flate-compressed; the turn is the page's `/Rotate`, the skew a drawing
|
|
13
|
+
matrix. Nothing is resampled. Standard library only.
|
|
14
|
+
- **Searchable** (optional): a text layer by OCRmyPDF, read by Tesseract, or by
|
|
15
|
+
PaddleOCR (PP-OCRv5 on OpenVINO, CPU) for receipts.
|
|
16
|
+
|
|
17
|
+
When anything is unsure, the page is kept as scanned. Written for the scans of
|
|
18
|
+
[scankeyd](https://github.com/luukvisser/scankeyd) (the Scan key of Brother
|
|
19
|
+
network printers to a folder), and usable for any scanned pages.
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
pip install tidyscan # pages to PDF only
|
|
24
|
+
pip install 'tidyscan[straighten]' # plus Pillow: turning upright and straightening
|
|
25
|
+
pip install 'tidyscan[ocr]' # plus OCRmyPDF: the text layer
|
|
26
|
+
pip install 'tidyscan[paddle]' # plus RapidOCR and OpenVINO: PaddleOCR
|
|
27
|
+
|
|
28
|
+
Python 3.11 or newer, on Linux. Turning and straightening also need the
|
|
29
|
+
`tesseract` program with its `osd` data, OCR its language data (Debian:
|
|
30
|
+
`tesseract-ocr tesseract-ocr-osd tesseract-ocr-nld ...`). PaddleOCR needs its
|
|
31
|
+
models once (checked by sha256, never downloaded by themselves):
|
|
32
|
+
|
|
33
|
+
tidyscan models fetch # into ~/.local/share/tidyscan/models
|
|
34
|
+
tidyscan models path # where they are looked up, and whether they are there
|
|
35
|
+
|
|
36
|
+
## Use
|
|
37
|
+
|
|
38
|
+
tidyscan --ocr eng+nld -o out.pdf page1.jpg page2.pbm
|
|
39
|
+
tidyscan --ocr nld --engine paddle -o receipt.pdf receipt.jpg
|
|
40
|
+
|
|
41
|
+
JPEG and PBM pages go in as they are. Other formats (PNG, TIFF, every page of
|
|
42
|
+
a multi-page TIFF) are read with Pillow and converted first, to PBM when
|
|
43
|
+
black-and-white, else to JPEG (quality 95): for those the PDF holds the
|
|
44
|
+
conversion. Options: `--dpi` (300), `--no-rotate`, `--no-deskew`,
|
|
45
|
+
`--min-confidence` (3), `--engine`, `-q`. Exit code 0, or 1 for a page that is
|
|
46
|
+
not a JPEG or PBM; an OCR failure writes the PDF without text and says so.
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
import tidyscan
|
|
50
|
+
from tidyscan import ocr, pdf
|
|
51
|
+
|
|
52
|
+
pages = [open("page1.jpg", "rb").read()]
|
|
53
|
+
turns, skews = zip(*(tidyscan.straighten(p, 300) for p in pages))
|
|
54
|
+
data = pdf.build(pages, 300, list(turns), list(skews))
|
|
55
|
+
data = ocr.add_text_layer(data, "eng+nld") # raises ocr.OcrError; engine="tesseract" (default), "paddle", "auto"
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
A page is JPEG bytes, binary PBM (P4) bytes, or an object with `to_pbm()`.
|
|
59
|
+
`straighten()` never raises and logs to the `tidyscan` logger; `turn=` takes a
|
|
60
|
+
turn already known (from the scanner), `budget=` the seconds of Tesseract it
|
|
61
|
+
may spend on a page (300; what is not measured by then is kept as scanned).
|
|
62
|
+
|
|
63
|
+
### Which OCR engine
|
|
64
|
+
|
|
65
|
+
| engine | words found | words right | 1 page | each further page | memory |
|
|
66
|
+
|---|---|---|---|---|---|
|
|
67
|
+
| **Tesseract 5.5** (`--engine tesseract`, the default) | 90% | 87% | 3.6 s | 2.7 s | 0.15 GB |
|
|
68
|
+
| PaddleOCR PP-OCRv5 (`--engine paddle`) | 83% | 92% | 6.1 s | 9.9 s | 0.9 GB |
|
|
69
|
+
|
|
70
|
+
Tesseract reads letters, documents, forms and book pages better, faster and
|
|
71
|
+
in less memory. PaddleOCR reads receipts, photo captions and tables better
|
|
72
|
+
(it adds far fewer wrong words on tables), and phone photos of receipts that
|
|
73
|
+
Tesseract cannot read at all. `--engine auto` takes PaddleOCR when it is
|
|
74
|
+
installed and the languages are Latin-script ones, else Tesseract; Tesseract
|
|
75
|
+
also takes over a document PaddleOCR fails on. PaddleOCR's Latin recogniser
|
|
76
|
+
reads Dutch, English and some 30 other languages at once. Measured on 45 pages
|
|
77
|
+
in 188 scan-like variants, through the whole pipeline, on 2 cores of an Intel
|
|
78
|
+
N150: [docs/benchmarks.md](docs/benchmarks.md).
|
|
79
|
+
|
|
80
|
+
## As a service: `tidyscan watch`
|
|
81
|
+
|
|
82
|
+
`tidyscan watch IN OUT` turns document folders into PDFs, oldest first: a
|
|
83
|
+
folder `IN/<function>/<name>/` with the pages (`page-0001.jpg`,
|
|
84
|
+
`page-0002.pbm`, ...) and a `document.json` (optional `"resolution"`, default
|
|
85
|
+
300; `"pages"`, default the page files in order; `"ocr": true` for a text
|
|
86
|
+
layer), as [scankeyd](https://github.com/luukvisser/scankeyd) writes them.
|
|
87
|
+
Each becomes `OUT/<name>.pdf`, written as a hidden `.part` file and renamed;
|
|
88
|
+
then the folder goes. A document that cannot be read moves to `IN/.failed/`;
|
|
89
|
+
when OCR fails, the PDF is written without text. IN and its function folders
|
|
90
|
+
must be writable for this user, as each folder goes once its PDF is there.
|
|
91
|
+
|
|
92
|
+
| Option | Environment | Default | |
|
|
93
|
+
|---|---|---|---|
|
|
94
|
+
| `--ocr LANGS` | `OCR_LANGUAGES` | `eng` | text layer languages, e.g. `eng+nld` |
|
|
95
|
+
| `--engine` | `OCR_ENGINE` | `tesseract` | `tesseract`, `paddle` or `auto` |
|
|
96
|
+
| `--ocr-folders` | `OCR_FOLDERS` | `ocr` | for documents without an `"ocr"` field: the function folders that get a text layer (comma list, `all`, or empty for none) |
|
|
97
|
+
| `--no-rotate`, `--no-deskew` | `ROTATE`, `DESKEW` | `auto` | `on`, `off` or `auto` (on when Tesseract is there) |
|
|
98
|
+
| `--pdfa` | `PDFA` | `off` | PDF/A-2b for the documents with a text layer (still lossless: 1-bit pages become CCITT G4) |
|
|
99
|
+
| `--status FILE` | `STATUS_FILE` | none | a JSON status file for a monitor: `when`, `last_ok`, `last_file`, `last_error`, `failed`, `waiting`, and the counts `pdfs`, `unreadable`, `no_text` |
|
|
100
|
+
| `--keep-folders` | | off | `OUT/<function>/<name>.pdf` |
|
|
101
|
+
| `--interval` | | 5 | seconds between looks at IN |
|
|
102
|
+
| `--once` | | | convert what is there, then stop |
|
|
103
|
+
|
|
104
|
+
On/off settings take `on`/`off`, `true`/`false`, `yes`/`no` or `1`/`0`; any
|
|
105
|
+
other value stops the start with a message.
|
|
106
|
+
|
|
107
|
+
**Docker**: three images, built from this repository (`docker build ...`):
|
|
108
|
+
`--target base` (pages to PDF only, ~77 MB, no copyleft software), the default
|
|
109
|
+
`full` (with Tesseract and OCRmyPDF, ~480 MB) and `--target paddle` (with
|
|
110
|
+
PaddleOCR, ~1 GB, amd64 only). They run `tidyscan watch /in /out` as uid 1000:
|
|
111
|
+
|
|
112
|
+
```sh
|
|
113
|
+
docker build -t tidyscan .
|
|
114
|
+
docker run -d --network none --read-only --tmpfs /tmp --env-file scan.env \
|
|
115
|
+
-v ./scans/new:/in -v ./scans/pdf:/out tidyscan
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
OCR languages for the image: `--build-arg OCR_LANGS="eng nld deu fra"`.
|
|
119
|
+
|
|
120
|
+
## Limits
|
|
121
|
+
|
|
122
|
+
- Turning upright only for scripts you allow (Latin by default); skew up to 45
|
|
123
|
+
degrees. Photos and nearly blank pages are kept as scanned, and so are phone
|
|
124
|
+
photos of receipts (Tesseract finds no text lines on them).
|
|
125
|
+
- At most 5 minutes of Tesseract per page; a page above 200 million pixels is
|
|
126
|
+
not read at all.
|
|
127
|
+
- OCRmyPDF gets 600 s per document: a long document read by PaddleOCR (about
|
|
128
|
+
10 s a page) may run out and is then read by Tesseract.
|
|
129
|
+
- PaddleOCR: Latin script only; OCRmyPDF must run in the same Python as
|
|
130
|
+
tidyscan and RapidOCR (the engine is the OCRmyPDF plugin
|
|
131
|
+
`tidyscan.ocrmypdf_paddle`, also usable on its own: `ocrmypdf --plugin
|
|
132
|
+
tidyscan.ocrmypdf_paddle`). `TIDYSCAN_PADDLE_THREADS` sets its threads.
|
|
133
|
+
|
|
134
|
+
## How well it works
|
|
135
|
+
|
|
136
|
+
Measured with `tests/eval_deskew.py` on 1946 cases from 69 pages, each turned,
|
|
137
|
+
tilted or mirrored by a known amount (06-11-2026):
|
|
138
|
+
|
|
139
|
+
- **Upright**: of 381 text pages that needed a turn, 96% upright and none
|
|
140
|
+
turned the wrong way (OCRmyPDF's `--rotate-pages`: 29%; Tesseract OSD alone:
|
|
141
|
+
77%, with 53 wrong turns). No upright page was turned away.
|
|
142
|
+
- **Straight**: within 0.5 degrees on 100%, 100% and 96% of the pages tilted
|
|
143
|
+
slightly, medium and steeply. jdeskew with `angle_max=45` measures as well
|
|
144
|
+
but turns no page upright; it, OCRmyPDF, ImageMagick, unpaper and Leptonica
|
|
145
|
+
straighten by rotating the image, which resamples it.
|
|
146
|
+
|
|
147
|
+
Details, the other tools and the pages: [docs/benchmarks.md](docs/benchmarks.md).
|
|
148
|
+
|
|
149
|
+
## Development
|
|
150
|
+
|
|
151
|
+
With [uv](https://docs.astral.sh/uv/):
|
|
152
|
+
|
|
153
|
+
uv sync
|
|
154
|
+
uv run ruff check && uv run ruff format --check && uv run ty check
|
|
155
|
+
uv run python -m unittest tests.test_tidyscan
|
|
156
|
+
|
|
157
|
+
Tests that need Tesseract, OCRmyPDF or the PaddleOCR models are skipped when
|
|
158
|
+
those are missing. The benchmarks (`tests/eval_*.py`) download their pages and
|
|
159
|
+
are not part of the unit tests.
|
|
160
|
+
|
|
161
|
+
## Licence
|
|
162
|
+
|
|
163
|
+
BSD-3-Clause, see [LICENSE](LICENSE); the repository follows
|
|
164
|
+
[REUSE](https://reuse.software/). Written by Luuk Visser with Claude Code
|
|
165
|
+
(Claude Opus 5.5).
|
|
166
|
+
|
|
167
|
+
What the optional parts use is not included in this package: OCRmyPDF
|
|
168
|
+
(MPL-2.0), Tesseract, RapidOCR, OpenVINO, OpenCV and PaddleOCR's PP-OCRv5
|
|
169
|
+
models (Apache-2.0; the models come with their own notice). The `full` and
|
|
170
|
+
`paddle` images add Debian packages under their own licences, among them
|
|
171
|
+
Ghostscript (AGPL-3.0, used by OCRmyPDF); the `base` image has no copyleft
|
|
172
|
+
software.
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["flit_core==3.12.0"]
|
|
3
|
+
build-backend = "flit_core.buildapi"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "tidyscan"
|
|
7
|
+
dynamic = ["version", "description"]
|
|
8
|
+
readme = "README.md"
|
|
9
|
+
license = "BSD-3-Clause"
|
|
10
|
+
license-files = ["LICENSE"]
|
|
11
|
+
requires-python = ">=3.11"
|
|
12
|
+
authors = [{ name = "Luuk Visser" }]
|
|
13
|
+
keywords = ["scan", "deskew", "orientation", "pdf", "ocr", "tesseract", "paddleocr"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Operating System :: POSIX :: Linux",
|
|
18
|
+
"Topic :: Multimedia :: Graphics :: Graphics Conversion",
|
|
19
|
+
"Topic :: Scientific/Engineering :: Image Recognition",
|
|
20
|
+
]
|
|
21
|
+
dependencies = []
|
|
22
|
+
|
|
23
|
+
[project.optional-dependencies]
|
|
24
|
+
straighten = ["Pillow>=10"]
|
|
25
|
+
ocr = ["Pillow>=10", "ocrmypdf>=16"]
|
|
26
|
+
# PaddleOCR on OpenVINO (CPU) as the OCR engine; the models are separate files (see README)
|
|
27
|
+
paddle = ["Pillow>=10", "ocrmypdf>=16,<18", "rapidocr>=3.10,<4", "openvino>=2025.1"]
|
|
28
|
+
|
|
29
|
+
[project.urls]
|
|
30
|
+
Source = "https://github.com/luukvisser/tidyscan"
|
|
31
|
+
Issues = "https://github.com/luukvisser/tidyscan/issues"
|
|
32
|
+
Changelog = "https://github.com/luukvisser/tidyscan/blob/main/CHANGELOG.md"
|
|
33
|
+
|
|
34
|
+
[project.scripts]
|
|
35
|
+
tidyscan = "tidyscan.__main__:main"
|
|
36
|
+
|
|
37
|
+
[tool.flit.sdist]
|
|
38
|
+
include = ["CHANGELOG.md", "tests/", "tools/"]
|
|
39
|
+
|
|
40
|
+
[dependency-groups]
|
|
41
|
+
# Lint, type check and the tests that need no Tesseract; `uv sync` installs these.
|
|
42
|
+
# OCRmyPDF here so ty checks the plugin against its API, and its tests run.
|
|
43
|
+
dev = ["ruff==0.17.3", "ty==0.0.95", "Pillow>=10", "ocrmypdf>=16,<18"]
|
|
44
|
+
|
|
45
|
+
[tool.ruff]
|
|
46
|
+
line-length = 120
|
|
47
|
+
target-version = "py311"
|
|
48
|
+
|
|
49
|
+
[tool.ruff.lint]
|
|
50
|
+
select = ["E", "F", "W", "I", "B", "UP", "ANN", "TC", "FA"]
|
|
51
|
+
|
|
52
|
+
[tool.ruff.lint.per-file-ignores]
|
|
53
|
+
# The benchmarks (not part of the package or its tests): long report lines, helpers without annotations
|
|
54
|
+
"tests/eval_*.py" = ["E501", "E702", "ANN001", "ANN202"]
|
|
55
|
+
|
|
56
|
+
[tool.ty.terminal]
|
|
57
|
+
# warnings (e.g. deprecated APIs) fail the check too
|
|
58
|
+
error-on-warning = true
|
|
59
|
+
|
|
60
|
+
[tool.ty.rules]
|
|
61
|
+
# on top of ty's defaults: no bare generics (missing-override-decorator needs typing.override, 3.12+)
|
|
62
|
+
missing-type-argument = "error"
|
|
63
|
+
|
|
64
|
+
[tool.ty.src]
|
|
65
|
+
# The benchmarks run in their own image (tests/eval_deskew.Dockerfile) with libraries the package does
|
|
66
|
+
# not depend on (OpenCV, numpy, the other deskew tools); ruff still checks them.
|
|
67
|
+
exclude = ["tests/eval_*.py"]
|
|
68
|
+
|
|
69
|
+
[tool.ty.analysis]
|
|
70
|
+
# rapidocr: the optional PaddleOCR runtime (with OpenVINO and OpenCV, ~300 MB), imported only when
|
|
71
|
+
# that engine is used; not worth installing just to type check two imports.
|
|
72
|
+
# yaml: a build-time dependency of tools/models.py only, pinned in tools/convert-requirements.txt.
|
|
73
|
+
allowed-unresolved-imports = ["rapidocr", "yaml"]
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Auto-rotate and deskew scanned pages into a lossless PDF, with an optional OCR text layer.
|
|
2
|
+
|
|
3
|
+
turn, angle = tidyscan.straighten(page, 300)
|
|
4
|
+
data = tidyscan.pdf.build([page], 300, [turn], [angle])
|
|
5
|
+
data = tidyscan.ocr.add_text_layer(data, "eng+nld") # Tesseract; engine="paddle" for receipts
|
|
6
|
+
|
|
7
|
+
A page is JPEG bytes, binary PBM (P4) bytes, or an object with to_pbm().
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
|
|
14
|
+
from . import _budget, hocr, models, ocr, orientation, paddle, pdf, skew
|
|
15
|
+
from ._page import Page, PbmSource, encoded
|
|
16
|
+
from .ocr import OcrError
|
|
17
|
+
from .pdf import UnsupportedPage
|
|
18
|
+
|
|
19
|
+
__version__ = "0.3.0"
|
|
20
|
+
__all__ = [
|
|
21
|
+
"OcrError",
|
|
22
|
+
"Page",
|
|
23
|
+
"PbmSource",
|
|
24
|
+
"UnsupportedPage",
|
|
25
|
+
"hocr",
|
|
26
|
+
"models",
|
|
27
|
+
"ocr",
|
|
28
|
+
"orientation",
|
|
29
|
+
"paddle",
|
|
30
|
+
"pdf",
|
|
31
|
+
"skew",
|
|
32
|
+
"straighten",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
log = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
STEEP_EDGE = 2.0 # degrees from +-45: a steep-path answer there is as likely the other quarter turn
|
|
38
|
+
BUDGET = 300.0 # seconds of Tesseract per page at most (see _budget)
|
|
39
|
+
MAX_PIXELS = 200_000_000 # above Pillow's own refusal (2 x 89.5 M pixels); an A4 page at 1200 dpi is 140 M
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def straighten(
|
|
43
|
+
page: Page,
|
|
44
|
+
dpi: int,
|
|
45
|
+
*,
|
|
46
|
+
turn: int = 0,
|
|
47
|
+
rotate: bool = True,
|
|
48
|
+
deskew: bool = True,
|
|
49
|
+
min_confidence: float = 3.0,
|
|
50
|
+
scripts: tuple[str, ...] = ("Latin",),
|
|
51
|
+
deskew_min: float = 0.3,
|
|
52
|
+
workdir: str | None = None,
|
|
53
|
+
budget: float | None = BUDGET,
|
|
54
|
+
) -> tuple[int, float]:
|
|
55
|
+
"""(clockwise turn 0/90/180/270, skew in degrees counter-clockwise) for pdf.build; 0 for what is off,
|
|
56
|
+
unsure or unavailable.
|
|
57
|
+
|
|
58
|
+
turn: a turn already known (from the scanner); it is kept and not detected. deskew_min: smaller
|
|
59
|
+
skews are not visible and left alone. budget: seconds of Tesseract for this page at most (None: no
|
|
60
|
+
limit); what is not measured by then is kept as scanned. A page larger than MAX_PIXELS is not read
|
|
61
|
+
at all. Never raises: straightening is a nicety and must not cost a page (a JPEG that Pillow
|
|
62
|
+
cannot decode is still a page the PDF can carry)."""
|
|
63
|
+
if _too_large(page):
|
|
64
|
+
return turn, 0.0
|
|
65
|
+
token = _budget.start(budget)
|
|
66
|
+
try:
|
|
67
|
+
return _straighten(page, dpi, turn, rotate, deskew, min_confidence, scripts, deskew_min, workdir)
|
|
68
|
+
finally:
|
|
69
|
+
_budget.end(token)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _too_large(page: Page) -> bool:
|
|
73
|
+
try:
|
|
74
|
+
data, ext = encoded(page)
|
|
75
|
+
w, h = pdf.pbm_info(data)[:2] if ext == "pbm" else pdf.jpeg_info(data)[:2]
|
|
76
|
+
except Exception: # noqa: BLE001 (the PDF writer reports a damaged page)
|
|
77
|
+
return False
|
|
78
|
+
if w * h > MAX_PIXELS:
|
|
79
|
+
log.warning("page of %d x %d pixels: too large to read; kept as scanned", w, h)
|
|
80
|
+
return True
|
|
81
|
+
return False
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _straighten(
|
|
85
|
+
page: Page,
|
|
86
|
+
dpi: int,
|
|
87
|
+
turn: int,
|
|
88
|
+
rotate: bool,
|
|
89
|
+
deskew: bool,
|
|
90
|
+
min_confidence: float,
|
|
91
|
+
scripts: tuple[str, ...],
|
|
92
|
+
deskew_min: float,
|
|
93
|
+
workdir: str | None,
|
|
94
|
+
) -> tuple[int, float]:
|
|
95
|
+
# Skew first, then the orientation of the straightened page: OSD cannot read a page tilted by
|
|
96
|
+
# more than a few degrees. A 180-degree turn leaves the text lines' angle as it is; a quarter
|
|
97
|
+
# turn means the lines were measured across the text, so the skew is measured again upright.
|
|
98
|
+
known = bool(turn)
|
|
99
|
+
found = _detect(page, dpi, turn, workdir) if deskew else None
|
|
100
|
+
base = 0
|
|
101
|
+
if found is None and deskew and rotate and not known:
|
|
102
|
+
# A page laid at more than 45 degrees (a receipt at about 60, upside down, 01-11-2026): the skew
|
|
103
|
+
# search covers +-45, so its text rows only show a quarter turn on. Measured there, the skew is
|
|
104
|
+
# an ordinary one, and OSD then only has to tell 90 from 270.
|
|
105
|
+
steep = _detect(page, dpi, 90, workdir)
|
|
106
|
+
if steep is not None and abs(abs(steep) - 45) < STEEP_EDGE:
|
|
107
|
+
# a chart with slanted axis labels came out a quarter plus about 45 degrees (benchmark 05-11-2026)
|
|
108
|
+
log.info("skew unsure (%.2f degrees a quarter turn on: too near 45); page kept as scanned", steep)
|
|
109
|
+
elif steep is not None:
|
|
110
|
+
base, found = 90, steep
|
|
111
|
+
log.info("page lies at more than 45 degrees; measured a quarter turn on")
|
|
112
|
+
angle = found if found is not None and abs(found) >= deskew_min else 0.0
|
|
113
|
+
if not known and rotate:
|
|
114
|
+
try:
|
|
115
|
+
more, sure = orientation.detect(page, workdir, min_confidence, scripts, skew=angle, quarter_turn=base)
|
|
116
|
+
except Exception as e: # noqa: BLE001
|
|
117
|
+
log.warning("orientation not determined (%s); page kept as scanned", e)
|
|
118
|
+
more, sure = 0, False
|
|
119
|
+
if base and not sure:
|
|
120
|
+
# The steep path's quarter turn is only half an answer: 90 and 270 level the same lines, and
|
|
121
|
+
# only OSD on the turned page tells them apart. Unsure, it was kept, and every wrong turn in the
|
|
122
|
+
# benchmark (10, 05-11-2026) came from here. Rotations commute, so the angle measured in the
|
|
123
|
+
# turned frame straightens the page as scanned too: sideways, but level. First the words
|
|
124
|
+
# decide, as for an unsure upside-down page.
|
|
125
|
+
try:
|
|
126
|
+
by_words = orientation.quarter_by_words(page, workdir, skew=angle)
|
|
127
|
+
except Exception as e: # noqa: BLE001
|
|
128
|
+
log.warning("words not read (%s)", e)
|
|
129
|
+
by_words = None
|
|
130
|
+
if by_words:
|
|
131
|
+
base, more = by_words, 0
|
|
132
|
+
else:
|
|
133
|
+
log.info("quarter turn not confirmed by the orientation check; page kept sideways")
|
|
134
|
+
base = 0
|
|
135
|
+
turn = (base + more) % 360
|
|
136
|
+
if more in (90, 270):
|
|
137
|
+
angle = _skew(page, dpi, turn, deskew, deskew_min, workdir)
|
|
138
|
+
if angle:
|
|
139
|
+
log.info("page straightened by %.2f degrees", angle)
|
|
140
|
+
return turn, angle
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _detect(page: Page, dpi: int, turn: int, workdir: str | None) -> float | None:
|
|
144
|
+
"""skew.detect, None when unsure or failing."""
|
|
145
|
+
try:
|
|
146
|
+
return skew.detect(page, dpi, turn, workdir)
|
|
147
|
+
except Exception as e: # noqa: BLE001
|
|
148
|
+
log.warning("skew not measured (%s); page kept as scanned", e)
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _skew(page: Page, dpi: int, turn: int, deskew: bool, deskew_min: float, workdir: str | None) -> float:
|
|
153
|
+
if not deskew:
|
|
154
|
+
return 0.0
|
|
155
|
+
found = _detect(page, dpi, turn, workdir)
|
|
156
|
+
return found if found is not None and abs(found) >= deskew_min else 0.0
|