diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0317ca9c5..03952eba0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,6 +2,8 @@ bioformats = "7.0.1" bioimageIoSpec = "0.1.0" +omeZarrReader = "0.4.1" +blosc = "1.21.5" commonmark = "0.21.0" commonsMath3 = "3.6.1" diff --git a/qupath-extension-bioformats/build.gradle b/qupath-extension-bioformats/build.gradle index 62fce96c9..0f9a032d4 100644 --- a/qupath-extension-bioformats/build.gradle +++ b/qupath-extension-bioformats/build.gradle @@ -34,6 +34,7 @@ dependencies { implementation libs.qupath.fxtras implementation libs.controlsfx implementation libs.picocli + implementation libs.jna // needed for OMEZarrReader (see https://github.com/bcdev/jzarr/issues/31) implementation "ome:formats-gpl:${bioformatsVersion}", { exclude group: 'xalan', module: 'serializer' @@ -45,6 +46,8 @@ dependencies { exclude group: 'com.google.code.findbugs', module: 'jsr305' exclude group: 'com.google.code.findbugs', module: 'annotations' } + implementation group: 'ome', name: 'OMEZarrReader', version: libs.versions.omeZarrReader.get() + implementation "io.github.qupath:blosc:${libs.versions.blosc.get()}:${nativesClassifier.replace("natives-", "")}" // testImplementation("ome:bioformats_package:${bioformatsVersion}") testImplementation "ome:bio-formats_plugins:${bioformatsVersion}" diff --git a/qupath-extension-bioformats/src/main/java/qupath/lib/images/servers/bioformats/BioFormatsImageServer.java b/qupath-extension-bioformats/src/main/java/qupath/lib/images/servers/bioformats/BioFormatsImageServer.java index a9b216ebf..66b64cf18 100644 --- a/qupath-extension-bioformats/src/main/java/qupath/lib/images/servers/bioformats/BioFormatsImageServer.java +++ b/qupath-extension-bioformats/src/main/java/qupath/lib/images/servers/bioformats/BioFormatsImageServer.java @@ -37,6 +37,7 @@ import loci.formats.gui.AWTImageTools; import loci.formats.in.DynamicMetadataOptions; import loci.formats.in.MetadataOptions; +import loci.formats.in.ZarrReader; import loci.formats.meta.DummyMetadata; import loci.formats.meta.MetadataStore; import loci.formats.ome.OMEPyramidStore; @@ -276,6 +277,12 @@ static BioFormatsImageServer checkSupport(URI uri, final BioFormatsServerOptions int width = 0, height = 0, nChannels = 1, nZSlices = 1, nTimepoints = 1, tileWidth = 0, tileHeight = 0; double pixelWidth = Double.NaN, pixelHeight = Double.NaN, zSpacing = Double.NaN, magnification = Double.NaN; TimeUnit timeUnit = null; + + // Zarr images can be opened by selecting the .zattrs or .zgroup file + // In that case, the parent directory contains the whole image + if (uri.toString().endsWith(".zattrs") || uri.toString().endsWith(".zgroup")) { + uri = new File(uri).getParentFile().toURI(); + } // See if there is a series name embedded in the path (temporarily the way things were done in v0.2.0-m1 and v0.2.0-m2) // Add it to the args if so @@ -1185,10 +1192,15 @@ private IFormatReader createReader(final BioFormatsServerOptions options, final } IFormatReader imageReader; - if (classList != null) { - imageReader = new ImageReader(classList); + if (new File(id).isDirectory()) { + // Using new ImageReader() on a directory won't work + imageReader = new ZarrReader(); } else { - imageReader = new ImageReader(getDefaultClassList()); + if (classList != null) { + imageReader = new ImageReader(classList); + } else { + imageReader = new ImageReader(getDefaultClassList()); + } } imageReader.setFlattenedResolutions(false); @@ -1373,7 +1385,24 @@ BufferedImage openImage(TileRequest tileRequest, int series, int nChannels, bool synchronized(ipReader) { ipReader.setSeries(series); - ipReader.setResolution(level); + + // Some files provide z scaling (the number of z stacks decreases when the resolution becomes + // lower, like the width and height), so z needs to be updated for levels > 0 + if (level > 0 && z > 0) { + ipReader.setResolution(0); + int zStacksFullResolution = ipReader.getSizeZ(); + ipReader.setResolution(level); + int zStacksCurrentResolution = ipReader.getSizeZ(); + + if (zStacksFullResolution != zStacksCurrentResolution) { + z = (int) (z * zStacksCurrentResolution / (float) zStacksFullResolution); + } + + + } else { + ipReader.setResolution(level); + } + order = ipReader.isLittleEndian() ? ByteOrder.LITTLE_ENDIAN : ByteOrder.BIG_ENDIAN; interleaved = ipReader.isInterleaved(); pixelType = ipReader.getPixelType(); diff --git a/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrAttributesCreator.java b/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrAttributesCreator.java new file mode 100644 index 000000000..3bdb92966 --- /dev/null +++ b/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrAttributesCreator.java @@ -0,0 +1,236 @@ +package qupath.lib.images.writers.ome.zarr; + +import qupath.lib.common.ColorTools; +import qupath.lib.images.servers.ImageChannel; +import qupath.lib.images.servers.PixelType; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.stream.IntStream; + +/** + * Create attributes of a OME-Zarr file as described by version 0.4 of the specifications of the + * Next-generation file formats (NGFF). + */ +class OMEZarrAttributesCreator { + + private static final String VERSION = "0.4"; + private final String imageName; + private final int numberOfZSlices; + private final int numberOfTimePoints; + private final int numberOfChannels; + private final boolean pixelSizeInMicrometer; + private final TimeUnit timeUnit; + private final double[] downsamples; + private final List channels; + private final boolean isRGB; + private final PixelType pixelType; + private enum Dimension { + X, + Y, + Z, + C, + T + } + + /** + * Create an instance of the attributes' creator. + * + * @param imageName the name of the image + * @param numberOfZSlices the number of z-stacks + * @param numberOfTimePoints the number of time points + * @param numberOfChannels the number of channels + * @param pixelSizeInMicrometer whether pixel sizes are in micrometer + * @param timeUnit the unit of the time dimension of the image + * @param downsamples the downsamples of the image + * @param channels the channels of the image + * @param isRGB whether the image stores pixel values with the RGB format + * @param pixelType the type of the pixel values of the image + */ + public OMEZarrAttributesCreator( + String imageName, + int numberOfZSlices, + int numberOfTimePoints, + int numberOfChannels, + boolean pixelSizeInMicrometer, + TimeUnit timeUnit, + double[] downsamples, + List channels, + boolean isRGB, + PixelType pixelType + ) { + this.imageName = imageName; + this.numberOfZSlices = numberOfZSlices; + this.numberOfTimePoints = numberOfTimePoints; + this.numberOfChannels = numberOfChannels; + this.pixelSizeInMicrometer = pixelSizeInMicrometer; + this.timeUnit = timeUnit; + this.downsamples = downsamples; + this.channels = channels; + this.isRGB = isRGB; + this.pixelType = pixelType; + } + + /** + * @return an unmodifiable map of attributes describing the zarr group that should + * be at the root of the image files + */ + public Map getGroupAttributes() { + return Map.of( + "multiscales", List.of(Map.of( + "axes", getAxes(), + "datasets", getDatasets(), + "name", imageName, + "version", VERSION + )), + "omero", Map.of( + "name", imageName, + "version", VERSION, + "channels", getChannels(), + "rdefs", Map.of( + "defaultT", 0, + "defaultZ", 0, + "model", "color" + ) + ) + ); + } + + /** + * @return an unmodifiable map of attributes describing a zarr array corresponding to + * a level of the image + */ + public Map getLevelAttributes() { + List arrayDimensions = new ArrayList<>(); + if (numberOfTimePoints > 1) { + arrayDimensions.add("t"); + } + if (numberOfChannels > 1) { + arrayDimensions.add("c"); + } + if (numberOfZSlices > 1) { + arrayDimensions.add("z"); + } + arrayDimensions.add("y"); + arrayDimensions.add("x"); + + return Map.of("_ARRAY_DIMENSIONS", arrayDimensions); + } + + private List> getAxes() { + List> axes = new ArrayList<>(); + + if (numberOfTimePoints > 1) { + axes.add(getAxis(Dimension.T)); + } + if (numberOfChannels > 1) { + axes.add(getAxis(Dimension.C)); + } + if (numberOfZSlices > 1) { + axes.add(getAxis(Dimension.Z)); + } + axes.add(getAxis(Dimension.Y)); + axes.add(getAxis(Dimension.X)); + + return axes; + } + + private List> getDatasets() { + return IntStream.range(0, downsamples.length) + .mapToObj(level -> Map.of( + "path", "s" + level, + "coordinateTransformations", List.of(getCoordinateTransformation((float) downsamples[level])) + )) + .toList(); + } + + private List> getChannels() { + Object maxValue = isRGB ? Integer.MAX_VALUE : switch (pixelType) { + case UINT8, INT8 -> Byte.MAX_VALUE; + case UINT16, INT16 -> Short.MAX_VALUE; + case UINT32, INT32 -> Integer.MAX_VALUE; + case FLOAT32 -> Float.MAX_VALUE; + case FLOAT64 -> Double.MAX_VALUE; + }; + + return channels.stream() + .map(channel -> Map.of( + "active", true, + "coefficient", 1d, + "color", String.format( + "%02X%02X%02X", + ColorTools.unpackRGB(channel.getColor())[0], + ColorTools.unpackRGB(channel.getColor())[1], + ColorTools.unpackRGB(channel.getColor())[2] + ), + "family", "linear", + "inverted", false, + "label", channel.getName(), + "window", Map.of( + "start", 0d, + "end", maxValue, + "min", 0d, + "max", maxValue + ) + )) + .toList(); + } + + private Map getAxis(Dimension dimension) { + Map axis = new HashMap<>(); + axis.put("name", switch (dimension) { + case X -> "x"; + case Y -> "y"; + case Z -> "z"; + case T -> "t"; + case C -> "c"; + }); + axis.put("type", switch (dimension) { + case X, Y, Z -> "space"; + case T -> "time"; + case C -> "channel"; + }); + + switch (dimension) { + case X, Y, Z -> { + if (pixelSizeInMicrometer) { + axis.put("unit", "micrometer"); + } + } + case T -> axis.put("unit", switch (timeUnit) { + case NANOSECONDS -> "nanosecond"; + case MICROSECONDS -> "microsecond"; + case MILLISECONDS -> "millisecond"; + case SECONDS -> "second"; + case MINUTES -> "minute"; + case HOURS -> "hour"; + case DAYS -> "day"; + }); + } + + return axis; + } + + private Map getCoordinateTransformation(float downsample) { + List scales = new ArrayList<>(); + if (numberOfTimePoints > 1) { + scales.add(1F); + } + if (numberOfChannels > 1) { + scales.add(1F); + } + if (numberOfZSlices > 1) { + scales.add(1F); + } + scales.add(downsample); + scales.add(downsample); + + return Map.of( + "type", "scale", + "scale", scales + ); + } +} diff --git a/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriter.java b/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriter.java new file mode 100644 index 000000000..7a8993af2 --- /dev/null +++ b/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriter.java @@ -0,0 +1,518 @@ +package qupath.lib.images.writers.ome.zarr; + +import com.bc.zarr.ArrayParams; +import com.bc.zarr.Compressor; +import com.bc.zarr.CompressorFactory; +import com.bc.zarr.DataType; +import com.bc.zarr.DimensionSeparator; +import com.bc.zarr.ZarrArray; +import com.bc.zarr.ZarrGroup; +import loci.formats.gui.AWTImageTools; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import qupath.lib.images.servers.ImageServer; +import qupath.lib.images.servers.ImageServers; +import qupath.lib.images.servers.PixelCalibration; +import qupath.lib.images.servers.TileRequest; +import qupath.lib.images.servers.TileRequestManager; + +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +/** + *

+ * Create an OME-Zarr file writer as described by version 0.4 of the specifications of the + * Next-generation file formats (NGFF). + *

+ *

+ * Use a {@link Builder} to create an instance of this class. + *

+ *

+ * This class is thread-safe but already uses concurrency internally to write tiles. + *

+ *

+ * This writer has to be {@link #close() closed} once no longer used. + *

+ */ +public class OMEZarrWriter implements AutoCloseable { + + private static final Logger logger = LoggerFactory.getLogger(OMEZarrWriter.class); + private final ImageServer server; + private final Map levelArrays; + private final ExecutorService executorService; + + private OMEZarrWriter(Builder builder) throws IOException { + server = ImageServers.pyramidalizeTiled( + builder.server, + getChunkSize( + builder.tileWidth > 0 ? builder.tileWidth : builder.server.getMetadata().getPreferredTileWidth(), + builder.maxNumberOfChunks, + builder.server.getWidth() + ), + getChunkSize( + builder.tileHeight > 0 ? builder.tileHeight : builder.server.getMetadata().getPreferredTileHeight(), + builder.maxNumberOfChunks, + builder.server.getHeight() + ), + builder.downsamples.length == 0 ? builder.server.getPreferredDownsamples() : builder.downsamples + ); + + OMEZarrAttributesCreator attributes = new OMEZarrAttributesCreator( + server.getMetadata().getName(), + server.nZSlices(), + server.nTimepoints(), + server.nChannels(), + server.getMetadata().getPixelCalibration().getPixelWidthUnit().equals(PixelCalibration.MICROMETER), + server.getMetadata().getTimeUnit(), + server.getPreferredDownsamples(), + server.getMetadata().getChannels(), + server.isRGB(), + server.getPixelType() + ); + levelArrays = createLevelArrays( + server, + ZarrGroup.create( + builder.path, + attributes.getGroupAttributes() + ), + attributes.getLevelAttributes(), + builder.compressor + ); + + executorService = Executors.newFixedThreadPool(builder.numberOfThreads); + } + + /** + * Close this writer. This will wait until all pending tiles + * are written. + * + * @throws InterruptedException when the waiting is interrupted + */ + @Override + public void close() throws InterruptedException { + executorService.shutdown(); + executorService.awaitTermination(Long.MAX_VALUE, TimeUnit.NANOSECONDS); + } + + /** + *

+ * Write the entire image in a background thread. + *

+ *

+ * The image will be written from an internal pool of thread, so this function may + * return before the image is actually written. + *

+ */ + public void writeImage() { + for (TileRequest tileRequest: server.getTileRequestManager().getAllTileRequests()) { + writeTile(tileRequest); + } + } + + /** + *

+ * Write the provided tile in a background thread. + *

+ *

+ * The tile will be written from an internal pool of thread, so this function may + * return before the tile is actually written. + *

+ *

+ * Note that the image server used internally by this writer may not be the one given in + * {@link Builder#Builder(ImageServer, String)}. Therefore, the {@link ImageServer#getTileRequestManager() TileRequestManager} + * of the internal image server may be different from the one of the provided image server, + * so functions like {@link TileRequestManager#getAllTileRequests()} may not return the expected tiles. + * Use the {@link ImageServer#getTileRequestManager() TileRequestManager} of {@link #getReaderServer()} + * to get accurate tiles. + *

+ * + * @param tileRequest the tile to write + */ + public void writeTile(TileRequest tileRequest) { + executorService.execute(() -> { + try { + levelArrays.get(tileRequest.getLevel()).write( + getData(server.readRegion(tileRequest.getRegionRequest())), + getDimensionsOfTile(tileRequest), + getOffsetsOfTile(tileRequest) + ); + } catch (Exception e) { + logger.error("Error when writing tile", e); + } + }); + } + + /** + * + *

+ * Get the image server used internally by this writer to read the tiles. It can be + * different from the one given in {@link Builder#Builder(ImageServer, String)}. + *

+ *

+ * This function can be useful to get information like the tiles used by this server + * (for example when using the {@link #writeTile(TileRequest)} function). + *

+ * + * @return the image server used internally by this writer to read the tiles + */ + public ImageServer getReaderServer() { + return server; + } + + /** + * Builder to create an instance of a {@link OMEZarrWriter}. + */ + public static class Builder { + + private static final String FILE_EXTENSION = ".ome.zarr"; + private final ImageServer server; + private final String path; + private Compressor compressor = CompressorFactory.createDefaultCompressor(); + private int numberOfThreads = 12; + private double[] downsamples = new double[0]; + private int maxNumberOfChunks = 50; + private int tileWidth = 512; + private int tileHeight = 512; + + /** + * Create the builder. + * + * @param server the image to write + * @param path the path where to write the image. It must end with ".ome.zarr" + * @throws IllegalArgumentException when the provided path doesn't end with ".ome.zarr" + */ + public Builder(ImageServer server, String path) { + if (!path.endsWith(FILE_EXTENSION)) { + throw new IllegalArgumentException(String.format("The provided path (%s) does not have the OME-Zarr extension (%s)", path, FILE_EXTENSION)); + } + + this.server = server; + this.path = path; + } + + /** + * Set the compressor to use when writing tiles. By default, the blocs compression is used. + * + * @param compressor the compressor to use when writing tiles + * @return this builder + */ + public Builder setCompressor(Compressor compressor) { + this.compressor = compressor; + return this; + } + + /** + * Tiles will be written from a pool of thread. This function + * specifies the number of threads to use. By default, 12 threads are + * used. + * + * @param numberOfThreads the number of threads to use when writing tiles + * @return this builder + */ + public Builder setNumberOfThreads(int numberOfThreads) { + this.numberOfThreads = numberOfThreads; + return this; + } + + /** + *

+ * Enable the creation of a pyramidal image with the provided downsamples. The levels corresponding + * to the provided downsamples will be automatically generated. + *

+ *

+ * If this function is not called (or if it is called with no parameters), the downsamples of + * the provided image server will be used instead. + *

+ * + * @param downsamples the downsamples of the pyramid to generate + * @return this builder + */ + public Builder setDownsamples(double... downsamples) { + this.downsamples = downsamples; + return this; + } + + /** + *

+ * In Zarr files, data is stored in chunks. This parameter defines the maximum number + * of chunks on the x,y, and z dimensions. By default, this value is set to 50. + *

+ *

+ * Use a negative value to not define any maximum number of chunks. + *

+ * + * @param maxNumberOfChunks the maximum number of chunks on the x,y, and z dimensions + * @return this builder + */ + public Builder setMaxNumberOfChunksOnEachSpatialDimension(int maxNumberOfChunks) { + this.maxNumberOfChunks = maxNumberOfChunks; + return this; + } + + /** + *

+ * In Zarr files, data is stored in chunks. This parameter defines the size + * of chunks on the x dimension. By default, this value is set to 512. + *

+ *

+ * Use a negative value to use the tile width of the provided image server. + *

+ *

+ * The provided tile width may not be used if this implies creating more chunks + * than the value given in {@link #setMaxNumberOfChunksOnEachSpatialDimension(int)}. + *

+ * + * @param tileWidth the width each chunk should have + * @return this builder + */ + public Builder setTileWidth(int tileWidth) { + this.tileWidth = tileWidth; + return this; + } + + /** + *

+ * In Zarr files, data is stored in chunks. This parameter defines the size + * of chunks on the y dimension. By default, this value is set to 512. + *

+ *

+ * Use a negative value to use the tile height of the provided image server. + *

+ *

+ * The provided tile height may not be used if this implies creating more chunks + * than the value given in {@link #setMaxNumberOfChunksOnEachSpatialDimension(int)}. + *

+ * + * @param tileHeight the height each chunk should have + * @return this builder + */ + public Builder setTileHeight(int tileHeight) { + this.tileHeight = tileHeight; + return this; + } + + /** + * Create a new instance of {@link OMEZarrWriter}. This will also + * create an empty image on the provided path. + * + * @return the new {@link OMEZarrWriter} + * @throws IOException when the empty image cannot be created. This can happen + * if the provided path is incorrect or if the user doesn't have enough permissions + */ + public OMEZarrWriter build() throws IOException { + return new OMEZarrWriter(this); + } + } + + private static int getChunkSize(int tileSize, int maxNumberOfChunks, int imageSize) { + return maxNumberOfChunks > 0 ? + Math.max(tileSize, imageSize / maxNumberOfChunks) : + tileSize; + } + + private static Map createLevelArrays( + ImageServer server, + ZarrGroup root, + Map levelAttributes, + Compressor compressor + ) throws IOException { + Map levelArrays = new HashMap<>(); + + for (int level=0; level DataType.u1; + case INT8 -> DataType.i1; + case UINT16 -> DataType.u2; + case INT16 -> DataType.i2; + case UINT32 -> DataType.u4; + case INT32 -> DataType.i4; + case FLOAT32 -> DataType.f4; + case FLOAT64 -> DataType.f8; + }) + .dimensionSeparator(DimensionSeparator.SLASH), + levelAttributes + )); + } + + return levelArrays; + } + + private static int[] getDimensionsOfImage(ImageServer server, int level) { + List dimensions = new ArrayList<>(); + if (server.nTimepoints() > 1) { + dimensions.add(server.nTimepoints()); + } + if (server.nChannels() > 1) { + dimensions.add(server.nChannels()); + } + if (server.nZSlices() > 1) { + dimensions.add(server.nZSlices()); + } + dimensions.add((int) (server.getHeight() / server.getDownsampleForResolution(level))); + dimensions.add((int) (server.getWidth() / server.getDownsampleForResolution(level))); + + return dimensions.stream().mapToInt(i -> i).toArray(); + } + + private static int[] getChunksOfImage(ImageServer server) { + List chunks = new ArrayList<>(); + if (server.nTimepoints() > 1) { + chunks.add(1); + } + if (server.nChannels() > 1) { + chunks.add(1); + } + if (server.nZSlices() > 1) { + chunks.add(Math.max(server.getMetadata().getPreferredTileWidth(), server.getMetadata().getPreferredTileHeight())); + } + chunks.add(server.getMetadata().getPreferredTileHeight()); + chunks.add(server.getMetadata().getPreferredTileWidth()); + + return chunks.stream().mapToInt(i -> i).toArray(); + } + + private Object getData(BufferedImage image) { + Object pixels = AWTImageTools.getPixels(image); + + if (server.isRGB()) { + int[][] data = (int[][]) pixels; + + int[] output = new int[server.nChannels() * image.getWidth() * image.getHeight()]; + int i = 0; + for (int c=0; c { + byte[][] data = (byte[][]) pixels; + + byte[] output = new byte[server.nChannels() * image.getWidth() * image.getHeight()]; + int i = 0; + for (int c=0; c { + short[][] data = (short[][]) pixels; + + short[] output = new short[server.nChannels() * image.getWidth() * image.getHeight()]; + int i = 0; + for (int c=0; c { + int[][] data = (int[][]) pixels; + + int[] output = new int[server.nChannels() * image.getWidth() * image.getHeight()]; + int i = 0; + for (int c=0; c { + float[][] data = (float[][]) pixels; + + float[] output = new float[server.nChannels() * image.getWidth() * image.getHeight()]; + int i = 0; + for (int c=0; c { + double[][] data = (double[][]) pixels; + + double[] output = new double[server.nChannels() * image.getWidth() * image.getHeight()]; + int i = 0; + for (int c=0; c dimensions = new ArrayList<>(); + if (server.nTimepoints() > 1) { + dimensions.add(1); + } + if (server.nChannels() > 1) { + dimensions.add(server.nChannels()); + } + if (server.nZSlices() > 1) { + dimensions.add(1); + } + dimensions.add(tileRequest.getTileHeight()); + dimensions.add(tileRequest.getTileWidth()); + + return dimensions.stream().mapToInt(i -> i).toArray(); + } + + private int[] getOffsetsOfTile(TileRequest tileRequest) { + List offset = new ArrayList<>(); + if (server.nTimepoints() > 1) { + offset.add(tileRequest.getT()); + } + if (server.nChannels() > 1) { + offset.add(0); + } + if (server.nZSlices() > 1) { + offset.add(tileRequest.getZ()); + } + offset.add(tileRequest.getTileY()); + offset.add(tileRequest.getTileX()); + + return offset.stream().mapToInt(i -> i).toArray(); + } +}