Passed
Push — master ( 6d359e...f9c2b6 )
by Konstantin
02:35
created

ocrd.workspace   F

Complexity

Total Complexity 174

Size/Duplication

Total Lines 1096
Duplicated Lines 5.29 %

Importance

Changes 0
Metric Value
wmc 174
eloc 605
dl 58
loc 1096
rs 1.995
c 0
b 0
f 0

17 Methods

Rating   Name   Duplication   Size   Complexity  
A Workspace.reload_mets() 0 5 1
A Workspace.download_url() 0 16 1
B Workspace.merge() 0 22 6
C Workspace.download_file() 0 26 9
A Workspace.__str__() 0 6 1
A Workspace.__init__() 0 10 2
F Workspace.remove_file() 0 53 18
F Workspace.rename_file_group() 0 67 17
F Workspace.remove_file_group() 0 41 17
D Workspace.add_file() 0 45 13
A Workspace.save_image_file() 0 40 3
D Workspace._resolve_image_as_pil() 0 65 13
F Workspace.image_from_segment() 29 241 22
A Workspace.resolve_image_exif() 0 21 4
A Workspace.resolve_image_as_pil() 0 15 1
F Workspace.image_from_page() 29 213 23
A Workspace.save_mets() 0 10 3

5 Functions

Rating   Name   Duplication   Size   Complexity  
A download_temporary_file() 0 6 3
A _scale() 0 18 2
B _crop() 0 31 5
B _rotate() 0 35 8
A _reflect() 0 21 2

How to fix   Duplicated Code    Complexity   

Duplicated Code

Duplicate code is one of the most pungent code smells. A rule that is often used is to re-structure code once it is duplicated in three or more places.

Common duplication problems, and corresponding solutions are:

Complexity

 Tip:   Before tackling complexity, make sure that you eliminate any duplication first. This often can reduce the size of classes significantly.

Complex classes like ocrd.workspace often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

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