Repository: dcs4cop/xcube
Pull request #625 (2022-02-25 11:11:51)

Fixed flipping for unevenly tiled images
Part of #597 . Required to correctly display datasets in xcube-viewer.

Checklist:

* [x] Add unit tests and/or doctests in docstrings
* [x] Add docstrings and API docs for any new/modified user-facing classes and functions
* ~[ ] New/modified features documented in `docs/source/*`~
* [x] Changes documented in `CHANGES.md`
* [ ] AppVeyor CI passes
* [ ] Test coverage remains or increases (target 100%)

Remember to close associated issues after merge!


Patch:
diff --git a/CHANGES.md b/CHANGES.md
index b390c8b9..8045b7a6 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -4,6 +4,10 @@
 
 ### Fixes
 
+* Images with ascending y-values are tiled correctly. This fixes an issue where
+  some datasets seemed to be shifted in the y-(latitude-) direction and were
+  misplaced on maps. (#626)
+
 ### Other
 
 ## Changes in 0.10.1
diff --git a/xcube/core/tile.py b/xcube/core/tile.py
index 9e6338e8..327dd2dd 100644
--- a/xcube/core/tile.py
+++ b/xcube/core/tile.py
@@ -30,14 +30,14 @@ from xcube.core.mldataset import MultiLevelDataset
 from xcube.core.schema import get_dataset_xy_var_names
 from xcube.util.cache import Cache
 from xcube.util.perf import measure_time_cm
-from xcube.util.tiledimage import ArrayImage
+from xcube.util.tiledimage import SourceArrayImage
 from xcube.util.tiledimage import ColorMappedRgbaImage
 from xcube.util.tiledimage import DEFAULT_COLOR_MAP_NAME
 from xcube.util.tiledimage import DEFAULT_COLOR_MAP_VALUE_RANGE
 from xcube.util.tiledimage import DirectRgbaImage
 from xcube.util.tiledimage import Tile
 from xcube.util.tiledimage import TiledImage
-from xcube.util.tiledimage import TransformArrayImage
+from xcube.util.tiledimage import NormalizeArrayImage
 
 
 def get_ml_dataset_tile(
@@ -148,13 +148,13 @@ def new_rgb_image(ml_dataset: MultiLevelDataset,
                 exception_type
             )
 
-        image = ArrayImage(array,
-                           image_id=f'ai-{image_id}',
-                           tile_size=tile_grid.tile_size,
-                           trace_perf=trace_perf)
-        image = TransformArrayImage(image,
+        image = SourceArrayImage(array,
+                                 image_id=f'ai-{image_id}',
+                                 tile_size=tile_grid.tile_size,
+                                 flip_y=tile_grid.is_j_axis_up,
+                                 trace_perf=trace_perf)
+        image = NormalizeArrayImage(image,
                                     image_id=f'tai-{image_id}',
-                                    flip_y=tile_grid.is_j_axis_up,
                                     norm_range=norm_ranges[i],
                                     trace_perf=trace_perf)
         images.append(image)
@@ -189,13 +189,13 @@ def new_color_mapped_image(ml_dataset: MultiLevelDataset,
                                                 cmap_range,
                                                 valid_range)
     tile_grid = ml_dataset.tile_grid
-    image = ArrayImage(array,
-                       image_id=f'ai-{image_id}',
-                       tile_size=tile_grid.tile_size,
-                       trace_perf=trace_perf)
-    image = TransformArrayImage(image,
+    image = SourceArrayImage(array,
+                             image_id=f'ai-{image_id}',
+                             tile_size=tile_grid.tile_size,
+                             flip_y=tile_grid.is_j_axis_up,
+                             trace_perf=trace_perf)
+    image = NormalizeArrayImage(image,
                                 image_id=f'tai-{image_id}',
-                                flip_y=tile_grid.is_j_axis_up,
                                 norm_range=cmap_range,
                                 trace_perf=trace_perf)
     if cmap_name is None and cmap_range is None:
diff --git a/xcube/util/tiledimage.py b/xcube/util/tiledimage.py
index 73fca6c3..1dde77bf 100644
--- a/xcube/util/tiledimage.py
+++ b/xcube/util/tiledimage.py
@@ -338,14 +338,14 @@ class DecoratorImage(OpImage, metaclass=ABCMeta):
         """
 
 
-class TransformArrayImage(DecoratorImage):
+class NormalizeArrayImage(DecoratorImage):
     """
-    Performs basic (numpy) array tile transformations. Currently available: force_masked, flip_y.
+    Performs basic (numpy) array tile transformations.
+    Currently available: norm_range.
     Expects the source image to provide (numpy) arrays.
 
     :param source_image: The source image
     :param image_id: Optional unique image identifier
-    :param flip_y: Whether to flip pixels in y-direction
     :param tile_cache: Optional tile cache
     :param trace_perf: Whether to log runtime performance information
     """
@@ -353,23 +353,15 @@ class TransformArrayImage(DecoratorImage):
     def __init__(self,
                  source_image: TiledImage,
                  image_id: str = None,
-                 flip_y: bool = False,
                  force_2d: bool = False,
                  norm_range: NormRange = None,
                  tile_cache: Cache = None,
                  trace_perf: bool = False):
         super().__init__(source_image, image_id=image_id, tile_cache=tile_cache, trace_perf=trace_perf)
         self._force_2d = force_2d
-        self._flip_y = flip_y
         self._norm_range = norm_range
 
     def compute_tile(self, tile_x: int, tile_y: int, rectangle: Rectangle2D) -> Tile:
-        if self._flip_y:
-            num_tiles_y = self.num_tiles[1]
-            tile_size_y = self.tile_size[1]
-            tile_y = num_tiles_y - 1 - tile_y
-            x, y, w, h = rectangle
-            rectangle = x, tile_y * tile_size_y, w, h
         source_tile = self._source_image.get_tile(tile_x, tile_y)
         target_tile = None
         if source_tile is not None:
@@ -381,11 +373,6 @@ class TransformArrayImage(DecoratorImage):
         measure_time = self.measure_time
         tile_tag = self._get_tile_tag(tile_x, tile_y)
 
-        if self._flip_y:
-            with measure_time(tile_tag + "flip y"):
-                # Flip tile using fancy indexing
-                tile = tile[..., ::-1, :]
-
         if self._norm_range is not None:
             norm_min, norm_max = self._norm_range
             with measure_time(tile_tag + "normalize_min_max"):
@@ -522,13 +509,14 @@ class ColorMappedRgbaImage(DecoratorImage):
             return image
 
 
-class ArrayImage(OpImage):
+class SourceArrayImage(OpImage):
     """
-    A tiled image created an numpy ndarray-like data array.
+    A tiled image created from a numpy ndarray-like data array.
 
     :param array: a numpy-ndarray-like data array
     :param tile_size: the tile size
     :param image_id: optional unique image identifier
+    :param flip_y: Whether to flip pixels in y-direction
     :param tile_cache: an optional tile cache
     """
 
@@ -536,6 +524,7 @@ class ArrayImage(OpImage):
                  array: Union[NDArrayLike, Any],
                  tile_size: Size2D,
                  image_id: str = None,
+                 flip_y: bool = False,
                  tile_cache: Cache = None,
                  trace_perf: bool = False):
         if len(array.shape) != 2:
@@ -556,19 +545,44 @@ class ArrayImage(OpImage):
                          trace_perf=trace_perf)
         is_xarray_like = hasattr(array, 'data') and hasattr(array, 'dims') and hasattr(array, 'attrs')
         self._array = array.data if is_xarray_like else array
+        self._flip_y = flip_y
+        self._tile_offset_y = self.size[1] % self.tile_size[1]
 
-    def compute_tile(self, tile_x: int, tile_y: int, rectangle: Rectangle2D) -> NDArrayLike:
+    def compute_tile(self, tile_x: int, tile_y: int, rectangle: Rectangle2D) \
+            -> NDArrayLike:
+        measure_time = self.measure_time
+        tile_tag = self._get_tile_tag(tile_x, tile_y)
         x, y, w, h = rectangle
+
+        if self._flip_y:
+            num_tiles_y = self.num_tiles[1]
+            tile_size_y = self.tile_size[1]
+            tile_y = num_tiles_y - 1 - tile_y
+            if self._tile_offset_y > 0:
+                if tile_y == 0:
+                    y = 0
+                    h = self._tile_offset_y
+                else:
+                    y = self._tile_offset_y + (tile_y - 1) * tile_size_y
+            else:
+                y = tile_y * tile_size_y
+
         tile = self._array[y:y + h, x:x + w]
+        if self._flip_y:
+            with measure_time(tile_tag + "flip y"):
+                # Flip tile using fancy indexing
+                tile = tile[..., ::-1, :]
         # ensure that our tile size is w x h
         return trim_tile(tile, self.tile_size)
 
 
-def trim_tile(tile: NDArrayLike, expected_tile_size: Size2D, fill_value: float = np.nan) -> NDArrayLike:
+def trim_tile(tile: NDArrayLike,
+              expected_tile_size: Size2D,
+              fill_value: float = np.nan) -> NDArrayLike:
     """
     Trim a tile.
 
-    If to small, expand and pad with background value. If to large, crop.
+    If too small, expand and pad with background value. If too large, crop.
 
     :param tile: The tile
     :param expected_tile_size: expected tile size


Test patch:
diff --git a/test/util/test_tiledimage.py b/test/util/test_tiledimage.py
index 87a33277..9f1d81b9 100644
--- a/test/util/test_tiledimage.py
+++ b/test/util/test_tiledimage.py
@@ -3,11 +3,11 @@ from unittest import TestCase
 import numpy as np
 import PIL.Image
 
-from xcube.util.tiledimage import ArrayImage
+from xcube.util.tiledimage import SourceArrayImage
 from xcube.util.tiledimage import ColorMappedRgbaImage
 from xcube.util.tiledimage import DirectRgbaImage
 from xcube.util.tiledimage import OpImage
-from xcube.util.tiledimage import TransformArrayImage
+from xcube.util.tiledimage import NormalizeArrayImage
 from xcube.util.tiledimage import trim_tile
 
 
@@ -26,7 +26,7 @@ class MyTiledImage(OpImage):
 class ColorMappedRgbaImageTest(TestCase):
     def test_default(self):
         a = np.linspace(0, 255, 24, dtype=np.int32).reshape((4, 6))
-        source_image = ArrayImage(a, (2, 2))
+        source_image = SourceArrayImage(a, (2, 2))
         cm_rgb_image = ColorMappedRgbaImage(source_image, format='PNG')
         self.assertEqual(cm_rgb_image.size, (6, 4))
         self.assertEqual(cm_rgb_image.tile_size, (2, 2))
@@ -41,9 +41,9 @@ class DirectRgbaImageTest(TestCase):
         b = np.linspace(255, 0, 24, dtype=np.int32).reshape((4, 6))
         c = np.linspace(50, 200, 24, dtype=np.int32).reshape((4, 6))
         source_images = [
-            ArrayImage(a, (2, 2)),
-            ArrayImage(b, (2, 2)),
-            ArrayImage(c, (2, 2)),
+            SourceArrayImage(a, (2, 2)),
+            SourceArrayImage(b, (2, 2)),
+            SourceArrayImage(c, (2, 2)),
         ]
         cm_rgb_image = DirectRgbaImage(source_images, format='PNG')
         self.assertEqual(cm_rgb_image.size, (6, 4))
@@ -53,11 +53,11 @@ class DirectRgbaImageTest(TestCase):
         self.assertIsInstance(tile, PIL.Image.Image)
 
 
-class TransformArrayImageTest(TestCase):
+class NormalizeArrayImageTest(TestCase):
     def test_default(self):
         a = np.arange(0, 24, dtype=np.int32).reshape((4, 6))
-        source_image = ArrayImage(a, (2, 2))
-        target_image = TransformArrayImage(source_image)
+        source_image = SourceArrayImage(a, (2, 2))
+        target_image = NormalizeArrayImage(source_image)
 
         self.assertEqual(target_image.size, (6, 4))
         self.assertEqual(target_image.tile_size, (2, 2))
@@ -77,37 +77,86 @@ class TransformArrayImageTest(TestCase):
         self.assertEqual(target_image.get_tile(2, 1).tolist(), [[16, 17],
                                                                 [22, 23]])
 
-    def test_flip_y(self):
-        a = np.arange(0, 24, dtype=np.int32).reshape((4, 6))
-        source_image = ArrayImage(a, (2, 2))
-        target_image = TransformArrayImage(source_image, flip_y=True)
+    def test_force_2d(self):
+        a = np.arange(0, 48, dtype=np.int32).reshape((2, 4, 6))
+        source_image = SourceArrayImage(a[0], (2, 2))
+        target_image = NormalizeArrayImage(source_image, force_2d=True)
 
         self.assertEqual(target_image.size, (6, 4))
         self.assertEqual(target_image.tile_size, (2, 2))
         self.assertEqual(target_image.num_tiles, (3, 2))
 
-        self.assertEqual(target_image.get_tile(0, 0).tolist(), [[18, 19],
-                                                                [12, 13]])
-        self.assertEqual(target_image.get_tile(1, 0).tolist(), [[20, 21],
-                                                                [14, 15]])
-        self.assertEqual(target_image.get_tile(2, 0).tolist(), [[22, 23],
-                                                                [16, 17]])
 
-        self.assertEqual(target_image.get_tile(0, 1).tolist(), [[6, 7],
-                                                                [0, 1]])
-        self.assertEqual(target_image.get_tile(1, 1).tolist(), [[8, 9],
-                                                                [2, 3]])
-        self.assertEqual(target_image.get_tile(2, 1).tolist(), [[10, 11],
-                                                                [4, 5]])
-
-    def test_force_2d(self):
-        a = np.arange(0, 48, dtype=np.int32).reshape((2, 4, 6))
-        source_image = ArrayImage(a[0], (2, 2))
-        target_image = TransformArrayImage(source_image, force_2d=True)
+class SourceArrayImageTest(TestCase):
 
-        self.assertEqual(target_image.size, (6, 4))
-        self.assertEqual(target_image.tile_size, (2, 2))
-        self.assertEqual(target_image.num_tiles, (3, 2))
+    def test_flip_y_tiled_evenly(self):
+        a = np.arange(0, 24, dtype=np.int32).reshape((4, 6))
+        source_array_image = SourceArrayImage(a, (2, 2), flip_y=True)
+
+        self.assertEqual(source_array_image.size, (6, 4))
+        self.assertEqual(source_array_image.tile_size, (2, 2))
+        self.assertEqual(source_array_image.num_tiles, (3, 2))
+
+        self.assertEqual(source_array_image.get_tile(0, 0).tolist(),
+                         [[18, 19],
+                          [12, 13]])
+        self.assertEqual(source_array_image.get_tile(1, 0).tolist(),
+                         [[20, 21],
+                          [14, 15]])
+        self.assertEqual(source_array_image.get_tile(2, 0).tolist(),
+                         [[22, 23],
+                          [16, 17]])
+
+        self.assertEqual(source_array_image.get_tile(0, 1).tolist(),
+                         [[6, 7],
+                          [0, 1]])
+        self.assertEqual(source_array_image.get_tile(1, 1).tolist(),
+                         [[8, 9],
+                          [2, 3]])
+        self.assertEqual(source_array_image.get_tile(2, 1).tolist(),
+                         [[10, 11],
+                          [4, 5]])
+
+    def test_flip_y_not_tiled_evenly(self):
+        a = np.arange(0, 30, dtype=np.int32).reshape((5, 6))
+        source_array_image = SourceArrayImage(a, (2, 2), flip_y=True)
+
+        self.assertEqual(source_array_image.size, (6, 5))
+        self.assertEqual(source_array_image.tile_size, (2, 2))
+        self.assertEqual(source_array_image.num_tiles, (3, 3))
+
+        self.assertEqual(source_array_image.get_tile(0, 0).tolist(),
+                         [[24, 25],
+                          [18, 19]])
+        self.assertEqual(source_array_image.get_tile(1, 0).tolist(),
+                         [[26, 27],
+                          [20, 21]])
+        self.assertEqual(source_array_image.get_tile(2, 0).tolist(),
+                         [[28, 29],
+                          [22, 23]])
+
+        self.assertEqual(source_array_image.get_tile(0, 1).tolist(),
+                         [[12, 13],
+                          [6, 7]])
+        self.assertEqual(source_array_image.get_tile(1, 1).tolist(),
+                         [[14, 15],
+                          [8, 9]])
+        self.assertEqual(source_array_image.get_tile(2, 1).tolist(),
+                         [[16, 17],
+                          [10, 11]])
+
+        tile_0_2 = source_array_image.get_tile(0, 2).tolist()
+        self.assertEqual(tile_0_2[0], [0, 1])
+        self.assertTrue(np.isnan(tile_0_2[1][0]))
+        self.assertTrue(np.isnan(tile_0_2[1][1]))
+        tile_1_2 = source_array_image.get_tile(1, 2).tolist()
+        self.assertEqual(tile_1_2[0], [2, 3])
+        self.assertTrue(tile_1_2[1][0])
+        self.assertTrue(tile_1_2[1][1])
+        tile_2_2 = source_array_image.get_tile(2, 2).tolist()
+        self.assertEqual(tile_2_2[0], [4, 5])
+        self.assertTrue(tile_2_2[1][0])
+        self.assertTrue(tile_2_2[1][1])
 
 
 class TrimTileTest(TestCase):