From eabdaa1b6e78f917acca4431fe677980ecf58326 Mon Sep 17 00:00:00 2001 From: Jean-Yves TINEVEZ Date: Mon, 20 Jul 2026 16:01:28 +0200 Subject: [PATCH 01/13] Code style changes. --- .../java/org/mastodon/geff/GeffMetadata.java | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/src/main/java/org/mastodon/geff/GeffMetadata.java b/src/main/java/org/mastodon/geff/GeffMetadata.java index 9c773de..ae44a45 100644 --- a/src/main/java/org/mastodon/geff/GeffMetadata.java +++ b/src/main/java/org/mastodon/geff/GeffMetadata.java @@ -6,13 +6,13 @@ * %% * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are met: - * + * * 1. Redistributions of source code must retain the above copyright notice, * this list of conditions and the following disclaimer. * 2. Redistributions in binary form must reproduce the above copyright notice, * this list of conditions and the following disclaimer in the documentation * and/or other materials provided with the distribution. - * + * * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE @@ -89,7 +89,7 @@ public GeffMetadata() /** * Constructor with basic parameters */ - public GeffMetadata( String geffVersion, boolean directed ) + public GeffMetadata( final String geffVersion, final boolean directed ) { setGeffVersion( geffVersion ); this.directed = directed; @@ -98,7 +98,7 @@ public GeffMetadata( String geffVersion, boolean directed ) /** * Constructor with all parameters */ - public GeffMetadata( String geffVersion, boolean directed, GeffAxis[] geffAxes ) + public GeffMetadata( final String geffVersion, final boolean directed, final GeffAxis[] geffAxes ) { setGeffVersion( geffVersion ); this.directed = directed; @@ -108,7 +108,7 @@ public GeffMetadata( String geffVersion, boolean directed, GeffAxis[] geffAxes ) /** * Constructor with all parameters */ - public GeffMetadata( String geffVersion, boolean directed, List< GeffAxis > geffAxes ) + public GeffMetadata( final String geffVersion, final boolean directed, final List< GeffAxis > geffAxes ) { setGeffVersion( geffVersion ); this.directed = directed; @@ -121,7 +121,7 @@ public String getGeffVersion() return geffVersion; } - public void setGeffVersion( String geffVersion ) + public void setGeffVersion( final String geffVersion ) { if ( geffVersion != null && !SUPPORTED_VERSIONS_PATTERN.matcher( geffVersion ).matches() ) { throw new IllegalArgumentException( @@ -137,7 +137,7 @@ public boolean isDirected() return directed; } - public void setDirected( boolean directed ) + public void setDirected( final boolean directed ) { this.directed = directed; } @@ -152,7 +152,7 @@ public List< GeffAxis > getGeffAxesList() // TODO rename getGeffAxes() return ( geffAxes != null ) ? Arrays.asList( geffAxes ) : null; } - public void setGeffAxes( GeffAxis[] geffAxes ) // TODO make List + public void setGeffAxes( final GeffAxis[] geffAxes ) // TODO make List { this.geffAxes = geffAxes != null ? geffAxes.clone() : null; validate(); @@ -169,7 +169,7 @@ public Map< String, PropMetadata > getNodePropsMetadata() return nodePropsMetadata; } - public void setNodePropsMetadata( Map< String, PropMetadata > nodePropsMetadata ) + public void setNodePropsMetadata( final Map< String, PropMetadata > nodePropsMetadata ) { this.nodePropsMetadata = nodePropsMetadata; } @@ -179,7 +179,7 @@ public Map< String, PropMetadata > getEdgePropsMetadata() return edgePropsMetadata; } - public void setEdgePropsMetadata( Map< String, PropMetadata > edgePropsMetadata ) + public void setEdgePropsMetadata( final Map< String, PropMetadata > edgePropsMetadata ) { this.edgePropsMetadata = edgePropsMetadata; } @@ -189,7 +189,7 @@ public Map< String, String > getTrackNodeProps() return trackNodeProps; } - public void setTrackNodeProps( Map< String, String > trackNodeProps ) + public void setTrackNodeProps( final Map< String, String > trackNodeProps ) { this.trackNodeProps = trackNodeProps; } @@ -201,11 +201,11 @@ public void setTrackNodeProps( Map< String, String > trackNodeProps ) * @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 ) + public String getAxisNameByType( final String type ) { if ( geffAxes != null ) { - for ( GeffAxis axis : geffAxes ) + for ( final GeffAxis axis : geffAxes ) { if ( type.equals( axis.getType() ) ) { @@ -223,7 +223,7 @@ public String getAxisNameByType( String 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 ) + public String[] getAxisNamesByType( final String type ) { if ( geffAxes == null ) return new String[ 0 ]; @@ -245,7 +245,7 @@ public void validate() // Check spatial metadata consistency if position is provided if ( geffAxes != null ) { - for ( GeffAxis axis : geffAxes ) + for ( final GeffAxis axis : geffAxes ) { if ( !Arrays.asList( GeffAxis.TYPE_TIME, GeffAxis.TYPE_SPACE, GeffAxis.TYPE_CHANNEL ).contains( axis.getType() ) ) { throw new IllegalArgumentException( @@ -387,7 +387,7 @@ public boolean equals( final Object o ) { if ( !( o instanceof GeffMetadata ) ) return false; - GeffMetadata that = ( GeffMetadata ) o; + final GeffMetadata that = ( GeffMetadata ) o; return directed == that.directed && Objects.equals( geffVersion, that.geffVersion ) && Objects.deepEquals( geffAxes, that.geffAxes ) && Objects.equals( nodePropsMetadata, that.nodePropsMetadata ) && Objects.equals( edgePropsMetadata, that.edgePropsMetadata ) && Objects.equals( trackNodeProps, that.trackNodeProps ); From b8e9fc9e34e1dc6ae2741a80acb0e82d50f3e25d Mon Sep 17 00:00:00 2001 From: Jean-Yves TINEVEZ Date: Mon, 20 Jul 2026 16:31:48 +0200 Subject: [PATCH 02/13] Store and retrieve the 'extra' metadata parameter. --- .../java/org/mastodon/geff/GeffMetadata.java | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/src/main/java/org/mastodon/geff/GeffMetadata.java b/src/main/java/org/mastodon/geff/GeffMetadata.java index ae44a45..706ac44 100644 --- a/src/main/java/org/mastodon/geff/GeffMetadata.java +++ b/src/main/java/org/mastodon/geff/GeffMetadata.java @@ -80,6 +80,20 @@ public class GeffMetadata private Map< String, String > trackNodeProps; + /** + * The optional extra object is a free-form dictionary that can hold any + * additional, application-specific metadata that is not covered by the core + * geff schema. Users may place arbitrary keys and values inside extra + * without fear of clashing with future reserved fields. Although the core + * geff reader makes these attributes available, their meaning and use are + * left entirely to downstream applications. + * + * @see GEFF + * Specification: extra + */ + private Map< String, Object > extra; + /** * Default constructor */ @@ -194,6 +208,16 @@ public void setTrackNodeProps( final Map< String, String > trackNodeProps ) this.trackNodeProps = trackNodeProps; } + public Map< String, Object > getExtra() + { + return extra; + } + + public void setExtra( final Map< String, Object > extra ) + { + this.extra = extra; + } + /** * 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. @@ -318,10 +342,27 @@ public static GeffMetadata readFromN5( final N5Reader reader, final String group } LOG.debug( "found geff/track_node_props = {}", trackNodeProps ); + // Extra may be null, so safe-read it + Map< String, Object > extra = null; + try + { + extra = reader.getAttribute( group, "geff/extra", + new TypeToken< Map< String, Object > >() + {}.getType() ); + } + catch ( final Exception e ) + { + // If the attribute cannot be parsed as Map (e.g., + // if it's null in JSON), just leave it as null + LOG.debug( "Could not parse geff/extra as Map, setting to null: {}", e.getMessage() ); + } + LOG.debug( "found geff/extra = {}", extra ); + final GeffMetadata metadata = new GeffMetadata( geffVersion, directed, axes ); metadata.setNodePropsMetadata( nodePropsMetadata ); metadata.setEdgePropsMetadata( edgePropsMetadata ); metadata.setTrackNodeProps( trackNodeProps ); + metadata.setExtra( extra ); metadata.validate(); return metadata; @@ -372,6 +413,12 @@ public void writeToN5( final N5Writer writer, final String group ) LOG.debug( "writing geff/track_node_props {}", trackNodeProps ); writer.setAttribute( group, "geff/track_node_props", trackNodeProps ); } + + if ( extra != null ) + { + LOG.debug( "writing geff/extra {}", extra ); + writer.setAttribute( group, "geff/extra", extra ); + } } @Override From a56f894e0c59eac24692cbd99d86173d38af4395 Mon Sep 17 00:00:00 2001 From: Ko Sugawara Date: Mon, 20 Jul 2026 23:44:32 +0900 Subject: [PATCH 03/13] Redesign VarlengthProperty to hold per-node data instead of bulk data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, VarlengthProperty held a flattened data array and an offset table covering all nodes at once. This forced callers to pass a node index to getData (getNodeData(i)) and isMissing(i), and every node in a list ended up sharing the same VarlengthProperty object — a confusing mismatch with the single-node abstraction of GeffNode and GeffEdge. Each VarlengthProperty now stores the data slice for exactly one node/edge. The per-node constructors are: new VarlengthProperty(name, dtype, data) new VarlengthProperty(name, dtype, data, missing) getData() and isMissing() take no index. The old bulk constructor and getNodeData(int) / getOffsets() are removed. GeffUtils.readVarlengthProperty now returns VarlengthProperty[] (one entry per node). GeffNode and GeffEdge split the returned array when building node/edge objects on read, and assemble per-node data into the bulk format required by writeVarlengthProperty on write. --- README.md | 15 +- src/main/java/org/mastodon/geff/GeffEdge.java | 47 ++---- src/main/java/org/mastodon/geff/GeffNode.java | 62 ++----- .../java/org/mastodon/geff/GeffUtils.java | 71 ++++---- .../org/mastodon/geff/VarlengthProperty.java | 154 ++++++------------ src/test/java/org/mastodon/geff/GeffTest.java | 41 +++-- .../mastodon/geff/VarlengthPropertyTest.java | 131 +++++---------- .../geff/VarlengthPropertyWriteTest.java | 9 +- 8 files changed, 195 insertions(+), 335 deletions(-) diff --git a/README.md b/README.md index 696ce6d..d419470 100644 --- a/README.md +++ b/README.md @@ -155,11 +155,20 @@ newEdges.add(edge); // Write to Zarr format GeffEdge.writeToZarr(newEdges, "/path/to/output.zarr/tracks", "1.0.0"); -// Access variable-length properties after reading (e.g. per-node polygon) +// Set variable-length properties per node (e.g. per-node polygon vertices). +// Polygon data is stored as interleaved [x1, y1, x2, y2, ...] pairs. +node0.setVarlengthProperty("polygon", + new VarlengthProperty("polygon", "float64", + new Object[]{1.0, 2.0, 3.0, 2.0, 2.0, 4.0})); // triangle: 3 vertices +node1.setVarlengthProperty("polygon", + new VarlengthProperty("polygon", "float64", + new Object[]{5.0, 6.0, 7.0, 6.0, 7.0, 8.0, 5.0, 8.0})); // quad: 4 vertices + +// Access variable-length properties after reading List readNodes = GeffNode.readFromZarr("/path/to/data.zarr/tracks"); VarlengthProperty polygon = readNodes.get(0).getVarlengthProperty("polygon"); -if (polygon != null) { - Object nodeData = polygon.getNodeData(0); // double[] or int[] for node 0 +if (polygon != null && !polygon.isMissing()) { + Object[] nodeData = polygon.getData(); // values for this node } // Create metadata with axis information diff --git a/src/main/java/org/mastodon/geff/GeffEdge.java b/src/main/java/org/mastodon/geff/GeffEdge.java index 9be9762..b6cfc2b 100644 --- a/src/main/java/org/mastodon/geff/GeffEdge.java +++ b/src/main/java/org/mastodon/geff/GeffEdge.java @@ -31,7 +31,6 @@ import static org.mastodon.geff.GeffUtils.checkSupportedVersion; import static org.mastodon.geff.GeffUtils.verifyLength; -import java.lang.reflect.Array; import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; @@ -324,7 +323,7 @@ public static List< GeffEdge > readFromN5( final N5Reader reader, final String g // Read custom non-standard, non-varlength edge props from metadata final Map< String, Object[] > customPropData = new HashMap<>(); - final Map< String, VarlengthProperty > varlengthPropsMap = new HashMap<>(); + final Map< String, VarlengthProperty[] > varlengthPropsMap = new HashMap<>(); if ( metadata != null && metadata.getEdgePropsMetadata() != null ) { for ( final Map.Entry< String, PropMetadata > entry : metadata.getEdgePropsMetadata().entrySet() ) @@ -340,10 +339,10 @@ public static List< GeffEdge > readFromN5( final N5Reader reader, final String g { // Varlength edge prop final String propPath = path + "/edges/props/" + propName; - final VarlengthProperty vp = GeffUtils.readVarlengthProperty( reader, propPath, numEdges, propMeta ); - if ( vp != null ) + final VarlengthProperty[] perEdgeProps = GeffUtils.readVarlengthProperty( reader, propPath, numEdges, propMeta ); + if ( perEdgeProps != null ) { - varlengthPropsMap.put( propName, vp ); + varlengthPropsMap.put( propName, perEdgeProps ); LOG.debug( "Successfully read varlength edge property: {}", propName ); } } @@ -421,9 +420,13 @@ else if ( ndim == 2 ) edge.setProp( entry.getKey(), val ); } - // Set varlength props - for ( final Map.Entry< String, VarlengthProperty > entry : varlengthPropsMap.entrySet() ) - edge.setVarlengthProperty( entry.getKey(), entry.getValue() ); + // Set varlength props (each edge gets its own per-edge slice) + for ( final Map.Entry< String, VarlengthProperty[] > entry : varlengthPropsMap.entrySet() ) + { + final VarlengthProperty edgeProp = entry.getValue()[ i ]; + if ( edgeProp != null ) + edge.setVarlengthProperty( entry.getKey(), edgeProp ); + } edges.add( edge ); } @@ -616,7 +619,7 @@ else if ( sample instanceof int[] ) for ( int i = 0; i < numEdges; i++ ) { final VarlengthProperty property = edges.get( i ).getVarlengthProperty( propName ); - if ( property == null || property.isMissing( i ) ) + if ( property == null || property.isMissing() ) { edgeDataArrays[ i ] = null; missing[ i ] = true; @@ -626,30 +629,8 @@ else if ( sample instanceof int[] ) if ( dtype == null ) dtype = property.getDtype(); - final Object edgeData = property.getNodeData( i ); - if ( edgeData == null ) - { - edgeDataArrays[ i ] = new Object[ 0 ]; - } - else if ( edgeData.getClass().isArray() ) - { - if ( edgeData instanceof Object[] ) - { - edgeDataArrays[ i ] = ( Object[] ) edgeData; - } - else - { - final int length = Array.getLength( edgeData ); - final Object[] converted = new Object[ length ]; - for ( int j = 0; j < length; j++ ) - converted[ j ] = Array.get( edgeData, j ); - edgeDataArrays[ i ] = converted; - } - } - else - { - edgeDataArrays[ i ] = new Object[] { edgeData }; - } + final Object[] edgeData = property.getData(); + edgeDataArrays[ i ] = edgeData != null ? edgeData : new Object[ 0 ]; } if ( dtype == null ) diff --git a/src/main/java/org/mastodon/geff/GeffNode.java b/src/main/java/org/mastodon/geff/GeffNode.java index 1f5855b..8caf8ec 100644 --- a/src/main/java/org/mastodon/geff/GeffNode.java +++ b/src/main/java/org/mastodon/geff/GeffNode.java @@ -32,7 +32,6 @@ import static org.mastodon.geff.GeffUtils.verifyLength; import java.io.IOException; -import java.lang.reflect.Array; import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; @@ -810,8 +809,8 @@ public static List< GeffNode > readFromN5( final N5Reader reader, final String g double[][] polygonsX = null; double[][] polygonsY = null; - // Read varlength properties - final Map< String, VarlengthProperty > varlengthPropsMap = new HashMap<>(); + // Read varlength properties (one VarlengthProperty[] per property name) + final Map< String, VarlengthProperty[] > varlengthPropsMap = new HashMap<>(); if ( metadata.getNodePropsMetadata() != null ) { for ( final String propName : metadata.getNodePropsMetadata().keySet() ) @@ -820,10 +819,10 @@ public static List< GeffNode > readFromN5( final N5Reader reader, final String g if ( propMeta != null && propMeta.getVarlength() != null && propMeta.getVarlength() ) { final String propPath = path + "/nodes/props/" + propName; - final VarlengthProperty varlengthProp = GeffUtils.readVarlengthProperty( reader, propPath, numNodes, propMeta ); - if ( varlengthProp != null ) + final VarlengthProperty[] perNodeProps = GeffUtils.readVarlengthProperty( reader, propPath, numNodes, propMeta ); + if ( perNodeProps != null ) { - varlengthPropsMap.put( propName, varlengthProp ); + varlengthPropsMap.put( propName, perNodeProps ); LOG.debug( "Successfully read varlength property: {}", propName ); } } @@ -899,20 +898,20 @@ else if ( ndim == 2 ) } // Extract polygon from varlength map into polygonX/Y fields (v1 spec: nodes/props/polygon/). - // The VarlengthProperty stays in the map so nodes also expose it via getVarlengthProperty(). + // The per-node VarlengthProperty stays in the map so nodes also expose it via getVarlengthProperty(). if ( varlengthPropsMap.containsKey( "polygon" ) ) { - final VarlengthProperty polygonProp = varlengthPropsMap.get( "polygon" ); + final VarlengthProperty[] polygonProps = varlengthPropsMap.get( "polygon" ); polygonsX = new double[ numNodes ][]; polygonsY = new double[ numNodes ][]; for ( int i = 0; i < numNodes; i++ ) { - if ( !polygonProp.isMissing( i ) ) + final VarlengthProperty prop = polygonProps[ i ]; + if ( prop != null && !prop.isMissing() ) { - final Object nodeData = polygonProp.getNodeData( i ); - if ( nodeData instanceof Object[] ) + final Object[] flat = prop.getData(); + if ( flat != null ) { - final Object[] flat = ( Object[] ) nodeData; final int numVertices = flat.length / 2; polygonsX[ i ] = new double[ numVertices ]; polygonsY[ i ] = new double[ numVertices ]; @@ -985,11 +984,12 @@ else if ( ndim == 2 ) final double[] polygonY = polygonsY != null ? polygonsY[ i ] : null; final GeffNode node = new GeffNode( id, t, x, y, z, color, segmentId, r, covariance2d, covariance3d, polygonX, polygonY ); - // Add varlength properties to the node + // Add varlength properties to the node (each node gets its own slice) for ( final String propName : varlengthPropsMap.keySet() ) { - final VarlengthProperty varlengthProp = varlengthPropsMap.get( propName ); - node.setVarlengthProperty( propName, varlengthProp ); + final VarlengthProperty nodeProp = varlengthPropsMap.get( propName )[ i ]; + if ( nodeProp != null ) + node.setVarlengthProperty( propName, nodeProp ); } // Set custom non-standard props on the node @@ -1173,7 +1173,7 @@ public static void writeToN5( for ( int i = 0; i < numNodes; i++ ) { final VarlengthProperty property = nodes.get( i ).getVarlengthProperty( propName ); - if ( property == null || property.isMissing( i ) ) + if ( property == null || property.isMissing() ) { nodeDataArrays[ i ] = null; missing[ i ] = true; @@ -1181,36 +1181,10 @@ public static void writeToN5( } if ( dtype == null ) - { dtype = property.getDtype(); - } - final Object nodeData = property.getNodeData( i ); - if ( nodeData == null ) - { - nodeDataArrays[ i ] = new Object[ 0 ]; - } - else if ( nodeData.getClass().isArray() ) - { - if ( nodeData instanceof Object[] ) - { - nodeDataArrays[ i ] = ( Object[] ) nodeData; - } - else - { - final int length = Array.getLength( nodeData ); - final Object[] converted = new Object[ length ]; - for ( int j = 0; j < length; j++ ) - { - converted[ j ] = Array.get( nodeData, j ); - } - nodeDataArrays[ i ] = converted; - } - } - else - { - nodeDataArrays[ i ] = new Object[] { nodeData }; - } + final Object[] nodeData = property.getData(); + nodeDataArrays[ i ] = nodeData != null ? nodeData : new Object[ 0 ]; } if ( dtype == null ) diff --git a/src/main/java/org/mastodon/geff/GeffUtils.java b/src/main/java/org/mastodon/geff/GeffUtils.java index ee4e6e9..98b4667 100644 --- a/src/main/java/org/mastodon/geff/GeffUtils.java +++ b/src/main/java/org/mastodon/geff/GeffUtils.java @@ -620,26 +620,27 @@ private static void copy( } /** - * Read a variable-length property from the zarr format - * + * Read a variable-length property from the zarr format. Returns one + * {@link VarlengthProperty} per node/edge, or {@code null} if the property + * does not exist. + * * @param reader * N5Reader to read from * @param propPath * Path to the property (e.g., /nodes/props/polygon) * @param numNodes - * Number of nodes in the graph + * Number of nodes/edges in the graph * @param metadata * PropMetadata for the property (optional, for dtype info) - * @return VarlengthProperty containing the varlength data, or null if - * property doesn't exist + * @return array of per-node {@link VarlengthProperty}, or {@code null} if + * the property does not exist */ - public static VarlengthProperty readVarlengthProperty( + public static VarlengthProperty[] readVarlengthProperty( final N5Reader reader, final String propPath, final int numNodes, final PropMetadata metadata ) { - // Check if the property exists final String dataPath = propPath + "/data"; final String valuesPath = propPath + "/values"; @@ -651,7 +652,6 @@ public static VarlengthProperty readVarlengthProperty( try { - // Read the data array (flattened values) final Object dataArray = readFully( reader, dataPath ); if ( dataArray == null ) { @@ -659,9 +659,7 @@ public static VarlengthProperty readVarlengthProperty( return null; } - // Read the values array (offset and shape information) - // values shape is (numNodes, ndim+1) where first column is offset, - // rest are dims + // values shape is (ndim+1, numNodes): first row = offsets, rest = dims final FlattenedInts valuesArray = readAsIntMatrix( reader, valuesPath, "varlength property values" ); if ( valuesArray == null ) { @@ -669,11 +667,9 @@ public static VarlengthProperty readVarlengthProperty( return null; } - // Convert values array to long[][] for VarlengthProperty final int[] valuesDims = valuesArray.size(); final int numColumns = valuesDims[ 0 ]; // ndim + 1 - final int numRowsFromValues = valuesDims[ 1 ]; // should equal - // numNodes + final int numRowsFromValues = valuesDims[ 1 ]; if ( numRowsFromValues != numNodes ) { @@ -681,18 +677,8 @@ public static VarlengthProperty readVarlengthProperty( return null; } - final long[][] offsets = new long[ numNodes ][]; - for ( int i = 0; i < numNodes; i++ ) - { - offsets[ i ] = new long[ numColumns ]; - for ( int j = 0; j < numColumns; j++ ) - { - offsets[ i ][ j ] = valuesArray.at( j, i ); - } - } - - // Read missing array if present - boolean[] missing = null; + // Read missing indicators if present + boolean[] missingFlags = null; final String missingPath = propPath + "/missing"; if ( reader.datasetExists( missingPath ) ) { @@ -701,9 +687,9 @@ public static VarlengthProperty readVarlengthProperty( final byte[] missingBytes = ( byte[] ) readFully( reader, missingPath ); if ( missingBytes != null && missingBytes.length == numNodes ) { - missing = new boolean[ numNodes ]; + missingFlags = new boolean[ numNodes ]; for ( int i = 0; i < numNodes; i++ ) - missing[ i ] = missingBytes[ i ] != 0; + missingFlags[ i ] = missingBytes[ i ] != 0; LOG.debug( "Varlength property {} has missing indicators", propPath ); } } @@ -713,18 +699,35 @@ public static VarlengthProperty readVarlengthProperty( } } - // Determine dtype from metadata if available final String dtype = metadata != null ? metadata.getDtype() : "unknown"; - - // Use the property name from metadata; fall back to the last path segment final String propName = ( metadata != null && metadata.getIdentifier() != null ) ? metadata.getIdentifier() : propPath.substring( propPath.lastIndexOf( '/' ) + 1 ); - // Create and return VarlengthProperty - // Convert Object array to handle different data types properly final Object[] convertedData = convertVarlengthData( dataArray, dtype ); - return new VarlengthProperty( propName, dtype, convertedData, offsets, missing ); + + // Split bulk data into one VarlengthProperty per node/edge + final VarlengthProperty[] result = new VarlengthProperty[ numNodes ]; + for ( int i = 0; i < numNodes; i++ ) + { + final boolean isMissing = missingFlags != null && missingFlags[ i ]; + if ( isMissing ) + { + result[ i ] = new VarlengthProperty( propName, dtype, null, true ); + continue; + } + + final long offset = valuesArray.at( 0, i ); + long totalElements = 1; + for ( int j = 1; j < numColumns; j++ ) + totalElements *= valuesArray.at( j, i ); + + final Object[] nodeData = new Object[ ( int ) totalElements ]; + if ( convertedData != null ) + System.arraycopy( convertedData, ( int ) offset, nodeData, 0, ( int ) totalElements ); + result[ i ] = new VarlengthProperty( propName, dtype, nodeData ); + } + return result; } catch ( final Exception e ) { diff --git a/src/main/java/org/mastodon/geff/VarlengthProperty.java b/src/main/java/org/mastodon/geff/VarlengthProperty.java index 945d794..f88d69d 100644 --- a/src/main/java/org/mastodon/geff/VarlengthProperty.java +++ b/src/main/java/org/mastodon/geff/VarlengthProperty.java @@ -6,13 +6,13 @@ * %% * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are met: - * + * * 1. Redistributions of source code must retain the above copyright notice, * this list of conditions and the following disclaimer. * 2. Redistributions in binary form must reproduce the above copyright notice, * this list of conditions and the following disclaimer in the documentation * and/or other materials provided with the distribution. - * + * * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE @@ -32,14 +32,15 @@ import java.util.Objects; /** - * Represents a variable-length property in the GEFF format. Each node can have - * a different shape/length for the property value. The data is stored as a - * single flattened array, with offset and shape information for each node. - * - * Format (from spec): - data: 1D array containing all flattened values - * concatenated - values: (N, ndim+1) array where N is number of nodes, ndim is - * dimensionality - First column: offset into the data array for that node's - * data - Remaining columns: shape of that node's data + * Represents a variable-length property value for a single node or edge. Each + * instance holds the data for exactly one element; the length may differ + * between elements, which is the defining feature of a varlength property. + *

+ * On-disk (Zarr), varlength data is stored as a flattened array with an offset + * table. That layout is an I/O implementation detail handled by + * {@link GeffUtils#readVarlengthProperty} and + * {@link GeffUtils#writeVarlengthProperty}; users of this class do not need to + * know about it. */ public class VarlengthProperty { @@ -49,36 +50,47 @@ public class VarlengthProperty private final Object[] data; - private final long[][] offsets; + private final boolean missing; - private final boolean[] missing; + /** + * Creates a non-missing varlength property for one node/edge. + * + * @param name + * property name (e.g. {@code "polygon"}) + * @param dtype + * data type string (e.g. {@code "float64"}) + * @param data + * the values for this node/edge + */ + public VarlengthProperty( final String name, final String dtype, final Object[] data ) + { + this( name, dtype, data, false ); + } /** - * Constructor for VarlengthProperty + * Creates a varlength property for one node/edge with an explicit missing + * flag. * * @param name - * Name of the property + * property name * @param dtype - * Data type of the property + * data type string * @param data - * The raw data array that holds all values flattened - * @param offsets - * Array of shape information for each node. offsets[i][0] is the - * offset, offsets[i][1:] are the dimensions + * the values for this node/edge; ignored when {@code missing} is + * {@code true} * @param missing - * Boolean array indicating which nodes have missing values + * {@code true} if this entry has no value */ - public VarlengthProperty( final String name, final String dtype, final Object[] data, final long[][] offsets, final boolean[] missing ) + public VarlengthProperty( final String name, final String dtype, final Object[] data, final boolean missing ) { this.name = name; this.dtype = dtype; this.data = data; - this.offsets = offsets; this.missing = missing; } /** - * Get the name of the property + * Returns the property name. */ public String getName() { @@ -86,7 +98,7 @@ public String getName() } /** - * Get the data type + * Returns the data type string (e.g. {@code "float64"}, {@code "int32"}). */ public String getDtype() { @@ -94,7 +106,8 @@ public String getDtype() } /** - * Get the raw flattened data array + * Returns the data values for this node/edge, or {@code null} when + * {@link #isMissing()} is {@code true}. */ public Object[] getData() { @@ -102,90 +115,20 @@ public Object[] getData() } /** - * Get offset and shape information for each node - * - * @return 2D array where [i][0] is offset, [i][1:] are dimensions for node - * i + * Returns {@code true} when this entry has no value (missing-value + * indicator). */ - public long[][] getOffsets() - { - return offsets; - } - - /** - * Get missing value indicators - */ - public boolean[] getMissing() + public boolean isMissing() { return missing; } - /** - * Check if a specific node has a missing value - * - * @param nodeIndex - * Index of the node - * @return true if the value is missing, false otherwise - */ - public boolean isMissing( final int nodeIndex ) - { - if ( missing == null || nodeIndex < 0 || nodeIndex >= missing.length ) - { return false; } - return missing[ nodeIndex ]; - } - - /** - * Get the data for a specific node - * - * @param nodeIndex - * Index of the node - * @return Array of data for this node, or null if missing - */ - public Object getNodeData( final int nodeIndex ) - { - if ( isMissing( nodeIndex ) ) - { return null; } - - if ( nodeIndex < 0 || nodeIndex >= offsets.length ) - { return null; } - - final long[] shapeInfo = offsets[ nodeIndex ]; - if ( shapeInfo == null || shapeInfo.length < 1 ) - { return null; } - - final long offset = shapeInfo[ 0 ]; - final long[] shape = Arrays.copyOfRange( shapeInfo, 1, shapeInfo.length ); - - // Calculate total number of elements - long totalElements = 1; - for ( final long dim : shape ) - { - totalElements *= dim; - } - - if ( offset < 0 || offset + totalElements > data.length ) - { - // Invalid offset/shape, return null - return null; - } - - // Extract the slice of data - final Object[] nodeData = new Object[ ( int ) totalElements ]; - System.arraycopy( data, ( int ) offset, nodeData, 0, ( int ) totalElements ); - - // If single element or 1D, return as-is; otherwise keep as array - if ( shape.length == 0 ) - { return nodeData.length > 0 ? nodeData[ 0 ] : null; } - - return nodeData; - } - @Override public String toString() { return String.format( - "VarlengthProperty{name='%s', dtype='%s', dataLength=%d, nodeCount=%d}", - name, dtype, data != null ? data.length : 0, offsets != null ? offsets.length : 0 ); + "VarlengthProperty{name='%s', dtype='%s', length=%d, missing=%b}", + name, dtype, data != null ? data.length : 0, missing ); } @Override @@ -195,13 +138,18 @@ public boolean equals( final Object o ) return true; if ( o == null || getClass() != o.getClass() ) return false; - VarlengthProperty that = ( VarlengthProperty ) o; - return Objects.equals( name, that.name ) && Objects.equals( dtype, that.dtype ); + final VarlengthProperty that = ( VarlengthProperty ) o; + return missing == that.missing + && Objects.equals( name, that.name ) + && Objects.equals( dtype, that.dtype ) + && Arrays.equals( data, that.data ); } @Override public int hashCode() { - return Objects.hash( name, dtype ); + int result = Objects.hash( name, dtype, missing ); + result = 31 * result + Arrays.hashCode( data ); + return result; } } diff --git a/src/test/java/org/mastodon/geff/GeffTest.java b/src/test/java/org/mastodon/geff/GeffTest.java index e1d4c77..9d86745 100644 --- a/src/test/java/org/mastodon/geff/GeffTest.java +++ b/src/test/java/org/mastodon/geff/GeffTest.java @@ -275,15 +275,16 @@ void testVarlengthPropertyWriteRead( @TempDir Path tempDir ) throws IOException nodes.add( node ); } - final Object[] data = new Object[] { 1.0, 2.0, 3.0, 4.0, 5.0, 6.0 }; - final long[][] offsets = new long[][] { { 0, 2 }, { 2, 3 }, { 5, 1 } }; - final boolean[] missing = new boolean[] { false, false, false }; - - final VarlengthProperty polygonProperty = new VarlengthProperty( "polygon", "float64", data, offsets, missing ); - for ( GeffNode node : nodes ) - { - node.setVarlengthProperty( "polygon", polygonProperty ); - } + // Polygon data is stored as interleaved x,y pairs, so length must be even. + // Node 0: 1 vertex → [x1, y1] + nodes.get( 0 ).setVarlengthProperty( "polygon", + new VarlengthProperty( "polygon", "float64", new Object[] { 1.0, 2.0 } ) ); + // Node 1: 2 vertices → [x1, y1, x2, y2] + nodes.get( 1 ).setVarlengthProperty( "polygon", + new VarlengthProperty( "polygon", "float64", new Object[] { 3.0, 4.0, 5.0, 6.0 } ) ); + // Node 2: 2 vertices → [x1, y1, x2, y2] + nodes.get( 2 ).setVarlengthProperty( "polygon", + new VarlengthProperty( "polygon", "float64", new Object[] { 7.0, 8.0, 9.0, 10.0 } ) ); final GeffMetadata metadata = new GeffMetadata( Geff.VERSION, true ); GeffNode.writeToZarr( nodes, tempPath, metadata ); @@ -295,24 +296,22 @@ void testVarlengthPropertyWriteRead( @TempDir Path tempDir ) throws IOException assertTrue( readMetadata.getNodePropsMetadata().containsKey( "polygon" ) ); assertEquals( true, readMetadata.getNodePropsMetadata().get( "polygon" ).getVarlength() ); + final Object[][] expectedData = { + new Object[] { 1.0, 2.0 }, + new Object[] { 3.0, 4.0, 5.0, 6.0 }, + new Object[] { 7.0, 8.0, 9.0, 10.0 } + }; + for ( int i = 0; i < nodes.size(); i++ ) { final VarlengthProperty readProperty = readNodes.get( i ).getVarlengthProperty( "polygon" ); assertNotNull( readProperty ); - final Object nodeData = readProperty.getNodeData( i ); + assertFalse( readProperty.isMissing() ); + final Object[] nodeData = readProperty.getData(); assertNotNull( nodeData ); - assertTrue( nodeData instanceof Object[] ); - - final int offset = ( int ) offsets[ i ][ 0 ]; - final int length = ( int ) offsets[ i ][ 1 ]; - final Object[] expected = new Object[ length ]; - for ( int j = 0; j < length; j++ ) - { - expected[ j ] = data[ offset + j ]; - } - System.out.println( "Node " + i + " expected=" + java.util.Arrays.toString( expected ) + " actual=" + java.util.Arrays.toString( ( Object[] ) nodeData ) ); - assertArrayEquals( expected, ( Object[] ) nodeData ); + System.out.println( "Node " + i + " expected=" + java.util.Arrays.toString( expectedData[ i ] ) + " actual=" + java.util.Arrays.toString( nodeData ) ); + assertArrayEquals( expectedData[ i ], nodeData ); } } diff --git a/src/test/java/org/mastodon/geff/VarlengthPropertyTest.java b/src/test/java/org/mastodon/geff/VarlengthPropertyTest.java index bdb24fa..1df9eb4 100644 --- a/src/test/java/org/mastodon/geff/VarlengthPropertyTest.java +++ b/src/test/java/org/mastodon/geff/VarlengthPropertyTest.java @@ -6,13 +6,13 @@ * %% * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are met: - * + * * 1. Redistributions of source code must retain the above copyright notice, * this list of conditions and the following disclaimer. * 2. Redistributions in binary form must reproduce the above copyright notice, * this list of conditions and the following disclaimer in the documentation * and/or other materials provided with the distribution. - * + * * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE @@ -30,12 +30,11 @@ import static org.junit.Assert.*; -import org.junit.After; import org.junit.Before; import org.junit.Test; /** - * Unit tests for VarlengthProperty class + * Unit tests for VarlengthProperty (per-node design). */ public class VarlengthPropertyTest { @@ -44,30 +43,9 @@ public class VarlengthPropertyTest @Before public void setUp() { - // Create test data: 3 nodes with variable-length data - // Node 0: offset=0, shape=[2, 3] (6 elements) - // Node 1: offset=6, shape=[3, 2] (6 elements) - // Node 2: offset=12, shape=[1, 4] (4 elements) - final Object[] data = new Object[ 16 ]; - for ( int i = 0; i < 16; i++ ) - { - data[ i ] = ( double ) i; - } - - final long[][] offsets = new long[ 3 ][]; - offsets[ 0 ] = new long[] { 0, 2, 3 }; - offsets[ 1 ] = new long[] { 6, 3, 2 }; - offsets[ 2 ] = new long[] { 12, 1, 4 }; - - final boolean[] missing = new boolean[] { false, false, false }; - - property = new VarlengthProperty( "test_polygon", "float64", data, offsets, missing ); - } - - @After - public void tearDown() - { - property = null; + // A VarlengthProperty holds data for a single node + property = new VarlengthProperty( "test_polygon", "float64", + new Object[] { 0.0, 1.0, 2.0, 3.0, 4.0, 5.0 } ); } @Test @@ -76,41 +54,32 @@ public void testBasicProperties() assertEquals( "test_polygon", property.getName() ); assertEquals( "float64", property.getDtype() ); assertNotNull( property.getData() ); - assertEquals( 16, property.getData().length ); - assertNotNull( property.getOffsets() ); - assertEquals( 3, property.getOffsets().length ); + assertEquals( 6, property.getData().length ); + assertFalse( property.isMissing() ); } @Test - public void testMissingValues() + public void testGetData() { - assertFalse( property.isMissing( 0 ) ); - assertFalse( property.isMissing( 1 ) ); - assertFalse( property.isMissing( 2 ) ); - assertFalse( property.isMissing( -1 ) ); // Out of bounds - assertFalse( property.isMissing( 3 ) ); // Out of bounds + final Object[] data = property.getData(); + assertNotNull( data ); + assertEquals( 6, data.length ); + assertEquals( 0.0, data[ 0 ] ); + assertEquals( 5.0, data[ 5 ] ); } @Test - public void testGetNodeData() + public void testNonMissingDefault() { - // Test Node 0 - final Object nodeData0 = property.getNodeData( 0 ); - assertNotNull( nodeData0 ); - assertTrue( nodeData0 instanceof Object[] ); - assertEquals( 6, ( ( Object[] ) nodeData0 ).length ); - - // Test Node 1 - final Object nodeData1 = property.getNodeData( 1 ); - assertNotNull( nodeData1 ); - assertTrue( nodeData1 instanceof Object[] ); - assertEquals( 6, ( ( Object[] ) nodeData1 ).length ); + assertFalse( property.isMissing() ); + } - // Test Node 2 - final Object nodeData2 = property.getNodeData( 2 ); - assertNotNull( nodeData2 ); - assertTrue( nodeData2 instanceof Object[] ); - assertEquals( 4, ( ( Object[] ) nodeData2 ).length ); + @Test + public void testMissingFlag() + { + final VarlengthProperty missing = new VarlengthProperty( "test", "float64", null, true ); + assertTrue( missing.isMissing() ); + assertNull( missing.getData() ); } @Test @@ -119,55 +88,31 @@ public void testToString() final String str = property.toString(); assertTrue( str.contains( "test_polygon" ) ); assertTrue( str.contains( "float64" ) ); - assertTrue( str.contains( "dataLength=16" ) ); - assertTrue( str.contains( "nodeCount=3" ) ); + assertTrue( str.contains( "length=6" ) ); + assertTrue( str.contains( "missing=false" ) ); } @Test public void testEquals() { - final VarlengthProperty prop2 = new VarlengthProperty( "test_polygon", "float64", new Object[ 10 ], new long[ 3 ][], null ); - // Equals should only compare name and dtype - assertEquals( property, prop2 ); - - final VarlengthProperty prop3 = new VarlengthProperty( "different_name", "float64", property.getData(), property.getOffsets(), null ); - assertNotEquals( property, prop3 ); - } - - @Test - public void testHashCode() - { - // Objects with same name and dtype should have same hash - final VarlengthProperty prop2 = new VarlengthProperty( "test_polygon", "float64", new Object[ 10 ], new long[ 3 ][], null ); - assertEquals( property.hashCode(), prop2.hashCode() ); - } + final VarlengthProperty same = new VarlengthProperty( "test_polygon", "float64", + new Object[] { 0.0, 1.0, 2.0, 3.0, 4.0, 5.0 } ); + assertEquals( property, same ); - @Test - public void testGetNodeDataWithMissing() - { - final boolean[] missing = new boolean[] { false, true, false }; - final VarlengthProperty propWithMissing = new VarlengthProperty( - "test", - "float64", - property.getData(), - property.getOffsets(), - missing ); - - // Node 0 should have data - assertNotNull( propWithMissing.getNodeData( 0 ) ); + final VarlengthProperty diffName = new VarlengthProperty( "other", "float64", + new Object[] { 0.0, 1.0, 2.0, 3.0, 4.0, 5.0 } ); + assertNotEquals( property, diffName ); - // Node 1 should return null (missing) - assertNull( propWithMissing.getNodeData( 1 ) ); - - // Node 2 should have data - assertNotNull( propWithMissing.getNodeData( 2 ) ); + final VarlengthProperty diffData = new VarlengthProperty( "test_polygon", "float64", + new Object[] { 9.0 } ); + assertNotEquals( property, diffData ); } @Test - public void testGetNodeDataOutOfBounds() + public void testHashCode() { - assertNull( property.getNodeData( -1 ) ); - assertNull( property.getNodeData( 3 ) ); - assertNull( property.getNodeData( 100 ) ); + final VarlengthProperty same = new VarlengthProperty( "test_polygon", "float64", + new Object[] { 0.0, 1.0, 2.0, 3.0, 4.0, 5.0 } ); + assertEquals( property.hashCode(), same.hashCode() ); } } diff --git a/src/test/java/org/mastodon/geff/VarlengthPropertyWriteTest.java b/src/test/java/org/mastodon/geff/VarlengthPropertyWriteTest.java index b7eabb0..8e4a861 100644 --- a/src/test/java/org/mastodon/geff/VarlengthPropertyWriteTest.java +++ b/src/test/java/org/mastodon/geff/VarlengthPropertyWriteTest.java @@ -162,10 +162,11 @@ public void testRoundTrip() throws Exception try (final N5ZarrReader reader = new N5ZarrReader( zarrPath )) { - final VarlengthProperty prop = GeffUtils.readVarlengthProperty( reader, "/props/test", 2, metadata ); - assertNotNull( "Property should not be null", prop ); - assertFalse( "Node 0 should not be missing", prop.isMissing( 0 ) ); - assertFalse( "Node 1 should not be missing", prop.isMissing( 1 ) ); + final VarlengthProperty[] props = GeffUtils.readVarlengthProperty( reader, "/props/test", 2, metadata ); + assertNotNull( "Property array should not be null", props ); + assertEquals( "Should have 2 per-node entries", 2, props.length ); + assertFalse( "Node 0 should not be missing", props[ 0 ].isMissing() ); + assertFalse( "Node 1 should not be missing", props[ 1 ].isMissing() ); } } From b675681eb1f56dccb5f891778a492314dc7de3c3 Mon Sep 17 00:00:00 2001 From: Ko Sugawara Date: Tue, 21 Jul 2026 00:05:55 +0900 Subject: [PATCH 04/13] Support uint8 varlength properties for UTF-8 encoded labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add proper uint8 read/write support to varlength properties so that variable-length byte sequences — such as per-node labels encoded as UTF-8 — can be stored and retrieved correctly. On write, Integer elements are now converted to a byte[] before being stored when the declared dtype is "uint8", avoiding the int-to-byte sign mismatch that would otherwise occur via SubArrayCopy. On read, the UINT8 zarr array is returned by N5 as a Java byte[]. convertVarlengthData now handles byte[] by applying "& 0xFF" to each element, yielding unsigned Integer values (0–255) in the Object[] that callers receive from VarlengthProperty.getData(). Multibyte characters (e.g. Japanese) work correctly because the encoding/decoding is entirely UTF-8 byte-level; the varlength layer treats the data as opaque uint8 values. Update README to show the label/uint8 pattern in place of the polygon example, which already has dedicated polygonX/polygonY fields and is therefore a misleading illustration of arbitrary varlength properties. Add testUint8LabelRoundTrip to VarlengthPropertyWriteTest covering both ASCII ("cell_A") and multibyte ("細胞_B") labels. --- README.md | 37 +++++++++----- .../java/org/mastodon/geff/GeffUtils.java | 24 +++++++++- .../geff/VarlengthPropertyWriteTest.java | 48 +++++++++++++++++++ 3 files changed, 95 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index d419470..7c9263b 100644 --- a/README.md +++ b/README.md @@ -155,20 +155,31 @@ newEdges.add(edge); // Write to Zarr format GeffEdge.writeToZarr(newEdges, "/path/to/output.zarr/tracks", "1.0.0"); -// Set variable-length properties per node (e.g. per-node polygon vertices). -// Polygon data is stored as interleaved [x1, y1, x2, y2, ...] pairs. -node0.setVarlengthProperty("polygon", - new VarlengthProperty("polygon", "float64", - new Object[]{1.0, 2.0, 3.0, 2.0, 2.0, 4.0})); // triangle: 3 vertices -node1.setVarlengthProperty("polygon", - new VarlengthProperty("polygon", "float64", - new Object[]{5.0, 6.0, 7.0, 6.0, 7.0, 8.0, 5.0, 8.0})); // quad: 4 vertices - -// Access variable-length properties after reading +// Set a custom variable-length property per node. +// Example: per-node UTF-8 label stored as uint8 byte array. +// (Note: polygon vertices use the dedicated polygonX/polygonY fields instead.) +String label0 = "cell_A"; +String label1 = "細胞_B"; // multibyte characters are supported via UTF-8 encoding +byte[] bytes0 = label0.getBytes(java.nio.charset.StandardCharsets.UTF_8); +byte[] bytes1 = label1.getBytes(java.nio.charset.StandardCharsets.UTF_8); + +Object[] data0 = new Object[bytes0.length]; +for (int i = 0; i < bytes0.length; i++) data0[i] = bytes0[i] & 0xFF; + +Object[] data1 = new Object[bytes1.length]; +for (int i = 0; i < bytes1.length; i++) data1[i] = bytes1[i] & 0xFF; + +node0.setVarlengthProperty("label", new VarlengthProperty("label", "uint8", data0)); +node1.setVarlengthProperty("label", new VarlengthProperty("label", "uint8", data1)); + +// Decode the label back after reading List readNodes = GeffNode.readFromZarr("/path/to/data.zarr/tracks"); -VarlengthProperty polygon = readNodes.get(0).getVarlengthProperty("polygon"); -if (polygon != null && !polygon.isMissing()) { - Object[] nodeData = polygon.getData(); // values for this node +VarlengthProperty labelProp = readNodes.get(0).getVarlengthProperty("label"); +if (labelProp != null && !labelProp.isMissing()) { + Object[] labelData = labelProp.getData(); + byte[] labelBytes = new byte[labelData.length]; + for (int i = 0; i < labelData.length; i++) labelBytes[i] = ((Number) labelData[i]).byteValue(); + String label = new String(labelBytes, java.nio.charset.StandardCharsets.UTF_8); } // Create metadata with axis information diff --git a/src/main/java/org/mastodon/geff/GeffUtils.java b/src/main/java/org/mastodon/geff/GeffUtils.java index 98b4667..01903dc 100644 --- a/src/main/java/org/mastodon/geff/GeffUtils.java +++ b/src/main/java/org/mastodon/geff/GeffUtils.java @@ -788,6 +788,15 @@ else if ( dataArray instanceof float[] ) } return result; } + else if ( dataArray instanceof byte[] ) + { + // UINT8 data: convert signed Java bytes to unsigned integers (0–255) + final byte[] bArray = ( byte[] ) dataArray; + final Object[] result = new Object[ bArray.length ]; + for ( int i = 0; i < bArray.length; i++ ) + result[ i ] = bArray[ i ] & 0xFF; + return result; + } else { // Unknown type, return as Object array if possible @@ -886,7 +895,20 @@ public static void writeVarlengthProperty( { // Step 2: Write data array using declared dtype when available so the // on-disk type matches the metadata (e.g. uint64 stays uint64). - final Object dataToWrite = convertObjectArrayToNativeArray( flattenedData, elementType, ( int ) currentOffset ); + // For uint8 (e.g. UTF-8 byte labels), Integer elements are converted + // to a byte[] to avoid int-to-byte sign issues in SubArrayCopy. + final Object dataToWrite; + if ( "uint8".equalsIgnoreCase( declaredDtype ) && elementType instanceof Integer ) + { + final byte[] bytes = new byte[ ( int ) currentOffset ]; + for ( int i = 0; i < ( int ) currentOffset; i++ ) + bytes[ i ] = ( byte ) ( ( Number ) flattenedData[ i ] ).intValue(); + dataToWrite = bytes; + } + else + { + dataToWrite = convertObjectArrayToNativeArray( flattenedData, elementType, ( int ) currentOffset ); + } DataType dataType = declaredDtype != null ? dtypeStringToDataType( declaredDtype ) : inferDataType( dataToWrite ); diff --git a/src/test/java/org/mastodon/geff/VarlengthPropertyWriteTest.java b/src/test/java/org/mastodon/geff/VarlengthPropertyWriteTest.java index 8e4a861..7c8ec52 100644 --- a/src/test/java/org/mastodon/geff/VarlengthPropertyWriteTest.java +++ b/src/test/java/org/mastodon/geff/VarlengthPropertyWriteTest.java @@ -170,6 +170,54 @@ public void testRoundTrip() throws Exception } } + @Test + public void testUint8LabelRoundTrip() throws Exception + { + final String zarrPath = TEST_OUTPUT_DIR + "/uint8_label.zarr"; + + // Encode UTF-8 labels (including multibyte characters) as uint8 Object[] + final String label0 = "cell_A"; + final String label1 = "細胞_B"; + final byte[] bytes0 = label0.getBytes( java.nio.charset.StandardCharsets.UTF_8 ); + final byte[] bytes1 = label1.getBytes( java.nio.charset.StandardCharsets.UTF_8 ); + + final Object[][] nodeData = new Object[ 2 ][]; + nodeData[ 0 ] = new Object[ bytes0.length ]; + for ( int i = 0; i < bytes0.length; i++ ) + nodeData[ 0 ][ i ] = bytes0[ i ] & 0xFF; + nodeData[ 1 ] = new Object[ bytes1.length ]; + for ( int i = 0; i < bytes1.length; i++ ) + nodeData[ 1 ][ i ] = bytes1[ i ] & 0xFF; + + final boolean[] missing = { false, false }; + final PropMetadata metadata = new PropMetadata( "label", "uint8", true, null, null, null ); + + try (final N5ZarrWriter writer = new N5ZarrWriter( zarrPath, true )) + { + GeffUtils.writeVarlengthProperty( writer, "/props/label", nodeData, missing, 1000, "uint8" ); + } + + try (final N5ZarrReader reader = new N5ZarrReader( zarrPath )) + { + final VarlengthProperty[] props = GeffUtils.readVarlengthProperty( reader, "/props/label", 2, metadata ); + assertNotNull( props ); + + // Decode node 0: "cell_A" + final Object[] data0 = props[ 0 ].getData(); + final byte[] decoded0 = new byte[ data0.length ]; + for ( int i = 0; i < data0.length; i++ ) + decoded0[ i ] = ( ( Number ) data0[ i ] ).byteValue(); + assertEquals( label0, new String( decoded0, java.nio.charset.StandardCharsets.UTF_8 ) ); + + // Decode node 1: "細胞_B" (multibyte) + final Object[] data1 = props[ 1 ].getData(); + final byte[] decoded1 = new byte[ data1.length ]; + for ( int i = 0; i < data1.length; i++ ) + decoded1[ i ] = ( ( Number ) data1[ i ] ).byteValue(); + assertEquals( label1, new String( decoded1, java.nio.charset.StandardCharsets.UTF_8 ) ); + } + } + @Test public void testWriteIntArray() throws Exception { From 9bf7222ce83549875e5dfeda4f38749a9bf54e29 Mon Sep 17 00:00:00 2001 From: Jean-Yves TINEVEZ Date: Tue, 21 Jul 2026 10:26:49 +0200 Subject: [PATCH 05/13] Store and serialize display_hints in the metadata. --- .../java/org/mastodon/geff/GeffMetadata.java | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/src/main/java/org/mastodon/geff/GeffMetadata.java b/src/main/java/org/mastodon/geff/GeffMetadata.java index 706ac44..698fb5d 100644 --- a/src/main/java/org/mastodon/geff/GeffMetadata.java +++ b/src/main/java/org/mastodon/geff/GeffMetadata.java @@ -32,6 +32,7 @@ import java.io.IOException; import java.util.Arrays; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; @@ -56,6 +57,7 @@ */ public class GeffMetadata { + private static final Logger LOG = LoggerFactory.getLogger( GeffMetadata.class ); // Supported GEFF versions @@ -94,6 +96,8 @@ public class GeffMetadata */ private Map< String, Object > extra; + private DisplayHints displayHints; + /** * Default constructor */ @@ -218,6 +222,16 @@ public void setExtra( final Map< String, Object > extra ) this.extra = extra; } + public DisplayHints getDisplayHints() + { + return displayHints; + } + + public void setDisplayHints( final DisplayHints displayHints ) + { + this.displayHints = displayHints; + } + /** * 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. @@ -342,6 +356,17 @@ public static GeffMetadata readFromN5( final N5Reader reader, final String group } LOG.debug( "found geff/track_node_props = {}", trackNodeProps ); + // DisplayHints + DisplayHints displayHints = null; + try + { + displayHints = reader.getAttribute( group, "geff/display_hints", DisplayHints.class ); + } + catch ( final Exception e ) + { + LOG.debug( "Could not parse geff/display_hints as DisplayHints, setting to null: {}", e.getMessage() ); + } + // Extra may be null, so safe-read it Map< String, Object > extra = null; try @@ -362,6 +387,7 @@ public static GeffMetadata readFromN5( final N5Reader reader, final String group metadata.setNodePropsMetadata( nodePropsMetadata ); metadata.setEdgePropsMetadata( edgePropsMetadata ); metadata.setTrackNodeProps( trackNodeProps ); + metadata.setDisplayHints( displayHints ); metadata.setExtra( extra ); metadata.validate(); @@ -414,6 +440,12 @@ public void writeToN5( final N5Writer writer, final String group ) writer.setAttribute( group, "geff/track_node_props", trackNodeProps ); } + if ( displayHints != null ) + { + LOG.debug( "writing geff/display_hints {}", displayHints ); + writer.setAttribute( group, "geff/display_hints", displayHints ); + } + if ( extra != null ) { LOG.debug( "writing geff/extra {}", extra ); @@ -445,4 +477,50 @@ public int hashCode() { return Objects.hash( geffVersion, directed, Arrays.hashCode( geffAxes ), nodePropsMetadata, edgePropsMetadata, trackNodeProps ); } + + /** + * Display hints for GEFF + * + * @see GEFF + * Specification: DisplayHint + * @author Jean-Yves Tinevez + */ + public static class DisplayHints + { + + private final Map< String, String > hints = new HashMap<>(); + + + /** + * Which spatial axis to use for horizontal display. + * + * @param propName + * the name of the property to use for horizontal display. + * @return + */ + public DisplayHints displayHorizontal( final String propName ) + { + hints.put( "display_horizontal", propName ); + return this; + } + + public DisplayHints displayVertical( final String propName ) + { + hints.put( "display_vertical", propName ); + return this; + } + + public DisplayHints displayDepth( final String propName ) + { + hints.put( "display_depth", propName ); + return this; + } + + public DisplayHints displayTime( final String propName ) + { + hints.put( "display_time", propName ); + return this; + } + } } From 12bb45a1c59036f3c1c67b80d553eda5ef83f203 Mon Sep 17 00:00:00 2001 From: Jean-Yves TINEVEZ Date: Tue, 21 Jul 2026 10:55:44 +0200 Subject: [PATCH 06/13] Related objects in metadata. Careful: double check deserialization. --- .../java/org/mastodon/geff/GeffMetadata.java | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/src/main/java/org/mastodon/geff/GeffMetadata.java b/src/main/java/org/mastodon/geff/GeffMetadata.java index 698fb5d..acef4dc 100644 --- a/src/main/java/org/mastodon/geff/GeffMetadata.java +++ b/src/main/java/org/mastodon/geff/GeffMetadata.java @@ -31,6 +31,7 @@ import static org.mastodon.geff.GeffUtils.checkSupportedVersion; import java.io.IOException; +import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; import java.util.List; @@ -98,6 +99,8 @@ public class GeffMetadata private DisplayHints displayHints; + private RelatedObjects relatedObjects; + /** * Default constructor */ @@ -232,6 +235,16 @@ public void setDisplayHints( final DisplayHints displayHints ) this.displayHints = displayHints; } + public void setRelatedObjects( final RelatedObjects relatedObjects ) + { + this.relatedObjects = relatedObjects; + } + + public RelatedObjects getRelatedObjects() + { + return relatedObjects; + } + /** * 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. @@ -367,6 +380,17 @@ public static GeffMetadata readFromN5( final N5Reader reader, final String group LOG.debug( "Could not parse geff/display_hints as DisplayHints, setting to null: {}", e.getMessage() ); } + // RelatedObjects + RelatedObjects relatedObjects = null; + try + { + relatedObjects = reader.getAttribute( group, "geff/related_objects", RelatedObjects.class ); + } + catch ( final Exception e ) + { + LOG.debug( "Could not parse geff/related_objects as RelatedObjects, setting to null: {}", e.getMessage() ); + } + // Extra may be null, so safe-read it Map< String, Object > extra = null; try @@ -388,6 +412,7 @@ public static GeffMetadata readFromN5( final N5Reader reader, final String group metadata.setEdgePropsMetadata( edgePropsMetadata ); metadata.setTrackNodeProps( trackNodeProps ); metadata.setDisplayHints( displayHints ); + metadata.setRelatedObjects( relatedObjects ); metadata.setExtra( extra ); metadata.validate(); @@ -446,6 +471,12 @@ public void writeToN5( final N5Writer writer, final String group ) writer.setAttribute( group, "geff/display_hints", displayHints ); } + if ( relatedObjects != null ) + { + LOG.debug( "writing geff/related_objects {}", relatedObjects.relatedObjects ); + writer.setAttribute( group, "geff/related_objects", relatedObjects.relatedObjects ); + } + if ( extra != null ) { LOG.debug( "writing geff/extra {}", extra ); @@ -523,4 +554,57 @@ public DisplayHints displayTime( final String propName ) return this; } } + + /** + * A set of metadata for data that is associated with the graph. The types + * 'labels' and 'image' should be used for label and image objects, + * respectively. Other types are also allowed. + * + * @see GEFF + * Specification: RelatedObject + * @author Jean-Yves Tinevez + */ + public static class RelatedObjects + { + + private final List< Map< String, String > > relatedObjects = new ArrayList<>(); + + /** + * Add a related object of type 'labels' with the specified path and + * label property. + * + * @param path + * Path of the labels within the zarr group, relative to the + * geff zarr-attributes file. It is strongly recommended all + * related objects are stored as siblings of the geff group + * within the top-level zarr group. + * @param labelProp + * Property name for label objects. This is the node property + * that will be used to identify the labels in the related + * object. + * @return this RelatedObject instance for method chaining. + */ + public RelatedObjects labels( final String path, final String labelProp ) + { + relatedObjects.add( Map.of( "type", "labels", "path", path, "label_prop", labelProp ) ); + return this; + } + + /** + * Add a related object of type 'image' with the specified path. + * + * @param path + * Path of the image within the zarr group, relative to the + * geff zarr-attributes file. It is strongly recommended all + * related objects are stored as siblings of the geff group + * within the top-level zarr group. + * @return this RelatedObject instance for method chaining. + */ + public RelatedObjects image( final String path ) + { + relatedObjects.add( Map.of( "type", "image", "path", path ) ); + return this; + } + } } From 83897504b7010a9e0c35776b8d0f21db72ad87a5 Mon Sep 17 00:00:00 2001 From: Jean-Yves TINEVEZ Date: Tue, 21 Jul 2026 15:17:11 +0200 Subject: [PATCH 07/13] Properly deserialize the RelatedObjects field. Still hacky, but temporary. We miss the support of querying labels paths anyway. --- src/main/java/org/mastodon/geff/GeffMetadata.java | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/mastodon/geff/GeffMetadata.java b/src/main/java/org/mastodon/geff/GeffMetadata.java index acef4dc..a294981 100644 --- a/src/main/java/org/mastodon/geff/GeffMetadata.java +++ b/src/main/java/org/mastodon/geff/GeffMetadata.java @@ -38,6 +38,7 @@ import java.util.Map; import java.util.Objects; import java.util.regex.Pattern; +import java.util.stream.Collectors; import org.janelia.saalfeldlab.n5.N5Reader; import org.janelia.saalfeldlab.n5.N5Writer; @@ -384,7 +385,9 @@ public static GeffMetadata readFromN5( final N5Reader reader, final String group RelatedObjects relatedObjects = null; try { - relatedObjects = reader.getAttribute( group, "geff/related_objects", RelatedObjects.class ); + final List< Map< String, String > > roMap = reader.getAttribute( group, "geff/related_objects", List.class ); + relatedObjects = new RelatedObjects(); + relatedObjects.relatedObjects.addAll( roMap ); } catch ( final Exception e ) { @@ -606,5 +609,13 @@ public RelatedObjects image( final String path ) relatedObjects.add( Map.of( "type", "image", "path", path ) ); return this; } + + public List< String > getImagePaths() + { + return relatedObjects.stream() + .filter( obj -> "image".equals( obj.get( "type" ) ) ) + .map( obj -> obj.get( "path" ) ) + .collect( Collectors.toList() ); + } } } From 06bf97f55d4ecd0e33a510be1b6b8e28d353c50b Mon Sep 17 00:00:00 2001 From: Jean-Yves TINEVEZ Date: Tue, 21 Jul 2026 17:14:04 +0200 Subject: [PATCH 08/13] Save DisplayHints at the right level. --- src/main/java/org/mastodon/geff/GeffMetadata.java | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/mastodon/geff/GeffMetadata.java b/src/main/java/org/mastodon/geff/GeffMetadata.java index a294981..4286f2e 100644 --- a/src/main/java/org/mastodon/geff/GeffMetadata.java +++ b/src/main/java/org/mastodon/geff/GeffMetadata.java @@ -374,7 +374,12 @@ public static GeffMetadata readFromN5( final N5Reader reader, final String group DisplayHints displayHints = null; try { - displayHints = reader.getAttribute( group, "geff/display_hints", DisplayHints.class ); + final Map< String, String > dhMap = reader.getAttribute( group, "geff/display_hints", Map.class ); + if ( dhMap != null ) + { + displayHints = new DisplayHints(); + displayHints.hints.putAll( dhMap ); + } } catch ( final Exception e ) { @@ -471,7 +476,7 @@ public void writeToN5( final N5Writer writer, final String group ) if ( displayHints != null ) { LOG.debug( "writing geff/display_hints {}", displayHints ); - writer.setAttribute( group, "geff/display_hints", displayHints ); + writer.setAttribute( group, "geff/display_hints", displayHints.hints ); } if ( relatedObjects != null ) From c41b88dd7784642273b82729ed0d3ce0cc51f20a Mon Sep 17 00:00:00 2001 From: Ko Sugawara Date: Tue, 8 Sep 2026 00:16:00 +0900 Subject: [PATCH 09/13] Resolve node axis names from display_hints Resolve node axis names from display_hints, falling back to axes order Add GeffDisplayHint to model the optional geff/display_hints attribute (display_horizontal/vertical/depth/time), and have GeffMetadata resolve each t/x/y/z axis name by combining display_hints with the declared axes in the following order: 1. the matching display hint, which names the axis explicitly; 2. a spatial axis named `x`, `y` or `z` (case-insensitive), or one whose name ends in `_x`, `_y`, `_z` (e.g. `cell_x`); 3. the position in the `axes` list, which the spec defines as image dimension order, i.e. slowest to fastest (`z`, `y`, `x`) and therefore counted from the end; 4. the standard name (`x`, `y`, `z`) for datasets that declare no spatial axes at all. GeffNode now reads/writes coordinates using these resolved names, and correctly omits the Z property for datasets with no third spatial axis instead of guessing "z". Did this in part with Claude --- README.md | 26 +- .../org/mastodon/geff/GeffDisplayHint.java | 189 +++++++++++ .../java/org/mastodon/geff/GeffMetadata.java | 221 +++++++++++-- src/main/java/org/mastodon/geff/GeffNode.java | 42 +-- .../mastodon/geff/GeffDisplayHintTest.java | 303 ++++++++++++++++++ 5 files changed, 736 insertions(+), 45 deletions(-) create mode 100644 src/main/java/org/mastodon/geff/GeffDisplayHint.java create mode 100644 src/test/java/org/mastodon/geff/GeffDisplayHintTest.java diff --git a/README.md b/README.md index 696ce6d..8c051d4 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,8 @@ 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 +- **Custom axis names** - Node property paths for coordinates (t/x/y/z) are derived from `display_hints` combined with the axis names declared in `axes`, so non-standard names such as `frame`, `cell_x`, `cell_y` work out of the box; standard names are used as fallbacks when neither is declared +- **Display hints** - Full `display_hints` support (`display_horizontal`, `display_vertical`, `display_depth`, `display_time`), validated against the declared axis names - **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`) @@ -33,7 +34,7 @@ Represents nodes in tracking graphs with spatial and temporal attributes: - 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 +- **Axis-aware I/O**: property paths for time and spatial coordinates are resolved from `GeffMetadata` by combining `display_hints` and `axes`; falls back to `t`, `x`, `y`, `z` when neither is defined - Builder pattern for convenient object construction - Chunked Zarr Format 2 I/O @@ -58,8 +59,21 @@ 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) +- `display_hints` as a `GeffDisplayHint` object, validated against the declared axis names - `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) +- `getAxisNames()` – returns all axis names, in declaration order +- `getTimeAxisName()` / `getHorizontalAxisName()` / `getVerticalAxisName()` / `getDepthAxisName()` – resolve which axis carries the t/x/y/z coordinate (see below) + +### GeffDisplayHint +Represents the optional `display_hints` metadata, i.e. which axis a viewer shows horizontally, vertically, as depth and as time. Since it is optional, `GeffMetadata` resolves each spatial coordinate from the following sources, in order: + +1. the matching display hint, which names the axis explicitly; +2. a spatial axis named `x`, `y` or `z` (case-insensitive), or one whose name ends in `_x`, `_y`, `_z` (e.g. `cell_x`); +3. the position in the `axes` list, which the spec defines as image dimension order, i.e. slowest to fastest (`z`, `y`, `x`) and therefore counted from the end; +4. the standard name (`x`, `y`, `z`) for datasets that declare no spatial axes at all. + +Depth resolves to `null` for datasets that declare spatial axes but no third one, in which case no z property is read or written. ### PropMetadata Describes a single node or edge property as required by the v1 spec: @@ -236,16 +250,16 @@ dataset.zarr/ │ # version, directed, axes, │ # node_props_metadata, │ # edge_props_metadata, -│ # track_node_props +│ # track_node_props, display_hints └── tracks/ ├── .zgroup ├── nodes/ │ ├── ids/ # Node IDs [N] │ ├── props/ │ │ ├── /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) + │ │ ├── /values # X coordinates [N] (name from display_hints/axes, default "x") + │ │ ├── /values # Y coordinates [N] (name from display_hints/axes, default "y") + │ │ ├── /values # Z coordinates [N] (name from display_hints/axes, 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/src/main/java/org/mastodon/geff/GeffDisplayHint.java b/src/main/java/org/mastodon/geff/GeffDisplayHint.java new file mode 100644 index 0000000..af2c558 --- /dev/null +++ b/src/main/java/org/mastodon/geff/GeffDisplayHint.java @@ -0,0 +1,189 @@ +/*- + * #%L + * geff-java + * %% + * Copyright (C) 2025 - 2026 Ko Sugawara + * %% + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + * #L% + */ +package org.mastodon.geff; + +import java.util.Objects; + +import com.google.gson.annotations.SerializedName; + +/** + * Metadata indicating how spatiotemporal axes are displayed by a viewer, + * corresponding to the {@code geff/display_hints} attribute of the GEFF + * specification. + *

+ * The hints reference axes by {@link GeffAxis#getName() name}, e.g. + *

+ * "display_hints": {
+ *     "display_horizontal": "x",
+ *     "display_vertical": "y",
+ *     "display_depth": "z",
+ *     "display_time": "t"
+ * }
+ * 
+ * {@code display_horizontal} and {@code display_vertical} are required, + * {@code display_depth} and {@code display_time} are optional. + * + * @see GEFF + * specification + */ +public class GeffDisplayHint +{ + @SerializedName( "display_horizontal" ) + private String displayHorizontal; + + @SerializedName( "display_vertical" ) + private String displayVertical; + + @SerializedName( "display_depth" ) + private String displayDepth; // Optional - can be null + + @SerializedName( "display_time" ) + private String displayTime; // Optional - can be null + + /** + * Default constructor + */ + public GeffDisplayHint() + {} + + /** + * Constructor with the required fields + */ + public GeffDisplayHint( String displayHorizontal, String displayVertical ) + { + this.displayHorizontal = displayHorizontal; + this.displayVertical = displayVertical; + } + + /** + * Constructor with all fields + */ + public GeffDisplayHint( String displayHorizontal, String displayVertical, String displayDepth, String displayTime ) + { + this.displayHorizontal = displayHorizontal; + this.displayVertical = displayVertical; + this.displayDepth = displayDepth; + this.displayTime = displayTime; + } + + // Getters and Setters + public String getDisplayHorizontal() + { + return displayHorizontal; + } + + public void setDisplayHorizontal( String displayHorizontal ) + { + this.displayHorizontal = displayHorizontal; + } + + public String getDisplayVertical() + { + return displayVertical; + } + + public void setDisplayVertical( String displayVertical ) + { + this.displayVertical = displayVertical; + } + + public String getDisplayDepth() + { + return displayDepth; + } + + public void setDisplayDepth( String displayDepth ) + { + this.displayDepth = displayDepth; + } + + public String getDisplayTime() + { + return displayTime; + } + + public void setDisplayTime( String displayTime ) + { + this.displayTime = displayTime; + } + + /** + * Validate the display hints according to GEFF rules + */ + public void validate() + { + if ( displayHorizontal == null || displayHorizontal.trim().isEmpty() ) + { throw new IllegalArgumentException( "display_horizontal cannot be null or empty" ); } + + if ( displayVertical == null || displayVertical.trim().isEmpty() ) + { throw new IllegalArgumentException( "display_vertical cannot be null or empty" ); } + } + + @Override + public String toString() + { + StringBuilder sb = new StringBuilder(); + sb.append( "GeffDisplayHint{" ); + sb.append( "displayHorizontal='" ).append( displayHorizontal ).append( '\'' ); + sb.append( ", displayVertical='" ).append( displayVertical ).append( '\'' ); + if ( displayDepth != null ) + { + sb.append( ", displayDepth='" ).append( displayDepth ).append( '\'' ); + } + if ( displayTime != null ) + { + sb.append( ", displayTime='" ).append( displayTime ).append( '\'' ); + } + sb.append( '}' ); + return sb.toString(); + } + + @Override + public boolean equals( Object obj ) + { + if ( this == obj ) + return true; + if ( obj == null || getClass() != obj.getClass() ) + return false; + + GeffDisplayHint that = ( GeffDisplayHint ) obj; + + return Objects.equals( displayHorizontal, that.displayHorizontal ) && + Objects.equals( displayVertical, that.displayVertical ) && + Objects.equals( displayDepth, that.displayDepth ) && + Objects.equals( displayTime, that.displayTime ); + } + + @Override + public int hashCode() + { + return Objects.hash( displayHorizontal, displayVertical, displayDepth, displayTime ); + } +} diff --git a/src/main/java/org/mastodon/geff/GeffMetadata.java b/src/main/java/org/mastodon/geff/GeffMetadata.java index 9c773de..c504a12 100644 --- a/src/main/java/org/mastodon/geff/GeffMetadata.java +++ b/src/main/java/org/mastodon/geff/GeffMetadata.java @@ -31,6 +31,7 @@ import static org.mastodon.geff.GeffUtils.checkSupportedVersion; import java.io.IOException; +import java.lang.reflect.Type; import java.util.Arrays; import java.util.List; import java.util.Map; @@ -80,6 +81,8 @@ public class GeffMetadata private Map< String, String > trackNodeProps; + private GeffDisplayHint displayHints; + /** * Default constructor */ @@ -194,6 +197,131 @@ public void setTrackNodeProps( Map< String, String > trackNodeProps ) this.trackNodeProps = trackNodeProps; } + public GeffDisplayHint getDisplayHints() + { + return displayHints; + } + + public void setDisplayHints( GeffDisplayHint displayHints ) + { + this.displayHints = displayHints; + validate(); + } + + /** + * Get the name of the axis holding the time coordinate. + *

+ * The {@code display_time} display hint takes precedence; otherwise the + * first axis of type {@link GeffAxis#TYPE_TIME} is used, falling back to + * the standard name {@code "t"} if no axes are declared. + * + * @return the time axis name, never null + */ + public String getTimeAxisName() + { + final String hint = displayHints != null ? displayHints.getDisplayTime() : null; + if ( hint != null && !hint.trim().isEmpty() ) + return hint; + + final String name = getAxisNameByType( GeffAxis.TYPE_TIME ); + return name != null ? name : GeffAxis.NAME_TIME; + } + + /** + * Get the name of the spatial axis holding the X coordinate, i.e. the one + * a viewer displays horizontally. + * + * @return the axis name, never null + * @see #resolveSpaceAxisName(String, String, int) + */ + public String getHorizontalAxisName() + { + final String hint = displayHints != null ? displayHints.getDisplayHorizontal() : null; + return resolveSpaceAxisName( hint, GeffAxis.NAME_SPACE_X, 0 ); + } + + /** + * Get the name of the spatial axis holding the Y coordinate, i.e. the one + * a viewer displays vertically. + * + * @return the axis name, never null + * @see #resolveSpaceAxisName(String, String, int) + */ + public String getVerticalAxisName() + { + final String hint = displayHints != null ? displayHints.getDisplayVertical() : null; + return resolveSpaceAxisName( hint, GeffAxis.NAME_SPACE_Y, 1 ); + } + + /** + * Get the name of the spatial axis holding the Z coordinate, i.e. the one + * a viewer displays as depth. + * + * @return the axis name, or null for datasets without a third spatial axis + * @see #resolveSpaceAxisName(String, String, int) + */ + public String getDepthAxisName() + { + final String hint = displayHints != null ? displayHints.getDisplayDepth() : null; + return resolveSpaceAxisName( hint, GeffAxis.NAME_SPACE_Z, 2 ); + } + + /** + * Resolve which spatial axis carries a given display dimension by + * combining {@code display_hints} and {@code axes}. Since + * {@code display_hints} is optional, the following sources are consulted + * in order: + *

    + *
  1. the display hint, which names the axis explicitly,
  2. + *
  3. a spatial axis called {@code standardName} (case-insensitive), or one + * whose name ends in {@code "_" + standardName}, e.g. {@code "cell_x"},
  4. + *
  5. the position in the axes list, which the spec defines as image + * dimension order, i.e. slowest to fastest ({@code z}, {@code y}, + * {@code x}), hence counted from the end,
  6. + *
  7. {@code standardName} itself, for datasets that declare no spatial + * axes at all.
  8. + *
+ * + * @param hint + * the corresponding display hint, or null if not given + * @param standardName + * the conventional name of this axis ({@code x}, {@code y} or + * {@code z}) + * @param indexFromLast + * position of this axis counted backwards from the end of the + * spatial axes list (0 for x, 1 for y, 2 for z) + * @return the axis name, or null if the dataset declares spatial axes but + * not the requested one + */ + private String resolveSpaceAxisName( final String hint, final String standardName, final int indexFromLast ) + { + if ( hint != null && !hint.trim().isEmpty() ) + return hint; + + final String[] spaceAxes = getAxisNamesByType( GeffAxis.TYPE_SPACE ); + + for ( final String name : spaceAxes ) + if ( standardName.equalsIgnoreCase( name ) ) + return name; + + for ( final String name : spaceAxes ) + if ( name != null && name.toLowerCase().endsWith( "_" + standardName ) ) + return name; + + final int index = spaceAxes.length - 1 - indexFromLast; + if ( index >= 0 ) + { + LOG.debug( "no display hint for {}, guessing axis '{}' from the axes order {}", + standardName, spaceAxes[ index ], Arrays.toString( spaceAxes ) ); + return spaceAxes[ index ]; + } + + // A dataset that declares no axes at all is assumed to use the + // standard t/x/y/z layout. Otherwise the requested axis is beyond the + // declared ones, i.e. the dataset has no depth axis. + return ( spaceAxes.length == 0 || indexFromLast < 2 ) ? standardName : null; + } + /** * 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. @@ -234,6 +362,21 @@ public String[] getAxisNamesByType( String type ) .toArray( String[]::new ); } + /** + * Get the names of all axes, in declaration order. + * + * @return array of axis names (empty array if no axes are declared) + */ + public String[] getAxisNames() + { + if ( geffAxes == null ) + return new String[ 0 ]; + + return Arrays.stream( geffAxes ) + .map( GeffAxis::getName ) + .toArray( String[]::new ); + } + /** * Validates the metadata according to the GEFF schema rules */ @@ -257,6 +400,28 @@ public void validate() "max " + axis.getMax() + " in dimension " + axis.getName() ); } } } + + if ( displayHints != null ) + { + displayHints.validate(); + + // Display hints must reference axes that exist + if ( geffAxes != null ) + { + final List< String > axisNames = Arrays.asList( getAxisNames() ); + checkDisplayHint( "display_horizontal", displayHints.getDisplayHorizontal(), axisNames ); + checkDisplayHint( "display_vertical", displayHints.getDisplayVertical(), axisNames ); + checkDisplayHint( "display_depth", displayHints.getDisplayDepth(), axisNames ); + checkDisplayHint( "display_time", displayHints.getDisplayTime(), axisNames ); + } + } + } + + private static void checkDisplayHint( final String hintName, final String axisName, final List< String > axisNames ) + { + if ( axisName != null && !axisNames.contains( axisName ) ) + { throw new IllegalArgumentException( + "Display hint " + hintName + " name " + axisName + " not found in axes " + axisNames ); } } /** @@ -302,31 +467,44 @@ public static GeffMetadata readFromN5( final N5Reader reader, final String group LOG.debug( "found geff/edge_props_metadata = {}", edgePropsMetadata ); // trackNodeProps may be null, so safe-read it - Map< String, String > trackNodeProps = null; - try - { - trackNodeProps = reader.getAttribute( group, "geff/track_node_props", - new TypeToken< Map< String, String > >() - {}.getType() ); - } - catch ( final Exception e ) - { - // If the attribute cannot be parsed as Map (e.g., - // if it's null in JSON), - // just leave it as null - LOG.debug( "Could not parse geff/track_node_props as Map, setting to null: {}", e.getMessage() ); - } + final Map< String, String > trackNodeProps = readOptionalAttribute( reader, group, "geff/track_node_props", + new TypeToken< Map< String, String > >() + {}.getType() ); LOG.debug( "found geff/track_node_props = {}", trackNodeProps ); + // displayHints may be null, so safe-read it + final GeffDisplayHint displayHints = readOptionalAttribute( reader, group, "geff/display_hints", GeffDisplayHint.class ); + LOG.debug( "found geff/display_hints = {}", displayHints ); + final GeffMetadata metadata = new GeffMetadata( geffVersion, directed, axes ); metadata.setNodePropsMetadata( nodePropsMetadata ); metadata.setEdgePropsMetadata( edgePropsMetadata ); metadata.setTrackNodeProps( trackNodeProps ); + metadata.setDisplayHints( displayHints ); metadata.validate(); return metadata; } + /** + * Read an optional attribute. An attribute that is explicitly written as + * JSON {@code null}, as the Python implementation does for the optional + * fields it leaves unset, cannot be parsed into the target type; report it + * as absent instead of failing. + */ + private static < T > T readOptionalAttribute( final N5Reader reader, final String group, final String key, final Type type ) + { + try + { + return reader.getAttribute( group, key, type ); + } + catch ( final Exception e ) + { + LOG.debug( "Could not parse {} as {}, setting to null: {}", key, type.getTypeName(), e.getMessage() ); + return null; + } + } + /** * Write metadata to Zarr format at specified path */ @@ -372,14 +550,20 @@ public void writeToN5( final N5Writer writer, final String group ) LOG.debug( "writing geff/track_node_props {}", trackNodeProps ); writer.setAttribute( group, "geff/track_node_props", trackNodeProps ); } + + if ( displayHints != null ) + { + LOG.debug( "writing geff/display_hints {}", displayHints ); + writer.setAttribute( group, "geff/display_hints", displayHints ); + } } @Override public String toString() { return String.format( - "GeffMetadata{geffVersion='%s', directed=%s, geffAxes=%s, nodePropsMetadata=%s, edgePropsMetadata=%s, trackNodeProps=%s}", - geffVersion, directed, Arrays.toString( geffAxes ), nodePropsMetadata, edgePropsMetadata, trackNodeProps ); + "GeffMetadata{geffVersion='%s', directed=%s, geffAxes=%s, nodePropsMetadata=%s, edgePropsMetadata=%s, trackNodeProps=%s, displayHints=%s}", + geffVersion, directed, Arrays.toString( geffAxes ), nodePropsMetadata, edgePropsMetadata, trackNodeProps, displayHints ); } @Override @@ -390,12 +574,13 @@ public boolean equals( final Object o ) GeffMetadata that = ( GeffMetadata ) o; return directed == that.directed && Objects.equals( geffVersion, that.geffVersion ) && Objects.deepEquals( geffAxes, that.geffAxes ) && Objects.equals( nodePropsMetadata, that.nodePropsMetadata ) && Objects.equals( edgePropsMetadata, that.edgePropsMetadata ) - && Objects.equals( trackNodeProps, that.trackNodeProps ); + && Objects.equals( trackNodeProps, that.trackNodeProps ) && Objects.equals( displayHints, that.displayHints ); } @Override public int hashCode() { - return Objects.hash( geffVersion, directed, Arrays.hashCode( geffAxes ), nodePropsMetadata, edgePropsMetadata, trackNodeProps ); + return Objects.hash( geffVersion, directed, Arrays.hashCode( geffAxes ), nodePropsMetadata, edgePropsMetadata, trackNodeProps, + displayHints ); } } diff --git a/src/main/java/org/mastodon/geff/GeffNode.java b/src/main/java/org/mastodon/geff/GeffNode.java index 1f5855b..502ba2b 100644 --- a/src/main/java/org/mastodon/geff/GeffNode.java +++ b/src/main/java/org/mastodon/geff/GeffNode.java @@ -729,13 +729,13 @@ 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"; + // Determine axis names dynamically by combining display_hints and axes, + // falling back to standard names (t, x, y, z) if neither is defined. + // zAxisName is null for datasets without a third spatial axis. + final String timeAxisName = metadata.getTimeAxisName(); + final String xAxisName = metadata.getHorizontalAxisName(); + final String yAxisName = metadata.getVerticalAxisName(); + final String zAxisName = metadata.getDepthAxisName(); // Read node IDs from chunks final int[] nodeIds = GeffUtils.readAsIntArray( reader, path + "/nodes/ids", "node IDs" ); @@ -744,7 +744,7 @@ public static List< GeffNode > readFromN5( final N5Reader reader, final String g final int numNodes = nodeIds.length; // Read time points from chunks using dynamic axis name - final String timePropPath = path + "/nodes/props/" + ( timeAxisName != null ? timeAxisName : "t" ) + "/values"; + final String timePropPath = path + "/nodes/props/" + timeAxisName + "/values"; final int[] timepoints = GeffUtils.readAsIntArray( reader, timePropPath, "timepoints" ); verifyLength( timepoints, numNodes, timePropPath ); @@ -760,8 +760,8 @@ public static List< GeffNode > readFromN5( final N5Reader reader, final String g // 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 ) ) + final String zPropPath = zAxisName != null ? path + "/nodes/props/" + zAxisName + "/values" : null; + if ( zPropPath != null && reader.datasetExists( zPropPath ) ) { zCoords = GeffUtils.readAsDoubleArray( reader, zPropPath, "Z coordinates" ); verifyLength( zCoords, numNodes, zPropPath ); @@ -1068,13 +1068,13 @@ 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"; + // Determine axis names dynamically by combining display_hints and axes, + // falling back to standard names (t, x, y, z) if neither is defined. + // zAxisName is null for datasets without a third spatial axis. + final String timePropName = metadata.getTimeAxisName(); + final String xAxisName = metadata.getHorizontalAxisName(); + final String yAxisName = metadata.getVerticalAxisName(); + final String zAxisName = metadata.getDepthAxisName(); final Map< String, PropMetadata > metadataNodeProps = metadata.getNodePropsMetadata(); final boolean writeAllProps = metadataNodeProps == null; @@ -1083,7 +1083,6 @@ public static void writeToN5( GeffUtils.writeIntArray( nodes, GeffNode::getId, writer, path + "/nodes/ids", chunkSize ); // 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( timePropName ) : null; @@ -1103,8 +1102,9 @@ public static void writeToN5( if ( writeAllProps || metadataNodeProps.containsKey( yAxisName ) ) GeffUtils.writeDoubleArray( nodes, GeffNode::getY, writer, path + "/nodes/props/" + yAxisName + "/values", chunkSize ); - // Write Z coordinates in chunks using dynamic axis name - if ( writeAllProps || metadataNodeProps.containsKey( zAxisName ) ) + // Write Z coordinates in chunks using dynamic axis name (skipped for + // datasets without a third spatial axis) + if ( zAxisName != null && ( writeAllProps || metadataNodeProps.containsKey( zAxisName ) ) ) GeffUtils.writeDoubleArray( nodes, GeffNode::getZ, writer, path + "/nodes/props/" + zAxisName + "/values", chunkSize ); // Write color in chunks @@ -1140,7 +1140,7 @@ public static void writeToN5( 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 ) + if ( zAxisName != null ) { nodePropsMap.put( zAxisName, new PropMetadata( zAxisName, "float64", false, null, null, null ) ); } diff --git a/src/test/java/org/mastodon/geff/GeffDisplayHintTest.java b/src/test/java/org/mastodon/geff/GeffDisplayHintTest.java new file mode 100644 index 0000000..f7bb2d0 --- /dev/null +++ b/src/test/java/org/mastodon/geff/GeffDisplayHintTest.java @@ -0,0 +1,303 @@ +/*- + * #%L + * geff-java + * %% + * Copyright (C) 2025 - 2026 Ko Sugawara + * %% + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + * #L% + */ +package org.mastodon.geff; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * Test class for resolving axis names from display_hints and axes. + */ +public class GeffDisplayHintTest +{ + + private static GeffMetadata metadata( final GeffAxis... axes ) + { + return new GeffMetadata( Geff.VERSION, true, axes ); + } + + /** + * Read the .zattrs of a group with all whitespace removed, so that + * assertions on the JSON do not depend on formatting. + */ + private static String readZattrs( final String groupPath ) throws IOException + { + return new String( Files.readAllBytes( Paths.get( groupPath, ".zattrs" ) ) ).replaceAll( "\\s", "" ); + } + + @Test + @DisplayName( "Test display hints take precedence over axis names and order" ) + void testDisplayHintsWin() + { + final GeffMetadata meta = metadata( + new GeffAxis( "frame", GeffAxis.TYPE_TIME, "frame" ), + new GeffAxis( "x", GeffAxis.TYPE_SPACE, GeffAxis.UNIT_PIXEL ), + new GeffAxis( "y", GeffAxis.TYPE_SPACE, GeffAxis.UNIT_PIXEL ), + new GeffAxis( "z", GeffAxis.TYPE_SPACE, GeffAxis.UNIT_PIXEL ) ); + // Deliberately unconventional: display x as vertical and y as horizontal + meta.setDisplayHints( new GeffDisplayHint( "y", "x", "z", "frame" ) ); + + assertEquals( "y", meta.getHorizontalAxisName() ); + assertEquals( "x", meta.getVerticalAxisName() ); + assertEquals( "z", meta.getDepthAxisName() ); + assertEquals( "frame", meta.getTimeAxisName() ); + } + + @Test + @DisplayName( "Test axes in image dimension order without display hints" ) + void testImageOrderAxesWithoutHints() + { + // The order used by the spec example and by the Python implementation + final GeffMetadata meta = metadata( + new GeffAxis( "t", GeffAxis.TYPE_TIME, GeffAxis.UNIT_SECOND ), + new GeffAxis( "z", GeffAxis.TYPE_SPACE, GeffAxis.UNIT_MICROMETER ), + new GeffAxis( "y", GeffAxis.TYPE_SPACE, GeffAxis.UNIT_MICROMETER ), + new GeffAxis( "x", GeffAxis.TYPE_SPACE, GeffAxis.UNIT_MICROMETER ) ); + + assertEquals( "x", meta.getHorizontalAxisName() ); + assertEquals( "y", meta.getVerticalAxisName() ); + assertEquals( "z", meta.getDepthAxisName() ); + assertEquals( "t", meta.getTimeAxisName() ); + } + + @Test + @DisplayName( "Test axes in x, y, z order without display hints" ) + void testXyzOrderAxesWithoutHints() + { + final GeffMetadata meta = metadata( + new GeffAxis( "t", GeffAxis.TYPE_TIME, GeffAxis.UNIT_SECOND ), + new GeffAxis( "x", GeffAxis.TYPE_SPACE, GeffAxis.UNIT_MICROMETER ), + new GeffAxis( "y", GeffAxis.TYPE_SPACE, GeffAxis.UNIT_MICROMETER ), + new GeffAxis( "z", GeffAxis.TYPE_SPACE, GeffAxis.UNIT_MICROMETER ) ); + + // Names are matched before positions, so the order does not matter here + assertEquals( "x", meta.getHorizontalAxisName() ); + assertEquals( "y", meta.getVerticalAxisName() ); + assertEquals( "z", meta.getDepthAxisName() ); + } + + @Test + @DisplayName( "Test suffixed axis names without display hints" ) + void testSuffixedAxisNamesWithoutHints() + { + final GeffMetadata meta = metadata( + new GeffAxis( "frame", GeffAxis.TYPE_TIME, "frame" ), + new GeffAxis( "cell_x", GeffAxis.TYPE_SPACE, GeffAxis.UNIT_PIXEL ), + new GeffAxis( "cell_y", GeffAxis.TYPE_SPACE, GeffAxis.UNIT_PIXEL ) ); + + assertEquals( "cell_x", meta.getHorizontalAxisName() ); + assertEquals( "cell_y", meta.getVerticalAxisName() ); + assertNull( meta.getDepthAxisName(), "2D dataset must not have a depth axis" ); + assertEquals( "frame", meta.getTimeAxisName() ); + } + + @Test + @DisplayName( "Test unrecognizable axis names fall back to the axes order" ) + void testUnrecognizableAxisNamesWithoutHints() + { + // Nothing but the position is left to go by, so image dimension order + // (slowest to fastest) is assumed + final GeffMetadata meta = metadata( + new GeffAxis( "depth", GeffAxis.TYPE_SPACE, GeffAxis.UNIT_MICROMETER ), + new GeffAxis( "row", GeffAxis.TYPE_SPACE, GeffAxis.UNIT_MICROMETER ), + new GeffAxis( "column", GeffAxis.TYPE_SPACE, GeffAxis.UNIT_MICROMETER ) ); + + assertEquals( "column", meta.getHorizontalAxisName() ); + assertEquals( "row", meta.getVerticalAxisName() ); + assertEquals( "depth", meta.getDepthAxisName() ); + assertEquals( "t", meta.getTimeAxisName(), "no time axis declared, standard name expected" ); + } + + @Test + @DisplayName( "Test standard names are used when no axes are declared" ) + void testNoAxes() + { + final GeffMetadata meta = new GeffMetadata( Geff.VERSION, true ); + + assertEquals( "t", meta.getTimeAxisName() ); + assertEquals( "x", meta.getHorizontalAxisName() ); + assertEquals( "y", meta.getVerticalAxisName() ); + assertEquals( "z", meta.getDepthAxisName() ); + } + + @Test + @DisplayName( "Test channel axes are ignored when resolving spatial axes" ) + void testChannelAxisIgnored() + { + final GeffMetadata meta = metadata( + new GeffAxis( "t", GeffAxis.TYPE_TIME, GeffAxis.UNIT_SECOND ), + new GeffAxis( "c", GeffAxis.TYPE_CHANNEL, "channel" ), + new GeffAxis( "row", GeffAxis.TYPE_SPACE, GeffAxis.UNIT_MICROMETER ), + new GeffAxis( "column", GeffAxis.TYPE_SPACE, GeffAxis.UNIT_MICROMETER ) ); + + assertEquals( "column", meta.getHorizontalAxisName() ); + assertEquals( "row", meta.getVerticalAxisName() ); + assertNull( meta.getDepthAxisName() ); + } + + @Test + @DisplayName( "Test display hints must reference declared axes" ) + void testDisplayHintsValidation() + { + final GeffMetadata meta = metadata( + new GeffAxis( "t", GeffAxis.TYPE_TIME, GeffAxis.UNIT_SECOND ), + new GeffAxis( "y", GeffAxis.TYPE_SPACE, GeffAxis.UNIT_MICROMETER ), + new GeffAxis( "x", GeffAxis.TYPE_SPACE, GeffAxis.UNIT_MICROMETER ) ); + + assertThrows( IllegalArgumentException.class, + () -> meta.setDisplayHints( new GeffDisplayHint( "column", "y" ) ), + "display_horizontal naming an undeclared axis should be rejected" ); + assertThrows( IllegalArgumentException.class, + () -> meta.setDisplayHints( new GeffDisplayHint( "x", "y", "z", null ) ), + "display_depth naming an undeclared axis should be rejected" ); + assertThrows( IllegalArgumentException.class, + () -> meta.setDisplayHints( new GeffDisplayHint( "x", "y", null, "frame" ) ), + "display_time naming an undeclared axis should be rejected" ); + assertThrows( IllegalArgumentException.class, + () -> meta.setDisplayHints( new GeffDisplayHint( "x", null ) ), + "display_vertical is required" ); + } + + @Test + @DisplayName( "Test display hints round trip through Zarr" ) + void testDisplayHintsRoundTrip( @TempDir Path tempDir ) throws IOException + { + final GeffMetadata meta = metadata( + new GeffAxis( "frame", GeffAxis.TYPE_TIME, "frame" ), + new GeffAxis( "depth", GeffAxis.TYPE_SPACE, GeffAxis.UNIT_MICROMETER ), + new GeffAxis( "row", GeffAxis.TYPE_SPACE, GeffAxis.UNIT_MICROMETER ), + new GeffAxis( "column", GeffAxis.TYPE_SPACE, GeffAxis.UNIT_MICROMETER ) ); + meta.setDisplayHints( new GeffDisplayHint( "column", "row", "depth", "frame" ) ); + + final String tempPath = tempDir.toString() + "/test-display-hints.zarr/tracks"; + GeffMetadata.writeToZarr( meta, tempPath ); + + // The attribute must be written with the snake_case keys of the spec + final String zattrs = readZattrs( tempPath ); + assertTrue( zattrs.contains( "\"display_hints\":" ), zattrs ); + assertTrue( zattrs.contains( "\"display_horizontal\":\"column\"" ), zattrs ); + assertTrue( zattrs.contains( "\"display_vertical\":\"row\"" ), zattrs ); + assertTrue( zattrs.contains( "\"display_depth\":\"depth\"" ), zattrs ); + assertTrue( zattrs.contains( "\"display_time\":\"frame\"" ), zattrs ); + + final GeffMetadata read = GeffMetadata.readFromZarr( tempPath ); + assertEquals( meta.getDisplayHints(), read.getDisplayHints() ); + assertEquals( "column", read.getHorizontalAxisName() ); + assertEquals( "row", read.getVerticalAxisName() ); + assertEquals( "depth", read.getDepthAxisName() ); + assertEquals( "frame", read.getTimeAxisName() ); + } + + @Test + @DisplayName( "Test unset optional display hint fields stay null" ) + void testOptionalDisplayHintsUnset( @TempDir Path tempDir ) throws IOException + { + final GeffMetadata meta = metadata( + new GeffAxis( "y", GeffAxis.TYPE_SPACE, GeffAxis.UNIT_MICROMETER ), + new GeffAxis( "x", GeffAxis.TYPE_SPACE, GeffAxis.UNIT_MICROMETER ) ); + meta.setDisplayHints( new GeffDisplayHint( "x", "y" ) ); + + final String tempPath = tempDir.toString() + "/test-partial-hints.zarr/tracks"; + GeffMetadata.writeToZarr( meta, tempPath ); + + final String zattrs = readZattrs( tempPath ); + assertTrue( zattrs.contains( "\"display_horizontal\":\"x\"" ), zattrs ); + assertTrue( zattrs.contains( "\"display_depth\":null" ), zattrs ); + assertTrue( zattrs.contains( "\"display_time\":null" ), zattrs ); + + final GeffDisplayHint read = GeffMetadata.readFromZarr( tempPath ).getDisplayHints(); + assertNotNull( read ); + assertEquals( "x", read.getDisplayHorizontal() ); + assertEquals( "y", read.getDisplayVertical() ); + assertNull( read.getDisplayDepth() ); + assertNull( read.getDisplayTime() ); + } + + @Test + @DisplayName( "Test nodes are stored under the axes named by the display hints" ) + void testNodesUseDisplayHintedAxes( @TempDir Path tempDir ) throws IOException + { + final GeffMetadata meta = metadata( + new GeffAxis( "frame", GeffAxis.TYPE_TIME, "frame" ), + new GeffAxis( "depth", GeffAxis.TYPE_SPACE, GeffAxis.UNIT_MICROMETER ), + new GeffAxis( "row", GeffAxis.TYPE_SPACE, GeffAxis.UNIT_MICROMETER ), + new GeffAxis( "column", GeffAxis.TYPE_SPACE, GeffAxis.UNIT_MICROMETER ) ); + meta.setDisplayHints( new GeffDisplayHint( "column", "row", "depth", "frame" ) ); + + final List< GeffNode > nodes = new ArrayList<>(); + for ( int i = 0; i < 3; i++ ) + { + final GeffNode node = new GeffNode(); + node.setId( i ); + node.setT( i ); + node.setX( i * 10.0 ); + node.setY( i * 20.0 ); + node.setZ( i * 30.0 ); + nodes.add( node ); + } + + final String tempPath = tempDir.toString() + "/test-hinted-nodes.zarr/tracks"; + GeffNode.writeToZarr( nodes, tempPath, meta ); + GeffMetadata.writeToZarr( meta, tempPath ); + + // Coordinates must land in the props named by the display hints + for ( final String propName : new String[] { "frame", "depth", "row", "column" } ) + assertTrue( Files.isDirectory( Paths.get( tempPath, "nodes", "props", propName, "values" ) ), + "missing node property " + propName ); + + final GeffMetadata readMeta = GeffMetadata.readFromZarr( tempPath ); + assertEquals( meta.getDisplayHints(), readMeta.getDisplayHints() ); + + final List< GeffNode > readNodes = GeffNode.readFromZarr( tempPath, readMeta ); + 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.getZ(), actual.getZ(), 1e-9, "z mismatch at node " + i ); + } + } +} From 1e7408c2743939cd89efc56dc3401474a6690b1c Mon Sep 17 00:00:00 2001 From: Ko Sugawara Date: Tue, 8 Sep 2026 18:33:44 +0900 Subject: [PATCH 10/13] Revert previous changes --- .../org/mastodon/geff/GeffDisplayHint.java | 189 ----------- .../java/org/mastodon/geff/GeffMetadata.java | 221 ++----------- src/main/java/org/mastodon/geff/GeffNode.java | 42 +-- .../mastodon/geff/GeffDisplayHintTest.java | 303 ------------------ 4 files changed, 39 insertions(+), 716 deletions(-) delete mode 100644 src/main/java/org/mastodon/geff/GeffDisplayHint.java delete mode 100644 src/test/java/org/mastodon/geff/GeffDisplayHintTest.java diff --git a/src/main/java/org/mastodon/geff/GeffDisplayHint.java b/src/main/java/org/mastodon/geff/GeffDisplayHint.java deleted file mode 100644 index af2c558..0000000 --- a/src/main/java/org/mastodon/geff/GeffDisplayHint.java +++ /dev/null @@ -1,189 +0,0 @@ -/*- - * #%L - * geff-java - * %% - * Copyright (C) 2025 - 2026 Ko Sugawara - * %% - * Redistribution and use in source and binary forms, with or without - * modification, are permitted provided that the following conditions are met: - * - * 1. Redistributions of source code must retain the above copyright notice, - * this list of conditions and the following disclaimer. - * 2. Redistributions in binary form must reproduce the above copyright notice, - * this list of conditions and the following disclaimer in the documentation - * and/or other materials provided with the distribution. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" - * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE - * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE - * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE - * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR - * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF - * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS - * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN - * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) - * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE - * POSSIBILITY OF SUCH DAMAGE. - * #L% - */ -package org.mastodon.geff; - -import java.util.Objects; - -import com.google.gson.annotations.SerializedName; - -/** - * Metadata indicating how spatiotemporal axes are displayed by a viewer, - * corresponding to the {@code geff/display_hints} attribute of the GEFF - * specification. - *

- * The hints reference axes by {@link GeffAxis#getName() name}, e.g. - *

- * "display_hints": {
- *     "display_horizontal": "x",
- *     "display_vertical": "y",
- *     "display_depth": "z",
- *     "display_time": "t"
- * }
- * 
- * {@code display_horizontal} and {@code display_vertical} are required, - * {@code display_depth} and {@code display_time} are optional. - * - * @see GEFF - * specification - */ -public class GeffDisplayHint -{ - @SerializedName( "display_horizontal" ) - private String displayHorizontal; - - @SerializedName( "display_vertical" ) - private String displayVertical; - - @SerializedName( "display_depth" ) - private String displayDepth; // Optional - can be null - - @SerializedName( "display_time" ) - private String displayTime; // Optional - can be null - - /** - * Default constructor - */ - public GeffDisplayHint() - {} - - /** - * Constructor with the required fields - */ - public GeffDisplayHint( String displayHorizontal, String displayVertical ) - { - this.displayHorizontal = displayHorizontal; - this.displayVertical = displayVertical; - } - - /** - * Constructor with all fields - */ - public GeffDisplayHint( String displayHorizontal, String displayVertical, String displayDepth, String displayTime ) - { - this.displayHorizontal = displayHorizontal; - this.displayVertical = displayVertical; - this.displayDepth = displayDepth; - this.displayTime = displayTime; - } - - // Getters and Setters - public String getDisplayHorizontal() - { - return displayHorizontal; - } - - public void setDisplayHorizontal( String displayHorizontal ) - { - this.displayHorizontal = displayHorizontal; - } - - public String getDisplayVertical() - { - return displayVertical; - } - - public void setDisplayVertical( String displayVertical ) - { - this.displayVertical = displayVertical; - } - - public String getDisplayDepth() - { - return displayDepth; - } - - public void setDisplayDepth( String displayDepth ) - { - this.displayDepth = displayDepth; - } - - public String getDisplayTime() - { - return displayTime; - } - - public void setDisplayTime( String displayTime ) - { - this.displayTime = displayTime; - } - - /** - * Validate the display hints according to GEFF rules - */ - public void validate() - { - if ( displayHorizontal == null || displayHorizontal.trim().isEmpty() ) - { throw new IllegalArgumentException( "display_horizontal cannot be null or empty" ); } - - if ( displayVertical == null || displayVertical.trim().isEmpty() ) - { throw new IllegalArgumentException( "display_vertical cannot be null or empty" ); } - } - - @Override - public String toString() - { - StringBuilder sb = new StringBuilder(); - sb.append( "GeffDisplayHint{" ); - sb.append( "displayHorizontal='" ).append( displayHorizontal ).append( '\'' ); - sb.append( ", displayVertical='" ).append( displayVertical ).append( '\'' ); - if ( displayDepth != null ) - { - sb.append( ", displayDepth='" ).append( displayDepth ).append( '\'' ); - } - if ( displayTime != null ) - { - sb.append( ", displayTime='" ).append( displayTime ).append( '\'' ); - } - sb.append( '}' ); - return sb.toString(); - } - - @Override - public boolean equals( Object obj ) - { - if ( this == obj ) - return true; - if ( obj == null || getClass() != obj.getClass() ) - return false; - - GeffDisplayHint that = ( GeffDisplayHint ) obj; - - return Objects.equals( displayHorizontal, that.displayHorizontal ) && - Objects.equals( displayVertical, that.displayVertical ) && - Objects.equals( displayDepth, that.displayDepth ) && - Objects.equals( displayTime, that.displayTime ); - } - - @Override - public int hashCode() - { - return Objects.hash( displayHorizontal, displayVertical, displayDepth, displayTime ); - } -} diff --git a/src/main/java/org/mastodon/geff/GeffMetadata.java b/src/main/java/org/mastodon/geff/GeffMetadata.java index c504a12..9c773de 100644 --- a/src/main/java/org/mastodon/geff/GeffMetadata.java +++ b/src/main/java/org/mastodon/geff/GeffMetadata.java @@ -31,7 +31,6 @@ import static org.mastodon.geff.GeffUtils.checkSupportedVersion; import java.io.IOException; -import java.lang.reflect.Type; import java.util.Arrays; import java.util.List; import java.util.Map; @@ -81,8 +80,6 @@ public class GeffMetadata private Map< String, String > trackNodeProps; - private GeffDisplayHint displayHints; - /** * Default constructor */ @@ -197,131 +194,6 @@ public void setTrackNodeProps( Map< String, String > trackNodeProps ) this.trackNodeProps = trackNodeProps; } - public GeffDisplayHint getDisplayHints() - { - return displayHints; - } - - public void setDisplayHints( GeffDisplayHint displayHints ) - { - this.displayHints = displayHints; - validate(); - } - - /** - * Get the name of the axis holding the time coordinate. - *

- * The {@code display_time} display hint takes precedence; otherwise the - * first axis of type {@link GeffAxis#TYPE_TIME} is used, falling back to - * the standard name {@code "t"} if no axes are declared. - * - * @return the time axis name, never null - */ - public String getTimeAxisName() - { - final String hint = displayHints != null ? displayHints.getDisplayTime() : null; - if ( hint != null && !hint.trim().isEmpty() ) - return hint; - - final String name = getAxisNameByType( GeffAxis.TYPE_TIME ); - return name != null ? name : GeffAxis.NAME_TIME; - } - - /** - * Get the name of the spatial axis holding the X coordinate, i.e. the one - * a viewer displays horizontally. - * - * @return the axis name, never null - * @see #resolveSpaceAxisName(String, String, int) - */ - public String getHorizontalAxisName() - { - final String hint = displayHints != null ? displayHints.getDisplayHorizontal() : null; - return resolveSpaceAxisName( hint, GeffAxis.NAME_SPACE_X, 0 ); - } - - /** - * Get the name of the spatial axis holding the Y coordinate, i.e. the one - * a viewer displays vertically. - * - * @return the axis name, never null - * @see #resolveSpaceAxisName(String, String, int) - */ - public String getVerticalAxisName() - { - final String hint = displayHints != null ? displayHints.getDisplayVertical() : null; - return resolveSpaceAxisName( hint, GeffAxis.NAME_SPACE_Y, 1 ); - } - - /** - * Get the name of the spatial axis holding the Z coordinate, i.e. the one - * a viewer displays as depth. - * - * @return the axis name, or null for datasets without a third spatial axis - * @see #resolveSpaceAxisName(String, String, int) - */ - public String getDepthAxisName() - { - final String hint = displayHints != null ? displayHints.getDisplayDepth() : null; - return resolveSpaceAxisName( hint, GeffAxis.NAME_SPACE_Z, 2 ); - } - - /** - * Resolve which spatial axis carries a given display dimension by - * combining {@code display_hints} and {@code axes}. Since - * {@code display_hints} is optional, the following sources are consulted - * in order: - *

    - *
  1. the display hint, which names the axis explicitly,
  2. - *
  3. a spatial axis called {@code standardName} (case-insensitive), or one - * whose name ends in {@code "_" + standardName}, e.g. {@code "cell_x"},
  4. - *
  5. the position in the axes list, which the spec defines as image - * dimension order, i.e. slowest to fastest ({@code z}, {@code y}, - * {@code x}), hence counted from the end,
  6. - *
  7. {@code standardName} itself, for datasets that declare no spatial - * axes at all.
  8. - *
- * - * @param hint - * the corresponding display hint, or null if not given - * @param standardName - * the conventional name of this axis ({@code x}, {@code y} or - * {@code z}) - * @param indexFromLast - * position of this axis counted backwards from the end of the - * spatial axes list (0 for x, 1 for y, 2 for z) - * @return the axis name, or null if the dataset declares spatial axes but - * not the requested one - */ - private String resolveSpaceAxisName( final String hint, final String standardName, final int indexFromLast ) - { - if ( hint != null && !hint.trim().isEmpty() ) - return hint; - - final String[] spaceAxes = getAxisNamesByType( GeffAxis.TYPE_SPACE ); - - for ( final String name : spaceAxes ) - if ( standardName.equalsIgnoreCase( name ) ) - return name; - - for ( final String name : spaceAxes ) - if ( name != null && name.toLowerCase().endsWith( "_" + standardName ) ) - return name; - - final int index = spaceAxes.length - 1 - indexFromLast; - if ( index >= 0 ) - { - LOG.debug( "no display hint for {}, guessing axis '{}' from the axes order {}", - standardName, spaceAxes[ index ], Arrays.toString( spaceAxes ) ); - return spaceAxes[ index ]; - } - - // A dataset that declares no axes at all is assumed to use the - // standard t/x/y/z layout. Otherwise the requested axis is beyond the - // declared ones, i.e. the dataset has no depth axis. - return ( spaceAxes.length == 0 || indexFromLast < 2 ) ? standardName : null; - } - /** * 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. @@ -362,21 +234,6 @@ public String[] getAxisNamesByType( String type ) .toArray( String[]::new ); } - /** - * Get the names of all axes, in declaration order. - * - * @return array of axis names (empty array if no axes are declared) - */ - public String[] getAxisNames() - { - if ( geffAxes == null ) - return new String[ 0 ]; - - return Arrays.stream( geffAxes ) - .map( GeffAxis::getName ) - .toArray( String[]::new ); - } - /** * Validates the metadata according to the GEFF schema rules */ @@ -400,28 +257,6 @@ public void validate() "max " + axis.getMax() + " in dimension " + axis.getName() ); } } } - - if ( displayHints != null ) - { - displayHints.validate(); - - // Display hints must reference axes that exist - if ( geffAxes != null ) - { - final List< String > axisNames = Arrays.asList( getAxisNames() ); - checkDisplayHint( "display_horizontal", displayHints.getDisplayHorizontal(), axisNames ); - checkDisplayHint( "display_vertical", displayHints.getDisplayVertical(), axisNames ); - checkDisplayHint( "display_depth", displayHints.getDisplayDepth(), axisNames ); - checkDisplayHint( "display_time", displayHints.getDisplayTime(), axisNames ); - } - } - } - - private static void checkDisplayHint( final String hintName, final String axisName, final List< String > axisNames ) - { - if ( axisName != null && !axisNames.contains( axisName ) ) - { throw new IllegalArgumentException( - "Display hint " + hintName + " name " + axisName + " not found in axes " + axisNames ); } } /** @@ -467,44 +302,31 @@ public static GeffMetadata readFromN5( final N5Reader reader, final String group LOG.debug( "found geff/edge_props_metadata = {}", edgePropsMetadata ); // trackNodeProps may be null, so safe-read it - final Map< String, String > trackNodeProps = readOptionalAttribute( reader, group, "geff/track_node_props", - new TypeToken< Map< String, String > >() - {}.getType() ); + Map< String, String > trackNodeProps = null; + try + { + trackNodeProps = reader.getAttribute( group, "geff/track_node_props", + new TypeToken< Map< String, String > >() + {}.getType() ); + } + catch ( final Exception e ) + { + // If the attribute cannot be parsed as Map (e.g., + // if it's null in JSON), + // just leave it as null + LOG.debug( "Could not parse geff/track_node_props as Map, setting to null: {}", e.getMessage() ); + } LOG.debug( "found geff/track_node_props = {}", trackNodeProps ); - // displayHints may be null, so safe-read it - final GeffDisplayHint displayHints = readOptionalAttribute( reader, group, "geff/display_hints", GeffDisplayHint.class ); - LOG.debug( "found geff/display_hints = {}", displayHints ); - final GeffMetadata metadata = new GeffMetadata( geffVersion, directed, axes ); metadata.setNodePropsMetadata( nodePropsMetadata ); metadata.setEdgePropsMetadata( edgePropsMetadata ); metadata.setTrackNodeProps( trackNodeProps ); - metadata.setDisplayHints( displayHints ); metadata.validate(); return metadata; } - /** - * Read an optional attribute. An attribute that is explicitly written as - * JSON {@code null}, as the Python implementation does for the optional - * fields it leaves unset, cannot be parsed into the target type; report it - * as absent instead of failing. - */ - private static < T > T readOptionalAttribute( final N5Reader reader, final String group, final String key, final Type type ) - { - try - { - return reader.getAttribute( group, key, type ); - } - catch ( final Exception e ) - { - LOG.debug( "Could not parse {} as {}, setting to null: {}", key, type.getTypeName(), e.getMessage() ); - return null; - } - } - /** * Write metadata to Zarr format at specified path */ @@ -550,20 +372,14 @@ public void writeToN5( final N5Writer writer, final String group ) LOG.debug( "writing geff/track_node_props {}", trackNodeProps ); writer.setAttribute( group, "geff/track_node_props", trackNodeProps ); } - - if ( displayHints != null ) - { - LOG.debug( "writing geff/display_hints {}", displayHints ); - writer.setAttribute( group, "geff/display_hints", displayHints ); - } } @Override public String toString() { return String.format( - "GeffMetadata{geffVersion='%s', directed=%s, geffAxes=%s, nodePropsMetadata=%s, edgePropsMetadata=%s, trackNodeProps=%s, displayHints=%s}", - geffVersion, directed, Arrays.toString( geffAxes ), nodePropsMetadata, edgePropsMetadata, trackNodeProps, displayHints ); + "GeffMetadata{geffVersion='%s', directed=%s, geffAxes=%s, nodePropsMetadata=%s, edgePropsMetadata=%s, trackNodeProps=%s}", + geffVersion, directed, Arrays.toString( geffAxes ), nodePropsMetadata, edgePropsMetadata, trackNodeProps ); } @Override @@ -574,13 +390,12 @@ public boolean equals( final Object o ) GeffMetadata that = ( GeffMetadata ) o; return directed == that.directed && Objects.equals( geffVersion, that.geffVersion ) && Objects.deepEquals( geffAxes, that.geffAxes ) && Objects.equals( nodePropsMetadata, that.nodePropsMetadata ) && Objects.equals( edgePropsMetadata, that.edgePropsMetadata ) - && Objects.equals( trackNodeProps, that.trackNodeProps ) && Objects.equals( displayHints, that.displayHints ); + && Objects.equals( trackNodeProps, that.trackNodeProps ); } @Override public int hashCode() { - return Objects.hash( geffVersion, directed, Arrays.hashCode( geffAxes ), nodePropsMetadata, edgePropsMetadata, trackNodeProps, - displayHints ); + return Objects.hash( geffVersion, directed, Arrays.hashCode( geffAxes ), nodePropsMetadata, edgePropsMetadata, trackNodeProps ); } } diff --git a/src/main/java/org/mastodon/geff/GeffNode.java b/src/main/java/org/mastodon/geff/GeffNode.java index 502ba2b..1f5855b 100644 --- a/src/main/java/org/mastodon/geff/GeffNode.java +++ b/src/main/java/org/mastodon/geff/GeffNode.java @@ -729,13 +729,13 @@ public static List< GeffNode > readFromN5( final N5Reader reader, final String g // See GeffUtils.shouldSkipProperty() and checkForMissingValues() for // implementation - // Determine axis names dynamically by combining display_hints and axes, - // falling back to standard names (t, x, y, z) if neither is defined. - // zAxisName is null for datasets without a third spatial axis. - final String timeAxisName = metadata.getTimeAxisName(); - final String xAxisName = metadata.getHorizontalAxisName(); - final String yAxisName = metadata.getVerticalAxisName(); - final String zAxisName = metadata.getDepthAxisName(); + // 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" ); @@ -744,7 +744,7 @@ public static List< GeffNode > readFromN5( final N5Reader reader, final String g final int numNodes = nodeIds.length; // Read time points from chunks using dynamic axis name - final String timePropPath = path + "/nodes/props/" + timeAxisName + "/values"; + final String timePropPath = path + "/nodes/props/" + ( timeAxisName != null ? timeAxisName : "t" ) + "/values"; final int[] timepoints = GeffUtils.readAsIntArray( reader, timePropPath, "timepoints" ); verifyLength( timepoints, numNodes, timePropPath ); @@ -760,8 +760,8 @@ public static List< GeffNode > readFromN5( final N5Reader reader, final String g // Read Z coordinates from chunks using dynamic axis name (optional) final double[] zCoords; - final String zPropPath = zAxisName != null ? path + "/nodes/props/" + zAxisName + "/values" : null; - if ( zPropPath != null && reader.datasetExists( zPropPath ) ) + 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 ); @@ -1068,13 +1068,13 @@ public static void writeToN5( final String path = N5URI.normalizeGroupPath( group ); final int numNodes = nodes.size(); - // Determine axis names dynamically by combining display_hints and axes, - // falling back to standard names (t, x, y, z) if neither is defined. - // zAxisName is null for datasets without a third spatial axis. - final String timePropName = metadata.getTimeAxisName(); - final String xAxisName = metadata.getHorizontalAxisName(); - final String yAxisName = metadata.getVerticalAxisName(); - final String zAxisName = metadata.getDepthAxisName(); + // 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; @@ -1083,6 +1083,7 @@ public static void writeToN5( GeffUtils.writeIntArray( nodes, GeffNode::getId, writer, path + "/nodes/ids", chunkSize ); // 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( timePropName ) : null; @@ -1102,9 +1103,8 @@ public static void writeToN5( if ( writeAllProps || metadataNodeProps.containsKey( yAxisName ) ) GeffUtils.writeDoubleArray( nodes, GeffNode::getY, writer, path + "/nodes/props/" + yAxisName + "/values", chunkSize ); - // Write Z coordinates in chunks using dynamic axis name (skipped for - // datasets without a third spatial axis) - if ( zAxisName != null && ( writeAllProps || metadataNodeProps.containsKey( zAxisName ) ) ) + // 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 @@ -1140,7 +1140,7 @@ public static void writeToN5( 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 ( zAxisName != null ) + if ( spaceAxes.length > 2 ) { nodePropsMap.put( zAxisName, new PropMetadata( zAxisName, "float64", false, null, null, null ) ); } diff --git a/src/test/java/org/mastodon/geff/GeffDisplayHintTest.java b/src/test/java/org/mastodon/geff/GeffDisplayHintTest.java deleted file mode 100644 index f7bb2d0..0000000 --- a/src/test/java/org/mastodon/geff/GeffDisplayHintTest.java +++ /dev/null @@ -1,303 +0,0 @@ -/*- - * #%L - * geff-java - * %% - * Copyright (C) 2025 - 2026 Ko Sugawara - * %% - * Redistribution and use in source and binary forms, with or without - * modification, are permitted provided that the following conditions are met: - * - * 1. Redistributions of source code must retain the above copyright notice, - * this list of conditions and the following disclaimer. - * 2. Redistributions in binary form must reproduce the above copyright notice, - * this list of conditions and the following disclaimer in the documentation - * and/or other materials provided with the distribution. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" - * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE - * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE - * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE - * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR - * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF - * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS - * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN - * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) - * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE - * POSSIBILITY OF SUCH DAMAGE. - * #L% - */ -package org.mastodon.geff; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.ArrayList; -import java.util.List; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; - -/** - * Test class for resolving axis names from display_hints and axes. - */ -public class GeffDisplayHintTest -{ - - private static GeffMetadata metadata( final GeffAxis... axes ) - { - return new GeffMetadata( Geff.VERSION, true, axes ); - } - - /** - * Read the .zattrs of a group with all whitespace removed, so that - * assertions on the JSON do not depend on formatting. - */ - private static String readZattrs( final String groupPath ) throws IOException - { - return new String( Files.readAllBytes( Paths.get( groupPath, ".zattrs" ) ) ).replaceAll( "\\s", "" ); - } - - @Test - @DisplayName( "Test display hints take precedence over axis names and order" ) - void testDisplayHintsWin() - { - final GeffMetadata meta = metadata( - new GeffAxis( "frame", GeffAxis.TYPE_TIME, "frame" ), - new GeffAxis( "x", GeffAxis.TYPE_SPACE, GeffAxis.UNIT_PIXEL ), - new GeffAxis( "y", GeffAxis.TYPE_SPACE, GeffAxis.UNIT_PIXEL ), - new GeffAxis( "z", GeffAxis.TYPE_SPACE, GeffAxis.UNIT_PIXEL ) ); - // Deliberately unconventional: display x as vertical and y as horizontal - meta.setDisplayHints( new GeffDisplayHint( "y", "x", "z", "frame" ) ); - - assertEquals( "y", meta.getHorizontalAxisName() ); - assertEquals( "x", meta.getVerticalAxisName() ); - assertEquals( "z", meta.getDepthAxisName() ); - assertEquals( "frame", meta.getTimeAxisName() ); - } - - @Test - @DisplayName( "Test axes in image dimension order without display hints" ) - void testImageOrderAxesWithoutHints() - { - // The order used by the spec example and by the Python implementation - final GeffMetadata meta = metadata( - new GeffAxis( "t", GeffAxis.TYPE_TIME, GeffAxis.UNIT_SECOND ), - new GeffAxis( "z", GeffAxis.TYPE_SPACE, GeffAxis.UNIT_MICROMETER ), - new GeffAxis( "y", GeffAxis.TYPE_SPACE, GeffAxis.UNIT_MICROMETER ), - new GeffAxis( "x", GeffAxis.TYPE_SPACE, GeffAxis.UNIT_MICROMETER ) ); - - assertEquals( "x", meta.getHorizontalAxisName() ); - assertEquals( "y", meta.getVerticalAxisName() ); - assertEquals( "z", meta.getDepthAxisName() ); - assertEquals( "t", meta.getTimeAxisName() ); - } - - @Test - @DisplayName( "Test axes in x, y, z order without display hints" ) - void testXyzOrderAxesWithoutHints() - { - final GeffMetadata meta = metadata( - new GeffAxis( "t", GeffAxis.TYPE_TIME, GeffAxis.UNIT_SECOND ), - new GeffAxis( "x", GeffAxis.TYPE_SPACE, GeffAxis.UNIT_MICROMETER ), - new GeffAxis( "y", GeffAxis.TYPE_SPACE, GeffAxis.UNIT_MICROMETER ), - new GeffAxis( "z", GeffAxis.TYPE_SPACE, GeffAxis.UNIT_MICROMETER ) ); - - // Names are matched before positions, so the order does not matter here - assertEquals( "x", meta.getHorizontalAxisName() ); - assertEquals( "y", meta.getVerticalAxisName() ); - assertEquals( "z", meta.getDepthAxisName() ); - } - - @Test - @DisplayName( "Test suffixed axis names without display hints" ) - void testSuffixedAxisNamesWithoutHints() - { - final GeffMetadata meta = metadata( - new GeffAxis( "frame", GeffAxis.TYPE_TIME, "frame" ), - new GeffAxis( "cell_x", GeffAxis.TYPE_SPACE, GeffAxis.UNIT_PIXEL ), - new GeffAxis( "cell_y", GeffAxis.TYPE_SPACE, GeffAxis.UNIT_PIXEL ) ); - - assertEquals( "cell_x", meta.getHorizontalAxisName() ); - assertEquals( "cell_y", meta.getVerticalAxisName() ); - assertNull( meta.getDepthAxisName(), "2D dataset must not have a depth axis" ); - assertEquals( "frame", meta.getTimeAxisName() ); - } - - @Test - @DisplayName( "Test unrecognizable axis names fall back to the axes order" ) - void testUnrecognizableAxisNamesWithoutHints() - { - // Nothing but the position is left to go by, so image dimension order - // (slowest to fastest) is assumed - final GeffMetadata meta = metadata( - new GeffAxis( "depth", GeffAxis.TYPE_SPACE, GeffAxis.UNIT_MICROMETER ), - new GeffAxis( "row", GeffAxis.TYPE_SPACE, GeffAxis.UNIT_MICROMETER ), - new GeffAxis( "column", GeffAxis.TYPE_SPACE, GeffAxis.UNIT_MICROMETER ) ); - - assertEquals( "column", meta.getHorizontalAxisName() ); - assertEquals( "row", meta.getVerticalAxisName() ); - assertEquals( "depth", meta.getDepthAxisName() ); - assertEquals( "t", meta.getTimeAxisName(), "no time axis declared, standard name expected" ); - } - - @Test - @DisplayName( "Test standard names are used when no axes are declared" ) - void testNoAxes() - { - final GeffMetadata meta = new GeffMetadata( Geff.VERSION, true ); - - assertEquals( "t", meta.getTimeAxisName() ); - assertEquals( "x", meta.getHorizontalAxisName() ); - assertEquals( "y", meta.getVerticalAxisName() ); - assertEquals( "z", meta.getDepthAxisName() ); - } - - @Test - @DisplayName( "Test channel axes are ignored when resolving spatial axes" ) - void testChannelAxisIgnored() - { - final GeffMetadata meta = metadata( - new GeffAxis( "t", GeffAxis.TYPE_TIME, GeffAxis.UNIT_SECOND ), - new GeffAxis( "c", GeffAxis.TYPE_CHANNEL, "channel" ), - new GeffAxis( "row", GeffAxis.TYPE_SPACE, GeffAxis.UNIT_MICROMETER ), - new GeffAxis( "column", GeffAxis.TYPE_SPACE, GeffAxis.UNIT_MICROMETER ) ); - - assertEquals( "column", meta.getHorizontalAxisName() ); - assertEquals( "row", meta.getVerticalAxisName() ); - assertNull( meta.getDepthAxisName() ); - } - - @Test - @DisplayName( "Test display hints must reference declared axes" ) - void testDisplayHintsValidation() - { - final GeffMetadata meta = metadata( - new GeffAxis( "t", GeffAxis.TYPE_TIME, GeffAxis.UNIT_SECOND ), - new GeffAxis( "y", GeffAxis.TYPE_SPACE, GeffAxis.UNIT_MICROMETER ), - new GeffAxis( "x", GeffAxis.TYPE_SPACE, GeffAxis.UNIT_MICROMETER ) ); - - assertThrows( IllegalArgumentException.class, - () -> meta.setDisplayHints( new GeffDisplayHint( "column", "y" ) ), - "display_horizontal naming an undeclared axis should be rejected" ); - assertThrows( IllegalArgumentException.class, - () -> meta.setDisplayHints( new GeffDisplayHint( "x", "y", "z", null ) ), - "display_depth naming an undeclared axis should be rejected" ); - assertThrows( IllegalArgumentException.class, - () -> meta.setDisplayHints( new GeffDisplayHint( "x", "y", null, "frame" ) ), - "display_time naming an undeclared axis should be rejected" ); - assertThrows( IllegalArgumentException.class, - () -> meta.setDisplayHints( new GeffDisplayHint( "x", null ) ), - "display_vertical is required" ); - } - - @Test - @DisplayName( "Test display hints round trip through Zarr" ) - void testDisplayHintsRoundTrip( @TempDir Path tempDir ) throws IOException - { - final GeffMetadata meta = metadata( - new GeffAxis( "frame", GeffAxis.TYPE_TIME, "frame" ), - new GeffAxis( "depth", GeffAxis.TYPE_SPACE, GeffAxis.UNIT_MICROMETER ), - new GeffAxis( "row", GeffAxis.TYPE_SPACE, GeffAxis.UNIT_MICROMETER ), - new GeffAxis( "column", GeffAxis.TYPE_SPACE, GeffAxis.UNIT_MICROMETER ) ); - meta.setDisplayHints( new GeffDisplayHint( "column", "row", "depth", "frame" ) ); - - final String tempPath = tempDir.toString() + "/test-display-hints.zarr/tracks"; - GeffMetadata.writeToZarr( meta, tempPath ); - - // The attribute must be written with the snake_case keys of the spec - final String zattrs = readZattrs( tempPath ); - assertTrue( zattrs.contains( "\"display_hints\":" ), zattrs ); - assertTrue( zattrs.contains( "\"display_horizontal\":\"column\"" ), zattrs ); - assertTrue( zattrs.contains( "\"display_vertical\":\"row\"" ), zattrs ); - assertTrue( zattrs.contains( "\"display_depth\":\"depth\"" ), zattrs ); - assertTrue( zattrs.contains( "\"display_time\":\"frame\"" ), zattrs ); - - final GeffMetadata read = GeffMetadata.readFromZarr( tempPath ); - assertEquals( meta.getDisplayHints(), read.getDisplayHints() ); - assertEquals( "column", read.getHorizontalAxisName() ); - assertEquals( "row", read.getVerticalAxisName() ); - assertEquals( "depth", read.getDepthAxisName() ); - assertEquals( "frame", read.getTimeAxisName() ); - } - - @Test - @DisplayName( "Test unset optional display hint fields stay null" ) - void testOptionalDisplayHintsUnset( @TempDir Path tempDir ) throws IOException - { - final GeffMetadata meta = metadata( - new GeffAxis( "y", GeffAxis.TYPE_SPACE, GeffAxis.UNIT_MICROMETER ), - new GeffAxis( "x", GeffAxis.TYPE_SPACE, GeffAxis.UNIT_MICROMETER ) ); - meta.setDisplayHints( new GeffDisplayHint( "x", "y" ) ); - - final String tempPath = tempDir.toString() + "/test-partial-hints.zarr/tracks"; - GeffMetadata.writeToZarr( meta, tempPath ); - - final String zattrs = readZattrs( tempPath ); - assertTrue( zattrs.contains( "\"display_horizontal\":\"x\"" ), zattrs ); - assertTrue( zattrs.contains( "\"display_depth\":null" ), zattrs ); - assertTrue( zattrs.contains( "\"display_time\":null" ), zattrs ); - - final GeffDisplayHint read = GeffMetadata.readFromZarr( tempPath ).getDisplayHints(); - assertNotNull( read ); - assertEquals( "x", read.getDisplayHorizontal() ); - assertEquals( "y", read.getDisplayVertical() ); - assertNull( read.getDisplayDepth() ); - assertNull( read.getDisplayTime() ); - } - - @Test - @DisplayName( "Test nodes are stored under the axes named by the display hints" ) - void testNodesUseDisplayHintedAxes( @TempDir Path tempDir ) throws IOException - { - final GeffMetadata meta = metadata( - new GeffAxis( "frame", GeffAxis.TYPE_TIME, "frame" ), - new GeffAxis( "depth", GeffAxis.TYPE_SPACE, GeffAxis.UNIT_MICROMETER ), - new GeffAxis( "row", GeffAxis.TYPE_SPACE, GeffAxis.UNIT_MICROMETER ), - new GeffAxis( "column", GeffAxis.TYPE_SPACE, GeffAxis.UNIT_MICROMETER ) ); - meta.setDisplayHints( new GeffDisplayHint( "column", "row", "depth", "frame" ) ); - - final List< GeffNode > nodes = new ArrayList<>(); - for ( int i = 0; i < 3; i++ ) - { - final GeffNode node = new GeffNode(); - node.setId( i ); - node.setT( i ); - node.setX( i * 10.0 ); - node.setY( i * 20.0 ); - node.setZ( i * 30.0 ); - nodes.add( node ); - } - - final String tempPath = tempDir.toString() + "/test-hinted-nodes.zarr/tracks"; - GeffNode.writeToZarr( nodes, tempPath, meta ); - GeffMetadata.writeToZarr( meta, tempPath ); - - // Coordinates must land in the props named by the display hints - for ( final String propName : new String[] { "frame", "depth", "row", "column" } ) - assertTrue( Files.isDirectory( Paths.get( tempPath, "nodes", "props", propName, "values" ) ), - "missing node property " + propName ); - - final GeffMetadata readMeta = GeffMetadata.readFromZarr( tempPath ); - assertEquals( meta.getDisplayHints(), readMeta.getDisplayHints() ); - - final List< GeffNode > readNodes = GeffNode.readFromZarr( tempPath, readMeta ); - 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.getZ(), actual.getZ(), 1e-9, "z mismatch at node " + i ); - } - } -} From 79760a39392c079f1b2497b2988f05489fd00602 Mon Sep 17 00:00:00 2001 From: Ko Sugawara Date: Tue, 8 Sep 2026 19:32:42 +0900 Subject: [PATCH 11/13] Make GeffDisplayHints gson-serializable DisplayHints class was renamed to GeffDisplayHints, and was made gson-serializable. Add validation code for GeffDisplayHints. GeffDisplayHints, RelatedObjects, extra metadata were taken into account for the toString(), equals( Object obj ), and hashCode() methods. --- .../java/org/mastodon/geff/GeffMetadata.java | 414 +++++++++++++++--- 1 file changed, 342 insertions(+), 72 deletions(-) diff --git a/src/main/java/org/mastodon/geff/GeffMetadata.java b/src/main/java/org/mastodon/geff/GeffMetadata.java index 4286f2e..8af14b2 100644 --- a/src/main/java/org/mastodon/geff/GeffMetadata.java +++ b/src/main/java/org/mastodon/geff/GeffMetadata.java @@ -2,7 +2,7 @@ * #%L * geff-java * %% - * Copyright (C) 2025 - 2026 Ko Sugawara + * Copyright (C) 2025 - 2026 Ko Sugawara, Jean-Yves Tinevez * %% * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are met: @@ -31,9 +31,9 @@ import static org.mastodon.geff.GeffUtils.checkSupportedVersion; import java.io.IOException; +import java.lang.reflect.Type; import java.util.ArrayList; import java.util.Arrays; -import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; @@ -48,6 +48,7 @@ import org.slf4j.LoggerFactory; import com.google.gson.GsonBuilder; +import com.google.gson.annotations.SerializedName; import com.google.gson.reflect.TypeToken; /** @@ -98,7 +99,7 @@ public class GeffMetadata */ private Map< String, Object > extra; - private DisplayHints displayHints; + private GeffDisplayHints displayHints; private RelatedObjects relatedObjects; @@ -174,7 +175,8 @@ public List< GeffAxis > getGeffAxesList() // TODO rename getGeffAxes() return ( geffAxes != null ) ? Arrays.asList( geffAxes ) : null; } - public void setGeffAxes( final GeffAxis[] geffAxes ) // TODO make List + public void setGeffAxes( final GeffAxis[] geffAxes ) // TODO make + // List { this.geffAxes = geffAxes != null ? geffAxes.clone() : null; validate(); @@ -226,19 +228,129 @@ public void setExtra( final Map< String, Object > extra ) this.extra = extra; } - public DisplayHints getDisplayHints() + public GeffDisplayHints getDisplayHints() { return displayHints; } - public void setDisplayHints( final DisplayHints displayHints ) + public void setDisplayHints( final GeffDisplayHints displayHints ) { this.displayHints = displayHints; + validate(); } - public void setRelatedObjects( final RelatedObjects relatedObjects ) + /** + * Get the name of the axis holding the time coordinate. + *

+ * The {@code display_time} display hint takes precedence; otherwise the + * first axis of type {@link GeffAxis#TYPE_TIME} is used, falling back to + * the standard name {@code "t"} if no axes are declared. + * + * @return the time axis name, never null + */ + public String getTimeAxisName() { - this.relatedObjects = relatedObjects; + final String hint = displayHints != null ? displayHints.getDisplayTime() : null; + if ( hint != null && !hint.trim().isEmpty() ) + return hint; + + final String name = getAxisNameByType( GeffAxis.TYPE_TIME ); + return name != null ? name : GeffAxis.NAME_TIME; + } + + /** + * Get the name of the spatial axis holding the X coordinate, i.e. the one a + * viewer displays horizontally. + * + * @return the axis name, never null + * @see #resolveSpaceAxisName(String, String, int) + */ + public String getHorizontalAxisName() + { + final String hint = displayHints != null ? displayHints.getDisplayHorizontal() : null; + return resolveSpaceAxisName( hint, GeffAxis.NAME_SPACE_X, 0 ); + } + + /** + * Get the name of the spatial axis holding the Y coordinate, i.e. the one a + * viewer displays vertically. + * + * @return the axis name, never null + * @see #resolveSpaceAxisName(String, String, int) + */ + public String getVerticalAxisName() + { + final String hint = displayHints != null ? displayHints.getDisplayVertical() : null; + return resolveSpaceAxisName( hint, GeffAxis.NAME_SPACE_Y, 1 ); + } + + /** + * Get the name of the spatial axis holding the Z coordinate, i.e. the one a + * viewer displays as depth. + * + * @return the axis name, or null for datasets without a third spatial axis + * @see #resolveSpaceAxisName(String, String, int) + */ + public String getDepthAxisName() + { + final String hint = displayHints != null ? displayHints.getDisplayDepth() : null; + return resolveSpaceAxisName( hint, GeffAxis.NAME_SPACE_Z, 2 ); + } + + /** + * Resolve which spatial axis carries a given display dimension by combining + * {@code display_hints} and {@code axes}. Since {@code display_hints} is + * optional, the following sources are consulted in order: + *

    + *
  1. the display hint, which names the axis explicitly,
  2. + *
  3. a spatial axis called {@code standardName} (case-insensitive), or one + * whose name ends in {@code "_" + standardName}, e.g. + * {@code "cell_x"},
  4. + *
  5. the position in the axes list, which the spec defines as image + * dimension order, i.e. slowest to fastest ({@code z}, {@code y}, + * {@code x}), hence counted from the end,
  6. + *
  7. {@code standardName} itself, for datasets that declare no spatial + * axes at all.
  8. + *
+ * + * @param hint + * the corresponding display hint, or null if not given + * @param standardName + * the conventional name of this axis ({@code x}, {@code y} or + * {@code z}) + * @param indexFromLast + * position of this axis counted backwards from the end of the + * spatial axes list (0 for x, 1 for y, 2 for z) + * @return the axis name, or null if the dataset declares spatial axes but + * not the requested one + */ + private String resolveSpaceAxisName( final String hint, final String standardName, final int indexFromLast ) + { + if ( hint != null && !hint.trim().isEmpty() ) + return hint; + + final String[] spaceAxes = getAxisNamesByType( GeffAxis.TYPE_SPACE ); + + for ( final String name : spaceAxes ) + if ( standardName.equalsIgnoreCase( name ) ) + return name; + + for ( final String name : spaceAxes ) + if ( name != null && name.toLowerCase().endsWith( "_" + standardName ) ) + return name; + + final int index = spaceAxes.length - 1 - indexFromLast; + if ( index >= 0 ) + { + LOG.debug( "no display hint for {}, guessing axis '{}' from the axes order {}", + standardName, spaceAxes[ index ], Arrays.toString( spaceAxes ) ); + return spaceAxes[ index ]; + } + + // A dataset that declares no axes at all is assumed to use the + // standard t/x/y/z layout. Otherwise the requested axis is beyond the + // declared ones, i.e. the dataset has no depth axis. + return ( spaceAxes.length == 0 || indexFromLast < 2 ) ? standardName : null; } public RelatedObjects getRelatedObjects() @@ -246,11 +358,18 @@ public RelatedObjects getRelatedObjects() return relatedObjects; } + public void setRelatedObjects( final RelatedObjects relatedObjects ) + { + this.relatedObjects = relatedObjects; + validate(); + } + /** - * 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. + * 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") + * @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( final String type ) @@ -260,19 +379,18 @@ public String getAxisNameByType( final String type ) for ( final GeffAxis axis : geffAxes ) { if ( type.equals( axis.getType() ) ) - { - return axis.getName(); - } + { 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. + * 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) + * @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( final String type ) @@ -286,6 +404,21 @@ public String[] getAxisNamesByType( final String type ) .toArray( String[]::new ); } + /** + * Get the names of all axes, in declaration order. + * + * @return array of axis names (empty array if no axes are declared) + */ + public String[] getAxisNames() + { + if ( geffAxes == null ) + return new String[ 0 ]; + + return Arrays.stream( geffAxes ) + .map( GeffAxis::getName ) + .toArray( String[]::new ); + } + /** * Validates the metadata according to the GEFF schema rules */ @@ -309,6 +442,29 @@ public void validate() "max " + axis.getMax() + " in dimension " + axis.getName() ); } } } + + // Check display hints consistency if provided + if ( displayHints != null ) + { + displayHints.validate(); + + // Display hints must reference axes that exist + if ( geffAxes != null ) + { + final List< String > axisNames = Arrays.asList( getAxisNames() ); + checkDisplayHint( "display_horizontal", displayHints.getDisplayHorizontal(), axisNames ); + checkDisplayHint( "display_vertical", displayHints.getDisplayVertical(), axisNames ); + checkDisplayHint( "display_depth", displayHints.getDisplayDepth(), axisNames ); + checkDisplayHint( "display_time", displayHints.getDisplayTime(), axisNames ); + } + } + } + + private static void checkDisplayHint( final String hintName, final String axisName, final List< String > axisNames ) + { + if ( axisName != null && !axisNames.contains( axisName ) ) + { throw new IllegalArgumentException( + "Display hint " + hintName + " name " + axisName + " not found in axes " + axisNames ); } } /** @@ -354,37 +510,14 @@ public static GeffMetadata readFromN5( final N5Reader reader, final String group LOG.debug( "found geff/edge_props_metadata = {}", edgePropsMetadata ); // trackNodeProps may be null, so safe-read it - Map< String, String > trackNodeProps = null; - try - { - trackNodeProps = reader.getAttribute( group, "geff/track_node_props", - new TypeToken< Map< String, String > >() - {}.getType() ); - } - catch ( final Exception e ) - { - // If the attribute cannot be parsed as Map (e.g., - // if it's null in JSON), - // just leave it as null - LOG.debug( "Could not parse geff/track_node_props as Map, setting to null: {}", e.getMessage() ); - } + final Map< String, String > trackNodeProps = readOptionalAttribute( reader, group, "geff/track_node_props", + new TypeToken< Map< String, String > >() + {}.getType() ); LOG.debug( "found geff/track_node_props = {}", trackNodeProps ); - // DisplayHints - DisplayHints displayHints = null; - try - { - final Map< String, String > dhMap = reader.getAttribute( group, "geff/display_hints", Map.class ); - if ( dhMap != null ) - { - displayHints = new DisplayHints(); - displayHints.hints.putAll( dhMap ); - } - } - catch ( final Exception e ) - { - LOG.debug( "Could not parse geff/display_hints as DisplayHints, setting to null: {}", e.getMessage() ); - } + // displayHints may be null, so safe-read it + final GeffDisplayHints displayHints = readOptionalAttribute( reader, group, "geff/display_hints", GeffDisplayHints.class ); + LOG.debug( "found geff/display_hints = {}", displayHints ); // RelatedObjects RelatedObjects relatedObjects = null; @@ -427,6 +560,25 @@ public static GeffMetadata readFromN5( final N5Reader reader, final String group return metadata; } + /** + * Read an optional attribute. An attribute that is explicitly written as + * JSON {@code null}, as the Python implementation does for the optional + * fields it leaves unset, cannot be parsed into the target type; report it + * as absent instead of failing. + */ + private static < T > T readOptionalAttribute( final N5Reader reader, final String group, final String key, final Type type ) + { + try + { + return reader.getAttribute( group, key, type ); + } + catch ( final Exception e ) + { + LOG.debug( "Could not parse {} as {}, setting to null: {}", key, type.getTypeName(), e.getMessage() ); + return null; + } + } + /** * Write metadata to Zarr format at specified path */ @@ -476,7 +628,7 @@ public void writeToN5( final N5Writer writer, final String group ) if ( displayHints != null ) { LOG.debug( "writing geff/display_hints {}", displayHints ); - writer.setAttribute( group, "geff/display_hints", displayHints.hints ); + writer.setAttribute( group, "geff/display_hints", displayHints ); } if ( relatedObjects != null ) @@ -496,8 +648,8 @@ public void writeToN5( final N5Writer writer, final String group ) public String toString() { return String.format( - "GeffMetadata{geffVersion='%s', directed=%s, geffAxes=%s, nodePropsMetadata=%s, edgePropsMetadata=%s, trackNodeProps=%s}", - geffVersion, directed, Arrays.toString( geffAxes ), nodePropsMetadata, edgePropsMetadata, trackNodeProps ); + "GeffMetadata{geffVersion='%s', directed=%s, geffAxes=%s, nodePropsMetadata=%s, edgePropsMetadata=%s, trackNodeProps=%s, displayHints=%s, relatedObjects=%s, extra=%s}", + geffVersion, directed, Arrays.toString( geffAxes ), nodePropsMetadata, edgePropsMetadata, trackNodeProps, displayHints, relatedObjects, extra ); } @Override @@ -508,58 +660,176 @@ public boolean equals( final Object o ) final GeffMetadata that = ( GeffMetadata ) o; return directed == that.directed && Objects.equals( geffVersion, that.geffVersion ) && Objects.deepEquals( geffAxes, that.geffAxes ) && Objects.equals( nodePropsMetadata, that.nodePropsMetadata ) && Objects.equals( edgePropsMetadata, that.edgePropsMetadata ) - && Objects.equals( trackNodeProps, that.trackNodeProps ); + && Objects.equals( trackNodeProps, that.trackNodeProps ) && Objects.equals( displayHints, that.displayHints ) + && Objects.equals( relatedObjects, that.relatedObjects ) && Objects.equals( extra, that.extra ); } @Override public int hashCode() { - return Objects.hash( geffVersion, directed, Arrays.hashCode( geffAxes ), nodePropsMetadata, edgePropsMetadata, trackNodeProps ); + return Objects.hash( geffVersion, directed, Arrays.hashCode( geffAxes ), nodePropsMetadata, edgePropsMetadata, trackNodeProps, displayHints, relatedObjects, extra ); } /** * Display hints for GEFF + * + * Metadata indicating how spatiotemporal axes are displayed by a viewer, + * corresponding to the {@code geff/display_hints} attribute of the GEFF + * specification. + *

+ * The hints reference axes by {@link GeffAxis#getName() name}, e.g. + * + *

+	 * "display_hints": {
+	 *     "display_horizontal": "x",
+	 *     "display_vertical": "y",
+	 *     "display_depth": "z",
+	 *     "display_time": "t"
+	 * }
+	 * 
+ * + * {@code display_horizontal} and {@code display_vertical} are required, + * {@code display_depth} and {@code display_time} are optional. * * @see GEFF - * Specification: DisplayHint + * "https://liveimagetrackingtools.org/geff/latest/specification/">GEFF + * specification * @author Jean-Yves Tinevez + * @author Ko Sugawara */ - public static class DisplayHints + public static class GeffDisplayHints { - private final Map< String, String > hints = new HashMap<>(); + @SerializedName( "display_horizontal" ) + private String displayHorizontal; + + @SerializedName( "display_vertical" ) + private String displayVertical; + + @SerializedName( "display_depth" ) + private String displayDepth; // Optional - can be null + @SerializedName( "display_time" ) + private String displayTime; // Optional - can be null /** - * Which spatial axis to use for horizontal display. - * - * @param propName - * the name of the property to use for horizontal display. - * @return + * Default constructor + */ + public GeffDisplayHints() + {} + + /** + * Constructor with the required fields */ - public DisplayHints displayHorizontal( final String propName ) + public GeffDisplayHints( String displayHorizontal, String displayVertical ) { - hints.put( "display_horizontal", propName ); - return this; + this.displayHorizontal = displayHorizontal; + this.displayVertical = displayVertical; } - public DisplayHints displayVertical( final String propName ) + /** + * Constructor with all fields + */ + public GeffDisplayHints( String displayHorizontal, String displayVertical, String displayDepth, String displayTime ) { - hints.put( "display_vertical", propName ); - return this; + this.displayHorizontal = displayHorizontal; + this.displayVertical = displayVertical; + this.displayDepth = displayDepth; + this.displayTime = displayTime; } - public DisplayHints displayDepth( final String propName ) + // Getters and Setters + public String getDisplayHorizontal() { - hints.put( "display_depth", propName ); - return this; + return displayHorizontal; } - public DisplayHints displayTime( final String propName ) + public void setDisplayHorizontal( String displayHorizontal ) { - hints.put( "display_time", propName ); - return this; + this.displayHorizontal = displayHorizontal; + } + + public String getDisplayVertical() + { + return displayVertical; + } + + public void setDisplayVertical( String displayVertical ) + { + this.displayVertical = displayVertical; + } + + public String getDisplayDepth() + { + return displayDepth; + } + + public void setDisplayDepth( String displayDepth ) + { + this.displayDepth = displayDepth; + } + + public String getDisplayTime() + { + return displayTime; + } + + public void setDisplayTime( String displayTime ) + { + this.displayTime = displayTime; + } + + /** + * Validate the display hints according to GEFF rules + */ + public void validate() + { + if ( displayHorizontal == null || displayHorizontal.trim().isEmpty() ) + { throw new IllegalArgumentException( "display_horizontal cannot be null or empty" ); } + + if ( displayVertical == null || displayVertical.trim().isEmpty() ) + { throw new IllegalArgumentException( "display_vertical cannot be null or empty" ); } + } + + @Override + public String toString() + { + StringBuilder sb = new StringBuilder(); + sb.append( "GeffDisplayHint{" ); + sb.append( "displayHorizontal='" ).append( displayHorizontal ).append( '\'' ); + sb.append( ", displayVertical='" ).append( displayVertical ).append( '\'' ); + if ( displayDepth != null ) + { + sb.append( ", displayDepth='" ).append( displayDepth ).append( '\'' ); + } + if ( displayTime != null ) + { + sb.append( ", displayTime='" ).append( displayTime ).append( '\'' ); + } + sb.append( '}' ); + return sb.toString(); + } + + @Override + public boolean equals( Object obj ) + { + if ( this == obj ) + return true; + if ( obj == null || getClass() != obj.getClass() ) + return false; + + GeffDisplayHints that = ( GeffDisplayHints ) obj; + + return Objects.equals( displayHorizontal, that.displayHorizontal ) && + Objects.equals( displayVertical, that.displayVertical ) && + Objects.equals( displayDepth, that.displayDepth ) && + Objects.equals( displayTime, that.displayTime ); + } + + @Override + public int hashCode() + { + return Objects.hash( displayHorizontal, displayVertical, displayDepth, displayTime ); } } From cf43767a54ccdc2e83d96f5c8d350ce9f3f88b20 Mon Sep 17 00:00:00 2001 From: Ko Sugawara Date: Tue, 8 Sep 2026 19:34:39 +0900 Subject: [PATCH 12/13] Add GeffDisplayHintsTest --- .../mastodon/geff/GeffDisplayHintsTest.java | 305 ++++++++++++++++++ 1 file changed, 305 insertions(+) create mode 100644 src/test/java/org/mastodon/geff/GeffDisplayHintsTest.java diff --git a/src/test/java/org/mastodon/geff/GeffDisplayHintsTest.java b/src/test/java/org/mastodon/geff/GeffDisplayHintsTest.java new file mode 100644 index 0000000..7397970 --- /dev/null +++ b/src/test/java/org/mastodon/geff/GeffDisplayHintsTest.java @@ -0,0 +1,305 @@ +/*- + * #%L + * geff-java + * %% + * Copyright (C) 2025 - 2026 Ko Sugawara + * %% + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + * #L% + */ +package org.mastodon.geff; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mastodon.geff.GeffMetadata.GeffDisplayHints; + +/** + * Test class for resolving axis names from display_hints and axes. + */ +public class GeffDisplayHintsTest +{ + + private static GeffMetadata metadata( final GeffAxis... axes ) + { + return new GeffMetadata( Geff.VERSION, true, axes ); + } + + /** + * Read the .zattrs of a group with all whitespace removed, so that + * assertions on the JSON do not depend on formatting. + */ + private static String readZattrs( final String groupPath ) throws IOException + { + return new String( Files.readAllBytes( Paths.get( groupPath, ".zattrs" ) ) ).replaceAll( "\\s", "" ); + } + + @Test + @DisplayName( "Test display hints take precedence over axis names and order" ) + void testDisplayHintsWin() + { + final GeffMetadata meta = metadata( + new GeffAxis( "frame", GeffAxis.TYPE_TIME, "frame" ), + new GeffAxis( "x", GeffAxis.TYPE_SPACE, GeffAxis.UNIT_PIXEL ), + new GeffAxis( "y", GeffAxis.TYPE_SPACE, GeffAxis.UNIT_PIXEL ), + new GeffAxis( "z", GeffAxis.TYPE_SPACE, GeffAxis.UNIT_PIXEL ) ); + // Deliberately unconventional: display x as vertical and y as + // horizontal + meta.setDisplayHints( new GeffDisplayHints( "y", "x", "z", "frame" ) ); + + assertEquals( "y", meta.getHorizontalAxisName() ); + assertEquals( "x", meta.getVerticalAxisName() ); + assertEquals( "z", meta.getDepthAxisName() ); + assertEquals( "frame", meta.getTimeAxisName() ); + } + + @Test + @DisplayName( "Test axes in image dimension order without display hints" ) + void testImageOrderAxesWithoutHints() + { + // The order used by the spec example and by the Python implementation + final GeffMetadata meta = metadata( + new GeffAxis( "t", GeffAxis.TYPE_TIME, GeffAxis.UNIT_SECOND ), + new GeffAxis( "z", GeffAxis.TYPE_SPACE, GeffAxis.UNIT_MICROMETER ), + new GeffAxis( "y", GeffAxis.TYPE_SPACE, GeffAxis.UNIT_MICROMETER ), + new GeffAxis( "x", GeffAxis.TYPE_SPACE, GeffAxis.UNIT_MICROMETER ) ); + + assertEquals( "x", meta.getHorizontalAxisName() ); + assertEquals( "y", meta.getVerticalAxisName() ); + assertEquals( "z", meta.getDepthAxisName() ); + assertEquals( "t", meta.getTimeAxisName() ); + } + + @Test + @DisplayName( "Test axes in x, y, z order without display hints" ) + void testXyzOrderAxesWithoutHints() + { + final GeffMetadata meta = metadata( + new GeffAxis( "t", GeffAxis.TYPE_TIME, GeffAxis.UNIT_SECOND ), + new GeffAxis( "x", GeffAxis.TYPE_SPACE, GeffAxis.UNIT_MICROMETER ), + new GeffAxis( "y", GeffAxis.TYPE_SPACE, GeffAxis.UNIT_MICROMETER ), + new GeffAxis( "z", GeffAxis.TYPE_SPACE, GeffAxis.UNIT_MICROMETER ) ); + + // Names are matched before positions, so the order does not matter here + assertEquals( "x", meta.getHorizontalAxisName() ); + assertEquals( "y", meta.getVerticalAxisName() ); + assertEquals( "z", meta.getDepthAxisName() ); + } + + @Test + @DisplayName( "Test suffixed axis names without display hints" ) + void testSuffixedAxisNamesWithoutHints() + { + final GeffMetadata meta = metadata( + new GeffAxis( "frame", GeffAxis.TYPE_TIME, "frame" ), + new GeffAxis( "cell_x", GeffAxis.TYPE_SPACE, GeffAxis.UNIT_PIXEL ), + new GeffAxis( "cell_y", GeffAxis.TYPE_SPACE, GeffAxis.UNIT_PIXEL ) ); + + assertEquals( "cell_x", meta.getHorizontalAxisName() ); + assertEquals( "cell_y", meta.getVerticalAxisName() ); + assertNull( meta.getDepthAxisName(), "2D dataset must not have a depth axis" ); + assertEquals( "frame", meta.getTimeAxisName() ); + } + + @Test + @DisplayName( "Test unrecognizable axis names fall back to the axes order" ) + void testUnrecognizableAxisNamesWithoutHints() + { + // Nothing but the position is left to go by, so image dimension order + // (slowest to fastest) is assumed + final GeffMetadata meta = metadata( + new GeffAxis( "depth", GeffAxis.TYPE_SPACE, GeffAxis.UNIT_MICROMETER ), + new GeffAxis( "row", GeffAxis.TYPE_SPACE, GeffAxis.UNIT_MICROMETER ), + new GeffAxis( "column", GeffAxis.TYPE_SPACE, GeffAxis.UNIT_MICROMETER ) ); + + assertEquals( "column", meta.getHorizontalAxisName() ); + assertEquals( "row", meta.getVerticalAxisName() ); + assertEquals( "depth", meta.getDepthAxisName() ); + assertEquals( "t", meta.getTimeAxisName(), "no time axis declared, standard name expected" ); + } + + @Test + @DisplayName( "Test standard names are used when no axes are declared" ) + void testNoAxes() + { + final GeffMetadata meta = new GeffMetadata( Geff.VERSION, true ); + + assertEquals( "t", meta.getTimeAxisName() ); + assertEquals( "x", meta.getHorizontalAxisName() ); + assertEquals( "y", meta.getVerticalAxisName() ); + assertEquals( "z", meta.getDepthAxisName() ); + } + + @Test + @DisplayName( "Test channel axes are ignored when resolving spatial axes" ) + void testChannelAxisIgnored() + { + final GeffMetadata meta = metadata( + new GeffAxis( "t", GeffAxis.TYPE_TIME, GeffAxis.UNIT_SECOND ), + new GeffAxis( "c", GeffAxis.TYPE_CHANNEL, "channel" ), + new GeffAxis( "row", GeffAxis.TYPE_SPACE, GeffAxis.UNIT_MICROMETER ), + new GeffAxis( "column", GeffAxis.TYPE_SPACE, GeffAxis.UNIT_MICROMETER ) ); + + assertEquals( "column", meta.getHorizontalAxisName() ); + assertEquals( "row", meta.getVerticalAxisName() ); + assertNull( meta.getDepthAxisName() ); + } + + @Test + @DisplayName( "Test display hints must reference declared axes" ) + void testDisplayHintsValidation() + { + final GeffMetadata meta = metadata( + new GeffAxis( "t", GeffAxis.TYPE_TIME, GeffAxis.UNIT_SECOND ), + new GeffAxis( "y", GeffAxis.TYPE_SPACE, GeffAxis.UNIT_MICROMETER ), + new GeffAxis( "x", GeffAxis.TYPE_SPACE, GeffAxis.UNIT_MICROMETER ) ); + + assertThrows( IllegalArgumentException.class, + () -> meta.setDisplayHints( new GeffDisplayHints( "column", "y" ) ), + "display_horizontal naming an undeclared axis should be rejected" ); + assertThrows( IllegalArgumentException.class, + () -> meta.setDisplayHints( new GeffDisplayHints( "x", "y", "z", null ) ), + "display_depth naming an undeclared axis should be rejected" ); + assertThrows( IllegalArgumentException.class, + () -> meta.setDisplayHints( new GeffDisplayHints( "x", "y", null, "frame" ) ), + "display_time naming an undeclared axis should be rejected" ); + assertThrows( IllegalArgumentException.class, + () -> meta.setDisplayHints( new GeffDisplayHints( "x", null ) ), + "display_vertical is required" ); + } + + @Test + @DisplayName( "Test display hints round trip through Zarr" ) + void testDisplayHintsRoundTrip( @TempDir Path tempDir ) throws IOException + { + final GeffMetadata meta = metadata( + new GeffAxis( "frame", GeffAxis.TYPE_TIME, "frame" ), + new GeffAxis( "depth", GeffAxis.TYPE_SPACE, GeffAxis.UNIT_MICROMETER ), + new GeffAxis( "row", GeffAxis.TYPE_SPACE, GeffAxis.UNIT_MICROMETER ), + new GeffAxis( "column", GeffAxis.TYPE_SPACE, GeffAxis.UNIT_MICROMETER ) ); + meta.setDisplayHints( new GeffDisplayHints( "column", "row", "depth", "frame" ) ); + + final String tempPath = tempDir.toString() + "/test-display-hints.zarr/tracks"; + GeffMetadata.writeToZarr( meta, tempPath ); + + // The attribute must be written with the snake_case keys of the spec + final String zattrs = readZattrs( tempPath ); + assertTrue( zattrs.contains( "\"display_hints\":" ), zattrs ); + assertTrue( zattrs.contains( "\"display_horizontal\":\"column\"" ), zattrs ); + assertTrue( zattrs.contains( "\"display_vertical\":\"row\"" ), zattrs ); + assertTrue( zattrs.contains( "\"display_depth\":\"depth\"" ), zattrs ); + assertTrue( zattrs.contains( "\"display_time\":\"frame\"" ), zattrs ); + + final GeffMetadata read = GeffMetadata.readFromZarr( tempPath ); + assertEquals( meta.getDisplayHints(), read.getDisplayHints() ); + assertEquals( "column", read.getHorizontalAxisName() ); + assertEquals( "row", read.getVerticalAxisName() ); + assertEquals( "depth", read.getDepthAxisName() ); + assertEquals( "frame", read.getTimeAxisName() ); + } + + @Test + @DisplayName( "Test unset optional display hint fields stay null" ) + void testOptionalDisplayHintsUnset( @TempDir Path tempDir ) throws IOException + { + final GeffMetadata meta = metadata( + new GeffAxis( "y", GeffAxis.TYPE_SPACE, GeffAxis.UNIT_MICROMETER ), + new GeffAxis( "x", GeffAxis.TYPE_SPACE, GeffAxis.UNIT_MICROMETER ) ); + meta.setDisplayHints( new GeffDisplayHints( "x", "y" ) ); + + final String tempPath = tempDir.toString() + "/test-partial-hints.zarr/tracks"; + GeffMetadata.writeToZarr( meta, tempPath ); + + final String zattrs = readZattrs( tempPath ); + assertTrue( zattrs.contains( "\"display_horizontal\":\"x\"" ), zattrs ); + assertTrue( zattrs.contains( "\"display_depth\":null" ), zattrs ); + assertTrue( zattrs.contains( "\"display_time\":null" ), zattrs ); + + final GeffDisplayHints read = GeffMetadata.readFromZarr( tempPath ).getDisplayHints(); + assertNotNull( read ); + assertEquals( "x", read.getDisplayHorizontal() ); + assertEquals( "y", read.getDisplayVertical() ); + assertNull( read.getDisplayDepth() ); + assertNull( read.getDisplayTime() ); + } + + @Test + @DisplayName( "Test nodes are stored under the axes named by the display hints" ) + void testNodesUseDisplayHintedAxes( @TempDir Path tempDir ) throws IOException + { + final GeffMetadata meta = metadata( + new GeffAxis( "frame", GeffAxis.TYPE_TIME, "frame" ), + new GeffAxis( "depth", GeffAxis.TYPE_SPACE, GeffAxis.UNIT_MICROMETER ), + new GeffAxis( "row", GeffAxis.TYPE_SPACE, GeffAxis.UNIT_MICROMETER ), + new GeffAxis( "column", GeffAxis.TYPE_SPACE, GeffAxis.UNIT_MICROMETER ) ); + meta.setDisplayHints( new GeffDisplayHints( "column", "row", "depth", "frame" ) ); + + final List< GeffNode > nodes = new ArrayList<>(); + for ( int i = 0; i < 3; i++ ) + { + final GeffNode node = new GeffNode(); + node.setId( i ); + node.setT( i ); + node.setX( i * 10.0 ); + node.setY( i * 20.0 ); + node.setZ( i * 30.0 ); + nodes.add( node ); + } + + final String tempPath = tempDir.toString() + "/test-hinted-nodes.zarr/tracks"; + GeffNode.writeToZarr( nodes, tempPath, meta ); + GeffMetadata.writeToZarr( meta, tempPath ); + + // Coordinates must land in the props named by the display hints + for ( final String propName : new String[] { "frame", "depth", "row", "column" } ) + assertTrue( Files.isDirectory( Paths.get( tempPath, "nodes", "props", propName, "values" ) ), + "missing node property " + propName ); + + final GeffMetadata readMeta = GeffMetadata.readFromZarr( tempPath ); + assertEquals( meta.getDisplayHints(), readMeta.getDisplayHints() ); + + final List< GeffNode > readNodes = GeffNode.readFromZarr( tempPath, readMeta ); + 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.getZ(), actual.getZ(), 1e-9, "z mismatch at node " + i ); + } + } +} From 15daedaeb60c77d732085a46f35ecdcaa71b14ea Mon Sep 17 00:00:00 2001 From: Ko Sugawara Date: Tue, 8 Sep 2026 22:21:04 +0900 Subject: [PATCH 13/13] Get axes names from the displayhints --- src/main/java/org/mastodon/geff/GeffNode.java | 48 +++++++++---------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/src/main/java/org/mastodon/geff/GeffNode.java b/src/main/java/org/mastodon/geff/GeffNode.java index 8caf8ec..33d2d41 100644 --- a/src/main/java/org/mastodon/geff/GeffNode.java +++ b/src/main/java/org/mastodon/geff/GeffNode.java @@ -728,13 +728,13 @@ 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"; + // Determine axis names dynamically by combining display_hints and axes, + // falling back to standard names (t, x, y, z) if neither is defined. + // zAxisName is null for datasets without a third spatial axis. + final String timeAxisName = metadata.getTimeAxisName(); + final String xAxisName = metadata.getHorizontalAxisName(); + final String yAxisName = metadata.getVerticalAxisName(); + final String zAxisName = metadata.getDepthAxisName(); // Read node IDs from chunks final int[] nodeIds = GeffUtils.readAsIntArray( reader, path + "/nodes/ids", "node IDs" ); @@ -743,7 +743,7 @@ public static List< GeffNode > readFromN5( final N5Reader reader, final String g final int numNodes = nodeIds.length; // Read time points from chunks using dynamic axis name - final String timePropPath = path + "/nodes/props/" + ( timeAxisName != null ? timeAxisName : "t" ) + "/values"; + final String timePropPath = path + "/nodes/props/" + timeAxisName + "/values"; final int[] timepoints = GeffUtils.readAsIntArray( reader, timePropPath, "timepoints" ); verifyLength( timepoints, numNodes, timePropPath ); @@ -759,8 +759,8 @@ public static List< GeffNode > readFromN5( final N5Reader reader, final String g // 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 ) ) + final String zPropPath = zAxisName != null ? path + "/nodes/props/" + zAxisName + "/values" : null; + if ( zPropPath != null && reader.datasetExists( zPropPath ) ) { zCoords = GeffUtils.readAsDoubleArray( reader, zPropPath, "Z coordinates" ); verifyLength( zCoords, numNodes, zPropPath ); @@ -1010,7 +1010,7 @@ else if ( ndim == 2 ) */ public static void writeToZarr( List< GeffNode > nodes, String zarrPath ) { - writeToZarr( nodes, zarrPath, GeffUtils.computeFirstDimChunk( new long[]{ nodes.size() }, Integer.BYTES ) ); + writeToZarr( nodes, zarrPath, GeffUtils.computeFirstDimChunk( new long[] { nodes.size() }, Integer.BYTES ) ); } /** @@ -1033,12 +1033,12 @@ public static void writeToZarr( List< GeffNode > nodes, String zarrPath, String // directed // for // now - writeToZarr( nodes, zarrPath, GeffUtils.computeFirstDimChunk( new long[]{ nodes.size() }, Integer.BYTES ), metadata ); + writeToZarr( nodes, zarrPath, GeffUtils.computeFirstDimChunk( new long[] { nodes.size() }, Integer.BYTES ), metadata ); } public static void writeToZarr( List< GeffNode > nodes, String zarrPath, GeffMetadata metadata ) { - writeToZarr( nodes, zarrPath, GeffUtils.computeFirstDimChunk( new long[]{ nodes.size() }, Integer.BYTES ), metadata ); + writeToZarr( nodes, zarrPath, GeffUtils.computeFirstDimChunk( new long[] { nodes.size() }, Integer.BYTES ), metadata ); } public static void writeToZarr( List< GeffNode > nodes, String zarrPath, int chunkSize, GeffMetadata metadata ) @@ -1068,13 +1068,13 @@ 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"; + // Determine axis names dynamically by combining display_hints and axes, + // falling back to standard names (t, x, y, z) if neither is defined. + // zAxisName is null for datasets without a third spatial axis. + final String timePropName = metadata.getTimeAxisName(); + final String xAxisName = metadata.getHorizontalAxisName(); + final String yAxisName = metadata.getVerticalAxisName(); + final String zAxisName = metadata.getDepthAxisName(); final Map< String, PropMetadata > metadataNodeProps = metadata.getNodePropsMetadata(); final boolean writeAllProps = metadataNodeProps == null; @@ -1083,7 +1083,6 @@ public static void writeToN5( GeffUtils.writeIntArray( nodes, GeffNode::getId, writer, path + "/nodes/ids", chunkSize ); // 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( timePropName ) : null; @@ -1103,8 +1102,9 @@ public static void writeToN5( if ( writeAllProps || metadataNodeProps.containsKey( yAxisName ) ) GeffUtils.writeDoubleArray( nodes, GeffNode::getY, writer, path + "/nodes/props/" + yAxisName + "/values", chunkSize ); - // Write Z coordinates in chunks using dynamic axis name - if ( writeAllProps || metadataNodeProps.containsKey( zAxisName ) ) + // Write Z coordinates in chunks using dynamic axis name (skipped for + // datasets without a third spatial axis) + if ( zAxisName != null && ( writeAllProps || metadataNodeProps.containsKey( zAxisName ) ) ) GeffUtils.writeDoubleArray( nodes, GeffNode::getZ, writer, path + "/nodes/props/" + zAxisName + "/values", chunkSize ); // Write color in chunks @@ -1140,7 +1140,7 @@ public static void writeToN5( 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 ) + if ( zAxisName != null ) { nodePropsMap.put( zAxisName, new PropMetadata( zAxisName, "float64", false, null, null, null ) ); }