From 15d8f003bd99e86a15bf641e4374575547f0c26e Mon Sep 17 00:00:00 2001 From: Ajayrama Kumaraswamy Date: Mon, 10 Aug 2026 10:04:18 +0200 Subject: [PATCH 01/54] Transformation manager foundation (#1164) * chore: added pandas-stubs as dev dependency; helps with type checking * chore: added local hatch.toml to gitignore * chore: Add .kilo and plans to .gitignore * feat: added literals and types for element types and group names * feat: added .coverage and htmlcov to gitignore * feat: transformation manager at the root of spatialdata object * fix: io_zarr.py mixup between ELEMENT_TYPE_VECTOR/RASTER * fix: avoid exposing TransformationManager in spatialdata __init__.py * fix: element associated to cs -> element belonging to cs * refac: transformation_manager now uses nx.MultiDiGraph * refac: tests to match new transformation manager implementation * fix: restored unintentionally removed functions + others fixes * feat: custom error messages for transformation graph elements * feat: custom exceptions for transform manager * refac: transform manager attributes private; added missing method * feat: more custom error and warnings + fixes * feat: more test coverage + fixes * feat: support for transformation management in graph with mutliple edges * fix: fixes in tests + renaming for readability * fix: typing Affine Transform * feat: TransformationManager return Sequence instead of list * feat: made TransformationManager.graph type specific over node type * fix: removed internal attribute access warnings * refac: rename check_if... methods to assert_... + one additional method * fix: node type spec for TransformationManager._graph * fix: made edge key definition from transforms more robust * refac: method rename * refac: TransformationManager get/remove methods don't raise error if edges are missing * feat: new test for TransformationManager.add_transformation * fix: better error messages and edge case handling for when transformation path is ambiguous * feat: TransformationManager edge (key) definition made stronger * fix: simplified access to attributes of TransformationManager * fix: TransformationManager.unset_element is now private * fix: throw error a path has one node * fix: simplified checking if coordinate system has associated transforms * fix: improved Transformation Manager code quality with better typing * fix: improved Transformation Manager exception naming and messaging * fix: improved Transformation Manager documentation * feat: added tests for Transformation Manager * fix: used explicit syntax for type definitoin * refac: typo * fix: removed TransformationManager import from spatialdata __init__.py * fix: TransformationManager, simplified code using custom error --- .gitignore | 15 + src/spatialdata/__init__.py | 1 - src/spatialdata/_core/spatialdata.py | 2 + .../_core/transformation_manager/__init__.py | 635 +++++++++++++++++ .../transformation_manager/exceptions.py | 229 +++++++ src/spatialdata/_io/io_raster.py | 40 +- src/spatialdata/_io/io_zarr.py | 35 +- src/spatialdata/_types.py | 35 +- tests/core/transformation_manager/conftest.py | 92 +++ .../test_transformation_manager.py | 643 ++++++++++++++++++ 10 files changed, 1693 insertions(+), 34 deletions(-) create mode 100644 src/spatialdata/_core/transformation_manager/__init__.py create mode 100644 src/spatialdata/_core/transformation_manager/exceptions.py create mode 100644 tests/core/transformation_manager/conftest.py create mode 100644 tests/core/transformation_manager/test_transformation_manager.py diff --git a/.gitignore b/.gitignore index d157ddaae..1c56e190e 100644 --- a/.gitignore +++ b/.gitignore @@ -54,3 +54,18 @@ _version.py # benchmarking and profiling .asv/ profile.speedscope.json + +# other +node_modules/ + +.mypy_cache +.ruff_cache + +# Kilo and plans +.kilo/ +plans/ + +# test coverage +.coverage +htmlcov/ + diff --git a/src/spatialdata/__init__.py b/src/spatialdata/__init__.py index 96e7032dd..1c04924a4 100644 --- a/src/spatialdata/__init__.py +++ b/src/spatialdata/__init__.py @@ -59,7 +59,6 @@ "polygon_query": "spatialdata._core.query.spatial_query", # _core.spatialdata "SpatialData": "spatialdata._core.spatialdata", - # _io._utils "get_dask_backing_files": "spatialdata._io._utils", # _io.format "SpatialDataFormatType": "spatialdata._io.format", diff --git a/src/spatialdata/_core/spatialdata.py b/src/spatialdata/_core/spatialdata.py index 9f5c91c7a..34e25aa64 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, @@ -132,6 +133,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 000000000..079fe5cce --- /dev/null +++ b/src/spatialdata/_core/transformation_manager/__init__.py @@ -0,0 +1,635 @@ +from __future__ import annotations + +from collections.abc import Sequence + +import networkx as nx + +from spatialdata._core.transformation_manager.exceptions import ( + CannotRemoveCoordinateSystemError, + CoordinateSystemAlreadyExistsError, + CoordinateSystemHasElementsError, + CoordinateSystemHasTransformationsError, + CoordinateSystemNotFoundError, + ElementAlreadyExistsError, + ElementNotRegisteredToAnyCoordinateSystemError, + InvalidPathError, + TransformationNotFoundError, + TransformationPathAmbiguousError, + TransformationPathAmbiguousMultipleEdgeExpectedError, + TransformationPathAmbiguousNoEdgeExpectedError, + TransformationPathNotFoundError, + TransformationPathNotSimple, +) +from spatialdata.transformations import transformations as sd_transforms +from spatialdata.transformations.ngff.ngff_coordinate_system import NgffCoordinateSystem + +TRANSFORM_KEY = "transformation" +type EDGE_DEF = tuple[NgffCoordinateSystem, NgffCoordinateSystem, sd_transforms.BaseTransformation] + + +class TransformationManager: + def __init__(self) -> None: + """Initialize a TransformationManager with empty graph and mappings.""" + self._graph: nx.MultiDiGraph[NgffCoordinateSystem] = nx.MultiDiGraph() + # MultiDiGraph with NgffCoordinateSystem objects as nodes and transforms as edge attributes + self._element_to_cs_mapping: dict[str, NgffCoordinateSystem] = {} + # mapping element_name to the coordinate system to which the element belongs + + def assert_element_exists(self, element_name: str) -> None: + """ + Assert that an element exists in the transformation manager. + + Parameters + ---------- + element_name + The name of the element to check. + + Raises + ------ + ElementNotFoundError + If the element does not exist. + """ + if element_name not in self._element_to_cs_mapping: + raise ElementNotRegisteredToAnyCoordinateSystemError(element_name) + + def assert_element_does_not_exist(self, element_name: str) -> None: + """ + Assert than an element doesn't exist in the transformation manager. + + Parameters + ---------- + element_name + The name of the element to check. + + Raises + ------ + ElementAlreadyExistsError + If the element already exists. + """ + if element_name in self._element_to_cs_mapping: + raise ElementAlreadyExistsError(element_name) + + def assert_coordinate_system_exists(self, cs: NgffCoordinateSystem) -> None: + """ + Assert that coordinate system exists in the graph. + + Parameters + ---------- + cs + The coordinate system to check. + + Raises + ------ + CoordinateSystemNotFoundError + If the coordinate system does not exist. + """ + if cs not in self._graph: + raise CoordinateSystemNotFoundError(cs.name) + + def assert_coordinate_system_does_not_exist(self, cs: NgffCoordinateSystem) -> None: + """ + Assert that coordinate system doesn't exist in the graph. + + Parameters + ---------- + cs + The coordinate system to check. + + Raises + ------ + CoordinateSystemAlreadyExistsError + If the coordinate system already exists. + """ + if cs in self._graph: + raise CoordinateSystemAlreadyExistsError(cs.name) + + def assert_an_edge_exists_between_coordinate_systems( + self, source_cs: NgffCoordinateSystem, target_cs: NgffCoordinateSystem, edge_key: str | None = None + ) -> None: + """ + Assert that an edge exists between coordinate systems. + + Parameters + ---------- + source_cs + The input coordinate system. + target_cs + The output coordinate system. + edge_key + If specified, asserts that the specific edge exists between coordinate systems + + + Raises + ------ + CoordinateSystemNotFoundError + If either coordinate system does not exist. + TransformationNotFoundError + If the edge does not exist. + """ + self.assert_coordinate_system_exists(source_cs) + self.assert_coordinate_system_exists(target_cs) + if not self._graph.has_edge(source_cs, target_cs): + raise TransformationNotFoundError(source_cs.name, target_cs.name, edge_key) + + def assert_coordinate_system_has_no_transformations(self, cs: NgffCoordinateSystem) -> None: + """ + Assert that coordinate system has no associated transformations. + + Parameters + ---------- + cs + The coordinate system to check. + + Raises + ------ + CoordinateSystemNotFoundError + If either coordinate system does not exist. + CoordinateSystemHasTransformationsError + If the coordinate system has associated transformations. + + """ + self.assert_coordinate_system_exists(cs) + has_successors = next(self._graph.successors(cs), None) is not None + has_predecessors = next(self._graph.predecessors(cs), None) is not None + + if has_successors or has_predecessors: + raise CoordinateSystemHasTransformationsError(cs.name) + + def assert_coordinate_system_has_no_elements(self, cs: NgffCoordinateSystem) -> None: + """ + Assert that a coordinate system doesn't have elements belonging to it. + + Parameters + ---------- + cs + The coordinate system to check + + Raises + ------ + CoordinateSystemHasElementsError + if the coordinate system has elements belonging to it. + """ + elements = self._get_elements_belonging_to_cs(cs) + # also checks if cs exists + if elements: + raise CoordinateSystemHasElementsError(cs.name, elements) + + def add_coordinate_system(self, cs: NgffCoordinateSystem) -> None: + """ + Add a coordinate system to the transformation manager. + + Parameters + ---------- + cs + The coordinate system to add. + + Raises + ------ + CoordinateSystemAlreadyExistsError + If the coordinate system already exists. + """ + self.assert_coordinate_system_does_not_exist(cs) + self._graph.add_node(cs) + + def remove_coordinate_system(self, cs: NgffCoordinateSystem) -> None: + """ + Remove a coordinate system from the transformation manager. + + Parameters + ---------- + cs + The coordinate system to remove. + + Raises + ------ + CoordinateSystemNotFoundError + If the coordinate system is not found + CannotRemoveCoordinateSystemError + If the coordinate system cannot be removed. + Caused by + CoordinateSystemHasTransformationsError or + CoordinateSystemHasElementsError when the system has dependencies. + """ + try: + self.assert_coordinate_system_has_no_transformations(cs) + # also asserts that cs exists + self.assert_coordinate_system_has_no_elements(cs) + except (CoordinateSystemHasTransformationsError, CoordinateSystemHasElementsError) as err: + raise CannotRemoveCoordinateSystemError(cs.name) from err + + self._graph.remove_node(cs) + + def list_coordinate_systems(self) -> list[NgffCoordinateSystem]: + """ + List all registered coordinate systems. + + Returns + ------- + A list of coordinate system objects. + """ + return list(self._graph.nodes()) + + def add_element(self, element_name: str, coordinate_system: NgffCoordinateSystem) -> None: + """ + Register an element and associate it with a coordinate system. + + Parameters + ---------- + element_name + The name of the element. + coordinate_system + The coordinate system to which the element belongs. + + Raises + ------ + CoordinateSystemNotFoundError + If the coordinate system is not found. + """ + self.assert_element_does_not_exist(element_name) + self.assert_coordinate_system_exists(coordinate_system) + self._element_to_cs_mapping[element_name] = coordinate_system + + def get_element_coordinate_system(self, element_name: str) -> NgffCoordinateSystem: + """ + Get the coordinate system to which an element belongs. + + Parameters + ---------- + element_name + The name of the element. + + Returns + ------- + The coordinate system + + Raises + ------ + ElementNotFoundError + If the element does not exist. + """ + self.assert_element_exists(element_name) + return self._element_to_cs_mapping[element_name] + + def _unset_element(self, element_name: str) -> None: + """ + Unregister an element from the coordinate system to which it belongs. + + Parameters + ---------- + element_name + The name of the element. + + Raises + ------ + ElementNotFoundError + If the element has not been registered to any coordinate system. + """ + self.assert_element_exists(element_name) + del self._element_to_cs_mapping[element_name] + + @staticmethod + def _get_edge_key(edge_def: EDGE_DEF) -> str: + """ + Encode the definition of an edge into a string to be used as graph-wide edge identifier. + + Parameters + ---------- + edge_def + tuple, (source_cs, target_cs, transformation) + + Returns + ------- + edge_key + str, the encoded edge identifier + """ + source_cs, target_cs, transform = edge_def + return f"{source_cs.name}_{target_cs.name}_{id(transform)}" + + def add_transformation( + self, + source_cs: NgffCoordinateSystem, + target_cs: NgffCoordinateSystem, + transformation: sd_transforms.BaseTransformation, + ) -> None: + """ + Add a transformation between coordinate systems. + + Parameters + ---------- + source_cs + The input coordinate system. + target_cs + The output coordinate system. + transformation + The transformation to add. + + Raises + ------ + CoordinateSystemNotFoundError + If either coordinate system does not exist. + """ + self.assert_coordinate_system_exists(source_cs) + self.assert_coordinate_system_exists(target_cs) + + edge_key = self._get_edge_key((source_cs, target_cs, transformation)) + edge_attributes = {TRANSFORM_KEY: transformation} + self._graph.add_edge(source_cs, target_cs, key=edge_key, **edge_attributes) + + def get_direct_transformations( + self, source_cs: NgffCoordinateSystem, target_cs: NgffCoordinateSystem + ) -> list[sd_transforms.BaseTransformation]: + """ + Retrieve transformations directly defined between coordinate systems. + + Parameters + ---------- + source_cs + The input coordinate system. + target_cs + The output coordinate system. + + Returns + ------- + List of transformations (empty if there are no direct transformations). + + Raises + ------ + CoordinateSystemNotFoundError + If either coordinate system does not exist. + """ + self.assert_coordinate_system_exists(source_cs) + self.assert_coordinate_system_exists(target_cs) + + transforms = [] + if self._graph.has_edge(source_cs, target_cs): + for edge_data in self._graph[source_cs][target_cs].values(): + transform: sd_transforms.BaseTransformation = edge_data[TRANSFORM_KEY] + transforms.append(transform) + return transforms + + def remove_specific_transformation( + self, + source_cs: NgffCoordinateSystem, + target_cs: NgffCoordinateSystem, + transformation: sd_transforms.BaseTransformation, + ) -> None: + """ + Remove a specific transformation between coordinate systems. + + Parameters + ---------- + source_cs + The input coordinate system. + target_cs + The output coordinate system. + transformation + The transformation to remove. + (mainly useful for cases with multiple transformations between the same coordinate systems). + + Raises + ------ + CoordinateSystemNotFoundError + If either coordinate system does not exist. + TransformationNotFoundError + If the transformation does not exist. + """ + expected_edge_key = self._get_edge_key((source_cs, target_cs, transformation)) + self.assert_an_edge_exists_between_coordinate_systems(source_cs, target_cs, expected_edge_key) + # also checks if source_cs and target_cs exist + self._graph.remove_edge(source_cs, target_cs, key=expected_edge_key) + + def remove_all_transformations_between_coordinate_systems( + self, + source_cs: NgffCoordinateSystem, + target_cs: NgffCoordinateSystem, + ) -> None: + """ + Remove all transformation between coordinate systems. + + Parameters + ---------- + source_cs + The input coordinate system. + target_cs + The output coordinate system. + + Raises + ------ + CoordinateSystemNotFoundError + If either coordinate system does not exist. + TransformationNotFoundError + If no transformation exists between the coordiante systems + """ + self.assert_coordinate_system_exists(source_cs) + self.assert_coordinate_system_exists(target_cs) + if self._graph.has_edge(source_cs, target_cs): + for edge_key in list(self._graph[source_cs][target_cs].keys()): + # need to covert keys() to list to freeze it, else it will change during the following removal + self._graph.remove_edge(source_cs, target_cs, key=edge_key) + + def _get_transformation_sequences_from_simple_paths_after_disambiguation( + self, + paths: list[list[NgffCoordinateSystem]], + expected_intermediate_edges: Sequence[EDGE_DEF], + ) -> list[sd_transforms.Sequence]: + """ + Traverses paths to form sequence of Transformations. + + In case of ambiguity looks into `expected_intermediate_transformations` to disambiguate. + + Parameters + ---------- + paths: + sequence of list of nodes + expected_intermediate_edges: + list of edge definitions, for use when multiple edges are found between two coordinate systems in a path + An edge is defined as a tuple, (source_cs, target_cs, transform). + + Returns + ------- + list of transformations, each an instance of the transformation class Sequence + + Raises + ------ + TransformationPathNotSimple + if any path in `paths` is not simple, i.e., has recurring coordinate systems + """ + for path in paths: + if len(set(path)) != len(path): + raise TransformationPathNotSimple(path) + + expected_intermediate_transformation_edge_keys = set() + if expected_intermediate_edges is not None: + for edge_def in expected_intermediate_edges: + edge_key = self._get_edge_key(edge_def) + expected_intermediate_transformation_edge_keys |= {edge_key} + + all_sequences = [] + deduplicated_paths = list({repr(x): x for x in paths}.values()) + for path in deduplicated_paths: + if len(path) <= 1: + raise InvalidPathError(path) + + transformation_list = [] + for i in range(len(path) - 1): + source_cs_here, target_cs_here = path[i], path[i + 1] + edge_data = self._graph[source_cs_here][target_cs_here] + if len(edge_data) > 1: + # when there are multiple edges between a pair of coordinate systems in the path + # find the expected edge based on key + expected_intermediate_transformation_key_here = ( + expected_intermediate_transformation_edge_keys & set(edge_data.keys()) + ) + if len(expected_intermediate_transformation_key_here) == 0: + # transformation was not specified in `expected_intermediate_transformations` for disambiguation + raise TransformationPathAmbiguousNoEdgeExpectedError(source_cs_here.name, target_cs_here.name) + if len(expected_intermediate_transformation_key_here) > 1: + # multiple transformations were specified in `expected_intermediate_transformations` + # for disambiguation + raise TransformationPathAmbiguousMultipleEdgeExpectedError( + source_cs_here.name, target_cs_here.name, len(expected_intermediate_transformation_key_here) + ) + edge_key_to_use = list(expected_intermediate_transformation_key_here)[0] + transformation_list.append(edge_data[edge_key_to_use][TRANSFORM_KEY]) + else: + # Only one edge, no ambiguity + edge_key = next(iter(edge_data.keys())) + transformation_list.append(edge_data[edge_key][TRANSFORM_KEY]) + all_sequences.append(sd_transforms.Sequence(transformation_list)) + return all_sequences + + def get_all_shortest_transformation_sequences( + self, + source_cs: NgffCoordinateSystem, + target_cs: NgffCoordinateSystem, + expected_intermediate_edges: Sequence[EDGE_DEF] = (), + ) -> list[sd_transforms.Sequence]: + """ + Get all shortest sequences of transformations between two coordinate systems. + + Parameters + ---------- + source_cs + The source coordinate system. + target_cs + The target coordinate system. + expected_intermediate_edges + list of intermediate edges expected. + An edge is defined as a tuple, (source_cs, target_cs, transform). + Used to choose an edge when multiple edges are found between the same coordinate systems. + + Returns + ------- + list[Sequence] + All shortest sequences of transformations from source_cs to target_cs. + When multiple transformations are defined between the same coordinate systems, only those containing + a transformation among intermediate transformations are included. + + Raises + ------ + CoordinateSystemNotFoundError + If either coordinate system does not exist. + TransformationPathNotFoundError + If no path exists between the source and target coordinate systems. + TransformationPathAmbiguousError + When multiple transformations are defined between the same coordinate systems and transformations + are not specified in `expeceted_intermediate_transformations` for disambiguation. + """ + try: + paths = list(nx.all_shortest_paths(self._graph, source=source_cs, target=target_cs)) + except nx.NetworkXNoPath as nxe: + raise TransformationPathNotFoundError(source_cs.name, target_cs.name) from nxe + + try: + return self._get_transformation_sequences_from_simple_paths_after_disambiguation( + paths, expected_intermediate_edges + ) + except TransformationPathAmbiguousError as tpae: + raise TransformationPathAmbiguousError(source_cs.name, target_cs.name) from tpae + + def get_all_transformation_sequences( + self, + source_cs: NgffCoordinateSystem, + target_cs: NgffCoordinateSystem, + expected_intermediate_edges: Sequence[EDGE_DEF] = (), + ) -> list[sd_transforms.Sequence]: + """ + Get all existing sequences of transformations between two coordinate systems. + + Parameters + ---------- + source_cs + The source coordinate system. + target_cs + The target coordinate system. + expected_intermediate_edges + list of intermediate edges expected. + An edge is defined as a tuple, (source_cs, target_cs, transform). + Used to choose an edge when multiple edges are found between the same coordinate systems. + + Returns + ------- + list[Sequence] + All existing sequences of transformations from source_cs to target_cs. + When multiple transformations are defined between the same coordinate systems, only those containing + a transformation among intermediate transformations are included. + + Raises + ------ + CoordinateSystemNotFoundError + If either coordinate system does not exist. + TransformationPathNotFoundError + If no path exists between the source and target coordinate systems. + TransformationPathAmbiguousError + When multiple transformations are defined between the same coordinate systems and transformations + are not specified in `expected_intermediate_transformations` for disambiguation. + """ + try: + paths = list(nx.all_simple_paths(self._graph, source=source_cs, target=target_cs)) + except nx.NetworkXNoPath as nxe: + raise TransformationPathNotFoundError(source_cs.name, target_cs.name) from nxe + + try: + return self._get_transformation_sequences_from_simple_paths_after_disambiguation( + paths, expected_intermediate_edges + ) + except TransformationPathAmbiguousError as tpae: + raise tpae from TransformationPathAmbiguousError(source_cs.name, target_cs.name) + + def _get_elements_belonging_to_cs(self, cs: NgffCoordinateSystem) -> list[str]: + """ + Get all elements belonging to a coordinate system. + + Parameters + ---------- + cs + The coordinate system to check. + + Returns + ------- + List of element names belonging to the coordinate system. + + Raises + ------ + CoordinateSystemNotFoundError + If the coordinate system does not exist. + + """ + self.assert_coordinate_system_exists(cs) + + elements = [] + for element_name, element_cs in self._element_to_cs_mapping.items(): + if element_cs == cs: + elements.append(element_name) + + return elements + + def __repr__(self) -> str: + """Return a string representation of the TransformationManager.""" + return ( + f"TransformationManager(" + f" coordinate_systems={list(self._graph.nodes())}, " + f" coordinate_transforms={[x[TRANSFORM_KEY] for *_, x in self._graph.edges(data=True)]}, " + f" elements={list(self._element_to_cs_mapping.keys())}" + f")" + ) diff --git a/src/spatialdata/_core/transformation_manager/exceptions.py b/src/spatialdata/_core/transformation_manager/exceptions.py new file mode 100644 index 000000000..c8324032a --- /dev/null +++ b/src/spatialdata/_core/transformation_manager/exceptions.py @@ -0,0 +1,229 @@ +from __future__ import annotations + +from spatialdata.transformations.ngff.ngff_coordinate_system import NgffCoordinateSystem + + +class CoordinateSystemNotFoundError(ValueError): + """ + Exception raised when a coordinate system is not found in the transformation manager. + + Attributes + ---------- + name : str + The name of the coordinate system that was not found. + """ + + def __init__(self, name: str) -> None: + self.name = name + super().__init__(f"Coordinate system '{name}' not found in the transformation manager.") + + +class CoordinateSystemAlreadyExistsError(ValueError): + """ + Exception raised when a coordinate system already exists in the transformation manager. + + Attributes + ---------- + name : str + The name of the coordinate system to check. + """ + + def __init__(self, name: str) -> None: + self.name = name + super().__init__(f"Coordinate system '{name}' already exists in the transformation manager.") + + +class ElementNotRegisteredToAnyCoordinateSystemError(KeyError): + """ + Exception raised when an element is not found in the transformation manager. + + Attributes + ---------- + element_name : str + The name of the element that was not found. + """ + + def __init__(self, element_name: str) -> None: + self.element_name = element_name + super().__init__(f"Element '{element_name}' has not been registered to any coordinate system.") + + +class TransformationNotFoundError(KeyError): + """ + Exception raised when a transformation is not found between coordinate systems. + + Attributes + ---------- + input_cs_name : str + The name of the input coordinate system. + output_cs_name : str + The name of the output coordinate system. + edge_key: str or None + key used when adding transformation + """ + + def __init__(self, source_cs_name: str, target_cs_name: str, edge_key: str | None = None) -> None: + self.input_cs_name = source_cs_name + self.output_cs_name = target_cs_name + self.edge_key = edge_key + msg = f"Transformation from '{source_cs_name}' to '{target_cs_name}' not found" + if edge_key is not None: + msg += f" with key '{edge_key}'" + super().__init__(msg) + + +class ElementAlreadyExistsError(ValueError): + """ + Exception raised when trying to add an Element that already exists. + + Attributes + ---------- + name : str + The name of the Element that already exists. + """ + + def __init__(self, name: str) -> None: + self.name = name + super().__init__(f"Element '{name}' already exists in the transformation manager") + + +class InvalidPathError(ValueError): + """ + Exception raised when a path is defined with less than 2 nodes. + + Attributes + ---------- + invalid_path : list[NgffCoordinateSystem] + """ + + def __init__(self, invalid_path: list[NgffCoordinateSystem]) -> None: + super().__init__(f"Found an invalid path with less than 2 nodes: {invalid_path}") + + +class TransformationPathNotFoundError(ValueError): + """ + Exception raised when no transformation path exists between coordinate systems. + + Attributes + ---------- + source_cs_name : str + The name of the source coordinate system. + target_cs_name : str + The name of the target coordinate system. + """ + + def __init__(self, source_cs_name: str, target_cs_name: str) -> None: + self.source_cs_name = source_cs_name + self.target_cs_name = target_cs_name + super().__init__(f"No transformation path found from {source_cs_name} to {target_cs_name}") + + +class TransformationPathAmbiguousError(ValueError): + """ + Exception raised when multiple transformation path exists between coordinate systems. + + Attributes + ---------- + source_cs_name : str + The name of the source coordinate system. + target_cs_name : str + The name of the target coordinate system. + """ + + def __init__(self, source_cs_name: str, target_cs_name: str) -> None: + self.source_cs_name = source_cs_name + self.target_cs_name = target_cs_name + base_msg = f"Transformation Path ambiguous from {source_cs_name} to {target_cs_name}." + cause_of_confusion = self.cause_of_confusion() + msg = f"{base_msg} {cause_of_confusion}" if cause_of_confusion else base_msg + super().__init__(msg) + + def cause_of_confusion(self) -> str: + return "" + + +class TransformationPathAmbiguousNoEdgeExpectedError(TransformationPathAmbiguousError): + def __init__(self, source_cs_name: str, target_cs_name: str) -> None: + super().__init__(source_cs_name, target_cs_name) + + def cause_of_confusion(self) -> str: + + return "Multiple edges found. None of them were specified to be expected" + + +class TransformationPathAmbiguousMultipleEdgeExpectedError(TransformationPathAmbiguousError): + def __init__(self, source_cs_name: str, target_cs_name: str, number_of_edges_expected: int) -> None: + self.number_of_edges_expected = number_of_edges_expected + super().__init__(source_cs_name, target_cs_name) + + def cause_of_confusion(self) -> str: + return f"Multiple ({self.number_of_edges_expected}) edges were specified to be expected" + + +class TransformationPathNotSimple(ValueError): + """ + Exception raised when a path represented by a list of coordinate systems is not simple. + + A simple path is one in which each coordinate system appears only once + """ + + def __init__(self, path: list[NgffCoordinateSystem]) -> None: + self.path = path + css_formatted_one_per_line = "\n".join(repr(cs) for cs in path) + super().__init__( + f"Transformation Path not simple, i.e., some coordinate systems appear multiple times:\n" + f"{css_formatted_one_per_line}" + ) + + +class CannotRemoveCoordinateSystemError(ValueError): + """Exception raised when trying to remove a coordinate system. + + Attributes + ---------- + name: str + name of the coordinate system + """ + + def __init__(self, name: str) -> None: + self.name = name + super().__init__(f"Cannot remove coordinate system with name {name}.") + + +class CoordinateSystemHasTransformationsError(ValueError): + """ + Exception raised when trying to remove a coordinate system that has associated transformations. + + Attributes + ---------- + name : str + The name of the coordinate system that has transformations. + """ + + def __init__(self, name: str) -> None: + self.name = name + super().__init__(f"Coordinate System ('{name}') has transformations.") + + +class CoordinateSystemHasElementsError(ValueError): + """ + Exception raised when trying to remove a coordinate system that has associated elements. + + Attributes + ---------- + name : str + The name of the coordinate system that has elements. + associated_elements : list[str] + List of element names associated with the coordinate system. + """ + + def __init__(self, name: str, associated_elements: list[str]) -> None: + self.name = name + self.associated_elements = associated_elements + super().__init__(f"Coordinate system '{name}' has elements belonging to it: {associated_elements}") + + +class TransformationManagerWarning(UserWarning): + """Base warning category for TransformationManager.""" + + pass diff --git a/src/spatialdata/_io/io_raster.py b/src/spatialdata/_io/io_raster.py index 3806fabf7..d2cb9c70c 100644 --- a/src/spatialdata/_io/io_raster.py +++ b/src/spatialdata/_io/io_raster.py @@ -3,7 +3,7 @@ import warnings from collections.abc import Mapping, Sequence from pathlib import Path -from typing import Any, Literal, TypeGuard +from typing import Any, Literal, TypeGuard, get_args import dask.array as da import numpy as np @@ -30,6 +30,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 @@ -162,10 +163,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) @@ -214,7 +217,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: @@ -229,7 +232,7 @@ def _read_multiscale( data = loaded_node.array(resolution=datasets[0]) si = DataArray( data, - name="image", + name=ELEMENT_TYPE.IMAGE, dims=axes, coords={"c": channels} if channels is not None else {}, ) @@ -265,7 +268,7 @@ def _get_multiscale_nodes(image_nodes: list[Node], nodes: list[Node]) -> list[No def _get_raster_element_group( - raster_type: Literal["image", "labels"], group: zarr.Group, element_name: str + raster_type: ELEMENT_TYPE_RASTER, group: zarr.Group, element_name: str ) -> zarr.Group: """Get the Zarr group holding a raster element that has just been written. @@ -299,7 +302,7 @@ def _get_raster_element_group( def _write_raster( - raster_type: Literal["image", "labels"], + raster_type: ELEMENT_TYPE_RASTER, raster_data: DataArray | DataTree, group: zarr.Group, name: str, @@ -332,12 +335,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 @@ -367,7 +371,7 @@ def _write_raster( raise ValueError("Not a valid labels object") group = _get_raster_element_group(raster_type, group, name) - if raster_type == "image": + 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: @@ -463,7 +467,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, @@ -493,7 +497,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) @@ -532,7 +536,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, @@ -562,7 +566,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 @@ -632,7 +638,7 @@ def write_image( ) _write_raster( - raster_type="image", + raster_type=ELEMENT_TYPE.IMAGE, raster_data=image, group=group, name=name, @@ -659,7 +665,7 @@ def write_labels( ) _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 186d7c974..1301761d5 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 @@ -28,7 +28,14 @@ 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 JSONValue, Raster_T +from spatialdata._types import ( + ELEMENT_TYPE, + ELEMENT_TYPE_RASTER, + ELEMENT_TYPE_VECTOR, + GROUP_NAME, + JSONValue, + Raster_T, +) def _read_zarr_group_spatialdata_element( @@ -37,8 +44,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: @@ -76,14 +83,14 @@ 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, element_type, reader_format, ) - elif element_type in ["shapes", "points", "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}") @@ -204,19 +211,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 e41de9fe7..014587c9e 100644 --- a/src/spatialdata/_types.py +++ b/src/spatialdata/_types.py @@ -1,11 +1,22 @@ 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", "JSONValue", "Raster_T"] +__all__ = [ + "ArrayLike", + "ColorLike", + "DTypeLike", + "JSONValue", + "Raster_T", + "ELEMENT_TYPE", + "ELEMENT_TYPE_RASTER", + "ELEMENT_TYPE_VECTOR", + "GROUP_NAME", +] from numpy.typing import DTypeLike, NDArray @@ -26,3 +37,23 @@ # arrays, sets, DataFrames). Note that JSON has no tuples and only string keys, so a tuple is read back as a list and # a non-string key as a string. type JSONValue = dict[str, JSONValue] | list[JSONValue] | str | int | float | bool | None + + +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] diff --git a/tests/core/transformation_manager/conftest.py b/tests/core/transformation_manager/conftest.py new file mode 100644 index 000000000..696935996 --- /dev/null +++ b/tests/core/transformation_manager/conftest.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 + +""" +Fixtures for transformation manager tests. +""" + +from __future__ import annotations + +import pytest + +from spatialdata.transformations import Affine, Scale, Translation +from spatialdata.transformations.ngff.ngff_coordinate_system import NgffAxis, NgffCoordinateSystem + + +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[Translation]]: + """Fixture providing a single point graph with one coordinate system and one transformation.""" + coordinate_systems = [get_ngff_coodinate_system("cs1")] + transformations = [Translation(translation=[1.0, 2.0], axes=("x", "y"))] + return coordinate_systems, transformations + + +@pytest.fixture +def fully_connected_two_point_graph() -> tuple[list[NgffCoordinateSystem], list[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 = [ + Translation(translation=[-1.0, -2.0], axes=("x", "y")), + ] + return coordinate_systems, transformations + + +@pytest.fixture +def four_point_graph() -> tuple[list[NgffCoordinateSystem], list[Scale | 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 + Scale( + scale=[ + 2, + 2, + ], + axes=("x", "y"), + ), # cs3 -> cs4; + Translation(translation=[4.0, 6.0], axes=("x", "y")), # cs1 -> cs3 (consistent with cs1->cs2 and cs2->cs3) + ] + return coordinate_systems, transformations + + +@pytest.fixture +def five_point_graph() -> tuple[list[NgffCoordinateSystem], list[Scale | Translation | Affine]]: + """Fixture providing a five-point graph with five coordinate systems and five transformations.""" + + coordinate_systems = [ + get_ngff_coodinate_system("cs1"), + get_ngff_coodinate_system("cs2"), + get_ngff_coodinate_system("cs3"), + get_ngff_coodinate_system("cs4"), + get_ngff_coodinate_system("cs5"), + ] + transformations = [ + Translation(translation=[1.0, 2.0], axes=("x", "y")), # cs1 -> cs2, cs4 -> cs3 + Translation(translation=[3.0, 4.0], axes=("x", "y")), # cs2 -> cs3, cs1 -> cs4 + Translation(translation=[4.0, 6.0], axes=("x", "y")), # cs1 -> cs3 (consistent with cs1->cs2 and cs2->cs3) + Scale( + scale=[ + 2, + 2, + ], + axes=("x", "y"), + ), # cs3 -> cs5; + Affine( + matrix=[[2, 0, 0], [0, 2, 0], [0, 0, 1]], + input_axes=("x", "y"), + output_axes=("x", "y"), + ), # cs3 -> cs5 + ] + 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 000000000..c1f5d8300 --- /dev/null +++ b/tests/core/transformation_manager/test_transformation_manager.py @@ -0,0 +1,643 @@ +#!/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._core.transformation_manager import TRANSFORM_KEY, TransformationManager +from spatialdata._core.transformation_manager.exceptions import ( + CannotRemoveCoordinateSystemError, + CoordinateSystemAlreadyExistsError, + CoordinateSystemNotFoundError, + ElementAlreadyExistsError, + ElementNotRegisteredToAnyCoordinateSystemError, + InvalidPathError, + TransformationNotFoundError, + TransformationPathAmbiguousError, + TransformationPathAmbiguousMultipleEdgeExpectedError, + TransformationPathAmbiguousNoEdgeExpectedError, + TransformationPathNotFoundError, + TransformationPathNotSimple, +) +from spatialdata.transformations.transformations import Sequence + + +def test_initialization(): + """Test that TransformationManager initializes correctly.""" + + tm = TransformationManager() + assert len(tm._graph.nodes()) == 0 + assert len(tm._graph.edges()) == 0 + assert tm._element_to_cs_mapping == {} + + +def test_add_coordinate_system(one_point_graph): + """Test adding a coordinate system.""" + + tm = TransformationManager() + [cs1], _ = one_point_graph + + tm.add_coordinate_system(cs1) + assert cs1 in tm._graph.nodes() + + +def test_add_coordinate_system_duplicate(one_point_graph): + """Test adding an already existing coordinate system""" + tm = TransformationManager() + [cs1], _ = one_point_graph + + tm.add_coordinate_system(cs1) + with pytest.raises(CoordinateSystemAlreadyExistsError, match=f"Coordinate system '{cs1.name}' already exists"): + tm.add_coordinate_system(cs1) + + +def test_remove_coordinate_system(one_point_graph): + """Test removing a coordinate system.""" + tm = TransformationManager() + [cs1], _ = one_point_graph + + tm.add_coordinate_system(cs1) + tm.remove_coordinate_system(cs1) + + assert cs1 not in tm._graph.nodes() + + +def test_remove_coordinate_system_nonexistent(one_point_graph): + """Test that removing a non-existent coordinate system raises CoordinateSystemNotFoundError.""" + tm = TransformationManager() + [cs1], _ = one_point_graph + + # Add the coordinate system first + tm.add_coordinate_system(cs1) + + # Remove it + tm.remove_coordinate_system(cs1) + + # Try to remove it again - should raise CoordinateSystemNotFoundError + with pytest.raises(CoordinateSystemNotFoundError): + tm.remove_coordinate_system(cs1) + + +def test_remove_coordinate_system_with_associated_transformations(fully_connected_two_point_graph): + """ + Test that removing a coordinate system with associated transformations raises CannotRemoveCoordinateSystemError. + """ + tm = TransformationManager() + [cs1, cs2], [transformation] = fully_connected_two_point_graph + + tm.add_coordinate_system(cs1) + tm.add_coordinate_system(cs2) + + tm.add_transformation(cs1, cs2, transformation) + + # Should raise CannotRemoveCoordinateSystem when trying to remove a coordinate system with transformations + with pytest.raises(CannotRemoveCoordinateSystemError, match="Cannot remove coordinate system"): + tm.remove_coordinate_system(cs1) + + # Should raise CannotRemoveCoordinateSystem when trying to remove a coordinate system with transformations + with pytest.raises(CannotRemoveCoordinateSystemError, match="Cannot remove coordinate system"): + tm.remove_coordinate_system(cs2) + + +def test_remove_coordinate_system_with_belonging_elements(one_point_graph): + """Test that removing a coordinate system with element associations raises CannotRemoveCoordinateSystemError.""" + tm = TransformationManager() + [cs1], _ = one_point_graph + tm.add_coordinate_system(cs1) + tm.add_element("image1", cs1) + + with pytest.raises(CannotRemoveCoordinateSystemError, match="Cannot remove coordinate system"): + tm.remove_coordinate_system(cs1) + + +def test_list_coordinate_systems(fully_connected_two_point_graph): + """Test listing all coordinate systems.""" + tm = TransformationManager() + [cs1, cs2], _ = fully_connected_two_point_graph + tm.add_coordinate_system(cs1) + tm.add_coordinate_system(cs2) + + systems = tm.list_coordinate_systems() + assert len(systems) == 2 + assert cs1 in systems + assert cs2 in systems + + +def test_add_element(one_point_graph): + """Test adding an element with an existing coordinate system.""" + tm = TransformationManager() + [cs1], _ = one_point_graph + tm.add_coordinate_system(cs1) + tm.add_element("image1", cs1) + + mapping = tm._element_to_cs_mapping + assert "image1" in mapping + assert mapping["image1"] == cs1 + + +def test_add_element_duplicate(one_point_graph): + """Test that adding a duplicate element raises ElementAlreadyExistsError.""" + tm = TransformationManager() + [cs1], _ = one_point_graph + tm.add_coordinate_system(cs1) + element_name = "image1" + tm.add_element(element_name, cs1) + + # Try to add the same element again - should raise ElementAlreadyExistsError + with pytest.raises( + ElementAlreadyExistsError, match=f"Element '{element_name}' already exists in the transformation manager" + ): + tm.add_element(element_name, cs1) + + +def test_unset_element(one_point_graph): + """Test unsetting an element.""" + tm = TransformationManager() + [cs1], _ = one_point_graph + tm.add_coordinate_system(cs1) + tm.add_element("image1", cs1) + tm._unset_element("image1") + + assert "image1" not in tm._element_to_cs_mapping + + +def test_unset_element_nonexistent(one_point_graph): + """Test that unsetting a non-existent element raises ElementNotFoundError.""" + tm = TransformationManager() + [cs1], _ = one_point_graph + tm.add_coordinate_system(cs1) + element_name = "image1" + + # Try to unset non-existent element + with pytest.raises(ElementNotRegisteredToAnyCoordinateSystemError): + tm._unset_element(element_name) + + +def test_add_transformation(fully_connected_two_point_graph): + """Test adding a transformation between coordinate systems.""" + + tm = TransformationManager() + [cs1, cs2], [transform] = fully_connected_two_point_graph + + tm.add_coordinate_system(cs1) + tm.add_coordinate_system(cs2) + + tm.add_transformation(cs1, cs2, transform) + + assert tm._graph.has_edge(cs1, cs2) + # Get the first edge key and check the transformation + edge_key = tm._get_edge_key((cs1, cs2, transform)) + assert tm._graph[cs1][cs2][edge_key][TRANSFORM_KEY] == transform + + +def test_add_transformation_existing_edge(fully_connected_two_point_graph): + """Test adding a transformation between coordinate systems that already have an edge.""" + tm = TransformationManager() + [cs1, cs2], [transform] = fully_connected_two_point_graph + tm.add_coordinate_system(cs1) + tm.add_coordinate_system(cs2) + + tm.add_transformation(cs1, cs2, transform) + assert tm._graph.has_edge(cs1, cs2) + + # Add same transformation again between the same coordinate systems + tm.add_transformation(cs1, cs2, transform) + # Should have just rewritten the edge + assert tm._graph.has_edge(cs1, cs2) + assert len(tm._graph[cs1][cs2]) == 1 + + +def test_add_transformation_nonexistent_cs(fully_connected_two_point_graph): + """Test that adding a transformation with non-existent coordinate systems raises CoordinateSystemNotFoundError.""" + tm = TransformationManager() + [cs1, cs2], [transform] = fully_connected_two_point_graph + + expected_msg = "Coordinate system .* not found in the transformation manager." + with pytest.raises(CoordinateSystemNotFoundError, match=expected_msg): + tm.add_transformation(cs1, cs2, transform) + + # Add one coordinate system + tm.add_coordinate_system(cs1) + + with pytest.raises(CoordinateSystemNotFoundError, match=expected_msg): + tm.add_transformation(cs1, cs2, transform) + + +def test_get_direct_transformations(fully_connected_two_point_graph): + """Test getting an existing transformation.""" + tm = TransformationManager() + [cs1, cs2], [transform] = fully_connected_two_point_graph + tm.add_coordinate_system(cs1) + tm.add_coordinate_system(cs2) + + tm.add_transformation(cs1, cs2, transform) + + retrieved = tm.get_direct_transformations(cs1, cs2) + assert retrieved == [transform] + + +def test_get_direct_transformations_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_direct_transformations(cs1, cs2) + assert retrieved == [] + + +def test_remove_all_transformations_between_coordinate_systems(fully_connected_two_point_graph, mocker): + """Test removing all transformations between coordinate systems.""" + + tm = TransformationManager() + [cs1, cs2], [transform] = fully_connected_two_point_graph + tm.add_coordinate_system(cs1) + tm.add_coordinate_system(cs2) + + tm.add_transformation(cs1, cs2, transform) + + spy = mocker.spy(tm._graph, "remove_edge") + tm.remove_all_transformations_between_coordinate_systems(cs1, cs2) + assert spy.call_count == 1 + assert not tm._graph.has_edge(cs1, cs2) + + +def test_remove_all_transformation_nonexistent(fully_connected_two_point_graph, mocker): + """Test that removing non-existent transformations between coordinate systems raises TransformationNotFoundError.""" + + tm = TransformationManager() + [cs1, cs2], _ = fully_connected_two_point_graph + tm._graph.add_node(cs1) + tm._graph.add_node(cs2) + + spy = mocker.spy(tm._graph, "remove_edge") + tm.remove_all_transformations_between_coordinate_systems(cs1, cs2) + assert spy.call_count == 0 + + +def test_remove_specific_transformation_between_coordinate_systems(five_point_graph): + """Test removing specific transformation between coordinate systems.""" + + tm = TransformationManager() + [_cs1, _cs2, cs3, _cs4, cs5], [_transform1, _transform2, _transform3, transform4, transform5] = five_point_graph + tm.add_coordinate_system(cs3) + tm.add_coordinate_system(cs5) + + tm.add_transformation(cs3, cs5, transform4) + tm.add_transformation(cs3, cs5, transform5) + + tm.remove_specific_transformation(cs3, cs5, transform4) + edge_key = tm._get_edge_key((cs3, cs5, transform4)) + assert not tm._graph.has_edge(cs3, cs5, key=edge_key) + + +def test_remove_specific_transformation_between_coordinate_systems_non_existent(five_point_graph): + """ + Test that removing non-existent specific transformation between coordinate systems raises + TransformationNotFoundError. + """ + + tm = TransformationManager() + [_cs1, _cs2, cs3, _cs4, cs5], [_transform1, _transform2, _transform3, transform4, _transform5] = five_point_graph + tm.add_coordinate_system(cs3) + tm.add_coordinate_system(cs5) + + with pytest.raises( + TransformationNotFoundError, match=f"Transformation from '{cs3.name}' to '{cs5.name}' not found" + ): + tm.remove_specific_transformation(cs3, cs5, transform4) + + +def test_get_all_shortest_transformation_sequences_direct(four_point_graph): + """Test getting all shortest transformation sequences for a direct transformation path.""" + tm = TransformationManager() + [cs1, cs2, cs3, cs4], [transform1, transform2, _transform3, transform4] = four_point_graph + + tm.add_coordinate_system(cs1) + tm.add_coordinate_system(cs2) + tm.add_coordinate_system(cs3) + tm.add_coordinate_system(cs4) + + tm.add_transformation(cs1, cs2, transform1) + tm.add_transformation(cs2, cs3, transform2) + tm.add_transformation(cs1, cs3, transform4) + + sequences = tm.get_all_shortest_transformation_sequences(cs1, cs3) + assert len(sequences) == 1 + assert Sequence([transform4]) in sequences + + +def test_get_all_shortest_transformation_sequences_indirect_one_path(four_point_graph): + """Test getting all shortest transformation sequences for one indirect transformation path.""" + tm = TransformationManager() + [cs1, cs2, cs3, _cs4], [transform1, transform2, _transform3, _transform4] = four_point_graph + tm.add_coordinate_system(cs1) + tm.add_coordinate_system(cs2) + tm.add_coordinate_system(cs3) + + tm.add_transformation(cs1, cs2, transform1) + tm.add_transformation(cs2, cs3, transform2) + + sequences = tm.get_all_shortest_transformation_sequences(cs1, cs3) + assert len(sequences) == 1 + assert Sequence([transform1, transform2]) in sequences + + +def test_get_all_shortest_transformation_sequences_indirect_two_paths(five_point_graph): + """Test getting all shortest transformation sequences for two indirect transformation paths.""" + tm = TransformationManager() + ([cs1, cs2, cs3, cs4, _cs5], [transform1, transform2, _transform3, _transform4, _transform5]) = five_point_graph + tm.add_coordinate_system(cs1) + tm.add_coordinate_system(cs2) + tm.add_coordinate_system(cs3) + tm.add_coordinate_system(cs4) + + tm.add_transformation(cs1, cs2, transform1) + tm.add_transformation(cs2, cs3, transform2) + tm.add_transformation(cs1, cs4, transform2) + tm.add_transformation(cs4, cs3, transform1) + + sequences = tm.get_all_shortest_transformation_sequences(cs1, cs3) + assert len(sequences) == 2 + assert Sequence([transform1, transform2]) in sequences + assert Sequence([transform2, transform1]) in sequences + + +def test_get_all_shortest_transformation_sequences_no_path(four_point_graph): + """Test that getting all shortest transformation sequences with no path raises TransformationPathNotFoundError.""" + tm = TransformationManager() + [cs1, cs2, cs3, cs4], [transform1, transform2, _transform3, _transform4] = four_point_graph + tm.add_coordinate_system(cs1) + tm.add_coordinate_system(cs2) + tm.add_coordinate_system(cs3) + tm.add_coordinate_system(cs4) + + tm.add_transformation(cs1, cs2, transform1) + tm.add_transformation(cs2, cs3, transform2) + + expected_error_msg = f"No transformation path found from {cs1.name} to {cs4.name}" + with pytest.raises(TransformationPathNotFoundError, match=expected_error_msg): + tm.get_all_shortest_transformation_sequences(cs1, cs4) + + +def test_get_all_shortest_transformation_sequences_multiple_paths_multiple_edges_success(five_point_graph): + """Test getting all shortest transformation sequences, with multiple path and multiple edges between nodes.""" + tm = TransformationManager() + [cs1, cs2, cs3, cs4, cs5], [transform1, transform2, transform3, transform4, transform5] = five_point_graph + tm.add_coordinate_system(cs1) + tm.add_coordinate_system(cs2) + tm.add_coordinate_system(cs3) + tm.add_coordinate_system(cs4) + tm.add_coordinate_system(cs5) + + tm.add_transformation(cs1, cs2, transform1) + tm.add_transformation(cs2, cs3, transform2) + tm.add_transformation(cs1, cs4, transform2) + tm.add_transformation(cs4, cs3, transform1) + # tm.add_transformation(cs1, cs3, transform3) + tm.add_transformation(cs3, cs5, transform4) + tm.add_transformation(cs3, cs5, transform5) + + expected_intermediate_edges = [(cs3, cs5, transform4)] + + sequences = tm.get_all_shortest_transformation_sequences( + cs1, cs5, expected_intermediate_edges=expected_intermediate_edges + ) + assert len(sequences) == 2 + assert Sequence([transform1, transform2, transform4]) in sequences + assert Sequence([transform2, transform1, transform4]) in sequences + + +def test_get_all_shortest_transformation_sequences_multiple_paths_multiple_edges_failure(five_point_graph): + """Test getting all shortest transformation sequences, with multiple path and multiple edges between nodes.""" + tm = TransformationManager() + [cs1, cs2, cs3, cs4, cs5], [transform1, transform2, transform3, transform4, transform5] = five_point_graph + tm.add_coordinate_system(cs1) + tm.add_coordinate_system(cs2) + tm.add_coordinate_system(cs3) + tm.add_coordinate_system(cs4) + tm.add_coordinate_system(cs5) + + tm.add_transformation(cs1, cs2, transform1) + tm.add_transformation(cs2, cs3, transform2) + tm.add_transformation(cs1, cs4, transform2) + tm.add_transformation(cs4, cs3, transform1) + tm.add_transformation(cs1, cs3, transform3) + tm.add_transformation(cs3, cs5, transform4) + tm.add_transformation(cs3, cs5, transform5) + + expected_msg = f"Transformation Path ambiguous from {cs1.name} to {cs5.name}." + with pytest.raises(TransformationPathAmbiguousError, match=expected_msg): + tm.get_all_shortest_transformation_sequences(cs1, cs5) + + +def test_get_all_transformation_sequences(four_point_graph): + """Test getting all transformation sequences.""" + tm = TransformationManager() + [cs1, cs2, cs3, cs4], [transform1, transform2, transform3, transform4] = four_point_graph + tm.add_coordinate_system(cs1) + tm.add_coordinate_system(cs2) + tm.add_coordinate_system(cs3) + tm.add_coordinate_system(cs4) + + tm.add_transformation(cs1, cs2, transform1) + tm.add_transformation(cs2, cs3, transform2) + tm.add_transformation(cs3, cs4, transform3) + tm.add_transformation(cs1, cs3, transform4) + + sequences = tm.get_all_transformation_sequences(cs1, cs4) + assert len(sequences) == 2 + assert Sequence([transform1, transform2, transform3]) in sequences + assert Sequence([transform4, transform3]) in sequences + + +def test_get_all_transformation_sequences_multiple_paths(five_point_graph): + """Test getting all transformation sequences, with multiple path and multiple edges between nodes.""" + tm = TransformationManager() + [cs1, cs2, cs3, cs4, _cs5], [transform1, transform2, transform3, _transform4, _transform5] = five_point_graph + tm.add_coordinate_system(cs1) + tm.add_coordinate_system(cs2) + tm.add_coordinate_system(cs3) + tm.add_coordinate_system(cs4) + + tm.add_transformation(cs1, cs2, transform1) + tm.add_transformation(cs2, cs3, transform2) + tm.add_transformation(cs1, cs4, transform2) + tm.add_transformation(cs4, cs3, transform1) + tm.add_transformation(cs1, cs3, transform3) + + sequences = tm.get_all_transformation_sequences(cs1, cs3) + assert len(sequences) == 3 + assert Sequence([transform1, transform2]) in sequences + assert Sequence([transform2, transform1]) in sequences + assert Sequence([transform3]) in sequences + + +def test_get_all_transformation_sequences_multiple_paths_multiple_edges_success(five_point_graph): + """Test getting all transformation sequences, with multiple paths and multiple edges between nodes.""" + tm = TransformationManager() + [cs1, cs2, cs3, cs4, cs5], [transform1, transform2, transform3, transform4, transform5] = five_point_graph + tm.add_coordinate_system(cs1) + tm.add_coordinate_system(cs2) + tm.add_coordinate_system(cs3) + tm.add_coordinate_system(cs4) + tm.add_coordinate_system(cs5) + + tm.add_transformation(cs1, cs2, transform1) + tm.add_transformation(cs2, cs3, transform2) + tm.add_transformation(cs1, cs4, transform2) + tm.add_transformation(cs4, cs3, transform1) + tm.add_transformation(cs1, cs3, transform3) + tm.add_transformation(cs3, cs5, transform4) + tm.add_transformation(cs3, cs5, transform5) + + expected_intermediate_edges = [(cs3, cs5, transform4)] + sequences = tm.get_all_transformation_sequences(cs1, cs5, expected_intermediate_edges=expected_intermediate_edges) + assert len(sequences) == 3 + assert Sequence([transform1, transform2, transform4]) in sequences + assert Sequence([transform2, transform1, transform4]) in sequences + assert Sequence([transform3, transform4]) in sequences + + +def test_get_all_transformation_sequences_multiple_paths_multiple_edges_no_edge_expected(five_point_graph): + """Test getting all transformation sequences with multiple paths and multiple edges, no edge expected.""" + tm = TransformationManager() + [cs1, cs2, cs3, cs4, cs5], [transform1, transform2, transform3, transform4, transform5] = five_point_graph + tm.add_coordinate_system(cs1) + tm.add_coordinate_system(cs2) + tm.add_coordinate_system(cs3) + tm.add_coordinate_system(cs4) + tm.add_coordinate_system(cs5) + + tm.add_transformation(cs1, cs2, transform1) + tm.add_transformation(cs2, cs3, transform2) + tm.add_transformation(cs1, cs4, transform2) + tm.add_transformation(cs4, cs3, transform1) + tm.add_transformation(cs1, cs3, transform3) + tm.add_transformation(cs3, cs5, transform4) + tm.add_transformation(cs3, cs5, transform5) + + expected_msg = f"Transformation Path ambiguous from {cs3.name} to {cs5.name}." + with pytest.raises(TransformationPathAmbiguousNoEdgeExpectedError, match=expected_msg): + tm.get_all_transformation_sequences(cs1, cs5) + + +def test_get_all_transformation_sequences_multiple_paths_multiple_edges_multiple_expected(five_point_graph): + """Test getting all transformation sequences with multiple paths and multiple edges, multiple edges expected.""" + tm = TransformationManager() + [cs1, cs2, cs3, cs4, cs5], [transform1, transform2, transform3, transform4, transform5] = five_point_graph + tm.add_coordinate_system(cs1) + tm.add_coordinate_system(cs2) + tm.add_coordinate_system(cs3) + tm.add_coordinate_system(cs4) + tm.add_coordinate_system(cs5) + + tm.add_transformation(cs1, cs2, transform1) + tm.add_transformation(cs2, cs3, transform2) + tm.add_transformation(cs1, cs4, transform2) + tm.add_transformation(cs4, cs3, transform1) + tm.add_transformation(cs1, cs3, transform3) + tm.add_transformation(cs3, cs5, transform4) + tm.add_transformation(cs3, cs5, transform5) + + expected_intermediate_edges = [ + (cs3, cs5, transform4), + (cs3, cs5, transform5), + ] + + expected_msg = f"Transformation Path ambiguous from {cs3.name} to {cs5.name}." + with pytest.raises(TransformationPathAmbiguousMultipleEdgeExpectedError, match=expected_msg): + tm.get_all_transformation_sequences(cs1, cs5, expected_intermediate_edges=expected_intermediate_edges) + + +def test_get_transformation_sequences_from_simple_paths_after_disambiguation_with_non_simple_paths( + fully_connected_two_point_graph, +): + """Test _get_transformation_sequences_from_simple_paths_after_disambiguation with non-simple paths.""" + tm = TransformationManager() + [cs1, cs2], _ = fully_connected_two_point_graph + + tm.add_coordinate_system(cs1) + + paths = [[cs1, cs2, cs1, cs2]] + + with pytest.raises(TransformationPathNotSimple): + tm._get_transformation_sequences_from_simple_paths_after_disambiguation(paths, ()) + + +def test_get_transformation_sequences_from_simple_paths_after_disambiguation_with_single_node_path( + fully_connected_two_point_graph, +): + """Test _get_transformation_sequences_from_simple_paths_after_disambiguation with a path with only one node.""" + tm = TransformationManager() + [cs1, cs2], [transformation] = fully_connected_two_point_graph + + tm.add_coordinate_system(cs1) + + paths = [[cs1]] + with pytest.raises(InvalidPathError): + tm._get_transformation_sequences_from_simple_paths_after_disambiguation(paths, ()) + + +def test_get_all_transformation_sequences_no_path(four_point_graph): + """Test getting all transformation sequences.""" + tm = TransformationManager() + [cs1, cs2, cs3, cs4], [transform1, transform2, transform3, transform4] = four_point_graph + tm.add_coordinate_system(cs1) + tm.add_coordinate_system(cs2) + tm.add_coordinate_system(cs3) + tm.add_coordinate_system(cs4) + + expected_error_msg = f"No transformation path found from {cs1.name} to {cs4.name}" + with pytest.raises(TransformationPathNotFoundError, match=expected_error_msg): + tm.get_all_shortest_transformation_sequences(cs1, cs4) + + +def test_get_element_coordinate_system_success(one_point_graph): + """Test successfully getting an element's coordinate system.""" + tm = TransformationManager() + [cs1], _ = one_point_graph + tm.add_coordinate_system(cs1) + tm.add_element("image1", cs1) + + # This should successfully return the coordinate system + result = tm.get_element_coordinate_system("image1") + assert result == cs1 + + +def test_repr_method_comprehensive(fully_connected_two_point_graph): + """Test TransformationManager.__repr__ method comprehensively.""" + + # Test empty TransformationManager + tm_empty = TransformationManager() + repr_empty = repr(tm_empty) + assert "TransformationManager" in repr_empty + assert "coordinate_systems=[]" in repr_empty + assert "coordinate_transforms=[]" in repr_empty + assert "elements=[]" in repr_empty + + # Test TransformationManager with data + tm = TransformationManager() + [cs1, cs2], [transform] = fully_connected_two_point_graph + tm.add_coordinate_system(cs1) + tm.add_coordinate_system(cs2) + + tm.add_transformation(cs1, cs2, transform) + tm.add_element("image1", cs1) + tm.add_element("image2", cs2) + + repr_str = repr(tm) + assert "TransformationManager" in repr_str + assert "cs1" in repr_str + assert "cs2" in repr_str + assert repr_str.find(repr(transform)) + assert "image1" in repr_str + assert "image2" in repr_str From 89b8667b4fce2f28210e7921f47a88bbbc3a3159 Mon Sep 17 00:00:00 2001 From: Ajayrama Kumaraswamy Date: Mon, 10 Aug 2026 10:04:18 +0200 Subject: [PATCH 02/54] Transformation manager foundation (#1164) * chore: added pandas-stubs as dev dependency; helps with type checking * chore: added local hatch.toml to gitignore * chore: Add .kilo and plans to .gitignore * feat: added literals and types for element types and group names * feat: added .coverage and htmlcov to gitignore * feat: transformation manager at the root of spatialdata object * fix: io_zarr.py mixup between ELEMENT_TYPE_VECTOR/RASTER * fix: avoid exposing TransformationManager in spatialdata __init__.py * fix: element associated to cs -> element belonging to cs * refac: transformation_manager now uses nx.MultiDiGraph * refac: tests to match new transformation manager implementation * fix: restored unintentionally removed functions + others fixes * feat: custom error messages for transformation graph elements * feat: custom exceptions for transform manager * refac: transform manager attributes private; added missing method * feat: more custom error and warnings + fixes * feat: more test coverage + fixes * feat: support for transformation management in graph with mutliple edges * fix: fixes in tests + renaming for readability * fix: typing Affine Transform * feat: TransformationManager return Sequence instead of list * feat: made TransformationManager.graph type specific over node type * fix: removed internal attribute access warnings * refac: rename check_if... methods to assert_... + one additional method * fix: node type spec for TransformationManager._graph * fix: made edge key definition from transforms more robust * refac: method rename * refac: TransformationManager get/remove methods don't raise error if edges are missing * feat: new test for TransformationManager.add_transformation * fix: better error messages and edge case handling for when transformation path is ambiguous * feat: TransformationManager edge (key) definition made stronger * fix: simplified access to attributes of TransformationManager * fix: TransformationManager.unset_element is now private * fix: throw error a path has one node * fix: simplified checking if coordinate system has associated transforms * fix: improved Transformation Manager code quality with better typing * fix: improved Transformation Manager exception naming and messaging * fix: improved Transformation Manager documentation * feat: added tests for Transformation Manager * fix: used explicit syntax for type definitoin * refac: typo * fix: removed TransformationManager import from spatialdata __init__.py * fix: TransformationManager, simplified code using custom error --- .gitignore | 11 +++++++++++ src/spatialdata/_io/io_zarr.py | 2 +- src/spatialdata/_types.py | 9 +++++---- src/spatialdata/_utils.py | 7 ++++++- 4 files changed, 23 insertions(+), 6 deletions(-) diff --git a/.gitignore b/.gitignore index 1c56e190e..f44cfbecf 100644 --- a/.gitignore +++ b/.gitignore @@ -69,3 +69,14 @@ plans/ .coverage htmlcov/ +# local hatch config +hatch.toml + +# Kilo and plans +.kilo/ +plans/ + +# test coverage +.coverage +htmlcov/ + diff --git a/src/spatialdata/_io/io_zarr.py b/src/spatialdata/_io/io_zarr.py index 1301761d5..cee3aba7d 100644 --- a/src/spatialdata/_io/io_zarr.py +++ b/src/spatialdata/_io/io_zarr.py @@ -302,7 +302,7 @@ def _get_groups_for_element( The Zarr groups for the root, element_type and element for a specific element. """ if not isinstance(zarr_path, Path): - raise ValueError("zarr_path should be a Path object") + raise TypeError("zarr_path should be a Path object") if element_type not in [ "images", diff --git a/src/spatialdata/_types.py b/src/spatialdata/_types.py index 014587c9e..46c3f19c5 100644 --- a/src/spatialdata/_types.py +++ b/src/spatialdata/_types.py @@ -7,15 +7,15 @@ from xarray import DataArray, DataTree __all__ = [ + "ELEMENT_TYPE", + "ELEMENT_TYPE_RASTER", + "ELEMENT_TYPE_VECTOR", + "GROUP_NAME", "ArrayLike", "ColorLike", "DTypeLike", "JSONValue", "Raster_T", - "ELEMENT_TYPE", - "ELEMENT_TYPE_RASTER", - "ELEMENT_TYPE_VECTOR", - "GROUP_NAME", ] from numpy.typing import DTypeLike, NDArray @@ -32,6 +32,7 @@ type Raster_T = DataArray | DataTree ColorLike = tuple[float, ...] | str + # A value that survives a round-trip through JSON, which is the invariant that `SpatialData.attrs` must satisfy: the # attrs are persisted with `zarr.Group.attrs.put()`, which rejects anything that is not JSON-serializable (e.g. numpy # arrays, sets, DataFrames). Note that JSON has no tuples and only string keys, so a tuple is read back as a list and diff --git a/src/spatialdata/_utils.py b/src/spatialdata/_utils.py index a0086b7b2..f5ec67865 100644 --- a/src/spatialdata/_utils.py +++ b/src/spatialdata/_utils.py @@ -17,7 +17,12 @@ from xarray import DataArray, Dataset, DataTree from spatialdata._types import ArrayLike, ListOrNDArrayFloating -from spatialdata.transformations import Sequence, Translation, get_transformation, set_transformation +from spatialdata.transformations import ( + Sequence, + Translation, + get_transformation, + set_transformation, +) RT = TypeVar("RT") From db849b7b937e32cb5fca768a9423f77b368d827e Mon Sep 17 00:00:00 2001 From: Tomaz Vieira Date: Fri, 21 Aug 2026 11:25:40 +0000 Subject: [PATCH 03/54] Creates the graph module based on the Ngff* classes Cleans up and modernizes the NGff classes so that they have stronger invariants and guarantee that they always have input and output. Reading and writing to zarr is done via ome-zarr-models-py. Uses graph module as part of io_raster.py::try_read_ngff06_multiscale to interpret Ngff trnasformations and produce an output that could be added to the new graph implementation. --- pyproject.toml | 2 +- src/spatialdata/_io/io_raster.py | 83 ++ src/spatialdata/transformations/__init__.py | 2 + .../transformations/graph/__init__.py | 1 + src/spatialdata/transformations/graph/edge.py | 948 ++++++++++++++++++ src/spatialdata/transformations/graph/vert.py | 201 ++++ 6 files changed, 1236 insertions(+), 1 deletion(-) create mode 100644 src/spatialdata/transformations/graph/__init__.py create mode 100644 src/spatialdata/transformations/graph/edge.py create mode 100644 src/spatialdata/transformations/graph/vert.py diff --git a/pyproject.toml b/pyproject.toml index 4cc02bcb4..400d884b1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,7 @@ dependencies = [ "networkx", "numba>=0.55", "numpy", - "ome-zarr>=0.16", + "ome-zarr>=0.18.0", "pandas", "pooch", "pyarrow", diff --git a/src/spatialdata/_io/io_raster.py b/src/spatialdata/_io/io_raster.py index d2cb9c70c..29f40d6d8 100644 --- a/src/spatialdata/_io/io_raster.py +++ b/src/spatialdata/_io/io_raster.py @@ -7,6 +7,9 @@ import dask.array as da import numpy as np +import ome_zarr as oz +import ome_zarr_models.v06.coordinate_transforms as ozm06trans +import xarray as xr import zarr from ome_zarr.format import Format from ome_zarr.io import ZarrLocation @@ -18,6 +21,7 @@ from ome_zarr.writer import write_multiscale as write_multiscale_ngff from ome_zarr.writer import write_multiscale_labels as write_multiscale_labels_ngff from xarray import DataArray, DataTree +from xarray.indexes import RangeIndex from spatialdata._io._utils import ( _get_transformations_from_ngff_dict, @@ -40,6 +44,8 @@ _set_transformations, compute_coordinates, ) +from spatialdata.transformations.graph.edge import BaseTransfEdge, parse_ngff_transf +from spatialdata.transformations.graph.vert import Axis, CoordSystem def _is_flat_int_sequence(value: object) -> TypeGuard[Sequence[int]]: @@ -162,6 +168,83 @@ def _prepare_storage_options( return prepared_options +def try_read_ngff06_multiscale(store: Path) -> tuple[DataTree, Sequence[BaseTransfEdge]]: + multiscale = oz.OMEZarrMultiscale.from_ome_zarr(str(store)) + assert isinstance(multiscale, oz.OMEZarrMultiscale) # disambiguate from OMEZarrLabel + + name_to_cs: dict[str, CoordSystem] = {} + for cs in multiscale.metadata.coordinateSystems or (): + parsed_cs = CoordSystem.try_from_model(cs) + name_to_cs[cs.name] = parsed_cs + + parsed_transfs: list[BaseTransfEdge] = [] + for transf in multiscale.metadata.coordinateTransformations or (): + in_cs_id = transf.input + out_cs_ref = transf.output + # these should not be None as per the spec + assert in_cs_id is not None + assert out_cs_ref is not None + + in_cs_name = in_cs_id.name + out_cs_name = out_cs_ref.name + # FIXME: not handling references into labels yet + assert in_cs_name is not None + assert out_cs_name is not None + + # assume CS references are valid via ome-zarr(-models)-py + input = name_to_cs[in_cs_name] + output = name_to_cs[out_cs_name] + parsed = parse_ngff_transf(input=input, output=output, model=transf) + parsed_transfs.append(parsed) + + omero = multiscale.omero + channel_names = None if omero is None else [d.color for d in omero.channels] + + data_tree = xr.DataTree() + for scale_idx, (ds_md, ds) in enumerate(zip(multiscale.metadata.datasets, multiscale.images, strict=True)): + transf = ds_md.coordinateTransformations[0] + + out_cs = name_to_cs[multiscale.metadata.intrinsic_coordinate_system.name] + assert transf.input is not None + assert transf.input.path is not None + in_cs = CoordSystem(name=str(transf.input.name), axes=[Axis(name=ax.name, type=ax.type) for ax in out_cs.axes]) + + ozm_seq = ozm06trans.Sequence(transformations=ds_md.coordinateTransformations) + seq = parse_ngff_transf(input=in_cs, output=out_cs, model=ozm_seq) + ds_shape = np.asarray(ds.data.shape) + transformed_start = seq.transform_points(np.zeros_like(ds.data.shape)[np.newaxis, :])[0] + transformed_stop = seq.transform_points((ds_shape - 1)[np.newaxis, :])[0] + + coords = xr.Coordinates() + for low, high, ax, extent in zip(transformed_start, transformed_stop, out_cs.axes, ds_shape, strict=True): + if ax.type == "channel" and channel_names is not None: + coords.merge({ax.name: channel_names}) + continue + coords = coords.merge( + xr.Coordinates.from_xindex( + RangeIndex.linspace( + start=low, + stop=high, + num=extent, + endpoint=True, + dim=ax.name, + ) + ) + ) + + data_tree[f"scale{scale_idx}"] = xr.Dataset( + { + "image": xr.DataArray( + ds.data, + name="image", + dims=out_cs.axes_names, + coords=coords, + ) + } + ) + return data_tree, parsed_transfs + + def _read_multiscale( store: str | Path, raster_type: ELEMENT_TYPE_RASTER, reader_format: Format ) -> DataArray | DataTree: diff --git a/src/spatialdata/transformations/__init__.py b/src/spatialdata/transformations/__init__.py index e95a92766..802cc47cd 100644 --- a/src/spatialdata/transformations/__init__.py +++ b/src/spatialdata/transformations/__init__.py @@ -1,5 +1,6 @@ from __future__ import annotations +from spatialdata.transformations import graph from spatialdata.transformations.operations import ( align_elements_using_landmarks, get_transformation, @@ -20,6 +21,7 @@ ) __all__ = [ + "graph", "BaseTransformation", "Identity", "MapAxis", diff --git a/src/spatialdata/transformations/graph/__init__.py b/src/spatialdata/transformations/graph/__init__.py new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/src/spatialdata/transformations/graph/__init__.py @@ -0,0 +1 @@ + diff --git a/src/spatialdata/transformations/graph/edge.py b/src/spatialdata/transformations/graph/edge.py new file mode 100644 index 000000000..b01a11e97 --- /dev/null +++ b/src/spatialdata/transformations/graph/edge.py @@ -0,0 +1,948 @@ +# pyright: strict + +from __future__ import annotations + +from abc import ABC, abstractmethod +from collections.abc import Sequence +from typing import Final + +import numpy as np +import ome_zarr_models.v06.coordinate_transforms as ozm06trans +import pydantic as pyd +import xarray as xr + +from spatialdata._types import ArrayLike +from spatialdata.transformations.graph.vert import Axis, CoordSystem + + +class GarbledInput(Exception): + def __init__(self, message: str, input: pyd.JsonValue) -> None: + import json + + super().__init__(message + "\n" + json.dumps(input, indent=4)) + self.input = input + + +class BaseTransfEdge(ABC): + """Base class for all the transformations defined by the NGFF specification.""" + + input: Final[CoordSystem] + output: Final[CoordSystem] + name: str | None + + def __init__( + self, + name: str | None, + *, + input: CoordSystem, + output: CoordSystem, + ) -> None: + self.input = input + self.output = output + self.name = name + super().__init__() + + def __repr__(self) -> str: + domain = ", ".join(self.input.axes_names) + codomain = ", ".join(self.output.axes_names) + return f"{type(self).__name__} ({domain} -> {codomain})" + + @abstractmethod + def inverse(self) -> BaseTransfEdge: + """Return the inverse of the transformation.""" + + @abstractmethod + def transform_points(self, points: ArrayLike) -> ArrayLike: + """ + Transform points (coordinates). + + Notes + ------- + This function will check if the dimensionality of the input and output coordinate systems of the + transformation are compatible with the given points. + """ + + @abstractmethod + def to_affine(self) -> AffineEdge: + """Convert the transformation to an affine transformation, whenever the conversion can be made.""" + + def _validate_transform_points_shapes(self, points: xr.DataArray | xr.DataTree | ArrayLike) -> None: + """ + Validate if the shape of the points (coordinats to be transformed) are consistent with the input size of the + transformation. + """ + input_size = len(self.input.axes) + if len(points.shape) != 2 or points.shape[1] != input_size: + raise ValueError( + f"points must be a tensor of shape (n, d), where n is the number of points and d is the " + f"the number of spatial dimensions. Points shape: {points.shape}, input size: {input_size}" + ) + + # order of the composition: self is applied first, then the transformation passed as argument + def compose_with(self, transformation: BaseTransfEdge) -> BaseTransfEdge: + """ + Compose the transfomation object with another transformation + + Parameters + ---------- + transformation + The transformation to compose with. + + Returns + ------- + The compoesed transformation. + + Notes + ------- + Self is applied first, then the transformation passed as argument. + """ + return SequenceEdge([self, transformation], name=None) # FIXME: no name? + + @abstractmethod + def to_model(self) -> ozm06trans.AnyTransform: + pass + + +class AffineEdge(BaseTransfEdge): + """The Affine transformation from the NGFF specification.""" + + linear: Final[ArrayLike] + translation: Final[ArrayLike] + affine: Final[ArrayLike] + + def __init__( + self, + name: str | None, + *, + linear: ArrayLike, + translation: ArrayLike | None = None, + input: CoordSystem, + output: CoordSystem, + ) -> None: + """ + Parameters + ---------- + name + A human readable name for this transformation + linear + The linear part of this transformation, i.e., the one that keeps + the origin in the same place. Shape must be (output.num_axes, input.num_axes) + translation y + The translation part of this transformation, of shape (output.num_axes,) + input + Input coordinate system of the transformation. + output + Output coordinate system of the transformation. + """ + num_inputs = input.num_axes + num_outputs = output.num_axes + translation = np.zeros(num_outputs) if translation is None else translation + + expected_linear_shape = (num_outputs, num_inputs) + if linear.shape != expected_linear_shape: + raise ValueError(f"linear's shape is {linear.shape}. Expected f{(num_outputs, num_inputs)}") + expected_translation_shape = (num_outputs,) + if translation.shape != expected_translation_shape: + raise ValueError(f"translation's shape is {translation.shape}. Expected {expected_translation_shape}") + + self.linear = linear + self.translation = translation + + self.affine = np.zeros((num_outputs + 1, num_inputs + 1)) + self.affine[:-1, :-1] = self.linear + self.affine[:-1, -1] = self.translation + self.affine[-1, -1] = 1 + + super().__init__(input=input, output=output, name=name) + + def __repr__(self) -> str: + s = super().__repr__() + "\n" + s += "\n".join(str(row) for row in self.affine) + return s + + @classmethod + def from_affine_matrix( + cls, + *, + name: str | None, + affine_matrix: ArrayLike, + input: CoordSystem, + output: CoordSystem, + ) -> AffineEdge: + """Creates an AffineEdge from a raw affine matrix + + Parameters + ---------- + name + A human readable name for this transformation + affine_matrix + row-major, (output.num_axes + 1, input.num_axes + 1) matrix with: + - linear part at the top left + - translation as the rightmost column + - last row is [0, 0, ..., 0, 1] + input + Input coordinate system of the transformation. + output + Output coordinate system of the transformation. + """ + return AffineEdge( + linear=affine_matrix[:-1, :-1], + translation=affine_matrix[-1, :-1], + input=input, + output=output, + name=name, + ) + + def inverse(self) -> BaseTransfEdge: + inv = np.linalg.inv(self.affine) + return AffineEdge( + linear=inv[:-1, :-1], + translation=inv[-1, :-1], + input=self.output, + output=self.input, + name=self.name and f"{self.name}__affine", + ) + + def transform_points(self, points: ArrayLike) -> ArrayLike: + self._validate_transform_points_shapes(points) + p = np.vstack([points.T, np.ones(points.shape[0])]) + q = self.affine @ p + res = q[: self.output.num_axes, :].T + assert isinstance(res, np.ndarray) + return res + + def to_affine(self) -> AffineEdge: + return self + + def to_model(self) -> ozm06trans.Affine: + return ozm06trans.Affine( + name=self.name, + affine=tuple(tuple(row) for row in self.affine[:-1, :]), + input=self.input.to_model_cs_ident(), + output=self.output.to_model_cs_ident(), + ) + + +class IdentityEdge(BaseTransfEdge): + """The Identity transformation from the NGFF specification.""" + + def __init__( + self, + name: str | None, + *, + input: CoordSystem, + output: CoordSystem, + ) -> None: + """ + Parameters + ---------- + name + A human readable name for this transformation + input + Input coordinate system of the transformation. + output + Output coordinate system of the transformation. + """ + if input.num_axes != output.num_axes: + raise ValueError("Input and output must have the same number of dimensions") + super().__init__(input=input, output=output, name=name) + + def inverse(self) -> BaseTransfEdge: + return IdentityEdge(input=self.output, output=self.input, name=self.name and f"{self.name}__inverse") + + def transform_points(self, points: ArrayLike) -> ArrayLike: + self._validate_transform_points_shapes(points) + return points + + def to_affine(self) -> AffineEdge: + return AffineEdge( + linear=np.eye(self.input.num_axes), + input=self.input, + output=self.output, + name=self.name and f"{self.name}__affine", + ) + + def to_model(self) -> ozm06trans.Identity: + return ozm06trans.Identity( + name=self.name, + input=self.input.to_model_cs_ident(), + output=self.output.to_model_cs_ident(), + ) + + +class MapAxisEdge(BaseTransfEdge): + """The MapAxis transformation from the NGFF specification.""" + + def __init__( + self, + name: str | None, + *, + output_to_input: dict[str, str], + input: CoordSystem, + output: CoordSystem, + ) -> None: + """ + Init the NgffMapAxis object. + Parameters + ---------- + name + A human readable name for this transformation + output_to_input + A dictionary mapping the output axes (keys) to the input axes (values). + input + Input coordinate system of the transformation. + output + Output coordinate system of the transformation. + """ + for out_ax, inp_ax in output_to_input.items(): + if not input.has_axis(inp_ax): + raise ValueError(f"input has no axis named {inp_ax}") + if not output.has_axis(out_ax): + raise ValueError(f"output has no axis named {out_ax}") + if not (len(output_to_input) == output.num_axes == input.num_axes): + raise ValueError("input_to_output, input and output must have the same number of axes entries") + if len(set(output_to_input.values())) != len(output_to_input): + raise ValueError("input_to_output must map unique inputs to unique outputs") + + self.output_to_input = output_to_input + super().__init__(input=input, output=output, name=name) + + def __repr__(self) -> str: + s = super().__repr__() + "\n" + s += "\n".join(f" {out} <- {inp}\n" for out, inp in self.output_to_input.items()) + return s + + def inverse(self) -> BaseTransfEdge: + return MapAxisEdge( + output_to_input={v: k for k, v in self.output_to_input.items()}, + input=self.output, + output=self.input, + name=self.name and f"{self.name}__inverse", + ) + + def transform_points(self, points: ArrayLike) -> ArrayLike: + input_axes = self.input.axes_names + output_axes = self.output.axes_names + self._validate_transform_points_shapes(points) + new_indices = [input_axes.index(self.output_to_input[ax]) for ax in output_axes] + mapped = points[:, new_indices] + assert isinstance(mapped, np.ndarray) + return mapped + + def to_affine(self) -> AffineEdge: + input_axes = self.input.axes_names + output_axes = self.output.axes_names + linear: ArrayLike = np.zeros((len(output_axes), len(input_axes)), dtype=float) + for i, des_axis in enumerate(output_axes): + for j, src_axis in enumerate(input_axes): + if src_axis == self.output_to_input[des_axis]: + linear[i, j] = 1 + affine = AffineEdge( + linear=linear, input=self.input, output=self.output, name=self.name and f"{self.name}__affine" + ) + return affine + + def to_model(self) -> ozm06trans.MapAxis: + mapAxis: list[int] = [] + for out_ax in self.output.axes_names: + in_ax = self.output_to_input[out_ax] + in_idx = self.input.axes_names.index(in_ax) + mapAxis.append(in_idx) + return ozm06trans.MapAxis( + name=self.name, + mapAxis=tuple(mapAxis), + input=self.input.to_model_cs_ident(), + output=self.output.to_model_cs_ident(), + ) + + +class TranslationEdge(BaseTransfEdge): + """The Translation transformation from the NGFF specification.""" + + def __init__( + self, + name: str | None, + *, + translation: ArrayLike, + input: CoordSystem, + output: CoordSystem, + ) -> None: + """ + Init the NgffTranslation object. + Parameters + ---------- + name + A human readable name for this transformation + translation + A vector of shape (input.num_axes,) specifying the translation along each axis. + input + Input coordinate system of the transformation. + output + Output coordinate system of the transformation. + """ + if input.num_axes != output.num_axes: + raise ValueError("Number of input and output axes must be the same") + self.translation = translation + super().__init__(input=input, output=output, name=name) + + def __repr__(self) -> str: + return super().__repr__() + str(self.translation) + + def inverse(self) -> BaseTransfEdge: + return TranslationEdge( + translation=-self.translation, + input=self.output, + output=self.input, + name=self.name and f"{self.name}__inverse", + ) + + def transform_points(self, points: ArrayLike) -> ArrayLike: + self._validate_transform_points_shapes(points) + return points + self.translation + + def to_affine(self) -> AffineEdge: + return AffineEdge( + linear=np.identity(self.input.num_axes), + translation=self.translation, + input=self.input, + output=self.output, + name=self.name and f"{self.name}__affine", + ) + + def to_model(self) -> ozm06trans.Translation: + return ozm06trans.Translation( + name=self.name, + input=self.input.to_model_cs_ident(), + output=self.output.to_model_cs_ident(), + translation=tuple(self.translation), + ) + + +class ScaleEdge(BaseTransfEdge): + """The Scale transformation from the NGFF specification.""" + + def __init__( + self, + name: str | None, + *, + scale: ArrayLike, + input: CoordSystem, + output: CoordSystem, + ) -> None: + """ + Init the NgffScale object. + Parameters + ---------- + scale + A list of numbers or a vector specifying the scale along each axis. + input + Input coordinate system of the transformation. + output + Output coordinate system of the transformation. + """ + if scale.shape != (input.num_axes,): + raise ValueError(f"scale should be of shape f{(input.num_axes,)}") + if input.num_axes != output.num_axes: + raise ValueError("input and output must have same number of dimensions") + self.scale = scale + super().__init__(input=input, output=output, name=name) + + def __repr__(self) -> str: + return super().__repr__() + str(self.scale) + + def inverse(self) -> ScaleEdge: + if any(s == 0 for s in self.scale): + raise ValueError(f"Scaling {self} is not invertible") + new_scale = 1 / self.scale + return ScaleEdge( + scale=new_scale, input=self.output, output=self.input, name=self.name and f"{self.name}__inverse" + ) + + def transform_points(self, points: ArrayLike) -> ArrayLike: + self._validate_transform_points_shapes(points) + return points * self.scale + + def to_affine(self) -> AffineEdge: + return AffineEdge( + linear=np.diag(self.scale), input=self.input, output=self.output, name=self.name and f"{self.name}__affine" + ) + + def to_model(self) -> ozm06trans.Scale: + return ozm06trans.Scale( + name=self.name, + input=self.input.to_model_cs_ident(), + output=self.output.to_model_cs_ident(), + scale=tuple(self.scale), + ) + + +class RotationEdge(BaseTransfEdge): + """The Rotation transformation from the NGFF specification.""" + + rotation: Final[ArrayLike] + + def __init__( + self, + name: str | None, + *, + linear_matrix: ArrayLike, + input: CoordSystem, + output: CoordSystem, + ) -> None: + """ + Init the NgffRotation object. + Parameters + ---------- + linear_matrix + an array of shape (output.num_axes, input.num_axes) representing the rotation + input + Input coordinate system of the transformation. + output + Output coordinate system of the transformation. + """ + expected_shape = (output.num_axes, input.num_axes) + if linear_matrix.shape != expected_shape: + raise ValueError(f"linear matrix should have shape {expected_shape}") + if input.num_axes != output.num_axes: + raise ValueError("input and output should have the same numbe rof axes") + if not np.isclose(np.linalg.det(linear_matrix), 1.0): + raise ValueError("det(linear_matrix) should be ~= 1") + linear_matrix.flags.writeable = False + self.rotation = linear_matrix + super().__init__(input=input, output=output, name=name) + + def __repr__(self) -> str: + s = super().__repr__() + "\n" + s += "\n".join(str(row) for row in self.rotation) + return s + + def inverse(self) -> BaseTransfEdge: + return RotationEdge( + linear_matrix=self.rotation.T, + input=self.output, + output=self.input, + name=self.name and f"{self.name}__inverse", + ) + + def transform_points(self, points: ArrayLike) -> ArrayLike: + self._validate_transform_points_shapes(points) + res = (self.rotation @ points.T).T + assert isinstance(res, np.ndarray) + return res + + def to_affine(self) -> AffineEdge: + return AffineEdge( + linear=self.rotation, input=self.input, output=self.output, name=self.name and f"{self.name}__affine" + ) + + def to_model(self) -> ozm06trans.Rotation: + return ozm06trans.Rotation( + name=self.name, + input=self.input.to_model_cs_ident(), + output=self.output.to_model_cs_ident(), + rotation=tuple(tuple(row) for row in self.rotation), + ) + + +class SequenceEdge(BaseTransfEdge): + """The Sequence transformation from the NGFF specification.""" + + def __init__( + self, + transformations: Sequence[BaseTransfEdge], + name: str | None, + ) -> None: + """ + Init the NgffSequence object. + + Parameters + ---------- + transformations + The transformations which compose the sequence. + """ + if len(transformations) == 0: + raise ValueError("Empty transformation list") + previous_transf = transformations[0] + for current_transf in transformations[1:]: + if previous_transf.output != current_transf.input: + raise ValueError(f"Mismatched input/output from {previous_transf} to {current_transf}") + previous_transf = current_transf + self.transformations = transformations + super().__init__( + input=transformations[0].input, + output=transformations[-1].output, + name=name, + ) + + def __repr__(self) -> str: + from textwrap import indent + + out = super().__repr__() + " [\n" + for t in self.transformations: + out += indent(repr(t), prefix=" ") + "\n" + out += "]" + return out + + def inverse(self) -> SequenceEdge: + return SequenceEdge( + [t.inverse() for t in reversed(self.transformations)], name=self.name and f"{self.name}__inverse" + ) + + def to_affine(self) -> AffineEdge: + composed = self.transformations[0].to_affine().affine + for t in self.transformations[1:]: + a = t.to_affine() + composed = a.affine @ composed + return AffineEdge.from_affine_matrix( + affine_matrix=composed, input=self.input, output=self.output, name=self.name and f"{self.name}__affine" + ) + + def transform_points(self, points: ArrayLike) -> ArrayLike: + return self.to_affine().transform_points(points) # FIXME + + def to_model(self) -> ozm06trans.Sequence: + return ozm06trans.Sequence( + name=self.name, + input=self.input.to_model_cs_ident(), + output=self.output.to_model_cs_ident(), + transformations=tuple(t.to_model() for t in self.transformations), + ) + + +class ByDimensionEdge(BaseTransfEdge): + """The ByDimension transformation from the NGFF specification.""" + + transformations: Final[Sequence[BaseTransfEdge]] + + def __init__( + self, + name: str | None, + *, + transformations: Sequence[BaseTransfEdge], + input: CoordSystem, + output: CoordSystem, + ) -> None: + """ + Init the ByDimension object. + + Parameters + ---------- + transformations + A list of transformations, whose set of output coordinate systems partition the output coordinate system of + the ByDimension transformation. + input + The input coordinate system of the transformation. + output + The output coordinate system of the transformation. + """ + # we check that: + # 1. each input from each transformation in self.transformation must appear in the set of input axes + # 2. each output from each transformation in self.transformation must appear at most once in the set of output + # axes + input_axes = input.axes_names + output_axes = output.axes_names + defined_output_axes: set[str] = set() + for t in transformations: + for ax in t.input.axes_names: + if ax not in input_axes: + raise ValueError(f"By dimension axis {ax} not in {input_axes}") + for ax in t.output.axes_names: + if ax not in output_axes: + raise ValueError(f"Axis {ax} not in output axes {output_axes}") + if ax in defined_output_axes: + raise ValueError(f"Output axis {ax} is defined more than once") + defined_output_axes.add(ax) + if len(output_axes) != len(defined_output_axes): + raise ValueError("Not all outputs are mapped") + + self.transformations = tuple(transformations) + super().__init__(input=input, output=output, name=name) + + def __repr__(self) -> str: + from textwrap import indent + + out = super().__repr__() + " [\n" + for t in self.transformations: + out += indent(repr(t), prefix=" ") + "\n" + out += "]" + return out + + def inverse(self) -> BaseTransfEdge: + inverse_transformations = [t.inverse() for t in self.transformations] + return ByDimensionEdge( + transformations=inverse_transformations, + input=self.output, + output=self.input, + name=self.name and f"{self.name}__inverse", + ) + + def transform_points(self, points: ArrayLike) -> ArrayLike: + input_axes = self.input.axes_names + output_axes = self.output.axes_names + self._validate_transform_points_shapes(points) + output_columns: dict[str, ArrayLike] = {} + for t in self.transformations: + input_columns = [points[:, input_axes.index(ax)] for ax in t.input.axes_names] + input_columns_stacked: ArrayLike = np.stack(input_columns, axis=1) + output_columns_t = t.transform_points(input_columns_stacked) + for ax, col in zip(t.output.axes_names, output_columns_t.T, strict=True): + output_columns[ax] = col # type: ignore[assignment] + output: ArrayLike = np.stack([output_columns[ax] for ax in output_axes], axis=1) + return output + + def to_affine(self) -> AffineEdge: + input_axes = self.input.axes_names + output_axes = self.output.axes_names + m = np.zeros((len(output_axes) + 1, len(input_axes) + 1)) + m[-1, -1] = 1 + for t in self.transformations: + t_affine = t.to_affine() + target_output_indices = [output_axes.index(ax) for ax in t.output.axes_names if ax in output_axes] + source_output_indices = [t.output.axes_names.index(ax) for ax in t.output.axes_names] + target_input_indices = [input_axes.index(ax) for ax in t.input.axes_names] + [-1] + m[np.ix_(target_output_indices, target_input_indices)] = t_affine.affine[source_output_indices, :] + return AffineEdge.from_affine_matrix( + affine_matrix=m, input=self.input, output=self.output, name=self.name and f"{self.name}__affine" + ) + + def to_model(self) -> ozm06trans.ByDimension: + by_dim_transfs: list[ozm06trans.ByDimensionTransform] = [] + for t in self.transformations: + input_axes = tuple(self.input.axes_names.index(ax_name) for ax_name in t.input.axes_names) + output_axes = tuple(self.output.axes_names.index(ax_name) for ax_name in t.output.axes_names) + by_dim_transfs.append( + ozm06trans.ByDimensionTransform( + input_axes=input_axes, + output_axes=output_axes, + transformation=t.to_model(), + ) + ) + return ozm06trans.ByDimension( + name=self.name, + input=self.input.to_model_cs_ident(), + output=self.output.to_model_cs_ident(), + transformations=tuple(by_dim_transfs), + ) + + +class CsGen: + """A coordinate system generator + + Use it to create coordinate systems on the fly while avoiding + repeating names + """ + + def __init__(self, base_name: str): + self._base_name = base_name + self._cs_count: int = 0 + super().__init__() + + def generate(self, *, num_axes: int) -> CoordSystem: + out = CoordSystem( + name=f"{self._base_name}{self._cs_count}", + axes=[ + Axis( + name=f"axis_{ax_idx}", + type="space", # FIXME + ) + for ax_idx in range(num_axes) + ], + virtual=True, + ) + self._cs_count += 1 + return out + + def generate_like(self, other: CoordSystem) -> CoordSystem: + out = CoordSystem( + name=f"{self._base_name}{self._cs_count}", + axes=[ + Axis( + name=axis.name, + type=axis.type, + unit=axis.unit, + long_name=axis.long_name, + ) + for axis in other.axes + ], + virtual=True, + ) + self._cs_count += 1 + return out + + +def parse_identity( + model: ozm06trans.Identity, + *, + input: CoordSystem, + out: CoordSystem | CsGen, +) -> IdentityEdge: + output = out.generate_like(input) if isinstance(out, CsGen) else out + return IdentityEdge(name=model.name, input=input, output=output) + + +def parse_translation( + model: ozm06trans.Translation, + *, + input: CoordSystem, + out: CoordSystem | CsGen, +) -> TranslationEdge: + output = out.generate_like(input) if isinstance(out, CsGen) else out + return TranslationEdge( + translation=np.asarray(model.translation, dtype=float), + input=input, + output=output, + name=input.name, + ) + + +def parse_scale( + model: ozm06trans.Scale, + *, + input: CoordSystem, + out: CoordSystem | CsGen, +) -> ScaleEdge: + output = out.generate_like(input) if isinstance(out, CsGen) else out + return ScaleEdge( + scale=np.asarray(model.scale, dtype=float), + input=input, + output=output, + name=model.name, + ) + + +def parse_map_axis( + model: ozm06trans.MapAxis, + *, + input: CoordSystem, + out: CoordSystem | CsGen, +) -> MapAxisEdge: + output = out.generate(num_axes=len(model.mapAxis)) if isinstance(out, CsGen) else out + return MapAxisEdge( + input=input, + output=output, + name=model.name, + output_to_input={ # FIXME: double check this. Feels like we depend a lot on order + output.axes[output_axis].name: input.axes[input_axis].name + for output_axis, input_axis in enumerate(model.mapAxis) + }, + ) + + +def parse_affine( + model: ozm06trans.Affine, + *, + input: CoordSystem, + output: CoordSystem | CsGen, +) -> AffineEdge: + num_output_axes = len(model.affine_matrix) # spec doesn't save last row + output = output.generate(num_axes=num_output_axes) if isinstance(output, CsGen) else output + affine_array = np.asarray(model.affine_matrix, dtype=float) + return AffineEdge( + name=model.name, linear=affine_array[:, :-1], translation=affine_array[:, -1], input=input, output=output + ) + + +def parse_rotation( + model: ozm06trans.Rotation, + *, + input: CoordSystem, + out: CoordSystem | CsGen, +) -> RotationEdge: + num_output_axes = len(model.rotation_matrix) + output = out.generate(num_axes=num_output_axes) if isinstance(out, CsGen) else out + return RotationEdge( + name=model.name, + linear_matrix=np.asarray(model.rotation_matrix, dtype=float), + input=input, + output=output, + ) + + +def parse_sequence( + model: ozm06trans.Sequence, + *, + input: CoordSystem, + output: CoordSystem | CsGen, +) -> SequenceEdge: + parsed_inners: list[BaseTransfEdge] = [] + + base_name = "intermediate" + ("" if not model.name else f"_for_{model.name}") + cs_gen: CsGen = output if isinstance(output, CsGen) else CsGen(base_name=base_name) + parsed = parse_ngff_transf( + input=input, + model=model.transformations[0], + output=cs_gen if len(model.transformations) > 1 else output, + ) + parsed_inners.append(parsed) + + for t in model.transformations[1:-1]: + parsed = parse_ngff_transf(input=parsed.output, output=cs_gen, model=t) + parsed_inners.append(parsed) + + if len(model.transformations) > 1: + parsed = parse_ngff_transf(input=parsed.output, output=output, model=model.transformations[-1]) + parsed_inners.append(parsed) + + return SequenceEdge(name=model.name, transformations=parsed_inners) + + +def parse_by_dimension( + model: ozm06trans.ByDimension, + *, + input: CoordSystem, + output: CoordSystem | CsGen, +) -> ByDimensionEdge: + if not isinstance(output, CoordSystem): + max_out_idx = max(ax_idx for t in model.transformations for ax_idx in t.output_axes) + output = output.generate(num_axes=max_out_idx + 1) + + piecewise_transforms: list[BaseTransfEdge] = [] + for t in model.transformations: + inp_axes = [input.axes[i] for i in t.input_axes] + partial_input = CoordSystem( + axes=inp_axes, + name=f"{input.name}_{','.join(ax.name for ax in inp_axes)}", + virtual=True, + ) + + out_axes = [output.axes[i] for i in t.output_axes] + partial_out = CoordSystem( + axes=out_axes, + name=f"{output.name}_{','.join(ax.name for ax in inp_axes)}", + virtual=True, + ) + + parsed_t = parse_ngff_transf(model=t.transformation, input=partial_input, output=partial_out) + piecewise_transforms.append(parsed_t) + + return ByDimensionEdge( + input=input, + output=output, + name=model.name, + transformations=piecewise_transforms, + ) + + +def parse_ngff_transf( + input: CoordSystem, + model: ozm06trans.AnyTransform, + output: CoordSystem | CsGen, +) -> BaseTransfEdge: + if isinstance(model, ozm06trans.Identity): + return parse_identity(model, input=input, out=output) + elif isinstance(model, ozm06trans.Translation): + return parse_translation(model, input=input, out=output) + elif isinstance(model, ozm06trans.Scale): + return parse_scale(model, input=input, out=output) + elif isinstance(model, ozm06trans.MapAxis): + return parse_map_axis(model, input=input, out=output) + elif isinstance(model, ozm06trans.Affine): + return parse_affine(model, input=input, output=output) + elif isinstance(model, ozm06trans.Rotation): + return parse_rotation(model, input=input, out=output) + elif isinstance(model, ozm06trans.Sequence): + return parse_sequence(model, input=input, output=output) + elif isinstance(model, ozm06trans.ByDimension): + return parse_by_dimension(model, input=input, output=output) + else: + raise NotImplementedError(f"Unsupported transformation: {model.type}") diff --git a/src/spatialdata/transformations/graph/vert.py b/src/spatialdata/transformations/graph/vert.py new file mode 100644 index 000000000..8819d04e9 --- /dev/null +++ b/src/spatialdata/transformations/graph/vert.py @@ -0,0 +1,201 @@ +from __future__ import annotations + +from collections.abc import Sequence +from typing import Final, Literal + +import ome_zarr.classes.image as ozi +import pydantic as pyd + + +class AxisParsingException(Exception): + pass + + +class Axis: + """ + Representation of an axis, following the NGFF specification. + + Attributes + ---------- + name + name of the axis. + type + type of the axis. Should be in ["channel", "space"]. + unit + unit of the axis. For a set of valid options see https://ngff.openmicroscopy.org/ + long_name: + a longer, human-friendly name for this axis + """ + + name: Final[str] + type: Final[Literal["space", "channel"]] + unit: Final[str | None] + long_name: Final[str | None] + + class LegacyModel(pyd.BaseModel): + name: Literal["x", "y", "z", "c"] + type: Literal["space", "channel"] + + def __init__( + self, *, name: str, type: Literal["space", "channel"], unit: str | None = None, long_name: str | None = None + ): + self.name = name + self.type = type + self.unit = unit + self.long_name = long_name + + def cloned_with(self, *, unit: str | None) -> Axis: + return Axis(name=self.name, type=self.type, unit=unit or self.unit, long_name=self.long_name) + + def __hash__(self) -> int: + return hash((self.name, self.type, self.unit, self.long_name)) + + def __repr__(self) -> str: + return f"NgffAxis(name={self.name}, type={self.type})" + + def __eq__(self, value: object, /) -> bool: + if not isinstance(value, Axis): + return False + return ( + self.name == value.name + and self.type == value.type + and self.unit == value.unit + and self.long_name == value.long_name + ) + + @classmethod + def try_from_model(cls, model: ozi.Axis) -> Axis: + name = model.name + if name is None: + raise AxisParsingException("Axis doesn't have a name") + if model.type != "channel" and model.type != "space": + raise AxisParsingException(f"Can't handle axis of type {model.type}") + if not isinstance(model.unit, str): + raise AxisParsingException("Can't handle axis unit") + return Axis( + name=name, + type=model.type, + unit=model.unit, + long_name=model.longName, + ) + + @classmethod + def try_from_dict(cls, d: pyd.JsonValue) -> Axis: + model = ozi.Axis.model_validate(d) + return Axis.try_from_model(model) + + def to_model(self) -> ozi.Axis: + return ozi.Axis( + discrete=False, + longName=self.long_name, + name=self.name, + type=self.type, + unit=self.unit, + ) + + +class CoordSystemParsingException(Exception): + pass + + +class CoordSystem: + """ + Representation of a coordinate system, following the NGFF specification. + + Parameters + ---------- + name + name of the coordinate system + axes + names of the axes of the coordinate system + """ + + name: Final[str] + axes: Final[tuple[Axis, ...]] + + virtual: Final[bool] + """A virtual coordinate system exists as an intermediate step between + non-virtual coordinate systems and is usually ignored during serialization""" + + class LegacyAxes: + pass + + def __init__(self, name: str, axes: Sequence[Axis], virtual: bool = False): + self.name = name + self.axes = tuple(axes) + self.virtual = virtual + if len(self.axes) != len({axis.name for axis in self.axes}): + raise ValueError("Axes names must be unique") + + def __repr__(self) -> str: + return f"{self.__class__.__name__}({self.name!r}, {self.axes})" + + def __hash__(self) -> int: + return hash((self.name, self.axes, self.virtual)) + + @classmethod + def try_from_model(cls, model: ozi.CoordinateSystem) -> CoordSystem: + axes: list[Axis] = [] + for axis in model.axes: + if isinstance(parsed := Axis.try_from_model(axis), Exception): + raise CoordSystemParsingException(parsed) # FIXME + axes.append(parsed) + return CoordSystem( + name=model.name, + axes=axes, + ) + + @classmethod + def try_from_model_or_default[T](cls, model: ozi.CoordinateSystem | None, *, default: T) -> CoordSystem | T: + if model is not None: + return CoordSystem.try_from_model(model) + return default + + def to_model(self) -> ozi.CoordinateSystem | None: + if self.virtual: + return None + return ozi.CoordinateSystem( + name=self.name, + axes=tuple(ax.to_model() for ax in self.axes), + ) + + def to_model_cs_ident(self) -> ozi.CoordinateSystemIdentifier | None: + input = self.to_model() + if input is None: + return None + return ozi.CoordinateSystemIdentifier(name=input.name) + + @property + def num_axes(self) -> int: + return len(self.axes) + + @property + def axes_names(self) -> tuple[str, ...]: + """Get axes' names""" + return tuple([ax.name for ax in self.axes]) + + @property + def axes_types(self) -> tuple[str, ...]: + """Get axes' types""" + return tuple([ax.type for ax in self.axes]) + + def has_axis(self, name: str) -> bool: + """ + Check the coordinate system has an axis of the given name. + + Parameters + ---------- + name + name of the axis. + """ + return any(axis.name == name for axis in self.axes) + + def get_axis(self, name: str) -> Axis | None: + """Get the axis by name""" + for axis in self.axes: + if axis.name == name: + return axis + return None + + def get_spatial_axes(self) -> Sequence[Axis]: + return [axis for axis in self.axes if axis.type == "space"] From dccc3f6ea0bd5306b66f946bd1d77ed14e0a1832 Mon Sep 17 00:00:00 2001 From: Tomaz Vieira Date: Wed, 2 Sep 2026 16:11:38 +0000 Subject: [PATCH 04/54] addresses some PR comments, simplifies MapAxis --- src/spatialdata/transformations/graph/edge.py | 162 ++++++++++-------- src/spatialdata/transformations/graph/vert.py | 19 +- 2 files changed, 94 insertions(+), 87 deletions(-) diff --git a/src/spatialdata/transformations/graph/edge.py b/src/spatialdata/transformations/graph/edge.py index b01a11e97..42396175f 100644 --- a/src/spatialdata/transformations/graph/edge.py +++ b/src/spatialdata/transformations/graph/edge.py @@ -9,7 +9,6 @@ import numpy as np import ome_zarr_models.v06.coordinate_transforms as ozm06trans import pydantic as pyd -import xarray as xr from spatialdata._types import ArrayLike from spatialdata.transformations.graph.vert import Axis, CoordSystem @@ -48,7 +47,7 @@ def __repr__(self) -> str: return f"{type(self).__name__} ({domain} -> {codomain})" @abstractmethod - def inverse(self) -> BaseTransfEdge: + def inverse(self, name: str | None = None) -> BaseTransfEdge: """Return the inverse of the transformation.""" @abstractmethod @@ -63,23 +62,23 @@ def transform_points(self, points: ArrayLike) -> ArrayLike: """ @abstractmethod - def to_affine(self) -> AffineEdge: + def to_affine(self, name: str | None = None) -> AffineEdge: """Convert the transformation to an affine transformation, whenever the conversion can be made.""" - def _validate_transform_points_shapes(self, points: xr.DataArray | xr.DataTree | ArrayLike) -> None: + def _validate_transform_points_shapes(self, points: ArrayLike) -> None: """ - Validate if the shape of the points (coordinats to be transformed) are consistent with the input size of the + Validate if the shape of the points (coordinates to be transformed) are consistent with the input size of the transformation. """ input_size = len(self.input.axes) if len(points.shape) != 2 or points.shape[1] != input_size: raise ValueError( f"points must be a tensor of shape (n, d), where n is the number of points and d is the " - f"the number of spatial dimensions. Points shape: {points.shape}, input size: {input_size}" + f"the number of dimensions. Points shape: {points.shape}, input size: {input_size}" ) # order of the composition: self is applied first, then the transformation passed as argument - def compose_with(self, transformation: BaseTransfEdge) -> BaseTransfEdge: + def compose_with(self, transformation: BaseTransfEdge, name: str | None) -> BaseTransfEdge: """ Compose the transfomation object with another transformation @@ -96,7 +95,7 @@ def compose_with(self, transformation: BaseTransfEdge) -> BaseTransfEdge: ------- Self is applied first, then the transformation passed as argument. """ - return SequenceEdge([self, transformation], name=None) # FIXME: no name? + return SequenceEdge([self, transformation], name=name) @abstractmethod def to_model(self) -> ozm06trans.AnyTransform: @@ -112,8 +111,8 @@ class AffineEdge(BaseTransfEdge): def __init__( self, - name: str | None, *, + name: str | None = None, linear: ArrayLike, translation: ArrayLike | None = None, input: CoordSystem, @@ -169,7 +168,7 @@ def from_affine_matrix( input: CoordSystem, output: CoordSystem, ) -> AffineEdge: - """Creates an AffineEdge from a raw affine matrix + """Creates an AffineEdge from a raw affine matrix in homogenous coordinates Parameters ---------- @@ -193,14 +192,14 @@ def from_affine_matrix( name=name, ) - def inverse(self) -> BaseTransfEdge: + def inverse(self, name: str | None = None) -> BaseTransfEdge: inv = np.linalg.inv(self.affine) return AffineEdge( linear=inv[:-1, :-1], translation=inv[-1, :-1], input=self.output, output=self.input, - name=self.name and f"{self.name}__affine", + name=name, ) def transform_points(self, points: ArrayLike) -> ArrayLike: @@ -211,8 +210,10 @@ def transform_points(self, points: ArrayLike) -> ArrayLike: assert isinstance(res, np.ndarray) return res - def to_affine(self) -> AffineEdge: - return self + def to_affine(self, name: str | None = None) -> AffineEdge: + return AffineEdge( + input=self.input, output=self.output, linear=self.linear, translation=self.translation, name=name + ) def to_model(self) -> ozm06trans.Affine: return ozm06trans.Affine( @@ -247,19 +248,19 @@ def __init__( raise ValueError("Input and output must have the same number of dimensions") super().__init__(input=input, output=output, name=name) - def inverse(self) -> BaseTransfEdge: - return IdentityEdge(input=self.output, output=self.input, name=self.name and f"{self.name}__inverse") + def inverse(self, name: str | None = None) -> BaseTransfEdge: + return IdentityEdge(input=self.output, output=self.input, name=name) def transform_points(self, points: ArrayLike) -> ArrayLike: self._validate_transform_points_shapes(points) return points - def to_affine(self) -> AffineEdge: + def to_affine(self, name: str | None = None) -> AffineEdge: return AffineEdge( linear=np.eye(self.input.num_axes), input=self.input, output=self.output, - name=self.name and f"{self.name}__affine", + name=name, ) def to_model(self) -> ozm06trans.Identity: @@ -270,6 +271,13 @@ def to_model(self) -> ozm06trans.Identity: ) +class UnmappableCoordSystemsError(Exception): + def __init__(self, input: CoordSystem, output: CoordSystem) -> None: + self.input = input + self.output = output + super().__init__("Output axes can't be mapped to input axes") + + class MapAxisEdge(BaseTransfEdge): """The MapAxis transformation from the NGFF specification.""" @@ -277,7 +285,6 @@ def __init__( self, name: str | None, *, - output_to_input: dict[str, str], input: CoordSystem, output: CoordSystem, ) -> None: @@ -287,67 +294,56 @@ def __init__( ---------- name A human readable name for this transformation - output_to_input - A dictionary mapping the output axes (keys) to the input axes (values). input Input coordinate system of the transformation. output - Output coordinate system of the transformation. + Output coordinate system of the transformation, whose axes + must be a shuffling of `input` """ - for out_ax, inp_ax in output_to_input.items(): - if not input.has_axis(inp_ax): - raise ValueError(f"input has no axis named {inp_ax}") - if not output.has_axis(out_ax): - raise ValueError(f"output has no axis named {out_ax}") - if not (len(output_to_input) == output.num_axes == input.num_axes): - raise ValueError("input_to_output, input and output must have the same number of axes entries") - if len(set(output_to_input.values())) != len(output_to_input): - raise ValueError("input_to_output must map unique inputs to unique outputs") - - self.output_to_input = output_to_input + + if set(input.axes) != set(output.axes): + raise UnmappableCoordSystemsError(input=input, output=output) super().__init__(input=input, output=output, name=name) def __repr__(self) -> str: s = super().__repr__() + "\n" - s += "\n".join(f" {out} <- {inp}\n" for out, inp in self.output_to_input.items()) + s += "\n".join( + f" {out.name} <- {inp.name}\n" for out, inp in zip(self.output.axes, self.input.axes, strict=True) + ) return s - def inverse(self) -> BaseTransfEdge: + def inverse(self, name: str | None = None) -> BaseTransfEdge: return MapAxisEdge( - output_to_input={v: k for k, v in self.output_to_input.items()}, input=self.output, output=self.input, - name=self.name and f"{self.name}__inverse", + name=name, ) def transform_points(self, points: ArrayLike) -> ArrayLike: - input_axes = self.input.axes_names - output_axes = self.output.axes_names self._validate_transform_points_shapes(points) - new_indices = [input_axes.index(self.output_to_input[ax]) for ax in output_axes] + new_indices = [self.input.axes.index(out_ax.name) for out_ax in self.output.axes] mapped = points[:, new_indices] assert isinstance(mapped, np.ndarray) return mapped - def to_affine(self) -> AffineEdge: + def to_affine(self, name: str | None = None) -> AffineEdge: input_axes = self.input.axes_names output_axes = self.output.axes_names linear: ArrayLike = np.zeros((len(output_axes), len(input_axes)), dtype=float) for i, des_axis in enumerate(output_axes): for j, src_axis in enumerate(input_axes): - if src_axis == self.output_to_input[des_axis]: + if src_axis == des_axis: linear[i, j] = 1 affine = AffineEdge( - linear=linear, input=self.input, output=self.output, name=self.name and f"{self.name}__affine" + linear=linear, + input=self.input, + output=self.output, + name=name, ) return affine def to_model(self) -> ozm06trans.MapAxis: - mapAxis: list[int] = [] - for out_ax in self.output.axes_names: - in_ax = self.output_to_input[out_ax] - in_idx = self.input.axes_names.index(in_ax) - mapAxis.append(in_idx) + mapAxis: list[int] = [self.input.axes.index(out_ax) for out_ax in self.output.axes] return ozm06trans.MapAxis( name=self.name, mapAxis=tuple(mapAxis), @@ -388,25 +384,25 @@ def __init__( def __repr__(self) -> str: return super().__repr__() + str(self.translation) - def inverse(self) -> BaseTransfEdge: + def inverse(self, name: str | None = None) -> BaseTransfEdge: return TranslationEdge( translation=-self.translation, input=self.output, output=self.input, - name=self.name and f"{self.name}__inverse", + name=name, ) def transform_points(self, points: ArrayLike) -> ArrayLike: self._validate_transform_points_shapes(points) return points + self.translation - def to_affine(self) -> AffineEdge: + def to_affine(self, name: str | None = None) -> AffineEdge: return AffineEdge( linear=np.identity(self.input.num_axes), translation=self.translation, input=self.input, output=self.output, - name=self.name and f"{self.name}__affine", + name=name, ) def to_model(self) -> ozm06trans.Translation: @@ -450,21 +446,27 @@ def __init__( def __repr__(self) -> str: return super().__repr__() + str(self.scale) - def inverse(self) -> ScaleEdge: + def inverse(self, name: str | None = None) -> ScaleEdge: if any(s == 0 for s in self.scale): raise ValueError(f"Scaling {self} is not invertible") new_scale = 1 / self.scale return ScaleEdge( - scale=new_scale, input=self.output, output=self.input, name=self.name and f"{self.name}__inverse" + scale=new_scale, + input=self.output, + output=self.input, + name=name, ) def transform_points(self, points: ArrayLike) -> ArrayLike: self._validate_transform_points_shapes(points) return points * self.scale - def to_affine(self) -> AffineEdge: + def to_affine(self, name: str | None = None) -> AffineEdge: return AffineEdge( - linear=np.diag(self.scale), input=self.input, output=self.output, name=self.name and f"{self.name}__affine" + linear=np.diag(self.scale), + input=self.input, + output=self.output, + name=name, ) def to_model(self) -> ozm06trans.Scale: @@ -516,12 +518,12 @@ def __repr__(self) -> str: s += "\n".join(str(row) for row in self.rotation) return s - def inverse(self) -> BaseTransfEdge: + def inverse(self, name: str | None = None) -> BaseTransfEdge: return RotationEdge( linear_matrix=self.rotation.T, input=self.output, output=self.input, - name=self.name and f"{self.name}__inverse", + name=name, ) def transform_points(self, points: ArrayLike) -> ArrayLike: @@ -530,9 +532,12 @@ def transform_points(self, points: ArrayLike) -> ArrayLike: assert isinstance(res, np.ndarray) return res - def to_affine(self) -> AffineEdge: + def to_affine(self, name: str | None = None) -> AffineEdge: return AffineEdge( - linear=self.rotation, input=self.input, output=self.output, name=self.name and f"{self.name}__affine" + linear=self.rotation, + input=self.input, + output=self.output, + name=name, ) def to_model(self) -> ozm06trans.Rotation: @@ -583,18 +588,22 @@ def __repr__(self) -> str: out += "]" return out - def inverse(self) -> SequenceEdge: + def inverse(self, name: str | None = None) -> SequenceEdge: return SequenceEdge( - [t.inverse() for t in reversed(self.transformations)], name=self.name and f"{self.name}__inverse" + [t.inverse() for t in reversed(self.transformations)], + name=name, ) - def to_affine(self) -> AffineEdge: + def to_affine(self, name: str | None = None) -> AffineEdge: composed = self.transformations[0].to_affine().affine for t in self.transformations[1:]: a = t.to_affine() composed = a.affine @ composed return AffineEdge.from_affine_matrix( - affine_matrix=composed, input=self.input, output=self.output, name=self.name and f"{self.name}__affine" + affine_matrix=composed, + input=self.input, + output=self.output, + name=name, ) def transform_points(self, points: ArrayLike) -> ArrayLike: @@ -667,13 +676,13 @@ def __repr__(self) -> str: out += "]" return out - def inverse(self) -> BaseTransfEdge: + def inverse(self, name: str | None = None) -> BaseTransfEdge: inverse_transformations = [t.inverse() for t in self.transformations] return ByDimensionEdge( transformations=inverse_transformations, input=self.output, output=self.input, - name=self.name and f"{self.name}__inverse", + name=name, ) def transform_points(self, points: ArrayLike) -> ArrayLike: @@ -690,7 +699,7 @@ def transform_points(self, points: ArrayLike) -> ArrayLike: output: ArrayLike = np.stack([output_columns[ax] for ax in output_axes], axis=1) return output - def to_affine(self) -> AffineEdge: + def to_affine(self, name: str | None = None) -> AffineEdge: input_axes = self.input.axes_names output_axes = self.output.axes_names m = np.zeros((len(output_axes) + 1, len(input_axes) + 1)) @@ -702,7 +711,10 @@ def to_affine(self) -> AffineEdge: target_input_indices = [input_axes.index(ax) for ax in t.input.axes_names] + [-1] m[np.ix_(target_output_indices, target_input_indices)] = t_affine.affine[source_output_indices, :] return AffineEdge.from_affine_matrix( - affine_matrix=m, input=self.input, output=self.output, name=self.name and f"{self.name}__affine" + affine_matrix=m, + input=self.input, + output=self.output, + name=name, ) def to_model(self) -> ozm06trans.ByDimension: @@ -816,15 +828,19 @@ def parse_map_axis( input: CoordSystem, out: CoordSystem | CsGen, ) -> MapAxisEdge: - output = out.generate(num_axes=len(model.mapAxis)) if isinstance(out, CsGen) else out + if isinstance(out, CoordSystem): + output = out + else: + dummy_cs = out.generate(num_axes=len(model.mapAxis)) + output = CoordSystem( + name=dummy_cs.name, + axes=[input.axes[i] for i in model.mapAxis], + virtual=True, + ) return MapAxisEdge( input=input, output=output, name=model.name, - output_to_input={ # FIXME: double check this. Feels like we depend a lot on order - output.axes[output_axis].name: input.axes[input_axis].name - for output_axis, input_axis in enumerate(model.mapAxis) - }, ) diff --git a/src/spatialdata/transformations/graph/vert.py b/src/spatialdata/transformations/graph/vert.py index 8819d04e9..128c50706 100644 --- a/src/spatialdata/transformations/graph/vert.py +++ b/src/spatialdata/transformations/graph/vert.py @@ -4,7 +4,7 @@ from typing import Final, Literal import ome_zarr.classes.image as ozi -import pydantic as pyd +import ome_zarr_models.v06.coordinate_transforms as ozm06ct class AxisParsingException(Exception): @@ -32,10 +32,6 @@ class Axis: unit: Final[str | None] long_name: Final[str | None] - class LegacyModel(pyd.BaseModel): - name: Literal["x", "y", "z", "c"] - type: Literal["space", "channel"] - def __init__( self, *, name: str, type: Literal["space", "channel"], unit: str | None = None, long_name: str | None = None ): @@ -64,13 +60,13 @@ def __eq__(self, value: object, /) -> bool: ) @classmethod - def try_from_model(cls, model: ozi.Axis) -> Axis: + def try_from_model(cls, model: ozm06ct.Axis) -> Axis: name = model.name if name is None: raise AxisParsingException("Axis doesn't have a name") if model.type != "channel" and model.type != "space": raise AxisParsingException(f"Can't handle axis of type {model.type}") - if not isinstance(model.unit, str): + if not isinstance(model.unit, (str, type(None))): raise AxisParsingException("Can't handle axis unit") return Axis( name=name, @@ -79,13 +75,8 @@ def try_from_model(cls, model: ozi.Axis) -> Axis: long_name=model.longName, ) - @classmethod - def try_from_dict(cls, d: pyd.JsonValue) -> Axis: - model = ozi.Axis.model_validate(d) - return Axis.try_from_model(model) - - def to_model(self) -> ozi.Axis: - return ozi.Axis( + def to_model(self) -> ozm06ct.Axis: + return ozm06ct.Axis( discrete=False, longName=self.long_name, name=self.name, From 950ffd75946735e384a8d38312c33f804b812179 Mon Sep 17 00:00:00 2001 From: Tomaz Vieira Date: Thu, 3 Sep 2026 16:32:06 +0000 Subject: [PATCH 05/54] Adds ProjectAxisEdge, makes inverse return optional --- src/spatialdata/transformations/graph/edge.py | 160 +++++++++++++++--- 1 file changed, 132 insertions(+), 28 deletions(-) diff --git a/src/spatialdata/transformations/graph/edge.py b/src/spatialdata/transformations/graph/edge.py index 42396175f..74664cff7 100644 --- a/src/spatialdata/transformations/graph/edge.py +++ b/src/spatialdata/transformations/graph/edge.py @@ -31,8 +31,8 @@ class BaseTransfEdge(ABC): def __init__( self, - name: str | None, *, + name: str | None = None, input: CoordSystem, output: CoordSystem, ) -> None: @@ -47,8 +47,8 @@ def __repr__(self) -> str: return f"{type(self).__name__} ({domain} -> {codomain})" @abstractmethod - def inverse(self, name: str | None = None) -> BaseTransfEdge: - """Return the inverse of the transformation.""" + def inverse(self, name: str | None = None) -> BaseTransfEdge | None: + """Return the inverse of the transformation if it exists""" @abstractmethod def transform_points(self, points: ArrayLike) -> ArrayLike: @@ -192,8 +192,26 @@ def from_affine_matrix( name=name, ) - def inverse(self, name: str | None = None) -> BaseTransfEdge: - inv = np.linalg.inv(self.affine) + @classmethod + def mapping(cls, input: CoordSystem, output: CoordSystem, name: str | None = None) -> AffineEdge: + linear: ArrayLike = np.zeros((output.num_axes, input.num_axes), dtype=float) + for i, des_axis in enumerate(output.axes): + for j, src_axis in enumerate(input.axes): + if src_axis.name == des_axis.name: # FIXME: compare the entire axis? + linear[i, j] = 1 + return AffineEdge( + linear=linear, + input=input, + output=output, + name=name, + ) + + def inverse(self, name: str | None = None) -> BaseTransfEdge | None: + try: + # FIXME: I think there are more efficient/precise ways to invert a matrix + inv = np.linalg.inv(self.affine) + except np.linalg.LinAlgError: + return None return AffineEdge( linear=inv[:-1, :-1], translation=inv[-1, :-1], @@ -327,20 +345,7 @@ def transform_points(self, points: ArrayLike) -> ArrayLike: return mapped def to_affine(self, name: str | None = None) -> AffineEdge: - input_axes = self.input.axes_names - output_axes = self.output.axes_names - linear: ArrayLike = np.zeros((len(output_axes), len(input_axes)), dtype=float) - for i, des_axis in enumerate(output_axes): - for j, src_axis in enumerate(input_axes): - if src_axis == des_axis: - linear[i, j] = 1 - affine = AffineEdge( - linear=linear, - input=self.input, - output=self.output, - name=name, - ) - return affine + return AffineEdge.mapping(input=self.input, output=self.output, name=name) def to_model(self) -> ozm06trans.MapAxis: mapAxis: list[int] = [self.input.axes.index(out_ax) for out_ax in self.output.axes] @@ -352,6 +357,97 @@ def to_model(self) -> ozm06trans.MapAxis: ) +class AxisNotInCoordSystemError(Exception): + def __init__(self, axis: Axis, cs: CoordSystem) -> None: + self.axis = axis + self.cs = cs + super().__init__(f"Axis {axis.name} is not in coordinate system {cs.name}") + + +class ProjectAxisEdge(BaseTransfEdge): + dropped_inputs: Final[set[Axis]] + created_outputs: Final[set[Axis]] + + def __init__( + self, + *, + name: str | None = None, + input: CoordSystem, + output: CoordSystem, + dropped_inputs: set[Axis], + created_outputs: set[Axis], + ) -> None: + for axis in dropped_inputs: + if axis not in input.axes: + raise AxisNotInCoordSystemError(axis=axis, cs=input) + for axis in dropped_inputs: + if axis not in output.axes: + raise AxisNotInCoordSystemError(axis=axis, cs=output) + self.dropped_inputs = set(dropped_inputs) + self.created_outputs = set(created_outputs) + super().__init__(name=name, input=input, output=output) + + def to_affine(self, name: str | None = None) -> AffineEdge: + linear = np.zeros((self.output.num_axes, self.input.num_axes), dtype=float) + + for out_idx, out_ax in enumerate(self.output.axes): + if out_ax in self.created_outputs: + continue + for in_idx, in_ax in enumerate(self.input.axes): + if in_ax not in self.dropped_inputs: + linear[out_idx, in_idx] = 1 + + return AffineEdge(name=name, input=self.input, output=self.output, linear=linear) + + def transform_points(self, points: ArrayLike) -> ArrayLike: + return self.to_affine().transform_points(points) + + def inverse(self, name: str | None = None) -> BaseTransfEdge | None: + # FIXME: there may be other cases where this is invertible + if self.input.num_axes != self.output.num_axes: + return None + if len(self.dropped_inputs) > 0: + return None + if len(self.created_outputs) > 0: + return None + return ProjectAxisEdge( + input=self.output, + output=self.input, + dropped_inputs=set(), + created_outputs=set(), + name=name, + ) + + def to_model(self) -> ozm06trans.ProjectAxis: + return ozm06trans.ProjectAxis( + createdOutputs=tuple(self.output.axes.index(co) for co in self.created_outputs) or None, + droppedInputs=tuple(self.input.axes.index(di) for di in self.dropped_inputs) or None, + ) + + +def parse_project_axis( + model: ozm06trans.ProjectAxis, + *, + input: CoordSystem, + out: CoordSystem | CsGen, +) -> ProjectAxisEdge: + if isinstance(out, CoordSystem): + output = out + else: + num_dropped_inputs = len(model.droppedInputs or ()) + num_created_outputs = len(model.droppedInputs or ()) + num_output_axes = input.num_axes - num_dropped_inputs + num_created_outputs + output = out.generate(num_axes=num_output_axes) + + return ProjectAxisEdge( + created_outputs={output.axes[i] for i in model.createdOutputs or ()}, + dropped_inputs={input.axes[i] for i in model.droppedInputs or ()}, + input=input, + output=output, + name=model.name, + ) + + class TranslationEdge(BaseTransfEdge): """The Translation transformation from the NGFF specification.""" @@ -446,9 +542,9 @@ def __init__( def __repr__(self) -> str: return super().__repr__() + str(self.scale) - def inverse(self, name: str | None = None) -> ScaleEdge: + def inverse(self, name: str | None = None) -> ScaleEdge | None: if any(s == 0 for s in self.scale): - raise ValueError(f"Scaling {self} is not invertible") + return None new_scale = 1 / self.scale return ScaleEdge( scale=new_scale, @@ -588,11 +684,14 @@ def __repr__(self) -> str: out += "]" return out - def inverse(self, name: str | None = None) -> SequenceEdge: - return SequenceEdge( - [t.inverse() for t in reversed(self.transformations)], - name=name, - ) + def inverse(self, name: str | None = None) -> SequenceEdge | None: + inverted: list[BaseTransfEdge] = [] + for t in self.transformations: + inv = t.inverse() + if inv is None: + return None + inverted.append(inv) + return SequenceEdge(inverted, name=name) def to_affine(self, name: str | None = None) -> AffineEdge: composed = self.transformations[0].to_affine().affine @@ -676,8 +775,13 @@ def __repr__(self) -> str: out += "]" return out - def inverse(self, name: str | None = None) -> BaseTransfEdge: - inverse_transformations = [t.inverse() for t in self.transformations] + def inverse(self, name: str | None = None) -> BaseTransfEdge | None: + inverse_transformations: list[BaseTransfEdge] = [] + for t in self.transformations: + inv = t.inverse() + if inv is None: + return None + inverse_transformations.append(inv) return ByDimensionEdge( transformations=inverse_transformations, input=self.output, From 0a2f374aa5dfd8bfe18710ca937ce603b9111dc9 Mon Sep 17 00:00:00 2001 From: Tomaz Vieira Date: Fri, 4 Sep 2026 09:16:37 +0000 Subject: [PATCH 06/54] Moves exceptions to exceptions.py, edge names default to None --- .../transformation_manager/exceptions.py | 15 +++++++ src/spatialdata/transformations/graph/edge.py | 42 +++++-------------- 2 files changed, 25 insertions(+), 32 deletions(-) diff --git a/src/spatialdata/_core/transformation_manager/exceptions.py b/src/spatialdata/_core/transformation_manager/exceptions.py index c8324032a..77ae94837 100644 --- a/src/spatialdata/_core/transformation_manager/exceptions.py +++ b/src/spatialdata/_core/transformation_manager/exceptions.py @@ -1,5 +1,6 @@ from __future__ import annotations +from spatialdata.transformations.graph.vert import Axis, CoordSystem from spatialdata.transformations.ngff.ngff_coordinate_system import NgffCoordinateSystem @@ -227,3 +228,17 @@ class TransformationManagerWarning(UserWarning): """Base warning category for TransformationManager.""" pass + + +class UnmappableCoordSystemsError(Exception): + def __init__(self, input: CoordSystem, output: CoordSystem) -> None: + self.input = input + self.output = output + super().__init__("Output axes can't be mapped to input axes") + + +class AxisNotInCoordSystemError(Exception): + def __init__(self, axis: Axis, cs: CoordSystem) -> None: + self.axis = axis + self.cs = cs + super().__init__(f"Axis {axis.name} is not in coordinate system {cs.name}") diff --git a/src/spatialdata/transformations/graph/edge.py b/src/spatialdata/transformations/graph/edge.py index 74664cff7..9a6051942 100644 --- a/src/spatialdata/transformations/graph/edge.py +++ b/src/spatialdata/transformations/graph/edge.py @@ -8,20 +8,12 @@ import numpy as np import ome_zarr_models.v06.coordinate_transforms as ozm06trans -import pydantic as pyd +from spatialdata._core.transformation_manager.exceptions import AxisNotInCoordSystemError, UnmappableCoordSystemsError from spatialdata._types import ArrayLike from spatialdata.transformations.graph.vert import Axis, CoordSystem -class GarbledInput(Exception): - def __init__(self, message: str, input: pyd.JsonValue) -> None: - import json - - super().__init__(message + "\n" + json.dumps(input, indent=4)) - self.input = input - - class BaseTransfEdge(ABC): """Base class for all the transformations defined by the NGFF specification.""" @@ -95,7 +87,7 @@ def compose_with(self, transformation: BaseTransfEdge, name: str | None) -> Base ------- Self is applied first, then the transformation passed as argument. """ - return SequenceEdge([self, transformation], name=name) + return SequenceEdge(transformations=[self, transformation], name=name) @abstractmethod def to_model(self) -> ozm06trans.AnyTransform: @@ -289,25 +281,17 @@ def to_model(self) -> ozm06trans.Identity: ) -class UnmappableCoordSystemsError(Exception): - def __init__(self, input: CoordSystem, output: CoordSystem) -> None: - self.input = input - self.output = output - super().__init__("Output axes can't be mapped to input axes") - - class MapAxisEdge(BaseTransfEdge): """The MapAxis transformation from the NGFF specification.""" def __init__( self, - name: str | None, *, + name: str | None = None, input: CoordSystem, output: CoordSystem, ) -> None: """ - Init the NgffMapAxis object. Parameters ---------- name @@ -357,13 +341,6 @@ def to_model(self) -> ozm06trans.MapAxis: ) -class AxisNotInCoordSystemError(Exception): - def __init__(self, axis: Axis, cs: CoordSystem) -> None: - self.axis = axis - self.cs = cs - super().__init__(f"Axis {axis.name} is not in coordinate system {cs.name}") - - class ProjectAxisEdge(BaseTransfEdge): dropped_inputs: Final[set[Axis]] created_outputs: Final[set[Axis]] @@ -453,8 +430,8 @@ class TranslationEdge(BaseTransfEdge): def __init__( self, - name: str | None, *, + name: str | None = None, translation: ArrayLike, input: CoordSystem, output: CoordSystem, @@ -515,8 +492,8 @@ class ScaleEdge(BaseTransfEdge): def __init__( self, - name: str | None, *, + name: str | None = None, scale: ArrayLike, input: CoordSystem, output: CoordSystem, @@ -581,8 +558,8 @@ class RotationEdge(BaseTransfEdge): def __init__( self, - name: str | None, *, + name: str | None = None, linear_matrix: ArrayLike, input: CoordSystem, output: CoordSystem, @@ -650,8 +627,9 @@ class SequenceEdge(BaseTransfEdge): def __init__( self, + *, + name: str | None = None, transformations: Sequence[BaseTransfEdge], - name: str | None, ) -> None: """ Init the NgffSequence object. @@ -691,7 +669,7 @@ def inverse(self, name: str | None = None) -> SequenceEdge | None: if inv is None: return None inverted.append(inv) - return SequenceEdge(inverted, name=name) + return SequenceEdge(transformations=inverted, name=name) def to_affine(self, name: str | None = None) -> AffineEdge: composed = self.transformations[0].to_affine().affine @@ -724,8 +702,8 @@ class ByDimensionEdge(BaseTransfEdge): def __init__( self, - name: str | None, *, + name: str | None = None, transformations: Sequence[BaseTransfEdge], input: CoordSystem, output: CoordSystem, From d29a2a485da7bace45b811c1aacac718d180bc1f Mon Sep 17 00:00:00 2001 From: Tomaz Vieira Date: Fri, 4 Sep 2026 11:13:27 +0000 Subject: [PATCH 07/54] Use custom exceptions everywhere, adds Raises to docstrings --- .../transformation_manager/exceptions.py | 49 +++++- src/spatialdata/transformations/graph/edge.py | 162 +++++++++++++----- 2 files changed, 167 insertions(+), 44 deletions(-) diff --git a/src/spatialdata/_core/transformation_manager/exceptions.py b/src/spatialdata/_core/transformation_manager/exceptions.py index 77ae94837..d921beb9d 100644 --- a/src/spatialdata/_core/transformation_manager/exceptions.py +++ b/src/spatialdata/_core/transformation_manager/exceptions.py @@ -1,5 +1,6 @@ from __future__ import annotations +from spatialdata._types import ArrayLike from spatialdata.transformations.graph.vert import Axis, CoordSystem from spatialdata.transformations.ngff.ngff_coordinate_system import NgffCoordinateSystem @@ -230,15 +231,55 @@ class TransformationManagerWarning(UserWarning): pass -class UnmappableCoordSystemsError(Exception): - def __init__(self, input: CoordSystem, output: CoordSystem) -> None: +class IncompatibleCoordSystemsError(Exception): + def __init__(self, input: CoordSystem, output: CoordSystem, message: str | None = None) -> None: self.input = input self.output = output - super().__init__("Output axes can't be mapped to input axes") + super().__init__(message or "Output axes can't be mapped to input axes") -class AxisNotInCoordSystemError(Exception): +class MissingAxisError(Exception): def __init__(self, axis: Axis, cs: CoordSystem) -> None: self.axis = axis self.cs = cs super().__init__(f"Axis {axis.name} is not in coordinate system {cs.name}") + + +class UnexpectedShapeError(Exception): + def __init__( + self, + *, + array_shape: tuple[int, ...], + expected_shape: tuple[int, ...] | str | None = None, + array_name: str | None = None, + ) -> None: + self.array_shape = array_shape + self.expected_shape = expected_shape + message = "Unexpected array shape" + if array_name is not None: + message += f"for '{array_name}'" + message += f": {array_shape}" + if expected_shape is not None: + message += f" instead of {expected_shape}" + super().__init__(message) + + +class NotUnimodularError(Exception): + def __init__(self, matrix: ArrayLike) -> None: + self.matrix = matrix + super().__init__("Matrix does not have det(M) == 1") + + +class EmptyTransformSequenceError(Exception): + def __init__(self) -> None: + super().__init__("Empty sequence of transformations") + + +class AxisRedefinitionError(Exception): + def __init__(self, axis: Axis) -> None: + super().__init__(f"Axis {axis.name} is defined multiple times") + + +class UnmappedAxisError(Exception): + def __init__(self, axis: Axis, cs: CoordSystem) -> None: + super().__init__(f"Axis {axis.name} from coordinate system {cs.name} is not mapped to anything") diff --git a/src/spatialdata/transformations/graph/edge.py b/src/spatialdata/transformations/graph/edge.py index 9a6051942..aba0de7e5 100644 --- a/src/spatialdata/transformations/graph/edge.py +++ b/src/spatialdata/transformations/graph/edge.py @@ -9,7 +9,15 @@ import numpy as np import ome_zarr_models.v06.coordinate_transforms as ozm06trans -from spatialdata._core.transformation_manager.exceptions import AxisNotInCoordSystemError, UnmappableCoordSystemsError +from spatialdata._core.transformation_manager.exceptions import ( + AxisRedefinitionError, + EmptyTransformSequenceError, + IncompatibleCoordSystemsError, + MissingAxisError, + NotUnimodularError, + UnexpectedShapeError, + UnmappedAxisError, +) from spatialdata._types import ArrayLike from spatialdata.transformations.graph.vert import Axis, CoordSystem @@ -61,12 +69,18 @@ def _validate_transform_points_shapes(self, points: ArrayLike) -> None: """ Validate if the shape of the points (coordinates to be transformed) are consistent with the input size of the transformation. + + Raises + ------ + UnexpectedShapeError + if `points`'s shape is incompatible with this transformation's input shape """ input_size = len(self.input.axes) if len(points.shape) != 2 or points.shape[1] != input_size: - raise ValueError( - f"points must be a tensor of shape (n, d), where n is the number of points and d is the " - f"the number of dimensions. Points shape: {points.shape}, input size: {input_size}" + raise UnexpectedShapeError( + array_name="points", + expected_shape=f"(, {self.input.num_axes})", + array_shape=points.shape, ) # order of the composition: self is applied first, then the transformation passed as argument @@ -131,10 +145,14 @@ def __init__( expected_linear_shape = (num_outputs, num_inputs) if linear.shape != expected_linear_shape: - raise ValueError(f"linear's shape is {linear.shape}. Expected f{(num_outputs, num_inputs)}") + raise UnexpectedShapeError( + array_name="linear", array_shape=linear.shape, expected_shape=expected_linear_shape + ) expected_translation_shape = (num_outputs,) if translation.shape != expected_translation_shape: - raise ValueError(f"translation's shape is {translation.shape}. Expected {expected_translation_shape}") + raise UnexpectedShapeError( + array_name="translation", array_shape=translation.shape, expected_shape=expected_translation_shape + ) self.linear = linear self.translation = translation @@ -255,7 +273,9 @@ def __init__( Output coordinate system of the transformation. """ if input.num_axes != output.num_axes: - raise ValueError("Input and output must have the same number of dimensions") + raise IncompatibleCoordSystemsError( + input=input, output=output, message="Axes must have the same number of dimensions" + ) super().__init__(input=input, output=output, name=name) def inverse(self, name: str | None = None) -> BaseTransfEdge: @@ -304,7 +324,9 @@ def __init__( """ if set(input.axes) != set(output.axes): - raise UnmappableCoordSystemsError(input=input, output=output) + raise IncompatibleCoordSystemsError( + input=input, output=output, message="Input and output must have the same axes" + ) super().__init__(input=input, output=output, name=name) def __repr__(self) -> str: @@ -354,12 +376,27 @@ def __init__( dropped_inputs: set[Axis], created_outputs: set[Axis], ) -> None: + """ + Parameters + ---------- + dropped_inputs + axes in `input` that will be dropped by this transformation + created_inputs + axes in `output` that will be set to 0 + + Raises + ------ + MissingAxisError + axis in `dropped_inputs` not in `input` + axis in `created_outputs` not in `output` + """ + for axis in dropped_inputs: if axis not in input.axes: - raise AxisNotInCoordSystemError(axis=axis, cs=input) + raise MissingAxisError(axis=axis, cs=input) for axis in dropped_inputs: if axis not in output.axes: - raise AxisNotInCoordSystemError(axis=axis, cs=output) + raise MissingAxisError(axis=axis, cs=output) self.dropped_inputs = set(dropped_inputs) self.created_outputs = set(created_outputs) super().__init__(name=name, input=input, output=output) @@ -437,7 +474,6 @@ def __init__( output: CoordSystem, ) -> None: """ - Init the NgffTranslation object. Parameters ---------- name @@ -448,9 +484,16 @@ def __init__( Input coordinate system of the transformation. output Output coordinate system of the transformation. + + Raises + ------ + IncompatibleCoordSystemsError + If the input and output have different number of dimensions """ if input.num_axes != output.num_axes: - raise ValueError("Number of input and output axes must be the same") + raise IncompatibleCoordSystemsError( + input=input, output=output, message="Number of input and output axes must be the same" + ) self.translation = translation super().__init__(input=input, output=output, name=name) @@ -499,20 +542,29 @@ def __init__( output: CoordSystem, ) -> None: """ - Init the NgffScale object. Parameters ---------- scale - A list of numbers or a vector specifying the scale along each axis. + A vector specifying the scale along each axis of `input`. input Input coordinate system of the transformation. output Output coordinate system of the transformation. + + Raises + ------ + UnexpectedShapeError + If scale doesn't have the same number of elements as input has axes + IncompatibleCoordSystemsError + If input and output have different number of axes """ - if scale.shape != (input.num_axes,): - raise ValueError(f"scale should be of shape f{(input.num_axes,)}") + expected_scale_shape = (input.num_axes,) + if scale.shape != expected_scale_shape: + raise UnexpectedShapeError(array_name="scale", array_shape=scale.shape, expected_shape=expected_scale_shape) if input.num_axes != output.num_axes: - raise ValueError("input and output must have same number of dimensions") + raise IncompatibleCoordSystemsError( + input=input, output=output, message="input and output must have same number of dimensions" + ) self.scale = scale super().__init__(input=input, output=output, name=name) @@ -565,7 +617,6 @@ def __init__( output: CoordSystem, ) -> None: """ - Init the NgffRotation object. Parameters ---------- linear_matrix @@ -574,14 +625,26 @@ def __init__( Input coordinate system of the transformation. output Output coordinate system of the transformation. + Raises + ------ + UnexpectedShapeError + if linear_matrix's shape isn't (output.num_axes, input.num_axes) + IncompatibleCoordSystemsError + if input and output don't have the same number of axes + NotUnimodularError + if linear_matrix doesn't have determinant ~= 1 """ + if input.num_axes != output.num_axes: + raise IncompatibleCoordSystemsError( + input=input, output=output, message="input and output should have the same numbe rof axes" + ) expected_shape = (output.num_axes, input.num_axes) if linear_matrix.shape != expected_shape: - raise ValueError(f"linear matrix should have shape {expected_shape}") - if input.num_axes != output.num_axes: - raise ValueError("input and output should have the same numbe rof axes") + raise UnexpectedShapeError( + array_name="linear_matrix", array_shape=linear_matrix.shape, expected_shape=expected_shape + ) if not np.isclose(np.linalg.det(linear_matrix), 1.0): - raise ValueError("det(linear_matrix) should be ~= 1") + raise NotUnimodularError(matrix=linear_matrix) linear_matrix.flags.writeable = False self.rotation = linear_matrix super().__init__(input=input, output=output, name=name) @@ -638,13 +701,26 @@ def __init__( ---------- transformations The transformations which compose the sequence. + Raises + ------ + EmptyTransformSequence + if `transformations` is empty + IncompatibleCoordSystemsError + if any item in `transformations` is incompatible with its neighbors """ if len(transformations) == 0: - raise ValueError("Empty transformation list") + raise EmptyTransformSequenceError() previous_transf = transformations[0] - for current_transf in transformations[1:]: + for transf_idx, current_transf in enumerate(transformations[1:], start=1): if previous_transf.output != current_transf.input: - raise ValueError(f"Mismatched input/output from {previous_transf} to {current_transf}") + raise IncompatibleCoordSystemsError( + input=current_transf.input, + output=previous_transf.output, + message=( + f"Output of transformation #{transf_idx - 1} is different " + f"from input of transformation #{transf_idx}" + ), + ) previous_transf = current_transf self.transformations = transformations super().__init__( @@ -709,8 +785,6 @@ def __init__( output: CoordSystem, ) -> None: """ - Init the ByDimension object. - Parameters ---------- transformations @@ -720,26 +794,34 @@ def __init__( The input coordinate system of the transformation. output The output coordinate system of the transformation. + Raises + ------ + MissingAxisError + if any input axis from `transformations` is not present in `input` or + if any output axis from `transformations` is not present in `output`. + AxisRedefinitionError + if an output axis is specified by more than one item of `transformations` + UnmappedAxisError + if axis of `output` is not covered by any item of `transformations` """ # we check that: # 1. each input from each transformation in self.transformation must appear in the set of input axes # 2. each output from each transformation in self.transformation must appear at most once in the set of output # axes - input_axes = input.axes_names - output_axes = output.axes_names defined_output_axes: set[str] = set() for t in transformations: - for ax in t.input.axes_names: - if ax not in input_axes: - raise ValueError(f"By dimension axis {ax} not in {input_axes}") - for ax in t.output.axes_names: - if ax not in output_axes: - raise ValueError(f"Axis {ax} not in output axes {output_axes}") - if ax in defined_output_axes: - raise ValueError(f"Output axis {ax} is defined more than once") - defined_output_axes.add(ax) - if len(output_axes) != len(defined_output_axes): - raise ValueError("Not all outputs are mapped") + for ax in t.input.axes: + if ax not in input.axes: + raise MissingAxisError(axis=ax, cs=input) + for ax in t.output.axes: + if ax not in output.axes: + raise MissingAxisError(axis=ax, cs=output) + if ax.name in defined_output_axes: + raise AxisRedefinitionError(axis=ax) + defined_output_axes.add(ax.name) + for ax in output.axes: + if ax.name not in defined_output_axes: + raise UnmappedAxisError(axis=ax, cs=output) self.transformations = tuple(transformations) super().__init__(input=input, output=output, name=name) From 3618cdd7a2a8d5e799509fa9e72b690ffa2e1de4 Mon Sep 17 00:00:00 2001 From: Tomaz Vieira Date: Tue, 8 Sep 2026 08:59:35 +0000 Subject: [PATCH 08/54] Fix Affine.from_affine_matrix slicing] --- src/spatialdata/transformations/graph/edge.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/spatialdata/transformations/graph/edge.py b/src/spatialdata/transformations/graph/edge.py index aba0de7e5..30a0f98d1 100644 --- a/src/spatialdata/transformations/graph/edge.py +++ b/src/spatialdata/transformations/graph/edge.py @@ -196,7 +196,7 @@ def from_affine_matrix( """ return AffineEdge( linear=affine_matrix[:-1, :-1], - translation=affine_matrix[-1, :-1], + translation=affine_matrix[:-1, -1], input=input, output=output, name=name, From 4313c563444599a39e82821a2db7235bc6d78b2b Mon Sep 17 00:00:00 2001 From: Tomaz Vieira Date: Tue, 8 Sep 2026 11:26:18 +0000 Subject: [PATCH 09/54] Fix coords bug when reading from OMEZarrMultiscale. Some cleanup --- src/spatialdata/_io/io_raster.py | 37 ++++++++++++++++++++------------ 1 file changed, 23 insertions(+), 14 deletions(-) diff --git a/src/spatialdata/_io/io_raster.py b/src/spatialdata/_io/io_raster.py index 29f40d6d8..884a39e0a 100644 --- a/src/spatialdata/_io/io_raster.py +++ b/src/spatialdata/_io/io_raster.py @@ -171,7 +171,11 @@ def _prepare_storage_options( def try_read_ngff06_multiscale(store: Path) -> tuple[DataTree, Sequence[BaseTransfEdge]]: multiscale = oz.OMEZarrMultiscale.from_ome_zarr(str(store)) assert isinstance(multiscale, oz.OMEZarrMultiscale) # disambiguate from OMEZarrLabel + return try_parse_ngff06_multiscale(multiscale) + +def try_parse_ngff06_multiscale(multiscale: oz.OMEZarrMultiscale) -> tuple[DataTree, Sequence[BaseTransfEdge]]: + """Parse an OMEZarMultiscale into a DataTree and collects Multiscale-level transforms.""" name_to_cs: dict[str, CoordSystem] = {} for cs in multiscale.metadata.coordinateSystems or (): parsed_cs = CoordSystem.try_from_model(cs) @@ -204,24 +208,27 @@ def try_read_ngff06_multiscale(store: Path) -> tuple[DataTree, Sequence[BaseTran for scale_idx, (ds_md, ds) in enumerate(zip(multiscale.metadata.datasets, multiscale.images, strict=True)): transf = ds_md.coordinateTransformations[0] - out_cs = name_to_cs[multiscale.metadata.intrinsic_coordinate_system.name] + intrinsic_cs = name_to_cs[multiscale.metadata.intrinsic_coordinate_system.name] assert transf.input is not None assert transf.input.path is not None - in_cs = CoordSystem(name=str(transf.input.name), axes=[Axis(name=ax.name, type=ax.type) for ax in out_cs.axes]) + pixel_cs = CoordSystem( + name=transf.input.path, + axes=[Axis(name=ax.name, type=ax.type) for ax in intrinsic_cs.axes], + virtual=True, + ) - ozm_seq = ozm06trans.Sequence(transformations=ds_md.coordinateTransformations) - seq = parse_ngff_transf(input=in_cs, output=out_cs, model=ozm_seq) + pixel_cs_to_intrinsic_ngff = ozm06trans.Sequence(transformations=ds_md.coordinateTransformations) + seq = parse_ngff_transf(input=pixel_cs, output=intrinsic_cs, model=pixel_cs_to_intrinsic_ngff) ds_shape = np.asarray(ds.data.shape) - transformed_start = seq.transform_points(np.zeros_like(ds.data.shape)[np.newaxis, :])[0] + transformed_start = seq.transform_points(np.zeros_like(ds_shape)[np.newaxis, :])[0] transformed_stop = seq.transform_points((ds_shape - 1)[np.newaxis, :])[0] - coords = xr.Coordinates() - for low, high, ax, extent in zip(transformed_start, transformed_stop, out_cs.axes, ds_shape, strict=True): + coords: xr.Coordinates = xr.Coordinates() + for low, high, ax, extent in zip(transformed_start, transformed_stop, intrinsic_cs.axes, ds_shape, strict=True): if ax.type == "channel" and channel_names is not None: - coords.merge({ax.name: channel_names}) - continue - coords = coords.merge( - xr.Coordinates.from_xindex( + coords = coords.merge({ax.name: channel_names}).coords + else: + axis_index = xr.Coordinates.from_xindex( RangeIndex.linspace( start=low, stop=high, @@ -230,17 +237,19 @@ def try_read_ngff06_multiscale(store: Path) -> tuple[DataTree, Sequence[BaseTran dim=ax.name, ) ) - ) + coords = coords.merge(axis_index).coords + # Note: the magic "image" and "scale " strings mimic the current + # behavior from `dask_arrays_to_datatree` data_tree[f"scale{scale_idx}"] = xr.Dataset( { "image": xr.DataArray( ds.data, name="image", - dims=out_cs.axes_names, + dims=intrinsic_cs.axes_names, coords=coords, ) - } + }, ) return data_tree, parsed_transfs From 9c2e1d3a0ddf0aabb52138ef41883538334a376c Mon Sep 17 00:00:00 2001 From: Tomaz Vieira Date: Tue, 8 Sep 2026 11:30:05 +0000 Subject: [PATCH 10/54] Adds test for parsing OMEZarrMultiscale --- tests/io/test_ngff_06.py | 45 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 tests/io/test_ngff_06.py diff --git a/tests/io/test_ngff_06.py b/tests/io/test_ngff_06.py new file mode 100644 index 000000000..46c2aea75 --- /dev/null +++ b/tests/io/test_ngff_06.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +import numpy as np +from ome_zarr import OMEZarrImage, OMEZarrMultiscale +from ome_zarr_models.v06.coordinate_transforms import Scale as ModelsScale +from ome_zarr_models.v06.coordinate_transforms import Sequence as ModelsSequence +from ome_zarr_models.v06.coordinate_transforms import Translation as ModelsTranslation + +from spatialdata._io.io_raster import try_parse_ngff06_multiscale +from tests.conftest import SEED + + +def test_parse_multiscale(): + data = np.random.default_rng(seed=SEED).random((256, 256)) + omz_img = OMEZarrImage(data=data, axes="yx") + ms = OMEZarrMultiscale(image=omz_img, scale_factors=(2, 4, 8, 16)) + + data_tree, _transforms = try_parse_ngff06_multiscale(ms) + for xr_scale_node, ngff_scale, ngff_meta in zip( + data_tree.children.values(), ms.images, ms.metadata.datasets, strict=True + ): + xr_scale = xr_scale_node["image"] + assert xr_scale.shape == ngff_scale.data.shape + + coords_x = xr_scale.coords["x"] + coords_y = xr_scale.coords["y"] + + seq = ngff_meta.coordinateTransformations[0] + assert isinstance(seq, ModelsSequence) + scale = seq.transformations[0] + assert isinstance(scale, ModelsScale) + translate = seq.transformations[1] + assert isinstance(translate, ModelsTranslation) + + start_indices = (0, 0) + start = (coords_x[0], coords_y[0]) + + end_indices = (xr_scale.shape[0] - 1, xr_scale.shape[1] - 1) + end = (coords_x[-1], coords_y[-1]) + + for indices, point in [(start_indices, start), (end_indices, end)]: + expected = np.asarray(scale.scale) * indices + translate.translation + assert np.allclose(expected, point) + + return data_tree, ms From f94498ed6460205d9099d259b1e8600b7eb044c2 Mon Sep 17 00:00:00 2001 From: Tomaz Vieira Date: Wed, 9 Sep 2026 11:00:58 +0000 Subject: [PATCH 11/54] Adds transformations tests, fixes some slicing bugs Co-authored-by: Claude Sonnet 5 --- .../transformation_manager/exceptions.py | 2 + src/spatialdata/transformations/graph/edge.py | 33 +- tests/transformations/edge/__init__.py | 0 tests/transformations/edge/conftest.py | 19 + .../edge/test_edge_transformations.py | 365 ++++++++++++++++++ 5 files changed, 410 insertions(+), 9 deletions(-) create mode 100644 tests/transformations/edge/__init__.py create mode 100644 tests/transformations/edge/conftest.py create mode 100644 tests/transformations/edge/test_edge_transformations.py diff --git a/src/spatialdata/_core/transformation_manager/exceptions.py b/src/spatialdata/_core/transformation_manager/exceptions.py index d921beb9d..a9dec744b 100644 --- a/src/spatialdata/_core/transformation_manager/exceptions.py +++ b/src/spatialdata/_core/transformation_manager/exceptions.py @@ -282,4 +282,6 @@ def __init__(self, axis: Axis) -> None: class UnmappedAxisError(Exception): def __init__(self, axis: Axis, cs: CoordSystem) -> None: + self.axis = axis + self.cs = cs super().__init__(f"Axis {axis.name} from coordinate system {cs.name} is not mapped to anything") diff --git a/src/spatialdata/transformations/graph/edge.py b/src/spatialdata/transformations/graph/edge.py index 30a0f98d1..35370cc28 100644 --- a/src/spatialdata/transformations/graph/edge.py +++ b/src/spatialdata/transformations/graph/edge.py @@ -224,7 +224,7 @@ def inverse(self, name: str | None = None) -> BaseTransfEdge | None: return None return AffineEdge( linear=inv[:-1, :-1], - translation=inv[-1, :-1], + translation=inv[:-1, -1], input=self.output, output=self.input, name=name, @@ -345,7 +345,7 @@ def inverse(self, name: str | None = None) -> BaseTransfEdge: def transform_points(self, points: ArrayLike) -> ArrayLike: self._validate_transform_points_shapes(points) - new_indices = [self.input.axes.index(out_ax.name) for out_ax in self.output.axes] + new_indices = [self.input.axes.index(out_ax) for out_ax in self.output.axes] mapped = points[:, new_indices] assert isinstance(mapped, np.ndarray) return mapped @@ -389,27 +389,42 @@ def __init__( MissingAxisError axis in `dropped_inputs` not in `input` axis in `created_outputs` not in `output` + IncompatibleCoordSystemsError + when input can't be mapped to output given dropped_inputs and created_outputs """ for axis in dropped_inputs: if axis not in input.axes: raise MissingAxisError(axis=axis, cs=input) - for axis in dropped_inputs: + for axis in created_outputs: if axis not in output.axes: raise MissingAxisError(axis=axis, cs=output) + if input.num_axes - len(dropped_inputs) + len(created_outputs) != output.num_axes: + message = f"Can't map from {input} to {output}" + if dropped_inputs: + message += f" dropping {dropped_inputs}" + if created_outputs: + message += f" creating {created_outputs}" + raise IncompatibleCoordSystemsError( + input=input, + output=output, + message=message, + ) + self.dropped_inputs = set(dropped_inputs) self.created_outputs = set(created_outputs) super().__init__(name=name, input=input, output=output) def to_affine(self, name: str | None = None) -> AffineEdge: linear = np.zeros((self.output.num_axes, self.input.num_axes), dtype=float) - + input_indices = iter(range(self.input.num_axes)) for out_idx, out_ax in enumerate(self.output.axes): if out_ax in self.created_outputs: continue - for in_idx, in_ax in enumerate(self.input.axes): - if in_ax not in self.dropped_inputs: - linear[out_idx, in_idx] = 1 + in_idx = next(input_indices) + if in_idx in self.dropped_inputs: + continue + linear[out_idx, in_idx] = 1 return AffineEdge(name=name, input=self.input, output=self.output, linear=linear) @@ -449,7 +464,7 @@ def parse_project_axis( output = out else: num_dropped_inputs = len(model.droppedInputs or ()) - num_created_outputs = len(model.droppedInputs or ()) + num_created_outputs = len(model.createdOutputs or ()) num_output_axes = input.num_axes - num_dropped_inputs + num_created_outputs output = out.generate(num_axes=num_output_axes) @@ -740,7 +755,7 @@ def __repr__(self) -> str: def inverse(self, name: str | None = None) -> SequenceEdge | None: inverted: list[BaseTransfEdge] = [] - for t in self.transformations: + for t in reversed(self.transformations): inv = t.inverse() if inv is None: return None diff --git a/tests/transformations/edge/__init__.py b/tests/transformations/edge/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/transformations/edge/conftest.py b/tests/transformations/edge/conftest.py new file mode 100644 index 000000000..75de85b49 --- /dev/null +++ b/tests/transformations/edge/conftest.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from spatialdata.transformations.graph.vert import Axis, CoordSystem + +x_axis = Axis(name="x", type="space", unit="micrometer") +y_axis = Axis(name="y", type="space", unit="micrometer") +z_axis = Axis(name="z", type="space", unit="micrometer") +c_axis = Axis(name="c", type="channel") + +x_cs = CoordSystem(name="x", axes=[x_axis]) +y_cs = CoordSystem(name="y", axes=[y_axis]) +z_cs = CoordSystem(name="z", axes=[z_axis]) +c_cs = CoordSystem(name="c", axes=[c_axis]) +xy_cs = CoordSystem(name="xy", axes=[x_axis, y_axis]) +yx_cs = CoordSystem(name="yx", axes=[y_axis, x_axis]) +xyz_cs = CoordSystem(name="xyz", axes=[x_axis, y_axis, z_axis]) +zyx_cs = CoordSystem(name="zyx", axes=[z_axis, y_axis, x_axis]) +xyc_cs = CoordSystem(name="xyc", axes=[x_axis, y_axis, c_axis]) +cyx_cs = CoordSystem(name="cyx", axes=[c_axis, y_axis, x_axis]) diff --git a/tests/transformations/edge/test_edge_transformations.py b/tests/transformations/edge/test_edge_transformations.py new file mode 100644 index 000000000..8283369d3 --- /dev/null +++ b/tests/transformations/edge/test_edge_transformations.py @@ -0,0 +1,365 @@ +# pyright: strict + +from __future__ import annotations + +import numpy as np +import pytest + +from spatialdata._core.transformation_manager.exceptions import ( + AxisRedefinitionError, + EmptyTransformSequenceError, + IncompatibleCoordSystemsError, + MissingAxisError, + NotUnimodularError, + UnexpectedShapeError, + UnmappedAxisError, +) +from spatialdata.transformations.graph.edge import ( + AffineEdge, + ByDimensionEdge, + IdentityEdge, + MapAxisEdge, + ProjectAxisEdge, + RotationEdge, + ScaleEdge, + SequenceEdge, + TranslationEdge, +) +from tests.transformations.edge.conftest import ( + x_cs, + xy_cs, + xyc_cs, + xyz_cs, + y_axis, + y_cs, + yx_cs, + z_axis, + zyx_cs, +) + +POINTS_2D = np.array([[1.0, 2.0], [3.0, 4.0]]) +POINTS_3D = np.array([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]) + + +class TestAffineEdge: + def test_constructor_rejects_wrong_linear_shape(self): + with pytest.raises(UnexpectedShapeError): + AffineEdge(linear=np.eye(3), input=xy_cs, output=xy_cs) + + def test_constructor_rejects_wrong_translation_shape(self): + with pytest.raises(UnexpectedShapeError): + AffineEdge(linear=np.eye(2), translation=np.zeros(3), input=xy_cs, output=xy_cs) + + def test_from_affine_matrix(self): + # fmt: off + affine_matrix = np.array([ + [2.0, 0.0, 1.0], + [0.0, 3.0, 5.0], + [0.0, 0.0, 1.0] + ]) + linear = [ + [2.0, 0.0], + [0.0, 3.0], + ] + translation = [ + 1.0, + 5.0 + ] + # fmt: on + edge = AffineEdge.from_affine_matrix(name=None, affine_matrix=affine_matrix, input=xy_cs, output=xy_cs) + np.testing.assert_equal(edge.linear, linear) + np.testing.assert_equal(edge.translation, translation) + np.testing.assert_allclose(edge.transform_points(POINTS_2D), np.array([[3.0, 11.0], [7.0, 17.0]])) + + def test_mapping_classmethod_builds_permutation_matrix(self): + edge = AffineEdge.mapping(input=xy_cs, output=yx_cs) + np.testing.assert_allclose(edge.linear, np.array([[0.0, 1.0], [1.0, 0.0]])) + np.testing.assert_allclose(edge.transform_points(POINTS_2D), np.array([[2.0, 1.0], [4.0, 3.0]])) + + def test_transform_points_scale_and_translate(self): + edge = AffineEdge( + linear=np.array([[2.0, 0.0], [0.0, 3.0]]), translation=np.array([1.0, 1.0]), input=xy_cs, output=xy_cs + ) + np.testing.assert_allclose(edge.transform_points(POINTS_2D), np.array([[3.0, 7.0], [7.0, 13.0]])) + + def test_transform_points_2d_to_3d(self): + linear = np.array([[1.0, 0.0], [0.0, 1.0], [1.0, 1.0]]) + edge = AffineEdge(linear=linear, input=xy_cs, output=xyz_cs) + np.testing.assert_allclose(edge.transform_points(POINTS_2D), np.array([[1.0, 2.0, 3.0], [3.0, 4.0, 7.0]])) + + def test_transform_points_rejects_wrong_shape(self): + edge = AffineEdge(linear=np.eye(2), input=xy_cs, output=xy_cs) + with pytest.raises(UnexpectedShapeError): + # expecting (n, 2), gets (2,) + edge.transform_points(np.asarray([1.0, 2.0])) + with pytest.raises(UnexpectedShapeError): + # expecting (n, 2), gets (n, 3) + edge.transform_points(POINTS_3D) + + def test_inverse_roundtrips(self): + edge = AffineEdge( + linear=np.array([[2.0, 0.0], [0.0, 4.0]]), translation=np.array([1.0, -1.0]), input=xy_cs, output=xy_cs + ) + inv = edge.inverse() + assert inv is not None + np.testing.assert_allclose(inv.transform_points(edge.transform_points(POINTS_2D)), POINTS_2D) + + def test_inverse_returns_none_for_singular_matrix(self): + edge = AffineEdge(linear=np.zeros((2, 2)), input=xy_cs, output=xy_cs) + assert edge.inverse() is None + + +class TestIdentityEdge: + def test_constructor_rejects_mismatched_number_of_axes(self): + with pytest.raises(IncompatibleCoordSystemsError): + IdentityEdge(None, input=xy_cs, output=xyz_cs) + + def test_transform_points_is_a_noop(self): + edge = IdentityEdge(None, input=xy_cs, output=xy_cs) + np.testing.assert_allclose(edge.transform_points(POINTS_2D), POINTS_2D) + + def test_to_affine_matches_transform_points(self): + edge = IdentityEdge(None, input=xy_cs, output=xy_cs) + affine = edge.to_affine() + np.testing.assert_allclose(affine.linear, np.eye(2)) + np.testing.assert_allclose(affine.transform_points(POINTS_2D), POINTS_2D) + + def test_inverse_is_still_the_identity(self): + edge = IdentityEdge(None, input=xy_cs, output=xy_cs) + inv = edge.inverse() + np.testing.assert_allclose(inv.transform_points(POINTS_2D), POINTS_2D) + + +class TestMapAxisEdge: + def test_constructor_rejects_different_sets_of_axes(self): + with pytest.raises(IncompatibleCoordSystemsError): + MapAxisEdge(input=xy_cs, output=xyz_cs) + + def test_transform_points_swaps_axes(self): + edge = MapAxisEdge(input=xy_cs, output=yx_cs) + np.testing.assert_allclose(edge.transform_points(POINTS_2D), np.array([[2.0, 1.0], [4.0, 3.0]])) + + def test_transform_points_permutation_of_three_axes(self): + edge = MapAxisEdge(input=xyz_cs, output=zyx_cs) + np.testing.assert_allclose(edge.transform_points(POINTS_3D), np.array([[3.0, 2.0, 1.0], [6.0, 5.0, 4.0]])) + + def test_to_affine_matches_transform_points(self): + edge = MapAxisEdge(input=xy_cs, output=yx_cs) + affine = edge.to_affine() + np.testing.assert_allclose(affine.transform_points(POINTS_2D), edge.transform_points(POINTS_2D)) + + def test_inverse_roundtrips(self): + edge = MapAxisEdge(input=xyz_cs, output=zyx_cs) + inv = edge.inverse() + np.testing.assert_allclose(inv.transform_points(edge.transform_points(POINTS_3D)), POINTS_3D) + + +class TestProjectAxisEdge: + def test_constructor_rejects_dropped_axis_missing_from_input(self): + with pytest.raises(MissingAxisError): + ProjectAxisEdge(input=xy_cs, output=xy_cs, dropped_inputs={z_axis}, created_outputs=set()) + + def test_constructor_rejects_created_axis_missing_from_output(self): + with pytest.raises(MissingAxisError): + ProjectAxisEdge(input=xy_cs, output=xy_cs, dropped_inputs=set(), created_outputs={z_axis}) + + def test_general_coord_system_incompatibility(self): + with pytest.raises(IncompatibleCoordSystemsError): + ProjectAxisEdge(input=xy_cs, output=xyz_cs, dropped_inputs=set(), created_outputs=set()) + with pytest.raises(IncompatibleCoordSystemsError): + ProjectAxisEdge(input=xyz_cs, output=xy_cs, dropped_inputs=set(), created_outputs=set()) + + def test_to_affine_for_identity_case(self): + edge = ProjectAxisEdge(input=xy_cs, output=xy_cs, dropped_inputs=set(), created_outputs=set()) + affine = edge.to_affine() + np.testing.assert_allclose(affine.linear, np.eye(2)) + np.testing.assert_allclose(edge.transform_points(POINTS_2D), POINTS_2D) + + def test_dropping_one_axis(self): + edge = ProjectAxisEdge(input=xyz_cs, output=xy_cs, dropped_inputs={z_axis}, created_outputs=set()) + affine = edge.to_affine() + assert affine is not None + np.testing.assert_allclose(edge.transform_points(POINTS_3D), np.array([[1.0, 2.0], [4.0, 5.0]])) + + def test_creating_one_axis(self): + edge = ProjectAxisEdge(input=xy_cs, output=xyz_cs, dropped_inputs=set(), created_outputs={z_axis}) + affine = edge.to_affine() + assert affine is not None + np.testing.assert_allclose(edge.transform_points(POINTS_2D), np.array([[1.0, 2.0, 0.0], [3.0, 4.0, 0.0]])) + + def test_inverse_none_when_axes_are_dropped_or_created(self): + dropped = ProjectAxisEdge(input=xyz_cs, output=xy_cs, dropped_inputs={z_axis}, created_outputs=set()) + assert dropped.inverse() is None + created = ProjectAxisEdge(input=xy_cs, output=xyz_cs, dropped_inputs=set(), created_outputs={z_axis}) + assert created.inverse() is None + + def test_inverse_roundtrip(self): + edge = ProjectAxisEdge(input=xy_cs, output=yx_cs, dropped_inputs=set(), created_outputs=set()) + inv = edge.inverse() + assert inv is not None + np.testing.assert_allclose(inv.transform_points(edge.transform_points(POINTS_2D)), POINTS_2D) + + +class TestTranslationEdge: + def test_constructor_rejects_mismatched_number_of_axes(self): + with pytest.raises(IncompatibleCoordSystemsError): + TranslationEdge(translation=np.array([1.0, 2.0]), input=xy_cs, output=xyz_cs) + + def test_transform_points_adds_translation(self): + edge = TranslationEdge(translation=np.array([10.0, 20.0]), input=xy_cs, output=xy_cs) + np.testing.assert_allclose(edge.transform_points(POINTS_2D), np.array([[11.0, 22.0], [13.0, 24.0]])) + + def test_to_affine_matches_transform_points(self): + edge = TranslationEdge(translation=np.array([10.0, 20.0]), input=xy_cs, output=xy_cs) + affine = edge.to_affine() + assert affine is not None + np.testing.assert_allclose(affine.transform_points(POINTS_2D), edge.transform_points(POINTS_2D)) + + def test_inverse_roundtrips(self): + edge = TranslationEdge(translation=np.array([10.0, 20.0]), input=xy_cs, output=xy_cs) + inv = edge.inverse() + np.testing.assert_allclose(inv.transform_points(edge.transform_points(POINTS_2D)), POINTS_2D) + + +class TestScaleEdge: + def test_constructor_rejects_wrong_scale_shape(self): + with pytest.raises(UnexpectedShapeError): + ScaleEdge(scale=np.array([1.0, 2.0, 3.0]), input=xy_cs, output=xy_cs) + + def test_constructor_rejects_mismatched_number_of_axes(self): + with pytest.raises(IncompatibleCoordSystemsError): + ScaleEdge(scale=np.array([1.0, 2.0]), input=xy_cs, output=xyz_cs) + + def test_transform_points(self): + edge = ScaleEdge(scale=np.array([2.0, 4.0]), input=xy_cs, output=xy_cs) + np.testing.assert_allclose(edge.transform_points(POINTS_2D), np.array([[2.0, 8.0], [6.0, 16.0]])) + + def test_to_affine_matches_transform_points(self): + edge = ScaleEdge(scale=np.array([2.0, 4.0]), input=xy_cs, output=xy_cs) + affine = edge.to_affine() + np.testing.assert_allclose(affine.transform_points(POINTS_2D), edge.transform_points(POINTS_2D)) + + def test_inverse_roundtrips(self): + edge = ScaleEdge(scale=np.array([2.0, 4.0]), input=xy_cs, output=xy_cs) + inv = edge.inverse() + assert inv is not None + np.testing.assert_allclose(inv.transform_points(edge.transform_points(POINTS_2D)), POINTS_2D) + + def test_inverse_returns_none_when_scale_is_zero(self): + edge = ScaleEdge(scale=np.array([0.0, 4.0]), input=xy_cs, output=xy_cs) + assert edge.inverse() is None + + +class TestRotationEdge: + def test_constructor_rejects_mismatched_number_of_axes(self): + with pytest.raises(IncompatibleCoordSystemsError): + RotationEdge(linear_matrix=np.eye(2), input=xy_cs, output=xyz_cs) + + def test_constructor_rejects_wrong_shape(self): + with pytest.raises(UnexpectedShapeError): + RotationEdge(linear_matrix=np.eye(3), input=xy_cs, output=xy_cs) + + def test_constructor_rejects_non_unimodular_matrix(self): + with pytest.raises(NotUnimodularError): + RotationEdge(linear_matrix=np.array([[1.0, 0.0], [0.0, -1.0]]), input=xy_cs, output=xy_cs) + + def test_transform_points_rotates_90_degrees(self): + edge = RotationEdge(linear_matrix=np.array([[0.0, -1.0], [1.0, 0.0]]), input=xy_cs, output=xy_cs) + np.testing.assert_allclose(edge.transform_points(POINTS_2D), np.array([[-2.0, 1.0], [-4.0, 3.0]])) + + def test_to_affine_matches_transform_points(self): + edge = RotationEdge(linear_matrix=np.array([[0.0, -1.0], [1.0, 0.0]]), input=xy_cs, output=xy_cs) + affine = edge.to_affine() + np.testing.assert_allclose(affine.transform_points(POINTS_2D), edge.transform_points(POINTS_2D)) + + def test_inverse_roundtrips(self): + edge = RotationEdge(linear_matrix=np.array([[0.0, -1.0], [1.0, 0.0]]), input=xy_cs, output=xy_cs) + inv = edge.inverse() + np.testing.assert_allclose(inv.transform_points(edge.transform_points(POINTS_2D)), POINTS_2D) + + +class TestSequenceEdge: + def test_constructor_rejects_empty_sequence(self): + with pytest.raises(EmptyTransformSequenceError): + SequenceEdge(transformations=[]) + + def test_constructor_rejects_incompatible_neighbors(self): + first = TranslationEdge(translation=np.array([1.0, 2.0]), input=xy_cs, output=xy_cs) + second = TranslationEdge(translation=np.array([1.0, 2.0, 3.0]), input=xyz_cs, output=xyz_cs) + with pytest.raises(IncompatibleCoordSystemsError): + SequenceEdge(transformations=[first, second]) + + def test_transform_points_composes_in_order(self): + translate = TranslationEdge(translation=np.array([1.0, 2.0]), input=xy_cs, output=xy_cs) + scale = ScaleEdge(scale=np.array([3.0, 4.0]), input=xy_cs, output=xy_cs) + edge = SequenceEdge(transformations=[translate, scale]) + expected = (POINTS_2D + np.array([1.0, 2.0])) * np.array([3.0, 4.0]) + np.testing.assert_allclose(edge.transform_points(POINTS_2D), expected) + + def test_to_affine_matches_transform_points(self): + translate = TranslationEdge(translation=np.array([1.0, 2.0]), input=xy_cs, output=xy_cs) + scale = ScaleEdge(scale=np.array([3.0, 4.0]), input=xy_cs, output=xy_cs) + edge = SequenceEdge(transformations=[translate, scale]) + affine = edge.to_affine() + np.testing.assert_allclose(affine.transform_points(POINTS_2D), edge.transform_points(POINTS_2D)) + + def test_inverse_roundtrips(self): + translate = TranslationEdge(translation=np.array([1.0, 2.0]), input=xy_cs, output=xy_cs) + scale = ScaleEdge(scale=np.array([3.0, 4.0]), input=xy_cs, output=xy_cs) + edge = SequenceEdge(transformations=[translate, scale]) + inv = edge.inverse() + assert inv is not None + np.testing.assert_allclose(inv.transform_points(edge.transform_points(POINTS_2D)), POINTS_2D) + + def test_inverse_returns_none_if_any_component_is_not_invertible(self): + scale = ScaleEdge(scale=np.array([0.0, 4.0]), input=xy_cs, output=xy_cs) + translate = TranslationEdge(translation=np.array([1.0, 2.0]), input=xy_cs, output=xy_cs) + edge = SequenceEdge(transformations=[scale, translate]) + assert edge.inverse() is None + + +class TestByDimensionEdge: + def test_constructor_rejects_input_axis_missing_from_overall_input(self): + sub = IdentityEdge(None, input=xyc_cs, output=xyc_cs) + with pytest.raises(MissingAxisError): + ByDimensionEdge(transformations=[sub], input=xy_cs, output=xyc_cs) + + def test_constructor_rejects_output_axis_missing_from_overall_output(self): + sub = IdentityEdge(None, input=xy_cs, output=xy_cs) + with pytest.raises(MissingAxisError): + ByDimensionEdge(transformations=[sub], input=xy_cs, output=x_cs) + + def test_constructor_rejects_output_axis_defined_more_than_once(self): + first = IdentityEdge(None, input=x_cs, output=x_cs) + second = IdentityEdge(None, input=x_cs, output=x_cs) + with pytest.raises(AxisRedefinitionError): + ByDimensionEdge(transformations=[first, second], input=x_cs, output=x_cs) + + def test_constructor_rejects_unmapped_output_axis(self): + sub = IdentityEdge(None, input=x_cs, output=x_cs) + try: + ByDimensionEdge(transformations=[sub], input=xy_cs, output=xy_cs) + raise AssertionError(f"Expected {UnmappedAxisError.__name__} to be raised") + except UnmappedAxisError as e: + assert e.axis == y_axis + + def test_transform_points_applies_each_transformation_per_axis(self): + scale_x = ScaleEdge(scale=np.array([2.0]), input=x_cs, output=x_cs) + translate_y = TranslationEdge(translation=np.array([5.0]), input=y_cs, output=y_cs) + edge = ByDimensionEdge(transformations=[scale_x, translate_y], input=xy_cs, output=xy_cs) + np.testing.assert_allclose(edge.transform_points(POINTS_2D), np.array([[2.0, 7.0], [6.0, 9.0]])) + + def test_to_affine_matches_transform_points(self): + scale_x = ScaleEdge(scale=np.array([2.0]), input=x_cs, output=x_cs) + translate_y = TranslationEdge(translation=np.array([5.0]), input=y_cs, output=y_cs) + edge = ByDimensionEdge(transformations=[scale_x, translate_y], input=xy_cs, output=xy_cs) + affine = edge.to_affine() + np.testing.assert_allclose(affine.transform_points(POINTS_2D), edge.transform_points(POINTS_2D)) + + def test_inverse_roundtrips(self): + scale_x = ScaleEdge(scale=np.array([2.0]), input=x_cs, output=x_cs) + translate_y = TranslationEdge(translation=np.array([5.0]), input=y_cs, output=y_cs) + edge = ByDimensionEdge(transformations=[scale_x, translate_y], input=xy_cs, output=xy_cs) + inv = edge.inverse() + assert inv is not None + np.testing.assert_allclose(inv.transform_points(edge.transform_points(POINTS_2D)), POINTS_2D) From c0da25f6252b1b77b76ef25b337f97b4f606d06d Mon Sep 17 00:00:00 2001 From: Tomaz Vieira Date: Wed, 9 Sep 2026 12:08:42 +0000 Subject: [PATCH 12/54] Adds basic .sel test to parsed multiscale --- tests/io/test_ngff_06.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/io/test_ngff_06.py b/tests/io/test_ngff_06.py index 46c2aea75..7e4288023 100644 --- a/tests/io/test_ngff_06.py +++ b/tests/io/test_ngff_06.py @@ -42,4 +42,7 @@ def test_parse_multiscale(): expected = np.asarray(scale.scale) * indices + translate.translation assert np.allclose(expected, point) + sliced_data_tree = data_tree.sel(x=slice(0, 256), y=slice(0, 256), method="nearest") + assert sliced_data_tree.equals(data_tree) + return data_tree, ms From 3a943827a48f67b8e644a3ca969510ea93d7357a Mon Sep 17 00:00:00 2001 From: Tomaz Vieira Date: Wed, 9 Sep 2026 12:08:57 +0000 Subject: [PATCH 13/54] Removes unused class --- src/spatialdata/transformations/graph/vert.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/spatialdata/transformations/graph/vert.py b/src/spatialdata/transformations/graph/vert.py index 128c50706..c3401b76c 100644 --- a/src/spatialdata/transformations/graph/vert.py +++ b/src/spatialdata/transformations/graph/vert.py @@ -108,9 +108,6 @@ class CoordSystem: """A virtual coordinate system exists as an intermediate step between non-virtual coordinate systems and is usually ignored during serialization""" - class LegacyAxes: - pass - def __init__(self, name: str, axes: Sequence[Axis], virtual: bool = False): self.name = name self.axes = tuple(axes) From 129fd2d51beb1f901443ccbbae36fe0b76b0d807 Mon Sep 17 00:00:00 2001 From: Tomaz Vieira Date: Wed, 9 Sep 2026 12:26:17 +0000 Subject: [PATCH 14/54] Adds more slicing tests to parsed multiscales --- tests/io/test_ngff_06.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/tests/io/test_ngff_06.py b/tests/io/test_ngff_06.py index 7e4288023..222bae81e 100644 --- a/tests/io/test_ngff_06.py +++ b/tests/io/test_ngff_06.py @@ -11,9 +11,11 @@ def test_parse_multiscale(): - data = np.random.default_rng(seed=SEED).random((256, 256)) + full_shape = (256, 256) + data = np.random.default_rng(seed=SEED).random(full_shape) omz_img = OMEZarrImage(data=data, axes="yx") - ms = OMEZarrMultiscale(image=omz_img, scale_factors=(2, 4, 8, 16)) + scale_factors = (2, 4, 8, 16) + ms = OMEZarrMultiscale(image=omz_img, scale_factors=scale_factors) data_tree, _transforms = try_parse_ngff06_multiscale(ms) for xr_scale_node, ngff_scale, ngff_meta in zip( @@ -45,4 +47,8 @@ def test_parse_multiscale(): sliced_data_tree = data_tree.sel(x=slice(0, 256), y=slice(0, 256), method="nearest") assert sliced_data_tree.equals(data_tree) - return data_tree, ms + quarter_tree = data_tree.sel(x=slice(0, 128), y=slice(0, 128), method="nearest") + for xr_scale_node, scale_factor in zip(quarter_tree.children.values(), [1, *scale_factors], strict=True): + xr_scale = xr_scale_node["image"] + expected_shape = np.asarray(full_shape) / 2 / scale_factor + np.testing.assert_equal(xr_scale.shape, expected_shape) From e050c10bf840f86be06ac062c626b6bc5a4a523c Mon Sep 17 00:00:00 2001 From: Tomaz Vieira Date: Wed, 9 Sep 2026 13:07:48 +0000 Subject: [PATCH 15/54] Adds/fixes comments in try_parse_ngff06_multiscales --- src/spatialdata/_io/io_raster.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/spatialdata/_io/io_raster.py b/src/spatialdata/_io/io_raster.py index 884a39e0a..655c1da9a 100644 --- a/src/spatialdata/_io/io_raster.py +++ b/src/spatialdata/_io/io_raster.py @@ -189,9 +189,9 @@ def try_parse_ngff06_multiscale(multiscale: oz.OMEZarrMultiscale) -> tuple[DataT assert in_cs_id is not None assert out_cs_ref is not None + # FIXME: not handling references into labels yet, which use name and path in_cs_name = in_cs_id.name out_cs_name = out_cs_ref.name - # FIXME: not handling references into labels yet assert in_cs_name is not None assert out_cs_name is not None @@ -211,6 +211,11 @@ def try_parse_ngff06_multiscale(multiscale: oz.OMEZarrMultiscale) -> tuple[DataT intrinsic_cs = name_to_cs[multiscale.metadata.intrinsic_coordinate_system.name] assert transf.input is not None assert transf.input.path is not None + + # This coord system doesn't exist explicitly in the NGFF file, nor will + # it exist in our graph of transformations; It is only created here + # for the sake of creating the transformations that will be expressed + # in levels of a xr.DataTree pixel_cs = CoordSystem( name=transf.input.path, axes=[Axis(name=ax.name, type=ax.type) for ax in intrinsic_cs.axes], From 5836cd0860b94f204995e7692923af431da82d23 Mon Sep 17 00:00:00 2001 From: Tomaz Vieira Date: Wed, 9 Sep 2026 13:14:24 +0000 Subject: [PATCH 16/54] Removes to_model methods for now --- src/spatialdata/transformations/graph/edge.py | 85 ------------------- src/spatialdata/transformations/graph/vert.py | 23 ----- 2 files changed, 108 deletions(-) diff --git a/src/spatialdata/transformations/graph/edge.py b/src/spatialdata/transformations/graph/edge.py index 35370cc28..854fe18f5 100644 --- a/src/spatialdata/transformations/graph/edge.py +++ b/src/spatialdata/transformations/graph/edge.py @@ -103,10 +103,6 @@ def compose_with(self, transformation: BaseTransfEdge, name: str | None) -> Base """ return SequenceEdge(transformations=[self, transformation], name=name) - @abstractmethod - def to_model(self) -> ozm06trans.AnyTransform: - pass - class AffineEdge(BaseTransfEdge): """The Affine transformation from the NGFF specification.""" @@ -243,14 +239,6 @@ def to_affine(self, name: str | None = None) -> AffineEdge: input=self.input, output=self.output, linear=self.linear, translation=self.translation, name=name ) - def to_model(self) -> ozm06trans.Affine: - return ozm06trans.Affine( - name=self.name, - affine=tuple(tuple(row) for row in self.affine[:-1, :]), - input=self.input.to_model_cs_ident(), - output=self.output.to_model_cs_ident(), - ) - class IdentityEdge(BaseTransfEdge): """The Identity transformation from the NGFF specification.""" @@ -293,13 +281,6 @@ def to_affine(self, name: str | None = None) -> AffineEdge: name=name, ) - def to_model(self) -> ozm06trans.Identity: - return ozm06trans.Identity( - name=self.name, - input=self.input.to_model_cs_ident(), - output=self.output.to_model_cs_ident(), - ) - class MapAxisEdge(BaseTransfEdge): """The MapAxis transformation from the NGFF specification.""" @@ -353,15 +334,6 @@ def transform_points(self, points: ArrayLike) -> ArrayLike: def to_affine(self, name: str | None = None) -> AffineEdge: return AffineEdge.mapping(input=self.input, output=self.output, name=name) - def to_model(self) -> ozm06trans.MapAxis: - mapAxis: list[int] = [self.input.axes.index(out_ax) for out_ax in self.output.axes] - return ozm06trans.MapAxis( - name=self.name, - mapAxis=tuple(mapAxis), - input=self.input.to_model_cs_ident(), - output=self.output.to_model_cs_ident(), - ) - class ProjectAxisEdge(BaseTransfEdge): dropped_inputs: Final[set[Axis]] @@ -447,12 +419,6 @@ def inverse(self, name: str | None = None) -> BaseTransfEdge | None: name=name, ) - def to_model(self) -> ozm06trans.ProjectAxis: - return ozm06trans.ProjectAxis( - createdOutputs=tuple(self.output.axes.index(co) for co in self.created_outputs) or None, - droppedInputs=tuple(self.input.axes.index(di) for di in self.dropped_inputs) or None, - ) - def parse_project_axis( model: ozm06trans.ProjectAxis, @@ -536,14 +502,6 @@ def to_affine(self, name: str | None = None) -> AffineEdge: name=name, ) - def to_model(self) -> ozm06trans.Translation: - return ozm06trans.Translation( - name=self.name, - input=self.input.to_model_cs_ident(), - output=self.output.to_model_cs_ident(), - translation=tuple(self.translation), - ) - class ScaleEdge(BaseTransfEdge): """The Scale transformation from the NGFF specification.""" @@ -609,14 +567,6 @@ def to_affine(self, name: str | None = None) -> AffineEdge: name=name, ) - def to_model(self) -> ozm06trans.Scale: - return ozm06trans.Scale( - name=self.name, - input=self.input.to_model_cs_ident(), - output=self.output.to_model_cs_ident(), - scale=tuple(self.scale), - ) - class RotationEdge(BaseTransfEdge): """The Rotation transformation from the NGFF specification.""" @@ -691,14 +641,6 @@ def to_affine(self, name: str | None = None) -> AffineEdge: name=name, ) - def to_model(self) -> ozm06trans.Rotation: - return ozm06trans.Rotation( - name=self.name, - input=self.input.to_model_cs_ident(), - output=self.output.to_model_cs_ident(), - rotation=tuple(tuple(row) for row in self.rotation), - ) - class SequenceEdge(BaseTransfEdge): """The Sequence transformation from the NGFF specification.""" @@ -777,14 +719,6 @@ def to_affine(self, name: str | None = None) -> AffineEdge: def transform_points(self, points: ArrayLike) -> ArrayLike: return self.to_affine().transform_points(points) # FIXME - def to_model(self) -> ozm06trans.Sequence: - return ozm06trans.Sequence( - name=self.name, - input=self.input.to_model_cs_ident(), - output=self.output.to_model_cs_ident(), - transformations=tuple(t.to_model() for t in self.transformations), - ) - class ByDimensionEdge(BaseTransfEdge): """The ByDimension transformation from the NGFF specification.""" @@ -896,25 +830,6 @@ def to_affine(self, name: str | None = None) -> AffineEdge: name=name, ) - def to_model(self) -> ozm06trans.ByDimension: - by_dim_transfs: list[ozm06trans.ByDimensionTransform] = [] - for t in self.transformations: - input_axes = tuple(self.input.axes_names.index(ax_name) for ax_name in t.input.axes_names) - output_axes = tuple(self.output.axes_names.index(ax_name) for ax_name in t.output.axes_names) - by_dim_transfs.append( - ozm06trans.ByDimensionTransform( - input_axes=input_axes, - output_axes=output_axes, - transformation=t.to_model(), - ) - ) - return ozm06trans.ByDimension( - name=self.name, - input=self.input.to_model_cs_ident(), - output=self.output.to_model_cs_ident(), - transformations=tuple(by_dim_transfs), - ) - class CsGen: """A coordinate system generator diff --git a/src/spatialdata/transformations/graph/vert.py b/src/spatialdata/transformations/graph/vert.py index c3401b76c..1f9886b7d 100644 --- a/src/spatialdata/transformations/graph/vert.py +++ b/src/spatialdata/transformations/graph/vert.py @@ -75,15 +75,6 @@ def try_from_model(cls, model: ozm06ct.Axis) -> Axis: long_name=model.longName, ) - def to_model(self) -> ozm06ct.Axis: - return ozm06ct.Axis( - discrete=False, - longName=self.long_name, - name=self.name, - type=self.type, - unit=self.unit, - ) - class CoordSystemParsingException(Exception): pass @@ -139,20 +130,6 @@ def try_from_model_or_default[T](cls, model: ozi.CoordinateSystem | None, *, def return CoordSystem.try_from_model(model) return default - def to_model(self) -> ozi.CoordinateSystem | None: - if self.virtual: - return None - return ozi.CoordinateSystem( - name=self.name, - axes=tuple(ax.to_model() for ax in self.axes), - ) - - def to_model_cs_ident(self) -> ozi.CoordinateSystemIdentifier | None: - input = self.to_model() - if input is None: - return None - return ozi.CoordinateSystemIdentifier(name=input.name) - @property def num_axes(self) -> int: return len(self.axes) From de5718ce678137102b9581154ee79d2af09303e7 Mon Sep 17 00:00:00 2001 From: Tomaz Vieira Date: Wed, 9 Sep 2026 13:15:19 +0000 Subject: [PATCH 17/54] Moves AxisParsingException to exceptions.py --- src/spatialdata/_core/transformation_manager/exceptions.py | 4 ++++ src/spatialdata/transformations/graph/vert.py | 4 +--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/spatialdata/_core/transformation_manager/exceptions.py b/src/spatialdata/_core/transformation_manager/exceptions.py index a9dec744b..8224bd60d 100644 --- a/src/spatialdata/_core/transformation_manager/exceptions.py +++ b/src/spatialdata/_core/transformation_manager/exceptions.py @@ -285,3 +285,7 @@ def __init__(self, axis: Axis, cs: CoordSystem) -> None: self.axis = axis self.cs = cs super().__init__(f"Axis {axis.name} from coordinate system {cs.name} is not mapped to anything") + + +class AxisParsingException(Exception): + pass diff --git a/src/spatialdata/transformations/graph/vert.py b/src/spatialdata/transformations/graph/vert.py index 1f9886b7d..a6231813e 100644 --- a/src/spatialdata/transformations/graph/vert.py +++ b/src/spatialdata/transformations/graph/vert.py @@ -6,9 +6,7 @@ import ome_zarr.classes.image as ozi import ome_zarr_models.v06.coordinate_transforms as ozm06ct - -class AxisParsingException(Exception): - pass +from spatialdata._core.transformation_manager.exceptions import AxisParsingException class Axis: From d58038d77be7ee6e81ef260985b9c05616c03d33 Mon Sep 17 00:00:00 2001 From: Tomaz Vieira Date: Wed, 9 Sep 2026 13:51:41 +0000 Subject: [PATCH 18/54] Moves AxisParsingException back into vert.py to prevent circular imports --- src/spatialdata/_core/transformation_manager/exceptions.py | 4 ---- src/spatialdata/transformations/graph/vert.py | 4 +++- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/spatialdata/_core/transformation_manager/exceptions.py b/src/spatialdata/_core/transformation_manager/exceptions.py index 8224bd60d..a9dec744b 100644 --- a/src/spatialdata/_core/transformation_manager/exceptions.py +++ b/src/spatialdata/_core/transformation_manager/exceptions.py @@ -285,7 +285,3 @@ def __init__(self, axis: Axis, cs: CoordSystem) -> None: self.axis = axis self.cs = cs super().__init__(f"Axis {axis.name} from coordinate system {cs.name} is not mapped to anything") - - -class AxisParsingException(Exception): - pass diff --git a/src/spatialdata/transformations/graph/vert.py b/src/spatialdata/transformations/graph/vert.py index a6231813e..1f9886b7d 100644 --- a/src/spatialdata/transformations/graph/vert.py +++ b/src/spatialdata/transformations/graph/vert.py @@ -6,7 +6,9 @@ import ome_zarr.classes.image as ozi import ome_zarr_models.v06.coordinate_transforms as ozm06ct -from spatialdata._core.transformation_manager.exceptions import AxisParsingException + +class AxisParsingException(Exception): + pass class Axis: From 55e1ba587a5293bb8478bf5f300dfa9f29443d5a Mon Sep 17 00:00:00 2001 From: Tomaz Vieira Date: Wed, 9 Sep 2026 15:54:26 +0000 Subject: [PATCH 19/54] Fixes docstrings, list exceptions in "Raises" Co-authored-by: Claude Sonnet 5 --- src/spatialdata/transformations/graph/edge.py | 126 +++++++++++++++++- src/spatialdata/transformations/graph/vert.py | 71 +++++++--- 2 files changed, 177 insertions(+), 20 deletions(-) diff --git a/src/spatialdata/transformations/graph/edge.py b/src/spatialdata/transformations/graph/edge.py index 854fe18f5..143469112 100644 --- a/src/spatialdata/transformations/graph/edge.py +++ b/src/spatialdata/transformations/graph/edge.py @@ -55,10 +55,10 @@ def transform_points(self, points: ArrayLike) -> ArrayLike: """ Transform points (coordinates). - Notes - ------- - This function will check if the dimensionality of the input and output coordinate systems of the - transformation are compatible with the given points. + Raises + ------ + UnexpectedShapeError + if `points`'s shape is incompatible with this transformation's input shape """ @abstractmethod @@ -100,6 +100,11 @@ def compose_with(self, transformation: BaseTransfEdge, name: str | None) -> Base Notes ------- Self is applied first, then the transformation passed as argument. + + Raises + ------ + IncompatibleCoordSystemsError + if this transformation's output coordinate system doesn't match `transformation`'s input """ return SequenceEdge(transformations=[self, transformation], name=name) @@ -134,6 +139,12 @@ def __init__( Input coordinate system of the transformation. output Output coordinate system of the transformation. + + Raises + ------ + UnexpectedShapeError + if `linear`'s shape isn't (output.num_axes, input.num_axes) or + if `translation`'s shape isn't (output.num_axes,) """ num_inputs = input.num_axes num_outputs = output.num_axes @@ -189,6 +200,11 @@ def from_affine_matrix( Input coordinate system of the transformation. output Output coordinate system of the transformation. + + Raises + ------ + UnexpectedShapeError + if `affine_matrix`'s shape isn't (output.num_axes + 1, input.num_axes + 1) """ return AffineEdge( linear=affine_matrix[:-1, :-1], @@ -200,6 +216,7 @@ def from_affine_matrix( @classmethod def mapping(cls, input: CoordSystem, output: CoordSystem, name: str | None = None) -> AffineEdge: + """Create an AffineEdge that maps input axes to output axes of the same name.""" linear: ArrayLike = np.zeros((output.num_axes, input.num_axes), dtype=float) for i, des_axis in enumerate(output.axes): for j, src_axis in enumerate(input.axes): @@ -227,6 +244,14 @@ def inverse(self, name: str | None = None) -> BaseTransfEdge | None: ) def transform_points(self, points: ArrayLike) -> ArrayLike: + """ + Transform points (coordinates). + + Raises + ------ + UnexpectedShapeError + if `points`'s shape is incompatible with this transformation's input shape + """ self._validate_transform_points_shapes(points) p = np.vstack([points.T, np.ones(points.shape[0])]) q = self.affine @ p @@ -259,6 +284,11 @@ def __init__( Input coordinate system of the transformation. output Output coordinate system of the transformation. + + Raises + ------ + IncompatibleCoordSystemsError + if `input` and `output` don't have the same number of axes """ if input.num_axes != output.num_axes: raise IncompatibleCoordSystemsError( @@ -270,6 +300,14 @@ def inverse(self, name: str | None = None) -> BaseTransfEdge: return IdentityEdge(input=self.output, output=self.input, name=name) def transform_points(self, points: ArrayLike) -> ArrayLike: + """ + Transform points (coordinates). + + Raises + ------ + UnexpectedShapeError + if `points`'s shape is incompatible with this transformation's input shape + """ self._validate_transform_points_shapes(points) return points @@ -302,6 +340,11 @@ def __init__( output Output coordinate system of the transformation, whose axes must be a shuffling of `input` + + Raises + ------ + IncompatibleCoordSystemsError + if `input` and `output` don't have the same set of axes """ if set(input.axes) != set(output.axes): @@ -325,6 +368,14 @@ def inverse(self, name: str | None = None) -> BaseTransfEdge: ) def transform_points(self, points: ArrayLike) -> ArrayLike: + """ + Transform points (coordinates). + + Raises + ------ + UnexpectedShapeError + if `points`'s shape is incompatible with this transformation's input shape + """ self._validate_transform_points_shapes(points) new_indices = [self.input.axes.index(out_ax) for out_ax in self.output.axes] mapped = points[:, new_indices] @@ -401,6 +452,14 @@ def to_affine(self, name: str | None = None) -> AffineEdge: return AffineEdge(name=name, input=self.input, output=self.output, linear=linear) def transform_points(self, points: ArrayLike) -> ArrayLike: + """ + Transform points (coordinates). + + Raises + ------ + UnexpectedShapeError + if `points`'s shape is incompatible with this transformation's input shape + """ return self.to_affine().transform_points(points) def inverse(self, name: str | None = None) -> BaseTransfEdge | None: @@ -426,6 +485,7 @@ def parse_project_axis( input: CoordSystem, out: CoordSystem | CsGen, ) -> ProjectAxisEdge: + """Parse a `ProjectAxis` NGFF transformation model into a `ProjectAxisEdge`.""" if isinstance(out, CoordSystem): output = out else: @@ -470,11 +530,20 @@ def __init__( ------ IncompatibleCoordSystemsError If the input and output have different number of dimensions + UnexpectedShapeError + if `translation`'s shape doesn't match `input` """ if input.num_axes != output.num_axes: raise IncompatibleCoordSystemsError( input=input, output=output, message="Number of input and output axes must be the same" ) + expected_translation_shape = (input.num_axes,) + if translation.shape != expected_translation_shape: + raise UnexpectedShapeError( + array_name="translation", + array_shape=translation.shape, + expected_shape=expected_translation_shape, + ) self.translation = translation super().__init__(input=input, output=output, name=name) @@ -490,6 +559,14 @@ def inverse(self, name: str | None = None) -> BaseTransfEdge: ) def transform_points(self, points: ArrayLike) -> ArrayLike: + """ + Transform points (coordinates). + + Raises + ------ + UnexpectedShapeError + if `points`'s shape is incompatible with this transformation's input shape + """ self._validate_transform_points_shapes(points) return points + self.translation @@ -556,6 +633,14 @@ def inverse(self, name: str | None = None) -> ScaleEdge | None: ) def transform_points(self, points: ArrayLike) -> ArrayLike: + """ + Transform points (coordinates). + + Raises + ------ + UnexpectedShapeError + if `points`'s shape is incompatible with this transformation's input shape + """ self._validate_transform_points_shapes(points) return points * self.scale @@ -628,6 +713,14 @@ def inverse(self, name: str | None = None) -> BaseTransfEdge: ) def transform_points(self, points: ArrayLike) -> ArrayLike: + """ + Transform points (coordinates). + + Raises + ------ + UnexpectedShapeError + if `points`'s shape is incompatible with this transformation's input shape + """ self._validate_transform_points_shapes(points) res = (self.rotation @ points.T).T assert isinstance(res, np.ndarray) @@ -717,6 +810,14 @@ def to_affine(self, name: str | None = None) -> AffineEdge: ) def transform_points(self, points: ArrayLike) -> ArrayLike: + """ + Transform points (coordinates). + + Raises + ------ + UnexpectedShapeError + if `points`'s shape is incompatible with this transformation's input shape + """ return self.to_affine().transform_points(points) # FIXME @@ -799,6 +900,14 @@ def inverse(self, name: str | None = None) -> BaseTransfEdge | None: ) def transform_points(self, points: ArrayLike) -> ArrayLike: + """ + Transform points (coordinates). + + Raises + ------ + UnexpectedShapeError + if `points`'s shape is incompatible with this transformation's input shape + """ input_axes = self.input.axes_names output_axes = self.output.axes_names self._validate_transform_points_shapes(points) @@ -882,6 +991,7 @@ def parse_identity( input: CoordSystem, out: CoordSystem | CsGen, ) -> IdentityEdge: + """Parse an `Identity` NGFF transformation model into an `IdentityEdge`.""" output = out.generate_like(input) if isinstance(out, CsGen) else out return IdentityEdge(name=model.name, input=input, output=output) @@ -892,6 +1002,7 @@ def parse_translation( input: CoordSystem, out: CoordSystem | CsGen, ) -> TranslationEdge: + """Parse a `Translation` NGFF transformation model into a `TranslationEdge`.""" output = out.generate_like(input) if isinstance(out, CsGen) else out return TranslationEdge( translation=np.asarray(model.translation, dtype=float), @@ -907,6 +1018,7 @@ def parse_scale( input: CoordSystem, out: CoordSystem | CsGen, ) -> ScaleEdge: + """Parse a `Scale` NGFF transformation model into a `ScaleEdge`.""" output = out.generate_like(input) if isinstance(out, CsGen) else out return ScaleEdge( scale=np.asarray(model.scale, dtype=float), @@ -922,6 +1034,7 @@ def parse_map_axis( input: CoordSystem, out: CoordSystem | CsGen, ) -> MapAxisEdge: + """Parse a `MapAxis` NGFF transformation model into a `MapAxisEdge`.""" if isinstance(out, CoordSystem): output = out else: @@ -944,6 +1057,7 @@ def parse_affine( input: CoordSystem, output: CoordSystem | CsGen, ) -> AffineEdge: + """Parse an `Affine` NGFF transformation model into an `AffineEdge`.""" num_output_axes = len(model.affine_matrix) # spec doesn't save last row output = output.generate(num_axes=num_output_axes) if isinstance(output, CsGen) else output affine_array = np.asarray(model.affine_matrix, dtype=float) @@ -958,6 +1072,7 @@ def parse_rotation( input: CoordSystem, out: CoordSystem | CsGen, ) -> RotationEdge: + """Parse a `Rotation` NGFF transformation model into a `RotationEdge`.""" num_output_axes = len(model.rotation_matrix) output = out.generate(num_axes=num_output_axes) if isinstance(out, CsGen) else out return RotationEdge( @@ -974,6 +1089,7 @@ def parse_sequence( input: CoordSystem, output: CoordSystem | CsGen, ) -> SequenceEdge: + """Parse a `Sequence` NGFF transformation model into a `SequenceEdge`.""" parsed_inners: list[BaseTransfEdge] = [] base_name = "intermediate" + ("" if not model.name else f"_for_{model.name}") @@ -1002,6 +1118,7 @@ def parse_by_dimension( input: CoordSystem, output: CoordSystem | CsGen, ) -> ByDimensionEdge: + """Parse a `ByDimension` NGFF transformation model into a `ByDimensionEdge`.""" if not isinstance(output, CoordSystem): max_out_idx = max(ax_idx for t in model.transformations for ax_idx in t.output_axes) output = output.generate(num_axes=max_out_idx + 1) @@ -1038,6 +1155,7 @@ def parse_ngff_transf( model: ozm06trans.AnyTransform, output: CoordSystem | CsGen, ) -> BaseTransfEdge: + """Parse an NGFF coordinate transformation model into a `BaseTransfEdge`""" if isinstance(model, ozm06trans.Identity): return parse_identity(model, input=input, out=output) elif isinstance(model, ozm06trans.Translation): diff --git a/src/spatialdata/transformations/graph/vert.py b/src/spatialdata/transformations/graph/vert.py index 1f9886b7d..177a8d8a9 100644 --- a/src/spatialdata/transformations/graph/vert.py +++ b/src/spatialdata/transformations/graph/vert.py @@ -61,6 +61,15 @@ def __eq__(self, value: object, /) -> bool: @classmethod def try_from_model(cls, model: ozm06ct.Axis) -> Axis: + """ + Parse an `Axis` from an ome-zarr-models axis model. + + Raises + ------ + AxisParsingException + if `model` doesn't have a name, has a type other than "channel" or "space", or has a unit + that isn't a string or None + """ name = model.name if name is None: raise AxisParsingException("Axis doesn't have a name") @@ -80,16 +89,15 @@ class CoordSystemParsingException(Exception): pass +class DuplicateAxisNameError(Exception): + def __init__(self, *, axis_name: str) -> None: + self.axis_name = axis_name + super().__init__(f"Axis name '{axis_name}' is used more than once") + + class CoordSystem: """ - Representation of a coordinate system, following the NGFF specification. - - Parameters - ---------- - name - name of the coordinate system - axes - names of the axes of the coordinate system + Representation of a coordinate system. """ name: Final[str] @@ -100,11 +108,29 @@ class CoordSystem: non-virtual coordinate systems and is usually ignored during serialization""" def __init__(self, name: str, axes: Sequence[Axis], virtual: bool = False): + """ + Parameters + ---------- + name + name of the coordinate system + axes + axes of the coordinate system + virtual + vitual coordinate systems don't serialize to NGFF + + Raises + ------ + DuplicateAxisNameError + if `axes` contains axes with duplicate names + """ self.name = name self.axes = tuple(axes) self.virtual = virtual - if len(self.axes) != len({axis.name for axis in self.axes}): - raise ValueError("Axes names must be unique") + seen_names: set[str] = set() + for axis in self.axes: + if axis.name in seen_names: + raise DuplicateAxisNameError(axis_name=axis.name) + seen_names.add(axis.name) def __repr__(self) -> str: return f"{self.__class__.__name__}({self.name!r}, {self.axes})" @@ -114,18 +140,31 @@ def __hash__(self) -> int: @classmethod def try_from_model(cls, model: ozi.CoordinateSystem) -> CoordSystem: - axes: list[Axis] = [] - for axis in model.axes: - if isinstance(parsed := Axis.try_from_model(axis), Exception): - raise CoordSystemParsingException(parsed) # FIXME - axes.append(parsed) + """ + Parse a `CoordSystem` from an ome-zarr-models coordinate system model. + + Raises + ------ + AxisParsingException + if any axis in `model.axes` fails to parse + DuplicateAxisNameError + if `model.axes` contains axes with duplicate names + """ return CoordSystem( name=model.name, - axes=axes, + axes=[Axis.try_from_model(axis) for axis in model.axes], ) @classmethod def try_from_model_or_default[T](cls, model: ozi.CoordinateSystem | None, *, default: T) -> CoordSystem | T: + """ + Parse a `CoordSystem` from `model`, or return `default` if `model` is None. + + Raises + ------ + AxisParsingException + if `model` is not None and any of its axes fails to parse + """ if model is not None: return CoordSystem.try_from_model(model) return default From d802aa83c66984a819c772ed84e760e09275b1fd Mon Sep 17 00:00:00 2001 From: Tomaz Vieira Date: Wed, 9 Sep 2026 16:03:39 +0000 Subject: [PATCH 20/54] Incorporates parse_project_axis --- src/spatialdata/transformations/graph/edge.py | 48 +++++++++---------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/src/spatialdata/transformations/graph/edge.py b/src/spatialdata/transformations/graph/edge.py index 143469112..d21fbef7d 100644 --- a/src/spatialdata/transformations/graph/edge.py +++ b/src/spatialdata/transformations/graph/edge.py @@ -479,30 +479,6 @@ def inverse(self, name: str | None = None) -> BaseTransfEdge | None: ) -def parse_project_axis( - model: ozm06trans.ProjectAxis, - *, - input: CoordSystem, - out: CoordSystem | CsGen, -) -> ProjectAxisEdge: - """Parse a `ProjectAxis` NGFF transformation model into a `ProjectAxisEdge`.""" - if isinstance(out, CoordSystem): - output = out - else: - num_dropped_inputs = len(model.droppedInputs or ()) - num_created_outputs = len(model.createdOutputs or ()) - num_output_axes = input.num_axes - num_dropped_inputs + num_created_outputs - output = out.generate(num_axes=num_output_axes) - - return ProjectAxisEdge( - created_outputs={output.axes[i] for i in model.createdOutputs or ()}, - dropped_inputs={input.axes[i] for i in model.droppedInputs or ()}, - input=input, - output=output, - name=model.name, - ) - - class TranslationEdge(BaseTransfEdge): """The Translation transformation from the NGFF specification.""" @@ -1012,6 +988,28 @@ def parse_translation( ) +def parse_project_axis( + model: ozm06trans.ProjectAxis, + *, + input: CoordSystem, + output: CoordSystem | CsGen, +) -> ProjectAxisEdge: + """Parse a `ProjectAxis` NGFF transformation model into a `ProjectAxisEdge`.""" + if isinstance(output, CsGen): + num_dropped_inputs = len(model.droppedInputs or ()) + num_created_outputs = len(model.createdOutputs or ()) + num_output_axes = input.num_axes - num_dropped_inputs + num_created_outputs + output = output.generate(num_axes=num_output_axes) + + return ProjectAxisEdge( + created_outputs={output.axes[i] for i in model.createdOutputs or ()}, + dropped_inputs={input.axes[i] for i in model.droppedInputs or ()}, + input=input, + output=output, + name=model.name, + ) + + def parse_scale( model: ozm06trans.Scale, *, @@ -1172,5 +1170,7 @@ def parse_ngff_transf( return parse_sequence(model, input=input, output=output) elif isinstance(model, ozm06trans.ByDimension): return parse_by_dimension(model, input=input, output=output) + elif isinstance(model, ozm06trans.ProjectAxis): + return parse_project_axis(model, input=input, output=output) else: raise NotImplementedError(f"Unsupported transformation: {model.type}") From c18df09d0eebb782808e8e839312247e88c232b9 Mon Sep 17 00:00:00 2001 From: Tomaz Vieira Date: Wed, 9 Sep 2026 16:18:46 +0000 Subject: [PATCH 21/54] Fixes parsing translatiion model getting bad name Co-authored-by: Claude Sonnet 5 --- src/spatialdata/transformations/graph/edge.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/spatialdata/transformations/graph/edge.py b/src/spatialdata/transformations/graph/edge.py index d21fbef7d..74a277aca 100644 --- a/src/spatialdata/transformations/graph/edge.py +++ b/src/spatialdata/transformations/graph/edge.py @@ -984,7 +984,7 @@ def parse_translation( translation=np.asarray(model.translation, dtype=float), input=input, output=output, - name=input.name, + name=model.name, ) From c10c7583d6240516466c55e858a42e39149ebf25 Mon Sep 17 00:00:00 2001 From: Tomaz Vieira Date: Wed, 9 Sep 2026 16:29:37 +0000 Subject: [PATCH 22/54] Adds test for parsing *Edge transforms from ngff Co-authored-by: Claude Sonnet 5 --- .../transformations/edge/test_edge_parsing.py | 226 ++++++++++++++++++ 1 file changed, 226 insertions(+) create mode 100644 tests/transformations/edge/test_edge_parsing.py diff --git a/tests/transformations/edge/test_edge_parsing.py b/tests/transformations/edge/test_edge_parsing.py new file mode 100644 index 000000000..5f95a4d61 --- /dev/null +++ b/tests/transformations/edge/test_edge_parsing.py @@ -0,0 +1,226 @@ +# pyright: strict + +from __future__ import annotations + +import numpy as np +import ome_zarr_models.v06.coordinate_transforms as ozm06trans + +from spatialdata.transformations.graph.edge import ( + AffineEdge, + ByDimensionEdge, + CsGen, + MapAxisEdge, + RotationEdge, + ScaleEdge, + SequenceEdge, + TranslationEdge, + parse_affine, + parse_by_dimension, + parse_identity, + parse_map_axis, + parse_project_axis, + parse_rotation, + parse_scale, + parse_sequence, + parse_translation, +) +from tests.transformations.edge.conftest import ( + xy_cs, + xyz_cs, + yx_cs, + z_axis, +) + +# The ome-zarr-models transform models used below all leave `input`/`output` at their +# default of None: those fields identify coordinate systems by name/path in the NGFF +# metadata and aren't consumed by the parse_* functions, which instead take the already +# resolved `CoordSystem` (or a `CsGen`) as separate arguments. Only the transform-specific +# fields (e.g. `translation`, `scale`, `name`) are exercised here. + +POINTS_2D = np.array([[1.0, 2.0], [3.0, 4.0]]) + + +class TestParseIdentity: + def test_parses_name(self): + model = ozm06trans.Identity(name="my-identity") + edge = parse_identity(model, input=xy_cs, out=xy_cs) + assert edge.name == "my-identity" + assert edge.input is xy_cs + assert edge.output is xy_cs + + def test_csgen_output_mirrors_input(self): + model = ozm06trans.Identity(name="my-identity") + edge = parse_identity(model, input=xy_cs, out=CsGen(base_name="gen")) + assert edge.output.axes_names == xy_cs.axes_names + assert edge.output.virtual is True + + +class TestParseTranslation: + def test_parses_translation_vector(self): + model = ozm06trans.Translation(name="my-translation", translation=(1.0, 2.0)) + edge = parse_translation(model, input=xy_cs, out=xy_cs) + np.testing.assert_allclose(edge.translation, [1.0, 2.0]) + assert edge.name == model.name + assert edge.input is xy_cs + assert edge.output is xy_cs + + def test_csgen_output_mirrors_input(self): + model = ozm06trans.Translation(translation=(1.0, 2.0)) + edge = parse_translation(model, input=xy_cs, out=CsGen(base_name="gen")) + assert edge.output.axes_names == xy_cs.axes_names + assert edge.output.virtual is True + + +class TestParseProjectAxis: + def test_parses_dropped_inputs(self): + model = ozm06trans.ProjectAxis(name="drop-z", droppedInputs=(2,)) + edge = parse_project_axis(model, input=xyz_cs, output=xy_cs) + assert edge.dropped_inputs == {z_axis} + assert edge.created_outputs == set() + assert edge.name == "drop-z" + + def test_parses_created_outputs(self): + model = ozm06trans.ProjectAxis(name="create-z", createdOutputs=(2,)) + edge = parse_project_axis(model, input=xy_cs, output=xyz_cs) + assert edge.created_outputs == {z_axis} + assert edge.dropped_inputs == set() + + def test_csgen_output_computes_dimensionality(self): + model = ozm06trans.ProjectAxis(droppedInputs=(2,)) + edge = parse_project_axis(model, input=xyz_cs, output=CsGen(base_name="gen")) + assert edge.output.num_axes == 2 + assert edge.dropped_inputs == {z_axis} + + +class TestParseScale: + def test_parses_scale_vector_and_name(self): + model = ozm06trans.Scale(name="my-scale", scale=(2.0, 3.0)) + edge = parse_scale(model, input=xy_cs, out=xy_cs) + assert isinstance(edge, ScaleEdge) + np.testing.assert_allclose(edge.scale, [2.0, 3.0]) + assert edge.name == "my-scale" + + def test_csgen_output_mirrors_input(self): + model = ozm06trans.Scale(scale=(2.0, 3.0)) + edge = parse_scale(model, input=xy_cs, out=CsGen(base_name="gen")) + assert edge.output.axes_names == xy_cs.axes_names + assert edge.output.virtual is True + + +class TestParseMapAxis: + def test_parses_with_explicit_output(self): + model = ozm06trans.MapAxis(name="my-map-axis", mapAxis=(1, 0)) + edge = parse_map_axis(model, input=xy_cs, out=yx_cs) + assert isinstance(edge, MapAxisEdge) + assert edge.name == "my-map-axis" + assert edge.output is yx_cs + np.testing.assert_allclose(edge.transform_points(POINTS_2D), np.array([[2.0, 1.0], [4.0, 3.0]])) + + def test_csgen_output_is_permuted_input_axes(self): + model = ozm06trans.MapAxis(mapAxis=(1, 0)) + edge = parse_map_axis(model, input=xy_cs, out=CsGen(base_name="gen")) + assert edge.output.axes_names == yx_cs.axes_names + assert edge.output.virtual is True + + +class TestParseAffine: + def test_parses_linear_translation_and_name(self): + model = ozm06trans.Affine(name="my-affine", affine=((2.0, 0.0, 1.0), (0.0, 3.0, 5.0))) + edge = parse_affine(model, input=xy_cs, output=xy_cs) + assert isinstance(edge, AffineEdge) + np.testing.assert_allclose(edge.linear, [[2.0, 0.0], [0.0, 3.0]]) + np.testing.assert_allclose(edge.translation, [1.0, 5.0]) + assert edge.name == "my-affine" + + def test_csgen_output_derived_from_matrix_row_count(self): + model = ozm06trans.Affine(affine=((2.0, 0.0, 1.0), (0.0, 3.0, 5.0))) + edge = parse_affine(model, input=xy_cs, output=CsGen(base_name="gen")) + assert edge.output.num_axes == 2 + assert edge.output.virtual is True + + +class TestParseRotation: + def test_parses_rotation_matrix_and_name(self): + model = ozm06trans.Rotation(name="my-rotation", rotation=((0.0, -1.0), (1.0, 0.0))) + edge = parse_rotation(model, input=xy_cs, out=xy_cs) + assert isinstance(edge, RotationEdge) + np.testing.assert_allclose(edge.rotation, [[0.0, -1.0], [1.0, 0.0]]) + assert edge.name == "my-rotation" + + def test_csgen_output_derived_from_matrix_row_count(self): + model = ozm06trans.Rotation(rotation=((0.0, -1.0), (1.0, 0.0))) + edge = parse_rotation(model, input=xy_cs, out=CsGen(base_name="gen")) + assert edge.output.num_axes == 2 + assert edge.output.virtual is True + + +class TestParseSequence: + def test_parses_name_and_composes_inner_transformations_in_order(self): + inner_translation = ozm06trans.Translation(translation=(1.0, 2.0)) + inner_scale = ozm06trans.Scale(scale=(2.0, 3.0)) + model = ozm06trans.Sequence(name="my-sequence", transformations=(inner_translation, inner_scale)) + edge = parse_sequence(model, input=xy_cs, output=xy_cs) + assert isinstance(edge, SequenceEdge) + assert edge.name == "my-sequence" + assert edge.input is xy_cs + assert edge.output is xy_cs + assert [type(t) for t in edge.transformations] == [TranslationEdge, ScaleEdge] + expected = (POINTS_2D + np.array([1.0, 2.0])) * np.array([2.0, 3.0]) + np.testing.assert_allclose(edge.transform_points(POINTS_2D), expected) + + def test_single_inner_transformation_uses_given_output_directly(self): + inner_translation = ozm06trans.Translation(translation=(1.0, 2.0)) + model = ozm06trans.Sequence(transformations=(inner_translation,)) + edge = parse_sequence(model, input=xy_cs, output=xy_cs) + assert len(edge.transformations) == 1 + assert edge.transformations[0].output is xy_cs + + def test_multiple_inner_transformations_use_virtual_intermediate_coord_systems(self): + inner_translation = ozm06trans.Translation(translation=(1.0, 2.0)) + inner_scale = ozm06trans.Scale(scale=(2.0, 3.0)) + model = ozm06trans.Sequence(transformations=(inner_translation, inner_scale)) + edge = parse_sequence(model, input=xy_cs, output=xy_cs) + intermediate = edge.transformations[0].output + assert intermediate is not xy_cs + assert intermediate.virtual is True + assert intermediate.axes_names == xy_cs.axes_names + assert edge.transformations[1].output is xy_cs + + def test_csgen_output_mirrors_input(self): + inner_translation = ozm06trans.Translation(translation=(1.0, 2.0)) + inner_scale = ozm06trans.Scale(scale=(2.0, 3.0)) + model = ozm06trans.Sequence(transformations=(inner_translation, inner_scale)) + edge = parse_sequence(model, input=xy_cs, output=CsGen(base_name="gen")) + assert edge.output.axes_names == xy_cs.axes_names + assert edge.output.virtual is True + + +class TestParseByDimension: + def test_parses_name_and_splits_axes_between_inner_transformations(self): + scale_x = ozm06trans.Scale(scale=(2.0,)) + translate_y = ozm06trans.Translation(translation=(5.0,)) + model = ozm06trans.ByDimension( + name="my-by-dim", + transformations=( + ozm06trans.ByDimensionTransform(transformation=scale_x, input_axes=(0,), output_axes=(0,)), + ozm06trans.ByDimensionTransform(transformation=translate_y, input_axes=(1,), output_axes=(1,)), + ), + ) + edge = parse_by_dimension(model, input=xy_cs, output=xy_cs) + assert isinstance(edge, ByDimensionEdge) + assert edge.name == "my-by-dim" + assert [type(t) for t in edge.transformations] == [ScaleEdge, TranslationEdge] + np.testing.assert_allclose(edge.transform_points(POINTS_2D), np.array([[2.0, 7.0], [6.0, 9.0]])) + + def test_csgen_output_derived_from_max_output_axis_index(self): + scale_x = ozm06trans.Scale(scale=(2.0,)) + translate_y = ozm06trans.Translation(translation=(5.0,)) + model = ozm06trans.ByDimension( + transformations=( + ozm06trans.ByDimensionTransform(transformation=scale_x, input_axes=(0,), output_axes=(0,)), + ozm06trans.ByDimensionTransform(transformation=translate_y, input_axes=(1,), output_axes=(1,)), + ), + ) + edge = parse_by_dimension(model, input=xy_cs, output=CsGen(base_name="gen")) + assert edge.output.num_axes == 2 + assert edge.output.virtual is True From b6d4a66f0afb456ad727862a3a3ddb6816c6e414 Mon Sep 17 00:00:00 2001 From: Tomaz Vieira Date: Fri, 11 Sep 2026 08:17:09 +0000 Subject: [PATCH 23/54] Fixes dependencies on ome-zarr and ome-zarr-models --- pyproject.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 400d884b1..04947e348 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,8 @@ dependencies = [ "networkx", "numba>=0.55", "numpy", - "ome-zarr>=0.18.0", + "ome-zarr>=0.19.2", + "ome-zarr-models>=1.8", "pandas", "pooch", "pyarrow", From ec7f22bd0e9542ad56d5caed77c5533f050804e6 Mon Sep 17 00:00:00 2001 From: Tomaz Vieira Date: Fri, 11 Sep 2026 09:57:01 +0000 Subject: [PATCH 24/54] Cleans up transform_points for rotation and affine --- src/spatialdata/_io/io_raster.py | 16 +- src/spatialdata/transformations/graph/edge.py | 147 ++++++++++-------- src/spatialdata/transformations/graph/vert.py | 7 +- 3 files changed, 97 insertions(+), 73 deletions(-) diff --git a/src/spatialdata/_io/io_raster.py b/src/spatialdata/_io/io_raster.py index 655c1da9a..0b336999d 100644 --- a/src/spatialdata/_io/io_raster.py +++ b/src/spatialdata/_io/io_raster.py @@ -34,7 +34,7 @@ RasterFormatType, get_ome_zarr_format, ) -from spatialdata._types import ELEMENT_TYPE, ELEMENT_TYPE_RASTER, GROUP_NAME +from spatialdata._types import ELEMENT_TYPE, ELEMENT_TYPE_RASTER from spatialdata._utils import get_pyramid_levels from spatialdata.models.models import ATTRS_KEY from spatialdata.models.pyramids_utils import dask_arrays_to_datatree @@ -44,7 +44,7 @@ _set_transformations, compute_coordinates, ) -from spatialdata.transformations.graph.edge import BaseTransfEdge, parse_ngff_transf +from spatialdata.transformations.graph.edge import BaseTransformationEdge, parse_ngff_transf from spatialdata.transformations.graph.vert import Axis, CoordSystem @@ -168,20 +168,20 @@ def _prepare_storage_options( return prepared_options -def try_read_ngff06_multiscale(store: Path) -> tuple[DataTree, Sequence[BaseTransfEdge]]: +def try_read_ngff06_multiscale(store: Path) -> tuple[DataTree, Sequence[BaseTransformationEdge]]: multiscale = oz.OMEZarrMultiscale.from_ome_zarr(str(store)) assert isinstance(multiscale, oz.OMEZarrMultiscale) # disambiguate from OMEZarrLabel return try_parse_ngff06_multiscale(multiscale) -def try_parse_ngff06_multiscale(multiscale: oz.OMEZarrMultiscale) -> tuple[DataTree, Sequence[BaseTransfEdge]]: +def try_parse_ngff06_multiscale(multiscale: oz.OMEZarrMultiscale) -> tuple[DataTree, Sequence[BaseTransformationEdge]]: """Parse an OMEZarMultiscale into a DataTree and collects Multiscale-level transforms.""" name_to_cs: dict[str, CoordSystem] = {} for cs in multiscale.metadata.coordinateSystems or (): parsed_cs = CoordSystem.try_from_model(cs) name_to_cs[cs.name] = parsed_cs - parsed_transfs: list[BaseTransfEdge] = [] + parsed_transfs: list[BaseTransformationEdge] = [] for transf in multiscale.metadata.coordinateTransformations or (): in_cs_id = transf.input out_cs_ref = transf.output @@ -364,9 +364,7 @@ def _get_multiscale_nodes(image_nodes: list[Node], nodes: list[Node]) -> list[No return nodes -def _get_raster_element_group( - raster_type: ELEMENT_TYPE_RASTER, group: zarr.Group, element_name: str -) -> zarr.Group: +def _get_raster_element_group(raster_type: ELEMENT_TYPE_RASTER, group: zarr.Group, element_name: str) -> zarr.Group: """Get the Zarr group holding a raster element that has just been written. Labels are nested one level deeper than images: ome-zarr writes them inside a "labels" group, so for them the @@ -385,7 +383,7 @@ def _get_raster_element_group( ------- The Zarr group of the raster element. """ - if raster_type != "labels": + if raster_type != ELEMENT_TYPE.LABELS: return group labels_group = group["labels"] if not isinstance(labels_group, zarr.Group): diff --git a/src/spatialdata/transformations/graph/edge.py b/src/spatialdata/transformations/graph/edge.py index 74a277aca..2201b257b 100644 --- a/src/spatialdata/transformations/graph/edge.py +++ b/src/spatialdata/transformations/graph/edge.py @@ -3,7 +3,7 @@ from __future__ import annotations from abc import ABC, abstractmethod -from collections.abc import Sequence +from collections.abc import Mapping, Sequence from typing import Final import numpy as np @@ -22,7 +22,7 @@ from spatialdata.transformations.graph.vert import Axis, CoordSystem -class BaseTransfEdge(ABC): +class BaseTransformationEdge(ABC): """Base class for all the transformations defined by the NGFF specification.""" input: Final[CoordSystem] @@ -47,7 +47,7 @@ def __repr__(self) -> str: return f"{type(self).__name__} ({domain} -> {codomain})" @abstractmethod - def inverse(self, name: str | None = None) -> BaseTransfEdge | None: + def inverse(self, name: str | None = None) -> BaseTransformationEdge | None: """Return the inverse of the transformation if it exists""" @abstractmethod @@ -84,7 +84,7 @@ def _validate_transform_points_shapes(self, points: ArrayLike) -> None: ) # order of the composition: self is applied first, then the transformation passed as argument - def compose_with(self, transformation: BaseTransfEdge, name: str | None) -> BaseTransfEdge: + def compose_with(self, transformation: BaseTransformationEdge, name: str | None) -> BaseTransformationEdge: """ Compose the transfomation object with another transformation @@ -109,7 +109,7 @@ def compose_with(self, transformation: BaseTransfEdge, name: str | None) -> Base return SequenceEdge(transformations=[self, transformation], name=name) -class AffineEdge(BaseTransfEdge): +class AffineEdge(BaseTransformationEdge): """The Affine transformation from the NGFF specification.""" linear: Final[ArrayLike] @@ -229,7 +229,7 @@ def mapping(cls, input: CoordSystem, output: CoordSystem, name: str | None = Non name=name, ) - def inverse(self, name: str | None = None) -> BaseTransfEdge | None: + def inverse(self, name: str | None = None) -> BaseTransformationEdge | None: try: # FIXME: I think there are more efficient/precise ways to invert a matrix inv = np.linalg.inv(self.affine) @@ -253,11 +253,7 @@ def transform_points(self, points: ArrayLike) -> ArrayLike: if `points`'s shape is incompatible with this transformation's input shape """ self._validate_transform_points_shapes(points) - p = np.vstack([points.T, np.ones(points.shape[0])]) - q = self.affine @ p - res = q[: self.output.num_axes, :].T - assert isinstance(res, np.ndarray) - return res + return (points @ self.linear.T) + self.translation def to_affine(self, name: str | None = None) -> AffineEdge: return AffineEdge( @@ -265,7 +261,7 @@ def to_affine(self, name: str | None = None) -> AffineEdge: ) -class IdentityEdge(BaseTransfEdge): +class IdentityEdge(BaseTransformationEdge): """The Identity transformation from the NGFF specification.""" def __init__( @@ -296,7 +292,7 @@ def __init__( ) super().__init__(input=input, output=output, name=name) - def inverse(self, name: str | None = None) -> BaseTransfEdge: + def inverse(self, name: str | None = None) -> BaseTransformationEdge: return IdentityEdge(input=self.output, output=self.input, name=name) def transform_points(self, points: ArrayLike) -> ArrayLike: @@ -313,14 +309,14 @@ def transform_points(self, points: ArrayLike) -> ArrayLike: def to_affine(self, name: str | None = None) -> AffineEdge: return AffineEdge( - linear=np.eye(self.input.num_axes), + linear=np.identity(self.input.num_axes), input=self.input, output=self.output, name=name, ) -class MapAxisEdge(BaseTransfEdge): +class MapAxisEdge(BaseTransformationEdge): """The MapAxis transformation from the NGFF specification.""" def __init__( @@ -329,6 +325,7 @@ def __init__( name: str | None = None, input: CoordSystem, output: CoordSystem, + input_to_output: Mapping[Axis, Axis], ) -> None: """ Parameters @@ -338,32 +335,52 @@ def __init__( input Input coordinate system of the transformation. output - Output coordinate system of the transformation, whose axes - must be a shuffling of `input` + Output coordinate system of the transformation. Must + have the same number of axes as `input` + input_to_output + A mapping from Raises ------ IncompatibleCoordSystemsError - if `input` and `output` don't have the same set of axes + if `input` and `output` don't have the number of axes """ - if set(input.axes) != set(output.axes): + if input.num_axes != output.num_axes: raise IncompatibleCoordSystemsError( - input=input, output=output, message="Input and output must have the same axes" + input=input, output=output, message="Input and output must have the same number of axes" ) + + unmapped_inputs = set(input.axes) + unmapped_outputs = set(output.axes) + for inp_ax, out_ax in input_to_output.items(): + try: + unmapped_inputs.remove(inp_ax) + except KeyError as e: + raise MissingAxisError(axis=inp_ax, cs=self.input) from e + try: + unmapped_outputs.remove(out_ax) + except KeyError as e: + raise MissingAxisError(axis=out_ax, cs=self.output) from e + + for ax in unmapped_inputs: + raise UnmappedAxisError(axis=ax, cs=self.input) + for ax in unmapped_outputs: + raise UnmappedAxisError(axis=ax, cs=self.output) + + self.input_to_output = input_to_output super().__init__(input=input, output=output, name=name) def __repr__(self) -> str: s = super().__repr__() + "\n" - s += "\n".join( - f" {out.name} <- {inp.name}\n" for out, inp in zip(self.output.axes, self.input.axes, strict=True) - ) + s += "\n".join(f" {out.name} <- {inp.name}\n" for out, inp in self.input_to_output.items()) return s - def inverse(self, name: str | None = None) -> BaseTransfEdge: + def inverse(self, name: str | None = None) -> BaseTransformationEdge: return MapAxisEdge( input=self.output, output=self.input, + input_to_output={out: inp for inp, out in self.input_to_output.items()}, name=name, ) @@ -377,16 +394,23 @@ def transform_points(self, points: ArrayLike) -> ArrayLike: if `points`'s shape is incompatible with this transformation's input shape """ self._validate_transform_points_shapes(points) - new_indices = [self.input.axes.index(out_ax) for out_ax in self.output.axes] + output_to_input = {out_ax: in_ax for in_ax, out_ax in self.input_to_output.items()} + new_indices = [self.input.axes.index(output_to_input[out_ax]) for out_ax in self.output.axes] + assert len(new_indices) == self.output.num_axes mapped = points[:, new_indices] assert isinstance(mapped, np.ndarray) return mapped def to_affine(self, name: str | None = None) -> AffineEdge: - return AffineEdge.mapping(input=self.input, output=self.output, name=name) + linear = np.zeros((self.output.num_axes, self.input.num_axes)) + for inp_ax, out_ax in self.input_to_output.items(): + inp_idx = self.input.axes.index(inp_ax) + out_idx = self.output.axes.index(out_ax) + linear[out_idx, inp_idx] = 1 + return AffineEdge(linear=linear, input=self.input, output=self.output) -class ProjectAxisEdge(BaseTransfEdge): +class ProjectAxisEdge(BaseTransformationEdge): dropped_inputs: Final[set[Axis]] created_outputs: Final[set[Axis]] @@ -462,7 +486,7 @@ def transform_points(self, points: ArrayLike) -> ArrayLike: """ return self.to_affine().transform_points(points) - def inverse(self, name: str | None = None) -> BaseTransfEdge | None: + def inverse(self, name: str | None = None) -> BaseTransformationEdge | None: # FIXME: there may be other cases where this is invertible if self.input.num_axes != self.output.num_axes: return None @@ -479,7 +503,7 @@ def inverse(self, name: str | None = None) -> BaseTransfEdge | None: ) -class TranslationEdge(BaseTransfEdge): +class TranslationEdge(BaseTransformationEdge): """The Translation transformation from the NGFF specification.""" def __init__( @@ -526,7 +550,7 @@ def __init__( def __repr__(self) -> str: return super().__repr__() + str(self.translation) - def inverse(self, name: str | None = None) -> BaseTransfEdge: + def inverse(self, name: str | None = None) -> BaseTransformationEdge: return TranslationEdge( translation=-self.translation, input=self.output, @@ -556,7 +580,7 @@ def to_affine(self, name: str | None = None) -> AffineEdge: ) -class ScaleEdge(BaseTransfEdge): +class ScaleEdge(BaseTransformationEdge): """The Scale transformation from the NGFF specification.""" def __init__( @@ -629,7 +653,7 @@ def to_affine(self, name: str | None = None) -> AffineEdge: ) -class RotationEdge(BaseTransfEdge): +class RotationEdge(BaseTransformationEdge): """The Rotation transformation from the NGFF specification.""" rotation: Final[ArrayLike] @@ -646,7 +670,7 @@ def __init__( Parameters ---------- linear_matrix - an array of shape (output.num_axes, input.num_axes) representing the rotation + And orthonormal matrix of shape (output.num_axes, input.num_axes) input Input coordinate system of the transformation. output @@ -657,8 +681,10 @@ def __init__( if linear_matrix's shape isn't (output.num_axes, input.num_axes) IncompatibleCoordSystemsError if input and output don't have the same number of axes - NotUnimodularError + DeterminantDifferentFromOne if linear_matrix doesn't have determinant ~= 1 + NotOrthonormal + if linear_matrix is not orthonormal """ if input.num_axes != output.num_axes: raise IncompatibleCoordSystemsError( @@ -680,7 +706,7 @@ def __repr__(self) -> str: s += "\n".join(str(row) for row in self.rotation) return s - def inverse(self, name: str | None = None) -> BaseTransfEdge: + def inverse(self, name: str | None = None) -> BaseTransformationEdge: return RotationEdge( linear_matrix=self.rotation.T, input=self.output, @@ -698,9 +724,7 @@ def transform_points(self, points: ArrayLike) -> ArrayLike: if `points`'s shape is incompatible with this transformation's input shape """ self._validate_transform_points_shapes(points) - res = (self.rotation @ points.T).T - assert isinstance(res, np.ndarray) - return res + return points @ self.rotation.T def to_affine(self, name: str | None = None) -> AffineEdge: return AffineEdge( @@ -711,14 +735,14 @@ def to_affine(self, name: str | None = None) -> AffineEdge: ) -class SequenceEdge(BaseTransfEdge): +class SequenceEdge(BaseTransformationEdge): """The Sequence transformation from the NGFF specification.""" def __init__( self, *, name: str | None = None, - transformations: Sequence[BaseTransfEdge], + transformations: Sequence[BaseTransformationEdge], ) -> None: """ Init the NgffSequence object. @@ -765,7 +789,7 @@ def __repr__(self) -> str: return out def inverse(self, name: str | None = None) -> SequenceEdge | None: - inverted: list[BaseTransfEdge] = [] + inverted: list[BaseTransformationEdge] = [] for t in reversed(self.transformations): inv = t.inverse() if inv is None: @@ -797,16 +821,16 @@ def transform_points(self, points: ArrayLike) -> ArrayLike: return self.to_affine().transform_points(points) # FIXME -class ByDimensionEdge(BaseTransfEdge): +class ByDimensionEdge(BaseTransformationEdge): """The ByDimension transformation from the NGFF specification.""" - transformations: Final[Sequence[BaseTransfEdge]] + transformations: Final[Sequence[BaseTransformationEdge]] def __init__( self, *, name: str | None = None, - transformations: Sequence[BaseTransfEdge], + transformations: Sequence[BaseTransformationEdge], input: CoordSystem, output: CoordSystem, ) -> None: @@ -861,8 +885,8 @@ def __repr__(self) -> str: out += "]" return out - def inverse(self, name: str | None = None) -> BaseTransfEdge | None: - inverse_transformations: list[BaseTransfEdge] = [] + def inverse(self, name: str | None = None) -> BaseTransformationEdge | None: + inverse_transformations: list[BaseTransformationEdge] = [] for t in self.transformations: inv = t.inverse() if inv is None: @@ -931,13 +955,13 @@ def __init__(self, base_name: str): def generate(self, *, num_axes: int) -> CoordSystem: out = CoordSystem( name=f"{self._base_name}{self._cs_count}", - axes=[ + axes=tuple( Axis( name=f"axis_{ax_idx}", type="space", # FIXME ) for ax_idx in range(num_axes) - ], + ), virtual=True, ) self._cs_count += 1 @@ -946,7 +970,7 @@ def generate(self, *, num_axes: int) -> CoordSystem: def generate_like(self, other: CoordSystem) -> CoordSystem: out = CoordSystem( name=f"{self._base_name}{self._cs_count}", - axes=[ + axes=tuple( Axis( name=axis.name, type=axis.type, @@ -954,7 +978,7 @@ def generate_like(self, other: CoordSystem) -> CoordSystem: long_name=axis.long_name, ) for axis in other.axes - ], + ), virtual=True, ) self._cs_count += 1 @@ -1039,14 +1063,13 @@ def parse_map_axis( dummy_cs = out.generate(num_axes=len(model.mapAxis)) output = CoordSystem( name=dummy_cs.name, - axes=[input.axes[i] for i in model.mapAxis], + axes=tuple(input.axes[i] for i in model.mapAxis), virtual=True, ) - return MapAxisEdge( - input=input, - output=output, - name=model.name, - ) + input_to_output = { + input.axes[inp_idx]: output_axis for inp_idx, output_axis in zip(model.mapAxis, output.axes, strict=True) + } + return MapAxisEdge(input=input, output=output, name=model.name, input_to_output=input_to_output) def parse_affine( @@ -1088,7 +1111,7 @@ def parse_sequence( output: CoordSystem | CsGen, ) -> SequenceEdge: """Parse a `Sequence` NGFF transformation model into a `SequenceEdge`.""" - parsed_inners: list[BaseTransfEdge] = [] + parsed_inners: list[BaseTransformationEdge] = [] base_name = "intermediate" + ("" if not model.name else f"_for_{model.name}") cs_gen: CsGen = output if isinstance(output, CsGen) else CsGen(base_name=base_name) @@ -1118,19 +1141,19 @@ def parse_by_dimension( ) -> ByDimensionEdge: """Parse a `ByDimension` NGFF transformation model into a `ByDimensionEdge`.""" if not isinstance(output, CoordSystem): - max_out_idx = max(ax_idx for t in model.transformations for ax_idx in t.output_axes) + max_out_idx = max(ax_idx for t in model.transformations for ax_idx in t.outputAxes) output = output.generate(num_axes=max_out_idx + 1) - piecewise_transforms: list[BaseTransfEdge] = [] + piecewise_transforms: list[BaseTransformationEdge] = [] for t in model.transformations: - inp_axes = [input.axes[i] for i in t.input_axes] + inp_axes = tuple(input.axes[i] for i in t.inputAxes) partial_input = CoordSystem( axes=inp_axes, name=f"{input.name}_{','.join(ax.name for ax in inp_axes)}", virtual=True, ) - out_axes = [output.axes[i] for i in t.output_axes] + out_axes = tuple(output.axes[i] for i in t.outputAxes) partial_out = CoordSystem( axes=out_axes, name=f"{output.name}_{','.join(ax.name for ax in inp_axes)}", @@ -1152,7 +1175,7 @@ def parse_ngff_transf( input: CoordSystem, model: ozm06trans.AnyTransform, output: CoordSystem | CsGen, -) -> BaseTransfEdge: +) -> BaseTransformationEdge: """Parse an NGFF coordinate transformation model into a `BaseTransfEdge`""" if isinstance(model, ozm06trans.Identity): return parse_identity(model, input=input, out=output) diff --git a/src/spatialdata/transformations/graph/vert.py b/src/spatialdata/transformations/graph/vert.py index 177a8d8a9..a78046b90 100644 --- a/src/spatialdata/transformations/graph/vert.py +++ b/src/spatialdata/transformations/graph/vert.py @@ -70,16 +70,19 @@ def try_from_model(cls, model: ozm06ct.Axis) -> Axis: if `model` doesn't have a name, has a type other than "channel" or "space", or has a unit that isn't a string or None """ + name = model.name if name is None: raise AxisParsingException("Axis doesn't have a name") - if model.type != "channel" and model.type != "space": + if model.type is None: + raise AxisParsingException("Axis doesn't have a type") + if model.type not in ("channel", "space"): raise AxisParsingException(f"Can't handle axis of type {model.type}") if not isinstance(model.unit, (str, type(None))): raise AxisParsingException("Can't handle axis unit") return Axis( name=name, - type=model.type, + type=model.type, # type: ignore[arg-type] unit=model.unit, long_name=model.longName, ) From 05a28c7a6c530716121041cc27ce07f87f76b483 Mon Sep 17 00:00:00 2001 From: Tomaz Vieira Date: Fri, 11 Sep 2026 10:15:45 +0000 Subject: [PATCH 25/54] Fixes renamed fields in ome-zarr-models --- tests/transformations/edge/test_edge_parsing.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/transformations/edge/test_edge_parsing.py b/tests/transformations/edge/test_edge_parsing.py index 5f95a4d61..a20acbaa3 100644 --- a/tests/transformations/edge/test_edge_parsing.py +++ b/tests/transformations/edge/test_edge_parsing.py @@ -202,8 +202,8 @@ def test_parses_name_and_splits_axes_between_inner_transformations(self): model = ozm06trans.ByDimension( name="my-by-dim", transformations=( - ozm06trans.ByDimensionTransform(transformation=scale_x, input_axes=(0,), output_axes=(0,)), - ozm06trans.ByDimensionTransform(transformation=translate_y, input_axes=(1,), output_axes=(1,)), + ozm06trans.ByDimensionTransform(transformation=scale_x, inputAxes=(0,), outputAxes=(0,)), + ozm06trans.ByDimensionTransform(transformation=translate_y, inputAxes=(1,), outputAxes=(1,)), ), ) edge = parse_by_dimension(model, input=xy_cs, output=xy_cs) @@ -217,8 +217,8 @@ def test_csgen_output_derived_from_max_output_axis_index(self): translate_y = ozm06trans.Translation(translation=(5.0,)) model = ozm06trans.ByDimension( transformations=( - ozm06trans.ByDimensionTransform(transformation=scale_x, input_axes=(0,), output_axes=(0,)), - ozm06trans.ByDimensionTransform(transformation=translate_y, input_axes=(1,), output_axes=(1,)), + ozm06trans.ByDimensionTransform(transformation=scale_x, inputAxes=(0,), outputAxes=(0,)), + ozm06trans.ByDimensionTransform(transformation=translate_y, inputAxes=(1,), outputAxes=(1,)), ), ) edge = parse_by_dimension(model, input=xy_cs, output=CsGen(base_name="gen")) From 98c1b37bb0c7b3c2c4bb072e88f17e0408927172 Mon Sep 17 00:00:00 2001 From: Tomaz Vieira Date: Thu, 10 Sep 2026 12:35:59 +0000 Subject: [PATCH 26/54] Use frozen dataclasses for CoordSystems and Axis. Fix MapAxisEdge --- src/spatialdata/_io/io_raster.py | 2 +- src/spatialdata/transformations/graph/edge.py | 4 +- src/spatialdata/transformations/graph/vert.py | 57 ++++--------- tests/transformations/edge/conftest.py | 82 ++++++++++++++++--- .../edge/test_edge_transformations.py | 32 ++++---- 5 files changed, 106 insertions(+), 71 deletions(-) diff --git a/src/spatialdata/_io/io_raster.py b/src/spatialdata/_io/io_raster.py index 0b336999d..1cfc1a942 100644 --- a/src/spatialdata/_io/io_raster.py +++ b/src/spatialdata/_io/io_raster.py @@ -218,7 +218,7 @@ def try_parse_ngff06_multiscale(multiscale: oz.OMEZarrMultiscale) -> tuple[DataT # in levels of a xr.DataTree pixel_cs = CoordSystem( name=transf.input.path, - axes=[Axis(name=ax.name, type=ax.type) for ax in intrinsic_cs.axes], + axes=tuple(Axis(name=ax.name, type=ax.type) for ax in intrinsic_cs.axes), virtual=True, ) diff --git a/src/spatialdata/transformations/graph/edge.py b/src/spatialdata/transformations/graph/edge.py index 2201b257b..7bea0b82e 100644 --- a/src/spatialdata/transformations/graph/edge.py +++ b/src/spatialdata/transformations/graph/edge.py @@ -681,10 +681,8 @@ def __init__( if linear_matrix's shape isn't (output.num_axes, input.num_axes) IncompatibleCoordSystemsError if input and output don't have the same number of axes - DeterminantDifferentFromOne + NotUnimodularError if linear_matrix doesn't have determinant ~= 1 - NotOrthonormal - if linear_matrix is not orthonormal """ if input.num_axes != output.num_axes: raise IncompatibleCoordSystemsError( diff --git a/src/spatialdata/transformations/graph/vert.py b/src/spatialdata/transformations/graph/vert.py index a78046b90..62b118b16 100644 --- a/src/spatialdata/transformations/graph/vert.py +++ b/src/spatialdata/transformations/graph/vert.py @@ -1,9 +1,9 @@ from __future__ import annotations from collections.abc import Sequence +from dataclasses import dataclass from typing import Final, Literal -import ome_zarr.classes.image as ozi import ome_zarr_models.v06.coordinate_transforms as ozm06ct @@ -11,34 +11,16 @@ class AxisParsingException(Exception): pass +@dataclass(frozen=True) class Axis: - """ - Representation of an axis, following the NGFF specification. - - Attributes - ---------- - name - name of the axis. - type - type of the axis. Should be in ["channel", "space"]. - unit - unit of the axis. For a set of valid options see https://ngff.openmicroscopy.org/ - long_name: - a longer, human-friendly name for this axis - """ + """Representation of a coordinate system axis""" name: Final[str] type: Final[Literal["space", "channel"]] - unit: Final[str | None] - long_name: Final[str | None] - - def __init__( - self, *, name: str, type: Literal["space", "channel"], unit: str | None = None, long_name: str | None = None - ): - self.name = name - self.type = type - self.unit = unit - self.long_name = long_name + unit: Final[str | None] = None + "unit of the axis. For a set of valid options see https://ngff.openmicroscopy.org/" + long_name: Final[str | None] = None + "a longer, human-friendly name for this axis" def cloned_with(self, *, unit: str | None) -> Axis: return Axis(name=self.name, type=self.type, unit=unit or self.unit, long_name=self.long_name) @@ -47,7 +29,7 @@ def __hash__(self) -> int: return hash((self.name, self.type, self.unit, self.long_name)) def __repr__(self) -> str: - return f"NgffAxis(name={self.name}, type={self.type})" + return f"Axis(name={self.name}, type={self.type})" def __eq__(self, value: object, /) -> bool: if not isinstance(value, Axis): @@ -98,6 +80,7 @@ def __init__(self, *, axis_name: str) -> None: super().__init__(f"Axis name '{axis_name}' is used more than once") +@dataclass(frozen=True) class CoordSystem: """ Representation of a coordinate system. @@ -105,30 +88,17 @@ class CoordSystem: name: Final[str] axes: Final[tuple[Axis, ...]] - virtual: Final[bool] """A virtual coordinate system exists as an intermediate step between non-virtual coordinate systems and is usually ignored during serialization""" - def __init__(self, name: str, axes: Sequence[Axis], virtual: bool = False): + def __post_init__(self) -> None: """ - Parameters - ---------- - name - name of the coordinate system - axes - axes of the coordinate system - virtual - vitual coordinate systems don't serialize to NGFF - Raises ------ DuplicateAxisNameError if `axes` contains axes with duplicate names """ - self.name = name - self.axes = tuple(axes) - self.virtual = virtual seen_names: set[str] = set() for axis in self.axes: if axis.name in seen_names: @@ -142,7 +112,7 @@ def __hash__(self) -> int: return hash((self.name, self.axes, self.virtual)) @classmethod - def try_from_model(cls, model: ozi.CoordinateSystem) -> CoordSystem: + def try_from_model(cls, model: ozm06ct.CoordinateSystem) -> CoordSystem: """ Parse a `CoordSystem` from an ome-zarr-models coordinate system model. @@ -155,11 +125,12 @@ def try_from_model(cls, model: ozi.CoordinateSystem) -> CoordSystem: """ return CoordSystem( name=model.name, - axes=[Axis.try_from_model(axis) for axis in model.axes], + axes=tuple(Axis.try_from_model(axis) for axis in model.axes), + virtual=False, ) @classmethod - def try_from_model_or_default[T](cls, model: ozi.CoordinateSystem | None, *, default: T) -> CoordSystem | T: + def try_from_model_or_default[T](cls, model: ozm06ct.CoordinateSystem | None, *, default: T) -> CoordSystem | T: """ Parse a `CoordSystem` from `model`, or return `default` if `model` is None. diff --git a/tests/transformations/edge/conftest.py b/tests/transformations/edge/conftest.py index 75de85b49..5ff56a219 100644 --- a/tests/transformations/edge/conftest.py +++ b/tests/transformations/edge/conftest.py @@ -7,13 +7,75 @@ z_axis = Axis(name="z", type="space", unit="micrometer") c_axis = Axis(name="c", type="channel") -x_cs = CoordSystem(name="x", axes=[x_axis]) -y_cs = CoordSystem(name="y", axes=[y_axis]) -z_cs = CoordSystem(name="z", axes=[z_axis]) -c_cs = CoordSystem(name="c", axes=[c_axis]) -xy_cs = CoordSystem(name="xy", axes=[x_axis, y_axis]) -yx_cs = CoordSystem(name="yx", axes=[y_axis, x_axis]) -xyz_cs = CoordSystem(name="xyz", axes=[x_axis, y_axis, z_axis]) -zyx_cs = CoordSystem(name="zyx", axes=[z_axis, y_axis, x_axis]) -xyc_cs = CoordSystem(name="xyc", axes=[x_axis, y_axis, c_axis]) -cyx_cs = CoordSystem(name="cyx", axes=[c_axis, y_axis, x_axis]) +a_axis = Axis(name="a", type="space", unit="micrometer") +b_axis = Axis(name="b", type="space", unit="micrometer") +c_axis = Axis(name="c", type="space", unit="micrometer") + +x_cs = CoordSystem(name="x", axes=(x_axis,), virtual=False) +y_cs = CoordSystem(name="y", axes=(y_axis,), virtual=False) +z_cs = CoordSystem(name="z", axes=(z_axis,), virtual=False) +c_cs = CoordSystem(name="c", axes=(c_axis,), virtual=False) +xy_cs = CoordSystem( + name="xy", + axes=( + x_axis, + y_axis, + ), + virtual=False, +) +yx_cs = CoordSystem( + name="yx", + axes=( + y_axis, + x_axis, + ), + virtual=False, +) +xyz_cs = CoordSystem( + name="xyz", + axes=( + x_axis, + y_axis, + z_axis, + ), + virtual=False, +) +zyx_cs = CoordSystem( + name="zyx", + axes=( + z_axis, + y_axis, + x_axis, + ), + virtual=False, +) +xyc_cs = CoordSystem( + name="xyc", + axes=( + x_axis, + y_axis, + c_axis, + ), + virtual=False, +) +cyx_cs = CoordSystem( + name="cyx", + axes=( + c_axis, + y_axis, + x_axis, + ), + virtual=False, +) + +abc_cs = CoordSystem(name="abc", axes=(a_axis, b_axis, c_axis), virtual=False) +cba_cs = CoordSystem( + name="cba", + axes=( + c_axis, + b_axis, + a_axis, + ), + virtual=False, +) +cba_cs = CoordSystem(name="cba", axes=(c_axis, b_axis, a_axis), virtual=False) diff --git a/tests/transformations/edge/test_edge_transformations.py b/tests/transformations/edge/test_edge_transformations.py index 8283369d3..22f6f15d1 100644 --- a/tests/transformations/edge/test_edge_transformations.py +++ b/tests/transformations/edge/test_edge_transformations.py @@ -26,6 +26,11 @@ TranslationEdge, ) from tests.transformations.edge.conftest import ( + a_axis, + abc_cs, + b_axis, + c_axis, + x_axis, x_cs, xy_cs, xyc_cs, @@ -71,11 +76,6 @@ def test_from_affine_matrix(self): np.testing.assert_equal(edge.translation, translation) np.testing.assert_allclose(edge.transform_points(POINTS_2D), np.array([[3.0, 11.0], [7.0, 17.0]])) - def test_mapping_classmethod_builds_permutation_matrix(self): - edge = AffineEdge.mapping(input=xy_cs, output=yx_cs) - np.testing.assert_allclose(edge.linear, np.array([[0.0, 1.0], [1.0, 0.0]])) - np.testing.assert_allclose(edge.transform_points(POINTS_2D), np.array([[2.0, 1.0], [4.0, 3.0]])) - def test_transform_points_scale_and_translate(self): edge = AffineEdge( linear=np.array([[2.0, 0.0], [0.0, 3.0]]), translation=np.array([1.0, 1.0]), input=xy_cs, output=xy_cs @@ -131,25 +131,29 @@ def test_inverse_is_still_the_identity(self): class TestMapAxisEdge: - def test_constructor_rejects_different_sets_of_axes(self): - with pytest.raises(IncompatibleCoordSystemsError): - MapAxisEdge(input=xy_cs, output=xyz_cs) - def test_transform_points_swaps_axes(self): - edge = MapAxisEdge(input=xy_cs, output=yx_cs) - np.testing.assert_allclose(edge.transform_points(POINTS_2D), np.array([[2.0, 1.0], [4.0, 3.0]])) + edge = MapAxisEdge( + input=xyz_cs, + output=abc_cs, + input_to_output={x_axis: b_axis, y_axis: c_axis, z_axis: a_axis}, + ) + np.testing.assert_equal(edge.transform_points(POINTS_3D), np.array([[3.0, 1.0, 2.0], [6.0, 4.0, 5.0]])) def test_transform_points_permutation_of_three_axes(self): - edge = MapAxisEdge(input=xyz_cs, output=zyx_cs) + edge = MapAxisEdge( + input=xyz_cs, output=abc_cs, input_to_output={x_axis: c_axis, y_axis: b_axis, z_axis: a_axis} + ) np.testing.assert_allclose(edge.transform_points(POINTS_3D), np.array([[3.0, 2.0, 1.0], [6.0, 5.0, 4.0]])) def test_to_affine_matches_transform_points(self): - edge = MapAxisEdge(input=xy_cs, output=yx_cs) + edge = MapAxisEdge(input=xy_cs, output=xy_cs, input_to_output={x_axis: y_axis, y_axis: x_axis}) affine = edge.to_affine() np.testing.assert_allclose(affine.transform_points(POINTS_2D), edge.transform_points(POINTS_2D)) def test_inverse_roundtrips(self): - edge = MapAxisEdge(input=xyz_cs, output=zyx_cs) + edge = MapAxisEdge( + input=xyz_cs, output=zyx_cs, input_to_output={x_axis: z_axis, y_axis: y_axis, z_axis: x_axis} + ) inv = edge.inverse() np.testing.assert_allclose(inv.transform_points(edge.transform_points(POINTS_3D)), POINTS_3D) From 834f921c68881fffbae387cc61e8af8b3a76cf04 Mon Sep 17 00:00:00 2001 From: Tomaz Vieira Date: Thu, 10 Sep 2026 13:49:16 +0000 Subject: [PATCH 27/54] Enforces Rotation matrix is orthonormal with det==1 --- .../_core/transformation_manager/exceptions.py | 8 +++++++- src/spatialdata/transformations/graph/edge.py | 11 ++++++++--- .../transformations/edge/test_edge_transformations.py | 11 ++++++++--- 3 files changed, 23 insertions(+), 7 deletions(-) diff --git a/src/spatialdata/_core/transformation_manager/exceptions.py b/src/spatialdata/_core/transformation_manager/exceptions.py index a9dec744b..06fa5e96b 100644 --- a/src/spatialdata/_core/transformation_manager/exceptions.py +++ b/src/spatialdata/_core/transformation_manager/exceptions.py @@ -264,12 +264,18 @@ def __init__( super().__init__(message) -class NotUnimodularError(Exception): +class DeterminantDifferentFromOne(Exception): def __init__(self, matrix: ArrayLike) -> None: self.matrix = matrix super().__init__("Matrix does not have det(M) == 1") +class NotOrthonormalError(Exception): + def __init__(self, matrix: ArrayLike) -> None: + self.matrix = matrix + super().__init__("Matrix is not orthonormal") + + class EmptyTransformSequenceError(Exception): def __init__(self) -> None: super().__init__("Empty sequence of transformations") diff --git a/src/spatialdata/transformations/graph/edge.py b/src/spatialdata/transformations/graph/edge.py index 7bea0b82e..9f20151e6 100644 --- a/src/spatialdata/transformations/graph/edge.py +++ b/src/spatialdata/transformations/graph/edge.py @@ -11,10 +11,11 @@ from spatialdata._core.transformation_manager.exceptions import ( AxisRedefinitionError, + DeterminantDifferentFromOne, EmptyTransformSequenceError, IncompatibleCoordSystemsError, MissingAxisError, - NotUnimodularError, + NotOrthonormalError, UnexpectedShapeError, UnmappedAxisError, ) @@ -681,8 +682,10 @@ def __init__( if linear_matrix's shape isn't (output.num_axes, input.num_axes) IncompatibleCoordSystemsError if input and output don't have the same number of axes - NotUnimodularError + DeterminantDifferentFromOne if linear_matrix doesn't have determinant ~= 1 + NotOrthonormal + if linear_matrix is not orthonormal """ if input.num_axes != output.num_axes: raise IncompatibleCoordSystemsError( @@ -693,8 +696,10 @@ def __init__( raise UnexpectedShapeError( array_name="linear_matrix", array_shape=linear_matrix.shape, expected_shape=expected_shape ) + if not np.allclose(linear_matrix.T @ linear_matrix, np.identity(input.num_axes)): + raise NotOrthonormalError(matrix=linear_matrix) if not np.isclose(np.linalg.det(linear_matrix), 1.0): - raise NotUnimodularError(matrix=linear_matrix) + raise DeterminantDifferentFromOne(matrix=linear_matrix) linear_matrix.flags.writeable = False self.rotation = linear_matrix super().__init__(input=input, output=output, name=name) diff --git a/tests/transformations/edge/test_edge_transformations.py b/tests/transformations/edge/test_edge_transformations.py index 22f6f15d1..2427da620 100644 --- a/tests/transformations/edge/test_edge_transformations.py +++ b/tests/transformations/edge/test_edge_transformations.py @@ -7,10 +7,11 @@ from spatialdata._core.transformation_manager.exceptions import ( AxisRedefinitionError, + DeterminantDifferentFromOne, EmptyTransformSequenceError, IncompatibleCoordSystemsError, MissingAxisError, - NotUnimodularError, + NotOrthonormalError, UnexpectedShapeError, UnmappedAxisError, ) @@ -263,10 +264,14 @@ def test_constructor_rejects_wrong_shape(self): with pytest.raises(UnexpectedShapeError): RotationEdge(linear_matrix=np.eye(3), input=xy_cs, output=xy_cs) - def test_constructor_rejects_non_unimodular_matrix(self): - with pytest.raises(NotUnimodularError): + def test_constructor_rejects_matrix_with_det_diff_from_1(self): + with pytest.raises(DeterminantDifferentFromOne): RotationEdge(linear_matrix=np.array([[1.0, 0.0], [0.0, -1.0]]), input=xy_cs, output=xy_cs) + def test_constructor_rejects_non_orthonormal_matrix(self): + with pytest.raises(NotOrthonormalError): + RotationEdge(linear_matrix=np.array([[1.0, 0.0], [0.0, 2.0]]), input=xy_cs, output=xy_cs) + def test_transform_points_rotates_90_degrees(self): edge = RotationEdge(linear_matrix=np.array([[0.0, -1.0], [1.0, 0.0]]), input=xy_cs, output=xy_cs) np.testing.assert_allclose(edge.transform_points(POINTS_2D), np.array([[-2.0, 1.0], [-4.0, 3.0]])) From 6ca7e2140c8b688b4b1510289b25f75bd32b2c3b Mon Sep 17 00:00:00 2001 From: Tomaz Vieira Date: Mon, 21 Sep 2026 13:14:34 +0000 Subject: [PATCH 28/54] Bump xarray so we can use RangeIndex --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 04947e348..13ab89cc6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,7 +52,7 @@ dependencies = [ "spatial-image>=1.2.3", "typing-extensions>=4.8", "universal-pathlib>=0.2.6", - "xarray>=2024.10", + "xarray>=2025.4", "xarray-spatial>=0.3.5", "zarr>=3", ] From 0c3ae6498b754045b81612dbc61967f495c85121 Mon Sep 17 00:00:00 2001 From: Tomaz Vieira Date: Mon, 21 Sep 2026 13:22:23 +0000 Subject: [PATCH 29/54] Removes try-from_model_or_defaul for now --- src/spatialdata/transformations/graph/vert.py | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/src/spatialdata/transformations/graph/vert.py b/src/spatialdata/transformations/graph/vert.py index 62b118b16..a0c2b9b9b 100644 --- a/src/spatialdata/transformations/graph/vert.py +++ b/src/spatialdata/transformations/graph/vert.py @@ -129,20 +129,6 @@ def try_from_model(cls, model: ozm06ct.CoordinateSystem) -> CoordSystem: virtual=False, ) - @classmethod - def try_from_model_or_default[T](cls, model: ozm06ct.CoordinateSystem | None, *, default: T) -> CoordSystem | T: - """ - Parse a `CoordSystem` from `model`, or return `default` if `model` is None. - - Raises - ------ - AxisParsingException - if `model` is not None and any of its axes fails to parse - """ - if model is not None: - return CoordSystem.try_from_model(model) - return default - @property def num_axes(self) -> int: return len(self.axes) From 1951436ae22cb2d3bb1257c88a6600362063ea1c Mon Sep 17 00:00:00 2001 From: Tomaz Vieira Date: Mon, 21 Sep 2026 13:46:08 +0000 Subject: [PATCH 30/54] Emits warning on AffineEdge.inverse when condition is big --- src/spatialdata/transformations/graph/edge.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/spatialdata/transformations/graph/edge.py b/src/spatialdata/transformations/graph/edge.py index 9f20151e6..822cd811b 100644 --- a/src/spatialdata/transformations/graph/edge.py +++ b/src/spatialdata/transformations/graph/edge.py @@ -5,6 +5,7 @@ from abc import ABC, abstractmethod from collections.abc import Mapping, Sequence from typing import Final +from warnings import warn import numpy as np import ome_zarr_models.v06.coordinate_transforms as ozm06trans @@ -231,6 +232,13 @@ def mapping(cls, input: CoordSystem, output: CoordSystem, name: str | None = Non ) def inverse(self, name: str | None = None) -> BaseTransformationEdge | None: + cond = np.linalg.cond(self.affine) + if cond > 1e10: + warn( + f"Inverting an affine matrix with large condition number ({cond:.2e})", + RuntimeWarning, + stacklevel=2, + ) try: # FIXME: I think there are more efficient/precise ways to invert a matrix inv = np.linalg.inv(self.affine) From 475d15b32d38bb87eaffa45f1f07cc5e85613b39 Mon Sep 17 00:00:00 2001 From: Tomaz Vieira Date: Mon, 21 Sep 2026 15:10:27 +0000 Subject: [PATCH 31/54] Uses matrix in exceptions --- src/spatialdata/_core/transformation_manager/exceptions.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/spatialdata/_core/transformation_manager/exceptions.py b/src/spatialdata/_core/transformation_manager/exceptions.py index 06fa5e96b..86adeb1f5 100644 --- a/src/spatialdata/_core/transformation_manager/exceptions.py +++ b/src/spatialdata/_core/transformation_manager/exceptions.py @@ -267,13 +267,13 @@ def __init__( class DeterminantDifferentFromOne(Exception): def __init__(self, matrix: ArrayLike) -> None: self.matrix = matrix - super().__init__("Matrix does not have det(M) == 1") + super().__init__(f"Matrix does not have det(M) == 1:\n{matrix}") class NotOrthonormalError(Exception): def __init__(self, matrix: ArrayLike) -> None: self.matrix = matrix - super().__init__("Matrix is not orthonormal") + super().__init__(f"Matrix is not orthonormal:\n{matrix}") class EmptyTransformSequenceError(Exception): From 9832d6dc37afa4d6866b7c262d8af34878fe33ba Mon Sep 17 00:00:00 2001 From: Ajayrama Kumaraswamy Date: Thu, 10 Sep 2026 15:07:36 +0200 Subject: [PATCH 32/54] feat(NgffAxisTypes): defined --- src/spatialdata/models/_utils.py | 13 +++++++++++++ src/spatialdata/transformations/graph/edge.py | 3 ++- src/spatialdata/transformations/graph/vert.py | 14 +++++++++----- 3 files changed, 24 insertions(+), 6 deletions(-) diff --git a/src/spatialdata/models/_utils.py b/src/spatialdata/models/_utils.py index 972e2d9be..3a57d219a 100644 --- a/src/spatialdata/models/_utils.py +++ b/src/spatialdata/models/_utils.py @@ -1,6 +1,7 @@ from __future__ import annotations import warnings +from enum import StrEnum from functools import singledispatch from typing import TYPE_CHECKING, Any @@ -29,6 +30,14 @@ Y = "y" X = "x" + +class NgffAxisType(StrEnum): + SPACE = "space" + CHANNEL = "channel" + + +axis_type_mapping_ngff = {C: NgffAxisType.CHANNEL, X: NgffAxisType.SPACE, Y: NgffAxisType.SPACE, Z: NgffAxisType.SPACE} + if TYPE_CHECKING: from anndata import AnnData @@ -123,6 +132,10 @@ def get_spatial_axes(axes: tuple[ValidAxis_t, ...]) -> tuple[ValidAxis_t, ...]: return tuple(ax for ax in axes if ax in [X, Y, Z]) +def get_axes_types(e: SpatialElement) -> tuple[NgffAxisType, ...]: + return tuple(axis_type_mapping_ngff[x] for x in get_axes_names(e)) + + @singledispatch def get_axes_names(e: SpatialElement | pd.DataFrame) -> tuple[str, ...]: """ diff --git a/src/spatialdata/transformations/graph/edge.py b/src/spatialdata/transformations/graph/edge.py index 822cd811b..ae23363e2 100644 --- a/src/spatialdata/transformations/graph/edge.py +++ b/src/spatialdata/transformations/graph/edge.py @@ -21,6 +21,7 @@ UnmappedAxisError, ) from spatialdata._types import ArrayLike +from spatialdata.models._utils import NgffAxisType from spatialdata.transformations.graph.vert import Axis, CoordSystem @@ -969,7 +970,7 @@ def generate(self, *, num_axes: int) -> CoordSystem: axes=tuple( Axis( name=f"axis_{ax_idx}", - type="space", # FIXME + type=NgffAxisType.SPACE, # FIXME ) for ax_idx in range(num_axes) ), diff --git a/src/spatialdata/transformations/graph/vert.py b/src/spatialdata/transformations/graph/vert.py index a0c2b9b9b..3e6ec7611 100644 --- a/src/spatialdata/transformations/graph/vert.py +++ b/src/spatialdata/transformations/graph/vert.py @@ -2,10 +2,12 @@ from collections.abc import Sequence from dataclasses import dataclass -from typing import Final, Literal +from typing import Final import ome_zarr_models.v06.coordinate_transforms as ozm06ct +from spatialdata.models._utils import NgffAxisType + class AxisParsingException(Exception): pass @@ -16,7 +18,7 @@ class Axis: """Representation of a coordinate system axis""" name: Final[str] - type: Final[Literal["space", "channel"]] + type: Final[NgffAxisType] unit: Final[str | None] = None "unit of the axis. For a set of valid options see https://ngff.openmicroscopy.org/" long_name: Final[str | None] = None @@ -58,13 +60,15 @@ def try_from_model(cls, model: ozm06ct.Axis) -> Axis: raise AxisParsingException("Axis doesn't have a name") if model.type is None: raise AxisParsingException("Axis doesn't have a type") - if model.type not in ("channel", "space"): - raise AxisParsingException(f"Can't handle axis of type {model.type}") + try: + axis_type = NgffAxisType(model.type) + except ValueError as ve: + raise AxisParsingException(f"Can't handle axis of type {model.type}") from ve if not isinstance(model.unit, (str, type(None))): raise AxisParsingException("Can't handle axis unit") return Axis( name=name, - type=model.type, # type: ignore[arg-type] + type=axis_type, unit=model.unit, long_name=model.longName, ) From 20af288f35e8bab92aa083d881f541990fcbcabf Mon Sep 17 00:00:00 2001 From: Ajayrama Kumaraswamy Date: Thu, 10 Sep 2026 15:21:25 +0200 Subject: [PATCH 33/54] refac + fix(TransformationManager + Tests): to work with new Vert and Edge classes; split into files --- src/spatialdata/_core/spatialdata.py | 4 +- .../_core/transformation_manager/__init__.py | 635 ----------------- .../_transformation_graph.py | 640 ++++++++++++++++++ .../transformation_manager/exceptions.py | 68 +- .../transformations_facade.py | 22 + .../_core/transformation_manager/types.py | 3 + src/spatialdata/models/_utils.py | 8 +- src/spatialdata/transformations/graph/edge.py | 17 +- tests/core/transformation_manager/conftest.py | 91 ++- .../test_transformation_manager.py | 363 +++++----- 10 files changed, 967 insertions(+), 884 deletions(-) create mode 100644 src/spatialdata/_core/transformation_manager/_transformation_graph.py create mode 100644 src/spatialdata/_core/transformation_manager/transformations_facade.py create mode 100644 src/spatialdata/_core/transformation_manager/types.py diff --git a/src/spatialdata/_core/spatialdata.py b/src/spatialdata/_core/spatialdata.py index 34e25aa64..6680e2fa5 100644 --- a/src/spatialdata/_core/spatialdata.py +++ b/src/spatialdata/_core/spatialdata.py @@ -22,7 +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.transformation_manager._transformation_graph import TransformationGraph from spatialdata._core.validation import ( check_all_keys_case_insensitively_unique, check_target_region_column_symmetry, @@ -133,7 +133,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 + self.transformation_graph = TransformationGraph() # 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 index 079fe5cce..e69de29bb 100644 --- a/src/spatialdata/_core/transformation_manager/__init__.py +++ b/src/spatialdata/_core/transformation_manager/__init__.py @@ -1,635 +0,0 @@ -from __future__ import annotations - -from collections.abc import Sequence - -import networkx as nx - -from spatialdata._core.transformation_manager.exceptions import ( - CannotRemoveCoordinateSystemError, - CoordinateSystemAlreadyExistsError, - CoordinateSystemHasElementsError, - CoordinateSystemHasTransformationsError, - CoordinateSystemNotFoundError, - ElementAlreadyExistsError, - ElementNotRegisteredToAnyCoordinateSystemError, - InvalidPathError, - TransformationNotFoundError, - TransformationPathAmbiguousError, - TransformationPathAmbiguousMultipleEdgeExpectedError, - TransformationPathAmbiguousNoEdgeExpectedError, - TransformationPathNotFoundError, - TransformationPathNotSimple, -) -from spatialdata.transformations import transformations as sd_transforms -from spatialdata.transformations.ngff.ngff_coordinate_system import NgffCoordinateSystem - -TRANSFORM_KEY = "transformation" -type EDGE_DEF = tuple[NgffCoordinateSystem, NgffCoordinateSystem, sd_transforms.BaseTransformation] - - -class TransformationManager: - def __init__(self) -> None: - """Initialize a TransformationManager with empty graph and mappings.""" - self._graph: nx.MultiDiGraph[NgffCoordinateSystem] = nx.MultiDiGraph() - # MultiDiGraph with NgffCoordinateSystem objects as nodes and transforms as edge attributes - self._element_to_cs_mapping: dict[str, NgffCoordinateSystem] = {} - # mapping element_name to the coordinate system to which the element belongs - - def assert_element_exists(self, element_name: str) -> None: - """ - Assert that an element exists in the transformation manager. - - Parameters - ---------- - element_name - The name of the element to check. - - Raises - ------ - ElementNotFoundError - If the element does not exist. - """ - if element_name not in self._element_to_cs_mapping: - raise ElementNotRegisteredToAnyCoordinateSystemError(element_name) - - def assert_element_does_not_exist(self, element_name: str) -> None: - """ - Assert than an element doesn't exist in the transformation manager. - - Parameters - ---------- - element_name - The name of the element to check. - - Raises - ------ - ElementAlreadyExistsError - If the element already exists. - """ - if element_name in self._element_to_cs_mapping: - raise ElementAlreadyExistsError(element_name) - - def assert_coordinate_system_exists(self, cs: NgffCoordinateSystem) -> None: - """ - Assert that coordinate system exists in the graph. - - Parameters - ---------- - cs - The coordinate system to check. - - Raises - ------ - CoordinateSystemNotFoundError - If the coordinate system does not exist. - """ - if cs not in self._graph: - raise CoordinateSystemNotFoundError(cs.name) - - def assert_coordinate_system_does_not_exist(self, cs: NgffCoordinateSystem) -> None: - """ - Assert that coordinate system doesn't exist in the graph. - - Parameters - ---------- - cs - The coordinate system to check. - - Raises - ------ - CoordinateSystemAlreadyExistsError - If the coordinate system already exists. - """ - if cs in self._graph: - raise CoordinateSystemAlreadyExistsError(cs.name) - - def assert_an_edge_exists_between_coordinate_systems( - self, source_cs: NgffCoordinateSystem, target_cs: NgffCoordinateSystem, edge_key: str | None = None - ) -> None: - """ - Assert that an edge exists between coordinate systems. - - Parameters - ---------- - source_cs - The input coordinate system. - target_cs - The output coordinate system. - edge_key - If specified, asserts that the specific edge exists between coordinate systems - - - Raises - ------ - CoordinateSystemNotFoundError - If either coordinate system does not exist. - TransformationNotFoundError - If the edge does not exist. - """ - self.assert_coordinate_system_exists(source_cs) - self.assert_coordinate_system_exists(target_cs) - if not self._graph.has_edge(source_cs, target_cs): - raise TransformationNotFoundError(source_cs.name, target_cs.name, edge_key) - - def assert_coordinate_system_has_no_transformations(self, cs: NgffCoordinateSystem) -> None: - """ - Assert that coordinate system has no associated transformations. - - Parameters - ---------- - cs - The coordinate system to check. - - Raises - ------ - CoordinateSystemNotFoundError - If either coordinate system does not exist. - CoordinateSystemHasTransformationsError - If the coordinate system has associated transformations. - - """ - self.assert_coordinate_system_exists(cs) - has_successors = next(self._graph.successors(cs), None) is not None - has_predecessors = next(self._graph.predecessors(cs), None) is not None - - if has_successors or has_predecessors: - raise CoordinateSystemHasTransformationsError(cs.name) - - def assert_coordinate_system_has_no_elements(self, cs: NgffCoordinateSystem) -> None: - """ - Assert that a coordinate system doesn't have elements belonging to it. - - Parameters - ---------- - cs - The coordinate system to check - - Raises - ------ - CoordinateSystemHasElementsError - if the coordinate system has elements belonging to it. - """ - elements = self._get_elements_belonging_to_cs(cs) - # also checks if cs exists - if elements: - raise CoordinateSystemHasElementsError(cs.name, elements) - - def add_coordinate_system(self, cs: NgffCoordinateSystem) -> None: - """ - Add a coordinate system to the transformation manager. - - Parameters - ---------- - cs - The coordinate system to add. - - Raises - ------ - CoordinateSystemAlreadyExistsError - If the coordinate system already exists. - """ - self.assert_coordinate_system_does_not_exist(cs) - self._graph.add_node(cs) - - def remove_coordinate_system(self, cs: NgffCoordinateSystem) -> None: - """ - Remove a coordinate system from the transformation manager. - - Parameters - ---------- - cs - The coordinate system to remove. - - Raises - ------ - CoordinateSystemNotFoundError - If the coordinate system is not found - CannotRemoveCoordinateSystemError - If the coordinate system cannot be removed. - Caused by - CoordinateSystemHasTransformationsError or - CoordinateSystemHasElementsError when the system has dependencies. - """ - try: - self.assert_coordinate_system_has_no_transformations(cs) - # also asserts that cs exists - self.assert_coordinate_system_has_no_elements(cs) - except (CoordinateSystemHasTransformationsError, CoordinateSystemHasElementsError) as err: - raise CannotRemoveCoordinateSystemError(cs.name) from err - - self._graph.remove_node(cs) - - def list_coordinate_systems(self) -> list[NgffCoordinateSystem]: - """ - List all registered coordinate systems. - - Returns - ------- - A list of coordinate system objects. - """ - return list(self._graph.nodes()) - - def add_element(self, element_name: str, coordinate_system: NgffCoordinateSystem) -> None: - """ - Register an element and associate it with a coordinate system. - - Parameters - ---------- - element_name - The name of the element. - coordinate_system - The coordinate system to which the element belongs. - - Raises - ------ - CoordinateSystemNotFoundError - If the coordinate system is not found. - """ - self.assert_element_does_not_exist(element_name) - self.assert_coordinate_system_exists(coordinate_system) - self._element_to_cs_mapping[element_name] = coordinate_system - - def get_element_coordinate_system(self, element_name: str) -> NgffCoordinateSystem: - """ - Get the coordinate system to which an element belongs. - - Parameters - ---------- - element_name - The name of the element. - - Returns - ------- - The coordinate system - - Raises - ------ - ElementNotFoundError - If the element does not exist. - """ - self.assert_element_exists(element_name) - return self._element_to_cs_mapping[element_name] - - def _unset_element(self, element_name: str) -> None: - """ - Unregister an element from the coordinate system to which it belongs. - - Parameters - ---------- - element_name - The name of the element. - - Raises - ------ - ElementNotFoundError - If the element has not been registered to any coordinate system. - """ - self.assert_element_exists(element_name) - del self._element_to_cs_mapping[element_name] - - @staticmethod - def _get_edge_key(edge_def: EDGE_DEF) -> str: - """ - Encode the definition of an edge into a string to be used as graph-wide edge identifier. - - Parameters - ---------- - edge_def - tuple, (source_cs, target_cs, transformation) - - Returns - ------- - edge_key - str, the encoded edge identifier - """ - source_cs, target_cs, transform = edge_def - return f"{source_cs.name}_{target_cs.name}_{id(transform)}" - - def add_transformation( - self, - source_cs: NgffCoordinateSystem, - target_cs: NgffCoordinateSystem, - transformation: sd_transforms.BaseTransformation, - ) -> None: - """ - Add a transformation between coordinate systems. - - Parameters - ---------- - source_cs - The input coordinate system. - target_cs - The output coordinate system. - transformation - The transformation to add. - - Raises - ------ - CoordinateSystemNotFoundError - If either coordinate system does not exist. - """ - self.assert_coordinate_system_exists(source_cs) - self.assert_coordinate_system_exists(target_cs) - - edge_key = self._get_edge_key((source_cs, target_cs, transformation)) - edge_attributes = {TRANSFORM_KEY: transformation} - self._graph.add_edge(source_cs, target_cs, key=edge_key, **edge_attributes) - - def get_direct_transformations( - self, source_cs: NgffCoordinateSystem, target_cs: NgffCoordinateSystem - ) -> list[sd_transforms.BaseTransformation]: - """ - Retrieve transformations directly defined between coordinate systems. - - Parameters - ---------- - source_cs - The input coordinate system. - target_cs - The output coordinate system. - - Returns - ------- - List of transformations (empty if there are no direct transformations). - - Raises - ------ - CoordinateSystemNotFoundError - If either coordinate system does not exist. - """ - self.assert_coordinate_system_exists(source_cs) - self.assert_coordinate_system_exists(target_cs) - - transforms = [] - if self._graph.has_edge(source_cs, target_cs): - for edge_data in self._graph[source_cs][target_cs].values(): - transform: sd_transforms.BaseTransformation = edge_data[TRANSFORM_KEY] - transforms.append(transform) - return transforms - - def remove_specific_transformation( - self, - source_cs: NgffCoordinateSystem, - target_cs: NgffCoordinateSystem, - transformation: sd_transforms.BaseTransformation, - ) -> None: - """ - Remove a specific transformation between coordinate systems. - - Parameters - ---------- - source_cs - The input coordinate system. - target_cs - The output coordinate system. - transformation - The transformation to remove. - (mainly useful for cases with multiple transformations between the same coordinate systems). - - Raises - ------ - CoordinateSystemNotFoundError - If either coordinate system does not exist. - TransformationNotFoundError - If the transformation does not exist. - """ - expected_edge_key = self._get_edge_key((source_cs, target_cs, transformation)) - self.assert_an_edge_exists_between_coordinate_systems(source_cs, target_cs, expected_edge_key) - # also checks if source_cs and target_cs exist - self._graph.remove_edge(source_cs, target_cs, key=expected_edge_key) - - def remove_all_transformations_between_coordinate_systems( - self, - source_cs: NgffCoordinateSystem, - target_cs: NgffCoordinateSystem, - ) -> None: - """ - Remove all transformation between coordinate systems. - - Parameters - ---------- - source_cs - The input coordinate system. - target_cs - The output coordinate system. - - Raises - ------ - CoordinateSystemNotFoundError - If either coordinate system does not exist. - TransformationNotFoundError - If no transformation exists between the coordiante systems - """ - self.assert_coordinate_system_exists(source_cs) - self.assert_coordinate_system_exists(target_cs) - if self._graph.has_edge(source_cs, target_cs): - for edge_key in list(self._graph[source_cs][target_cs].keys()): - # need to covert keys() to list to freeze it, else it will change during the following removal - self._graph.remove_edge(source_cs, target_cs, key=edge_key) - - def _get_transformation_sequences_from_simple_paths_after_disambiguation( - self, - paths: list[list[NgffCoordinateSystem]], - expected_intermediate_edges: Sequence[EDGE_DEF], - ) -> list[sd_transforms.Sequence]: - """ - Traverses paths to form sequence of Transformations. - - In case of ambiguity looks into `expected_intermediate_transformations` to disambiguate. - - Parameters - ---------- - paths: - sequence of list of nodes - expected_intermediate_edges: - list of edge definitions, for use when multiple edges are found between two coordinate systems in a path - An edge is defined as a tuple, (source_cs, target_cs, transform). - - Returns - ------- - list of transformations, each an instance of the transformation class Sequence - - Raises - ------ - TransformationPathNotSimple - if any path in `paths` is not simple, i.e., has recurring coordinate systems - """ - for path in paths: - if len(set(path)) != len(path): - raise TransformationPathNotSimple(path) - - expected_intermediate_transformation_edge_keys = set() - if expected_intermediate_edges is not None: - for edge_def in expected_intermediate_edges: - edge_key = self._get_edge_key(edge_def) - expected_intermediate_transformation_edge_keys |= {edge_key} - - all_sequences = [] - deduplicated_paths = list({repr(x): x for x in paths}.values()) - for path in deduplicated_paths: - if len(path) <= 1: - raise InvalidPathError(path) - - transformation_list = [] - for i in range(len(path) - 1): - source_cs_here, target_cs_here = path[i], path[i + 1] - edge_data = self._graph[source_cs_here][target_cs_here] - if len(edge_data) > 1: - # when there are multiple edges between a pair of coordinate systems in the path - # find the expected edge based on key - expected_intermediate_transformation_key_here = ( - expected_intermediate_transformation_edge_keys & set(edge_data.keys()) - ) - if len(expected_intermediate_transformation_key_here) == 0: - # transformation was not specified in `expected_intermediate_transformations` for disambiguation - raise TransformationPathAmbiguousNoEdgeExpectedError(source_cs_here.name, target_cs_here.name) - if len(expected_intermediate_transformation_key_here) > 1: - # multiple transformations were specified in `expected_intermediate_transformations` - # for disambiguation - raise TransformationPathAmbiguousMultipleEdgeExpectedError( - source_cs_here.name, target_cs_here.name, len(expected_intermediate_transformation_key_here) - ) - edge_key_to_use = list(expected_intermediate_transformation_key_here)[0] - transformation_list.append(edge_data[edge_key_to_use][TRANSFORM_KEY]) - else: - # Only one edge, no ambiguity - edge_key = next(iter(edge_data.keys())) - transformation_list.append(edge_data[edge_key][TRANSFORM_KEY]) - all_sequences.append(sd_transforms.Sequence(transformation_list)) - return all_sequences - - def get_all_shortest_transformation_sequences( - self, - source_cs: NgffCoordinateSystem, - target_cs: NgffCoordinateSystem, - expected_intermediate_edges: Sequence[EDGE_DEF] = (), - ) -> list[sd_transforms.Sequence]: - """ - Get all shortest sequences of transformations between two coordinate systems. - - Parameters - ---------- - source_cs - The source coordinate system. - target_cs - The target coordinate system. - expected_intermediate_edges - list of intermediate edges expected. - An edge is defined as a tuple, (source_cs, target_cs, transform). - Used to choose an edge when multiple edges are found between the same coordinate systems. - - Returns - ------- - list[Sequence] - All shortest sequences of transformations from source_cs to target_cs. - When multiple transformations are defined between the same coordinate systems, only those containing - a transformation among intermediate transformations are included. - - Raises - ------ - CoordinateSystemNotFoundError - If either coordinate system does not exist. - TransformationPathNotFoundError - If no path exists between the source and target coordinate systems. - TransformationPathAmbiguousError - When multiple transformations are defined between the same coordinate systems and transformations - are not specified in `expeceted_intermediate_transformations` for disambiguation. - """ - try: - paths = list(nx.all_shortest_paths(self._graph, source=source_cs, target=target_cs)) - except nx.NetworkXNoPath as nxe: - raise TransformationPathNotFoundError(source_cs.name, target_cs.name) from nxe - - try: - return self._get_transformation_sequences_from_simple_paths_after_disambiguation( - paths, expected_intermediate_edges - ) - except TransformationPathAmbiguousError as tpae: - raise TransformationPathAmbiguousError(source_cs.name, target_cs.name) from tpae - - def get_all_transformation_sequences( - self, - source_cs: NgffCoordinateSystem, - target_cs: NgffCoordinateSystem, - expected_intermediate_edges: Sequence[EDGE_DEF] = (), - ) -> list[sd_transforms.Sequence]: - """ - Get all existing sequences of transformations between two coordinate systems. - - Parameters - ---------- - source_cs - The source coordinate system. - target_cs - The target coordinate system. - expected_intermediate_edges - list of intermediate edges expected. - An edge is defined as a tuple, (source_cs, target_cs, transform). - Used to choose an edge when multiple edges are found between the same coordinate systems. - - Returns - ------- - list[Sequence] - All existing sequences of transformations from source_cs to target_cs. - When multiple transformations are defined between the same coordinate systems, only those containing - a transformation among intermediate transformations are included. - - Raises - ------ - CoordinateSystemNotFoundError - If either coordinate system does not exist. - TransformationPathNotFoundError - If no path exists between the source and target coordinate systems. - TransformationPathAmbiguousError - When multiple transformations are defined between the same coordinate systems and transformations - are not specified in `expected_intermediate_transformations` for disambiguation. - """ - try: - paths = list(nx.all_simple_paths(self._graph, source=source_cs, target=target_cs)) - except nx.NetworkXNoPath as nxe: - raise TransformationPathNotFoundError(source_cs.name, target_cs.name) from nxe - - try: - return self._get_transformation_sequences_from_simple_paths_after_disambiguation( - paths, expected_intermediate_edges - ) - except TransformationPathAmbiguousError as tpae: - raise tpae from TransformationPathAmbiguousError(source_cs.name, target_cs.name) - - def _get_elements_belonging_to_cs(self, cs: NgffCoordinateSystem) -> list[str]: - """ - Get all elements belonging to a coordinate system. - - Parameters - ---------- - cs - The coordinate system to check. - - Returns - ------- - List of element names belonging to the coordinate system. - - Raises - ------ - CoordinateSystemNotFoundError - If the coordinate system does not exist. - - """ - self.assert_coordinate_system_exists(cs) - - elements = [] - for element_name, element_cs in self._element_to_cs_mapping.items(): - if element_cs == cs: - elements.append(element_name) - - return elements - - def __repr__(self) -> str: - """Return a string representation of the TransformationManager.""" - return ( - f"TransformationManager(" - f" coordinate_systems={list(self._graph.nodes())}, " - f" coordinate_transforms={[x[TRANSFORM_KEY] for *_, x in self._graph.edges(data=True)]}, " - f" elements={list(self._element_to_cs_mapping.keys())}" - f")" - ) diff --git a/src/spatialdata/_core/transformation_manager/_transformation_graph.py b/src/spatialdata/_core/transformation_manager/_transformation_graph.py new file mode 100644 index 000000000..5654992cf --- /dev/null +++ b/src/spatialdata/_core/transformation_manager/_transformation_graph.py @@ -0,0 +1,640 @@ +from __future__ import annotations + +from collections.abc import Sequence +from typing import Any, override + +import networkx as nx + +from spatialdata._core.transformation_manager.exceptions import ( + CannotRemoveCoordinateSystemError, + CoordinateSystemAlreadyExistsError, + CoordinateSystemHasElementsError, + CoordinateSystemHasTransformationsError, + CoordinateSystemNotFoundError, + ElementAlreadyExistsError, + ElementNotRegisteredToAnyCoordinateSystemError, + InvalidPathError, + TransformationNotFoundError, + TransformationPathAmbiguousError, + TransformationPathAmbiguousMultipleEdgeExpectedError, + TransformationPathAmbiguousNoEdgeExpectedError, + TransformationPathNotFoundError, + TransformationPathNotSimple, +) +from spatialdata._core.transformation_manager.types import TRANSFORM_KEY +from spatialdata.transformations.graph.edge import BaseTransformationEdge, SequenceEdge +from spatialdata.transformations.graph.vert import CoordSystem + + +class TransformationGraph: + def __init__(self) -> None: + """Initialize a TransformationManager with empty graph and mappings.""" + self._graph: nx.MultiDiGraph[CoordSystem, dict[str, Any], dict[str, BaseTransformationEdge]] = nx.MultiDiGraph() + # MultiDiGraph with CoordSystem objects as nodes and BaseTransformationEdge as edge attributes + self._element_to_cs_mapping: dict[str, CoordSystem] = {} + # mapping element_name to the coordinate system to which the element belongs + + def assert_element_exists(self, element_name: str) -> None: + """ + Assert that an element exists in the transformation manager. + + Parameters + ---------- + element_name + The name of the element to check. + + Raises + ------ + ElementNotFoundError + If the element does not exist. + """ + if element_name not in self._element_to_cs_mapping: + raise ElementNotRegisteredToAnyCoordinateSystemError(element_name) + + def assert_element_does_not_exist(self, element_name: str) -> None: + """ + Assert than an element doesn't exist in the transformation manager. + + Parameters + ---------- + element_name + The name of the element to check. + + Raises + ------ + ElementAlreadyExistsError + If the element already exists. + """ + if element_name in self._element_to_cs_mapping: + raise ElementAlreadyExistsError(element_name) + + def assert_coordinate_system_exists(self, cs: CoordSystem) -> None: + """ + Assert that coordinate system exists in the graph. + + Parameters + ---------- + cs + The coordinate system to check. + + Raises + ------ + CoordinateSystemNotFoundError + If the coordinate system does not exist. + """ + if cs not in self._graph: + raise CoordinateSystemNotFoundError(cs.name) + + def assert_coordinate_system_does_not_exist(self, cs: CoordSystem) -> None: + """ + Assert that coordinate system doesn't exist in the graph. + + Parameters + ---------- + cs + The coordinate system to check. + + Raises + ------ + CoordinateSystemAlreadyExistsError + If the coordinate system already exists. + """ + if cs in self._graph: + raise CoordinateSystemAlreadyExistsError(cs.name) + + def assert_an_edge_exists_between_coordinate_systems( + self, source_cs: CoordSystem, target_cs: CoordSystem, edge_key: str | None = None + ) -> None: + """ + Assert that an edge exists between coordinate systems. + + Parameters + ---------- + source_cs + The input coordinate system. + target_cs + The output coordinate system. + edge_key + If specified, asserts that the specific edge exists between coordinate systems + + + Raises + ------ + CoordinateSystemNotFoundError + If either coordinate system does not exist. + TransformationNotFoundError + If the edge does not exist. + """ + self.assert_coordinate_system_exists(source_cs) + self.assert_coordinate_system_exists(target_cs) + if not self._graph.has_edge(source_cs, target_cs): + raise TransformationNotFoundError(source_cs.name, target_cs.name, edge_key=edge_key) + + def assert_coordinate_system_has_no_transformations(self, cs: CoordSystem) -> None: + """ + Assert that coordinate system has no associated transformations. + + Parameters + ---------- + cs + The coordinate system to check. + + Raises + ------ + CoordinateSystemNotFoundError + If either coordinate system does not exist. + CoordinateSystemHasTransformationsError + If the coordinate system has associated transformations. + + """ + self.assert_coordinate_system_exists(cs) + has_successors = next(self._graph.successors(cs), None) is not None + has_predecessors = next(self._graph.predecessors(cs), None) is not None + + if has_successors or has_predecessors: + raise CoordinateSystemHasTransformationsError(cs.name) + + def assert_coordinate_system_has_no_elements(self, cs: CoordSystem) -> None: + """ + Assert that a coordinate system doesn't have elements belonging to it. + + Parameters + ---------- + cs + The coordinate system to check + + Raises + ------ + CoordinateSystemHasElementsError + if the coordinate system has elements belonging to it. + """ + elements = self._get_elements_belonging_to_cs(cs) + # also checks if cs exists + if elements: + raise CoordinateSystemHasElementsError(cs.name, elements) + + def add_coordinate_system(self, cs: CoordSystem) -> None: + """ + Add a coordinate system to the transformation manager. + + Parameters + ---------- + cs + The coordinate system to add. + + Raises + ------ + CoordinateSystemAlreadyExistsError + If the coordinate system already exists. + """ + self.assert_coordinate_system_does_not_exist(cs) + self._graph.add_node(cs) + + def remove_coordinate_system(self, cs: CoordSystem) -> None: + """ + Remove a coordinate system from the transformation manager. + + Parameters + ---------- + cs + The coordinate system to remove. + + Raises + ------ + CoordinateSystemNotFoundError + If the coordinate system is not found + CannotRemoveCoordinateSystemError + If the coordinate system cannot be removed. + Caused by + CoordinateSystemHasTransformationsError or + CoordinateSystemHasElementsError when the system has dependencies. + """ + try: + self.assert_coordinate_system_has_no_transformations(cs) + # also asserts that cs exists + self.assert_coordinate_system_has_no_elements(cs) + except (CoordinateSystemHasTransformationsError, CoordinateSystemHasElementsError) as err: + raise CannotRemoveCoordinateSystemError(cs.name) from err + + self._graph.remove_node(cs) + + def list_coordinate_systems(self) -> list[CoordSystem]: + """ + List all registered coordinate systems. + + Returns + ------- + A list of coordinate system objects. + """ + return list(self._graph.nodes()) + + def add_element(self, element_name: str, coordinate_system: CoordSystem) -> None: + """ + Register an element and associate it with a coordinate system. + + Parameters + ---------- + element_name + The name of the element. + coordinate_system + The coordinate system to which the element belongs. + + Raises + ------ + CoordinateSystemNotFoundError + If the coordinate system is not found. + """ + self.assert_element_does_not_exist(element_name) + self.assert_coordinate_system_exists(coordinate_system) + self._element_to_cs_mapping[element_name] = coordinate_system + + def get_element_coordinate_system(self, element_name: str) -> CoordSystem: + """ + Get the coordinate system to which an element belongs. + + Parameters + ---------- + element_name + The name of the element. + + Returns + ------- + The coordinate system + + Raises + ------ + ElementNotFoundError + If the element does not exist. + """ + self.assert_element_exists(element_name) + return self._element_to_cs_mapping[element_name] + + def get_outgoing_edges( + self, element_name: str, target_coordinate_system_names: Sequence[str] | None + ) -> list[BaseTransformationEdge]: + + element_coordinate_system = self.get_element_coordinate_system(element_name) + outgoing_edges: list[BaseTransformationEdge] = [] + + successors_to_consider = list(self._graph.successors(element_coordinate_system)) + if target_coordinate_system_names is not None: + successors_to_consider = [x for x in successors_to_consider if x.name in target_coordinate_system_names] + + for successor_coordinate_system in successors_to_consider: + edge_data = self._graph[element_coordinate_system][successor_coordinate_system] + outgoing_edge_this_successor = [x[TRANSFORM_KEY] for x in edge_data.values()] + outgoing_edges += outgoing_edge_this_successor + + return outgoing_edges + + def _unset_element(self, element_name: str) -> None: + """ + Unregister an element from the coordinate system to which it belongs. + + Parameters + ---------- + element_name + The name of the element. + + Raises + ------ + ElementNotFoundError + If the element has not been registered to any coordinate system. + """ + self.assert_element_exists(element_name) + del self._element_to_cs_mapping[element_name] + + @staticmethod + def _get_edge_key(edge: BaseTransformationEdge) -> str: + """ + Encode the definition of an edge into a str to be used as graph-wide edge identifier. + + Parameters + ---------- + edge + + Returns + ------- + edge_key + str, the encoded edge identifier + """ + return str(id(edge)) + + def add_transformation_edge( + self, + transformation_edge: BaseTransformationEdge, + ) -> None: + """ + Add an edge representing a transformation between coordinate systems. + + Parameters + ---------- + transformation_edge + The transformation edge to add. + + Raises + ------ + CoordinateSystemNotFoundError + If either coordinate system does not exist. + """ + source_cs, target_cs = transformation_edge.input, transformation_edge.output + self.assert_coordinate_system_exists(source_cs) + self.assert_coordinate_system_exists(target_cs) + + edge_key = self._get_edge_key(transformation_edge) + edge_attributes = {TRANSFORM_KEY: transformation_edge} + self._graph.add_edge(source_cs, target_cs, key=edge_key, **edge_attributes) + + def get_direct_transformations( + self, source_cs: CoordSystem, target_cs: CoordSystem + ) -> list[BaseTransformationEdge]: + """ + Retrieve transformations directly defined between coordinate systems. + + Parameters + ---------- + source_cs + The input coordinate system. + target_cs + The output coordinate system. + + Returns + ------- + List of transformations (empty if there are no direct transformations). + + Raises + ------ + CoordinateSystemNotFoundError + If either coordinate system does not exist. + """ + self.assert_coordinate_system_exists(source_cs) + self.assert_coordinate_system_exists(target_cs) + + transforms = [] + if self._graph.has_edge(source_cs, target_cs): + for edge_data in self._graph[source_cs][target_cs].values(): + transform: BaseTransformationEdge = edge_data[TRANSFORM_KEY] + transforms.append(transform) + return transforms + + def remove_specific_transformation( + self, + transformation_edge: BaseTransformationEdge, + ) -> None: + """ + Remove a specific transformation between coordinate systems. + + Parameters + ---------- + transformation_edge + The transformation edge to remove. + + Raises + ------ + CoordinateSystemNotFoundError + If either coordinate system does not exist. + TransformationNotFoundError + If the transformation does not exist. + """ + source_cs, target_cs = transformation_edge.input, transformation_edge.output + expected_edge_key = self._get_edge_key(transformation_edge) + self.assert_an_edge_exists_between_coordinate_systems(source_cs, target_cs, edge_key=expected_edge_key) + # also checks if source_cs and target_cs exist + self._graph.remove_edge(source_cs, target_cs, key=expected_edge_key) + + def remove_all_transformations_between_coordinate_systems( + self, + source_cs: CoordSystem, + target_cs: CoordSystem, + ) -> None: + """ + Remove all transformation between coordinate systems. + + Parameters + ---------- + source_cs + The input coordinate system. + target_cs + The output coordinate system. + + Raises + ------ + CoordinateSystemNotFoundError + If either coordinate system does not exist. + TransformationNotFoundError + If no transformation exists between the coordiante systems + """ + self.assert_coordinate_system_exists(source_cs) + self.assert_coordinate_system_exists(target_cs) + if self._graph.has_edge(source_cs, target_cs): + transform_edges_to_remove = {} + for edge_key in self._graph[source_cs][target_cs]: + transform_edges_to_remove[edge_key] = self._graph[source_cs][target_cs][edge_key][TRANSFORM_KEY] + + for edge_key, transform_edge in transform_edges_to_remove.items(): + self._graph.remove_edge(transform_edge.input, transform_edge.output, key=edge_key) + + def _get_transformation_sequences_from_simple_paths_after_disambiguation( + self, + paths: list[list[CoordSystem]], + expected_intermediate_edges: Sequence[BaseTransformationEdge] | None, + ) -> list[SequenceEdge]: + """ + Traverses paths to form sequence of Transformations. + + In case of ambiguity looks into `expected_intermediate_transformations` to disambiguate. + + Parameters + ---------- + paths: + sequence of list of nodes + expected_intermediate_edges: + list of edges, for use when multiple edges are found between two coordinate systems in a path + + Returns + ------- + list of transformation edges, each an instance of the transformation class Sequence + + Raises + ------ + TransformationPathNotSimple + if any path in `paths` is not simple, i.e., has recurring coordinate systems + """ + for path in paths: + if len(set(path)) != len(path): + raise TransformationPathNotSimple(path) + + expected_intermediate_transformation_edge_keys = set[str]() + if expected_intermediate_edges is not None: + for edge_def in expected_intermediate_edges: + edge_key = self._get_edge_key(edge_def) + expected_intermediate_transformation_edge_keys |= {edge_key} + + all_sequences = [] + deduplicated_paths = list({repr(x): x for x in paths}.values()) + for path in deduplicated_paths: + if len(path) <= 1: + raise InvalidPathError(path) + + transformation_list: list[BaseTransformationEdge] = [] + for i in range(len(path) - 1): + source_cs_here, target_cs_here = path[i], path[i + 1] + edge_data = self._graph[source_cs_here][target_cs_here] + if len(edge_data) > 1: + # when there are multiple edges between a pair of coordinate systems in the path + # find the expected edge based on key + expected_intermediate_transformation_key_here = ( + expected_intermediate_transformation_edge_keys & set(edge_data.keys()) + ) + if len(expected_intermediate_transformation_key_here) == 0: + # transformation was not specified in `expected_intermediate_transformations` for disambiguation + raise TransformationPathAmbiguousNoEdgeExpectedError(source_cs_here.name, target_cs_here.name) + if len(expected_intermediate_transformation_key_here) > 1: + # multiple transformations were specified in `expected_intermediate_transformations` + # for disambiguation + raise TransformationPathAmbiguousMultipleEdgeExpectedError( + source_cs_here.name, target_cs_here.name, len(expected_intermediate_transformation_key_here) + ) + edge_key_to_use = list(expected_intermediate_transformation_key_here)[0] + transformation_list.append(edge_data[edge_key_to_use][TRANSFORM_KEY]) + else: + # Only one edge, no ambiguity + edge_key = next(iter(edge_data.keys())) + transformation_list.append(edge_data[edge_key][TRANSFORM_KEY]) + all_sequences.append(SequenceEdge(transformations=transformation_list)) + return all_sequences + + def get_all_shortest_transformation_sequences( + self, + source_cs: CoordSystem, + target_cs: CoordSystem, + expected_intermediate_edges: Sequence[BaseTransformationEdge] = (), + ) -> list[SequenceEdge]: + """ + Get all shortest sequences of transformations between two coordinate systems. + + Parameters + ---------- + source_cs + The source coordinate system. + target_cs + The target coordinate system. + expected_intermediate_edges + list of intermediate edges expected. + Used to choose an edge when multiple edges are found between the same coordinate systems. + + Returns + ------- + list[Sequence] + All shortest sequences of transformations from source_cs to target_cs. + When multiple transformations are defined between the same coordinate systems, only those containing + a transformation among intermediate transformations are included. + + Raises + ------ + CoordinateSystemNotFoundError + If either coordinate system does not exist. + TransformationPathNotFoundError + If no path exists between the source and target coordinate systems. + TransformationPathAmbiguousError + When multiple transformations are defined between the same coordinate systems and transformations + are not specified in `expeceted_intermediate_transformations` for disambiguation. + """ + try: + paths = list(nx.all_shortest_paths(self._graph, source=source_cs, target=target_cs)) + except nx.NetworkXNoPath as nxe: + raise TransformationPathNotFoundError(source_cs.name, target_cs.name) from nxe + + try: + return self._get_transformation_sequences_from_simple_paths_after_disambiguation( + paths, expected_intermediate_edges + ) + except TransformationPathAmbiguousError as tpae: + raise TransformationPathAmbiguousError(source_cs.name, target_cs.name) from tpae + + def get_all_transformation_sequences( + self, + source_cs: CoordSystem, + target_cs: CoordSystem, + expected_intermediate_edges: Sequence[BaseTransformationEdge] = (), + ) -> list[SequenceEdge]: + """ + Get all existing sequences of transformations between two coordinate systems. + + Parameters + ---------- + source_cs + The source coordinate system. + target_cs + The target coordinate system. + expected_intermediate_edges + list of intermediate edges expected. + Used to choose an edge when multiple edges are found between the same coordinate systems. + + Returns + ------- + list[Sequence] + All existing sequences of transformations from source_cs to target_cs. + When multiple transformations are defined between the same coordinate systems, only those containing + a transformation among intermediate transformations are included. + + Raises + ------ + CoordinateSystemNotFoundError + If either coordinate system does not exist. + TransformationPathNotFoundError + If no path exists between the source and target coordinate systems. + TransformationPathAmbiguousError + When multiple transformations are defined between the same coordinate systems and transformations + are not specified in `expected_intermediate_transformations` for disambiguation. + """ + try: + paths = list(nx.all_simple_paths(self._graph, source=source_cs, target=target_cs)) + except nx.NetworkXNoPath as nxe: + raise TransformationPathNotFoundError(source_cs.name, target_cs.name) from nxe + + try: + return self._get_transformation_sequences_from_simple_paths_after_disambiguation( + paths, expected_intermediate_edges + ) + except TransformationPathAmbiguousError as tpae: + raise tpae from TransformationPathAmbiguousError(source_cs.name, target_cs.name) + + def _get_elements_belonging_to_cs(self, cs: CoordSystem) -> list[str]: + """ + Get all elements belonging to a coordinate system. + + Parameters + ---------- + cs + The coordinate system to check. + + Returns + ------- + List of element names belonging to the coordinate system. + + Raises + ------ + CoordinateSystemNotFoundError + If the coordinate system does not exist. + + """ + self.assert_coordinate_system_exists(cs) + + elements = [] + for element_name, element_cs in self._element_to_cs_mapping.items(): + if element_cs == cs: + elements.append(element_name) + + return elements + + @override + def __repr__(self) -> str: + """Return a string representation of the TransformationManager.""" + return ( + f"TransformationManager(" + f" coordinate_systems={list(self._graph.nodes())}, " + f" coordinate_transforms={[x[TRANSFORM_KEY] for *_, x in self._graph.edges(data=True)]}, " + f" elements={list(self._element_to_cs_mapping.keys())}" + f")" + ) diff --git a/src/spatialdata/_core/transformation_manager/exceptions.py b/src/spatialdata/_core/transformation_manager/exceptions.py index 86adeb1f5..19fdb66d7 100644 --- a/src/spatialdata/_core/transformation_manager/exceptions.py +++ b/src/spatialdata/_core/transformation_manager/exceptions.py @@ -1,8 +1,9 @@ from __future__ import annotations +from typing import override + from spatialdata._types import ArrayLike from spatialdata.transformations.graph.vert import Axis, CoordSystem -from spatialdata.transformations.ngff.ngff_coordinate_system import NgffCoordinateSystem class CoordinateSystemNotFoundError(ValueError): @@ -16,7 +17,7 @@ class CoordinateSystemNotFoundError(ValueError): """ def __init__(self, name: str) -> None: - self.name = name + self.name: str = name super().__init__(f"Coordinate system '{name}' not found in the transformation manager.") @@ -31,7 +32,7 @@ class CoordinateSystemAlreadyExistsError(ValueError): """ def __init__(self, name: str) -> None: - self.name = name + self.name: str = name super().__init__(f"Coordinate system '{name}' already exists in the transformation manager.") @@ -46,7 +47,7 @@ class ElementNotRegisteredToAnyCoordinateSystemError(KeyError): """ def __init__(self, element_name: str) -> None: - self.element_name = element_name + self.element_name: str = element_name super().__init__(f"Element '{element_name}' has not been registered to any coordinate system.") @@ -65,9 +66,9 @@ class TransformationNotFoundError(KeyError): """ def __init__(self, source_cs_name: str, target_cs_name: str, edge_key: str | None = None) -> None: - self.input_cs_name = source_cs_name - self.output_cs_name = target_cs_name - self.edge_key = edge_key + self.input_cs_name: str = source_cs_name + self.output_cs_name: str = target_cs_name + self.edge_key: str | None = edge_key msg = f"Transformation from '{source_cs_name}' to '{target_cs_name}' not found" if edge_key is not None: msg += f" with key '{edge_key}'" @@ -85,7 +86,7 @@ class ElementAlreadyExistsError(ValueError): """ def __init__(self, name: str) -> None: - self.name = name + self.name: str = name super().__init__(f"Element '{name}' already exists in the transformation manager") @@ -95,10 +96,10 @@ class InvalidPathError(ValueError): Attributes ---------- - invalid_path : list[NgffCoordinateSystem] + invalid_path : list[CoordSystem] """ - def __init__(self, invalid_path: list[NgffCoordinateSystem]) -> None: + def __init__(self, invalid_path: list[CoordSystem]) -> None: super().__init__(f"Found an invalid path with less than 2 nodes: {invalid_path}") @@ -115,8 +116,8 @@ class TransformationPathNotFoundError(ValueError): """ def __init__(self, source_cs_name: str, target_cs_name: str) -> None: - self.source_cs_name = source_cs_name - self.target_cs_name = target_cs_name + self.source_cs_name: str = source_cs_name + self.target_cs_name: str = target_cs_name super().__init__(f"No transformation path found from {source_cs_name} to {target_cs_name}") @@ -133,8 +134,8 @@ class TransformationPathAmbiguousError(ValueError): """ def __init__(self, source_cs_name: str, target_cs_name: str) -> None: - self.source_cs_name = source_cs_name - self.target_cs_name = target_cs_name + self.source_cs_name: str = source_cs_name + self.target_cs_name: str = target_cs_name base_msg = f"Transformation Path ambiguous from {source_cs_name} to {target_cs_name}." cause_of_confusion = self.cause_of_confusion() msg = f"{base_msg} {cause_of_confusion}" if cause_of_confusion else base_msg @@ -148,6 +149,7 @@ class TransformationPathAmbiguousNoEdgeExpectedError(TransformationPathAmbiguous def __init__(self, source_cs_name: str, target_cs_name: str) -> None: super().__init__(source_cs_name, target_cs_name) + @override def cause_of_confusion(self) -> str: return "Multiple edges found. None of them were specified to be expected" @@ -155,9 +157,10 @@ def cause_of_confusion(self) -> str: class TransformationPathAmbiguousMultipleEdgeExpectedError(TransformationPathAmbiguousError): def __init__(self, source_cs_name: str, target_cs_name: str, number_of_edges_expected: int) -> None: - self.number_of_edges_expected = number_of_edges_expected + self.number_of_edges_expected: int = number_of_edges_expected super().__init__(source_cs_name, target_cs_name) + @override def cause_of_confusion(self) -> str: return f"Multiple ({self.number_of_edges_expected}) edges were specified to be expected" @@ -169,12 +172,12 @@ class TransformationPathNotSimple(ValueError): A simple path is one in which each coordinate system appears only once """ - def __init__(self, path: list[NgffCoordinateSystem]) -> None: - self.path = path + def __init__(self, path: list[CoordSystem]) -> None: + self.path: list[CoordSystem] = path css_formatted_one_per_line = "\n".join(repr(cs) for cs in path) super().__init__( - f"Transformation Path not simple, i.e., some coordinate systems appear multiple times:\n" - f"{css_formatted_one_per_line}" + "Transformation Path not simple, i.e., some coordinate systems appear multiple times:\n" + + f"{css_formatted_one_per_line}" ) @@ -188,7 +191,7 @@ class CannotRemoveCoordinateSystemError(ValueError): """ def __init__(self, name: str) -> None: - self.name = name + self.name: str = name super().__init__(f"Cannot remove coordinate system with name {name}.") @@ -203,7 +206,7 @@ class CoordinateSystemHasTransformationsError(ValueError): """ def __init__(self, name: str) -> None: - self.name = name + self.name: str = name super().__init__(f"Coordinate System ('{name}') has transformations.") @@ -220,8 +223,8 @@ class CoordinateSystemHasElementsError(ValueError): """ def __init__(self, name: str, associated_elements: list[str]) -> None: - self.name = name - self.associated_elements = associated_elements + self.name: str = name + self.associated_elements: list[str] = associated_elements super().__init__(f"Coordinate system '{name}' has elements belonging to it: {associated_elements}") @@ -232,10 +235,21 @@ class TransformationManagerWarning(UserWarning): class IncompatibleCoordSystemsError(Exception): - def __init__(self, input: CoordSystem, output: CoordSystem, message: str | None = None) -> None: + def __init__( + self, + input: CoordSystem, + output: CoordSystem, + transformation_name: str | None = None, + axes_error_message: str | None = None, + ) -> None: self.input = input self.output = output - super().__init__(message or "Output axes can't be mapped to input axes") + self.transformation_name = transformation_name + axes_message = axes_error_message or "Output axes can't be mapped to input axes" + axes_transformation_message = ( + transformation_name and f"In the context of a `{transformation_name}` transformation, {axes_message}" + ) + super().__init__(axes_transformation_message or axes_message) class MissingAxisError(Exception): @@ -291,3 +305,7 @@ def __init__(self, axis: Axis, cs: CoordSystem) -> None: self.axis = axis self.cs = cs super().__init__(f"Axis {axis.name} from coordinate system {cs.name} is not mapped to anything") + + +class AxisParsingException(Exception): + pass diff --git a/src/spatialdata/_core/transformation_manager/transformations_facade.py b/src/spatialdata/_core/transformation_manager/transformations_facade.py new file mode 100644 index 000000000..a06d5ee02 --- /dev/null +++ b/src/spatialdata/_core/transformation_manager/transformations_facade.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from collections.abc import Sequence + +from spatialdata._core.transformation_manager._transformation_graph import TransformationGraph +from spatialdata.transformations import BaseTransformation +from spatialdata.transformations.graph.edge import BaseTransformationEdge + + +class TransformationsFacade: + def __init__(self) -> None: + self.graph = TransformationGraph() + + def _get_element_transformations( + self, element_name: str, target_coordinate_system_names: Sequence[str] + ) -> list[BaseTransformationEdge]: + + return self.graph.get_outgoing_edges(element_name, target_coordinate_system_names) + + def set_element_transformations(self, element_name: str, transformations: Sequence[BaseTransformation]) -> None: + + pass diff --git a/src/spatialdata/_core/transformation_manager/types.py b/src/spatialdata/_core/transformation_manager/types.py new file mode 100644 index 000000000..00a2db1f4 --- /dev/null +++ b/src/spatialdata/_core/transformation_manager/types.py @@ -0,0 +1,3 @@ +from __future__ import annotations + +TRANSFORM_KEY = "transformation" diff --git a/src/spatialdata/models/_utils.py b/src/spatialdata/models/_utils.py index 3a57d219a..ccda78656 100644 --- a/src/spatialdata/models/_utils.py +++ b/src/spatialdata/models/_utils.py @@ -36,7 +36,13 @@ class NgffAxisType(StrEnum): CHANNEL = "channel" -axis_type_mapping_ngff = {C: NgffAxisType.CHANNEL, X: NgffAxisType.SPACE, Y: NgffAxisType.SPACE, Z: NgffAxisType.SPACE} +axis_type_mapping_ngff = { + C: NgffAxisType.CHANNEL, + X: NgffAxisType.SPACE, + Y: NgffAxisType.SPACE, + Z: NgffAxisType.SPACE, +} + if TYPE_CHECKING: from anndata import AnnData diff --git a/src/spatialdata/transformations/graph/edge.py b/src/spatialdata/transformations/graph/edge.py index ae23363e2..890174eee 100644 --- a/src/spatialdata/transformations/graph/edge.py +++ b/src/spatialdata/transformations/graph/edge.py @@ -298,7 +298,10 @@ def __init__( """ if input.num_axes != output.num_axes: raise IncompatibleCoordSystemsError( - input=input, output=output, message="Axes must have the same number of dimensions" + input=input, + output=output, + axes_error_message="Axes must have the same number of dimensions", + transformation_name=self.__class__.__name__, ) super().__init__(input=input, output=output, name=name) @@ -358,7 +361,7 @@ def __init__( if input.num_axes != output.num_axes: raise IncompatibleCoordSystemsError( - input=input, output=output, message="Input and output must have the same number of axes" + input=input, output=output, axes_error_message="Input and output must have the same number of axes" ) unmapped_inputs = set(input.axes) @@ -465,7 +468,7 @@ def __init__( raise IncompatibleCoordSystemsError( input=input, output=output, - message=message, + axes_error_message=message, ) self.dropped_inputs = set(dropped_inputs) @@ -545,7 +548,7 @@ def __init__( """ if input.num_axes != output.num_axes: raise IncompatibleCoordSystemsError( - input=input, output=output, message="Number of input and output axes must be the same" + input=input, output=output, axes_error_message="Number of input and output axes must be the same" ) expected_translation_shape = (input.num_axes,) if translation.shape != expected_translation_shape: @@ -623,7 +626,7 @@ def __init__( raise UnexpectedShapeError(array_name="scale", array_shape=scale.shape, expected_shape=expected_scale_shape) if input.num_axes != output.num_axes: raise IncompatibleCoordSystemsError( - input=input, output=output, message="input and output must have same number of dimensions" + input=input, output=output, axes_error_message="input and output must have same number of dimensions" ) self.scale = scale super().__init__(input=input, output=output, name=name) @@ -698,7 +701,7 @@ def __init__( """ if input.num_axes != output.num_axes: raise IncompatibleCoordSystemsError( - input=input, output=output, message="input and output should have the same numbe rof axes" + input=input, output=output, axes_error_message="input and output should have the same numbe rof axes" ) expected_shape = (output.num_axes, input.num_axes) if linear_matrix.shape != expected_shape: @@ -778,7 +781,7 @@ def __init__( raise IncompatibleCoordSystemsError( input=current_transf.input, output=previous_transf.output, - message=( + axes_error_message=( f"Output of transformation #{transf_idx - 1} is different " f"from input of transformation #{transf_idx}" ), diff --git a/tests/core/transformation_manager/conftest.py b/tests/core/transformation_manager/conftest.py index 696935996..212520266 100644 --- a/tests/core/transformation_manager/conftest.py +++ b/tests/core/transformation_manager/conftest.py @@ -6,87 +6,82 @@ from __future__ import annotations +import numpy as np import pytest -from spatialdata.transformations import Affine, Scale, Translation -from spatialdata.transformations.ngff.ngff_coordinate_system import NgffAxis, NgffCoordinateSystem +from spatialdata.transformations.graph.edge import AffineEdge, ScaleEdge, TranslationEdge +from spatialdata.transformations.graph.vert import Axis, CoordSystem -def get_ngff_coodinate_system(cs_name: str) -> NgffCoordinateSystem: - return NgffCoordinateSystem( +def get_coord_system(cs_name: str) -> CoordSystem: + return CoordSystem( name=cs_name, - axes=[NgffAxis(name="x", type="space", unit="micrometer"), NgffAxis(name="y", type="space", unit="micrometer")], + axes=[Axis(name="x", type="space", unit="micrometer"), Axis(name="y", type="space", unit="micrometer")], ) @pytest.fixture -def one_point_graph() -> tuple[list[NgffCoordinateSystem], list[Translation]]: - """Fixture providing a single point graph with one coordinate system and one transformation.""" - coordinate_systems = [get_ngff_coodinate_system("cs1")] - transformations = [Translation(translation=[1.0, 2.0], axes=("x", "y"))] - return coordinate_systems, transformations +def one_point_graph() -> list[CoordSystem]: + """Fixture providing a single point graph with one coordinate system.""" + return [get_coord_system("cs1")] @pytest.fixture -def fully_connected_two_point_graph() -> tuple[list[NgffCoordinateSystem], list[Translation]]: +def fully_connected_two_point_graph() -> tuple[list[CoordSystem], list[TranslationEdge]]: """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")] + coordinate_systems = [get_coord_system("cs1"), get_coord_system("cs2")] + cs1, cs2 = coordinate_systems transformations = [ - Translation(translation=[-1.0, -2.0], axes=("x", "y")), + TranslationEdge(translation=np.array([-1.0, -2.0]), input=cs1, output=cs2), ] return coordinate_systems, transformations @pytest.fixture -def four_point_graph() -> tuple[list[NgffCoordinateSystem], list[Scale | Translation]]: +def four_point_graph() -> tuple[list[CoordSystem], list[ScaleEdge | TranslationEdge]]: """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"), + get_coord_system("cs1"), + get_coord_system("cs2"), + get_coord_system("cs3"), + get_coord_system("cs4"), ] + cs1, cs2, cs3, cs4 = coordinate_systems transformations = [ - Translation(translation=[1.0, 2.0], axes=("x", "y")), # cs1 -> cs2 - Translation(translation=[3.0, 4.0], axes=("x", "y")), # cs2 -> cs3 - Scale( - scale=[ - 2, - 2, - ], - axes=("x", "y"), - ), # cs3 -> cs4; - Translation(translation=[4.0, 6.0], axes=("x", "y")), # cs1 -> cs3 (consistent with cs1->cs2 and cs2->cs3) + TranslationEdge(translation=np.array([1.0, 2.0]), input=cs1, output=cs2), # cs1 -> cs2 + TranslationEdge(translation=np.array([3.0, 4.0]), input=cs2, output=cs3), # cs2 -> cs3 + ScaleEdge(scale=np.array([2, 2]), input=cs3, output=cs4), # cs3 -> cs4 + TranslationEdge(translation=np.array([4.0, 6.0]), input=cs1, output=cs3), + # cs1 -> cs3 (consistent with cs1->cs2 and cs2->cs3) ] return coordinate_systems, transformations @pytest.fixture -def five_point_graph() -> tuple[list[NgffCoordinateSystem], list[Scale | Translation | Affine]]: +def five_point_graph() -> tuple[list[CoordSystem], list[ScaleEdge | TranslationEdge | AffineEdge]]: """Fixture providing a five-point graph with five coordinate systems and five transformations.""" coordinate_systems = [ - get_ngff_coodinate_system("cs1"), - get_ngff_coodinate_system("cs2"), - get_ngff_coodinate_system("cs3"), - get_ngff_coodinate_system("cs4"), - get_ngff_coodinate_system("cs5"), + get_coord_system("cs1"), + get_coord_system("cs2"), + get_coord_system("cs3"), + get_coord_system("cs4"), + get_coord_system("cs5"), ] + cs1, cs2, cs3, cs4, cs5 = coordinate_systems transformations = [ - Translation(translation=[1.0, 2.0], axes=("x", "y")), # cs1 -> cs2, cs4 -> cs3 - Translation(translation=[3.0, 4.0], axes=("x", "y")), # cs2 -> cs3, cs1 -> cs4 - Translation(translation=[4.0, 6.0], axes=("x", "y")), # cs1 -> cs3 (consistent with cs1->cs2 and cs2->cs3) - Scale( - scale=[ - 2, - 2, - ], - axes=("x", "y"), - ), # cs3 -> cs5; - Affine( - matrix=[[2, 0, 0], [0, 2, 0], [0, 0, 1]], - input_axes=("x", "y"), - output_axes=("x", "y"), + TranslationEdge(translation=np.array([1.0, 2.0]), input=cs1, output=cs2), # cs1 -> cs2, + TranslationEdge(translation=np.array([1.0, 2.0]), input=cs4, output=cs3), # cs4 -> cs3 + TranslationEdge(translation=np.array([3.0, 4.0]), input=cs2, output=cs3), # cs2 -> cs3 + TranslationEdge(translation=np.array([3.0, 4.0]), input=cs1, output=cs4), # cs1 -> cs4 + TranslationEdge(translation=np.array([4.0, 6.0]), input=cs1, output=cs3), + # cs1 -> cs3 (consistent with cs1->cs2 and cs2->cs3) + ScaleEdge(scale=np.array([2, 2]), input=cs3, output=cs5), # cs3 -> cs5; + AffineEdge( + linear=np.identity(2), + translation=np.array([0, 0]), + input=cs3, + output=cs5, ), # cs3 -> cs5 ] return coordinate_systems, transformations diff --git a/tests/core/transformation_manager/test_transformation_manager.py b/tests/core/transformation_manager/test_transformation_manager.py index c1f5d8300..ccdf948f7 100644 --- a/tests/core/transformation_manager/test_transformation_manager.py +++ b/tests/core/transformation_manager/test_transformation_manager.py @@ -10,7 +10,8 @@ import pytest -from spatialdata._core.transformation_manager import TRANSFORM_KEY, TransformationManager +from spatialdata._core.transformation_manager import TRANSFORM_KEY +from spatialdata._core.transformation_manager._transformation_graph import TransformationGraph from spatialdata._core.transformation_manager.exceptions import ( CannotRemoveCoordinateSystemError, CoordinateSystemAlreadyExistsError, @@ -25,13 +26,13 @@ TransformationPathNotFoundError, TransformationPathNotSimple, ) -from spatialdata.transformations.transformations import Sequence +from spatialdata.transformations.graph.edge import BaseTransfEdge, SequenceEdge def test_initialization(): """Test that TransformationManager initializes correctly.""" - tm = TransformationManager() + tm = TransformationGraph() assert len(tm._graph.nodes()) == 0 assert len(tm._graph.edges()) == 0 assert tm._element_to_cs_mapping == {} @@ -40,8 +41,8 @@ def test_initialization(): def test_add_coordinate_system(one_point_graph): """Test adding a coordinate system.""" - tm = TransformationManager() - [cs1], _ = one_point_graph + tm = TransformationGraph() + [cs1] = one_point_graph tm.add_coordinate_system(cs1) assert cs1 in tm._graph.nodes() @@ -49,8 +50,8 @@ def test_add_coordinate_system(one_point_graph): def test_add_coordinate_system_duplicate(one_point_graph): """Test adding an already existing coordinate system""" - tm = TransformationManager() - [cs1], _ = one_point_graph + tm = TransformationGraph() + [cs1] = one_point_graph tm.add_coordinate_system(cs1) with pytest.raises(CoordinateSystemAlreadyExistsError, match=f"Coordinate system '{cs1.name}' already exists"): @@ -59,8 +60,8 @@ def test_add_coordinate_system_duplicate(one_point_graph): def test_remove_coordinate_system(one_point_graph): """Test removing a coordinate system.""" - tm = TransformationManager() - [cs1], _ = one_point_graph + tm = TransformationGraph() + [cs1] = one_point_graph tm.add_coordinate_system(cs1) tm.remove_coordinate_system(cs1) @@ -70,8 +71,8 @@ def test_remove_coordinate_system(one_point_graph): def test_remove_coordinate_system_nonexistent(one_point_graph): """Test that removing a non-existent coordinate system raises CoordinateSystemNotFoundError.""" - tm = TransformationManager() - [cs1], _ = one_point_graph + tm = TransformationGraph() + [cs1] = one_point_graph # Add the coordinate system first tm.add_coordinate_system(cs1) @@ -88,13 +89,13 @@ def test_remove_coordinate_system_with_associated_transformations(fully_connecte """ Test that removing a coordinate system with associated transformations raises CannotRemoveCoordinateSystemError. """ - tm = TransformationManager() + tm = TransformationGraph() [cs1, cs2], [transformation] = fully_connected_two_point_graph tm.add_coordinate_system(cs1) tm.add_coordinate_system(cs2) - tm.add_transformation(cs1, cs2, transformation) + tm.add_transformation_edge(transformation) # Should raise CannotRemoveCoordinateSystem when trying to remove a coordinate system with transformations with pytest.raises(CannotRemoveCoordinateSystemError, match="Cannot remove coordinate system"): @@ -107,8 +108,8 @@ def test_remove_coordinate_system_with_associated_transformations(fully_connecte def test_remove_coordinate_system_with_belonging_elements(one_point_graph): """Test that removing a coordinate system with element associations raises CannotRemoveCoordinateSystemError.""" - tm = TransformationManager() - [cs1], _ = one_point_graph + tm = TransformationGraph() + [cs1] = one_point_graph tm.add_coordinate_system(cs1) tm.add_element("image1", cs1) @@ -118,7 +119,7 @@ def test_remove_coordinate_system_with_belonging_elements(one_point_graph): def test_list_coordinate_systems(fully_connected_two_point_graph): """Test listing all coordinate systems.""" - tm = TransformationManager() + tm = TransformationGraph() [cs1, cs2], _ = fully_connected_two_point_graph tm.add_coordinate_system(cs1) tm.add_coordinate_system(cs2) @@ -131,8 +132,8 @@ def test_list_coordinate_systems(fully_connected_two_point_graph): def test_add_element(one_point_graph): """Test adding an element with an existing coordinate system.""" - tm = TransformationManager() - [cs1], _ = one_point_graph + tm = TransformationGraph() + [cs1] = one_point_graph tm.add_coordinate_system(cs1) tm.add_element("image1", cs1) @@ -143,8 +144,8 @@ def test_add_element(one_point_graph): def test_add_element_duplicate(one_point_graph): """Test that adding a duplicate element raises ElementAlreadyExistsError.""" - tm = TransformationManager() - [cs1], _ = one_point_graph + tm = TransformationGraph() + [cs1] = one_point_graph tm.add_coordinate_system(cs1) element_name = "image1" tm.add_element(element_name, cs1) @@ -158,8 +159,8 @@ def test_add_element_duplicate(one_point_graph): def test_unset_element(one_point_graph): """Test unsetting an element.""" - tm = TransformationManager() - [cs1], _ = one_point_graph + tm = TransformationGraph() + [cs1] = one_point_graph tm.add_coordinate_system(cs1) tm.add_element("image1", cs1) tm._unset_element("image1") @@ -169,8 +170,8 @@ def test_unset_element(one_point_graph): def test_unset_element_nonexistent(one_point_graph): """Test that unsetting a non-existent element raises ElementNotFoundError.""" - tm = TransformationManager() - [cs1], _ = one_point_graph + tm = TransformationGraph() + [cs1] = one_point_graph tm.add_coordinate_system(cs1) element_name = "image1" @@ -182,32 +183,32 @@ def test_unset_element_nonexistent(one_point_graph): def test_add_transformation(fully_connected_two_point_graph): """Test adding a transformation between coordinate systems.""" - tm = TransformationManager() - [cs1, cs2], [transform] = fully_connected_two_point_graph + tm = TransformationGraph() + [cs1, cs2], [transform_edge] = fully_connected_two_point_graph tm.add_coordinate_system(cs1) tm.add_coordinate_system(cs2) - tm.add_transformation(cs1, cs2, transform) + tm.add_transformation_edge(transform_edge) assert tm._graph.has_edge(cs1, cs2) # Get the first edge key and check the transformation - edge_key = tm._get_edge_key((cs1, cs2, transform)) - assert tm._graph[cs1][cs2][edge_key][TRANSFORM_KEY] == transform + edge_key = tm._get_edge_key(transform_edge) + assert tm._graph[cs1][cs2][edge_key][TRANSFORM_KEY] is transform_edge def test_add_transformation_existing_edge(fully_connected_two_point_graph): """Test adding a transformation between coordinate systems that already have an edge.""" - tm = TransformationManager() - [cs1, cs2], [transform] = fully_connected_two_point_graph + tm = TransformationGraph() + [cs1, cs2], [transform_edge] = fully_connected_two_point_graph tm.add_coordinate_system(cs1) tm.add_coordinate_system(cs2) - tm.add_transformation(cs1, cs2, transform) + tm.add_transformation_edge(transform_edge) assert tm._graph.has_edge(cs1, cs2) # Add same transformation again between the same coordinate systems - tm.add_transformation(cs1, cs2, transform) + tm.add_transformation_edge(transform_edge) # Should have just rewritten the edge assert tm._graph.has_edge(cs1, cs2) assert len(tm._graph[cs1][cs2]) == 1 @@ -215,38 +216,38 @@ def test_add_transformation_existing_edge(fully_connected_two_point_graph): def test_add_transformation_nonexistent_cs(fully_connected_two_point_graph): """Test that adding a transformation with non-existent coordinate systems raises CoordinateSystemNotFoundError.""" - tm = TransformationManager() - [cs1, cs2], [transform] = fully_connected_two_point_graph + tm = TransformationGraph() + [cs1, cs2], [transform_edge] = fully_connected_two_point_graph expected_msg = "Coordinate system .* not found in the transformation manager." with pytest.raises(CoordinateSystemNotFoundError, match=expected_msg): - tm.add_transformation(cs1, cs2, transform) + tm.add_transformation_edge(transform_edge) # Add one coordinate system tm.add_coordinate_system(cs1) with pytest.raises(CoordinateSystemNotFoundError, match=expected_msg): - tm.add_transformation(cs1, cs2, transform) + tm.add_transformation_edge(transform_edge) def test_get_direct_transformations(fully_connected_two_point_graph): """Test getting an existing transformation.""" - tm = TransformationManager() - [cs1, cs2], [transform] = fully_connected_two_point_graph + tm = TransformationGraph() + [cs1, cs2], [transform_edge] = fully_connected_two_point_graph tm.add_coordinate_system(cs1) tm.add_coordinate_system(cs2) - tm.add_transformation(cs1, cs2, transform) + tm.add_transformation_edge(transform_edge) retrieved = tm.get_direct_transformations(cs1, cs2) - assert retrieved == [transform] + assert len(retrieved) == 1 + assert retrieved[0] is transform_edge def test_get_direct_transformations_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 = TransformationGraph() + [cs1, cs2], _ = fully_connected_two_point_graph tm.add_coordinate_system(cs1) tm.add_coordinate_system(cs2) @@ -257,12 +258,12 @@ def test_get_direct_transformations_nonexistent(fully_connected_two_point_graph) def test_remove_all_transformations_between_coordinate_systems(fully_connected_two_point_graph, mocker): """Test removing all transformations between coordinate systems.""" - tm = TransformationManager() - [cs1, cs2], [transform] = fully_connected_two_point_graph + tm = TransformationGraph() + [cs1, cs2], [transform_edge] = fully_connected_two_point_graph tm.add_coordinate_system(cs1) tm.add_coordinate_system(cs2) - tm.add_transformation(cs1, cs2, transform) + tm.add_transformation_edge(transform_edge) spy = mocker.spy(tm._graph, "remove_edge") tm.remove_all_transformations_between_coordinate_systems(cs1, cs2) @@ -273,7 +274,7 @@ def test_remove_all_transformations_between_coordinate_systems(fully_connected_t def test_remove_all_transformation_nonexistent(fully_connected_two_point_graph, mocker): """Test that removing non-existent transformations between coordinate systems raises TransformationNotFoundError.""" - tm = TransformationManager() + tm = TransformationGraph() [cs1, cs2], _ = fully_connected_two_point_graph tm._graph.add_node(cs1) tm._graph.add_node(cs2) @@ -286,16 +287,19 @@ def test_remove_all_transformation_nonexistent(fully_connected_two_point_graph, def test_remove_specific_transformation_between_coordinate_systems(five_point_graph): """Test removing specific transformation between coordinate systems.""" - tm = TransformationManager() - [_cs1, _cs2, cs3, _cs4, cs5], [_transform1, _transform2, _transform3, transform4, transform5] = five_point_graph + tm = TransformationGraph() + + coord_systems, transform_edges = five_point_graph + [_cs1, _cs2, cs3, _cs4, cs5] = coord_systems + [_transform1, _transform2, _transform3, _transform4, _transform5, transform6, transform7] = transform_edges tm.add_coordinate_system(cs3) tm.add_coordinate_system(cs5) - tm.add_transformation(cs3, cs5, transform4) - tm.add_transformation(cs3, cs5, transform5) + tm.add_transformation_edge(transform6) + tm.add_transformation_edge(transform7) - tm.remove_specific_transformation(cs3, cs5, transform4) - edge_key = tm._get_edge_key((cs3, cs5, transform4)) + tm.remove_specific_transformation(transform7) + edge_key = tm._get_edge_key(transform7) assert not tm._graph.has_edge(cs3, cs5, key=edge_key) @@ -305,20 +309,32 @@ def test_remove_specific_transformation_between_coordinate_systems_non_existent( TransformationNotFoundError. """ - tm = TransformationManager() - [_cs1, _cs2, cs3, _cs4, cs5], [_transform1, _transform2, _transform3, transform4, _transform5] = five_point_graph + tm = TransformationGraph() + coord_systems, transform_edges = five_point_graph + [_cs1, _cs2, cs3, _cs4, cs5] = coord_systems + [_transform1, _transform2, _transform3, _transform4, _transform5, transform6, transform7] = transform_edges tm.add_coordinate_system(cs3) tm.add_coordinate_system(cs5) with pytest.raises( TransformationNotFoundError, match=f"Transformation from '{cs3.name}' to '{cs5.name}' not found" ): - tm.remove_specific_transformation(cs3, cs5, transform4) + tm.remove_specific_transformation(transform7) + + +def assert_sequences_equivalent( + result_sequences: list[SequenceEdge], expected_transform_lists: list[list[BaseTransfEdge]] +): + + assert len(result_sequences) == len(expected_transform_lists) + assert all(isinstance(x, SequenceEdge) for x in result_sequences) + transformation_sequences_found = [x.transformations for x in result_sequences] + assert set(map(tuple, expected_transform_lists)) == set(map(tuple, transformation_sequences_found)) def test_get_all_shortest_transformation_sequences_direct(four_point_graph): """Test getting all shortest transformation sequences for a direct transformation path.""" - tm = TransformationManager() + tm = TransformationGraph() [cs1, cs2, cs3, cs4], [transform1, transform2, _transform3, transform4] = four_point_graph tm.add_coordinate_system(cs1) @@ -326,62 +342,62 @@ def test_get_all_shortest_transformation_sequences_direct(four_point_graph): tm.add_coordinate_system(cs3) tm.add_coordinate_system(cs4) - tm.add_transformation(cs1, cs2, transform1) - tm.add_transformation(cs2, cs3, transform2) - tm.add_transformation(cs1, cs3, transform4) + tm.add_transformation_edge(transform1) + tm.add_transformation_edge(transform2) + tm.add_transformation_edge(transform4) sequences = tm.get_all_shortest_transformation_sequences(cs1, cs3) - assert len(sequences) == 1 - assert Sequence([transform4]) in sequences + assert_sequences_equivalent(sequences, [[transform4]]) def test_get_all_shortest_transformation_sequences_indirect_one_path(four_point_graph): """Test getting all shortest transformation sequences for one indirect transformation path.""" - tm = TransformationManager() + tm = TransformationGraph() [cs1, cs2, cs3, _cs4], [transform1, transform2, _transform3, _transform4] = four_point_graph tm.add_coordinate_system(cs1) tm.add_coordinate_system(cs2) tm.add_coordinate_system(cs3) - tm.add_transformation(cs1, cs2, transform1) - tm.add_transformation(cs2, cs3, transform2) + tm.add_transformation_edge(transform1) + tm.add_transformation_edge(transform2) sequences = tm.get_all_shortest_transformation_sequences(cs1, cs3) - assert len(sequences) == 1 - assert Sequence([transform1, transform2]) in sequences + assert_sequences_equivalent(sequences, [[transform1, transform2]]) def test_get_all_shortest_transformation_sequences_indirect_two_paths(five_point_graph): """Test getting all shortest transformation sequences for two indirect transformation paths.""" - tm = TransformationManager() - ([cs1, cs2, cs3, cs4, _cs5], [transform1, transform2, _transform3, _transform4, _transform5]) = five_point_graph + tm = TransformationGraph() + + coord_systems, transform_edges = five_point_graph + [cs1, cs2, cs3, cs4, _cs5] = coord_systems + [transform1, transform2, transform3, transform4, _transform5, _transform6, _transform7] = transform_edges + tm.add_coordinate_system(cs1) tm.add_coordinate_system(cs2) tm.add_coordinate_system(cs3) tm.add_coordinate_system(cs4) - tm.add_transformation(cs1, cs2, transform1) - tm.add_transformation(cs2, cs3, transform2) - tm.add_transformation(cs1, cs4, transform2) - tm.add_transformation(cs4, cs3, transform1) + tm.add_transformation_edge(transform1) + tm.add_transformation_edge(transform3) + tm.add_transformation_edge(transform4) + tm.add_transformation_edge(transform2) sequences = tm.get_all_shortest_transformation_sequences(cs1, cs3) - assert len(sequences) == 2 - assert Sequence([transform1, transform2]) in sequences - assert Sequence([transform2, transform1]) in sequences + assert_sequences_equivalent(sequences, [[transform4, transform2], [transform1, transform3]]) def test_get_all_shortest_transformation_sequences_no_path(four_point_graph): """Test that getting all shortest transformation sequences with no path raises TransformationPathNotFoundError.""" - tm = TransformationManager() + tm = TransformationGraph() [cs1, cs2, cs3, cs4], [transform1, transform2, _transform3, _transform4] = four_point_graph tm.add_coordinate_system(cs1) tm.add_coordinate_system(cs2) tm.add_coordinate_system(cs3) tm.add_coordinate_system(cs4) - tm.add_transformation(cs1, cs2, transform1) - tm.add_transformation(cs2, cs3, transform2) + tm.add_transformation_edge(transform1) + tm.add_transformation_edge(transform2) expected_error_msg = f"No transformation path found from {cs1.name} to {cs4.name}" with pytest.raises(TransformationPathNotFoundError, match=expected_error_msg): @@ -390,49 +406,55 @@ def test_get_all_shortest_transformation_sequences_no_path(four_point_graph): def test_get_all_shortest_transformation_sequences_multiple_paths_multiple_edges_success(five_point_graph): """Test getting all shortest transformation sequences, with multiple path and multiple edges between nodes.""" - tm = TransformationManager() - [cs1, cs2, cs3, cs4, cs5], [transform1, transform2, transform3, transform4, transform5] = five_point_graph + tm = TransformationGraph() + + coord_systems, transform_edges = five_point_graph + [cs1, cs2, cs3, cs4, cs5] = coord_systems + [transform1, transform2, transform3, transform4, _transform5, transform6, transform7] = transform_edges + tm.add_coordinate_system(cs1) tm.add_coordinate_system(cs2) tm.add_coordinate_system(cs3) tm.add_coordinate_system(cs4) tm.add_coordinate_system(cs5) - tm.add_transformation(cs1, cs2, transform1) - tm.add_transformation(cs2, cs3, transform2) - tm.add_transformation(cs1, cs4, transform2) - tm.add_transformation(cs4, cs3, transform1) + tm.add_transformation_edge(transform1) + tm.add_transformation_edge(transform3) + tm.add_transformation_edge(transform4) + tm.add_transformation_edge(transform2) # tm.add_transformation(cs1, cs3, transform3) - tm.add_transformation(cs3, cs5, transform4) - tm.add_transformation(cs3, cs5, transform5) + tm.add_transformation_edge(transform6) + tm.add_transformation_edge(transform7) - expected_intermediate_edges = [(cs3, cs5, transform4)] + expected_intermediate_edges = [transform6] sequences = tm.get_all_shortest_transformation_sequences( cs1, cs5, expected_intermediate_edges=expected_intermediate_edges ) - assert len(sequences) == 2 - assert Sequence([transform1, transform2, transform4]) in sequences - assert Sequence([transform2, transform1, transform4]) in sequences + assert_sequences_equivalent(sequences, [[transform1, transform3, transform6], [transform4, transform2, transform6]]) def test_get_all_shortest_transformation_sequences_multiple_paths_multiple_edges_failure(five_point_graph): """Test getting all shortest transformation sequences, with multiple path and multiple edges between nodes.""" - tm = TransformationManager() - [cs1, cs2, cs3, cs4, cs5], [transform1, transform2, transform3, transform4, transform5] = five_point_graph + tm = TransformationGraph() + + coord_systems, transform_edges = five_point_graph + [cs1, cs2, cs3, cs4, cs5] = coord_systems + [transform1, transform2, transform3, transform4, _transform5, transform6, transform7] = transform_edges + tm.add_coordinate_system(cs1) tm.add_coordinate_system(cs2) tm.add_coordinate_system(cs3) tm.add_coordinate_system(cs4) tm.add_coordinate_system(cs5) - tm.add_transformation(cs1, cs2, transform1) - tm.add_transformation(cs2, cs3, transform2) - tm.add_transformation(cs1, cs4, transform2) - tm.add_transformation(cs4, cs3, transform1) - tm.add_transformation(cs1, cs3, transform3) - tm.add_transformation(cs3, cs5, transform4) - tm.add_transformation(cs3, cs5, transform5) + tm.add_transformation_edge(transform1) + tm.add_transformation_edge(transform3) + tm.add_transformation_edge(transform4) + tm.add_transformation_edge(transform2) + # tm.add_transformation(cs1, cs3, transform3) + tm.add_transformation_edge(transform6) + tm.add_transformation_edge(transform7) expected_msg = f"Transformation Path ambiguous from {cs1.name} to {cs5.name}." with pytest.raises(TransformationPathAmbiguousError, match=expected_msg): @@ -441,89 +463,97 @@ def test_get_all_shortest_transformation_sequences_multiple_paths_multiple_edges def test_get_all_transformation_sequences(four_point_graph): """Test getting all transformation sequences.""" - tm = TransformationManager() + tm = TransformationGraph() [cs1, cs2, cs3, cs4], [transform1, transform2, transform3, transform4] = four_point_graph tm.add_coordinate_system(cs1) tm.add_coordinate_system(cs2) tm.add_coordinate_system(cs3) tm.add_coordinate_system(cs4) - tm.add_transformation(cs1, cs2, transform1) - tm.add_transformation(cs2, cs3, transform2) - tm.add_transformation(cs3, cs4, transform3) - tm.add_transformation(cs1, cs3, transform4) + tm.add_transformation_edge(transform1) + tm.add_transformation_edge(transform2) + tm.add_transformation_edge(transform3) + tm.add_transformation_edge(transform4) sequences = tm.get_all_transformation_sequences(cs1, cs4) - assert len(sequences) == 2 - assert Sequence([transform1, transform2, transform3]) in sequences - assert Sequence([transform4, transform3]) in sequences + assert_sequences_equivalent(sequences, [[transform1, transform2, transform3], [transform4, transform3]]) def test_get_all_transformation_sequences_multiple_paths(five_point_graph): """Test getting all transformation sequences, with multiple path and multiple edges between nodes.""" - tm = TransformationManager() - [cs1, cs2, cs3, cs4, _cs5], [transform1, transform2, transform3, _transform4, _transform5] = five_point_graph + tm = TransformationGraph() + + coord_systems, transform_edges = five_point_graph + [cs1, cs2, cs3, cs4, cs5] = coord_systems + [transform1, transform2, transform3, transform4, transform5, _transform6, _transform7] = transform_edges + tm.add_coordinate_system(cs1) tm.add_coordinate_system(cs2) tm.add_coordinate_system(cs3) tm.add_coordinate_system(cs4) + tm.add_coordinate_system(cs5) - tm.add_transformation(cs1, cs2, transform1) - tm.add_transformation(cs2, cs3, transform2) - tm.add_transformation(cs1, cs4, transform2) - tm.add_transformation(cs4, cs3, transform1) - tm.add_transformation(cs1, cs3, transform3) + tm.add_transformation_edge(transform1) + tm.add_transformation_edge(transform3) + tm.add_transformation_edge(transform4) + tm.add_transformation_edge(transform2) + tm.add_transformation_edge(transform5) sequences = tm.get_all_transformation_sequences(cs1, cs3) - assert len(sequences) == 3 - assert Sequence([transform1, transform2]) in sequences - assert Sequence([transform2, transform1]) in sequences - assert Sequence([transform3]) in sequences + assert_sequences_equivalent(sequences, [[transform1, transform3], [transform4, transform2], [transform5]]) def test_get_all_transformation_sequences_multiple_paths_multiple_edges_success(five_point_graph): """Test getting all transformation sequences, with multiple paths and multiple edges between nodes.""" - tm = TransformationManager() - [cs1, cs2, cs3, cs4, cs5], [transform1, transform2, transform3, transform4, transform5] = five_point_graph + tm = TransformationGraph() + + coord_systems, transform_edges = five_point_graph + [cs1, cs2, cs3, cs4, cs5] = coord_systems + [transform1, transform2, transform3, transform4, transform5, transform6, transform7] = transform_edges + tm.add_coordinate_system(cs1) tm.add_coordinate_system(cs2) tm.add_coordinate_system(cs3) tm.add_coordinate_system(cs4) tm.add_coordinate_system(cs5) - tm.add_transformation(cs1, cs2, transform1) - tm.add_transformation(cs2, cs3, transform2) - tm.add_transformation(cs1, cs4, transform2) - tm.add_transformation(cs4, cs3, transform1) - tm.add_transformation(cs1, cs3, transform3) - tm.add_transformation(cs3, cs5, transform4) - tm.add_transformation(cs3, cs5, transform5) + tm.add_transformation_edge(transform1) + tm.add_transformation_edge(transform3) + tm.add_transformation_edge(transform4) + tm.add_transformation_edge(transform2) + tm.add_transformation_edge(transform5) + tm.add_transformation_edge(transform6) + tm.add_transformation_edge(transform7) - expected_intermediate_edges = [(cs3, cs5, transform4)] + expected_intermediate_edges = [transform6] sequences = tm.get_all_transformation_sequences(cs1, cs5, expected_intermediate_edges=expected_intermediate_edges) - assert len(sequences) == 3 - assert Sequence([transform1, transform2, transform4]) in sequences - assert Sequence([transform2, transform1, transform4]) in sequences - assert Sequence([transform3, transform4]) in sequences + assert_sequences_equivalent( + sequences, + [[transform1, transform3, transform6], [transform4, transform2, transform6], [transform5, transform6]], + ) def test_get_all_transformation_sequences_multiple_paths_multiple_edges_no_edge_expected(five_point_graph): """Test getting all transformation sequences with multiple paths and multiple edges, no edge expected.""" - tm = TransformationManager() - [cs1, cs2, cs3, cs4, cs5], [transform1, transform2, transform3, transform4, transform5] = five_point_graph + tm = TransformationGraph() + + coord_systems, transform_edges = five_point_graph + [cs1, cs2, cs3, cs4, cs5] = coord_systems + [transform1, transform2, transform3, transform4, transform5, transform6, transform7] = transform_edges + tm.add_coordinate_system(cs1) tm.add_coordinate_system(cs2) tm.add_coordinate_system(cs3) tm.add_coordinate_system(cs4) tm.add_coordinate_system(cs5) - tm.add_transformation(cs1, cs2, transform1) - tm.add_transformation(cs2, cs3, transform2) - tm.add_transformation(cs1, cs4, transform2) - tm.add_transformation(cs4, cs3, transform1) - tm.add_transformation(cs1, cs3, transform3) - tm.add_transformation(cs3, cs5, transform4) - tm.add_transformation(cs3, cs5, transform5) + tm.add_transformation_edge(transform1) + tm.add_transformation_edge(transform3) + tm.add_transformation_edge(transform4) + tm.add_transformation_edge(transform2) + tm.add_transformation_edge(transform5) + tm.add_transformation_edge(transform6) + tm.add_transformation_edge(transform7) expected_msg = f"Transformation Path ambiguous from {cs3.name} to {cs5.name}." with pytest.raises(TransformationPathAmbiguousNoEdgeExpectedError, match=expected_msg): @@ -532,26 +562,27 @@ def test_get_all_transformation_sequences_multiple_paths_multiple_edges_no_edge_ def test_get_all_transformation_sequences_multiple_paths_multiple_edges_multiple_expected(five_point_graph): """Test getting all transformation sequences with multiple paths and multiple edges, multiple edges expected.""" - tm = TransformationManager() - [cs1, cs2, cs3, cs4, cs5], [transform1, transform2, transform3, transform4, transform5] = five_point_graph + tm = TransformationGraph() + + coord_systems, transform_edges = five_point_graph + [cs1, cs2, cs3, cs4, cs5] = coord_systems + [transform1, transform2, transform3, transform4, transform5, transform6, transform7] = transform_edges + tm.add_coordinate_system(cs1) tm.add_coordinate_system(cs2) tm.add_coordinate_system(cs3) tm.add_coordinate_system(cs4) tm.add_coordinate_system(cs5) - tm.add_transformation(cs1, cs2, transform1) - tm.add_transformation(cs2, cs3, transform2) - tm.add_transformation(cs1, cs4, transform2) - tm.add_transformation(cs4, cs3, transform1) - tm.add_transformation(cs1, cs3, transform3) - tm.add_transformation(cs3, cs5, transform4) - tm.add_transformation(cs3, cs5, transform5) + tm.add_transformation_edge(transform1) + tm.add_transformation_edge(transform3) + tm.add_transformation_edge(transform4) + tm.add_transformation_edge(transform2) + tm.add_transformation_edge(transform5) + tm.add_transformation_edge(transform6) + tm.add_transformation_edge(transform7) - expected_intermediate_edges = [ - (cs3, cs5, transform4), - (cs3, cs5, transform5), - ] + expected_intermediate_edges = [transform6, transform7] expected_msg = f"Transformation Path ambiguous from {cs3.name} to {cs5.name}." with pytest.raises(TransformationPathAmbiguousMultipleEdgeExpectedError, match=expected_msg): @@ -562,7 +593,7 @@ def test_get_transformation_sequences_from_simple_paths_after_disambiguation_wit fully_connected_two_point_graph, ): """Test _get_transformation_sequences_from_simple_paths_after_disambiguation with non-simple paths.""" - tm = TransformationManager() + tm = TransformationGraph() [cs1, cs2], _ = fully_connected_two_point_graph tm.add_coordinate_system(cs1) @@ -577,7 +608,7 @@ def test_get_transformation_sequences_from_simple_paths_after_disambiguation_wit fully_connected_two_point_graph, ): """Test _get_transformation_sequences_from_simple_paths_after_disambiguation with a path with only one node.""" - tm = TransformationManager() + tm = TransformationGraph() [cs1, cs2], [transformation] = fully_connected_two_point_graph tm.add_coordinate_system(cs1) @@ -589,7 +620,7 @@ def test_get_transformation_sequences_from_simple_paths_after_disambiguation_wit def test_get_all_transformation_sequences_no_path(four_point_graph): """Test getting all transformation sequences.""" - tm = TransformationManager() + tm = TransformationGraph() [cs1, cs2, cs3, cs4], [transform1, transform2, transform3, transform4] = four_point_graph tm.add_coordinate_system(cs1) tm.add_coordinate_system(cs2) @@ -603,8 +634,8 @@ def test_get_all_transformation_sequences_no_path(four_point_graph): def test_get_element_coordinate_system_success(one_point_graph): """Test successfully getting an element's coordinate system.""" - tm = TransformationManager() - [cs1], _ = one_point_graph + tm = TransformationGraph() + [cs1] = one_point_graph tm.add_coordinate_system(cs1) tm.add_element("image1", cs1) @@ -617,7 +648,7 @@ def test_repr_method_comprehensive(fully_connected_two_point_graph): """Test TransformationManager.__repr__ method comprehensively.""" # Test empty TransformationManager - tm_empty = TransformationManager() + tm_empty = TransformationGraph() repr_empty = repr(tm_empty) assert "TransformationManager" in repr_empty assert "coordinate_systems=[]" in repr_empty @@ -625,12 +656,12 @@ def test_repr_method_comprehensive(fully_connected_two_point_graph): assert "elements=[]" in repr_empty # Test TransformationManager with data - tm = TransformationManager() - [cs1, cs2], [transform] = fully_connected_two_point_graph + tm = TransformationGraph() + [cs1, cs2], [transform_edge] = fully_connected_two_point_graph tm.add_coordinate_system(cs1) tm.add_coordinate_system(cs2) - tm.add_transformation(cs1, cs2, transform) + tm.add_transformation_edge(transform_edge) tm.add_element("image1", cs1) tm.add_element("image2", cs2) @@ -638,6 +669,6 @@ def test_repr_method_comprehensive(fully_connected_two_point_graph): assert "TransformationManager" in repr_str assert "cs1" in repr_str assert "cs2" in repr_str - assert repr_str.find(repr(transform)) + assert repr_str.find(repr(transform_edge)) assert "image1" in repr_str assert "image2" in repr_str From 095686fef3c131f80afc99760ed0a68999972470 Mon Sep 17 00:00:00 2001 From: Ajayrama Kumaraswamy Date: Thu, 10 Sep 2026 15:32:39 +0200 Subject: [PATCH 34/54] feat(transformations): added _to_ngff_transformation_edge() for all classes --- src/spatialdata/_io/_utils.py | 6 +- src/spatialdata/transformations/graph/edge.py | 28 +- .../transformations/transformations.py | 267 ++++++++++++++---- 3 files changed, 235 insertions(+), 66 deletions(-) diff --git a/src/spatialdata/_io/_utils.py b/src/spatialdata/_io/_utils.py index ba10405bd..214cad58b 100644 --- a/src/spatialdata/_io/_utils.py +++ b/src/spatialdata/_io/_utils.py @@ -37,7 +37,7 @@ _validate_mapping_to_coordinate_system_type, ) from spatialdata.transformations.ngff.ngff_transformations import NgffBaseTransformation -from spatialdata.transformations.transformations import BaseTransformation, _get_current_output_axes +from spatialdata.transformations.transformations import BaseTransformation def _get_transformations_from_ngff_dict( @@ -70,7 +70,7 @@ def overwrite_coordinate_transformations_non_raster( _validate_mapping_to_coordinate_system_type(transformations) ngff_transformations = [] for target_coordinate_system, t in transformations.items(): - output_axes = _get_current_output_axes(transformation=t, input_axes=tuple(axes)) + output_axes = t._get_resulting_output_axes(input_axes=tuple(axes)) ngff_transformations.append( t.to_ngff( input_axes=tuple(axes), @@ -112,7 +112,7 @@ def overwrite_coordinate_transformations_raster( # prepare the transformations in the dict representation ngff_transformations = [] for target_coordinate_system, t in transformations.items(): - output_axes = _get_current_output_axes(transformation=t, input_axes=tuple(axes)) + output_axes = t._get_resulting_output_axes(input_axes=tuple(axes)) ngff_transformations.append( t.to_ngff( input_axes=tuple(axes), diff --git a/src/spatialdata/transformations/graph/edge.py b/src/spatialdata/transformations/graph/edge.py index 890174eee..1a83324ea 100644 --- a/src/spatialdata/transformations/graph/edge.py +++ b/src/spatialdata/transformations/graph/edge.py @@ -967,9 +967,15 @@ def __init__(self, base_name: str): self._cs_count: int = 0 super().__init__() + def get_unique_name(self) -> str: + + unique_name = f"{self._base_name}{self._cs_count}" + self._cs_count += 1 + return unique_name + def generate(self, *, num_axes: int) -> CoordSystem: - out = CoordSystem( - name=f"{self._base_name}{self._cs_count}", + return CoordSystem( + name=self.get_unique_name(), axes=tuple( Axis( name=f"axis_{ax_idx}", @@ -979,12 +985,18 @@ def generate(self, *, num_axes: int) -> CoordSystem: ), virtual=True, ) - self._cs_count += 1 - return out + + def generate_with_axes(self, *, axes: tuple[Axis]) -> CoordSystem: + + return CoordSystem( + name=self.get_unique_name(), + axes=axes, + virtual=True, + ) def generate_like(self, other: CoordSystem) -> CoordSystem: - out = CoordSystem( - name=f"{self._base_name}{self._cs_count}", + return CoordSystem( + name=self.get_unique_name(), axes=tuple( Axis( name=axis.name, @@ -996,8 +1008,6 @@ def generate_like(self, other: CoordSystem) -> CoordSystem: ), virtual=True, ) - self._cs_count += 1 - return out def parse_identity( @@ -1191,7 +1201,7 @@ def parse_ngff_transf( model: ozm06trans.AnyTransform, output: CoordSystem | CsGen, ) -> BaseTransformationEdge: - """Parse an NGFF coordinate transformation model into a `BaseTransfEdge`""" + """Parse an NGFF coordinate transformation model into a `BaseTransformationEdge`""" if isinstance(model, ozm06trans.Identity): return parse_identity(model, input=input, out=output) elif isinstance(model, ozm06trans.Translation): diff --git a/src/spatialdata/transformations/transformations.py b/src/spatialdata/transformations/transformations.py index 32b81b1be..9aace4d85 100644 --- a/src/spatialdata/transformations/transformations.py +++ b/src/spatialdata/transformations/transformations.py @@ -1,7 +1,7 @@ from __future__ import annotations from abc import ABC, abstractmethod -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, override from warnings import warn import numpy as np @@ -9,6 +9,18 @@ import xarray as xr from xarray import DataArray +from spatialdata._types import ArrayLike +from spatialdata.models._utils import axis_type_mapping_ngff +from spatialdata.transformations.graph.edge import ( + AffineEdge, + BaseTransformationEdge, + CsGen, + IdentityEdge, + ScaleEdge, + SequenceEdge, + TranslationEdge, +) +from spatialdata.transformations.graph.vert import Axis, CoordSystem from spatialdata.transformations.ngff.ngff_coordinate_system import NgffCoordinateSystem, _get_spatial_axes from spatialdata.transformations.ngff.ngff_transformations import ( NgffAffine, @@ -63,6 +75,10 @@ def _repr_indent(self, indent: int = 0) -> str: def __repr__(self) -> str: return self._repr_indent(0) + @abstractmethod + def _get_resulting_output_axes(self, input_axes: tuple[ValidAxis_t, ...]) -> tuple[ValidAxis_t, ...]: + pass + @classmethod @abstractmethod def _from_ngff(cls, t: NgffBaseTransformation) -> BaseTransformation: @@ -85,6 +101,24 @@ def to_ngff( ) -> NgffBaseTransformation: pass + @abstractmethod + def _to_ngff_transformation_edge( + self, + input_coordinate_system: CoordSystem, + output_coordinate_system_name: str, + transformation_edge_name: str | None = None, + ) -> BaseTransformationEdge: + pass + + def _get_ngff_output_coordinate_system( + self, input_coordinate_system: CoordSystem, output_coordinate_system_name: str + ) -> CoordSystem: + + output_axes_names = self._get_resulting_output_axes(input_coordinate_system.axes_names) + + output_axes = tuple(Axis(name=name, type=axis_type_mapping_ngff[name]) for name in output_axes_names) + return CoordSystem(name=output_coordinate_system_name, axes=output_axes, virtual=False) + def _get_default_coordinate_system( self, axes: tuple[ValidAxis_t, ...], @@ -230,6 +264,9 @@ def _repr_transformation_description(self, indent: int = 0) -> str: def _transform_coordinates(self, data: DataArray) -> DataArray: return data + def _get_resulting_output_axes(self, input_axes: tuple[ValidAxis_t, ...]) -> tuple[ValidAxis_t, ...]: + return input_axes + @classmethod def _from_ngff(cls, t: NgffBaseTransformation) -> BaseTransformation: assert isinstance(t, NgffIdentity) @@ -252,6 +289,24 @@ def to_ngff( ngff_transformation = NgffIdentity(input_coordinate_system=input_cs, output_coordinate_system=output_cs) return ngff_transformation + def _to_ngff_transformation_edge( + self, + input_coordinate_system: CoordSystem, + output_coordinate_system_name: str, + transformation_edge_name: str | None = None, + ) -> BaseTransformationEdge: + + output_coordinate_system = self._get_ngff_output_coordinate_system( + input_coordinate_system=input_coordinate_system, + output_coordinate_system_name=output_coordinate_system_name, + ) + + return IdentityEdge( + input=input_coordinate_system, + output=output_coordinate_system, + name=transformation_edge_name, + ) + def __eq__(self, other: Any) -> bool: return isinstance(other, Identity) @@ -319,7 +374,7 @@ def _repr_transformation_description(self, indent: int = 0) -> str: def _transform_coordinates(self, data: DataArray) -> DataArray: self._xarray_coords_validate_axes(data) data_input_axes = self._xarray_coords_get_coords(data) - data_output_axes = _get_current_output_axes(self, data_input_axes) + data_output_axes = self._get_resulting_output_axes(input_axes=data_input_axes) transformed = [] for ax in data_output_axes: @@ -333,6 +388,20 @@ def _transform_coordinates(self, data: DataArray) -> DataArray: to_return = self._xarray_coords_reorder_axes(to_return) return to_return + def _get_resulting_output_axes(self, input_axes: tuple[ValidAxis_t, ...]) -> tuple[ValidAxis_t, ...]: + map_axis_input_axes = set(self.map_axis.values()) + set(self.map_axis.keys()) + to_return = [] + for ax in input_axes: + if ax not in map_axis_input_axes: + assert ax not in to_return + to_return.append(ax) + else: + mapped = [ax_out for ax_out, ax_in in self.map_axis.items() if ax_in == ax] + assert all(ax_out not in to_return for ax_out in mapped) + to_return.extend(mapped) + return tuple(to_return) + @classmethod def _from_ngff(cls, t: NgffBaseTransformation) -> BaseTransformation: assert isinstance(t, NgffMapAxis) @@ -357,6 +426,19 @@ def to_ngff( ) return ngff_transformation + @override + def _to_ngff_transformation_edge( + self, + input_coordinate_system: CoordSystem, + output_coordinate_system_name: str, + transformation_edge_name: str | None = None, + ) -> BaseTransformationEdge: + + # MapAxisEdge currently is a mixture of projectAxis and MapAxis of NGFF 0.6, + # but does not allow renaming the axes, which is possible in NGFF + # see warning above the definition of the current class + raise NotImplementedError() + def __eq__(self, other: Any) -> bool: return isinstance(other, MapAxis) and self.map_axis == other.map_axis @@ -410,6 +492,9 @@ def _transform_coordinates(self, data: DataArray) -> DataArray: to_return = self._xarray_coords_reorder_axes(transformed) return to_return + def _get_resulting_output_axes(self, input_axes: tuple[ValidAxis_t, ...]) -> tuple[ValidAxis_t, ...]: + return input_axes + @classmethod def _from_ngff(cls, t: NgffBaseTransformation) -> BaseTransformation: assert isinstance(t, NgffTranslation) @@ -440,6 +525,26 @@ def to_ngff( ) return ngff_transformation + def _to_ngff_transformation_edge( + self, + input_coordinate_system: CoordSystem, + output_coordinate_system_name: str, + transformation_edge_name: str | None = None, + ) -> BaseTransformationEdge: + + output_coordinate_system = self._get_ngff_output_coordinate_system( + input_coordinate_system, output_coordinate_system_name + ) + + translation_vector = self.to_translation_vector(axes=output_coordinate_system.axes_names) + + return TranslationEdge( + input=input_coordinate_system, + output=output_coordinate_system, + translation=translation_vector, + name=transformation_edge_name, + ) + def __eq__(self, other: Any) -> bool: return ( isinstance(other, Translation) @@ -496,6 +601,9 @@ def _transform_coordinates(self, data: DataArray) -> DataArray: to_return = self._xarray_coords_reorder_axes(transformed) return to_return + def _get_resulting_output_axes(self, input_axes: tuple[ValidAxis_t, ...]) -> tuple[ValidAxis_t, ...]: + return input_axes + @classmethod def _from_ngff(cls, t: NgffBaseTransformation) -> BaseTransformation: assert isinstance(t, NgffScale) @@ -523,6 +631,26 @@ def to_ngff( ) return ngff_transformation + def _to_ngff_transformation_edge( + self, + input_coordinate_system: CoordSystem, + output_coordinate_system_name: str, + transformation_edge_name: str | None = None, + ) -> BaseTransformationEdge: + + output_coordinate_system = self._get_ngff_output_coordinate_system( + input_coordinate_system, output_coordinate_system_name + ) + + scale_vector = self.to_scale_vector(input_coordinate_system.axes_names) + + return ScaleEdge( + input=input_coordinate_system, + output=output_coordinate_system, + scale=scale_vector, + name=transformation_edge_name, + ) + def __eq__(self, other: Any) -> bool: return isinstance(other, Scale) and np.allclose(self.scale, other.scale) and self.axes == other.axes @@ -782,7 +910,7 @@ def _repr_transformation_description(self, indent: int = 0) -> str: def _transform_coordinates(self, data: DataArray) -> DataArray: self._xarray_coords_validate_axes(data) data_input_axes = self._xarray_coords_get_coords(data) - data_output_axes = _get_current_output_axes(self, data_input_axes) + data_output_axes = self._get_resulting_output_axes(data_input_axes) matrix = self.to_affine_matrix(data_input_axes, data_output_axes) transformed = (matrix @ np.vstack((data.data.T, np.ones(len(data))))).T[:, :-1] to_return = DataArray(transformed, coords={"points": data.coords["points"], "dim": list(data_output_axes)}) @@ -799,6 +927,27 @@ def _from_ngff(cls, t: NgffBaseTransformation) -> BaseTransformation: output_axes = tuple(t.output_coordinate_system.axes_names) return Affine(matrix=t.affine, input_axes=input_axes, output_axes=output_axes) + def _get_resulting_output_axes(self, input_axes: tuple[ValidAxis_t, ...]) -> tuple[ValidAxis_t, ...]: + to_return = [] + add_affine_output_axes = False + for ax in input_axes: + if ax not in self.input_axes: + assert ax not in to_return + to_return.append(ax) + else: + add_affine_output_axes = True + if add_affine_output_axes: + for ax in self.output_axes: + if ax not in to_return: + to_return.append(ax) + else: + raise ValueError( + f"Trying to query an invalid representation of an affine matrix: the ax {ax} is not " + f"an input axis of the affine matrix but it appears both as output as input of the " + f"matrix representation being queried" + ) + return tuple(to_return) + def to_ngff( self, input_axes: tuple[ValidAxis_t, ...], @@ -819,6 +968,30 @@ def to_ngff( ) return ngff_transformation + def _to_ngff_transformation_edge( + self, + input_coordinate_system: CoordSystem, + output_coordinate_system_name: str, + transformation_edge_name: str | None = None, + ) -> BaseTransformationEdge: + + output_coordinate_system = self._get_ngff_output_coordinate_system( + input_coordinate_system, output_coordinate_system_name + ) + + affine_matrix = self.to_affine_matrix( + input_axes=input_coordinate_system.axes_names, + output_axes=output_coordinate_system.axes_names, + ) + + return AffineEdge( + input=input_coordinate_system, + output=output_coordinate_system, + linear=affine_matrix[:-1, :-1], + translation=affine_matrix[:-1, -1], + name=transformation_edge_name, + ) + def __eq__(self, other: Any) -> bool: if not isinstance(other, Affine): return False @@ -848,7 +1021,7 @@ def _to_affine_matrix_wrapper( raise ValueError("Input axes must be a subset of output axes.") current_input_axes = input_axes - current_output_axes = _get_current_output_axes(self.transformations[0], current_input_axes) + current_output_axes = self.transformations[0]._get_resulting_output_axes(input_axes=current_input_axes) m = self.transformations[0].to_affine_matrix(current_input_axes, current_output_axes) if DEBUG_SEQUENCE: print(f"# 0: current_input_axes = {current_input_axes}, current_output_axes = {current_output_axes}") @@ -856,7 +1029,7 @@ def _to_affine_matrix_wrapper( print() for i, t in enumerate(self.transformations[1:]): current_input_axes = current_output_axes - current_output_axes = _get_current_output_axes(t, current_input_axes) + current_output_axes = t._get_resulting_output_axes(input_axes=current_input_axes) if DEBUG_SEQUENCE: print( f"# {i + 1}: current_input_axes = {current_input_axes}, current_output_axes = {current_output_axes}" @@ -917,6 +1090,13 @@ def _transform_coordinates(self, data: DataArray) -> DataArray: self._xarray_coords_validate_axes(data) return data + def _get_resulting_output_axes(self, input_axes: tuple[ValidAxis_t, ...]) -> tuple[ValidAxis_t, ...]: + + input_axes_this_iteration = input_axes + for t in self.transformations: + input_axes_this_iteration = t._get_resulting_output_axes(input_axes_this_iteration) + return input_axes_this_iteration + @classmethod def _from_ngff(cls, t: NgffBaseTransformation) -> BaseTransformation: assert isinstance(t, NgffSequence) @@ -939,7 +1119,7 @@ def to_ngff( converted_transformations = [] latest_input_axes = input_axes for t in self.transformations: - latest_output_axes = _get_current_output_axes(t, latest_input_axes) + latest_output_axes = t._get_resulting_output_axes(input_axes=latest_input_axes) converted_transformations.append( t.to_ngff( input_axes=latest_input_axes, @@ -956,63 +1136,42 @@ def to_ngff( ) return ngff_transformation + def _to_ngff_transformation_edge( + self, + input_coordinate_system: CoordSystem, + output_coordinate_system_name: str, + transformation_edge_name: str | None = None, + ) -> BaseTransformationEdge: + + cs_gen = CsGen(base_name=f"Sequence_from_{input_coordinate_system.name}_to_{output_coordinate_system_name}") + output_coordinate_system_names = [cs_gen.get_unique_name() for _t in self.transformations] + output_coordinate_system_names[-1] = output_coordinate_system_name + + individual_edges = [] + current_input_cs = input_coordinate_system + for t, current_output_coordinate_system_name in zip( + self.transformations, output_coordinate_system_names, strict=True + ): + current_edge = t._to_ngff_transformation_edge( + input_coordinate_system=current_input_cs, + output_coordinate_system_name=current_output_coordinate_system_name, + ) + current_input_cs = current_edge.output + individual_edges.append(current_edge) + + return SequenceEdge(name=transformation_edge_name, transformations=individual_edges) + def __eq__(self, other: Any) -> bool: if not isinstance(other, Sequence): return False return self.transformations == other.transformations -def _get_current_output_axes( - transformation: BaseTransformation, input_axes: tuple[ValidAxis_t, ...] -) -> tuple[ValidAxis_t, ...]: - if isinstance(transformation, Identity | Translation | Scale): - return input_axes - elif isinstance(transformation, MapAxis): - map_axis_input_axes = set(transformation.map_axis.values()) - set(transformation.map_axis.keys()) - to_return = [] - for ax in input_axes: - if ax not in map_axis_input_axes: - assert ax not in to_return - to_return.append(ax) - else: - mapped = [ax_out for ax_out, ax_in in transformation.map_axis.items() if ax_in == ax] - assert all(ax_out not in to_return for ax_out in mapped) - to_return.extend(mapped) - return tuple(to_return) - elif isinstance(transformation, Affine): - to_return = [] - add_affine_output_axes = False - for ax in input_axes: - if ax not in transformation.input_axes: - assert ax not in to_return - to_return.append(ax) - else: - add_affine_output_axes = True - if add_affine_output_axes: - for ax in transformation.output_axes: - if ax not in to_return: - to_return.append(ax) - else: - raise ValueError( - f"Trying to query an invalid representation of an affine matrix: the ax {ax} is not " - f"an input axis of the affine matrix but it appears both as output as input of the " - f"matrix representation being queried" - ) - return tuple(to_return) - elif isinstance(transformation, Sequence): - for t in transformation.transformations: - input_axes = _get_current_output_axes(t, input_axes) - return input_axes - else: - raise ValueError("Unknown transformation type.") - - def _get_affine_for_element(element: SpatialElement, transformation: BaseTransformation) -> Affine: from spatialdata.models import get_axes_names input_axes = get_axes_names(element) - output_axes = _get_current_output_axes(transformation, input_axes) + output_axes = transformation._get_resulting_output_axes(input_axes) matrix = transformation.to_affine_matrix(input_axes=input_axes, output_axes=output_axes) return Affine(matrix, input_axes=input_axes, output_axes=output_axes) @@ -1060,7 +1219,7 @@ def _validate_square_affine_for_decomposition( If the linear part of the affine has a large condition number, in which case the decomposition may be numerically inaccurate. """ - output_axes = _get_current_output_axes(transformation=transformation, input_axes=input_axes) + output_axes = transformation._get_resulting_output_axes(input_axes=input_axes) if set(input_axes) != set(output_axes): raise ValueError("The transformation should leave the set of input axes unmodified.") # the axes may come out in a different order than input_axes; querying in input_axes order makes the matrix From 3f834169e1cd2118a180a4a7f74864ae41ff7439 Mon Sep 17 00:00:00 2001 From: Ajayrama Kumaraswamy Date: Wed, 16 Sep 2026 09:44:43 +0200 Subject: [PATCH 35/54] feat(transformations_facade): setting and removing transformations --- .../transformations_facade.py | 61 +++++++++++++++++-- .../transformations/graph/utils.py | 18 ++++++ 2 files changed, 73 insertions(+), 6 deletions(-) create mode 100644 src/spatialdata/transformations/graph/utils.py diff --git a/src/spatialdata/_core/transformation_manager/transformations_facade.py b/src/spatialdata/_core/transformation_manager/transformations_facade.py index a06d5ee02..c0a03e8f6 100644 --- a/src/spatialdata/_core/transformation_manager/transformations_facade.py +++ b/src/spatialdata/_core/transformation_manager/transformations_facade.py @@ -3,20 +3,69 @@ from collections.abc import Sequence from spatialdata._core.transformation_manager._transformation_graph import TransformationGraph +from spatialdata._core.transformation_manager.exceptions import CoordinateSystemNotFoundError +from spatialdata.models import SpatialElement from spatialdata.transformations import BaseTransformation from spatialdata.transformations.graph.edge import BaseTransformationEdge +from spatialdata.transformations.graph.utils import get_default_coordinate_system class TransformationsFacade: def __init__(self) -> None: - self.graph = TransformationGraph() + self._graph = TransformationGraph() - def _get_element_transformations( - self, element_name: str, target_coordinate_system_names: Sequence[str] + def _get_element_transformation_edges( + self, element_name: str, target_coordinate_system_names: Sequence[str] | None = None ) -> list[BaseTransformationEdge]: - return self.graph.get_outgoing_edges(element_name, target_coordinate_system_names) + return self._graph.get_outgoing_edges(element_name, target_coordinate_system_names) - def set_element_transformations(self, element_name: str, transformations: Sequence[BaseTransformation]) -> None: + def _add_new_element_to_graph_creating_default_cs(self, new_element: SpatialElement, new_element_key: str) -> None: - pass + element_cs = get_default_coordinate_system( + element_to_be_added_to_graph=new_element, coordinate_system_name=new_element_key + ) + + self._graph.add_coordinate_system(element_cs) + self._graph.add_element(element_name=new_element_key, coordinate_system=element_cs) + + def set_element_transformations( + self, + element_name: str, + transformations_config: dict[str, BaseTransformation], + ) -> list[BaseTransformationEdge]: + + element_cs = self._graph.get_element_coordinate_system(element_name) + + transformation_edges = [] + for output_coordinate_system_name, t in transformations_config.items(): + ngff_transformation_edge = t._to_ngff_transformation_edge( + input_coordinate_system=element_cs, output_coordinate_system_name=output_coordinate_system_name + ) + self._graph.add_transformation_edge(ngff_transformation_edge) + transformation_edges.append(ngff_transformation_edge) + + return transformation_edges + + def remove_transformations(self, transformation_edges: list[BaseTransformationEdge]) -> None: + + for transformation_edge in transformation_edges: + self._graph.remove_specific_transformation(transformation_edge) + + def remove_all_transformations_of_element(self, element_name: str) -> None: + + all_outgoing_edges_from_element = self._get_element_transformation_edges(element_name) + self.remove_transformations(all_outgoing_edges_from_element) + + def remove_all_transformations_to_coordinate_system(self, coordinate_system_name: str) -> None: + + cs_probables = [x for x in self._graph.list_coordinate_systems() if x.name == coordinate_system_name] + if len(cs_probables) == 1: + for predecessor in self._graph._graph.predecessors(cs_probables[0]): + self._graph.remove_all_transformations_between_coordinate_systems(predecessor, cs_probables[0]) + elif len(cs_probables) == 0: + CoordinateSystemNotFoundError(name=coordinate_system_name) + else: + raise AssertionError( + "This case should never happen, please raise an issue at https://github.com/scverse/spatialdata/issues" + ) diff --git a/src/spatialdata/transformations/graph/utils.py b/src/spatialdata/transformations/graph/utils.py new file mode 100644 index 000000000..6a651b140 --- /dev/null +++ b/src/spatialdata/transformations/graph/utils.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from spatialdata.models import SpatialElement, get_axes_names +from spatialdata.models._utils import get_axes_types +from spatialdata.transformations.graph.vert import Axis, CoordSystem + + +def get_default_coordinate_system( + element_to_be_added_to_graph: SpatialElement, coordinate_system_name: str +) -> CoordSystem: + + axes_names = get_axes_names(element_to_be_added_to_graph) + axes_types = get_axes_types(element_to_be_added_to_graph) + ngff_axes = [] + for axis_name, axis_type in zip(axes_names, axes_types, strict=True): + ngff_axis = Axis(name=axis_name, type=axis_type) + ngff_axes.append(ngff_axis) + return CoordSystem(name=coordinate_system_name, axes=tuple(ngff_axes), virtual=False) From 261a5f751a3827d7c1cefd7191a0cd28d9a07b40 Mon Sep 17 00:00:00 2001 From: Ajayrama Kumaraswamy Date: Wed, 16 Sep 2026 10:55:15 +0200 Subject: [PATCH 36/54] feat(sd-transformations): MapAxis._to_ngff_transformation_edge --- .../transformation_manager/exceptions.py | 6 +++ src/spatialdata/transformations/graph/edge.py | 9 +++++ src/spatialdata/transformations/graph/vert.py | 6 ++- .../transformations/transformations.py | 37 +++++++++++++++---- 4 files changed, 50 insertions(+), 8 deletions(-) diff --git a/src/spatialdata/_core/transformation_manager/exceptions.py b/src/spatialdata/_core/transformation_manager/exceptions.py index 19fdb66d7..efa6a5512 100644 --- a/src/spatialdata/_core/transformation_manager/exceptions.py +++ b/src/spatialdata/_core/transformation_manager/exceptions.py @@ -309,3 +309,9 @@ def __init__(self, axis: Axis, cs: CoordSystem) -> None: class AxisParsingException(Exception): pass + + +class NGFFCompatibilityError(Exception): + def __init__(self, msg: str): + self.msg = msg + super().__init__(msg) diff --git a/src/spatialdata/transformations/graph/edge.py b/src/spatialdata/transformations/graph/edge.py index 1a83324ea..a8057b9e1 100644 --- a/src/spatialdata/transformations/graph/edge.py +++ b/src/spatialdata/transformations/graph/edge.py @@ -357,6 +357,15 @@ def __init__( ------ IncompatibleCoordSystemsError if `input` and `output` don't have the number of axes + MissingAxisError + if an axis specified among keys of `input_to_output` is not in `input` + or + if an axis specified among values of `input_to_output` is not in `output` + UnmappedAxisError + if an axis specified in `input` is not a key of `input_to_output` + or + if an axis specified in `output` is not a value of `input_to_output` + """ if input.num_axes != output.num_axes: diff --git a/src/spatialdata/transformations/graph/vert.py b/src/spatialdata/transformations/graph/vert.py index 3e6ec7611..31b47f51b 100644 --- a/src/spatialdata/transformations/graph/vert.py +++ b/src/spatialdata/transformations/graph/vert.py @@ -6,7 +6,7 @@ import ome_zarr_models.v06.coordinate_transforms as ozm06ct -from spatialdata.models._utils import NgffAxisType +from spatialdata.models._utils import NgffAxisType, axis_type_mapping_ngff class AxisParsingException(Exception): @@ -73,6 +73,10 @@ def try_from_model(cls, model: ozm06ct.Axis) -> Axis: long_name=model.longName, ) + @classmethod + def from_spatialdata_axis_name(cls, axis_name: str, unit: None = None) -> Axis: + return cls(name=axis_name, type=axis_type_mapping_ngff[axis_name], unit=unit) + class CoordSystemParsingException(Exception): pass diff --git a/src/spatialdata/transformations/transformations.py b/src/spatialdata/transformations/transformations.py index 9aace4d85..d32e6f97b 100644 --- a/src/spatialdata/transformations/transformations.py +++ b/src/spatialdata/transformations/transformations.py @@ -9,13 +9,18 @@ import xarray as xr from xarray import DataArray -from spatialdata._types import ArrayLike -from spatialdata.models._utils import axis_type_mapping_ngff +from spatialdata._core.transformation_manager.exceptions import ( + IncompatibleCoordSystemsError, + MissingAxisError, + NGFFCompatibilityError, + UnmappedAxisError, +) from spatialdata.transformations.graph.edge import ( AffineEdge, BaseTransformationEdge, CsGen, IdentityEdge, + MapAxisEdge, ScaleEdge, SequenceEdge, TranslationEdge, @@ -116,7 +121,7 @@ def _get_ngff_output_coordinate_system( output_axes_names = self._get_resulting_output_axes(input_coordinate_system.axes_names) - output_axes = tuple(Axis(name=name, type=axis_type_mapping_ngff[name]) for name in output_axes_names) + output_axes = tuple(Axis.from_spatialdata_axis_name(name) for name in output_axes_names) return CoordSystem(name=output_coordinate_system_name, axes=output_axes, virtual=False) def _get_default_coordinate_system( @@ -434,10 +439,28 @@ def _to_ngff_transformation_edge( transformation_edge_name: str | None = None, ) -> BaseTransformationEdge: - # MapAxisEdge currently is a mixture of projectAxis and MapAxis of NGFF 0.6, - # but does not allow renaming the axes, which is possible in NGFF - # see warning above the definition of the current class - raise NotImplementedError() + output_coordinate_system = self._get_ngff_output_coordinate_system( + input_coordinate_system=input_coordinate_system, + output_coordinate_system_name=output_coordinate_system_name, + ) + + input_to_output_axes_mapping = { + Axis.from_spatialdata_axis_name(input_axis_name): Axis.from_spatialdata_axis_name(output_axis_name) + for output_axis_name, input_axis_name in self.map_axis.items() + } + + try: + map_axis_edge = MapAxisEdge( + input=input_coordinate_system, + output=output_coordinate_system, + name=transformation_edge_name, + input_to_output=input_to_output_axes_mapping, + ) + except (IncompatibleCoordSystemsError, MissingAxisError, UnmappedAxisError) as e: + raise NGFFCompatibilityError( + f"Cannot map the following transformation to an NGFF transformation Edge.\n{repr(self)}" + ) from e + return map_axis_edge def __eq__(self, other: Any) -> bool: return isinstance(other, MapAxis) and self.map_axis == other.map_axis From 5f24aa767859e43d39db45c003180240ec380352 Mon Sep 17 00:00:00 2001 From: Ajayrama Kumaraswamy Date: Wed, 16 Sep 2026 11:46:40 +0200 Subject: [PATCH 37/54] feat: doc strings --- .../_transformation_graph.py | 63 +++++++++------ .../transformation_manager/exceptions.py | 9 +++ .../transformations_facade.py | 78 +++++++++++++++++-- .../_core/transformation_manager/types.py | 1 + src/spatialdata/models/_utils.py | 4 - src/spatialdata/transformations/graph/edge.py | 43 ++++++++++ .../transformations/graph/utils.py | 20 +++-- src/spatialdata/transformations/graph/vert.py | 14 ++++ .../transformations/transformations.py | 42 ++++++++++ 9 files changed, 236 insertions(+), 38 deletions(-) diff --git a/src/spatialdata/_core/transformation_manager/_transformation_graph.py b/src/spatialdata/_core/transformation_manager/_transformation_graph.py index 5654992cf..6c75b9ae1 100644 --- a/src/spatialdata/_core/transformation_manager/_transformation_graph.py +++ b/src/spatialdata/_core/transformation_manager/_transformation_graph.py @@ -28,7 +28,12 @@ class TransformationGraph: def __init__(self) -> None: - """Initialize a TransformationManager with empty graph and mappings.""" + """ + Initialize a TransformationManager with empty graph and mappings. + + The TransformationManager maintains a graph of coordinate systems and transformations between them, + as well as a mapping of element names to their respective coordinate systems. + """ self._graph: nx.MultiDiGraph[CoordSystem, dict[str, Any], dict[str, BaseTransformationEdge]] = nx.MultiDiGraph() # MultiDiGraph with CoordSystem objects as nodes and BaseTransformationEdge as edge attributes self._element_to_cs_mapping: dict[str, CoordSystem] = {} @@ -45,7 +50,7 @@ def assert_element_exists(self, element_name: str) -> None: Raises ------ - ElementNotFoundError + ElementNotRegisteredToAnyCoordinateSystemError If the element does not exist. """ if element_name not in self._element_to_cs_mapping: @@ -53,7 +58,7 @@ def assert_element_exists(self, element_name: str) -> None: def assert_element_does_not_exist(self, element_name: str) -> None: """ - Assert than an element doesn't exist in the transformation manager. + Assert that an element doesn't exist in the transformation manager. Parameters ---------- @@ -115,8 +120,7 @@ def assert_an_edge_exists_between_coordinate_systems( target_cs The output coordinate system. edge_key - If specified, asserts that the specific edge exists between coordinate systems - + If specified, asserts that the specific edge exists between coordinate systems. Raises ------ @@ -161,12 +165,12 @@ def assert_coordinate_system_has_no_elements(self, cs: CoordSystem) -> None: Parameters ---------- cs - The coordinate system to check + The coordinate system to check. Raises ------ CoordinateSystemHasElementsError - if the coordinate system has elements belonging to it. + If the coordinate system has elements belonging to it. """ elements = self._get_elements_belonging_to_cs(cs) # also checks if cs exists @@ -202,12 +206,11 @@ def remove_coordinate_system(self, cs: CoordSystem) -> None: Raises ------ CoordinateSystemNotFoundError - If the coordinate system is not found + If the coordinate system is not found. CannotRemoveCoordinateSystemError If the coordinate system cannot be removed. - Caused by - CoordinateSystemHasTransformationsError or - CoordinateSystemHasElementsError when the system has dependencies. + Caused by CoordinateSystemHasTransformationsError or + CoordinateSystemHasElementsError when the system has dependencies. """ try: self.assert_coordinate_system_has_no_transformations(cs) @@ -259,7 +262,7 @@ def get_element_coordinate_system(self, element_name: str) -> CoordSystem: Returns ------- - The coordinate system + The coordinate system. Raises ------ @@ -272,7 +275,20 @@ def get_element_coordinate_system(self, element_name: str) -> CoordSystem: def get_outgoing_edges( self, element_name: str, target_coordinate_system_names: Sequence[str] | None ) -> list[BaseTransformationEdge]: + """ + Get outgoing transformation edges from an element's coordinate system. + Parameters + ---------- + element_name + The name of the element. + target_coordinate_system_names + Optional list of target coordinate system names to filter by. + + Returns + ------- + List of outgoing transformation edges from the element's coordinate system. + """ element_coordinate_system = self.get_element_coordinate_system(element_name) outgoing_edges: list[BaseTransformationEdge] = [] @@ -312,11 +328,12 @@ def _get_edge_key(edge: BaseTransformationEdge) -> str: Parameters ---------- edge + The transformation edge to encode. Returns ------- edge_key - str, the encoded edge identifier + str, the encoded edge identifier. """ return str(id(edge)) @@ -422,7 +439,7 @@ def remove_all_transformations_between_coordinate_systems( CoordinateSystemNotFoundError If either coordinate system does not exist. TransformationNotFoundError - If no transformation exists between the coordiante systems + If no transformation exists between the coordinate systems. """ self.assert_coordinate_system_exists(source_cs) self.assert_coordinate_system_exists(target_cs) @@ -446,19 +463,19 @@ def _get_transformation_sequences_from_simple_paths_after_disambiguation( Parameters ---------- - paths: - sequence of list of nodes - expected_intermediate_edges: - list of edges, for use when multiple edges are found between two coordinate systems in a path + paths + Sequence of list of nodes. + expected_intermediate_edges + List of edges, for use when multiple edges are found between two coordinate systems in a path. Returns ------- - list of transformation edges, each an instance of the transformation class Sequence + List of transformation edges, each an instance of the transformation class Sequence. Raises ------ - TransformationPathNotSimple - if any path in `paths` is not simple, i.e., has recurring coordinate systems + TransformationPathNotSimple + If any path in `paths` is not simple, i.e., has recurring coordinate systems. """ for path in paths: if len(set(path)) != len(path): @@ -520,7 +537,7 @@ def get_all_shortest_transformation_sequences( target_cs The target coordinate system. expected_intermediate_edges - list of intermediate edges expected. + List of intermediate edges expected. Used to choose an edge when multiple edges are found between the same coordinate systems. Returns @@ -568,7 +585,7 @@ def get_all_transformation_sequences( target_cs The target coordinate system. expected_intermediate_edges - list of intermediate edges expected. + List of intermediate edges expected. Used to choose an edge when multiple edges are found between the same coordinate systems. Returns diff --git a/src/spatialdata/_core/transformation_manager/exceptions.py b/src/spatialdata/_core/transformation_manager/exceptions.py index efa6a5512..75b3ab8b3 100644 --- a/src/spatialdata/_core/transformation_manager/exceptions.py +++ b/src/spatialdata/_core/transformation_manager/exceptions.py @@ -312,6 +312,15 @@ class AxisParsingException(Exception): class NGFFCompatibilityError(Exception): + """ + Exception raised when a transformation cannot be mapped to an NGFF transformation edge. + + Attributes + ---------- + msg : str + The error message describing the compatibility issue. + """ + def __init__(self, msg: str): self.msg = msg super().__init__(msg) diff --git a/src/spatialdata/_core/transformation_manager/transformations_facade.py b/src/spatialdata/_core/transformation_manager/transformations_facade.py index c0a03e8f6..a0a0ca618 100644 --- a/src/spatialdata/_core/transformation_manager/transformations_facade.py +++ b/src/spatialdata/_core/transformation_manager/transformations_facade.py @@ -12,16 +12,39 @@ class TransformationsFacade: def __init__(self) -> None: + """Initialize a TransformationsFacade with an empty transformation graph.""" self._graph = TransformationGraph() def _get_element_transformation_edges( self, element_name: str, target_coordinate_system_names: Sequence[str] | None = None ) -> list[BaseTransformationEdge]: - + """ + Get the NGFF transformation edges associated with an element. + + Parameters + ---------- + element_name + The name of the element. + target_coordinate_system_names + Optional sequence of target coordinate system names to filter by. + + Returns + ------- + List of transformation edges for the element. + """ return self._graph.get_outgoing_edges(element_name, target_coordinate_system_names) def _add_new_element_to_graph_creating_default_cs(self, new_element: SpatialElement, new_element_key: str) -> None: - + """ + Add a new element to the transformation graph with a default coordinate system. + + Parameters + ---------- + new_element + The spatial element to add to the graph. + new_element_key + The key/name to use for the new element in the graph. + """ element_cs = get_default_coordinate_system( element_to_be_added_to_graph=new_element, coordinate_system_name=new_element_key ) @@ -34,7 +57,22 @@ def set_element_transformations( element_name: str, transformations_config: dict[str, BaseTransformation], ) -> list[BaseTransformationEdge]: - + """ + Set transformations for an element to specified target coordinate systems + + This is done by adding corresponding coordinate systems and transformation edges to the graph + + Parameters + ---------- + element_name + The name of the element. + transformations_config + Dictionary mapping target coordinate system names to (spatialdata) transformations. + + Returns + ------- + List of NGFF transformation edges that were created and added to the graph. + """ element_cs = self._graph.get_element_coordinate_system(element_name) transformation_edges = [] @@ -48,17 +86,45 @@ def set_element_transformations( return transformation_edges def remove_transformations(self, transformation_edges: list[BaseTransformationEdge]) -> None: - + """ + Remove specific transformation edges from the graph. + + Parameters + ---------- + transformation_edges + List of transformation edges to remove. + """ for transformation_edge in transformation_edges: self._graph.remove_specific_transformation(transformation_edge) def remove_all_transformations_of_element(self, element_name: str) -> None: - + """ + Remove all transformation edges associated with an element. + + Parameters + ---------- + element_name + The name of the element whose transformations should be removed. + """ all_outgoing_edges_from_element = self._get_element_transformation_edges(element_name) self.remove_transformations(all_outgoing_edges_from_element) def remove_all_transformations_to_coordinate_system(self, coordinate_system_name: str) -> None: - + """ + Remove all transformation edges that target a specific coordinate system. + + Parameters + ---------- + coordinate_system_name + The name of the target coordinate system. + + Raises + ------ + CoordinateSystemNotFoundError + If the coordinate system is not found. + AssertionError + If multiple coordinate systems with the same name exist (should never happen). + """ cs_probables = [x for x in self._graph.list_coordinate_systems() if x.name == coordinate_system_name] if len(cs_probables) == 1: for predecessor in self._graph._graph.predecessors(cs_probables[0]): diff --git a/src/spatialdata/_core/transformation_manager/types.py b/src/spatialdata/_core/transformation_manager/types.py index 00a2db1f4..df67d2d2a 100644 --- a/src/spatialdata/_core/transformation_manager/types.py +++ b/src/spatialdata/_core/transformation_manager/types.py @@ -1,3 +1,4 @@ from __future__ import annotations TRANSFORM_KEY = "transformation" +"""Key used to store transformation data in graph edge attributes.""" diff --git a/src/spatialdata/models/_utils.py b/src/spatialdata/models/_utils.py index ccda78656..e3f32d312 100644 --- a/src/spatialdata/models/_utils.py +++ b/src/spatialdata/models/_utils.py @@ -138,10 +138,6 @@ def get_spatial_axes(axes: tuple[ValidAxis_t, ...]) -> tuple[ValidAxis_t, ...]: return tuple(ax for ax in axes if ax in [X, Y, Z]) -def get_axes_types(e: SpatialElement) -> tuple[NgffAxisType, ...]: - return tuple(axis_type_mapping_ngff[x] for x in get_axes_names(e)) - - @singledispatch def get_axes_names(e: SpatialElement | pd.DataFrame) -> tuple[str, ...]: """ diff --git a/src/spatialdata/transformations/graph/edge.py b/src/spatialdata/transformations/graph/edge.py index a8057b9e1..be47ee7ac 100644 --- a/src/spatialdata/transformations/graph/edge.py +++ b/src/spatialdata/transformations/graph/edge.py @@ -977,12 +977,31 @@ def __init__(self, base_name: str): super().__init__() def get_unique_name(self) -> str: + """ + Generate a unique name for a coordinate system, based on self._base_name and self._cs_count. + + Returns + ------- + A unique name string. + """ unique_name = f"{self._base_name}{self._cs_count}" self._cs_count += 1 return unique_name def generate(self, *, num_axes: int) -> CoordSystem: + """ + Generate a coordinate system with the specified number of axes. + + Parameters + ---------- + num_axes + The number of axes for the coordinate system. + + Returns + ------- + A CoordSystem with the specified number of axes. + """ return CoordSystem( name=self.get_unique_name(), axes=tuple( @@ -996,6 +1015,18 @@ def generate(self, *, num_axes: int) -> CoordSystem: ) def generate_with_axes(self, *, axes: tuple[Axis]) -> CoordSystem: + """ + Generate a coordinate system with the specified axes. + + Parameters + ---------- + axes + The axes to use for the coordinate system. + + Returns + ------- + A CoordSystem with the specified axes. + """ return CoordSystem( name=self.get_unique_name(), @@ -1004,6 +1035,18 @@ def generate_with_axes(self, *, axes: tuple[Axis]) -> CoordSystem: ) def generate_like(self, other: CoordSystem) -> CoordSystem: + """ + Generate a coordinate system similar to another coordinate system. + + Parameters + ---------- + other + The coordinate system to use as a template. + + Returns + ------- + A CoordSystem with the same axes as the template but a unique name. + """ return CoordSystem( name=self.get_unique_name(), axes=tuple( diff --git a/src/spatialdata/transformations/graph/utils.py b/src/spatialdata/transformations/graph/utils.py index 6a651b140..89c80ead6 100644 --- a/src/spatialdata/transformations/graph/utils.py +++ b/src/spatialdata/transformations/graph/utils.py @@ -1,18 +1,28 @@ from __future__ import annotations from spatialdata.models import SpatialElement, get_axes_names -from spatialdata.models._utils import get_axes_types from spatialdata.transformations.graph.vert import Axis, CoordSystem def get_default_coordinate_system( element_to_be_added_to_graph: SpatialElement, coordinate_system_name: str ) -> CoordSystem: + """ + Create a default coordinate system object for a spatial element, which can be added to a graph. - axes_names = get_axes_names(element_to_be_added_to_graph) - axes_types = get_axes_types(element_to_be_added_to_graph) + Parameters + ---------- + element_to_be_added_to_graph + The spatial element for which to create a coordinate system. + coordinate_system_name + The name to use for the coordinate system. + + Returns + ------- + A CoordSystem object representing the default coordinate system for the element. + """ ngff_axes = [] - for axis_name, axis_type in zip(axes_names, axes_types, strict=True): - ngff_axis = Axis(name=axis_name, type=axis_type) + for axis_name in get_axes_names(element_to_be_added_to_graph): + ngff_axis = Axis.from_spatialdata_axis_name(axis_name) ngff_axes.append(ngff_axis) return CoordSystem(name=coordinate_system_name, axes=tuple(ngff_axes), virtual=False) diff --git a/src/spatialdata/transformations/graph/vert.py b/src/spatialdata/transformations/graph/vert.py index 31b47f51b..95c5c77a1 100644 --- a/src/spatialdata/transformations/graph/vert.py +++ b/src/spatialdata/transformations/graph/vert.py @@ -75,6 +75,20 @@ def try_from_model(cls, model: ozm06ct.Axis) -> Axis: @classmethod def from_spatialdata_axis_name(cls, axis_name: str, unit: None = None) -> Axis: + """ + Create an Axis from a spatialdata axis name. + + Parameters + ---------- + axis_name + The name of the axis (e.g., 'x', 'y', 'z', 'c'). + unit + Optional unit for the axis. + + Returns + ------- + An Axis object with the specified name and corresponding type. + """ return cls(name=axis_name, type=axis_type_mapping_ngff[axis_name], unit=unit) diff --git a/src/spatialdata/transformations/transformations.py b/src/spatialdata/transformations/transformations.py index d32e6f97b..af1233e33 100644 --- a/src/spatialdata/transformations/transformations.py +++ b/src/spatialdata/transformations/transformations.py @@ -82,6 +82,18 @@ def __repr__(self) -> str: @abstractmethod def _get_resulting_output_axes(self, input_axes: tuple[ValidAxis_t, ...]) -> tuple[ValidAxis_t, ...]: + """ + Get the resulting output axes for the transformation, given a tuple of input axes. + + Parameters + ---------- + input_axes + The input axes. + + Returns + ------- + The output axes after applying the transformation. + """ pass @classmethod @@ -113,11 +125,41 @@ def _to_ngff_transformation_edge( output_coordinate_system_name: str, transformation_edge_name: str | None = None, ) -> BaseTransformationEdge: + """ + Convert the transformation to an NGFF transformation edge. + + Parameters + ---------- + input_coordinate_system + The input coordinate system. + output_coordinate_system_name + The name for the output coordinate system. + transformation_edge_name + Optional name for the transformation edge. + + Returns + ------- + A subclass of BaseTransformationEdge representing an equivalent NGFF coordinate transformation. + """ pass def _get_ngff_output_coordinate_system( self, input_coordinate_system: CoordSystem, output_coordinate_system_name: str ) -> CoordSystem: + """ + Get the NGFF output coordinate system for a transformation. + + Parameters + ---------- + input_coordinate_system + The input coordinate system. + output_coordinate_system_name + The name for the output coordinate system. + + Returns + ------- + The output coordinate system. + """ output_axes_names = self._get_resulting_output_axes(input_coordinate_system.axes_names) From 672b569c41a03b7b09df3ca9cc489cb872ed2425 Mon Sep 17 00:00:00 2001 From: Ajayrama Kumaraswamy Date: Wed, 16 Sep 2026 16:45:59 +0200 Subject: [PATCH 38/54] refac(model, transformation._graph): code reorganization in files and imports centralized from __init__s --- src/spatialdata/_core/_deepcopy.py | 4 +- src/spatialdata/_core/centroids.py | 4 +- src/spatialdata/_core/data_extent.py | 4 +- src/spatialdata/_core/operations/transform.py | 15 ++- src/spatialdata/_core/query/spatial_query.py | 5 +- src/spatialdata/_core/spatialdata.py | 12 +- .../transformations_facade.py | 10 +- src/spatialdata/_io/_utils.py | 8 +- src/spatialdata/_io/format.py | 2 +- src/spatialdata/_io/io_raster.py | 5 +- src/spatialdata/_io/io_shapes.py | 4 +- src/spatialdata/models/__init__.py | 26 ++++- src/spatialdata/models/_utils.py | 33 +----- src/spatialdata/models/literals.py | 26 +++++ src/spatialdata/models/models.py | 10 +- src/spatialdata/models/types.py | 11 ++ src/spatialdata/testing.py | 3 +- src/spatialdata/transformations/__init__.py | 2 - .../transformations/_graph/__init__.py | 108 ++++++++++++++++++ .../transformations/{graph => _graph}/edge.py | 8 +- .../_graph}/exceptions.py | 2 +- .../_graph/graph.py} | 8 +- .../_graph/literals.py} | 0 .../{graph => _graph}/utils.py | 2 +- .../transformations/{graph => _graph}/vert.py | 7 +- src/spatialdata/transformations/_utils.py | 7 +- .../transformations/graph/__init__.py | 1 - .../ngff/ngff_transformations.py | 3 +- src/spatialdata/transformations/operations.py | 6 +- .../transformations/transformations.py | 24 ++-- tests/core/operations/test_rasterize_bins.py | 2 +- tests/core/operations/test_vectorize.py | 2 +- tests/core/query/test_relational_query.py | 2 +- tests/core/query/test_spatial_query.py | 2 +- tests/models/test_models.py | 16 +-- .../{edge => graph}/__init__.py | 0 tests/transformations/graph/edge/__init__.py | 0 .../{ => graph}/edge/conftest.py | 2 +- .../{ => graph}/edge/test_edge_parsing.py | 4 +- .../edge/test_edge_transformations.py | 18 ++- tests/transformations/graph/graph/__init__.py | 0 .../graph/graph}/conftest.py | 3 +- .../graph/graph/test_graph.py} | 18 +-- tests/transformations/test_transformations.py | 3 +- 44 files changed, 265 insertions(+), 167 deletions(-) create mode 100644 src/spatialdata/models/literals.py create mode 100644 src/spatialdata/models/types.py create mode 100644 src/spatialdata/transformations/_graph/__init__.py rename src/spatialdata/transformations/{graph => _graph}/edge.py (99%) rename src/spatialdata/{_core/transformation_manager => transformations/_graph}/exceptions.py (99%) rename src/spatialdata/{_core/transformation_manager/_transformation_graph.py => transformations/_graph/graph.py} (98%) rename src/spatialdata/{_core/transformation_manager/types.py => transformations/_graph/literals.py} (100%) rename src/spatialdata/transformations/{graph => _graph}/utils.py (93%) rename src/spatialdata/transformations/{graph => _graph}/vert.py (97%) delete mode 100644 src/spatialdata/transformations/graph/__init__.py rename tests/transformations/{edge => graph}/__init__.py (100%) create mode 100644 tests/transformations/graph/edge/__init__.py rename tests/transformations/{ => graph}/edge/conftest.py (96%) rename tests/transformations/{ => graph}/edge/test_edge_parsing.py (98%) rename tests/transformations/{ => graph}/edge/test_edge_transformations.py (99%) create mode 100644 tests/transformations/graph/graph/__init__.py rename tests/{core/transformation_manager => transformations/graph/graph}/conftest.py (95%) rename tests/{core/transformation_manager/test_transformation_manager.py => transformations/graph/graph/test_graph.py} (97%) diff --git a/src/spatialdata/_core/_deepcopy.py b/src/spatialdata/_core/_deepcopy.py index 2a68ab7a2..ec3f3e7ae 100644 --- a/src/spatialdata/_core/_deepcopy.py +++ b/src/spatialdata/_core/_deepcopy.py @@ -11,14 +11,14 @@ from xarray import DataArray, DataTree from spatialdata._core.spatialdata import SpatialData -from spatialdata.models._utils import SpatialElement -from spatialdata.models.models import ( +from spatialdata.models import ( Image2DModel, Image3DModel, Labels2DModel, Labels3DModel, PointsModel, RasterSchema, + SpatialElement, get_model, ) diff --git a/src/spatialdata/_core/centroids.py b/src/spatialdata/_core/centroids.py index 9979b9f20..65c361b3d 100644 --- a/src/spatialdata/_core/centroids.py +++ b/src/spatialdata/_core/centroids.py @@ -11,9 +11,7 @@ from xarray import DataArray, DataTree from spatialdata._core.operations.transform import transform -from spatialdata.models import get_axes_names -from spatialdata.models._utils import SpatialElement -from spatialdata.models.models import Labels2DModel, Labels3DModel, PointsModel, get_model +from spatialdata.models import Labels2DModel, Labels3DModel, PointsModel, SpatialElement, get_axes_names, get_model from spatialdata.transformations.operations import get_transformation from spatialdata.transformations.transformations import BaseTransformation diff --git a/src/spatialdata/_core/data_extent.py b/src/spatialdata/_core/data_extent.py index 32f05834b..5efdcfdb8 100644 --- a/src/spatialdata/_core/data_extent.py +++ b/src/spatialdata/_core/data_extent.py @@ -14,9 +14,7 @@ from spatialdata._core.operations.transform import transform from spatialdata._core.spatialdata import SpatialData -from spatialdata.models import get_axes_names -from spatialdata.models._utils import SpatialElement -from spatialdata.models.models import PointsModel +from spatialdata.models import PointsModel, SpatialElement, get_axes_names from spatialdata.transformations.operations import get_transformation BoundingBoxDescription = dict[str, tuple[float, float]] diff --git a/src/spatialdata/_core/operations/transform.py b/src/spatialdata/_core/operations/transform.py index 410e92fc3..c3e3cb71a 100644 --- a/src/spatialdata/_core/operations/transform.py +++ b/src/spatialdata/_core/operations/transform.py @@ -20,9 +20,15 @@ from spatialdata._core.spatialdata import SpatialData from spatialdata._types import ArrayLike from spatialdata._utils import disable_dask_tune_optimization -from spatialdata.models import SpatialElement, get_axes_names, get_model -from spatialdata.models._utils import DEFAULT_COORDINATE_SYSTEM, get_channel_names -from spatialdata.models.models import RasterSchema +from spatialdata.models import ( + DEFAULT_COORDINATE_SYSTEM, + TRANSFORM_KEY, + RasterSchema, + SpatialElement, + get_axes_names, + get_channel_names, + get_model, +) from spatialdata.transformations._utils import _get_scale, compute_coordinates, scale_radii if TYPE_CHECKING: @@ -356,7 +362,6 @@ def _( schema = get_model(data) assert issubclass(schema, RasterSchema) from spatialdata.models import Image2DModel, Image3DModel, Labels2DModel, Labels3DModel - from spatialdata.models._utils import TRANSFORM_KEY from spatialdata.transformations import get_transformation, set_transformation from spatialdata.transformations.transformations import Identity, Sequence @@ -441,7 +446,6 @@ def _( to_coordinate_system: str | None = None, ) -> DaskDataFrame: from spatialdata.models import PointsModel - from spatialdata.models._utils import TRANSFORM_KEY from spatialdata.transformations import Identity, get_transformation transformation, to_coordinate_system = _validate_target_coordinate_systems( @@ -519,7 +523,6 @@ def _( to_coordinate_system: str | None = None, ) -> GeoDataFrame: from spatialdata.models import ShapesModel - from spatialdata.models._utils import TRANSFORM_KEY from spatialdata.transformations import Identity, get_transformation axes = get_axes_names(data) diff --git a/src/spatialdata/_core/query/spatial_query.py b/src/spatialdata/_core/query/spatial_query.py index f0360c310..dda863615 100644 --- a/src/spatialdata/_core/query/spatial_query.py +++ b/src/spatialdata/_core/query/spatial_query.py @@ -21,15 +21,16 @@ from spatialdata._types import ArrayLike, ListOrNDArrayFloating, Raster_T from spatialdata._utils import _parse_list_into_array from spatialdata.models import ( + ATTRS_KEY, PointsModel, ShapesModel, SpatialElement, + ValidAxis_t, get_axes_names, + get_spatial_axes, points_dask_dataframe_to_geopandas, points_geopandas_to_dask_dataframe, ) -from spatialdata.models._utils import ValidAxis_t, get_spatial_axes -from spatialdata.models.models import ATTRS_KEY from spatialdata.transformations.operations import set_transformation from spatialdata.transformations.transformations import ( Affine, diff --git a/src/spatialdata/_core/spatialdata.py b/src/spatialdata/_core/spatialdata.py index 6680e2fa5..d6aac424b 100644 --- a/src/spatialdata/_core/spatialdata.py +++ b/src/spatialdata/_core/spatialdata.py @@ -22,7 +22,7 @@ from zarr.errors import GroupNotFoundError from spatialdata._core._elements import Images, Labels, Points, Shapes, Tables -from spatialdata._core.transformation_manager._transformation_graph import TransformationGraph +from spatialdata._core.transformation_manager.transformations_facade import TransformationsFacade from spatialdata._core.validation import ( check_all_keys_case_insensitively_unique, check_target_region_column_symmetry, @@ -40,16 +40,14 @@ Labels3DModel, PointsModel, ShapesModel, + SpatialElement, TableModel, + get_axes_names, get_model, get_table_keys, -) -from spatialdata.models._utils import ( - SpatialElement, - convert_region_column_to_categorical, - get_axes_names, set_channel_names, ) +from spatialdata.models._utils import convert_region_column_to_categorical if TYPE_CHECKING: from pandas._typing import DtypeObj @@ -133,7 +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_graph = TransformationGraph() # Initialize the TransformationManager + self.transformation_facade = TransformationsFacade() 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/transformations_facade.py b/src/spatialdata/_core/transformation_manager/transformations_facade.py index a0a0ca618..14470073c 100644 --- a/src/spatialdata/_core/transformation_manager/transformations_facade.py +++ b/src/spatialdata/_core/transformation_manager/transformations_facade.py @@ -2,12 +2,14 @@ from collections.abc import Sequence -from spatialdata._core.transformation_manager._transformation_graph import TransformationGraph -from spatialdata._core.transformation_manager.exceptions import CoordinateSystemNotFoundError from spatialdata.models import SpatialElement from spatialdata.transformations import BaseTransformation -from spatialdata.transformations.graph.edge import BaseTransformationEdge -from spatialdata.transformations.graph.utils import get_default_coordinate_system +from spatialdata.transformations._graph import ( + BaseTransformationEdge, + CoordinateSystemNotFoundError, + TransformationGraph, + get_default_coordinate_system, +) class TransformationsFacade: diff --git a/src/spatialdata/_io/_utils.py b/src/spatialdata/_io/_utils.py index 214cad58b..e2ff28956 100644 --- a/src/spatialdata/_io/_utils.py +++ b/src/spatialdata/_io/_utils.py @@ -30,12 +30,8 @@ from spatialdata._io.format import RasterFormatType, RasterFormatV01, RasterFormatV02, RasterFormatV03 from spatialdata._logging import logger from spatialdata._utils import get_pyramid_levels -from spatialdata.models._utils import ( - MappingToCoordinateSystem_t, - SpatialElement, - ValidAxis_t, - _validate_mapping_to_coordinate_system_type, -) +from spatialdata.models import MappingToCoordinateSystem_t, SpatialElement, ValidAxis_t +from spatialdata.models._utils import _validate_mapping_to_coordinate_system_type from spatialdata.transformations.ngff.ngff_transformations import NgffBaseTransformation from spatialdata.transformations.transformations import BaseTransformation diff --git a/src/spatialdata/_io/format.py b/src/spatialdata/_io/format.py index a9aa21ad3..ba522bc71 100644 --- a/src/spatialdata/_io/format.py +++ b/src/spatialdata/_io/format.py @@ -15,7 +15,7 @@ ) from shapely import GeometryType -from spatialdata.models.models import ATTRS_KEY, PointsModel, ShapesModel +from spatialdata.models import ATTRS_KEY, PointsModel, ShapesModel CoordinateTransform_t = list[dict[str, Any]] diff --git a/src/spatialdata/_io/io_raster.py b/src/spatialdata/_io/io_raster.py index 1cfc1a942..b04ab2c2b 100644 --- a/src/spatialdata/_io/io_raster.py +++ b/src/spatialdata/_io/io_raster.py @@ -36,16 +36,15 @@ ) from spatialdata._types import ELEMENT_TYPE, ELEMENT_TYPE_RASTER from spatialdata._utils import get_pyramid_levels -from spatialdata.models.models import ATTRS_KEY +from spatialdata.models.literals import ATTRS_KEY from spatialdata.models.pyramids_utils import dask_arrays_to_datatree +from spatialdata.transformations._graph import Axis, BaseTransformationEdge, CoordSystem, parse_ngff_transf from spatialdata.transformations._utils import ( _get_transformations, _get_transformations_xarray, _set_transformations, compute_coordinates, ) -from spatialdata.transformations.graph.edge import BaseTransformationEdge, parse_ngff_transf -from spatialdata.transformations.graph.vert import Axis, CoordSystem def _is_flat_int_sequence(value: object) -> TypeGuard[Sequence[int]]: diff --git a/src/spatialdata/_io/io_shapes.py b/src/spatialdata/_io/io_shapes.py index 833406d39..dee5fe7b7 100644 --- a/src/spatialdata/_io/io_shapes.py +++ b/src/spatialdata/_io/io_shapes.py @@ -26,7 +26,7 @@ ShapesFormatV03, _parse_version, ) -from spatialdata.models import ShapesModel, get_axes_names +from spatialdata.models import TRANSFORM_KEY, ShapesModel, get_axes_names from spatialdata.transformations._utils import ( _get_transformations, _set_transformations, @@ -187,8 +187,6 @@ def _write_shapes_v02_v03( Whether to use the WKB or geoarrow encoding for GeoParquet. See :meth:`geopandas.GeoDataFrame.to_parquet` for details. """ - from spatialdata.models._utils import TRANSFORM_KEY - element_store = group.store_path.store if not isinstance(element_store, LocalStore): raise TypeError(f"Writing a shapes element requires a local zarr store, got {type(element_store).__name__}.") diff --git a/src/spatialdata/models/__init__.py b/src/spatialdata/models/__init__.py index 01db9dc2d..2eb31b764 100644 --- a/src/spatialdata/models/__init__.py +++ b/src/spatialdata/models/__init__.py @@ -2,11 +2,6 @@ from spatialdata._core.validation import check_target_region_column_symmetry from spatialdata.models._utils import ( - C, - SpatialElement, - X, - Y, - Z, force_2d, get_axes_names, get_channel_names, @@ -17,18 +12,31 @@ validate_axes, validate_axis_name, ) +from spatialdata.models.literals import ( + ATTRS_KEY, + DEFAULT_COORDINATE_SYSTEM, + TRANSFORM_KEY, + C, + NgffAxisType, + X, + Y, + Z, + axis_type_mapping_ngff, +) from spatialdata.models.models import ( Image2DModel, Image3DModel, Labels2DModel, Labels3DModel, PointsModel, + RasterSchema, ShapesModel, TableModel, get_model, get_table_keys, validate_element, ) +from spatialdata.models.types import MappingToCoordinateSystem_t, SpatialElement, ValidAxis_t __all__ = [ "Labels2DModel", @@ -37,6 +45,7 @@ "Image3DModel", "ShapesModel", "PointsModel", + "RasterSchema", "TableModel", "get_model", "SpatialElement", @@ -56,4 +65,11 @@ "get_channel_names", "set_channel_names", "force_2d", + "NgffAxisType", + "axis_type_mapping_ngff", + "DEFAULT_COORDINATE_SYSTEM", + "TRANSFORM_KEY", + "ValidAxis_t", + "ATTRS_KEY", + "MappingToCoordinateSystem_t", ] diff --git a/src/spatialdata/models/_utils.py b/src/spatialdata/models/_utils.py index e3f32d312..6be65304b 100644 --- a/src/spatialdata/models/_utils.py +++ b/src/spatialdata/models/_utils.py @@ -1,7 +1,6 @@ from __future__ import annotations import warnings -from enum import StrEnum from functools import singledispatch from typing import TYPE_CHECKING, Any @@ -18,36 +17,14 @@ from spatialdata._logging import logger from spatialdata._utils import _check_match_length_channels_c_dim +from spatialdata.models.literals import C, X, Y, Z +from spatialdata.models.types import MappingToCoordinateSystem_t, SpatialElement, ValidAxis_t from spatialdata.transformations.transformations import BaseTransformation -type SpatialElement = DataArray | DataTree | GeoDataFrame | DaskDataFrame -TRANSFORM_KEY = "transform" -DEFAULT_COORDINATE_SYSTEM = "global" -ValidAxis_t = str -MappingToCoordinateSystem_t = dict[str, BaseTransformation] -C = "c" -Z = "z" -Y = "y" -X = "x" - - -class NgffAxisType(StrEnum): - SPACE = "space" - CHANNEL = "channel" - - -axis_type_mapping_ngff = { - C: NgffAxisType.CHANNEL, - X: NgffAxisType.SPACE, - Y: NgffAxisType.SPACE, - Z: NgffAxisType.SPACE, -} - - if TYPE_CHECKING: from anndata import AnnData - from spatialdata.models.models import RasterSchema + from spatialdata.models import RasterSchema # mypy says that we can't do isinstance(something, SpatialElement), @@ -379,7 +356,7 @@ def get_raster_model_from_data_dims(dims: tuple[str, ...]) -> type[RasterSchema] ------- The raster model corresponding to the dimensions of the data. """ - from spatialdata.models.models import Image2DModel, Image3DModel, Labels2DModel, Labels3DModel + from spatialdata.models import Image2DModel, Image3DModel, Labels2DModel, Labels3DModel if not set(dims).issubset({C, Z, Y, X}): raise ValueError(f"Invalid dimensions: {dims}") @@ -390,7 +367,7 @@ def get_raster_model_from_data_dims(dims: tuple[str, ...]) -> type[RasterSchema] def convert_region_column_to_categorical(table: AnnData) -> None: - from spatialdata.models.models import TableModel + from spatialdata.models import TableModel if TableModel.ATTRS_KEY in table.uns: region_key = table.uns[TableModel.ATTRS_KEY][TableModel.REGION_KEY_KEY] diff --git a/src/spatialdata/models/literals.py b/src/spatialdata/models/literals.py new file mode 100644 index 000000000..1c8c75284 --- /dev/null +++ b/src/spatialdata/models/literals.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from enum import StrEnum + +TRANSFORM_KEY = "transform" +DEFAULT_COORDINATE_SYSTEM = "global" +C = "c" +Z = "z" +Y = "y" +X = "x" + + +class NgffAxisType(StrEnum): + """Enum defining NGFF axis types""" + + SPACE = "space" + CHANNEL = "channel" + + +axis_type_mapping_ngff = { + C: NgffAxisType.CHANNEL, + X: NgffAxisType.SPACE, + Y: NgffAxisType.SPACE, + Z: NgffAxisType.SPACE, +} +ATTRS_KEY = "spatialdata_attrs" diff --git a/src/spatialdata/models/models.py b/src/spatialdata/models/models.py index b0a6c16f6..2ab90e6c6 100644 --- a/src/spatialdata/models/models.py +++ b/src/spatialdata/models/models.py @@ -32,18 +32,16 @@ from spatialdata._types import ArrayLike from spatialdata._utils import _check_match_length_channels_c_dim from spatialdata.config import settings -from spatialdata.models import C, X, Y, Z, get_axes_names +from spatialdata.models import C, SpatialElement, X, Y, Z, get_axes_names from spatialdata.models._utils import ( - DEFAULT_COORDINATE_SYSTEM, - TRANSFORM_KEY, - MappingToCoordinateSystem_t, - SpatialElement, _validate_mapping_to_coordinate_system_type, convert_region_column_to_categorical, ) from spatialdata.models.chunks_utils import Chunks_t +from spatialdata.models.literals import ATTRS_KEY, DEFAULT_COORDINATE_SYSTEM, TRANSFORM_KEY from spatialdata.models.pyramids_utils import ScaleFactors_t # ozp -> ome-zarr-py from spatialdata.models.pyramids_utils import to_multiscale as to_multiscale_ozp +from spatialdata.models.types import MappingToCoordinateSystem_t from spatialdata.transformations._utils import ( _get_transformations, _set_transformations, @@ -56,8 +54,6 @@ __all__ = ["Chunks_t", "ScaleFactors_t"] -ATTRS_KEY = "spatialdata_attrs" - def _parse_transformations(element: SpatialElement, transformations: MappingToCoordinateSystem_t | None = None) -> None: _validate_mapping_to_coordinate_system_type(transformations) diff --git a/src/spatialdata/models/types.py b/src/spatialdata/models/types.py new file mode 100644 index 000000000..e8b5c35a2 --- /dev/null +++ b/src/spatialdata/models/types.py @@ -0,0 +1,11 @@ +from __future__ import annotations + +from dask.dataframe import DataFrame as DaskDataFrame +from geopandas import GeoDataFrame +from xarray import DataArray, DataTree + +from spatialdata.transformations import BaseTransformation + +type SpatialElement = DataArray | DataTree | GeoDataFrame | DaskDataFrame +ValidAxis_t = str +MappingToCoordinateSystem_t = dict[str, BaseTransformation] diff --git a/src/spatialdata/testing.py b/src/spatialdata/testing.py index f699d2e1c..6a5187e79 100644 --- a/src/spatialdata/testing.py +++ b/src/spatialdata/testing.py @@ -11,8 +11,7 @@ from spatialdata import SpatialData from spatialdata._core._elements import Images, Labels, Points, Shapes, Tables -from spatialdata.models import PointsModel -from spatialdata.models._utils import SpatialElement +from spatialdata.models import PointsModel, SpatialElement from spatialdata.transformations.operations import get_transformation _Elements = Images | Labels | Shapes | Points | Tables diff --git a/src/spatialdata/transformations/__init__.py b/src/spatialdata/transformations/__init__.py index 802cc47cd..e95a92766 100644 --- a/src/spatialdata/transformations/__init__.py +++ b/src/spatialdata/transformations/__init__.py @@ -1,6 +1,5 @@ from __future__ import annotations -from spatialdata.transformations import graph from spatialdata.transformations.operations import ( align_elements_using_landmarks, get_transformation, @@ -21,7 +20,6 @@ ) __all__ = [ - "graph", "BaseTransformation", "Identity", "MapAxis", diff --git a/src/spatialdata/transformations/_graph/__init__.py b/src/spatialdata/transformations/_graph/__init__.py new file mode 100644 index 000000000..6cb92b9d9 --- /dev/null +++ b/src/spatialdata/transformations/_graph/__init__.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +from spatialdata.transformations._graph.edge import ( + AffineEdge, + BaseTransformationEdge, + ByDimensionEdge, + CsGen, + IdentityEdge, + MapAxisEdge, + ProjectAxisEdge, + RotationEdge, + ScaleEdge, + SequenceEdge, + TranslationEdge, + parse_affine, + parse_by_dimension, + parse_identity, + parse_map_axis, + parse_ngff_transf, + parse_project_axis, + parse_rotation, + parse_scale, + parse_sequence, + parse_translation, +) +from spatialdata.transformations._graph.exceptions import ( + AxisRedefinitionError, + CannotRemoveCoordinateSystemError, + CoordinateSystemAlreadyExistsError, + CoordinateSystemNotFoundError, + DeterminantDifferentFromOne, + ElementAlreadyExistsError, + ElementNotRegisteredToAnyCoordinateSystemError, + EmptyTransformSequenceError, + IncompatibleCoordSystemsError, + InvalidPathError, + MissingAxisError, + NGFFCompatibilityError, + NotOrthonormalError, + TransformationNotFoundError, + TransformationPathAmbiguousError, + TransformationPathAmbiguousMultipleEdgeExpectedError, + TransformationPathAmbiguousNoEdgeExpectedError, + TransformationPathNotFoundError, + TransformationPathNotSimple, + UnexpectedShapeError, + UnmappedAxisError, +) +from spatialdata.transformations._graph.graph import TransformationGraph +from spatialdata.transformations._graph.literals import TRANSFORM_KEY +from spatialdata.transformations._graph.utils import get_default_coordinate_system +from spatialdata.transformations._graph.vert import Axis, CoordSystem + +__all__ = [ + # from edge + "AffineEdge", + "BaseTransformationEdge", + "ByDimensionEdge", + "CsGen", + "IdentityEdge", + "MapAxisEdge", + "ProjectAxisEdge", + "RotationEdge", + "ScaleEdge", + "SequenceEdge", + "TranslationEdge", + "parse_affine", + "parse_by_dimension", + "parse_identity", + "parse_map_axis", + "parse_ngff_transf", + "parse_project_axis", + "parse_rotation", + "parse_scale", + "parse_sequence", + "parse_translation", + # from exceptions + "AxisRedefinitionError", + "CannotRemoveCoordinateSystemError", + "CoordinateSystemAlreadyExistsError", + "CoordinateSystemNotFoundError", + "DeterminantDifferentFromOne", + "ElementAlreadyExistsError", + "ElementNotRegisteredToAnyCoordinateSystemError", + "EmptyTransformSequenceError", + "IncompatibleCoordSystemsError", + "InvalidPathError", + "MissingAxisError", + "NGFFCompatibilityError", + "NotOrthonormalError", + "TransformationNotFoundError", + "TransformationPathAmbiguousError", + "TransformationPathAmbiguousMultipleEdgeExpectedError", + "TransformationPathAmbiguousNoEdgeExpectedError", + "TransformationPathNotFoundError", + "TransformationPathNotSimple", + "UnexpectedShapeError", + "UnmappedAxisError", + # from graph + "TransformationGraph", + # from literals + "TRANSFORM_KEY", + # from utils + "get_default_coordinate_system", + # from vert + "Axis", + "CoordSystem", +] diff --git a/src/spatialdata/transformations/graph/edge.py b/src/spatialdata/transformations/_graph/edge.py similarity index 99% rename from src/spatialdata/transformations/graph/edge.py rename to src/spatialdata/transformations/_graph/edge.py index be47ee7ac..b249cfa42 100644 --- a/src/spatialdata/transformations/graph/edge.py +++ b/src/spatialdata/transformations/_graph/edge.py @@ -10,7 +10,9 @@ import numpy as np import ome_zarr_models.v06.coordinate_transforms as ozm06trans -from spatialdata._core.transformation_manager.exceptions import ( +from spatialdata._types import ArrayLike +from spatialdata.models import NgffAxisType +from spatialdata.transformations._graph.exceptions import ( AxisRedefinitionError, DeterminantDifferentFromOne, EmptyTransformSequenceError, @@ -20,9 +22,7 @@ UnexpectedShapeError, UnmappedAxisError, ) -from spatialdata._types import ArrayLike -from spatialdata.models._utils import NgffAxisType -from spatialdata.transformations.graph.vert import Axis, CoordSystem +from spatialdata.transformations._graph.vert import Axis, CoordSystem class BaseTransformationEdge(ABC): diff --git a/src/spatialdata/_core/transformation_manager/exceptions.py b/src/spatialdata/transformations/_graph/exceptions.py similarity index 99% rename from src/spatialdata/_core/transformation_manager/exceptions.py rename to src/spatialdata/transformations/_graph/exceptions.py index 75b3ab8b3..f4bdc280f 100644 --- a/src/spatialdata/_core/transformation_manager/exceptions.py +++ b/src/spatialdata/transformations/_graph/exceptions.py @@ -3,7 +3,7 @@ from typing import override from spatialdata._types import ArrayLike -from spatialdata.transformations.graph.vert import Axis, CoordSystem +from spatialdata.transformations._graph.vert import Axis, CoordSystem class CoordinateSystemNotFoundError(ValueError): diff --git a/src/spatialdata/_core/transformation_manager/_transformation_graph.py b/src/spatialdata/transformations/_graph/graph.py similarity index 98% rename from src/spatialdata/_core/transformation_manager/_transformation_graph.py rename to src/spatialdata/transformations/_graph/graph.py index 6c75b9ae1..543e8c828 100644 --- a/src/spatialdata/_core/transformation_manager/_transformation_graph.py +++ b/src/spatialdata/transformations/_graph/graph.py @@ -5,7 +5,8 @@ import networkx as nx -from spatialdata._core.transformation_manager.exceptions import ( +from spatialdata.transformations._graph.edge import BaseTransformationEdge, SequenceEdge +from spatialdata.transformations._graph.exceptions import ( CannotRemoveCoordinateSystemError, CoordinateSystemAlreadyExistsError, CoordinateSystemHasElementsError, @@ -21,9 +22,8 @@ TransformationPathNotFoundError, TransformationPathNotSimple, ) -from spatialdata._core.transformation_manager.types import TRANSFORM_KEY -from spatialdata.transformations.graph.edge import BaseTransformationEdge, SequenceEdge -from spatialdata.transformations.graph.vert import CoordSystem +from spatialdata.transformations._graph.literals import TRANSFORM_KEY +from spatialdata.transformations._graph.vert import CoordSystem class TransformationGraph: diff --git a/src/spatialdata/_core/transformation_manager/types.py b/src/spatialdata/transformations/_graph/literals.py similarity index 100% rename from src/spatialdata/_core/transformation_manager/types.py rename to src/spatialdata/transformations/_graph/literals.py diff --git a/src/spatialdata/transformations/graph/utils.py b/src/spatialdata/transformations/_graph/utils.py similarity index 93% rename from src/spatialdata/transformations/graph/utils.py rename to src/spatialdata/transformations/_graph/utils.py index 89c80ead6..2e77e8b12 100644 --- a/src/spatialdata/transformations/graph/utils.py +++ b/src/spatialdata/transformations/_graph/utils.py @@ -1,7 +1,7 @@ from __future__ import annotations from spatialdata.models import SpatialElement, get_axes_names -from spatialdata.transformations.graph.vert import Axis, CoordSystem +from spatialdata.transformations._graph.vert import Axis, CoordSystem def get_default_coordinate_system( diff --git a/src/spatialdata/transformations/graph/vert.py b/src/spatialdata/transformations/_graph/vert.py similarity index 97% rename from src/spatialdata/transformations/graph/vert.py rename to src/spatialdata/transformations/_graph/vert.py index 95c5c77a1..d20c5fbe8 100644 --- a/src/spatialdata/transformations/graph/vert.py +++ b/src/spatialdata/transformations/_graph/vert.py @@ -6,11 +6,8 @@ import ome_zarr_models.v06.coordinate_transforms as ozm06ct -from spatialdata.models._utils import NgffAxisType, axis_type_mapping_ngff - - -class AxisParsingException(Exception): - pass +from spatialdata.models import NgffAxisType, axis_type_mapping_ngff +from spatialdata.transformations._graph.exceptions import AxisParsingException @dataclass(frozen=True) diff --git a/src/spatialdata/transformations/_utils.py b/src/spatialdata/transformations/_utils.py index 032c89c13..a9f9c7b53 100644 --- a/src/spatialdata/transformations/_utils.py +++ b/src/spatialdata/transformations/_utils.py @@ -13,13 +13,11 @@ if TYPE_CHECKING: from spatialdata._core.spatialdata import SpatialData - from spatialdata.models import SpatialElement - from spatialdata.models._utils import MappingToCoordinateSystem_t + from spatialdata.models import TRANSFORM_KEY, MappingToCoordinateSystem_t, SpatialElement from spatialdata.transformations.transformations import Affine, BaseTransformation, Scale def _get_transformations_from_dict_container(dict_container: Any) -> MappingToCoordinateSystem_t | None: - from spatialdata.models._utils import TRANSFORM_KEY if TRANSFORM_KEY in dict_container: d = dict_container[TRANSFORM_KEY] @@ -38,7 +36,6 @@ def _get_transformations(e: SpatialElement) -> MappingToCoordinateSystem_t | Non def _set_transformations_to_dict_container(dict_container: Any, transformations: MappingToCoordinateSystem_t) -> None: - from spatialdata.models._utils import TRANSFORM_KEY if TRANSFORM_KEY not in dict_container: dict_container[TRANSFORM_KEY] = {} @@ -48,7 +45,6 @@ def _set_transformations_to_dict_container(dict_container: Any, transformations: def _set_transformations_to_element(element: Any, transformations: MappingToCoordinateSystem_t) -> None: - from spatialdata.models._utils import TRANSFORM_KEY attrs = element.attrs if TRANSFORM_KEY not in attrs: @@ -134,7 +130,6 @@ def _(e: DataArray) -> MappingToCoordinateSystem_t | None: @_get_transformations.register(DataTree) def _(e: DataTree) -> MappingToCoordinateSystem_t | None: - from spatialdata.models._utils import TRANSFORM_KEY if TRANSFORM_KEY in e.attrs: raise ValueError( diff --git a/src/spatialdata/transformations/graph/__init__.py b/src/spatialdata/transformations/graph/__init__.py deleted file mode 100644 index 8b1378917..000000000 --- a/src/spatialdata/transformations/graph/__init__.py +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/spatialdata/transformations/ngff/ngff_transformations.py b/src/spatialdata/transformations/ngff/ngff_transformations.py index 1758159bf..13f564247 100644 --- a/src/spatialdata/transformations/ngff/ngff_transformations.py +++ b/src/spatialdata/transformations/ngff/ngff_transformations.py @@ -8,6 +8,7 @@ import numpy as np from spatialdata._types import ArrayLike +from spatialdata.models import C, X, Y, Z from spatialdata.transformations.ngff.ngff_coordinate_system import NgffCoordinateSystem __all__ = [ @@ -366,8 +367,6 @@ def _affine_matrix_from_input_and_output_axes( output_axes the output axes. """ - from spatialdata.models import C, X, Y, Z - assert all(ax in (X, Y, Z, C) for ax in input_axes) assert all(ax in (X, Y, Z, C) for ax in output_axes) m = np.zeros((len(output_axes) + 1, len(input_axes) + 1)) diff --git a/src/spatialdata/transformations/operations.py b/src/spatialdata/transformations/operations.py index 9091cec38..bcbed90cf 100644 --- a/src/spatialdata/transformations/operations.py +++ b/src/spatialdata/transformations/operations.py @@ -8,13 +8,14 @@ from geopandas import GeoDataFrame from spatialdata._logging import logger +from spatialdata.models import DEFAULT_COORDINATE_SYSTEM from spatialdata.transformations._utils import _get_transformations, _set_transformations if TYPE_CHECKING: import networkx as nx from spatialdata._core.spatialdata import SpatialData - from spatialdata.models._utils import SpatialElement + from spatialdata.models import SpatialElement from spatialdata.transformations.transformations import Affine, BaseTransformation @@ -49,7 +50,6 @@ def set_transformation( to the SpatialData object, and the SpatialData object needs to be backed. """ - from spatialdata.models._utils import DEFAULT_COORDINATE_SYSTEM from spatialdata.transformations import BaseTransformation if write_to_sdata is None: @@ -112,7 +112,6 @@ def get_transformation( The transformation, if `to_coordinate_system` is not None, otherwise a dictionary of transformations to all the coordinate systems. """ - from spatialdata.models._utils import DEFAULT_COORDINATE_SYSTEM transformations = _get_transformations(element) assert isinstance(transformations, dict) @@ -159,7 +158,6 @@ def remove_transformation( If not None, the element needs to belong to the SpatialData object, and the SpatialData object needs to be backed. """ - from spatialdata.models._utils import DEFAULT_COORDINATE_SYSTEM if write_to_sdata is None: if remove_all is False: diff --git a/src/spatialdata/transformations/transformations.py b/src/spatialdata/transformations/transformations.py index af1233e33..4314d79a2 100644 --- a/src/spatialdata/transformations/transformations.py +++ b/src/spatialdata/transformations/transformations.py @@ -9,23 +9,23 @@ import xarray as xr from xarray import DataArray -from spatialdata._core.transformation_manager.exceptions import ( - IncompatibleCoordSystemsError, - MissingAxisError, - NGFFCompatibilityError, - UnmappedAxisError, -) -from spatialdata.transformations.graph.edge import ( +from spatialdata.transformations._graph import ( AffineEdge, + Axis, BaseTransformationEdge, + CoordSystem, CsGen, IdentityEdge, + IncompatibleCoordSystemsError, MapAxisEdge, + MissingAxisError, + NGFFCompatibilityError, ScaleEdge, SequenceEdge, TranslationEdge, + UnmappedAxisError, ) -from spatialdata.transformations.graph.vert import Axis, CoordSystem +from spatialdata.transformations.ngff._utils import get_default_coordinate_system from spatialdata.transformations.ngff.ngff_coordinate_system import NgffCoordinateSystem, _get_spatial_axes from spatialdata.transformations.ngff.ngff_transformations import ( NgffAffine, @@ -39,8 +39,7 @@ if TYPE_CHECKING: from spatialdata._types import ArrayLike, ListOrNDArrayFloating - from spatialdata.models import SpatialElement - from spatialdata.models._utils import ValidAxis_t + from spatialdata.models import DEFAULT_COORDINATE_SYSTEM, SpatialElement, ValidAxis_t TRANSFORMATIONS_MAP: dict[type[NgffBaseTransformation], type[BaseTransformation]] = {} @@ -55,7 +54,7 @@ def validate_axes(axes: tuple[ValidAxis_t, ...]) -> None: This function is to allow to call validate_axes() from this file in multiple places while avoiding circular imports. """ - from spatialdata.models._utils import validate_axes + from spatialdata.models import validate_axes validate_axes(axes) @@ -173,7 +172,6 @@ def _get_default_coordinate_system( name: str | None = None, default_to_global: bool = False, ) -> NgffCoordinateSystem: - from spatialdata.transformations.ngff._utils import get_default_coordinate_system cs = get_default_coordinate_system(axes) if unit is not None: @@ -183,8 +181,6 @@ def _get_default_coordinate_system( if name is not None: cs.name = name elif default_to_global: - from spatialdata.models._utils import DEFAULT_COORDINATE_SYSTEM - cs.name = DEFAULT_COORDINATE_SYSTEM return cs diff --git a/tests/core/operations/test_rasterize_bins.py b/tests/core/operations/test_rasterize_bins.py index afd807657..ba5479bc0 100644 --- a/tests/core/operations/test_rasterize_bins.py +++ b/tests/core/operations/test_rasterize_bins.py @@ -22,7 +22,7 @@ from spatialdata._core.spatialdata import SpatialData from spatialdata._logging import logger from spatialdata._types import ArrayLike -from spatialdata.models.models import ( +from spatialdata.models import ( Image2DModel, PointsModel, ShapesModel, diff --git a/tests/core/operations/test_vectorize.py b/tests/core/operations/test_vectorize.py index b0368a12a..4a962c655 100644 --- a/tests/core/operations/test_vectorize.py +++ b/tests/core/operations/test_vectorize.py @@ -15,7 +15,7 @@ to_polygons, ) from spatialdata.datasets import blobs -from spatialdata.models.models import ShapesModel +from spatialdata.models import ShapesModel from spatialdata.testing import assert_elements_are_identical diff --git a/tests/core/query/test_relational_query.py b/tests/core/query/test_relational_query.py index 07d232559..2e7aaf9dd 100644 --- a/tests/core/query/test_relational_query.py +++ b/tests/core/query/test_relational_query.py @@ -18,7 +18,7 @@ get_element_annotators, join_spatialelement_table, ) -from spatialdata.models.models import TableModel +from spatialdata.models import TableModel from spatialdata.testing import assert_anndata_equal, assert_geodataframe_equal diff --git a/tests/core/query/test_spatial_query.py b/tests/core/query/test_spatial_query.py index 3b6ff3581..27ca2093b 100644 --- a/tests/core/query/test_spatial_query.py +++ b/tests/core/query/test_spatial_query.py @@ -23,6 +23,7 @@ ) from spatialdata._core.spatialdata import SpatialData from spatialdata.models import ( + ATTRS_KEY, Image2DModel, Image3DModel, Labels2DModel, @@ -31,7 +32,6 @@ ShapesModel, TableModel, ) -from spatialdata.models.models import ATTRS_KEY from spatialdata.testing import assert_spatial_data_objects_are_identical from spatialdata.transformations import Identity, MapAxis, set_transformation from tests.conftest import _make_points, _make_squares diff --git a/tests/models/test_models.py b/tests/models/test_models.py index 4ee3210b7..151908b37 100644 --- a/tests/models/test_models.py +++ b/tests/models/test_models.py @@ -31,14 +31,7 @@ from spatialdata._core.validation import ValidationError from spatialdata._types import ArrayLike from spatialdata.config import LARGE_CHUNK_THRESHOLD_BYTES -from spatialdata.models import get_table_keys -from spatialdata.models._utils import ( - force_2d, - points_dask_dataframe_to_geopandas, - points_geopandas_to_dask_dataframe, - validate_axis_name, -) -from spatialdata.models.models import ( +from spatialdata.models import ( Image2DModel, Image3DModel, Labels2DModel, @@ -49,6 +42,13 @@ TableModel, get_axes_names, get_model, + get_table_keys, +) +from spatialdata.models._utils import ( + force_2d, + points_dask_dataframe_to_geopandas, + points_geopandas_to_dask_dataframe, + validate_axis_name, ) from spatialdata.testing import assert_elements_are_identical from spatialdata.transformations._utils import _set_transformations diff --git a/tests/transformations/edge/__init__.py b/tests/transformations/graph/__init__.py similarity index 100% rename from tests/transformations/edge/__init__.py rename to tests/transformations/graph/__init__.py diff --git a/tests/transformations/graph/edge/__init__.py b/tests/transformations/graph/edge/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/transformations/edge/conftest.py b/tests/transformations/graph/edge/conftest.py similarity index 96% rename from tests/transformations/edge/conftest.py rename to tests/transformations/graph/edge/conftest.py index 5ff56a219..2afe25854 100644 --- a/tests/transformations/edge/conftest.py +++ b/tests/transformations/graph/edge/conftest.py @@ -1,6 +1,6 @@ from __future__ import annotations -from spatialdata.transformations.graph.vert import Axis, CoordSystem +from spatialdata.transformations._graph import Axis, CoordSystem x_axis = Axis(name="x", type="space", unit="micrometer") y_axis = Axis(name="y", type="space", unit="micrometer") diff --git a/tests/transformations/edge/test_edge_parsing.py b/tests/transformations/graph/edge/test_edge_parsing.py similarity index 98% rename from tests/transformations/edge/test_edge_parsing.py rename to tests/transformations/graph/edge/test_edge_parsing.py index a20acbaa3..a2f270cde 100644 --- a/tests/transformations/edge/test_edge_parsing.py +++ b/tests/transformations/graph/edge/test_edge_parsing.py @@ -5,7 +5,7 @@ import numpy as np import ome_zarr_models.v06.coordinate_transforms as ozm06trans -from spatialdata.transformations.graph.edge import ( +from spatialdata.transformations._graph import ( AffineEdge, ByDimensionEdge, CsGen, @@ -24,7 +24,7 @@ parse_sequence, parse_translation, ) -from tests.transformations.edge.conftest import ( +from tests.transformations.graph.edge.conftest import ( xy_cs, xyz_cs, yx_cs, diff --git a/tests/transformations/edge/test_edge_transformations.py b/tests/transformations/graph/edge/test_edge_transformations.py similarity index 99% rename from tests/transformations/edge/test_edge_transformations.py rename to tests/transformations/graph/edge/test_edge_transformations.py index 2427da620..2d260814d 100644 --- a/tests/transformations/edge/test_edge_transformations.py +++ b/tests/transformations/graph/edge/test_edge_transformations.py @@ -5,28 +5,26 @@ import numpy as np import pytest -from spatialdata._core.transformation_manager.exceptions import ( +from spatialdata.transformations._graph import ( + AffineEdge, AxisRedefinitionError, + ByDimensionEdge, DeterminantDifferentFromOne, EmptyTransformSequenceError, + IdentityEdge, IncompatibleCoordSystemsError, + MapAxisEdge, MissingAxisError, NotOrthonormalError, - UnexpectedShapeError, - UnmappedAxisError, -) -from spatialdata.transformations.graph.edge import ( - AffineEdge, - ByDimensionEdge, - IdentityEdge, - MapAxisEdge, ProjectAxisEdge, RotationEdge, ScaleEdge, SequenceEdge, TranslationEdge, + UnexpectedShapeError, + UnmappedAxisError, ) -from tests.transformations.edge.conftest import ( +from tests.transformations.graph.edge.conftest import ( a_axis, abc_cs, b_axis, diff --git a/tests/transformations/graph/graph/__init__.py b/tests/transformations/graph/graph/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/core/transformation_manager/conftest.py b/tests/transformations/graph/graph/conftest.py similarity index 95% rename from tests/core/transformation_manager/conftest.py rename to tests/transformations/graph/graph/conftest.py index 212520266..32d204f49 100644 --- a/tests/core/transformation_manager/conftest.py +++ b/tests/transformations/graph/graph/conftest.py @@ -9,8 +9,7 @@ import numpy as np import pytest -from spatialdata.transformations.graph.edge import AffineEdge, ScaleEdge, TranslationEdge -from spatialdata.transformations.graph.vert import Axis, CoordSystem +from spatialdata.transformations._graph import AffineEdge, Axis, CoordSystem, ScaleEdge, TranslationEdge def get_coord_system(cs_name: str) -> CoordSystem: diff --git a/tests/core/transformation_manager/test_transformation_manager.py b/tests/transformations/graph/graph/test_graph.py similarity index 97% rename from tests/core/transformation_manager/test_transformation_manager.py rename to tests/transformations/graph/graph/test_graph.py index ccdf948f7..4ecf7b364 100644 --- a/tests/core/transformation_manager/test_transformation_manager.py +++ b/tests/transformations/graph/graph/test_graph.py @@ -1,24 +1,19 @@ #!/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._core.transformation_manager import TRANSFORM_KEY -from spatialdata._core.transformation_manager._transformation_graph import TransformationGraph -from spatialdata._core.transformation_manager.exceptions import ( +from spatialdata.transformations._graph import ( + TRANSFORM_KEY, + BaseTransformationEdge, CannotRemoveCoordinateSystemError, CoordinateSystemAlreadyExistsError, CoordinateSystemNotFoundError, ElementAlreadyExistsError, ElementNotRegisteredToAnyCoordinateSystemError, InvalidPathError, + SequenceEdge, + TransformationGraph, TransformationNotFoundError, TransformationPathAmbiguousError, TransformationPathAmbiguousMultipleEdgeExpectedError, @@ -26,7 +21,6 @@ TransformationPathNotFoundError, TransformationPathNotSimple, ) -from spatialdata.transformations.graph.edge import BaseTransfEdge, SequenceEdge def test_initialization(): @@ -323,7 +317,7 @@ def test_remove_specific_transformation_between_coordinate_systems_non_existent( def assert_sequences_equivalent( - result_sequences: list[SequenceEdge], expected_transform_lists: list[list[BaseTransfEdge]] + result_sequences: list[SequenceEdge], expected_transform_lists: list[list[BaseTransformationEdge]] ): assert len(result_sequences) == len(expected_transform_lists) diff --git a/tests/transformations/test_transformations.py b/tests/transformations/test_transformations.py index dd5b52dba..435f5d011 100644 --- a/tests/transformations/test_transformations.py +++ b/tests/transformations/test_transformations.py @@ -10,8 +10,7 @@ from spatialdata import transform from spatialdata.datasets import blobs -from spatialdata.models import Image2DModel, PointsModel -from spatialdata.models._utils import DEFAULT_COORDINATE_SYSTEM, ValidAxis_t, get_channel_names +from spatialdata.models import DEFAULT_COORDINATE_SYSTEM, Image2DModel, PointsModel, ValidAxis_t, get_channel_names from spatialdata.transformations.ngff._utils import get_default_coordinate_system from spatialdata.transformations.ngff.ngff_coordinate_system import NgffCoordinateSystem from spatialdata.transformations.ngff.ngff_transformations import ( From 0f08a75fbc279981e04fda31b4c7625b095ff940 Mon Sep 17 00:00:00 2001 From: Ajayrama Kumaraswamy Date: Thu, 17 Sep 2026 10:59:41 +0200 Subject: [PATCH 39/54] feat(spatialdata.SpatialData): add transform info from spatialelement.attrs to graph --- src/spatialdata/_core/spatialdata.py | 46 ++++++++++++++++++- .../_core/transformation_manager/__init__.py | 5 ++ src/spatialdata/models/types.py | 12 +++-- 3 files changed, 57 insertions(+), 6 deletions(-) diff --git a/src/spatialdata/_core/spatialdata.py b/src/spatialdata/_core/spatialdata.py index d6aac424b..80678c8b7 100644 --- a/src/spatialdata/_core/spatialdata.py +++ b/src/spatialdata/_core/spatialdata.py @@ -22,7 +22,7 @@ from zarr.errors import GroupNotFoundError from spatialdata._core._elements import Images, Labels, Points, Shapes, Tables -from spatialdata._core.transformation_manager.transformations_facade import TransformationsFacade +from spatialdata._core.transformation_manager import TransformationsFacade from spatialdata._core.validation import ( check_all_keys_case_insensitively_unique, check_target_region_column_symmetry, @@ -34,6 +34,7 @@ from spatialdata._types import ArrayLike, JSONValue, Raster_T from spatialdata._utils import _deprecation_alias from spatialdata.models import ( + DEFAULT_COORDINATE_SYSTEM, Image2DModel, Image3DModel, Labels2DModel, @@ -48,6 +49,7 @@ set_channel_names, ) from spatialdata.models._utils import convert_region_column_to_categorical +from spatialdata.transformations import get_transformation if TYPE_CHECKING: from pandas._typing import DtypeObj @@ -57,6 +59,7 @@ SpatialDataContainerFormatType, SpatialDataFormatType, ) + from spatialdata.transformations._graph import BaseTransformationEdge class SpatialData: @@ -131,7 +134,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_facade = TransformationsFacade() + self.transformations = TransformationsFacade() element_names = list(chain.from_iterable([e.keys() for e in [images, labels, points, shapes] if e is not None])) @@ -151,27 +154,43 @@ def __init__( with collect_error(location=("images", k)): self.images[k] = image + self._initialize_coordinate_transforms_from_element_attrs(new_element=image, new_element_key=k) + if labels is not None: for k, labels_element in labels.items(): with collect_error(location=("labels", k)): self.labels[k] = labels_element + self._initialize_coordinate_transforms_from_element_attrs( + new_element=labels_element, new_element_key=k + ) + if shapes is not None: for k, shapes_element in shapes.items(): with collect_error(location=("shapes", k)): self.shapes[k] = shapes_element + self._initialize_coordinate_transforms_from_element_attrs( + new_element=shapes_element, new_element_key=k + ) + if points is not None: for k, points_element in points.items(): with collect_error(location=("points", k)): self.points[k] = points_element + self._initialize_coordinate_transforms_from_element_attrs( + new_element=points_element, new_element_key=k + ) + if tables is not None: for k, table in tables.items(): with collect_error(location=("tables", k)): self.validate_table_in_spatialdata(table) self.tables[k] = table + # transformation data is not stored in AnnData.attrs + def validate_table_in_spatialdata(self, table: AnnData) -> None: """ Validate the presence of the annotation target of a SpatialData table in the SpatialData object. @@ -737,6 +756,21 @@ def _filter_tables( return tables + def _initialize_coordinate_transforms_from_element_attrs( + self, new_element: SpatialElement, new_element_key: str + ) -> list[BaseTransformationEdge]: + transforms = get_transformation(new_element) + + if not isinstance(transforms, dict): + transforms = {DEFAULT_COORDINATE_SYSTEM: transforms} + + self.transformations._add_new_element_to_graph_creating_default_cs( + new_element=new_element, new_element_key=new_element_key + ) + return self.transformations.set_element_transformations( + element_name=new_element_key, transformations_config=transforms + ) + def rename_coordinate_systems(self, rename_dict: dict[str, str]) -> None: """ Rename coordinate systems. @@ -2524,6 +2558,8 @@ def __setitem__(self, key: str, value: SpatialElement | AnnData) -> None: """ Add the element to the SpatialData object. + Also create a "default" coordinate system for the element and link the element to it. + Parameters ---------- key @@ -2536,18 +2572,24 @@ def __setitem__(self, key: str, value: SpatialElement | AnnData) -> None: if schema in (Image2DModel, Image3DModel): assert isinstance(value, DataArray | DataTree) self.images[key] = value + self._initialize_coordinate_transforms_from_element_attrs(new_element=value, new_element_key=key) elif schema in (Labels2DModel, Labels3DModel): assert isinstance(value, DataArray | DataTree) self.labels[key] = value + self._initialize_coordinate_transforms_from_element_attrs(new_element=value, new_element_key=key) elif schema == PointsModel: assert isinstance(value, DaskDataFrame) self.points[key] = value + self._initialize_coordinate_transforms_from_element_attrs(new_element=value, new_element_key=key) elif schema == ShapesModel: assert isinstance(value, GeoDataFrame) self.shapes[key] = value + self._initialize_coordinate_transforms_from_element_attrs(new_element=value, new_element_key=key) elif schema == TableModel: assert isinstance(value, AnnData) + self.validate_table_in_spatialdata(value) self.tables[key] = value + # transformation data is not stored in AnnData.attrs else: raise TypeError(f"Unknown element type with schema: {schema!r}.") diff --git a/src/spatialdata/_core/transformation_manager/__init__.py b/src/spatialdata/_core/transformation_manager/__init__.py index e69de29bb..e4af58490 100644 --- a/src/spatialdata/_core/transformation_manager/__init__.py +++ b/src/spatialdata/_core/transformation_manager/__init__.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +from spatialdata._core.transformation_manager.transformations_facade import TransformationsFacade + +__all__ = ["TransformationsFacade"] diff --git a/src/spatialdata/models/types.py b/src/spatialdata/models/types.py index e8b5c35a2..84c62dc8d 100644 --- a/src/spatialdata/models/types.py +++ b/src/spatialdata/models/types.py @@ -1,10 +1,14 @@ from __future__ import annotations -from dask.dataframe import DataFrame as DaskDataFrame -from geopandas import GeoDataFrame -from xarray import DataArray, DataTree +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from dask.dataframe import DataFrame as DaskDataFrame + from geopandas import GeoDataFrame + from xarray import DataArray, DataTree + + from spatialdata.transformations import BaseTransformation -from spatialdata.transformations import BaseTransformation type SpatialElement = DataArray | DataTree | GeoDataFrame | DaskDataFrame ValidAxis_t = str From 5c0f3cd4daf91f145ee5f0213bac02876045ff1a Mon Sep 17 00:00:00 2001 From: Ajayrama Kumaraswamy Date: Tue, 29 Sep 2026 15:57:15 +0200 Subject: [PATCH 40/54] fix + reorg (transformations): fix circular import issue --- src/spatialdata/__init__.py | 2 + src/spatialdata/_core/centroids.py | 3 +- src/spatialdata/_core/data_extent.py | 3 +- src/spatialdata/_core/operations/_utils.py | 3 +- src/spatialdata/_core/operations/map.py | 3 +- src/spatialdata/_core/operations/rasterize.py | 3 +- src/spatialdata/_core/operations/transform.py | 5 +- src/spatialdata/_core/operations/vectorize.py | 2 +- src/spatialdata/_core/query/spatial_query.py | 5 +- src/spatialdata/_core/spatialdata.py | 32 ++-- .../transformations_facade.py | 20 ++- src/spatialdata/_io/_utils.py | 3 +- src/spatialdata/_io/io_points.py | 2 +- src/spatialdata/_io/io_shapes.py | 4 +- src/spatialdata/_utils.py | 2 +- src/spatialdata/dataloader/datasets.py | 2 +- src/spatialdata/models/__init__.py | 27 +--- src/spatialdata/models/_utils.py | 132 +--------------- src/spatialdata/models/literals.py | 23 --- src/spatialdata/models/models.py | 15 +- src/spatialdata/models/types.py | 5 +- .../transformations/_graph/__init__.py | 24 ++- .../transformations/_graph/edge.py | 41 ++--- .../transformations/_graph/exceptions.py | 70 +++++--- .../transformations/_graph/graph.py | 20 ++- .../transformations/_graph/literals.py | 24 ++- .../transformations/_graph/types.py | 3 + .../transformations/_graph/utils.py | 149 +++++++++++++++++- .../transformations/_graph/vert.py | 2 +- src/spatialdata/transformations/_utils.py | 9 +- .../transformations/ngff/_utils.py | 2 +- .../ngff/ngff_transformations.py | 2 +- src/spatialdata/transformations/operations.py | 19 ++- .../transformations/transformations.py | 10 +- tests/core/operations/test_rasterize.py | 4 +- tests/core/operations/test_transform.py | 3 +- tests/core/test_centroids.py | 3 +- tests/models/test_models.py | 3 +- .../ngff/test_ngff_transformations.py | 2 +- tests/transformations/test_transformations.py | 4 +- 40 files changed, 378 insertions(+), 312 deletions(-) create mode 100644 src/spatialdata/transformations/_graph/types.py diff --git a/src/spatialdata/__init__.py b/src/spatialdata/__init__.py index 1c04924a4..a4e98e70e 100644 --- a/src/spatialdata/__init__.py +++ b/src/spatialdata/__init__.py @@ -6,6 +6,8 @@ import spatialdata.models._accessor +# Import registers Dask .attrs accessor, enabling metadata handling on Dask collections + __version__ = version("spatialdata") _submodules = { diff --git a/src/spatialdata/_core/centroids.py b/src/spatialdata/_core/centroids.py index 65c361b3d..6d8780665 100644 --- a/src/spatialdata/_core/centroids.py +++ b/src/spatialdata/_core/centroids.py @@ -11,7 +11,8 @@ from xarray import DataArray, DataTree from spatialdata._core.operations.transform import transform -from spatialdata.models import Labels2DModel, Labels3DModel, PointsModel, SpatialElement, get_axes_names, get_model +from spatialdata.models import Labels2DModel, Labels3DModel, PointsModel, SpatialElement, get_model +from spatialdata.transformations._graph.utils import get_axes_names from spatialdata.transformations.operations import get_transformation from spatialdata.transformations.transformations import BaseTransformation diff --git a/src/spatialdata/_core/data_extent.py b/src/spatialdata/_core/data_extent.py index 5efdcfdb8..0df4b5cbf 100644 --- a/src/spatialdata/_core/data_extent.py +++ b/src/spatialdata/_core/data_extent.py @@ -14,7 +14,8 @@ from spatialdata._core.operations.transform import transform from spatialdata._core.spatialdata import SpatialData -from spatialdata.models import PointsModel, SpatialElement, get_axes_names +from spatialdata.models import PointsModel, SpatialElement +from spatialdata.transformations._graph.utils import get_axes_names from spatialdata.transformations.operations import get_transformation BoundingBoxDescription = dict[str, tuple[float, float]] diff --git a/src/spatialdata/_core/operations/_utils.py b/src/spatialdata/_core/operations/_utils.py index 600dbed75..c1e10c903 100644 --- a/src/spatialdata/_core/operations/_utils.py +++ b/src/spatialdata/_core/operations/_utils.py @@ -5,7 +5,8 @@ from anndata import AnnData from xarray import DataArray, DataTree -from spatialdata.models import SpatialElement, get_axes_names, get_spatial_axes +from spatialdata.models import SpatialElement +from spatialdata.transformations._graph.utils import get_axes_names, get_spatial_axes if TYPE_CHECKING: from spatialdata._core.spatialdata import SpatialData diff --git a/src/spatialdata/_core/operations/map.py b/src/spatialdata/_core/operations/map.py index b0738dbff..6bee93432 100644 --- a/src/spatialdata/_core/operations/map.py +++ b/src/spatialdata/_core/operations/map.py @@ -13,8 +13,9 @@ from xarray import DataArray, DataTree from spatialdata._types import IntArrayLike -from spatialdata.models._utils import get_axes_names, get_channel_names, get_raster_model_from_data_dims +from spatialdata.models._utils import get_channel_names, get_raster_model_from_data_dims from spatialdata.transformations import get_transformation +from spatialdata.transformations._graph.utils import get_axes_names __all__ = ["map_raster", "relabel_sequential"] diff --git a/src/spatialdata/_core/operations/rasterize.py b/src/spatialdata/_core/operations/rasterize.py index d47f4bfca..0b15e85c9 100644 --- a/src/spatialdata/_core/operations/rasterize.py +++ b/src/spatialdata/_core/operations/rasterize.py @@ -28,10 +28,9 @@ PointsModel, ShapesModel, SpatialElement, - get_axes_names, get_model, ) -from spatialdata.models._utils import get_spatial_axes +from spatialdata.transformations._graph.utils import get_axes_names, get_spatial_axes from spatialdata.transformations._utils import _get_scale, compute_coordinates from spatialdata.transformations.operations import get_transformation, remove_transformation, set_transformation from spatialdata.transformations.transformations import ( diff --git a/src/spatialdata/_core/operations/transform.py b/src/spatialdata/_core/operations/transform.py index c3e3cb71a..f39b4635b 100644 --- a/src/spatialdata/_core/operations/transform.py +++ b/src/spatialdata/_core/operations/transform.py @@ -21,14 +21,13 @@ from spatialdata._types import ArrayLike from spatialdata._utils import disable_dask_tune_optimization from spatialdata.models import ( - DEFAULT_COORDINATE_SYSTEM, - TRANSFORM_KEY, RasterSchema, SpatialElement, - get_axes_names, get_channel_names, get_model, ) +from spatialdata.transformations._graph import DEFAULT_COORDINATE_SYSTEM, TRANSFORM_KEY +from spatialdata.transformations._graph.utils import get_axes_names from spatialdata.transformations._utils import _get_scale, compute_coordinates, scale_radii if TYPE_CHECKING: diff --git a/src/spatialdata/_core/operations/vectorize.py b/src/spatialdata/_core/operations/vectorize.py index 431bdbd17..88161de64 100644 --- a/src/spatialdata/_core/operations/vectorize.py +++ b/src/spatialdata/_core/operations/vectorize.py @@ -28,10 +28,10 @@ Labels3DModel, ShapesModel, SpatialElement, - get_axes_names, get_model, ) from spatialdata.models._utils import points_dask_dataframe_to_geopandas +from spatialdata.transformations._graph.utils import get_axes_names from spatialdata.transformations.operations import get_transformation from spatialdata.transformations.transformations import Identity diff --git a/src/spatialdata/_core/query/spatial_query.py b/src/spatialdata/_core/query/spatial_query.py index dda863615..77367dd93 100644 --- a/src/spatialdata/_core/query/spatial_query.py +++ b/src/spatialdata/_core/query/spatial_query.py @@ -25,12 +25,11 @@ PointsModel, ShapesModel, SpatialElement, - ValidAxis_t, - get_axes_names, - get_spatial_axes, points_dask_dataframe_to_geopandas, points_geopandas_to_dask_dataframe, ) +from spatialdata.transformations._graph.types import ValidAxis_t +from spatialdata.transformations._graph.utils import get_axes_names, get_spatial_axes from spatialdata.transformations.operations import set_transformation from spatialdata.transformations.transformations import ( Affine, diff --git a/src/spatialdata/_core/spatialdata.py b/src/spatialdata/_core/spatialdata.py index 80678c8b7..e52b8ad20 100644 --- a/src/spatialdata/_core/spatialdata.py +++ b/src/spatialdata/_core/spatialdata.py @@ -34,7 +34,6 @@ from spatialdata._types import ArrayLike, JSONValue, Raster_T from spatialdata._utils import _deprecation_alias from spatialdata.models import ( - DEFAULT_COORDINATE_SYSTEM, Image2DModel, Image3DModel, Labels2DModel, @@ -43,13 +42,13 @@ ShapesModel, SpatialElement, TableModel, - get_axes_names, get_model, get_table_keys, set_channel_names, ) from spatialdata.models._utils import convert_region_column_to_categorical -from spatialdata.transformations import get_transformation +from spatialdata.transformations import BaseTransformation, get_transformation +from spatialdata.transformations._graph.utils import get_axes_names if TYPE_CHECKING: from pandas._typing import DtypeObj @@ -154,42 +153,34 @@ def __init__( with collect_error(location=("images", k)): self.images[k] = image - self._initialize_coordinate_transforms_from_element_attrs(new_element=image, new_element_key=k) - if labels is not None: for k, labels_element in labels.items(): with collect_error(location=("labels", k)): self.labels[k] = labels_element - self._initialize_coordinate_transforms_from_element_attrs( - new_element=labels_element, new_element_key=k - ) - if shapes is not None: for k, shapes_element in shapes.items(): with collect_error(location=("shapes", k)): self.shapes[k] = shapes_element - self._initialize_coordinate_transforms_from_element_attrs( - new_element=shapes_element, new_element_key=k - ) - if points is not None: for k, points_element in points.items(): with collect_error(location=("points", k)): self.points[k] = points_element - self._initialize_coordinate_transforms_from_element_attrs( - new_element=points_element, new_element_key=k - ) - if tables is not None: for k, table in tables.items(): with collect_error(location=("tables", k)): self.validate_table_in_spatialdata(table) self.tables[k] = table - # transformation data is not stored in AnnData.attrs + # to be deleted later, when cleaning up parts that rely on transformation info in attrs + for element_set in (images, labels, shapes, points): + if element_set is not None: + for element_key, element in element_set.items(): + self._initialize_coordinate_transforms_from_element_attrs( + new_element=element, new_element_key=element_key + ) def validate_table_in_spatialdata(self, table: AnnData) -> None: """ @@ -759,10 +750,7 @@ def _filter_tables( def _initialize_coordinate_transforms_from_element_attrs( self, new_element: SpatialElement, new_element_key: str ) -> list[BaseTransformationEdge]: - transforms = get_transformation(new_element) - - if not isinstance(transforms, dict): - transforms = {DEFAULT_COORDINATE_SYSTEM: transforms} + transforms: dict[str, BaseTransformation] = get_transformation(new_element, get_all=True) self.transformations._add_new_element_to_graph_creating_default_cs( new_element=new_element, new_element_key=new_element_key diff --git a/src/spatialdata/_core/transformation_manager/transformations_facade.py b/src/spatialdata/_core/transformation_manager/transformations_facade.py index 14470073c..7833a6baa 100644 --- a/src/spatialdata/_core/transformation_manager/transformations_facade.py +++ b/src/spatialdata/_core/transformation_manager/transformations_facade.py @@ -8,8 +8,10 @@ BaseTransformationEdge, CoordinateSystemNotFoundError, TransformationGraph, - get_default_coordinate_system, + get_instrinsic_coordinate_system, ) +from spatialdata.transformations._graph.exceptions import IncompatibleTransformationOutputCoordinateSystemError +from spatialdata.transformations._graph.utils import get_intrinsic_coordinate_system_name class TransformationsFacade: @@ -47,8 +49,9 @@ def _add_new_element_to_graph_creating_default_cs(self, new_element: SpatialElem new_element_key The key/name to use for the new element in the graph. """ - element_cs = get_default_coordinate_system( - element_to_be_added_to_graph=new_element, coordinate_system_name=new_element_key + element_cs = get_instrinsic_coordinate_system( + element_to_be_added_to_graph=new_element, + coordinate_system_name=get_intrinsic_coordinate_system_name(new_element_key), ) self._graph.add_coordinate_system(element_cs) @@ -82,6 +85,17 @@ def set_element_transformations( ngff_transformation_edge = t._to_ngff_transformation_edge( input_coordinate_system=element_cs, output_coordinate_system_name=output_coordinate_system_name ) + possible_coordinate_system = self._graph.get_coordinate_system(output_coordinate_system_name) + possible_coordinate_system_exists = possible_coordinate_system is not None + if not possible_coordinate_system_exists: + self._graph.add_coordinate_system(ngff_transformation_edge.output) + else: + if possible_coordinate_system != ngff_transformation_edge.output: + raise IncompatibleTransformationOutputCoordinateSystemError( + input_coordinate_system_name=element_cs.name, + output_coordinate_system_name=output_coordinate_system_name, + transformation=t, + ) self._graph.add_transformation_edge(ngff_transformation_edge) transformation_edges.append(ngff_transformation_edge) diff --git a/src/spatialdata/_io/_utils.py b/src/spatialdata/_io/_utils.py index e2ff28956..ad35091c9 100644 --- a/src/spatialdata/_io/_utils.py +++ b/src/spatialdata/_io/_utils.py @@ -30,8 +30,9 @@ from spatialdata._io.format import RasterFormatType, RasterFormatV01, RasterFormatV02, RasterFormatV03 from spatialdata._logging import logger from spatialdata._utils import get_pyramid_levels -from spatialdata.models import MappingToCoordinateSystem_t, SpatialElement, ValidAxis_t +from spatialdata.models import MappingToCoordinateSystem_t, SpatialElement from spatialdata.models._utils import _validate_mapping_to_coordinate_system_type +from spatialdata.transformations._graph.types import ValidAxis_t from spatialdata.transformations.ngff.ngff_transformations import NgffBaseTransformation from spatialdata.transformations.transformations import BaseTransformation diff --git a/src/spatialdata/_io/io_points.py b/src/spatialdata/_io/io_points.py index b7cc4ba3a..b3c4a21e2 100644 --- a/src/spatialdata/_io/io_points.py +++ b/src/spatialdata/_io/io_points.py @@ -16,7 +16,7 @@ ) from spatialdata._io.exceptions import WritingToZarrV2DeprecationWarning from spatialdata._io.format import CurrentPointsFormat, PointsFormats, PointsFormatType, _parse_version -from spatialdata.models import get_axes_names +from spatialdata.transformations._graph.utils import get_axes_names from spatialdata.transformations._utils import ( _get_transformations, _set_transformations, diff --git a/src/spatialdata/_io/io_shapes.py b/src/spatialdata/_io/io_shapes.py index dee5fe7b7..a5b831826 100644 --- a/src/spatialdata/_io/io_shapes.py +++ b/src/spatialdata/_io/io_shapes.py @@ -26,7 +26,9 @@ ShapesFormatV03, _parse_version, ) -from spatialdata.models import TRANSFORM_KEY, ShapesModel, get_axes_names +from spatialdata.models import ShapesModel +from spatialdata.transformations._graph import TRANSFORM_KEY +from spatialdata.transformations._graph.utils import get_axes_names from spatialdata.transformations._utils import ( _get_transformations, _set_transformations, diff --git a/src/spatialdata/_utils.py b/src/spatialdata/_utils.py index f5ec67865..50388e8f6 100644 --- a/src/spatialdata/_utils.py +++ b/src/spatialdata/_utils.py @@ -84,7 +84,7 @@ def unpad_raster(raster: DataArray | DataTree) -> DataArray | DataTree: ------- The unpadded raster. """ - from spatialdata.models import get_axes_names + from spatialdata.transformations._graph.utils import get_axes_names from spatialdata.transformations._utils import compute_coordinates def _compute_paddings(data: DataArray, axis: str) -> tuple[int, int]: diff --git a/src/spatialdata/dataloader/datasets.py b/src/spatialdata/dataloader/datasets.py index b34ad5bf5..f10adfa50 100644 --- a/src/spatialdata/dataloader/datasets.py +++ b/src/spatialdata/dataloader/datasets.py @@ -30,11 +30,11 @@ Labels3DModel, PointsModel, TableModel, - get_axes_names, get_model, get_table_keys, ) from spatialdata.transformations import BaseTransformation, get_transformation, set_transformation +from spatialdata.transformations._graph.utils import get_axes_names __all__ = ["ImageTilesDataset"] diff --git a/src/spatialdata/models/__init__.py b/src/spatialdata/models/__init__.py index 2eb31b764..9ec9daf78 100644 --- a/src/spatialdata/models/__init__.py +++ b/src/spatialdata/models/__init__.py @@ -3,25 +3,13 @@ from spatialdata._core.validation import check_target_region_column_symmetry from spatialdata.models._utils import ( force_2d, - get_axes_names, get_channel_names, - get_spatial_axes, points_dask_dataframe_to_geopandas, points_geopandas_to_dask_dataframe, set_channel_names, - validate_axes, - validate_axis_name, ) from spatialdata.models.literals import ( ATTRS_KEY, - DEFAULT_COORDINATE_SYSTEM, - TRANSFORM_KEY, - C, - NgffAxisType, - X, - Y, - Z, - axis_type_mapping_ngff, ) from spatialdata.models.models import ( Image2DModel, @@ -36,7 +24,7 @@ get_table_keys, validate_element, ) -from spatialdata.models.types import MappingToCoordinateSystem_t, SpatialElement, ValidAxis_t +from spatialdata.models.types import MappingToCoordinateSystem_t, SpatialElement __all__ = [ "Labels2DModel", @@ -49,14 +37,6 @@ "TableModel", "get_model", "SpatialElement", - "get_spatial_axes", - "validate_axes", - "validate_axis_name", - "X", - "Y", - "Z", - "C", - "get_axes_names", "points_geopandas_to_dask_dataframe", "points_dask_dataframe_to_geopandas", "check_target_region_column_symmetry", @@ -65,11 +45,6 @@ "get_channel_names", "set_channel_names", "force_2d", - "NgffAxisType", - "axis_type_mapping_ngff", - "DEFAULT_COORDINATE_SYSTEM", - "TRANSFORM_KEY", - "ValidAxis_t", "ATTRS_KEY", "MappingToCoordinateSystem_t", ] diff --git a/src/spatialdata/models/_utils.py b/src/spatialdata/models/_utils.py index 6be65304b..c6b612643 100644 --- a/src/spatialdata/models/_utils.py +++ b/src/spatialdata/models/_utils.py @@ -10,21 +10,20 @@ import pandas as pd from dask.dataframe import DataFrame as DaskDataFrame from geopandas import GeoDataFrame -from shapely import get_coordinate_dimension from shapely.geometry import MultiPolygon, Point, Polygon from shapely.geometry.base import BaseGeometry from xarray import DataArray, DataTree from spatialdata._logging import logger from spatialdata._utils import _check_match_length_channels_c_dim -from spatialdata.models.literals import C, X, Y, Z -from spatialdata.models.types import MappingToCoordinateSystem_t, SpatialElement, ValidAxis_t +from spatialdata.transformations._graph import C, X, Y, Z from spatialdata.transformations.transformations import BaseTransformation if TYPE_CHECKING: from anndata import AnnData - from spatialdata.models import RasterSchema + from spatialdata.models.models import RasterSchema + from spatialdata.models.types import MappingToCoordinateSystem_t # mypy says that we can't do isinstance(something, SpatialElement), @@ -60,131 +59,6 @@ def _validate_mapping_to_coordinate_system_type(transformations: MappingToCoordi ) -def validate_axis_name(axis: ValidAxis_t) -> None: - """ - Check if the axis name is valid. - - Parameters - ---------- - axis - The axis name - - Raises - ------ - TypeError - If the axis name not in ["c", "x", "y", "z"] - """ - if axis not in ["c", "x", "y", "z"]: - raise TypeError(f"Invalid axis: {axis}") - - -def validate_axes(axes: tuple[ValidAxis_t, ...]) -> None: - """ - Check if the names of the axes are valid. - - Parameters - ---------- - axes - The names of the axes - - Raises - ------ - TypeError - If not in ["c", "x", "y", "z"] - """ - for ax in axes: - validate_axis_name(ax) - if len(axes) != len(set(axes)): - raise ValueError("Axes must be unique.") - - -def get_spatial_axes(axes: tuple[ValidAxis_t, ...]) -> tuple[ValidAxis_t, ...]: - """ - Get the spatial axes of interest. - - Parameters - ---------- - axes - Should be a subset of ['x', 'y', 'z', 'c'] - - Returns - ------- - The spatial axes, i.e. the input axes but without 'c' - """ - validate_axes(axes) - return tuple(ax for ax in axes if ax in [X, Y, Z]) - - -@singledispatch -def get_axes_names(e: SpatialElement | pd.DataFrame) -> tuple[str, ...]: - """ - Get the dimensions of a SpatialElement. - - Parameters - ---------- - e - SpatialElement - - Returns - ------- - Dimensions of the SpatialElement (e.g. ("z", "y", "x")) - """ - raise TypeError(f"Unsupported type: {type(e)}") - - -@get_axes_names.register(DataArray) -def _(e: DataArray) -> tuple[str, ...]: - dims = tuple(str(dim) for dim in e.dims) - _validate_dims(dims) - return dims - - -@get_axes_names.register(DataTree) -def _(e: DataTree) -> tuple[str, ...]: - if "scale0" in e: - # dims_coordinates = tuple(i for i in e["scale0"].dims.keys()) - - scale0 = e["scale0"] - if not isinstance(scale0, DataTree): - raise TypeError(f"Expected `scale0` to be a DataTree node, got {type(scale0).__name__}.") - variables = list(scale0.values()) - assert len(variables) == 1 - xdata = variables[0] - dims_data = tuple(str(dim) for dim in xdata.dims) - - _validate_dims(dims_data) - return dims_data - raise ValueError("Spatialdata DataTree does not contain the scale0 key") - - -@get_axes_names.register(GeoDataFrame) -def _(e: GeoDataFrame) -> tuple[str, ...]: - all_dims = (X, Y, Z) - n = get_coordinate_dimension(e.geometry.iloc[0]) - dims = all_dims[:n] - if Z not in dims and Z in e.columns: - dims += (Z,) - _validate_dims(dims) - return dims - - -@get_axes_names.register(DaskDataFrame) -@get_axes_names.register(pd.DataFrame) -def _(e: DaskDataFrame) -> tuple[str, ...]: - valid_dims = (X, Y, Z) - dims = tuple([c for c in valid_dims if c in e.columns]) - _validate_dims(dims) - return dims - - -def _validate_dims(dims: tuple[str, ...]) -> None: - for c in dims: - if c not in (X, Y, Z, C): - raise ValueError(f"Invalid dimension: {c}") - if dims not in [(X,), (Y,), (Z,), (C,), (X, Y), (X, Y, Z), (Y, X), (Z, Y, X), (C, Y, X), (C, Z, Y, X)]: - raise ValueError(f"Invalid dimensions: {dims}") - - def points_dask_dataframe_to_geopandas(points: DaskDataFrame, suppress_z_warning: bool = False) -> GeoDataFrame: """ Convert a Dask DataFrame to a GeoDataFrame. diff --git a/src/spatialdata/models/literals.py b/src/spatialdata/models/literals.py index 1c8c75284..742cfcab5 100644 --- a/src/spatialdata/models/literals.py +++ b/src/spatialdata/models/literals.py @@ -1,26 +1,3 @@ from __future__ import annotations -from enum import StrEnum - -TRANSFORM_KEY = "transform" -DEFAULT_COORDINATE_SYSTEM = "global" -C = "c" -Z = "z" -Y = "y" -X = "x" - - -class NgffAxisType(StrEnum): - """Enum defining NGFF axis types""" - - SPACE = "space" - CHANNEL = "channel" - - -axis_type_mapping_ngff = { - C: NgffAxisType.CHANNEL, - X: NgffAxisType.SPACE, - Y: NgffAxisType.SPACE, - Z: NgffAxisType.SPACE, -} ATTRS_KEY = "spatialdata_attrs" diff --git a/src/spatialdata/models/models.py b/src/spatialdata/models/models.py index 2ab90e6c6..ecd22e72b 100644 --- a/src/spatialdata/models/models.py +++ b/src/spatialdata/models/models.py @@ -29,19 +29,15 @@ from spatialdata._core.validation import validate_table_attr_keys from spatialdata._logging import logger -from spatialdata._types import ArrayLike from spatialdata._utils import _check_match_length_channels_c_dim from spatialdata.config import settings -from spatialdata.models import C, SpatialElement, X, Y, Z, get_axes_names -from spatialdata.models._utils import ( - _validate_mapping_to_coordinate_system_type, - convert_region_column_to_categorical, -) +from spatialdata.models._utils import _validate_mapping_to_coordinate_system_type, convert_region_column_to_categorical from spatialdata.models.chunks_utils import Chunks_t -from spatialdata.models.literals import ATTRS_KEY, DEFAULT_COORDINATE_SYSTEM, TRANSFORM_KEY +from spatialdata.models.literals import ATTRS_KEY from spatialdata.models.pyramids_utils import ScaleFactors_t # ozp -> ome-zarr-py from spatialdata.models.pyramids_utils import to_multiscale as to_multiscale_ozp -from spatialdata.models.types import MappingToCoordinateSystem_t +from spatialdata.transformations._graph import DEFAULT_COORDINATE_SYSTEM, TRANSFORM_KEY, C, X, Y, Z +from spatialdata.transformations._graph.utils import get_axes_names from spatialdata.transformations._utils import ( _get_transformations, _set_transformations, @@ -52,6 +48,9 @@ if TYPE_CHECKING: from pandas._typing import DtypeObj + from spatialdata._types import ArrayLike + from spatialdata.models.types import MappingToCoordinateSystem_t, SpatialElement + __all__ = ["Chunks_t", "ScaleFactors_t"] diff --git a/src/spatialdata/models/types.py b/src/spatialdata/models/types.py index 84c62dc8d..62397041b 100644 --- a/src/spatialdata/models/types.py +++ b/src/spatialdata/models/types.py @@ -2,14 +2,13 @@ from typing import TYPE_CHECKING +from spatialdata.transformations import BaseTransformation + if TYPE_CHECKING: from dask.dataframe import DataFrame as DaskDataFrame from geopandas import GeoDataFrame from xarray import DataArray, DataTree - from spatialdata.transformations import BaseTransformation - type SpatialElement = DataArray | DataTree | GeoDataFrame | DaskDataFrame -ValidAxis_t = str MappingToCoordinateSystem_t = dict[str, BaseTransformation] diff --git a/src/spatialdata/transformations/_graph/__init__.py b/src/spatialdata/transformations/_graph/__init__.py index 6cb92b9d9..ee8973f59 100644 --- a/src/spatialdata/transformations/_graph/__init__.py +++ b/src/spatialdata/transformations/_graph/__init__.py @@ -24,6 +24,7 @@ parse_translation, ) from spatialdata.transformations._graph.exceptions import ( + AxisParsingException, AxisRedefinitionError, CannotRemoveCoordinateSystemError, CoordinateSystemAlreadyExistsError, @@ -47,8 +48,17 @@ UnmappedAxisError, ) from spatialdata.transformations._graph.graph import TransformationGraph -from spatialdata.transformations._graph.literals import TRANSFORM_KEY -from spatialdata.transformations._graph.utils import get_default_coordinate_system +from spatialdata.transformations._graph.literals import ( + DEFAULT_COORDINATE_SYSTEM, + TRANSFORM_KEY, + C, + NgffAxisType, + X, + Y, + Z, + axis_type_mapping_ngff, +) +from spatialdata.transformations._graph.utils import get_instrinsic_coordinate_system from spatialdata.transformations._graph.vert import Axis, CoordSystem __all__ = [ @@ -75,6 +85,7 @@ "parse_sequence", "parse_translation", # from exceptions + "AxisParsingException", "AxisRedefinitionError", "CannotRemoveCoordinateSystemError", "CoordinateSystemAlreadyExistsError", @@ -99,9 +110,16 @@ # from graph "TransformationGraph", # from literals + "DEFAULT_COORDINATE_SYSTEM", "TRANSFORM_KEY", + "X", + "Y", + "Z", + "C", + "NgffAxisType", + "axis_type_mapping_ngff", # from utils - "get_default_coordinate_system", + "get_instrinsic_coordinate_system", # from vert "Axis", "CoordSystem", diff --git a/src/spatialdata/transformations/_graph/edge.py b/src/spatialdata/transformations/_graph/edge.py index b249cfa42..a5cedae36 100644 --- a/src/spatialdata/transformations/_graph/edge.py +++ b/src/spatialdata/transformations/_graph/edge.py @@ -11,7 +11,6 @@ import ome_zarr_models.v06.coordinate_transforms as ozm06trans from spatialdata._types import ArrayLike -from spatialdata.models import NgffAxisType from spatialdata.transformations._graph.exceptions import ( AxisRedefinitionError, DeterminantDifferentFromOne, @@ -22,6 +21,7 @@ UnexpectedShapeError, UnmappedAxisError, ) +from spatialdata.transformations._graph.literals import NgffAxisType from spatialdata.transformations._graph.vert import Axis, CoordSystem @@ -298,8 +298,6 @@ def __init__( """ if input.num_axes != output.num_axes: raise IncompatibleCoordSystemsError( - input=input, - output=output, axes_error_message="Axes must have the same number of dimensions", transformation_name=self.__class__.__name__, ) @@ -369,9 +367,7 @@ def __init__( """ if input.num_axes != output.num_axes: - raise IncompatibleCoordSystemsError( - input=input, output=output, axes_error_message="Input and output must have the same number of axes" - ) + raise IncompatibleCoordSystemsError(axes_error_message="Input and output must have the same number of axes") unmapped_inputs = set(input.axes) unmapped_outputs = set(output.axes) @@ -379,16 +375,16 @@ def __init__( try: unmapped_inputs.remove(inp_ax) except KeyError as e: - raise MissingAxisError(axis=inp_ax, cs=self.input) from e + raise MissingAxisError(axis_name=inp_ax.name, cs_name=self.input.name) from e try: unmapped_outputs.remove(out_ax) except KeyError as e: - raise MissingAxisError(axis=out_ax, cs=self.output) from e + raise MissingAxisError(axis_name=out_ax.name, cs_name=self.output.name) from e for ax in unmapped_inputs: - raise UnmappedAxisError(axis=ax, cs=self.input) + raise UnmappedAxisError(axis_name=ax.name, cs_name=self.input.name) for ax in unmapped_outputs: - raise UnmappedAxisError(axis=ax, cs=self.output) + raise UnmappedAxisError(axis_name=ax.name, cs_name=self.output.name) self.input_to_output = input_to_output super().__init__(input=input, output=output, name=name) @@ -464,10 +460,10 @@ def __init__( for axis in dropped_inputs: if axis not in input.axes: - raise MissingAxisError(axis=axis, cs=input) + raise MissingAxisError(axis_name=axis.name, cs_name=input.name) for axis in created_outputs: if axis not in output.axes: - raise MissingAxisError(axis=axis, cs=output) + raise MissingAxisError(axis_name=axis.name, cs_name=output.name) if input.num_axes - len(dropped_inputs) + len(created_outputs) != output.num_axes: message = f"Can't map from {input} to {output}" if dropped_inputs: @@ -475,8 +471,6 @@ def __init__( if created_outputs: message += f" creating {created_outputs}" raise IncompatibleCoordSystemsError( - input=input, - output=output, axes_error_message=message, ) @@ -485,6 +479,7 @@ def __init__( super().__init__(name=name, input=input, output=output) def to_affine(self, name: str | None = None) -> AffineEdge: + linear = np.zeros((self.output.num_axes, self.input.num_axes), dtype=float) input_indices = iter(range(self.input.num_axes)) for out_idx, out_ax in enumerate(self.output.axes): @@ -556,9 +551,7 @@ def __init__( if `translation`'s shape doesn't match `input` """ if input.num_axes != output.num_axes: - raise IncompatibleCoordSystemsError( - input=input, output=output, axes_error_message="Number of input and output axes must be the same" - ) + raise IncompatibleCoordSystemsError(axes_error_message="Number of input and output axes must be the same") expected_translation_shape = (input.num_axes,) if translation.shape != expected_translation_shape: raise UnexpectedShapeError( @@ -635,7 +628,7 @@ def __init__( raise UnexpectedShapeError(array_name="scale", array_shape=scale.shape, expected_shape=expected_scale_shape) if input.num_axes != output.num_axes: raise IncompatibleCoordSystemsError( - input=input, output=output, axes_error_message="input and output must have same number of dimensions" + axes_error_message="input and output must have same number of dimensions" ) self.scale = scale super().__init__(input=input, output=output, name=name) @@ -710,7 +703,7 @@ def __init__( """ if input.num_axes != output.num_axes: raise IncompatibleCoordSystemsError( - input=input, output=output, axes_error_message="input and output should have the same numbe rof axes" + axes_error_message="input and output should have the same number of axes" ) expected_shape = (output.num_axes, input.num_axes) if linear_matrix.shape != expected_shape: @@ -788,8 +781,6 @@ def __init__( for transf_idx, current_transf in enumerate(transformations[1:], start=1): if previous_transf.output != current_transf.input: raise IncompatibleCoordSystemsError( - input=current_transf.input, - output=previous_transf.output, axes_error_message=( f"Output of transformation #{transf_idx - 1} is different " f"from input of transformation #{transf_idx}" @@ -886,16 +877,16 @@ def __init__( for t in transformations: for ax in t.input.axes: if ax not in input.axes: - raise MissingAxisError(axis=ax, cs=input) + raise MissingAxisError(axis_name=ax.name, cs_name=input.name) for ax in t.output.axes: if ax not in output.axes: - raise MissingAxisError(axis=ax, cs=output) + raise MissingAxisError(axis_name=ax.name, cs_name=output.name) if ax.name in defined_output_axes: - raise AxisRedefinitionError(axis=ax) + raise AxisRedefinitionError(axis_name=ax.name) defined_output_axes.add(ax.name) for ax in output.axes: if ax.name not in defined_output_axes: - raise UnmappedAxisError(axis=ax, cs=output) + raise UnmappedAxisError(axis_name=ax.name, cs_name=output.name) self.transformations = tuple(transformations) super().__init__(input=input, output=output, name=name) diff --git a/src/spatialdata/transformations/_graph/exceptions.py b/src/spatialdata/transformations/_graph/exceptions.py index f4bdc280f..86f46efee 100644 --- a/src/spatialdata/transformations/_graph/exceptions.py +++ b/src/spatialdata/transformations/_graph/exceptions.py @@ -1,9 +1,10 @@ from __future__ import annotations -from typing import override +from typing import TYPE_CHECKING, override -from spatialdata._types import ArrayLike -from spatialdata.transformations._graph.vert import Axis, CoordSystem +if TYPE_CHECKING: + from spatialdata._types import ArrayLike + from spatialdata.transformations import BaseTransformation class CoordinateSystemNotFoundError(ValueError): @@ -96,10 +97,11 @@ class InvalidPathError(ValueError): Attributes ---------- - invalid_path : list[CoordSystem] + invalid_path : + list of coordinate system names """ - def __init__(self, invalid_path: list[CoordSystem]) -> None: + def __init__(self, invalid_path: list[str]) -> None: super().__init__(f"Found an invalid path with less than 2 nodes: {invalid_path}") @@ -170,14 +172,17 @@ class TransformationPathNotSimple(ValueError): Exception raised when a path represented by a list of coordinate systems is not simple. A simple path is one in which each coordinate system appears only once + + Attributes + ---------- + path: + list of coordinate system names """ - def __init__(self, path: list[CoordSystem]) -> None: - self.path: list[CoordSystem] = path - css_formatted_one_per_line = "\n".join(repr(cs) for cs in path) + def __init__(self, path: list[str]) -> None: + self.path = path super().__init__( - "Transformation Path not simple, i.e., some coordinate systems appear multiple times:\n" - + f"{css_formatted_one_per_line}" + "Transformation Path not simple, i.e., some coordinate systems appear multiple times:\n" + f"{path}" ) @@ -237,13 +242,9 @@ class TransformationManagerWarning(UserWarning): class IncompatibleCoordSystemsError(Exception): def __init__( self, - input: CoordSystem, - output: CoordSystem, transformation_name: str | None = None, axes_error_message: str | None = None, ) -> None: - self.input = input - self.output = output self.transformation_name = transformation_name axes_message = axes_error_message or "Output axes can't be mapped to input axes" axes_transformation_message = ( @@ -253,10 +254,10 @@ def __init__( class MissingAxisError(Exception): - def __init__(self, axis: Axis, cs: CoordSystem) -> None: - self.axis = axis - self.cs = cs - super().__init__(f"Axis {axis.name} is not in coordinate system {cs.name}") + def __init__(self, axis_name: str, cs_name: str) -> None: + self.axis_name = axis_name + self.cs_name = cs_name + super().__init__(f"Axis {axis_name} is not in coordinate system {cs_name}") class UnexpectedShapeError(Exception): @@ -296,15 +297,16 @@ def __init__(self) -> None: class AxisRedefinitionError(Exception): - def __init__(self, axis: Axis) -> None: - super().__init__(f"Axis {axis.name} is defined multiple times") + def __init__(self, axis_name: str) -> None: + self.axis_name = axis_name + super().__init__(f"Axis {axis_name} is defined multiple times") class UnmappedAxisError(Exception): - def __init__(self, axis: Axis, cs: CoordSystem) -> None: - self.axis = axis - self.cs = cs - super().__init__(f"Axis {axis.name} from coordinate system {cs.name} is not mapped to anything") + def __init__(self, axis_name: str, cs_name: str) -> None: + self.axis_name = axis_name + self.cs_name = cs_name + super().__init__(f"Axis {axis_name} from coordinate system {cs_name} is not mapped to anything") class AxisParsingException(Exception): @@ -324,3 +326,23 @@ class NGFFCompatibilityError(Exception): def __init__(self, msg: str): self.msg = msg super().__init__(msg) + + +class IncompatibleTransformationOutputCoordinateSystemError(Exception): + """ + Exception raised the expected output coordinate system is incompatible with an input coordinate system and a transformation edge. + """ + + def __init__( + self, input_coordinate_system_name: str, transformation: BaseTransformation, output_coordinate_system_name: str + ) -> None: + + self.input_coordinate_system_name = input_coordinate_system_name + self.output_coordinate_system_name = output_coordinate_system_name + self.transformation = transformation + super().__init__( + f"Transformation incompatible with input/output coordinate systems: " + f"\nInput coordinate system name: {input_coordinate_system_name}" + f"\nOutput coordinate system name: {output_coordinate_system_name}" + f"\nTransformation: {transformation}" + ) diff --git a/src/spatialdata/transformations/_graph/graph.py b/src/spatialdata/transformations/_graph/graph.py index 543e8c828..385006b58 100644 --- a/src/spatialdata/transformations/_graph/graph.py +++ b/src/spatialdata/transformations/_graph/graph.py @@ -194,6 +194,22 @@ def add_coordinate_system(self, cs: CoordSystem) -> None: self.assert_coordinate_system_does_not_exist(cs) self._graph.add_node(cs) + def get_coordinate_system(self, cs_name: str) -> CoordSystem | None: + """ + Search for a coordinate system by name, return it if found, else return None + + Parameters + ---------- + cs_name + Name of a coordinate system + """ + + for cs in self.list_coordinate_systems(): + if cs.name == cs_name: + return cs + + return None + def remove_coordinate_system(self, cs: CoordSystem) -> None: """ Remove a coordinate system from the transformation manager. @@ -479,7 +495,7 @@ def _get_transformation_sequences_from_simple_paths_after_disambiguation( """ for path in paths: if len(set(path)) != len(path): - raise TransformationPathNotSimple(path) + raise TransformationPathNotSimple([x.name for x in path]) expected_intermediate_transformation_edge_keys = set[str]() if expected_intermediate_edges is not None: @@ -491,7 +507,7 @@ def _get_transformation_sequences_from_simple_paths_after_disambiguation( deduplicated_paths = list({repr(x): x for x in paths}.values()) for path in deduplicated_paths: if len(path) <= 1: - raise InvalidPathError(path) + raise InvalidPathError([x.name for x in path]) transformation_list: list[BaseTransformationEdge] = [] for i in range(len(path) - 1): diff --git a/src/spatialdata/transformations/_graph/literals.py b/src/spatialdata/transformations/_graph/literals.py index df67d2d2a..05bacdbb6 100644 --- a/src/spatialdata/transformations/_graph/literals.py +++ b/src/spatialdata/transformations/_graph/literals.py @@ -1,4 +1,26 @@ from __future__ import annotations -TRANSFORM_KEY = "transformation" +from enum import StrEnum + """Key used to store transformation data in graph edge attributes.""" +DEFAULT_COORDINATE_SYSTEM = "global" +C = "c" +Z = "z" +Y = "y" +X = "x" + + +class NgffAxisType(StrEnum): + """Enum defining NGFF axis types""" + + CHANNEL = "channel" + SPACE = "space" + + +axis_type_mapping_ngff = { + C: NgffAxisType.CHANNEL, + X: NgffAxisType.SPACE, + Y: NgffAxisType.SPACE, + Z: NgffAxisType.SPACE, +} +TRANSFORM_KEY = "transform" diff --git a/src/spatialdata/transformations/_graph/types.py b/src/spatialdata/transformations/_graph/types.py new file mode 100644 index 000000000..03f20636e --- /dev/null +++ b/src/spatialdata/transformations/_graph/types.py @@ -0,0 +1,3 @@ +from __future__ import annotations + +ValidAxis_t = str diff --git a/src/spatialdata/transformations/_graph/utils.py b/src/spatialdata/transformations/_graph/utils.py index 2e77e8b12..7a92e5cd3 100644 --- a/src/spatialdata/transformations/_graph/utils.py +++ b/src/spatialdata/transformations/_graph/utils.py @@ -1,14 +1,32 @@ from __future__ import annotations -from spatialdata.models import SpatialElement, get_axes_names +from functools import singledispatch +from typing import TYPE_CHECKING + +import pandas as pd +from dask.dataframe import DataFrame as DaskDataFrame +from geopandas import GeoDataFrame +from shapely import get_coordinate_dimension +from xarray import DataArray, DataTree + +from spatialdata.transformations._graph import C, X, Y, Z +from spatialdata.transformations._graph.types import ValidAxis_t from spatialdata.transformations._graph.vert import Axis, CoordSystem +if TYPE_CHECKING: + from spatialdata.models import SpatialElement + + +def get_intrinsic_coordinate_system_name(element_key: str) -> str: -def get_default_coordinate_system( + return f"{element_key}_intrinsic" + + +def get_instrinsic_coordinate_system( element_to_be_added_to_graph: SpatialElement, coordinate_system_name: str ) -> CoordSystem: """ - Create a default coordinate system object for a spatial element, which can be added to a graph. + Create a coordinate system object from a spatial element, which can be added to a graph. Parameters ---------- @@ -26,3 +44,128 @@ def get_default_coordinate_system( ngff_axis = Axis.from_spatialdata_axis_name(axis_name) ngff_axes.append(ngff_axis) return CoordSystem(name=coordinate_system_name, axes=tuple(ngff_axes), virtual=False) + + +def validate_axis_name(axis: ValidAxis_t) -> None: + """ + Check if the axis name is valid. + + Parameters + ---------- + axis + The axis name + + Raises + ------ + TypeError + If the axis name not in ["c", "x", "y", "z"] + """ + if axis not in ["c", "x", "y", "z"]: + raise TypeError(f"Invalid axis: {axis}") + + +def validate_axes(axes: tuple[ValidAxis_t, ...]) -> None: + """ + Check if the names of the axes are valid. + + Parameters + ---------- + axis + The names of the axes + + Raises + ------ + TypeError + If not in ["c", "x", "y", "z"] + """ + for ax in axes: + validate_axis_name(ax) + if len(axes) != len(set(axes)): + raise ValueError("Axes must be unique.") + + +def get_spatial_axes(axes: tuple[ValidAxis_t, ...]) -> tuple[ValidAxis_t, ...]: + """ + Get the spatial axes of interest. + + Parameters + ---------- + axes + Should be a subset of ['x', 'y', 'z', 'c'] + + Returns + ------- + The spatial axes, i.e. the input axes but without 'c' + """ + validate_axes(axes) + return tuple(ax for ax in axes if ax in [X, Y, Z]) + + +@singledispatch +def get_axes_names(e: SpatialElement | pd.DataFrame) -> tuple[str, ...]: + """ + Get the dimensions of a SpatialElement. + + Parameters + ---------- + e + SpatialElement + + Returns + ------- + Dimensions of the SpatialElement (e.g. ("z", "y", "x")) + """ + raise TypeError(f"Unsupported type: {type(e)}") + + +@get_axes_names.register(DataTree) +def _(e: DataTree) -> tuple[str, ...]: + if "scale0" in e: + # dims_coordinates = tuple(i for i in e["scale0"].dims.keys()) + + scale0 = e["scale0"] + if not isinstance(scale0, DataTree): + raise TypeError(f"Expected `scale0` to be a DataTree node, got {type(scale0).__name__}.") + variables = list(scale0.values()) + assert len(variables) == 1 + xdata = variables[0] + dims_data = tuple(str(dim) for dim in xdata.dims) + + _validate_dims(dims_data) + return dims_data + raise ValueError("Spatialdata DataTree does not contain the scale0 key") + + +@get_axes_names.register(GeoDataFrame) +def _(e: GeoDataFrame) -> tuple[str, ...]: + all_dims = (X, Y, Z) + n = get_coordinate_dimension(e.geometry.iloc[0]) + dims = all_dims[:n] + if Z not in dims and Z in e.columns: + dims += (Z,) + _validate_dims(dims) + return dims + + +@get_axes_names.register(DaskDataFrame) +@get_axes_names.register(pd.DataFrame) +def _(e: DaskDataFrame | pd.DataFrame) -> tuple[str, ...]: + valid_dims = (X, Y, Z) + dims = tuple([c for c in valid_dims if c in e.columns]) + _validate_dims(dims) + return dims + + +@get_axes_names.register(DataArray) +def _(e: DataArray) -> tuple[str, ...]: + dims = tuple(str(dim) for dim in e.dims) + _validate_dims(dims) + return dims + + +def _validate_dims(dims: tuple[str, ...]) -> None: + for c in dims: + if c not in (X, Y, Z, C): + raise ValueError(f"Invalid dimension: {c}") + if dims not in [(X,), (Y,), (Z,), (C,), (X, Y), (X, Y, Z), (Y, X), (Z, Y, X), (C, Y, X), (C, Z, Y, X)]: + raise ValueError(f"Invalid dimensions: {dims}") diff --git a/src/spatialdata/transformations/_graph/vert.py b/src/spatialdata/transformations/_graph/vert.py index d20c5fbe8..15652f38d 100644 --- a/src/spatialdata/transformations/_graph/vert.py +++ b/src/spatialdata/transformations/_graph/vert.py @@ -6,8 +6,8 @@ import ome_zarr_models.v06.coordinate_transforms as ozm06ct -from spatialdata.models import NgffAxisType, axis_type_mapping_ngff from spatialdata.transformations._graph.exceptions import AxisParsingException +from spatialdata.transformations._graph.literals import NgffAxisType, axis_type_mapping_ngff @dataclass(frozen=True) diff --git a/src/spatialdata/transformations/_utils.py b/src/spatialdata/transformations/_utils.py index a9f9c7b53..7a64f86c4 100644 --- a/src/spatialdata/transformations/_utils.py +++ b/src/spatialdata/transformations/_utils.py @@ -10,10 +10,11 @@ from xarray import DataArray, Dataset, DataTree from spatialdata._types import ArrayLike +from spatialdata.transformations._graph import TRANSFORM_KEY if TYPE_CHECKING: from spatialdata._core.spatialdata import SpatialData - from spatialdata.models import TRANSFORM_KEY, MappingToCoordinateSystem_t, SpatialElement + from spatialdata.models import MappingToCoordinateSystem_t, SpatialElement from spatialdata.transformations.transformations import Affine, BaseTransformation, Scale @@ -80,7 +81,7 @@ def _(e: DataArray, transformations: MappingToCoordinateSystem_t) -> None: @_set_transformations.register(DataTree) def _(e: DataTree, transformations: MappingToCoordinateSystem_t) -> None: - from spatialdata.models import get_axes_names + from spatialdata.transformations._graph.utils import get_axes_names # set the transformation at the highest level and concatenate with the appropriate scale at each level dims = get_axes_names(e) @@ -118,7 +119,7 @@ def _(e: DataTree, transformations: MappingToCoordinateSystem_t) -> None: @_set_transformations.register(GeoDataFrame) @_set_transformations.register(DaskDataFrame) -def _(e: GeoDataFrame | GeoDataFrame, transformations: MappingToCoordinateSystem_t) -> None: +def _(e: GeoDataFrame | DaskDataFrame, transformations: MappingToCoordinateSystem_t) -> None: _set_transformations_to_element(e, transformations) # _set_transformations_to_dict_container(e.attrs, transformations) @@ -207,7 +208,7 @@ def _(data: DataArray) -> DataArray: @compute_coordinates.register(DataTree) def _(data: DataTree) -> DataTree: - from spatialdata.models import get_axes_names + from spatialdata.transformations._graph.utils import get_axes_names spatial_coords = [ax for ax in get_axes_names(data) if ax in ["x", "y", "z"]] img_name = list(data["scale0"].data_vars.keys())[0] diff --git a/src/spatialdata/transformations/ngff/_utils.py b/src/spatialdata/transformations/ngff/_utils.py index 07dff152c..34210c147 100644 --- a/src/spatialdata/transformations/ngff/_utils.py +++ b/src/spatialdata/transformations/ngff/_utils.py @@ -2,7 +2,7 @@ import copy -from spatialdata.models import C, X, Y, Z +from spatialdata.transformations._graph import C, X, Y, Z from spatialdata.transformations.ngff.ngff_coordinate_system import NgffAxis, NgffCoordinateSystem __all__ = "get_default_coordinate_system" diff --git a/src/spatialdata/transformations/ngff/ngff_transformations.py b/src/spatialdata/transformations/ngff/ngff_transformations.py index 13f564247..ea4507236 100644 --- a/src/spatialdata/transformations/ngff/ngff_transformations.py +++ b/src/spatialdata/transformations/ngff/ngff_transformations.py @@ -8,7 +8,7 @@ import numpy as np from spatialdata._types import ArrayLike -from spatialdata.models import C, X, Y, Z +from spatialdata.transformations._graph import C, X, Y, Z from spatialdata.transformations.ngff.ngff_coordinate_system import NgffCoordinateSystem __all__ = [ diff --git a/src/spatialdata/transformations/operations.py b/src/spatialdata/transformations/operations.py index bcbed90cf..2ea43df8f 100644 --- a/src/spatialdata/transformations/operations.py +++ b/src/spatialdata/transformations/operations.py @@ -1,14 +1,14 @@ from __future__ import annotations import contextlib -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Literal, overload import numpy as np from dask.dataframe import DataFrame as DaskDataFrame from geopandas import GeoDataFrame from spatialdata._logging import logger -from spatialdata.models import DEFAULT_COORDINATE_SYSTEM +from spatialdata.transformations._graph.literals import DEFAULT_COORDINATE_SYSTEM from spatialdata.transformations._utils import _get_transformations, _set_transformations if TYPE_CHECKING: @@ -88,6 +88,19 @@ def set_transformation( write_to_sdata.write_transformations(element_name=element_name) +# these signatures help the static type checker understand the correct return type based on the inputs +@overload +def get_transformation( + element: SpatialElement, to_coordinate_system: str | None = None, get_all: Literal[False] = False +) -> BaseTransformation: ... + + +@overload +def get_transformation( + element: SpatialElement, to_coordinate_system: None = None, get_all: Literal[True] = True +) -> dict[str, BaseTransformation]: ... + + def get_transformation( element: SpatialElement, to_coordinate_system: str | None = None, get_all: bool = False ) -> BaseTransformation | dict[str, BaseTransformation]: @@ -379,7 +392,7 @@ def get_transformation_between_landmarks( from skimage.transform import estimate_transform from spatialdata import transform - from spatialdata.models import get_axes_names + from spatialdata.transformations._graph.utils import get_axes_names from spatialdata.transformations.transformations import Affine, Sequence assert get_axes_names(references_coords) == ("x", "y") diff --git a/src/spatialdata/transformations/transformations.py b/src/spatialdata/transformations/transformations.py index 4314d79a2..56c5b191d 100644 --- a/src/spatialdata/transformations/transformations.py +++ b/src/spatialdata/transformations/transformations.py @@ -39,7 +39,9 @@ if TYPE_CHECKING: from spatialdata._types import ArrayLike, ListOrNDArrayFloating - from spatialdata.models import DEFAULT_COORDINATE_SYSTEM, SpatialElement, ValidAxis_t + from spatialdata.models import SpatialElement + from spatialdata.transformations._graph.literals import DEFAULT_COORDINATE_SYSTEM + from spatialdata.transformations._graph.types import ValidAxis_t TRANSFORMATIONS_MAP: dict[type[NgffBaseTransformation], type[BaseTransformation]] = {} @@ -54,7 +56,7 @@ def validate_axes(axes: tuple[ValidAxis_t, ...]) -> None: This function is to allow to call validate_axes() from this file in multiple places while avoiding circular imports. """ - from spatialdata.models import validate_axes + from spatialdata.transformations._graph.utils import validate_axes validate_axes(axes) @@ -370,7 +372,7 @@ class MapAxis(BaseTransformation): def __init__(self, map_axis: dict[ValidAxis_t, ValidAxis_t]) -> None: # to avoid circular imports - from spatialdata.models import validate_axis_name + from spatialdata.transformations._graph.utils import validate_axis_name assert isinstance(map_axis, dict) for des_ax, src_ax in map_axis.items(): @@ -1229,7 +1231,7 @@ def __eq__(self, other: Any) -> bool: def _get_affine_for_element(element: SpatialElement, transformation: BaseTransformation) -> Affine: - from spatialdata.models import get_axes_names + from spatialdata.transformations._graph.utils import get_axes_names input_axes = get_axes_names(element) output_axes = transformation._get_resulting_output_axes(input_axes) diff --git a/tests/core/operations/test_rasterize.py b/tests/core/operations/test_rasterize.py index 53d362d15..91116f843 100644 --- a/tests/core/operations/test_rasterize.py +++ b/tests/core/operations/test_rasterize.py @@ -16,9 +16,9 @@ from spatialdata._core.operations.rasterize import rasterize from spatialdata._core.query.relational_query import get_element_instances from spatialdata._utils import get_pyramid_levels -from spatialdata.models import PointsModel, ShapesModel, TableModel, get_axes_names -from spatialdata.models._utils import get_spatial_axes +from spatialdata.models import PointsModel, ShapesModel, TableModel from spatialdata.transformations import MapAxis +from spatialdata.transformations._graph.utils import get_axes_names, get_spatial_axes from tests.conftest import _get_images, _get_labels diff --git a/tests/core/operations/test_transform.py b/tests/core/operations/test_transform.py index ef307ac7b..cf95cfbe6 100644 --- a/tests/core/operations/test_transform.py +++ b/tests/core/operations/test_transform.py @@ -16,7 +16,8 @@ from spatialdata._core.data_extent import are_extents_equal, get_extent from spatialdata._core.spatialdata import SpatialData from spatialdata._utils import disable_dask_tune_optimization, unpad_raster -from spatialdata.models import Image2DModel, PointsModel, ShapesModel, get_axes_names +from spatialdata.models import Image2DModel, PointsModel, ShapesModel +from spatialdata.transformations._graph.utils import get_axes_names from spatialdata.transformations.operations import ( align_elements_using_landmarks, get_transformation, diff --git a/tests/core/test_centroids.py b/tests/core/test_centroids.py index f8c8be1da..d80a9a0f4 100644 --- a/tests/core/test_centroids.py +++ b/tests/core/test_centroids.py @@ -10,8 +10,9 @@ from spatialdata._core.centroids import get_centroids from spatialdata._core.query.relational_query import get_element_instances -from spatialdata.models import Labels2DModel, Labels3DModel, PointsModel, TableModel, get_axes_names +from spatialdata.models import Labels2DModel, Labels3DModel, PointsModel, TableModel from spatialdata.transformations import Affine, Identity, get_transformation, set_transformation +from spatialdata.transformations._graph.utils import get_axes_names RNG = default_rng(42) diff --git a/tests/models/test_models.py b/tests/models/test_models.py index 151908b37..88e80871d 100644 --- a/tests/models/test_models.py +++ b/tests/models/test_models.py @@ -40,7 +40,6 @@ RasterSchema, ShapesModel, TableModel, - get_axes_names, get_model, get_table_keys, ) @@ -48,9 +47,9 @@ force_2d, points_dask_dataframe_to_geopandas, points_geopandas_to_dask_dataframe, - validate_axis_name, ) from spatialdata.testing import assert_elements_are_identical +from spatialdata.transformations._graph.utils import get_axes_names, validate_axis_name from spatialdata.transformations._utils import _set_transformations from spatialdata.transformations.operations import get_transformation, set_transformation from spatialdata.transformations.transformations import Identity, Scale diff --git a/tests/transformations/ngff/test_ngff_transformations.py b/tests/transformations/ngff/test_ngff_transformations.py index 7051f3646..e2a0fa4ec 100644 --- a/tests/transformations/ngff/test_ngff_transformations.py +++ b/tests/transformations/ngff/test_ngff_transformations.py @@ -8,7 +8,7 @@ import pytest from spatialdata._types import ArrayLike -from spatialdata.models import C, X, Y, Z +from spatialdata.transformations._graph import C, X, Y, Z from spatialdata.transformations.ngff._utils import get_default_coordinate_system from spatialdata.transformations.ngff.ngff_coordinate_system import NgffCoordinateSystem from spatialdata.transformations.ngff.ngff_transformations import ( diff --git a/tests/transformations/test_transformations.py b/tests/transformations/test_transformations.py index 435f5d011..e6b0ac3e8 100644 --- a/tests/transformations/test_transformations.py +++ b/tests/transformations/test_transformations.py @@ -10,7 +10,9 @@ from spatialdata import transform from spatialdata.datasets import blobs -from spatialdata.models import DEFAULT_COORDINATE_SYSTEM, Image2DModel, PointsModel, ValidAxis_t, get_channel_names +from spatialdata.models import Image2DModel, PointsModel, get_channel_names +from spatialdata.transformations._graph.literals import DEFAULT_COORDINATE_SYSTEM +from spatialdata.transformations._graph.types import ValidAxis_t from spatialdata.transformations.ngff._utils import get_default_coordinate_system from spatialdata.transformations.ngff.ngff_coordinate_system import NgffCoordinateSystem from spatialdata.transformations.ngff.ngff_transformations import ( From 345146216ed43b37a18ebdb0f21da45f42251c51 Mon Sep 17 00:00:00 2001 From: Ajayrama Kumaraswamy Date: Wed, 30 Sep 2026 11:57:20 +0200 Subject: [PATCH 41/54] feat(transformations): get_union_of_axes + tests --- .../transformations/_graph/__init__.py | 3 +- .../transformations/_graph/utils.py | 46 +++++ tests/transformations/utils/conftest.py | 91 ++++++++++ .../utils/test_get_union_of_axes.py | 164 ++++++++++++++++++ 4 files changed, 303 insertions(+), 1 deletion(-) create mode 100644 tests/transformations/utils/conftest.py create mode 100644 tests/transformations/utils/test_get_union_of_axes.py diff --git a/src/spatialdata/transformations/_graph/__init__.py b/src/spatialdata/transformations/_graph/__init__.py index ee8973f59..818239aa5 100644 --- a/src/spatialdata/transformations/_graph/__init__.py +++ b/src/spatialdata/transformations/_graph/__init__.py @@ -58,7 +58,7 @@ Z, axis_type_mapping_ngff, ) -from spatialdata.transformations._graph.utils import get_instrinsic_coordinate_system +from spatialdata.transformations._graph.utils import get_instrinsic_coordinate_system, get_union_of_axes from spatialdata.transformations._graph.vert import Axis, CoordSystem __all__ = [ @@ -120,6 +120,7 @@ "axis_type_mapping_ngff", # from utils "get_instrinsic_coordinate_system", + "get_union_of_axes", # from vert "Axis", "CoordSystem", diff --git a/src/spatialdata/transformations/_graph/utils.py b/src/spatialdata/transformations/_graph/utils.py index 7a92e5cd3..622e8280c 100644 --- a/src/spatialdata/transformations/_graph/utils.py +++ b/src/spatialdata/transformations/_graph/utils.py @@ -169,3 +169,49 @@ def _validate_dims(dims: tuple[str, ...]) -> None: raise ValueError(f"Invalid dimension: {c}") if dims not in [(X,), (Y,), (Z,), (C,), (X, Y), (X, Y, Z), (Y, X), (Z, Y, X), (C, Y, X), (C, Z, Y, X)]: raise ValueError(f"Invalid dimensions: {dims}") + + +def get_union_of_axes(axes: tuple[Axis, ...]) -> tuple[Axis, ...]: + """ + Produce a union of ``Axis`` objects, dropping duplicates and ordering reverse alphabetically. + + The ordering favors non‑None values for ``unit`` and ``long_name``. + Axes that share the same ``name`` **and** ``type`` are considered duplicates; + only the first occurrence (according to the sorted order) is retained. + """ + # ------------------------------------------------------------------ + # Build a DataFrame that holds the original Axis objects together with + # their attributes and auxiliary “non‑null” flags. + # ------------------------------------------------------------------ + df = pd.DataFrame( + { + "axis": pd.Series(axes, dtype="object"), # original Axis objects + "name": pd.Series([ax.name for ax in axes], dtype="string"), + "type": pd.Series([ax.type for ax in axes], dtype="object"), + "unit": pd.Series([ax.unit for ax in axes], dtype="string"), + "long_name": pd.Series([ax.long_name for ax in axes], dtype="string"), + } + ) + + # Boolean columns that indicate the presence of a value. + df["unit_not_none"] = df["unit"].notna() + df["long_name_not_none"] = df["long_name"].notna() + + # Sort axes in descending (alphabetical) order + df = df.sort_values( + by=[ + "name", + "type", + "unit_not_none", + "unit", + "long_name_not_none", + "long_name", + ], + ascending=False, + kind="mergesort", # stable sort to preserve order of equal keys + ) + + # Remove duplicates based on the logical key (name, type). + df = df.drop_duplicates(subset=["name", "type"], keep="first") + + return tuple(df["axis"]) diff --git a/tests/transformations/utils/conftest.py b/tests/transformations/utils/conftest.py new file mode 100644 index 000000000..a8d6d5b02 --- /dev/null +++ b/tests/transformations/utils/conftest.py @@ -0,0 +1,91 @@ +""" +Global pytest fixtures that provide ready‑made ``Axis`` instances. +These fixtures are deliberately small – any test can import the one it needs +and combine them arbitrarily. +""" + +from __future__ import annotations + +import pytest + +from spatialdata.transformations._graph import Axis, NgffAxisType + + +# ---------------------------------------------------------------------- +# Simple axes – one per common name / type combination +# ---------------------------------------------------------------------- +@pytest.fixture +def axis_x_space() -> Axis: + """Axis(name='x', type=SPACE)""" + return Axis(name="x", type=NgffAxisType.SPACE) + + +@pytest.fixture +def axis_y_space() -> Axis: + """Axis(name='y', type=SPACE)""" + return Axis(name="y", type=NgffAxisType.SPACE) + + +@pytest.fixture +def axis_z_space() -> Axis: + """Axis(name='z', type=SPACE)""" + return Axis(name="z", type=NgffAxisType.SPACE) + + +@pytest.fixture +def axis_c_channel() -> Axis: + """Axis(name='c', type=CHANNEL)""" + return Axis(name="c", type=NgffAxisType.CHANNEL) + + +# ---------------------------------------------------------------------- +# Axes with optional metadata – useful for “prefer non‑None” tests +# ---------------------------------------------------------------------- +@pytest.fixture +def axis_x_space_with_unit() -> Axis: + """Axis(name='x', type=SPACE, unit='micrometer')""" + return Axis(name="x", type=NgffAxisType.SPACE, unit="micrometer") + + +@pytest.fixture +def axis_x_space_with_long_name() -> Axis: + """Axis(name='x', type=SPACE, long_name='X axis')""" + return Axis(name="x", type=NgffAxisType.SPACE, long_name="X axis") + + +@pytest.fixture +def axis_x_space_full() -> Axis: + """Axis(name='x', type=SPACE, unit='micrometer', long_name='X axis')""" + return Axis( + name="x", + type=NgffAxisType.SPACE, + unit="micrometer", + long_name="X axis", + ) + + +# ---------------------------------------------------------------------- +# Composite groups that appear frequently in the code base +# ---------------------------------------------------------------------- +@pytest.fixture +def axes_xy(axis_x_space, axis_y_space) -> tuple[Axis, ...]: + """Typical 2‑D spatial system: ('x', 'y').""" + return axis_x_space, axis_y_space + + +@pytest.fixture +def axes_zyx(axis_z_space, axis_y_space, axis_x_space) -> tuple[Axis, ...]: + """Typical 3‑D spatial system in reverse order: ('z', 'y', 'x').""" + return axis_z_space, axis_y_space, axis_x_space + + +@pytest.fixture +def axes_czyx(axis_c_channel, axis_z_space, axis_y_space, axis_x_space) -> tuple[Axis, ...]: + """Channel plus full 3‑D spatial axes: ('c', 'z', 'y', 'x').""" + return axis_c_channel, axis_z_space, axis_y_space, axis_x_space + + +@pytest.fixture +def axes_cyx(axis_c_channel, axis_y_space, axis_x_space) -> tuple[Axis, ...]: + """Channel plus 2‑D spatial axes: ('c', 'y', 'x').""" + return axis_c_channel, axis_y_space, axis_x_space diff --git a/tests/transformations/utils/test_get_union_of_axes.py b/tests/transformations/utils/test_get_union_of_axes.py new file mode 100644 index 000000000..46ef840be --- /dev/null +++ b/tests/transformations/utils/test_get_union_of_axes.py @@ -0,0 +1,164 @@ +""" +Test suite for ``get_union_of_axes`` that uses the reusable ``Axis`` +fixtures defined in the top‑level ``conftest.py``. +Each logical behavior is expressed as a separate test function; when the +behavior is identical we use ``@pytest.mark.parametrize`` to drive the +different input combinations. +""" + +from __future__ import annotations + +import pytest + +from spatialdata.transformations._graph import Axis, NgffAxisType, get_union_of_axes + + +# ---------------------------------------------------------------------- +# 1. Reverse‑alphabetical ordering (basic union & mixed channel/space) +# ---------------------------------------------------------------------- +@pytest.mark.parametrize( + "axes_fixture_names,expected_names", + [ + # three pure spatial axes + (("axis_x_space", "axis_y_space", "axis_z_space"), ("z", "y", "x")), + # channel + full 3‑D spatial axes + (("axis_c_channel", "axis_x_space", "axis_y_space", "axis_z_space"), ("z", "y", "x", "c")), + # mixed groups: xy + full 3‑D (duplicates should be removed) + (("axes_xy", "axes_zyx"), ("z", "y", "x")), + # mixed groups: cyx + full 3‑D (duplicates removed, c kept) + (("axes_cyx", "axes_zyx"), ("z", "y", "x", "c")), + ], + ids=[ + "basic_spatial", + "channel_plus_spatial", + "xy_plus_zyx", + "cyx_plus_zyx", + ], +) +def test_reverse_alphabetical_order(axes_fixture_names, expected_names, request): + """ + Verify reverse‑alphabetical ordering of the resulting axis names. + The fixture names are resolved to the actual ``Axis`` objects. + """ + # Resolve each name (whether a single Axis fixture or a composite tuple fixture) + resolved = [] + for name in axes_fixture_names: + obj = request.getfixturevalue(name) + # Composite fixtures return a tuple; flatten them + if isinstance(obj, tuple): + resolved.extend(obj) + else: + resolved.append(obj) + + result = get_union_of_axes(tuple(resolved)) + assert tuple(ax.name for ax in result) == expected_names + + +# ---------------------------------------------------------------------- +# 2. Duplicate removal when name and type are identical +# ---------------------------------------------------------------------- +def test_duplicate_removal(axis_x_space, axis_y_space): + """ + Two identical ``x`` axes should be deduplicated, leaving ``y`` untouched. + """ + result = get_union_of_axes((axis_x_space, axis_x_space, axis_y_space)) + assert len(result) == 2 + assert result[0].name == "y" + assert result[1].name == "x" + + +# ---------------------------------------------------------------------- +# 3. Same name, different type – both must be kept +# ---------------------------------------------------------------------- +def test_same_name_different_type(axis_x_space): + """ + ``x`` as a spatial axis and ``x`` as a channel axis are distinct. + """ + axis_x_channel = Axis(name="x", type=NgffAxisType.CHANNEL) # ad‑hoc, no fixture needed + result = get_union_of_axes((axis_x_space, axis_x_channel)) + assert len(result) == 2 + # Reverse alphabetical: spatial ``x`` comes before channel ``x`` + assert result == (axis_x_space, axis_x_channel) + + +# ---------------------------------------------------------------------- +# 4. Prefer non‑None ``unit`` over ``None`` +# ---------------------------------------------------------------------- +def test_prefer_non_none_unit(axis_x_space, axis_x_space_with_unit): + """ + Between an axis with ``unit=None`` and one with a concrete unit, + the latter must survive duplicate removal. + """ + result = get_union_of_axes((axis_x_space, axis_x_space_with_unit)) + assert len(result) == 1 + assert result[0].unit == "micrometer" + + +# ---------------------------------------------------------------------- +# 5. Prefer non‑None ``long_name`` over ``None`` +# ---------------------------------------------------------------------- +def test_prefer_non_none_long_name( + axis_x_space, + axis_x_space_with_long_name, +): + """ + When only one duplicate defines ``long_name``, that value must be kept. + """ + result = get_union_of_axes((axis_x_space, axis_x_space_with_long_name)) + assert len(result) == 1 + assert result[0].long_name == "X axis" + + +# ---------------------------------------------------------------------- +# 6. ``unit`` outranks ``long_name`` when they are on different duplicates +# ---------------------------------------------------------------------- +def test_unit_beats_long_name( + axis_x_space_with_unit, + axis_x_space_with_long_name, +): + """ + The sorting key places ``unit_not_none`` before ``long_name_not_none``, + therefore the axis that defines a unit must be chosen even if another + duplicate defines a long name. + """ + result = get_union_of_axes((axis_x_space_with_long_name, axis_x_space_with_unit)) + assert len(result) == 1 + assert result[0].unit == "micrometer" + assert result[0].long_name is None + + +# ---------------------------------------------------------------------- +# 7. Full hierarchy – axis with both optional fields wins +# ---------------------------------------------------------------------- +def test_full_hierarchy( + axis_x_space_full, + axis_x_space_with_unit, + axis_x_space, +): + """ + Three duplicate ``x`` axes: + * full (unit + long_name) + * unit only + * nothing + The most complete axis must be kept. + """ + result = get_union_of_axes((axis_x_space, axis_x_space_with_unit, axis_x_space_full)) + assert len(result) == 1 + kept = result[0] + assert kept.unit == "micrometer" + assert kept.long_name == "X axis" + + +# ---------------------------------------------------------------------- +# 8. Empty input & single‑axis sanity checks +# ---------------------------------------------------------------------- +def test_empty_input(): + """Empty tuple must return an empty tuple.""" + assert get_union_of_axes(()) == () + + +def test_single_axis(axis_x_space): + """A single axis should be returned unchanged.""" + result = get_union_of_axes((axis_x_space,)) + assert len(result) == 1 + assert result[0] is axis_x_space From 732b94b6ea68856449111c125eef52de90440fea Mon Sep 17 00:00:00 2001 From: Ajayrama Kumaraswamy Date: Wed, 30 Sep 2026 15:14:09 +0200 Subject: [PATCH 42/54] feat(transformations): dynamic project axis classes, no tests yet --- .../transformations/_graph/__init__.py | 20 ++- .../transformations/_graph/edges/__init__.py | 0 .../edges/dynamic_project_axis_edges.py | 162 ++++++++++++++++++ .../_graph/edges/exceptions.py | 102 +++++++++++ .../{edge.py => edges/ngff_aligned_edges.py} | 2 +- .../transformations/_graph/exceptions.py | 71 -------- .../transformations/_graph/graph.py | 2 +- 7 files changed, 277 insertions(+), 82 deletions(-) create mode 100644 src/spatialdata/transformations/_graph/edges/__init__.py create mode 100644 src/spatialdata/transformations/_graph/edges/dynamic_project_axis_edges.py create mode 100644 src/spatialdata/transformations/_graph/edges/exceptions.py rename src/spatialdata/transformations/_graph/{edge.py => edges/ngff_aligned_edges.py} (99%) diff --git a/src/spatialdata/transformations/_graph/__init__.py b/src/spatialdata/transformations/_graph/__init__.py index 818239aa5..d770db5ef 100644 --- a/src/spatialdata/transformations/_graph/__init__.py +++ b/src/spatialdata/transformations/_graph/__init__.py @@ -1,6 +1,16 @@ from __future__ import annotations -from spatialdata.transformations._graph.edge import ( +from spatialdata.transformations._graph.edges.exceptions import ( + AxisRedefinitionError, + DeterminantDifferentFromOne, + EmptyTransformSequenceError, + IncompatibleCoordSystemsError, + MissingAxisError, + NotOrthonormalError, + UnexpectedShapeError, + UnmappedAxisError, +) +from spatialdata.transformations._graph.edges.ngff_aligned_edges import ( AffineEdge, BaseTransformationEdge, ByDimensionEdge, @@ -25,27 +35,19 @@ ) from spatialdata.transformations._graph.exceptions import ( AxisParsingException, - AxisRedefinitionError, CannotRemoveCoordinateSystemError, CoordinateSystemAlreadyExistsError, CoordinateSystemNotFoundError, - DeterminantDifferentFromOne, ElementAlreadyExistsError, ElementNotRegisteredToAnyCoordinateSystemError, - EmptyTransformSequenceError, - IncompatibleCoordSystemsError, InvalidPathError, - MissingAxisError, NGFFCompatibilityError, - NotOrthonormalError, TransformationNotFoundError, TransformationPathAmbiguousError, TransformationPathAmbiguousMultipleEdgeExpectedError, TransformationPathAmbiguousNoEdgeExpectedError, TransformationPathNotFoundError, TransformationPathNotSimple, - UnexpectedShapeError, - UnmappedAxisError, ) from spatialdata.transformations._graph.graph import TransformationGraph from spatialdata.transformations._graph.literals import ( diff --git a/src/spatialdata/transformations/_graph/edges/__init__.py b/src/spatialdata/transformations/_graph/edges/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/spatialdata/transformations/_graph/edges/dynamic_project_axis_edges.py b/src/spatialdata/transformations/_graph/edges/dynamic_project_axis_edges.py new file mode 100644 index 000000000..e29219fff --- /dev/null +++ b/src/spatialdata/transformations/_graph/edges/dynamic_project_axis_edges.py @@ -0,0 +1,162 @@ +# ---------------------------------------------------------------------- +# Shared abstract base for the two dynamic edges +# ---------------------------------------------------------------------- +from __future__ import annotations + +from abc import abstractmethod + +import numpy as np + +from spatialdata._types import ArrayLike +from spatialdata.transformations._graph.edges.exceptions import NonZeroDroppedAxisError +from spatialdata.transformations._graph.edges.ngff_aligned_edges import ( + AffineEdge, + BaseTransformationEdge, + ProjectAxisEdge, +) +from spatialdata.transformations._graph.vert import Axis, CoordSystem + + +class _DynamicAxisEdgeBase(BaseTransformationEdge): + """ + Base for the “dynamic” project-axis edges. + + * The constant that is filled into newly created axes is **always** ``0``, + * Sub‑classes only have to tell the base class which axes they create + (`_created_axes`) and which they drop (`_dropped_axes`). + * All heavy lifting – building a temporary ``ProjectAxisEdge``, + computing the affine matrix and transforming points – lives here. + """ + + def __init__( + self, + *, + input: CoordSystem, + output: CoordSystem, + name: str | None = None, + ) -> None: + super().__init__(name=name, input=input, output=output) + + # ------------------------------------------------------------------ + # Sub‑class contracts – must be provided by the concrete edge + # ------------------------------------------------------------------ + @property + @abstractmethod + def _created_axes(self) -> set[Axis]: + """Axes that are added to the output (empty for the Drop edge).""" + + @property + @abstractmethod + def _dropped_axes(self) -> set[Axis]: + """Axes that are removed from the input (empty for the Create edge).""" + + # ------------------------------------------------------------------ + # Helper: build the underlying ProjectAxisEdge on demand + # ------------------------------------------------------------------ + def _proj_edge(self) -> ProjectAxisEdge: + """Instantiate a ProjectAxisEdge with the sets supplied by the subclass.""" + return ProjectAxisEdge( + input=self.input, + output=self.output, + dropped_inputs=self._dropped_axes, + created_outputs=self._created_axes, + name=self.name, + ) + + # ------------------------------------------------------------------ + # Public API required by BaseTransformationEdge + # ------------------------------------------------------------------ + def to_affine(self, name: str | None = None) -> AffineEdge: + """ + Delegates to a temporary ProjectAxisEdge; because the constant is always + zero the affine matrix returned by ProjectAxisEdge is already correct. + """ + return self._proj_edge().to_affine(name=name) + + # ------------------------------------------------------------------ + # Validation that the dropped columns are all zero before delegating + # ------------------------------------------------------------------ + def pre_transforming_check(self, points: ArrayLike) -> None: + """ + Verify that every column belonging to a dropped axis consists only of + zeros. If any non‑zero value is found, raise ``NonZeroDroppedAxisError``. + """ + if not self._dropped_axes: + return + + # Map input axis → column index. + col_index = {ax: idx for idx, ax in enumerate(self.input.axes)} + for ax in self._dropped_axes: + col = points[:, col_index[ax]] + if not np.allclose(col, 0): + raise NonZeroDroppedAxisError( + axis_name=ax.name, + expected=0, + observed=col, + ) + + def transform_points(self, points: ArrayLike) -> ArrayLike: + """Run the zero‑check, then delegate to the ProjectAxisEdge.""" + self.pre_transforming_check(points) + return self._proj_edge().transform_points(points) + + # ------------------------------------------------------------------ + # Human‑readable representation – shows which axes are created / dropped + # ------------------------------------------------------------------ + def __repr__(self) -> str: # pragma: no cover – representation only + base = super().__repr__() + if self._created_axes: + created = ", ".join(ax.name for ax in self._created_axes) + base += f"\n + creates axes: {created}" + if self._dropped_axes: + dropped = ", ".join(ax.name for ax in self._dropped_axes) + base += f"\n - drops axes: {dropped}" + return base + + +# ---------------------------------------------------------------------- +# DynamicCreateAxisEdge – adds missing axes (filled with zero) +# ---------------------------------------------------------------------- +class DynamicCreateAxisEdge(_DynamicAxisEdgeBase): + """Adds axes that are present in ``output`` but absent from ``input``.""" + + @property + def _created_axes(self) -> set[Axis]: + # Axes in the output that are not in the input must be created. + return {ax for ax in self.output.axes if ax not in self.input.axes} + + @property + def _dropped_axes(self) -> set[Axis]: + # This edge never drops anything. + return set() + + def inverse(self, name: str | None = None) -> BaseTransformationEdge: + return DynamicDropAxisEdge( + input=self.output, + output=self.input, + name=name, + ) + + +# ---------------------------------------------------------------------- +# DynamicDropAxisEdge – removes axes that must be zero +# ---------------------------------------------------------------------- +class DynamicDropAxisEdge(_DynamicAxisEdgeBase): + """Removes axes present in ``input`` but missing from ``output``.""" + + @property + def _created_axes(self) -> set[Axis]: + # This edge never creates new axes. + return set() + + @property + def _dropped_axes(self) -> set[Axis]: + # Axes in the input that are not present in the output are to be dropped. + return {ax for ax in self.input.axes if ax not in self.output.axes} + + def inverse(self, name: str | None = None) -> BaseTransformationEdge: + return DynamicCreateAxisEdge( + input=self.output, + output=self.input, + name=name, + ) diff --git a/src/spatialdata/transformations/_graph/edges/exceptions.py b/src/spatialdata/transformations/_graph/edges/exceptions.py new file mode 100644 index 000000000..88dbeb1b4 --- /dev/null +++ b/src/spatialdata/transformations/_graph/edges/exceptions.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +import numpy as np + +from spatialdata._types import ArrayLike + + +class IncompatibleCoordSystemsError(Exception): + def __init__( + self, + transformation_name: str | None = None, + axes_error_message: str | None = None, + ) -> None: + self.transformation_name = transformation_name + axes_message = axes_error_message or "Output axes can't be mapped to input axes" + axes_transformation_message = ( + transformation_name and f"In the context of a `{transformation_name}` transformation, {axes_message}" + ) + super().__init__(axes_transformation_message or axes_message) + + +class MissingAxisError(Exception): + def __init__(self, axis_name: str, cs_name: str) -> None: + self.axis_name = axis_name + self.cs_name = cs_name + super().__init__(f"Axis {axis_name} is not in coordinate system {cs_name}") + + +class UnexpectedShapeError(Exception): + def __init__( + self, + *, + array_shape: tuple[int, ...], + expected_shape: tuple[int, ...] | str | None = None, + array_name: str | None = None, + ) -> None: + self.array_shape = array_shape + self.expected_shape = expected_shape + message = "Unexpected array shape" + if array_name is not None: + message += f"for '{array_name}'" + message += f": {array_shape}" + if expected_shape is not None: + message += f" instead of {expected_shape}" + super().__init__(message) + + +class DeterminantDifferentFromOne(Exception): + def __init__(self, matrix: ArrayLike) -> None: + self.matrix = matrix + super().__init__("Matrix does not have det(M) == 1") + + +class NotOrthonormalError(Exception): + def __init__(self, matrix: ArrayLike) -> None: + self.matrix = matrix + super().__init__("Matrix is not orthonormal") + + +class EmptyTransformSequenceError(Exception): + def __init__(self) -> None: + super().__init__("Empty sequence of transformations") + + +class AxisRedefinitionError(Exception): + def __init__(self, axis_name: str) -> None: + self.axis_name = axis_name + super().__init__(f"Axis {axis_name} is defined multiple times") + + +class UnmappedAxisError(Exception): + def __init__(self, axis_name: str, cs_name: str) -> None: + self.axis_name = axis_name + self.cs_name = cs_name + super().__init__(f"Axis {axis_name} from coordinate system {cs_name} is not mapped to anything") + + +class NonZeroDroppedAxisError(ValueError): + """ + Raised by :class:`DynamicDropAxisEdge` when a column that is supposed to be + dropped does not contain only zeros. + + Parameters + ---------- + axis_name : str + Name of the axis whose column violated the zero‑only requirement. + expected : float + The constant value that was expected (always ``0`` in the current + implementation). + observed : numpy.ndarray + The full column extracted from the input points that contains the + offending values. + """ + + def __init__(self, axis_name: str, expected: float, observed: np.ndarray) -> None: + self.axis_name = axis_name + self.expected = expected + self.observed = observed + super().__init__( + f"Axis '{axis_name}' contains non‑zero values; " + f"expected all entries to be {expected}, but observed {observed}" + ) diff --git a/src/spatialdata/transformations/_graph/edge.py b/src/spatialdata/transformations/_graph/edges/ngff_aligned_edges.py similarity index 99% rename from src/spatialdata/transformations/_graph/edge.py rename to src/spatialdata/transformations/_graph/edges/ngff_aligned_edges.py index a5cedae36..075ce380a 100644 --- a/src/spatialdata/transformations/_graph/edge.py +++ b/src/spatialdata/transformations/_graph/edges/ngff_aligned_edges.py @@ -11,7 +11,7 @@ import ome_zarr_models.v06.coordinate_transforms as ozm06trans from spatialdata._types import ArrayLike -from spatialdata.transformations._graph.exceptions import ( +from spatialdata.transformations._graph.edges.exceptions import ( AxisRedefinitionError, DeterminantDifferentFromOne, EmptyTransformSequenceError, diff --git a/src/spatialdata/transformations/_graph/exceptions.py b/src/spatialdata/transformations/_graph/exceptions.py index 86f46efee..48aaeee5a 100644 --- a/src/spatialdata/transformations/_graph/exceptions.py +++ b/src/spatialdata/transformations/_graph/exceptions.py @@ -3,7 +3,6 @@ from typing import TYPE_CHECKING, override if TYPE_CHECKING: - from spatialdata._types import ArrayLike from spatialdata.transformations import BaseTransformation @@ -239,76 +238,6 @@ class TransformationManagerWarning(UserWarning): pass -class IncompatibleCoordSystemsError(Exception): - def __init__( - self, - transformation_name: str | None = None, - axes_error_message: str | None = None, - ) -> None: - self.transformation_name = transformation_name - axes_message = axes_error_message or "Output axes can't be mapped to input axes" - axes_transformation_message = ( - transformation_name and f"In the context of a `{transformation_name}` transformation, {axes_message}" - ) - super().__init__(axes_transformation_message or axes_message) - - -class MissingAxisError(Exception): - def __init__(self, axis_name: str, cs_name: str) -> None: - self.axis_name = axis_name - self.cs_name = cs_name - super().__init__(f"Axis {axis_name} is not in coordinate system {cs_name}") - - -class UnexpectedShapeError(Exception): - def __init__( - self, - *, - array_shape: tuple[int, ...], - expected_shape: tuple[int, ...] | str | None = None, - array_name: str | None = None, - ) -> None: - self.array_shape = array_shape - self.expected_shape = expected_shape - message = "Unexpected array shape" - if array_name is not None: - message += f"for '{array_name}'" - message += f": {array_shape}" - if expected_shape is not None: - message += f" instead of {expected_shape}" - super().__init__(message) - - -class DeterminantDifferentFromOne(Exception): - def __init__(self, matrix: ArrayLike) -> None: - self.matrix = matrix - super().__init__(f"Matrix does not have det(M) == 1:\n{matrix}") - - -class NotOrthonormalError(Exception): - def __init__(self, matrix: ArrayLike) -> None: - self.matrix = matrix - super().__init__(f"Matrix is not orthonormal:\n{matrix}") - - -class EmptyTransformSequenceError(Exception): - def __init__(self) -> None: - super().__init__("Empty sequence of transformations") - - -class AxisRedefinitionError(Exception): - def __init__(self, axis_name: str) -> None: - self.axis_name = axis_name - super().__init__(f"Axis {axis_name} is defined multiple times") - - -class UnmappedAxisError(Exception): - def __init__(self, axis_name: str, cs_name: str) -> None: - self.axis_name = axis_name - self.cs_name = cs_name - super().__init__(f"Axis {axis_name} from coordinate system {cs_name} is not mapped to anything") - - class AxisParsingException(Exception): pass diff --git a/src/spatialdata/transformations/_graph/graph.py b/src/spatialdata/transformations/_graph/graph.py index 385006b58..b882df550 100644 --- a/src/spatialdata/transformations/_graph/graph.py +++ b/src/spatialdata/transformations/_graph/graph.py @@ -5,7 +5,7 @@ import networkx as nx -from spatialdata.transformations._graph.edge import BaseTransformationEdge, SequenceEdge +from spatialdata.transformations._graph.edges.ngff_aligned_edges import BaseTransformationEdge, SequenceEdge from spatialdata.transformations._graph.exceptions import ( CannotRemoveCoordinateSystemError, CoordinateSystemAlreadyExistsError, From 7f03be0a0d272b3ea4d8c8809cb096d2cd0030fb Mon Sep 17 00:00:00 2001 From: Ajayrama Kumaraswamy Date: Wed, 30 Sep 2026 15:17:27 +0200 Subject: [PATCH 43/54] reorg (tests/transformations/graph/utils) --- tests/transformations/graph/utils/__init__.py | 0 tests/transformations/{ => graph}/utils/conftest.py | 0 tests/transformations/{ => graph}/utils/test_get_union_of_axes.py | 0 3 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 tests/transformations/graph/utils/__init__.py rename tests/transformations/{ => graph}/utils/conftest.py (100%) rename tests/transformations/{ => graph}/utils/test_get_union_of_axes.py (100%) diff --git a/tests/transformations/graph/utils/__init__.py b/tests/transformations/graph/utils/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/transformations/utils/conftest.py b/tests/transformations/graph/utils/conftest.py similarity index 100% rename from tests/transformations/utils/conftest.py rename to tests/transformations/graph/utils/conftest.py diff --git a/tests/transformations/utils/test_get_union_of_axes.py b/tests/transformations/graph/utils/test_get_union_of_axes.py similarity index 100% rename from tests/transformations/utils/test_get_union_of_axes.py rename to tests/transformations/graph/utils/test_get_union_of_axes.py From 04344ccbdaa7e2724f353609b07774d2f2fcc541 Mon Sep 17 00:00:00 2001 From: Ajayrama Kumaraswamy Date: Fri, 16 Oct 2026 17:15:24 +0200 Subject: [PATCH 44/54] reorg (tests/transformations/graph): consolidated conftest files --- tests/transformations/graph/conftest.py | 240 ++++++++++++++++++ tests/transformations/graph/edge/conftest.py | 81 ------ .../graph/edge/test_edge_parsing.py | 58 ++--- .../graph/edge/test_edge_transformations.py | 202 +++++++++------ tests/transformations/graph/utils/conftest.py | 91 ------- 5 files changed, 390 insertions(+), 282 deletions(-) create mode 100644 tests/transformations/graph/conftest.py delete mode 100644 tests/transformations/graph/edge/conftest.py delete mode 100644 tests/transformations/graph/utils/conftest.py diff --git a/tests/transformations/graph/conftest.py b/tests/transformations/graph/conftest.py new file mode 100644 index 000000000..f764ca9ee --- /dev/null +++ b/tests/transformations/graph/conftest.py @@ -0,0 +1,240 @@ +""" +Global pytest fixtures for graph transformations. +""" + +from __future__ import annotations + +import pytest + +from spatialdata.transformations._graph import Axis, CoordSystem, NgffAxisType + + +# ---------------------------------------------------------------------- +# Simple axes – one per common name / type combination +# ---------------------------------------------------------------------- +@pytest.fixture +def axis_x_space() -> Axis: + """Axis(name='x', type=SPACE)""" + return Axis(name="x", type=NgffAxisType.SPACE) + + +@pytest.fixture +def axis_y_space() -> Axis: + """Axis(name='y', type=SPACE)""" + return Axis(name="y", type=NgffAxisType.SPACE) + + +@pytest.fixture +def axis_z_space() -> Axis: + """Axis(name='z', type=SPACE)""" + return Axis(name="z", type=NgffAxisType.SPACE) + + +@pytest.fixture +def axis_c_channel() -> Axis: + """Axis(name='c', type=CHANNEL)""" + return Axis(name="c", type=NgffAxisType.CHANNEL) + + +# ---------------------------------------------------------------------- +# Axes with optional metadata +# ---------------------------------------------------------------------- +@pytest.fixture +def axis_x_space_with_unit() -> Axis: + """Axis(name='x', type=SPACE, unit='micrometer')""" + return Axis(name="x", type=NgffAxisType.SPACE, unit="micrometer") + + +@pytest.fixture +def axis_y_space_with_unit() -> Axis: + """Axis(name='y', type=SPACE, unit='micrometer')""" + return Axis(name="y", type=NgffAxisType.SPACE, unit="micrometer") + + +@pytest.fixture +def axis_z_space_with_unit() -> Axis: + """Axis(name='z', type=SPACE, unit='micrometer')""" + return Axis(name="z", type=NgffAxisType.SPACE, unit="micrometer") + + +@pytest.fixture +def axis_a_space_with_unit() -> Axis: + """Axis(name='a', type=SPACE, unit='micrometer')""" + return Axis(name="a", type=NgffAxisType.SPACE, unit="micrometer") + + +@pytest.fixture +def axis_b_space_with_unit() -> Axis: + """Axis(name='b', type=SPACE, unit='micrometer')""" + return Axis(name="b", type=NgffAxisType.SPACE, unit="micrometer") + + +@pytest.fixture +def axis_c_space_with_unit() -> Axis: + """Axis(name='x', type=SPACE, unit='micrometer')""" + return Axis(name="c", type=NgffAxisType.SPACE, unit="micrometer") + + +@pytest.fixture +def axis_x_space_with_long_name() -> Axis: + """Axis(name='x', type=SPACE, long_name='X axis')""" + return Axis(name="x", type=NgffAxisType.SPACE, long_name="X axis") + + +@pytest.fixture +def axis_x_space_full() -> Axis: + """Axis(name='x', type=SPACE, unit='micrometer', long_name='X axis')""" + return Axis( + name="x", + type=NgffAxisType.SPACE, + unit="micrometer", + long_name="X axis", + ) + + +# ---------------------------------------------------------------------- +# Composite groups that appear frequently in the code base +# ---------------------------------------------------------------------- +@pytest.fixture +def axes_xy(axis_x_space, axis_y_space) -> tuple[Axis, ...]: + """Typical 2‑D spatial system: ('x', 'y').""" + return axis_x_space, axis_y_space + + +@pytest.fixture +def axes_zyx(axis_z_space, axis_y_space, axis_x_space) -> tuple[Axis, ...]: + """Typical 3‑D spatial system in reverse order: ('z', 'y', 'x').""" + return axis_z_space, axis_y_space, axis_x_space + + +@pytest.fixture +def axes_czyx(axis_c_channel, axis_z_space, axis_y_space, axis_x_space) -> tuple[Axis, ...]: + """Channel plus full 3‑D spatial axes: ('c', 'z', 'y', 'x').""" + return axis_c_channel, axis_z_space, axis_y_space, axis_x_space + + +@pytest.fixture +def axes_cyx(axis_c_channel, axis_y_space, axis_x_space) -> tuple[Axis, ...]: + """Channel plus 2‑D spatial axes: ('c', 'y', 'x').""" + return axis_c_channel, axis_y_space, axis_x_space + + +# ---------------------------------------------------------------------- +# CoordSystem Fixtures +# ---------------------------------------------------------------------- + + +@pytest.fixture +def x_cs(axis_x_space_with_unit) -> CoordSystem: + return CoordSystem(name="x", axes=(axis_x_space_with_unit,), virtual=False) + + +@pytest.fixture +def y_cs(axis_y_space_with_unit) -> CoordSystem: + return CoordSystem(name="y", axes=(axis_y_space_with_unit,), virtual=False) + + +@pytest.fixture +def z_cs(axis_z_space_with_unit) -> CoordSystem: + return CoordSystem(name="z", axes=(axis_z_space_with_unit,), virtual=False) + + +@pytest.fixture +def c_cs(axis_c_channel) -> CoordSystem: + return CoordSystem(name="c", axes=(axis_c_channel,), virtual=False) + + +@pytest.fixture +def xy_cs(axis_x_space_with_unit, axis_y_space_with_unit) -> CoordSystem: + return CoordSystem( + name="xy", + axes=( + axis_x_space_with_unit, + axis_y_space_with_unit, + ), + virtual=False, + ) + + +@pytest.fixture +def yx_cs(axis_y_space_with_unit, axis_x_space_with_unit) -> CoordSystem: + return CoordSystem( + name="yx", + axes=( + axis_y_space_with_unit, + axis_x_space_with_unit, + ), + virtual=False, + ) + + +@pytest.fixture +def xyz_cs(axis_x_space_with_unit, axis_y_space_with_unit, axis_z_space_with_unit) -> CoordSystem: + return CoordSystem( + name="xyz", + axes=( + axis_x_space_with_unit, + axis_y_space_with_unit, + axis_z_space_with_unit, + ), + virtual=False, + ) + + +@pytest.fixture +def zyx_cs(axis_z_space_with_unit, axis_y_space_with_unit, axis_x_space_with_unit) -> CoordSystem: + return CoordSystem( + name="zyx", + axes=( + axis_z_space_with_unit, + axis_y_space_with_unit, + axis_x_space_with_unit, + ), + virtual=False, + ) + + +@pytest.fixture +def xyc_cs(axis_x_space_with_unit, axis_y_space_with_unit, axis_c_channel) -> CoordSystem: + return CoordSystem( + name="xyc", + axes=( + axis_x_space_with_unit, + axis_y_space_with_unit, + axis_c_channel, + ), + virtual=False, + ) + + +@pytest.fixture +def cyx_cs(axis_c_channel, axis_y_space_with_unit, axis_x_space_with_unit) -> CoordSystem: + return CoordSystem( + name="cyx", + axes=( + axis_c_channel, + axis_y_space_with_unit, + axis_x_space_with_unit, + ), + virtual=False, + ) + + +@pytest.fixture +def abc_cs(axis_a_space_with_unit, axis_b_space_with_unit, axis_c_space_with_unit) -> CoordSystem: + return CoordSystem( + name="abc", axes=(axis_a_space_with_unit, axis_b_space_with_unit, axis_c_space_with_unit), virtual=False + ) + + +@pytest.fixture +def cba_cs(axis_c_space_with_unit, axis_b_space_with_unit, axis_a_space_with_unit) -> CoordSystem: + return CoordSystem( + name="cba", + axes=( + axis_c_space_with_unit, + axis_b_space_with_unit, + axis_a_space_with_unit, + ), + virtual=False, + ) diff --git a/tests/transformations/graph/edge/conftest.py b/tests/transformations/graph/edge/conftest.py deleted file mode 100644 index 2afe25854..000000000 --- a/tests/transformations/graph/edge/conftest.py +++ /dev/null @@ -1,81 +0,0 @@ -from __future__ import annotations - -from spatialdata.transformations._graph import Axis, CoordSystem - -x_axis = Axis(name="x", type="space", unit="micrometer") -y_axis = Axis(name="y", type="space", unit="micrometer") -z_axis = Axis(name="z", type="space", unit="micrometer") -c_axis = Axis(name="c", type="channel") - -a_axis = Axis(name="a", type="space", unit="micrometer") -b_axis = Axis(name="b", type="space", unit="micrometer") -c_axis = Axis(name="c", type="space", unit="micrometer") - -x_cs = CoordSystem(name="x", axes=(x_axis,), virtual=False) -y_cs = CoordSystem(name="y", axes=(y_axis,), virtual=False) -z_cs = CoordSystem(name="z", axes=(z_axis,), virtual=False) -c_cs = CoordSystem(name="c", axes=(c_axis,), virtual=False) -xy_cs = CoordSystem( - name="xy", - axes=( - x_axis, - y_axis, - ), - virtual=False, -) -yx_cs = CoordSystem( - name="yx", - axes=( - y_axis, - x_axis, - ), - virtual=False, -) -xyz_cs = CoordSystem( - name="xyz", - axes=( - x_axis, - y_axis, - z_axis, - ), - virtual=False, -) -zyx_cs = CoordSystem( - name="zyx", - axes=( - z_axis, - y_axis, - x_axis, - ), - virtual=False, -) -xyc_cs = CoordSystem( - name="xyc", - axes=( - x_axis, - y_axis, - c_axis, - ), - virtual=False, -) -cyx_cs = CoordSystem( - name="cyx", - axes=( - c_axis, - y_axis, - x_axis, - ), - virtual=False, -) - -abc_cs = CoordSystem(name="abc", axes=(a_axis, b_axis, c_axis), virtual=False) -cba_cs = CoordSystem( - name="cba", - axes=( - c_axis, - b_axis, - a_axis, - ), - virtual=False, -) -cba_cs = CoordSystem(name="cba", axes=(c_axis, b_axis, a_axis), virtual=False) diff --git a/tests/transformations/graph/edge/test_edge_parsing.py b/tests/transformations/graph/edge/test_edge_parsing.py index a2f270cde..bc26a44d1 100644 --- a/tests/transformations/graph/edge/test_edge_parsing.py +++ b/tests/transformations/graph/edge/test_edge_parsing.py @@ -24,12 +24,6 @@ parse_sequence, parse_translation, ) -from tests.transformations.graph.edge.conftest import ( - xy_cs, - xyz_cs, - yx_cs, - z_axis, -) # The ome-zarr-models transform models used below all leave `input`/`output` at their # default of None: those fields identify coordinate systems by name/path in the NGFF @@ -41,14 +35,14 @@ class TestParseIdentity: - def test_parses_name(self): + def test_parses_name(self, xy_cs): model = ozm06trans.Identity(name="my-identity") edge = parse_identity(model, input=xy_cs, out=xy_cs) assert edge.name == "my-identity" assert edge.input is xy_cs assert edge.output is xy_cs - def test_csgen_output_mirrors_input(self): + def test_csgen_output_mirrors_input(self, xy_cs): model = ozm06trans.Identity(name="my-identity") edge = parse_identity(model, input=xy_cs, out=CsGen(base_name="gen")) assert edge.output.axes_names == xy_cs.axes_names @@ -56,7 +50,7 @@ def test_csgen_output_mirrors_input(self): class TestParseTranslation: - def test_parses_translation_vector(self): + def test_parses_translation_vector(self, xy_cs): model = ozm06trans.Translation(name="my-translation", translation=(1.0, 2.0)) edge = parse_translation(model, input=xy_cs, out=xy_cs) np.testing.assert_allclose(edge.translation, [1.0, 2.0]) @@ -64,7 +58,7 @@ def test_parses_translation_vector(self): assert edge.input is xy_cs assert edge.output is xy_cs - def test_csgen_output_mirrors_input(self): + def test_csgen_output_mirrors_input(self, xy_cs): model = ozm06trans.Translation(translation=(1.0, 2.0)) edge = parse_translation(model, input=xy_cs, out=CsGen(base_name="gen")) assert edge.output.axes_names == xy_cs.axes_names @@ -72,35 +66,39 @@ def test_csgen_output_mirrors_input(self): class TestParseProjectAxis: - def test_parses_dropped_inputs(self): + def test_parses_dropped_inputs(self, xyz_cs, xy_cs, axis_z_space_with_unit): model = ozm06trans.ProjectAxis(name="drop-z", droppedInputs=(2,)) edge = parse_project_axis(model, input=xyz_cs, output=xy_cs) - assert edge.dropped_inputs == {z_axis} + assert edge.dropped_inputs == {axis_z_space_with_unit} assert edge.created_outputs == set() assert edge.name == "drop-z" - def test_parses_created_outputs(self): + def test_parses_created_outputs(self, xyz_cs, xy_cs, axis_z_space_with_unit): model = ozm06trans.ProjectAxis(name="create-z", createdOutputs=(2,)) edge = parse_project_axis(model, input=xy_cs, output=xyz_cs) - assert edge.created_outputs == {z_axis} + assert edge.created_outputs == { + axis_z_space_with_unit, + } assert edge.dropped_inputs == set() - def test_csgen_output_computes_dimensionality(self): + def test_csgen_output_computes_dimensionality(self, xyz_cs, axis_z_space_with_unit): model = ozm06trans.ProjectAxis(droppedInputs=(2,)) edge = parse_project_axis(model, input=xyz_cs, output=CsGen(base_name="gen")) assert edge.output.num_axes == 2 - assert edge.dropped_inputs == {z_axis} + assert edge.dropped_inputs == { + axis_z_space_with_unit, + } class TestParseScale: - def test_parses_scale_vector_and_name(self): + def test_parses_scale_vector_and_name(self, xy_cs): model = ozm06trans.Scale(name="my-scale", scale=(2.0, 3.0)) edge = parse_scale(model, input=xy_cs, out=xy_cs) assert isinstance(edge, ScaleEdge) np.testing.assert_allclose(edge.scale, [2.0, 3.0]) assert edge.name == "my-scale" - def test_csgen_output_mirrors_input(self): + def test_csgen_output_mirrors_input(self, xy_cs): model = ozm06trans.Scale(scale=(2.0, 3.0)) edge = parse_scale(model, input=xy_cs, out=CsGen(base_name="gen")) assert edge.output.axes_names == xy_cs.axes_names @@ -108,7 +106,7 @@ def test_csgen_output_mirrors_input(self): class TestParseMapAxis: - def test_parses_with_explicit_output(self): + def test_parses_with_explicit_output(self, xy_cs, yx_cs): model = ozm06trans.MapAxis(name="my-map-axis", mapAxis=(1, 0)) edge = parse_map_axis(model, input=xy_cs, out=yx_cs) assert isinstance(edge, MapAxisEdge) @@ -116,7 +114,7 @@ def test_parses_with_explicit_output(self): assert edge.output is yx_cs np.testing.assert_allclose(edge.transform_points(POINTS_2D), np.array([[2.0, 1.0], [4.0, 3.0]])) - def test_csgen_output_is_permuted_input_axes(self): + def test_csgen_output_is_permuted_input_axes(self, xy_cs, yx_cs): model = ozm06trans.MapAxis(mapAxis=(1, 0)) edge = parse_map_axis(model, input=xy_cs, out=CsGen(base_name="gen")) assert edge.output.axes_names == yx_cs.axes_names @@ -124,7 +122,7 @@ def test_csgen_output_is_permuted_input_axes(self): class TestParseAffine: - def test_parses_linear_translation_and_name(self): + def test_parses_linear_translation_and_name(self, xy_cs): model = ozm06trans.Affine(name="my-affine", affine=((2.0, 0.0, 1.0), (0.0, 3.0, 5.0))) edge = parse_affine(model, input=xy_cs, output=xy_cs) assert isinstance(edge, AffineEdge) @@ -132,7 +130,7 @@ def test_parses_linear_translation_and_name(self): np.testing.assert_allclose(edge.translation, [1.0, 5.0]) assert edge.name == "my-affine" - def test_csgen_output_derived_from_matrix_row_count(self): + def test_csgen_output_derived_from_matrix_row_count(self, xy_cs): model = ozm06trans.Affine(affine=((2.0, 0.0, 1.0), (0.0, 3.0, 5.0))) edge = parse_affine(model, input=xy_cs, output=CsGen(base_name="gen")) assert edge.output.num_axes == 2 @@ -140,14 +138,14 @@ def test_csgen_output_derived_from_matrix_row_count(self): class TestParseRotation: - def test_parses_rotation_matrix_and_name(self): + def test_parses_rotation_matrix_and_name(self, xy_cs): model = ozm06trans.Rotation(name="my-rotation", rotation=((0.0, -1.0), (1.0, 0.0))) edge = parse_rotation(model, input=xy_cs, out=xy_cs) assert isinstance(edge, RotationEdge) np.testing.assert_allclose(edge.rotation, [[0.0, -1.0], [1.0, 0.0]]) assert edge.name == "my-rotation" - def test_csgen_output_derived_from_matrix_row_count(self): + def test_csgen_output_derived_from_matrix_row_count(self, xy_cs): model = ozm06trans.Rotation(rotation=((0.0, -1.0), (1.0, 0.0))) edge = parse_rotation(model, input=xy_cs, out=CsGen(base_name="gen")) assert edge.output.num_axes == 2 @@ -155,7 +153,7 @@ def test_csgen_output_derived_from_matrix_row_count(self): class TestParseSequence: - def test_parses_name_and_composes_inner_transformations_in_order(self): + def test_parses_name_and_composes_inner_transformations_in_order(self, xy_cs): inner_translation = ozm06trans.Translation(translation=(1.0, 2.0)) inner_scale = ozm06trans.Scale(scale=(2.0, 3.0)) model = ozm06trans.Sequence(name="my-sequence", transformations=(inner_translation, inner_scale)) @@ -168,14 +166,14 @@ def test_parses_name_and_composes_inner_transformations_in_order(self): expected = (POINTS_2D + np.array([1.0, 2.0])) * np.array([2.0, 3.0]) np.testing.assert_allclose(edge.transform_points(POINTS_2D), expected) - def test_single_inner_transformation_uses_given_output_directly(self): + def test_single_inner_transformation_uses_given_output_directly(self, xy_cs): inner_translation = ozm06trans.Translation(translation=(1.0, 2.0)) model = ozm06trans.Sequence(transformations=(inner_translation,)) edge = parse_sequence(model, input=xy_cs, output=xy_cs) assert len(edge.transformations) == 1 assert edge.transformations[0].output is xy_cs - def test_multiple_inner_transformations_use_virtual_intermediate_coord_systems(self): + def test_multiple_inner_transformations_use_virtual_intermediate_coord_systems(self, xy_cs): inner_translation = ozm06trans.Translation(translation=(1.0, 2.0)) inner_scale = ozm06trans.Scale(scale=(2.0, 3.0)) model = ozm06trans.Sequence(transformations=(inner_translation, inner_scale)) @@ -186,7 +184,7 @@ def test_multiple_inner_transformations_use_virtual_intermediate_coord_systems(s assert intermediate.axes_names == xy_cs.axes_names assert edge.transformations[1].output is xy_cs - def test_csgen_output_mirrors_input(self): + def test_csgen_output_mirrors_input(self, xy_cs): inner_translation = ozm06trans.Translation(translation=(1.0, 2.0)) inner_scale = ozm06trans.Scale(scale=(2.0, 3.0)) model = ozm06trans.Sequence(transformations=(inner_translation, inner_scale)) @@ -196,7 +194,7 @@ def test_csgen_output_mirrors_input(self): class TestParseByDimension: - def test_parses_name_and_splits_axes_between_inner_transformations(self): + def test_parses_name_and_splits_axes_between_inner_transformations(self, xy_cs): scale_x = ozm06trans.Scale(scale=(2.0,)) translate_y = ozm06trans.Translation(translation=(5.0,)) model = ozm06trans.ByDimension( @@ -212,7 +210,7 @@ def test_parses_name_and_splits_axes_between_inner_transformations(self): assert [type(t) for t in edge.transformations] == [ScaleEdge, TranslationEdge] np.testing.assert_allclose(edge.transform_points(POINTS_2D), np.array([[2.0, 7.0], [6.0, 9.0]])) - def test_csgen_output_derived_from_max_output_axis_index(self): + def test_csgen_output_derived_from_max_output_axis_index(self, xy_cs): scale_x = ozm06trans.Scale(scale=(2.0,)) translate_y = ozm06trans.Translation(translation=(5.0,)) model = ozm06trans.ByDimension( diff --git a/tests/transformations/graph/edge/test_edge_transformations.py b/tests/transformations/graph/edge/test_edge_transformations.py index 2d260814d..31884b091 100644 --- a/tests/transformations/graph/edge/test_edge_transformations.py +++ b/tests/transformations/graph/edge/test_edge_transformations.py @@ -24,37 +24,21 @@ UnexpectedShapeError, UnmappedAxisError, ) -from tests.transformations.graph.edge.conftest import ( - a_axis, - abc_cs, - b_axis, - c_axis, - x_axis, - x_cs, - xy_cs, - xyc_cs, - xyz_cs, - y_axis, - y_cs, - yx_cs, - z_axis, - zyx_cs, -) POINTS_2D = np.array([[1.0, 2.0], [3.0, 4.0]]) POINTS_3D = np.array([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]) class TestAffineEdge: - def test_constructor_rejects_wrong_linear_shape(self): + def test_constructor_rejects_wrong_linear_shape(self, xy_cs): with pytest.raises(UnexpectedShapeError): AffineEdge(linear=np.eye(3), input=xy_cs, output=xy_cs) - def test_constructor_rejects_wrong_translation_shape(self): + def test_constructor_rejects_wrong_translation_shape(self, xy_cs): with pytest.raises(UnexpectedShapeError): AffineEdge(linear=np.eye(2), translation=np.zeros(3), input=xy_cs, output=xy_cs) - def test_from_affine_matrix(self): + def test_from_affine_matrix(self, xy_cs): # fmt: off affine_matrix = np.array([ [2.0, 0.0, 1.0], @@ -75,18 +59,18 @@ def test_from_affine_matrix(self): np.testing.assert_equal(edge.translation, translation) np.testing.assert_allclose(edge.transform_points(POINTS_2D), np.array([[3.0, 11.0], [7.0, 17.0]])) - def test_transform_points_scale_and_translate(self): + def test_transform_points_scale_and_translate(self, xy_cs): edge = AffineEdge( linear=np.array([[2.0, 0.0], [0.0, 3.0]]), translation=np.array([1.0, 1.0]), input=xy_cs, output=xy_cs ) np.testing.assert_allclose(edge.transform_points(POINTS_2D), np.array([[3.0, 7.0], [7.0, 13.0]])) - def test_transform_points_2d_to_3d(self): + def test_transform_points_2d_to_3d(self, xy_cs, xyz_cs): linear = np.array([[1.0, 0.0], [0.0, 1.0], [1.0, 1.0]]) edge = AffineEdge(linear=linear, input=xy_cs, output=xyz_cs) np.testing.assert_allclose(edge.transform_points(POINTS_2D), np.array([[1.0, 2.0, 3.0], [3.0, 4.0, 7.0]])) - def test_transform_points_rejects_wrong_shape(self): + def test_transform_points_rejects_wrong_shape(self, xy_cs): edge = AffineEdge(linear=np.eye(2), input=xy_cs, output=xy_cs) with pytest.raises(UnexpectedShapeError): # expecting (n, 2), gets (2,) @@ -95,7 +79,7 @@ def test_transform_points_rejects_wrong_shape(self): # expecting (n, 2), gets (n, 3) edge.transform_points(POINTS_3D) - def test_inverse_roundtrips(self): + def test_inverse_roundtrips(self, xy_cs): edge = AffineEdge( linear=np.array([[2.0, 0.0], [0.0, 4.0]]), translation=np.array([1.0, -1.0]), input=xy_cs, output=xy_cs ) @@ -103,100 +87,158 @@ def test_inverse_roundtrips(self): assert inv is not None np.testing.assert_allclose(inv.transform_points(edge.transform_points(POINTS_2D)), POINTS_2D) - def test_inverse_returns_none_for_singular_matrix(self): + def test_inverse_returns_none_for_singular_matrix(self, xy_cs): edge = AffineEdge(linear=np.zeros((2, 2)), input=xy_cs, output=xy_cs) assert edge.inverse() is None class TestIdentityEdge: - def test_constructor_rejects_mismatched_number_of_axes(self): + def test_constructor_rejects_mismatched_number_of_axes(self, xy_cs, xyz_cs): with pytest.raises(IncompatibleCoordSystemsError): IdentityEdge(None, input=xy_cs, output=xyz_cs) - def test_transform_points_is_a_noop(self): + def test_transform_points_is_a_noop(self, xy_cs): edge = IdentityEdge(None, input=xy_cs, output=xy_cs) np.testing.assert_allclose(edge.transform_points(POINTS_2D), POINTS_2D) - def test_to_affine_matches_transform_points(self): + def test_to_affine_matches_transform_points(self, xy_cs): edge = IdentityEdge(None, input=xy_cs, output=xy_cs) affine = edge.to_affine() np.testing.assert_allclose(affine.linear, np.eye(2)) np.testing.assert_allclose(affine.transform_points(POINTS_2D), POINTS_2D) - def test_inverse_is_still_the_identity(self): + def test_inverse_is_still_the_identity(self, xy_cs): edge = IdentityEdge(None, input=xy_cs, output=xy_cs) inv = edge.inverse() np.testing.assert_allclose(inv.transform_points(POINTS_2D), POINTS_2D) class TestMapAxisEdge: - def test_transform_points_swaps_axes(self): + def test_transform_points_swaps_axes( + self, + xyz_cs, + abc_cs, + axis_x_space_with_unit, + axis_y_space_with_unit, + axis_z_space_with_unit, + axis_a_space_with_unit, + axis_b_space_with_unit, + axis_c_space_with_unit, + ): edge = MapAxisEdge( input=xyz_cs, output=abc_cs, - input_to_output={x_axis: b_axis, y_axis: c_axis, z_axis: a_axis}, + input_to_output={ + axis_x_space_with_unit: axis_b_space_with_unit, + axis_y_space_with_unit: axis_c_space_with_unit, + axis_z_space_with_unit: axis_a_space_with_unit, + }, ) np.testing.assert_equal(edge.transform_points(POINTS_3D), np.array([[3.0, 1.0, 2.0], [6.0, 4.0, 5.0]])) - def test_transform_points_permutation_of_three_axes(self): + def test_transform_points_permutation_of_three_axes( + self, + xyz_cs, + abc_cs, + axis_x_space_with_unit, + axis_y_space_with_unit, + axis_z_space_with_unit, + axis_a_space_with_unit, + axis_b_space_with_unit, + axis_c_space_with_unit, + ): edge = MapAxisEdge( - input=xyz_cs, output=abc_cs, input_to_output={x_axis: c_axis, y_axis: b_axis, z_axis: a_axis} + input=xyz_cs, + output=abc_cs, + input_to_output={ + axis_x_space_with_unit: axis_c_space_with_unit, + axis_y_space_with_unit: axis_b_space_with_unit, + axis_z_space_with_unit: axis_a_space_with_unit, + }, ) np.testing.assert_allclose(edge.transform_points(POINTS_3D), np.array([[3.0, 2.0, 1.0], [6.0, 5.0, 4.0]])) - def test_to_affine_matches_transform_points(self): - edge = MapAxisEdge(input=xy_cs, output=xy_cs, input_to_output={x_axis: y_axis, y_axis: x_axis}) + def test_to_affine_matches_transform_points(self, xy_cs, abc_cs, axis_x_space_with_unit, axis_y_space_with_unit): + edge = MapAxisEdge( + input=xy_cs, + output=xy_cs, + input_to_output={ + axis_x_space_with_unit: axis_y_space_with_unit, + axis_y_space_with_unit: axis_x_space_with_unit, + }, + ) affine = edge.to_affine() np.testing.assert_allclose(affine.transform_points(POINTS_2D), edge.transform_points(POINTS_2D)) - def test_inverse_roundtrips(self): + def test_inverse_roundtrips( + self, + xyz_cs, + zyx_cs, + axis_x_space_with_unit, + axis_y_space_with_unit, + axis_z_space_with_unit, + ): edge = MapAxisEdge( - input=xyz_cs, output=zyx_cs, input_to_output={x_axis: z_axis, y_axis: y_axis, z_axis: x_axis} + input=xyz_cs, + output=zyx_cs, + input_to_output={ + axis_x_space_with_unit: axis_z_space_with_unit, + axis_y_space_with_unit: axis_y_space_with_unit, + axis_z_space_with_unit: axis_x_space_with_unit, + }, ) inv = edge.inverse() np.testing.assert_allclose(inv.transform_points(edge.transform_points(POINTS_3D)), POINTS_3D) class TestProjectAxisEdge: - def test_constructor_rejects_dropped_axis_missing_from_input(self): + def test_constructor_rejects_dropped_axis_missing_from_input(self, xy_cs, axis_z_space_with_unit): with pytest.raises(MissingAxisError): - ProjectAxisEdge(input=xy_cs, output=xy_cs, dropped_inputs={z_axis}, created_outputs=set()) + ProjectAxisEdge(input=xy_cs, output=xy_cs, dropped_inputs={axis_z_space_with_unit}, created_outputs=set()) - def test_constructor_rejects_created_axis_missing_from_output(self): + def test_constructor_rejects_created_axis_missing_from_output(self, xy_cs, axis_z_space_with_unit): with pytest.raises(MissingAxisError): - ProjectAxisEdge(input=xy_cs, output=xy_cs, dropped_inputs=set(), created_outputs={z_axis}) + ProjectAxisEdge(input=xy_cs, output=xy_cs, dropped_inputs=set(), created_outputs={axis_z_space_with_unit}) - def test_general_coord_system_incompatibility(self): + def test_general_coord_system_incompatibility(self, xy_cs, xyz_cs): with pytest.raises(IncompatibleCoordSystemsError): ProjectAxisEdge(input=xy_cs, output=xyz_cs, dropped_inputs=set(), created_outputs=set()) with pytest.raises(IncompatibleCoordSystemsError): ProjectAxisEdge(input=xyz_cs, output=xy_cs, dropped_inputs=set(), created_outputs=set()) - def test_to_affine_for_identity_case(self): + def test_to_affine_for_identity_case(self, xy_cs, xyz_cs): edge = ProjectAxisEdge(input=xy_cs, output=xy_cs, dropped_inputs=set(), created_outputs=set()) affine = edge.to_affine() np.testing.assert_allclose(affine.linear, np.eye(2)) np.testing.assert_allclose(edge.transform_points(POINTS_2D), POINTS_2D) - def test_dropping_one_axis(self): - edge = ProjectAxisEdge(input=xyz_cs, output=xy_cs, dropped_inputs={z_axis}, created_outputs=set()) + def test_dropping_one_axis(self, xy_cs, xyz_cs, axis_z_space_with_unit): + edge = ProjectAxisEdge( + input=xyz_cs, output=xy_cs, dropped_inputs={axis_z_space_with_unit}, created_outputs=set() + ) affine = edge.to_affine() assert affine is not None np.testing.assert_allclose(edge.transform_points(POINTS_3D), np.array([[1.0, 2.0], [4.0, 5.0]])) - def test_creating_one_axis(self): - edge = ProjectAxisEdge(input=xy_cs, output=xyz_cs, dropped_inputs=set(), created_outputs={z_axis}) + def test_creating_one_axis(self, xy_cs, xyz_cs, axis_z_space_with_unit): + edge = ProjectAxisEdge( + input=xy_cs, output=xyz_cs, dropped_inputs=set(), created_outputs={axis_z_space_with_unit} + ) affine = edge.to_affine() assert affine is not None np.testing.assert_allclose(edge.transform_points(POINTS_2D), np.array([[1.0, 2.0, 0.0], [3.0, 4.0, 0.0]])) - def test_inverse_none_when_axes_are_dropped_or_created(self): - dropped = ProjectAxisEdge(input=xyz_cs, output=xy_cs, dropped_inputs={z_axis}, created_outputs=set()) + def test_inverse_none_when_axes_are_dropped_or_created(self, xy_cs, xyz_cs, axis_z_space_with_unit): + dropped = ProjectAxisEdge( + input=xyz_cs, output=xy_cs, dropped_inputs={axis_z_space_with_unit}, created_outputs=set() + ) assert dropped.inverse() is None - created = ProjectAxisEdge(input=xy_cs, output=xyz_cs, dropped_inputs=set(), created_outputs={z_axis}) + created = ProjectAxisEdge( + input=xy_cs, output=xyz_cs, dropped_inputs=set(), created_outputs={axis_z_space_with_unit} + ) assert created.inverse() is None - def test_inverse_roundtrip(self): + def test_inverse_roundtrip(self, xy_cs, yx_cs): edge = ProjectAxisEdge(input=xy_cs, output=yx_cs, dropped_inputs=set(), created_outputs=set()) inv = edge.inverse() assert inv is not None @@ -204,82 +246,82 @@ def test_inverse_roundtrip(self): class TestTranslationEdge: - def test_constructor_rejects_mismatched_number_of_axes(self): + def test_constructor_rejects_mismatched_number_of_axes(self, xy_cs, xyz_cs): with pytest.raises(IncompatibleCoordSystemsError): TranslationEdge(translation=np.array([1.0, 2.0]), input=xy_cs, output=xyz_cs) - def test_transform_points_adds_translation(self): + def test_transform_points_adds_translation(self, xy_cs): edge = TranslationEdge(translation=np.array([10.0, 20.0]), input=xy_cs, output=xy_cs) np.testing.assert_allclose(edge.transform_points(POINTS_2D), np.array([[11.0, 22.0], [13.0, 24.0]])) - def test_to_affine_matches_transform_points(self): + def test_to_affine_matches_transform_points(self, xy_cs): edge = TranslationEdge(translation=np.array([10.0, 20.0]), input=xy_cs, output=xy_cs) affine = edge.to_affine() assert affine is not None np.testing.assert_allclose(affine.transform_points(POINTS_2D), edge.transform_points(POINTS_2D)) - def test_inverse_roundtrips(self): + def test_inverse_roundtrips(self, xy_cs): edge = TranslationEdge(translation=np.array([10.0, 20.0]), input=xy_cs, output=xy_cs) inv = edge.inverse() np.testing.assert_allclose(inv.transform_points(edge.transform_points(POINTS_2D)), POINTS_2D) class TestScaleEdge: - def test_constructor_rejects_wrong_scale_shape(self): + def test_constructor_rejects_wrong_scale_shape(self, xy_cs): with pytest.raises(UnexpectedShapeError): ScaleEdge(scale=np.array([1.0, 2.0, 3.0]), input=xy_cs, output=xy_cs) - def test_constructor_rejects_mismatched_number_of_axes(self): + def test_constructor_rejects_mismatched_number_of_axes(self, xy_cs, xyz_cs): with pytest.raises(IncompatibleCoordSystemsError): ScaleEdge(scale=np.array([1.0, 2.0]), input=xy_cs, output=xyz_cs) - def test_transform_points(self): + def test_transform_points(self, xy_cs): edge = ScaleEdge(scale=np.array([2.0, 4.0]), input=xy_cs, output=xy_cs) np.testing.assert_allclose(edge.transform_points(POINTS_2D), np.array([[2.0, 8.0], [6.0, 16.0]])) - def test_to_affine_matches_transform_points(self): + def test_to_affine_matches_transform_points(self, xy_cs): edge = ScaleEdge(scale=np.array([2.0, 4.0]), input=xy_cs, output=xy_cs) affine = edge.to_affine() np.testing.assert_allclose(affine.transform_points(POINTS_2D), edge.transform_points(POINTS_2D)) - def test_inverse_roundtrips(self): + def test_inverse_roundtrips(self, xy_cs): edge = ScaleEdge(scale=np.array([2.0, 4.0]), input=xy_cs, output=xy_cs) inv = edge.inverse() assert inv is not None np.testing.assert_allclose(inv.transform_points(edge.transform_points(POINTS_2D)), POINTS_2D) - def test_inverse_returns_none_when_scale_is_zero(self): + def test_inverse_returns_none_when_scale_is_zero(self, xy_cs): edge = ScaleEdge(scale=np.array([0.0, 4.0]), input=xy_cs, output=xy_cs) assert edge.inverse() is None class TestRotationEdge: - def test_constructor_rejects_mismatched_number_of_axes(self): + def test_constructor_rejects_mismatched_number_of_axes(self, xy_cs, xyz_cs): with pytest.raises(IncompatibleCoordSystemsError): RotationEdge(linear_matrix=np.eye(2), input=xy_cs, output=xyz_cs) - def test_constructor_rejects_wrong_shape(self): + def test_constructor_rejects_wrong_shape(self, xy_cs): with pytest.raises(UnexpectedShapeError): RotationEdge(linear_matrix=np.eye(3), input=xy_cs, output=xy_cs) - def test_constructor_rejects_matrix_with_det_diff_from_1(self): + def test_constructor_rejects_matrix_with_det_diff_from_1(self, xy_cs): with pytest.raises(DeterminantDifferentFromOne): RotationEdge(linear_matrix=np.array([[1.0, 0.0], [0.0, -1.0]]), input=xy_cs, output=xy_cs) - def test_constructor_rejects_non_orthonormal_matrix(self): + def test_constructor_rejects_non_orthonormal_matrix(self, xy_cs): with pytest.raises(NotOrthonormalError): RotationEdge(linear_matrix=np.array([[1.0, 0.0], [0.0, 2.0]]), input=xy_cs, output=xy_cs) - def test_transform_points_rotates_90_degrees(self): + def test_transform_points_rotates_90_degrees(self, xy_cs): edge = RotationEdge(linear_matrix=np.array([[0.0, -1.0], [1.0, 0.0]]), input=xy_cs, output=xy_cs) np.testing.assert_allclose(edge.transform_points(POINTS_2D), np.array([[-2.0, 1.0], [-4.0, 3.0]])) - def test_to_affine_matches_transform_points(self): + def test_to_affine_matches_transform_points(self, xy_cs): edge = RotationEdge(linear_matrix=np.array([[0.0, -1.0], [1.0, 0.0]]), input=xy_cs, output=xy_cs) affine = edge.to_affine() np.testing.assert_allclose(affine.transform_points(POINTS_2D), edge.transform_points(POINTS_2D)) - def test_inverse_roundtrips(self): + def test_inverse_roundtrips(self, xy_cs): edge = RotationEdge(linear_matrix=np.array([[0.0, -1.0], [1.0, 0.0]]), input=xy_cs, output=xy_cs) inv = edge.inverse() np.testing.assert_allclose(inv.transform_points(edge.transform_points(POINTS_2D)), POINTS_2D) @@ -290,27 +332,27 @@ def test_constructor_rejects_empty_sequence(self): with pytest.raises(EmptyTransformSequenceError): SequenceEdge(transformations=[]) - def test_constructor_rejects_incompatible_neighbors(self): + def test_constructor_rejects_incompatible_neighbors(self, xy_cs, xyz_cs): first = TranslationEdge(translation=np.array([1.0, 2.0]), input=xy_cs, output=xy_cs) second = TranslationEdge(translation=np.array([1.0, 2.0, 3.0]), input=xyz_cs, output=xyz_cs) with pytest.raises(IncompatibleCoordSystemsError): SequenceEdge(transformations=[first, second]) - def test_transform_points_composes_in_order(self): + def test_transform_points_composes_in_order(self, xy_cs): translate = TranslationEdge(translation=np.array([1.0, 2.0]), input=xy_cs, output=xy_cs) scale = ScaleEdge(scale=np.array([3.0, 4.0]), input=xy_cs, output=xy_cs) edge = SequenceEdge(transformations=[translate, scale]) expected = (POINTS_2D + np.array([1.0, 2.0])) * np.array([3.0, 4.0]) np.testing.assert_allclose(edge.transform_points(POINTS_2D), expected) - def test_to_affine_matches_transform_points(self): + def test_to_affine_matches_transform_points(self, xy_cs): translate = TranslationEdge(translation=np.array([1.0, 2.0]), input=xy_cs, output=xy_cs) scale = ScaleEdge(scale=np.array([3.0, 4.0]), input=xy_cs, output=xy_cs) edge = SequenceEdge(transformations=[translate, scale]) affine = edge.to_affine() np.testing.assert_allclose(affine.transform_points(POINTS_2D), edge.transform_points(POINTS_2D)) - def test_inverse_roundtrips(self): + def test_inverse_roundtrips(self, xy_cs): translate = TranslationEdge(translation=np.array([1.0, 2.0]), input=xy_cs, output=xy_cs) scale = ScaleEdge(scale=np.array([3.0, 4.0]), input=xy_cs, output=xy_cs) edge = SequenceEdge(transformations=[translate, scale]) @@ -318,7 +360,7 @@ def test_inverse_roundtrips(self): assert inv is not None np.testing.assert_allclose(inv.transform_points(edge.transform_points(POINTS_2D)), POINTS_2D) - def test_inverse_returns_none_if_any_component_is_not_invertible(self): + def test_inverse_returns_none_if_any_component_is_not_invertible(self, xy_cs): scale = ScaleEdge(scale=np.array([0.0, 4.0]), input=xy_cs, output=xy_cs) translate = TranslationEdge(translation=np.array([1.0, 2.0]), input=xy_cs, output=xy_cs) edge = SequenceEdge(transformations=[scale, translate]) @@ -326,44 +368,44 @@ def test_inverse_returns_none_if_any_component_is_not_invertible(self): class TestByDimensionEdge: - def test_constructor_rejects_input_axis_missing_from_overall_input(self): + def test_constructor_rejects_input_axis_missing_from_overall_input(self, xyc_cs, xy_cs): sub = IdentityEdge(None, input=xyc_cs, output=xyc_cs) with pytest.raises(MissingAxisError): ByDimensionEdge(transformations=[sub], input=xy_cs, output=xyc_cs) - def test_constructor_rejects_output_axis_missing_from_overall_output(self): + def test_constructor_rejects_output_axis_missing_from_overall_output(self, xy_cs, x_cs): sub = IdentityEdge(None, input=xy_cs, output=xy_cs) with pytest.raises(MissingAxisError): ByDimensionEdge(transformations=[sub], input=xy_cs, output=x_cs) - def test_constructor_rejects_output_axis_defined_more_than_once(self): + def test_constructor_rejects_output_axis_defined_more_than_once(self, x_cs): first = IdentityEdge(None, input=x_cs, output=x_cs) second = IdentityEdge(None, input=x_cs, output=x_cs) with pytest.raises(AxisRedefinitionError): ByDimensionEdge(transformations=[first, second], input=x_cs, output=x_cs) - def test_constructor_rejects_unmapped_output_axis(self): + def test_constructor_rejects_unmapped_output_axis(self, x_cs, xy_cs, axis_y_space_with_unit): sub = IdentityEdge(None, input=x_cs, output=x_cs) try: ByDimensionEdge(transformations=[sub], input=xy_cs, output=xy_cs) raise AssertionError(f"Expected {UnmappedAxisError.__name__} to be raised") except UnmappedAxisError as e: - assert e.axis == y_axis + assert e.axis_name == axis_y_space_with_unit.name - def test_transform_points_applies_each_transformation_per_axis(self): + def test_transform_points_applies_each_transformation_per_axis(self, x_cs, y_cs, xy_cs): scale_x = ScaleEdge(scale=np.array([2.0]), input=x_cs, output=x_cs) translate_y = TranslationEdge(translation=np.array([5.0]), input=y_cs, output=y_cs) edge = ByDimensionEdge(transformations=[scale_x, translate_y], input=xy_cs, output=xy_cs) np.testing.assert_allclose(edge.transform_points(POINTS_2D), np.array([[2.0, 7.0], [6.0, 9.0]])) - def test_to_affine_matches_transform_points(self): + def test_to_affine_matches_transform_points(self, x_cs, y_cs, xy_cs): scale_x = ScaleEdge(scale=np.array([2.0]), input=x_cs, output=x_cs) translate_y = TranslationEdge(translation=np.array([5.0]), input=y_cs, output=y_cs) edge = ByDimensionEdge(transformations=[scale_x, translate_y], input=xy_cs, output=xy_cs) affine = edge.to_affine() np.testing.assert_allclose(affine.transform_points(POINTS_2D), edge.transform_points(POINTS_2D)) - def test_inverse_roundtrips(self): + def test_inverse_roundtrips(self, x_cs, y_cs, xy_cs): scale_x = ScaleEdge(scale=np.array([2.0]), input=x_cs, output=x_cs) translate_y = TranslationEdge(translation=np.array([5.0]), input=y_cs, output=y_cs) edge = ByDimensionEdge(transformations=[scale_x, translate_y], input=xy_cs, output=xy_cs) diff --git a/tests/transformations/graph/utils/conftest.py b/tests/transformations/graph/utils/conftest.py deleted file mode 100644 index a8d6d5b02..000000000 --- a/tests/transformations/graph/utils/conftest.py +++ /dev/null @@ -1,91 +0,0 @@ -""" -Global pytest fixtures that provide ready‑made ``Axis`` instances. -These fixtures are deliberately small – any test can import the one it needs -and combine them arbitrarily. -""" - -from __future__ import annotations - -import pytest - -from spatialdata.transformations._graph import Axis, NgffAxisType - - -# ---------------------------------------------------------------------- -# Simple axes – one per common name / type combination -# ---------------------------------------------------------------------- -@pytest.fixture -def axis_x_space() -> Axis: - """Axis(name='x', type=SPACE)""" - return Axis(name="x", type=NgffAxisType.SPACE) - - -@pytest.fixture -def axis_y_space() -> Axis: - """Axis(name='y', type=SPACE)""" - return Axis(name="y", type=NgffAxisType.SPACE) - - -@pytest.fixture -def axis_z_space() -> Axis: - """Axis(name='z', type=SPACE)""" - return Axis(name="z", type=NgffAxisType.SPACE) - - -@pytest.fixture -def axis_c_channel() -> Axis: - """Axis(name='c', type=CHANNEL)""" - return Axis(name="c", type=NgffAxisType.CHANNEL) - - -# ---------------------------------------------------------------------- -# Axes with optional metadata – useful for “prefer non‑None” tests -# ---------------------------------------------------------------------- -@pytest.fixture -def axis_x_space_with_unit() -> Axis: - """Axis(name='x', type=SPACE, unit='micrometer')""" - return Axis(name="x", type=NgffAxisType.SPACE, unit="micrometer") - - -@pytest.fixture -def axis_x_space_with_long_name() -> Axis: - """Axis(name='x', type=SPACE, long_name='X axis')""" - return Axis(name="x", type=NgffAxisType.SPACE, long_name="X axis") - - -@pytest.fixture -def axis_x_space_full() -> Axis: - """Axis(name='x', type=SPACE, unit='micrometer', long_name='X axis')""" - return Axis( - name="x", - type=NgffAxisType.SPACE, - unit="micrometer", - long_name="X axis", - ) - - -# ---------------------------------------------------------------------- -# Composite groups that appear frequently in the code base -# ---------------------------------------------------------------------- -@pytest.fixture -def axes_xy(axis_x_space, axis_y_space) -> tuple[Axis, ...]: - """Typical 2‑D spatial system: ('x', 'y').""" - return axis_x_space, axis_y_space - - -@pytest.fixture -def axes_zyx(axis_z_space, axis_y_space, axis_x_space) -> tuple[Axis, ...]: - """Typical 3‑D spatial system in reverse order: ('z', 'y', 'x').""" - return axis_z_space, axis_y_space, axis_x_space - - -@pytest.fixture -def axes_czyx(axis_c_channel, axis_z_space, axis_y_space, axis_x_space) -> tuple[Axis, ...]: - """Channel plus full 3‑D spatial axes: ('c', 'z', 'y', 'x').""" - return axis_c_channel, axis_z_space, axis_y_space, axis_x_space - - -@pytest.fixture -def axes_cyx(axis_c_channel, axis_y_space, axis_x_space) -> tuple[Axis, ...]: - """Channel plus 2‑D spatial axes: ('c', 'y', 'x').""" - return axis_c_channel, axis_y_space, axis_x_space From 5bc5100a4429b6ff965987c48f4abb4975c0ebef Mon Sep 17 00:00:00 2001 From: Ajayrama Kumaraswamy Date: Fri, 16 Oct 2026 17:19:10 +0200 Subject: [PATCH 45/54] feat (transformations/_graph/exceptions): passing only name instead of complete transformation object --- .../transformation_manager/transformations_facade.py | 2 +- src/spatialdata/transformations/_graph/exceptions.py | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/spatialdata/_core/transformation_manager/transformations_facade.py b/src/spatialdata/_core/transformation_manager/transformations_facade.py index 7833a6baa..7ed958a2d 100644 --- a/src/spatialdata/_core/transformation_manager/transformations_facade.py +++ b/src/spatialdata/_core/transformation_manager/transformations_facade.py @@ -94,7 +94,7 @@ def set_element_transformations( raise IncompatibleTransformationOutputCoordinateSystemError( input_coordinate_system_name=element_cs.name, output_coordinate_system_name=output_coordinate_system_name, - transformation=t, + transformation_class_name=t.__class__.__name__, ) self._graph.add_transformation_edge(ngff_transformation_edge) transformation_edges.append(ngff_transformation_edge) diff --git a/src/spatialdata/transformations/_graph/exceptions.py b/src/spatialdata/transformations/_graph/exceptions.py index 48aaeee5a..8761a8f18 100644 --- a/src/spatialdata/transformations/_graph/exceptions.py +++ b/src/spatialdata/transformations/_graph/exceptions.py @@ -3,7 +3,7 @@ from typing import TYPE_CHECKING, override if TYPE_CHECKING: - from spatialdata.transformations import BaseTransformation + pass class CoordinateSystemNotFoundError(ValueError): @@ -263,15 +263,15 @@ class IncompatibleTransformationOutputCoordinateSystemError(Exception): """ def __init__( - self, input_coordinate_system_name: str, transformation: BaseTransformation, output_coordinate_system_name: str + self, input_coordinate_system_name: str, transformation_class_name: str, output_coordinate_system_name: str ) -> None: self.input_coordinate_system_name = input_coordinate_system_name self.output_coordinate_system_name = output_coordinate_system_name - self.transformation = transformation + self.transformation_class_name = transformation_class_name super().__init__( f"Transformation incompatible with input/output coordinate systems: " f"\nInput coordinate system name: {input_coordinate_system_name}" f"\nOutput coordinate system name: {output_coordinate_system_name}" - f"\nTransformation: {transformation}" + f"\nTransformation type: {transformation_class_name}" ) From 5fb6860c23818d9bb6608d3c0077249de9e5d8df Mon Sep 17 00:00:00 2001 From: Ajayrama Kumaraswamy Date: Fri, 16 Oct 2026 17:20:43 +0200 Subject: [PATCH 46/54] wip (dynamic projection axes) --- .../transformations/_graph/__init__.py | 12 +- .../edges/dynamic_project_axis_edges.py | 37 +++++- .../transformations/_graph/utils.py | 4 +- .../graph/edge/test_dynamic_axis_edges.py | 122 ++++++++++++++++++ 4 files changed, 166 insertions(+), 9 deletions(-) create mode 100644 tests/transformations/graph/edge/test_dynamic_axis_edges.py diff --git a/src/spatialdata/transformations/_graph/__init__.py b/src/spatialdata/transformations/_graph/__init__.py index d770db5ef..786feac66 100644 --- a/src/spatialdata/transformations/_graph/__init__.py +++ b/src/spatialdata/transformations/_graph/__init__.py @@ -1,11 +1,16 @@ from __future__ import annotations +from spatialdata.transformations._graph.edges.dynamic_project_axis_edges import ( + DynamicCreateAxisEdge, + DynamicDropAxisEdge, +) from spatialdata.transformations._graph.edges.exceptions import ( AxisRedefinitionError, DeterminantDifferentFromOne, EmptyTransformSequenceError, IncompatibleCoordSystemsError, MissingAxisError, + NonZeroDroppedAxisError, NotOrthonormalError, UnexpectedShapeError, UnmappedAxisError, @@ -64,7 +69,7 @@ from spatialdata.transformations._graph.vert import Axis, CoordSystem __all__ = [ - # from edge + # from ngff aligned edge "AffineEdge", "BaseTransformationEdge", "ByDimensionEdge", @@ -86,6 +91,11 @@ "parse_scale", "parse_sequence", "parse_translation", + # dynamic project edges + "DynamicDropAxisEdge", + "DynamicCreateAxisEdge", + # from edge.exceptions + "NonZeroDroppedAxisError", # from exceptions "AxisParsingException", "AxisRedefinitionError", diff --git a/src/spatialdata/transformations/_graph/edges/dynamic_project_axis_edges.py b/src/spatialdata/transformations/_graph/edges/dynamic_project_axis_edges.py index e29219fff..7f136a75d 100644 --- a/src/spatialdata/transformations/_graph/edges/dynamic_project_axis_edges.py +++ b/src/spatialdata/transformations/_graph/edges/dynamic_project_axis_edges.py @@ -3,7 +3,7 @@ # ---------------------------------------------------------------------- from __future__ import annotations -from abc import abstractmethod +from abc import ABC, abstractmethod import numpy as np @@ -14,10 +14,11 @@ BaseTransformationEdge, ProjectAxisEdge, ) +from spatialdata.transformations._graph.exceptions import IncompatibleTransformationOutputCoordinateSystemError from spatialdata.transformations._graph.vert import Axis, CoordSystem -class _DynamicAxisEdgeBase(BaseTransformationEdge): +class _DynamicAxisEdgeBase(BaseTransformationEdge, ABC): """ Base for the “dynamic” project-axis edges. @@ -28,13 +29,18 @@ class _DynamicAxisEdgeBase(BaseTransformationEdge): computing the affine matrix and transforming points – lives here. """ + @abstractmethod + def validate_input_output(self, input: CoordSystem, output: CoordSystem) -> None: + """Validate that the input and output for this edge""" + def __init__( self, *, + name: str | None = None, input: CoordSystem, output: CoordSystem, - name: str | None = None, - ) -> None: + ): + self.validate_input_output(input, output) super().__init__(name=name, input=input, output=output) # ------------------------------------------------------------------ @@ -123,7 +129,7 @@ class DynamicCreateAxisEdge(_DynamicAxisEdgeBase): @property def _created_axes(self) -> set[Axis]: # Axes in the output that are not in the input must be created. - return {ax for ax in self.output.axes if ax not in self.input.axes} + return set(self.output.axes) - set(self.input.axes) @property def _dropped_axes(self) -> set[Axis]: @@ -137,6 +143,16 @@ def inverse(self, name: str | None = None) -> BaseTransformationEdge: name=name, ) + def validate_input_output(self, input: CoordSystem, output: CoordSystem) -> None: + + if not set(input.axes).issubset(output.axes): + # if input axes are not a subset of output axes + raise IncompatibleTransformationOutputCoordinateSystemError( + input_coordinate_system_name=input.name, + output_coordinate_system_name=output.name, + transformation_class_name=self.__class__.__name__, + ) + # ---------------------------------------------------------------------- # DynamicDropAxisEdge – removes axes that must be zero @@ -152,7 +168,7 @@ def _created_axes(self) -> set[Axis]: @property def _dropped_axes(self) -> set[Axis]: # Axes in the input that are not present in the output are to be dropped. - return {ax for ax in self.input.axes if ax not in self.output.axes} + return set(self.input.axes) - set(self.output.axes) def inverse(self, name: str | None = None) -> BaseTransformationEdge: return DynamicCreateAxisEdge( @@ -160,3 +176,12 @@ def inverse(self, name: str | None = None) -> BaseTransformationEdge: output=self.input, name=name, ) + + def validate_input_output(self, input: CoordSystem, output: CoordSystem) -> None: + if not set(output.axes).issubset(input.axes): + # if output axes are not a subset of input axes + raise IncompatibleTransformationOutputCoordinateSystemError( + input_coordinate_system_name=input.name, + output_coordinate_system_name=output.name, + transformation_class_name=self.__class__.__name__, + ) diff --git a/src/spatialdata/transformations/_graph/utils.py b/src/spatialdata/transformations/_graph/utils.py index 622e8280c..fc4beb540 100644 --- a/src/spatialdata/transformations/_graph/utils.py +++ b/src/spatialdata/transformations/_graph/utils.py @@ -211,7 +211,7 @@ def get_union_of_axes(axes: tuple[Axis, ...]) -> tuple[Axis, ...]: kind="mergesort", # stable sort to preserve order of equal keys ) - # Remove duplicates based on the logical key (name, type). - df = df.drop_duplicates(subset=["name", "type"], keep="first") + # Remove duplicates based on the logical key (name, type, unit, long_name). + df = df.drop_duplicates(subset=["name", "type", "unit", "long_name"], keep="first") return tuple(df["axis"]) diff --git a/tests/transformations/graph/edge/test_dynamic_axis_edges.py b/tests/transformations/graph/edge/test_dynamic_axis_edges.py new file mode 100644 index 000000000..d33d6c444 --- /dev/null +++ b/tests/transformations/graph/edge/test_dynamic_axis_edges.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +import numpy as np +import pytest + +from spatialdata.transformations._graph import DynamicCreateAxisEdge, DynamicDropAxisEdge, NonZeroDroppedAxisError + + +# ---------------------------------------------------------------------- +# DynamicCreateAxisEdge – creation logic +# ---------------------------------------------------------------------- +def test_dynamic_create_axis_edge_no_created_axes(xyz_cs): + """When input == output, ``_created_axes`` is empty.""" + edge = DynamicCreateAxisEdge(input=xyz_cs, output=xyz_cs) + assert edge._created_axes == set() + + +def test_dynamic_create_axis_edge_single_created_axis(xyz_cs, xyc_cs): + """When ``c`` is missing from the input, it is reported as a created axis.""" + edge = DynamicCreateAxisEdge(input=xyz_cs, output=xyc_cs) + created = {ax.name for ax in edge._created_axes} + assert created == {"c"} + + +def test_dynamic_create_axis_edge_multiple_created_axes(xyc_cs, abc_cs): + """When ``a`` and ``b`` are missing from the input, both are reported.""" + edge = DynamicCreateAxisEdge(input=xyc_cs, output=abc_cs) + created = {ax.name for ax in edge._created_axes} + assert created == {"a", "b"} + + +def test_dynamic_create_axis_edge_inverse(xyz_cs, xyc_cs): + """Inverse of a create edge must be a drop‑edge with swapped coordinate systems.""" + edge = DynamicCreateAxisEdge(input=xyz_cs, output=xyc_cs) + inv = edge.inverse() + assert isinstance(inv, DynamicDropAxisEdge) + assert inv.input is edge.output + assert inv.output is edge.input + + +def test_dynamic_create_axis_edge_to_affine(xyz_cs, xyc_cs): + """``to_affine`` must delegate to a temporary ``ProjectAxisEdge``.""" + edge = DynamicCreateAxisEdge(input=xyz_cs, output=xyc_cs) + affine = edge.to_affine() + expected_proj_affine = edge._proj_edge().to_affine() + assert np.allclose(affine.linear, expected_proj_affine.linear) + assert np.allclose(affine.translation, expected_proj_affine.translation) + assert affine.input is edge.input + assert affine.output is edge.output + + +def test_dynamic_create_axis_edge_repr_contains_created_axes(xyz_cs, xyc_cs): + """``repr`` must list the created axes and must not contain a drops‑section.""" + edge = DynamicCreateAxisEdge(input=xyz_cs, output=xyc_cs) + rep = repr(edge) + assert "+ creates axes:" in rep + assert "c" in rep + assert "- drops axes:" not in rep + + +# ---------------------------------------------------------------------- +# DynamicDropAxisEdge – dropping logic +# ---------------------------------------------------------------------- +def test_dynamic_drop_axis_edge_no_dropped_axes(xyz_cs): + """When input == output, ``_dropped_axes`` is empty.""" + edge = DynamicDropAxisEdge(input=xyz_cs, output=xyz_cs) + assert edge._dropped_axes == set() + + +def test_dynamic_drop_axis_edge_single_dropped_axis(xyz_cs, xy_cs): + """When ``z`` is missing from the output, it is reported as a dropped axis.""" + edge = DynamicDropAxisEdge(input=xyz_cs, output=xy_cs) + dropped = {ax.name for ax in edge._dropped_axes} + assert dropped == {"z"} + + +def test_dynamic_drop_axis_edge_multiple_dropped_axes(abc_cs, c_cs): + """When ``a`` and ``b`` are missing from the output, both are reported.""" + edge = DynamicDropAxisEdge(input=abc_cs, output=c_cs) + dropped = {ax.name for ax in edge._dropped_axes} + assert dropped == {"a", "b"} + + +def test_dynamic_drop_axis_edge_inverse(xyz_cs, xy_cs): + """Inverse of a drop‑edge must be a create-edge with swapped coordinate systems.""" + edge = DynamicDropAxisEdge(input=xyz_cs, output=xy_cs) + inv = edge.inverse() + assert isinstance(inv, DynamicCreateAxisEdge) + assert inv.input is edge.output + assert inv.output is edge.input + + +def test_dynamic_drop_axis_edge_to_affine(xyz_cs, xy_cs): + """``to_affine`` of a drop‑edge must produce an affine with linear part whose shape + reflects the output‑by‑input dimensionality (2×3 in this case).""" + edge = DynamicDropAxisEdge(input=xyz_cs, output=xy_cs) + affine = edge.to_affine() + assert affine.linear.shape == (2, 3) # 2 output axes, 3 input axes + + +def test_dynamic_drop_axis_edge_pre_transform_check_passes(xyz_cs, xy_cs): + """Zero‑check passes when the dropped axis (z) contains only zeros.""" + points = np.array([[1, 2, 0], [3, 4, 0]], dtype=float) + edge = DynamicDropAxisEdge(input=xyz_cs, output=xy_cs) # drops ``z`` + edge.pre_transforming_check(points) # should not raise + + +def test_dynamic_drop_axis_edge_pre_transform_check_fails(xyz_cs, xy_cs): + """Zero‑check raises ``NonZeroDroppedAxisError`` when a dropped axis is non‑zero.""" + points = np.array([[1, 2, 5], [3, 4, 0]], dtype=float) + edge = DynamicDropAxisEdge(input=xyz_cs, output=xy_cs) # drops ``z`` + with pytest.raises(NonZeroDroppedAxisError): + edge.pre_transforming_check(points) + + +def test_dynamic_drop_axis_edge_repr_contains_dropped_axes(xyz_cs, xy_cs): + """``repr`` must list the dropped axes and must not contain a creates‑section.""" + edge = DynamicDropAxisEdge(input=xyz_cs, output=xy_cs) + rep = repr(edge) + assert "- drops axes:" in rep + assert "z" in rep + assert "+ creates axes:" not in rep From 541b087028416b33aecc4ad4748589b971983ecd Mon Sep 17 00:00:00 2001 From: Ajayrama Kumaraswamy Date: Fri, 16 Oct 2026 18:05:30 +0200 Subject: [PATCH 47/54] fix (test_dynamic_axis_edges.py): tests passing --- tests/transformations/graph/conftest.py | 14 ++++++++ .../graph/edge/test_dynamic_axis_edges.py | 32 +++++++++---------- 2 files changed, 30 insertions(+), 16 deletions(-) diff --git a/tests/transformations/graph/conftest.py b/tests/transformations/graph/conftest.py index f764ca9ee..fec31dcc2 100644 --- a/tests/transformations/graph/conftest.py +++ b/tests/transformations/graph/conftest.py @@ -194,6 +194,20 @@ def zyx_cs(axis_z_space_with_unit, axis_y_space_with_unit, axis_x_space_with_uni ) +@pytest.fixture +def czyx_cs(axis_c_channel, axis_z_space_with_unit, axis_y_space_with_unit, axis_x_space_with_unit) -> CoordSystem: + return CoordSystem( + name="czyx", + axes=( + axis_c_channel, + axis_z_space_with_unit, + axis_y_space_with_unit, + axis_x_space_with_unit, + ), + virtual=False, + ) + + @pytest.fixture def xyc_cs(axis_x_space_with_unit, axis_y_space_with_unit, axis_c_channel) -> CoordSystem: return CoordSystem( diff --git a/tests/transformations/graph/edge/test_dynamic_axis_edges.py b/tests/transformations/graph/edge/test_dynamic_axis_edges.py index d33d6c444..4abd5257b 100644 --- a/tests/transformations/graph/edge/test_dynamic_axis_edges.py +++ b/tests/transformations/graph/edge/test_dynamic_axis_edges.py @@ -15,32 +15,32 @@ def test_dynamic_create_axis_edge_no_created_axes(xyz_cs): assert edge._created_axes == set() -def test_dynamic_create_axis_edge_single_created_axis(xyz_cs, xyc_cs): +def test_dynamic_create_axis_edge_single_created_axis(xy_cs, cyx_cs): """When ``c`` is missing from the input, it is reported as a created axis.""" - edge = DynamicCreateAxisEdge(input=xyz_cs, output=xyc_cs) + edge = DynamicCreateAxisEdge(input=xy_cs, output=cyx_cs) created = {ax.name for ax in edge._created_axes} assert created == {"c"} -def test_dynamic_create_axis_edge_multiple_created_axes(xyc_cs, abc_cs): +def test_dynamic_create_axis_edge_multiple_created_axes(xy_cs, czyx_cs): """When ``a`` and ``b`` are missing from the input, both are reported.""" - edge = DynamicCreateAxisEdge(input=xyc_cs, output=abc_cs) + edge = DynamicCreateAxisEdge(input=xy_cs, output=czyx_cs) created = {ax.name for ax in edge._created_axes} - assert created == {"a", "b"} + assert created == {"c", "z"} -def test_dynamic_create_axis_edge_inverse(xyz_cs, xyc_cs): +def test_dynamic_create_axis_edge_inverse(xy_cs, czyx_cs): """Inverse of a create edge must be a drop‑edge with swapped coordinate systems.""" - edge = DynamicCreateAxisEdge(input=xyz_cs, output=xyc_cs) + edge = DynamicCreateAxisEdge(input=xy_cs, output=czyx_cs) inv = edge.inverse() assert isinstance(inv, DynamicDropAxisEdge) assert inv.input is edge.output assert inv.output is edge.input -def test_dynamic_create_axis_edge_to_affine(xyz_cs, xyc_cs): +def test_dynamic_create_axis_edge_to_affine(xy_cs, xyc_cs): """``to_affine`` must delegate to a temporary ``ProjectAxisEdge``.""" - edge = DynamicCreateAxisEdge(input=xyz_cs, output=xyc_cs) + edge = DynamicCreateAxisEdge(input=xy_cs, output=xyc_cs) affine = edge.to_affine() expected_proj_affine = edge._proj_edge().to_affine() assert np.allclose(affine.linear, expected_proj_affine.linear) @@ -49,9 +49,9 @@ def test_dynamic_create_axis_edge_to_affine(xyz_cs, xyc_cs): assert affine.output is edge.output -def test_dynamic_create_axis_edge_repr_contains_created_axes(xyz_cs, xyc_cs): +def test_dynamic_create_axis_edge_repr_contains_created_axes(xy_cs, xyc_cs): """``repr`` must list the created axes and must not contain a drops‑section.""" - edge = DynamicCreateAxisEdge(input=xyz_cs, output=xyc_cs) + edge = DynamicCreateAxisEdge(input=xy_cs, output=xyc_cs) rep = repr(edge) assert "+ creates axes:" in rep assert "c" in rep @@ -74,16 +74,16 @@ def test_dynamic_drop_axis_edge_single_dropped_axis(xyz_cs, xy_cs): assert dropped == {"z"} -def test_dynamic_drop_axis_edge_multiple_dropped_axes(abc_cs, c_cs): +def test_dynamic_drop_axis_edge_multiple_dropped_axes(czyx_cs, xy_cs): """When ``a`` and ``b`` are missing from the output, both are reported.""" - edge = DynamicDropAxisEdge(input=abc_cs, output=c_cs) + edge = DynamicDropAxisEdge(input=czyx_cs, output=xy_cs) dropped = {ax.name for ax in edge._dropped_axes} - assert dropped == {"a", "b"} + assert dropped == {"c", "z"} -def test_dynamic_drop_axis_edge_inverse(xyz_cs, xy_cs): +def test_dynamic_drop_axis_edge_inverse(zyx_cs, xy_cs): """Inverse of a drop‑edge must be a create-edge with swapped coordinate systems.""" - edge = DynamicDropAxisEdge(input=xyz_cs, output=xy_cs) + edge = DynamicDropAxisEdge(input=zyx_cs, output=xy_cs) inv = edge.inverse() assert isinstance(inv, DynamicCreateAxisEdge) assert inv.input is edge.output From 26b39b2fddd0390452176138f0777ec9af38f672 Mon Sep 17 00:00:00 2001 From: Ajayrama Kumaraswamy Date: Fri, 16 Oct 2026 18:06:49 +0200 Subject: [PATCH 48/54] fix (transformations/utils/get_union_of_axes): during union, sorting axes based on type first, name next --- src/spatialdata/transformations/_graph/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/spatialdata/transformations/_graph/utils.py b/src/spatialdata/transformations/_graph/utils.py index fc4beb540..1337536b3 100644 --- a/src/spatialdata/transformations/_graph/utils.py +++ b/src/spatialdata/transformations/_graph/utils.py @@ -200,8 +200,8 @@ def get_union_of_axes(axes: tuple[Axis, ...]) -> tuple[Axis, ...]: # Sort axes in descending (alphabetical) order df = df.sort_values( by=[ - "name", "type", + "name", "unit_not_none", "unit", "long_name_not_none", From d6c435f6e89032c323898f307c30b4fbe055bf93 Mon Sep 17 00:00:00 2001 From: Ajayrama Kumaraswamy Date: Mon, 26 Oct 2026 16:39:29 +0100 Subject: [PATCH 49/54] fix (dynamic project axis): removed unused code --- .../transformations/_graph/__init__.py | 7 - .../edges/dynamic_project_axis_edges.py | 187 ------------------ .../graph/edge/test_dynamic_axis_edges.py | 122 ------------ 3 files changed, 316 deletions(-) delete mode 100644 src/spatialdata/transformations/_graph/edges/dynamic_project_axis_edges.py delete mode 100644 tests/transformations/graph/edge/test_dynamic_axis_edges.py diff --git a/src/spatialdata/transformations/_graph/__init__.py b/src/spatialdata/transformations/_graph/__init__.py index 786feac66..8ad7343ff 100644 --- a/src/spatialdata/transformations/_graph/__init__.py +++ b/src/spatialdata/transformations/_graph/__init__.py @@ -1,9 +1,5 @@ from __future__ import annotations -from spatialdata.transformations._graph.edges.dynamic_project_axis_edges import ( - DynamicCreateAxisEdge, - DynamicDropAxisEdge, -) from spatialdata.transformations._graph.edges.exceptions import ( AxisRedefinitionError, DeterminantDifferentFromOne, @@ -91,9 +87,6 @@ "parse_scale", "parse_sequence", "parse_translation", - # dynamic project edges - "DynamicDropAxisEdge", - "DynamicCreateAxisEdge", # from edge.exceptions "NonZeroDroppedAxisError", # from exceptions diff --git a/src/spatialdata/transformations/_graph/edges/dynamic_project_axis_edges.py b/src/spatialdata/transformations/_graph/edges/dynamic_project_axis_edges.py deleted file mode 100644 index 7f136a75d..000000000 --- a/src/spatialdata/transformations/_graph/edges/dynamic_project_axis_edges.py +++ /dev/null @@ -1,187 +0,0 @@ -# ---------------------------------------------------------------------- -# Shared abstract base for the two dynamic edges -# ---------------------------------------------------------------------- -from __future__ import annotations - -from abc import ABC, abstractmethod - -import numpy as np - -from spatialdata._types import ArrayLike -from spatialdata.transformations._graph.edges.exceptions import NonZeroDroppedAxisError -from spatialdata.transformations._graph.edges.ngff_aligned_edges import ( - AffineEdge, - BaseTransformationEdge, - ProjectAxisEdge, -) -from spatialdata.transformations._graph.exceptions import IncompatibleTransformationOutputCoordinateSystemError -from spatialdata.transformations._graph.vert import Axis, CoordSystem - - -class _DynamicAxisEdgeBase(BaseTransformationEdge, ABC): - """ - Base for the “dynamic” project-axis edges. - - * The constant that is filled into newly created axes is **always** ``0``, - * Sub‑classes only have to tell the base class which axes they create - (`_created_axes`) and which they drop (`_dropped_axes`). - * All heavy lifting – building a temporary ``ProjectAxisEdge``, - computing the affine matrix and transforming points – lives here. - """ - - @abstractmethod - def validate_input_output(self, input: CoordSystem, output: CoordSystem) -> None: - """Validate that the input and output for this edge""" - - def __init__( - self, - *, - name: str | None = None, - input: CoordSystem, - output: CoordSystem, - ): - self.validate_input_output(input, output) - super().__init__(name=name, input=input, output=output) - - # ------------------------------------------------------------------ - # Sub‑class contracts – must be provided by the concrete edge - # ------------------------------------------------------------------ - @property - @abstractmethod - def _created_axes(self) -> set[Axis]: - """Axes that are added to the output (empty for the Drop edge).""" - - @property - @abstractmethod - def _dropped_axes(self) -> set[Axis]: - """Axes that are removed from the input (empty for the Create edge).""" - - # ------------------------------------------------------------------ - # Helper: build the underlying ProjectAxisEdge on demand - # ------------------------------------------------------------------ - def _proj_edge(self) -> ProjectAxisEdge: - """Instantiate a ProjectAxisEdge with the sets supplied by the subclass.""" - return ProjectAxisEdge( - input=self.input, - output=self.output, - dropped_inputs=self._dropped_axes, - created_outputs=self._created_axes, - name=self.name, - ) - - # ------------------------------------------------------------------ - # Public API required by BaseTransformationEdge - # ------------------------------------------------------------------ - def to_affine(self, name: str | None = None) -> AffineEdge: - """ - Delegates to a temporary ProjectAxisEdge; because the constant is always - zero the affine matrix returned by ProjectAxisEdge is already correct. - """ - return self._proj_edge().to_affine(name=name) - - # ------------------------------------------------------------------ - # Validation that the dropped columns are all zero before delegating - # ------------------------------------------------------------------ - def pre_transforming_check(self, points: ArrayLike) -> None: - """ - Verify that every column belonging to a dropped axis consists only of - zeros. If any non‑zero value is found, raise ``NonZeroDroppedAxisError``. - """ - if not self._dropped_axes: - return - - # Map input axis → column index. - col_index = {ax: idx for idx, ax in enumerate(self.input.axes)} - for ax in self._dropped_axes: - col = points[:, col_index[ax]] - if not np.allclose(col, 0): - raise NonZeroDroppedAxisError( - axis_name=ax.name, - expected=0, - observed=col, - ) - - def transform_points(self, points: ArrayLike) -> ArrayLike: - """Run the zero‑check, then delegate to the ProjectAxisEdge.""" - self.pre_transforming_check(points) - return self._proj_edge().transform_points(points) - - # ------------------------------------------------------------------ - # Human‑readable representation – shows which axes are created / dropped - # ------------------------------------------------------------------ - def __repr__(self) -> str: # pragma: no cover – representation only - base = super().__repr__() - if self._created_axes: - created = ", ".join(ax.name for ax in self._created_axes) - base += f"\n + creates axes: {created}" - if self._dropped_axes: - dropped = ", ".join(ax.name for ax in self._dropped_axes) - base += f"\n - drops axes: {dropped}" - return base - - -# ---------------------------------------------------------------------- -# DynamicCreateAxisEdge – adds missing axes (filled with zero) -# ---------------------------------------------------------------------- -class DynamicCreateAxisEdge(_DynamicAxisEdgeBase): - """Adds axes that are present in ``output`` but absent from ``input``.""" - - @property - def _created_axes(self) -> set[Axis]: - # Axes in the output that are not in the input must be created. - return set(self.output.axes) - set(self.input.axes) - - @property - def _dropped_axes(self) -> set[Axis]: - # This edge never drops anything. - return set() - - def inverse(self, name: str | None = None) -> BaseTransformationEdge: - return DynamicDropAxisEdge( - input=self.output, - output=self.input, - name=name, - ) - - def validate_input_output(self, input: CoordSystem, output: CoordSystem) -> None: - - if not set(input.axes).issubset(output.axes): - # if input axes are not a subset of output axes - raise IncompatibleTransformationOutputCoordinateSystemError( - input_coordinate_system_name=input.name, - output_coordinate_system_name=output.name, - transformation_class_name=self.__class__.__name__, - ) - - -# ---------------------------------------------------------------------- -# DynamicDropAxisEdge – removes axes that must be zero -# ---------------------------------------------------------------------- -class DynamicDropAxisEdge(_DynamicAxisEdgeBase): - """Removes axes present in ``input`` but missing from ``output``.""" - - @property - def _created_axes(self) -> set[Axis]: - # This edge never creates new axes. - return set() - - @property - def _dropped_axes(self) -> set[Axis]: - # Axes in the input that are not present in the output are to be dropped. - return set(self.input.axes) - set(self.output.axes) - - def inverse(self, name: str | None = None) -> BaseTransformationEdge: - return DynamicCreateAxisEdge( - input=self.output, - output=self.input, - name=name, - ) - - def validate_input_output(self, input: CoordSystem, output: CoordSystem) -> None: - if not set(output.axes).issubset(input.axes): - # if output axes are not a subset of input axes - raise IncompatibleTransformationOutputCoordinateSystemError( - input_coordinate_system_name=input.name, - output_coordinate_system_name=output.name, - transformation_class_name=self.__class__.__name__, - ) diff --git a/tests/transformations/graph/edge/test_dynamic_axis_edges.py b/tests/transformations/graph/edge/test_dynamic_axis_edges.py deleted file mode 100644 index 4abd5257b..000000000 --- a/tests/transformations/graph/edge/test_dynamic_axis_edges.py +++ /dev/null @@ -1,122 +0,0 @@ -from __future__ import annotations - -import numpy as np -import pytest - -from spatialdata.transformations._graph import DynamicCreateAxisEdge, DynamicDropAxisEdge, NonZeroDroppedAxisError - - -# ---------------------------------------------------------------------- -# DynamicCreateAxisEdge – creation logic -# ---------------------------------------------------------------------- -def test_dynamic_create_axis_edge_no_created_axes(xyz_cs): - """When input == output, ``_created_axes`` is empty.""" - edge = DynamicCreateAxisEdge(input=xyz_cs, output=xyz_cs) - assert edge._created_axes == set() - - -def test_dynamic_create_axis_edge_single_created_axis(xy_cs, cyx_cs): - """When ``c`` is missing from the input, it is reported as a created axis.""" - edge = DynamicCreateAxisEdge(input=xy_cs, output=cyx_cs) - created = {ax.name for ax in edge._created_axes} - assert created == {"c"} - - -def test_dynamic_create_axis_edge_multiple_created_axes(xy_cs, czyx_cs): - """When ``a`` and ``b`` are missing from the input, both are reported.""" - edge = DynamicCreateAxisEdge(input=xy_cs, output=czyx_cs) - created = {ax.name for ax in edge._created_axes} - assert created == {"c", "z"} - - -def test_dynamic_create_axis_edge_inverse(xy_cs, czyx_cs): - """Inverse of a create edge must be a drop‑edge with swapped coordinate systems.""" - edge = DynamicCreateAxisEdge(input=xy_cs, output=czyx_cs) - inv = edge.inverse() - assert isinstance(inv, DynamicDropAxisEdge) - assert inv.input is edge.output - assert inv.output is edge.input - - -def test_dynamic_create_axis_edge_to_affine(xy_cs, xyc_cs): - """``to_affine`` must delegate to a temporary ``ProjectAxisEdge``.""" - edge = DynamicCreateAxisEdge(input=xy_cs, output=xyc_cs) - affine = edge.to_affine() - expected_proj_affine = edge._proj_edge().to_affine() - assert np.allclose(affine.linear, expected_proj_affine.linear) - assert np.allclose(affine.translation, expected_proj_affine.translation) - assert affine.input is edge.input - assert affine.output is edge.output - - -def test_dynamic_create_axis_edge_repr_contains_created_axes(xy_cs, xyc_cs): - """``repr`` must list the created axes and must not contain a drops‑section.""" - edge = DynamicCreateAxisEdge(input=xy_cs, output=xyc_cs) - rep = repr(edge) - assert "+ creates axes:" in rep - assert "c" in rep - assert "- drops axes:" not in rep - - -# ---------------------------------------------------------------------- -# DynamicDropAxisEdge – dropping logic -# ---------------------------------------------------------------------- -def test_dynamic_drop_axis_edge_no_dropped_axes(xyz_cs): - """When input == output, ``_dropped_axes`` is empty.""" - edge = DynamicDropAxisEdge(input=xyz_cs, output=xyz_cs) - assert edge._dropped_axes == set() - - -def test_dynamic_drop_axis_edge_single_dropped_axis(xyz_cs, xy_cs): - """When ``z`` is missing from the output, it is reported as a dropped axis.""" - edge = DynamicDropAxisEdge(input=xyz_cs, output=xy_cs) - dropped = {ax.name for ax in edge._dropped_axes} - assert dropped == {"z"} - - -def test_dynamic_drop_axis_edge_multiple_dropped_axes(czyx_cs, xy_cs): - """When ``a`` and ``b`` are missing from the output, both are reported.""" - edge = DynamicDropAxisEdge(input=czyx_cs, output=xy_cs) - dropped = {ax.name for ax in edge._dropped_axes} - assert dropped == {"c", "z"} - - -def test_dynamic_drop_axis_edge_inverse(zyx_cs, xy_cs): - """Inverse of a drop‑edge must be a create-edge with swapped coordinate systems.""" - edge = DynamicDropAxisEdge(input=zyx_cs, output=xy_cs) - inv = edge.inverse() - assert isinstance(inv, DynamicCreateAxisEdge) - assert inv.input is edge.output - assert inv.output is edge.input - - -def test_dynamic_drop_axis_edge_to_affine(xyz_cs, xy_cs): - """``to_affine`` of a drop‑edge must produce an affine with linear part whose shape - reflects the output‑by‑input dimensionality (2×3 in this case).""" - edge = DynamicDropAxisEdge(input=xyz_cs, output=xy_cs) - affine = edge.to_affine() - assert affine.linear.shape == (2, 3) # 2 output axes, 3 input axes - - -def test_dynamic_drop_axis_edge_pre_transform_check_passes(xyz_cs, xy_cs): - """Zero‑check passes when the dropped axis (z) contains only zeros.""" - points = np.array([[1, 2, 0], [3, 4, 0]], dtype=float) - edge = DynamicDropAxisEdge(input=xyz_cs, output=xy_cs) # drops ``z`` - edge.pre_transforming_check(points) # should not raise - - -def test_dynamic_drop_axis_edge_pre_transform_check_fails(xyz_cs, xy_cs): - """Zero‑check raises ``NonZeroDroppedAxisError`` when a dropped axis is non‑zero.""" - points = np.array([[1, 2, 5], [3, 4, 0]], dtype=float) - edge = DynamicDropAxisEdge(input=xyz_cs, output=xy_cs) # drops ``z`` - with pytest.raises(NonZeroDroppedAxisError): - edge.pre_transforming_check(points) - - -def test_dynamic_drop_axis_edge_repr_contains_dropped_axes(xyz_cs, xy_cs): - """``repr`` must list the dropped axes and must not contain a creates‑section.""" - edge = DynamicDropAxisEdge(input=xyz_cs, output=xy_cs) - rep = repr(edge) - assert "- drops axes:" in rep - assert "z" in rep - assert "+ creates axes:" not in rep From 7bcce04b1ce97ff37d2243c86dd22912fd0ff5f6 Mon Sep 17 00:00:00 2001 From: Ajayrama Kumaraswamy Date: Mon, 26 Oct 2026 17:01:34 +0100 Subject: [PATCH 50/54] fix (transformation graph): assorted minor fixes --- .../_graph/edges/ngff_aligned_edges.py | 10 +++--- .../transformations/_graph/utils.py | 15 +++++--- tests/transformations/graph/graph/conftest.py | 3 +- .../graph/utils/test_get_union_of_axes.py | 35 +++++++++++-------- 4 files changed, 38 insertions(+), 25 deletions(-) diff --git a/src/spatialdata/transformations/_graph/edges/ngff_aligned_edges.py b/src/spatialdata/transformations/_graph/edges/ngff_aligned_edges.py index 075ce380a..09ca9fb2e 100644 --- a/src/spatialdata/transformations/_graph/edges/ngff_aligned_edges.py +++ b/src/spatialdata/transformations/_graph/edges/ngff_aligned_edges.py @@ -375,16 +375,16 @@ def __init__( try: unmapped_inputs.remove(inp_ax) except KeyError as e: - raise MissingAxisError(axis_name=inp_ax.name, cs_name=self.input.name) from e + raise MissingAxisError(axis_name=inp_ax.name, cs_name=input.name) from e try: unmapped_outputs.remove(out_ax) except KeyError as e: - raise MissingAxisError(axis_name=out_ax.name, cs_name=self.output.name) from e + raise MissingAxisError(axis_name=out_ax.name, cs_name=output.name) from e for ax in unmapped_inputs: - raise UnmappedAxisError(axis_name=ax.name, cs_name=self.input.name) + raise UnmappedAxisError(axis_name=ax.name, cs_name=input.name) for ax in unmapped_outputs: - raise UnmappedAxisError(axis_name=ax.name, cs_name=self.output.name) + raise UnmappedAxisError(axis_name=ax.name, cs_name=output.name) self.input_to_output = input_to_output super().__init__(input=input, output=output, name=name) @@ -446,7 +446,7 @@ def __init__( ---------- dropped_inputs axes in `input` that will be dropped by this transformation - created_inputs + created_outputs axes in `output` that will be set to 0 Raises diff --git a/src/spatialdata/transformations/_graph/utils.py b/src/spatialdata/transformations/_graph/utils.py index 1337536b3..dfee835fd 100644 --- a/src/spatialdata/transformations/_graph/utils.py +++ b/src/spatialdata/transformations/_graph/utils.py @@ -193,6 +193,9 @@ def get_union_of_axes(axes: tuple[Axis, ...]) -> tuple[Axis, ...]: } ) + # Remove duplicates based on the logical key (name, type, unit, long_name). + df = df.drop_duplicates(subset=["name", "type", "unit", "long_name"], keep="first") + # Boolean columns that indicate the presence of a value. df["unit_not_none"] = df["unit"].notna() df["long_name_not_none"] = df["long_name"].notna() @@ -207,11 +210,15 @@ def get_union_of_axes(axes: tuple[Axis, ...]) -> tuple[Axis, ...]: "long_name_not_none", "long_name", ], - ascending=False, + ascending=[ + True, # NgffAxisType has CHANNEL first, SPACE next, so that we get channel axes before space axes + False, + False, # so that those with unit are placed before those without + False, + False, # so that those with long_name are placed before those without + False, + ], kind="mergesort", # stable sort to preserve order of equal keys ) - # Remove duplicates based on the logical key (name, type, unit, long_name). - df = df.drop_duplicates(subset=["name", "type", "unit", "long_name"], keep="first") - return tuple(df["axis"]) diff --git a/tests/transformations/graph/graph/conftest.py b/tests/transformations/graph/graph/conftest.py index 32d204f49..592b73df7 100644 --- a/tests/transformations/graph/graph/conftest.py +++ b/tests/transformations/graph/graph/conftest.py @@ -15,7 +15,8 @@ def get_coord_system(cs_name: str) -> CoordSystem: return CoordSystem( name=cs_name, - axes=[Axis(name="x", type="space", unit="micrometer"), Axis(name="y", type="space", unit="micrometer")], + axes=(Axis(name="x", type="space", unit="micrometer"), Axis(name="y", type="space", unit="micrometer")), + virtual=False, ) diff --git a/tests/transformations/graph/utils/test_get_union_of_axes.py b/tests/transformations/graph/utils/test_get_union_of_axes.py index 46ef840be..b5361fb88 100644 --- a/tests/transformations/graph/utils/test_get_union_of_axes.py +++ b/tests/transformations/graph/utils/test_get_union_of_axes.py @@ -22,11 +22,11 @@ # three pure spatial axes (("axis_x_space", "axis_y_space", "axis_z_space"), ("z", "y", "x")), # channel + full 3‑D spatial axes - (("axis_c_channel", "axis_x_space", "axis_y_space", "axis_z_space"), ("z", "y", "x", "c")), + (("axis_c_channel", "axis_x_space", "axis_y_space", "axis_z_space"), ("c", "z", "y", "x")), # mixed groups: xy + full 3‑D (duplicates should be removed) (("axes_xy", "axes_zyx"), ("z", "y", "x")), # mixed groups: cyx + full 3‑D (duplicates removed, c kept) - (("axes_cyx", "axes_zyx"), ("z", "y", "x", "c")), + (("axes_cyx", "axes_zyx"), ("c", "z", "y", "x")), ], ids=[ "basic_spatial", @@ -35,9 +35,9 @@ "cyx_plus_zyx", ], ) -def test_reverse_alphabetical_order(axes_fixture_names, expected_names, request): +def test_ordering(axes_fixture_names, expected_names, request): """ - Verify reverse‑alphabetical ordering of the resulting axis names. + Verify ordering of the resulting axis names. The fixture names are resolved to the actual ``Axis`` objects. """ # Resolve each name (whether a single Axis fixture or a composite tuple fixture) @@ -78,11 +78,11 @@ def test_same_name_different_type(axis_x_space): result = get_union_of_axes((axis_x_space, axis_x_channel)) assert len(result) == 2 # Reverse alphabetical: spatial ``x`` comes before channel ``x`` - assert result == (axis_x_space, axis_x_channel) + assert result == (axis_x_channel, axis_x_space) # ---------------------------------------------------------------------- -# 4. Prefer non‑None ``unit`` over ``None`` +# 4. Place axis with unit before an equivalent axis without unit # ---------------------------------------------------------------------- def test_prefer_non_none_unit(axis_x_space, axis_x_space_with_unit): """ @@ -90,12 +90,12 @@ def test_prefer_non_none_unit(axis_x_space, axis_x_space_with_unit): the latter must survive duplicate removal. """ result = get_union_of_axes((axis_x_space, axis_x_space_with_unit)) - assert len(result) == 1 + assert len(result) == 2 assert result[0].unit == "micrometer" # ---------------------------------------------------------------------- -# 5. Prefer non‑None ``long_name`` over ``None`` +# 5. Place axis with long_name before an equivalent axis without long_name # ---------------------------------------------------------------------- def test_prefer_non_none_long_name( axis_x_space, @@ -105,7 +105,7 @@ def test_prefer_non_none_long_name( When only one duplicate defines ``long_name``, that value must be kept. """ result = get_union_of_axes((axis_x_space, axis_x_space_with_long_name)) - assert len(result) == 1 + assert len(result) == 2 assert result[0].long_name == "X axis" @@ -122,13 +122,15 @@ def test_unit_beats_long_name( duplicate defines a long name. """ result = get_union_of_axes((axis_x_space_with_long_name, axis_x_space_with_unit)) - assert len(result) == 1 + assert len(result) == 2 assert result[0].unit == "micrometer" assert result[0].long_name is None + assert result[1].unit is None + assert result[1].long_name == "X axis" # ---------------------------------------------------------------------- -# 7. Full hierarchy – axis with both optional fields wins +# 7. Full hierarchy – axis with both optional fields is placed first # ---------------------------------------------------------------------- def test_full_hierarchy( axis_x_space_full, @@ -143,10 +145,13 @@ def test_full_hierarchy( The most complete axis must be kept. """ result = get_union_of_axes((axis_x_space, axis_x_space_with_unit, axis_x_space_full)) - assert len(result) == 1 - kept = result[0] - assert kept.unit == "micrometer" - assert kept.long_name == "X axis" + assert len(result) == 3 + assert result[0].unit == "micrometer" + assert result[0].long_name == "X axis" + assert result[1].unit == "micrometer" + assert result[1].long_name is None + assert result[2].unit is None + assert result[2].long_name is None # ---------------------------------------------------------------------- From 6aabf6a4f870c5d988f4de52287b8a714017dc72 Mon Sep 17 00:00:00 2001 From: Ajayrama Kumaraswamy Date: Tue, 27 Oct 2026 12:47:38 +0100 Subject: [PATCH 51/54] feat (spatialdata.__init__): coordinate systems and edges added based on bags of elements --- src/spatialdata/_core/spatialdata.py | 101 ++++++++-- .../transformations_facade.py | 178 ++++++++++++----- src/spatialdata/models/types.py | 12 +- .../transformations/_graph/graph.py | 135 +++++++++---- .../transformations/transformations.py | 180 ++++++++++++------ .../graph/graph/blobs_dataset_related.py | 37 ++++ .../transformations/graph/graph/test_graph.py | 53 +++--- 7 files changed, 510 insertions(+), 186 deletions(-) create mode 100644 tests/transformations/graph/graph/blobs_dataset_related.py diff --git a/src/spatialdata/_core/spatialdata.py b/src/spatialdata/_core/spatialdata.py index e52b8ad20..be101f5b5 100644 --- a/src/spatialdata/_core/spatialdata.py +++ b/src/spatialdata/_core/spatialdata.py @@ -4,7 +4,7 @@ import json import os import warnings -from collections.abc import Generator, Mapping +from collections.abc import Generator, Iterable, Mapping from itertools import chain from pathlib import Path from typing import TYPE_CHECKING, Any, Literal, cast @@ -58,7 +58,6 @@ SpatialDataContainerFormatType, SpatialDataFormatType, ) - from spatialdata.transformations._graph import BaseTransformationEdge class SpatialData: @@ -178,10 +177,55 @@ def __init__( for element_set in (images, labels, shapes, points): if element_set is not None: for element_key, element in element_set.items(): - self._initialize_coordinate_transforms_from_element_attrs( + self.transformations._add_new_element_to_graph_creating_default_cs( new_element=element, new_element_key=element_key ) + all_element_dict = (images or {}) | (labels or {}) | (shapes or {}) | (points or {}) + elements_grouped_by_transformations = self.group_elements_by_target_cs(all_element_dict.keys()) + + for target_cs_name, element_name_transformation_config in elements_grouped_by_transformations.items(): + self.transformations.set_transformations_from_elements_to_common_cs( + target_cs_name=target_cs_name, + element_name_transformation_config=element_name_transformation_config, + ) + + def group_elements_by_target_cs( + self, spatial_element_names: Iterable[str] + ) -> dict[str, dict[str, BaseTransformation]]: + """ + Groups SpatialElement objects by target coordinate systems + + Based on the transformation information in their .attrs + + Parameters + ---------- + spatial_element_names + names of SpatialElement objects + + Returns + ------- + dictionary with target coordinate systems as keys and element-wise transformation information as values. + These values are themselves dictionaries with element_names as keys and transformation objects mapping + element to the corresponding target coordinate system as values + """ + spatial_elements_grouped_by_transformations: dict[str, dict[str, BaseTransformation]] = {} + for element_name in spatial_element_names: + element = self.get(element_name) + if (element is not None) and isinstance(element, SpatialElement): + transformations_config = get_transformation(element, get_all=True) + for target_cs_name, transformation_object in transformations_config.items(): + if target_cs_name in spatial_elements_grouped_by_transformations: + spatial_elements_grouped_by_transformations[target_cs_name][element_name] = ( + transformation_object + ) + else: + spatial_elements_grouped_by_transformations[target_cs_name] = { + element_name: transformation_object + } + + return spatial_elements_grouped_by_transformations + def validate_table_in_spatialdata(self, table: AnnData) -> None: """ Validate the presence of the annotation target of a SpatialData table in the SpatialData object. @@ -747,18 +791,6 @@ def _filter_tables( return tables - def _initialize_coordinate_transforms_from_element_attrs( - self, new_element: SpatialElement, new_element_key: str - ) -> list[BaseTransformationEdge]: - transforms: dict[str, BaseTransformation] = get_transformation(new_element, get_all=True) - - self.transformations._add_new_element_to_graph_creating_default_cs( - new_element=new_element, new_element_key=new_element_key - ) - return self.transformations.set_element_transformations( - element_name=new_element_key, transformations_config=transforms - ) - def rename_coordinate_systems(self, rename_dict: dict[str, str]) -> None: """ Rename coordinate systems. @@ -2390,6 +2422,12 @@ def _find_element(self, element_name: str) -> tuple[str, str, SpatialElement | A return found[0] + def assert_element_does_not_exist(self, element_name: str) -> None: + """Raises a key error if an element with name `element_name` already exists.""" + existing_element_names = [x[1] for x in self.gen_elements()] + if element_name in existing_element_names: + raise KeyError(f"Element with name '{element_name}' already exists") + @classmethod def init_from_elements( cls, @@ -2559,20 +2597,44 @@ def __setitem__(self, key: str, value: SpatialElement | AnnData) -> None: schema = get_model(value) if schema in (Image2DModel, Image3DModel): assert isinstance(value, DataArray | DataTree) + self.images[key] = value - self._initialize_coordinate_transforms_from_element_attrs(new_element=value, new_element_key=key) + # this will throw KeyError if we try to assign an element of different type, but with the same name as an + # existing spatial element; will overwrite if it is the same type + self.transformations._remove_element_from_graph_if_exists(key) + self.transformations._add_new_element_to_graph_creating_default_cs(new_element=value, new_element_key=key) + # TODO add transformations from element attrs to graph + elif schema in (Labels2DModel, Labels3DModel): assert isinstance(value, DataArray | DataTree) + self.labels[key] = value - self._initialize_coordinate_transforms_from_element_attrs(new_element=value, new_element_key=key) + # this will throw KeyError if we try to assign an element of different type, but with the same name as an + # existing spatial element; will overwrite if it is the same type + self.transformations._remove_element_from_graph_if_exists(key) + self.transformations._add_new_element_to_graph_creating_default_cs(new_element=value, new_element_key=key) + # TODO add transformations from element attrs to graph + elif schema == PointsModel: assert isinstance(value, DaskDataFrame) + self.points[key] = value - self._initialize_coordinate_transforms_from_element_attrs(new_element=value, new_element_key=key) + # this will throw KeyError if we try to assign an element of different type, but with the same name as an + # existing spatial element; will overwrite if it is the same type + self.transformations._remove_element_from_graph_if_exists(key) + self.transformations._add_new_element_to_graph_creating_default_cs(new_element=value, new_element_key=key) + # TODO add transformations from element attrs to graph + elif schema == ShapesModel: assert isinstance(value, GeoDataFrame) + self.shapes[key] = value - self._initialize_coordinate_transforms_from_element_attrs(new_element=value, new_element_key=key) + # this will throw KeyError if we try to assign an element of different type, but with the same name as an + # existing spatial element; will overwrite if it is the same type + self.transformations._remove_element_from_graph_if_exists(key) + self.transformations._add_new_element_to_graph_creating_default_cs(new_element=value, new_element_key=key) + # TODO add transformations from element attrs to graph + elif schema == TableModel: assert isinstance(value, AnnData) self.validate_table_in_spatialdata(value) @@ -2592,6 +2654,7 @@ def __delitem__(self, key: str) -> None: """ element_type, _, _ = self._find_element(key) getattr(self, element_type).__delitem__(key) + self.transformations._remove_element_from_graph_if_exists(key) @property def attrs(self) -> dict[str, JSONValue]: diff --git a/src/spatialdata/_core/transformation_manager/transformations_facade.py b/src/spatialdata/_core/transformation_manager/transformations_facade.py index 7ed958a2d..4ca9fa5a2 100644 --- a/src/spatialdata/_core/transformation_manager/transformations_facade.py +++ b/src/spatialdata/_core/transformation_manager/transformations_facade.py @@ -1,17 +1,20 @@ from __future__ import annotations from collections.abc import Sequence +from itertools import chain from spatialdata.models import SpatialElement from spatialdata.transformations import BaseTransformation from spatialdata.transformations._graph import ( BaseTransformationEdge, CoordinateSystemNotFoundError, + CoordSystem, + ElementNotRegisteredToAnyCoordinateSystemError, + ProjectAxisEdge, TransformationGraph, get_instrinsic_coordinate_system, ) -from spatialdata.transformations._graph.exceptions import IncompatibleTransformationOutputCoordinateSystemError -from spatialdata.transformations._graph.utils import get_intrinsic_coordinate_system_name +from spatialdata.transformations._graph.utils import get_intrinsic_coordinate_system_name, get_union_of_axes class TransformationsFacade: @@ -21,7 +24,7 @@ def __init__(self) -> None: def _get_element_transformation_edges( self, element_name: str, target_coordinate_system_names: Sequence[str] | None = None - ) -> list[BaseTransformationEdge]: + ) -> dict[str, BaseTransformationEdge]: """ Get the NGFF transformation edges associated with an element. @@ -34,7 +37,7 @@ def _get_element_transformation_edges( Returns ------- - List of transformation edges for the element. + dict, with edge keys as keys and transformations from the element's coordinate system as values. """ return self._graph.get_outgoing_edges(element_name, target_coordinate_system_names) @@ -57,74 +60,161 @@ def _add_new_element_to_graph_creating_default_cs(self, new_element: SpatialElem self._graph.add_coordinate_system(element_cs) self._graph.add_element(element_name=new_element_key, coordinate_system=element_cs) - def set_element_transformations( - self, - element_name: str, - transformations_config: dict[str, BaseTransformation], - ) -> list[BaseTransformationEdge]: + def _remove_element_from_graph_if_exists(self, element_name: str) -> None: """ - Set transformations for an element to specified target coordinate systems + Remove element from the graph if it exists, do nothing otherwise - This is done by adding corresponding coordinate systems and transformation edges to the graph + when removing also removes all incoming and outgoing edges from the coordinate system to which the + element belongs, then the coordinate system itself as well as the element-coordinate system mapping Parameters ---------- element_name - The name of the element. - transformations_config - Dictionary mapping target coordinate system names to (spatialdata) transformations. + The name of the element to remove. + """ + try: + element_coordinate_system = self._graph.get_element_coordinate_system(element_name) + except ElementNotRegisteredToAnyCoordinateSystemError as _err: + return # element does not exist, do nothing - Returns - ------- - List of NGFF transformation edges that were created and added to the graph. + number_of_elements_in_element_cs = len( + self._graph.get_elements_in_coordinate_system(element_coordinate_system.name) + ) + + if number_of_elements_in_element_cs == 1: + for incoming_edge_key, incoming_edge in self._graph.get_incoming_edges(element_name).items(): + self._graph.remove_specific_transformation(incoming_edge_key, incoming_edge) + + for outgoing_edge_key, outgoing_edge in self._graph.get_outgoing_edges(element_name).items(): + self._graph.remove_specific_transformation(outgoing_edge_key, outgoing_edge) + + self._graph._unset_element(element_name) + self._graph.remove_coordinate_system(element_coordinate_system) + else: + self._graph._unset_element(element_name) + + def set_transformations_from_elements_to_common_cs( + self, + target_cs_name: str, + element_name_transformation_config: dict[str, BaseTransformation], + make_target_cs_virtual_when_creating: bool = False, + ) -> None: """ - element_cs = self._graph.get_element_coordinate_system(element_name) + Infer and add the transformation edges needed to align a group of spatial elements to a common coordinate system - transformation_edges = [] - for output_coordinate_system_name, t in transformations_config.items(): - ngff_transformation_edge = t._to_ngff_transformation_edge( - input_coordinate_system=element_cs, output_coordinate_system_name=output_coordinate_system_name + In addition to the transformations specified, adds project axis edges as needed. + + Parameters + ---------- + target_cs_name + Name of the target coordinate system. Will be created if it doesn't exist. + element_name_transformation_config + Dictionary mapping names of spatial elements to transformations that map the corresponding element to + the target coordinate system. + make_target_cs_virtual_when_creating + whether to make target coordinate system virtual, if and when it gets created. + """ + intermediate_edge_list: list[BaseTransformationEdge] = [] + for element_name, transformation in element_name_transformation_config.items(): + element_cs = self._graph.get_element_coordinate_system(element_name) + intermediate_edge = transformation._to_ngff_transformation_edge_creating_output_cs( + input_coordinate_system=element_cs, + output_coordinate_system_name=f"{target_cs_name}_{element_name}", + output_coordinate_system_virtual=True, ) - possible_coordinate_system = self._graph.get_coordinate_system(output_coordinate_system_name) - possible_coordinate_system_exists = possible_coordinate_system is not None - if not possible_coordinate_system_exists: - self._graph.add_coordinate_system(ngff_transformation_edge.output) - else: - if possible_coordinate_system != ngff_transformation_edge.output: - raise IncompatibleTransformationOutputCoordinateSystemError( - input_coordinate_system_name=element_cs.name, - output_coordinate_system_name=output_coordinate_system_name, - transformation_class_name=t.__class__.__name__, + # if a project axis edge is needed for this element, this edge can be used below + # hence it has a virtual output cs with a specially encoded name. + intermediate_edge_list.append(intermediate_edge) + + expected_output_axes_flat = tuple(chain.from_iterable(x.output.axes for x in intermediate_edge_list)) + # basically flattens a tuple[tuple[T, ...], ...] into tuple[T] + expected_output_axes_union = get_union_of_axes(expected_output_axes_flat) + + target_cs = self._graph.get_coordinate_system(target_cs_name) + if target_cs is None: + new_target_cs = CoordSystem( + name=target_cs_name, axes=expected_output_axes_union, virtual=make_target_cs_virtual_when_creating + ) + self._graph.add_coordinate_system(new_target_cs) + else: + new_target_cs_axes = get_union_of_axes(target_cs.axes + expected_output_axes_union) + created_outputs = set(new_target_cs_axes) - set(target_cs.axes) + if created_outputs: + new_target_cs = CoordSystem( + name=target_cs_name, + axes=new_target_cs_axes, + virtual=target_cs.virtual, + # same "bag" of coordinate systems, so inherits this attribute + ) + self._graph.add_coordinate_system(new_target_cs) + self._graph.add_transformation_edge( + ProjectAxisEdge( + input=target_cs, output=new_target_cs, created_outputs=created_outputs, dropped_inputs=set() ) - self._graph.add_transformation_edge(ngff_transformation_edge) - transformation_edges.append(ngff_transformation_edge) - - return transformation_edges - - def remove_transformations(self, transformation_edges: list[BaseTransformationEdge]) -> None: + ) + else: + new_target_cs = target_cs + + for ind, (_element_name, transformation) in enumerate(element_name_transformation_config.items()): + created_outputs = set(new_target_cs.axes) - set(intermediate_edge_list[ind].output.axes) + + if created_outputs: + self._graph.add_coordinate_system(intermediate_edge_list[ind].output) + self._graph.add_transformation_edge(intermediate_edge_list[ind]) + # create intermediate virtual cs, create and add edge between element and this virtual cs + + project_axis_edge = ProjectAxisEdge( + input=intermediate_edge_list[ind].output, + output=new_target_cs, + created_outputs=created_outputs, + dropped_inputs=set(), + ) + self._graph.add_transformation_edge(project_axis_edge) + # add an extra projection edge from virtual cs to new_target_cs + else: + transformation_edge = transformation._to_ngff_transformation_edge_existing_output_cs( + input_coordinate_system=intermediate_edge_list[ind].input, + output_coordinate_system=new_target_cs, + ) + self._graph.add_transformation_edge(transformation_edge) + # create and add edge based on element_name and transformation to new_target_cs + + def remove_transformations(self, transformation_edges: dict[str, BaseTransformationEdge]) -> None: """ Remove specific transformation edges from the graph. Parameters ---------- transformation_edges - List of transformation edges to remove. + dictionary with edge keys as keys and edges as values; those to be removed """ - for transformation_edge in transformation_edges: - self._graph.remove_specific_transformation(transformation_edge) + for edge_key, transformation_edge in transformation_edges.items(): + self._graph.remove_specific_transformation(edge_key, transformation_edge) - def remove_all_transformations_of_element(self, element_name: str) -> None: + def remove_all_outgoing_transformations_of_element(self, element_name: str) -> None: """ - Remove all transformation edges associated with an element. + Remove all outgoing transformation edges of an element. Parameters ---------- element_name The name of the element whose transformations should be removed. """ - all_outgoing_edges_from_element = self._get_element_transformation_edges(element_name) + all_outgoing_edges_from_element = self._graph.get_outgoing_edges(element_name) self.remove_transformations(all_outgoing_edges_from_element) + def remove_all_incoming_transformations_of_element(self, element_name: str) -> None: + """ + Remove all incoming transformation edges of an element. + + Parameters + ---------- + element_name + The name of the element whose transformations should be removed. + """ + all_incoming_edges_from_element = self._graph.get_incoming_edges(element_name) + self.remove_transformations(all_incoming_edges_from_element) + def remove_all_transformations_to_coordinate_system(self, coordinate_system_name: str) -> None: """ Remove all transformation edges that target a specific coordinate system. diff --git a/src/spatialdata/models/types.py b/src/spatialdata/models/types.py index 62397041b..f5ceb8e9d 100644 --- a/src/spatialdata/models/types.py +++ b/src/spatialdata/models/types.py @@ -1,14 +1,10 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from dask.dataframe import DataFrame as DaskDataFrame +from geopandas import GeoDataFrame +from xarray import DataArray, DataTree from spatialdata.transformations import BaseTransformation -if TYPE_CHECKING: - from dask.dataframe import DataFrame as DaskDataFrame - from geopandas import GeoDataFrame - from xarray import DataArray, DataTree - - -type SpatialElement = DataArray | DataTree | GeoDataFrame | DaskDataFrame +SpatialElement = DataArray | DataTree | GeoDataFrame | DaskDataFrame MappingToCoordinateSystem_t = dict[str, BaseTransformation] diff --git a/src/spatialdata/transformations/_graph/graph.py b/src/spatialdata/transformations/_graph/graph.py index b882df550..182d20c74 100644 --- a/src/spatialdata/transformations/_graph/graph.py +++ b/src/spatialdata/transformations/_graph/graph.py @@ -1,5 +1,7 @@ from __future__ import annotations +import hashlib +import uuid from collections.abc import Sequence from typing import Any, override @@ -210,6 +212,22 @@ def get_coordinate_system(self, cs_name: str) -> CoordSystem | None: return None + def get_elements_in_coordinate_system(self, cs_name: str) -> list[str]: + """ + Returns a list of element names that belong to this coordinate system + + Parameters + ---------- + cs_name + Name of a coordinate system + + Returns + ---- + list of element names + """ + + return [element_name for element_name, cs in self._element_to_cs_mapping.items() if cs.name == cs_name] + def remove_coordinate_system(self, cs: CoordSystem) -> None: """ Remove a coordinate system from the transformation manager. @@ -289,8 +307,8 @@ def get_element_coordinate_system(self, element_name: str) -> CoordSystem: return self._element_to_cs_mapping[element_name] def get_outgoing_edges( - self, element_name: str, target_coordinate_system_names: Sequence[str] | None - ) -> list[BaseTransformationEdge]: + self, element_name: str, target_coordinate_system_names: Sequence[str] | None = None + ) -> dict[str, BaseTransformationEdge]: """ Get outgoing transformation edges from an element's coordinate system. @@ -303,22 +321,55 @@ def get_outgoing_edges( Returns ------- - List of outgoing transformation edges from the element's coordinate system. + dict, with edge keys as keys and outgoing transformation edges from the element's coordinate system as values. """ element_coordinate_system = self.get_element_coordinate_system(element_name) - outgoing_edges: list[BaseTransformationEdge] = [] + outgoing_edges: dict[str, BaseTransformationEdge] = {} successors_to_consider = list(self._graph.successors(element_coordinate_system)) if target_coordinate_system_names is not None: successors_to_consider = [x for x in successors_to_consider if x.name in target_coordinate_system_names] for successor_coordinate_system in successors_to_consider: - edge_data = self._graph[element_coordinate_system][successor_coordinate_system] - outgoing_edge_this_successor = [x[TRANSFORM_KEY] for x in edge_data.values()] - outgoing_edges += outgoing_edge_this_successor + outgoing_edge_this_successor = self.get_direct_transformations( + element_coordinate_system, successor_coordinate_system + ) + outgoing_edges |= outgoing_edge_this_successor return outgoing_edges + def get_incoming_edges( + self, element_name: str, source_coordinate_system_names: Sequence[str] | None = None + ) -> dict[str, BaseTransformationEdge]: + """ + Get incoming transformation edges to an element's coordinate system. + + Parameters + ---------- + element_name + The name of the element. + source_coordinate_system_names + Optional list of source coordinate system names to filter by. + + Returns + ------- + dict, with edge keys as keys and transformations from the element's coordinate system as values. + """ + element_coordinate_system = self.get_element_coordinate_system(element_name) + incoming_edges: dict[str, BaseTransformationEdge] = {} + + predecessors_to_consider = list(self._graph.successors(element_coordinate_system)) + if source_coordinate_system_names is not None: + predecessors_to_consider = [x for x in predecessors_to_consider if x.name in source_coordinate_system_names] + + for predecessor_coordinate_system in predecessors_to_consider: + incoming_edge_this_successor = self.get_direct_transformations( + predecessor_coordinate_system, element_coordinate_system + ) + incoming_edges |= incoming_edge_this_successor + + return incoming_edges + def _unset_element(self, element_name: str) -> None: """ Unregister an element from the coordinate system to which it belongs. @@ -337,9 +388,16 @@ def _unset_element(self, element_name: str) -> None: del self._element_to_cs_mapping[element_name] @staticmethod - def _get_edge_key(edge: BaseTransformationEdge) -> str: + def _calc_edge_key(edge: BaseTransformationEdge) -> str: """ - Encode the definition of an edge into a str to be used as graph-wide edge identifier. + Encode attributes of an edge into a string to be used as graph-wide edge identifier. + + The string consists of a deterministic part -- {input cs name}->{output cs name}:{edge class name}, followed + by a random part. + + It is better to not make edge key a deterministic function of the transformation edge (e.g., using id()), + because, when the transformation graph gets deep copied, the transformation edge objects will be recreated, + but the edge keys will just be copied, leading to inconsistencies. Parameters ---------- @@ -351,12 +409,15 @@ def _get_edge_key(edge: BaseTransformationEdge) -> str: edge_key str, the encoded edge identifier. """ - return str(id(edge)) + base = f"{edge.input.name}->{edge.output.name}:{type(edge).__name__}" + salt = uuid.uuid4().hex + suffix = hashlib.sha256((base + salt).encode()).hexdigest()[:12] # short hash + return f"{base}-{suffix}" def add_transformation_edge( self, transformation_edge: BaseTransformationEdge, - ) -> None: + ) -> str: """ Add an edge representing a transformation between coordinate systems. @@ -365,6 +426,11 @@ def add_transformation_edge( transformation_edge The transformation edge to add. + Returns + ------- + edge_key + str, the encoded edge identifier. + Raises ------ CoordinateSystemNotFoundError @@ -374,13 +440,15 @@ def add_transformation_edge( self.assert_coordinate_system_exists(source_cs) self.assert_coordinate_system_exists(target_cs) - edge_key = self._get_edge_key(transformation_edge) + edge_key = self._calc_edge_key(transformation_edge) edge_attributes = {TRANSFORM_KEY: transformation_edge} self._graph.add_edge(source_cs, target_cs, key=edge_key, **edge_attributes) + return edge_key + def get_direct_transformations( self, source_cs: CoordSystem, target_cs: CoordSystem - ) -> list[BaseTransformationEdge]: + ) -> dict[str, BaseTransformationEdge]: """ Retrieve transformations directly defined between coordinate systems. @@ -393,7 +461,7 @@ def get_direct_transformations( Returns ------- - List of transformations (empty if there are no direct transformations). + dict, with edge keys as keys and transformations as values (empty if there are no direct transformations). Raises ------ @@ -403,15 +471,16 @@ def get_direct_transformations( self.assert_coordinate_system_exists(source_cs) self.assert_coordinate_system_exists(target_cs) - transforms = [] + transforms = {} if self._graph.has_edge(source_cs, target_cs): - for edge_data in self._graph[source_cs][target_cs].values(): + for edge_key, edge_data in self._graph[source_cs][target_cs].items(): transform: BaseTransformationEdge = edge_data[TRANSFORM_KEY] - transforms.append(transform) + transforms[edge_key] = transform return transforms def remove_specific_transformation( self, + edge_key: str, transformation_edge: BaseTransformationEdge, ) -> None: """ @@ -419,6 +488,8 @@ def remove_specific_transformation( Parameters ---------- + edge_key + unique id of the edge transformation_edge The transformation edge to remove. @@ -430,10 +501,9 @@ def remove_specific_transformation( If the transformation does not exist. """ source_cs, target_cs = transformation_edge.input, transformation_edge.output - expected_edge_key = self._get_edge_key(transformation_edge) - self.assert_an_edge_exists_between_coordinate_systems(source_cs, target_cs, edge_key=expected_edge_key) + self.assert_an_edge_exists_between_coordinate_systems(source_cs, target_cs, edge_key=edge_key) # also checks if source_cs and target_cs exist - self._graph.remove_edge(source_cs, target_cs, key=expected_edge_key) + self._graph.remove_edge(source_cs, target_cs, key=edge_key) def remove_all_transformations_between_coordinate_systems( self, @@ -470,7 +540,7 @@ def remove_all_transformations_between_coordinate_systems( def _get_transformation_sequences_from_simple_paths_after_disambiguation( self, paths: list[list[CoordSystem]], - expected_intermediate_edges: Sequence[BaseTransformationEdge] | None, + expected_intermediate_edge_keys: Sequence[str] | None, ) -> list[SequenceEdge]: """ Traverses paths to form sequence of Transformations. @@ -481,7 +551,7 @@ def _get_transformation_sequences_from_simple_paths_after_disambiguation( ---------- paths Sequence of list of nodes. - expected_intermediate_edges + expected_intermediate_edge_keys List of edges, for use when multiple edges are found between two coordinate systems in a path. Returns @@ -498,9 +568,8 @@ def _get_transformation_sequences_from_simple_paths_after_disambiguation( raise TransformationPathNotSimple([x.name for x in path]) expected_intermediate_transformation_edge_keys = set[str]() - if expected_intermediate_edges is not None: - for edge_def in expected_intermediate_edges: - edge_key = self._get_edge_key(edge_def) + if expected_intermediate_edge_keys is not None: + for edge_key in expected_intermediate_edge_keys: expected_intermediate_transformation_edge_keys |= {edge_key} all_sequences = [] @@ -541,7 +610,7 @@ def get_all_shortest_transformation_sequences( self, source_cs: CoordSystem, target_cs: CoordSystem, - expected_intermediate_edges: Sequence[BaseTransformationEdge] = (), + expected_intermediate_edge_keys: Sequence[str] = (), ) -> list[SequenceEdge]: """ Get all shortest sequences of transformations between two coordinate systems. @@ -552,8 +621,8 @@ def get_all_shortest_transformation_sequences( The source coordinate system. target_cs The target coordinate system. - expected_intermediate_edges - List of intermediate edges expected. + expected_intermediate_edge_keys + List of keys of intermediate edges. Used to choose an edge when multiple edges are found between the same coordinate systems. Returns @@ -580,7 +649,7 @@ def get_all_shortest_transformation_sequences( try: return self._get_transformation_sequences_from_simple_paths_after_disambiguation( - paths, expected_intermediate_edges + paths, expected_intermediate_edge_keys ) except TransformationPathAmbiguousError as tpae: raise TransformationPathAmbiguousError(source_cs.name, target_cs.name) from tpae @@ -589,7 +658,7 @@ def get_all_transformation_sequences( self, source_cs: CoordSystem, target_cs: CoordSystem, - expected_intermediate_edges: Sequence[BaseTransformationEdge] = (), + expected_intermediate_edge_keys: Sequence[str] = (), ) -> list[SequenceEdge]: """ Get all existing sequences of transformations between two coordinate systems. @@ -600,8 +669,8 @@ def get_all_transformation_sequences( The source coordinate system. target_cs The target coordinate system. - expected_intermediate_edges - List of intermediate edges expected. + expected_intermediate_edge_keys + List of keys of intermediate edges expected. Used to choose an edge when multiple edges are found between the same coordinate systems. Returns @@ -628,7 +697,7 @@ def get_all_transformation_sequences( try: return self._get_transformation_sequences_from_simple_paths_after_disambiguation( - paths, expected_intermediate_edges + paths, expected_intermediate_edge_keys ) except TransformationPathAmbiguousError as tpae: raise tpae from TransformationPathAmbiguousError(source_cs.name, target_cs.name) diff --git a/src/spatialdata/transformations/transformations.py b/src/spatialdata/transformations/transformations.py index 56c5b191d..76911f1d1 100644 --- a/src/spatialdata/transformations/transformations.py +++ b/src/spatialdata/transformations/transformations.py @@ -25,6 +25,8 @@ TranslationEdge, UnmappedAxisError, ) +from spatialdata.transformations._graph.exceptions import IncompatibleTransformationOutputCoordinateSystemError +from spatialdata.transformations._graph.literals import DEFAULT_COORDINATE_SYSTEM from spatialdata.transformations.ngff._utils import get_default_coordinate_system from spatialdata.transformations.ngff.ngff_coordinate_system import NgffCoordinateSystem, _get_spatial_axes from spatialdata.transformations.ngff.ngff_transformations import ( @@ -40,7 +42,6 @@ if TYPE_CHECKING: from spatialdata._types import ArrayLike, ListOrNDArrayFloating from spatialdata.models import SpatialElement - from spatialdata.transformations._graph.literals import DEFAULT_COORDINATE_SYSTEM from spatialdata.transformations._graph.types import ValidAxis_t TRANSFORMATIONS_MAP: dict[type[NgffBaseTransformation], type[BaseTransformation]] = {} @@ -119,15 +120,15 @@ def to_ngff( ) -> NgffBaseTransformation: pass - @abstractmethod - def _to_ngff_transformation_edge( + def _to_ngff_transformation_edge_creating_output_cs( self, input_coordinate_system: CoordSystem, output_coordinate_system_name: str, + output_coordinate_system_virtual: bool = False, transformation_edge_name: str | None = None, ) -> BaseTransformationEdge: """ - Convert the transformation to an NGFF transformation edge. + Convert the transformation to an NGFF transformation edge, creating its output coordinate system Parameters ---------- @@ -135,6 +136,8 @@ def _to_ngff_transformation_edge( The input coordinate system. output_coordinate_system_name The name for the output coordinate system. + output_coordinate_system_virtual + Whether the output coordinate system is virtual transformation_edge_name Optional name for the transformation edge. @@ -142,10 +145,78 @@ def _to_ngff_transformation_edge( ------- A subclass of BaseTransformationEdge representing an equivalent NGFF coordinate transformation. """ + + output_coordinate_system = self._get_ngff_output_coordinate_system( + input_coordinate_system=input_coordinate_system, + output_coordinate_system_name=output_coordinate_system_name, + output_coordinate_system_virtual=output_coordinate_system_virtual, + ) + + return self._define_ngff_transformation_edge_given_valid_output_cs( + input_coordinate_system=input_coordinate_system, + output_coordinate_system=output_coordinate_system, + transformation_edge_name=transformation_edge_name, + ) + + @abstractmethod + def _define_ngff_transformation_edge_given_valid_output_cs( + self, + input_coordinate_system: CoordSystem, + output_coordinate_system: CoordSystem, + transformation_edge_name: str | None = None, + ) -> BaseTransformationEdge: + pass + def _to_ngff_transformation_edge_existing_output_cs( + self, + input_coordinate_system: CoordSystem, + output_coordinate_system: CoordSystem, + transformation_edge_name: str | None = None, + ) -> BaseTransformationEdge: + """ + Convert the transformation to an NGFF transformation edge, creating its output coordinate system + + Parameters + ---------- + input_coordinate_system + The input coordinate system. + output_coordinate_system + Existing coordinate system to use as output + transformation_edge_name + Optional name for the transformation edge. + + Returns + ------- + A subclass of BaseTransformationEdge representing an equivalent NGFF coordinate transformation. + """ + + self.assert_input_output_cs_compatible(input_cs=input_coordinate_system, output_cs=output_coordinate_system) + + return self._define_ngff_transformation_edge_given_valid_output_cs( + input_coordinate_system=input_coordinate_system, + output_coordinate_system=output_coordinate_system, + ) + + def assert_input_output_cs_compatible(self, input_cs: CoordSystem, output_cs: CoordSystem) -> None: + + expected_cs = self._get_ngff_output_coordinate_system( + input_coordinate_system=input_cs, + output_coordinate_system_name="dummy", + ) + + if set(expected_cs.axes) != set(output_cs.axes): + raise IncompatibleTransformationOutputCoordinateSystemError( + input_coordinate_system_name=input_cs.name, + output_coordinate_system_name=output_cs.name, + transformation_class_name=self.__class__.__name__, + ) + def _get_ngff_output_coordinate_system( - self, input_coordinate_system: CoordSystem, output_coordinate_system_name: str + self, + input_coordinate_system: CoordSystem, + output_coordinate_system_name: str, + output_coordinate_system_virtual: bool = False, ) -> CoordSystem: """ Get the NGFF output coordinate system for a transformation. @@ -165,7 +236,9 @@ def _get_ngff_output_coordinate_system( output_axes_names = self._get_resulting_output_axes(input_coordinate_system.axes_names) output_axes = tuple(Axis.from_spatialdata_axis_name(name) for name in output_axes_names) - return CoordSystem(name=output_coordinate_system_name, axes=output_axes, virtual=False) + return CoordSystem( + name=output_coordinate_system_name, axes=output_axes, virtual=output_coordinate_system_virtual + ) def _get_default_coordinate_system( self, @@ -334,22 +407,15 @@ def to_ngff( ngff_transformation = NgffIdentity(input_coordinate_system=input_cs, output_coordinate_system=output_cs) return ngff_transformation - def _to_ngff_transformation_edge( + def _define_ngff_transformation_edge_given_valid_output_cs( self, input_coordinate_system: CoordSystem, - output_coordinate_system_name: str, + output_coordinate_system: CoordSystem, transformation_edge_name: str | None = None, ) -> BaseTransformationEdge: - output_coordinate_system = self._get_ngff_output_coordinate_system( - input_coordinate_system=input_coordinate_system, - output_coordinate_system_name=output_coordinate_system_name, - ) - return IdentityEdge( - input=input_coordinate_system, - output=output_coordinate_system, - name=transformation_edge_name, + input=input_coordinate_system, output=output_coordinate_system, name=transformation_edge_name ) def __eq__(self, other: Any) -> bool: @@ -471,24 +537,33 @@ def to_ngff( ) return ngff_transformation + def _to_ngff_input_output_mapping( + self, input_coordinate_system: CoordSystem, output_coordinate_system: CoordSystem + ) -> dict[Axis, Axis]: + + input_to_output_axes_mapping = { + Axis.from_spatialdata_axis_name(input_axis_name): Axis.from_spatialdata_axis_name(output_axis_name) + for output_axis_name, input_axis_name in self.map_axis.items() + } + + for input_cs_axis in input_coordinate_system.axes: + if (input_cs_axis not in input_to_output_axes_mapping) and (input_cs_axis in output_coordinate_system.axes): + input_to_output_axes_mapping[input_cs_axis] = input_cs_axis + + return input_to_output_axes_mapping + @override - def _to_ngff_transformation_edge( + def _define_ngff_transformation_edge_given_valid_output_cs( self, input_coordinate_system: CoordSystem, - output_coordinate_system_name: str, + output_coordinate_system: CoordSystem, transformation_edge_name: str | None = None, ) -> BaseTransformationEdge: - output_coordinate_system = self._get_ngff_output_coordinate_system( - input_coordinate_system=input_coordinate_system, - output_coordinate_system_name=output_coordinate_system_name, + input_to_output_axes_mapping = self._to_ngff_input_output_mapping( + input_coordinate_system, output_coordinate_system ) - input_to_output_axes_mapping = { - Axis.from_spatialdata_axis_name(input_axis_name): Axis.from_spatialdata_axis_name(output_axis_name) - for output_axis_name, input_axis_name in self.map_axis.items() - } - try: map_axis_edge = MapAxisEdge( input=input_coordinate_system, @@ -588,17 +663,14 @@ def to_ngff( ) return ngff_transformation - def _to_ngff_transformation_edge( + @override + def _define_ngff_transformation_edge_given_valid_output_cs( self, input_coordinate_system: CoordSystem, - output_coordinate_system_name: str, + output_coordinate_system: CoordSystem, transformation_edge_name: str | None = None, ) -> BaseTransformationEdge: - output_coordinate_system = self._get_ngff_output_coordinate_system( - input_coordinate_system, output_coordinate_system_name - ) - translation_vector = self.to_translation_vector(axes=output_coordinate_system.axes_names) return TranslationEdge( @@ -694,17 +766,14 @@ def to_ngff( ) return ngff_transformation - def _to_ngff_transformation_edge( + @override + def _define_ngff_transformation_edge_given_valid_output_cs( self, input_coordinate_system: CoordSystem, - output_coordinate_system_name: str, + output_coordinate_system: CoordSystem, transformation_edge_name: str | None = None, ) -> BaseTransformationEdge: - output_coordinate_system = self._get_ngff_output_coordinate_system( - input_coordinate_system, output_coordinate_system_name - ) - scale_vector = self.to_scale_vector(input_coordinate_system.axes_names) return ScaleEdge( @@ -1031,17 +1100,14 @@ def to_ngff( ) return ngff_transformation - def _to_ngff_transformation_edge( + @override + def _define_ngff_transformation_edge_given_valid_output_cs( self, input_coordinate_system: CoordSystem, - output_coordinate_system_name: str, + output_coordinate_system: CoordSystem, transformation_edge_name: str | None = None, ) -> BaseTransformationEdge: - output_coordinate_system = self._get_ngff_output_coordinate_system( - input_coordinate_system, output_coordinate_system_name - ) - affine_matrix = self.to_affine_matrix( input_axes=input_coordinate_system.axes_names, output_axes=output_coordinate_system.axes_names, @@ -1199,28 +1265,32 @@ def to_ngff( ) return ngff_transformation - def _to_ngff_transformation_edge( + @override + def _define_ngff_transformation_edge_given_valid_output_cs( self, input_coordinate_system: CoordSystem, - output_coordinate_system_name: str, + output_coordinate_system: CoordSystem, transformation_edge_name: str | None = None, ) -> BaseTransformationEdge: - cs_gen = CsGen(base_name=f"Sequence_from_{input_coordinate_system.name}_to_{output_coordinate_system_name}") - output_coordinate_system_names = [cs_gen.get_unique_name() for _t in self.transformations] - output_coordinate_system_names[-1] = output_coordinate_system_name + cs_gen = CsGen(base_name=f"Sequence_from_{input_coordinate_system.name}_to_{output_coordinate_system.name}") individual_edges = [] current_input_cs = input_coordinate_system - for t, current_output_coordinate_system_name in zip( - self.transformations, output_coordinate_system_names, strict=True - ): - current_edge = t._to_ngff_transformation_edge( + for t in self.transformations[:-1]: + current_edge = t._to_ngff_transformation_edge_creating_output_cs( input_coordinate_system=current_input_cs, - output_coordinate_system_name=current_output_coordinate_system_name, + output_coordinate_system_name=cs_gen.get_unique_name(), + output_coordinate_system_virtual=True, ) - current_input_cs = current_edge.output individual_edges.append(current_edge) + current_input_cs = current_edge.output + + last_edge = self.transformations[-1]._define_ngff_transformation_edge_given_valid_output_cs( + input_coordinate_system=current_input_cs, + output_coordinate_system=output_coordinate_system, + ) + individual_edges.append(last_edge) return SequenceEdge(name=transformation_edge_name, transformations=individual_edges) diff --git a/tests/transformations/graph/graph/blobs_dataset_related.py b/tests/transformations/graph/graph/blobs_dataset_related.py new file mode 100644 index 000000000..976c55e91 --- /dev/null +++ b/tests/transformations/graph/graph/blobs_dataset_related.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from spatialdata.datasets import blobs +from spatialdata.transformations._graph import IdentityEdge, ProjectAxisEdge +from spatialdata.transformations._graph.utils import get_intrinsic_coordinate_system_name + + +def test_blobs_dataset_visualization_export(): + sdata = blobs() + # has elements of all types mapping to "global" cs with Identity transformation + graph = sdata.transformations._graph + + global_cs = graph.get_coordinate_system("global") + assert global_cs.axes_names == ("c", "y", "x") + + for element_name, _element in (sdata.images | sdata.labels | sdata.shapes | sdata.points).items(): + element_cs = graph.get_element_coordinate_system(element_name) + assert element_cs.name == get_intrinsic_coordinate_system_name(element_name) + + created_axes = set(global_cs.axes) - set(element_cs.axes) + + if created_axes: + intermediate_cs = graph.get_coordinate_system(f"global_{element_name}") + direct_edges_element_to_intermediate = graph.get_direct_transformations(element_cs, intermediate_cs) + assert len(direct_edges_element_to_intermediate) == 1 + assert isinstance(direct_edges_element_to_intermediate[0], IdentityEdge) + + direct_edges_intermediate_to_global = graph.get_direct_transformations(intermediate_cs, global_cs) + assert len(direct_edges_intermediate_to_global) == 1 + assert isinstance(direct_edges_intermediate_to_global[0], ProjectAxisEdge) + assert direct_edges_intermediate_to_global[0].created_outputs == created_axes + assert direct_edges_intermediate_to_global[0].dropped_inputs == set() + + else: + direct_edges_element_to_global = graph.get_direct_transformations(element_cs, global_cs) + assert len(direct_edges_element_to_global) == 1 + assert isinstance(direct_edges_element_to_global[0], IdentityEdge) diff --git a/tests/transformations/graph/graph/test_graph.py b/tests/transformations/graph/graph/test_graph.py index 4ecf7b364..9aa1c103c 100644 --- a/tests/transformations/graph/graph/test_graph.py +++ b/tests/transformations/graph/graph/test_graph.py @@ -183,11 +183,9 @@ def test_add_transformation(fully_connected_two_point_graph): tm.add_coordinate_system(cs1) tm.add_coordinate_system(cs2) - tm.add_transformation_edge(transform_edge) + edge_key = tm.add_transformation_edge(transform_edge) assert tm._graph.has_edge(cs1, cs2) - # Get the first edge key and check the transformation - edge_key = tm._get_edge_key(transform_edge) assert tm._graph[cs1][cs2][edge_key][TRANSFORM_KEY] is transform_edge @@ -203,9 +201,9 @@ def test_add_transformation_existing_edge(fully_connected_two_point_graph): # Add same transformation again between the same coordinate systems tm.add_transformation_edge(transform_edge) - # Should have just rewritten the edge + assert tm._graph.has_edge(cs1, cs2) - assert len(tm._graph[cs1][cs2]) == 1 + assert len(tm._graph[cs1][cs2]) == 2 def test_add_transformation_nonexistent_cs(fully_connected_two_point_graph): @@ -235,7 +233,7 @@ def test_get_direct_transformations(fully_connected_two_point_graph): retrieved = tm.get_direct_transformations(cs1, cs2) assert len(retrieved) == 1 - assert retrieved[0] is transform_edge + assert next(iter(retrieved.values())) is transform_edge def test_get_direct_transformations_nonexistent(fully_connected_two_point_graph): @@ -246,7 +244,7 @@ def test_get_direct_transformations_nonexistent(fully_connected_two_point_graph) tm.add_coordinate_system(cs2) retrieved = tm.get_direct_transformations(cs1, cs2) - assert retrieved == [] + assert retrieved == {} def test_remove_all_transformations_between_coordinate_systems(fully_connected_two_point_graph, mocker): @@ -290,11 +288,10 @@ def test_remove_specific_transformation_between_coordinate_systems(five_point_gr tm.add_coordinate_system(cs5) tm.add_transformation_edge(transform6) - tm.add_transformation_edge(transform7) + edge_key7 = tm.add_transformation_edge(transform7) - tm.remove_specific_transformation(transform7) - edge_key = tm._get_edge_key(transform7) - assert not tm._graph.has_edge(cs3, cs5, key=edge_key) + tm.remove_specific_transformation(edge_key7, transform7) + assert not tm._graph.has_edge(cs3, cs5, key=edge_key7) def test_remove_specific_transformation_between_coordinate_systems_non_existent(five_point_graph): @@ -313,7 +310,7 @@ def test_remove_specific_transformation_between_coordinate_systems_non_existent( with pytest.raises( TransformationNotFoundError, match=f"Transformation from '{cs3.name}' to '{cs5.name}' not found" ): - tm.remove_specific_transformation(transform7) + tm.remove_specific_transformation("irrelevant", transform7) def assert_sequences_equivalent( @@ -412,18 +409,18 @@ def test_get_all_shortest_transformation_sequences_multiple_paths_multiple_edges tm.add_coordinate_system(cs4) tm.add_coordinate_system(cs5) - tm.add_transformation_edge(transform1) - tm.add_transformation_edge(transform3) - tm.add_transformation_edge(transform4) - tm.add_transformation_edge(transform2) + tm.add_transformation_edge(transform1) # cs1 -> cs2 + tm.add_transformation_edge(transform3) # cs2 -> cs3 + tm.add_transformation_edge(transform4) # cs1 -> cs4 + tm.add_transformation_edge(transform2) # cs4 -> cs3 # tm.add_transformation(cs1, cs3, transform3) - tm.add_transformation_edge(transform6) - tm.add_transformation_edge(transform7) + key6 = tm.add_transformation_edge(transform6) # cs3 -> cs5 + tm.add_transformation_edge(transform7) # cs3 -> cs5 - expected_intermediate_edges = [transform6] + expected_intermediate_edges = [key6] sequences = tm.get_all_shortest_transformation_sequences( - cs1, cs5, expected_intermediate_edges=expected_intermediate_edges + cs1, cs5, expected_intermediate_edge_keys=expected_intermediate_edges ) assert_sequences_equivalent(sequences, [[transform1, transform3, transform6], [transform4, transform2, transform6]]) @@ -516,11 +513,13 @@ def test_get_all_transformation_sequences_multiple_paths_multiple_edges_success( tm.add_transformation_edge(transform4) tm.add_transformation_edge(transform2) tm.add_transformation_edge(transform5) - tm.add_transformation_edge(transform6) + key6 = tm.add_transformation_edge(transform6) tm.add_transformation_edge(transform7) - expected_intermediate_edges = [transform6] - sequences = tm.get_all_transformation_sequences(cs1, cs5, expected_intermediate_edges=expected_intermediate_edges) + expected_intermediate_edges = [key6] + sequences = tm.get_all_transformation_sequences( + cs1, cs5, expected_intermediate_edge_keys=expected_intermediate_edges + ) assert_sequences_equivalent( sequences, [[transform1, transform3, transform6], [transform4, transform2, transform6], [transform5, transform6]], @@ -573,14 +572,14 @@ def test_get_all_transformation_sequences_multiple_paths_multiple_edges_multiple tm.add_transformation_edge(transform4) tm.add_transformation_edge(transform2) tm.add_transformation_edge(transform5) - tm.add_transformation_edge(transform6) - tm.add_transformation_edge(transform7) + key6 = tm.add_transformation_edge(transform6) + key7 = tm.add_transformation_edge(transform7) - expected_intermediate_edges = [transform6, transform7] + expected_intermediate_edges = [key6, key7] expected_msg = f"Transformation Path ambiguous from {cs3.name} to {cs5.name}." with pytest.raises(TransformationPathAmbiguousMultipleEdgeExpectedError, match=expected_msg): - tm.get_all_transformation_sequences(cs1, cs5, expected_intermediate_edges=expected_intermediate_edges) + tm.get_all_transformation_sequences(cs1, cs5, expected_intermediate_edge_keys=expected_intermediate_edges) def test_get_transformation_sequences_from_simple_paths_after_disambiguation_with_non_simple_paths( From a540da32d23c25756f59dc61137deb66c123014a Mon Sep 17 00:00:00 2001 From: Ajayrama Kumaraswamy Date: Tue, 27 Oct 2026 22:25:57 +0100 Subject: [PATCH 52/54] fix + reorg (literals, get_axes_names, get_spatial_axes): avoid circular imports --- src/spatialdata/_core/centroids.py | 3 +- src/spatialdata/_core/data_extent.py | 3 +- src/spatialdata/_core/operations/_utils.py | 4 +- src/spatialdata/_core/operations/map.py | 3 +- src/spatialdata/_core/operations/rasterize.py | 3 +- src/spatialdata/_core/operations/transform.py | 4 +- src/spatialdata/_core/operations/vectorize.py | 4 +- src/spatialdata/_core/query/spatial_query.py | 3 +- src/spatialdata/_core/spatialdata.py | 4 +- src/spatialdata/_io/_utils.py | 4 +- src/spatialdata/_io/io_points.py | 2 +- src/spatialdata/_io/io_shapes.py | 5 +- src/spatialdata/_utils.py | 39 +----- src/spatialdata/dataloader/datasets.py | 2 +- src/spatialdata/literals/__init__.py | 14 ++ .../graph_literals.py} | 0 src/spatialdata/models/__init__.py | 9 +- src/spatialdata/models/_utils.py | 124 +++++++++++++++--- src/spatialdata/models/models.py | 14 +- src/spatialdata/models/types.py | 3 - src/spatialdata/transformations/__init__.py | 5 + .../transformations/_graph/__init__.py | 24 +--- .../_graph/edges/ngff_aligned_edges.py | 2 +- .../transformations/_graph/graph.py | 2 +- .../transformations/_graph/utils.py | 80 +---------- .../transformations/_graph/vert.py | 2 +- src/spatialdata/transformations/_utils.py | 26 +++- .../transformations/ngff/_utils.py | 2 +- .../ngff/ngff_transformations.py | 2 +- src/spatialdata/transformations/operations.py | 10 +- .../transformations/transformations.py | 4 +- tests/core/operations/test_rasterize.py | 4 +- tests/core/operations/test_transform.py | 2 +- tests/core/test_centroids.py | 2 +- tests/models/test_models.py | 3 +- tests/transformations/graph/conftest.py | 3 +- .../transformations/graph/graph/test_graph.py | 2 +- .../graph/utils/test_get_union_of_axes.py | 3 +- .../ngff/test_ngff_transformations.py | 2 +- tests/transformations/test_transformations.py | 2 +- 40 files changed, 218 insertions(+), 211 deletions(-) create mode 100644 src/spatialdata/literals/__init__.py rename src/spatialdata/{transformations/_graph/literals.py => literals/graph_literals.py} (100%) diff --git a/src/spatialdata/_core/centroids.py b/src/spatialdata/_core/centroids.py index 6d8780665..65c361b3d 100644 --- a/src/spatialdata/_core/centroids.py +++ b/src/spatialdata/_core/centroids.py @@ -11,8 +11,7 @@ from xarray import DataArray, DataTree from spatialdata._core.operations.transform import transform -from spatialdata.models import Labels2DModel, Labels3DModel, PointsModel, SpatialElement, get_model -from spatialdata.transformations._graph.utils import get_axes_names +from spatialdata.models import Labels2DModel, Labels3DModel, PointsModel, SpatialElement, get_axes_names, get_model from spatialdata.transformations.operations import get_transformation from spatialdata.transformations.transformations import BaseTransformation diff --git a/src/spatialdata/_core/data_extent.py b/src/spatialdata/_core/data_extent.py index 0df4b5cbf..5efdcfdb8 100644 --- a/src/spatialdata/_core/data_extent.py +++ b/src/spatialdata/_core/data_extent.py @@ -14,8 +14,7 @@ from spatialdata._core.operations.transform import transform from spatialdata._core.spatialdata import SpatialData -from spatialdata.models import PointsModel, SpatialElement -from spatialdata.transformations._graph.utils import get_axes_names +from spatialdata.models import PointsModel, SpatialElement, get_axes_names from spatialdata.transformations.operations import get_transformation BoundingBoxDescription = dict[str, tuple[float, float]] diff --git a/src/spatialdata/_core/operations/_utils.py b/src/spatialdata/_core/operations/_utils.py index c1e10c903..cd097e5d8 100644 --- a/src/spatialdata/_core/operations/_utils.py +++ b/src/spatialdata/_core/operations/_utils.py @@ -5,8 +5,8 @@ from anndata import AnnData from xarray import DataArray, DataTree -from spatialdata.models import SpatialElement -from spatialdata.transformations._graph.utils import get_axes_names, get_spatial_axes +from spatialdata.models import SpatialElement, get_axes_names +from spatialdata.transformations import get_spatial_axes if TYPE_CHECKING: from spatialdata._core.spatialdata import SpatialData diff --git a/src/spatialdata/_core/operations/map.py b/src/spatialdata/_core/operations/map.py index 6bee93432..4dc860133 100644 --- a/src/spatialdata/_core/operations/map.py +++ b/src/spatialdata/_core/operations/map.py @@ -13,9 +13,8 @@ from xarray import DataArray, DataTree from spatialdata._types import IntArrayLike -from spatialdata.models._utils import get_channel_names, get_raster_model_from_data_dims +from spatialdata.models import get_axes_names, get_channel_names, get_raster_model_from_data_dims from spatialdata.transformations import get_transformation -from spatialdata.transformations._graph.utils import get_axes_names __all__ = ["map_raster", "relabel_sequential"] diff --git a/src/spatialdata/_core/operations/rasterize.py b/src/spatialdata/_core/operations/rasterize.py index 0b15e85c9..eac8fef10 100644 --- a/src/spatialdata/_core/operations/rasterize.py +++ b/src/spatialdata/_core/operations/rasterize.py @@ -28,9 +28,10 @@ PointsModel, ShapesModel, SpatialElement, + get_axes_names, get_model, ) -from spatialdata.transformations._graph.utils import get_axes_names, get_spatial_axes +from spatialdata.transformations import get_spatial_axes from spatialdata.transformations._utils import _get_scale, compute_coordinates from spatialdata.transformations.operations import get_transformation, remove_transformation, set_transformation from spatialdata.transformations.transformations import ( diff --git a/src/spatialdata/_core/operations/transform.py b/src/spatialdata/_core/operations/transform.py index f39b4635b..421715bef 100644 --- a/src/spatialdata/_core/operations/transform.py +++ b/src/spatialdata/_core/operations/transform.py @@ -20,14 +20,14 @@ from spatialdata._core.spatialdata import SpatialData from spatialdata._types import ArrayLike from spatialdata._utils import disable_dask_tune_optimization +from spatialdata.literals import DEFAULT_COORDINATE_SYSTEM, TRANSFORM_KEY from spatialdata.models import ( RasterSchema, SpatialElement, + get_axes_names, get_channel_names, get_model, ) -from spatialdata.transformations._graph import DEFAULT_COORDINATE_SYSTEM, TRANSFORM_KEY -from spatialdata.transformations._graph.utils import get_axes_names from spatialdata.transformations._utils import _get_scale, compute_coordinates, scale_radii if TYPE_CHECKING: diff --git a/src/spatialdata/_core/operations/vectorize.py b/src/spatialdata/_core/operations/vectorize.py index 88161de64..14a16a072 100644 --- a/src/spatialdata/_core/operations/vectorize.py +++ b/src/spatialdata/_core/operations/vectorize.py @@ -28,10 +28,10 @@ Labels3DModel, ShapesModel, SpatialElement, + get_axes_names, get_model, + points_dask_dataframe_to_geopandas, ) -from spatialdata.models._utils import points_dask_dataframe_to_geopandas -from spatialdata.transformations._graph.utils import get_axes_names from spatialdata.transformations.operations import get_transformation from spatialdata.transformations.transformations import Identity diff --git a/src/spatialdata/_core/query/spatial_query.py b/src/spatialdata/_core/query/spatial_query.py index 77367dd93..d31b977fb 100644 --- a/src/spatialdata/_core/query/spatial_query.py +++ b/src/spatialdata/_core/query/spatial_query.py @@ -25,11 +25,12 @@ PointsModel, ShapesModel, SpatialElement, + get_axes_names, points_dask_dataframe_to_geopandas, points_geopandas_to_dask_dataframe, ) +from spatialdata.transformations import get_spatial_axes from spatialdata.transformations._graph.types import ValidAxis_t -from spatialdata.transformations._graph.utils import get_axes_names, get_spatial_axes from spatialdata.transformations.operations import set_transformation from spatialdata.transformations.transformations import ( Affine, diff --git a/src/spatialdata/_core/spatialdata.py b/src/spatialdata/_core/spatialdata.py index be101f5b5..3d162b77b 100644 --- a/src/spatialdata/_core/spatialdata.py +++ b/src/spatialdata/_core/spatialdata.py @@ -42,13 +42,13 @@ ShapesModel, SpatialElement, TableModel, + convert_region_column_to_categorical, + get_axes_names, get_model, get_table_keys, set_channel_names, ) -from spatialdata.models._utils import convert_region_column_to_categorical from spatialdata.transformations import BaseTransformation, get_transformation -from spatialdata.transformations._graph.utils import get_axes_names if TYPE_CHECKING: from pandas._typing import DtypeObj diff --git a/src/spatialdata/_io/_utils.py b/src/spatialdata/_io/_utils.py index ad35091c9..57baeb9b4 100644 --- a/src/spatialdata/_io/_utils.py +++ b/src/spatialdata/_io/_utils.py @@ -30,9 +30,9 @@ from spatialdata._io.format import RasterFormatType, RasterFormatV01, RasterFormatV02, RasterFormatV03 from spatialdata._logging import logger from spatialdata._utils import get_pyramid_levels -from spatialdata.models import MappingToCoordinateSystem_t, SpatialElement -from spatialdata.models._utils import _validate_mapping_to_coordinate_system_type +from spatialdata.models import SpatialElement from spatialdata.transformations._graph.types import ValidAxis_t +from spatialdata.transformations._utils import MappingToCoordinateSystem_t, _validate_mapping_to_coordinate_system_type from spatialdata.transformations.ngff.ngff_transformations import NgffBaseTransformation from spatialdata.transformations.transformations import BaseTransformation diff --git a/src/spatialdata/_io/io_points.py b/src/spatialdata/_io/io_points.py index b3c4a21e2..b7cc4ba3a 100644 --- a/src/spatialdata/_io/io_points.py +++ b/src/spatialdata/_io/io_points.py @@ -16,7 +16,7 @@ ) from spatialdata._io.exceptions import WritingToZarrV2DeprecationWarning from spatialdata._io.format import CurrentPointsFormat, PointsFormats, PointsFormatType, _parse_version -from spatialdata.transformations._graph.utils import get_axes_names +from spatialdata.models import get_axes_names from spatialdata.transformations._utils import ( _get_transformations, _set_transformations, diff --git a/src/spatialdata/_io/io_shapes.py b/src/spatialdata/_io/io_shapes.py index a5b831826..787b72973 100644 --- a/src/spatialdata/_io/io_shapes.py +++ b/src/spatialdata/_io/io_shapes.py @@ -26,9 +26,8 @@ ShapesFormatV03, _parse_version, ) -from spatialdata.models import ShapesModel -from spatialdata.transformations._graph import TRANSFORM_KEY -from spatialdata.transformations._graph.utils import get_axes_names +from spatialdata.literals import TRANSFORM_KEY +from spatialdata.models import ShapesModel, get_axes_names from spatialdata.transformations._utils import ( _get_transformations, _set_transformations, diff --git a/src/spatialdata/_utils.py b/src/spatialdata/_utils.py index 50388e8f6..b0ff0b7fd 100644 --- a/src/spatialdata/_utils.py +++ b/src/spatialdata/_utils.py @@ -13,13 +13,14 @@ from anndata import AnnData from dask import array as da from dask import config -from dask.array import Array as DaskArray from xarray import DataArray, Dataset, DataTree from spatialdata._types import ArrayLike, ListOrNDArrayFloating +from spatialdata.models import get_axes_names from spatialdata.transformations import ( Sequence, Translation, + compute_coordinates, get_transformation, set_transformation, ) @@ -84,8 +85,6 @@ def unpad_raster(raster: DataArray | DataTree) -> DataArray | DataTree: ------- The unpadded raster. """ - from spatialdata.transformations._graph.utils import get_axes_names - from spatialdata.transformations._utils import compute_coordinates def _compute_paddings(data: DataArray, axis: str) -> tuple[int, int]: others = list(data.dims) @@ -335,37 +334,3 @@ def _error_message_add_element() -> None: "write_labels(), write_points(), write_shapes() and write_table(). We are going to make these calls more " "ergonomic in a follow up PR." ) - - -def _check_match_length_channels_c_dim( - data: DaskArray | DataArray | DataTree, c_coords: str | list[str], dims: tuple[str, ...] -) -> list[str]: - """ - Check whether channel names `c_coords` are of equal length to the `c` dimension of the data. - - Parameters - ---------- - data - The image array - c_coords - The channel names - dims - The axes names in the order that is the same as the `ImageModel` from which it is derived. - - Returns - ------- - c_coords - The channel names as list - """ - c_index = dims.index("c") - c_length = ( - data.shape[c_index] if isinstance(data, DataArray | DaskArray) else data["scale0"]["image"].shape[c_index] - ) - if isinstance(c_coords, str): - c_coords = [c_coords] - if c_coords is not None and len(c_coords) != c_length: - raise ValueError( - f"The number of channel names `{len(c_coords)}` does not match the length of dimension 'c'" - f" with length {c_length}." - ) - return c_coords diff --git a/src/spatialdata/dataloader/datasets.py b/src/spatialdata/dataloader/datasets.py index f10adfa50..b34ad5bf5 100644 --- a/src/spatialdata/dataloader/datasets.py +++ b/src/spatialdata/dataloader/datasets.py @@ -30,11 +30,11 @@ Labels3DModel, PointsModel, TableModel, + get_axes_names, get_model, get_table_keys, ) from spatialdata.transformations import BaseTransformation, get_transformation, set_transformation -from spatialdata.transformations._graph.utils import get_axes_names __all__ = ["ImageTilesDataset"] diff --git a/src/spatialdata/literals/__init__.py b/src/spatialdata/literals/__init__.py new file mode 100644 index 000000000..78041a78a --- /dev/null +++ b/src/spatialdata/literals/__init__.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +from spatialdata.literals.graph_literals import ( + DEFAULT_COORDINATE_SYSTEM, + TRANSFORM_KEY, + C, + NgffAxisType, + X, + Y, + Z, + axis_type_mapping_ngff, +) + +__all__ = ["DEFAULT_COORDINATE_SYSTEM", "C", "X", "Y", "Z", "NgffAxisType", "axis_type_mapping_ngff", "TRANSFORM_KEY"] diff --git a/src/spatialdata/transformations/_graph/literals.py b/src/spatialdata/literals/graph_literals.py similarity index 100% rename from src/spatialdata/transformations/_graph/literals.py rename to src/spatialdata/literals/graph_literals.py diff --git a/src/spatialdata/models/__init__.py b/src/spatialdata/models/__init__.py index 9ec9daf78..5023e3671 100644 --- a/src/spatialdata/models/__init__.py +++ b/src/spatialdata/models/__init__.py @@ -2,8 +2,11 @@ from spatialdata._core.validation import check_target_region_column_symmetry from spatialdata.models._utils import ( + convert_region_column_to_categorical, force_2d, + get_axes_names, get_channel_names, + get_raster_model_from_data_dims, points_dask_dataframe_to_geopandas, points_geopandas_to_dask_dataframe, set_channel_names, @@ -24,7 +27,7 @@ get_table_keys, validate_element, ) -from spatialdata.models.types import MappingToCoordinateSystem_t, SpatialElement +from spatialdata.models.types import SpatialElement __all__ = [ "Labels2DModel", @@ -46,5 +49,7 @@ "set_channel_names", "force_2d", "ATTRS_KEY", - "MappingToCoordinateSystem_t", + "get_axes_names", + "convert_region_column_to_categorical", + "get_raster_model_from_data_dims", ] diff --git a/src/spatialdata/models/_utils.py b/src/spatialdata/models/_utils.py index c6b612643..188c96676 100644 --- a/src/spatialdata/models/_utils.py +++ b/src/spatialdata/models/_utils.py @@ -8,22 +8,22 @@ import geopandas import numpy as np import pandas as pd +from dask.array import Array as DaskArray from dask.dataframe import DataFrame as DaskDataFrame from geopandas import GeoDataFrame +from shapely import get_coordinate_dimension from shapely.geometry import MultiPolygon, Point, Polygon from shapely.geometry.base import BaseGeometry from xarray import DataArray, DataTree from spatialdata._logging import logger -from spatialdata._utils import _check_match_length_channels_c_dim -from spatialdata.transformations._graph import C, X, Y, Z -from spatialdata.transformations.transformations import BaseTransformation +from spatialdata.literals import C, X, Y, Z if TYPE_CHECKING: from anndata import AnnData from spatialdata.models.models import RasterSchema - from spatialdata.models.types import MappingToCoordinateSystem_t + from spatialdata.models.types import SpatialElement # mypy says that we can't do isinstance(something, SpatialElement), @@ -47,18 +47,6 @@ def has_type_spatial_element(e: Any) -> bool: return isinstance(e, DataArray | DataTree | GeoDataFrame | DaskDataFrame) -# added this code as part of a refactoring to catch errors earlier -def _validate_mapping_to_coordinate_system_type(transformations: MappingToCoordinateSystem_t | None) -> None: - if not ( - transformations is None - or isinstance(transformations, dict) - and all(isinstance(k, str) and isinstance(v, BaseTransformation) for k, v in transformations.items()) - ): - raise TypeError( - f"Transform must be of type {MappingToCoordinateSystem_t} or None, but is of type {type(transformations)}." - ) - - def points_dask_dataframe_to_geopandas(points: DaskDataFrame, suppress_z_warning: bool = False) -> GeoDataFrame: """ Convert a Dask DataFrame to a GeoDataFrame. @@ -300,3 +288,107 @@ def _get_uint_dtype(value: int) -> str: else: raise ValueError(f"Maximum cell number is {value}. Values higher than {max_uint64} are not supported.") return dtype + + +@singledispatch +def get_axes_names(e: SpatialElement | pd.DataFrame) -> tuple[str, ...]: + """ + Get the dimensions of a SpatialElement. + + Parameters + ---------- + e + SpatialElement + + Returns + ------- + Dimensions of the SpatialElement (e.g. ("z", "y", "x")) + """ + raise TypeError(f"Unsupported type: {type(e)}") + + +@get_axes_names.register(DataTree) +def _(e: DataTree) -> tuple[str, ...]: + if "scale0" in e: + # dims_coordinates = tuple(i for i in e["scale0"].dims.keys()) + + scale0 = e["scale0"] + if not isinstance(scale0, DataTree): + raise TypeError(f"Expected `scale0` to be a DataTree node, got {type(scale0).__name__}.") + variables = list(scale0.values()) + assert len(variables) == 1 + xdata = variables[0] + dims_data = tuple(str(dim) for dim in xdata.dims) + + _validate_dims(dims_data) + return dims_data + raise ValueError("Spatialdata DataTree does not contain the scale0 key") + + +@get_axes_names.register(GeoDataFrame) +def _(e: GeoDataFrame) -> tuple[str, ...]: + all_dims = (X, Y, Z) + n = get_coordinate_dimension(e.geometry.iloc[0]) + dims = all_dims[:n] + if Z not in dims and Z in e.columns: + dims += (Z,) + _validate_dims(dims) + return dims + + +@get_axes_names.register(DaskDataFrame) +@get_axes_names.register(pd.DataFrame) +def _(e: DaskDataFrame | pd.DataFrame) -> tuple[str, ...]: + valid_dims = (X, Y, Z) + dims = tuple([c for c in valid_dims if c in e.columns]) + _validate_dims(dims) + return dims + + +@get_axes_names.register(DataArray) +def _(e: DataArray) -> tuple[str, ...]: + dims = tuple(str(dim) for dim in e.dims) + _validate_dims(dims) + return dims + + +def _validate_dims(dims: tuple[str, ...]) -> None: + for c in dims: + if c not in (X, Y, Z, C): + raise ValueError(f"Invalid dimension: {c}") + if dims not in [(X,), (Y,), (Z,), (C,), (X, Y), (X, Y, Z), (Y, X), (Z, Y, X), (C, Y, X), (C, Z, Y, X)]: + raise ValueError(f"Invalid dimensions: {dims}") + + +def _check_match_length_channels_c_dim( + data: DaskArray | DataArray | DataTree, c_coords: str | list[str], dims: tuple[str, ...] +) -> list[str]: + """ + Check whether channel names `c_coords` are of equal length to the `c` dimension of the data. + + Parameters + ---------- + data + The image array + c_coords + The channel names + dims + The axes names in the order that is the same as the `ImageModel` from which it is derived. + + Returns + ------- + c_coords + The channel names as list + """ + c_index = dims.index("c") + c_length = ( + data.shape[c_index] if isinstance(data, DataArray | DaskArray) else data["scale0"]["image"].shape[c_index] + ) + if isinstance(c_coords, str): + c_coords = [c_coords] + if c_coords is not None and len(c_coords) != c_length: + raise ValueError( + f"The number of channel names `{len(c_coords)}` does not match the length of dimension 'c'" + f" with length {c_length}." + ) + return c_coords diff --git a/src/spatialdata/models/models.py b/src/spatialdata/models/models.py index ecd22e72b..543f953a0 100644 --- a/src/spatialdata/models/models.py +++ b/src/spatialdata/models/models.py @@ -29,18 +29,21 @@ from spatialdata._core.validation import validate_table_attr_keys from spatialdata._logging import logger -from spatialdata._utils import _check_match_length_channels_c_dim from spatialdata.config import settings -from spatialdata.models._utils import _validate_mapping_to_coordinate_system_type, convert_region_column_to_categorical +from spatialdata.literals import DEFAULT_COORDINATE_SYSTEM, TRANSFORM_KEY, C, X, Y, Z +from spatialdata.models._utils import ( + _check_match_length_channels_c_dim, + convert_region_column_to_categorical, + get_axes_names, +) from spatialdata.models.chunks_utils import Chunks_t from spatialdata.models.literals import ATTRS_KEY from spatialdata.models.pyramids_utils import ScaleFactors_t # ozp -> ome-zarr-py from spatialdata.models.pyramids_utils import to_multiscale as to_multiscale_ozp -from spatialdata.transformations._graph import DEFAULT_COORDINATE_SYSTEM, TRANSFORM_KEY, C, X, Y, Z -from spatialdata.transformations._graph.utils import get_axes_names from spatialdata.transformations._utils import ( _get_transformations, _set_transformations, + _validate_mapping_to_coordinate_system_type, compute_coordinates, ) from spatialdata.transformations.transformations import Identity @@ -49,7 +52,8 @@ from pandas._typing import DtypeObj from spatialdata._types import ArrayLike - from spatialdata.models.types import MappingToCoordinateSystem_t, SpatialElement + from spatialdata.models.types import SpatialElement + from spatialdata.transformations._utils import MappingToCoordinateSystem_t __all__ = ["Chunks_t", "ScaleFactors_t"] diff --git a/src/spatialdata/models/types.py b/src/spatialdata/models/types.py index f5ceb8e9d..d228c49a8 100644 --- a/src/spatialdata/models/types.py +++ b/src/spatialdata/models/types.py @@ -4,7 +4,4 @@ from geopandas import GeoDataFrame from xarray import DataArray, DataTree -from spatialdata.transformations import BaseTransformation - SpatialElement = DataArray | DataTree | GeoDataFrame | DaskDataFrame -MappingToCoordinateSystem_t = dict[str, BaseTransformation] diff --git a/src/spatialdata/transformations/__init__.py b/src/spatialdata/transformations/__init__.py index e95a92766..c02c43f0a 100644 --- a/src/spatialdata/transformations/__init__.py +++ b/src/spatialdata/transformations/__init__.py @@ -1,5 +1,7 @@ from __future__ import annotations +from spatialdata.transformations._graph import get_spatial_axes +from spatialdata.transformations._utils import compute_coordinates from spatialdata.transformations.operations import ( align_elements_using_landmarks, get_transformation, @@ -34,4 +36,7 @@ "get_transformation_between_landmarks", "align_elements_using_landmarks", "remove_transformations_to_coordinate_system", + "compute_coordinates", + # utils + "get_spatial_axes", ] diff --git a/src/spatialdata/transformations/_graph/__init__.py b/src/spatialdata/transformations/_graph/__init__.py index 8ad7343ff..24ad0d46a 100644 --- a/src/spatialdata/transformations/_graph/__init__.py +++ b/src/spatialdata/transformations/_graph/__init__.py @@ -51,17 +51,11 @@ TransformationPathNotSimple, ) from spatialdata.transformations._graph.graph import TransformationGraph -from spatialdata.transformations._graph.literals import ( - DEFAULT_COORDINATE_SYSTEM, - TRANSFORM_KEY, - C, - NgffAxisType, - X, - Y, - Z, - axis_type_mapping_ngff, +from spatialdata.transformations._graph.utils import ( + get_instrinsic_coordinate_system, + get_spatial_axes, + get_union_of_axes, ) -from spatialdata.transformations._graph.utils import get_instrinsic_coordinate_system, get_union_of_axes from spatialdata.transformations._graph.vert import Axis, CoordSystem __all__ = [ @@ -114,18 +108,10 @@ "UnmappedAxisError", # from graph "TransformationGraph", - # from literals - "DEFAULT_COORDINATE_SYSTEM", - "TRANSFORM_KEY", - "X", - "Y", - "Z", - "C", - "NgffAxisType", - "axis_type_mapping_ngff", # from utils "get_instrinsic_coordinate_system", "get_union_of_axes", + "get_spatial_axes", # from vert "Axis", "CoordSystem", diff --git a/src/spatialdata/transformations/_graph/edges/ngff_aligned_edges.py b/src/spatialdata/transformations/_graph/edges/ngff_aligned_edges.py index 09ca9fb2e..aa1665ec4 100644 --- a/src/spatialdata/transformations/_graph/edges/ngff_aligned_edges.py +++ b/src/spatialdata/transformations/_graph/edges/ngff_aligned_edges.py @@ -11,6 +11,7 @@ import ome_zarr_models.v06.coordinate_transforms as ozm06trans from spatialdata._types import ArrayLike +from spatialdata.literals import NgffAxisType from spatialdata.transformations._graph.edges.exceptions import ( AxisRedefinitionError, DeterminantDifferentFromOne, @@ -21,7 +22,6 @@ UnexpectedShapeError, UnmappedAxisError, ) -from spatialdata.transformations._graph.literals import NgffAxisType from spatialdata.transformations._graph.vert import Axis, CoordSystem diff --git a/src/spatialdata/transformations/_graph/graph.py b/src/spatialdata/transformations/_graph/graph.py index 182d20c74..0ab3421c4 100644 --- a/src/spatialdata/transformations/_graph/graph.py +++ b/src/spatialdata/transformations/_graph/graph.py @@ -7,6 +7,7 @@ import networkx as nx +from spatialdata.literals import TRANSFORM_KEY from spatialdata.transformations._graph.edges.ngff_aligned_edges import BaseTransformationEdge, SequenceEdge from spatialdata.transformations._graph.exceptions import ( CannotRemoveCoordinateSystemError, @@ -24,7 +25,6 @@ TransformationPathNotFoundError, TransformationPathNotSimple, ) -from spatialdata.transformations._graph.literals import TRANSFORM_KEY from spatialdata.transformations._graph.vert import CoordSystem diff --git a/src/spatialdata/transformations/_graph/utils.py b/src/spatialdata/transformations/_graph/utils.py index dfee835fd..c997313fd 100644 --- a/src/spatialdata/transformations/_graph/utils.py +++ b/src/spatialdata/transformations/_graph/utils.py @@ -1,20 +1,16 @@ from __future__ import annotations -from functools import singledispatch from typing import TYPE_CHECKING import pandas as pd -from dask.dataframe import DataFrame as DaskDataFrame -from geopandas import GeoDataFrame -from shapely import get_coordinate_dimension -from xarray import DataArray, DataTree -from spatialdata.transformations._graph import C, X, Y, Z -from spatialdata.transformations._graph.types import ValidAxis_t +from spatialdata.literals import X, Y, Z +from spatialdata.models import get_axes_names from spatialdata.transformations._graph.vert import Axis, CoordSystem if TYPE_CHECKING: from spatialdata.models import SpatialElement + from spatialdata.transformations._graph.types import ValidAxis_t def get_intrinsic_coordinate_system_name(element_key: str) -> str: @@ -101,76 +97,6 @@ def get_spatial_axes(axes: tuple[ValidAxis_t, ...]) -> tuple[ValidAxis_t, ...]: return tuple(ax for ax in axes if ax in [X, Y, Z]) -@singledispatch -def get_axes_names(e: SpatialElement | pd.DataFrame) -> tuple[str, ...]: - """ - Get the dimensions of a SpatialElement. - - Parameters - ---------- - e - SpatialElement - - Returns - ------- - Dimensions of the SpatialElement (e.g. ("z", "y", "x")) - """ - raise TypeError(f"Unsupported type: {type(e)}") - - -@get_axes_names.register(DataTree) -def _(e: DataTree) -> tuple[str, ...]: - if "scale0" in e: - # dims_coordinates = tuple(i for i in e["scale0"].dims.keys()) - - scale0 = e["scale0"] - if not isinstance(scale0, DataTree): - raise TypeError(f"Expected `scale0` to be a DataTree node, got {type(scale0).__name__}.") - variables = list(scale0.values()) - assert len(variables) == 1 - xdata = variables[0] - dims_data = tuple(str(dim) for dim in xdata.dims) - - _validate_dims(dims_data) - return dims_data - raise ValueError("Spatialdata DataTree does not contain the scale0 key") - - -@get_axes_names.register(GeoDataFrame) -def _(e: GeoDataFrame) -> tuple[str, ...]: - all_dims = (X, Y, Z) - n = get_coordinate_dimension(e.geometry.iloc[0]) - dims = all_dims[:n] - if Z not in dims and Z in e.columns: - dims += (Z,) - _validate_dims(dims) - return dims - - -@get_axes_names.register(DaskDataFrame) -@get_axes_names.register(pd.DataFrame) -def _(e: DaskDataFrame | pd.DataFrame) -> tuple[str, ...]: - valid_dims = (X, Y, Z) - dims = tuple([c for c in valid_dims if c in e.columns]) - _validate_dims(dims) - return dims - - -@get_axes_names.register(DataArray) -def _(e: DataArray) -> tuple[str, ...]: - dims = tuple(str(dim) for dim in e.dims) - _validate_dims(dims) - return dims - - -def _validate_dims(dims: tuple[str, ...]) -> None: - for c in dims: - if c not in (X, Y, Z, C): - raise ValueError(f"Invalid dimension: {c}") - if dims not in [(X,), (Y,), (Z,), (C,), (X, Y), (X, Y, Z), (Y, X), (Z, Y, X), (C, Y, X), (C, Z, Y, X)]: - raise ValueError(f"Invalid dimensions: {dims}") - - def get_union_of_axes(axes: tuple[Axis, ...]) -> tuple[Axis, ...]: """ Produce a union of ``Axis`` objects, dropping duplicates and ordering reverse alphabetically. diff --git a/src/spatialdata/transformations/_graph/vert.py b/src/spatialdata/transformations/_graph/vert.py index 15652f38d..c7688d720 100644 --- a/src/spatialdata/transformations/_graph/vert.py +++ b/src/spatialdata/transformations/_graph/vert.py @@ -6,8 +6,8 @@ import ome_zarr_models.v06.coordinate_transforms as ozm06ct +from spatialdata.literals import NgffAxisType, axis_type_mapping_ngff from spatialdata.transformations._graph.exceptions import AxisParsingException -from spatialdata.transformations._graph.literals import NgffAxisType, axis_type_mapping_ngff @dataclass(frozen=True) diff --git a/src/spatialdata/transformations/_utils.py b/src/spatialdata/transformations/_utils.py index 7a64f86c4..e1bca3b05 100644 --- a/src/spatialdata/transformations/_utils.py +++ b/src/spatialdata/transformations/_utils.py @@ -10,12 +10,15 @@ from xarray import DataArray, Dataset, DataTree from spatialdata._types import ArrayLike -from spatialdata.transformations._graph import TRANSFORM_KEY +from spatialdata.literals import TRANSFORM_KEY +from spatialdata.transformations.transformations import Affine, BaseTransformation, Scale, Sequence if TYPE_CHECKING: from spatialdata._core.spatialdata import SpatialData - from spatialdata.models import MappingToCoordinateSystem_t, SpatialElement - from spatialdata.transformations.transformations import Affine, BaseTransformation, Scale + from spatialdata.models import SpatialElement + + +MappingToCoordinateSystem_t = dict[str, BaseTransformation] def _get_transformations_from_dict_container(dict_container: Any) -> MappingToCoordinateSystem_t | None: @@ -81,11 +84,10 @@ def _(e: DataArray, transformations: MappingToCoordinateSystem_t) -> None: @_set_transformations.register(DataTree) def _(e: DataTree, transformations: MappingToCoordinateSystem_t) -> None: - from spatialdata.transformations._graph.utils import get_axes_names + from spatialdata.models import get_axes_names # set the transformation at the highest level and concatenate with the appropriate scale at each level dims = get_axes_names(e) - from spatialdata.transformations.transformations import Scale, Sequence old_shape: ArrayLike | None = None for i, (scale, node) in enumerate(dict(e).items()): @@ -208,7 +210,7 @@ def _(data: DataArray) -> DataArray: @compute_coordinates.register(DataTree) def _(data: DataTree) -> DataTree: - from spatialdata.transformations._graph.utils import get_axes_names + from spatialdata.models._utils import get_axes_names spatial_coords = [ax for ax in get_axes_names(data) if ax in ["x", "y", "z"]] img_name = list(data["scale0"].data_vars.keys())[0] @@ -291,3 +293,15 @@ def convert_transformations_to_affine(sdata: SpatialData, coordinate_system: str if not isinstance(t, Affine): affine = _get_affine_for_element(element, t) set_transformation(element, transformation=affine, to_coordinate_system=coordinate_system) + + +# added this code as part of a refactoring to catch errors earlier +def _validate_mapping_to_coordinate_system_type(transformations: MappingToCoordinateSystem_t | None) -> None: + if not ( + transformations is None + or isinstance(transformations, dict) + and all(isinstance(k, str) and isinstance(v, BaseTransformation) for k, v in transformations.items()) + ): + raise TypeError( + f"Transform must be of type {MappingToCoordinateSystem_t} or None, but is of type {type(transformations)}." + ) diff --git a/src/spatialdata/transformations/ngff/_utils.py b/src/spatialdata/transformations/ngff/_utils.py index 34210c147..8652252c4 100644 --- a/src/spatialdata/transformations/ngff/_utils.py +++ b/src/spatialdata/transformations/ngff/_utils.py @@ -2,7 +2,7 @@ import copy -from spatialdata.transformations._graph import C, X, Y, Z +from spatialdata.literals import C, X, Y, Z from spatialdata.transformations.ngff.ngff_coordinate_system import NgffAxis, NgffCoordinateSystem __all__ = "get_default_coordinate_system" diff --git a/src/spatialdata/transformations/ngff/ngff_transformations.py b/src/spatialdata/transformations/ngff/ngff_transformations.py index ea4507236..9caf84584 100644 --- a/src/spatialdata/transformations/ngff/ngff_transformations.py +++ b/src/spatialdata/transformations/ngff/ngff_transformations.py @@ -8,7 +8,7 @@ import numpy as np from spatialdata._types import ArrayLike -from spatialdata.transformations._graph import C, X, Y, Z +from spatialdata.literals import C, X, Y, Z from spatialdata.transformations.ngff.ngff_coordinate_system import NgffCoordinateSystem __all__ = [ diff --git a/src/spatialdata/transformations/operations.py b/src/spatialdata/transformations/operations.py index 2ea43df8f..9b9abfcb3 100644 --- a/src/spatialdata/transformations/operations.py +++ b/src/spatialdata/transformations/operations.py @@ -6,17 +6,18 @@ import numpy as np from dask.dataframe import DataFrame as DaskDataFrame from geopandas import GeoDataFrame +from skimage.transform import estimate_transform from spatialdata._logging import logger -from spatialdata.transformations._graph.literals import DEFAULT_COORDINATE_SYSTEM +from spatialdata.literals import DEFAULT_COORDINATE_SYSTEM from spatialdata.transformations._utils import _get_transformations, _set_transformations +from spatialdata.transformations.transformations import Affine, BaseTransformation, Sequence if TYPE_CHECKING: import networkx as nx from spatialdata._core.spatialdata import SpatialData from spatialdata.models import SpatialElement - from spatialdata.transformations.transformations import Affine, BaseTransformation def set_transformation( @@ -389,11 +390,8 @@ def get_transformation_between_landmarks( >>> references_coords = PointsModel(points_reference) >>> transformation = get_transformation_between_landmarks(references_coords, moving_coords) """ - from skimage.transform import estimate_transform - from spatialdata import transform - from spatialdata.transformations._graph.utils import get_axes_names - from spatialdata.transformations.transformations import Affine, Sequence + from spatialdata.models import get_axes_names assert get_axes_names(references_coords) == ("x", "y") assert get_axes_names(moving_coords) == ("x", "y") diff --git a/src/spatialdata/transformations/transformations.py b/src/spatialdata/transformations/transformations.py index 76911f1d1..400b69db9 100644 --- a/src/spatialdata/transformations/transformations.py +++ b/src/spatialdata/transformations/transformations.py @@ -9,6 +9,8 @@ import xarray as xr from xarray import DataArray +from spatialdata.literals import DEFAULT_COORDINATE_SYSTEM +from spatialdata.models import get_axes_names from spatialdata.transformations._graph import ( AffineEdge, Axis, @@ -26,7 +28,6 @@ UnmappedAxisError, ) from spatialdata.transformations._graph.exceptions import IncompatibleTransformationOutputCoordinateSystemError -from spatialdata.transformations._graph.literals import DEFAULT_COORDINATE_SYSTEM from spatialdata.transformations.ngff._utils import get_default_coordinate_system from spatialdata.transformations.ngff.ngff_coordinate_system import NgffCoordinateSystem, _get_spatial_axes from spatialdata.transformations.ngff.ngff_transformations import ( @@ -1301,7 +1302,6 @@ def __eq__(self, other: Any) -> bool: def _get_affine_for_element(element: SpatialElement, transformation: BaseTransformation) -> Affine: - from spatialdata.transformations._graph.utils import get_axes_names input_axes = get_axes_names(element) output_axes = transformation._get_resulting_output_axes(input_axes) diff --git a/tests/core/operations/test_rasterize.py b/tests/core/operations/test_rasterize.py index 91116f843..48fa4a9bf 100644 --- a/tests/core/operations/test_rasterize.py +++ b/tests/core/operations/test_rasterize.py @@ -17,8 +17,8 @@ from spatialdata._core.query.relational_query import get_element_instances from spatialdata._utils import get_pyramid_levels from spatialdata.models import PointsModel, ShapesModel, TableModel -from spatialdata.transformations import MapAxis -from spatialdata.transformations._graph.utils import get_axes_names, get_spatial_axes +from spatialdata.models._utils import get_axes_names +from spatialdata.transformations import MapAxis, get_spatial_axes from tests.conftest import _get_images, _get_labels diff --git a/tests/core/operations/test_transform.py b/tests/core/operations/test_transform.py index cf95cfbe6..3286fe312 100644 --- a/tests/core/operations/test_transform.py +++ b/tests/core/operations/test_transform.py @@ -17,7 +17,7 @@ from spatialdata._core.spatialdata import SpatialData from spatialdata._utils import disable_dask_tune_optimization, unpad_raster from spatialdata.models import Image2DModel, PointsModel, ShapesModel -from spatialdata.transformations._graph.utils import get_axes_names +from spatialdata.models._utils import get_axes_names from spatialdata.transformations.operations import ( align_elements_using_landmarks, get_transformation, diff --git a/tests/core/test_centroids.py b/tests/core/test_centroids.py index d80a9a0f4..5c1f05ae6 100644 --- a/tests/core/test_centroids.py +++ b/tests/core/test_centroids.py @@ -11,8 +11,8 @@ from spatialdata._core.centroids import get_centroids from spatialdata._core.query.relational_query import get_element_instances from spatialdata.models import Labels2DModel, Labels3DModel, PointsModel, TableModel +from spatialdata.models._utils import get_axes_names from spatialdata.transformations import Affine, Identity, get_transformation, set_transformation -from spatialdata.transformations._graph.utils import get_axes_names RNG = default_rng(42) diff --git a/tests/models/test_models.py b/tests/models/test_models.py index 88e80871d..d39c17de4 100644 --- a/tests/models/test_models.py +++ b/tests/models/test_models.py @@ -45,11 +45,12 @@ ) from spatialdata.models._utils import ( force_2d, + get_axes_names, points_dask_dataframe_to_geopandas, points_geopandas_to_dask_dataframe, ) from spatialdata.testing import assert_elements_are_identical -from spatialdata.transformations._graph.utils import get_axes_names, validate_axis_name +from spatialdata.transformations._graph.utils import validate_axis_name from spatialdata.transformations._utils import _set_transformations from spatialdata.transformations.operations import get_transformation, set_transformation from spatialdata.transformations.transformations import Identity, Scale diff --git a/tests/transformations/graph/conftest.py b/tests/transformations/graph/conftest.py index fec31dcc2..9a6f21752 100644 --- a/tests/transformations/graph/conftest.py +++ b/tests/transformations/graph/conftest.py @@ -6,7 +6,8 @@ import pytest -from spatialdata.transformations._graph import Axis, CoordSystem, NgffAxisType +from spatialdata.literals import NgffAxisType +from spatialdata.transformations._graph import Axis, CoordSystem # ---------------------------------------------------------------------- diff --git a/tests/transformations/graph/graph/test_graph.py b/tests/transformations/graph/graph/test_graph.py index 9aa1c103c..c5ca7e7ef 100644 --- a/tests/transformations/graph/graph/test_graph.py +++ b/tests/transformations/graph/graph/test_graph.py @@ -3,8 +3,8 @@ import pytest +from spatialdata.literals import TRANSFORM_KEY from spatialdata.transformations._graph import ( - TRANSFORM_KEY, BaseTransformationEdge, CannotRemoveCoordinateSystemError, CoordinateSystemAlreadyExistsError, diff --git a/tests/transformations/graph/utils/test_get_union_of_axes.py b/tests/transformations/graph/utils/test_get_union_of_axes.py index b5361fb88..b23d48e53 100644 --- a/tests/transformations/graph/utils/test_get_union_of_axes.py +++ b/tests/transformations/graph/utils/test_get_union_of_axes.py @@ -10,7 +10,8 @@ import pytest -from spatialdata.transformations._graph import Axis, NgffAxisType, get_union_of_axes +from spatialdata.literals import NgffAxisType +from spatialdata.transformations._graph import Axis, get_union_of_axes # ---------------------------------------------------------------------- diff --git a/tests/transformations/ngff/test_ngff_transformations.py b/tests/transformations/ngff/test_ngff_transformations.py index e2a0fa4ec..ef3803e15 100644 --- a/tests/transformations/ngff/test_ngff_transformations.py +++ b/tests/transformations/ngff/test_ngff_transformations.py @@ -8,7 +8,7 @@ import pytest from spatialdata._types import ArrayLike -from spatialdata.transformations._graph import C, X, Y, Z +from spatialdata.literals import C, X, Y, Z from spatialdata.transformations.ngff._utils import get_default_coordinate_system from spatialdata.transformations.ngff.ngff_coordinate_system import NgffCoordinateSystem from spatialdata.transformations.ngff.ngff_transformations import ( diff --git a/tests/transformations/test_transformations.py b/tests/transformations/test_transformations.py index e6b0ac3e8..63fcf7ecf 100644 --- a/tests/transformations/test_transformations.py +++ b/tests/transformations/test_transformations.py @@ -10,8 +10,8 @@ from spatialdata import transform from spatialdata.datasets import blobs +from spatialdata.literals import DEFAULT_COORDINATE_SYSTEM from spatialdata.models import Image2DModel, PointsModel, get_channel_names -from spatialdata.transformations._graph.literals import DEFAULT_COORDINATE_SYSTEM from spatialdata.transformations._graph.types import ValidAxis_t from spatialdata.transformations.ngff._utils import get_default_coordinate_system from spatialdata.transformations.ngff.ngff_coordinate_system import NgffCoordinateSystem From 743a9c372545060ef888dfc6bc0c16ee95e75c64 Mon Sep 17 00:00:00 2001 From: Ajayrama Kumaraswamy Date: Tue, 27 Oct 2026 22:27:38 +0100 Subject: [PATCH 53/54] fix (attrs accessor registration for dask data structures) explicit function call --- src/spatialdata/__init__.py | 3 ++- src/spatialdata/models/_accessor.py | 10 ++++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/spatialdata/__init__.py b/src/spatialdata/__init__.py index a4e98e70e..82292e65f 100644 --- a/src/spatialdata/__init__.py +++ b/src/spatialdata/__init__.py @@ -4,9 +4,10 @@ from importlib.metadata import version from typing import TYPE_CHECKING, Any -import spatialdata.models._accessor +from spatialdata.models._accessor import register_attrs_accessor_dask_df_series # Import registers Dask .attrs accessor, enabling metadata handling on Dask collections +register_attrs_accessor_dask_df_series() __version__ = version("spatialdata") diff --git a/src/spatialdata/models/_accessor.py b/src/spatialdata/models/_accessor.py index 77acf437c..7dec5acf4 100644 --- a/src/spatialdata/models/_accessor.py +++ b/src/spatialdata/models/_accessor.py @@ -138,7 +138,9 @@ def __repr__(self) -> str: ]: wrap_method_with_attrs(method_name=method_name, dask_class=DaskSeries) -wrap_indexer_with_attrs(indexer_name="loc", dask_class=DaskDataFrame) -wrap_indexer_with_attrs(indexer_name="iloc", dask_class=DaskDataFrame) -wrap_indexer_with_attrs(indexer_name="loc", dask_class=DaskSeries) -# DaskSeries do not have iloc + +def register_attrs_accessor_dask_df_series() -> None: + wrap_indexer_with_attrs(indexer_name="loc", dask_class=DaskDataFrame) + wrap_indexer_with_attrs(indexer_name="iloc", dask_class=DaskDataFrame) + wrap_indexer_with_attrs(indexer_name="loc", dask_class=DaskSeries) + # DaskSeries do not have iloc From 14578ff6f4726448040a19cb71dacd566d464362 Mon Sep 17 00:00:00 2001 From: Ajayrama Kumaraswamy Date: Tue, 27 Oct 2026 22:28:36 +0100 Subject: [PATCH 54/54] fix (docs): moved get_spatial_axes from model utils to tranformations utils --- docs/api/models_utils.md | 1 - docs/api/transformations_utils.md | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/api/models_utils.md b/docs/api/models_utils.md index c2a74803b..071cd8ba7 100644 --- a/docs/api/models_utils.md +++ b/docs/api/models_utils.md @@ -7,7 +7,6 @@ .. autofunction:: validate_element .. autodata:: SpatialElement .. autofunction:: get_axes_names -.. autofunction:: get_spatial_axes .. autofunction:: points_geopandas_to_dask_dataframe .. autofunction:: points_dask_dataframe_to_geopandas .. autofunction:: get_channel_names diff --git a/docs/api/transformations_utils.md b/docs/api/transformations_utils.md index dafa228b6..307b4aee4 100644 --- a/docs/api/transformations_utils.md +++ b/docs/api/transformations_utils.md @@ -10,4 +10,5 @@ .. autofunction:: get_transformation_between_landmarks .. autofunction:: align_elements_using_landmarks .. autofunction:: remove_transformations_to_coordinate_system +.. autofunction:: get_spatial_axes ```