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
29 changes: 29 additions & 0 deletions src/funtracks/import_export/_tracks_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -678,6 +678,24 @@ def enable_features(
if static_features:
tracks.features.update(static_features)

def _resolve_import_scale(self, scale: list[float] | None) -> list[float] | None:
"""Return the scale to use for this import.

By default the caller-provided scale is used as-is (e.g. the scale a user
entered in a scale widget for a CSV import). Format-specific builders can
override this to source the scale elsewhere.
"""
return scale

def _scale_to_world_coords(self, scale: list[float] | None) -> None:
"""Rescale the loaded position columns in ``self.in_memory_geff``.

No-op by default. Formats that store positions in a different coordinate
space than funtracks' world coordinates (e.g. GEFF stores pixels) override
this to convert using the resolved import ``scale``.
"""
return

def build(
self,
source: Path | pd.DataFrame,
Expand Down Expand Up @@ -740,11 +758,22 @@ def build(
# Validate node_name_map is complete and valid
self.validate_name_map(has_segmentation=segmentation is not None)

# Resolve the scale to use for this import. By default the caller-provided
# scale is used (e.g. from a scale widget for CSV imports); format-specific
# builders may override this (the GEFF builder reads the authoritative
# scale from the geff metadata).
scale = self._resolve_import_scale(scale)

# 1. Load source data to InMemoryGeff
self.load_source(source, self.node_name_map)
if self.in_memory_geff is None:
raise ValueError("load_source() must populate self.in_memory_geff")

# Rescale loaded positions into world coordinates if the format requires
# it (GEFF stores pixel coordinates). Uses the same resolved scale that is
# attached to the resulting Tracks, so the two stay consistent.
self._scale_to_world_coords(scale)

# 2. Combine multi-value feature columns
self._combine_multi_value_props(
self.in_memory_geff["node_props"], self.node_name_map
Expand Down
25 changes: 23 additions & 2 deletions src/funtracks/import_export/geff/_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
)

import geff_spec
import numpy as np
import polars as pl
import tracksdata as td
from geff_spec import GeffMetadata
Expand Down Expand Up @@ -170,6 +171,22 @@ def _build_geff_metadata(
}
)

# Convert world coordinates to pixel coordinates by dividing each spatial
# position attribute by its axis scale, to align with the geff format, which
# stores pixel coordinates together with a per-axis scale in the metadata.
# `graph` is a detached copy (see split_position_attr), so this does not
# mutate the caller's live graph.
pos_pixel_coords: dict[str, np.ndarray] = {}
node_df = graph.node_attrs()
for name, axis_type, axis_scale in zip(
axis_names, axis_types, tracks.scale, strict=True
):
if axis_type != "space" or axis_scale == 1:
continue
pos_pixel_coords[name] = node_df[name].to_numpy() / axis_scale
if pos_pixel_coords:
graph.update_node_attrs(attrs=pos_pixel_coords, node_ids=graph.node_ids())

extra: dict = {}
if include_features:
extra["funtracks"] = {"features": tracks.features.dump_json()}
Expand Down Expand Up @@ -253,7 +270,11 @@ def split_position_attr(tracks: Tracks) -> tuple[td.graph.GraphView, list[str] |
new_graph.remove_node_attr_key(pos_key)
return new_graph, new_keys
elif pos_key is not None:
# Position is already split into separate attributes
return tracks.graph, list(pos_key)
# Position is already split into separate attributes. Detach so that
# downstream coordinate rescaling operates on an independent copy and
# does not mutate the live graph.
new_graph = tracks.graph.detach()
new_graph = new_graph.filter().subgraph()
return new_graph, list(pos_key)
else:
return tracks.graph, None
63 changes: 61 additions & 2 deletions src/funtracks/import_export/geff/_import.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import warnings
from typing import TYPE_CHECKING

import numpy as np
import tracksdata as td
from geff._typing import InMemoryGeff
from geff.core_io._base_read import read_to_memory
Expand Down Expand Up @@ -166,7 +167,7 @@ def read_header(self, source_path: Path) -> None:
self.importable_node_props = list(metadata.node_props_metadata.keys())
self.importable_edge_props = list(metadata.edge_props_metadata.keys())

# Store axes metadata for use in infer_node_name_map
# Store axes metadata for use in infer_node_name_map and scale resolution
self._geff_axes = metadata.axes or []

# Read funtracks FeatureDict from GEFF extra metadata if present
Expand Down Expand Up @@ -313,6 +314,61 @@ def load_source(
if self.ndim is None:
self.ndim = ndim

def _scale_from_axes(self) -> list[float] | None:
"""Return the per-axis scale stored in the geff metadata.

The scale is ordered as ``[time, *space]`` (mirroring
:meth:`infer_node_name_map`), matching the funtracks scale convention
([time, z, y, x]) and the position column order. Returns None when the
metadata has no scaled axes (e.g. external geffs without axis scales).
"""
axes = getattr(self, "_geff_axes", [])
time_axes = [ax for ax in axes if ax.type == "time"]
space_axes = [ax for ax in axes if ax.type == "space"]
ordered_axes = time_axes + space_axes
if ordered_axes and all(ax.scale is not None for ax in ordered_axes):
return [float(ax.scale) for ax in ordered_axes]
return None

def _resolve_import_scale(self, scale: list[float] | None) -> list[float] | None:
"""For GEFF, the per-axis scale stored in the metadata is used when present, the
caller-provided scale is only used when the metadata has no scaled axes.
"""
geff_scale = self._scale_from_axes()
return geff_scale if geff_scale is not None else scale

def _scale_to_world_coords(self, scale: list[float] | None) -> None:
"""Convert stored pixel coordinates to world coordinates.

geff stores spatial coordinates in pixels together with a per-axis scale;
funtracks keeps positions in world coordinates, so each spatial position
column is multiplied by its scale (from `builder._resolve_import_scale`), so
the conversion stays consistent with the scale attached to the Tracks.
"""
if scale is None or self.in_memory_geff is None:
return

spatial_scale = list(scale)[1:] # drop the time axis
pos_cols = self.position_attr
node_props = self.in_memory_geff["node_props"]

# Composite position stored as separate columns (e.g. "z", "y", "x").
if (
pos_cols
and len(pos_cols) == len(spatial_scale)
and all(col in node_props for col in pos_cols)
):
for col, s in zip(pos_cols, spatial_scale, strict=True):
if s != 1:
node_props[col]["values"] = node_props[col]["values"] * s
return

# Position stored as a single vector column named "pos".
if "pos" in node_props:
values = node_props["pos"]["values"]
if values.ndim == 2 and values.shape[1] == len(spatial_scale):
node_props["pos"]["values"] = values * np.asarray(spatial_scale)


def import_from_geff(
directory: Path,
Expand All @@ -334,7 +390,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 scale ([time, z, y, x]). For a GEFF import the per-axis
scale stored in the metadata is authoritative and is always used when
present; this argument is only a fallback for geffs whose axes carry
no scale.
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
9 changes: 6 additions & 3 deletions tests/import_export/test_import_from_geff.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,12 +66,15 @@ def valid_segmentation():
times = [1, 2, 3, 4, 5]
x = [1.0, 0.775, 0.55, 0.325, 0.1]
y = [100, 200, 300, 400, 500]
scale = [1, 1, 100]
seg_ids = np.array([10, 20, 30, 40, 50])

# The geff stores pixel coordinates. On import each spatial coordinate is
# multiplied by its scale to get world coordinates, and the validation
# converts back to pixels via ``pixel = world / scale``, recovering the
# original stored coordinate. So place each seg id at the pixel equal to the
# stored geff coordinate.
for t, y_val, x_f, seg_id in zip(times, y, x, seg_ids, strict=False):
x = int(x_f * scale[2])
seg[t, y_val, x] = seg_id
seg[t, int(y_val), int(x_f)] = seg_id
return seg


Expand Down
Loading