From c3c44d18762000a6940a8df679e96b3beb9f8cc3 Mon Sep 17 00:00:00 2001 From: Teun Huijben Date: Wed, 29 Jul 2026 16:37:03 -0700 Subject: [PATCH] scale did not survive geff round trip --- src/funtracks/import_export/geff/_import.py | 58 +++++++++++++- .../test_geff_scale_roundtrip.py | 77 +++++++++++++++++++ 2 files changed, 134 insertions(+), 1 deletion(-) create mode 100644 tests/import_export/test_geff_scale_roundtrip.py diff --git a/src/funtracks/import_export/geff/_import.py b/src/funtracks/import_export/geff/_import.py index 8cd6ce9d..96437dc9 100644 --- a/src/funtracks/import_export/geff/_import.py +++ b/src/funtracks/import_export/geff/_import.py @@ -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, @@ -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. @@ -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, diff --git a/tests/import_export/test_geff_scale_roundtrip.py b/tests/import_export/test_geff_scale_roundtrip.py new file mode 100644 index 00000000..735ade83 --- /dev/null +++ b/tests/import_export/test_geff_scale_roundtrip.py @@ -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)