|
1
|
|
|
""" |
|
2
|
|
|
Utility functions and constants usable in various circumstances. |
|
3
|
|
|
|
|
4
|
|
|
* ``coordinates_of_segment``, ``coordinates_for_segment`` |
|
5
|
|
|
|
|
6
|
|
|
These functions convert polygon outlines for PAGE elements on all hierarchy |
|
7
|
|
|
levels below page (i.e. region, line, word, glyph) between relative coordinates |
|
8
|
|
|
w.r.t. a corresponding image and absolute coordinates w.r.t. the top-level image. |
|
9
|
|
|
This includes rotation and offset correction, based on affine transformations. |
|
10
|
|
|
(Used by ``Workspace`` methods ``image_from_page`` and ``image_from_segment``) |
|
11
|
|
|
|
|
12
|
|
|
* ``rotate_coordinates``, ``shift_coordinates``, ``transpose_coordinates``, ``transform_coordinates`` |
|
13
|
|
|
|
|
14
|
|
|
These backend functions compose affine transformations for reflection, rotation |
|
15
|
|
|
and offset correction of coordinates, or apply them to a set of points. They can be |
|
16
|
|
|
used to pass down the coordinate system along with images (both invariably sharing |
|
17
|
|
|
the same operations context) when traversing the element hierarchy top to bottom. |
|
18
|
|
|
(Used by ``Workspace`` methods ``image_from_page`` and ``image_from_segment``). |
|
19
|
|
|
|
|
20
|
|
|
* ``rotate_image``, ``crop_image``, ``transpose_image`` |
|
21
|
|
|
|
|
22
|
|
|
These PIL.Image functions are safe replacements for the ``rotate``, ``crop``, and |
|
23
|
|
|
``transpose`` methods. |
|
24
|
|
|
|
|
25
|
|
|
* ``image_from_polygon``, ``polygon_mask`` |
|
26
|
|
|
|
|
27
|
|
|
These functions apply polygon masks to PIL.Image objects. |
|
28
|
|
|
|
|
29
|
|
|
* ``xywh_from_points``, ``points_from_xywh``, ``polygon_from_points`` etc. |
|
30
|
|
|
|
|
31
|
|
|
These functions have the syntax ``X_from_Y``, where ``X``/``Y`` can be |
|
32
|
|
|
|
|
33
|
|
|
* ``bbox`` is a 4-tuple of integers x0, y0, x1, y1 of the bounding box (rectangle) |
|
34
|
|
|
|
|
35
|
|
|
(used by PIL.Image) |
|
36
|
|
|
* ``points`` a string encoding a polygon: ``"0,0 100,0 100,100, 0,100"`` |
|
37
|
|
|
|
|
38
|
|
|
(used by PAGE-XML) |
|
39
|
|
|
* ``polygon`` is a list of 2-lists of integers x, y of points forming an (implicitly closed) polygon path: ``[[0,0], [100,0], [100,100], [0,100]]`` |
|
40
|
|
|
|
|
41
|
|
|
(used by opencv2 and higher-level coordinate functions in ocrd_utils) |
|
42
|
|
|
* ``xywh`` a dict with keys for x, y, width and height: ``{'x': 0, 'y': 0, 'w': 100, 'h': 100}`` |
|
43
|
|
|
|
|
44
|
|
|
(produced by tesserocr and image/coordinate recursion methods in ocrd.workspace) |
|
45
|
|
|
* ``x0y0x1y1`` is a 4-list of strings ``x0``, ``y0``, ``x1``, ``y1`` of the bounding box (rectangle) |
|
46
|
|
|
|
|
47
|
|
|
(produced by tesserocr) |
|
48
|
|
|
* ``y0x0y1x1`` is the same as ``x0y0x1y1`` with positions of ``x`` and ``y`` in the list swapped |
|
49
|
|
|
|
|
50
|
|
|
* ``is_local_filename``, ``safe_filename``, ``abspath``, ``get_local_filename`` |
|
51
|
|
|
|
|
52
|
|
|
FS-related utilities |
|
53
|
|
|
|
|
54
|
|
|
* ``is_string``, ``membername``, ``concat_padded``, ``nth_url_segment``, ``remove_non_path_from_url``, ``parse_json_string_with_comments``, ``parse_json_string_or_file``, ``set_json_key_value_overrides``, ``assert_file_grp_cardinality``, ``make_file_id`` |
|
55
|
|
|
|
|
56
|
|
|
String and OOP utilities |
|
57
|
|
|
|
|
58
|
|
|
* ``MIMETYPE_PAGE``, ``EXT_TO_MIME``, ``MIME_TO_EXT``, ``VERSION`` |
|
59
|
|
|
|
|
60
|
|
|
Constants |
|
61
|
|
|
|
|
62
|
|
|
* ``logging``, ``setOverrideLogLevel``, ``getLevelName``, ``getLogger``, ``initLogging`` |
|
63
|
|
|
|
|
64
|
|
|
Exports of ocrd_utils.logging |
|
65
|
|
|
|
|
66
|
|
|
* ``deprecated_alias`` |
|
67
|
|
|
|
|
68
|
|
|
Decorator to mark a kwarg as deprecated |
|
69
|
|
|
""" |
|
70
|
|
|
|
|
71
|
|
|
__all__ = [ |
|
72
|
|
|
'abspath', |
|
73
|
|
|
'adjust_canvas_to_rotation', |
|
74
|
|
|
'adjust_canvas_to_transposition', |
|
75
|
|
|
'assert_file_grp_cardinality', |
|
76
|
|
|
'bbox_from_points', |
|
77
|
|
|
'bbox_from_xywh', |
|
78
|
|
|
'bbox_from_polygon', |
|
79
|
|
|
'coordinates_for_segment', |
|
80
|
|
|
'coordinates_of_segment', |
|
81
|
|
|
'concat_padded', |
|
82
|
|
|
'crop_image', |
|
83
|
|
|
'deprecated_alias', |
|
84
|
|
|
'getLevelName', |
|
85
|
|
|
'getLogger', |
|
86
|
|
|
'initLogging', |
|
87
|
|
|
'is_local_filename', |
|
88
|
|
|
'is_string', |
|
89
|
|
|
'nth_url_segment', |
|
90
|
|
|
'remove_non_path_from_url', |
|
91
|
|
|
'logging', |
|
92
|
|
|
'make_file_id', |
|
93
|
|
|
'membername', |
|
94
|
|
|
'image_from_polygon', |
|
95
|
|
|
'parse_json_string_with_comments', |
|
96
|
|
|
'parse_json_string_or_file', |
|
97
|
|
|
'points_from_bbox', |
|
98
|
|
|
'points_from_polygon', |
|
99
|
|
|
'points_from_x0y0x1y1', |
|
100
|
|
|
'points_from_xywh', |
|
101
|
|
|
'points_from_y0x0y1x1', |
|
102
|
|
|
'polygon_from_bbox', |
|
103
|
|
|
'polygon_from_points', |
|
104
|
|
|
'polygon_from_x0y0x1y1', |
|
105
|
|
|
'polygon_from_xywh', |
|
106
|
|
|
'polygon_mask', |
|
107
|
|
|
'rotate_coordinates', |
|
108
|
|
|
'rotate_image', |
|
109
|
|
|
'safe_filename', |
|
110
|
|
|
'setOverrideLogLevel', |
|
111
|
|
|
'set_json_key_value_overrides', |
|
112
|
|
|
'shift_coordinates', |
|
113
|
|
|
'transform_coordinates', |
|
114
|
|
|
'transpose_coordinates', |
|
115
|
|
|
'transpose_image', |
|
116
|
|
|
'unzip_file_to_dir', |
|
117
|
|
|
'xywh_from_bbox', |
|
118
|
|
|
'xywh_from_points', |
|
119
|
|
|
|
|
120
|
|
|
'VERSION', |
|
121
|
|
|
'MIMETYPE_PAGE', |
|
122
|
|
|
'EXT_TO_MIME', |
|
123
|
|
|
'MIME_TO_EXT', |
|
124
|
|
|
] |
|
125
|
|
|
|
|
126
|
|
|
import io |
|
127
|
|
|
import json |
|
128
|
|
|
import sys |
|
129
|
|
|
import os |
|
130
|
|
|
from os import getcwd, chdir |
|
131
|
|
|
from os.path import isfile, abspath as os_abspath |
|
132
|
|
|
import re |
|
133
|
|
|
from zipfile import ZipFile |
|
134
|
|
|
import contextlib |
|
135
|
|
|
|
|
136
|
|
|
import numpy as np |
|
137
|
|
|
from PIL import Image, ImageStat, ImageDraw, ImageChops |
|
138
|
|
|
|
|
139
|
|
|
from .logging import * # pylint: disable=wildcard-import |
|
140
|
|
|
from .constants import * # pylint: disable=wildcard-import |
|
141
|
|
|
from .deprecate import deprecated_alias |
|
142
|
|
|
|
|
143
|
|
|
LOG = getLogger('ocrd_utils') |
|
144
|
|
|
|
|
145
|
|
|
|
|
146
|
|
|
def abspath(url): |
|
147
|
|
|
""" |
|
148
|
|
|
Get a full path to a file or file URL |
|
149
|
|
|
|
|
150
|
|
|
See os.abspath |
|
151
|
|
|
""" |
|
152
|
|
|
if url.startswith('file://'): |
|
153
|
|
|
url = url[len('file://'):] |
|
154
|
|
|
return os_abspath(url) |
|
155
|
|
|
|
|
156
|
|
|
def bbox_from_points(points): |
|
157
|
|
|
"""Construct a numeric list representing a bounding box from polygon coordinates in page representation.""" |
|
158
|
|
|
xys = [[int(p) for p in pair.split(',')] for pair in points.split(' ')] |
|
159
|
|
|
return bbox_from_polygon(xys) |
|
160
|
|
|
|
|
161
|
|
|
def bbox_from_polygon(polygon): |
|
162
|
|
|
"""Construct a numeric list representing a bounding box from polygon coordinates in numeric list representation.""" |
|
163
|
|
|
minx = sys.maxsize |
|
164
|
|
|
miny = sys.maxsize |
|
165
|
|
|
maxx = -sys.maxsize |
|
166
|
|
|
maxy = -sys.maxsize |
|
167
|
|
|
for xy in polygon: |
|
168
|
|
|
if xy[0] < minx: |
|
169
|
|
|
minx = xy[0] |
|
170
|
|
|
if xy[0] > maxx: |
|
171
|
|
|
maxx = xy[0] |
|
172
|
|
|
if xy[1] < miny: |
|
173
|
|
|
miny = xy[1] |
|
174
|
|
|
if xy[1] > maxy: |
|
175
|
|
|
maxy = xy[1] |
|
176
|
|
|
return minx, miny, maxx, maxy |
|
177
|
|
|
|
|
178
|
|
|
def bbox_from_xywh(xywh): |
|
179
|
|
|
"""Convert a bounding box from a numeric dict to a numeric list representation.""" |
|
180
|
|
|
return ( |
|
181
|
|
|
xywh['x'], |
|
182
|
|
|
xywh['y'], |
|
183
|
|
|
xywh['x'] + xywh['w'], |
|
184
|
|
|
xywh['y'] + xywh['h'] |
|
185
|
|
|
) |
|
186
|
|
|
|
|
187
|
|
|
def xywh_from_polygon(polygon): |
|
188
|
|
|
"""Construct a numeric dict representing a bounding box from polygon coordinates in numeric list representation.""" |
|
189
|
|
|
return xywh_from_bbox(*bbox_from_polygon(polygon)) |
|
190
|
|
|
|
|
191
|
|
|
def coordinates_for_segment(polygon, parent_image, parent_coords): |
|
192
|
|
|
"""Convert relative coordinates to absolute. |
|
193
|
|
|
|
|
194
|
|
|
Given... |
|
195
|
|
|
|
|
196
|
|
|
- ``polygon``, a numpy array of points relative to |
|
197
|
|
|
- ``parent_image``, a PIL.Image (not used), along with |
|
198
|
|
|
- ``parent_coords``, its corresponding affine transformation, |
|
199
|
|
|
|
|
200
|
|
|
...calculate the absolute coordinates within the page. |
|
201
|
|
|
|
|
202
|
|
|
That is, apply the given transform inversely to ``polygon`` |
|
203
|
|
|
The transform encodes (recursively): |
|
204
|
|
|
|
|
205
|
|
|
1. Whenever ``parent_image`` or any of its parents was cropped, |
|
206
|
|
|
all points must be shifted by the offset in opposite direction |
|
207
|
|
|
(i.e. coordinate system gets translated by the upper left). |
|
208
|
|
|
2. Whenever ``parent_image`` or any of its parents was rotated, |
|
209
|
|
|
all points must be rotated around the center of that image in |
|
210
|
|
|
opposite direction |
|
211
|
|
|
(i.e. coordinate system gets translated by the center in |
|
212
|
|
|
opposite direction, rotated purely, and translated back; |
|
213
|
|
|
the latter involves an additional offset from the increase |
|
214
|
|
|
in canvas size necessary to accommodate all points). |
|
215
|
|
|
|
|
216
|
|
|
Return the rounded numpy array of the resulting polygon. |
|
217
|
|
|
""" |
|
218
|
|
|
polygon = np.array(polygon, dtype=np.float32) # avoid implicit type cast problems |
|
219
|
|
|
# apply inverse of affine transform: |
|
220
|
|
|
inv_transform = np.linalg.inv(parent_coords['transform']) |
|
221
|
|
|
polygon = transform_coordinates(polygon, inv_transform) |
|
222
|
|
|
return np.round(polygon).astype(np.int32) |
|
223
|
|
|
|
|
224
|
|
|
def coordinates_of_segment(segment, parent_image, parent_coords): |
|
225
|
|
|
"""Extract the coordinates of a PAGE segment element relative to its parent. |
|
226
|
|
|
|
|
227
|
|
|
Given... |
|
228
|
|
|
|
|
229
|
|
|
- ``segment``, a PAGE segment object in absolute coordinates |
|
230
|
|
|
(i.e. RegionType / TextLineType / WordType / GlyphType), and |
|
231
|
|
|
- ``parent_image``, the PIL.Image of its corresponding parent object |
|
232
|
|
|
(i.e. PageType / RegionType / TextLineType / WordType), (not used), |
|
233
|
|
|
along with |
|
234
|
|
|
- ``parent_coords``, its corresponding affine transformation, |
|
235
|
|
|
|
|
236
|
|
|
...calculate the relative coordinates of the segment within the image. |
|
237
|
|
|
|
|
238
|
|
|
That is, apply the given transform to the points annotated in ``segment``. |
|
239
|
|
|
The transform encodes (recursively): |
|
240
|
|
|
|
|
241
|
|
|
1. Whenever ``parent_image`` or any of its parents was cropped, |
|
242
|
|
|
all points must be shifted by the offset |
|
243
|
|
|
(i.e. coordinate system gets translated by the upper left). |
|
244
|
|
|
2. Whenever ``parent_image`` or any of its parents was rotated, |
|
245
|
|
|
all points must be rotated around the center of that image |
|
246
|
|
|
(i.e. coordinate system gets translated by the center in |
|
247
|
|
|
opposite direction, rotated purely, and translated back; |
|
248
|
|
|
the latter involves an additional offset from the increase |
|
249
|
|
|
in canvas size necessary to accommodate all points). |
|
250
|
|
|
|
|
251
|
|
|
Return the rounded numpy array of the resulting polygon. |
|
252
|
|
|
""" |
|
253
|
|
|
# get polygon: |
|
254
|
|
|
polygon = np.array(polygon_from_points(segment.get_Coords().points)) |
|
255
|
|
|
# apply affine transform: |
|
256
|
|
|
polygon = transform_coordinates(polygon, parent_coords['transform']) |
|
257
|
|
|
return np.round(polygon).astype(np.int32) |
|
258
|
|
|
|
|
259
|
|
|
@contextlib.contextmanager |
|
260
|
|
|
def pushd_popd(newcwd=None): |
|
261
|
|
|
try: |
|
262
|
|
|
oldcwd = getcwd() |
|
263
|
|
|
except FileNotFoundError as e: # pylint: disable=unused-variable |
|
264
|
|
|
# This happens when a directory is deleted before the context is exited |
|
265
|
|
|
oldcwd = '/tmp' |
|
266
|
|
|
try: |
|
267
|
|
|
if newcwd: |
|
268
|
|
|
chdir(newcwd) |
|
269
|
|
|
yield |
|
270
|
|
|
finally: |
|
271
|
|
|
chdir(oldcwd) |
|
272
|
|
|
|
|
273
|
|
|
def concat_padded(base, *args): |
|
274
|
|
|
""" |
|
275
|
|
|
Concatenate string and zero-padded 4 digit number |
|
276
|
|
|
""" |
|
277
|
|
|
ret = base |
|
278
|
|
|
for n in args: |
|
279
|
|
|
if is_string(n): |
|
280
|
|
|
ret = "%s_%s" % (ret, n) |
|
281
|
|
|
else: |
|
282
|
|
|
ret = "%s_%04i" % (ret, n + 1) |
|
283
|
|
|
return ret |
|
284
|
|
|
|
|
285
|
|
|
def remove_non_path_from_url(url): |
|
286
|
|
|
""" |
|
287
|
|
|
Remove everything from URL after path. |
|
288
|
|
|
""" |
|
289
|
|
|
url = url.split('?', 1)[0] # query |
|
290
|
|
|
url = url.split('#', 1)[0] # fragment identifier |
|
291
|
|
|
url = re.sub(r"/+$", "", url) # trailing slashes |
|
292
|
|
|
return url |
|
293
|
|
|
|
|
294
|
|
|
def nth_url_segment(url, n=-1): |
|
295
|
|
|
""" |
|
296
|
|
|
Return the last /-delimited segment of a URL-like string |
|
297
|
|
|
|
|
298
|
|
|
Arguments: |
|
299
|
|
|
url (string): |
|
300
|
|
|
n (integer): index of segment, default: -1 |
|
301
|
|
|
""" |
|
302
|
|
|
segments = remove_non_path_from_url(url).split('/') |
|
303
|
|
|
try: |
|
304
|
|
|
return segments[n] |
|
305
|
|
|
except IndexError: |
|
306
|
|
|
return '' |
|
307
|
|
|
|
|
308
|
|
|
def crop_image(image, box=None): |
|
309
|
|
|
""""Crop an image to a rectangle, filling with background. |
|
310
|
|
|
|
|
311
|
|
|
Given a PIL.Image ``image`` and a list ``box`` of the bounding |
|
312
|
|
|
rectangle relative to the image, crop at the box coordinates, |
|
313
|
|
|
filling everything outside ``image`` with the background. |
|
314
|
|
|
(This covers the case where ``box`` indexes are negative or |
|
315
|
|
|
larger than ``image`` width/height. PIL.Image.crop would fill |
|
316
|
|
|
with black.) Since ``image`` is not necessarily binarized yet, |
|
317
|
|
|
determine the background from the median color (instead of |
|
318
|
|
|
white). |
|
319
|
|
|
|
|
320
|
|
|
Return a new PIL.Image. |
|
321
|
|
|
""" |
|
322
|
|
|
if not box: |
|
323
|
|
|
box = (0, 0, image.width, image.height) |
|
324
|
|
|
elif box[0] < 0 or box[1] < 0 or box[2] > image.width or box[3] > image.height: |
|
325
|
|
|
# (It should be invalid in PAGE-XML to extend beyond parents.) |
|
326
|
|
|
LOG.warning('crop coordinates (%s) exceed image (%dx%d)', |
|
327
|
|
|
str(box), image.width, image.height) |
|
328
|
|
|
LOG.debug('cropping image to %s', str(box)) |
|
329
|
|
|
xywh = xywh_from_bbox(*box) |
|
330
|
|
|
background = tuple(ImageStat.Stat(image).median) |
|
331
|
|
|
new_image = Image.new(image.mode, (xywh['w'], xywh['h']), |
|
332
|
|
|
background) # or 'white' |
|
333
|
|
|
new_image.paste(image, (-xywh['x'], -xywh['y'])) |
|
334
|
|
|
return new_image |
|
335
|
|
|
|
|
336
|
|
|
def rotate_image(image, angle, fill='background', transparency=False): |
|
337
|
|
|
""""Rotate an image, enlarging and filling with background. |
|
338
|
|
|
|
|
339
|
|
|
Given a PIL.Image ``image`` and a rotation angle in degrees |
|
340
|
|
|
counter-clockwise ``angle``, rotate the image, increasing its |
|
341
|
|
|
size at the margins accordingly, and filling everything outside |
|
342
|
|
|
the original image according to ``fill``: |
|
343
|
|
|
|
|
344
|
|
|
- if ``background`` (the default), |
|
345
|
|
|
then use the median color of the image; |
|
346
|
|
|
- otherwise use the given color, e.g. ``'white'`` or (255,255,255). |
|
347
|
|
|
|
|
348
|
|
|
Moreover, if ``transparency`` is true, then add an alpha channel |
|
349
|
|
|
fully opaque (i.e. everything outside the original image will |
|
350
|
|
|
be transparent for those that can interpret alpha channels). |
|
351
|
|
|
(This is true for images which already have an alpha channel, |
|
352
|
|
|
regardless of the setting used.) |
|
353
|
|
|
|
|
354
|
|
|
Return a new PIL.Image. |
|
355
|
|
|
""" |
|
356
|
|
|
LOG.debug('rotating image by %.2f°', angle) |
|
357
|
|
|
if transparency and image.mode in ['RGB', 'L']: |
|
358
|
|
|
# ensure no information is lost by adding transparency channel |
|
359
|
|
|
# initialized to fully opaque (so cropping and rotation will |
|
360
|
|
|
# expose areas as transparent): |
|
361
|
|
|
image = image.copy() |
|
362
|
|
|
image.putalpha(255) |
|
363
|
|
|
if fill == 'background': |
|
364
|
|
|
background = ImageStat.Stat(image).median |
|
365
|
|
|
if image.mode in ['RGBA', 'LA']: |
|
366
|
|
|
background[-1] = 0 # fully transparent |
|
367
|
|
|
background = tuple(background) |
|
368
|
|
|
else: |
|
369
|
|
|
background = fill |
|
370
|
|
|
new_image = image.rotate(angle, |
|
371
|
|
|
expand=True, |
|
372
|
|
|
#resample=Image.BILINEAR, |
|
373
|
|
|
fillcolor=background) |
|
374
|
|
|
if new_image.mode in ['LA']: |
|
375
|
|
|
# workaround for #1600 (bug in LA support which |
|
376
|
|
|
# causes areas fully transparent before rotation |
|
377
|
|
|
# to be filled with black here): |
|
378
|
|
|
image = new_image |
|
379
|
|
|
new_image = Image.new(image.mode, image.size, background) |
|
380
|
|
|
new_image.paste(image, mask=image.getchannel('A')) |
|
381
|
|
|
return new_image |
|
382
|
|
|
|
|
383
|
|
|
def transpose_image(image, method): |
|
384
|
|
|
""""Transpose (i.e. flip or rotate in 90° multiples) an image. |
|
385
|
|
|
|
|
386
|
|
|
Given a PIL.Image ``image`` and a transposition mode ``method``, |
|
387
|
|
|
apply the respective operation: |
|
388
|
|
|
|
|
389
|
|
|
- ``PIL.Image.FLIP_LEFT_RIGHT``: |
|
390
|
|
|
all pixels get mirrored at half the width of the image |
|
391
|
|
|
- ``PIL.Image.FLIP_TOP_BOTTOM``: |
|
392
|
|
|
all pixels get mirrored at half the height of the image |
|
393
|
|
|
- ``PIL.Image.ROTATE_180``: |
|
394
|
|
|
all pixels get mirrored at both, the width and half the height |
|
395
|
|
|
of the image, |
|
396
|
|
|
i.e. the image gets rotated by 180° counter-clockwise |
|
397
|
|
|
- ``PIL.Image.ROTATE_90``: |
|
398
|
|
|
rows become columns (but counted from the right) and |
|
399
|
|
|
columns become rows, |
|
400
|
|
|
i.e. the image gets rotated by 90° counter-clockwise; |
|
401
|
|
|
width becomes height and vice versa |
|
402
|
|
|
- ``PIL.Image.ROTATE_270``: |
|
403
|
|
|
rows become columns and |
|
404
|
|
|
columns become rows (but counted from the bottom), |
|
405
|
|
|
i.e. the image gets rotated by 270° counter-clockwise; |
|
406
|
|
|
width becomes height and vice versa |
|
407
|
|
|
- ``PIL.Image.TRANSPOSE``: |
|
408
|
|
|
rows become columns and vice versa, |
|
409
|
|
|
i.e. all pixels get mirrored at the main diagonal; |
|
410
|
|
|
width becomes height and vice versa |
|
411
|
|
|
- ``PIL.Image.TRANSVERSE``: |
|
412
|
|
|
rows become columns (but counted from the right) and |
|
413
|
|
|
columns become rows (but counted from the bottom), |
|
414
|
|
|
i.e. all pixels get mirrored at the opposite diagonal; |
|
415
|
|
|
width becomes height and vice versa |
|
416
|
|
|
|
|
417
|
|
|
Return a new PIL.Image. |
|
418
|
|
|
""" |
|
419
|
|
|
LOG.debug('transposing image with %s', membername(Image, method)) |
|
420
|
|
|
return image.transpose(method) |
|
421
|
|
|
|
|
422
|
|
|
def get_local_filename(url, start=None): |
|
423
|
|
|
""" |
|
424
|
|
|
Return local filename, optionally relative to ``start`` |
|
425
|
|
|
|
|
426
|
|
|
Arguments: |
|
427
|
|
|
url (string): filename or URL |
|
428
|
|
|
start (string): Base path to remove from filename. Raise an exception if not a prefix of url |
|
429
|
|
|
""" |
|
430
|
|
|
if url.startswith('https://') or url.startswith('http:'): |
|
431
|
|
|
raise Exception("Can't determine local filename of http(s) URL") |
|
432
|
|
|
if url.startswith('file://'): |
|
433
|
|
|
url = url[len('file://'):] |
|
434
|
|
|
# Goobi/Kitodo produces those, they are always absolute |
|
435
|
|
|
if url.startswith('file:/'): |
|
436
|
|
|
raise Exception("Invalid (java) URL: %s" % url) |
|
437
|
|
|
if start: |
|
438
|
|
|
if not url.startswith(start): |
|
439
|
|
|
raise Exception("Cannot remove prefix %s from url %s" % (start, url)) |
|
440
|
|
|
if not start.endswith('/'): |
|
441
|
|
|
start += '/' |
|
442
|
|
|
url = url[len(start):] |
|
443
|
|
|
return url |
|
444
|
|
|
|
|
445
|
|
|
def image_from_polygon(image, polygon, fill='background', transparency=False): |
|
446
|
|
|
""""Mask an image with a polygon. |
|
447
|
|
|
|
|
448
|
|
|
Given a PIL.Image ``image`` and a numpy array ``polygon`` |
|
449
|
|
|
of relative coordinates into the image, fill everything |
|
450
|
|
|
outside the polygon hull to a color according to ``fill``: |
|
451
|
|
|
|
|
452
|
|
|
- if ``background`` (the default), |
|
453
|
|
|
then use the median color of the image; |
|
454
|
|
|
- otherwise use the given color, e.g. ``'white'`` or (255,255,255). |
|
455
|
|
|
|
|
456
|
|
|
Moreover, if ``transparency`` is true, then add an alpha channel |
|
457
|
|
|
from the polygon mask (i.e. everything outside the polygon will |
|
458
|
|
|
be transparent, for those consumers that can interpret alpha channels). |
|
459
|
|
|
Images which already have an alpha channel will have it shrunk |
|
460
|
|
|
from the polygon mask (i.e. everything outside the polygon will |
|
461
|
|
|
be transparent, in addition to existing transparent pixels). |
|
462
|
|
|
|
|
463
|
|
|
Return a new PIL.Image. |
|
464
|
|
|
""" |
|
465
|
|
|
mask = polygon_mask(image, polygon) |
|
466
|
|
|
if fill == 'background': |
|
467
|
|
|
background = tuple(ImageStat.Stat(image).median) |
|
468
|
|
|
else: |
|
469
|
|
|
background = fill |
|
470
|
|
|
new_image = Image.new(image.mode, image.size, background) |
|
471
|
|
|
new_image.paste(image, mask=mask) |
|
472
|
|
|
# ensure no information is lost by a adding transparency channel |
|
473
|
|
|
# initialized to fully transparent outside the polygon mask |
|
474
|
|
|
# (so consumers do not have to rely on background estimation, |
|
475
|
|
|
# which can fail on foreground-dominated segments, or white, |
|
476
|
|
|
# which can be inconsistent on unbinarized images): |
|
477
|
|
|
if image.mode in ['RGBA', 'LA']: |
|
478
|
|
|
# ensure transparency maximizes (i.e. parent mask AND mask): |
|
479
|
|
|
mask = ImageChops.darker(mask, image.getchannel('A')) # min opaque |
|
480
|
|
|
new_image.putalpha(mask) |
|
481
|
|
|
elif transparency and image.mode in ['RGB', 'L']: |
|
482
|
|
|
# introduce transparency: |
|
483
|
|
|
new_image.putalpha(mask) |
|
484
|
|
|
return new_image |
|
485
|
|
|
|
|
486
|
|
|
def is_local_filename(url): |
|
487
|
|
|
""" |
|
488
|
|
|
Whether a url is a local filename. |
|
489
|
|
|
""" |
|
490
|
|
|
return url.startswith('file://') or not('://' in url) |
|
491
|
|
|
|
|
492
|
|
|
def is_string(val): |
|
493
|
|
|
""" |
|
494
|
|
|
Return whether a value is a ``str``. |
|
495
|
|
|
""" |
|
496
|
|
|
return isinstance(val, str) |
|
497
|
|
|
|
|
498
|
|
|
def membername(class_, val): |
|
499
|
|
|
"""Convert a member variable/constant into a member name string.""" |
|
500
|
|
|
return next((k for k, v in class_.__dict__.items() if v == val), str(val)) |
|
|
|
|
|
|
501
|
|
|
|
|
502
|
|
|
def points_from_bbox(minx, miny, maxx, maxy): |
|
503
|
|
|
"""Construct polygon coordinates in page representation from a numeric list representing a bounding box.""" |
|
504
|
|
|
return "%i,%i %i,%i %i,%i %i,%i" % ( |
|
505
|
|
|
minx, miny, maxx, miny, maxx, maxy, minx, maxy) |
|
506
|
|
|
|
|
507
|
|
|
def points_from_polygon(polygon): |
|
508
|
|
|
"""Convert polygon coordinates from a numeric list representation to a page representation.""" |
|
509
|
|
|
return " ".join("%i,%i" % (x, y) for x, y in polygon) |
|
510
|
|
|
|
|
511
|
|
|
def points_from_xywh(box): |
|
512
|
|
|
""" |
|
513
|
|
|
Construct polygon coordinates in page representation from numeric dict representing a bounding box. |
|
514
|
|
|
""" |
|
515
|
|
|
x, y, w, h = box['x'], box['y'], box['w'], box['h'] |
|
516
|
|
|
# tesseract uses a different region representation format |
|
517
|
|
|
return "%i,%i %i,%i %i,%i %i,%i" % ( |
|
518
|
|
|
x, y, |
|
519
|
|
|
x + w, y, |
|
520
|
|
|
x + w, y + h, |
|
521
|
|
|
x, y + h |
|
522
|
|
|
) |
|
523
|
|
|
|
|
524
|
|
|
def points_from_y0x0y1x1(yxyx): |
|
525
|
|
|
""" |
|
526
|
|
|
Construct a polygon representation from a rectangle described as a list [y0, x0, y1, x1] |
|
527
|
|
|
""" |
|
528
|
|
|
y0 = yxyx[0] |
|
529
|
|
|
x0 = yxyx[1] |
|
530
|
|
|
y1 = yxyx[2] |
|
531
|
|
|
x1 = yxyx[3] |
|
532
|
|
|
return "%s,%s %s,%s %s,%s %s,%s" % ( |
|
533
|
|
|
x0, y0, |
|
534
|
|
|
x1, y0, |
|
535
|
|
|
x1, y1, |
|
536
|
|
|
x0, y1 |
|
537
|
|
|
) |
|
538
|
|
|
|
|
539
|
|
|
def points_from_x0y0x1y1(xyxy): |
|
540
|
|
|
""" |
|
541
|
|
|
Construct a polygon representation from a rectangle described as a list [x0, y0, x1, y1] |
|
542
|
|
|
""" |
|
543
|
|
|
x0 = xyxy[0] |
|
544
|
|
|
y0 = xyxy[1] |
|
545
|
|
|
x1 = xyxy[2] |
|
546
|
|
|
y1 = xyxy[3] |
|
547
|
|
|
return "%s,%s %s,%s %s,%s %s,%s" % ( |
|
548
|
|
|
x0, y0, |
|
549
|
|
|
x1, y0, |
|
550
|
|
|
x1, y1, |
|
551
|
|
|
x0, y1 |
|
552
|
|
|
) |
|
553
|
|
|
|
|
554
|
|
|
def polygon_from_bbox(minx, miny, maxx, maxy): |
|
555
|
|
|
"""Construct polygon coordinates in numeric list representation from a numeric list representing a bounding box.""" |
|
556
|
|
|
return [[minx, miny], [maxx, miny], [maxx, maxy], [minx, maxy]] |
|
557
|
|
|
|
|
558
|
|
|
def polygon_from_points(points): |
|
559
|
|
|
""" |
|
560
|
|
|
Convert polygon coordinates in page representation to polygon coordinates in numeric list representation. |
|
561
|
|
|
""" |
|
562
|
|
|
polygon = [] |
|
563
|
|
|
for pair in points.split(" "): |
|
564
|
|
|
x_y = pair.split(",") |
|
565
|
|
|
polygon.append([float(x_y[0]), float(x_y[1])]) |
|
566
|
|
|
return polygon |
|
567
|
|
|
|
|
568
|
|
|
def polygon_from_x0y0x1y1(x0y0x1y1): |
|
569
|
|
|
"""Construct polygon coordinates in numeric list representation from a string list representing a bounding box.""" |
|
570
|
|
|
minx = int(x0y0x1y1[0]) |
|
571
|
|
|
miny = int(x0y0x1y1[1]) |
|
572
|
|
|
maxx = int(x0y0x1y1[2]) |
|
573
|
|
|
maxy = int(x0y0x1y1[3]) |
|
574
|
|
|
return [[minx, miny], [maxx, miny], [maxx, maxy], [minx, maxy]] |
|
575
|
|
|
|
|
576
|
|
|
def polygon_from_xywh(xywh): |
|
577
|
|
|
"""Construct polygon coordinates in numeric list representation from numeric dict representing a bounding box.""" |
|
578
|
|
|
return polygon_from_bbox(*bbox_from_xywh(xywh)) |
|
579
|
|
|
|
|
580
|
|
|
def polygon_mask(image, coordinates): |
|
581
|
|
|
""""Create a mask image of a polygon. |
|
582
|
|
|
|
|
583
|
|
|
Given a PIL.Image ``image`` (merely for dimensions), and |
|
584
|
|
|
a numpy array ``polygon`` of relative coordinates into the image, |
|
585
|
|
|
create a new image of the same size with black background, and |
|
586
|
|
|
fill everything inside the polygon hull with white. |
|
587
|
|
|
|
|
588
|
|
|
Return the new PIL.Image. |
|
589
|
|
|
""" |
|
590
|
|
|
mask = Image.new('L', image.size, 0) |
|
591
|
|
|
if isinstance(coordinates, np.ndarray): |
|
592
|
|
|
coordinates = list(map(tuple, coordinates)) |
|
593
|
|
|
ImageDraw.Draw(mask).polygon(coordinates, outline=255, fill=255) |
|
594
|
|
|
return mask |
|
595
|
|
|
|
|
596
|
|
|
def adjust_canvas_to_rotation(size, angle): |
|
597
|
|
|
"""Calculate the enlarged image size after rotation. |
|
598
|
|
|
|
|
599
|
|
|
Given a numpy array ``size`` of an original canvas (width and height), |
|
600
|
|
|
and a rotation angle in degrees counter-clockwise ``angle``, |
|
601
|
|
|
calculate the new size which is necessary to encompass the full |
|
602
|
|
|
image after rotation. |
|
603
|
|
|
|
|
604
|
|
|
Return a numpy array of the enlarged width and height. |
|
605
|
|
|
""" |
|
606
|
|
|
angle = np.deg2rad(angle) |
|
607
|
|
|
sin = np.abs(np.sin(angle)) |
|
608
|
|
|
cos = np.abs(np.cos(angle)) |
|
609
|
|
|
return np.dot(np.array([[cos, sin], |
|
610
|
|
|
[sin, cos]]), |
|
611
|
|
|
np.array(size)) |
|
612
|
|
|
|
|
613
|
|
|
def adjust_canvas_to_transposition(size, method): |
|
614
|
|
|
"""Calculate the flipped image size after transposition. |
|
615
|
|
|
|
|
616
|
|
|
Given a numpy array ``size`` of an original canvas (width and height), |
|
617
|
|
|
and a transposition mode ``method`` (see ``transpose_image``), |
|
618
|
|
|
calculate the new size after transposition. |
|
619
|
|
|
|
|
620
|
|
|
Return a numpy array of the enlarged width and height. |
|
621
|
|
|
""" |
|
622
|
|
|
if method in [Image.ROTATE_90, |
|
623
|
|
|
Image.ROTATE_270, |
|
624
|
|
|
Image.TRANSPOSE, |
|
625
|
|
|
Image.TRANSVERSE]: |
|
626
|
|
|
size = size[::-1] |
|
627
|
|
|
return size |
|
628
|
|
|
|
|
629
|
|
|
def rotate_coordinates(transform, angle, orig=np.array([0, 0])): |
|
630
|
|
|
"""Compose an affine coordinate transformation with a passive rotation. |
|
631
|
|
|
|
|
632
|
|
|
Given a numpy array ``transform`` of an existing transformation |
|
633
|
|
|
matrix in homogeneous (3d) coordinates, and a rotation angle in |
|
634
|
|
|
degrees counter-clockwise ``angle``, as well as a numpy array |
|
635
|
|
|
``orig`` of the center of rotation, calculate the affine |
|
636
|
|
|
coordinate transform corresponding to the composition of both |
|
637
|
|
|
transformations. (This entails translation to the center, followed |
|
638
|
|
|
by pure rotation, and subsequent translation back. However, since |
|
639
|
|
|
rotation necessarily increases the bounding box, and thus image size, |
|
640
|
|
|
do not translate back the same amount, but to the enlarged offset.) |
|
641
|
|
|
|
|
642
|
|
|
Return a numpy array of the resulting affine transformation matrix. |
|
643
|
|
|
""" |
|
644
|
|
|
rad = np.deg2rad(angle) |
|
645
|
|
|
cos = np.cos(rad) |
|
646
|
|
|
sin = np.sin(rad) |
|
647
|
|
|
# get rotation matrix for passive rotation: |
|
648
|
|
|
rot = np.array([[+cos, sin, 0], |
|
649
|
|
|
[-sin, cos, 0], |
|
650
|
|
|
[0, 0, 1]]) |
|
651
|
|
|
# shift to center of rotation |
|
652
|
|
|
transform = shift_coordinates(transform, -orig) |
|
653
|
|
|
# apply pure rotation |
|
654
|
|
|
LOG.debug('rotating coordinates by %.2f° around %s', angle, str(orig)) |
|
655
|
|
|
transform = np.dot(rot, transform) |
|
656
|
|
|
# shift back |
|
657
|
|
|
transform = shift_coordinates( |
|
658
|
|
|
transform, |
|
659
|
|
|
#orig) |
|
660
|
|
|
# the image (bounding box) increases with rotation, |
|
661
|
|
|
# so we must translate back to the new upper left: |
|
662
|
|
|
adjust_canvas_to_rotation(orig, angle)) |
|
663
|
|
|
return transform |
|
664
|
|
|
|
|
665
|
|
|
def shift_coordinates(transform, offset): |
|
666
|
|
|
"""Compose an affine coordinate transformation with a translation. |
|
667
|
|
|
|
|
668
|
|
|
Given a numpy array ``transform`` of an existing transformation |
|
669
|
|
|
matrix in homogeneous (3d) coordinates, and a numpy array |
|
670
|
|
|
``offset`` of the translation vector, calculate the affine |
|
671
|
|
|
coordinate transform corresponding to the composition of both |
|
672
|
|
|
transformations. |
|
673
|
|
|
|
|
674
|
|
|
Return a numpy array of the resulting affine transformation matrix. |
|
675
|
|
|
""" |
|
676
|
|
|
LOG.debug('shifting coordinates by %s', str(offset)) |
|
677
|
|
|
shift = np.eye(3) |
|
678
|
|
|
shift[0, 2] = offset[0] |
|
679
|
|
|
shift[1, 2] = offset[1] |
|
680
|
|
|
return np.dot(shift, transform) |
|
681
|
|
|
|
|
682
|
|
|
def transpose_coordinates(transform, method, orig=np.array([0, 0])): |
|
683
|
|
|
""""Compose an affine coordinate transformation with a transposition (i.e. flip or rotate in 90° multiples). |
|
684
|
|
|
|
|
685
|
|
|
Given a numpy array ``transform`` of an existing transformation |
|
686
|
|
|
matrix in homogeneous (3d) coordinates, a transposition mode ``method``, |
|
687
|
|
|
as well as a numpy array ``orig`` of the center of the image, |
|
688
|
|
|
calculate the affine coordinate transform corresponding to the composition |
|
689
|
|
|
of both transformations, which is respectively: |
|
690
|
|
|
|
|
691
|
|
|
- ``PIL.Image.FLIP_LEFT_RIGHT``: |
|
692
|
|
|
entails translation to the center, followed by pure reflection |
|
693
|
|
|
about the y-axis, and subsequent translation back |
|
694
|
|
|
- ``PIL.Image.FLIP_TOP_BOTTOM``: |
|
695
|
|
|
entails translation to the center, followed by pure reflection |
|
696
|
|
|
about the x-axis, and subsequent translation back |
|
697
|
|
|
- ``PIL.Image.ROTATE_180``: |
|
698
|
|
|
entails translation to the center, followed by pure reflection |
|
699
|
|
|
about the origin, and subsequent translation back |
|
700
|
|
|
- ``PIL.Image.ROTATE_90``: |
|
701
|
|
|
entails translation to the center, followed by pure rotation |
|
702
|
|
|
by 90° counter-clockwise, and subsequent translation back |
|
703
|
|
|
- ``PIL.Image.ROTATE_270``: |
|
704
|
|
|
entails translation to the center, followed by pure rotation |
|
705
|
|
|
by 270° counter-clockwise, and subsequent translation back |
|
706
|
|
|
- ``PIL.Image.TRANSPOSE``: |
|
707
|
|
|
entails translation to the center, followed by pure rotation |
|
708
|
|
|
by 90° counter-clockwise and pure reflection about the x-axis, |
|
709
|
|
|
and subsequent translation back |
|
710
|
|
|
- ``PIL.Image.TRANSVERSE``: |
|
711
|
|
|
entails translation to the center, followed by pure rotation |
|
712
|
|
|
by 90° counter-clockwise and pure reflection about the y-axis, |
|
713
|
|
|
and subsequent translation back |
|
714
|
|
|
|
|
715
|
|
|
Return a numpy array of the resulting affine transformation matrix. |
|
716
|
|
|
""" |
|
717
|
|
|
LOG.debug('transposing coordinates with %s around %s', membername(Image, method), str(orig)) |
|
718
|
|
|
# get rotation matrix for passive rotation/reflection: |
|
719
|
|
|
rot90 = np.array([[0, 1, 0], |
|
720
|
|
|
[-1, 0, 0], |
|
721
|
|
|
[0, 0, 1]]) |
|
722
|
|
|
reflx = np.array([[1, 0, 0], |
|
723
|
|
|
[0, -1, 0], |
|
724
|
|
|
[0, 0, 1]]) |
|
725
|
|
|
refly = np.array([[-1, 0, 0], |
|
726
|
|
|
[0, 1, 0], |
|
727
|
|
|
[0, 0, 1]]) |
|
728
|
|
|
transform = shift_coordinates(transform, -orig) |
|
729
|
|
|
operations = { |
|
730
|
|
|
Image.FLIP_LEFT_RIGHT: [refly], |
|
731
|
|
|
Image.FLIP_TOP_BOTTOM: [reflx], |
|
732
|
|
|
Image.ROTATE_180: [reflx, refly], |
|
733
|
|
|
Image.ROTATE_90: [rot90], |
|
734
|
|
|
Image.ROTATE_270: [rot90, reflx, refly], |
|
735
|
|
|
Image.TRANSPOSE: [rot90, reflx], |
|
736
|
|
|
Image.TRANSVERSE: [rot90, refly] |
|
737
|
|
|
}.get(method) # no default |
|
738
|
|
|
for operation in operations: |
|
739
|
|
|
transform = np.dot(operation, transform) |
|
740
|
|
|
transform = shift_coordinates( |
|
741
|
|
|
transform, |
|
742
|
|
|
# the image (bounding box) may flip with transposition, |
|
743
|
|
|
# so we must translate back to the new upper left: |
|
744
|
|
|
adjust_canvas_to_transposition(orig, method)) |
|
745
|
|
|
return transform |
|
746
|
|
|
|
|
747
|
|
|
def transform_coordinates(polygon, transform=None): |
|
748
|
|
|
"""Apply an affine transformation to a set of points. |
|
749
|
|
|
Augment the 2d numpy array of points ``polygon`` with a an extra |
|
750
|
|
|
column of ones (homogeneous coordinates), then multiply with |
|
751
|
|
|
the transformation matrix ``transform`` (or the identity matrix), |
|
752
|
|
|
and finally remove the extra column from the result. |
|
753
|
|
|
""" |
|
754
|
|
|
if transform is None: |
|
755
|
|
|
transform = np.eye(3) |
|
756
|
|
|
polygon = np.insert(polygon, 2, 1, axis=1) # make 3d homogeneous coordinates |
|
757
|
|
|
polygon = np.dot(transform, polygon.T).T |
|
758
|
|
|
# ones = polygon[:,2] |
|
759
|
|
|
# assert np.all(np.array_equal(ones, np.clip(ones, 1 - 1e-2, 1 + 1e-2))), \ |
|
760
|
|
|
# 'affine transform failed' # should never happen |
|
761
|
|
|
polygon = np.delete(polygon, 2, axis=1) # remove z coordinate again |
|
762
|
|
|
return polygon |
|
763
|
|
|
|
|
764
|
|
|
def safe_filename(url): |
|
765
|
|
|
""" |
|
766
|
|
|
Sanitize input to be safely used as the basename of a local file. |
|
767
|
|
|
""" |
|
768
|
|
|
ret = re.sub('[^A-Za-z0-9]+', '.', url) |
|
769
|
|
|
# print('safe filename: %s -> %s' % (url, ret)) |
|
770
|
|
|
return ret |
|
771
|
|
|
|
|
772
|
|
|
def unzip_file_to_dir(path_to_zip, output_directory): |
|
773
|
|
|
""" |
|
774
|
|
|
Extract a ZIP archive to a directory |
|
775
|
|
|
""" |
|
776
|
|
|
z = ZipFile(path_to_zip, 'r') |
|
777
|
|
|
z.extractall(output_directory) |
|
778
|
|
|
z.close() |
|
779
|
|
|
|
|
780
|
|
|
def xywh_from_bbox(minx, miny, maxx, maxy): |
|
781
|
|
|
"""Convert a bounding box from a numeric list to a numeric dict representation.""" |
|
782
|
|
|
return { |
|
783
|
|
|
'x': minx, |
|
784
|
|
|
'y': miny, |
|
785
|
|
|
'w': maxx - minx, |
|
786
|
|
|
'h': maxy - miny, |
|
787
|
|
|
} |
|
788
|
|
|
|
|
789
|
|
|
def xywh_from_points(points): |
|
790
|
|
|
""" |
|
791
|
|
|
Construct a numeric dict representing a bounding box from polygon coordinates in page representation. |
|
792
|
|
|
""" |
|
793
|
|
|
return xywh_from_bbox(*bbox_from_points(points)) |
|
794
|
|
|
|
|
795
|
|
|
def parse_json_string_with_comments(val): |
|
796
|
|
|
""" |
|
797
|
|
|
Parse a string of JSON interspersed with #-prefixed full-line comments |
|
798
|
|
|
""" |
|
799
|
|
|
jsonstr = re.sub('^\s*#.*$', '', val, flags=re.MULTILINE) |
|
800
|
|
|
return json.loads(jsonstr) |
|
801
|
|
|
|
|
802
|
|
|
def parse_json_string_or_file(*values): # pylint: disable=unused-argument |
|
803
|
|
|
""" |
|
804
|
|
|
Parse a string as either the path to a JSON object or a literal JSON object. |
|
805
|
|
|
|
|
806
|
|
|
Empty strings are equivalent to '{}' |
|
807
|
|
|
""" |
|
808
|
|
|
ret = {} |
|
809
|
|
|
for value in values: |
|
810
|
|
|
err = None |
|
811
|
|
|
value_parsed = None |
|
812
|
|
|
if re.fullmatch(r"\s*", value): |
|
813
|
|
|
continue |
|
814
|
|
|
try: |
|
815
|
|
|
try: |
|
816
|
|
|
with open(value, 'r') as f: |
|
817
|
|
|
value_parsed = parse_json_string_with_comments(f.read()) |
|
818
|
|
|
except (FileNotFoundError, OSError): |
|
819
|
|
|
value_parsed = parse_json_string_with_comments(value.strip()) |
|
820
|
|
|
if not isinstance(value_parsed, dict): |
|
821
|
|
|
err = ValueError("Not a valid JSON object: '%s' (parsed as '%s')" % (value, value_parsed)) |
|
822
|
|
|
except json.decoder.JSONDecodeError as e: |
|
823
|
|
|
err = ValueError("Error parsing '%s': %s" % (value, e)) |
|
824
|
|
|
if err: |
|
825
|
|
|
raise err # pylint: disable=raising-bad-type |
|
826
|
|
|
ret = {**ret, **value_parsed} |
|
827
|
|
|
return ret |
|
828
|
|
|
|
|
829
|
|
|
|
|
830
|
|
|
def set_json_key_value_overrides(obj, *kvpairs): |
|
831
|
|
|
for kv in kvpairs: |
|
832
|
|
|
k, v = kv |
|
833
|
|
|
try: |
|
834
|
|
|
obj[k] = json.loads(v) |
|
835
|
|
|
except json.decoder.JSONDecodeError: |
|
836
|
|
|
obj[k] = v |
|
837
|
|
|
return obj |
|
838
|
|
|
|
|
839
|
|
|
def assert_file_grp_cardinality(grps, n): |
|
840
|
|
|
""" |
|
841
|
|
|
Assert that a string of comma-separated fileGrps contains exactly ``n`` entries. |
|
842
|
|
|
""" |
|
843
|
|
|
if isinstance(grps, str): |
|
844
|
|
|
grps = grps.split(',') |
|
845
|
|
|
assert len(grps) == n, \ |
|
846
|
|
|
"Expected exactly %d output file group%s, but '%s' has %d" % ( |
|
847
|
|
|
n, '' if n == 1 else 's', grps, len(grps)) |
|
848
|
|
|
|
|
849
|
|
|
|
|
850
|
|
|
def make_file_id(ocrd_file, output_file_grp): |
|
851
|
|
|
""" |
|
852
|
|
|
Derive a new file ID for an output file from an existing input file ``ocrd_file`` |
|
853
|
|
|
and the name of the output file's ``fileGrp/@USE``, ``output_file_grp``. |
|
854
|
|
|
If ``ocrd_file``'s ID contains the input file's fileGrp name, then replace it by ``output_file_grp``. |
|
855
|
|
|
Otherwise use ``output_file_grp`` together with the position of ``ocrd_file`` within the input fileGrp |
|
856
|
|
|
(as a fallback counter). Increment counter until there is no more ID conflict. |
|
857
|
|
|
""" |
|
858
|
|
|
ret = ocrd_file.ID.replace(ocrd_file.fileGrp, output_file_grp) |
|
859
|
|
|
if ret == ocrd_file.ID: |
|
860
|
|
|
m = re.match(r'.*?(\d{3,}).*', ocrd_file.pageId or '') |
|
861
|
|
|
if m: |
|
862
|
|
|
n = m.group(1) |
|
863
|
|
|
else: |
|
864
|
|
|
ids = [f.ID for f in ocrd_file.mets.find_files(fileGrp=ocrd_file.fileGrp, mimetype=ocrd_file.mimetype)] |
|
865
|
|
|
try: |
|
866
|
|
|
n = ids.index(ocrd_file.ID) |
|
867
|
|
|
except ValueError: |
|
868
|
|
|
n = len(ids) |
|
869
|
|
|
ret = concat_padded(output_file_grp, n) |
|
870
|
|
|
while ocrd_file.mets.find_files(ID=ret): |
|
871
|
|
|
n += 1 |
|
872
|
|
|
ret = concat_padded(output_file_grp, n) |
|
873
|
|
|
return ret |
|
874
|
|
|
|