From 30511a718d03116e98fb2f42fc6ae2348e693e8d Mon Sep 17 00:00:00 2001 From: Ajayrama Kumaraswamy Date: Tue, 7 Jul 2026 14:10:46 +0200 Subject: [PATCH 1/7] chore: added pandas-stubs as dev dependency; helps with type checking --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 03181ead..f25a5fbb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -64,6 +64,7 @@ extra = [ [dependency-groups] dev = [ "bump2version", + "pandas-stubs>=3.0.3.260530", ] test = [ "pytest", From 986f0bb8b455ce4798156a5abf8ee4d4b99079be Mon Sep 17 00:00:00 2001 From: Ajayrama Kumaraswamy Date: Tue, 7 Jul 2026 14:14:26 +0200 Subject: [PATCH 2/7] chore: added local hatch.toml to gitignore --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 23fa4b3d..71e50570 100644 --- a/.gitignore +++ b/.gitignore @@ -61,3 +61,6 @@ node_modules/ uv.lock pixi.lock +# local hatch config +hatch.toml + From 5e0d12fde8cb677083562dc5eb1d36d26a657dd8 Mon Sep 17 00:00:00 2001 From: Ajayrama Kumaraswamy Date: Thu, 9 Jul 2026 13:00:03 +0200 Subject: [PATCH 3/7] chore: Add .kilo and plans to .gitignore --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index 71e50570..209d03ef 100644 --- a/.gitignore +++ b/.gitignore @@ -64,3 +64,7 @@ pixi.lock # local hatch config hatch.toml +# Kilo and plans +.kilo/ +plans/ + From 81bb674dbf33061d3fcb155ef80337be664937c1 Mon Sep 17 00:00:00 2001 From: Ajayrama Kumaraswamy Date: Mon, 13 Jul 2026 14:09:21 +0200 Subject: [PATCH 4/7] feat: added literals and types for element types and group names --- src/spatialdata/_io/io_raster.py | 44 ++++++++++++++++++-------------- src/spatialdata/_io/io_zarr.py | 31 ++++++++++++---------- src/spatialdata/_types.py | 34 ++++++++++++++++++++++-- 3 files changed, 75 insertions(+), 34 deletions(-) diff --git a/src/spatialdata/_io/io_raster.py b/src/spatialdata/_io/io_raster.py index 276f016b..29973ad2 100644 --- a/src/spatialdata/_io/io_raster.py +++ b/src/spatialdata/_io/io_raster.py @@ -2,7 +2,7 @@ from collections.abc import Sequence from pathlib import Path -from typing import Any, Literal, TypeGuard, cast +from typing import Any, Literal, TypeGuard, cast, get_args import dask.array as da import numpy as np @@ -28,6 +28,7 @@ RasterFormatType, get_ome_zarr_format, ) +from spatialdata._types import ELEMENT_TYPE, ELEMENT_TYPE_RASTER, GROUP_NAME from spatialdata._utils import get_pyramid_levels from spatialdata.models.models import ATTRS_KEY from spatialdata.models.pyramids_utils import dask_arrays_to_datatree @@ -160,10 +161,12 @@ def _prepare_storage_options( def _read_multiscale( - store: str | Path, raster_type: Literal["image", "labels"], reader_format: Format + store: str | Path, raster_type: ELEMENT_TYPE_RASTER, reader_format: Format ) -> DataArray | DataTree: assert isinstance(store, str | Path) - assert raster_type in ["image", "labels"] + assert raster_type in get_args(ELEMENT_TYPE_RASTER), ValueError( + f"{raster_type} is not a valid raster type. Must be one of {get_args(ELEMENT_TYPE_RASTER)}." + ) nodes: list[Node] = [] image_loc = ZarrLocation(store, fmt=reader_format) @@ -208,7 +211,7 @@ def _read_multiscale( transformations = _get_transformations_from_ngff_dict(encoded_ngff_transformations) # if image, read channels metadata channels: list[Any] | None = None - if raster_type == "image": + if raster_type == ELEMENT_TYPE.IMAGE: if legacy_channels_metadata is not None: channels = [d["label"] for d in legacy_channels_metadata["channels"]] if omero_metadata is not None: @@ -223,7 +226,7 @@ def _read_multiscale( data = node.load(Multiscales).array(resolution=datasets[0]) si = DataArray( data, - name="image", + name=ELEMENT_TYPE.IMAGE, dims=axes, coords={"c": channels} if channels is not None else {}, ) @@ -259,7 +262,7 @@ def _get_multiscale_nodes(image_nodes: list[Node], nodes: list[Node]) -> list[No def _write_raster( - raster_type: Literal["image", "labels"], + raster_type: ELEMENT_TYPE_RASTER, raster_data: DataArray | DataTree, group: zarr.Group, name: str, @@ -292,12 +295,13 @@ def _write_raster( metadata Additional metadata for the raster element """ - if raster_type not in ["image", "labels"]: - raise ValueError(f"{raster_type} is not a valid raster type. Must be 'image' or 'labels'.") + assert raster_type in get_args(ELEMENT_TYPE_RASTER), ValueError( + f"{raster_type} is not a valid raster type. Must be one of {get_args(ELEMENT_TYPE_RASTER)}." + ) # "name" and "label_metadata" are only used for labels. "name" is written in write_multiscale_ngff() but ignored in # write_image_ngff() (possibly an ome-zarr-py bug). We only use "name" to ensure correct group access in the # ome-zarr API. - if raster_type == "labels": + if raster_type == ELEMENT_TYPE.LABELS: metadata["name"] = name metadata["label_metadata"] = label_metadata @@ -326,8 +330,8 @@ def _write_raster( else: raise ValueError("Not a valid labels object") - group = group["labels"][name] if raster_type == "labels" else group - if raster_type == "image": + group = group[GROUP_NAME.LABELS][name] if raster_type == ELEMENT_TYPE.LABELS else group + if raster_type == ELEMENT_TYPE.IMAGE: # ome-zarr-py >= 0.18 no longer writes the omero channel metadata, so we write it ourselves. overwrite_channel_names(group, raster_data) if ATTRS_KEY not in group.attrs: @@ -418,7 +422,7 @@ def _update_dict_v3(d: dict[str, Any]) -> None: def _write_raster_dataarray( - raster_type: Literal["image", "labels"], + raster_type: ELEMENT_TYPE_RASTER, group: zarr.Group, element_name: str, raster_data: DataArray, @@ -448,7 +452,7 @@ def _write_raster_dataarray( metadata Additional metadata for the raster element """ - write_single_scale_ngff = write_image_ngff if raster_type == "image" else write_labels_ngff + write_single_scale_ngff = write_image_ngff if raster_type == ELEMENT_TYPE.IMAGE else write_labels_ngff data = raster_data.data transformations = _get_transformations(raster_data) @@ -479,7 +483,7 @@ def _write_raster_dataarray( **metadata, ) - trans_group = group["labels"][element_name] if raster_type == "labels" else group + trans_group = group[GROUP_NAME.LABELS][element_name] if raster_type == ELEMENT_TYPE.LABELS else group overwrite_coordinate_transformations_raster( group=trans_group, transformations=transformations, @@ -489,7 +493,7 @@ def _write_raster_dataarray( def _write_raster_datatree( - raster_type: Literal["image", "labels"], + raster_type: ELEMENT_TYPE_RASTER, group: zarr.Group, element_name: str, raster_data: DataTree, @@ -519,7 +523,9 @@ def _write_raster_datatree( metadata Additional metadata for the raster element """ - write_multi_scale_ngff = write_multiscale_ngff if raster_type == "image" else write_multiscale_labels_ngff + write_multi_scale_ngff = ( + write_multiscale_ngff if raster_type == ELEMENT_TYPE.IMAGE else write_multiscale_labels_ngff + ) data = get_pyramid_levels(raster_data, attr="data") list_of_input_axes: list[Any] = get_pyramid_levels(raster_data, attr="dims") assert len(set(list_of_input_axes)) == 1 @@ -562,7 +568,7 @@ def _write_raster_datatree( # This workaround should not be needed once https://github.com/ome/ome-zarr-py/issues/580 is fixed. group = zarr.open_group(store=group.store, path=group.path, mode="r+", use_consolidated=False) - trans_group = group["labels"][element_name] if raster_type == "labels" else group + trans_group = group[GROUP_NAME.LABELS][element_name] if raster_type == ELEMENT_TYPE.LABELS else group overwrite_coordinate_transformations_raster( group=trans_group, transformations=transformations, @@ -582,7 +588,7 @@ def write_image( **metadata: str | JSONDict | list[JSONDict], ) -> None: _write_raster( - raster_type="image", + raster_type=ELEMENT_TYPE.IMAGE, raster_data=image, group=group, name=name, @@ -604,7 +610,7 @@ def write_labels( **metadata: JSONDict, ) -> None: _write_raster( - raster_type="labels", + raster_type=ELEMENT_TYPE.LABELS, raster_data=labels, group=group, name=name, diff --git a/src/spatialdata/_io/io_zarr.py b/src/spatialdata/_io/io_zarr.py index 4c410fab..074b0eb1 100644 --- a/src/spatialdata/_io/io_zarr.py +++ b/src/spatialdata/_io/io_zarr.py @@ -5,7 +5,7 @@ from collections.abc import Callable from json import JSONDecodeError from pathlib import Path -from typing import Any, Literal, cast +from typing import Any, Literal, cast, get_args import zarr.storage from anndata import AnnData @@ -27,7 +27,12 @@ from spatialdata._io.io_shapes import _read_shapes from spatialdata._io.io_table import _read_table from spatialdata._logging import logger -from spatialdata._types import Raster_T +from spatialdata._types import ( + ELEMENT_TYPE, + ELEMENT_TYPE_RASTER, + GROUP_NAME, + Raster_T, +) def _read_zarr_group_spatialdata_element( @@ -36,8 +41,8 @@ def _read_zarr_group_spatialdata_element( sdata_version: Literal["0.1", "0.2"], selector: set[str], read_func: Callable[..., Any], - group_name: Literal["images", "labels", "shapes", "points", "tables"], - element_type: Literal["image", "labels", "shapes", "points", "tables"], + group_name: GROUP_NAME, + element_type: ELEMENT_TYPE, element_container: (dict[str, Raster_T] | dict[str, DaskDataFrame] | dict[str, GeoDataFrame] | dict[str, AnnData]), on_bad_files: Literal[BadFileHandleMethod.ERROR, BadFileHandleMethod.WARN], ) -> None: @@ -67,7 +72,7 @@ def _read_zarr_group_spatialdata_element( ValueError, ), ): - if element_type in ["image", "labels"]: + if element_type in get_args(ELEMENT_TYPE_RASTER): reader_format = get_raster_format_for_read(elem_group, sdata_version) element = read_func( elem_group_path, @@ -183,19 +188,19 @@ def read_zarr( # we could make this more readable. One can get lost when looking at this dict and iteration over the items group_readers: dict[ - Literal["images", "labels", "shapes", "points", "tables"], + GROUP_NAME, tuple[ Callable[..., Any], - Literal["image", "labels", "shapes", "points", "tables"], + ELEMENT_TYPE, dict[str, Raster_T] | dict[str, DaskDataFrame] | dict[str, GeoDataFrame] | dict[str, AnnData], ], ] = { - # ome-zarr-py needs a kwargs that has "image" has key. So here we have "image" and not "images" - "images": (_read_multiscale, "image", images), - "labels": (_read_multiscale, "labels", labels), - "points": (_read_points, "points", points), - "shapes": (_read_shapes, "shapes", shapes), - "tables": (_read_table, "tables", tables), + # ome-zarr-py needs a kwargs that has "image" as key. So here we have "image" and not "images" + GROUP_NAME.IMAGES: (_read_multiscale, ELEMENT_TYPE.IMAGE, images), + GROUP_NAME.LABELS: (_read_multiscale, ELEMENT_TYPE.LABELS, labels), + GROUP_NAME.POINTS: (_read_points, ELEMENT_TYPE.POINTS, points), + GROUP_NAME.SHAPES: (_read_shapes, ELEMENT_TYPE.SHAPES, shapes), + GROUP_NAME.TABLES: (_read_table, ELEMENT_TYPE.TABLES, tables), } for group_name, ( read_func, diff --git a/src/spatialdata/_types.py b/src/spatialdata/_types.py index da4443af..48fb5de5 100644 --- a/src/spatialdata/_types.py +++ b/src/spatialdata/_types.py @@ -1,11 +1,21 @@ from __future__ import annotations -from typing import Any +from enum import StrEnum +from typing import Any, Literal import numpy as np from xarray import DataArray, DataTree -__all__ = ["ArrayLike", "ColorLike", "DTypeLike", "Raster_T"] +__all__ = [ + "ArrayLike", + "ColorLike", + "DTypeLike", + "Raster_T", + "ELEMENT_TYPE", + "ELEMENT_TYPE_RASTER", + "ELEMENT_TYPE_VECTOR", + "GROUP_NAME", +] from numpy.typing import DTypeLike, NDArray @@ -14,3 +24,23 @@ type Raster_T = DataArray | DataTree ColorLike = tuple[float, ...] | str + + +class ELEMENT_TYPE(StrEnum): + IMAGE = "image" + LABELS = "labels" + SHAPES = "shapes" + POINTS = "points" + TABLES = "tables" + + +class GROUP_NAME(StrEnum): + IMAGES = "images" + LABELS = "labels" + SHAPES = "shapes" + POINTS = "points" + TABLES = "tables" + + +ELEMENT_TYPE_RASTER = Literal[ELEMENT_TYPE.IMAGE, ELEMENT_TYPE.LABELS] +ELEMENT_TYPE_VECTOR = Literal[ELEMENT_TYPE.POINTS, ELEMENT_TYPE.SHAPES] From 787770ac8d4fcf4ffcb3910c2c499d4f6ccc8238 Mon Sep 17 00:00:00 2001 From: Ajayrama Kumaraswamy Date: Tue, 14 Jul 2026 17:26:00 +0200 Subject: [PATCH 5/7] feat: added .coverage and htmlcov to gitignore --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index 209d03ef..421f25d3 100644 --- a/.gitignore +++ b/.gitignore @@ -68,3 +68,7 @@ hatch.toml .kilo/ plans/ +# test coverage +.coverage +htmlcov/ + From 9b2d2595c0b4ec1e9db536cf17646161f02da37a Mon Sep 17 00:00:00 2001 From: Ajayrama Kumaraswamy Date: Tue, 14 Jul 2026 17:27:42 +0200 Subject: [PATCH 6/7] feat: transformation manager at the root of spatialdata object --- src/spatialdata/__init__.py | 7 + src/spatialdata/_core/spatialdata.py | 2 + .../_core/transformation_manager/__init__.py | 342 +++++++++++++++ src/spatialdata/_io/io_zarr.py | 4 +- tests/core/transformation_manager/conftest.py | 58 +++ .../test_transformation_manager.py | 407 ++++++++++++++++++ 6 files changed, 818 insertions(+), 2 deletions(-) create mode 100644 src/spatialdata/_core/transformation_manager/__init__.py create mode 100644 tests/core/transformation_manager/conftest.py create mode 100644 tests/core/transformation_manager/test_transformation_manager.py diff --git a/src/spatialdata/__init__.py b/src/spatialdata/__init__.py index 7ba66e71..7a1af48c 100644 --- a/src/spatialdata/__init__.py +++ b/src/spatialdata/__init__.py @@ -57,6 +57,8 @@ # _core.query.spatial_query "bounding_box_query": "spatialdata._core.query.spatial_query", "polygon_query": "spatialdata._core.query.spatial_query", + # _core.transformation_manager + "TransformationManager": "spatialdata._core.transformation_manager", # _core.spatialdata "SpatialData": "spatialdata._core.spatialdata", # _io._utils @@ -115,6 +117,8 @@ # _core.query.spatial_query "bounding_box_query", "polygon_query", + # _core.transformation_manager + "TransformationManager", # _core.spatialdata "SpatialData", # _io._utils @@ -208,6 +212,9 @@ def __dir__() -> list[str]: # _core.spatialdata from spatialdata._core.spatialdata import SpatialData + # _core.transformation_manager + from spatialdata._core.transformation_manager import TransformationManager + # _io._utils from spatialdata._io._utils import get_dask_backing_files diff --git a/src/spatialdata/_core/spatialdata.py b/src/spatialdata/_core/spatialdata.py index fb55ab08..8f81a8d7 100644 --- a/src/spatialdata/_core/spatialdata.py +++ b/src/spatialdata/_core/spatialdata.py @@ -22,6 +22,7 @@ from zarr.errors import GroupNotFoundError from spatialdata._core._elements import Images, Labels, Points, Shapes, Tables +from spatialdata._core.transformation_manager import TransformationManager from spatialdata._core.validation import ( check_all_keys_case_insensitively_unique, check_target_region_column_symmetry, @@ -130,6 +131,7 @@ def __init__( self._shapes: Shapes = Shapes(shared_keys=self._shared_keys) self._tables: Tables = Tables(shared_keys=self._shared_keys) self.attrs = attrs if attrs else {} # type: ignore[assignment] + self.transformation_manager = TransformationManager() # Initialize the TransformationManager element_names = list(chain.from_iterable([e.keys() for e in [images, labels, points, shapes] if e is not None])) diff --git a/src/spatialdata/_core/transformation_manager/__init__.py b/src/spatialdata/_core/transformation_manager/__init__.py new file mode 100644 index 00000000..40871702 --- /dev/null +++ b/src/spatialdata/_core/transformation_manager/__init__.py @@ -0,0 +1,342 @@ +from __future__ import annotations + +from typing import Any + +from spatialdata._types import ELEMENT_TYPE +from spatialdata.transformations.ngff.ngff_coordinate_system import NgffCoordinateSystem +from spatialdata.transformations.transformations import BaseTransformation + + +class TransformationManager: + """Centralized storage for managing coordinate systems (cs), transformations and element-cs mapping.""" + + def __init__(self) -> None: + """Initialize a Scene with empty mappings.""" + self._coordinate_systems: dict[str, NgffCoordinateSystem] = {} + # mapping names of coordinate system to coordinate systems + self._coordinate_transforms: dict[tuple[str, str], BaseTransformation] = {} + # mapping a tuple of coordinate system names, (, ) + # to a transformation object representing the transformation between them + self._element_to_cs_mapping: dict[tuple[ELEMENT_TYPE, str], str] = {} + # mapping a tuple, (, ) to the name of the coordinate + # system + + def add_coordinate_system(self, cs: NgffCoordinateSystem) -> None: + """ + Register a new coordinate system. + + Parameters + ---------- + cs + The coordinate system to register. + + Raises + ------ + ValueError + If a coordinate system with the same name already exists. + """ + if cs.name in self._coordinate_systems: + raise ValueError(f"Coordinate system with name '{cs.name}' already exists.") + self._coordinate_systems[cs.name] = cs + + def _get_transformations_associated_with_cs(self, cs_name: str) -> list[tuple[str, str]]: + """ + Get all transformations associated with a coordinate system. + + Parameters + ---------- + cs_name + The name of the coordinate system. + + Returns + ------- + list[tuple[str, str]] + A list of tuples representing the transformations associated with the coordinate system. + """ + associated_transformations = [] + for input_cs, output_cs in self._coordinate_transforms: + transformation_key = (input_cs, output_cs) + if (input_cs == cs_name or output_cs == cs_name) and transformation_key not in associated_transformations: + associated_transformations.append(transformation_key) + return associated_transformations + + def _get_elements_associated_with_cs(self, cs_name: str) -> list[tuple[ELEMENT_TYPE, str]]: + """ + Get all elements associated with a coordinate system. + + Parameters + ---------- + cs_name + The name of the coordinate system. + + Returns + ------- + list[tuple[ELEMENT_TYPE, str]] + A list of tuples representing the elements associated with the coordinate system. + """ + associated_elements = [] + for (element_type, element_name), element_cs in self._element_to_cs_mapping.items(): + if element_cs == cs_name: + associated_elements.append((element_type, element_name)) + return associated_elements + + def remove_coordinate_system(self, cs_name: str) -> None: + """ + Remove an existing coordinate system. + + Parameters + ---------- + cs_name + The name of the coordinate system to remove. + + Raises + ------ + KeyError + If the coordinate system does not exist. + ValueError + If there are transformations or elements associated with the coordinate system. + """ + if cs_name not in self._coordinate_systems: + raise KeyError(f"Coordinate system with name '{cs_name}' not found.") + + associated_transformations = self._get_transformations_associated_with_cs(cs_name) + associated_elements = self._get_elements_associated_with_cs(cs_name) + + # Raise error if there are associated transformations or elements + if len(associated_transformations) or len(associated_elements): + raise ValueError( + f"Cannot remove coordinate system with name '{cs_name}'. " + f"{len(associated_elements)} elements and {len(associated_transformations)} transformations" + f" are associated with it. " + f"Please remove transformations or disassociate elements from the coordinate system first." + ) + + del self._coordinate_systems[cs_name] + + def list_coordinate_systems(self) -> list[str]: + """ + List all registered coordinate system names, ordered as they were added. + + Returns + ------- + A list of coordinate system names. + """ + return list(self._coordinate_systems.keys()) + + def add_element(self, element_type: ELEMENT_TYPE, element_name: str, coordinate_system: str) -> None: + """ + Register an element and associate it with a coordinate system. + + Parameters + ---------- + element_type + The type of the element (e.g., 'images', 'labels', 'points', 'shapes'). + element_name + The name of the element. + coordinate_system + The name of the coordinate system to which the element belongs. If None, a new coordinate system + will be created with the name "_created_at_insert". + + Raises + ------ + KeyError + If the coordinate system does not exist. + """ + if coordinate_system not in self._coordinate_systems: + raise KeyError( + f"Cannot set coordinate system ('{coordinate_system}') to element as the " + f"coordinate system does not exist." + ) + + mapping_key = (element_type, element_name) + self._element_to_cs_mapping[mapping_key] = coordinate_system + + def get_element_coordinate_system(self, element_type: ELEMENT_TYPE, element_name: str) -> str | None: + """ + Get the name of the coordinate system to which an element belongs. + + Parameters + ---------- + element_type + The type of the element. + element_name + The name of the element. + + Returns + ------- + The name of the coordinate system or None if not found + + """ + mapping_key = (element_type, element_name) + return self._element_to_cs_mapping.get(mapping_key) + + def add_transformation(self, input_cs: str, output_cs: str, transformation: BaseTransformation) -> None: + """ + Add a transformation between coordinate systems. + + Parameters + ---------- + input_cs + The name of the input coordinate system. + output_cs + The name of the output coordinate system. + transformation + The transformation to add. + + Raises + ------ + ValueError + If either coordinate system does not exist. + """ + if input_cs not in self._coordinate_systems: + raise ValueError( + f"Input coordinate system '{input_cs}' does not exist." + f" Please create it before adding a transform associated with it" + ) + if output_cs not in self._coordinate_systems: + raise ValueError( + f"Output coordinate system '{output_cs}' does not exist." + f" Please create it before adding a transform associated with it" + ) + + key = (input_cs, output_cs) + self._coordinate_transforms[key] = transformation + + def get_existing_transformation(self, input_cs: str, output_cs: str) -> BaseTransformation | None: + """ + Retrieve a transformation defined between coordinate systems. + + Parameters + ---------- + input_cs + The name of the input coordinate system. + output_cs + The name of the output coordinate system. + + Returns + ------- + The transformation + + Raises + ------ + KeyError: + if no transformation exists from input_cs to output_cs. + """ + key = (input_cs, output_cs) + return self._coordinate_transforms.get(key) + + def remove_transformation(self, input_cs: str, output_cs: str) -> None: + """ + Remove a transformation between coordinate systems. + + Parameters + ---------- + input_cs + The name of the input coordinate system. + output_cs + The name of the output coordinate system. + + Raises + ------ + KeyError + If the transformation does not exist. + """ + key = (input_cs, output_cs) + if key not in self._coordinate_transforms: + raise KeyError(f"Transformation from '{input_cs}' to '{output_cs}' not found.") + del self._coordinate_transforms[key] + + def build_nx_graph(self) -> Any: # type: ignore[unresolved-reference] # noqa: F821 + # nx lazily imported + """ + Build a directed graph where nodes are coordinate systems and edges are transformations. + + Returns + ------- + nx.DiGraph + A directed graph representing the coordinate systems and transformations. + """ + import networkx as nx + + g = nx.DiGraph() + for cs_name in self._coordinate_systems: + g.add_node(cs_name) + for (input_cs, output_cs), transformation in self._coordinate_transforms.items(): + g.add_edge(input_cs, output_cs, transformation=transformation) + return g + + def get_shortest_transformation_sequence(self, source_cs: str, target_cs: str) -> list[BaseTransformation]: + """ + Get the shortest sequence of transformations between two coordinate systems. + + Parameters + ---------- + source_cs + The name of the source coordinate system. + target_cs + The name of the target coordinate system. + + Returns + ------- + list[BaseTransformation] + The shortest sequence of transformations from source_cs to target_cs. + + Raises + ------ + ValueError + If no path exists between the source and target coordinate systems. + """ + if (source_cs, target_cs) in self._coordinate_transforms: + return [self._coordinate_transforms[(source_cs, target_cs)]] + + g = self.build_nx_graph() + import networkx as nx + + try: + path = nx.shortest_path(g, source=source_cs, target=target_cs) # type: ignore[name-defined, unresolved-reference] # noqa: F821 + # nx lazily imported + except nx.NetworkXNoPath as nxe: # type: ignore[name-defined, unresolved-reference] # noqa: F821 + raise ValueError(f"No path found from {source_cs} to {target_cs}") from nxe + + transformations = [] + for i in range(len(path) - 1): + transformations.append(g[path[i]][path[i + 1]]["transformation"]) + return transformations + + def get_all_transformation_sequences(self, source_cs: str, target_cs: str) -> list[list[BaseTransformation]]: + """ + Get all existing sequences of transformations between two coordinate systems. + + Parameters + ---------- + source_cs + The name of the source coordinate system. + target_cs + The name of the target coordinate system. + + Returns + ------- + list[list[BaseTransformation]] + All existing sequences of transformations from source_cs to target_cs. + """ + g = self.build_nx_graph() + import networkx as nx + + paths = list(nx.all_simple_paths(g, source=source_cs, target=target_cs)) + + all_sequences = [] + for path in paths: + sequence = [] + for i in range(len(path) - 1): + sequence.append(g[path[i]][path[i + 1]]["transformation"]) + all_sequences.append(sequence) + return all_sequences + + def __repr__(self) -> str: + return ( + f"TransformationManager(" + f" coordinate_systems={list(self._coordinate_systems.keys())}, " + f" coordinate_transforms={list(self._coordinate_transforms.keys())}, " + f" elements={list(self._element_to_cs_mapping.keys())}" + f")" + ) diff --git a/src/spatialdata/_io/io_zarr.py b/src/spatialdata/_io/io_zarr.py index 074b0eb1..4f774da8 100644 --- a/src/spatialdata/_io/io_zarr.py +++ b/src/spatialdata/_io/io_zarr.py @@ -76,10 +76,10 @@ def _read_zarr_group_spatialdata_element( reader_format = get_raster_format_for_read(elem_group, sdata_version) element = read_func( elem_group_path, - cast(Literal["image", "labels"], element_type), + cast(ELEMENT_TYPE_RASTER, element_type), reader_format, ) - elif element_type in ["shapes", "points", "tables"]: + elif element_type in get_args(ELEMENT_TYPE_RASTER) + (ELEMENT_TYPE.TABLES,): element = read_func(elem_group_path) else: raise ValueError(f"Unknown element type {element_type}") diff --git a/tests/core/transformation_manager/conftest.py b/tests/core/transformation_manager/conftest.py new file mode 100644 index 00000000..61b9dafa --- /dev/null +++ b/tests/core/transformation_manager/conftest.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 + +""" +Fixtures for transformation manager tests. +""" + +from __future__ import annotations + +import pytest + +from spatialdata.transformations.ngff.ngff_coordinate_system import NgffAxis, NgffCoordinateSystem +from spatialdata.transformations.transformations import Identity, Translation + + +def get_ngff_coodinate_system(cs_name: str) -> NgffCoordinateSystem: + return NgffCoordinateSystem( + name=cs_name, + axes=[NgffAxis(name="x", type="space", unit="micrometer"), NgffAxis(name="y", type="space", unit="micrometer")], + ) + + +@pytest.fixture +def one_point_graph() -> tuple[list[NgffCoordinateSystem], list[Identity]]: + """Fixture providing a single point graph with one coordinate system and one transformation.""" + coordinate_systems = [get_ngff_coodinate_system("cs1")] + transformations = [Identity()] + return coordinate_systems, transformations + + +@pytest.fixture +def fully_connected_two_point_graph() -> tuple[list[NgffCoordinateSystem], list[Identity | Translation]]: + """Fixture providing a fully connected two-point graph with two coordinate systems and transformations.""" + coordinate_systems = [get_ngff_coodinate_system("cs1"), get_ngff_coodinate_system("cs2")] + transformations = [ + Identity(), + Identity(), + Translation(translation=[1.0, 2.0], axes=("x", "y")), + Translation(translation=[-1.0, -2.0], axes=("x", "y")), + ] + return coordinate_systems, transformations + + +@pytest.fixture +def four_point_graph() -> tuple[list[NgffCoordinateSystem], list[Identity | Translation]]: + """Fixture providing a four-point graph with four coordinate systems and transformations.""" + coordinate_systems = [ + get_ngff_coodinate_system("cs1"), + get_ngff_coodinate_system("cs2"), + get_ngff_coodinate_system("cs3"), + get_ngff_coodinate_system("cs4"), + ] + transformations = [ + Translation(translation=[1.0, 2.0], axes=("x", "y")), # cs1 -> cs2 + Translation(translation=[3.0, 4.0], axes=("x", "y")), # cs2 -> cs3 + Identity(), # cs3 -> cs4 + Translation(translation=[4.0, 6.0], axes=("x", "y")), # cs1 -> cs3 (consistent with cs1->cs2 and cs2->cs3) + ] + return coordinate_systems, transformations diff --git a/tests/core/transformation_manager/test_transformation_manager.py b/tests/core/transformation_manager/test_transformation_manager.py new file mode 100644 index 00000000..6669a97c --- /dev/null +++ b/tests/core/transformation_manager/test_transformation_manager.py @@ -0,0 +1,407 @@ +#!/usr/bin/env python3 + +""" +Unit tests for the TransformationManager class in _core/transformation_manager/__init__.py. + +This module tests all the functionality of the TransformationManager class to achieve 100% coverage. +""" + +from __future__ import annotations + +import pytest + +from spatialdata import TransformationManager +from spatialdata._types import ELEMENT_TYPE + + +def test_initialization(): + """Test that TransformationManager initializes correctly.""" + tm = TransformationManager() + assert tm._coordinate_systems == {} + assert tm._coordinate_transforms == {} + assert tm._element_to_cs_mapping == {} + + +def test_add_coordinate_system(one_point_graph): + """Test adding a coordinate system.""" + tm = TransformationManager() + coordinate_systems, _ = one_point_graph + cs = coordinate_systems[0] + tm.add_coordinate_system(cs) + assert cs.name in tm._coordinate_systems + assert tm._coordinate_systems[cs.name] == cs + + +def test_add_coordinate_system_duplicate(one_point_graph): + """Test that adding a duplicate coordinate system raises ValueError.""" + tm = TransformationManager() + coordinate_systems, _ = one_point_graph + cs = coordinate_systems[0] + tm.add_coordinate_system(cs) + with pytest.raises(ValueError, match=f"Coordinate system with name '{cs.name}' already exists"): + tm.add_coordinate_system(cs) + + +def test_get_transformations_associated_with_cs(fully_connected_two_point_graph): + """Test getting transformations associated with a coordinate system.""" + tm = TransformationManager() + coordinate_systems, transformations = fully_connected_two_point_graph + cs1, cs2 = coordinate_systems + tm.add_coordinate_system(cs1) + tm.add_coordinate_system(cs2) + + transform = transformations[0] + tm.add_transformation(cs1.name, cs2.name, transform) + + associated = tm._get_transformations_associated_with_cs(cs1.name) + assert associated == [(cs1.name, cs2.name)] + + +def test_get_elements_associated_with_cs(one_point_graph): + """Test getting elements associated with a coordinate system.""" + tm = TransformationManager() + coordinate_systems, _ = one_point_graph + cs = coordinate_systems[0] + tm.add_coordinate_system(cs) + tm.add_element(ELEMENT_TYPE.IMAGE, "image1", cs.name) + + associated = tm._get_elements_associated_with_cs(cs.name) + assert associated == [(ELEMENT_TYPE.IMAGE, "image1")] + + +def test_remove_coordinate_system(one_point_graph): + """Test removing a coordinate system.""" + tm = TransformationManager() + + coordinate_systems, _ = one_point_graph + cs = coordinate_systems[0] + tm.add_coordinate_system(cs) + tm.remove_coordinate_system(cs.name) + assert cs.name not in tm._coordinate_systems + + +def test_remove_coordinate_system_nonexistent(): + """Test that removing a non-existent coordinate system raises KeyError.""" + tm = TransformationManager() + with pytest.raises(KeyError, match="Coordinate system with name 'cs1' not found"): + tm.remove_coordinate_system("cs1") + + +def test_remove_coordinate_system_with_associations(fully_connected_two_point_graph): + """Test that removing a coordinate system with associations raises ValueError.""" + tm = TransformationManager() + coordinate_systems, _transformations = fully_connected_two_point_graph + cs1, cs2 = coordinate_systems + tm.add_coordinate_system(cs1) + tm.add_coordinate_system(cs2) + + transform = _transformations[0] + tm.add_transformation(cs1.name, cs2.name, transform) + + with pytest.raises(ValueError, match="Cannot remove coordinate system"): + tm.remove_coordinate_system(cs1.name) + + +def test_remove_coordinate_system_with_element_associations(one_point_graph): + """Test that removing a coordinate system with element associations raises ValueError.""" + tm = TransformationManager() + coordinate_systems, _ = one_point_graph + cs = coordinate_systems[0] + tm.add_coordinate_system(cs) + tm.add_element(ELEMENT_TYPE.IMAGE, "image1", cs.name) + + with pytest.raises(ValueError, match="Cannot remove coordinate system"): + tm.remove_coordinate_system(cs.name) + + +@pytest.mark.parametrize("cs_names", [["cs1", "cs2"]]) +def test_list_coordinate_systems(fully_connected_two_point_graph, cs_names): + """Test listing all coordinate systems.""" + tm = TransformationManager() + coordinate_systems, _ = fully_connected_two_point_graph + cs1, cs2 = coordinate_systems + tm.add_coordinate_system(cs1) + tm.add_coordinate_system(cs2) + + systems = tm.list_coordinate_systems() + assert set(systems) == {cs1.name, cs2.name} + + +def test_add_element(one_point_graph): + """Test adding an element with an existing coordinate system.""" + tm = TransformationManager() + coordinate_systems, _ = one_point_graph + cs = coordinate_systems[0] + tm.add_coordinate_system(cs) + tm.add_element(ELEMENT_TYPE.IMAGE, "image1", cs.name) + + mapping = tm._element_to_cs_mapping + assert (ELEMENT_TYPE.IMAGE, "image1") in mapping + assert mapping[(ELEMENT_TYPE.IMAGE, "image1")] == cs.name + + +def test_add_element_nonexistent_cs(): + """Test that adding an element with a non-existent coordinate system raises KeyError.""" + tm = TransformationManager() + with pytest.raises(KeyError, match="Cannot set coordinate system"): + tm.add_element(ELEMENT_TYPE.IMAGE, "image1", "nonexistent_cs") + + +def test_get_element_coordinate_system(one_point_graph): + """Test getting the coordinate system of an element.""" + tm = TransformationManager() + coordinate_systems, _ = one_point_graph + cs = coordinate_systems[0] + tm.add_coordinate_system(cs) + tm.add_element(ELEMENT_TYPE.IMAGE, "image1", cs.name) + + cs_name = tm.get_element_coordinate_system(ELEMENT_TYPE.IMAGE, "image1") + assert cs_name == cs.name + + +def test_get_element_coordinate_system_nonexistent(): + """Test getting the coordinate system of a non-existent element.""" + tm = TransformationManager() + cs_name = tm.get_element_coordinate_system(ELEMENT_TYPE.IMAGE, "nonexistent") + assert cs_name is None + + +@pytest.mark.parametrize("cs_names", [["cs1", "cs2"]]) +def test_add_transformation(fully_connected_two_point_graph, cs_names): + """Test adding a transformation between coordinate systems.""" + tm = TransformationManager() + coordinate_systems, transformations = fully_connected_two_point_graph + cs1, cs2 = coordinate_systems + tm.add_coordinate_system(cs1) + tm.add_coordinate_system(cs2) + + transform = transformations[0] + tm.add_transformation(cs1.name, cs2.name, transform) + + key = (cs1.name, cs2.name) + assert key in tm._coordinate_transforms + assert tm._coordinate_transforms[key] == transform + + +@pytest.mark.parametrize("cs_names", [["cs1", "cs2"]]) +def test_add_transformation_nonexistent_cs(fully_connected_two_point_graph, cs_names): + """Test that adding a transformation with non-existent coordinate systems raises ValueError.""" + tm = TransformationManager() + coordinate_systems, transformations = fully_connected_two_point_graph + transform = transformations[0] + cs1, cs2 = coordinate_systems + + with pytest.raises(ValueError, match=f"Input coordinate system '{cs1.name}' does not exist"): + tm.add_transformation(cs1.name, cs2.name, transform) + + # Add one coordinate system + tm.add_coordinate_system(cs1) + + with pytest.raises(ValueError, match=f"Output coordinate system '{cs2.name}' does not exist"): + tm.add_transformation(cs1.name, cs2.name, transform) + + +@pytest.mark.parametrize("cs_names", [["cs1", "cs2"]]) +def test_get_existing_transformation(fully_connected_two_point_graph, cs_names): + """Test getting an existing transformation.""" + tm = TransformationManager() + coordinate_systems, transformations = fully_connected_two_point_graph + cs1, cs2 = coordinate_systems + tm.add_coordinate_system(cs1) + tm.add_coordinate_system(cs2) + + transform = transformations[0] + tm.add_transformation(cs1.name, cs2.name, transform) + + retrieved = tm.get_existing_transformation(cs1.name, cs2.name) + assert retrieved == transform + + +def test_get_existing_transformation_nonexistent(fully_connected_two_point_graph): + """Test getting a non-existent transformation.""" + tm = TransformationManager() + coordinate_systems, _transformations = fully_connected_two_point_graph + cs1, cs2 = coordinate_systems + tm.add_coordinate_system(cs1) + tm.add_coordinate_system(cs2) + + retrieved = tm.get_existing_transformation(cs1.name, cs2.name) + assert retrieved is None + + +@pytest.mark.parametrize("cs_names", [["cs1", "cs2"]]) +def test_remove_transformation(fully_connected_two_point_graph, cs_names): + """Test removing a transformation.""" + tm = TransformationManager() + coordinate_systems, transformations = fully_connected_two_point_graph + cs1, cs2 = coordinate_systems + tm.add_coordinate_system(cs1) + tm.add_coordinate_system(cs2) + + transform = transformations[0] + tm.add_transformation(cs1.name, cs2.name, transform) + + tm.remove_transformation(cs1.name, cs2.name) + assert (cs1.name, cs2.name) not in tm._coordinate_transforms + + +def test_remove_transformation_nonexistent(): + """Test that removing a non-existent transformation raises KeyError.""" + tm = TransformationManager() + with pytest.raises(KeyError, match="Transformation from 'cs1' to 'cs2' not found"): + tm.remove_transformation("cs1", "cs2") + + +def test_build_nx_graph(four_point_graph): + """Test building a networkx graph from the transformation manager.""" + tm = TransformationManager() + coordinate_systems, transformations = four_point_graph + cs1, cs2, cs3, cs4 = coordinate_systems + tm.add_coordinate_system(cs1) + tm.add_coordinate_system(cs2) + tm.add_coordinate_system(cs3) + tm.add_coordinate_system(cs4) + + transform1 = transformations[0] # cs1 -> cs2 + transform2 = transformations[1] # cs2 -> cs3 + transform3 = transformations[2] # cs3 -> cs4 + transform4 = transformations[3] # cs1 -> cs3 + tm.add_transformation(cs1.name, cs2.name, transform1) + tm.add_transformation(cs2.name, cs3.name, transform2) + tm.add_transformation(cs3.name, cs4.name, transform3) + tm.add_transformation(cs1.name, cs3.name, transform4) + + g = tm.build_nx_graph() + assert g.has_node(cs1.name) + assert g.has_node(cs2.name) + assert g.has_node(cs3.name) + assert g.has_node(cs4.name) + assert g.has_edge(cs1.name, cs2.name) + assert g.has_edge(cs2.name, cs3.name) + assert g.has_edge(cs3.name, cs4.name) + assert g.has_edge(cs1.name, cs3.name) + assert g[cs1.name][cs2.name]["transformation"] == transform1 + assert g[cs2.name][cs3.name]["transformation"] == transform2 + assert g[cs3.name][cs4.name]["transformation"] == transform3 + assert g[cs1.name][cs3.name]["transformation"] == transform4 + + +def test_get_shortest_transformation_sequence_direct(four_point_graph): + """Test getting the shortest transformation sequence for a direct transformation.""" + tm = TransformationManager() + coordinate_systems, transformations = four_point_graph + cs1, cs2, cs3, cs4 = coordinate_systems + tm.add_coordinate_system(cs1) + tm.add_coordinate_system(cs2) + tm.add_coordinate_system(cs3) + tm.add_coordinate_system(cs4) + + transform1 = transformations[0] # cs1 -> cs2 + transform2 = transformations[1] # cs2 -> cs3 + transform4 = transformations[3] # cs1 -> cs3 + tm.add_transformation(cs1.name, cs2.name, transform1) + tm.add_transformation(cs2.name, cs3.name, transform2) + tm.add_transformation(cs1.name, cs3.name, transform4) + + sequence = tm.get_shortest_transformation_sequence(cs1.name, cs3.name) + assert sequence == [transform4] + + +def test_get_shortest_transformation_sequence_indirect(four_point_graph): + """Test getting the shortest transformation sequence for an indirect transformation.""" + tm = TransformationManager() + coordinate_systems, transformations = four_point_graph + cs1, cs2, cs3, cs4 = coordinate_systems + tm.add_coordinate_system(cs1) + tm.add_coordinate_system(cs2) + tm.add_coordinate_system(cs3) + tm.add_coordinate_system(cs4) + + transform1 = transformations[0] # cs1 -> cs2 + transform2 = transformations[1] # cs2 -> cs3 + transform3 = transformations[2] # cs3 -> cs4 + tm.add_transformation(cs1.name, cs2.name, transform1) + tm.add_transformation(cs2.name, cs3.name, transform2) + tm.add_transformation(cs3.name, cs4.name, transform3) + + sequence = tm.get_shortest_transformation_sequence(cs1.name, cs3.name) + assert sequence == [transform1, transform2] + + +def test_get_shortest_transformation_sequence_no_path(four_point_graph): + """Test that getting a transformation sequence with no path raises ValueError.""" + tm = TransformationManager() + coordinate_systems, transformations = four_point_graph + cs1, cs2, cs3, cs4 = coordinate_systems + tm.add_coordinate_system(cs1) + tm.add_coordinate_system(cs2) + tm.add_coordinate_system(cs3) + tm.add_coordinate_system(cs4) + + transform1 = transformations[0] # cs1 -> cs2 + transform2 = transformations[1] # cs2 -> cs3 + + tm.add_transformation(cs1.name, cs2.name, transform1) + tm.add_transformation(cs2.name, cs3.name, transform2) + + with pytest.raises(ValueError, match=f"No path found from {cs1.name} to {cs4.name}"): + tm.get_shortest_transformation_sequence(cs1.name, cs4.name) + + +def test_get_all_transformation_sequences(four_point_graph): + """Test getting all transformation sequences.""" + tm = TransformationManager() + coordinate_systems, transformations = four_point_graph + cs1, cs2, cs3, cs4 = coordinate_systems + tm.add_coordinate_system(cs1) + tm.add_coordinate_system(cs2) + tm.add_coordinate_system(cs3) + tm.add_coordinate_system(cs4) + + transform1 = transformations[0] # cs1 -> cs2 + transform2 = transformations[1] # cs2 -> cs3 + transform3 = transformations[2] # cs3 -> cs4 + transform4 = transformations[3] # cs1 -> cs3 + tm.add_transformation(cs1.name, cs2.name, transform1) + tm.add_transformation(cs2.name, cs3.name, transform2) + tm.add_transformation(cs3.name, cs4.name, transform3) + tm.add_transformation(cs1.name, cs3.name, transform4) + + sequences = tm.get_all_transformation_sequences(cs1.name, cs4.name) + assert len(sequences) == 2 + assert [transform1, transform2, transform3] in sequences + assert [transform4, transform3] in sequences + + +def test_get_all_transformation_sequences_no_path(four_point_graph): + """Test that getting all transformation sequences with no path returns an empty list.""" + tm = TransformationManager() + coordinate_systems, transformations = four_point_graph + cs1, cs2, cs3, cs4 = coordinate_systems + tm.add_coordinate_system(cs1) + tm.add_coordinate_system(cs2) + tm.add_coordinate_system(cs3) + tm.add_coordinate_system(cs4) + + transform1 = transformations[0] # cs1 -> cs2 + transform2 = transformations[1] # cs2 -> cs3 + + tm.add_transformation(cs1.name, cs2.name, transform1) + tm.add_transformation(cs2.name, cs3.name, transform2) + + sequences = tm.get_all_transformation_sequences(cs1.name, cs4.name) + assert sequences == [] + + +def test_repr(one_point_graph): + """Test the string representation of TransformationManager.""" + tm = TransformationManager() + coordinate_systems, _ = one_point_graph + cs = coordinate_systems[0] + tm.add_coordinate_system(cs) + tm.add_element(ELEMENT_TYPE.IMAGE, "image1", cs.name) + + repr_str = repr(tm) + assert "TransformationManager" in repr_str + assert cs.name in repr_str + assert "image1" in repr_str From ca80f912a954004b93511e9cb6b4b2a046019fcb Mon Sep 17 00:00:00 2001 From: Ajayrama Kumaraswamy Date: Thu, 16 Jul 2026 15:06:07 +0200 Subject: [PATCH 7/7] fix: io_zarr.py mixup between ELEMENT_TYPE_VECTOR/RASTER --- src/spatialdata/_io/io_zarr.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/spatialdata/_io/io_zarr.py b/src/spatialdata/_io/io_zarr.py index 4f774da8..10ec2a48 100644 --- a/src/spatialdata/_io/io_zarr.py +++ b/src/spatialdata/_io/io_zarr.py @@ -30,6 +30,7 @@ from spatialdata._types import ( ELEMENT_TYPE, ELEMENT_TYPE_RASTER, + ELEMENT_TYPE_VECTOR, GROUP_NAME, Raster_T, ) @@ -79,7 +80,7 @@ def _read_zarr_group_spatialdata_element( cast(ELEMENT_TYPE_RASTER, element_type), reader_format, ) - elif element_type in get_args(ELEMENT_TYPE_RASTER) + (ELEMENT_TYPE.TABLES,): + elif element_type in get_args(ELEMENT_TYPE_VECTOR) + (ELEMENT_TYPE.TABLES,): element = read_func(elem_group_path) else: raise ValueError(f"Unknown element type {element_type}")