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.
@@ -0,0 +1,14 @@
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+
9
+ # Virtual environments
10
+ .venv
11
+
12
+ # Sample images used only by the parity harness
13
+ testdata/
14
+ uv.lock
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).
@@ -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)