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 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