From c68f9a89cbfa229eeb21e8b8a95650d8d42554da Mon Sep 17 00:00:00 2001 From: Mark Bader Date: Tue, 2 Apr 2024 14:55:52 +0200 Subject: [PATCH] Reading nd datasets (#966) * Started debuging for reading nd datasets. * Implementation of VecNInt and NDBoundingBox. * Rename VecInt and fix some issues. * Work on import of existing zarr dataset. * Add nd_bounding_box to properties of Layer. * Update hooks in properties.py to make import of 4d zarr datasets possible. * Add axis_order and index of additional axes to nd_bounding box creation. * Working on reading nd data with pims images. * Modify pims images to support more _iter_dims. * Add method for expected bounding box instead of expected shape in pims images. * Adding functions for editing ndboundingbox in 3d and update import from_images. * Propagade nd-bounding box to different methods that take care of writing the dataset. * Update object unstructuring for json and start implementing nd array access. * Adapt array classes and add axes information to ArrayInfo. * Updated zarr writing for nd arrays. Axes order still get mixed up between old BoundingBoxes and new NDBoundingBoxes. * Adapted buffered_slice_reader and test behaviour with different tif images. * Adding testdata and fix bugs with axes operation for writing zarr array. * Fixing issues with axis order. * Rewrite buffered_slice_writer and fix bugs to get old tests working. * Fix chunking in buffered slice writer. * Fix issues with old tests. * Working on fixing all tests. * Fixing issues with alignment, writing with offsets and different mags. * Fix reading without parameters for nd datasets and addint test. * Fix issue with empty additionalAxes in json and some minor issues. * Move script reading_nd_data to examples in docs and implement some feedback. * Do some formatting and implement requested changes. * Update naming for accessing attributes of nd bounding boxes. * Fix buffered_slice_writer for different axis and typechecking. * Fix issue with ensure_size in initialization. * Adapt pims_images to support xyz images with channels and timepoint. * run formatter * Fix issues with failed tests and add comments. * Fix statement with wrong VecInt initialization. * Insert previously deleted assertions. * Add docstrings and use bounding box for read in nd case instead of VecInts. * Implement requested changes. * Changes init of NDBoundingBoxes. * Add converter for VecInt attributes of nd bounding box to pass typechecks. * Enhance documentation. * Update Changelog.md --- docs/mkdocs.yml | 3 + .../webknossos-py/examples/convert_4d_tiff.md | 13 + webknossos/Changelog.md | 1 + webknossos/examples/convert_4d_tiff.py | 48 + .../testdata/4D/4D_series/4D-series.ome.tif | Bin 0 -> 2629919 bytes .../dataset/test_add_layer_from_images.py | 34 +- webknossos/webknossos/_nml/parameters.py | 24 +- .../webknossos/annotation/annotation.py | 8 +- webknossos/webknossos/cli/convert_knossos.py | 6 +- .../webknossos/cli/export_wkw_as_tiff.py | 2 +- webknossos/webknossos/dataset/_array.py | 226 ++--- .../dataset/_utils/buffered_slice_reader.py | 51 +- .../dataset/_utils/buffered_slice_writer.py | 126 ++- .../infer_bounding_box_existing_files.py | 2 +- .../webknossos/dataset/_utils/pims_images.py | 301 +++++-- webknossos/webknossos/dataset/dataset.py | 97 +- webknossos/webknossos/dataset/layer.py | 45 +- webknossos/webknossos/dataset/mag_view.py | 60 +- webknossos/webknossos/dataset/properties.py | 48 +- webknossos/webknossos/dataset/view.py | 119 ++- webknossos/webknossos/geometry/__init__.py | 2 + .../webknossos/geometry/bounding_box.py | 151 +--- .../webknossos/geometry/nd_bounding_box.py | 849 ++++++++++++++++++ webknossos/webknossos/geometry/vec3_int.py | 186 ++-- webknossos/webknossos/geometry/vec_int.py | 336 +++++++ 25 files changed, 2070 insertions(+), 668 deletions(-) create mode 100644 docs/src/webknossos-py/examples/convert_4d_tiff.md create mode 100644 webknossos/examples/convert_4d_tiff.py create mode 100644 webknossos/testdata/4D/4D_series/4D-series.ome.tif create mode 100644 webknossos/webknossos/geometry/nd_bounding_box.py create mode 100644 webknossos/webknossos/geometry/vec_int.py diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 24b1968c3..bade6d083 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -97,6 +97,7 @@ nav: - webknossos-py/examples/download_tiff_stack.md - webknossos-py/examples/remote_datasets.md - webknossos-py/examples/zarr_and_dask.md + - webknossos-py/examples/convert_4d_tiff.md - Annotation Examples: - webknossos-py/examples/apply_merger_mode.md - webknossos-py/examples/learned_segmenter.md @@ -112,8 +113,10 @@ nav: - Overview: api/webknossos.md - Geometry: - BoundingBox: api/webknossos/geometry/bounding_box.md + - NDBoundingBox: api/webknossos/geometry/nd_bounding_box.md - Mag: api/webknossos/geometry/mag.md - Vec3Int: api/webknossos/geometry/vec3_int.md + - VecInt: api/webknossos/geometry/vec_int.md - Dataset: - Dataset: api/webknossos/dataset/dataset.md - Layer: api/webknossos/dataset/layer.md diff --git a/docs/src/webknossos-py/examples/convert_4d_tiff.md b/docs/src/webknossos-py/examples/convert_4d_tiff.md new file mode 100644 index 000000000..10edc2298 --- /dev/null +++ b/docs/src/webknossos-py/examples/convert_4d_tiff.md @@ -0,0 +1,13 @@ +# Convert 4D Tiff + +This example demonstrates the basic interactions with Datasets that have more than three dimensions. + +In order to manipulate 4D data in WEBKNOSSOS, we first convert the 4D Tiff dataset into a Zarr3 dataset. This conversion is achieved using the [from_images method](../../api/webknossos/dataset/dataset.md#Dataset.from_images). + +Once the dataset is converted, we can access specific layers and views, [read data](../../api/webknossos/dataset/mag_view.md#MagView.read) from a defined bounding box, and [write data](../../api/webknossos/dataset/mag_view.md#MagView.write) to a different position within the dataset. The [NDBoundingBox](../../api/webknossos/geometry/nd_bounding_box.md#NDBoundingBox) is utilized to select a 4D region of the dataset. + +```python +--8<-- +webknossos/examples/convert_4d_tiff.py +--8<-- +``` diff --git a/webknossos/Changelog.md b/webknossos/Changelog.md index bb493f1e9..758a139ad 100644 --- a/webknossos/Changelog.md +++ b/webknossos/Changelog.md @@ -21,6 +21,7 @@ For upgrade instructions, please check the respective _Breaking Changes_ section - The rules for naming the layers have been tightened to match the allowed layer names on webknossos. [#1016](https://github.com/scalableminds/webknossos-libs/pull/1016) - Replaced PyLint linter + black formatter with Ruff for development. [#1013](https://github.com/scalableminds/webknossos-libs/pull/1013) - The remote operations now use the WEBKNOSSOS API version 6. [#1018](https://github.com/scalableminds/webknossos-libs/pull/1018) +- The conversion of 4D Tiff files to a Zarr3 Dataset is possible. NDBoundingBoxes and VecInt classes are introduced to support working with more than 3 dimensions. [#966](https://github.com/scalableminds/webknossos-libs/pull/966) ### Fixed diff --git a/webknossos/examples/convert_4d_tiff.py b/webknossos/examples/convert_4d_tiff.py new file mode 100644 index 000000000..184bee12c --- /dev/null +++ b/webknossos/examples/convert_4d_tiff.py @@ -0,0 +1,48 @@ +from pathlib import Path + +import webknossos as wk + + +def main() -> None: + # Create a WEBKNOSSOS dataset from a 4D tiff image + dataset = wk.Dataset.from_images( + Path(__file__).parent.parent / "testdata" / "4D" / "4D_series", + "testoutput/4D_series", + voxel_size=(10, 10, 10), + data_format="zarr3", + use_bioformats=True, + ) + + # Access the first color layer and the Mag 1 view of this layer + layer = dataset.get_color_layers()[0] + mag_view = layer.get_finest_mag() + + # To get the bounding box of the dataset use layer.bounding_box + # -> NDBoundingBox(topleft=(0, 0, 0, 0), size=(7, 5, 167, 439), axes=('t', 'z', 'y', 'x')) + + # Read all data of the dataset + data = mag_view.read() + # data.shape -> (1, 7, 5, 167, 439) # first value is the channel dimension + + # Read data for a specific time point (t=3) of the dataset + data = mag_view.read( + absolute_bounding_box=layer.bounding_box.with_bounds("t", 3, 1) + ) + # data.shape -> (1, 1, 5, 167, 439) + + # Create a NDBoundingBox to read data from a specific region of the dataset + read_bbox = wk.NDBoundingBox( + topleft=(2, 0, 67, 39), + size=(2, 5, 100, 400), + axes=("t", "z", "y", "x"), + index=(1, 2, 3, 4), + ) + data = mag_view.read(absolute_bounding_box=read_bbox) + # data.shape -> (1, 2, 5, 100, 400) # first value is the channel dimension + + # Write some data to a given position + mag_view.write(data, absolute_bounding_box=read_bbox.offset((2, 0, 0, 0))) + + +if __name__ == "__main__": + main() diff --git a/webknossos/testdata/4D/4D_series/4D-series.ome.tif b/webknossos/testdata/4D/4D_series/4D-series.ome.tif new file mode 100644 index 0000000000000000000000000000000000000000..f957ee3807e1d0720bf909c902b912d706fb0ef5 GIT binary patch literal 2629919 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 literal 0 HcmV?d00001 diff --git a/webknossos/tests/dataset/test_add_layer_from_images.py b/webknossos/tests/dataset/test_add_layer_from_images.py index 8750923b0..82babf807 100644 --- a/webknossos/tests/dataset/test_add_layer_from_images.py +++ b/webknossos/tests/dataset/test_add_layer_from_images.py @@ -43,6 +43,31 @@ def test_compare_tifffile(tmp_path: Path) -> None: assert np.array_equal(data[:, :, z_index], comparison_slice) +def test_compare_nd_tifffile(tmp_path: Path) -> None: + ds = wk.Dataset(tmp_path, (1, 1, 1)) + layer = ds.add_layer_from_images( + "testdata/4D/4D_series/4D-series.ome.tif", + layer_name="color", + category="color", + topleft=(100, 100, 55), + use_bioformats=True, + data_format="zarr3", + chunk_shape=(8, 8, 8), + chunks_per_shard=(8, 8, 8), + ) + assert layer.bounding_box.topleft == wk.VecInt( + 0, 55, 100, 100, axes=("t", "z", "y", "x") + ) + assert layer.bounding_box.size == wk.VecInt( + 7, 5, 167, 439, axes=("t", "z", "y", "x") + ) + read_with_tifffile_reader = TiffFile( + "testdata/4D/4D_series/4D-series.ome.tif" + ).asarray() + read_first_channel_from_dataset = layer.get_finest_mag().read()[0] + assert np.array_equal(read_with_tifffile_reader, read_first_channel_from_dataset) + + REPO_IMAGES_ARGS: List[ Tuple[Union[str, List[Path]], Dict[str, Any], str, int, Tuple[int, int, int]] ] = [ @@ -205,15 +230,6 @@ def download_and_unpack( (192, 128, 9), 1, ), - ( - "https://samples.scif.io/sdub.zip", - "sdub*.pic", - {"allow_multiple_layers": True}, - "uint8", - 1, - (192, 128, 9), - 12, - ), ( "https://samples.scif.io/test-avi.zip", "t1-rendering.avi", diff --git a/webknossos/webknossos/_nml/parameters.py b/webknossos/webknossos/_nml/parameters.py index 1e5c4c3d8..b9424f128 100644 --- a/webknossos/webknossos/_nml/parameters.py +++ b/webknossos/webknossos/_nml/parameters.py @@ -3,7 +3,7 @@ from loxun import XmlWriter -from ..geometry import BoundingBox +from ..geometry import BoundingBox, NDBoundingBox from ..geometry.bounding_box import _DEFAULT_BBOX_NAME from .utils import Vector3, enforce_not_null, filter_none_values @@ -22,22 +22,24 @@ class Parameters(NamedTuple): editPosition: Optional[Vector3] = None editRotation: Optional[Vector3] = None zoomLevel: Optional[float] = None - taskBoundingBox: Optional[BoundingBox] = None - userBoundingBoxes: Optional[List[BoundingBox]] = None + taskBoundingBox: Optional[NDBoundingBox] = None + userBoundingBoxes: Optional[List[NDBoundingBox]] = None def _dump_bounding_box( self, xf: XmlWriter, - bounding_box: BoundingBox, + bounding_box: NDBoundingBox, tag_name: str, bbox_id: Optional[int], # user bounding boxes need an id ) -> None: color = bounding_box.color or DEFAULT_BOUNDING_BOX_COLOR attributes = { - "name": _DEFAULT_BBOX_NAME - if bounding_box.name is None - else str(bounding_box.name), + "name": ( + _DEFAULT_BBOX_NAME + if bounding_box.name is None + else str(bounding_box.name) + ), "isVisible": "true" if bounding_box.is_visible else "false", "color.r": str(color[0]), "color.g": str(color[1]), @@ -136,7 +138,7 @@ def _dump(self, xf: XmlWriter) -> None: xf.endTag() # parameters @classmethod - def _parse_bounding_box(cls, bounding_box_element: Element) -> BoundingBox: + def _parse_bounding_box(cls, bounding_box_element: Element) -> NDBoundingBox: topleft = ( int(bounding_box_element.get("topLeftX", 0)), int(bounding_box_element.get("topLeftY", 0)), @@ -165,14 +167,16 @@ def _parse_bounding_box(cls, bounding_box_element: Element) -> BoundingBox: ) @classmethod - def _parse_user_bounding_boxes(cls, nml_parameters: Element) -> List[BoundingBox]: + def _parse_user_bounding_boxes(cls, nml_parameters: Element) -> List[NDBoundingBox]: if nml_parameters.find("userBoundingBox") is None: return [] bb_elements = nml_parameters.findall("userBoundingBox") return [cls._parse_bounding_box(bb_element) for bb_element in bb_elements] @classmethod - def _parse_task_bounding_box(cls, nml_parameters: Element) -> Optional[BoundingBox]: + def _parse_task_bounding_box( + cls, nml_parameters: Element + ) -> Optional[NDBoundingBox]: bb_element = nml_parameters.find("taskBoundingBox") if bb_element is not None: return cls._parse_bounding_box(bb_element) diff --git a/webknossos/webknossos/annotation/annotation.py b/webknossos/webknossos/annotation/annotation.py index bb2cb4f15..9d93aa94a 100644 --- a/webknossos/webknossos/annotation/annotation.py +++ b/webknossos/webknossos/annotation/annotation.py @@ -60,7 +60,7 @@ ) from ..dataset.defaults import PROPERTIES_FILE_NAME from ..dataset.properties import DatasetProperties, dataset_converter -from ..geometry import BoundingBox, Vec3Int +from ..geometry import NDBoundingBox, Vec3Int from ..skeleton import Skeleton from ..utils import time_since_epoch_in_ms, warn_deprecated from ._nml_conversion import annotation_to_nml, nml_to_skeleton @@ -124,8 +124,8 @@ class Annotation: edit_rotation: Optional[Vector3] = None zoom_level: Optional[float] = None metadata: Dict[str, str] = attr.Factory(dict) - task_bounding_box: Optional[BoundingBox] = None - user_bounding_boxes: List[BoundingBox] = attr.Factory(list) + task_bounding_box: Optional[NDBoundingBox] = None + user_bounding_boxes: List[NDBoundingBox] = attr.Factory(list) _volume_layers: List[_VolumeLayer] = attr.field(factory=list, init=False) @classmethod @@ -474,7 +474,7 @@ def _load_from_zip(cls, content: Union[str, PathLike, BinaryIO]) -> "Annotation" assert len(nml_paths) > 0, "Couldn't find an nml file in the supplied zip-file." assert ( len(nml_paths) == 1 - ), f"There must be exactly one nml file in the zip-file, buf found {len(nml_paths)}." + ), f"There must be exactly one nml file in the zip-file, but found {len(nml_paths)}." with nml_paths[0].open(mode="rb") as f: return cls._load_from_nml(nml_paths[0].stem, f, possible_volume_paths=paths) diff --git a/webknossos/webknossos/cli/convert_knossos.py b/webknossos/webknossos/cli/convert_knossos.py index 51184c342..44ad18faf 100644 --- a/webknossos/webknossos/cli/convert_knossos.py +++ b/webknossos/webknossos/cli/convert_knossos.py @@ -151,15 +151,15 @@ def convert_cube_job( time_start(f"Converting of {target_view.bounding_box}") cube_size = cast(Tuple[int, int, int], (KNOSSOS_CUBE_EDGE_LEN,) * 3) - offset = target_view.bounding_box.in_mag(target_view.mag).topleft - size = target_view.bounding_box.in_mag(target_view.mag).size + offset = target_view.bounding_box.in_mag(target_view.mag).topleft_xyz + size = target_view.bounding_box.in_mag(target_view.mag).size_xyz buffer = np.zeros(size.to_tuple(), dtype=target_view.get_dtype()) with open_knossos(source_knossos_info) as source_knossos: for x in range(0, size.x, KNOSSOS_CUBE_EDGE_LEN): for y in range(0, size.y, KNOSSOS_CUBE_EDGE_LEN): for z in range(0, size.z, KNOSSOS_CUBE_EDGE_LEN): cube_data = source_knossos.read( - (offset + Vec3Int(x, y, z)).to_tuple(), cube_size + Vec3Int(offset + (x, y, z)).to_tuple(), cube_size ) buffer[ x : (x + KNOSSOS_CUBE_EDGE_LEN), diff --git a/webknossos/webknossos/cli/export_wkw_as_tiff.py b/webknossos/webknossos/cli/export_wkw_as_tiff.py index 56afcaf2e..b4bb4aa14 100644 --- a/webknossos/webknossos/cli/export_wkw_as_tiff.py +++ b/webknossos/webknossos/cli/export_wkw_as_tiff.py @@ -262,7 +262,7 @@ def main( mag_view = Dataset.open(source).get_layer(layer_name).get_mag(mag) - bbox = mag_view.bounding_box if bbox is None else bbox + bbox = BoundingBox.from_ndbbox(mag_view.bounding_box) if bbox is None else bbox logging.info("Starting tiff export for bounding box: %s", bbox) executor_args = Namespace( diff --git a/webknossos/webknossos/dataset/_array.py b/webknossos/webknossos/dataset/_array.py index 547ca08a0..eab5a4286 100644 --- a/webknossos/webknossos/dataset/_array.py +++ b/webknossos/webknossos/dataset/_array.py @@ -5,7 +5,17 @@ from dataclasses import dataclass from os.path import relpath from pathlib import Path -from typing import TYPE_CHECKING, Any, Dict, Iterator, List, Optional, Type, Union +from typing import ( + TYPE_CHECKING, + Any, + Dict, + Iterator, + List, + Optional, + Tuple, + Type, + Union, +) import numcodecs import numpy as np @@ -14,7 +24,7 @@ from upath import UPath from zarr.storage import FSStore -from ..geometry import BoundingBox, Vec3Int, Vec3IntLike +from ..geometry import BoundingBox, NDBoundingBox, Vec3Int, VecInt from ..utils import is_fs_path, warn_deprecated from .data_format import DataFormat @@ -60,6 +70,9 @@ class ArrayInfo: voxel_type: np.dtype chunk_shape: Vec3Int chunks_per_shard: Vec3Int + shape: VecInt = VecInt(c=1, x=1, y=1, z=1) + dimension_names: Tuple[str, ...] = ("c", "x", "y", "z") + axis_order: VecInt = VecInt(c=3, x=2, y=1, z=0) compression_mode: bool = False @property @@ -103,21 +116,24 @@ def create(cls, path: Path, array_info: ArrayInfo) -> "BaseArray": pass @abstractmethod - def read(self, offset: Vec3IntLike, shape: Vec3IntLike) -> np.ndarray: + def read(self, bbox: NDBoundingBox) -> np.ndarray: pass @abstractmethod - def write(self, offset: Vec3IntLike, data: np.ndarray) -> None: + def write(self, bbox: NDBoundingBox, data: np.ndarray) -> None: pass @abstractmethod def ensure_size( - self, new_shape: Vec3IntLike, align_with_shards: bool = True, warn: bool = False + self, + new_bbox: NDBoundingBox, + align_with_shards: bool = True, + warn: bool = False, ) -> None: pass @abstractmethod - def list_bounding_boxes(self) -> Iterator[BoundingBox]: + def list_bounding_boxes(self) -> Iterator[NDBoundingBox]: "The bounding boxes are measured in voxels of the current mag." @abstractmethod @@ -208,15 +224,15 @@ def create(cls, path: Path, array_info: ArrayInfo) -> "WKWArray": raise ArrayException(f"Exception while creating array {path}") from e return WKWArray(path) - def read(self, offset: Vec3IntLike, shape: Vec3IntLike) -> np.ndarray: - return self._wkw_dataset.read(Vec3Int(offset), Vec3Int(shape)) + def read(self, bbox: NDBoundingBox) -> np.ndarray: + return self._wkw_dataset.read(Vec3Int(bbox.topleft), Vec3Int(bbox.size)) - def write(self, offset: Vec3IntLike, data: np.ndarray) -> None: - self._wkw_dataset.write(Vec3Int(offset), data) + def write(self, bbox: NDBoundingBox, data: np.ndarray) -> None: + self._wkw_dataset.write(Vec3Int(bbox.topleft), data) def ensure_size( self, - new_shape: Vec3IntLike, + new_bbox: NDBoundingBox, align_with_shards: bool = True, warn: bool = False, ) -> None: @@ -228,7 +244,7 @@ def _list_files(self) -> Iterator[Path]: for filename in self._wkw_dataset.list_files() ) - def list_bounding_boxes(self) -> Iterator[BoundingBox]: + def list_bounding_boxes(self) -> Iterator[NDBoundingBox]: def _extract_num(s: str) -> int: match = re.search("[0-9]+", s) assert match is not None @@ -331,19 +347,14 @@ def create(cls, path: Path, array_info: ArrayInfo) -> "ZarrArray": ) return ZarrArray(path) - def read(self, offset: Vec3IntLike, shape: Vec3IntLike) -> np.ndarray: - offset = Vec3Int(offset) - shape = Vec3Int(shape) + def read(self, bbox: NDBoundingBox) -> np.ndarray: + shape = bbox.size zarray = self._zarray with _blosc_disable_threading(): - data = zarray[ - :, - offset.x : (offset.x + shape.x), - offset.y : (offset.y + shape.y), - offset.z : (offset.z + shape.z), - ] + data = zarray[(slice(None),) + bbox.to_slices()] + shape_with_channels = (self.info.num_channels,) + shape.to_tuple() - if data.shape != shape and data.shape != shape_with_channels: + if data.shape not in (shape, shape_with_channels): padded_data = np.zeros(shape_with_channels, dtype=data.dtype) padded_data[ :, @@ -355,16 +366,21 @@ def read(self, offset: Vec3IntLike, shape: Vec3IntLike) -> np.ndarray: return data def ensure_size( - self, new_shape: Vec3IntLike, align_with_shards: bool = True, warn: bool = False + self, + new_bbox: NDBoundingBox, + align_with_shards: bool = True, + warn: bool = False, ) -> None: - new_shape = Vec3Int(new_shape) + new_shape = VecInt(new_bbox.size, axes=new_bbox.axes) zarray = self._zarray - new_shape_tuple = ( - zarray.shape[0], - max(zarray.shape[1], new_shape.x), - max(zarray.shape[2], new_shape.y), - max(zarray.shape[3], new_shape.z), + new_shape_tuple = (zarray.shape[0],) + tuple( + ( + max(zarray.shape[i + 1], new_shape[i]) + if len(zarray.shape) > i + else new_shape[i] + ) + for i in range(len(new_shape)) ) if new_shape_tuple != zarray.shape: if align_with_shards: @@ -388,24 +404,22 @@ def ensure_size( ) zarray.resize(new_shape_tuple) - def write(self, offset: Vec3IntLike, data: np.ndarray) -> None: - offset = Vec3Int(offset) + def write(self, bbox: NDBoundingBox, data: np.ndarray) -> None: + """Writes a ZarrArray. If offset and bbox are given, the bbox is preferred to enable writing of n-dimensional data.""" + # If data is 3-dimensional, it is assumed that num_channels=1. if data.ndim == 3: data = data.reshape((1,) + data.shape) assert data.ndim == 4 with _blosc_disable_threading(): - self.ensure_size(offset + Vec3Int(data.shape[1:4]), warn=True) + self.ensure_size(bbox, warn=True) zarray = self._zarray - zarray[ - :, - offset.x : (offset.x + data.shape[1]), - offset.y : (offset.y + data.shape[2]), - offset.z : (offset.z + data.shape[3]), - ] = data + index_tuple = (slice(None),) + bbox.to_slices() + + zarray[index_tuple] = data - def list_bounding_boxes(self) -> Iterator[BoundingBox]: + def list_bounding_boxes(self) -> Iterator[NDBoundingBox]: zarray = self._zarray chunk_shape = Vec3Int(*zarray.chunks[1:4]) for key in zarray.store.keys(): @@ -484,6 +498,10 @@ def info(self) -> ArrayInfo: from zarrita.sharding import ShardingCodec zarray = self._zarray + if (names := getattr(zarray.metadata, "dimension_names", None)) is None: + dimension_names = ("c", "x", "y", "z") + else: + dimension_names = names if isinstance(zarray, Array): if len(zarray.codec_pipeline.codecs) == 1 and isinstance( zarray.codec_pipeline.codecs[0], ShardingCodec @@ -499,8 +517,10 @@ def info(self) -> ArrayInfo: sharding_codec.codec_pipeline.codecs ), chunk_shape=Vec3Int(chunk_shape[1:4]), - chunks_per_shard=Vec3Int(shard_shape[1:4]) - // Vec3Int(chunk_shape[1:4]), + chunks_per_shard=Vec3Int( + Vec3Int(shard_shape[1:4]) // Vec3Int(chunk_shape[1:4]) + ), + dimension_names=dimension_names, ) return ArrayInfo( data_format=DataFormat.Zarr3, @@ -514,6 +534,7 @@ def info(self) -> ArrayInfo: ) or Vec3Int.full(1), chunks_per_shard=Vec3Int.full(1), + dimension_names=dimension_names, ) else: return ArrayInfo( @@ -523,6 +544,7 @@ def info(self) -> ArrayInfo: compression_mode=zarray.metadata.compressor is not None, chunk_shape=Vec3Int(*zarray.metadata.chunks[1:4]) or Vec3Int.full(1), chunks_per_shard=Vec3Int.full(1), + dimension_names=dimension_names, ) @classmethod @@ -532,37 +554,45 @@ def create(cls, path: Path, array_info: ArrayInfo) -> "ZarritaArray": assert array_info.data_format in (DataFormat.Zarr, DataFormat.Zarr3) if array_info.data_format == DataFormat.Zarr3: + chunk_shape = (array_info.num_channels,) + tuple( + getattr(array_info.chunk_shape, axis, 1) + for axis in array_info.dimension_names[1:] + ) + shard_shape = (array_info.num_channels,) + tuple( + getattr(array_info.shard_shape, axis, 1) + for axis in array_info.dimension_names[1:] + ) Array.create( store=path, - shape=(array_info.num_channels, 1, 1, 1), - chunk_shape=(array_info.num_channels,) - + array_info.shard_shape.to_tuple(), + shape=array_info.shape, + chunk_shape=shard_shape, chunk_key_encoding=("default", "/"), dtype=array_info.voxel_type, - dimension_names=["c", "x", "y", "z"], + dimension_names=array_info.dimension_names, codecs=[ zarrita.codecs.sharding_codec( - chunk_shape=(array_info.num_channels,) - + array_info.chunk_shape.to_tuple(), - codecs=[ - zarrita.codecs.transpose_codec([3, 2, 1, 0]), - zarrita.codecs.bytes_codec(), - zarrita.codecs.blosc_codec( - typesize=array_info.voxel_type.itemsize - ), - ] - if array_info.compression_mode - else [ - zarrita.codecs.transpose_codec([3, 2, 1, 0]), - zarrita.codecs.bytes_codec(), - ], + chunk_shape=chunk_shape, + codecs=( + [ + zarrita.codecs.transpose_codec(array_info.axis_order), + zarrita.codecs.bytes_codec(), + zarrita.codecs.blosc_codec( + typesize=array_info.voxel_type.itemsize + ), + ] + if array_info.compression_mode + else [ + zarrita.codecs.transpose_codec(array_info.axis_order), + zarrita.codecs.bytes_codec(), + ] + ), ) ], ) else: ArrayV2.create( store=path, - shape=(array_info.num_channels, 1, 1, 1), + shape=(array_info.shape), chunks=(array_info.num_channels,) + array_info.chunk_shape.to_tuple(), dtype=array_info.voxel_type, compressor=( @@ -575,46 +605,45 @@ def create(cls, path: Path, array_info: ArrayInfo) -> "ZarritaArray": ) return ZarritaArray(path) - def read(self, offset: Vec3IntLike, shape: Vec3IntLike) -> np.ndarray: - offset = Vec3Int(offset) - shape = Vec3Int(shape) + def read(self, bbox: NDBoundingBox) -> np.ndarray: + shape = bbox.size.to_tuple() zarray = self._zarray + slice_tuple = (slice(None),) + bbox.to_slices() with _blosc_disable_threading(): - data = zarray[ - :, - offset.x : (offset.x + shape.x), - offset.y : (offset.y + shape.y), - offset.z : (offset.z + shape.z), - ] - shape_with_channels = (self.info.num_channels,) + shape.to_tuple() - if data.shape != shape and data.shape != shape_with_channels: - padded_data = np.zeros(shape_with_channels, dtype=data.dtype) - padded_data[ - :, - 0 : data.shape[1], - 0 : data.shape[2], - 0 : data.shape[3], - ] = data + data = zarray[slice_tuple] + + shape_with_channels = (self.info.num_channels,) + shape + if data.shape != shape_with_channels: + data_slice_tuple = tuple(slice(0, size) for size in data.shape) + padded_data = np.zeros(shape_with_channels, dtype=zarray.metadata.dtype) + padded_data[data_slice_tuple] = data data = padded_data return data def ensure_size( - self, new_shape: Vec3IntLike, align_with_shards: bool = True, warn: bool = False + self, + new_bbox: NDBoundingBox, + align_with_shards: bool = True, + warn: bool = False, ) -> None: - new_shape = Vec3Int(new_shape) zarray = self._zarray - new_shape_tuple = ( - zarray.metadata.shape[0], - max(zarray.metadata.shape[1], new_shape.x), - max(zarray.metadata.shape[2], new_shape.y), - max(zarray.metadata.shape[3], new_shape.z), + new_bbox = new_bbox.with_bottomright( + ( + max(zarray.metadata.shape[i + 1], new_bbox.bottomright[i]) + for i in range(len(new_bbox)) + ) ) + new_shape_tuple = (zarray.metadata.shape[0],) + tuple(new_bbox.bottomright) if new_shape_tuple != zarray.metadata.shape: if align_with_shards: shard_shape = self.info.shard_shape - new_shape = new_shape.ceildiv(shard_shape) * shard_shape - new_shape_tuple = (zarray.metadata.shape[0],) + new_shape.to_tuple() + new_aligned_bbox = new_bbox.with_bottomright_xyz( + new_bbox.bottomright_xyz.ceildiv(shard_shape) * shard_shape + ) + new_shape_tuple = ( + zarray.metadata.shape[0], + ) + new_aligned_bbox.bottomright.to_tuple() # Check on-disk for changes to shape current_zarray = zarray.open(self._path) @@ -630,24 +659,21 @@ def ensure_size( ) self._cached_zarray = zarray.resize(new_shape_tuple) - def write(self, offset: Vec3IntLike, data: np.ndarray) -> None: - offset = Vec3Int(offset) - - if data.ndim == 3: + def write(self, bbox: NDBoundingBox, data: np.ndarray) -> None: + if data.ndim == len(bbox): + # the bbox does not include the channels, if data and bbox have the same size there is only 1 channel data = data.reshape((1,) + data.shape) - assert data.ndim == 4 + + assert data.ndim == len(bbox) + 1 with _blosc_disable_threading(): - self.ensure_size(offset + Vec3Int(data.shape[1:4]), warn=True) + self.ensure_size(bbox, warn=True) zarray = self._zarray - zarray[ - :, - offset.x : (offset.x + data.shape[1]), - offset.y : (offset.y + data.shape[2]), - offset.z : (offset.z + data.shape[3]), - ] = data + index_tuple = (slice(None),) + bbox.to_slices() + + zarray[index_tuple] = data - def list_bounding_boxes(self) -> Iterator[BoundingBox]: + def list_bounding_boxes(self) -> Iterator[NDBoundingBox]: raise NotImplementedError def close(self) -> None: diff --git a/webknossos/webknossos/dataset/_utils/buffered_slice_reader.py b/webknossos/webknossos/dataset/_utils/buffered_slice_reader.py index de6969899..69b73031e 100644 --- a/webknossos/webknossos/dataset/_utils/buffered_slice_reader.py +++ b/webknossos/webknossos/dataset/_utils/buffered_slice_reader.py @@ -9,8 +9,7 @@ if TYPE_CHECKING: from ..view import View -from ...geometry import BoundingBox, Vec3IntLike -from ...utils import get_chunks +from ...geometry import BoundingBox, NDBoundingBox, Vec3IntLike class BufferedSliceReader: @@ -23,8 +22,8 @@ def __init__( buffer_size: int = 32, dimension: int = 2, # z *, - relative_bounding_box: Optional[BoundingBox] = None, # in mag1 - absolute_bounding_box: Optional[BoundingBox] = None, # in mag1 + relative_bounding_box: Optional[NDBoundingBox] = None, # in mag1 + absolute_bounding_box: Optional[NDBoundingBox] = None, # in mag1 use_logging: bool = False, ) -> None: """see `View.get_buffered_slice_reader()`""" @@ -61,49 +60,17 @@ def __init__( self.bbox_current_mag = absolute_bounding_box.in_mag(view.mag) def _get_slice_generator(self) -> Generator[np.ndarray, None, None]: - for batch in get_chunks( - list( - range( - self.bbox_current_mag.topleft[self.dimension], - self.bbox_current_mag.bottomright[self.dimension], - ) - ), - self.buffer_size, - ): - n_slices = len(batch) - batch_start_idx = batch[0] - - assert ( - n_slices <= self.buffer_size - ), f"n_slices should at most be batch_size, but {n_slices} > {self.buffer_size}" - - bbox_offset = self.bbox_current_mag.topleft - bbox_size = self.bbox_current_mag.size - - buffer_bounding_box = BoundingBox.from_tuple2( - ( - bbox_offset[: self.dimension] - + (batch_start_idx,) - + bbox_offset[self.dimension + 1 :], - bbox_size[: self.dimension] - + (n_slices,) - + bbox_size[self.dimension + 1 :], - ) - ) + chunk_size = self.bbox_current_mag.size_xyz.to_list() + chunk_size[self.dimension] = self.buffer_size + for chunk in self.bbox_current_mag.chunk(chunk_size): if self.use_logging: - info( - f"({getpid()}) Reading {n_slices} slices at position {batch_start_idx}." - ) + info(f"({getpid()}) Reading data from bbox {chunk}.") data = self.view.read( - absolute_bounding_box=buffer_bounding_box.from_mag_to_mag1( - self.view.mag - ) + absolute_bounding_box=chunk.from_mag_to_mag1(self.view.mag) ) - for current_slice in np.rollaxis( - data, self.dimension + 1 - ): # The '+1' is important because the first dimension is the channel + for current_slice in np.rollaxis(data, chunk.index_xyz[self.dimension]): yield current_slice def __enter__(self) -> Generator[np.ndarray, None, None]: diff --git a/webknossos/webknossos/dataset/_utils/buffered_slice_writer.py b/webknossos/webknossos/dataset/_utils/buffered_slice_writer.py index 95aec77e1..296a02453 100644 --- a/webknossos/webknossos/dataset/_utils/buffered_slice_writer.py +++ b/webknossos/webknossos/dataset/_utils/buffered_slice_writer.py @@ -9,6 +9,8 @@ import numpy as np import psutil +from webknossos.geometry.nd_bounding_box import NDBoundingBox + from ...geometry import BoundingBox, Vec3Int, Vec3IntLike if TYPE_CHECKING: @@ -43,6 +45,8 @@ def __init__( *, relative_offset: Optional[Vec3IntLike] = None, # in mag1 absolute_offset: Optional[Vec3IntLike] = None, # in mag1 + relative_bounding_box: Optional[NDBoundingBox] = None, # in mag1 + absolute_bounding_box: Optional[NDBoundingBox] = None, # in mag1 use_logging: bool = False, ) -> None: """see `View.get_buffered_slice_writer()`""" @@ -52,7 +56,15 @@ def __init__( self.dtype = self.view.get_dtype() self.use_logging = use_logging self.json_update_allowed = json_update_allowed - if offset is None and relative_offset is None and absolute_offset is None: + self.bbox: NDBoundingBox + + if ( + offset is None + and relative_offset is None + and absolute_offset is None + and relative_bounding_box is None + and absolute_bounding_box is None + ): relative_offset = Vec3Int.zeros() if offset is not None: warnings.warn( @@ -61,25 +73,28 @@ def __init__( DeprecationWarning, ) self.offset = None if offset is None else Vec3Int(offset) - self.relative_offset = ( - None if relative_offset is None else Vec3Int(relative_offset) - ) - self.absolute_offset = ( - None if absolute_offset is None else Vec3Int(absolute_offset) - ) - self.dimension = dimension - effective_offset = Vec3Int.full(0) - if self.relative_offset is not None: - effective_offset = self.view.bounding_box.topleft + self.relative_offset + if relative_offset is not None: + self.bbox = BoundingBox( + self.view.bounding_box.topleft + relative_offset, Vec3Int.zeros() + ) + + if absolute_offset is not None: + self.bbox = BoundingBox(absolute_offset, Vec3Int.zeros()) - if self.absolute_offset is not None: - effective_offset = self.absolute_offset + if relative_bounding_box is not None: + self.bbox = relative_bounding_box.offset(self.view.bounding_box.topleft) - view_chunk_depth = self.view.info.chunk_shape[self.dimension] + if absolute_bounding_box is not None: + self.bbox = absolute_bounding_box + + assert 0 <= dimension <= 2 # either x (0), y (1) or z (2) + self.dimension = dimension + + view_chunk_depth = self.view.info.chunk_shape[dimension] if ( - effective_offset is not None - and effective_offset[self.dimension] % view_chunk_depth != 0 + self.bbox is not None + and self.bbox.topleft_xyz[self.dimension] % view_chunk_depth != 0 ): warnings.warn( "[WARNING] Using an offset that doesn't align with the datataset's chunk size, " @@ -91,8 +106,6 @@ def __init__( + "will slow down the buffered slice writer.", ) - assert 0 <= dimension <= 2 - self.slices_to_write: List[np.ndarray] = [] self.current_slice: Optional[int] = None self.buffer_start_slice: Optional[int] = None @@ -129,52 +142,83 @@ def _flush_buffer(self) -> None: max_width = max(section.shape[-2] for section in self.slices_to_write) max_height = max(section.shape[-1] for section in self.slices_to_write) channel_count = self.slices_to_write[0].shape[0] - buffer_depth = min(self.buffer_size, len(self.slices_to_write)) - buffer_bbox = BoundingBox((0, 0, 0), (max_width, max_height, buffer_depth)) - - shard_dimensions = self.view._get_file_dimensions().moveaxis( - -1, self.dimension + buffer_start = Vec3Int.zeros().with_replaced( + self.dimension, self.buffer_start_slice ) + + bbox = self.bbox.with_size_xyz( + Vec3Int(max_width, max_height, buffer_depth).moveaxis( + -1, self.dimension + ) + ).offset(buffer_start) + + shard_dimensions = self.view._get_file_dimensions() chunk_size = Vec3Int( min(shard_dimensions[0], max_width), min(shard_dimensions[1], max_height), buffer_depth, - ) - for chunk_bbox in buffer_bbox.chunk(chunk_size): - info(f"Writing chunk {chunk_bbox}") - width, height, depth = chunk_bbox.size + ).moveaxis(-1, self.dimension) + for chunk_bbox in bbox.chunk(chunk_size): + info(f"Writing chunk {chunk_bbox}.") + data = np.zeros( - (channel_count, width, height, depth), + (channel_count, *chunk_bbox.size), dtype=self.slices_to_write[0].dtype, ) + section_topleft = Vec3Int( + (chunk_bbox.topleft_xyz - bbox.topleft_xyz).moveaxis( + self.dimension, -1 + ) + ) + section_bottomright = Vec3Int( + (chunk_bbox.bottomright_xyz - bbox.topleft_xyz).moveaxis( + self.dimension, -1 + ) + ) + + z_index = chunk_bbox.index_xyz[self.dimension] z = 0 for section in self.slices_to_write: section_chunk = section[ :, - chunk_bbox.topleft.x : chunk_bbox.bottomright.x, - chunk_bbox.topleft.y : chunk_bbox.bottomright.y, + section_topleft.x : section_bottomright.x, + section_topleft.y : section_bottomright.y, ] + # Section chunk includes the axes c, x, y. The remaining axes are added by considering + # the length of the bbox. Since the bbox does not contain the channel, we subtract 2 + # instead of 3. + section_chunk = section_chunk[ + (slice(None), slice(None), slice(None)) + + tuple(np.newaxis for _ in range(len(bbox) - 2)) + ] + section_chunk = np.moveaxis( + section_chunk, + [1, 2], + bbox.index_xyz[: self.dimension] + + bbox.index_xyz[self.dimension + 1 :], + ) + + slice_tuple = (slice(None),) + tuple( + slice(0, min(size1, size2)) + for size1, size2 in zip( + chunk_bbox.size, section_chunk.shape[1:] + ) + ) + data[ - :, 0 : section_chunk.shape[-2], 0 : section_chunk.shape[-1], z + slice_tuple[:z_index] + + (slice(z, z + 1),) + + slice_tuple[z_index + 1 :] ] = section_chunk z += 1 - buffer_start = Vec3Int( - chunk_bbox.topleft.x, chunk_bbox.topleft.y, self.buffer_start_slice - ).moveaxis(-1, self.dimension) - buffer_start_mag1 = buffer_start * self.view.mag.to_vec3_int() - - data = np.moveaxis(data, -1, self.dimension + 1) - self.view.write( data, - offset=buffer_start.add_or_none(self.offset), - relative_offset=buffer_start_mag1.add_or_none(self.relative_offset), - absolute_offset=buffer_start_mag1.add_or_none(self.absolute_offset), json_update_allowed=self.json_update_allowed, + absolute_bounding_box=chunk_bbox.from_mag_to_mag1(self.view._mag), ) del data diff --git a/webknossos/webknossos/dataset/_utils/infer_bounding_box_existing_files.py b/webknossos/webknossos/dataset/_utils/infer_bounding_box_existing_files.py index 177042e01..47c4fcb6c 100644 --- a/webknossos/webknossos/dataset/_utils/infer_bounding_box_existing_files.py +++ b/webknossos/webknossos/dataset/_utils/infer_bounding_box_existing_files.py @@ -10,7 +10,7 @@ def infer_bounding_box_existing_files(mag_view: MagView) -> BoundingBox: The returned bounding box is measured in Mag(1) voxels.""" return reduce( - lambda acc, bbox: acc.extended_by(bbox), + lambda acc, bbox: acc.extended_by(BoundingBox.from_ndbbox(bbox)), mag_view.get_bounding_boxes_on_disk(), BoundingBox.empty(), ) diff --git a/webknossos/webknossos/dataset/_utils/pims_images.py b/webknossos/webknossos/dataset/_utils/pims_images.py index 2587e8d03..bb4942742 100644 --- a/webknossos/webknossos/dataset/_utils/pims_images.py +++ b/webknossos/webknossos/dataset/_utils/pims_images.py @@ -24,6 +24,10 @@ from natsort import natsorted from numpy.typing import DTypeLike +from webknossos.geometry.bounding_box import BoundingBox +from webknossos.geometry.nd_bounding_box import NDBoundingBox + +# pylint: disable=unused-import try: from .pims_czi_reader import PimsCziReader except ImportError: @@ -45,7 +49,7 @@ pass -from ...geometry.vec3_int import Vec3Int +from ...geometry.vec_int import VecInt from ..mag_view import MagView try: @@ -67,7 +71,6 @@ def _assume_color_channel(dim_size: int, dtype: np.dtype) -> bool: class PimsImages: dtype: DTypeLike - expected_shape: Vec3Int num_channels: int def __init__( @@ -86,11 +89,11 @@ def __init__( """ During initialization the pims objects are examined and configured to produce ndarrays that follow the following form: - (self._iter_dim, *self._img_dims) - self._iter_dim can be either "z", "t" or "" if the image is 2D. + (self._iter_axes, *self._bundle_axis) + self._iter_axes can be a list of different axes or an empty list if the image is 2D. In the latter case, the inner 2D image is still wrapped in a single-element list by _open_images() to be consistent with 3D images. - self._img_dims can consist of "x", "y" and "c", where "c" is optional and must be + self._bundle_axis can consist of "x", "y" and "c", where "c" is optional and must be at the start or the end, so one of "xy", "yx", "xyc", "yxc", "cxy", "cyx". The part "IDENTIFY AXIS ORDER" figures out (self._iter_dim, *self._img_dims) @@ -116,9 +119,10 @@ def __init__( self._use_bioformats = use_bioformats ## attributes that will be set in __init__() - self._iter_dim = None + # _bundle_axes + self._iter_axes = None + self._iter_loop_size = None self._possible_layers = {} - # _img_dims ## attributes only for pims.FramesSequenceND instances: # _default_coords @@ -126,7 +130,6 @@ def __init__( ## attributes that will also be set in __init__() # dtype - # expected_shape # num_channels # _first_n_channels @@ -141,10 +144,6 @@ def __init__( self.dtype = images.dtype if isinstance(images, pims.FramesSequenceND): - assert all( - axis in "xyzct" for axis in images.axes - ), f"Found unknown axes {set(images.axes) - set('xyzct')}" - self._default_coords = {} self._init_c_axis = False if isinstance(images, pims.imageio_reader.ImageIOReader): @@ -163,34 +162,49 @@ def __init__( if len(available_czi_channels) > 1: self._possible_layers["czi_channel"] = available_czi_channels + # An image slice should always consist of a 2D image. If there are multiple channels + # the data of each channel is part of the image slices. Possible shapes of an image + # slice are (#y_shape, #x_shape), (1, #y_shape, #x_shape) or (3, #y_shape, #x_shape). if images.sizes.get("c", 1) > 1: - self._img_dims = "cyx" + self._bundle_axes = ["c", "y", "x"] else: if "c" in images.axes: + # When c-axis is not in _bundle_axes and _iter_axes its value at coordinate 0 + # should be returned self._default_coords["c"] = 0 - self._img_dims = "yx" - - self._iter_dim = "" - - if images.sizes.get("z", 1) > 1: - self._iter_dim = "z" - elif "z" in images.axes: - self._default_coords["z"] = 0 - - if timepoint is None: - if images.sizes.get("t", 1) > 1: - if self._iter_dim == "": - self._iter_dim = "t" - else: - self._default_coords["t"] = 0 - self._possible_layers["timepoint"] = list( - range(0, images.sizes["t"]) - ) - elif "t" in images.axes: - self._default_coords["t"] = 0 - else: - assert "t" in images.axes - self._default_coords["t"] = timepoint + self._bundle_axes = ["y", "x"] + + # All other axes are used to iterate over them. The last one is iterated the fastest. + self._iter_axes = list( + set(images.axes).difference({*self._bundle_axes, "c", "z"}) + ) + if "z" in images.axes: + self._iter_axes.append("z") + + if self._timepoint is not None: + # if a timepoint is given, PimsImages should only generate image slices for that timepoint + if "t" in self._iter_axes: + self._iter_axes.remove("t") + self._default_coords["t"] = self._timepoint + + if len(self._iter_axes) > 1: + iter_size = 1 + self._iter_loop_size = dict() + for axis, other_axis in zip( + self._iter_axes[-1:0:-1], self._iter_axes[-2::-1] + ): + # Creates a dict that contains the size of the loop for each axis + # the axes are identified by their index in the _iter_axes list + # the last axis is the fastest iterating axis, therfore the size of the loop + # for the last axis is 1. For all other axes it is the product of all previous axes sizes. + # self._iter_axes[-1:0:-1] is a reversed copy of self._iter_axes without the last element + # e.g. [1,2,3,4] -> [4,3,2] + # self._iter_axes[-2::-1] is a reversed copy of self._iter_axes without the first element + # e.g. [1,2,3,4] -> [3,2,1] + self._iter_loop_size[other_axis] = ( + iter_size := iter_size * images.sizes[axis] + ) + else: # Fallback for generic pims classes that do not name their # dimensions as pims.FramesSequenceND does: @@ -201,31 +215,31 @@ def __init__( if len(images.shape) == 2: # Assume yx - self._img_dims = "yx" - self._iter_dim = "" + self._bundle_axes = ["y", "x"] + self._iter_axes = [] elif len(images.shape) == 3: # Assume yxc, cyx or zyx if _assume_color_channel(images.shape[2], images.dtype): - self._img_dims = "yxc" - self._iter_dim = "" + self._bundle_axes = ["y", "x", "c"] + self._iter_axes = [] elif images.shape[0] == 1 or ( _allow_channels_first and _assume_color_channel(images.shape[0], images.dtype) ): - self._img_dims = "cyx" - self._iter_dim = "" + self._bundle_axes = ["c", "y", "x"] + self._iter_axes = [] else: - self._img_dims = "yx" - self._iter_dim = "z" + self._bundle_axes = ["y", "x"] + self._iter_axes = ["z"] elif len(images.shape) == 4: # Assume zcyx or zyxc if images.shape[1] == 1 or _assume_color_channel( images.shape[1], images.dtype ): - self._img_dims = "cyx" + self._bundle_axes = ["c", "y", "x"] else: - self._img_dims = "yxc" - self._iter_dim = "z" + self._bundle_axes = ["y", "x", "c"] + self._iter_axes = ["z"] elif len(images.shape) == 5: # Assume tzcyx or tzyxc # t has to be constant for this reader to obtain 4D image @@ -236,13 +250,15 @@ def __init__( raise RuntimeError( f"Got {len(images.shape)} axes for the images after " + "removing time dimension, can only map to 3D+channels." + + "To import image with more dimensions use dataformat" + + "Zarr3 and set use_bioformats=True." ) if _assume_color_channel(images.shape[2], images.dtype): - self._img_dims = "cyx" + self._bundle_axes = ["c", "y", "x"] else: - self._img_dims = "yxc" - self._iter_dim = "z" + self._bundle_axes = ["y", "x", "c"] + self._iter_axes = ["z"] self._timepoint = 0 if images.shape[0] > 1: self._possible_layers["timepoint"] = list( @@ -251,36 +267,29 @@ def __init__( else: raise RuntimeError( f"Got {len(images.shape)} axes for the images, " - + "cannot map to 3D+channels+timepoints." + + "but don't have axes information. Try to open " + + "an N-dimensional image file with use_bioformats=" + + "True." ) - ############################# - # IDENTIFY SHAPE & CHANNELS # - ############################# + ######################### + # IDENTIFY NUM_CHANNELS # + ######################### with self._open_images() as images: - if isinstance(images, list): - images_shape = (len(images),) + cast( - pims.FramesSequence, images[0] - ).shape - else: - images_shape = images.shape - c_index = self._img_dims.find("c") - if c_index == -1: - self.num_channels = 1 - else: - # Since images_shape contains the first dimension iter_dim, - # we need to offset the index by one before accessing the images_shape. - # images_shape corresponds to (z, *_img_dims) + try: + c_index = self._bundle_axes.index("c") + if isinstance(images, list): + images_shape = (len(images),) + cast( + pims.FramesSequence, images[0] + ).shape + else: + images_shape = images.shape # pylint: disable=no-member + self.num_channels = images_shape[c_index + 1] - x_index = self._img_dims.find("x") + 1 - y_index = self._img_dims.find("y") + 1 - if swap_xy: - x_index, y_index = y_index, x_index - self.expected_shape = Vec3Int( - images_shape[x_index], images_shape[y_index], images_shape[0] - ) + except ValueError: + self.num_channels = 1 self._first_n_channels = None if self._channel is not None: @@ -418,13 +427,13 @@ def _open_images( self, ) -> Iterator[Union[pims.FramesSequence, List[pims.FramesSequence]]]: """ - This yields well-defined images of the form (self._iter_dim, *self._img_dims), + This yields well-defined images of the form (self._iter_axes, *self._bundle_axes), after IDENTIFY AXIS ORDER of __init__() has run. For a 2D image this is achieved by wrapping it in a list. """ images_context_manager: Optional[ContextManager] with warnings.catch_warnings(): - if isinstance(self._original_images, pims.FramesSequence): + if isinstance(self._original_images, pims.FramesSequenceND): images_context_manager = nullcontext(enter_result=self._original_images) else: exceptions: List[Exception] = [] @@ -454,7 +463,7 @@ def _open_images( with images_context_manager as images: if isinstance(images, pims.FramesSequenceND): - if hasattr(self, "_img_dims"): + if hasattr(self, "_bundle_axes"): # first part of __init__() has happened images.default_coords.update(self._default_coords) if self._init_c_axis and "c" not in images.sizes: @@ -466,29 +475,41 @@ def _open_images( images._get_frame_dict[key + ("c",)] = ( images._get_frame_dict.pop(key) ) - images.bundle_axes = self._img_dims - images.iter_axes = self._iter_dim or "" + self._bundle_axes.remove("c") + self._bundle_axes.append("c") + images.bundle_axes = self._bundle_axes + images.iter_axes = self._iter_axes else: if self._timepoint is not None: images = images[self._timepoint] - if self._iter_dim == "": + if self._iter_axes and "t" in self._iter_axes: + self._iter_axes.remove("t") + if self._iter_axes == []: # add outer list to wrap 2D images as 3D-like structure images = [images] yield images def copy_to_view( self, - args: Tuple[int, int], + args: Union[BoundingBox, NDBoundingBox], mag_view: MagView, is_segmentation: bool, dtype: Optional[DTypeLike] = None, ) -> Tuple[Tuple[int, int], Optional[int]]: """Copies the images according to the passed arguments to the given mag_view. - args is expected to be the start and end of the z-range, meant for usage with an executor. + args is expected to be a (ND)BoundingBox the start and end of the z-range, meant for usage with an executor. copy_to_view returns an iterable of image shapes and largest segment ids. When using this method a manual update of the bounding box and the largest segment id might be necessary. """ - z_start, z_end = args + relative_bbox = args + + assert all( + size == 1 + for size, axis in zip(relative_bbox.size, relative_bbox.axes) + if axis not in ("x", "y", "z") + ), "The delivered BoundingBox has to be flat except for x,y and z dimension." + + z_start, z_end = relative_bbox.get_bounds("z") shapes = [] max_id: Optional[int] if is_segmentation: @@ -497,10 +518,22 @@ def copy_to_view( max_id = None with self._open_images() as images: + if self._iter_axes is not None and self._iter_loop_size is not None: + # select the range of images that represents one xyz combination + lower_bounds = sum( + self._iter_loop_size[axis_name] + * relative_bbox.get_bounds(axis_name)[0] + for axis_name in self._iter_axes[:-1] + ) + upper_bounds = lower_bounds + relative_bbox.get_shape("z") + images = images[lower_bounds:upper_bounds] if self._flip_z: - images = images[::-1] + images = images[::-1] # pylint: disable=unsubscriptable-object + with mag_view.get_buffered_slice_writer( - relative_offset=(0, 0, z_start * mag_view.mag.z), + # Previously only z_start and its end were important, now the slice writer needs to know + # which axis is currently written. + relative_bounding_box=relative_bbox, buffer_size=mag_view.info.chunk_shape.z, # copy_to_view is typically used in a multiprocessing-context. Therefore the # buffered slice writer should not update the json file to avoid race conditions. @@ -509,15 +542,17 @@ def copy_to_view( for image_slice in images[z_start:z_end]: image_slice = np.array(image_slice) # place channels first - if self._img_dims.endswith("c"): - image_slice = np.moveaxis(image_slice, source=-1, destination=0) - # ensure the last two axes are xy: - if ("yx" in self._img_dims and not self._swap_xy) or ( - "xy" in self._img_dims and self._swap_xy - ): - image_slice = image_slice.swapaxes(-1, -2) - - if "c" in self._img_dims: + if "c" in self._bundle_axes: + if hasattr(self, "_init_c_axis") and self._init_c_axis: + # Bugfix for ImageIOReader which misses channel axis sometimes, + # assuming channels come last. _init_c_axis is set in __init__(). + # This might get fixed via + image_slice = image_slice[0] + image_slice = np.moveaxis( + image_slice, + source=self._bundle_axes.index("c"), + destination=0, + ) if self._channel is not None: image_slice = image_slice[self._channel : self._channel + 1] elif self._first_n_channels is not None: @@ -537,6 +572,10 @@ def copy_to_view( if max_id is not None: max_id = max(max_id, image_slice.max()) + + if self._swap_xy is False: + image_slice = np.moveaxis(image_slice, -1, -2) + shapes.append(image_slice.shape[-2:]) writer.send(image_slice) @@ -548,6 +587,78 @@ def get_possible_layers(self) -> Optional[Dict["str", List[int]]]: else: return self._possible_layers + @property + def expected_bbox(self) -> NDBoundingBox: + # replaces the previous expected_shape to enable n-dimensional input files + with self._open_images() as images: + if isinstance(images, pims.FramesSequenceND): + axes = images.axes + images_shape = tuple(images.sizes[axis] for axis in axes) + else: + if isinstance(images, list): + images_shape = (len(images),) + cast( + pims.FramesSequence, images[0] + ).shape + + else: + images_shape = images.shape # pylint: disable=no-member + if len(images_shape) == 3: + axes = ("z", "y", "x") + else: + axes = ("z", "c", "y", "x") + + if self._iter_loop_size is None: + # There is no or only one element in self._iter_axes, so a 3D bounding box is sufficient. + x_index, y_index = ( + axes.index("x"), + axes.index("y"), + ) + if self._iter_axes: + try: + # In case the naming of the third axis is not "z", + # it is still considered as the z-axis. + z_index = axes.index(self._iter_axes[0]) + except ValueError: + z_index = axes.index("z") + z_shape = images_shape[z_index] + else: + z_shape = 1 + if self._swap_xy: + x_index, y_index = y_index, x_index + return BoundingBox( + (0, 0, 0), + (images_shape[x_index], images_shape[y_index], z_shape), + ) + else: + if isinstance(images, pims.FramesSequenceND): + axes_names = (self._iter_axes or []) + [ + axis for axis in self._bundle_axes if axis != "c" + ] + axes_sizes = [ + images.sizes[axis] # pylint: disable=no-member + for axis in axes_names + ] + axes_index = list(range(1, len(axes_names) + 1)) + topleft = VecInt.zeros(tuple(axes_names)) + + if self._swap_xy: + x_index, y_index = axes_names.index("x"), axes_names.index("y") + axes_sizes[x_index], axes_sizes[y_index] = ( + axes_sizes[y_index], + axes_sizes[x_index], + ) + + return NDBoundingBox( + topleft, + VecInt(axes_sizes, axes=axes_names), + axes_names, + VecInt(axes_index, axes=axes_names), + ) + + raise ValueError( + "It seems as if you try to load an N-dimensional image from 2D images. This is currently not supported." + ) + T = TypeVar("T", bound=Tuple[int, ...]) @@ -605,4 +716,4 @@ def has_image_z_dimension( flip_z=False, ) - return pims_images.expected_shape.z > 1 + return pims_images.expected_bbox.get_shape("z") > 1 diff --git a/webknossos/webknossos/dataset/dataset.py b/webknossos/webknossos/dataset/dataset.py index ddf74051b..46b7e96c6 100644 --- a/webknossos/webknossos/dataset/dataset.py +++ b/webknossos/webknossos/dataset/dataset.py @@ -35,6 +35,8 @@ from numpy.typing import DTypeLike from upath import UPath +from webknossos.geometry.vec_int import VecInt, VecIntLike + from ..client.api_client.models import ApiDataset from ..geometry.vec3_int import Vec3Int, Vec3IntLike from ._array import ArrayException, ArrayInfo, BaseArray @@ -60,7 +62,7 @@ from ..administration.user import Team from ..client._upload_dataset import LayerToLink -from ..geometry import BoundingBox, Mag +from ..geometry import BoundingBox, Mag, NDBoundingBox from ..utils import ( copy_directory_with_symlinks, copytree, @@ -778,7 +780,7 @@ def add_layer( dtype_per_channel: Optional[DTypeLike] = None, num_channels: Optional[int] = None, data_format: Union[str, DataFormat] = DEFAULT_DATA_FORMAT, - bounding_box: Optional[BoundingBox] = None, + bounding_box: Optional[NDBoundingBox] = None, **kwargs: Any, ) -> Layer: """ @@ -1043,7 +1045,7 @@ def add_layer_from_images( compress: bool = False, *, ## other arguments - topleft: Vec3IntLike = Vec3Int.zeros(), # in Mag(1) + topleft: VecIntLike = Vec3Int.zeros(), # in Mag(1) swap_xy: bool = False, flip_x: bool = False, flip_y: bool = False, @@ -1210,8 +1212,12 @@ def add_layer_from_images( num_channels=pims_images.num_channels, **add_layer_kwargs, # type: ignore[arg-type] ) + + expected_bbox = pims_images.expected_bbox + + # When the expected bbox is 2D the chunk_shape is set to 2D too. if ( - pims_images.expected_shape.z == 1 + expected_bbox.get_shape("z") == 1 and layer.data_format == DataFormat.Zarr ): if chunk_shape is None: @@ -1222,18 +1228,14 @@ def add_layer_from_images( if chunks_per_shard is None and layer.data_format == DataFormat.Zarr3: chunks_per_shard = DEFAULT_CHUNKS_PER_SHARD_FROM_IMAGES + mag = Mag(mag) + layer.bounding_box = expected_bbox.from_mag_to_mag1(mag).offset(topleft) mag_view = layer.add_mag( mag=mag, chunk_shape=chunk_shape, chunks_per_shard=chunks_per_shard, compress=compress, ) - mag = mag_view.mag - layer.bounding_box = ( - BoundingBox((0, 0, 0), pims_images.expected_shape) - .from_mag_to_mag1(mag) - .offset(topleft) - ) if batch_size is None: if compress: @@ -1257,10 +1259,44 @@ def add_layer_from_images( ) args = [] - for z_start in range(0, pims_images.expected_shape.z, batch_size): - z_end = min(z_start + batch_size, pims_images.expected_shape.z) - # return shapes and set to union when using --pad - args.append((z_start, z_end)) + bbox = layer.bounding_box + additional_axes = [ + axis_name for axis_name in bbox.axes if axis_name not in ("x", "y", "z") + ] + additional_axes_shapes = tuple( + product( + *[range(bbox.get_shape(axis_name)) for axis_name in additional_axes] + ) + ) + if additional_axes and layer.data_format != DataFormat.Zarr3: + assert ( + len(additional_axes_shapes) == 1 + ), "The data stores additional axes with shape bigger than 1. These are only supported by data format Zarr3." + + # Convert NDBoundingBox to 3D BoundingBox + bbox = BoundingBox( + bbox.topleft_xyz, + bbox.size_xyz, + ) + expected_bbox = bbox + additional_axes = [] + + z_shape = bbox.get_shape("z") + bbox = bbox.with_topleft(VecInt.zeros(bbox.axes)) + for z_start in range(0, z_shape, batch_size): + z_size = min(batch_size, z_shape - z_start) + z_bbox = bbox.with_bounds("z", z_start, z_size) + if not additional_axes: + args.append(z_bbox) + else: + for shape in additional_axes_shapes: + reduced_bbox = z_bbox + for index, axis in enumerate(additional_axes): + reduced_bbox = reduced_bbox.with_bounds( + axis, shape[index], 1 + ) + args.append(reduced_bbox) + with warnings.catch_warnings(): # Block alignmnent within the dataset should not be a problem, since shard-wise chunking is enforced. # However, dataset borders might change between different parallelized writes, when sizes differ. @@ -1289,18 +1325,14 @@ def add_layer_from_images( if category == "segmentation": max_id = max(max_ids) cast(SegmentationLayer, layer).largest_segment_id = max_id - actual_size = Vec3Int( - dimwise_max(shapes) + (pims_images.expected_shape.z,) - ) - layer.bounding_box = ( - BoundingBox((0, 0, 0), actual_size) - .from_mag_to_mag1(mag) - .offset(topleft) + layer.bounding_box = layer.bounding_box.with_size_xyz( + Vec3Int(dimwise_max(shapes) + (layer.bounding_box.get_shape("z"),)) + * mag.to_vec3_int().with_z(1) ) - if pims_images.expected_shape != actual_size: + if expected_bbox != layer.bounding_box: warnings.warn( "[WARNING] Some images are larger than expected, smaller slices are padded with zeros now. " - + f"New size is {actual_size}, expected {pims_images.expected_shape}." + + f"New bbox is {layer.bounding_box}, expected {expected_bbox}." ) if first_layer is None: first_layer = layer @@ -1510,7 +1542,7 @@ def add_fs_copy_layer( self._export_as_json() return self.layers[new_layer_name] - def calculate_bounding_box(self) -> BoundingBox: + def calculate_bounding_box(self) -> NDBoundingBox: """ Calculates and returns the enclosing bounding box of all data layers of the dataset. """ @@ -1731,12 +1763,19 @@ def _export_as_json(self) -> None: self._ensure_writable() properties_on_disk = self._load_properties() - - if properties_on_disk != self._last_read_properties: + try: + if properties_on_disk != self._last_read_properties: + warnings.warn( + "[WARNING] While exporting the dataset's properties, properties were found on disk which are " + + "newer than the ones that were seen last time. The properties will be overwritten. This is " + + "likely happening because multiple processes changed the metadata of this dataset." + ) + except ValueError: + # the __eq__ operator raises a ValueError when two bboxes are not comparable. This is the case when the + # axes are not the same. During initialization axes are added or moved sometimes. warnings.warn( - "[WARNING] While exporting the dataset's properties, properties were found on disk which are " - + "newer than the ones that were seen last time. The properties will be overwritten. This is " - + "likely happening because multiple processes changed the metadata of this dataset." + "[WARNING] Properties changed in a way that they are not comparable anymore. Most likely " + + "the bounding box naming or axis order changed." ) with (self.path / PROPERTIES_FILE_NAME).open("w", encoding="utf-8") as outfile: diff --git a/webknossos/webknossos/dataset/layer.py b/webknossos/webknossos/dataset/layer.py index 7d37308f3..80de7443c 100644 --- a/webknossos/webknossos/dataset/layer.py +++ b/webknossos/webknossos/dataset/layer.py @@ -13,7 +13,7 @@ from numpy.typing import DTypeLike from upath import UPath -from ..geometry import BoundingBox, Mag, Vec3Int, Vec3IntLike +from ..geometry import Mag, NDBoundingBox, Vec3Int, Vec3IntLike from ._array import ArrayException, BaseArray, DataFormat from ._downsampling_utils import ( calculate_default_coarsest_mag, @@ -192,7 +192,7 @@ def __init__(self, dataset: "Dataset", properties: LayerProperties) -> None: self.path.mkdir(parents=True, exist_ok=True) for mag in properties.mags: - self._setup_mag(Mag(mag.mag)) + self._setup_mag(Mag(mag.mag), mag.path) # Only keep the properties of mags that were initialized. # Sometimes the directory of a mag is removed from disk manually, but the properties are not updated. self._properties.mags = [ @@ -251,11 +251,11 @@ def dataset(self) -> "Dataset": return self._dataset @property - def bounding_box(self) -> BoundingBox: + def bounding_box(self) -> NDBoundingBox: return self._properties.bounding_box @bounding_box.setter - def bounding_box(self, bbox: BoundingBox) -> None: + def bounding_box(self, bbox: NDBoundingBox) -> None: """ Updates the offset and size of the bounding box of this layer in the properties. """ @@ -264,9 +264,7 @@ def bounding_box(self, bbox: BoundingBox) -> None: self._properties.bounding_box = bbox self.dataset._export_as_json() for mag in self.mags.values(): - mag._array.ensure_size( - bbox.align_with_mag(mag.mag).in_mag(mag.mag).bottomright - ) + mag._array.ensure_size(bbox.align_with_mag(mag.mag).in_mag(mag.mag)) @property def category(self) -> LayerCategoryType: @@ -399,9 +397,7 @@ def add_mag( create=True, ) - mag_view._array.ensure_size( - self.bounding_box.align_with_mag(mag).in_mag(mag).bottomright - ) + mag_view._array.ensure_size(self.bounding_box.align_with_mag(mag).in_mag(mag)) self._mags[mag] = mag_view mag_array_info = mag_view.info @@ -414,7 +410,12 @@ def add_mag( else None ), axis_order=( - {"x": 1, "y": 2, "z": 3, "c": 0} + dict( + zip( + ("c", "x", "y", "z"), + (0, *self.bounding_box.index_xyz), + ) + ) if mag_array_info.data_format in (DataFormat.Zarr, DataFormat.Zarr3) else None ), @@ -451,7 +452,13 @@ def add_mag_for_existing_files( else None ), axis_order=( - {"x": 1, "y": 2, "z": 3, "c": 0} + { + key: value + for key, value in zip( + ("c", *self.bounding_box.axes), + (0, *self.bounding_box.index), + ) + } if mag_array_info.data_format in (DataFormat.Zarr, DataFormat.Zarr3) else None ), @@ -473,7 +480,7 @@ def get_or_add_mag( file_len: Optional[int] = None, # deprecated ) -> MagView: """ - Creates a new mag called and adds it to the dataset, in case it did not exist before. + Creates a new mag and adds it to the dataset, in case it did not exist before. Then, returns the mag. See `add_mag` for more information. @@ -559,9 +566,11 @@ def add_copy_mag( chunk_shape=chunk_shape or foreign_mag_view._array_info.chunk_shape, chunks_per_shard=chunks_per_shard or foreign_mag_view._array_info.chunks_per_shard, - compress=compress - if compress is not None - else foreign_mag_view._array_info.compression_mode, + compress=( + compress + if compress is not None + else foreign_mag_view._array_info.compression_mode + ), ) if extend_layer_bounding_box: @@ -1075,7 +1084,7 @@ def upsample( # Restoring the original layer bbox self.bounding_box = old_layer_bbox - def _setup_mag(self, mag: Mag) -> None: + def _setup_mag(self, mag: Mag, path: Optional[str] = None) -> None: # This method is used to initialize the mag when opening the Dataset. This does not create e.g. the wk_header. mag_name = mag.to_layer_name() @@ -1085,7 +1094,7 @@ def _setup_mag(self, mag: Mag) -> None: try: cls_array = BaseArray.get_class(self._properties.data_format) info = cls_array.open( - _find_mag_path_on_disk(self.dataset.path, self.name, mag_name) + _find_mag_path_on_disk(self.dataset.path, self.name, mag_name, path) ).info self._mags[mag] = MagView( self, diff --git a/webknossos/webknossos/dataset/mag_view.py b/webknossos/webknossos/dataset/mag_view.py index c3e9f7e98..e7bf52c41 100644 --- a/webknossos/webknossos/dataset/mag_view.py +++ b/webknossos/webknossos/dataset/mag_view.py @@ -10,7 +10,7 @@ from cluster_tools import Executor from upath import UPath -from ..geometry import BoundingBox, Mag, Vec3Int, Vec3IntLike +from ..geometry import Mag, NDBoundingBox, Vec3Int, Vec3IntLike, VecInt from ..utils import ( NDArrayLike, get_executor_for_args, @@ -30,7 +30,12 @@ from .view import View -def _find_mag_path_on_disk(dataset_path: Path, layer_name: str, mag_name: str) -> Path: +def _find_mag_path_on_disk( + dataset_path: Path, layer_name: str, mag_name: str, path: Optional[str] = None +) -> Path: + if path is not None: + return dataset_path / path + mag = Mag(mag_name) short_mag_file_path = dataset_path / layer_name / mag.to_layer_name() long_mag_file_path = dataset_path / layer_name / mag.to_long_layer_name() @@ -73,6 +78,15 @@ def __init__( chunk_shape=chunk_shape, chunks_per_shard=chunks_per_shard, compression_mode=compression_mode, + axis_order=VecInt( + 0, *layer.bounding_box.index, axes=("c",) + layer.bounding_box.axes + ), + shape=VecInt( + layer.num_channels, + *VecInt.ones(layer.bounding_box.axes), + axes=("c",) + layer.bounding_box.axes, + ), + dimension_names=("c",) + layer.bounding_box.axes, ) if create: self_path = layer.dataset.path / layer.name / mag.to_layer_name() @@ -81,14 +95,14 @@ def __init__( super().__init__( _find_mag_path_on_disk(layer.dataset.path, layer.name, mag.to_layer_name()), array_info, - bounding_box=None, + bounding_box=layer.bounding_box, mag=mag, ) self._layer = layer # Overwrites of View methods: @property - def bounding_box(self) -> BoundingBox: + def bounding_box(self) -> NDBoundingBox: # Overwrites View's method since no extra bbox is stored for a MagView, # but the Layer's bbox is used: return self.layer.bounding_box.align_with_mag(self._mag, ceil=True) @@ -105,7 +119,7 @@ def global_offset(self) -> Vec3Int: return Vec3Int.zeros() @property - def size(self) -> Vec3Int: + def size(self) -> VecInt: """⚠️ Deprecated, use `mag_view.bounding_box.in_mag(mag_view.mag).bottomright` instead.""" warnings.warn( "[DEPRECATION] mag_view.size is deprecated. " @@ -149,6 +163,8 @@ def write( *, relative_offset: Optional[Vec3IntLike] = None, # in mag1 absolute_offset: Optional[Vec3IntLike] = None, # in mag1 + relative_bounding_box: Optional[NDBoundingBox] = None, # in mag1 + absolute_bounding_box: Optional[NDBoundingBox] = None, # in mag1 ) -> None: if offset is not None: if self._mag == Mag(1): @@ -166,14 +182,30 @@ def write( DeprecationWarning, ) - if all(i is None for i in [offset, absolute_offset, relative_offset]): + if all( + i is None + for i in [ + offset, + absolute_offset, + relative_offset, + absolute_bounding_box, + relative_bounding_box, + ] + ): relative_offset = Vec3Int.zeros() + if (absolute_bounding_box or relative_bounding_box) is not None: + data_shape = None + else: + data_shape = Vec3Int(data.shape[-3:]) + mag1_bbox = self._get_mag1_bbox( abs_current_mag_offset=offset, rel_mag1_offset=relative_offset, abs_mag1_offset=absolute_offset, - current_mag_size=Vec3Int(data.shape[-3:]), + abs_mag1_bbox=absolute_bounding_box, + rel_mag1_bbox=relative_bounding_box, + current_mag_size=data_shape, ) # Only update the layer's bbox if we are actually larger @@ -183,8 +215,8 @@ def write( super().write( data, - absolute_offset=mag1_bbox.topleft, json_update_allowed=json_update_allowed, + absolute_bounding_box=mag1_bbox, ) def read( @@ -196,8 +228,8 @@ def read( *, relative_offset: Optional[Vec3IntLike] = None, # in mag1 absolute_offset: Optional[Vec3IntLike] = None, # in mag1 - relative_bounding_box: Optional[BoundingBox] = None, # in mag1 - absolute_bounding_box: Optional[BoundingBox] = None, # in mag1 + relative_bounding_box: Optional[NDBoundingBox] = None, # in mag1 + absolute_bounding_box: Optional[NDBoundingBox] = None, # in mag1 ) -> np.ndarray: # THIS METHOD CAN BE REMOVED WHEN THE DEPRECATED OFFSET IS REMOVED @@ -237,12 +269,14 @@ def get_view( *, relative_offset: Optional[Vec3IntLike] = None, # in mag1 absolute_offset: Optional[Vec3IntLike] = None, # in mag1 + relative_bbox: Optional[NDBoundingBox] = None, # in mag1 + absolute_bbox: Optional[NDBoundingBox] = None, # in mag1 read_only: Optional[bool] = None, ) -> View: # THIS METHOD CAN BE REMOVED WHEN THE DEPRECATED OFFSET IS REMOVED # This has other defaults than the View implementation - # (all deprecations are handled in the subsclass) + # (all deprecations are handled in the superclass) bb = self.bounding_box.in_mag(self._mag) if offset is not None and size is None: offset = Vec3Int(offset) @@ -253,12 +287,14 @@ def get_view( size, relative_offset=relative_offset, absolute_offset=absolute_offset, + relative_bbox=relative_bbox, + absolute_bbox=absolute_bbox, read_only=read_only, ) def get_bounding_boxes_on_disk( self, - ) -> Iterator[BoundingBox]: + ) -> Iterator[NDBoundingBox]: """ Returns a Mag(1) bounding box for each file on disk. diff --git a/webknossos/webknossos/dataset/properties.py b/webknossos/webknossos/dataset/properties.py index 1cf285b48..72686bfdf 100644 --- a/webknossos/webknossos/dataset/properties.py +++ b/webknossos/webknossos/dataset/properties.py @@ -1,3 +1,4 @@ +import copy from pathlib import Path from typing import Any, Callable, Dict, List, Optional, Tuple, Type, Union @@ -6,7 +7,7 @@ import numpy as np from cattr.gen import make_dict_structure_fn, make_dict_unstructure_fn, override -from ..geometry import BoundingBox, Mag, Vec3Int +from ..geometry import Mag, NDBoundingBox, Vec3Int from ..utils import snake_to_camel_case, warn_deprecated from ._array import ArrayException, BaseArray, DataFormat from .layer_categories import LayerCategoryType @@ -119,6 +120,7 @@ class LayerViewConfiguration: @attr.define class MagViewProperties: mag: Mag + path: Optional[str] = None cube_length: Optional[int] = None axis_order: Optional[Dict[str, int]] = None @@ -128,11 +130,18 @@ def resolution(self) -> Mag: return self.mag +@attr.define +class AxisProperties: + name: str + bounds: Tuple[int, int] + index: int + + @attr.define class LayerProperties: name: str category: LayerCategoryType - bounding_box: BoundingBox + bounding_box: NDBoundingBox element_class: str data_format: DataFormat mags: List[MagViewProperties] @@ -169,10 +178,10 @@ class DatasetProperties: dataset_converter = cattr.Converter() # register (un-)structure hooks for non-attr-classes -bbox_to_wkw: Callable[[BoundingBox], dict] = lambda o: o.to_wkw_dict() # noqa: E731 -dataset_converter.register_unstructure_hook(BoundingBox, bbox_to_wkw) +bbox_to_wkw: Callable[[NDBoundingBox], dict] = lambda o: o.to_wkw_dict() # noqa: E731 +dataset_converter.register_unstructure_hook(NDBoundingBox, bbox_to_wkw) dataset_converter.register_structure_hook( - BoundingBox, lambda d, _: BoundingBox.from_wkw_dict(d) + NDBoundingBox, lambda d, _: NDBoundingBox.from_wkw_dict(d) ) @@ -234,13 +243,13 @@ def mag_unstructure(mag: Mag) -> List[int]: # The serialization of `LayerProperties` differs slightly based on whether it is a `wkw` or `zarr` layer. # These post-unstructure and pre-structure functions perform the conditional field renames. -def mag_view_properties_post_structure(d: Dict[str, Any]) -> Dict[str, Any]: +def mag_view_properties_post_unstructure(d: Dict[str, Any]) -> Dict[str, Any]: d["resolution"] = d["mag"] del d["mag"] return d -def mag_view_properties_pre_unstructure(d: Dict[str, Any]) -> Dict[str, Any]: +def mag_view_properties_pre_structure(d: Dict[str, Any]) -> Dict[str, Any]: d["mag"] = d["resolution"] del d["resolution"] return d @@ -257,9 +266,14 @@ def __layer_properties_post_unstructure( d = converter_fn(obj) if d["dataFormat"] == "wkw": d["wkwResolutions"] = [ - mag_view_properties_post_structure(m) for m in d["mags"] + mag_view_properties_post_unstructure(m) for m in d["mags"] ] del d["mags"] + + # json expects nd_bounding_box to be represented as bounding_box and additional_axes + if "additionalAxes" in d["boundingBox"]: + d["additionalAxes"] = d["boundingBox"]["additionalAxes"] + del d["boundingBox"]["additionalAxes"] return d return __layer_properties_post_unstructure @@ -280,9 +294,25 @@ def __layer_properties_pre_structure( ) -> Union[LayerProperties, SegmentationLayerProperties]: if d["dataFormat"] == "wkw": d["mags"] = [ - mag_view_properties_pre_unstructure(m) for m in d["wkwResolutions"] + mag_view_properties_pre_structure(m) for m in d["wkwResolutions"] ] del d["wkwResolutions"] + # bounding_box and additional_axes are internally handled as nd_bounding_box + if "additionalAxes" in d: + d["boundingBox"]["additionalAxes"] = copy.deepcopy(d["additionalAxes"]) + del d["additionalAxes"] + if len(d["mags"]) > 0: + first_mag = d["mags"][0] + if "axisOrder" in first_mag: + assert ( + first_mag["axisOrder"]["c"] == 0 + ), "The channels c must have index 0 in axis order." + assert all( + first_mag["axisOrder"] == mag["axisOrder"] for mag in d["mags"] + ) + d["boundingBox"]["axisOrder"] = copy.deepcopy(first_mag["axisOrder"]) + del d["boundingBox"]["axisOrder"]["c"] + obj = converter_fn(d, type_value) return obj diff --git a/webknossos/webknossos/dataset/view.py b/webknossos/webknossos/dataset/view.py index d6c77d6e3..2b9171b36 100644 --- a/webknossos/webknossos/dataset/view.py +++ b/webknossos/webknossos/dataset/view.py @@ -19,7 +19,9 @@ import wkw from cluster_tools import Executor -from ..geometry import BoundingBox, Mag, Vec3Int, Vec3IntLike +from webknossos.geometry.vec_int import VecInt + +from ..geometry import BoundingBox, Mag, NDBoundingBox, Vec3Int, Vec3IntLike from ..utils import ( get_executor_for_args, get_rich_progress, @@ -54,7 +56,7 @@ class View: _path: Path _array_info: ArrayInfo - _bounding_box: Optional[BoundingBox] + _bounding_box: Optional[NDBoundingBox] _read_only: bool _cached_array: Optional[BaseArray] _mag: Mag @@ -64,7 +66,7 @@ def __init__( path_to_mag_view: Path, array_info: ArrayInfo, bounding_box: Optional[ - BoundingBox + NDBoundingBox ], # in mag 1, absolute coordinates, optional only for mag_view since it overwrites the bounding_box property mag: Mag, read_only: bool = False, @@ -93,7 +95,7 @@ def header(self) -> wkw.Header: return self._array._wkw_dataset.header @property - def bounding_box(self) -> BoundingBox: + def bounding_box(self) -> NDBoundingBox: assert self._bounding_box is not None return self._bounding_box @@ -106,7 +108,7 @@ def read_only(self) -> bool: return self._read_only @property - def global_offset(self) -> Vec3Int: + def global_offset(self) -> VecInt: """⚠️ Deprecated, use `view.bounding_box.in_mag(view.mag).topleft` instead.""" warnings.warn( "[DEPRECATION] view.global_offset is deprecated. " @@ -117,7 +119,7 @@ def global_offset(self) -> Vec3Int: return self.bounding_box.in_mag(self._mag).topleft @property - def size(self) -> Vec3Int: + def size(self) -> VecInt: """⚠️ Deprecated, use `view.bounding_box.in_mag(view.mag).size` instead.""" warnings.warn( "[DEPRECATION] view.size is deprecated. " @@ -129,15 +131,15 @@ def size(self) -> Vec3Int: def _get_mag1_bbox( self, - abs_mag1_bbox: Optional[BoundingBox] = None, - rel_mag1_bbox: Optional[BoundingBox] = None, + abs_mag1_bbox: Optional[NDBoundingBox] = None, + rel_mag1_bbox: Optional[NDBoundingBox] = None, abs_mag1_offset: Optional[Vec3IntLike] = None, rel_mag1_offset: Optional[Vec3IntLike] = None, mag1_size: Optional[Vec3IntLike] = None, abs_current_mag_offset: Optional[Vec3IntLike] = None, rel_current_mag_offset: Optional[Vec3IntLike] = None, current_mag_size: Optional[Vec3IntLike] = None, - ) -> BoundingBox: + ) -> NDBoundingBox: num_bboxes = _count_defined_values([abs_mag1_bbox, rel_mag1_bbox]) num_offsets = _count_defined_values( [ @@ -167,7 +169,7 @@ def _get_mag1_bbox( if abs_mag1_bbox is not None: return abs_mag1_bbox - elif rel_mag1_bbox is not None: + if rel_mag1_bbox is not None: return rel_mag1_bbox.offset(self.bounding_box.topleft) else: @@ -187,7 +189,12 @@ def _get_mag1_bbox( assert abs_mag1_offset is not None, "No offset was supplied." assert mag1_size is not None, "No size was supplied." - return BoundingBox(Vec3Int(abs_mag1_offset), Vec3Int(mag1_size)) + + assert ( + len(self.bounding_box) == 3 + ), "The delivered offset and size are only usable for 3D views." + + return self.bounding_box.with_topleft(abs_mag1_offset).with_size(mag1_size) def write( self, @@ -197,9 +204,17 @@ def write( *, relative_offset: Optional[Vec3IntLike] = None, # in mag1 absolute_offset: Optional[Vec3IntLike] = None, # in mag1 + relative_bounding_box: Optional[NDBoundingBox] = None, # in mag1 + absolute_bounding_box: Optional[NDBoundingBox] = None, # in mag1 ) -> None: """ - Writes the `data` at the specified `relative_offset` or `absolute_offset`, both specified in Mag(1). + The user can specify where the data should be written. + The default is to write the data to the view's bounding box. + Alternatively, one can supply one of the following keywords: + * `relative_offset` in Mag(1) -> only usable for 3D datasets + * `absolute_offset` in Mag(1) -> only usable for 3D datasets + * `relative_bounding_box` in Mag(1) + * `absolute_bounding_box` in Mag(1) ⚠️ The `offset` parameter is deprecated. This parameter used to be relative for `View` and absolute for `MagView`, @@ -230,9 +245,23 @@ def write( """ assert not self.read_only, "Cannot write data to an read_only View" - if all(i is None for i in [offset, absolute_offset, relative_offset]): + if all( + i is None + for i in [ + offset, + absolute_offset, + relative_offset, + absolute_bounding_box, + relative_bounding_box, + ] + ): relative_offset = Vec3Int.zeros() + if (absolute_bounding_box or relative_bounding_box) is not None: + data_shape = None + else: + data_shape = Vec3Int(data.shape[-3:]) + if offset is not None: if self._mag == Mag(1): alternative = "Since this is a View in Mag(1), please use view.write(relative_offset=my_vec)" @@ -263,32 +292,31 @@ def write( rel_current_mag_offset=offset, rel_mag1_offset=relative_offset, abs_mag1_offset=absolute_offset, - current_mag_size=Vec3Int(data.shape[-3:]), + rel_mag1_bbox=relative_bounding_box, + abs_mag1_bbox=absolute_bounding_box, + current_mag_size=data_shape, ) if json_update_allowed: assert self.bounding_box.contains_bbox( mag1_bbox ), f"The bounding box to write {mag1_bbox} is larger than the view's bounding box {self.bounding_box}" - if len(data.shape) == 4 and data.shape[0] == 1: - data = data[0] # remove channel dimension for single-channel data - current_mag_bbox = mag1_bbox.in_mag(self._mag) if self._is_compressed(): for current_mag_bbox, chunked_data in self._prepare_compressed_write( current_mag_bbox, data, json_update_allowed ): - self._array.write(current_mag_bbox.topleft, chunked_data) + self._array.write(current_mag_bbox, chunked_data) else: - self._array.write(current_mag_bbox.topleft, data) + self._array.write(current_mag_bbox, data) def _prepare_compressed_write( self, - current_mag_bbox: BoundingBox, + current_mag_bbox: NDBoundingBox, data: np.ndarray, json_update_allowed: bool = True, - ) -> Iterator[Tuple[BoundingBox, np.ndarray]]: + ) -> Iterator[Tuple[NDBoundingBox, np.ndarray]]: """This method takes an arbitrary sized chunk of data with an accompanying bbox, divides these into chunks of shard_shape size and delegates the preparation to _prepare_compressed_write_chunk.""" @@ -314,10 +342,10 @@ def _prepare_compressed_write( def _prepare_compressed_write_chunk( self, - current_mag_bbox: BoundingBox, + current_mag_bbox: NDBoundingBox, data: np.ndarray, json_update_allowed: bool = True, - ) -> Tuple[BoundingBox, np.ndarray]: + ) -> Tuple[NDBoundingBox, np.ndarray]: """This method takes an arbitrary sized chunk of data with an accompanying bbox (ideally not larger than a shard) and enlarges that chunk to fit the shard it resides in (by reading the entire shard data and writing the passed data ndarray @@ -358,8 +386,8 @@ def read( *, relative_offset: Optional[Vec3IntLike] = None, # in mag1 absolute_offset: Optional[Vec3IntLike] = None, # in mag1 - relative_bounding_box: Optional[BoundingBox] = None, # in mag1 - absolute_bounding_box: Optional[BoundingBox] = None, # in mag1 + relative_bounding_box: Optional[NDBoundingBox] = None, # in mag1 + absolute_bounding_box: Optional[NDBoundingBox] = None, # in mag1 ) -> np.ndarray: """ The user can specify which data should be read. @@ -400,8 +428,9 @@ def read( assert ( relative_offset is None and absolute_offset is None ), "You must supply size, when reading with an offset." + absolute_bounding_box = self.bounding_box current_mag_size = None - mag1_size = self.bounding_box.size + mag1_size = None else: if relative_offset is None and absolute_offset is None: if type(self) == View: @@ -440,14 +469,26 @@ def read( ) if size is None: + absolute_bounding_box = self.bounding_box.offset( + self._mag.to_vec3_int() * offset + ) + offset = None current_mag_size = None - mag1_size = self.bounding_box.size + mag1_size = None else: # (deprecated) offset and size are given current_mag_size = size mag1_size = None - if all(i is None for i in [offset, absolute_offset, relative_offset]): + if all( + i is None + for i in [ + offset, + absolute_offset, + relative_offset, + absolute_bounding_box, + ] + ): relative_offset = Vec3Int.zeros() else: assert ( @@ -507,11 +548,9 @@ def read_bbox(self, bounding_box: Optional[BoundingBox] = None) -> np.ndarray: def _read_without_checks( self, - current_mag_bbox: BoundingBox, + current_mag_bbox: NDBoundingBox, ) -> np.ndarray: - data = self._array.read( - current_mag_bbox.topleft.to_np(), current_mag_bbox.size.to_np() - ) + data = self._array.read(current_mag_bbox) return data def get_view( @@ -521,6 +560,8 @@ def get_view( *, relative_offset: Optional[Vec3IntLike] = None, # in mag1 absolute_offset: Optional[Vec3IntLike] = None, # in mag1 + relative_bbox: Optional[NDBoundingBox] = None, # in mag1 + absolute_bbox: Optional[NDBoundingBox] = None, # in mag1 read_only: Optional[bool] = None, ) -> "View": """ @@ -617,6 +658,8 @@ def get_view( relative_offset = Vec3Int.zeros() mag1_bbox = self._get_mag1_bbox( + abs_mag1_bbox=absolute_bbox, + rel_mag1_bbox=relative_bbox, rel_current_mag_offset=offset, rel_mag1_offset=relative_offset, abs_mag1_offset=absolute_offset, @@ -670,6 +713,8 @@ def get_buffered_slice_writer( *, relative_offset: Optional[Vec3IntLike] = None, # in mag1 absolute_offset: Optional[Vec3IntLike] = None, # in mag1 + relative_bounding_box: Optional[NDBoundingBox] = None, # in mag1 + absolute_bounding_box: Optional[NDBoundingBox] = None, # in mag1 use_logging: bool = False, ) -> "BufferedSliceWriter": """ @@ -680,6 +725,8 @@ def get_buffered_slice_writer( * The user can specify where the writer should start: * `relative_offset` in Mag(1) * `absolute_offset` in Mag(1) + * `relative_bounding_box` in Mag(1) + * `absolute_bounding_box` in Mag(1) * ⚠️ deprecated: `offset` in the current Mag, used to be relative for `View` and absolute for `MagView` * `buffer_size`: amount of slices that get buffered @@ -713,6 +760,8 @@ def get_buffered_slice_writer( dimension=dimension, relative_offset=relative_offset, absolute_offset=absolute_offset, + relative_bounding_box=relative_bounding_box, + absolute_bounding_box=absolute_bounding_box, use_logging=use_logging, ) @@ -723,8 +772,8 @@ def get_buffered_slice_reader( buffer_size: int = 32, dimension: int = 2, # z *, - relative_bounding_box: Optional[BoundingBox] = None, # in mag1 - absolute_bounding_box: Optional[BoundingBox] = None, # in mag1 + relative_bounding_box: Optional[NDBoundingBox] = None, # in mag1 + absolute_bounding_box: Optional[NDBoundingBox] = None, # in mag1 use_logging: bool = False, ) -> "BufferedSliceReader": """ @@ -1088,7 +1137,7 @@ def _get_file_dimensions(self) -> Vec3Int: return self.info.shard_shape def _get_file_dimensions_mag1(self) -> Vec3Int: - return self._get_file_dimensions() * self.mag.to_vec3_int() + return Vec3Int(self._get_file_dimensions() * self.mag.to_vec3_int()) @property def _array(self) -> BaseArray: diff --git a/webknossos/webknossos/geometry/__init__.py b/webknossos/webknossos/geometry/__init__.py index 328823d9e..301d074b1 100644 --- a/webknossos/webknossos/geometry/__init__.py +++ b/webknossos/webknossos/geometry/__init__.py @@ -2,4 +2,6 @@ from .bounding_box import BoundingBox from .mag import Mag +from .nd_bounding_box import NDBoundingBox from .vec3_int import Vec3Int, Vec3IntLike +from .vec_int import VecInt, VecIntLike diff --git a/webknossos/webknossos/geometry/bounding_box.py b/webknossos/webknossos/geometry/bounding_box.py index a9e0010f1..75038a40e 100644 --- a/webknossos/webknossos/geometry/bounding_box.py +++ b/webknossos/webknossos/geometry/bounding_box.py @@ -1,6 +1,5 @@ import json import re -from collections import defaultdict from typing import ( Callable, Dict, @@ -17,13 +16,14 @@ import numpy as np from .mag import Mag +from .nd_bounding_box import NDBoundingBox from .vec3_int import Vec3Int, Vec3IntLike _DEFAULT_BBOX_NAME = "Unnamed Bounding Box" @attr.frozen -class BoundingBox: +class BoundingBox(NDBoundingBox): """ This class is used to represent an axis-aligned cuboid in 3D. The top-left coordinate is inclusive and the bottom-right coordinate is exclusive. @@ -42,6 +42,8 @@ class BoundingBox: topleft: Vec3Int = attr.field(converter=Vec3Int) size: Vec3Int = attr.field(converter=Vec3Int) + axes: Tuple[str, str, str] = attr.field(default=("x", "y", "z")) + index: Vec3Int = attr.field(default=Vec3Int(1, 2, 3)) bottomright: Vec3Int = attr.field(init=False) name: Optional[str] = _DEFAULT_BBOX_NAME is_visible: bool = True @@ -61,61 +63,26 @@ def __attrs_post_init__(self) -> None: # it is needed. object.__setattr__(self, "bottomright", self.topleft + self.size) - def with_topleft(self, new_topleft: Vec3IntLike) -> "BoundingBox": - return attr.evolve(self, topleft=new_topleft) - - def with_size(self, new_size: Vec3IntLike) -> "BoundingBox": - return attr.evolve(self, size=new_size) - - def with_name(self, name: Optional[str]) -> "BoundingBox": - return attr.evolve(self, name=name) - - def with_is_visible(self, is_visible: bool) -> "BoundingBox": - return attr.evolve(self, is_visible=is_visible) - - def with_color( - self, color: Optional[Tuple[float, float, float, float]] - ) -> "BoundingBox": - return attr.evolve(self, color=color) - def with_bounds_x( self, new_topleft_x: Optional[int] = None, new_size_x: Optional[int] = None ) -> "BoundingBox": """Returns a copy of the bounding box with topleft.x optionally replaced and size.x optionally replaced.""" - new_topleft = ( - self.topleft.with_x(new_topleft_x) - if new_topleft_x is not None - else self.topleft - ) - new_size = self.size.with_x(new_size_x) if new_size_x is not None else self.size - return attr.evolve(self, topleft=new_topleft, size=new_size) + return cast(BoundingBox, self.with_bounds("x", new_topleft_x, new_size_x)) def with_bounds_y( self, new_topleft_y: Optional[int] = None, new_size_y: Optional[int] = None ) -> "BoundingBox": """Returns a copy of the bounding box with topleft.y optionally replaced and size.y optionally replaced.""" - new_topleft = ( - self.topleft.with_y(new_topleft_y) - if new_topleft_y is not None - else self.topleft - ) - new_size = self.size.with_y(new_size_y) if new_size_y is not None else self.size - return attr.evolve(self, topleft=new_topleft, size=new_size) + return cast(BoundingBox, self.with_bounds("y", new_topleft_y, new_size_y)) def with_bounds_z( self, new_topleft_z: Optional[int] = None, new_size_z: Optional[int] = None ) -> "BoundingBox": """Returns a copy of the bounding box with topleft.z optionally replaced and size.z optionally replaced.""" - new_topleft = ( - self.topleft.with_z(new_topleft_z) - if new_topleft_z is not None - else self.topleft - ) - new_size = self.size.with_z(new_size_z) if new_size_z is not None else self.size - return attr.evolve(self, topleft=new_topleft, size=new_size) + return cast(BoundingBox, self.with_bounds("z", new_topleft_z, new_size_z)) @classmethod def from_wkw_dict(cls, bbox: Dict) -> "BoundingBox": @@ -162,6 +129,10 @@ def from_csv(cls, csv_bbox: str) -> "BoundingBox": bbox_tuple = tuple(int(x) for x in csv_bbox.split(",")) return cls.from_tuple6(cast(Tuple[int, int, int, int, int, int], bbox_tuple)) + @classmethod + def from_ndbbox(cls, bbox: NDBoundingBox) -> "BoundingBox": + return cls(bbox.topleft_xyz, bbox.size_xyz) + @classmethod def from_auto( cls, obj: Union["BoundingBox", str, Dict, List, Tuple] @@ -185,25 +156,6 @@ def from_auto( raise Exception("Unknown bounding box format.") - @classmethod - def group_boxes_with_aligned_mag( - cls, bounding_boxes: Iterable["BoundingBox"], aligning_mag: Mag - ) -> Dict["BoundingBox", List["BoundingBox"]]: - """ - Groups the given BoundingBox instances by aligning each - bbox to the given mag and using that as the key. - For example, bounding boxes of size 256**3 could be grouped - into the corresponding 1024**3 chunks to which they belong - by using aligning_mag = Mag(1024). - """ - - chunks_with_bboxes = defaultdict(list) - for bbox in bounding_boxes: - chunk_key = bbox.align_with_mag(aligning_mag, ceil=True) - chunks_with_bboxes[chunk_key].append(bbox) - - return chunks_with_bboxes - @classmethod def empty( cls, @@ -240,19 +192,15 @@ def to_tuple6(self) -> Tuple[int, int, int, int, int, int]: def to_csv(self) -> str: return ",".join(map(str, self.to_tuple6())) - def __repr__(self) -> str: - return "BoundingBox(topleft={}, size={})".format( - str(tuple(self.topleft)), str(tuple(self.size)) - ) - - def __str__(self) -> str: - return self.__repr__() - def __eq__(self, other: object) -> bool: - if isinstance(other, BoundingBox): + if isinstance(other, NDBoundingBox): + self._check_compatibility(other) return self.topleft == other.topleft and self.size == other.size - else: - raise NotImplementedError() + + raise NotImplementedError() + + def __repr__(self) -> str: + return f"BoundingBox(topleft={self.topleft.to_tuple()}, size={self.size.to_tuple()})" def padded_with_margins( self, margins_left: Vec3IntLike, margins_right: Optional[Vec3IntLike] = None @@ -269,36 +217,6 @@ def padded_with_margins( size=self.size + (margins_left + margins_right), ) - def intersected_with( - self, other: "BoundingBox", dont_assert: bool = False - ) -> "BoundingBox": - """If dont_assert is set to False, this method may return empty bounding boxes (size == (0, 0, 0))""" - - topleft = self.topleft.pairmax(other.topleft) - bottomright = self.bottomright.pairmin(other.bottomright) - size = (bottomright - topleft).pairmax(Vec3Int.zeros()) - - intersection = attr.evolve(self, topleft=topleft, size=size) - - if not dont_assert: - assert ( - not intersection.is_empty() - ), f"No intersection between bounding boxes {self} and {other}." - - return intersection - - def extended_by(self, other: "BoundingBox") -> "BoundingBox": - if self.is_empty(): - return other - if other.is_empty(): - return self - - topleft = self.topleft.pairmin(other.topleft) - bottomright = self.bottomright.pairmax(other.bottomright) - size = bottomright - topleft - - return attr.evolve(self, topleft=topleft, size=size) - def is_empty(self) -> bool: return not self.size.is_positive(strictly_positive=True) @@ -318,14 +236,6 @@ def in_mag(self, mag: Mag) -> "BoundingBox": size=(self.size // mag_vec), ) - def from_mag_to_mag1(self, from_mag: Mag) -> "BoundingBox": - mag_vec = from_mag.to_vec3_int() - return attr.evolve( - self, - topleft=(self.topleft * mag_vec), - size=(self.size * mag_vec), - ) - def _align_with_mag_slow(self, mag: Mag, ceil: bool = False) -> "BoundingBox": """Rounds the bounding box, so that both topleft and bottomright are divisible by mag. @@ -394,9 +304,6 @@ def contains(self, coord: Union[Vec3IntLike, np.ndarray]) -> bool: and self.topleft[2] <= coord[2] < self.bottomright[2] ) - def contains_bbox(self, inner_bbox: "BoundingBox") -> bool: - return inner_bbox.intersected_with(self, dont_assert=True) == inner_bbox - def chunk( self, chunk_shape: Vec3IntLike, @@ -433,24 +340,10 @@ def chunk( for z in range( start[2] - start_adjust[2], start[2] + self.size[2], chunk_shape[2] ): - yield BoundingBox([x, y, z], chunk_shape).intersected_with(self) - - def volume(self) -> int: - return self.size.prod() - - def slice_array(self, array: np.ndarray) -> np.ndarray: - return array[ - self.topleft.x : self.bottomright.x, - self.topleft.y : self.bottomright.y, - self.topleft.z : self.bottomright.z, - ] - - def to_slices(self) -> Tuple[slice, slice, slice]: - return np.index_exp[ - self.topleft.x : self.bottomright.x, - self.topleft.y : self.bottomright.y, - self.topleft.z : self.bottomright.z, - ] + yield cast( + BoundingBox, + BoundingBox([x, y, z], chunk_shape).intersected_with(self), + ) def offset(self, vector: Vec3IntLike) -> "BoundingBox": return attr.evolve(self, topleft=self.topleft + Vec3Int(vector)) diff --git a/webknossos/webknossos/geometry/nd_bounding_box.py b/webknossos/webknossos/geometry/nd_bounding_box.py new file mode 100644 index 000000000..def386881 --- /dev/null +++ b/webknossos/webknossos/geometry/nd_bounding_box.py @@ -0,0 +1,849 @@ +from collections import defaultdict +from itertools import product +from typing import ( + Dict, + Generator, + Iterable, + List, + Optional, + Tuple, + TypeVar, + Union, + cast, +) + +import attr +import numpy as np + +from .mag import Mag +from .vec3_int import Vec3Int, Vec3IntLike +from .vec_int import VecInt, VecIntLike + +_DEFAULT_BBOX_NAME = "Unnamed Bounding Box" + +_T = TypeVar("_T", bound="NDBoundingBox") + + +def str_tpl(str_list: Iterable[str]) -> Tuple[str, ...]: + # Fix for mypy bug https://github.com/python/mypy/issues/5313. + # Solution based on other issue for the same bug: https://github.com/python/mypy/issues/8389. + return tuple(str_list) + + +def int_tpl(vec_int_like: VecIntLike) -> VecInt: + return VecInt( + vec_int_like, axes=(f"unset_{i}" for i in range(len(list(vec_int_like)))) + ) + + +@attr.frozen +class NDBoundingBox: + """ + The NDBoundingBox class is a generalized version of the 3-dimensional BoundingBox class. It is designed to represent bounding boxes in any number of dimensions. + + The bounding box is characterized by its top-left corner, the size of the box, the names of the axes for each dimension, and the index (or order) of the axes. Each axis must have a unique index, starting from 1 (index 0 is reserved for channel information). + + The top-left coordinate is inclusive, while the bottom-right coordinate is exclusive. + + Here's a brief example of how to use it: + + ```python + + # Create a 2D bounding box + bbox_1 = NDBoundingBox( + top_left=(0, 0), + size=(100, 100), + axes=("x", "y"), + index=(1,2) + ) + + # Create a 4D bounding box + bbox_2 = NDBoundingBox( + top_left=(75, 75, 75, 0), + size=(100, 100, 100, 20), + axes=("x", "y", "z", "t"), + index=(2,3,4,1) + ) + ``` + """ + + topleft: VecInt = attr.field(converter=int_tpl) + size: VecInt = attr.field(converter=int_tpl) + axes: Tuple[str, ...] = attr.field(converter=str_tpl) + index: VecInt = attr.field(converter=int_tpl) + bottomright: VecInt = attr.field(init=False) + name: Optional[str] = _DEFAULT_BBOX_NAME + is_visible: bool = True + color: Optional[Tuple[float, float, float, float]] = None + + def __attrs_post_init__(self) -> None: + assert ( + len(self.topleft) == len(self.size) == len(self.axes) == len(self.index) + ), ( + f"The dimensions of topleft, size, axes and index ({len(self.topleft)}, " + + f"{len(self.size)}, {len(self.axes)} and {len(self.index)}) do not match." + ) + assert 0 not in self.index, "Index 0 is reserved for channels." + + # Convert the delivered tuples to VecInts + object.__setattr__(self, "topleft", VecInt(self.topleft, axes=self.axes)) + object.__setattr__(self, "size", VecInt(self.size, axes=self.axes)) + object.__setattr__(self, "index", VecInt(self.index, axes=self.axes)) + + if not self._is_sorted(): + self._sort_positions_of_axes() + + if not self.size.is_positive(): + # Flip the size in negative dimensions, so that the topleft is smaller than bottomright. + # E.g. BoundingBox((10, 10, 10), (-5, 5, 5)) -> BoundingBox((5, 10, 10), (5, 5, 5)). + negative_size = tuple(min(0, value) for value in self.size) + new_topleft = tuple( + val1 + val2 for val1, val2 in zip(self.topleft, negative_size) + ) + new_size = (abs(value) for value in self.size) + object.__setattr__(self, "topleft", VecInt(new_topleft, axes=self.axes)) + object.__setattr__(self, "size", VecInt(new_size, axes=self.axes)) + + # Compute bottomright to avoid that it's recomputed every time + # it is needed. + object.__setattr__( + self, + "bottomright", + self.topleft + self.size, + ) + + def _sort_positions_of_axes(self) -> None: + # Bring topleft and size in required order + # defined in axisOrder and index of additionalAxes + + size, topleft, axes, index = zip( + *sorted( + zip(self.size, self.topleft, self.axes, self.index), key=lambda x: x[3] + ) + ) + object.__setattr__(self, "size", VecInt(size, axes=axes)) + object.__setattr__(self, "topleft", VecInt(topleft, axes=axes)) + object.__setattr__(self, "axes", axes) + object.__setattr__(self, "index", VecInt(index, axes=axes)) + + def _is_sorted(self) -> bool: + return all(self.index[i - 1] < self.index[i] for i in range(1, len(self.index))) + + def with_name(self: _T, name: Optional[str]) -> _T: + """ + Returns a new instance of `NDBoundingBox` with the specified name. + + Args: + - name (Optional[str]): The name to assign to the new `NDBoundingBox` instance. + + Returns: + - NDBoundingBox: A new instance of `NDBoundingBox` with the specified name. + """ + return attr.evolve(self, name=name) + + def with_topleft(self: _T, new_topleft: VecIntLike) -> _T: + """ + Returns a new NDBoundingBox object with the specified top left coordinates. + + Args: + - new_topleft (VecIntLike): The new top left coordinates for the bounding box. + + Returns: + - NDBoundingBox: A new NDBoundingBox object with the updated top left coordinates. + """ + return attr.evolve(self, topleft=VecInt(new_topleft, axes=self.axes)) + + def with_size(self: _T, new_size: VecIntLike) -> _T: + """ + Returns a new NDBoundingBox object with the specified size. + + Args: + - new_size (VecIntLike): The new size of the bounding box. Can be a VecInt or any object that can be converted to a VecInt. + + Returns: + - A new NDBoundingBox object with the specified size. + """ + return attr.evolve(self, size=VecInt(new_size, axes=self.axes)) + + def with_index(self: _T, new_index: VecIntLike) -> _T: + """ + Returns a new NDBoundingBox object with the specified index. + + Args: + - new_index (VecIntLike): The new axis order for the bounding box. + + Returns: + - NDBoundingBox: A new NDBoundingBox object with the updated index. + """ + axes, _ = zip(*sorted(zip(self.axes, new_index), key=lambda x: x[1])) + return attr.evolve(self, index=VecInt(new_index, axes=axes)) + + def with_bottomright(self: _T, new_bottomright: VecIntLike) -> _T: + """ + Returns a new NDBoundingBox with an updated bottomright value. + + Args: + - new_bottomright (VecIntLike): The new bottom right corner coordinates. + + Returns: + - NDBoundingBox: A new NDBoundingBox object with the updated bottom right corner. + """ + new_size = VecInt(new_bottomright, axes=self.axes) - self.topleft + + return self.with_size(new_size) + + def with_is_visible(self: _T, is_visible: bool) -> _T: + """ + Returns a new NDBoundingBox object with the specified visibility. + + Args: + - is_visible (bool): The visibility value to set. + + Returns: + - NDBoundingBox: A new NDBoundingBox object with the updated visibility value. + """ + return attr.evolve(self, is_visible=is_visible) + + def with_color(self: _T, color: Optional[Tuple[float, float, float, float]]) -> _T: + """ + Returns a new instance of NDBoundingBox with the specified color. + + Args: + - color (Optional[Tuple[float, float, float, float]]): The color to set for the bounding box. + The color should be specified as a tuple of four floats representing RGBA values. + + Returns: + - NDBoundingBox: A new instance of NDBoundingBox with the specified color. + """ + return attr.evolve(self, color=color) + + def with_bounds( + self: _T, axis: str, new_topleft: Optional[int], new_size: Optional[int] + ) -> _T: + """ + Returns a new NDBoundingBox object with updated bounds along the specified axis. + + Args: + - axis (str): The name of the axis to update. + - new_topleft (Optional[int]): The new value for the top-left coordinate along the specified axis. + - new_size (Optional[int]): The new size along the specified axis. + + Returns: + - NDBoundingBox: A new NDBoundingBox object with updated bounds. + + Raises: + - ValueError: If the given axis name does not exist. + + """ + try: + index = self.axes.index(axis) + except ValueError as err: + raise ValueError("The given axis name does not exist.") from err + + _new_topleft = ( + self.topleft.with_replaced(index, new_topleft) + if new_topleft is not None + else self.topleft + ) + _new_size = ( + self.size.with_replaced(index, new_size) + if new_size is not None + else self.size + ) + + return attr.evolve(self, topleft=_new_topleft, size=_new_size) + + def get_bounds(self, axis: str) -> Tuple[int, int]: + """ + Returns the bounds of the given axis. + + Args: + - axis (str): The name of the axis to get the bounds for. + + Returns: + - Tuple[int, int]: A tuple containing the top-left and bottom-right coordinates along the specified axis. + """ + try: + index = self.axes.index(axis) + except ValueError as err: + raise ValueError("The given axis name does not exist.") from err + + return (self.topleft[index], self.topleft[index] + self.size[index]) + + @classmethod + def group_boxes_with_aligned_mag( + cls, bounding_boxes: Iterable["NDBoundingBox"], aligning_mag: Mag + ) -> Dict["NDBoundingBox", List["NDBoundingBox"]]: + """ + Groups the given BoundingBox instances by aligning each + bbox to the given mag and using that as the key. + For example, bounding boxes of size 256**3 could be grouped + into the corresponding 1024**3 chunks to which they belong + by using aligning_mag = Mag(1024). + """ + + chunks_with_bboxes = defaultdict(list) + for bbox in bounding_boxes: + chunk_key = bbox.align_with_mag(aligning_mag, ceil=True) + chunks_with_bboxes[chunk_key].append(bbox) + + return chunks_with_bboxes + + @classmethod + def from_wkw_dict(cls, bbox: Dict) -> "NDBoundingBox": + """ + Create an instance of NDBoundingBox from a dictionary representation. + + Args: + - bbox (Dict): The dictionary representation of the bounding box. + + Returns: + - NDBoundingBox: An instance of NDBoundingBox. + + Raises: + - AssertionError: If additionalAxes are present but axisOrder is not provided. + """ + + topleft: Tuple[int, ...] = bbox["topLeft"] + size: Tuple[int, ...] = (bbox["width"], bbox["height"], bbox["depth"]) + axes: Tuple[str, ...] = ("x", "y", "z") + index: Tuple[int, ...] = (1, 2, 3) + + if "axisOrder" in bbox: + axes = tuple(bbox["axisOrder"].keys()) + index = tuple(bbox["axisOrder"][axis] for axis in axes) + + if "additionalAxes" in bbox: + assert ( + "axisOrder" in bbox + ), "If there are additionalAxes an axisOrder needs to be provided." + for axis in bbox["additionalAxes"]: + topleft += (axis["bounds"][0],) + size += (axis["bounds"][1] - axis["bounds"][0],) + axes += (axis["name"],) + index += (axis["index"],) + + return cls( + topleft=VecInt(topleft, axes=axes), + size=VecInt(size, axes=axes), + axes=axes, + index=VecInt(index, axes=axes), + ) + + def to_wkw_dict(self) -> dict: + """ + Converts the bounding box object to a json dictionary. + + Returns: + - dict: A json dictionary representing the bounding box. + """ + topleft = [None, None, None] + width, height, depth = None, None, None + additional_axes = [] + for i, axis in enumerate(self.axes): + if axis == "x": + topleft[0] = self.topleft[i] + width = self.size[i] + elif axis == "y": + topleft[1] = self.topleft[i] + height = self.size[i] + elif axis == "z": + topleft[2] = self.topleft[i] + depth = self.size[i] + else: + additional_axes.append( + { + "name": axis, + "bounds": [self.topleft[i], self.bottomright[i]], + "index": self.index[i], + } + ) + if additional_axes: + return { + "topLeft": topleft, + "width": width, + "height": height, + "depth": depth, + "additionalAxes": additional_axes, + } + return { + "topLeft": topleft, + "width": width, + "height": height, + "depth": depth, + } + + def to_config_dict(self) -> dict: + """ + Returns a dictionary representation of the bounding box. + + Returns: + - dict: A dictionary representation of the bounding box. + """ + return { + "topleft": self.topleft.to_list(), + "size": self.size.to_list(), + "axes": self.axes, + } + + def to_checkpoint_name(self) -> str: + """ + Returns a string representation of the bounding box that can be used as a checkpoint name. + + Returns: + - str: A string representation of the bounding box. + """ + return f"{'_'.join(str(element) for element in self.topleft)}_{'_'.join(str(element) for element in self.size)}" + + def __repr__(self) -> str: + return f"NDBoundingBox(topleft={self.topleft.to_tuple()}, size={self.size.to_tuple()}, axes={self.axes})" + + def __str__(self) -> str: + return self.__repr__() + + def __eq__(self, other: object) -> bool: + if isinstance(other, NDBoundingBox): + self._check_compatibility(other) + return self.topleft == other.topleft and self.size == other.size + + raise NotImplementedError() + + def __len__(self) -> int: + return len(self.axes) + + def get_shape(self, axis_name: str) -> int: + """ + Returns the size of the bounding box along the specified axis. + + Args: + - axis_name (str): The name of the axis to get the size for. + + Returns: + - int: The size of the bounding box along the specified axis. + """ + try: + index = self.axes.index(axis_name) + return self.size[index] + except ValueError as err: + raise ValueError( + f"Axis {axis_name} doesn't exist in NDBoundingBox." + ) from err + + def _get_attr_xyz(self, attr_name: str) -> Vec3Int: + axes = ("x", "y", "z") + attr_3d = [] + + for axis in axes: + index = self.axes.index(axis) + attr_3d.append(getattr(self, attr_name)[index]) + + return Vec3Int(attr_3d) + + def _get_attr_with_replaced_xyz(self, attr_name: str, xyz: Vec3IntLike) -> VecInt: + value = Vec3Int(xyz) + axes = ("x", "y", "z") + modified_attr = getattr(self, attr_name).to_list() + + for i, axis in enumerate(axes): + index = self.axes.index(axis) + modified_attr[index] = value[i] + + return VecInt(modified_attr, axes=self.axes) + + @property + def topleft_xyz(self) -> Vec3Int: + """The topleft corner of the bounding box regarding only x, y and z axis.""" + + return self._get_attr_xyz("topleft") + + @property + def size_xyz(self) -> Vec3Int: + """The size of the bounding box regarding only x, y and z axis.""" + + return self._get_attr_xyz("size") + + @property + def bottomright_xyz(self) -> Vec3Int: + """The bottomright corner of the bounding box regarding only x, y and z axis.""" + + return self._get_attr_xyz("bottomright") + + @property + def index_xyz(self) -> Vec3Int: + """The index of x, y and z axis within the bounding box.""" + + return self._get_attr_xyz("index") + + def with_topleft_xyz(self: _T, new_xyz: Vec3IntLike) -> _T: + """ + Returns a new NDBoundingBox object with changed x, y and z coordinates of the topleft corner. + + Args: + - new_xyz (Vec3IntLike): The new x, y and z coordinates for the topleft corner. + + Returns: + - NDBoundingBox: A new NDBoundingBox object with the updated x, y and z coordinates of the topleft corner. + """ + new_topleft = self._get_attr_with_replaced_xyz("topleft", new_xyz) + + return self.with_topleft(new_topleft) + + def with_size_xyz(self: _T, new_xyz: Vec3IntLike) -> _T: + """ + Returns a new NDBoundingBox object with changed x, y and z size. + + Args: + - new_xyz (Vec3IntLike): The new x, y and z size for the bounding box. + + Returns: + - NDBoundingBox: A new NDBoundingBox object with the updated x, y and z size. + """ + new_size = self._get_attr_with_replaced_xyz("size", new_xyz) + + return self.with_size(new_size) + + def with_bottomright_xyz(self: _T, new_xyz: Vec3IntLike) -> _T: + """ + Returns a new NDBoundingBox object with changed x, y and z coordinates of the bottomright corner. + + Args: + - new_xyz (Vec3IntLike): The new x, y and z coordinates for the bottomright corner. + + Returns: + - NDBoundingBox: A new NDBoundingBox object with the updated x, y and z coordinates of the bottomright corner. + """ + new_bottomright = self._get_attr_with_replaced_xyz("bottomright", new_xyz) + + return self.with_bottomright(new_bottomright) + + def with_index_xyz(self: _T, new_xyz: Vec3IntLike) -> _T: + """ + Returns a new NDBoundingBox object with changed x, y and z index. + + Args: + - new_xyz (Vec3IntLike): The new x, y and z index for the bounding box. + + Returns: + - NDBoundingBox: A new NDBoundingBox object with the updated x, y and z index. + """ + new_index = self._get_attr_with_replaced_xyz("index", new_xyz) + + return self.with_index(new_index) + + def _check_compatibility(self, other: "NDBoundingBox") -> None: + """Checks if two bounding boxes are comparable. To be comparable they need the same number of axes, with same names and same order.""" + + if self.axes != other.axes: + raise ValueError( + f"Operation with two bboxes is only possible if they have the same axes and axes order. {self.axes} != {other.axes}" + ) + + def padded_with_margins( + self, margins_left: VecIntLike, margins_right: Optional[VecIntLike] = None + ) -> "NDBoundingBox": + raise NotImplementedError() + + def intersected_with(self: _T, other: _T, dont_assert: bool = False) -> _T: + """ + Returns the intersection of two bounding boxes. + + If dont_assert is set to False, this method may return empty bounding boxes (size == (0, 0, 0)) + + Args: + - other (NDBoundingBox): The other bounding box to intersect with. + - dont_assert (bool): If True, the method may return empty bounding boxes. + + Returns: + - NDBoundingBox: The intersection of the two bounding boxes. + """ + + self._check_compatibility(other) + topleft = self.topleft.pairmax(other.topleft) + bottomright = self.bottomright.pairmin(other.bottomright) + size = (bottomright - topleft).pairmax(VecInt.zeros(self.axes)) + + intersection = attr.evolve(self, topleft=topleft, size=size) + + if not dont_assert: + assert ( + not intersection.is_empty() + ), f"No intersection between bounding boxes {self} and {other}." + + return intersection + + def extended_by(self: _T, other: _T) -> _T: + """ + Returns the smallest bounding box that contains both bounding boxes. + + Args: + - other (NDBoundingBox): The other bounding box to extend with. + + Returns: + - NDBoundingBox: The smallest bounding box that contains both bounding boxes. + """ + self._check_compatibility(other) + if self.is_empty(): + return other + if other.is_empty(): + return self + + topleft = self.topleft.pairmin(other.topleft) + bottomright = self.bottomright.pairmax(other.bottomright) + size = bottomright - topleft + + return attr.evolve(self, topleft=topleft, size=size) + + def is_empty(self) -> bool: + """ + Boolean check whether the boundung box is empty. + + Returns: + - bool: True if the bounding box is empty, False otherwise. + """ + return not self.size.is_positive(strictly_positive=True) + + def in_mag(self: _T, mag: Mag) -> _T: + """ + Returns the bounding box in the given mag. + + Args: + - mag (Mag): The magnification to convert the bounding box to. + + Returns: + - NDBoundingBox: The bounding box in the given magnification. + """ + mag_vec = mag.to_vec3_int() + + assert ( + self.topleft_xyz % mag_vec == Vec3Int.zeros() + ), f"topleft {self.topleft} is not aligned with the mag {mag}. Use BoundingBox.align_with_mag()." + assert ( + self.bottomright_xyz % mag_vec == Vec3Int.zeros() + ), f"bottomright {self.bottomright} is not aligned with the mag {mag}. Use BoundingBox.align_with_mag()." + + return self.with_topleft_xyz(self.topleft_xyz // mag_vec).with_size_xyz( + self.size_xyz // mag_vec + ) + + def from_mag_to_mag1(self: _T, from_mag: Mag) -> _T: + """ + Returns the bounging box in the finest magnification (Mag(1)). + + Args: + - from_mag (Mag): The current magnification of the bounding box. + + Returns: + - NDBoundingBox: The bounding box in the given magnification. + """ + mag_vec = from_mag.to_vec3_int() + + return self.with_topleft_xyz(self.topleft_xyz * mag_vec).with_size_xyz( + self.size_xyz * mag_vec + ) + + def _align_with_mag_slow(self: _T, mag: Mag, ceil: bool = False) -> _T: + """Rounds the bounding box, so that both topleft and bottomright are divisible by mag. + + :argument ceil: If true, the bounding box is enlarged when necessary. If false, it's shrinked when necessary. + """ + np_mag = mag.to_np() + + align = ( # noqa E731 + lambda point, round_fn: round_fn(point.to_np() / np_mag).astype(int) + * np_mag + ) + + if ceil: + topleft = align(self.topleft, np.floor) + bottomright = align(self.bottomright, np.ceil) + else: + topleft = align(self.topleft, np.ceil) + bottomright = align(self.bottomright, np.floor) + return attr.evolve(self, topleft=topleft, size=bottomright - topleft) + + def align_with_mag(self: _T, mag: Union[Mag, Vec3Int], ceil: bool = False) -> _T: + """ + Rounds the bounding box, so that both topleft and bottomright are divisible by mag. + + Args: + - mag (Union[Mag, Vec3Int]): The magnification to align the bounding box to. + - ceil (bool): If True, the bounding box is enlarged when necessary. If False, it's shrinked when necessary. + + Returns: + - NDBoundingBox: The aligned bounding box. + """ + # This does the same as _align_with_mag_slow, which is more readable. + # Same behavior is asserted in test_align_with_mag_against_numpy_implementation + mag_vec = mag.to_vec3_int() if isinstance(mag, Mag) else mag + topleft = self.topleft_xyz + bottomright = self.bottomright_xyz + roundup = topleft if ceil else bottomright + rounddown = bottomright if ceil else topleft + margin_to_roundup = roundup % mag_vec + aligned_roundup = roundup - margin_to_roundup + margin_to_rounddown = (mag_vec - (rounddown % mag_vec)) % mag_vec + aligned_rounddown = rounddown + margin_to_rounddown + if ceil: + return self.with_topleft_xyz(aligned_roundup).with_size_xyz( + aligned_rounddown - aligned_roundup + ) + else: + return self.with_topleft_xyz(aligned_rounddown).with_size_xyz( + aligned_roundup - aligned_rounddown + ) + + def contains(self, coord: VecIntLike) -> bool: + """ + Check whether a point is inside of the bounding box. + Note that the point may have float coordinates in the ndarray case + + Args: + - coord (VecIntLike): The coordinates to check. + + Returns: + - bool: True if the point is inside of the bounding box, False otherwise. + """ + + if isinstance(coord, np.ndarray): + assert ( + coord.shape == (len(self.size),) + ), f"Numpy array BoundingBox.contains must have shape ({len(self.size)},), got {coord.shape}." + return cast( + bool, + np.all(coord >= self.topleft) and np.all(coord < self.bottomright), + ) + else: + # In earlier versions, we simply converted to ndarray to have + # a unified calculation here, but this turned out to be a performance bottleneck. + # Therefore, the contains-check is performed on the tuple here. + coord = VecInt(coord, axes=self.axes) + return all( + self.topleft[i] <= coord[i] < self.bottomright[i] + for i in range(len(self.axes)) + ) + + def contains_bbox(self: _T, inner_bbox: _T) -> bool: + """ + Check whether a bounding box is completely inside of the bounding box. + + Args: + - inner_bbox (NDBoundingBox): The bounding box to check. + + Returns: + - bool: True if the bounding box is completely inside of the bounding box, False otherwise. + """ + self._check_compatibility(inner_bbox) + return inner_bbox.intersected_with(self, dont_assert=True) == inner_bbox + + def chunk( + self: _T, + chunk_shape: VecIntLike, + chunk_border_alignments: Optional[VecIntLike] = None, + ) -> Generator[_T, None, None]: + """ + Decompose the bounding box into smaller chunks of size `chunk_shape`. + + Chunks at the border of the bounding box might be smaller than chunk_shape. + If `chunk_border_alignment` is set, all border coordinates + *between two chunks* will be divisible by that value. + + Args: + - chunk_shape (VecIntLike): The size of the chunks to generate. + - chunk_border_alignments (Optional[VecIntLike]): The alignment of the chunk borders. + + + Yields: + - Generator[NDBoundingBox]: A generator of the chunks. + """ + + start = self.topleft.to_np() + try: + # If a 3D chunk_shape is given it is assumed that iteration over xyz is + # intended. Therefore NDBoundingBoxes are generated that have a shape of + # x: chunk_shape.x, y: chunk_shape.y, z: chunk_shape.z and 1 for all other + # axes. + chunk_shape = Vec3Int(chunk_shape) + + chunk_shape = ( + self.with_size(VecInt.ones(self.axes)) + .with_size_xyz(chunk_shape) + .size.to_np() + ) + except AssertionError: + chunk_shape = VecInt(chunk_shape, axes=self.axes).to_np() + + start_adjust = VecInt.zeros(self.axes).to_np() + if chunk_border_alignments is not None: + try: + chunk_border_alignments = Vec3Int(chunk_border_alignments) + + chunk_border_alignments = ( + self.with_size(VecInt.ones(self.axes)) + .with_size_xyz(chunk_border_alignments) + .size.to_np() + ) + except AssertionError: + chunk_border_alignments = VecInt( + chunk_border_alignments, axes=self.axes + ).to_np() + + assert np.all( + chunk_shape % chunk_border_alignments == 0 + ), f"{chunk_shape} not divisible by {chunk_border_alignments}" + + # Move the start to be aligned correctly. This doesn't actually change + # the start of the first chunk, because we'll intersect with `self`, + # but it'll lead to all chunk borders being aligned correctly. + start_adjust = start % chunk_border_alignments + for coordinates in product( + *[ + range( + start[i] - start_adjust[i], start[i] + self.size[i], chunk_shape[i] + ) + for i in range(len(self.axes)) + ] + ): + yield self.intersected_with( + self.__class__( + topleft=VecInt(coordinates, axes=self.axes), + size=VecInt(chunk_shape, axes=self.axes), + axes=self.axes, + index=self.index, + ) + ) + + def volume(self) -> int: + """ + Returns the volume of the bounding box. + """ + return self.size.prod() + + def slice_array(self, array: np.ndarray) -> np.ndarray: + """ + Returns a slice of the given array that corresponds to the bounding box. + """ + return array[self.to_slices()] + + def to_slices(self) -> Tuple[slice, ...]: + """ + Returns a tuple of slices that corresponds to the bounding box. + """ + return tuple( + slice(topleft, topleft + size) + for topleft, size in zip(self.topleft, self.size) + ) + + def offset(self: _T, vector: VecIntLike) -> _T: + """ + Returns a new NDBoundingBox object with the specified offset. + + Args: + - vector (VecIntLike): The offset to apply to the bounding box. + + Returns: + - NDBoundingBox: A new NDBoundingBox object with the specified offset. + """ + try: + return self.with_topleft_xyz(self.topleft_xyz + Vec3Int(vector)) + except AssertionError: + return self.with_topleft(self.topleft + VecInt(vector, axes=self.axes)) diff --git a/webknossos/webknossos/geometry/vec3_int.py b/webknossos/webknossos/geometry/vec3_int.py index a38d149e6..ed1f7b6be 100644 --- a/webknossos/webknossos/geometry/vec3_int.py +++ b/webknossos/webknossos/geometry/vec3_int.py @@ -1,18 +1,19 @@ import re -from operator import add, floordiv, mod, mul, sub -from typing import Any, Callable, Iterable, List, Optional, Tuple, Union, cast +from typing import Iterable, Optional, Tuple, Union, cast import numpy as np -value_error = "Vector components must be three integers or a Vec3IntLike object." +from .vec_int import VecInt +_VALUE_ERROR = "Vector components must be three integers or a Vec3IntLike object." -class Vec3Int(tuple): + +class Vec3Int(VecInt): def __new__( cls, - vec: Union[int, "Vec3IntLike"], - y: Optional[int] = None, - z: Optional[int] = None, + *args: Union["Vec3IntLike", Iterable[str], int], + axes: Optional[Iterable[str]] = ("x", "y", "z"), + **kwargs: int, ) -> "Vec3Int": """ Class to represent a 3D vector. Inherits from tuple and provides useful @@ -31,54 +32,36 @@ def __new__( ``` """ - if isinstance(vec, Vec3Int): - return vec + if args: + if isinstance(args[0], Vec3Int): + return args[0] - as_tuple: Optional[Tuple[int, int, int]] = None + assert axes is not None, _VALUE_ERROR - if isinstance(vec, int): - assert y is not None and z is not None, value_error - assert isinstance(y, int) and isinstance(z, int), value_error - as_tuple = vec, y, z - else: - assert y is None and z is None, value_error - if isinstance(vec, np.ndarray): - assert np.count_nonzero(vec % 1) == 0, value_error - assert vec.shape == ( - 3, - ), "Numpy array for Vec3Int must have shape (3,)." - if isinstance(vec, Iterable): - as_tuple = cast(Tuple[int, int, int], tuple(int(item) for item in vec)) - assert len(as_tuple) == 3, value_error - assert as_tuple is not None and len(as_tuple) == 3, value_error - - return super().__new__(cls, cast(Iterable, as_tuple)) + if isinstance(args[0], Iterable): + self = super().__new__(cls, *args[0], axes=("x", "y", "z")) + assert self is not None and len(self) == 3, _VALUE_ERROR - @staticmethod - def from_xyz(x: int, y: int, z: int) -> "Vec3Int": - """Use Vec3Int.from_xyz for fast construction.""" + return cast(Vec3Int, self) - # By calling __new__ of tuple directly, we circumvent - # the tolerant (and potentially) slow Vec3Int.__new__ method. - return tuple.__new__(Vec3Int, (x, y, z)) + assert len(args) == 3 and len(tuple(axes)) == 3, _VALUE_ERROR + assert kwargs is None or len(kwargs) == 0, _VALUE_ERROR + assert "x" in axes and "y" in axes and "z" in axes, _VALUE_ERROR + values, _ = zip(*sorted(zip(args, axes), key=lambda x: x[1])) + else: + assert "x" in kwargs and "y" in kwargs and "z" in kwargs, _VALUE_ERROR + assert len(kwargs) == 3, _VALUE_ERROR + values = kwargs["x"], kwargs["y"], kwargs["z"] - @staticmethod - def from_vec3_float(vec: Tuple[float, float, float]) -> "Vec3Int": - return Vec3Int(int(vec[0]), int(vec[1]), int(vec[2])) + self = super().__new__(cls, *values, axes=("x", "y", "z")) + self.axes = ("x", "y", "z") - @staticmethod - def from_vec_or_int(vec_or_int: Union["Vec3IntLike", int]) -> "Vec3Int": - if isinstance(vec_or_int, int): - return Vec3Int.full(vec_or_int) - else: - return Vec3Int(vec_or_int) + assert self is not None and len(self) == 3, _VALUE_ERROR - @staticmethod - def from_str(string: str) -> "Vec3Int": - if re.match(r"\(\d+,\d+,\d+\)", string): - return Vec3Int(tuple(map(int, re.findall(r"\d+", string)))) - else: - return Vec3Int.full(int(string)) + return cast(Vec3Int, self) + + def __getnewargs__(self) -> Tuple[Tuple[int, ...], Tuple[str, ...]]: + return (self.to_tuple(), self.axes) @property def x(self) -> int: @@ -101,104 +84,47 @@ def with_y(self, new_y: int) -> "Vec3Int": def with_z(self, new_z: int) -> "Vec3Int": return Vec3Int.from_xyz(self.x, self.y, new_z) - def to_np(self) -> np.ndarray: - return np.array((self.x, self.y, self.z)) - - def to_list(self) -> List[int]: - return [self.x, self.y, self.z] - def to_tuple(self) -> Tuple[int, int, int]: - return self.x, self.y, self.z + return (self.x, self.y, self.z) - def contains(self, needle: int) -> bool: - return self.x == needle or self.y == needle or self.z == needle - - def is_positive(self, strictly_positive: bool = False) -> bool: - if strictly_positive: - return all(i > 0 for i in self) - else: - return all(i >= 0 for i in self) - - def is_uniform(self) -> bool: - return self.x == self.y == self.z - - def _element_wise( - self, other: Union[int, "Vec3IntLike"], fn: Callable[[int, Any], int] - ) -> "Vec3Int": - if isinstance(other, int): - other_imported = Vec3Int.from_xyz(other, other, other) - else: - other_imported = Vec3Int(other) - return Vec3Int.from_xyz( - fn(self.x, other_imported.x), - fn(self.y, other_imported.y), - fn(self.z, other_imported.z), - ) - - # note: (arguments incompatible with superclass, do not add Vec3Int to plain tuple! Hence the type:ignore) - def __add__(self, other: Union[int, "Vec3IntLike"]) -> "Vec3Int": # type: ignore[override] - return self._element_wise(other, add) - - def __sub__(self, other: Union[int, "Vec3IntLike"]) -> "Vec3Int": - return self._element_wise(other, sub) - - # Note: When multiplying regular tuples with an int those are repeated, - # which is a different behavior in the superclass! Hence the type:ignore. - def __mul__(self, other: Union[int, "Vec3IntLike"]) -> "Vec3Int": # type: ignore[override] - return self._element_wise(other, mul) - - def __floordiv__(self, other: Union[int, "Vec3IntLike"]) -> "Vec3Int": - return self._element_wise(other, floordiv) - - def __mod__(self, other: Union[int, "Vec3IntLike"]) -> "Vec3Int": - return self._element_wise(other, mod) - - def __neg__(self) -> "Vec3Int": - return Vec3Int.from_xyz(-self.x, -self.y, -self.z) - - def ceildiv(self, other: Union[int, "Vec3IntLike"]) -> "Vec3Int": - return (self + other - 1) // other - - def pairmax(self, other: Union[int, "Vec3IntLike"]) -> "Vec3Int": - return self._element_wise(other, max) + @staticmethod + def from_xyz(x: int, y: int, z: int) -> "Vec3Int": + """Use Vec3Int.from_xyz for fast construction.""" - def pairmin(self, other: Union[int, "Vec3IntLike"]) -> "Vec3Int": - return self._element_wise(other, min) + # By calling __new__ of tuple directly, we circumvent + # the tolerant (and potentially) slow Vec3Int.__new__ method. + vec3int = tuple.__new__(Vec3Int, (x, y, z)) + vec3int.axes = ("x", "y", "z") + return vec3int - def prod(self) -> int: - return self.x * self.y * self.z + @staticmethod + def from_vec3_float(vec: Tuple[float, float, float]) -> "Vec3Int": + return Vec3Int(int(vec[0]), int(vec[1]), int(vec[2])) - def __repr__(self) -> str: - return f"Vec3Int({self.x},{self.y},{self.z})" + @staticmethod + def from_vec_or_int(vec_or_int: Union["Vec3IntLike", int]) -> "Vec3Int": + if isinstance(vec_or_int, int): + return Vec3Int.full(vec_or_int) - def add_or_none(self, other: Optional["Vec3Int"]) -> Optional["Vec3Int"]: - return None if other is None else self + other + return Vec3Int(vec_or_int) - def moveaxis( - self, source: Union[int, List[int]], target: Union[int, List[int]] - ) -> "Vec3Int": - """ - Allows to move one element at index `source` to another index `target`. Similar to - np.moveaxis, this is *not* a swap operation but instead it moves the specified - source so that the other elements move when necessary. - """ + @staticmethod + def from_str(string: str) -> "Vec3Int": + if re.match(r"\(\d+,\d+,\d+\)", string): + return Vec3Int(tuple(map(int, re.findall(r"\d+", string)))) - # Piggy-back on np.moveaxis by creating an auxiliary array where the indices 0, 1 and - # 2 appear in the shape. - indices = np.moveaxis(np.zeros((0, 1, 2)), source, target).shape - arr = self.to_np()[np.array(indices)] - return Vec3Int(arr) + return Vec3Int.full(int(string)) @classmethod - def zeros(cls) -> "Vec3Int": + def zeros(cls, _axes: Tuple[str, ...] = ("x", "y", "z")) -> "Vec3Int": return cls(0, 0, 0) @classmethod - def ones(cls) -> "Vec3Int": + def ones(cls, _axes: Tuple[str, ...] = ("x", "y", "z")) -> "Vec3Int": return cls(1, 1, 1) @classmethod - def full(cls, an_int: int) -> "Vec3Int": + def full(cls, an_int: int, _axes: Tuple[str, ...] = ("x", "y", "z")) -> "Vec3Int": return cls(an_int, an_int, an_int) diff --git a/webknossos/webknossos/geometry/vec_int.py b/webknossos/webknossos/geometry/vec_int.py new file mode 100644 index 000000000..0c64bc5c0 --- /dev/null +++ b/webknossos/webknossos/geometry/vec_int.py @@ -0,0 +1,336 @@ +import re +from operator import add, floordiv, mod, mul, sub +from typing import Any, Callable, Iterable, List, Optional, Tuple, TypeVar, Union, cast + +import numpy as np + +_VALUE_ERROR = "VecInt can be instantiated with int values `VecInt(1,2,3,4) or with `VecIntLike` object `VecInt([1,2,3,4])." + +_T = TypeVar("_T", bound="VecInt") + + +class VecInt(tuple): + """ + The VecInt class is designed to represent a vector of integers. This class is a subclass of the built-in tuple class, and it extends the functionality of tuples by providing additional methods and operations. + + One of the key features of the VecInt class is that it allows for the storage of axis names along with their corresponding values. + + Here is a brief example demonstrating how to use the VecInt class: + + ```python + from webknossos import VecInt + + # Creating a VecInt instance with 4 elements and axes x, y, z, t: + vector_1 = VecInt(1, 2, 3, 4, axes=("x", "y", "z", "t")) + # Alternative ways to create the same VecInt instance: + vector_1 = VecInt([1, 2, 3, 4], axes=("x", "y", "z", "t")) + vector_1 = VecInt(x=1, y=2, z=3, t=4) + + # Creating a VecInt instance with all elements set to 1 and axes x, y, z, t: + vector_2 = VecInt.full(1, axes=("x", "y", "z", "t")) + # Asserting that all elements in vector_2 are equal to 1: + assert vector_2[0] == vector_2[1] == vector_2[2] == vector_2[3] + + # Demonstrating the addition operation between two VecInt instances: + assert vector_1 + vector_2 == VecInt(2, 3, 4, 5) + ``` + """ + + axes: Tuple[str, ...] + _x_pos: Optional[int] + _y_pos: Optional[int] + _z_pos: Optional[int] + + def __new__( + cls, + *args: Union["VecIntLike", Iterable[str], int], + axes: Optional[Iterable[str]] = None, + **kwargs: int, + ) -> "VecInt": + as_tuple: Optional[Tuple[int, ...]] = None + + if args: + if isinstance(args[0], VecInt): + return args[0] + if isinstance(args[0], np.ndarray): + assert np.count_nonzero(args[0] % 1) == 0, _VALUE_ERROR + if isinstance(args[0], str): + return cls.from_str(args[0]) + if isinstance(args[0], Iterable): + as_tuple = tuple(int(item) for item in args[0]) + if args[1:] and isinstance(args[1], Iterable): + assert all(isinstance(arg, str) for arg in args[1]), _VALUE_ERROR + axes = tuple(args[1]) # type: ignore + elif isinstance(args, Iterable): + as_tuple = tuple(int(arg) for arg in args) # type: ignore + else: + raise ValueError(_VALUE_ERROR) + assert axes is not None, _VALUE_ERROR + else: + assert kwargs, _VALUE_ERROR + assert axes is None, _VALUE_ERROR + as_tuple = tuple(kwargs.values()) + + assert as_tuple is not None, _VALUE_ERROR + + self = super().__new__(cls, cast(Iterable, as_tuple)) + # self.axes is set in __new__ instead of __init__ so that pickling/unpickling + # works without problems. As long as the deserialization of a tree instance + # is not finished, the object is only half-initialized. Since self.axes + # is needed after deepcopy, an error would be raised otherwise. + # Also see: + # https://stackoverflow.com/questions/46283738/attributeerror-when-using-python-deepcopy + self.axes = tuple(axes or kwargs.keys()) + self._x_pos = self.axes.index("x") if "x" in self.axes else None + self._y_pos = self.axes.index("y") if "y" in self.axes else None + self._z_pos = self.axes.index("z") if "z" in self.axes else None + + return self + + def __getnewargs__(self) -> Tuple[Tuple[int, ...], Tuple[str, ...]]: + return (self.to_tuple(), self.axes) + + @property + def x(self) -> int: + """ + Returns the x component of the vector. + """ + if self._x_pos is not None: + return self[self._x_pos] + + raise ValueError("The vector does not have an x component.") + + @property + def y(self) -> int: + """ + Returns the y component of the vector. + """ + if self._y_pos is not None: + return self[self._y_pos] + + raise ValueError("The vector does not have an y component.") + + @property + def z(self) -> int: + """ + Returns the z component of the vector. + """ + if self._z_pos is not None: + return self[self._z_pos] + + raise ValueError("The vector does not have an z component.") + + @staticmethod + def from_str(string: str) -> "VecInt": + """ + Returns a new ND Vector from a string representation. + + Args: + - string (str): The string representation of the vector. + + Returns: + - VecInt: The new vector. + """ + return VecInt(tuple(map(int, re.findall(r"\d+", string)))) + + def with_replaced(self: _T, index: int, new_element: int) -> _T: + """Returns a new ND Vector with a replaced element at a given index.""" + + return self.__class__( + *self[:index], new_element, *self[index + 1 :], axes=self.axes + ) + + def to_np(self) -> np.ndarray: + """ + Returns the vector as a numpy array. + """ + return np.array(self) + + def to_list(self) -> List[int]: + """ + Returns the vector as a list. + """ + return list(self) + + def to_tuple(self) -> Tuple[int, ...]: + """ + Returns the vector as a tuple. + """ + return tuple(self) + + def contains(self, needle: int) -> bool: + """ + Checks if the vector contains a given element. + """ + return any(element == needle for element in self) + + def is_positive(self, strictly_positive: bool = False) -> bool: + """ + Checks if all elements in the vector are positive. + + Args: + - strictly_positive (bool): If True, checks if all elements are strictly positive. + + Returns: + - bool: True if all elements are positive, False otherwise. + """ + if strictly_positive: + return all(i > 0 for i in self) + + return all(i >= 0 for i in self) + + def is_uniform(self) -> bool: + """ + Checks if all elements in the vector are the same. + """ + first = self[0] + return all(element == first for element in self) + + def _element_wise( + self: _T, other: Union[int, "VecIntLike"], fn: Callable[[int, Any], int] + ) -> _T: + if isinstance(other, int): + other_imported = VecInt.full(other, axes=self.axes) + else: + other_imported = VecInt(other, axes=self.axes) + assert len(other_imported) == len( + self + ), f"{other} and {self} are not equally shaped." + return self.__class__( + **{ + axis: fn(self[i], other_imported[i]) for i, axis in enumerate(self.axes) + }, + axes=None, + ) + + # Note: When adding regular tuples the first tuple is extended with the second tuple. + # For VecInt we want to add the elements at the same index. + # Do not add VecInt to plain tuple! Hence the type:ignore) + def __add__(self: _T, other: Union[int, "VecIntLike"]) -> _T: # type: ignore[override] + return self._element_wise(other, add) + + def __sub__(self: _T, other: Union[int, "VecIntLike"]) -> _T: + return self._element_wise(other, sub) + + # Note: When multiplying regular tuples with an int those are repeated, + # which is a different behavior in the superclass! Hence the type:ignore. + def __mul__(self: _T, other: Union[int, "VecIntLike"]) -> _T: # type: ignore[override] + return self._element_wise(other, mul) + + def __floordiv__(self: _T, other: Union[int, "VecIntLike"]) -> _T: + return self._element_wise(other, floordiv) + + def __mod__(self: _T, other: Union[int, "VecIntLike"]) -> _T: + return self._element_wise(other, mod) + + def __neg__(self: _T) -> _T: + return self.__class__((-elem for elem in self), axes=self.axes) + + def ceildiv(self: _T, other: Union[int, "VecIntLike"]) -> _T: + """ + Returns a new VecInt with the ceil division of each element by the other. + """ + return (self + other - 1) // other + + def pairmax(self: _T, other: Union[int, "VecIntLike"]) -> _T: + """ + Returns a new VecInt with the maximum of each pair of elements from the two vectors. + """ + return self._element_wise(other, max) + + def pairmin(self: _T, other: Union[int, "VecIntLike"]) -> _T: + """ + Returns a new VecInt with the minimum of each pair of elements from the two vectors. + """ + return self._element_wise(other, min) + + def prod(self) -> int: + """ + Returns the product of all elements in the vector. + """ + return int(np.prod(self.to_np())) + + def __repr__(self) -> str: + return ( + f"{self.__class__.__name__}({','.join((str(element) for element in self))})" + ) + + def add_or_none(self: _T, other: Optional["VecInt"]) -> Optional[_T]: + """ + Adds two VecInts or returns None if the other is None. + + Args: + - other (Optional[VecInt]): The other vector to add. + + Returns: + - Optional[VecInt]: The sum of the two vectors or None if the other is None. + """ + return None if other is None else self + other + + def moveaxis( + self: _T, source: Union[int, List[int]], target: Union[int, List[int]] + ) -> _T: + """ + Allows to move one element at index `source` to another index `target`. Similar to + np.moveaxis, this is *not* a swap operation but instead it moves the specified + source so that the other elements move when necessary. + + Args: + - source (Union[int, List[int]]): The index of the element to move. + - target (Union[int, List[int]]): The index where the element should be moved to. + + Returns: + - VecInt: A new vector with the moved element. + """ + + # Piggy-back on np.moveaxis by creating an auxiliary array where the indices 0, 1 and + # 2 appear in the shape. + indices = np.moveaxis( + np.zeros(tuple(i for i in range(len(self)))), source, target + ).shape + arr = self.to_np()[np.array(indices)] + axes = np.array(self.axes)[np.array(indices)] + return self.__class__(arr, axes=axes) + + @classmethod + def zeros(cls, axes: Tuple[str, ...]) -> "VecInt": + """ + Returns a new ND Vector with all elements set to 0. + + Args: + - axes (Tuple[str, ...]): The axes of the vector. + + Returns: + - VecInt: The new vector. + """ + return cls((0 for _ in range(len(axes))), axes=axes) + + @classmethod + def ones(cls, axes: Tuple[str, ...]) -> "VecInt": + """ + Returns a new ND Vector with all elements set to 1. + + Args: + - axes (Tuple[str, ...]): The axes of the vector. + + Returns: + - VecInt: The new vector. + """ + return cls((1 for _ in range(len(axes))), axes=axes) + + @classmethod + def full(cls, an_int: int, axes: Tuple[str, ...]) -> "VecInt": + """ + Returns a new ND Vector with all elements set to the same value. + + Args: + - an_int (int): The value to set all elements to. + - axes (Tuple[str, ...]): The axes of the vector. + + Returns: + - VecInt: The new vector. + """ + return cls((an_int for _ in range(len(axes))), axes=axes) + + +VecIntLike = Union[VecInt, Tuple[int, ...], np.ndarray, Iterable[int]]