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.
Files changed (40) hide show
  1. docorient-0.4.0/CHANGELOG.md +58 -0
  2. {docorient-0.3.2 → docorient-0.4.0}/PKG-INFO +36 -37
  3. {docorient-0.3.2 → docorient-0.4.0}/README.md +32 -32
  4. {docorient-0.3.2 → docorient-0.4.0}/docs/architecture.md +63 -28
  5. {docorient-0.3.2 → docorient-0.4.0}/docs/contributing.md +24 -3
  6. {docorient-0.3.2 → docorient-0.4.0}/pyproject.toml +10 -4
  7. {docorient-0.3.2 → docorient-0.4.0}/src/docorient/__init__.py +2 -4
  8. docorient-0.4.0/src/docorient/_version.py +1 -0
  9. {docorient-0.3.2 → docorient-0.4.0}/src/docorient/cli.py +17 -28
  10. {docorient-0.3.2 → docorient-0.4.0}/src/docorient/config.py +1 -2
  11. {docorient-0.3.2 → docorient-0.4.0}/src/docorient/detection/__init__.py +3 -3
  12. docorient-0.4.0/src/docorient/detection/engine.py +83 -0
  13. docorient-0.4.0/src/docorient/detection/flip_classifier.py +115 -0
  14. {docorient-0.3.2 → docorient-0.4.0}/src/docorient/exceptions.py +0 -4
  15. docorient-0.4.0/src/docorient/models/__init__.py +1 -0
  16. docorient-0.4.0/src/docorient/models/orientation_detector.onnx +0 -0
  17. {docorient-0.3.2 → docorient-0.4.0}/tests/test_detection.py +5 -8
  18. docorient-0.3.2/CHANGELOG.md +0 -44
  19. docorient-0.3.2/src/docorient/_version.py +0 -1
  20. docorient-0.3.2/src/docorient/detection/engine.py +0 -55
  21. docorient-0.3.2/src/docorient/detection/secondary.py +0 -75
  22. {docorient-0.3.2 → docorient-0.4.0}/.github/workflows/publish.yml +0 -0
  23. {docorient-0.3.2 → docorient-0.4.0}/.gitignore +0 -0
  24. {docorient-0.3.2 → docorient-0.4.0}/LICENSE +0 -0
  25. {docorient-0.3.2 → docorient-0.4.0}/src/docorient/_imaging.py +0 -0
  26. {docorient-0.3.2 → docorient-0.4.0}/src/docorient/batch/__init__.py +0 -0
  27. {docorient-0.3.2 → docorient-0.4.0}/src/docorient/batch/processor.py +0 -0
  28. {docorient-0.3.2 → docorient-0.4.0}/src/docorient/batch/scanner.py +0 -0
  29. {docorient-0.3.2 → docorient-0.4.0}/src/docorient/batch/worker.py +0 -0
  30. {docorient-0.3.2 → docorient-0.4.0}/src/docorient/correction.py +0 -0
  31. {docorient-0.3.2 → docorient-0.4.0}/src/docorient/detection/base.py +0 -0
  32. {docorient-0.3.2 → docorient-0.4.0}/src/docorient/detection/primary.py +0 -0
  33. {docorient-0.3.2 → docorient-0.4.0}/src/docorient/rotation.py +0 -0
  34. {docorient-0.3.2 → docorient-0.4.0}/src/docorient/types.py +0 -0
  35. {docorient-0.3.2 → docorient-0.4.0}/src/docorient/voting.py +0 -0
  36. {docorient-0.3.2 → docorient-0.4.0}/tests/__init__.py +0 -0
  37. {docorient-0.3.2 → docorient-0.4.0}/tests/conftest.py +0 -0
  38. {docorient-0.3.2 → docorient-0.4.0}/tests/test_batch.py +0 -0
  39. {docorient-0.3.2 → docorient-0.4.0}/tests/test_cli.py +0 -0
  40. {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.2
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,ocr,orientation,rotation
13
- Classifier: Development Status :: 3 - Alpha
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 pipeline, with support for multi-page majority voting and parallel batch processing.
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 --no-secondary --limit 100
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
- secondary_confidence_threshold=2.0, # Minimum confidence for secondary engine (default: 2.0)
151
- output_quality=92, # JPEG output quality 1-100 (default: 92)
152
- secondary_max_dimension=1200, # Max image size for secondary engine (default: 1200)
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
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) OrientationResult`
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` returns `PIL.Image.Image`
192
- - `return_metadata=True` returns `CorrectionResult`
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) list[CorrectionResult]`
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) BatchSummary`
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 ├─► grouped as "contrato" majority voting applied
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 ┘─► grouped as "edital"
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/docorient.git
273
- cd docorient
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,ocr]"
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 pipeline, with support for multi-page majority voting and parallel batch processing.
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 --no-secondary --limit 100
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
- secondary_confidence_threshold=2.0, # Minimum confidence for secondary engine (default: 2.0)
115
- output_quality=92, # JPEG output quality 1-100 (default: 92)
116
- secondary_max_dimension=1200, # Max image size for secondary engine (default: 1200)
117
- primary_max_dimension=800, # Max image size for primary engine (default: 800)
118
- workers=4, # Parallel workers; None = cpu_count-2 (default: None)
119
- resume_enabled=True, # Resume interrupted batch jobs (default: True)
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) OrientationResult`
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` returns `PIL.Image.Image`
156
- - `return_metadata=True` returns `CorrectionResult`
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) list[CorrectionResult]`
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) BatchSummary`
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 ├─► grouped as "contrato" majority voting applied
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 ┘─► grouped as "edital"
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/docorient.git
237
- cd docorient
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,ocr]"
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 (90°/270°)
22
- │ └── secondary.py SecondaryEngine (180°)
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 list[DetectionEngine]
39
+ Iterates through [PrimaryEngine, FlipClassifierEngine]
38
40
 
39
41
 
40
42
  PrimaryEngine.detect()
41
- Analyzes pixel density distribution to determine text alignment.
43
+ Analyzes pixel energy distribution to classify axis:
44
+ - Horizontal text (0° or 180°)
45
+ - Vertical text (90° or 270°)
42
46
 
43
- ├── angle ∈ {90, 270} ──► Return result immediately
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
- └── angle = 0 (aligned) ──► SecondaryEngine.detect()
46
-
47
- is_available()?
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
- └── Detects 0°, 90°, 270° via energy analysis
68
+ ├── Computes horizontal/vertical energy via pixel projection
69
+ └── Classifies text axis with ~97% accuracy
74
70
 
75
- SecondaryEngine implements DetectionEngine
76
- ├── Returns None when unavailable or low confidence
77
- └── Detects 180° via optional OCR dependency
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(), SecondaryEngine()])
81
- └── Executes engines in sequence with short-circuit logic
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/docorient.git
7
- cd docorient
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,ocr]"
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.3.2"
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", "ocr", "image", "correction"]
13
+ keywords = ["document", "orientation", "rotation", "image", "correction", "onnx"]
14
14
  classifiers = [
15
- "Development Status :: 3 - Alpha",
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.secondary import is_secondary_engine_available
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: {input_path}")
98
- print(f" Files: {total_files} source documents")
99
- print(f" Pages: {total_pages} images")
100
- print(f" Workers: {config.effective_workers}")
101
- print(f" Secondary engine: {'enabled' if is_secondary_engine_available() else 'disabled'}")
102
- print(f" Quality: {config.output_quality}")
103
- print(f" Resume: {'enabled' if config.resume_enabled else 'disabled'}")
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: {input_path}")
158
- print(f" Output: {arguments.output_dir or 'auto (UUID)'}")
159
- print(f" Workers: {config.effective_workers}")
160
- print(f" Secondary engine: {'disabled' if arguments.no_secondary else 'enabled'}")
161
- print(f" Quality: {config.output_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
- secondary_confidence_threshold: float = 2.0
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
- "is_secondary_engine_available",
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()
@@ -12,7 +12,3 @@ class CorrectionError(DocorientError):
12
12
 
13
13
  class BatchProcessingError(DocorientError):
14
14
  pass
15
-
16
-
17
- class TesseractNotAvailableError(DocorientError):
18
- pass
@@ -31,21 +31,18 @@ class TestPrimaryEngine:
31
31
  assert isinstance(result, OrientationResult)
32
32
 
33
33
 
34
- class TestDetectionEngine:
35
- def test_aligned_image_detection(self, horizontal_text_image):
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 == 0
37
+ assert result.angle in (0, 180)
38
38
  assert result.reliable is True
39
39
 
40
- def test_rotated_image_detection(self, vertical_text_image):
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
 
@@ -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