Completed
Pull Request — master (#409)
by Konstantin
02:45 queued 16s
created

ocrd.workspace.Workspace.reload_mets()   A

Complexity

Conditions 1

Size

Total Lines 5
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

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