diff --git a/README.md b/README.md index 1a30b6b..ee93e65 100644 --- a/README.md +++ b/README.md @@ -137,6 +137,12 @@ pip install "hoct[bioio]" ``` The `bioio` extra is needed for the `track` CLI (reading image/label files). +HOCT uses the open-source SCIP backend by default. To make Gurobi available as +an alternative solver, install the optional extra and configure its license: + +```bash +pip install "hoct[gurobi]" +``` ## Installation (for developers) @@ -178,6 +184,25 @@ solution_graph = predict(model, labels=labels, images=images) solution_graph.to_geff("tracks.geff") ``` +For Fiji, Appose, or other array-based integrations, convert the solution to a +label-stable result. Endpoints are identified by the original segmentation +label together with its frame, never by HOCT's internal graph node ID: + +```python +from hoct import solution_to_tracking_result + +result = solution_to_tracking_result(solution_graph) +result.detections # int64 (N, 2): [t, label_id] +result.links # int64 (M, 4): [source_t, source_label, target_t, target_label] +result.similarities # float64 (M,): model confidence for each link +link_table = result.link_table() # float64 (M, 5) convenience representation +``` + +The arrays owned by `result` are defensive, read-only copies and remain valid +independently of the graph. Label values may be sparse and may be reused in +different frames. If no candidate link exists, prediction returns all input +detections as isolated objects with exact empty link and similarity arrays. + See `hoct.predict` for the full signature (custom solver config, tiled inference, test-time augmentation, etc.). diff --git a/pyproject.toml b/pyproject.toml index d760265..e13bb42 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,7 +41,6 @@ dependencies = [ "pyyaml>=6.0.3", "typer>=0.21.1", "rich>=14.3.1", - "gurobipy>12.0.1,<13.0.0", "matplotlib>=3.10.8", ] @@ -50,6 +49,9 @@ Homepage = "https://github.com/royerlab/hoct" Repository = "https://github.com/royerlab/hoct" [project.optional-dependencies] +gurobi = [ + "gurobipy>12.0.1,<13.0.0", +] correction = [ "scikit-learn>=1.6.0", ] diff --git a/src/hoct/__init__.py b/src/hoct/__init__.py index 563200e..3803cee 100644 --- a/src/hoct/__init__.py +++ b/src/hoct/__init__.py @@ -2,6 +2,14 @@ from hoct.__about__ import __version__ from hoct._api import predict +from hoct._interop import TrackingResult, solution_to_tracking_result from hoct._models import available_models, load_model -__all__ = ["__version__", "available_models", "load_model", "predict"] +__all__ = [ + "TrackingResult", + "__version__", + "available_models", + "load_model", + "predict", + "solution_to_tracking_result", +] diff --git a/src/hoct/_api.py b/src/hoct/_api.py index 8b266ed..35eb06a 100644 --- a/src/hoct/_api.py +++ b/src/hoct/_api.py @@ -321,6 +321,10 @@ def predict( LOG.info(f"Created graph with {graph.num_nodes()} nodes and {graph.num_edges()} edges") + if graph.num_edges() == 0: + LOG.warning("Candidate graph has no edges; returning detections without links") + return graph if return_solution else None + dataset = _create_dataset(graph, tiling_scheme, window_size, test_time_augs) LOG.info("Running model inference and solving tracking") diff --git a/src/hoct/_interop.py b/src/hoct/_interop.py new file mode 100644 index 0000000..9e1edb7 --- /dev/null +++ b/src/hoct/_interop.py @@ -0,0 +1,195 @@ +"""Stable array-based representations of HOCT tracking solutions.""" + +from dataclasses import dataclass +from typing import Any + +import numpy as np +import polars as pl +import tracksdata as td +from numpy.typing import NDArray + +from hoct.features.graph import LABEL_ID_KEY + +__all__ = ["TrackingResult", "solution_to_tracking_result"] + + +def _readonly_array(value: Any, *, dtype: np.dtype, columns: int, name: str) -> NDArray: + array = np.asarray(value) + if array.ndim != 2 or array.shape[1] != columns: + raise ValueError(f"{name} must have shape (N, {columns}), got {array.shape}") + if np.issubdtype(dtype, np.integer) and not np.issubdtype(array.dtype, np.integer): + raise TypeError(f"{name} must contain integers, got dtype {array.dtype}") + if not np.issubdtype(array.dtype, np.number): + raise TypeError(f"{name} must contain numbers, got dtype {array.dtype}") + result = np.array(array, dtype=dtype, copy=True, order="C") + result.setflags(write=False) + return result + + +def _readonly_similarities(value: Any) -> NDArray[np.float64]: + array = np.asarray(value) + if array.ndim != 1: + raise ValueError(f"similarities must have shape (N,), got {array.shape}") + if not np.issubdtype(array.dtype, np.number): + raise TypeError(f"similarities must contain numbers, got dtype {array.dtype}") + result = np.array(array, dtype=np.float64, copy=True, order="C") + result.setflags(write=False) + return result + + +@dataclass(frozen=True) +class TrackingResult: + """A label-stable tracking solution suitable for language bridges. + + Attributes + ---------- + detections : numpy.ndarray + Integer ``(N, 2)`` array whose columns are ``[t, label_id]``. + links : numpy.ndarray + Integer ``(M, 4)`` array whose columns are + ``[source_t, source_label, target_t, target_label]``. + similarities : numpy.ndarray + Floating-point ``(M,)`` array containing the model similarity for each + corresponding link. + """ + + detections: NDArray[np.int64] + links: NDArray[np.int64] + similarities: NDArray[np.float64] + + def __post_init__(self) -> None: + detections = _readonly_array(self.detections, dtype=np.dtype(np.int64), columns=2, name="detections") + links = _readonly_array(self.links, dtype=np.dtype(np.int64), columns=4, name="links") + similarities = _readonly_similarities(self.similarities) + + if links.shape[0] != similarities.shape[0]: + raise ValueError( + f"links and similarities must have the same number of rows, got {links.shape[0]} and " + f"{similarities.shape[0]}" + ) + + detection_ids = [tuple(row) for row in detections.tolist()] + if len(detection_ids) != len(set(detection_ids)): + raise ValueError("detections must contain unique (t, label_id) pairs") + + known_detections = set(detection_ids) + missing_endpoints = { + endpoint + for row in links.tolist() + for endpoint in (tuple(row[:2]), tuple(row[2:])) + if endpoint not in known_detections + } + if missing_endpoints: + raise ValueError(f"link endpoints are missing from detections: {sorted(missing_endpoints)}") + + object.__setattr__(self, "detections", detections) + object.__setattr__(self, "links", links) + object.__setattr__(self, "similarities", similarities) + + def link_table(self) -> NDArray[np.float64]: + """Return links and similarities as a homogeneous ``(M, 5)`` array.""" + table = np.empty((self.links.shape[0], 5), dtype=np.float64) + table[:, :4] = self.links + table[:, 4] = self.similarities + table.setflags(write=False) + return table + + +def _validate_solution_schema(graph: td.graph.BaseGraph) -> bool: + solution_key = td.DEFAULT_ATTR_KEYS.SOLUTION + has_node_solution = solution_key in graph.node_attr_keys() + has_edge_solution = solution_key in graph.edge_attr_keys() + if has_node_solution != has_edge_solution: + raise ValueError("solution must be present on both nodes and edges, or on neither") + return has_node_solution + + +def solution_to_tracking_result(graph: td.graph.BaseGraph) -> TrackingResult: + """Convert a HOCT solution graph into a stable, array-based result. + + A full candidate graph is filtered using its boolean ``solution`` node and + edge attributes. An already-filtered graph without these attributes is + interpreted as containing only selected nodes and links. + + Parameters + ---------- + graph : tracksdata.graph.BaseGraph + Full or already-filtered tracking solution. Nodes must carry ``t`` and + ``label_id``. Selected edges must carry ``similarity``. + + Returns + ------- + TrackingResult + Deterministically sorted detections, links, and link similarities. + """ + required_node_keys = {td.DEFAULT_ATTR_KEYS.T, LABEL_ID_KEY} + missing_node_keys = required_node_keys - set(graph.node_attr_keys()) + if missing_node_keys: + raise ValueError(f"solution graph is missing node attributes: {sorted(missing_node_keys)}") + + has_solution = _validate_solution_schema(graph) + solution_key = td.DEFAULT_ATTR_KEYS.SOLUTION + node_id_key = td.DEFAULT_ATTR_KEYS.NODE_ID + node_keys = [node_id_key, td.DEFAULT_ATTR_KEYS.T, LABEL_ID_KEY] + if has_solution: + node_keys.append(solution_key) + nodes = graph.node_attrs(attr_keys=node_keys) + if has_solution: + if nodes.schema[solution_key] != pl.Boolean: + raise TypeError(f"node '{solution_key}' attribute must be boolean") + nodes = nodes.filter(pl.col(solution_key)) + + selected_nodes = { + int(node_id): (int(t), int(label)) + for node_id, t, label in nodes.select(node_id_key, td.DEFAULT_ATTR_KEYS.T, LABEL_ID_KEY).iter_rows() + } + detections = np.asarray(sorted(selected_nodes.values()), dtype=np.int64).reshape((-1, 2)) + + edge_id_key = td.DEFAULT_ATTR_KEYS.EDGE_ID + source_key = td.DEFAULT_ATTR_KEYS.EDGE_SOURCE + target_key = td.DEFAULT_ATTR_KEYS.EDGE_TARGET + edge_keys = [edge_id_key, source_key, target_key] + if has_solution: + edge_keys.append(solution_key) + edges = graph.edge_attrs(attr_keys=edge_keys) + if has_solution: + if edges.schema[solution_key] != pl.Boolean: + raise TypeError(f"edge '{solution_key}' attribute must be boolean") + edges = edges.filter(pl.col(solution_key)) + + if edges.height == 0: + return TrackingResult( + detections=detections, + links=np.empty((0, 4), dtype=np.int64), + similarities=np.empty((0,), dtype=np.float64), + ) + + if "similarity" not in graph.edge_attr_keys(): + raise ValueError("selected solution edges require a 'similarity' attribute") + + selected_edge_ids = edges[edge_id_key].implode() + selected_edges = graph.edge_attrs(attr_keys=[edge_id_key, source_key, target_key, "similarity"]).filter( + pl.col(edge_id_key).is_in(selected_edge_ids) + ) + + records: list[tuple[int, int, int, int, float, int]] = [] + missing_endpoints: set[int] = set() + for edge_id, similarity, source_id, target_id in selected_edges.select( + edge_id_key, "similarity", source_key, target_key + ).iter_rows(): + source = selected_nodes.get(int(source_id)) + target = selected_nodes.get(int(target_id)) + if source is None: + missing_endpoints.add(int(source_id)) + if target is None: + missing_endpoints.add(int(target_id)) + if source is not None and target is not None: + records.append((*source, *target, float(similarity), int(edge_id))) + + if missing_endpoints: + raise ValueError(f"selected link endpoints are not selected detections: {sorted(missing_endpoints)}") + + records.sort(key=lambda row: (*row[:4], row[5])) + links = np.asarray([row[:4] for row in records], dtype=np.int64).reshape((-1, 4)) + similarities = np.asarray([row[4] for row in records], dtype=np.float64) + return TrackingResult(detections=detections, links=links, similarities=similarities) diff --git a/src/hoct/_tests/test_api.py b/src/hoct/_tests/test_api.py index a8172dd..c6a0aae 100644 --- a/src/hoct/_tests/test_api.py +++ b/src/hoct/_tests/test_api.py @@ -5,7 +5,9 @@ import numpy as np import pytest +import tracksdata as td +from hoct import predict, solution_to_tracking_result from hoct.features.constants import REGIONPROPS from hoct.features.graph import create_graph from hoct.tracking import ILPSolverConfig @@ -99,6 +101,99 @@ def test_inference_mode_no_gt_features(self, synthetic_2d_labels): edge_attrs = graph.edge_attr_keys() assert "edge_is_gt" not in edge_attrs + def test_preserves_sparse_and_reused_label_ids(self): + labels = np.zeros((2, 16, 16), dtype=np.uint32) + labels[0, 1:4, 1:4] = 7 + labels[0, 8:11, 8:11] = 42 + labels[1, 2:5, 2:5] = 7 + + graph = create_graph(labels, distance_threshold=30.0, n_neighbors=5, delta_t=1) + identities = graph.node_attrs(attr_keys=["t", "label_id"]).select("t", "label_id").rows() + + assert sorted(identities) == [(0, 7), (0, 42), (1, 7)] + assert "label_id" not in REGIONPROPS + + def test_scale_controls_candidate_geometry_without_changing_raw_coordinates(self): + labels = np.zeros((2, 12, 12), dtype=np.uint16) + labels[0, 1:3, 1:3] = 7 + labels[1, 1:3, 5:7] = 42 + + unscaled = create_graph(labels, distance_threshold=5.0, n_neighbors=3, delta_t=1) + scaled = create_graph(labels, distance_threshold=5.0, n_neighbors=3, delta_t=1, scale=(1.0, 1.0, 2.0)) + + assert unscaled.num_edges() == 1 + assert scaled.num_edges() == 0 + assert ( + unscaled.node_attrs(attr_keys=["z", "y", "x"]) + .select("z", "y", "x") + .equals(scaled.node_attrs(attr_keys=["z", "y", "x"]).select("z", "y", "x")) + ) + scaled_positions = scaled.node_attrs(attr_keys=["x", "scaled_x"]) + np.testing.assert_allclose(scaled_positions["scaled_x"], scaled_positions["x"] * 2.0) + assert scaled.metadata["scale"] == (1.0, 1.0, 1.0, 2.0) + + @pytest.mark.parametrize( + ("shape", "scale", "expected"), + [ + ((2, 8, 8), (1.0, 1.0, 1.0, 1.0), "3 elements"), + ((2, 3, 8, 8), (1.0, 1.0, 1.0), "4 elements"), + ], + ) + def test_scale_length_is_validated_for_original_dimensions(self, shape, scale, expected): + labels = np.zeros(shape, dtype=np.uint16) + + with pytest.raises(ValueError, match=expected): + create_graph(labels, distance_threshold=5.0, n_neighbors=3, delta_t=1, scale=scale) + + def test_out_graph_is_updated_in_place_with_interop_schema(self): + labels = np.zeros((2, 8, 8), dtype=np.uint16) + labels[0, 1:3, 1:3] = 7 + labels[1, 2:4, 2:4] = 42 + out_graph = td.graph.InMemoryGraph() + + result = create_graph( + labels, + out_graph=out_graph, + distance_threshold=5.0, + n_neighbors=3, + delta_t=1, + ) + + assert result is out_graph + assert {"label_id", "scaled_z", "scaled_y", "scaled_x"} <= set(result.node_attr_keys()) + + @pytest.mark.parametrize("shape", [(2, 8, 8), (2, 3, 8, 8)]) + def test_empty_graph_has_interop_and_scaled_coordinate_schema(self, shape): + labels = np.zeros(shape, dtype=np.uint16) + + graph = create_graph(labels, distance_threshold=5.0, n_neighbors=3, delta_t=1) + + assert graph.num_nodes() == 0 + assert graph.num_edges() == 0 + assert {"label_id", "scaled_z", "scaled_y", "scaled_x"} <= set(graph.node_attr_keys()) + + def test_predict_returns_an_exact_empty_result_without_running_the_model(self): + labels = np.zeros((2, 8, 8), dtype=np.uint16) + + graph = predict(None, labels=labels, distance_threshold=5.0, n_neighbors=3, max_delta_t=1) + result = solution_to_tracking_result(graph) + + assert result.detections.shape == (0, 2) + assert result.links.shape == (0, 4) + assert result.similarities.shape == (0,) + + def test_predict_returns_isolated_detections_when_no_candidate_link_exists(self): + labels = np.zeros((2, 16, 16), dtype=np.uint16) + labels[0, 1:3, 1:3] = 7 + labels[1, 12:14, 12:14] = 42 + + graph = predict(None, labels=labels, distance_threshold=1.0, n_neighbors=3, max_delta_t=1) + result = solution_to_tracking_result(graph) + + np.testing.assert_array_equal(result.detections, [[0, 7], [1, 42]]) + assert result.links.shape == (0, 4) + assert result.similarities.shape == (0,) + class TestSolverConfig: """Tests for ILPSolverConfig validation and immutability.""" diff --git a/src/hoct/_tests/test_interop.py b/src/hoct/_tests/test_interop.py new file mode 100644 index 0000000..a7f3d6a --- /dev/null +++ b/src/hoct/_tests/test_interop.py @@ -0,0 +1,166 @@ +"""Tests for label-stable array interop results.""" + +import numpy as np +import polars as pl +import pytest +import tracksdata as td + +from hoct import TrackingResult, solution_to_tracking_result + + +def _tracking_graph(*, solution_attrs: bool, similarities: bool = True) -> td.graph.InMemoryGraph: + graph = td.graph.InMemoryGraph() + graph.add_node_attr_key("label_id", pl.Int64, 0) + if solution_attrs: + graph.add_node_attr_key("solution", pl.Boolean, False) + if similarities: + graph.add_edge_attr_key("similarity", pl.Float64, 0.0) + if solution_attrs: + graph.add_edge_attr_key("solution", pl.Boolean, False) + + node_specs = [ + (1, 42, True), + (0, 42, False), + (0, 7, True), + (1, 7, True), + ] + node_ids = [ + graph.add_node( + { + "t": t, + "label_id": label, + **({"solution": selected} if solution_attrs else {}), + } + ) + for t, label, selected in node_specs + ] + + edge_specs = [ + (node_ids[2], node_ids[0], 0.6, True), + (node_ids[1], node_ids[0], 0.9, False), + (node_ids[2], node_ids[3], 0.8, True), + ] + for source, target, similarity, selected in edge_specs: + graph.add_edge( + source, + target, + { + **({"similarity": similarity} if similarities else {}), + **({"solution": selected} if solution_attrs else {}), + }, + ) + return graph + + +def test_solution_graph_is_filtered_and_sorted_deterministically(): + result = solution_to_tracking_result(_tracking_graph(solution_attrs=True)) + + np.testing.assert_array_equal(result.detections, [[0, 7], [1, 7], [1, 42]]) + np.testing.assert_array_equal(result.links, [[0, 7, 1, 7], [0, 7, 1, 42]]) + np.testing.assert_allclose(result.similarities, [0.8, 0.6]) + np.testing.assert_allclose( + result.link_table(), + [[0.0, 7.0, 1.0, 7.0, 0.8], [0.0, 7.0, 1.0, 42.0, 0.6]], + ) + + +def test_already_filtered_graph_uses_every_node_and_edge(): + graph = _tracking_graph(solution_attrs=False) + + result = solution_to_tracking_result(graph) + + np.testing.assert_array_equal(result.detections, [[0, 7], [0, 42], [1, 7], [1, 42]]) + np.testing.assert_array_equal( + result.links, + [[0, 7, 1, 7], [0, 7, 1, 42], [0, 42, 1, 42]], + ) + np.testing.assert_allclose(result.similarities, [0.8, 0.6, 0.9]) + + +def test_result_defensively_copies_and_makes_arrays_read_only(): + detections = np.array([[0, 7], [1, 42]], dtype=np.int64) + links = np.array([[0, 7, 1, 42]], dtype=np.int64) + similarities = np.array([0.75], dtype=np.float64) + + result = TrackingResult(detections, links, similarities) + detections[0, 1] = 99 + links[0, 1] = 99 + similarities[0] = 0.0 + + np.testing.assert_array_equal(result.detections, [[0, 7], [1, 42]]) + np.testing.assert_array_equal(result.links, [[0, 7, 1, 42]]) + np.testing.assert_allclose(result.similarities, [0.75]) + assert not result.detections.flags.writeable + assert not result.links.flags.writeable + assert not result.similarities.flags.writeable + assert not result.link_table().flags.writeable + with pytest.raises(ValueError, match="read-only"): + result.links[0, 0] = 1 + + +def test_empty_result_has_exact_shapes_without_similarity_schema(): + graph = td.graph.InMemoryGraph() + graph.add_node_attr_key("label_id", pl.Int64, 0) + + result = solution_to_tracking_result(graph) + + assert result.detections.shape == (0, 2) + assert result.detections.dtype == np.int64 + assert result.links.shape == (0, 4) + assert result.links.dtype == np.int64 + assert result.similarities.shape == (0,) + assert result.similarities.dtype == np.float64 + assert result.link_table().shape == (0, 5) + + +def test_selected_edge_requires_similarity(): + graph = _tracking_graph(solution_attrs=False, similarities=False) + + with pytest.raises(ValueError, match="similarity"): + solution_to_tracking_result(graph) + + +def test_selected_edge_requires_selected_endpoints(): + graph = _tracking_graph(solution_attrs=True) + edge_ids = graph.edge_attrs()["edge_id"].to_list() + graph.update_edge_attrs(attrs={"solution": True}, edge_ids=[edge_ids[1]]) + + with pytest.raises(ValueError, match="not selected detections"): + solution_to_tracking_result(graph) + + +def test_label_id_is_required(): + graph = td.graph.InMemoryGraph() + + with pytest.raises(ValueError, match="label_id"): + solution_to_tracking_result(graph) + + +def test_solution_attributes_must_be_present_on_nodes_and_edges(): + graph = td.graph.InMemoryGraph() + graph.add_node_attr_key("label_id", pl.Int64, 0) + graph.add_node_attr_key("solution", pl.Boolean, False) + + with pytest.raises(ValueError, match="both nodes and edges"): + solution_to_tracking_result(graph) + + +def test_solution_attributes_must_be_boolean(): + graph = td.graph.InMemoryGraph() + graph.add_node_attr_key("label_id", pl.Int64, 0) + graph.add_node_attr_key("solution", pl.Int32, 0) + graph.add_edge_attr_key("solution", pl.Int32, 0) + + with pytest.raises(TypeError, match="must be boolean"): + solution_to_tracking_result(graph) + + +def test_tracking_result_validates_shapes_rows_and_endpoints(): + with pytest.raises(ValueError, match="shape"): + TrackingResult(np.array([0, 7]), np.empty((0, 4), dtype=np.int64), np.empty((0,))) + with pytest.raises(ValueError, match="same number"): + TrackingResult(np.array([[0, 7]]), np.empty((0, 4), dtype=np.int64), np.array([0.5])) + with pytest.raises(ValueError, match="missing from detections"): + TrackingResult(np.array([[0, 7]]), np.array([[0, 7, 1, 7]]), np.array([0.5])) + with pytest.raises(ValueError, match="unique"): + TrackingResult(np.array([[0, 7], [0, 7]]), np.empty((0, 4), dtype=np.int64), np.empty((0,))) diff --git a/src/hoct/features/graph.py b/src/hoct/features/graph.py index 64de854..ea85182 100644 --- a/src/hoct/features/graph.py +++ b/src/hoct/features/graph.py @@ -12,6 +12,15 @@ from hoct.features.constants import EDGE_GT_KEY, REGIONPROPS from hoct.features.features import add_border_dist, add_delta_t, add_is_div, normalize_image +LABEL_ID_KEY = "label_id" +_SPATIAL_KEYS = ("z", "y", "x") +_SCALED_SPATIAL_KEYS = tuple(f"scaled_{key}" for key in _SPATIAL_KEYS) + + +def label_id(region: Any) -> int: + """Return the original integer label for a segmented region.""" + return int(region.label) + def convert_to_3d(graph: td.graph.RustWorkXGraph) -> None: """ @@ -139,11 +148,21 @@ def create_graph( normalize_kwargs = {} images = images.map_blocks(normalize_image, **normalize_kwargs) + original_ndim = labels.ndim + if original_ndim not in (3, 4): + raise ValueError(f"Labels must be 3D (T, Y, X) or 4D (T, Z, Y, X), got shape: {labels.shape}") + if scale is None: - scale = (1.0,) * labels.ndim + scale = (1.0,) * original_ndim + elif len(scale) != original_ndim: + expected_axes = "t, y, x" if original_ndim == 3 else "t, z, y, x" + raise ValueError( + f"Scale must have {original_ndim} elements ({expected_axes}) for labels with shape {labels.shape}, " + f"got {len(scale)}" + ) # Handle 2D vs 3D data - if labels.ndim == 3: + if original_ndim == 3: labels = da.expand_dims(labels, axis=1) was_2d = True # 2D+t case @@ -152,15 +171,10 @@ def create_graph( scale = (scale[0], 1.0, *scale[1:]) - elif labels.ndim == 4: + else: # 3D+t case was_2d = False - else: - raise ValueError(f"Labels must be 3D (T, Y, X) or 4D (T, Z, Y, X), got shape: {labels.shape}") - - assert len(scale) == 4, f"Scale must have 4 elements (t, z, y, x), got {len(scale)}" - # Add nodes from regionprops # Only request intensity properties if images are provided if images is not None: @@ -173,13 +187,31 @@ def create_graph( extra_properties.remove("border_dist") td.nodes.RegionPropsNodes( - extra_properties=extra_properties, + extra_properties=[*extra_properties, label_id], ).add_nodes(graph, labels=labels, intensity_image=images) - # Add scaled position attributes - cols = [td.DEFAULT_ATTR_KEYS.T, "z", "y", "x"] - node_attrs = graph.node_attrs(attr_keys=[td.DEFAULT_ATTR_KEYS.NODE_ID, *cols]) - node_attrs = node_attrs.with_columns([(pl.col(c) * scale[i]).alias(f"scaled_{c}") for i, c in enumerate(cols)]) + # RegionPropsNodes cannot infer a schema when every frame is empty. + empty_graph_schema = { + td.DEFAULT_ATTR_KEYS.T: (pl.Int32, 0), + LABEL_ID_KEY: (pl.Int64, 0), + **dict.fromkeys(_SPATIAL_KEYS, (pl.Float64, 0.0)), + } + for key, (dtype, default) in empty_graph_schema.items(): + if key not in graph.node_attr_keys(): + graph.add_node_attr_key(key, dtype, default) + + # Persist scaled spatial positions for candidate generation without altering + # the raw coordinates consumed by the model. + node_ids = list(graph.node_ids()) + node_attrs = graph.node_attrs(attr_keys=[td.DEFAULT_ATTR_KEYS.NODE_ID, *_SPATIAL_KEYS]) + scaled_attrs = { + scaled_key: (node_attrs[spatial_key] * scale[index + 1]).to_numpy() + for index, (spatial_key, scaled_key) in enumerate(zip(_SPATIAL_KEYS, _SCALED_SPATIAL_KEYS, strict=True)) + } + for scaled_key in _SCALED_SPATIAL_KEYS: + if scaled_key not in graph.node_attr_keys(): + graph.add_node_attr_key(scaled_key, pl.Float64, 0.0) + graph.update_node_attrs(attrs=scaled_attrs, node_ids=node_ids) if images is None: for prop in REGIONPROPS: @@ -193,6 +225,7 @@ def create_graph( n_neighbors=n_neighbors, delta_t=delta_t, neighbors_per_frame=True, + attr_keys=_SCALED_SPATIAL_KEYS, ).add_edges(graph) # Add required features