Passed
Pull Request — master (#404)
by Konstantin
02:09
created

ocrd.workspace.Workspace.download_url()   A

Complexity

Conditions 1

Size

Total Lines 15
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 5
dl 0
loc 15
rs 10
c 0
b 0
f 0
cc 1
nop 3
1
import io
2
from os import makedirs, unlink
3
from pathlib import Path
4
5
import cv2
6
from PIL import Image
7
import numpy as np
8
from atomicwrites import atomic_write
9
from deprecated.sphinx import deprecated
10
11
from ocrd_models import OcrdMets, OcrdExif, OcrdFile
12
from ocrd_utils import (
13
    getLogger,
14
    image_from_polygon,
15
    coordinates_of_segment,
16
    adjust_canvas_to_rotation,
17
    adjust_canvas_to_transposition,
18
    shift_coordinates,
19
    rotate_coordinates,
20
    transpose_coordinates,
21
    crop_image,
22
    rotate_image,
23
    transpose_image,
24
    bbox_from_polygon,
25
    polygon_from_points,
26
    xywh_from_bbox,
27
    pushd_popd,
28
    MIME_TO_EXT,
29
)
30
31
from .workspace_backup import WorkspaceBackupManager
32
33
log = getLogger('ocrd.workspace')
34
35
36
class Workspace():
37
    """
38
    A workspace is a temporary directory set up for a processor. It's the
39
    interface to the METS/PAGE XML and delegates download and upload to the
40
    Resolver.
41
42
    Args:
43
44
        directory (string) : Folder to work in
45
        mets (:class:`OcrdMets`) : OcrdMets representing this workspace. Loaded from 'mets.xml' if ``None``.
46
        mets_basename (string) : Basename of the METS XML file. Default: Last URL segment of the mets_url.
47
        baseurl (string) : Base URL to prefix to relative URL.
48
    """
49
50
    def __init__(self, resolver, directory, mets=None, mets_basename='mets.xml', automatic_backup=False, baseurl=None):
51
        self.resolver = resolver
52
        self.directory = directory
53
        self.mets_target = str(Path(directory, mets_basename))
54
        if mets is None:
55
            mets = OcrdMets(filename=self.mets_target)
56
        self.mets = mets
57
        self.automatic_backup = automatic_backup
58
        self.baseurl = baseurl
59
        #  print(mets.to_xml(xmllint=True).decode('utf-8'))
60
61
    def __str__(self):
62
        return 'Workspace[directory=%s, baseurl=%s, file_groups=%s, files=%s]' % (
63
            self.directory,
64
            self.baseurl,
65
            self.mets.file_groups,
66
            [str(f) for f in self.mets.find_files()],
67
        )
68
69
    def reload_mets(self):
70
        """
71
        Reload METS from disk.
72
        """
73
        self.mets = OcrdMets(filename=self.mets_target)
74
75
76
    @deprecated(version='1.0.0', reason="Use workspace.download_file")
77
    def download_url(self, url, **kwargs):
78
        """
79
        Download a URL to the workspace.
80
81
        Args:
82
            url (string): URL to download to directory
83
            **kwargs : See :py:mod:`ocrd_models.ocrd_file.OcrdFile`
84
85
        Returns:
86
            The local filename of the downloaded file
87
        """
88
        f = OcrdFile(None, url=url, **kwargs)
89
        f = self.download_file(f)
90
        return f.local_filename
91
92
93
    def download_file(self, f, _recursion_count=0):
94
        """
95
        Download a :py:mod:`ocrd.model.ocrd_file.OcrdFile` to the workspace.
96
        """
97
        log.debug('download_file %s [_recursion_count=%s]' % (f, _recursion_count))
98
        with pushd_popd(self.directory):
99
            try:
100
                # If the f.url is already a file path, and is within self.directory, do nothing
101
                url_path = Path(f.url).resolve()
102
                if not (url_path.exists() and url_path.relative_to(str(Path(self.directory).resolve()))):
103
                    raise Exception("Not already downloaded, moving on")
104
            except Exception as e:
105
                basename = '%s%s' % (f.ID, MIME_TO_EXT.get(f.mimetype, '')) if f.ID else f.basename
106
                try:
107
                    f.url = self.resolver.download_to_directory(self.directory, f.url, subdir=f.fileGrp, basename=basename)
108
                except FileNotFoundError as e:
109
                    if not self.baseurl:
110
                        raise Exception("No baseurl defined by workspace. Cannot retrieve '%s'" % f.url)
111
                    if _recursion_count >= 1:
112
                        raise Exception("Already tried prepending baseurl '%s'. Cannot retrieve '%s'" % (self.baseurl, f.url))
113
                    log.debug("First run of resolver.download_to_directory(%s) failed, try prepending baseurl '%s': %s", f.url, self.baseurl, e)
114
                    f.url = '%s/%s' % (self.baseurl, f.url)
115
                    f.url = self.download_file(f, _recursion_count + 1).local_filename
116
            f.local_filename = f.url
117
            return f
118
119
    def remove_file(self, ID, force=False):
120
        """
121
        Remove a file from the workspace.
122
123
        Arguments:
124
            ID (string|OcrdFile): ID of the file to delete or the file itself
125
            force (boolean): Whether to delete from disk as well
126
        """
127
        log.debug('Deleting mets:file %s', ID)
128
        ocrd_file = self.mets.remove_file(ID)
129
        if force:
130
            if not ocrd_file:
131
                raise Exception("File '%s' not found" % ID)
132
            if not ocrd_file.local_filename:
133
                raise Exception("File not locally available %s" % ocrd_file)
134
            with pushd_popd(self.directory):
135
                log.info("rm %s [cwd=%s]", ocrd_file.local_filename, self.directory)
136
                unlink(ocrd_file.local_filename)
137
        return ocrd_file
138
139
    def remove_file_group(self, USE, recursive=False, force=False):
140
        """
141
        Remove a fileGrp.
142
143
        Arguments:
144
            USE (string): USE attribute of the fileGrp to delete
145
            recursive (boolean): Whether to recursively delete all files in the group
146
            force (boolean): When deleting recursively whether to delete files from HDD
147
        """
148
        if force and not recursive:
149
            raise Exception("remove_file_group: force without recursive is likely a logic error")
150
        if USE not in self.mets.file_groups:
151
            raise Exception("No such fileGrp: %s" % USE)
152
        if recursive:
153
            for f in self.mets.find_files(fileGrp=USE):
154
                self.remove_file(f.ID, force=force)
155
        self.mets.remove_file_group(USE)
156
157
    def add_file(self, file_grp, content=None, **kwargs):
158
        """
159
        Add an output file. Creates an :class:`OcrdFile` to pass around and adds that to the
160
        OcrdMets OUTPUT section.
161
        """
162
        log.debug(
163
            'outputfile file_grp=%s local_filename=%s content=%s',
164
            file_grp,
165
            kwargs.get('local_filename'),
166
            content is not None)
167
        if content is not None and 'local_filename' not in kwargs:
168
            raise Exception("'content' was set but no 'local_filename'")
169
170
        with pushd_popd(self.directory):
171
            if 'local_filename' in kwargs:
172
                # If the local filename has folder components, create those folders
173
                local_filename_dir = kwargs['local_filename'].rsplit('/', 1)[0]
174
                if local_filename_dir != kwargs['local_filename'] and not Path(local_filename_dir).is_dir():
175
                    makedirs(local_filename_dir)
176
                if 'url' not in kwargs:
177
                    kwargs['url'] = kwargs['local_filename']
178
179
            #  print(kwargs)
180
            ret = self.mets.add_file(file_grp, **kwargs)
181
182
            if content is not None:
183
                with open(kwargs['local_filename'], 'wb') as f:
184
                    if isinstance(content, str):
185
                        content = bytes(content, 'utf-8')
186
                    f.write(content)
187
188
        return ret
189
190
    def save_mets(self):
191
        """
192
        Write out the current state of the METS file.
193
        """
194
        log.info("Saving mets '%s'", self.mets_target)
195
        if self.automatic_backup:
196
            WorkspaceBackupManager(self).add()
197
        with atomic_write(self.mets_target, overwrite=True) as f:
198
            f.write(self.mets.to_xml(xmllint=True).decode('utf-8'))
199
200
    def resolve_image_exif(self, image_url):
201
        """
202
        Get the EXIF metadata about an image URL as :class:`OcrdExif`
203
204
        Args:
205
            image_url (string) : URL of image
206
207
        Return
208
            :class:`OcrdExif`
209
        """
210
        files = self.mets.find_files(url=image_url)
211
        f = files[0] if files else OcrdFile(None, url=image_url)
212
        image_filename = self.download_file(f).local_filename
213
        with Image.open(image_filename) as pil_img:
214
            ocrd_exif = OcrdExif(pil_img)
215
        return ocrd_exif
216
217
    @deprecated(version='1.0.0', reason="Use workspace.image_from_page and workspace.image_from_segment")
218
    def resolve_image_as_pil(self, image_url, coords=None):
219
        return self._resolve_image_as_pil(image_url, coords)
220
221
    def _resolve_image_as_pil(self, image_url, coords=None):
222
        """
223
        Resolve an image URL to a PIL image.
224
225
        Args:
226
            - coords (list) : Coordinates of the bounding box to cut from the image
227
228
        Returns:
229
            Image or region in image as PIL.Image
230
231
        """
232
        files = self.mets.find_files(url=image_url)
233
        f = files[0] if files else OcrdFile(None, url=image_url)
234
        image_filename = self.download_file(f).local_filename
235
236
        with pushd_popd(self.directory):
237
            pil_image = Image.open(image_filename)
238
239
        if coords is None:
240
            return pil_image
241
242
        log.debug("Converting PIL to OpenCV: %s", image_url)
243
        color_conversion = cv2.COLOR_GRAY2BGR if pil_image.mode in ('1', 'L') else  cv2.COLOR_RGB2BGR
244
        pil_as_np_array = np.array(pil_image).astype('uint8') if pil_image.mode == '1' else np.array(pil_image)
245
        cv2_image = cv2.cvtColor(pil_as_np_array, color_conversion)
246
247
        poly = np.array(coords, np.int32)
248
        log.debug("Cutting region %s from %s", coords, image_url)
249
        region_cut = cv2_image[
250
            np.min(poly[:, 1]):np.max(poly[:, 1]),
251
            np.min(poly[:, 0]):np.max(poly[:, 0])
252
        ]
253
        return Image.fromarray(region_cut)
254
255
    def image_from_page(self, page, page_id,
256
                        fill='background', transparency=False,
257
                        feature_selector='', feature_filter=''):
258
        """Extract an image for a PAGE-XML page from the workspace.
259
260
        Given ``page``, a PAGE PageType object, extract its PIL.Image,
261
        either from its AlternativeImage (if it exists), or from its
262
        @imageFilename (otherwise). Also crop it, if a Border exists,
263
        and rotate it, if any @orientation angle is annotated.
264
265
        If ``feature_selector`` and/or ``feature_filter`` is given, then
266
        select/filter among the @imageFilename image and the available
267
        AlternativeImages the last one which contains all of the selected,
268
        but none of the filtered features (i.e. @comments classes), or
269
        raise an error.
270
271
        (Required and produced features need not be in the same order, so
272
        ``feature_selector`` is merely a mask specifying Boolean AND, and
273
        ``feature_filter`` is merely a mask specifying Boolean OR.)
274
275
        If the chosen image does not have the feature "cropped" yet, but
276
        a Border exists, and unless "cropped" is being filtered, then crop it.
277
        Likewise, if the chosen image does not have the feature "deskewed" yet,
278
        but an @orientation angle is annotated, and unless "deskewed" is being
279
        filtered, then rotate it. (However, if @orientation is above the
280
        [-45°,45°] interval, then apply as much transposition as possible first,
281
        unless "rotated-90" / "rotated-180" / "rotated-270" is being filtered.)
282
283
        Cropping uses a polygon mask (not just the bounding box rectangle).
284
        Areas outside the polygon will be filled according to ``fill``:
285
286
        - if ``background`` (the default),
287
          then fill with the median color of the image;
288
        - otherwise, use the given color, e.g. ``white`` or (255,255,255).
289
290
        Moreover, if ``transparency`` is true, and unless the image already
291
        has an alpha channel, then add an alpha channel which is fully opaque
292
        before cropping and rotating. (Thus, only the exposed areas will be
293
        transparent afterwards, for those that can interpret alpha channels).
294
295
        Return a tuple:
296
297
         * the extracted image,
298
         * a dictionary with information about the extracted image:
299
300
           - ``transform``: a Numpy array with an affine transform which
301
             converts from absolute coordinates to those relative to the image,
302
             i.e. after cropping to the page's border / bounding box (if any)
303
             and deskewing with the page's orientation angle (if any)
304
           - ``angle``: the rotation/reflection angle applied to the image so far,
305
           - ``features``: the AlternativeImage @comments for the image, i.e.
306
             names of all operations that lead up to this result,
307
308
         * an OcrdExif instance associated with the original image.
309
310
        (The first two can be used to annotate a new AlternativeImage,
311
         or be passed down with ``image_from_segment``.)
312
313
        Example:
314
315
         * get a raw (colored) but already deskewed and cropped image:
316
317
           ``
318
           page_image, page_coords, page_image_info = workspace.image_from_page(
319
                 page, page_id,
320
                 feature_selector='deskewed,cropped',
321
                 feature_filter='binarized,grayscale_normalized')
322
           ``
323
        """
324
        page_image = self._resolve_image_as_pil(page.imageFilename)
325
        page_image_info = OcrdExif(page_image)
326
        # FIXME: remove PrintSpace here as soon as GT abides by the PAGE standard:
327
        border = page.get_Border() or page.get_PrintSpace()
328
        if (border and
329
            not 'cropped' in feature_filter.split(',')):
330
            page_points = border.get_Coords().points
331
            log.debug("Using explicitly set page border '%s' for page '%s'",
332
                      page_points, page_id)
333
            # get polygon outline of page border:
334
            page_polygon = np.array(polygon_from_points(page_points), dtype=np.int32)
335
            page_bbox = bbox_from_polygon(page_polygon)
336
            # subtract offset in affine coordinate transform:
337
            # (consistent with image cropping or AlternativeImage below)
338
            page_coords = {
339
                'transform': shift_coordinates(
340
                    np.eye(3),
341
                    np.array([-page_bbox[0],
342
                              -page_bbox[1]]))
343
            }
344
        else:
345
            page_bbox = [0, 0, page_image.width, page_image.height]
346
            # use identity as affine coordinate transform:
347
            page_coords = {
348
                'transform': np.eye(3)
349
            }
350
        # get size of the page after cropping but before rotation:
351
        page_xywh = xywh_from_bbox(*page_bbox)
352
        
353
        # page angle: PAGE @orientation is defined clockwise,
354
        # whereas PIL/ndimage rotation is in mathematical direction:
355
        page_coords['angle'] = -(page.get_orientation() or 0)
356
        # map angle from (-180,180] to [0,360], and partition into multiples of 90;
357
        # but avoid unnecessary large remainders, i.e. split symmetrically:
358
        orientation = (page_coords['angle'] + 45) % 360
359
        orientation = orientation - (orientation % 90)
360
        skew = (page_coords['angle'] % 360) - orientation
361
        skew = 180 - (180 - skew) % 360 # map to [-45,45]
362
        page_coords['angle'] = 0 # nothing applied yet (depends on filters)
363
        log.debug("page '%s' has orientation=%d skew=%.2f",
364
                  page_id, orientation, skew)
365
        
366 View Code Duplication
        if (orientation and
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated in your project.
Loading history...
367
            not 'rotated-%d' % orientation in feature_filter.split(',')):
368
            # Transpose in affine coordinate transform:
369
            # (consistent with image transposition or AlternativeImage below)
370
            transposition = { 90: Image.ROTATE_90,
371
                              180: Image.ROTATE_180,
372
                              270: Image.ROTATE_270
373
            }.get(orientation) # no default
374
            page_coords['transform'] = transpose_coordinates(
375
                page_coords['transform'],
376
                transposition,
377
                np.array([0.5 * page_xywh['w'],
378
                          0.5 * page_xywh['h']]))
379
            page_xywh['w'], page_xywh['h'] = adjust_canvas_to_transposition(
380
                [page_xywh['w'], page_xywh['h']], transposition)
381
            page_coords['angle'] = orientation
382
        if (skew and
383
            not 'deskewed' in feature_filter.split(',')):
384
            # Rotate around center in affine coordinate transform:
385
            # (consistent with image rotation or AlternativeImage below)
386
            page_coords['transform'] = rotate_coordinates(
387
                page_coords['transform'],
388
                skew,
389
                np.array([0.5 * page_xywh['w'],
390
                          0.5 * page_xywh['h']]))
391
            page_coords['angle'] += skew
392
            
393
        # initialize AlternativeImage@comments classes as empty:
394
        page_coords['features'] = ''
395
        
396
        alternative_image = None
397
        alternative_images = page.get_AlternativeImage()
398 View Code Duplication
        if alternative_images:
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated in your project.
Loading history...
399
            # (e.g. from page-level cropping, binarization, deskewing or despeckling)
400
            if feature_selector or feature_filter:
401
                alternative_image = None
402
                # search from the end, because by convention we always append,
403
                # and among multiple satisfactory images we want the most recent:
404
                for alternative_image in reversed(alternative_images):
405
                    features = alternative_image.get_comments()
406
                    if (all(feature in features
407
                            for feature in feature_selector.split(',') if feature) and
408
                        not any(feature in features
409
                                for feature in feature_filter.split(',') if feature)):
410
                        break
411
                    else:
412
                        alternative_image = None
413
            else:
414
                alternative_image = alternative_images[-1]
415
                features = alternative_image.get_comments()
416
            if alternative_image:
417
                log.debug("Using AlternativeImage %d (%s) for page '%s'",
418
                          alternative_images.index(alternative_image) + 1,
419
                          features, page_id)
0 ignored issues
show
introduced by
The variable features does not seem to be defined in case the for loop on line 404 is not entered. Are you sure this can never be the case?
Loading history...
420
                page_image = self._resolve_image_as_pil(alternative_image.get_filename())
421
                page_coords['features'] = features
422
        
423
        # crop, if (still) necessary:
424
        if (border and
425
            not 'cropped' in page_coords['features'] and
426
            not 'cropped' in feature_filter.split(',')):
427
            log.debug("Cropping %s for page '%s' to border", 
428
                      "AlternativeImage" if alternative_image else "image",
429
                      page_id)
430
            # create a mask from the page polygon:
431
            page_image = image_from_polygon(page_image, page_polygon,
0 ignored issues
show
introduced by
The variable page_polygon does not seem to be defined for all execution paths.
Loading history...
432
                                            fill=fill, transparency=transparency)
433
            # recrop into page rectangle:
434
            page_image = crop_image(page_image, box=page_bbox)
435
            page_coords['features'] += ',cropped'
436
        # transpose, if (still) necessary:
437
        if (orientation and
438
            not 'rotated-%d' % orientation in page_coords['features'] and
439
            not 'rotated-%d' % orientation in feature_filter.split(',')):
440
            log.info("Transposing %s for page '%s' by %d°",
441
                     "AlternativeImage" if alternative_image else
442
                     "image", page_id, orientation)
443
            page_image = transpose_image(page_image, {
444
                90: Image.ROTATE_90,
445
                180: Image.ROTATE_180,
446
                270: Image.ROTATE_270
447
            }.get(orientation)) # no default
448
            page_coords['features'] += ',rotated-%d' % orientation
449
        if (orientation and
450
            not 'rotated-%d' % orientation in feature_filter.split(',')):
451
            # FIXME we should enforce consistency here (i.e. split into transposition
452
            #       and minimal rotation)
453
            if not (page_image.width == page_xywh['w'] and
454
                    page_image.height == page_xywh['h']):
455
                log.error('page "%s" image (%s; %dx%d) has not been transposed properly (%dx%d) during rotation',
456
                          page_id, page_coords['features'],
457
                          page_image.width, page_image.height,
458
                          page_xywh['w'], page_xywh['h'])
459
        # deskew, if (still) necessary:
460
        if (skew and
461
            not 'deskewed' in page_coords['features'] and
462
            not 'deskewed' in feature_filter.split(',')):
463
            log.info("Rotating %s for page '%s' by %.2f°",
464
                     "AlternativeImage" if alternative_image else
465
                     "image", page_id, skew)
466
            page_image = rotate_image(page_image, skew,
467
                                      fill=fill, transparency=transparency)
468
            page_coords['features'] += ',deskewed'
469
        if (skew and
470
            not 'deskewed' in feature_filter.split(',')):
471
            w_new, h_new = adjust_canvas_to_rotation(
472
                [page_xywh['w'], page_xywh['h']], skew)
473
            # FIXME we should enforce consistency here (i.e. rotation always reshapes,
474
            #       and rescaling never happens)
475
            if not (w_new - 2 < page_image.width < w_new + 2 and
476
                    h_new - 2 < page_image.height < h_new + 2):
477
                log.error('page "%s" image (%s; %dx%d) has not been reshaped properly (%dx%d) during rotation',
478
                          page_id, page_coords['features'],
479
                          page_image.width, page_image.height,
480
                          w_new, h_new)
481
        
482
        # verify constraints again:
483
        if not all(feature in page_coords['features']
484
                   for feature in feature_selector.split(',') if feature):
485
            raise Exception('Found no AlternativeImage that satisfies all requirements ' +
486
                            'selector="%s" in page "%s"' % (
487
                                feature_selector, page_id))
488
        if any(feature in page_coords['features']
489
               for feature in feature_filter.split(',') if feature):
490
            raise Exception('Found no AlternativeImage that satisfies all requirements ' +
491
                            'filter="%s" in page "%s"' % (
492
                                feature_filter, page_id))
493
        page_image.format = 'PNG' # workaround for tesserocr#194
494
        return page_image, page_coords, page_image_info
495
496
    def image_from_segment(self, segment, parent_image, parent_coords,
497
                           fill='background', transparency=False,
498
                           feature_selector='', feature_filter=''):
499
        """Extract an image for a PAGE-XML hierarchy segment from its parent's image.
500
501
        Given...
502
503
         * ``parent_image``, a PIL.Image of the parent, with
504
         * ``parent_coords``, a dict with information about ``parent_image``:
505
           - ``transform``: a Numpy array with an affine transform which
506
             converts from absolute coordinates to those relative to the image,
507
             i.e. after applying all operations (starting with the original image)
508
           - ``angle``: the rotation/reflection angle applied to the image so far,
509
           - ``features``: the AlternativeImage @comments for the image, i.e.
510
             names of all operations that lead up to this result, and
511
         * ``segment``, a PAGE segment object logically contained in it
512
           (i.e. TextRegionType / TextLineType / WordType / GlyphType),
513
514
        ...extract the segment's corresponding PIL.Image, either from
515
        AlternativeImage (if it exists), or producing a new image via
516
        cropping from ``parent_image`` (otherwise).
517
518
        If ``feature_selector`` and/or ``feature_filter`` is given, then
519
        select/filter among the cropped ``parent_image`` and the available
520
        AlternativeImages the last one which contains all of the selected,
521
        but none of the filtered features (i.e. @comments classes), or
522
        raise an error.
523
524
        (Required and produced features need not be in the same order, so
525
        ``feature_selector`` is merely a mask specifying Boolean AND, and
526
        ``feature_filter`` is merely a mask specifying Boolean OR.)
527
528
        Cropping uses a polygon mask (not just the bounding box rectangle).
529
        Areas outside the polygon will be filled according to ``fill``:
530
531
        - if ``background`` (the default),
532
          then fill with the median color of the image;
533
        - otherwise, use the given color, e.g. ``white`` or (255,255,255).
534
535
        Moreover, if ``transparency`` is true, and unless the image already
536
        has an alpha channel, then add an alpha channel which is fully opaque
537
        before cropping and rotating. (Thus, only the exposed areas will be
538
        transparent afterwards, for those that can interpret alpha channels).
539
540
        When cropping, compensate any @orientation angle annotated for the
541
        parent (from parent-level deskewing) by rotating the segment coordinates
542
        in an inverse transformation (i.e. translation to center, then passive
543
        rotation, and translation back).
544
545
        Regardless, if any @orientation angle is annotated for the segment
546
        (from segment-level deskewing), and the chosen image does not have
547
        the feature "deskewed" yet, and unless "deskewed" is being filtered,
548
        then rotate it - compensating for any previous ``angle``. (However,
549
        if @orientation is above the [-45°,45°] interval, then apply as much
550
        transposition as possible first, unless "rotated-90" / "rotated-180" /
551
        "rotated-270" is being filtered.)
552
553
        Return a tuple:
554
555
         * the extracted image,
556
         * a dictionary with information about the extracted image:
557
           - ``transform``: a Numpy array with an affine transform which
558
             converts from absolute coordinates to those relative to the image,
559
             i.e. after applying all parent operations, and then cropping to
560
             the segment's bounding box, and deskewing with the segment's
561
             orientation angle (if any)
562
           - ``angle``: the rotation/reflection angle applied to the image so far,
563
           - ``features``: the AlternativeImage @comments for the image, i.e.
564
             names of all operations that lead up to this result.
565
566
        (These can be used to create a new AlternativeImage, or passed down
567
         for calls on lower hierarchy levels.)
568
569
        Example:
570
571
         * get a raw (colored) but already deskewed and cropped image:
572
573
           ``image, xywh = workspace.image_from_segment(region,
574
                 page_image, page_xywh,
575
                 feature_selector='deskewed,cropped',
576
                 feature_filter='binarized,grayscale_normalized')``
577
        """
578
        # note: We should mask overlapping neighbouring segments here,
579
        # but finding the right clipping rules can be difficult if operating
580
        # on the raw (non-binary) image data alone: for each intersection, it
581
        # must be decided which one of either segment or neighbour to assign,
582
        # e.g. an ImageRegion which properly contains our TextRegion should be
583
        # completely ignored, but an ImageRegion which is properly contained
584
        # in our TextRegion should be completely masked, while partial overlap
585
        # may be more difficult to decide. On the other hand, on the binary image,
586
        # we can use connected component analysis to mask foreground areas which
587
        # originate in the neighbouring regions. But that would introduce either
588
        # the assumption that the input has already been binarized, or a dependency
589
        # on some ad-hoc binarization method. Thus, it is preferable to use
590
        # a dedicated processor for this (which produces clipped AlternativeImage
591
        # or reduced polygon coordinates).
592
        
593
        # get polygon outline of segment relative to parent image:
594
        segment_polygon = coordinates_of_segment(segment, parent_image, parent_coords)
595
        # get relative bounding box:
596
        segment_bbox = bbox_from_polygon(segment_polygon)
597
        # get size of the segment in the parent image after cropping
598
        # (i.e. possibly different from size before rotation at the parent, but
599
        #  also possibly different from size after rotation below/AlternativeImage):
600
        segment_xywh = xywh_from_bbox(*segment_bbox)
601
        # create a mask from the segment polygon:
602
        segment_image = image_from_polygon(parent_image, segment_polygon,
603
                                           fill=fill, transparency=transparency)
604
        # recrop into segment rectangle:
605
        segment_image = crop_image(segment_image, box=segment_bbox)
606
        # subtract offset from parent in affine coordinate transform:
607
        # (consistent with image cropping)
608
        segment_coords = {
609
            'transform': shift_coordinates(
610
                parent_coords['transform'],
611
                np.array([-segment_bbox[0],
612
                          -segment_bbox[1]]))
613
        }
614
        
615
        if 'orientation' in segment.__dict__:
616
            # region angle: PAGE @orientation is defined clockwise,
617
            # whereas PIL/ndimage rotation is in mathematical direction:
618
            segment_coords['angle'] = -(segment.get_orientation() or 0)
619
        else:
620
            segment_coords['angle'] = 0
621
        if segment_coords['angle']:
622
            # @orientation is always absolute; if higher levels
623
            # have already rotated, then we must compensate:
624
            angle = segment_coords['angle'] - parent_coords['angle']
625
            # map angle from (-180,180] to [0,360], and partition into multiples of 90;
626
            # but avoid unnecessary large remainders, i.e. split symmetrically:
627
            orientation = (angle + 45) % 360
628
            orientation = orientation - (orientation % 90)
629
            skew = (angle % 360) - orientation
630
            skew = 180 - (180 - skew) % 360 # map to [-45,45]
631
            log.debug("segment '%s' has orientation=%d skew=%.2f",
632
                      segment.id, orientation, skew)
633
        else:
634
            orientation = 0
635
            skew = 0
636
        segment_coords['angle'] = parent_coords['angle'] # nothing applied yet (depends on filters)
637
638 View Code Duplication
        if (orientation and
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated in your project.
Loading history...
639
            not 'rotated-%d' % orientation in feature_filter.split(',')):
640
            # Transpose in affine coordinate transform:
641
            # (consistent with image transposition or AlternativeImage below)
642
            transposition = { 90: Image.ROTATE_90,
643
                              180: Image.ROTATE_180,
644
                              270: Image.ROTATE_270
645
            }.get(orientation) # no default
646
            segment_coords['transform'] = transpose_coordinates(
647
                segment_coords['transform'],
648
                transposition,
649
                np.array([0.5 * segment_xywh['w'],
650
                          0.5 * segment_xywh['h']]))
651
            segment_xywh['w'], segment_xywh['h'] = adjust_canvas_to_transposition(
652
                [segment_xywh['w'], segment_xywh['h']], transposition)
653
            segment_coords['angle'] += orientation
654
        if (skew and
655
            not 'deskewed' in feature_filter.split(',')):
656
            # Rotate around center in affine coordinate transform:
657
            # (consistent with image rotation or AlternativeImage below)
658
            segment_coords['transform'] = rotate_coordinates(
659
                segment_coords['transform'],
660
                skew,
661
                np.array([0.5 * segment_xywh['w'],
662
                          0.5 * segment_xywh['h']]))
663
            segment_coords['angle'] += skew
664
            
665
        # initialize AlternativeImage@comments classes from parent, except
666
        # for those operations that can apply on multiple hierarchy levels:
667
        segment_coords['features'] = ','.join(
668
            [feature for feature in parent_coords['features'].split(',')
669
             if feature in ['binarized', 'grayscale_normalized',
670
                            'despeckled', 'dewarped']])
671
        
672
        alternative_image = None
673
        alternative_images = segment.get_AlternativeImage()
674 View Code Duplication
        if alternative_images:
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated in your project.
Loading history...
675
            # (e.g. from segment-level cropping, binarization, deskewing or despeckling)
676
            if feature_selector or feature_filter:
677
                alternative_image = None
678
                # search from the end, because by convention we always append,
679
                # and among multiple satisfactory images we want the most recent:
680
                for alternative_image in reversed(alternative_images):
681
                    features = alternative_image.get_comments()
682
                    if (all(feature in features
683
                            for feature in feature_selector.split(',') if feature) and
684
                        not any(feature in features
685
                                for feature in feature_filter.split(',') if feature)):
686
                        break
687
                    else:
688
                        alternative_image = None
689
            else:
690
                alternative_image = alternative_images[-1]
691
                features = alternative_image.get_comments()
692
            if alternative_image:
693
                log.debug("Using AlternativeImage %d (%s) for segment '%s'",
694
                          alternative_images.index(alternative_image) + 1,
695
                          features, segment.id)
0 ignored issues
show
introduced by
The variable features does not seem to be defined in case the for loop on line 680 is not entered. Are you sure this can never be the case?
Loading history...
696
                segment_image = self._resolve_image_as_pil(alternative_image.get_filename())
697
                segment_coords['features'] = features
698
        # transpose, if (still) necessary:
699
        if (orientation and
700
            not 'rotated-%d' % orientation in segment_coords['features'] and
701
            not 'rotated-%d' % orientation in feature_filter.split(',')):
702
            log.info("Transposing %s for segment '%s' by %d°",
703
                     "AlternativeImage" if alternative_image else
704
                     "image", segment.id, orientation)
705
            segment_image = transpose_image(segment_image, {
706
                90: Image.ROTATE_90,
707
                180: Image.ROTATE_180,
708
                270: Image.ROTATE_270
709
            }.get(orientation)) # no default
710
            segment_coords['features'] += ',rotated-%d' % orientation
711
        if (orientation and
712
            not 'rotated-%d' % orientation in feature_filter.split(',')):
713
            # FIXME we should enforce consistency here (i.e. split into transposition
714
            #       and minimal rotation)
715
            if not (segment_image.width == segment_xywh['w'] and
716
                    segment_image.height == segment_xywh['h']):
717
                log.error('segment "%s" image (%s; %dx%d) has not been transposed properly (%dx%d) during rotation',
718
                          segment.id, segment_coords['features'],
719
                          segment_image.width, segment_image.height,
720
                          segment_xywh['w'], segment_xywh['h'])
721
        # deskew, if (still) necessary:
722
        if (skew and
723
            not 'deskewed' in segment_coords['features'] and
724
            not 'deskewed' in feature_filter.split(',')):
725
            log.info("Rotating %s for segment '%s' by %.2f°",
726
                     "AlternativeImage" if alternative_image else
727
                     "image", segment.id, skew)
728
            segment_image = rotate_image(segment_image, skew,
729
                                         fill=fill, transparency=transparency)
730
            segment_coords['features'] += ',deskewed'
731
        if (skew and
732
            not 'deskewed' in feature_filter.split(',')):
733
            # FIXME we should enforce consistency here (i.e. rotation always reshapes,
734
            #       and rescaling never happens)
735
            w_new, h_new = adjust_canvas_to_rotation(
736
                [segment_xywh['w'], segment_xywh['h']], skew)
737
            if not (w_new - 2 < segment_image.width < w_new + 2 and
738
                    h_new - 2 < segment_image.height < h_new + 2):
739
                log.error('segment "%s" image (%s; %dx%d) has not been reshaped properly (%dx%d) during rotation',
740
                          segment.id, segment_coords['features'],
741
                          segment_image.width, segment_image.height,
742
                          w_new, h_new)
743
        else:
744
            # FIXME: currently unavoidable with line-level dewarping (which increases height)
745
            if not (segment_xywh['w'] - 2 < segment_image.width < segment_xywh['w'] + 2 and
746
                    segment_xywh['h'] - 2 < segment_image.height < segment_xywh['h'] + 2):
747
                log.error('segment "%s" image (%s; %dx%d) has not been cropped properly (%dx%d)',
748
                          segment.id, segment_coords['features'],
749
                          segment_image.width, segment_image.height,
750
                          segment_xywh['w'], segment_xywh['h'])
751
            
752
        # verify constraints again:
753
        if not all(feature in segment_coords['features']
754
                   for feature in feature_selector.split(',') if feature):
755
            raise Exception('Found no AlternativeImage that satisfies all requirements' +
756
                            'selector="%s" in segment "%s"' % (
757
                                feature_selector, segment.id))
758
        if any(feature in segment_coords['features']
759
               for feature in feature_filter.split(',') if feature):
760
            raise Exception('Found no AlternativeImage that satisfies all requirements ' +
761
                            'filter="%s" in segment "%s"' % (
762
                                feature_filter, segment.id))
763
        segment_image.format = 'PNG' # workaround for tesserocr#194
764
        return segment_image, segment_coords
765
766
    # pylint: disable=redefined-builtin
767
    def save_image_file(self, image,
768
                        file_id,
769
                        file_grp,
770
                        page_id=None,
771
                        format='PNG',
772
                        force=True):
773
        """Store and reference an image as file into the workspace.
774
775
        Given a PIL.Image `image`, and an ID `file_id` to use in METS,
776
        store the image under the fileGrp `file_grp` and physical page
777
        `page_id` into the workspace (in a file name based on
778
        the `file_grp`, `file_id` and `format` extension).
779
780
        Return the (absolute) path of the created file.
781
        """
782
        image_bytes = io.BytesIO()
783
        image.save(image_bytes, format=format)
784
        file_path = str(Path(file_grp, '%s.%s' % (file_id, format.lower())))
785
        out = self.add_file(
786
            ID=file_id,
787
            file_grp=file_grp,
788
            pageId=page_id,
789
            local_filename=file_path,
790
            mimetype='image/' + format.lower(),
791
            content=image_bytes.getvalue(),
792
            force=force)
793
        log.info('created file ID: %s, file_grp: %s, path: %s',
794
                 file_id, file_grp, out.local_filename)
795
        return file_path
796