mrzmini 0.1.0__py3-none-any.whl
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/__init__.py +17 -0
- mrzmini/__main__.py +28 -0
- mrzmini/geometry.py +241 -0
- mrzmini/image.py +354 -0
- mrzmini/imageproc.py +688 -0
- mrzmini/ocr.py +68 -0
- mrzmini/pdf.py +32 -0
- mrzmini/pipeline.py +90 -0
- mrzmini/text.py +443 -0
- mrzmini-0.1.0.dist-info/METADATA +137 -0
- mrzmini-0.1.0.dist-info/RECORD +14 -0
- mrzmini-0.1.0.dist-info/WHEEL +4 -0
- mrzmini-0.1.0.dist-info/entry_points.txt +2 -0
- mrzmini-0.1.0.dist-info/licenses/LICENSE +30 -0
mrzmini/__init__.py
ADDED
|
@@ -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']
|
mrzmini/__main__.py
ADDED
|
@@ -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()
|
mrzmini/geometry.py
ADDED
|
@@ -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)
|
mrzmini/image.py
ADDED
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
'''
|
|
2
|
+
mrzmini.image: the MRZ extraction pipeline.
|
|
3
|
+
|
|
4
|
+
Adapted from PassportEye's ``passporteye/mrz/image.py``. The pipeline structure
|
|
5
|
+
and parameters are reproduced exactly; only the underlying image-processing
|
|
6
|
+
calls are routed through :mod:`mrzmini.imageproc` (numpy/Pillow) instead of
|
|
7
|
+
``skimage`` / ``imageio``, and PDF loading goes through :mod:`mrzmini.pdf`
|
|
8
|
+
instead of ``pdfminer``.
|
|
9
|
+
|
|
10
|
+
Author of original: Konstantin Tretyakov. License: MIT.
|
|
11
|
+
'''
|
|
12
|
+
import io
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
|
|
16
|
+
from . import imageproc as ip
|
|
17
|
+
from .pdf import extract_first_jpeg_in_pdf
|
|
18
|
+
from .pipeline import Pipeline
|
|
19
|
+
from .geometry import RotatedBox
|
|
20
|
+
from .ocr import ocr
|
|
21
|
+
from .text import MRZ
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Loader(object):
|
|
25
|
+
"""Loads `file` to `img`."""
|
|
26
|
+
|
|
27
|
+
__depends__ = []
|
|
28
|
+
__provides__ = ['img']
|
|
29
|
+
|
|
30
|
+
def __init__(self, file, as_gray=True, pdf_aware=True):
|
|
31
|
+
self.file = file
|
|
32
|
+
self.as_gray = as_gray
|
|
33
|
+
self.pdf_aware = pdf_aware
|
|
34
|
+
|
|
35
|
+
def _imread(self, file):
|
|
36
|
+
"""Proxy to mrzmini.imageproc.imread_gray (replaces skimage.io.imread)."""
|
|
37
|
+
return ip.imread_gray(file)
|
|
38
|
+
|
|
39
|
+
def __call__(self):
|
|
40
|
+
if isinstance(self.file, str):
|
|
41
|
+
if self.pdf_aware and self.file.lower().endswith('.pdf'):
|
|
42
|
+
with open(self.file, 'rb') as f:
|
|
43
|
+
img_data = extract_first_jpeg_in_pdf(f)
|
|
44
|
+
if img_data is None:
|
|
45
|
+
return None
|
|
46
|
+
return self._imread(img_data)
|
|
47
|
+
else:
|
|
48
|
+
return self._imread(self.file)
|
|
49
|
+
elif isinstance(self.file, (bytes, io.IOBase)):
|
|
50
|
+
return self._imread(self.file)
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class Scaler(object):
|
|
55
|
+
"""Scales `image` down to `img_scaled` so that its width is at most 250."""
|
|
56
|
+
|
|
57
|
+
__depends__ = ['img']
|
|
58
|
+
__provides__ = ['img_small', 'scale_factor']
|
|
59
|
+
|
|
60
|
+
def __init__(self, max_width=250):
|
|
61
|
+
self.max_width = max_width
|
|
62
|
+
|
|
63
|
+
def __call__(self, img):
|
|
64
|
+
scale_factor = self.max_width / float(img.shape[1])
|
|
65
|
+
if scale_factor <= 1:
|
|
66
|
+
img_small = ip.rescale(img, scale_factor)
|
|
67
|
+
else:
|
|
68
|
+
scale_factor = 1.0
|
|
69
|
+
img_small = img
|
|
70
|
+
return img_small, scale_factor
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class BooneTransform(object):
|
|
74
|
+
"""Processes `img_small` according to Hans Boone's method
|
|
75
|
+
(http://www.pyimagesearch.com/2015/11/30/detecting-machine-readable-zones-in-passport-images/)
|
|
76
|
+
Outputs a `img_binary` - a result of threshold_otsu(closing(sobel(black_tophat(img_small)))"""
|
|
77
|
+
|
|
78
|
+
__depends__ = ['img_small']
|
|
79
|
+
__provides__ = ['img_binary']
|
|
80
|
+
|
|
81
|
+
def __init__(self, square_size=5):
|
|
82
|
+
self.square_size = square_size
|
|
83
|
+
|
|
84
|
+
def __call__(self, img_small):
|
|
85
|
+
m = ip.square(self.square_size)
|
|
86
|
+
img_th = ip.black_tophat(img_small, m)
|
|
87
|
+
img_sob = abs(ip.sobel_v(img_th))
|
|
88
|
+
img_closed = ip.closing(img_sob, m)
|
|
89
|
+
threshold = ip.threshold_otsu(img_closed)
|
|
90
|
+
return img_closed > threshold
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class MRZBoxLocator(object):
|
|
94
|
+
"""Extracts putative MRZs as RotatedBox instances from the contours of `img_binary`"""
|
|
95
|
+
|
|
96
|
+
__depends__ = ['img_binary']
|
|
97
|
+
__provides__ = ['boxes']
|
|
98
|
+
|
|
99
|
+
def __init__(self, max_boxes=4, min_points_in_contour=50, min_area=500, min_box_aspect=5, angle_tol=0.1,
|
|
100
|
+
lineskip_tol=1.5, box_type='bb'):
|
|
101
|
+
self.max_boxes = max_boxes
|
|
102
|
+
self.min_points_in_contour = min_points_in_contour
|
|
103
|
+
self.min_area = min_area
|
|
104
|
+
self.min_box_aspect = min_box_aspect
|
|
105
|
+
self.angle_tol = angle_tol
|
|
106
|
+
self.lineskip_tol = lineskip_tol
|
|
107
|
+
self.box_type = box_type
|
|
108
|
+
|
|
109
|
+
def __call__(self, img_binary):
|
|
110
|
+
cs = ip.find_contours(img_binary, 0.5)
|
|
111
|
+
|
|
112
|
+
# Collect contours into RotatedBoxes
|
|
113
|
+
results = []
|
|
114
|
+
for c in cs:
|
|
115
|
+
# Now examine the bounding box. If it is too small, we ignore the contour
|
|
116
|
+
ll, ur = np.min(c, 0), np.max(c, 0)
|
|
117
|
+
wh = ur - ll
|
|
118
|
+
if wh[0] * wh[1] < self.min_area:
|
|
119
|
+
continue
|
|
120
|
+
|
|
121
|
+
# Finally, construct the rotatedbox. If its aspect ratio is too small, we ignore it
|
|
122
|
+
rb = RotatedBox.from_points(c, self.box_type)
|
|
123
|
+
if rb.height == 0 or rb.width / rb.height < self.min_box_aspect:
|
|
124
|
+
continue
|
|
125
|
+
|
|
126
|
+
# All tests fine, add to the list
|
|
127
|
+
results.append(rb)
|
|
128
|
+
|
|
129
|
+
# Next sort and leave only max_boxes largest boxes by area
|
|
130
|
+
results.sort(key=lambda x: -x.area)
|
|
131
|
+
return self._fixup_boxes(self._merge_boxes(results[0:self.max_boxes]))
|
|
132
|
+
|
|
133
|
+
def _are_aligned_angles(self, b1, b2):
|
|
134
|
+
"Are two boxes aligned according to their angle?"
|
|
135
|
+
return abs(b1 - b2) <= self.angle_tol or abs(np.pi - abs(b1 - b2)) <= self.angle_tol
|
|
136
|
+
|
|
137
|
+
def _are_nearby_parallel_boxes(self, b1, b2):
|
|
138
|
+
"Are two boxes nearby, parallel, and similar in width?"
|
|
139
|
+
if not self._are_aligned_angles(b1.angle, b2.angle):
|
|
140
|
+
return False
|
|
141
|
+
# Otherwise pick the smaller angle and see whether the two boxes are close according to the "up" direction wrt that angle
|
|
142
|
+
angle = min(b1.angle, b2.angle)
|
|
143
|
+
return abs(np.dot(b1.center - b2.center, [-np.sin(angle), np.cos(angle)])) < self.lineskip_tol * (
|
|
144
|
+
b1.height + b2.height) and (b1.width > 0) and (b2.width > 0) and (0.5 < b1.width / b2.width < 2.0)
|
|
145
|
+
|
|
146
|
+
def _merge_any_two_boxes(self, box_list):
|
|
147
|
+
"""Given a list of boxes, finds two nearby parallel ones and merges them. Returns false if none found."""
|
|
148
|
+
n = len(box_list)
|
|
149
|
+
for i in range(n):
|
|
150
|
+
for j in range(i + 1, n):
|
|
151
|
+
if self._are_nearby_parallel_boxes(box_list[i], box_list[j]):
|
|
152
|
+
# Remove the two boxes from the list, add a new one
|
|
153
|
+
a, b = box_list[i], box_list[j]
|
|
154
|
+
merged_points = np.vstack([a.points, b.points])
|
|
155
|
+
merged_box = RotatedBox.from_points(merged_points, self.box_type)
|
|
156
|
+
if merged_box.width / merged_box.height >= self.min_box_aspect:
|
|
157
|
+
box_list.remove(a)
|
|
158
|
+
box_list.remove(b)
|
|
159
|
+
box_list.append(merged_box)
|
|
160
|
+
return True
|
|
161
|
+
return False
|
|
162
|
+
|
|
163
|
+
def _merge_boxes(self, box_list):
|
|
164
|
+
"""Mergest nearby parallel boxes in the given list."""
|
|
165
|
+
while self._merge_any_two_boxes(box_list):
|
|
166
|
+
pass
|
|
167
|
+
return box_list
|
|
168
|
+
|
|
169
|
+
def _fixup_boxes(self, box_list):
|
|
170
|
+
"""If the box's angle is np.pi/2 +- 0.01, we shall round it to np.pi/2.
|
|
171
|
+
|
|
172
|
+
This way image extraction is fast and introduces no distortions.
|
|
173
|
+
and this may be more important than being perfectly straight
|
|
174
|
+
similar for 0 angle.
|
|
175
|
+
The box_list is fixed in-place (but also returned).
|
|
176
|
+
"""
|
|
177
|
+
for box in box_list:
|
|
178
|
+
if abs(abs(box.angle) - np.pi / 2) <= 0.01:
|
|
179
|
+
box.angle = np.pi / 2
|
|
180
|
+
if abs(box.angle) <= 0.01:
|
|
181
|
+
box.angle = 0.0
|
|
182
|
+
return box_list
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class ExtractAllBoxes(object):
|
|
186
|
+
"""Extract all the images from the boxes, for external OCR processing"""
|
|
187
|
+
|
|
188
|
+
__provides__ = ['rois']
|
|
189
|
+
__depends__ = ['boxes', 'img', 'scale_factor']
|
|
190
|
+
|
|
191
|
+
def __call__(self, boxes, img, scale_factor):
|
|
192
|
+
return [box.extract_from_image(img, 1.0 / scale_factor) for box in boxes]
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class FindFirstValidMRZ(object):
|
|
196
|
+
"""Iterates over boxes found by MRZBoxLocator, passes them to BoxToMRZ, finds the first valid MRZ
|
|
197
|
+
or the best-scoring MRZ"""
|
|
198
|
+
|
|
199
|
+
__provides__ = ['box_idx', 'roi', 'text', 'mrz']
|
|
200
|
+
__depends__ = ['boxes', 'img', 'img_small', 'scale_factor', '__data__']
|
|
201
|
+
|
|
202
|
+
def __init__(self, use_original_image=True, extra_cmdline_params=''):
|
|
203
|
+
self.box_to_mrz = BoxToMRZ(use_original_image, extra_cmdline_params=extra_cmdline_params)
|
|
204
|
+
|
|
205
|
+
def __call__(self, boxes, img, img_small, scale_factor, data):
|
|
206
|
+
mrzs = []
|
|
207
|
+
data['__debug__mrz'] = []
|
|
208
|
+
for i, b in enumerate(boxes):
|
|
209
|
+
roi, text, mrz = self.box_to_mrz(b, img, img_small, scale_factor)
|
|
210
|
+
data['__debug__mrz'].append((roi, text, mrz))
|
|
211
|
+
if mrz.valid:
|
|
212
|
+
return i, roi, text, mrz
|
|
213
|
+
elif mrz.valid_score > 0:
|
|
214
|
+
mrzs.append((i, roi, text, mrz))
|
|
215
|
+
if not mrzs:
|
|
216
|
+
return None, None, None, None
|
|
217
|
+
else:
|
|
218
|
+
mrzs.sort(key=lambda x: x[3].valid_score)
|
|
219
|
+
return mrzs[-1]
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
class BoxToMRZ(object):
|
|
223
|
+
"""Extracts ROI from the image, corresponding to a box found by MRZBoxLocator, does OCR and MRZ parsing on this region."""
|
|
224
|
+
|
|
225
|
+
__provides__ = ['roi', 'text', 'mrz']
|
|
226
|
+
__depends__ = ['box', 'img', 'img_small', 'scale_factor']
|
|
227
|
+
|
|
228
|
+
def __init__(self, use_original_image=True, extra_cmdline_params=''):
|
|
229
|
+
"""
|
|
230
|
+
:param use_original_image: when True, the ROI is extracted from img, otherwise from img_small
|
|
231
|
+
"""
|
|
232
|
+
self.use_original_image = use_original_image
|
|
233
|
+
self.extra_cmdline_params = extra_cmdline_params
|
|
234
|
+
|
|
235
|
+
def __call__(self, box, img, img_small, scale_factor):
|
|
236
|
+
img = img if self.use_original_image else img_small
|
|
237
|
+
scale = 1.0 / scale_factor if self.use_original_image else 1.0
|
|
238
|
+
roi = box.extract_from_image(img, scale)
|
|
239
|
+
text = ocr(roi, extra_cmdline_params=self.extra_cmdline_params)
|
|
240
|
+
|
|
241
|
+
if '>>' in text or ('>' in text and '<' not in text):
|
|
242
|
+
# Most probably we need to reverse the ROI
|
|
243
|
+
roi = roi[::-1, ::-1]
|
|
244
|
+
text = ocr(roi, extra_cmdline_params=self.extra_cmdline_params)
|
|
245
|
+
|
|
246
|
+
if '<' not in text:
|
|
247
|
+
# Assume this is unrecoverable and stop here (TODO: this may be premature, although it saves time on useless stuff)
|
|
248
|
+
return roi, text, MRZ.from_ocr(text)
|
|
249
|
+
|
|
250
|
+
mrz = MRZ.from_ocr(text)
|
|
251
|
+
mrz.aux['method'] = 'direct'
|
|
252
|
+
|
|
253
|
+
# Now try improving the result via hacks
|
|
254
|
+
if not mrz.valid:
|
|
255
|
+
text, mrz = self._try_larger_image(roi, text, mrz)
|
|
256
|
+
|
|
257
|
+
# Sometimes the filter used for enlargement is important!
|
|
258
|
+
if not mrz.valid:
|
|
259
|
+
text, mrz = self._try_larger_image(roi, text, mrz, 1)
|
|
260
|
+
|
|
261
|
+
if not mrz.valid:
|
|
262
|
+
text, mrz = self._try_black_tophat(roi, text, mrz)
|
|
263
|
+
|
|
264
|
+
return roi, text, mrz
|
|
265
|
+
|
|
266
|
+
def _try_larger_image(self, roi, cur_text, cur_mrz, filter_order=3):
|
|
267
|
+
"""Attempts to improve the OCR result by scaling the image. If the new mrz is better, returns it, otherwise returns
|
|
268
|
+
the old mrz."""
|
|
269
|
+
if roi.shape[1] <= 700:
|
|
270
|
+
scale_by = int(1050.0 / roi.shape[1] + 0.5)
|
|
271
|
+
roi_lg = ip.rescale(roi, scale_by, order=filter_order)
|
|
272
|
+
new_text = ocr(roi_lg, extra_cmdline_params=self.extra_cmdline_params)
|
|
273
|
+
new_mrz = MRZ.from_ocr(new_text)
|
|
274
|
+
new_mrz.aux['method'] = 'rescaled(%d)' % filter_order
|
|
275
|
+
if new_mrz.valid_score > cur_mrz.valid_score:
|
|
276
|
+
cur_mrz = new_mrz
|
|
277
|
+
cur_text = new_text
|
|
278
|
+
return cur_text, cur_mrz
|
|
279
|
+
|
|
280
|
+
def _try_black_tophat(self, roi, cur_text, cur_mrz):
|
|
281
|
+
roi_b = ip.black_tophat(roi, ip.disk(5))
|
|
282
|
+
# There are some examples where this line basically hangs for an undetermined amount of time.
|
|
283
|
+
new_text = ocr(roi_b, extra_cmdline_params=self.extra_cmdline_params)
|
|
284
|
+
new_mrz = MRZ.from_ocr(new_text)
|
|
285
|
+
if new_mrz.valid_score > cur_mrz.valid_score:
|
|
286
|
+
new_mrz.aux['method'] = 'black_tophat'
|
|
287
|
+
cur_text, cur_mrz = new_text, new_mrz
|
|
288
|
+
|
|
289
|
+
new_text, new_mrz = self._try_larger_image(roi_b, cur_text, cur_mrz)
|
|
290
|
+
if new_mrz.valid_score > cur_mrz.valid_score:
|
|
291
|
+
new_mrz.aux['method'] = 'black_tophat(rescaled(3))'
|
|
292
|
+
cur_text, cur_mrz = new_text, new_mrz
|
|
293
|
+
|
|
294
|
+
return cur_text, cur_mrz
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
class TryOtherMaxWidth(object):
|
|
298
|
+
"""
|
|
299
|
+
If mrz was not found so far in the current pipeline,
|
|
300
|
+
changes the max_width parameter of the scaler to 1000 and reruns the pipeline again.
|
|
301
|
+
"""
|
|
302
|
+
|
|
303
|
+
__provides__ = ['mrz_final']
|
|
304
|
+
__depends__ = ['mrz', '__pipeline__']
|
|
305
|
+
|
|
306
|
+
def __init__(self, other_max_width=1000):
|
|
307
|
+
self.other_max_width = other_max_width
|
|
308
|
+
|
|
309
|
+
def __call__(self, mrz, __pipeline__):
|
|
310
|
+
# We'll only try this if we see that img_binary.mean() is very small or img.mean() is very large (i.e. image is mostly white).
|
|
311
|
+
if mrz is None and (__pipeline__['img_binary'].mean() < 0.01 or __pipeline__['img'].mean() > 0.95):
|
|
312
|
+
__pipeline__.replace_component('scaler', Scaler(self.other_max_width))
|
|
313
|
+
new_mrz = __pipeline__['mrz']
|
|
314
|
+
if new_mrz is not None:
|
|
315
|
+
new_mrz.aux['method'] = new_mrz.aux['method'] + '|max_width(%d)' % self.other_max_width
|
|
316
|
+
mrz = new_mrz
|
|
317
|
+
return mrz
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
class MRZPipeline(Pipeline):
|
|
321
|
+
"""This is the "currently best-performing" pipeline for parsing MRZ from a given image file."""
|
|
322
|
+
|
|
323
|
+
def __init__(self, file, extra_cmdline_params=''):
|
|
324
|
+
super(MRZPipeline, self).__init__()
|
|
325
|
+
self.version = '1.0' # In principle we might have different pipelines in use, so possible backward compatibility is an issue
|
|
326
|
+
self.file = file
|
|
327
|
+
self.add_component('loader', Loader(file))
|
|
328
|
+
self.add_component('scaler', Scaler())
|
|
329
|
+
self.add_component('boone', BooneTransform())
|
|
330
|
+
self.add_component('box_locator', MRZBoxLocator())
|
|
331
|
+
self.add_component('mrz', FindFirstValidMRZ(extra_cmdline_params=extra_cmdline_params))
|
|
332
|
+
self.add_component('other_max_width', TryOtherMaxWidth())
|
|
333
|
+
|
|
334
|
+
# Step used by extract_mrz_rois (not even invoked by the standard result method)
|
|
335
|
+
self.add_component('extractor', ExtractAllBoxes())
|
|
336
|
+
|
|
337
|
+
@property
|
|
338
|
+
def result(self):
|
|
339
|
+
return self['mrz_final']
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def read_mrz(file, save_roi=False, extra_cmdline_params=''):
|
|
343
|
+
"""The main interface function to this module, encapsulating the recognition pipeline.
|
|
344
|
+
Given an image filename, runs MRZPipeline on it, returning the parsed MRZ object.
|
|
345
|
+
|
|
346
|
+
:param file: A filename or a stream to read the file data from.
|
|
347
|
+
:param save_roi: when this is True, the .aux['roi'] field will contain the Region of Interest where the MRZ was parsed from.
|
|
348
|
+
:param extra_cmdline_params: extra parameters to the OCR.
|
|
349
|
+
"""
|
|
350
|
+
p = MRZPipeline(file, extra_cmdline_params)
|
|
351
|
+
mrz = p.result
|
|
352
|
+
if mrz is not None and save_roi:
|
|
353
|
+
mrz.aux['roi'] = p['roi']
|
|
354
|
+
return mrz
|