From d6a932dbed5a85a98d3855758de60f771a8ab839 Mon Sep 17 00:00:00 2001
From: Michael Innerberger
Date: Tue, 10 Feb 2026 11:45:57 -0500
Subject: [PATCH 01/13] Add first version of creep-correction translated from
Ken's py script
---
.../multisem/CreepCorrectionClient.java | 465 ++++++++++++++++++
.../multisem/CreepCorrectionSparkClient.java | 162 ++++++
2 files changed, 627 insertions(+)
create mode 100644 render-ws-java-client/src/main/java/org/janelia/render/client/multisem/CreepCorrectionClient.java
create mode 100644 render-ws-spark-client/src/main/java/org/janelia/render/client/spark/multisem/CreepCorrectionSparkClient.java
diff --git a/render-ws-java-client/src/main/java/org/janelia/render/client/multisem/CreepCorrectionClient.java b/render-ws-java-client/src/main/java/org/janelia/render/client/multisem/CreepCorrectionClient.java
new file mode 100644
index 000000000..c9a7eaa2b
--- /dev/null
+++ b/render-ws-java-client/src/main/java/org/janelia/render/client/multisem/CreepCorrectionClient.java
@@ -0,0 +1,465 @@
+package org.janelia.render.client.multisem;
+
+import static org.janelia.alignment.spec.ResolvedTileSpecCollection.TransformApplicationMethod.INSERT_BEFORE_LAST;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import org.janelia.alignment.match.CanvasMatchResult;
+import org.janelia.alignment.match.CanvasMatches;
+import org.janelia.alignment.multisem.MultiSemUtilities;
+import org.janelia.alignment.spec.LeafTransformSpec;
+import org.janelia.alignment.spec.LayoutData;
+import org.janelia.alignment.spec.ResolvedTileSpecCollection;
+import org.janelia.alignment.spec.TileSpec;
+import org.janelia.alignment.spec.TransformSpec;
+import org.janelia.render.client.RenderDataClient;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import mpicbg.models.AffineModel2D;
+import mpicbg.models.NotEnoughDataPointsException;
+import mpicbg.models.PointMatch;
+
+/**
+ * Estimates and applies piezo creep correction for multi-SEM sFOV tiles within each mFOV.
+ *
+ * The correction is estimated by fitting pairwise affine transforms between geometrically
+ * adjacent sFOV pairs and extracting the y-stretch factor. A double-exponential correction
+ * (using {@link org.janelia.alignment.transform.SEMDistortionTransformA}) is then derived
+ * and inserted into each tile's transform list.
+ *
+ * @author Michael Innerberger
+ */
+public class CreepCorrectionClient {
+
+ // physical constants for the double-exponential model
+ private static final double TAU_0 = 0.42; // seconds
+ private static final double TAU_1 = 4.0; // seconds
+ private static final double PIXEL_DWELL = 800e-9; // seconds
+ private static final double LINE_WIDTH = 2000; // pixels
+ private static final double L0 = TAU_0 / (PIXEL_DWELL * LINE_WIDTH); // 262500 lines
+ private static final double L1 = TAU_1 / (PIXEL_DWELL * LINE_WIDTH); // 2500000 lines
+
+ // line numbers used for stretch-to-amplitude conversion
+ private static final int TOP_LINE = 50;
+ private static final int BOTTOM_LINE = 1700;
+
+ // validation thresholds
+ private static final double MIN_STRETCH = 0.9;
+ private static final double MAX_STRETCH = 1.1;
+ private static final int MIN_VALID_STRETCHES = 10;
+ private static final double MAX_STRETCH_STDDEV = 0.02;
+
+ // RANSAC parameters for pairwise affine estimation
+ private static final int RANSAC_ITERATIONS = 1000;
+ private static final double RANSAC_MAX_EPSILON = 10.0;
+ private static final double RANSAC_MIN_INLIER_RATIO = 0.1;
+ private static final int RANSAC_MIN_NUM_INLIERS = 7;
+ private static final double RANSAC_MAX_TRUST = 3.0;
+
+ // minimum offset for geometric neighbor detection (in stage coordinate units)
+ private static final double MIN_NEIGHBOR_OFFSET = 100.0;
+
+ /**
+ * Processes all mFOVs for a given z-layer: loads tiles and matches, estimates creep correction,
+ * applies it where valid, and saves the results to the target stack.
+ *
+ * @return number of tiles processed
+ */
+ public int processZLayer(final double z,
+ final RenderDataClient renderDataClient,
+ final RenderDataClient matchDataClient,
+ final String stack,
+ final String targetStack)
+ throws IOException {
+
+ LOG.info("processZLayer: entry, z={}", z);
+
+ final ResolvedTileSpecCollection resolvedTiles = renderDataClient.getResolvedTiles(stack, z);
+
+ // group tiles by mFOV
+ final Map> mfovToTiles = new HashMap<>();
+ for (final TileSpec tileSpec : resolvedTiles.getTileSpecs()) {
+ final String mfov = MultiSemUtilities.getMagcMfovForTileId(tileSpec.getTileId());
+ mfovToTiles.computeIfAbsent(mfov, k -> new ArrayList<>()).add(tileSpec);
+ }
+
+ // load all within-group matches and index by tile pair
+ final String groupId = String.valueOf(z);
+ final List allMatches = matchDataClient.getMatchesWithinGroup(groupId, false);
+ final Map pairKeyToMatches = new HashMap<>();
+ for (final CanvasMatches match : allMatches) {
+ final String pMFOV = MultiSemUtilities.getMagcMfovForTileId(match.getpId());
+ final String qMFOV = MultiSemUtilities.getMagcMfovForTileId(match.getqId());
+ if (pMFOV.equals(qMFOV)) {
+ pairKeyToMatches.put(pairKey(match.getpId(), match.getqId()), match);
+ }
+ }
+
+ // process each mFOV independently
+ int correctedMFOVCount = 0;
+ int skippedMFOVCount = 0;
+ for (final Map.Entry> entry : mfovToTiles.entrySet()) {
+ final String mfov = entry.getKey();
+ final List mfovTiles = entry.getValue();
+
+ final boolean corrected = processMFOV(mfov, mfovTiles, pairKeyToMatches, resolvedTiles);
+ if (corrected) {
+ correctedMFOVCount++;
+ } else {
+ skippedMFOVCount++;
+ }
+ }
+
+ // save all tiles (both corrected and uncorrected)
+ renderDataClient.saveResolvedTiles(resolvedTiles, targetStack, z);
+
+ LOG.info("processZLayer: exit, z={}, correctedMFOVs={}, skippedMFOVs={}, totalTiles={}",
+ z, correctedMFOVCount, skippedMFOVCount, resolvedTiles.getTileCount());
+
+ return resolvedTiles.getTileCount();
+ }
+
+ /**
+ * Processes a single mFOV: finds neighbor pairs, estimates stretch, validates, and applies correction.
+ *
+ * @return true if correction was applied, false if skipped
+ */
+ boolean processMFOV(final String mfov,
+ final List mfovTiles,
+ final Map pairKeyToMatches,
+ final ResolvedTileSpecCollection resolvedTiles) {
+
+ // find geometric neighbor pairs
+ final List neighborPairs = findGeometricNeighborPairs(mfovTiles);
+
+ if (neighborPairs.isEmpty()) {
+ LOG.warn("processMFOV: no geometric neighbor pairs found for mFOV {}, skipping", mfov);
+ return false;
+ }
+
+ // estimate y-stretch for each pair that has matches
+ final List stretches = new ArrayList<>();
+ int pairsWithoutMatches = 0;
+ for (final TilePair pair : neighborPairs) {
+ final String key = pairKey(pair.pTileId, pair.qTileId);
+ final CanvasMatches matches = pairKeyToMatches.get(key);
+
+ if (matches == null) {
+ pairsWithoutMatches++;
+ continue;
+ }
+
+ final double yStretch = estimateYStretchForPair(
+ CanvasMatchResult.convertMatchesToPointMatchList(matches.getMatches()));
+ stretches.add(yStretch);
+ }
+
+ if (pairsWithoutMatches > 0) {
+ LOG.info("processMFOV: {} of {} neighbor pairs in mFOV {} had no matches",
+ pairsWithoutMatches, neighborPairs.size(), mfov);
+ }
+
+ // validate results
+ final ValidationResult validation = validateResults(stretches, mfov);
+
+ if (!validation.isValid) {
+ LOG.warn("processMFOV: skipping mFOV {} - {}", mfov, validation.diagnosticMessage);
+ return false;
+ }
+
+ // build and apply correction transform
+ final TransformSpec correctionSpec = buildCorrectionTransformSpec(validation.amplitude);
+ final Set mfovTileIds = mfovTiles.stream()
+ .map(TileSpec::getTileId)
+ .collect(Collectors.toSet());
+ applyCorrectionToMFOV(resolvedTiles, mfovTileIds, correctionSpec);
+
+ LOG.info("processMFOV: applied creep correction to mFOV {} with amplitude={}, medianStretch={}, stddev={}",
+ mfov, validation.amplitude, validation.medianStretch, validation.stddev);
+
+ return true;
+ }
+
+ /**
+ * Finds geometric neighbor pairs (lower-right and lower-left) using stage positions,
+ * replicating the Python prototype's neighbor-finding logic.
+ */
+ List findGeometricNeighborPairs(final List mfovTiles) {
+ final List pairs = new ArrayList<>();
+
+ for (final TileSpec targetTile : mfovTiles) {
+ final StageCoordinates target = getStageCoordinates(targetTile);
+ if (target == null) continue;
+
+ // find closest lower-right neighbor
+ findClosestNeighbor(targetTile.getTileId(), target, mfovTiles, true)
+ .ifPresent(pairs::add);
+
+ // find closest lower-left neighbor
+ findClosestNeighbor(targetTile.getTileId(), target, mfovTiles, false)
+ .ifPresent(pairs::add);
+ }
+
+ return pairs;
+ }
+
+ private static StageCoordinates getStageCoordinates(final TileSpec targetTile) {
+ final LayoutData targetLayout = targetTile.getLayout();
+ if (targetLayout == null || targetLayout.getStageX() == null || targetLayout.getStageY() == null) {
+ return null;
+ }
+ final double targetX = targetLayout.getStageX();
+ final double targetY = targetLayout.getStageY();
+ return new StageCoordinates(targetX, targetY);
+ }
+
+ private Optional findClosestNeighbor(final String targetTileId,
+ final StageCoordinates target,
+ final List candidates,
+ final boolean lowerRight) {
+ double minDist = Double.MAX_VALUE;
+ String closestId = null;
+
+ for (final TileSpec candidate : candidates) {
+ if (candidate.getTileId().equals(targetTileId)) {
+ continue;
+ }
+ final LayoutData layout = candidate.getLayout();
+ final StageCoordinates stage = getStageCoordinates(candidate);
+
+ if (layout == null || stage == null) {
+ continue;
+ }
+
+ final boolean xCondition = lowerRight
+ ? (stage.x > target.x + MIN_NEIGHBOR_OFFSET)
+ : (stage.x < target.y - MIN_NEIGHBOR_OFFSET);
+
+ if (xCondition && (stage.y > target.y + MIN_NEIGHBOR_OFFSET)) {
+ final double dist = Math.sqrt(Math.pow(stage.x - target.x, 2) + Math.pow(stage.y - target.y, 2));
+ if (dist < minDist) {
+ minDist = dist;
+ closestId = candidate.getTileId();
+ }
+ }
+ }
+
+ if (closestId != null) {
+ // normalize pair ordering so pairKey lookups work regardless of match storage order
+ final String pId = targetTileId.compareTo(closestId) < 0 ? targetTileId : closestId;
+ final String qId = targetTileId.compareTo(closestId) < 0 ? closestId : targetTileId;
+ return java.util.Optional.of(new TilePair(pId, qId));
+ }
+ return java.util.Optional.empty();
+ }
+
+ /**
+ * Estimates the y-stretch factor from a set of point matches by fitting an affine model with RANSAC.
+ *
+ * @return the y-stretch (m11 element of the affine), or {@link Double#NaN} on failure
+ */
+ double estimateYStretchForPair(final List candidates) {
+ if (candidates.size() < RANSAC_MIN_NUM_INLIERS) {
+ return Double.NaN;
+ }
+
+ final AffineModel2D model = new AffineModel2D();
+ final List inliers = new ArrayList<>();
+
+ try {
+ model.filterRansac(candidates,
+ inliers,
+ RANSAC_ITERATIONS,
+ RANSAC_MAX_EPSILON,
+ RANSAC_MIN_INLIER_RATIO,
+ RANSAC_MIN_NUM_INLIERS,
+ RANSAC_MAX_TRUST);
+ } catch (final NotEnoughDataPointsException e) {
+ return Double.NaN;
+ }
+
+ if (inliers.isEmpty()) {
+ return Double.NaN;
+ }
+
+ final double[] affineData = new double[6];
+ model.toArray(affineData);
+ // affineData layout: [m00, m10, m01, m11, m02, m12]
+ return affineData[3]; // m11 = y-stretch
+ }
+
+ /**
+ * Computes the correction amplitude from the median y-stretch, using the double-exponential derivative model.
+ * Faithfully translates the Python prototype's {@code calculate_a_helper_func}.
+ */
+ static double computeCorrectionAmplitude(final double medianStretch) {
+ final double b = (-1.0 / L0) * Math.exp(-(double) TOP_LINE / L0)
+ + (-1.0 / L1) * Math.exp(-(double) TOP_LINE / L1);
+ final double c = (-1.0 / L0) * Math.exp(-(double) BOTTOM_LINE / L0)
+ + (-1.0 / L1) * Math.exp(-(double) BOTTOM_LINE / L1);
+ return (medianStretch - 1.0) / (b - c * medianStretch);
+ }
+
+ /**
+ * Centralized validation of all stretch estimates for a single mFOV.
+ * On failure, returns an invalid result; the mFOV should be skipped (uploaded without correction).
+ */
+ ValidationResult validateResults(final List stretches, final String mfov) {
+ final int totalPairs = stretches.size();
+
+ if (totalPairs == 0) {
+ return ValidationResult.invalid("no stretch estimates available");
+ }
+
+ // filter out NaN and out-of-range values
+ final List validStretches = new ArrayList<>();
+ int nanCount = 0;
+ int outOfRangeCount = 0;
+ for (final double s : stretches) {
+ if (Double.isNaN(s)) {
+ nanCount++;
+ } else if (s < MIN_STRETCH || s > MAX_STRETCH) {
+ outOfRangeCount++;
+ } else {
+ validStretches.add(s);
+ }
+ }
+
+ if (nanCount > totalPairs / 2) {
+ LOG.warn("validateResults: mFOV {} has {} NaN stretches out of {} total",
+ mfov, nanCount, totalPairs);
+ }
+ if (outOfRangeCount > 0) {
+ LOG.info("validateResults: mFOV {} had {} stretches outside [{}, {}]",
+ mfov, outOfRangeCount, MIN_STRETCH, MAX_STRETCH);
+ }
+
+ if (validStretches.size() < MIN_VALID_STRETCHES) {
+ return ValidationResult.invalid(
+ "only " + validStretches.size() + " valid stretches (need " + MIN_VALID_STRETCHES +
+ "), nanCount=" + nanCount + ", outOfRange=" + outOfRangeCount);
+ }
+
+ // compute median
+ validStretches.sort(Double::compareTo);
+ final double medianStretch;
+ final int n = validStretches.size();
+ if (n % 2 == 0) {
+ medianStretch = (validStretches.get(n / 2 - 1) + validStretches.get(n / 2)) / 2.0;
+ } else {
+ medianStretch = validStretches.get(n / 2);
+ }
+
+ // compute standard deviation
+ final double mean = validStretches.stream().mapToDouble(Double::doubleValue).average().orElse(0.0);
+ final double variance = validStretches.stream()
+ .mapToDouble(s -> Math.pow(s - mean, 2))
+ .sum() / validStretches.size();
+ final double stddev = Math.sqrt(variance);
+
+ if (stddev > MAX_STRETCH_STDDEV) {
+ return ValidationResult.invalid(
+ "stretch stddev " + stddev + " exceeds threshold " + MAX_STRETCH_STDDEV +
+ " (median=" + medianStretch + ", validCount=" + validStretches.size() + ")");
+ }
+
+ // compute amplitude
+ final double amplitude = computeCorrectionAmplitude(medianStretch);
+ if (!Double.isFinite(amplitude)) {
+ return ValidationResult.invalid(
+ "computed amplitude is not finite (median=" + medianStretch + ")");
+ }
+
+ LOG.info("validateResults: mFOV {} - totalPairs={}, valid={}, nan={}, outOfRange={}, " +
+ "median={}, stddev={}, amplitude={}",
+ mfov, totalPairs, validStretches.size(), nanCount, outOfRangeCount,
+ medianStretch, stddev, amplitude);
+
+ return new ValidationResult(true, medianStretch, stddev, amplitude, "OK");
+ }
+
+ /**
+ * Builds a {@link LeafTransformSpec} for the creep correction using {@code SEMDistortionTransformA}.
+ * Formula: {@code y += amplitude * exp(-y/L0) + amplitude * exp(-y/L1)}
+ */
+ static TransformSpec buildCorrectionTransformSpec(final double amplitude) {
+ final String dataString = amplitude + "," + L0 + "," + amplitude + "," + L1 + ",1";
+ return new LeafTransformSpec("org.janelia.alignment.transform.SEMDistortionTransformA", dataString);
+ }
+
+ /**
+ * Applies the correction transform to all tiles of an mFOV by inserting it before the last
+ * (alignment) transform, placing it after the existing scan correction.
+ */
+ private void applyCorrectionToMFOV(final ResolvedTileSpecCollection tiles,
+ final Set mfovTileIds,
+ final TransformSpec correctionSpec) {
+ for (final String tileId : mfovTileIds) {
+ tiles.addTransformSpecToTile(tileId, correctionSpec, INSERT_BEFORE_LAST);
+ }
+ }
+
+ /**
+ * Creates a canonical pair key for match lookups. CanvasMatches normalizes p < q,
+ * so we ensure the same ordering.
+ */
+ private static String pairKey(final String id1, final String id2) {
+ return id1.compareTo(id2) < 0 ? id1 + "::" + id2 : id2 + "::" + id1;
+ }
+
+ /** A pair of tile IDs representing geometrically adjacent sFOVs. */
+ static class TilePair {
+ final String pTileId;
+ final String qTileId;
+
+ TilePair(final String pTileId, final String qTileId) {
+ this.pTileId = pTileId;
+ this.qTileId = qTileId;
+ }
+ }
+
+ /** Result of validating stretch estimates for an mFOV. */
+ static class ValidationResult {
+ final boolean isValid;
+ final double medianStretch;
+ final double stddev;
+ final double amplitude;
+ final String diagnosticMessage;
+
+ ValidationResult(final boolean isValid,
+ final double medianStretch,
+ final double stddev,
+ final double amplitude,
+ final String diagnosticMessage) {
+ this.isValid = isValid;
+ this.medianStretch = medianStretch;
+ this.stddev = stddev;
+ this.amplitude = amplitude;
+ this.diagnosticMessage = diagnosticMessage;
+ }
+
+ static ValidationResult invalid(final String diagnosticMessage) {
+ return new ValidationResult(false, Double.NaN, Double.NaN, Double.NaN, diagnosticMessage);
+ }
+ }
+
+ static class StageCoordinates {
+ final double x;
+ final double y;
+
+ StageCoordinates(final double x, final double y) {
+ this.x = x;
+ this.y = y;
+ }
+ }
+
+ private static final Logger LOG = LoggerFactory.getLogger(CreepCorrectionClient.class);
+}
diff --git a/render-ws-spark-client/src/main/java/org/janelia/render/client/spark/multisem/CreepCorrectionSparkClient.java b/render-ws-spark-client/src/main/java/org/janelia/render/client/spark/multisem/CreepCorrectionSparkClient.java
new file mode 100644
index 000000000..bad722172
--- /dev/null
+++ b/render-ws-spark-client/src/main/java/org/janelia/render/client/spark/multisem/CreepCorrectionSparkClient.java
@@ -0,0 +1,162 @@
+package org.janelia.render.client.spark.multisem;
+
+import com.beust.jcommander.Parameter;
+import com.beust.jcommander.ParametersDelegate;
+
+import java.io.IOException;
+import java.io.Serializable;
+import java.util.List;
+
+import org.apache.spark.SparkConf;
+import org.apache.spark.api.java.JavaRDD;
+import org.apache.spark.api.java.JavaSparkContext;
+import org.apache.spark.api.java.function.Function;
+import org.janelia.alignment.spec.stack.StackMetaData;
+import org.janelia.render.client.ClientRunner;
+import org.janelia.render.client.RenderDataClient;
+import org.janelia.render.client.multisem.CreepCorrectionClient;
+import org.janelia.render.client.parameter.CommandLineParameters;
+import org.janelia.render.client.parameter.RenderWebServiceParameters;
+import org.janelia.render.client.parameter.ZRangeParameters;
+import org.janelia.render.client.spark.LogUtilities;
+import org.jetbrains.annotations.NotNull;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Spark client for applying piezo creep correction to multi-SEM tiles.
+ * Each z-layer is processed independently on a Spark executor.
+ *
+ * Within each z-layer, all mFOVs are processed sequentially: for each mFOV, the y-stretch
+ * is estimated from pairwise affine fits of geometrically adjacent sFOV pairs, and a
+ * double-exponential correction is applied if validation passes. mFOVs that fail validation
+ * are skipped (uploaded without correction).
+ *
+ * @see CreepCorrectionClient
+ *
+ * @author Michael Innerberger
+ */
+public class CreepCorrectionSparkClient implements Serializable {
+
+ public static class Parameters extends CommandLineParameters {
+
+ @ParametersDelegate
+ public RenderWebServiceParameters renderWeb = new RenderWebServiceParameters();
+
+ @ParametersDelegate
+ public ZRangeParameters layerRange = new ZRangeParameters();
+
+ @Parameter(
+ names = "--stack",
+ description = "Name of source stack",
+ required = true)
+ public String stack;
+
+ @Parameter(
+ names = "--targetStack",
+ description = "Name of target stack for corrected tiles",
+ required = true)
+ public String targetStack;
+
+ @Parameter(
+ names = "--matchOwner",
+ description = "Owner of match collection (default is same as render owner)")
+ public String matchOwner;
+
+ @Parameter(
+ names = "--matchCollection",
+ description = "Name of match collection containing within-layer montage matches",
+ required = true)
+ public String matchCollection;
+
+ String getMatchOwner() {
+ return matchOwner != null ? matchOwner : renderWeb.owner;
+ }
+ }
+
+ 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 CreepCorrectionSparkClient client = new CreepCorrectionSparkClient(parameters);
+ client.run();
+ }
+ };
+ clientRunner.run();
+ }
+
+ private final Parameters parameters;
+
+ public CreepCorrectionSparkClient(final Parameters parameters) {
+ this.parameters = parameters;
+ }
+
+ public void run() throws IOException {
+
+ final SparkConf conf = new SparkConf().setAppName("CreepCorrectionSparkClient");
+
+ try (final JavaSparkContext sparkContext = new JavaSparkContext(conf)) {
+
+ final String sparkAppId = sparkContext.getConf().getAppId();
+ final String executorsJson = LogUtilities.getExecutorsApiJson(sparkAppId);
+ LOG.info("run: appId is {}, executors data is {}", sparkAppId, executorsJson);
+
+ final RenderDataClient sourceDataClient = parameters.renderWeb.getDataClient();
+
+ final List zValues = sourceDataClient.getStackZValues(parameters.stack,
+ parameters.layerRange.minZ,
+ parameters.layerRange.maxZ);
+
+ if (zValues.isEmpty()) {
+ throw new IllegalArgumentException("source stack does not contain any matching z values");
+ }
+
+ // set up target stack on the driver
+ final StackMetaData sourceStackMetaData = sourceDataClient.getStackMetaData(parameters.stack);
+ sourceDataClient.setupDerivedStack(sourceStackMetaData, parameters.targetStack);
+
+ LOG.info("run: distributing {} z values for processing", zValues.size());
+
+ final JavaRDD rddZValues = sparkContext.parallelize(zValues);
+
+ final JavaRDD rddTileCounts = rddZValues.map(this::processSingleLayer);
+ final List tileCountList = rddTileCounts.collect();
+
+ long total = 0;
+ for (final Integer tileCount : tileCountList) {
+ total += tileCount;
+ }
+
+ LOG.info("run: processed {} tiles across {} z-layers", total, zValues.size());
+
+ // complete target stack on the driver
+ sourceDataClient.setStackState(parameters.targetStack, StackMetaData.StackState.COMPLETE);
+ }
+
+ LOG.info("run: exit");
+ }
+
+ private Integer processSingleLayer(final Double z) throws IOException {
+ LogUtilities.setupExecutorLog4j("z " + z);
+
+ final RenderDataClient executorRenderClient = parameters.renderWeb.getDataClient();
+ final RenderDataClient executorMatchClient = new RenderDataClient(
+ parameters.renderWeb.baseDataUrl,
+ parameters.getMatchOwner(),
+ parameters.matchCollection);
+
+ final CreepCorrectionClient correctionClient = new CreepCorrectionClient();
+ return correctionClient.processZLayer(z,
+ executorRenderClient,
+ executorMatchClient,
+ parameters.stack,
+ parameters.targetStack);
+ }
+
+ private static final Logger LOG = LoggerFactory.getLogger(CreepCorrectionSparkClient.class);
+}
From 132fd3e9cac6f4e816db4d7524388a74620f653d Mon Sep 17 00:00:00 2001
From: Michael Innerberger
Date: Tue, 10 Feb 2026 14:37:26 -0500
Subject: [PATCH 02/13] Fix bug in neighbor identification logic
---
.../janelia/render/client/multisem/CreepCorrectionClient.java | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/render-ws-java-client/src/main/java/org/janelia/render/client/multisem/CreepCorrectionClient.java b/render-ws-java-client/src/main/java/org/janelia/render/client/multisem/CreepCorrectionClient.java
index c9a7eaa2b..0c113073d 100644
--- a/render-ws-java-client/src/main/java/org/janelia/render/client/multisem/CreepCorrectionClient.java
+++ b/render-ws-java-client/src/main/java/org/janelia/render/client/multisem/CreepCorrectionClient.java
@@ -241,7 +241,7 @@ private Optional findClosestNeighbor(final String targetTileId,
final boolean xCondition = lowerRight
? (stage.x > target.x + MIN_NEIGHBOR_OFFSET)
- : (stage.x < target.y - MIN_NEIGHBOR_OFFSET);
+ : (stage.x < target.x - MIN_NEIGHBOR_OFFSET);
if (xCondition && (stage.y > target.y + MIN_NEIGHBOR_OFFSET)) {
final double dist = Math.sqrt(Math.pow(stage.x - target.x, 2) + Math.pow(stage.y - target.y, 2));
From ad50b6c7517b0a44d6ca35f7efa892c16cecea45 Mon Sep 17 00:00:00 2001
From: Michael Innerberger
Date: Tue, 17 Feb 2026 16:22:33 -0500
Subject: [PATCH 03/13] Add a new transformation that is true to the original
python script
---
.../StageCreepCorrectionTransform.java | 78 +++++++++++++++++++
.../multisem/CreepCorrectionClient.java | 14 ++--
2 files changed, 87 insertions(+), 5 deletions(-)
create mode 100644 render-app/src/main/java/org/janelia/alignment/transform/StageCreepCorrectionTransform.java
diff --git a/render-app/src/main/java/org/janelia/alignment/transform/StageCreepCorrectionTransform.java b/render-app/src/main/java/org/janelia/alignment/transform/StageCreepCorrectionTransform.java
new file mode 100644
index 000000000..003d90fc0
--- /dev/null
+++ b/render-app/src/main/java/org/janelia/alignment/transform/StageCreepCorrectionTransform.java
@@ -0,0 +1,78 @@
+package org.janelia.alignment.transform;
+
+import mpicbg.trakem2.transform.CoordinateTransform;
+
+/**
+ * Forward transform for piezo stage creep correction in multi-SEM imaging.
+ *
+ * The backward map (used by the Python prototype via cv2.remap) is:
+ * y_source = y_world + a * exp(-y_world / b) + c * exp(-y_world / d)
+ *
+ * This class implements the exact forward (source → world) transform by numerically
+ * inverting the backward map using Newton's method: given {@code y_s}, find {@code y_w} such that
+ * {@code y_w + a * exp(-y_w / b) + c * exp(-y_w / d) = y_s}.
+ *
+ * Coefficients: a, b, c, d (same as {@link SEMDistortionTransformA}).
+ * Data string format: {@code "a,b,c,d,dimension"}.
+ */
+public class StageCreepCorrectionTransform
+ extends MultiParameterSingleDimensionTransform {
+
+ private static final int MAX_ITERATIONS = 10;
+ private static final double TOLERANCE = 1e-9;
+
+ public StageCreepCorrectionTransform() {
+ this(0, 0, 0, 0, 0);
+ }
+
+ public StageCreepCorrectionTransform(final double a,
+ final double b,
+ final double c,
+ final double d,
+ final int dimension) {
+ super(new double[] {a, b, c, d}, dimension);
+ }
+
+ @Override
+ public int getNumberOfCoefficients() {
+ return 4;
+ }
+
+ /**
+ * Forward transform (source → world): given y_s, computes y_w by solving
+ * {@code y_w + a * exp(-y_w / b) + c * exp(-y_w / d) = y_s} using Newton's method.
+ */
+ @Override
+ public void applyInPlace(final double[] location) {
+ final double a = coefficients[0];
+ final double b = coefficients[1];
+ final double c = coefficients[2];
+ final double d = coefficients[3];
+ final double y_s = location[dimension];
+
+ // solve g(y_w) = y_w + a*exp(-y_w/b) + c*exp(-y_w/d) - y_s = 0
+ // g'(y_w) = 1 - (a/b)*exp(-y_w/b) - (c/d)*exp(-y_w/d)
+ double y_w = y_s; // initial guess
+ for (int i = 0; i < MAX_ITERATIONS; i++) {
+ final double expB = Math.exp(-y_w / b);
+ final double expD = Math.exp(-y_w / d);
+ final double g = y_w + a * expB + c * expD - y_s;
+ if (Math.abs(g) < TOLERANCE) {
+ break;
+ }
+ final double gPrime = 1.0 - (a / b) * expB - (c / d) * expD;
+ y_w -= g / gPrime;
+ }
+
+ location[dimension] = y_w;
+ }
+
+ @Override
+ public CoordinateTransform copy() {
+ return new StageCreepCorrectionTransform(coefficients[0],
+ coefficients[1],
+ coefficients[2],
+ coefficients[3],
+ dimension);
+ }
+}
diff --git a/render-ws-java-client/src/main/java/org/janelia/render/client/multisem/CreepCorrectionClient.java b/render-ws-java-client/src/main/java/org/janelia/render/client/multisem/CreepCorrectionClient.java
index 0c113073d..696fda0e5 100644
--- a/render-ws-java-client/src/main/java/org/janelia/render/client/multisem/CreepCorrectionClient.java
+++ b/render-ws-java-client/src/main/java/org/janelia/render/client/multisem/CreepCorrectionClient.java
@@ -297,8 +297,10 @@ private Optional findClosestNeighbor(final String targetTileId,
}
/**
- * Computes the correction amplitude from the median y-stretch, using the double-exponential derivative model.
- * Faithfully translates the Python prototype's {@code calculate_a_helper_func}.
+ * Computes the creep correction amplitude from the median y-stretch.
+ * Returns a negative value (same sign as the Python prototype's {@code calculate_a_helper_func}),
+ * which is used directly as the coefficient in {@link StageCreepCorrectionTransform}'s backward map:
+ * {@code y_source = y_world + a * exp(-y_world / L)}.
*/
static double computeCorrectionAmplitude(final double medianStretch) {
final double b = (-1.0 / L0) * Math.exp(-(double) TOP_LINE / L0)
@@ -387,12 +389,14 @@ ValidationResult validateResults(final List stretches, final String mfov
}
/**
- * Builds a {@link LeafTransformSpec} for the creep correction using {@code SEMDistortionTransformA}.
- * Formula: {@code y += amplitude * exp(-y/L0) + amplitude * exp(-y/L1)}
+ * Builds a {@link LeafTransformSpec} for the creep correction using {@code StageCreepCorrectionTransform}.
+ * The amplitude is negative (same as the Python prototype), defining the backward map
+ * {@code y_s = y_w + a*exp(-y_w/L0) + a*exp(-y_w/L1)} where negative {@code a} reads from above,
+ * compressing the top of the image to correct for creep stretch.
*/
static TransformSpec buildCorrectionTransformSpec(final double amplitude) {
final String dataString = amplitude + "," + L0 + "," + amplitude + "," + L1 + ",1";
- return new LeafTransformSpec("org.janelia.alignment.transform.SEMDistortionTransformA", dataString);
+ return new LeafTransformSpec("org.janelia.alignment.transform.StageCreepCorrectionTransform", dataString);
}
/**
From baeff857a2008476bbe75a6c79d6fe83203e208a Mon Sep 17 00:00:00 2001
From: Michael Innerberger
Date: Fri, 20 Feb 2026 16:29:08 -0500
Subject: [PATCH 04/13] Require creep correction transform to be part of
matching
---
.../render/client/multisem/CreepCorrectionClient.java | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/render-ws-java-client/src/main/java/org/janelia/render/client/multisem/CreepCorrectionClient.java b/render-ws-java-client/src/main/java/org/janelia/render/client/multisem/CreepCorrectionClient.java
index 696fda0e5..60deca6cc 100644
--- a/render-ws-java-client/src/main/java/org/janelia/render/client/multisem/CreepCorrectionClient.java
+++ b/render-ws-java-client/src/main/java/org/janelia/render/client/multisem/CreepCorrectionClient.java
@@ -19,6 +19,7 @@
import org.janelia.alignment.spec.ResolvedTileSpecCollection;
import org.janelia.alignment.spec.TileSpec;
import org.janelia.alignment.spec.TransformSpec;
+import org.janelia.alignment.spec.TransformSpecMetaData;
import org.janelia.render.client.RenderDataClient;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -396,7 +397,10 @@ ValidationResult validateResults(final List stretches, final String mfov
*/
static TransformSpec buildCorrectionTransformSpec(final double amplitude) {
final String dataString = amplitude + "," + L0 + "," + amplitude + "," + L1 + ",1";
- return new LeafTransformSpec("org.janelia.alignment.transform.StageCreepCorrectionTransform", dataString);
+ final LeafTransformSpec spec = new LeafTransformSpec(
+ "org.janelia.alignment.transform.StageCreepCorrectionTransform", dataString);
+ spec.addLabel(TransformSpecMetaData.INCLUDE_LABEL);
+ return spec;
}
/**
From f489d28a15684ec428a1e7c2e9defd3764072350 Mon Sep 17 00:00:00 2001
From: Michael Innerberger
Date: Tue, 24 Mar 2026 10:29:55 -0400
Subject: [PATCH 05/13] Revert "Require creep correction transform to be part
of matching"
This reverts commit baeff857a2008476bbe75a6c79d6fe83203e208a.
Even with this flag, the transformation doesn't seem to be recognized
during matching; try another approach
---
.../render/client/multisem/CreepCorrectionClient.java | 6 +-----
1 file changed, 1 insertion(+), 5 deletions(-)
diff --git a/render-ws-java-client/src/main/java/org/janelia/render/client/multisem/CreepCorrectionClient.java b/render-ws-java-client/src/main/java/org/janelia/render/client/multisem/CreepCorrectionClient.java
index 60deca6cc..696fda0e5 100644
--- a/render-ws-java-client/src/main/java/org/janelia/render/client/multisem/CreepCorrectionClient.java
+++ b/render-ws-java-client/src/main/java/org/janelia/render/client/multisem/CreepCorrectionClient.java
@@ -19,7 +19,6 @@
import org.janelia.alignment.spec.ResolvedTileSpecCollection;
import org.janelia.alignment.spec.TileSpec;
import org.janelia.alignment.spec.TransformSpec;
-import org.janelia.alignment.spec.TransformSpecMetaData;
import org.janelia.render.client.RenderDataClient;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -397,10 +396,7 @@ ValidationResult validateResults(final List stretches, final String mfov
*/
static TransformSpec buildCorrectionTransformSpec(final double amplitude) {
final String dataString = amplitude + "," + L0 + "," + amplitude + "," + L1 + ",1";
- final LeafTransformSpec spec = new LeafTransformSpec(
- "org.janelia.alignment.transform.StageCreepCorrectionTransform", dataString);
- spec.addLabel(TransformSpecMetaData.INCLUDE_LABEL);
- return spec;
+ return new LeafTransformSpec("org.janelia.alignment.transform.StageCreepCorrectionTransform", dataString);
}
/**
From e8d67c708eb7c5562bfb401a02d0b13e8c6b0880 Mon Sep 17 00:00:00 2001
From: Michael Innerberger
Date: Tue, 24 Mar 2026 14:03:24 -0400
Subject: [PATCH 06/13] Transform matches alongside creep correction
---
.../multisem/CreepCorrectionClient.java | 209 ++++++++++++++++--
.../multisem/CreepCorrectionSparkClient.java | 100 ++++++++-
2 files changed, 282 insertions(+), 27 deletions(-)
diff --git a/render-ws-java-client/src/main/java/org/janelia/render/client/multisem/CreepCorrectionClient.java b/render-ws-java-client/src/main/java/org/janelia/render/client/multisem/CreepCorrectionClient.java
index 696fda0e5..cc814ab94 100644
--- a/render-ws-java-client/src/main/java/org/janelia/render/client/multisem/CreepCorrectionClient.java
+++ b/render-ws-java-client/src/main/java/org/janelia/render/client/multisem/CreepCorrectionClient.java
@@ -3,8 +3,11 @@
import static org.janelia.alignment.spec.ResolvedTileSpecCollection.TransformApplicationMethod.INSERT_BEFORE_LAST;
import java.io.IOException;
+import java.io.Serializable;
import java.util.ArrayList;
+import java.util.Arrays;
import java.util.HashMap;
+import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@@ -13,6 +16,7 @@
import org.janelia.alignment.match.CanvasMatchResult;
import org.janelia.alignment.match.CanvasMatches;
+import org.janelia.alignment.match.Matches;
import org.janelia.alignment.multisem.MultiSemUtilities;
import org.janelia.alignment.spec.LeafTransformSpec;
import org.janelia.alignment.spec.LayoutData;
@@ -24,6 +28,10 @@
import org.slf4j.LoggerFactory;
import mpicbg.models.AffineModel2D;
+import mpicbg.models.CoordinateTransform;
+import mpicbg.models.InvertibleCoordinateTransform;
+import mpicbg.models.InvertibleCoordinateTransformList;
+import mpicbg.models.NoninvertibleModelException;
import mpicbg.models.NotEnoughDataPointsException;
import mpicbg.models.PointMatch;
@@ -71,13 +79,13 @@ public class CreepCorrectionClient {
* Processes all mFOVs for a given z-layer: loads tiles and matches, estimates creep correction,
* applies it where valid, and saves the results to the target stack.
*
- * @return number of tiles processed
+ * @return result containing tile count and per-mFOV correction specs
*/
- public int processZLayer(final double z,
- final RenderDataClient renderDataClient,
- final RenderDataClient matchDataClient,
- final String stack,
- final String targetStack)
+ public ZLayerResult processZLayer(final double z,
+ final RenderDataClient renderDataClient,
+ final RenderDataClient matchDataClient,
+ final String stack,
+ final String targetStack)
throws IOException {
LOG.info("processZLayer: entry, z={}", z);
@@ -104,14 +112,16 @@ public int processZLayer(final double z,
}
// process each mFOV independently
+ final Map mfovCorrections = new HashMap<>();
int correctedMFOVCount = 0;
int skippedMFOVCount = 0;
for (final Map.Entry> entry : mfovToTiles.entrySet()) {
final String mfov = entry.getKey();
final List mfovTiles = entry.getValue();
- final boolean corrected = processMFOV(mfov, mfovTiles, pairKeyToMatches, resolvedTiles);
- if (corrected) {
+ final TransformSpec correctionSpec = processMFOV(mfov, mfovTiles, pairKeyToMatches, resolvedTiles);
+ if (correctionSpec != null) {
+ mfovCorrections.put(mfov, correctionSpec);
correctedMFOVCount++;
} else {
skippedMFOVCount++;
@@ -124,25 +134,25 @@ public int processZLayer(final double z,
LOG.info("processZLayer: exit, z={}, correctedMFOVs={}, skippedMFOVs={}, totalTiles={}",
z, correctedMFOVCount, skippedMFOVCount, resolvedTiles.getTileCount());
- return resolvedTiles.getTileCount();
+ return new ZLayerResult(resolvedTiles.getTileCount(), mfovCorrections);
}
/**
* Processes a single mFOV: finds neighbor pairs, estimates stretch, validates, and applies correction.
*
- * @return true if correction was applied, false if skipped
+ * @return the correction transform spec if applied, or null if skipped
*/
- boolean processMFOV(final String mfov,
- final List mfovTiles,
- final Map pairKeyToMatches,
- final ResolvedTileSpecCollection resolvedTiles) {
+ TransformSpec processMFOV(final String mfov,
+ final List mfovTiles,
+ final Map pairKeyToMatches,
+ final ResolvedTileSpecCollection resolvedTiles) {
// find geometric neighbor pairs
final List neighborPairs = findGeometricNeighborPairs(mfovTiles);
if (neighborPairs.isEmpty()) {
LOG.warn("processMFOV: no geometric neighbor pairs found for mFOV {}, skipping", mfov);
- return false;
+ return null;
}
// estimate y-stretch for each pair that has matches
@@ -172,7 +182,7 @@ boolean processMFOV(final String mfov,
if (!validation.isValid) {
LOG.warn("processMFOV: skipping mFOV {} - {}", mfov, validation.diagnosticMessage);
- return false;
+ return null;
}
// build and apply correction transform
@@ -185,7 +195,7 @@ boolean processMFOV(final String mfov,
LOG.info("processMFOV: applied creep correction to mFOV {} with amplitude={}, medianStretch={}, stddev={}",
mfov, validation.amplitude, validation.medianStretch, validation.stddev);
- return true;
+ return correctionSpec;
}
/**
@@ -419,6 +429,171 @@ private static String pairKey(final String id1, final String id2) {
return id1.compareTo(id2) < 0 ? id1 + "::" + id2 : id2 + "::" + id1;
}
+ /**
+ * Transforms all matches for a given group (z-layer) using the collected creep corrections.
+ * Handles both within-group and outside-group matches.
+ */
+ public void transformMatchesForGroup(final String groupId,
+ final Map allResults,
+ final RenderDataClient renderDataClient,
+ final RenderDataClient sourceMatchClient,
+ final RenderDataClient targetMatchClient,
+ final String stack)
+ throws IOException {
+
+ LOG.info("transformMatchesForGroup: entry, groupId={}", groupId);
+
+ // get within-group and outside-group matches
+ final List withinMatches = sourceMatchClient.getMatchesWithinGroup(groupId, false);
+ final List outsideMatches = sourceMatchClient.getMatchesOutsideGroup(groupId, false);
+
+ final List allMatches = new ArrayList<>(withinMatches);
+ allMatches.addAll(outsideMatches);
+
+ if (allMatches.isEmpty()) {
+ LOG.info("transformMatchesForGroup: no matches found for groupId={}", groupId);
+ return;
+ }
+
+ // collect all z-layers referenced by these matches
+ final Set neededZLayers = new HashSet<>();
+ neededZLayers.add(groupId);
+ for (final CanvasMatches cm : outsideMatches) {
+ neededZLayers.add(cm.getpGroupId());
+ neededZLayers.add(cm.getqGroupId());
+ }
+
+ // load tile specs for all referenced z-layers from source stack
+ final Map tileIdToSpec = new HashMap<>();
+ for (final String zString : neededZLayers) {
+ final ResolvedTileSpecCollection resolved =
+ renderDataClient.getResolvedTiles(stack, Double.parseDouble(zString));
+ for (final TileSpec ts : resolved.getTileSpecs()) {
+ tileIdToSpec.put(ts.getTileId(), ts);
+ }
+ }
+
+ // transform and collect matches
+ final List transformedMatches = new ArrayList<>();
+ for (final CanvasMatches cm : allMatches) {
+ transformedMatches.add(transformCanvasMatches(cm, allResults, tileIdToSpec));
+ }
+
+ // save to target match collection
+ targetMatchClient.saveMatches(transformedMatches);
+
+ LOG.info("transformMatchesForGroup: exit, groupId={}, transformed {} matches",
+ groupId, transformedMatches.size());
+ }
+
+ /**
+ * Transforms a single CanvasMatches by applying creep corrections to both p and q coordinates.
+ */
+ static CanvasMatches transformCanvasMatches(final CanvasMatches cm,
+ final Map allResults,
+ final Map tileIdToSpec) {
+ final Matches matches = cm.getMatches();
+ final double[][] ps = matches.getPs();
+ final double[][] qs = matches.getQs();
+ final double[] ws = matches.getWs();
+ final int n = ws.length;
+
+ // deep copy coordinate arrays
+ final double[][] newPs = new double[][] { Arrays.copyOf(ps[0], n), Arrays.copyOf(ps[1], n) };
+ final double[][] newQs = new double[][] { Arrays.copyOf(qs[0], n), Arrays.copyOf(qs[1], n) };
+
+ // transform p and q coordinates using their respective tile's creep correction
+ transformMatchCoordinates(newPs, cm.getpId(), cm.getpGroupId(), allResults, tileIdToSpec);
+ transformMatchCoordinates(newQs, cm.getqId(), cm.getqGroupId(), allResults, tileIdToSpec);
+
+ return new CanvasMatches(cm.getpGroupId(), cm.getpId(),
+ cm.getqGroupId(), cm.getqId(),
+ new Matches(newPs, newQs, Arrays.copyOf(ws, n)));
+ }
+
+ /**
+ * Transforms match coordinates in place by applying creep correction.
+ * Uses the pattern from {@link MultiSemUtilities#transformMFOVMatchesForTile}:
+ * invert post-matching transforms, apply CC, re-apply post-matching transforms.
+ */
+ static void transformMatchCoordinates(final double[][] coords,
+ final String tileId,
+ final String groupId,
+ final Map allResults,
+ final Map tileIdToSpec) {
+
+ // look up CC spec for this tile's mFOV
+ final String mfov = MultiSemUtilities.getMagcMfovForTileId(tileId);
+ final ZLayerResult layerResult = allResults.get(groupId);
+ if (layerResult == null) {
+ return;
+ }
+ final TransformSpec ccSpec = layerResult.mfovCorrections.get(mfov);
+ if (ccSpec == null) {
+ return;
+ }
+
+ // get tile's post-matching transforms (alignment transforms applied after feature matching)
+ final TileSpec tileSpec = tileIdToSpec.get(tileId);
+ if (tileSpec == null) {
+ LOG.warn("transformMatchCoordinates: no tile spec found for {}", tileId);
+ return;
+ }
+
+ final List postMatchingTransformList =
+ tileSpec.getPostMatchingTransformList().getList(null);
+
+ if (postMatchingTransformList.isEmpty()) {
+ // no post-matching transforms; CC applies directly to match coordinates
+ final CoordinateTransform ccTransform = ccSpec.getNewInstance();
+ for (int i = 0; i < coords[0].length; i++) {
+ final double[] point = new double[] { coords[0][i], coords[1][i] };
+ ccTransform.applyInPlace(point);
+ coords[0][i] = point[0];
+ coords[1][i] = point[1];
+ }
+ return;
+ }
+
+ // build invertible post-matching transform list
+ final InvertibleCoordinateTransformList invertiblePostMatching =
+ new InvertibleCoordinateTransformList<>();
+ for (final CoordinateTransform ct : postMatchingTransformList) {
+ invertiblePostMatching.add((InvertibleCoordinateTransform) ct);
+ }
+
+ final CoordinateTransform ccTransform = ccSpec.getNewInstance();
+
+ // transform each point: new_world = postMatching(CC(postMatching^(-1)(old_world)))
+ for (int i = 0; i < coords[0].length; i++) {
+ final double[] point = new double[] { coords[0][i], coords[1][i] };
+ try {
+ // step 1: invert post-matching transforms to get intermediate coordinates
+ invertiblePostMatching.applyInverseInPlace(point);
+ // step 2: apply creep correction
+ ccTransform.applyInPlace(point);
+ // step 3: re-apply post-matching transforms
+ invertiblePostMatching.applyInPlace(point);
+
+ coords[0][i] = point[0];
+ coords[1][i] = point[1];
+ } catch (final NoninvertibleModelException e) {
+ LOG.warn("transformMatchCoordinates: skipping non-invertible point for tile {}, index {}", tileId, i);
+ }
+ }
+ }
+
+ /** Result of processing a z-layer, containing tile count and per-mFOV correction specs. */
+ public static class ZLayerResult implements Serializable {
+ public final int tileCount;
+ public final Map mfovCorrections;
+
+ ZLayerResult(final int tileCount, final Map mfovCorrections) {
+ this.tileCount = tileCount;
+ this.mfovCorrections = mfovCorrections;
+ }
+ }
+
/** A pair of tile IDs representing geometrically adjacent sFOVs. */
static class TilePair {
final String pTileId;
diff --git a/render-ws-spark-client/src/main/java/org/janelia/render/client/spark/multisem/CreepCorrectionSparkClient.java b/render-ws-spark-client/src/main/java/org/janelia/render/client/spark/multisem/CreepCorrectionSparkClient.java
index bad722172..1fee8ad0d 100644
--- a/render-ws-spark-client/src/main/java/org/janelia/render/client/spark/multisem/CreepCorrectionSparkClient.java
+++ b/render-ws-spark-client/src/main/java/org/janelia/render/client/spark/multisem/CreepCorrectionSparkClient.java
@@ -5,21 +5,23 @@
import java.io.IOException;
import java.io.Serializable;
+import java.util.HashMap;
import java.util.List;
+import java.util.Map;
import org.apache.spark.SparkConf;
import org.apache.spark.api.java.JavaRDD;
import org.apache.spark.api.java.JavaSparkContext;
-import org.apache.spark.api.java.function.Function;
+import org.apache.spark.broadcast.Broadcast;
import org.janelia.alignment.spec.stack.StackMetaData;
import org.janelia.render.client.ClientRunner;
import org.janelia.render.client.RenderDataClient;
import org.janelia.render.client.multisem.CreepCorrectionClient;
+import org.janelia.render.client.multisem.CreepCorrectionClient.ZLayerResult;
import org.janelia.render.client.parameter.CommandLineParameters;
import org.janelia.render.client.parameter.RenderWebServiceParameters;
import org.janelia.render.client.parameter.ZRangeParameters;
import org.janelia.render.client.spark.LogUtilities;
-import org.jetbrains.annotations.NotNull;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -32,6 +34,10 @@
* double-exponential correction is applied if validation passes. mFOVs that fail validation
* are skipped (uploaded without correction).
*
+ * After tile correction, existing point matches are transformed to account for the creep
+ * correction and saved to a new match collection (named {@code targetStack + "_match"}).
+ * This can be skipped with {@code --skipMatchCorrection}.
+ *
* @see CreepCorrectionClient
*
* @author Michael Innerberger
@@ -69,9 +75,18 @@ public static class Parameters extends CommandLineParameters {
required = true)
public String matchCollection;
+ @Parameter(
+ names = "--skipMatchCorrection",
+ description = "Skip transforming match coordinates (default is to transform them)")
+ public boolean skipMatchCorrection = false;
+
String getMatchOwner() {
return matchOwner != null ? matchOwner : renderWeb.owner;
}
+
+ String getTargetMatchCollection() {
+ return targetStack + "_match";
+ }
}
public static void main(final String[] args) {
@@ -120,28 +135,69 @@ public void run() throws IOException {
final StackMetaData sourceStackMetaData = sourceDataClient.getStackMetaData(parameters.stack);
sourceDataClient.setupDerivedStack(sourceStackMetaData, parameters.targetStack);
- LOG.info("run: distributing {} z values for processing", zValues.size());
+ // Phase 1: process tiles and collect corrections
+ LOG.info("run: Phase 1 - distributing {} z values for tile correction", zValues.size());
final JavaRDD rddZValues = sparkContext.parallelize(zValues);
- final JavaRDD rddTileCounts = rddZValues.map(this::processSingleLayer);
- final List tileCountList = rddTileCounts.collect();
+ final JavaRDD rddResults = rddZValues.map(this::processSingleLayer);
+ final List resultList = rddResults.collect();
- long total = 0;
- for (final Integer tileCount : tileCountList) {
- total += tileCount;
+ // collect all corrections on the driver
+ final Map allResults = new HashMap<>();
+ long totalTiles = 0;
+ for (int i = 0; i < zValues.size(); i++) {
+ final ZLayerResult result = resultList.get(i);
+ totalTiles += result.tileCount;
+ allResults.put(String.valueOf(zValues.get(i).doubleValue()), result);
}
- LOG.info("run: processed {} tiles across {} z-layers", total, zValues.size());
+ LOG.info("run: Phase 1 complete - processed {} tiles across {} z-layers", totalTiles, zValues.size());
// complete target stack on the driver
sourceDataClient.setStackState(parameters.targetStack, StackMetaData.StackState.COMPLETE);
+
+ // Phase 2: transform matches
+ if (!parameters.skipMatchCorrection) {
+ transformMatches(sparkContext, allResults);
+ } else {
+ LOG.info("run: skipping match correction (--skipMatchCorrection)");
+ }
}
LOG.info("run: exit");
}
- private Integer processSingleLayer(final Double z) throws IOException {
+ private void transformMatches(final JavaSparkContext sparkContext,
+ final Map allResults)
+ throws IOException {
+
+ final RenderDataClient driverMatchClient = new RenderDataClient(
+ parameters.renderWeb.baseDataUrl,
+ parameters.getMatchOwner(),
+ parameters.matchCollection);
+
+ final List pGroupIds = driverMatchClient.getMatchPGroupIds();
+
+ if (pGroupIds.isEmpty()) {
+ LOG.info("transformMatches: no match groups found, skipping");
+ return;
+ }
+
+ LOG.info("run: Phase 2 - distributing {} match groups for coordinate transformation", pGroupIds.size());
+
+ final Broadcast