diff --git a/README.md b/README.md index 9fb2cc9..eec75b3 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,19 @@ In addition to your usual OMERO.server configuration, the microservice's : zlib compression level for chunks, default 6 `omero.ms.zarr.folder.layout` -: for directory listings, default `nested` chunks, can be `flattened`; `none` disables directory listings +: for directory listings, default `flattened` chunks, can be `nested`; `none` disables directory listings + +`omero.ms.zarr.mask.split.enable` +: if masks split by ROI should be offered; default is `false`, can be set to `true` + +`omero.ms.zarr.mask.overlap.color` +: color to use for overlapping region in labeled masks, as a signed integer as in the OME Model; not set by default + +`omero.ms.zarr.mask.overlap.value` +: value to set for overlapping region in labeled masks, as a signed integer or "LOWEST" or "HIGHEST" (the default); setting to null disables overlap support + +`omero.ms.zarr.mask-cache.size` +: mask cache size in megabytes, default 250 `omero.ms.zarr.net.path.image` : URI template path for getting image data, default `/image/{image}.zarr/` where `{image}` signifies the image ID and is mandatory diff --git a/src/main/java/org/openmicroscopy/ms/zarr/Configuration.java b/src/main/java/org/openmicroscopy/ms/zarr/Configuration.java index 65ab5f7..62d2eae 100644 --- a/src/main/java/org/openmicroscopy/ms/zarr/Configuration.java +++ b/src/main/java/org/openmicroscopy/ms/zarr/Configuration.java @@ -52,17 +52,25 @@ public class Configuration { public final static String CONF_CHUNK_SIZE_MIN = "chunk.size.min"; public final static String CONF_COMPRESS_ZLIB_LEVEL = "compress.zlib.level"; public final static String CONF_FOLDER_LAYOUT = "folder.layout"; + public final static String CONF_MASK_CACHE_SIZE = "mask-cache.size"; + public final static String CONF_MASK_SPLIT_ENABLE = "mask.split.enable"; + public final static String CONF_MASK_OVERLAP_COLOR = "mask.overlap.color"; + public final static String CONF_MASK_OVERLAP_VALUE = "mask.overlap.value"; public final static String CONF_NET_PATH_IMAGE = "net.path.image"; public final static String CONF_NET_PORT = "net.port"; /* Configuration initialized to default values. */ - private int cacheSize = 16; + private int bufferCacheSize = 16; + private long maskCacheSize = 250 * 1000000L; private List chunkSizeAdjust = ImmutableList.of('X', 'Y', 'Z'); private int chunkSizeMin = 0x100000; private int zlibLevel = 6; - private Boolean foldersNested = true; + private Boolean foldersNested = false; private String netPath = getRegexForNetPath("/image/" + PLACEHOLDER_IMAGE_ID + ".zarr/"); private int netPort = 8080; + private boolean maskSplitEnable = false; + private Integer maskOverlapColor = null; + private Long maskOverlapValue = Long.MAX_VALUE; /** * Convert the given URI path to a regular expression in which {@link #PLACEHOLDER_IMAGE_ID} matches the image ID. @@ -115,19 +123,23 @@ public Configuration(Map configuration) { * @param configuration the configuration keys and values to apply over the current state. */ private void setConfiguration(Map configuration) { - final String cacheSize = configuration.get(CONF_BUFFER_CACHE_SIZE); + final String bufferCacheSize = configuration.get(CONF_BUFFER_CACHE_SIZE); final String chunkSizeAdjust = configuration.get(CONF_CHUNK_SIZE_ADJUST); final String chunkSizeMin = configuration.get(CONF_CHUNK_SIZE_MIN); final String zlibLevel = configuration.get(CONF_COMPRESS_ZLIB_LEVEL); final String folderLayout = configuration.get(CONF_FOLDER_LAYOUT); + final String maskCacheSize = configuration.get(CONF_MASK_CACHE_SIZE); + final String maskSplitEnable = configuration.get(CONF_MASK_SPLIT_ENABLE); + final String maskOverlapColor = configuration.get(CONF_MASK_OVERLAP_COLOR); + final String maskOverlapValue = configuration.get(CONF_MASK_OVERLAP_VALUE); final String netPath = configuration.get(CONF_NET_PATH_IMAGE); final String netPort = configuration.get(CONF_NET_PORT); - if (cacheSize != null) { + if (bufferCacheSize != null) { try { - this.cacheSize = Integer.parseInt(cacheSize); + this.bufferCacheSize = Integer.parseInt(bufferCacheSize); } catch (NumberFormatException nfe) { - final String message = "buffer cache size must be an integer, not " + cacheSize; + final String message = "buffer cache size must be an integer, not " + bufferCacheSize; LOGGER.error(message); throw new IllegalArgumentException(message); } @@ -195,6 +207,49 @@ private void setConfiguration(Map configuration) { } } + if (maskCacheSize != null) { + try { + this.maskCacheSize = Long.parseLong(maskCacheSize) * 1000000L; + } catch (NumberFormatException nfe) { + final String message = "mask cache size must be an integer, not " + maskCacheSize; + LOGGER.error(message); + throw new IllegalArgumentException(message); + } + } + + if (maskSplitEnable != null) { + this.maskSplitEnable = Boolean.parseBoolean(maskSplitEnable); + } + + if (maskOverlapColor != null) { + try { + this.maskOverlapColor = Integer.parseInt(maskOverlapColor); + } catch (NumberFormatException nfe) { + final String message = "mask overlap color must be an integer, not " + maskOverlapColor; + LOGGER.error(message); + throw new IllegalArgumentException(message); + } + } + + if (maskOverlapValue != null) { + try { + this.maskOverlapValue = Long.parseLong(maskOverlapValue); + } catch (NumberFormatException nfe) { + switch (maskOverlapValue.toLowerCase()) { + case "highest": + this.maskOverlapValue = Long.MAX_VALUE; + break; + case "lowest": + this.maskOverlapValue = Long.MIN_VALUE; + break; + default: + final String message = "mask overlap value must be an integer or HIGHEST or LOWEST, not " + maskOverlapValue; + LOGGER.error(message); + throw new IllegalArgumentException(message); + } + } + } + if (netPath != null) { if (netPath.indexOf('\\') == -1) { if (netPath.contains(PLACEHOLDER_IMAGE_ID)) { @@ -232,7 +287,7 @@ private void setConfiguration(Map configuration) { * @return the configured pixel buffer cache size */ public int getBufferCacheSize() { - return cacheSize; + return bufferCacheSize; } /** @@ -263,6 +318,34 @@ public Boolean getFoldersNested() { return foldersNested; } + /** + * @return the configured mask cache size + */ + public long getMaskCacheSize() { + return maskCacheSize; + } + + /** + * @return if split masks are enabled + */ + public boolean isSplitMasksEnabled() { + return maskSplitEnable; + } + + /** + * @return the configured mask overlap color ({@code null} for no color) + */ + public Integer getMaskOverlapColor() { + return maskOverlapColor; + } + + /** + * @return the configured mask overlap value ({@code null} for no overlaps) + */ + public Long getMaskOverlapValue() { + return maskOverlapValue; + } + /** * @return the configured URI path as a regular expression */ diff --git a/src/main/java/org/openmicroscopy/ms/zarr/OmeroDao.java b/src/main/java/org/openmicroscopy/ms/zarr/OmeroDao.java index bed94c1..fad0b5b 100644 --- a/src/main/java/org/openmicroscopy/ms/zarr/OmeroDao.java +++ b/src/main/java/org/openmicroscopy/ms/zarr/OmeroDao.java @@ -21,9 +21,15 @@ import ome.io.nio.PixelsService; import ome.model.core.Pixels; +import ome.model.roi.Mask; +import ome.model.roi.Roi; +import java.util.Iterator; +import java.util.SortedSet; import java.util.function.Function; +import com.google.common.collect.ImmutableSortedSet; + import org.hibernate.Session; import org.hibernate.SessionFactory; import org.slf4j.Logger; @@ -89,4 +95,89 @@ Pixels getPixels(long imageId) { return withSession(session -> (Pixels) session.createQuery(hql).setParameter(0, imageId).uniqueResult()); } + + /** + * @param imageId the ID of an image + * @return how many Rois the image has + */ + long getRoiCountOfImage(long imageId) { + LOGGER.debug("fetch Roi count for Image:{}", imageId); + final String hql = + "SELECT COUNT(id) FROM Roi " + + "WHERE image.id = ?"; + return withSession(session -> + (Long) session.createQuery(hql).setParameter(0, imageId).uniqueResult()); + } + + /** + * @param imageId the ID of an image + * @return the IDs of the image's Rois + */ + SortedSet getRoiIdsOfImage(long imageId) { + LOGGER.debug("fetch Roi IDs for Image:{}", imageId); + final String hql = + "SELECT id FROM Roi " + + "WHERE image.id = ?"; + return withSession(session -> + ImmutableSortedSet.copyOf((Iterator) session.createQuery(hql).setParameter(0, imageId).iterate())); + } + + /** + * @param imageId the ID of an image + * @return the IDs of the image's Rois, limited to those that have a Mask + */ + SortedSet getRoiIdsWithMaskOfImage(long imageId) { + LOGGER.debug("fetch Roi IDs for Image:{} where each Roi has a Mask", imageId); + final String hql = + "SELECT DISTINCT roi.id FROM Mask " + + "WHERE roi.image.id = ?"; + return withSession(session -> + ImmutableSortedSet.copyOf((Iterator) session.createQuery(hql).setParameter(0, imageId).iterate())); + } + + /** + * @param roiId the ID of a Roi + * @return how many masks the Roi has + */ + long getMaskCountOfRoi(long roiId) { + LOGGER.debug("fetch Mask count for Roi:{}", roiId); + final String hql = + "SELECT COUNT(id) FROM Mask " + + "WHERE roi.id = ?"; + return withSession(session -> + (Long) session.createQuery(hql).setParameter(0, roiId).uniqueResult()); + } + + /** + * @param roiId the ID of a Roi + * @return the IDs of the Roi's Masks + */ + SortedSet getMaskIdsOfRoi(long roiId) { + LOGGER.debug("fetch Mask IDs for Roi:{}", roiId); + final String hql = + "SELECT id FROM Mask " + + "WHERE roi.id = ?"; + return withSession(session -> + ImmutableSortedSet.copyOf((Iterator) session.createQuery(hql).setParameter(0, roiId).iterate())); + } + + /** + * @param roiId the ID of a Roi + * @return the Roi + */ + Roi getRoi(long roiId) { + LOGGER.debug("fetch Roi:{}", roiId); + return withSession(session -> + (Roi) session.get(Roi.class, roiId)); + } + + /** + * @param maskId the ID of a Mask + * @return the Mask + */ + Mask getMask(long maskId) { + LOGGER.debug("fetch Mask:{}", maskId); + return withSession(session -> + (Mask) session.get(Mask.class, maskId)); + } } diff --git a/src/main/java/org/openmicroscopy/ms/zarr/RequestHandlerForImage.java b/src/main/java/org/openmicroscopy/ms/zarr/RequestHandlerForImage.java index ab96934..255ce3a 100644 --- a/src/main/java/org/openmicroscopy/ms/zarr/RequestHandlerForImage.java +++ b/src/main/java/org/openmicroscopy/ms/zarr/RequestHandlerForImage.java @@ -19,6 +19,10 @@ package org.openmicroscopy.ms.zarr; +import org.openmicroscopy.ms.zarr.mask.Bitmask; +import org.openmicroscopy.ms.zarr.mask.ImageMask; +import org.openmicroscopy.ms.zarr.mask.UnionMask; + import ome.io.nio.PixelBuffer; import ome.io.nio.PixelsService; import ome.model.core.Channel; @@ -29,25 +33,43 @@ import ome.model.display.RenderingDef; import ome.model.display.ReverseIntensityContext; import ome.model.enums.RenderingModel; +import ome.model.roi.Mask; +import ome.model.roi.Roi; import ome.model.stats.StatsInfo; import ome.util.PixelData; import java.awt.Dimension; import java.io.IOException; +import java.nio.ByteBuffer; import java.nio.ByteOrder; import java.util.ArrayList; import java.util.Collection; +import java.util.Collections; import java.util.HashMap; import java.util.Iterator; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Optional; +import java.util.SortedMap; +import java.util.SortedSet; +import java.util.TreeMap; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; import java.util.function.BiConsumer; +import java.util.function.BiPredicate; import java.util.function.Function; import java.util.regex.Matcher; import java.util.regex.Pattern; +import java.util.stream.Collectors; import java.util.zip.Deflater; import com.google.common.base.Joiner; +import com.google.common.cache.CacheBuilder; +import com.google.common.cache.CacheLoader; +import com.google.common.cache.LoadingCache; +import com.google.common.cache.Weigher; +import com.google.common.collect.ImmutableCollection; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; @@ -90,6 +112,7 @@ static class DataShape { int xTile, yTile, zTile; /** + * Construct the shape of image data. * @param buffer the pixel buffer from which to extract the dimensionality */ DataShape(PixelBuffer buffer) { @@ -99,7 +122,44 @@ static class DataShape { zSize = buffer.getSizeZ(); tSize = buffer.getSizeT(); - final Dimension tileSize = buffer.getTileSize(); + byteWidth = buffer.getByteWidth(); + + applyTileSize(buffer.getTileSize()); + } + + /** + * Construct the shape of a mask. + * @param buffer the pixel buffer from which to extract the dimensionality + * @param mask the mask to use to limit dimensionality based on {@link Bitmask#isSignificant(char)} + * @param byteWidth the applicable byte width, typically {@code 1} for a split mask + */ + DataShape(PixelBuffer buffer, Bitmask mask, int byteWidth) { + this(buffer, Collections.singleton(mask), byteWidth); + } + + /** + * Construct the shape of a mask. + * @param buffer the pixel buffer from which to extract the dimensionality + * @param masks the masks to use to limit dimensionality based on {@link Bitmask#isSignificant(char)} + * @param byteWidth the applicable byte width, typically {@link Long#BYTES} for a labeled mask + */ + DataShape(PixelBuffer buffer, Collection masks, int byteWidth) { + xSize = buffer.getSizeX(); + ySize = buffer.getSizeY(); + cSize = masks.stream().anyMatch(mask -> mask.isSignificant('C')) ? buffer.getSizeC() : 1; + zSize = masks.stream().anyMatch(mask -> mask.isSignificant('Z')) ? buffer.getSizeZ() : 1; + tSize = masks.stream().anyMatch(mask -> mask.isSignificant('T')) ? buffer.getSizeT() : 1; + + this.byteWidth = byteWidth; + + applyTileSize(buffer.getTileSize()); + } + + /** + * Helper for constructors. + * @param tileSize the tile size to set for this shape + */ + private void applyTileSize(Dimension tileSize) { if (tileSize == null) { xTile = xSize; yTile = ySize; @@ -108,8 +168,6 @@ static class DataShape { yTile = tileSize.height; } zTile = 1; - - byteWidth = buffer.getByteWidth(); } /** @@ -214,15 +272,61 @@ public String toString() { private final int chunkSizeMin; private final int deflateLevel; private final Boolean foldersNested; + private final boolean isSplitMasksEnabled; + private final Integer maskOverlapColor; + private final Long maskOverlapValue; private final Pattern patternForImageDir; - private final Pattern patternForGroupDir; - private final Pattern patternForChunkDir; + private final Pattern patternForImageGroupDir; + private final Pattern patternForImageChunkDir; + private final Pattern patternForImageMasksDir; + private final Pattern patternForMaskDir; + private final Pattern patternForMaskChunkDir; + private final Pattern patternForMaskLabeledDir; + private final Pattern patternForMaskLabeledChunkDir; + + private final Pattern patternForImageGroup; + private final Pattern patternForImageAttrs; + private final Pattern patternForImageMasksGroup; + private final Pattern patternForImageMasksAttrs; + private final Pattern patternForMaskAttrs; + private final Pattern patternForMaskLabeledAttrs; + private final Pattern patternForImageArray; + private final Pattern patternForMaskArray; + private final Pattern patternForMaskLabeledArray; + private final Pattern patternForImageChunk; + private final Pattern patternForMaskChunk; + private final Pattern patternForMaskLabeledChunk; - private final Pattern patternForGroup; - private final Pattern patternForAttrs; - private final Pattern patternForArray; - private final Pattern patternForChunk; + /* Labeled masks may be expensive to construct so here they are cached. */ + private final LoadingCache>> labeledMaskCache; + + /** + * Build the cache for labeled masks. + * @param maximumWeight the maximum weight to set for the cache + * @return the cache + */ + private LoadingCache>> buildLabeledMaskCache(long maximumWeight) { + return CacheBuilder.newBuilder() + .weigher(new Weigher>>() { + @Override + public int weigh(Long imageId, Optional> labeledMasks) { + if (labeledMasks.isPresent()) { + return labeledMasks.get().values().stream().map(Bitmask::size).reduce(0, Math::addExact); + } else { + return 0; + } + } + }) + .maximumWeight(maximumWeight) + .expireAfterAccess(1, TimeUnit.MINUTES) + .build(new CacheLoader>>() { + @Override + public Optional> load(Long key) { + return Optional.ofNullable(getLabeledMasksForCache(key)); + } + }); + } /** * Create the HTTP request handler. @@ -246,17 +350,34 @@ public RequestHandlerForImage(Configuration configuration, PixelsService pixelsS this.chunkSizeMin = configuration.getMinimumChunkSize(); this.deflateLevel = configuration.getDeflateLevel(); this.foldersNested = configuration.getFoldersNested(); + this.isSplitMasksEnabled = configuration.isSplitMasksEnabled(); + this.labeledMaskCache = buildLabeledMaskCache(configuration.getMaskCacheSize()); + this.maskOverlapColor = configuration.getMaskOverlapColor(); + this.maskOverlapValue = configuration.getMaskOverlapValue(); final String path = configuration.getPathRegex(); this.patternForImageDir = Pattern.compile(path); - this.patternForGroupDir = Pattern.compile(path + "(\\d+)/"); - this.patternForChunkDir = Pattern.compile(path + "(\\d+)/(\\d+([/.]\\d+)*)/"); + this.patternForImageGroupDir = Pattern.compile(path + "(\\d+)/"); + this.patternForImageChunkDir = Pattern.compile(path + "(\\d+)/(\\d+([/.]\\d+)*)/"); + this.patternForImageMasksDir = Pattern.compile(path + "masks/"); + this.patternForMaskDir = Pattern.compile(path + "masks/(\\d+)/"); + this.patternForMaskChunkDir = Pattern.compile(path + "masks/(\\d+)/(\\d+([/.]\\d+)*)/"); + this.patternForMaskLabeledDir = Pattern.compile(path + "masks/labell?ed/"); + this.patternForMaskLabeledChunkDir = Pattern.compile(path + "masks/labell?ed/(\\d+([/.]\\d+)*)/"); - this.patternForGroup = Pattern.compile(path + "\\.zgroup"); - this.patternForAttrs = Pattern.compile(path + "\\.zattrs"); - this.patternForArray = Pattern.compile(path + "(\\d+)/\\.zarray"); - this.patternForChunk = Pattern.compile(path + "(\\d+)/(\\d+([/.]\\d+)*)"); + this.patternForImageGroup = Pattern.compile(path + "\\.zgroup"); + this.patternForImageAttrs = Pattern.compile(path + "\\.zattrs"); + this.patternForImageMasksGroup = Pattern.compile(path + "masks/\\.zgroup"); + this.patternForImageMasksAttrs = Pattern.compile(path + "masks/\\.zattrs"); + this.patternForMaskAttrs = Pattern.compile(path + "masks/(\\d+)/\\.zattrs"); + this.patternForMaskLabeledAttrs = Pattern.compile(path + "masks/labell?ed/\\.zattrs"); + this.patternForImageArray = Pattern.compile(path + "(\\d+)/\\.zarray"); + this.patternForMaskArray = Pattern.compile(path + "masks/(\\d+)/\\.zarray"); + this.patternForMaskLabeledArray = Pattern.compile(path + "masks/labell?ed/\\.zarray"); + this.patternForImageChunk = Pattern.compile(path + "(\\d+)/(\\d+([/.]\\d+)*)"); + this.patternForMaskChunk = Pattern.compile(path + "masks/(\\d+)/(\\d+([/.]\\d+)*)"); + this.patternForMaskLabeledChunk = Pattern.compile(path + "masks/labell?ed/(\\d+([/.]\\d+)*)"); } /** @@ -286,7 +407,7 @@ public void handle(RoutingContext context) { } catch (NumberFormatException nfe) { fail(response, 400, "failed to parse integer"); } catch (IllegalArgumentException iae) { - fail(response, 404, "path specifies unknown form of query parameters"); + fail(response, 404, iae.getMessage()); } catch (Throwable t) { LOGGER.warn("unexpected failure handling path: {}", requestPath, t); throw t; @@ -301,16 +422,37 @@ public void handleFor(Router router) { if (foldersNested != null) { handleFor(router, patternForImageDir, this::returnImageDirectory); if (foldersNested) { - handleFor(router, patternForGroupDir, this::returnGroupDirectoryNested); - handleFor(router, patternForChunkDir, this::returnChunkDirectory); + handleFor(router, patternForImageGroupDir, this::returnImageGroupDirectoryNested); + handleFor(router, patternForMaskLabeledDir, this::returnMaskLabeledDirectoryNested); + handleFor(router, patternForImageChunkDir, this::returnImageChunkDirectory); + handleFor(router, patternForMaskLabeledChunkDir, this::returnMaskLabeledChunkDirectory); + if (isSplitMasksEnabled) { + handleFor(router, patternForMaskDir, this::returnMaskDirectoryNested); + handleFor(router, patternForMaskChunkDir, this::returnMaskChunkDirectory); + } } else { - handleFor(router, patternForGroupDir, this::returnGroupDirectoryFlattened); + handleFor(router, patternForImageGroupDir, this::returnImageGroupDirectoryFlattened); + handleFor(router, patternForMaskLabeledDir, this::returnMaskLabeledDirectoryFlattened); + if (isSplitMasksEnabled) { + handleFor(router, patternForMaskDir, this::returnMaskDirectoryFlattened); + } } + handleFor(router, patternForImageMasksDir, this::returnImageMasksDirectory); + } + handleFor(router, patternForImageGroup, this::returnGroup); + handleFor(router, patternForImageMasksGroup, this::returnGroup); + handleFor(router, patternForImageAttrs, this::returnImageAttrs); + handleFor(router, patternForImageMasksAttrs, this::returnImageMasksAttrs); + handleFor(router, patternForMaskLabeledAttrs, this::returnMaskLabeledAttrs); + handleFor(router, patternForImageArray, this::returnImageArray); + handleFor(router, patternForMaskLabeledArray, this::returnMaskLabeledArray); + handleFor(router, patternForImageChunk, this::returnImageChunk); + handleFor(router, patternForMaskLabeledChunk, this::returnMaskLabeledChunk); + if (isSplitMasksEnabled) { + handleFor(router, patternForMaskAttrs, this::returnMaskAttrs); + handleFor(router, patternForMaskArray, this::returnMaskArray); + handleFor(router, patternForMaskChunk, this::returnMaskChunk); } - handleFor(router, patternForGroup, this::returnGroup); - handleFor(router, patternForAttrs, this::returnAttrs); - handleFor(router, patternForArray, this::returnArray); - handleFor(router, patternForChunk, this::returnChunk); } /** @@ -435,6 +577,68 @@ private static void respondWithJson(HttpServerResponse response, JsonObject data response.end(responseText); } + /** + * Helper for building directory contents: adds entries for flattened chunks. + * @param contents the directory contents + * @param shape the dimensionality of the data + */ + private static void addChunkEntriesFlattened(ImmutableCollection.Builder contents, DataShape shape) { + for (int t = 0; t < shape.tSize; t += 1) { + for (int c = 0; c < shape.cSize; c += 1) { + for (int z = 0; z < shape.zSize; z += shape.zTile) { + for (int y = 0; y < shape.ySize; y += shape.yTile) { + for (int x = 0; x < shape.xSize; x += shape.xTile) { + contents.add(Joiner.on('.').join(t, c, z / shape.zTile, y / shape.yTile, x / shape.xTile)); + } + } + } + } + } + } + + /** + * Helper for building directory contents: adds entries for nested chunks. + * @param contents the directory contents + * @param shape the dimensionality of the data + * @param depth the depth into the chunk, ranging from zero to four inclusive + */ + private static void addChunkEntriesNested(ImmutableCollection.Builder contents, DataShape shape, int depth) { + final int extent, step; + switch (depth) { + case 0: + extent = shape.tSize; + step = 1; + break; + case 1: + extent = shape.cSize; + step = 1; + break; + case 2: + extent = shape.zSize; + step = shape.zTile; + break; + case 3: + extent = shape.ySize; + step = shape.yTile; + break; + case 4: + extent = shape.xSize; + step = shape.xTile; + break; + default: + throw new IllegalArgumentException("depth cannot be " + depth); + } + final StringBuilder content = new StringBuilder(); + for (int position = 0; position < extent; position += step) { + content.setLength(0); + content.append(position / step); + if (depth < 4) { + content.append('/'); + } + contents.add(content.toString()); + } + } + /** * Handle a request for the image directory. * @param response the HTTP server response to populate @@ -454,6 +658,12 @@ private void returnImageDirectory(HttpServerResponse response, List para final ImmutableList.Builder contents = ImmutableList.builder(); contents.add(".zattrs"); contents.add(".zgroup"); + for (final long roiId : omeroDao.getRoiIdsOfImage(imageId)) { + if (omeroDao.getMaskCountOfRoi(roiId) > 0) { + contents.add("masks/"); + break; + } + } for (int resolution = 0; resolution < resolutions; resolution++) { contents.add(Integer.toString(resolution) + '/'); } @@ -465,7 +675,7 @@ private void returnImageDirectory(HttpServerResponse response, List para * @param response the HTTP server response to populate * @param parameters the parameters of the request to handle */ - private void returnGroupDirectoryFlattened(HttpServerResponse response, List parameters) { + private void returnImageGroupDirectoryFlattened(HttpServerResponse response, List parameters) { /* parse parameters from path */ final long imageId = Long.parseLong(parameters.get(0)); final int resolution = Integer.parseInt(parameters.get(1)); @@ -478,17 +688,7 @@ private void returnGroupDirectoryFlattened(HttpServerResponse response, List contents = ImmutableList.builder(); contents.add(".zarray"); - for (int t = 0; t < shape.tSize; t += 1) { - for (int c = 0; c < shape.cSize; c += 1) { - for (int z = 0; z < shape.zSize; z += shape.zTile) { - for (int y = 0; y < shape.ySize; y += shape.yTile) { - for (int x = 0; x < shape.xSize; x += shape.xTile) { - contents.add(Joiner.on('.').join(t, c, z / shape.zTile, y / shape.yTile, x / shape.xTile)); - } - } - } - } - } + addChunkEntriesFlattened(contents, shape); respondWithDirectory(response, "Image #" + imageId + ", resolution " + resolution + " (flattened)", contents.build()); } @@ -497,11 +697,11 @@ private void returnGroupDirectoryFlattened(HttpServerResponse response, List parameters) { + private void returnImageGroupDirectoryNested(HttpServerResponse response, List parameters) { /* parse parameters from path */ final long imageId = Long.parseLong(parameters.get(0)); final int resolution = Integer.parseInt(parameters.get(1)); - returnGroupDirectoryNested(response, imageId, resolution, 0); + returnImageGroupDirectoryNested(response, imageId, resolution, 0); } /** @@ -509,7 +709,7 @@ private void returnGroupDirectoryNested(HttpServerResponse response, List parameters) { + private void returnImageChunkDirectory(HttpServerResponse response, List parameters) { /* parse parameters from path */ final long imageId = Long.parseLong(parameters.get(0)); final int resolution = Integer.parseInt(parameters.get(1)); @@ -517,7 +717,7 @@ private void returnChunkDirectory(HttpServerResponse response, List para if (chunkDir.size() >= 5) { throw new IllegalArgumentException("chunks must have five dimensions"); } - returnGroupDirectoryNested(response, imageId, resolution, chunkDir.size()); + returnImageGroupDirectoryNested(response, imageId, resolution, chunkDir.size()); } /** @@ -527,7 +727,7 @@ private void returnChunkDirectory(HttpServerResponse response, List para * @param resolution the resolution to query * @param depth how many directory levels inside the group */ - private void returnGroupDirectoryNested(HttpServerResponse response, long imageId, int resolution, int depth) { + private void returnImageGroupDirectoryNested(HttpServerResponse response, long imageId, int resolution, int depth) { LOGGER.debug("providing nested directory listing for resolution {} of Image:{} at depth {}", resolution, imageId, depth); /* gather data from pixels service */ final DataShape shape = getDataShape(response, imageId, resolution); @@ -539,163 +739,347 @@ private void returnGroupDirectoryNested(HttpServerResponse response, long imageI if (depth == 0) { contents.add(".zarray"); } - final int extent, step; - switch (depth) { - case 0: - extent = shape.tSize; - step = 1; - break; - case 1: - extent = shape.cSize; - step = 1; - break; - case 2: - extent = shape.zSize; - step = shape.zTile; - break; - case 3: - extent = shape.ySize; - step = shape.yTile; - break; - case 4: - extent = shape.xSize; - step = shape.xTile; - break; - default: - throw new IllegalArgumentException("depth cannot be " + depth); + addChunkEntriesNested(contents, shape, depth); + respondWithDirectory(response, "Image #" + imageId + ", resolution " + resolution, contents.build()); + } + + /** + * Handle a request for the masks of an image directory. + * @param response the HTTP server response to populate + * @param parameters the parameters of the request to handle + */ + private void returnImageMasksDirectory(HttpServerResponse response, List parameters) { + /* parse parameters from path */ + final long imageId = Long.parseLong(parameters.get(0)); + LOGGER.debug("providing directory listing for Masks of Image:{}", imageId); + /* gather data from database */ + final Collection roiIdsWithMask = omeroDao.getRoiIdsWithMaskOfImage(imageId); + final Map labeledMasks = roiIdsWithMask.isEmpty() ? null : getLabeledMasks(imageId); + if (roiIdsWithMask.isEmpty()) { + fail(response, 404, "no image masks for that id"); + return; } - final StringBuilder content = new StringBuilder(); - for (int position = 0; position < extent; position += step) { - content.setLength(0); - content.append(position / step); - if (depth < 4) { - content.append('/'); + /* package data for client */ + final ImmutableList.Builder contents = ImmutableList.builder(); + contents.add(".zattrs"); + if (labeledMasks != null) { + contents.add("labeled/"); + } + if (isSplitMasksEnabled) { + for (long roiId : roiIdsWithMask) { + contents.add(Long.toString(roiId) + '/'); } - contents.add(content.toString()); } - respondWithDirectory(response, "Image #" + imageId + ", resolution " + resolution + " (nested)", contents.build()); + respondWithDirectory(response, "Masks of Image #" + imageId, contents.build()); } /** - * Handle a request for {@code .zgroup}. + * Handle a request for the group directory in flattened mode. * @param response the HTTP server response to populate * @param parameters the parameters of the request to handle */ - private void returnGroup(HttpServerResponse response, List parameters) { + private void returnMaskDirectoryFlattened(HttpServerResponse response, List parameters) { /* parse parameters from path */ final long imageId = Long.parseLong(parameters.get(0)); - LOGGER.debug("providing .zgroup for Image:{}", imageId); - /* package data for client */ + final long roiId = Long.parseLong(parameters.get(1)); + LOGGER.debug("providing flattened directory listing for Mask:{}", roiId); + /* gather data */ final Pixels pixels = omeroDao.getPixels(imageId); - if (pixels == null) { - fail(response, 404, "no image for that id"); + final Roi roi = omeroDao.getRoi(roiId); + if (roi == null || roi.getImage() == null || roi.getImage().getId() != imageId) { + throw new IllegalArgumentException("image has no such mask"); + } + final SortedSet maskIds = omeroDao.getMaskIdsOfRoi(roiId); + if (maskIds.isEmpty()) { + throw new IllegalArgumentException("image has no such mask"); + } + final Bitmask imageMask = UnionMask.union(maskIds.stream() + .map(omeroDao::getMask).map(ImageMask::new).collect(Collectors.toList())); + final DataShape shape = getDataFromPixels(response, pixels, buffer -> new DataShape(buffer, imageMask, 1)) + .adjustTileSize(chunkSizeAdjust, chunkSizeMin); + if (response.ended()) { return; } - final Map result = new HashMap<>(); - result.put("zarr_format", 2); - respondWithJson(response, new JsonObject(result)); + /* package data for client */ + final ImmutableList.Builder contents = ImmutableList.builder(); + contents.add(".zarray"); + contents.add(".zattrs"); + addChunkEntriesFlattened(contents, shape); + respondWithDirectory(response, "Mask #" + roiId + " (flattened)", contents.build()); } /** - * Build OMERO metadata for {@link #returnAttrs(HttpServerResponse, long)} to include with a {@code "omero"} key. - * @param pixels the {@link Pixels} instance for which to build the metadata - * @return the nested metadata + * Handle a request for the mask directory in nested mode. + * @param response the HTTP server response to populate + * @param parameters the parameters of the request to handle + * @param parameters the parameters of the request to handle */ - private Map buildOmeroMetadata(Pixels pixels) { - final Map omero = new HashMap<>(); - final Image image = pixels.getImage(); - omero.put("id", image.getId()); - omero.put("name", image.getName()); - final long ownerId = pixels.getDetails().getOwner().getId(); - RenderingDef rdef = null; - final Iterator settingsIterator = pixels.iterateSettings(); - while (rdef == null && settingsIterator.hasNext()) { - final RenderingDef setting = settingsIterator.next(); - if (ownerId == setting.getDetails().getOwner().getId()) { - /* settings owned by the image owner ... */ - rdef = setting; - } - } - if (rdef == null) { - if (pixels.sizeOfSettings() > 0) { - /* ... or fall back to other settings */ - rdef = pixels.iterateSettings().next(); - } - } - if (rdef != null) { - final Map rdefs = new HashMap<>(); - rdefs.put("defaultZ", rdef.getDefaultZ()); - rdefs.put("defaultT", rdef.getDefaultT()); - if (RenderingModel.VALUE_GREYSCALE.equals(rdef.getModel().getValue())) { - rdefs.put("model", "greyscale"); - } else { - /* probably "rgb" */ - rdefs.put("model", "color"); - } - omero.put("rdefs", rdefs); - final int channelCount = pixels.sizeOfChannels(); - if (channelCount == rdef.sizeOfWaveRendering()) { - final List> channels = new ArrayList<>(channelCount); - for (int index = 0; index < channelCount; index++) { - final Channel channel = pixels.getChannel(index); - final ChannelBinding binding = rdef.getChannelBinding(index); - final Map channelsElem = new HashMap<>(); - final String name = channel.getLogicalChannel().getName(); - if (name != null) { - channelsElem.put("label", channel.getLogicalChannel().getName()); - } - channelsElem.put("active", binding.getActive()); - channelsElem.put("coefficient", binding.getCoefficient()); - channelsElem.put("family", binding.getFamily().getValue()); - boolean isInverted = false; - final Iterator sdeIterator = binding.iterateSpatialDomainEnhancement(); - while (sdeIterator.hasNext()) { - final CodomainMapContext sde = sdeIterator.next(); - if (sde instanceof ReverseIntensityContext) { - isInverted ^= ((ReverseIntensityContext) sde).getReverse(); - } - } - channelsElem.put("inverted", isInverted); - channelsElem.put("color", - String.format("%02X", binding.getRed()) + - String.format("%02X", binding.getGreen()) + - String.format("%02X", binding.getBlue())); - final Map window = new HashMap<>(); - final StatsInfo stats = channel.getStatsInfo(); - if (stats != null) { - window.put("min", stats.getGlobalMin()); - window.put("max", stats.getGlobalMax()); - } - window.put("start", binding.getInputStart()); - window.put("end", binding.getInputEnd()); - channelsElem.put("window", window); - channels.add(channelsElem); - } - omero.put("channels", channels); - } - } - return omero; + private void returnMaskDirectoryNested(HttpServerResponse response, List parameters) { + /* parse parameters from path */ + final long imageId = Long.parseLong(parameters.get(0)); + final long roiId = Long.parseLong(parameters.get(1)); + returnMaskDirectoryNested(response, imageId, roiId, 0); } /** - * Handle a request for {@code .zattrs}. + * Handle a request for the chunk directory in nested mode. * @param response the HTTP server response to populate * @param parameters the parameters of the request to handle */ - private void returnAttrs(HttpServerResponse response, List parameters) { + private void returnMaskChunkDirectory(HttpServerResponse response, List parameters) { /* parse parameters from path */ final long imageId = Long.parseLong(parameters.get(0)); - LOGGER.debug("providing .zattrs for Image:{}", imageId); - /* gather data from pixels service */ - final Pixels pixels = omeroDao.getPixels(imageId); - final List> resolutions = getDataFromPixels(response, pixels, buffer -> buffer.getResolutionDescriptions()); - if (response.ended()) { - return; + final long roiId = Long.parseLong(parameters.get(1)); + final List chunkDir = getIndicesFromPath(parameters.get(2)); + if (chunkDir.size() >= 5) { + throw new IllegalArgumentException("chunks must have five dimensions"); } - double xMax = 0, yMax = 0; - for (final List resolution : resolutions) { - final int x = resolution.get(0); - final int y = resolution.get(1); + returnMaskDirectoryNested(response, imageId, roiId, chunkDir.size()); + } + + /** + * Handle a request for the mask directory in nested mode. + * @param response the HTTP server response to populate + * @param roiId the ID of the mask being queried + * @param depth how many directory levels inside the group + */ + private void returnMaskDirectoryNested(HttpServerResponse response, long imageId, long roiId, int depth) { + LOGGER.debug("providing directory listing for Mask:{}", roiId); + /* gather data */ + final Roi roi = omeroDao.getRoi(roiId); + if (roi == null || roi.getImage() == null || roi.getImage().getId() != imageId) { + throw new IllegalArgumentException("image has no such mask"); + } + final SortedSet maskIds = omeroDao.getMaskIdsOfRoi(roiId); + if (maskIds.isEmpty()) { + throw new IllegalArgumentException("image has no such mask"); + } + final Pixels pixels = omeroDao.getPixels(imageId); + final Bitmask imageMask = UnionMask.union(maskIds.stream() + .map(omeroDao::getMask).map(ImageMask::new).collect(Collectors.toList())); + final DataShape shape = getDataFromPixels(response, pixels, buffer -> new DataShape(buffer, imageMask, 1)) + .adjustTileSize(chunkSizeAdjust, chunkSizeMin); + if (response.ended()) { + return; + } + /* package data for client */ + final ImmutableList.Builder contents = ImmutableList.builder(); + if (depth == 0) { + contents.add(".zarray"); + contents.add(".zattrs"); + } + addChunkEntriesNested(contents, shape, depth); + respondWithDirectory(response, "Mask #" + roiId, contents.build()); + } + + /** + * Handle a request for the group directory in flattened mode. + * @param response the HTTP server response to populate + * @param parameters the parameters of the request to handle + */ + private void returnMaskLabeledDirectoryFlattened(HttpServerResponse response, List parameters) { + /* parse parameters from path */ + final long imageId = Long.parseLong(parameters.get(0)); + LOGGER.debug("providing flattened directory listing for labeled Mask of Image:{}", imageId); + final Map labeledMasks = getLabeledMasks(imageId); + if (labeledMasks == null) { + fail(response, 404, "the image for that id does not have a labeled mask"); + return; + } + /* gather data */ + final Pixels pixels = omeroDao.getPixels(imageId); + final Collection imageMasks = labeledMasks.values(); + final DataShape shape = getDataFromPixels(response, pixels, buffer -> new DataShape(buffer, imageMasks, Long.BYTES)) + .adjustTileSize(chunkSizeAdjust, chunkSizeMin); + if (response.ended()) { + return; + } + /* package data for client */ + final ImmutableList.Builder contents = ImmutableList.builder(); + contents.add(".zarray"); + contents.add(".zattrs"); + addChunkEntriesFlattened(contents, shape); + respondWithDirectory(response, "Labeled Mask of Image #" + imageId + " (flattened)", contents.build()); + } + + /** + * Handle a request for the mask directory in nested mode. + * @param response the HTTP server response to populate + * @param parameters the parameters of the request to handle + * @param parameters the parameters of the request to handle + */ + private void returnMaskLabeledDirectoryNested(HttpServerResponse response, List parameters) { + /* parse parameters from path */ + final long imageId = Long.parseLong(parameters.get(0)); + returnMaskLabeledDirectoryNested(response, imageId, 0); + } + + /** + * Handle a request for the chunk directory in nested mode. + * @param response the HTTP server response to populate + * @param parameters the parameters of the request to handle + */ + private void returnMaskLabeledChunkDirectory(HttpServerResponse response, List parameters) { + /* parse parameters from path */ + final long imageId = Long.parseLong(parameters.get(0)); + final List chunkDir = getIndicesFromPath(parameters.get(1)); + if (chunkDir.size() >= 5) { + throw new IllegalArgumentException("chunks must have five dimensions"); + } + returnMaskLabeledDirectoryNested(response, imageId, chunkDir.size()); + } + + /** + * Handle a request for the mask directory in nested mode. + * @param response the HTTP server response to populate + * @param roiId the ID of the mask being queried + * @param depth how many directory levels inside the group + */ + private void returnMaskLabeledDirectoryNested(HttpServerResponse response, long imageId, int depth) { + LOGGER.debug("providing directory listing for labeled Mask of Image:{}", imageId); + final Map labeledMasks = getLabeledMasks(imageId); + if (labeledMasks == null) { + fail(response, 404, "the image for that id does not have a labeled mask"); + return; + } + /* gather data */ + final Pixels pixels = omeroDao.getPixels(imageId); + final Collection imageMasks = labeledMasks.values(); + final DataShape shape = getDataFromPixels(response, pixels, buffer -> new DataShape(buffer, imageMasks, Long.BYTES)) + .adjustTileSize(chunkSizeAdjust, chunkSizeMin); + if (response.ended()) { + return; + } + /* package data for client */ + final ImmutableList.Builder contents = ImmutableList.builder(); + if (depth == 0) { + contents.add(".zarray"); + contents.add(".zattrs"); + } + addChunkEntriesNested(contents, shape, depth); + respondWithDirectory(response, "Labeled Mask of Image #" + imageId, contents.build()); + } + + /** + * Handle a request for {@code .zgroup}. + * @param response the HTTP server response to populate + * @param parameters the parameters of the request to handle + */ + private void returnGroup(HttpServerResponse response, List parameters) { + /* parse parameters from path */ + final long imageId = Long.parseLong(parameters.get(0)); + LOGGER.debug("providing .zgroup for Image:{}", imageId); + /* package data for client */ + final Pixels pixels = omeroDao.getPixels(imageId); + if (pixels == null) { + fail(response, 404, "no image for that id"); + return; + } + final Map result = new HashMap<>(); + result.put("zarr_format", 2); + respondWithJson(response, new JsonObject(result)); + } + + /** + * Build OMERO metadata for {@link #returnImageAttrs(HttpServerResponse, long)} to include with a {@code "omero"} key. + * @param pixels the {@link Pixels} instance for which to build the metadata + * @return the nested metadata + */ + private Map buildOmeroMetadata(Pixels pixels) { + final Map omero = new HashMap<>(); + final Image image = pixels.getImage(); + omero.put("id", image.getId()); + omero.put("name", image.getName()); + final long ownerId = pixels.getDetails().getOwner().getId(); + RenderingDef rdef = null; + final Iterator settingsIterator = pixels.iterateSettings(); + while (rdef == null && settingsIterator.hasNext()) { + final RenderingDef setting = settingsIterator.next(); + if (ownerId == setting.getDetails().getOwner().getId()) { + /* settings owned by the image owner ... */ + rdef = setting; + } + } + if (rdef == null) { + if (pixels.sizeOfSettings() > 0) { + /* ... or fall back to other settings */ + rdef = pixels.iterateSettings().next(); + } + } + if (rdef != null) { + final Map rdefs = new HashMap<>(); + rdefs.put("defaultZ", rdef.getDefaultZ()); + rdefs.put("defaultT", rdef.getDefaultT()); + if (RenderingModel.VALUE_GREYSCALE.equals(rdef.getModel().getValue())) { + rdefs.put("model", "greyscale"); + } else { + /* probably "rgb" */ + rdefs.put("model", "color"); + } + omero.put("rdefs", rdefs); + final int channelCount = pixels.sizeOfChannels(); + if (channelCount == rdef.sizeOfWaveRendering()) { + final List> channels = new ArrayList<>(channelCount); + for (int index = 0; index < channelCount; index++) { + final Channel channel = pixels.getChannel(index); + final ChannelBinding binding = rdef.getChannelBinding(index); + final Map channelsElem = new HashMap<>(); + final String name = channel.getLogicalChannel().getName(); + if (name != null) { + channelsElem.put("label", channel.getLogicalChannel().getName()); + } + channelsElem.put("active", binding.getActive()); + channelsElem.put("coefficient", binding.getCoefficient()); + channelsElem.put("family", binding.getFamily().getValue()); + boolean isInverted = false; + final Iterator sdeIterator = binding.iterateSpatialDomainEnhancement(); + while (sdeIterator.hasNext()) { + final CodomainMapContext sde = sdeIterator.next(); + if (sde instanceof ReverseIntensityContext) { + isInverted ^= ((ReverseIntensityContext) sde).getReverse(); + } + } + channelsElem.put("inverted", isInverted); + channelsElem.put("color", + String.format("%02X", binding.getRed()) + + String.format("%02X", binding.getGreen()) + + String.format("%02X", binding.getBlue())); + final Map window = new HashMap<>(); + final StatsInfo stats = channel.getStatsInfo(); + if (stats != null) { + window.put("min", stats.getGlobalMin()); + window.put("max", stats.getGlobalMax()); + } + window.put("start", binding.getInputStart()); + window.put("end", binding.getInputEnd()); + channelsElem.put("window", window); + channels.add(channelsElem); + } + omero.put("channels", channels); + } + } + return omero; + } + + /** + * Handle a request for {@code .zattrs} of an image. + * @param response the HTTP server response to populate + * @param parameters the parameters of the request to handle + */ + private void returnImageAttrs(HttpServerResponse response, List parameters) { + /* parse parameters from path */ + final long imageId = Long.parseLong(parameters.get(0)); + LOGGER.debug("providing .zattrs for Image:{}", imageId); + /* gather data from pixels service */ + final Pixels pixels = omeroDao.getPixels(imageId); + final List> resolutions = getDataFromPixels(response, pixels, buffer -> buffer.getResolutionDescriptions()); + if (response.ended()) { + return; + } + double xMax = 0, yMax = 0; + for (final List resolution : resolutions) { + final int x = resolution.get(0); + final int y = resolution.get(1); if (xMax < x) { xMax = x; } @@ -739,11 +1123,240 @@ private void returnAttrs(HttpServerResponse response, List parameters) { } /** - * Handle a request for {@code .zarray}. + * Handle a request for {@code .zattrs} of an image's masks. + * @param response the HTTP server response to populate + * @param parameters the parameters of the request to handle + */ + private void returnImageMasksAttrs(HttpServerResponse response, List parameters) { + /* parse parameters from path */ + final long imageId = Long.parseLong(parameters.get(0)); + LOGGER.debug("providing .zattrs for Masks of Image:{}", imageId); + /* gather data */ + final Collection roiIdsWithMask = omeroDao.getRoiIdsWithMaskOfImage(imageId); + final Map labeledMasks = roiIdsWithMask.isEmpty() ? null : getLabeledMasks(imageId); + if (roiIdsWithMask.isEmpty()) { + fail(response, 404, "no image masks for that id"); + return; + } + final List masks = new ArrayList<>(roiIdsWithMask.size() + 1); + if (labeledMasks != null) { + masks.add("labeled"); + } + if (isSplitMasksEnabled) { + for (final long roiId : roiIdsWithMask) { + masks.add(Long.toString(roiId)); + } + } + /* package data for client */ + final Map result = new HashMap<>(); + result.put("masks", masks); + respondWithJson(response, new JsonObject(result)); + } + + /** + * Return a color for the given set of masks if all those with a color concur on which. + * @param maskIds some mask IDs + * @return a color for the masks, or {@code null} if no mask sets a color or masks differ on the color + */ + private Integer getConsensusColor(Iterable maskIds) { + Integer roiColor = null; + for (final long maskId : maskIds) { + final Mask mask = omeroDao.getMask(maskId); + final Integer maskColor = mask.getFillColor(); + if (maskColor != null) { + if (roiColor == null) { + roiColor = maskColor; + } else if (!roiColor.equals(maskColor)) { + return null; + } + } + } + return roiColor; + } + + /** + * Handle a request for {@code .zattrs} for a mask of an image. * @param response the HTTP server response to populate * @param parameters the parameters of the request to handle */ - private void returnArray(HttpServerResponse response, List parameters) { + private void returnMaskAttrs(HttpServerResponse response, List parameters) { + /* parse parameters from path */ + final long imageId = Long.parseLong(parameters.get(0)); + final long roiId = Long.parseLong(parameters.get(1)); + LOGGER.debug("providing .zattrs for Mask:{}", roiId); + /* gather data */ + final Roi roi = omeroDao.getRoi(roiId); + if (roi == null || roi.getImage() == null || roi.getImage().getId() != imageId) { + throw new IllegalArgumentException("image has no such mask"); + } + final SortedSet maskIds = omeroDao.getMaskIdsOfRoi(roiId); + if (maskIds.isEmpty()) { + throw new IllegalArgumentException("image has no such mask"); + } + final Integer maskColor = getConsensusColor(maskIds); + /* package data for client */ + final Map color = new HashMap<>(); + if (maskColor != null) { + color.put(Boolean.toString(true), maskColor); + } + final Map image = new HashMap<>(); + image.put("array", "../../0/"); + final Map result = new HashMap<>(); + if (!color.isEmpty()) { + result.put("color", color); + } + result.put("image", image); + respondWithJson(response, new JsonObject(result)); + } + + /** + * Provide a labeled mask for the given image. May be refused if masks overlap. + * @param imageId an image ID + * @return a labeled mask, or {@code null} if there are no masks or + * overlapping masks with {@link Configuration#CONF_MASK_OVERLAP_VALUE} not set + */ + private Map getLabeledMasks(long imageId) { + final Collection roiIds = omeroDao.getRoiIdsWithMaskOfImage(imageId); + switch (roiIds.size()) { + case 0: + return null; + case 1: + return getLabeledMasksForCache(imageId); + default: + try { + final Optional> labeledMasks = labeledMaskCache.get(imageId); + return labeledMasks.isPresent() ? labeledMasks.get() : null; + } catch (ExecutionException ee) { + LOGGER.warn("failed to get labeled masks for image {}", imageId, ee.getCause()); + return null; + } + } + } + + /** + * Provide a labeled mask for the given image. May be refused if masks overlap. + * Intended to be used only by {@link #labeledMaskCache} because + * other callers should benefit from the cache by using {@link #getLabeledMasks(long)}. + * @param imageId an image ID + * @return a labeled mask, or {@code null} if there are no masks or + * overlapping masks with {@link Configuration#CONF_MASK_OVERLAP_VALUE} not set + */ + private Map getLabeledMasksForCache(long imageId) { + final List roiIds = new ArrayList<>(); + for (final long roiId : omeroDao.getRoiIdsWithMaskOfImage(imageId)) { + roiIds.add(roiId); + } + if (roiIds.isEmpty()) { + return null; + } + final Map labeledMasks = new HashMap<>(); + for (final long roiId : roiIds) { + final Collection maskIds = omeroDao.getMaskIdsOfRoi(roiId); + final Bitmask imageMask = UnionMask.union(maskIds.stream() + .map(omeroDao::getMask).map(ImageMask::new).collect(Collectors.toList())); + labeledMasks.put(roiId, imageMask); + } + if (maskOverlapValue == null) { + /* Check that there are no overlaps. */ + for (final Map.Entry labeledMask1 : labeledMasks.entrySet()) { + final long label1 = labeledMask1.getKey(); + final Bitmask mask1 = labeledMask1.getValue(); + if (mask1 instanceof ImageMask) { + final ImageMask imageMask1 = (ImageMask) mask1; + for (final Map.Entry labeledMask2 : labeledMasks.entrySet()) { + final long label2 = labeledMask2.getKey(); + final Bitmask mask2 = labeledMask2.getValue(); + if (label1 < label2) { + if (mask2 instanceof ImageMask) { + final ImageMask imageMask2 = (ImageMask) mask2; + if (imageMask2.isOverlap(imageMask1)) { + return null; + } + } else if (mask2 instanceof UnionMask) { + final UnionMask unionMask2 = (UnionMask) mask2; + if (unionMask2.isOverlap(imageMask1)) { + return null; + } + } else { + throw new IllegalStateException(); + } + } + } + } else if (mask1 instanceof UnionMask) { + final UnionMask unionMask1 = (UnionMask) mask1; + for (final Map.Entry labeledMask2 : labeledMasks.entrySet()) { + final long label2 = labeledMask2.getKey(); + final Bitmask mask2 = labeledMask2.getValue(); + if (label1 < label2) { + if (mask2 instanceof ImageMask) { + final ImageMask imageMask2 = (ImageMask) mask2; + if (unionMask1.isOverlap(imageMask2)) { + return null; + } + } else if (mask2 instanceof UnionMask) { + final UnionMask unionMask2 = (UnionMask) mask2; + if (unionMask1.isOverlap(unionMask2)) { + return null; + } + } else { + throw new IllegalStateException(); + } + } + } + } else { + throw new IllegalStateException(); + } + } + } + return labeledMasks; + } + + /** + * Handle a request for {@code .zattrs} for the labeled mask of an image. + * @param response the HTTP server response to populate + * @param parameters the parameters of the request to handle + */ + private void returnMaskLabeledAttrs(HttpServerResponse response, List parameters) { + /* parse parameters from path */ + final long imageId = Long.parseLong(parameters.get(0)); + LOGGER.debug("providing .zattrs for labeled Mask of Image:{}", imageId); + /* gather data */ + final Map labeledMasks = getLabeledMasks(imageId); + if (labeledMasks == null) { + fail(response, 404, "the image for that id does not have a labeled mask"); + return; + } + final SortedMap maskColors = new TreeMap<>(); + for (final long roiId : omeroDao.getRoiIdsWithMaskOfImage(imageId)) { + final Collection maskIds = omeroDao.getMaskIdsOfRoi(roiId); + final Integer maskColor = getConsensusColor(maskIds); + if (maskColor != null) { + maskColors.put(roiId, maskColor); + } + } + if (maskOverlapColor != null && maskOverlapValue != null) { + maskColors.put(maskOverlapValue, maskOverlapColor); + } + /* package data for client */ + final Map color = maskColors.entrySet().stream() + .collect(Collectors.toMap(entry -> Long.toString(entry.getKey()), Map.Entry::getValue, + (v1, v2) -> { throw new IllegalStateException(); }, LinkedHashMap::new)); + final Map image = new HashMap<>(); + image.put("array", "../../0/"); + final Map result = new HashMap<>(); + if (!color.isEmpty()) { + result.put("color", color); + } + result.put("image", image); + respondWithJson(response, new JsonObject(result)); + } + + /** + * Handle a request for {@code .zarray} for an image. + * @param response the HTTP server response to populate + * @param parameters the parameters of the request to handle + */ + private void returnImageArray(HttpServerResponse response, List parameters) { /* parse parameters from path */ final long imageId = Long.parseLong(parameters.get(0)); final int resolution = Integer.parseInt(parameters.get(1)); @@ -794,11 +1407,242 @@ private void returnArray(HttpServerResponse response, List parameters) { } /** - * Handle a request for a chunk of the pixel data. + * Handle a request for {@code .zarray} for a mask of an image. + * @param response the HTTP server response to populate + * @param parameters the parameters of the request to handle + */ + private void returnMaskArray(HttpServerResponse response, List parameters) { + /* parse parameters from path */ + final long imageId = Long.parseLong(parameters.get(0)); + final long roiId = Long.parseLong(parameters.get(1)); + LOGGER.debug("providing .zarray for Mask:{}", roiId); + /* gather data */ + final Roi roi = omeroDao.getRoi(roiId); + if (roi == null || roi.getImage() == null || roi.getImage().getId() != imageId) { + throw new IllegalArgumentException("image has no such mask"); + } + final SortedSet maskIds = omeroDao.getMaskIdsOfRoi(roiId); + if (maskIds.isEmpty()) { + throw new IllegalArgumentException("image has no such mask"); + } + /* gather data */ + final Pixels pixels = omeroDao.getPixels(imageId); + final Bitmask imageMask = UnionMask.union(maskIds.stream() + .map(omeroDao::getMask).map(ImageMask::new).collect(Collectors.toList())); + final DataShape shape = getDataFromPixels(response, pixels, buffer -> new DataShape(buffer, imageMask, 1)) + .adjustTileSize(chunkSizeAdjust, chunkSizeMin); + if (response.ended()) { + return; + } + /* package data for client */ + final Map compressor = new HashMap<>(); + compressor.put("id", "zlib"); + compressor.put("level", deflateLevel); + final Map result = new HashMap<>(); + result.put("zarr_format", 2); + result.put("order", "C"); + result.put("shape", ImmutableList.of(shape.tSize, shape.cSize, shape.zSize, shape.ySize, shape.xSize)); + result.put("chunks", ImmutableList.of(1, 1, shape.zTile, shape.yTile, shape.xTile)); + result.put("fill_value", false); + result.put("dtype", "|b1"); + result.put("filters", null); + result.put("compressor", compressor); + respondWithJson(response, new JsonObject(result)); + } + + /** + * Handle a request for {@code .zarray} for the labeled mask of an image. + * @param response the HTTP server response to populate + * @param parameters the parameters of the request to handle + */ + private void returnMaskLabeledArray(HttpServerResponse response, List parameters) { + /* parse parameters from path */ + final long imageId = Long.parseLong(parameters.get(0)); + LOGGER.debug("providing .zarray for labeled Mask of Image:{}", imageId); + /* gather data */ + final Map labeledMasks = getLabeledMasks(imageId); + if (labeledMasks == null) { + fail(response, 404, "the image for that id does not have a labeled mask"); + return; + } + /* gather data */ + final Pixels pixels = omeroDao.getPixels(imageId); + final Collection imageMasks = labeledMasks.values(); + final DataShape shape = getDataFromPixels(response, pixels, buffer -> new DataShape(buffer, imageMasks, Long.BYTES)) + .adjustTileSize(chunkSizeAdjust, chunkSizeMin); + if (response.ended()) { + return; + } + /* package data for client */ + final Map compressor = new HashMap<>(); + compressor.put("id", "zlib"); + compressor.put("level", deflateLevel); + final Map result = new HashMap<>(); + result.put("zarr_format", 2); + result.put("order", "C"); + result.put("shape", ImmutableList.of(shape.tSize, shape.cSize, shape.zSize, shape.ySize, shape.xSize)); + result.put("chunks", ImmutableList.of(1, 1, shape.zTile, shape.yTile, shape.xTile)); + result.put("fill_value", false); + result.put("dtype", ">i8"); + result.put("filters", null); + result.put("compressor", compressor); + respondWithJson(response, new JsonObject(result)); + } + + /** + * Constructs tiles on request. + * @author m.t.b.carroll@dundee.ac.uk + * @since v0.1.7 + */ + private interface TileGetter { + /** + * Construct the tile at the given position. + * @param x the leftmost extent of the tile + * @param y the topmost extent of the tile + * @param w the width of the tile + * @param h the height of the tile + * @return the tile + * @throws IOException from the OMERO pixel buffer + */ + byte[] getTile(int x, int y, int w, int h) throws IOException; + } + + /** + * Transfers pixel data from a tile into the given chunk array. + * @param chunk the chunk into which to write + * @param offset the byte offset from which to write + * @param tileGetter the source of pixel data for the tile + * @param shape the dimensionality of the chunk and the tile + * @param x the leftmost extent of the tile + * @param y the topmost extent of the tile + * @throws IOException from {@link TileGetter#getTile(int, int, int, int)} + */ + private static void getTileIntoChunk(byte[] chunk, int offset, TileGetter tileGetter, DataShape shape, int x, int y) + throws IOException { + if (x + shape.xTile > shape.xSize) { + /* a tile that crosses the right side of the image */ + final int xd = shape.xSize - x; + final int yd; + if (y + shape.yTile > shape.ySize) { + /* also crosses the bottom side */ + yd = shape.ySize - y; + } else { + /* does not cross the bottom side */ + yd = shape.yTile; + } + final byte[] chunkSrc = tileGetter.getTile(x, y, xd, yd); + /* must now assemble row-by-row into a plane in the chunk */ + for (int row = 0; row < yd; row++) { + final int srcIndex = row * xd * shape.byteWidth; + final int dstIndex = row * shape.xTile * shape.byteWidth + offset; + System.arraycopy(chunkSrc, srcIndex, chunk, dstIndex, xd * shape.byteWidth); + } + } else { + final int yd; + if (y + shape.yTile > shape.ySize) { + /* a tile that crosses the bottom of the image */ + yd = shape.ySize - y; + } else { + /* the tile fills a plane in the chunk */ + yd = shape.yTile; + } + final byte[] chunkSrc = tileGetter.getTile(x, y, shape.xTile, yd); + /* simply copy into the plane */ + System.arraycopy(chunkSrc, 0, chunk, offset, chunkSrc.length); + } + } + + /** + * Construct a tile getter that obtains pixel data from an OMERO pixel buffer. + * @param buffer the pixel buffer + * @param z the Z index of the plane from which to fetch pixel data + * @param c the C index of the plane from which to fetch pixel data + * @param t the T index of the plane from which to fetch pixel data + * @return a tile getter for that plane + */ + private TileGetter buildTileGetter(PixelBuffer buffer, int z, int c, int t) { + return new TileGetter() { + @Override + public byte[] getTile(int x, int y, int w, int h) throws IOException { + final PixelData tile = buffer.getTile(z, c, t, x, y, w, h); + final byte[] tileData = tile.getData().array(); + tile.dispose(); + return tileData; + } + }; + } + + /** + * Construct a tile getter for a split mask. + * @param isMasked the source of mask data + * @return a tile for the split mask, each pixel occupying one byte set to zero or non-zero + */ + private TileGetter buildTileGetter(BiPredicate isMasked) { + return new TileGetter() { + @Override + public byte[] getTile(int x, int y, int w, int h) { + final byte[] tile = new byte[w * h]; + int tilePosition = 0; + for (int yCurr = y; yCurr < y + h; yCurr++) { + for (int xCurr = x; xCurr < x + w; xCurr++) { + if (isMasked.test(xCurr, yCurr)) { + tile[tilePosition] = -1; + } + tilePosition++; + } + } + return tile; + } + }; + } + + /** + * Construct a tile getter for a labeled mask. + * @param isMasked the source of mask data + * @return a tile for the split mask, each pixel occupying {@link Long#BYTES} set to zero or the label + */ + private TileGetter buildTileGetter(Map> imageMasksByLabel) { + return new TileGetter() { + @Override + public byte[] getTile(int x, int y, int w, int h) { + final ByteBuffer tile = ByteBuffer.allocate(w * h * Long.BYTES); + int tilePosition = 0; + for (int yCurr = y; yCurr < y + h; yCurr++) { + for (int xCurr = x; xCurr < x + w; xCurr++) { + Long labelCurrent = null; + for (final Map.Entry> imageMaskWithLabel : + imageMasksByLabel.entrySet()) { + final long label = imageMaskWithLabel.getKey(); + final BiPredicate isMasked = imageMaskWithLabel.getValue(); + if (isMasked.test(xCurr, yCurr)) { + if (maskOverlapValue == null) { + labelCurrent = label; + break; + } if (labelCurrent == null) { + labelCurrent = label; + } else { + labelCurrent = maskOverlapValue; + break; + } + } + } + if (labelCurrent != null) { + tile.putLong(tilePosition, labelCurrent); + } + tilePosition += Long.BYTES; + } + } + return tile.array(); + } + }; + } + + /** + * Handle a request for a chunk of the pixel data of an image. * @param response the HTTP server response to populate * @param parameters the parameters of the request to handle */ - private void returnChunk(HttpServerResponse response, List parameters) { + private void returnImageChunk(HttpServerResponse response, List parameters) { /* parse parameters from path */ final long imageId = Long.parseLong(parameters.get(0)); final int resolution = Integer.parseInt(parameters.get(1)); @@ -829,41 +1673,8 @@ private void returnChunk(HttpServerResponse response, List parameters) { chunk = new byte[shape.xTile * shape.yTile * shape.zTile * shape.byteWidth]; for (int plane = 0; plane < shape.zTile && z + plane < shape.zSize; plane++) { final int planeOffset = plane * (chunk.length / shape.zTile); - final PixelData tile; - if (x + shape.xTile > shape.xSize) { - /* a tile that crosses the right side of the image */ - final int xd = shape.xSize - x; - final int yd; - if (y + shape.yTile > shape.ySize) { - /* also crosses the bottom side */ - yd = shape.ySize - y; - } else { - /* does not cross the bottom side */ - yd = shape.yTile; - } - tile = buffer.getTile(z + plane, c, t, x, y, xd, yd); - final byte[] chunkSrc = tile.getData().array(); - /* must now assemble row-by-row into a plane in the chunk */ - for (int row = 0; row < yd; row++) { - final int srcIndex = row * xd * shape.byteWidth; - final int dstIndex = row * shape.xTile * shape.byteWidth + planeOffset; - System.arraycopy(chunkSrc, srcIndex, chunk, dstIndex, xd * shape.byteWidth); - } - } else { - final int yd; - if (y + shape.yTile > shape.ySize) { - /* a tile that crosses the bottom of the image */ - yd = shape.ySize - y; - } else { - /* the tile fills a plane in the chunk */ - yd = shape.yTile; - } - tile = buffer.getTile(z + plane, c, t, x, y, shape.xTile, yd); - final byte[] chunkSrc = tile.getData().array(); - /* simply copy into the plane */ - System.arraycopy(chunkSrc, 0, chunk, planeOffset, chunkSrc.length); - } - tile.dispose(); + final TileGetter tileGetter = buildTileGetter(buffer, z + plane, c, t); + getTileIntoChunk(chunk, planeOffset, tileGetter, shape, x, y); } } catch (Exception e) { LOGGER.debug("pixel buffer failure", e); @@ -883,6 +1694,140 @@ private void returnChunk(HttpServerResponse response, List parameters) { response.end(chunkZipped); } + /** + * Handle a request for a chunk of the mask of an image. + * @param response the HTTP server response to populate + * @param parameters the parameters of the request to handle + */ + private void returnMaskChunk(HttpServerResponse response, List parameters) { + /* parse parameters from path */ + final long imageId = Long.parseLong(parameters.get(0)); + final long roiId = Long.parseLong(parameters.get(1)); + final List chunkId = getIndicesFromPath(parameters.get(2)); + if (chunkId.size() != 5) { + throw new IllegalArgumentException("chunks must have five dimensions"); + } + LOGGER.debug("providing chunk {} of Mask:{}", chunkId, roiId); + /* gather data */ + final Roi roi = omeroDao.getRoi(roiId); + if (roi == null || roi.getImage() == null || roi.getImage().getId() != imageId) { + throw new IllegalArgumentException("image has no such mask"); + } + final SortedSet maskIds = omeroDao.getMaskIdsOfRoi(roiId); + if (maskIds.isEmpty()) { + throw new IllegalArgumentException("image has no such mask"); + } + /* gather data */ + final byte[] chunk; + try { + final Pixels pixels = omeroDao.getPixels(imageId); + final Bitmask imageMask = UnionMask.union(maskIds.stream() + .map(omeroDao::getMask).map(ImageMask::new).collect(Collectors.toList())); + final DataShape shape = getDataFromPixels(response, pixels, buffer -> new DataShape(buffer, imageMask, 1)) + .adjustTileSize(chunkSizeAdjust, chunkSizeMin); + if (response.ended()) { + return; + } + final int x = shape.xTile * chunkId.get(4); + final int y = shape.yTile * chunkId.get(3); + final int z = shape.zTile * chunkId.get(2); + final int c = chunkId.get(1); + final int t = chunkId.get(0); + if (x >= shape.xSize || y >= shape.ySize || c >= shape.cSize || z >= shape.zSize || t >= shape.tSize) { + fail(response, 404, "no chunk with that index"); + return; + } + chunk = new byte[shape.xTile * shape.yTile * shape.zTile * shape.byteWidth]; + for (int plane = 0; plane < shape.zTile && z + plane < shape.zSize; plane++) { + final BiPredicate isMasked = imageMask.getMaskReader(z + plane, c, t); + if (isMasked != null) { + final int planeOffset = plane * (chunk.length / shape.zTile); + final TileGetter tileGetter = buildTileGetter(isMasked); + getTileIntoChunk(chunk, planeOffset, tileGetter, shape, x, y); + } + } + } catch (Exception e) { + LOGGER.debug("pixel buffer failure", e); + fail(response, 500, "query failed"); + return; + } + /* package data for client */ + final Buffer chunkZipped = compress(chunk); + final int responseSize = chunkZipped.length(); + LOGGER.debug("constructed binary response of size {}", responseSize); + response.putHeader("Content-Type", "application/octet-stream"); + response.putHeader("Content-Length", Integer.toString(responseSize)); + response.end(chunkZipped); + } + + /** + * Handle a request for a chunk of the labeled mask of an image. + * @param response the HTTP server response to populate + * @param parameters the parameters of the request to handle + */ + private void returnMaskLabeledChunk(HttpServerResponse response, List parameters) { + /* parse parameters from path */ + final long imageId = Long.parseLong(parameters.get(0)); + final List chunkId = getIndicesFromPath(parameters.get(1)); + if (chunkId.size() != 5) { + throw new IllegalArgumentException("chunks must have five dimensions"); + } + LOGGER.debug("providing chunk {} of labeled Mask of Image:{}", chunkId, imageId); + /* gather data */ + final Map labeledMasks = getLabeledMasks(imageId); + if (labeledMasks == null) { + fail(response, 404, "the image for that id does not have a labeled mask"); + return; + } + final byte[] chunk; + try { + final Pixels pixels = omeroDao.getPixels(imageId); + final Collection imageMasks = labeledMasks.values(); + final DataShape shape = getDataFromPixels(response, pixels, buffer -> new DataShape(buffer, imageMasks, Long.BYTES)) + .adjustTileSize(chunkSizeAdjust, chunkSizeMin); + if (response.ended()) { + return; + } + final int x = shape.xTile * chunkId.get(4); + final int y = shape.yTile * chunkId.get(3); + final int z = shape.zTile * chunkId.get(2); + final int c = chunkId.get(1); + final int t = chunkId.get(0); + if (x >= shape.xSize || y >= shape.ySize || c >= shape.cSize || z >= shape.zSize || t >= shape.tSize) { + fail(response, 404, "no chunk with that index"); + return; + } + chunk = new byte[shape.xTile * shape.yTile * shape.zTile * shape.byteWidth]; + for (int plane = 0; plane < shape.zTile && z + plane < shape.zSize; plane++) { + final Map> applicableMasks = new HashMap<>(); + for (final Map.Entry labeledMask : labeledMasks.entrySet()) { + final long label = labeledMask.getKey(); + final Bitmask mask = labeledMask.getValue(); + final BiPredicate isMasked = mask.getMaskReader(z + plane, c, t); + if (isMasked != null) { + applicableMasks.put(label, isMasked); + } + } + if (!applicableMasks.isEmpty()) { + final int planeOffset = plane * (chunk.length / shape.zTile); + final TileGetter tileGetter = buildTileGetter(applicableMasks); + getTileIntoChunk(chunk, planeOffset, tileGetter, shape, x, y); + } + } + } catch (Exception e) { + LOGGER.debug("pixel buffer failure", e); + fail(response, 500, "query failed"); + return; + } + /* package data for client */ + final Buffer chunkZipped = compress(chunk); + final int responseSize = chunkZipped.length(); + LOGGER.debug("constructed binary response of size {}", responseSize); + response.putHeader("Content-Type", "application/octet-stream"); + response.putHeader("Content-Length", Integer.toString(responseSize)); + response.end(chunkZipped); + } + /** * Compress the given byte array. * @param uncompressed a byte array diff --git a/src/main/java/org/openmicroscopy/ms/zarr/mask/Bitmask.java b/src/main/java/org/openmicroscopy/ms/zarr/mask/Bitmask.java new file mode 100644 index 0000000..3109a1c --- /dev/null +++ b/src/main/java/org/openmicroscopy/ms/zarr/mask/Bitmask.java @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2020 University of Dundee & Open Microscopy Environment. + * All rights reserved. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + */ + +package org.openmicroscopy.ms.zarr.mask; + +import java.util.function.BiPredicate; + +/** + * The most abstract kind of bitmask. + * @author m.t.b.carroll@dundee.ac.uk + * @since v0.1.7 + */ +public interface Bitmask { + /** + * @param dimension one of XYZCT + * @return if the value of this dimension may affect the result from any of the other methods + */ + boolean isSignificant(char dimension); + + /** + * @param z a Z plane + * @param c a C plane + * @param t a T plane + * @return an X, Y mask reader if the mask exists on the given plane, otherwise {@code null} + */ + BiPredicate getMaskReader(int z, int c, int t); + + /** + * @return an estimate of the memory consumption of this mask, to guide cache management + */ + int size(); +} diff --git a/src/main/java/org/openmicroscopy/ms/zarr/mask/ImageMask.java b/src/main/java/org/openmicroscopy/ms/zarr/mask/ImageMask.java new file mode 100644 index 0000000..3d2ec62 --- /dev/null +++ b/src/main/java/org/openmicroscopy/ms/zarr/mask/ImageMask.java @@ -0,0 +1,228 @@ +/* + * Copyright (C) 2020 University of Dundee & Open Microscopy Environment. + * All rights reserved. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + */ + +package org.openmicroscopy.ms.zarr.mask; + +import ome.model.roi.Mask; + +import java.awt.Rectangle; +import java.util.OptionalInt; +import java.util.function.BiPredicate; + +/** + * Represents a simple planar bitmask. + * @author m.t.b.carroll@dundee.ac.uk + * @since v0.1.7 + */ +public class ImageMask implements Bitmask { + + final OptionalInt z, c, t; + final Rectangle pos; + final byte[] bitmask; + + /** + * Copy a byte array. + * @param src a byte array + * @return a copy of the byte array + */ + private static byte[] copyBitmask(byte[] src) { + final byte[] dst = new byte[src.length]; + System.arraycopy(src, 0, dst, 0, src.length); + return dst; + } + + /** + * Construct an image mask. + * @param x the leftmost extent of the mask + * @param y the topmost extent of the mask + * @param width the width of the mask + * @param height the height of the mask + * @param z the Z plane of the mask, or {@code null} if it extends to all Z planes + * @param c the C plane of the mask, or {@code null} if it extends to all C planes + * @param t the T plane of the mask, or {@code null} if it extends to all T planes + * @param bitmask the actual bitmask, as packed bits ranging over X before Y + */ + public ImageMask(int x, int y, int width, int height, Integer z, Integer c, Integer t, byte[] bitmask) { + if (width < 1 || height < 1) { + throw new IllegalArgumentException("dimensions of mask must be strictly positive"); + } + if (bitmask == null) { + throw new IllegalArgumentException("bitmask cannot be null"); + } + if (bitmask.length != width * height + 7 >> 3) { + throw new IllegalArgumentException( + bitmask.length + "-byte mask not expected for " + width + '×' + height + " mask"); + } + this.pos = new Rectangle(x, y, width, height); + this.z = z == null ? OptionalInt.empty() : OptionalInt.of(z); + this.c = c == null ? OptionalInt.empty() : OptionalInt.of(c); + this.t = t == null ? OptionalInt.empty() : OptionalInt.of(t); + this.bitmask = bitmask; + } + + /** + * Construct an image mask from an OMERO mask. + * @param mask an OMERO mask + */ + public ImageMask(Mask mask) { + this(mask.getX().intValue(), mask.getY().intValue(), mask.getWidth().intValue(), mask.getHeight().intValue(), + mask.getTheZ(), mask.getTheC(), mask.getTheT(), copyBitmask(mask.getBytes())); + if (pos.x != mask.getX() || pos.y != mask.getY() || pos.width != mask.getWidth() || pos.height != mask.getHeight()) { + throw new IllegalArgumentException("mask position must be specified as integers"); + } + } + + /** + * Construct an image mask, populating the fields with exactly the given object instances, with no validation. + * @param pos the position of the mask + * @param z the optional Z plane of the mask + * @param c the optional C plane of the mask + * @param t the optional T plane of the mask + * @param bitmask the byte array to adopt as the bitmask + */ + ImageMask(Rectangle pos, OptionalInt z, OptionalInt c, OptionalInt t, byte[] bitmask) { + this.pos = pos; + this.z = z; + this.c = c; + this.t = t; + this.bitmask = bitmask; + } + + @Override + public boolean isSignificant(char dimension) { + switch (Character.toUpperCase(dimension)) { + case 'X': + return true; + case 'Y': + return true; + case 'Z': + return z.isPresent(); + case 'C': + return c.isPresent(); + case 'T': + return t.isPresent(); + default: + throw new IllegalArgumentException(); + } + } + + @Override + public BiPredicate getMaskReader(int z, int c, int t) { + if (this.z.isPresent() && this.z.getAsInt() != z || + this.c.isPresent() && this.c.getAsInt() != c || + this.t.isPresent() && this.t.getAsInt() != t) { + return null; + } + return new BiPredicate() { + @Override + public boolean test(Integer x, Integer y) { + if (!pos.contains(x, y)) { + return false; + } + final int bitPosition = (x - pos.x) + (y - pos.y) * pos.width; + final int bytePosition = bitPosition >> 3; + final int bitRemainder = 7 - (bitPosition & 7); + final int bitState = bitmask[bytePosition] & 1 << bitRemainder; + return bitState != 0; + } + }; + } + + @Override + public int size() { + return bitmask.length; + } + + /** + * Test if this mask overlaps another. + * @param mask a mask + * @return if this mask overlaps the given mask + */ + public boolean isOverlap(ImageMask mask) { + /* Same planes? */ + if (z.isPresent() && mask.z.isPresent() && z.getAsInt() != mask.z.getAsInt() || + c.isPresent() && mask.c.isPresent() && c.getAsInt() != mask.c.getAsInt() || + t.isPresent() && mask.t.isPresent() && t.getAsInt() != mask.t.getAsInt()) { + return false; + } + if (pos.equals(mask.pos)) { + /* If same position then the packing is aligned so compare byte-by-byte. */ + for (int index = 0; index < bitmask.length; index++) { + if ((bitmask[index] & mask.bitmask[index]) != 0) { + return true; + } + } + } else { + /* Different positions so check bit-by-bit. */ + final int z = this.z.orElse(mask.z.orElse(1)); + final int c = this.c.orElse(mask.c.orElse(1)); + final int t = this.t.orElse(mask.t.orElse(1)); + final BiPredicate isMasked1 = this.getMaskReader(z, c, t); + final BiPredicate isMasked2 = mask.getMaskReader(z, c, t); + final Rectangle intersection = pos.intersection(mask.pos); + for (int y = intersection.y; y < intersection.y + intersection.height; y++) { + for (int x = intersection.x; x < intersection.x + intersection.width; x++) { + if (isMasked1.test(x, y) && isMasked2.test(x, y)) { + return true; + } + } + } + } + return false; + } + + /** + * Calculate the union of this mask with another. + * @param mask a mask + * @return the union of the masks, or {@code null} if the union requires a mask larger than either of the pair + */ + public ImageMask union(ImageMask mask) { + if (z.equals(mask.z) && c.equals(mask.c) && t.equals(mask.t)) { + if (pos.contains(mask.pos)) { + /* Can use this mask's position for the union of the two. */ + final byte[] bitmask = copyBitmask(this.bitmask); + if (pos.equals(mask.pos)) { + /* If same position then the packing is aligned so combine byte-by-byte. */ + for (int index = 0; index < bitmask.length; index++) { + bitmask[index] |= mask.bitmask[index]; + } + } else { + /* Different positions so combine bit-by-bit. */ + final BiPredicate isMasked = mask.getMaskReader(z.orElse(1), c.orElse(1), t.orElse(1)); + for (int y = mask.pos.y; y < mask.pos.y + mask.pos.height; y++) { + for (int x = mask.pos.x; x < mask.pos.x + mask.pos.width; x++) { + if (isMasked.test(x, y)) { + final int bitPosition = (x - pos.x) + (y - pos.y) * pos.width; + final int bytePosition = bitPosition >> 3; + final int bitRemainder = 7 - (bitPosition & 7); + bitmask[bytePosition] |= 1 << bitRemainder; + } + } + } + } + return new ImageMask(pos, z, c, t, bitmask); + } else if (mask.pos.contains(pos)) { + /* Combine this mask onto the other. */ + return mask.union(this); + } + } + /* No efficient combination. */ + return null; + } +} diff --git a/src/main/java/org/openmicroscopy/ms/zarr/mask/UnionMask.java b/src/main/java/org/openmicroscopy/ms/zarr/mask/UnionMask.java new file mode 100644 index 0000000..966a2e3 --- /dev/null +++ b/src/main/java/org/openmicroscopy/ms/zarr/mask/UnionMask.java @@ -0,0 +1,140 @@ +/* + * Copyright (C) 2020 University of Dundee & Open Microscopy Environment. + * All rights reserved. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + */ + +package org.openmicroscopy.ms.zarr.mask; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Iterator; +import java.util.List; +import java.util.function.BiPredicate; +import java.util.stream.Collectors; + +/** + * Represents a mask that may combine multiple masks, + * as for the union of multiple {@link ome.model.roi.Mask}s in the same {@link ome.model.roi.Roi}. + * @author m.t.b.carroll@dundee.ac.uk + * @since v0.1.7 + */ +public class UnionMask implements Bitmask { + + final Collection masks; + + /** + * Construct a union mask from the given set of masks. + * @param masks the masks to combine + */ + UnionMask(Collection masks) { + this.masks = masks; + } + + @Override + public boolean isSignificant(char dimension) { + return masks.stream().anyMatch(mask -> mask.isSignificant(dimension)); + } + + @Override + public BiPredicate getMaskReader(int z, int c, int t) { + final List> applicableMasks = masks.stream() + .map(mask -> mask.getMaskReader(z, c, t)).filter(obj -> obj != null).collect(Collectors.toList()); + return applicableMasks.isEmpty() ? null : (x, y) -> applicableMasks.stream().anyMatch(p -> p.test(x, y)); + } + + @Override + public int size() { + return masks.stream().map(Bitmask::size).reduce(0, Math::addExact); + } + + /** + * Test if this mask overlaps another. + * @param mask a mask + * @return if this mask overlaps the given mask + */ + public boolean isOverlap(ImageMask mask) { + for (final Bitmask myMask : masks) { + if (myMask instanceof ImageMask) { + if (mask.isOverlap((ImageMask) myMask)) { + return true; + } + } else if (myMask instanceof UnionMask) { + if (((UnionMask) myMask).isOverlap(mask)) { + return true; + } + } else { + throw new IllegalStateException(); + } + } + return false; + } + + /** + * Test if this mask overlaps another. + * @param mask a mask + * @return if this mask overlaps the given mask + */ + public boolean isOverlap(UnionMask mask) { + for (final Bitmask myMask : masks) { + if (myMask instanceof ImageMask) { + if (mask.isOverlap((ImageMask) myMask)) { + return true; + } + } else if (myMask instanceof UnionMask) { + if (((UnionMask) myMask).isOverlap(mask)) { + return true; + } + } else { + throw new IllegalStateException(); + } + } + return false; + } + + /** + * Calculate the union of a set of masks. + * @param masks some masks + * @return the union of the masks + */ + public static Bitmask union(Iterable masks) { + final List masksAbstract = new ArrayList<>(); + final List masksConcrete = new ArrayList<>(); + for (final Bitmask maskNewAbstract : masks) { + if (maskNewAbstract instanceof ImageMask) { + /* Where possible, combine masks into a single new one. */ + ImageMask maskNewConcrete = (ImageMask) maskNewAbstract; + final Iterator masksConcreteIter = masksConcrete.iterator(); + while (masksConcreteIter.hasNext()) { + final ImageMask maskOldConcrete = masksConcreteIter.next(); + final ImageMask maskUnion = maskOldConcrete.union(maskNewConcrete); + if (maskUnion != null) { + masksConcreteIter.remove(); + maskNewConcrete = maskUnion; + } + } + masksConcrete.add(maskNewConcrete); + } else { + masksAbstract.add(maskNewAbstract); + } + } + final int maskCount = masksAbstract.size() + masksConcrete.size(); + final List union = new ArrayList<>(maskCount); + union.addAll(masksAbstract); + union.addAll(masksConcrete); + return maskCount == 1 ? union.get(0) : new UnionMask(union); + } +} diff --git a/src/scripts/copy-masks.py b/src/scripts/copy-masks.py new file mode 100755 index 0000000..2bcb199 --- /dev/null +++ b/src/scripts/copy-masks.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python + +# Copyright (C) 2020 University of Dundee & Open Microscopy Environment. +# All rights reserved. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +# Copy masks from one server to another. +# author: m.t.b.carroll@dundee.ac.uk + +from omero.gateway import BlitzGateway +from omero.model import ImageI, MaskI, RoiI +from omero.rtypes import rstring +from omero.sys import ParametersI + +import argparse + +parser = argparse.ArgumentParser( + description="copy masks from an image on one server to another" +) +parser.add_argument("--from-host", default="idr.openmicroscopy.org") +parser.add_argument("--from-user", default="public") +parser.add_argument("--from-pass", default="public") +parser.add_argument("--to-host", default="localhost") +parser.add_argument("--to-user", default="root") +parser.add_argument("--to-pass", default="omero") +parser.add_argument("source_image", type=int, help="input image") +parser.add_argument("target_image", type=int, help="output image") +ns = parser.parse_args() + +image_id_src = ns.source_image +image_id_dst = ns.target_image + +idr = BlitzGateway(ns.from_user, ns.from_pass, host=ns.from_host, secure=True) +local = BlitzGateway(ns.to_user, ns.to_pass, host=ns.to_host, secure=True) + +idr.connect() +local.connect() + +query_service = idr.getQueryService() +update_service = local.getUpdateService() + +query = "FROM Mask WHERE roi.image.id = :id" + +params = ParametersI() +params.addId(image_id_src) + +count = 0 + +for mask_src in query_service.findAllByQuery(query, params): + mask_dst = MaskI() + mask_dst.x = mask_src.x + mask_dst.y = mask_src.y + mask_dst.width = mask_src.width + mask_dst.height = mask_src.height + mask_dst.theZ = mask_src.theZ + mask_dst.theC = mask_src.theC + mask_dst.theT = mask_src.theT + mask_dst.bytes = mask_src.bytes + mask_dst.fillColor = mask_src.fillColor + mask_dst.transform = mask_src.transform + roi_dst = RoiI() + roi_dst.description = rstring( + "created by copy-masks script for original mask #{}".format( + mask_src.id.val + ) + ) + roi_dst.image = ImageI(image_id_dst, False) + roi_dst.addShape(mask_dst) + update_service.saveObject(roi_dst) + count += 1 + +idr._closeSession() +local._closeSession() + +print( + "from image #{} to #{}, mask count = {}".format( + image_id_src, image_id_dst, count + ) +) diff --git a/src/scripts/overlapping-masks.py b/src/scripts/overlapping-masks.py new file mode 100755 index 0000000..2f22980 --- /dev/null +++ b/src/scripts/overlapping-masks.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python + +# Copyright (C) 2020 University of Dundee & Open Microscopy Environment. +# All rights reserved. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +# Generate overlapping masks on an existing image. +# author: m.t.b.carroll@dundee.ac.uk + +from omero.gateway import BlitzGateway +from omero.model import MaskI, RoiI +from omero.rtypes import rstring, rdouble +from omero.sys import ParametersI + +import numpy as np + +import argparse + +parser = argparse.ArgumentParser( + description="generate fake masks that overlap" +) +parser.add_argument("--to-host", default="localhost") +parser.add_argument("--to-user", default="root") +parser.add_argument("--to-pass", default="omero") +parser.add_argument("target_image", type=int, help="output image") +ns = parser.parse_args() + +image_id_dst = ns.target_image + +local = BlitzGateway(ns.to_user, ns.to_pass, host=ns.to_host, secure=True) + +local.connect() + +query_service = local.getQueryService() +update_service = local.getUpdateService() + +query = "FROM Image WHERE id = :id" + +params = ParametersI() +params.addId(image_id_dst) + +count = 0 +image = query_service.findByQuery(query, params) + + +def make_circle(h, w): + x = np.arange(0, w) + y = np.arange(0, h) + arr = np.zeros((y.size, x.size), dtype=bool) + + cx = w // 2 + cy = h // 2 + r = min(w, h) // 2 + + mask = (x[np.newaxis, :] - cx) ** 2 + (y[:, np.newaxis] - cy) ** 2 < r ** 2 + arr[mask] = 1 + arr = np.packbits(arr) + return arr + + +def make_mask(x, y, h, w): + mask = MaskI() + mask.x = rdouble(x) + mask.y = rdouble(y) + mask.height = rdouble(h) + mask.width = rdouble(w) + mask.bytes = make_circle(h, w) + + roi = RoiI() + roi.description = rstring("created by overlapping-masks.py") + roi.addShape(mask) + roi.image = image + roi = update_service.saveAndReturnObject(roi) + print(f"Roi:{roi.id.val}") + + +make_mask(20, 20, 40, 40) +make_mask(30, 30, 50, 50) +local._closeSession() diff --git a/src/test/java/org/openmicroscopy/ms/zarr/ZarrBinaryDataTest.java b/src/test/java/org/openmicroscopy/ms/zarr/ZarrBinaryImageTest.java similarity index 83% rename from src/test/java/org/openmicroscopy/ms/zarr/ZarrBinaryDataTest.java rename to src/test/java/org/openmicroscopy/ms/zarr/ZarrBinaryImageTest.java index 71ab7fe..ea55e0b 100644 --- a/src/test/java/org/openmicroscopy/ms/zarr/ZarrBinaryDataTest.java +++ b/src/test/java/org/openmicroscopy/ms/zarr/ZarrBinaryImageTest.java @@ -21,9 +21,7 @@ import java.io.IOException; import java.util.zip.DataFormatException; -import java.util.zip.Inflater; -import io.vertx.core.buffer.Buffer; import io.vertx.core.json.JsonArray; import io.vertx.core.json.JsonObject; @@ -37,28 +35,7 @@ * Check that the binary data served from the microservice endpoints has the expected pixel values. * @author m.t.b.carroll@dundee.ac.uk */ -public class ZarrBinaryDataTest extends ZarrEndpointsTestBase { - - /** - * Uncompress the given byte array. - * @param compressed a byte array - * @return the uncompressed bytes - * @throws DataFormatException unexpected - */ - private static byte[] uncompress(byte[] compressed) throws DataFormatException { - final Inflater inflater = new Inflater(); - inflater.setInput(compressed); - final Buffer uncompressed = Buffer.factory.buffer(2 * compressed.length); - final byte[] batch = new byte[8192]; - int batchSize; - do { - batchSize = inflater.inflate(batch); - uncompressed.appendBytes(batch, 0, batchSize); - } while (batchSize > 0); - Assertions.assertFalse(inflater.needsDictionary()); - inflater.end(); - return uncompressed.getBytes(); - } +public class ZarrBinaryImageTest extends ZarrEndpointsImageTestBase { boolean isSomeChunkFitsWithin; boolean isSomeChunkOverlapsRight; @@ -110,12 +87,15 @@ private void assessChunkType(int sizeX, int sizeY, int x, int y, int w, int h) { @MethodSource("provideGroupDetails") public void testZarrChunks(int resolution, String path, Double scale) throws DataFormatException, IOException { final JsonObject response = getResponseAsJson(0, path, ".zarray"); + final JsonArray shape = response.getJsonArray("shape"); + final int imageSizeX = shape.getInteger(4); + final int imageSizeY = shape.getInteger(3); + pixelBuffer.setResolutionLevel(resolution); + Assertions.assertEquals(pixelBuffer.getSizeX(), imageSizeX); + Assertions.assertEquals(pixelBuffer.getSizeY(), imageSizeY); final JsonArray chunks = response.getJsonArray("chunks"); final int chunkSizeX = chunks.getInteger(4); final int chunkSizeY = chunks.getInteger(3); - pixelBuffer.setResolutionLevel(resolution); - final int imageSizeX = pixelBuffer.getSizeX(); - final int imageSizeY = pixelBuffer.getSizeY(); int chunkIndexY = 0; for (int y = 0; y < imageSizeY; y += chunkSizeY) { int chunkIndexX = 0; diff --git a/src/test/java/org/openmicroscopy/ms/zarr/ZarrBinaryMaskTest.java b/src/test/java/org/openmicroscopy/ms/zarr/ZarrBinaryMaskTest.java new file mode 100644 index 0000000..fc19c78 --- /dev/null +++ b/src/test/java/org/openmicroscopy/ms/zarr/ZarrBinaryMaskTest.java @@ -0,0 +1,219 @@ +/* + * Copyright (C) 2020 University of Dundee & Open Microscopy Environment. + * All rights reserved. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + */ + +package org.openmicroscopy.ms.zarr; + +import org.openmicroscopy.ms.zarr.mask.ImageMask; + +import ome.model.core.Image; +import ome.model.core.Pixels; +import ome.model.roi.Mask; +import ome.model.roi.Roi; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; +import java.util.function.BiPredicate; +import java.util.zip.DataFormatException; + +import com.google.common.collect.ImmutableSortedSet; + +import io.vertx.core.json.JsonArray; +import io.vertx.core.json.JsonObject; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import org.mockito.Mockito; + +/** + * Check that the binary data served from the microservice endpoints has the expected mask values. + * @author m.t.b.carroll@dundee.ac.uk + * @since v0.1.7 + */ +public class ZarrBinaryMaskTest extends ZarrEndpointsTestBase { + + private Image image; + private Roi roi1, roi2; + private Mask mask1, mask2, mask3; + private BiPredicate isMasked1, isMasked2, isMasked3; + + /** + * Create an test image with masks. + */ + @BeforeAll + private void maskSetup() { + pixelBuffer.setResolutionLevel(pixelBuffer.getResolutionLevels() - 1); + final int size = (int) Math.sqrt(pixelBuffer.getSizeX() * pixelBuffer.getSizeY() >> 3) & -4; + final byte[] bitmask1 = new byte[size * size]; + final byte[] bitmask2 = new byte[size * size >> 2]; + final byte[] bitmask3 = new byte[size * size >> 2]; + int factor = 3; + for (final byte[] bitmask : new byte[][] {bitmask1, bitmask2, bitmask3}) { + for (int index = 0; index < bitmask.length; index += factor) { + bitmask[index] = -1; + } + factor += 2; + } + long id = 0; + mask1 = new Mask(++id, true); + mask1.setX(0.0); + mask1.setY(0.0); + mask1.setWidth((double) (size << 1)); + mask1.setHeight((double) (size << 2)); + mask1.setBytes(bitmask1); + mask2 = new Mask(++id, true); + mask2.setX(0.0); + mask2.setY(0.0); + mask2.setWidth((double) (size << 1)); + mask2.setHeight((double) (size)); + mask2.setBytes(bitmask2); + mask3 = new Mask(++id, true); + mask3.setX(mask1.getWidth() / 2); + mask3.setY(mask1.getHeight() / 2); + mask3.setWidth((double) (size)); + mask3.setHeight((double) (size << 1)); + mask3.setBytes(bitmask3); + roi1 = new Roi(++id, true); + roi2 = new Roi(++id, true); + image = new Image(++id, true); + roi1.addShape(mask1); + roi1.addShape(mask2); + roi2.addShape(mask3); + image.addRoiSet(Arrays.asList(roi1, roi2)); + isMasked1 = new ImageMask(mask1).getMaskReader(1, 1, 1); + isMasked2 = new ImageMask(mask2).getMaskReader(1, 1, 1); + isMasked3 = new ImageMask(mask3).getMaskReader(1, 1, 1); + } + + @Override + protected OmeroDao daoSetup() { + final Pixels pixels = constructMockPixels(); + final OmeroDao dao = Mockito.mock(OmeroDao.class); + Mockito.when(dao.getPixels(Mockito.eq(image.getId()))).thenReturn(pixels); + Mockito.when(dao.getMask(Mockito.eq(mask1.getId()))).thenReturn(mask1); + Mockito.when(dao.getMask(Mockito.eq(mask2.getId()))).thenReturn(mask2); + Mockito.when(dao.getMask(Mockito.eq(mask3.getId()))).thenReturn(mask3); + Mockito.when(dao.getRoi(Mockito.eq(roi1.getId()))).thenReturn(roi1); + Mockito.when(dao.getRoi(Mockito.eq(roi2.getId()))).thenReturn(roi2); + Mockito.when(dao.getMaskCountOfRoi(Mockito.eq(roi1.getId()))).thenReturn((long) roi1.sizeOfShapes()); + Mockito.when(dao.getMaskCountOfRoi(Mockito.eq(roi2.getId()))).thenReturn((long) roi2.sizeOfShapes()); + Mockito.when(dao.getRoiCountOfImage(Mockito.eq(image.getId()))).thenReturn((long) image.sizeOfRois()); + Mockito.when(dao.getMaskIdsOfRoi(Mockito.eq(roi1.getId()))).thenReturn(ImmutableSortedSet.of(mask1.getId(), mask2.getId())); + Mockito.when(dao.getMaskIdsOfRoi(Mockito.eq(roi2.getId()))).thenReturn(ImmutableSortedSet.of(mask3.getId())); + Mockito.when(dao.getRoiIdsOfImage(Mockito.eq(image.getId()))).thenReturn(ImmutableSortedSet.of(roi1.getId(), roi2.getId())); + Mockito.when(dao.getRoiIdsWithMaskOfImage(Mockito.eq(image.getId()))) + .thenReturn(ImmutableSortedSet.of(roi1.getId(), roi2.getId())); + return dao; + } + + /** + * Check that the chunks for a split mask from the microservice are as expected. + * @throws DataFormatException unexpected + * @throws IOException unexpected + */ + @Test + public void testMaskChunks() throws DataFormatException, IOException { + final Set seenIsMasked = new HashSet<>(); + final JsonObject response = getResponseAsJson(image.getId(), "masks", roi1.getId(), ".zarray"); + final JsonArray shape = response.getJsonArray("shape"); + final int maskSizeX = shape.getInteger(4); + final int maskSizeY = shape.getInteger(3); + Assertions.assertEquals(pixelBuffer.getSizeX(), maskSizeX); + Assertions.assertEquals(pixelBuffer.getSizeY(), maskSizeY); + final JsonArray chunks = response.getJsonArray("chunks"); + final int chunkSizeX = chunks.getInteger(4); + final int chunkSizeY = chunks.getInteger(3); + int chunkIndexY = 0; + for (int y = 0; y < maskSizeY; y += chunkSizeY) { + int chunkIndexX = 0; + for (int x = 0; x < maskSizeX; x += chunkSizeX) { + mockSetup(); + final byte[] chunkZipped = + getResponseAsBytes(image.getId(), "masks", roi1.getId(), 0, 0, 0, chunkIndexY, chunkIndexX); + final byte[] chunk = uncompress(chunkZipped); + for (int cx = 0; cx < chunkSizeX; cx++) { + for (int cy = 0; cy < chunkSizeY; cy++) { + final boolean isMasked = isMasked1.test(x + cx, y + cy) || isMasked2.test(x + cx, y + cy); + final int index = chunkSizeX * cy + cx; + if (isMasked) { + Assertions.assertNotEquals(0, chunk[index]); + } else { + Assertions.assertEquals(0, chunk[index]); + } + seenIsMasked.add(chunk[index]); + } + } + chunkIndexX++; + } + chunkIndexY++; + } + Assertions.assertEquals(2, seenIsMasked.size()); + } + + /** + * Check that the chunks for a labeled mask from the microservice are as expected. + * @throws DataFormatException unexpected + * @throws IOException unexpected + */ + @Test + public void testLabeledMaskChunks() throws DataFormatException, IOException { + final Set seenLabels = new HashSet<>(); + final JsonObject response = getResponseAsJson(image.getId(), "masks", "labeled", ".zarray"); + final JsonArray shape = response.getJsonArray("shape"); + final int maskSizeX = shape.getInteger(4); + final int maskSizeY = shape.getInteger(3); + Assertions.assertEquals(pixelBuffer.getSizeX(), maskSizeX); + Assertions.assertEquals(pixelBuffer.getSizeY(), maskSizeY); + final JsonArray chunks = response.getJsonArray("chunks"); + final int chunkSizeX = chunks.getInteger(4); + final int chunkSizeY = chunks.getInteger(3); + int chunkIndexY = 0; + for (int y = 0; y < maskSizeY; y += chunkSizeY) { + int chunkIndexX = 0; + for (int x = 0; x < maskSizeX; x += chunkSizeX) { + mockSetup(); + final byte[] chunkZipped = getResponseAsBytes(image.getId(), "masks", "labeled", 0, 0, 0, chunkIndexY, chunkIndexX); + final ByteBuffer chunk = ByteBuffer.wrap(uncompress(chunkZipped)); + for (int cx = 0; cx < chunkSizeX; cx++) { + for (int cy = 0; cy < chunkSizeY; cy++) { + final boolean isRoi1 = isMasked1.test(x + cx, y + cy) || isMasked2.test(x + cx, y + cy); + final boolean isRoi2 = isMasked3.test(x + cx, y + cy); + final long expectedLabel; + if (isRoi1) { + expectedLabel = isRoi2 ? ZarrEndpointsTestBase.MASK_OVERLAP_VALUE : roi1.getId(); + } else { + expectedLabel = isRoi2 ? roi2.getId() : 0; + } + final int index = Long.BYTES * (chunkSizeX * cy + cx); + final long actualLabel = chunk.getLong(index); + Assertions.assertEquals(expectedLabel, actualLabel); + seenLabels.add(actualLabel); + } + } + chunkIndexX++; + } + chunkIndexY++; + } + Assertions.assertEquals(4, seenLabels.size()); + } +} diff --git a/src/test/java/org/openmicroscopy/ms/zarr/ZarrEndpointsImageTestBase.java b/src/test/java/org/openmicroscopy/ms/zarr/ZarrEndpointsImageTestBase.java new file mode 100644 index 0000000..40e229b --- /dev/null +++ b/src/test/java/org/openmicroscopy/ms/zarr/ZarrEndpointsImageTestBase.java @@ -0,0 +1,50 @@ +/* + * Copyright (C) 2020 University of Dundee & Open Microscopy Environment. + * All rights reserved. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + */ + +package org.openmicroscopy.ms.zarr; + +import ome.model.core.Pixels; + +import org.hibernate.Query; +import org.hibernate.SessionFactory; +import org.hibernate.classic.Session; + +import org.mockito.Mockito; +import org.openmicroscopy.ms.zarr.OmeroDao; + +/** + * Base class that sets up a simple DAO that always fetches the mock pixels object. + * @author m.t.b.carroll@dundee.ac.uk + * @since v0.1.7 + */ +public abstract class ZarrEndpointsImageTestBase extends ZarrEndpointsTestBase { + + @Override + protected OmeroDao daoSetup() { + final Pixels pixels = constructMockPixels(); + final Query query = Mockito.mock(Query.class); + final Session session = Mockito.mock(Session.class); + final SessionFactory sessionFactory = Mockito.mock(SessionFactory.class); + Mockito.when(query.uniqueResult()).thenReturn(pixels); + Mockito.when(query.setParameter(Mockito.eq(0), Mockito.anyLong())).thenReturn(query); + Mockito.when(session.createQuery(Mockito.anyString())).thenReturn(query); + Mockito.when(sessionFactory.openSession()).thenReturn(session); + return new OmeroDao(sessionFactory); + } +} diff --git a/src/test/java/org/openmicroscopy/ms/zarr/ZarrEndpointsTestBase.java b/src/test/java/org/openmicroscopy/ms/zarr/ZarrEndpointsTestBase.java index 8a6d8dd..cdc4f8d 100644 --- a/src/test/java/org/openmicroscopy/ms/zarr/ZarrEndpointsTestBase.java +++ b/src/test/java/org/openmicroscopy/ms/zarr/ZarrEndpointsTestBase.java @@ -41,6 +41,8 @@ import java.util.Collections; import java.util.Map; import java.util.stream.Stream; +import java.util.zip.DataFormatException; +import java.util.zip.Inflater; import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableSet; @@ -53,10 +55,6 @@ import io.vertx.core.json.JsonObject; import io.vertx.ext.web.Router; -import org.hibernate.Query; -import org.hibernate.SessionFactory; -import org.hibernate.classic.Session; - import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.TestInstance; @@ -77,16 +75,9 @@ public abstract class ZarrEndpointsTestBase { protected static final String MEDIA_TYPE_BINARY = "application/octet-stream"; protected static final String MEDIA_TYPE_JSON = "application/json; charset=utf-8"; - protected static final String URI_PATH_PREFIX = "test"; - - @Mock - private Query query; - - @Mock - private Session sessionMock; + protected static final long MASK_OVERLAP_VALUE = -1; - @Mock - private SessionFactory sessionFactoryMock; + protected static final String URI_PATH_PREFIX = "test"; protected PixelBuffer pixelBuffer = new PixelBufferFake(); @@ -110,7 +101,7 @@ public abstract class ZarrEndpointsTestBase { * Set up a mock pixels object from which to source OMERO metadata. * @return a mock pixels object */ - private Pixels constructMockPixels() { + protected Pixels constructMockPixels() { /* Create skeleton objects. */ final Channel channel1 = new Channel(1L, true); final Channel channel2 = new Channel(2L, true); @@ -184,24 +175,27 @@ private Pixels constructMockPixels() { @BeforeEach protected void mockSetup() throws IOException { MockitoAnnotations.initMocks(this); - final Pixels pixels = constructMockPixels(); - Mockito.when(query.uniqueResult()).thenReturn(pixels); - Mockito.when(query.setParameter(Mockito.eq(0), Mockito.anyLong())).thenReturn(query); - Mockito.when(sessionMock.createQuery(Mockito.anyString())).thenReturn(query); - Mockito.when(sessionFactoryMock.openSession()).thenReturn(sessionMock); Mockito.when(pixelsServiceMock.getPixelBuffer(Mockito.any(Pixels.class), Mockito.eq(false))).thenReturn(pixelBuffer); Mockito.when(httpRequest.method()).thenReturn(HttpMethod.GET); Mockito.when(httpRequest.response()).thenReturn(httpResponse); final String URI = URI_PATH_PREFIX + '/' + Configuration.PLACEHOLDER_IMAGE_ID + '/'; - final Map settings = ImmutableMap.of(Configuration.CONF_NET_PATH_IMAGE, URI); + final Map settings = ImmutableMap.of( + Configuration.CONF_MASK_OVERLAP_VALUE, Long.toString(MASK_OVERLAP_VALUE), + Configuration.CONF_MASK_SPLIT_ENABLE, Boolean.toString(true), // Non-default to enable tests + Configuration.CONF_NET_PATH_IMAGE, URI); final Configuration configuration = new Configuration(settings); - final OmeroDao dao = new OmeroDao(sessionFactoryMock); + final OmeroDao dao = daoSetup(); final PixelBufferCache cache = new PixelBufferCache(configuration, pixelsServiceMock, dao); final HttpHandler handler = new RequestHandlerForImage(configuration, pixelsServiceMock, cache, dao); router = new RouterFake(); handler.handleFor(router); } + /** + * @return the DAO to be used by {@link #mockSetup()} + */ + protected abstract OmeroDao daoSetup(); + /** * Construct an endpoint URI for the given query arguments. * @param query the query arguments for the microservice @@ -274,4 +268,25 @@ protected Stream provideGroupDetails() throws IOException { } return details.build(); } + + /** + * Uncompress the given byte array. + * @param compressed a byte array + * @return the uncompressed bytes + * @throws DataFormatException unexpected + */ + protected static byte[] uncompress(byte[] compressed) throws DataFormatException { + final Inflater inflater = new Inflater(); + inflater.setInput(compressed); + final Buffer uncompressed = Buffer.factory.buffer(2 * compressed.length); + final byte[] batch = new byte[8192]; + int batchSize; + do { + batchSize = inflater.inflate(batch); + uncompressed.appendBytes(batch, 0, batchSize); + } while (batchSize > 0); + Assertions.assertFalse(inflater.needsDictionary()); + inflater.end(); + return uncompressed.getBytes(); + } } diff --git a/src/test/java/org/openmicroscopy/ms/zarr/ZarrMetadataTest.java b/src/test/java/org/openmicroscopy/ms/zarr/ZarrMetadataTest.java index f9c3233..fd939f3 100644 --- a/src/test/java/org/openmicroscopy/ms/zarr/ZarrMetadataTest.java +++ b/src/test/java/org/openmicroscopy/ms/zarr/ZarrMetadataTest.java @@ -44,7 +44,7 @@ * Check that the metadata served from the microservice endpoints are of the expected form. * @author m.t.b.carroll@dundee.ac.uk */ -public class ZarrMetadataTest extends ZarrEndpointsTestBase { +public class ZarrMetadataTest extends ZarrEndpointsImageTestBase { private final List resolutionSizes = new ArrayList<>(); diff --git a/src/test/java/org/openmicroscopy/ms/zarr/mask/TestUnionMasks.java b/src/test/java/org/openmicroscopy/ms/zarr/mask/TestUnionMasks.java new file mode 100644 index 0000000..c3eff65 --- /dev/null +++ b/src/test/java/org/openmicroscopy/ms/zarr/mask/TestUnionMasks.java @@ -0,0 +1,413 @@ +/* + * Copyright (C) 2020 University of Dundee & Open Microscopy Environment. + * All rights reserved. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + */ + +package org.openmicroscopy.ms.zarr.mask; + +import java.awt.Dimension; +import java.awt.Rectangle; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.function.BiPredicate; +import java.util.stream.Stream; + +import com.google.common.collect.HashMultimap; +import com.google.common.collect.Lists; +import com.google.common.collect.Multimap; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +/** + * Test that unions of bitmasks work correctly. + * @author m.t.b.carroll@dundee.ac.uk + * @since v0.1.7 + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +public class TestUnionMasks { + + private static BiPredicate ALWAYS_NOTHING = getMaskPredicate(new Rectangle(), 0, 0); + + private final Set maskCountsObserved = new HashSet<>(); + private final Multimap dimensionSignificancesObserved = HashMultimap.create(); + + /** + * Construct a mask predicate that has pixels set at a regular interval. + * @param pos the position of the mask + * @param xInterval the horizontal distance from one set pixel to the next + * @param yInterval the vertical distance from one set pixel to the next + * @return a new mask predicate + */ + private static BiPredicate getMaskPredicate(Rectangle pos, int xInterval, int yInterval) { + return new BiPredicate() { + @Override + public boolean test(Integer x, Integer y) { + return pos.contains(x, y) && (x - pos.x) % xInterval == 0 && (y - pos.y) % yInterval == 0; + } + }; + } + + /** + * Construct a mask whose bitmask is set according to a predicate. + * @param pos the position of the mask + * @param z the Z plane of the mask, or {@code null} if it extends to all Z planes + * @param c the C plane of the mask, or {@code null} if it extends to all C planes + * @param t the T plane of the mask, or {@code null} if it extends to all T planes + * @param maskPredicate the predicate defining the mask's pixel data + * @return a new mask + */ + private static ImageMask constructMask(Rectangle pos, Integer z, Integer c, Integer t, + BiPredicate maskPredicate) { + final byte[] bitmask = new byte[pos.width * pos.height + 7 >> 3]; + for (int y = pos.y; y < pos.y + pos.height; y++) { + for (int x = pos.x; x < pos.x + pos.width; x++) { + if (maskPredicate.test(x, y)) { + final int bitPosition = (x - pos.x) + (y - pos.y) * pos.width; + final int bytePosition = bitPosition >> 3; + final int bitRemainder = 7 - (bitPosition & 7); + bitmask[bytePosition] |= 1 << bitRemainder; + } + } + } + return new ImageMask(pos.x, pos.y, pos.width, pos.height, z, c, t, bitmask); + } + + /** + * Test the union of three masks. + * @param x1 the X offset of the second mask with respect to the first + * @param z1 the Z plane of the second mask, or {@code null} if it extends to all Z planes + * @param c1 the C plane of the second mask, or {@code null} if it extends to all C planes + * @param t1 the T plane of the second mask, or {@code null} if it extends to all T planes + * @param x2 the X offset of the third mask with respect to the first + * @param z2 the Z plane of the third mask, or {@code null} if it extends to all Z planes + * @param c2 the C plane of the third mask, or {@code null} if it extends to all C planes + * @param t2 the T plane of the third mask, or {@code null} if it extends to all T planes + * @param isReverse if the mask union operation should be performed in the order of third, second, first + */ + @ParameterizedTest + @MethodSource("provideMaskPlacements") + public void testCombiningMasks(int x1, Integer z1, Integer c1, Integer t1, int x2, Integer z2, Integer c2, Integer t2, + boolean isReverse) { + /* Define the three masks. */ + final Rectangle pos0 = new Rectangle(0, 0, 80, 80); + final Rectangle pos1 = new Rectangle(x1, 20, 35, 35); + final Rectangle pos2 = new Rectangle(x2, 30, 40, 25); + final BiPredicate isMasked0 = getMaskPredicate(pos0, 11, 13); + final BiPredicate isMasked1 = getMaskPredicate(pos1, 3, 7); + final BiPredicate isMasked2 = getMaskPredicate(pos2, 5, 9); + final Bitmask mask0 = constructMask(pos0, null, null, null, isMasked0); + final Bitmask mask1 = constructMask(pos1, z1, c1, t1, isMasked1); + final Bitmask mask2 = constructMask(pos2, z2, c2, t2, isMasked2); + /* Determine how many masks should be required for the union. */ + int expectedMaskCount = 3; + if (z1 == null && c1 == null && t1 == null && pos0.contains(pos1)) { + expectedMaskCount--; + } + if (z2 == null && c2 == null && t2 == null && pos0.contains(pos2)) { + expectedMaskCount--; + } + /* Determine the union mask. */ + final List masks = Lists.newArrayList(mask0, mask1, mask2); + if (isReverse) { + Collections.reverse(masks); + } + final Bitmask unionMask = UnionMask.union(masks); + /* Check that the union mask has the expected properties. */ + if (expectedMaskCount == 1) { + /* Should be just as the first mask except in pixel data. */ + Assertions.assertTrue(unionMask instanceof ImageMask); + Assertions.assertEquals(pos0, ((ImageMask) unionMask).pos); + Assertions.assertEquals(mask0.size(), unionMask.size()); + } else { + /* Should be larger than the first mask. */ + Assertions.assertTrue(unionMask instanceof UnionMask); + Assertions.assertEquals(expectedMaskCount, ((UnionMask) unionMask).masks.size()); + Assertions.assertTrue(mask0.size() < unionMask.size()); + } + /* Check that the significance of dimensions is accurately reported so that space can be saved. */ + Assertions.assertNotEquals(z1 == null && z2 == null, unionMask.isSignificant('Z')); + Assertions.assertNotEquals(c1 == null && c2 == null, unionMask.isSignificant('C')); + Assertions.assertNotEquals(t1 == null && t2 == null, unionMask.isSignificant('T')); + /* Check that the pixel data is as one would expect from a union of the masks. */ + final Rectangle pos012 = pos0.union(pos1.union(pos2)); + for (int t = 0; t < 2; t++) { + for (int c = 0; c < 2; c++) { + for (int z = 0; z < 2; z++) { + final boolean isMask1 = (z1 == null || z1 == z) && (c1 == null || c1 == c) && (t1 == null || t1 == t); + final boolean isMask2 = (z2 == null || z2 == z) && (c2 == null || c2 == c) && (t2 == null || t2 == t); + final BiPredicate isMaskedCurrent0 = isMasked0; + final BiPredicate isMaskedCurrent1 = isMask1 ? isMasked1 : ALWAYS_NOTHING; + final BiPredicate isMaskedCurrent2 = isMask2 ? isMasked2 : ALWAYS_NOTHING; + final BiPredicate isMasked = unionMask.getMaskReader(z, c, t); + for (int y = pos012.y; y < pos012.y + pos012.height; y++) { + for (int x = pos012.x; x < pos012.x + pos012.width; x++) { + final boolean expected = + isMaskedCurrent0.test(x, y) || isMaskedCurrent1.test(x, y) || isMaskedCurrent2.test(x, y); + final boolean actual = isMasked.test(x, y); + Assertions.assertEquals(expected, actual); + } + } + } + } + } + /* Note observations. */ + maskCountsObserved.add(expectedMaskCount); + for (final char dimension : "ZCT".toCharArray()) { + dimensionSignificancesObserved.put(dimension, unionMask.isSignificant(dimension)); + } + } + + /** + * @return mask placements for + * {@link #testCombiningMasks(int, Integer, Integer, Integer, int, Integer, Integer, Integer, boolean)} + */ + private static Stream provideMaskPlacements() { + final Stream.Builder arguments = Stream.builder(); + final List planes = Arrays.asList(null, 0, 1); + for (final boolean isReverse : new boolean[] {false, true}) { + for (final Integer t1 : planes) { + for (final Integer c1 : planes) { + for (final Integer z1 : planes) { + if ((z1 == null ? 1 : 0) + (c1 == null ? 1 : 0) + (t1 == null ? 1 : 0) == 1) { + /* To save time, skip cases with two of Z, C, T set. */ + continue; + } + for (final Integer t2 : planes.subList(0, 2)) { + for (final Integer c2 : planes.subList(0, 2)) { + for (final Integer z2 : planes.subList(0, 2)) { + if ((z2 == null ? 1 : 0) + (c2 == null ? 1 : 0) + (t2 == null ? 1 : 0) == 1) { + /* To save time, skip cases with two of Z, C, T set. */ + continue; + } + for (int x1 = -1; x1 < 2; x1++) { + for (int x2 = -1; x2 < 2; x2++) { + arguments.add(Arguments.of(x1, z1, c1, t1, x2, z2, c2, t2, isReverse)); + } + } + } + } + } + } + } + } + } + return arguments.build(); + } + + /** + * Convenience method for dispatching overlap queries according to the relevant types. + * @param mask1 a mask + * @param mask2 another mask + * @return if the masks overlap + */ + private static boolean isOverlap(Bitmask mask1, Bitmask mask2) { + if (mask1 instanceof ImageMask) { + if (mask2 instanceof ImageMask) { + return ((ImageMask) mask2).isOverlap((ImageMask) mask1); + } else if (mask2 instanceof UnionMask) { + return ((UnionMask) mask2).isOverlap((ImageMask) mask1); + } + } else if (mask1 instanceof UnionMask) { + if (mask2 instanceof ImageMask) { + return ((UnionMask) mask1).isOverlap((ImageMask) mask2); + } else if (mask2 instanceof UnionMask) { + return ((UnionMask) mask1).isOverlap((UnionMask) mask2); + } + } + throw new IllegalArgumentException(); + } + + /** + * Assert that the given masks overlap. + * @param mask1 a mask + * @param mask2 another mask + */ + private static void assertOverlap(Bitmask mask1, Bitmask mask2) { + Assertions.assertTrue(isOverlap(mask1, mask2)); + Assertions.assertTrue(isOverlap(mask2, mask1)); + } + + /** + * Assert that the given masks do not overlap. + * @param mask1 a mask + * @param mask2 another mask + */ + private static void assertNoOverlap(Bitmask mask1, Bitmask mask2) { + Assertions.assertFalse(isOverlap(mask1, mask2)); + Assertions.assertFalse(isOverlap(mask2, mask1)); + } + + /** + * Check that mask overlaps are properly detected. + * @param size1 the size of the first mask + * @param size2 the size of the second mask + * @param dimension the dimension to which the {@code plane} argument applies + * @param plane the plane index to set for the given dimension for the second mask + */ + @ParameterizedTest + @MethodSource("provideOverlaps") + public void testOverlap(Dimension size1, Dimension size2, char dimension, Integer plane) { + /* Construct three masks. */ + final Integer one = 1; + final Integer z2 = Character.valueOf('Z').equals(dimension) ? plane : one; + final Integer c2 = Character.valueOf('C').equals(dimension) ? plane : one; + final Integer t2 = Character.valueOf('T').equals(dimension) ? plane : one; + final ImageMask mask1 = constructMask(new Rectangle(10, 10, size1.width, size1.height), 1, 1, 1, ALWAYS_NOTHING); + final ImageMask mask2 = constructMask(new Rectangle(15, 15, size2.width, size2.height), z2, c2, t2, ALWAYS_NOTHING); + final ImageMask mask3 = constructMask(new Rectangle(0, 0, 50, 50), null, null, null, ALWAYS_NOTHING); + final BiPredicate isMasked1 = mask1.getMaskReader(1, 1, 1); + final BiPredicate isMasked2 = mask2.getMaskReader( + z2 == null ? 1 :z2, + c2 == null ? 1 :c2, + t2 == null ? 1 :t2); + final BiPredicate isMasked3 = mask3.getMaskReader(1, 1, 1); + /* Set a bit in each of the masks. */ + final int index1 = 30; + final int index2 = 40; + final int index3 = 50; + final int bitPos1 = 0; + final int bitPos2 = 2; + final int bitPos3 = 7; + final int bit1 = index1 * 8 + bitPos1; + final int y1 = bit1 / mask1.pos.width; + final int x1 = bit1 - y1 * mask1.pos.width; + final int bit2 = index2 * 8 + bitPos2; + final int y2 = bit2 / mask2.pos.width; + final int x2 = bit2 - y2 * mask2.pos.width; + final int bit3 = index3 * 8 + bitPos3; + final int y3 = bit3 / mask3.pos.width; + final int x3 = bit3 - y3 * mask3.pos.width; + Assertions.assertFalse(isMasked1.test(x1 + mask1.pos.x, y1 + mask1.pos.y)); + Assertions.assertFalse(isMasked2.test(x2 + mask2.pos.x, y2 + mask2.pos.y)); + Assertions.assertFalse(isMasked3.test(x3 + mask3.pos.x, y3 + mask3.pos.y)); + mask1.bitmask[index1] = (byte) (1 << 7 - bitPos1); + mask2.bitmask[index2] = (byte) (1 << 7 - bitPos2); + mask3.bitmask[index3] = (byte) (1 << 7 - bitPos3); + Assertions.assertTrue(isMasked1.test(x1 + mask1.pos.x, y1 + mask1.pos.y)); + Assertions.assertTrue(isMasked2.test(x2 + mask2.pos.x, y2 + mask2.pos.y)); + Assertions.assertTrue(isMasked3.test(x3 + mask3.pos.x, y3 + mask3.pos.y)); + /* Check that the three masks do not overlap. */ + assertNoOverlap(mask1, mask2); + assertNoOverlap(mask1, mask3); + /* Calculate "overlap" versions of the second and third masks that overlap with the first. */ + final boolean isDifferentPlane = Arrays.asList(z2, c2, t2).contains(0); + final Rectangle overlapPos2 = new Rectangle(mask2.pos); + final Rectangle overlapPos3 = new Rectangle(mask3.pos); + overlapPos2.translate(x1 - x2, y1 - y2); + overlapPos2.translate(mask1.pos.x - mask2.pos.x, mask1.pos.y - mask2.pos.y); + overlapPos3.translate(x1 - x3, y1 - y3); + overlapPos3.translate(mask1.pos.x - mask3.pos.x, mask1.pos.y - mask3.pos.y); + final ImageMask overlapMask2 = new ImageMask(overlapPos2, mask2.z, mask2.c, mask2.t, mask2.bitmask); + final ImageMask overlapMask3 = new ImageMask(overlapPos3, mask3.z, mask3.c, mask3.t, mask3.bitmask); + final BiPredicate isMaskedOverlap2 = overlapMask2.getMaskReader( + z2 == null ? 1 :z2, + c2 == null ? 1 :c2, + t2 == null ? 1 :t2); + final BiPredicate isMaskedOverlap3 = overlapMask3.getMaskReader(1, 1, 1); + Assertions.assertTrue(isMaskedOverlap2.test(x1 + mask1.pos.x, y1 + mask1.pos.y)); + Assertions.assertTrue(isMaskedOverlap3.test(x1 + mask1.pos.x, y1 + mask1.pos.y)); + if (isDifferentPlane) { + assertNoOverlap(mask1, overlapMask2); + } else { + assertOverlap(mask1, overlapMask2); + } + assertOverlap(mask1, overlapMask3); + /* Calculate translated versions of the first mask then check for overlaps. */ + final Bitmask unionMask23 = UnionMask.union(Arrays.asList(overlapMask2, overlapMask3)); + for (int yOffset = -2; yOffset < 3; yOffset++) { + for (int xOffset = -2; xOffset < 3; xOffset++) { + final Rectangle overlapPos1 = new Rectangle(mask1.pos); + overlapPos1.translate(xOffset, yOffset); + final ImageMask overlapMask1 = new ImageMask(overlapPos1, mask1.z, mask1.c, mask1.t, mask1.bitmask); + final Bitmask unionMask12 = UnionMask.union(Arrays.asList(overlapMask1, overlapMask2)); + final Bitmask unionMask13 = UnionMask.union(Arrays.asList(overlapMask1, overlapMask3)); + if (xOffset == 0 && yOffset == 0) { + /* The first "overlap" mask should overlap with the second (if same plane) and third. */ + if (isDifferentPlane) { + assertNoOverlap(overlapMask1, overlapMask2); + } else { + assertOverlap(overlapMask1, overlapMask2); + } + assertOverlap(overlapMask1, overlapMask3); + assertOverlap(overlapMask1, unionMask23); + } else { + /* The first "overlap" mask should not overlap with the second or third. */ + assertNoOverlap(overlapMask1, overlapMask2); + assertNoOverlap(overlapMask1, overlapMask3); + assertNoOverlap(overlapMask1, unionMask23); + } + /* The second and third "overlap" masks do overlap even when part of a union mask. */ + assertOverlap(unionMask12, overlapMask3); + assertOverlap(unionMask13, overlapMask2); + assertOverlap(unionMask12, unionMask13); + assertOverlap(unionMask12, unionMask23); + assertOverlap(unionMask13, unionMask23); + } + } + } + + /** + * @return mask sizes and planes for {@link #testOverlap(Dimension, Dimension, char, Integer)} + */ + private static Stream provideOverlaps() { + final Stream.Builder arguments = Stream.builder(); + final List planes = Arrays.asList(null, 0, 1); + for (int width1 = 25; width1 < 45; width1 += 3) { + for (int height1 = 25; height1 < 45; height1 += 3) { + final Dimension size1 = new Dimension(width1, height1); + for (int width2 = 20; width2 < 50; width2 += 7) { + for (int height2 = 20; height2 < 50; height2 += 5) { + final Dimension size2 = new Dimension(width2, height2); + for (char dimension : "ZCT".toCharArray()) { + for (Integer plane : planes) { + arguments.add(Arguments.of(size1, size2, dimension, plane)); + } + } + } + } + } + } + return arguments.build(); + } + + /** + * Reset the observation notes ready for a test run. + */ + @BeforeAll + public void clearObservations() { + maskCountsObserved.clear(); + dimensionSignificancesObserved.clear(); + } + + /** + * After a test run check that the coverage was as intended. + */ + @AfterAll + public void checkObservations() { + Assertions.assertEquals(3, maskCountsObserved.size()); + Assertions.assertEquals(6, dimensionSignificancesObserved.size()); + } +}