Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Zarr reading and writing support #1474

Merged
merged 21 commits into from
Apr 30, 2024
Merged
Show file tree
Hide file tree
Changes from 20 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 2 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

bioformats = "7.0.1"
bioimageIoSpec = "0.1.0"
omeZarrReader = "0.4.1-SNAPSHOT"
Rylern marked this conversation as resolved.
Show resolved Hide resolved
blosc = "1.21.5"

commonmark = "0.21.0"
commonsMath3 = "3.6.1"
Expand Down
3 changes: 3 additions & 0 deletions qupath-extension-bioformats/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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}"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be round or floor? My intuition says round but my intuition isn't certain...

Also, it would be useful to look at the number of z slices for different levels in the sample images you have access to. It might be possible to compute the ratio between the z slices for a level and the zero level, to determine how much to rescale the z index (which might be more robust than using the downsample value, in case it's possible that the downsampling in z uses a different factor).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried to use round but I got an error when z / tileRequest.getDownsample() was giving something like 133.5, which turned into 134 after applying round, when the max possible value was 133.

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();
Expand Down
Original file line number Diff line number Diff line change
@@ -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
* <a href="https://ngff.openmicroscopy.org/0.4/index.html">Next-generation file formats (NGFF)</a>.
*/
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<ImageChannel> 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<ImageChannel> 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<String, Object> 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<String, Object> getLevelAttributes() {
List<String> 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<Map<String, Object>> getAxes() {
List<Map<String, Object>> axes = new ArrayList<>();

if (numberOfTimePoints > 1) {
axes.add(getAxe(Dimension.T));
}
if (numberOfChannels > 1) {
axes.add(getAxe(Dimension.C));
}
if (numberOfZSlices > 1) {
axes.add(getAxe(Dimension.Z));
}
axes.add(getAxe(Dimension.Y));
axes.add(getAxe(Dimension.X));

return axes;
}

private List<Map<String, Object>> getDatasets() {
return IntStream.range(0, downSamples.length)
.mapToObj(level -> Map.of(
"path", "s" + level,
"coordinateTransformations", List.of(getCoordinateTransformation((float) downSamples[level]))
))
.toList();
}

private List<Map<String, Object>> 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<String, Object> getAxe(Dimension dimension) {
Rylern marked this conversation as resolved.
Show resolved Hide resolved
Map<String, Object> axes = new HashMap<>();
axes.put("name", switch (dimension) {
case X -> "x";
case Y -> "y";
case Z -> "z";
case T -> "t";
case C -> "c";
});
axes.put("type", switch (dimension) {
case X, Y, Z -> "space";
case T -> "time";
case C -> "channel";
});

switch (dimension) {
case X, Y, Z -> {
if (pixelSizeInMicrometer) {
axes.put("unit", "micrometer");
}
}
case T -> axes.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 axes;
}

private Map<String, Object> getCoordinateTransformation(float downSample) {
Rylern marked this conversation as resolved.
Show resolved Hide resolved
List<Float> 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
);
}
}