Skip to content
Draft
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
58 changes: 57 additions & 1 deletion src/funtracks/import_export/geff/_import.py
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,54 @@ def infer_node_name_map(self) -> dict[str, str | list[str]]:
# Fall back to fuzzy matching when axes metadata is absent or incomplete
return super().infer_node_name_map()

def infer_scale(self) -> list[float] | None:
"""Derive the per-dimension scale from the geff axes metadata.

The result is ordered like ``Tracks.scale``: time first, then the spatial
axes in the same order as ``self.node_name_map["pos"]`` (falling back to
the order the axes appear in the file).

Per the geff spec ``scale`` is optional per axis, so any axis without one
falls back to 1.0. Returns None when there is no axes metadata, when no
axis declares a scale at all, or when the axes cannot be lined up with the
graph's dimensions — in those cases the scale is genuinely unknown, and
callers should be able to tell that apart from unity.

Returns:
Scale per dimension (time first), or None if unknown.
"""
geff_axes = getattr(self, "_geff_axes", [])
if not geff_axes:
return None

scale_by_name = {ax.name: ax.scale for ax in geff_axes}

# Reconstruct the dimension order used for the graph, not the file order.
pos_names = self.node_name_map.get("pos")
ordered: list[str] = []
time_name = self.node_name_map.get("time")
if isinstance(time_name, str):
ordered.append(time_name)
if isinstance(pos_names, list):
ordered.extend(pos_names)
if not ordered or not all(name in scale_by_name for name in ordered):
# The name map does not line up with the axes metadata (e.g. position
# stored as a single ndarray property): fall back to the file order.
ordered = [ax.name for ax in geff_axes]

# Only trust the result if it covers exactly the graph's dimensions,
# otherwise Tracks would reject the mismatched length.
expected_ndim = self.ndim
if expected_ndim is None and isinstance(pos_names, list):
expected_ndim = len(pos_names) + 1
if expected_ndim is not None and len(ordered) != expected_ndim:
return None

scales = [scale_by_name.get(name) for name in ordered]
if all(s is None for s in scales):
return None
return [1.0 if s is None else float(s) for s in scales]

def construct_graph(
self,
node_name_map: dict[str, str | list[str]] | None = None,
Expand Down Expand Up @@ -334,7 +382,10 @@ def import_from_geff(
- For multi-value features like position, use a list: {"pos": ["y", "x"]}
If None, property names are auto-inferred using fuzzy matching.
segmentation_path: Optional path to segmentation data
scale: Optional spatial scale
scale: Optional spatial scale. If None, defaults to the scale stored in
the geff axes metadata (see
:meth:`GeffTracksBuilder.infer_scale`), and stays None when the file
does not declare one.
edge_name_map: Optional mapping from standard funtracks keys to GEFF
edge property names. Example: {"iou": "overlap"}
database: Optional path to a SQLite database file for backing storage.
Expand Down Expand Up @@ -376,6 +427,11 @@ def import_from_geff(
builder.node_name_map = node_name_map
if edge_name_map is not None and not has_feature_dict:
builder.edge_name_map = edge_name_map

# An explicit scale always wins; otherwise honour what the file's axes say.
if scale is None:
scale = builder.infer_scale()

return builder.build(
directory,
segmentation_path,
Expand Down
77 changes: 77 additions & 0 deletions tests/import_export/test_geff_scale_roundtrip.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
"""Regression tests: the geff axes metadata carries the scale, so a geff
round-trip must return it.

``write_to_geff`` / ``export_to_geff`` write ``scale`` into each geff axis.
``import_from_geff`` used to ignore it, so anisotropic data silently came back
with ``scale=None``.
"""

import numpy as np
import pytest
from geff.testing.data import create_mock_geff

from funtracks.import_export import export_to_geff, import_from_geff, write_to_geff


def test_scale_survives_write_to_geff_roundtrip(get_tracks, tmp_path):
tracks = get_tracks(ndim=3, with_seg=False, prefill_track_ids=True)
tracks.scale = [1.0, 0.25, 0.5]

geff_path = tmp_path / "tracks.geff"
write_to_geff(tracks, geff_path)

assert import_from_geff(geff_path).scale == [1.0, 0.25, 0.5]


def test_scale_survives_export_to_geff_roundtrip(get_tracks, tmp_path):
tracks = get_tracks(ndim=4, with_seg=False, prefill_track_ids=True)
tracks.scale = [1.0, 2.0, 0.25, 0.5]

export_to_geff(tracks, tmp_path / "container")

loaded = import_from_geff(tmp_path / "container" / "tracks.geff")
assert loaded.scale == [1.0, 2.0, 0.25, 0.5]


def test_explicit_scale_overrides_metadata(get_tracks, tmp_path):
"""A caller-supplied scale must still win over what the file says."""
tracks = get_tracks(ndim=3, with_seg=False, prefill_track_ids=True)
tracks.scale = [1.0, 0.25, 0.5]

geff_path = tmp_path / "tracks.geff"
write_to_geff(tracks, geff_path)

loaded = import_from_geff(geff_path, scale=[1.0, 3.0, 3.0])
assert loaded.scale == [1.0, 3.0, 3.0]


def test_scale_none_when_axes_have_no_scale():
"""Third-party geff without per-axis scale stays 'unknown', not unity."""
store, _ = create_mock_geff(
node_id_dtype="uint",
node_axis_dtypes={"position": "float64", "time": "int64"},
directed=True,
num_nodes=5,
num_edges=2,
include_t=True,
include_z=False,
include_y=True,
include_x=True,
)
assert import_from_geff(store).scale is None


@pytest.mark.parametrize("ndim", [3, 4])
def test_scale_roundtrip_with_segmentation(get_tracks, tmp_path, ndim):
"""Positions are in world units; the scale must come back so that
segmentation lookups keep matching."""
tracks = get_tracks(ndim=ndim, with_seg=True, prefill_track_ids=True)
scale = [1.0, 0.25, 0.5] if ndim == 3 else [1.0, 2.0, 0.25, 0.5]
tracks.scale = scale

geff_path = tmp_path / "tracks.geff"
write_to_geff(tracks, geff_path)

loaded = import_from_geff(geff_path)
assert loaded.scale is not None
np.testing.assert_allclose(loaded.scale, scale)
Loading