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/ + 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) 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 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 ); + } + } }