diff --git a/render-ws-spark-client/src/main/java/org/janelia/render/client/spark/multisem/Wafer6061Inpainter.java b/render-ws-spark-client/src/main/java/org/janelia/render/client/spark/multisem/Wafer6061Inpainter.java index b05bc3090..938297519 100644 --- a/render-ws-spark-client/src/main/java/org/janelia/render/client/spark/multisem/Wafer6061Inpainter.java +++ b/render-ws-spark-client/src/main/java/org/janelia/render/client/spark/multisem/Wafer6061Inpainter.java @@ -2,23 +2,50 @@ import com.beust.jcommander.Parameter; + +import java.io.IOException; +import java.io.Serializable; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Random; +import java.util.function.Consumer; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import bdv.export.Downsample; import net.imglib2.Cursor; -import net.imglib2.Interval; +import net.imglib2.IterableInterval; +import net.imglib2.KDTree; import net.imglib2.RandomAccess; import net.imglib2.RandomAccessibleInterval; +import net.imglib2.RealPoint; import net.imglib2.img.Img; import net.imglib2.img.array.ArrayImgs; +import net.imglib2.img.basictypeaccess.AccessFlags; +import net.imglib2.img.cell.CellGrid; +import net.imglib2.neighborsearch.KNearestNeighborSearchOnKDTree; import net.imglib2.type.numeric.integer.UnsignedByteType; -import net.imglib2.util.Intervals; +import net.imglib2.type.numeric.real.DoubleType; import net.imglib2.view.Views; + import org.apache.spark.SparkConf; import org.apache.spark.api.java.JavaRDD; import org.apache.spark.api.java.JavaSparkContext; import org.apache.spark.broadcast.Broadcast; +import org.janelia.alignment.multisem.MultiSemUtilities; +import org.janelia.alignment.spec.Bounds; +import org.janelia.alignment.spec.TileBounds; import org.janelia.alignment.util.Grid; import org.janelia.render.client.ClientRunner; +import org.janelia.render.client.RenderDataClient; import org.janelia.render.client.parameter.CommandLineParameters; import org.janelia.render.client.spark.LogUtilities; +import org.janelia.saalfeldlab.n5.DataBlock; import org.janelia.saalfeldlab.n5.DatasetAttributes; import org.janelia.saalfeldlab.n5.N5Reader; import org.janelia.saalfeldlab.n5.N5Writer; @@ -27,376 +54,957 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.io.Serializable; -import java.util.Arrays; -import java.util.List; -import java.util.Objects; - /** - * Class for inpainting small gaps between tiles in the wafer 60/61 dataset. + * Spark client for filling holes in the tissue of wafer 60/61 N5 volumes. + *

+ * Inputs are (a) an N5 tissue volume (only non-empty blocks stored), (b) an N5 mask marking where image data is + * present ({@code mask > 0}) vs. missing ({@code mask == 0}), and (c) the acquisition xlog zarr. The ROI point cloud + * is built in the tissue's s0 voxel frame from the ALIGNED render stack (so montage stitching is accounted for): the + * first layer's tile bounds are fetched from the render web service on the driver, and each tile center (render world + * pixels) is mapped to the voxel frame with {@code voxel = center - translate} (the neuroglancer group-level + * {@code translate}, i.e. the stack bounding-box min). Each tile carries the {@code distance_roi} of its SFOV, read + * from the xlog for the slab whose {@code id_serial} equals {@code serial}: the tile's mfov (0-based) maps to the xlog + * mfov axis as {@code render_mfov + 5} (the axis reserves 5 leading always-NaN rows), and its sfov number (the 1-based + * {@code _s##} field) maps to the 0-based xlog sfov axis as {@code _s## - 1}. *

- * The regions to inpaint are determined by looking at mask pixels: if an unmasked pixel is encountered, - * the algorithm looks at pairs of pixels `stepSize` away in the x and y directions to find non-masked pixels. - * If such a pair is found, the pixel is most likely in a narrow gap between tiles and needs inpainting. The - * inpainting is done by averaging the image data from the adjacent pixels in the z direction. If only one of the - * image values in z is available, that value is used. If neither is available, the pixel is set to 0. + * The client processes the full-resolution ({@code s0}) blocks of the tissue in parallel. For each block it first + * applies the cheap ROI-distance filter (interpolating {@code distance_roi} at the block center via inverse-distance + * weighting; blocks whose interpolated distance is {@code >= maxRoiDistance} are skipped without any I/O), then reads + * the block (skipping absent/empty blocks) and fills every pixel where the mask is 0 with the average of the tissue in + * the sections above and below (copying the single neighbor at the volume's z-boundary). Before overwriting a modified + * block, the original block is copied verbatim into a sibling {@code _backup} N5 container. Finally, only the pyramid + * blocks affected by the modified {@code s0} blocks are re-downsampled (again backing up the originals first), using + * the same per-block averaging as {@code N5DownsamplerSpark} so the pyramid stays consistent. */ public class Wafer6061Inpainter { + // Inverse-distance-weighting knobs for the ROI-distance interpolation; fixed for wafer 60/61. + private static final int IDW_K = 8; + private static final double IDW_POWER = 2.0; + + // Tissue containers/datasets are named w_s_r (e.g. w61_s109_r00); the serial is parsed from that. + private static final Pattern SERIAL_IN_NAME = Pattern.compile("_s(\\d+)"); + + // xlog field layout (consumed by loadPointCloud). The xlog is a zarr: its arrays are stored C-order, but the + // n5-zarr reader REVERSES the axis order, so the imglib2 axis indices below are the reverse of the C-order shape. + // field C-order shape imglib2 axes (what this code sees) meaning + // id_serial (slab,) [slab] serial label per slab position + // distance_roi (slab, mfov, sfov) [sfov, mfov, slab] distance to ROI, um (scan-independent) + // The slab axis index is HARDCODED (below) rather than discovered by matching sizes (sizes are not guaranteed to + // be unique). After slicing the slab axis, distance_roi reduces to a 2-D (sfov, mfov) plane. + private static final int DIST_AXIS_SLAB = 2; + + // The distance_roi mfov axis reserves 5 leading (always-NaN) positions before the first real mfov: real mfov data + // starts at row 5 for every wafer-60/61 slab (verified constant + contiguous across all 399 finite slabs). Render + // numbers each section's mfovs from 0, so the render mfov maps to the xlog mfov axis position as render_mfov + 5. + private static final int MFOV_ROW_OFFSET = 5; + private static final Logger LOG = LoggerFactory.getLogger(Wafer6061Inpainter.class); public static class Parameters extends CommandLineParameters { @Parameter( names = "--n5Path", - description = "Path to the N5 container containing the data and the mask.", + description = "Path to the N5 container holding the tissue and mask (local path or gs://...).", required = true) public String n5Path; @Parameter( names = "--dataset", - description = "Name of the dataset to inpaint; assumed to be a multiscale pyramid, only s0 is inpainted.", + description = "Name of the tissue multiscale group; the full-resolution data is at /s0.", required = true) public String dataset; @Parameter( names = "--mask", - description = "Name of the mask dataset. This is supposed be a binary uint8 mask covering the whole dataset.", + description = "Name of the binary uint8 mask dataset (same grid as /s0). Pass /s0 if the mask is itself a pyramid.", required = true) public String mask; @Parameter( - names = "--output", - description = "Name of the dataset to write the inpainted data to. Only blocks that are inpainted are written. " - + "If omitted, input blocks are overwritten.") - public String output; + names = "--xlogPath", + description = "Path to the acquisition xlog zarr, e.g. xlog_wafer_61.zarr (local path or gs://...).", + required = true) + public String xlogPath; @Parameter( - names = "--inpaintingSize", - description = "Rough size of the inpainting region in pixels. This is used to determine the regions to inpaint, so better to be too large than too small.", - required = true) - public int stepSize; + names = "--serial", + description = "Serial label (id_serial) of the section stored in this N5, resolved to the reference " + + "arrays' slab position. Defaults to the serial parsed from the dataset name " + + "(w_s_r, e.g. w61_s109_r00 -> 109).") + public long serial = -1; + + @Parameter( + names = "--maxRoiDistance", + description = "Keep (inpaint) a block only if its interpolated distance_roi (microns) is less than this.") + public double maxRoiDistance = 10.0; + + @Parameter( + names = "--backupPath", + description = "N5 container where original (overwritten) blocks are backed up. Defaults to a sibling _backup.n5.") + public String backupPath; + @Parameter( + names = "--dryRun", + description = "Only compute and log candidate / would-be-modified counts; do not write or back up anything.") + public boolean dryRun = false; + + public String fullDataset() { + return dataset + "/s0"; + } + + public String getBackupPath() { + if (backupPath != null) { + return backupPath; + } + String p = n5Path; + while (p.endsWith("/")) { + p = p.substring(0, p.length() - 1); + } + if (p.endsWith(".n5")) { + return p.substring(0, p.length() - 3) + "_backup.n5"; + } + return p + "_backup.n5"; + } public void validate() { - if (stepSize <= 0) { - throw new IllegalArgumentException("Inpainting size must be positive"); + if (maxRoiDistance <= 0) { + throw new IllegalArgumentException("--maxRoiDistance must be positive"); + } + if (serial < 0) { + serial = inferSerial(dataset); } } - public String fullDataset() { - return dataset + "/s0"; + /** Parses the serial label from a dataset/container name like {@code .../w61_s109_r00} (basename {@code _s}). */ + static long inferSerial(final String path) { + final Matcher matcher = SERIAL_IN_NAME.matcher(basename(path)); + if (matcher.find()) { + return Long.parseLong(matcher.group(1)); + } + throw new IllegalArgumentException("could not infer the serial from '" + path + "'; pass --serial explicitly"); + } + + /** Last path segment of a path, stripped of any leading group/parent path and trailing slashes. */ + static String basename(final String path) { + String p = path; + while (p.endsWith("/")) { + p = p.substring(0, p.length() - 1); + } + final int slash = p.lastIndexOf('/'); + return slash >= 0 ? p.substring(slash + 1) : p; } } - private final Parameters param; + private final Parameters params; - private ExtendedAttributes tissueAttributes; - private ExtendedAttributes maskAttributes; + public Wafer6061Inpainter(final Parameters params) { + this.params = params; + } + 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); + parameters.validate(); + + LOG.info("runClient: entry, parameters={}", parameters); - public Wafer6061Inpainter(final Parameters parameters) { - this.param = parameters; + final Wafer6061Inpainter client = new Wafer6061Inpainter(parameters); + client.run(); + } + }; + clientRunner.run(); } - public void run() { - final String output = param.output == null ? "input dataset" : "'" + param.output + "'"; - LOG.info("Inpainting dataset '{}' in '{}' using mask '{}' and writing to {}", - param.dataset, param.n5Path, param.mask, output); - - // Read and cache some metadata of the tissue and mask datasets - // Assume that the tissue is a multiscale pyramid / mask is a standalone dataset - try (final N5Reader n5 = new N5Factory().openReader(N5Factory.StorageFormat.N5, param.n5Path)) { - LOG.info("Reading metadata from {}", param.n5Path); - tissueAttributes = ExtendedAttributes.read(n5, param.fullDataset(), param.dataset); - maskAttributes = ExtendedAttributes.read(n5, param.mask, param.mask); - - if (param.output == null) { - param.output = param.fullDataset(); - LOG.info("Output dataset equals input dataset. Overwriting blocks in the input dataset '{}'", param.output); - } else if (n5.exists(param.output)) { - throw new IllegalArgumentException("Dataset '" + param.output + "' is different from the input dataset and already exists. Stopping."); - } else { - LOG.info("Output dataset is '{}'. Creating new dataset.", param.output); - try (final N5Writer n5Writer = new N5Factory().openWriter(N5Factory.StorageFormat.N5, param.n5Path)) { - n5Writer.createDataset(param.output, tissueAttributes.attrs); + public void run() throws IOException { + + // Read the tissue s0 metadata, the render target, the world->voxel offset, and discover the pyramid levels. + final double[] worldToVoxel; + final RenderTarget renderTarget; + final int numDimensions; + final List levels = new ArrayList<>(); + final Map levelAttributes = new LinkedHashMap<>(); + int[] downsampleFactors = null; + try (final N5Reader n5 = openN5Reader(params.n5Path)) { + + final DatasetAttributes s0Attributes = n5.getDatasetAttributes(params.fullDataset()); + if (s0Attributes == null) { + throw new IllegalArgumentException("tissue dataset " + params.fullDataset() + " does not exist"); + } + numDimensions = s0Attributes.getNumDimensions(); + if (numDimensions != 3) { + throw new IllegalArgumentException("expected a 3D tissue volume but " + params.fullDataset() + + " has " + numDimensions + " dimensions"); + } + final DatasetAttributes maskAttributes = n5.getDatasetAttributes(params.mask); + if (maskAttributes == null) { + throw new IllegalArgumentException("mask dataset " + params.mask + " does not exist"); + } + + // The inpainter reads and writes one chunk at a time (no whole-volume open, so no accumulating cell + // cache). That requires z to be a single chunk, so that every z-1 / z+1 section the z-average needs lives + // inside the block; and it reads the mask by the tissue block's grid position, so the two datasets must + // share the same block grid and origin. + if (s0Attributes.getBlockSize()[2] < s0Attributes.getDimensions()[2]) { + throw new IllegalArgumentException( + "block-local inpainting requires z to be a single chunk, but " + params.fullDataset() + + " has blockSize[2]=" + s0Attributes.getBlockSize()[2] + + " < dimensions[2]=" + s0Attributes.getDimensions()[2]); + } + if (! Arrays.equals(maskAttributes.getBlockSize(), s0Attributes.getBlockSize()) || + ! Arrays.equals(maskAttributes.getDimensions(), s0Attributes.getDimensions())) { + throw new IllegalArgumentException( + "mask grid " + Arrays.toString(maskAttributes.getDimensions()) + " @ " + + Arrays.toString(maskAttributes.getBlockSize()) + " must match tissue grid " + + Arrays.toString(s0Attributes.getDimensions()) + " @ " + + Arrays.toString(s0Attributes.getBlockSize())); + } + + // Render service parameters (baseDataUrl / owner / project / stack) come straight from the group's + // "renderExport" metadata written by render's N5 export — the same attributes.json that holds the pyramid + // scales and translate — so they never have to be passed on the command line. + renderTarget = readRenderTarget(n5, params.dataset); + + // World->voxel offset for placing render tile centers: the neuroglancer 'translate' (the stack + // bounding-box min in world pixels) is written on the multiscale GROUP, not on s0 (s0 only carries a + // sub-pixel centering transform). May be null here; loadPointCloud falls back to the render stack bounds. + worldToVoxel = readGroupTranslate(n5, params.dataset); + + levels.add(params.fullDataset()); + levelAttributes.put(params.fullDataset(), s0Attributes); + for (int scale = 1; ; scale++) { + final String levelDataset = params.dataset + "/s" + scale; + if (! n5.datasetExists(levelDataset)) { + break; } + levels.add(levelDataset); + levelAttributes.put(levelDataset, n5.getDatasetAttributes(levelDataset)); + } + + // The relative per-level downsampling factor is read from the pyramid itself rather than passed in: the + // group's neuroglancer "scales" attribute lists the cumulative factor per level relative to s0, and these + // pyramids are built with a constant factor at every step (see DownsampleHelper / N5DownsamplerSpark), so + // scales[1] applies to all levels. + if (levels.size() > 1) { + downsampleFactors = readDownsamplingFactors(n5, params.dataset, numDimensions); } } + LOG.info("run: world->voxel translate={}, pyramid levels={}, downsampleFactors={}", + Arrays.toString(worldToVoxel), levels, Arrays.toString(downsampleFactors)); + + // Load the (small) per-slab ROI point cloud on the driver: positions from the aligned render stack, distances + // from the xlog. A single render request keeps the server load light; the cloud is then broadcast to executors. + final RenderDataClient renderClient = + new RenderDataClient(renderTarget.baseDataUrl, renderTarget.owner, renderTarget.project); + final PointCloud cloud = loadPointCloud(params, renderClient, renderTarget.stack, worldToVoxel); + LOG.info("run: loaded {} ROI reference points for serial {} (render {} {}/{}/{})", + cloud.size(), params.serial, renderTarget.baseDataUrl, renderTarget.owner, renderTarget.project, + renderTarget.stack); + + // Create the backup container and mirror all datasets that might receive backups. + final String backupPath = params.getBackupPath(); + if (! params.dryRun) { + try (final N5Writer backup = openN5Writer(backupPath)) { + for (final String levelDataset : levels) { + if (! backup.datasetExists(levelDataset)) { + backup.createDataset(levelDataset, levelAttributes.get(levelDataset)); + } + } + } + LOG.info("run: originals will be backed up to {}", backupPath); + } - final SparkConf conf = new SparkConf().setAppName("Wafer6061Inpainter"); + final SparkConf conf = new SparkConf().setAppName(getClass().getSimpleName()); try (final JavaSparkContext sparkContext = new JavaSparkContext(conf)) { - runWithSparkContext(sparkContext); + LOG.info("run: appId is {}", sparkContext.getConf().getAppId()); + runWithSparkContext(sparkContext, cloud, levels, levelAttributes, backupPath, downsampleFactors); } } - private void runWithSparkContext(final JavaSparkContext sparkContext) { - // Find out which blocks need inpainting (i.e., find blocks that are neither all mask nor all void) - final List maskBlocks = Grid.create(maskAttributes.attrs.getDimensions(), maskAttributes.attrs.getBlockSize()); - final JavaRDD maskRDD = sparkContext.parallelize(maskBlocks); - final Broadcast maskAttributesBroadcast = sparkContext.broadcast(maskAttributes); - final Broadcast paramBroadcast = sparkContext.broadcast(param); - LOG.info("Filtering empty mask blocks from {} blocks", maskBlocks.size()); - - final List nonHomogeneousMaskBlocks = maskRDD - .map(block -> translateAndCheckHomogeneity(block, - maskAttributesBroadcast.value().min, - paramBroadcast.value())) - .filter(Objects::nonNull) - .collect(); - LOG.info("Found {} non-homogeneous mask blocks", nonHomogeneousMaskBlocks.size()); - - // Check which tissue blocks are covered by the potentially inpainted mask blocks determined above - final List tissueBlocks = Grid.create(tissueAttributes.attrs.getDimensions(), tissueAttributes.attrs.getBlockSize()); - final JavaRDD tissueBlocksRDD = sparkContext.parallelize(tissueBlocks); - final Broadcast tissueAttributesBroadcast = sparkContext.broadcast(tissueAttributes); - final Broadcast> maskBlocksBroadcast = sparkContext.broadcast(nonHomogeneousMaskBlocks); - - final List tissueBlocksToInpaint = tissueBlocksRDD.map( - block -> translateAndCheckIfOverlaps(block, - tissueAttributesBroadcast.value().min, - maskBlocksBroadcast.value())) - .filter(Objects::nonNull) - .collect(); - LOG.info("Found {} tissue blocks to inpaint", tissueBlocksToInpaint.size()); - - // Inpaint the blocks - final JavaRDD inpaintingBlocksRDD = sparkContext.parallelize(tissueBlocksToInpaint); - - inpaintingBlocksRDD.foreach(block -> inpaintBlock(block, - maskAttributesBroadcast.value().min, - tissueAttributesBroadcast.value().min, - paramBroadcast.value(), - tissueAttributesBroadcast.value().attrs)); + private void runWithSparkContext(final JavaSparkContext sparkContext, + final PointCloud cloud, + final List levels, + final Map levelAttributes, + final String backupPath, + final int[] downsampleFactors) { + + final DatasetAttributes s0Attributes = levelAttributes.get(params.fullDataset()); + final List s0Blocks = new ArrayList<>(Grid.create(s0Attributes.getDimensions(), + s0Attributes.getBlockSize())); + + // Grid.create returns blocks in raster order, and Spark's parallelize slices the list into contiguous + // partitions. The blocks that actually do work (present-check + inpaint) are the near-ROI ones, and the ROI is + // a small, spatially clustered region, so contiguous slicing piles all the expensive blocks into a few + // partitions while the rest only run the cheap no-I/O distance filter -> severe load skew. Shuffling first gives + // every partition a representative mix of near- and far-ROI blocks, so the pass is balanced. The seed (serial) + // keeps the partitioning reproducible, and the outputs (emitted grid positions, per-block decision logs) are + // order-independent, so this changes only the distribution of work, not the result. + Collections.shuffle(s0Blocks, new Random(params.serial)); + LOG.info("runWithSparkContext: {} s0 grid blocks to consider (shuffled for load balance)", s0Blocks.size()); + + final Broadcast cloudBroadcast = sparkContext.broadcast(cloud); + final Broadcast paramsBroadcast = sparkContext.broadcast(params); + + // A single driver line carries the grid extent and z-depth the visualization needs; the per-block + // 'blockDecision ...' lines are logged on the executors (see inpaintPartition / logDecision). + final long[] dims = s0Attributes.getDimensions(); + final int[] blockSize = s0Attributes.getBlockSize(); + LOG.info("runWithSparkContext: diagnostics metadata serial={} maxRoiDistance={} gridX={} gridY={} zLayers={}", + params.serial, params.maxRoiDistance, + (dims[0] + blockSize[0] - 1) / blockSize[0], (dims[1] + blockSize[1] - 1) / blockSize[1], dims[2]); + + // 2. + 3. Filter (distance, then presence) and inpaint in one distributed pass. + final String backup = params.dryRun ? null : backupPath; + final JavaRDD modifiedRDD = sparkContext.parallelize(s0Blocks).mapPartitions( + blockIterator -> inpaintPartition(blockIterator, + cloudBroadcast.value(), + paramsBroadcast.value(), + backup)); + + final List modifiedS0 = modifiedRDD.collect(); + LOG.info("runWithSparkContext: {} s0 block(s) were {}", + modifiedS0.size(), params.dryRun ? "identified for inpainting (dry run)" : "inpainted"); + + if (params.dryRun || modifiedS0.isEmpty()) { + return; + } + + // 6. Selectively update the downsample pyramid, one level at a time. + updatePyramid(sparkContext, levels, levelAttributes, modifiedS0, backupPath, downsampleFactors); } - private static Grid.Block translateAndCheckHomogeneity( - final Grid.Block block, - final long[] shift, - final Parameters param - ) { - LogUtilities.setupExecutorLog4j(""); - - // Read the mask block and check if it is homogeneous - boolean isHomogeneous = true; - try (final N5Reader n5 = new N5Factory().openReader(N5Factory.StorageFormat.N5, param.n5Path)) { - final Img mask = N5Utils.open(n5, param.mask); - final Interval interval = Intervals.intersect(mask, block); - final RandomAccessibleInterval maskPixels = Views.interval(mask, interval); - - final UnsignedByteType firstPixel = maskPixels.firstElement(); - for (final UnsignedByteType pixel : maskPixels) { - if (! pixel.equals(firstPixel)) { - isHomogeneous = false; - break; + // ------------------------------------------------------------------------------------------------ + // Step 1: load the per-slab ROI point cloud (positions from the aligned render stack, distances from the xlog). + // ------------------------------------------------------------------------------------------------ + + /** + * Builds the per-slab ROI point cloud in the tissue s0 voxel frame. Positions come from the ALIGNED render + * stack (so montage stitching is accounted for): the first layer's tile bounds are fetched from the render web + * service on the driver, and each tile center (render world pixels) is mapped to the voxel frame with + * {@code voxel = center - worldToVoxel} ({@code worldToVoxel} is the neuroglancer group-level {@code translate}, + * i.e. the stack bounding-box min; if it is null we fall back to the render stack bounds). Each tile carries the + * {@code distance_roi} of its SFOV, read from the xlog for the slab whose {@code id_serial} equals + * {@code params.serial}: the tile's mfov (0-based, parsed from the tileId) maps to the xlog mfov axis as + * {@code render_mfov + 5} (the axis reserves 5 leading always-NaN rows; see {@link #MFOV_ROW_OFFSET}), and its sfov + * number (the 1-based {@code _s##} field) maps to the 0-based xlog sfov axis as {@code _s## - 1}. + * Tiles whose SFOV has no (NaN) {@code distance_roi}, or an out-of-range mfov/sfov, are dropped. + */ + static PointCloud loadPointCloud(final Parameters params, + final RenderDataClient renderClient, + final String stack, + final double[] worldToVoxel) + throws IOException { + + // (a) xlog: the slab's 2-D distance_roi grid, materialized into a plain array so it outlives the xlog reader. + final double[][] distBySfovMfov; // [rowMajorSfov0Based][mfov0Based] + try (final N5Reader xlog = new N5Factory().openReader(params.xlogPath)) { + + final double[] idSerial = read1d(xlog); + final int slabPosition = findSlabPosition(idSerial, params.serial); + if (slabPosition < 0) { + final long[] available = new long[idSerial.length]; + for (int i = 0; i < idSerial.length; i++) { + available[i] = Math.round(idSerial[i]); } + throw new IllegalArgumentException("serial " + params.serial + " not found in id_serial; available serials are " + + Arrays.toString(available)); } - } - // Translate the block to physical coordinates - final Interval blockInterval = Intervals.translate(block, shift); - final Grid.Block translatedBlock = new Grid.Block(blockInterval, block.gridPosition); + final RandomAccessibleInterval distAll = openDoubles(xlog, "distance_roi"); + requireSlabAxis(distAll, idSerial.length); + + // distance_roi is scan-independent: slice the slab axis -> 2-D (sfov, mfov). + final RandomAccessibleInterval distSlab = Views.hyperSlice(distAll, DIST_AXIS_SLAB, slabPosition); + final int nSfov = (int) distSlab.dimension(0); + final int nMfov = (int) distSlab.dimension(1); + distBySfovMfov = new double[nSfov][nMfov]; + final RandomAccess dra = distSlab.randomAccess(); + final long[] pos = new long[2]; + for (int s = 0; s < nSfov; s++) { + for (int m = 0; m < nMfov; m++) { + pos[0] = s; + pos[1] = m; + distBySfovMfov[s][m] = dra.setPositionAndGet(pos).get(); + } + } + LOG.info("loadPointCloud: serial {} -> slab position {}, distance_roi grid is {} sfov x {} mfov", + params.serial, slabPosition, nSfov, nMfov); + } + final int nSfov = distBySfovMfov.length; + final int nMfov = nSfov > 0 ? distBySfovMfov[0].length : 0; - final String blockType = isHomogeneous ? "homogeneous -> skip" : "non-homogeneous -> possibly inpaint"; - LOG.info("Mask block {} at {} is {}", translatedBlock.gridPosition, translatedBlock.offset, blockType); - return isHomogeneous ? null : translatedBlock; - } + // (b) render: the first layer's aligned tile centers, fetched once on the driver. + final List zValues = renderClient.getStackZValues(stack); + if (zValues.isEmpty()) { + throw new IllegalArgumentException("render stack " + stack + " has no z layers"); + } + final double firstZ = zValues.get(0); + final List tiles = renderClient.getTileBounds(stack, firstZ); + + final double offsetX; + final double offsetY; + if (worldToVoxel != null) { + offsetX = worldToVoxel[0]; + offsetY = worldToVoxel[1]; + } else { + final Bounds stackBounds = renderClient.getStackMetaData(stack).getStackBounds(); + if (stackBounds == null || stackBounds.getMinX() == null || stackBounds.getMinY() == null) { + throw new IllegalArgumentException("N5 group " + params.dataset + " has no neuroglancer 'translate' and " + + "render stack " + stack + " has no bounds; cannot map world coordinates to voxels"); + } + offsetX = stackBounds.getMinX(); + offsetY = stackBounds.getMinY(); + LOG.warn("loadPointCloud: N5 group {} has no 'translate'; using render stack bounds min ({}, {}) as the world->voxel offset", + params.dataset, offsetX, offsetY); + } - private static Grid.Block translateAndCheckIfOverlaps( - final Grid.Block block, - final long[] shift, - final List blocksToCheckAgainst - ) { - LogUtilities.setupExecutorLog4j(""); - - // Translate the block to physical coordinates - final Interval blockInterval = Intervals.translate(block, shift); - final Grid.Block translatedBlock = new Grid.Block(blockInterval, block.gridPosition); - - // Check if the block overlaps with any of the mask blocks that might need inpainting - for (final Interval maskBlock : blocksToCheckAgainst) { - final boolean intervalsAreDisjoint = Intervals.isEmpty(Intervals.intersect(translatedBlock, maskBlock)); - if (! intervalsAreDisjoint) { - LOG.info("Tissue block {} at {} is determined a candidate for inpainting", - translatedBlock.gridPosition, translatedBlock.minAsLongArray()); - return translatedBlock; + // Attach each tile's distance_roi (by mfov + sfov) and place its center in the voxel frame. + final List xs = new ArrayList<>(); + final List ys = new ArrayList<>(); + final List ds = new ArrayList<>(); + int noDistance = 0; + int outOfRange = 0; + for (final TileBounds tile : tiles) { + final String tileId = tile.getTileId(); + // render mfov is 0-based per section; the xlog mfov axis reserves 5 leading NaN rows (see MFOV_ROW_OFFSET). + final int mfov = Integer.parseInt(MultiSemUtilities.getSimpleMfovForTileId(tileId).substring(1)) + MFOV_ROW_OFFSET; // m0013 -> 13 -> row 18 + final int sfov = Integer.parseInt(MultiSemUtilities.getSFOVIndexForTileId(tileId)) - 1; + if (mfov < 0 || mfov >= nMfov || sfov < 0 || sfov >= nSfov) { + outOfRange++; + continue; + } + final double d = distBySfovMfov[sfov][mfov]; + if (! Double.isFinite(d)) { + noDistance++; + continue; } + xs.add(tile.getCenterX() - offsetX); + ys.add(tile.getCenterY() - offsetY); + ds.add(d); + } + if (xs.isEmpty()) { + throw new IllegalArgumentException("no render tiles in stack " + stack + " z " + firstZ + + " could be matched to a finite xlog distance_roi (fetched " + tiles.size() + + " tiles; " + noDistance + " had NaN distance, " + outOfRange + " had out-of-range mfov/sfov)"); } + LOG.info("loadPointCloud: matched {} of {} render tiles (z {}) to distance_roi; dropped {} NaN-distance, {} out-of-range; world->voxel offset ({}, {})", + xs.size(), tiles.size(), firstZ, noDistance, outOfRange, offsetX, offsetY); + return new PointCloud(toArray(xs), toArray(ys), toArray(ds)); + } - LOG.info("Tissue block {} at {} is not a candidate for inpainting", - translatedBlock.gridPosition, translatedBlock.minAsLongArray()); - return null; + /** Fails fast if a hardcoded slab axis does not carry the expected slab count (guards the layout at the top). */ + private static void requireSlabAxis(final RandomAccessibleInterval img, final long slabCount) { + final int distAxis = Wafer6061Inpainter.DIST_AXIS_SLAB; + if (img.numDimensions() <= distAxis || img.dimension(distAxis) != slabCount) { + throw new IllegalArgumentException("xlog field '" + "distance_roi" + "' has dimensions " + + Arrays.toString(img.dimensionsAsLongArray()) + " but the hardcoded layout " + + "expects " + slabCount + " slabs on axis " + Wafer6061Inpainter.DIST_AXIS_SLAB + + " (see the xlog axis layout at the top of the class)"); + } } - private static void inpaintBlock( - final Grid.Block block, - final long[] maskMin, - final long[] tissueMin, - final Parameters param, - final DatasetAttributes targetAttributes - ) { - LogUtilities.setupExecutorLog4j("Block " + Arrays.toString(block.gridPosition)); - - // Preallocate the inpainted block - final Img inpaintedBlock = ArrayImgs.unsignedBytes(block.dimensions); - - try (final N5Reader n5 = new N5Factory().openReader(N5Factory.StorageFormat.N5, param.n5Path)) { - // Load and translate the tissue and mask data - LOG.info("Loading data at {}", block.offset); - final Img rawTissue = N5Utils.open(n5, param.fullDataset()); - final Img rawMask = N5Utils.open(n5, param.mask); - - final RandomAccessibleInterval tissue = Views.translate(rawTissue, tissueMin); - final RandomAccessibleInterval mask = Views.translate(rawMask, maskMin); - - // For each pixel, determine if it should be inpainted and if so, inpaint it by interpolating in z - LOG.info("Start inpainting"); - final long start = System.currentTimeMillis(); - final Cursor targetCursor = Views.translate(inpaintedBlock, block.offset).localizingCursor(); - final long[] location = new long[3]; - final PixelFiller interpolator = new PixelFiller(tissue, mask, param.stepSize); - - while (targetCursor.hasNext()) { - final UnsignedByteType targetPixel = targetCursor.next(); - targetCursor.localize(location); - final int value = interpolator.getPixel(location); - targetPixel.set(value); - } - LOG.info("Finished inpainting in {} ms", System.currentTimeMillis() - start); + /** Copies a list of doubles into a primitive array. */ + private static double[] toArray(final List list) { + final double[] array = new double[list.size()]; + for (int i = 0; i < array.length; i++) { + array[i] = list.get(i); } + return array; + } - try (final N5Writer n5Writer = new N5Factory().openWriter(N5Factory.StorageFormat.N5, param.n5Path)) { - N5Utils.saveBlock(inpaintedBlock, n5Writer, param.output, targetAttributes, block.gridPosition); - LOG.info("Wrote tissue block to '{}'", param.output); - } catch (final Exception e) { - LOG.error("Failed to write inpainted block", e); + /** Reads a 1-D double dataset (blosc-safe: uses the block-not-found overload to avoid the getAttribute NPE). */ + private static double[] read1d(final N5Reader n5) { + final RandomAccessibleInterval img = openDoubles(n5, "id_serial"); + final long length = img.dimension(0); + final double[] values = new double[(int) length]; + final RandomAccess ra = img.randomAccess(); + for (int i = 0; i < length; i++) { + values[i] = ra.setPositionAndGet(new long[] {i}).get(); } + return values; } + /** + * Opens a double-typed dataset using the {@code (blockNotFoundHandler, accessFlags)} overload. This avoids + * {@code N5Utils.open(reader, dataset)}'s {@code isLabelMultisetType -> getAttribute} path, which NPEs on blosc + * arrays in the shaded jar (the n5-blosc CompressionType service registration is filtered out). Missing blocks + * are filled with NaN so they are dropped downstream. + */ + private static RandomAccessibleInterval openDoubles(final N5Reader n5, final String dataset) { + final Consumer> nanFill = it -> it.forEach(t -> t.set(Double.NaN)); + return N5Utils.open(n5, dataset, nanFill, AccessFlags.setOf()); + } - public static void main(final String[] args) { - final ClientRunner clientRunner = new ClientRunner(args) { - @Override - public void runClient(final String[] args) { + // ------------------------------------------------------------------------------------------------ + // Steps 2 + 3: filter and inpaint one partition of s0 blocks. + // ------------------------------------------------------------------------------------------------ + + private static Iterator inpaintPartition(final Iterator blocks, + final PointCloud cloud, + final Parameters params, + final String backupPath) { + + LogUtilities.setupExecutorLog4j("inpaint"); + + final List modified = new ArrayList<>(); + final KNearestNeighborSearchOnKDTree search = cloud.buildSearch(); + + try (final N5Reader reader = openN5Reader(params.n5Path); + final N5Writer tissueWriter = params.dryRun ? null : openN5Writer(params.n5Path); + final N5Writer backupWriter = params.dryRun ? null : openN5Writer(backupPath)) { + + final DatasetAttributes s0Attributes = reader.getDatasetAttributes(params.fullDataset()); + final DatasetAttributes maskAttributes = reader.getDatasetAttributes(params.mask); + + long considered = 0; + long nearRoi = 0; + long present = 0; + while (blocks.hasNext()) { + final Grid.Block block = blocks.next(); + considered++; + final long gridX = block.gridPosition[0]; + final long gridY = block.gridPosition[1]; + + // (1) ROI-distance filter, computed with no I/O. The cloud is already in the s0 voxel frame, so the + // block center (voxel index) queries it directly; distance_roi is interpolated by inverse-distance + // weighting (the interpolated value is in microns regardless of the voxel-space query units). + final double centerX = block.offset[0] + block.dimensions[0] / 2.0; + final double centerY = block.offset[1] + block.dimensions[1] / 2.0; + final double roiDistance = cloud.interpolate(search, centerX, centerY); + if (! (roiDistance < params.maxRoiDistance)) { + logDecision(gridX, gridY, "outside_roi", -1, roiDistance); + continue; + } + nearRoi++; + + // (2) presence check: readBlock returns null for absent (empty) blocks. The block it returns is also + // the raw tissue we inpaint from and back up, so no whole-volume open (and no accumulating cell cache) + // is needed: because z is a single chunk (guarded on the driver), every z-1 / z+1 section the + // z-average reads lives inside this same block. Near-ROI empty blocks are not logged: the distance + // filter runs before this presence check, so block emptiness is not observable outside the ROI. + final DataBlock tissueBlock = reader.readBlock(params.fullDataset(), s0Attributes, block.gridPosition); + if (tissueBlock == null) { + continue; + } + present++; + + // (3) inpaint: fill mask==0 pixels with the z-average of the sections above/below. The mask is read by + // the same grid position (the datasets share a grid, guarded on the driver); an absent mask block + // counts as all-background (all holes), matching a zero-filled whole-volume read of a missing chunk. + final DataBlock maskBlock = reader.readBlock(params.mask, maskAttributes, block.gridPosition); + final InpaintResult result = inpaintBlock(asByteImg(tissueBlock, block.dimensions), + asByteImg(maskBlock, block.dimensions), + block.dimensions); + if (! result.changed) { + logDecision(gridX, gridY, "inside_roi", -1, roiDistance); + continue; + } - final Wafer6061Inpainter.Parameters parameters = new Wafer6061Inpainter.Parameters(); - parameters.parse(args); - parameters.validate(); + modified.add(block.gridPosition); + if (! params.dryRun) { + backupWriter.writeBlock(params.fullDataset(), s0Attributes, tissueBlock); + N5Utils.saveBlock(result.inpainted, tissueWriter, params.fullDataset(), s0Attributes, block.gridPosition); + } + logDecision(gridX, gridY, "inpainted", result.minChangedLayer, roiDistance); + } + LOG.info("inpaintPartition: partition summary: considered={}, nearRoi(<{}um)={}, present={}, inpainted={}", + considered, params.maxRoiDistance, nearRoi, present, modified.size()); + } - LOG.info("runClient: entry, parameters={}", parameters); + return modified.iterator(); + } - final Wafer6061Inpainter inpainter = new Wafer6061Inpainter(parameters); - inpainter.run(); - } - }; - clientRunner.run(); + /** + * Logs one greppable per-block decision line consumed by the visualization (plot_inpainter_diagnostics.py). Keeping + * a fixed {@code blockDecision key=value ...} shape means the log is the single source of truth — no file is written + * and no decision logic is re-derived downstream. + */ + private static void logDecision(final long gridX, final long gridY, final String decision, + final int minLayer, final double roiDistance) { + LOG.info("blockDecision gridX={} gridY={} decision={} minLayer={} roiDistance={}", + gridX, gridY, decision, minLayer, roiDistance); } + /** + * Wraps a uint8 {@link DataBlock} as a block-local image. An absent (null) block becomes all-zeros, matching the + * zero fill an {@code N5Utils.open} whole-volume read would give for a missing chunk. + */ + private static Img asByteImg(final DataBlock dataBlock, final long[] blockDimensions) { + if (dataBlock == null) { + return ArrayImgs.unsignedBytes(blockDimensions); + } + return ArrayImgs.unsignedBytes((byte[]) dataBlock.getData(), blockDimensions); + } /** - * Performs all the inpainting-logic, i.e., when and how to inpaint. + * Produces the inpainted version of a single block: every pixel where the mask is 0 is replaced with the average + * of the tissue in the sections above and below (the single neighbor is copied at the block's z-boundary). Both + * {@code tissueBlock} and {@code maskBlock} are block-local images with dimensions {@code blockDimensions}; because + * the volume is a single z-chunk, the block's z-boundary is the volume's z-boundary, so no neighbouring block is + * needed for the z-average. */ - private static class PixelFiller { - - private final RandomAccess tissueAccess; - private final RandomAccess maskAccess; - private final int posStep; - private final int negStep; - - public PixelFiller( - final RandomAccessibleInterval tissue, - final RandomAccessibleInterval mask, - final int stepSize - ) { - this.tissueAccess = tissue.randomAccess(); - this.maskAccess = Views.extendZero(mask).randomAccess(); - this.posStep = stepSize; - this.negStep = -2 * stepSize; - } - - public int getPixel(final long[] position) { - if (shouldBeInpainted(position)) { - return zAverage(position); + static InpaintResult inpaintBlock(final RandomAccessibleInterval tissueBlock, + final RandomAccessibleInterval maskBlock, + final long[] blockDimensions) { + + final RandomAccess tissueAccess = tissueBlock.randomAccess(); + final RandomAccess maskAccess = maskBlock.randomAccess(); + final long zMax = blockDimensions[2] - 1; + + final Img inpainted = ArrayImgs.unsignedBytes(blockDimensions); + final Cursor cursor = inpainted.localizingCursor(); + final long[] local = new long[3]; + boolean changed = false; + int minChangedLayer = Integer.MAX_VALUE; + while (cursor.hasNext()) { + final UnsignedByteType target = cursor.next(); + cursor.localize(local); + + final int original = tissueAccess.setPositionAndGet(local).get(); + final int value; + if (maskAccess.setPositionAndGet(local).get() == 255) { + value = original; } else { - return tissueAccess.setPositionAndGet(position).get(); + value = zAverage(tissueAccess, local, 0, zMax); + if (value != original) { + changed = true; + if (local[2] < minChangedLayer) { + minChangedLayer = (int) local[2]; + } + } } + target.set(value); } - /** - * Average the z-values of the pixels above and below the current pixel. - * If only one of the pixels is available, that value is used. - * If neither is available, the pixel is set to 0. - */ - private int zAverage(final long[] position) { - maskAccess.setPosition(position); - maskAccess.move(-1, 2); - final boolean hasContentAbove = maskAccess.get().get() > 0; - maskAccess.move(2, 2); - final boolean hasContentBelow = maskAccess.get().get() > 0; - - tissueAccess.setPositionAndGet(position); - if (hasContentAbove && hasContentBelow) { - tissueAccess.move(-1, 2); - final int above = tissueAccess.get().get(); - - tissueAccess.move(2, 2); - final int below = tissueAccess.get().get(); - - return UnsignedByteType.getCodedSignedByteChecked((above + below) >>> 1); - } else if (hasContentAbove) { - tissueAccess.move(-1, 2); - return tissueAccess.get().get(); - } else if (hasContentBelow) { - tissueAccess.move(2, 2); - return tissueAccess.get().get(); - } else { - return 0; + return new InpaintResult(inpainted, changed, changed ? minChangedLayer : -1); + } + + /** Average of the tissue in the z-1 and z+1 sections, copying the single neighbor at the volume z-boundary. */ + private static int zAverage(final RandomAccess tissueAccess, + final long[] world, + final long zMin, + final long zMax) { + final long z = world[2]; + int sum = 0; + int count = 0; + if (z - 1 >= zMin) { + world[2] = z - 1; + sum += tissueAccess.setPositionAndGet(world).get(); + count++; + } + if (z + 1 <= zMax) { + world[2] = z + 1; + sum += tissueAccess.setPositionAndGet(world).get(); + count++; + } + world[2] = z; + // both neighbors -> mean (== the old (above+below)>>>1 for byte values); one -> that neighbor; none -> unchanged. + return count > 0 ? sum / count : tissueAccess.setPositionAndGet(world).get(); + } + + // ------------------------------------------------------------------------------------------------ + // Step 6: selectively re-downsample only the pyramid blocks affected by the modified s0 blocks. + // ------------------------------------------------------------------------------------------------ + + private void updatePyramid(final JavaSparkContext sparkContext, + final List levels, + final Map levelAttributes, + final List modifiedS0, + final String backupPath, + final int[] factors) { + + List modifiedPrevious = modifiedS0; + + for (int scale = 1; scale < levels.size(); scale++) { + final String fromDataset = levels.get(scale - 1); + final String toDataset = levels.get(scale); + final DatasetAttributes toAttributes = levelAttributes.get(toDataset); + + // affected blocks: previous-level grid position p maps to this-level block p / factor. + final Map affected = new LinkedHashMap<>(); + for (final long[] p : modifiedPrevious) { + final long[] g = affectedBlock(p, factors); + affected.putIfAbsent(Arrays.toString(g), g); } + final List affectedBlocks = new ArrayList<>(affected.values()); + // Every affected block does real work, so there is no near/far skew here, but the per-block cost still varies + // with how much of its source region is actually present (dense in the ROI interior, sparse at its edge). + // The affected blocks inherit a spatial order, so contiguous partitioning would group same-density + // neighbours together and leave some partitions all-dense and others all-sparse. Shuffle (as for s0) mixes + // densities across partitions; the seed varies per level but stays reproducible. + Collections.shuffle(affectedBlocks, new Random(params.serial * 31L + scale)); + LOG.info("updatePyramid: re-downsampling {} block(s) for {}", affectedBlocks.size(), toDataset); + + final String n5Path = params.n5Path; + sparkContext.parallelize(affectedBlocks).foreachPartition( + gridPositions -> downsamplePartition(gridPositions, n5Path, backupPath, + fromDataset, toDataset, toAttributes, factors)); + + modifiedPrevious = affectedBlocks; } + } + + /** + * Re-downsamples one partition's worth of pyramid blocks, opening the N5 handles and the source level once + * for the whole partition rather than per block (the source open is a lazy {@code N5Utils.open}, so each block + * still reads only the source chunks it needs). Each block is rebuilt from the (already updated) previous level + * with the same per-block math as {@code N5DownsamplerSpark} so the result matches the rest of the pyramid, backing + * up the original block before overwriting it. + */ + static void downsamplePartition(final Iterator gridPositions, + final String n5Path, + final String backupPath, + final String fromDataset, + final String toDataset, + final DatasetAttributes toAttributes, + final int[] factors) { + + LogUtilities.setupExecutorLog4j("downsample"); + + final CellGrid cellGrid = new CellGrid(toAttributes.getDimensions(), toAttributes.getBlockSize()); + + try (final N5Reader reader = openN5Reader(n5Path); + final N5Writer writer = openN5Writer(n5Path); + final N5Writer backupWriter = openN5Writer(backupPath)) { + + final RandomAccessibleInterval source = N5Utils.open(reader, fromDataset); + + int count = 0; + while (gridPositions.hasNext()) { + downsampleBlock(source, reader, writer, backupWriter, cellGrid, + gridPositions.next(), toDataset, toAttributes, factors); + count++; + } + LOG.info("downsamplePartition: re-downsampled {} {} block(s)", count, toDataset); + } + } + + /** Re-downsamples a single block using the handles and source level already opened for the partition. */ + private static void downsampleBlock(final RandomAccessibleInterval source, + final N5Reader reader, + final N5Writer writer, + final N5Writer backupWriter, + final CellGrid cellGrid, + final long[] gridPosition, + final String toDataset, + final DatasetAttributes toAttributes, + final int[] factors) { + + final int n = toAttributes.getNumDimensions(); + final long[] targetMin = new long[n]; + final int[] cellDimensions = new int[n]; + cellGrid.getCellDimensions(gridPosition, targetMin, cellDimensions); + + final long[] sourceMin = new long[n]; + final long[] sourceSize = new long[n]; + final long[] targetSize = new long[n]; + for (int d = 0; d < n; d++) { + sourceMin[d] = targetMin[d] * factors[d]; + sourceSize[d] = (long) cellDimensions[d] * factors[d]; + targetSize[d] = cellDimensions[d]; + } + + final RandomAccessibleInterval sourceBlock = Views.offsetInterval(source, sourceMin, sourceSize); + final Img targetBlock = ArrayImgs.unsignedBytes(targetSize); + Downsample.downsample(sourceBlock, targetBlock, factors); + + // back up the original block (if present) before overwriting. + final DataBlock originalBlock = reader.readBlock(toDataset, toAttributes, gridPosition); + if (originalBlock != null) { + backupWriter.writeBlock(toDataset, toAttributes, originalBlock); + } + + // delete first so a block that became empty does not leave a stale remnant. + N5Utils.deleteBlock(targetBlock, writer, toDataset, gridPosition); + N5Utils.saveNonEmptyBlock(targetBlock, writer, toDataset, gridPosition, new UnsignedByteType()); + LOG.info("downsampleBlock: updated {} block {}", toDataset, Arrays.toString(gridPosition)); + } + + // ------------------------------------------------------------------------------------------------ + // Helpers + // ------------------------------------------------------------------------------------------------ - /** - * Determines if the pixel at the given position should be inpainted based on the local environment. - */ - private boolean shouldBeInpainted(final long[] position) { - final boolean hasContent = maskAccess.setPositionAndGet(position).get() > 0; - if (hasContent) { - return false; + /** Returns the slab-axis position whose id_serial label equals {@code serial}, or -1 if none matches. */ + static int findSlabPosition(final double[] idSerial, final long serial) { + for (int i = 0; i < idSerial.length; i++) { + if (Math.round(idSerial[i]) == serial) { + return i; } + } + return -1; + } + + /** Maps a grid block position at pyramid level k-1 to the block it feeds at level k (per-dimension p / factor). */ + static long[] affectedBlock(final long[] previousGridPosition, final int[] factors) { + final long[] g = new long[previousGridPosition.length]; + for (int d = 0; d < g.length; d++) { + g[d] = previousGridPosition[d] / factors[d]; + } + return g; + } - // If the pixel has no content, check the pixels in +/- y direction - // Only if both have content, the pixel should be inpainted (otherwise, it is a border pixel) - maskAccess.move(posStep, 1); - final boolean hasContentFront = maskAccess.get().get() > 0; - maskAccess.move(negStep, 1); - final boolean hasContentBack = maskAccess.get().get() > 0; - if (hasContentFront && hasContentBack) { - return true; + /** + * Reads the neuroglancer {@code translate} (the stack bounding-box min in world pixels) from the multiscale group, + * or returns null if it is absent. The render N5 export writes it on the group ({@code }), not on + * {@code s0} (s0 only carries a sub-pixel centering {@code transform}); callers fall back to the render stack bounds. + */ + private static double[] readGroupTranslate(final N5Reader n5, final String group) { + try { + final double[] translate = n5.getAttribute(group, "translate", double[].class); + if (translate != null && translate.length >= 2) { + return translate; } + } catch (final Exception e) { + LOG.warn("readGroupTranslate: could not read 'translate' from group {} ({})", group, e.getMessage()); + } + return null; + } + + /** Opens an N5 reader for a tissue/backup container (explicit N5 format; local path or gs://). */ + private static N5Reader openN5Reader(final String path) { + return new N5Factory().openReader(N5Factory.StorageFormat.N5, path); + } + + /** Opens an N5 writer for a tissue/backup container (explicit N5 format; local path or gs://). */ + private static N5Writer openN5Writer(final String path) { + return new N5Factory().openWriter(N5Factory.StorageFormat.N5, path); + } + + /** + * Reads the per-step pyramid downsampling factor from the multiscale group's neuroglancer {@code scales} attribute + * (as written by render's N5 export). {@code scales} is the cumulative factor per level relative to s0, e.g. + * {@code [[1,1,1],[2,2,1],[4,4,1],...]}, so {@code scales[1]} is the factor of s1 relative to s0 — and because these + * pyramids use a constant step at every level, it is the per-step factor for all levels. Fails fast when it is + * absent or has fewer than two levels, so the pyramid factor is never guessed. + */ + private static int[] readDownsamplingFactors(final N5Reader n5, final String group, final int numDimensions) { + final int[][] scales = n5.getAttribute(group, "scales", int[][].class); + if (scales == null || scales.length < 2) { + throw new IllegalArgumentException("group " + group + " has no 'scales' attribute with at least two levels " + + "to derive the pyramid downsampling factor from"); + } + final int[] factors = scales[1]; // s1 relative to s0 == the per-step factor (constant-step pyramids) + if (factors.length != numDimensions) { + throw new IllegalArgumentException("group " + group + " has scales[1] " + Arrays.toString(factors) + + " but the volume is " + numDimensions + "-dimensional"); + } + return factors; + } - // If that is inconclusive, check the pixels in +/- x direction - maskAccess.move(posStep, 1); - maskAccess.move(posStep, 0); - final boolean hasContentRight = maskAccess.get().get() > 0; - maskAccess.move(negStep, 0); - final boolean hasContentLeft = maskAccess.get().get() > 0; - return hasContentRight && hasContentLeft; + /** + * Reads the render service coordinates (baseDataUrl / owner / project / stack) straight from the multiscale group's + * {@code renderExport} metadata, written by render's N5 export (the same attributes.json that holds the pyramid + * {@code scales} and {@code translate}). Fails fast when it is absent so the render target is never guessed. + */ + private static RenderTarget readRenderTarget(final N5Reader n5, final String group) { + final RenderExport export = n5.getAttribute(group, "renderExport", RenderExport.class); + if (export == null || export.runParameters == null || export.runParameters.renderWeb == null || + export.runParameters.renderWeb.baseDataUrl == null || export.runParameters.stack == null) { + throw new IllegalArgumentException( + "N5 group " + group + " has no usable 'renderExport' metadata (need runParameters.renderWeb." + + "baseDataUrl/owner/project and runParameters.stack); this client reads the render service parameters from there"); } + final RenderExport.RenderWeb web = export.runParameters.renderWeb; + return new RenderTarget(web.baseDataUrl, web.owner, web.project, export.runParameters.stack); + } + /** Render service coordinates resolved from the group's {@code renderExport} metadata. */ + static class RenderTarget { + final String baseDataUrl; + final String owner; + final String project; + final String stack; + + RenderTarget(final String baseDataUrl, final String owner, final String project, final String stack) { + this.baseDataUrl = baseDataUrl; + this.owner = owner; + this.project = project; + this.stack = stack; + } } - private static class ExtendedAttributes implements Serializable { - public final DatasetAttributes attrs; - public final long[] min; + /** + * Minimal GSON view of the group's {@code renderExport} attribute (only the render coordinates this client needs; + * all other fields written by the export are ignored). + */ + private static class RenderExport { + RunParameters runParameters; + + private static class RunParameters { + RenderWeb renderWeb; + String stack; + } - public ExtendedAttributes(final DatasetAttributes attrs, final long[] min) { - this.attrs = attrs; - this.min = min; + private static class RenderWeb { + String baseDataUrl; + String owner; + String project; } + } - public static ExtendedAttributes read(final N5Reader n5, final String attrsPath, final String minPath) { - final DatasetAttributes attrs = n5.getDatasetAttributes(attrsPath); - final long[] min = n5.getAttribute(minPath, "translate", long[].class); - return new ExtendedAttributes(attrs, min); + /** Result of inpainting one block: the (block-sized) inpainted image and whether any pixel changed. */ + static class InpaintResult { + final Img inpainted; + final boolean changed; + final int minChangedLayer; // minimal z-layer with an inpainted (changed) pixel, or -1 when nothing changed + + InpaintResult(final Img inpainted, final boolean changed, final int minChangedLayer) { + this.inpainted = inpainted; + this.changed = changed; + this.minChangedLayer = minChangedLayer; + } + } + + /** Serializable 2-D point cloud of ROI reference points with a distance value per point. */ + static class PointCloud implements Serializable { + private final double[] xs; + private final double[] ys; + private final double[] dists; + + PointCloud(final double[] xs, final double[] ys, final double[] dists) { + this.xs = xs; + this.ys = ys; + this.dists = dists; + } + + int size() { + return xs.length; + } + + KNearestNeighborSearchOnKDTree buildSearch() { + final List points = new ArrayList<>(xs.length); + final List values = new ArrayList<>(xs.length); + for (int i = 0; i < xs.length; i++) { + points.add(new RealPoint(xs[i], ys[i])); + values.add(new DoubleType(dists[i])); + } + final KDTree tree = new KDTree<>(values, points); + return new KNearestNeighborSearchOnKDTree<>(tree, Math.min(Wafer6061Inpainter.IDW_K, xs.length)); + } + + /** Inverse-distance-weighted interpolation of the distance value at (x, y). */ + double interpolate(final KNearestNeighborSearchOnKDTree search, + final double x, + final double y) { + search.search(new RealPoint(x, y)); + final int numNeighbors = search.getK(); + double numerator = 0; + double denominator = 0; + for (int i = 0; i < numNeighbors; i++) { + final double r = search.getDistance(i); + final double v = search.getSampler(i).get().get(); + if (r == 0.0) { + return v; + } + final double w = 1.0 / Math.pow(r, Wafer6061Inpainter.IDW_POWER); + numerator += w * v; + denominator += w; + } + return numerator / denominator; } } }