From f044594c989faf6e637366e4c3ce60c80ddc9763 Mon Sep 17 00:00:00 2001 From: Jean-Yves Tinevez Date: Sat, 27 Jun 2026 15:57:47 +0200 Subject: [PATCH 1/4] Support custom axis names in GEFF files If we try to open a GEFF file, e.g. generated with pycellin, the position props names are not neceaarily x, y, z, t. The names are defined in the 'axes' properties, which we need to read. - Add getAxisNameByType() and getAxisNamesByType() methods to GeffMetadata - Modify GeffNode.readFromN5() to read coordinates from dynamic axis paths - Modify GeffNode.writeToN5() to write coordinates to dynamic axis paths - Add test for custom axis names (frame, cell_x, cell_y instead of t, x, y, z) - Maintain backward compatibility with standard axis names Fixes issue where GEFF files with non-standard axis names (e.g., 'frame' instead of 't', 'cell_x' instead of 'x') could not be read. Did this in part with Claude --- .../java/org/mastodon/geff/GeffMetadata.java | 40 +++++++ src/main/java/org/mastodon/geff/GeffNode.java | 102 ++++++++++++------ src/test/java/org/mastodon/geff/GeffTest.java | 58 ++++++++++ 3 files changed, 167 insertions(+), 33 deletions(-) diff --git a/src/main/java/org/mastodon/geff/GeffMetadata.java b/src/main/java/org/mastodon/geff/GeffMetadata.java index ca7051f..9c773de 100644 --- a/src/main/java/org/mastodon/geff/GeffMetadata.java +++ b/src/main/java/org/mastodon/geff/GeffMetadata.java @@ -194,6 +194,46 @@ public void setTrackNodeProps( Map< String, String > trackNodeProps ) this.trackNodeProps = trackNodeProps; } + /** + * Get the axis name for a given axis type. + * Returns the name of the first axis matching the specified type, or null if no such axis exists. + * + * @param type the axis type (e.g., "time", "space", "channel") + * @return the axis name, or null if no axis of the given type exists + */ + public String getAxisNameByType( String type ) + { + if ( geffAxes != null ) + { + for ( GeffAxis axis : geffAxes ) + { + if ( type.equals( axis.getType() ) ) + { + return axis.getName(); + } + } + } + return null; + } + + /** + * Get all axis names for a given axis type. + * Returns an array of names for all axes matching the specified type. + * + * @param type the axis type (e.g., "space" for all spatial axes) + * @return array of axis names (empty array if no matching axes) + */ + public String[] getAxisNamesByType( String type ) + { + if ( geffAxes == null ) + return new String[ 0 ]; + + return Arrays.stream( geffAxes ) + .filter( axis -> type.equals( axis.getType() ) ) + .map( GeffAxis::getName ) + .toArray( String[]::new ); + } + /** * Validates the metadata according to the GEFF schema rules */ diff --git a/src/main/java/org/mastodon/geff/GeffNode.java b/src/main/java/org/mastodon/geff/GeffNode.java index 5d5e626..1f5855b 100644 --- a/src/main/java/org/mastodon/geff/GeffNode.java +++ b/src/main/java/org/mastodon/geff/GeffNode.java @@ -729,27 +729,47 @@ public static List< GeffNode > readFromN5( final N5Reader reader, final String g // See GeffUtils.shouldSkipProperty() and checkForMissingValues() for // implementation + // Determine axis names dynamically from metadata + // Fall back to standard names (t, x, y, z) if axes not defined + final String timeAxisName = metadata.getAxisNameByType( GeffAxis.TYPE_TIME ); + final String[] spaceAxes = metadata.getAxisNamesByType( GeffAxis.TYPE_SPACE ); + final String xAxisName = spaceAxes.length > 0 ? spaceAxes[ 0 ] : "x"; + final String yAxisName = spaceAxes.length > 1 ? spaceAxes[ 1 ] : "y"; + final String zAxisName = spaceAxes.length > 2 ? spaceAxes[ 2 ] : "z"; + // Read node IDs from chunks final int[] nodeIds = GeffUtils.readAsIntArray( reader, path + "/nodes/ids", "node IDs" ); if ( nodeIds == null ) { throw new IllegalArgumentException( "required property '/nodes/ids' not found" ); } final int numNodes = nodeIds.length; - // Read time points from chunks - final int[] timepoints = GeffUtils.readAsIntArray( reader, path + "/nodes/props/t/values", "timepoints" ); - verifyLength( timepoints, numNodes, "/nodes/props/t/values" ); - - // Read X coordinates from chunks - final double[] xCoords = GeffUtils.readAsDoubleArray( reader, path + "/nodes/props/x/values", "X coordinates" ); - verifyLength( xCoords, numNodes, "/nodes/props/x/values" ); - - // Read Y coordinates from chunks - final double[] yCoords = GeffUtils.readAsDoubleArray( reader, path + "/nodes/props/y/values", "Y coordinates" ); - verifyLength( yCoords, numNodes, "/nodes/props/y/values" ); - - // Read Z coordinates from chunks - final double[] zCoords = GeffUtils.readAsDoubleArray( reader, path + "/nodes/props/z/values", "Z coordinates" ); - verifyLength( zCoords, numNodes, "/nodes/props/z/values" ); + // Read time points from chunks using dynamic axis name + final String timePropPath = path + "/nodes/props/" + ( timeAxisName != null ? timeAxisName : "t" ) + "/values"; + final int[] timepoints = GeffUtils.readAsIntArray( reader, timePropPath, "timepoints" ); + verifyLength( timepoints, numNodes, timePropPath ); + + // Read X coordinates from chunks using dynamic axis name + final String xPropPath = path + "/nodes/props/" + xAxisName + "/values"; + final double[] xCoords = GeffUtils.readAsDoubleArray( reader, xPropPath, "X coordinates" ); + verifyLength( xCoords, numNodes, xPropPath ); + + // Read Y coordinates from chunks using dynamic axis name + final String yPropPath = path + "/nodes/props/" + yAxisName + "/values"; + final double[] yCoords = GeffUtils.readAsDoubleArray( reader, yPropPath, "Y coordinates" ); + verifyLength( yCoords, numNodes, yPropPath ); + + // Read Z coordinates from chunks using dynamic axis name (optional) + final double[] zCoords; + final String zPropPath = path + "/nodes/props/" + zAxisName + "/values"; + if ( spaceAxes.length > 2 && reader.datasetExists( zPropPath ) ) + { + zCoords = GeffUtils.readAsDoubleArray( reader, zPropPath, "Z coordinates" ); + verifyLength( zCoords, numNodes, zPropPath ); + } + else + { + zCoords = null; + } // Read color from chunks final FlattenedDoubles colors = GeffUtils.readAsDoubleMatrix( reader, path + "/nodes/props/color/values", "color" ); @@ -1047,34 +1067,45 @@ public static void writeToN5( final String path = N5URI.normalizeGroupPath( group ); final int numNodes = nodes.size(); + + // Determine axis names dynamically from metadata + // Fall back to standard names (t, x, y, z) if axes not defined + final String timeAxisName = metadata.getAxisNameByType( GeffAxis.TYPE_TIME ); + final String[] spaceAxes = metadata.getAxisNamesByType( GeffAxis.TYPE_SPACE ); + final String xAxisName = spaceAxes.length > 0 ? spaceAxes[ 0 ] : "x"; + final String yAxisName = spaceAxes.length > 1 ? spaceAxes[ 1 ] : "y"; + final String zAxisName = spaceAxes.length > 2 ? spaceAxes[ 2 ] : "z"; + final Map< String, PropMetadata > metadataNodeProps = metadata.getNodePropsMetadata(); final boolean writeAllProps = metadataNodeProps == null; // Write node IDs in chunks GeffUtils.writeIntArray( nodes, GeffNode::getId, writer, path + "/nodes/ids", chunkSize ); - // Write timepoints in chunks - if ( writeAllProps || metadataNodeProps.containsKey( "t" ) ) + // Write timepoints in chunks using dynamic axis name + final String timePropName = timeAxisName != null ? timeAxisName : "t"; + if ( writeAllProps || metadataNodeProps.containsKey( timePropName ) ) { - final PropMetadata timeMetadata = metadataNodeProps != null ? metadataNodeProps.get( "t" ) : null; + final PropMetadata timeMetadata = metadataNodeProps != null ? metadataNodeProps.get( timePropName ) : null; final String timeDtype = timeMetadata != null ? timeMetadata.getDtype() : null; + final String timePropPath = path + "/nodes/props/" + timePropName + "/values"; if ( timeDtype != null && timeDtype.toLowerCase().startsWith( "float" ) ) - GeffUtils.writeDoubleArray( nodes, node -> node.getT(), writer, path + "/nodes/props/t/values", chunkSize ); + GeffUtils.writeDoubleArray( nodes, node -> node.getT(), writer, timePropPath, chunkSize ); else - GeffUtils.writeIntArray( nodes, GeffNode::getT, writer, path + "/nodes/props/t/values", chunkSize ); + GeffUtils.writeIntArray( nodes, GeffNode::getT, writer, timePropPath, chunkSize ); } - // Write X coordinates in chunks - if ( writeAllProps || metadataNodeProps.containsKey( "x" ) ) - GeffUtils.writeDoubleArray( nodes, GeffNode::getX, writer, path + "/nodes/props/x/values", chunkSize ); + // Write X coordinates in chunks using dynamic axis name + if ( writeAllProps || metadataNodeProps.containsKey( xAxisName ) ) + GeffUtils.writeDoubleArray( nodes, GeffNode::getX, writer, path + "/nodes/props/" + xAxisName + "/values", chunkSize ); - // Write Y coordinates in chunks - if ( writeAllProps || metadataNodeProps.containsKey( "y" ) ) - GeffUtils.writeDoubleArray( nodes, GeffNode::getY, writer, path + "/nodes/props/y/values", chunkSize ); + // Write Y coordinates in chunks using dynamic axis name + if ( writeAllProps || metadataNodeProps.containsKey( yAxisName ) ) + GeffUtils.writeDoubleArray( nodes, GeffNode::getY, writer, path + "/nodes/props/" + yAxisName + "/values", chunkSize ); - // Write Z coordinates in chunks - if ( writeAllProps || metadataNodeProps.containsKey( "z" ) ) - GeffUtils.writeDoubleArray( nodes, GeffNode::getZ, writer, path + "/nodes/props/z/values", chunkSize ); + // Write Z coordinates in chunks using dynamic axis name + if ( writeAllProps || metadataNodeProps.containsKey( zAxisName ) ) + GeffUtils.writeDoubleArray( nodes, GeffNode::getZ, writer, path + "/nodes/props/" + zAxisName + "/values", chunkSize ); // Write color in chunks if ( writeAllProps || metadataNodeProps.containsKey( "color" ) ) @@ -1101,13 +1132,18 @@ public static void writeToN5( // When writeAllProps=true (no nodePropsMetadata provided), populate metadata // with the standard props so the output zarr passes Python structural // validation (node_props_metadata is a required field in the Python spec). + // Use dynamic axis names from metadata if available. if ( writeAllProps ) { final Map< String, PropMetadata > nodePropsMap = new HashMap<>(); - nodePropsMap.put( "t", new PropMetadata( "t", "int32", false, null, null, null ) ); - nodePropsMap.put( "x", new PropMetadata( "x", "float64", false, null, null, null ) ); - nodePropsMap.put( "y", new PropMetadata( "y", "float64", false, null, null, null ) ); - nodePropsMap.put( "z", new PropMetadata( "z", "float64", false, null, null, null ) ); + // Use dynamic axis names + nodePropsMap.put( timePropName, new PropMetadata( timePropName, "int32", false, null, null, null ) ); + nodePropsMap.put( xAxisName, new PropMetadata( xAxisName, "float64", false, null, null, null ) ); + nodePropsMap.put( yAxisName, new PropMetadata( yAxisName, "float64", false, null, null, null ) ); + if ( spaceAxes.length > 2 ) + { + nodePropsMap.put( zAxisName, new PropMetadata( zAxisName, "float64", false, null, null, null ) ); + } nodePropsMap.put( "color", new PropMetadata( "color", "float64", false, null, null, null ) ); nodePropsMap.put( trackletProp, new PropMetadata( trackletProp, "int32", false, null, null, null ) ); nodePropsMap.put( "radius", new PropMetadata( "radius", "float64", false, null, null, null ) ); diff --git a/src/test/java/org/mastodon/geff/GeffTest.java b/src/test/java/org/mastodon/geff/GeffTest.java index d971b40..e1d4c77 100644 --- a/src/test/java/org/mastodon/geff/GeffTest.java +++ b/src/test/java/org/mastodon/geff/GeffTest.java @@ -439,4 +439,62 @@ void testVersionValidationEdgeCases() }, "Version " + version + " should be invalid" ); } } + + @Test + @DisplayName( "Test reading/writing with custom axis names (non-standard)" ) + void testCustomAxisNames( @TempDir Path tempDir ) throws IOException + { + // Create metadata with custom axis names (like "frame", "cell_x", "cell_y") + final GeffMetadata metadata = new GeffMetadata( Geff.VERSION, true ); + + // Use custom axis names instead of standard t, x, y, z + final GeffAxis[] axes = { + new GeffAxis( "frame", GeffAxis.TYPE_TIME, "frame", 0.0, 100.0 ), + new GeffAxis( "cell_x", GeffAxis.TYPE_SPACE, "pixel", 0.0, 1024.0 ), + new GeffAxis( "cell_y", GeffAxis.TYPE_SPACE, "pixel", 0.0, 768.0 ) + }; + metadata.setGeffAxes( axes ); + + // Create test nodes + final List< GeffNode > nodes = new ArrayList<>(); + for ( int i = 0; i < 3; i++ ) + { + final GeffNode node = new GeffNode(); + node.setId( i ); + node.setT( i ); // timepoint stored as "frame" + node.setX( i * 10.0 ); // stored as "cell_x" + node.setY( i * 20.0 ); // stored as "cell_y" + node.setSegmentId( i + 100 ); + nodes.add( node ); + } + + final String tempPath = tempDir.toString() + "/test-custom-axes.zarr/tracks"; + + // Write with custom axis names + GeffNode.writeToZarr( nodes, tempPath, metadata ); + GeffMetadata.writeToZarr( metadata, tempPath ); + + // Read back and verify + final GeffMetadata readMetadata = GeffMetadata.readFromZarr( tempPath ); + final List< GeffNode > readNodes = GeffNode.readFromZarr( tempPath, readMetadata ); + + // Verify metadata + assertEquals( 3, readMetadata.getGeffAxes().length ); + assertEquals( "frame", readMetadata.getGeffAxes()[ 0 ].getName() ); + assertEquals( GeffAxis.TYPE_TIME, readMetadata.getGeffAxes()[ 0 ].getType() ); + assertEquals( "cell_x", readMetadata.getGeffAxes()[ 1 ].getName() ); + assertEquals( "cell_y", readMetadata.getGeffAxes()[ 2 ].getName() ); + + // Verify node data + assertEquals( nodes.size(), readNodes.size() ); + for ( int i = 0; i < nodes.size(); i++ ) + { + final GeffNode expected = nodes.get( i ); + final GeffNode actual = readNodes.get( i ); + assertEquals( expected.getT(), actual.getT(), "timepoint mismatch at node " + i ); + assertEquals( expected.getX(), actual.getX(), 1e-9, "x mismatch at node " + i ); + assertEquals( expected.getY(), actual.getY(), 1e-9, "y mismatch at node " + i ); + assertEquals( expected.getSegmentId(), actual.getSegmentId(), "segmentId mismatch at node " + i ); + } + } } From 7dff1639afdc4cb7adffbc0de7b6325ab42e6d58 Mon Sep 17 00:00:00 2001 From: Jean-Yves Tinevez Date: Sat, 27 Jun 2026 16:02:47 +0200 Subject: [PATCH 2/4] Gitignore Eclipse stuff. --- .gitignore | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.gitignore b/.gitignore index accaf07..314cd01 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,9 @@ buildNumber.properties # Cross-language test data cross-language-tests/data/*.zarr/ + +# Eclipse stuff +.classpath +.project +.settings/ + From cbf157049e92449f4ce57c9770c7590d20aabd92 Mon Sep 17 00:00:00 2001 From: Ko Sugawara Date: Sun, 5 Jul 2026 10:25:59 +0900 Subject: [PATCH 3/4] Update README for custom axis name support Document that node property paths for time and spatial coordinates are resolved from the axis names declared in GeffMetadata rather than being hardcoded to "t", "x", "y", "z". - Add "Custom axis names" feature bullet explaining the fallback behaviour - Update GeffNode description to mention axis-aware I/O - Add getAxisNameByType() / getAxisNamesByType() to GeffMetadata description - Annotate the Data Format file-tree to show paths as /// with a note that names come from the axes array in metadata - Add usage example showing round-trip with custom axis names ("frame", "cell_x", "cell_y") --- README.md | 36 +++++++++++++++++++++++++++++++----- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 8514d79..696ce6d 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ The **Graph Exchange File Format (GEFF)** is a standardized format for storing a - **Zarr Format 2** - Reads and writes [Zarr Format 2](https://zarr-specs.readthedocs.io/en/latest/v2/v2.0.html) only; Zarr Format 3 is not supported - **Complete data model** - Support for nodes (spatial-temporal features), edges (connections), and metadata - **Flexible metadata handling** - Axis-based metadata with GeffAxis objects; supports `time`, `space`, and `channel` axis types with any axis name +- **Custom axis names** - Node property paths for coordinates (t/x/y/z) are derived from the axis names declared in metadata, so non-standard names such as `frame`, `cell_x`, `cell_y` work out of the box; standard names are used as fallbacks when axes are not declared - **Property metadata** - Full `node_props_metadata` / `edge_props_metadata` support as required by the v1 spec - **Variable-length properties** - Read and write properties with `varlength: true` (e.g. polygon coordinates per node) - **Type safety** - Strong typing with comprehensive validation; graceful skip with warning for unsupported types (`str`, `bytes`) @@ -29,8 +30,10 @@ Represents nodes in tracking graphs with spatial and temporal attributes: - Spatial coordinates (x, y, z) - Segment identifiers (dynamic property name via metadata) - Additional properties: color, radius, covariance2d, covariance3d -- Polygon geometry stored via `polygonX`/`polygonY` builder fields, serialized to `serialized_props/polygon/` +- Polygon geometry stored as a varlength property under `nodes/props/polygon/` - Variable-length properties accessible via `getVarlengthProperty(name)` / `setVarlengthProperty(name, ...)` +- Arbitrary scalar/vector properties accessible via `getProp(name)` / `setProp(name, value)` / `getProps()` +- **Axis-aware I/O**: property paths for time and spatial coordinates are resolved from the axis names declared in `GeffMetadata`; falls back to `t`, `x`, `y`, `z` when no axes are defined - Builder pattern for convenient object construction - Chunked Zarr Format 2 I/O @@ -55,6 +58,8 @@ Handles GEFF metadata with schema validation: - Node/edge property metadata maps (`nodePropsMetadata`, `edgePropsMetadata`) - Dynamic tracklet property name from `track_node_props["tracklet"]` - Graph properties (directed/undirected) +- `getAxisNameByType(type)` – returns the name of the first axis matching a given type (e.g. `"time"`) +- `getAxisNamesByType(type)` – returns all axis names matching a given type (e.g. all `"space"` axes in order) ### PropMetadata Describes a single node or edge property as required by the v1 spec: @@ -166,6 +171,27 @@ GeffAxis[] axes = { }; GeffMetadata metadata = new GeffMetadata("1.0.0", true, axes); GeffMetadata.writeToZarr(metadata, "/path/to/output.zarr/tracks"); + +// Use custom axis names (e.g. "frame", "cell_x", "cell_y" instead of "t", "x", "y") +// Node property paths are resolved from the axis names declared in metadata. +GeffAxis[] customAxes = { + new GeffAxis("frame", GeffAxis.TYPE_TIME, "frame", 0.0, 500.0), + new GeffAxis("cell_x", GeffAxis.TYPE_SPACE, "pixel", 0.0, 1024.0), + new GeffAxis("cell_y", GeffAxis.TYPE_SPACE, "pixel", 0.0, 768.0) +}; +GeffMetadata customMetadata = new GeffMetadata("1.0.0", true, customAxes); +// GeffNode.writeToZarr will write to nodes/props/frame/values, +// nodes/props/cell_x/values, nodes/props/cell_y/values automatically. +GeffNode.writeToZarr(newNodes, "/path/to/output.zarr/tracks", customMetadata); +GeffMetadata.writeToZarr(customMetadata, "/path/to/output.zarr/tracks"); + +// When reading back, pass the metadata so axis names are resolved correctly: +GeffMetadata readMetadata = GeffMetadata.readFromZarr("/path/to/output.zarr/tracks"); +List readNodes = GeffNode.readFromZarr("/path/to/output.zarr/tracks", readMetadata); + +// Query axis names from metadata directly: +String timeAxis = readMetadata.getAxisNameByType("time"); // "frame" +String[] spaceAxes = readMetadata.getAxisNamesByType("space"); // ["cell_x", "cell_y"] ``` ## Building @@ -216,10 +242,10 @@ dataset.zarr/ ├── nodes/ │ ├── ids/ # Node IDs [N] │ ├── props/ - │ │ ├── t/values # Time points [N] - │ │ ├── x/values # X coordinates [N] - │ │ ├── y/values # Y coordinates [N] - │ │ ├── z/values # Z coordinates [N] (optional) + │ │ ├── /values # Time points [N] (name from axes[type=time], default "t") + │ │ ├── /values # X coordinates [N] (name from axes[type=space][0], default "x") + │ │ ├── /values # Y coordinates [N] (name from axes[type=space][1], default "y") + │ │ ├── /values # Z coordinates [N] (name from axes[type=space][2], default "z", optional) │ │ ├── color/values # RGBA colors [N, 4] (optional) │ │ ├── radius/values # Node radii [N] (optional) │ │ ├── /values # Track IDs [N] (name from track_node_props, optional) From 4fe604113bafd6e8d95b7ef262645ee1f88011c8 Mon Sep 17 00:00:00 2001 From: Ko Sugawara Date: Sun, 5 Jul 2026 10:41:48 +0900 Subject: [PATCH 4/4] Update versionto 1.1.0-SNAPSHOT --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 11f6747..d44dad8 100644 --- a/pom.xml +++ b/pom.xml @@ -10,7 +10,7 @@ org.litt geff - 1.0.1-SNAPSHOT + 1.1.0-SNAPSHOT org.litt.geff