VALID_PARAMETERS = Set.of("z", "scale", "offset", "vectorScale");
+}
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/transform/DisplacementFieldTransformTest.java b/render-app/src/test/java/org/janelia/alignment/transform/DisplacementFieldTransformTest.java
new file mode 100644
index 000000000..b6246b988
--- /dev/null
+++ b/render-app/src/test/java/org/janelia/alignment/transform/DisplacementFieldTransformTest.java
@@ -0,0 +1,185 @@
+package org.janelia.alignment.transform;
+
+import java.nio.file.Path;
+import java.util.Arrays;
+
+import org.janelia.saalfeldlab.n5.DataType;
+import org.janelia.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;
+
+/**
+ * Tests the {@link DisplacementFieldTransform} class.
+ */
+public class DisplacementFieldTransformTest {
+
+ private static final String SAMPLE_URI =
+ "file:///tmp/does-not-exist.n5?z=5&scale=8.0&offset=100.0,-50.0&vectorScale=2.0";
+
+ @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 RuntimeException 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"));
+ }
+ }
+
+ @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);
+
+ // 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, "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, "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, "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, "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 + "?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(),
+ e.getMessage().contains("z range"));
+ }
+ }
+
+ private static void assertDisplacement(final Path fieldDir,
+ final String queryString,
+ final double[] location,
+ final double expectedDx,
+ final double expectedDy) {
+
+ final String data = fieldDir + "?" + queryString;
+ final DisplacementFieldTransform transform = new DisplacementFieldTransform();
+ transform.init(data);
+
+ // 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 + "?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);
+ 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 + "?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),
+ Double.isFinite(estimate[0]) && Double.isFinite(estimate[1]));
+ }
+
+ @Test
+ public void testMisspelledParameterFails() {
+ // 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?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",
+ e.getMessage().contains("scalex"));
+ }
+ }
+
+ @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);
+ }
+}
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
+ }
+}
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..bfedbd254
--- /dev/null
+++ b/render-ws-java-client/src/main/java/org/janelia/render/client/multisem/ImportSofimaClient.java
@@ -0,0 +1,206 @@
+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 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.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.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
+ * 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 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.
+ *
+ * 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
+ * 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
+ */
+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", required = true)
+ private String targetStack;
+ @Parameter(names = "--sofimaFieldUri", description = "URI of the SOFIMA displacement field N5 container", required = true)
+ 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 = "--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) {
+ 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);
+ 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,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 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.
+ final String scaleKey = fieldReader.list("/")[0];
+ final long[] fieldDimensions = fieldReader.getDatasetAttributes(scaleKey).getDimensions();
+
+ // 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.
+ 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);
+ }
+
+ // Set up the target stack
+ final String targetStack = params.targetStack;
+ 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 with {} threads", zValues.size(), params.numThreads);
+
+ // 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 (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);
+ }
+ }
+
+ // Complete the target stack
+ if (params.completeTargetStack) {
+ LOG.info("addDisplacementField: completing stack {}", targetStack);
+ renderClient.setStackState(targetStack, StackMetaData.StackState.COMPLETE);
+ }
+
+ 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 ForkJoinPool pool)
+ throws IOException {
+
+ final ResolvedTileSpecCollection tileSpecs = renderClient.getResolvedTiles(params.stack, z);
+
+ // 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);
+ }
+
+ /**
+ * Compiles the {@link DisplacementFieldTransform} data string for one layer. The format must match what
+ * {@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 long fieldZIndex,
+ final double scale,
+ final double[] offset) {
+ return params.sofimaFieldUri +
+ "?z=" + fieldZIndex +
+ "&scale=" + scale +
+ "&offset=" + offset[0] + "," + offset[1];
+ }
+
+ private static final Logger LOG = LoggerFactory.getLogger(ImportSofimaClient.class);
+}