diff --git a/src/main/java/com/glencoesoftware/bioformats2raw/Axis.java b/src/main/java/com/glencoesoftware/bioformats2raw/Axis.java index 30941956..ac1a1e38 100644 --- a/src/main/java/com/glencoesoftware/bioformats2raw/Axis.java +++ b/src/main/java/com/glencoesoftware/bioformats2raw/Axis.java @@ -12,9 +12,10 @@ */ public class Axis { - private char type; + private String type; private int length; private int chunkSize; + private String dimensionType; /** * Create a new Axis. @@ -22,17 +23,31 @@ public class Axis { * @param t axis type (e.g. 'X') * @param len axis length * @param chunk chunk length (expected to be in range [1, len]) + * @param dimType Zarr dimension type e.g. 'space' */ - public Axis(char t, int len, int chunk) { + public Axis(char t, int len, int chunk, String dimType) { + this(String.valueOf(t), len, chunk, dimType); + } + + /** + * Create a new Axis. + * + * @param t axis type (e.g. 'X') + * @param len axis length + * @param chunk chunk length (expected to be in range [1, len]) + * @param dimType Zarr dimension type e.g. 'space' + */ + public Axis(String t, int len, int chunk, String dimType) { type = t; length = len; chunkSize = chunk; + dimensionType = dimType; } /** * @return axis type (e.g. 'X') */ - public char getType() { + public String getType() { return type; } @@ -50,4 +65,11 @@ public int getChunkSize() { return chunkSize; } + /** + * @return dimension type e.g. 'space' + */ + public String getDimensionType() { + return dimensionType; + } + } diff --git a/src/main/java/com/glencoesoftware/bioformats2raw/Converter.java b/src/main/java/com/glencoesoftware/bioformats2raw/Converter.java index d506ee1f..17bc7214 100644 --- a/src/main/java/com/glencoesoftware/bioformats2raw/Converter.java +++ b/src/main/java/com/glencoesoftware/bioformats2raw/Converter.java @@ -51,6 +51,7 @@ import loci.formats.Memoizer; import loci.formats.MinMaxCalculator; import loci.formats.MissingLibraryException; +import loci.formats.Modulo; import loci.formats.in.DynamicMetadataOptions; import loci.formats.meta.IMetadata; import loci.formats.ome.OMEXMLMetadata; @@ -1250,7 +1251,7 @@ public SupportedVersions getNGFFVersion() { * @return true if Zarr v3 data should be written */ public boolean getV3() { - return getNGFFVersion() == SupportedVersions.NGFF_05; + return getNGFFVersion().getZarrVersion() == 3; } /** @@ -2110,17 +2111,17 @@ private byte[] getTileDownsampled( int activeTileWidth = 1; int activeTileHeight = 1; for (int i=0; i getDimensions( dimensionOrder != null? dimensionOrder.toString() : reader.getDimensionOrder()).reverse().toString(); + Modulo mz = null; + Modulo mc = null; + Modulo mt = null; + if (getNGFFVersion().supportsExtraDimensions()) { + mz = reader.getModuloZ(); + mc = reader.getModuloC(); + mt = reader.getModuloT(); + } + int spatialDims = 0; for (char c : o.toCharArray()) { switch (c) { case 'X': - axes.add(new Axis(c, scaledWidth, scaledTileWidth)); + axes.add(new Axis(c, scaledWidth, scaledTileWidth, "space")); spatialDims++; break; case 'Y': - axes.add(new Axis(c, scaledHeight, scaledTileHeight)); + axes.add(new Axis(c, scaledHeight, scaledTileHeight, "space")); spatialDims++; break; case 'Z': - axes.add(new Axis(c, scaledDepth, scaledChunkDepth)); + if (mz != null && mz.length() > 1) { + Axis actualZ = + new Axis(c, scaledDepth / mz.length(), scaledChunkDepth, "space"); + Axis moduloZ = new Axis(mz.type, mz.length(), 1, "space"); + if (Math.abs(mz.step - 1) > Constants.EPSILON) { + axes.add(moduloZ); + axes.add(actualZ); + } + else { + axes.add(actualZ); + axes.add(moduloZ); + } + } + else { + axes.add(new Axis(c, scaledDepth, scaledChunkDepth, "space")); + } spatialDims++; break; case 'C': - axes.add(new Axis(c, sizeC, 1)); + if (mc != null && mc.length() > 1) { + if (Math.abs(mc.step - 1) > Constants.EPSILON) { + axes.add(new Axis(mc.type, mc.length(), 1, "channel")); + axes.add(new Axis(c, sizeC / mc.length(), 1, "channel")); + } + else { + axes.add(new Axis(c, sizeC / mc.length(), 1, "channel")); + axes.add(new Axis(mc.type, mc.length(), 1, "channel")); + } + } + else { + axes.add(new Axis(c, sizeC, 1, "channel")); + } break; case 'T': - axes.add(new Axis(c, sizeT, 1)); + if (mt != null && mt.length() > 1) { + if (Math.abs(mt.step - 1) > Constants.EPSILON) { + axes.add(new Axis(mt.type, mt.length(), 1, "time")); + axes.add(new Axis(c, sizeT / mt.length(), 1, "time")); + } + else { + axes.add(new Axis(c, sizeT / mt.length(), 1, "time")); + axes.add(new Axis(mt.type, mt.length(), 1, "time")); + } + } + else { + axes.add(new Axis(c, sizeT, 1, "time")); + } break; default: LOGGER.trace("ignoring axis type {}", c); @@ -2241,8 +2290,8 @@ private List getDimensions( for (int a=0; a axes) { int[] shard = new int[axes.size()]; for (int i=0; i axes, int width, int height, int depth) { int[] shape = new int[axes.size()]; Arrays.fill(shape, 1); for (int i=0; i 1) { + count++; + } + if (size / m.length() > 1) { + count++; + } + return new int[count]; + } + return new int[m.length() > 1 ? 2 : 1]; + } + /** * Retrieve the offset based on either the configured or input file * dimension order at the current resolution. @@ -2334,31 +2407,80 @@ private int[] getOffset( int[] zct = reader.getZCTCoords(plane); int[] offset = new int[axes.size()]; Arrays.fill(offset, 0); + + boolean useModulo = getNGFFVersion().supportsExtraDimensions(); + Modulo mz = useModulo ? reader.getModuloZ() : null; + int[] zLengths = getModuloLengths(mz, reader.getSizeZ()); + int zLengthIndex = zLengths.length - 1; + Modulo mc = useModulo ? reader.getModuloC() : null; + int[] cLengths = getModuloLengths(mc, reader.getSizeC()); + int cLengthIndex = cLengths.length - 1; + Modulo mt = useModulo ? reader.getModuloT() : null; + int[] tLengths = getModuloLengths(mt, reader.getSizeT()); + int tLengthIndex = tLengths.length - 1; + for (int i=0; i axes) throws EnumerationException, FormatException, IOException, @@ -2383,21 +2505,21 @@ private void processChunk(int series, int resolution, int plane, //Get coords of current series zct = reader.getZCTCoords(plane); for (int i=0; i 1) { + planeIndex = plane; + } + } } finally { readers.put(reader); @@ -3014,9 +3142,9 @@ private void setSeriesLevelMetadata(int series, int resolutions) scale.put("type", "scale"); List axisValues = new ArrayList(); for (int i=0; i> axes = new ArrayList>(); for (int i=0; i thisAxis = new HashMap(); thisAxis.put("name", axis); - thisAxis.put("type", type); + if (type != null) { + thisAxis.put("type", type); + } if (scale != null) { String symbol = scale.unit().getSymbol(); String unitName = null; @@ -3221,7 +3345,7 @@ else if (scale instanceof Time) { LOGGER.debug(" finished writing subgroup attributes"); } - private Quantity getScale(IMetadata meta, int series, char axisChar) { + private Quantity getScale(IMetadata meta, int series, String axisType) { if (meta == null) { return null; } @@ -3231,24 +3355,25 @@ private Quantity getScale(IMetadata meta, int series, char axisChar) { return null; } - switch (axisChar) { - case 'x': - return meta.getPixelsPhysicalSizeX(seriesIndex); - case 'y': - return meta.getPixelsPhysicalSizeY(seriesIndex); - case 'z': - return meta.getPixelsPhysicalSizeZ(seriesIndex); - case 't': - Quantity timeIncrement = meta.getPixelsTimeIncrement(seriesIndex); - if (timeIncrement != null && timeIncrement.value().doubleValue() > 0) { - return timeIncrement; - } - else { - return null; - } - default: + if (axisType.equalsIgnoreCase("x")) { + return meta.getPixelsPhysicalSizeX(seriesIndex); + } + else if (axisType.equalsIgnoreCase("y")) { + return meta.getPixelsPhysicalSizeY(seriesIndex); + } + else if (axisType.equalsIgnoreCase("z")) { + return meta.getPixelsPhysicalSizeZ(seriesIndex); + } + else if (axisType.equalsIgnoreCase("t")) { + Quantity timeIncrement = meta.getPixelsTimeIncrement(seriesIndex); + if (timeIncrement != null && timeIncrement.value().doubleValue() > 0) { + return timeIncrement; + } + else { return null; + } } + return null; } /** diff --git a/src/main/java/com/glencoesoftware/bioformats2raw/SupportedVersions.java b/src/main/java/com/glencoesoftware/bioformats2raw/SupportedVersions.java index 89dc513a..717cf962 100644 --- a/src/main/java/com/glencoesoftware/bioformats2raw/SupportedVersions.java +++ b/src/main/java/com/glencoesoftware/bioformats2raw/SupportedVersions.java @@ -7,15 +7,44 @@ */ package com.glencoesoftware.bioformats2raw; +import java.util.Arrays; +import java.util.List; + public enum SupportedVersions { - NGFF_01("0.1"), - NGFF_04("0.4"), - NGFF_05("0.5"); + NGFF_01("0.1", 2, null), + NGFF_04("0.4", 2, null), + NGFF_05("0.5", 3, new Integer[] {2}), + NGFF_DEV("0.9.dev1", 3, new Integer[] {2, 3}); private final String value; + private final int zarrVersion; + private final List supportedRFCs; - private SupportedVersions(final String value) { + private SupportedVersions( + final String value, int zarrVersion, Integer[] rfcs) + { this.value = value; + this.zarrVersion = zarrVersion; + if (rfcs != null) { + this.supportedRFCs = Arrays.asList(rfcs); + } + else { + this.supportedRFCs = null; + } + } + + /** + * @return the version of the Zarr format used by this OME-Zarr version + */ + public int getZarrVersion() { + return zarrVersion; + } + + /** + * @return true if extra dimensions (RFC-3) are supported by this version + */ + public boolean supportsExtraDimensions() { + return supportedRFCs != null && supportedRFCs.contains(3); } @Override diff --git a/src/test/java/com/glencoesoftware/bioformats2raw/test/AbstractZarrTest.java b/src/test/java/com/glencoesoftware/bioformats2raw/test/AbstractZarrTest.java index 1b82c998..862a9374 100644 --- a/src/test/java/com/glencoesoftware/bioformats2raw/test/AbstractZarrTest.java +++ b/src/test/java/com/glencoesoftware/bioformats2raw/test/AbstractZarrTest.java @@ -18,13 +18,17 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.stream.Stream; import loci.common.LogbackTools; import loci.common.services.ServiceFactory; import loci.formats.FormatTools; import loci.formats.in.FakeReader; +import loci.formats.in.OMETiffReader; +import loci.formats.ome.OMEXMLMetadata; import loci.formats.services.OMEXMLService; import ome.xml.model.OME; +import ome.xml.model.Pixels; import com.glencoesoftware.bioformats2raw.Converter; import dev.zarr.zarrjava.ZarrException; @@ -34,6 +38,9 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -162,6 +169,66 @@ static Path fake(Map options, } } + /** + * Test modulo dimension handling. + * As written, this tests correct behavior for OME-Zarr 0.5 and earlier, + * which is to ignore the modulo dimensions in favor of the parent dimension. + * + * @param moduloFile OME-TIFF file with modulo dimension(s) + * @param compact true if compact dimensions should be used + */ + @ParameterizedTest + @MethodSource("getModuloFiles") + public void testModulo(String moduloFile, boolean compact) throws Exception { + input = getTestFile(moduloFile); + // intentionally skip compact tests by default, since there are many other + // compact tests that cover the case where modulo dimensions are not used + if (compact) { + return; + } + assertTool(); + + OME ome = getOMEMetadata(); + Pixels pix = ome.getImage(0).getPixels(); + int x = pix.getSizeX().getValue().intValue(); + int y = pix.getSizeY().getValue().intValue(); + int z = pix.getSizeZ().getValue().intValue(); + int c = pix.getSizeC().getValue().intValue(); + int t = pix.getSizeT().getValue().intValue(); + + Array series0 = Array.open(store.resolve("0", "0")); + assertArrayEquals(new long[] {t, c, z, y, x}, series0.metadata().shape); + assertArrayEquals( + new int[] {1, 1, 1, y, x}, series0.metadata().chunkShape()); + + try (OMETiffReader r = new OMETiffReader()) { + r.setId(input.toString()); + for (int p=0; p getModuloFiles() { + return Stream.of( + Arguments.of("mini-flim-moduloC.ome.tiff", false), + Arguments.of("mini-flim-moduloC.ome.tiff", true), + Arguments.of("mini-flim-moduloT.ome.tiff", false), + Arguments.of("mini-flim-moduloT.ome.tiff", true), + Arguments.of("mini-spim-moduloZ.ome.tiff", false), + Arguments.of("mini-spim-moduloZ.ome.tiff", true) + ); + } + void checkAxes(List> axes, String order, String[] units) { @@ -197,6 +264,15 @@ OME getOMEMetadata() throws Exception { return (OME) xmlService.createOMEXMLRoot(omexml); } + OMEXMLMetadata getOMEMetadataStore() throws Exception { + Path xml = output.resolve("OME").resolve("METADATA.ome.xml"); + String omexml = new String(Files.readAllBytes(xml), StandardCharsets.UTF_8); + ServiceFactory sf = new ServiceFactory(); + OMEXMLService xmlService = sf.getInstance(OMEXMLService.class); + assertTrue(xmlService.validateOMEXML(omexml)); + return xmlService.createOMEXMLMetadata(omexml); + } + void checkPlateSeriesMetadata(List groupMap, int rowCount, int colCount, int fieldCount) { @@ -329,4 +405,5 @@ public void checkSpecialPixels(int s, int sizeZ, int sizeC, int sizeT, abstract void checkMultiscale(Map multiscale, String name); abstract String getNGFFVersion(); + } diff --git a/src/test/java/com/glencoesoftware/bioformats2raw/test/OMEZarr1Test.java b/src/test/java/com/glencoesoftware/bioformats2raw/test/OMEZarr1Test.java new file mode 100644 index 00000000..d459aa5c --- /dev/null +++ b/src/test/java/com/glencoesoftware/bioformats2raw/test/OMEZarr1Test.java @@ -0,0 +1,154 @@ +/** + * Copyright (c) 2025 Glencoe Software, Inc. All rights reserved. + * + * This software is distributed under the terms described by the LICENSE.txt + * file you can find at the root of the distribution bundle. If the file is + * missing please request a copy by contacting info@glencoesoftware.com + */ +package com.glencoesoftware.bioformats2raw.test; + +import java.nio.ByteBuffer; +import java.util.Arrays; + +import dev.zarr.zarrjava.utils.Utils; +import dev.zarr.zarrjava.v3.Array; + +import loci.common.Constants; +import loci.common.services.ServiceFactory; +import loci.formats.FormatTools; +import loci.formats.Modulo; +import loci.formats.in.OMETiffReader; +import loci.formats.ome.OMEXMLMetadata; +import loci.formats.services.OMEXMLService; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; + +public class OMEZarr1Test extends ZarrV3Test { + + @Override + String getNGFFVersion() { + return "0.9.dev1"; + } + + /** + * Test modulo dimension handling. + * Since this is OME-Zarr 0.9.dev1 which includes RFC-3, modulo dimensions + * should be reported as their own axis. + * + * @param moduloFile OME-TIFF file with modulo dimension(s) + * @param compact true if compact dimensions should be written + */ + @ParameterizedTest + @MethodSource("getModuloFiles") + public void testModulo(String moduloFile, boolean compact) throws Exception { + input = getTestFile(moduloFile); + if (compact) { + assertTool("--ngff-version", getNGFFVersion(), "--compact"); + } + else { + assertTool("--ngff-version", getNGFFVersion()); + } + + OMEXMLMetadata meta = getOMEMetadataStore(); + OMEXMLService service = + new ServiceFactory().getInstance(OMEXMLService.class); + + int x = meta.getPixelsSizeX(0).getValue().intValue(); + int y = meta.getPixelsSizeY(0).getValue().intValue(); + int z = meta.getPixelsSizeZ(0).getValue().intValue(); + int c = meta.getPixelsSizeC(0).getValue().intValue(); + int t = meta.getPixelsSizeT(0).getValue().intValue(); + Modulo mz = service.getModuloAlongZ(meta, 0); + Modulo mc = service.getModuloAlongC(meta, 0); + Modulo mt = service.getModuloAlongT(meta, 0); + + int[] zAxes = getAxes(z, mz, compact); + int[] cAxes = getAxes(c, mc, compact); + int[] tAxes = getAxes(t, mt, compact); + int dims = zAxes.length + cAxes.length + tAxes.length + 2; + int[] chunkShape = new int[dims]; + Arrays.fill(chunkShape, 1); + chunkShape[dims - 2] = y; + chunkShape[dims - 1] = x; + + long[] shape = new long[dims]; + System.arraycopy(Utils.toLongArray(tAxes), 0, shape, 0, tAxes.length); + System.arraycopy(Utils.toLongArray(cAxes), 0, + shape, tAxes.length, cAxes.length); + System.arraycopy(Utils.toLongArray(zAxes), 0, + shape, tAxes.length + cAxes.length, zAxes.length); + shape[dims - 2] = y; + shape[dims - 1] = x; + + Array series0 = Array.open(store.resolve("0", "0")); + assertArrayEquals(shape, series0.metadata().shape); + assertArrayEquals(chunkShape, series0.metadata().chunkShape()); + + try (OMETiffReader r = new OMETiffReader()) { + r.setId(input.toString()); + for (int p=0; p> multiscales = (List>) omeAttrs.get("multiscales"); diff --git a/src/test/resources/com/glencoesoftware/bioformats2raw/test/mini-flim-moduloC.ome.tiff b/src/test/resources/com/glencoesoftware/bioformats2raw/test/mini-flim-moduloC.ome.tiff new file mode 100755 index 00000000..c15135e8 Binary files /dev/null and b/src/test/resources/com/glencoesoftware/bioformats2raw/test/mini-flim-moduloC.ome.tiff differ diff --git a/src/test/resources/com/glencoesoftware/bioformats2raw/test/mini-flim-moduloT.ome.tiff b/src/test/resources/com/glencoesoftware/bioformats2raw/test/mini-flim-moduloT.ome.tiff new file mode 100755 index 00000000..f62177a3 Binary files /dev/null and b/src/test/resources/com/glencoesoftware/bioformats2raw/test/mini-flim-moduloT.ome.tiff differ diff --git a/src/test/resources/com/glencoesoftware/bioformats2raw/test/mini-spim-moduloZ.ome.tiff b/src/test/resources/com/glencoesoftware/bioformats2raw/test/mini-spim-moduloZ.ome.tiff new file mode 100755 index 00000000..7d7edd9d Binary files /dev/null and b/src/test/resources/com/glencoesoftware/bioformats2raw/test/mini-spim-moduloZ.ome.tiff differ