Passed
Push — master ( 1be2dd...d2a25b )
by Konstantin
41s queued 11s
created

ocrd.workspace.Workspace.remove_file()   C

Complexity

Conditions 9

Size

Total Lines 27
Code Lines 18

Duplication

Lines 0
Ratio 0 %

Importance

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