Passed
Push — master ( d4a853...e428fe )
by Konstantin
02:09
created

ocrd.workspace.Workspace.download_url()   A

Complexity

Conditions 1

Size

Total Lines 16
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 6
dl 0
loc 16
rs 10
c 0
b 0
f 0
cc 1
nop 3
1
import io
2
from os import makedirs, unlink, listdir, path
3
from pathlib import Path
4
from shutil import move, copyfileobj
5
from re import sub
6
from tempfile import NamedTemporaryFile
7
from contextlib import contextmanager
8
9
from cv2 import COLOR_GRAY2BGR, COLOR_RGB2BGR, cvtColor
10
from PIL import Image
11
import numpy as np
12
from deprecated.sphinx import deprecated
13
import requests
14
15
from ocrd_models import OcrdMets, OcrdFile
16
from ocrd_models.ocrd_page import parse, BorderType, to_xml
17
from ocrd_modelfactory import exif_from_filename, page_from_file
18
from ocrd_utils import (
19
    atomic_write,
20
    getLogger,
21
    image_from_polygon,
22
    coordinates_of_segment,
23
    adjust_canvas_to_rotation,
24
    adjust_canvas_to_transposition,
25
    shift_coordinates,
26
    rotate_coordinates,
27
    transform_coordinates,
28
    transpose_coordinates,
29
    crop_image,
30
    rotate_image,
31
    transpose_image,
32
    bbox_from_polygon,
33
    polygon_from_points,
34
    xywh_from_bbox,
35
    pushd_popd,
36
    MIME_TO_EXT,
37
    MIME_TO_PIL,
38
    MIMETYPE_PAGE,
39
    REGEX_PREFIX
40
)
41
42
from .workspace_backup import WorkspaceBackupManager
43
44
__all__ = ['Workspace']
45
46
@contextmanager
47
def download_temporary_file(url):
48
    with NamedTemporaryFile(prefix='ocrd-download-') as f:
49
        with requests.get(url) as r:
50
            f.write(r.content)
51
        yield f
52
53
54
class Workspace():
55
    """
56
    A workspace is a temporary directory set up for a processor. It's the
57
    interface to the METS/PAGE XML and delegates download and upload to the
58
    :py:class:`ocrd.resolver.Resolver`.
59
60
    Args:
61
62
        directory (string) : Filesystem folder to work in
63
        mets (:py:class:`ocrd_models.ocrd_mets.OcrdMets`) : `OcrdMets` representing this workspace. 
64
            Loaded from `'mets.xml'` if `None`.
65
        mets_basename (string) : Basename of the METS XML file. Default: Last URL segment of the mets_url.
66
        overwrite_mode (boolean) : Whether to force add operations on this workspace globally
67
        baseurl (string) : Base URL to prefix to relative URL.
68
    """
69
70
    def __init__(self, resolver, directory, mets=None, mets_basename='mets.xml', automatic_backup=False, baseurl=None):
71
        self.resolver = resolver
72
        self.directory = directory
73
        self.mets_target = str(Path(directory, mets_basename))
74
        self.overwrite_mode = False
75
        if mets is None:
76
            mets = OcrdMets(filename=self.mets_target)
77
        self.mets = mets
78
        self.automatic_backup = automatic_backup
79
        self.baseurl = baseurl
80
        #  print(mets.to_xml(xmllint=True).decode('utf-8'))
81
82
    def __str__(self):
83
        return 'Workspace[directory=%s, baseurl=%s, file_groups=%s, files=%s]' % (
84
            self.directory,
85
            self.baseurl,
86
            self.mets.file_groups,
87
            [str(f) for f in self.mets.find_all_files()],
88
        )
89
90
    def reload_mets(self):
91
        """
92
        Reload METS from the filesystem.
93
        """
94
        self.mets = OcrdMets(filename=self.mets_target)
95
96
    def merge(self, other_workspace, copy_files=True, **kwargs):
97
        """
98
        Merge ``other_workspace`` into this one
99
100
        See :py:meth:`ocrd_models.ocrd_mets.OcrdMets.merge` for the `kwargs`
101
102
        Keyword Args:
103
            copy_files (boolean): Whether to copy files from `other_workspace` to this one
104
        """
105
        def after_add_cb(f):
106
            if not copy_files:
107
                return
108
            fpath_src = Path(other_workspace.directory, f.url)
109
            fpath_dest = Path(self.directory, f.url)
110
            if fpath_src.exists():
111
                if fpath_dest.exists():
112
                    raise Exception("Copying %s to %s would overwrite the latter" % (fpath_src, fpath_dest))
113
                if not fpath_dest.parent.is_dir():
114
                    makedirs(str(fpath_dest.parent))
115
                with open(str(fpath_src), 'rb') as fstream_in, open(str(fpath_dest), 'wb') as fstream_out:
116
                    copyfileobj(fstream_in, fstream_out)
117
        self.mets.merge(other_workspace.mets, after_add_cb=after_add_cb, **kwargs)
118
119
120
    @deprecated(version='1.0.0', reason="Use workspace.download_file")
121
    def download_url(self, url, **kwargs):
122
        """
123
        Download a URL to the workspace.
124
125
        Args:
126
            url (string): URL to download to directory
127
            **kwargs : See :py:class:`ocrd_models.ocrd_file.OcrdFile`
128
129
        Returns:
130
            The local filename of the downloaded file
131
        """
132
        dummy_mets = OcrdMets.empty_mets()
133
        f = dummy_mets.add_file('DEPRECATED', ID=Path(url).name, url=url)
134
        f = self.download_file(f)
135
        return f.local_filename
136
137
    def download_file(self, f, _recursion_count=0):
138
        """
139
        Download a :py:class:`ocrd_models.ocrd_file.OcrdFile` to the workspace.
140
        """
141
        log = getLogger('ocrd.workspace.download_file')
142
        log.debug('download_file %s [_recursion_count=%s]' % (f, _recursion_count))
143
        with pushd_popd(self.directory):
144
            try:
145
                # If the f.url is already a file path, and is within self.directory, do nothing
146
                url_path = Path(f.url).resolve()
147
                if not (url_path.exists() and url_path.relative_to(str(Path(self.directory).resolve()))):
148
                    raise Exception("Not already downloaded, moving on")
149
            except Exception as e:
150
                basename = '%s%s' % (f.ID, MIME_TO_EXT.get(f.mimetype, '')) if f.ID else f.basename
151
                try:
152
                    f.url = self.resolver.download_to_directory(self.directory, f.url, subdir=f.fileGrp, basename=basename)
153
                except FileNotFoundError as e:
154
                    if not self.baseurl:
155
                        raise Exception("No baseurl defined by workspace. Cannot retrieve '%s'" % f.url)
156
                    if _recursion_count >= 1:
157
                        raise FileNotFoundError("Already tried prepending baseurl '%s'. Cannot retrieve '%s'" % (self.baseurl, f.url))
158
                    log.debug("First run of resolver.download_to_directory(%s) failed, try prepending baseurl '%s': %s", f.url, self.baseurl, e)
159
                    f.url = '%s/%s' % (self.baseurl, f.url)
160
                    f.url = self.download_file(f, _recursion_count + 1).local_filename
161
            f.local_filename = f.url
162
            return f
163
164
    def remove_file(self, ID, force=False, keep_file=False, page_recursive=False, page_same_group=False):
165
        """
166
        Remove a METS `file` from the workspace.
167
168
        Arguments:
169
            ID (string|:py:class:`ocrd_models.ocrd_file.OcrdFile`): `@ID` of the METS `file`
170
                to delete or the file itself
171
        Keyword Args:
172
            force (boolean): Continue removing even if file not found in METS
173
            keep_file (boolean): Whether to keep files on disk
174
            page_recursive (boolean): Whether to remove all images referenced in the file
175
                if the file is a PAGE-XML document.
176
            page_same_group (boolean): Remove only images in the same file group as the PAGE-XML.
177
                Has no effect unless ``page_recursive`` is `True`.
178
        """
179
        log = getLogger('ocrd.workspace.remove_file')
180
        log.debug('Deleting mets:file %s', ID)
181
        if not force and self.overwrite_mode:
182
            force = True
183
        if isinstance(ID, OcrdFile):
184
            ID = ID.ID
185
        try:
186
            try:
187
                ocrd_file = next(self.mets.find_files(ID=ID))
188
            except StopIteration:
189
                raise FileNotFoundError("File %s not found in METS" % ID)
190
            if page_recursive and ocrd_file.mimetype == MIMETYPE_PAGE:
191
                with pushd_popd(self.directory):
192
                    ocrd_page = parse(self.download_file(ocrd_file).local_filename, silence=True)
193
                    for img_url in ocrd_page.get_AllAlternativeImagePaths():
194
                        img_kwargs = {'url': img_url}
195
                        if page_same_group:
196
                            img_kwargs['fileGrp'] = ocrd_file.fileGrp
197
                        for img_file in self.mets.find_files(**img_kwargs):
198
                            self.remove_file(img_file, keep_file=keep_file, force=force)
199
            if not keep_file:
200
                with pushd_popd(self.directory):
201
                    if not ocrd_file.local_filename:
202
                        log.warning("File not locally available %s", ocrd_file)
203
                        if not force:
204
                            raise Exception("File not locally available %s" % ocrd_file)
205
                    else:
206
                        log.info("rm %s [cwd=%s]", ocrd_file.local_filename, self.directory)
207
                        unlink(ocrd_file.local_filename)
208
            # Remove from METS only after the recursion of AlternativeImages
209
            self.mets.remove_file(ID)
210
            return ocrd_file
211
        except FileNotFoundError as e:
212
            if not force:
213
                raise e
214
215
    def remove_file_group(self, USE, recursive=False, force=False, keep_files=False, page_recursive=False, page_same_group=False):
216
        """
217
        Remove a METS `fileGrp`.
218
219
        Arguments:
220
            USE (string): `@USE` of the METS `fileGrp` to delete
221
        Keyword Args:
222
            recursive (boolean): Whether to recursively delete all files in the group
223
            force (boolean): Continue removing even if group or containing files not found in METS
224
            keep_files (boolean): When deleting recursively whether to keep files on disk
225
            page_recursive (boolean): Whether to remove all images referenced in the file 
226
                if the file is a PAGE-XML document.
227
            page_same_group (boolean): Remove only images in the same file group as the PAGE-XML. 
228
                Has no effect unless ``page_recursive`` is `True`.
229
        """
230
        if not force and self.overwrite_mode:
231
            force = True
232
233
        if (not USE.startswith(REGEX_PREFIX)) and (USE not in self.mets.file_groups) and (not force):
234
            raise Exception("No such fileGrp: %s" % USE)
235
236
        file_dirs = []
237
        if recursive:
238
            for f in self.mets.find_files(fileGrp=USE):
239
                self.remove_file(f, force=force, keep_file=keep_files, page_recursive=page_recursive, page_same_group=page_same_group)
240
                if f.local_filename:
241
                    file_dirs.append(path.dirname(f.local_filename))
242
243
        self.mets.remove_file_group(USE, force=force)
244
245
        # PLEASE NOTE: this only removes directories in the workspace if they are empty
246
        # and named after the fileGrp which is a convention in OCR-D.
247
        with pushd_popd(self.directory):
248
            if Path(USE).is_dir() and not listdir(USE):
249
                Path(USE).rmdir()
250
            if file_dirs:
251
                for file_dir in set(file_dirs):
252
                    if Path(file_dir).is_dir() and not listdir(file_dir):
253
                        Path(file_dir).rmdir()
254
255
256
    def rename_file_group(self, old, new):
257
        """
258
        Rename a METS `fileGrp`.
259
260
        Arguments:
261
            old (string): `@USE` of the METS `fileGrp` to rename
262
            new (string): `@USE` of the METS `fileGrp` to rename as
263
        """
264
        log = getLogger('ocrd.workspace.rename_file_group')
265
266
        if old not in self.mets.file_groups:
267
            raise ValueError("No such fileGrp: %s" % old)
268
        if new in self.mets.file_groups:
269
            raise ValueError("fileGrp already exists %s" % new)
270
271
        with pushd_popd(self.directory):
272
            # create workspace dir ``new``
273
            log.info("mkdir %s" % new)
274
            if not Path(new).is_dir():
275
                Path(new).mkdir()
276
            url_replacements = {}
277
            log.info("Moving files")
278
            for mets_file in self.mets.find_files(fileGrp=old, local_only=True):
279
                new_url = sub(r'^%s/' % old, '%s/' % new, mets_file.url)
280
                url_replacements[mets_file.url] = new_url
281
                # move file from ``old`` to ``new``
282
                move(mets_file.url, new_url)
283
                # change the url of ``mets:file``
284
                mets_file.url = new_url
285
            # change file paths in PAGE-XML imageFilename and filename attributes
286
            for page_file in self.mets.find_files(mimetype=MIMETYPE_PAGE, local_only=True):
287
                log.info("Renaming file references in PAGE-XML %s" % page_file)
288
                pcgts = page_from_file(page_file)
289
                changed = False
290
                for old_url, new_url in url_replacements.items():
291
                    if pcgts.get_Page().imageFilename == old_url:
292
                        changed = True
293
                        log.info("Rename pc:Page/@imageFilename: %s -> %s" % (old_url, new_url))
294
                        pcgts.get_Page().imageFilename = new_url
295
                for ai in pcgts.get_Page().get_AllAlternativeImages():
296
                    for old_url, new_url in url_replacements.items():
297
                        if ai.filename == old_url:
298
                            changed = True
299
                            log.info("Rename pc:Page/../AlternativeImage: %s -> %s" % (old_url, new_url))
300
                            ai.filename = new_url
301
                if changed:
302
                    log.info("PAGE-XML changed, writing %s" % (page_file.local_filename))
303
                    with open(page_file.local_filename, 'w', encoding='utf-8') as f:
304
                        f.write(to_xml(pcgts))
305
            # change the ``USE`` attribute of the fileGrp
306
            self.mets.rename_file_group(old, new)
307
            # Remove the old dir
308
            log.info("rmdir %s" % old)
309
            if Path(old).is_dir() and not listdir(old):
310
                Path(old).rmdir()
311
312
    def add_file(self, file_grp, content=None, **kwargs):
313
        """
314
        Add a file to the :py:class:`ocrd_models.ocrd_mets.OcrdMets` of the workspace.
315
        
316
        Arguments:
317
            file_grp (string): `@USE` of the METS `fileGrp` to add to
318
        Keyword Args:
319
            content (string|bytes): optional content to write to the file
320
                in the filesystem
321
            **kwargs: See :py:func:`ocrd_models.ocrd_mets.OcrdMets.add_file`
322
        Returns:
323
            a new :py:class:`ocrd_models.ocrd_file.OcrdFile` 
324
        """
325
        log = getLogger('ocrd.workspace.add_file')
326
        log.debug(
327
            'outputfile file_grp=%s local_filename=%s content=%s',
328
            file_grp,
329
            kwargs.get('local_filename'),
330
            content is not None)
331
        if 'pageId' not in kwargs:
332
            raise ValueError("workspace.add_file must be passed a 'pageId' kwarg, even if it is None.")
333
        if content is not None and 'local_filename' not in kwargs:
334
            raise Exception("'content' was set but no 'local_filename'")
335
        if self.overwrite_mode:
336
            kwargs['force'] = True
337
338
        with pushd_popd(self.directory):
339
            if 'local_filename' in kwargs:
340
                # If the local filename has folder components, create those folders
341
                local_filename_dir = kwargs['local_filename'].rsplit('/', 1)[0]
342
                if local_filename_dir != kwargs['local_filename'] and not Path(local_filename_dir).is_dir():
343
                    makedirs(local_filename_dir)
344
                if 'url' not in kwargs:
345
                    kwargs['url'] = kwargs['local_filename']
346
347
            #  print(kwargs)
348
            ret = self.mets.add_file(file_grp, **kwargs)
349
350
            if content is not None:
351
                with open(kwargs['local_filename'], 'wb') as f:
352
                    if isinstance(content, str):
353
                        content = bytes(content, 'utf-8')
354
                    f.write(content)
355
356
        return ret
357
358
    def save_mets(self):
359
        """
360
        Write out the current state of the METS file to the filesystem.
361
        """
362
        log = getLogger('ocrd.workspace.save_mets')
363
        log.info("Saving mets '%s'", self.mets_target)
364
        if self.automatic_backup:
365
            WorkspaceBackupManager(self).add()
366
        with atomic_write(self.mets_target) as f:
367
            f.write(self.mets.to_xml(xmllint=True).decode('utf-8'))
368
369
    def resolve_image_exif(self, image_url):
370
        """
371
        Get the EXIF metadata about an image URL as :py:class:`ocrd_models.ocrd_exif.OcrdExif`
372
373
        Args:
374
            image_url (string) : `@href` (path or URL) of the METS `file` to inspect
375
376
        Returns:
377
            :py:class:`ocrd_models.ocrd_exif.OcrdExif`
378
        """
379
        if not image_url:
380
            # avoid "finding" just any file
381
            raise Exception("Cannot resolve empty image path")
382
        try:
383
            f = next(self.mets.find_files(url=image_url))
384
            image_filename = self.download_file(f).local_filename
385
            ocrd_exif = exif_from_filename(image_filename)
386
        except StopIteration:
387
            with download_temporary_file(image_url) as f:
388
                ocrd_exif = exif_from_filename(f.filename)
389
        return ocrd_exif
390
391
    @deprecated(version='1.0.0', reason="Use workspace.image_from_page and workspace.image_from_segment")
392
    def resolve_image_as_pil(self, image_url, coords=None):
393
        """
394
        Resolve an image URL to a `PIL.Image`.
395
396
        Arguments:
397
            image_url (string): `@href` (path or URL) of the METS `file` to retrieve
398
        Keyword Args:
399
            coords (list) : Coordinates of the bounding box to cut from the image
400
401
        Returns:
402
            Full or cropped `PIL.Image`
403
404
        """
405
        return self._resolve_image_as_pil(image_url, coords)
406
407
    def _resolve_image_as_pil(self, image_url, coords=None):
408
        if not image_url:
409
            # avoid "finding" just any file
410
            raise Exception("Cannot resolve empty image path")
411
        log = getLogger('ocrd.workspace._resolve_image_as_pil')
412
        with pushd_popd(self.directory):
413
            try:
414
                f = next(self.mets.find_files(url=image_url))
415
                pil_image = Image.open(self.download_file(f).local_filename)
416
            except StopIteration:
417
                with download_temporary_file(image_url) as f:
418
                    pil_image = Image.open(f.filename)
419
            pil_image.load() # alloc and give up the FD
420
421
        # Pillow does not properly support higher color depths
422
        # (e.g. 16-bit or 32-bit or floating point grayscale),
423
        # clipping its dynamic range to the lower 8-bit in
424
        # many operations (including paste, putalpha, ImageStat...),
425
        # even including conversion.
426
        # Cf. Pillow#3011 Pillow#3159 Pillow#3838 (still open in 8.0)
427
        # So to be on the safe side, we must re-quantize these
428
        # to 8-bit via numpy (conversion to/from which fortunately
429
        # seems to work reliably):
430
        if (pil_image.mode.startswith('I') or
431
            pil_image.mode.startswith('F')):
432
            arr_image = np.array(pil_image)
433
            if arr_image.dtype.kind == 'i':
434
                # signed integer is *not* trustworthy in this context
435
                # (usually a mistake in the array interface)
436
                log.debug('Casting image "%s" from signed to unsigned', image_url)
437
                arr_image.dtype = np.dtype('u' + arr_image.dtype.name)
438
            if arr_image.dtype.kind == 'u':
439
                # integer needs to be scaled linearly to 8 bit
440
                # of course, an image might actually have some lower range
441
                # (e.g. 10-bit in I;16 or 20-bit in I or 4-bit in L),
442
                # but that would be guessing anyway, so here don't
443
                # make assumptions on _scale_, just reduce _precision_
444
                log.debug('Reducing image "%s" from depth %d bit to 8 bit',
445
                          image_url, arr_image.dtype.itemsize * 8)
446
                arr_image = arr_image >> 8 * (arr_image.dtype.itemsize-1)
447
                arr_image = arr_image.astype(np.uint8)
448
            elif arr_image.dtype.kind == 'f':
449
                # float needs to be scaled from [0,1.0] to [0,255]
450
                log.debug('Reducing image "%s" from floating point to 8 bit',
451
                          image_url)
452
                arr_image *= 255
453
                arr_image = arr_image.astype(np.uint8)
454
            pil_image = Image.fromarray(arr_image)
455
456
        if coords is None:
457
            return pil_image
458
459
        # FIXME: remove or replace this by (image_from_polygon+) crop_image ...
460
        log.debug("Converting PIL to OpenCV: %s", image_url)
461
        color_conversion = COLOR_GRAY2BGR if pil_image.mode in ('1', 'L') else  COLOR_RGB2BGR
462
        pil_as_np_array = np.array(pil_image).astype('uint8') if pil_image.mode == '1' else np.array(pil_image)
463
        cv2_image = cvtColor(pil_as_np_array, color_conversion)
464
465
        poly = np.array(coords, np.int32)
466
        log.debug("Cutting region %s from %s", coords, image_url)
467
        region_cut = cv2_image[
468
            np.min(poly[:, 1]):np.max(poly[:, 1]),
469
            np.min(poly[:, 0]):np.max(poly[:, 0])
470
        ]
471
        return Image.fromarray(region_cut)
472
473
    def image_from_page(self, page, page_id,
474
                        fill='background', transparency=False,
475
                        feature_selector='', feature_filter=''):
476
        """Extract an image for a PAGE-XML page from the workspace.
477
478
        Args:
479
            page (:py:class:`ocrd_models.ocrd_page.PageType`): a PAGE `PageType` object
480
            page_id (string): its `@ID` in the METS physical `structMap`
481
        Keyword Args:
482
            fill (string): a `PIL` color specifier
483
            transparency (boolean): whether to add an alpha channel for masking
484
            feature_selector (string): a comma-separated list of `@comments` classes
485
            feature_filter (string): a comma-separated list of `@comments` classes
486
487
        Extract a `PIL.Image` from ``page``, either from its `AlternativeImage`
488
        (if it exists), or from its `@imageFilename` (otherwise). Also crop it,
489
        if a `Border` exists, and rotate it, if any `@orientation` angle is
490
        annotated.
491
492
        If ``feature_selector`` and/or ``feature_filter`` is given, then
493
        select/filter among the `@imageFilename` image and the available
494
        AlternativeImages the richest one which contains all of the selected,
495
        but none of the filtered features (i.e. `@comments` classes), or
496
        raise an error.
497
498
        (Required and produced features need not be in the same order, so
499
        ``feature_selector`` is merely a mask specifying Boolean AND, and
500
        ``feature_filter`` is merely a mask specifying Boolean OR.)
501
502
        If the chosen image does not have the feature `"cropped"` yet, but
503
        a `Border` exists, and unless `"cropped"` is being filtered, then crop it.
504
        Likewise, if the chosen image does not have the feature `"deskewed"` yet,
505
        but an `@orientation` angle is annotated, and unless `"deskewed"` is being
506
        filtered, then rotate it. (However, if `@orientation` is above the
507
        [-45°,45°] interval, then apply as much transposition as possible first,
508
        unless `"rotated-90"` / `"rotated-180"` / `"rotated-270"` is being filtered.)
509
510
        Cropping uses a polygon mask (not just the bounding box rectangle).
511
        Areas outside the polygon will be filled according to ``fill``:
512
513
        - if `"background"` (the default),
514
          then fill with the median color of the image;
515
        - otherwise, use the given color, e.g. `"white"` or `(255,255,255)`.
516
517
        Moreover, if ``transparency`` is true, and unless the image already
518
        has an alpha channel, then add an alpha channel which is fully opaque
519
        before cropping and rotating. (Thus, unexposed/masked areas will be
520
        transparent afterwards for consumers that can interpret alpha channels).
521
522
        Returns:
523
            a tuple of
524
             * the extracted `PIL.Image`,
525
             * a `dict` with information about the extracted image:
526
527
               - `"transform"`: a `Numpy` array with an affine transform which
528
                   converts from absolute coordinates to those relative to the image,
529
                   i.e. after cropping to the page's border / bounding box (if any)
530
                   and deskewing with the page's orientation angle (if any)
531
               - `"angle"`: the rotation/reflection angle applied to the image so far,
532
               - `"features"`: the `AlternativeImage` `@comments` for the image, i.e.
533
                 names of all applied operations that lead up to this result,
534
             * an :py:class:`ocrd_models.ocrd_exif.OcrdExif` instance associated with 
535
               the original image.
536
537
        (The first two can be used to annotate a new `AlternativeImage`,
538
         or be passed down with :py:meth:`image_from_segment`.)
539
540
        Examples:
541
542
         * get a raw (colored) but already deskewed and cropped image::
543
544
                page_image, page_coords, page_image_info = workspace.image_from_page(
545
                    page, page_id,
546
                    feature_selector='deskewed,cropped',
547
                    feature_filter='binarized,grayscale_normalized')
548
        """
549
        log = getLogger('ocrd.workspace.image_from_page')
550
        page_image_info = self.resolve_image_exif(page.imageFilename)
551
        page_image = self._resolve_image_as_pil(page.imageFilename)
552
        page_coords = dict()
553
        # use identity as initial affine coordinate transform:
554
        page_coords['transform'] = np.eye(3)
555
        # interim bbox (updated with each change to the transform):
556
        page_bbox = [0, 0, page_image.width, page_image.height]
557
        page_xywh = {'x': 0, 'y': 0,
558
                     'w': page_image.width, 'h': page_image.height}
559
560
        border = page.get_Border()
561
        # page angle: PAGE @orientation is defined clockwise,
562
        # whereas PIL/ndimage rotation is in mathematical direction:
563
        page_coords['angle'] = -(page.get_orientation() or 0)
564
        # map angle from (-180,180] to [0,360], and partition into multiples of 90;
565
        # but avoid unnecessary large remainders, i.e. split symmetrically:
566
        orientation = (page_coords['angle'] + 45) % 360
567
        orientation = orientation - (orientation % 90)
568
        skew = (page_coords['angle'] % 360) - orientation
569
        skew = 180 - (180 - skew) % 360 # map to [-45,45]
570
        page_coords['angle'] = 0 # nothing applied yet (depends on filters)
571
        log.debug("page '%s' has %s orientation=%d skew=%.2f",
572
                  page_id, "border," if border else "", orientation, skew)
573
574
        # initialize AlternativeImage@comments classes as empty:
575
        page_coords['features'] = ''
576
        best_image = None
577
        alternative_images = page.get_AlternativeImage()
578 View Code Duplication
        if alternative_images:
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated in your project.
Loading history...
579
            # (e.g. from page-level cropping, binarization, deskewing or despeckling)
580
            best_features = set()
581
            auto_features = {'cropped', 'deskewed', 'rotated-90', 'rotated-180', 'rotated-270'}
582
            # search to the end, because by convention we always append,
583
            # and among multiple satisfactory images we want the most recent,
584
            # but also ensure that we get the richest feature set, i.e. most
585
            # of those features that we cannot reproduce automatically below
586
            for alternative_image in alternative_images:
587
                features = alternative_image.get_comments()
588
                if not features:
589
                    log.warning("AlternativeImage %d for page '%s' does not have any feature attributes",
590
                                alternative_images.index(alternative_image) + 1, page_id)
591
                    features = ''
592
                featureset = set(features.split(','))
593
                if (all(feature in featureset
594
                        for feature in feature_selector.split(',') if feature) and
595
                    not any(feature in featureset
596
                            for feature in feature_filter.split(',') if feature) and
597
                    len(featureset.difference(auto_features)) >= \
598
                    len(best_features.difference(auto_features))):
599
                    best_features = featureset
600
                    best_image = alternative_image
601
            if best_image:
602
                log.debug("Using AlternativeImage %d %s for page '%s'",
603
                          alternative_images.index(best_image) + 1,
604
                          best_features, page_id)
605
                page_image = self._resolve_image_as_pil(best_image.get_filename())
606
                page_coords['features'] = best_image.get_comments() # including duplicates
607
608
        # adjust the coord transformation to the steps applied on the image,
609
        # and apply steps on the existing image in case it is missing there,
610
        # but traverse all steps (crop/reflect/rotate) in a particular order:
611
        # - existing image features take priority (in the order annotated),
612
        # - next is cropping (if necessary but not already applied),
613
        # - next is reflection (if necessary but not already applied),
614
        # - next is rotation (if necessary but not already applied).
615
        # This helps deal with arbitrary workflows (e.g. crop then deskew,
616
        # or deskew then crop), regardless of where images are generated.
617
        alternative_image_features = page_coords['features'].split(',')
618
        for duplicate_feature in set([feature for feature in alternative_image_features
619
                                      # features relevant in reconstructing coordinates:
620
                                      if (feature in ['cropped', 'deskewed', 'rotated-90',
621
                                                      'rotated-180', 'rotated-270'] and
622
                                          alternative_image_features.count(feature) > 1)]):
623
            log.error("Duplicate feature %s in AlternativeImage for page '%s'",
624
                      duplicate_feature, page_id)
625
        for i, feature in enumerate(alternative_image_features +
626
                                    (['cropped']
627
                                     if (border and
628
                                         not 'cropped' in alternative_image_features and
629
                                         not 'cropped' in feature_filter.split(','))
630
                                     else []) +
631
                                    (['rotated-%d' % orientation]
632
                                     if (orientation and
633
                                         not 'rotated-%d' % orientation in alternative_image_features and
634
                                         not 'rotated-%d' % orientation in feature_filter.split(','))
635
                                     else []) +
636
                                    (['deskewed']
637
                                     if (skew and
638
                                         not 'deskewed' in alternative_image_features and
639
                                         not 'deskewed' in feature_filter.split(','))
640
                                     else []) +
641
                                    # not a feature to be added, but merely as a fallback position
642
                                    # to always enter loop at i == len(alternative_image_features)
643
                                    ['_check']):
644
            # image geometry vs feature consistency can only be checked
645
            # after all features on the existing AlternativeImage have
646
            # been adjusted for in the transform, and when there is a mismatch,
647
            # additional steps applied here would only repeat the respective
648
            # error message; so we only check once at the boundary between
649
            # existing and new features
650
            # FIXME we should check/enforce consistency when _adding_ AlternativeImage
651
            if (i == len(alternative_image_features) and
652
                not (page_xywh['w'] - 2 < page_image.width < page_xywh['w'] + 2 and
653
                     page_xywh['h'] - 2 < page_image.height < page_xywh['h'] + 2)):
654
                log.error('page "%s" image (%s; %dx%d) has not been cropped properly (%dx%d)',
655
                          page_id, page_coords['features'],
656
                          page_image.width, page_image.height,
657
                          page_xywh['w'], page_xywh['h'])
658
            name = "%s for page '%s'" % ("AlternativeImage" if best_image
659
                                         else "original image", page_id)
660
            # adjust transform to feature, and ensure feature is applied to image
661
            if feature == 'cropped':
662
                page_image, page_coords, page_xywh = _crop(
663
                    log, name, border, page_image, page_coords,
664
                    fill=fill, transparency=transparency)
665
            elif feature == 'rotated-%d' % orientation:
666
                page_image, page_coords, page_xywh = _reflect(
667
                    log, name, orientation, page_image, page_coords, page_xywh)
668
            elif feature == 'deskewed':
669
                page_image, page_coords, page_xywh = _rotate(
670
                    log, name, skew, border, page_image, page_coords, page_xywh,
671
                    fill=fill, transparency=transparency)
672
673
        # verify constraints again:
674
        if not all(feature in page_coords['features']
675
                   for feature in feature_selector.split(',') if feature):
676
            raise Exception('Found no AlternativeImage that satisfies all requirements ' +
677
                            'selector="%s" in page "%s"' % (
678
                                feature_selector, page_id))
679
        if any(feature in page_coords['features']
680
               for feature in feature_filter.split(',') if feature):
681
            raise Exception('Found no AlternativeImage that satisfies all requirements ' +
682
                            'filter="%s" in page "%s"' % (
683
                                feature_filter, page_id))
684
        page_image.format = 'PNG' # workaround for tesserocr#194
685
        return page_image, page_coords, page_image_info
686
687
    def image_from_segment(self, segment, parent_image, parent_coords,
688
                           fill='background', transparency=False,
689
                           feature_selector='', feature_filter=''):
690
        """Extract an image for a PAGE-XML hierarchy segment from its parent's image.
691
692
        Args:
693
            segment (object): a PAGE segment object \
694
                (i.e. :py:class:`~ocrd_models.ocrd_page.TextRegionType` \
695
                or :py:class:`~ocrd_models.ocrd_page.TextLineType` \
696
                or :py:class:`~ocrd_models.ocrd_page.WordType` \
697
                or :py:class:`~ocrd_models.ocrd_page.GlyphType`)
698
            parent_image (`PIL.Image`): image of the `segment`'s parent
699
            parent_coords (dict): a `dict` with information about `parent_image`:
700
701
               - `"transform"`: a `Numpy` array with an affine transform which
702
                 converts from absolute coordinates to those relative to the image,
703
                 i.e. after applying all operations (starting with the original image)
704
               - `"angle"`: the rotation/reflection angle applied to the image so far,
705
               - `"features"`: the ``AlternativeImage/@comments`` for the image, i.e.
706
                 names of all operations that lead up to this result, and
707
        Keyword Args:
708
            fill (string): a `PIL` color specifier
709
            transparency (boolean): whether to add an alpha channel for masking
710
            feature_selector (string): a comma-separated list of ``@comments`` classes
711
            feature_filter (string): a comma-separated list of ``@comments`` classes            
712
        
713
        Extract a `PIL.Image` from `segment`, either from ``AlternativeImage``
714
        (if it exists), or producing a new image via cropping from `parent_image`
715
        (otherwise). Pass in `parent_image` and `parent_coords` from the result
716
        of the next higher-level of this function or from :py:meth:`image_from_page`.
717
718
        If `feature_selector` and/or `feature_filter` is given, then
719
        select/filter among the cropped `parent_image` and the available
720
        AlternativeImages the richest one which contains all of the selected,
721
        but none of the filtered features (i.e. ``@comments`` classes), or
722
        raise an error.
723
724
        (Required and produced features need not be in the same order, so
725
        `feature_selector` is merely a mask specifying Boolean AND, and
726
        `feature_filter` is merely a mask specifying Boolean OR.)
727
728
        Cropping uses a polygon mask (not just the bounding box rectangle).
729
        Areas outside the polygon will be filled according to `fill`:
730
731
        - if `"background"` (the default),
732
          then fill with the median color of the image;
733
        - otherwise, use the given color, e.g. `"white"` or `(255,255,255)`.
734
735
        Moreover, if `transparency` is true, and unless the image already
736
        has an alpha channel, then add an alpha channel which is fully opaque
737
        before cropping and rotating. (Thus, unexposed/masked areas will be
738
        transparent afterwards for consumers that can interpret alpha channels).
739
740
        When cropping, compensate any ``@orientation`` angle annotated for the
741
        parent (from parent-level deskewing) by rotating the segment coordinates
742
        in an inverse transformation (i.e. translation to center, then passive
743
        rotation, and translation back).
744
745
        Regardless, if any ``@orientation`` angle is annotated for the segment
746
        (from segment-level deskewing), and the chosen image does not have
747
        the feature `"deskewed"` yet, and unless `"deskewed"` is being filtered,
748
        then rotate it - compensating for any previous `"angle"`. (However,
749
        if ``@orientation`` is above the [-45°,45°] interval, then apply as much
750
        transposition as possible first, unless `"rotated-90"` / `"rotated-180"` /
751
        `"rotated-270"` is being filtered.)
752
753
        Returns:
754
            a tuple of
755
             * the extracted `PIL.Image`,
756
             * a `dict` with information about the extracted image:
757
758
               - `"transform"`: a `Numpy` array with an affine transform which
759
                   converts from absolute coordinates to those relative to the image,
760
                   i.e. after applying all parent operations, and then cropping to
761
                   the segment's bounding box, and deskewing with the segment's
762
                   orientation angle (if any)
763
               - `"angle"`: the rotation/reflection angle applied to the image so far,
764
               - `"features"`: the ``AlternativeImage/@comments`` for the image, i.e.
765
                 names of all applied operations that lead up to this result.
766
767
        (These can be used to create a new ``AlternativeImage``, or passed down
768
         for :py:meth:`image_from_segment` calls on lower hierarchy levels.)
769
770
        Examples:
771
772
         * get a raw (colored) but already deskewed and cropped image::
773
774
                image, xywh = workspace.image_from_segment(region,
775
                    page_image, page_xywh,
776
                    feature_selector='deskewed,cropped',
777
                    feature_filter='binarized,grayscale_normalized')
778
        """
779
        log = getLogger('ocrd.workspace.image_from_segment')
780
        # note: We should mask overlapping neighbouring segments here,
781
        # but finding the right clipping rules can be difficult if operating
782
        # on the raw (non-binary) image data alone: for each intersection, it
783
        # must be decided which one of either segment or neighbour to assign,
784
        # e.g. an ImageRegion which properly contains our TextRegion should be
785
        # completely ignored, but an ImageRegion which is properly contained
786
        # in our TextRegion should be completely masked, while partial overlap
787
        # may be more difficult to decide. On the other hand, on the binary image,
788
        # we can use connected component analysis to mask foreground areas which
789
        # originate in the neighbouring regions. But that would introduce either
790
        # the assumption that the input has already been binarized, or a dependency
791
        # on some ad-hoc binarization method. Thus, it is preferable to use
792
        # a dedicated processor for this (which produces clipped AlternativeImage
793
        # or reduced polygon coordinates).
794
        segment_image, segment_coords, segment_xywh = _crop(
795
            log, "parent image for segment '%s'" % segment.id,
796
            segment, parent_image, parent_coords,
797
            fill=fill, transparency=transparency)
798
799
        # Semantics of missing @orientation at region level could be either
800
        # - inherited from page level: same as line or word level (no @orientation),
801
        # - zero (unrotate page angle): different from line or word level (because
802
        #   otherwise deskewing would never have an effect on lines and words)
803
        # The PAGE specification is silent here (but does generally not concern itself
804
        # much with AlternativeImage coordinate consistency).
805
        # Since our (generateDS-backed) ocrd_page supports the zero/none distinction,
806
        # we choose the former (i.e. None is inheritance).
807
        if 'orientation' in segment.__dict__ and segment.get_orientation() is not None:
808
            # region angle: PAGE @orientation is defined clockwise,
809
            # whereas PIL/ndimage rotation is in mathematical direction:
810
            angle = -segment.get_orientation()
811
            # @orientation is always absolute; if higher levels
812
            # have already rotated, then we must compensate:
813
            angle -= parent_coords['angle']
814
            # map angle from (-180,180] to [0,360], and partition into multiples of 90;
815
            # but avoid unnecessary large remainders, i.e. split symmetrically:
816
            orientation = (angle + 45) % 360
817
            orientation = orientation - (orientation % 90)
818
            skew = (angle % 360) - orientation
819
            skew = 180 - (180 - skew) % 360 # map to [-45,45]
820
            log.debug("segment '%s' has orientation=%d skew=%.2f",
821
                      segment.id, orientation, skew)
822
        else:
823
            orientation = 0
824
            skew = 0
825
        segment_coords['angle'] = parent_coords['angle'] # nothing applied yet (depends on filters)
826
827
        # initialize AlternativeImage@comments classes from parent, except
828
        # for those operations that can apply on multiple hierarchy levels:
829
        segment_coords['features'] = ','.join(
830
            [feature for feature in parent_coords['features'].split(',')
831
             if feature in ['binarized', 'grayscale_normalized',
832
                            'despeckled', 'dewarped']])
833
834
        best_image = None
835
        alternative_images = segment.get_AlternativeImage()
836 View Code Duplication
        if alternative_images:
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated in your project.
Loading history...
837
            # (e.g. from segment-level cropping, binarization, deskewing or despeckling)
838
            best_features = set()
839
            auto_features = {'cropped', 'deskewed', 'rotated-90', 'rotated-180', 'rotated-270'}
840
            # search to the end, because by convention we always append,
841
            # and among multiple satisfactory images we want the most recent,
842
            # but also ensure that we get the richest feature set, i.e. most
843
            # of those features that we cannot reproduce automatically below
844
            for alternative_image in alternative_images:
845
                features = alternative_image.get_comments()
846
                if not features:
847
                    log.warning("AlternativeImage %d for segment '%s' does not have any feature attributes",
848
                                alternative_images.index(alternative_image) + 1, segment.id)
849
                    features = ''
850
                featureset = set(features.split(','))
851
                if (all(feature in featureset
852
                        for feature in feature_selector.split(',') if feature) and
853
                    not any(feature in featureset
854
                            for feature in feature_filter.split(',') if feature) and
855
                    len(featureset.difference(auto_features)) >= \
856
                    len(best_features.difference(auto_features))):
857
                    best_features = featureset
858
                    best_image = alternative_image
859
            if best_image:
860
                log.debug("Using AlternativeImage %d %s for segment '%s'",
861
                          alternative_images.index(best_image) + 1,
862
                          best_features, segment.id)
863
                segment_image = self._resolve_image_as_pil(alternative_image.get_filename())
0 ignored issues
show
introduced by
The variable alternative_image does not seem to be defined in case the for loop on line 844 is not entered. Are you sure this can never be the case?
Loading history...
864
                segment_coords['features'] = best_image.get_comments() # including duplicates
865
866
        alternative_image_features = segment_coords['features'].split(',')
867
        for duplicate_feature in set([feature for feature in alternative_image_features
868
                                      # features relevant in reconstructing coordinates:
869
                                      if (feature in ['deskewed', 'rotated-90',
870
                                                      'rotated-180', 'rotated-270'] and
871
                                          alternative_image_features.count(feature) > 1)]):
872
            log.error("Duplicate feature %s in AlternativeImage for segment '%s'",
873
                      duplicate_feature, segment.id)
874
        for i, feature in enumerate(alternative_image_features +
875
                                    (['rotated-%d' % orientation]
876
                                     if (orientation and
877
                                         not 'rotated-%d' % orientation in alternative_image_features and
878
                                         not 'rotated-%d' % orientation in feature_filter.split(','))
879
                                     else []) +
880
                                    (['deskewed']
881
                                     if (skew and
882
                                         not 'deskewed' in alternative_image_features and
883
                                         not 'deskewed' in feature_filter.split(','))
884
                                     else []) +
885
                                    # not a feature to be added, but merely as a fallback position
886
                                    # to always enter loop at i == len(alternative_image_features)
887
                                    ['_check']):
888
            # image geometry vs feature consistency can only be checked
889
            # after all features on the existing AlternativeImage have
890
            # been adjusted for in the transform, and when there is a mismatch,
891
            # additional steps applied here would only repeat the respective
892
            # error message; so we only check once at the boundary between
893
            # existing and new features
894
            # FIXME we should enforce consistency here (i.e. split into transposition
895
            #       and minimal rotation, rotation always reshapes, rescaling never happens)
896
            # FIXME: inconsistency currently unavoidable with line-level dewarping (which increases height)
897
            if (i == len(alternative_image_features) and
898
                not (segment_xywh['w'] - 2 < segment_image.width < segment_xywh['w'] + 2 and
899
                     segment_xywh['h'] - 2 < segment_image.height < segment_xywh['h'] + 2)):
900
                log.error('segment "%s" image (%s; %dx%d) has not been cropped properly (%dx%d)',
901
                          segment.id, segment_coords['features'],
902
                          segment_image.width, segment_image.height,
903
                          segment_xywh['w'], segment_xywh['h'])
904
            name = "%s for segment '%s'" % ("AlternativeImage" if best_image
905
                                            else "parent image", segment.id)
906
            # adjust transform to feature, and ensure feature is applied to image
907
            if feature == 'rotated-%d' % orientation:
908
                segment_image, segment_coords, segment_xywh = _reflect(
909
                    log, name, orientation, segment_image, segment_coords, segment_xywh)
910
            elif feature == 'deskewed':
911
                segment_image, segment_coords, segment_xywh = _rotate(
912
                    log, name, skew, segment, segment_image, segment_coords, segment_xywh,
913
                    fill=fill, transparency=transparency)
914
915
        # verify constraints again:
916
        if not all(feature in segment_coords['features']
917
                   for feature in feature_selector.split(',') if feature):
918
            raise Exception('Found no AlternativeImage that satisfies all requirements' +
919
                            'selector="%s" in segment "%s"' % (
920
                                feature_selector, segment.id))
921
        if any(feature in segment_coords['features']
922
               for feature in feature_filter.split(',') if feature):
923
            raise Exception('Found no AlternativeImage that satisfies all requirements ' +
924
                            'filter="%s" in segment "%s"' % (
925
                                feature_filter, segment.id))
926
        segment_image.format = 'PNG' # workaround for tesserocr#194
927
        return segment_image, segment_coords
928
929
    # pylint: disable=redefined-builtin
930
    def save_image_file(self, image,
931
                        file_id,
932
                        file_grp,
933
                        page_id=None,
934
                        mimetype='image/png',
935
                        force=False):
936
        """Store an image in the filesystem and reference it as new file in the METS.
937
938
        Args:
939
            image (PIL.Image): derived image to save
940
            file_id (string): `@ID` of the METS `file` to use
941
            file_grp (string): `@USE` of the METS `fileGrp` to use
942
        Keyword Args:
943
            page_id (string): `@ID` in the METS physical `structMap` to use
944
            mimetype (string): MIME type of the image format to serialize as
945
            force (boolean): whether to replace any existing `file` with that `@ID`
946
        
947
        Serialize the image into the filesystem, and add a `file` for it in the METS.
948
        Use a filename extension based on ``mimetype``.
949
950
        Returns:
951
            The (absolute) path of the created file.
952
        """
953
        log = getLogger('ocrd.workspace.save_image_file')
954
        if not force and self.overwrite_mode:
955
            force = True
956
        image_bytes = io.BytesIO()
957
        image.save(image_bytes, format=MIME_TO_PIL[mimetype])
958
        file_path = str(Path(file_grp, '%s%s' % (file_id, MIME_TO_EXT[mimetype])))
959
        out = self.add_file(
960
            ID=file_id,
961
            file_grp=file_grp,
962
            pageId=page_id,
963
            local_filename=file_path,
964
            mimetype=mimetype,
965
            content=image_bytes.getvalue(),
966
            force=force)
967
        log.info('created file ID: %s, file_grp: %s, path: %s',
968
                 file_id, file_grp, out.local_filename)
969
        return file_path
970
971
def _crop(log, name, segment, parent_image, parent_coords, op='cropped', **kwargs):
972
    segment_coords = parent_coords.copy()
973
    # get polygon outline of segment relative to parent image:
974
    segment_polygon = coordinates_of_segment(segment, parent_image, parent_coords)
975
    # get relative bounding box:
976
    segment_bbox = bbox_from_polygon(segment_polygon)
977
    # get size of the segment in the parent image after cropping
978
    # (i.e. possibly different from size before rotation at the parent, but
979
    #  also possibly different from size after rotation below/AlternativeImage):
980
    segment_xywh = xywh_from_bbox(*segment_bbox)
981
    # crop, if (still) necessary:
982
    if (not isinstance(segment, BorderType) or # always crop below page level
983
        not op in parent_coords['features']):
984
        if isinstance(segment, BorderType):
985
            log.info("Cropping %s", name)
986
            segment_coords['features'] += ',' + op
987
        # create a mask from the segment polygon:
988
        segment_image = image_from_polygon(parent_image, segment_polygon, **kwargs)
989
        # crop to bbox:
990
        segment_image = crop_image(segment_image, box=segment_bbox)
991
    else:
992
        segment_image = parent_image
993
    # subtract offset from parent in affine coordinate transform:
994
    # (consistent with image cropping)
995
    segment_coords['transform'] = shift_coordinates(
996
        parent_coords['transform'],
997
        np.array([-segment_bbox[0],
998
                  -segment_bbox[1]]))
999
    return segment_image, segment_coords, segment_xywh
1000
1001
def _reflect(log, name, orientation, segment_image, segment_coords, segment_xywh):
1002
    # Transpose in affine coordinate transform:
1003
    # (consistent with image transposition or AlternativeImage below)
1004
    transposition = {
1005
        90: Image.ROTATE_90,
1006
        180: Image.ROTATE_180,
1007
        270: Image.ROTATE_270
1008
    }.get(orientation) # no default
1009
    segment_coords['transform'] = transpose_coordinates(
1010
        segment_coords['transform'], transposition,
1011
        np.array([0.5 * segment_xywh['w'],
1012
                  0.5 * segment_xywh['h']]))
1013
    segment_xywh['w'], segment_xywh['h'] = adjust_canvas_to_transposition(
1014
        [segment_xywh['w'], segment_xywh['h']], transposition)
1015
    segment_coords['angle'] += orientation
1016
    # transpose, if (still) necessary:
1017
    if not 'rotated-%d' % orientation in segment_coords['features']:
1018
        log.info("Transposing %s by %d°", name, orientation)
1019
        segment_image = transpose_image(segment_image, transposition)
1020
        segment_coords['features'] += ',rotated-%d' % orientation
1021
    return segment_image, segment_coords, segment_xywh
1022
1023
def _rotate(log, name, skew, segment, segment_image, segment_coords, segment_xywh, **kwargs):
1024
    # Rotate around center in affine coordinate transform:
1025
    # (consistent with image rotation or AlternativeImage below)
1026
    segment_coords['transform'] = rotate_coordinates(
1027
        segment_coords['transform'], skew,
1028
        np.array([0.5 * segment_xywh['w'],
1029
                  0.5 * segment_xywh['h']]))
1030
    segment_xywh['w'], segment_xywh['h'] = adjust_canvas_to_rotation(
1031
        [segment_xywh['w'], segment_xywh['h']], skew)
1032
    segment_coords['angle'] += skew
1033
    # deskew, if (still) necessary:
1034
    if not 'deskewed' in segment_coords['features']:
1035
        log.info("Rotating %s by %.2f°", name, skew)
1036
        segment_image = rotate_image(segment_image, skew, **kwargs)
1037
        segment_coords['features'] += ',deskewed'
1038
        if (segment and
1039
            (not isinstance(segment, BorderType) or # always crop below page level
1040
             'cropped' in segment_coords['features'])):
1041
            # re-crop to new bbox (which may deviate
1042
            # if segment polygon was not a rectangle)
1043
            segment_image, segment_coords, segment_xywh = _crop(
1044
                log, name, segment, segment_image, segment_coords,
1045
                op='recropped', **kwargs)
1046
    elif (segment and
1047
          (not isinstance(segment, BorderType) or # always crop below page level
1048
           'cropped' in segment_coords['features'])):
1049
        # only shift coordinates as if re-cropping
1050
        _, segment_coords, segment_xywh = _crop(
1051
            log, name, segment, segment_image, segment_coords,
1052
            op='recropped', **kwargs)
1053
    return segment_image, segment_coords, segment_xywh
1054
1055
def _scale(log, name, factor, segment_image, segment_coords, segment_xywh, **kwargs):
1056
    # Resize linearly
1057
    segment_coords['transform'] = scale_coordinates(
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable scale_coordinates does not seem to be defined.
Loading history...
1058
        segment_coords['transform'], [factor, factor])
1059
    segment_coords['scale'] = segment_coords.setdefault('scale', 1.0) * factor
1060
    segment_xywh['w'] *= factor
1061
    segment_xywh['h'] *= factor
1062
    # resize, if (still) necessary
1063
    if not 'scaled' in segment_coords['features']:
1064
        log.info("Scaling %s by %.2f", name, factor)
1065
        segment_coords['features'] += ',scaled'
1066
        # FIXME: validate factor against PAGE-XML attributes
1067
        # FIXME: factor should become less precise due to rounding
1068
        segment_image = segment_image.resize((int(segment_image.width * factor),
1069
                                              int(segment_image.height * factor)),
1070
                                             # slowest, but highest quality:
1071
                                             Image.BICUBIC)
1072
    return segment_image, segment_coords, segment_xywh
1073