docorient 0.3.2__tar.gz → 0.4.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.
- docorient-0.4.0/CHANGELOG.md +58 -0
- {docorient-0.3.2 → docorient-0.4.0}/PKG-INFO +36 -37
- {docorient-0.3.2 → docorient-0.4.0}/README.md +32 -32
- {docorient-0.3.2 → docorient-0.4.0}/docs/architecture.md +63 -28
- {docorient-0.3.2 → docorient-0.4.0}/docs/contributing.md +24 -3
- {docorient-0.3.2 → docorient-0.4.0}/pyproject.toml +10 -4
- {docorient-0.3.2 → docorient-0.4.0}/src/docorient/__init__.py +2 -4
- docorient-0.4.0/src/docorient/_version.py +1 -0
- {docorient-0.3.2 → docorient-0.4.0}/src/docorient/cli.py +17 -28
- {docorient-0.3.2 → docorient-0.4.0}/src/docorient/config.py +1 -2
- {docorient-0.3.2 → docorient-0.4.0}/src/docorient/detection/__init__.py +3 -3
- docorient-0.4.0/src/docorient/detection/engine.py +83 -0
- docorient-0.4.0/src/docorient/detection/flip_classifier.py +115 -0
- {docorient-0.3.2 → docorient-0.4.0}/src/docorient/exceptions.py +0 -4
- docorient-0.4.0/src/docorient/models/__init__.py +1 -0
- docorient-0.4.0/src/docorient/models/orientation_detector.onnx +0 -0
- {docorient-0.3.2 → docorient-0.4.0}/tests/test_detection.py +5 -8
- docorient-0.3.2/CHANGELOG.md +0 -44
- docorient-0.3.2/src/docorient/_version.py +0 -1
- docorient-0.3.2/src/docorient/detection/engine.py +0 -55
- docorient-0.3.2/src/docorient/detection/secondary.py +0 -75
- {docorient-0.3.2 → docorient-0.4.0}/.github/workflows/publish.yml +0 -0
- {docorient-0.3.2 → docorient-0.4.0}/.gitignore +0 -0
- {docorient-0.3.2 → docorient-0.4.0}/LICENSE +0 -0
- {docorient-0.3.2 → docorient-0.4.0}/src/docorient/_imaging.py +0 -0
- {docorient-0.3.2 → docorient-0.4.0}/src/docorient/batch/__init__.py +0 -0
- {docorient-0.3.2 → docorient-0.4.0}/src/docorient/batch/processor.py +0 -0
- {docorient-0.3.2 → docorient-0.4.0}/src/docorient/batch/scanner.py +0 -0
- {docorient-0.3.2 → docorient-0.4.0}/src/docorient/batch/worker.py +0 -0
- {docorient-0.3.2 → docorient-0.4.0}/src/docorient/correction.py +0 -0
- {docorient-0.3.2 → docorient-0.4.0}/src/docorient/detection/base.py +0 -0
- {docorient-0.3.2 → docorient-0.4.0}/src/docorient/detection/primary.py +0 -0
- {docorient-0.3.2 → docorient-0.4.0}/src/docorient/rotation.py +0 -0
- {docorient-0.3.2 → docorient-0.4.0}/src/docorient/types.py +0 -0
- {docorient-0.3.2 → docorient-0.4.0}/src/docorient/voting.py +0 -0
- {docorient-0.3.2 → docorient-0.4.0}/tests/__init__.py +0 -0
- {docorient-0.3.2 → docorient-0.4.0}/tests/conftest.py +0 -0
- {docorient-0.3.2 → docorient-0.4.0}/tests/test_batch.py +0 -0
- {docorient-0.3.2 → docorient-0.4.0}/tests/test_cli.py +0 -0
- {docorient-0.3.2 → docorient-0.4.0}/tests/test_correction.py +0 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.4.0 (2026-02-27)
|
|
4
|
+
|
|
5
|
+
- **Breaking:** remove Tesseract/pytesseract dependency entirely
|
|
6
|
+
- **Breaking:** remove `SecondaryEngine`, `TesseractNotAvailableError`, `[ocr]` extra
|
|
7
|
+
- **Breaking:** remove `secondary_confidence_threshold` and `secondary_max_dimension` from `OrientationConfig`
|
|
8
|
+
- Add `FlipClassifierEngine`: MobileNetV2-based ONNX neural network for 180° detection
|
|
9
|
+
- ONNX model (~8.5 MB) ships embedded in the package — no external binaries needed
|
|
10
|
+
- Model trained on 3,000+ diverse document images from 5 public datasets (FUNSD, Invoices & Receipts, CORD-v2, DocLayNet, DocumentVQA)
|
|
11
|
+
- CNN standalone accuracy: 96.8% — combined pipeline accuracy: 98% across all 4 orientations
|
|
12
|
+
- Inference latency: ~5ms per image (CNN) / ~20ms per image (full pipeline)
|
|
13
|
+
- Add `flip_confidence_threshold` to `OrientationConfig`
|
|
14
|
+
- `onnxruntime` is now a core dependency (no longer optional)
|
|
15
|
+
- Detection pipeline now resolves all 4 angles (0°, 90°, 180°, 270°) without external tools
|
|
16
|
+
- Pipeline: PrimaryEngine detects axis (H/V), FlipClassifier resolves flip within each axis
|
|
17
|
+
- Update CLI: remove `--no-secondary` and `--confidence` flags
|
|
18
|
+
- Bump status to "Development Status :: 4 - Beta"
|
|
19
|
+
|
|
20
|
+
## 0.3.2 (2026-02-27)
|
|
21
|
+
|
|
22
|
+
- Fix: update project URLs to correct GitHub repository
|
|
23
|
+
- Add changelog link to PyPI metadata
|
|
24
|
+
|
|
25
|
+
## 0.3.1 (2026-02-27)
|
|
26
|
+
|
|
27
|
+
- Fix: author metadata corrected to Lucas Gabriel Vaz
|
|
28
|
+
- Remove `tesseract` from PyPI keywords
|
|
29
|
+
|
|
30
|
+
## 0.3.0 (2026-02-27)
|
|
31
|
+
|
|
32
|
+
- **Breaking:** detection engines refactored to class-based architecture with `DetectionEngine` Protocol
|
|
33
|
+
- Introduce `DetectionPipeline` for extensible engine orchestration
|
|
34
|
+
- Introduce `PrimaryEngine` and `SecondaryEngine` classes
|
|
35
|
+
- Extract `rotation.py` and `voting.py` as standalone modules
|
|
36
|
+
- Encapsulate worker state in `WorkerContext` dataclass
|
|
37
|
+
- Transform `_imaging.py` functions into `ImageIO` class
|
|
38
|
+
- Decompose `process_directory` into focused sub-functions
|
|
39
|
+
- Apply custom exceptions throughout codebase
|
|
40
|
+
- Export `DetectionEngine`, `DetectionPipeline`, `PrimaryEngine` in public API
|
|
41
|
+
|
|
42
|
+
## 0.2.0 (2026-02-26)
|
|
43
|
+
|
|
44
|
+
- **Breaking:** renamed config params and CLI flags for internal engines
|
|
45
|
+
- Updated `OrientationResult.method` trace strings
|
|
46
|
+
|
|
47
|
+
## 0.1.1 (2026-02-26)
|
|
48
|
+
|
|
49
|
+
- Docs: added `if __name__ == "__main__":` note for `process_directory` on macOS/Windows
|
|
50
|
+
|
|
51
|
+
## 0.1.0 (2026-02-25)
|
|
52
|
+
|
|
53
|
+
- Initial release
|
|
54
|
+
- Primary engine for 90°/270° detection
|
|
55
|
+
- Single image and batch directory processing
|
|
56
|
+
- Multi-page majority voting
|
|
57
|
+
- Resumable batch processing
|
|
58
|
+
- CLI interface
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: docorient
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Document image orientation detection and correction.
|
|
5
5
|
Project-URL: Homepage, https://github.com/lucasleirbag/DocOrient
|
|
6
6
|
Project-URL: Repository, https://github.com/lucasleirbag/DocOrient
|
|
@@ -9,8 +9,8 @@ Project-URL: Changelog, https://github.com/lucasleirbag/DocOrient/blob/main/CHAN
|
|
|
9
9
|
Author: Lucas Gabriel Vaz
|
|
10
10
|
License-Expression: MIT
|
|
11
11
|
License-File: LICENSE
|
|
12
|
-
Keywords: correction,document,image,
|
|
13
|
-
Classifier: Development Status ::
|
|
12
|
+
Keywords: correction,document,image,onnx,orientation,rotation
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
14
|
Classifier: Intended Audience :: Developers
|
|
15
15
|
Classifier: License :: OSI Approved :: MIT License
|
|
16
16
|
Classifier: Programming Language :: Python :: 3
|
|
@@ -22,6 +22,7 @@ Classifier: Topic :: Scientific/Engineering :: Image Processing
|
|
|
22
22
|
Classifier: Typing :: Typed
|
|
23
23
|
Requires-Python: >=3.10
|
|
24
24
|
Requires-Dist: numpy>=1.24
|
|
25
|
+
Requires-Dist: onnxruntime>=1.16
|
|
25
26
|
Requires-Dist: pillow>=10.0
|
|
26
27
|
Requires-Dist: tqdm>=4.60
|
|
27
28
|
Provides-Extra: dev
|
|
@@ -30,8 +31,6 @@ Requires-Dist: pytest-cov; extra == 'dev'
|
|
|
30
31
|
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
31
32
|
Requires-Dist: ruff>=0.4; extra == 'dev'
|
|
32
33
|
Requires-Dist: twine; extra == 'dev'
|
|
33
|
-
Provides-Extra: ocr
|
|
34
|
-
Requires-Dist: pytesseract>=0.3.10; extra == 'ocr'
|
|
35
34
|
Description-Content-Type: text/markdown
|
|
36
35
|
|
|
37
36
|
# docorient
|
|
@@ -42,7 +41,9 @@ Description-Content-Type: text/markdown
|
|
|
42
41
|
|
|
43
42
|
Document image orientation detection and correction.
|
|
44
43
|
|
|
45
|
-
Automatically detects and fixes rotation (0°, 90°, 180°, 270°) in scanned document images using a two-stage detection
|
|
44
|
+
Automatically detects and fixes rotation (0°, 90°, 180°, 270°) in scanned document images using a two-stage pipeline: energy-based axis detection + neural network flip classification. Achieves **98% accuracy** across all four orientations. Includes multi-page majority voting and parallel batch processing.
|
|
45
|
+
|
|
46
|
+
No external dependencies like Tesseract required — the neural model ships embedded in the package (~8.5 MB).
|
|
46
47
|
|
|
47
48
|
---
|
|
48
49
|
|
|
@@ -52,18 +53,6 @@ Automatically detects and fixes rotation (0°, 90°, 180°, 270°) in scanned do
|
|
|
52
53
|
pip install docorient
|
|
53
54
|
```
|
|
54
55
|
|
|
55
|
-
To enable full 180° detection:
|
|
56
|
-
|
|
57
|
-
```bash
|
|
58
|
-
pip install docorient[ocr]
|
|
59
|
-
```
|
|
60
|
-
|
|
61
|
-
> **Note:** The `[ocr]` extra requires [Tesseract](https://github.com/tesseract-ocr/tesseract) installed on your system.
|
|
62
|
-
>
|
|
63
|
-
> - **macOS:** `brew install tesseract`
|
|
64
|
-
> - **Ubuntu/Debian:** `sudo apt install tesseract-ocr`
|
|
65
|
-
> - **Windows:** [installer](https://github.com/UB-Mannheim/tesseract/wiki)
|
|
66
|
-
|
|
67
56
|
---
|
|
68
57
|
|
|
69
58
|
## Quick Start
|
|
@@ -134,26 +123,37 @@ if __name__ == "__main__":
|
|
|
134
123
|
```bash
|
|
135
124
|
docorient ./scans --output ./fixed
|
|
136
125
|
docorient ./scans --output ./fixed --workers 4 --quality 95
|
|
137
|
-
docorient ./scans --
|
|
126
|
+
docorient ./scans --limit 100
|
|
138
127
|
docorient ./scans --dry-run
|
|
139
128
|
docorient --version
|
|
140
129
|
```
|
|
141
130
|
|
|
142
131
|
---
|
|
143
132
|
|
|
133
|
+
## How It Works
|
|
134
|
+
|
|
135
|
+
DocOrient uses a two-stage detection pipeline:
|
|
136
|
+
|
|
137
|
+
1. **PrimaryEngine** — Analyzes horizontal and vertical energy distribution to determine if text is horizontal (0°/180°) or vertical (90°/270°). Fast, pure NumPy, ~97% accurate for axis detection.
|
|
138
|
+
|
|
139
|
+
2. **FlipClassifierEngine** — A MobileNetV2-based neural network (exported as ONNX, ~8.5 MB) that classifies whether a document is upside-down (180°). Trained on 3,000+ diverse document images from 5 public datasets (forms, invoices, receipts, technical documents, varied layouts). Achieves 96.8% standalone accuracy. Runs via ONNX Runtime at ~5ms per image on CPU.
|
|
140
|
+
|
|
141
|
+
The pipeline combines both: PrimaryEngine detects the axis, FlipClassifier resolves the ambiguity within each axis pair (0 vs 180, 90 vs 270). Combined pipeline accuracy: **98%** across all four orientations (~20ms per image).
|
|
142
|
+
|
|
143
|
+
---
|
|
144
|
+
|
|
144
145
|
## Configuration Reference
|
|
145
146
|
|
|
146
147
|
```python
|
|
147
148
|
from docorient import OrientationConfig
|
|
148
149
|
|
|
149
150
|
config = OrientationConfig(
|
|
150
|
-
|
|
151
|
-
output_quality=92,
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
supported_extensions=( # File extensions to process
|
|
151
|
+
flip_confidence_threshold=0.50, # CNN confidence threshold (default: 0.50)
|
|
152
|
+
output_quality=92, # JPEG output quality 1-100 (default: 92)
|
|
153
|
+
primary_max_dimension=800, # Max image size for primary engine (default: 800)
|
|
154
|
+
workers=4, # Parallel workers; None = cpu_count-2 (default: None)
|
|
155
|
+
resume_enabled=True, # Resume interrupted batch jobs (default: True)
|
|
156
|
+
supported_extensions=( # File extensions to process
|
|
157
157
|
".jpg", ".jpeg", ".png",
|
|
158
158
|
".tiff", ".tif", ".bmp",
|
|
159
159
|
".gif", ".webp",
|
|
@@ -165,7 +165,7 @@ config = OrientationConfig(
|
|
|
165
165
|
|
|
166
166
|
## API Reference
|
|
167
167
|
|
|
168
|
-
### `detect_orientation(image, config=None)
|
|
168
|
+
### `detect_orientation(image, config=None) -> OrientationResult`
|
|
169
169
|
|
|
170
170
|
Detects the orientation of a document image without modifying it.
|
|
171
171
|
|
|
@@ -188,20 +188,20 @@ Detects the orientation of a document image without modifying it.
|
|
|
188
188
|
|
|
189
189
|
Detects and corrects the orientation of a single image.
|
|
190
190
|
|
|
191
|
-
- `return_metadata=False`
|
|
192
|
-
- `return_metadata=True`
|
|
191
|
+
- `return_metadata=False` -> returns `PIL.Image.Image`
|
|
192
|
+
- `return_metadata=True` -> returns `CorrectionResult`
|
|
193
193
|
|
|
194
194
|
**`CorrectionResult`** fields: `image: PIL.Image.Image`, `orientation: OrientationResult`
|
|
195
195
|
|
|
196
196
|
---
|
|
197
197
|
|
|
198
|
-
### `correct_document_pages(pages, *, config=None)
|
|
198
|
+
### `correct_document_pages(pages, *, config=None) -> list[CorrectionResult]`
|
|
199
199
|
|
|
200
200
|
Corrects orientation for all pages of a multi-page document with majority voting.
|
|
201
201
|
|
|
202
202
|
---
|
|
203
203
|
|
|
204
|
-
### `process_directory(input_dir, *, output_dir=None, config=None, limit=0, show_progress=True)
|
|
204
|
+
### `process_directory(input_dir, *, output_dir=None, config=None, limit=0, show_progress=True) -> BatchSummary`
|
|
205
205
|
|
|
206
206
|
Processes all images in a directory in parallel.
|
|
207
207
|
|
|
@@ -227,11 +227,11 @@ Processes all images in a directory in parallel.
|
|
|
227
227
|
|
|
228
228
|
```
|
|
229
229
|
contrato_p1.jpg ┐
|
|
230
|
-
contrato_p2.jpg
|
|
230
|
+
contrato_p2.jpg ├── grouped as "contrato" -> majority voting applied
|
|
231
231
|
contrato_p3.jpg ┘
|
|
232
232
|
|
|
233
233
|
edital_p1.jpg ┐
|
|
234
|
-
edital_p2.jpg
|
|
234
|
+
edital_p2.jpg ┘── grouped as "edital"
|
|
235
235
|
```
|
|
236
236
|
|
|
237
237
|
Files that don't match the pattern are treated as single-page documents.
|
|
@@ -260,7 +260,6 @@ from docorient import (
|
|
|
260
260
|
DetectionError,
|
|
261
261
|
CorrectionError,
|
|
262
262
|
BatchProcessingError,
|
|
263
|
-
TesseractNotAvailableError,
|
|
264
263
|
)
|
|
265
264
|
```
|
|
266
265
|
|
|
@@ -269,10 +268,10 @@ from docorient import (
|
|
|
269
268
|
## Development
|
|
270
269
|
|
|
271
270
|
```bash
|
|
272
|
-
git clone https://github.com/lucasleirbag/
|
|
273
|
-
cd
|
|
271
|
+
git clone https://github.com/lucasleirbag/DocOrient.git
|
|
272
|
+
cd DocOrient
|
|
274
273
|
python -m venv .venv && source .venv/bin/activate
|
|
275
|
-
pip install -e ".[dev
|
|
274
|
+
pip install -e ".[dev]"
|
|
276
275
|
pytest tests/ -v
|
|
277
276
|
ruff check src/ tests/
|
|
278
277
|
```
|
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
|
|
7
7
|
Document image orientation detection and correction.
|
|
8
8
|
|
|
9
|
-
Automatically detects and fixes rotation (0°, 90°, 180°, 270°) in scanned document images using a two-stage detection
|
|
9
|
+
Automatically detects and fixes rotation (0°, 90°, 180°, 270°) in scanned document images using a two-stage pipeline: energy-based axis detection + neural network flip classification. Achieves **98% accuracy** across all four orientations. Includes multi-page majority voting and parallel batch processing.
|
|
10
|
+
|
|
11
|
+
No external dependencies like Tesseract required — the neural model ships embedded in the package (~8.5 MB).
|
|
10
12
|
|
|
11
13
|
---
|
|
12
14
|
|
|
@@ -16,18 +18,6 @@ Automatically detects and fixes rotation (0°, 90°, 180°, 270°) in scanned do
|
|
|
16
18
|
pip install docorient
|
|
17
19
|
```
|
|
18
20
|
|
|
19
|
-
To enable full 180° detection:
|
|
20
|
-
|
|
21
|
-
```bash
|
|
22
|
-
pip install docorient[ocr]
|
|
23
|
-
```
|
|
24
|
-
|
|
25
|
-
> **Note:** The `[ocr]` extra requires [Tesseract](https://github.com/tesseract-ocr/tesseract) installed on your system.
|
|
26
|
-
>
|
|
27
|
-
> - **macOS:** `brew install tesseract`
|
|
28
|
-
> - **Ubuntu/Debian:** `sudo apt install tesseract-ocr`
|
|
29
|
-
> - **Windows:** [installer](https://github.com/UB-Mannheim/tesseract/wiki)
|
|
30
|
-
|
|
31
21
|
---
|
|
32
22
|
|
|
33
23
|
## Quick Start
|
|
@@ -98,26 +88,37 @@ if __name__ == "__main__":
|
|
|
98
88
|
```bash
|
|
99
89
|
docorient ./scans --output ./fixed
|
|
100
90
|
docorient ./scans --output ./fixed --workers 4 --quality 95
|
|
101
|
-
docorient ./scans --
|
|
91
|
+
docorient ./scans --limit 100
|
|
102
92
|
docorient ./scans --dry-run
|
|
103
93
|
docorient --version
|
|
104
94
|
```
|
|
105
95
|
|
|
106
96
|
---
|
|
107
97
|
|
|
98
|
+
## How It Works
|
|
99
|
+
|
|
100
|
+
DocOrient uses a two-stage detection pipeline:
|
|
101
|
+
|
|
102
|
+
1. **PrimaryEngine** — Analyzes horizontal and vertical energy distribution to determine if text is horizontal (0°/180°) or vertical (90°/270°). Fast, pure NumPy, ~97% accurate for axis detection.
|
|
103
|
+
|
|
104
|
+
2. **FlipClassifierEngine** — A MobileNetV2-based neural network (exported as ONNX, ~8.5 MB) that classifies whether a document is upside-down (180°). Trained on 3,000+ diverse document images from 5 public datasets (forms, invoices, receipts, technical documents, varied layouts). Achieves 96.8% standalone accuracy. Runs via ONNX Runtime at ~5ms per image on CPU.
|
|
105
|
+
|
|
106
|
+
The pipeline combines both: PrimaryEngine detects the axis, FlipClassifier resolves the ambiguity within each axis pair (0 vs 180, 90 vs 270). Combined pipeline accuracy: **98%** across all four orientations (~20ms per image).
|
|
107
|
+
|
|
108
|
+
---
|
|
109
|
+
|
|
108
110
|
## Configuration Reference
|
|
109
111
|
|
|
110
112
|
```python
|
|
111
113
|
from docorient import OrientationConfig
|
|
112
114
|
|
|
113
115
|
config = OrientationConfig(
|
|
114
|
-
|
|
115
|
-
output_quality=92,
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
supported_extensions=( # File extensions to process
|
|
116
|
+
flip_confidence_threshold=0.50, # CNN confidence threshold (default: 0.50)
|
|
117
|
+
output_quality=92, # JPEG output quality 1-100 (default: 92)
|
|
118
|
+
primary_max_dimension=800, # Max image size for primary engine (default: 800)
|
|
119
|
+
workers=4, # Parallel workers; None = cpu_count-2 (default: None)
|
|
120
|
+
resume_enabled=True, # Resume interrupted batch jobs (default: True)
|
|
121
|
+
supported_extensions=( # File extensions to process
|
|
121
122
|
".jpg", ".jpeg", ".png",
|
|
122
123
|
".tiff", ".tif", ".bmp",
|
|
123
124
|
".gif", ".webp",
|
|
@@ -129,7 +130,7 @@ config = OrientationConfig(
|
|
|
129
130
|
|
|
130
131
|
## API Reference
|
|
131
132
|
|
|
132
|
-
### `detect_orientation(image, config=None)
|
|
133
|
+
### `detect_orientation(image, config=None) -> OrientationResult`
|
|
133
134
|
|
|
134
135
|
Detects the orientation of a document image without modifying it.
|
|
135
136
|
|
|
@@ -152,20 +153,20 @@ Detects the orientation of a document image without modifying it.
|
|
|
152
153
|
|
|
153
154
|
Detects and corrects the orientation of a single image.
|
|
154
155
|
|
|
155
|
-
- `return_metadata=False`
|
|
156
|
-
- `return_metadata=True`
|
|
156
|
+
- `return_metadata=False` -> returns `PIL.Image.Image`
|
|
157
|
+
- `return_metadata=True` -> returns `CorrectionResult`
|
|
157
158
|
|
|
158
159
|
**`CorrectionResult`** fields: `image: PIL.Image.Image`, `orientation: OrientationResult`
|
|
159
160
|
|
|
160
161
|
---
|
|
161
162
|
|
|
162
|
-
### `correct_document_pages(pages, *, config=None)
|
|
163
|
+
### `correct_document_pages(pages, *, config=None) -> list[CorrectionResult]`
|
|
163
164
|
|
|
164
165
|
Corrects orientation for all pages of a multi-page document with majority voting.
|
|
165
166
|
|
|
166
167
|
---
|
|
167
168
|
|
|
168
|
-
### `process_directory(input_dir, *, output_dir=None, config=None, limit=0, show_progress=True)
|
|
169
|
+
### `process_directory(input_dir, *, output_dir=None, config=None, limit=0, show_progress=True) -> BatchSummary`
|
|
169
170
|
|
|
170
171
|
Processes all images in a directory in parallel.
|
|
171
172
|
|
|
@@ -191,11 +192,11 @@ Processes all images in a directory in parallel.
|
|
|
191
192
|
|
|
192
193
|
```
|
|
193
194
|
contrato_p1.jpg ┐
|
|
194
|
-
contrato_p2.jpg
|
|
195
|
+
contrato_p2.jpg ├── grouped as "contrato" -> majority voting applied
|
|
195
196
|
contrato_p3.jpg ┘
|
|
196
197
|
|
|
197
198
|
edital_p1.jpg ┐
|
|
198
|
-
edital_p2.jpg
|
|
199
|
+
edital_p2.jpg ┘── grouped as "edital"
|
|
199
200
|
```
|
|
200
201
|
|
|
201
202
|
Files that don't match the pattern are treated as single-page documents.
|
|
@@ -224,7 +225,6 @@ from docorient import (
|
|
|
224
225
|
DetectionError,
|
|
225
226
|
CorrectionError,
|
|
226
227
|
BatchProcessingError,
|
|
227
|
-
TesseractNotAvailableError,
|
|
228
228
|
)
|
|
229
229
|
```
|
|
230
230
|
|
|
@@ -233,10 +233,10 @@ from docorient import (
|
|
|
233
233
|
## Development
|
|
234
234
|
|
|
235
235
|
```bash
|
|
236
|
-
git clone https://github.com/lucasleirbag/
|
|
237
|
-
cd
|
|
236
|
+
git clone https://github.com/lucasleirbag/DocOrient.git
|
|
237
|
+
cd DocOrient
|
|
238
238
|
python -m venv .venv && source .venv/bin/activate
|
|
239
|
-
pip install -e ".[dev
|
|
239
|
+
pip install -e ".[dev]"
|
|
240
240
|
pytest tests/ -v
|
|
241
241
|
ruff check src/ tests/
|
|
242
242
|
```
|
|
@@ -18,8 +18,10 @@ src/docorient/
|
|
|
18
18
|
│ ├── __init__.py Re-exports detection API
|
|
19
19
|
│ ├── base.py DetectionEngine Protocol
|
|
20
20
|
│ ├── engine.py DetectionPipeline orchestrator
|
|
21
|
-
│ ├── primary.py PrimaryEngine (
|
|
22
|
-
│ └──
|
|
21
|
+
│ ├── primary.py PrimaryEngine (axis detection)
|
|
22
|
+
│ └── flip_classifier.py FlipClassifierEngine (180° CNN)
|
|
23
|
+
├── models/
|
|
24
|
+
│ └── orientation_detector.onnx MobileNetV2 ONNX model (~8.5 MB)
|
|
23
25
|
└── batch/
|
|
24
26
|
├── __init__.py Re-exports process_directory
|
|
25
27
|
├── scanner.py Directory scanning and grouping
|
|
@@ -34,35 +36,28 @@ detect_orientation()
|
|
|
34
36
|
│
|
|
35
37
|
▼
|
|
36
38
|
DetectionPipeline.run()
|
|
37
|
-
Iterates through
|
|
39
|
+
Iterates through [PrimaryEngine, FlipClassifierEngine]
|
|
38
40
|
│
|
|
39
41
|
▼
|
|
40
42
|
PrimaryEngine.detect()
|
|
41
|
-
Analyzes pixel
|
|
43
|
+
Analyzes pixel energy distribution to classify axis:
|
|
44
|
+
- Horizontal text (0° or 180°)
|
|
45
|
+
- Vertical text (90° or 270°)
|
|
42
46
|
│
|
|
43
|
-
|
|
47
|
+
▼
|
|
48
|
+
FlipClassifierEngine via _resolve_flip()
|
|
49
|
+
│
|
|
50
|
+
├── If horizontal → CNN classifies directly
|
|
51
|
+
│ ├── Upright → return 0°
|
|
52
|
+
│ └── Upside-down → return 180°
|
|
44
53
|
│
|
|
45
|
-
└──
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
│
|
|
49
|
-
Yes ─────────┴───────── No
|
|
50
|
-
▼ ▼
|
|
51
|
-
Runs secondary analysis Return 0° (no change)
|
|
52
|
-
Checks for 180° inversion
|
|
53
|
-
with confidence scoring
|
|
54
|
-
│
|
|
55
|
-
confidence >= threshold?
|
|
56
|
-
│
|
|
57
|
-
Yes ────────┴──────── No
|
|
58
|
-
▼ ▼
|
|
59
|
-
Return 180° Return 0°
|
|
54
|
+
└── If vertical → normalize by rotating 90° CCW, then CNN classifies
|
|
55
|
+
├── Upright (was at 90°) → return 90°
|
|
56
|
+
└── Upside-down (was at 270°) → return 270°
|
|
60
57
|
```
|
|
61
58
|
|
|
62
59
|
## Engine Architecture
|
|
63
60
|
|
|
64
|
-
The detection system is built on a `DetectionEngine` Protocol:
|
|
65
|
-
|
|
66
61
|
```
|
|
67
62
|
DetectionEngine (Protocol)
|
|
68
63
|
├── name: str
|
|
@@ -70,17 +65,57 @@ DetectionEngine (Protocol)
|
|
|
70
65
|
|
|
71
66
|
PrimaryEngine implements DetectionEngine
|
|
72
67
|
├── Always returns OrientationResult (never None)
|
|
73
|
-
|
|
68
|
+
├── Computes horizontal/vertical energy via pixel projection
|
|
69
|
+
└── Classifies text axis with ~97% accuracy
|
|
74
70
|
|
|
75
|
-
|
|
76
|
-
├── Returns None when
|
|
77
|
-
|
|
71
|
+
FlipClassifierEngine implements DetectionEngine
|
|
72
|
+
├── Returns None when confidence below threshold
|
|
73
|
+
├── Uses embedded MobileNetV2 ONNX model
|
|
74
|
+
├── Binary classification: upright vs upside-down
|
|
75
|
+
├── Inference via ONNX Runtime (~5ms on CPU)
|
|
76
|
+
├── Trained on 3,000+ diverse documents (96.8% accuracy)
|
|
77
|
+
└── Model input: 224×224 RGB, ImageNet-normalized
|
|
78
78
|
|
|
79
79
|
DetectionPipeline
|
|
80
|
-
├── Holds list[DetectionEngine] (default: [PrimaryEngine(),
|
|
81
|
-
|
|
80
|
+
├── Holds list[DetectionEngine] (default: [PrimaryEngine(), FlipClassifierEngine()])
|
|
81
|
+
├── PrimaryEngine detects axis
|
|
82
|
+
└── FlipClassifier resolves flip direction within the detected axis
|
|
82
83
|
```
|
|
83
84
|
|
|
85
|
+
## FlipClassifier Details
|
|
86
|
+
|
|
87
|
+
The FlipClassifierEngine wraps a MobileNetV2 fine-tuned for binary classification:
|
|
88
|
+
|
|
89
|
+
- **Task**: Classify if a document image is upside-down (180° rotated)
|
|
90
|
+
- **Architecture**: MobileNetV2 with custom binary classifier head
|
|
91
|
+
- **Input**: 224×224 RGB image, center-cropped from 256×256 resize
|
|
92
|
+
- **Normalization**: ImageNet mean/std
|
|
93
|
+
- **Output**: Single logit → sigmoid → P(upside_down)
|
|
94
|
+
- **Export format**: ONNX (opset 13)
|
|
95
|
+
- **Model size**: ~8.5 MB
|
|
96
|
+
- **Inference**: ~5ms per image on CPU via ONNX Runtime
|
|
97
|
+
- **Standalone accuracy**: 96.8% (validation set)
|
|
98
|
+
- **Pipeline accuracy**: 98% across all 4 orientations (0°, 90°, 180°, 270°)
|
|
99
|
+
|
|
100
|
+
### Training Data
|
|
101
|
+
|
|
102
|
+
The model was trained on 3,049 document images from 5 diverse public datasets:
|
|
103
|
+
|
|
104
|
+
| Dataset | Count | Type |
|
|
105
|
+
|---|---|---|
|
|
106
|
+
| FUNSD | 199 | Scanned forms |
|
|
107
|
+
| Invoices & Receipts | 250 | Financial documents |
|
|
108
|
+
| CORD-v2 | 900 | Point-of-sale receipts |
|
|
109
|
+
| DocLayNet v1.1 | 1,000 | Varied document layouts |
|
|
110
|
+
| DocumentVQA | 700 | Diverse document types |
|
|
111
|
+
|
|
112
|
+
Each image generates 2 training samples (upright + 180° flipped), with 3x augmentation
|
|
113
|
+
(random crop, color jitter, blur, grayscale, perspective, erasing). Total effective
|
|
114
|
+
training samples: ~15,500.
|
|
115
|
+
|
|
116
|
+
For vertical images (90°/270°), the pipeline first normalizes by rotating 90° CCW,
|
|
117
|
+
then passes the horizontally-aligned image to the classifier.
|
|
118
|
+
|
|
84
119
|
## Batch Processing Pipeline
|
|
85
120
|
|
|
86
121
|
```
|
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
## Setup
|
|
4
4
|
|
|
5
5
|
```bash
|
|
6
|
-
git clone https://github.com/lucasleirbag/
|
|
7
|
-
cd
|
|
6
|
+
git clone https://github.com/lucasleirbag/DocOrient.git
|
|
7
|
+
cd DocOrient
|
|
8
8
|
python -m venv .venv && source .venv/bin/activate
|
|
9
|
-
pip install -e ".[dev
|
|
9
|
+
pip install -e ".[dev]"
|
|
10
10
|
```
|
|
11
11
|
|
|
12
12
|
## Running Tests
|
|
@@ -31,6 +31,27 @@ ruff check src/ tests/ --fix
|
|
|
31
31
|
- Functions must do exactly one thing, max ~30 lines of logic
|
|
32
32
|
- Configuration is always passed as a parameter
|
|
33
33
|
|
|
34
|
+
## Retraining the FlipClassifier Model
|
|
35
|
+
|
|
36
|
+
The CNN model can be retrained with updated or expanded datasets:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install torch torchvision datasets onnxscript
|
|
40
|
+
python scripts/training/train_orientation_cnn.py
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
The script automatically downloads and caches images from 5 public datasets to `data/image_cache/`.
|
|
44
|
+
On subsequent runs, the cache is reused (no re-download). Training uses MPS (Apple Silicon)
|
|
45
|
+
or CUDA when available. The exported ONNX model is saved to `models/orientation_detector.onnx`.
|
|
46
|
+
|
|
47
|
+
After retraining, copy the model to the package and validate:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
cp models/orientation_detector.onnx src/docorient/models/orientation_detector.onnx
|
|
51
|
+
python scripts/training/export_and_validate.py
|
|
52
|
+
pytest tests/ -v
|
|
53
|
+
```
|
|
54
|
+
|
|
34
55
|
## Publishing a New Version
|
|
35
56
|
|
|
36
57
|
1. Update `version` in `pyproject.toml` and `src/docorient/_version.py`
|
|
@@ -4,15 +4,15 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "docorient"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.4.0"
|
|
8
8
|
description = "Document image orientation detection and correction."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
11
11
|
requires-python = ">= 3.10"
|
|
12
12
|
authors = [{ name = "Lucas Gabriel Vaz" }]
|
|
13
|
-
keywords = ["document", "orientation", "rotation", "
|
|
13
|
+
keywords = ["document", "orientation", "rotation", "image", "correction", "onnx"]
|
|
14
14
|
classifiers = [
|
|
15
|
-
"Development Status ::
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
16
|
"Intended Audience :: Developers",
|
|
17
17
|
"License :: OSI Approved :: MIT License",
|
|
18
18
|
"Programming Language :: Python :: 3",
|
|
@@ -27,10 +27,10 @@ dependencies = [
|
|
|
27
27
|
"Pillow >= 10.0",
|
|
28
28
|
"numpy >= 1.24",
|
|
29
29
|
"tqdm >= 4.60",
|
|
30
|
+
"onnxruntime >= 1.16",
|
|
30
31
|
]
|
|
31
32
|
|
|
32
33
|
[project.optional-dependencies]
|
|
33
|
-
ocr = ["pytesseract >= 0.3.10"]
|
|
34
34
|
dev = ["pytest >= 8.0", "pytest-cov", "ruff >= 0.4", "build", "twine"]
|
|
35
35
|
|
|
36
36
|
[project.scripts]
|
|
@@ -42,9 +42,15 @@ Repository = "https://github.com/lucasleirbag/DocOrient"
|
|
|
42
42
|
Issues = "https://github.com/lucasleirbag/DocOrient/issues"
|
|
43
43
|
Changelog = "https://github.com/lucasleirbag/DocOrient/blob/main/CHANGELOG.md"
|
|
44
44
|
|
|
45
|
+
[tool.hatch.build]
|
|
46
|
+
exclude = ["/data", "/models", "/scripts"]
|
|
47
|
+
|
|
45
48
|
[tool.hatch.build.targets.wheel]
|
|
46
49
|
packages = ["src/docorient"]
|
|
47
50
|
|
|
51
|
+
[tool.hatch.build.targets.wheel.force-include]
|
|
52
|
+
"src/docorient/models/orientation_detector.onnx" = "docorient/models/orientation_detector.onnx"
|
|
53
|
+
|
|
48
54
|
[tool.ruff]
|
|
49
55
|
target-version = "py310"
|
|
50
56
|
line-length = 100
|
|
@@ -4,14 +4,13 @@ from docorient.config import OrientationConfig
|
|
|
4
4
|
from docorient.correction import correct_document_pages, correct_image
|
|
5
5
|
from docorient.detection.base import DetectionEngine
|
|
6
6
|
from docorient.detection.engine import DetectionPipeline, detect_orientation
|
|
7
|
+
from docorient.detection.flip_classifier import FlipClassifierEngine
|
|
7
8
|
from docorient.detection.primary import PrimaryEngine
|
|
8
|
-
from docorient.detection.secondary import SecondaryEngine
|
|
9
9
|
from docorient.exceptions import (
|
|
10
10
|
BatchProcessingError,
|
|
11
11
|
CorrectionError,
|
|
12
12
|
DetectionError,
|
|
13
13
|
DocorientError,
|
|
14
|
-
TesseractNotAvailableError,
|
|
15
14
|
)
|
|
16
15
|
from docorient.types import (
|
|
17
16
|
BatchSummary,
|
|
@@ -29,12 +28,11 @@ __all__ = [
|
|
|
29
28
|
"DetectionError",
|
|
30
29
|
"DetectionPipeline",
|
|
31
30
|
"DocorientError",
|
|
31
|
+
"FlipClassifierEngine",
|
|
32
32
|
"OrientationConfig",
|
|
33
33
|
"OrientationResult",
|
|
34
34
|
"PageResult",
|
|
35
35
|
"PrimaryEngine",
|
|
36
|
-
"SecondaryEngine",
|
|
37
|
-
"TesseractNotAvailableError",
|
|
38
36
|
"__version__",
|
|
39
37
|
"correct_document_pages",
|
|
40
38
|
"correct_image",
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.4.0"
|
|
@@ -7,7 +7,7 @@ from pathlib import Path
|
|
|
7
7
|
from docorient._version import __version__
|
|
8
8
|
from docorient.batch.processor import process_directory
|
|
9
9
|
from docorient.config import OrientationConfig
|
|
10
|
-
from docorient.detection.
|
|
10
|
+
from docorient.detection.flip_classifier import is_flip_classifier_available
|
|
11
11
|
|
|
12
12
|
|
|
13
13
|
def _build_argument_parser() -> argparse.ArgumentParser:
|
|
@@ -45,18 +45,6 @@ def _build_argument_parser() -> argparse.ArgumentParser:
|
|
|
45
45
|
default=92,
|
|
46
46
|
help="Output JPEG quality (1-100). Default: 92.",
|
|
47
47
|
)
|
|
48
|
-
parser.add_argument(
|
|
49
|
-
"--confidence",
|
|
50
|
-
type=float,
|
|
51
|
-
default=2.0,
|
|
52
|
-
help="Secondary engine confidence threshold. Default: 2.0.",
|
|
53
|
-
)
|
|
54
|
-
parser.add_argument(
|
|
55
|
-
"--no-secondary",
|
|
56
|
-
action="store_true",
|
|
57
|
-
default=False,
|
|
58
|
-
help="Disable secondary engine (primary engine only).",
|
|
59
|
-
)
|
|
60
48
|
parser.add_argument(
|
|
61
49
|
"--no-resume",
|
|
62
50
|
action="store_true",
|
|
@@ -91,16 +79,18 @@ def _print_dry_run_info(input_path: Path, config: OrientationConfig, limit: int)
|
|
|
91
79
|
total_files = len(pages_by_source)
|
|
92
80
|
total_pages = sum(len(pages) for pages in pages_by_source.values())
|
|
93
81
|
|
|
82
|
+
flip_status = "available" if is_flip_classifier_available() else "unavailable"
|
|
83
|
+
|
|
94
84
|
print(f"\n{'=' * 60}")
|
|
95
85
|
print(" DRY RUN - No changes will be made")
|
|
96
86
|
print(f"{'=' * 60}")
|
|
97
|
-
print(f" Input:
|
|
98
|
-
print(f" Files:
|
|
99
|
-
print(f" Pages:
|
|
100
|
-
print(f" Workers:
|
|
101
|
-
print(f"
|
|
102
|
-
print(f" Quality:
|
|
103
|
-
print(f" Resume:
|
|
87
|
+
print(f" Input: {input_path}")
|
|
88
|
+
print(f" Files: {total_files} source documents")
|
|
89
|
+
print(f" Pages: {total_pages} images")
|
|
90
|
+
print(f" Workers: {config.effective_workers}")
|
|
91
|
+
print(f" Flip classifier: {flip_status}")
|
|
92
|
+
print(f" Quality: {config.output_quality}")
|
|
93
|
+
print(f" Resume: {'enabled' if config.resume_enabled else 'disabled'}")
|
|
104
94
|
print(f"{'=' * 60}\n")
|
|
105
95
|
|
|
106
96
|
|
|
@@ -138,10 +128,7 @@ def main() -> None:
|
|
|
138
128
|
print(f"Error: '{arguments.input_dir}' is not a valid directory.", file=sys.stderr)
|
|
139
129
|
sys.exit(1)
|
|
140
130
|
|
|
141
|
-
secondary_threshold = arguments.confidence if not arguments.no_secondary else float("inf")
|
|
142
|
-
|
|
143
131
|
config = OrientationConfig(
|
|
144
|
-
secondary_confidence_threshold=secondary_threshold,
|
|
145
132
|
output_quality=arguments.quality,
|
|
146
133
|
workers=arguments.workers,
|
|
147
134
|
resume_enabled=not arguments.no_resume,
|
|
@@ -151,14 +138,16 @@ def main() -> None:
|
|
|
151
138
|
_print_dry_run_info(input_path, config, arguments.limit)
|
|
152
139
|
return
|
|
153
140
|
|
|
141
|
+
flip_status = "available" if is_flip_classifier_available() else "unavailable"
|
|
142
|
+
|
|
154
143
|
print(f"\n{'=' * 60}")
|
|
155
144
|
print(f" docorient v{__version__}")
|
|
156
145
|
print(f"{'=' * 60}")
|
|
157
|
-
print(f" Input:
|
|
158
|
-
print(f" Output:
|
|
159
|
-
print(f" Workers:
|
|
160
|
-
print(f"
|
|
161
|
-
print(f" Quality:
|
|
146
|
+
print(f" Input: {input_path}")
|
|
147
|
+
print(f" Output: {arguments.output_dir or 'auto (UUID)'}")
|
|
148
|
+
print(f" Workers: {config.effective_workers}")
|
|
149
|
+
print(f" Flip classifier: {flip_status}")
|
|
150
|
+
print(f" Quality: {config.output_quality}")
|
|
162
151
|
print(f"{'=' * 60}\n")
|
|
163
152
|
|
|
164
153
|
summary = process_directory(
|
|
@@ -23,9 +23,8 @@ def _default_worker_count() -> int:
|
|
|
23
23
|
|
|
24
24
|
@dataclass(frozen=True, slots=True)
|
|
25
25
|
class OrientationConfig:
|
|
26
|
-
|
|
26
|
+
flip_confidence_threshold: float = 0.50
|
|
27
27
|
output_quality: int = 92
|
|
28
|
-
secondary_max_dimension: int = 1200
|
|
29
28
|
primary_max_dimension: int = 800
|
|
30
29
|
workers: int | None = None
|
|
31
30
|
resume_enabled: bool = True
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
from docorient.detection.base import DetectionEngine
|
|
2
2
|
from docorient.detection.engine import DetectionPipeline, detect_orientation
|
|
3
|
+
from docorient.detection.flip_classifier import FlipClassifierEngine, is_flip_classifier_available
|
|
3
4
|
from docorient.detection.primary import PrimaryEngine
|
|
4
|
-
from docorient.detection.secondary import SecondaryEngine, is_secondary_engine_available
|
|
5
5
|
|
|
6
6
|
__all__ = [
|
|
7
7
|
"DetectionEngine",
|
|
8
8
|
"DetectionPipeline",
|
|
9
|
+
"FlipClassifierEngine",
|
|
9
10
|
"PrimaryEngine",
|
|
10
|
-
"SecondaryEngine",
|
|
11
11
|
"detect_orientation",
|
|
12
|
-
"
|
|
12
|
+
"is_flip_classifier_available",
|
|
13
13
|
]
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from PIL import Image
|
|
4
|
+
|
|
5
|
+
from docorient.config import OrientationConfig
|
|
6
|
+
from docorient.detection.base import DetectionEngine
|
|
7
|
+
from docorient.detection.flip_classifier import FlipClassifierEngine
|
|
8
|
+
from docorient.detection.primary import PrimaryEngine
|
|
9
|
+
from docorient.types import OrientationResult
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _build_default_engines() -> list[DetectionEngine]:
|
|
13
|
+
return [PrimaryEngine(), FlipClassifierEngine()]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class DetectionPipeline:
|
|
17
|
+
def __init__(self, engines: list[DetectionEngine] | None = None) -> None:
|
|
18
|
+
self.engines: list[DetectionEngine] = engines or _build_default_engines()
|
|
19
|
+
|
|
20
|
+
def run(self, image: Image.Image, config: OrientationConfig) -> OrientationResult:
|
|
21
|
+
primary_result: OrientationResult | None = None
|
|
22
|
+
|
|
23
|
+
for engine in self.engines:
|
|
24
|
+
if engine.name == "primary":
|
|
25
|
+
primary_result = engine.detect(image, config)
|
|
26
|
+
continue
|
|
27
|
+
|
|
28
|
+
if engine.name == "flip_classifier":
|
|
29
|
+
flip_result = self._resolve_flip(engine, image, config, primary_result)
|
|
30
|
+
if flip_result is not None:
|
|
31
|
+
return flip_result
|
|
32
|
+
continue
|
|
33
|
+
|
|
34
|
+
if primary_result is not None:
|
|
35
|
+
return primary_result
|
|
36
|
+
|
|
37
|
+
return OrientationResult(angle=0, method="none", reliable=False)
|
|
38
|
+
|
|
39
|
+
@staticmethod
|
|
40
|
+
def _resolve_flip(
|
|
41
|
+
engine: DetectionEngine,
|
|
42
|
+
image: Image.Image,
|
|
43
|
+
config: OrientationConfig,
|
|
44
|
+
primary_result: OrientationResult | None,
|
|
45
|
+
) -> OrientationResult | None:
|
|
46
|
+
is_vertical = primary_result is not None and primary_result.angle in (90, 270)
|
|
47
|
+
normalized_image = (
|
|
48
|
+
image.transpose(Image.Transpose.ROTATE_90) if is_vertical else image
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
flip_result = engine.detect(normalized_image, config)
|
|
52
|
+
|
|
53
|
+
if is_vertical and normalized_image is not image:
|
|
54
|
+
normalized_image.close()
|
|
55
|
+
|
|
56
|
+
if flip_result is None:
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
if is_vertical:
|
|
60
|
+
final_angle = 270 if flip_result.angle == 180 else 90
|
|
61
|
+
else:
|
|
62
|
+
final_angle = flip_result.angle
|
|
63
|
+
|
|
64
|
+
combined_method = flip_result.method
|
|
65
|
+
if primary_result is not None:
|
|
66
|
+
combined_method = f"{flip_result.method},{primary_result.method}"
|
|
67
|
+
|
|
68
|
+
return OrientationResult(
|
|
69
|
+
angle=final_angle,
|
|
70
|
+
method=combined_method,
|
|
71
|
+
reliable=flip_result.reliable,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
_default_pipeline = DetectionPipeline()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def detect_orientation(
|
|
79
|
+
image: Image.Image,
|
|
80
|
+
config: OrientationConfig | None = None,
|
|
81
|
+
) -> OrientationResult:
|
|
82
|
+
effective_config = config or OrientationConfig()
|
|
83
|
+
return _default_pipeline.run(image, effective_config)
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from importlib import resources
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
from PIL import Image
|
|
8
|
+
|
|
9
|
+
from docorient.config import OrientationConfig
|
|
10
|
+
from docorient.types import OrientationResult
|
|
11
|
+
|
|
12
|
+
MODEL_INPUT_SIZE = 224
|
|
13
|
+
IMAGENET_MEAN = np.array([0.485, 0.456, 0.406], dtype=np.float32)
|
|
14
|
+
IMAGENET_STD = np.array([0.229, 0.224, 0.225], dtype=np.float32)
|
|
15
|
+
|
|
16
|
+
_onnx_session_cache: object | None = None
|
|
17
|
+
_onnx_available: bool | None = None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _check_onnxruntime_available() -> bool:
|
|
21
|
+
global _onnx_available
|
|
22
|
+
if _onnx_available is None:
|
|
23
|
+
try:
|
|
24
|
+
import onnxruntime # noqa: F401
|
|
25
|
+
_onnx_available = True
|
|
26
|
+
except ImportError:
|
|
27
|
+
_onnx_available = False
|
|
28
|
+
return _onnx_available
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _get_model_path() -> Path:
|
|
32
|
+
model_ref = resources.files("docorient.models").joinpath("orientation_detector.onnx")
|
|
33
|
+
return Path(str(model_ref))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _get_onnx_session() -> object:
|
|
37
|
+
global _onnx_session_cache
|
|
38
|
+
if _onnx_session_cache is None:
|
|
39
|
+
import onnxruntime as ort
|
|
40
|
+
model_path = _get_model_path()
|
|
41
|
+
_onnx_session_cache = ort.InferenceSession(
|
|
42
|
+
str(model_path),
|
|
43
|
+
providers=["CPUExecutionProvider"],
|
|
44
|
+
)
|
|
45
|
+
return _onnx_session_cache
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _preprocess_image(image: Image.Image) -> np.ndarray:
|
|
49
|
+
rgb_image = image.convert("RGB")
|
|
50
|
+
resized_image = rgb_image.resize((256, 256), Image.LANCZOS)
|
|
51
|
+
|
|
52
|
+
crop_left = (256 - MODEL_INPUT_SIZE) // 2
|
|
53
|
+
crop_top = (256 - MODEL_INPUT_SIZE) // 2
|
|
54
|
+
cropped_image = resized_image.crop((
|
|
55
|
+
crop_left,
|
|
56
|
+
crop_top,
|
|
57
|
+
crop_left + MODEL_INPUT_SIZE,
|
|
58
|
+
crop_top + MODEL_INPUT_SIZE,
|
|
59
|
+
))
|
|
60
|
+
|
|
61
|
+
pixel_array = np.array(cropped_image, dtype=np.float32) / 255.0
|
|
62
|
+
normalized_array = (pixel_array - IMAGENET_MEAN) / IMAGENET_STD
|
|
63
|
+
transposed_array = normalized_array.transpose(2, 0, 1)
|
|
64
|
+
return np.expand_dims(transposed_array, axis=0)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _classify_upside_down(image: Image.Image) -> tuple[bool, float]:
|
|
68
|
+
session = _get_onnx_session()
|
|
69
|
+
input_name = session.get_inputs()[0].name
|
|
70
|
+
input_tensor = _preprocess_image(image)
|
|
71
|
+
outputs = session.run(None, {input_name: input_tensor})
|
|
72
|
+
logit = float(outputs[0][0][0])
|
|
73
|
+
probability = 1.0 / (1.0 + np.exp(-logit))
|
|
74
|
+
return logit > 0.0, probability
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class FlipClassifierEngine:
|
|
78
|
+
@property
|
|
79
|
+
def name(self) -> str:
|
|
80
|
+
return "flip_classifier"
|
|
81
|
+
|
|
82
|
+
@staticmethod
|
|
83
|
+
def is_available() -> bool:
|
|
84
|
+
if not _check_onnxruntime_available():
|
|
85
|
+
return False
|
|
86
|
+
return _get_model_path().exists()
|
|
87
|
+
|
|
88
|
+
def detect(
|
|
89
|
+
self, image: Image.Image, config: OrientationConfig
|
|
90
|
+
) -> OrientationResult | None:
|
|
91
|
+
if not self.is_available():
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
is_flipped, confidence = _classify_upside_down(image)
|
|
95
|
+
|
|
96
|
+
effective_confidence = confidence if is_flipped else (1.0 - confidence)
|
|
97
|
+
if effective_confidence < config.flip_confidence_threshold:
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
if is_flipped:
|
|
101
|
+
return OrientationResult(
|
|
102
|
+
angle=180,
|
|
103
|
+
method=f"flip_classifier(flipped,conf={confidence:.2f})",
|
|
104
|
+
reliable=True,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
return OrientationResult(
|
|
108
|
+
angle=0,
|
|
109
|
+
method=f"flip_classifier(upright,conf={confidence:.2f})",
|
|
110
|
+
reliable=True,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def is_flip_classifier_available() -> bool:
|
|
115
|
+
return FlipClassifierEngine.is_available()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
Binary file
|
|
@@ -31,21 +31,18 @@ class TestPrimaryEngine:
|
|
|
31
31
|
assert isinstance(result, OrientationResult)
|
|
32
32
|
|
|
33
33
|
|
|
34
|
-
class
|
|
35
|
-
def
|
|
34
|
+
class TestDetectionPipeline:
|
|
35
|
+
def test_horizontal_image_detection(self, horizontal_text_image):
|
|
36
36
|
result = detect_orientation(horizontal_text_image)
|
|
37
|
-
assert result.angle
|
|
37
|
+
assert result.angle in (0, 180)
|
|
38
38
|
assert result.reliable is True
|
|
39
39
|
|
|
40
|
-
def
|
|
40
|
+
def test_vertical_image_detection(self, vertical_text_image):
|
|
41
41
|
result = detect_orientation(vertical_text_image)
|
|
42
42
|
assert result.angle in (90, 270)
|
|
43
43
|
|
|
44
44
|
def test_with_custom_config(self, horizontal_text_image):
|
|
45
|
-
config = OrientationConfig(
|
|
46
|
-
secondary_confidence_threshold=10.0,
|
|
47
|
-
primary_max_dimension=400,
|
|
48
|
-
)
|
|
45
|
+
config = OrientationConfig(primary_max_dimension=400)
|
|
49
46
|
result = detect_orientation(horizontal_text_image, config=config)
|
|
50
47
|
assert isinstance(result, OrientationResult)
|
|
51
48
|
|
docorient-0.3.2/CHANGELOG.md
DELETED
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
# Changelog
|
|
2
|
-
|
|
3
|
-
## 0.3.2 (2026-02-27)
|
|
4
|
-
|
|
5
|
-
- Fix: update project URLs to correct GitHub repository
|
|
6
|
-
- Add changelog link to PyPI metadata
|
|
7
|
-
|
|
8
|
-
## 0.3.1 (2026-02-27)
|
|
9
|
-
|
|
10
|
-
- Fix: author metadata corrected to Lucas Gabriel Vaz
|
|
11
|
-
- Remove `tesseract` from PyPI keywords
|
|
12
|
-
|
|
13
|
-
## 0.3.0 (2026-02-27)
|
|
14
|
-
|
|
15
|
-
- **Breaking:** detection engines refactored to class-based architecture with `DetectionEngine` Protocol
|
|
16
|
-
- Introduce `DetectionPipeline` for extensible engine orchestration
|
|
17
|
-
- Introduce `PrimaryEngine` and `SecondaryEngine` classes
|
|
18
|
-
- Extract `rotation.py` and `voting.py` as standalone modules
|
|
19
|
-
- Encapsulate worker state in `WorkerContext` dataclass
|
|
20
|
-
- Transform `_imaging.py` functions into `ImageIO` class
|
|
21
|
-
- Decompose `process_directory` into focused sub-functions
|
|
22
|
-
- Apply custom exceptions (`DetectionError`, `CorrectionError`, `BatchProcessingError`) throughout codebase
|
|
23
|
-
- Export `DetectionEngine`, `DetectionPipeline`, `PrimaryEngine`, `SecondaryEngine` in public API
|
|
24
|
-
|
|
25
|
-
## 0.2.0 (2026-02-26)
|
|
26
|
-
|
|
27
|
-
- **Breaking:** renamed config params `osd_confidence_threshold` → `secondary_confidence_threshold`, `max_osd_dimension` → `secondary_max_dimension`, `projection_target_dimension` → `primary_max_dimension`
|
|
28
|
-
- **Breaking:** renamed CLI flag `--no-ocr` → `--no-secondary`
|
|
29
|
-
- Internal engines renamed to `primary` and `secondary`
|
|
30
|
-
- Updated `OrientationResult.method` trace strings
|
|
31
|
-
|
|
32
|
-
## 0.1.1 (2026-02-26)
|
|
33
|
-
|
|
34
|
-
- Docs: added `if __name__ == "__main__":` note for `process_directory` on macOS/Windows
|
|
35
|
-
|
|
36
|
-
## 0.1.0 (2026-02-25)
|
|
37
|
-
|
|
38
|
-
- Initial release
|
|
39
|
-
- Primary engine for 90°/270° detection
|
|
40
|
-
- Optional secondary engine for 180° detection
|
|
41
|
-
- Single image and batch directory processing
|
|
42
|
-
- Multi-page majority voting
|
|
43
|
-
- Resumable batch processing
|
|
44
|
-
- CLI interface
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.3.2"
|
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
from PIL import Image
|
|
4
|
-
|
|
5
|
-
from docorient.config import OrientationConfig
|
|
6
|
-
from docorient.detection.base import DetectionEngine
|
|
7
|
-
from docorient.detection.primary import PrimaryEngine
|
|
8
|
-
from docorient.detection.secondary import SecondaryEngine
|
|
9
|
-
from docorient.types import OrientationResult
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
class DetectionPipeline:
|
|
13
|
-
def __init__(self, engines: list[DetectionEngine] | None = None) -> None:
|
|
14
|
-
self.engines: list[DetectionEngine] = engines or [PrimaryEngine(), SecondaryEngine()]
|
|
15
|
-
|
|
16
|
-
def run(self, image: Image.Image, config: OrientationConfig) -> OrientationResult:
|
|
17
|
-
primary_result: OrientationResult | None = None
|
|
18
|
-
|
|
19
|
-
for engine in self.engines:
|
|
20
|
-
result = engine.detect(image, config)
|
|
21
|
-
|
|
22
|
-
if result is None:
|
|
23
|
-
continue
|
|
24
|
-
|
|
25
|
-
if engine.name == "primary":
|
|
26
|
-
primary_result = result
|
|
27
|
-
if result.angle in (90, 270):
|
|
28
|
-
return result
|
|
29
|
-
continue
|
|
30
|
-
|
|
31
|
-
if primary_result is not None:
|
|
32
|
-
combined_method = f"{result.method},{primary_result.method}"
|
|
33
|
-
else:
|
|
34
|
-
combined_method = result.method
|
|
35
|
-
return OrientationResult(
|
|
36
|
-
angle=result.angle,
|
|
37
|
-
method=combined_method,
|
|
38
|
-
reliable=True,
|
|
39
|
-
)
|
|
40
|
-
|
|
41
|
-
if primary_result is not None:
|
|
42
|
-
return primary_result
|
|
43
|
-
|
|
44
|
-
return OrientationResult(angle=0, method="none", reliable=False)
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
_default_pipeline = DetectionPipeline()
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
def detect_orientation(
|
|
51
|
-
image: Image.Image,
|
|
52
|
-
config: OrientationConfig | None = None,
|
|
53
|
-
) -> OrientationResult:
|
|
54
|
-
effective_config = config or OrientationConfig()
|
|
55
|
-
return _default_pipeline.run(image, effective_config)
|
|
@@ -1,75 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import warnings
|
|
4
|
-
|
|
5
|
-
from PIL import Image
|
|
6
|
-
|
|
7
|
-
from docorient._imaging import ImageIO
|
|
8
|
-
from docorient.config import OrientationConfig
|
|
9
|
-
from docorient.types import OrientationResult
|
|
10
|
-
|
|
11
|
-
_secondary_engine_available: bool | None = None
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
class SecondaryEngine:
|
|
15
|
-
@property
|
|
16
|
-
def name(self) -> str:
|
|
17
|
-
return "secondary"
|
|
18
|
-
|
|
19
|
-
@staticmethod
|
|
20
|
-
def is_available() -> bool:
|
|
21
|
-
global _secondary_engine_available
|
|
22
|
-
if _secondary_engine_available is None:
|
|
23
|
-
try:
|
|
24
|
-
import pytesseract # noqa: F401
|
|
25
|
-
|
|
26
|
-
_secondary_engine_available = True
|
|
27
|
-
except ImportError:
|
|
28
|
-
_secondary_engine_available = False
|
|
29
|
-
return _secondary_engine_available
|
|
30
|
-
|
|
31
|
-
def detect(
|
|
32
|
-
self, image: Image.Image, config: OrientationConfig
|
|
33
|
-
) -> OrientationResult | None:
|
|
34
|
-
if not self.is_available():
|
|
35
|
-
warnings.warn(
|
|
36
|
-
"Secondary engine not available. 180° detection is disabled. "
|
|
37
|
-
"Install with: pip install docorient[ocr]",
|
|
38
|
-
UserWarning,
|
|
39
|
-
stacklevel=2,
|
|
40
|
-
)
|
|
41
|
-
return None
|
|
42
|
-
|
|
43
|
-
downscaled_image = ImageIO.downscale(image, config.secondary_max_dimension)
|
|
44
|
-
detected_angle, detection_confidence = self._run_engine(downscaled_image)
|
|
45
|
-
|
|
46
|
-
if downscaled_image is not image:
|
|
47
|
-
downscaled_image.close()
|
|
48
|
-
|
|
49
|
-
if detection_confidence < config.secondary_confidence_threshold:
|
|
50
|
-
return None
|
|
51
|
-
|
|
52
|
-
if detected_angle not in (90, 180, 270):
|
|
53
|
-
return None
|
|
54
|
-
|
|
55
|
-
return OrientationResult(
|
|
56
|
-
angle=detected_angle,
|
|
57
|
-
method=f"secondary(angle={detected_angle},conf={detection_confidence:.1f})",
|
|
58
|
-
reliable=True,
|
|
59
|
-
)
|
|
60
|
-
|
|
61
|
-
@staticmethod
|
|
62
|
-
def _run_engine(image: Image.Image) -> tuple[int, float]:
|
|
63
|
-
import pytesseract
|
|
64
|
-
|
|
65
|
-
try:
|
|
66
|
-
result = pytesseract.image_to_osd(image, output_type=pytesseract.Output.DICT)
|
|
67
|
-
detected_angle = int(result.get("orientation", 0))
|
|
68
|
-
detection_confidence = float(result.get("orientation_conf", 0.0))
|
|
69
|
-
return detected_angle, detection_confidence
|
|
70
|
-
except pytesseract.TesseractError:
|
|
71
|
-
return 0, 0.0
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
def is_secondary_engine_available() -> bool:
|
|
75
|
-
return SecondaryEngine.is_available()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|