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
52 changes: 18 additions & 34 deletions src/examples/graph_3d.zarr/tracks/.zattrs
Original file line number Diff line number Diff line change
@@ -1,36 +1,20 @@
{
"geff": {
"axes": [
{
"max": 531.0,
"min": 0.0,
"name": "t",
"type": "time",
"unit": null
},
{
"max": 4398.1,
"min": 1523.368197,
"name": "z",
"type": "space",
"unit": null
},
{
"max": 1877.7,
"min": 81.667,
"name": "y",
"type": "space",
"unit": null
},
{
"max": 2152.3,
"min": 764.42,
"name": "x",
"type": "space",
"unit": null
}
],
"directed": true,
"geff_version": "0.2.2.dev20+g611e7a2.d20250719"
}
"geff": {
"geff_version": "1.1.2",
"directed": true,
"axes": [
{ "name": "t", "type": "time", "unit": null, "min": 0.0, "max": 531.0 },
{ "name": "z", "type": "space", "unit": null, "min": 1523.368197, "max": 4398.1 },
{ "name": "y", "type": "space", "unit": null, "min": 81.667, "max": 1877.7 },
{ "name": "x", "type": "space", "unit": null, "min": 764.42, "max": 2152.3 }
],
"node_props_metadata": {
"t": { "identifier": "t", "dtype": "float64", "varlength": false },
"z": { "identifier": "z", "dtype": "float64", "varlength": false },
"y": { "identifier": "y", "dtype": "float64", "varlength": false },
"x": { "identifier": "x", "dtype": "float64", "varlength": false },
"track_id": { "identifier": "track_id", "dtype": "int64", "varlength": false }
},
"edge_props_metadata": {}
}
}
1 change: 1 addition & 0 deletions src/examples/graph_3d.zarr/tracks/edges/.zattrs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{}
41 changes: 21 additions & 20 deletions src/examples/graph_3d.zarr/tracks/edges/ids/.zarray
Original file line number Diff line number Diff line change
@@ -1,22 +1,23 @@
{
"chunks": [
18458,
2
],
"compressor": {
"blocksize": 0,
"clevel": 5,
"cname": "lz4",
"id": "blosc",
"shuffle": 1
},
"dtype": "<f8",
"fill_value": 0.0,
"filters": null,
"order": "C",
"shape": [
36916,
2
],
"zarr_format": 2
"shape": [
36916,
2
],
"chunks": [
18458,
2
],
"dtype": "<i8",
"fill_value": 0,
"order": "C",
"filters": null,
"dimension_separator": ".",
"compressor": {
"id": "blosc",
"cname": "lz4",
"clevel": 5,
"shuffle": 1,
"blocksize": 0
},
"zarr_format": 2
}
1 change: 1 addition & 0 deletions src/examples/graph_3d.zarr/tracks/edges/ids/.zattrs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{}
Binary file modified src/examples/graph_3d.zarr/tracks/edges/ids/0.0
Binary file not shown.
Binary file modified src/examples/graph_3d.zarr/tracks/edges/ids/1.0
Binary file not shown.
1 change: 1 addition & 0 deletions src/examples/graph_3d.zarr/tracks/nodes/.zattrs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{}
37 changes: 19 additions & 18 deletions src/examples/graph_3d.zarr/tracks/nodes/ids/.zarray
Original file line number Diff line number Diff line change
@@ -1,20 +1,21 @@
{
"chunks": [
18505
],
"compressor": {
"blocksize": 0,
"clevel": 5,
"cname": "lz4",
"id": "blosc",
"shuffle": 1
},
"dtype": "<f8",
"fill_value": 0.0,
"filters": null,
"order": "C",
"shape": [
37009
],
"zarr_format": 2
"shape": [
37009
],
"chunks": [
18505
],
"dtype": "<i8",
"fill_value": 0,
"order": "C",
"filters": null,
"dimension_separator": ".",
"compressor": {
"id": "blosc",
"cname": "lz4",
"clevel": 5,
"shuffle": 1,
"blocksize": 0
},
"zarr_format": 2
}
1 change: 1 addition & 0 deletions src/examples/graph_3d.zarr/tracks/nodes/ids/.zattrs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{}
Binary file modified src/examples/graph_3d.zarr/tracks/nodes/ids/0
Binary file not shown.
Binary file modified src/examples/graph_3d.zarr/tracks/nodes/ids/1
Binary file not shown.
10 changes: 8 additions & 2 deletions src/examples/graph_labels_seg.zarr/tracks/zarr.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"attributes": {
"geff": {
"geff_version": "0.3.0",
"geff_version": "1.1.2",
"directed": true,
"axes": [
{
Expand Down Expand Up @@ -36,7 +36,13 @@
"type": "image",
"path": "../image/"
}
]
],
"edge_props_metadata": {},
"node_props_metadata": {
"frame": { "identifier": "frame", "dtype": "int64", "varlength": false, "unit": "frame" },
"x": { "identifier": "x", "dtype": "float64", "varlength": false, "unit": "pixel" },
"y": { "identifier": "y", "dtype": "float64", "varlength": false, "unit": "pixel" }
}
}
},
"zarr_format": 3,
Expand Down
141 changes: 141 additions & 0 deletions src/napari_geff/_features.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
from __future__ import annotations

from collections.abc import Sequence
from pathlib import Path
from typing import Any

import numpy as np
import pandas as pd
import zarr


def _pd_nullable_from_str(dtype_str: str) -> Any:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have some serious concerns about this one 😬 because it seems brittle and difficult to maintain.

Where does GEFF get its dtypes from? Is it dependent on what library was used to write the GEFF initially? Presumably, the list we're checking against here is not exhaustive, right?

I think we should defer to pandas' own type inference API because it's more likely to be correct, and it'll be updated with future versions of pandas. WDYT?

"""Map GEFF dtype strings to pandas nullable extension dtypes.

Falls back to object if unknown.
"""
s = dtype_str.lower()
# integers
if s == "int8":
return pd.Int8Dtype()
if s == "int16":
return pd.Int16Dtype()
if s == "int32":
return pd.Int32Dtype()
if s == "int64":
return pd.Int64Dtype()
# unsigned integers
if s == "uint8":
return pd.UInt8Dtype()
if s == "uint16":
return pd.UInt16Dtype()
if s == "uint32":
return pd.UInt32Dtype()
if s == "uint64":
return pd.UInt64Dtype()
# floats
if s == "float32":
return pd.Float32Dtype()
if s == "float64":
return pd.Float64Dtype()
# boolean
if s == "bool":
return pd.BooleanDtype()
# strings
if s == "string":
return pd.StringDtype()

# Fallback: use pandas object dtype
return object


def _reorder_by_node_ids(
values: np.ndarray,
node_ids_in_store: np.ndarray,
desired_order: Sequence[Any],
) -> np.ndarray:
idx_map = {nid: i for i, nid in enumerate(node_ids_in_store.tolist())}
indices = [idx_map[nid] for nid in desired_order]
return values[indices]


def build_typed_node_features(
store_path: str | Path,
geff_metadata: Any,
node_id_order: Sequence[Any],
) -> pd.DataFrame:
"""Build a features DataFrame from a geff zarr store (for use with Napari's tracks layer)."""
z = zarr.open(store_path, mode="r")

node_ids_arr = np.asarray(z["nodes"]["ids"][...])

# Prefer metadata order
meta = geff_metadata.node_props_metadata
prop_names = list(meta.keys())

# If there are no node properties, return only node_id
if len(prop_names) == 0:
return pd.DataFrame({"node_id": list(node_id_order)})

# Otherwise, read property arrays from the canonical props group
props_group = z["nodes"]["props"]

data_cols: dict[str, pd.Series] = {}

# Always include node_id in the requested order
data_cols["node_id"] = pd.Series(
list(node_id_order), dtype=pd.Int64Dtype()
)

for prop in prop_names:
# Skip variable-length properties for napari features
m = meta[prop]
varlen = bool(m.varlength)
dtype_str = m.dtype
if varlen:
continue

values_arr = np.asarray(props_group[prop]["values"][...])

# Optional missing mask (True means missing)
missing_mask = None
if "missing" in props_group[prop]: # type: ignore[operator]
missing_mask = np.asarray(props_group[prop]["missing"][...])

# Reorder to match requested node_id order
values_ord = _reorder_by_node_ids(
values_arr, node_ids_arr, node_id_order
)

missing_ord = (
_reorder_by_node_ids(
missing_mask, node_ids_arr, node_id_order
).astype(bool)
if missing_mask is not None
else None
)

# Determine pandas nullable dtype
dtype_for_cast = dtype_str or str(values_arr.dtype)
pd_dtype = _pd_nullable_from_str(dtype_for_cast)

# Create Series with the extension dtype, then apply mask
s = pd.Series(values_ord)
try:
s = s.astype(pd_dtype)
except TypeError:
s = s.astype("object")

if missing_ord is not None:
# Assign pandas NA to missing positions
s.loc[missing_ord] = pd.NA

data_cols[prop] = s

# Assemble DataFrame with node_id first
col_order = ["node_id"] + [
c for c in prop_names if c in data_cols and c != "node_id"
]
df = pd.DataFrame(data_cols)
print("Here")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Debugging leftover?

return df.loc[:, col_order]
Loading