Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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.).

Expand Down
4 changes: 3 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]

Expand All @@ -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",
]
Expand Down
10 changes: 9 additions & 1 deletion src/hoct/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]
4 changes: 4 additions & 0 deletions src/hoct/_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
195 changes: 195 additions & 0 deletions src/hoct/_interop.py
Original file line number Diff line number Diff line change
@@ -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)
95 changes: 95 additions & 0 deletions src/hoct/_tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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."""
Expand Down
Loading
Loading