Skip to content
Merged
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
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,9 @@ buildNumber.properties

# Cross-language test data
cross-language-tests/data/*.zarr/

# Eclipse stuff
.classpath
.project
.settings/

36 changes: 31 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand All @@ -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

Expand All @@ -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:
Expand Down Expand Up @@ -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<GeffNode> 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
Expand Down Expand Up @@ -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)
│ │ ├── <t>/values # Time points [N] (name from axes[type=time], default "t")
│ │ ├── <x>/values # X coordinates [N] (name from axes[type=space][0], default "x")
│ │ ├── <y>/values # Y coordinates [N] (name from axes[type=space][1], default "y")
│ │ ├── <z>/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)
│ │ ├── <tracklet>/values # Track IDs [N] (name from track_node_props, optional)
Expand Down
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

<groupId>org.litt</groupId>
<artifactId>geff</artifactId>
<version>1.0.1-SNAPSHOT</version>
<version>1.1.0-SNAPSHOT</version>

<properties>
<package-name>org.litt.geff</package-name>
Expand Down
40 changes: 40 additions & 0 deletions src/main/java/org/mastodon/geff/GeffMetadata.java
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
102 changes: 69 additions & 33 deletions src/main/java/org/mastodon/geff/GeffNode.java
Original file line number Diff line number Diff line change
Expand Up @@ -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" );
Expand Down Expand Up @@ -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" ) )
Expand All @@ -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 ) );
Expand Down
58 changes: 58 additions & 0 deletions src/test/java/org/mastodon/geff/GeffTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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 );
}
}
}
Loading