Passed
Push — master ( b4ac4a...903ac6 )
by Konstantin
01:52
created

ocrd_utils.make_file_id()   A

Complexity

Conditions 5

Size

Total Lines 24
Code Lines 16

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 16
dl 0
loc 24
rs 9.1333
c 0
b 0
f 0
cc 5
nop 2
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))
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable k does not seem to be defined.
Loading history...
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