mrzmini 0.1.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.
- mrzmini-0.1.0/.gitignore +14 -0
- mrzmini-0.1.0/LICENSE +30 -0
- mrzmini-0.1.0/PKG-INFO +137 -0
- mrzmini-0.1.0/README.md +112 -0
- mrzmini-0.1.0/mrzmini/__init__.py +17 -0
- mrzmini-0.1.0/mrzmini/__main__.py +28 -0
- mrzmini-0.1.0/mrzmini/geometry.py +241 -0
- mrzmini-0.1.0/mrzmini/image.py +354 -0
- mrzmini-0.1.0/mrzmini/imageproc.py +688 -0
- mrzmini-0.1.0/mrzmini/ocr.py +68 -0
- mrzmini-0.1.0/mrzmini/pdf.py +32 -0
- mrzmini-0.1.0/mrzmini/pipeline.py +90 -0
- mrzmini-0.1.0/mrzmini/text.py +443 -0
- mrzmini-0.1.0/pyproject.toml +58 -0
mrzmini-0.1.0/.gitignore
ADDED
mrzmini-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Andrianina Rabakoson
|
|
4
|
+
|
|
5
|
+
Portions of this software (the MRZ text parser in ``mrzmini/text.py`` and the
|
|
6
|
+
pipeline engine in ``mrzmini/pipeline.py``) are copied verbatim from PassportEye
|
|
7
|
+
and are:
|
|
8
|
+
|
|
9
|
+
Copyright (c) 2016 Konstantin Tretyakov
|
|
10
|
+
|
|
11
|
+
PassportEye is distributed under the MIT License. Its original copyright notice
|
|
12
|
+
is preserved above and in the docstrings of the affected modules.
|
|
13
|
+
|
|
14
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
15
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
16
|
+
in the Software without restriction, including without limitation the rights
|
|
17
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
18
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
19
|
+
furnished to do so, subject to the following conditions:
|
|
20
|
+
|
|
21
|
+
The above copyright notice and this permission notice shall be included in all
|
|
22
|
+
copies or substantial portions of the Software.
|
|
23
|
+
|
|
24
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
25
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
26
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
27
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
28
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
29
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
30
|
+
SOFTWARE.
|
mrzmini-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mrzmini
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Minimal-footprint reimplementation of PassportEye's MRZ detection/reading (numpy + pillow only)
|
|
5
|
+
Project-URL: Homepage, https://github.com/rbaks/mrzmini
|
|
6
|
+
Project-URL: Repository, https://github.com/rbaks/mrzmini
|
|
7
|
+
Project-URL: Issues, https://github.com/rbaks/mrzmini/issues
|
|
8
|
+
Author-email: Andrianina Rabakoson <andri.bakoson@gmail.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: id-document,mrz,ocr,passport,passporteye,tesseract
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Scientific/Engineering :: Image Recognition
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
21
|
+
Requires-Python: >=3.12
|
|
22
|
+
Requires-Dist: numpy>=1.24
|
|
23
|
+
Requires-Dist: pillow>=9
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# mrzmini
|
|
27
|
+
|
|
28
|
+
A minimal-footprint, drop-in reimplementation of [PassportEye](https://github.com/konstantint/PassportEye)'s
|
|
29
|
+
MRZ (machine-readable zone) detection and reading pipeline.
|
|
30
|
+
|
|
31
|
+
PassportEye does one small, precise job — find and read the MRZ on an ID document —
|
|
32
|
+
but drags in a heavy dependency stack to do it: `numpy`, `scipy`, `scikit-image`,
|
|
33
|
+
`scikit-learn`, `matplotlib`, `imageio`, `pdfminer`, `pytesseract`.
|
|
34
|
+
|
|
35
|
+
`mrzmini` reproduces the **exact same algorithm and output** using only:
|
|
36
|
+
|
|
37
|
+
- **numpy**
|
|
38
|
+
- **Pillow**
|
|
39
|
+
- the external **`tesseract`** binary (called via `subprocess`; required only for OCR)
|
|
40
|
+
|
|
41
|
+
Everything `skimage` / `sklearn` / `scipy` / `imageio` / `pdfminer` / `pytesseract`
|
|
42
|
+
did is reimplemented on numpy in [`mrzmini/imageproc.py`](mrzmini/imageproc.py),
|
|
43
|
+
[`mrzmini/geometry.py`](mrzmini/geometry.py), [`mrzmini/ocr.py`](mrzmini/ocr.py)
|
|
44
|
+
and [`mrzmini/pdf.py`](mrzmini/pdf.py). The MRZ text parser
|
|
45
|
+
([`text.py`](mrzmini/text.py)) and the pipeline engine ([`pipeline.py`](mrzmini/pipeline.py))
|
|
46
|
+
are pure-Python and copied verbatim from upstream (MIT).
|
|
47
|
+
|
|
48
|
+
## Install
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
pip install mrzmini
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
`mrzmini` needs the external **`tesseract`** OCR binary on `PATH` for the OCR step
|
|
55
|
+
(detection alone does not need it):
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
apt install tesseract-ocr # Debian/Ubuntu
|
|
59
|
+
brew install tesseract # macOS
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Override the binary location with the `TESSERACT_CMD` environment variable.
|
|
63
|
+
|
|
64
|
+
## Usage
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
from mrzmini import read_mrz
|
|
68
|
+
|
|
69
|
+
mrz = read_mrz('passport.jpg') # path, bytes, file-like, or a .pdf
|
|
70
|
+
print(mrz) # None if nothing was found
|
|
71
|
+
print(mrz.to_dict())
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
The public surface mirrors PassportEye: `read_mrz(file, save_roi=False,
|
|
75
|
+
extra_cmdline_params='')` returns an `MRZ` object with the same fields
|
|
76
|
+
(`mrz_type`, `valid`, `valid_score`, `number`, `names`, `surname`,
|
|
77
|
+
`date_of_birth`, `expiration_date`, `nationality`, `sex`, check digits, …).
|
|
78
|
+
|
|
79
|
+
Command-line demo:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
mrzmini passport.jpg # installed console script
|
|
83
|
+
python -m mrzmini passport.jpg # or via the module
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Requirements
|
|
87
|
+
|
|
88
|
+
- Python ≥ 3.12, `numpy`, `pillow` (installed via `uv sync`).
|
|
89
|
+
- The `tesseract` OCR binary on `PATH` (e.g. `apt install tesseract-ocr`).
|
|
90
|
+
Override its location with the `TESSERACT_CMD` environment variable.
|
|
91
|
+
|
|
92
|
+
## How it works
|
|
93
|
+
|
|
94
|
+
The pipeline is identical to PassportEye's (a lazy DAG of components):
|
|
95
|
+
|
|
96
|
+
| Step | What it does | Upstream dependency replaced |
|
|
97
|
+
|------|--------------|------------------------------|
|
|
98
|
+
| `Loader` | read image → grayscale (color → float64 luma, gray → uint8) | `skimage.io` / `imageio`; `pdfminer` for PDFs |
|
|
99
|
+
| `Scaler` | downscale so width ≤ 250 (anti-aliased) | `skimage.transform.rescale` (→ scipy) |
|
|
100
|
+
| `BooneTransform` | `threshold_otsu(closing(\|sobel_v(black_tophat(img))\|))` → binary | `skimage.morphology` + `skimage.filters` |
|
|
101
|
+
| `MRZBoxLocator` | marching-squares contours → `RotatedBox` (PCA) → merge parallel boxes | `skimage.measure` + `sklearn.PCA` |
|
|
102
|
+
| `extract_from_image` | un-rotate + crop the ROI | `skimage.transform.rotate` |
|
|
103
|
+
| `ocr` | run Tesseract on the ROI | `pytesseract` |
|
|
104
|
+
| `MRZ.from_ocr` | clean up + parse + checksum-validate | (pure Python) |
|
|
105
|
+
|
|
106
|
+
## Parity with PassportEye
|
|
107
|
+
|
|
108
|
+
[`parity_check.py`](parity_check.py) compares `mrzmini` against the real
|
|
109
|
+
PassportEye stage by stage. Across the **entire PassportEye test corpus
|
|
110
|
+
(36 images: TD1/TD2/TD3/MRVA/MRVB, scores 0–100, JPG/PNG/PDF)**:
|
|
111
|
+
|
|
112
|
+
- `img_binary`: **0 pixel disagreements** on every image
|
|
113
|
+
- detected MRZ **boxes: identical** on every image
|
|
114
|
+
- `read_mrz(...).to_dict()`: **identical** on every image
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
uv run --group parity python parity_check.py # scans testdata/
|
|
118
|
+
uv run --group parity python parity_check.py some.jpg # specific files
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Internally every reimplemented primitive matches its scikit-image / scipy /
|
|
122
|
+
scikit-learn counterpart to floating-point precision (bilinear resize and
|
|
123
|
+
marching-squares contours are bit-exact; the bicubic OCR-retry resize matches
|
|
124
|
+
scipy to ~1e-14, and the PCA box geometry to ~1e-12).
|
|
125
|
+
|
|
126
|
+
## Notes / limitations
|
|
127
|
+
|
|
128
|
+
- PDF support is best-effort: like upstream it extracts the first embedded JFIF
|
|
129
|
+
JPEG (`\xff\xd8\xff\xe0`); other embedded image encodings are not handled.
|
|
130
|
+
- Tesseract is an external binary, not a Python library, so it remains a
|
|
131
|
+
requirement for the OCR step (detection alone does not need it).
|
|
132
|
+
|
|
133
|
+
## License
|
|
134
|
+
|
|
135
|
+
The reused PassportEye algorithm and copied modules are MIT
|
|
136
|
+
(© Konstantin Tretyakov). Reimplemented primitives follow scikit-image /
|
|
137
|
+
scipy behavior (BSD).
|
mrzmini-0.1.0/README.md
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# mrzmini
|
|
2
|
+
|
|
3
|
+
A minimal-footprint, drop-in reimplementation of [PassportEye](https://github.com/konstantint/PassportEye)'s
|
|
4
|
+
MRZ (machine-readable zone) detection and reading pipeline.
|
|
5
|
+
|
|
6
|
+
PassportEye does one small, precise job — find and read the MRZ on an ID document —
|
|
7
|
+
but drags in a heavy dependency stack to do it: `numpy`, `scipy`, `scikit-image`,
|
|
8
|
+
`scikit-learn`, `matplotlib`, `imageio`, `pdfminer`, `pytesseract`.
|
|
9
|
+
|
|
10
|
+
`mrzmini` reproduces the **exact same algorithm and output** using only:
|
|
11
|
+
|
|
12
|
+
- **numpy**
|
|
13
|
+
- **Pillow**
|
|
14
|
+
- the external **`tesseract`** binary (called via `subprocess`; required only for OCR)
|
|
15
|
+
|
|
16
|
+
Everything `skimage` / `sklearn` / `scipy` / `imageio` / `pdfminer` / `pytesseract`
|
|
17
|
+
did is reimplemented on numpy in [`mrzmini/imageproc.py`](mrzmini/imageproc.py),
|
|
18
|
+
[`mrzmini/geometry.py`](mrzmini/geometry.py), [`mrzmini/ocr.py`](mrzmini/ocr.py)
|
|
19
|
+
and [`mrzmini/pdf.py`](mrzmini/pdf.py). The MRZ text parser
|
|
20
|
+
([`text.py`](mrzmini/text.py)) and the pipeline engine ([`pipeline.py`](mrzmini/pipeline.py))
|
|
21
|
+
are pure-Python and copied verbatim from upstream (MIT).
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install mrzmini
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
`mrzmini` needs the external **`tesseract`** OCR binary on `PATH` for the OCR step
|
|
30
|
+
(detection alone does not need it):
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
apt install tesseract-ocr # Debian/Ubuntu
|
|
34
|
+
brew install tesseract # macOS
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Override the binary location with the `TESSERACT_CMD` environment variable.
|
|
38
|
+
|
|
39
|
+
## Usage
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from mrzmini import read_mrz
|
|
43
|
+
|
|
44
|
+
mrz = read_mrz('passport.jpg') # path, bytes, file-like, or a .pdf
|
|
45
|
+
print(mrz) # None if nothing was found
|
|
46
|
+
print(mrz.to_dict())
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
The public surface mirrors PassportEye: `read_mrz(file, save_roi=False,
|
|
50
|
+
extra_cmdline_params='')` returns an `MRZ` object with the same fields
|
|
51
|
+
(`mrz_type`, `valid`, `valid_score`, `number`, `names`, `surname`,
|
|
52
|
+
`date_of_birth`, `expiration_date`, `nationality`, `sex`, check digits, …).
|
|
53
|
+
|
|
54
|
+
Command-line demo:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
mrzmini passport.jpg # installed console script
|
|
58
|
+
python -m mrzmini passport.jpg # or via the module
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Requirements
|
|
62
|
+
|
|
63
|
+
- Python ≥ 3.12, `numpy`, `pillow` (installed via `uv sync`).
|
|
64
|
+
- The `tesseract` OCR binary on `PATH` (e.g. `apt install tesseract-ocr`).
|
|
65
|
+
Override its location with the `TESSERACT_CMD` environment variable.
|
|
66
|
+
|
|
67
|
+
## How it works
|
|
68
|
+
|
|
69
|
+
The pipeline is identical to PassportEye's (a lazy DAG of components):
|
|
70
|
+
|
|
71
|
+
| Step | What it does | Upstream dependency replaced |
|
|
72
|
+
|------|--------------|------------------------------|
|
|
73
|
+
| `Loader` | read image → grayscale (color → float64 luma, gray → uint8) | `skimage.io` / `imageio`; `pdfminer` for PDFs |
|
|
74
|
+
| `Scaler` | downscale so width ≤ 250 (anti-aliased) | `skimage.transform.rescale` (→ scipy) |
|
|
75
|
+
| `BooneTransform` | `threshold_otsu(closing(\|sobel_v(black_tophat(img))\|))` → binary | `skimage.morphology` + `skimage.filters` |
|
|
76
|
+
| `MRZBoxLocator` | marching-squares contours → `RotatedBox` (PCA) → merge parallel boxes | `skimage.measure` + `sklearn.PCA` |
|
|
77
|
+
| `extract_from_image` | un-rotate + crop the ROI | `skimage.transform.rotate` |
|
|
78
|
+
| `ocr` | run Tesseract on the ROI | `pytesseract` |
|
|
79
|
+
| `MRZ.from_ocr` | clean up + parse + checksum-validate | (pure Python) |
|
|
80
|
+
|
|
81
|
+
## Parity with PassportEye
|
|
82
|
+
|
|
83
|
+
[`parity_check.py`](parity_check.py) compares `mrzmini` against the real
|
|
84
|
+
PassportEye stage by stage. Across the **entire PassportEye test corpus
|
|
85
|
+
(36 images: TD1/TD2/TD3/MRVA/MRVB, scores 0–100, JPG/PNG/PDF)**:
|
|
86
|
+
|
|
87
|
+
- `img_binary`: **0 pixel disagreements** on every image
|
|
88
|
+
- detected MRZ **boxes: identical** on every image
|
|
89
|
+
- `read_mrz(...).to_dict()`: **identical** on every image
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
uv run --group parity python parity_check.py # scans testdata/
|
|
93
|
+
uv run --group parity python parity_check.py some.jpg # specific files
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Internally every reimplemented primitive matches its scikit-image / scipy /
|
|
97
|
+
scikit-learn counterpart to floating-point precision (bilinear resize and
|
|
98
|
+
marching-squares contours are bit-exact; the bicubic OCR-retry resize matches
|
|
99
|
+
scipy to ~1e-14, and the PCA box geometry to ~1e-12).
|
|
100
|
+
|
|
101
|
+
## Notes / limitations
|
|
102
|
+
|
|
103
|
+
- PDF support is best-effort: like upstream it extracts the first embedded JFIF
|
|
104
|
+
JPEG (`\xff\xd8\xff\xe0`); other embedded image encodings are not handled.
|
|
105
|
+
- Tesseract is an external binary, not a Python library, so it remains a
|
|
106
|
+
requirement for the OCR step (detection alone does not need it).
|
|
107
|
+
|
|
108
|
+
## License
|
|
109
|
+
|
|
110
|
+
The reused PassportEye algorithm and copied modules are MIT
|
|
111
|
+
(© Konstantin Tretyakov). Reimplemented primitives follow scikit-image /
|
|
112
|
+
scipy behavior (BSD).
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
'''
|
|
2
|
+
mrzmini: a minimal-footprint reimplementation of PassportEye's MRZ
|
|
3
|
+
detection and reading pipeline.
|
|
4
|
+
|
|
5
|
+
Only depends on numpy + Pillow (plus the external ``tesseract`` binary for OCR).
|
|
6
|
+
The public interface mirrors PassportEye:
|
|
7
|
+
|
|
8
|
+
from mrzmini import read_mrz
|
|
9
|
+
mrz = read_mrz('passport.jpg')
|
|
10
|
+
print(mrz.to_dict())
|
|
11
|
+
|
|
12
|
+
Original algorithm and code: Konstantin Tretyakov, PassportEye (MIT License).
|
|
13
|
+
'''
|
|
14
|
+
from .text import MRZ
|
|
15
|
+
from .image import read_mrz, MRZPipeline
|
|
16
|
+
|
|
17
|
+
__all__ = ['read_mrz', 'MRZ', 'MRZPipeline']
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Command-line entry point for mrzmini.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
mrzmini <image-or-pdf> [...]
|
|
5
|
+
python -m mrzmini <image-or-pdf> [...]
|
|
6
|
+
"""
|
|
7
|
+
import sys
|
|
8
|
+
import json
|
|
9
|
+
|
|
10
|
+
from mrzmini import read_mrz
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def main():
|
|
14
|
+
if len(sys.argv) < 2:
|
|
15
|
+
print(__doc__)
|
|
16
|
+
return
|
|
17
|
+
for path in sys.argv[1:]:
|
|
18
|
+
print("=" * 60)
|
|
19
|
+
print(path)
|
|
20
|
+
mrz = read_mrz(path)
|
|
21
|
+
if mrz is None:
|
|
22
|
+
print(" No MRZ found.")
|
|
23
|
+
continue
|
|
24
|
+
print(json.dumps(mrz.to_dict(), indent=2, ensure_ascii=False))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
if __name__ == "__main__":
|
|
28
|
+
main()
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
'''
|
|
2
|
+
mrzmini.geometry: RotatedBox geometry, adapted from PassportEye's
|
|
3
|
+
``passporteye/util/geometry.py``.
|
|
4
|
+
|
|
5
|
+
Changes vs upstream (to drop heavy dependencies), behaviour preserved:
|
|
6
|
+
* ``sklearn.decomposition.PCA`` -> a small numpy SVD (see :func:`_pca`).
|
|
7
|
+
The resulting box (center/width/height/angle) is invariant to the sign of
|
|
8
|
+
the principal components, so sklearn's ``svd_flip`` convention need not be
|
|
9
|
+
reproduced.
|
|
10
|
+
* ``skimage.transform`` (``rotate`` / ``SimilarityTransform``) ->
|
|
11
|
+
:mod:`mrzmini.imageproc`.
|
|
12
|
+
* The matplotlib ``plot`` method is removed.
|
|
13
|
+
|
|
14
|
+
Author of original: Konstantin Tretyakov. License: MIT.
|
|
15
|
+
'''
|
|
16
|
+
import numpy as np
|
|
17
|
+
|
|
18
|
+
from .imageproc import rotate as _rotate, SimilarityTransform
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _pca(points):
|
|
22
|
+
"""Return ``(mean, components)`` matching ``sklearn.decomposition.PCA(2).fit``.
|
|
23
|
+
|
|
24
|
+
``components`` rows are the principal axes ordered by descending variance
|
|
25
|
+
(row 0 = longest axis). ``transform(x) = (x - mean) @ components.T``.
|
|
26
|
+
"""
|
|
27
|
+
mean = points.mean(axis=0)
|
|
28
|
+
centered = points - mean
|
|
29
|
+
# full_matrices=False -> Vt has shape (min(n,2), 2); singular values descending.
|
|
30
|
+
_u, _s, vt = np.linalg.svd(centered, full_matrices=False)
|
|
31
|
+
components = vt
|
|
32
|
+
if components.shape[0] < 2:
|
|
33
|
+
# Degenerate (all points collinear with <2 dims of variance): complete
|
|
34
|
+
# the basis with an orthogonal vector so downstream maths is well-defined.
|
|
35
|
+
v0 = components[0]
|
|
36
|
+
components = np.vstack([v0, [-v0[1], v0[0]]])
|
|
37
|
+
return mean, components
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class RotatedBox(object):
|
|
41
|
+
"""
|
|
42
|
+
RotatedBox represents a rectangular box centered at (cx,cy) with dimensions width x height,
|
|
43
|
+
rotated by angle radians counterclockwise.
|
|
44
|
+
|
|
45
|
+
>>> RotatedBox.from_points([[0,0], [2,1], [0,1], [2,0]])
|
|
46
|
+
RotatedBox(cx=1.0, cy=0.5, width=2.0, height=1.0, angle=0.0)
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(self, center, width, height, angle, points=None):
|
|
50
|
+
"""Creates a new RotatedBox.
|
|
51
|
+
|
|
52
|
+
:param points: This parameter may be used to indicate the set of points used to create the box.
|
|
53
|
+
"""
|
|
54
|
+
self.center = np.asarray(center, dtype=np.float64)
|
|
55
|
+
self.width = width
|
|
56
|
+
self.height = height
|
|
57
|
+
self.angle = angle
|
|
58
|
+
self.points = points
|
|
59
|
+
|
|
60
|
+
def __repr__(self):
|
|
61
|
+
return "RotatedBox(cx={0}, cy={1}, width={2}, height={3}, angle={4})".format(self.cx, self.cy, self.width, self.height, self.angle)
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def cx(self):
|
|
65
|
+
return self.center[0]
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def cy(self):
|
|
69
|
+
return self.center[1]
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def area(self):
|
|
73
|
+
return self.width * self.height
|
|
74
|
+
|
|
75
|
+
def approx_equal(self, center, width, height, angle, tol=1e-6):
|
|
76
|
+
"Method mainly useful for testing"
|
|
77
|
+
return abs(self.cx - center[0]) < tol and abs(self.cy - center[1]) < tol and abs(self.width - width) < tol and \
|
|
78
|
+
abs(self.height - height) < tol and abs(self.angle - angle) < tol
|
|
79
|
+
|
|
80
|
+
def rotated(self, rotation_center, angle):
|
|
81
|
+
"""Returns a RotatedBox that is obtained by rotating this box around a given center by a given angle.
|
|
82
|
+
|
|
83
|
+
>>> assert RotatedBox([2, 2], 2, 1, 0.1).rotated([1, 1], np.pi/2).approx_equal([0, 2], 2, 1, np.pi/2+0.1)
|
|
84
|
+
"""
|
|
85
|
+
rot = np.array([[np.cos(angle), np.sin(angle)], [-np.sin(angle), np.cos(angle)]])
|
|
86
|
+
t = np.asarray(rotation_center, dtype=np.float64)
|
|
87
|
+
new_c = np.dot(rot.T, (self.center - t)) + t
|
|
88
|
+
return RotatedBox(new_c, self.width, self.height, (self.angle+angle) % (np.pi*2))
|
|
89
|
+
|
|
90
|
+
def as_poly(self, margin_width=0, margin_height=0):
|
|
91
|
+
"""Converts this box to a polygon, i.e. 4x2 array, representing the four corners starting from lower left to upper left counterclockwise.
|
|
92
|
+
|
|
93
|
+
:param margin_width: The additional "margin" that will be added to the box along its width dimension (from both sides) before conversion.
|
|
94
|
+
:param margin_height: The additional "margin" that will be added to the box along its height dimension (from both sides) before conversion.
|
|
95
|
+
|
|
96
|
+
>>> RotatedBox([0, 0], 4, 2, 0).as_poly()
|
|
97
|
+
array([[-2., -1.],
|
|
98
|
+
[ 2., -1.],
|
|
99
|
+
[ 2., 1.],
|
|
100
|
+
[-2., 1.]])
|
|
101
|
+
>>> RotatedBox([0, 0], 4, 2, np.pi/2).as_poly()
|
|
102
|
+
array([[ 1., -2.],
|
|
103
|
+
[ 1., 2.],
|
|
104
|
+
[-1., 2.],
|
|
105
|
+
[-1., -2.]])
|
|
106
|
+
>>> RotatedBox([0, 0], 0, 0, np.pi/2).as_poly(2, 1)
|
|
107
|
+
array([[ 1., -2.],
|
|
108
|
+
[ 1., 2.],
|
|
109
|
+
[-1., 2.],
|
|
110
|
+
[-1., -2.]])
|
|
111
|
+
"""
|
|
112
|
+
v_hor = (self.width/2 + margin_width)*np.array([np.cos(self.angle), np.sin(self.angle)])
|
|
113
|
+
v_vert = (self.height/2 + margin_height)*np.array([-np.sin(self.angle), np.cos(self.angle)])
|
|
114
|
+
c = np.array([self.cx, self.cy])
|
|
115
|
+
return np.vstack([c - v_hor - v_vert, c + v_hor - v_vert, c + v_hor + v_vert, c - v_hor + v_vert])
|
|
116
|
+
|
|
117
|
+
def extract_from_image(self, img, scale=1.0, margin_width=5, margin_height=5):
|
|
118
|
+
"""Extracts the contents of this box from a given image.
|
|
119
|
+
For that the image is "unrotated" by the appropriate angle, and the corresponding part is extracted from it.
|
|
120
|
+
|
|
121
|
+
Returns an image with dimensions height*scale x width*scale.
|
|
122
|
+
Note that the box coordinates are interpreted as "image coordinates" (i.e. x is row and y is column),
|
|
123
|
+
and box angle is considered to be relative to the vertical (i.e. np.pi/2 is "normal orientation")
|
|
124
|
+
|
|
125
|
+
:param img: a numpy ndarray suitable for image processing.
|
|
126
|
+
:param scale: the RotatedBox is scaled by this value before performing the extraction.
|
|
127
|
+
This is necessary when, for example, the location of a particular feature is determined using a smaller image,
|
|
128
|
+
yet then the corresponding area needs to be extracted from the original, larger image.
|
|
129
|
+
The scale parameter in this case should be width_of_larger_image/width_of_smaller_image.
|
|
130
|
+
:param margin_width: The margin that should be added to the width dimension of the box from each size.
|
|
131
|
+
This value is given wrt actual box dimensions (i.e. not scaled).
|
|
132
|
+
:param margin_height: The margin that should be added to the height dimension of the box from each side.
|
|
133
|
+
:return: a numpy ndarray, corresponding to the extracted region (aligned straight).
|
|
134
|
+
"""
|
|
135
|
+
rotate_by = (np.pi/2 - self.angle)*180/np.pi
|
|
136
|
+
img_rotated = _rotate(img, angle=rotate_by, center=[self.center[1]*scale, self.center[0]*scale], resize=True)
|
|
137
|
+
# The resizeable transform will shift the resulting image somewhat wrt original coordinates.
|
|
138
|
+
# When we cut out the box we will compensate for this shift.
|
|
139
|
+
shift_c, shift_r = self._compensate_rotation_shift(img, scale)
|
|
140
|
+
|
|
141
|
+
r1 = max(int((self.center[0] - self.height/2 - margin_height)*scale - shift_r), 0)
|
|
142
|
+
r2 = int((self.center[0] + self.height/2 + margin_height)*scale - shift_r)
|
|
143
|
+
c1 = max(int((self.center[1] - self.width/2 - margin_width)*scale - shift_c), 0)
|
|
144
|
+
c2 = int((self.center[1] + self.width/2 + margin_width)*scale - shift_c)
|
|
145
|
+
return img_rotated[r1:r2, c1:c2]
|
|
146
|
+
|
|
147
|
+
def _compensate_rotation_shift(self, img, scale):
|
|
148
|
+
"""This is an auxiliary method used by extract_from_image.
|
|
149
|
+
It repeats the shift computation performed by skimage's resize-enabled rotate,
|
|
150
|
+
so that the box can be cut out at the right offset.
|
|
151
|
+
"""
|
|
152
|
+
ctr = np.asarray([self.center[1]*scale, self.center[0]*scale])
|
|
153
|
+
tform1 = SimilarityTransform(translation=ctr)
|
|
154
|
+
tform2 = SimilarityTransform(rotation=np.pi/2 - self.angle)
|
|
155
|
+
tform3 = SimilarityTransform(translation=-ctr)
|
|
156
|
+
tform = tform3 + tform2 + tform1
|
|
157
|
+
|
|
158
|
+
rows, cols = img.shape[0], img.shape[1]
|
|
159
|
+
corners = np.array([
|
|
160
|
+
[0, 0],
|
|
161
|
+
[0, rows - 1],
|
|
162
|
+
[cols - 1, rows - 1],
|
|
163
|
+
[cols - 1, 0]
|
|
164
|
+
])
|
|
165
|
+
corners = tform.inverse(corners)
|
|
166
|
+
minc = corners[:, 0].min()
|
|
167
|
+
minr = corners[:, 1].min()
|
|
168
|
+
maxc = corners[:, 0].max()
|
|
169
|
+
maxr = corners[:, 1].max()
|
|
170
|
+
|
|
171
|
+
# fit output image in new shape
|
|
172
|
+
translation = (minc, minr)
|
|
173
|
+
tform4 = SimilarityTransform(translation=translation)
|
|
174
|
+
tform = tform4 + tform
|
|
175
|
+
tform.params[2] = (0, 0, 1)
|
|
176
|
+
|
|
177
|
+
# Compute the shift of the transformed center wrt original
|
|
178
|
+
return (ctr - tform.inverse(ctr)).ravel().tolist()
|
|
179
|
+
|
|
180
|
+
@staticmethod
|
|
181
|
+
def from_points(points, box_type='bb'):
|
|
182
|
+
"""
|
|
183
|
+
Interpret a given point cloud as a RotatedBox, using PCA to determine the potential orientation (the longest component becomes width)
|
|
184
|
+
This is basically an approximate version of a min-area-rectangle algorithm.
|
|
185
|
+
|
|
186
|
+
:param points: An n x 2 numpy array of coordinates.
|
|
187
|
+
:param box_type: The kind of method used to estimate the "box".
|
|
188
|
+
Possible values:
|
|
189
|
+
- `'bb'`, denoting the "bounding box" approach (min/max coordinates of the points correspond to box limits)
|
|
190
|
+
- `'mrz`, denoting a slightly modified technique, suited for MRZ zone detection from contour images.
|
|
191
|
+
Here the assumption is that the upper and lower bounds of the box are better estimated as the
|
|
192
|
+
10% and 90% quantile of the corresponding coordinates (rather than 0% and 100%, i.e. min and max).
|
|
193
|
+
This helps against accidental noise in the contour.
|
|
194
|
+
The `'mrz'` correction is only applied when there are at least 10 points in the set.
|
|
195
|
+
:returns: a RotatedBox, bounding the given set of points, oriented according to the principal components.
|
|
196
|
+
|
|
197
|
+
>>> RotatedBox.from_points([[0,0]])
|
|
198
|
+
RotatedBox(cx=0.0, cy=0.0, width=0.0, height=0.0, angle=0.0)
|
|
199
|
+
>>> assert RotatedBox.from_points([[0,0], [1,1], [2,2]]).approx_equal([1, 1], np.sqrt(8), 0, np.pi/4)
|
|
200
|
+
>>> assert RotatedBox.from_points([[0,0], [1,1], [0,1], [1,0]]).approx_equal([0.5, 0.5], 1, 1, 0.0) # The angle is rather arbitrary here
|
|
201
|
+
>>> assert RotatedBox.from_points([[0,0], [2,1], [0,1], [2,0]]).approx_equal([1, 0.5], 2, 1, 0)
|
|
202
|
+
>>> assert RotatedBox.from_points([[0,0], [2,4], [0,4], [2,0]]).approx_equal([1, 2], 4, 2, np.pi/2)
|
|
203
|
+
>>> assert RotatedBox.from_points([[0,0], [1,1.5], [2,0]]).approx_equal([1, 0.75], 2, 1.5, 0)
|
|
204
|
+
>>> assert RotatedBox.from_points([[0,0], [0,1], [1,1]]).approx_equal([0.25, 0.75], np.sqrt(2), np.sqrt(2)/2, np.pi/4)
|
|
205
|
+
"""
|
|
206
|
+
points = np.asarray(points, dtype=np.float64)
|
|
207
|
+
if points.shape[0] == 1:
|
|
208
|
+
return RotatedBox(points[0], width=0.0, height=0.0, angle=0.0, points=points)
|
|
209
|
+
|
|
210
|
+
mean, components = _pca(points)
|
|
211
|
+
# Find the angle
|
|
212
|
+
angle = (np.arctan2(components[0, 1], components[0, 0]) % np.pi)
|
|
213
|
+
if abs(angle - np.pi) < angle:
|
|
214
|
+
# Here the angle is always between -pi and pi
|
|
215
|
+
# If the principal component happened to be oriented so that the angle happens to be > pi/2 by absolute value,
|
|
216
|
+
# we flip the direction
|
|
217
|
+
angle = angle - np.pi if angle > 0 else angle + np.pi
|
|
218
|
+
points_transformed = (points - mean) @ components.T
|
|
219
|
+
ll = np.min(points_transformed, 0)
|
|
220
|
+
ur = np.max(points_transformed, 0)
|
|
221
|
+
wh = ur - ll
|
|
222
|
+
|
|
223
|
+
# Now compute and return the bounding box
|
|
224
|
+
if box_type == 'bb' or (box_type == 'mrz' and points.shape[0] < 10):
|
|
225
|
+
# We know that if we rotate the points around mean, we get a box with bounds ur and ll
|
|
226
|
+
# The center of this box is (ur+ll)/2 + mean, which is not the same as the mean,
|
|
227
|
+
# hence to get the center of the original box we need to "unrotate" this box back.
|
|
228
|
+
return RotatedBox(np.dot(components.T, (ll+ur)/2) + mean, width=wh[0], height=wh[1], angle=angle, points=points)
|
|
229
|
+
elif box_type == 'mrz':
|
|
230
|
+
# When working with MRZ detection from contours, we may have minor "bumps" in the contour,
|
|
231
|
+
# that should be ignored at least along the long ("horizontal") side.
|
|
232
|
+
# To do that, we will use 10% and 90% quantiles as the bounds of the box instead of the max and min.
|
|
233
|
+
h_coord = sorted(points_transformed[:, 1])
|
|
234
|
+
n = len(h_coord)
|
|
235
|
+
bottom, top = h_coord[n // 10], h_coord[n * 9 // 10]
|
|
236
|
+
valid_points = np.logical_and(points_transformed[:, 1] >= bottom, points_transformed[:, 1] <= top)
|
|
237
|
+
rb = RotatedBox.from_points(points[valid_points, :], 'bb')
|
|
238
|
+
rb.points = points
|
|
239
|
+
return rb
|
|
240
|
+
else:
|
|
241
|
+
raise ValueError("Unknown parameter value: box_type=%s" % box_type)
|