From 0846f775563fca9b1c649690c889183f1fa25128 Mon Sep 17 00:00:00 2001 From: Michael Innerberger Date: Wed, 15 Jul 2026 11:18:54 -0400 Subject: [PATCH 01/24] Add scaffold / mock-up for displacement field transform --- .../transform/DisplacementFieldTransform.java | 228 ++++++++++++++++++ .../DisplacementFieldTransformTest.java | 48 ++++ 2 files changed, 276 insertions(+) create mode 100644 render-app/src/main/java/org/janelia/alignment/transform/DisplacementFieldTransform.java create mode 100644 render-app/src/test/java/org/janelia/alignment/transform/DisplacementFieldTransformTest.java diff --git a/render-app/src/main/java/org/janelia/alignment/transform/DisplacementFieldTransform.java b/render-app/src/main/java/org/janelia/alignment/transform/DisplacementFieldTransform.java new file mode 100644 index 000000000..d1c67b7a7 --- /dev/null +++ b/render-app/src/main/java/org/janelia/alignment/transform/DisplacementFieldTransform.java @@ -0,0 +1,228 @@ +package org.janelia.alignment.transform; + +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; + +import mpicbg.trakem2.transform.CoordinateTransform; + +import net.imglib2.RandomAccessible; +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.RealRandomAccess; +import net.imglib2.interpolation.InterpolatorFactory; +import net.imglib2.interpolation.randomaccess.NLinearInterpolatorFactory; +import net.imglib2.realtransform.RealViews; +import net.imglib2.realtransform.ScaleAndTranslation; +import net.imglib2.type.numeric.real.DoubleType; +import net.imglib2.view.Views; +import net.imglib2.view.composite.RealComposite; + +import org.janelia.saalfeldlab.n5.N5FSReader; +import org.janelia.saalfeldlab.n5.N5Reader; +import org.janelia.saalfeldlab.n5.imglib2.N5Utils; + +/** + * Transform that reads a dense displacement (translation vector) field from a file on disk and adds the + * interpolated vector at each queried location to that location. + *

+ * TODO: this is a scaffold. The {@link #loadFieldAccessor} method currently assumes an N5/HDF5-style container + * openable with {@link N5Utils}; extend it (or the source parsing) to support whatever field formats are needed. + *

+ */ +public class DisplacementFieldTransform + implements CoordinateTransform { + + /** URI (as supplied to {@link #init}) identifying the field on disk and its world-coordinate mapping. */ + private String fieldSourceUri; + + /** World coordinate of field sample (0, 0); subtracted from a location before querying the field. */ + private double[] locationOffsets; + + /** World pixels spanned by one field sample along each axis; used to stretch the field over pixel space. */ + private double[] scale; + + // ImgLib2 accessor for the displacement field; null until a field source has been loaded. + private transient RealRandomAccess> fieldAccessor; + + /** + * Reflection constructor; leaves the instance uninitialized until {@link #init(String)} is called. + */ + public DisplacementFieldTransform() { + this.fieldSourceUri = null; + this.locationOffsets = new double[] {0.0, 0.0}; + this.scale = new double[] {1.0, 1.0}; + this.fieldAccessor = null; + } + + /** + * Constructs and immediately loads a transform for the field at the specified source. + * + * @param fieldSourceUri URI locating the field on disk (see class Javadoc for format). + * @param locationOffsets world coordinate of field sample (0, 0). + * @param scale world pixels per field sample along each axis. + * + * @throws IllegalArgumentException + * if the field cannot be loaded. + */ + public DisplacementFieldTransform(final String fieldSourceUri, + final double[] locationOffsets, + final double[] scale) + throws IllegalArgumentException { + this.fieldSourceUri = fieldSourceUri; + this.locationOffsets = locationOffsets; + this.scale = scale; + this.fieldAccessor = loadFieldAccessor(); + } + + @Override + public double[] apply(final double[] location) { + final double[] out = location.clone(); + applyInPlace(out); + return out; + } + + @Override + public void applyInPlace(final double[] location) { + + if (fieldAccessor == null) { + throw new IllegalStateException( + "displacement field has not been loaded; call init(String) before applying this transform"); + } + + final double[] fieldLocation = { + location[0] - locationOffsets[0], + location[1] - locationOffsets[1] + }; + + fieldAccessor.setPosition(fieldLocation); + final RealComposite displacement = fieldAccessor.get(); + + location[0] += displacement.get(0).getRealDouble(); + location[1] += displacement.get(1).getRealDouble(); + } + + /** + * Initializes this transform by parsing the field source URI and loading the field into an imglib2 image. + * + * @param data field source URI (see class Javadoc for format). + * + * @throws IllegalArgumentException + * if the data string cannot be parsed or the field cannot be loaded. + */ + @Override + public void init(final String data) throws IllegalArgumentException { + this.fieldSourceUri = data.trim(); + this.fieldAccessor = loadFieldAccessor(); + } + + @Override + public String toXML(final String indent) { + return indent + ""; + } + + @Override + public String toDataString() { + // The source URI already encodes the offsets and scale, so it round-trips through init unchanged. + return fieldSourceUri; + } + + @Override + public CoordinateTransform copy() { + // Re-loads the field so the copy has an independent accessor (imglib2 accessors are not thread-safe). + return new DisplacementFieldTransform(fieldSourceUri, + locationOffsets.clone(), + scale.clone()); + } + + @Override + public String toString() { + return "{ \"fieldSourceUri\": \"" + fieldSourceUri + + "\", \"locationOffsets\": [" + locationOffsets[0] + ", " + locationOffsets[1] + + "], \"scale\": [" + scale[0] + ", " + scale[1] + "] }"; + } + + /** + * Opens the configured field source into an imglib2 image and builds an interpolating accessor over it. + * + *

+ * The field is collapsed along its component axis, extended with a border, interpolated, and stretched over + * pixel space using {@link #scale} and {@link #locationOffsets} (the same pattern as + * {@link AffineWarpField#getAccessor}). The returned accessor is queried in field-local coordinates (i.e. + * after {@link #locationOffsets} have been subtracted from the world location). + *

+ * + * @return an accessor yielding the interpolated {@code (dx, dy)} vector at a field-local location. + * + * @throws IllegalArgumentException + * if the field cannot be opened or has an unexpected shape. + */ + private RealRandomAccess> loadFieldAccessor() + throws IllegalArgumentException { + + // TODO: this is just a mock-up; fill with actual logic + if (fieldSourceUri == null) { + throw new IllegalArgumentException("no field source URI defined"); + } + + this.locationOffsets = new double[] {0.0, 0.0}; + this.scale = new double[] {1.0, 1.0}; + + final RandomAccessibleInterval field = openField(fieldSourceUri); + + final int lastDimension = field.numDimensions() - 1; + if ((lastDimension < 2) || (field.dimension(lastDimension) != 2)) { + throw new IllegalArgumentException( + "displacement field must have a trailing component axis of length " + 2 + + " (dx, dy), but loaded field from '" + fieldSourceUri + "' has shape with last-axis length " + + field.dimension(lastDimension)); + } + + // Stretch the field grid across pixel space; shift by half a sample so samples sit at cell centers. + final double[] shift = { 0.5 * scale[0], 0.5 * scale[1] }; + final ScaleAndTranslation scaleAndTranslation = new ScaleAndTranslation(scale, shift); + + return RealViews.transform( + Views.interpolate( + Views.extendBorder(Views.collapseReal(field)), + getInterpolatorFactory() + ), + scaleAndTranslation + ).realRandomAccess(); + } + + /** + * Opens the field at the specified source into an imglib2 image. + */ + private static RandomAccessibleInterval openField(final String sourceUri) + throws IllegalArgumentException { + + // TODO: Replace or extend this with the loading logic appropriate for the field format(s) actually in use. + final URI uri; + try { + uri = new URI(sourceUri); + } catch (final URISyntaxException e) { + throw new IllegalArgumentException("invalid field source URI '" + sourceUri + "'", e); + } + + final String scheme = uri.getScheme(); + if ((scheme != null) && (! scheme.equals("file"))) { + // n5universe is not yet a dependency of render-app! + throw new IllegalArgumentException(scheme + " scheme not currently supported, must be a local file"); + } + + final String basePath = URLDecoder.decode(uri.getPath(), StandardCharsets.UTF_8); + final String dataset = "/"; + try (final N5Reader n5Reader = new N5FSReader(basePath)) { + return N5Utils.open(n5Reader, dataset); + } catch (final Exception e) { + throw new IllegalArgumentException( + "failed to open displacement field dataSet '" + dataset + "' in '" + basePath + "'", e); + } + } + + private static InterpolatorFactory, RandomAccessible>> getInterpolatorFactory() { + return new NLinearInterpolatorFactory<>(); + } +} diff --git a/render-app/src/test/java/org/janelia/alignment/transform/DisplacementFieldTransformTest.java b/render-app/src/test/java/org/janelia/alignment/transform/DisplacementFieldTransformTest.java new file mode 100644 index 000000000..ac934671d --- /dev/null +++ b/render-app/src/test/java/org/janelia/alignment/transform/DisplacementFieldTransformTest.java @@ -0,0 +1,48 @@ +package org.janelia.alignment.transform; + +import org.junit.Assert; +import org.junit.Test; + +import mpicbg.trakem2.transform.CoordinateTransform; + +/** + * Tests the {@link DisplacementFieldTransform} class. + */ +public class DisplacementFieldTransformTest { + + private static final String SAMPLE_URI = + "file:///tmp/does-not-exist.n5"; + + @Test + public void testDataStringRoundTrip() { + // init should fail to open the (nonexistent) field, but only after parsing the data string, + // so a real field is not needed to exercise the parse-and-serialize contract. + final DisplacementFieldTransform transform = new DisplacementFieldTransform(); + try { + transform.init(SAMPLE_URI); + Assert.fail("expected init to fail loading a nonexistent field"); + } catch (final IllegalArgumentException e) { + Assert.assertEquals("data string should round-trip even when loading fails", + SAMPLE_URI, transform.toDataString()); + } + } + + @Test + public void testApplyBeforeInitFails() { + final DisplacementFieldTransform transform = new DisplacementFieldTransform(); + try { + transform.applyInPlace(new double[] {0.0, 0.0}); + Assert.fail("expected applyInPlace to fail before the field is loaded"); + } catch (final IllegalStateException e) { + Assert.assertTrue("exception should mention initialization", + e.getMessage().contains("init")); + } + } + + @Test + public void testImplementsCoordinateTransform() { + // guards the reflective LeafTransformSpec.newInstance() contract (no-arg constructor + interface) + final CoordinateTransform transform = new DisplacementFieldTransform(); + Assert.assertNotNull("no-arg constructed instance should exist", transform); + } +} From dfaaf87830d830d1a309bab1d743b2597b84864e Mon Sep 17 00:00:00 2001 From: Stephan Preibisch Date: Thu, 16 Jul 2026 09:50:17 -0400 Subject: [PATCH 02/24] transfer logic for SOFIMA import from hot-knife --- .../transform/DisplacementFieldTransform.java | 118 +++++++++++++++++- 1 file changed, 112 insertions(+), 6 deletions(-) diff --git a/render-app/src/main/java/org/janelia/alignment/transform/DisplacementFieldTransform.java b/render-app/src/main/java/org/janelia/alignment/transform/DisplacementFieldTransform.java index d1c67b7a7..0ccb8fccb 100644 --- a/render-app/src/main/java/org/janelia/alignment/transform/DisplacementFieldTransform.java +++ b/render-app/src/main/java/org/janelia/alignment/transform/DisplacementFieldTransform.java @@ -4,23 +4,33 @@ import java.net.URISyntaxException; import java.net.URLDecoder; import java.nio.charset.StandardCharsets; +import java.util.Arrays; import mpicbg.trakem2.transform.CoordinateTransform; - +import net.imglib2.FinalInterval; +import net.imglib2.Interval; import net.imglib2.RandomAccessible; import net.imglib2.RandomAccessibleInterval; import net.imglib2.RealRandomAccess; +import net.imglib2.converter.Converters; import net.imglib2.interpolation.InterpolatorFactory; import net.imglib2.interpolation.randomaccess.NLinearInterpolatorFactory; +import net.imglib2.realtransform.AffineGet; +import net.imglib2.realtransform.AffineRandomAccessible; import net.imglib2.realtransform.RealViews; +import net.imglib2.realtransform.Scale; import net.imglib2.realtransform.ScaleAndTranslation; import net.imglib2.type.numeric.real.DoubleType; +import net.imglib2.type.numeric.real.FloatType; +import net.imglib2.util.Util; import net.imglib2.view.Views; import net.imglib2.view.composite.RealComposite; import org.janelia.saalfeldlab.n5.N5FSReader; import org.janelia.saalfeldlab.n5.N5Reader; import org.janelia.saalfeldlab.n5.imglib2.N5Utils; +import org.janelia.saalfeldlab.n5.universe.N5Factory; +import org.janelia.saalfeldlab.n5.universe.N5Factory.StorageFormat; /** * Transform that reads a dense displacement (translation vector) field from a file on disk and adds the @@ -34,7 +44,10 @@ public class DisplacementFieldTransform implements CoordinateTransform { /** URI (as supplied to {@link #init}) identifying the field on disk and its world-coordinate mapping. */ - private String fieldSourceUri; + private String sofimaField; + + /** The scale index at which the deformed images were fed to SOFIMA, needed for vector size adjustment */ + private int scaleIndexSOFIMAinput; /** World coordinate of field sample (0, 0); subtracted from a location before querying the field. */ private double[] locationOffsets; @@ -49,7 +62,8 @@ public class DisplacementFieldTransform * Reflection constructor; leaves the instance uninitialized until {@link #init(String)} is called. */ public DisplacementFieldTransform() { - this.fieldSourceUri = null; + this.sofimaField = null; + this.scaleIndexSOFIMAinput = 0; this.locationOffsets = new double[] {0.0, 0.0}; this.scale = new double[] {1.0, 1.0}; this.fieldAccessor = null; @@ -58,23 +72,115 @@ public DisplacementFieldTransform() { /** * Constructs and immediately loads a transform for the field at the specified source. * - * @param fieldSourceUri URI locating the field on disk (see class Javadoc for format). + * @param sofimaField URI locating the field on disk (see class Javadoc for format). + * @param scaleIndexSOFIMAinput The scale index at which the deformed images were fed to SOFIMA, needed for vector size adjustment * @param locationOffsets world coordinate of field sample (0, 0). * @param scale world pixels per field sample along each axis. * * @throws IllegalArgumentException * if the field cannot be loaded. */ - public DisplacementFieldTransform(final String fieldSourceUri, + public DisplacementFieldTransform(final String sofimaField, + final int scaleIndexSOFIMAinput, + final int sofimaZindex, // which slice? + final int[] fullResSize, final double[] locationOffsets, final double[] scale) throws IllegalArgumentException { - this.fieldSourceUri = fieldSourceUri; + this.sofimaField = sofimaField; this.locationOffsets = locationOffsets; this.scale = scale; this.fieldAccessor = loadFieldAccessor(); + + // has to go to init() + final N5Reader sofimaContainer = new N5Factory().openReader( StorageFormat.N5, sofimaField ); + + // + // load the SOFIMA relative deformation field and scale it + // + + // XY axes are flipped compared to python (N5 solves that already) + // still, first slice are X vectors, 2nd slice are Y vectors + final RandomAccessibleInterval< DoubleType > sofimaRaw = N5Utils.open( sofimaContainer, "/" ); + + System.out.println( Util.printInterval( sofimaRaw )); + //System.exit( 0 ); + + // Note: the SOFIMA field can contain NaN's, could be in FloatType from the start + final RandomAccessibleInterval< DoubleType > sofima = Converters.convertRAI( + (RandomAccessibleInterval< FloatType >)(RandomAccessibleInterval)sofimaRaw, // michal's field is actually float + (i,o) -> o.set( Double.isNaN( i.getRealDouble() ) ? 0 : i.getRealDouble() ), // maybe interpolate/inpaint? + new DoubleType() ); + + // Michal's field is 4D, [2342, 2374, 2, 91]; the ZARR to N5 conversion mixed up Z and C, now [X,Y,C,Z] + System.out.println( "dimensions of SOFIMA deformation field: " + Arrays.toString( sofima.dimensionsAsLongArray() ) ); + + final Interval fullRes2dInterval = new FinalInterval( fullResSize[ 0 ], fullResSize[ 1 ] ); + final Interval sofima2DInterval = new FinalInterval( sofima.dimension( 0 ), sofima.dimension( 1 ) ); + + // we have to now convert this transformation to full resolution + final double[] scalingFactorSofima = scalingFactor( fullRes2dInterval, sofima2DInterval ); + + System.out.println( "scalingFactorSofima: " + Arrays.toString( scalingFactorSofima ) ); + + // the vectors are scaled relative to the input image size, i.e. we need to know at which factor the images + // that were fed into SOFIMA were scaled + final double sofimaBaseScale = 1.0 / (1 << scaleIndexSOFIMAinput ); + + final AffineRandomAccessible transformedSofimaX, transformedSofimaY; + + // TODO: this is a rough approximation, need to handle this properly (right now x and y factor is slightly different) + transformedSofimaX = RealViews.affine( + Views.interpolate( + Views.extendMirrorDouble( Views.hyperSlice( Views.hyperSlice( sofima, 3, sofimaZindex), 2, 0 ) ), + new NLinearInterpolatorFactory<>()), + new Scale( scalingFactorSofima ) ); + + // TODO: this is a rough approximation, need to handle this properly (right now x and y factor is slightly different) + transformedSofimaY = RealViews.affine( + Views.interpolate( + Views.extendMirrorDouble( Views.hyperSlice( Views.hyperSlice( sofima, 3, sofimaZindex), 2, 1 ) ), + new NLinearInterpolatorFactory<>()), + new Scale( scalingFactorSofima ) ); + + // FROM HOT-KNIFE + // + // we need to adjust the sofima vectors for the original scale of the images and the scale of the hot-knife field + // + // The SOFIMA vectors have the same size, no matter with which stride they were computed, + // so they must be in the size of the input images fed to SOFIMA + // + // Saalfeld's absolute transformation fields store the vectors in the scale the transformation fields + // are stored in. E.g. at scale 0.03125 a value that is 2400, will be 4800 at scale 0.0625 + // + // Next topic, values: + // SOFIMA imports e.g. 343, 516 X=1.3092;Y=7.3169 (positive means move up) + // SOFIMA x positive means move left + + // needs to be returned/exposed + RandomAccessible fullResX = Converters.convert( + (RandomAccessible)transformedSofimaX, + (i,o) -> o.set( i.get() / sofimaBaseScale ), // maybe needs sign flip, maybe need to switch X and Y + new DoubleType() ); + + // needs to be returned/exposed + RandomAccessible fullResY = Converters.convert( + (RandomAccessible)transformedSofimaY, + (i,o) -> o.set( i.get() / sofimaBaseScale ), // maybe needs sign flip, maybe need to switch X and Y + new DoubleType() ); + } + public static double[] scalingFactor( final Interval a, Interval b ) + { + final double[] s = new double[ a.numDimensions() ]; + + for ( int d = 0; d < a.numDimensions(); ++d ) + s[ d ] = (double) a.dimension( d ) / (double) b.dimension( d ); + + return s; + } + @Override public double[] apply(final double[] location) { final double[] out = location.clone(); From de9396270b8d20e0c10f9b2f69d4f5e2a7547950 Mon Sep 17 00:00:00 2001 From: Michael Innerberger Date: Fri, 17 Jul 2026 13:22:06 -0400 Subject: [PATCH 03/24] Add N5 universe to dependencies to render-app Displacement field needs to open N5/zarr datasets, possibly on a cloud --- render-app/pom.xml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/render-app/pom.xml b/render-app/pom.xml index f1a5aca3b..276cfa7a1 100644 --- a/render-app/pom.xml +++ b/render-app/pom.xml @@ -164,6 +164,13 @@ n5-hdf5 + + + org.janelia.saalfeldlab + n5-universe + + com.fasterxml.jackson.core From 2e03bb024cd06648640cdc84aab4f8fc2623d323 Mon Sep 17 00:00:00 2001 From: Michael Innerberger Date: Fri, 17 Jul 2026 13:39:34 -0400 Subject: [PATCH 04/24] Clean up and abstract implementation of displacement field --- .../transform/DisplacementFieldTransform.java | 385 +++++++----------- 1 file changed, 154 insertions(+), 231 deletions(-) diff --git a/render-app/src/main/java/org/janelia/alignment/transform/DisplacementFieldTransform.java b/render-app/src/main/java/org/janelia/alignment/transform/DisplacementFieldTransform.java index 0ccb8fccb..1511a044e 100644 --- a/render-app/src/main/java/org/janelia/alignment/transform/DisplacementFieldTransform.java +++ b/render-app/src/main/java/org/janelia/alignment/transform/DisplacementFieldTransform.java @@ -1,184 +1,125 @@ package org.janelia.alignment.transform; -import java.net.URI; -import java.net.URISyntaxException; -import java.net.URLDecoder; -import java.nio.charset.StandardCharsets; -import java.util.Arrays; - import mpicbg.trakem2.transform.CoordinateTransform; -import net.imglib2.FinalInterval; -import net.imglib2.Interval; -import net.imglib2.RandomAccessible; import net.imglib2.RandomAccessibleInterval; import net.imglib2.RealRandomAccess; +import net.imglib2.RealRandomAccessible; import net.imglib2.converter.Converters; -import net.imglib2.interpolation.InterpolatorFactory; import net.imglib2.interpolation.randomaccess.NLinearInterpolatorFactory; -import net.imglib2.realtransform.AffineGet; -import net.imglib2.realtransform.AffineRandomAccessible; import net.imglib2.realtransform.RealViews; import net.imglib2.realtransform.Scale; -import net.imglib2.realtransform.ScaleAndTranslation; -import net.imglib2.type.numeric.real.DoubleType; import net.imglib2.type.numeric.real.FloatType; -import net.imglib2.util.Util; import net.imglib2.view.Views; -import net.imglib2.view.composite.RealComposite; - -import org.janelia.saalfeldlab.n5.N5FSReader; import org.janelia.saalfeldlab.n5.N5Reader; import org.janelia.saalfeldlab.n5.imglib2.N5Utils; import org.janelia.saalfeldlab.n5.universe.N5Factory; import org.janelia.saalfeldlab.n5.universe.N5Factory.StorageFormat; +import java.util.HashMap; +import java.util.Map; + + /** * Transform that reads a dense displacement (translation vector) field from a file on disk and adds the * interpolated vector at each queried location to that location. - *

- * TODO: this is a scaffold. The {@link #loadFieldAccessor} method currently assumes an N5/HDF5-style container - * openable with {@link N5Utils}; extend it (or the source parsing) to support whatever field formats are needed. - *

*/ public class DisplacementFieldTransform implements CoordinateTransform { /** URI (as supplied to {@link #init}) identifying the field on disk and its world-coordinate mapping. */ - private String sofimaField; - - /** The scale index at which the deformed images were fed to SOFIMA, needed for vector size adjustment */ - private int scaleIndexSOFIMAinput; - - /** World coordinate of field sample (0, 0); subtracted from a location before querying the field. */ - private double[] locationOffsets; - - /** World pixels spanned by one field sample along each axis; used to stretch the field over pixel space. */ - private double[] scale; + private String fieldSourceUri; + private double[] xyScale; + private int fieldScaleIndex; + private int fieldZIndex; // ImgLib2 accessor for the displacement field; null until a field source has been loaded. - private transient RealRandomAccess> fieldAccessor; + private RealRandomAccess displacementX; + private RealRandomAccess displacementY; /** * Reflection constructor; leaves the instance uninitialized until {@link #init(String)} is called. */ public DisplacementFieldTransform() { - this.sofimaField = null; - this.scaleIndexSOFIMAinput = 0; - this.locationOffsets = new double[] {0.0, 0.0}; - this.scale = new double[] {1.0, 1.0}; - this.fieldAccessor = null; + this.fieldSourceUri = null; + this.xyScale = null; + this.fieldScaleIndex = -1; + this.fieldZIndex = -1; + + this.displacementX = null; + this.displacementY = null; } /** * Constructs and immediately loads a transform for the field at the specified source. * - * @param sofimaField URI locating the field on disk (see class Javadoc for format). - * @param scaleIndexSOFIMAinput The scale index at which the deformed images were fed to SOFIMA, needed for vector size adjustment - * @param locationOffsets world coordinate of field sample (0, 0). - * @param scale world pixels per field sample along each axis. + * @param fieldSourceUri URI locating the field on disk (see class Javadoc for format). + * @param fieldScaleIndex The scale index at which the deformed images were fed to SOFIMA, needed for vector size adjustment + * @param fieldZIndex The z-slice index of the field to use (the field may be 3D, but this transform is 2D) + * @param xyScale The scale of the field in x and y, needed for scaling the field to full resolution * * @throws IllegalArgumentException * if the field cannot be loaded. */ - public DisplacementFieldTransform(final String sofimaField, - final int scaleIndexSOFIMAinput, - final int sofimaZindex, // which slice? - final int[] fullResSize, - final double[] locationOffsets, - final double[] scale) - throws IllegalArgumentException { - this.sofimaField = sofimaField; - this.locationOffsets = locationOffsets; - this.scale = scale; - this.fieldAccessor = loadFieldAccessor(); - - // has to go to init() - final N5Reader sofimaContainer = new N5Factory().openReader( StorageFormat.N5, sofimaField ); - - // - // load the SOFIMA relative deformation field and scale it - // - - // XY axes are flipped compared to python (N5 solves that already) - // still, first slice are X vectors, 2nd slice are Y vectors - final RandomAccessibleInterval< DoubleType > sofimaRaw = N5Utils.open( sofimaContainer, "/" ); - - System.out.println( Util.printInterval( sofimaRaw )); - //System.exit( 0 ); - - // Note: the SOFIMA field can contain NaN's, could be in FloatType from the start - final RandomAccessibleInterval< DoubleType > sofima = Converters.convertRAI( - (RandomAccessibleInterval< FloatType >)(RandomAccessibleInterval)sofimaRaw, // michal's field is actually float - (i,o) -> o.set( Double.isNaN( i.getRealDouble() ) ? 0 : i.getRealDouble() ), // maybe interpolate/inpaint? - new DoubleType() ); - - // Michal's field is 4D, [2342, 2374, 2, 91]; the ZARR to N5 conversion mixed up Z and C, now [X,Y,C,Z] - System.out.println( "dimensions of SOFIMA deformation field: " + Arrays.toString( sofima.dimensionsAsLongArray() ) ); - - final Interval fullRes2dInterval = new FinalInterval( fullResSize[ 0 ], fullResSize[ 1 ] ); - final Interval sofima2DInterval = new FinalInterval( sofima.dimension( 0 ), sofima.dimension( 1 ) ); - - // we have to now convert this transformation to full resolution - final double[] scalingFactorSofima = scalingFactor( fullRes2dInterval, sofima2DInterval ); - - System.out.println( "scalingFactorSofima: " + Arrays.toString( scalingFactorSofima ) ); - - // the vectors are scaled relative to the input image size, i.e. we need to know at which factor the images - // that were fed into SOFIMA were scaled - final double sofimaBaseScale = 1.0 / (1 << scaleIndexSOFIMAinput ); - - final AffineRandomAccessible transformedSofimaX, transformedSofimaY; - - // TODO: this is a rough approximation, need to handle this properly (right now x and y factor is slightly different) - transformedSofimaX = RealViews.affine( - Views.interpolate( - Views.extendMirrorDouble( Views.hyperSlice( Views.hyperSlice( sofima, 3, sofimaZindex), 2, 0 ) ), - new NLinearInterpolatorFactory<>()), - new Scale( scalingFactorSofima ) ); - - // TODO: this is a rough approximation, need to handle this properly (right now x and y factor is slightly different) - transformedSofimaY = RealViews.affine( - Views.interpolate( - Views.extendMirrorDouble( Views.hyperSlice( Views.hyperSlice( sofima, 3, sofimaZindex), 2, 1 ) ), - new NLinearInterpolatorFactory<>()), - new Scale( scalingFactorSofima ) ); - - // FROM HOT-KNIFE - // - // we need to adjust the sofima vectors for the original scale of the images and the scale of the hot-knife field - // - // The SOFIMA vectors have the same size, no matter with which stride they were computed, - // so they must be in the size of the input images fed to SOFIMA - // - // Saalfeld's absolute transformation fields store the vectors in the scale the transformation fields - // are stored in. E.g. at scale 0.03125 a value that is 2400, will be 4800 at scale 0.0625 - // - // Next topic, values: - // SOFIMA imports e.g. 343, 516 X=1.3092;Y=7.3169 (positive means move up) - // SOFIMA x positive means move left - - // needs to be returned/exposed - RandomAccessible fullResX = Converters.convert( - (RandomAccessible)transformedSofimaX, - (i,o) -> o.set( i.get() / sofimaBaseScale ), // maybe needs sign flip, maybe need to switch X and Y - new DoubleType() ); - - // needs to be returned/exposed - RandomAccessible fullResY = Converters.convert( - (RandomAccessible)transformedSofimaY, - (i,o) -> o.set( i.get() / sofimaBaseScale ), // maybe needs sign flip, maybe need to switch X and Y - new DoubleType() ); - + public DisplacementFieldTransform(final String fieldSourceUri, + final int fieldScaleIndex, + final int fieldZIndex, + final double[] xyScale) { + this.init(fieldSourceUri, xyScale, fieldScaleIndex, fieldZIndex); } - public static double[] scalingFactor( final Interval a, Interval b ) - { - final double[] s = new double[ a.numDimensions() ]; - - for ( int d = 0; d < a.numDimensions(); ++d ) - s[ d ] = (double) a.dimension( d ) / (double) b.dimension( d ); + private void init(final String fieldSourceUri, + final double[] xyScale, + final int fieldScaleIndex, + final int fieldZIndex) { + this.fieldSourceUri = fieldSourceUri; + this.xyScale = xyScale; + this.fieldScaleIndex = fieldScaleIndex; + this.fieldZIndex = fieldZIndex; + + /* Load displacement field. Currently, this is tailored to output from SOFIMA for multi-sem acquisitions. + * - Layout is [X,Y,C,Z]; C=0 is X vectors, C=1 is Y vectors + * - XY axes are flipped compared to python due to N5 convention + */ + final N5Reader fieldReader = new N5Factory().openReader(StorageFormat.N5, fieldSourceUri); + final RandomAccessibleInterval fieldRaw = N5Utils.open(fieldReader, "/"); + + displacementX = extractAndTransform(fieldRaw, 0); + displacementY = extractAndTransform(fieldRaw, 1); + } - return s; + /** + * Currently, this is tailored to output from SOFIMA for multi-sem acquisitions. The following code and comments + * are from hot-knife. + * - We need to adjust the sofima vectors for the original scale of the images and the scale of the hot-knife field + * - The SOFIMA vectors have the same size, no matter with which stride they were computed, so they must be in the size of the input images fed to SOFIMA + * - Saalfeld's absolute transformation fields store the vectors in the scale the transformation fields are stored in. E.g. at scale 0.03125 a value that is 2400, will be 4800 at scale 0.0625 + * - Positive y means move up, positive x means move left + */ + private RealRandomAccess extractAndTransform(final RandomAccessibleInterval rawField, + final int xory) { + // The deformation field can contain NaNs, replace them with zeros + // Do this up front to not interpolate NaNs + final RandomAccessibleInterval cleaned = Converters.convertRAI( + rawField, + (i, o) -> o.set(Float.isNaN(i.getRealFloat()) ? 0 : i.getRealFloat()), + new FloatType()); + + // Slice the dataset: choose right z-slice (dim=3) and then choose between x or y (dim=2) + final RandomAccessibleInterval slice = Views.hyperSlice( + Views.hyperSlice(cleaned, 3, this.fieldZIndex), 2, xory); + + // Scale and interpolate the slice to full resolution + final RealRandomAccessible scaledAndInterpolated = RealViews.affine( + Views.interpolate(Views.extendMirrorDouble(slice), new NLinearInterpolatorFactory<>()), + new Scale(this.xyScale)); + + // Scale the deformation vectors to account for the scale of the images that they were computed on + // Do this last to reduce the number of scaling operations + final float vectorScale = 1.0f / (1 << this.fieldScaleIndex); + return Converters.convert( + scaledAndInterpolated, + (i, o) -> o.set(i.getRealFloat() / vectorScale), + new FloatType()).realRandomAccess(); } @Override @@ -191,35 +132,50 @@ public double[] apply(final double[] location) { @Override public void applyInPlace(final double[] location) { - if (fieldAccessor == null) { + if (displacementX == null || displacementY == null) { throw new IllegalStateException( "displacement field has not been loaded; call init(String) before applying this transform"); } - final double[] fieldLocation = { - location[0] - locationOffsets[0], - location[1] - locationOffsets[1] - }; - - fieldAccessor.setPosition(fieldLocation); - final RealComposite displacement = fieldAccessor.get(); - - location[0] += displacement.get(0).getRealDouble(); - location[1] += displacement.get(1).getRealDouble(); + // Query both components at the original (undisplaced) location before mutating it. + final double dx = displacementX.setPositionAndGet(location).getRealDouble(); + final double dy = displacementY.setPositionAndGet(location).getRealDouble(); + location[0] += dx; + location[1] += dy; } /** - * Initializes this transform by parsing the field source URI and loading the field into an imglib2 image. + * Initializes this transform by parsing the data string and loading the field into an imglib2 image. + *

+ * The data string is the field source URI followed by {@code ?key=value} query parameters, e.g. + * {@code file:///path/to/field.n5?scaleIndex=3&zIndex=5&scaleX=8.0&scaleY=8.0}. The portion before the + * {@code ?} becomes the {@link #fieldSourceUri} (the actual path); the query parameters supply the + * remaining fields. * - * @param data field source URI (see class Javadoc for format). + * @param data field source URI with query parameters (see above). * * @throws IllegalArgumentException * if the data string cannot be parsed or the field cannot be loaded. */ @Override public void init(final String data) throws IllegalArgumentException { - this.fieldSourceUri = data.trim(); - this.fieldAccessor = loadFieldAccessor(); + + final String trimmed = data.trim(); + final int queryStart = trimmed.indexOf('?'); + if (queryStart < 0) { + throw new IllegalArgumentException( + "transform data must be a field source URI followed by " + + "'?scaleIndex=&zIndex=&scaleX=&scaleY=', but was '" + data + "'"); + } + + final String parsedSourceUri = trimmed.substring(0, queryStart); + final Map params = parseQueryParameters(trimmed.substring(queryStart + 1), data); + + init(parsedSourceUri, + new double[] { parseDoubleParameter(params, "scaleX", data), + parseDoubleParameter(params, "scaleY", data) }, + parseIntParameter(params, "scaleIndex", data), + parseIntParameter(params, "zIndex", data)); } @Override @@ -230,105 +186,72 @@ public String toXML(final String indent) { @Override public String toDataString() { - // The source URI already encodes the offsets and scale, so it round-trips through init unchanged. - return fieldSourceUri; + // Serializes all fields as a source URI plus query parameters so the string round-trips through init. + return fieldSourceUri + + "?scaleIndex=" + fieldScaleIndex + + "&zIndex=" + fieldZIndex + + "&scaleX=" + xyScale[0] + + "&scaleY=" + xyScale[1]; } @Override public CoordinateTransform copy() { - // Re-loads the field so the copy has an independent accessor (imglib2 accessors are not thread-safe). - return new DisplacementFieldTransform(fieldSourceUri, - locationOffsets.clone(), - scale.clone()); + // Re-loads the field so the copy has independent accessors (imglib2 accessors are not thread-safe). + return new DisplacementFieldTransform(fieldSourceUri, fieldScaleIndex, fieldZIndex, xyScale.clone()); } @Override public String toString() { return "{ \"fieldSourceUri\": \"" + fieldSourceUri + - "\", \"locationOffsets\": [" + locationOffsets[0] + ", " + locationOffsets[1] + - "], \"scale\": [" + scale[0] + ", " + scale[1] + "] }"; + "\", \"fieldScaleIndex\": " + fieldScaleIndex + + ", \"fieldZIndex\": " + fieldZIndex + + ", \"xyScale\": [" + xyScale[0] + ", " + xyScale[1] + "] }"; } - /** - * Opens the configured field source into an imglib2 image and builds an interpolating accessor over it. - * - *

- * The field is collapsed along its component axis, extended with a border, interpolated, and stretched over - * pixel space using {@link #scale} and {@link #locationOffsets} (the same pattern as - * {@link AffineWarpField#getAccessor}). The returned accessor is queried in field-local coordinates (i.e. - * after {@link #locationOffsets} have been subtracted from the world location). - *

- * - * @return an accessor yielding the interpolated {@code (dx, dy)} vector at a field-local location. - * - * @throws IllegalArgumentException - * if the field cannot be opened or has an unexpected shape. - */ - private RealRandomAccess> loadFieldAccessor() - throws IllegalArgumentException { - - // TODO: this is just a mock-up; fill with actual logic - if (fieldSourceUri == null) { - throw new IllegalArgumentException("no field source URI defined"); + private static Map parseQueryParameters(final String query, final String data) { + final Map params = new HashMap<>(); + for (final String pair : query.split("&")) { + if (pair.isEmpty()) { + continue; + } + final int eq = pair.indexOf('='); + if (eq < 0) { + throw new IllegalArgumentException( + "invalid query parameter '" + pair + "' in transform data '" + data + "'"); + } + params.put(pair.substring(0, eq), pair.substring(eq + 1)); } + return params; + } - this.locationOffsets = new double[] {0.0, 0.0}; - this.scale = new double[] {1.0, 1.0}; - - final RandomAccessibleInterval field = openField(fieldSourceUri); - - final int lastDimension = field.numDimensions() - 1; - if ((lastDimension < 2) || (field.dimension(lastDimension) != 2)) { + private static int parseIntParameter(final Map params, final String key, final String data) { + final String value = requireParameter(params, key, data); + try { + return Integer.parseInt(value); + } catch (final NumberFormatException e) { throw new IllegalArgumentException( - "displacement field must have a trailing component axis of length " + 2 + - " (dx, dy), but loaded field from '" + fieldSourceUri + "' has shape with last-axis length " + - field.dimension(lastDimension)); + "invalid integer value '" + value + "' for parameter '" + key + + "' in transform data '" + data + "'", e); } - - // Stretch the field grid across pixel space; shift by half a sample so samples sit at cell centers. - final double[] shift = { 0.5 * scale[0], 0.5 * scale[1] }; - final ScaleAndTranslation scaleAndTranslation = new ScaleAndTranslation(scale, shift); - - return RealViews.transform( - Views.interpolate( - Views.extendBorder(Views.collapseReal(field)), - getInterpolatorFactory() - ), - scaleAndTranslation - ).realRandomAccess(); } - /** - * Opens the field at the specified source into an imglib2 image. - */ - private static RandomAccessibleInterval openField(final String sourceUri) - throws IllegalArgumentException { - - // TODO: Replace or extend this with the loading logic appropriate for the field format(s) actually in use. - final URI uri; + private static double parseDoubleParameter(final Map params, final String key, final String data) { + final String value = requireParameter(params, key, data); try { - uri = new URI(sourceUri); - } catch (final URISyntaxException e) { - throw new IllegalArgumentException("invalid field source URI '" + sourceUri + "'", e); - } - - final String scheme = uri.getScheme(); - if ((scheme != null) && (! scheme.equals("file"))) { - // n5universe is not yet a dependency of render-app! - throw new IllegalArgumentException(scheme + " scheme not currently supported, must be a local file"); - } - - final String basePath = URLDecoder.decode(uri.getPath(), StandardCharsets.UTF_8); - final String dataset = "/"; - try (final N5Reader n5Reader = new N5FSReader(basePath)) { - return N5Utils.open(n5Reader, dataset); - } catch (final Exception e) { + return Double.parseDouble(value); + } catch (final NumberFormatException e) { throw new IllegalArgumentException( - "failed to open displacement field dataSet '" + dataset + "' in '" + basePath + "'", e); + "invalid double value '" + value + "' for parameter '" + key + + "' in transform data '" + data + "'", e); } } - private static InterpolatorFactory, RandomAccessible>> getInterpolatorFactory() { - return new NLinearInterpolatorFactory<>(); + private static String requireParameter(final Map params, final String key, final String data) { + final String value = params.get(key); + if (value == null) { + throw new IllegalArgumentException( + "missing required parameter '" + key + "' in transform data '" + data + "'"); + } + return value; } } From cbef28c372362c773c365d41a0d742bfb070aad7 Mon Sep 17 00:00:00 2001 From: Michael Innerberger Date: Fri, 17 Jul 2026 18:18:40 -0400 Subject: [PATCH 05/24] Add first version of sofima importer --- .../client/multisem/ImportSofimaClient.java | 178 ++++++++++++++++++ 1 file changed, 178 insertions(+) create mode 100644 render-ws-java-client/src/main/java/org/janelia/render/client/multisem/ImportSofimaClient.java diff --git a/render-ws-java-client/src/main/java/org/janelia/render/client/multisem/ImportSofimaClient.java b/render-ws-java-client/src/main/java/org/janelia/render/client/multisem/ImportSofimaClient.java new file mode 100644 index 000000000..cba5fad8c --- /dev/null +++ b/render-ws-java-client/src/main/java/org/janelia/render/client/multisem/ImportSofimaClient.java @@ -0,0 +1,178 @@ +package org.janelia.render.client.multisem; + +import com.beust.jcommander.Parameter; +import com.beust.jcommander.ParametersDelegate; + +import java.io.IOException; +import java.util.Arrays; +import java.util.List; + +import org.janelia.alignment.spec.Bounds; +import org.janelia.alignment.spec.LeafTransformSpec; +import org.janelia.alignment.spec.ResolvedTileSpecCollection; +import org.janelia.alignment.spec.TileSpec; +import org.janelia.alignment.spec.stack.StackMetaData; +import org.janelia.alignment.transform.DisplacementFieldTransform; +import org.janelia.render.client.ClientRunner; +import org.janelia.render.client.RenderDataClient; +import org.janelia.render.client.parameter.CommandLineParameters; +import org.janelia.render.client.parameter.RenderWebServiceParameters; +import org.janelia.render.client.parameter.ZRangeParameters; +import org.janelia.saalfeldlab.n5.N5Reader; +import org.janelia.saalfeldlab.n5.universe.N5Factory; +import org.janelia.saalfeldlab.n5.universe.N5Factory.StorageFormat; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Adds a {@link DisplacementFieldTransform} to every tile spec of a stack, layer by layer. + *

+ * The (dense) displacement field is currently expected to be SOFIMA output for multi-SEM acquisitions, stored as a + * 4D {@code [X,Y,C,Z]} N5 dataset with one z-slice per stack layer. For each layer, this client + *

    + *
  • computes {@code fieldZIndex} from the {@code --zOffset} parameter and the running (0-based) layer index,
  • + *
  • computes the field-to-full-resolution {@code xyScale} from the stack bounds and the field's XY dimensions,
  • + *
  • appends a {@link DisplacementFieldTransform} with the resulting data string to each tile spec, and
  • + *
  • saves the modified tile specs to the target stack.
  • + *
+ * If {@code --targetStack} is given, the modified tile specs are written there (the stack is derived from the source + * if it does not yet exist); otherwise they are written back into the source stack. If {@code --completeStack} is set, + * the target stack is completed once all layers have been saved. + * + * @author Michael Innerberger + */ +public class ImportSofimaClient { + + private final Parameters params; + private final RenderDataClient renderClient; + + public static class Parameters extends CommandLineParameters { + @ParametersDelegate + private final RenderWebServiceParameters renderParams = new RenderWebServiceParameters(); + @ParametersDelegate + private final ZRangeParameters zRangeParams = new ZRangeParameters(); + @Parameter(names = "--stack", description = "Source stack to which the displacement field is added", required = true) + private String stack; + @Parameter(names = "--targetStack", description = "Stack to save modified tile specs to (defaults to the source stack)") + private String targetStack; + @Parameter(names = "--sofimaFieldUri", description = "URI of the SOFIMA displacement field N5 container", required = true) + private String sofimaFieldUri; + @Parameter(names = "--sofimaScaleIndex", description = "Scale index at which the deformed images were fed to SOFIMA (used to adjust vector sizes)", required = true) + private int sofimaScaleIndex; + @Parameter(names = "--zOffset", description = "Offset added to the running (0-based) layer index to obtain the field's z-slice index (default: 0)") + private int zOffset = 0; + @Parameter(names = "--completeStack", description = "Complete the target stack after all layers have been saved") + private boolean completeStack = false; + + public String getTargetStack() { + return (targetStack == null) ? stack : targetStack; + } + } + + public static void main(final String[] args) { + final ClientRunner clientRunner = new ClientRunner(args) { + @Override + public void runClient(final String[] args) throws Exception { + final Parameters parameters = new Parameters(); + parameters.parse(args); + LOG.info("runClient: entry, parameters={}", parameters); + + final ImportSofimaClient client = new ImportSofimaClient(parameters); + client.addDisplacementField(); + } + }; + clientRunner.run(); + } + + public ImportSofimaClient(final Parameters parameters) { + this.params = parameters; + this.renderClient = new RenderDataClient(parameters.renderParams.baseDataUrl, + parameters.renderParams.owner, + parameters.renderParams.project); + } + + public void addDisplacementField() throws Exception { + + final StackMetaData sourceStackMetaData = renderClient.getStackMetaData(params.stack); + + // Get full-resolution stack size and the field's XY size to scale the field to full resolution + final double[] xyScale; + try (final N5Reader fieldReader = new N5Factory().openReader(StorageFormat.N5, params.sofimaFieldUri)) { + + final Bounds stackBounds = sourceStackMetaData.getStats().getStackBounds(); + final long[] fieldDimensions = fieldReader.getDatasetAttributes("/").getDimensions(); + + xyScale = new double[]{ + stackBounds.getDeltaX() / fieldDimensions[0], + stackBounds.getDeltaY() / fieldDimensions[1] + }; + LOG.info("addDisplacementField: stack bounds are {}, field bounds are {}", stackBounds, Arrays.toString(fieldDimensions)); + LOG.info("addDisplacementField: xy scales are {}", Arrays.toString(xyScale)); + } catch (final Exception e) { + throw new IllegalArgumentException("Failed to process SOFIMA field at " + params.sofimaFieldUri, e); + } + + // Set up the target stack + final String targetStack = params.getTargetStack(); + if (! targetStack.equals(params.stack)) { + renderClient.setupDerivedStack(sourceStackMetaData, targetStack); + } else { + renderClient.ensureStackIsInLoadingState(targetStack, sourceStackMetaData); + } + + // Get and process all z values + final List zValues = renderClient.getStackZValues(params.stack, + params.zRangeParams.minZ, + params.zRangeParams.maxZ); + LOG.info("addDisplacementField: processing {} layers", zValues.size()); + + for (int layerIndex = 0; layerIndex < zValues.size(); layerIndex++) { + final Double z = zValues.get(layerIndex); + final int fieldZIndex = params.zOffset + layerIndex; + final String dataString = buildDataString(fieldZIndex, xyScale); + + addFieldToLayer(z, dataString, targetStack); + } + + // Complete the target stack + if (params.completeStack) { + LOG.info("addDisplacementField: completing stack {}", targetStack); + renderClient.setStackState(targetStack, StackMetaData.StackState.COMPLETE); + } + + LOG.info("addDisplacementField: exit"); + } + + private void addFieldToLayer(final Double z, + final String dataString, + final String targetStack) + throws IOException { + + final ResolvedTileSpecCollection tileSpecs = renderClient.getResolvedTiles(params.stack, z); + + for (final TileSpec tileSpec : tileSpecs.getTileSpecs()) { + final LeafTransformSpec transformSpec = + new LeafTransformSpec(DisplacementFieldTransform.class.getName(), dataString); + tileSpec.addTransformSpecs(List.of(transformSpec)); + tileSpec.deriveBoundingBox(tileSpec.getMeshCellSize(), true); + } + + renderClient.saveResolvedTiles(tileSpecs, targetStack, z); + } + + /** + * Compiles the {@link DisplacementFieldTransform} data string for one layer. The format must match what + * {@link DisplacementFieldTransform#init(String)} parses (and {@link DisplacementFieldTransform#toDataString()} + * produces). + */ + private String buildDataString(final int fieldZIndex, + final double[] xyScale) { + return params.sofimaFieldUri + + "?scaleIndex=" + params.sofimaScaleIndex + + "&zIndex=" + fieldZIndex + + "&scaleX=" + xyScale[0] + + "&scaleY=" + xyScale[1]; + } + + private static final Logger LOG = LoggerFactory.getLogger(ImportSofimaClient.class); +} From 5928c0f5fb2e602e2f6c5ded5f60db9b3f57d6f3 Mon Sep 17 00:00:00 2001 From: Michael Innerberger Date: Wed, 22 Jul 2026 09:53:31 -0400 Subject: [PATCH 06/24] Rename completeStack to completeTargetStack --- .../render/client/multisem/ImportSofimaClient.java | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/render-ws-java-client/src/main/java/org/janelia/render/client/multisem/ImportSofimaClient.java b/render-ws-java-client/src/main/java/org/janelia/render/client/multisem/ImportSofimaClient.java index cba5fad8c..0bf1fd636 100644 --- a/render-ws-java-client/src/main/java/org/janelia/render/client/multisem/ImportSofimaClient.java +++ b/render-ws-java-client/src/main/java/org/janelia/render/client/multisem/ImportSofimaClient.java @@ -36,8 +36,8 @@ *
  • saves the modified tile specs to the target stack.
  • * * If {@code --targetStack} is given, the modified tile specs are written there (the stack is derived from the source - * if it does not yet exist); otherwise they are written back into the source stack. If {@code --completeStack} is set, - * the target stack is completed once all layers have been saved. + * if it does not yet exist); otherwise they are written back into the source stack. If {@code --completeTargetStack} + * is set, the target stack is completed once all layers have been saved. * * @author Michael Innerberger */ @@ -61,8 +61,8 @@ public static class Parameters extends CommandLineParameters { private int sofimaScaleIndex; @Parameter(names = "--zOffset", description = "Offset added to the running (0-based) layer index to obtain the field's z-slice index (default: 0)") private int zOffset = 0; - @Parameter(names = "--completeStack", description = "Complete the target stack after all layers have been saved") - private boolean completeStack = false; + @Parameter(names = "--completeTargetStack", description = "Complete the target stack after all layers have been saved") + private boolean completeTargetStack = false; public String getTargetStack() { return (targetStack == null) ? stack : targetStack; @@ -135,7 +135,7 @@ public void addDisplacementField() throws Exception { } // Complete the target stack - if (params.completeStack) { + if (params.completeTargetStack) { LOG.info("addDisplacementField: completing stack {}", targetStack); renderClient.setStackState(targetStack, StackMetaData.StackState.COMPLETE); } From a4e629c0faea6fd3e8cf3ff1ed341dfcfcba1f82 Mon Sep 17 00:00:00 2001 From: Michael Innerberger Date: Fri, 24 Jul 2026 12:08:04 -0400 Subject: [PATCH 07/24] Add a caching mechanism to displacement field transform --- .../transform/DisplacementFieldTransform.java | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/render-app/src/main/java/org/janelia/alignment/transform/DisplacementFieldTransform.java b/render-app/src/main/java/org/janelia/alignment/transform/DisplacementFieldTransform.java index 1511a044e..3ad0e6da1 100644 --- a/render-app/src/main/java/org/janelia/alignment/transform/DisplacementFieldTransform.java +++ b/render-app/src/main/java/org/janelia/alignment/transform/DisplacementFieldTransform.java @@ -17,6 +17,7 @@ import java.util.HashMap; import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; /** @@ -80,13 +81,31 @@ private void init(final String fieldSourceUri, * - Layout is [X,Y,C,Z]; C=0 is X vectors, C=1 is Y vectors * - XY axes are flipped compared to python due to N5 convention */ - final N5Reader fieldReader = new N5Factory().openReader(StorageFormat.N5, fieldSourceUri); - final RandomAccessibleInterval fieldRaw = N5Utils.open(fieldReader, "/"); + final RandomAccessibleInterval fieldRaw = openRawField(fieldSourceUri); displacementX = extractAndTransform(fieldRaw, 0); displacementY = extractAndTransform(fieldRaw, 1); } + /** + * Cache of raw (scale- and z-independent) displacement fields keyed by source URI. A single tile spec resolves + * its transform once per {@code getTransformList()} call (with no per-spec instance caching), so importing or + * rendering a layer would otherwise re-open the reader and re-read chunks once per tile. The cached value is the + * lazy {@link N5Utils#open} {@code CachedCellImg}: reader open + chunk reads happen once per field and are then + * shared across every tile and z-slice. Per-instance accessors are still built fresh in + * {@link #extractAndTransform} (imglib2 accessors are not thread safe); only the underlying chunk cache is shared. + */ + // ponytail: unbounded static cache, one entry per distinct field URI. A run touches a handful of fields, so this + // is bounded in practice; add eviction only if that stops holding. + private static final Map> RAW_FIELD_CACHE = new ConcurrentHashMap<>(); + + private static RandomAccessibleInterval openRawField(final String fieldSourceUri) { + return RAW_FIELD_CACHE.computeIfAbsent(fieldSourceUri, uri -> { + final N5Reader fieldReader = new N5Factory().openReader(StorageFormat.N5, uri); + return N5Utils.open(fieldReader, "/"); + }); + } + /** * Currently, this is tailored to output from SOFIMA for multi-sem acquisitions. The following code and comments * are from hot-knife. From 644adaf584b3a78dec4adcbdc26e8899b61cd68d Mon Sep 17 00:00:00 2001 From: Michael Innerberger Date: Mon, 3 Aug 2026 11:00:26 -0400 Subject: [PATCH 08/24] Bump java version to 21 --- .github/workflows/maven.yml | 4 ++-- pom.xml | 8 ++++---- render-ws/src/main/scripts/install.sh | 2 +- render-ws/src/main/scripts/jdk-vars.sh | 4 ++-- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml index acf3b6452..3a878a6bf 100644 --- a/.github/workflows/maven.yml +++ b/.github/workflows/maven.yml @@ -16,10 +16,10 @@ jobs: steps: - uses: actions/checkout@v5 - - name: Set up JDK 11 + - name: Set up JDK 21 uses: actions/setup-java@v5 with: - java-version: '11' + java-version: '21' distribution: 'zulu' cache: maven diff --git a/pom.xml b/pom.xml index c2cabe000..5efc4c7f9 100644 --- a/pom.xml +++ b/pom.xml @@ -163,9 +163,9 @@ gpl_v2 Howard Hughes Medical Institute - - 11 - [11,] + + 21 + [21,] false @@ -179,7 +179,7 @@ maven-compiler-plugin - 11 + 21 diff --git a/render-ws/src/main/scripts/install.sh b/render-ws/src/main/scripts/install.sh index fb25d24b4..dedfaec27 100755 --- a/render-ws/src/main/scripts/install.sh +++ b/render-ws/src/main/scripts/install.sh @@ -91,7 +91,7 @@ JETTY_WRAPPER_SCRIPT="${JETTY_BASE}/jetty_wrapper.sh" sed " s~/opt/local/jetty_home~${JETTY_HOME}~ s~/opt/local/jetty_base~${JETTY_BASE}~ - s~/misc/sc/jdks/zulu11~${JAVA_HOME}~ + s~/misc/sc/jdks/zulu21~${JAVA_HOME}~ " "${SCRIPTS_DIR}"/jetty/jetty_wrapper.sh > "${JETTY_WRAPPER_SCRIPT}" chmod 755 "${JETTY_WRAPPER_SCRIPT}" diff --git a/render-ws/src/main/scripts/jdk-vars.sh b/render-ws/src/main/scripts/jdk-vars.sh index ca88833a7..68f4c3fa7 100755 --- a/render-ws/src/main/scripts/jdk-vars.sh +++ b/render-ws/src/main/scripts/jdk-vars.sh @@ -1,8 +1,8 @@ #!/bin/bash -export JDK_VERSION="zulu11.78.15-ca-jdk11.0.26-linux_x64" +export JDK_VERSION="zulu21.52.15-ca-jdk21.0.12-linux_x64" # URL for JDK # You can find latest Linux x64 download links at: -# https://www.azul.com/downloads/?version=java-11-lts&os=linux&architecture=x86-64-bit&package=jdk&show-old-builds=true#zulu +# https://www.azul.com/downloads/?version=java-21-lts&os=linux&architecture=x86-64-bit&package=jdk&show-old-builds=true#zulu export JDK_URL="https://cdn.azul.com/zulu/bin/${JDK_VERSION}.tar.gz" \ No newline at end of file From 0161f04390250f5e650f5d771ded08478b396724 Mon Sep 17 00:00:00 2001 From: Michael Innerberger Date: Mon, 3 Aug 2026 11:00:51 -0400 Subject: [PATCH 09/24] Update jetty environment --- Dockerfile | 4 ++-- render-ws/pom.xml | 2 +- render-ws/src/main/scripts/install.sh | 2 +- render-ws/src/main/scripts/jetty/jetty_wrapper.sh | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Dockerfile b/Dockerfile index b921534cc..23da3f3b4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -32,7 +32,7 @@ # Install library dependencies before actually building source. # This caches libraries into an image layer that can be reused when only source code has changed. -FROM azul/zulu-openjdk-debian:11 as build_environment +FROM azul/zulu-openjdk-debian:21 as build_environment LABEL maintainer="Forrest Collman , Eric Trautman " RUN apt-get update && apt-get install -y maven @@ -85,7 +85,7 @@ RUN mkdir -p /root/render-lib && \ # Once web service application is built, set up jetty server and deploy application to it. # NOTE: jetty version should be kept in sync with values in render/render-ws/pom.xml and render/render-ws/src/main/scripts/install.sh -FROM jetty:10.0.13-jre11 as render-ws +FROM jetty:10.0.26-jre21 as render-ws # add packages not included in base image: # curl and coreutils are always needed for gnu readlink, tzdata is needed to set timezone diff --git a/render-ws/pom.xml b/render-ws/pom.xml index 6785ea5db..49783e5e3 100644 --- a/render-ws/pom.xml +++ b/render-ws/pom.xml @@ -247,7 +247,7 @@ 4.7.9.Final - 10.0.13 + 10.0.26 4.4.1 gpl_v2 Howard Hughes Medical Institute diff --git a/render-ws/src/main/scripts/install.sh b/render-ws/src/main/scripts/install.sh index dedfaec27..653d3fa03 100755 --- a/render-ws/src/main/scripts/install.sh +++ b/render-ws/src/main/scripts/install.sh @@ -1,6 +1,6 @@ #!/bin/bash -JETTY_VERSION="10.0.13" # NOTE: jetty version should be kept in sync with values in render/render-ws/pom.xml and render/Dockerfile +JETTY_VERSION="10.0.26" # NOTE: jetty version should be kept in sync with values in render/render-ws/pom.xml and render/Dockerfile JETTY_DIST="jetty-home-${JETTY_VERSION}" # URLs for Jetty 10, SLF4J 1.7, Logback 1.1, and Swagger 2.1 MAVEN_CENTRAL_URL="https://repo1.maven.org" diff --git a/render-ws/src/main/scripts/jetty/jetty_wrapper.sh b/render-ws/src/main/scripts/jetty/jetty_wrapper.sh index 81a1b0d07..1a42783be 100755 --- a/render-ws/src/main/scripts/jetty/jetty_wrapper.sh +++ b/render-ws/src/main/scripts/jetty/jetty_wrapper.sh @@ -21,7 +21,7 @@ export JETTY_STATE="${JETTY_RUN}/jetty.state" # JETTY_USER # JETTY_SHELL -export JAVA_HOME="/misc/sc/jdks/zulu11" +export JAVA_HOME="/misc/sc/jdks/zulu21" export PATH="${JAVA_HOME}/bin:${PATH}" export JAVA="${JAVA_HOME}/bin/java" export JAVA_OPTIONS="-Xms${JETTY_MIN_AND_MAX_MEMORY} -Xmx${JETTY_MIN_AND_MAX_MEMORY} -server -Djava.awt.headless=true" From fd00ff80b702205b5119440be44d8465bf451a13 Mon Sep 17 00:00:00 2001 From: Michael Innerberger Date: Mon, 3 Aug 2026 11:01:13 -0400 Subject: [PATCH 10/24] Remove obsolete pin --- pom.xml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/pom.xml b/pom.xml index 5efc4c7f9..a2527fc28 100644 --- a/pom.xml +++ b/pom.xml @@ -146,11 +146,6 @@ that matcher is a separate effort, so keep the 2.x line reactor-wide (render-app also pins 2.1.3). --> 2.1.3 - - 3.1.9 - 2.14.3 1.6.2 From 5d572295fdefbe758b161e6d320949a0408890fe Mon Sep 17 00:00:00 2001 From: Michael Innerberger Date: Mon, 3 Aug 2026 11:47:07 -0400 Subject: [PATCH 11/24] Update spark version --- .../markdown/how-to/how-to-local-spark-intellij.md | 10 +++++----- render-ws-spark-client/pom.xml | 11 ++++++++--- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/docs/src/site/markdown/how-to/how-to-local-spark-intellij.md b/docs/src/site/markdown/how-to/how-to-local-spark-intellij.md index 1fb918a1c..954f0d09e 100644 --- a/docs/src/site/markdown/how-to/how-to-local-spark-intellij.md +++ b/docs/src/site/markdown/how-to/how-to-local-spark-intellij.md @@ -1,7 +1,7 @@ # How To: Run Spark Locally in IntelliJ It can be useful to run and test render spark clients within an IDE -[using a local master URL](https://spark.apache.org/docs/3.4.1/submitting-applications.html#master-urls). Unfortunately +[using a local master URL](https://spark.apache.org/docs/4.0.4/submitting-applications.html#master-urls). Unfortunately because we need to exclude spark libraries from the render-ws-spark-client fat jar, you'll need to add a few extra settings to your IntelliJ run configuration to get spark clients to run within the IDE. @@ -10,7 +10,7 @@ few extra settings to your IntelliJ run configuration to get spark clients to ru The hadoop client runtime library is required to run spark locally. If you don't already have it in a local directory, an easy way to install it is to: - Comment out the library's exclusion block in the -[render-ws-spark-client pom.xml](../../../../../render-ws-spark-client/pom.xml#L213-L216): +[render-ws-spark-client pom.xml](../../../../../render-ws-spark-client/pom.xml#L267-L270): ``` + + 4.0.4 provided @@ -249,9 +254,9 @@ Comment out the hadoop-client-runtime exclusion below and run mvn compile to pull it into your local .m2/repository. The compile will fail because of the enforcer no-duplicate-classes rule but the jar will get pulled. - Once $HOME/.m2/repository/org/apache/hadoop/hadoop-client-runtime/3.3.4/hadoop-client-runtime-3.3.4.jar exists, + Once $HOME/.m2/repository/org/apache/hadoop/hadoop-client-runtime/3.4.1/hadoop-client-runtime-3.4.1.jar exists, uncomment the exclusion and run mvn clean package. - Finally, add the hadoop-client-runtime-3.3.4.jar to IntelliJ run configurations to run local Spark jobs from IDE. + Finally, add the hadoop-client-runtime-3.4.1.jar to IntelliJ run configurations to run local Spark jobs from IDE. Note that the hadoop version will change with Spark version changes. Hopefully, whoever updates Spark will also update this comment with the new corresponding hadoop version and .m2 path. From 759db47cb4d116d89891a1da33e453df7eaefe50 Mon Sep 17 00:00:00 2001 From: Michael Innerberger Date: Mon, 3 Aug 2026 11:49:35 -0400 Subject: [PATCH 12/24] Fix pinned versions and exclusions --- pom.xml | 8 +++++++- render-app/pom.xml | 4 ++-- render-ws-java-client/pom.xml | 4 ++-- render-ws-spark-client/pom.xml | 26 +++++--------------------- render-ws/pom.xml | 13 ++++++++++--- trakem2-scripts/pom.xml | 4 ++-- 6 files changed, 28 insertions(+), 31 deletions(-) diff --git a/pom.xml b/pom.xml index a2527fc28..b11899651 100644 --- a/pom.xml +++ b/pom.xml @@ -146,7 +146,13 @@ that matcher is a separate effort, so keep the 2.x line reactor-wide (render-app also pins 2.1.3). --> 2.1.3 - 2.14.3 + + 2.18.6 1.6.2 diff --git a/render-app/pom.xml b/render-app/pom.xml index f1a5aca3b..52119274b 100644 --- a/render-app/pom.xml +++ b/render-app/pom.xml @@ -164,11 +164,11 @@ n5-hdf5
    - + com.fasterxml.jackson.core jackson-databind - ${jackson-version} diff --git a/render-ws-java-client/pom.xml b/render-ws-java-client/pom.xml index 0599c6b3a..ce9ef8d82 100644 --- a/render-ws-java-client/pom.xml +++ b/render-ws-java-client/pom.xml @@ -230,11 +230,11 @@ ${n5-version} - + com.fasterxml.jackson.core jackson-databind - ${jackson-version} diff --git a/render-ws-spark-client/pom.xml b/render-ws-spark-client/pom.xml index 9ec199fdc..1d5945f74 100644 --- a/render-ws-spark-client/pom.xml +++ b/render-ws-spark-client/pom.xml @@ -183,38 +183,22 @@ org.janelia.saalfeldlab n5-spark - 3.7.3 + + 4.1.0 - + com.esotericsoftware kryo - - javax.validation - validation-api - - - - com.google.http-client - google-http-client-xml - - - - com.kjetland - mbknor-jackson-jsonschema_2.12 - - + com.fasterxml.jackson.core jackson-databind - ${jackson-version} diff --git a/render-ws/pom.xml b/render-ws/pom.xml index 49783e5e3..fbb5ceafe 100644 --- a/render-ws/pom.xml +++ b/render-ws/pom.xml @@ -102,11 +102,11 @@ ${n5-version} - + com.fasterxml.jackson.core jackson-databind - ${jackson-version} @@ -161,16 +161,23 @@ resteasy-servlet-initializer + com.fasterxml.jackson.jaxrs jackson-jaxrs-json-provider - ${jackson-version} + ${jackson.version} jakarta.activation jakarta.activation-api + + + javax.xml.bind + jaxb-api + diff --git a/trakem2-scripts/pom.xml b/trakem2-scripts/pom.xml index 1f171b9f3..c15e4809d 100644 --- a/trakem2-scripts/pom.xml +++ b/trakem2-scripts/pom.xml @@ -238,11 +238,11 @@ ${n5-version} - + com.fasterxml.jackson.core jackson-databind - ${jackson-version} From a51d33c3109590cc9c1f752e8fa72edc9910854f Mon Sep 17 00:00:00 2001 From: Michael Innerberger Date: Mon, 3 Aug 2026 13:14:01 -0400 Subject: [PATCH 13/24] Add a comment about java-spark compatibility --- docs/src/site/markdown/how-to/how-to-local-spark-intellij.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/src/site/markdown/how-to/how-to-local-spark-intellij.md b/docs/src/site/markdown/how-to/how-to-local-spark-intellij.md index 954f0d09e..3fd2594de 100644 --- a/docs/src/site/markdown/how-to/how-to-local-spark-intellij.md +++ b/docs/src/site/markdown/how-to/how-to-local-spark-intellij.md @@ -44,6 +44,9 @@ Select the run drop-down menu and click Edit Configurations... Then make the following changes to the run configuration (and save the changes): - A: Add `SPARK_LOCAL_IP=127.0.0.1` to the environment variables so that runs will work when you are connected via VPN. + Also make sure the configuration's JDK is 21: spark 4 supports java 17 and 21 only, and on java 24+ every + spark client dies in `new JavaSparkContext(...)` with `UnsupportedOperationException: getSubject is not supported` + (hadoop's `UserGroupInformation` uses the security manager API that JEP 486 removed). - B: Select the `Modify options` drop-down menu. - C: Select `Modify classpath`. - D: Select `Add dependencies with "provided" scope to classpath` and close the drop-down menu. From 4495dd750c9f9f08448918c575ebaec906c61667 Mon Sep 17 00:00:00 2001 From: Michael Innerberger Date: Tue, 4 Aug 2026 11:14:34 -0400 Subject: [PATCH 14/24] Use n5-precomputed in sofima importer --- render-app/pom.xml | 18 ++++++ .../transform/DisplacementFieldTransform.java | 63 ++++++++++++++++--- .../DisplacementFieldTransformTest.java | 58 ++++++++++++++++- .../client/multisem/ImportSofimaClient.java | 13 ++-- 4 files changed, 136 insertions(+), 16 deletions(-) diff --git a/render-app/pom.xml b/render-app/pom.xml index 2c867921a..cf011d41d 100644 --- a/render-app/pom.xml +++ b/render-app/pom.xml @@ -170,6 +170,24 @@ n5-universe + + + org.janelia.saalfeldlab + n5-ng-precomputed + 0.1.0-SNAPSHOT + + + + + org.janelia.saalfeldlab + n5-ng-precomputed + 0.1.0-SNAPSHOT + tests + test + + diff --git a/render-app/src/main/java/org/janelia/alignment/transform/DisplacementFieldTransform.java b/render-app/src/main/java/org/janelia/alignment/transform/DisplacementFieldTransform.java index 3ad0e6da1..adb86194e 100644 --- a/render-app/src/main/java/org/janelia/alignment/transform/DisplacementFieldTransform.java +++ b/render-app/src/main/java/org/janelia/alignment/transform/DisplacementFieldTransform.java @@ -10,11 +10,18 @@ import net.imglib2.realtransform.Scale; import net.imglib2.type.numeric.real.FloatType; import net.imglib2.view.Views; +import org.janelia.saalfeldlab.n5.KeyValueAccess; import org.janelia.saalfeldlab.n5.N5Reader; +import org.janelia.saalfeldlab.n5.googlecloud.GoogleCloudStorageKeyValueAccess; import org.janelia.saalfeldlab.n5.imglib2.N5Utils; -import org.janelia.saalfeldlab.n5.universe.N5Factory; -import org.janelia.saalfeldlab.n5.universe.N5Factory.StorageFormat; +import org.janelia.saalfeldlab.n5.precomputed.N5PrecomputedReader; +import org.janelia.saalfeldlab.n5.precomputed.PrecomputedKeyValueReader; +import com.google.cloud.storage.Storage; +import com.google.cloud.storage.StorageOptions; +import com.google.gson.GsonBuilder; + +import java.net.URI; import java.util.HashMap; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @@ -77,9 +84,11 @@ private void init(final String fieldSourceUri, this.fieldScaleIndex = fieldScaleIndex; this.fieldZIndex = fieldZIndex; - /* Load displacement field. Currently, this is tailored to output from SOFIMA for multi-sem acquisitions. - * - Layout is [X,Y,C,Z]; C=0 is X vectors, C=1 is Y vectors - * - XY axes are flipped compared to python due to N5 convention + /* Load displacement field. Currently, this is tailored to output from SOFIMA for multi-sem acquisitions, + * stored as a Neuroglancer precomputed volume and read through the n5-ng-precomputed backend. + * - Layout is [x,y,z,channel]; channel=0 is X vectors, channel=1 is Y vectors + * - Precomputed raw is column-major [x,y,z,channel], matching N5/ImgLib2, so (unlike the Zarr + * backend) no XY axis reversal is performed: dim 0 is X, dim 1 is Y */ final RandomAccessibleInterval fieldRaw = openRawField(fieldSourceUri); @@ -101,11 +110,46 @@ private void init(final String fieldSourceUri, private static RandomAccessibleInterval openRawField(final String fieldSourceUri) { return RAW_FIELD_CACHE.computeIfAbsent(fieldSourceUri, uri -> { - final N5Reader fieldReader = new N5Factory().openReader(StorageFormat.N5, uri); - return N5Utils.open(fieldReader, "/"); + final N5Reader fieldReader = openPrecomputedReader(uri); + final String scaleKey = fieldReader.list("/")[0]; + return N5Utils.open(fieldReader, scaleKey); }); } + /** + * Opens a Neuroglancer precomputed field through the N5 API. The URI may be prefixed with + * {@code precomputed://}. {@code gs://} buckets are read anonymously (matching the public warp-field + * bucket); any other scheme (e.g. {@code file://}) is routed through {@link N5Factory}'s key-value access. + * + *

    This wires the reader up by hand because {@code n5-universe}'s {@code N5Factory} does not yet know + * the precomputed format. Mirrors the {@code n5-ng-precomputed} examples. + * + *

    Exposed so that clients preparing a field (e.g. {@code ImportSofimaClient}) open it through exactly + * this same path rather than reimplementing the wiring. The field dataset itself lives under the first + * scale key, i.e. {@code reader.list("/")[0]}. + * + * @param fieldSourceUri the (optionally {@code precomputed://}-prefixed) container URI. + * @return an {@link N5Reader} over the precomputed container. + */ + public static N5Reader openPrecomputedReader(final String fieldSourceUri) { + String uri = fieldSourceUri; + if (uri.startsWith("precomputed://")) { + uri = uri.substring("precomputed://".length()); + } + + if (uri.startsWith("gs://")) { + // gs:// buckets are read anonymously (the public warp-field bucket needs no credentials). + final Storage storage = StorageOptions.getUnauthenticatedInstance().getService(); + final KeyValueAccess keyValueAccess = new GoogleCloudStorageKeyValueAccess(storage, uri, false); + return new PrecomputedKeyValueReader(keyValueAccess, uri, new GsonBuilder(), true); + } + + // Local filesystem (optionally file://-prefixed): N5PrecomputedReader wires up FileSystemKeyValueAccess + // over the default filesystem. (n5-universe 1.6.0's N5Factory.getKeyValueAccess is package-private.) + final String path = uri.startsWith("file://") ? URI.create(uri).getPath() : uri; + return new N5PrecomputedReader(path, new GsonBuilder(), true); + } + /** * Currently, this is tailored to output from SOFIMA for multi-sem acquisitions. The following code and comments * are from hot-knife. @@ -123,9 +167,10 @@ private RealRandomAccess extractAndTransform(final RandomAccessibleIn (i, o) -> o.set(Float.isNaN(i.getRealFloat()) ? 0 : i.getRealFloat()), new FloatType()); - // Slice the dataset: choose right z-slice (dim=3) and then choose between x or y (dim=2) + // Slice the [x,y,z,channel] dataset: choose the vector component (channel, dim=3) and then the + // z-slice (dim=2). Slicing the higher dimension (channel) first keeps the z index valid at dim=2. final RandomAccessibleInterval slice = Views.hyperSlice( - Views.hyperSlice(cleaned, 3, this.fieldZIndex), 2, xory); + Views.hyperSlice(cleaned, 3, xory), 2, this.fieldZIndex); // Scale and interpolate the slice to full resolution final RealRandomAccessible scaledAndInterpolated = RealViews.affine( diff --git a/render-app/src/test/java/org/janelia/alignment/transform/DisplacementFieldTransformTest.java b/render-app/src/test/java/org/janelia/alignment/transform/DisplacementFieldTransformTest.java index ac934671d..323e48f25 100644 --- a/render-app/src/test/java/org/janelia/alignment/transform/DisplacementFieldTransformTest.java +++ b/render-app/src/test/java/org/janelia/alignment/transform/DisplacementFieldTransformTest.java @@ -1,7 +1,13 @@ package org.janelia.alignment.transform; +import java.nio.file.Path; + +import org.janelia.saalfeldlab.n5.DataType; +import org.janelia.saalfeldlab.n5.precomputed.PrecomputedTestVolumes; import org.junit.Assert; +import org.junit.Rule; import org.junit.Test; +import org.junit.rules.TemporaryFolder; import mpicbg.trakem2.transform.CoordinateTransform; @@ -11,7 +17,7 @@ public class DisplacementFieldTransformTest { private static final String SAMPLE_URI = - "file:///tmp/does-not-exist.n5"; + "file:///tmp/does-not-exist.n5?scaleIndex=3&zIndex=5&scaleX=8.0&scaleY=8.0"; @Test public void testDataStringRoundTrip() { @@ -21,7 +27,7 @@ public void testDataStringRoundTrip() { try { transform.init(SAMPLE_URI); Assert.fail("expected init to fail loading a nonexistent field"); - } catch (final IllegalArgumentException e) { + } catch (final RuntimeException e) { Assert.assertEquals("data string should round-trip even when loading fails", SAMPLE_URI, transform.toDataString()); } @@ -39,6 +45,54 @@ public void testApplyBeforeInitFails() { } } + @Rule + public TemporaryFolder tempFolder = new TemporaryFolder(); + + /** + * End-to-end read of a real Neuroglancer-precomputed field through n5-ng-precomputed: guards the + * [x,y,z,channel] slicing, both scalings, and (since it actually touches the reader) the n5 version + * alignment between this reactor and the locally installed n5-ng-precomputed build. + */ + @Test + public void testAppliesPrecomputedField() throws Exception { + + // value at (x,y,z) is x+y+10*z for the X component (channel 0) and 100 more for Y (channel 1) + final Path fieldDir = tempFolder.newFolder("field").toPath(); + PrecomputedTestVolumes.writeRawVolume(fieldDir, + DataType.FLOAT32, + 2, + new long[] {4, 3, 2}, + new int[] {4, 3, 2}, + new long[] {0, 0, 0}, + (x, y, z, c) -> x + y + 10 * z + 100 * c); + + // unscaled: full-resolution (1,1) reads field (1,1) of z-slice 1, vectors are used as stored + assertDisplacement(fieldDir, 0, 1, 1.0, new double[] {1.0, 1.0}, 12.0, 112.0); + + // xyScale 2 halves the query position (so (2,2) reads field (1,1)) and scaleIndex 2 quadruples the vectors + assertDisplacement(fieldDir, 2, 1, 2.0, new double[] {2.0, 2.0}, 12.0 * 4, 112.0 * 4); + } + + private static void assertDisplacement(final Path fieldDir, + final int scaleIndex, + final int zIndex, + final double xyScale, + final double[] location, + final double expectedDx, + final double expectedDy) { + + final String data = fieldDir + "?scaleIndex=" + scaleIndex + "&zIndex=" + zIndex + + "&scaleX=" + xyScale + "&scaleY=" + xyScale; + final DisplacementFieldTransform transform = new DisplacementFieldTransform(); + transform.init(data); + + final double[] displaced = transform.apply(location); + Assert.assertEquals("wrong x displacement for " + data, + location[0] + expectedDx, displaced[0], 0.0001); + Assert.assertEquals("wrong y displacement for " + data, + location[1] + expectedDy, displaced[1], 0.0001); + } + @Test public void testImplementsCoordinateTransform() { // guards the reflective LeafTransformSpec.newInstance() contract (no-arg constructor + interface) diff --git a/render-ws-java-client/src/main/java/org/janelia/render/client/multisem/ImportSofimaClient.java b/render-ws-java-client/src/main/java/org/janelia/render/client/multisem/ImportSofimaClient.java index 0bf1fd636..ed581de70 100644 --- a/render-ws-java-client/src/main/java/org/janelia/render/client/multisem/ImportSofimaClient.java +++ b/render-ws-java-client/src/main/java/org/janelia/render/client/multisem/ImportSofimaClient.java @@ -19,8 +19,6 @@ import org.janelia.render.client.parameter.RenderWebServiceParameters; import org.janelia.render.client.parameter.ZRangeParameters; import org.janelia.saalfeldlab.n5.N5Reader; -import org.janelia.saalfeldlab.n5.universe.N5Factory; -import org.janelia.saalfeldlab.n5.universe.N5Factory.StorageFormat; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -28,7 +26,9 @@ * Adds a {@link DisplacementFieldTransform} to every tile spec of a stack, layer by layer. *

    * The (dense) displacement field is currently expected to be SOFIMA output for multi-SEM acquisitions, stored as a - * 4D {@code [X,Y,C,Z]} N5 dataset with one z-slice per stack layer. For each layer, this client + * Neuroglancer precomputed volume with a 4D {@code [x,y,z,channel]} layout and one z-slice per stack layer (opened + * through the same {@link DisplacementFieldTransform#openPrecomputedReader} path the transform itself uses). For + * each layer, this client *

      *
    • computes {@code fieldZIndex} from the {@code --zOffset} parameter and the running (0-based) layer index,
    • *
    • computes the field-to-full-resolution {@code xyScale} from the stack bounds and the field's XY dimensions,
    • @@ -97,10 +97,13 @@ public void addDisplacementField() throws Exception { // Get full-resolution stack size and the field's XY size to scale the field to full resolution final double[] xyScale; - try (final N5Reader fieldReader = new N5Factory().openReader(StorageFormat.N5, params.sofimaFieldUri)) { + try (final N5Reader fieldReader = DisplacementFieldTransform.openPrecomputedReader(params.sofimaFieldUri)) { final Bounds stackBounds = sourceStackMetaData.getStats().getStackBounds(); - final long[] fieldDimensions = fieldReader.getDatasetAttributes("/").getDimensions(); + // The precomputed dataset lives under the first scale key (see DisplacementFieldTransform); the + // layout is [x,y,z,channel], so dim 0 is X and dim 1 is Y. + final String scaleKey = fieldReader.list("/")[0]; + final long[] fieldDimensions = fieldReader.getDatasetAttributes(scaleKey).getDimensions(); xyScale = new double[]{ stackBounds.getDeltaX() / fieldDimensions[0], From d3c7241e03f401d08888da68cada4344f76feb87 Mon Sep 17 00:00:00 2001 From: Michael Innerberger Date: Tue, 4 Aug 2026 14:57:45 -0400 Subject: [PATCH 15/24] Fix pull vs push format of displacement field --- .../transform/DisplacementFieldTransform.java | 29 +++++++++++-------- .../DisplacementFieldTransformTest.java | 7 +++-- 2 files changed, 21 insertions(+), 15 deletions(-) diff --git a/render-app/src/main/java/org/janelia/alignment/transform/DisplacementFieldTransform.java b/render-app/src/main/java/org/janelia/alignment/transform/DisplacementFieldTransform.java index adb86194e..978499461 100644 --- a/render-app/src/main/java/org/janelia/alignment/transform/DisplacementFieldTransform.java +++ b/render-app/src/main/java/org/janelia/alignment/transform/DisplacementFieldTransform.java @@ -20,6 +20,7 @@ import com.google.cloud.storage.Storage; import com.google.cloud.storage.StorageOptions; import com.google.gson.GsonBuilder; +import org.janelia.saalfeldlab.n5.universe.N5Factory; import java.net.URI; import java.util.HashMap; @@ -104,8 +105,6 @@ private void init(final String fieldSourceUri, * shared across every tile and z-slice. Per-instance accessors are still built fresh in * {@link #extractAndTransform} (imglib2 accessors are not thread safe); only the underlying chunk cache is shared. */ - // ponytail: unbounded static cache, one entry per distinct field URI. A run touches a handful of fields, so this - // is bounded in practice; add eviction only if that stops holding. private static final Map> RAW_FIELD_CACHE = new ConcurrentHashMap<>(); private static RandomAccessibleInterval openRawField(final String fieldSourceUri) { @@ -151,12 +150,15 @@ public static N5Reader openPrecomputedReader(final String fieldSourceUri) { } /** - * Currently, this is tailored to output from SOFIMA for multi-sem acquisitions. The following code and comments - * are from hot-knife. - * - We need to adjust the sofima vectors for the original scale of the images and the scale of the hot-knife field - * - The SOFIMA vectors have the same size, no matter with which stride they were computed, so they must be in the size of the input images fed to SOFIMA - * - Saalfeld's absolute transformation fields store the vectors in the scale the transformation fields are stored in. E.g. at scale 0.03125 a value that is 2400, will be 4800 at scale 0.0625 - * - Positive y means move up, positive x means move left + * Currently, this is tailored to output from SOFIMA for multi-sem acquisitions. + *
        + *
      • The field is a pull map: the vector stored at a target position points at the source position + * the data is pulled from, i.e. {@code source = target + vector}. Render's transform lists run + * source to target, so the vectors are negated here.
      • + *
      • Vector units are the pixels of the images that were fed to SOFIMA, so they are scaled up by + * {@code 1 << fieldScaleIndex} to reach full resolution. Use {@code scaleIndex=0} for fields whose + * vectors are already expressed in full-resolution units (SOFIMA does this by default).
      • + *
      */ private RealRandomAccess extractAndTransform(final RandomAccessibleInterval rawField, final int xory) { @@ -177,12 +179,15 @@ private RealRandomAccess extractAndTransform(final RandomAccessibleIn Views.interpolate(Views.extendMirrorDouble(slice), new NLinearInterpolatorFactory<>()), new Scale(this.xyScale)); - // Scale the deformation vectors to account for the scale of the images that they were computed on - // Do this last to reduce the number of scaling operations - final float vectorScale = 1.0f / (1 << this.fieldScaleIndex); + // Invert the pull map and scale the vectors up from the resolution of the images SOFIMA saw, folded into a + // single factor applied last (after interpolation) to keep the number of passes down. + // ponytail: negating is a first-order inverse, p - d(p) instead of solving t = p - d(t). Exact enough for + // the fields seen so far (Jacobian ~2e-4, so it is off by well under 0.01 px); switch to a fixed-point + // iteration if a field with steep gradients ever shows up. + final float vectorScale = -(1 << this.fieldScaleIndex); return Converters.convert( scaledAndInterpolated, - (i, o) -> o.set(i.getRealFloat() / vectorScale), + (i, o) -> o.set(i.getRealFloat() * vectorScale), new FloatType()).realRandomAccess(); } diff --git a/render-app/src/test/java/org/janelia/alignment/transform/DisplacementFieldTransformTest.java b/render-app/src/test/java/org/janelia/alignment/transform/DisplacementFieldTransformTest.java index 323e48f25..b749de27f 100644 --- a/render-app/src/test/java/org/janelia/alignment/transform/DisplacementFieldTransformTest.java +++ b/render-app/src/test/java/org/janelia/alignment/transform/DisplacementFieldTransformTest.java @@ -66,11 +66,12 @@ public void testAppliesPrecomputedField() throws Exception { new long[] {0, 0, 0}, (x, y, z, c) -> x + y + 10 * z + 100 * c); - // unscaled: full-resolution (1,1) reads field (1,1) of z-slice 1, vectors are used as stored - assertDisplacement(fieldDir, 0, 1, 1.0, new double[] {1.0, 1.0}, 12.0, 112.0); + // unscaled: full-resolution (1,1) reads field (1,1) of z-slice 1; vectors are negated (pull map) but + // otherwise used as stored + assertDisplacement(fieldDir, 0, 1, 1.0, new double[] {1.0, 1.0}, -12.0, -112.0); // xyScale 2 halves the query position (so (2,2) reads field (1,1)) and scaleIndex 2 quadruples the vectors - assertDisplacement(fieldDir, 2, 1, 2.0, new double[] {2.0, 2.0}, 12.0 * 4, 112.0 * 4); + assertDisplacement(fieldDir, 2, 1, 2.0, new double[] {2.0, 2.0}, -12.0 * 4, -112.0 * 4); } private static void assertDisplacement(final Path fieldDir, From 794c9b6d19cb3dd7644683f23182f7335b7d4d0a Mon Sep 17 00:00:00 2001 From: Michael Innerberger Date: Tue, 4 Aug 2026 14:58:09 -0400 Subject: [PATCH 16/24] Make target stack argument required --- .../render/client/multisem/ImportSofimaClient.java | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/render-ws-java-client/src/main/java/org/janelia/render/client/multisem/ImportSofimaClient.java b/render-ws-java-client/src/main/java/org/janelia/render/client/multisem/ImportSofimaClient.java index ed581de70..0a33af152 100644 --- a/render-ws-java-client/src/main/java/org/janelia/render/client/multisem/ImportSofimaClient.java +++ b/render-ws-java-client/src/main/java/org/janelia/render/client/multisem/ImportSofimaClient.java @@ -53,7 +53,7 @@ public static class Parameters extends CommandLineParameters { private final ZRangeParameters zRangeParams = new ZRangeParameters(); @Parameter(names = "--stack", description = "Source stack to which the displacement field is added", required = true) private String stack; - @Parameter(names = "--targetStack", description = "Stack to save modified tile specs to (defaults to the source stack)") + @Parameter(names = "--targetStack", description = "Stack to save modified tile specs to", required = true) private String targetStack; @Parameter(names = "--sofimaFieldUri", description = "URI of the SOFIMA displacement field N5 container", required = true) private String sofimaFieldUri; @@ -63,10 +63,6 @@ public static class Parameters extends CommandLineParameters { private int zOffset = 0; @Parameter(names = "--completeTargetStack", description = "Complete the target stack after all layers have been saved") private boolean completeTargetStack = false; - - public String getTargetStack() { - return (targetStack == null) ? stack : targetStack; - } } public static void main(final String[] args) { @@ -116,7 +112,7 @@ public void addDisplacementField() throws Exception { } // Set up the target stack - final String targetStack = params.getTargetStack(); + final String targetStack = params.targetStack; if (! targetStack.equals(params.stack)) { renderClient.setupDerivedStack(sourceStackMetaData, targetStack); } else { From 690086d96761e80030fc7be71934162375c4e13d Mon Sep 17 00:00:00 2001 From: Michael Innerberger Date: Wed, 5 Aug 2026 14:16:07 -0400 Subject: [PATCH 17/24] Add defaults and slightly change parameter interpretation --- .../transform/DisplacementFieldTransform.java | 137 ++++++++++++------ .../DisplacementFieldTransformTest.java | 52 +++++-- .../client/multisem/ImportSofimaClient.java | 36 +++-- 3 files changed, 155 insertions(+), 70 deletions(-) diff --git a/render-app/src/main/java/org/janelia/alignment/transform/DisplacementFieldTransform.java b/render-app/src/main/java/org/janelia/alignment/transform/DisplacementFieldTransform.java index 978499461..1337de292 100644 --- a/render-app/src/main/java/org/janelia/alignment/transform/DisplacementFieldTransform.java +++ b/render-app/src/main/java/org/janelia/alignment/transform/DisplacementFieldTransform.java @@ -6,8 +6,8 @@ import net.imglib2.RealRandomAccessible; import net.imglib2.converter.Converters; import net.imglib2.interpolation.randomaccess.NLinearInterpolatorFactory; +import net.imglib2.realtransform.AffineTransform2D; import net.imglib2.realtransform.RealViews; -import net.imglib2.realtransform.Scale; import net.imglib2.type.numeric.real.FloatType; import net.imglib2.view.Views; import org.janelia.saalfeldlab.n5.KeyValueAccess; @@ -25,6 +25,7 @@ import java.net.URI; import java.util.HashMap; import java.util.Map; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; @@ -37,9 +38,13 @@ public class DisplacementFieldTransform /** URI (as supplied to {@link #init}) identifying the field on disk and its world-coordinate mapping. */ private String fieldSourceUri; - private double[] xyScale; - private int fieldScaleIndex; private int fieldZIndex; + /** Full-resolution pixels per field pixel in x and y. */ + private double[] xyScale; + /** World coordinate that field index 0 maps to, in x and y. */ + private double[] offset; + /** Full-resolution pixels per unit of stored vector. */ + private double vectorScale; // ImgLib2 accessor for the displacement field; null until a field source has been loaded. private RealRandomAccess displacementX; @@ -50,9 +55,10 @@ public class DisplacementFieldTransform */ public DisplacementFieldTransform() { this.fieldSourceUri = null; - this.xyScale = null; - this.fieldScaleIndex = -1; this.fieldZIndex = -1; + this.xyScale = new double[] { DEFAULT_SCALE, DEFAULT_SCALE }; + this.offset = new double[] { DEFAULT_OFFSET, DEFAULT_OFFSET }; + this.vectorScale = DEFAULT_VECTOR_SCALE; this.displacementX = null; this.displacementY = null; @@ -62,28 +68,33 @@ public DisplacementFieldTransform() { * Constructs and immediately loads a transform for the field at the specified source. * * @param fieldSourceUri URI locating the field on disk (see class Javadoc for format). - * @param fieldScaleIndex The scale index at which the deformed images were fed to SOFIMA, needed for vector size adjustment * @param fieldZIndex The z-slice index of the field to use (the field may be 3D, but this transform is 2D) - * @param xyScale The scale of the field in x and y, needed for scaling the field to full resolution + * @param xyScale Full-resolution pixels per field pixel in x and y (1 leaves the field at full resolution) + * @param offset World coordinate that field index 0 maps to, in x and y (0 puts the field at the world origin) + * @param vectorScale Full-resolution pixels per unit of stored vector (1 for vectors already in + * full-resolution units, which is what SOFIMA emits) * * @throws IllegalArgumentException * if the field cannot be loaded. */ public DisplacementFieldTransform(final String fieldSourceUri, - final int fieldScaleIndex, final int fieldZIndex, - final double[] xyScale) { - this.init(fieldSourceUri, xyScale, fieldScaleIndex, fieldZIndex); + final double[] xyScale, + final double[] offset, + final double vectorScale) { + this.init(fieldSourceUri, fieldZIndex, xyScale, offset, vectorScale); } private void init(final String fieldSourceUri, + final int fieldZIndex, final double[] xyScale, - final int fieldScaleIndex, - final int fieldZIndex) { + final double[] offset, + final double vectorScale) { this.fieldSourceUri = fieldSourceUri; - this.xyScale = xyScale; - this.fieldScaleIndex = fieldScaleIndex; this.fieldZIndex = fieldZIndex; + this.xyScale = xyScale; + this.offset = offset; + this.vectorScale = vectorScale; /* Load displacement field. Currently, this is tailored to output from SOFIMA for multi-sem acquisitions, * stored as a Neuroglancer precomputed volume and read through the n5-ng-precomputed backend. @@ -93,6 +104,14 @@ private void init(final String fieldSourceUri, */ final RandomAccessibleInterval fieldRaw = openRawField(fieldSourceUri); + // Out-of-range x and y are handled by the mirrored extension in extractAndTransform, but an out-of-range + // z would read outside the cached image, which is undefined rather than merely inaccurate. + if ((fieldZIndex < 0) || (fieldZIndex >= fieldRaw.dimension(2))) { + throw new IllegalArgumentException( + "zIndex " + fieldZIndex + " is outside the z range [0, " + fieldRaw.dimension(2) + + ") of the field at " + fieldSourceUri); + } + displacementX = extractAndTransform(fieldRaw, 0); displacementY = extractAndTransform(fieldRaw, 1); } @@ -155,9 +174,8 @@ public static N5Reader openPrecomputedReader(final String fieldSourceUri) { *
    • The field is a pull map: the vector stored at a target position points at the source position * the data is pulled from, i.e. {@code source = target + vector}. Render's transform lists run * source to target, so the vectors are negated here.
    • - *
    • Vector units are the pixels of the images that were fed to SOFIMA, so they are scaled up by - * {@code 1 << fieldScaleIndex} to reach full resolution. Use {@code scaleIndex=0} for fields whose - * vectors are already expressed in full-resolution units (SOFIMA does this by default).
    • + *
    • Stored vectors are multiplied by {@code vectorScale} to reach full resolution. SOFIMA expresses them + * in the units of the original volume already, so the default of 1 is what that output needs.
    • *
    */ private RealRandomAccess extractAndTransform(final RandomAccessibleInterval rawField, @@ -174,20 +192,24 @@ private RealRandomAccess extractAndTransform(final RandomAccessibleIn final RandomAccessibleInterval slice = Views.hyperSlice( Views.hyperSlice(cleaned, 3, xory), 2, this.fieldZIndex); - // Scale and interpolate the slice to full resolution + // Place the slice in world coordinates: field index 0 lands on offset, one field pixel spans xyScale + // full-resolution pixels, so a query at p reads the field at (p - offset) / xyScale. + final AffineTransform2D fieldToWorld = new AffineTransform2D(); + fieldToWorld.set(this.xyScale[0], 0, this.offset[0], + 0, this.xyScale[1], this.offset[1]); final RealRandomAccessible scaledAndInterpolated = RealViews.affine( Views.interpolate(Views.extendMirrorDouble(slice), new NLinearInterpolatorFactory<>()), - new Scale(this.xyScale)); + fieldToWorld); - // Invert the pull map and scale the vectors up from the resolution of the images SOFIMA saw, folded into a - // single factor applied last (after interpolation) to keep the number of passes down. + // Invert the pull map and scale the vectors to full resolution, folded into a single factor applied last + // (after interpolation) to keep the number of passes down. // ponytail: negating is a first-order inverse, p - d(p) instead of solving t = p - d(t). Exact enough for // the fields seen so far (Jacobian ~2e-4, so it is off by well under 0.01 px); switch to a fixed-point // iteration if a field with steep gradients ever shows up. - final float vectorScale = -(1 << this.fieldScaleIndex); + final double pullToPushScale = -this.vectorScale; return Converters.convert( scaledAndInterpolated, - (i, o) -> o.set(i.getRealFloat() * vectorScale), + (i, o) -> o.set((float) (i.getRealFloat() * pullToPushScale)), new FloatType()).realRandomAccess(); } @@ -217,9 +239,11 @@ public void applyInPlace(final double[] location) { * Initializes this transform by parsing the data string and loading the field into an imglib2 image. *

    * The data string is the field source URI followed by {@code ?key=value} query parameters, e.g. - * {@code file:///path/to/field.n5?scaleIndex=3&zIndex=5&scaleX=8.0&scaleY=8.0}. The portion before the - * {@code ?} becomes the {@link #fieldSourceUri} (the actual path); the query parameters supply the - * remaining fields. + * {@code file:///path/to/field.n5?zIndex=5&scaleX=40.0&scaleY=40.0}. The portion before the {@code ?} + * becomes the {@link #fieldSourceUri} (the actual path); the query parameters supply the remaining fields. + * Only {@code zIndex} is required; everything else defaults to the identity placement + * ({@code scaleX=scaleY=vectorScale=1}, {@code offsetX=offsetY=0}). Unknown parameters are rejected so + * that a misspelled one cannot silently fall back to its default. * * @param data field source URI with query parameters (see above). * @@ -233,18 +257,21 @@ public void init(final String data) throws IllegalArgumentException { final int queryStart = trimmed.indexOf('?'); if (queryStart < 0) { throw new IllegalArgumentException( - "transform data must be a field source URI followed by " + - "'?scaleIndex=&zIndex=&scaleX=&scaleY=', but was '" + data + "'"); + "transform data must be a field source URI followed by '?zIndex=' and optionally " + + "'&scaleX=&scaleY=&offsetX=&offsetY=&vectorScale=', " + + "but was '" + data + "'"); } final String parsedSourceUri = trimmed.substring(0, queryStart); final Map params = parseQueryParameters(trimmed.substring(queryStart + 1), data); init(parsedSourceUri, - new double[] { parseDoubleParameter(params, "scaleX", data), - parseDoubleParameter(params, "scaleY", data) }, - parseIntParameter(params, "scaleIndex", data), - parseIntParameter(params, "zIndex", data)); + parseIntParameter(params, "zIndex", data), + new double[] { parseDoubleParameter(params, "scaleX", DEFAULT_SCALE, data), + parseDoubleParameter(params, "scaleY", DEFAULT_SCALE, data) }, + new double[] { parseDoubleParameter(params, "offsetX", DEFAULT_OFFSET, data), + parseDoubleParameter(params, "offsetY", DEFAULT_OFFSET, data) }, + parseDoubleParameter(params, "vectorScale", DEFAULT_VECTOR_SCALE, data)); } @Override @@ -255,28 +282,39 @@ public String toXML(final String indent) { @Override public String toDataString() { - // Serializes all fields as a source URI plus query parameters so the string round-trips through init. + // Writes every parameter, including any left at its default, so a persisted string keeps its meaning + // even if a default ever changes. Callers building a string by hand may omit the defaulted ones. return fieldSourceUri + - "?scaleIndex=" + fieldScaleIndex + - "&zIndex=" + fieldZIndex + + "?zIndex=" + fieldZIndex + "&scaleX=" + xyScale[0] + - "&scaleY=" + xyScale[1]; + "&scaleY=" + xyScale[1] + + "&offsetX=" + offset[0] + + "&offsetY=" + offset[1] + + "&vectorScale=" + vectorScale; } @Override public CoordinateTransform copy() { // Re-loads the field so the copy has independent accessors (imglib2 accessors are not thread-safe). - return new DisplacementFieldTransform(fieldSourceUri, fieldScaleIndex, fieldZIndex, xyScale.clone()); + return new DisplacementFieldTransform(fieldSourceUri, fieldZIndex, xyScale.clone(), offset.clone(), vectorScale); } @Override public String toString() { return "{ \"fieldSourceUri\": \"" + fieldSourceUri + - "\", \"fieldScaleIndex\": " + fieldScaleIndex + - ", \"fieldZIndex\": " + fieldZIndex + - ", \"xyScale\": [" + xyScale[0] + ", " + xyScale[1] + "] }"; + "\", \"fieldZIndex\": " + fieldZIndex + + ", \"xyScale\": [" + xyScale[0] + ", " + xyScale[1] + "]" + + ", \"offset\": [" + offset[0] + ", " + offset[1] + "]" + + ", \"vectorScale\": " + vectorScale + " }"; } + private static final double DEFAULT_SCALE = 1.0; + private static final double DEFAULT_OFFSET = 0.0; + private static final double DEFAULT_VECTOR_SCALE = 1.0; + + private static final Set VALID_PARAMETERS = + Set.of("zIndex", "scaleX", "scaleY", "offsetX", "offsetY", "vectorScale"); + private static Map parseQueryParameters(final String query, final String data) { final Map params = new HashMap<>(); for (final String pair : query.split("&")) { @@ -288,7 +326,14 @@ private static Map parseQueryParameters(final String query, fina throw new IllegalArgumentException( "invalid query parameter '" + pair + "' in transform data '" + data + "'"); } - params.put(pair.substring(0, eq), pair.substring(eq + 1)); + final String key = pair.substring(0, eq); + if (! VALID_PARAMETERS.contains(key)) { + // Everything but zIndex is optional, so a typo would otherwise silently use the default. + throw new IllegalArgumentException( + "unknown query parameter '" + key + "' in transform data '" + data + + "'; supported parameters are " + VALID_PARAMETERS); + } + params.put(key, pair.substring(eq + 1)); } return params; } @@ -304,8 +349,14 @@ private static int parseIntParameter(final Map params, final Str } } - private static double parseDoubleParameter(final Map params, final String key, final String data) { - final String value = requireParameter(params, key, data); + private static double parseDoubleParameter(final Map params, + final String key, + final double defaultValue, + final String data) { + final String value = params.get(key); + if (value == null) { + return defaultValue; + } try { return Double.parseDouble(value); } catch (final NumberFormatException e) { diff --git a/render-app/src/test/java/org/janelia/alignment/transform/DisplacementFieldTransformTest.java b/render-app/src/test/java/org/janelia/alignment/transform/DisplacementFieldTransformTest.java index b749de27f..1175d4310 100644 --- a/render-app/src/test/java/org/janelia/alignment/transform/DisplacementFieldTransformTest.java +++ b/render-app/src/test/java/org/janelia/alignment/transform/DisplacementFieldTransformTest.java @@ -17,7 +17,8 @@ public class DisplacementFieldTransformTest { private static final String SAMPLE_URI = - "file:///tmp/does-not-exist.n5?scaleIndex=3&zIndex=5&scaleX=8.0&scaleY=8.0"; + "file:///tmp/does-not-exist.n5?zIndex=5&scaleX=8.0&scaleY=8.0&offsetX=100.0&offsetY=-50.0" + + "&vectorScale=2.0"; @Test public void testDataStringRoundTrip() { @@ -66,24 +67,40 @@ public void testAppliesPrecomputedField() throws Exception { new long[] {0, 0, 0}, (x, y, z, c) -> x + y + 10 * z + 100 * c); - // unscaled: full-resolution (1,1) reads field (1,1) of z-slice 1; vectors are negated (pull map) but - // otherwise used as stored - assertDisplacement(fieldDir, 0, 1, 1.0, new double[] {1.0, 1.0}, -12.0, -112.0); + // defaults only: scale 1 and offset 0 mean (1,1) reads field (1,1) of z-slice 1, and vector scale 1 means + // the stored vectors are used as-is apart from the pull-to-push negation + assertDisplacement(fieldDir, "zIndex=1", new double[] {1.0, 1.0}, -12.0, -112.0); - // xyScale 2 halves the query position (so (2,2) reads field (1,1)) and scaleIndex 2 quadruples the vectors - assertDisplacement(fieldDir, 2, 1, 2.0, new double[] {2.0, 2.0}, -12.0 * 4, -112.0 * 4); + // scale 2 halves the query position (so (2,2) reads field (1,1)) and vectorScale 4 quadruples the vectors + assertDisplacement(fieldDir, "zIndex=1&scaleX=2.0&scaleY=2.0&vectorScale=4.0", + new double[] {2.0, 2.0}, -12.0 * 4, -112.0 * 4); + + // offset shifts the query position, so (2,2) again reads field (1,1) + assertDisplacement(fieldDir, "zIndex=1&offsetX=1.0&offsetY=1.0", + new double[] {2.0, 2.0}, -12.0, -112.0); + + // x and y beyond the field are answered from the mirrored extension rather than failing; just past the last + // sample (the field is 4 wide, so x=3) the double-mirrored extension repeats that boundary value + assertDisplacement(fieldDir, "zIndex=1", new double[] {4.0, 1.0}, -14.0, -114.0); + + // z is guarded instead, since an out-of-range slice would read outside the cached image + final DisplacementFieldTransform transform = new DisplacementFieldTransform(); + try { + transform.init(fieldDir + "?zIndex=2"); + Assert.fail("expected init to reject a z index outside the field"); + } catch (final IllegalArgumentException e) { + Assert.assertTrue("exception should mention the z range, but was: " + e.getMessage(), + e.getMessage().contains("z range")); + } } private static void assertDisplacement(final Path fieldDir, - final int scaleIndex, - final int zIndex, - final double xyScale, + final String queryString, final double[] location, final double expectedDx, final double expectedDy) { - final String data = fieldDir + "?scaleIndex=" + scaleIndex + "&zIndex=" + zIndex + - "&scaleX=" + xyScale + "&scaleY=" + xyScale; + final String data = fieldDir + "?" + queryString; final DisplacementFieldTransform transform = new DisplacementFieldTransform(); transform.init(data); @@ -94,6 +111,19 @@ private static void assertDisplacement(final Path fieldDir, location[1] + expectedDy, displaced[1], 0.0001); } + @Test + public void testMisspelledParameterFails() { + // since everything but zIndex is optional, a typo would otherwise silently apply the default + final DisplacementFieldTransform transform = new DisplacementFieldTransform(); + try { + transform.init("file:///tmp/does-not-exist.n5?zIndex=0&scalex=40.0"); + Assert.fail("expected init to reject the misspelled parameter"); + } catch (final IllegalArgumentException e) { + Assert.assertTrue("exception should name the offending parameter", + e.getMessage().contains("scalex")); + } + } + @Test public void testImplementsCoordinateTransform() { // guards the reflective LeafTransformSpec.newInstance() contract (no-arg constructor + interface) diff --git a/render-ws-java-client/src/main/java/org/janelia/render/client/multisem/ImportSofimaClient.java b/render-ws-java-client/src/main/java/org/janelia/render/client/multisem/ImportSofimaClient.java index 0a33af152..d383102c2 100644 --- a/render-ws-java-client/src/main/java/org/janelia/render/client/multisem/ImportSofimaClient.java +++ b/render-ws-java-client/src/main/java/org/janelia/render/client/multisem/ImportSofimaClient.java @@ -31,10 +31,13 @@ * each layer, this client *

      *
    • computes {@code fieldZIndex} from the {@code --zOffset} parameter and the running (0-based) layer index,
    • - *
    • computes the field-to-full-resolution {@code xyScale} from the stack bounds and the field's XY dimensions,
    • *
    • appends a {@link DisplacementFieldTransform} with the resulting data string to each tile spec, and
    • *
    • saves the modified tile specs to the target stack.
    • *
    + * The data string carries only {@code zIndex} and the xy scale, which is either the {@code --scale} given on the + * command line or, if that is omitted, the stack bounds divided by the field dimensions and rounded to a whole + * number. The transform's remaining parameters (offset and vector scale) are left at their defaults, which suit + * SOFIMA output placed at the world origin with vectors already in full-resolution units. * If {@code --targetStack} is given, the modified tile specs are written there (the stack is derived from the source * if it does not yet exist); otherwise they are written back into the source stack. If {@code --completeTargetStack} * is set, the target stack is completed once all layers have been saved. @@ -57,8 +60,8 @@ public static class Parameters extends CommandLineParameters { private String targetStack; @Parameter(names = "--sofimaFieldUri", description = "URI of the SOFIMA displacement field N5 container", required = true) private String sofimaFieldUri; - @Parameter(names = "--sofimaScaleIndex", description = "Scale index at which the deformed images were fed to SOFIMA (used to adjust vector sizes)", required = true) - private int sofimaScaleIndex; + @Parameter(names = "--scale", description = "Full-resolution pixels per field pixel, i.e. the factor by which the field is downsampled in x and y (e.g. 40); derived from the stack bounds and the field dimensions if omitted") + private Double scale; @Parameter(names = "--zOffset", description = "Offset added to the running (0-based) layer index to obtain the field's z-slice index (default: 0)") private int zOffset = 0; @Parameter(names = "--completeTargetStack", description = "Complete the target stack after all layers have been saved") @@ -91,22 +94,24 @@ public void addDisplacementField() throws Exception { final StackMetaData sourceStackMetaData = renderClient.getStackMetaData(params.stack); - // Get full-resolution stack size and the field's XY size to scale the field to full resolution + // Open the field up front so that a bad URI fails before any stack is touched, and work out the scale final double[] xyScale; try (final N5Reader fieldReader = DisplacementFieldTransform.openPrecomputedReader(params.sofimaFieldUri)) { - - final Bounds stackBounds = sourceStackMetaData.getStats().getStackBounds(); // The precomputed dataset lives under the first scale key (see DisplacementFieldTransform); the // layout is [x,y,z,channel], so dim 0 is X and dim 1 is Y. final String scaleKey = fieldReader.list("/")[0]; final long[] fieldDimensions = fieldReader.getDatasetAttributes(scaleKey).getDimensions(); + final Bounds stackBounds = sourceStackMetaData.getStats().getStackBounds(); + + // The field does not cover the stack bounds exactly, rounding recovers the intended + // factor; the leftover strip is handled by the transform's mirrored extension. + xyScale = (params.scale != null) + ? new double[] { params.scale, params.scale } + : new double[] { Math.round(stackBounds.getDeltaX() / fieldDimensions[0]), + Math.round(stackBounds.getDeltaY() / fieldDimensions[1]) }; - xyScale = new double[]{ - stackBounds.getDeltaX() / fieldDimensions[0], - stackBounds.getDeltaY() / fieldDimensions[1] - }; - LOG.info("addDisplacementField: stack bounds are {}, field bounds are {}", stackBounds, Arrays.toString(fieldDimensions)); - LOG.info("addDisplacementField: xy scales are {}", Arrays.toString(xyScale)); + LOG.info("addDisplacementField: stack bounds are {}, field {} has dimensions {}, xy scale is {}", + stackBounds, scaleKey, Arrays.toString(fieldDimensions), Arrays.toString(xyScale)); } catch (final Exception e) { throw new IllegalArgumentException("Failed to process SOFIMA field at " + params.sofimaFieldUri, e); } @@ -161,14 +166,13 @@ private void addFieldToLayer(final Double z, /** * Compiles the {@link DisplacementFieldTransform} data string for one layer. The format must match what - * {@link DisplacementFieldTransform#init(String)} parses (and {@link DisplacementFieldTransform#toDataString()} - * produces). + * {@link DisplacementFieldTransform#init(String)} parses. Offset and vector scale are omitted so that the + * transform's defaults (0 and 1) apply. */ private String buildDataString(final int fieldZIndex, final double[] xyScale) { return params.sofimaFieldUri + - "?scaleIndex=" + params.sofimaScaleIndex + - "&zIndex=" + fieldZIndex + + "?zIndex=" + fieldZIndex + "&scaleX=" + xyScale[0] + "&scaleY=" + xyScale[1]; } From dd276c98e617973eabba187eafadd34c4b7e9520 Mon Sep 17 00:00:00 2001 From: Michael Innerberger Date: Wed, 5 Aug 2026 15:33:09 -0400 Subject: [PATCH 18/24] Parallelize ImportSofimaClient --- .../client/multisem/ImportSofimaClient.java | 39 ++++++++++++------- 1 file changed, 26 insertions(+), 13 deletions(-) diff --git a/render-ws-java-client/src/main/java/org/janelia/render/client/multisem/ImportSofimaClient.java b/render-ws-java-client/src/main/java/org/janelia/render/client/multisem/ImportSofimaClient.java index d383102c2..3bd7a0eb3 100644 --- a/render-ws-java-client/src/main/java/org/janelia/render/client/multisem/ImportSofimaClient.java +++ b/render-ws-java-client/src/main/java/org/janelia/render/client/multisem/ImportSofimaClient.java @@ -6,11 +6,12 @@ import java.io.IOException; import java.util.Arrays; import java.util.List; +import java.util.concurrent.ForkJoinPool; +import java.util.concurrent.ForkJoinTask; import org.janelia.alignment.spec.Bounds; import org.janelia.alignment.spec.LeafTransformSpec; import org.janelia.alignment.spec.ResolvedTileSpecCollection; -import org.janelia.alignment.spec.TileSpec; import org.janelia.alignment.spec.stack.StackMetaData; import org.janelia.alignment.transform.DisplacementFieldTransform; import org.janelia.render.client.ClientRunner; @@ -38,6 +39,8 @@ * command line or, if that is omitted, the stack bounds divided by the field dimensions and rounded to a whole * number. The transform's remaining parameters (offset and vector scale) are left at their defaults, which suit * SOFIMA output placed at the world origin with vectors already in full-resolution units. + * Layers are processed in order, but the tiles within a layer are processed by {@code --numThreads} threads, which is + * what parallelizes the field chunk reads that deriving the bounding boxes triggers. * If {@code --targetStack} is given, the modified tile specs are written there (the stack is derived from the source * if it does not yet exist); otherwise they are written back into the source stack. If {@code --completeTargetStack} * is set, the target stack is completed once all layers have been saved. @@ -66,6 +69,8 @@ public static class Parameters extends CommandLineParameters { private int zOffset = 0; @Parameter(names = "--completeTargetStack", description = "Complete the target stack after all layers have been saved") private boolean completeTargetStack = false; + @Parameter(names = "--numThreads", description = "Number of tiles within a layer to process concurrently (default: 1)") + private int numThreads = 1; } public static void main(final String[] args) { @@ -128,14 +133,14 @@ public void addDisplacementField() throws Exception { final List zValues = renderClient.getStackZValues(params.stack, params.zRangeParams.minZ, params.zRangeParams.maxZ); - LOG.info("addDisplacementField: processing {} layers", zValues.size()); + LOG.info("addDisplacementField: processing {} layers with {} threads", zValues.size(), params.numThreads); - for (int layerIndex = 0; layerIndex < zValues.size(); layerIndex++) { - final Double z = zValues.get(layerIndex); - final int fieldZIndex = params.zOffset + layerIndex; - final String dataString = buildDataString(fieldZIndex, xyScale); - - addFieldToLayer(z, dataString, targetStack); + // One pool, reused for the tiles of each layer in turn (see addFieldToLayer for why tiles and not layers) + try (final ForkJoinPool pool = new ForkJoinPool(params.numThreads)) { + for (int layerIndex = 0; layerIndex < zValues.size(); layerIndex++) { + final String dataString = buildDataString(params.zOffset + layerIndex, xyScale); + addFieldToLayer(zValues.get(layerIndex), dataString, targetStack, pool); + } } // Complete the target stack @@ -147,21 +152,29 @@ public void addDisplacementField() throws Exception { LOG.info("addDisplacementField: exit"); } + /** + * Adds the transform to every tile spec of one layer and saves them. Deriving a bounding box evaluates the + * transform over the tile's footprint, so this is where the (blocking) field chunk reads happen. Tiles are the + * natural axis to parallelize: they cover disjoint parts of the field, so concurrent tiles load disjoint chunks + * and the round-trip latency of those reads overlaps. + */ private void addFieldToLayer(final Double z, final String dataString, - final String targetStack) + final String targetStack, + final ForkJoinPool pool) throws IOException { final ResolvedTileSpecCollection tileSpecs = renderClient.getResolvedTiles(params.stack, z); - for (final TileSpec tileSpec : tileSpecs.getTileSpecs()) { - final LeafTransformSpec transformSpec = - new LeafTransformSpec(DisplacementFieldTransform.class.getName(), dataString); + // Run the parallel stream inside the given pool. Balances the load internally + pool.invoke(ForkJoinTask.adapt(() -> tileSpecs.getTileSpecs().parallelStream().forEach(tileSpec -> { + final LeafTransformSpec transformSpec = new LeafTransformSpec(DisplacementFieldTransform.class.getName(), dataString); tileSpec.addTransformSpecs(List.of(transformSpec)); tileSpec.deriveBoundingBox(tileSpec.getMeshCellSize(), true); - } + }))); renderClient.saveResolvedTiles(tileSpecs, targetStack, z); + LOG.info("addFieldToLayer: saved {} tile specs for z {}", tileSpecs.getTileCount(), z); } /** From e19e8d5a8f2f9d2c598e26e46ba2893f0f8341dd Mon Sep 17 00:00:00 2001 From: Michael Innerberger Date: Wed, 5 Aug 2026 16:03:20 -0400 Subject: [PATCH 19/24] Actually invert the deformation field --- .../transform/DisplacementFieldTransform.java | 69 +++++++++++++++---- .../DisplacementFieldTransformTest.java | 53 ++++++++++++-- 2 files changed, 105 insertions(+), 17 deletions(-) diff --git a/render-app/src/main/java/org/janelia/alignment/transform/DisplacementFieldTransform.java b/render-app/src/main/java/org/janelia/alignment/transform/DisplacementFieldTransform.java index 1337de292..034b9fc31 100644 --- a/render-app/src/main/java/org/janelia/alignment/transform/DisplacementFieldTransform.java +++ b/render-app/src/main/java/org/janelia/alignment/transform/DisplacementFieldTransform.java @@ -21,6 +21,8 @@ import com.google.cloud.storage.StorageOptions; import com.google.gson.GsonBuilder; import org.janelia.saalfeldlab.n5.universe.N5Factory; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import java.net.URI; import java.util.HashMap; @@ -30,8 +32,10 @@ /** - * Transform that reads a dense displacement (translation vector) field from a file on disk and adds the - * interpolated vector at each queried location to that location. + * Transform that reads a dense displacement (translation vector) field from a file on disk and moves each queried + * location by the interpolated vector. Since the field is a pull map (see {@link #extractAndTransform}), the vector + * belongs to the target location, so applying the transform means inverting the field, which {@link #applyInPlace} + * does by fixed-point iteration. */ public class DisplacementFieldTransform implements CoordinateTransform { @@ -50,6 +54,9 @@ public class DisplacementFieldTransform private RealRandomAccess displacementX; private RealRandomAccess displacementY; + /** Whether this instance has already logged a non-converging inversion (see {@link #applyInPlace}). */ + private boolean divergenceLogged = false; + /** * Reflection constructor; leaves the instance uninitialized until {@link #init(String)} is called. */ @@ -201,11 +208,9 @@ private RealRandomAccess extractAndTransform(final RandomAccessibleIn Views.interpolate(Views.extendMirrorDouble(slice), new NLinearInterpolatorFactory<>()), fieldToWorld); - // Invert the pull map and scale the vectors to full resolution, folded into a single factor applied last - // (after interpolation) to keep the number of passes down. - // ponytail: negating is a first-order inverse, p - d(p) instead of solving t = p - d(t). Exact enough for - // the fields seen so far (Jacobian ~2e-4, so it is off by well under 0.01 px); switch to a fixed-point - // iteration if a field with steep gradients ever shows up. + // Negate the pull map and scale the vectors to full resolution, folded into a single factor applied last + // (after interpolation) to keep the number of passes down. Negating alone only flips the vectors; the + // actual inversion (evaluating them at the target rather than the source) happens in applyInPlace. final double pullToPushScale = -this.vectorScale; return Converters.convert( scaledAndInterpolated, @@ -228,11 +233,45 @@ public void applyInPlace(final double[] location) { "displacement field has not been loaded; call init(String) before applying this transform"); } - // Query both components at the original (undisplaced) location before mutating it. - final double dx = displacementX.setPositionAndGet(location).getRealDouble(); - final double dy = displacementY.setPositionAndGet(location).getRealDouble(); - location[0] += dx; - location[1] += dy; + // The (negated) field vector belongs to the target location, not to the queried source location, so the + // target solves t = source + d(t). Iterate t <- source + d(t) from t = source; this converges (linearly, + // at the rate of the field's Jacobian norm) for any field whose Jacobian norm stays below one, which is + // the same condition under which the field is invertible at all. + final double[] target = { location[0], location[1] }; + final double[] vector = new double[2]; + double step = Double.POSITIVE_INFINITY; + for (int i = 0; (i < MAX_INVERSION_ITERATIONS) && (step > INVERSION_TOLERANCE); i++) { + lookUpVector(target, vector); + final double x = location[0] + vector[0]; + final double y = location[1] + vector[1]; + step = Math.max(Math.abs(x - target[0]), Math.abs(y - target[1])); + target[0] = x; + target[1] = y; + } + + // A non-invertible (or very steep) field may still be off by more than the tolerance after the iteration + // cap. The last estimate is used rather than failing a whole render, but it is logged once per instance + // (instances are per tile spec, so logging every occurrence would flood the log with a line per pixel). + if ((step > INVERSION_TOLERANCE) && (! divergenceLogged)) { + divergenceLogged = true; + LOG.warn("applyInPlace: inversion did not converge within {} iterations at ({}, {}) for field {}; " + + "residual step is {} px and the last estimate is used. Further occurrences for this " + + "transform instance are not logged.", + MAX_INVERSION_ITERATIONS, location[0], location[1], toDataString(), step); + } + + location[0] = target[0]; + location[1] = target[1]; + } + + /** + * Looks up the interpolated field vector (already negated and scaled to full resolution) at a world location. + * Package private so that tests can check the field placement on its own, separately from the inversion. + */ + void lookUpVector(final double[] location, + final double[] vector) { + vector[0] = displacementX.setPositionAndGet(location).getRealDouble(); + vector[1] = displacementY.setPositionAndGet(location).getRealDouble(); } /** @@ -308,10 +347,16 @@ public String toString() { ", \"vectorScale\": " + vectorScale + " }"; } + private static final Logger LOG = LoggerFactory.getLogger(DisplacementFieldTransform.class); + private static final double DEFAULT_SCALE = 1.0; private static final double DEFAULT_OFFSET = 0.0; private static final double DEFAULT_VECTOR_SCALE = 1.0; + /** Full-resolution pixels of movement below which the inversion in {@link #applyInPlace} is considered done. */ + private static final double INVERSION_TOLERANCE = 1e-4; + private static final int MAX_INVERSION_ITERATIONS = 20; + private static final Set VALID_PARAMETERS = Set.of("zIndex", "scaleX", "scaleY", "offsetX", "offsetY", "vectorScale"); diff --git a/render-app/src/test/java/org/janelia/alignment/transform/DisplacementFieldTransformTest.java b/render-app/src/test/java/org/janelia/alignment/transform/DisplacementFieldTransformTest.java index 1175d4310..f8cd2be54 100644 --- a/render-app/src/test/java/org/janelia/alignment/transform/DisplacementFieldTransformTest.java +++ b/render-app/src/test/java/org/janelia/alignment/transform/DisplacementFieldTransformTest.java @@ -1,6 +1,7 @@ package org.janelia.alignment.transform; import java.nio.file.Path; +import java.util.Arrays; import org.janelia.saalfeldlab.n5.DataType; import org.janelia.saalfeldlab.n5.precomputed.PrecomputedTestVolumes; @@ -104,11 +105,53 @@ private static void assertDisplacement(final Path fieldDir, final DisplacementFieldTransform transform = new DisplacementFieldTransform(); transform.init(data); - final double[] displaced = transform.apply(location); - Assert.assertEquals("wrong x displacement for " + data, - location[0] + expectedDx, displaced[0], 0.0001); - Assert.assertEquals("wrong y displacement for " + data, - location[1] + expectedDy, displaced[1], 0.0001); + // the raw lookup, not apply(): apply() inverts the field, which would move the query off the position + // whose placement is under test here (and this steep test field is not invertible anyway) + final double[] vector = new double[2]; + transform.lookUpVector(location, vector); + Assert.assertEquals("wrong x displacement for " + data, expectedDx, vector[0], 0.0001); + Assert.assertEquals("wrong y displacement for " + data, expectedDy, vector[1], 0.0001); + } + + /** + * The field is a pull map, so applying it means solving {@code t = p + d(t)} rather than evaluating {@code d} + * at {@code p}. Uses a field with displacement {@code -0.2*(x,y)}, whose exact fixed point is {@code p/1.2} — + * clearly apart from the first-order answer {@code 0.8*p}. + */ + @Test + public void testInvertsFieldByIteration() throws Exception { + + final Path fieldDir = tempFolder.newFolder("rampField").toPath(); + PrecomputedTestVolumes.writeRawVolume(fieldDir, + DataType.FLOAT32, + 2, + new long[] {64, 48, 1}, + new int[] {64, 48, 1}, + new long[] {0, 0, 0}, + (x, y, z, c) -> (c == 0) ? x : y); + + // vectorScale carries the 0.2 because the test volume can only hold whole numbers + final DisplacementFieldTransform transform = new DisplacementFieldTransform(); + transform.init(fieldDir + "?zIndex=0&vectorScale=0.2"); + + final double[] target = transform.apply(new double[] {24.0, 12.0}); + Assert.assertEquals("x should solve t = 24 - 0.2 * t", 20.0, target[0], 0.001); + Assert.assertEquals("y should solve t = 12 - 0.2 * t", 10.0, target[1], 0.001); + + // the defining property, independent of the analytic solution above + final double[] vector = new double[2]; + transform.lookUpVector(target, vector); + Assert.assertEquals("x residual", 0.0, 24.0 + vector[0] - target[0], 0.001); + Assert.assertEquals("y residual", 0.0, 12.0 + vector[1] - target[1], 0.001); + + // the same field at vectorScale 2 is not invertible (Jacobian norm 2), so the iteration hits its cap: + // that must warn and return the last estimate rather than throw or hand back a non-number + final DisplacementFieldTransform steep = new DisplacementFieldTransform(); + steep.init(fieldDir + "?zIndex=0&vectorScale=2.0"); + final double[] estimate = steep.apply(new double[] {24.0, 12.0}); + Assert.assertTrue("a non-converging inversion should still return numbers, but was " + + Arrays.toString(estimate), + Double.isFinite(estimate[0]) && Double.isFinite(estimate[1])); } @Test From 098ba7a64842874ef56392cf5d6a780258f4136c Mon Sep 17 00:00:00 2001 From: Michael Innerberger Date: Fri, 7 Aug 2026 15:06:57 -0400 Subject: [PATCH 20/24] Get the offset from the stack bounds --- .../client/multisem/ImportSofimaClient.java | 34 ++++++++++++------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/render-ws-java-client/src/main/java/org/janelia/render/client/multisem/ImportSofimaClient.java b/render-ws-java-client/src/main/java/org/janelia/render/client/multisem/ImportSofimaClient.java index 3bd7a0eb3..e6c6b9152 100644 --- a/render-ws-java-client/src/main/java/org/janelia/render/client/multisem/ImportSofimaClient.java +++ b/render-ws-java-client/src/main/java/org/janelia/render/client/multisem/ImportSofimaClient.java @@ -35,10 +35,11 @@ *
  • appends a {@link DisplacementFieldTransform} with the resulting data string to each tile spec, and
  • *
  • saves the modified tile specs to the target stack.
  • * - * The data string carries only {@code zIndex} and the xy scale, which is either the {@code --scale} given on the - * command line or, if that is omitted, the stack bounds divided by the field dimensions and rounded to a whole - * number. The transform's remaining parameters (offset and vector scale) are left at their defaults, which suit - * SOFIMA output placed at the world origin with vectors already in full-resolution units. + * The data string carries {@code zIndex}, the xy scale, and the xy offset. The scale is either the {@code --scale} + * given on the command line or, if that is omitted, the stack bounds divided by the field dimensions and rounded to a + * whole number. The offset is the minimum corner of the source stack bounds, since the field is computed on an export + * of that stack and an export re-origins the data at (0,0). Only the vector scale is left at its default, which suits + * SOFIMA output with vectors already in full-resolution units. * Layers are processed in order, but the tiles within a layer are processed by {@code --numThreads} threads, which is * what parallelizes the field chunk reads that deriving the bounding boxes triggers. * If {@code --targetStack} is given, the modified tile specs are written there (the stack is derived from the source @@ -98,6 +99,12 @@ public ImportSofimaClient(final Parameters parameters) { public void addDisplacementField() throws Exception { final StackMetaData sourceStackMetaData = renderClient.getStackMetaData(params.stack); + final Bounds stackBounds = sourceStackMetaData.getStats().getStackBounds(); + + // The field is computed on an export of this stack, and an export puts its own origin at (0,0) and merely + // notes the world offset in its metadata - which the field producer does not read. So field index 0 sits on + // the corner of the stack bounding box, not on the world origin. + final double[] offset = { stackBounds.getMinX(), stackBounds.getMinY() }; // Open the field up front so that a bad URI fails before any stack is touched, and work out the scale final double[] xyScale; @@ -106,7 +113,6 @@ public void addDisplacementField() throws Exception { // layout is [x,y,z,channel], so dim 0 is X and dim 1 is Y. final String scaleKey = fieldReader.list("/")[0]; final long[] fieldDimensions = fieldReader.getDatasetAttributes(scaleKey).getDimensions(); - final Bounds stackBounds = sourceStackMetaData.getStats().getStackBounds(); // The field does not cover the stack bounds exactly, rounding recovers the intended // factor; the leftover strip is handled by the transform's mirrored extension. @@ -115,8 +121,9 @@ public void addDisplacementField() throws Exception { : new double[] { Math.round(stackBounds.getDeltaX() / fieldDimensions[0]), Math.round(stackBounds.getDeltaY() / fieldDimensions[1]) }; - LOG.info("addDisplacementField: stack bounds are {}, field {} has dimensions {}, xy scale is {}", - stackBounds, scaleKey, Arrays.toString(fieldDimensions), Arrays.toString(xyScale)); + LOG.info("addDisplacementField: stack bounds are {}, field {} has dimensions {}, xy scale is {}, offset is {}", + stackBounds, scaleKey, Arrays.toString(fieldDimensions), Arrays.toString(xyScale), + Arrays.toString(offset)); } catch (final Exception e) { throw new IllegalArgumentException("Failed to process SOFIMA field at " + params.sofimaFieldUri, e); } @@ -138,7 +145,7 @@ public void addDisplacementField() throws Exception { // One pool, reused for the tiles of each layer in turn (see addFieldToLayer for why tiles and not layers) try (final ForkJoinPool pool = new ForkJoinPool(params.numThreads)) { for (int layerIndex = 0; layerIndex < zValues.size(); layerIndex++) { - final String dataString = buildDataString(params.zOffset + layerIndex, xyScale); + final String dataString = buildDataString(params.zOffset + layerIndex, xyScale, offset); addFieldToLayer(zValues.get(layerIndex), dataString, targetStack, pool); } } @@ -179,15 +186,18 @@ private void addFieldToLayer(final Double z, /** * Compiles the {@link DisplacementFieldTransform} data string for one layer. The format must match what - * {@link DisplacementFieldTransform#init(String)} parses. Offset and vector scale are omitted so that the - * transform's defaults (0 and 1) apply. + * {@link DisplacementFieldTransform#init(String)} parses. Only the vector scale is omitted, so that the + * transform's default of 1 applies (SOFIMA vectors are already in full-resolution pixels). */ private String buildDataString(final int fieldZIndex, - final double[] xyScale) { + final double[] xyScale, + final double[] offset) { return params.sofimaFieldUri + "?zIndex=" + fieldZIndex + "&scaleX=" + xyScale[0] + - "&scaleY=" + xyScale[1]; + "&scaleY=" + xyScale[1] + + "&offsetX=" + offset[0] + + "&offsetY=" + offset[1]; } private static final Logger LOG = LoggerFactory.getLogger(ImportSofimaClient.class); From d00fea5b2190f48e22610577094790074aaf30e5 Mon Sep 17 00:00:00 2001 From: Michael Innerberger Date: Fri, 7 Aug 2026 15:21:51 -0400 Subject: [PATCH 21/24] Simplify transformation string key-value arguments --- .../transform/DisplacementFieldTransform.java | 85 ++++++++++++------- .../DisplacementFieldTransformTest.java | 31 ++++--- .../client/multisem/ImportSofimaClient.java | 64 +++++++------- 3 files changed, 105 insertions(+), 75 deletions(-) diff --git a/render-app/src/main/java/org/janelia/alignment/transform/DisplacementFieldTransform.java b/render-app/src/main/java/org/janelia/alignment/transform/DisplacementFieldTransform.java index 034b9fc31..fc068885c 100644 --- a/render-app/src/main/java/org/janelia/alignment/transform/DisplacementFieldTransform.java +++ b/render-app/src/main/java/org/janelia/alignment/transform/DisplacementFieldTransform.java @@ -43,8 +43,8 @@ public class DisplacementFieldTransform /** URI (as supplied to {@link #init}) identifying the field on disk and its world-coordinate mapping. */ private String fieldSourceUri; private int fieldZIndex; - /** Full-resolution pixels per field pixel in x and y. */ - private double[] xyScale; + /** Full-resolution pixels per field pixel; the same in x and y. */ + private double scale; /** World coordinate that field index 0 maps to, in x and y. */ private double[] offset; /** Full-resolution pixels per unit of stored vector. */ @@ -63,7 +63,7 @@ public class DisplacementFieldTransform public DisplacementFieldTransform() { this.fieldSourceUri = null; this.fieldZIndex = -1; - this.xyScale = new double[] { DEFAULT_SCALE, DEFAULT_SCALE }; + this.scale = DEFAULT_SCALE; this.offset = new double[] { DEFAULT_OFFSET, DEFAULT_OFFSET }; this.vectorScale = DEFAULT_VECTOR_SCALE; @@ -76,7 +76,7 @@ public DisplacementFieldTransform() { * * @param fieldSourceUri URI locating the field on disk (see class Javadoc for format). * @param fieldZIndex The z-slice index of the field to use (the field may be 3D, but this transform is 2D) - * @param xyScale Full-resolution pixels per field pixel in x and y (1 leaves the field at full resolution) + * @param scale Full-resolution pixels per field pixel, in x and y alike (1 leaves the field at full resolution) * @param offset World coordinate that field index 0 maps to, in x and y (0 puts the field at the world origin) * @param vectorScale Full-resolution pixels per unit of stored vector (1 for vectors already in * full-resolution units, which is what SOFIMA emits) @@ -86,20 +86,20 @@ public DisplacementFieldTransform() { */ public DisplacementFieldTransform(final String fieldSourceUri, final int fieldZIndex, - final double[] xyScale, + final double scale, final double[] offset, final double vectorScale) { - this.init(fieldSourceUri, fieldZIndex, xyScale, offset, vectorScale); + this.init(fieldSourceUri, fieldZIndex, scale, offset, vectorScale); } private void init(final String fieldSourceUri, final int fieldZIndex, - final double[] xyScale, + final double scale, final double[] offset, final double vectorScale) { this.fieldSourceUri = fieldSourceUri; this.fieldZIndex = fieldZIndex; - this.xyScale = xyScale; + this.scale = scale; this.offset = offset; this.vectorScale = vectorScale; @@ -115,7 +115,7 @@ private void init(final String fieldSourceUri, // z would read outside the cached image, which is undefined rather than merely inaccurate. if ((fieldZIndex < 0) || (fieldZIndex >= fieldRaw.dimension(2))) { throw new IllegalArgumentException( - "zIndex " + fieldZIndex + " is outside the z range [0, " + fieldRaw.dimension(2) + + "z " + fieldZIndex + " is outside the z range [0, " + fieldRaw.dimension(2) + ") of the field at " + fieldSourceUri); } @@ -199,11 +199,11 @@ private RealRandomAccess extractAndTransform(final RandomAccessibleIn final RandomAccessibleInterval slice = Views.hyperSlice( Views.hyperSlice(cleaned, 3, xory), 2, this.fieldZIndex); - // Place the slice in world coordinates: field index 0 lands on offset, one field pixel spans xyScale - // full-resolution pixels, so a query at p reads the field at (p - offset) / xyScale. + // Place the slice in world coordinates: field index 0 lands on offset, one field pixel spans scale + // full-resolution pixels, so a query at p reads the field at (p - offset) / scale. final AffineTransform2D fieldToWorld = new AffineTransform2D(); - fieldToWorld.set(this.xyScale[0], 0, this.offset[0], - 0, this.xyScale[1], this.offset[1]); + fieldToWorld.set(this.scale, 0, this.offset[0], + 0, this.scale, this.offset[1]); final RealRandomAccessible scaledAndInterpolated = RealViews.affine( Views.interpolate(Views.extendMirrorDouble(slice), new NLinearInterpolatorFactory<>()), fieldToWorld); @@ -278,10 +278,10 @@ void lookUpVector(final double[] location, * Initializes this transform by parsing the data string and loading the field into an imglib2 image. *

    * The data string is the field source URI followed by {@code ?key=value} query parameters, e.g. - * {@code file:///path/to/field.n5?zIndex=5&scaleX=40.0&scaleY=40.0}. The portion before the {@code ?} + * {@code file:///path/to/field.n5?z=5&scale=40.0&offset=-5318.0,-783.0}. The portion before the {@code ?} * becomes the {@link #fieldSourceUri} (the actual path); the query parameters supply the remaining fields. - * Only {@code zIndex} is required; everything else defaults to the identity placement - * ({@code scaleX=scaleY=vectorScale=1}, {@code offsetX=offsetY=0}). Unknown parameters are rejected so + * Only {@code z} is required; everything else defaults to the identity placement + * ({@code scale=vectorScale=1}, {@code offset=0,0}). Unknown parameters are rejected so * that a misspelled one cannot silently fall back to its default. * * @param data field source URI with query parameters (see above). @@ -296,8 +296,8 @@ public void init(final String data) throws IllegalArgumentException { final int queryStart = trimmed.indexOf('?'); if (queryStart < 0) { throw new IllegalArgumentException( - "transform data must be a field source URI followed by '?zIndex=' and optionally " + - "'&scaleX=&scaleY=&offsetX=&offsetY=&vectorScale=', " + + "transform data must be a field source URI followed by '?z=' and optionally " + + "'&scale=&offset=,&vectorScale=', " + "but was '" + data + "'"); } @@ -305,11 +305,9 @@ public void init(final String data) throws IllegalArgumentException { final Map params = parseQueryParameters(trimmed.substring(queryStart + 1), data); init(parsedSourceUri, - parseIntParameter(params, "zIndex", data), - new double[] { parseDoubleParameter(params, "scaleX", DEFAULT_SCALE, data), - parseDoubleParameter(params, "scaleY", DEFAULT_SCALE, data) }, - new double[] { parseDoubleParameter(params, "offsetX", DEFAULT_OFFSET, data), - parseDoubleParameter(params, "offsetY", DEFAULT_OFFSET, data) }, + parseIntParameter(params, "z", data), + parseDoubleParameter(params, "scale", DEFAULT_SCALE, data), + parseDoublePairParameter(params, "offset", DEFAULT_OFFSET, data), parseDoubleParameter(params, "vectorScale", DEFAULT_VECTOR_SCALE, data)); } @@ -324,25 +322,23 @@ public String toDataString() { // Writes every parameter, including any left at its default, so a persisted string keeps its meaning // even if a default ever changes. Callers building a string by hand may omit the defaulted ones. return fieldSourceUri + - "?zIndex=" + fieldZIndex + - "&scaleX=" + xyScale[0] + - "&scaleY=" + xyScale[1] + - "&offsetX=" + offset[0] + - "&offsetY=" + offset[1] + + "?z=" + fieldZIndex + + "&scale=" + scale + + "&offset=" + offset[0] + "," + offset[1] + "&vectorScale=" + vectorScale; } @Override public CoordinateTransform copy() { // Re-loads the field so the copy has independent accessors (imglib2 accessors are not thread-safe). - return new DisplacementFieldTransform(fieldSourceUri, fieldZIndex, xyScale.clone(), offset.clone(), vectorScale); + return new DisplacementFieldTransform(fieldSourceUri, fieldZIndex, scale, offset.clone(), vectorScale); } @Override public String toString() { return "{ \"fieldSourceUri\": \"" + fieldSourceUri + "\", \"fieldZIndex\": " + fieldZIndex + - ", \"xyScale\": [" + xyScale[0] + ", " + xyScale[1] + "]" + + ", \"scale\": " + scale + ", \"offset\": [" + offset[0] + ", " + offset[1] + "]" + ", \"vectorScale\": " + vectorScale + " }"; } @@ -357,8 +353,7 @@ public String toString() { private static final double INVERSION_TOLERANCE = 1e-4; private static final int MAX_INVERSION_ITERATIONS = 20; - private static final Set VALID_PARAMETERS = - Set.of("zIndex", "scaleX", "scaleY", "offsetX", "offsetY", "vectorScale"); + private static final Set VALID_PARAMETERS = Set.of("z", "scale", "offset", "vectorScale"); private static Map parseQueryParameters(final String query, final String data) { final Map params = new HashMap<>(); @@ -373,7 +368,7 @@ private static Map parseQueryParameters(final String query, fina } final String key = pair.substring(0, eq); if (! VALID_PARAMETERS.contains(key)) { - // Everything but zIndex is optional, so a typo would otherwise silently use the default. + // Everything but z is optional, so a typo would otherwise silently use the default. throw new IllegalArgumentException( "unknown query parameter '" + key + "' in transform data '" + data + "'; supported parameters are " + VALID_PARAMETERS); @@ -411,6 +406,30 @@ private static double parseDoubleParameter(final Map params, } } + /** Parses a {@code key=,} pair; both components fall back to {@code defaultValue} if the key is absent. */ + private static double[] parseDoublePairParameter(final Map params, + final String key, + final double defaultValue, + final String data) { + final String value = params.get(key); + if (value == null) { + return new double[] { defaultValue, defaultValue }; + } + final String[] components = value.split(","); + if (components.length != 2) { + throw new IllegalArgumentException( + "parameter '" + key + "' must be two comma separated numbers, but was '" + value + + "' in transform data '" + data + "'"); + } + try { + return new double[] { Double.parseDouble(components[0]), Double.parseDouble(components[1]) }; + } catch (final NumberFormatException e) { + throw new IllegalArgumentException( + "invalid double value in parameter '" + key + "=" + value + + "' in transform data '" + data + "'", e); + } + } + private static String requireParameter(final Map params, final String key, final String data) { final String value = params.get(key); if (value == null) { diff --git a/render-app/src/test/java/org/janelia/alignment/transform/DisplacementFieldTransformTest.java b/render-app/src/test/java/org/janelia/alignment/transform/DisplacementFieldTransformTest.java index f8cd2be54..aa95850d7 100644 --- a/render-app/src/test/java/org/janelia/alignment/transform/DisplacementFieldTransformTest.java +++ b/render-app/src/test/java/org/janelia/alignment/transform/DisplacementFieldTransformTest.java @@ -18,8 +18,7 @@ public class DisplacementFieldTransformTest { private static final String SAMPLE_URI = - "file:///tmp/does-not-exist.n5?zIndex=5&scaleX=8.0&scaleY=8.0&offsetX=100.0&offsetY=-50.0" + - "&vectorScale=2.0"; + "file:///tmp/does-not-exist.n5?z=5&scale=8.0&offset=100.0,-50.0&vectorScale=2.0"; @Test public void testDataStringRoundTrip() { @@ -70,24 +69,34 @@ public void testAppliesPrecomputedField() throws Exception { // defaults only: scale 1 and offset 0 mean (1,1) reads field (1,1) of z-slice 1, and vector scale 1 means // the stored vectors are used as-is apart from the pull-to-push negation - assertDisplacement(fieldDir, "zIndex=1", new double[] {1.0, 1.0}, -12.0, -112.0); + assertDisplacement(fieldDir, "z=1", new double[] {1.0, 1.0}, -12.0, -112.0); // scale 2 halves the query position (so (2,2) reads field (1,1)) and vectorScale 4 quadruples the vectors - assertDisplacement(fieldDir, "zIndex=1&scaleX=2.0&scaleY=2.0&vectorScale=4.0", + assertDisplacement(fieldDir, "z=1&scale=2.0&vectorScale=4.0", new double[] {2.0, 2.0}, -12.0 * 4, -112.0 * 4); // offset shifts the query position, so (2,2) again reads field (1,1) - assertDisplacement(fieldDir, "zIndex=1&offsetX=1.0&offsetY=1.0", + assertDisplacement(fieldDir, "z=1&offset=1.0,1.0", new double[] {2.0, 2.0}, -12.0, -112.0); + // a malformed offset is rejected rather than silently read as one number + final DisplacementFieldTransform malformed = new DisplacementFieldTransform(); + try { + malformed.init(fieldDir + "?z=1&offset=1.0"); + Assert.fail("expected init to reject a one-component offset"); + } catch (final IllegalArgumentException e) { + Assert.assertTrue("exception should mention the offset, but was: " + e.getMessage(), + e.getMessage().contains("offset")); + } + // x and y beyond the field are answered from the mirrored extension rather than failing; just past the last // sample (the field is 4 wide, so x=3) the double-mirrored extension repeats that boundary value - assertDisplacement(fieldDir, "zIndex=1", new double[] {4.0, 1.0}, -14.0, -114.0); + assertDisplacement(fieldDir, "z=1", new double[] {4.0, 1.0}, -14.0, -114.0); // z is guarded instead, since an out-of-range slice would read outside the cached image final DisplacementFieldTransform transform = new DisplacementFieldTransform(); try { - transform.init(fieldDir + "?zIndex=2"); + transform.init(fieldDir + "?z=2"); Assert.fail("expected init to reject a z index outside the field"); } catch (final IllegalArgumentException e) { Assert.assertTrue("exception should mention the z range, but was: " + e.getMessage(), @@ -132,7 +141,7 @@ public void testInvertsFieldByIteration() throws Exception { // vectorScale carries the 0.2 because the test volume can only hold whole numbers final DisplacementFieldTransform transform = new DisplacementFieldTransform(); - transform.init(fieldDir + "?zIndex=0&vectorScale=0.2"); + transform.init(fieldDir + "?z=0&vectorScale=0.2"); final double[] target = transform.apply(new double[] {24.0, 12.0}); Assert.assertEquals("x should solve t = 24 - 0.2 * t", 20.0, target[0], 0.001); @@ -147,7 +156,7 @@ public void testInvertsFieldByIteration() throws Exception { // the same field at vectorScale 2 is not invertible (Jacobian norm 2), so the iteration hits its cap: // that must warn and return the last estimate rather than throw or hand back a non-number final DisplacementFieldTransform steep = new DisplacementFieldTransform(); - steep.init(fieldDir + "?zIndex=0&vectorScale=2.0"); + steep.init(fieldDir + "?z=0&vectorScale=2.0"); final double[] estimate = steep.apply(new double[] {24.0, 12.0}); Assert.assertTrue("a non-converging inversion should still return numbers, but was " + Arrays.toString(estimate), @@ -156,10 +165,10 @@ public void testInvertsFieldByIteration() throws Exception { @Test public void testMisspelledParameterFails() { - // since everything but zIndex is optional, a typo would otherwise silently apply the default + // since everything but z is optional, a typo would otherwise silently apply the default final DisplacementFieldTransform transform = new DisplacementFieldTransform(); try { - transform.init("file:///tmp/does-not-exist.n5?zIndex=0&scalex=40.0"); + transform.init("file:///tmp/does-not-exist.n5?z=0&scalex=40.0"); Assert.fail("expected init to reject the misspelled parameter"); } catch (final IllegalArgumentException e) { Assert.assertTrue("exception should name the offending parameter", diff --git a/render-ws-java-client/src/main/java/org/janelia/render/client/multisem/ImportSofimaClient.java b/render-ws-java-client/src/main/java/org/janelia/render/client/multisem/ImportSofimaClient.java index e6c6b9152..bfedbd254 100644 --- a/render-ws-java-client/src/main/java/org/janelia/render/client/multisem/ImportSofimaClient.java +++ b/render-ws-java-client/src/main/java/org/janelia/render/client/multisem/ImportSofimaClient.java @@ -31,15 +31,16 @@ * through the same {@link DisplacementFieldTransform#openPrecomputedReader} path the transform itself uses). For * each layer, this client *

      - *
    • computes {@code fieldZIndex} from the {@code --zOffset} parameter and the running (0-based) layer index,
    • + *
    • computes {@code fieldZIndex} from the layer z and the stack's {@code minZ},
    • *
    • appends a {@link DisplacementFieldTransform} with the resulting data string to each tile spec, and
    • *
    • saves the modified tile specs to the target stack.
    • *
    - * The data string carries {@code zIndex}, the xy scale, and the xy offset. The scale is either the {@code --scale} - * given on the command line or, if that is omitted, the stack bounds divided by the field dimensions and rounded to a - * whole number. The offset is the minimum corner of the source stack bounds, since the field is computed on an export - * of that stack and an export re-origins the data at (0,0). Only the vector scale is left at its default, which suits - * SOFIMA output with vectors already in full-resolution units. + * Field index {@code (0,0,0)} is taken to sit on the minimum corner {@code (minX,minY,minZ)} of the source stack + * bounds, since the field is computed on an export of that stack and an export re-origins the data at {@code (0,0,0)}. + * The x and y parts of that corner go into the data string as the transform's offset; the z part turns the layer z + * into the field's z-slice index. The data string's scale is either the {@code --scale} given on the command line or, + * if that is omitted, the stack bounds divided by the field dimensions and rounded to a whole number. Only the vector + * scale is left at its default, which suits SOFIMA output with vectors already in full-resolution units. * Layers are processed in order, but the tiles within a layer are processed by {@code --numThreads} threads, which is * what parallelizes the field chunk reads that deriving the bounding boxes triggers. * If {@code --targetStack} is given, the modified tile specs are written there (the stack is derived from the source @@ -66,8 +67,6 @@ public static class Parameters extends CommandLineParameters { private String sofimaFieldUri; @Parameter(names = "--scale", description = "Full-resolution pixels per field pixel, i.e. the factor by which the field is downsampled in x and y (e.g. 40); derived from the stack bounds and the field dimensions if omitted") private Double scale; - @Parameter(names = "--zOffset", description = "Offset added to the running (0-based) layer index to obtain the field's z-slice index (default: 0)") - private int zOffset = 0; @Parameter(names = "--completeTargetStack", description = "Complete the target stack after all layers have been saved") private boolean completeTargetStack = false; @Parameter(names = "--numThreads", description = "Number of tiles within a layer to process concurrently (default: 1)") @@ -101,13 +100,13 @@ public void addDisplacementField() throws Exception { final StackMetaData sourceStackMetaData = renderClient.getStackMetaData(params.stack); final Bounds stackBounds = sourceStackMetaData.getStats().getStackBounds(); - // The field is computed on an export of this stack, and an export puts its own origin at (0,0) and merely - // notes the world offset in its metadata - which the field producer does not read. So field index 0 sits on - // the corner of the stack bounding box, not on the world origin. - final double[] offset = { stackBounds.getMinX(), stackBounds.getMinY() }; + // The field is computed on an export of this stack, and an export puts its own origin at (0,0,0) and merely + // notes the world offset in its metadata - which the field producer does not read. So field index (0,0,0) + // sits on the minimum corner of the stack bounding box, in z just as much as in x and y. + final double[] offset = { stackBounds.getMinX(), stackBounds.getMinY(), stackBounds.getMinZ() }; // Open the field up front so that a bad URI fails before any stack is touched, and work out the scale - final double[] xyScale; + final double scale; try (final N5Reader fieldReader = DisplacementFieldTransform.openPrecomputedReader(params.sofimaFieldUri)) { // The precomputed dataset lives under the first scale key (see DisplacementFieldTransform); the // layout is [x,y,z,channel], so dim 0 is X and dim 1 is Y. @@ -116,14 +115,17 @@ public void addDisplacementField() throws Exception { // The field does not cover the stack bounds exactly, rounding recovers the intended // factor; the leftover strip is handled by the transform's mirrored extension. - xyScale = (params.scale != null) - ? new double[] { params.scale, params.scale } - : new double[] { Math.round(stackBounds.getDeltaX() / fieldDimensions[0]), - Math.round(stackBounds.getDeltaY() / fieldDimensions[1]) }; - - LOG.info("addDisplacementField: stack bounds are {}, field {} has dimensions {}, xy scale is {}, offset is {}", - stackBounds, scaleKey, Arrays.toString(fieldDimensions), Arrays.toString(xyScale), - Arrays.toString(offset)); + final double xScale = Math.round(stackBounds.getDeltaX() / fieldDimensions[0]); + final double yScale = Math.round(stackBounds.getDeltaY() / fieldDimensions[1]); + if ((params.scale == null) && (xScale != yScale)) { + // The transform downsamples x and y by the same factor, so a field that does not is not supported. + throw new IllegalArgumentException( + "derived x and y scales differ (" + xScale + " vs " + yScale + "); pass --scale explicitly"); + } + scale = (params.scale != null) ? params.scale : xScale; + + LOG.info("addDisplacementField: stack bounds are {}, field {} has dimensions {}, scale is {}, offset is {}", + stackBounds, scaleKey, Arrays.toString(fieldDimensions), scale, Arrays.toString(offset)); } catch (final Exception e) { throw new IllegalArgumentException("Failed to process SOFIMA field at " + params.sofimaFieldUri, e); } @@ -144,9 +146,11 @@ public void addDisplacementField() throws Exception { // One pool, reused for the tiles of each layer in turn (see addFieldToLayer for why tiles and not layers) try (final ForkJoinPool pool = new ForkJoinPool(params.numThreads)) { - for (int layerIndex = 0; layerIndex < zValues.size(); layerIndex++) { - final String dataString = buildDataString(params.zOffset + layerIndex, xyScale, offset); - addFieldToLayer(zValues.get(layerIndex), dataString, targetStack, pool); + for (final Double z : zValues) { + // Derive the slice from z itself rather than from the running layer index, so that a gap in the + // stack's z values does not shift every later layer onto the wrong slice. + final long fieldZIndex = Math.round(z - offset[2]); + addFieldToLayer(z, buildDataString(fieldZIndex, scale, offset), targetStack, pool); } } @@ -189,15 +193,13 @@ private void addFieldToLayer(final Double z, * {@link DisplacementFieldTransform#init(String)} parses. Only the vector scale is omitted, so that the * transform's default of 1 applies (SOFIMA vectors are already in full-resolution pixels). */ - private String buildDataString(final int fieldZIndex, - final double[] xyScale, + private String buildDataString(final long fieldZIndex, + final double scale, final double[] offset) { return params.sofimaFieldUri + - "?zIndex=" + fieldZIndex + - "&scaleX=" + xyScale[0] + - "&scaleY=" + xyScale[1] + - "&offsetX=" + offset[0] + - "&offsetY=" + offset[1]; + "?z=" + fieldZIndex + + "&scale=" + scale + + "&offset=" + offset[0] + "," + offset[1]; } private static final Logger LOG = LoggerFactory.getLogger(ImportSofimaClient.class); From 83b66fe5c442eff2f4f0569823667ad15a5baded Mon Sep 17 00:00:00 2001 From: Michael Innerberger Date: Sat, 12 Sep 2026 11:48:46 -0400 Subject: [PATCH 22/24] Use updated n5-ng-precomputed library (different groupId, deployed) --- render-app/pom.xml | 12 +++++------- .../transform/DisplacementFieldTransform.java | 4 ++-- .../transform/DisplacementFieldTransformTest.java | 2 +- 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/render-app/pom.xml b/render-app/pom.xml index 96a62c5a2..fb17b1d4f 100644 --- a/render-app/pom.xml +++ b/render-app/pom.xml @@ -170,20 +170,18 @@ n5-universe
    - + - org.janelia.saalfeldlab + org.janelia n5-ng-precomputed - 0.1.0-SNAPSHOT + 0.1.0 - org.janelia.saalfeldlab + org.janelia n5-ng-precomputed - 0.1.0-SNAPSHOT + 0.1.0 tests test diff --git a/render-app/src/main/java/org/janelia/alignment/transform/DisplacementFieldTransform.java b/render-app/src/main/java/org/janelia/alignment/transform/DisplacementFieldTransform.java index fc068885c..f6dc9d883 100644 --- a/render-app/src/main/java/org/janelia/alignment/transform/DisplacementFieldTransform.java +++ b/render-app/src/main/java/org/janelia/alignment/transform/DisplacementFieldTransform.java @@ -14,8 +14,8 @@ import org.janelia.saalfeldlab.n5.N5Reader; import org.janelia.saalfeldlab.n5.googlecloud.GoogleCloudStorageKeyValueAccess; import org.janelia.saalfeldlab.n5.imglib2.N5Utils; -import org.janelia.saalfeldlab.n5.precomputed.N5PrecomputedReader; -import org.janelia.saalfeldlab.n5.precomputed.PrecomputedKeyValueReader; +import org.janelia.n5.precomputed.N5PrecomputedReader; +import org.janelia.n5.precomputed.PrecomputedKeyValueReader; import com.google.cloud.storage.Storage; import com.google.cloud.storage.StorageOptions; diff --git a/render-app/src/test/java/org/janelia/alignment/transform/DisplacementFieldTransformTest.java b/render-app/src/test/java/org/janelia/alignment/transform/DisplacementFieldTransformTest.java index aa95850d7..b6246b988 100644 --- a/render-app/src/test/java/org/janelia/alignment/transform/DisplacementFieldTransformTest.java +++ b/render-app/src/test/java/org/janelia/alignment/transform/DisplacementFieldTransformTest.java @@ -4,7 +4,7 @@ import java.util.Arrays; import org.janelia.saalfeldlab.n5.DataType; -import org.janelia.saalfeldlab.n5.precomputed.PrecomputedTestVolumes; +import org.janelia.n5.precomputed.PrecomputedTestVolumes; import org.junit.Assert; import org.junit.Rule; import org.junit.Test; From d78677d769a0403475a10bcd389d5752cf0f59db Mon Sep 17 00:00:00 2001 From: Michael Innerberger Date: Sat, 12 Sep 2026 12:17:17 -0400 Subject: [PATCH 23/24] Pull query parameter parsing into separate class --- .../alignment/loader/N5SliceLoader.java | 72 +++----- .../transform/DisplacementFieldTransform.java | 106 ++--------- .../util/QueryKeyValueParameters.java | 173 ++++++++++++++++++ .../util/QueryKeyValueParametersTest.java | 97 ++++++++++ 4 files changed, 307 insertions(+), 141 deletions(-) create mode 100644 render-app/src/main/java/org/janelia/alignment/util/QueryKeyValueParameters.java create mode 100644 render-app/src/test/java/org/janelia/alignment/util/QueryKeyValueParametersTest.java diff --git a/render-app/src/main/java/org/janelia/alignment/loader/N5SliceLoader.java b/render-app/src/main/java/org/janelia/alignment/loader/N5SliceLoader.java index d2801f456..cd176707d 100644 --- a/render-app/src/main/java/org/janelia/alignment/loader/N5SliceLoader.java +++ b/render-app/src/main/java/org/janelia/alignment/loader/N5SliceLoader.java @@ -9,9 +9,11 @@ import java.net.URI; import java.net.URLDecoder; import java.nio.charset.Charset; +import java.util.Optional; import net.imglib2.loops.LoopBuilder; import net.imglib2.type.numeric.integer.UnsignedShortType; +import org.janelia.alignment.util.QueryKeyValueParameters; import org.janelia.saalfeldlab.n5.DataType; import org.janelia.saalfeldlab.n5.DatasetAttributes; import org.janelia.saalfeldlab.n5.N5FSReader; @@ -67,44 +69,19 @@ public ImageProcessor load(final String urlString) final String defaultCharsetName = Charset.defaultCharset().name(); final String basePath = URLDecoder.decode(uri.getPath(), defaultCharsetName); - final String query = uri.getQuery(); - final String[] queryKeyValuePairs = query.split("&"); // note: uses "fastpath" for simple regex - String dataSet = null; - Long x = null; - Long y = null; - Long z = null; - Integer width = null; - Integer height = null; - for (final String keyValuePair : queryKeyValuePairs) { - final String[] keyValue = keyValuePair.split("="); - if (keyValue.length == 2) { - final String key = keyValue[0]; - if ("x".equals(key)){ - x = Long.valueOf(keyValue[1]); - } else if ("y".equals(key)) { - y = Long.valueOf(keyValue[1]); - } else if ("z".equals(key)) { - z = Long.valueOf(keyValue[1]); - } else if ("w".equals(key)) { - width = Integer.valueOf(keyValue[1]); - } else if ("h".equals(key)) { - height = Integer.valueOf(keyValue[1]); - } else if ("dataSet".equals(key)) { - dataSet = URLDecoder.decode(keyValue[1], defaultCharsetName); - } - } - } + final QueryKeyValueParameters query = new QueryKeyValueParameters(uri.getQuery(), urlString); - long[] xAndYOffsets = null; - if (x != null) { - if (y != null) { - xAndYOffsets = new long[] { x, y }; - } else { - xAndYOffsets = new long[] { x, 0 }; - } - } else if (y != null) { - xAndYOffsets = new long[] { 0, y }; - } + final Optional rawDataSet = query.getString("dataSet"); + final String dataSet = rawDataSet.isPresent() ? URLDecoder.decode(rawDataSet.get(), defaultCharsetName) : null; + final Long z = query.getLong("z").orElse(null); + Integer width = query.getInt("w").orElse(null); + Integer height = query.getInt("h").orElse(null); + + final Optional x = query.getLong("x"); + final Optional y = query.getLong("y"); + final long[] xAndYOffsets = (x.isPresent() || y.isPresent()) + ? new long[] { x.orElse(0L), y.orElse(0L) } + : null; if ((basePath != null) && (dataSet != null)) { @@ -128,20 +105,13 @@ public ImageProcessor load(final String urlString) height = (int) dimensions[1]; } - switch(dataType) { - case UINT8: - imageProcessor = UNSIGNED_BYTE_HELPER.load(reader, dataSet, width, height, xAndYOffsets, z); - break; - case INT16: - imageProcessor = SHORT_HELPER.load(reader, dataSet, width, height, xAndYOffsets, z); - break; - case FLOAT32: - imageProcessor = FLOAT_HELPER.load(reader, dataSet, width, height, xAndYOffsets, z); - break; - default: - // case INT8: case INT32: case INT64: case FLOAT64: case OBJECT: case UINT16: case UINT32: case UINT64: - throw new IllegalArgumentException("dataType " + dataType + " is not supported"); - } + imageProcessor = switch (dataType) { + case UINT8 -> UNSIGNED_BYTE_HELPER.load(reader, dataSet, width, height, xAndYOffsets, z); + case INT16 -> SHORT_HELPER.load(reader, dataSet, width, height, xAndYOffsets, z); + case FLOAT32 -> FLOAT_HELPER.load(reader, dataSet, width, height, xAndYOffsets, z); + // case INT8: case INT32: case INT64: case FLOAT64: case OBJECT: case UINT16: case UINT32: case UINT64: + default -> throw new IllegalArgumentException("dataType " + dataType + " is not supported"); + }; } else { throw new IllegalArgumentException( diff --git a/render-app/src/main/java/org/janelia/alignment/transform/DisplacementFieldTransform.java b/render-app/src/main/java/org/janelia/alignment/transform/DisplacementFieldTransform.java index f6dc9d883..b479fbc47 100644 --- a/render-app/src/main/java/org/janelia/alignment/transform/DisplacementFieldTransform.java +++ b/render-app/src/main/java/org/janelia/alignment/transform/DisplacementFieldTransform.java @@ -20,13 +20,14 @@ import com.google.cloud.storage.Storage; import com.google.cloud.storage.StorageOptions; import com.google.gson.GsonBuilder; +import org.janelia.alignment.util.QueryKeyValueParameters; import org.janelia.saalfeldlab.n5.universe.N5Factory; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.net.URI; -import java.util.HashMap; import java.util.Map; +import java.util.Optional; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; @@ -302,13 +303,22 @@ public void init(final String data) throws IllegalArgumentException { } final String parsedSourceUri = trimmed.substring(0, queryStart); - final Map params = parseQueryParameters(trimmed.substring(queryStart + 1), data); + final QueryKeyValueParameters params = new QueryKeyValueParameters(trimmed.substring(queryStart + 1), data); + params.validateKeys(VALID_PARAMETERS); + + final Optional parsedOffset = params.getDoubleArray("offset"); + if (parsedOffset.isPresent() && (parsedOffset.get().length != 2)) { + throw new IllegalArgumentException( + "parameter 'offset' must be two comma separated numbers, but was '" + + params.getString("offset").orElseThrow() + "' in transform data '" + data + "'"); + } + final double[] offset = parsedOffset.orElse(new double[] { DEFAULT_OFFSET, DEFAULT_OFFSET }); init(parsedSourceUri, - parseIntParameter(params, "z", data), - parseDoubleParameter(params, "scale", DEFAULT_SCALE, data), - parseDoublePairParameter(params, "offset", DEFAULT_OFFSET, data), - parseDoubleParameter(params, "vectorScale", DEFAULT_VECTOR_SCALE, data)); + params.getInt("z").orElseThrow(() -> new IllegalArgumentException("missing required parameter 'z' in transform data '" + data + "'")), + params.getDouble("scale").orElse(DEFAULT_SCALE), + offset, + params.getDouble("vectorScale").orElse(DEFAULT_VECTOR_SCALE)); } @Override @@ -354,88 +364,4 @@ public String toString() { private static final int MAX_INVERSION_ITERATIONS = 20; private static final Set VALID_PARAMETERS = Set.of("z", "scale", "offset", "vectorScale"); - - private static Map parseQueryParameters(final String query, final String data) { - final Map params = new HashMap<>(); - for (final String pair : query.split("&")) { - if (pair.isEmpty()) { - continue; - } - final int eq = pair.indexOf('='); - if (eq < 0) { - throw new IllegalArgumentException( - "invalid query parameter '" + pair + "' in transform data '" + data + "'"); - } - final String key = pair.substring(0, eq); - if (! VALID_PARAMETERS.contains(key)) { - // Everything but z is optional, so a typo would otherwise silently use the default. - throw new IllegalArgumentException( - "unknown query parameter '" + key + "' in transform data '" + data + - "'; supported parameters are " + VALID_PARAMETERS); - } - params.put(key, pair.substring(eq + 1)); - } - return params; - } - - private static int parseIntParameter(final Map params, final String key, final String data) { - final String value = requireParameter(params, key, data); - try { - return Integer.parseInt(value); - } catch (final NumberFormatException e) { - throw new IllegalArgumentException( - "invalid integer value '" + value + "' for parameter '" + key + - "' in transform data '" + data + "'", e); - } - } - - private static double parseDoubleParameter(final Map params, - final String key, - final double defaultValue, - final String data) { - final String value = params.get(key); - if (value == null) { - return defaultValue; - } - try { - return Double.parseDouble(value); - } catch (final NumberFormatException e) { - throw new IllegalArgumentException( - "invalid double value '" + value + "' for parameter '" + key + - "' in transform data '" + data + "'", e); - } - } - - /** Parses a {@code key=,} pair; both components fall back to {@code defaultValue} if the key is absent. */ - private static double[] parseDoublePairParameter(final Map params, - final String key, - final double defaultValue, - final String data) { - final String value = params.get(key); - if (value == null) { - return new double[] { defaultValue, defaultValue }; - } - final String[] components = value.split(","); - if (components.length != 2) { - throw new IllegalArgumentException( - "parameter '" + key + "' must be two comma separated numbers, but was '" + value + - "' in transform data '" + data + "'"); - } - try { - return new double[] { Double.parseDouble(components[0]), Double.parseDouble(components[1]) }; - } catch (final NumberFormatException e) { - throw new IllegalArgumentException( - "invalid double value in parameter '" + key + "=" + value + - "' in transform data '" + data + "'", e); - } - } - - private static String requireParameter(final Map params, final String key, final String data) { - final String value = params.get(key); - if (value == null) { - throw new IllegalArgumentException( - "missing required parameter '" + key + "' in transform data '" + data + "'"); - } - return value; - } } diff --git a/render-app/src/main/java/org/janelia/alignment/util/QueryKeyValueParameters.java b/render-app/src/main/java/org/janelia/alignment/util/QueryKeyValueParameters.java new file mode 100644 index 000000000..821871f67 --- /dev/null +++ b/render-app/src/main/java/org/janelia/alignment/util/QueryKeyValueParameters.java @@ -0,0 +1,173 @@ +package org.janelia.alignment.util; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +/** + * Parses a {@code key=value&key2=value2} style query string into typed accessors. + * Used both for URI query strings and for the {@code ?key=value&...} suffix of a + * hand-encoded data string (e.g. a {@code CoordinateTransform} data string). + *

    + * Every accessor returns an empty {@link Optional} for an absent key; callers decide how to handle + * that (a required parameter throws via {@code orElseThrow}, an optional one falls back via + * {@code orElse}). + */ +public class QueryKeyValueParameters { + + private final Map params = new HashMap<>(); + private final String source; + + /** + * @param query the query string to parse (everything after the {@code ?}, without the {@code ?} itself); + * may be {@code null} or empty, in which case no parameters are present. + * @param source the full original string, included in error messages so they can point back to it. + */ + public QueryKeyValueParameters(final String query, final String source) { + this.source = source; + if (query != null) { + for (final String pair : query.split("&")) { + if (pair.isEmpty()) { + continue; + } + final int eq = pair.indexOf('='); + if (eq < 0) { + throw new IllegalArgumentException( + "invalid query parameter '" + pair + "' in '" + source + "'"); + } + params.put(pair.substring(0, eq), pair.substring(eq + 1)); + } + } + } + + public Optional getString(final String key) { + return Optional.ofNullable(params.get(key)); + } + + /** @throws IllegalArgumentException if present but not a valid integer. */ + public Optional getInt(final String key) { + return getString(key).map(value -> parseInt(key, value)); + } + + /** @throws IllegalArgumentException if present but not a valid long. */ + public Optional getLong(final String key) { + return getString(key).map(value -> parseLong(key, value)); + } + + /** @throws IllegalArgumentException if present but not a valid float. */ + public Optional getFloat(final String key) { + return getString(key).map(value -> parseFloat(key, value)); + } + + /** @throws IllegalArgumentException if present but not a valid double. */ + public Optional getDouble(final String key) { + return getString(key).map(value -> parseDouble(key, value)); + } + + /** @throws IllegalArgumentException if present but contains an invalid integer. */ + public Optional getIntArray(final String key) { + return getString(key).map(value -> parseIntArray(key, value)); + } + + /** @throws IllegalArgumentException if present but contains an invalid long. */ + public Optional getLongArray(final String key) { + return getString(key).map(value -> parseLongArray(key, value)); + } + + /** @throws IllegalArgumentException if present but contains an invalid float. */ + public Optional getFloatArray(final String key) { + return getString(key).map(value -> parseFloatArray(key, value)); + } + + /** @throws IllegalArgumentException if present but contains an invalid double. */ + public Optional getDoubleArray(final String key) { + return getString(key).map(value -> parseDoubleArray(key, value)); + } + + /** + * @throws IllegalArgumentException if any parsed key is not in {@code validKeys}; helps catch typos that + * would otherwise silently fall back to a default value. + */ + public void validateKeys(final Set validKeys) { + for (final String key : params.keySet()) { + if (! validKeys.contains(key)) { + throw new IllegalArgumentException( + "unknown query parameter '" + key + "' in '" + source + + "'; supported parameters are " + validKeys); + } + } + } + + private int parseInt(final String key, final String value) { + try { + return Integer.parseInt(value); + } catch (final NumberFormatException e) { + throw new IllegalArgumentException( + "invalid integer value '" + value + "' for parameter '" + key + "' in '" + source + "'", e); + } + } + + private long parseLong(final String key, final String value) { + try { + return Long.parseLong(value); + } catch (final NumberFormatException e) { + throw new IllegalArgumentException( + "invalid long value '" + value + "' for parameter '" + key + "' in '" + source + "'", e); + } + } + + private float parseFloat(final String key, final String value) { + try { + return Float.parseFloat(value); + } catch (final NumberFormatException e) { + throw new IllegalArgumentException( + "invalid float value '" + value + "' for parameter '" + key + "' in '" + source + "'", e); + } + } + + private double parseDouble(final String key, final String value) { + try { + return Double.parseDouble(value); + } catch (final NumberFormatException e) { + throw new IllegalArgumentException( + "invalid double value '" + value + "' for parameter '" + key + "' in '" + source + "'", e); + } + } + + private int[] parseIntArray(final String key, final String value) { + final String[] parts = value.split(","); + final int[] result = new int[parts.length]; + for (int i = 0; i < parts.length; i++) { + result[i] = parseInt(key, parts[i]); + } + return result; + } + + private long[] parseLongArray(final String key, final String value) { + final String[] parts = value.split(","); + final long[] result = new long[parts.length]; + for (int i = 0; i < parts.length; i++) { + result[i] = parseLong(key, parts[i]); + } + return result; + } + + private float[] parseFloatArray(final String key, final String value) { + final String[] parts = value.split(","); + final float[] result = new float[parts.length]; + for (int i = 0; i < parts.length; i++) { + result[i] = parseFloat(key, parts[i]); + } + return result; + } + + private double[] parseDoubleArray(final String key, final String value) { + final String[] parts = value.split(","); + final double[] result = new double[parts.length]; + for (int i = 0; i < parts.length; i++) { + result[i] = parseDouble(key, parts[i]); + } + return result; + } +} diff --git a/render-app/src/test/java/org/janelia/alignment/util/QueryKeyValueParametersTest.java b/render-app/src/test/java/org/janelia/alignment/util/QueryKeyValueParametersTest.java new file mode 100644 index 000000000..0994dc589 --- /dev/null +++ b/render-app/src/test/java/org/janelia/alignment/util/QueryKeyValueParametersTest.java @@ -0,0 +1,97 @@ +package org.janelia.alignment.util; + +import java.util.Optional; +import java.util.Set; + +import org.junit.Assert; +import org.junit.Test; + +/** + * Tests the {@link QueryKeyValueParameters} class. + */ +public class QueryKeyValueParametersTest { + + private static final String SOURCE = "test-source"; + + @Test + public void testParsesEveryType() { + final QueryKeyValueParameters params = new QueryKeyValueParameters( + "s=hello&i=42&l=9999999999&f=1.5&d=2.5&ia=1,2,3&la=1,2,3&fa=1.5,2.5&da=1.5,2.5", + SOURCE); + + Assert.assertEquals("hello", params.getString("s").orElseThrow()); + Assert.assertEquals(Integer.valueOf(42), params.getInt("i").orElseThrow()); + Assert.assertEquals(Long.valueOf(9999999999L), params.getLong("l").orElseThrow()); // exceeds int range + Assert.assertEquals(1.5f, params.getFloat("f").orElseThrow(), 0.0001f); + Assert.assertEquals(2.5, params.getDouble("d").orElseThrow(), 0.0001); + Assert.assertArrayEquals(new int[] {1, 2, 3}, params.getIntArray("ia").orElseThrow()); + Assert.assertArrayEquals(new long[] {1, 2, 3}, params.getLongArray("la").orElseThrow()); + Assert.assertArrayEquals(new float[] {1.5f, 2.5f}, params.getFloatArray("fa").orElseThrow(), 0.0001f); + Assert.assertArrayEquals(new double[] {1.5, 2.5}, params.getDoubleArray("da").orElseThrow(), 0.0001); + } + + @Test + public void testAbsentKeyReturnsEmptyForEveryType() { + final QueryKeyValueParameters params = new QueryKeyValueParameters("present=1", SOURCE); + + Assert.assertEquals(Optional.empty(), params.getString("missing")); + Assert.assertEquals(Optional.empty(), params.getInt("missing")); + Assert.assertEquals(Optional.empty(), params.getLong("missing")); + Assert.assertEquals(Optional.empty(), params.getFloat("missing")); + Assert.assertEquals(Optional.empty(), params.getDouble("missing")); + Assert.assertEquals(Optional.empty(), params.getIntArray("missing")); + Assert.assertEquals(Optional.empty(), params.getLongArray("missing")); + Assert.assertEquals(Optional.empty(), params.getFloatArray("missing")); + Assert.assertEquals(Optional.empty(), params.getDoubleArray("missing")); + } + + @Test + public void testMalformedPairFails() { + try { + new QueryKeyValueParameters("noEqualsSign", SOURCE); + Assert.fail("expected a pair without '=' to fail"); + } catch (final IllegalArgumentException e) { + Assert.assertTrue(e.getMessage().contains("noEqualsSign")); + } + } + + @Test + public void testInvalidNumberFailsForEveryScalarType() { + final QueryKeyValueParameters params = new QueryKeyValueParameters("v=notANumber", SOURCE); + assertInvalid(() -> params.getInt("v"), "integer"); + assertInvalid(() -> params.getLong("v"), "long"); + assertInvalid(() -> params.getFloat("v"), "float"); + assertInvalid(() -> params.getDouble("v"), "double"); + } + + @Test + public void testInvalidNumberFailsForEveryArrayType() { + final QueryKeyValueParameters params = new QueryKeyValueParameters("v=1,notANumber", SOURCE); + assertInvalid(() -> params.getIntArray("v"), "integer"); + assertInvalid(() -> params.getLongArray("v"), "long"); + assertInvalid(() -> params.getFloatArray("v"), "float"); + assertInvalid(() -> params.getDoubleArray("v"), "double"); + } + + private static void assertInvalid(final Runnable action, final String expectedTypeName) { + try { + action.run(); + Assert.fail("expected invalid " + expectedTypeName + " value to fail"); + } catch (final IllegalArgumentException e) { + Assert.assertTrue("exception should mention " + expectedTypeName + ", but was: " + e.getMessage(), + e.getMessage().contains(expectedTypeName)); + } + } + + @Test + public void testValidateKeysRejectsUnknownKey() { + final QueryKeyValueParameters params = new QueryKeyValueParameters("a=1&b=2", SOURCE); + try { + params.validateKeys(Set.of("a")); + Assert.fail("expected unknown key 'b' to fail validation"); + } catch (final IllegalArgumentException e) { + Assert.assertTrue(e.getMessage().contains("b")); + } + params.validateKeys(Set.of("a", "b")); // should not throw once every key is known + } +} From c75061d8b0f480bb606c403cee9ae28acbc7b7da Mon Sep 17 00:00:00 2001 From: Michael Innerberger Date: Sat, 12 Sep 2026 12:34:57 -0400 Subject: [PATCH 24/24] Pipe lengthy comments through /wtf claude skill --- .../transform/DisplacementFieldTransform.java | 88 ++++++------------- 1 file changed, 27 insertions(+), 61 deletions(-) diff --git a/render-app/src/main/java/org/janelia/alignment/transform/DisplacementFieldTransform.java b/render-app/src/main/java/org/janelia/alignment/transform/DisplacementFieldTransform.java index b479fbc47..4390ceb20 100644 --- a/render-app/src/main/java/org/janelia/alignment/transform/DisplacementFieldTransform.java +++ b/render-app/src/main/java/org/janelia/alignment/transform/DisplacementFieldTransform.java @@ -33,10 +33,9 @@ /** - * Transform that reads a dense displacement (translation vector) field from a file on disk and moves each queried - * location by the interpolated vector. Since the field is a pull map (see {@link #extractAndTransform}), the vector - * belongs to the target location, so applying the transform means inverting the field, which {@link #applyInPlace} - * does by fixed-point iteration. + * Moves each queried location by a displacement vector interpolated from a field on disk. The field is a pull + * map (see {@link #extractAndTransform}), so applying it means inverting the field, done in {@link #applyInPlace} + * by fixed-point iteration. */ public class DisplacementFieldTransform implements CoordinateTransform { @@ -104,16 +103,10 @@ private void init(final String fieldSourceUri, this.offset = offset; this.vectorScale = vectorScale; - /* Load displacement field. Currently, this is tailored to output from SOFIMA for multi-sem acquisitions, - * stored as a Neuroglancer precomputed volume and read through the n5-ng-precomputed backend. - * - Layout is [x,y,z,channel]; channel=0 is X vectors, channel=1 is Y vectors - * - Precomputed raw is column-major [x,y,z,channel], matching N5/ImgLib2, so (unlike the Zarr - * backend) no XY axis reversal is performed: dim 0 is X, dim 1 is Y - */ + // SOFIMA output as a Neuroglancer precomputed volume, layout [x,y,z,channel], channel 0/1 = X/Y vectors. final RandomAccessibleInterval fieldRaw = openRawField(fieldSourceUri); - // Out-of-range x and y are handled by the mirrored extension in extractAndTransform, but an out-of-range - // z would read outside the cached image, which is undefined rather than merely inaccurate. + // x/y out of range is handled by the mirrored extension in extractAndTransform; z is not, so check it here. if ((fieldZIndex < 0) || (fieldZIndex >= fieldRaw.dimension(2))) { throw new IllegalArgumentException( "z " + fieldZIndex + " is outside the z range [0, " + fieldRaw.dimension(2) + @@ -125,12 +118,9 @@ private void init(final String fieldSourceUri, } /** - * Cache of raw (scale- and z-independent) displacement fields keyed by source URI. A single tile spec resolves - * its transform once per {@code getTransformList()} call (with no per-spec instance caching), so importing or - * rendering a layer would otherwise re-open the reader and re-read chunks once per tile. The cached value is the - * lazy {@link N5Utils#open} {@code CachedCellImg}: reader open + chunk reads happen once per field and are then - * shared across every tile and z-slice. Per-instance accessors are still built fresh in - * {@link #extractAndTransform} (imglib2 accessors are not thread safe); only the underlying chunk cache is shared. + * Cache of raw fields keyed by source URI, so a layer's tiles share one reader and chunk cache instead of each + * re-opening it. Per-instance accessors are still built fresh in {@link #extractAndTransform} since imglib2 + * accessors aren't thread-safe. */ private static final Map> RAW_FIELD_CACHE = new ConcurrentHashMap<>(); @@ -143,19 +133,11 @@ private static RandomAccessibleInterval openRawField(final String fie } /** - * Opens a Neuroglancer precomputed field through the N5 API. The URI may be prefixed with - * {@code precomputed://}. {@code gs://} buckets are read anonymously (matching the public warp-field - * bucket); any other scheme (e.g. {@code file://}) is routed through {@link N5Factory}'s key-value access. - * - *

    This wires the reader up by hand because {@code n5-universe}'s {@code N5Factory} does not yet know - * the precomputed format. Mirrors the {@code n5-ng-precomputed} examples. - * - *

    Exposed so that clients preparing a field (e.g. {@code ImportSofimaClient}) open it through exactly - * this same path rather than reimplementing the wiring. The field dataset itself lives under the first - * scale key, i.e. {@code reader.list("/")[0]}. - * - * @param fieldSourceUri the (optionally {@code precomputed://}-prefixed) container URI. - * @return an {@link N5Reader} over the precomputed container. + * Opens a Neuroglancer precomputed field (optionally {@code precomputed://}-prefixed) through the N5 API. + * Wired up by hand since {@code n5-universe}'s {@code N5Factory} doesn't know the precomputed format yet. + * {@code gs://} buckets are read anonymously; other schemes go through {@link N5Factory}'s key-value access. + * Exposed so field-preparing clients (e.g. {@code ImportSofimaClient}) use the same path. The dataset lives + * under the first scale key, {@code reader.list("/")[0]}. */ public static N5Reader openPrecomputedReader(final String fieldSourceUri) { String uri = fieldSourceUri; @@ -177,19 +159,13 @@ public static N5Reader openPrecomputedReader(final String fieldSourceUri) { } /** - * Currently, this is tailored to output from SOFIMA for multi-sem acquisitions. - *

      - *
    • The field is a pull map: the vector stored at a target position points at the source position - * the data is pulled from, i.e. {@code source = target + vector}. Render's transform lists run - * source to target, so the vectors are negated here.
    • - *
    • Stored vectors are multiplied by {@code vectorScale} to reach full resolution. SOFIMA expresses them - * in the units of the original volume already, so the default of 1 is what that output needs.
    • - *
    + * The field is a pull map: the vector at a target position points at the source it was pulled from, + * i.e. {@code source = target + vector}. Render's transform lists run source to target, so the vectors are + * negated here and scaled by {@code vectorScale} to full resolution. */ private RealRandomAccess extractAndTransform(final RandomAccessibleInterval rawField, final int xory) { - // The deformation field can contain NaNs, replace them with zeros - // Do this up front to not interpolate NaNs + // Replace NaNs before interpolating, so they don't leak into neighboring pixels. final RandomAccessibleInterval cleaned = Converters.convertRAI( rawField, (i, o) -> o.set(Float.isNaN(i.getRealFloat()) ? 0 : i.getRealFloat()), @@ -209,9 +185,8 @@ private RealRandomAccess extractAndTransform(final RandomAccessibleIn Views.interpolate(Views.extendMirrorDouble(slice), new NLinearInterpolatorFactory<>()), fieldToWorld); - // Negate the pull map and scale the vectors to full resolution, folded into a single factor applied last - // (after interpolation) to keep the number of passes down. Negating alone only flips the vectors; the - // actual inversion (evaluating them at the target rather than the source) happens in applyInPlace. + // Negate and scale in one pass, applied after interpolation. Negating just flips the vectors; the actual + // inversion (evaluating at the target rather than the source) happens in applyInPlace. final double pullToPushScale = -this.vectorScale; return Converters.convert( scaledAndInterpolated, @@ -250,9 +225,8 @@ public void applyInPlace(final double[] location) { target[1] = y; } - // A non-invertible (or very steep) field may still be off by more than the tolerance after the iteration - // cap. The last estimate is used rather than failing a whole render, but it is logged once per instance - // (instances are per tile spec, so logging every occurrence would flood the log with a line per pixel). + // A non-invertible field may still exceed the tolerance after the cap; use the last estimate rather than + // failing the render, logged once per instance to avoid a line per pixel. if ((step > INVERSION_TOLERANCE) && (! divergenceLogged)) { divergenceLogged = true; LOG.warn("applyInPlace: inversion did not converge within {} iterations at ({}, {}) for field {}; " + @@ -269,23 +243,16 @@ public void applyInPlace(final double[] location) { * Looks up the interpolated field vector (already negated and scaled to full resolution) at a world location. * Package private so that tests can check the field placement on its own, separately from the inversion. */ - void lookUpVector(final double[] location, - final double[] vector) { + void lookUpVector(final double[] location, final double[] vector) { vector[0] = displacementX.setPositionAndGet(location).getRealDouble(); vector[1] = displacementY.setPositionAndGet(location).getRealDouble(); } /** - * Initializes this transform by parsing the data string and loading the field into an imglib2 image. - *

    - * The data string is the field source URI followed by {@code ?key=value} query parameters, e.g. - * {@code file:///path/to/field.n5?z=5&scale=40.0&offset=-5318.0,-783.0}. The portion before the {@code ?} - * becomes the {@link #fieldSourceUri} (the actual path); the query parameters supply the remaining fields. - * Only {@code z} is required; everything else defaults to the identity placement - * ({@code scale=vectorScale=1}, {@code offset=0,0}). Unknown parameters are rejected so - * that a misspelled one cannot silently fall back to its default. - * - * @param data field source URI with query parameters (see above). + * Parses the data string (field source URI plus {@code ?key=value} params, e.g. + * {@code file:///path/to/field.n5?z=5&scale=40.0&offset=-5318.0,-783.0}) and loads the field. Only + * {@code z} is required; the rest default to the identity placement. Unknown parameters are rejected so a + * misspelled one can't silently fall back to its default. * * @throws IllegalArgumentException * if the data string cannot be parsed or the field cannot be loaded. @@ -329,8 +296,7 @@ public String toXML(final String indent) { @Override public String toDataString() { - // Writes every parameter, including any left at its default, so a persisted string keeps its meaning - // even if a default ever changes. Callers building a string by hand may omit the defaulted ones. + // Writes every parameter, even defaults, so a persisted string keeps its meaning if a default ever changes. return fieldSourceUri + "?z=" + fieldZIndex + "&scale=" + scale +