Repository: scalableminds/webknossos-libs

Issue:
Bounding box in JSON after conversion
Starting from v0.5.0 the bounding box specified in the JSON is no longer the exact dimensions of the source TIFF image stack. If convert using just `python -m wkcuber`, then the xyz dimensions of the bounding box are all set to `1024`. If convert using `python -m wkcuber.convert_image_stack_to_wkw`, then the xyz dimensions of the bounding box are slightly larger than the source stack dimensions. Converting using v0.4.0 gives the correct bounding box dimensions.


Discussion:
@cnzqy1 could you provide me some sample files to debug the problem. The initial problem of the `python -m wkcuber` interface always using `1024` as xyz dimensions should be fixed by now, but that does not explain the differing dimensions when using the `python -m wkcuber.convert_image_stack_to_wkw` entry point.
Test volume and wkcuber conversion results: https://drive.google.com/file/d/13nI2D3xZznFrMHp4HQf5kCxykTSbBMhS/view?usp=sharing

Expected bounding box: 465, 443, 61. Actual bounding box: 472, 448, 64 (same for both entry points).

Probably because bounding boxes generated in the new versions are aligned to the block size? v0.4.0 and earlier versions yield the correct bounding boxes.

New entry point conversion command (`auto`): `python -m wkcuber --scale 6,6,6 --name cube7 cube7/ auto/`
Old entry point conversion command (`legacy`): `python -m wkcuber.convert_image_stack_to_wkw --layer_name color --name cube7 cube7/ legacy/`

Resolving patch:
diff --git a/wkcuber/api/Layer.py b/wkcuber/api/Layer.py
--- a/wkcuber/api/Layer.py
+++ b/wkcuber/api/Layer.py
@@ -132,74 +132,36 @@ def _assert_mag_does_not_exist_yet(
     def set_bounding_box(
         self, offset: Tuple[int, int, int], size: Tuple[int, int, int]
     ) -> None:
-        self.set_bounding_box_offset(offset)
-        self.set_bounding_box_size(size)
+        self.dataset.properties._set_bounding_box_of_layer(self.name, offset, size)
+        bounding_box = BoundingBox(offset, size)
+
+        for mag_name, mag in self.mags.items():
+            mag.view.size = cast(
+                Tuple[int, int, int],
+                tuple(
+                    bounding_box.align_with_mag(Mag(mag_name), ceil=True)
+                    .in_mag(Mag(mag_name))
+                    .bottomright
+                ),
+            )
 
     def set_bounding_box_offset(self, offset: Tuple[int, int, int]) -> None:
-        size: Tuple[int, int, int] = self.dataset.properties.data_layers[
-            "color"
-        ].get_bounding_box_size()
-        self.dataset.properties._set_bounding_box_of_layer(self.name, offset, size)
-        for _, mag in self.mags.items():
-            mag.view.global_offset = offset
+        size: Tuple[int, int, int] = self.dataset.properties.get_bounding_box_of_layer(
+            self.name
+        )[1]
+        self.set_bounding_box(offset, size)
 
     def set_bounding_box_size(self, size: Tuple[int, int, int]) -> None:
-        offset: Tuple[int, int, int] = self.dataset.properties.data_layers[
-            "color"
-        ].get_bounding_box_offset()
-        self.dataset.properties._set_bounding_box_of_layer(self.name, offset, size)
-        for _, mag in self.mags.items():
-            mag.view.size = size
+        offset: Tuple[
+            int, int, int
+        ] = self.dataset.properties.get_bounding_box_of_layer(self.name)[0]
+        self.set_bounding_box(offset, size)
 
     def _initialize_mag_from_other_mag(
         self, new_mag_name: Union[str, Mag], other_mag: MagDataset, compress: bool
     ) -> MagDataset:
         raise NotImplemented
 
-    def _pad_existing_mags_for_downsampling(
-        self,
-        from_mag: Mag,
-        max_mag: Mag,
-        scale: Optional[Tuple[float, float, float]],
-        only_max_mag: bool = False,
-    ) -> None:
-        # pad all existing mags if necessary
-        # during each downsampling step, the data shape or offset of the new mag should never need to be rounded
-        existing_mags = sorted([Mag(mag) for mag in self.mags.keys()])
-        all_mags_after_downsampling = existing_mags.copy()
-        if only_max_mag:
-            all_mags_after_downsampling += [max_mag]
-        else:
-            cur_mag = get_next_mag(from_mag, scale)
-            while cur_mag <= max_mag:
-                all_mags_after_downsampling += [cur_mag]
-                cur_mag = get_next_mag(cur_mag, scale)
-
-        all_mags_after_downsampling = sorted(all_mags_after_downsampling)
-
-        bb_mag1 = BoundingBox(
-            topleft=self.dataset.properties.data_layers[
-                self.name
-            ].get_bounding_box_offset(),
-            size=self.dataset.properties.data_layers[self.name].get_bounding_box_size(),
-        )
-
-        aligned_bb = bb_mag1.align_with_mag(all_mags_after_downsampling[-1], ceil=True)
-
-        self.dataset.properties._set_bounding_box_of_layer(
-            self.name,
-            cast(Tuple[int, int, int], tuple(aligned_bb.topleft)),
-            cast(Tuple[int, int, int], tuple(aligned_bb.size)),
-        )
-
-        for mag_name in existing_mags:
-            mag = self.mags[mag_name.to_layer_name()]
-            # The base view of a MagDataset always starts at (0, 0, 0)
-            mag.view.size = cast(
-                Tuple[int, int, int],
-                tuple(aligned_bb.in_mag(Mag(mag_name)).bottomright),
-            )
-
     def downsample(
         self,
         from_mag: Optional[Mag] = None,
@@ -239,8 +201,6 @@ def downsample(
                 f"Downsampling failed: {sampling_mode} is not a valid SamplingMode ({SamplingModes.AUTO}, {SamplingModes.ISOTROPIC}, {SamplingModes.CONSTANT_Z})"
             )
 
-        self._pad_existing_mags_for_downsampling(from_mag, max_mag, scale)
-
         prev_mag = from_mag
         target_mag = get_next_mag(prev_mag, scale)
 
@@ -251,7 +211,6 @@ def downsample(
                 interpolation_mode=interpolation_mode,
                 compress=compress,
                 buffer_edge_len=buffer_edge_len,
-                pad_data=False,
                 args=args,
             )
 
@@ -265,18 +224,12 @@ def downsample_mag(
         interpolation_mode: str = "default",
         compress: bool = True,
         buffer_edge_len: Optional[int] = None,
-        pad_data: bool = True,
         args: Optional[Namespace] = None,
     ) -> None:
         assert (
             from_mag.to_layer_name() in self.mags.keys()
         ), f"Failed to downsample data. The from_mag ({from_mag}) does not exist."
 
-        if pad_data:
-            self._pad_existing_mags_for_downsampling(
-                from_mag, target_mag, None, only_max_mag=True
-            )
-
         parsed_interpolation_mode = parse_interpolation_mode(
             interpolation_mode, self.dataset.properties.data_layers[self.name].category
         )
@@ -295,8 +248,39 @@ def downsample_mag(
             target_mag, prev_mag_ds, compress
         )
 
+        bb_mag1 = BoundingBox(
+            topleft=self.dataset.properties.data_layers[
+                self.name
+            ].get_bounding_box_offset(),
+            size=self.dataset.properties.data_layers[self.name].get_bounding_box_size(),
+        )
+
+        aligned_source_bb = bb_mag1.align_with_mag(target_mag, ceil=True).in_mag(
+            from_mag
+        )
+        aligned_target_bb = bb_mag1.align_with_mag(target_mag, ceil=True).in_mag(
+            target_mag
+        )
+
         # Get target view
-        target_mag_view = target_mag_ds.get_view(is_bounded=not compress)
+        target_mag_view = target_mag_ds.get_view(
+            offset=aligned_target_bb.topleft,
+            size=aligned_target_bb.size,
+            is_bounded=not compress,
+        )
+
+        # Source view
+        # Setting "is_bounded" first to "False" and the to "True" temporarily disables the "bounds check".
+        # This is not ideal, but we know what we are doing.
+        # The reason why there might be an error otherwise is that the view is aligned with the target_mag
+        # (not just with the from_mag). In this case we want that the view is aligned to the target_mag
+        # because this makes it very easy to downsample data from the source to the target.
+        source_view = prev_mag_ds.get_view(
+            offset=aligned_source_bb.topleft,
+            size=aligned_source_bb.size,
+            is_bounded=False,
+        )
+        source_view.is_bounded = True
 
         # perform downsampling
         with get_executor_for_args(args) as executor:
@@ -317,7 +301,8 @@ def downsample_mag(
                 compress=compress,
                 job_count_per_log=job_count_per_log,
             )
-            prev_mag_ds.get_view().for_zipped_chunks(
+
+            source_view.for_zipped_chunks(
                 # this view is restricted to the bounding box specified in the properties
                 func,
                 target_view=target_mag_view,
@@ -349,14 +334,10 @@ def downsample_mag_list(
             assert np.less_equal(
                 target_mags[i].as_np(), target_mags[i + 1].as_np()
             ).all(), (
-                f"Downsampling failed: cannot downsample {target_mags[i].to_layer_name()} to {target_mags[i+1].to_layer_name()}. "
+                f"Downsampling failed: cannot downsample {target_mags[i].to_layer_name()} to {target_mags[i + 1].to_layer_name()}. "
                 f"Check 'target_mags' ({', '.join([str(mag) for mag in target_mags])}): each pair of adjacent Mags results in a downsampling step."
             )
 
-        self._pad_existing_mags_for_downsampling(
-            from_mag, target_mags[-1], None, only_max_mag=True
-        )
-
         source_mag = from_mag
         for target_mag in target_mags:
             self.downsample_mag(
@@ -365,7 +346,6 @@ def downsample_mag_list(
                 interpolation_mode=interpolation_mode,
                 compress=compress,
                 buffer_edge_len=buffer_edge_len,
-                pad_data=False,
                 args=args,
             )
             source_mag = target_mag
@@ -634,7 +614,6 @@ def downsample_mag(
         interpolation_mode: str = "default",
         compress: bool = True,
         buffer_edge_len: Optional[int] = None,
-        pad_data: bool = True,
         args: Optional[Namespace] = None,
     ) -> None:
         raise NotImplemented
diff --git a/wkcuber/api/MagDataset.py b/wkcuber/api/MagDataset.py
--- a/wkcuber/api/MagDataset.py
+++ b/wkcuber/api/MagDataset.py
@@ -154,7 +154,7 @@ def get_view(
             )
         )
 
-        properties_offset_in_current_mag = convert_mag1_size(
+        properties_offset_in_current_mag = convert_mag1_offset(
             mag1_offset_in_properties, Mag(self.name)
         )
 


Test patch:
diff --git a/tests/test_dataset.py b/tests/test_dataset.py
--- a/tests/test_dataset.py
+++ b/tests/test_dataset.py
@@ -1293,17 +1293,28 @@ def test_changing_layer_bounding_box() -> None:
 
     assert ds.properties.data_layers["color"].get_bounding_box_offset() == (0, 0, 0)
 
-    layer.set_bounding_box(
-        offset=(10, 10, 0), size=(255, 255, 10)
-    )  # change offset and size
+    # Move the offset from (0, 0, 0) to (10, 10, 0)
+    # Note that the bottom right coordinate of the dataset is still at (265, 265, 10)
+    layer.set_bounding_box(offset=(10, 10, 0), size=(255, 255, 10))
 
     new_bbox_offset = ds.properties.data_layers["color"].get_bounding_box_offset()
     new_bbox_size = ds.properties.data_layers["color"].get_bounding_box_size()
     assert new_bbox_offset == (10, 10, 0)
     assert new_bbox_size == (255, 255, 10)
-    new_data = mag.read(size=new_bbox_size)
+    # Note that even though the offset was changed (in the properties), the offset of 'mag.read()' still refers to the absolute position (relative to (0, 0, 0)).
+    # The default offset is (0, 0, 0). Since the bottom right did not change, the read data equals 'original_data'.
+    assert np.array_equal(original_data, mag.read())
+
+    assert np.array_equal(
+        original_data[:, 10:, 10:, :], mag.read(offset=(10, 10, 0), size=(255, 255, 10))
+    )
+
+    # resetting the offset to (0, 0, 0)
+    # Note that the size did not change. Therefore, the new bottom right is now at (255, 255, 10)
+    layer.set_bounding_box_offset((0, 0, 0))
+    new_data = mag.read()
     assert new_data.shape == (1, 255, 255, 10)
-    assert np.array_equal(original_data[:, 10:, 10:, :], new_data)
+    assert np.array_equal(original_data[:, :255, :255, :], new_data)
 
 
 def test_view_offsets() -> None:
diff --git a/tests/test_downsampling.py b/tests/test_downsampling.py
--- a/tests/test_downsampling.py
+++ b/tests/test_downsampling.py
@@ -232,54 +232,6 @@ def test_anisotropic_mag_calculation() -> None:
         )
 
 
-def test_downsampling_padding() -> None:
-    # offset, size, max_mag, scale, expected_offset, expected_size
-    padding_tests = [
-        (
-            (0, 0, 0),
-            (128, 128, 256),
-            Mag(4),
-            (1, 1, 1),
-            (0, 0, 0),
-            (128, 128, 256),
-        ),  # no padding in this case
-        ((10, 0, 0), (118, 128, 256), Mag(4), (1, 1, 1), (8, 0, 0), (120, 128, 256)),
-        (
-            (10, 20, 30),
-            (128, 148, 168),
-            Mag(8),
-            (2, 1, 1),
-            (8, 16, 24),
-            (132, 152, 176),
-        ),
-    ]
-    for args in padding_tests:
-        ds_path = TESTOUTPUT_DIR / "larger_wk_dataset"
-        try:
-            shutil.rmtree(ds_path)
-        except:
-            pass
-
-        (offset, size, max_mag, scale, expected_offset, expected_size) = args
-
-        ds = WKDataset.create(ds_path, scale=scale)
-        layer = ds.add_layer(
-            "layer1", "segmentation", num_channels=1, largest_segment_id=1000000000
-        )
-        mag1 = layer.add_mag("1", block_len=8, file_len=8)
-
-        # write random data to mag 1 to set the initial offset and size
-        mag1.write(
-            offset=offset,
-            data=(np.random.rand(*size) * 255).astype(np.uint8),
-        )
-
-        layer._pad_existing_mags_for_downsampling(Mag(1), max_mag, scale)
-
-        assert np.array_equal(mag1.get_view().size, expected_size)
-        assert np.array_equal(mag1.get_view().global_offset, expected_offset)
-
-
 def test_default_max_mag() -> None:
     assert calculate_default_max_mag(dataset_size=(65536, 65536, 65536)) == Mag(1024)
     assert calculate_default_max_mag(dataset_size=(4096, 4096, 4096)) == Mag(64)