File size: 23,956 Bytes
8931de0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
from tkinter import image_types
import numpy as np
import kornia
import torch
import torchvision.transforms.functional as TF

from torch import Tensor
from torchvision import transforms
from collections import OrderedDict


def SceneClassificationToColorTransform(scene_classification):
    """ Converts scene classification labels to a color labels.
    See: https://www.sentinel-hub.com/faq/how-get-s2a-scene-classification-sentinel-2/

    Mapping from scene classification to color:
    0:  No Data -> black
    1:  Saturated / Defective -> red
    2:  Dark Area Pixels -> gray
    3:  Cloud Shadows -> brown
    4:  Vegetation -> green
    5:  Bare Soils -> yellow
    6:  Water -> blue
    7:  Clouds low probability / Unclassified -> medium gray
    8:  Clouds medium probability -> light gray
    9:  Clouds high probability -> very light gray
    10: Cirrus -> light blue/purple
    11: Snow / Ice -> cyan

    Parameters
    ----------
    scene_classification : Tensor
        Scene classification labels.

    Returns
    -------
    Tensor
        Color scene classification labels.
    """

    """ TODO: Don't have to use OrderedDict if Python >= 3.7.
    Since 3.6 Python respects insertion order and with 3.7 it is guaranteed.
    The environment and the dependencies do require >= 3.6 anyways...
    
    See: 
    https://docs.python.org/2/library/stdtypes.html#dict.items
    https://docs.python.org/3.7/library/stdtypes.html#dict
    """

    CLASSES = OrderedDict(
        {
            "No Data": 0,
            "Saturated / Defective": 1,
            "Dark Area Pixels": 2,
            "Cloud Shadows": 3,
            "Vegetation": 4,
            "Bare Soils": 5,
            "Water": 6,
            "Clouds low probability / Unclassified": 7,
            "Clouds medium probability": 8,
            "Clouds high probability": 9,
            "Cirrus": 10,
            "Snow / Ice": 11,
        }
    )

    COLORS = OrderedDict(
        {
            "black": [0, 0, 0],
            "red": [1, 0, 0.016],
            "gray": [0.525, 0.525, 0.525],
            "brown": [0.467, 0.298, 0.043],
            "green": [0.063, 0.827, 0.176],
            "yellow": [1, 1, 0.325],
            "blue": [0.0, 0.0, 1.0],
            "medium gray": [0.506, 0.506, 0.506],
            "light gray": [0.753, 0.753, 0.753],
            "very light gray": [0.949, 0.949, 0.949],
            "light blue/purple": [0.733, 0.773, 0.925],
            "cyan": [0.325, 1, 0.980],
        }
    )
    color_scene_classification = torch.tensor(0)
    # Scene classification has multiple revisits: (revisits, 1, height, width)
    if scene_classification.ndim == 4:

        # Runs the function for each revisit.
        # Output shape: (revisits, 3 [RGB], height, width)
        color_scene_classification = torch.stack(
            [SceneClassificationToColorTransform(x[0]) for x in scene_classification]
        )

    # Scene classification has only a single revisit: (1, height, width)
    elif scene_classification.ndim == 3:
        # Output shape: (3 [RGB], height, width)
        color_scene_classification = scene_classification[0]
        height, width = scene_classification.shape
        color_scene_classification = torch.zeros((3, height, width))

        for scene_class, color in zip(CLASSES.values(), COLORS.values()):
            color_scene_classification[
                :, scene_classification == scene_class
            ] = torch.Tensor([color]).T

    return color_scene_classification


class NormalizeInverse(transforms.Normalize):
    """ Inverse of Normalize.
    Undoes the normalization and returns the reconstructed images in the input domain.
    Source: https://discuss.pytorch.org/t/simple-way-to-inverse-transform-normalization/4821/8
    """

    def __init__(self, mean, std):
        """ Initializes the NormalizeInverse class.

        Parameters
        ----------
        mean : Tensor
            Mean of the normalization.
        std : Tensor
            Standard deviation of the normalization.
        """
        std_inverse = 1 / (std + 1e-7)
        mean_inverse = -mean * std_inverse
        super().__init__(mean=mean_inverse, std=std_inverse)

    def __call__(self, tensor):
        """ Applies the inverse normalization.

        Parameters
        ----------
        tensor : Tensor
            Tensor to be un-normalized.

        Returns
        -------
        Tensor
            Un-normalized tensor.
        """
        return super().__call__(tensor.clone())


class RandomShuffleRevisitsDict:
    """ Randomly shuffles the revisits of a dictionary. """

    def __init__(self, src="lr"):
        """ Initializes the RandomShuffleRevisitsDict class.

        Parameters
        ----------
        src : str, optional
            Source of the dictionary, by default 'lr'.
        """
        self.src = src

    # Disable gradients for effiency.
    @torch.no_grad()
    def __call__(self, item):
        """ Applies the RandomShuffleRevisitsDict class.

        Parameters
        ----------
        item : dict of Tensor
            Dictionary with the data to be shuffled.

        Returns
        -------
        dict of Tensor
            Shuffled dictionary.
        """
        number_of_revisits = item[self.src].shape[-4]
        random_shuffle_ix = torch.randperm(number_of_revisits)
        for image_type, revisit in item.items():
            if image_type != "hr":
                item[image_type] = revisit[random_shuffle_ix].clone()
        return item


class CropDict:
    """ Crops the dictionary items, while keeping the cropping proportional
    to the original/source image. """

    def __init__(self, start_x, start_y, end_x, end_y, src="lr"):
        """ Initializes the CropDict class.

        Parameters
        ----------
        start_x : int
            Starting x-coordinate of the crop.
        start_y : int
            Starting y-coordinate of the crop.
        end_x : int
            Ending x-coordinate of the crop.
        end_y : int
            Ending y-coordinate of the crop.
        src : str, optional
            Source of the original/source image, by default 'lr'.
        """

        self.start_x = start_x
        self.start_y = start_y
        self.end_x = end_x
        self.end_y = end_y
        self.src = src

    def crop(self, item):
        """ Applies the crop to the dictionary item.

        Parameters
        ----------
        item : union of dict of Tensor
            Dictionary item to be cropped.

        Returns
        -------
        union of dict of Tensor
            Cropped dictionary item.
        """
        start_x, start_y, end_x, end_y = (
            self.start_x,
            self.start_y,
            self.end_x,
            self.end_y,
        )
        if isinstance(item, dict):
            source_img = item[self.src]
            source_height, source_width = source_img.shape[-2:]
            for image_type, image in item.items():
                if isinstance(image, Tensor):
                    image_height, image_width = image.shape[-2:]

                    ratio_height, ratio_width = (
                        image_height / source_height,
                        image_width / source_width,
                    )

                    crop_start_x = round(start_x * ratio_width)
                    crop_start_y = round(start_y * ratio_height)
                    crop_end_x = round(end_x * ratio_width)
                    crop_end_y = round(end_y * ratio_height)

                    item[image_type] = TF.crop(
                        img=image,
                        top=crop_start_y,
                        left=crop_start_x,
                        height=crop_end_x,
                        width=crop_end_y,
                    ).clone()

        elif isinstance(item, Tensor):
            item = TF.crop(item, start_y, start_x, end_y, end_x).clone()
        return item

    # Disable gradients for effiency.
    @torch.no_grad()
    def __call__(self, item):
        """ Applies the CropDict class.

        Parameters
        ----------
        item : dict
            Dictionary with the data to be cropped.

        Returns
        -------
        dict
            Cropped dictionary.
        """
        return self.crop(item)


class RandomCropDict:
    """ Randomly crops the dictionary items, while keeping the cropping 
    proportional to the original/source image. """

    def __init__(self, src, size, batched=False):
        """ Initializes the RandomCropDict class.

        Parameters
        ----------
        src : str
            Source of the original/source image.
        size : tuple of int
            Size of the crop.
        batched : bool, optional
            Whether the data is batched, by default False.
        """
        self.src = src
        self.size = size
        self.batched = batched

    def random_crop(self, item):
        """ Applies the random crop to the dictionary item.

        Parameters
        ----------
        item : union of Tensor and dict
            Dictionary item to be cropped.

        Returns
        -------
        union of Tensor and dict
            Cropped dictionary item.
        """
        if isinstance(item, dict):
            source_image = item[self.src]
            height, width = source_image.shape[-2:]
            random_crop = transforms.RandomCrop.get_params(
                source_image, output_size=self.size
            )

            source_crop_start_y, source_crop_start_x = random_crop[:2]
            source_crop_end_y, source_crop_end_x = random_crop[2:]

            for image_type, image in item.items():
                if isinstance(image, Tensor):

                    if self.batched and image.ndim == 5:
                        batch_size, revisits = image.shape[:2]
                        image = image.flatten(start_dim=0, end_dim=1)

                    image_height, image_width = image.shape[-2:]
                    ratio_height, ratio_width = (
                        image_height / height,
                        image_width / width,
                    )

                    crop_start_y = round(source_crop_start_y * ratio_height)
                    crop_start_x = round(source_crop_start_x * ratio_width)
                    crop_end_y = round(source_crop_end_y * ratio_height)
                    crop_end_x = round(source_crop_end_x * ratio_width)

                    item[image_type] = TF.crop(
                        img=image,
                        top=crop_start_y,
                        left=crop_start_x,
                        height=crop_end_x,
                        width=crop_end_y,
                    ).clone()

                    if self.batched:
                        item[image_type] = item[image_type].unflatten(
                            dim=0, sizes=(batch_size, revisits)
                        )

        elif isinstance(item, Tensor):
            random_crop = transforms.RandomCrop.get_params(
                source_image, output_size=self.size
            )

            source_crop_start_y, source_crop_start_x = random_crop[:2]
            source_crop_end_y, source_crop_end_x = random_crop[2:]

            item[image_type] = TF.crop(
                img=item,
                top=crop_start_y,
                left=crop_start_x,
                height=crop_end_x,
                width=crop_end_y,
            ).clone()

        return item

    @torch.no_grad()
    def __call__(self, item: dict) -> dict:
        """ Applies the RandomCropDict class.

        Parameters
        ----------
        item : dict
            Dictionary with the data to be cropped.

        Returns
        -------
        dict
            Cropped dictionary.
        """
        return self.random_crop(item)


class RandomRotateFlipDict:
    """ Randomly rotates by one of the given angles 
    and flips the dictionary items horizontally or vertically. """

    def __init__(self, angles, batched: bool = False):
        """ Initializes the RandomRotateFlipDict class.

        Parameters
        ----------
        angles : list of int
            List of angles to be rotated by.
        batched : bool, optional
            Whether the data is batched, by default False.
        """
        self.angles = angles
        self.batched = batched

    # Disable gradients for efficiency.
    @torch.no_grad()
    def __call__(self, item):
        """ Applies the RandomRotateFlipDict class.

        Parameters
        ----------
        item : dict
            Dictionary with the data to be rotated and flipped.

        Returns
        -------
        dict
            Rotated and flipped dictionary.
        """
        random_angle_index = torch.randint(4, size=(1,)).item()
        angle = self.angles[random_angle_index]
        flip = torch.randint(2, size=(1,)).item()
        if isinstance(item, dict):
            for image_type, image in item.items():
                if isinstance(image, Tensor):
                    if self.batched and image.ndim == 5:
                        batch_size, revisits = image.shape[:2]
                        image = image.flatten(start_dim=0, end_dim=1)
                    item[image_type] = TF.rotate(image, angle)
                    if flip:
                        item[image_type] = TF.vflip(item[image_type])
                    if self.batched:
                        item[image_type] = item[image_type].unflatten(
                            dim=0, sizes=(batch_size, revisits)
                        )

        elif isinstance(item, Tensor):
            item = TF.rotate(item, angle)
            if flip:
                item = TF.vflip(item)
        return item


# TODO: infill bad parts from neigboring days.
class FilterData:
    """ 
    Removes revisits that exceed a given threshold of pixels of the following
    categories in a cloud mask, either of which can be selected:

    0: No Data -> black
    1: Saturated / Defective -> red
    2: Dark Area Pixels -> gray
    3: Cloud Shadows -> brown
    4: Vegetation -> green
    5: Bare Soils -> yellow
    6: Water -> blue
    7: Clouds low probability / Unclassified -> medium gray
    8: Clouds medium probability -> light gray
    9: Clouds high probability -> very light gray
    10: Cirrus -> light blue/purple
    11: Snow / Ice -> cyan

    - fill=='replace' replaces a "bad" revisit with a "nice" one;
    - fill=='zero' zeroes out a "bad" revisit; else, it is removed.
    """

    def __init__(self, thres=0.4, values=(0,), fill="replace", batched=False):
        """ Initializes the FilterData class.

        Parameters
        ----------
        thres : float, optional
            Threshold of pixels a revisit has to exceed, by default .4.
        values : tuple of int, optional
            Categories to be filtered, by default (0,).
        fill : str, optional
            Whether to replace or zero out a "bad" revisit, by default 'replace'.
        batched : bool, optional
            Whether the data is batched, by default False.
        """

        self.threshold = thres
        self.fill = fill
        self.values = Tensor(values)
        self.batched = batched
        if batched:
            # Hacky but simple
            self.values = self.values.to("cuda")

    def remove_no_data(self, item):
        low_res, low_res_classification = item["lr"], item["lrc"]

        # If data is batched:
        if self.batched and low_res_classification.ndim == 5:
            batch_size, revisits = low_res_classification.shape[:2]
            low_res = low_res.flatten(start_dim=0, end_dim=1)
            low_res_classification = low_res_classification.flatten(
                start_dim=0, end_dim=1
            )

        if low_res_classification.shape[-3] > 1:  # one-hot to index
            fraction_without_data = (
                low_res_classification[:, self.values.long()]
                .any(-3)
                .float()
                .mean(dim=(-1, -2))
            )

        else:
            fraction_without_data = (
                (low_res_classification[..., None] == self.values)
                .any(-1)
                .float()
                .mean(dim=(1, 2, 3))
            )
        good_revisits_ix = fraction_without_data < self.threshold

        # If there is at least one bad revisit.
        if sum(~good_revisits_ix) > 0:
            # Replace it with zero.
            if self.fill == "zero":
                low_res[~good_revisits_ix] = 0
                low_res_classification[~good_revisits_ix] = 0
            # Replace it with the median of revisits.
            elif self.fill == "median":
                # If there are no good revisits:
                if sum(good_revisits_ix) == 0:
                    good_revisits_ix = torch.ones_like(good_revisits_ix).bool()
                low_res[~good_revisits_ix], _ = low_res[good_revisits_ix].median(
                    dim=-4, keepdim=True
                )

        if self.batched:
            low_res = low_res.unflatten(dim=0, sizes=(batch_size, revisits))
            low_res_classification = low_res_classification.unflatten(
                dim=0, sizes=(batch_size, revisits)
            )
        item["lr"], item["lrc"] = low_res, low_res_classification
        return item

    # Disable gradients for efficiency.
    @torch.no_grad()
    def __call__(self, item):
        return self.remove_no_data(item)


def lanczos_kernel(translation_in_px, kernel_lobes=3, kernel_width=None):
    """ Generates 1D Lanczos kernels for translation and interpolation.
    Adapted from: https://github.com/ElementAI/HighRes-net/blob/master/src/lanczos.py

    Parameters
    ----------
    translation_in_px : Tensor
        Translation in (sub-)pixels, (B,1).
    kernel_lobes : int, optional
        Number of kernel lobes, by default 3.
        If kernel_lobes is None, then the width is the kernel support 
        (length of all lobes), S = 2(a + ceil(subpixel_x)) + 1.
    kernel_width : Optional[int], optional
        Kernel width, by default None.

    Returns
    -------
    Tensor
        1D Lanczos kernel, (B,) or (N,) or (S,).
    """

    device = translation_in_px.device
    dtype = translation_in_px.dtype

    absolute_rounded_translation_in_px = translation_in_px.abs().ceil().int()
    # width of kernel support
    kernel_support_width = 2 * (kernel_lobes + absolute_rounded_translation_in_px) + 1

    maximum_support_width = (
        kernel_support_width.max()
        if hasattr(kernel_support_width, "shape")
        else kernel_support_width
    )

    if (kernel_width is None) or (kernel_width < maximum_support_width):
        kernel_width = kernel_support_width

    # Width of zeros beyond kernel support
    zeros_beyond_support_width = (
        ((kernel_width - kernel_support_width) / 2).floor().int()
    )

    start = (
        -(
            kernel_lobes
            + absolute_rounded_translation_in_px
            + zeros_beyond_support_width
        )
    ).min()
    end = (
        kernel_lobes
        + absolute_rounded_translation_in_px
        + zeros_beyond_support_width
        + 1
    ).max()
    x = (
        torch.arange(start, end, dtype=dtype, device=device).view(1, -1)
        - translation_in_px
    )
    px = (np.pi * x) + 1e-3

    sin_px = torch.sin(px)
    sin_pxa = torch.sin(px / kernel_lobes)

    # sinc(x) masked by sinc(x/a)
    k = kernel_lobes * sin_px * sin_pxa / px ** 2

    return k


def lanczos_shift(x, shift, padding=3, kernel_lobes=3):
    """ Shifts an image by convolving it with a Lanczos kernel.
    Lanczos interpolation is an approximation to ideal sinc interpolation,
    by windowing a sinc kernel with another sinc function extending up to
    a few number of its lobes (typically 3).

    Adapted from:
            https://github.com/ElementAI/HighRes-net/blob/master/src/lanczos.py

    Parameters
    ----------
    x : Tensor
        Image to be shifted, (batch_size, channels, height, width).
    shift : Tensor
        Shift in (sub-)pixels/translation parameters, (B,2).
    padding : int, optional
        Width of the padding prior to convolution, by default 3.
    kernel_lobes : int, optional
        Number of lobes of the Lanczos interpolation kernel, by default 3.

    Returns
    -------
    _type_
        _description_
    """

    (batch_size, channels, height, width) = x.shape

    # Because examples and channels are interleaved in dim 1.
    shift = shift.repeat(channels, 1)  # (B, C * 2)
    shift = shift.reshape(batch_size * channels, 2)  # (B * C, 2)
    x = x.view(1, batch_size * channels, height, width)

    # Reflection pre-padding.
    pad = torch.nn.ReflectionPad2d(padding)
    x = pad(x)

    # 1D shifting kernels.
    y_shift = shift[:, [0]]
    x_shift = shift[:, [1]]

    # Flip dimension of convolution and expand dims to (batch_size, channels, len(kernel_y), 1).
    kernel_y = (lanczos_kernel(y_shift, kernel_lobes=kernel_lobes).flip(1))[
        :, None, :, None
    ]
    kernel_x = (lanczos_kernel(x_shift, kernel_lobes=kernel_lobes).flip(1))[
        :, None, None, :
    ]

    # 1D-convolve image with kernels: shifts image on x- then y-axis.
    x = torch.conv1d(
        x,
        groups=kernel_y.shape[0],
        weight=kernel_y,
        padding=[kernel_y.shape[2] // 2, 0],  # "same" padding.
    )
    x = torch.conv1d(
        x,
        groups=kernel_x.shape[0],
        weight=kernel_x,
        padding=[0, kernel_x.shape[3] // 2],
    )

    # Remove padding.
    x = x[..., padding:-padding, padding:-padding]

    return x.view(batch_size, channels, height, width)


class Shift:
    """ Sub-pixel image shifter with Lanczos shifting and interpolation kernels. """

    def __init__(self, padding=5, kernel_lobes=3):
        """ Initialize Shift.

        Parameters
        ----------
        padding : int, optional
            Width of the padding prior to convolution, by default 5.
        kernel_lobes : int, optional
            Number of lobes of the Lanczos interpolation kernel, by default 3.
        """
        self.padding = padding
        self.kernel_lobes = kernel_lobes

    def __call__(self, x, shift):
        """ Shift an image by convolving it with a Lanczos kernel.

        Parameters
        ----------
        x : Tensor
            Image to be shifted, (batch_size, channels, height, width).
        shift : Tensor
            Shift in (sub-)pixels/translation parameters, (batch_size,2).

        Returns
        -------
        Tensor
            Shifted image, (batch_size, channels, height, width).
        """
        return lanczos_shift(
            x, shift.flip(-1), padding=self.padding, kernel_lobes=self.kernel_lobes
        )


class WarpPerspective:
    """ Transform module for perspective warping.
    Uses kornia.warp_perspective."""

    def __call__(self, x, warp):
        """ Warp an image by perspective transformation.

        Parameters
        ----------
        x : Tensor
            Image to be warped, (batch_size, channels, height, width).
        warp : Tensor
            Warp/homography parameters, (batch_size, 8).

        Returns
        -------
        Tensor
            Warped image, (batch_size, channels, height, width).
        """
        """
        Args:
            x: Tensor (batch_size, channels, height, width), images.
            warp: Tensor (batch_size, 8), homography parameters.
        Returns:
            Tensor (batch_size, channels, height, width), shifted images.
        """
        batch_size, channels, height, width = x.shape

        # Homography parameters with warp[2,2]=1 (B, 9)
        warp = torch.cat([warp, torch.ones(batch_size, 1)])

        # Warp matrix (B, 3, 3)
        warp = warp.view(batch_size, 3, 3)

        return kornia.warp_perspective(
            x, warp, dsize=(height, width), mode="bilinear", padding_mode="zeros"
        )