From 85eec6108e844347aaabc6b14625ec5d200a299e Mon Sep 17 00:00:00 2001 From: lleplat Date: Mon, 12 Feb 2024 12:41:07 +0000 Subject: [PATCH 001/866] Make AbstractTileableImageServer.readRegion return an empty tile (instead of null) when AbstractTileableImageServer.readTile returns an empty tile --- .../images/servers/AbstractTileableImageServer.java | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/qupath-core/src/main/java/qupath/lib/images/servers/AbstractTileableImageServer.java b/qupath-core/src/main/java/qupath/lib/images/servers/AbstractTileableImageServer.java index 6ac763628..a8c547049 100644 --- a/qupath-core/src/main/java/qupath/lib/images/servers/AbstractTileableImageServer.java +++ b/qupath-core/src/main/java/qupath/lib/images/servers/AbstractTileableImageServer.java @@ -397,7 +397,12 @@ public BufferedImage readRegion(final RegionRequest request) throws IOException } } } - // Maybe we don't have anything at all (which is not an error if the image is sparse!) + + // If we have an empty region, try to use an empty tile + if (isEmptyRegion) { + return getEmptyTile(request.getWidth(), request.getHeight()); + } + if (raster == null) return null; @@ -428,11 +433,6 @@ else if (xEnd - xStart <= 0 || yEnd - yStart <= 0) raster = raster2; } - // If we have an empty region, try to use an empty tile - if (isEmptyRegion) { - return getEmptyTile(raster.getWidth(), raster.getHeight()); - } - // Return the image, resizing if necessary BufferedImage imgResult = new BufferedImage(colorModel, raster, alphaPremultiplied, null); int currentWidth = imgResult.getWidth(); From 2fa9b6cd3deac0bfcd51778427668ecbd283ea36 Mon Sep 17 00:00:00 2001 From: lleplat Date: Fri, 23 Feb 2024 14:53:51 +0000 Subject: [PATCH 002/866] Added possibility to open Zarr images --- gradle/libs.versions.toml | 2 ++ qupath-extension-bioformats/build.gradle | 3 +++ .../servers/bioformats/BioFormatsImageServer.java | 12 +++++++++--- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0317ca9c5..fb87dec50 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,6 +2,8 @@ bioformats = "7.0.1" bioimageIoSpec = "0.1.0" +omeZarrReader = "0.4.1-SNAPSHOT" +blosc = "1.21.5-SNAPSHOT" commonmark = "0.21.0" commonsMath3 = "3.6.1" diff --git a/qupath-extension-bioformats/build.gradle b/qupath-extension-bioformats/build.gradle index 62fce96c9..0f9a032d4 100644 --- a/qupath-extension-bioformats/build.gradle +++ b/qupath-extension-bioformats/build.gradle @@ -34,6 +34,7 @@ dependencies { implementation libs.qupath.fxtras implementation libs.controlsfx implementation libs.picocli + implementation libs.jna // needed for OMEZarrReader (see https://github.com/bcdev/jzarr/issues/31) implementation "ome:formats-gpl:${bioformatsVersion}", { exclude group: 'xalan', module: 'serializer' @@ -45,6 +46,8 @@ dependencies { exclude group: 'com.google.code.findbugs', module: 'jsr305' exclude group: 'com.google.code.findbugs', module: 'annotations' } + implementation group: 'ome', name: 'OMEZarrReader', version: libs.versions.omeZarrReader.get() + implementation "io.github.qupath:blosc:${libs.versions.blosc.get()}:${nativesClassifier.replace("natives-", "")}" // testImplementation("ome:bioformats_package:${bioformatsVersion}") testImplementation "ome:bio-formats_plugins:${bioformatsVersion}" diff --git a/qupath-extension-bioformats/src/main/java/qupath/lib/images/servers/bioformats/BioFormatsImageServer.java b/qupath-extension-bioformats/src/main/java/qupath/lib/images/servers/bioformats/BioFormatsImageServer.java index a9b216ebf..86bd8bd83 100644 --- a/qupath-extension-bioformats/src/main/java/qupath/lib/images/servers/bioformats/BioFormatsImageServer.java +++ b/qupath-extension-bioformats/src/main/java/qupath/lib/images/servers/bioformats/BioFormatsImageServer.java @@ -37,6 +37,7 @@ import loci.formats.gui.AWTImageTools; import loci.formats.in.DynamicMetadataOptions; import loci.formats.in.MetadataOptions; +import loci.formats.in.ZarrReader; import loci.formats.meta.DummyMetadata; import loci.formats.meta.MetadataStore; import loci.formats.ome.OMEPyramidStore; @@ -1185,10 +1186,15 @@ private IFormatReader createReader(final BioFormatsServerOptions options, final } IFormatReader imageReader; - if (classList != null) { - imageReader = new ImageReader(classList); + if (new File(id).isDirectory()) { + // Using new ImageReader() on a directory won't work + imageReader = new ZarrReader(); } else { - imageReader = new ImageReader(getDefaultClassList()); + if (classList != null) { + imageReader = new ImageReader(classList); + } else { + imageReader = new ImageReader(getDefaultClassList()); + } } imageReader.setFlattenedResolutions(false); From a7b184f2bf0134432c52080e394ab51fea02dfe3 Mon Sep 17 00:00:00 2001 From: Leo Leplat <60394504+Rylern@users.noreply.github.com> Date: Thu, 29 Feb 2024 14:22:31 +0000 Subject: [PATCH 003/866] Fix accessing z-stack pyramidal images --- .../images/servers/bioformats/BioFormatsImageServer.java | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/qupath-extension-bioformats/src/main/java/qupath/lib/images/servers/bioformats/BioFormatsImageServer.java b/qupath-extension-bioformats/src/main/java/qupath/lib/images/servers/bioformats/BioFormatsImageServer.java index 86bd8bd83..bbff2702a 100644 --- a/qupath-extension-bioformats/src/main/java/qupath/lib/images/servers/bioformats/BioFormatsImageServer.java +++ b/qupath-extension-bioformats/src/main/java/qupath/lib/images/servers/bioformats/BioFormatsImageServer.java @@ -1376,6 +1376,12 @@ BufferedImage openImage(TileRequest tileRequest, int series, int nChannels, bool if (tileWidth <= 0 || tileHeight <= 0) { throw new IOException("Unable to request pixels for region with downsampled size " + tileWidth + " x " + tileHeight); } + + // OME Zarr files provide z scaling (the number of z stacks decreases when the resolution becomes + // lower, like the width and height) + if (ipReader instanceof ZarrReader) { + z /= tileRequest.getDownsample(); + } synchronized(ipReader) { ipReader.setSeries(series); From d475f92c2f01f361dc3dcb0067afbe5eb2f6bf3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20Leplat?= <60394504+Rylern@users.noreply.github.com> Date: Fri, 1 Mar 2024 10:36:57 +0000 Subject: [PATCH 004/866] Update gradle/libs.versions.toml Co-authored-by: Alan O'Callaghan --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index fb87dec50..9f0925798 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,7 +3,7 @@ bioformats = "7.0.1" bioimageIoSpec = "0.1.0" omeZarrReader = "0.4.1-SNAPSHOT" -blosc = "1.21.5-SNAPSHOT" +blosc = "1.21.5" commonmark = "0.21.0" commonsMath3 = "3.6.1" From 21b43e0a54b070c19e6c556b00d1fdef66be1489 Mon Sep 17 00:00:00 2001 From: lleplat Date: Fri, 1 Mar 2024 11:21:07 +0000 Subject: [PATCH 005/866] Improved fix accessing z-stack pyramidal images --- .../bioformats/BioFormatsImageServer.java | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/qupath-extension-bioformats/src/main/java/qupath/lib/images/servers/bioformats/BioFormatsImageServer.java b/qupath-extension-bioformats/src/main/java/qupath/lib/images/servers/bioformats/BioFormatsImageServer.java index bbff2702a..5ddfeb606 100644 --- a/qupath-extension-bioformats/src/main/java/qupath/lib/images/servers/bioformats/BioFormatsImageServer.java +++ b/qupath-extension-bioformats/src/main/java/qupath/lib/images/servers/bioformats/BioFormatsImageServer.java @@ -1376,16 +1376,24 @@ BufferedImage openImage(TileRequest tileRequest, int series, int nChannels, bool if (tileWidth <= 0 || tileHeight <= 0) { throw new IOException("Unable to request pixels for region with downsampled size " + tileWidth + " x " + tileHeight); } - - // OME Zarr files provide z scaling (the number of z stacks decreases when the resolution becomes - // lower, like the width and height) - if (ipReader instanceof ZarrReader) { - z /= tileRequest.getDownsample(); - } synchronized(ipReader) { ipReader.setSeries(series); ipReader.setResolution(level); + + // Some files provide z scaling (the number of z stacks decreases when the resolution becomes + // lower, like the width and height), so z needs to be updated for levels > 0 + if (level > 0) { + ipReader.setResolution(0); + int zStacksFullResolution = ipReader.getSizeZ(); + ipReader.setResolution(level); + int zStacksCurrentResolution = ipReader.getSizeZ(); + + if (zStacksFullResolution != zStacksCurrentResolution) { + z = (int) Math.round(z / tileRequest.getDownsample()); + } + } + order = ipReader.isLittleEndian() ? ByteOrder.LITTLE_ENDIAN : ByteOrder.BIG_ENDIAN; interleaved = ipReader.isInterleaved(); pixelType = ipReader.getPixelType(); From d7fc43a2b7728a03f97824195ef480519c186e40 Mon Sep 17 00:00:00 2001 From: lleplat Date: Fri, 1 Mar 2024 11:32:29 +0000 Subject: [PATCH 006/866] Improved fix accessing z-stack pyramidal images --- .../lib/images/servers/bioformats/BioFormatsImageServer.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qupath-extension-bioformats/src/main/java/qupath/lib/images/servers/bioformats/BioFormatsImageServer.java b/qupath-extension-bioformats/src/main/java/qupath/lib/images/servers/bioformats/BioFormatsImageServer.java index 5ddfeb606..cc9d98869 100644 --- a/qupath-extension-bioformats/src/main/java/qupath/lib/images/servers/bioformats/BioFormatsImageServer.java +++ b/qupath-extension-bioformats/src/main/java/qupath/lib/images/servers/bioformats/BioFormatsImageServer.java @@ -1390,7 +1390,7 @@ BufferedImage openImage(TileRequest tileRequest, int series, int nChannels, bool int zStacksCurrentResolution = ipReader.getSizeZ(); if (zStacksFullResolution != zStacksCurrentResolution) { - z = (int) Math.round(z / tileRequest.getDownsample()); + z = (int) Math.floor(z / tileRequest.getDownsample()); } } From 00d05ffaefa7e69ab7fc15a7e80d1ed180387540 Mon Sep 17 00:00:00 2001 From: lleplat Date: Fri, 1 Mar 2024 13:53:03 +0000 Subject: [PATCH 007/866] Addressed comments --- .../images/servers/bioformats/BioFormatsImageServer.java | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/qupath-extension-bioformats/src/main/java/qupath/lib/images/servers/bioformats/BioFormatsImageServer.java b/qupath-extension-bioformats/src/main/java/qupath/lib/images/servers/bioformats/BioFormatsImageServer.java index cc9d98869..e3b7705f4 100644 --- a/qupath-extension-bioformats/src/main/java/qupath/lib/images/servers/bioformats/BioFormatsImageServer.java +++ b/qupath-extension-bioformats/src/main/java/qupath/lib/images/servers/bioformats/BioFormatsImageServer.java @@ -1379,19 +1379,22 @@ BufferedImage openImage(TileRequest tileRequest, int series, int nChannels, bool synchronized(ipReader) { ipReader.setSeries(series); - ipReader.setResolution(level); // Some files provide z scaling (the number of z stacks decreases when the resolution becomes // lower, like the width and height), so z needs to be updated for levels > 0 - if (level > 0) { + if (level > 0 && z > 0) { ipReader.setResolution(0); int zStacksFullResolution = ipReader.getSizeZ(); ipReader.setResolution(level); int zStacksCurrentResolution = ipReader.getSizeZ(); if (zStacksFullResolution != zStacksCurrentResolution) { - z = (int) Math.floor(z / tileRequest.getDownsample()); + z = (int) (z * zStacksCurrentResolution / (float) zStacksFullResolution); } + + + } else { + ipReader.setResolution(level); } order = ipReader.isLittleEndian() ? ByteOrder.LITTLE_ENDIAN : ByteOrder.BIG_ENDIAN; From 59ac700c3785df3cbe59e3b886bf0e739f64c466 Mon Sep 17 00:00:00 2001 From: lleplat Date: Mon, 4 Mar 2024 09:50:23 +0000 Subject: [PATCH 008/866] Started zarr writer --- .../writers/ome/zarr/OMEZarrAttributes.java | 133 ++++++++ .../writers/ome/zarr/OMEZarrWriter.java | 322 ++++++++++++++++++ 2 files changed, 455 insertions(+) create mode 100644 qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrAttributes.java create mode 100644 qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriter.java diff --git a/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrAttributes.java b/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrAttributes.java new file mode 100644 index 000000000..087e02ac8 --- /dev/null +++ b/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrAttributes.java @@ -0,0 +1,133 @@ +package qupath.lib.images.writers.ome.zarr; + +import qupath.lib.images.servers.ImageServer; +import qupath.lib.images.servers.ImageServerMetadata; +import qupath.lib.images.servers.PixelCalibration; + +import java.awt.image.BufferedImage; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.IntStream; + +class OMEZarrAttributes { + + private enum Dimension { + X, + Y, + Z, + C, + T + } + + public static Map getGroupAttributes(ImageServer server) { + return Map.of( + "multiscales", List.of(Map.of( + "axes", getAxes(server), + "datasets", getDatasets(server), + "name", server.getMetadata().getName(), + "version", "0.4" + )) + ); + } + + public static Map getLevelAttributes(ImageServer server) { + List arrayDimensions = new ArrayList<>(); + if (server.nTimepoints() > 1) { + arrayDimensions.add("t"); + } + if (server.nChannels() > 1) { + arrayDimensions.add("c"); + } + if (server.nZSlices() > 1) { + arrayDimensions.add("z"); + } + arrayDimensions.add("y"); + arrayDimensions.add("x"); + + return Map.of("_ARRAY_DIMENSIONS", arrayDimensions); + } + + private static List> getAxes(ImageServer server) { + List> axes = new ArrayList<>(); + + if (server.nTimepoints() > 1) { + axes.add(getAxe(server.getMetadata(), Dimension.T)); + } + if (server.nChannels() > 1) { + axes.add(getAxe(server.getMetadata(), Dimension.C)); + } + if (server.nZSlices() > 1) { + axes.add(getAxe(server.getMetadata(), Dimension.Z)); + } + axes.add(getAxe(server.getMetadata(), Dimension.Y)); + axes.add(getAxe(server.getMetadata(), Dimension.X)); + + return axes; + } + + private static List> getDatasets(ImageServer server) { + return IntStream.range(0, server.getMetadata().nLevels()) + .mapToObj(level -> Map.of( + "path", "s" + level, + "coordinateTransformations", List.of(getCoordinateTransformation(server, level)) + )) + .toList(); + } + + private static Map getAxe(ImageServerMetadata metadata, Dimension dimension) { + Map axes = new HashMap<>(); + axes.put("name", switch (dimension) { + case X -> "x"; + case Y -> "y"; + case Z -> "z"; + case T -> "t"; + case C -> "c"; + }); + axes.put("type", switch (dimension) { + case X, Y, Z -> "space"; + case T -> "time"; + case C -> "channel"; + }); + + switch (dimension) { + case X, Y, Z -> { + if (metadata.getPixelCalibration().getPixelWidthUnit().equals(PixelCalibration.MICROMETER)) { + axes.put("unit", "micrometer"); + } + } + case T -> axes.put("unit", switch (metadata.getTimeUnit()) { + case NANOSECONDS -> "nanosecond"; + case MICROSECONDS -> "microsecond"; + case MILLISECONDS -> "millisecond"; + case SECONDS -> "second"; + case MINUTES -> "minute"; + case HOURS -> "hour"; + case DAYS -> "day"; + }); + } + + return axes; + } + + private static Map getCoordinateTransformation(ImageServer server, int level) { + List scales = new ArrayList<>(); + if (server.nTimepoints() > 1) { + scales.add(1F); + } + if (server.nChannels() > 1) { + scales.add(1F); + } + if (server.nZSlices() > 1) { + scales.add(1F); + } + scales.add((float) server.getDownsampleForResolution(level)); + scales.add((float) server.getDownsampleForResolution(level)); + + return Map.of( + "type", "scale", + "scale", scales + ); + } +} diff --git a/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriter.java b/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriter.java new file mode 100644 index 000000000..39f8f9082 --- /dev/null +++ b/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriter.java @@ -0,0 +1,322 @@ +package qupath.lib.images.writers.ome.zarr; + +import com.bc.zarr.ArrayParams; +import com.bc.zarr.Compressor; +import com.bc.zarr.CompressorFactory; +import com.bc.zarr.DataType; +import com.bc.zarr.DimensionSeparator; +import com.bc.zarr.ZarrArray; +import com.bc.zarr.ZarrGroup; +import loci.formats.gui.AWTImageTools; +import qupath.lib.images.servers.ImageServer; +import qupath.lib.images.servers.PixelType; +import qupath.lib.images.servers.TileRequest; +import ucar.ma2.InvalidRangeException; + +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +public class OMEZarrWriter implements AutoCloseable { + + private final ImageServer server; + private final Compressor compressor; + private final Map levelArrays; + private final ExecutorService executorService; + private int numberOfTasksRunning = 0; + + private OMEZarrWriter(Builder builder) throws IOException { + this.server = builder.server; + this.compressor = builder.compressor; + this.levelArrays = createLevelArrays(ZarrGroup.create( + builder.path, + OMEZarrAttributes.getGroupAttributes(server) + )); + executorService = Executors.newFixedThreadPool(builder.numberOfThreads); + } + + @Override + public void close() { + executorService.shutdown(); + } + + public void write(TileRequest tileRequest) throws IOException { + try { + CompletableFuture.runAsync(() -> { + try { + addTask(); + writeTile(tileRequest); + } catch (IOException e) { + throw new RuntimeException(e); + } finally { + removeTask(); + } + }, executorService); + } catch (Exception e) { + throw new IOException(e); + } + } + + public synchronized boolean isWriting() { + return numberOfTasksRunning > 0; + } + + public static class Builder { + + private static final String FILE_EXTENSION = ".ome.zarr"; + private final ImageServer server; + private final String path; + private Compressor compressor = CompressorFactory.createDefaultCompressor(); + private int numberOfThreads = 12; + + public Builder(ImageServer server, String path) { + if (!path.endsWith(FILE_EXTENSION)) { + throw new IllegalArgumentException(String.format("The provided path (%s) does not have the OME-Zarr extension (%s)", path, FILE_EXTENSION)); + } + + this.server = server; + this.path = path; + } + + public Builder setCompressor(Compressor compressor) { + this.compressor = compressor; + return this; + } + + public Builder setNumberOfThreads(int numberOfThreads) { + this.numberOfThreads = numberOfThreads; + return this; + } + + public OMEZarrWriter build() throws IOException { + return new OMEZarrWriter(this); + } + } + + private Map createLevelArrays(ZarrGroup root) throws IOException { + Map levelArrays = new HashMap<>(); + + for (int level=0; level DataType.u1; + case INT8 -> DataType.i1; + case UINT16 -> DataType.u2; + case INT16 -> DataType.i2; + case UINT32 -> DataType.u4; + case INT32 -> DataType.i4; + case FLOAT32 -> DataType.f4; + case FLOAT64 -> DataType.f8; + }) + .dimensionSeparator(DimensionSeparator.SLASH), + OMEZarrAttributes.getLevelAttributes(server) + )); + } + + return levelArrays; + } + + private synchronized void addTask() { + numberOfTasksRunning++; + } + + private synchronized void removeTask() { + numberOfTasksRunning--; + } + + private void writeTile(TileRequest tileRequest) throws IOException { + try { + levelArrays.get(tileRequest.getLevel()).write( + getData( + server.readRegion(tileRequest.getRegionRequest()), + server.getPixelType(), + server.nChannels(), + tileRequest.getTileHeight(), + tileRequest.getTileWidth() + ), + getDimensionsOfTile(tileRequest), + getOffsetsOfTile(tileRequest) + ); + } catch (InvalidRangeException e) { + throw new IOException(e); + } + } + + private int[] getDimensionsOfImage(int level) { + List dimensions = new ArrayList<>(); + if (server.nTimepoints() > 1) { + dimensions.add(server.nTimepoints()); + } + if (server.nChannels() > 1) { + dimensions.add(server.nChannels()); + } + if (server.nZSlices() > 1) { + dimensions.add(server.nZSlices()); + } + dimensions.add((int) (server.getHeight() / server.getDownsampleForResolution(level))); + dimensions.add((int) (server.getWidth() / server.getDownsampleForResolution(level))); + + int[] dimensionArray = new int[dimensions.size()]; + for(int i = 0; i < dimensions.size(); i++) { + dimensionArray[i] = dimensions.get(i); + } + return dimensionArray; + } + + private int[] getChunksOfImage() { + List chunks = new ArrayList<>(); + if (server.nTimepoints() > 1) { + chunks.add(1); + } + if (server.nChannels() > 1) { + chunks.add(1); + } + if (server.nZSlices() > 1) { + chunks.add(Math.max(server.getMetadata().getPreferredTileWidth(), server.getMetadata().getPreferredTileHeight())); + } + chunks.add(server.getMetadata().getPreferredTileHeight()); + + chunks.add(server.getMetadata().getPreferredTileWidth()); + + int[] chunksArray = new int[chunks.size()]; + for (int i = 0; i < chunks.size(); i++) { + chunksArray[i] = chunks.get(i); + } + return chunksArray; + } + + private int[] getDimensionsOfTile(TileRequest tileRequest) { + List dimensions = new ArrayList<>(); + if (server.nTimepoints() > 1) { + dimensions.add(1); + } + if (server.nChannels() > 1) { + dimensions.add(server.nChannels()); + } + if (server.nZSlices() > 1) { + dimensions.add(1); + } + dimensions.add(tileRequest.getTileHeight()); + dimensions.add(tileRequest.getTileWidth()); + + int[] dimensionArray = new int[dimensions.size()]; + for (int i = 0; i < dimensions.size(); i++) { + dimensionArray[i] = dimensions.get(i); + } + return dimensionArray; + } + + private int[] getOffsetsOfTile(TileRequest tileRequest) { + List offset = new ArrayList<>(); + if (server.nTimepoints() > 1) { + offset.add(tileRequest.getT()); + } + if (server.nChannels() > 1) { + offset.add(0); + } + if (server.nZSlices() > 1) { + offset.add(tileRequest.getZ()); + } + offset.add(tileRequest.getTileY()); + offset.add(tileRequest.getTileX()); + + int[] offsetArray = new int[offset.size()]; + for (int i = 0; i < offset.size(); i++) { + offsetArray[i] = offset.get(i); + } + return offsetArray; + } + + private static Object getData(BufferedImage image, PixelType pixelType, int numberOfChannels, int height, int width) { + Object pixels = AWTImageTools.getPixels(image); + + return switch (pixelType) { + case UINT8, INT8 -> { + byte[][] data = (byte[][]) pixels; + + byte[] output = new byte[numberOfChannels * width * height]; + int i = 0; + for (int c=0; c { + short[][] data = (short[][]) pixels; + + short[] output = new short[numberOfChannels * width * height]; + int i = 0; + for (int c=0; c { + int[][] data = (int[][]) pixels; + + int[] output = new int[numberOfChannels * width * height]; + int i = 0; + for (int c=0; c { + float[][] data = (float[][]) pixels; + + float[] output = new float[numberOfChannels * width * height]; + int i = 0; + for (int c=0; c { + double[][] data = (double[][]) pixels; + + double[] output = new double[numberOfChannels * width * height]; + int i = 0; + for (int c=0; c Date: Mon, 4 Mar 2024 11:11:16 +0000 Subject: [PATCH 009/866] Removed QuPath specific code from attributes --- .../writers/ome/zarr/OMEZarrAttributes.java | 89 ++++++++++++------- .../writers/ome/zarr/OMEZarrWriter.java | 26 ++++-- 2 files changed, 75 insertions(+), 40 deletions(-) diff --git a/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrAttributes.java b/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrAttributes.java index 087e02ac8..ceeae09a8 100644 --- a/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrAttributes.java +++ b/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrAttributes.java @@ -1,18 +1,21 @@ package qupath.lib.images.writers.ome.zarr; -import qupath.lib.images.servers.ImageServer; -import qupath.lib.images.servers.ImageServerMetadata; -import qupath.lib.images.servers.PixelCalibration; - -import java.awt.image.BufferedImage; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.TimeUnit; import java.util.stream.IntStream; class OMEZarrAttributes { + private final String imageName; + private final int numberOfZSlices; + private final int numberOfTimePoints; + private final int numberOfChannels; + private final boolean valuesInMicrometer; + private final TimeUnit timeUnit; + private final double[] downSamples; private enum Dimension { X, Y, @@ -21,26 +24,44 @@ private enum Dimension { T } - public static Map getGroupAttributes(ImageServer server) { + public OMEZarrAttributes( + String imageName, + int numberOfZSlices, + int numberOfTimePoints, + int numberOfChannels, + boolean valuesInMicrometer, + TimeUnit timeUnit, + double[] downSamples + ) { + this.imageName = imageName; + this.numberOfZSlices = numberOfZSlices; + this.numberOfTimePoints = numberOfTimePoints; + this.numberOfChannels = numberOfChannels; + this.valuesInMicrometer = valuesInMicrometer; + this.timeUnit = timeUnit; + this.downSamples = downSamples; + } + + public Map getGroupAttributes() { return Map.of( "multiscales", List.of(Map.of( - "axes", getAxes(server), - "datasets", getDatasets(server), - "name", server.getMetadata().getName(), + "axes", getAxes(), + "datasets", getDatasets(), + "name", imageName, "version", "0.4" )) ); } - public static Map getLevelAttributes(ImageServer server) { + public Map getLevelAttributes() { List arrayDimensions = new ArrayList<>(); - if (server.nTimepoints() > 1) { + if (numberOfTimePoints > 1) { arrayDimensions.add("t"); } - if (server.nChannels() > 1) { + if (numberOfChannels > 1) { arrayDimensions.add("c"); } - if (server.nZSlices() > 1) { + if (numberOfZSlices > 1) { arrayDimensions.add("z"); } arrayDimensions.add("y"); @@ -49,34 +70,34 @@ public static Map getLevelAttributes(ImageServer return Map.of("_ARRAY_DIMENSIONS", arrayDimensions); } - private static List> getAxes(ImageServer server) { + private List> getAxes() { List> axes = new ArrayList<>(); - if (server.nTimepoints() > 1) { - axes.add(getAxe(server.getMetadata(), Dimension.T)); + if (numberOfTimePoints > 1) { + axes.add(getAxe(Dimension.T)); } - if (server.nChannels() > 1) { - axes.add(getAxe(server.getMetadata(), Dimension.C)); + if (numberOfChannels > 1) { + axes.add(getAxe(Dimension.C)); } - if (server.nZSlices() > 1) { - axes.add(getAxe(server.getMetadata(), Dimension.Z)); + if (numberOfZSlices > 1) { + axes.add(getAxe(Dimension.Z)); } - axes.add(getAxe(server.getMetadata(), Dimension.Y)); - axes.add(getAxe(server.getMetadata(), Dimension.X)); + axes.add(getAxe(Dimension.Y)); + axes.add(getAxe(Dimension.X)); return axes; } - private static List> getDatasets(ImageServer server) { - return IntStream.range(0, server.getMetadata().nLevels()) + private List> getDatasets() { + return IntStream.range(0, downSamples.length) .mapToObj(level -> Map.of( "path", "s" + level, - "coordinateTransformations", List.of(getCoordinateTransformation(server, level)) + "coordinateTransformations", List.of(getCoordinateTransformation((float) downSamples[level])) )) .toList(); } - private static Map getAxe(ImageServerMetadata metadata, Dimension dimension) { + private Map getAxe(Dimension dimension) { Map axes = new HashMap<>(); axes.put("name", switch (dimension) { case X -> "x"; @@ -93,11 +114,11 @@ private static Map getAxe(ImageServerMetadata metadata, Dimensio switch (dimension) { case X, Y, Z -> { - if (metadata.getPixelCalibration().getPixelWidthUnit().equals(PixelCalibration.MICROMETER)) { + if (valuesInMicrometer) { axes.put("unit", "micrometer"); } } - case T -> axes.put("unit", switch (metadata.getTimeUnit()) { + case T -> axes.put("unit", switch (timeUnit) { case NANOSECONDS -> "nanosecond"; case MICROSECONDS -> "microsecond"; case MILLISECONDS -> "millisecond"; @@ -111,19 +132,19 @@ private static Map getAxe(ImageServerMetadata metadata, Dimensio return axes; } - private static Map getCoordinateTransformation(ImageServer server, int level) { + private Map getCoordinateTransformation(float downSample) { List scales = new ArrayList<>(); - if (server.nTimepoints() > 1) { + if (numberOfTimePoints > 1) { scales.add(1F); } - if (server.nChannels() > 1) { + if (numberOfChannels > 1) { scales.add(1F); } - if (server.nZSlices() > 1) { + if (numberOfZSlices > 1) { scales.add(1F); } - scales.add((float) server.getDownsampleForResolution(level)); - scales.add((float) server.getDownsampleForResolution(level)); + scales.add(downSample); + scales.add(downSample); return Map.of( "type", "scale", diff --git a/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriter.java b/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriter.java index 39f8f9082..c882ab70f 100644 --- a/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriter.java +++ b/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriter.java @@ -9,6 +9,7 @@ import com.bc.zarr.ZarrGroup; import loci.formats.gui.AWTImageTools; import qupath.lib.images.servers.ImageServer; +import qupath.lib.images.servers.PixelCalibration; import qupath.lib.images.servers.PixelType; import qupath.lib.images.servers.TileRequest; import ucar.ma2.InvalidRangeException; @@ -32,12 +33,25 @@ public class OMEZarrWriter implements AutoCloseable { private int numberOfTasksRunning = 0; private OMEZarrWriter(Builder builder) throws IOException { + OMEZarrAttributes attributes = new OMEZarrAttributes( + builder.server.getMetadata().getName(), + builder.server.nZSlices(), + builder.server.nTimepoints(), + builder.server.nChannels(), + builder.server.getMetadata().getPixelCalibration().getPixelWidthUnit().equals(PixelCalibration.MICROMETER), + builder.server.getMetadata().getTimeUnit(), + builder.server.getPreferredDownsamples() + ); + this.server = builder.server; this.compressor = builder.compressor; - this.levelArrays = createLevelArrays(ZarrGroup.create( - builder.path, - OMEZarrAttributes.getGroupAttributes(server) - )); + this.levelArrays = createLevelArrays( + ZarrGroup.create( + builder.path, + attributes.getGroupAttributes() + ), + attributes.getLevelAttributes() + ); executorService = Executors.newFixedThreadPool(builder.numberOfThreads); } @@ -99,7 +113,7 @@ public OMEZarrWriter build() throws IOException { } } - private Map createLevelArrays(ZarrGroup root) throws IOException { + private Map createLevelArrays(ZarrGroup root, Map levelAttributes) throws IOException { Map levelArrays = new HashMap<>(); for (int level=0; level createLevelArrays(ZarrGroup root) throws IOExcep case FLOAT64 -> DataType.f8; }) .dimensionSeparator(DimensionSeparator.SLASH), - OMEZarrAttributes.getLevelAttributes(server) + levelAttributes )); } From e8af37075cdfe24fd9dd05d316a118a7592e368b Mon Sep 17 00:00:00 2001 From: lleplat Date: Mon, 4 Mar 2024 12:10:40 +0000 Subject: [PATCH 010/866] Added Javadoc comments --- ...tes.java => OMEZarrAttributesCreator.java} | 35 ++++++++-- .../writers/ome/zarr/OMEZarrWriter.java | 65 ++++++++++++++++++- 2 files changed, 93 insertions(+), 7 deletions(-) rename qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/{OMEZarrAttributes.java => OMEZarrAttributesCreator.java} (77%) diff --git a/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrAttributes.java b/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrAttributesCreator.java similarity index 77% rename from qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrAttributes.java rename to qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrAttributesCreator.java index ceeae09a8..44350efd3 100644 --- a/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrAttributes.java +++ b/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrAttributesCreator.java @@ -7,13 +7,17 @@ import java.util.concurrent.TimeUnit; import java.util.stream.IntStream; -class OMEZarrAttributes { +/** + * Create attributes of a OME-Zarr file as described by version 0.4 of the specifications of the + * Next-generation file formats (NGFF). + */ +class OMEZarrAttributesCreator { private final String imageName; private final int numberOfZSlices; private final int numberOfTimePoints; private final int numberOfChannels; - private final boolean valuesInMicrometer; + private final boolean pixelSizeInMicrometer; private final TimeUnit timeUnit; private final double[] downSamples; private enum Dimension { @@ -24,12 +28,23 @@ private enum Dimension { T } - public OMEZarrAttributes( + /** + * Create an instance of the attributes' creator. + * + * @param imageName the name of the image + * @param numberOfZSlices the number of z-stacks + * @param numberOfTimePoints the number of time points + * @param numberOfChannels the number of channels + * @param pixelSizeInMicrometer whether pixel sizes are in micrometer + * @param timeUnit the unit of the time dimension of the image + * @param downSamples the downsamples of the image + */ + public OMEZarrAttributesCreator( String imageName, int numberOfZSlices, int numberOfTimePoints, int numberOfChannels, - boolean valuesInMicrometer, + boolean pixelSizeInMicrometer, TimeUnit timeUnit, double[] downSamples ) { @@ -37,11 +52,15 @@ public OMEZarrAttributes( this.numberOfZSlices = numberOfZSlices; this.numberOfTimePoints = numberOfTimePoints; this.numberOfChannels = numberOfChannels; - this.valuesInMicrometer = valuesInMicrometer; + this.pixelSizeInMicrometer = pixelSizeInMicrometer; this.timeUnit = timeUnit; this.downSamples = downSamples; } + /** + * @return an unmodifiable map of attributes describing the zarr group that should + * be at the root of the image files + */ public Map getGroupAttributes() { return Map.of( "multiscales", List.of(Map.of( @@ -53,6 +72,10 @@ public Map getGroupAttributes() { ); } + /** + * @return an unmodifiable map of attributes describing a zarr array corresponding to + * a level of the image + */ public Map getLevelAttributes() { List arrayDimensions = new ArrayList<>(); if (numberOfTimePoints > 1) { @@ -114,7 +137,7 @@ private Map getAxe(Dimension dimension) { switch (dimension) { case X, Y, Z -> { - if (valuesInMicrometer) { + if (pixelSizeInMicrometer) { axes.put("unit", "micrometer"); } } diff --git a/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriter.java b/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriter.java index c882ab70f..62f100cca 100644 --- a/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriter.java +++ b/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriter.java @@ -24,6 +24,21 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +/** + *

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

+ *

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

+ *

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

+ *

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

+ */ public class OMEZarrWriter implements AutoCloseable { private final ImageServer server; @@ -33,7 +48,7 @@ public class OMEZarrWriter implements AutoCloseable { private int numberOfTasksRunning = 0; private OMEZarrWriter(Builder builder) throws IOException { - OMEZarrAttributes attributes = new OMEZarrAttributes( + OMEZarrAttributesCreator attributes = new OMEZarrAttributesCreator( builder.server.getMetadata().getName(), builder.server.nZSlices(), builder.server.nTimepoints(), @@ -60,6 +75,18 @@ public void close() { executorService.shutdown(); } + /** + *

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

+ *

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

+ * + * @param tileRequest the tile to write + * @throws IOException when an error occurs while writing the tile + */ public void write(TileRequest tileRequest) throws IOException { try { CompletableFuture.runAsync(() -> { @@ -77,10 +104,17 @@ public void write(TileRequest tileRequest) throws IOException { } } + /** + * @return whether this writer is currently writing tiles. This can be useful to wait + * for tiles to finish being written + */ public synchronized boolean isWriting() { return numberOfTasksRunning > 0; } + /** + * Builder to create an instance of a {@link OMEZarrWriter}. + */ public static class Builder { private static final String FILE_EXTENSION = ".ome.zarr"; @@ -89,6 +123,13 @@ public static class Builder { private Compressor compressor = CompressorFactory.createDefaultCompressor(); private int numberOfThreads = 12; + /** + * Create the builder. + * + * @param server the image to write + * @param path the path where to write the image. It must end with ".ome.zarr" + * @throws IllegalArgumentException when the provided path doesn't end with ".ome.zarr" + */ public Builder(ImageServer server, String path) { if (!path.endsWith(FILE_EXTENSION)) { throw new IllegalArgumentException(String.format("The provided path (%s) does not have the OME-Zarr extension (%s)", path, FILE_EXTENSION)); @@ -98,16 +139,38 @@ public Builder(ImageServer server, String path) { this.path = path; } + /** + * Set the compressor to use when writing tiles. By default, the blocs compression is used. + * + * @param compressor the compressor to use when writing tiles + * @return this builder + */ public Builder setCompressor(Compressor compressor) { this.compressor = compressor; return this; } + /** + * Tiles will be written from a pool of thread. This function + * specifies the number of threads to use. By default, 12 threads are + * used. + * + * @param numberOfThreads the number of threads to use when writing tiles + * @return this builder + */ public Builder setNumberOfThreads(int numberOfThreads) { this.numberOfThreads = numberOfThreads; return this; } + /** + * Create a new instance of {@link OMEZarrWriter}. This will also + * create an empty image on the provided path. + * + * @return the new {@link OMEZarrWriter} + * @throws IOException when the empty image cannot be created. This can happen + * if the provided path is incorrect or if the user doesn't have enough permissions + */ public OMEZarrWriter build() throws IOException { return new OMEZarrWriter(this); } From 25c054c79bd50862e95b653b363dc492af4a23e4 Mon Sep 17 00:00:00 2001 From: Alan O'Callaghan Date: Mon, 4 Mar 2024 13:12:23 +0000 Subject: [PATCH 011/866] Fix tar command --- .github/workflows/jpackage.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/jpackage.yml b/.github/workflows/jpackage.yml index 033848516..c0a7aabfa 100644 --- a/.github/workflows/jpackage.yml +++ b/.github/workflows/jpackage.yml @@ -53,7 +53,7 @@ if: matrix.name == 'Linux' shell: bash run: | - tar -c build/dist/QuPath/ | xz > build/dist/QuPath-v${{ env.QUPATH_VERSION }}-${{ matrix.name }}.tar.xz + tar -c -C build/dist/ QuPath | xz > build/dist/QuPath-v${{ env.QUPATH_VERSION }}-${{ matrix.name }}.tar.xz rm -r build/dist/QuPath/ - name: Clean windows artifact From 604e5857b55fe692a626a3adddcb9d76300a02c6 Mon Sep 17 00:00:00 2001 From: lleplat Date: Tue, 5 Mar 2024 10:14:08 +0000 Subject: [PATCH 012/866] Added empty downsampled tile creator --- .../ome/zarr/DownSampledTileCreator.java | 22 +++++++ .../writers/ome/zarr/OMEZarrWriter.java | 57 +++++++++++++++---- 2 files changed, 67 insertions(+), 12 deletions(-) create mode 100644 qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/DownSampledTileCreator.java diff --git a/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/DownSampledTileCreator.java b/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/DownSampledTileCreator.java new file mode 100644 index 000000000..304daaca1 --- /dev/null +++ b/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/DownSampledTileCreator.java @@ -0,0 +1,22 @@ +package qupath.lib.images.writers.ome.zarr; + +import qupath.lib.images.servers.TileRequest; + +import java.util.List; +import java.util.function.BiConsumer; + + +public class DownSampledTileCreator { + + private final BiConsumer onTileCreated; + private final List downSamplesToCreate; + + public DownSampledTileCreator(BiConsumer onTileCreated, List downSamplesToCreate) { + this.onTileCreated = onTileCreated; + this.downSamplesToCreate = downSamplesToCreate; + } + + public void addTile(TileRequest tileRequest, Object data) { + + } +} diff --git a/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriter.java b/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriter.java index 62f100cca..5bec3b806 100644 --- a/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriter.java +++ b/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriter.java @@ -8,6 +8,8 @@ import com.bc.zarr.ZarrArray; import com.bc.zarr.ZarrGroup; import loci.formats.gui.AWTImageTools; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import qupath.lib.images.servers.ImageServer; import qupath.lib.images.servers.PixelCalibration; import qupath.lib.images.servers.PixelType; @@ -41,9 +43,11 @@ */ public class OMEZarrWriter implements AutoCloseable { + private static final Logger logger = LoggerFactory.getLogger(OMEZarrWriter.class); private final ImageServer server; private final Compressor compressor; private final Map levelArrays; + private final DownSampledTileCreator downSampledTileCreator; private final ExecutorService executorService; private int numberOfTasksRunning = 0; @@ -67,7 +71,11 @@ private OMEZarrWriter(Builder builder) throws IOException { ), attributes.getLevelAttributes() ); - executorService = Executors.newFixedThreadPool(builder.numberOfThreads); + this.downSampledTileCreator = builder.downSamplesToCreate.isEmpty() ? null : new DownSampledTileCreator( + this::writeTile, + builder.downSamplesToCreate + ); + this.executorService = Executors.newFixedThreadPool(builder.numberOfThreads); } @Override @@ -92,7 +100,17 @@ public void write(TileRequest tileRequest) throws IOException { CompletableFuture.runAsync(() -> { try { addTask(); - writeTile(tileRequest); + + Object data = getData( + server.readRegion(tileRequest.getRegionRequest()), + server.getPixelType(), + server.nChannels(), + tileRequest.getTileHeight(), + tileRequest.getTileWidth() + ); + + writeTile(tileRequest, data); + downSampledTileCreator.addTile(tileRequest, data); } catch (IOException e) { throw new RuntimeException(e); } finally { @@ -122,6 +140,7 @@ public static class Builder { private final String path; private Compressor compressor = CompressorFactory.createDefaultCompressor(); private int numberOfThreads = 12; + private List downSamplesToCreate = List.of(); /** * Create the builder. @@ -163,6 +182,26 @@ public Builder setNumberOfThreads(int numberOfThreads) { return this; } + /** + * When writing the image, additional levels not present in the original image can be created by + * downsampling the full resolution image. This function specifies the downsamples of such levels. + * By default, no additional levels will be created. + * + * @param downSamplesToCreate the downsamples of each level that should be added to the output image + * @return this builder + * @throws IllegalArgumentException when one of the provided downsamples is less than or equal to 1 + */ + public Builder setDownSamplesToCreate(List downSamplesToCreate) { + for (double downSample: downSamplesToCreate) { + if (downSample <= 1) { + throw new IllegalArgumentException(String.format("The provided downsample (%.2f) is less than or equal to 1", downSample)); + } + } + + this.downSamplesToCreate = downSamplesToCreate; + return this; + } + /** * Create a new instance of {@link OMEZarrWriter}. This will also * create an empty image on the provided path. @@ -212,21 +251,15 @@ private synchronized void removeTask() { numberOfTasksRunning--; } - private void writeTile(TileRequest tileRequest) throws IOException { + private void writeTile(TileRequest tileRequest, Object data) { try { levelArrays.get(tileRequest.getLevel()).write( - getData( - server.readRegion(tileRequest.getRegionRequest()), - server.getPixelType(), - server.nChannels(), - tileRequest.getTileHeight(), - tileRequest.getTileWidth() - ), + data, getDimensionsOfTile(tileRequest), getOffsetsOfTile(tileRequest) ); - } catch (InvalidRangeException e) { - throw new IOException(e); + } catch (InvalidRangeException | IOException e) { + logger.error(String.format("Could not write tile %s", tileRequest)); } } From 3b177d607a2505a81c26f601f3d17c47d2878f7a Mon Sep 17 00:00:00 2001 From: lleplat Date: Tue, 5 Mar 2024 10:17:27 +0000 Subject: [PATCH 013/866] Added function to write entire image --- .../writers/ome/zarr/OMEZarrWriter.java | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriter.java b/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriter.java index 5bec3b806..6879bce09 100644 --- a/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriter.java +++ b/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriter.java @@ -83,6 +83,23 @@ public void close() { executorService.shutdown(); } + /** + *

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

+ *

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

+ * + * @throws IOException when an error occurs while writing the tile + */ + public void writeImage() throws IOException { + for (TileRequest tileRequest: server.getTileRequestManager().getAllTileRequests()) { + writeTile(tileRequest); + } + } + /** *

* Write the provided tile in a background thread. @@ -95,7 +112,7 @@ public void close() { * @param tileRequest the tile to write * @throws IOException when an error occurs while writing the tile */ - public void write(TileRequest tileRequest) throws IOException { + public void writeTile(TileRequest tileRequest) throws IOException { try { CompletableFuture.runAsync(() -> { try { From c60cc863a1e1b825c0098bc87305302ca63ea54e Mon Sep 17 00:00:00 2001 From: lleplat Date: Tue, 5 Mar 2024 11:47:34 +0000 Subject: [PATCH 014/866] Wait for writing to finish in close() --- .../writers/ome/zarr/OMEZarrWriter.java | 31 ++++++------------- 1 file changed, 9 insertions(+), 22 deletions(-) diff --git a/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriter.java b/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriter.java index 6879bce09..ce961b634 100644 --- a/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriter.java +++ b/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriter.java @@ -25,6 +25,7 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; /** *

@@ -49,7 +50,6 @@ public class OMEZarrWriter implements AutoCloseable { private final Map levelArrays; private final DownSampledTileCreator downSampledTileCreator; private final ExecutorService executorService; - private int numberOfTasksRunning = 0; private OMEZarrWriter(Builder builder) throws IOException { OMEZarrAttributesCreator attributes = new OMEZarrAttributesCreator( @@ -78,9 +78,16 @@ private OMEZarrWriter(Builder builder) throws IOException { this.executorService = Executors.newFixedThreadPool(builder.numberOfThreads); } + /** + * Close this writer. This will wait until all pending tiles + * are written. + * + * @throws InterruptedException when the waiting is interrupted + */ @Override - public void close() { + public void close() throws InterruptedException { executorService.shutdown(); + executorService.awaitTermination(Long.MAX_VALUE, TimeUnit.NANOSECONDS); } /** @@ -116,8 +123,6 @@ public void writeTile(TileRequest tileRequest) throws IOException { try { CompletableFuture.runAsync(() -> { try { - addTask(); - Object data = getData( server.readRegion(tileRequest.getRegionRequest()), server.getPixelType(), @@ -130,8 +135,6 @@ public void writeTile(TileRequest tileRequest) throws IOException { downSampledTileCreator.addTile(tileRequest, data); } catch (IOException e) { throw new RuntimeException(e); - } finally { - removeTask(); } }, executorService); } catch (Exception e) { @@ -139,14 +142,6 @@ public void writeTile(TileRequest tileRequest) throws IOException { } } - /** - * @return whether this writer is currently writing tiles. This can be useful to wait - * for tiles to finish being written - */ - public synchronized boolean isWriting() { - return numberOfTasksRunning > 0; - } - /** * Builder to create an instance of a {@link OMEZarrWriter}. */ @@ -260,14 +255,6 @@ private Map createLevelArrays(ZarrGroup root, Map Date: Tue, 5 Mar 2024 16:58:05 +0000 Subject: [PATCH 015/866] Added support for RGB, set channel names/colors --- .../ome/zarr/DownSampledTileCreator.java | 22 -- .../ome/zarr/OMEZarrAttributesCreator.java | 65 +++++- .../writers/ome/zarr/OMEZarrWriter.java | 215 ++++++++---------- 3 files changed, 160 insertions(+), 142 deletions(-) delete mode 100644 qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/DownSampledTileCreator.java diff --git a/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/DownSampledTileCreator.java b/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/DownSampledTileCreator.java deleted file mode 100644 index 304daaca1..000000000 --- a/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/DownSampledTileCreator.java +++ /dev/null @@ -1,22 +0,0 @@ -package qupath.lib.images.writers.ome.zarr; - -import qupath.lib.images.servers.TileRequest; - -import java.util.List; -import java.util.function.BiConsumer; - - -public class DownSampledTileCreator { - - private final BiConsumer onTileCreated; - private final List downSamplesToCreate; - - public DownSampledTileCreator(BiConsumer onTileCreated, List downSamplesToCreate) { - this.onTileCreated = onTileCreated; - this.downSamplesToCreate = downSamplesToCreate; - } - - public void addTile(TileRequest tileRequest, Object data) { - - } -} diff --git a/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrAttributesCreator.java b/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrAttributesCreator.java index 44350efd3..82a39baef 100644 --- a/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrAttributesCreator.java +++ b/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrAttributesCreator.java @@ -1,5 +1,9 @@ package qupath.lib.images.writers.ome.zarr; +import qupath.lib.common.ColorTools; +import qupath.lib.images.servers.ImageChannel; +import qupath.lib.images.servers.PixelType; + import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -13,6 +17,7 @@ */ class OMEZarrAttributesCreator { + private static final String VERSION = "0.4"; private final String imageName; private final int numberOfZSlices; private final int numberOfTimePoints; @@ -20,6 +25,9 @@ class OMEZarrAttributesCreator { private final boolean pixelSizeInMicrometer; private final TimeUnit timeUnit; private final double[] downSamples; + private final List channels; + private final boolean isRGB; + private final PixelType pixelType; private enum Dimension { X, Y, @@ -38,6 +46,9 @@ private enum Dimension { * @param pixelSizeInMicrometer whether pixel sizes are in micrometer * @param timeUnit the unit of the time dimension of the image * @param downSamples the downsamples of the image + * @param channels the channels of the image + * @param isRGB whether the image stores pixel values with the RGB format + * @param pixelType the type of the pixel values of the image */ public OMEZarrAttributesCreator( String imageName, @@ -46,7 +57,10 @@ public OMEZarrAttributesCreator( int numberOfChannels, boolean pixelSizeInMicrometer, TimeUnit timeUnit, - double[] downSamples + double[] downSamples, + List channels, + boolean isRGB, + PixelType pixelType ) { this.imageName = imageName; this.numberOfZSlices = numberOfZSlices; @@ -55,6 +69,9 @@ public OMEZarrAttributesCreator( this.pixelSizeInMicrometer = pixelSizeInMicrometer; this.timeUnit = timeUnit; this.downSamples = downSamples; + this.channels = channels; + this.isRGB = isRGB; + this.pixelType = pixelType; } /** @@ -67,8 +84,18 @@ public Map getGroupAttributes() { "axes", getAxes(), "datasets", getDatasets(), "name", imageName, - "version", "0.4" - )) + "version", VERSION + )), + "omero", Map.of( + "name", imageName, + "version", VERSION, + "channels", getChannels(), + "rdefs", Map.of( + "defaultT", 0, + "defaultZ", 0, + "model", "color" + ) + ) ); } @@ -120,6 +147,38 @@ private List> getDatasets() { .toList(); } + private List> getChannels() { + Object maxValue = isRGB ? Integer.MAX_VALUE : switch (pixelType) { + case UINT8, INT8 -> Byte.MAX_VALUE; + case UINT16, INT16 -> Short.MAX_VALUE; + case UINT32, INT32 -> Integer.MAX_VALUE; + case FLOAT32 -> Float.MAX_VALUE; + case FLOAT64 -> Double.MAX_VALUE; + }; + + return channels.stream() + .map(channel -> Map.of( + "active", true, + "coefficient", 1d, + "color", String.format( + "%02X%02X%02X", + ColorTools.unpackRGB(channel.getColor())[0], + ColorTools.unpackRGB(channel.getColor())[1], + ColorTools.unpackRGB(channel.getColor())[2] + ), + "family", "linear", + "inverted", false, + "label", channel.getName(), + "window", Map.of( + "start", 0d, + "end", maxValue, + "min", 0d, + "max", maxValue + ) + )) + .toList(); + } + private Map getAxe(Dimension dimension) { Map axes = new HashMap<>(); axes.put("name", switch (dimension) { diff --git a/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriter.java b/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriter.java index ce961b634..cc7c56346 100644 --- a/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriter.java +++ b/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriter.java @@ -12,7 +12,6 @@ import org.slf4j.LoggerFactory; import qupath.lib.images.servers.ImageServer; import qupath.lib.images.servers.PixelCalibration; -import qupath.lib.images.servers.PixelType; import qupath.lib.images.servers.TileRequest; import ucar.ma2.InvalidRangeException; @@ -48,7 +47,6 @@ public class OMEZarrWriter implements AutoCloseable { private final ImageServer server; private final Compressor compressor; private final Map levelArrays; - private final DownSampledTileCreator downSampledTileCreator; private final ExecutorService executorService; private OMEZarrWriter(Builder builder) throws IOException { @@ -59,7 +57,10 @@ private OMEZarrWriter(Builder builder) throws IOException { builder.server.nChannels(), builder.server.getMetadata().getPixelCalibration().getPixelWidthUnit().equals(PixelCalibration.MICROMETER), builder.server.getMetadata().getTimeUnit(), - builder.server.getPreferredDownsamples() + builder.server.getPreferredDownsamples(), + builder.server.getMetadata().getChannels(), + builder.server.isRGB(), + builder.server.getPixelType() ); this.server = builder.server; @@ -71,10 +72,6 @@ private OMEZarrWriter(Builder builder) throws IOException { ), attributes.getLevelAttributes() ); - this.downSampledTileCreator = builder.downSamplesToCreate.isEmpty() ? null : new DownSampledTileCreator( - this::writeTile, - builder.downSamplesToCreate - ); this.executorService = Executors.newFixedThreadPool(builder.numberOfThreads); } @@ -98,10 +95,8 @@ public void close() throws InterruptedException { * The image will be written from an internal pool of thread, so this function may * return before the image is actually written. *

- * - * @throws IOException when an error occurs while writing the tile */ - public void writeImage() throws IOException { + public void writeImage() { for (TileRequest tileRequest: server.getTileRequestManager().getAllTileRequests()) { writeTile(tileRequest); } @@ -117,29 +112,21 @@ public void writeImage() throws IOException { *

* * @param tileRequest the tile to write - * @throws IOException when an error occurs while writing the tile */ - public void writeTile(TileRequest tileRequest) throws IOException { - try { - CompletableFuture.runAsync(() -> { - try { - Object data = getData( - server.readRegion(tileRequest.getRegionRequest()), - server.getPixelType(), - server.nChannels(), - tileRequest.getTileHeight(), - tileRequest.getTileWidth() - ); - - writeTile(tileRequest, data); - downSampledTileCreator.addTile(tileRequest, data); - } catch (IOException e) { - throw new RuntimeException(e); - } - }, executorService); - } catch (Exception e) { - throw new IOException(e); - } + public void writeTile(TileRequest tileRequest) { + CompletableFuture.runAsync(() -> { + try { + Object data = getData( + server.readRegion(tileRequest.getRegionRequest()), + tileRequest.getTileHeight(), + tileRequest.getTileWidth() + ); + + writeTile(tileRequest, data); + } catch (Exception e) { + logger.error("Error when writing tile", e); + } + }, executorService); } /** @@ -152,7 +139,6 @@ public static class Builder { private final String path; private Compressor compressor = CompressorFactory.createDefaultCompressor(); private int numberOfThreads = 12; - private List downSamplesToCreate = List.of(); /** * Create the builder. @@ -194,26 +180,6 @@ public Builder setNumberOfThreads(int numberOfThreads) { return this; } - /** - * When writing the image, additional levels not present in the original image can be created by - * downsampling the full resolution image. This function specifies the downsamples of such levels. - * By default, no additional levels will be created. - * - * @param downSamplesToCreate the downsamples of each level that should be added to the output image - * @return this builder - * @throws IllegalArgumentException when one of the provided downsamples is less than or equal to 1 - */ - public Builder setDownSamplesToCreate(List downSamplesToCreate) { - for (double downSample: downSamplesToCreate) { - if (downSample <= 1) { - throw new IllegalArgumentException(String.format("The provided downsample (%.2f) is less than or equal to 1", downSample)); - } - } - - this.downSamplesToCreate = downSamplesToCreate; - return this; - } - /** * Create a new instance of {@link OMEZarrWriter}. This will also * create an empty image on the provided path. @@ -300,7 +266,6 @@ private int[] getChunksOfImage() { chunks.add(Math.max(server.getMetadata().getPreferredTileWidth(), server.getMetadata().getPreferredTileHeight())); } chunks.add(server.getMetadata().getPreferredTileHeight()); - chunks.add(server.getMetadata().getPreferredTileWidth()); int[] chunksArray = new int[chunks.size()]; @@ -352,85 +317,101 @@ private int[] getOffsetsOfTile(TileRequest tileRequest) { return offsetArray; } - private static Object getData(BufferedImage image, PixelType pixelType, int numberOfChannels, int height, int width) { + private Object getData(BufferedImage image, int height, int width) { Object pixels = AWTImageTools.getPixels(image); - return switch (pixelType) { - case UINT8, INT8 -> { - byte[][] data = (byte[][]) pixels; - - byte[] output = new byte[numberOfChannels * width * height]; - int i = 0; - for (int c=0; c { - short[][] data = (short[][]) pixels; - - short[] output = new short[numberOfChannels * width * height]; - int i = 0; - for (int c=0; c { + byte[][] data = (byte[][]) pixels; + + byte[] output = new byte[server.nChannels() * width * height]; + int i = 0; + for (int c=0; c { - int[][] data = (int[][]) pixels; - - int[] output = new int[numberOfChannels * width * height]; - int i = 0; - for (int c=0; c { + short[][] data = (short[][]) pixels; + + short[] output = new short[server.nChannels() * width * height]; + int i = 0; + for (int c=0; c { - float[][] data = (float[][]) pixels; - - float[] output = new float[numberOfChannels * width * height]; - int i = 0; - for (int c=0; c { + int[][] data = (int[][]) pixels; + + int[] output = new int[server.nChannels() * width * height]; + int i = 0; + for (int c=0; c { - double[][] data = (double[][]) pixels; - - double[] output = new double[numberOfChannels * width * height]; - int i = 0; - for (int c=0; c { + float[][] data = (float[][]) pixels; + + float[] output = new float[server.nChannels() * width * height]; + int i = 0; + for (int c=0; c { + double[][] data = (double[][]) pixels; + + double[] output = new double[server.nChannels() * width * height]; + int i = 0; + for (int c=0; c Date: Thu, 7 Mar 2024 16:16:05 +0000 Subject: [PATCH 016/866] Handle opening Zarr files by selecting the .zattrs or .zgroup file --- .../images/servers/bioformats/BioFormatsImageServer.java | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/qupath-extension-bioformats/src/main/java/qupath/lib/images/servers/bioformats/BioFormatsImageServer.java b/qupath-extension-bioformats/src/main/java/qupath/lib/images/servers/bioformats/BioFormatsImageServer.java index e3b7705f4..66b64cf18 100644 --- a/qupath-extension-bioformats/src/main/java/qupath/lib/images/servers/bioformats/BioFormatsImageServer.java +++ b/qupath-extension-bioformats/src/main/java/qupath/lib/images/servers/bioformats/BioFormatsImageServer.java @@ -277,6 +277,12 @@ static BioFormatsImageServer checkSupport(URI uri, final BioFormatsServerOptions int width = 0, height = 0, nChannels = 1, nZSlices = 1, nTimepoints = 1, tileWidth = 0, tileHeight = 0; double pixelWidth = Double.NaN, pixelHeight = Double.NaN, zSpacing = Double.NaN, magnification = Double.NaN; TimeUnit timeUnit = null; + + // Zarr images can be opened by selecting the .zattrs or .zgroup file + // In that case, the parent directory contains the whole image + if (uri.toString().endsWith(".zattrs") || uri.toString().endsWith(".zgroup")) { + uri = new File(uri).getParentFile().toURI(); + } // See if there is a series name embedded in the path (temporarily the way things were done in v0.2.0-m1 and v0.2.0-m2) // Add it to the args if so From e3718bca46748e597d73a6c7349c4c17907ce52b Mon Sep 17 00:00:00 2001 From: Leo Leplat <60394504+Rylern@users.noreply.github.com> Date: Thu, 7 Mar 2024 16:16:39 +0000 Subject: [PATCH 017/866] Simplify OMEZarrWriter --- .../images/writers/ome/zarr/OMEZarrWriter.java | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriter.java b/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriter.java index cc7c56346..c9a5b1468 100644 --- a/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriter.java +++ b/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriter.java @@ -21,7 +21,6 @@ import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; @@ -114,19 +113,20 @@ public void writeImage() { * @param tileRequest the tile to write */ public void writeTile(TileRequest tileRequest) { - CompletableFuture.runAsync(() -> { + executorService.execute(() -> { try { - Object data = getData( - server.readRegion(tileRequest.getRegionRequest()), - tileRequest.getTileHeight(), - tileRequest.getTileWidth() + writeTile( + tileRequest, + getData( + server.readRegion(tileRequest.getRegionRequest()), + tileRequest.getTileHeight(), + tileRequest.getTileWidth() + ) ); - - writeTile(tileRequest, data); } catch (Exception e) { logger.error("Error when writing tile", e); } - }, executorService); + }); } /** From a0ffcf795577b1c4995dfcbe5a229db095423457 Mon Sep 17 00:00:00 2001 From: lleplat Date: Fri, 8 Mar 2024 13:09:56 +0000 Subject: [PATCH 018/866] Added possibility to define downsamples and max chunk size --- .../writers/ome/zarr/OMEZarrWriter.java | 146 ++++++++++++------ 1 file changed, 96 insertions(+), 50 deletions(-) diff --git a/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriter.java b/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriter.java index c9a5b1468..781b3032b 100644 --- a/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriter.java +++ b/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriter.java @@ -11,8 +11,10 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import qupath.lib.images.servers.ImageServer; +import qupath.lib.images.servers.ImageServers; import qupath.lib.images.servers.PixelCalibration; import qupath.lib.images.servers.TileRequest; +import qupath.lib.images.servers.TileRequestManager; import ucar.ma2.InvalidRangeException; import java.awt.image.BufferedImage; @@ -49,27 +51,33 @@ public class OMEZarrWriter implements AutoCloseable { private final ExecutorService executorService; private OMEZarrWriter(Builder builder) throws IOException { + this.server = builder.downsamples.length == 0 ? + builder.server : + ImageServers.pyramidalize( + builder.server, + builder.downsamples + ); + this.compressor = builder.compressor; + OMEZarrAttributesCreator attributes = new OMEZarrAttributesCreator( - builder.server.getMetadata().getName(), - builder.server.nZSlices(), - builder.server.nTimepoints(), - builder.server.nChannels(), - builder.server.getMetadata().getPixelCalibration().getPixelWidthUnit().equals(PixelCalibration.MICROMETER), - builder.server.getMetadata().getTimeUnit(), - builder.server.getPreferredDownsamples(), - builder.server.getMetadata().getChannels(), - builder.server.isRGB(), - builder.server.getPixelType() + server.getMetadata().getName(), + server.nZSlices(), + server.nTimepoints(), + server.nChannels(), + server.getMetadata().getPixelCalibration().getPixelWidthUnit().equals(PixelCalibration.MICROMETER), + server.getMetadata().getTimeUnit(), + server.getPreferredDownsamples(), + server.getMetadata().getChannels(), + server.isRGB(), + server.getPixelType() ); - - this.server = builder.server; - this.compressor = builder.compressor; this.levelArrays = createLevelArrays( ZarrGroup.create( builder.path, attributes.getGroupAttributes() ), - attributes.getLevelAttributes() + attributes.getLevelAttributes(), + builder.maxChunkSize ); this.executorService = Executors.newFixedThreadPool(builder.numberOfThreads); } @@ -109,6 +117,12 @@ public void writeImage() { * The tile will be written from an internal pool of thread, so this function may * return before the tile is actually written. *

+ *

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

* * @param tileRequest the tile to write */ @@ -117,11 +131,7 @@ public void writeTile(TileRequest tileRequest) { try { writeTile( tileRequest, - getData( - server.readRegion(tileRequest.getRegionRequest()), - tileRequest.getTileHeight(), - tileRequest.getTileWidth() - ) + getData(server.readRegion(tileRequest.getRegionRequest())) ); } catch (Exception e) { logger.error("Error when writing tile", e); @@ -139,6 +149,8 @@ public static class Builder { private final String path; private Compressor compressor = CompressorFactory.createDefaultCompressor(); private int numberOfThreads = 12; + private double[] downsamples = new double[0]; + private int maxChunkSize = 12; /** * Create the builder. @@ -180,6 +192,37 @@ public Builder setNumberOfThreads(int numberOfThreads) { return this; } + /** + *

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

+ *

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

+ * + * @param downsamples the downsamples of the pyramid to generate + * @return this builder + */ + public Builder setDownsamples(double... downsamples) { + this.downsamples = downsamples; + return this; + } + + /** + * In Zarr files, data is stored in chunks. This parameter + * defines the maximum number of chunks on the x,y, and z dimensions. + * By default, this value is set to 12. + * + * @param maxChunkSize the maximum number of chunks on the x,y, and z dimensions + * @return this builder + */ + public Builder setMaxChunkSize(int maxChunkSize) { + this.maxChunkSize = maxChunkSize; + return this; + } + /** * Create a new instance of {@link OMEZarrWriter}. This will also * create an empty image on the provided path. @@ -193,7 +236,7 @@ public OMEZarrWriter build() throws IOException { } } - private Map createLevelArrays(ZarrGroup root, Map levelAttributes) throws IOException { + private Map createLevelArrays(ZarrGroup root, Map levelAttributes, int maxChunkSize) throws IOException { Map levelArrays = new HashMap<>(); for (int level=0; level createLevelArrays(ZarrGroup root, Map DataType.u1; @@ -254,7 +297,10 @@ private int[] getDimensionsOfImage(int level) { return dimensionArray; } - private int[] getChunksOfImage() { + private int[] getChunksOfImage(int maxChunkSize) { + int chunkWidth = Math.max(server.getMetadata().getPreferredTileWidth(), server.getWidth() / maxChunkSize); + int chunkHeight = Math.max(server.getMetadata().getPreferredTileHeight(), server.getHeight() / maxChunkSize); + List chunks = new ArrayList<>(); if (server.nTimepoints() > 1) { chunks.add(1); @@ -263,10 +309,10 @@ private int[] getChunksOfImage() { chunks.add(1); } if (server.nZSlices() > 1) { - chunks.add(Math.max(server.getMetadata().getPreferredTileWidth(), server.getMetadata().getPreferredTileHeight())); + chunks.add(Math.max(chunkWidth, chunkHeight)); } - chunks.add(server.getMetadata().getPreferredTileHeight()); - chunks.add(server.getMetadata().getPreferredTileWidth()); + chunks.add(chunkHeight); + chunks.add(chunkWidth); int[] chunksArray = new int[chunks.size()]; for (int i = 0; i < chunks.size(); i++) { @@ -317,18 +363,18 @@ private int[] getOffsetsOfTile(TileRequest tileRequest) { return offsetArray; } - private Object getData(BufferedImage image, int height, int width) { + private Object getData(BufferedImage image) { Object pixels = AWTImageTools.getPixels(image); if (server.isRGB()) { int[][] data = (int[][]) pixels; - int[] output = new int[server.nChannels() * width * height]; + int[] output = new int[server.nChannels() * image.getWidth() * image.getHeight()]; int i = 0; for (int c=0; c { byte[][] data = (byte[][]) pixels; - byte[] output = new byte[server.nChannels() * width * height]; + byte[] output = new byte[server.nChannels() * image.getWidth() * image.getHeight()]; int i = 0; for (int c=0; c { short[][] data = (short[][]) pixels; - short[] output = new short[server.nChannels() * width * height]; + short[] output = new short[server.nChannels() * image.getWidth() * image.getHeight()]; int i = 0; for (int c=0; c { int[][] data = (int[][]) pixels; - int[] output = new int[server.nChannels() * width * height]; + int[] output = new int[server.nChannels() * image.getWidth() * image.getHeight()]; int i = 0; for (int c=0; c { float[][] data = (float[][]) pixels; - float[] output = new float[server.nChannels() * width * height]; + float[] output = new float[server.nChannels() * image.getWidth() * image.getHeight()]; int i = 0; for (int c=0; c { double[][] data = (double[][]) pixels; - double[] output = new double[server.nChannels() * width * height]; + double[] output = new double[server.nChannels() * image.getWidth() * image.getHeight()]; int i = 0; for (int c=0; c Date: Fri, 8 Mar 2024 16:23:07 +0000 Subject: [PATCH 019/866] Improved max number of chunks --- .../writers/ome/zarr/OMEZarrWriter.java | 55 +++++++++---------- 1 file changed, 26 insertions(+), 29 deletions(-) diff --git a/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriter.java b/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriter.java index 781b3032b..c7c155232 100644 --- a/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriter.java +++ b/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriter.java @@ -15,7 +15,6 @@ import qupath.lib.images.servers.PixelCalibration; import qupath.lib.images.servers.TileRequest; import qupath.lib.images.servers.TileRequestManager; -import ucar.ma2.InvalidRangeException; import java.awt.image.BufferedImage; import java.io.IOException; @@ -77,7 +76,7 @@ private OMEZarrWriter(Builder builder) throws IOException { attributes.getGroupAttributes() ), attributes.getLevelAttributes(), - builder.maxChunkSize + builder.maxNumberOfChunks ); this.executorService = Executors.newFixedThreadPool(builder.numberOfThreads); } @@ -129,9 +128,10 @@ public void writeImage() { public void writeTile(TileRequest tileRequest) { executorService.execute(() -> { try { - writeTile( - tileRequest, - getData(server.readRegion(tileRequest.getRegionRequest())) + levelArrays.get(tileRequest.getLevel()).write( + getData(server.readRegion(tileRequest.getRegionRequest())), + getDimensionsOfTile(tileRequest), + getOffsetsOfTile(tileRequest) ); } catch (Exception e) { logger.error("Error when writing tile", e); @@ -150,7 +150,7 @@ public static class Builder { private Compressor compressor = CompressorFactory.createDefaultCompressor(); private int numberOfThreads = 12; private double[] downsamples = new double[0]; - private int maxChunkSize = 12; + private int maxNumberOfChunks = 12; /** * Create the builder. @@ -211,15 +211,20 @@ public Builder setDownsamples(double... downsamples) { } /** - * In Zarr files, data is stored in chunks. This parameter - * defines the maximum number of chunks on the x,y, and z dimensions. - * By default, this value is set to 12. * - * @param maxChunkSize the maximum number of chunks on the x,y, and z dimensions + *

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

+ *

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

+ * + * @param maxNumberOfChunks the maximum number of chunks on the x,y, and z dimensions * @return this builder */ - public Builder setMaxChunkSize(int maxChunkSize) { - this.maxChunkSize = maxChunkSize; + public Builder setMaxNumberOfChunks(int maxNumberOfChunks) { + this.maxNumberOfChunks = maxNumberOfChunks; return this; } @@ -236,7 +241,7 @@ public OMEZarrWriter build() throws IOException { } } - private Map createLevelArrays(ZarrGroup root, Map levelAttributes, int maxChunkSize) throws IOException { + private Map createLevelArrays(ZarrGroup root, Map levelAttributes, int maxNumberOfChunks) throws IOException { Map levelArrays = new HashMap<>(); for (int level=0; level createLevelArrays(ZarrGroup root, Map DataType.u1; @@ -264,18 +269,6 @@ private Map createLevelArrays(ZarrGroup root, Map dimensions = new ArrayList<>(); if (server.nTimepoints() > 1) { @@ -297,9 +290,13 @@ private int[] getDimensionsOfImage(int level) { return dimensionArray; } - private int[] getChunksOfImage(int maxChunkSize) { - int chunkWidth = Math.max(server.getMetadata().getPreferredTileWidth(), server.getWidth() / maxChunkSize); - int chunkHeight = Math.max(server.getMetadata().getPreferredTileHeight(), server.getHeight() / maxChunkSize); + private int[] getChunksOfImage(int maxNumberOfChunks) { + int chunkWidth = maxNumberOfChunks > 0 ? + Math.max(server.getMetadata().getPreferredTileWidth(), server.getWidth() / maxNumberOfChunks) : + server.getMetadata().getPreferredTileWidth(); + int chunkHeight = maxNumberOfChunks > 0 ? + Math.max(server.getMetadata().getPreferredTileHeight(), server.getHeight() / maxNumberOfChunks) : + server.getMetadata().getPreferredTileHeight(); List chunks = new ArrayList<>(); if (server.nTimepoints() > 1) { From 768c05cc4143ad7dadc76e1674bba0b6baf699b2 Mon Sep 17 00:00:00 2001 From: lleplat Date: Mon, 11 Mar 2024 14:09:56 +0000 Subject: [PATCH 020/866] Enforce max number of chunks --- .../writers/ome/zarr/OMEZarrWriter.java | 77 ++++++++++++------- 1 file changed, 49 insertions(+), 28 deletions(-) diff --git a/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriter.java b/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriter.java index c7c155232..35e8745e7 100644 --- a/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriter.java +++ b/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriter.java @@ -45,18 +45,20 @@ public class OMEZarrWriter implements AutoCloseable { private static final Logger logger = LoggerFactory.getLogger(OMEZarrWriter.class); private final ImageServer server; - private final Compressor compressor; private final Map levelArrays; private final ExecutorService executorService; private OMEZarrWriter(Builder builder) throws IOException { - this.server = builder.downsamples.length == 0 ? - builder.server : - ImageServers.pyramidalize( - builder.server, - builder.downsamples - ); - this.compressor = builder.compressor; + server = ImageServers.pyramidalizeTiled( + builder.server, + builder.maxNumberOfChunks > 0 ? + Math.max(builder.server.getMetadata().getPreferredTileWidth(), builder.server.getWidth() / builder.maxNumberOfChunks) : + builder.server.getMetadata().getPreferredTileWidth(), + builder.maxNumberOfChunks > 0 ? + Math.max(builder.server.getMetadata().getPreferredTileHeight(), builder.server.getHeight() / builder.maxNumberOfChunks) : + builder.server.getMetadata().getPreferredTileHeight(), + builder.downsamples.length == 0 ? builder.server.getPreferredDownsamples() : builder.downsamples + ); OMEZarrAttributesCreator attributes = new OMEZarrAttributesCreator( server.getMetadata().getName(), @@ -70,15 +72,17 @@ private OMEZarrWriter(Builder builder) throws IOException { server.isRGB(), server.getPixelType() ); - this.levelArrays = createLevelArrays( + levelArrays = createLevelArrays( + server, ZarrGroup.create( builder.path, attributes.getGroupAttributes() ), attributes.getLevelAttributes(), - builder.maxNumberOfChunks + builder.compressor ); - this.executorService = Executors.newFixedThreadPool(builder.numberOfThreads); + + executorService = Executors.newFixedThreadPool(builder.numberOfThreads); } /** @@ -121,6 +125,8 @@ public void writeImage() { * {@link Builder#Builder(ImageServer, String)}. Therefore, the {@link ImageServer#getTileRequestManager() TileRequestManager} * of the internal image server may be different from the one of the provided image server, * so functions like {@link TileRequestManager#getAllTileRequests()} may not return the expected tiles. + * Use the {@link ImageServer#getTileRequestManager() TileRequestManager} of {@link #getReaderServer()} + * to get accurate tiles. *

* * @param tileRequest the tile to write @@ -139,6 +145,23 @@ public void writeTile(TileRequest tileRequest) { }); } + /** + * + *

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

+ *

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

+ * + * @return the image server used internally by this writer to read the tiles + */ + public ImageServer getReaderServer() { + return server; + } + /** * Builder to create an instance of a {@link OMEZarrWriter}. */ @@ -150,7 +173,7 @@ public static class Builder { private Compressor compressor = CompressorFactory.createDefaultCompressor(); private int numberOfThreads = 12; private double[] downsamples = new double[0]; - private int maxNumberOfChunks = 12; + private int maxNumberOfChunks = 30; /** * Create the builder. @@ -214,7 +237,7 @@ public Builder setDownsamples(double... downsamples) { * *

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

*

* Use a negative value to not define any maximum number of chunks. @@ -241,15 +264,20 @@ public OMEZarrWriter build() throws IOException { } } - private Map createLevelArrays(ZarrGroup root, Map levelAttributes, int maxNumberOfChunks) throws IOException { + private static Map createLevelArrays( + ImageServer server, + ZarrGroup root, + Map levelAttributes, + Compressor compressor + ) throws IOException { Map levelArrays = new HashMap<>(); for (int level=0; level DataType.u1; @@ -269,7 +297,7 @@ private Map createLevelArrays(ZarrGroup root, Map server, int level) { List dimensions = new ArrayList<>(); if (server.nTimepoints() > 1) { dimensions.add(server.nTimepoints()); @@ -290,14 +318,7 @@ private int[] getDimensionsOfImage(int level) { return dimensionArray; } - private int[] getChunksOfImage(int maxNumberOfChunks) { - int chunkWidth = maxNumberOfChunks > 0 ? - Math.max(server.getMetadata().getPreferredTileWidth(), server.getWidth() / maxNumberOfChunks) : - server.getMetadata().getPreferredTileWidth(); - int chunkHeight = maxNumberOfChunks > 0 ? - Math.max(server.getMetadata().getPreferredTileHeight(), server.getHeight() / maxNumberOfChunks) : - server.getMetadata().getPreferredTileHeight(); - + private static int[] getChunksOfImage(ImageServer server) { List chunks = new ArrayList<>(); if (server.nTimepoints() > 1) { chunks.add(1); @@ -306,10 +327,10 @@ private int[] getChunksOfImage(int maxNumberOfChunks) { chunks.add(1); } if (server.nZSlices() > 1) { - chunks.add(Math.max(chunkWidth, chunkHeight)); + chunks.add(Math.max(server.getMetadata().getPreferredTileWidth(), server.getMetadata().getPreferredTileHeight())); } - chunks.add(chunkHeight); - chunks.add(chunkWidth); + chunks.add(server.getMetadata().getPreferredTileHeight()); + chunks.add(server.getMetadata().getPreferredTileWidth()); int[] chunksArray = new int[chunks.size()]; for (int i = 0; i < chunks.size(); i++) { From abe5c4e620e4e27a133d0ea985ed8b69d88a46ee Mon Sep 17 00:00:00 2001 From: lleplat Date: Mon, 11 Mar 2024 14:44:47 +0000 Subject: [PATCH 021/866] Added tile size --- .../writers/ome/zarr/OMEZarrWriter.java | 73 ++++++++++++++++--- 1 file changed, 63 insertions(+), 10 deletions(-) diff --git a/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriter.java b/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriter.java index 35e8745e7..7c696a59f 100644 --- a/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriter.java +++ b/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriter.java @@ -51,12 +51,16 @@ public class OMEZarrWriter implements AutoCloseable { private OMEZarrWriter(Builder builder) throws IOException { server = ImageServers.pyramidalizeTiled( builder.server, - builder.maxNumberOfChunks > 0 ? - Math.max(builder.server.getMetadata().getPreferredTileWidth(), builder.server.getWidth() / builder.maxNumberOfChunks) : - builder.server.getMetadata().getPreferredTileWidth(), - builder.maxNumberOfChunks > 0 ? - Math.max(builder.server.getMetadata().getPreferredTileHeight(), builder.server.getHeight() / builder.maxNumberOfChunks) : - builder.server.getMetadata().getPreferredTileHeight(), + getChunkSize( + builder.tileWidth > 0 ? builder.tileWidth : builder.server.getMetadata().getPreferredTileWidth(), + builder.maxNumberOfChunks, + builder.server.getWidth() + ), + getChunkSize( + builder.tileHeight > 0 ? builder.tileHeight : builder.server.getMetadata().getPreferredTileHeight(), + builder.maxNumberOfChunks, + builder.server.getHeight() + ), builder.downsamples.length == 0 ? builder.server.getPreferredDownsamples() : builder.downsamples ); @@ -173,7 +177,9 @@ public static class Builder { private Compressor compressor = CompressorFactory.createDefaultCompressor(); private int numberOfThreads = 12; private double[] downsamples = new double[0]; - private int maxNumberOfChunks = 30; + private int maxNumberOfChunks = 50; + private int tileWidth = 512; + private int tileHeight = 512; /** * Create the builder. @@ -234,10 +240,9 @@ public Builder setDownsamples(double... downsamples) { } /** - * *

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

*

* Use a negative value to not define any maximum number of chunks. @@ -246,11 +251,53 @@ public Builder setDownsamples(double... downsamples) { * @param maxNumberOfChunks the maximum number of chunks on the x,y, and z dimensions * @return this builder */ - public Builder setMaxNumberOfChunks(int maxNumberOfChunks) { + public Builder setMaxNumberOfChunksOnEachSpatialDimension(int maxNumberOfChunks) { this.maxNumberOfChunks = maxNumberOfChunks; return this; } + /** + *

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

+ *

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

+ *

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

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

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

+ *

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

+ *

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

+ * + * @param tileHeight the height each chunk should have + * @return this builder + */ + public Builder setTileHeight(int tileHeight) { + this.tileHeight = tileHeight; + return this; + } + /** * Create a new instance of {@link OMEZarrWriter}. This will also * create an empty image on the provided path. @@ -264,6 +311,12 @@ public OMEZarrWriter build() throws IOException { } } + private static int getChunkSize(int tileSize, int maxNumberOfChunks, int imageSize) { + return maxNumberOfChunks > 0 ? + Math.max(tileSize, imageSize / maxNumberOfChunks) : + tileSize; + } + private static Map createLevelArrays( ImageServer server, ZarrGroup root, From e8f96669038246bbee90f63d9b194844f7bdf3f2 Mon Sep 17 00:00:00 2001 From: Leo Leplat <60394504+Rylern@users.noreply.github.com> Date: Tue, 12 Mar 2024 11:16:04 +0000 Subject: [PATCH 022/866] Typo --- .../writers/ome/zarr/OMEZarrWriter.java | 90 +++++++++---------- 1 file changed, 45 insertions(+), 45 deletions(-) diff --git a/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriter.java b/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriter.java index 7c696a59f..122a5aef4 100644 --- a/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriter.java +++ b/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriter.java @@ -280,13 +280,13 @@ public Builder setTileWidth(int tileWidth) { /** *

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

*

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

*

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

* @@ -392,48 +392,6 @@ private static int[] getChunksOfImage(ImageServer server) { return chunksArray; } - private int[] getDimensionsOfTile(TileRequest tileRequest) { - List dimensions = new ArrayList<>(); - if (server.nTimepoints() > 1) { - dimensions.add(1); - } - if (server.nChannels() > 1) { - dimensions.add(server.nChannels()); - } - if (server.nZSlices() > 1) { - dimensions.add(1); - } - dimensions.add(tileRequest.getTileHeight()); - dimensions.add(tileRequest.getTileWidth()); - - int[] dimensionArray = new int[dimensions.size()]; - for (int i = 0; i < dimensions.size(); i++) { - dimensionArray[i] = dimensions.get(i); - } - return dimensionArray; - } - - private int[] getOffsetsOfTile(TileRequest tileRequest) { - List offset = new ArrayList<>(); - if (server.nTimepoints() > 1) { - offset.add(tileRequest.getT()); - } - if (server.nChannels() > 1) { - offset.add(0); - } - if (server.nZSlices() > 1) { - offset.add(tileRequest.getZ()); - } - offset.add(tileRequest.getTileY()); - offset.add(tileRequest.getTileX()); - - int[] offsetArray = new int[offset.size()]; - for (int i = 0; i < offset.size(); i++) { - offsetArray[i] = offset.get(i); - } - return offsetArray; - } - private Object getData(BufferedImage image) { Object pixels = AWTImageTools.getPixels(image); @@ -531,4 +489,46 @@ private Object getData(BufferedImage image) { }; } } + + private int[] getDimensionsOfTile(TileRequest tileRequest) { + List dimensions = new ArrayList<>(); + if (server.nTimepoints() > 1) { + dimensions.add(1); + } + if (server.nChannels() > 1) { + dimensions.add(server.nChannels()); + } + if (server.nZSlices() > 1) { + dimensions.add(1); + } + dimensions.add(tileRequest.getTileHeight()); + dimensions.add(tileRequest.getTileWidth()); + + int[] dimensionArray = new int[dimensions.size()]; + for (int i = 0; i < dimensions.size(); i++) { + dimensionArray[i] = dimensions.get(i); + } + return dimensionArray; + } + + private int[] getOffsetsOfTile(TileRequest tileRequest) { + List offset = new ArrayList<>(); + if (server.nTimepoints() > 1) { + offset.add(tileRequest.getT()); + } + if (server.nChannels() > 1) { + offset.add(0); + } + if (server.nZSlices() > 1) { + offset.add(tileRequest.getZ()); + } + offset.add(tileRequest.getTileY()); + offset.add(tileRequest.getTileX()); + + int[] offsetArray = new int[offset.size()]; + for (int i = 0; i < offset.size(); i++) { + offsetArray[i] = offset.get(i); + } + return offsetArray; + } } From 09659c68e9115d0f9af1faa00fd468ced21ef1e2 Mon Sep 17 00:00:00 2001 From: Pete Date: Thu, 28 Mar 2024 12:08:17 +0000 Subject: [PATCH 023/866] Lazy-load ImageServer from ImageData Rough draft at an alternative approach to address the issue raised in https://github.com/qupath/qupath/pull/1488 --- .../java/qupath/lib/images/ImageData.java | 72 +++++++++++++++---- .../src/main/java/qupath/lib/io/PathIO.java | 69 +++++++++++++----- .../qupath/lib/projects/DefaultProject.java | 38 +++++----- .../gui/scripting/DefaultScriptEditor.java | 4 +- 4 files changed, 131 insertions(+), 52 deletions(-) diff --git a/qupath-core/src/main/java/qupath/lib/images/ImageData.java b/qupath-core/src/main/java/qupath/lib/images/ImageData.java index 824dceee4..c612906b3 100644 --- a/qupath-core/src/main/java/qupath/lib/images/ImageData.java +++ b/qupath-core/src/main/java/qupath/lib/images/ImageData.java @@ -4,7 +4,7 @@ * %% * Copyright (C) 2014 - 2016 The Queen's University of Belfast, Northern Ireland * Contact: IP Management (ipmanagement@qub.ac.uk) - * Copyright (C) 2018 - 2020 QuPath developers, The University of Edinburgh + * Copyright (C) 2018 - 2024 QuPath developers, The University of Edinburgh * %% * QuPath is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as @@ -29,6 +29,7 @@ import java.util.HashMap; import java.util.Map; import java.util.Objects; +import java.util.function.Supplier; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -60,8 +61,8 @@ * @param * */ -public class ImageData implements WorkflowListener, PathObjectHierarchyListener { - +public class ImageData implements WorkflowListener, PathObjectHierarchyListener, AutoCloseable { + /** * Enum representing possible image types. *

@@ -109,7 +110,10 @@ public String toString() { private static final Logger logger = LoggerFactory.getLogger(ImageData.class); private transient PropertyChangeSupport pcs; - + + private transient Supplier> serverSupplier; + private transient ImageServerMetadata lazyMetadata; + private transient ImageServer server; private String lastSavedPath = null; @@ -138,8 +142,9 @@ public String toString() { * @param hierarchy * @param type */ - public ImageData(ImageServer server, PathObjectHierarchy hierarchy, ImageType type) { - pcs = new PropertyChangeSupport(this); + public ImageData(Supplier> supplier, ImageServer server, PathObjectHierarchy hierarchy, ImageType type) { + this.pcs = new PropertyChangeSupport(this); + this.serverSupplier = supplier; this.server = server; this.hierarchy = hierarchy == null ? new PathObjectHierarchy() : hierarchy; this.serverPath = server == null ? null : server.getPath(); // TODO: Deal with sub image servers @@ -147,14 +152,18 @@ public ImageData(ImageServer server, PathObjectHierarchy hierarchy, ImageType if (type == null) type = ImageType.UNSET; setImageType(type); - + // Add listeners for changes this.hierarchy.addListener(this); workflow.addWorkflowListener(this); - + // Discard any changes during construction changes = false; } + + public ImageData(ImageServer server, PathObjectHierarchy hierarchy, ImageType type) { + this(null, server, hierarchy, type); + } /** * Create a new ImageData with a specified type and creating a new object hierarchy. @@ -231,6 +240,15 @@ public void setColorDeconvolutionStains(ColorDeconvolutionStains stains) { */ public void updateServerMetadata(ImageServerMetadata newMetadata) { Objects.requireNonNull(newMetadata); + if (server == null) { + if (serverSupplier == null) + throw new IllegalStateException("Cannot update server metadata without a server or server supplier"); + else { + logger.debug("Setting serve metadata lazily (no change will be fired)"); + lazyMetadata = newMetadata; + return; + } + } logger.trace("Updating server metadata"); var oldMetadata = server.getMetadata(); server.setMetadata(newMetadata); @@ -345,6 +363,13 @@ private static void addColorDeconvolutionStainsToWorkflow(ImageData imageData * @return */ public ImageServer getServer() { + if (server == null && serverSupplier != null) { + logger.debug("Lazily requesting image server"); + server = serverSupplier.get(); + if (lazyMetadata != null && !lazyMetadata.equals(server.getMetadata())) { + updateServerMetadata(lazyMetadata); + } + } return server; } @@ -506,14 +531,33 @@ public void hierarchyChanged(PathObjectHierarchyEvent event) { public void workflowUpdated(Workflow workflow) { changes = true; } - - + + /** + * Close the server if it has been loaded. + * @throws Exception + */ + @Override + public void close() throws Exception { + if (server != null) + server.close(); + } + + @Override public String toString() { - if (getServer() == null) - return "ImageData: " + getImageType() + ", no server"; - else - return "ImageData: " + getImageType() + ", " + ServerTools.getDisplayableImageName(getServer()); + String serverName; + if (server == null) { + if (serverSupplier == null) { + serverName = "no server"; + } else if (lazyMetadata != null){ + serverName = lazyMetadata.getName() + " (not yet loaded)"; + } else { + serverName = "lazy-loaded server"; + } + } else { + serverName = ServerTools.getDisplayableImageName(server); + } + return "ImageData: " + getImageType() + ", " + serverName; } diff --git a/qupath-core/src/main/java/qupath/lib/io/PathIO.java b/qupath-core/src/main/java/qupath/lib/io/PathIO.java index 36c42c297..29f194935 100644 --- a/qupath-core/src/main/java/qupath/lib/io/PathIO.java +++ b/qupath-core/src/main/java/qupath/lib/io/PathIO.java @@ -4,7 +4,7 @@ * %% * Copyright (C) 2014 - 2016 The Queen's University of Belfast, Northern Ireland * Contact: IP Management (ipmanagement@qub.ac.uk) - * Copyright (C) 2018 - 2020 QuPath developers, The University of Edinburgh + * Copyright (C) 2018 - 2024 QuPath developers, The University of Edinburgh * %% * QuPath is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as @@ -78,6 +78,7 @@ import java.util.Map.Entry; import java.util.Objects; import java.util.Set; +import java.util.function.Supplier; import java.util.stream.Collectors; import java.util.zip.GZIPInputStream; import java.util.zip.GZIPOutputStream; @@ -287,15 +288,29 @@ static ServerBuilderWrapper create(ServerBuilder builder, String id) } } - - - - private static ImageData readImageDataSerialized(final Path path, ImageData imageData, ImageServer server, Class cls) throws FileNotFoundException, IOException { + + + private static ImageData readImageDataSerialized(final Path path, ImageData imageData, + ImageServer server, Class cls) throws FileNotFoundException, IOException { + imageData = readImageDataSerialized(path, imageData, () -> server, cls); + imageData.getServer(); // Ensure the server is loaded + return imageData; + } + + private static ImageData readImageDataSerialized(final InputStream stream, ImageData imageData, + ImageServer server, Class cls) throws FileNotFoundException, IOException { + imageData = readImageDataSerialized(stream, imageData, () -> server, cls); + imageData.getServer(); // Ensure the server is loaded + return imageData; + } + + private static ImageData readImageDataSerialized(final Path path, ImageData imageData, + Supplier> serverSupplier, Class cls) throws FileNotFoundException, IOException { if (path == null) return null; logger.info("Reading data from {}...", path.getFileName().toString()); try (InputStream stream = Files.newInputStream(path)) { - imageData = readImageDataSerialized(stream, imageData, server, cls); + imageData = readImageDataSerialized(stream, imageData, serverSupplier, cls); // Set the last saved path (actually the path from which this was opened) if (imageData != null) imageData.setLastSavedPath(path.toAbsolutePath().toString(), true); @@ -307,7 +322,7 @@ private static ImageData readImageDataSerialized(final Path path, ImageDa } @SuppressWarnings("unchecked") - private static ImageData readImageDataSerialized(final InputStream stream, ImageData imageData, ImageServer server, Class cls) throws IOException { + private static ImageData readImageDataSerialized(final InputStream stream, ImageData imageData, Supplier> serverSupplier, Class cls) throws IOException { long startTime = System.currentTimeMillis(); Locale locale = Locale.getDefault(Category.FORMAT); @@ -338,7 +353,7 @@ private static ImageData readImageDataSerialized(final InputStream stream String serverString = (String)inStream.readObject(); // Don't log warnings if we are provided with a server - serverBuilder = extractServerBuilder(serverString, server == null); + serverBuilder = extractServerBuilder(serverString, serverSupplier == null); while (true) { // logger.debug("Starting read: " + inStream.available()); @@ -395,23 +410,27 @@ else if (input == null) { var existingBuilder = imageData == null || imageData.getServer() == null ? null : imageData.getServer().getBuilder(); if (imageData == null || !Objects.equals(serverBuilder, existingBuilder)) { // Create a new server if we need to - if (server == null) { - try { - server = serverBuilder.build(); - } catch (Exception e) { - logger.error(e.getLocalizedMessage()); + if (serverSupplier == null) { + // Load the server lazily + var builder = serverBuilder; + serverSupplier = () -> { + try { + return builder.build(); + } catch (Exception e) { + logger.error("Warning: Unable to build server with " + builder); + if (e instanceof RuntimeException runtimeException) + throw runtimeException; + else + throw new RuntimeException(e); + } }; - if (server == null) { - logger.error("Warning: Unable to build server with " + serverBuilder); - // throw new RuntimeException("Warning: Unable to create server for path " + serverPath); - } } // TODO: Make this less clumsy... but for now we need to ensure we have a fully-initialized hierarchy (which deserialization alone doesn't achieve) PathObjectHierarchy hierarchy2 = new PathObjectHierarchy(); hierarchy2.setHierarchy(hierarchy); hierarchy = hierarchy2; - imageData = new ImageData<>(server, hierarchy, imageType); + imageData = new ImageData<>(serverSupplier, null, hierarchy, imageType); } else { if (imageType != null) imageData.setImageType(imageType); @@ -487,6 +506,20 @@ public static ImageData readImageData(final InputStream stream, ImageData return readImageDataSerialized(stream, imageData, server, cls); } + + /** + * Read an ImageData with lazy image loading. + * @param stream + * @param serverSupplier + * @param cls + * @return + * @param + * @throws IOException + */ + public static ImageData readLazyImageData(final InputStream stream, Supplier> serverSupplier, Class cls) throws IOException { + return readImageDataSerialized(stream, null, serverSupplier, cls); + } + /** * Read ImageData from a File into an existing ImageData object, or create a new one if required. diff --git a/qupath-core/src/main/java/qupath/lib/projects/DefaultProject.java b/qupath-core/src/main/java/qupath/lib/projects/DefaultProject.java index 0523f07aa..04889a001 100644 --- a/qupath-core/src/main/java/qupath/lib/projects/DefaultProject.java +++ b/qupath-core/src/main/java/qupath/lib/projects/DefaultProject.java @@ -4,7 +4,7 @@ * %% * Copyright (C) 2014 - 2016 The Queen's University of Belfast, Northern Ireland * Contact: IP Management (ipmanagement@qub.ac.uk) - * Copyright (C) 2018 - 2020 QuPath developers, The University of Edinburgh + * Copyright (C) 2018 - 2024 QuPath developers, The University of Edinburgh * %% * QuPath is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as @@ -47,6 +47,7 @@ import java.util.Map.Entry; import java.util.Objects; import java.util.concurrent.atomic.AtomicLong; +import java.util.function.Supplier; import java.util.stream.Collectors; import javax.imageio.ImageIO; @@ -688,20 +689,25 @@ private Path getThumbnailPath() { @Override public synchronized ImageData readImageData() throws IOException { Path path = getImageDataPath(); - ImageServer server; - try { - server = getServerBuilder().build(); - } catch (IOException e) { - throw e; - } catch (Exception e) { - throw new IOException(e); - } - if (server == null) - return null; + Supplier> serverSupplier = () -> { + try { + var server = getServerBuilder().build(); + // Ensure the names match + var name = getOriginalImageName(); + if (name != null) + ServerTools.setImageName(server, name); + return server; + } catch (Exception e) { + if (e instanceof RuntimeException exception) + throw exception; + else + throw new RuntimeException(e); + } + }; ImageData imageData = null; if (Files.exists(path)) { try (var stream = Files.newInputStream(path)) { - imageData = PathIO.readImageData(stream, null, server, BufferedImage.class); + imageData = PathIO.readLazyImageData(stream, serverSupplier, BufferedImage.class); imageData.setLastSavedPath(path.toString(), true); } catch (Exception e) { logger.error("Error reading image data from " + path, e); @@ -713,7 +719,7 @@ public synchronized ImageData readImageData() throws IOException var pathBackup = getBackupImageDataPath(); if (Files.exists(pathBackup)) { try (var stream = Files.newInputStream(pathBackup)) { - imageData = PathIO.readImageData(stream, null, server, BufferedImage.class); + imageData = PathIO.readLazyImageData(stream, serverSupplier, BufferedImage.class); imageData.setLastSavedPath(pathBackup.toString(), true); logger.warn("Restored previous ImageData from {}", pathBackup); } catch (IOException e) { @@ -723,11 +729,7 @@ public synchronized ImageData readImageData() throws IOException } if (imageData == null) - imageData = new ImageData<>(server); - // Ensure the names match - var name = getOriginalImageName(); - if (name != null) - ServerTools.setImageName(server, name); + imageData = new ImageData<>(serverSupplier, null, new PathObjectHierarchy(), ImageType.UNSET); imageData.setProperty(IMAGE_ID, getFullProjectEntryID()); // Required to be able to test for the ID later imageData.setChanged(false); return imageData; diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/scripting/DefaultScriptEditor.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/scripting/DefaultScriptEditor.java index e7b7be578..4735744ac 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/scripting/DefaultScriptEditor.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/scripting/DefaultScriptEditor.java @@ -4,7 +4,7 @@ * %% * Copyright (C) 2014 - 2016 The Queen's University of Belfast, Northern Ireland * Contact: IP Management (ipmanagement@qub.ac.uk) - * Copyright (C) 2018 - 2022 QuPath developers, The University of Edinburgh + * Copyright (C) 2018 - 2024 QuPath developers, The University of Edinburgh * %% * QuPath is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as @@ -1730,7 +1730,7 @@ public Void call() { executeScript(tab, tab.getEditorControl().getText(), project, imageData, batchIndex, batchSize, doSave, useCompiled); if (doSave) entry.saveImageData(imageData); - imageData.getServer().close(); + imageData.close(); if (clearCache.get()) { try { From aae4fdf8453d40340b01cbd3daf74a47ed8bc736 Mon Sep 17 00:00:00 2001 From: Pete Date: Thu, 28 Mar 2024 12:19:28 +0000 Subject: [PATCH 024/866] Synchronize lazy loading in ImageData --- .../src/main/java/qupath/lib/images/ImageData.java | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/qupath-core/src/main/java/qupath/lib/images/ImageData.java b/qupath-core/src/main/java/qupath/lib/images/ImageData.java index c612906b3..29f447f44 100644 --- a/qupath-core/src/main/java/qupath/lib/images/ImageData.java +++ b/qupath-core/src/main/java/qupath/lib/images/ImageData.java @@ -364,10 +364,14 @@ private static void addColorDeconvolutionStainsToWorkflow(ImageData imageData */ public ImageServer getServer() { if (server == null && serverSupplier != null) { - logger.debug("Lazily requesting image server"); - server = serverSupplier.get(); - if (lazyMetadata != null && !lazyMetadata.equals(server.getMetadata())) { - updateServerMetadata(lazyMetadata); + synchronized (this) { + if (server == null) { + logger.debug("Lazily requesting image server"); + server = serverSupplier.get(); + if (lazyMetadata != null && !lazyMetadata.equals(server.getMetadata())) { + updateServerMetadata(lazyMetadata); + } + } } } return server; From 1a559eb93565294a9f7df100a7e0d3ffd29b4f47 Mon Sep 17 00:00:00 2001 From: Pete Date: Fri, 12 Apr 2024 12:31:28 +0100 Subject: [PATCH 025/866] Bump v0.6.0-SNAPSHOT --- qupath-app/src/main/resources/VERSION | 2 +- qupath-core-processing/src/main/resources/VERSION | 2 +- qupath-core/src/main/resources/VERSION | 2 +- qupath-extension-bioformats/src/main/resources/VERSION | 2 +- qupath-extension-openslide/src/main/resources/VERSION | 2 +- qupath-extension-processing/src/main/resources/VERSION | 2 +- qupath-extension-script-editor/src/main/resources/VERSION | 2 +- qupath-extension-svg/src/main/resources/VERSION | 2 +- qupath-gui-fx/src/main/resources/VERSION | 2 +- settings.gradle | 3 +-- 10 files changed, 10 insertions(+), 11 deletions(-) diff --git a/qupath-app/src/main/resources/VERSION b/qupath-app/src/main/resources/VERSION index 5d4294b91..e75e24346 100644 --- a/qupath-app/src/main/resources/VERSION +++ b/qupath-app/src/main/resources/VERSION @@ -1 +1 @@ -0.5.1 \ No newline at end of file +0.6.0-SNAPSHOT \ No newline at end of file diff --git a/qupath-core-processing/src/main/resources/VERSION b/qupath-core-processing/src/main/resources/VERSION index 5d4294b91..e75e24346 100644 --- a/qupath-core-processing/src/main/resources/VERSION +++ b/qupath-core-processing/src/main/resources/VERSION @@ -1 +1 @@ -0.5.1 \ No newline at end of file +0.6.0-SNAPSHOT \ No newline at end of file diff --git a/qupath-core/src/main/resources/VERSION b/qupath-core/src/main/resources/VERSION index 5d4294b91..e75e24346 100644 --- a/qupath-core/src/main/resources/VERSION +++ b/qupath-core/src/main/resources/VERSION @@ -1 +1 @@ -0.5.1 \ No newline at end of file +0.6.0-SNAPSHOT \ No newline at end of file diff --git a/qupath-extension-bioformats/src/main/resources/VERSION b/qupath-extension-bioformats/src/main/resources/VERSION index 5d4294b91..e75e24346 100644 --- a/qupath-extension-bioformats/src/main/resources/VERSION +++ b/qupath-extension-bioformats/src/main/resources/VERSION @@ -1 +1 @@ -0.5.1 \ No newline at end of file +0.6.0-SNAPSHOT \ No newline at end of file diff --git a/qupath-extension-openslide/src/main/resources/VERSION b/qupath-extension-openslide/src/main/resources/VERSION index 5d4294b91..e75e24346 100644 --- a/qupath-extension-openslide/src/main/resources/VERSION +++ b/qupath-extension-openslide/src/main/resources/VERSION @@ -1 +1 @@ -0.5.1 \ No newline at end of file +0.6.0-SNAPSHOT \ No newline at end of file diff --git a/qupath-extension-processing/src/main/resources/VERSION b/qupath-extension-processing/src/main/resources/VERSION index 5d4294b91..e75e24346 100644 --- a/qupath-extension-processing/src/main/resources/VERSION +++ b/qupath-extension-processing/src/main/resources/VERSION @@ -1 +1 @@ -0.5.1 \ No newline at end of file +0.6.0-SNAPSHOT \ No newline at end of file diff --git a/qupath-extension-script-editor/src/main/resources/VERSION b/qupath-extension-script-editor/src/main/resources/VERSION index 5d4294b91..e75e24346 100644 --- a/qupath-extension-script-editor/src/main/resources/VERSION +++ b/qupath-extension-script-editor/src/main/resources/VERSION @@ -1 +1 @@ -0.5.1 \ No newline at end of file +0.6.0-SNAPSHOT \ No newline at end of file diff --git a/qupath-extension-svg/src/main/resources/VERSION b/qupath-extension-svg/src/main/resources/VERSION index 5d4294b91..e75e24346 100644 --- a/qupath-extension-svg/src/main/resources/VERSION +++ b/qupath-extension-svg/src/main/resources/VERSION @@ -1 +1 @@ -0.5.1 \ No newline at end of file +0.6.0-SNAPSHOT \ No newline at end of file diff --git a/qupath-gui-fx/src/main/resources/VERSION b/qupath-gui-fx/src/main/resources/VERSION index 5d4294b91..e75e24346 100644 --- a/qupath-gui-fx/src/main/resources/VERSION +++ b/qupath-gui-fx/src/main/resources/VERSION @@ -1 +1 @@ -0.5.1 \ No newline at end of file +0.6.0-SNAPSHOT \ No newline at end of file diff --git a/settings.gradle b/settings.gradle index de2f1d85e..5d43ef8f6 100644 --- a/settings.gradle +++ b/settings.gradle @@ -2,7 +2,7 @@ plugins { id 'org.gradle.toolchains.foojay-resolver-convention' version '0.7.0' // to download if needed } -gradle.ext.qupathVersion = "0.5.1" +gradle.ext.qupathVersion = "0.6.0-SNAPSHOT" rootProject.name = 'qupath' @@ -36,4 +36,3 @@ dependencyResolutionManagement { } } } - From 2e4713c634bb454390d2d8c444c3ebb71dfaa107 Mon Sep 17 00:00:00 2001 From: Pete Date: Fri, 12 Apr 2024 12:38:45 +0100 Subject: [PATCH 026/866] Fix WrappedBufferedImageServer bug Fixes https://github.com/qupath/qupath/issues/1494 --- CHANGELOG.md | 7 +++++++ .../lib/images/servers/WrappedBufferedImageServer.java | 6 ++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 56fd9deac..0341c5a87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## Version 0.6.0-SNAPSHOT + +This is a *work in progress* for the next major release. + +### Bugs fixed +* Tile export to .ome.tif can convert to 8-bit unnecessarily (https://github.com/qupath/qupath/issues/1494) + ## Version 0.5.1 This is a *minor release* that aims to be fully compatible with v0.5.0, while fixing several bugs. diff --git a/qupath-core/src/main/java/qupath/lib/images/servers/WrappedBufferedImageServer.java b/qupath-core/src/main/java/qupath/lib/images/servers/WrappedBufferedImageServer.java index b015eabdf..23e2c03e8 100644 --- a/qupath-core/src/main/java/qupath/lib/images/servers/WrappedBufferedImageServer.java +++ b/qupath-core/src/main/java/qupath/lib/images/servers/WrappedBufferedImageServer.java @@ -101,8 +101,10 @@ public WrappedBufferedImageServer(final String imageName, final BufferedImage im int nChannels = img.getSampleModel().getNumBands(); boolean isRGB = false; for (int type : rgbTypes) { - isRGB = isRGB | type == img.getType(); - pixelType = PixelType.UINT8; + if (type == img.getType()) { + isRGB = true; + pixelType = PixelType.UINT8; + } } // Warning! This method of obtaining channels risks resulting in different colors from the original image if (channels == null) { From 3c437522f4a5fc07b0e46686b8c067e5626f7192 Mon Sep 17 00:00:00 2001 From: Pete Date: Fri, 12 Apr 2024 13:18:51 +0100 Subject: [PATCH 027/866] Update dependencies These will likely cause some problems: * Any JavaFX from 20.0.1 onwards breaks (javascript) search links within javadocs - at least when accessing javadocs from within a .jar * Deep Java Library has probably broken the approach previously used to test if an `Engine` is available before attempting to download it Nevertheless, we don't want to be stuck on old dependencies - so merging early in the path towards v0.6.0 will hopefully give us time to find fixes/workarounds. This commit also updates the pref name to have the correct QuPath version. --- CHANGELOG.md | 14 +++++ gradle/libs.versions.toml | 48 ++++++++---------- gradle/wrapper/gradle-wrapper.jar | Bin 63721 -> 43462 bytes gradle/wrapper/gradle-wrapper.properties | 2 +- .../java/qupath/opencv/dnn/OpenCVDnn.java | 2 +- .../java/qupath/lib/gui/prefs/PathPrefs.java | 4 +- 6 files changed, 38 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0341c5a87..ba80f6ebe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,20 @@ This is a *work in progress* for the next major release. ### Bugs fixed * Tile export to .ome.tif can convert to 8-bit unnecessarily (https://github.com/qupath/qupath/issues/1494) +### Dependency updates +* Bio-Formats 7.2.0 +* Commonmark 0.22.0 +* DeepJavaLibrary 0.27.0 +* Groovy 4.0.21 +* Guava 33.1.0-jre +* ImageJ 1.54i +* JavaFX 22 +* JNA 5.14.0 +* OpenCV 4.9.0 +* OpenJDK 21 +* slf4j 2.0.12 + + ## Version 0.5.1 This is a *minor release* that aims to be fully compatible with v0.5.0, while fixing several bugs. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b3ef2b473..e6e613976 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,42 +1,34 @@ [versions] -bioformats = "7.0.1" +bioformats = "7.2.0" bioimageIoSpec = "0.1.0" -commonmark = "0.21.0" +commonmark = "0.22.0" commonsMath3 = "3.6.1" commonsText = "1.10.0" controlsFX = "11.1.2" -deepJavaLibrary = "0.24.0" +deepJavaLibrary = "0.27.0" -groovy = "4.0.15" +groovy = "4.0.21" gson = "2.10.1" -guava = "32.1.3-jre" +guava = "33.1.0-jre" ikonli = "12.3.1" -imagej = "1.54f" - -# Compatibility with Java 17 with QuPath v0.5.x (may update to 21 in the future) -jdk = "17" - -# Would ideally use JavaCPP 1.5.9 for gradle fixes: https://github.com/bytedeco/gradle-javacpp/issues/28 -# But need JavaCPP 1.5.8 for OpenCV DNN: https://github.com/qupath/qupath/issues/1406 -# When JavaCPP 1.5.10 is released we should be able to just use that for both. -javacppgradle = "1.5.9" -javacpp = "1.5.8" -# Stuck on 4.6.0 because of problem with OpenCVN DNN running x86_64 build on Apple Silicon -# https://github.com/qupath/qupath/issues/1406 -# This is fixed in 4.8.0, but that requires JavaCPP 1.5.10 (not released yet) -opencv = "4.6.0-1.5.8" -cuda = "11.8-8.6-1.5.8" -# opencv = "4.7.0-1.5.9" -# cuda = "12.1-8.9-1.5.9" - - -# JavaFX 20.0.1 and later seem to break search links in Javadocs -javafx = "20" -jna = "5.13.0" +imagej = "1.54i" + +# Compatibility with Java 21 with QuPath v0.6.x +jdk = "21" + +javacppgradle = "1.5.10" +javacpp = "1.5.10" + +opencv = "4.9.0-1.5.10" +cuda = "12.3-8.9-1.5.10" + +# Warning! JavaFX 20.0.1 and later seem to break search links in Javadocs +javafx = "22" +jna = "5.14.0" jfreeSvg = "5.0.5" jfxtras = "17-r1" jts = "1.19.0" @@ -52,7 +44,7 @@ qupath-fxtras = "0.1.4" richtextfx = "0.11.2" -slf4j = "2.0.9" +slf4j = "2.0.12" snakeyaml = "2.2" diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 7f93135c49b765f8051ef9d0a6055ff8e46073d8..d64cd4917707c1f8861d8cb53dd15194d4248596 100644 GIT binary patch literal 43462 zcma&NWl&^owk(X(xVyW%ySuwf;qI=D6|RlDJ2cR^yEKh!@I- zp9QeisK*rlxC>+~7Dk4IxIRsKBHqdR9b3+fyL=ynHmIDe&|>O*VlvO+%z5;9Z$|DJ zb4dO}-R=MKr^6EKJiOrJdLnCJn>np?~vU-1sSFgPu;pthGwf}bG z(1db%xwr#x)r+`4AGu$j7~u2MpVs3VpLp|mx&;>`0p0vH6kF+D2CY0fVdQOZ@h;A` z{infNyvmFUiu*XG}RNMNwXrbec_*a3N=2zJ|Wh5z* z5rAX$JJR{#zP>KY**>xHTuw?|-Rg|o24V)74HcfVT;WtQHXlE+_4iPE8QE#DUm%x0 zEKr75ur~W%w#-My3Tj`hH6EuEW+8K-^5P62$7Sc5OK+22qj&Pd1;)1#4tKihi=~8C zHiQSst0cpri6%OeaR`PY>HH_;CPaRNty%WTm4{wDK8V6gCZlG@U3$~JQZ;HPvDJcT1V{ z?>H@13MJcCNe#5z+MecYNi@VT5|&UiN1D4ATT+%M+h4c$t;C#UAs3O_q=GxK0}8%8 z8J(_M9bayxN}69ex4dzM_P3oh@ZGREjVvn%%r7=xjkqxJP4kj}5tlf;QosR=%4L5y zWhgejO=vao5oX%mOHbhJ8V+SG&K5dABn6!WiKl{|oPkq(9z8l&Mm%(=qGcFzI=eLu zWc_oCLyf;hVlB@dnwY98?75B20=n$>u3b|NB28H0u-6Rpl((%KWEBOfElVWJx+5yg z#SGqwza7f}$z;n~g%4HDU{;V{gXIhft*q2=4zSezGK~nBgu9-Q*rZ#2f=Q}i2|qOp z!!y4p)4o=LVUNhlkp#JL{tfkhXNbB=Ox>M=n6soptJw-IDI|_$is2w}(XY>a=H52d z3zE$tjPUhWWS+5h=KVH&uqQS=$v3nRs&p$%11b%5qtF}S2#Pc`IiyBIF4%A!;AVoI zXU8-Rpv!DQNcF~(qQnyyMy=-AN~U>#&X1j5BLDP{?K!%h!;hfJI>$mdLSvktEr*89 zdJHvby^$xEX0^l9g$xW-d?J;L0#(`UT~zpL&*cEh$L|HPAu=P8`OQZV!-}l`noSp_ zQ-1$q$R-gDL)?6YaM!=8H=QGW$NT2SeZlb8PKJdc=F-cT@j7Xags+Pr*jPtlHFnf- zh?q<6;)27IdPc^Wdy-mX%2s84C1xZq9Xms+==F4);O`VUASmu3(RlgE#0+#giLh-& zcxm3_e}n4{%|X zJp{G_j+%`j_q5}k{eW&TlP}J2wtZ2^<^E(O)4OQX8FDp6RJq!F{(6eHWSD3=f~(h} zJXCf7=r<16X{pHkm%yzYI_=VDP&9bmI1*)YXZeB}F? z(%QsB5fo*FUZxK$oX~X^69;x~j7ms8xlzpt-T15e9}$4T-pC z6PFg@;B-j|Ywajpe4~bk#S6(fO^|mm1hKOPfA%8-_iGCfICE|=P_~e;Wz6my&)h_~ zkv&_xSAw7AZ%ThYF(4jADW4vg=oEdJGVOs>FqamoL3Np8>?!W#!R-0%2Bg4h?kz5I zKV-rKN2n(vUL%D<4oj@|`eJ>0i#TmYBtYmfla;c!ATW%;xGQ0*TW@PTlGG><@dxUI zg>+3SiGdZ%?5N=8uoLA|$4isK$aJ%i{hECP$bK{J#0W2gQ3YEa zZQ50Stn6hqdfxJ*9#NuSLwKFCUGk@c=(igyVL;;2^wi4o30YXSIb2g_ud$ zgpCr@H0qWtk2hK8Q|&wx)}4+hTYlf;$a4#oUM=V@Cw#!$(nOFFpZ;0lc!qd=c$S}Z zGGI-0jg~S~cgVT=4Vo)b)|4phjStD49*EqC)IPwyeKBLcN;Wu@Aeph;emROAwJ-0< z_#>wVm$)ygH|qyxZaet&(Vf%pVdnvKWJn9`%DAxj3ot;v>S$I}jJ$FLBF*~iZ!ZXE zkvui&p}fI0Y=IDX)mm0@tAd|fEHl~J&K}ZX(Mm3cm1UAuwJ42+AO5@HwYfDH7ipIc zmI;1J;J@+aCNG1M`Btf>YT>~c&3j~Qi@Py5JT6;zjx$cvOQW@3oQ>|}GH?TW-E z1R;q^QFjm5W~7f}c3Ww|awg1BAJ^slEV~Pk`Kd`PS$7;SqJZNj->it4DW2l15}xP6 zoCl$kyEF%yJni0(L!Z&14m!1urXh6Btj_5JYt1{#+H8w?5QI%% zo-$KYWNMJVH?Hh@1n7OSu~QhSswL8x0=$<8QG_zepi_`y_79=nK=_ZP_`Em2UI*tyQoB+r{1QYZCpb?2OrgUw#oRH$?^Tj!Req>XiE#~B|~ z+%HB;=ic+R@px4Ld8mwpY;W^A%8%l8$@B@1m5n`TlKI6bz2mp*^^^1mK$COW$HOfp zUGTz-cN9?BGEp}5A!mDFjaiWa2_J2Iq8qj0mXzk; z66JBKRP{p%wN7XobR0YjhAuW9T1Gw3FDvR5dWJ8ElNYF94eF3ebu+QwKjtvVu4L zI9ip#mQ@4uqVdkl-TUQMb^XBJVLW(-$s;Nq;@5gr4`UfLgF$adIhd?rHOa%D);whv z=;krPp~@I+-Z|r#s3yCH+c1US?dnm+C*)r{m+86sTJusLdNu^sqLrfWed^ndHXH`m zd3#cOe3>w-ga(Dus_^ppG9AC>Iq{y%%CK+Cro_sqLCs{VLuK=dev>OL1dis4(PQ5R zcz)>DjEkfV+MO;~>VUlYF00SgfUo~@(&9$Iy2|G0T9BSP?&T22>K46D zL*~j#yJ?)^*%J3!16f)@Y2Z^kS*BzwfAQ7K96rFRIh>#$*$_Io;z>ux@}G98!fWR@ zGTFxv4r~v)Gsd|pF91*-eaZ3Qw1MH$K^7JhWIdX%o$2kCbvGDXy)a?@8T&1dY4`;L z4Kn+f%SSFWE_rpEpL9bnlmYq`D!6F%di<&Hh=+!VI~j)2mfil03T#jJ_s?}VV0_hp z7T9bWxc>Jm2Z0WMU?`Z$xE74Gu~%s{mW!d4uvKCx@WD+gPUQ zV0vQS(Ig++z=EHN)BR44*EDSWIyT~R4$FcF*VEY*8@l=218Q05D2$|fXKFhRgBIEE zdDFB}1dKkoO^7}{5crKX!p?dZWNz$m>1icsXG2N+((x0OIST9Zo^DW_tytvlwXGpn zs8?pJXjEG;T@qrZi%#h93?FP$!&P4JA(&H61tqQi=opRzNpm zkrG}$^t9&XduK*Qa1?355wd8G2CI6QEh@Ua>AsD;7oRUNLPb76m4HG3K?)wF~IyS3`fXuNM>${?wmB zpVz;?6_(Fiadfd{vUCBM*_kt$+F3J+IojI;9L(gc9n3{sEZyzR9o!_mOwFC#tQ{Q~ zP3-`#uK#tP3Q7~Q;4H|wjZHO8h7e4IuBxl&vz2w~D8)w=Wtg31zpZhz%+kzSzL*dV zwp@{WU4i;hJ7c2f1O;7Mz6qRKeASoIv0_bV=i@NMG*l<#+;INk-^`5w@}Dj~;k=|}qM1vq_P z|GpBGe_IKq|LNy9SJhKOQ$c=5L{Dv|Q_lZl=-ky*BFBJLW9&y_C|!vyM~rQx=!vun z?rZJQB5t}Dctmui5i31C_;_}CEn}_W%>oSXtt>@kE1=JW*4*v4tPp;O6 zmAk{)m!)}34pTWg8{i>($%NQ(Tl;QC@J@FfBoc%Gr&m560^kgSfodAFrIjF}aIw)X zoXZ`@IsMkc8_=w%-7`D6Y4e*CG8k%Ud=GXhsTR50jUnm+R*0A(O3UKFg0`K;qp1bl z7``HN=?39ic_kR|^R^~w-*pa?Vj#7|e9F1iRx{GN2?wK!xR1GW!qa=~pjJb-#u1K8 zeR?Y2i-pt}yJq;SCiVHODIvQJX|ZJaT8nO+(?HXbLefulKKgM^B(UIO1r+S=7;kLJ zcH}1J=Px2jsh3Tec&v8Jcbng8;V-`#*UHt?hB(pmOipKwf3Lz8rG$heEB30Sg*2rx zV<|KN86$soN(I!BwO`1n^^uF2*x&vJ$2d$>+`(romzHP|)K_KkO6Hc>_dwMW-M(#S zK(~SiXT1@fvc#U+?|?PniDRm01)f^#55;nhM|wi?oG>yBsa?~?^xTU|fX-R(sTA+5 zaq}-8Tx7zrOy#3*JLIIVsBmHYLdD}!0NP!+ITW+Thn0)8SS!$@)HXwB3tY!fMxc#1 zMp3H?q3eD?u&Njx4;KQ5G>32+GRp1Ee5qMO0lZjaRRu&{W<&~DoJNGkcYF<5(Ab+J zgO>VhBl{okDPn78<%&e2mR{jwVCz5Og;*Z;;3%VvoGo_;HaGLWYF7q#jDX=Z#Ml`H z858YVV$%J|e<1n`%6Vsvq7GmnAV0wW4$5qQ3uR@1i>tW{xrl|ExywIc?fNgYlA?C5 zh$ezAFb5{rQu6i7BSS5*J-|9DQ{6^BVQ{b*lq`xS@RyrsJN?-t=MTMPY;WYeKBCNg z^2|pN!Q^WPJuuO4!|P@jzt&tY1Y8d%FNK5xK(!@`jO2aEA*4 zkO6b|UVBipci?){-Ke=+1;mGlND8)6+P;8sq}UXw2hn;fc7nM>g}GSMWu&v&fqh

iViYT=fZ(|3Ox^$aWPp4a8h24tD<|8-!aK0lHgL$N7Efw}J zVIB!7=T$U`ao1?upi5V4Et*-lTG0XvExbf!ya{cua==$WJyVG(CmA6Of*8E@DSE%L z`V^$qz&RU$7G5mg;8;=#`@rRG`-uS18$0WPN@!v2d{H2sOqP|!(cQ@ zUHo!d>>yFArLPf1q`uBvY32miqShLT1B@gDL4XoVTK&@owOoD)OIHXrYK-a1d$B{v zF^}8D3Y^g%^cnvScOSJR5QNH+BI%d|;J;wWM3~l>${fb8DNPg)wrf|GBP8p%LNGN# z3EaIiItgwtGgT&iYCFy9-LG}bMI|4LdmmJt@V@% zb6B)1kc=T)(|L@0;wr<>=?r04N;E&ef+7C^`wPWtyQe(*pD1pI_&XHy|0gIGHMekd zF_*M4yi6J&Z4LQj65)S zXwdM{SwUo%3SbPwFsHgqF@V|6afT|R6?&S;lw=8% z3}@9B=#JI3@B*#4s!O))~z zc>2_4Q_#&+5V`GFd?88^;c1i7;Vv_I*qt!_Yx*n=;rj!82rrR2rQ8u5(Ejlo{15P% zs~!{%XJ>FmJ})H^I9bn^Re&38H{xA!0l3^89k(oU;bZWXM@kn$#aoS&Y4l^-WEn-fH39Jb9lA%s*WsKJQl?n9B7_~P z-XM&WL7Z!PcoF6_D>V@$CvUIEy=+Z&0kt{szMk=f1|M+r*a43^$$B^MidrT0J;RI` z(?f!O<8UZkm$_Ny$Hth1J#^4ni+im8M9mr&k|3cIgwvjAgjH z8`N&h25xV#v*d$qBX5jkI|xOhQn!>IYZK7l5#^P4M&twe9&Ey@@GxYMxBZq2e7?`q z$~Szs0!g{2fGcp9PZEt|rdQ6bhAgpcLHPz?f-vB?$dc*!9OL?Q8mn7->bFD2Si60* z!O%y)fCdMSV|lkF9w%x~J*A&srMyYY3{=&$}H zGQ4VG_?$2X(0|vT0{=;W$~icCI{b6W{B!Q8xdGhF|D{25G_5_+%s(46lhvNLkik~R z>nr(&C#5wwOzJZQo9m|U<;&Wk!_#q|V>fsmj1g<6%hB{jGoNUPjgJslld>xmODzGjYc?7JSuA?A_QzjDw5AsRgi@Y|Z0{F{!1=!NES-#*f^s4l0Hu zz468))2IY5dmD9pa*(yT5{EyP^G>@ZWumealS-*WeRcZ}B%gxq{MiJ|RyX-^C1V=0 z@iKdrGi1jTe8Ya^x7yyH$kBNvM4R~`fbPq$BzHum-3Zo8C6=KW@||>zsA8-Y9uV5V z#oq-f5L5}V<&wF4@X@<3^C%ptp6+Ce)~hGl`kwj)bsAjmo_GU^r940Z-|`<)oGnh7 zFF0Tde3>ui?8Yj{sF-Z@)yQd~CGZ*w-6p2U<8}JO-sRsVI5dBji`01W8A&3$?}lxBaC&vn0E$c5tW* zX>5(zzZ=qn&!J~KdsPl;P@bmA-Pr8T*)eh_+Dv5=Ma|XSle6t(k8qcgNyar{*ReQ8 zTXwi=8vr>!3Ywr+BhggHDw8ke==NTQVMCK`$69fhzEFB*4+H9LIvdt-#IbhZvpS}} zO3lz;P?zr0*0$%-Rq_y^k(?I{Mk}h@w}cZpMUp|ucs55bcloL2)($u%mXQw({Wzc~ z;6nu5MkjP)0C(@%6Q_I_vsWrfhl7Zpoxw#WoE~r&GOSCz;_ro6i(^hM>I$8y>`!wW z*U^@?B!MMmb89I}2(hcE4zN2G^kwyWCZp5JG>$Ez7zP~D=J^LMjSM)27_0B_X^C(M z`fFT+%DcKlu?^)FCK>QzSnV%IsXVcUFhFdBP!6~se&xxrIxsvySAWu++IrH;FbcY$ z2DWTvSBRfLwdhr0nMx+URA$j3i7_*6BWv#DXfym?ZRDcX9C?cY9sD3q)uBDR3uWg= z(lUIzB)G$Hr!){>E{s4Dew+tb9kvToZp-1&c?y2wn@Z~(VBhqz`cB;{E4(P3N2*nJ z_>~g@;UF2iG{Kt(<1PyePTKahF8<)pozZ*xH~U-kfoAayCwJViIrnqwqO}7{0pHw$ zs2Kx?s#vQr7XZ264>5RNKSL8|Ty^=PsIx^}QqOOcfpGUU4tRkUc|kc7-!Ae6!+B{o~7nFpm3|G5^=0#Bnm6`V}oSQlrX(u%OWnC zoLPy&Q;1Jui&7ST0~#+}I^&?vcE*t47~Xq#YwvA^6^} z`WkC)$AkNub|t@S!$8CBlwbV~?yp&@9h{D|3z-vJXgzRC5^nYm+PyPcgRzAnEi6Q^gslXYRv4nycsy-SJu?lMps-? zV`U*#WnFsdPLL)Q$AmD|0`UaC4ND07+&UmOu!eHruzV|OUox<+Jl|Mr@6~C`T@P%s zW7sgXLF2SSe9Fl^O(I*{9wsFSYb2l%-;&Pi^dpv!{)C3d0AlNY6!4fgmSgj_wQ*7Am7&$z;Jg&wgR-Ih;lUvWS|KTSg!&s_E9_bXBkZvGiC6bFKDWZxsD$*NZ#_8bl zG1P-#@?OQzED7@jlMJTH@V!6k;W>auvft)}g zhoV{7$q=*;=l{O>Q4a@ ziMjf_u*o^PsO)#BjC%0^h>Xp@;5$p{JSYDt)zbb}s{Kbt!T*I@Pk@X0zds6wsefuU zW$XY%yyRGC94=6mf?x+bbA5CDQ2AgW1T-jVAJbm7K(gp+;v6E0WI#kuACgV$r}6L? zd|Tj?^%^*N&b>Dd{Wr$FS2qI#Ucs1yd4N+RBUQiSZGujH`#I)mG&VKoDh=KKFl4=G z&MagXl6*<)$6P}*Tiebpz5L=oMaPrN+caUXRJ`D?=K9!e0f{@D&cZLKN?iNP@X0aF zE(^pl+;*T5qt?1jRC=5PMgV!XNITRLS_=9{CJExaQj;lt!&pdzpK?8p>%Mb+D z?yO*uSung=-`QQ@yX@Hyd4@CI^r{2oiu`%^bNkz+Nkk!IunjwNC|WcqvX~k=><-I3 zDQdbdb|!v+Iz01$w@aMl!R)koD77Xp;eZwzSl-AT zr@Vu{=xvgfq9akRrrM)}=!=xcs+U1JO}{t(avgz`6RqiiX<|hGG1pmop8k6Q+G_mv zJv|RfDheUp2L3=^C=4aCBMBn0aRCU(DQwX-W(RkRwmLeuJYF<0urcaf(=7)JPg<3P zQs!~G)9CT18o!J4{zX{_e}4eS)U-E)0FAt}wEI(c0%HkxgggW;(1E=>J17_hsH^sP z%lT0LGgbUXHx-K*CI-MCrP66UP0PvGqM$MkeLyqHdbgP|_Cm!7te~b8p+e6sQ_3k| zVcwTh6d83ltdnR>D^)BYQpDKlLk3g0Hdcgz2}%qUs9~~Rie)A-BV1mS&naYai#xcZ z(d{8=-LVpTp}2*y)|gR~;qc7fp26}lPcLZ#=JpYcn3AT9(UIdOyg+d(P5T7D&*P}# zQCYplZO5|7+r19%9e`v^vfSS1sbX1c%=w1;oyruXB%Kl$ACgKQ6=qNWLsc=28xJjg zwvsI5-%SGU|3p>&zXVl^vVtQT3o-#$UT9LI@Npz~6=4!>mc431VRNN8od&Ul^+G_kHC`G=6WVWM z%9eWNyy(FTO|A+@x}Ou3CH)oi;t#7rAxdIXfNFwOj_@Y&TGz6P_sqiB`Q6Lxy|Q{`|fgmRG(k+!#b*M+Z9zFce)f-7;?Km5O=LHV9f9_87; zF7%R2B+$?@sH&&-$@tzaPYkw0;=i|;vWdI|Wl3q_Zu>l;XdIw2FjV=;Mq5t1Q0|f< zs08j54Bp`3RzqE=2enlkZxmX6OF+@|2<)A^RNQpBd6o@OXl+i)zO%D4iGiQNuXd+zIR{_lb96{lc~bxsBveIw6umhShTX+3@ZJ=YHh@ zWY3(d0azg;7oHn>H<>?4@*RQbi>SmM=JrHvIG(~BrvI)#W(EAeO6fS+}mxxcc+X~W6&YVl86W9WFSS}Vz-f9vS?XUDBk)3TcF z8V?$4Q)`uKFq>xT=)Y9mMFVTUk*NIA!0$?RP6Ig0TBmUFrq*Q-Agq~DzxjStQyJ({ zBeZ;o5qUUKg=4Hypm|}>>L=XKsZ!F$yNTDO)jt4H0gdQ5$f|d&bnVCMMXhNh)~mN z@_UV6D7MVlsWz+zM+inZZp&P4fj=tm6fX)SG5H>OsQf_I8c~uGCig$GzuwViK54bcgL;VN|FnyQl>Ed7(@>=8$a_UKIz|V6CeVSd2(P z0Uu>A8A+muM%HLFJQ9UZ5c)BSAv_zH#1f02x?h9C}@pN@6{>UiAp>({Fn(T9Q8B z^`zB;kJ5b`>%dLm+Ol}ty!3;8f1XDSVX0AUe5P#@I+FQ-`$(a;zNgz)4x5hz$Hfbg z!Q(z26wHLXko(1`;(BAOg_wShpX0ixfWq3ponndY+u%1gyX)_h=v1zR#V}#q{au6; z!3K=7fQwnRfg6FXtNQmP>`<;!N137paFS%y?;lb1@BEdbvQHYC{976l`cLqn;b8lp zIDY>~m{gDj(wfnK!lpW6pli)HyLEiUrNc%eXTil|F2s(AY+LW5hkKb>TQ3|Q4S9rr zpDs4uK_co6XPsn_z$LeS{K4jFF`2>U`tbgKdyDne`xmR<@6AA+_hPNKCOR-Zqv;xk zu5!HsBUb^!4uJ7v0RuH-7?l?}b=w5lzzXJ~gZcxRKOovSk@|#V+MuX%Y+=;14i*%{)_gSW9(#4%)AV#3__kac1|qUy!uyP{>?U#5wYNq}y$S9pCc zFc~4mgSC*G~j0u#qqp9 z${>3HV~@->GqEhr_Xwoxq?Hjn#=s2;i~g^&Hn|aDKpA>Oc%HlW(KA1?BXqpxB;Ydx)w;2z^MpjJ(Qi(X!$5RC z*P{~%JGDQqojV>2JbEeCE*OEu!$XJ>bWA9Oa_Hd;y)F%MhBRi*LPcdqR8X`NQ&1L# z5#9L*@qxrx8n}LfeB^J{%-?SU{FCwiWyHp682F+|pa+CQa3ZLzBqN1{)h4d6+vBbV zC#NEbQLC;}me3eeYnOG*nXOJZEU$xLZ1<1Y=7r0(-U0P6-AqwMAM`a(Ed#7vJkn6plb4eI4?2y3yOTGmmDQ!z9`wzbf z_OY#0@5=bnep;MV0X_;;SJJWEf^E6Bd^tVJ9znWx&Ks8t*B>AM@?;D4oWUGc z!H*`6d7Cxo6VuyS4Eye&L1ZRhrRmN6Lr`{NL(wDbif|y&z)JN>Fl5#Wi&mMIr5i;x zBx}3YfF>>8EC(fYnmpu~)CYHuHCyr5*`ECap%t@y=jD>!_%3iiE|LN$mK9>- zHdtpy8fGZtkZF?%TW~29JIAfi2jZT8>OA7=h;8T{{k?c2`nCEx9$r zS+*&vt~2o^^J+}RDG@+9&M^K*z4p{5#IEVbz`1%`m5c2};aGt=V?~vIM}ZdPECDI)47|CWBCfDWUbxBCnmYivQ*0Nu_xb*C>~C9(VjHM zxe<*D<#dQ8TlpMX2c@M<9$w!RP$hpG4cs%AI){jp*Sj|*`m)5(Bw*A0$*i-(CA5#%>a)$+jI2C9r6|(>J8InryENI z$NohnxDUB;wAYDwrb*!N3noBTKPpPN}~09SEL18tkG zxgz(RYU_;DPT{l?Q$+eaZaxnsWCA^ds^0PVRkIM%bOd|G2IEBBiz{&^JtNsODs;5z zICt_Zj8wo^KT$7Bg4H+y!Df#3mbl%%?|EXe!&(Vmac1DJ*y~3+kRKAD=Ovde4^^%~ zw<9av18HLyrf*_>Slp;^i`Uy~`mvBjZ|?Ad63yQa#YK`4+c6;pW4?XIY9G1(Xh9WO8{F-Aju+nS9Vmv=$Ac0ienZ+p9*O%NG zMZKy5?%Z6TAJTE?o5vEr0r>f>hb#2w2U3DL64*au_@P!J!TL`oH2r*{>ffu6|A7tv zL4juf$DZ1MW5ZPsG!5)`k8d8c$J$o;%EIL0va9&GzWvkS%ZsGb#S(?{!UFOZ9<$a| zY|a+5kmD5N&{vRqkgY>aHsBT&`rg|&kezoD)gP0fsNYHsO#TRc_$n6Lf1Z{?+DLziXlHrq4sf(!>O{?Tj;Eh@%)+nRE_2VxbN&&%%caU#JDU%vL3}Cb zsb4AazPI{>8H&d=jUaZDS$-0^AxE@utGs;-Ez_F(qC9T=UZX=>ok2k2 ziTn{K?y~a5reD2A)P${NoI^>JXn>`IeArow(41c-Wm~)wiryEP(OS{YXWi7;%dG9v zI?mwu1MxD{yp_rrk!j^cKM)dc4@p4Ezyo%lRN|XyD}}>v=Xoib0gOcdXrQ^*61HNj z=NP|pd>@yfvr-=m{8$3A8TQGMTE7g=z!%yt`8`Bk-0MMwW~h^++;qyUP!J~ykh1GO z(FZ59xuFR$(WE;F@UUyE@Sp>`aVNjyj=Ty>_Vo}xf`e7`F;j-IgL5`1~-#70$9_=uBMq!2&1l zomRgpD58@)YYfvLtPW}{C5B35R;ZVvB<<#)x%srmc_S=A7F@DW8>QOEGwD6suhwCg z>Pa+YyULhmw%BA*4yjDp|2{!T98~<6Yfd(wo1mQ!KWwq0eg+6)o1>W~f~kL<-S+P@$wx*zeI|1t7z#Sxr5 zt6w+;YblPQNplq4Z#T$GLX#j6yldXAqj>4gAnnWtBICUnA&-dtnlh=t0Ho_vEKwV` z)DlJi#!@nkYV#$!)@>udAU*hF?V`2$Hf=V&6PP_|r#Iv*J$9)pF@X3`k;5})9^o4y z&)~?EjX5yX12O(BsFy-l6}nYeuKkiq`u9145&3Ssg^y{5G3Pse z9w(YVa0)N-fLaBq1`P!_#>SS(8fh_5!f{UrgZ~uEdeMJIz7DzI5!NHHqQtm~#CPij z?=N|J>nPR6_sL7!f4hD_|KH`vf8(Wpnj-(gPWH+ZvID}%?~68SwhPTC3u1_cB`otq z)U?6qo!ZLi5b>*KnYHWW=3F!p%h1;h{L&(Q&{qY6)_qxNfbP6E3yYpW!EO+IW3?@J z);4>g4gnl^8klu7uA>eGF6rIGSynacogr)KUwE_R4E5Xzi*Qir@b-jy55-JPC8c~( zo!W8y9OGZ&`xmc8;=4-U9=h{vCqfCNzYirONmGbRQlR`WWlgnY+1wCXbMz&NT~9*| z6@FrzP!LX&{no2!Ln_3|I==_4`@}V?4a;YZKTdw;vT<+K+z=uWbW(&bXEaWJ^W8Td z-3&1bY^Z*oM<=M}LVt>_j+p=2Iu7pZmbXrhQ_k)ysE9yXKygFNw$5hwDn(M>H+e1&9BM5!|81vd%r%vEm zqxY3?F@fb6O#5UunwgAHR9jp_W2zZ}NGp2%mTW@(hz7$^+a`A?mb8|_G*GNMJ) zjqegXQio=i@AINre&%ofexAr95aop5C+0MZ0m-l=MeO8m3epm7U%vZB8+I+C*iNFM z#T3l`gknX;D$-`2XT^Cg*vrv=RH+P;_dfF++cP?B_msQI4j+lt&rX2)3GaJx%W*Nn zkML%D{z5tpHH=dksQ*gzc|}gzW;lwAbxoR07VNgS*-c3d&8J|;@3t^ zVUz*J*&r7DFRuFVDCJDK8V9NN5hvpgGjwx+5n)qa;YCKe8TKtdnh{I7NU9BCN!0dq zczrBk8pE{{@vJa9ywR@mq*J=v+PG;?fwqlJVhijG!3VmIKs>9T6r7MJpC)m!Tc#>g zMtVsU>wbwFJEfwZ{vB|ZlttNe83)$iz`~#8UJ^r)lJ@HA&G#}W&ZH*;k{=TavpjWE z7hdyLZPf*X%Gm}i`Y{OGeeu^~nB8=`{r#TUrM-`;1cBvEd#d!kPqIgYySYhN-*1;L z^byj%Yi}Gx)Wnkosi337BKs}+5H5dth1JA{Ir-JKN$7zC)*}hqeoD(WfaUDPT>0`- z(6sa0AoIqASwF`>hP}^|)a_j2s^PQn*qVC{Q}htR z5-)duBFXT_V56-+UohKXlq~^6uf!6sA#ttk1o~*QEy_Y-S$gAvq47J9Vtk$5oA$Ct zYhYJ@8{hsC^98${!#Ho?4y5MCa7iGnfz}b9jE~h%EAAv~Qxu)_rAV;^cygV~5r_~?l=B`zObj7S=H=~$W zPtI_m%g$`kL_fVUk9J@>EiBH zOO&jtn~&`hIFMS5S`g8w94R4H40mdNUH4W@@XQk1sr17b{@y|JB*G9z1|CrQjd+GX z6+KyURG3;!*BQrentw{B2R&@2&`2}n(z-2&X7#r!{yg@Soy}cRD~j zj9@UBW+N|4HW4AWapy4wfUI- zZ`gSL6DUlgj*f1hSOGXG0IVH8HxK?o2|3HZ;KW{K+yPAlxtb)NV_2AwJm|E)FRs&& z=c^e7bvUsztY|+f^k7NXs$o1EUq>cR7C0$UKi6IooHWlK_#?IWDkvywnzg&ThWo^? z2O_N{5X39#?eV9l)xI(>@!vSB{DLt*oY!K1R8}_?%+0^C{d9a%N4 zoxHVT1&Lm|uDX%$QrBun5e-F`HJ^T$ zmzv)p@4ZHd_w9!%Hf9UYNvGCw2TTTbrj9pl+T9%-_-}L(tES>Or-}Z4F*{##n3~L~TuxjirGuIY#H7{%$E${?p{Q01 zi6T`n;rbK1yIB9jmQNycD~yZq&mbIsFWHo|ZAChSFPQa<(%d8mGw*V3fh|yFoxOOiWJd(qvVb!Z$b88cg->N=qO*4k~6;R==|9ihg&riu#P~s4Oap9O7f%crSr^rljeIfXDEg>wi)&v*a%7zpz<9w z*r!3q9J|390x`Zk;g$&OeN&ctp)VKRpDSV@kU2Q>jtok($Y-*x8_$2piTxun81@vt z!Vj?COa0fg2RPXMSIo26T=~0d`{oGP*eV+$!0I<(4azk&Vj3SiG=Q!6mX0p$z7I}; z9BJUFgT-K9MQQ-0@Z=^7R<{bn2Fm48endsSs`V7_@%8?Bxkqv>BDoVcj?K#dV#uUP zL1ND~?D-|VGKe3Rw_7-Idpht>H6XRLh*U7epS6byiGvJpr%d}XwfusjH9g;Z98H`x zyde%%5mhGOiL4wljCaWCk-&uE4_OOccb9c!ZaWt4B(wYl!?vyzl%7n~QepN&eFUrw zFIOl9c({``6~QD+43*_tzP{f2x41h(?b43^y6=iwyB)2os5hBE!@YUS5?N_tXd=h( z)WE286Fbd>R4M^P{!G)f;h<3Q>Fipuy+d2q-)!RyTgt;wr$(?9ox3;q+{E*ZQHhOn;lM`cjnu9 zXa48ks-v(~b*;MAI<>YZH(^NV8vjb34beE<_cwKlJoR;k6lJNSP6v}uiyRD?|0w+X@o1ONrH8a$fCxXpf? z?$DL0)7|X}Oc%h^zrMKWc-NS9I0Utu@>*j}b@tJ=ixQSJ={4@854wzW@E>VSL+Y{i z#0b=WpbCZS>kUCO_iQz)LoE>P5LIG-hv9E+oG}DtlIDF>$tJ1aw9^LuhLEHt?BCj& z(O4I8v1s#HUi5A>nIS-JK{v!7dJx)^Yg%XjNmlkWAq2*cv#tHgz`Y(bETc6CuO1VkN^L-L3j_x<4NqYb5rzrLC-7uOv z!5e`GZt%B782C5-fGnn*GhDF$%(qP<74Z}3xx+{$4cYKy2ikxI7B2N+2r07DN;|-T->nU&!=Cm#rZt%O_5c&1Z%nlWq3TKAW0w zQqemZw_ue--2uKQsx+niCUou?HjD`xhEjjQd3%rrBi82crq*~#uA4+>vR<_S{~5ce z-2EIl?~s z1=GVL{NxP1N3%=AOaC}j_Fv=ur&THz zyO!d9kHq|c73kpq`$+t+8Bw7MgeR5~`d7ChYyGCBWSteTB>8WAU(NPYt2Dk`@#+}= zI4SvLlyk#pBgVigEe`?NG*vl7V6m+<}%FwPV=~PvvA)=#ths==DRTDEYh4V5}Cf$z@#;< zyWfLY_5sP$gc3LLl2x+Ii)#b2nhNXJ{R~vk`s5U7Nyu^3yFg&D%Txwj6QezMX`V(x z=C`{76*mNb!qHHs)#GgGZ_7|vkt9izl_&PBrsu@}L`X{95-2jf99K)0=*N)VxBX2q z((vkpP2RneSIiIUEnGb?VqbMb=Zia+rF~+iqslydE34cSLJ&BJW^3knX@M;t*b=EA zNvGzv41Ld_T+WT#XjDB840vovUU^FtN_)G}7v)1lPetgpEK9YS^OWFkPoE{ovj^=@ zO9N$S=G$1ecndT_=5ehth2Lmd1II-PuT~C9`XVePw$y8J#dpZ?Tss<6wtVglm(Ok7 z3?^oi@pPio6l&!z8JY(pJvG=*pI?GIOu}e^EB6QYk$#FJQ%^AIK$I4epJ+9t?KjqA+bkj&PQ*|vLttme+`9G=L% ziadyMw_7-M)hS(3E$QGNCu|o23|%O+VN7;Qggp?PB3K-iSeBa2b}V4_wY`G1Jsfz4 z9|SdB^;|I8E8gWqHKx!vj_@SMY^hLEIbSMCuE?WKq=c2mJK z8LoG-pnY!uhqFv&L?yEuxo{dpMTsmCn)95xanqBrNPTgXP((H$9N${Ow~Is-FBg%h z53;|Y5$MUN)9W2HBe2TD`ct^LHI<(xWrw}$qSoei?}s)&w$;&!14w6B6>Yr6Y8b)S z0r71`WmAvJJ`1h&poLftLUS6Ir zC$bG9!Im_4Zjse)#K=oJM9mHW1{%l8sz$1o?ltdKlLTxWWPB>Vk22czVt|1%^wnN@*!l)}?EgtvhC>vlHm^t+ogpgHI1_$1ox9e;>0!+b(tBrmXRB`PY1vp-R**8N7 zGP|QqI$m(Rdu#=(?!(N}G9QhQ%o!aXE=aN{&wtGP8|_qh+7a_j_sU5|J^)vxq;# zjvzLn%_QPHZZIWu1&mRAj;Sa_97p_lLq_{~j!M9N^1yp3U_SxRqK&JnR%6VI#^E12 z>CdOVI^_9aPK2eZ4h&^{pQs}xsijXgFYRIxJ~N7&BB9jUR1fm!(xl)mvy|3e6-B3j zJn#ajL;bFTYJ2+Q)tDjx=3IklO@Q+FFM}6UJr6km7hj7th9n_&JR7fnqC!hTZoM~T zBeaVFp%)0cbPhejX<8pf5HyRUj2>aXnXBqDJe73~J%P(2C?-RT{c3NjE`)om! zl$uewSgWkE66$Kb34+QZZvRn`fob~Cl9=cRk@Es}KQm=?E~CE%spXaMO6YmrMl%9Q zlA3Q$3|L1QJ4?->UjT&CBd!~ru{Ih^in&JXO=|<6J!&qp zRe*OZ*cj5bHYlz!!~iEKcuE|;U4vN1rk$xq6>bUWD*u(V@8sG^7>kVuo(QL@Ki;yL zWC!FT(q{E8#on>%1iAS0HMZDJg{Z{^!De(vSIq&;1$+b)oRMwA3nc3mdTSG#3uYO_ z>+x;7p4I;uHz?ZB>dA-BKl+t-3IB!jBRgdvAbW!aJ(Q{aT>+iz?91`C-xbe)IBoND z9_Xth{6?(y3rddwY$GD65IT#f3<(0o#`di{sh2gm{dw*#-Vnc3r=4==&PU^hCv$qd zjw;>i&?L*Wq#TxG$mFIUf>eK+170KG;~+o&1;Tom9}}mKo23KwdEM6UonXgc z!6N(@k8q@HPw{O8O!lAyi{rZv|DpgfU{py+j(X_cwpKqcalcqKIr0kM^%Br3SdeD> zHSKV94Yxw;pjzDHo!Q?8^0bb%L|wC;4U^9I#pd5O&eexX+Im{ z?jKnCcsE|H?{uGMqVie_C~w7GX)kYGWAg%-?8|N_1#W-|4F)3YTDC+QSq1s!DnOML3@d`mG%o2YbYd#jww|jD$gotpa)kntakp#K;+yo-_ZF9qrNZw<%#C zuPE@#3RocLgPyiBZ+R_-FJ_$xP!RzWm|aN)S+{$LY9vvN+IW~Kf3TsEIvP+B9Mtm! zpfNNxObWQpLoaO&cJh5>%slZnHl_Q~(-Tfh!DMz(dTWld@LG1VRF`9`DYKhyNv z2pU|UZ$#_yUx_B_|MxUq^glT}O5Xt(Vm4Mr02><%C)@v;vPb@pT$*yzJ4aPc_FZ3z z3}PLoMBIM>q_9U2rl^sGhk1VUJ89=*?7|v`{!Z{6bqFMq(mYiA?%KbsI~JwuqVA9$H5vDE+VocjX+G^%bieqx->s;XWlKcuv(s%y%D5Xbc9+ zc(_2nYS1&^yL*ey664&4`IoOeDIig}y-E~_GS?m;D!xv5-xwz+G`5l6V+}CpeJDi^ z%4ed$qowm88=iYG+(`ld5Uh&>Dgs4uPHSJ^TngXP_V6fPyl~>2bhi20QB%lSd#yYn zO05?KT1z@?^-bqO8Cg`;ft>ilejsw@2%RR7;`$Vs;FmO(Yr3Fp`pHGr@P2hC%QcA|X&N2Dn zYf`MqXdHi%cGR@%y7Rg7?d3?an){s$zA{!H;Ie5exE#c~@NhQUFG8V=SQh%UxUeiV zd7#UcYqD=lk-}sEwlpu&H^T_V0{#G?lZMxL7ih_&{(g)MWBnCZxtXg znr#}>U^6!jA%e}@Gj49LWG@*&t0V>Cxc3?oO7LSG%~)Y5}f7vqUUnQ;STjdDU}P9IF9d9<$;=QaXc zL1^X7>fa^jHBu_}9}J~#-oz3Oq^JmGR#?GO7b9a(=R@fw@}Q{{@`Wy1vIQ#Bw?>@X z-_RGG@wt|%u`XUc%W{J z>iSeiz8C3H7@St3mOr_mU+&bL#Uif;+Xw-aZdNYUpdf>Rvu0i0t6k*}vwU`XNO2he z%miH|1tQ8~ZK!zmL&wa3E;l?!!XzgV#%PMVU!0xrDsNNZUWKlbiOjzH-1Uoxm8E#r`#2Sz;-o&qcqB zC-O_R{QGuynW14@)7&@yw1U}uP(1cov)twxeLus0s|7ayrtT8c#`&2~Fiu2=R;1_4bCaD=*E@cYI>7YSnt)nQc zohw5CsK%m?8Ack)qNx`W0_v$5S}nO|(V|RZKBD+btO?JXe|~^Qqur%@eO~<8-L^9d z=GA3-V14ng9L29~XJ>a5k~xT2152zLhM*@zlp2P5Eu}bywkcqR;ISbas&#T#;HZSf z2m69qTV(V@EkY(1Dk3`}j)JMo%ZVJ*5eB zYOjIisi+igK0#yW*gBGj?@I{~mUOvRFQR^pJbEbzFxTubnrw(Muk%}jI+vXmJ;{Q6 zrSobKD>T%}jV4Ub?L1+MGOD~0Ir%-`iTnWZN^~YPrcP5y3VMAzQ+&en^VzKEb$K!Q z<7Dbg&DNXuow*eD5yMr+#08nF!;%4vGrJI++5HdCFcGLfMW!KS*Oi@=7hFwDG!h2< zPunUEAF+HncQkbfFj&pbzp|MU*~60Z(|Ik%Tn{BXMN!hZOosNIseT?R;A`W?=d?5X zK(FB=9mZusYahp|K-wyb={rOpdn=@;4YI2W0EcbMKyo~-#^?h`BA9~o285%oY zfifCh5Lk$SY@|2A@a!T2V+{^!psQkx4?x0HSV`(w9{l75QxMk!)U52Lbhn{8ol?S) zCKo*7R(z!uk<6*qO=wh!Pul{(qq6g6xW;X68GI_CXp`XwO zxuSgPRAtM8K7}5E#-GM!*ydOOG_{A{)hkCII<|2=ma*71ci_-}VPARm3crFQjLYV! z9zbz82$|l01mv`$WahE2$=fAGWkd^X2kY(J7iz}WGS z@%MyBEO=A?HB9=^?nX`@nh;7;laAjs+fbo!|K^mE!tOB>$2a_O0y-*uaIn8k^6Y zSbuv;5~##*4Y~+y7Z5O*3w4qgI5V^17u*ZeupVGH^nM&$qmAk|anf*>r zWc5CV;-JY-Z@Uq1Irpb^O`L_7AGiqd*YpGUShb==os$uN3yYvb`wm6d=?T*it&pDk zo`vhw)RZX|91^^Wa_ti2zBFyWy4cJu#g)_S6~jT}CC{DJ_kKpT`$oAL%b^!2M;JgT zM3ZNbUB?}kP(*YYvXDIH8^7LUxz5oE%kMhF!rnPqv!GiY0o}NR$OD=ITDo9r%4E>E0Y^R(rS^~XjWyVI6 zMOR5rPXhTp*G*M&X#NTL`Hu*R+u*QNoiOKg4CtNPrjgH>c?Hi4MUG#I917fx**+pJfOo!zFM&*da&G_x)L(`k&TPI*t3e^{crd zX<4I$5nBQ8Ax_lmNRa~E*zS-R0sxkz`|>7q_?*e%7bxqNm3_eRG#1ae3gtV9!fQpY z+!^a38o4ZGy9!J5sylDxZTx$JmG!wg7;>&5H1)>f4dXj;B+@6tMlL=)cLl={jLMxY zbbf1ax3S4>bwB9-$;SN2?+GULu;UA-35;VY*^9Blx)Jwyb$=U!D>HhB&=jSsd^6yw zL)?a|>GxU!W}ocTC(?-%z3!IUhw^uzc`Vz_g>-tv)(XA#JK^)ZnC|l1`@CdX1@|!| z_9gQ)7uOf?cR@KDp97*>6X|;t@Y`k_N@)aH7gY27)COv^P3ya9I{4z~vUjLR9~z1Z z5=G{mVtKH*&$*t0@}-i_v|3B$AHHYale7>E+jP`ClqG%L{u;*ff_h@)al?RuL7tOO z->;I}>%WI{;vbLP3VIQ^iA$4wl6@0sDj|~112Y4OFjMs`13!$JGkp%b&E8QzJw_L5 zOnw9joc0^;O%OpF$Qp)W1HI!$4BaXX84`%@#^dk^hFp^pQ@rx4g(8Xjy#!X%+X5Jd@fs3amGT`}mhq#L97R>OwT5-m|h#yT_-v@(k$q7P*9X~T*3)LTdzP!*B} z+SldbVWrrwQo9wX*%FyK+sRXTa@O?WM^FGWOE?S`R(0P{<6p#f?0NJvnBia?k^fX2 zNQs7K-?EijgHJY}&zsr;qJ<*PCZUd*x|dD=IQPUK_nn)@X4KWtqoJNHkT?ZWL_hF? zS8lp2(q>;RXR|F;1O}EE#}gCrY~#n^O`_I&?&z5~7N;zL0)3Tup`%)oHMK-^r$NT% zbFg|o?b9w(q@)6w5V%si<$!U<#}s#x@0aX-hP>zwS#9*75VXA4K*%gUc>+yzupTDBOKH8WR4V0pM(HrfbQ&eJ79>HdCvE=F z|J>s;;iDLB^3(9}?biKbxf1$lI!*Z%*0&8UUq}wMyPs_hclyQQi4;NUY+x2qy|0J; zhn8;5)4ED1oHwg+VZF|80<4MrL97tGGXc5Sw$wAI#|2*cvQ=jB5+{AjMiDHmhUC*a zlmiZ`LAuAn_}hftXh;`Kq0zblDk8?O-`tnilIh|;3lZp@F_osJUV9`*R29M?7H{Fy z`nfVEIDIWXmU&YW;NjU8)EJpXhxe5t+scf|VXM!^bBlwNh)~7|3?fWwo_~ZFk(22% zTMesYw+LNx3J-_|DM~`v93yXe=jPD{q;li;5PD?Dyk+b? zo21|XpT@)$BM$%F=P9J19Vi&1#{jM3!^Y&fr&_`toi`XB1!n>sbL%U9I5<7!@?t)~ z;&H%z>bAaQ4f$wIzkjH70;<8tpUoxzKrPhn#IQfS%9l5=Iu))^XC<58D!-O z{B+o5R^Z21H0T9JQ5gNJnqh#qH^na|z92=hONIM~@_iuOi|F>jBh-?aA20}Qx~EpDGElELNn~|7WRXRFnw+Wdo`|# zBpU=Cz3z%cUJ0mx_1($X<40XEIYz(`noWeO+x#yb_pwj6)R(__%@_Cf>txOQ74wSJ z0#F3(zWWaR-jMEY$7C*3HJrohc79>MCUu26mfYN)f4M~4gD`}EX4e}A!U}QV8!S47 z6y-U-%+h`1n`*pQuKE%Av0@)+wBZr9mH}@vH@i{v(m-6QK7Ncf17x_D=)32`FOjjo zg|^VPf5c6-!FxN{25dvVh#fog=NNpXz zfB$o+0jbRkHH{!TKhE709f+jI^$3#v1Nmf80w`@7-5$1Iv_`)W^px8P-({xwb;D0y z7LKDAHgX<84?l!I*Dvi2#D@oAE^J|g$3!)x1Ua;_;<@#l1fD}lqU2_tS^6Ht$1Wl} zBESo7o^)9-Tjuz$8YQSGhfs{BQV6zW7dA?0b(Dbt=UnQs&4zHfe_sj{RJ4uS-vQpC zX;Bbsuju4%!o8?&m4UZU@~ZZjeFF6ex2ss5_60_JS_|iNc+R0GIjH1@Z z=rLT9%B|WWgOrR7IiIwr2=T;Ne?30M!@{%Qf8o`!>=s<2CBpCK_TWc(DX51>e^xh8 z&@$^b6CgOd7KXQV&Y4%}_#uN*mbanXq(2=Nj`L7H7*k(6F8s6{FOw@(DzU`4-*77{ zF+dxpv}%mFpYK?>N_2*#Y?oB*qEKB}VoQ@bzm>ptmVS_EC(#}Lxxx730trt0G)#$b zE=wVvtqOct1%*9}U{q<)2?{+0TzZzP0jgf9*)arV)*e!f`|jgT{7_9iS@e)recI#z zbzolURQ+TOzE!ymqvBY7+5NnAbWxvMLsLTwEbFqW=CPyCsmJ}P1^V30|D5E|p3BC5 z)3|qgw@ra7aXb-wsa|l^in~1_fm{7bS9jhVRkYVO#U{qMp z)Wce+|DJ}4<2gp8r0_xfZpMo#{Hl2MfjLcZdRB9(B(A(f;+4s*FxV{1F|4d`*sRNd zp4#@sEY|?^FIJ;tmH{@keZ$P(sLh5IdOk@k^0uB^BWr@pk6mHy$qf&~rI>P*a;h0C{%oA*i!VjWn&D~O#MxN&f@1Po# zKN+ zrGrkSjcr?^R#nGl<#Q722^wbYcgW@{+6CBS<1@%dPA8HC!~a`jTz<`g_l5N1M@9wn9GOAZ>nqNgq!yOCbZ@1z`U_N`Z>}+1HIZxk*5RDc&rd5{3qjRh8QmT$VyS;jK z;AF+r6XnnCp=wQYoG|rT2@8&IvKq*IB_WvS%nt%e{MCFm`&W*#LXc|HrD?nVBo=(8*=Aq?u$sDA_sC_RPDUiQ+wnIJET8vx$&fxkW~kP9qXKt zozR)@xGC!P)CTkjeWvXW5&@2?)qt)jiYWWBU?AUtzAN}{JE1I)dfz~7$;}~BmQF`k zpn11qmObXwRB8&rnEG*#4Xax3XBkKlw(;tb?Np^i+H8m(Wyz9k{~ogba@laiEk;2! zV*QV^6g6(QG%vX5Um#^sT&_e`B1pBW5yVth~xUs#0}nv?~C#l?W+9Lsb_5)!71rirGvY zTIJ$OPOY516Y|_014sNv+Z8cc5t_V=i>lWV=vNu#!58y9Zl&GsMEW#pPYPYGHQ|;vFvd*9eM==$_=vc7xnyz0~ zY}r??$<`wAO?JQk@?RGvkWVJlq2dk9vB(yV^vm{=NVI8dhsX<)O(#nr9YD?I?(VmQ z^r7VfUBn<~p3()8yOBjm$#KWx!5hRW)5Jl7wY@ky9lNM^jaT##8QGVsYeaVywmpv>X|Xj7gWE1Ezai&wVLt3p)k4w~yrskT-!PR!kiyQlaxl(( zXhF%Q9x}1TMt3~u@|#wWm-Vq?ZerK={8@~&@9r5JW}r#45#rWii};t`{5#&3$W)|@ zbAf2yDNe0q}NEUvq_Quq3cTjcw z@H_;$hu&xllCI9CFDLuScEMg|x{S7GdV8<&Mq=ezDnRZAyX-8gv97YTm0bg=d)(>N z+B2FcqvI9>jGtnK%eO%y zoBPkJTk%y`8TLf4)IXPBn`U|9>O~WL2C~C$z~9|0m*YH<-vg2CD^SX#&)B4ngOSG$ zV^wmy_iQk>dfN@Pv(ckfy&#ak@MLC7&Q6Ro#!ezM*VEh`+b3Jt%m(^T&p&WJ2Oqvj zs-4nq0TW6cv~(YI$n0UkfwN}kg3_fp?(ijSV#tR9L0}l2qjc7W?i*q01=St0eZ=4h zyGQbEw`9OEH>NMuIe)hVwYHsGERWOD;JxEiO7cQv%pFCeR+IyhwQ|y@&^24k+|8fD zLiOWFNJ2&vu2&`Jv96_z-Cd5RLgmeY3*4rDOQo?Jm`;I_(+ejsPM03!ly!*Cu}Cco zrQSrEDHNyzT(D5s1rZq!8#?f6@v6dB7a-aWs(Qk>N?UGAo{gytlh$%_IhyL7h?DLXDGx zgxGEBQoCAWo-$LRvM=F5MTle`M})t3vVv;2j0HZY&G z22^iGhV@uaJh(XyyY%} zd4iH_UfdV#T=3n}(Lj^|n;O4|$;xhu*8T3hR1mc_A}fK}jfZ7LX~*n5+`8N2q#rI$ z@<_2VANlYF$vIH$ zl<)+*tIWW78IIINA7Rr7i{<;#^yzxoLNkXL)eSs=%|P>$YQIh+ea_3k z_s7r4%j7%&*NHSl?R4k%1>Z=M9o#zxY!n8sL5>BO-ZP;T3Gut>iLS@U%IBrX6BA3k z)&@q}V8a{X<5B}K5s(c(LQ=%v1ocr`t$EqqY0EqVjr65usa=0bkf|O#ky{j3)WBR(((L^wmyHRzoWuL2~WTC=`yZ zn%VX`L=|Ok0v7?s>IHg?yArBcync5rG#^+u)>a%qjES%dRZoIyA8gQ;StH z1Ao7{<&}6U=5}4v<)1T7t!J_CL%U}CKNs-0xWoTTeqj{5{?Be$L0_tk>M9o8 zo371}S#30rKZFM{`H_(L`EM9DGp+Mifk&IP|C2Zu_)Ghr4Qtpmkm1osCf@%Z$%t+7 zYH$Cr)Ro@3-QDeQJ8m+x6%;?YYT;k6Z0E-?kr>x33`H%*ueBD7Zx~3&HtWn0?2Wt} zTG}*|v?{$ajzt}xPzV%lL1t-URi8*Zn)YljXNGDb>;!905Td|mpa@mHjIH%VIiGx- zd@MqhpYFu4_?y5N4xiHn3vX&|e6r~Xt> zZG`aGq|yTNjv;9E+Txuoa@A(9V7g?1_T5FzRI;!=NP1Kqou1z5?%X~Wwb{trRfd>i z8&y^H)8YnKyA_Fyx>}RNmQIczT?w2J4SNvI{5J&}Wto|8FR(W;Qw#b1G<1%#tmYzQ zQ2mZA-PAdi%RQOhkHy9Ea#TPSw?WxwL@H@cbkZwIq0B!@ns}niALidmn&W?!Vd4Gj zO7FiuV4*6Mr^2xlFSvM;Cp_#r8UaqIzHJQg_z^rEJw&OMm_8NGAY2)rKvki|o1bH~ z$2IbfVeY2L(^*rMRU1lM5Y_sgrDS`Z??nR2lX;zyR=c%UyGb*%TC-Dil?SihkjrQy~TMv6;BMs7P8il`H7DmpVm@rJ;b)hW)BL)GjS154b*xq-NXq2cwE z^;VP7ua2pxvCmxrnqUYQMH%a%nHmwmI33nJM(>4LznvY*k&C0{8f*%?zggpDgkuz&JBx{9mfb@wegEl2v!=}Sq2Gaty0<)UrOT0{MZtZ~j5y&w zXlYa_jY)I_+VA-^#mEox#+G>UgvM!Ac8zI<%JRXM_73Q!#i3O|)lOP*qBeJG#BST0 zqohi)O!|$|2SeJQo(w6w7%*92S})XfnhrH_Z8qe!G5>CglP=nI7JAOW?(Z29;pXJ9 zR9`KzQ=WEhy*)WH>$;7Cdz|>*i>=##0bB)oU0OR>>N<21e4rMCHDemNi2LD>Nc$;& zQRFthpWniC1J6@Zh~iJCoLOxN`oCKD5Q4r%ynwgUKPlIEd#?QViIqovY|czyK8>6B zSP%{2-<;%;1`#0mG^B(8KbtXF;Nf>K#Di72UWE4gQ%(_26Koiad)q$xRL~?pN71ZZ zujaaCx~jXjygw;rI!WB=xrOJO6HJ!!w}7eiivtCg5K|F6$EXa)=xUC za^JXSX98W`7g-tm@uo|BKj39Dl;sg5ta;4qjo^pCh~{-HdLl6qI9Ix6f$+qiZ$}s= zNguKrU;u+T@ko(Vr1>)Q%h$?UKXCY>3se%&;h2osl2D zE4A9bd7_|^njDd)6cI*FupHpE3){4NQ*$k*cOWZ_?CZ>Z4_fl@n(mMnYK62Q1d@+I zr&O))G4hMihgBqRIAJkLdk(p(D~X{-oBUA+If@B}j& zsHbeJ3RzTq96lB7d($h$xTeZ^gP0c{t!Y0c)aQE;$FY2!mACg!GDEMKXFOPI^)nHZ z`aSPJpvV0|bbrzhWWkuPURlDeN%VT8tndV8?d)eN*i4I@u zVKl^6{?}A?P)Fsy?3oi#clf}L18t;TjNI2>eI&(ezDK7RyqFxcv%>?oxUlonv(px) z$vnPzRH`y5A(x!yOIfL0bmgeMQB$H5wenx~!ujQK*nUBW;@Em&6Xv2%s(~H5WcU2R z;%Nw<$tI)a`Ve!>x+qegJnQsN2N7HaKzrFqM>`6R*gvh%O*-%THt zrB$Nk;lE;z{s{r^PPm5qz(&lM{sO*g+W{sK+m3M_z=4=&CC>T`{X}1Vg2PEfSj2x_ zmT*(x;ov%3F?qoEeeM>dUn$a*?SIGyO8m806J1W1o+4HRhc2`9$s6hM#qAm zChQ87b~GEw{ADfs+5}FJ8+|bIlIv(jT$Ap#hSHoXdd9#w<#cA<1Rkq^*EEkknUd4& zoIWIY)sAswy6fSERVm&!SO~#iN$OgOX*{9@_BWFyJTvC%S++ilSfCrO(?u=Dc?CXZ zzCG&0yVR{Z`|ZF0eEApWEo#s9osV>F{uK{QA@BES#&;#KsScf>y zvs?vIbI>VrT<*!;XmQS=bhq%46-aambZ(8KU-wOO2=en~D}MCToB_u;Yz{)1ySrPZ z@=$}EvjTdzTWU7c0ZI6L8=yP+YRD_eMMos}b5vY^S*~VZysrkq<`cK3>>v%uy7jgq z0ilW9KjVDHLv0b<1K_`1IkbTOINs0=m-22c%M~l=^S}%hbli-3?BnNq?b`hx^HX2J zIe6ECljRL0uBWb`%{EA=%!i^4sMcj+U_TaTZRb+~GOk z^ZW!nky0n*Wb*r+Q|9H@ml@Z5gU&W`(z4-j!OzC1wOke`TRAYGZVl$PmQ16{3196( zO*?`--I}Qf(2HIwb2&1FB^!faPA2=sLg(@6P4mN)>Dc3i(B0;@O-y2;lM4akD>@^v z=u>*|!s&9zem70g7zfw9FXl1bpJW(C#5w#uy5!V?Q(U35A~$dR%LDVnq@}kQm13{} zd53q3N(s$Eu{R}k2esbftfjfOITCL;jWa$}(mmm}d(&7JZ6d3%IABCapFFYjdEjdK z&4Edqf$G^MNAtL=uCDRs&Fu@FXRgX{*0<(@c3|PNHa>L%zvxWS={L8%qw`STm+=Rd zA}FLspESSIpE_^41~#5yI2bJ=9`oc;GIL!JuW&7YetZ?0H}$$%8rW@*J37L-~Rsx!)8($nI4 zZhcZ2^=Y+p4YPl%j!nFJA|*M^gc(0o$i3nlphe+~-_m}jVkRN{spFs(o0ajW@f3K{ zDV!#BwL322CET$}Y}^0ixYj2w>&Xh12|R8&yEw|wLDvF!lZ#dOTHM9pK6@Nm-@9Lnng4ZHBgBSrr7KI8YCC9DX5Kg|`HsiwJHg2(7#nS;A{b3tVO?Z% za{m5b3rFV6EpX;=;n#wltDv1LE*|g5pQ+OY&*6qCJZc5oDS6Z6JD#6F)bWxZSF@q% z+1WV;m!lRB!n^PC>RgQCI#D1br_o^#iPk>;K2hB~0^<~)?p}LG%kigm@moD#q3PE+ zA^Qca)(xnqw6x>XFhV6ku9r$E>bWNrVH9fum0?4s?Rn2LG{Vm_+QJHse6xa%nzQ?k zKug4PW~#Gtb;#5+9!QBgyB@q=sk9=$S{4T>wjFICStOM?__fr+Kei1 z3j~xPqW;W@YkiUM;HngG!;>@AITg}vAE`M2Pj9Irl4w1fo4w<|Bu!%rh%a(Ai^Zhi zs92>v5;@Y(Zi#RI*ua*h`d_7;byQSa*v9E{2x$<-_=5Z<7{%)}4XExANcz@rK69T0x3%H<@frW>RA8^swA+^a(FxK| zFl3LD*ImHN=XDUkrRhp6RY5$rQ{bRgSO*(vEHYV)3Mo6Jy3puiLmU&g82p{qr0F?ohmbz)f2r{X2|T2 z$4fdQ=>0BeKbiVM!e-lIIs8wVTuC_m7}y4A_%ikI;Wm5$9j(^Y z(cD%U%k)X>_>9~t8;pGzL6L-fmQO@K; zo&vQzMlgY95;1BSkngY)e{`n0!NfVgf}2mB3t}D9@*N;FQ{HZ3Pb%BK6;5#-O|WI( zb6h@qTLU~AbVW#_6?c!?Dj65Now7*pU{h!1+eCV^KCuPAGs28~3k@ueL5+u|Z-7}t z9|lskE`4B7W8wMs@xJa{#bsCGDFoRSNSnmNYB&U7 zVGKWe%+kFB6kb)e;TyHfqtU6~fRg)f|>=5(N36)0+C z`hv65J<$B}WUc!wFAb^QtY31yNleq4dzmG`1wHTj=c*=hay9iD071Hc?oYoUk|M*_ zU1GihAMBsM@5rUJ(qS?9ZYJ6@{bNqJ`2Mr+5#hKf?doa?F|+^IR!8lq9)wS3tF_9n zW_?hm)G(M+MYb?V9YoX^_mu5h-LP^TL^!Q9Z7|@sO(rg_4+@=PdI)WL(B7`!K^ND- z-uIuVDCVEdH_C@c71YGYT^_Scf_dhB8Z2Xy6vGtBSlYud9vggOqv^L~F{BraSE_t} zIkP+Hp2&nH^-MNEs}^`oMLy11`PQW$T|K(`Bu*(f@)mv1-qY(_YG&J2M2<7k;;RK~ zL{Fqj9yCz8(S{}@c)S!65aF<=&eLI{hAMErCx&>i7OeDN>okvegO87OaG{Jmi<|}D zaT@b|0X{d@OIJ7zvT>r+eTzgLq~|Dpu)Z&db-P4z*`M$UL51lf>FLlq6rfG)%doyp z)3kk_YIM!03eQ8Vu_2fg{+osaEJPtJ-s36R+5_AEG12`NG)IQ#TF9c@$99%0iye+ zUzZ57=m2)$D(5Nx!n)=5Au&O0BBgwxIBaeI(mro$#&UGCr<;C{UjJVAbVi%|+WP(a zL$U@TYCxJ=1{Z~}rnW;7UVb7+ZnzgmrogDxhjLGo>c~MiJAWs&&;AGg@%U?Y^0JhL ze(x6Z74JG6FlOFK(T}SXQfhr}RIFl@QXKnIcXYF)5|V~e-}suHILKT-k|<*~Ij|VF zC;t@=uj=hot~*!C68G8hTA%8SzOfETOXQ|3FSaIEjvBJp(A)7SWUi5!Eu#yWgY+;n zlm<$+UDou*V+246_o#V4kMdto8hF%%Lki#zPh}KYXmMf?hrN0;>Mv%`@{0Qn`Ujp) z=lZe+13>^Q!9zT);H<(#bIeRWz%#*}sgUX9P|9($kexOyKIOc`dLux}c$7It4u|Rl z6SSkY*V~g_B-hMPo_ak>>z@AVQ(_N)VY2kB3IZ0G(iDUYw+2d7W^~(Jq}KY=JnWS( z#rzEa&0uNhJ>QE8iiyz;n2H|SV#Og+wEZv=f2%1ELX!SX-(d3tEj$5$1}70Mp<&eI zCkfbByL7af=qQE@5vDVxx1}FSGt_a1DoE3SDI+G)mBAna)KBG4p8Epxl9QZ4BfdAN zFnF|Y(umr;gRgG6NLQ$?ZWgllEeeq~z^ZS7L?<(~O&$5|y)Al^iMKy}&W+eMm1W z7EMU)u^ke(A1#XCV>CZ71}P}0x)4wtHO8#JRG3MA-6g=`ZM!FcICCZ{IEw8Dm2&LQ z1|r)BUG^0GzI6f946RrBlfB1Vs)~8toZf~7)+G;pv&XiUO(%5bm)pl=p>nV^o*;&T z;}@oZSibzto$arQgfkp|z4Z($P>dTXE{4O=vY0!)kDO* zGF8a4wq#VaFpLfK!iELy@?-SeRrdz%F*}hjKcA*y@mj~VD3!it9lhRhX}5YOaR9$} z3mS%$2Be7{l(+MVx3 z(4?h;P!jnRmX9J9sYN#7i=iyj_5q7n#X(!cdqI2lnr8T$IfOW<_v`eB!d9xY1P=2q&WtOXY=D9QYteP)De?S4}FK6#6Ma z=E*V+#s8>L;8aVroK^6iKo=MH{4yEZ_>N-N z`(|;aOATba1^asjxlILk<4}f~`39dBFlxj>Dw(hMYKPO3EEt1@S`1lxFNM+J@uB7T zZ8WKjz7HF1-5&2=l=fqF-*@>n5J}jIxdDwpT?oKM3s8Nr`x8JnN-kCE?~aM1H!hAE z%%w(3kHfGwMnMmNj(SU(w42OrC-euI>Dsjk&jz3ts}WHqmMpzQ3vZrsXrZ|}+MHA7 z068obeXZTsO*6RS@o3x80E4ok``rV^Y3hr&C1;|ZZ0|*EKO`$lECUYG2gVFtUTw)R z4Um<0ZzlON`zTdvVdL#KFoMFQX*a5wM0Czp%wTtfK4Sjs)P**RW&?lP$(<}q%r68Z zS53Y!d@&~ne9O)A^tNrXHhXBkj~$8j%pT1%%mypa9AW5E&s9)rjF4@O3ytH{0z6riz|@< zB~UPh*wRFg2^7EbQrHf0y?E~dHlkOxof_a?M{LqQ^C!i2dawHTPYUE=X@2(3<=OOxs8qn_(y>pU>u^}3y&df{JarR0@VJn0f+U%UiF=$Wyq zQvnVHESil@d|8&R<%}uidGh7@u^(%?$#|&J$pvFC-n8&A>utA=n3#)yMkz+qnG3wd zP7xCnF|$9Dif@N~L)Vde3hW8W!UY0BgT2v(wzp;tlLmyk2%N|0jfG$%<;A&IVrOI< z!L)o>j>;dFaqA3pL}b-Je(bB@VJ4%!JeX@3x!i{yIeIso^=n?fDX`3bU=eG7sTc%g%ye8$v8P@yKE^XD=NYxTb zbf!Mk=h|otpqjFaA-vs5YOF-*GwWPc7VbaOW&stlANnCN8iftFMMrUdYNJ_Bnn5Vt zxfz@Ah|+4&P;reZxp;MmEI7C|FOv8NKUm8njF7Wb6Gi7DeODLl&G~}G4be&*Hi0Qw z5}77vL0P+7-B%UL@3n1&JPxW^d@vVwp?u#gVcJqY9#@-3X{ok#UfW3<1fb%FT`|)V~ggq z(3AUoUS-;7)^hCjdT0Kf{i}h)mBg4qhtHHBti=~h^n^OTH5U*XMgDLIR@sre`AaB$ zg)IGBET_4??m@cx&c~bA80O7B8CHR7(LX7%HThkeC*@vi{-pL%e)yXp!B2InafbDF zjPXf1mko3h59{lT6EEbxKO1Z5GF71)WwowO6kY|6tjSVSWdQ}NsK2x{>i|MKZK8%Q zfu&_0D;CO-Jg0#YmyfctyJ!mRJp)e#@O0mYdp|8x;G1%OZQ3Q847YWTyy|%^cpA;m zze0(5p{tMu^lDkpe?HynyO?a1$_LJl2L&mpeKu%8YvgRNr=%2z${%WThHG=vrWY@4 zsA`OP#O&)TetZ>s%h!=+CE15lOOls&nvC~$Qz0Ph7tHiP;O$i|eDwpT{cp>+)0-|; zY$|bB+Gbel>5aRN3>c0x)4U=|X+z+{ zn*_p*EQoquRL+=+p;=lm`d71&1NqBz&_ph)MXu(Nv6&XE7(RsS)^MGj5Q?Fwude-(sq zjJ>aOq!7!EN>@(fK7EE#;i_BGvli`5U;r!YA{JRodLBc6-`n8K+Fjgwb%sX;j=qHQ z7&Tr!)!{HXoO<2BQrV9Sw?JRaLXV8HrsNevvnf>Y-6|{T!pYLl7jp$-nEE z#X!4G4L#K0qG_4Z;Cj6=;b|Be$hi4JvMH!-voxqx^@8cXp`B??eFBz2lLD8RRaRGh zn7kUfy!YV~p(R|p7iC1Rdgt$_24i0cd-S8HpG|`@my70g^y`gu%#Tf_L21-k?sRRZHK&at(*ED0P8iw{7?R$9~OF$Ko;Iu5)ur5<->x!m93Eb zFYpIx60s=Wxxw=`$aS-O&dCO_9?b1yKiPCQmSQb>T)963`*U+Ydj5kI(B(B?HNP8r z*bfSBpSu)w(Z3j7HQoRjUG(+d=IaE~tv}y14zHHs|0UcN52fT8V_<@2ep_ee{QgZG zmgp8iv4V{k;~8@I%M3<#B;2R>Ef(Gg_cQM7%}0s*^)SK6!Ym+~P^58*wnwV1BW@eG z4sZLqsUvBbFsr#8u7S1r4teQ;t)Y@jnn_m5jS$CsW1um!p&PqAcc8!zyiXHVta9QC zY~wCwCF0U%xiQPD_INKtTb;A|Zf29(mu9NI;E zc-e>*1%(LSXB`g}kd`#}O;veb<(sk~RWL|f3ljxCnEZDdNSTDV6#Td({6l&y4IjKF z^}lIUq*ZUqgTPumD)RrCN{M^jhY>E~1pn|KOZ5((%F)G|*ZQ|r4zIbrEiV%42hJV8 z3xS)=!X1+=olbdGJ=yZil?oXLct8FM{(6ikLL3E%=q#O6(H$p~gQu6T8N!plf!96| z&Q3=`L~>U0zZh;z(pGR2^S^{#PrPxTRHD1RQOON&f)Siaf`GLj#UOk&(|@0?zm;Sx ztsGt8=29-MZs5CSf1l1jNFtNt5rFNZxJPvkNu~2}7*9468TWm>nN9TP&^!;J{-h)_ z7WsHH9|F%I`Pb!>KAS3jQWKfGivTVkMJLO-HUGM_a4UQ_%RgL6WZvrW+Z4ujZn;y@ zz9$=oO!7qVTaQAA^BhX&ZxS*|5dj803M=k&2%QrXda`-Q#IoZL6E(g+tN!6CA!CP* zCpWtCujIea)ENl0liwVfj)Nc<9mV%+e@=d`haoZ*`B7+PNjEbXBkv=B+Pi^~L#EO$D$ZqTiD8f<5$eyb54-(=3 zh)6i8i|jp(@OnRrY5B8t|LFXFQVQ895n*P16cEKTrT*~yLH6Z4e*bZ5otpRDri&+A zfNbK1D5@O=sm`fN=WzWyse!za5n%^+6dHPGX#8DyIK>?9qyX}2XvBWVqbP%%D)7$= z=#$WulZlZR<{m#gU7lwqK4WS1Ne$#_P{b17qe$~UOXCl>5b|6WVh;5vVnR<%d+Lnp z$uEmML38}U4vaW8>shm6CzB(Wei3s#NAWE3)a2)z@i{4jTn;;aQS)O@l{rUM`J@K& l00vQ5JBs~;vo!vr%%-k{2_Fq1Mn4QF81S)AQ99zk{{c4yR+0b! literal 63721 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鯊%1Et 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⁢ zv5o9#E7!G0XmdzfsS{efPNi`1b44~SZ4Z8fuX!I}#8g+(wxzQwUT#Xb2(tbY1+EUhGKoT@KEU9Ktl>_0 z%bjDJg;#*gtJZv!-Zs`?^}v5eKmnbjqlvnSzE@_SP|LG_PJ6CYU+6zY6>92%E+ z=j@TZf-iW4(%U{lnYxQA;7Q!b;^brF8n0D>)`q5>|WDDXLrqYU_tKN2>=#@~OE7grMnNh?UOz-O~6 z6%rHy{#h9K0AT+lDC7q4{hw^|q6*Ry;;L%Q@)Ga}$60_q%D)rv(CtS$CQbpq9|y1e zRSrN4;$Jyl{m5bZw`$8TGvb}(LpY{-cQ)fcyJv7l3S52TLXVDsphtv&aPuDk1OzCA z4A^QtC(!11`IsNx_HnSy?>EKpHJWT^wmS~hc^p^zIIh@9f6U@I2 zC=Mve{j2^)mS#U$e{@Q?SO6%LDsXz@SY+=cK_QMmXBIU)j!$ajc-zLx3V60EXJ!qC zi<%2x8Q24YN+&8U@CIlN zrZkcT9yh%LrlGS9`G)KdP(@9Eo-AQz@8GEFWcb7U=a0H^ZVbLmz{+&M7W(nXJ4sN8 zJLR7eeK(K8`2-}j(T7JsO`L!+CvbueT%izanm-^A1Dn{`1Nw`9P?cq;7no+XfC`K(GO9?O^5zNIt4M+M8LM0=7Gz8UA@Z0N+lg+cX)NfazRu z5D)~HA^(u%w^cz+@2@_#S|u>GpB+j4KzQ^&Wcl9f z&hG#bCA(Yk0D&t&aJE^xME^&E-&xGHhXn%}psEIj641H+Nl-}boj;)Zt*t(4wZ5DN z@GXF$bL=&pBq-#vkTkh>7hl%K5|3 z{`Vn9b$iR-SoGENp}bn4;fR3>9sA%X2@1L3aE9yTra;Wb#_`xWwLSLdfu+PAu+o3| zGVnpzPr=ch{uuoHjtw7+_!L_2;knQ!DuDl0R`|%jr+}jFzXtrHIKc323?JO{l&;VF z*L1+}JU7%QJOg|5|Tc|D8fN zJORAg=_vsy{ak|o);@)Yh8Lkcg@$FG3k@ep36BRa^>~UmnRPziS>Z=`Jb2x*Q#`%A zU*i3&Vg?TluO@X0O;r2Jl6LKLUOVhSqg1*qOt^|8*c7 zo(298@+r$k_wQNGHv{|$tW(T8L+4_`FQ{kEW5Jgg{yf7ey4ss_(SNKfz(N9lx&a;< je(UuV8hP?p&}TPdm1I$XmG#(RzlD&B2izSj9sl%y5~4qc diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 1af9e0930..b82aa23a4 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/qupath-core-processing/src/main/java/qupath/opencv/dnn/OpenCVDnn.java b/qupath-core-processing/src/main/java/qupath/opencv/dnn/OpenCVDnn.java index 58f66d56a..7bc63860f 100644 --- a/qupath-core-processing/src/main/java/qupath/opencv/dnn/OpenCVDnn.java +++ b/qupath-core-processing/src/main/java/qupath/opencv/dnn/OpenCVDnn.java @@ -218,7 +218,7 @@ public void initializeModel(Model model) { if (mean != null) model.setInputMean(mean); if (Double.isFinite(scale)) - model.setInputScale(scale); + model.setInputScale(Scalar.all(scale)); if (size != null) model.setInputSize(size); } diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/prefs/PathPrefs.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/prefs/PathPrefs.java index 4564055ce..5d8d79d24 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/prefs/PathPrefs.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/prefs/PathPrefs.java @@ -83,14 +83,14 @@ public class PathPrefs { /** * Default name for preference node in this QuPath version */ - private static final String DEFAULT_NODE_NAME = "io.github.qupath/0.5"; + private static final String DEFAULT_NODE_NAME = "io.github.qupath/0.6"; /** * Previous preference node, in case these need to be restored. * For now, this isn't supported. */ @SuppressWarnings("unused") - private static final String PREVIOUS_NODE_NAME = "io.github.qupath/0.4"; + private static final String PREVIOUS_NODE_NAME = "io.github.qupath/0.5"; /** * The preference manager used to store preferences. From 6af3ab4e4c9ef8ae889bb0b9e1f9ca78e07b08da Mon Sep 17 00:00:00 2001 From: Pete Date: Fri, 12 Apr 2024 14:00:25 +0100 Subject: [PATCH 028/866] Revert ImageJ update --- CHANGELOG.md | 1 - gradle/libs.versions.toml | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ba80f6ebe..253ff31d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,6 @@ This is a *work in progress* for the next major release. * DeepJavaLibrary 0.27.0 * Groovy 4.0.21 * Guava 33.1.0-jre -* ImageJ 1.54i * JavaFX 22 * JNA 5.14.0 * OpenCV 4.9.0 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e6e613976..10d862ae8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -15,7 +15,7 @@ gson = "2.10.1" guava = "33.1.0-jre" ikonli = "12.3.1" -imagej = "1.54i" +imagej = "1.54f" # Compatibility with Java 21 with QuPath v0.6.x jdk = "21" From 16ab826bcbba6b8b64953b837ed04d15d5b42044 Mon Sep 17 00:00:00 2001 From: Leo Leplat <60394504+Rylern@users.noreply.github.com> Date: Thu, 18 Apr 2024 12:06:48 +0100 Subject: [PATCH 029/866] Addressed comments --- gradle/libs.versions.toml | 2 +- .../ome/zarr/OMEZarrAttributesCreator.java | 42 +++++++++---------- .../writers/ome/zarr/OMEZarrWriter.java | 24 ++--------- 3 files changed, 26 insertions(+), 42 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9f0925798..03952eba0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,7 +2,7 @@ bioformats = "7.0.1" bioimageIoSpec = "0.1.0" -omeZarrReader = "0.4.1-SNAPSHOT" +omeZarrReader = "0.4.1" blosc = "1.21.5" commonmark = "0.21.0" diff --git a/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrAttributesCreator.java b/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrAttributesCreator.java index 82a39baef..3bdb92966 100644 --- a/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrAttributesCreator.java +++ b/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrAttributesCreator.java @@ -24,7 +24,7 @@ class OMEZarrAttributesCreator { private final int numberOfChannels; private final boolean pixelSizeInMicrometer; private final TimeUnit timeUnit; - private final double[] downSamples; + private final double[] downsamples; private final List channels; private final boolean isRGB; private final PixelType pixelType; @@ -45,7 +45,7 @@ private enum Dimension { * @param numberOfChannels the number of channels * @param pixelSizeInMicrometer whether pixel sizes are in micrometer * @param timeUnit the unit of the time dimension of the image - * @param downSamples the downsamples of the image + * @param downsamples the downsamples of the image * @param channels the channels of the image * @param isRGB whether the image stores pixel values with the RGB format * @param pixelType the type of the pixel values of the image @@ -57,7 +57,7 @@ public OMEZarrAttributesCreator( int numberOfChannels, boolean pixelSizeInMicrometer, TimeUnit timeUnit, - double[] downSamples, + double[] downsamples, List channels, boolean isRGB, PixelType pixelType @@ -68,7 +68,7 @@ public OMEZarrAttributesCreator( this.numberOfChannels = numberOfChannels; this.pixelSizeInMicrometer = pixelSizeInMicrometer; this.timeUnit = timeUnit; - this.downSamples = downSamples; + this.downsamples = downsamples; this.channels = channels; this.isRGB = isRGB; this.pixelType = pixelType; @@ -124,25 +124,25 @@ private List> getAxes() { List> axes = new ArrayList<>(); if (numberOfTimePoints > 1) { - axes.add(getAxe(Dimension.T)); + axes.add(getAxis(Dimension.T)); } if (numberOfChannels > 1) { - axes.add(getAxe(Dimension.C)); + axes.add(getAxis(Dimension.C)); } if (numberOfZSlices > 1) { - axes.add(getAxe(Dimension.Z)); + axes.add(getAxis(Dimension.Z)); } - axes.add(getAxe(Dimension.Y)); - axes.add(getAxe(Dimension.X)); + axes.add(getAxis(Dimension.Y)); + axes.add(getAxis(Dimension.X)); return axes; } private List> getDatasets() { - return IntStream.range(0, downSamples.length) + return IntStream.range(0, downsamples.length) .mapToObj(level -> Map.of( "path", "s" + level, - "coordinateTransformations", List.of(getCoordinateTransformation((float) downSamples[level])) + "coordinateTransformations", List.of(getCoordinateTransformation((float) downsamples[level])) )) .toList(); } @@ -179,16 +179,16 @@ private List> getChannels() { .toList(); } - private Map getAxe(Dimension dimension) { - Map axes = new HashMap<>(); - axes.put("name", switch (dimension) { + private Map getAxis(Dimension dimension) { + Map axis = new HashMap<>(); + axis.put("name", switch (dimension) { case X -> "x"; case Y -> "y"; case Z -> "z"; case T -> "t"; case C -> "c"; }); - axes.put("type", switch (dimension) { + axis.put("type", switch (dimension) { case X, Y, Z -> "space"; case T -> "time"; case C -> "channel"; @@ -197,10 +197,10 @@ private Map getAxe(Dimension dimension) { switch (dimension) { case X, Y, Z -> { if (pixelSizeInMicrometer) { - axes.put("unit", "micrometer"); + axis.put("unit", "micrometer"); } } - case T -> axes.put("unit", switch (timeUnit) { + case T -> axis.put("unit", switch (timeUnit) { case NANOSECONDS -> "nanosecond"; case MICROSECONDS -> "microsecond"; case MILLISECONDS -> "millisecond"; @@ -211,10 +211,10 @@ private Map getAxe(Dimension dimension) { }); } - return axes; + return axis; } - private Map getCoordinateTransformation(float downSample) { + private Map getCoordinateTransformation(float downsample) { List scales = new ArrayList<>(); if (numberOfTimePoints > 1) { scales.add(1F); @@ -225,8 +225,8 @@ private Map getCoordinateTransformation(float downSample) { if (numberOfZSlices > 1) { scales.add(1F); } - scales.add(downSample); - scales.add(downSample); + scales.add(downsample); + scales.add(downsample); return Map.of( "type", "scale", diff --git a/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriter.java b/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriter.java index 122a5aef4..7a8993af2 100644 --- a/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriter.java +++ b/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriter.java @@ -364,11 +364,7 @@ private static int[] getDimensionsOfImage(ImageServer server, int dimensions.add((int) (server.getHeight() / server.getDownsampleForResolution(level))); dimensions.add((int) (server.getWidth() / server.getDownsampleForResolution(level))); - int[] dimensionArray = new int[dimensions.size()]; - for(int i = 0; i < dimensions.size(); i++) { - dimensionArray[i] = dimensions.get(i); - } - return dimensionArray; + return dimensions.stream().mapToInt(i -> i).toArray(); } private static int[] getChunksOfImage(ImageServer server) { @@ -385,11 +381,7 @@ private static int[] getChunksOfImage(ImageServer server) { chunks.add(server.getMetadata().getPreferredTileHeight()); chunks.add(server.getMetadata().getPreferredTileWidth()); - int[] chunksArray = new int[chunks.size()]; - for (int i = 0; i < chunks.size(); i++) { - chunksArray[i] = chunks.get(i); - } - return chunksArray; + return chunks.stream().mapToInt(i -> i).toArray(); } private Object getData(BufferedImage image) { @@ -504,11 +496,7 @@ private int[] getDimensionsOfTile(TileRequest tileRequest) { dimensions.add(tileRequest.getTileHeight()); dimensions.add(tileRequest.getTileWidth()); - int[] dimensionArray = new int[dimensions.size()]; - for (int i = 0; i < dimensions.size(); i++) { - dimensionArray[i] = dimensions.get(i); - } - return dimensionArray; + return dimensions.stream().mapToInt(i -> i).toArray(); } private int[] getOffsetsOfTile(TileRequest tileRequest) { @@ -525,10 +513,6 @@ private int[] getOffsetsOfTile(TileRequest tileRequest) { offset.add(tileRequest.getTileY()); offset.add(tileRequest.getTileX()); - int[] offsetArray = new int[offset.size()]; - for (int i = 0; i < offset.size(); i++) { - offsetArray[i] = offset.get(i); - } - return offsetArray; + return offset.stream().mapToInt(i -> i).toArray(); } } From d4c17b2a40114ceaaa7ae5830b1a5a259151704b Mon Sep 17 00:00:00 2001 From: Alan O'Callaghan Date: Wed, 24 Apr 2024 10:33:22 +0100 Subject: [PATCH 030/866] Add IoU merger for InstanSeg --- .../lib/objects/utils/ObjectMerger.java | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/qupath-core/src/main/java/qupath/lib/objects/utils/ObjectMerger.java b/qupath-core/src/main/java/qupath/lib/objects/utils/ObjectMerger.java index f24754703..c4840aef5 100644 --- a/qupath-core/src/main/java/qupath/lib/objects/utils/ObjectMerger.java +++ b/qupath-core/src/main/java/qupath/lib/objects/utils/ObjectMerger.java @@ -359,6 +359,36 @@ public static ObjectMerger createTouchingMerger() { 0); } + /** + * Create an object merger that can merge together any objects with sufficiently large intersection over union. + *

+ * Objects must also have the same classification and be on the same image plane to be mergeable. + *

+ * IoU is calculated using Java Topology Suite intersection, union, and getArea calls. + *

+ * This merger assumes that you are using an OutputHandler that doesn't clip to tile boundaries (only to region + * requests) and that you are using sufficient padding to ensure that objects are being detected in more than on + * tile/region request. + * You should probably also remove any objects that touch the regionRequest boundaries, as these will probably be + * clipped, and merging them will result in weirdly shaped detections. + * @return an object merger that can merge together any objects with sufficiently high IoU and the same classification + */ + public static ObjectMerger createIoUMerger(double iouThreshold) { + return new ObjectMerger( + ObjectMerger::sameClassTypePlaneTest, + createIoUMergeTest(iouThreshold), + 0.0625); + } + + private static BiPredicate createIoUMergeTest(double iouThreshold) { + return (geom, geomOverlap) -> { + double union = geomOverlap.union(geomOverlap).getArea(); + if (union == 0) { + return false; + } + return geom.intersection(geomOverlap).getArea() / union > iouThreshold; + }; + } /** * Method to use as a predicate, indicating that two geometries have the same dimension and also touch. From 4980120a989864a3177754861d3ca6356abc64e9 Mon Sep 17 00:00:00 2001 From: Alan O'Callaghan Date: Fri, 26 Apr 2024 13:53:23 +0100 Subject: [PATCH 031/866] Don't explicitly calculate union --- .../main/java/qupath/lib/objects/utils/ObjectMerger.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/qupath-core/src/main/java/qupath/lib/objects/utils/ObjectMerger.java b/qupath-core/src/main/java/qupath/lib/objects/utils/ObjectMerger.java index c4840aef5..9c9fcc34f 100644 --- a/qupath-core/src/main/java/qupath/lib/objects/utils/ObjectMerger.java +++ b/qupath-core/src/main/java/qupath/lib/objects/utils/ObjectMerger.java @@ -382,11 +382,13 @@ public static ObjectMerger createIoUMerger(double iouThreshold) { private static BiPredicate createIoUMergeTest(double iouThreshold) { return (geom, geomOverlap) -> { - double union = geomOverlap.union(geomOverlap).getArea(); + var i = geom.intersection(geomOverlap); + var intersection = i.getArea(); + double union = geom.getArea() + geomOverlap.getArea() - intersection; if (union == 0) { return false; } - return geom.intersection(geomOverlap).getArea() / union > iouThreshold; + return intersection / union > iouThreshold; }; } From 8f96e05e05e741729736922a96aeffe4525124b0 Mon Sep 17 00:00:00 2001 From: Alan O'Callaghan Date: Fri, 26 Apr 2024 13:54:21 +0100 Subject: [PATCH 032/866] Use parallel merger --- .../main/java/qupath/lib/objects/utils/ObjectMerger.java | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/qupath-core/src/main/java/qupath/lib/objects/utils/ObjectMerger.java b/qupath-core/src/main/java/qupath/lib/objects/utils/ObjectMerger.java index 9c9fcc34f..cee218162 100644 --- a/qupath-core/src/main/java/qupath/lib/objects/utils/ObjectMerger.java +++ b/qupath-core/src/main/java/qupath/lib/objects/utils/ObjectMerger.java @@ -49,6 +49,7 @@ import java.util.Queue; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentLinkedDeque; import java.util.function.BiPredicate; import java.util.function.Function; import java.util.stream.Collectors; @@ -119,7 +120,7 @@ public List merge(Collection pathObjects) { // Parallelize the merging - it can be slow var output = clustersToMerge.stream() .parallel() - .map(cluster -> mergeObjects(cluster)) + .map(ObjectMerger::mergeObjects) .toList(); assert output.size() <= pathObjects.size(); return output; @@ -165,7 +166,7 @@ private List> computeClustersIterative(Collection> clusters = new ArrayList<>(); Set alreadyVisited = new HashSet<>(); - Queue pending = new ArrayDeque<>(); + Queue pending = new ConcurrentLinkedDeque<>(); for (var p : allObjects) { if (alreadyVisited.contains(p)) continue; @@ -189,7 +190,7 @@ private List> computeClustersIterative(Collection { if (!alreadyVisited.contains(neighbor)) { if (mergeTest.test( currentGeometry, @@ -197,7 +198,7 @@ private List> computeClustersIterative(Collection Date: Mon, 29 Apr 2024 10:56:10 +0100 Subject: [PATCH 033/866] Revert "Use parallel merger" This reverts commit 8f96e05e05e741729736922a96aeffe4525124b0. --- .../main/java/qupath/lib/objects/utils/ObjectMerger.java | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/qupath-core/src/main/java/qupath/lib/objects/utils/ObjectMerger.java b/qupath-core/src/main/java/qupath/lib/objects/utils/ObjectMerger.java index cee218162..9c9fcc34f 100644 --- a/qupath-core/src/main/java/qupath/lib/objects/utils/ObjectMerger.java +++ b/qupath-core/src/main/java/qupath/lib/objects/utils/ObjectMerger.java @@ -49,7 +49,6 @@ import java.util.Queue; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ConcurrentLinkedDeque; import java.util.function.BiPredicate; import java.util.function.Function; import java.util.stream.Collectors; @@ -120,7 +119,7 @@ public List merge(Collection pathObjects) { // Parallelize the merging - it can be slow var output = clustersToMerge.stream() .parallel() - .map(ObjectMerger::mergeObjects) + .map(cluster -> mergeObjects(cluster)) .toList(); assert output.size() <= pathObjects.size(); return output; @@ -166,7 +165,7 @@ private List> computeClustersIterative(Collection> clusters = new ArrayList<>(); Set alreadyVisited = new HashSet<>(); - Queue pending = new ConcurrentLinkedDeque<>(); + Queue pending = new ArrayDeque<>(); for (var p : allObjects) { if (alreadyVisited.contains(p)) continue; @@ -190,7 +189,7 @@ private List> computeClustersIterative(Collection { + for (var neighbor : neighbors) { if (!alreadyVisited.contains(neighbor)) { if (mergeTest.test( currentGeometry, @@ -198,7 +197,7 @@ private List> computeClustersIterative(Collection Date: Mon, 29 Apr 2024 12:47:28 +0100 Subject: [PATCH 034/866] Use parallel merger (#1504) --- .../lib/objects/utils/ObjectMerger.java | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/qupath-core/src/main/java/qupath/lib/objects/utils/ObjectMerger.java b/qupath-core/src/main/java/qupath/lib/objects/utils/ObjectMerger.java index f24754703..88b780b13 100644 --- a/qupath-core/src/main/java/qupath/lib/objects/utils/ObjectMerger.java +++ b/qupath-core/src/main/java/qupath/lib/objects/utils/ObjectMerger.java @@ -49,9 +49,11 @@ import java.util.Queue; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentLinkedDeque; import java.util.function.BiPredicate; import java.util.function.Function; import java.util.stream.Collectors; +import java.util.stream.IntStream; /** * Helper class for merging objects using different criteria. @@ -119,7 +121,7 @@ public List merge(Collection pathObjects) { // Parallelize the merging - it can be slow var output = clustersToMerge.stream() .parallel() - .map(cluster -> mergeObjects(cluster)) + .map(ObjectMerger::mergeObjects) .toList(); assert output.size() <= pathObjects.size(); return output; @@ -189,15 +191,14 @@ private List> computeClustersIterative(Collection !alreadyVisited.contains(neighbor)).toList(); + addable = addable.parallelStream() + .filter(neighbor -> mergeTest.test( currentGeometry, - getGeometry(neighbor, geometryMap))) { - pending.add(neighbor); - } - } - } + getGeometry(neighbor, geometryMap))).toList(); + pending.addAll(addable); } } if (cluster.isEmpty()) { @@ -209,7 +210,6 @@ private List> computeClustersIterative(Collection Date: Mon, 29 Apr 2024 14:06:59 +0100 Subject: [PATCH 035/866] Log openslide exception --- .../lib/images/servers/openslide/jna/OpenSlideLoader.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qupath-extension-openslide/src/main/java/qupath/lib/images/servers/openslide/jna/OpenSlideLoader.java b/qupath-extension-openslide/src/main/java/qupath/lib/images/servers/openslide/jna/OpenSlideLoader.java index f7dfa142d..68ebafa87 100644 --- a/qupath-extension-openslide/src/main/java/qupath/lib/images/servers/openslide/jna/OpenSlideLoader.java +++ b/qupath-extension-openslide/src/main/java/qupath/lib/images/servers/openslide/jna/OpenSlideLoader.java @@ -75,7 +75,7 @@ private static synchronized OpenSlideJNA tryToLoadJnaInstance(String... searchPa File openslideFile = Native.extractFromResourcePath("openslide"); return Native.load(openslideFile.getAbsolutePath(), OpenSlideJNA.class); } catch (IOException e) { - logger.error("No OpenSlide search path supplied and failed to load OpenSlide from packaged jar! OpenSlide will not work."); + logger.error("No OpenSlide search path supplied and failed to load OpenSlide from packaged jar! OpenSlide will not work.", e); } } From 9d5e6ebf30c83dc6775d64c5a6d8262941c2625c Mon Sep 17 00:00:00 2001 From: Pete Date: Tue, 30 Apr 2024 15:03:18 +0100 Subject: [PATCH 036/866] Update dependencies & changelog --- CHANGELOG.md | 7 +++++-- gradle/libs.versions.toml | 7 +++---- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 253ff31d6..014ee7a45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,16 +2,19 @@ This is a *work in progress* for the next major release. +### Enhancements +* Read and write OME-Zarr images (https://github.com/qupath/qupath/pull/1474) + ### Bugs fixed * Tile export to .ome.tif can convert to 8-bit unnecessarily (https://github.com/qupath/qupath/issues/1494) ### Dependency updates -* Bio-Formats 7.2.0 +* Bio-Formats 7.3.0 * Commonmark 0.22.0 * DeepJavaLibrary 0.27.0 * Groovy 4.0.21 * Guava 33.1.0-jre -* JavaFX 22 +* JavaFX 22.0.1 * JNA 5.14.0 * OpenCV 4.9.0 * OpenJDK 21 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5ca0e0dbb..65b908782 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,6 @@ [versions] -bioformats = "7.2.0" +bioformats = "7.3.0" bioimageIoSpec = "0.1.0" omeZarrReader = "0.4.1" blosc = "1.21.5" @@ -22,14 +22,13 @@ imagej = "1.54f" # Compatibility with Java 21 with QuPath v0.6.x jdk = "21" -javacppgradle = "1.5.10" javacpp = "1.5.10" opencv = "4.9.0-1.5.10" cuda = "12.3-8.9-1.5.10" # Warning! JavaFX 20.0.1 and later seem to break search links in Javadocs -javafx = "22" +javafx = "22.0.1" jna = "5.14.0" jfreeSvg = "5.0.5" jfxtras = "17-r1" @@ -117,7 +116,7 @@ yaml = ["snakeyaml"] [plugins] # Use the javafx plugin to add modules -javacpp = { id = "org.bytedeco.gradle-javacpp-platform", version.ref = "javacppgradle" } +javacpp = { id = "org.bytedeco.gradle-javacpp-platform", version.ref = "javacpp" } # If javafx plugin causes trouble, see https://github.com/openjfx/javafx-gradle-plugin#migrating-from-0014-to-010 javafx = { id = "org.openjfx.javafxplugin", version = "0.1.0" } #javafx = { id = "org.openjfx.javafxplugin", version = "0.0.14" } From 0b0cd6e46db682283b336f7999dae23bb4fc2e2d Mon Sep 17 00:00:00 2001 From: Alan O'Callaghan Date: Tue, 30 Apr 2024 15:26:54 +0100 Subject: [PATCH 037/866] Use geq in threshold and document arg --- .../src/main/java/qupath/lib/objects/utils/ObjectMerger.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/qupath-core/src/main/java/qupath/lib/objects/utils/ObjectMerger.java b/qupath-core/src/main/java/qupath/lib/objects/utils/ObjectMerger.java index 9c9fcc34f..25cdc6c86 100644 --- a/qupath-core/src/main/java/qupath/lib/objects/utils/ObjectMerger.java +++ b/qupath-core/src/main/java/qupath/lib/objects/utils/ObjectMerger.java @@ -371,6 +371,7 @@ public static ObjectMerger createTouchingMerger() { * tile/region request. * You should probably also remove any objects that touch the regionRequest boundaries, as these will probably be * clipped, and merging them will result in weirdly shaped detections. + * @param iouThreshold Intersection over union threshold; any pairs with values above this are merged. * @return an object merger that can merge together any objects with sufficiently high IoU and the same classification */ public static ObjectMerger createIoUMerger(double iouThreshold) { @@ -388,7 +389,7 @@ private static BiPredicate createIoUMergeTest(double iouThre if (union == 0) { return false; } - return intersection / union > iouThreshold; + return (intersection / union) >= iouThreshold; }; } From c856f42d5c7292dd1e2d508b6a76f55df4adf43b Mon Sep 17 00:00:00 2001 From: Pete Date: Tue, 30 Apr 2024 15:46:38 +0100 Subject: [PATCH 038/866] Fix 'Apply to similar images' delay bug Fixes https://github.com/qupath/qupath/issues/1499 --- CHANGELOG.md | 1 + .../java/qupath/lib/display/ImageDisplay.java | 36 +++++++++++++------ 2 files changed, 26 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 014ee7a45..f52de2a28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ This is a *work in progress* for the next major release. ### Bugs fixed * Tile export to .ome.tif can convert to 8-bit unnecessarily (https://github.com/qupath/qupath/issues/1494) +* Brightness/Contrast 'Apply to similar images' fails to update settings immediately across viewers (https://github.com/qupath/qupath/issues/1499) ### Dependency updates * Bio-Formats 7.3.0 diff --git a/qupath-gui-fx/src/main/java/qupath/lib/display/ImageDisplay.java b/qupath-gui-fx/src/main/java/qupath/lib/display/ImageDisplay.java index 29dd2ead3..404d79b7b 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/display/ImageDisplay.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/display/ImageDisplay.java @@ -1094,16 +1094,21 @@ boolean updateFromJSON(final String json) { List helperList = gson.fromJson(json, type); // Try updating everything List newSelectedChannels = new ArrayList<>(); + boolean changes = false; for (JsonHelperChannelInfo helper : helperList) { for (ChannelDisplayInfo info : channelOptions) { - if (helper.updateInfo(info)) { + if (helper.matches(info)) { + // Set the min/max display & color if needed + if (helper.updateInfo(info)) { + changes = true; + } + // Store whether the channel is selected if (Boolean.TRUE.equals(helper.selected)) { newSelectedChannels.add(info); } } } } - boolean changes = false; if (!newSelectedChannels.equals(selectedChannels)) { selectedChannels.setAll(newSelectedChannels); changes = true; @@ -1141,20 +1146,29 @@ boolean matches(final ChannelDisplayInfo info) { * Check is this helper matches the info, and set its properties if so. * * @param info - * @return + * @return true if changes were made, false otherwise */ boolean updateInfo(final ChannelDisplayInfo info) { if (!matches(info)) return false; - if (info instanceof ModifiableChannelDisplayInfo) { - if (minDisplay != null) - ((ModifiableChannelDisplayInfo)info).setMinDisplay(minDisplay); - if (maxDisplay != null) - ((ModifiableChannelDisplayInfo)info).setMaxDisplay(maxDisplay); + boolean changes = false; + if (info instanceof ModifiableChannelDisplayInfo modifiableInfo) { + if (minDisplay != null && minDisplay != modifiableInfo.getMinDisplay()) { + modifiableInfo.setMinDisplay(minDisplay); + changes = true; + } + if (maxDisplay != null && maxDisplay != modifiableInfo.getMaxDisplay()) { + modifiableInfo.setMaxDisplay(maxDisplay); + changes = true; + } } - if (color != null && info instanceof DirectServerChannelInfo) - ((DirectServerChannelInfo)info).setLUTColor(color); - return true; + if (color != null && info instanceof DirectServerChannelInfo directInfo) { + if (!Objects.equals(color, directInfo.getColor())) { + directInfo.setLUTColor(color); + changes = true; + } + } + return changes; } } From 1f541b982e682e75a07729968e4f90b7dc294fa5 Mon Sep 17 00:00:00 2001 From: Alan O'Callaghan Date: Tue, 30 Apr 2024 15:49:23 +0100 Subject: [PATCH 039/866] Clarify jdoc --- .../src/main/java/qupath/lib/objects/utils/ObjectMerger.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qupath-core/src/main/java/qupath/lib/objects/utils/ObjectMerger.java b/qupath-core/src/main/java/qupath/lib/objects/utils/ObjectMerger.java index 25cdc6c86..4fab9b34e 100644 --- a/qupath-core/src/main/java/qupath/lib/objects/utils/ObjectMerger.java +++ b/qupath-core/src/main/java/qupath/lib/objects/utils/ObjectMerger.java @@ -371,7 +371,7 @@ public static ObjectMerger createTouchingMerger() { * tile/region request. * You should probably also remove any objects that touch the regionRequest boundaries, as these will probably be * clipped, and merging them will result in weirdly shaped detections. - * @param iouThreshold Intersection over union threshold; any pairs with values above this are merged. + * @param iouThreshold Intersection over union threshold; any pairs with values greater than or equal to this are merged. * @return an object merger that can merge together any objects with sufficiently high IoU and the same classification */ public static ObjectMerger createIoUMerger(double iouThreshold) { From 69e779f6dca15b211b0ad6eec68c0cd0ea8e0d9d Mon Sep 17 00:00:00 2001 From: Pete Date: Tue, 30 Apr 2024 16:33:41 +0100 Subject: [PATCH 040/866] Fix ImageServer javadoc, update readme --- CHANGELOG.md | 1 + .../main/java/qupath/lib/images/servers/ImageServer.java | 9 +++++---- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f52de2a28..db585d508 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ This is a *work in progress* for the next major release. ### Bugs fixed * Tile export to .ome.tif can convert to 8-bit unnecessarily (https://github.com/qupath/qupath/issues/1494) * Brightness/Contrast 'Apply to similar images' fails to update settings immediately across viewers (https://github.com/qupath/qupath/issues/1499) +* Full image annotation for Sparse training image throws errors for detections (https://github.com/qupath/qupath/issues/1443) ### Dependency updates * Bio-Formats 7.3.0 diff --git a/qupath-core/src/main/java/qupath/lib/images/servers/ImageServer.java b/qupath-core/src/main/java/qupath/lib/images/servers/ImageServer.java index c97cb2225..dee2267bd 100644 --- a/qupath-core/src/main/java/qupath/lib/images/servers/ImageServer.java +++ b/qupath-core/src/main/java/qupath/lib/images/servers/ImageServer.java @@ -306,15 +306,16 @@ public default T readRegion(double downsample, int x, int y, int width, int heig /** * Test whether a region is empty, i.e. it contains nothing to be painted (e.g. the server paints objects - * but there are no objects present in the region) and readBufferedImage(RegionRequest region) would return null. + * but there are no objects present in the region) and {@link #readRegion(RegionRequest)} would return an empty + * tile. *

* This makes it possible to avoid a (potentially more expensive) request to {@link #readRegion(RegionRequest)}, * or to add it to a request queue, if we know there will be nothing to show for it. *

- * Note: if this method returns true, it is safe to assume readBufferedImage would return null. - * However, even if the method returns false it is possible that the region is still empty - + * Note: Even if the method returns false it is possible that the region is still empty - * the purpose of this method is to assist performance, and it should return quickly. - * Therefore if the calculations needed to identify if the region is empty are too onerous, it may conservatively return false. + * Therefore if the calculations needed to identify if the region is empty are too onerous, it may conservatively + * return false. * * @param request * @return From ebe7db61d895cda4e4832ae0ce2d24374ee9ba7e Mon Sep 17 00:00:00 2001 From: Pete Date: Tue, 30 Apr 2024 17:54:15 +0100 Subject: [PATCH 041/866] Fix channel color change can also change name Fix https://github.com/qupath/qupath/issues/1500 --- .../qupath/lib/display/DirectServerChannelInfo.java | 12 ++++++++++++ .../display/BrightnessContrastChannelPane.java | 10 +++++++--- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/qupath-gui-fx/src/main/java/qupath/lib/display/DirectServerChannelInfo.java b/qupath-gui-fx/src/main/java/qupath/lib/display/DirectServerChannelInfo.java index b7db5f62a..0b8e1942c 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/display/DirectServerChannelInfo.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/display/DirectServerChannelInfo.java @@ -90,6 +90,18 @@ public String getName() { return channelName + postfix; } + /** + * Get the original channel name, extracted for the current ImageData if available. + * This differs from {@link #getName()} in that it does not append the channel number, or return a default name + * if no ImageData is present. + * @return the channel name, or null if no image is available + */ + public String getOriginalChannelName() { + // See https://github.com/qupath/qupath/issues/1500 + ImageData imageData = getImageData(); + return imageData == null ? null : imageData.getServer().getChannel(channel).getName(); + } + void setLUTColor(int rgb) { setLUTColor( diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/display/BrightnessContrastChannelPane.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/display/BrightnessContrastChannelPane.java index 15382fcb0..9c4c365de 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/display/BrightnessContrastChannelPane.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/display/BrightnessContrastChannelPane.java @@ -752,9 +752,13 @@ private void handleColorChange(ObservableValue observable, Colo return; } var item = this.getItem(); - if (item instanceof DirectServerChannelInfo) - updateChannelColor((DirectServerChannelInfo)item, item.getName(), newValue); - else + if (item instanceof DirectServerChannelInfo directInfo) { + // See https://github.com/qupath/qupath/issues/1500 + String name = directInfo.getOriginalChannelName(); + if (name == null) + name = item.getName(); // Not expected to occur + updateChannelColor(directInfo, name, newValue); + } else logger.debug("Invalid channel type - cannot set color for {}", item); } From 0cb8a589e9e935fb7985d744a12220826b802144 Mon Sep 17 00:00:00 2001 From: Pete Date: Tue, 30 Apr 2024 18:05:52 +0100 Subject: [PATCH 042/866] Change channel colors less eagerly Addresses issue at https://forum.image.sc/t/bug-channel-name-changed-when-changing-color/95010/6 - attempting to set a custom color via the 'small square' icon would change the channel color immediately, and give no option to interact further with the color picker dialog. --- .../display/BrightnessContrastChannelPane.java | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/display/BrightnessContrastChannelPane.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/display/BrightnessContrastChannelPane.java index 9c4c365de..b22beb0b6 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/display/BrightnessContrastChannelPane.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/display/BrightnessContrastChannelPane.java @@ -676,7 +676,11 @@ private ChannelDisplayTableCell(ObservableList customColors) { // Minimal color picker - just a small, clickable colored square colorPicker = new ColorPicker(); colorPicker.getStyleClass().addAll("button", "minimal-color-picker", "always-opaque"); - colorPicker.valueProperty().addListener(this::handleColorChange); + // See https://forum.image.sc/t/bug-channel-name-changed-when-changing-color/95010/6 + // Need to use the hiding event to update the color, rather than listen to the value property + // (which changes very eagerly...) +// colorPicker.valueProperty().addListener(this::handleColorChange); + colorPicker.setOnHiding(e -> this.handleColorChange(colorPicker.getValue())); setGraphic(colorPicker); setEditable(true); } @@ -742,13 +746,11 @@ private void setColorQuietly(Color color) { updatingTableCell = false; } - private void handleColorChange(ObservableValue observable, Color oldValue, Color newValue) { + private void handleColorChange(Color newValue) { if (updatingTableCell) return; if (newValue == null) { - logger.debug("Attempting to set channel color to null!"); - if (oldValue != null) - setColorQuietly(oldValue); + logger.warn("Can't set channel color to null!"); return; } var item = this.getItem(); From 7773b026e0a426273ad43179b355fadc7af06b5e Mon Sep 17 00:00:00 2001 From: Pete Date: Tue, 30 Apr 2024 18:07:52 +0100 Subject: [PATCH 043/866] Update CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index db585d508..b908b4d2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ This is a *work in progress* for the next major release. * Tile export to .ome.tif can convert to 8-bit unnecessarily (https://github.com/qupath/qupath/issues/1494) * Brightness/Contrast 'Apply to similar images' fails to update settings immediately across viewers (https://github.com/qupath/qupath/issues/1499) * Full image annotation for Sparse training image throws errors for detections (https://github.com/qupath/qupath/issues/1443) + Channel name can sometimes change when using the quick channel color selector (https://github.com/qupath/qupath/issues/1500) ### Dependency updates * Bio-Formats 7.3.0 From 9771f8c276708d71c276d5d5e6c06b9002afd604 Mon Sep 17 00:00:00 2001 From: Pete Date: Tue, 30 Apr 2024 18:50:14 +0100 Subject: [PATCH 044/866] Use logviewer and qupath-fxtras snapshots Temporarily during development, until we make the final releases. --- gradle/libs.versions.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 65b908782..c60f0faa5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -36,12 +36,12 @@ jts = "1.19.0" junit = "5.9.2" logback = "1.3.11" -logviewer = "0.1.1" +logviewer = "0.2.0-SNAPSHOT" openslide = "4.0.0" picocli = "4.7.5" -qupath-fxtras = "0.1.4" +qupath-fxtras = "0.1.5-SNAPSHOT" richtextfx = "0.11.2" From 1d926fcd27d19b406b0c492f84e46edd5a8753ff Mon Sep 17 00:00:00 2001 From: finglis <42358257+finglis@users.noreply.github.com> Date: Wed, 1 May 2024 09:21:12 +0100 Subject: [PATCH 045/866] resizable and center align UI --- .../java/qupath/lib/gui/commands/MeasurementExportCommand.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/MeasurementExportCommand.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/MeasurementExportCommand.java index 43d4541eb..5678bc36a 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/MeasurementExportCommand.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/MeasurementExportCommand.java @@ -270,6 +270,7 @@ else if ((n.equals("Comma (.csv)") || n.equals("Semicolon (.csv)")) && currentOu dialog = Dialogs.builder() .title("Export measurements") + .resizable() .buttons(btnExport, ButtonType.CANCEL) .content(mainPane) .build(); @@ -281,7 +282,7 @@ else if ((n.equals("Comma (.csv)") || n.equals("Semicolon (.csv)")) && currentOu var emptyOutputTextBinding = outputText.textProperty().isEqualTo(""); dialog.getDialogPane().lookupButton(btnExport).disableProperty().bind(Bindings.or(emptyOutputTextBinding, targetItemBinding)); - mainPane.setTop(imageEntryPane); + mainPane.setCenter(imageEntryPane); mainPane.setBottom(optionPane); Optional result = dialog.showAndWait(); From f4f3a79cdb466aea72811bfd6da4caee3172eb04 Mon Sep 17 00:00:00 2001 From: Pete Date: Wed, 1 May 2024 11:40:58 +0100 Subject: [PATCH 046/866] Retain object colors after splitting/merging This won't necessarily retain object colors always, but it should with the `PixelProcessor` and via uses of `PathObjectTools.createLike` --- .../java/qupath/lib/objects/PathObjectTools.java | 15 ++++++++++----- .../qupath/lib/objects/utils/ObjectMerger.java | 15 +++++++++++---- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/qupath-core/src/main/java/qupath/lib/objects/PathObjectTools.java b/qupath-core/src/main/java/qupath/lib/objects/PathObjectTools.java index f207a8962..6e2c6154a 100644 --- a/qupath-core/src/main/java/qupath/lib/objects/PathObjectTools.java +++ b/qupath-core/src/main/java/qupath/lib/objects/PathObjectTools.java @@ -1890,7 +1890,7 @@ public static PathObject createLike(PathObject pathObject, ROI roiNew) { } /** - * Create a new object with the same type and classification as the input object, but a new ROI and ID. + * Create a new object with the same type, classification, name and color as the input object, but a new ROI and ID. * This version of the method supports cell objects with a nucleus ROI. *

* Note that TMA core objects are not supported. @@ -1901,16 +1901,21 @@ public static PathObject createLike(PathObject pathObject, ROI roiNew) { * @since v0.5.0 */ public static PathObject createLike(PathObject pathObject, ROI roiNew, ROI roiNucleus) { + PathObject newObject; if (pathObject.isCell()) { - return PathObjects.createCellObject(roiNew, roiNucleus, pathObject.getPathClass(), null); + newObject = PathObjects.createCellObject(roiNew, roiNucleus, pathObject.getPathClass(), null); } else if (pathObject.isAnnotation()) { - return PathObjects.createAnnotationObject(roiNew, pathObject.getPathClass()); + newObject = PathObjects.createAnnotationObject(roiNew, pathObject.getPathClass()); } else if (pathObject.isTile()) { - return PathObjects.createTileObject(roiNew, pathObject.getPathClass(), null); + newObject = PathObjects.createTileObject(roiNew, pathObject.getPathClass(), null); } else if (pathObject.isDetection()) { - return PathObjects.createDetectionObject(roiNew, pathObject.getPathClass()); + newObject = PathObjects.createDetectionObject(roiNew, pathObject.getPathClass()); } else throw new IllegalArgumentException("Unsupported object type - cannot create similar object for " + pathObject); + // Retain name and color as well + newObject.setName(pathObject.getName()); + newObject.setColor(pathObject.getColor()); + return newObject; } diff --git a/qupath-core/src/main/java/qupath/lib/objects/utils/ObjectMerger.java b/qupath-core/src/main/java/qupath/lib/objects/utils/ObjectMerger.java index 1d26f6b29..af5b1388e 100644 --- a/qupath-core/src/main/java/qupath/lib/objects/utils/ObjectMerger.java +++ b/qupath-core/src/main/java/qupath/lib/objects/utils/ObjectMerger.java @@ -491,21 +491,28 @@ private static PathObject mergeObjects(List pathObjects) { var allROIs = pathObjects.stream().map(PathObject::getROI).filter(Objects::nonNull).collect(Collectors.toList()); ROI mergedROI = RoiTools.union(allROIs); + PathObject mergedObject = null; if (pathObject.isTile()) { - return PathObjects.createTileObject(mergedROI, pathObject.getPathClass(), null); + mergedObject = PathObjects.createTileObject(mergedROI, pathObject.getPathClass(), null); } else if (pathObject.isCell()) { var nucleusROIs = pathObjects.stream() .map(PathObjectTools::getNucleusROI) .filter(Objects::nonNull) .collect(Collectors.toList()); ROI nucleusROI = nucleusROIs.isEmpty() ? null : RoiTools.union(nucleusROIs); - return PathObjects.createCellObject(mergedROI, nucleusROI, pathObject.getPathClass(), null); + mergedObject = PathObjects.createCellObject(mergedROI, nucleusROI, pathObject.getPathClass(), null); } else if (pathObject.isDetection()) { - return PathObjects.createDetectionObject(mergedROI, pathObject.getPathClass()); + mergedObject = PathObjects.createDetectionObject(mergedROI, pathObject.getPathClass()); } else if (pathObject.isAnnotation()) { - return PathObjects.createAnnotationObject(mergedROI, pathObject.getPathClass()); + mergedObject = PathObjects.createAnnotationObject(mergedROI, pathObject.getPathClass()); } else throw new IllegalArgumentException("Unsupported object type for merging: " + pathObject.getClass()); + + // We might need to transfer over the color as well + var color = pathObject.getColor(); + if (color != null) + mergedObject.setColor(color); + return mergedObject; } From f4ba2c0f0ca1cb994f478d122329d23ea7d10e7d Mon Sep 17 00:00:00 2001 From: finglis <42358257+finglis@users.noreply.github.com> Date: Wed, 1 May 2024 14:19:05 +0100 Subject: [PATCH 047/866] UX - Classifier image select resizable UI --- .../qupath/process/gui/commands/ObjectClassifierCommand.java | 1 + .../java/qupath/process/gui/commands/ml/PixelClassifierPane.java | 1 + 2 files changed, 2 insertions(+) diff --git a/qupath-extension-processing/src/main/java/qupath/process/gui/commands/ObjectClassifierCommand.java b/qupath-extension-processing/src/main/java/qupath/process/gui/commands/ObjectClassifierCommand.java index d505e3258..5d4a7c5a4 100644 --- a/qupath-extension-processing/src/main/java/qupath/process/gui/commands/ObjectClassifierCommand.java +++ b/qupath-extension-processing/src/main/java/qupath/process/gui/commands/ObjectClassifierCommand.java @@ -477,6 +477,7 @@ private boolean promptToLoadTrainingImages() { if (Dialogs.builder() .title("Object classifier training images") .content(pane) + .resizable() .buttons(ButtonType.APPLY, ButtonType.CANCEL) .showAndWait().orElse(ButtonType.CANCEL) == ButtonType.CANCEL) return false; diff --git a/qupath-extension-processing/src/main/java/qupath/process/gui/commands/ml/PixelClassifierPane.java b/qupath-extension-processing/src/main/java/qupath/process/gui/commands/ml/PixelClassifierPane.java index 82150cb3c..fbe47b5be 100644 --- a/qupath-extension-processing/src/main/java/qupath/process/gui/commands/ml/PixelClassifierPane.java +++ b/qupath-extension-processing/src/main/java/qupath/process/gui/commands/ml/PixelClassifierPane.java @@ -1402,6 +1402,7 @@ private boolean promptToLoadTrainingImages() { if (Dialogs.builder() .title("Pixel classifier training images") .content(pane) + .resizable() .buttons(ButtonType.APPLY, ButtonType.CANCEL) .showAndWait().orElse(ButtonType.CANCEL) == ButtonType.CANCEL) return false; From 254b45f6392a6ea2dd7f7cd0c069b40d06ef8bd2 Mon Sep 17 00:00:00 2001 From: lleplat Date: Wed, 1 May 2024 14:52:42 +0100 Subject: [PATCH 048/866] Added getJavadocsOfDependencies task --- qupath-app/build.gradle | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/qupath-app/build.gradle b/qupath-app/build.gradle index cb4e06217..623263e09 100644 --- a/qupath-app/build.gradle +++ b/qupath-app/build.gradle @@ -120,6 +120,33 @@ task mergedJavadocJar(type: Jar, dependsOn: rootProject.tasks.mergedJavadocs) { */ jpackage.dependsOn(mergedJavadocJar) + +/** + * Retrieve Javadocs of dependencies + */ +tasks.register("getJavadocsOfDependencies", Copy) { + def componentIds = configurations + .runtimeClasspath + .incoming + .resolutionResult + .allDependencies + .collect { it.selected.id } + + def result = dependencies.createArtifactResolutionQuery() + .forComponents(componentIds) + .withArtifacts(JvmLibrary, SourcesArtifact, JavadocArtifact) + .execute() + + def javadocFiles = new TreeSet() + for (component in result.resolvedComponents) { + def artifacts = component.getArtifacts(JavadocArtifact) + javadocFiles.addAll(artifacts.collect(a -> a.file)) + } + from javadocFiles + into layout.buildDirectory.dir("dependencies-javadoc") +} +tasks.installDist.dependsOn("getJavadocsOfDependencies") + /** * Create license report */ @@ -177,6 +204,10 @@ distributions { into('lib/docs') { from mergedJavadocJar.archiveFile } + // Copy javadoc of dependencies + into('lib/docs') { + from layout.buildDirectory.dir("dependencies-javadoc") + } } } } From e1c97895fab931c438fe043b2f0bab420f8d2cdc Mon Sep 17 00:00:00 2001 From: Pete Date: Thu, 2 May 2024 16:23:43 +0100 Subject: [PATCH 049/866] Update PixelProcessorUtils.java Handle clipping cells with nuclei. This assumes that the nucleus is always inside the cell. --- .../lib/experimental/pixels/PixelProcessorUtils.java | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/qupath-core-processing/src/main/java/qupath/lib/experimental/pixels/PixelProcessorUtils.java b/qupath-core-processing/src/main/java/qupath/lib/experimental/pixels/PixelProcessorUtils.java index 970dedb07..74551cf7d 100644 --- a/qupath-core-processing/src/main/java/qupath/lib/experimental/pixels/PixelProcessorUtils.java +++ b/qupath-core-processing/src/main/java/qupath/lib/experimental/pixels/PixelProcessorUtils.java @@ -129,8 +129,18 @@ public static List maskObject(ROI parentROI, PathObject child) { else if (childGeom.equals(geomOutput)) return Collections.singletonList(child); else { + // Handle a nucleus if necessary + // We assume that the nucleus *must* be inside the cell, so don't check it elsewhere var newROI = GeometryTools.geometryToROI(geomOutput, child.getROI().getImagePlane()); - return Collections.singletonList(PathObjectTools.createLike(child, newROI)); + var nucleusROI = PathObjectTools.getNucleusROI(child); + if (nucleusROI != null) { + var nucleusGeom = nucleusROI.getGeometry(); + var nucleusOutput = GeometryTools.homogenizeGeometryCollection(geom.intersection(nucleusGeom)); + if (nucleusOutput.isEmpty() || nucleusOutput.getDimension() < nucleusGeom.getDimension()) + return Collections.emptyList(); + nucleusROI = GeometryTools.geometryToROI(nucleusOutput, child.getROI().getImagePlane()); + } + return Collections.singletonList(PathObjectTools.createLike(child, newROI, nucleusROI)); } } From a25d719a09c3758c8bd004857e58c076f4fc9393 Mon Sep 17 00:00:00 2001 From: Pete Date: Thu, 2 May 2024 16:31:26 +0100 Subject: [PATCH 050/866] Update PixelProcessorUtils.java --- .../qupath/lib/experimental/pixels/PixelProcessorUtils.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/qupath-core-processing/src/main/java/qupath/lib/experimental/pixels/PixelProcessorUtils.java b/qupath-core-processing/src/main/java/qupath/lib/experimental/pixels/PixelProcessorUtils.java index 74551cf7d..4503fe773 100644 --- a/qupath-core-processing/src/main/java/qupath/lib/experimental/pixels/PixelProcessorUtils.java +++ b/qupath-core-processing/src/main/java/qupath/lib/experimental/pixels/PixelProcessorUtils.java @@ -137,8 +137,9 @@ else if (childGeom.equals(geomOutput)) var nucleusGeom = nucleusROI.getGeometry(); var nucleusOutput = GeometryTools.homogenizeGeometryCollection(geom.intersection(nucleusGeom)); if (nucleusOutput.isEmpty() || nucleusOutput.getDimension() < nucleusGeom.getDimension()) - return Collections.emptyList(); - nucleusROI = GeometryTools.geometryToROI(nucleusOutput, child.getROI().getImagePlane()); + nucleusROI = null; + else + nucleusROI = GeometryTools.geometryToROI(nucleusOutput, child.getROI().getImagePlane()); } return Collections.singletonList(PathObjectTools.createLike(child, newROI, nucleusROI)); } From f8646b5f0c9b01115e32b2fb29d51f45a43a9942 Mon Sep 17 00:00:00 2001 From: Pete Date: Fri, 3 May 2024 11:32:28 +0100 Subject: [PATCH 051/866] Fix uncalibrated ImagePlus dimensions Fixes https://github.com/qupath/qupath/issues/1503 (at least the dimensions bit) --- .../src/main/java/qupath/imagej/tools/IJTools.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/qupath-core-processing/src/main/java/qupath/imagej/tools/IJTools.java b/qupath-core-processing/src/main/java/qupath/imagej/tools/IJTools.java index 0df53b1ac..419d03568 100644 --- a/qupath-core-processing/src/main/java/qupath/imagej/tools/IJTools.java +++ b/qupath-core-processing/src/main/java/qupath/imagej/tools/IJTools.java @@ -787,7 +787,7 @@ public static double tryToParseMicrons(final double value, final String unit) { * @return */ public static ImagePlus convertToUncalibratedImagePlus(String title, BufferedImage img) { - ImagePlus imp = null; + ImagePlus imp; int nBands = img.getSampleModel().getNumBands(); // Let ImageJ handle indexed & 8-bit color images if ((img.getType() == BufferedImage.TYPE_BYTE_INDEXED && nBands == 1) || BufferedImageTools.is8bitColorType(img.getType())) @@ -798,6 +798,7 @@ public static ImagePlus convertToUncalibratedImagePlus(String title, BufferedIma stack.addSlice(convertToImageProcessor(img, b)); } imp = new ImagePlus(title, stack); + imp.setDimensions(nBands, 1, 1); } return imp; } From 28a7f81de0d45b0d1ab1ca638b9b6d37bf9d3685 Mon Sep 17 00:00:00 2001 From: Pete Date: Fri, 3 May 2024 11:38:28 +0100 Subject: [PATCH 052/866] Update CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b908b4d2d..8ee8fd1b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ This is a *work in progress* for the next major release. * Brightness/Contrast 'Apply to similar images' fails to update settings immediately across viewers (https://github.com/qupath/qupath/issues/1499) * Full image annotation for Sparse training image throws errors for detections (https://github.com/qupath/qupath/issues/1443) Channel name can sometimes change when using the quick channel color selector (https://github.com/qupath/qupath/issues/1500) +* TileExporter exports ImageJ TIFFs with channels converted to z-stacks (https://github.com/qupath/qupath/issues/1503) ### Dependency updates * Bio-Formats 7.3.0 From 383603929fef49558b1518d6629742f046b46b41 Mon Sep 17 00:00:00 2001 From: lleplat Date: Fri, 3 May 2024 12:49:10 +0100 Subject: [PATCH 053/866] Added command line flags --- qupath-app/build.gradle | 66 +++++++++++++++++++++++++++-------------- 1 file changed, 44 insertions(+), 22 deletions(-) diff --git a/qupath-app/build.gradle b/qupath-app/build.gradle index 623263e09..13022e63b 100644 --- a/qupath-app/build.gradle +++ b/qupath-app/build.gradle @@ -122,30 +122,52 @@ jpackage.dependsOn(mergedJavadocJar) /** - * Retrieve Javadocs of dependencies + * Retrieve Javadocs of dependencies and sub-projects */ -tasks.register("getJavadocsOfDependencies", Copy) { - def componentIds = configurations - .runtimeClasspath - .incoming - .resolutionResult - .allDependencies - .collect { it.selected.id } - - def result = dependencies.createArtifactResolutionQuery() - .forComponents(componentIds) +tasks.register("getJavadocs", Copy) { + group = "documentation" + description = "Retrieve the Javadocs Jar of all dependencies and sub-projects" + duplicatesStrategy = DuplicatesStrategy.EXCLUDE + dependsOn rootProject.subprojects.tasks.collect {it.withType(Jar)} + + def subProjectsJavadoc = rootProject.subprojects + .collect {it.layout.buildDirectory.dir("libs").get().getAsFileTree().getFiles().findAll {it.name.contains("javadoc")}} + .findAll {it.size() == 1} + .collect {it.toList().get(0)} + def dependenciesJavadoc = dependencies.createArtifactResolutionQuery() + .forComponents(configurations + .runtimeClasspath + .incoming + .resolutionResult + .allDependencies + .collect { it.selected.id } + ) .withArtifacts(JvmLibrary, SourcesArtifact, JavadocArtifact) .execute() - - def javadocFiles = new TreeSet() - for (component in result.resolvedComponents) { - def artifacts = component.getArtifacts(JavadocArtifact) - javadocFiles.addAll(artifacts.collect(a -> a.file)) - } - from javadocFiles - into layout.buildDirectory.dir("dependencies-javadoc") + .resolvedComponents + .collect { + it.getArtifacts(JavadocArtifact).collect {it.file} + } + .flatten() + def allJavadocs = subProjectsJavadoc + dependenciesJavadoc + + from allJavadocs + .findAll { + def docs = findProperty('docs') ?: 'default' + + if (docs == "all") { + return true + } else if (docs == "none") { + return false + } else if (docs == "qupath") { + return it.name.startsWith("qupath") + } else { + return it.name.startsWith("qupath") || it.name.startsWith("jts") || it.name.startsWith("ij") + } + } + into layout.buildDirectory.dir("javadocs") } -tasks.installDist.dependsOn("getJavadocsOfDependencies") +tasks.installDist.dependsOn("getJavadocs") /** * Create license report @@ -204,9 +226,9 @@ distributions { into('lib/docs') { from mergedJavadocJar.archiveFile } - // Copy javadoc of dependencies + // Copy javadocs into('lib/docs') { - from layout.buildDirectory.dir("dependencies-javadoc") + from layout.buildDirectory.dir("javadocs") } } } From 6c6abc0502e8ffc10ff26f2db4594cc538b1aa03 Mon Sep 17 00:00:00 2001 From: lleplat Date: Fri, 3 May 2024 12:57:02 +0100 Subject: [PATCH 054/866] Removed mergedJavadocs task --- build.gradle | 31 ------------------------------- qupath-app/build.gradle | 18 ------------------ 2 files changed, 49 deletions(-) diff --git a/build.gradle b/build.gradle index 924bef3ef..95f3159d7 100644 --- a/build.gradle +++ b/build.gradle @@ -7,37 +7,6 @@ plugins { // We don't want to generate javadocs for the root project javadoc.enabled = false - -// See https://discuss.gradle.org/t/best-approach-gradle-multi-module-project-generate-just-one-global-javadoc/18657 -task mergedJavadocs(type: Javadoc, - description: 'Generate merged javadocs for all projects', - group: 'Documentation', - dependsOn: subprojects.tasks.collect {it.withType(Javadoc)} ) { - - destinationDir = file("$buildDir/docs-merged/javadoc") - title = "QuPath $gradle.ext.qupathVersion" - - // See https://docs.gradle.org/current/javadoc/org/gradle/external/javadoc/StandardJavadocDocletOptions.html - options.author(true) - options.addStringOption('Xdoclint:none', '-quiet') - - options.encoding = 'UTF-8' - - options.links "https://docs.oracle.com/en/java/javase/${libs.versions.jdk.get()}/docs/api/" - // Need to use the major version only with javafx - options.links "https://openjfx.io/javadoc/${libs.versions.javafx.get().split('\\.')[0]}/" - options.links "https://javadoc.io/doc/org.bytedeco/javacpp/${libs.versions.javacpp.get()}/" - options.links "https://javadoc.io/doc/org.bytedeco/opencv/${libs.versions.opencv.get()}/" - options.links "https://javadoc.io/doc/com.google.code.gson/gson/${libs.versions.gson.get()}/" - options.links "https://javadoc.io/doc/org.locationtech.jts/jts-core/${libs.versions.jts.get()}/" - options.links "https://javadoc.io/doc/net.imagej/ij/${libs.versions.imagej.get()}/" - options.links "https://javadoc.scijava.org/Bio-Formats/" - options.links "https://javadoc.io/doc/ai.djl/api/${libs.versions.deepJavaLibrary.get()}/" - - // Don't fail on error, because this happened too often due to a javadoc link being temporarily down - failOnError = false -} - /* * Get version catalog */ diff --git a/qupath-app/build.gradle b/qupath-app/build.gradle index 13022e63b..e762dd061 100644 --- a/qupath-app/build.gradle +++ b/qupath-app/build.gradle @@ -106,20 +106,6 @@ dependencies { implementation libs.picocli } -/** - * Create a single javadoc jar - */ -task mergedJavadocJar(type: Jar, dependsOn: rootProject.tasks.mergedJavadocs) { - archiveFileName = "qupath-$qupathVersion-javadoc.jar" - destinationDirectory = rootProject.layout.buildDirectory.dir("docs-merged") - from rootProject.tasks.mergedJavadocs.destinationDir -} - -/** - * Specify task dependencies - */ -jpackage.dependsOn(mergedJavadocJar) - /** * Retrieve Javadocs of dependencies and sub-projects @@ -222,10 +208,6 @@ distributions { from this.rootProject.layout.buildDirectory.dir("reports/dependency-license") include 'THIRD-PARTY.txt' } - // Copy native libraries - into('lib/docs') { - from mergedJavadocJar.archiveFile - } // Copy javadocs into('lib/docs') { from layout.buildDirectory.dir("javadocs") From f16c16fa33cfb5197e9d95e9ec5cf9cb65648ac6 Mon Sep 17 00:00:00 2001 From: lleplat Date: Mon, 6 May 2024 11:36:55 +0100 Subject: [PATCH 055/866] Added Javadoc with Run task --- qupath-app/build.gradle | 1 + 1 file changed, 1 insertion(+) diff --git a/qupath-app/build.gradle b/qupath-app/build.gradle index e762dd061..da33891ae 100644 --- a/qupath-app/build.gradle +++ b/qupath-app/build.gradle @@ -153,6 +153,7 @@ tasks.register("getJavadocs", Copy) { } into layout.buildDirectory.dir("javadocs") } +tasks.run.dependsOn("getJavadocs") tasks.installDist.dependsOn("getJavadocs") /** From f0f87f25a04fc4316321125c5ae7f6ada43cc12a Mon Sep 17 00:00:00 2001 From: lleplat Date: Mon, 6 May 2024 11:37:11 +0100 Subject: [PATCH 056/866] Added Javadoc options links --- .../groovy/qupath.java-conventions.gradle | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/buildSrc/src/main/groovy/qupath.java-conventions.gradle b/buildSrc/src/main/groovy/qupath.java-conventions.gradle index 54831d90c..d1794cecc 100644 --- a/buildSrc/src/main/groovy/qupath.java-conventions.gradle +++ b/buildSrc/src/main/groovy/qupath.java-conventions.gradle @@ -84,28 +84,28 @@ afterEvaluate { /* - * Create javadocs for all modules/packages in one place. + * Set options for creating javadocs for all modules/packages * Use -PstrictJavadoc=true to fail on error with doclint (which is rather strict). */ -def strictJavadoc = findProperty('strictJavadoc') - tasks.withType(Javadoc).each { javadocTask -> - if (!strictJavadoc) { + if (!findProperty('strictJavadoc')) { // This should be made more strict in the future javadocTask.options.addStringOption('Xdoclint:none', '-quiet') -// javadocTask.options.addStringOption('Xdoclint:html,syntax,reference', '-quiet') } - javadocTask.options.encoding = 'UTF-8' - javadocTask.options.tags = ["apiNote:a:API Note", + javadocTask.options.encoding = 'UTF-8' + javadocTask.options.tags = ["apiNote:a:API Note", "implNote:a:Implementation Note", "implSpec:a:Implementation Requirements"] - - rootProject.tasks.withType(Javadoc) { rootTask -> - rootTask.source += javadocTask.source - rootTask.classpath += javadocTask.classpath - rootTask.excludes += javadocTask.excludes - rootTask.includes += javadocTask.includes - } + javadocTask.options.links "https://docs.oracle.com/en/java/javase/${libs.versions.jdk.get()}/docs/api/" + // Need to use the major version only with javafx + javadocTask.options.links "https://openjfx.io/javadoc/${libs.versions.javafx.get().split('\\.')[0]}/" + javadocTask.options.links "https://javadoc.io/doc/org.bytedeco/javacpp/${libs.versions.javacpp.get()}/" + javadocTask.options.links "https://javadoc.io/doc/org.bytedeco/opencv/${libs.versions.opencv.get()}/" + javadocTask.options.links "https://javadoc.io/doc/com.google.code.gson/gson/${libs.versions.gson.get()}/" + javadocTask.options.links "https://javadoc.io/doc/org.locationtech.jts/jts-core/${libs.versions.jts.get()}/" + javadocTask.options.links "https://javadoc.io/doc/net.imagej/ij/${libs.versions.imagej.get()}/" + javadocTask.options.links "https://javadoc.scijava.org/Bio-Formats/" + javadocTask.options.links "https://javadoc.io/doc/ai.djl/api/${libs.versions.deepJavaLibrary.get()}/" } /* From 5a455319fff18eba29906edd6d6358c2b14308c4 Mon Sep 17 00:00:00 2001 From: lleplat Date: Mon, 6 May 2024 17:12:10 +0100 Subject: [PATCH 057/866] Removed run task dependency on getJavadocs --- qupath-app/build.gradle | 1 - 1 file changed, 1 deletion(-) diff --git a/qupath-app/build.gradle b/qupath-app/build.gradle index da33891ae..e762dd061 100644 --- a/qupath-app/build.gradle +++ b/qupath-app/build.gradle @@ -153,7 +153,6 @@ tasks.register("getJavadocs", Copy) { } into layout.buildDirectory.dir("javadocs") } -tasks.run.dependsOn("getJavadocs") tasks.installDist.dependsOn("getJavadocs") /** From 21667379bfb517da530c5a4f5a7950274d19baf7 Mon Sep 17 00:00:00 2001 From: Leo Leplat <60394504+Rylern@users.noreply.github.com> Date: Tue, 7 May 2024 10:24:31 +0100 Subject: [PATCH 058/866] Renamed Javadoc task --- qupath-app/build.gradle | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/qupath-app/build.gradle b/qupath-app/build.gradle index e762dd061..38de5b375 100644 --- a/qupath-app/build.gradle +++ b/qupath-app/build.gradle @@ -108,11 +108,11 @@ dependencies { /** - * Retrieve Javadocs of dependencies and sub-projects + * Copies the Javadoc jars to a directory for access within QuPath */ -tasks.register("getJavadocs", Copy) { +tasks.register("assembleJavadocs", Copy) { group = "documentation" - description = "Retrieve the Javadocs Jar of all dependencies and sub-projects" + description = "Copies the Javadoc jars to a directory for access within QuPath" duplicatesStrategy = DuplicatesStrategy.EXCLUDE dependsOn rootProject.subprojects.tasks.collect {it.withType(Jar)} @@ -153,7 +153,7 @@ tasks.register("getJavadocs", Copy) { } into layout.buildDirectory.dir("javadocs") } -tasks.installDist.dependsOn("getJavadocs") +tasks.installDist.dependsOn("assembleJavadocs") /** * Create license report From c2285cfb6a33773935c7a112fa836d530fae3472 Mon Sep 17 00:00:00 2001 From: Leo Leplat <60394504+Rylern@users.noreply.github.com> Date: Tue, 7 May 2024 14:38:42 +0100 Subject: [PATCH 059/866] Switched to new javadoc viewer --- gradle/libs.versions.toml | 4 + qupath-gui-fx/build.gradle | 2 + .../java/qupath/lib/gui/JavadocViewer.java | 744 ++---------------- .../gui/scripting/DefaultScriptEditor.java | 4 +- 4 files changed, 68 insertions(+), 686 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c60f0faa5..1cb9a44c5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -38,6 +38,8 @@ junit = "5.9.2" logback = "1.3.11" logviewer = "0.2.0-SNAPSHOT" +javadocviewer = "0.1.0-SNAPSHOT" + openslide = "4.0.0" picocli = "4.7.5" @@ -77,6 +79,8 @@ logviewer-console = { module = "io.github.qupath:logviewer-ui-textarea", versio logviewer-rich = { module = "io.github.qupath:logviewer-ui-richtextfx", version.ref = "logviewer" } logviewer-logback = { module = "io.github.qupath:logviewer-logging-logback", version.ref = "logviewer" } +javadocviewer = { module = "io.github.qupath:javadocviewer", version.ref = "javadocviewer" } + picocli = { module = "info.picocli:picocli", version.ref = "picocli" } qupath-fxtras = { module = "io.github.qupath:qupath-fxtras", version.ref = "qupath-fxtras" } diff --git a/qupath-gui-fx/build.gradle b/qupath-gui-fx/build.gradle index bf5193085..ce2aae1f5 100644 --- a/qupath-gui-fx/build.gradle +++ b/qupath-gui-fx/build.gradle @@ -35,6 +35,8 @@ dependencies { implementation libs.bundles.markdown implementation libs.bundles.logviewer + + implementation libs.javadocviewer } javafx { diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/JavadocViewer.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/JavadocViewer.java index c7b16f5fb..4e3df1fdf 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/JavadocViewer.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/JavadocViewer.java @@ -1,695 +1,69 @@ -/*- - * #%L - * This file is part of QuPath. - * %% - * Copyright (C) 2022 QuPath developers, The University of Edinburgh - * %% - * QuPath 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 3 of the - * License, or (at your option) any later version. - * - * QuPath 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 QuPath. If not, see . - * #L% - */ - - package qupath.lib.gui; -import java.io.IOException; -import java.net.URI; -import java.net.URISyntaxException; -import java.nio.charset.StandardCharsets; -import java.nio.file.FileSystems; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.Collections; -import java.util.List; -import java.util.Objects; -import java.util.Set; -import java.util.stream.Stream; -import java.util.zip.ZipFile; - -import com.google.gson.GsonBuilder; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import com.google.gson.annotations.SerializedName; -import com.google.gson.reflect.TypeToken; - -import javafx.application.Platform; -import javafx.beans.binding.Bindings; -import javafx.beans.property.ObjectProperty; -import javafx.beans.property.SimpleObjectProperty; -import javafx.beans.property.SimpleStringProperty; import javafx.beans.property.StringProperty; -import javafx.collections.FXCollections; -import javafx.collections.ObservableList; -import javafx.geometry.Insets; -import javafx.scene.Scene; -import javafx.scene.control.Button; -import javafx.scene.control.ComboBox; -import javafx.scene.control.Tooltip; -import javafx.scene.layout.BorderPane; -import javafx.scene.layout.HBox; -import javafx.scene.layout.Priority; -import javafx.scene.web.WebView; import javafx.stage.Stage; -import javafx.stage.Window; -import qupath.fx.utils.FXUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import qupath.lib.common.GeneralTools; import qupath.lib.gui.prefs.PathPrefs; -import qupath.lib.gui.tools.WebViews; +import qupath.ui.javadocviewer.gui.viewer.JavadocViewerCommand; + +import java.net.URI; +import java.util.Objects; +import java.util.stream.Stream; /** - * Command to show javadocs in a {@link WebView}. - * - * @author Pete Bankhead + *

+ * A command to show a {@link qupath.ui.javadocviewer.gui.viewer.JavadocViewer JavadocViewer} + * in a standalone window. Only one instance of the viewer will be created. + *

+ *

+ * The following places will be searched for javadocs: + *

    + *
  • The value of the {@link #JAVADOC_PATH_SYSTEM_PROPERTY} system property.
  • + *
  • The value of the {@link #JAVADOC_PATH_PREFERENCE} persistent preference.
  • + *
  • Around the currently running executable.
  • + *
  • In the QuPath user directory (so including QuPath extensions).
  • + *
+ *

*/ -public class JavadocViewer { - - private static final Logger logger = LoggerFactory.getLogger(JavadocViewer.class); - - /** - * Name of the system property used to set the javadoc path. - * Could be set to https://qupath.github.io/javadoc/docs/ although note this path may change. - */ - public static final String PROP_JAVADOC_PATH = "javadoc"; - - /** - * Optional persistent property to store a javadoc path, to be used if the system property is missing. - */ - private static final StringProperty javadocPath = PathPrefs.createPersistentPreference("javadocPath", null); - - private static JavadocViewer INSTANCE; - - private final Window parent; - - private final StringProperty title; - private final ObservableList uris; - - private WebView webview; - private ObjectProperty selectedUri = new SimpleObjectProperty<>(); - - /** - * Temporary flag to indicate that the combo box is being synced to the current URI - * (and therefore shouldn't update the selected URI itself). - */ - private boolean updatingFromHistory = false; - - private Stage stage; - - private JavadocViewer(Window parent, String title, List uris) { - this.parent = parent; - this.title = new SimpleStringProperty(title); - this.uris = FXCollections.observableArrayList(uris); - } - - /** - * Get the stage used to show the javadocs. - * @return - */ - public Stage getStage() { - if (stage == null) { - init(); - } - return stage; - } - - - /** - * Get a (sorted) list of URIs for potential javadocs. - * @return - * @throws URISyntaxException - * @throws IOException - */ - private static List findJavadocUris() throws URISyntaxException, IOException { - - // If we have a system property, use it first - int searchDepth = 0; - var uris = JavadocUriFinder.tryToFindJavadocUris(System.getProperty(PROP_JAVADOC_PATH), searchDepth); - if (!uris.isEmpty()) { - logger.debug("Read javadoc URIs from System property: {}", System.getProperty(PROP_JAVADOC_PATH)); - return uris; - } - - // If we have a stored uri, use it next - uris = JavadocUriFinder.tryToFindJavadocUris(javadocPath.get(), searchDepth); - if (!uris.isEmpty()) { - logger.debug("Read javadoc URIs from persistent preference: {}", javadocPath.get()); - return uris; - } - - // Get the location of the code - var codeUri = JavadocViewer.class.getProtectionDomain().getCodeSource().getLocation().toURI(); - var codePath = Paths.get(codeUri); - var codeFilename = codePath.getFileName().toString().toLowerCase(); - - // We will be starting from a directory - Path dir = null; - - // If we have a jar file, we need to check the location... - if (codeFilename.endsWith(".jar")) { - if (codePath.getParent().toString().endsWith("/build/libs")) { - // We are probably using gradlew run - // We can go up several directories to the root project, and then search inside for javadocs - dir = codePath.getParent().resolve("../../../").normalize(); - searchDepth = 4; - } else { - // We are probably within a pre-built package - // javadoc jars should be either in the same directory or a subdirectory - dir = codePath.getParent(); - searchDepth = 2; - } - } else if (codePath.toString().endsWith("/qupath-gui-fx/bin/main")) { - // If we have a binary directory, we may well be launching from an IDE - // We can go up several directories to the root project, and then search inside for javadocs - dir = codePath.resolve("../../../").normalize(); - searchDepth = 4; - } - - logger.info("Searching for javadocs in {} (depth={})", dir, searchDepth); - - if (dir != null) - return JavadocUriFinder.tryToFindJavadocUris(dir, searchDepth); - else - return Collections.emptyList(); - } - - - - private void init() { - - webview = WebViews.create(true); - var engine = webview.getEngine(); - var history = engine.getHistory(); - - selectedUri = new SimpleObjectProperty<>(); - - var pane = new BorderPane(webview); - - selectedUri.addListener((v, o, n) ->{ - if (!updatingFromHistory) - engine.load(n.toString()); - }); - - double spacing = 4; - var toolbar = new HBox(); - toolbar.setSpacing(spacing); - toolbar.setPadding(new Insets(spacing)); - - var btnBack = new Button("<"); // ← - btnBack.setTooltip(new Tooltip("Back")); - btnBack.disableProperty().bind(history.currentIndexProperty().isEqualTo(0)); - btnBack.setOnAction(e -> backOne()); - - var btnForward = new Button(">"); // → - btnForward.setTooltip(new Tooltip("Forward")); - btnForward.disableProperty().bind(Bindings.createBooleanBinding(() -> { - return history.getCurrentIndex() >= history.getEntries().size() - 1; - }, history.currentIndexProperty(), history.getEntries())); - btnForward.setOnAction(e -> forwardOne()); - - toolbar.getChildren().addAll(btnBack, btnForward); - - var comboUris = new ComboBox<>(uris); - comboUris.setTooltip(new Tooltip("Javadoc source")); - comboUris.setMaxWidth(Double.MAX_VALUE); - comboUris.setCellFactory(v -> FXUtils.createCustomListCell(JavadocViewer::getName)); - comboUris.setButtonCell(FXUtils.createCustomListCell((JavadocViewer::getName))); -// selectedUri.bind(comboUris.getSelectionModel().selectedItemProperty()); - comboUris.setOnAction(e -> { - selectedUri.set(comboUris.getValue()); - }); - selectedUri.addListener((v, o, n) -> { - if (n != null && !Objects.equals(n, comboUris.getValue())) { - comboUris.getSelectionModel().select(n); - } - }); - // Select the first - if (!uris.isEmpty()) { - if (uris.size() == 1) { - selectedUri.set(uris.get(0)); - } else { - // Select the first URI with QuPath in the name... or the first one generally - var uriDefault = uris.stream() - .filter(u -> getName(u).toLowerCase().contains("qupath")) - .findFirst() - .orElse(uris.get(0)); - selectedUri.set(uriDefault); - } - } else { - webview.getEngine().loadContent("No javadocs could be found - sorry!"); - } - - toolbar.getChildren().add(comboUris); - HBox.setHgrow(comboUris, Priority.ALWAYS); - - pane.setTop(toolbar); - - var scene = new Scene(pane); - stage = new Stage(); - stage.titleProperty().bind(title); - stage.setScene(scene); - stage.initOwner(parent); - } - - /** - * Try to move one step forward in the WebHistory - * @return true if the entry was updated, false otherwise - */ - private boolean backOne() { - return offset(-1); - } - - /** - * Try to move one step back in the WebHistory - * @return true if the entry was updated, false otherwise - */ - private boolean forwardOne() { - return offset(1); - } - - /** - * Offset the WebHistory by the specified increment, or do nothing - * if the new index would be out of range. - * @param offset generally -1 or 1 - * @return true if the entry was updated, false otherwise - */ - private boolean offset(int offset) { - var history = webview.getEngine().getHistory(); - int ind = history.getCurrentIndex() + offset; - if (ind >= 0 && ind < history.getEntries().size()) { - try { - updatingFromHistory = true; - history.go(offset); - syncUrlToLocation(); - return true; - } finally { - updatingFromHistory = false; - } - } else - return false; - } - - /** - * Synchronize the selectedUri based upon the current URL in the WebView. - */ - private void syncUrlToLocation() { - if (uris.size() <= 1) - return; - - var uri = webview.getEngine().getLocation(); - if (uri == null || uri.isBlank()) - return; - - uris.stream().filter(u -> uri.startsWith(u.toString())).findFirst().ifPresent(baseUri -> selectedUri.set(baseUri)); - } - - - /** - * Get a display name for a URI (which can be used in the combo box) - * @param uri - * @return - */ - private static String getName(URI uri) { - if ("jar".equals(uri.getScheme())) - uri = URI.create(uri.getRawSchemeSpecificPart()); - var path = GeneralTools.toPath(uri); - if (path == null) - return uri.toString(); - String name = path.getFileName().toString().toLowerCase(); - // If we have index.html, we want to take the name of the parent - if (name.endsWith(".html")) { - var fileName = path.getParent().getFileName().toString(); - if (fileName.endsWith(".jar!")) - fileName = fileName.substring(0, fileName.length()-1); - return fileName; - } - return name; - } - - /** - * Create a new instance of {@link JavadocViewer}. - * {@link #getInstance()} should generally be used instead to reuse the same instance where possible. - * @return - */ - private static JavadocViewer createInstance() { - List uris; - try { - uris = findJavadocUris(); - } catch (Exception e) { - logger.warn("Exception requesting URIs: " + e.getLocalizedMessage(), e); - uris = Collections.emptyList(); - } - - var qupath = QuPathGUI.getInstance(); - var javadocViewer = new JavadocViewer(qupath == null ? null : qupath.getStage(), "QuPath Javadocs", uris); - return javadocViewer; - } - - /** - * Get the main (singleton) instance of {@link JavadocViewer}. - * @return - */ - public static JavadocViewer getInstance() { - if (INSTANCE == null) { - synchronized (JavadocViewer.class) { - if (INSTANCE == null) { - INSTANCE = createInstance(); - } - } - } - return INSTANCE; - } - - - /** - * Show javadoc stage (used for development). - * @param args - */ - public static void main(String[] args) { - Platform.startup(() -> getInstance().getStage().show()); - } - - - /** - * Helper methods for finding URIs that correspond to javadocs. - */ - private static class JavadocUriFinder { - - private static final Set SCHEMES_HTTP = Set.of("http", "https"); - - /** - * Try to create a URI from a string, returning null if this is not possible (or the input is null). - * @param uri - * @return - */ - private static URI tryToCreateUri(String uri) { - if (uri == null || uri.isBlank()) - return null; - try { - return GeneralTools.toURI(uri); - } catch (Exception e) { - logger.debug("Unable to convert {} to valid URI", uri); - return null; - } - } - - /** - * Try to find javadoc URIs given an input uri or path. - * @param uri the base URI, which will be converted using {@link #tryToCreateUri(String)} - * @param searchDepth how many directories deep to search if the input URI corresponds to a local directory (often 0) - * @return as many javadoc URIs as could be found starting from the input URI, or an empty list if none could be found. - * If the input is a URI to a file or website, then either a singleton or empty list will be returned. - */ - private static List tryToFindJavadocUris(String uri, int searchDepth) { - return tryToFindJavadocUris(tryToCreateUri(uri), searchDepth); - } - - private static List tryToFindJavadocUris(URI uri, int searchDepth) { - if (uri == null) - return Collections.emptyList(); - - String scheme = uri.getScheme(); - if (SCHEMES_HTTP.contains(scheme)) - return Collections.singletonList(uri); - - Path path = GeneralTools.toPath(uri); - return tryToFindJavadocUris(path, searchDepth); - } - - private static List tryToFindJavadocUris(Path path, int searchDepth) { - if (path == null) - return Collections.emptyList(); - - if (Files.isDirectory(path)) { - return getJavadocUris(path, searchDepth).sorted().toList(); - } else { - var docUri = getDocUri(path); - return docUri == null ? Collections.emptyList() : Collections.singletonList(docUri); - } - } - - - /** - * Get URIs for all potential javadoc index files - * @param dir - * @param depth - * @return - */ - private static Stream getJavadocUris(Path dir, int depth) { - try { - return Files.walk(dir, depth) - .map(p -> getDocUri(p)) - .filter(u -> u != null); - } catch (IOException e) { - logger.error("Exception requesting javadoc URIs: " + e.getLocalizedMessage(), e); - return Stream.empty(); - } - } - - /** - * Try to get a javadoc URI from a path (either an index.html, jar or zip file). - * @param path - * @return the javadoc URI if found, or null if none is available - */ - private static URI getDocUri(Path path) { - - var file = path.toFile(); - if (!file.isFile() || file.isHidden()) - return null; - - // Accept index.html if it is located in a 'javadoc', 'javadocs', 'doc', or 'docs' directory - // and the index.html contains some reference to javadoc as well - Set javadocDirectoryNames = Set.of("javadoc", "javadocs", "docs"); - String fileNameLower = file.getName().toLowerCase(); - String parentNameLower = file.getParentFile().getName().toLowerCase(); - if ("index.html".equals(fileNameLower) && javadocDirectoryNames.contains(parentNameLower)) { - try { - var lines = Files.readAllLines(path, StandardCharsets.UTF_8); - if (lines.stream().anyMatch(l -> l.contains("javadoc"))) - return file.toURI(); - } catch (IOException e) { - logger.warn("Exception parsing {}: {}", path, e.getLocalizedMessage()); - logger.debug(e.getLocalizedMessage(), e); - } - return null; - } - - String ext = GeneralTools.getExtension(file).orElse(null); - if (ext == null) - return null; - - // Accept [something]javadoc.jar and [something]javadoc.zip as long as there is an index.html inside - if ((".jar".equals(ext) || ".zip".equals(ext)) && fileNameLower.endsWith("javadoc" + ext)) { - try (var zf = new ZipFile(file)) { - if (zf.getEntry("index.html") != null) { - logger.debug("Found javadoc entry " + zf.getName()); - return URI.create("jar:" + file.toURI().toString() + "!/index.html"); - } - } catch (IOException e) { - logger.debug(e.getLocalizedMessage()); - } - } - return null; - } - - } - - - - - /** - * Javadocs from Java 9 onwards are searchable. - * This class isn't currently used in QuPath, but demonstrates how to parse the search index to - * get an overview of all available types and members (including methods and fields). - *

- * It might be used in the future to improve autocompletion and context help in the script editor. - */ - private static class JavadocSearch { - - private static final Logger logger = LoggerFactory.getLogger(JavadocSearch.class); - - - private static List parseJavadocSearchMembers(Path baseDir) throws IOException { - return parseJavadocSearchItems(baseDir, "member-search-index.js", JavadocMember.class); - } - - private static List parseJavadocSearchTypes(Path baseDir) throws IOException { - return parseJavadocSearchItems(baseDir, "type-search-index.js", JavadocType.class); - } - - private static List parseJavadocSearchItems(Path baseDir, String indexName, Class cls) throws IOException { - if (Files.isDirectory(baseDir)) { - // Handle javadoc directories - var file = baseDir.resolve(indexName); - if (Files.exists(file)) - return parseJavadocSearchItems(file, cls); - else - throw new IOException("Unable to find index file " + file); - } else if (Files.isRegularFile(baseDir)) { - // Attempt to handle zip and jar files, with the help of a ZipFileSystem - var fs = FileSystems.newFileSystem(baseDir, (ClassLoader)null); - var file = fs.getPath("/" + indexName); - return parseJavadocSearchItems(file, cls); - } else - throw new IOException(baseDir + " is not a javadoc directory, jar or zip file!"); - } - - @SuppressWarnings("unchecked") - private static List parseJavadocSearchItems(Path path, Class cls) throws IOException { - var json = Files.readString(path, StandardCharsets.UTF_8); - - // - int startInd = json.indexOf("["); - int endInd = json.lastIndexOf("]") + 1; - if (startInd >= 0 && endInd >= 0 && endInd < json.length()) - json = json.substring(startInd, endInd); - - return (List)new GsonBuilder().create().fromJson(json, TypeToken.getParameterized(List.class, cls).getType()); - } - - /** - * Distinguish between different kinds of javadoc item, since the index needs to build - * their URIs differently. - */ - private static enum JavadocItemType {TYPE, MEMBER} - - - /** - * Base class for an item that might be required in the javadocs - * (e.g. class, interface, method, field) - * - * TODO: Consider actually using this! Code is here for reference in case it is useful later. - */ - abstract static class JavadocItem { - - private static final String UNNAMED = ""; - - protected final JavadocItemType javadocType; - - @SerializedName("p") - protected String p; - - @SerializedName("c") - protected String c; - - @SerializedName("u") - protected String u; - - @SerializedName("l") - protected String l; - - JavadocItem(JavadocItemType javadocType) { - this.javadocType = javadocType; - } - - /** - * Create a javadoc URI for the item, given the provided base. - * @param baseUri - * @return - */ - public String toUri(String baseUri) { - var sb = new StringBuilder(); - if (baseUri != null) - sb.append(baseUri); - - if (p != null && !p.isBlank() && !p.equals(UNNAMED)) - sb.append(p.replaceAll("\\.", "/")).append("/"); - - switch(javadocType) { - case TYPE: - sb.append(l); - sb.append(".html"); - break; - case MEMBER: - sb.append(c); - sb.append(".html"); - if (l != null || u != null) { - sb.append("#"); - if (u != null) - sb.append(u); - else - sb.append(l); - } - break; - default: - logger.warn("Unrecognized javadoc type: {}", javadocType); - } - - return sb.toString(); - } - - } - - /** - * Member (field or method in a class). - */ - static class JavadocMember extends JavadocItem { - - JavadocMember() { - super(JavadocItemType.MEMBER); - } - - @Override - public String toString() { - return p + "." + c + "." + l; - } - - } - - /** - * Type (class or interface). - */ - static class JavadocType extends JavadocItem { - - JavadocType() { - super(JavadocItemType.TYPE); - } - - @Override - public String toString() { - return p + "." + l; - } - - } - - /** - * Loop through and print how many types & members can be parsed from any provided javadoc paths (directories or jars). - * (useful during development) - * @param args - */ - public static void main(String[] args) { - if (args.length == 0) - logger.warn("No javadoc paths provided!"); - for (var arg: args) { - try { - var path = Paths.get(arg); - if (path.endsWith("index.html")) - path = path.getParent(); - - logger.info("Parsing {}...", path); - var types = JavadocSearch.parseJavadocSearchTypes(path); - logger.info("Found {} types", types.size()); - var members = JavadocSearch.parseJavadocSearchMembers(path); - logger.info("Found {} members", members.size()); - } catch (IOException e) { - logger.error(e.getLocalizedMessage(), e); - } - } - } - - - } - - - +public class JavadocViewer implements Runnable { + + private static final Logger logger = LoggerFactory.getLogger(JavadocViewer.class); + private static final String JAVADOC_PATH_SYSTEM_PROPERTY = "javadoc"; + private static final String JAVADOC_PATH_PREFERENCE = "javadocPath"; + private static final StringProperty javadocPath = PathPrefs.createPersistentPreference(JAVADOC_PATH_PREFERENCE, null); + private final JavadocViewerCommand command; + + /** + * Create the command. This will not create the viewer yet. + * + * @param owner the stage that should own the viewer window. Can be null + */ + public JavadocViewer(Stage owner) { + command = new JavadocViewerCommand( + owner, + Stream.of( + System.getProperty(JAVADOC_PATH_SYSTEM_PROPERTY), + javadocPath.get(), + PathPrefs.userPathProperty().get() + ) + .filter(Objects::nonNull) + .map(uri -> { + try { + return GeneralTools.toURI(uri); + } catch (Exception e) { + logger.debug(String.format("Could not create URI from %s", uri), e); + return null; + } + }) + .filter(Objects::nonNull) + .toArray(URI[]::new) + ); + } + + @Override + public void run() { + command.run(); + } } diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/scripting/DefaultScriptEditor.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/scripting/DefaultScriptEditor.java index e7b7be578..5384c00dd 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/scripting/DefaultScriptEditor.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/scripting/DefaultScriptEditor.java @@ -321,7 +321,7 @@ private ScriptSyntax getSyntax(ScriptLanguage language) { private Action zapGremlinsAction = createReplaceTextAction("Zap gremlins", GeneralTools::zapGremlins, true); private Action replaceQuotesAction = createReplaceTextAction("Replace curly quotes", GeneralTools::replaceCurlyQuotes, true); - private Action showJavadocsAction = ActionTools.createAction(() -> JavadocViewer.getInstance().getStage().show(), "Show Javadocs"); + private final Action showJavadocsAction; protected Action runScriptAction; protected Action runSelectedAction; @@ -417,6 +417,8 @@ public DefaultScriptEditor(final QuPathGUI qupath) { String style = fontSize.get(); paneCode.setStyle(style); paneConsole.setStyle(style); + + showJavadocsAction = ActionTools.createAction(new JavadocViewer(qupath.getStage()), "Show Javadocs"); } private void setToggle(ScriptLanguage language) { From e7874f4a9ca4578e248aa7a1746307090d338f51 Mon Sep 17 00:00:00 2001 From: Pete Date: Thu, 9 May 2024 17:29:58 +0100 Subject: [PATCH 060/866] Faster ContourTracing Alternative approach that relies more heavily on JTS and Polygonize. --- .../lib/analysis/images/ContourTracing.java | 220 +++++++++++++++++- 1 file changed, 217 insertions(+), 3 deletions(-) diff --git a/qupath-core/src/main/java/qupath/lib/analysis/images/ContourTracing.java b/qupath-core/src/main/java/qupath/lib/analysis/images/ContourTracing.java index e27d7188c..6cd20f1e1 100644 --- a/qupath-core/src/main/java/qupath/lib/analysis/images/ContourTracing.java +++ b/qupath-core/src/main/java/qupath/lib/analysis/images/ContourTracing.java @@ -36,6 +36,7 @@ import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.SequencedCollection; import java.util.TreeMap; import java.util.TreeSet; import java.util.concurrent.ExecutionException; @@ -52,11 +53,14 @@ import javax.imageio.ImageIO; import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.CoordinateSequence; +import org.locationtech.jts.geom.Coordinates; import org.locationtech.jts.geom.Envelope; import org.locationtech.jts.geom.Geometry; import org.locationtech.jts.geom.GeometryFactory; import org.locationtech.jts.geom.LineString; import org.locationtech.jts.geom.Polygon; +import org.locationtech.jts.geom.impl.CoordinateArraySequence; import org.locationtech.jts.geom.util.AffineTransformation; import org.locationtech.jts.geom.util.PolygonExtracter; import org.locationtech.jts.index.quadtree.Quadtree; @@ -1269,6 +1273,205 @@ private static boolean selected(double v, double min, double max) { * @return */ private static Geometry traceGeometry(SimpleImage image, double min, double max, double xOffset, double yOffset) { +// if (4 > 2) +// return traceGeometryBackup(image, min, max, xOffset, yOffset); + + var factory = GeometryTools.getDefaultFactory(); + int w = image.getWidth(); + int h = image.getHeight(); + + List lines = new ArrayList<>(); + Coordinate lastHorizontalEdgeCoord = null; + Coordinate[] lastVerticalEdgeCoords = new Coordinate[w+1]; + for (int y = 0; y <= h; y++) { + for (int x = 0; x <= w; x++) { + boolean isOn = inRange(image, x, y, min, max); + boolean onHorizontalEdge = isOn != inRange(image, x, y-1, min, max); + boolean onVerticalEdge = isOn != inRange(image, x-1, y, min, max); + // Check if on a horizontal edge with the previous row + if (onHorizontalEdge) { + var nextEdgeCoord = new Coordinate(xOffset + x, yOffset + y); + if (lastHorizontalEdgeCoord != null) { + lines.add(factory.createLineString(createCoordinateSequence(lastHorizontalEdgeCoord, nextEdgeCoord))); + } + lastHorizontalEdgeCoord = nextEdgeCoord; + } else { + if (lastHorizontalEdgeCoord != null) { + var nextEdgeCoord = new Coordinate(xOffset + x, yOffset + y); + lines.add(factory.createLineString(createCoordinateSequence(lastHorizontalEdgeCoord, nextEdgeCoord))); + lastHorizontalEdgeCoord = null; + } + } + // Check if on a vertical edge with the previous column + var lastVerticalEdgeCoord = lastVerticalEdgeCoords[x]; + if (onVerticalEdge) { + var nextEdgeCoord = new Coordinate(xOffset + x, yOffset + y); + if (lastVerticalEdgeCoord != null) { + lines.add(factory.createLineString(createCoordinateSequence(lastVerticalEdgeCoord, nextEdgeCoord))); + } + lastVerticalEdgeCoords[x] = nextEdgeCoord; + } else { + if (lastVerticalEdgeCoord != null) { + var nextEdgeCoord = new Coordinate(xOffset + x, yOffset + y); + lines.add(factory.createLineString(createCoordinateSequence(lastVerticalEdgeCoord, nextEdgeCoord))); + lastVerticalEdgeCoords[x] = null; + } + } + } + } + +// List lines = new ArrayList<>(); +// Coordinate lastEdgeCoord = null; +// for (int y = 0; y <= h; y++) { +// for (int x = 0; x <= w; x++) { +// // Check if on a horizontal edge with the previous row +// boolean onEdge = inRange(image, x, y-1, min, max) != inRange(image, x, y, min, max); +// if (onEdge) { +// var nextEdgeCoord = new Coordinate(xOffset + x, yOffset + y); +// if (lastEdgeCoord != null) { +// lines.add(factory.createLineString(createCoordinateSequence(lastEdgeCoord, nextEdgeCoord))); +// } +// lastEdgeCoord = nextEdgeCoord; +// } else { +// if (lastEdgeCoord != null) { +// var nextEdgeCoord = new Coordinate(xOffset + x, yOffset + y); +// lines.add(factory.createLineString(createCoordinateSequence(lastEdgeCoord, nextEdgeCoord))); +// lastEdgeCoord = null; +// } +// } +// } +// } +// +// for (int x = 0; x <= w; x++) { +// for (int y = 0; y <= h; y++) { +// // Check if on a vertical edge with the previous column +// boolean onEdge = inRange(image, x-1, y, min, max) != inRange(image, x, y, min, max); +// if (onEdge) { +// var nextEdgeCoord = new Coordinate(xOffset + x, yOffset + y); +// if (lastEdgeCoord != null) { +// lines.add(factory.createLineString(createCoordinateSequence(lastEdgeCoord, nextEdgeCoord))); +// } +// lastEdgeCoord = nextEdgeCoord; +// } else { +// if (lastEdgeCoord != null) { +// var nextEdgeCoord = new Coordinate(xOffset + x, yOffset + y); +// lines.add(factory.createLineString(createCoordinateSequence(lastEdgeCoord, nextEdgeCoord))); +// lastEdgeCoord = null; +// } +// } +// } +// } + + var polygonizer = new Polygonizer(true); + polygonizer.add(lines); + var myResult = polygonizer.getGeometry(); +// var legacyResult = traceGeometryBackup(image, min, max, xOffset, yOffset); +// logger.info("Mine: {}, Legacy: {}", myResult, legacyResult); + return myResult; + } + +// private static CoordinateSequence createCoordinateSequence(Coordinate c1, Coordinate c2) { +// return new CoordinatePairSequence(c1, c2); +// } + + private static CoordinateSequence createCoordinateSequence(Coordinate... coords) { + return new CoordinateArraySequence(coords, 3, 0); + } + + private static class CoordinatePairSequence implements CoordinateSequence { + + private final Coordinate c1, c2; + private final int dimension; + + private CoordinatePairSequence(Coordinate c1, Coordinate c2) { + this.c1 = c1; + this.c2 = c2; + this.dimension = 3; + } + + @Override + public int getDimension() { + return dimension; + } + + @Override + public Coordinate getCoordinate(int i) { + if (i == 0) + return c1; + else if (i == 1) + return c2; + else + throw new IllegalArgumentException("No coordinate for index " + i); + } + + @Override + public Coordinate getCoordinateCopy(int i) { + return getCoordinate(i).copy(); + } + + @Override + public void getCoordinate(int index, Coordinate coord) { + coord.setCoordinate(getCoordinate(index)); + } + + @Override + public double getX(int index) { + return getOrdinate(index, Coordinate.X); + } + + @Override + public double getY(int index) { + return getOrdinate(index, Coordinate.Y); + } + + @Override + public double getOrdinate(int index, int ordinateIndex) { + return getCoordinate(index).getOrdinate(ordinateIndex); + } + + @Override + public int size() { + return 2; + } + + @Override + public void setOrdinate(int index, int ordinateIndex, double value) { + throw new UnsupportedOperationException(); + } + + @Override + public Coordinate[] toCoordinateArray() { + return new Coordinate[] { c1, c2 }; + } + + @Override + public Envelope expandEnvelope(Envelope env) { + env.expandToInclude(c1); + env.expandToInclude(c2); + return env; + } + + @Override + public Object clone() { + return copy(); + } + + @Override + public CoordinateSequence copy() { + return new CoordinatePairSequence(c1.copy(), c2.copy()); + } + } + + + private static boolean inRange(SimpleImage image, int x, int y, double min, double max) { + if (x < 0 || x >= image.getWidth() || y < 0 || y >= image.getHeight()) + return false; + double val = image.getValue(x, y); + return val >= min && val <= max; + } + + + private static Geometry traceGeometryBackup(SimpleImage image, double min, double max, double xOffset, double yOffset) { int w = image.getWidth(); int h = image.getHeight(); @@ -1536,9 +1739,18 @@ private void addOutline(Outline outline, boolean isHole) { public Geometry getFinalGeometry() { if (lines.isEmpty()) return null;//factory.createEmpty(2); + long a = System.nanoTime(); var geomTemp = factory.buildGeometry(lines).union(); + long b = System.nanoTime(); polygonizer.add(geomTemp); - return polygonizer.getGeometry(); + long c = System.nanoTime(); + var result = polygonizer.getGeometry(); + long d = System.nanoTime(); + long ba = b - a; + long cb = c - b; + long dc = d - c; +// logger.info("Union: {} ms, Polygonizer: {} ms, Result: {} ms", ba / 1e6, cb / 1e6, dc / 1e6); + return result; } } @@ -1548,7 +1760,7 @@ public Geometry getFinalGeometry() { private static class Outline { - private Deque coords = new ArrayDeque<>(); + private SequencedCollection coords; private double xOffset, yOffset; @@ -1564,6 +1776,7 @@ private static class Outline { public Outline(double xOffset, double yOffset) { this.xOffset = xOffset; this.yOffset = yOffset; + coords = new ArrayList<>(); } public void append(int x, int y) { @@ -1590,7 +1803,8 @@ public void prepend(Coordinate c) { } public void prepend(Outline outline) { - outline.coords.descendingIterator().forEachRemaining(c -> prepend(c)); + outline.coords.reversed().forEach(c -> prepend(c)); +// outline.coords.descendingIterator().forEachRemaining(c -> prepend(c)); // Update the coordinate array for the other - since they are now part of the same outline outline.coords = coords; } From a9e4c5c05ac9dae267162705b465b50e307a3ba4 Mon Sep 17 00:00:00 2001 From: Pete Date: Thu, 9 May 2024 21:36:48 +0100 Subject: [PATCH 061/866] Improve ContourTracing performance This makes `ContourTracing` much faster in some circumstances - especially when labeled images were involved. There are two main differences: 1. Previously, the code would tend to loop through all pixels to generate the contour for each label; now it generates bounding boxes in a single loop, and then iterates only through the relevant bounding box for each label (when using `createROIs`) 2. The tracing algorithm has been replaced by a reliance on JTS's `Polygonizer` class; this was already used before, but now we rely on it more heavily. The second of these makes this *slightly* risky - I encountered exceptions and infinite loops along the way, so this needs to be tested very carefully. --- .../lib/analysis/images/ContourTracing.java | 764 ++++-------------- .../java/qupath/lib/roi/GeometryTools.java | 4 +- 2 files changed, 140 insertions(+), 628 deletions(-) diff --git a/qupath-core/src/main/java/qupath/lib/analysis/images/ContourTracing.java b/qupath-core/src/main/java/qupath/lib/analysis/images/ContourTracing.java index 6cd20f1e1..317b0a3a0 100644 --- a/qupath-core/src/main/java/qupath/lib/analysis/images/ContourTracing.java +++ b/qupath-core/src/main/java/qupath/lib/analysis/images/ContourTracing.java @@ -2,7 +2,7 @@ * #%L * This file is part of QuPath. * %% - * Copyright (C) 2020 QuPath developers, The University of Edinburgh + * Copyright (C) 2020-2024 QuPath developers, The University of Edinburgh * %% * QuPath is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as @@ -24,19 +24,18 @@ import java.awt.image.BufferedImage; import java.awt.image.Raster; +import java.awt.image.WritableRaster; import java.io.IOException; import java.nio.file.Path; -import java.util.ArrayDeque; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collection; import java.util.Collections; -import java.util.Deque; +import java.util.HashMap; import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; -import java.util.Objects; -import java.util.SequencedCollection; import java.util.TreeMap; import java.util.TreeSet; import java.util.concurrent.ExecutionException; @@ -54,12 +53,12 @@ import org.locationtech.jts.geom.Coordinate; import org.locationtech.jts.geom.CoordinateSequence; -import org.locationtech.jts.geom.Coordinates; +import org.locationtech.jts.geom.CoordinateXY; import org.locationtech.jts.geom.Envelope; import org.locationtech.jts.geom.Geometry; -import org.locationtech.jts.geom.GeometryFactory; import org.locationtech.jts.geom.LineString; import org.locationtech.jts.geom.Polygon; +import org.locationtech.jts.geom.PrecisionModel; import org.locationtech.jts.geom.impl.CoordinateArraySequence; import org.locationtech.jts.geom.util.AffineTransformation; import org.locationtech.jts.geom.util.PolygonExtracter; @@ -603,16 +602,40 @@ public static Map createROIs(SimpleImage image, RegionRequest regio maxLabel = (int)maxValue; } // We don't want to search for all possible labels, since they might not be present in the image - // Therefore we loop through pixels & search only for labels that haven't previously been handled + // Therefore we loop through pixels & first find all unique labels and their bounding boxes, then + // trace the contours for each label Map rois = new TreeMap<>(); if (maxLabel > minLabel) { - float lastLabel = Float.NaN; - for (float p : pixels) { - if (p >= minLabel && p <= maxLabel && p != lastLabel && !rois.containsKey(p)) { - var roi = createTracedROI(image, p, p, region); - if (roi != null && !roi.isEmpty()) - rois.put(p, roi); - lastLabel = p; + var envelopes = new HashMap(); + for (int y = 0; y < image.getHeight(); y++) { + for (int x = 0; x < image.getWidth(); x++) { + float val = image.getValue(x, y); + if (val >= minLabel && val <= maxLabel) { + envelopes.computeIfAbsent(val, k -> new Envelope()).expandToInclude(x, y); + } + } + } + double xOffset = 0; + double yOffset = 0; + if (region != null && region.getDownsample() == 1) { + xOffset = region.getX(); + yOffset = region.getY(); + } + for (var entry : envelopes.entrySet()) { + var val = entry.getKey(); + var envelope = entry.getValue(); + var geom = traceGeometry(image, val, val, xOffset, yOffset, envelope); + if (geom != null && !geom.isEmpty()) { + // Handle rescaling if needed + if (region != null && region.getDownsample() != 1 && geom != null) { + double scale = region.getDownsample(); + var transform = AffineTransformation.scaleInstance(scale, scale); + transform = transform.translate(region.getX(), region.getY()); + if (!transform.isIdentity()) + geom = transform.transform(geom); + } + var roi = GeometryTools.geometryToROI(geom, region == null ? ImagePlane.getDefaultPlane() : region.getImagePlane()); + rois.put(val, roi); } } } else { @@ -669,11 +692,13 @@ public static ROI createTracedROI(SimpleImage image, double minThresholdInclusiv * @param maxThresholdInclusive * @param band * @param request + * @param envelope * @return */ - private static Geometry createTracedGeometry(Raster raster, double minThresholdInclusive, double maxThresholdInclusive, int band, TileRequest request) { + private static Geometry createTracedGeometry(Raster raster, double minThresholdInclusive, double maxThresholdInclusive, + int band, TileRequest request, Envelope envelope) { var image = extractBand(raster, band); - return createTracedGeometry(image, minThresholdInclusive, maxThresholdInclusive, request); + return createTracedGeometry(image, minThresholdInclusive, maxThresholdInclusive, request, envelope); } @@ -686,9 +711,10 @@ private static Geometry createTracedGeometry(Raster raster, double minThresholdI * @param minThresholdInclusive * @param maxThresholdInclusive * @param tile + * @param envelope * @return */ - private static Geometry createTracedGeometry(SimpleImage image, double minThresholdInclusive, double maxThresholdInclusive, TileRequest tile) { + private static Geometry createTracedGeometry(SimpleImage image, double minThresholdInclusive, double maxThresholdInclusive, TileRequest tile, Envelope envelope) { // If we are translating but not rescaling, we can do this during tracing double xOffset = 0; @@ -698,7 +724,7 @@ private static Geometry createTracedGeometry(SimpleImage image, double minThresh yOffset = tile.getTileY() * tile.getDownsample(); } - var geom = traceGeometry(image, minThresholdInclusive, maxThresholdInclusive, xOffset, yOffset); + var geom = traceGeometry(image, minThresholdInclusive, maxThresholdInclusive, xOffset, yOffset, envelope); // Handle rescaling if needed if (tile != null && tile.getDownsample() != 1 && geom != null) { @@ -732,7 +758,7 @@ public static Geometry createTracedGeometry(SimpleImage image, double minThresho yOffset = request.getY(); } - var geom = traceGeometry(image, minThresholdInclusive, maxThresholdInclusive, xOffset, yOffset); + var geom = traceGeometry(image, minThresholdInclusive, maxThresholdInclusive, xOffset, yOffset, null); // Handle rescaling if needed if (request != null && request.getDownsample() != 1 && geom != null) { @@ -866,60 +892,6 @@ public String toString() { } - - // Beginnings of a builder class (incomplete and unused) -// public static Tracer createTracer(ImageServer server) { -// return new Tracer(server); -// } -// -// public static class Tracer { -// -// private ImageServer server; -// private List thresholds = new ArrayList<>(); -// private RegionRequest region; -// -// private Tracer(ImageServer server) { -// this.server = server; -// } -// -// public Tracer channel(int channel, float minThreshold, float maxThreshold) { -// return channels(ChannelThreshold.create(channel, minThreshold, maxThreshold)); -// } -// -// public Tracer channels(ChannelThreshold... thresholds) { -// for (var c : thresholds) -// this.thresholds.add(c); -// return this; -// } -// -// public Tracer downsample(double downsample) { -// if (region == null) -// region = RegionRequest.createInstance(server, downsample); -// else -// region = region.updateDownsample(downsample); -// return this; -// } -// -// public Tracer region(RegionRequest region) { -// this.region = region; -// return this; -// } -// -// public Geometry traceGeometry(Geometry clipArea) { -// if (thresholds.isEmpty()) -// throw new IllegalArgumentException("No thresholds have been specified!"); -// var threshold = thresholds.get(0); -// return ContourTracing.traceGeometry(server, region, clipArea, threshold.channel, threshold.minThreshold, threshold.maxThreshold); -// } -// -// public Map traceAllGeometries(Geometry clipArea) { -// if (thresholds.isEmpty()) -// throw new IllegalArgumentException("No thresholds have been specified!"); -// return ContourTracing.traceGeometries(server, region, clipArea, thresholds.toArray(ChannelThreshold[]::new)); -// } -// -// } - /** * Trace a geometry from a single channel of a single image. @@ -1164,7 +1136,7 @@ private static List traceGeometriesImpl(ImageServer 1) || channelType == ImageServerMetadata.ChannelType.CLASSIFICATION; @@ -1198,7 +1170,7 @@ private static List traceGeometriesImpl(ImageServer g.intersection(clipArea)); @@ -1213,9 +1185,17 @@ private static List traceGeometriesImpl(ImageServer envelopes = new HashMap<>(); + if (thresholds.length > -1) { + logger.info("Populating envelopes!"); + populateEnvelopes(raster, envelopes, thresholds); + } + for (var threshold : thresholds) { Geometry geometry = ContourTracing.createTracedGeometry( - raster, threshold.getMinThreshold(), threshold.getMaxThreshold(), threshold.getChannel(), tile); + raster, threshold.getMinThreshold(), threshold.getMaxThreshold(), threshold.getChannel(), tile, envelopes.getOrDefault(threshold, null)); if (geometry != null) { if (clipArea != null) { geometry = GeometryTools.attemptOperation(geometry, g -> g.intersection(clipArea)); @@ -1231,6 +1211,45 @@ private static List traceGeometriesImpl(ImageServer envelopes, ChannelThreshold... thresholds) { + var groups = Arrays.stream(thresholds).collect(Collectors.groupingBy(ChannelThreshold::getChannel)); + for (var entry : groups.entrySet()) { + int channel = entry.getKey().intValue(); + var channelThresholds = entry.getValue(); + var image = extractBand(raster, channel); + populateEnvelopes(image, envelopes, channelThresholds.toArray(ChannelThreshold[]::new)); + } + } + + /** + * Populate an existing map of envelopes with the bounding boxes of pixels that fall within the specified thresholds. + * @param image + * @param envelopes + * @param thresholds + */ + private static void populateEnvelopes(SimpleImage image, Map envelopes, ChannelThreshold... thresholds) { + int w = image.getWidth(); + int h = image.getHeight(); + for (var t : thresholds) { + envelopes.computeIfAbsent(t, k -> new Envelope()); + } + for (int y = 0; y < h; y++) { + for (int x = 0; x < w; x++) { + float val = image.getValue(x, y); + for (var t : thresholds) { + if (selected(val, t.getMinThreshold(), t.getMaxThreshold())) + envelopes.get(t).expandToInclude(x, y); + } + } + } + } /** @@ -1258,210 +1277,94 @@ private static boolean selected(double v, double min, double max) { /** - * This is adapted from ImageJ's ThresholdToSelection.java (public domain) written by Johannes E. Schindelin - * based on a proposal by Tom Larkworthy. - *

- * See https://github.com/imagej/imagej1/blob/573ab799ae8deb0f4feb79724a5a6f82f60cd2d6/ij/plugin/filter/ThresholdToSelection.java - *

- * The code has been substantially rewritten to enable more efficient use within QuPath and to use Java Topology Suite. + * This was rewritten for QuPath v0.6.0 to use JTS Polygonizer instead of the previous approach. + * It should be considerably faster. * - * @param image - * @param min - * @param max - * @param xOffset - * @param yOffset + * @param image the image containing the contour + * @param min the minimum value to consider as part of the contour (inclusive) + * @param max the maximum value to consider as part of the contour (inclusive) + * @param xOffset the x offset to add to the contour + * @param yOffset the y offset to add to the contour + * @param envelope optional bounding box, in the image space, to restrict the contour search (may be null). + * This is useful to avoid searching the entire image when only a small region is needed. * @return */ - private static Geometry traceGeometry(SimpleImage image, double min, double max, double xOffset, double yOffset) { -// if (4 > 2) -// return traceGeometryBackup(image, min, max, xOffset, yOffset); + private static Geometry traceGeometry(SimpleImage image, double min, double max, double xOffset, double yOffset, Envelope envelope) { var factory = GeometryTools.getDefaultFactory(); - int w = image.getWidth(); - int h = image.getHeight(); + var pm = factory.getPrecisionModel(); + + int xStart = 0; + int yStart = 0; + int xEnd = image.getWidth(); + int yEnd = image.getHeight(); + // Clip searched pixels using the envelope if provided + if (envelope != null) { + xStart = Math.max(xStart, (int)Math.floor(envelope.getMinX()-1)); + yStart = Math.max(yStart, (int)Math.floor(envelope.getMinY()-1)); + xEnd = Math.min(xEnd, (int)Math.ceil(envelope.getMaxX())+1); + yEnd = Math.min(yEnd, (int)Math.ceil(envelope.getMaxY())+1); + } List lines = new ArrayList<>(); Coordinate lastHorizontalEdgeCoord = null; - Coordinate[] lastVerticalEdgeCoords = new Coordinate[w+1]; - for (int y = 0; y <= h; y++) { - for (int x = 0; x <= w; x++) { + Coordinate[] lastVerticalEdgeCoords = new Coordinate[xEnd-xStart+1]; + for (int y = yStart; y <= yEnd; y++) { + for (int x = xStart; x <= xEnd; x++) { boolean isOn = inRange(image, x, y, min, max); boolean onHorizontalEdge = isOn != inRange(image, x, y-1, min, max); boolean onVerticalEdge = isOn != inRange(image, x-1, y, min, max); // Check if on a horizontal edge with the previous row if (onHorizontalEdge) { - var nextEdgeCoord = new Coordinate(xOffset + x, yOffset + y); + var nextEdgeCoord = createCoordinate(pm, xOffset + x, yOffset + y); if (lastHorizontalEdgeCoord != null) { lines.add(factory.createLineString(createCoordinateSequence(lastHorizontalEdgeCoord, nextEdgeCoord))); } lastHorizontalEdgeCoord = nextEdgeCoord; } else { if (lastHorizontalEdgeCoord != null) { - var nextEdgeCoord = new Coordinate(xOffset + x, yOffset + y); + var nextEdgeCoord = createCoordinate(pm, xOffset + x, yOffset + y); lines.add(factory.createLineString(createCoordinateSequence(lastHorizontalEdgeCoord, nextEdgeCoord))); lastHorizontalEdgeCoord = null; } } // Check if on a vertical edge with the previous column - var lastVerticalEdgeCoord = lastVerticalEdgeCoords[x]; + var lastVerticalEdgeCoord = lastVerticalEdgeCoords[x - xStart]; if (onVerticalEdge) { - var nextEdgeCoord = new Coordinate(xOffset + x, yOffset + y); + var nextEdgeCoord = createCoordinate(pm, xOffset + x, yOffset + y); if (lastVerticalEdgeCoord != null) { lines.add(factory.createLineString(createCoordinateSequence(lastVerticalEdgeCoord, nextEdgeCoord))); } - lastVerticalEdgeCoords[x] = nextEdgeCoord; + lastVerticalEdgeCoords[x - xStart] = nextEdgeCoord; } else { if (lastVerticalEdgeCoord != null) { - var nextEdgeCoord = new Coordinate(xOffset + x, yOffset + y); + var nextEdgeCoord = createCoordinate(pm, xOffset + x, yOffset + y); lines.add(factory.createLineString(createCoordinateSequence(lastVerticalEdgeCoord, nextEdgeCoord))); - lastVerticalEdgeCoords[x] = null; + lastVerticalEdgeCoords[x - xStart] = null; } } } } -// List lines = new ArrayList<>(); -// Coordinate lastEdgeCoord = null; -// for (int y = 0; y <= h; y++) { -// for (int x = 0; x <= w; x++) { -// // Check if on a horizontal edge with the previous row -// boolean onEdge = inRange(image, x, y-1, min, max) != inRange(image, x, y, min, max); -// if (onEdge) { -// var nextEdgeCoord = new Coordinate(xOffset + x, yOffset + y); -// if (lastEdgeCoord != null) { -// lines.add(factory.createLineString(createCoordinateSequence(lastEdgeCoord, nextEdgeCoord))); -// } -// lastEdgeCoord = nextEdgeCoord; -// } else { -// if (lastEdgeCoord != null) { -// var nextEdgeCoord = new Coordinate(xOffset + x, yOffset + y); -// lines.add(factory.createLineString(createCoordinateSequence(lastEdgeCoord, nextEdgeCoord))); -// lastEdgeCoord = null; -// } -// } -// } -// } -// -// for (int x = 0; x <= w; x++) { -// for (int y = 0; y <= h; y++) { -// // Check if on a vertical edge with the previous column -// boolean onEdge = inRange(image, x-1, y, min, max) != inRange(image, x, y, min, max); -// if (onEdge) { -// var nextEdgeCoord = new Coordinate(xOffset + x, yOffset + y); -// if (lastEdgeCoord != null) { -// lines.add(factory.createLineString(createCoordinateSequence(lastEdgeCoord, nextEdgeCoord))); -// } -// lastEdgeCoord = nextEdgeCoord; -// } else { -// if (lastEdgeCoord != null) { -// var nextEdgeCoord = new Coordinate(xOffset + x, yOffset + y); -// lines.add(factory.createLineString(createCoordinateSequence(lastEdgeCoord, nextEdgeCoord))); -// lastEdgeCoord = null; -// } -// } -// } -// } - + // This passes the test and is fast... but beware https://github.com/locationtech/jts/issues/874 var polygonizer = new Polygonizer(true); polygonizer.add(lines); - var myResult = polygonizer.getGeometry(); -// var legacyResult = traceGeometryBackup(image, min, max, xOffset, yOffset); -// logger.info("Mine: {}, Legacy: {}", myResult, legacyResult); - return myResult; + var originalPolygon = polygonizer.getGeometry(); + return originalPolygon; } -// private static CoordinateSequence createCoordinateSequence(Coordinate c1, Coordinate c2) { -// return new CoordinatePairSequence(c1, c2); -// } + private static Coordinate createCoordinate(PrecisionModel pm, double x, double y) { + return new CoordinateXY( + pm.makePrecise(x), + pm.makePrecise(y)); + } private static CoordinateSequence createCoordinateSequence(Coordinate... coords) { + for (var c : coords) + GeometryTools.getDefaultFactory().getPrecisionModel().makePrecise(c); return new CoordinateArraySequence(coords, 3, 0); } - private static class CoordinatePairSequence implements CoordinateSequence { - - private final Coordinate c1, c2; - private final int dimension; - - private CoordinatePairSequence(Coordinate c1, Coordinate c2) { - this.c1 = c1; - this.c2 = c2; - this.dimension = 3; - } - - @Override - public int getDimension() { - return dimension; - } - - @Override - public Coordinate getCoordinate(int i) { - if (i == 0) - return c1; - else if (i == 1) - return c2; - else - throw new IllegalArgumentException("No coordinate for index " + i); - } - - @Override - public Coordinate getCoordinateCopy(int i) { - return getCoordinate(i).copy(); - } - - @Override - public void getCoordinate(int index, Coordinate coord) { - coord.setCoordinate(getCoordinate(index)); - } - - @Override - public double getX(int index) { - return getOrdinate(index, Coordinate.X); - } - - @Override - public double getY(int index) { - return getOrdinate(index, Coordinate.Y); - } - - @Override - public double getOrdinate(int index, int ordinateIndex) { - return getCoordinate(index).getOrdinate(ordinateIndex); - } - - @Override - public int size() { - return 2; - } - - @Override - public void setOrdinate(int index, int ordinateIndex, double value) { - throw new UnsupportedOperationException(); - } - - @Override - public Coordinate[] toCoordinateArray() { - return new Coordinate[] { c1, c2 }; - } - - @Override - public Envelope expandEnvelope(Envelope env) { - env.expandToInclude(c1); - env.expandToInclude(c2); - return env; - } - - @Override - public Object clone() { - return copy(); - } - - @Override - public CoordinateSequence copy() { - return new CoordinatePairSequence(c1.copy(), c2.copy()); - } - } - private static boolean inRange(SimpleImage image, int x, int y, double min, double max) { if (x < 0 || x >= image.getWidth() || y < 0 || y >= image.getHeight()) @@ -1470,393 +1373,4 @@ private static boolean inRange(SimpleImage image, int x, int y, double min, doub return val >= min && val <= max; } - - private static Geometry traceGeometryBackup(SimpleImage image, double min, double max, double xOffset, double yOffset) { - - int w = image.getWidth(); - int h = image.getHeight(); - - boolean[] prevRow, thisRow; - var manager = new GeometryManager(GeometryTools.getDefaultFactory()); - - // Cache for the current and previous thresholded rows - prevRow = new boolean[w + 2]; - thisRow = new boolean[w + 2]; - - // Current outlines - Outline[] movingDown = new Outline[w + 1]; - Outline movingRight = null; - - int pixelCount = 0; - - for (int y = 0; y <= h; y++) { - - // Swap this and previous rows (this row data will be overwritten as we go) - boolean[] tempSwap = prevRow; - prevRow = thisRow; - thisRow = tempSwap; - -// thisRow[1] = y < h ? selected(raster, 0, y, min, max) : false; - thisRow[1] = y < h ? selected(image.getValue(0, y), min, max) : false; - - for (int x = 0; x <= w; x++) { - - int left = x; - int center = x + 1; - int right = x + 2; - - if (y < h && x < w - 1) - thisRow[right] = selected(image.getValue(x+1, y), min, max); //we need to read one pixel ahead -// thisRow[right] = selected(raster, center, y, min, max); //we need to read one pixel ahead - else if (x < w - 1) - thisRow[right] = false; - - if (thisRow[center]) - pixelCount++; - - /* - * Pixels are considered in terms of a 2x2 square. - * ----0---- - * | A | B | - * 0---X==== - * | C | D | - * ----===== - * - * The current focus is on D, which is considered the 'center' (since subsequent - * pixels matter too for the pattern, but we don't need them during this iteration). - * - * In each case, the question is whether or not an outline will be created, - * or moved for a location 0 to location X - possibly involving merges or completion of - * an outline. - * - * Note that outlines are always drawn so that the 'on' pixels are on the left, - * from the point of view of the directed line. - * Therefore shells are anticlockwise whereas holes are clockwise. - */ - - // Extract the local 2x2 binary pattern - // This represented by a value between 0 and 15, where bits indicate if a pixel is selected or not - int pattern = (prevRow[left] ? 8 : 0) - + (prevRow[center] ? 4 : 0) - + (thisRow[left] ? 2 : 0) - + (thisRow[center] ? 1 : 0); - - - switch (pattern) { - case 0: - // Nothing selected -// assert movingDown[x] == null; -// assert movingRight == null; - break; - case 1: - // Selected D -// assert movingDown[x] == null; -// assert movingRight == null; - // Create new shell - movingRight = new Outline(xOffset, yOffset); - movingRight.append(x, y); - movingDown[x] = movingRight; - break; - case 2: - // Selected C -// assert movingDown[x] == null; - movingRight.prepend(x, y); - movingDown[x] = movingRight; - movingRight = null; - break; - case 3: - // Selected C, D -// assert movingDown[x] == null; -// assert movingRight != null; - break; - case 4: - // Selected B -// assert movingRight == null; - movingDown[x].append(x, y); - movingRight = movingDown[x]; - movingDown[x] = null; - break; - case 5: - // Selected B, D -// assert movingRight == null; -// assert movingDown[x] != null; - break; - case 6: - // Selected B, C -// assert movingDown[x] != null; -// assert movingRight != null; - movingRight.prepend(x, y); - if (Objects.equals(movingRight, movingDown[x])) { - // Hole completed! - manager.addHole(movingRight); - movingRight = new Outline(xOffset, yOffset); - movingRight.append(x, y); - movingDown[x] = movingRight; - } else { - movingDown[x].append(x, y); - var temp = movingRight; - movingRight = movingDown[x]; - movingDown[x] = temp; - } - break; - case 7: - // Selected B, C, D -// assert movingDown[x] != null; -// assert movingRight != null; - movingDown[x].append(x, y); - if (Objects.equals(movingRight, movingDown[x])) { - // Hole completed! - manager.addHole(movingRight); - } else { - movingRight.prepend(movingDown[x]); - replace(movingDown, movingDown[x], movingRight); - } - movingRight = null; - movingDown[x] = null; - break; - case 8: - // Selected A -// assert movingDown[x] != null; -// assert movingRight != null; - movingRight.append(x, y); - if (Objects.equals(movingRight, movingDown[x])) { - // Shell completed! - manager.addShell(movingRight); - } else { - movingDown[x].prepend(movingRight); - replace(movingDown, movingRight, movingDown[x]); - } - movingRight = null; - movingDown[x] = null; - break; - case 9: - // Selected A, D -// assert movingDown[x] != null; -// assert movingRight != null; - movingRight.append(x, y); - if (Objects.equals(movingRight, movingDown[x])) { - // Shell completed! - manager.addShell(movingRight); - movingRight = new Outline(xOffset, yOffset); - movingRight.append(x, y); - movingDown[x] = movingRight; - } else { - movingDown[x].prepend(x, y); - var temp = movingRight; - movingRight = movingDown[x]; - movingDown[x] = temp; - } - break; - case 10: - // Selected A, C -// assert movingRight == null; -// assert movingDown[x] != null; - break; - case 11: - // Selected A, C, D -// assert movingRight == null; -// assert movingDown[x] != null; - movingDown[x].prepend(x, y); - movingRight = movingDown[x]; - movingDown[x] = null; - break; - case 12: - // Selected A, B -// assert movingDown[x] == null; -// assert movingRight != null; - break; - case 13: - // Selected A, B, D -// assert movingDown[x] == null; -// assert movingRight != null; - movingRight.append(x, y); - movingDown[x] = movingRight; - movingRight = null; - break; - case 14: - // Selected A, B, C -// assert movingRight == null; -// assert movingDown[x] == null; - // Create new hole - movingRight = new Outline(xOffset, yOffset); - movingRight.append(x, y); - movingDown[x] = movingRight; - break; - case 15: - // Selected A, B, C, D -// assert movingDown[x] == null; -// assert movingRight == null; - break; - } - } - } - - var geom = manager.getFinalGeometry(); - if (geom == null) - return null; - - var area = geom.getArea(); - if (pixelCount != area) { - logger.warn("Pixel count {} is not equal to geometry area {}", pixelCount, area); - } - - return geom; - - } - - - private static void replace(Outline[] outlines, Outline original, Outline replacement) { - for (int i = 0; i < outlines.length; i++) { - if (outlines[i] == original) - outlines[i] = replacement; - } - } - - - private static class GeometryManager { - - private Polygonizer polygonizer = new Polygonizer(true); - private GeometryFactory factory; - - private List lines = new ArrayList<>(); - - GeometryManager(GeometryFactory factory) { - this.factory = factory; - } - - public void addHole(Outline outline) { - addOutline(outline, true); - } - - public void addShell(Outline outline) { - addOutline(outline, false); - } - - private void addOutline(Outline outline, boolean isHole) { - lines.add(factory.createLineString(outline.getRing())); - } - - public Geometry getFinalGeometry() { - if (lines.isEmpty()) - return null;//factory.createEmpty(2); - long a = System.nanoTime(); - var geomTemp = factory.buildGeometry(lines).union(); - long b = System.nanoTime(); - polygonizer.add(geomTemp); - long c = System.nanoTime(); - var result = polygonizer.getGeometry(); - long d = System.nanoTime(); - long ba = b - a; - long cb = c - b; - long dc = d - c; -// logger.info("Union: {} ms, Polygonizer: {} ms, Result: {} ms", ba / 1e6, cb / 1e6, dc / 1e6); - return result; - } - - } - - - - - private static class Outline { - - private SequencedCollection coords; - - private double xOffset, yOffset; - - /** - * Initialize an output. Optional x and y offsets may be provided, in which case - * these will be added to coordinates. The reason for this is to help support - * working with tiled images, where the tile origin is not 0,0 but we don't want to - * have to handle this elsewhere. - * - * @param xOffset - * @param yOffset - */ - public Outline(double xOffset, double yOffset) { - this.xOffset = xOffset; - this.yOffset = yOffset; - coords = new ArrayList<>(); - } - - public void append(int x, int y) { - append(new Coordinate(xOffset + x, yOffset + y)); - } - - public void append(Coordinate c) { - // Don't add repeating coordinate - if (!coords.isEmpty() && coords.getLast().equals(c)) - return; - coords.addLast(c); - } - - - public void prepend(int x, int y) { - prepend(new Coordinate(xOffset + x, yOffset + y)); - } - - public void prepend(Coordinate c) { - // Don't add repeating coordinate - if (!coords.isEmpty() && coords.getFirst().equals(c)) - return; - coords.addFirst(c); - } - - public void prepend(Outline outline) { - outline.coords.reversed().forEach(c -> prepend(c)); -// outline.coords.descendingIterator().forEachRemaining(c -> prepend(c)); - // Update the coordinate array for the other - since they are now part of the same outline - outline.coords = coords; - } - - public Coordinate[] getRing() { - if (!coords.getFirst().equals(coords.getLast())) - coords.add(coords.getFirst()); - return coords.toArray(Coordinate[]::new); - } - - @Override - public int hashCode() { - final int prime = 31; - int result = 1; - result = prime * result + ((coords == null) ? 0 : coords.hashCode()); - long temp; - temp = Double.doubleToLongBits(xOffset); - result = prime * result + (int) (temp ^ (temp >>> 32)); - temp = Double.doubleToLongBits(yOffset); - result = prime * result + (int) (temp ^ (temp >>> 32)); - return result; - } - - @Override - public boolean equals(Object obj) { - if (this == obj) - return true; - if (obj == null) - return false; - if (getClass() != obj.getClass()) - return false; - Outline other = (Outline) obj; - if (coords == null) { - if (other.coords != null) - return false; - } else if (!coords.equals(other.coords)) - return false; - if (Double.doubleToLongBits(xOffset) != Double.doubleToLongBits(other.xOffset)) - return false; - if (Double.doubleToLongBits(yOffset) != Double.doubleToLongBits(other.yOffset)) - return false; - return true; - } - - @Override - public String toString() { - return "[" + coords.stream() - .map(c -> "(" + GeneralTools.formatNumber(c.x, 2) + ", " + GeneralTools.formatNumber(c.y, 2) + ")") - .collect(Collectors.joining(", ")) + "]"; - } - - - } - } diff --git a/qupath-core/src/main/java/qupath/lib/roi/GeometryTools.java b/qupath-core/src/main/java/qupath/lib/roi/GeometryTools.java index 37f58d7c3..22202a63b 100644 --- a/qupath-core/src/main/java/qupath/lib/roi/GeometryTools.java +++ b/qupath-core/src/main/java/qupath/lib/roi/GeometryTools.java @@ -403,10 +403,8 @@ public static Geometry ensurePolygonal(Geometry geometry) { } if (keepGeometries.isEmpty()) return geometry.getFactory().createPolygon(); - if (keepGeometries.size() < geometry.getNumGeometries()) - return geometry.getFactory().buildGeometry(keepGeometries); else - return geometry; + return geometry.getFactory().buildGeometry(keepGeometries); } /** From 874641eaca37c051e53c53f5f8c6f89d6198c4f9 Mon Sep 17 00:00:00 2001 From: Pete Date: Fri, 10 May 2024 08:42:42 +0100 Subject: [PATCH 062/866] Simplify createROIs code --- .../lib/analysis/images/ContourTracing.java | 110 ++++++++++-------- 1 file changed, 62 insertions(+), 48 deletions(-) diff --git a/qupath-core/src/main/java/qupath/lib/analysis/images/ContourTracing.java b/qupath-core/src/main/java/qupath/lib/analysis/images/ContourTracing.java index 317b0a3a0..63523fee8 100644 --- a/qupath-core/src/main/java/qupath/lib/analysis/images/ContourTracing.java +++ b/qupath-core/src/main/java/qupath/lib/analysis/images/ContourTracing.java @@ -591,58 +591,66 @@ public static Map createROIs(Raster raster, int band, RegionRequest * @return an ordered map containing all the ROIs that could be found; corresponding labels are keys in the map */ public static Map createROIs(SimpleImage image, RegionRequest region, int minLabel, int maxLabel) { - // Check how many labels are needed - float[] pixels = SimpleImages.getPixels(image, true); - if (maxLabel < minLabel) { - float maxValue = minLabel; - for (float p : pixels) { - if (p > maxValue) - maxValue = p; - } - maxLabel = (int)maxValue; - } - // We don't want to search for all possible labels, since they might not be present in the image - // Therefore we loop through pixels & first find all unique labels and their bounding boxes, then - // trace the contours for each label - Map rois = new TreeMap<>(); - if (maxLabel > minLabel) { - var envelopes = new HashMap(); + var envelopes = new HashMap(); + if (minLabel != maxLabel) { + // Check if we need to identify the max label because it hasn't been provided + boolean searchingMaxLabel = maxLabel < minLabel; + int maxLabelFound = Integer.MIN_VALUE; + // If we want ROIs for more than one label, do a first pass to find envelopes + // (If we have just one label, we can skip this) for (int y = 0; y < image.getHeight(); y++) { for (int x = 0; x < image.getWidth(); x++) { - float val = image.getValue(x, y); - if (val >= minLabel && val <= maxLabel) { - envelopes.computeIfAbsent(val, k -> new Envelope()).expandToInclude(x, y); + float val = Math.round(image.getValue(x, y)); + int label = Math.round(val); + if (val != label) + continue; + // Update our max label if required + if (label > maxLabel) { + maxLabelFound = label; + if (searchingMaxLabel) + maxLabel = maxLabelFound; } - } - } - double xOffset = 0; - double yOffset = 0; - if (region != null && region.getDownsample() == 1) { - xOffset = region.getX(); - yOffset = region.getY(); - } - for (var entry : envelopes.entrySet()) { - var val = entry.getKey(); - var envelope = entry.getValue(); - var geom = traceGeometry(image, val, val, xOffset, yOffset, envelope); - if (geom != null && !geom.isEmpty()) { - // Handle rescaling if needed - if (region != null && region.getDownsample() != 1 && geom != null) { - double scale = region.getDownsample(); - var transform = AffineTransformation.scaleInstance(scale, scale); - transform = transform.translate(region.getX(), region.getY()); - if (!transform.isIdentity()) - geom = transform.transform(geom); + // Update envelope if required + if (selected(label, minLabel, maxLabel)) { + envelopes.computeIfAbsent(label, k -> new Envelope()).expandToInclude(x, y); } - var roi = GeometryTools.geometryToROI(geom, region == null ? ImagePlane.getDefaultPlane() : region.getImagePlane()); - rois.put(val, roi); } } + // If no label exceeds the min label, return an empty map + if (maxLabelFound < minLabel) + return Collections.emptyMap(); } else { - for (int i = minLabel; i <= maxLabel; i++) { - var roi = createTracedROI(image, i, i, region); - if (roi != null && !roi.isEmpty()) - rois.put(i, roi); + // Don't bother storing an envelope here - we'll iterate the full image when tracing + // But do store the label so that we can use the map for iterating + envelopes.put((float)minLabel, null); + } + + // We don't want to search for all possible labels, since they might not be present in the image + // Therefore we loop through pixels & first find all unique labels and their bounding boxes, then + // trace the contours for each label + Map rois = new TreeMap<>(); + double xOffset = 0; + double yOffset = 0; + // If we are translating but not rescaling, we can do this during tracing + if (region != null && region.getDownsample() == 1) { + xOffset = region.getX(); + yOffset = region.getY(); + } + for (var entry : envelopes.entrySet()) { + var label = entry.getKey(); + var envelope = entry.getValue(); + var geom = traceGeometry(image, label.doubleValue(), label.doubleValue(), xOffset, yOffset, envelope); + if (geom != null && !geom.isEmpty()) { + // Handle any additional rescaling if needed + if (region != null && region.getDownsample() != 1 && geom != null) { + double scale = region.getDownsample(); + var transform = AffineTransformation.scaleInstance(scale, scale); + transform = transform.translate(region.getX(), region.getY()); + if (!transform.isIdentity()) + geom = transform.transform(geom); + } + var roi = GeometryTools.geometryToROI(geom, region == null ? ImagePlane.getDefaultPlane() : region.getImagePlane()); + rois.put(label, roi); } } return rois; @@ -1266,9 +1274,15 @@ private GeometryWrapper(Geometry geometry, int label) { } } - - - + + + private static boolean selected(int v, int min, int max) { + return v >= min && v <= max; + } + + private static boolean selected(float v, float min, float max) { + return v >= min && v <= max; + } private static boolean selected(double v, double min, double max) { return v >= min && v <= max; From b2ed729323d7de44b654b91df2bb32583f8e6003 Mon Sep 17 00:00:00 2001 From: Pete Date: Fri, 10 May 2024 09:14:51 +0100 Subject: [PATCH 063/866] Use parallel stream for label tracing --- .../lib/analysis/images/ContourTracing.java | 60 ++++++++++--------- 1 file changed, 31 insertions(+), 29 deletions(-) diff --git a/qupath-core/src/main/java/qupath/lib/analysis/images/ContourTracing.java b/qupath-core/src/main/java/qupath/lib/analysis/images/ContourTracing.java index 63523fee8..ccce194dd 100644 --- a/qupath-core/src/main/java/qupath/lib/analysis/images/ContourTracing.java +++ b/qupath-core/src/main/java/qupath/lib/analysis/images/ContourTracing.java @@ -628,33 +628,26 @@ public static Map createROIs(SimpleImage image, RegionRequest regio // We don't want to search for all possible labels, since they might not be present in the image // Therefore we loop through pixels & first find all unique labels and their bounding boxes, then // trace the contours for each label + var map = envelopes.entrySet() + .parallelStream() + .collect( + Collectors.toMap( + Map.Entry::getKey, + e -> labelToROI(image, e.getKey().doubleValue(), region, e.getValue())) + ); + + // Return a sorted map with all non-empty ROIs Map rois = new TreeMap<>(); - double xOffset = 0; - double yOffset = 0; - // If we are translating but not rescaling, we can do this during tracing - if (region != null && region.getDownsample() == 1) { - xOffset = region.getX(); - yOffset = region.getY(); - } - for (var entry : envelopes.entrySet()) { - var label = entry.getKey(); - var envelope = entry.getValue(); - var geom = traceGeometry(image, label.doubleValue(), label.doubleValue(), xOffset, yOffset, envelope); - if (geom != null && !geom.isEmpty()) { - // Handle any additional rescaling if needed - if (region != null && region.getDownsample() != 1 && geom != null) { - double scale = region.getDownsample(); - var transform = AffineTransformation.scaleInstance(scale, scale); - transform = transform.translate(region.getX(), region.getY()); - if (!transform.isIdentity()) - geom = transform.transform(geom); - } - var roi = GeometryTools.geometryToROI(geom, region == null ? ImagePlane.getDefaultPlane() : region.getImagePlane()); - rois.put(label, roi); - } + for (var entry : map.entrySet()) { + if (entry.getValue() != null && !entry.getValue().isEmpty()) + rois.put(entry.getKey(), entry.getValue()); } return rois; } + + private static ROI labelToROI(SimpleImage image, double label, RegionRequest region, Envelope envelope) { + return createTracedROI(image, label, label, region, envelope); + } /** * Create a traced ROI from a raster. @@ -685,7 +678,12 @@ public static ROI createTracedROI(Raster raster, double minThresholdInclusive, d * @see #createTracedGeometry(SimpleImage, double, double, RegionRequest) */ public static ROI createTracedROI(SimpleImage image, double minThresholdInclusive, double maxThresholdInclusive, RegionRequest request) { - var geom = createTracedGeometry(image, minThresholdInclusive, maxThresholdInclusive, request); + return createTracedROI(image, minThresholdInclusive, maxThresholdInclusive, request, null); + } + + + private static ROI createTracedROI(SimpleImage image, double minThresholdInclusive, double maxThresholdInclusive, RegionRequest request, Envelope envelope) { + var geom = createTracedGeometry(image, minThresholdInclusive, maxThresholdInclusive, request, envelope); return geom == null ? null : GeometryTools.geometryToROI(geom, request == null ? ImagePlane.getDefaultPlane() : request.getImagePlane()); } @@ -757,7 +755,11 @@ private static Geometry createTracedGeometry(SimpleImage image, double minThresh * @return a polygonal geometry created by tracing pixel values ≥ minThresholdInclusive and ≤ maxThresholdInclusive */ public static Geometry createTracedGeometry(SimpleImage image, double minThresholdInclusive, double maxThresholdInclusive, RegionRequest request) { - + return createTracedGeometry(image, minThresholdInclusive, maxThresholdInclusive, request, null); + } + + private static Geometry createTracedGeometry(SimpleImage image, double minThresholdInclusive, double maxThresholdInclusive, RegionRequest request, Envelope envelope) { + // If we are translating but not rescaling, we can do this during tracing double xOffset = 0; double yOffset = 0; @@ -765,9 +767,9 @@ public static Geometry createTracedGeometry(SimpleImage image, double minThresho xOffset = request.getX(); yOffset = request.getY(); } - - var geom = traceGeometry(image, minThresholdInclusive, maxThresholdInclusive, xOffset, yOffset, null); - + + var geom = traceGeometry(image, minThresholdInclusive, maxThresholdInclusive, xOffset, yOffset, envelope); + // Handle rescaling if needed if (request != null && request.getDownsample() != 1 && geom != null) { double scale = request.getDownsample(); @@ -776,7 +778,7 @@ public static Geometry createTracedGeometry(SimpleImage image, double minThresho if (!transform.isIdentity()) geom = transform.transform(geom); } - + return geom; } From 5463489a7c18ece599afcd46e0a89642a2314f8d Mon Sep 17 00:00:00 2001 From: Pete Date: Fri, 10 May 2024 12:34:48 +0100 Subject: [PATCH 064/866] Some reordering --- .../lib/analysis/images/ContourTracing.java | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/qupath-core/src/main/java/qupath/lib/analysis/images/ContourTracing.java b/qupath-core/src/main/java/qupath/lib/analysis/images/ContourTracing.java index ccce194dd..eaee8f4bc 100644 --- a/qupath-core/src/main/java/qupath/lib/analysis/images/ContourTracing.java +++ b/qupath-core/src/main/java/qupath/lib/analysis/images/ContourTracing.java @@ -592,12 +592,17 @@ public static Map createROIs(Raster raster, int band, RegionRequest */ public static Map createROIs(SimpleImage image, RegionRequest region, int minLabel, int maxLabel) { var envelopes = new HashMap(); - if (minLabel != maxLabel) { + if (minLabel == maxLabel) { + // Don't bother storing an envelope here - we'll iterate the full image when tracing + // But do store the label so that we can use the map for iterating + envelopes.put(minLabel, null); + } else { // Check if we need to identify the max label because it hasn't been provided boolean searchingMaxLabel = maxLabel < minLabel; int maxLabelFound = Integer.MIN_VALUE; - // If we want ROIs for more than one label, do a first pass to find envelopes - // (If we have just one label, we can skip this) + // If we want ROIs for more than one label (or an unknown number of labels, + // do a first pass to find envelopes (i.e. bounding boxes) + // so that we don't need to visit all pixels every time we trace a contour later for (int y = 0; y < image.getHeight(); y++) { for (int x = 0; x < image.getWidth(); x++) { float val = Math.round(image.getValue(x, y)); @@ -619,15 +624,9 @@ public static Map createROIs(SimpleImage image, RegionRequest regio // If no label exceeds the min label, return an empty map if (maxLabelFound < minLabel) return Collections.emptyMap(); - } else { - // Don't bother storing an envelope here - we'll iterate the full image when tracing - // But do store the label so that we can use the map for iterating - envelopes.put((float)minLabel, null); } - // We don't want to search for all possible labels, since they might not be present in the image - // Therefore we loop through pixels & first find all unique labels and their bounding boxes, then - // trace the contours for each label + // Trace contours for all requested labels var map = envelopes.entrySet() .parallelStream() .collect( From 154745a150d7aad341d856664a40504e7dd57e2d Mon Sep 17 00:00:00 2001 From: Pete Date: Fri, 10 May 2024 12:38:50 +0100 Subject: [PATCH 065/866] Ensure downsample is set to finite value This was causing trouble in https://forum.image.sc/t/how-can-display-more-than-one-images-in-qupath-viewer-using-scripting/96156/ initially because the width and height here zero, leading to an infinite downsample value... and persistent JavaFX complaints upon all interactions with the viewer. This happened when setting the viewer grid size and then *immediately* setting the image in the viewer; somehow this resulted in wrong values being calculated, and a non-invertible AffineTransform that caused trouble indefinitely. The workaround here avoids many repeated exceptions, although still results in the image being opened at full resolution rather than the expected (fitted) downsample. --- .../src/main/java/qupath/lib/gui/viewer/QuPathViewer.java | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/QuPathViewer.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/QuPathViewer.java index 4b7b83cf9..c6e6be0ce 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/QuPathViewer.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/QuPathViewer.java @@ -2474,6 +2474,13 @@ private void setDownsampleFactorImpl(double downsampleFactor, double cx, double yCenter = p2.getY() - dy; } + // Downsample might not be finite if the width and height are 0 + // (and this method has been called too early) - we need a finite + // value to avoid the UI becoming unstable + if (!Double.isFinite(downsampleFactor)) { + logger.debug("Setting non-finite downsample {} to 1.0", downsampleFactor); + downsampleFactor = 1.0; + } this.downsampleFactor.set(downsampleFactor); updateAffineTransform(); From dee807fad97c419e78bc5455591fc41069ab1e01 Mon Sep 17 00:00:00 2001 From: Leo Leplat <60394504+Rylern@users.noreply.github.com> Date: Mon, 13 May 2024 13:32:28 +0100 Subject: [PATCH 066/866] Updated javadoc viewer version --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1cb9a44c5..ddbc43e18 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -38,7 +38,7 @@ junit = "5.9.2" logback = "1.3.11" logviewer = "0.2.0-SNAPSHOT" -javadocviewer = "0.1.0-SNAPSHOT" +javadocviewer = "0.1.1-SNAPSHOT" openslide = "4.0.0" From 6752ba54f47b1feb84f29002828e554401c04441 Mon Sep 17 00:00:00 2001 From: Alan O'Callaghan Date: Fri, 17 May 2024 15:49:26 +0100 Subject: [PATCH 067/866] Add "prompt to close" method to script editor interface --- .../src/main/java/qupath/lib/gui/QuPathGUI.java | 3 +++ .../qupath/lib/gui/scripting/DefaultScriptEditor.java | 11 ++++++++++- .../java/qupath/lib/gui/scripting/ScriptEditor.java | 7 +++++++ 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/QuPathGUI.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/QuPathGUI.java index 40425ebed..f80982e0b 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/QuPathGUI.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/QuPathGUI.java @@ -1013,6 +1013,9 @@ private void handleCloseMainStageRequest(WindowEvent e) { return; } } + + // should prompt users to save changes if desired. + scriptEditor.promptToClose(); // Warn if there is a script running if (scriptRunning.get()) { diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/scripting/DefaultScriptEditor.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/scripting/DefaultScriptEditor.java index e7b7be578..5994f7a9d 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/scripting/DefaultScriptEditor.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/scripting/DefaultScriptEditor.java @@ -511,7 +511,16 @@ public boolean supportsFile(final File file) { } return false; } - + + @Override + public boolean promptToClose() { + if (listScripts.getItems().isEmpty()) + dialog.close(); + while (promptToClose(getCurrentScriptTab())) { + continue; + } + return true; + } void maybeRefreshTab(final ScriptTab tab, boolean updateLanguage) { diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/scripting/ScriptEditor.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/scripting/ScriptEditor.java index eac12a387..aae613604 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/scripting/ScriptEditor.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/scripting/ScriptEditor.java @@ -56,5 +56,12 @@ public interface ScriptEditor { * @return */ public boolean supportsFile(File file); + + /** + * Attempt to close the editor, saving changes if requested. + * @return True if the editor can be closed without losing changes, + * unless the user consents to losing changes. + */ + public boolean promptToClose(); } From fe6382a953d69a778e37519d978b330c4f509547 Mon Sep 17 00:00:00 2001 From: Alan O'Callaghan Date: Fri, 17 May 2024 15:55:31 +0100 Subject: [PATCH 068/866] Fix prompt to close method when cancelling --- .../java/qupath/lib/gui/scripting/DefaultScriptEditor.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/scripting/DefaultScriptEditor.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/scripting/DefaultScriptEditor.java index 5994f7a9d..cacc2ff22 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/scripting/DefaultScriptEditor.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/scripting/DefaultScriptEditor.java @@ -516,10 +516,10 @@ public boolean supportsFile(final File file) { public boolean promptToClose() { if (listScripts.getItems().isEmpty()) dialog.close(); - while (promptToClose(getCurrentScriptTab())) { + var ret = true; + while (ret &= promptToClose(getCurrentScriptTab())) continue; - } - return true; + return ret; } From 53c587c5241bd79e73cf658c1dc87261f99ea448 Mon Sep 17 00:00:00 2001 From: Alan O'Callaghan Date: Fri, 17 May 2024 16:10:13 +0100 Subject: [PATCH 069/866] Fix prompt to close method when user says no --- .../qupath/lib/gui/scripting/DefaultScriptEditor.java | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/scripting/DefaultScriptEditor.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/scripting/DefaultScriptEditor.java index cacc2ff22..340f12316 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/scripting/DefaultScriptEditor.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/scripting/DefaultScriptEditor.java @@ -517,8 +517,14 @@ public boolean promptToClose() { if (listScripts.getItems().isEmpty()) dialog.close(); var ret = true; - while (ret &= promptToClose(getCurrentScriptTab())) - continue; + while (ret) { + var tab = getCurrentScriptTab(); + if (tab == null) { + break; + } + boolean bb = promptToClose(tab); + ret &= bb; + } return ret; } From 395d7c5d32657b91ca6cfa6064a4c206f9d9fcbf Mon Sep 17 00:00:00 2001 From: Alan O'Callaghan Date: Fri, 17 May 2024 16:36:40 +0100 Subject: [PATCH 070/866] Actually respect return value --- qupath-gui-fx/src/main/java/qupath/lib/gui/QuPathGUI.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/QuPathGUI.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/QuPathGUI.java index f80982e0b..4eecb2c5b 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/QuPathGUI.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/QuPathGUI.java @@ -1015,7 +1015,10 @@ private void handleCloseMainStageRequest(WindowEvent e) { } // should prompt users to save changes if desired. - scriptEditor.promptToClose(); + if (!scriptEditor.promptToClose()) { + e.consume(); + return; + } // Warn if there is a script running if (scriptRunning.get()) { From b885666b31a71a611dee54fb6ebffc2c856c8105 Mon Sep 17 00:00:00 2001 From: Alan O'Callaghan Date: Fri, 17 May 2024 16:46:12 +0100 Subject: [PATCH 071/866] Simplify code slightly --- .../java/qupath/lib/gui/scripting/DefaultScriptEditor.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/scripting/DefaultScriptEditor.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/scripting/DefaultScriptEditor.java index 340f12316..bb352865b 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/scripting/DefaultScriptEditor.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/scripting/DefaultScriptEditor.java @@ -522,8 +522,7 @@ public boolean promptToClose() { if (tab == null) { break; } - boolean bb = promptToClose(tab); - ret &= bb; + ret = promptToClose(tab); } return ret; } From ee8a9f86db5c926345e81165cb6554decad5bf9e Mon Sep 17 00:00:00 2001 From: lleplat Date: Mon, 20 May 2024 13:41:35 +0100 Subject: [PATCH 072/866] Added homogenizeGeometryCollection --- qupath-core/src/main/java/qupath/lib/roi/RoiTools.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qupath-core/src/main/java/qupath/lib/roi/RoiTools.java b/qupath-core/src/main/java/qupath/lib/roi/RoiTools.java index b758c3f72..a0067eb71 100644 --- a/qupath-core/src/main/java/qupath/lib/roi/RoiTools.java +++ b/qupath-core/src/main/java/qupath/lib/roi/RoiTools.java @@ -182,7 +182,7 @@ public static ROI intersection(Collection rois) { Geometry first = geometries.remove(0); for (var geom : geometries) first = first.intersection(geom); - return GeometryTools.geometryToROI(first, plane); + return GeometryTools.geometryToROI(GeometryTools.homogenizeGeometryCollection(first), plane); } From 2fdc591835a00db49bc988d5db93456f64d89307 Mon Sep 17 00:00:00 2001 From: Pete Date: Wed, 22 May 2024 11:55:30 +0100 Subject: [PATCH 073/866] Update AbstractTileableImageServer.java Attempt to fix https://github.com/qupath/qupath/issues/1527 Spoiler: didn't work. But *possibly* improves robustness slightly anyway. --- .../servers/AbstractTileableImageServer.java | 48 +++++++++++-------- 1 file changed, 28 insertions(+), 20 deletions(-) diff --git a/qupath-core/src/main/java/qupath/lib/images/servers/AbstractTileableImageServer.java b/qupath-core/src/main/java/qupath/lib/images/servers/AbstractTileableImageServer.java index a8c547049..c12139454 100644 --- a/qupath-core/src/main/java/qupath/lib/images/servers/AbstractTileableImageServer.java +++ b/qupath-core/src/main/java/qupath/lib/images/servers/AbstractTileableImageServer.java @@ -263,7 +263,7 @@ protected BufferedImage createDefaultRGBImage(int width, int height) { @Override - public BufferedImage readRegion(final RegionRequest request) throws IOException { + public BufferedImage readRegion(RegionRequest request) throws IOException { // Check if we already have a tile for precisely this occasion - with the right server path // Make a defensive copy, since the cache is critical var cache = getCache(); @@ -293,11 +293,35 @@ public BufferedImage readRegion(final RegionRequest request) throws IOException // Ensure all tiles are either cached or pending before we continue prerequestTiles(tiles); - - long startTime = System.currentTimeMillis(); - // Handle the general case for RGB + + // Fix output size to match tiles, if necessary + // See https://github.com/qupath/qupath/issues/1527 + if (request.getDownsample() > 1 && nResolutions() > 1 + && request.getMaxX() <= getWidth() && request.getMaxY() <= getHeight() + && request.getMinX() >= 0 && request.getMinY() >= 0) { + int minX = Integer.MAX_VALUE; + int minY = Integer.MAX_VALUE; + int maxX = -Integer.MAX_VALUE; + int maxY = -Integer.MAX_VALUE; + for (var tile : tiles) { + minX = Math.min(minX, tile.getRegionRequest().getMinX()); + minY = Math.min(minY, tile.getRegionRequest().getMinY()); + maxX = Math.max(maxX, tile.getRegionRequest().getMaxX()); + maxY = Math.max(maxY, tile.getRegionRequest().getMaxY()); + } + if (minX != request.getMinX() || minY != request.getMinY() || maxX != request.getMaxX() || maxY != request.getMaxY()) { + var request2 = request.intersect2D(minX, minY, maxX, maxY); + logger.debug("RegionRequest updated from {} -> {}", request, request2); + request = request2; + } + } + + // Determine output image size int width = (int)Math.max(1, Math.round(request.getWidth() / request.getDownsample())); int height = (int)Math.max(1, Math.round(request.getHeight() / request.getDownsample())); + + long startTime = System.currentTimeMillis(); + // Handle the general case for RGB if (isRGB()) { BufferedImage imgResult = createDefaultRGBImage(width, height); Graphics2D g2d = imgResult.createGraphics(); @@ -307,22 +331,6 @@ public BufferedImage readRegion(final RegionRequest request) throws IOException if (request.getDownsample() > 1) g2d.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR); - // Requests could be parallelized, but should consider the number of threads created/what happens if one blocks -// Map map = tiles.stream() -// .collect(Collectors.toMap(tileRequest -> tileRequest, -// tileRequest -> { -// try { -// return getTile(tileRequest); -// } catch (Exception ex) { -// return null; -// } -// })); -// for (TileRequest tileRequest : tiles) { -// BufferedImage imgTile = map.get(tileRequest); -// if (imgTile != null) -// g2d.drawImage(imgTile, tileRequest.getImageX(), tileRequest.getImageY(), tileRequest.getImageWidth(), tileRequest.getImageHeight(), null); -// } - for (TileRequest tileRequest : tiles) { BufferedImage imgTile = getTile(tileRequest); g2d.drawImage(imgTile, tileRequest.getImageX(), tileRequest.getImageY(), tileRequest.getImageWidth(), tileRequest.getImageHeight(), null); From 84a48596092006dd30a934cd2fef19472fd9a0f0 Mon Sep 17 00:00:00 2001 From: Pete Date: Wed, 22 May 2024 12:12:49 +0100 Subject: [PATCH 074/866] Update dependencies, add DJL model zoo deepJavaLibrary = "0.28.0" gson = "2.11.0" picocli = "4.7.6" ModelZoo enables creating models with common architectures (assuming a suitable engine is present). --- CHANGELOG.md | 4 +++- buildSrc/src/main/groovy/qupath.djl-conventions.gradle | 2 +- gradle/libs.versions.toml | 8 +++++--- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ee8fd1b8..3821899c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,11 +15,13 @@ This is a *work in progress* for the next major release. ### Dependency updates * Bio-Formats 7.3.0 * Commonmark 0.22.0 -* DeepJavaLibrary 0.27.0 +* DeepJavaLibrary 0.28.0 * Groovy 4.0.21 +* Gson 2.11.0 * Guava 33.1.0-jre * JavaFX 22.0.1 * JNA 5.14.0 +* Picocli 4.7.6 * OpenCV 4.9.0 * OpenJDK 21 * slf4j 2.0.12 diff --git a/buildSrc/src/main/groovy/qupath.djl-conventions.gradle b/buildSrc/src/main/groovy/qupath.djl-conventions.gradle index 12ae649d8..af43f0a98 100644 --- a/buildSrc/src/main/groovy/qupath.djl-conventions.gradle +++ b/buildSrc/src/main/groovy/qupath.djl-conventions.gradle @@ -42,7 +42,7 @@ def djlVersion = libs.versions.deepJavaLibrary.get() dependencies { if (djlApi) { - implementation libs.deepJavaLibrary + implementation libs.bundles.djl } if (djlZero) { implementation "ai.djl:djl-zero:$djlVersion" diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c60f0faa5..956a57a83 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,10 +10,10 @@ commonsMath3 = "3.6.1" commonsText = "1.10.0" controlsFX = "11.1.2" -deepJavaLibrary = "0.27.0" +deepJavaLibrary = "0.28.0" groovy = "4.0.21" -gson = "2.10.1" +gson = "2.11.0" guava = "33.1.0-jre" ikonli = "12.3.1" @@ -40,7 +40,7 @@ logviewer = "0.2.0-SNAPSHOT" openslide = "4.0.0" -picocli = "4.7.5" +picocli = "4.7.6" qupath-fxtras = "0.1.5-SNAPSHOT" richtextfx = "0.11.2" @@ -59,6 +59,7 @@ commons-text = { module = "org.apache.commons:commons-text", version.ref = "c controlsfx = { module = "org.controlsfx:controlsfx", version.ref = "controlsFX" } deepJavaLibrary = { module = "ai.djl:api", version.ref = "deepJavaLibrary" } +deepJavaLibrary-zoo = { module = "ai.djl:model-zoo", version.ref = "deepJavaLibrary" } gson = { module = "com.google.code.gson:gson", version.ref = "gson" } guava = { module = "com.google.guava:guava", version.ref = "guava" } @@ -104,6 +105,7 @@ junit-platform = { module = "org.junit.platform:junit-platform-launcher" } [bundles] +djl = ["deepJavaLibrary", "deepJavaLibrary-zoo"] groovy = ["groovy-core", "groovy-jsr223", "groovy-xml"] ikonli = ["ikonli-javafx", "ikonli-ionicons4"] logging = ["slf4j", "logback"] From e85209391f1ea2520598fc41ccf650029dc42dde Mon Sep 17 00:00:00 2001 From: Pete Date: Fri, 24 May 2024 10:09:12 +0100 Subject: [PATCH 075/866] Update AbstractTileableImageServer.java Potentially better fix for https://github.com/qupath/qupath/issues/1527 --- .../servers/AbstractTileableImageServer.java | 33 ++++++++++--------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/qupath-core/src/main/java/qupath/lib/images/servers/AbstractTileableImageServer.java b/qupath-core/src/main/java/qupath/lib/images/servers/AbstractTileableImageServer.java index c12139454..f9703cc4b 100644 --- a/qupath-core/src/main/java/qupath/lib/images/servers/AbstractTileableImageServer.java +++ b/qupath-core/src/main/java/qupath/lib/images/servers/AbstractTileableImageServer.java @@ -263,7 +263,7 @@ protected BufferedImage createDefaultRGBImage(int width, int height) { @Override - public BufferedImage readRegion(RegionRequest request) throws IOException { + public BufferedImage readRegion(final RegionRequest request) throws IOException { // Check if we already have a tile for precisely this occasion - with the right server path // Make a defensive copy, since the cache is critical var cache = getCache(); @@ -294,32 +294,33 @@ public BufferedImage readRegion(RegionRequest request) throws IOException { // Ensure all tiles are either cached or pending before we continue prerequestTiles(tiles); - // Fix output size to match tiles, if necessary + // Determine output image size + int width = (int)Math.max(1, Math.round(request.getWidth() / request.getDownsample())); + int height = (int)Math.max(1, Math.round(request.getHeight() / request.getDownsample())); + + // Fix output size to handle right/bottom edge issue caused by rounding/flooring within image pyramid // See https://github.com/qupath/qupath/issues/1527 + // The problem is that a black border can be created when there aren't quite enough pixels to fill the region, + // which happens if the lower-resolution levels have been truncated (even by a fraction of a pixel) if (request.getDownsample() > 1 && nResolutions() > 1 - && request.getMaxX() <= getWidth() && request.getMaxY() <= getHeight() - && request.getMinX() >= 0 && request.getMinY() >= 0) { - int minX = Integer.MAX_VALUE; - int minY = Integer.MAX_VALUE; + && request.getMaxX() == getWidth() || request.getMaxY() == getHeight()) { int maxX = -Integer.MAX_VALUE; int maxY = -Integer.MAX_VALUE; for (var tile : tiles) { - minX = Math.min(minX, tile.getRegionRequest().getMinX()); - minY = Math.min(minY, tile.getRegionRequest().getMinY()); maxX = Math.max(maxX, tile.getRegionRequest().getMaxX()); maxY = Math.max(maxY, tile.getRegionRequest().getMaxY()); } - if (minX != request.getMinX() || minY != request.getMinY() || maxX != request.getMaxX() || maxY != request.getMaxY()) { - var request2 = request.intersect2D(minX, minY, maxX, maxY); - logger.debug("RegionRequest updated from {} -> {}", request, request2); - request = request2; + if (maxX < request.getMaxX() || maxY < request.getMaxY()) { + int width2 = (int)Math.max(1, Math.round((maxX - request.getMinX()) / request.getDownsample() - 1e-9)); + int height2 = (int)Math.max(1, Math.round((maxY - request.getMinY()) / request.getDownsample() - 1e-9)); + if (width != width2 || height != height2) { + logger.debug("Region size updated from {}x{} to {}x{}", width, height, width2, height2); + width = width2; + height = height2; + } } } - // Determine output image size - int width = (int)Math.max(1, Math.round(request.getWidth() / request.getDownsample())); - int height = (int)Math.max(1, Math.round(request.getHeight() / request.getDownsample())); - long startTime = System.currentTimeMillis(); // Handle the general case for RGB if (isRGB()) { From d0b906123f6466c0b987faa2e810644775cd1e48 Mon Sep 17 00:00:00 2001 From: Pete Date: Fri, 24 May 2024 10:12:15 +0100 Subject: [PATCH 076/866] Update CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ee8fd1b8..f6083ba00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ This is a *work in progress* for the next major release. * Full image annotation for Sparse training image throws errors for detections (https://github.com/qupath/qupath/issues/1443) Channel name can sometimes change when using the quick channel color selector (https://github.com/qupath/qupath/issues/1500) * TileExporter exports ImageJ TIFFs with channels converted to z-stacks (https://github.com/qupath/qupath/issues/1503) +* Black row or column appears on some downsampled images (https://github.com/qupath/qupath/issues/1527) ### Dependency updates * Bio-Formats 7.3.0 From 7ddc300efbfdbb55fd2846bbdcfb73b56132f1d9 Mon Sep 17 00:00:00 2001 From: Pete Date: Fri, 24 May 2024 10:47:36 +0100 Subject: [PATCH 077/866] Improve resize consistency for RGB/non-RGB --- .../servers/AbstractTileableImageServer.java | 115 ++++++++++++------ 1 file changed, 75 insertions(+), 40 deletions(-) diff --git a/qupath-core/src/main/java/qupath/lib/images/servers/AbstractTileableImageServer.java b/qupath-core/src/main/java/qupath/lib/images/servers/AbstractTileableImageServer.java index f9703cc4b..5482eb550 100644 --- a/qupath-core/src/main/java/qupath/lib/images/servers/AbstractTileableImageServer.java +++ b/qupath-core/src/main/java/qupath/lib/images/servers/AbstractTileableImageServer.java @@ -298,33 +298,10 @@ public BufferedImage readRegion(final RegionRequest request) throws IOException int width = (int)Math.max(1, Math.round(request.getWidth() / request.getDownsample())); int height = (int)Math.max(1, Math.round(request.getHeight() / request.getDownsample())); - // Fix output size to handle right/bottom edge issue caused by rounding/flooring within image pyramid - // See https://github.com/qupath/qupath/issues/1527 - // The problem is that a black border can be created when there aren't quite enough pixels to fill the region, - // which happens if the lower-resolution levels have been truncated (even by a fraction of a pixel) - if (request.getDownsample() > 1 && nResolutions() > 1 - && request.getMaxX() == getWidth() || request.getMaxY() == getHeight()) { - int maxX = -Integer.MAX_VALUE; - int maxY = -Integer.MAX_VALUE; - for (var tile : tiles) { - maxX = Math.max(maxX, tile.getRegionRequest().getMaxX()); - maxY = Math.max(maxY, tile.getRegionRequest().getMaxY()); - } - if (maxX < request.getMaxX() || maxY < request.getMaxY()) { - int width2 = (int)Math.max(1, Math.round((maxX - request.getMinX()) / request.getDownsample() - 1e-9)); - int height2 = (int)Math.max(1, Math.round((maxY - request.getMinY()) / request.getDownsample() - 1e-9)); - if (width != width2 || height != height2) { - logger.debug("Region size updated from {}x{} to {}x{}", width, height, width2, height2); - width = width2; - height = height2; - } - } - } - long startTime = System.currentTimeMillis(); // Handle the general case for RGB if (isRGB()) { - BufferedImage imgResult = createDefaultRGBImage(width, height); + BufferedImage imgResult = createRGBImage(request, tiles, width, height); Graphics2D g2d = imgResult.createGraphics(); g2d.scale(1.0/request.getDownsample(), 1.0/request.getDownsample()); g2d.translate(-request.getX(), -request.getY()); @@ -341,9 +318,9 @@ public BufferedImage readRegion(final RegionRequest request) throws IOException long endTime = System.currentTimeMillis(); logger.trace("Requested " + tiles.size() + " tiles in " + (endTime - startTime) + " ms (RGB)"); - return imgResult; + return resizeIfNeeded(imgResult, width, height); } else { - // Request all of the tiles we need & figure out image dimensions + // Request all the tiles we need & figure out image dimensions // Do all this at the pyramid level of the tiles WritableRaster raster = null; ColorModel colorModel = null; @@ -398,11 +375,6 @@ public BufferedImage readRegion(final RegionRequest request) throws IOException continue; copyPixels(imgTile.getRaster(), dx, dy, raster); - -// raster.setRect( -// dx, -// dy, -// imgTile.getRaster()); } } } @@ -435,26 +407,89 @@ else if (xEnd - xStart <= 0 || yEnd - yStart <= 0) int w = xEnd - xStart; int h = yEnd - yStart; -// int w = Math.min(raster.getWidth() - xStart, xEnd - xStart); -// int h = Math.min(raster.getHeight() - yStart, yEnd - yStart); var raster2 = raster.createCompatibleWritableRaster(w, h); copyPixels(raster, -x, -y, raster2); raster = raster2; } - // Return the image, resizing if necessary + // Return the image, resizing if necessary (we determined the raster size based on tiles, not the request) BufferedImage imgResult = new BufferedImage(colorModel, raster, alphaPremultiplied, null); - int currentWidth = imgResult.getWidth(); - int currentHeight = imgResult.getHeight(); - if (currentWidth != width || currentHeight != height) { - imgResult = BufferedImageTools.resize(imgResult, width, height, allowSmoothInterpolation()); - } - + imgResult = resizeIfNeeded(imgResult, width, height); + long endTime = System.currentTimeMillis(); logger.trace("Requested " + tiles.size() + " tiles in " + (endTime - startTime) + " ms (non-RGB)"); return imgResult; } } + + /** + * Resize an image if it doesn't match the expected dimensions, and log a debug message. + * @param img the input image + * @param width the required width + * @param height the required height + * @return the resized image if necessary, or the original image otherwise + */ + private BufferedImage resizeIfNeeded(BufferedImage img, int width, int height) { + int currentWidth = img.getWidth(); + int currentHeight = img.getHeight(); + if (currentWidth != width || currentHeight != height) { + logger.debug("Region size updated from {}x{} to {}x{}", currentWidth, currentHeight, width, height); + return BufferedImageTools.resize(img, width, height, allowSmoothInterpolation()); + } else + return img; + } + + + /** + * Create an RGB image to fulfill a request with the given tiles. + * This method exists because of https://github.com/qupath/qupath/issues/1527 + *

+ * The problem is that a black border can be created when there aren't quite enough pixels to fill the region, + * which happens if the lower-resolution levels have been truncated (even by a fraction of a pixel). + *

+ * In this case, we want to paint to a small enough image that does fit, and handle resizing later if needed. + *

+ * This method tries to be conservative in making these adjustments; it is better to allow a border than to + * create an image that is of a very different size. + * Therefore we only permit changing the size by one pixel in each dimension. + * + * @param request the region being requested + * @param tiles the tiles to fulfil the request + * @param expectedWidth the expected width of the image (computed from the request) + * @param expectedHeight the expected height of the image (computed from the request) + * @return a blank RGB image that can be used to draw the requested image + */ + private BufferedImage createRGBImage(RegionRequest request, Collection tiles, int expectedWidth, int expectedHeight) { + int imgWidth = expectedWidth; + int imgHeight = expectedHeight; + // Fix output size to handle right/bottom edge issue caused by rounding/flooring within image pyramid + // See https://github.com/qupath/qupath/issues/1527 + // Only permit adjustments if we expect to be fulfilling the request from only pixels within the image + // (don't change anything if the request is already known to be out-of-bounds at the full resolution) + if (request.getDownsample() > 1 && nResolutions() > 1 + && (request.getMaxX() == getWidth() || request.getMaxY() == getHeight()) + && (request.getMinX() >= 0 && request.getMinY() >= 0)) { + int maxX = -Integer.MAX_VALUE; + int maxY = -Integer.MAX_VALUE; + for (var tile : tiles) { + maxX = Math.max(maxX, tile.getRegionRequest().getMaxX()); + maxY = Math.max(maxY, tile.getRegionRequest().getMaxY()); + } + if (maxX < request.getMaxX() || maxY < request.getMaxY()) { + int width2 = (int)Math.max(1, Math.round((maxX - request.getMinX()) / request.getDownsample() - 1e-9)); + int height2 = (int)Math.max(1, Math.round((maxY - request.getMinY()) / request.getDownsample() - 1e-9)); + // Be cautious with size adjustments - only permit changing by one pixel + if (expectedWidth == width2+1 || expectedHeight == height2+1) { + logger.trace("RGB image size updated from {}x{} to {}x{} to avoid border problems", + expectedWidth, expectedHeight, width2, height2); + imgWidth = width2; + imgHeight = height2; + } + } + } + return createDefaultRGBImage(imgWidth, imgHeight); + } + /** * Ensure all tiles in a list are either cached or requested. From aeaab7a3064ff37d08306a6a230525bfc5b591d0 Mon Sep 17 00:00:00 2001 From: lleplat Date: Fri, 24 May 2024 14:46:57 +0100 Subject: [PATCH 078/866] Find URI around executable + bind stylesheet --- .../java/qupath/lib/gui/JavadocViewer.java | 41 +++++++++++++++++++ .../java/qupath/lib/gui/tools/WebViews.java | 16 +++++--- 2 files changed, 52 insertions(+), 5 deletions(-) diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/JavadocViewer.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/JavadocViewer.java index 4e3df1fdf..9610d4e48 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/JavadocViewer.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/JavadocViewer.java @@ -6,9 +6,13 @@ import org.slf4j.LoggerFactory; import qupath.lib.common.GeneralTools; import qupath.lib.gui.prefs.PathPrefs; +import qupath.lib.gui.tools.WebViews; import qupath.ui.javadocviewer.gui.viewer.JavadocViewerCommand; import java.net.URI; +import java.net.URISyntaxException; +import java.nio.file.Path; +import java.nio.file.Paths; import java.util.Objects; import java.util.stream.Stream; @@ -43,7 +47,9 @@ public class JavadocViewer implements Runnable { public JavadocViewer(Stage owner) { command = new JavadocViewerCommand( owner, + WebViews.getStyleSheet(), Stream.of( + findJavadocUriAroundExecutable(), System.getProperty(JAVADOC_PATH_SYSTEM_PROPERTY), javadocPath.get(), PathPrefs.userPathProperty().get() @@ -66,4 +72,39 @@ public JavadocViewer(Stage owner) { public void run() { command.run(); } + + private static String findJavadocUriAroundExecutable() { + URI codeUri; + try { + codeUri = JavadocViewer.class.getProtectionDomain().getCodeSource().getLocation().toURI(); + } catch (URISyntaxException e) { + logger.debug("Could not convert URI", e); + return null; + } + + Path codePath; + try { + codePath = Paths.get(codeUri); + } catch (Exception e) { + logger.debug(String.format("Could not convert URI %s to path", codeUri), e); + return null; + } + + // If we have a jar file, we need to check the location... + if (codePath.getFileName().toString().toLowerCase().endsWith(".jar")) { + if (codePath.getParent().toString().endsWith("/build/libs")) { + // We are probably using gradlew run + // We can go up several directories to the root project, and then search inside for javadocs + return codePath.getParent().resolve("../../../").normalize().toString(); + } else { + // We are probably within a pre-built package + // javadoc jars should be either in the same directory or a subdirectory + return codePath.getParent().toString(); + } + } else { + // If we have a binary directory, we may well be launching from an IDE + // We can go up several directories to the root project, and then search inside for javadocs + return codePath.resolve("../../../").normalize().toString(); + } + } } diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/tools/WebViews.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/tools/WebViews.java index e046230cd..b86ac82c8 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/tools/WebViews.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/tools/WebViews.java @@ -22,8 +22,9 @@ package qupath.lib.gui.tools; -import javafx.beans.property.ObjectProperty; -import javafx.beans.property.SimpleObjectProperty; +import javafx.beans.property.ReadOnlyStringProperty; +import javafx.beans.property.SimpleStringProperty; +import javafx.beans.property.StringProperty; import javafx.scene.web.WebEngine; import javafx.scene.web.WebView; import qupath.lib.gui.prefs.QuPathStyleManager; @@ -37,7 +38,7 @@ public class WebViews { // Choose a stylesheet based on the current QuPath style - private static ObjectProperty userStylesheet = new SimpleObjectProperty<>(); + private static final StringProperty userStylesheet = new SimpleStringProperty(); static { QuPathStyleManager.fontProperty().addListener((v, o, n) -> updateStylesheet()); @@ -70,8 +71,13 @@ public static void bindUserStylesheetToStyle(WebEngine engine) { engine.userStyleSheetLocationProperty().unbind(); engine.userStyleSheetLocationProperty().bind(userStylesheet); } - - + + /** + * @return a stylesheet based on QuPath's current style (e.g. light or dark mode, serif or sans-serif fonts) + */ + public static ReadOnlyStringProperty getStyleSheet() { + return userStylesheet; + } private static void updateStylesheet() { String cssName = "/css/web-"; From 5ba9d326b1038475413aac7b3162daa33ed136d5 Mon Sep 17 00:00:00 2001 From: Pete Date: Fri, 24 May 2024 15:03:19 +0100 Subject: [PATCH 079/866] Improve display of annotation names --- CHANGELOG.md | 1 + .../gui/viewer/overlays/HierarchyOverlay.java | 93 +++++++++++++++++-- 2 files changed, 84 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3821899c2..2051b1602 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ This is a *work in progress* for the next major release. ### Enhancements * Read and write OME-Zarr images (https://github.com/qupath/qupath/pull/1474) +* Improved display of annotation names ### Bugs fixed * Tile export to .ome.tif can convert to 8-bit unnecessarily (https://github.com/qupath/qupath/issues/1494) diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/overlays/HierarchyOverlay.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/overlays/HierarchyOverlay.java index 0106bef05..e4fc151c7 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/overlays/HierarchyOverlay.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/overlays/HierarchyOverlay.java @@ -4,7 +4,7 @@ * %% * Copyright (C) 2014 - 2016 The Queen's University of Belfast, Northern Ireland * Contact: IP Management (ipmanagement@qub.ac.uk) - * Copyright (C) 2018 - 2020 QuPath developers, The University of Edinburgh + * Copyright (C) 2018 - 2024 QuPath developers, The University of Edinburgh * %% * QuPath is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as @@ -30,6 +30,7 @@ import java.awt.Graphics2D; import java.awt.RenderingHints; import java.awt.Shape; +import java.awt.geom.Line2D; import java.awt.geom.Rectangle2D; import java.awt.image.BufferedImage; import java.util.ArrayList; @@ -37,18 +38,24 @@ import java.util.Collections; import java.util.Comparator; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.TreeSet; +import java.util.WeakHashMap; +import java.util.concurrent.ConcurrentHashMap; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import qupath.lib.awt.common.AwtTools; import qupath.lib.color.ColorToolsAwt; +import qupath.lib.common.ColorTools; import qupath.lib.common.GeneralTools; +import qupath.lib.geom.Point2; import qupath.lib.gui.images.servers.PathHierarchyImageServer; import qupath.lib.gui.images.stores.DefaultImageRegionStore; import qupath.lib.gui.prefs.PathPrefs; +import qupath.lib.gui.tools.ColorToolsFX; import qupath.lib.gui.viewer.OverlayOptions; import qupath.lib.gui.viewer.PathObjectPainter; import qupath.lib.images.ImageData; @@ -61,6 +68,10 @@ import qupath.lib.objects.hierarchy.PathObjectHierarchy; import qupath.lib.regions.ImageRegion; import qupath.lib.regions.RegionRequest; +import qupath.lib.roi.EllipseROI; +import qupath.lib.roi.LineROI; +import qupath.lib.roi.RectangleROI; +import qupath.lib.roi.interfaces.ROI; /** @@ -84,7 +95,13 @@ public class HierarchyOverlay extends AbstractOverlay { private int lastPointRadius = PathPrefs.pointRadiusProperty().get(); private Font font = new Font("SansSerif", Font.BOLD, 10); - + + // Map of points around which names should be displayed, to avoid frequent searches + private Map nameConnectionPointMap = Collections.synchronizedMap(new WeakHashMap<>()); + + // Map of colors to use for displaying names, to avoid generating new color objects too often + private Map nameColorMap = new ConcurrentHashMap<>(); + /** * Comparator to determine the order in which detections should be painted. * This should be used with caution! Check out the docs for the class for details. @@ -290,13 +307,15 @@ public void paintOverlay(final Graphics2D g2d, final ImageRegion imageRegion, fi break; } } - float fontSize = (float)(requestedFontSize * downsampleFactor); + double fontDownsample = Math.min(downsampleFactor, 16); + float fontSize = (float)(requestedFontSize * fontDownsample); if (!GeneralTools.almostTheSame(font.getSize2D(), fontSize, 0.001)) font = font.deriveFont(fontSize); g2d.setFont(font); var metrics = g2d.getFontMetrics(font); var rect = new Rectangle2D.Double(); + var connector = new Line2D.Double(); g2d.setRenderingHint(RenderingHints.KEY_FRACTIONALMETRICS, RenderingHints.VALUE_FRACTIONALMETRICS_ON); g2d.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON); @@ -309,20 +328,74 @@ public void paintOverlay(final Graphics2D g2d, final ImageRegion imageRegion, fi g2d.setColor(ColorToolsAwt.TRANSLUCENT_BLACK); var bounds = metrics.getStringBounds(name, g2d); - - double pad = 5.0 * downsampleFactor; - double x = roi.getCentroidX() - bounds.getWidth() / 2.0; - double y = roi.getCentroidY() + bounds.getY() + metrics.getAscent() + pad; - + + // Find a point to connect to within the ROI + Point2 point = nameConnectionPointMap.computeIfAbsent(roi, this::findNamePointForROI); + + double pad = 5.0 * fontDownsample; + double x = point.getX() - bounds.getWidth()/2.0; + double y = point.getY() - (bounds.getY() + metrics.getAscent() + pad*4); rect.setFrame(x+bounds.getX()-pad, y+bounds.getY()-pad, bounds.getWidth()+pad*2, bounds.getHeight()+pad*2); + + // Get the object color + var objectColorInt = ColorToolsFX.getDisplayedColorARGB(annotation).intValue(); + + // Draw a line to where the name box will be + var objectColor = ColorToolsAwt.getCachedColor(objectColorInt); + float thickness = (float)(PathPrefs.annotationStrokeThicknessProperty().get() * fontDownsample); + g2d.setColor(objectColor); + g2d.setStroke(PathObjectPainter.getCachedStroke(thickness)); + connector.setLine(rect.getCenterX(), rect.getMaxY(), point.getX(), point.getY()); + g2d.draw(connector); + + // Draw a name box + g2d.draw(rect); + var colorTranslucent = nameColorMap.computeIfAbsent(objectColorInt, this::getNameRectangleColor); + g2d.setColor(colorTranslucent); g2d.fill(rect); g2d.setColor(Color.WHITE); - + + // Draw the text g2d.drawString(name, (float)x, (float)y); } } } - + } + + /** + * Get a color to use to fill the bounding box when showing an object's name + * @param objectColorInt + * @return + */ + private Color getNameRectangleColor(Integer objectColorInt) { + float darken = 0.6f; + return ColorToolsAwt.getCachedColor( + Math.round(ColorTools.red(objectColorInt) * darken), + Math.round(ColorTools.green(objectColorInt) * darken), + Math.round(ColorTools.blue(objectColorInt) * darken), + 128 + ); + } + + /** + * Find a point around which to display an object's name, if required. + * @param roi + * @return + */ + private Point2 findNamePointForROI(ROI roi) { + if (roi instanceof RectangleROI || roi instanceof EllipseROI) { + // Use top centre for rectangle and ellipses + return new Point2(roi.getCentroidX(), roi.getBoundsY()); + } else if (roi instanceof LineROI) { + // Use centroids for lines (2 points only) + return new Point2(roi.getCentroidX(), roi.getCentroidY()); + } else { + Point2 target = new Point2(roi.getCentroidX(), roi.getBoundsY()); + return roi.getAllPoints().stream() + .filter(p -> Math.abs(p.getY() - target.getY()) < 1e-3) + .min(Comparator.comparingDouble(p -> p.distanceSq(target))) + .get(); + } } /** From 59df823de186cd47d628b952ccd434459bf51af4 Mon Sep 17 00:00:00 2001 From: Pete Date: Fri, 24 May 2024 15:11:03 +0100 Subject: [PATCH 080/866] Use selection color when displaying names --- .../qupath/lib/gui/viewer/overlays/HierarchyOverlay.java | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/overlays/HierarchyOverlay.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/overlays/HierarchyOverlay.java index e4fc151c7..ba87d772a 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/overlays/HierarchyOverlay.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/overlays/HierarchyOverlay.java @@ -325,8 +325,7 @@ public void paintOverlay(final Graphics2D g2d, final ImageRegion imageRegion, fi var roi = annotation.getROI(); if (name != null && !name.isBlank() && roi != null && !overlayOptions.isPathClassHidden(annotation.getPathClass())) { - g2d.setColor(ColorToolsAwt.TRANSLUCENT_BLACK); - + var bounds = metrics.getStringBounds(name, g2d); // Find a point to connect to within the ROI @@ -338,7 +337,11 @@ public void paintOverlay(final Graphics2D g2d, final ImageRegion imageRegion, fi rect.setFrame(x+bounds.getX()-pad, y+bounds.getY()-pad, bounds.getWidth()+pad*2, bounds.getHeight()+pad*2); // Get the object color - var objectColorInt = ColorToolsFX.getDisplayedColorARGB(annotation).intValue(); + int objectColorInt; + if (hierarchy.getSelectionModel().isSelected(annotation) && PathPrefs.useSelectedColorProperty().get()) + objectColorInt = PathPrefs.colorSelectedObjectProperty().get(); + else + objectColorInt = ColorToolsFX.getDisplayedColorARGB(annotation).intValue(); // Draw a line to where the name box will be var objectColor = ColorToolsAwt.getCachedColor(objectColorInt); From 97d3a106d0af08e4c2dedaa0a84e83c9e29d1629 Mon Sep 17 00:00:00 2001 From: lleplat Date: Fri, 24 May 2024 16:54:46 +0100 Subject: [PATCH 081/866] Added copyright statement --- .../java/qupath/lib/gui/JavadocViewer.java | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/JavadocViewer.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/JavadocViewer.java index 9610d4e48..5f2616b8c 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/JavadocViewer.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/JavadocViewer.java @@ -1,3 +1,25 @@ +/*- + * #%L + * This file is part of QuPath. + * %% + * Copyright (C) 2024 QuPath developers, The University of Edinburgh + * %% + * QuPath 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 3 of the + * License, or (at your option) any later version. + * + * QuPath 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 QuPath. If not, see . + * #L% + */ + + package qupath.lib.gui; import javafx.beans.property.StringProperty; From c36f6514c23c5b04834c0caa78fe6cf6d700ce07 Mon Sep 17 00:00:00 2001 From: lleplat Date: Fri, 24 May 2024 17:03:58 +0100 Subject: [PATCH 082/866] Remove built-in search bar --- qupath-gui-fx/src/main/resources/css/web-sans-serif-dark.css | 5 +++++ .../src/main/resources/css/web-sans-serif-light.css | 5 +++++ qupath-gui-fx/src/main/resources/css/web-serif-dark.css | 5 +++++ qupath-gui-fx/src/main/resources/css/web-serif-light.css | 5 +++++ 4 files changed, 20 insertions(+) diff --git a/qupath-gui-fx/src/main/resources/css/web-sans-serif-dark.css b/qupath-gui-fx/src/main/resources/css/web-sans-serif-dark.css index afd8e52fe..70f485070 100644 --- a/qupath-gui-fx/src/main/resources/css/web-sans-serif-dark.css +++ b/qupath-gui-fx/src/main/resources/css/web-sans-serif-dark.css @@ -2,4 +2,9 @@ body { font-family: sans-serif; color: rgb(200, 200, 200); background: rgb(45, 48, 50); +} + +.sub-nav .nav-list-search { + /* Don't display built-in search, as it fails within a WebView */ + display: none; } \ No newline at end of file diff --git a/qupath-gui-fx/src/main/resources/css/web-sans-serif-light.css b/qupath-gui-fx/src/main/resources/css/web-sans-serif-light.css index 6615a2615..13a9d6f11 100644 --- a/qupath-gui-fx/src/main/resources/css/web-sans-serif-light.css +++ b/qupath-gui-fx/src/main/resources/css/web-sans-serif-light.css @@ -2,4 +2,9 @@ body { font-family: sans-serif; color: #262626; background-color: white; +} + +.sub-nav .nav-list-search { + /* Don't display built-in search, as it fails within a WebView */ + display: none; } \ No newline at end of file diff --git a/qupath-gui-fx/src/main/resources/css/web-serif-dark.css b/qupath-gui-fx/src/main/resources/css/web-serif-dark.css index c7f344162..f72aca087 100644 --- a/qupath-gui-fx/src/main/resources/css/web-serif-dark.css +++ b/qupath-gui-fx/src/main/resources/css/web-serif-dark.css @@ -2,4 +2,9 @@ body { font-family: serif; color: rgb(200, 200, 200); background: rgb(45, 48, 50); +} + +.sub-nav .nav-list-search { + /* Don't display built-in search, as it fails within a WebView */ + display: none; } \ No newline at end of file diff --git a/qupath-gui-fx/src/main/resources/css/web-serif-light.css b/qupath-gui-fx/src/main/resources/css/web-serif-light.css index 44339d6d4..90e200cf6 100644 --- a/qupath-gui-fx/src/main/resources/css/web-serif-light.css +++ b/qupath-gui-fx/src/main/resources/css/web-serif-light.css @@ -2,4 +2,9 @@ body { font-family: serif; color: #262626; background-color: white; +} + +.sub-nav .nav-list-search { + /* Don't display built-in search, as it fails within a WebView */ + display: none; } \ No newline at end of file From 1607eecf45a2c3fcb033cd94bd419abd8e8e92f5 Mon Sep 17 00:00:00 2001 From: Pete Date: Fri, 24 May 2024 17:22:12 +0100 Subject: [PATCH 083/866] Add comments --- .../images/servers/AbstractTileableImageServer.java | 2 ++ .../main/java/qupath/lib/gui/commands/Commands.java | 10 ++++++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/qupath-core/src/main/java/qupath/lib/images/servers/AbstractTileableImageServer.java b/qupath-core/src/main/java/qupath/lib/images/servers/AbstractTileableImageServer.java index 5482eb550..ccd988695 100644 --- a/qupath-core/src/main/java/qupath/lib/images/servers/AbstractTileableImageServer.java +++ b/qupath-core/src/main/java/qupath/lib/images/servers/AbstractTileableImageServer.java @@ -476,6 +476,8 @@ private BufferedImage createRGBImage(RegionRequest request, Collection Date: Fri, 24 May 2024 18:29:11 +0100 Subject: [PATCH 084/866] Update RoiEditor constrain to image Fix https://github.com/qupath/qupath/issues/1533 --- CHANGELOG.md | 1 + qupath-core/src/main/java/qupath/lib/roi/RoiEditor.java | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e48fb12d..9977d0aad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ This is a *work in progress* for the next major release. Channel name can sometimes change when using the quick channel color selector (https://github.com/qupath/qupath/issues/1500) * TileExporter exports ImageJ TIFFs with channels converted to z-stacks (https://github.com/qupath/qupath/issues/1503) * Black row or column appears on some downsampled images (https://github.com/qupath/qupath/issues/1527) +* Full image annotations can be shifted accidentally outside the image bounds (https://github.com/qupath/qupath/issues/1533) ### Dependency updates * Bio-Formats 7.3.0 diff --git a/qupath-core/src/main/java/qupath/lib/roi/RoiEditor.java b/qupath-core/src/main/java/qupath/lib/roi/RoiEditor.java index fe6b4203a..177e7e199 100644 --- a/qupath-core/src/main/java/qupath/lib/roi/RoiEditor.java +++ b/qupath-core/src/main/java/qupath/lib/roi/RoiEditor.java @@ -178,11 +178,11 @@ public ROI updateTranslation(double x, double y, ImageRegion constrainRegion) { if (bounds.getMinX() + dx < constrainBounds.getMinX()) dx = constrainBounds.getMinX() - bounds.getMinX(); else if (bounds.getMaxX() + dx >= constrainBounds.getMaxX()) - dx = constrainBounds.getMaxX() - bounds.getMaxX() - 1; + dx = constrainBounds.getMaxX() - bounds.getMaxX(); if (bounds.getMinY() + dy < constrainBounds.getMinY()) dy = constrainBounds.getMinY() - bounds.getMinY(); else if (bounds.getMaxY() + dy >= constrainBounds.getMaxY()) - dy = constrainBounds.getMaxY() - bounds.getMaxY() - 1; + dy = constrainBounds.getMaxY() - bounds.getMaxY(); } pTranslateCurrent.setLocation(pTranslateCurrent.getX() + dx, pTranslateCurrent.getY() + dy); From f514e596bc2c1969464a3b26a8c4dbd01d697954 Mon Sep 17 00:00:00 2001 From: Pete Date: Sat, 25 May 2024 09:37:32 +0100 Subject: [PATCH 085/866] Fix ImagePlus channel regression New bug most likely to have been introduced recently --- .../src/main/java/qupath/imagej/tools/IJTools.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qupath-core-processing/src/main/java/qupath/imagej/tools/IJTools.java b/qupath-core-processing/src/main/java/qupath/imagej/tools/IJTools.java index 419d03568..c9fde56a6 100644 --- a/qupath-core-processing/src/main/java/qupath/imagej/tools/IJTools.java +++ b/qupath-core-processing/src/main/java/qupath/imagej/tools/IJTools.java @@ -864,7 +864,7 @@ public static PathImage convertToImagePlus(String title, ImageServer< img = server.readRegion(request); ImagePlus imp = convertToUncalibratedImagePlus(title, img); // Set dimensions - because RegionRequest is only 2D, every 'slice' is a channel - imp.setDimensions(imp.getNSlices(), 1, 1); + imp.setDimensions(imp.getStackSize(), 1, 1); // Set colors SampleModel sampleModel = img.getSampleModel(); if (!server.isRGB() && sampleModel.getNumBands() > 1) { From cdbe62651d0c49666dc15ab90d6d1d4883fa989b Mon Sep 17 00:00:00 2001 From: Alan O'Callaghan Date: Tue, 28 May 2024 10:17:34 +0100 Subject: [PATCH 086/866] Check unsaved on editor exit --- .../main/java/qupath/lib/gui/QuPathGUI.java | 4 +- .../gui/scripting/DefaultScriptEditor.java | 49 +++++-------------- .../lib/gui/scripting/ScriptEditor.java | 2 +- 3 files changed, 14 insertions(+), 41 deletions(-) diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/QuPathGUI.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/QuPathGUI.java index 4eecb2c5b..0ad90a5ed 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/QuPathGUI.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/QuPathGUI.java @@ -1015,13 +1015,13 @@ private void handleCloseMainStageRequest(WindowEvent e) { } // should prompt users to save changes if desired. - if (!scriptEditor.promptToClose()) { + if (!scriptEditor.requestClose()) { e.consume(); return; } // Warn if there is a script running - if (scriptRunning.get()) { + if (scriptRunning.get()) { if (!Dialogs.showYesNoDialog("Quit QuPath", "A script is currently running! Quit anyway?")) { logger.trace("Pressed no to quit window with script running!"); e.consume(); diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/scripting/DefaultScriptEditor.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/scripting/DefaultScriptEditor.java index bb352865b..b15448be3 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/scripting/DefaultScriptEditor.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/scripting/DefaultScriptEditor.java @@ -513,9 +513,10 @@ public boolean supportsFile(final File file) { } @Override - public boolean promptToClose() { - if (listScripts.getItems().isEmpty()) + public boolean requestClose() { + if (listScripts.getItems().isEmpty() && dialog != null) { dialog.close(); + } var ret = true; while (ret) { var tab = getCurrentScriptTab(); @@ -701,13 +702,6 @@ protected ScriptEditorControl getNewEditor() { e.consume(); } }); - -// editor.getDocument().addUndoableEditListener(new UndoManager()); -// // Handle tabs -// editor.getInputMap().put(KeyStroke.getKeyStroke(KeyEvent.VK_TAB, 0), "tab"); -// editor.getActionMap().put("tab", new TabIndenter(editor, false)); -// editor.getInputMap().put(KeyStroke.getKeyStroke(KeyEvent.VK_TAB, KeyEvent.SHIFT_DOWN_MASK), "shiftTab"); -// editor.getActionMap().put("shiftTab", new TabIndenter(editor, true)); return control; } @@ -723,7 +717,7 @@ private void createDialog() { maybeRefreshTab(getCurrentScriptTab(), false); }); -// dialog.setOnCloseRequest(e -> attemptToQuitScriptEditor()); + dialog.setOnCloseRequest(e -> requestClose()); if (qupath != null) dialog.initOwner(qupath.getStage()); dialog.titleProperty().bind(title); @@ -744,10 +738,8 @@ private void createDialog() { createRevertAction("Revert/Refresh"), ActionTools.createCheckMenuItem(ActionTools.createSelectableAction(autoRefreshFiles, "Auto refresh files")), null, - createCloseAction("Close script") -// null, -// createExitAction("Exit") // Exit actually dramatically quits the entire application... - ); + createCloseAction("Close script"), + createExitAction("Close editor")); menubar.getMenus().add(menuFile); @@ -777,27 +769,7 @@ private void createDialog() { smartEditingAction, miWrapLines ); -// menuEdit.setMnemonic(KeyEvent.VK_E); -// -// menuEdit.add(undoAction); -// menuEdit.add(redoAction); -// menuEdit.addSeparator(); -// -// menuItem = new MenuItem(cutAction); -// menuItem.setText("Cut"); -// menuItem.setMnemonic(KeyEvent.VK_T); -// menuItem.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_X, SHORTCUT_MASK)); -// menuEdit.add(menuItem); -// menuItem = new MenuItem(copyAction); -// menuItem.setText("Copy"); -// menuItem.setMnemonic(KeyEvent.VK_C); -// menuItem.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_C, SHORTCUT_MASK)); -// menuEdit.add(menuItem); -// menuItem = new MenuItem(pasteAction); -// menuItem.setText("Paste"); -// menuItem.setMnemonic(KeyEvent.VK_P); -// menuItem.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_V, SHORTCUT_MASK)); -// menuEdit.add(menuItem); + menubar.getMenus().add(menuEdit); // Set font size (common user request) @@ -2001,7 +1973,7 @@ Action createFindAction(final String name) { Action createExitAction(final String name) { Action action = new Action(name, e -> { - attemptToQuitScriptEditor(); + requestClose(); e.consume(); }); action.setAccelerator(new KeyCodeCombination(KeyCode.Q, KeyCombination.SHORTCUT_DOWN)); @@ -2111,9 +2083,10 @@ void attemptToQuitScriptEditor() { boolean promptToClose(final ScriptTab tab) { int ind = listScripts.getItems().indexOf(tab); - if (ind < 0) + if (ind < 0) { return false; - + } + // Check if we need to save if (tab.isModifiedProperty().get() && tab.hasScript()) { // TODO: Consider that this previously had a different parent for the dialog... and probably should diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/scripting/ScriptEditor.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/scripting/ScriptEditor.java index aae613604..5ce651bbe 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/scripting/ScriptEditor.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/scripting/ScriptEditor.java @@ -62,6 +62,6 @@ public interface ScriptEditor { * @return True if the editor can be closed without losing changes, * unless the user consents to losing changes. */ - public boolean promptToClose(); + public boolean requestClose(); } From ba03307fbd57994371b598ea50411682340e19c0 Mon Sep 17 00:00:00 2001 From: Alan O'Callaghan Date: Tue, 28 May 2024 10:24:41 +0100 Subject: [PATCH 087/866] Fix dialog closing behaviour when cancel --- .../lib/gui/scripting/DefaultScriptEditor.java | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/scripting/DefaultScriptEditor.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/scripting/DefaultScriptEditor.java index b15448be3..180cd3fa1 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/scripting/DefaultScriptEditor.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/scripting/DefaultScriptEditor.java @@ -514,9 +514,6 @@ public boolean supportsFile(final File file) { @Override public boolean requestClose() { - if (listScripts.getItems().isEmpty() && dialog != null) { - dialog.close(); - } var ret = true; while (ret) { var tab = getCurrentScriptTab(); @@ -525,6 +522,9 @@ public boolean requestClose() { } ret = promptToClose(tab); } + if (ret && dialog != null) { + dialog.close(); + } return ret; } @@ -717,7 +717,11 @@ private void createDialog() { maybeRefreshTab(getCurrentScriptTab(), false); }); - dialog.setOnCloseRequest(e -> requestClose()); + dialog.setOnCloseRequest(e -> { + if (!requestClose()) { + e.consume(); + } + }); if (qupath != null) dialog.initOwner(qupath.getStage()); dialog.titleProperty().bind(title); From 2beb78fb009d96986cc142ca775c8627e136e056 Mon Sep 17 00:00:00 2001 From: Alan O'Callaghan Date: Tue, 28 May 2024 10:28:55 +0100 Subject: [PATCH 088/866] Remove unused method --- .../qupath/lib/gui/scripting/DefaultScriptEditor.java | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/scripting/DefaultScriptEditor.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/scripting/DefaultScriptEditor.java index 180cd3fa1..cfd4a07b2 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/scripting/DefaultScriptEditor.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/scripting/DefaultScriptEditor.java @@ -2076,15 +2076,7 @@ else if (name.toLowerCase().equals("detection")) return action; } - - - void attemptToQuitScriptEditor() { - if (listScripts.getItems().isEmpty()) - dialog.close(); - while (promptToClose(getCurrentScriptTab())) - continue; - } - + boolean promptToClose(final ScriptTab tab) { int ind = listScripts.getItems().indexOf(tab); if (ind < 0) { From 64c9b6582ade8792a19f5299a3ac8e1552d7787b Mon Sep 17 00:00:00 2001 From: Pete Date: Tue, 11 Jun 2024 15:12:15 +0100 Subject: [PATCH 089/866] Support log histograms with measurement tables Also use log10 rather than log (thanks Alan!) --- CHANGELOG.md | 2 + .../qupath/lib/gui/charts/HistogramChart.java | 17 ++++++++- .../lib/gui/charts/HistogramDisplay.java | 38 +++++++++---------- 3 files changed, 36 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9977d0aad..23220717e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ This is a *work in progress* for the next major release. ### Enhancements * Read and write OME-Zarr images (https://github.com/qupath/qupath/pull/1474) * Improved display of annotation names +* Support log10 counts with histograms (no longer use natural log) + * Log counts also now available in measurement tables ### Bugs fixed * Tile export to .ome.tif can convert to 8-bit unnecessarily (https://github.com/qupath/qupath/issues/1494) diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/charts/HistogramChart.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/charts/HistogramChart.java index 1e9330193..cdce049f8 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/charts/HistogramChart.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/charts/HistogramChart.java @@ -86,7 +86,20 @@ public enum CountsTransformMode { *

* Important: HistogramChart cannot currently adjust the tick display to indicate a log scale. */ - LOGARITHM + LOGARITHM; + + public String toString() { + switch (this) { + case RAW: + return "Raw"; + case NORMALIZED: + return "Normalized"; + case LOGARITHM: + return "Log10"; + default: + throw new IllegalArgumentException("Unknown count transform mode: " + this); + } + } } /** @@ -374,7 +387,7 @@ private static double getCount(CountsTransformMode mode, Histogram histogram, in case LOGARITHM: // Count should also be an integer; for the histogram, display 0 as 0 rather than -Infinity double count = histogram.getCountsForBin(bin); - return count == 0 ? 0 : Math.log(count); + return count == 0 ? 0 : Math.log10(count); default: throw new IllegalStateException("Unknown mode: " + mode); } diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/charts/HistogramDisplay.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/charts/HistogramDisplay.java index ad7de8157..f74d095fd 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/charts/HistogramDisplay.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/charts/HistogramDisplay.java @@ -78,10 +78,12 @@ public class HistogramDisplay implements ParameterChangeListener { private String currentColumn = null; private ParameterList params = new ParameterList() - .addBooleanParameter("normalizeCounts", "Normalize counts", false, "Normalize counts (probability distribution)") + .addChoiceParameter("countsTransform", "Counts", + HistogramChart.CountsTransformMode.RAW, Arrays.asList(HistogramChart.CountsTransformMode.values()), + "Normalize counts (probability distribution)") + .addIntParameter("nBins", "Number of bins", 32, null, "Number of histogram bins (>= 2 and <= 1000)") .addBooleanParameter("drawGrid", "Draw grid", true, "Draw grid") .addBooleanParameter("drawAxes", "Draw axes", true, "Draw axes") - .addIntParameter("nBins", "Number of bins", 32, null, "Number of histogram bins (>= 2 and <= 1000)") .addBooleanParameter("animate", "Animate changes", false, "Animate changes"); private TableView> table = new TableView<>(); @@ -248,8 +250,7 @@ else if (nBins > 1000) // histogram.setNormalizeCounts(params.getBooleanParameterValue("normalizeCounts")); HistogramData histogramData = HistogramChart.createHistogramData(histogram, (Integer)null); - boolean doNormalize = params.getBooleanParameterValue("normalizeCounts"); - histogramChart.setCountsTransform(doNormalize ? HistogramChart.CountsTransformMode.NORMALIZED : HistogramChart.CountsTransformMode.RAW); + updateCountsTransform(histogramChart, params); histogramChart.getHistogramData().setAll(histogramData); @@ -276,6 +277,18 @@ else if (nBins > 1000) histogramChart.getHistogramData().clear(); } + private static void updateCountsTransform(HistogramChart histogramChart, ParameterList params) { + var transform = params.getChoiceParameterValue("countsTransform"); + if (transform instanceof HistogramChart.CountsTransformMode mode) { + histogramChart.setCountsTransform(mode); + if (transform == HistogramChart.CountsTransformMode.RAW) + histogramChart.getYAxis().setLabel("Counts"); + else + histogramChart.getYAxis().setLabel("Counts (" + transform + ")"); + } else + logger.warn("Histogram counts transform not supported: {}", transform); + } + /** * Refresh the currently-displayed histogram (e.g. because underlying data has changed). @@ -300,28 +313,15 @@ public void showHistogram(final String column) { @Override public void parameterChanged(ParameterList parameterList, String key, boolean isAdjusting) { - if ("normalizeCounts".equals(key)) { - boolean doNormalize = params.getBooleanParameterValue("normalizeCounts"); - // This is rather clumsy (compared to just updating the histogram data), - // but the reason is that the animations are poor when the data is updated in-place - List list = new ArrayList<>(); - histogramChart.setCountsTransform(doNormalize ? HistogramChart.CountsTransformMode.NORMALIZED : HistogramChart.CountsTransformMode.RAW); - for (HistogramData histogramData : histogramChart.getHistogramData()) { - list.add(new HistogramData(histogramData.getHistogram(), histogramData.getStroke())); - // histogramData.update(); - } - histogramChart.getHistogramData().setAll(list); - return; + if ("countsTransform".equals(key)) { + updateCountsTransform(histogramChart, parameterList); } else if ("drawGrid".equals(key)) { histogramChart.setHorizontalGridLinesVisible(params.getBooleanParameterValue("drawGrid")); histogramChart.setVerticalGridLinesVisible(params.getBooleanParameterValue("drawGrid")); - return; } else if ("drawAxes".equals(key)) { histogramChart.setShowTickLabels(params.getBooleanParameterValue("drawAxes")); - return; } else if ("nBins".equals(key)) { setHistogram(model, comboName.getSelectionModel().getSelectedItem()); - return; } else if ("animate".equals(key)) { histogramChart.setAnimated(params.getBooleanParameterValue("animate")); } From 5315c4459a01e8025f5ae90c73050d59e77b1fe9 Mon Sep 17 00:00:00 2001 From: Pete Date: Wed, 12 Jun 2024 13:29:55 +0100 Subject: [PATCH 090/866] Cache PolygonROI geometries Use SoftReference so these can be garbage collected when memory is low. This helps address a performance bottleneck when polygons need to be repeatedly converted to geometries. --- .../main/java/qupath/lib/roi/PolygonROI.java | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/qupath-core/src/main/java/qupath/lib/roi/PolygonROI.java b/qupath-core/src/main/java/qupath/lib/roi/PolygonROI.java index fc88ce95a..a4ced745b 100644 --- a/qupath-core/src/main/java/qupath/lib/roi/PolygonROI.java +++ b/qupath-core/src/main/java/qupath/lib/roi/PolygonROI.java @@ -28,6 +28,7 @@ import java.io.InvalidObjectException; import java.io.ObjectInputStream; import java.io.Serializable; +import java.lang.ref.SoftReference; import java.util.List; import org.locationtech.jts.geom.Geometry; @@ -54,7 +55,12 @@ public class PolygonROI extends AbstractPathROI implements Serializable { private Vertices vertices; private transient ClosedShapeStatistics stats = null; - + + /** + * Cache a soft reference to the geometry because calculating a valid + * geometry can be a performance bottleneck (e.g. if there are self-intersections). + */ + private transient SoftReference cachedGeometry; PolygonROI() { super(); @@ -123,13 +129,18 @@ public class PolygonROI extends AbstractPathROI implements Serializable { // setPoints(vertices.getPoints()); // TODO: Implement this more efficiency, if it remains... // isAdjusting = false; // } - - + + @Override public Geometry getGeometry() { - // This can be a performance bottleneck - consider caching in the future - // (at least for complex polygons) - return super.getGeometry(); + // Cache a soft reference because converting polygons to + // (valid) geometries can be expensive + var geom = cachedGeometry == null ? null : cachedGeometry.get(); + if (geom == null) { + geom = super.getGeometry(); + cachedGeometry = new SoftReference<>(geom); + } + return geom.copy(); } /** From 85c50dae7f04fb6a343abc35063e08cc76701010 Mon Sep 17 00:00:00 2001 From: Pete Date: Wed, 12 Jun 2024 13:30:13 +0100 Subject: [PATCH 091/866] Create FastPolygonUnion.java Initial code to computer the union of many polygons in a faster way --- .../java/qupath/lib/roi/FastPolygonUnion.java | 246 ++++++++++++++++++ 1 file changed, 246 insertions(+) create mode 100644 qupath-core/src/main/java/qupath/lib/roi/FastPolygonUnion.java diff --git a/qupath-core/src/main/java/qupath/lib/roi/FastPolygonUnion.java b/qupath-core/src/main/java/qupath/lib/roi/FastPolygonUnion.java new file mode 100644 index 000000000..165509b52 --- /dev/null +++ b/qupath-core/src/main/java/qupath/lib/roi/FastPolygonUnion.java @@ -0,0 +1,246 @@ +/*- + * #%L + * This file is part of QuPath. + * %% + * Copyright (C) 2024 QuPath developers, The University of Edinburgh + * %% + * QuPath 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 3 of the + * License, or (at your option) any later version. + * + * QuPath 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 QuPath. If not, see . + * #L% + */ + +package qupath.lib.roi; + +import org.locationtech.jts.geom.Geometry; +import org.locationtech.jts.geom.Polygon; +import org.locationtech.jts.geom.util.PolygonExtracter; +import org.locationtech.jts.index.SpatialIndex; +import org.locationtech.jts.index.hprtree.HPRtree; +import org.locationtech.jts.operation.overlayng.UnaryUnionNG; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.BitSet; +import java.util.Collection; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.IntStream; + +/** + * Compute a faster union of large numbers of polygons. + *

+ * This is a sufficiently common requirement, especially with pixel classification, + * to require its own method. + *

+ * The algorithm is: + *

    + *
  1. + * Extract all polygons from the input. + *
  2. + *
  3. + * Identify intersecting and non-intersecting polygons + *
  4. + *
  5. + * Group all polygons that should potentially be merged, because they intersect + * (directly or indirectly) with other polygons in the group; + * each polygon should be represented in only one group + *
  6. + *
  7. + * Union all the polygon groups + *
  8. + *
  9. + * Combine all resulting polygons into a single polygon or multipolygon + *
  10. + *
+ * This partitioning of the problem makes it possible to parallelize checking for intersections + * and computing the union of groups. + * + * @author Pete Bankhead + */ +public class FastPolygonUnion { + + private static final Logger logger = LoggerFactory.getLogger(FastPolygonUnion.class); + + private static void populateAdjacencyMatrix(List allPolygons, AdjacencyMatrix matrix, SpatialIndex tree, int ind) { + var poly = allPolygons.get(ind); + for (int ind2 : (List)tree.query(poly.getEnvelopeInternal())) { + // Matrix is symmetric, so only test where needed + if (ind2 <= ind || matrix.isAdjacent(ind, ind2)) + continue; + // Check if polygons intersect + var poly2 = allPolygons.get(ind2); + if (poly.intersects(poly2)) { + matrix.setAdjacent(ind, ind2); + } + } + } + + public static Geometry union(Geometry... geoms) { + return union(Arrays.asList(geoms)); + } + + private static List extractAllPolygons(Collection geoms) { + List allPolygons = new ArrayList<>(); + for (var g : geoms) { + PolygonExtracter.getPolygons(g, allPolygons); + } + return allPolygons; + } + + public static Geometry union(Collection geoms) { + List allPolygons = extractAllPolygons(geoms); + + // Create a tree of envelopes and polygon indices + var tree = new HPRtree(); + int n = allPolygons.size(); + for (int i = 0; i < n; i++) { + tree.insert(allPolygons.get(i).getEnvelopeInternal(), i); + } + + // Check for adjacent objects, restricting search using the tree + var matrix = new AdjacencyMatrix(n); + IntStream.range(0, n) + .parallel() + .forEach(i -> populateAdjacencyMatrix(allPolygons, matrix, tree, i)); + + // Gather all the polygons that should be merged + var groupsToMerge = new ArrayList>(); + var toKeep = new ArrayList(); + var visited = new HashSet(); + for (int i = 0; i < n; i++) { + if (matrix.getAdjacentCount(i) == 0) { + // Nothing to merge, keep unchanged + toKeep.add(allPolygons.get(i)); + visited.add(i); + } else if (visited.contains(i)) { + // Already in a group, skip + continue; + } else { + // Generate a new group for merging + List temp = new ArrayList<>(); + for (Integer ind : matrix.getAdjacencyGroup(i)) { + temp.add(allPolygons.get(ind)); + if (!visited.add(ind)) + logger.warn("Polygon added more than once!"); + } + groupsToMerge.add(temp); + } + } + logger.debug("Number of polygon collections to merge: {}", groupsToMerge.size()); + + toKeep.addAll(groupsToMerge.parallelStream() + .map(list -> unionOpNg(list)) + .toList()); + + return createPolygonalGeometry(toKeep); + } + + private static Geometry unionOpNg(Collection geoms) { + var factory = GeometryTools.getDefaultFactory(); + return UnaryUnionNG.union(geoms, factory, factory.getPrecisionModel()); + } + + /** + * Create a polygonal geometry after extracting all polygons from a list of geometries. + * @param geoms + * @return a Polygon or MultiPolygon (may be empty) + */ + private static Geometry createPolygonalGeometry(Collection geoms) { + var list = extractAllPolygons(geoms); + if (list.isEmpty()) + return GeometryTools.getDefaultFactory().createPolygon(); + else if (list.size() == 1) + return list.get(0); + else + return GeometryTools.getDefaultFactory().createMultiPolygon(list.toArray(Polygon[]::new)); + } + + /** + * Simple adjacency matrix to help identify polygons that should be merged. + */ + private static class AdjacencyMatrix { + + private List bits = new ArrayList<>(); + + private AdjacencyMatrix(int n) { + for (int i = 0; i < n; i++) { + bits.add(new BitSet(n)); + } + } + + /** + * Flag that two entries are adjacent. + * @param i + * @param j + * @return true if a change was made, false if the entries were already + * flagged as adjacent. + */ + public boolean setAdjacent(int i, int j) { + if (bits.get(i).get(j)) + return false; + bits.get(i).set(j); + bits.get(j).set(i); + return true; + } + + /** + * Get indices for all entries that are directly or indirectly adjacent, + * i.e. polygons that may need to be merged. + * @param i + * @return + */ + public Set getAdjacencyGroup(int i) { + var set = new HashSet(); + accumulateAdjacencyGroup(i, set); + return set; + } + + /** + * Ensure an item is part of a group; if it has not already been added, + * any additional adjacent items will be added as well. + * @param i + * @param group + */ + private void accumulateAdjacencyGroup(int i, Set group) { + if (group.add(i)) { + for (int j : bits.get(i).stream().toArray()) { + accumulateAdjacencyGroup(j, group); + } + } + } + + /** + * Get the number of directly-adjacent items for the given item index. + * @param i + * @return + */ + public int getAdjacentCount(int i) { + return bits.get(i).cardinality(); + } + + /** + * Query whether two items are flagged as adjacent. + * @param i + * @param j + * @return + */ + public boolean isAdjacent(int i, int j) { + return bits.get(i).get(j); + } + + } + +} From 330ed349a43656b6d7828a11e52c7b2d7bf0a08f Mon Sep 17 00:00:00 2001 From: Pete Date: Wed, 12 Jun 2024 17:44:22 +0100 Subject: [PATCH 092/866] Avoid converting GeometryCollection to Area This was proving a major bottleneck with very complex ROIs (e.g. generated by merging all cells in an image), rendering QuPath unusable until there was an out-of-memory error. The use of JTS' `GeometryCollectionShape` leads to much better performance. We need to also avoid conversion to Area when creating icons. --- .../java/qupath/lib/roi/GeometryTools.java | 91 ++++++++++++++++--- .../qupath/lib/gui/tools/IconFactory.java | 3 +- 2 files changed, 79 insertions(+), 15 deletions(-) diff --git a/qupath-core/src/main/java/qupath/lib/roi/GeometryTools.java b/qupath-core/src/main/java/qupath/lib/roi/GeometryTools.java index 22202a63b..04805a072 100644 --- a/qupath-core/src/main/java/qupath/lib/roi/GeometryTools.java +++ b/qupath-core/src/main/java/qupath/lib/roi/GeometryTools.java @@ -28,6 +28,7 @@ import java.awt.geom.Path2D; import java.awt.geom.PathIterator; import java.awt.geom.Point2D; +import java.awt.geom.Rectangle2D; import java.text.NumberFormat; import java.text.ParseException; import java.util.ArrayList; @@ -1254,28 +1255,23 @@ private ShapeReader getShapeReader() { private ShapeWriter getShapeWriter() { return new ShapeWriter(transformer); -// if (shapeWriter == null) -// shapeWriter = new ShapeWriter(new Transformer()); -// return shapeWriter; } - - // private CoordinateSequence toCoordinates(PolygonROI roi) { - // CoordinateList list = new CoordinateList(); - // for (Point2 p : roi.getPolygonPoints()) - // list.add(new Coordinate(p.getX() * pixelWidth, p.getY() * pixelHeight)); - // return new CoordinateArraySequence(list.toCoordinateArray()); - // } + private Shape geometryToShape(Geometry geometry) { var shape = getShapeWriter().toShape(geometry); - // JTS Shapes can have some odd behavior (e.g. lack of contains method), so convert to Area if that is a suitable match + // JTS Shapes can have some odd behavior (e.g. lack of contains method). + // Previously we converted to Area, but this could have terrible (unusable) performance for + // large and complex shapes - so instead we wrap and implement the missing methods + // using the geometry directly. if (geometry instanceof Polygonal && shape instanceof GeometryCollectionShape) - return new Area(shape); + return new GeometryShapeWrapper(geometry, shape); return shape; } - - private ROI geometryToROI(Geometry geometry, ImagePlane plane) { + + + private ROI geometryToROI(Geometry geometry, ImagePlane plane) { if (geometry.isEmpty()) return ROIs.createEmptyROI(plane); @@ -1375,4 +1371,71 @@ public GeometryConverter build() { } + /** + * Wrapper for a JTS Shape. + * This implements missing methods based on the original geometry, + * to avoid unexpected exceptions when using the shape. + */ + private static class GeometryShapeWrapper implements Shape { + + private final Shape shape; + private final Geometry geometry; + + private GeometryShapeWrapper(Geometry geom, Shape shape) { + this.geometry = geom.copy(); + this.shape = shape; + } + + @Override + public Rectangle getBounds() { + return shape.getBounds(); + } + + @Override + public Rectangle2D getBounds2D() { + return shape.getBounds2D(); + } + + @Override + public boolean contains(double x, double y) { + return SimplePointInAreaLocator.isContained(new Coordinate(x, y), geometry); + } + + @Override + public boolean contains(Point2D p) { + return contains(p.getX(), p.getY()); + } + + @Override + public boolean intersects(double x, double y, double w, double h) { + return geometry.intersects( + GeometryTools.createRectangle(x, y, w, h) + ); + } + + @Override + public boolean intersects(Rectangle2D r) { + return intersects(r.getX(), r.getY(), r.getWidth(), r.getHeight()); + } + + @Override + public boolean contains(double x, double y, double w, double h) { + return false; + } + + @Override + public boolean contains(Rectangle2D r) { + return false; + } + + @Override + public PathIterator getPathIterator(AffineTransform at) { + return shape.getPathIterator(at); + } + + @Override + public PathIterator getPathIterator(AffineTransform at, double flatness) { + return shape.getPathIterator(at, flatness); + } + } } \ No newline at end of file diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/tools/IconFactory.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/tools/IconFactory.java index af4a99495..3e10a692b 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/tools/IconFactory.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/tools/IconFactory.java @@ -749,7 +749,8 @@ public static Node createROIIcon(ROI roi, int width, int height, Color color) { } else { var path = pathCache.getOrDefault(roi, null); if (path == null) { - var shape = roi.isArea() ? RoiTools.getArea(roi) : RoiTools.getShape(roi); + var shape = RoiTools.getShape(roi); +// var shape = roi.isArea() ? RoiTools.getArea(roi) : RoiTools.getShape(roi); if (shape != null) { var transform = new AffineTransform(); transform.translate(-roi.getBoundsX(), -roi.getBoundsY()); From 07f2dc992ada3e379f2b6e7f4449d8ef83e42136 Mon Sep 17 00:00:00 2001 From: Pete Date: Wed, 12 Jun 2024 18:33:19 +0100 Subject: [PATCH 093/866] Code cleanup --- .../src/main/java/qupath/lib/roi/AreaROI.java | 3 +- .../java/qupath/lib/roi/GeometryTools.java | 65 ++++--------------- 2 files changed, 16 insertions(+), 52 deletions(-) diff --git a/qupath-core/src/main/java/qupath/lib/roi/AreaROI.java b/qupath-core/src/main/java/qupath/lib/roi/AreaROI.java index 608ebfbd8..b39b82e85 100644 --- a/qupath-core/src/main/java/qupath/lib/roi/AreaROI.java +++ b/qupath-core/src/main/java/qupath/lib/roi/AreaROI.java @@ -54,8 +54,9 @@ * (e.g. java.awt.Area or some JavaFX alternative.) * * @author Pete Bankhead - * + * @deprecated since v0.6.0 (but really not used in recent versions) */ +@Deprecated public class AreaROI extends AbstractPathROI implements Serializable { // private static final Logger logger = LoggerFactory.getLogger(AreaROI.class); diff --git a/qupath-core/src/main/java/qupath/lib/roi/GeometryTools.java b/qupath-core/src/main/java/qupath/lib/roi/GeometryTools.java index 04805a072..6e54940c1 100644 --- a/qupath-core/src/main/java/qupath/lib/roi/GeometryTools.java +++ b/qupath-core/src/main/java/qupath/lib/roi/GeometryTools.java @@ -22,6 +22,7 @@ package qupath.lib.roi; import java.awt.Shape; +import java.awt.Rectangle; import java.awt.geom.AffineTransform; import java.awt.geom.Area; import java.awt.geom.GeneralPath; @@ -96,7 +97,7 @@ */ public class GeometryTools { - private static Logger logger = LoggerFactory.getLogger(GeometryTools.class); + private static final Logger logger = LoggerFactory.getLogger(GeometryTools.class); private static final GeometryFactory DEFAULT_FACTORY = new GeometryFactory( new PrecisionModel(100.0), @@ -105,7 +106,7 @@ public class GeometryTools { private static final PrecisionModel INTEGER_PRECISION_MODEL = new PrecisionModel(1); - private static GeometryConverter DEFAULT_INSTANCE = new GeometryConverter.Builder() + private static final GeometryConverter DEFAULT_INSTANCE = new GeometryConverter.Builder() .build(); /** @@ -180,32 +181,9 @@ public static AffineTransformation convertTransform(AffineTransform transform) { * @return */ public static Geometry shapeToGeometry(Shape shape) { -// System.err.println("Shape area: " + new ClosedShapeStatistics(shape).getArea()); - var geometry = DEFAULT_INSTANCE.shapeToGeometry(shape); -// System.err.println("Geometry area: " + geometry.getArea()); - return geometry; -// return ShapeReader.read(shape, DEFAULT_INSTANCE.flatness, DEFAULT_INSTANCE.factory); + return DEFAULT_INSTANCE.shapeToGeometry(shape); } - - -// /** -// * Round coordinates in a Geometry to integer values, and constrain to the specified bounding box. -// * @param geometry -// * @param minX -// * @param minY -// * @param maxX -// * @param maxY -// * @return -// */ -// protected Geometry roundAndConstrain(Geometry geometry, double minX, double minY, double maxX, double maxY) { -// geometry = GeometryPrecisionReducer.reduce(geometry, new PrecisionModel(1)); -// geometry = TopologyPreservingSimplifier.simplify(geometry, 0.0); -// geometry = geometry.intersection(GeometryTools.createRectangle(minX, minY, maxX-minX, maxY-minY)); -// return geometry; -//// roundingFilter.setBounds(minX, minY, maxX, maxY); -//// geometry.apply(roundingFilter); -//// return VWSimplifier.simplify(geometry, 0.5); -// } + /** @@ -361,7 +339,7 @@ private static Geometry union(Collection geometries, boolean if (geometries.size() == 1) return geometries.iterator().next(); if (fastUnion) { - double areaSum = geometries.stream().mapToDouble(g -> g.getArea()).sum(); + double areaSum = geometries.stream().mapToDouble(Geometry::getArea).sum(); var union = DEFAULT_INSTANCE.factory.buildGeometry(geometries).buffer(0); double areaUnion = union.getArea(); if (GeneralTools.almostTheSame(areaSum, areaUnion, 0.00001)) { @@ -531,10 +509,6 @@ public static Geometry removeInteriorRings(Geometry geometry, double minRingArea iter.remove(); break; } -// if (PointLocation.isInRing(small.getInteriorPoint().getCoordinate(), ring.getCoordinates())) { -// iter.remove(); -// break; -// } } } } @@ -672,7 +646,7 @@ public static Geometry tryToFixPolygon(Polygon polygon) { if (error == null) return polygon; - logger.debug("Invalid polygon detected! Attempting to correct {}", error.toString()); + logger.debug("Invalid polygon detected! Attempting to correct {}", error); // Area calculations seem to be reliable... even if the topology is invalid double areaBefore = polygon.getArea(); @@ -692,7 +666,7 @@ public static Geometry tryToFixPolygon(Polygon polygon) { if (geomDifference.isValid()) return geomDifference; } catch (Exception e) { - logger.debug("Attempting to fix by difference failed: " + e.getLocalizedMessage(), e); + logger.debug("Attempting to fix by difference failed: {}", e.getMessage(), e); } } @@ -804,10 +778,10 @@ public static List splitGeometryByLineStrings(Geometry polygon, Collec */ public static class GeometryConverter { - private GeometryFactory factory; + private final GeometryFactory factory; - private double pixelHeight, pixelWidth; - private double flatness = 0.1; + private final double pixelHeight, pixelWidth; + private final double flatness; private AffineTransform transform = null; private Transformer transformer; @@ -948,18 +922,7 @@ private static boolean containsClosedSegments(PathIterator iterator) { } private Geometry areaToGeometry(Area shape) { -// Geometry geometry = null; -// if (shape.isSingular()) { -// PathIterator iterator = shape.getPathIterator(transform, flatness); -// geometry = getShapeReader().read(iterator); -// } else { return convertAreaToGeometry(shape, transform, flatness, factory); -// } - // Use simplifier to ensure a valid geometry -// var error = new IsValidOp(geometry).getValidationError(); -// System.err.println(geometry.getArea()); -//// geometry = GeometrySnapper.snapToSelf(geometry, GeometryS, cleanResult) -// return VWSimplifier.simplify(geometry, 0); } @@ -1075,7 +1038,7 @@ private static Geometry convertAreaToGeometryLegacy(final Area area, final Affin default: // Shouldn't happen because of flattened PathIterator throw new RuntimeException("Invalid area computation!"); - }; + } areaTempSigned += 0.5 * (x0 * y1 - x1 * y0); // Add polygon if it has just been closed if (closed && points.size() == 1) { @@ -1168,8 +1131,8 @@ private static Geometry convertAreaToGeometryLegacy(final Area area, final Affin // To do that, we iterate through the holes and try to match these with the containing polygon, updating it accordingly. // By doing this in order we should find the 'correct' containing polygon. var ascendingArea = Comparator.comparingDouble((GeometryWithArea g) -> g.area); - var outerWithArea = outer.stream().map(g -> new GeometryWithArea(g)).sorted(ascendingArea).toList(); - var holesWithArea = holes.stream().map(g -> new GeometryWithArea(g)).sorted(ascendingArea).toList(); + var outerWithArea = outer.stream().map(GeometryWithArea::new).sorted(ascendingArea).toList(); + var holesWithArea = holes.stream().map(GeometryWithArea::new).sorted(ascendingArea).toList(); // For each hole, find the smallest polygon that contains it Map> matches = new HashMap<>(); From 09113808839197fb98292a27a44ad3b2140e68de Mon Sep 17 00:00:00 2001 From: Pete Date: Wed, 12 Jun 2024 19:10:23 +0100 Subject: [PATCH 094/866] Use FastPolygonUnion by default Also add new methods to `GeometryTools` to create lines, and union from varargs. --- .../java/qupath/lib/roi/FastPolygonUnion.java | 67 ++++++++---- .../java/qupath/lib/roi/GeometryTools.java | 100 +++++++++++------- .../qupath/lib/roi/TestGeometryTools.java | 39 +++++++ 3 files changed, 143 insertions(+), 63 deletions(-) diff --git a/qupath-core/src/main/java/qupath/lib/roi/FastPolygonUnion.java b/qupath-core/src/main/java/qupath/lib/roi/FastPolygonUnion.java index 165509b52..b8b1d5bc4 100644 --- a/qupath-core/src/main/java/qupath/lib/roi/FastPolygonUnion.java +++ b/qupath-core/src/main/java/qupath/lib/roi/FastPolygonUnion.java @@ -74,33 +74,24 @@ public class FastPolygonUnion { private static final Logger logger = LoggerFactory.getLogger(FastPolygonUnion.class); - private static void populateAdjacencyMatrix(List allPolygons, AdjacencyMatrix matrix, SpatialIndex tree, int ind) { - var poly = allPolygons.get(ind); - for (int ind2 : (List)tree.query(poly.getEnvelopeInternal())) { - // Matrix is symmetric, so only test where needed - if (ind2 <= ind || matrix.isAdjacent(ind, ind2)) - continue; - // Check if polygons intersect - var poly2 = allPolygons.get(ind2); - if (poly.intersects(poly2)) { - matrix.setAdjacent(ind, ind2); - } - } - } - + /** + * Compute a union of all polygons contained in an array of geometries. + * Non-polygon geometries will be ignored. + * @param geoms + * @return the union of polygons, or an empty polygon if no geometries are provided + */ public static Geometry union(Geometry... geoms) { return union(Arrays.asList(geoms)); } - private static List extractAllPolygons(Collection geoms) { - List allPolygons = new ArrayList<>(); - for (var g : geoms) { - PolygonExtracter.getPolygons(g, allPolygons); - } - return allPolygons; - } - + /** + * Compute a union of all polygons contained in a collection of geometries. + * Non-polygon geometries will be ignored. + * @param geoms + * @return the union of polygons, or an empty polygon if no geometries are provided + */ public static Geometry union(Collection geoms) { + logger.trace("Calling union for {} geometries", geoms.size()); List allPolygons = extractAllPolygons(geoms); // Create a tree of envelopes and polygon indices @@ -148,6 +139,24 @@ public static Geometry union(Collection geoms) { return createPolygonalGeometry(toKeep); } + /** + * Extract all polygons from a collection of geometries. + * @param geoms + * @return + */ + private static List extractAllPolygons(Collection geoms) { + List allPolygons = new ArrayList<>(); + for (var g : geoms) { + PolygonExtracter.getPolygons(g, allPolygons); + } + return allPolygons; + } + + /** + * Create a standard union with JTS. + * @param geoms + * @return + */ private static Geometry unionOpNg(Collection geoms) { var factory = GeometryTools.getDefaultFactory(); return UnaryUnionNG.union(geoms, factory, factory.getPrecisionModel()); @@ -168,6 +177,20 @@ else if (list.size() == 1) return GeometryTools.getDefaultFactory().createMultiPolygon(list.toArray(Polygon[]::new)); } + private static void populateAdjacencyMatrix(List allPolygons, AdjacencyMatrix matrix, SpatialIndex tree, int ind) { + var poly = allPolygons.get(ind); + for (int ind2 : (List)tree.query(poly.getEnvelopeInternal())) { + // Matrix is symmetric, so only test where needed + if (ind2 <= ind || matrix.isAdjacent(ind, ind2)) + continue; + // Check if polygons intersect + var poly2 = allPolygons.get(ind2); + if (poly.intersects(poly2)) { + matrix.setAdjacent(ind, ind2); + } + } + } + /** * Simple adjacency matrix to help identify polygons that should be merged. */ diff --git a/qupath-core/src/main/java/qupath/lib/roi/GeometryTools.java b/qupath-core/src/main/java/qupath/lib/roi/GeometryTools.java index 6e54940c1..0e6347687 100644 --- a/qupath-core/src/main/java/qupath/lib/roi/GeometryTools.java +++ b/qupath-core/src/main/java/qupath/lib/roi/GeometryTools.java @@ -74,8 +74,8 @@ import org.locationtech.jts.geom.util.PolygonExtracter; import org.locationtech.jts.index.quadtree.Quadtree; import org.locationtech.jts.operation.overlay.snap.GeometrySnapper; +import org.locationtech.jts.operation.overlayng.UnaryUnionNG; import org.locationtech.jts.operation.polygonize.Polygonizer; -import org.locationtech.jts.operation.union.UnaryUnionOp; import org.locationtech.jts.operation.valid.IsValidOp; import org.locationtech.jts.operation.valid.TopologyValidationError; import org.locationtech.jts.precision.GeometryPrecisionReducer; @@ -261,7 +261,7 @@ public static Geometry constrainToBounds(Geometry geometry, double x, double y, * @param height * @return */ - public static Geometry createRectangle(double x, double y, double width, double height) { + public static Polygon createRectangle(double x, double y, double width, double height) { var shapeFactory = new GeometricShapeFactory(DEFAULT_FACTORY); shapeFactory.setNumPoints(4); // Probably 5, but should be increased automatically shapeFactory.setEnvelope( @@ -270,6 +270,32 @@ public static Geometry createRectangle(double x, double y, double width, double ); return shapeFactory.createRectangle(); } + + + /** + * Create a line Geometry for the specified end points. + * @param x1 + * @param y1 + * @param x2 + * @param y2 + * @return + * @since v0.6.0 + */ + public static LineString createLineString(double x1, double y1, double x2, double y2) { + return createLineString(new Point2(x1, y1), new Point2(x2, y2)); + } + + /** + * Create a line Geometry for the specified array of points. + * @param points + * @return + * @since v0.6.0 + */ + public static LineString createLineString(Point2... points) { + return getDefaultFactory().createLineString( + Arrays.stream(points).map(p -> new Coordinate(p.getX(), p.getY())).toArray(Coordinate[]::new) + ); + } /** @@ -315,50 +341,42 @@ public static Geometry regionToGeometry(ImageRegion region) { coords[4] = coords[0]; return DEFAULT_INSTANCE.factory.createPolygon(coords); } - - - /** + + /** + * Calculate the union of multiple Geometry objects. + * @param geometries + * @return + * @since v0.6.0 + */ + public static Geometry union(Geometry... geometries) { + return union(Arrays.asList(geometries)); + } + + /** * Calculate the union of multiple Geometry objects. * @param geometries * @return + * @implNote since v0.6.0 this uses {@link FastPolygonUnion} for merging polygons. */ public static Geometry union(Collection geometries) { - return union(geometries, false); - } - - - /** - * Calculate the union of multiple Geometry objects. - * @param geometries - * @param fastUnion if true, it can be assumed that the Geometries are valid and cannot overlap. This may permit a faster union operation. - * @return - */ - private static Geometry union(Collection geometries, boolean fastUnion) { if (geometries.isEmpty()) - return DEFAULT_INSTANCE.factory.createPolygon(); + return getDefaultFactory().createPolygon(); if (geometries.size() == 1) return geometries.iterator().next(); - if (fastUnion) { - double areaSum = geometries.stream().mapToDouble(Geometry::getArea).sum(); - var union = DEFAULT_INSTANCE.factory.buildGeometry(geometries).buffer(0); - double areaUnion = union.getArea(); - if (GeneralTools.almostTheSame(areaSum, areaUnion, 0.00001)) { - return union; - } - logger.warn("Fast union failed with different areas ({} before vs {} after)", areaSum, areaUnion); - } - try { - return UnaryUnionOp.union(geometries); - } catch (Exception e) { - // Throw exception if we have no other options - if (fastUnion) - throw e; - else { - // Try again with other path - logger.warn("Exception attempting default union: {}", e.getLocalizedMessage()); - return union(geometries, true); - } - } + try { + if (geometries.size() > 2 || geometries.stream().allMatch(g -> g instanceof Polygonal)) { + // If we have multiple polygonal geometries, do things the 'fast' way + // (which may admittedly be slightly slower in some cases, but orders of magnitude faster in others) + return FastPolygonUnion.union(geometries); + } else { + // Standard union operation + logger.trace("Calling UnaryUnionNG for {} geometries", geometries.size()); + return UnaryUnionNG.union(new ArrayList<>(geometries), getDefaultFactory().getPrecisionModel()); + } + } catch (Exception e) { + logger.warn("Geometry union failed - attempting with buffer(0)"); + return getDefaultFactory().buildGeometry(geometries).buffer(0); + } } /** @@ -1120,12 +1138,12 @@ private static Geometry convertAreaToGeometryLegacy(final Area area, final Affin Geometry geometryOuter; if (holes.isEmpty()) { // If we have no holes, just use the outer geometry - geometryOuter = union(outer, true); + geometryOuter = union(outer); geometry = geometryOuter; } else if (outer.size() == 1) { // If we just have one outer geometry, remove all the holes - geometryOuter = union(outer, true); - geometry = geometryOuter.difference(union(holes, true)); + geometryOuter = union(outer); + geometry = geometryOuter.difference(union(holes)); } else { // We need to handle holes... and, in particular, additional objects that may be nested within holes. // To do that, we iterate through the holes and try to match these with the containing polygon, updating it accordingly. diff --git a/qupath-core/src/test/java/qupath/lib/roi/TestGeometryTools.java b/qupath-core/src/test/java/qupath/lib/roi/TestGeometryTools.java index bd1cd5f17..4008b34ec 100644 --- a/qupath-core/src/test/java/qupath/lib/roi/TestGeometryTools.java +++ b/qupath-core/src/test/java/qupath/lib/roi/TestGeometryTools.java @@ -133,6 +133,45 @@ public void testComplexROIs() { } } + + @Test + public void testUnion() { + var g1 = GeometryTools.createRectangle(0, 0, 100, 200).norm(); + var gBeside = GeometryTools.createRectangle(100, 0, 100, 200).norm(); + var gDiagonal = GeometryTools.createRectangle(100, 200, 100, 200).norm(); + var gDisconnected = GeometryTools.createRectangle(400, 400, 100, 200).norm(); + var gOverlapping = GeometryTools.createRectangle(0, 100, 100, 200).norm(); + var gLine = GeometryTools.createLineString(1000, 2000, 3000, 4000).norm(); + + assertEquals(g1.copy().norm(), GeometryTools.union(g1, g1.copy()).norm()); + assertEquals(g1.copy().norm(), FastPolygonUnion.union(g1, g1.copy()).norm()); + assertEquals(1, FastPolygonUnion.union(g1, g1).getNumGeometries()); + + assertEquals(g1.union(gBeside).norm(), GeometryTools.union(g1, gBeside).norm()); + assertEquals(g1.union(gBeside).norm(), FastPolygonUnion.union(g1, gBeside).norm()); + assertEquals(1, FastPolygonUnion.union(g1, gBeside).getNumGeometries()); + + assertEquals(g1.union(gDiagonal).norm(), GeometryTools.union(g1, gDiagonal).norm()); + assertEquals(g1.union(gDiagonal).norm(), FastPolygonUnion.union(g1, gDiagonal).norm()); + assertEquals(2, FastPolygonUnion.union(g1, gDiagonal).getNumGeometries()); + + assertEquals(g1.union(gDisconnected).norm(), GeometryTools.union(g1, gDisconnected).norm()); + assertEquals(g1.union(gDisconnected).norm(), FastPolygonUnion.union(g1, gDisconnected).norm()); + assertEquals(2, FastPolygonUnion.union(g1, gDisconnected).getNumGeometries()); + + assertEquals(g1.union(gOverlapping).norm(), GeometryTools.union(g1, gOverlapping).norm()); + assertEquals(g1.union(gOverlapping).norm(), FastPolygonUnion.union(g1, gOverlapping).norm()); + assertEquals(1, FastPolygonUnion.union(g1, gOverlapping).getNumGeometries()); + + var unionAll = g1.union(gOverlapping).union(gDisconnected).union(gBeside).union(gDiagonal).norm(); + assertEquals(unionAll, GeometryTools.union(g1, gOverlapping, gDisconnected, gBeside, gDiagonal).norm()); + assertEquals(unionAll, FastPolygonUnion.union(g1, gOverlapping, gDisconnected, gBeside, gDiagonal).norm()); + + // We can compute a union with a line string + assertEquals(2, GeometryTools.union(g1, gLine).getNumGeometries()); + // The fast polygon union discards lines + assertEquals(1, FastPolygonUnion.union(g1, gLine).getNumGeometries()); + } } \ No newline at end of file From ad560b2734d1f1d9d5da7143e8d7780b10f4e448 Mon Sep 17 00:00:00 2001 From: Alan O'Callaghan Date: Mon, 17 Jun 2024 17:00:41 +0100 Subject: [PATCH 095/866] Use shortcut+shift+w instead of shortcut+q --- .../main/java/qupath/lib/gui/scripting/DefaultScriptEditor.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/scripting/DefaultScriptEditor.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/scripting/DefaultScriptEditor.java index cfd4a07b2..5820e675c 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/scripting/DefaultScriptEditor.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/scripting/DefaultScriptEditor.java @@ -1980,7 +1980,7 @@ Action createExitAction(final String name) { requestClose(); e.consume(); }); - action.setAccelerator(new KeyCodeCombination(KeyCode.Q, KeyCombination.SHORTCUT_DOWN)); + action.setAccelerator(new KeyCodeCombination(KeyCode.W, KeyCombination.SHORTCUT_DOWN, KeyCombination.CONTROL_DOWN)); return action; } From e27d99ab387470da62d64bd59357d3480b825e52 Mon Sep 17 00:00:00 2001 From: Alan O'Callaghan Date: Wed, 19 Jun 2024 11:29:50 +0100 Subject: [PATCH 096/866] Update DefaultScriptEditor.java --- .../main/java/qupath/lib/gui/scripting/DefaultScriptEditor.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/scripting/DefaultScriptEditor.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/scripting/DefaultScriptEditor.java index 5820e675c..a5ba527ec 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/scripting/DefaultScriptEditor.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/scripting/DefaultScriptEditor.java @@ -1980,7 +1980,7 @@ Action createExitAction(final String name) { requestClose(); e.consume(); }); - action.setAccelerator(new KeyCodeCombination(KeyCode.W, KeyCombination.SHORTCUT_DOWN, KeyCombination.CONTROL_DOWN)); + action.setAccelerator(new KeyCodeCombination(KeyCode.W, KeyCombination.SHORTCUT_DOWN, KeyCombination.SHIFT_DOWN)); return action; } From c4a858c31ae4d1f93b55b253eb47232ef2e82e1a Mon Sep 17 00:00:00 2001 From: Pete Date: Wed, 19 Jun 2024 18:02:26 +0100 Subject: [PATCH 097/866] Log reason for fast union failure See https://forum.image.sc/t/can-creating-detections-from-pixel-classifier-be-made-to-run-faster/96745/27 --- qupath-core/src/main/java/qupath/lib/roi/GeometryTools.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qupath-core/src/main/java/qupath/lib/roi/GeometryTools.java b/qupath-core/src/main/java/qupath/lib/roi/GeometryTools.java index 0e6347687..e69318685 100644 --- a/qupath-core/src/main/java/qupath/lib/roi/GeometryTools.java +++ b/qupath-core/src/main/java/qupath/lib/roi/GeometryTools.java @@ -374,7 +374,7 @@ public static Geometry union(Collection geometries) { return UnaryUnionNG.union(new ArrayList<>(geometries), getDefaultFactory().getPrecisionModel()); } } catch (Exception e) { - logger.warn("Geometry union failed - attempting with buffer(0)"); + logger.warn("Geometry union failed - attempting with buffer(0)", e); return getDefaultFactory().buildGeometry(geometries).buffer(0); } } From eff467a9477ce7149e5ad501828cc0d0bd736953 Mon Sep 17 00:00:00 2001 From: Pete Date: Fri, 21 Jun 2024 13:21:27 +0100 Subject: [PATCH 098/866] Work around Cmd+Q behavior on macOS Introduce script editor flag to indicate that quit was cancelled. This is to improve behavior on macOS, where pressing Cmd+Q would result in the user being prompted twice about unsaved changes if they chose 'Cancel'. See code comments for more details. --- .../gui/scripting/DefaultScriptEditor.java | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/scripting/DefaultScriptEditor.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/scripting/DefaultScriptEditor.java index a5ba527ec..7af759f65 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/scripting/DefaultScriptEditor.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/scripting/DefaultScriptEditor.java @@ -166,7 +166,19 @@ public class DefaultScriptEditor implements ScriptEditor { private SplitPane splitMain; private ToggleGroup toggleLanguages = new ToggleGroup(); private Font fontMain = Font.font("Courier"); - + + /** + * Flag that quit was cancelled. + * This is a workaround for the fact that (on macOS at least) Cmd+Q triggers a close request on all windows, + * which can't be cancelled. + * If the user cancels quitting because of unsaved changes, the window close requests continue anyway - leading to + * the 'Save changes?' dialog being shown again. + * This flag is set to true if the quit was cancelled recently, and then reset by Platform.runLater(). + * The purpose is to give an indication that the user has already requested to cancel quitting, and shouldn't be + * immediately prompted again. + */ + private boolean quitCancelled = false; + /** * Pane to hold the main code component in its center */ @@ -514,6 +526,11 @@ public boolean supportsFile(final File file) { @Override public boolean requestClose() { + // If the quit was cancelled, don't prompt again + if (quitCancelled) { + logger.debug("Script editor quit was cancelled, won't close or prompt again"); + return false; + } var ret = true; while (ret) { var tab = getCurrentScriptTab(); @@ -524,6 +541,10 @@ public boolean requestClose() { } if (ret && dialog != null) { dialog.close(); + } else if (!ret) { + quitCancelled = true; + logger.trace("Script Editor quit was cancelled"); + Platform.runLater(() -> quitCancelled = false); } return ret; } From 67fba76fedcd350d07aac2b9199e65bd9d7bfc5e Mon Sep 17 00:00:00 2001 From: Pete Date: Fri, 21 Jun 2024 14:50:02 +0100 Subject: [PATCH 099/866] Close windows from keyboard Initial PR to test https://github.com/qupath/qupath-fxtras/pull/34 in action. Applied only to a few relevant windows for now. --- .../src/main/java/qupath/lib/gui/ParameterDialogWrapper.java | 2 ++ .../java/qupath/lib/gui/commands/BrightnessContrastCommand.java | 2 ++ .../main/java/qupath/lib/gui/commands/ContextHelpViewer.java | 2 ++ 3 files changed, 6 insertions(+) diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/ParameterDialogWrapper.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/ParameterDialogWrapper.java index 221196882..9fb13e25f 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/ParameterDialogWrapper.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/ParameterDialogWrapper.java @@ -42,6 +42,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import qupath.fx.dialogs.Dialogs; +import qupath.fx.utils.FXUtils; import qupath.lib.gui.dialogs.ParameterPanelFX; import qupath.lib.gui.tools.GuiTools; import qupath.lib.images.ImageData; @@ -102,6 +103,7 @@ public void showDialog() { dialog.centerOnScreen(); } dialog.toFront(); + FXUtils.addCloseWindowShortcuts(dialog); dialog.requestFocus(); Platform.runLater(() -> dialog.requestFocus()); diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/BrightnessContrastCommand.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/BrightnessContrastCommand.java index 4ddcd9d5f..ea79476d9 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/BrightnessContrastCommand.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/BrightnessContrastCommand.java @@ -60,6 +60,7 @@ import jfxtras.scene.layout.HBox; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import qupath.fx.utils.FXUtils; import qupath.fx.utils.GridPaneUtils; import qupath.lib.display.ChannelDisplayInfo; import qupath.lib.display.ImageDisplay; @@ -282,6 +283,7 @@ private Stage createDialog() { dialog.setMinWidth(300); dialog.setMinHeight(600); // dialog.setMaxWidth(600); + FXUtils.addCloseWindowShortcuts(dialog); table.updateTable(); diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/ContextHelpViewer.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/ContextHelpViewer.java index 69466c131..2ee799ba0 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/ContextHelpViewer.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/ContextHelpViewer.java @@ -55,6 +55,7 @@ import javafx.scene.text.TextAlignment; import javafx.stage.Stage; import javafx.stage.Window; +import qupath.fx.utils.FXUtils; import qupath.fx.utils.GridPaneUtils; import qupath.lib.display.ImageDisplay; import qupath.lib.gui.QuPathGUI; @@ -160,6 +161,7 @@ private ContextHelpViewer(QuPathGUI qupath) { createHelpLabels(); stage = createStage(new Scene(splitPane)); + FXUtils.addCloseWindowShortcuts(stage); } private void imageDataChanged(ObservableValue> observable, From 498246d97d77577d9fd48b604c7a011ca8a078df Mon Sep 17 00:00:00 2001 From: Pete Date: Fri, 21 Jun 2024 15:09:44 +0100 Subject: [PATCH 100/866] Close shortcuts for more windows There are a lot... --- .../qupath/imagej/gui/ImageJMacroRunner.java | 4 +++- .../CreateRegionAnnotationsCommand.java | 2 ++ .../commands/ExportTrainingRegionsCommand.java | 2 ++ .../gui/commands/ObjectClassifierCommand.java | 2 ++ .../commands/ObjectClassifierLoadCommand.java | 2 ++ .../gui/commands/SimpleThresholdCommand.java | 1 + .../gui/commands/density/DensityMapDialog.java | 1 + .../gui/commands/ml/PixelClassifierPane.java | 1 + .../gui/commands/ui/LoadResourceCommand.java | 2 ++ .../qupath/lib/gui/charts/ExportChartPane.java | 2 ++ .../gui/commands/BrightnessContrastCommand.java | 1 + .../java/qupath/lib/gui/commands/Commands.java | 5 +++++ .../lib/gui/commands/ContextHelpViewer.java | 6 +++--- .../lib/gui/commands/CountingDialogCommand.java | 4 +++- .../lib/gui/commands/MemoryMonitorDialog.java | 4 +++- .../qupath/lib/gui/commands/MiniViewers.java | 3 ++- .../lib/gui/commands/PathObjectGridView.java | 1 + .../lib/gui/commands/RotateImageCommand.java | 2 ++ .../lib/gui/commands/ScriptInterpreter.java | 2 ++ .../ShowInstalledExtensionsCommand.java | 1 + .../lib/gui/commands/ShowLicensesCommand.java | 2 ++ .../lib/gui/commands/ShowSystemInfoCommand.java | 2 ++ .../SummaryMeasurementTableCommand.java | 2 ++ .../lib/gui/panes/ObjectDescriptionPane.java | 2 ++ .../qupath/lib/gui/panes/SimpleImageViewer.java | 2 ++ .../lib/gui/panes/WorkflowCommandLogView.java | 2 ++ .../lib/gui/scripting/ScriptFindCommand.java | 2 ++ .../qupath/lib/gui/tma/KaplanMeierDisplay.java | 2 ++ .../java/qupath/lib/gui/tma/TMAExplorer.java | 2 ++ .../qupath/lib/gui/tma/TMASummaryViewer.java | 6 ++++-- .../lib/gui/tools/CommandFinderTools.java | 17 ++++------------- .../qupath/lib/gui/viewer/ViewerManager.java | 1 + .../recording/ViewTrackerAnalysisCommand.java | 2 ++ .../recording/ViewTrackerControlPane.java | 2 ++ 34 files changed, 72 insertions(+), 22 deletions(-) diff --git a/qupath-extension-processing/src/main/java/qupath/imagej/gui/ImageJMacroRunner.java b/qupath-extension-processing/src/main/java/qupath/imagej/gui/ImageJMacroRunner.java index 2894aafd3..fcc5f73fa 100644 --- a/qupath-extension-processing/src/main/java/qupath/imagej/gui/ImageJMacroRunner.java +++ b/qupath-extension-processing/src/main/java/qupath/imagej/gui/ImageJMacroRunner.java @@ -51,6 +51,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import qupath.fx.utils.FXUtils; import qupath.imagej.tools.IJTools; import qupath.lib.display.ImageDisplay; import qupath.lib.gui.QuPathGUI; @@ -124,7 +125,8 @@ public boolean runPlugin(final TaskRunner runner, final ImageData dialog = new Stage(); dialog.initOwner(qupath.getStage()); dialog.setTitle("ImageJ macro runner"); - + FXUtils.addCloseWindowShortcuts(dialog); + BorderPane pane = new BorderPane(); if (arg != null) diff --git a/qupath-extension-processing/src/main/java/qupath/process/gui/commands/CreateRegionAnnotationsCommand.java b/qupath-extension-processing/src/main/java/qupath/process/gui/commands/CreateRegionAnnotationsCommand.java index 7f49d208a..95e746be2 100644 --- a/qupath-extension-processing/src/main/java/qupath/process/gui/commands/CreateRegionAnnotationsCommand.java +++ b/qupath-extension-processing/src/main/java/qupath/process/gui/commands/CreateRegionAnnotationsCommand.java @@ -36,6 +36,7 @@ import javafx.scene.layout.GridPane; import javafx.scene.layout.Pane; import javafx.stage.Stage; +import qupath.fx.utils.FXUtils; import qupath.lib.common.GeneralTools; import qupath.lib.gui.QuPathGUI; import qupath.fx.dialogs.Dialogs; @@ -78,6 +79,7 @@ public void run() { stage.initOwner(qupath.getStage()); stage.setTitle("Create regions"); stage.setScene(new Scene(RegionMaker.create(qupath).getPane())); + FXUtils.addCloseWindowShortcuts(stage); } stage.show(); } diff --git a/qupath-extension-processing/src/main/java/qupath/process/gui/commands/ExportTrainingRegionsCommand.java b/qupath-extension-processing/src/main/java/qupath/process/gui/commands/ExportTrainingRegionsCommand.java index 17ffa3570..c61196943 100644 --- a/qupath-extension-processing/src/main/java/qupath/process/gui/commands/ExportTrainingRegionsCommand.java +++ b/qupath-extension-processing/src/main/java/qupath/process/gui/commands/ExportTrainingRegionsCommand.java @@ -78,6 +78,7 @@ import javafx.scene.layout.Priority; import javafx.stage.Stage; import qupath.fx.dialogs.FileChoosers; +import qupath.fx.utils.FXUtils; import qupath.imagej.tools.IJTools; import qupath.lib.common.ColorTools; import qupath.lib.common.GeneralTools; @@ -132,6 +133,7 @@ public void run() { stage.setTitle("Export training"); exporter = new TrainingRegionExporter(qupath); stage.setScene(new Scene(exporter.getPane())); + FXUtils.addCloseWindowShortcuts(stage); // } stage.show(); } diff --git a/qupath-extension-processing/src/main/java/qupath/process/gui/commands/ObjectClassifierCommand.java b/qupath-extension-processing/src/main/java/qupath/process/gui/commands/ObjectClassifierCommand.java index 5d4a7c5a4..fb8f9ff22 100644 --- a/qupath-extension-processing/src/main/java/qupath/process/gui/commands/ObjectClassifierCommand.java +++ b/qupath-extension-processing/src/main/java/qupath/process/gui/commands/ObjectClassifierCommand.java @@ -103,6 +103,7 @@ import javafx.stage.Stage; import qupath.fx.controls.PredicateTextField; import qupath.fx.dialogs.FileChoosers; +import qupath.fx.utils.FXUtils; import qupath.lib.classifiers.Normalization; import qupath.lib.classifiers.object.ObjectClassifier; import qupath.lib.classifiers.object.ObjectClassifiers; @@ -168,6 +169,7 @@ public ObjectClassifierCommand(final QuPathGUI qupath) { public void run() { if (dialog == null) { dialog = new Stage(); + FXUtils.addCloseWindowShortcuts(dialog); if (qupath != null) dialog.initOwner(qupath.getStage()); dialog.setTitle(name); diff --git a/qupath-extension-processing/src/main/java/qupath/process/gui/commands/ObjectClassifierLoadCommand.java b/qupath-extension-processing/src/main/java/qupath/process/gui/commands/ObjectClassifierLoadCommand.java index 0b95e15ed..3b1b650af 100644 --- a/qupath-extension-processing/src/main/java/qupath/process/gui/commands/ObjectClassifierLoadCommand.java +++ b/qupath-extension-processing/src/main/java/qupath/process/gui/commands/ObjectClassifierLoadCommand.java @@ -49,6 +49,7 @@ import javafx.scene.text.TextAlignment; import javafx.stage.Stage; import qupath.fx.dialogs.FileChoosers; +import qupath.fx.utils.FXUtils; import qupath.lib.classifiers.object.ObjectClassifier; import qupath.lib.classifiers.object.ObjectClassifiers; import qupath.lib.common.GeneralTools; @@ -244,6 +245,7 @@ public void run() { GridPaneUtils.setMaxWidth(Double.MAX_VALUE, listClassifiers, btnApplyClassifier); var stage = new Stage(); + FXUtils.addCloseWindowShortcuts(stage); stage.setTitle(title); stage.setScene(new Scene(pane)); stage.initOwner(qupath.getStage()); diff --git a/qupath-extension-processing/src/main/java/qupath/process/gui/commands/SimpleThresholdCommand.java b/qupath-extension-processing/src/main/java/qupath/process/gui/commands/SimpleThresholdCommand.java index 2c1bc3611..c95b83b01 100644 --- a/qupath-extension-processing/src/main/java/qupath/process/gui/commands/SimpleThresholdCommand.java +++ b/qupath-extension-processing/src/main/java/qupath/process/gui/commands/SimpleThresholdCommand.java @@ -328,6 +328,7 @@ private void showGUI() { pane.setMaxSize(GridPane.USE_COMPUTED_SIZE, GridPane.USE_COMPUTED_SIZE); stage = new Stage(); + FXUtils.addCloseWindowShortcuts(stage); stage.setTitle("Create thresholder"); stage.initOwner(qupath.getStage()); stage.setScene(new Scene(pane)); diff --git a/qupath-extension-processing/src/main/java/qupath/process/gui/commands/density/DensityMapDialog.java b/qupath-extension-processing/src/main/java/qupath/process/gui/commands/density/DensityMapDialog.java index 33259bd75..1e6e9e7b1 100644 --- a/qupath-extension-processing/src/main/java/qupath/process/gui/commands/density/DensityMapDialog.java +++ b/qupath-extension-processing/src/main/java/qupath/process/gui/commands/density/DensityMapDialog.java @@ -190,6 +190,7 @@ public DensityMapDialog(QuPathGUI qupath) { pane.setPadding(new Insets(10)); stage = new Stage(); + FXUtils.addCloseWindowShortcuts(stage); stage.setScene(new Scene(pane)); stage.setResizable(false); stage.initOwner(qupath.getStage()); diff --git a/qupath-extension-processing/src/main/java/qupath/process/gui/commands/ml/PixelClassifierPane.java b/qupath-extension-processing/src/main/java/qupath/process/gui/commands/ml/PixelClassifierPane.java index fbe47b5be..56ac4799b 100644 --- a/qupath-extension-processing/src/main/java/qupath/process/gui/commands/ml/PixelClassifierPane.java +++ b/qupath-extension-processing/src/main/java/qupath/process/gui/commands/ml/PixelClassifierPane.java @@ -546,6 +546,7 @@ public void updateItem(String value, boolean empty) { pane.setPadding(new Insets(5)); stage = new Stage(); + FXUtils.addCloseWindowShortcuts(stage); stage.setScene(new Scene(fullPane)); stage.setMinHeight(400); diff --git a/qupath-extension-processing/src/main/java/qupath/process/gui/commands/ui/LoadResourceCommand.java b/qupath-extension-processing/src/main/java/qupath/process/gui/commands/ui/LoadResourceCommand.java index 3516ddfd2..c942dee32 100644 --- a/qupath-extension-processing/src/main/java/qupath/process/gui/commands/ui/LoadResourceCommand.java +++ b/qupath-extension-processing/src/main/java/qupath/process/gui/commands/ui/LoadResourceCommand.java @@ -52,6 +52,7 @@ import javafx.stage.Modality; import javafx.stage.Stage; import qupath.fx.dialogs.FileChoosers; +import qupath.fx.utils.FXUtils; import qupath.lib.analysis.heatmaps.DensityMaps; import qupath.lib.analysis.heatmaps.DensityMaps.DensityMapBuilder; import qupath.lib.classifiers.pixel.PixelClassifier; @@ -306,6 +307,7 @@ public void run() { stage.initOwner(qupath.getStage()); stage.sizeToScene(); stage.setResizable(false); + FXUtils.addCloseWindowShortcuts(stage); stage.show(); stage.setOnHiding(e -> { diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/charts/ExportChartPane.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/charts/ExportChartPane.java index 13e8ac50b..e475d8e03 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/charts/ExportChartPane.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/charts/ExportChartPane.java @@ -79,6 +79,7 @@ import qupath.fx.dialogs.FileChoosers; import qupath.fx.prefs.controlsfx.PropertyItemBuilder; import qupath.fx.prefs.controlsfx.PropertySheetUtils; +import qupath.fx.utils.FXUtils; import qupath.lib.common.GeneralTools; import qupath.fx.dialogs.Dialogs; import qupath.lib.gui.prefs.PathPrefs; @@ -427,6 +428,7 @@ public static ExportChartPane showExportChartDialog(final Chart chart, final Cal ExportChartPane panel = new ExportChartPane(duplicator == null ? chart : duplicator.call(chart)); Scene scene = new Scene(panel.getPane()); Stage stage = new Stage(); + FXUtils.addCloseWindowShortcuts(stage); stage.setTitle("Export chart"); stage.setScene(scene); stage.show(); diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/BrightnessContrastCommand.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/BrightnessContrastCommand.java index ea79476d9..2cb616239 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/BrightnessContrastCommand.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/BrightnessContrastCommand.java @@ -204,6 +204,7 @@ private Stage createDialog() { Stage dialog = new Stage(); dialog.initOwner(qupath.getStage()); + FXUtils.addCloseWindowShortcuts(dialog); dialog.setTitle("Brightness & contrast"); GridPane pane = new GridPane(); diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/Commands.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/Commands.java index 4952f0099..d53b52910 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/Commands.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/Commands.java @@ -603,6 +603,7 @@ public static Stage createMeasurementMapDialog(QuPathGUI qupath) { var dialog = new Stage(); if (qupath != null) dialog.initOwner(qupath.getStage()); + FXUtils.addCloseWindowShortcuts(dialog); dialog.setTitle("Measurement maps"); var panel = new MeasurementMapPane(qupath); @@ -668,6 +669,7 @@ public static Stage createPreferencesDialog(QuPathGUI qupath) { var dialog = new Stage(); dialog.initOwner(qupath.getStage()); + FXUtils.addCloseWindowShortcuts(dialog); // dialog.initModality(Modality.APPLICATION_MODAL); dialog.setTitle("Preferences"); @@ -776,6 +778,7 @@ public static Action createZoomCommand(QuPathGUI qupath, int zoomAmount) { public static Stage createSpecifyAnnotationDialog(QuPathGUI qupath) { SpecifyAnnotationCommand pane = new SpecifyAnnotationCommand(qupath); var stage = new Stage(); + FXUtils.addCloseWindowShortcuts(stage); var scene = new Scene(pane.getPane()); stage.setScene(scene); stage.setWidth(300); @@ -1396,6 +1399,7 @@ public static boolean promptToOpenProject(QuPathGUI qupath) { */ public static void launchTMADataViewer(QuPathGUI qupath) { Stage stage = new Stage(); + FXUtils.addCloseWindowShortcuts(stage); stage.setMinHeight(200); stage.setMinWidth(200); if (qupath != null) @@ -1943,6 +1947,7 @@ public static void resetClassifications(final ImageData imageData, final Clas public static Stage createWorkflowDisplayDialog(QuPathGUI qupath) { var view = new WorkflowCommandLogView(qupath); Stage dialog = new Stage(); + FXUtils.addCloseWindowShortcuts(dialog); dialog.setMinHeight(200); dialog.setMinWidth(200); dialog.initOwner(qupath.getStage()); diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/ContextHelpViewer.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/ContextHelpViewer.java index 2ee799ba0..4de5b87ad 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/ContextHelpViewer.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/ContextHelpViewer.java @@ -89,7 +89,7 @@ public class ContextHelpViewer { private int iconSize = 16; - private Stage stage = new Stage(); + private Stage stage; private ObservableList windows; private EventHandler handler = this::handleMouseMove; @@ -161,7 +161,6 @@ private ContextHelpViewer(QuPathGUI qupath) { createHelpLabels(); stage = createStage(new Scene(splitPane)); - FXUtils.addCloseWindowShortcuts(stage); } private void imageDataChanged(ObservableValue> observable, @@ -220,7 +219,8 @@ private Stage createStage(Scene scene) { stage.titleProperty().bind(title); stage.setWidth(300); stage.setHeight(400); - stage.setScene(scene); + stage.setScene(scene); + FXUtils.addCloseWindowShortcuts(stage); return stage; } diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/CountingDialogCommand.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/CountingDialogCommand.java index 70e94de1c..5ad1275eb 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/CountingDialogCommand.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/CountingDialogCommand.java @@ -54,6 +54,7 @@ import javafx.stage.Modality; import javafx.stage.Stage; import qupath.fx.dialogs.FileChoosers; +import qupath.fx.utils.FXUtils; import qupath.lib.gui.QuPathGUI; import qupath.lib.gui.actions.ActionTools; import qupath.fx.dialogs.Dialogs; @@ -287,7 +288,8 @@ public void run() { dialog = new Stage(); dialog.setTitle("Points"); - + FXUtils.addCloseWindowShortcuts(dialog); + countingPane = new CountingPane(qupath, hierarchy); BorderPane pane = new BorderPane(); diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/MemoryMonitorDialog.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/MemoryMonitorDialog.java index 21c293066..47066ea13 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/MemoryMonitorDialog.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/MemoryMonitorDialog.java @@ -45,6 +45,7 @@ import javafx.scene.layout.Priority; import javafx.stage.Stage; import javafx.util.Duration; +import qupath.fx.utils.FXUtils; import qupath.lib.gui.QuPathGUI; import qupath.lib.gui.prefs.PathPrefs; @@ -270,7 +271,8 @@ class MemoryMonitorDialog { stage.initOwner(qupath.getStage()); stage.setScene(new Scene(pane)); stage.setTitle("Memory monitor"); - + FXUtils.addCloseWindowShortcuts(stage); + stage.setOnShowing(e -> { btnToggleMonitoring.setSelected(true); }); diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/MiniViewers.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/MiniViewers.java index c9fe53859..07c2ebc4c 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/MiniViewers.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/MiniViewers.java @@ -82,6 +82,7 @@ import javafx.scene.layout.Priority; import javafx.scene.layout.RowConstraints; import javafx.stage.Stage; +import qupath.fx.utils.FXUtils; import qupath.lib.awt.common.AwtTools; import qupath.lib.color.ColorToolsAwt; import qupath.lib.common.ColorTools; @@ -128,7 +129,7 @@ static Stage createDialog(QuPathViewer viewer, boolean channelViewer) { final Stage dialog = new Stage(); dialog.initOwner(viewer.getView().getScene().getWindow()); - + FXUtils.addCloseWindowShortcuts(dialog); var channels = getChannels(viewer.getImageDisplay()); MiniViewerManager manager = createManager(viewer, channelViewer ? channels : Collections.emptyList()); diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/PathObjectGridView.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/PathObjectGridView.java index d2970790d..dd821776f 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/PathObjectGridView.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/PathObjectGridView.java @@ -435,6 +435,7 @@ private void initializeGUI() { Scene scene = new Scene(pane, 640, 480); stage = new Stage(); + FXUtils.addCloseWindowShortcuts(stage); stage.initOwner(qupath.getStage()); stage.titleProperty().bindBidirectional(title); stage.setScene(scene); diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/RotateImageCommand.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/RotateImageCommand.java index fcc74fd06..9bc359a3e 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/RotateImageCommand.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/RotateImageCommand.java @@ -39,6 +39,7 @@ import javafx.stage.StageStyle; import javafx.util.Duration; import qupath.fx.controls.CircularSlider; +import qupath.fx.utils.FXUtils; import qupath.lib.gui.QuPathGUI; import qupath.lib.gui.viewer.QuPathViewer; @@ -57,6 +58,7 @@ Stage createDialog() { QuPathViewer viewerTemp = qupath.getViewer(); if (dialog == null) { dialog = new Stage(); + FXUtils.addCloseWindowShortcuts(dialog); dialog.initOwner(qupath.getStage()); dialog.initStyle(StageStyle.TRANSPARENT); diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/ScriptInterpreter.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/ScriptInterpreter.java index 2522dd428..f2711fdc2 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/ScriptInterpreter.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/ScriptInterpreter.java @@ -85,6 +85,7 @@ import javafx.scene.web.WebView; import javafx.stage.Stage; import jfxtras.scene.layout.GridPane; +import qupath.fx.utils.FXUtils; import qupath.lib.gui.QuPathGUI; import qupath.fx.dialogs.Dialogs; import qupath.lib.gui.panes.ObjectTreeBrowser; @@ -169,6 +170,7 @@ private static enum VariableInfoType {NAME, CLASS, VALUE}; setWriters(preferredContext); stage = new Stage(); + FXUtils.addCloseWindowShortcuts(stage); stage.setTitle("QuPath Interpreter"); initialize(); diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/ShowInstalledExtensionsCommand.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/ShowInstalledExtensionsCommand.java index 62ad1f55a..47047476f 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/ShowInstalledExtensionsCommand.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/ShowInstalledExtensionsCommand.java @@ -66,6 +66,7 @@ private void showInstalledExtensions() { private Stage createDialog(QuPathGUI qupath) { dialog = new Stage(); + FXUtils.addCloseWindowShortcuts(dialog); dialog.initOwner(qupath.getStage()); dialog.initModality(Modality.APPLICATION_MODAL); dialog.setTitle(QuPathResources.getString("ExtensionControlPane")); diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/ShowLicensesCommand.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/ShowLicensesCommand.java index 73e5a7311..67293536a 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/ShowLicensesCommand.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/ShowLicensesCommand.java @@ -46,6 +46,7 @@ import javafx.scene.layout.Pane; import javafx.stage.Modality; import javafx.stage.Stage; +import qupath.fx.utils.FXUtils; import qupath.lib.common.GeneralTools; import qupath.lib.gui.QuPathGUI; import qupath.lib.gui.Urls; @@ -126,6 +127,7 @@ else if (new File(dirBase.getParentFile(), "licenses").isDirectory()) // Create and show dialog Stage dialog = new Stage(); + FXUtils.addCloseWindowShortcuts(dialog); dialog.initOwner(qupath.getStage()); dialog.initModality(Modality.APPLICATION_MODAL); dialog.setTitle("Licenses"); diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/ShowSystemInfoCommand.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/ShowSystemInfoCommand.java index 79c15ce96..2e036c8da 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/ShowSystemInfoCommand.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/ShowSystemInfoCommand.java @@ -34,6 +34,7 @@ import javafx.scene.layout.BorderPane; import javafx.stage.Modality; import javafx.stage.Stage; +import qupath.fx.utils.FXUtils; import qupath.lib.common.GeneralTools; import qupath.lib.gui.QuPathGUI; @@ -49,6 +50,7 @@ class ShowSystemInfoCommand { public static Stage createShowSystemInfoDialog(QuPathGUI qupath) { var dialog = new Stage(); + FXUtils.addCloseWindowShortcuts(dialog); dialog.initOwner(qupath.getStage()); dialog.initModality(Modality.APPLICATION_MODAL); dialog.setTitle("System info"); diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/SummaryMeasurementTableCommand.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/SummaryMeasurementTableCommand.java index baa0fb272..a0ab10c28 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/SummaryMeasurementTableCommand.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/SummaryMeasurementTableCommand.java @@ -83,6 +83,7 @@ import javafx.stage.Stage; import qupath.fx.controls.PredicateTextField; import qupath.fx.dialogs.FileChoosers; +import qupath.fx.utils.FXUtils; import qupath.lib.common.GeneralTools; import qupath.lib.gui.QuPathGUI; import qupath.lib.gui.charts.HistogramDisplay; @@ -338,6 +339,7 @@ else if (type == PathAnnotationObject.class) { Stage frame = new Stage(); + FXUtils.addCloseWindowShortcuts(frame); frame.initOwner(qupath.getStage()); frame.titleProperty().bind(title); diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/panes/ObjectDescriptionPane.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/panes/ObjectDescriptionPane.java index 0da8075e4..f046466a1 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/panes/ObjectDescriptionPane.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/panes/ObjectDescriptionPane.java @@ -51,6 +51,7 @@ import javafx.scene.paint.Color; import javafx.scene.web.WebView; import javafx.stage.Stage; +import qupath.fx.utils.FXUtils; import qupath.lib.gui.QuPathGUI; import qupath.lib.gui.tools.GuiTools; import qupath.lib.gui.tools.PathObjectLabels; @@ -147,6 +148,7 @@ public static Stage createWindow(QuPathGUI qupath) { var pane = createPane(qupath.imageDataProperty(), true); var scene = new Scene(pane); var stage = new Stage(); + FXUtils.addCloseWindowShortcuts(stage); stage.setScene(scene); stage.initOwner(qupath.getStage()); stage.setTitle("Object description"); diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/panes/SimpleImageViewer.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/panes/SimpleImageViewer.java index 2d362ae8f..5ae5ffd64 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/panes/SimpleImageViewer.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/panes/SimpleImageViewer.java @@ -60,6 +60,7 @@ import qupath.fx.dialogs.Dialogs; import qupath.fx.dialogs.FileChoosers; import qupath.fx.localization.LocalizedResourceManager; +import qupath.fx.utils.FXUtils; import qupath.lib.awt.common.BufferedImageTools; import qupath.lib.display.ChannelDisplayInfo; import qupath.lib.display.ImageDisplay; @@ -158,6 +159,7 @@ private void initialize() { initializeContextMenu(); stage = new Stage(); + FXUtils.addCloseWindowShortcuts(stage); stage.titleProperty().bind(createTitleBinding()); var scene = new Scene(pane); // pane.setStyle("-fx-background-color: black;"); diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/panes/WorkflowCommandLogView.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/panes/WorkflowCommandLogView.java index d29ac0398..28bbd1a0c 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/panes/WorkflowCommandLogView.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/panes/WorkflowCommandLogView.java @@ -66,6 +66,7 @@ import javafx.scene.layout.Pane; import javafx.stage.Stage; import javafx.util.Callback; +import qupath.fx.utils.FXUtils; import qupath.lib.gui.QuPathGUI; import qupath.fx.dialogs.Dialogs; import qupath.lib.gui.scripting.ScriptEditor; @@ -327,6 +328,7 @@ else if (value == null) if (workflow == null) return; Stage stage = new Stage(); + FXUtils.addCloseWindowShortcuts(stage); stage.initOwner(qupath.getStage()); stage.setTitle("Workflow"); Workflow workflowNew = new Workflow(); diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/scripting/ScriptFindCommand.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/scripting/ScriptFindCommand.java index 7862c187e..b6f51f730 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/scripting/ScriptFindCommand.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/scripting/ScriptFindCommand.java @@ -42,6 +42,7 @@ import javafx.scene.layout.Priority; import javafx.stage.Modality; import javafx.stage.Stage; +import qupath.fx.utils.FXUtils; import qupath.fx.utils.GridPaneUtils; /** @@ -103,6 +104,7 @@ public void run() { private void createFindStage() { stage = new Stage(); stage.setTitle("Find/Replace"); + FXUtils.addCloseWindowShortcuts(stage); stage.initOwner(scriptEditor.getStage()); stage.initModality(Modality.NONE); stage.setOnHiding(e -> { diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/tma/KaplanMeierDisplay.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/tma/KaplanMeierDisplay.java index 4a457d4c0..2e8960903 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/tma/KaplanMeierDisplay.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/tma/KaplanMeierDisplay.java @@ -74,6 +74,7 @@ import javafx.stage.Stage; import javafx.stage.Window; import javafx.util.Callback; +import qupath.fx.utils.FXUtils; import qupath.lib.analysis.stats.Histogram; import qupath.lib.analysis.stats.StatisticsHelper; import qupath.lib.analysis.stats.survival.KaplanMeierData; @@ -210,6 +211,7 @@ public Parent getView() { private Stage createStage(final Window parent, final String title) { Stage frame = new Stage(); + FXUtils.addCloseWindowShortcuts(frame); if (parent != null) frame.initOwner(parent); frame.setTitle("Kaplan Meier: " + title); diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/tma/TMAExplorer.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/tma/TMAExplorer.java index 035750656..f80610ef9 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/tma/TMAExplorer.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/tma/TMAExplorer.java @@ -39,6 +39,7 @@ import org.slf4j.LoggerFactory; import javafx.stage.Stage; +import qupath.fx.utils.FXUtils; import qupath.lib.analysis.stats.RunningStatistics; import qupath.lib.gui.QuPathGUI; import qupath.lib.gui.tma.TMAEntries.TMAEntry; @@ -194,6 +195,7 @@ private void createAndShowStage() { Stage stage = new Stage(); + FXUtils.addCloseWindowShortcuts(stage); stage.initOwner(qupath.getStage()); TMASummaryViewer summaryViewer = new TMASummaryViewer(stage); summaryViewer.setTMAEntries(entries); diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/tma/TMASummaryViewer.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/tma/TMASummaryViewer.java index 896770f0d..80d6d1100 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/tma/TMASummaryViewer.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/tma/TMASummaryViewer.java @@ -137,6 +137,7 @@ import javafx.stage.Stage; import javafx.util.Callback; import qupath.fx.dialogs.FileChoosers; +import qupath.fx.utils.FXUtils; import qupath.lib.common.GeneralTools; import qupath.lib.gui.QuPathGUI; import qupath.lib.gui.charts.ChartTools; @@ -272,9 +273,10 @@ private static enum ImageAvailability {IMAGE_ONLY, OVERLAY_ONLY, BOTH, NONE} * @param stage stage that should be used for this TMA summary viewer. If null, a new stage will be created. */ public TMASummaryViewer(final Stage stage) { - if (stage == null) + if (stage == null) { this.stage = new Stage(); - else + FXUtils.addCloseWindowShortcuts(stage); + } else this.stage = stage; logger.trace("Creating TMA summary viewer"); diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/tools/CommandFinderTools.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/tools/CommandFinderTools.java index 38c3ccb41..241b6cdcd 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/tools/CommandFinderTools.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/tools/CommandFinderTools.java @@ -79,6 +79,7 @@ import org.controlsfx.control.HiddenSidesPane; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import qupath.fx.utils.FXUtils; import qupath.lib.gui.QuPathGUI; import qupath.lib.gui.actions.ActionTools; import qupath.lib.gui.prefs.PathPrefs; @@ -322,12 +323,7 @@ private static Stage createRecentCommandDialog(final MenuBar menubar, final Wind stage.setScene(new Scene(pane, 600, 400)); - stage.getScene().addEventFilter(KeyEvent.KEY_PRESSED, e -> { - if (e.getCode() == KeyCode.ESCAPE) { - stage.hide(); - e.consume(); - } - }); + FXUtils.addCloseWindowShortcuts(stage); textField.textProperty().addListener((v, o, n) -> { // Ensure the table is up to date if we are just starting @@ -382,13 +378,8 @@ private static Stage createCommandFinderDialog(final MenuBar menubar, final Wind pane.setBottom(panelSearch); stage.setScene(new Scene(pane, 600, 400)); - - stage.getScene().addEventFilter(KeyEvent.KEY_PRESSED, e -> { - if (e.getCode() == KeyCode.ESCAPE) { - stage.hide(); - e.consume(); - } - }); + + FXUtils.addCloseWindowShortcuts(stage); textField.textProperty().addListener((v, o, n) -> { // Ensure the table is up to date if we are just starting diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/ViewerManager.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/ViewerManager.java index 612b854a6..1a6e72b16 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/ViewerManager.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/ViewerManager.java @@ -1138,6 +1138,7 @@ public boolean detachViewer(QuPathViewer viewer) { splitRow.getItems().set(col, createViewer().getView()); refreshDividerBindings(); var stage = new Stage(); + FXUtils.addCloseWindowShortcuts(stage); var pane = new BorderPane(viewer.getView()); var scene = new Scene(pane); stage.setScene(scene); diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/recording/ViewTrackerAnalysisCommand.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/recording/ViewTrackerAnalysisCommand.java index 3b1eaf0ce..d0f3ae712 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/recording/ViewTrackerAnalysisCommand.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/recording/ViewTrackerAnalysisCommand.java @@ -75,6 +75,7 @@ import javafx.stage.Stage; import javafx.util.Callback; import javafx.util.converter.DateTimeStringConverter; +import qupath.fx.utils.FXUtils; import qupath.lib.color.ColorMaps; import qupath.lib.color.ColorMaps.ColorMap; import qupath.lib.common.GeneralTools; @@ -162,6 +163,7 @@ final class ViewTrackerAnalysisCommand implements Runnable { public void run() { if (dialog == null) { dialog = new Stage(); + FXUtils.addCloseWindowShortcuts(dialog); dialog.sizeToScene(); dialog.initOwner(qupath.getStage()); dialog.setTitle("Recording analysis"); diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/recording/ViewTrackerControlPane.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/recording/ViewTrackerControlPane.java index 3b65706e7..283f88287 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/recording/ViewTrackerControlPane.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/recording/ViewTrackerControlPane.java @@ -74,6 +74,7 @@ import javafx.scene.control.TableRow; import javafx.scene.input.Dragboard; import javafx.scene.input.TransferMode; +import qupath.fx.utils.FXUtils; import qupath.lib.common.GeneralTools; import qupath.lib.gui.QuPathGUI; import qupath.lib.gui.actions.ActionTools; @@ -519,6 +520,7 @@ public void run() { dialog.requestFocus(); else { dialog = new Stage(); + FXUtils.addCloseWindowShortcuts(dialog); dialog.sizeToScene(); dialog.initOwner(qupath.getStage()); dialog.setTitle("Tracking"); From 26d4b5bef510c8c25ff754ebeea17f70b9242c54 Mon Sep 17 00:00:00 2001 From: Pete Date: Fri, 21 Jun 2024 16:33:21 +0100 Subject: [PATCH 101/866] Update CHANGELOG.md --- CHANGELOG.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 23220717e..26062b991 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,11 @@ This is a *work in progress* for the next major release. ### Enhancements * Read and write OME-Zarr images (https://github.com/qupath/qupath/pull/1474) -* Improved display of annotation names -* Support log10 counts with histograms (no longer use natural log) +* Improved display of annotation names (https://github.com/qupath/qupath/pull/1532) +* Support log10 counts with histograms (no longer use natural log) (https://github.com/qupath/qupath/pull/1540) * Log counts also now available in measurement tables +* Prompt to save modified scripts when closing QuPath (https://github.com/qupath/qupath/pull/1524) +* Close most sub-windows from the keyboard with `Ctrl/Cmd + W` or `Esc` (https://github.com/qupath/qupath/pull/1548) ### Bugs fixed * Tile export to .ome.tif can convert to 8-bit unnecessarily (https://github.com/qupath/qupath/issues/1494) From a5f9c83e9cf31f1de292bd69b0c72860f5dc5c56 Mon Sep 17 00:00:00 2001 From: Pete Date: Fri, 21 Jun 2024 16:50:05 +0100 Subject: [PATCH 102/866] Improve robustness of polygon union Add more checks for null or empty polygons, and fall back to buffer(0) internally if unioning fails for a restricted part. --- .../java/qupath/lib/roi/FastPolygonUnion.java | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/qupath-core/src/main/java/qupath/lib/roi/FastPolygonUnion.java b/qupath-core/src/main/java/qupath/lib/roi/FastPolygonUnion.java index b8b1d5bc4..20eef5e80 100644 --- a/qupath-core/src/main/java/qupath/lib/roi/FastPolygonUnion.java +++ b/qupath-core/src/main/java/qupath/lib/roi/FastPolygonUnion.java @@ -140,16 +140,19 @@ public static Geometry union(Collection geoms) { } /** - * Extract all polygons from a collection of geometries. + * Extract all non-empty polygons from a collection of geometries. * @param geoms * @return */ private static List extractAllPolygons(Collection geoms) { List allPolygons = new ArrayList<>(); for (var g : geoms) { - PolygonExtracter.getPolygons(g, allPolygons); + if (g != null) + PolygonExtracter.getPolygons(g, allPolygons); } - return allPolygons; + return allPolygons.stream() + .filter(p -> p != null && !p.isEmpty()) + .toList(); } /** @@ -159,7 +162,18 @@ private static List extractAllPolygons(Collection g */ private static Geometry unionOpNg(Collection geoms) { var factory = GeometryTools.getDefaultFactory(); - return UnaryUnionNG.union(geoms, factory, factory.getPrecisionModel()); + + if (geoms.isEmpty()) + return factory.createPolygon(); + else if (geoms.size() == 1) + return geoms.iterator().next(); + + try { + return UnaryUnionNG.union(geoms, factory, factory.getPrecisionModel()); + } catch (Exception e) { + logger.error("Error during unary union operation for {} geometries, will attempt with buffer(0)", geoms.size(), e); + return factory.createGeometryCollection(geoms.toArray(Geometry[]::new)).buffer(0); + } } /** From 6992900f675c73e67362669d3c2c2523399f433d Mon Sep 17 00:00:00 2001 From: petebankhead Date: Fri, 21 Jun 2024 17:19:57 +0100 Subject: [PATCH 103/866] Handle Bio-Formats pyramid level changes See https://forum.image.sc/t/can-creating-detections-from-pixel-classifier-be-made-to-run-faster/96745/25 Bio-Formats 7.2.0 handles pyramid levels differently for SVS images, which would break compatibility with the metadata stored in QuPath projects. Basically, the levels stored in the project would override the levels that Bio-Formats expects to find. Consequence: if a project was created in v0.5.1 (or earlier), including SVS images read using Bio-Formats, these images could not be opened in v0.6.0. This PR logs a warning when this occurs, and uses the 'new' pyramid levels are used instead of the ones stored in the saved metadata. --- .../servers/bioformats/BioFormatsImageServer.java | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/qupath-extension-bioformats/src/main/java/qupath/lib/images/servers/bioformats/BioFormatsImageServer.java b/qupath-extension-bioformats/src/main/java/qupath/lib/images/servers/bioformats/BioFormatsImageServer.java index 66b64cf18..181cb896e 100644 --- a/qupath-extension-bioformats/src/main/java/qupath/lib/images/servers/bioformats/BioFormatsImageServer.java +++ b/qupath-extension-bioformats/src/main/java/qupath/lib/images/servers/bioformats/BioFormatsImageServer.java @@ -879,6 +879,20 @@ public String createID() { } return id; } + + @Override + public void setMetadata(ImageServerMetadata metadata) { + var currentMetadata = getMetadata(); + super.setMetadata(metadata); + if (currentMetadata != metadata && !currentMetadata.getLevels().equals(metadata.getLevels())) { + logger.warn("Can't set metadata to use incompatible pyramid levels - reverting to original pyramid levels"); + super.setMetadata( + new ImageServerMetadata.Builder(metadata) + .levels(currentMetadata.getLevels()) + .build() + ); + } + } /** * Returns a builder capable of creating a server like this one. From 79519d687ef2f10bca1263be0a87e26b5149e366 Mon Sep 17 00:00:00 2001 From: Alan O'Callaghan Date: Tue, 19 Mar 2024 09:01:22 +0000 Subject: [PATCH 104/866] Improve handling of extension installation Add a choice dialog when installing extensions through the GUI (ie, by inputting an owner and repo). First, if multiple releases are present in the github repo, ask the user to choose one. Then, if there is more than one valid (ie, a java archive that's not sources or javadoc), then ask them to choose one again. Finally, install as before. --- .../qupath/lib/gui/ExtensionControlPane.java | 311 +++++++++++++++++- qupath-gui-fx/src/main/resources/css/dark.css | 3 - qupath-gui-fx/src/main/resources/css/main.css | 19 +- .../qupath-gui-strings.properties | 3 + 4 files changed, 317 insertions(+), 19 deletions(-) diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/ExtensionControlPane.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/ExtensionControlPane.java index e6e0fd485..65ca16d6c 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/ExtensionControlPane.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/ExtensionControlPane.java @@ -21,21 +21,42 @@ package qupath.lib.gui; +import com.google.gson.Gson; +import javafx.beans.binding.DoubleBinding; +import javafx.beans.property.SimpleObjectProperty; +import javafx.beans.property.SimpleStringProperty; import javafx.collections.MapChangeListener; import javafx.fxml.FXML; import javafx.fxml.FXMLLoader; +import javafx.geometry.Insets; +import javafx.geometry.Pos; import javafx.scene.Node; -import javafx.scene.control.*; +import javafx.scene.control.Button; +import javafx.scene.control.ButtonType; +import javafx.scene.control.ContextMenu; +import javafx.scene.control.Dialog; +import javafx.scene.control.Label; +import javafx.scene.control.ListCell; +import javafx.scene.control.ListView; +import javafx.scene.control.SelectionMode; +import javafx.scene.control.TableColumn; +import javafx.scene.control.TableView; +import javafx.scene.control.TextField; +import javafx.scene.control.TitledPane; import javafx.scene.input.KeyCode; import javafx.scene.input.KeyEvent; import javafx.scene.layout.AnchorPane; +import javafx.scene.layout.BorderPane; import javafx.scene.layout.HBox; import javafx.scene.layout.VBox; +import javafx.scene.web.WebView; import org.apache.commons.text.WordUtils; +import org.commonmark.parser.Parser; +import org.commonmark.renderer.html.HtmlRenderer; +import org.controlsfx.control.PopOver; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import qupath.fx.dialogs.Dialogs; -import qupath.fx.utils.FXUtils; import qupath.lib.common.GeneralTools; import qupath.lib.common.Version; import qupath.lib.gui.actions.ActionTools; @@ -54,14 +75,23 @@ import java.net.URI; import java.net.URISyntaxException; import java.net.URL; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; import java.nio.channels.Channels; import java.nio.channels.ReadableByteChannel; import java.nio.file.Files; import java.nio.file.Path; +import java.text.SimpleDateFormat; +import java.util.Arrays; +import java.util.Collection; import java.util.Comparator; +import java.util.Date; +import java.util.Optional; import java.util.ResourceBundle; import java.util.regex.Matcher; import java.util.regex.Pattern; +import java.util.stream.Collectors; /** * Borderpane that displays extensions, with options to remove, @@ -194,8 +224,9 @@ public static void handleGitHubURL(String url) { var outputFile = new File(dir.toString(), jarMatcher.group(2)); logger.info("Downloading suspected extension {} to extension directory {}", url, outputFile); try { - downloadURLToFile(url, outputFile); + downloadURLToFile(new URL(url), outputFile); } catch (IOException e) { + logger.error("Unable to download extension", e); Dialogs.showErrorNotification(QuPathResources.getString("ExtensionControlPane"), QuPathResources.getString("ExtensionControlPane.unableToDownload")); } @@ -219,16 +250,28 @@ public static void handleGitHubURL(String url) { private static void askToDownload(GitHubProject.GitHubRepo repo) throws URISyntaxException, IOException, InterruptedException { var v = UpdateChecker.checkForUpdate(repo); - if (v != null && Dialogs.showYesNoDialog(QuPathResources.getString("ExtensionControlPane"), + if (v == null) { + Dialogs.showErrorNotification(QuPathResources.getString("ExtensionControlPane"), + String.format(QuPathResources.getString("ExtensionControlPane.unableToFetchUpdates"), repo.getOwner(), repo.getRepo())); + return; + } + + if (Dialogs.showYesNoDialog(QuPathResources.getString("ExtensionControlPane"), String.format(QuPathResources.getString("ExtensionControlPane.installExtensionFromGithub"), repo.getRepo()))) { - var downloadURL = String.format("https://github.com/%s/%s/releases/download/%s/%s-%s.jar", - repo.getOwner(), repo.getRepo(), "v" + v.getVersion().toString(), repo.getRepo(), v.getVersion().toString() - ); - // https://github.com/qupath/qupath-extension-wsinfer/releases/download/v0.2.0/qupath-extension-wsinfer-0.2.0.jar + + var asset = resolveReleaseAndAsset(repo); + if (asset.isEmpty()) { + return; + } + var downloadURL = asset.get().getUrl(); var dir = getExtensionPath(); if (dir == null) return; - File f = new File(dir.toString(), repo.getRepo() + "-" + v.getVersion() + ".jar"); - downloadURLToFile(downloadURL, f); + File f = new File(dir.toString(), asset.get().getName()); + try { + downloadURLToFile(downloadURL, f); + } catch (IOException e) { + logger.error("Unable to download extension", e); + } Dialogs.showInfoNotification( QuPathResources.getString("ExtensionControlPane"), String.format(QuPathResources.getString("ExtensionControlPane.successfullyDownloaded"), repo.getRepo())); @@ -236,6 +279,229 @@ private static void askToDownload(GitHubProject.GitHubRepo repo) throws URISynta } } + private static class GitHubRelease { + URL assets_url; + int id; + String tag_name; + String name; + boolean draft; + boolean prerelease; + Date published_at; + GitHubAsset[] assets; + String body; + + String getName() { + return name; + } + String getBody() { + return body; + } + Date getDate() { + return published_at; + } + String getTag() { + return tag_name; + } + + @Override + public String toString() { + return name + " with assets:" + Arrays.toString(assets); + } + } + + private static class GitHubAsset { + URL url; + int id; + String name; + String content_type; + URL browser_download_url; + @Override + public String toString() { + return name; + } + + String getType() { + return content_type; + } + + URL getUrl() { + return browser_download_url; + } + + public String getName() { + return name; + } + } + + private static Optional chooseAsset(GitHubProject.GitHubRepo repo, Collection options) { + ListView listView = new ListView<>(); + listView.getSelectionModel().setSelectionMode(SelectionMode.SINGLE); + for (var option: options) { + listView.getItems().add(option); + } + listView.getSelectionModel().select(0); + var dialog = createDialog(repo, listView, "asset", "release"); + dialog.setResizable(true); + var choice = dialog.showAndWait(); + + if (choice.orElse(ButtonType.CANCEL).equals(ButtonType.APPLY)) { + return Optional.ofNullable(listView.getSelectionModel().getSelectedItem()); + } + return Optional.empty(); + } + + private static Optional chooseRelease(GitHubProject.GitHubRepo repo, Collection options) { + TableView table = new TableView<>(); + TableColumn colTag = new TableColumn<>("Tag"); + colTag.setCellValueFactory(param -> new SimpleStringProperty(param.getValue().getTag())); + colTag.setSortable(false); + table.getColumns().add(colTag); + + TableColumn colName = new TableColumn<>("Name"); + colName.setCellValueFactory(param -> new SimpleStringProperty(WordUtils.wrap(param.getValue().getName(), 40))); + colName.setSortable(false); + table.getColumns().add(colName); + + TableColumn colDate = new TableColumn<>("Date published"); + colDate.setCellValueFactory(param -> { + SimpleDateFormat formatter = new SimpleDateFormat("yyyy-MM-dd"); + return new SimpleStringProperty(formatter.format(param.getValue().getDate())); + }); + colDate.setSortable(false); + table.getColumns().add(colDate); + + TableColumn colBody = new TableColumn<>("Description"); + WebView webView = new WebView(); + PopOver infoPopover = new PopOver(webView); + colBody.setCellValueFactory(param -> { + Button button = new Button(); + button.setGraphic(createIcon(IconFactory.PathIcons.INFO)); + button.setOnAction(e -> parseMarkdown(param.getValue(), webView, button, infoPopover)); + return new SimpleObjectProperty<>(button); + }); + colBody.setSortable(false); + table.getColumns().add(colBody); + + for (var option: options) { + table.getItems().add(option); + } + // to try to ensure something is selected + table.getSelectionModel().setSelectionMode(SelectionMode.SINGLE); + table.getSelectionModel().select(0); + + var dialog = createDialog(repo, table, "release", "extension"); + dialog.getDialogPane().setMinWidth(300); + + DoubleBinding width = colTag.widthProperty() + .add(colName.widthProperty()) + .add(colDate.widthProperty()) + .add(colBody.widthProperty()).add(25); + width.addListener((v, o, n) -> dialog.getDialogPane().setPrefWidth(n.doubleValue())); + dialog.getDialogPane().setPrefWidth(width.doubleValue()); + var choice = dialog.showAndWait(); + + if (choice.orElse(ButtonType.CANCEL).equals(ButtonType.APPLY)) { + return Optional.ofNullable(table.getSelectionModel().getSelectedItem()); + } + return Optional.empty(); + } + + private static Node createIcon(IconFactory.PathIcons icon) { + int iconSize = 12; + // The style class is actually a problem here, because it doesn't handle buttons + // inside select list cells + var node = IconFactory.createNode(iconSize, iconSize, icon); + node.getStyleClass().setAll("extension-manager-list-icon"); + return node; + } + + private static void parseMarkdown(GitHubRelease release, WebView webView, Button infoButton, PopOver infoPopover) { + String body = release.getBody(); + // Parse the initial markdown only, to extract any YAML front matter + var parser = Parser.builder().build(); + var doc = parser.parse(body); + + // If the markdown doesn't start with a title, pre-pending the model title & description (if available) + if (!body.startsWith("#")) { + var sb = new StringBuilder(); + sb.append("## ").append(release.getName()).append("\n\n"); + sb.append("----\n\n"); + doc.prependChild(parser.parse(sb.toString())); + } + webView.getEngine().loadContent(HtmlRenderer.builder().build().render(doc)); + infoPopover.show(infoButton); + } + + + private static Dialog createDialog(GitHubProject.GitHubRepo repo, Node control, String optionType, String parentType) { + BorderPane bp = new BorderPane(); + AnchorPane ap = new AnchorPane(); + Button githubButton = new Button("Browse GitHub repository"); + ap.getChildren().add(githubButton); + AnchorPane.setBottomAnchor(githubButton, 0.0); + AnchorPane.setLeftAnchor(githubButton, 0.0); + AnchorPane.setRightAnchor(githubButton, 0.0); + AnchorPane.setTopAnchor(githubButton, 0.0); + githubButton.setOnAction(e -> browseGitHub(repo)); + bp.setTop(ap); + bp.setBottom(control); + HBox hboxText = new HBox(); + hboxText.setPadding(new Insets(5)); + hboxText.setAlignment(Pos.CENTER_LEFT); + hboxText.getChildren().add(new Label(String.format("More than one %s available for this %s.\nPlease choose one:", optionType, parentType))); + bp.setCenter(hboxText); + + Dialog dialog = new Dialog<>(); + dialog.getDialogPane().setContent(bp); + dialog.getDialogPane().getButtonTypes().setAll(ButtonType.APPLY, ButtonType.CANCEL); + dialog.setTitle("Extension manager"); + return dialog; + } + + private static Optional resolveReleaseAndAsset(GitHubProject.GitHubRepo repo) throws IOException, InterruptedException { + String uString = String.format("https://api.github.com/repos/%s/%s/releases", repo.getOwner(), repo.getRepo()); + HttpClient client = HttpClient.newHttpClient(); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(uString)) + .GET() + .build(); + + String response = client.send(request, HttpResponse.BodyHandlers.ofString()).body(); + + Gson gson = new Gson(); + var releases = gson.fromJson(response, GitHubRelease[].class); + if (!(releases.length > 0)) { + Dialogs.showErrorMessage(QuPathResources.getString("ExtensionControlPane"), + QuPathResources.getString("ExtensionControlPane.noValidRelease")); + return Optional.empty(); + } + + var release = chooseRelease(repo, Arrays.asList(releases)); + if (release.isEmpty()) { + return Optional.empty(); + } + var assets = Arrays.stream(release.get().assets) + .filter(a -> a.getType().equals("application/java-archive")) + .filter(a -> !a.getName().endsWith("javadoc.jar")) + .filter(a -> !a.getName().endsWith("sources.jar")) + .toList(); + if (assets.isEmpty()) { + Dialogs.showInfoNotification(QuPathResources.getString("ExtensionControlPane"), + QuPathResources.getString("ExtensionControlPane.noValidAsset")); + logger.info("No valid assets identified for {}/{}", repo.getOwner(), repo.getRepo()); + return Optional.empty(); + } + if (assets.size() == 1) { + return Optional.of(assets.get(0)); + } + + // otherwise, make the user choose... + logger.info("More than one asset for release {}", release); + + return chooseAsset(repo, assets); + } + private static Path getExtensionPath() { var dir = ExtensionClassLoader.getInstance().getExtensionsDirectory(); if (dir == null || !Files.isDirectory(dir)) { @@ -256,8 +522,21 @@ private static Path getExtensionPath() { return dir; } - private static void downloadURLToFile(String downloadURL, File file) throws IOException { - try (InputStream stream = new URL(downloadURL).openStream()) { + private static void browseGitHub(GitHubProject.GitHubRepo repo) { + String url = repo.getUrlString(); + try { + logger.info("Trying to open URL {}", url); + GuiTools.browseURI(new URI(url)); + } catch (URISyntaxException e) { + Dialogs.showErrorNotification( + QuPathResources.getString("ExtensionControlPane.unableToOpenGitHubURL") + url, + e); + } + } + + + private static void downloadURLToFile(URL downloadURL, File file) throws IOException { + try (InputStream stream = downloadURL.openStream()) { try (ReadableByteChannel readableByteChannel = Channels.newChannel(stream)) { try (FileOutputStream fos = new FileOutputStream(file)) { fos.getChannel().transferFrom(readableByteChannel, 0, Long.MAX_VALUE); @@ -272,6 +551,7 @@ private void downloadExtension() { try { askToDownload(repo); } catch (URISyntaxException | IOException | InterruptedException e) { + logger.error("Unable to download extension", e); Dialogs.showErrorNotification(QuPathResources.getString("ExtensionControlPane.unableToDownload"), e); } cancelDownload(); @@ -367,6 +647,11 @@ private static void updateExtension(QuPathExtension extension) { } } + private static String truncateLines(String text, int nChars) { + return text.lines() + .map(l -> l.length() <= nChars ? l : (l.substring(0, nChars) + "[...]")) + .collect(Collectors.joining("\n")); + } /** * Controller class for extension list cells @@ -522,10 +807,12 @@ private void browseGitHub() { e); } } + @FXML private void updateExtension() { ExtensionControlPane.updateExtension(this.extension); } + @FXML private void removeExtension() { ExtensionControlPane.removeExtension(this.extension); diff --git a/qupath-gui-fx/src/main/resources/css/dark.css b/qupath-gui-fx/src/main/resources/css/dark.css index 6bb163f19..dc5d32d72 100644 --- a/qupath-gui-fx/src/main/resources/css/dark.css +++ b/qupath-gui-fx/src/main/resources/css/dark.css @@ -96,7 +96,4 @@ AXIS_COLOR: derive(-fx-background,40%); } -.list-cell:filled:selected:focused .button .text { - -fx-fill: -fx-text-base-color; -} diff --git a/qupath-gui-fx/src/main/resources/css/main.css b/qupath-gui-fx/src/main/resources/css/main.css index 88102cea8..5dc9012fe 100644 --- a/qupath-gui-fx/src/main/resources/css/main.css +++ b/qupath-gui-fx/src/main/resources/css/main.css @@ -125,20 +125,31 @@ -fx-text-alignment: center; } -.list-cell > .failed-extension { - -fx-text-background-color: -qp-script-error-color; - -fx-opacity: 0.8; +/*to prevent icons from being dark when the row is selected*/ +.list-cell .button .text { + -fx-fill: -fx-text-base-color; +} +.table-row-cell .button .text { + -fx-fill: -fx-text-base-color; } .list-cell .extension-manager-list-icon { -fx-text-fill: -fx-text-background-color; } +.table-row-cell .extension-manager-list-icon { + -fx-text-fill: -fx-text-background-color; +} + +.list-cell > .failed-extension { + -fx-text-background-color: -qp-script-error-color; + -fx-opacity: 0.8; +} + .list-cell > .failed-extension .extension-manager-list-icon { -fx-text-fill: -qp-script-error-color; } - /* Don't make the title for the training piechart too large */ .training-chart .chart-title { -fx-font-size: 1em; diff --git a/qupath-gui-fx/src/main/resources/qupath/lib/gui/localization/qupath-gui-strings.properties b/qupath-gui-fx/src/main/resources/qupath/lib/gui/localization/qupath-gui-strings.properties index d00e9ad6c..a3b32a3b8 100644 --- a/qupath-gui-fx/src/main/resources/qupath/lib/gui/localization/qupath-gui-strings.properties +++ b/qupath-gui-fx/src/main/resources/qupath/lib/gui/localization/qupath-gui-strings.properties @@ -938,6 +938,9 @@ ExtensionControlPane.unableToDeletePrompt = Extension file could not be deleted. ExtensionControlPane.confirmRemoveExtension = Are you sure you want to remove %s? ExtensionControlPane.extensionRemoved = Extension removed %s\nYou need to restart QuPath to see the changes reflected. ExtensionControlPane.unableToCheckForUpdates = Unable to check for updates +ExtensionControlPane.unableToFetchUpdates = Unable to fetch versions from GitHub repository %s/%s +ExtensionControlPane.noValidAsset = No valid asset(s) identified +ExtensionControlPane.noValidRelease = No valid release(s) identified ExtensionControlPane.installExtensionFromGithub = Would you like to install the extension %s from GitHub? ExtensionControlPane.successfullyDownloaded = Successfully downloaded %s ExtensionControlPane.noNewerRelease = No newer release for %s (%s versus %s) From 30d959164194d3e47cbccaa0db692ff9c8c662d8 Mon Sep 17 00:00:00 2001 From: Pete Date: Thu, 27 Jun 2024 07:36:55 +0100 Subject: [PATCH 105/866] Improve point selection in the viewer Fixes https://github.com/qupath/qupath/issues/1552 --- CHANGELOG.md | 15 ++++++++------- .../java/qupath/lib/objects/PathObjectTools.java | 10 +++++++++- .../lib/gui/viewer/tools/handlers/ToolUtils.java | 9 +++++++-- 3 files changed, 24 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 26062b991..ee1140867 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ This is a *work in progress* for the next major release. * TileExporter exports ImageJ TIFFs with channels converted to z-stacks (https://github.com/qupath/qupath/issues/1503) * Black row or column appears on some downsampled images (https://github.com/qupath/qupath/issues/1527) * Full image annotations can be shifted accidentally outside the image bounds (https://github.com/qupath/qupath/issues/1533) +* Search distance when selecting points in the viewer is too low (https://github.com/qupath/qupath/issues/1552) ### Dependency updates * Bio-Formats 7.3.0 @@ -111,7 +112,7 @@ This is a **major update**, with many enhancements and new features. #### Naming & measurements * Improve consistency of naming, including for measurements - * Use 'classification' rather then 'class' (to reduce confusion with Java 'classes') + * Use 'classification' rather than 'class' (to reduce confusion with Java 'classes') * Add a new 'Object type' measurement to tables, giving a readable string ('Annotation', 'Detection', 'Cell' etc.) * No longer show a default 'Name' if no name has been set * e.g. don't show 'PathAnnotationObject' or the classification as a placeholder, since this causes confusion for people writing scripts and requesting the name @@ -451,7 +452,7 @@ Here's an abridged version of the main changes, grouped by category. * Warn if trying to train a pixel classifier with too many features (https://github.com/qupath/qupath/issues/947) * New 'Analyze > Spatial analysis > Signed distance to annotations 2D' command (https://github.com/qupath/qupath/issues/1032) * New 'Objects > Lock... >' commands - * Enables annotations & TMA cores to be locked so they cannot accidentally be moved or edited (deletion is still possible) + * Enables annotations & TMA cores to be locked, so they cannot accidentally be moved or edited (deletion is still possible) * Toggle the 'locked' status of any selected object with `Ctrl/Cmd + K` * View locked status for annotations under the 'Annotation' tab * New 'TMA > Specify TMA grid' command to manually specify a TMA grid (rather than relying on the dearrayer) @@ -731,7 +732,7 @@ For full details, see the [Commit log](https://github.com/qupath/qupath/commits/ * Load object & pixel classifier dialogs support importing classifiers from other locations * Brightness/Contrast panel shows small min/max values to 2 decimal places * Better validation when entering numeric values in text fields -* BufferedImageOverlays are now tied to the the pixel classification display setting (rather than the detection display) +* BufferedImageOverlays are now tied to the pixel classification display setting (rather than the detection display) * Bio-Formats now optionally accepts URLs, not only local files (requires opt-in through the preferences) * Specify the logging level for the current QuPath session through the preferences, e.g. to emit extra debugging messages * Log files are now turned off by default; this can be changed in the preferences if a QuPath user directory is set @@ -785,7 +786,7 @@ For full details, see the [Commit log](https://github.com/qupath/qupath/commits/ * The requestedPixelSize option for `TileExporter` calculated the wrong downsample (https://github.com/qupath/qupath/issues/648) * Unable to find slide labels when reading images with Bio-Formats (https://github.com/qupath/qupath/issues/643) * The `TileExporter` could not properly export tiles from z-stacks/time series (https://github.com/qupath/qupath/issues/650) -* `PathClassifierTools.setIntensityClassification` method now correctly ignores ignored classes such as 'myClass*' (https://github.com/qupath/qupath/issues/691) +* `PathClassifierTools.setIntensityClassification` method now correctly ignores 'ignored' classes such as 'myClass*' (https://github.com/qupath/qupath/issues/691) * `Dialogs.showConfirmDialog(title, text)` shows the text in the title bar, rather than the title (https://github.com/qupath/qupath/issues/662) * Error in StarDist intensity measurements for 8-bit RGB fluorescence images (https://github.com/qupath/qupath/issues/686) * Opening images with very narrow tiles can fail with Bio-Formats (https://github.com/qupath/qupath/issues/715) @@ -879,7 +880,7 @@ Full list of bugs fixed: * Closing QuPath from the dock icon on macOS closes immediately with no opportunity to save data * Switched zoom in/out direction, + shortcut does not zoom in (https://github.com/qupath/qupath/issues/518) * Misbehaving 'Update URIs' dialog (https://github.com/qupath/qupath/issues/519) -* Create thresholder' dialog grows in size and forgets recent options when reopening (https://github.com/qupath/qupath/issues/517) +* 'Create thresholder' dialog grows in size and forgets recent options when reopening (https://github.com/qupath/qupath/issues/517) * Brightness/Contrast & color transforms reset when training a pixel classifier/creating a thresholder for an RGB image (https://github.com/qupath/qupath/issues/509) * Launching QuPath from the command line on Windows does not handle non-ASCII characters (https://github.com/qupath/qupath/issues/320) * Exception thrown by 'Add shape features' dialog under some circumstances (https://github.com/qupath/qupath/issues/522) @@ -954,9 +955,9 @@ This is the *release candidate* for v0.2.0 (i.e. the proposed stable version). * Changed behavior! Area thresholds now refer to total polygon/hole area ignoring any nested polygons or holes * Script editor improvements * Display which script is currently running in the script editor - * Current project now accessible in scripts run outside of the script editor (e.g. from the command line) + * Current project now accessible in scripts run outside the script editor (e.g. from the command line) * Intercept mouse clicks for main window while a script is running & show a warning - * Show a confirm prompt if trying to quit QuPath while a script is running + * Show a 'confirm' prompt if trying to quit QuPath while a script is running * Adapted "Show log in console" option gives better control of script output (turn off to see less console output) * Improved OMERO web API support * Supports a wider range of URLs, including import for multiple images via one 'link' URL diff --git a/qupath-core/src/main/java/qupath/lib/objects/PathObjectTools.java b/qupath-core/src/main/java/qupath/lib/objects/PathObjectTools.java index 6e2c6154a..d74ef2e8c 100644 --- a/qupath-core/src/main/java/qupath/lib/objects/PathObjectTools.java +++ b/qupath-core/src/main/java/qupath/lib/objects/PathObjectTools.java @@ -817,7 +817,15 @@ public static Collection getObjectsForLocation(final PathObjectHiera if (hierarchy == null) return Collections.emptyList(); Set pathObjects = new HashSet<>(8); - hierarchy.getObjectsForRegion(PathObject.class, ImageRegion.createInstance((int)x, (int)y, 1, 1, zPos, tPos), pathObjects); + // Introduce searchWidth to address https://github.com/qupath/qupath/issues/1552 - + // previously points were often missed + int searchWidth = (int)Math.ceil(Math.max(vertexDistance * 2, 2)); + hierarchy.getObjectsForRegion(PathObject.class, ImageRegion.createInstance( + (int)(x - searchWidth/2), + (int)(y - searchWidth/2), + searchWidth, + searchWidth, + zPos, tPos), pathObjects); if (vertexDistance < 0) removePoints(pathObjects); // Ensure we don't have any PointROIs diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/tools/handlers/ToolUtils.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/tools/handlers/ToolUtils.java index 34fb3c2ab..db3520e54 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/tools/handlers/ToolUtils.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/tools/handlers/ToolUtils.java @@ -24,6 +24,7 @@ package qupath.lib.gui.viewer.tools.handlers; +import qupath.lib.gui.prefs.PathPrefs; import qupath.lib.gui.viewer.QuPathViewer; import qupath.lib.objects.PathObject; import qupath.lib.objects.PathObjectTools; @@ -96,9 +97,13 @@ static List getSelectableObjectList(QuPathViewer viewer, double x, d PathObjectHierarchy hierarchy = viewer == null ? null : viewer.getHierarchy(); if (hierarchy == null) return Collections.emptyList(); - + // Note that this means that point display size can impact how easy it is to select lines as well, + // but it helps address https://github.com/qupath/qupath/issues/1552 + double vertexTolerance = Math.max( + PathPrefs.pointRadiusProperty().get() * viewer.getDownsampleFactor(), + viewer.getMaxROIHandleSize()); Collection pathObjects = PathObjectTools.getObjectsForLocation( - hierarchy, x, y, viewer.getZPosition(), viewer.getTPosition(), viewer.getMaxROIHandleSize()); + hierarchy, x, y, viewer.getZPosition(), viewer.getTPosition(), vertexTolerance); if (pathObjects.isEmpty()) return Collections.emptyList(); List pathObjectList = new ArrayList<>(pathObjects); From d5ceb5dee17f03fc5f81262750c93439595e0aaa Mon Sep 17 00:00:00 2001 From: Pete Date: Fri, 28 Jun 2024 17:04:18 +0100 Subject: [PATCH 106/866] Improve color reconvolution Support stain intensity scaling when applying color deconvolution + reconvolution. Fix bug in `pow10Approx` that could cause an `ArrayOutOfBoundsException`. --- .../qupath/lib/color/ColorTransformer.java | 130 ++++++++---------- 1 file changed, 58 insertions(+), 72 deletions(-) diff --git a/qupath-core/src/main/java/qupath/lib/color/ColorTransformer.java b/qupath-core/src/main/java/qupath/lib/color/ColorTransformer.java index 79b83e0a9..afa503f3a 100644 --- a/qupath-core/src/main/java/qupath/lib/color/ColorTransformer.java +++ b/qupath-core/src/main/java/qupath/lib/color/ColorTransformer.java @@ -1127,7 +1127,7 @@ public static float colorDeconvolveRGBPixel(int rgb, ColorDeconvolutionStains st */ private static double pow10Approx(double x) { int ind = (int)(x / POW_10_INC); - if (ind < 0 || ind > POW_10_TABLE.length) + if (ind < 0 || ind >= POW_10_TABLE.length) return Math.exp(-LOG_10 * x); return POW_10_TABLE[ind]; } @@ -1144,13 +1144,20 @@ private static double pow10Approx(double x) { * @param stainsOutput * @param discardResidual * @param bufOutput - * @return + * @return output RGB buffer */ public static int[] colorDeconvolveReconvolveRGBArray(int[] buf, ColorDeconvolutionStains stainsInput, ColorDeconvolutionStains stainsOutput, boolean discardResidual, int[] bufOutput) { - return colorDeconvolveReconvolveRGBArray(buf, stainsInput, stainsOutput, discardResidual, bufOutput, 1f, 0f); + double[] scales = null; + if (discardResidual) { + scales = new double[3]; + for (int i = 0; i < 3; i++) { + scales[i] = stainsInput.getStain(i + 1).isResidual() ? 0.0 : 1.0; + } + } + return colorDeconvolveReconvolveRGBArray(buf, stainsInput, stainsOutput, bufOutput, scales); } - + /** * Deconvolve RGB array with one set of stain vectors, and reconvolve with another - with optional scaling. *

@@ -1158,21 +1165,35 @@ public static int[] colorDeconvolveReconvolveRGBArray(int[] buf, ColorDeconvolut * Otherwise, if bufOutput == null, a new output array will be created. *

* Note: If {@code stainsInput} is null, the returned array will be filled with zeros. - * - * @param buf - * @param stainsInput - * @param stainsOutput - * @param discardResidual - * @param bufOutput - * @param scale - * @param offset - * @return + * + * @param buf buffer of input RGB values + * @param stainsInput stain vectors for deconvolution + * @param stainsOutput stain vectors for reconvolution + * @param bufOutput output RGB buffer + * @param stainScales optional array of scale values for each stain (e.g. [1, 2, 0] to double the second stain and + * discard the third) + * @return output RGB buffer + * + * @since v0.6.0 + * @implNote this method changed in v0.6.0 to support scaling of the stains, and to retain the alpha value. */ - public static int[] colorDeconvolveReconvolveRGBArray(int[] buf, ColorDeconvolutionStains stainsInput, ColorDeconvolutionStains stainsOutput, boolean discardResidual, int[] bufOutput, float scale, float offset) { + public static int[] colorDeconvolveReconvolveRGBArray(int[] buf, + ColorDeconvolutionStains stainsInput, + ColorDeconvolutionStains stainsOutput, + int[] bufOutput, + double[] stainScales) { if (bufOutput == null || bufOutput.length < buf.length) bufOutput = new int[buf.length]; else if (stainsInput == null) Arrays.fill(bufOutput, 0); + + // Rescale the stains if needed + double[] scales = null; + if (stainScales != null) { + scales = new double[3]; + Arrays.fill(scales, 1.0); + System.arraycopy(stainScales, 0, scales, 0, Math.min(scales.length, stainScales.length)); + } // Handle case where we have no stains if (stainsInput == null) @@ -1190,11 +1211,7 @@ else if (stainsInput == null) double s20 = matInv[2][0]; double s21 = matInv[2][1]; double s22 = matInv[2][2]; - // If the third stain isn't actually a residual, we shouldn't discard it - discardResidual = discardResidual && stainsInput.getStain(3).isResidual(); - -// discardResidual = false; - + // Extract output values double d00 = stainsOutput.getStain(1).getRed(); double d01 = stainsOutput.getStain(1).getGreen(); @@ -1215,71 +1232,40 @@ else if (stainsInput == null) double[] od_lut_red = ColorDeconvolutionHelper.makeODLUT(stainsInput.getMaxRed()); double[] od_lut_green = ColorDeconvolutionHelper.makeODLUT(stainsInput.getMaxGreen()); double[] od_lut_blue = ColorDeconvolutionHelper.makeODLUT(stainsInput.getMaxBlue()); - + for (int i = 0; i < buf.length; i++) { int c = buf[i]; + + // Retain alpha + int a = ColorTools.alpha(c); + // Extract RGB values & convert to optical densities using a lookup table double r = od_lut_red[(c & 0xff0000) >> 16]; double g = od_lut_green[(c & 0xff00) >> 8]; double b = od_lut_blue[c & 0xff]; - + // Apply deconvolution - double stain1 = r*s00 + g*s10 + b*s20; - double stain2 = r*s01 + g*s11 + b*s21; -// double stain3 = r*s02 + g*s12 + b*s22; - double stain3 = discardResidual ? 0 : r*s02 + g*s12 + b*s22; - -// // Apply reconvolution & convert back to 8-bit (or thereabouts) -// r = Math.pow(10, -stain1 * d00 - stain2 * d10 - stain3 * d20) * maxRed; -// g = Math.pow(10, -stain1 * d01 - stain2 * d11 - stain3 * d21) * maxGreen; -// b = Math.pow(10, -stain1 * d02 - stain2 * d12 - stain3 * d22) * maxBlue; - // This implementation is considerably faster than Math.pow... but still not very fast -// if (discardResidual) { -// r = Math.exp(-log10 * (stain1 * d00 + stain2 * d10)) * maxRed; -// g = Math.exp(-log10 * (stain1 * d01 + stain2 * d11)) * maxGreen; -// b = Math.exp(-log10 * (stain1 * d02 + stain2 * d12)) * maxBlue; -// } else { -// r = Math.exp(-log10 * (stain1 * d00 + stain2 * d10 + stain3 * d20)) * maxRed; -// g = Math.exp(-log10 * (stain1 * d01 + stain2 * d11 + stain3 * d21)) * maxGreen; -// b = Math.exp(-log10 * (stain1 * d02 + stain2 * d12 + stain3 * d22)) * maxBlue; -// } + double stain1 = r * s00 + g * s10 + b * s20; + double stain2 = r * s01 + g * s11 + b * s21; + double stain3 = r * s02 + g * s12 + b * s22; - if (discardResidual) { - r = pow10Approx(stain1 * d00 + stain2 * d10) * maxRed; - g = pow10Approx(stain1 * d01 + stain2 * d11) * maxGreen; - b = pow10Approx(stain1 * d02 + stain2 * d12) * maxBlue; - } else { - r = pow10Approx(stain1 * d00 + stain2 * d10 + stain3 * d20) * maxRed; - g = pow10Approx(stain1 * d01 + stain2 * d11 + stain3 * d21) * maxGreen; - b = pow10Approx(stain1 * d02 + stain2 * d12 + stain3 * d22) * maxBlue; + // Scale the stains, if required + if (scales != null) { + stain1 *= scales[0]; + stain2 *= scales[1]; + stain3 *= scales[2]; } - -// // This is pretty odd, but about the same speed as the exp method -// r = Arrays.binarySearch(od_lut_red2, (stain1 * d00 + stain2 * d10 + stain3 * d20)); -// r = r < 0 ? 256 + r : 255 - r; -// g = Arrays.binarySearch(od_lut_green2, (stain1 * d01 + stain2 * d11 + stain3 * d21)); -// g = g < 0 ? 256 + g : 255 - g; -// b = Arrays.binarySearch(od_lut_blue2, (stain1 * d02 + stain2 * d12 + stain3 * d22)); -// b = b < 0 ? 256 + b : 255 - b; - -//// // This is pretty odd, but about the same speed as the exp method -// r = getIndex(od_lut_red, (stain1 * d00 + stain2 * d10 + stain3 * d20)); -// g = getIndex(od_lut_green, (stain1 * d01 + stain2 * d11 + stain3 * d21)); -// b = getIndex(od_lut_blue, (stain1 * d02 + stain2 * d12 + stain3 * d22)); - -// // Confirming, it really is the exp that makes it slow... -// r = 255 - log10 * (stain1 * d00 + stain2 * d10 + stain3 * d20) * 50; -// g = 255 - log10 * (stain1 * d01 + stain2 * d11 + stain3 * d21) * 50; -// b = 255 - log10 * (stain1 * d02 + stain2 * d12 + stain3 * d22) * 50; - + // Perform the rescaling + r = pow10Approx(stain1 * d00 + stain2 * d10 + stain3 * d20) * maxRed; + g = pow10Approx(stain1 * d01 + stain2 * d11 + stain3 * d21) * maxGreen; + b = pow10Approx(stain1 * d02 + stain2 * d12 + stain3 * d22) * maxBlue; // Store the result - bufOutput[i] = (ColorTools.do8BitRangeCheck((r + offset) * scale) << 16) + - (ColorTools.do8BitRangeCheck((g + offset) * scale) << 8) + - ColorTools.do8BitRangeCheck((b + offset) * scale); - -// bufOutput[i] = ColorTransformer.makeRGBwithRangeCheck((float)(stain2)*100, null); + bufOutput[i] = ColorTools.packARGB(a, + ColorTools.do8BitRangeCheck(r), + ColorTools.do8BitRangeCheck(g), + ColorTools.do8BitRangeCheck(b)); } return bufOutput; } From bd3542420bb9f97c92e8d22d4aab2e0daa5eef65 Mon Sep 17 00:00:00 2001 From: Pete Date: Mon, 1 Jul 2024 17:21:47 +0100 Subject: [PATCH 107/866] Initial stain normalization support Support two types of 'normalization': - Subtract an offset, and scale the result (per channel) - intended for fluorescence - Apply color deconvolution, then re-convolve with standard stain vectors after optional channel rescaling - intended for brightfield Both methods are JSON-serializable, so can be used within projects. --- .../lib/images/servers/ImageServers.java | 53 ++++++- .../images/servers/NormalizedImageServer.java | 90 +++++++++++ .../servers/TiledTransformingImageServer.java | 73 +++++++++ .../servers/TransformedServerBuilder.java | 86 +++++++++-- .../transforms/BufferedImageNormalizer.java | 43 ++++++ .../ColorDeconvolutionNormalizer.java | 72 +++++++++ .../SubtractOffsetAndScaleNormalizer.java | 143 ++++++++++++++++++ 7 files changed, 542 insertions(+), 18 deletions(-) create mode 100644 qupath-core/src/main/java/qupath/lib/images/servers/NormalizedImageServer.java create mode 100644 qupath-core/src/main/java/qupath/lib/images/servers/TiledTransformingImageServer.java create mode 100644 qupath-core/src/main/java/qupath/lib/images/servers/transforms/BufferedImageNormalizer.java create mode 100644 qupath-core/src/main/java/qupath/lib/images/servers/transforms/ColorDeconvolutionNormalizer.java create mode 100644 qupath-core/src/main/java/qupath/lib/images/servers/transforms/SubtractOffsetAndScaleNormalizer.java diff --git a/qupath-core/src/main/java/qupath/lib/images/servers/ImageServers.java b/qupath-core/src/main/java/qupath/lib/images/servers/ImageServers.java index 1b2deb926..f0148d7af 100644 --- a/qupath-core/src/main/java/qupath/lib/images/servers/ImageServers.java +++ b/qupath-core/src/main/java/qupath/lib/images/servers/ImageServers.java @@ -64,6 +64,9 @@ import qupath.lib.images.servers.RotatedImageServer.Rotation; import qupath.lib.images.servers.SparseImageServer.SparseImageServerManagerRegion; import qupath.lib.images.servers.SparseImageServer.SparseImageServerManagerResolution; +import qupath.lib.images.servers.transforms.BufferedImageNormalizer; +import qupath.lib.images.servers.transforms.ColorDeconvolutionNormalizer; +import qupath.lib.images.servers.transforms.SubtractOffsetAndScaleNormalizer; import qupath.lib.io.GsonTools; import qupath.lib.io.GsonTools.SubTypeAdapterFactory; import qupath.lib.projects.Project; @@ -95,12 +98,19 @@ public class ImageServers { .registerSubtype(PyramidGeneratingServerBuilder.class, "pyramidize") // For consistency, this would ideally be pyramidalize... but need to keep backwards-compatibility .registerSubtype(ReorderRGBServerBuilder.class, "swapRedBlue") .registerSubtype(ColorDeconvolutionServerBuilder.class, "color_deconvolved") + .registerSubtype(NormalizedImageServerBuilder.class, "normalized") ; - + + private static GsonTools.SubTypeAdapterFactory normalizerFactory = + GsonTools.createSubTypeAdapterFactory(BufferedImageNormalizer.class, "normalizerType") + .registerSubtype(ColorDeconvolutionNormalizer.class, "colorDeconvolution") + .registerSubtype(SubtractOffsetAndScaleNormalizer.class, "offsetAndScale"); + static { GsonTools.getDefaultBuilder() .registerTypeAdapterFactory(ImageServers.getImageServerTypeAdapterFactory(true)) - .registerTypeAdapterFactory(ImageServers.getServerBuilderFactory()); + .registerTypeAdapterFactory(ImageServers.getServerBuilderFactory()) + .registerTypeAdapterFactory(ImageServers.getNormalizerFactory()); } @@ -118,6 +128,14 @@ public static TypeAdapterFactory getServerBuilderFactory() { return serverBuilderFactory; } + /** + * Get a TypeAdapterFactory to handle {@linkplain BufferedImageNormalizer BufferedImageNormalizers}. + * @return + */ + public static TypeAdapterFactory getNormalizerFactory() { + return normalizerFactory; + } + static class SparseImageServerBuilder extends AbstractServerBuilder { @@ -697,6 +715,37 @@ public ServerBuilder updateURIs(Map updateMap) { } } + + static class NormalizedImageServerBuilder extends AbstractServerBuilder { + + private ServerBuilder builder; + private BufferedImageNormalizer normalizer; + + NormalizedImageServerBuilder(ImageServerMetadata metadata, ServerBuilder builder, BufferedImageNormalizer normalizer) { + super(metadata); + this.builder = builder; + this.normalizer = normalizer; + } + + @Override + protected ImageServer buildOriginal() throws Exception { + return new NormalizedImageServer(builder.build(), normalizer); + } + + @Override + public Collection getURIs() { + return builder.getURIs(); + } + + @Override + public ServerBuilder updateURIs(Map updateMap) { + ServerBuilder newBuilder = builder.updateURIs(updateMap); + if (newBuilder == builder) + return this; + return new NormalizedImageServerBuilder(getMetadata(), newBuilder, normalizer); + } + + } static class ReorderRGBServerBuilder extends AbstractServerBuilder { diff --git a/qupath-core/src/main/java/qupath/lib/images/servers/NormalizedImageServer.java b/qupath-core/src/main/java/qupath/lib/images/servers/NormalizedImageServer.java new file mode 100644 index 000000000..687c745b3 --- /dev/null +++ b/qupath-core/src/main/java/qupath/lib/images/servers/NormalizedImageServer.java @@ -0,0 +1,90 @@ +/*- + * #%L + * This file is part of QuPath. + * %% + * Copyright (C) 2024 QuPath developers, The University of Edinburgh + * %% + * QuPath 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 3 of the + * License, or (at your option) any later version. + * + * QuPath 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 QuPath. If not, see . + * #L% + */ + +package qupath.lib.images.servers; + +import qupath.lib.images.servers.transforms.BufferedImageNormalizer; +import qupath.lib.io.GsonTools; + +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.net.URI; +import java.util.Collection; + +/** + * An ImageServer implementation used for stain normalization. + *

+ * This assumes that the input and output images have exactly the same type, and therefore there is no requirement + * to support changes in the image metadata. + * + * @since v0.6.0 + */ +public class NormalizedImageServer extends AbstractTileableImageServer { + + private final ImageServer server; + private final BufferedImageNormalizer transform; + + protected NormalizedImageServer(ImageServer server, BufferedImageNormalizer transform) { + super(); + this.server = server; + this.transform = transform; + } + + /** + * Get underlying ImageServer, i.e. the one that is being wrapped. + * + * @return + */ + protected ImageServer getWrappedServer() { + return server; + } + + @Override + public Collection getURIs() { + return getWrappedServer().getURIs(); + } + + @Override + public String getServerType() { + return "Normalizing image server"; + } + + @Override + public ImageServerMetadata getOriginalMetadata() { + return server.getOriginalMetadata(); + } + + @Override + protected BufferedImage readTile(TileRequest tileRequest) throws IOException { + var img = getWrappedServer().readRegion(tileRequest.getRegionRequest()); + return img == null ? null : transform.apply(img); + } + + @Override + protected ImageServerBuilder.ServerBuilder createServerBuilder() { + return new ImageServers.NormalizedImageServerBuilder(getMetadata(), getWrappedServer().getBuilder(), transform); + } + + @Override + protected String createID() { + return "Normalized: " + getWrappedServer().getPath() + " " + GsonTools.getInstance(false).toJson(transform); + } +} \ No newline at end of file diff --git a/qupath-core/src/main/java/qupath/lib/images/servers/TiledTransformingImageServer.java b/qupath-core/src/main/java/qupath/lib/images/servers/TiledTransformingImageServer.java new file mode 100644 index 000000000..a32de5778 --- /dev/null +++ b/qupath-core/src/main/java/qupath/lib/images/servers/TiledTransformingImageServer.java @@ -0,0 +1,73 @@ +/*- + * #%L + * This file is part of QuPath. + * %% + * Copyright (C) 2018 - 2020 QuPath developers, The University of Edinburgh + * %% + * QuPath 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 3 of the + * License, or (at your option) any later version. + * + * QuPath 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 QuPath. If not, see . + * #L% + */ + +package qupath.lib.images.servers; + +import qupath.lib.regions.RegionRequest; + +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.net.URI; +import java.util.Collection; + +/** + * An ImageServer implementation used to apply transforms to another ImageServer. + * This may implement a spatial or pixel intensity transformation, for example. + *

+ * Subclasses may only implement the methods necessary to apply the required transform, + * such as {@link #readTile(TileRequest)}. + *

+ * This class should be used in preference to {@link TransformingImageServer} when internal tile caching + * is desirable. + * + * @author Pete Bankhead + * + * @since v0.6.0 + */ +public abstract class TiledTransformingImageServer extends AbstractTileableImageServer { + + private ImageServer server; + + protected TiledTransformingImageServer(ImageServer server) { + super(); + this.server = server; + } + + /** + * Get underlying ImageServer, i.e. the one that is being wrapped. + * + * @return + */ + protected ImageServer getWrappedServer() { + return server; + } + + @Override + public Collection getURIs() { + return getWrappedServer().getURIs(); + } + + @Override + public ImageServerMetadata getOriginalMetadata() { + return server.getOriginalMetadata(); + } + +} \ No newline at end of file diff --git a/qupath-core/src/main/java/qupath/lib/images/servers/TransformedServerBuilder.java b/qupath-core/src/main/java/qupath/lib/images/servers/TransformedServerBuilder.java index 4b8fe5e17..4fcd621a5 100644 --- a/qupath-core/src/main/java/qupath/lib/images/servers/TransformedServerBuilder.java +++ b/qupath-core/src/main/java/qupath/lib/images/servers/TransformedServerBuilder.java @@ -32,6 +32,9 @@ import qupath.lib.color.ColorDeconvolutionStains; import qupath.lib.images.servers.ColorTransforms.ColorTransform; import qupath.lib.images.servers.RotatedImageServer.Rotation; +import qupath.lib.images.servers.transforms.BufferedImageNormalizer; +import qupath.lib.images.servers.transforms.ColorDeconvolutionNormalizer; +import qupath.lib.images.servers.transforms.SubtractOffsetAndScaleNormalizer; import qupath.lib.regions.ImageRegion; /** @@ -144,8 +147,8 @@ public TransformedServerBuilder extractChannels(String... names) { * @return */ public TransformedServerBuilder maxChannelProject() { - server = new ChannelTransformFeatureServer(server, - Arrays.asList(ColorTransforms.createMaximumChannelTransform())); + server = new ChannelTransformFeatureServer(server, + List.of(ColorTransforms.createMaximumChannelTransform())); return this; } @@ -154,8 +157,8 @@ public TransformedServerBuilder maxChannelProject() { * @return */ public TransformedServerBuilder averageChannelProject() { - server = new ChannelTransformFeatureServer(server, - Arrays.asList(ColorTransforms.createMeanChannelTransform())); + server = new ChannelTransformFeatureServer(server, + List.of(ColorTransforms.createMeanChannelTransform())); return this; } @@ -164,8 +167,8 @@ public TransformedServerBuilder averageChannelProject() { * @return */ public TransformedServerBuilder minChannelProject() { - server = new ChannelTransformFeatureServer(server, - Arrays.asList(ColorTransforms.createMinimumChannelTransform())); + server = new ChannelTransformFeatureServer(server, + List.of(ColorTransforms.createMinimumChannelTransform())); return this; } @@ -204,16 +207,67 @@ public TransformedServerBuilder concatChannels(ImageServer... add } return concatChannels(Arrays.asList(additionalChannels)); } - -// /** -// * Concatenate additional server along the 'channels' dimension. -// * @param additionalChannel additional server from which channels will be added; note that the server should be -// * of an appropriate type and dimension for concatenation. -// * @return -// */ -// public TransformedServerBuilder concatChannel(ImageServer additionalChannel) { -// return concatChannels(Arrays.asList(additionalChannel)); -// } + + /** + * Subtract a constant offset from all channels, without clipping. + * @param offsets a single offset to subtract from all channels, or an array of offsets to subtract from each channel. + * @return + */ + public TransformedServerBuilder subtractOffset(double... offsets) { + return normalize(SubtractOffsetAndScaleNormalizer.createSubtractOffset(offsets)); + } + + /** + * Subtract a constant offset from all channels, clipping the result to be ≥ 0. + * @param offsets a single offset to subtract from all channels, or an array of offsets to subtract from each channel. + * @return + */ + public TransformedServerBuilder subtractOffsetAndClipZero(double... offsets) { + return normalize(SubtractOffsetAndScaleNormalizer.createSubtractOffsetAndClipZero(offsets)); + } + + /** + * Subtract a constant offset from all channels, then multiply the result by a scale factor. + * @param offsets a single offset to subtract from all channels, or an array of offsets to subtract from each channel. + * @param scales a single scale factor to apply to all channels, or an array of scale factors to apply to each channel. + * @return + */ + public TransformedServerBuilder subtractOffsetAndScale(double[] offsets, double[] scales) { + return normalize(SubtractOffsetAndScaleNormalizer.create(offsets, scales)); + } + + /** + * Scale all channels by a constant factor. + * @param scales a single scale factor to apply to all channels, or an array of scale factors to apply to each channel. + * @return + */ + public TransformedServerBuilder scaleChannels(double... scales) { + return normalize(SubtractOffsetAndScaleNormalizer.create(null, scales)); + } + + /** + * Normalize stains using color deconvolution and reconvolution. + * @param stainsInput stain vectors to apply to deconvolve the input image, which should relate to the original colors + * @param stainsOutput stain vectors to apply for reconvolution, determining the output colors + * @param scales optional array of scale factors to apply to each deconvolved channel. + * A scale factor of 1.0 will leave the channel unchanged, while a scale of 0.0 will suppress the channel. + * @return + */ + public TransformedServerBuilder stainNormalize(ColorDeconvolutionStains stainsInput, ColorDeconvolutionStains stainsOutput, double... scales) { + return normalize(ColorDeconvolutionNormalizer.create(stainsInput, stainsOutput, scales)); + } + + /** + * Normalize the image using the provided normalizer. + * @param normalizer + * @return + * @ImplNote To use this method to create an image that can be added to a project, the normalizers must be JSON-serializable + * and registered under {@link ImageServers#getNormalizerFactory()}. + */ + public TransformedServerBuilder normalize(BufferedImageNormalizer normalizer) { + this.server = new NormalizedImageServer(server, normalizer); + return this; + } /** * Get the {@link ImageServer} that applies all the requested transforms. diff --git a/qupath-core/src/main/java/qupath/lib/images/servers/transforms/BufferedImageNormalizer.java b/qupath-core/src/main/java/qupath/lib/images/servers/transforms/BufferedImageNormalizer.java new file mode 100644 index 000000000..36bd227f6 --- /dev/null +++ b/qupath-core/src/main/java/qupath/lib/images/servers/transforms/BufferedImageNormalizer.java @@ -0,0 +1,43 @@ +/*- + * #%L + * This file is part of QuPath. + * %% + * Copyright (C) 2024 QuPath developers, The University of Edinburgh + * %% + * QuPath 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 3 of the + * License, or (at your option) any later version. + * + * QuPath 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 QuPath. If not, see . + * #L% + */ + +package qupath.lib.images.servers.transforms; + +import java.awt.image.BufferedImage; + +/** + * Interface for normalizing a BufferedImage. + *

+ * Implementations should be stateless, thread-safe and JSON-serializable. + * + * @since v0.6.0 + */ +public interface BufferedImageNormalizer { + + /** + * Apply the normalization to the input image. + * Note that the input image may be modified in place. + * @param input + * @return + */ + BufferedImage apply(BufferedImage input); + +} diff --git a/qupath-core/src/main/java/qupath/lib/images/servers/transforms/ColorDeconvolutionNormalizer.java b/qupath-core/src/main/java/qupath/lib/images/servers/transforms/ColorDeconvolutionNormalizer.java new file mode 100644 index 000000000..045b99fb7 --- /dev/null +++ b/qupath-core/src/main/java/qupath/lib/images/servers/transforms/ColorDeconvolutionNormalizer.java @@ -0,0 +1,72 @@ +/*- + * #%L + * This file is part of QuPath. + * %% + * Copyright (C) 2024 QuPath developers, The University of Edinburgh + * %% + * QuPath 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 3 of the + * License, or (at your option) any later version. + * + * QuPath 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 QuPath. If not, see . + * #L% + */ + +package qupath.lib.images.servers.transforms; + +import qupath.lib.color.ColorDeconvolutionStains; +import qupath.lib.color.ColorTransformer; + +import java.awt.image.BufferedImage; + +/** + * Normalizes an image by applying color deconvolution to RGB input, rescaling intensities, and using color + * 'reconvolution' to generate a new RGB image. + * + * @since v0.6.0 + */ +public class ColorDeconvolutionNormalizer implements BufferedImageNormalizer { + + private final ColorDeconvolutionStains stainsInput; + private final ColorDeconvolutionStains stainsOutput; + private final double[] scales; + + private ColorDeconvolutionNormalizer(ColorDeconvolutionStains stainsInput, ColorDeconvolutionStains stainsOutput, double[] scales) { + this.stainsInput = stainsInput; + this.stainsOutput = stainsOutput; + this.scales = scales == null || scales.length == 0 ? null : scales.clone(); + } + + /** + * Create a normalizer using color deconvolution and reconvolution. + * @param stainsInput stain vectors to apply to deconvolve the input image, which should relate to the original colors + * @param stainsOutput stain vectors to apply for reconvolution, determining the output colors + * @param scales optional array of scale factors to apply to each deconvolved channel. + * A scale factor of 1.0 will leave the channel unchanged, while a scale of 0.0 will suppress the channel. + * @return + */ + public static ColorDeconvolutionNormalizer create(ColorDeconvolutionStains stainsInput, ColorDeconvolutionStains stainsOutput, double... scales) { + return new ColorDeconvolutionNormalizer(stainsInput, stainsOutput, scales); + } + + @Override + public BufferedImage apply(BufferedImage img) { + var rgb = img.getRGB(0, 0, img.getWidth(), img.getHeight(), null, 0, img.getWidth()); + ColorTransformer.colorDeconvolveReconvolveRGBArray( + rgb, + stainsInput, + stainsOutput, + rgb, + scales + ); + img.setRGB(0, 0, img.getWidth(), img.getHeight(), rgb, 0, img.getWidth()); + return img; + } +} diff --git a/qupath-core/src/main/java/qupath/lib/images/servers/transforms/SubtractOffsetAndScaleNormalizer.java b/qupath-core/src/main/java/qupath/lib/images/servers/transforms/SubtractOffsetAndScaleNormalizer.java new file mode 100644 index 000000000..d23229589 --- /dev/null +++ b/qupath-core/src/main/java/qupath/lib/images/servers/transforms/SubtractOffsetAndScaleNormalizer.java @@ -0,0 +1,143 @@ +/*- + * #%L + * This file is part of QuPath. + * %% + * Copyright (C) 2024 QuPath developers, The University of Edinburgh + * %% + * QuPath 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 3 of the + * License, or (at your option) any later version. + * + * QuPath 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 QuPath. If not, see . + * #L% + */ + +package qupath.lib.images.servers.transforms; + +import qupath.lib.common.GeneralTools; + +import java.awt.image.BufferedImage; + +/** + * Normalizes the pixel values of a BufferedImage by subtracting and offset and multiplying by a scale factor. + *

+ * An expected use is to subtract a constant background value in a fluorescence image, with optional channel + * rescaling. + * + * @since v0.6.0 + */ +public class SubtractOffsetAndScaleNormalizer implements BufferedImageNormalizer { + + private final double[] offsets; + private final double[] scales; + private final double minClip; + private final double maxClip; + + private SubtractOffsetAndScaleNormalizer(double[] offsets, double[] scales, double minClip, double maxClip) { + this.scales = scales == null ? null : scales.clone(); + this.offsets = offsets == null ? null : offsets.clone(); + this.minClip = minClip; + this.maxClip = maxClip; + } + + /** + * Create a normalizer that scales each channel by a constant. + * @param scales + * @return + */ + public static SubtractOffsetAndScaleNormalizer createScaled(double... scales) { + return createWithClipRange(null, scales, Double.NEGATIVE_INFINITY, Double.POSITIVE_INFINITY); + } + + /** + * Create a normalizer that subtracts a constant from each channel, without clipping. + * @param offsets + * @return + */ + public static SubtractOffsetAndScaleNormalizer createSubtractOffset(double... offsets) { + return createWithClipRange(offsets, null, Double.NEGATIVE_INFINITY, Double.POSITIVE_INFINITY); + } + + /** + * Create a normalizer that subtracts a constant from each channel, clipping the lower bound to 0. + * @param offsets + * @return + */ + public static SubtractOffsetAndScaleNormalizer createSubtractOffsetAndClipZero(double... offsets) { + return createWithClipRange(offsets, null, 0, Double.POSITIVE_INFINITY); + } + + /** + * Create a normalizer that subtracts a constant from each channel, then multiples the result by a scale factor. + * The result is not clipped. + * @param offsets + * @param scales + * @return + */ + public static SubtractOffsetAndScaleNormalizer create(double[] offsets, double[] scales) { + return createWithClipRange(offsets, scales, Double.NEGATIVE_INFINITY, Double.POSITIVE_INFINITY); + } + + /** + * Create a normalizer that subtracts a constant from each channel, then multiples the result by a scale factor - + * clipping the result to a defined range. + * @param offsets + * @param scales + * @param minClip + * @param maxClip + * @return + */ + public static SubtractOffsetAndScaleNormalizer createWithClipRange(double[] offsets, double[] scales, double minClip, double maxClip) { + return new SubtractOffsetAndScaleNormalizer(offsets, scales, minClip, maxClip); + } + + @Override + public BufferedImage apply(BufferedImage img) { + var raster = img.getRaster(); + int w = img.getWidth(); + int h = img.getHeight(); + double[] pixels = null; + for (int b = 0; b < raster.getNumBands(); b++) { + pixels = raster.getSamples(0, 0, w, h, b, pixels); + double offset = offsetForChannel(b); + double scale = scaleForChannel(b); + if (offset != 0 || scale != 1) { + for (int i = 0; i < pixels.length; i++) { + pixels[i] = GeneralTools.clipValue((pixels[i] - offset) * scale, minClip, maxClip); + } + raster.setSamples(0, 0, w, h, b, pixels); + } + } + return img; + } + + private double scaleForChannel(int channel) { + if (scales == null) + return 1.0; + if (channel < scales.length) + return scales[channel]; + else if (scales.length == 1) + return scales[0]; + else + throw new IllegalArgumentException("Channel index out of bounds: " + channel); + } + + private double offsetForChannel(int channel) { + if (offsets == null) + return 1.0; + if (channel < offsets.length) + return offsets[channel]; + else if (offsets.length == 1) + return offsets[0]; + else + throw new IllegalArgumentException("Channel index out of bounds: " + channel); + } + +} From 2cc676e909a8d45b52837cf91dc83b4ca1bc2372 Mon Sep 17 00:00:00 2001 From: petebankhead Date: Tue, 2 Jul 2024 18:51:48 +0100 Subject: [PATCH 108/866] Update dependencies --- CHANGELOG.md | 6 ++++-- gradle/libs.versions.toml | 12 ++++++------ gradle/wrapper/gradle-wrapper.jar | Bin 43462 -> 43453 bytes gradle/wrapper/gradle-wrapper.properties | 2 +- gradlew.bat | 20 ++++++++++---------- 5 files changed, 21 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ee1140867..895431ea1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,14 +24,16 @@ This is a *work in progress* for the next major release. * Bio-Formats 7.3.0 * Commonmark 0.22.0 * DeepJavaLibrary 0.28.0 -* Groovy 4.0.21 +* Groovy 4.0.22 * Gson 2.11.0 -* Guava 33.1.0-jre +* Guava 33.2.0-jre * JavaFX 22.0.1 +* JFreeSVG 5.0.6 * JNA 5.14.0 * Picocli 4.7.6 * OpenCV 4.9.0 * OpenJDK 21 +* RichTextFX 0.11.3 * slf4j 2.0.12 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 46812b2cc..5cf9235b7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,7 +2,7 @@ bioformats = "7.3.0" bioimageIoSpec = "0.1.0" -omeZarrReader = "0.4.1" +omeZarrReader = "0.5.1" blosc = "1.21.5" commonmark = "0.22.0" @@ -12,9 +12,9 @@ controlsFX = "11.1.2" deepJavaLibrary = "0.28.0" -groovy = "4.0.21" +groovy = "4.0.22" gson = "2.11.0" -guava = "33.1.0-jre" +guava = "33.2.0-jre" ikonli = "12.3.1" imagej = "1.54f" @@ -30,7 +30,7 @@ cuda = "12.3-8.9-1.5.10" # Warning! JavaFX 20.0.1 and later seem to break search links in Javadocs javafx = "22.0.1" jna = "5.14.0" -jfreeSvg = "5.0.5" +jfreeSvg = "5.0.6" jfxtras = "17-r1" jts = "1.19.0" junit = "5.9.2" @@ -45,7 +45,7 @@ openslide = "4.0.0" picocli = "4.7.6" qupath-fxtras = "0.1.5-SNAPSHOT" -richtextfx = "0.11.2" +richtextfx = "0.11.3" slf4j = "2.0.12" snakeyaml = "2.2" @@ -128,4 +128,4 @@ javafx = { id = "org.openjfx.javafxplugin", version = "0 #javafx = { id = "org.openjfx.javafxplugin", version = "0.0.14" } jpackage = { id = "org.beryx.runtime", version = "1.13.0" } # Non-modular # jpackage = { id = "org.beryx.jlink", version = "2.26.0" } # Modular -license-report = { id = "com.github.jk1.dependency-license-report", version = "2.5" } +license-report = { id = "com.github.jk1.dependency-license-report", version = "2.8" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index d64cd4917707c1f8861d8cb53dd15194d4248596..e6441136f3d4ba8a0da8d277868979cfbc8ad796 100644 GIT binary patch delta 34118 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 delta 34176 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 SGW6ptSs!X7{rG*o_g?|vpSEZK diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index b82aa23a4..a4413138c 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew.bat b/gradlew.bat index 6689b85be..7101f8e46 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -43,11 +43,11 @@ set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 if %ERRORLEVEL% equ 0 goto execute -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 goto fail @@ -57,11 +57,11 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 goto fail From 6b5f80d9b138256ecfda792a334004cc435f0a94 Mon Sep 17 00:00:00 2001 From: Pete Date: Wed, 3 Jul 2024 07:35:48 +0100 Subject: [PATCH 109/866] Fix project metadata gradle exception Attempts to edit the project metadata would fail when run from gradle/IntelliJ. See https://github.com/controlsfx/controlsfx/issues/1505 --- qupath-app/build.gradle | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/qupath-app/build.gradle b/qupath-app/build.gradle index 38de5b375..ad855a587 100644 --- a/qupath-app/build.gradle +++ b/qupath-app/build.gradle @@ -78,6 +78,10 @@ application { applicationDefaultJvmArgs << '--add-opens' applicationDefaultJvmArgs << 'javafx.graphics/com.sun.javafx.css=ALL-UNNAMED' + // Necessary when using ./gradlew run to support project metadata autocomplete + // See https://github.com/controlsfx/controlsfx/issues/1505 + applicationDefaultJvmArgs << '--add-opens' + applicationDefaultJvmArgs << 'javafx.base/com.sun.javafx.event=ALL-UNNAMED' } /** From 2ed6fa3c64ee671280b8bdff6720e7d09e9a0940 Mon Sep 17 00:00:00 2001 From: Pete Date: Wed, 3 Jul 2024 07:37:04 +0100 Subject: [PATCH 110/866] Simplify project browser Extract methods and incorporate @alanocallaghan's change from https://github.com/qupath/qupath/pull/1538 --- .../qupath/lib/gui/panes/ProjectBrowser.java | 453 +++++++++--------- 1 file changed, 237 insertions(+), 216 deletions(-) diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/panes/ProjectBrowser.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/panes/ProjectBrowser.java index 10efa70f2..a815fcb5d 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/panes/ProjectBrowser.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/panes/ProjectBrowser.java @@ -4,7 +4,7 @@ * %% * Copyright (C) 2014 - 2016 The Queen's University of Belfast, Northern Ireland * Contact: IP Management (ipmanagement@qub.ac.uk) - * Copyright (C) 2018 - 2021 QuPath developers, The University of Edinburgh + * Copyright (C) 2018 - 2024 QuPath developers, The University of Edinburgh * %% * QuPath is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as @@ -283,231 +283,22 @@ public ProjectBrowser(final QuPathGUI qupath) { ContextMenu getPopup() { Action actionOpenImage = new Action("Open image", e -> qupath.openImageEntry(getSelectedEntry())); - Action actionRemoveImage = new Action("Remove image(s)", e -> { - Collection imageRows = getSelectedImageRowsRecursive(); - Collection> entries = ProjectTreeRow.getEntries(imageRows); - - if (entries.isEmpty()) - return; - - // Don't allow us to remove any entries that are currently open (in any viewer) - for (var viewer : qupath.getAllViewers()) { - var imageData = viewer.getImageData(); - var entry = imageData == null ? null : getProject().getEntry(imageData); - if (entry != null && entries.contains(entry)) { - Dialogs.showErrorMessage("Remove project entries", "Please close all images you want to remove!"); - return; - } - } - - if (entries.size() == 1) { - if (!Dialogs.showConfirmDialog("Remove project entry", "Remove " + entries.iterator().next().getImageName() + " from project?")) - return; - } else if (!Dialogs.showYesNoDialog("Remove project entries", String.format("Remove %d entries?", entries.size()))) - return; - - var result = Dialogs.showYesNoCancelDialog("Remove project entries", - "Delete all associated data?"); - if (result == ButtonType.CANCEL) - return; - - project.removeAllImages(entries, result == ButtonType.YES); - refreshTree(null); - syncProject(project); - if (tree != null) { - boolean isExpanded = tree.getRoot() != null && tree.getRoot().isExpanded(); - tree.setRoot(model.getRoot()); - tree.getRoot().setExpanded(isExpanded); - } - }); + Action actionRemoveImage = new Action("Remove image(s)", e -> promptToRemoveSelectedImages()); - Action actionDuplicateImages = new Action("Duplicate image(s)", e -> { - Collection imageRows = getSelectedImageRowsRecursive(); - if (imageRows.isEmpty()) { - logger.debug("Nothing to duplicate - no entries selected"); - return; - } - - boolean singleImage = false; - String name = ""; - String title = "Duplicate images"; - String namePrompt = "Append to image name"; - String nameHelp = "Specify text to append to the image name to distinguish duplicated images"; - if (imageRows.size() == 1) { - title = "Duplicate image"; - namePrompt = "Duplicate image name"; - nameHelp = "Specify name for the duplicated image"; - singleImage = true; - name = imageRows.iterator().next().getDisplayableString(); - name = GeneralTools.generateDistinctName( - name, - project.getImageList().stream().map(p -> p.getImageName()).collect(Collectors.toSet())); - } - var params = new ParameterList() - .addStringParameter("name", namePrompt, name, nameHelp) - .addBooleanParameter("copyData", "Also duplicate data files", true, "Duplicate any associated data files along with the image"); - - if (!GuiTools.showParameterDialog(title, params)) - return; - - boolean copyData = params.getBooleanParameterValue("copyData"); - name = params.getStringParameterValue("name"); - - // Ensure we have a single space and then the text to append, with extra whitespace removed - if (!singleImage && !name.isBlank()) - name = " " + name.strip(); - - for (var imageRow : imageRows) { - try { - var newEntry = project.addDuplicate(ProjectTreeRow.getEntry(imageRow), copyData); - if (newEntry != null && !name.isBlank()) { - if (singleImage) - newEntry.setImageName(name); - else - newEntry.setImageName(newEntry.getImageName() + name); - } - } catch (Exception ex) { - Dialogs.showErrorNotification("Duplicating image", "Error duplicating " + ProjectTreeRow.getEntry(imageRow).getImageName()); - logger.error(ex.getLocalizedMessage(), ex); - } - } - try { - project.syncChanges(); - } catch (Exception ex) { - logger.error("Error synchronizing project changes: " + ex.getLocalizedMessage(), ex); - } - refreshProject(); - if (imageRows.size() == 1) - logger.debug("Duplicated 1 image entry"); - else - logger.debug("Duplicated {} image entries", imageRows.size()); - }); + Action actionDuplicateImages = new Action("Duplicate image(s)", e -> promptToDuplicateSelectedImages()); - Action actionSetImageName = new Action("Rename image", e -> { - TreeItem path = tree.getSelectionModel().getSelectedItem(); - if (path == null) - return; - if (path.getValue().getType() == ProjectTreeRow.Type.IMAGE) { - if (setProjectEntryImageName(ProjectTreeRow.getEntry(path.getValue())) && project != null) - syncProject(project); - } - }); + Action actionSetImageName = new Action("Rename image", e -> promptToRenameSelectedImage()); // Add a metadata value - Action actionAddMetadataValue = new Action("Add metadata", e -> { - Project project = getProject(); - Collection imageRows = getSelectedImageRowsRecursive(); - if (project != null && !imageRows.isEmpty()) { - TextField tfMetadataKey = new TextField(); - var suggestions = project.getImageList().stream() - .map(p -> p.getMetadataKeys()) - .flatMap(Collection::stream) - .distinct() - .sorted() - .toList(); - TextFields.bindAutoCompletion(tfMetadataKey, suggestions); - - TextField tfMetadataValue = new TextField(); - Label labKey = new Label("New key"); - Label labValue = new Label("New value"); - labKey.setLabelFor(tfMetadataKey); - labValue.setLabelFor(tfMetadataValue); - tfMetadataKey.setTooltip(new Tooltip("Enter the name for the metadata entry")); - tfMetadataValue.setTooltip(new Tooltip("Enter the value for the metadata entry")); - - ProjectImageEntry entry = imageRows.size() == 1 ? ProjectTreeRow.getEntry(imageRows.iterator().next()) : null; - int nMetadataValues = entry == null ? 0 : entry.getMetadataKeys().size(); - - GridPane pane = new GridPane(); - pane.setVgap(5); - pane.setHgap(5); - pane.add(labKey, 0, 0); - pane.add(tfMetadataKey, 1, 0); - pane.add(labValue, 0, 1); - pane.add(tfMetadataValue, 1, 1); - String name = imageRows.size() + " images"; - if (entry != null) { - name = entry.getImageName(); - if (nMetadataValues > 0) { - Label labelCurrent = new Label("Current metadata"); - TextArea textAreaCurrent = new TextArea(); - textAreaCurrent.setEditable(false); - - String keyString = entry.getMetadataSummaryString(); - if (keyString.isEmpty()) - textAreaCurrent.setText("No metadata entries yet"); - else - textAreaCurrent.setText(keyString); - textAreaCurrent.setPrefRowCount(3); - labelCurrent.setLabelFor(textAreaCurrent); - - pane.add(labelCurrent, 0, 2); - pane.add(textAreaCurrent, 1, 2); - } - } - - Dialog dialog = new Dialog<>(); - dialog.setTitle("Metadata"); - dialog.getDialogPane().getButtonTypes().setAll(ButtonType.OK, ButtonType.CANCEL); - dialog.getDialogPane().setHeaderText("Set metadata for " + name); - dialog.getDialogPane().setContent(pane); - Optional result = dialog.showAndWait(); - if (result.isPresent() && result.get() == ButtonType.OK) { - String key = tfMetadataKey.getText().trim(); - String value = tfMetadataValue.getText(); - if (key.isEmpty()) { - logger.warn("Attempted to set metadata value for {}, but key was empty!", name); - } else { - // Set metadata for all entries - for (var temp : imageRows) - ProjectTreeRow.getEntry(temp).putMetadataValue(key, value); - syncProject(project); - tree.refresh(); - } - } - - } else { - Dialogs.showErrorMessage("Edit image description", "No entry is selected!"); - } - }); + Action actionAddMetadataValue = new Action("Add metadata", e -> promptToAddMetadataToSelectedImages()); // Edit the description for the image - Action actionEditDescription = new Action("Edit description", e -> { - Project project = getProject(); - ProjectImageEntry entry = getSelectedEntry(); - if (project != null && entry != null) { - if (showDescriptionEditor(entry)) { - descriptionText.set(entry.getDescription()); - syncProject(project); - } - } else { - Dialogs.showErrorMessage("Edit image description", "No entry is selected!"); - } - }); + Action actionEditDescription = new Action("Edit description", e -> promptToEditSelectedImageDescription()); // Mask the name of the images and shuffle the entry Action actionMaskImageNames = ActionTools.createSelectableAction(PathPrefs.maskImageNamesProperty(), "Mask image names"); // Refresh thumbnail according to current display settings - Action actionRefreshThumbnail = new Action("Refresh thumbnail", e -> { - TreeItem path = tree.getSelectionModel().getSelectedItem(); - if (path == null) - return; - if (path.getValue().getType() == ProjectTreeRow.Type.IMAGE) { - ProjectImageEntry entry =ProjectTreeRow.getEntry(path.getValue()); - if (!isCurrentImage(entry)) { - logger.warn("Cannot refresh entry for image that is not open!"); - return; - } - BufferedImage imgThumbnail = qupath.getViewer().getRGBThumbnail(); - imgThumbnail = resizeForThumbnail(imgThumbnail); - try { - entry.setThumbnail(imgThumbnail); - } catch (IOException e1) { - logger.error("Error writing thumbnail", e1); - } - tree.refresh(); - } - }); + Action actionRefreshThumbnail = new Action("Refresh thumbnail", e -> promptToRefreshSelectedThumbnails()); // Open the project directory using Explorer/Finder etc. Action actionOpenProjectDirectory = createBrowsePathAction("Project...", () -> getProjectPath()); @@ -603,6 +394,236 @@ ContextMenu getPopup() { } + private void promptToEditSelectedImageDescription() { + Project project = getProject(); + ProjectImageEntry entry = getSelectedEntry(); + if (project != null && entry != null) { + if (showDescriptionEditor(entry)) { + descriptionText.set(entry.getDescription()); + syncProject(project); + } + } else { + Dialogs.showErrorMessage("Edit image description", "No entry is selected!"); + } + } + + + private void promptToRemoveSelectedImages() { + Collection imageRows = getSelectedImageRowsRecursive(); + Collection> entries = ProjectTreeRow.getEntries(imageRows); + + if (entries.isEmpty()) + return; + + // Don't allow us to remove any entries that are currently open (in any viewer) + for (var viewer : qupath.getAllViewers()) { + var imageData = viewer.getImageData(); + var entry = imageData == null ? null : getProject().getEntry(imageData); + if (entry != null && entries.contains(entry)) { + Dialogs.showErrorMessage("Remove project entries", "Please close all images you want to remove!"); + return; + } + } + + if (entries.size() == 1) { + if (!Dialogs.showConfirmDialog("Remove project entry", "Remove " + entries.iterator().next().getImageName() + " from project?")) + return; + } else if (!Dialogs.showYesNoDialog("Remove project entries", String.format("Remove %d entries?", entries.size()))) + return; + + var result = Dialogs.showYesNoCancelDialog("Remove project entries", + "Delete all associated data?"); + if (result == ButtonType.CANCEL) + return; + + project.removeAllImages(entries, result == ButtonType.YES); + refreshTree(null); + syncProject(project); + if (tree != null) { + boolean isExpanded = tree.getRoot() != null && tree.getRoot().isExpanded(); + tree.setRoot(model.getRoot()); + tree.getRoot().setExpanded(isExpanded); + } + } + + + private void promptToRefreshSelectedThumbnails() { + TreeItem path = tree.getSelectionModel().getSelectedItem(); + if (path == null) + return; + if (path.getValue().getType() == ProjectTreeRow.Type.IMAGE) { + ProjectImageEntry entry = ProjectTreeRow.getEntry(path.getValue()); + if (!isCurrentImage(entry)) { + logger.warn("Cannot refresh entry for image that is not open!"); + return; + } + BufferedImage imgThumbnail = qupath.getViewer().getRGBThumbnail(); + imgThumbnail = resizeForThumbnail(imgThumbnail); + try { + entry.setThumbnail(imgThumbnail); + } catch (IOException e1) { + logger.error("Error writing thumbnail", e1); + } + tree.refresh(); + } + } + + + private void promptToDuplicateSelectedImages() { + Collection imageRows = getSelectedImageRowsRecursive(); + if (imageRows.isEmpty()) { + logger.debug("Nothing to duplicate - no entries selected"); + return; + } + + boolean singleImage = false; + String name = ""; + String title = "Duplicate images"; + String namePrompt = "Append to image name"; + String nameHelp = "Specify text to append to the image name to distinguish duplicated images"; + if (imageRows.size() == 1) { + title = "Duplicate image"; + namePrompt = "Duplicate image name"; + nameHelp = "Specify name for the duplicated image"; + singleImage = true; + name = imageRows.iterator().next().getDisplayableString(); + name = GeneralTools.generateDistinctName( + name, + project.getImageList().stream().map(p -> p.getImageName()).collect(Collectors.toSet())); + } + var params = new ParameterList() + .addStringParameter("name", namePrompt, name, nameHelp) + .addBooleanParameter("copyData", "Also duplicate data files", true, "Duplicate any associated data files along with the image"); + + if (!GuiTools.showParameterDialog(title, params)) + return; + + boolean copyData = params.getBooleanParameterValue("copyData"); + name = params.getStringParameterValue("name"); + + // Ensure we have a single space and then the text to append, with extra whitespace removed + if (!singleImage && !name.isBlank()) + name = " " + name.strip(); + + for (var imageRow : imageRows) { + try { + var newEntry = project.addDuplicate(ProjectTreeRow.getEntry(imageRow), copyData); + if (newEntry != null && !name.isBlank()) { + if (singleImage) + newEntry.setImageName(name); + else + newEntry.setImageName(newEntry.getImageName() + name); + } + } catch (Exception ex) { + Dialogs.showErrorNotification("Duplicating image", "Error duplicating " + ProjectTreeRow.getEntry(imageRow).getImageName()); + logger.error(ex.getLocalizedMessage(), ex); + } + } + try { + project.syncChanges(); + } catch (Exception ex) { + logger.error("Error synchronizing project changes: " + ex.getLocalizedMessage(), ex); + } + refreshProject(); + if (imageRows.size() == 1) + logger.debug("Duplicated 1 image entry"); + else + logger.debug("Duplicated {} image entries", imageRows.size()); + } + + + private void promptToRenameSelectedImage() { + TreeItem path = tree.getSelectionModel().getSelectedItem(); + if (path == null) + return; + if (path.getValue().getType() == ProjectTreeRow.Type.IMAGE) { + if (setProjectEntryImageName(ProjectTreeRow.getEntry(path.getValue())) && project != null) + syncProject(project); + } + } + + + private void promptToAddMetadataToSelectedImages() { + Project project = getProject(); + Collection imageRows = getSelectedImageRowsRecursive(); + if (project != null && !imageRows.isEmpty()) { + TextField tfMetadataKey = new TextField(); + var suggestions = project.getImageList().stream() + .map(p -> p.getMetadataKeys()) + .flatMap(Collection::stream) + .distinct() + .sorted() + .toList(); + TextFields.bindAutoCompletion(tfMetadataKey, suggestions); + + TextField tfMetadataValue = new TextField(); + Label labKey = new Label("New key"); + Label labValue = new Label("New value"); + labKey.setLabelFor(tfMetadataKey); + labValue.setLabelFor(tfMetadataValue); + tfMetadataKey.setTooltip(new Tooltip("Enter the name for the metadata entry")); + tfMetadataValue.setTooltip(new Tooltip("Enter the value for the metadata entry")); + + ProjectImageEntry entry = imageRows.size() == 1 ? ProjectTreeRow.getEntry(imageRows.iterator().next()) : null; + int nMetadataValues = entry == null ? 0 : entry.getMetadataKeys().size(); + + GridPane pane = new GridPane(); + pane.setVgap(5); + pane.setHgap(5); + pane.add(labKey, 0, 0); + pane.add(tfMetadataKey, 1, 0); + pane.add(labValue, 0, 1); + pane.add(tfMetadataValue, 1, 1); + String name = imageRows.size() + " images"; + if (entry != null) { + name = entry.getImageName(); + if (nMetadataValues > 0) { + Label labelCurrent = new Label("Current metadata"); + TextArea textAreaCurrent = new TextArea(); + textAreaCurrent.setEditable(false); + + String keyString = entry.getMetadataSummaryString(); + if (keyString.isEmpty()) + textAreaCurrent.setText("No metadata entries yet"); + else + textAreaCurrent.setText(keyString); + textAreaCurrent.setPrefRowCount(3); + labelCurrent.setLabelFor(textAreaCurrent); + + pane.add(labelCurrent, 0, 2); + pane.add(textAreaCurrent, 1, 2); + } + } + + Dialog dialog = new Dialog<>(); + dialog.setTitle("Metadata"); + dialog.getDialogPane().getButtonTypes().setAll(ButtonType.OK, ButtonType.CANCEL); + dialog.getDialogPane().setHeaderText("Set metadata for " + name); + dialog.getDialogPane().setContent(pane); + Optional result = dialog.showAndWait(); + if (result.isPresent() && result.get() == ButtonType.OK) { + String key = tfMetadataKey.getText().trim(); + String value = tfMetadataValue.getText(); + if (key.isEmpty()) { + logger.warn("Attempted to set metadata value for {}, but key was empty!", name); + } else { + // Set metadata for all entries + for (var temp : imageRows) + ProjectTreeRow.getEntry(temp).putMetadataValue(key, value); + syncProject(project); + tree.refresh(); + } + } + + ImageRow selectedImageRow = getSelectedImageRow(); + refreshTree(selectedImageRow); + + } else { + Dialogs.showErrorMessage("Edit image description", "No entry is selected!"); + } + } + + /** * Populate the 'Sort by...' menu, recreating values if necessary * @param menuSort From f64f1d55452b9a88892cc732a26cfd707640a1a0 Mon Sep 17 00:00:00 2001 From: Pete Date: Wed, 3 Jul 2024 08:06:06 +0100 Subject: [PATCH 111/866] Support hiding thumbnails This may help performance with very large projects. It also addresses a request to hide thumbnails when masking filenames (although both options are separated here). --- .../qupath/lib/gui/panes/ProjectBrowser.java | 104 ++++++++++-------- 1 file changed, 58 insertions(+), 46 deletions(-) diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/panes/ProjectBrowser.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/panes/ProjectBrowser.java index a815fcb5d..529fd3d6f 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/panes/ProjectBrowser.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/panes/ProjectBrowser.java @@ -1202,7 +1202,7 @@ public void updateItem(ProjectTreeRow item, boolean empty) { if (item.getType() == ProjectTreeRow.Type.ROOT) { var children = getTreeItem().getChildren(); - setText(item.getDisplayableString() + (children.size() > 0 ? " (" + children.size() + ")" : "")); + setText(item.getDisplayableString() + (!children.isEmpty() ? " (" + children.size() + ")" : "")); setGraphic(null); // TODO: Extract styles to external CSS setStyle("-fx-font-weight: normal; -fx-font-family: arial"); @@ -1210,7 +1210,7 @@ public void updateItem(ProjectTreeRow item, boolean empty) { } else if (item.getType() == ProjectTreeRow.Type.METADATA) { var children = getTreeItem().getChildren(); // TODO: Try not to display count when grouping by ID - setText(item.getDisplayableString() + (children.size() > 0 ? " (" + children.size() + ")" : "")); + setText(item.getDisplayableString() + (!children.isEmpty() ? " (" + children.size() + ")" : "")); setGraphic(null); setStyle("-fx-font-weight: normal; -fx-font-family: arial"); return; @@ -1226,7 +1226,7 @@ else if (entry == null || entry.hasImageData()) setStyle("-fx-font-style: italic; -fx-font-family: arial"); if (entry == null) { - setText(item.toString() + " (" + getTreeItem().getChildren().size() + ")"); + setText(item + " (" + getTreeItem().getChildren().size() + ")"); tooltip.setText(item.toString()); showTooltip.set(true); setGraphic(null); @@ -1234,54 +1234,59 @@ else if (entry == null || entry.hasImageData()) setGraphic(null); // Set whatever tooltip we have tooltip.setGraphic(null); - showTooltip.set(true); + showTooltip.set(true); setText(entry.getImageName()); tooltip.setText(entry.getSummary()); - try { - // Fetch the thumbnail or generate it if not present - BufferedImage img = entry.getThumbnail(); - if (img != null) { - // If the cell contains the same object, no need to repaint the graphic - if (objectCell == item && getGraphic() != null) - return; - - Image image = SwingFXUtils.toFXImage(img, null); - viewTooltip.setImage(image); - tooltip.setGraphic(viewTooltip); - GuiTools.paintImage(viewCanvas, image); - objectCell = item; - if (getGraphic() == null) - setGraphic(label); - } else if (!serversFailed.contains(item)) { - executor.submit(() -> { - final ProjectTreeRow objectTemp = getItem(); - final ProjectImageEntry entryTemp = ProjectTreeRow.getEntry(objectTemp); - try { - if (entryTemp != null && objectCell != objectTemp && entryTemp.getThumbnail() == null) { - try (ImageServer server = entryTemp.getServerBuilder().build()) { - entryTemp.setThumbnail(ProjectCommands.getThumbnailRGB(server)); - objectCell = objectTemp; - tree.refresh(); - } catch (Exception ex) { - logger.warn("Error opening ImageServer (thumbnail generation): " + ex.getLocalizedMessage(), ex); - Platform.runLater(() -> setGraphic(IconFactory.createNode(15, 15, PathIcons.INACTIVE_SERVER))); - serversFailed.add(item); + if (thumbnailSize.get() == ProjectThumbnailSize.HIDDEN) { + viewTooltip.setImage(null); + viewCanvas.getGraphicsContext2D().clearRect(0, 0, viewCanvas.getWidth(), viewCanvas.getHeight()); + } else { + try { + // Fetch the thumbnail or generate it if not present + BufferedImage img = entry.getThumbnail(); + if (img != null) { + // If the cell contains the same object, no need to repaint the graphic + if (objectCell == item && getGraphic() != null) + return; + + Image image = SwingFXUtils.toFXImage(img, null); + viewTooltip.setImage(image); + tooltip.setGraphic(viewTooltip); + GuiTools.paintImage(viewCanvas, image); + objectCell = item; + if (getGraphic() == null) + setGraphic(label); + } else if (!serversFailed.contains(item)) { + executor.submit(() -> { + final ProjectTreeRow objectTemp = getItem(); + final ProjectImageEntry entryTemp = ProjectTreeRow.getEntry(objectTemp); + try { + if (entryTemp != null && objectCell != objectTemp && entryTemp.getThumbnail() == null) { + try (ImageServer server = entryTemp.getServerBuilder().build()) { + entryTemp.setThumbnail(ProjectCommands.getThumbnailRGB(server)); + objectCell = objectTemp; + tree.refresh(); + } catch (Exception ex) { + logger.warn("Error opening ImageServer (thumbnail generation): {}", ex.getLocalizedMessage(), ex); + Platform.runLater(() -> setGraphic(IconFactory.createNode(15, 15, PathIcons.INACTIVE_SERVER))); + serversFailed.add(item); + } } + } catch (IOException ex) { + logger.warn("Error getting thumbnail: {}", ex.getLocalizedMessage()); + Platform.runLater(() -> setGraphic(IconFactory.createNode(15, 15, PathIcons.INACTIVE_SERVER))); + serversFailed.add(item); } - } catch (IOException ex) { - logger.warn("Error getting thumbnail: " + ex.getLocalizedMessage()); - Platform.runLater(() -> setGraphic(IconFactory.createNode(15, 15, PathIcons.INACTIVE_SERVER))); - serversFailed.add(item); - } - }); - } else + }); + } else + setGraphic(IconFactory.createNode(15, 15, PathIcons.INACTIVE_SERVER)); + } catch (Exception e) { setGraphic(IconFactory.createNode(15, 15, PathIcons.INACTIVE_SERVER)); - } catch (Exception e) { - setGraphic(IconFactory.createNode(15, 15, PathIcons.INACTIVE_SERVER)); - logger.warn("Unable to read thumbnail for {} ({})" + entry.getImageName(), e.getLocalizedMessage()); - serversFailed.add(item); + logger.warn("Unable to read thumbnail for {} ({})", entry.getImageName(), e.getMessage()); + serversFailed.add(item); + } } } } @@ -1385,8 +1390,9 @@ public ObservableList> getChildren() { return super.getChildren(); } } - static enum ProjectThumbnailSize { - SMALL, MEDIUM, LARGE; + + enum ProjectThumbnailSize { + HIDDEN, SMALL, MEDIUM, LARGE; private double defaultHeight = 40; private double defaultWidth = 50; @@ -1394,6 +1400,8 @@ static enum ProjectThumbnailSize { @Override public String toString() { switch(this) { + case HIDDEN: + return "Hidden"; case LARGE: return "Large"; case MEDIUM: @@ -1407,6 +1415,8 @@ public String toString() { public double getWidth() { switch(this) { + case HIDDEN: + return 0; case LARGE: return defaultWidth * 3.0; case MEDIUM: @@ -1419,6 +1429,8 @@ public double getWidth() { public double getHeight() { switch(this) { + case HIDDEN: + return 0; case LARGE: return defaultHeight * 3.0; case MEDIUM: From 7b93f7e629af6b113093e1c9cec3eede20eac23d Mon Sep 17 00:00:00 2001 From: Pete Date: Wed, 3 Jul 2024 08:17:41 +0100 Subject: [PATCH 112/866] Add thumbnail size context menu --- .../qupath/lib/gui/panes/ProjectBrowser.java | 29 ++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/panes/ProjectBrowser.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/panes/ProjectBrowser.java index 529fd3d6f..857c43ae3 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/panes/ProjectBrowser.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/panes/ProjectBrowser.java @@ -50,6 +50,8 @@ import java.util.stream.Stream; import javafx.beans.property.SimpleObjectProperty; +import javafx.scene.control.RadioMenuItem; +import javafx.scene.control.ToggleGroup; import org.controlsfx.control.MasterDetailPane; import org.controlsfx.control.action.Action; import org.controlsfx.control.action.ActionUtils; @@ -198,7 +200,7 @@ public ProjectBrowser(final QuPathGUI qupath) { // Get thumbnails in separate thread executor = Executors.newSingleThreadExecutor(ThreadTools.createThreadFactory("thumbnail-loader", true)); - + PathPrefs.maskImageNamesProperty().addListener((v, o, n) -> refreshTree(null)); panel = new BorderPane(); @@ -381,6 +383,7 @@ ContextMenu getPopup() { miAddMetadata, miEditDescription, miMaskImages, + createThumbnailSizeMenu(), miRefreshThumbnail, separator, menuSort, @@ -393,6 +396,30 @@ ContextMenu getPopup() { return menu; } + private Menu createThumbnailSizeMenu() { + Menu menu = new Menu("Thumbnail size"); + ToggleGroup group = new ToggleGroup(); + for (ProjectThumbnailSize size : ProjectThumbnailSize.values()) { + RadioMenuItem item = new RadioMenuItem(size.toString()); + item.setOnAction(e -> thumbnailSize.set(size)); + item.setUserData(size); + menu.getItems().add(item); + group.getToggles().add(item); + } + thumbnailSize.addListener((v, o, n) -> syncToggleGroupByUserData(group, n)); + syncToggleGroupByUserData(group, thumbnailSize.get()); + return menu; + } + + private void syncToggleGroupByUserData(ToggleGroup group, Object userData) { + for (var toggle : group.getToggles()) { + if (Objects.equals(toggle.getUserData(), userData)) { + group.selectToggle(toggle); + return; + } + } + } + private void promptToEditSelectedImageDescription() { Project project = getProject(); From 50f7c94b4de6939c8f1a16eb955d8bf340b924fb Mon Sep 17 00:00:00 2001 From: Pete Date: Wed, 3 Jul 2024 08:50:59 +0100 Subject: [PATCH 113/866] Project browser styles css Avoid calling `setStyle(String)` within `ProjectBrowser` and move styles instead to `main.css` --- CHANGELOG.md | 1 + .../qupath/lib/gui/panes/ProjectBrowser.java | 43 ++++++++----------- qupath-gui-fx/src/main/resources/css/main.css | 17 ++++++++ 3 files changed, 36 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 895431ea1..c0ca46c13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ This is a *work in progress* for the next major release. * Log counts also now available in measurement tables * Prompt to save modified scripts when closing QuPath (https://github.com/qupath/qupath/pull/1524) * Close most sub-windows from the keyboard with `Ctrl/Cmd + W` or `Esc` (https://github.com/qupath/qupath/pull/1548) +* Project thumbnails can be hidden or their size adjusted from a context menu (https://github.com/qupath/qupath/pull/1556) ### Bugs fixed * Tile export to .ome.tif can convert to 8-bit unnecessarily (https://github.com/qupath/qupath/issues/1494) diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/panes/ProjectBrowser.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/panes/ProjectBrowser.java index 857c43ae3..b5bd325d5 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/panes/ProjectBrowser.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/panes/ProjectBrowser.java @@ -204,6 +204,7 @@ public ProjectBrowser(final QuPathGUI qupath) { PathPrefs.maskImageNamesProperty().addListener((v, o, n) -> refreshTree(null)); panel = new BorderPane(); + panel.getStyleClass().add("project-browser"); tree.setCellFactory(n -> new ProjectTreeRowCell()); @@ -1205,9 +1206,9 @@ private ProjectTreeRowCell() { viewTooltip.setFitHeight(250); viewTooltip.setFitWidth(250); viewTooltip.setPreserveRatio(true); + viewCanvas.getStyleClass().add("project-thumbnail"); viewCanvas.widthProperty().bind(viewWidth); viewCanvas.heightProperty().bind(viewHeight); - viewCanvas.setStyle("-fx-effect: dropshadow(three-pass-box, rgba(0,0,0,0.5), 4, 0, 1, 1);"); label.getChildren().add(viewCanvas); label.prefWidthProperty().bind(viewCanvas.widthProperty()); label.prefHeightProperty().bind(viewCanvas.heightProperty()); @@ -1227,31 +1228,28 @@ public void updateItem(ProjectTreeRow item, boolean empty) { return; } + getStyleClass().setAll("tree-cell"); + if (item.getType() == ProjectTreeRow.Type.ROOT) { var children = getTreeItem().getChildren(); setText(item.getDisplayableString() + (!children.isEmpty() ? " (" + children.size() + ")" : "")); setGraphic(null); - // TODO: Extract styles to external CSS - setStyle("-fx-font-weight: normal; -fx-font-family: arial"); return; } else if (item.getType() == ProjectTreeRow.Type.METADATA) { var children = getTreeItem().getChildren(); // TODO: Try not to display count when grouping by ID setText(item.getDisplayableString() + (!children.isEmpty() ? " (" + children.size() + ")" : "")); setGraphic(null); - setStyle("-fx-font-weight: normal; -fx-font-family: arial"); return; } // IMAGE ProjectImageEntry entry = item.getType() == ProjectTreeRow.Type.IMAGE ? ProjectTreeRow.getEntry(item) : null; if (isCurrentImage(entry)) - setStyle("-fx-font-weight: bold; -fx-font-family: arial"); - else if (entry == null || entry.hasImageData()) - setStyle("-fx-font-weight: normal; -fx-font-family: arial"); - else - setStyle("-fx-font-style: italic; -fx-font-family: arial"); - + getStyleClass().add("current-image"); + if (entry != null && !entry.hasImageData()) + getStyleClass().add("no-saved-data"); + if (entry == null) { setText(item + " (" + getTreeItem().getChildren().size() + ")"); tooltip.setText(item.toString()); @@ -1333,21 +1331,16 @@ private ProjectTreeRowItem(ProjectTreeRow obj) { @Override public boolean isLeaf() { if (computed) - return super.getChildren().size() == 0; - - switch(getValue().getType()) { - case ROOT: - return project != null && project.getImageList().size() > 0 && !project.getImageList().stream() - .filter(entry -> predicateProperty.get().test(entry.getImageName())) - .findAny() - .isPresent(); - case METADATA: - return false; - case IMAGE: - return true; - default: - throw new IllegalArgumentException("Could not understand the type of the object: " + getValue().getType()); - } + return super.getChildren().isEmpty(); + + return switch (getValue().getType()) { + case ROOT -> project != null && !project.getImageList().isEmpty() && project.getImageList().stream() + .noneMatch(entry -> predicateProperty.get().test(entry.getImageName())); + case METADATA -> false; + case IMAGE -> true; + default -> + throw new IllegalArgumentException("Could not understand the type of the object: " + getValue().getType()); + }; } diff --git a/qupath-gui-fx/src/main/resources/css/main.css b/qupath-gui-fx/src/main/resources/css/main.css index 88102cea8..769d4306a 100644 --- a/qupath-gui-fx/src/main/resources/css/main.css +++ b/qupath-gui-fx/src/main/resources/css/main.css @@ -144,3 +144,20 @@ -fx-font-size: 1em; -fx-font-weight: bold; } + +/* Project browser */ +.project-browser .tree-cell { + -fx-font-family: arial; +} + +.project-browser .tree-cell.current-image { + -fx-font-weight: bold; +} + +.project-browser .tree-cell.no-saved-data { + -fx-font-style: italic; +} + +.project-browser .project-thumbnail { + -fx-effect: dropshadow(three-pass-box, rgba(0,0,0,0.5), 4, 0, 1, 1); +} \ No newline at end of file From c9d1624a7224d62e4bfc527a2eb2ff65943c61c7 Mon Sep 17 00:00:00 2001 From: Pete Date: Wed, 3 Jul 2024 10:16:01 +0100 Subject: [PATCH 114/866] Allow thumbnails to be reset In a default project, support `ProjectImageEntry.setThumbnail(null)` to remove the thumbnail. Previously this would throw an exception. --- .../java/qupath/lib/projects/DefaultProject.java | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/qupath-core/src/main/java/qupath/lib/projects/DefaultProject.java b/qupath-core/src/main/java/qupath/lib/projects/DefaultProject.java index 0523f07aa..70e07f26c 100644 --- a/qupath-core/src/main/java/qupath/lib/projects/DefaultProject.java +++ b/qupath-core/src/main/java/qupath/lib/projects/DefaultProject.java @@ -864,8 +864,18 @@ public synchronized void setThumbnail(BufferedImage img) throws IOException { resetCachedThumbnail(); getEntryPath(true); var path = getThumbnailPath(); - try (var stream = Files.newOutputStream(path)) { - ImageIO.write(img, "JPEG", stream); + if (img == null) { + // Reset the thumbnail + if (Files.exists(path)) { + logger.debug("Deleting thumbnail for {}", path); + Files.delete(path); + } + } else { + // Save the thumbnail + try (var stream = Files.newOutputStream(path)) { + logger.debug("Writing thumbnail to {}", path); + ImageIO.write(img, "JPEG", stream); + } } } From b25ee6b1295367c7fc5575cf52880dcb4368f232 Mon Sep 17 00:00:00 2001 From: Pete Date: Wed, 3 Jul 2024 13:30:05 +0100 Subject: [PATCH 115/866] Warning indicator for missing image files Visually show in the project browser when URIs refer to files that do not exist. Note that this can be turned off in the preferences (because of the worry that it could be a performance issue if checking 'exists' is very expensive for some reason... like a very slow network) --- CHANGELOG.md | 6 +- .../qupath/lib/gui/panes/PreferencePane.java | 5 +- .../qupath/lib/gui/panes/ProjectBrowser.java | 102 +++++++++++++++--- .../java/qupath/lib/gui/prefs/PathPrefs.java | 18 +++- qupath-gui-fx/src/main/resources/css/main.css | 10 ++ .../qupath-gui-strings.properties | 2 + 6 files changed, 125 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c0ca46c13..da4e9a0d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,11 @@ This is a *work in progress* for the next major release. * Log counts also now available in measurement tables * Prompt to save modified scripts when closing QuPath (https://github.com/qupath/qupath/pull/1524) * Close most sub-windows from the keyboard with `Ctrl/Cmd + W` or `Esc` (https://github.com/qupath/qupath/pull/1548) -* Project thumbnails can be hidden or their size adjusted from a context menu (https://github.com/qupath/qupath/pull/1556) +* Project browser improvements (https://github.com/qupath/qupath/pull/1556) + * Project thumbnails can be hidden or their size adjusted from a context menu + * Images remain sorted after adding new metadata values + * A warning indicator is shown if image files are missing + * Use the 'Skip file checks in projects' preference if you need to turn this off (e.g. your images are on a slow network) ### Bugs fixed * Tile export to .ome.tif can convert to 8-bit unnecessarily (https://github.com/qupath/qupath/issues/1494) diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/panes/PreferencePane.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/panes/PreferencePane.java index 2b5a2154b..8675cc4f1 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/panes/PreferencePane.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/panes/PreferencePane.java @@ -243,7 +243,10 @@ public static class GeneralPreferences { @BooleanPref("Prefs.General.maskImageNames") public final BooleanProperty maskImageNames = PathPrefs.maskImageNamesProperty(); - + + @BooleanPref("Prefs.General.skipProjectUriChecks") + public final BooleanProperty skipProjectUriChecks = PathPrefs.skipProjectUriChecksProperty(); + @BooleanPref("Prefs.General.logFiles") public final BooleanProperty createLogFiles = PathPrefs.doCreateLogFilesProperty(); diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/panes/ProjectBrowser.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/panes/ProjectBrowser.java index b5bd325d5..bc6bac3a9 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/panes/ProjectBrowser.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/panes/ProjectBrowser.java @@ -29,6 +29,7 @@ import java.awt.image.BufferedImage; import java.io.IOException; import java.net.URI; +import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import java.util.Arrays; @@ -41,6 +42,7 @@ import java.util.Optional; import java.util.Set; import java.util.TreeMap; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.Executors; import java.util.concurrent.ExecutorService; import java.util.function.Predicate; @@ -50,6 +52,7 @@ import java.util.stream.Stream; import javafx.beans.property.SimpleObjectProperty; +import javafx.scene.Node; import javafx.scene.control.RadioMenuItem; import javafx.scene.control.ToggleGroup; import org.controlsfx.control.MasterDetailPane; @@ -118,6 +121,7 @@ import qupath.lib.images.ImageData; import qupath.lib.images.servers.ImageServer; import qupath.lib.images.servers.ImageServerMetadata; +import qupath.lib.io.UriUpdater; import qupath.lib.plugins.parameters.ParameterList; import qupath.lib.projects.Project; import qupath.lib.projects.ProjectImageEntry; @@ -202,6 +206,7 @@ public ProjectBrowser(final QuPathGUI qupath) { executor = Executors.newSingleThreadExecutor(ThreadTools.createThreadFactory("thumbnail-loader", true)); PathPrefs.maskImageNamesProperty().addListener((v, o, n) -> refreshTree(null)); + PathPrefs.skipProjectUriChecksProperty().addListener((v, o, n) -> tree.refresh()); panel = new BorderPane(); panel.getStyleClass().add("project-browser"); @@ -785,6 +790,7 @@ public boolean setProject(final Project project) { return true; this.project = project; + ProjectTreeRowCell.resetUriStatus(); model = new ProjectImageTreeModel(project); tree.setRoot(model.getRoot()); tree.getRoot().setExpanded(true); @@ -1188,12 +1194,34 @@ private ProjectTreeRowItem getRoot() { private class ProjectTreeRowCell extends TreeCell { private Tooltip tooltip = new Tooltip(); - private StackPane label = new StackPane(); - private ImageView viewTooltip = new ImageView(); + + private Node missingGraphic; + + private StackPane viewPane = new StackPane(); private Canvas viewCanvas = new Canvas(); + private ImageView viewTooltip = new ImageView(); + private ProjectTreeRow objectCell = null; private BooleanProperty showTooltip = new SimpleBooleanProperty(); - + + private BooleanProperty urisMissing = new SimpleBooleanProperty(false); + + /** + * Cache whether or not URIs refer to missing files. + * We want to be able to inform the user when files are missing, but we don't want to call Files.exists() + * too often, so we retain the result. + * This means that, if the file was deleted or moved later, the user will need to refresh the project to see + * the change. + */ + private static Map uriStatus = new ConcurrentHashMap<>(); + + /** + * Reset the cache of URI statuses (called when a new project is opened). + */ + static void resetUriStatus() { + uriStatus.clear(); + } + private DoubleBinding viewWidth = Bindings.createDoubleBinding( () -> thumbnailSize.get().getWidth(), thumbnailSize); @@ -1209,9 +1237,20 @@ private ProjectTreeRowCell() { viewCanvas.getStyleClass().add("project-thumbnail"); viewCanvas.widthProperty().bind(viewWidth); viewCanvas.heightProperty().bind(viewHeight); - label.getChildren().add(viewCanvas); - label.prefWidthProperty().bind(viewCanvas.widthProperty()); - label.prefHeightProperty().bind(viewCanvas.heightProperty()); + viewPane.getChildren().add(viewCanvas); + viewPane.prefWidthProperty().bind(viewCanvas.widthProperty()); + viewPane.prefHeightProperty().bind(viewCanvas.heightProperty()); + viewCanvas.opacityProperty().bind( + Bindings.createDoubleBinding(() -> urisMissing.get() ? 0.2 : 1.0, urisMissing)); + + missingGraphic = IconFactory.createNode( + 15, 15, PathIcons.WARNING); + missingGraphic.getStyleClass().add("missing-uri"); + Tooltip.install(missingGraphic, new Tooltip("File not found")); + + viewPane.getChildren().add(missingGraphic); + missingGraphic.visibleProperty().bind(urisMissing); + // Avoid having the tooltip obscure any popup menu tooltipProperty().bind(Bindings.createObjectBinding(() -> { return showTooltip.get() && !contextMenuShowing.get() ? tooltip : null; @@ -1229,6 +1268,7 @@ public void updateItem(ProjectTreeRow item, boolean empty) { } getStyleClass().setAll("tree-cell"); + urisMissing.set(false); if (item.getType() == ProjectTreeRow.Type.ROOT) { var children = getTreeItem().getChildren(); @@ -1250,19 +1290,36 @@ public void updateItem(ProjectTreeRow item, boolean empty) { if (entry != null && !entry.hasImageData()) getStyleClass().add("no-saved-data"); + // Check for URIs + if (entry != null && !PathPrefs.skipProjectUriChecksProperty().get()) { + try { + for (var uri : entry.getURIs()) { + if (uriStatus.computeIfAbsent(uri, ProjectTreeRowCell::checkUri) == UriUpdater.UriStatus.MISSING) { + urisMissing.set(true); + break; + } + } + } catch (IOException e) { + logger.error("Exception checking URIs: {}", e.getMessage(), e); + } + } + if (entry == null) { setText(item + " (" + getTreeItem().getChildren().size() + ")"); tooltip.setText(item.toString()); showTooltip.set(true); setGraphic(null); } else { - setGraphic(null); + setGraphic(viewPane); // Set whatever tooltip we have tooltip.setGraphic(null); showTooltip.set(true); setText(entry.getImageName()); - tooltip.setText(entry.getSummary()); + if (urisMissing.get()) + tooltip.setText("Warning: At least one file is missing!\n\n" + entry.getSummary()); + else + tooltip.setText(entry.getSummary()); if (thumbnailSize.get() == ProjectThumbnailSize.HIDDEN) { viewTooltip.setImage(null); @@ -1282,7 +1339,7 @@ public void updateItem(ProjectTreeRow item, boolean empty) { GuiTools.paintImage(viewCanvas, image); objectCell = item; if (getGraphic() == null) - setGraphic(label); + setGraphic(viewPane); } else if (!serversFailed.contains(item)) { executor.submit(() -> { final ProjectTreeRow objectTemp = getItem(); @@ -1315,7 +1372,22 @@ public void updateItem(ProjectTreeRow item, boolean empty) { } } } + + + private static UriUpdater.UriStatus checkUri(URI uri) { + var path = GeneralTools.toPath(uri); + // In case the check is slow, we make it possible for the user to turn it off. + // See also https://github.com/qupath/qupath/pull/1298 for performance considerations. + // TODO: Can we check if this is a network drive, to skip the test? + if (path == null) + return UriUpdater.UriStatus.UNKNOWN; + else if (Files.notExists(path)) + return UriUpdater.UriStatus.MISSING; + else + return UriUpdater.UriStatus.EXISTS; + } } + /** * TreeItem to help with the display of project objects. @@ -1413,7 +1485,9 @@ public ObservableList> getChildren() { enum ProjectThumbnailSize { HIDDEN, SMALL, MEDIUM, LARGE; - + + private static int hiddenSize = 20; + private double defaultHeight = 40; private double defaultWidth = 50; @@ -1435,12 +1509,12 @@ public String toString() { public double getWidth() { switch(this) { - case HIDDEN: - return 0; case LARGE: return defaultWidth * 3.0; case MEDIUM: return defaultWidth * 2.0; + case HIDDEN: + return hiddenSize; case SMALL: default: return defaultWidth; @@ -1449,12 +1523,12 @@ public double getWidth() { public double getHeight() { switch(this) { - case HIDDEN: - return 0; case LARGE: return defaultHeight * 3.0; case MEDIUM: return defaultHeight * 2.0; + case HIDDEN: + return hiddenSize; case SMALL: default: return defaultHeight; diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/prefs/PathPrefs.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/prefs/PathPrefs.java index 5d8d79d24..b3129e691 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/prefs/PathPrefs.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/prefs/PathPrefs.java @@ -848,8 +848,22 @@ private static ObservableList createRecentScriptsList(int nRecentScripts) { public static ObservableList getRecentScriptsList() { return recentScripts; } - - + + + private static BooleanProperty skipProjectUriChecks = createPersistentPreference("Skip checking URIs in the project browser", + false); + + /** + * Property to suppress checking whether image files exists in the project browser. + * You might want to skip these checks if they are causing performance problems, e.g. working with images on a + * server with slow access. + * + * @return skipProjectUriChecks + */ + public static BooleanProperty skipProjectUriChecksProperty() { + return skipProjectUriChecks; + } + private static BooleanProperty invertScrolling = createPersistentPreference("invertScrolling", !GeneralTools.isMac()); diff --git a/qupath-gui-fx/src/main/resources/css/main.css b/qupath-gui-fx/src/main/resources/css/main.css index 769d4306a..d7a635217 100644 --- a/qupath-gui-fx/src/main/resources/css/main.css +++ b/qupath-gui-fx/src/main/resources/css/main.css @@ -158,6 +158,16 @@ -fx-font-style: italic; } +.project-browser .tree-cell.missing-uri { + -fx-text-fill: -qp-script-warn-color; +} + .project-browser .project-thumbnail { -fx-effect: dropshadow(three-pass-box, rgba(0,0,0,0.5), 4, 0, 1, 1); +} + +.project-browser .missing-uri { + -fx-text-fill: -qp-script-warn-color; + -fx-fill: -qp-script-warn-color; + -fx-effect: dropshadow(three-pass-box, rgba(0,0,0,0.25), 2, 0, 1, 1); } \ No newline at end of file diff --git a/qupath-gui-fx/src/main/resources/qupath/lib/gui/localization/qupath-gui-strings.properties b/qupath-gui-fx/src/main/resources/qupath/lib/gui/localization/qupath-gui-strings.properties index d00e9ad6c..2e69308dc 100644 --- a/qupath-gui-fx/src/main/resources/qupath/lib/gui/localization/qupath-gui-strings.properties +++ b/qupath-gui-fx/src/main/resources/qupath/lib/gui/localization/qupath-gui-strings.properties @@ -735,6 +735,8 @@ Prefs.General.showImageNameInTitle = Show image name in window title Prefs.General.showImageNameInTitle.description = Show the name of the current image in the main QuPath title bar (turn this off if the name shouldn't be seen). Prefs.General.maskImageNames = Mask image names in projects Prefs.General.maskImageNames.description = Mask the image names when using projects, to help reduce the potential for user bias during analysis. +Prefs.General.skipProjectUriChecks = Skip file checks in projects +Prefs.General.skipProjectUriChecks.description = Avoid checking files exist in the project browser. This might help in cases where the project browser is slow because of network latency. Prefs.General.maxMemory = Set max memory Prefs.General.maxMemory.description = Set the maximum memory for Java.\nNote that some commands (e.g. pixel classification) may still use more memory when needed,\nso this value should generally not exceed half the total memory available on the system. Prefs.General.tileCache = Percentage memory for tile caching From 980e4db36b39be815ab625d79a4b439b598e6e14 Mon Sep 17 00:00:00 2001 From: Alan O'Callaghan Date: Wed, 3 Jul 2024 15:59:31 +0100 Subject: [PATCH 116/866] Newlines at EOF --- .../qupath/lib/images/servers/NormalizedImageServer.java | 2 +- .../lib/images/servers/TiledTransformingImageServer.java | 5 +---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/qupath-core/src/main/java/qupath/lib/images/servers/NormalizedImageServer.java b/qupath-core/src/main/java/qupath/lib/images/servers/NormalizedImageServer.java index 687c745b3..4b3fee0e8 100644 --- a/qupath-core/src/main/java/qupath/lib/images/servers/NormalizedImageServer.java +++ b/qupath-core/src/main/java/qupath/lib/images/servers/NormalizedImageServer.java @@ -87,4 +87,4 @@ protected ImageServerBuilder.ServerBuilder createServerBuilder() protected String createID() { return "Normalized: " + getWrappedServer().getPath() + " " + GsonTools.getInstance(false).toJson(transform); } -} \ No newline at end of file +} diff --git a/qupath-core/src/main/java/qupath/lib/images/servers/TiledTransformingImageServer.java b/qupath-core/src/main/java/qupath/lib/images/servers/TiledTransformingImageServer.java index a32de5778..3756ebe08 100644 --- a/qupath-core/src/main/java/qupath/lib/images/servers/TiledTransformingImageServer.java +++ b/qupath-core/src/main/java/qupath/lib/images/servers/TiledTransformingImageServer.java @@ -21,10 +21,7 @@ package qupath.lib.images.servers; -import qupath.lib.regions.RegionRequest; - import java.awt.image.BufferedImage; -import java.io.IOException; import java.net.URI; import java.util.Collection; @@ -70,4 +67,4 @@ public ImageServerMetadata getOriginalMetadata() { return server.getOriginalMetadata(); } -} \ No newline at end of file +} From 9093d08c4ab116f098d87256ef9de7ce03bcac61 Mon Sep 17 00:00:00 2001 From: Pete Date: Thu, 4 Jul 2024 16:30:28 +0100 Subject: [PATCH 117/866] Add image normalization tests Also update `BufferedImageNormalizer` to extend `BufferedImageOp`. --- .../images/servers/NormalizedImageServer.java | 2 +- .../transforms/BufferedImageNormalizer.java | 55 +++++- .../ColorDeconvolutionNormalizer.java | 13 +- .../SubtractOffsetAndScaleNormalizer.java | 65 +++++++- .../TestColorDeconvolutionNormalizer.java | 138 ++++++++++++++++ .../TestSubtractOffsetAndScaleNormalizer.java | 156 ++++++++++++++++++ 6 files changed, 411 insertions(+), 18 deletions(-) create mode 100644 qupath-core/src/test/java/qupath/lib/images/servers/transforms/TestColorDeconvolutionNormalizer.java create mode 100644 qupath-core/src/test/java/qupath/lib/images/servers/transforms/TestSubtractOffsetAndScaleNormalizer.java diff --git a/qupath-core/src/main/java/qupath/lib/images/servers/NormalizedImageServer.java b/qupath-core/src/main/java/qupath/lib/images/servers/NormalizedImageServer.java index 4b3fee0e8..25f0e1386 100644 --- a/qupath-core/src/main/java/qupath/lib/images/servers/NormalizedImageServer.java +++ b/qupath-core/src/main/java/qupath/lib/images/servers/NormalizedImageServer.java @@ -75,7 +75,7 @@ public ImageServerMetadata getOriginalMetadata() { @Override protected BufferedImage readTile(TileRequest tileRequest) throws IOException { var img = getWrappedServer().readRegion(tileRequest.getRegionRequest()); - return img == null ? null : transform.apply(img); + return img == null ? null : transform.filter(img, img); } @Override diff --git a/qupath-core/src/main/java/qupath/lib/images/servers/transforms/BufferedImageNormalizer.java b/qupath-core/src/main/java/qupath/lib/images/servers/transforms/BufferedImageNormalizer.java index 36bd227f6..5444656b7 100644 --- a/qupath-core/src/main/java/qupath/lib/images/servers/transforms/BufferedImageNormalizer.java +++ b/qupath-core/src/main/java/qupath/lib/images/servers/transforms/BufferedImageNormalizer.java @@ -21,7 +21,16 @@ package qupath.lib.images.servers.transforms; +import qupath.lib.awt.common.BufferedImageTools; + +import java.awt.Rectangle; +import java.awt.RenderingHints; +import java.awt.geom.Point2D; +import java.awt.geom.Rectangle2D; import java.awt.image.BufferedImage; +import java.awt.image.BufferedImageOp; +import java.awt.image.ColorModel; +import java.util.Objects; /** * Interface for normalizing a BufferedImage. @@ -30,14 +39,42 @@ * * @since v0.6.0 */ -public interface BufferedImageNormalizer { - - /** - * Apply the normalization to the input image. - * Note that the input image may be modified in place. - * @param input - * @return - */ - BufferedImage apply(BufferedImage input); +public interface BufferedImageNormalizer extends BufferedImageOp { + + + @Override + default Rectangle2D getBounds2D(BufferedImage src) { + return new Rectangle(src.getRaster().getBounds()); + } + + @Override + default BufferedImage createCompatibleDestImage(BufferedImage src, + ColorModel destCM) { + // If we have an 8-bit color source, we can just use the type - using the color model can result + // in a BufferedImage.TYPE_CUSTOM, which is not what we want. + if (BufferedImageTools.is8bitColorType(src.getType()) && + (destCM == null || Objects.equals(destCM, ColorModel.getRGBdefault()))) + return new BufferedImage(src.getWidth(), src.getHeight(), src.getType()); + + if (destCM == null) { + destCM = src.getColorModel(); + } + return new BufferedImage( + destCM, + destCM.createCompatibleWritableRaster(src.getWidth(), src.getHeight()), + destCM.isAlphaPremultiplied(), + null + ); + } + + @Override + default Point2D getPoint2D(Point2D srcPt, Point2D dstPt) { + return new Point2D.Double(srcPt.getX(), srcPt.getY()); + } + + @Override + default RenderingHints getRenderingHints() { + return null; + } } diff --git a/qupath-core/src/main/java/qupath/lib/images/servers/transforms/ColorDeconvolutionNormalizer.java b/qupath-core/src/main/java/qupath/lib/images/servers/transforms/ColorDeconvolutionNormalizer.java index 045b99fb7..7582b6437 100644 --- a/qupath-core/src/main/java/qupath/lib/images/servers/transforms/ColorDeconvolutionNormalizer.java +++ b/qupath-core/src/main/java/qupath/lib/images/servers/transforms/ColorDeconvolutionNormalizer.java @@ -21,6 +21,7 @@ package qupath.lib.images.servers.transforms; +import qupath.lib.awt.common.BufferedImageTools; import qupath.lib.color.ColorDeconvolutionStains; import qupath.lib.color.ColorTransformer; @@ -57,7 +58,13 @@ public static ColorDeconvolutionNormalizer create(ColorDeconvolutionStains stain } @Override - public BufferedImage apply(BufferedImage img) { + public BufferedImage filter(BufferedImage img, BufferedImage output) { + if (output == null) + output = createCompatibleDestImage(img, null); + + if (!BufferedImageTools.is8bitColorType(img.getType()) || !BufferedImageTools.is8bitColorType(output.getType())) + throw new IllegalArgumentException("Color deconvolution normalizer only supports 8-bit RGB inputs and outputs"); + var rgb = img.getRGB(0, 0, img.getWidth(), img.getHeight(), null, 0, img.getWidth()); ColorTransformer.colorDeconvolveReconvolveRGBArray( rgb, @@ -66,7 +73,7 @@ public BufferedImage apply(BufferedImage img) { rgb, scales ); - img.setRGB(0, 0, img.getWidth(), img.getHeight(), rgb, 0, img.getWidth()); - return img; + output.setRGB(0, 0, img.getWidth(), img.getHeight(), rgb, 0, img.getWidth()); + return output; } } diff --git a/qupath-core/src/main/java/qupath/lib/images/servers/transforms/SubtractOffsetAndScaleNormalizer.java b/qupath-core/src/main/java/qupath/lib/images/servers/transforms/SubtractOffsetAndScaleNormalizer.java index d23229589..654418ec4 100644 --- a/qupath-core/src/main/java/qupath/lib/images/servers/transforms/SubtractOffsetAndScaleNormalizer.java +++ b/qupath-core/src/main/java/qupath/lib/images/servers/transforms/SubtractOffsetAndScaleNormalizer.java @@ -24,12 +24,16 @@ import qupath.lib.common.GeneralTools; import java.awt.image.BufferedImage; +import java.awt.image.DataBuffer; /** * Normalizes the pixel values of a BufferedImage by subtracting and offset and multiplying by a scale factor. *

    * An expected use is to subtract a constant background value in a fluorescence image, with optional channel * rescaling. + *

    + * Note that the result is necessarily clipped to the range of the output data type, and non-integer values + * are rounded if necessary. * * @since v0.6.0 */ @@ -99,23 +103,74 @@ public static SubtractOffsetAndScaleNormalizer createWithClipRange(double[] offs } @Override - public BufferedImage apply(BufferedImage img) { + public BufferedImage filter(BufferedImage img, BufferedImage output) { + if (output == null) + output = createCompatibleDestImage(img, img.getColorModel()); var raster = img.getRaster(); int w = img.getWidth(); int h = img.getHeight(); double[] pixels = null; + // Clip to the range of the output data type + var outputRaster = output.getRaster(); + double minClip = getMinClip(outputRaster.getDataBuffer().getDataType()); + double maxClip = getMaxClip(outputRaster.getDataBuffer().getDataType()); + boolean doRounding = isIntegerType(outputRaster.getDataBuffer().getDataType()); for (int b = 0; b < raster.getNumBands(); b++) { pixels = raster.getSamples(0, 0, w, h, b, pixels); double offset = offsetForChannel(b); double scale = scaleForChannel(b); if (offset != 0 || scale != 1) { for (int i = 0; i < pixels.length; i++) { - pixels[i] = GeneralTools.clipValue((pixels[i] - offset) * scale, minClip, maxClip); + double val = GeneralTools.clipValue((pixels[i] - offset) * scale, minClip, maxClip); + if (doRounding) + val = Math.round(val); + pixels[i] = val; } - raster.setSamples(0, 0, w, h, b, pixels); } + outputRaster.setSamples(0, 0, w, h, b, pixels); + } + return output; + } + + boolean isIntegerType(int dataType) { + switch (dataType) { + case DataBuffer.TYPE_BYTE: + case DataBuffer.TYPE_SHORT: + case DataBuffer.TYPE_USHORT: + case DataBuffer.TYPE_INT: + return true; + default: + return false; + } + } + + double getMinClip(int dataType) { + switch (dataType) { + case DataBuffer.TYPE_BYTE: + case DataBuffer.TYPE_USHORT: + return Math.max(0, minClip); + case DataBuffer.TYPE_INT: + return Math.max(Integer.MIN_VALUE, minClip); + case DataBuffer.TYPE_SHORT: + return Math.max(Short.MIN_VALUE, minClip); + default: + return minClip; + } + } + + double getMaxClip(int dataType) { + switch (dataType) { + case DataBuffer.TYPE_BYTE: + return Math.min(255, maxClip); + case DataBuffer.TYPE_USHORT: + return Math.min(65535, maxClip); + case DataBuffer.TYPE_INT: + return Math.min(Integer.MAX_VALUE, maxClip); + case DataBuffer.TYPE_SHORT: + return Math.min(Short.MAX_VALUE, maxClip); + default: + return maxClip; } - return img; } private double scaleForChannel(int channel) { @@ -131,7 +186,7 @@ else if (scales.length == 1) private double offsetForChannel(int channel) { if (offsets == null) - return 1.0; + return 0.0; if (channel < offsets.length) return offsets[channel]; else if (offsets.length == 1) diff --git a/qupath-core/src/test/java/qupath/lib/images/servers/transforms/TestColorDeconvolutionNormalizer.java b/qupath-core/src/test/java/qupath/lib/images/servers/transforms/TestColorDeconvolutionNormalizer.java new file mode 100644 index 000000000..0e4f0ee28 --- /dev/null +++ b/qupath-core/src/test/java/qupath/lib/images/servers/transforms/TestColorDeconvolutionNormalizer.java @@ -0,0 +1,138 @@ +/*- + * #%L + * This file is part of QuPath. + * %% + * Copyright (C) 2024 QuPath developers, The University of Edinburgh + * %% + * QuPath 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 3 of the + * License, or (at your option) any later version. + * + * QuPath 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 QuPath. If not, see . + * #L% + */ + +package qupath.lib.images.servers.transforms; + +import org.junit.jupiter.api.Test; +import qupath.lib.color.ColorDeconvolutionStains; +import qupath.lib.common.ColorTools; + +import java.awt.Color; +import java.awt.image.BufferedImage; +import java.util.Arrays; +import java.util.Objects; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class TestColorDeconvolutionNormalizer { + + private final ColorDeconvolutionStains stainsHE = ColorDeconvolutionStains.makeDefaultColorDeconvolutionStains(ColorDeconvolutionStains.DefaultColorDeconvolutionStains.H_E); + private final ColorDeconvolutionStains stainsDAB = ColorDeconvolutionStains.makeDefaultColorDeconvolutionStains(ColorDeconvolutionStains.DefaultColorDeconvolutionStains.H_DAB); + + private final ColorDeconvolutionNormalizer normHE = ColorDeconvolutionNormalizer.create( + stainsHE, stainsHE, 1, 1, 1 + ); + private final ColorDeconvolutionNormalizer normHEScaled = ColorDeconvolutionNormalizer.create( + stainsHE, stainsHE, 1.5, 0.5, 0 + ); + private final ColorDeconvolutionNormalizer normHE2DAB = ColorDeconvolutionNormalizer.create( + stainsHE, stainsDAB, 1, 1, 1 + ); + + @Test + public void testTypeCheck() { + var imgRGB = createImage(BufferedImage.TYPE_INT_RGB); + var imgARGB = createImage(BufferedImage.TYPE_INT_ARGB); + var imgBGR = createImage(BufferedImage.TYPE_3BYTE_BGR); + + // Check our RGB images + for (var img : Arrays.asList(imgRGB, imgARGB, imgBGR)) { + assertTrue(similarRGB(img, doNormalize(img, normHE), 1, true)); + assertFalse(similarRGB(img, doNormalize(img, normHE2DAB), 1, true)); + assertFalse(similarRGB(img, doNormalize(img, normHEScaled), 1, true)); + // Results should be the same, even when type is different + assertTrue(similarRGB(imgRGB, doNormalize(img, normHE), 1, false)); + } + } + + @Test + public void testNonRGBException() { + // Check the non-RGB fails + var imgIndexed = createImage(BufferedImage.TYPE_BYTE_GRAY); + assertThrows(IllegalArgumentException.class, () -> doNormalize(imgIndexed, normHE)); + } + + @Test + public void testBufferedOp() { + // Check we can use a BufferedImageOp + // (Note that TYPE_INT_ARGB fails but TYPE_INT_ARGB_PRE passes when converting in this way) + var img = createImage(BufferedImage.TYPE_INT_RGB); + + var img2 = new BufferedImage(img.getWidth(), img.getHeight(), BufferedImage.TYPE_INT_RGB); + var g2d = img2.createGraphics(); + g2d.drawImage(img, normHE2DAB, 0, 0); + g2d.dispose(); + + assertTrue(similarRGB(img2, doNormalize(img, normHE2DAB), 1, true)); + } + + + /** + * Check if the RGB values are similar within a specified tolerance. + * @param img + * @param img2 + * @param tol + * @return + */ + private static boolean similarRGB(BufferedImage img, BufferedImage img2, int tol, boolean checkAlpha) { + if (Objects.equals(img, img2)) + throw new IllegalArgumentException("Images are the same!"); + var rgb = img.getRGB(0, 0, img.getWidth(), img.getHeight(), null, 0, img.getWidth()); + var rgb2 = img2.getRGB(0, 0, img2.getWidth(), img2.getHeight(), null, 0, img2.getWidth()); + for (int i = 0; i < rgb.length; i++) { + var v = rgb[i]; + var v2 = rgb2[i]; + if (v == v2) + continue; + else if (tol > 0) { + if (Math.abs(ColorTools.red(v) - ColorTools.red(v2)) > tol) + return false; + if (Math.abs(ColorTools.green(v) - ColorTools.green(v2)) > tol) + return false; + if (Math.abs(ColorTools.blue(v) - ColorTools.blue(v2)) > tol) + return false; + if (checkAlpha && Math.abs(ColorTools.alpha(v) - ColorTools.alpha(v2)) > tol) + return false; + } else + return false; + } + return true; + } + + private static BufferedImage doNormalize(BufferedImage img, ColorDeconvolutionNormalizer normalizer) { + return normalizer.filter(img, null); + } + + private BufferedImage createImage(int type) { + var img = new BufferedImage(64, 64, type); + var g2d = img.createGraphics(); + g2d.setColor(Color.CYAN); + g2d.drawRect(5, 5, img.getWidth()/2, img.getHeight()/2); + g2d.setColor(Color.MAGENTA); + g2d.fillOval(0, 0, img.getWidth(), img.getHeight()); + g2d.dispose(); + return img; + } + + +} diff --git a/qupath-core/src/test/java/qupath/lib/images/servers/transforms/TestSubtractOffsetAndScaleNormalizer.java b/qupath-core/src/test/java/qupath/lib/images/servers/transforms/TestSubtractOffsetAndScaleNormalizer.java new file mode 100644 index 000000000..0f8921446 --- /dev/null +++ b/qupath-core/src/test/java/qupath/lib/images/servers/transforms/TestSubtractOffsetAndScaleNormalizer.java @@ -0,0 +1,156 @@ +/*- + * #%L + * This file is part of QuPath. + * %% + * Copyright (C) 2024 QuPath developers, The University of Edinburgh + * %% + * QuPath 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 3 of the + * License, or (at your option) any later version. + * + * QuPath 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 QuPath. If not, see . + * #L% + */ + +package qupath.lib.images.servers.transforms; + +import org.junit.jupiter.api.Test; +import qupath.lib.color.ColorModelFactory; +import qupath.lib.images.servers.ImageChannel; +import qupath.lib.images.servers.PixelType; + +import java.awt.image.BandedSampleModel; +import java.awt.image.BufferedImage; +import java.awt.image.DataBuffer; +import java.awt.image.DataBufferByte; +import java.awt.image.DataBufferDouble; +import java.awt.image.DataBufferFloat; +import java.awt.image.DataBufferInt; +import java.awt.image.DataBufferUShort; +import java.awt.image.WritableRaster; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class TestSubtractOffsetAndScaleNormalizer { + + @Test + public void testFloat32() { + var imgOnes = createImage(PixelType.FLOAT32, 1, 1, 1, 1); + + checkChannelPixelValues( + SubtractOffsetAndScaleNormalizer.createSubtractOffset(0, 1, 2, 3).filter(imgOnes, null), + 1, 0, -1, -2 + ); + + checkChannelPixelValues( + SubtractOffsetAndScaleNormalizer.createSubtractOffsetAndClipZero(0, 1, 2, 3).filter(imgOnes, null), + 1, 0, 0, 0 + ); + + checkChannelPixelValues( + SubtractOffsetAndScaleNormalizer.createScaled(1, -3, 0, 2).filter(imgOnes, null), + 1, -3, 0, 2 + ); + + checkChannelPixelValues( + SubtractOffsetAndScaleNormalizer.createWithClipRange( + new double[]{0, 0, 0, 0}, + new double[]{1, -3, 0, 2}, -1, 1).filter(imgOnes, null), + 1, -1, 0, 1 + ); + + checkChannelPixelValues( + SubtractOffsetAndScaleNormalizer.create( + new double[]{1, 2, 3, 4}, + new double[]{1, -3, 0, 2}).filter(imgOnes, null), + 0, 3, 0, -6 + ); + + checkChannelPixelValues( + SubtractOffsetAndScaleNormalizer.create( + null, + new double[]{1, 0.5, 0.25, 300}).filter(imgOnes, null), + 1, 0.5, 0.25, 300 + ); + } + + @Test + public void testUint8() { + var imgOnes = createImage(PixelType.UINT8, 1, 1, 1, 1); + + checkChannelPixelValues( + SubtractOffsetAndScaleNormalizer.createSubtractOffset(0, 1, 2, 3).filter(imgOnes, null), + 1, 0, 0, 0 + ); + + checkChannelPixelValues( + SubtractOffsetAndScaleNormalizer.createSubtractOffsetAndClipZero(0, 1, 2, 3).filter(imgOnes, null), + 1, 0, 0, 0 + ); + + checkChannelPixelValues( + SubtractOffsetAndScaleNormalizer.createScaled(1, -3, 0, 2).filter(imgOnes, null), + 1, 0, 0, 2 + ); + + checkChannelPixelValues( + SubtractOffsetAndScaleNormalizer.createWithClipRange( + new double[]{0, 0, 0, 0}, + new double[]{1, -3, 0, 2}, -1, 1).filter(imgOnes, null), + 1, 0, 0, 1 + ); + + checkChannelPixelValues( + SubtractOffsetAndScaleNormalizer.create( + new double[]{1, 2, 3, 4}, + new double[]{1, -3, 0, 2}).filter(imgOnes, null), + 0, 3, 0, 0 + ); + + checkChannelPixelValues( + SubtractOffsetAndScaleNormalizer.create( + null, + new double[]{1, 0.5, 0.25, 300}).filter(imgOnes, null), + 1, 1, 0, 255 + ); + } + + private void checkChannelPixelValues(BufferedImage img, double... channelValues) { + var raster = img.getRaster(); + for (int i = 0; i < channelValues.length; i++) { + assertEquals(channelValues[i], raster.getSampleDouble(0, 0, i), 1e-6); + } + } + + + private BufferedImage createImage(PixelType type, double... channelValues) { + int nChannels = channelValues.length; + DataBuffer dataBuffer = switch(type) { + case PixelType.UINT8 -> new DataBufferByte(1, nChannels); + case PixelType.UINT16 -> new DataBufferUShort(1, nChannels); + case PixelType.UINT32 -> new DataBufferInt(1, nChannels); + case PixelType.FLOAT32 -> new DataBufferFloat(1, nChannels); + case PixelType.FLOAT64 -> new DataBufferDouble(1, nChannels); + default -> + throw new UnsupportedOperationException("Unsupported pixel type: " + type); + }; + var sampleModel = new BandedSampleModel(dataBuffer.getDataType(), 1, 1, nChannels); + var raster = WritableRaster.createWritableRaster(sampleModel, dataBuffer, null); + for (int i = 0; i < nChannels; i++) + raster.setSample(0, 0, i, channelValues[i]); + return new BufferedImage( + ColorModelFactory.createColorModel(type, ImageChannel.getDefaultChannelList(nChannels)), + raster, + false, + null); + } + + +} From 9c437bfe7333c039d6127081e972ac0c7f8c2085 Mon Sep 17 00:00:00 2001 From: Pete Date: Thu, 4 Jul 2024 16:34:27 +0100 Subject: [PATCH 118/866] Fix unqualified enum constants --- .../TestSubtractOffsetAndScaleNormalizer.java | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/qupath-core/src/test/java/qupath/lib/images/servers/transforms/TestSubtractOffsetAndScaleNormalizer.java b/qupath-core/src/test/java/qupath/lib/images/servers/transforms/TestSubtractOffsetAndScaleNormalizer.java index 0f8921446..4990049b5 100644 --- a/qupath-core/src/test/java/qupath/lib/images/servers/transforms/TestSubtractOffsetAndScaleNormalizer.java +++ b/qupath-core/src/test/java/qupath/lib/images/servers/transforms/TestSubtractOffsetAndScaleNormalizer.java @@ -133,11 +133,11 @@ private void checkChannelPixelValues(BufferedImage img, double... channelValues) private BufferedImage createImage(PixelType type, double... channelValues) { int nChannels = channelValues.length; DataBuffer dataBuffer = switch(type) { - case PixelType.UINT8 -> new DataBufferByte(1, nChannels); - case PixelType.UINT16 -> new DataBufferUShort(1, nChannels); - case PixelType.UINT32 -> new DataBufferInt(1, nChannels); - case PixelType.FLOAT32 -> new DataBufferFloat(1, nChannels); - case PixelType.FLOAT64 -> new DataBufferDouble(1, nChannels); + case UINT8 -> new DataBufferByte(1, nChannels); + case UINT16 -> new DataBufferUShort(1, nChannels); + case UINT32 -> new DataBufferInt(1, nChannels); + case FLOAT32 -> new DataBufferFloat(1, nChannels); + case FLOAT64 -> new DataBufferDouble(1, nChannels); default -> throw new UnsupportedOperationException("Unsupported pixel type: " + type); }; From 96c452d1fe643d09bd1127fd3559219d81d6e7f7 Mon Sep 17 00:00:00 2001 From: Alan O'Callaghan Date: Fri, 5 Jul 2024 18:24:33 +0100 Subject: [PATCH 119/866] Increment openslide version (#1557) This version uses the official openslide-bin binaries for 4.0.0.3 The mac version is a universal binary --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5cf9235b7..dad520cca 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -40,7 +40,7 @@ logviewer = "0.2.0-SNAPSHOT" javadocviewer = "0.1.1-SNAPSHOT" -openslide = "4.0.0" +openslide = "4.0.0.3" picocli = "4.7.6" qupath-fxtras = "0.1.5-SNAPSHOT" From 669d457fafa09c87bf82d0273b36136782a1a959 Mon Sep 17 00:00:00 2001 From: Alan O'Callaghan Date: Mon, 15 Jul 2024 12:39:51 +0100 Subject: [PATCH 120/866] Add method to create geometries instead of ROIs Re-order imports Unused import Semicolon --- .../lib/analysis/images/ContourTracing.java | 41 +++++++++++++++---- 1 file changed, 32 insertions(+), 9 deletions(-) diff --git a/qupath-core/src/main/java/qupath/lib/analysis/images/ContourTracing.java b/qupath-core/src/main/java/qupath/lib/analysis/images/ContourTracing.java index eaee8f4bc..67122d92e 100644 --- a/qupath-core/src/main/java/qupath/lib/analysis/images/ContourTracing.java +++ b/qupath-core/src/main/java/qupath/lib/analysis/images/ContourTracing.java @@ -22,6 +22,7 @@ package qupath.lib.analysis.images; +import javax.imageio.ImageIO; import java.awt.image.BufferedImage; import java.awt.image.Raster; import java.awt.image.WritableRaster; @@ -49,8 +50,6 @@ import java.util.stream.Collectors; import java.util.stream.Stream; -import javax.imageio.ImageIO; - import org.locationtech.jts.geom.Coordinate; import org.locationtech.jts.geom.CoordinateSequence; import org.locationtech.jts.geom.CoordinateXY; @@ -66,7 +65,6 @@ import org.locationtech.jts.operation.polygonize.Polygonizer; import org.slf4j.Logger; import org.slf4j.LoggerFactory; - import qupath.lib.common.GeneralTools; import qupath.lib.common.ThreadTools; import qupath.lib.images.servers.ImageServer; @@ -89,8 +87,8 @@ public class ContourTracing { private static final Logger logger = LoggerFactory.getLogger(ContourTracing.class); - - + + /** * Convert labeled images to detection objects, determining the region from the filename if possible. * @param paths paths to image files (e.g. PNGs) @@ -582,7 +580,7 @@ public static Map createROIs(Raster raster, int band, RegionRequest } /** - * Create ROIs from a labelled image containing integer labels. + * Create Geometries from a labelled image containing integer labels. * * @param image the labelled image * @param region region used to convert coordinates into the full image space (optional) @@ -590,7 +588,7 @@ public static Map createROIs(Raster raster, int band, RegionRequest * @param maxLabel maximum label; if less than minLabel, the maximum label will be found in the image and used * @return an ordered map containing all the ROIs that could be found; corresponding labels are keys in the map */ - public static Map createROIs(SimpleImage image, RegionRequest region, int minLabel, int maxLabel) { + public static Map createGeometries(SimpleImage image, RegionRequest region, int minLabel, int maxLabel) { var envelopes = new HashMap(); if (minLabel == maxLabel) { // Don't bother storing an envelope here - we'll iterate the full image when tracing @@ -632,11 +630,12 @@ public static Map createROIs(SimpleImage image, RegionRequest regio .collect( Collectors.toMap( Map.Entry::getKey, - e -> labelToROI(image, e.getKey().doubleValue(), region, e.getValue())) + e -> createTracedGeometry(image, e.getKey().doubleValue(), e.getKey().doubleValue(), region, e.getValue()) + ) ); // Return a sorted map with all non-empty ROIs - Map rois = new TreeMap<>(); + Map rois = new TreeMap<>(); for (var entry : map.entrySet()) { if (entry.getValue() != null && !entry.getValue().isEmpty()) rois.put(entry.getKey(), entry.getValue()); @@ -644,6 +643,30 @@ public static Map createROIs(SimpleImage image, RegionRequest regio return rois; } + /** + * Create ROIs from a labelled image containing integer labels. + * + * @param image the labelled image + * @param region region used to convert coordinates into the full image space (optional) + * @param minLabel minimum label; usually 1, but may be 0 if a background ROI should be created + * @param maxLabel maximum label; if less than minLabel, the maximum label will be found in the image and used + * @return an ordered map containing all the ROIs that could be found; corresponding labels are keys in the map + */ + public static Map createROIs(SimpleImage image, RegionRequest region, int minLabel, int maxLabel) { + var map = createGeometries(image, region, minLabel, maxLabel); + return map.entrySet().stream() + .collect( + Collectors.toMap( + Map.Entry::getKey, + es -> { + var geom = es.getValue(); + return geom == null ? null : GeometryTools.geometryToROI(geom, region == null ? ImagePlane.getDefaultPlane() : region.getImagePlane()); + } + ) + ); + } + + private static ROI labelToROI(SimpleImage image, double label, RegionRequest region, Envelope envelope) { return createTracedROI(image, label, label, region, envelope); } From e660c21edecafc137b381105759c003efe4d7e3e Mon Sep 17 00:00:00 2001 From: Alan O'Callaghan Date: Thu, 25 Jul 2024 13:28:51 +0100 Subject: [PATCH 121/866] Rename runner class and add getter --- ...adocViewer.java => JavadocViewerRunner.java} | 17 +++++++++++++---- .../lib/gui/scripting/DefaultScriptEditor.java | 4 ++-- 2 files changed, 15 insertions(+), 6 deletions(-) rename qupath-gui-fx/src/main/java/qupath/lib/gui/{JavadocViewer.java => JavadocViewerRunner.java} (90%) diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/JavadocViewer.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/JavadocViewerRunner.java similarity index 90% rename from qupath-gui-fx/src/main/java/qupath/lib/gui/JavadocViewer.java rename to qupath-gui-fx/src/main/java/qupath/lib/gui/JavadocViewerRunner.java index 5f2616b8c..8395e6438 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/JavadocViewer.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/JavadocViewerRunner.java @@ -29,6 +29,7 @@ import qupath.lib.common.GeneralTools; import qupath.lib.gui.prefs.PathPrefs; import qupath.lib.gui.tools.WebViews; +import qupath.ui.javadocviewer.gui.viewer.JavadocViewer; import qupath.ui.javadocviewer.gui.viewer.JavadocViewerCommand; import java.net.URI; @@ -53,9 +54,9 @@ * *

    */ -public class JavadocViewer implements Runnable { +public class JavadocViewerRunner implements Runnable { - private static final Logger logger = LoggerFactory.getLogger(JavadocViewer.class); + private static final Logger logger = LoggerFactory.getLogger(JavadocViewerRunner.class); private static final String JAVADOC_PATH_SYSTEM_PROPERTY = "javadoc"; private static final String JAVADOC_PATH_PREFERENCE = "javadocPath"; private static final StringProperty javadocPath = PathPrefs.createPersistentPreference(JAVADOC_PATH_PREFERENCE, null); @@ -66,7 +67,7 @@ public class JavadocViewer implements Runnable { * * @param owner the stage that should own the viewer window. Can be null */ - public JavadocViewer(Stage owner) { + public JavadocViewerRunner(Stage owner) { command = new JavadocViewerCommand( owner, WebViews.getStyleSheet(), @@ -90,6 +91,14 @@ public JavadocViewer(Stage owner) { ); } + /** + * Get a reference to the viewer launched by the {@link JavadocViewerCommand}. + * @return A reference to the Javadoc viewer. + */ + public JavadocViewer getJavadocViewer() { + return command.getJavadocViewer(); + } + @Override public void run() { command.run(); @@ -98,7 +107,7 @@ public void run() { private static String findJavadocUriAroundExecutable() { URI codeUri; try { - codeUri = JavadocViewer.class.getProtectionDomain().getCodeSource().getLocation().toURI(); + codeUri = JavadocViewerRunner.class.getProtectionDomain().getCodeSource().getLocation().toURI(); } catch (URISyntaxException e) { logger.debug("Could not convert URI", e); return null; diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/scripting/DefaultScriptEditor.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/scripting/DefaultScriptEditor.java index 7c6316275..88fbd0174 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/scripting/DefaultScriptEditor.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/scripting/DefaultScriptEditor.java @@ -114,7 +114,7 @@ import javafx.util.Callback; import qupath.fx.dialogs.FileChoosers; import qupath.lib.common.GeneralTools; -import qupath.lib.gui.JavadocViewer; +import qupath.lib.gui.JavadocViewerRunner; import qupath.lib.gui.QuPathGUI; import qupath.lib.gui.actions.ActionTools; import qupath.fx.dialogs.Dialogs; @@ -430,7 +430,7 @@ public DefaultScriptEditor(final QuPathGUI qupath) { paneCode.setStyle(style); paneConsole.setStyle(style); - showJavadocsAction = ActionTools.createAction(new JavadocViewer(qupath.getStage()), "Show Javadocs"); + showJavadocsAction = ActionTools.createAction(new JavadocViewerRunner(qupath.getStage()), "Show Javadocs"); } private void setToggle(ScriptLanguage language) { From 74b5a6e5afa52f540982901242c00b5806d65f1d Mon Sep 17 00:00:00 2001 From: lleplat Date: Mon, 29 Jul 2024 11:27:09 +0100 Subject: [PATCH 122/866] Refactoring --- .../lib/images/servers/ColorTransforms.java | 448 +++++++----------- 1 file changed, 177 insertions(+), 271 deletions(-) diff --git a/qupath-core/src/main/java/qupath/lib/images/servers/ColorTransforms.java b/qupath-core/src/main/java/qupath/lib/images/servers/ColorTransforms.java index 4eb4bd7c8..61f37daf3 100644 --- a/qupath-core/src/main/java/qupath/lib/images/servers/ColorTransforms.java +++ b/qupath-core/src/main/java/qupath/lib/images/servers/ColorTransforms.java @@ -23,10 +23,13 @@ import java.awt.image.BufferedImage; import java.io.IOException; +import java.util.Arrays; +import java.util.Objects; import com.google.gson.Gson; import com.google.gson.GsonBuilder; import com.google.gson.JsonObject; +import com.google.gson.Strictness; import com.google.gson.TypeAdapter; import com.google.gson.stream.JsonReader; import com.google.gson.stream.JsonWriter; @@ -50,27 +53,28 @@ public class ColorTransforms { * The simplest example of this is to extract a single channel (band) from an image. */ public interface ColorTransform { - + /** * Extract a (row-wise) array containing the pixels extracted from a BufferedImage. + * * @param server the server from which the image was read; can be necessary for some transforms (e.g. to request color deconvolution stains) * @param img the image * @param pixels optional preallocated array; will be used if it is long enough to hold the transformed pixels - * @return + * @return a (row-wise) array containing the transformed pixels of the provided image */ float[] extractChannel(ImageServer server, BufferedImage img, float[] pixels); - + /** * Query whether this transform can be applied to the specified image. - * Reasons why it may not be include the type or channel number being incompatible. - * @param server - * @return + * Reasons why it may not be supported include the type or channel number being incompatible. + * + * @param server the server from which the image will be read + * @return whether this transform can be applied to the provided image */ boolean supportsImage(ImageServer server); /** - * Get a displayable name for the transform. - * @return + * Get a displayable name for the transform. Can be null */ String getName(); @@ -80,8 +84,8 @@ public interface ColorTransform { * {@link TypeAdapter} to support serializing a {@link ColorTransform}. */ public static class ColorTransformTypeAdapter extends TypeAdapter { - - private static Gson gson = new GsonBuilder().setLenient().create(); + + private static final Gson gson = new GsonBuilder().setStrictness(Strictness.LENIENT).create(); @Override public void write(JsonWriter out, ColorTransform value) throws IOException { @@ -114,23 +118,24 @@ public ColorTransform read(JsonReader in) throws IOException { } throw new IOException("Unknown ColorTransform " + obj); } - } - - + /** - * Create ColorTransform to extract a channel based on its number (0-based index, although result of {@link ColorTransform#getName()} is 1-based). - * @param channel - * @return + * Create a ColorTransform that extracts a channel based on its index. + * + * @param channel the index of the channel to extract. It must be 0-based, although + * the result of {@link ColorTransform#getName()} will be 1-based + * @return a ColorTransform extracting the provided channel */ public static ColorTransform createChannelExtractor(int channel) { return new ExtractChannel(channel); } /** - * Create ColorTransform to extract a channel based on its name. - * @param channelName - * @return + * Create a ColorTransform that extracts a channel based on its name. + * + * @param channelName the name of the channel to extract + * @return a ColorTransform extracting the provided channel */ public static ColorTransform createChannelExtractor(String channelName) { return new ExtractChannelByName(channelName); @@ -138,89 +143,62 @@ public static ColorTransform createChannelExtractor(String channelName) { /** * Create a ColorTransform that calculates the mean of all channels. - * @return */ public static ColorTransform createMeanChannelTransform() { return new AverageChannels(); } - - /** - * Create a ColorTransform that applies color deconvolution. - * @param stains the stains (this will be 'fixed', and not adapted for each image) - * @param stainNumber number of the stain (1, 2 or 3) - * @return - */ - public static ColorTransform createColorDeconvolvedChannel(ColorDeconvolutionStains stains, int stainNumber) { - return new ColorDeconvolvedChannel(stains, stainNumber); - } - + /** * Create a ColorTransform that calculates the maximum of all channels. - * @return */ public static ColorTransform createMaximumChannelTransform() { return new MaxChannels(); } - + /** * Create a ColorTransform that calculates the minimum of all channels. - * @return */ public static ColorTransform createMinimumChannelTransform() { return new MinChannels(); } - - - - static float[] ensureArrayLength(BufferedImage img, float[] pixels) { - int n = img.getWidth() * img.getHeight(); - if (pixels == null || pixels.length < n) - return new float[n]; - return pixels; + /** + * Create a ColorTransform that applies color deconvolution. + * + * @param stains the stains (this will be 'fixed', and not adapted for each image) + * @param stainNumber number of the stain (1, 2 or 3) + * @return a ColorTransform applying color deconvolution with the provided parameters + * @throws IllegalArgumentException when the stain number is incorrect + */ + public static ColorTransform createColorDeconvolvedChannel(ColorDeconvolutionStains stains, int stainNumber) { + return new ColorDeconvolvedChannel(stains, stainNumber); } - - - static class ColorDeconvolvedChannel implements ColorTransform { - - private ColorDeconvolutionStains stains; - private int stainNumber; - private transient ColorTransformMethod method; - - ColorDeconvolvedChannel(ColorDeconvolutionStains stains, int stainNumber) { - this.stains = stains; - this.stainNumber = stainNumber; + + static class ExtractChannel implements ColorTransform { + + private final int channel; + + public ExtractChannel(int channel) { + this.channel = channel; } @Override public float[] extractChannel(ImageServer server, BufferedImage img, float[] pixels) { - int[] rgb = img.getRGB(0, 0, img.getWidth(), img.getHeight(), null, 0, img.getWidth()); - return ColorTransformer.getTransformedPixels(rgb, getMethod(), pixels, stains); - } - - private ColorTransformMethod getMethod() { - if (method == null) { - switch (stainNumber) { - case 1: return ColorTransformMethod.Stain_1; - case 2: return ColorTransformMethod.Stain_2; - case 3: return ColorTransformMethod.Stain_3; - default: throw new IllegalArgumentException("Stain number is " + stainNumber + ", but must be between 1 and 3!"); - } - } - return method; + pixels = ensureArrayLength(img, pixels); + return img.getRaster().getSamples(0, 0, img.getWidth(), img.getHeight(), channel, pixels); } @Override - public boolean supportsImage(ImageServer server) { - return server.isRGB() && server.getPixelType() == PixelType.UINT8; + public String getName() { + return "Channel " + (channel + 1); } @Override - public String getName() { - return stains.getStain(stainNumber).getName(); + public boolean supportsImage(ImageServer server) { + return channel < server.nChannels(); } - + @Override public String toString() { return getName(); @@ -230,8 +208,7 @@ public String toString() { public int hashCode() { final int prime = 31; int result = 1; - result = prime * result + stainNumber; - result = prime * result + ((stains == null) ? 0 : stains.hashCode()); + result = prime * result + channel; return result; } @@ -239,97 +216,27 @@ public int hashCode() { public boolean equals(Object obj) { if (this == obj) return true; - if (obj == null) + if (!(obj instanceof ExtractChannel extractChannel)) return false; - if (getClass() != obj.getClass()) - return false; - ColorDeconvolvedChannel other = (ColorDeconvolvedChannel) obj; - if (stainNumber != other.stainNumber) - return false; - if (stains == null) { - if (other.stains != null) - return false; - } else if (!stains.equals(other.stains)) - return false; - return true; - } - - - - } - - - static class ExtractChannel implements ColorTransform { - - private int channel; - - ExtractChannel(int channel) { - this.channel = channel; - } - - @Override - public float[] extractChannel(ImageServer server, BufferedImage img, float[] pixels) { - pixels = ensureArrayLength(img, pixels); - return img.getRaster().getSamples(0, 0, img.getWidth(), img.getHeight(), channel, pixels); - } - - @Override - public String getName() { - return "Channel " + (channel + 1); + return channel == extractChannel.channel; } - - @Override - public String toString() { - return getName(); - } - + /** * Get the channel number to extract (0-based index). - * @return */ public int getChannelNumber() { return channel; } + } - @Override - public boolean supportsImage(ImageServer server) { - return channel < server.nChannels(); - } + static class ExtractChannelByName implements ColorTransform { - @Override - public int hashCode() { - final int prime = 31; - int result = 1; - result = prime * result + channel; - return result; - } + private final String channelName; - @Override - public boolean equals(Object obj) { - if (this == obj) - return true; - if (obj == null) - return false; - if (getClass() != obj.getClass()) - return false; - ExtractChannel other = (ExtractChannel) obj; - if (channel != other.channel) - return false; - return true; - } - - - - } - - static class ExtractChannelByName implements ColorTransform { - - private String channelName; - - ExtractChannelByName(String channel) { + public ExtractChannelByName(String channel) { this.channelName = channel; } - + @Override public float[] extractChannel(ImageServer server, BufferedImage img, float[] pixels) { pixels = ensureArrayLength(img, pixels); @@ -339,41 +246,22 @@ public float[] extractChannel(ImageServer server, BufferedImage i } throw new IllegalArgumentException("No channel found with name " + channelName); } - + @Override public String getName() { return channelName; } - - /** - * Get the channel name to extract. - * @return - */ - public String getChannelName() { - return channelName; - } - - @Override - public String toString() { - return getName(); - } - - private int getChannelNumber(ImageServer server) { - int i = 0; - for (ImageChannel channel : server.getMetadata().getChannels()) { - if (channelName.equals(channel.getName())) { - return i; - } - i++; - } - return -1; - } @Override public boolean supportsImage(ImageServer server) { return getChannelNumber(server) >= 0; } + @Override + public String toString() { + return getName(); + } + @Override public int hashCode() { final int prime = 31; @@ -386,26 +274,29 @@ public int hashCode() { public boolean equals(Object obj) { if (this == obj) return true; - if (obj == null) - return false; - if (getClass() != obj.getClass()) - return false; - ExtractChannelByName other = (ExtractChannelByName) obj; - if (channelName == null) { - if (other.channelName != null) - return false; - } else if (!channelName.equals(other.channelName)) + if (!(obj instanceof ExtractChannelByName extractChannel)) return false; - return true; + return Objects.equals(channelName, extractChannel.channelName); + } + + /** + * Get the channel name to extract. Can be null + */ + public String getChannelName() { + return channelName; + } + + private int getChannelNumber(ImageServer server) { + return server.getMetadata().getChannels() + .stream() + .map(ImageChannel::getName) + .toList() + .indexOf(channelName); } - - - } - - + abstract static class CombineChannels implements ColorTransform { - + @Override public float[] extractChannel(ImageServer server, BufferedImage img, float[] pixels) { pixels = ensureArrayLength(img, pixels); @@ -421,39 +312,28 @@ public float[] extractChannel(ImageServer server, BufferedImage i } return pixels; } - + abstract double computeValue(double[] values); @Override public boolean supportsImage(ImageServer server) { return true; } - + @Override public String toString() { return getName(); } - } - - /** - * Store the {@link CombineType}. This is really to add deserialization from JSON. - */ - private static enum CombineType {MEAN, MINIMUM, MAXIMUM} - - + static class AverageChannels extends CombineChannels { - - @SuppressWarnings("unused") - private CombineType combineType = CombineType.MEAN; - + + @SuppressWarnings("unused") // used for JSON serialization + private final CombineType combineType = CombineType.MEAN; + @Override public double computeValue(double[] values) { - int n = values.length; - double mean = 0; - for (double v : values) - mean += v/n; - return mean; + return Arrays.stream(values).average().orElse(Double.NaN); } @Override @@ -465,7 +345,7 @@ public String getName() { public int hashCode() { final int prime = 31; int result = 1; - result = prime * result + ((combineType == null) ? 0 : combineType.hashCode()); + result = prime * result + combineType.hashCode(); return result; } @@ -473,34 +353,18 @@ public int hashCode() { public boolean equals(Object obj) { if (this == obj) return true; - if (obj == null) - return false; - if (getClass() != obj.getClass()) - return false; - AverageChannels other = (AverageChannels) obj; - if (combineType != other.combineType) - return false; - return true; + return obj instanceof AverageChannels; } - } - + static class MaxChannels extends CombineChannels { - - @SuppressWarnings("unused") - private CombineType combineType = CombineType.MAXIMUM; - + + @SuppressWarnings("unused") // used for JSON serialization + private final CombineType combineType = CombineType.MAXIMUM; + @Override public double computeValue(double[] values) { - int n = values.length; - if (n == 0) - return Double.NaN; - double max = Double.NEGATIVE_INFINITY; - for (double v : values) { - if (v > max) - max = v; - } - return max; + return Arrays.stream(values).max().orElse(Double.NaN); } @Override @@ -512,7 +376,7 @@ public String getName() { public int hashCode() { final int prime = 31; int result = 1; - result = prime * result + ((combineType == null) ? 0 : combineType.hashCode()); + result = prime * result + combineType.hashCode(); return result; } @@ -520,35 +384,18 @@ public int hashCode() { public boolean equals(Object obj) { if (this == obj) return true; - if (obj == null) - return false; - if (getClass() != obj.getClass()) - return false; - MaxChannels other = (MaxChannels) obj; - if (combineType != other.combineType) - return false; - return true; + return obj instanceof MaxChannels; } - - } - + static class MinChannels extends CombineChannels { - - @SuppressWarnings("unused") - private CombineType combineType = CombineType.MINIMUM; - + + @SuppressWarnings("unused") // used for JSON serialization + private final CombineType combineType = CombineType.MINIMUM; + @Override public double computeValue(double[] values) { - int n = values.length; - if (n == 0) - return Double.NaN; - double min = Double.POSITIVE_INFINITY; - for (double v : values) { - if (v < min) - min = v; - } - return min; + return Arrays.stream(values).min().orElse(Double.NaN); } @Override @@ -560,7 +407,7 @@ public String getName() { public int hashCode() { final int prime = 31; int result = 1; - result = prime * result + ((combineType == null) ? 0 : combineType.hashCode()); + result = prime * result + combineType.hashCode(); return result; } @@ -568,18 +415,77 @@ public int hashCode() { public boolean equals(Object obj) { if (this == obj) return true; - if (obj == null) - return false; - if (getClass() != obj.getClass()) - return false; - MinChannels other = (MinChannels) obj; - if (combineType != other.combineType) - return false; - return true; + return obj instanceof MinChannels; } + } + + static class ColorDeconvolvedChannel implements ColorTransform { + private final ColorDeconvolutionStains stains; + private final int stainNumber; + private final transient ColorTransformMethod method; - + public ColorDeconvolvedChannel(ColorDeconvolutionStains stains, int stainNumber) { + this.stains = stains; + this.stainNumber = stainNumber; + this.method = switch (stainNumber) { + case 1 -> ColorTransformMethod.Stain_1; + case 2 -> ColorTransformMethod.Stain_2; + case 3 -> ColorTransformMethod.Stain_3; + default -> + throw new IllegalArgumentException("Stain number is " + stainNumber + ", but must be between 1 and 3!"); + }; + } + + @Override + public float[] extractChannel(ImageServer server, BufferedImage img, float[] pixels) { + int[] rgb = img.getRGB(0, 0, img.getWidth(), img.getHeight(), null, 0, img.getWidth()); + return ColorTransformer.getTransformedPixels(rgb, method, pixels, stains); + } + + @Override + public boolean supportsImage(ImageServer server) { + return server.isRGB() && server.getPixelType() == PixelType.UINT8; + } + + @Override + public String getName() { + return stains == null ? null : stains.getStain(stainNumber).getName(); + } + + @Override + public String toString() { + return getName(); + } + + @Override + public int hashCode() { + final int prime = 31; + int result = 1; + result = prime * result + stainNumber; + result = prime * result + ((stains == null) ? 0 : stains.hashCode()); + return result; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) + return true; + if (!(obj instanceof ColorDeconvolvedChannel colorDeconvolvedChannel)) + return false; + return Objects.equals(stains, colorDeconvolvedChannel.stains) && stainNumber == colorDeconvolvedChannel.stainNumber; + } } + /** + * Store the {@link CombineType}. This is really to add deserialization from JSON. + */ + private enum CombineType {MEAN, MINIMUM, MAXIMUM} + + private static float[] ensureArrayLength(BufferedImage img, float[] pixels) { + int n = img.getWidth() * img.getHeight(); + if (pixels == null || pixels.length < n) + return new float[n]; + return pixels; + } } \ No newline at end of file From f605b27b4d3c7949c6a9f3bcb6bc0aca0f19853a Mon Sep 17 00:00:00 2001 From: petebankhead Date: Mon, 29 Jul 2024 12:55:14 +0100 Subject: [PATCH 123/866] Convenience methods for PathObjectHierarchy Add more convenience methods for getting objects from a `PathObjectHierarchy` without needing to worry about the specific Java class used for object types. This includes: ``` getAllObjectsForROI(ROI roi) getAnnotationsForROI(ROI roi) getTilesForROI(ROI roi) getCellsForROI(ROI roi) getAllDetectionsForROI(ROI roi) ``` Also add `ROIs.createEllipseROI(ImageRegion region)` for convenience, similar to how a rectangle ROI can be made. Along the way, create a new class for hierarchy tests - since the older ones are hard to read and maintain. --- .../hierarchy/PathObjectHierarchy.java | 77 +++ .../src/main/java/qupath/lib/roi/ROIs.java | 10 + .../hierarchy/TestPathObjectHierarchy.java | 475 +++--------------- .../TestPathObjectHierarchyLegacy.java | 413 +++++++++++++++ 4 files changed, 571 insertions(+), 404 deletions(-) create mode 100644 qupath-core/src/test/java/qupath/lib/objects/hierarchy/TestPathObjectHierarchyLegacy.java diff --git a/qupath-core/src/main/java/qupath/lib/objects/hierarchy/PathObjectHierarchy.java b/qupath-core/src/main/java/qupath/lib/objects/hierarchy/PathObjectHierarchy.java index e260e3605..295811118 100644 --- a/qupath-core/src/main/java/qupath/lib/objects/hierarchy/PathObjectHierarchy.java +++ b/qupath-core/src/main/java/qupath/lib/objects/hierarchy/PathObjectHierarchy.java @@ -842,7 +842,11 @@ public synchronized void setHierarchy(PathObjectHierarchy hierarchy) { * @param cls class of PathObjects (e.g. PathDetectionObject), or null to accept all * @param roi * @return + * @deprecated v0.6.0; use {@link #getAllObjectsForROI(ROI)} instead and filter the returned collection, + * or use {@link #getAnnotationsForROI(ROI)}, {@link #getCellsForROI(ROI)}, + * {@link #getAllDetectionsForROI(ROI)} or {@link #getTilesForROI(ROI)}. */ + @Deprecated public Collection getObjectsForROI(Class cls, ROI roi) { if (roi.isEmpty() || !roi.isArea()) return Collections.emptyList(); @@ -850,6 +854,79 @@ public Collection getObjectsForROI(Class cls, Collection pathObjects = tileCache.getObjectsForRegion(cls, ImageRegion.createInstance(roi), new HashSet<>(), true); return filterObjectsForROI(roi, pathObjects); } + + /** + * Get all objects for a ROI. + * This uses the same rules as {@link #resolveHierarchy()}: annotations must be completely covered + * by the ROI, while detection need only have their centroid within the ROI. + * @param roi + * @return + * @see #getAnnotationsForROI(ROI) + * @see #getCellsForROI(ROI) + * @see #getAllDetectionsForROI(ROI) + * @see #getTilesForROI(ROI) + */ + public Collection getAllObjectsForROI(ROI roi) { + return getObjectsForROI(null, roi); + } + + /** + * Get all the annotations covered by the specified ROI. + * @param roi the ROI to use for filtering + * @return a collection of annotation objects that are completely covered by the specified ROI + * @see #getAllObjectsForROI(ROI) + * @implSpec This does not return all annotations that intersect with the ROI, + * but rather only those that are covered by the ROI - consistent with the + * behavior of {@link #resolveHierarchy()}. + */ + public Collection getAnnotationsForROI(ROI roi) { + return getObjectsForROI(PathAnnotationObject.class, roi); + } + + /** + * Get all the tile objects covered by the specified ROI. + * Tile objects are a special subclass of detections. + * @param roi the ROI to use for filtering + * @return a collection of tile objects with centroids contained within the specified ROI + * @see #getAllObjectsForROI(ROI) + * @see #getAllDetectionsForROI(ROI) + * @implSpec This does not return all tiles that intersect with the ROI, + * but rather only those whose centroid falls within the ROI - consistent with the + * behavior of {@link #resolveHierarchy()}. + */ + public Collection getTilesForROI(ROI roi) { + return getObjectsForROI(PathTileObject.class, roi); + } + + /** + * Get all the cell objects covered by the specified ROI. + * Cell objects are a special subclass of detections. + * @param roi the ROI to use for filtering + * @return a collection of cell objects with centroids contained within the specified ROI + * @see #getAllObjectsForROI(ROI) + * @see #getAllDetectionsForROI(ROI) + * @implSpec This does not return all cells that intersect with the ROI, + * but rather only those whose centroid falls within the ROI - consistent with the + * behavior of {@link #resolveHierarchy()}. + */ public Collection getCellsForROI(ROI roi) { + return getObjectsForROI(PathCellObject.class, roi); + } + + /** + * Get all the detection objects covered by the specified ROI - including subclasses of detections, + * such as cells and tiles. + * @param roi the ROI to use for filtering + * @return a collection of detection objects with centroids contained within the specified ROI + * @see #getAllObjectsForROI(ROI) + * @see #getCellsForROI(ROI) + * @see #getTilesForROI(ROI) + * @implSpec This does not return all cells that intersect with the ROI, + * but rather only those whose centroid falls within the ROI - consistent with the + * behavior of {@link #resolveHierarchy()}. + */ + public Collection getAllDetectionsForROI(ROI roi) { + return getObjectsForROI(PathDetectionObject.class, roi); + } /** * Filter the objects in a specified collection, returning only those contained 'inside' a ROI diff --git a/qupath-core/src/main/java/qupath/lib/roi/ROIs.java b/qupath-core/src/main/java/qupath/lib/roi/ROIs.java index 9dd263249..a4acdfd59 100644 --- a/qupath-core/src/main/java/qupath/lib/roi/ROIs.java +++ b/qupath-core/src/main/java/qupath/lib/roi/ROIs.java @@ -102,6 +102,16 @@ public static ROI createEllipseROI(double x, double y, double width, double heig return new EllipseROI(x, y, width, height, plane); } + /** + * Create an ellipse ROI defined by its bounding box. + * + * @param region + * @return + */ + public static ROI createEllipseROI(ImageRegion region) { + return createEllipseROI(region.getX(), region.getY(), region.getWidth(), region.getHeight(), region.getImagePlane()); + } + /** * Create a line ROI with start and end coordinates. * diff --git a/qupath-core/src/test/java/qupath/lib/objects/hierarchy/TestPathObjectHierarchy.java b/qupath-core/src/test/java/qupath/lib/objects/hierarchy/TestPathObjectHierarchy.java index cdf7e70f0..06dd7c2fb 100644 --- a/qupath-core/src/test/java/qupath/lib/objects/hierarchy/TestPathObjectHierarchy.java +++ b/qupath-core/src/test/java/qupath/lib/objects/hierarchy/TestPathObjectHierarchy.java @@ -1,411 +1,78 @@ -/*- - * #%L - * This file is part of QuPath. - * %% - * Copyright (C) 2014 - 2016 The Queen's University of Belfast, Northern Ireland - * Contact: IP Management (ipmanagement@qub.ac.uk) - * Copyright (C) 2018 - 2020 QuPath developers, The University of Edinburgh - * %% - * QuPath 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 3 of the - * License, or (at your option) any later version. - * - * QuPath 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 QuPath. If not, see . - * #L% - */ +package qupath.lib.objects.hierarchy; -package qupath.lib.objects.hierarchy; - -import static org.junit.jupiter.api.Assertions.*; - -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collection; -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.List; - import org.junit.jupiter.api.Test; - -import qupath.lib.regions.ImagePlane; -import qupath.lib.regions.ImageRegion; -import qupath.lib.objects.PathAnnotationObject; import qupath.lib.objects.PathObject; import qupath.lib.objects.PathObjectTools; import qupath.lib.objects.PathObjects; -import qupath.lib.objects.PathRootObject; -import qupath.lib.objects.hierarchy.events.PathObjectHierarchyEvent; -import qupath.lib.objects.hierarchy.events.PathObjectHierarchyListener; +import qupath.lib.regions.ImagePlane; +import qupath.lib.regions.ImageRegion; import qupath.lib.roi.ROIs; -import qupath.lib.roi.interfaces.ROI; - -@SuppressWarnings("javadoc") -public class TestPathObjectHierarchy { - PathObjectHierarchy myPH = new PathObjectHierarchy(); - PO_hlistener myPOHL = new PO_hlistener(); - PathObjectHierarchyEvent event = PathObjectHierarchyEvent.createObjectAddedEvent(new Object(), myPH, new PathAnnotationObject(), new PathAnnotationObject()); - PathRootObject myPRO = new PathRootObject(); - ROI my_PR1 = ROIs.createRectangleROI(10, 10, 2, 2, ImagePlane.getDefaultPlane()); - ROI my_PR2 = ROIs.createRectangleROI(10, 10, 1, 1, ImagePlane.getDefaultPlane()); - ROI my_PR3 = ROIs.createRectangleROI(30, 30, 1, 1, ImagePlane.getDefaultPlane()); - PathObject myChild1PAO = PathObjects.createAnnotationObject(my_PR1); - PathObject myChild2PAO = PathObjects.createAnnotationObject(my_PR2); - PathObject myChild3PAO = PathObjects.createAnnotationObject(my_PR3); - ImageRegion myIR = ImageRegion.createInstance(25, 25, 10, 10, 0, 0); // set to contain child3 - other values can be used to test negative - - @Test - public void test_PathHierarchy() { - - // Created new PH with listeners - myPH.addListener(myPOHL); - assertTrue(myPH.isEmpty()); - - // Firing direct event - myPH.fireEvent(event); - assertEquals(myPOHL.getFiredState(), 1); // event(ADDED) fired - myPOHL.setFiredState(0); - - // Creating structure of POs - myChild1PAO.addChildObject(myChild3PAO); - myPRO.addChildObject(myChild1PAO); - assertEquals(myPRO.nChildObjects(), 1); - assertEquals(myChild1PAO.getParent(), myPRO); - - // Firing indirect events (adding/removing from hierarchy) - // Adding one PO with a child (so 2) - myPH.addObject(myChild1PAO); - Collection POAL1 = new ArrayList<>(); - POAL1 = myPH.getObjects(POAL1, PathAnnotationObject.class); - assertEquals(POAL1.size(), 2); // 1 + child - assertEquals(myPH.getObjects(null, PathAnnotationObject.class), POAL1); - assertEquals(myChild1PAO.getParent(), myPH.getRootObject()); // child1 has been added to the PH - the PH root is the parent of child1 - assertEquals(myChild3PAO.getParent(), myChild1PAO); // child3 is added to the PH through the addition of child1 (its parent) - - assertEquals(myPOHL.getFiredState(), 1); // event(ADDED) fired - myPOHL.setFiredState(0); - - // Adding one PO without a child (so 1) - this PO, however, is fully contained within Child1 - myPH.insertPathObject(myChild2PAO, true); - Collection POAL2 = new ArrayList<>(); - POAL2 = myPH.getObjects(POAL2, PathAnnotationObject.class); - assertEquals(POAL2.size(), 3); // 2 + 1 - assertEquals(myPH.getObjects(null, PathAnnotationObject.class), POAL2); - assertEquals(myChild2PAO.nChildObjects(), 0); // child2 doesn't have any children (child3 is only a child to child1 through the PO lineage) - //assertEquals(myChild2PAO.getParent(), myPH.getRootObject()); // child2's parent is not the root of the PH - assertEquals(myChild2PAO.getParent(), myChild1PAO); // child2's parent is child1 (as child2 is contained within child1) - - Collection POAL3 = new ArrayList<>(); - POAL3 = PathObjectTools.getDescendantObjects(myChild1PAO, POAL3, PathAnnotationObject.class); - assertEquals(POAL3.size(), 2); // child1 has now 2 descendants - one on the PH lineage (child2) and one on the PO lineage (child3) - assertEquals(PathObjectTools.getDescendantObjects(myChild1PAO, null, PathAnnotationObject.class), POAL3); - - List POAL4 = new ArrayList<>(); - POAL4 = myPH.getFlattenedObjectList(POAL4); - assertEquals(POAL4.size(), 4); // all nodes (including parent node from hierarchy) - assertEquals(myPH.getFlattenedObjectList(null), POAL4); - - assertEquals(myPH.nObjects(), 3); // descendants - TODO: name may be a bit misleading??? - -// // Remove one PO without a child (so 2 left) -// myPH.removeObject(myChild2PAO, true); // no children, so a changed structure event will fire -// List POAL5 = new ArrayList<>(); -// POAL5 = myPH.getObjects(POAL5, PathAnnotationObject.class); -// assertEquals(POAL5.size(), 2); // 3 - 1 -// assertEquals(myPH.getObjects(null, PathAnnotationObject.class), POAL5); -// -// assertEquals(myPOHL.getFiredState(), 3); // event(CHANGED STRUCTURE) fired -// myPOHL.setFiredState(0); - - // Remove one PO without a child (so 2 left) - myPH.removeObject(myChild2PAO, true); // no children, so a removed event will fire - Collection POAL5 = new ArrayList<>(); - POAL5 = myPH.getObjects(POAL5, PathAnnotationObject.class); - assertEquals(POAL5.size(), 2); // 3 - 1 - assertEquals(myPH.getObjects(null, PathAnnotationObject.class), POAL5); - - assertEquals(myPOHL.getFiredState(), 2); // event(CHANGED REMOVED) fired - myPOHL.setFiredState(0); - - // Remove one PO with a child but keep child (so 1 left) - myPH.removeObject(myChild1PAO, true); - Collection POAL6 = new ArrayList<>(); - POAL6 = myPH.getObjects(POAL6, PathAnnotationObject.class); - assertEquals(POAL6.size(), 1); // 2 - 1 - assertEquals(myPH.getObjects(null, PathAnnotationObject.class), POAL6); - - assertEquals(myPOHL.getFiredState(), 2); // event(REMOVED) fired - myPOHL.setFiredState(0); - - // Check how many objects present in the region indicated below - Collection POAL7 = new ArrayList<>(); - POAL7 = myPH.getObjectsForRegion(PathAnnotationObject.class, myIR, POAL7); - assertEquals(POAL7.size(), 1); // since there's only 1 object left (child3), this checks whether it falls within the region - assertEquals(myPH.getObjects(null, PathAnnotationObject.class), POAL7); - - // Finalise by removing all items left - assertEquals(myPH.nObjects(), 1); - myPH.clearAll(); - assertEquals(myPH.nObjects(), 0); - - } - - /** - * Introduced in v0.2.1 to cope with fixed behavior with TMA cores. - * This test failed in v0.2.0, however {@link #test_resolveHierarchy()} already passed. - */ - @Test - public void test_resolveHierarchyWithTMA() { - - var hierarchy = new PathObjectHierarchy(); - - var core = PathObjects.createTMACoreObject(1000, 1000, 1000, false); - var tmaGrid = DefaultTMAGrid.create(Collections.singletonList(core), 1); - hierarchy.setTMAGrid(tmaGrid); - - assertEquals(hierarchy.nObjects(), 1); - - var annotationInCore = PathObjects.createAnnotationObject( - ROIs.createRectangleROI(900, 900, 200, 200, ImagePlane.getDefaultPlane()) - ); - annotationInCore.setName("Annotation in core"); - hierarchy.addObject(annotationInCore); - - var annotationInAnnotation = PathObjects.createAnnotationObject( - ROIs.createRectangleROI(950, 950, 100, 100, ImagePlane.getDefaultPlane()) - ); - annotationInAnnotation.setName("Annotation in " + annotationInCore.getName()); - hierarchy.addObject(annotationInAnnotation); - - var annotationOutsideCore = PathObjects.createAnnotationObject( - ROIs.createRectangleROI(2000, 2000, 100, 100, ImagePlane.getDefaultPlane()) - ); - annotationOutsideCore.setName("Annotation outside core"); - hierarchy.addObject(annotationOutsideCore); - - // An annotation containing the core should *not* become a parent of the core, - // since cores must always be directly below the root - var annotationContainingCore = PathObjects.createAnnotationObject( - ROIs.createRectangleROI(400, 400-100, 1200, 1200, ImagePlane.getDefaultPlane()) - ); - annotationContainingCore.setName("Annotation containing core"); - hierarchy.addObject(annotationContainingCore); - - // Sanity check to ensure that our rectangle does indeed contain the core - assertTrue(annotationContainingCore.getROI().getGeometry().contains(core.getROI().getGeometry())); - assertFalse(core.getROI().getGeometry().contains(annotationContainingCore.getROI().getGeometry())); - - // Add a detection at the centroid of each annotation - var mapDetections = new LinkedHashMap(); - for (var annotation : Arrays.asList(annotationInAnnotation, annotationOutsideCore)) { - double radius = 2; - var roi = annotation.getROI(); - var detection = PathObjects.createDetectionObject( - ROIs.createEllipseROI( - roi.getCentroidX()-radius, - roi.getCentroidX()-radius, - radius*2, radius*2, roi.getImagePlane()) - ); - detection.setName("Detection in " + annotation.getName()); - mapDetections.put(detection, annotation); - hierarchy.addObject(detection); - } - // Add another detection outside of everything - var detectionOutside = PathObjects.createDetectionObject( - ROIs.createRectangleROI(4000, 4000, 5, 5, ImagePlane.getDefaultPlane()) - ); - detectionOutside.setName("Detection outside everything"); - hierarchy.addObject(detectionOutside); - mapDetections.put(detectionOutside, hierarchy.getRootObject()); - - // Add another detection inside the core but outside of any annotations - var detectionInCore = PathObjects.createDetectionObject( - ROIs.createRectangleROI(1000, 510, 1, 1, ImagePlane.getDefaultPlane()) -// ROIs.createPointsROI(1000, 510, ImagePlane.getDefaultPlane()) - ); -// System.err.println("Centroid: " + detectionInCore.getROI().getCentroidX() + ", " + detectionInCore.getROI().getCentroidY()); - detectionInCore.setName("Detection in core only"); - hierarchy.addObject(detectionInCore); - mapDetections.put(detectionInCore, core); - +import qupath.lib.roi.interfaces.ROI; - // Check hierarchy size - assertEquals(hierarchy.nObjects(), 5 + mapDetections.size()); - - // Check level before resolving the hierarchy - assertEquals(core.getLevel(), 1); - assertEquals(annotationContainingCore.getLevel(), 1); - assertEquals(annotationInCore.getLevel(), 1); - assertEquals(annotationInAnnotation.getLevel(), 1); - assertEquals(annotationOutsideCore.getLevel(), 1); - - assertEquals(core.getParent(), hierarchy.getRootObject()); - assertEquals(annotationContainingCore.getParent(), hierarchy.getRootObject()); - assertEquals(annotationInCore.getParent(), hierarchy.getRootObject()); - assertEquals(annotationInAnnotation.getParent(), hierarchy.getRootObject()); - assertEquals(annotationOutsideCore.getParent(), hierarchy.getRootObject()); - - for (var detection : mapDetections.keySet()) - assertEquals(detection.getParent(), hierarchy.getRootObject()); - - hierarchy.resolveHierarchy(); - - // Check level after resolving the hierarchy - assertEquals(core.getLevel(), 1); - assertEquals(annotationInCore.getLevel(), 2); - assertEquals(annotationInAnnotation.getLevel(), 3); - assertEquals(annotationOutsideCore.getLevel(), 1); - - assertEquals(core.getParent(), hierarchy.getRootObject()); - assertEquals(annotationContainingCore.getParent(), hierarchy.getRootObject()); - assertEquals(annotationInCore.getParent(), core); - assertEquals(annotationInAnnotation.getParent(), annotationInCore); - assertEquals(annotationOutsideCore.getParent(), hierarchy.getRootObject()); - - for (var entry : mapDetections.entrySet()) { -// System.err.println(entry.getKey() + ": " + entry.getKey().getParent() + ", " + entry.getValue()); - assertEquals(entry.getKey().getParent(), entry.getValue()); - } - - } - - - /** - * Based on {@link #test_resolveHierarchyWithTMA()}, without the TMA core involved. - * This test already passed in v0.2.0. - */ - @Test - public void test_resolveHierarchy() { - - var hierarchy = new PathObjectHierarchy(); - - var annotationInCore = PathObjects.createAnnotationObject( - ROIs.createRectangleROI(900, 900, 200, 200, ImagePlane.getDefaultPlane()) - ); - annotationInCore.setName("Annotation in core"); - hierarchy.addObject(annotationInCore); - - var annotationInAnnotation = PathObjects.createAnnotationObject( - ROIs.createRectangleROI(950, 950, 100, 100, ImagePlane.getDefaultPlane()) - ); - annotationInAnnotation.setName("Annotation in " + annotationInCore.getName()); - hierarchy.addObject(annotationInAnnotation); - - var annotationOutsideCore = PathObjects.createAnnotationObject( - ROIs.createRectangleROI(2000, 2000, 100, 100, ImagePlane.getDefaultPlane()) - ); - annotationOutsideCore.setName("Annotation outside core"); - hierarchy.addObject(annotationOutsideCore); - - // This was previously containing the entire TMA core - without the core, it acts as a stand-in - var annotationContainingCore = PathObjects.createAnnotationObject( - ROIs.createRectangleROI(400, 400-100, 1200, 1200, ImagePlane.getDefaultPlane()) - ); - annotationContainingCore.setName("Annotation stand-in for core"); - hierarchy.addObject(annotationContainingCore); - - // Add a detection at the centroid of each annotation - var mapDetections = new LinkedHashMap(); - for (var annotation : Arrays.asList(annotationInAnnotation, annotationOutsideCore)) { - double radius = 2; - var roi = annotation.getROI(); - var detection = PathObjects.createDetectionObject( - ROIs.createEllipseROI( - roi.getCentroidX()-radius, - roi.getCentroidX()-radius, - radius*2, radius*2, roi.getImagePlane()) - ); - detection.setName("Detection in " + annotation.getName()); - mapDetections.put(detection, annotation); - hierarchy.addObject(detection); - } - // Add another detection outside of everything - var detectionOutside = PathObjects.createDetectionObject( - ROIs.createRectangleROI(4000, 4000, 5, 5, ImagePlane.getDefaultPlane()) - ); - detectionOutside.setName("Detection outside everything"); - hierarchy.addObject(detectionOutside); - mapDetections.put(detectionOutside, hierarchy.getRootObject()); - - // Add another detection inside the core but outside of any annotations - var detectionInCore = PathObjects.createDetectionObject( - ROIs.createRectangleROI(1000, 510, 1, 1, ImagePlane.getDefaultPlane()) -// ROIs.createPointsROI(1000, 510, ImagePlane.getDefaultPlane()) - ); -// System.err.println("Centroid: " + detectionInCore.getROI().getCentroidX() + ", " + detectionInCore.getROI().getCentroidY()); - detectionInCore.setName("Detection in core only"); - hierarchy.addObject(detectionInCore); - - - // Check hierarchy size - assertEquals(hierarchy.nObjects(), 5 + mapDetections.size()); - - // Check level before resolving the hierarchy - assertEquals(annotationContainingCore.getLevel(), 1); - assertEquals(annotationInCore.getLevel(), 1); - assertEquals(annotationInAnnotation.getLevel(), 1); - assertEquals(annotationOutsideCore.getLevel(), 1); - - assertEquals(annotationContainingCore.getParent(), hierarchy.getRootObject()); - assertEquals(annotationInCore.getParent(), hierarchy.getRootObject()); - assertEquals(annotationInAnnotation.getParent(), hierarchy.getRootObject()); - assertEquals(annotationOutsideCore.getParent(), hierarchy.getRootObject()); - - for (var detection : mapDetections.keySet()) - assertEquals(detection.getParent(), hierarchy.getRootObject()); - - hierarchy.resolveHierarchy(); - - // Check level after resolving the hierarchy - assertEquals(annotationInCore.getLevel(), 2); - assertEquals(annotationInAnnotation.getLevel(), 3); - assertEquals(annotationOutsideCore.getLevel(), 1); - - assertEquals(annotationContainingCore.getParent(), hierarchy.getRootObject()); - assertEquals(annotationInCore.getParent(), annotationContainingCore); - assertEquals(annotationInAnnotation.getParent(), annotationInCore); - assertEquals(annotationOutsideCore.getParent(), hierarchy.getRootObject()); - - for (var entry : mapDetections.entrySet()) { - assertEquals(entry.getKey().getParent(), entry.getValue()); - } - - } - - -} - -// Helper classes for testing - -class PO_hlistener implements PathObjectHierarchyListener { - private int firedState = 0; - - public int getFiredState() { - return firedState; - } - - public void setFiredState(int state) { - this.firedState = state; - } - - @Override - public void hierarchyChanged(PathObjectHierarchyEvent event) { - if (event.getEventType() == PathObjectHierarchyEvent.HierarchyEventType.ADDED) - //System.out.println("Added!"); - this.firedState = 1; - else if (event.getEventType() == PathObjectHierarchyEvent.HierarchyEventType.REMOVED) - //System.out.println("Removed!"); - this.firedState = 2; - else if (event.getEventType() == PathObjectHierarchyEvent.HierarchyEventType.OTHER_STRUCTURE_CHANGE) - //System.out.println("Other!"); - this.firedState = 3; - } - -} - +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import java.util.function.Function; +import java.util.stream.Stream; + +public class TestPathObjectHierarchy { + + @Test + public void testGetObjects() { + + // Define some boundaries to create objects + var region1 = ImageRegion.createInstance(0, 0, 10, 20, 0, 0); + var region2 = ImageRegion.createInstance(1000, 1000, 20, 10, 0, 0); + + var regions = List.of(region1, region2); + + var hierarchy = new PathObjectHierarchy(); + var cellRectangles = createObjects(regions, ROIs::createRectangleROI, r -> PathObjects.createCellObject(r, null)); + var cellEllipses = createObjects(regions, ROIs::createEllipseROI, r -> PathObjects.createCellObject(r, null)); + + var tileRectangles = createObjects(regions, ROIs::createRectangleROI, PathObjects::createTileObject); + var tileEllipses = createObjects(regions, ROIs::createEllipseROI, PathObjects::createTileObject); + + var detectionRectangles = createObjects(regions, ROIs::createRectangleROI, PathObjects::createDetectionObject); + var detectionEllipses = createObjects(regions, ROIs::createEllipseROI, PathObjects::createDetectionObject); + + var annotationRectangles = createObjects(regions, ROIs::createRectangleROI, PathObjects::createAnnotationObject); + var annotationEllipses = createObjects(regions, ROIs::createEllipseROI, PathObjects::createAnnotationObject); + + var defaultPlaneObjects = Stream.of( + cellEllipses, + cellRectangles, + tileEllipses, + tileRectangles, + detectionEllipses, + detectionRectangles, + annotationEllipses, + annotationRectangles + ).flatMap(List::stream).toList(); + var z1Objects = defaultPlaneObjects.stream().map(p -> updateZ(p, 1)).toList(); + var t1Objects = defaultPlaneObjects.stream().map(p -> updateT(p, 1)).toList(); + + hierarchy.addObjects(defaultPlaneObjects); + hierarchy.addObjects(z1Objects); + hierarchy.addObjects(t1Objects); + } + + private static List createObjects(Collection regions, Function roiCreator, Function objectCreator) { + return regions.stream().map(r -> objectCreator.apply(roiCreator.apply(r))).toList(); + } + + private static PathObject updateZ(PathObject pathObject, int z) { + return PathObjectTools.updatePlane( + pathObject, + ImagePlane.getPlane(z, pathObject.getROI().getT()), + false, true); + } + + private static PathObject updateT(PathObject pathObject, int t) { + return PathObjectTools.updatePlane( + pathObject, + ImagePlane.getPlane(pathObject.getROI().getZ(), t), + false, true); + } + +} diff --git a/qupath-core/src/test/java/qupath/lib/objects/hierarchy/TestPathObjectHierarchyLegacy.java b/qupath-core/src/test/java/qupath/lib/objects/hierarchy/TestPathObjectHierarchyLegacy.java new file mode 100644 index 000000000..52e2193fd --- /dev/null +++ b/qupath-core/src/test/java/qupath/lib/objects/hierarchy/TestPathObjectHierarchyLegacy.java @@ -0,0 +1,413 @@ +/*- + * #%L + * This file is part of QuPath. + * %% + * Copyright (C) 2014 - 2016 The Queen's University of Belfast, Northern Ireland + * Contact: IP Management (ipmanagement@qub.ac.uk) + * Copyright (C) 2018 - 2020 QuPath developers, The University of Edinburgh + * %% + * QuPath 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 3 of the + * License, or (at your option) any later version. + * + * QuPath 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 QuPath. If not, see . + * #L% + */ + +package qupath.lib.objects.hierarchy; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; + +import org.junit.jupiter.api.Test; + +import qupath.lib.regions.ImagePlane; +import qupath.lib.regions.ImageRegion; +import qupath.lib.objects.PathAnnotationObject; +import qupath.lib.objects.PathObject; +import qupath.lib.objects.PathObjectTools; +import qupath.lib.objects.PathObjects; +import qupath.lib.objects.PathRootObject; +import qupath.lib.objects.hierarchy.events.PathObjectHierarchyEvent; +import qupath.lib.objects.hierarchy.events.PathObjectHierarchyListener; +import qupath.lib.roi.ROIs; +import qupath.lib.roi.interfaces.ROI; + +/** + * Legacy test class for PathObjectHierarchy. + */ +public class TestPathObjectHierarchyLegacy { + PathObjectHierarchy myPH = new PathObjectHierarchy(); + PO_hlistener myPOHL = new PO_hlistener(); + PathObjectHierarchyEvent event = PathObjectHierarchyEvent.createObjectAddedEvent(new Object(), myPH, new PathAnnotationObject(), new PathAnnotationObject()); + PathRootObject myPRO = new PathRootObject(); + ROI my_PR1 = ROIs.createRectangleROI(10, 10, 2, 2, ImagePlane.getDefaultPlane()); + ROI my_PR2 = ROIs.createRectangleROI(10, 10, 1, 1, ImagePlane.getDefaultPlane()); + ROI my_PR3 = ROIs.createRectangleROI(30, 30, 1, 1, ImagePlane.getDefaultPlane()); + PathObject myChild1PAO = PathObjects.createAnnotationObject(my_PR1); + PathObject myChild2PAO = PathObjects.createAnnotationObject(my_PR2); + PathObject myChild3PAO = PathObjects.createAnnotationObject(my_PR3); + ImageRegion myIR = ImageRegion.createInstance(25, 25, 10, 10, 0, 0); // set to contain child3 - other values can be used to test negative + + @Test + public void test_PathHierarchy() { + + // Created new PH with listeners + myPH.addListener(myPOHL); + assertTrue(myPH.isEmpty()); + + // Firing direct event + myPH.fireEvent(event); + assertEquals(myPOHL.getFiredState(), 1); // event(ADDED) fired + myPOHL.setFiredState(0); + + // Creating structure of POs + myChild1PAO.addChildObject(myChild3PAO); + myPRO.addChildObject(myChild1PAO); + assertEquals(myPRO.nChildObjects(), 1); + assertEquals(myChild1PAO.getParent(), myPRO); + + // Firing indirect events (adding/removing from hierarchy) + // Adding one PO with a child (so 2) + myPH.addObject(myChild1PAO); + Collection POAL1 = new ArrayList<>(); + POAL1 = myPH.getObjects(POAL1, PathAnnotationObject.class); + assertEquals(POAL1.size(), 2); // 1 + child + assertEquals(myPH.getObjects(null, PathAnnotationObject.class), POAL1); + assertEquals(myChild1PAO.getParent(), myPH.getRootObject()); // child1 has been added to the PH - the PH root is the parent of child1 + assertEquals(myChild3PAO.getParent(), myChild1PAO); // child3 is added to the PH through the addition of child1 (its parent) + + assertEquals(myPOHL.getFiredState(), 1); // event(ADDED) fired + myPOHL.setFiredState(0); + + // Adding one PO without a child (so 1) - this PO, however, is fully contained within Child1 + myPH.insertPathObject(myChild2PAO, true); + Collection POAL2 = new ArrayList<>(); + POAL2 = myPH.getObjects(POAL2, PathAnnotationObject.class); + assertEquals(POAL2.size(), 3); // 2 + 1 + assertEquals(myPH.getObjects(null, PathAnnotationObject.class), POAL2); + assertEquals(myChild2PAO.nChildObjects(), 0); // child2 doesn't have any children (child3 is only a child to child1 through the PO lineage) + //assertEquals(myChild2PAO.getParent(), myPH.getRootObject()); // child2's parent is not the root of the PH + assertEquals(myChild2PAO.getParent(), myChild1PAO); // child2's parent is child1 (as child2 is contained within child1) + + Collection POAL3 = new ArrayList<>(); + POAL3 = PathObjectTools.getDescendantObjects(myChild1PAO, POAL3, PathAnnotationObject.class); + assertEquals(POAL3.size(), 2); // child1 has now 2 descendants - one on the PH lineage (child2) and one on the PO lineage (child3) + assertEquals(PathObjectTools.getDescendantObjects(myChild1PAO, null, PathAnnotationObject.class), POAL3); + + List POAL4 = new ArrayList<>(); + POAL4 = myPH.getFlattenedObjectList(POAL4); + assertEquals(POAL4.size(), 4); // all nodes (including parent node from hierarchy) + assertEquals(myPH.getFlattenedObjectList(null), POAL4); + + assertEquals(myPH.nObjects(), 3); // descendants - TODO: name may be a bit misleading??? + +// // Remove one PO without a child (so 2 left) +// myPH.removeObject(myChild2PAO, true); // no children, so a changed structure event will fire +// List POAL5 = new ArrayList<>(); +// POAL5 = myPH.getObjects(POAL5, PathAnnotationObject.class); +// assertEquals(POAL5.size(), 2); // 3 - 1 +// assertEquals(myPH.getObjects(null, PathAnnotationObject.class), POAL5); +// +// assertEquals(myPOHL.getFiredState(), 3); // event(CHANGED STRUCTURE) fired +// myPOHL.setFiredState(0); + + // Remove one PO without a child (so 2 left) + myPH.removeObject(myChild2PAO, true); // no children, so a removed event will fire + Collection POAL5 = new ArrayList<>(); + POAL5 = myPH.getObjects(POAL5, PathAnnotationObject.class); + assertEquals(POAL5.size(), 2); // 3 - 1 + assertEquals(myPH.getObjects(null, PathAnnotationObject.class), POAL5); + + assertEquals(myPOHL.getFiredState(), 2); // event(CHANGED REMOVED) fired + myPOHL.setFiredState(0); + + // Remove one PO with a child but keep child (so 1 left) + myPH.removeObject(myChild1PAO, true); + Collection POAL6 = new ArrayList<>(); + POAL6 = myPH.getObjects(POAL6, PathAnnotationObject.class); + assertEquals(POAL6.size(), 1); // 2 - 1 + assertEquals(myPH.getObjects(null, PathAnnotationObject.class), POAL6); + + assertEquals(myPOHL.getFiredState(), 2); // event(REMOVED) fired + myPOHL.setFiredState(0); + + // Check how many objects present in the region indicated below + Collection POAL7 = new ArrayList<>(); + POAL7 = myPH.getObjectsForRegion(PathAnnotationObject.class, myIR, POAL7); + assertEquals(POAL7.size(), 1); // since there's only 1 object left (child3), this checks whether it falls within the region + assertEquals(myPH.getObjects(null, PathAnnotationObject.class), POAL7); + + // Finalise by removing all items left + assertEquals(myPH.nObjects(), 1); + myPH.clearAll(); + assertEquals(myPH.nObjects(), 0); + + } + + /** + * Introduced in v0.2.1 to cope with fixed behavior with TMA cores. + * This test failed in v0.2.0, however {@link #test_resolveHierarchy()} already passed. + */ + @Test + public void test_resolveHierarchyWithTMA() { + + var hierarchy = new PathObjectHierarchy(); + + var core = PathObjects.createTMACoreObject(1000, 1000, 1000, false); + var tmaGrid = DefaultTMAGrid.create(Collections.singletonList(core), 1); + hierarchy.setTMAGrid(tmaGrid); + + assertEquals(hierarchy.nObjects(), 1); + + var annotationInCore = PathObjects.createAnnotationObject( + ROIs.createRectangleROI(900, 900, 200, 200, ImagePlane.getDefaultPlane()) + ); + annotationInCore.setName("Annotation in core"); + hierarchy.addObject(annotationInCore); + + var annotationInAnnotation = PathObjects.createAnnotationObject( + ROIs.createRectangleROI(950, 950, 100, 100, ImagePlane.getDefaultPlane()) + ); + annotationInAnnotation.setName("Annotation in " + annotationInCore.getName()); + hierarchy.addObject(annotationInAnnotation); + + var annotationOutsideCore = PathObjects.createAnnotationObject( + ROIs.createRectangleROI(2000, 2000, 100, 100, ImagePlane.getDefaultPlane()) + ); + annotationOutsideCore.setName("Annotation outside core"); + hierarchy.addObject(annotationOutsideCore); + + // An annotation containing the core should *not* become a parent of the core, + // since cores must always be directly below the root + var annotationContainingCore = PathObjects.createAnnotationObject( + ROIs.createRectangleROI(400, 400-100, 1200, 1200, ImagePlane.getDefaultPlane()) + ); + annotationContainingCore.setName("Annotation containing core"); + hierarchy.addObject(annotationContainingCore); + + // Sanity check to ensure that our rectangle does indeed contain the core + assertTrue(annotationContainingCore.getROI().getGeometry().contains(core.getROI().getGeometry())); + assertFalse(core.getROI().getGeometry().contains(annotationContainingCore.getROI().getGeometry())); + + // Add a detection at the centroid of each annotation + var mapDetections = new LinkedHashMap(); + for (var annotation : Arrays.asList(annotationInAnnotation, annotationOutsideCore)) { + double radius = 2; + var roi = annotation.getROI(); + var detection = PathObjects.createDetectionObject( + ROIs.createEllipseROI( + roi.getCentroidX()-radius, + roi.getCentroidX()-radius, + radius*2, radius*2, roi.getImagePlane()) + ); + detection.setName("Detection in " + annotation.getName()); + mapDetections.put(detection, annotation); + hierarchy.addObject(detection); + } + // Add another detection outside of everything + var detectionOutside = PathObjects.createDetectionObject( + ROIs.createRectangleROI(4000, 4000, 5, 5, ImagePlane.getDefaultPlane()) + ); + detectionOutside.setName("Detection outside everything"); + hierarchy.addObject(detectionOutside); + mapDetections.put(detectionOutside, hierarchy.getRootObject()); + + // Add another detection inside the core but outside of any annotations + var detectionInCore = PathObjects.createDetectionObject( + ROIs.createRectangleROI(1000, 510, 1, 1, ImagePlane.getDefaultPlane()) +// ROIs.createPointsROI(1000, 510, ImagePlane.getDefaultPlane()) + ); +// System.err.println("Centroid: " + detectionInCore.getROI().getCentroidX() + ", " + detectionInCore.getROI().getCentroidY()); + detectionInCore.setName("Detection in core only"); + hierarchy.addObject(detectionInCore); + mapDetections.put(detectionInCore, core); + + + // Check hierarchy size + assertEquals(hierarchy.nObjects(), 5 + mapDetections.size()); + + // Check level before resolving the hierarchy + assertEquals(core.getLevel(), 1); + assertEquals(annotationContainingCore.getLevel(), 1); + assertEquals(annotationInCore.getLevel(), 1); + assertEquals(annotationInAnnotation.getLevel(), 1); + assertEquals(annotationOutsideCore.getLevel(), 1); + + assertEquals(core.getParent(), hierarchy.getRootObject()); + assertEquals(annotationContainingCore.getParent(), hierarchy.getRootObject()); + assertEquals(annotationInCore.getParent(), hierarchy.getRootObject()); + assertEquals(annotationInAnnotation.getParent(), hierarchy.getRootObject()); + assertEquals(annotationOutsideCore.getParent(), hierarchy.getRootObject()); + + for (var detection : mapDetections.keySet()) + assertEquals(detection.getParent(), hierarchy.getRootObject()); + + hierarchy.resolveHierarchy(); + + // Check level after resolving the hierarchy + assertEquals(core.getLevel(), 1); + assertEquals(annotationInCore.getLevel(), 2); + assertEquals(annotationInAnnotation.getLevel(), 3); + assertEquals(annotationOutsideCore.getLevel(), 1); + + assertEquals(core.getParent(), hierarchy.getRootObject()); + assertEquals(annotationContainingCore.getParent(), hierarchy.getRootObject()); + assertEquals(annotationInCore.getParent(), core); + assertEquals(annotationInAnnotation.getParent(), annotationInCore); + assertEquals(annotationOutsideCore.getParent(), hierarchy.getRootObject()); + + for (var entry : mapDetections.entrySet()) { +// System.err.println(entry.getKey() + ": " + entry.getKey().getParent() + ", " + entry.getValue()); + assertEquals(entry.getKey().getParent(), entry.getValue()); + } + + } + + + /** + * Based on {@link #test_resolveHierarchyWithTMA()}, without the TMA core involved. + * This test already passed in v0.2.0. + */ + @Test + public void test_resolveHierarchy() { + + var hierarchy = new PathObjectHierarchy(); + + var annotationInCore = PathObjects.createAnnotationObject( + ROIs.createRectangleROI(900, 900, 200, 200, ImagePlane.getDefaultPlane()) + ); + annotationInCore.setName("Annotation in core"); + hierarchy.addObject(annotationInCore); + + var annotationInAnnotation = PathObjects.createAnnotationObject( + ROIs.createRectangleROI(950, 950, 100, 100, ImagePlane.getDefaultPlane()) + ); + annotationInAnnotation.setName("Annotation in " + annotationInCore.getName()); + hierarchy.addObject(annotationInAnnotation); + + var annotationOutsideCore = PathObjects.createAnnotationObject( + ROIs.createRectangleROI(2000, 2000, 100, 100, ImagePlane.getDefaultPlane()) + ); + annotationOutsideCore.setName("Annotation outside core"); + hierarchy.addObject(annotationOutsideCore); + + // This was previously containing the entire TMA core - without the core, it acts as a stand-in + var annotationContainingCore = PathObjects.createAnnotationObject( + ROIs.createRectangleROI(400, 400-100, 1200, 1200, ImagePlane.getDefaultPlane()) + ); + annotationContainingCore.setName("Annotation stand-in for core"); + hierarchy.addObject(annotationContainingCore); + + // Add a detection at the centroid of each annotation + var mapDetections = new LinkedHashMap(); + for (var annotation : Arrays.asList(annotationInAnnotation, annotationOutsideCore)) { + double radius = 2; + var roi = annotation.getROI(); + var detection = PathObjects.createDetectionObject( + ROIs.createEllipseROI( + roi.getCentroidX()-radius, + roi.getCentroidX()-radius, + radius*2, radius*2, roi.getImagePlane()) + ); + detection.setName("Detection in " + annotation.getName()); + mapDetections.put(detection, annotation); + hierarchy.addObject(detection); + } + // Add another detection outside of everything + var detectionOutside = PathObjects.createDetectionObject( + ROIs.createRectangleROI(4000, 4000, 5, 5, ImagePlane.getDefaultPlane()) + ); + detectionOutside.setName("Detection outside everything"); + hierarchy.addObject(detectionOutside); + mapDetections.put(detectionOutside, hierarchy.getRootObject()); + + // Add another detection inside the core but outside of any annotations + var detectionInCore = PathObjects.createDetectionObject( + ROIs.createRectangleROI(1000, 510, 1, 1, ImagePlane.getDefaultPlane()) +// ROIs.createPointsROI(1000, 510, ImagePlane.getDefaultPlane()) + ); +// System.err.println("Centroid: " + detectionInCore.getROI().getCentroidX() + ", " + detectionInCore.getROI().getCentroidY()); + detectionInCore.setName("Detection in core only"); + hierarchy.addObject(detectionInCore); + + + // Check hierarchy size + assertEquals(hierarchy.nObjects(), 5 + mapDetections.size()); + + // Check level before resolving the hierarchy + assertEquals(annotationContainingCore.getLevel(), 1); + assertEquals(annotationInCore.getLevel(), 1); + assertEquals(annotationInAnnotation.getLevel(), 1); + assertEquals(annotationOutsideCore.getLevel(), 1); + + assertEquals(annotationContainingCore.getParent(), hierarchy.getRootObject()); + assertEquals(annotationInCore.getParent(), hierarchy.getRootObject()); + assertEquals(annotationInAnnotation.getParent(), hierarchy.getRootObject()); + assertEquals(annotationOutsideCore.getParent(), hierarchy.getRootObject()); + + for (var detection : mapDetections.keySet()) + assertEquals(detection.getParent(), hierarchy.getRootObject()); + + hierarchy.resolveHierarchy(); + + // Check level after resolving the hierarchy + assertEquals(annotationInCore.getLevel(), 2); + assertEquals(annotationInAnnotation.getLevel(), 3); + assertEquals(annotationOutsideCore.getLevel(), 1); + + assertEquals(annotationContainingCore.getParent(), hierarchy.getRootObject()); + assertEquals(annotationInCore.getParent(), annotationContainingCore); + assertEquals(annotationInAnnotation.getParent(), annotationInCore); + assertEquals(annotationOutsideCore.getParent(), hierarchy.getRootObject()); + + for (var entry : mapDetections.entrySet()) { + assertEquals(entry.getKey().getParent(), entry.getValue()); + } + + } + + +} + +// Helper classes for testing + +class PO_hlistener implements PathObjectHierarchyListener { + private int firedState = 0; + + public int getFiredState() { + return firedState; + } + + public void setFiredState(int state) { + this.firedState = state; + } + + @Override + public void hierarchyChanged(PathObjectHierarchyEvent event) { + if (event.getEventType() == PathObjectHierarchyEvent.HierarchyEventType.ADDED) + //System.out.println("Added!"); + this.firedState = 1; + else if (event.getEventType() == PathObjectHierarchyEvent.HierarchyEventType.REMOVED) + //System.out.println("Removed!"); + this.firedState = 2; + else if (event.getEventType() == PathObjectHierarchyEvent.HierarchyEventType.OTHER_STRUCTURE_CHANGE) + //System.out.println("Other!"); + this.firedState = 3; + } + +} + From 37ecc97ed92d43636127098fe9c36be86dd78f28 Mon Sep 17 00:00:00 2001 From: petebankhead Date: Mon, 29 Jul 2024 13:41:09 +0100 Subject: [PATCH 124/866] Don't attempt to transform missing nuclei --- .../src/main/java/qupath/lib/objects/PathObjectTools.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/qupath-core/src/main/java/qupath/lib/objects/PathObjectTools.java b/qupath-core/src/main/java/qupath/lib/objects/PathObjectTools.java index d74ef2e8c..0011dafee 100644 --- a/qupath-core/src/main/java/qupath/lib/objects/PathObjectTools.java +++ b/qupath-core/src/main/java/qupath/lib/objects/PathObjectTools.java @@ -1476,7 +1476,8 @@ private static PathObject transformObjectImpl(PathObject pathObject, Function Date: Mon, 29 Jul 2024 14:19:50 +0100 Subject: [PATCH 125/866] Added LinearCombinationChannel --- .../lib/images/servers/ColorTransforms.java | 195 ++++++++++++++++-- 1 file changed, 175 insertions(+), 20 deletions(-) diff --git a/qupath-core/src/main/java/qupath/lib/images/servers/ColorTransforms.java b/qupath-core/src/main/java/qupath/lib/images/servers/ColorTransforms.java index 61f37daf3..365ef1f2c 100644 --- a/qupath-core/src/main/java/qupath/lib/images/servers/ColorTransforms.java +++ b/qupath-core/src/main/java/qupath/lib/images/servers/ColorTransforms.java @@ -24,13 +24,19 @@ import java.awt.image.BufferedImage; import java.io.IOException; import java.util.Arrays; +import java.util.List; +import java.util.Map; import java.util.Objects; +import java.util.stream.Collectors; +import java.util.stream.IntStream; import com.google.gson.Gson; import com.google.gson.GsonBuilder; +import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.Strictness; import com.google.gson.TypeAdapter; +import com.google.gson.reflect.TypeToken; import com.google.gson.stream.JsonReader; import com.google.gson.stream.JsonWriter; @@ -48,9 +54,13 @@ public class ColorTransforms { /** - * Interface defining a color transform that can extract a float values from a BufferedImage. + * Interface defining a color transform that can extract a float value from a BufferedImage. *

    * The simplest example of this is to extract a single channel (band) from an image. + *

    + * Note that only implementations of this interface present in this file will be correctly + * serialized/deserialized into JSON, and not custom implementations. As such, some features + * of QuPath (such as saving a ColorTransform in a project) won't work for custom implementations. */ public interface ColorTransform { @@ -77,7 +87,6 @@ public interface ColorTransform { * Get a displayable name for the transform. Can be null */ String getName(); - } /** @@ -95,28 +104,36 @@ public void write(JsonWriter out, ColorTransform value) throws IOException { @Override public ColorTransform read(JsonReader in) throws IOException { JsonObject obj = gson.fromJson(in, JsonObject.class); - if (obj.has("channel")) + + if (obj.has("channel")) { return new ExtractChannel(obj.get("channel").getAsInt()); - if (obj.has("channelName")) + } else if (obj.has("channelName")) { return new ExtractChannelByName(obj.get("channelName").getAsString()); - if (obj.has("stains")) + } else if (obj.has("channelNamesToCoefficients") || obj.has("channelIndicesToCoefficients")) { + Map channelNamesToCoefficients = null; + List channelIndicesToCoefficients = null; + + if (obj.get("channelNamesToCoefficients") != null) { + channelNamesToCoefficients = gson.fromJson(obj.get("channelNamesToCoefficients").getAsString(), new TypeToken>() {}.getType()); + } + if (obj.get("channelIndicesToCoefficients") != null) { + channelIndicesToCoefficients = obj.get("channelIndicesToCoefficients").getAsJsonArray().asList().stream().map(JsonElement::getAsFloat).toList(); + } + + return new LinearCombinationChannel(channelNamesToCoefficients, channelIndicesToCoefficients); + } else if (obj.has("stains")) { return new ColorDeconvolvedChannel( GsonTools.getInstance().fromJson(obj.get("stains"), ColorDeconvolutionStains.class), obj.get("stainNumber").getAsInt()); - if (obj.has("combineType")) { - String combine = obj.get("combineType").getAsString(); - switch (CombineType.valueOf(combine)) { - case MAXIMUM: - return new MaxChannels(); - case MEAN: - return new AverageChannels(); - case MINIMUM: - return new MinChannels(); - default: - break; - } + } else if (obj.has("combineType")) { + return switch (CombineType.valueOf(obj.get("combineType").getAsString())) { + case MEAN -> new AverageChannels(); + case MAXIMUM -> new MaxChannels(); + case MINIMUM -> new MinChannels(); + }; + } else { + throw new IOException("Unknown ColorTransform " + obj); } - throw new IOException("Unknown ColorTransform " + obj); } } @@ -140,6 +157,30 @@ public static ColorTransform createChannelExtractor(int channel) { public static ColorTransform createChannelExtractor(String channelName) { return new ExtractChannelByName(channelName); } + + /** + * Create a ColorTransform that apply a linear combination to the channels. + * For example, calling this function with the Map {"c1": 0.5, "c3": 0.2} + * will create a new channel with values "0.5*c1 + 0.2*c3". + * + * @param coefficients the channel names mapped to coefficients + * @return a ColorTransform computing the provided linear combination + */ + public static ColorTransform createLinearCombinationChannelTransform(Map coefficients) { + return new LinearCombinationChannel(coefficients); + } + + /** + * Create a ColorTransform that apply a linear combination to the channels. + * For example, calling this function with the list [0.5, 0.9, 0.2] + * will create a new channel with values "0.5*channel1 + 0.9*channel2 + 0.2*channel3". + * + * @param coefficients the list of coefficients to apply to each channel + * @return a ColorTransform computing the provided linear combination + */ + public static ColorTransform createLinearCombinationChannelTransform(List coefficients) { + return new LinearCombinationChannel(coefficients); + } /** * Create a ColorTransform that calculates the mean of all channels. @@ -295,6 +336,116 @@ private int getChannelNumber(ImageServer server) { } } + static class LinearCombinationChannel implements ColorTransform { + + private final Map channelNamesToCoefficients; + private final List channelIndicesToCoefficients; + + private LinearCombinationChannel(Map channelNamesToCoefficients, List channelIndicesToCoefficients) { + this.channelNamesToCoefficients = channelNamesToCoefficients; + this.channelIndicesToCoefficients = channelIndicesToCoefficients; + } + + public LinearCombinationChannel(Map coefficients) { + this(coefficients, null); + } + + public LinearCombinationChannel(List coefficients) { + this(null, coefficients); + } + + @Override + public float[] extractChannel(ImageServer server, BufferedImage img, float[] pixels) { + pixels = ensureArrayLength(img, pixels); + int w = img.getWidth(); + int h = img.getHeight(); + var raster = img.getRaster(); + Map coefficients = getCoefficients(server); + + for (int y = 0; y < h; y++) { + for (int x = 0; x < w; x++) { + double[] vals = raster.getPixel(x, y, (double[]) null); + + pixels[y*w+x] = (float) coefficients.entrySet().stream() + .mapToDouble(entry -> entry.getValue() * vals[entry.getKey()]) + .sum(); + } + } + return pixels; + } + + @Override + public String getName() { + if (channelNamesToCoefficients != null) { + return channelNamesToCoefficients.entrySet().stream() + .map(entry -> entry.getValue() + "*" + entry.getKey()) + .collect(Collectors.joining(" + ")); + } else if (channelIndicesToCoefficients != null) { + return IntStream.range(0, channelIndicesToCoefficients.size()) + .mapToObj(i -> channelIndicesToCoefficients.get(i) + "*channel" + i) + .collect(Collectors.joining(" + ")); + } else { + return "Linear combination channels"; + } + } + + @Override + public boolean supportsImage(ImageServer server) { + if (channelNamesToCoefficients != null) { + return server.getMetadata().getChannels().stream() + .map(ImageChannel::getName) + .collect(Collectors.toSet()) + .containsAll(channelNamesToCoefficients.keySet()); + } else if (channelIndicesToCoefficients != null) { + return server.nChannels() >= channelIndicesToCoefficients.size(); + } else { + return false; + } + } + + @Override + public String toString() { + return getName(); + } + + @Override + public int hashCode() { + final int prime = 31; + int result = 1; + result = prime * result + ((channelNamesToCoefficients == null) ? 0 : channelNamesToCoefficients.hashCode()); + result = prime * result + ((channelIndicesToCoefficients == null) ? 0 : channelIndicesToCoefficients.hashCode()); + return result; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) + return true; + if (!(obj instanceof LinearCombinationChannel linearCombinationChannel)) + return false; + return Objects.equals(channelNamesToCoefficients, linearCombinationChannel.channelNamesToCoefficients) && + Objects.equals(channelIndicesToCoefficients, linearCombinationChannel.channelIndicesToCoefficients); + } + + private Map getCoefficients(ImageServer server) { + List channelNames = server.getMetadata().getChannels().stream().map(ImageChannel::getName).toList(); + + if (channelNamesToCoefficients != null) { + return channelNamesToCoefficients.entrySet().stream() + .collect(Collectors.toMap( + entry -> channelNames.indexOf(entry.getKey()), + Map.Entry::getValue + )); + } else if (channelIndicesToCoefficients != null) { + return IntStream.range(0, channelIndicesToCoefficients.size()) + .boxed() + .collect(Collectors.toMap(i -> i, channelIndicesToCoefficients::get)); + } else { + return Map.of(); + } + } + } + abstract static class CombineChannels implements ColorTransform { @Override @@ -307,7 +458,7 @@ public float[] extractChannel(ImageServer server, BufferedImage i for (int y = 0; y < h; y++) { for (int x = 0; x < w; x++) { vals = raster.getPixel(x, y, vals); - pixels[y*w+x] = (float)computeValue(vals); + pixels[y*w+x] = (float) computeValue(vals); } } return pixels; @@ -480,7 +631,11 @@ public boolean equals(Object obj) { /** * Store the {@link CombineType}. This is really to add deserialization from JSON. */ - private enum CombineType {MEAN, MINIMUM, MAXIMUM} + private enum CombineType { + MEAN, + MINIMUM, + MAXIMUM + } private static float[] ensureArrayLength(BufferedImage img, float[] pixels) { int n = img.getWidth() * img.getHeight(); From 565665d87b7c6bb0ede0517c5b23744c5eb87ae2 Mon Sep 17 00:00:00 2001 From: lleplat Date: Mon, 29 Jul 2024 17:24:29 +0100 Subject: [PATCH 126/866] Started tests --- .../lib/images/servers/ColorTransforms.java | 8 +- .../images/servers/TestColorTransforms.java | 138 ++++++++++++++++++ 2 files changed, 144 insertions(+), 2 deletions(-) create mode 100644 qupath-core/src/test/java/qupath/lib/images/servers/TestColorTransforms.java diff --git a/qupath-core/src/main/java/qupath/lib/images/servers/ColorTransforms.java b/qupath-core/src/main/java/qupath/lib/images/servers/ColorTransforms.java index 365ef1f2c..2a8043506 100644 --- a/qupath-core/src/main/java/qupath/lib/images/servers/ColorTransforms.java +++ b/qupath-core/src/main/java/qupath/lib/images/servers/ColorTransforms.java @@ -52,6 +52,10 @@ * @author Pete Bankhead */ public class ColorTransforms { + + private ColorTransforms() { + throw new AssertionError("This class is not instantiable."); + } /** * Interface defining a color transform that can extract a float value from a BufferedImage. @@ -172,8 +176,8 @@ public static ColorTransform createLinearCombinationChannelTransform(Map sampleServer = new SampleImageServer(); + int channelIndex = sampleServer.nChannels() - 1; + ColorTransforms.ColorTransform channelExtractor = ColorTransforms.createChannelExtractor(channelIndex); + + boolean supported = channelExtractor.supportsImage(sampleServer); + + Assertions.assertTrue(supported); + + sampleServer.close(); + } + + @Test + void Check_Channel_Index_Extractor_Unsupported() throws Exception { + ImageServer sampleServer = new SampleImageServer(); + int channelIndex = sampleServer.nChannels() + 1; + ColorTransforms.ColorTransform channelExtractor = ColorTransforms.createChannelExtractor(channelIndex); + + boolean supported = channelExtractor.supportsImage(sampleServer); + + Assertions.assertFalse(supported); + + sampleServer.close(); + } + + @Test + void Check_Channel_Index_Extractor() throws Exception { + ImageServer sampleServer = new SampleImageServer(); + int channelIndex = sampleServer.nChannels() - 1; + ColorTransforms.ColorTransform channelExtractor = ColorTransforms.createChannelExtractor(channelIndex); + BufferedImage image = sampleServer.readRegion(1, 0, 0, 10, 10); + + float[] pixels = channelExtractor.extractChannel(sampleServer, image, null); + + sampleServer.close(); + } + + private static class SampleImageServer extends AbstractImageServer { + + public SampleImageServer() { + super(BufferedImage.class); + } + + @Override + protected ImageServerBuilder.ServerBuilder createServerBuilder() { + return null; + } + + @Override + protected String createID() { + return getClass().getName() + ": " + getURIs(); + } + + @Override + public Collection getURIs() { + return List.of(URI.create("")); + } + + @Override + public String getServerType() { + return "Sample server"; + } + + @Override + public ImageServerMetadata getOriginalMetadata() { + return new ImageServerMetadata.Builder() + .width(50) + .height(25) + .pixelType(PixelType.FLOAT64) + .channels(List.of( + ImageChannel.getInstance("c1", 1), + ImageChannel.getInstance("c2", 2), + ImageChannel.getInstance("c3", 3), + ImageChannel.getInstance("c4", 4), + ImageChannel.getInstance("c5", 5) + )) + .name("name") + .build(); + } + + @Override + public BufferedImage readRegion(RegionRequest request) { + DataBuffer dataBuffer = createDataBuffer(request); + + return new BufferedImage( + ColorModelFactory.createColorModel(getMetadata().getPixelType(), getMetadata().getChannels()), + WritableRaster.createWritableRaster( + new BandedSampleModel(dataBuffer.getDataType(), request.getWidth(), request.getHeight(), nChannels()), + dataBuffer, + null + ), + false, + null + ); + } + + private DataBuffer createDataBuffer(RegionRequest request) { + double[][] array = new double[nChannels()][]; + + for (int c = 0; c < array.length; c++) { + array[c] = getPixels(request, c); + } + + return new DataBufferDouble(array, request.getWidth() * request.getHeight() / 8); + } + + private double[] getPixels(RegionRequest request, int channel) { + double[] pixels = new double[request.getWidth() * request.getHeight()]; + + for (int y=0; y Date: Tue, 30 Jul 2024 08:41:42 +0100 Subject: [PATCH 127/866] Fix #1564 --- CHANGELOG.md | 1 + .../main/java/qupath/opencv/ops/ImageOps.java | 3 ++- .../java/qupath/opencv/ops/TestImageOps.java | 18 +++++++++++++++++- 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index da4e9a0d3..b3a0671bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ This is a *work in progress* for the next major release. * Black row or column appears on some downsampled images (https://github.com/qupath/qupath/issues/1527) * Full image annotations can be shifted accidentally outside the image bounds (https://github.com/qupath/qupath/issues/1533) * Search distance when selecting points in the viewer is too low (https://github.com/qupath/qupath/issues/1552) +* `ImageOps.Core.replace()` does not work as expected (https://github.com/qupath/qupath/issues/1564) ### Dependency updates * Bio-Formats 7.3.0 diff --git a/qupath-core-processing/src/main/java/qupath/opencv/ops/ImageOps.java b/qupath-core-processing/src/main/java/qupath/opencv/ops/ImageOps.java index 0b2e792ec..4a33ce377 100644 --- a/qupath-core-processing/src/main/java/qupath/opencv/ops/ImageOps.java +++ b/qupath-core-processing/src/main/java/qupath/opencv/ops/ImageOps.java @@ -2187,7 +2187,8 @@ static class ReplaceValueOp implements ImageOp { private double newValue; ReplaceValueOp(double originalValue, double newValue) { - this.originalValue = newValue; + this.originalValue = originalValue; + this.newValue = newValue; } @Override diff --git a/qupath-core-processing/src/test/java/qupath/opencv/ops/TestImageOps.java b/qupath-core-processing/src/test/java/qupath/opencv/ops/TestImageOps.java index e0eb7a999..d297877bc 100644 --- a/qupath-core-processing/src/test/java/qupath/opencv/ops/TestImageOps.java +++ b/qupath-core-processing/src/test/java/qupath/opencv/ops/TestImageOps.java @@ -192,7 +192,23 @@ public void testFilters() { } } } - + + @Test + public void testReplaceValues() { + try (var scope = new PointerScope()) { + float[] vals = new float[]{1f, 2f, 3f, 4f}; + var mat = new Mat(vals); + + var matSame = ImageOps.Core.replace(0, 10).apply(mat.clone()); + assertArrayEquals(vals, OpenCVTools.extractFloats(matSame)); + + var matOneToZero = ImageOps.Core.replace(1, 0).apply(mat.clone()); + assertArrayEquals(new float[]{0f, 2f, 3f, 4f}, OpenCVTools.extractFloats(matOneToZero)); + + var matTwoToOne = ImageOps.Core.replace(2, 1).apply(mat.clone()); + assertArrayEquals(new float[]{1f, 1f, 3f, 4f}, OpenCVTools.extractFloats(matTwoToOne)); + } + } @Test From d23bbd001a2b1a0254055495de5684ac9a9c0fc0 Mon Sep 17 00:00:00 2001 From: Pete Date: Tue, 30 Jul 2024 09:19:03 +0100 Subject: [PATCH 128/866] Add hierarchy getObject tests --- .../hierarchy/PathObjectHierarchy.java | 10 +- .../hierarchy/TestPathObjectHierarchy.java | 102 ++++++++++++++++++ 2 files changed, 107 insertions(+), 5 deletions(-) diff --git a/qupath-core/src/main/java/qupath/lib/objects/hierarchy/PathObjectHierarchy.java b/qupath-core/src/main/java/qupath/lib/objects/hierarchy/PathObjectHierarchy.java index 295811118..8e15e7463 100644 --- a/qupath-core/src/main/java/qupath/lib/objects/hierarchy/PathObjectHierarchy.java +++ b/qupath-core/src/main/java/qupath/lib/objects/hierarchy/PathObjectHierarchy.java @@ -858,7 +858,7 @@ public Collection getObjectsForROI(Class cls, /** * Get all objects for a ROI. * This uses the same rules as {@link #resolveHierarchy()}: annotations must be completely covered - * by the ROI, while detection need only have their centroid within the ROI. + * by the ROI, while detections need only have their centroid within the ROI. * @param roi * @return * @see #getAnnotationsForROI(ROI) @@ -884,7 +884,7 @@ public Collection getAnnotationsForROI(ROI roi) { } /** - * Get all the tile objects covered by the specified ROI. + * Get all the tile objects with centroids falling within the specified ROI. * Tile objects are a special subclass of detections. * @param roi the ROI to use for filtering * @return a collection of tile objects with centroids contained within the specified ROI @@ -899,7 +899,7 @@ public Collection getTilesForROI(ROI roi) { } /** - * Get all the cell objects covered by the specified ROI. + * Get all the cell objects with centroids falling within the specified ROI. * Cell objects are a special subclass of detections. * @param roi the ROI to use for filtering * @return a collection of cell objects with centroids contained within the specified ROI @@ -913,8 +913,8 @@ public Collection getTilesForROI(ROI roi) { } /** - * Get all the detection objects covered by the specified ROI - including subclasses of detections, - * such as cells and tiles. + * Get all the detection objects with centroids falling within the specified ROI - + * including subclasses of detections, such as cells and tiles. * @param roi the ROI to use for filtering * @return a collection of detection objects with centroids contained within the specified ROI * @see #getAllObjectsForROI(ROI) diff --git a/qupath-core/src/test/java/qupath/lib/objects/hierarchy/TestPathObjectHierarchy.java b/qupath-core/src/test/java/qupath/lib/objects/hierarchy/TestPathObjectHierarchy.java index 06dd7c2fb..33be256c2 100644 --- a/qupath-core/src/test/java/qupath/lib/objects/hierarchy/TestPathObjectHierarchy.java +++ b/qupath-core/src/test/java/qupath/lib/objects/hierarchy/TestPathObjectHierarchy.java @@ -1,11 +1,13 @@ package qupath.lib.objects.hierarchy; +import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; import qupath.lib.objects.PathObject; import qupath.lib.objects.PathObjectTools; import qupath.lib.objects.PathObjects; import qupath.lib.regions.ImagePlane; import qupath.lib.regions.ImageRegion; +import qupath.lib.regions.RegionRequest; import qupath.lib.roi.ROIs; import qupath.lib.roi.interfaces.ROI; @@ -15,6 +17,10 @@ import java.util.function.Function; import java.util.stream.Stream; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + public class TestPathObjectHierarchy { @Test @@ -55,6 +61,102 @@ public void testGetObjects() { hierarchy.addObjects(defaultPlaneObjects); hierarchy.addObjects(z1Objects); hierarchy.addObjects(t1Objects); + + var region1Smaller = ImageRegion.createInstance(region1.getX(), region1.getY(), region1.getWidth() - 1, region1.getHeight() - 1, region1.getZ(), region1.getT()); + + // ANNOTATIONS + + // Check we get rectangles and ellipses for the correct regions + assertTrue(hierarchy.getAnnotationsForROI(ROIs.createRectangleROI(region1)).stream().allMatch(PathObject::isAnnotation)); + assertFalse(hierarchy.getAnnotationsForROI(ROIs.createRectangleROI(region1)).stream().allMatch(PathObject::isDetection)); + assertEquals(2, hierarchy.getAnnotationsForROI(ROIs.createRectangleROI(region1)).size()); + assertEquals(1, hierarchy.getAnnotationsForROI(ROIs.createEllipseROI(region1)).size()); + assertEquals(2, hierarchy.getAnnotationsForROI(ROIs.createRectangleROI(region2)).size()); + assertEquals(1, hierarchy.getAnnotationsForROI(ROIs.createEllipseROI(region2)).size()); + + // Check we get no annotations for a smaller region (due to 'covers' rule) + assertEquals(0, hierarchy.getAnnotationsForROI(ROIs.createRectangleROI(region1Smaller)).size()); + + // Check behavior when z and t changes + assertEquals(2, hierarchy.getAnnotationsForROI(ROIs.createRectangleROI(region1).updatePlane(ImagePlane.getPlane(1, 0))).size()); + assertEquals(2, hierarchy.getAnnotationsForROI(ROIs.createRectangleROI(region1).updatePlane(ImagePlane.getPlane(0, 1))).size()); + assertEquals(0, hierarchy.getAnnotationsForROI(ROIs.createRectangleROI(region1).updatePlane(ImagePlane.getPlane(2, 0))).size()); + assertEquals(0, hierarchy.getAnnotationsForROI(ROIs.createRectangleROI(region1).updatePlane(ImagePlane.getPlane(0, 2))).size()); + + // CELLS + + // Check we get rectangles and ellipses for the correct regions + // Here, we expect both ellipses and rectangles when we use an ellipse - because of the 'centroid' rule for detections + assertEquals(2, hierarchy.getCellsForROI(ROIs.createRectangleROI(region1)).size()); + assertEquals(2, hierarchy.getCellsForROI(ROIs.createEllipseROI(region1)).size()); + assertEquals(2, hierarchy.getCellsForROI(ROIs.createRectangleROI(region2)).size()); + assertEquals(2, hierarchy.getCellsForROI(ROIs.createEllipseROI(region2)).size()); + + // Check we get no annotations for a smaller region (due to 'covers' rule) + assertEquals(2, hierarchy.getCellsForROI(ROIs.createRectangleROI(region1Smaller)).size()); + + // Check behavior when z and t changes + assertEquals(2, hierarchy.getCellsForROI(ROIs.createRectangleROI(region1).updatePlane(ImagePlane.getPlane(1, 0))).size()); + assertEquals(2, hierarchy.getCellsForROI(ROIs.createRectangleROI(region1).updatePlane(ImagePlane.getPlane(0, 1))).size()); + assertEquals(0, hierarchy.getCellsForROI(ROIs.createRectangleROI(region1).updatePlane(ImagePlane.getPlane(2, 0))).size()); + assertEquals(0, hierarchy.getCellsForROI(ROIs.createRectangleROI(region1).updatePlane(ImagePlane.getPlane(0, 2))).size()); + + // Check type + assertTrue(hierarchy.getCellsForROI(ROIs.createRectangleROI(region1)).stream().allMatch(PathObject::isCell)); + assertTrue(hierarchy.getCellsForROI(ROIs.createRectangleROI(region1)).stream().allMatch(PathObject::isDetection)); + assertFalse(hierarchy.getCellsForROI(ROIs.createRectangleROI(region1)).stream().allMatch(PathObject::isAnnotation)); + assertFalse(hierarchy.getCellsForROI(ROIs.createRectangleROI(region1)).stream().allMatch(PathObject::isTile)); + + // TILES + + // Check we get rectangles and ellipses for the correct regions + // Here, we expect both ellipses and rectangles when we use an ellipse - because of the 'centroid' rule for detections + assertTrue(hierarchy.getTilesForROI(ROIs.createRectangleROI(region1)).stream().allMatch(PathObject::isTile)); + assertEquals(2, hierarchy.getTilesForROI(ROIs.createRectangleROI(region1)).size()); + assertEquals(2, hierarchy.getTilesForROI(ROIs.createEllipseROI(region1)).size()); + assertEquals(2, hierarchy.getTilesForROI(ROIs.createRectangleROI(region2)).size()); + assertEquals(2, hierarchy.getTilesForROI(ROIs.createEllipseROI(region2)).size()); + + // Check we get no annotations for a smaller region (due to 'covers' rule) + assertEquals(2, hierarchy.getTilesForROI(ROIs.createRectangleROI(region1Smaller)).size()); + + // Check behavior when z and t changes + assertEquals(2, hierarchy.getTilesForROI(ROIs.createRectangleROI(region1).updatePlane(ImagePlane.getPlane(1, 0))).size()); + assertEquals(2, hierarchy.getTilesForROI(ROIs.createRectangleROI(region1).updatePlane(ImagePlane.getPlane(0, 1))).size()); + assertEquals(0, hierarchy.getTilesForROI(ROIs.createRectangleROI(region1).updatePlane(ImagePlane.getPlane(2, 0))).size()); + assertEquals(0, hierarchy.getTilesForROI(ROIs.createRectangleROI(region1).updatePlane(ImagePlane.getPlane(0, 2))).size()); + + // Check type + assertFalse(hierarchy.getTilesForROI(ROIs.createRectangleROI(region1)).stream().allMatch(PathObject::isCell)); + assertTrue(hierarchy.getTilesForROI(ROIs.createRectangleROI(region1)).stream().allMatch(PathObject::isDetection)); + assertFalse(hierarchy.getTilesForROI(ROIs.createRectangleROI(region1)).stream().allMatch(PathObject::isAnnotation)); + assertTrue(hierarchy.getTilesForROI(ROIs.createRectangleROI(region1)).stream().allMatch(PathObject::isTile)); + + + // ALL DETECTIONS + + // Check we get rectangles and ellipses for the correct regions + // Here, we expect both ellipses and rectangles when we use an ellipse - because of the 'centroid' rule for detections + // We also expect to receive all detections, regardless of type (i.e. including detections, cells and tiles) + assertEquals(6, hierarchy.getAllDetectionsForROI(ROIs.createRectangleROI(region1)).size()); + assertEquals(6, hierarchy.getAllDetectionsForROI(ROIs.createEllipseROI(region1)).size()); + assertEquals(6, hierarchy.getAllDetectionsForROI(ROIs.createRectangleROI(region2)).size()); + assertEquals(6, hierarchy.getAllDetectionsForROI(ROIs.createEllipseROI(region2)).size()); + + // Check we get no annotations for a smaller region (due to 'covers' rule) + assertEquals(6, hierarchy.getAllDetectionsForROI(ROIs.createRectangleROI(region1Smaller)).size()); + + // Check behavior when z and t changes + assertEquals(6, hierarchy.getAllDetectionsForROI(ROIs.createRectangleROI(region1).updatePlane(ImagePlane.getPlane(1, 0))).size()); + assertEquals(6, hierarchy.getAllDetectionsForROI(ROIs.createRectangleROI(region1).updatePlane(ImagePlane.getPlane(0, 1))).size()); + assertEquals(0, hierarchy.getAllDetectionsForROI(ROIs.createRectangleROI(region1).updatePlane(ImagePlane.getPlane(2, 0))).size()); + assertEquals(0, hierarchy.getAllDetectionsForROI(ROIs.createRectangleROI(region1).updatePlane(ImagePlane.getPlane(0, 2))).size()); + + // Check type + assertFalse(hierarchy.getAllDetectionsForROI(ROIs.createRectangleROI(region1)).stream().allMatch(PathObject::isCell)); + assertTrue(hierarchy.getAllDetectionsForROI(ROIs.createRectangleROI(region1)).stream().allMatch(PathObject::isDetection)); + assertFalse(hierarchy.getAllDetectionsForROI(ROIs.createRectangleROI(region1)).stream().allMatch(PathObject::isAnnotation)); + assertFalse(hierarchy.getAllDetectionsForROI(ROIs.createRectangleROI(region1)).stream().allMatch(PathObject::isTile)); } private static List createObjects(Collection regions, Function roiCreator, Function objectCreator) { From ec5569aed865e1d95af3a5f5076dc76707b9ac3c Mon Sep 17 00:00:00 2001 From: Alan O'Callaghan Date: Tue, 30 Jul 2024 13:46:38 +0100 Subject: [PATCH 129/866] Parse different URL variants --- .../qupath/lib/gui/ExtensionControlPane.java | 63 ++++++++++++++----- .../qupath/lib/gui/ExtensionControlPane.fxml | 15 +---- 2 files changed, 48 insertions(+), 30 deletions(-) diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/ExtensionControlPane.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/ExtensionControlPane.java index 65ca16d6c..3e0c627d6 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/ExtensionControlPane.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/ExtensionControlPane.java @@ -67,6 +67,7 @@ import qupath.lib.gui.localization.QuPathResources; import qupath.lib.gui.tools.GuiTools; import qupath.lib.gui.tools.IconFactory; +import qupath.lib.gui.tools.WebViews; import java.io.File; import java.io.FileOutputStream; @@ -126,9 +127,8 @@ public class ExtensionControlPane extends VBox { private HBox addHBox; @FXML - private TextField ownerTextArea; - @FXML - private TextField repoTextArea; + private TextField textArea; + @FXML private TitledPane inst; @@ -163,7 +163,7 @@ private void initialize() { openExtensionDirBtn.disableProperty().bind( UserDirectoryManager.getInstance().userDirectoryProperty().isNull()); downloadBtn.disableProperty().bind( - repoTextArea.textProperty().isEmpty().or(ownerTextArea.textProperty().isEmpty())); + textArea.textProperty().isEmpty()); downloadBtn.setGraphic(IconFactory.createNode(12, 12, IconFactory.PathIcons.DOWNLOAD)); @@ -180,15 +180,7 @@ private void initialize() { .toList()); extensionListView.setCellFactory(listView -> new ExtensionListCell(extensionManager, listView)); - ownerTextArea.addEventHandler(KeyEvent.KEY_RELEASED, e -> { - if (e.getCode() == KeyCode.ENTER) { - downloadExtension(); - } - if (e.getCode() == KeyCode.ESCAPE) { - cancelDownload(); - } - }); - repoTextArea.addEventHandler(KeyEvent.KEY_RELEASED, e -> { + textArea.addEventHandler(KeyEvent.KEY_RELEASED, e -> { if (e.getCode() == KeyCode.ENTER) { downloadExtension(); } @@ -371,7 +363,7 @@ private static Optional chooseRelease(GitHubProject.GitHubRepo re table.getColumns().add(colDate); TableColumn colBody = new TableColumn<>("Description"); - WebView webView = new WebView(); + WebView webView = WebViews.create(true); PopOver infoPopover = new PopOver(webView); colBody.setCellValueFactory(param -> { Button button = new Button(); @@ -547,7 +539,21 @@ private static void downloadURLToFile(URL downloadURL, File file) throws IOExcep @FXML private void downloadExtension() { - var repo = GitHubProject.GitHubRepo.create("", ownerTextArea.getText(), repoTextArea.getText()); + // todo: parse various types of input (https/git URL, owner/repo spec...) + var components = parseComponents(textArea.getText()); + if (!(components.length > 0)) { + Dialogs.showErrorNotification(QuPathResources.getString("ExtensionControlPane.unableToDownload"), QuPathResources.getString("ExtensionControlPane.unableToParseURL")); + return; + } + GitHubProject.GitHubRepo repo; + if (components.length == 1) { + repo = GitHubProject.GitHubRepo.create("", "qupath", components[0]); + } else if (components.length == 2) { + repo = GitHubProject.GitHubRepo.create("", components[0], components[1]); + } else { + Dialogs.showErrorNotification(QuPathResources.getString("ExtensionControlPane.unableToDownload"), QuPathResources.getString("ExtensionControlPane.unableToParseURL")); + return; + } try { askToDownload(repo); } catch (URISyntaxException | IOException | InterruptedException e) { @@ -557,10 +563,33 @@ private void downloadExtension() { cancelDownload(); } + + private String[] parseComponents(String text) { + // https://stackoverflow.com/questions/59081778/rules-for-special-characters-in-github-repository-name + String repoPart = "[\\w.-]+"; + // if it's just a repo name, then assume it's under qupath + if (text.matches("^" + repoPart + "$")) { + return new String[]{text}; + } + // if it's a something/somethingelse, then assume it's a github repo with owner/repo + if (text.matches("^" + repoPart + "/" + repoPart + "$")) { + return text.split("/"); + } + // last chance, it's a git https or git URL + if (text.matches("^(https://)?(www.)?github.com/" + repoPart + "/" + repoPart + "/?$") || + text.matches("^git@github.com/" + repoPart + "/" + repoPart + "/?$")) { + text = text.replace("https://", ""); + text = text.replace("www.", ""); + text = text.replace("git@", ""); + text = text.replace("github.com", ""); + return parseComponents(text); + } + return new String[0]; + } + @FXML private void cancelDownload() { - ownerTextArea.clear(); - repoTextArea.clear(); + // textArea.clear(); } @FXML diff --git a/qupath-gui-fx/src/main/resources/qupath/lib/gui/ExtensionControlPane.fxml b/qupath-gui-fx/src/main/resources/qupath/lib/gui/ExtensionControlPane.fxml index fb46ff4fe..92a980ea8 100644 --- a/qupath-gui-fx/src/main/resources/qupath/lib/gui/ExtensionControlPane.fxml +++ b/qupath-gui-fx/src/main/resources/qupath/lib/gui/ExtensionControlPane.fxml @@ -12,26 +12,15 @@ - + - - - - - - - - + From 881822d57148b8a285c4b55a07f4ab8c9157c07f Mon Sep 17 00:00:00 2001 From: Alan O'Callaghan Date: Tue, 30 Jul 2024 14:09:19 +0100 Subject: [PATCH 130/866] Externalise strings --- .../qupath/lib/gui/ExtensionControlPane.java | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/ExtensionControlPane.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/ExtensionControlPane.java index 3e0c627d6..a6267161e 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/ExtensionControlPane.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/ExtensionControlPane.java @@ -129,7 +129,6 @@ public class ExtensionControlPane extends VBox { @FXML private TextField textArea; - @FXML private TitledPane inst; @FXML @@ -152,7 +151,6 @@ private ExtensionControlPane() throws IOException { loader.load(); } - @FXML private void initialize() { this.setOnDragDropped(QuPathGUI.getInstance().getDefaultDragDropListener()); @@ -344,17 +342,17 @@ private static Optional chooseAsset(GitHubProject.GitHubRepo repo, Collec private static Optional chooseRelease(GitHubProject.GitHubRepo repo, Collection options) { TableView table = new TableView<>(); - TableColumn colTag = new TableColumn<>("Tag"); + TableColumn colTag = new TableColumn<>(QuPathResources.getString("ExtensionControlPane.tag")); colTag.setCellValueFactory(param -> new SimpleStringProperty(param.getValue().getTag())); colTag.setSortable(false); table.getColumns().add(colTag); - TableColumn colName = new TableColumn<>("Name"); + TableColumn colName = new TableColumn<>(QuPathResources.getString("ExtensionControlPane.name")); colName.setCellValueFactory(param -> new SimpleStringProperty(WordUtils.wrap(param.getValue().getName(), 40))); colName.setSortable(false); table.getColumns().add(colName); - TableColumn colDate = new TableColumn<>("Date published"); + TableColumn colDate = new TableColumn<>(QuPathResources.getString("ExtensionControlPane.datePublished")); colDate.setCellValueFactory(param -> { SimpleDateFormat formatter = new SimpleDateFormat("yyyy-MM-dd"); return new SimpleStringProperty(formatter.format(param.getValue().getDate())); @@ -362,7 +360,7 @@ private static Optional chooseRelease(GitHubProject.GitHubRepo re colDate.setSortable(false); table.getColumns().add(colDate); - TableColumn colBody = new TableColumn<>("Description"); + TableColumn colBody = new TableColumn<>(QuPathResources.getString("ExtensionControlPane.description")); WebView webView = WebViews.create(true); PopOver infoPopover = new PopOver(webView); colBody.setCellValueFactory(param -> { @@ -424,11 +422,10 @@ private static void parseMarkdown(GitHubRelease release, WebView webView, Button infoPopover.show(infoButton); } - private static Dialog createDialog(GitHubProject.GitHubRepo repo, Node control, String optionType, String parentType) { BorderPane bp = new BorderPane(); AnchorPane ap = new AnchorPane(); - Button githubButton = new Button("Browse GitHub repository"); + Button githubButton = new Button(QuPathResources.getString("ExtensionControlPane.browseGitHub")); ap.getChildren().add(githubButton); AnchorPane.setBottomAnchor(githubButton, 0.0); AnchorPane.setLeftAnchor(githubButton, 0.0); @@ -440,13 +437,13 @@ private static Dialog createDialog(GitHubProject.GitHubRepo repo, No HBox hboxText = new HBox(); hboxText.setPadding(new Insets(5)); hboxText.setAlignment(Pos.CENTER_LEFT); - hboxText.getChildren().add(new Label(String.format("More than one %s available for this %s.\nPlease choose one:", optionType, parentType))); + hboxText.getChildren().add(new Label(String.format(QuPathResources.getString("ExtensionControlPane.moreThanOneThing"), optionType, parentType))); bp.setCenter(hboxText); Dialog dialog = new Dialog<>(); dialog.getDialogPane().setContent(bp); dialog.getDialogPane().getButtonTypes().setAll(ButtonType.APPLY, ButtonType.CANCEL); - dialog.setTitle("Extension manager"); + dialog.setTitle(QuPathResources.getString("ExtensionControlPane")); return dialog; } @@ -539,7 +536,6 @@ private static void downloadURLToFile(URL downloadURL, File file) throws IOExcep @FXML private void downloadExtension() { - // todo: parse various types of input (https/git URL, owner/repo spec...) var components = parseComponents(textArea.getText()); if (!(components.length > 0)) { Dialogs.showErrorNotification(QuPathResources.getString("ExtensionControlPane.unableToDownload"), QuPathResources.getString("ExtensionControlPane.unableToParseURL")); @@ -849,5 +845,4 @@ private void removeExtension() { } } - } From 636ba7923bc43e363298d96bc2ebc6aa939e9817 Mon Sep 17 00:00:00 2001 From: Leo Leplat <60394504+Rylern@users.noreply.github.com> Date: Tue, 30 Jul 2024 15:14:48 +0100 Subject: [PATCH 131/866] Added tests --- .../lib/color/ColorDeconvolutionStains.java | 26 + .../lib/images/servers/ColorTransforms.java | 4 +- .../images/servers/TestColorTransforms.java | 506 +++++++++++++++++- 3 files changed, 523 insertions(+), 13 deletions(-) diff --git a/qupath-core/src/main/java/qupath/lib/color/ColorDeconvolutionStains.java b/qupath-core/src/main/java/qupath/lib/color/ColorDeconvolutionStains.java index dec32a495..440a57578 100644 --- a/qupath-core/src/main/java/qupath/lib/color/ColorDeconvolutionStains.java +++ b/qupath-core/src/main/java/qupath/lib/color/ColorDeconvolutionStains.java @@ -32,6 +32,7 @@ import java.util.Collection; import java.util.Locale; import java.util.Map; +import java.util.Objects; import java.util.StringTokenizer; import org.slf4j.Logger; @@ -420,6 +421,31 @@ public String toString() { return "Color deconvolution stains: " + stain1 + ", " + stain2 + ", " + stain3; } + public int hashCode() { + int hash = 7; + hash = 31 * hash + (int) maxRed; + hash = 31 * hash + (int) maxGreen; + hash = 31 * hash + (int) maxBlue; + hash = 31 * hash + (name == null ? 0 : name.hashCode()); + hash = 31 * hash + (stain1 == null ? 0 : stain1.hashCode()); + hash = 31 * hash + (stain2 == null ? 0 : stain2.hashCode()); + hash = 31 * hash + (stain3 == null ? 0 : stain3.hashCode()); + return hash; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) + return true; + if (!(obj instanceof ColorDeconvolutionStains colorDeconvolutionStains)) + return false; + return maxRed == colorDeconvolutionStains.maxRed && maxGreen == colorDeconvolutionStains.maxGreen && maxBlue == colorDeconvolutionStains.maxBlue && + Objects.equals(stain1, colorDeconvolutionStains.stain1) && + Objects.equals(stain2, colorDeconvolutionStains.stain2) && + Objects.equals(stain3, colorDeconvolutionStains.stain3) && + Objects.equals(name, colorDeconvolutionStains.name); + } + /** * Get a String representation of the stains and background. diff --git a/qupath-core/src/main/java/qupath/lib/images/servers/ColorTransforms.java b/qupath-core/src/main/java/qupath/lib/images/servers/ColorTransforms.java index 2a8043506..67ec0b9d6 100644 --- a/qupath-core/src/main/java/qupath/lib/images/servers/ColorTransforms.java +++ b/qupath-core/src/main/java/qupath/lib/images/servers/ColorTransforms.java @@ -118,7 +118,7 @@ public ColorTransform read(JsonReader in) throws IOException { List channelIndicesToCoefficients = null; if (obj.get("channelNamesToCoefficients") != null) { - channelNamesToCoefficients = gson.fromJson(obj.get("channelNamesToCoefficients").getAsString(), new TypeToken>() {}.getType()); + channelNamesToCoefficients = gson.fromJson(obj.get("channelNamesToCoefficients"), new TypeToken>() {}.getType()); } if (obj.get("channelIndicesToCoefficients") != null) { channelIndicesToCoefficients = obj.get("channelIndicesToCoefficients").getAsJsonArray().asList().stream().map(JsonElement::getAsFloat).toList(); @@ -209,7 +209,7 @@ public static ColorTransform createMinimumChannelTransform() { } /** - * Create a ColorTransform that applies color deconvolution. + * Create a ColorTransform that applies color deconvolution. It only works on RGB images. * * @param stains the stains (this will be 'fixed', and not adapted for each image) * @param stainNumber number of the stain (1, 2 or 3) diff --git a/qupath-core/src/test/java/qupath/lib/images/servers/TestColorTransforms.java b/qupath-core/src/test/java/qupath/lib/images/servers/TestColorTransforms.java index f998ce8a4..f47855882 100644 --- a/qupath-core/src/test/java/qupath/lib/images/servers/TestColorTransforms.java +++ b/qupath-core/src/test/java/qupath/lib/images/servers/TestColorTransforms.java @@ -1,7 +1,10 @@ package qupath.lib.images.servers; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; +import qupath.lib.color.ColorDeconvolutionStains; import qupath.lib.color.ColorModelFactory; import qupath.lib.regions.RegionRequest; @@ -13,6 +16,8 @@ import java.net.URI; import java.util.Collection; import java.util.List; +import java.util.Map; +import java.util.stream.Stream; public class TestColorTransforms { @@ -20,9 +25,9 @@ public class TestColorTransforms { void Check_Channel_Index_Extractor_Supported() throws Exception { ImageServer sampleServer = new SampleImageServer(); int channelIndex = sampleServer.nChannels() - 1; - ColorTransforms.ColorTransform channelExtractor = ColorTransforms.createChannelExtractor(channelIndex); + ColorTransforms.ColorTransform colorTransform = ColorTransforms.createChannelExtractor(channelIndex); - boolean supported = channelExtractor.supportsImage(sampleServer); + boolean supported = colorTransform.supportsImage(sampleServer); Assertions.assertTrue(supported); @@ -30,12 +35,12 @@ void Check_Channel_Index_Extractor_Supported() throws Exception { } @Test - void Check_Channel_Index_Extractor_Unsupported() throws Exception { + void Check_Channel_Index_Extractor_Not_Supported() throws Exception { ImageServer sampleServer = new SampleImageServer(); int channelIndex = sampleServer.nChannels() + 1; - ColorTransforms.ColorTransform channelExtractor = ColorTransforms.createChannelExtractor(channelIndex); + ColorTransforms.ColorTransform colorTransform = ColorTransforms.createChannelExtractor(channelIndex); - boolean supported = channelExtractor.supportsImage(sampleServer); + boolean supported = colorTransform.supportsImage(sampleServer); Assertions.assertFalse(supported); @@ -43,19 +48,457 @@ void Check_Channel_Index_Extractor_Unsupported() throws Exception { } @Test - void Check_Channel_Index_Extractor() throws Exception { + void Check_Channel_Index_Extractor_Pixels() throws Exception { ImageServer sampleServer = new SampleImageServer(); int channelIndex = sampleServer.nChannels() - 1; - ColorTransforms.ColorTransform channelExtractor = ColorTransforms.createChannelExtractor(channelIndex); + ColorTransforms.ColorTransform colorTransform = ColorTransforms.createChannelExtractor(channelIndex); BufferedImage image = sampleServer.readRegion(1, 0, 0, 10, 10); + float[] expectedPixels = new float[image.getWidth() * image.getHeight()]; + for (int y=0; y sampleServer = new SampleImageServer(); + int channelIndex = sampleServer.nChannels() - 1; + String channelName = sampleServer.getChannel(channelIndex).getName(); + ColorTransforms.ColorTransform colorTransform = ColorTransforms.createChannelExtractor(channelName); + + boolean supported = colorTransform.supportsImage(sampleServer); + + Assertions.assertTrue(supported); + + sampleServer.close(); + } + + @Test + void Check_Channel_Name_Extractor_Not_Supported() throws Exception { + ImageServer sampleServer = new SampleImageServer(); + String channelName = "channel not present in sample image server"; + ColorTransforms.ColorTransform colorTransform = ColorTransforms.createChannelExtractor(channelName); + + boolean supported = colorTransform.supportsImage(sampleServer); + + Assertions.assertFalse(supported); + + sampleServer.close(); + } + + @Test + void Check_Channel_Name_Extractor_Pixels() throws Exception { + ImageServer sampleServer = new SampleImageServer(); + int channelIndex = sampleServer.nChannels() - 2; + String channelName = sampleServer.getChannel(channelIndex).getName(); + ColorTransforms.ColorTransform colorTransform = ColorTransforms.createChannelExtractor(channelName); + BufferedImage image = sampleServer.readRegion(1, 0, 0, 10, 10); + float[] expectedPixels = new float[image.getWidth() * image.getHeight()]; + for (int y=0; y sampleServer = new SampleImageServer(); + ColorTransforms.ColorTransform colorTransform = ColorTransforms.createLinearCombinationChannelTransform(Map.of( + sampleServer.getChannel(0).getName(), 0.5f, + sampleServer.getChannel(1).getName(), 0.1f + )); + + boolean supported = colorTransform.supportsImage(sampleServer); + + Assertions.assertTrue(supported); + + sampleServer.close(); + } + + @Test + void Check_Map_Linear_Combination_Channel_Not_Supported() throws Exception { + ImageServer sampleServer = new SampleImageServer(); + ColorTransforms.ColorTransform colorTransform = ColorTransforms.createLinearCombinationChannelTransform(Map.of( + "channel not present in sample image server", 0.5f, + sampleServer.getChannel(1).getName(), 0.1f + )); + + boolean supported = colorTransform.supportsImage(sampleServer); + + Assertions.assertFalse(supported); + + sampleServer.close(); + } + + @Test + void Check_Map_Linear_Combination_Channel_Pixels() throws Exception { + ImageServer sampleServer = new SampleImageServer(); + Map coefficients = Map.of( + sampleServer.getChannel(0).getName(), 0.4f, + sampleServer.getChannel(2).getName(), 0.6f + ); + ColorTransforms.ColorTransform colorTransform = ColorTransforms.createLinearCombinationChannelTransform(coefficients); + BufferedImage image = sampleServer.readRegion(1, 0, 0, 10, 10); + float[] expectedPixels = new float[image.getWidth() * image.getHeight()]; + for (int y=0; y entry: coefficients.entrySet()) { + int channelIndex = sampleServer.getMetadata().getChannels().stream() + .map(ImageChannel::getName) + .toList() + .indexOf(entry.getKey()); + + expectedPixels[y*image.getWidth() + x] += entry.getValue() * SampleImageServer.getPixel(x, y, channelIndex); + } + } + } + + float[] pixels = colorTransform.extractChannel(sampleServer, image, null); + + Assertions.assertArrayEquals(expectedPixels, pixels, 0.001f); // delta for rounding errors + + sampleServer.close(); + } + + @Test + void Check_Map_Linear_Combination_Channel_To_And_From_JSON() { + ColorTransforms.ColorTransform expectedColorTransform = ColorTransforms.createLinearCombinationChannelTransform(Map.of( + "some channel", 0.5f, + "some other channel", 0.1f + )); + Gson gson = new GsonBuilder() + .registerTypeAdapter(ColorTransforms.ColorTransform.class, new ColorTransforms.ColorTransformTypeAdapter()) + .create(); + + ColorTransforms.ColorTransform colorTransform = gson.fromJson( + gson.toJson(expectedColorTransform), + ColorTransforms.ColorTransform.class + ); + + Assertions.assertEquals(expectedColorTransform, colorTransform); + } + + @Test + void Check_List_Linear_Combination_Channel_Supported() throws Exception { + ImageServer sampleServer = new SampleImageServer(); + ColorTransforms.ColorTransform colorTransform = ColorTransforms.createLinearCombinationChannelTransform( + sampleServer.getMetadata().getChannels().stream().map(c -> 1f).toList() + ); + + boolean supported = colorTransform.supportsImage(sampleServer); + + Assertions.assertTrue(supported); + + sampleServer.close(); + } + + @Test + void Check_List_Linear_Combination_Channel_Not_Supported() throws Exception { + ImageServer sampleServer = new SampleImageServer(); + ColorTransforms.ColorTransform colorTransform = ColorTransforms.createLinearCombinationChannelTransform( + Stream.concat( + sampleServer.getMetadata().getChannels().stream().map(c -> 1f), + Stream.of(3f) + ).toList() + ); + + boolean supported = colorTransform.supportsImage(sampleServer); + + Assertions.assertFalse(supported); + + sampleServer.close(); + } + + @Test + void Check_List_Linear_Combination_Channel_Pixels() throws Exception { + ImageServer sampleServer = new SampleImageServer(); + List coefficients = List.of(0.2f, 0.5f, 2f); + ColorTransforms.ColorTransform colorTransform = ColorTransforms.createLinearCombinationChannelTransform(coefficients); + BufferedImage image = sampleServer.readRegion(1, 0, 0, 10, 10); + float[] expectedPixels = new float[image.getWidth() * image.getHeight()]; + for (int y=0; y sampleServer = new SampleImageServer(); + ColorTransforms.ColorTransform colorTransform = ColorTransforms.createMeanChannelTransform(); + + boolean supported = colorTransform.supportsImage(sampleServer); + + Assertions.assertTrue(supported); + + sampleServer.close(); + } + + @Test + void Check_Mean_Channel_Pixels() throws Exception { + ImageServer sampleServer = new SampleImageServer(); + ColorTransforms.ColorTransform colorTransform = ColorTransforms.createMeanChannelTransform(); + BufferedImage image = sampleServer.readRegion(1, 0, 0, 10, 10); + float[] expectedPixels = new float[image.getWidth() * image.getHeight()]; + for (int y=0; y sampleServer = new SampleImageServer(); + ColorTransforms.ColorTransform colorTransform = ColorTransforms.createMaximumChannelTransform(); + + boolean supported = colorTransform.supportsImage(sampleServer); + + Assertions.assertTrue(supported); sampleServer.close(); } + @Test + void Check_Maximum_Channel_Pixels() throws Exception { + ImageServer sampleServer = new SampleImageServer(); + ColorTransforms.ColorTransform colorTransform = ColorTransforms.createMaximumChannelTransform(); + BufferedImage image = sampleServer.readRegion(1, 0, 0, 10, 10); + float[] expectedPixels = new float[image.getWidth() * image.getHeight()]; + for (int y=0; y sampleServer = new SampleImageServer(); + ColorTransforms.ColorTransform colorTransform = ColorTransforms.createMinimumChannelTransform(); + + boolean supported = colorTransform.supportsImage(sampleServer); + + Assertions.assertTrue(supported); + + sampleServer.close(); + } + + @Test + void Check_Minimum_Channel_Pixels() throws Exception { + ImageServer sampleServer = new SampleImageServer(); + ColorTransforms.ColorTransform colorTransform = ColorTransforms.createMinimumChannelTransform(); + BufferedImage image = sampleServer.readRegion(1, 0, 0, 10, 10); + float[] expectedPixels = new float[image.getWidth() * image.getHeight()]; + for (int y=0; y sampleServer = new SampleRGBImageServer(); + ColorTransforms.ColorTransform colorTransform = ColorTransforms.createColorDeconvolvedChannel( + new ColorDeconvolutionStains(), + 2 + ); + + boolean supported = colorTransform.supportsImage(sampleServer); + + Assertions.assertTrue(supported); + + sampleServer.close(); + } + + @Test + void Check_Color_Deconvolved_Channel_Not_Supported() throws Exception { + ImageServer sampleServer = new SampleImageServer(); + ColorTransforms.ColorTransform colorTransform = ColorTransforms.createColorDeconvolvedChannel( + new ColorDeconvolutionStains(), + 2 + ); + + boolean supported = colorTransform.supportsImage(sampleServer); + + Assertions.assertFalse(supported); + + sampleServer.close(); + } + + @Test + void Check_Color_Deconvolved_Channel_To_And_From_JSON() { + ColorTransforms.ColorTransform expectedColorTransform = ColorTransforms.createColorDeconvolvedChannel( + new ColorDeconvolutionStains(), + 2 + ); + Gson gson = new GsonBuilder() + .registerTypeAdapter(ColorTransforms.ColorTransform.class, new ColorTransforms.ColorTransformTypeAdapter()) + .create(); + + ColorTransforms.ColorTransform colorTransform = gson.fromJson( + gson.toJson(expectedColorTransform), + ColorTransforms.ColorTransform.class + ); + + Assertions.assertEquals(expectedColorTransform, colorTransform); + } + private static class SampleImageServer extends AbstractImageServer { + private static final int IMAGE_WIDTH = 50; + private static final int IMAGE_HEIGHT = 25; + public SampleImageServer() { super(BufferedImage.class); } @@ -83,8 +526,8 @@ public String getServerType() { @Override public ImageServerMetadata getOriginalMetadata() { return new ImageServerMetadata.Builder() - .width(50) - .height(25) + .width(IMAGE_WIDTH) + .height(IMAGE_HEIGHT) .pixelType(PixelType.FLOAT64) .channels(List.of( ImageChannel.getInstance("c1", 1), @@ -128,11 +571,52 @@ private double[] getPixels(RegionRequest request, int channel) { for (int y=0; y { + + public SampleRGBImageServer() { + super(BufferedImage.class); + } + + @Override + protected ImageServerBuilder.ServerBuilder createServerBuilder() { + return null; + } + + @Override + protected String createID() { + return getClass().getName() + ": " + getURIs(); + } + + @Override + public Collection getURIs() { + return List.of(URI.create("")); + } + + @Override + public String getServerType() { + return "Sample RGB server"; + } + + @Override + public ImageServerMetadata getOriginalMetadata() { + return new ImageServerMetadata.Builder() + .width(50) + .height(20) + .rgb(true) + .name("name") + .build(); + } } } From 81cd9c02cd7ef2dd852816985afc217f1c557bda Mon Sep 17 00:00:00 2001 From: Alan O'Callaghan Date: Tue, 30 Jul 2024 18:27:38 +0100 Subject: [PATCH 132/866] Add missing strings and resize textbox --- .../java/qupath/lib/gui/ExtensionControlPane.java | 11 +++++------ .../qupath/lib/gui/ExtensionControlPane.fxml | 3 ++- .../gui/localization/qupath-gui-strings.properties | 12 +++++++++--- 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/ExtensionControlPane.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/ExtensionControlPane.java index a6267161e..d3f04b67a 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/ExtensionControlPane.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/ExtensionControlPane.java @@ -538,17 +538,16 @@ private static void downloadURLToFile(URL downloadURL, File file) throws IOExcep private void downloadExtension() { var components = parseComponents(textArea.getText()); if (!(components.length > 0)) { - Dialogs.showErrorNotification(QuPathResources.getString("ExtensionControlPane.unableToDownload"), QuPathResources.getString("ExtensionControlPane.unableToParseURL")); + Dialogs.showErrorNotification( + QuPathResources.getString("ExtensionControlPane.unableToDownload"), + QuPathResources.getString("ExtensionControlPane.unableToParseURL")); return; } GitHubProject.GitHubRepo repo; if (components.length == 1) { repo = GitHubProject.GitHubRepo.create("", "qupath", components[0]); - } else if (components.length == 2) { - repo = GitHubProject.GitHubRepo.create("", components[0], components[1]); } else { - Dialogs.showErrorNotification(QuPathResources.getString("ExtensionControlPane.unableToDownload"), QuPathResources.getString("ExtensionControlPane.unableToParseURL")); - return; + repo = GitHubProject.GitHubRepo.create("", components[0], components[1]); } try { askToDownload(repo); @@ -779,7 +778,7 @@ QuPathExtension getExtension() { void setExtension(QuPathExtension extension) { boolean failedExtension = manager != null && manager.getFailedExtensions().containsValue(extension); if (failedExtension) - nameText.setText(extension.getName() + " (not compatible)"); + nameText.setText(extension.getName() + " " + QuPathResources.getString("ExtensionControlPane.notCompatible")); else nameText.setText(extension.getName()); typeText.setText(getExtensionType(extension)); diff --git a/qupath-gui-fx/src/main/resources/qupath/lib/gui/ExtensionControlPane.fxml b/qupath-gui-fx/src/main/resources/qupath/lib/gui/ExtensionControlPane.fxml index 92a980ea8..b46515da9 100644 --- a/qupath-gui-fx/src/main/resources/qupath/lib/gui/ExtensionControlPane.fxml +++ b/qupath-gui-fx/src/main/resources/qupath/lib/gui/ExtensionControlPane.fxml @@ -20,7 +20,8 @@ - + + diff --git a/qupath-gui-fx/src/main/resources/qupath/lib/gui/localization/qupath-gui-strings.properties b/qupath-gui-fx/src/main/resources/qupath/lib/gui/localization/qupath-gui-strings.properties index a3b32a3b8..bdc686211 100644 --- a/qupath-gui-fx/src/main/resources/qupath/lib/gui/localization/qupath-gui-strings.properties +++ b/qupath-gui-fx/src/main/resources/qupath/lib/gui/localization/qupath-gui-strings.properties @@ -950,10 +950,8 @@ ExtensionControlPane.updateExtension = Update Extension (checks for updates and ExtensionControlPane.removeExtension = Remove extension (deletes files on disk) ExtensionControlPane.browseGitHub = Open GitHub repository in browser ExtensionControlPane.unableToOpenGitHubURL = Unable to open GitHub URL: -ExtensionControlPane.owner = Owner -ExtensionControlPane.ownerTooltip = The username that the extension can be found under. e.g., qupath ExtensionControlPane.repository = Repository -ExtensionControlPane.repositoryTooltip = The repository that the extension can be found in.\ne.g., qupath-extension-stardist +ExtensionControlPane.repositoryTooltip = The repository that the extension can be found under. e.g., qupath/qupath-extension-stardist ExtensionControlPane.noExtensionDirectorySet = User directory has not been set. Install an extension to set a user directory ExtensionControlPane.openExtensionDirectory = Open extension directory ExtensionControlPane.openExtensionDirectoryTooltip = Open user directory containing extensions and other files @@ -964,3 +962,11 @@ ExtensionControlPane.coreExtension = Core extension (part of QuPath) ExtensionControlPane.githubExtension = User extension (GitHub enabled) ExtensionControlPane.userExtension = User extension (GitHub disabled) ExtensionControlPane.unknownVersion = Unknown version +ExtensionControlPane.moreThanOneThing = More than one %s available for this %s.\ + Please choose one: +ExtensionControlPane.unableToParseURL = Unable to parse URL +ExtensionControlPane.tag = Tag +ExtensionControlPane.name = Name +ExtensionControlPane.datePublished = Date published +ExtensionControlPane.description = Description +ExtensionControlPane.notCompatible = (not compatible) From 55cf5db8e5289df69504b272273240bf2025fc89 Mon Sep 17 00:00:00 2001 From: lleplat Date: Wed, 31 Jul 2024 09:59:57 +0100 Subject: [PATCH 133/866] Updated CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index da4e9a0d3..5f86bc1d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ This is a *work in progress* for the next major release. * Images remain sorted after adding new metadata values * A warning indicator is shown if image files are missing * Use the 'Skip file checks in projects' preference if you need to turn this off (e.g. your images are on a slow network) +* Create a new channel as a linear combination of other channels (https://github.com/qupath/qupath/pull/1566) ### Bugs fixed * Tile export to .ome.tif can convert to 8-bit unnecessarily (https://github.com/qupath/qupath/issues/1494) From f2b92aacd86bc2f6f627574185e9296ca6dff370 Mon Sep 17 00:00:00 2001 From: lleplat Date: Wed, 31 Jul 2024 13:40:47 +0100 Subject: [PATCH 134/866] Added applyColorTransforms --- .../servers/TransformedServerBuilder.java | 104 ++++++++++-------- 1 file changed, 58 insertions(+), 46 deletions(-) diff --git a/qupath-core/src/main/java/qupath/lib/images/servers/TransformedServerBuilder.java b/qupath-core/src/main/java/qupath/lib/images/servers/TransformedServerBuilder.java index 4b8fe5e17..0762f6350 100644 --- a/qupath-core/src/main/java/qupath/lib/images/servers/TransformedServerBuilder.java +++ b/qupath-core/src/main/java/qupath/lib/images/servers/TransformedServerBuilder.java @@ -55,9 +55,10 @@ public TransformedServerBuilder(ImageServer baseServer) { } /** - * Crop an specified region based on a bounding box. - * @param region - * @return + * Crop a specified region based on a bounding box. + * + * @param region the region to crop + * @return this builder */ public TransformedServerBuilder crop(ImageRegion region) { server = new CroppedImageServer(server, region); @@ -67,8 +68,9 @@ public TransformedServerBuilder crop(ImageRegion region) { /** * Apply an {@link AffineTransform} to the server. * Note that the transform must be invertible, otherwise and {@link IllegalArgumentException} will be thrown. - * @param transform - * @return + * + * @param transform the transform to apply to the image + * @return this builder */ public TransformedServerBuilder transform(AffineTransform transform) { try { @@ -80,10 +82,11 @@ public TransformedServerBuilder transform(AffineTransform transform) { } /** - * Apply color deconvolution to the brightfield image, so that deconvolved stains behave as separate channels/ + * Apply color deconvolution to the brightfield image, so that deconvolved stains behave as separate channels. + * * @param stains the stains to apply for color deconvolution * @param stainNumbers the indices of the stains that should be use (an array compressing values that are 1, 2 or 3); if not specified, all 3 stains will be used. - * @return + * @return this builder */ public TransformedServerBuilder deconvolveStains(ColorDeconvolutionStains stains, int...stainNumbers) { server = new ColorDeconvolutionImageServer(server, stains, stainNumbers); @@ -93,8 +96,9 @@ public TransformedServerBuilder deconvolveStains(ColorDeconvolutionStains stains /** * Rearrange the channel order of an RGB image. * This is intended for cases where an image has wrongly been interpreted as RGB or BGR. - * @param order - * @return + * + * @param order a text containing the letters R, G, and B in any order + * @return this builder */ public TransformedServerBuilder reorderRGB(String order) { server = new RearrangeRGBImageServer(server, order); @@ -103,8 +107,9 @@ public TransformedServerBuilder reorderRGB(String order) { /** * Rotate the image, using an increment of 90 degrees. - * @param rotation - * @return + * + * @param rotation the rotation to apply + * @return this image */ public TransformedServerBuilder rotate(Rotation rotation) { server = new RotatedImageServer(server, rotation); @@ -113,8 +118,9 @@ public TransformedServerBuilder rotate(Rotation rotation) { /** * Extract specified channels for an image. + * * @param channels indices (0-based) of channels to extract. - * @return + * @return this builder */ public TransformedServerBuilder extractChannels(int... channels) { var transforms = new ArrayList(); @@ -127,8 +133,9 @@ public TransformedServerBuilder extractChannels(int... channels) { /** * Extract specified channels for an image. + * * @param names names of channels to extract. - * @return + * @return this builder */ public TransformedServerBuilder extractChannels(String... names) { var transforms = new ArrayList(); @@ -141,48 +148,42 @@ public TransformedServerBuilder extractChannels(String... names) { /** * Perform a maximum projection of the channels. - * @return + * + * @return this builder */ public TransformedServerBuilder maxChannelProject() { - server = new ChannelTransformFeatureServer(server, - Arrays.asList(ColorTransforms.createMaximumChannelTransform())); + server = new ChannelTransformFeatureServer(server, List.of(ColorTransforms.createMaximumChannelTransform())); return this; } /** * Perform an average (mean) projection of the channels. - * @return + * + * @return this builder */ public TransformedServerBuilder averageChannelProject() { - server = new ChannelTransformFeatureServer(server, - Arrays.asList(ColorTransforms.createMeanChannelTransform())); + server = new ChannelTransformFeatureServer(server, List.of(ColorTransforms.createMeanChannelTransform())); return this; } /** * Perform a minimum projection of the channels. - * @return + * + * @return this builder */ public TransformedServerBuilder minChannelProject() { - server = new ChannelTransformFeatureServer(server, - Arrays.asList(ColorTransforms.createMinimumChannelTransform())); + server = new ChannelTransformFeatureServer(server, List.of(ColorTransforms.createMinimumChannelTransform())); return this; } /** * Concatenate a collection of additional servers along the 'channels' dimension (iteration order is used). - * @param additionalChannels additional servers that will be applied as channels; note that these should be - * of an appropriate type and dimension for concatenation. - * @return + * + * @param additionalChannels additional servers that will be applied as channels; note that these should be + * of an appropriate type and dimension for concatenation. + * @return this builder */ public TransformedServerBuilder concatChannels(Collection> additionalChannels) { -// // Try to avoid wrapping more than necessary -// if (server instanceof ConcatChannelsImageServer) { -// var temp = new ArrayList<>(((ConcatChannelsImageServer)server).getAllServers()); -// temp.addAll(additionalChannels); -// server = new ConcatChannelsImageServer(((ConcatChannelsImageServer)server).getWrappedServer(), temp); -// } else - List> allChannels = new ArrayList<>(additionalChannels); // Make sure that the current server is included if (!allChannels.contains(server)) @@ -193,9 +194,10 @@ public TransformedServerBuilder concatChannels(Collection... additionalChannels) { for (var temp : additionalChannels) { @@ -204,20 +206,30 @@ public TransformedServerBuilder concatChannels(ImageServer... add } return concatChannels(Arrays.asList(additionalChannels)); } - -// /** -// * Concatenate additional server along the 'channels' dimension. -// * @param additionalChannel additional server from which channels will be added; note that the server should be -// * of an appropriate type and dimension for concatenation. -// * @return -// */ -// public TransformedServerBuilder concatChannel(ImageServer additionalChannel) { -// return concatChannels(Arrays.asList(additionalChannel)); -// } + + /** + * Apply color transforms to the image. + * + * @param transforms the transforms to apply + * @return this builder + */ + public TransformedServerBuilder applyColorTransforms(Collection transforms) { + server = new ChannelTransformFeatureServer(server, new ArrayList<>(transforms)); + return this; + } + + /** + * Apply color transforms to the image. + * + * @param transforms the transforms to apply + * @return this builder + */ + public TransformedServerBuilder applyColorTransforms(ColorTransform... transforms) { + return applyColorTransforms(Arrays.asList(transforms)); + } /** * Get the {@link ImageServer} that applies all the requested transforms. - * @return */ public ImageServer build() { return server; From 77ff36dff88c1ba9d712839d1b85f641d7a3d327 Mon Sep 17 00:00:00 2001 From: Pete Date: Wed, 31 Jul 2024 18:15:21 +0100 Subject: [PATCH 135/866] Fix export snapshot file format bug Fixes https://github.com/qupath/qupath/issues/1567 --- CHANGELOG.md | 1 + .../java/qupath/lib/gui/commands/Commands.java | 17 ++++++++++------- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b3a0671bb..57ded45ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ This is a *work in progress* for the next major release. * Full image annotations can be shifted accidentally outside the image bounds (https://github.com/qupath/qupath/issues/1533) * Search distance when selecting points in the viewer is too low (https://github.com/qupath/qupath/issues/1552) * `ImageOps.Core.replace()` does not work as expected (https://github.com/qupath/qupath/issues/1564) +* QuPath doesn't always use the specified file extension when exporting snapshots (https://github.com/qupath/qupath/issues/1567) ### Dependency updates * Bio-Formats 7.3.0 diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/Commands.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/Commands.java index d53b52910..283ab58d7 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/Commands.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/Commands.java @@ -875,12 +875,7 @@ public static boolean promptToSaveImageData(QuPathGUI qupath, ImageData> compatibleWriters = ImageWriterTools.getCompatibleWriters(BufferedImage.class, ext); - if (compatibleWriters.isEmpty()) { - logger.error("No compatible image writers found for extension: " + ext); - return false; - } + String defaultExtension = defaultScreenshotExtension.get(); List extensionFilters = new ArrayList<>(Arrays.asList( FileChoosers.createExtensionFilter("PNG", "png"), @@ -889,7 +884,7 @@ public static boolean saveSnapshot(QuPathGUI qupath, GuiTools.SnapshotType type) )); FileChooser.ExtensionFilter selectedFilter = extensionFilters .stream() - .filter(e -> e.getExtensions().contains(ext)) + .filter(e -> defaultExtension == null ? false : e.getExtensions().contains(defaultExtension)) .findFirst() .orElse(extensionFilters.get(0)); if (!Objects.equals(selectedFilter, extensionFilters.get(0))) { @@ -923,6 +918,14 @@ else if (Files.isRegularFile(path)) return false; lastSnapshotDirectory = fileOutput.getParentFile(); + String ext = GeneralTools.getExtension(fileOutput).orElse(null); + List> compatibleWriters = ext == null ? Collections.emptyList() : + ImageWriterTools.getCompatibleWriters(BufferedImage.class, ext); + if (compatibleWriters.isEmpty()) { + logger.error("No compatible image writers found for extension: " + ext); + return false; + } + // Loop through the writers and stop when we are successful for (var writer : compatibleWriters) { try { From ac2394d1f92a3f8d99c74a206b9ead8de4a0f726 Mon Sep 17 00:00:00 2001 From: Leo Leplat <60394504+Rylern@users.noreply.github.com> Date: Thu, 1 Aug 2024 13:59:51 +0100 Subject: [PATCH 136/866] Addressed comments --- .../lib/images/servers/ColorTransforms.java | 119 +++++++++++------- .../images/servers/TestColorTransforms.java | 93 +++++++++++--- 2 files changed, 153 insertions(+), 59 deletions(-) diff --git a/qupath-core/src/main/java/qupath/lib/images/servers/ColorTransforms.java b/qupath-core/src/main/java/qupath/lib/images/servers/ColorTransforms.java index 67ec0b9d6..f308b49cf 100644 --- a/qupath-core/src/main/java/qupath/lib/images/servers/ColorTransforms.java +++ b/qupath-core/src/main/java/qupath/lib/images/servers/ColorTransforms.java @@ -114,14 +114,14 @@ public ColorTransform read(JsonReader in) throws IOException { } else if (obj.has("channelName")) { return new ExtractChannelByName(obj.get("channelName").getAsString()); } else if (obj.has("channelNamesToCoefficients") || obj.has("channelIndicesToCoefficients")) { - Map channelNamesToCoefficients = null; - List channelIndicesToCoefficients = null; + Map channelNamesToCoefficients = null; + List channelIndicesToCoefficients = null; if (obj.get("channelNamesToCoefficients") != null) { - channelNamesToCoefficients = gson.fromJson(obj.get("channelNamesToCoefficients"), new TypeToken>() {}.getType()); + channelNamesToCoefficients = gson.fromJson(obj.get("channelNamesToCoefficients"), new TypeToken>() {}.getType()); } if (obj.get("channelIndicesToCoefficients") != null) { - channelIndicesToCoefficients = obj.get("channelIndicesToCoefficients").getAsJsonArray().asList().stream().map(JsonElement::getAsFloat).toList(); + channelIndicesToCoefficients = obj.get("channelIndicesToCoefficients").getAsJsonArray().asList().stream().map(JsonElement::getAsDouble).toList(); } return new LinearCombinationChannel(channelNamesToCoefficients, channelIndicesToCoefficients); @@ -170,7 +170,7 @@ public static ColorTransform createChannelExtractor(String channelName) { * @param coefficients the channel names mapped to coefficients * @return a ColorTransform computing the provided linear combination */ - public static ColorTransform createLinearCombinationChannelTransform(Map coefficients) { + public static ColorTransform createLinearCombinationChannelTransform(Map coefficients) { return new LinearCombinationChannel(coefficients); } @@ -182,9 +182,21 @@ public static ColorTransform createLinearCombinationChannelTransform(Map coefficients) { + public static ColorTransform createLinearCombinationChannelTransform(List coefficients) { return new LinearCombinationChannel(coefficients); } + + /** + * Create a ColorTransform that apply a linear combination to the channels. + * For example, calling this function with the coefficients (0.5, 0.9) + * will create a new channel with values "0.5*firstChannel + 0.9*secondChannel". + * + * @param coefficients the coefficients to apply to each channel + * @return a ColorTransform computing the provided linear combination + */ + public static ColorTransform createLinearCombinationChannelTransform(double... coefficients) { + return createLinearCombinationChannelTransform(Arrays.stream(coefficients).boxed().toList()); + } /** * Create a ColorTransform that calculates the mean of all channels. @@ -332,30 +344,43 @@ public String getChannelName() { } private int getChannelNumber(ImageServer server) { - return server.getMetadata().getChannels() - .stream() - .map(ImageChannel::getName) - .toList() - .indexOf(channelName); + int i = 0; + for (ImageChannel channel : server.getMetadata().getChannels()) { + if (channel.getName().equals(channelName)) { + return i; + } + i++; + } + return -1; } } static class LinearCombinationChannel implements ColorTransform { - private final Map channelNamesToCoefficients; - private final List channelIndicesToCoefficients; + private final Map channelNamesToCoefficients; + private final List channelIndicesToCoefficients; + private transient String name; - private LinearCombinationChannel(Map channelNamesToCoefficients, List channelIndicesToCoefficients) { + private LinearCombinationChannel(Map channelNamesToCoefficients, List channelIndicesToCoefficients) { this.channelNamesToCoefficients = channelNamesToCoefficients; this.channelIndicesToCoefficients = channelIndicesToCoefficients; } - public LinearCombinationChannel(Map coefficients) { - this(coefficients, null); + public LinearCombinationChannel(Map coefficients) { + this( + coefficients.entrySet().stream().collect(Collectors.toMap( + Map.Entry::getKey, + entry -> entry.getValue().doubleValue() + )), + null + ); } - public LinearCombinationChannel(List coefficients) { - this(null, coefficients); + public LinearCombinationChannel(List coefficients) { + this( + null, + coefficients.stream().map(Number::doubleValue).toList() + ); } @Override @@ -364,15 +389,18 @@ public float[] extractChannel(ImageServer server, BufferedImage i int w = img.getWidth(); int h = img.getHeight(); var raster = img.getRaster(); - Map coefficients = getCoefficients(server); + double[] coefficients = getCoefficients(server); + double[] values = null; for (int y = 0; y < h; y++) { for (int x = 0; x < w; x++) { - double[] vals = raster.getPixel(x, y, (double[]) null); + values = raster.getPixel(x, y, values); - pixels[y*w+x] = (float) coefficients.entrySet().stream() - .mapToDouble(entry -> entry.getValue() * vals[entry.getKey()]) - .sum(); + double result = 0; + for (int i = 0; i < values.length; i++) { + result += values[i] * coefficients[i]; + } + pixels[y*w+x] = (float) result; } } return pixels; @@ -380,17 +408,20 @@ public float[] extractChannel(ImageServer server, BufferedImage i @Override public String getName() { - if (channelNamesToCoefficients != null) { - return channelNamesToCoefficients.entrySet().stream() - .map(entry -> entry.getValue() + "*" + entry.getKey()) - .collect(Collectors.joining(" + ")); - } else if (channelIndicesToCoefficients != null) { - return IntStream.range(0, channelIndicesToCoefficients.size()) - .mapToObj(i -> channelIndicesToCoefficients.get(i) + "*channel" + i) - .collect(Collectors.joining(" + ")); - } else { - return "Linear combination channels"; + if (name == null) { + if (channelNamesToCoefficients != null) { + name = channelNamesToCoefficients.entrySet().stream() + .map(entry -> entry.getValue() + "*" + entry.getKey()) + .collect(Collectors.joining(" + ")); + } else if (channelIndicesToCoefficients != null) { + name = IntStream.range(0, channelIndicesToCoefficients.size()) + .mapToObj(i -> channelIndicesToCoefficients.get(i) + "*channel" + i) + .collect(Collectors.joining(" + ")); + } else { + name = "Linear combination channels"; + } } + return name; } @Override @@ -431,22 +462,20 @@ public boolean equals(Object obj) { Objects.equals(channelIndicesToCoefficients, linearCombinationChannel.channelIndicesToCoefficients); } - private Map getCoefficients(ImageServer server) { - List channelNames = server.getMetadata().getChannels().stream().map(ImageChannel::getName).toList(); + private double[] getCoefficients(ImageServer server) { + double[] coefficients = new double[server.nChannels()]; if (channelNamesToCoefficients != null) { - return channelNamesToCoefficients.entrySet().stream() - .collect(Collectors.toMap( - entry -> channelNames.indexOf(entry.getKey()), - Map.Entry::getValue - )); + for (int i=0; i i, channelIndicesToCoefficients::get)); - } else { - return Map.of(); + for (int i=0; i sampleServer = new SampleImageServer(); ColorTransforms.ColorTransform colorTransform = ColorTransforms.createLinearCombinationChannelTransform(Map.of( - sampleServer.getChannel(0).getName(), 0.5f, - sampleServer.getChannel(1).getName(), 0.1f + sampleServer.getChannel(0).getName(), 0.5, + sampleServer.getChannel(1).getName(), 0.1 )); boolean supported = colorTransform.supportsImage(sampleServer); @@ -164,8 +164,8 @@ void Check_Map_Linear_Combination_Channel_Supported() throws Exception { void Check_Map_Linear_Combination_Channel_Not_Supported() throws Exception { ImageServer sampleServer = new SampleImageServer(); ColorTransforms.ColorTransform colorTransform = ColorTransforms.createLinearCombinationChannelTransform(Map.of( - "channel not present in sample image server", 0.5f, - sampleServer.getChannel(1).getName(), 0.1f + "channel not present in sample image server", 0.5, + sampleServer.getChannel(1).getName(), 0.1 )); boolean supported = colorTransform.supportsImage(sampleServer); @@ -178,16 +178,16 @@ void Check_Map_Linear_Combination_Channel_Not_Supported() throws Exception { @Test void Check_Map_Linear_Combination_Channel_Pixels() throws Exception { ImageServer sampleServer = new SampleImageServer(); - Map coefficients = Map.of( - sampleServer.getChannel(0).getName(), 0.4f, - sampleServer.getChannel(2).getName(), 0.6f + Map coefficients = Map.of( + sampleServer.getChannel(0).getName(), 0.4, + sampleServer.getChannel(2).getName(), 0.6 ); ColorTransforms.ColorTransform colorTransform = ColorTransforms.createLinearCombinationChannelTransform(coefficients); BufferedImage image = sampleServer.readRegion(1, 0, 0, 10, 10); float[] expectedPixels = new float[image.getWidth() * image.getHeight()]; for (int y=0; y entry: coefficients.entrySet()) { + for (Map.Entry entry: coefficients.entrySet()) { int channelIndex = sampleServer.getMetadata().getChannels().stream() .map(ImageChannel::getName) .toList() @@ -208,8 +208,8 @@ void Check_Map_Linear_Combination_Channel_Pixels() throws Exception { @Test void Check_Map_Linear_Combination_Channel_To_And_From_JSON() { ColorTransforms.ColorTransform expectedColorTransform = ColorTransforms.createLinearCombinationChannelTransform(Map.of( - "some channel", 0.5f, - "some other channel", 0.1f + "some channel", 0.5, + "some other channel", 0.1 )); Gson gson = new GsonBuilder() .registerTypeAdapter(ColorTransforms.ColorTransform.class, new ColorTransforms.ColorTransformTypeAdapter()) @@ -227,7 +227,7 @@ void Check_Map_Linear_Combination_Channel_To_And_From_JSON() { void Check_List_Linear_Combination_Channel_Supported() throws Exception { ImageServer sampleServer = new SampleImageServer(); ColorTransforms.ColorTransform colorTransform = ColorTransforms.createLinearCombinationChannelTransform( - sampleServer.getMetadata().getChannels().stream().map(c -> 1f).toList() + sampleServer.getMetadata().getChannels().stream().map(c -> 1).toList() ); boolean supported = colorTransform.supportsImage(sampleServer); @@ -242,8 +242,8 @@ void Check_List_Linear_Combination_Channel_Not_Supported() throws Exception { ImageServer sampleServer = new SampleImageServer(); ColorTransforms.ColorTransform colorTransform = ColorTransforms.createLinearCombinationChannelTransform( Stream.concat( - sampleServer.getMetadata().getChannels().stream().map(c -> 1f), - Stream.of(3f) + sampleServer.getMetadata().getChannels().stream().map(c -> 1), + Stream.of(3) ).toList() ); @@ -279,7 +279,7 @@ void Check_List_Linear_Combination_Channel_Pixels() throws Exception { @Test void Check_List_Linear_Combination_Channel_To_And_From_JSON() { ColorTransforms.ColorTransform expectedColorTransform = ColorTransforms.createLinearCombinationChannelTransform(List.of( - 0.5f, 0.1f + 5, 1 )); Gson gson = new GsonBuilder() .registerTypeAdapter(ColorTransforms.ColorTransform.class, new ColorTransforms.ColorTransformTypeAdapter()) @@ -293,6 +293,71 @@ void Check_List_Linear_Combination_Channel_To_And_From_JSON() { Assertions.assertEquals(expectedColorTransform, colorTransform); } + @Test + void Check_Array_Linear_Combination_Channel_Supported() throws Exception { + ImageServer sampleServer = new SampleImageServer(); + ColorTransforms.ColorTransform colorTransform = ColorTransforms.createLinearCombinationChannelTransform( + new double[sampleServer.nChannels()] + ); + + boolean supported = colorTransform.supportsImage(sampleServer); + + Assertions.assertTrue(supported); + + sampleServer.close(); + } + + @Test + void Check_Array_Linear_Combination_Channel_Not_Supported() throws Exception { + ImageServer sampleServer = new SampleImageServer(); + ColorTransforms.ColorTransform colorTransform = ColorTransforms.createLinearCombinationChannelTransform( + new double[sampleServer.nChannels() + 1] + ); + + boolean supported = colorTransform.supportsImage(sampleServer); + + Assertions.assertFalse(supported); + + sampleServer.close(); + } + + @Test + void Check_Array_Linear_Combination_Channel_Pixels() throws Exception { + ImageServer sampleServer = new SampleImageServer(); + double[] coefficients = new double[] {0.2, 0.5, 2}; + ColorTransforms.ColorTransform colorTransform = ColorTransforms.createLinearCombinationChannelTransform(coefficients); + BufferedImage image = sampleServer.readRegion(1, 0, 0, 10, 10); + float[] expectedPixels = new float[image.getWidth() * image.getHeight()]; + for (int y=0; y sampleServer = new SampleImageServer(); From 192cbdf56a5f396556f6ebe7cdcc096284ca79d0 Mon Sep 17 00:00:00 2001 From: petebankhead Date: Thu, 1 Aug 2024 16:28:37 +0100 Subject: [PATCH 137/866] Add ROI filters to PathObjectTools More quickly identify objects completely covered by a ROI, intersecting a ROI, or with centroids that fall within a ROI. Also remove some deprecated legacy methods. --- .../qupath/lib/objects/PathObjectTools.java | 212 +++++++----------- 1 file changed, 83 insertions(+), 129 deletions(-) diff --git a/qupath-core/src/main/java/qupath/lib/objects/PathObjectTools.java b/qupath-core/src/main/java/qupath/lib/objects/PathObjectTools.java index 0011dafee..d2926cf6e 100644 --- a/qupath-core/src/main/java/qupath/lib/objects/PathObjectTools.java +++ b/qupath-core/src/main/java/qupath/lib/objects/PathObjectTools.java @@ -4,7 +4,7 @@ * %% * Copyright (C) 2014 - 2016 The Queen's University of Belfast, Northern Ireland * Contact: IP Management (ipmanagement@qub.ac.uk) - * Copyright (C) 2018 - 2023 QuPath developers, The University of Edinburgh + * Copyright (C) 2018 - 2024 QuPath developers, The University of Edinburgh * %% * QuPath is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as @@ -86,11 +86,7 @@ public class PathObjectTools { */ private static void removePoints(Collection pathObjects) { logger.trace("Remove points"); - Iterator iter = pathObjects.iterator(); - while (iter.hasNext()) { - if (hasPointROI(iter.next())) - iter.remove(); - } + pathObjects.removeIf(PathObjectTools::hasPointROI); } /** @@ -193,8 +189,8 @@ public static List getPointObjects(Collection pathO private static class ImageRegionPredicate implements Predicate { - private ImageRegion region; - private PreparedGeometry geometry; + private final ImageRegion region; + private final PreparedGeometry geometry; ImageRegionPredicate(ImageRegion region) { this.region = region; @@ -246,111 +242,7 @@ public static int countDescendants(final PathObject pathObject) { return pathObject.nDescendants(); } - - - /** - * Test whether one ROI is can completely contain a second ROI. - * Returns false if either ROI is null. - *

    - * Note: This is not a perfect test, since it really only checks if the vertices of the child ROI fall within the parent - it is possible - * that connecting lines stray outside the parent, yet it still returns true. This behavior may change in later versions. - *

    - * TODO: Consider improving 'containsROI' method accuracy. - * - * @param parentROI - * @param childROI - * @return - */ - @Deprecated - public static boolean containsROI(final ROI parentROI, final ROI childROI) { - // Check for nulls... just to be sure - if (parentROI == null || childROI == null || !parentROI.isArea() || childROI.isEmpty() || parentROI.isEmpty()) - return false; - - // Check points - if (childROI != null && childROI.isPoint()) { - for (Point2 p : childROI.getAllPoints()) { - if (!parentROI.contains(p.getX(), p.getY())) - return false; - } - return true; - } - - // Check areas - child can't have a larger area - if (childROI.isArea()) { - if (childROI.getArea() > parentROI.getArea()) - return false; - } - - // Check bounds dimensions - if (childROI.getBoundsWidth() > parentROI.getBoundsWidth() || childROI.getBoundsHeight() > parentROI.getBoundsHeight()) - return false; - // Check bounds - double px = parentROI.getBoundsX(); - double py = parentROI.getBoundsY(); - double px2 = px + parentROI.getBoundsWidth(); - double py2 = py + parentROI.getBoundsHeight(); - double cx = childROI.getBoundsX(); - double cy = childROI.getBoundsY(); - double cx2 = px + childROI.getBoundsWidth(); - double cy2 = py + childROI.getBoundsHeight(); - if (!(cx >= px && cx2 <= px2 && cy >= py && cy2 <= py2)) - return false; - - // Check shapes - for (Point2 p : childROI.getAllPoints()) { - if (!parentROI.contains(p.getX(), p.getY())) - return false; - } - - if (parentROI.isArea() && childROI.isArea()) - return parentROI.getGeometry().covers(childROI.getGeometry()); - -// List points = parentArea.getPolygonPoints(); -// for (Point p : childROI.getPolygonPoints()) { -// int windingNumber = WindingTest.getWindingNumber(points, p.getX(), p.getY()); -// // logger.info("Winding number: " + windingNumber); -// if (windingNumber == 0) -// return false; -// } - return true; -// logger.info("Doing standard (AWT) test..."); -// return PathROIToolsAwt.containsShape(parentArea, (PathShape)childROI); - -// return false; - - // // Check for lines - // if (childROI instanceof PathLineROI) { - // PathLineROI line = (PathLineROI)childROI; - // return parentROI.contains(line.getX1(), line.getY1()) && parentROI.contains(line.getX2(), line.getY2()); - // } - // - // // If we have areas, check these - // if (parentROI instanceof PathArea && childROI instanceof PathArea) { - // double area = ((PathArea)parentROI).getArea(); - // if (!Double.isNaN(area) && area < ((PathArea)childROI).getArea()) - // return false; - // } - // - // // Check bounds - // // if (!parentROI.getBounds2D().contains(childROI.getBounds2D()) - // Rectangle2D childBounds = childROI.getBounds2D(); - // if (!parentROI.intersects(childBounds)) - // return false; - // - // // If we have shapes, do a proper test - // if ((parentROI instanceof PathShape) && (childROI instanceof PathShape)) { - // PathShape parentShape = (PathShape)parentROI; - // if (parentShape.contains(childBounds)) - // return true; - // PathShape childShape = (PathShape)childROI; - // Area areaDifference = parentShape.getShapeAsArea(); - // areaDifference.subtract(childShape.getShapeAsArea()); - // return areaDifference.isEmpty(); - // } - // return true; - } /** * Get all the objects with ROIs that are outside the bounds of an image. @@ -402,8 +294,84 @@ public static List findObjectsOutsideRegion(Collection !checkRegionContainsROI(p.getROI(), region, minZ, maxZ, minT, maxT, ignoreIntersecting)) .collect(Collectors.toList()); } - - + + /** + * Filter a collection of PathObjects to identify those that have ROIs that are covered by a specified ROI. + * @param roi the ROI to test against + * @param pathObjects the objects to filter + * @return a new collection that contains only the objects that are covered by the ROI + * @param + */ + public static Collection filterByRoiCovers(ROI roi, Collection pathObjects) { + Predicate predicate; + var geom = roi.getGeometry(); + if (pathObjects.size() > 1) { + var prepared = PreparedGeometryFactory.prepare(geom); + predicate = createGeometryPredicate(prepared::covers); + } else { + predicate = createGeometryPredicate(geom::covers); + } + return filterByRoiPredicate(roi, predicate, pathObjects); + } + + /** + * Filter a collection of PathObjects to identify those that intersect with a specified ROI. + * @param roi the ROI to test against + * @param pathObjects the objects to filter + * @return a new collection that contains only the objects that intersect with the ROI + * @param + */ + public static Collection filterByRoiIntersects(ROI roi, Collection pathObjects) { + Predicate predicate; + var geom = roi.getGeometry(); + if (pathObjects.size() > 1) { + var prepared = PreparedGeometryFactory.prepare(geom); + predicate = createGeometryPredicate(prepared::intersects); + } else { + predicate = createGeometryPredicate(geom::intersects); + } + return filterByRoiPredicate(roi, predicate, pathObjects); + } + + /** + * Filter a collection of PathObjects using a ROI predicate. + * This also tests that the object *has* a ROI, and that it is on the same place as the provided ROI. + * @param roi the main ROI to test against + * @param predicate any additional test to perform + * @param pathObjects the collection to filter + * @return + * @param + */ + private static Collection filterByRoiPredicate(ROI roi, Predicate predicate, Collection pathObjects) { + var planePredicate = createPlanePredicate(roi.getImagePlane()); + return pathObjects + .parallelStream() + .filter(PathObject::hasROI) + .filter(planePredicate) + .filter(predicate) + .collect(Collectors.toList()); + } + + private static Predicate createPlanePredicate(ImagePlane plane) { + return p -> p.getROI().getZ() == plane.getZ() && p.getROI().getT() == plane.getT(); + } + + private static Predicate createGeometryPredicate(Predicate predicate) { + return (PathObject p) -> predicate.test(p.getROI().getGeometry()); + } + + /** + * Filter a collection of PathObjects to identify those with centroids that fall within specified ROI. + * @param roi the ROI to test against + * @param pathObjects the objects to filter + * @return a new collection that contains only the objects with centroids that fall within the ROI + * @param + */ + public static Collection filterByRoiContainsCentroid(ROI roi, Collection pathObjects) { + return filterByRoiPredicate(roi, p -> roi.contains(p.getROI().getCentroidX(), p.getROI().getCentroidY()), pathObjects); + } + + private static boolean checkRegionContainsROI(ROI roi, ImageRegion region, int minZ, int maxZ, int minT, int maxT, boolean ignoreIntersecting) { if (roi == null) return false; @@ -519,21 +487,7 @@ public static String getSuitableName(Class cls, boolean ma return cls.getSimpleName(); } - - /** - * Test whether the ROI associated with one object can completely the ROI of a second object. - * Returns false if either ROI is null. - * - * @param parentObject - * @param childObject - * @return - */ - @Deprecated - public static boolean containsObject(PathObject parentObject, PathObject childObject) { - if (parentObject == null || childObject == null) - return false; - return containsROI(parentObject.getROI(), childObject.getROI()); - } + /** * Query if one object is the ancestor of another. From b2b5032de28909427ecca29e65de9ce6090eb3f7 Mon Sep 17 00:00:00 2001 From: petebankhead Date: Thu, 1 Aug 2024 16:30:37 +0100 Subject: [PATCH 138/866] Faster GeometryROI.contains(x,y) for complex ROIs Use `IndexedPointInAreaLocator` for complex geometry ROIs. Note that this currently uses a simple 'guess' for when to use the locator. If used too often, it makes performance *worse*, but if used for especially complex ROIs if can make performance *much better*. --- .../main/java/qupath/lib/roi/GeometryROI.java | 40 ++++++++++++------- 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/qupath-core/src/main/java/qupath/lib/roi/GeometryROI.java b/qupath-core/src/main/java/qupath/lib/roi/GeometryROI.java index 37d8095d3..787ae6690 100644 --- a/qupath-core/src/main/java/qupath/lib/roi/GeometryROI.java +++ b/qupath-core/src/main/java/qupath/lib/roi/GeometryROI.java @@ -27,11 +27,15 @@ import java.io.Serializable; import java.util.Arrays; import java.util.List; + +import org.locationtech.jts.algorithm.locate.IndexedPointInAreaLocator; +import org.locationtech.jts.algorithm.locate.PointOnGeometryLocator; import org.locationtech.jts.algorithm.locate.SimplePointInAreaLocator; import org.locationtech.jts.geom.Coordinate; import org.locationtech.jts.geom.Geometry; import org.locationtech.jts.geom.Lineal; import org.locationtech.jts.geom.Location; +import org.locationtech.jts.geom.Polygonal; import org.locationtech.jts.geom.Puntal; import org.locationtech.jts.geom.util.AffineTransformation; import org.locationtech.jts.io.ParseException; @@ -68,7 +72,12 @@ public class GeometryROI extends AbstractPathROI implements Serializable { private transient GeometryStats stats = null; private transient Shape shape = null; - + + /** + * Cache a locator for faster 'contains' checks. + */ + private transient PointOnGeometryLocator cachedLocator; + /** * Create a GeometryROI, without checking for validity. * @param geometry @@ -83,15 +92,18 @@ public class GeometryROI extends AbstractPathROI implements Serializable { * @param geometry * @param plane * @param checkValid if true, check the Geometry is valid before computing measurements. - * Because the validity check can be (very) slow, it may be desireable to skip it if not needed. + * Because the validity check can be (very) slow, it may be desirable to skip it if not needed. */ GeometryROI(Geometry geometry, ImagePlane plane, boolean checkValid) { super(plane); this.checkValid = checkValid; this.geometry = geometry.copy(); -// this.stats = computeGeometryStats(geometry, 1, 1); -// if (!stats.isValid()) -// logger.warn("Creating invalid geometry: {}", stats.getError()); + // We can cache a locator for 'contains' checks now because it is built lazily anyway + // but we only want it for large polygonal geometries + if (geometry instanceof Polygonal && geometry.getNumPoints() > 1000) { + logger.trace("Creating IndexedPointInAreaLocator for large geometry"); + cachedLocator = new IndexedPointInAreaLocator(geometry); + } } @Override @@ -212,10 +224,14 @@ public double getScaledLength(double pixelWidth, double pixelHeight) { @Override public boolean contains(double x, double y) { - if (isArea()) - return SimplePointInAreaLocator.locate( - new Coordinate(x, y), geometry) != Location.EXTERIOR; - else + if (isArea()) { + var coord = new Coordinate(x, y); + if (cachedLocator != null) { + return cachedLocator.locate(coord) != Location.EXTERIOR; + } else { + return SimplePointInAreaLocator.locate(coord, geometry) != Location.EXTERIOR; + } + } else return false; } @@ -227,12 +243,6 @@ public ROI translate(double dx, double dy) { private Object writeReplace() { // This relies on JTS serialization return new WKBSerializationProxy(this); -// // Try to preserve areas as they were... but we need to use JTS serialization for others -// if (isArea()) { -// AreaROI roi = new AreaROI(RoiTools.getVertices(getShape()), ImagePlane.getPlaneWithChannel(c, z, t)); -// return roi; -// } else -// return this; } From 73b4ce61698d2eddaac3e0c8014ac2cbc4783aef Mon Sep 17 00:00:00 2001 From: Pete Date: Thu, 1 Aug 2024 17:11:56 +0100 Subject: [PATCH 139/866] Simplify code, update readme * Simplify `TileClassificationsToAnnotationsPlugin` to use `RoiTools` rather than very involved calculations to merge/split tiles (this also seems to improve performance. * Remove two old deprecated classes These changes were provoked by removing `PathObjectTools.containsROI(parent, child)`, which was deprecated but still used here. --- CHANGELOG.md | 2 + ...ileClassificationsToAnnotationsPlugin.java | 60 +-- .../qupath/opencv/DetectCytokeratinCV.java | 399 -------------- .../java/qupath/opencv/WatershedNucleiCV.java | 503 ------------------ .../process/gui/ProcessingExtension.java | 1 - 5 files changed, 10 insertions(+), 955 deletions(-) delete mode 100644 qupath-core-processing/src/main/java/qupath/opencv/DetectCytokeratinCV.java delete mode 100644 qupath-core-processing/src/main/java/qupath/opencv/WatershedNucleiCV.java diff --git a/CHANGELOG.md b/CHANGELOG.md index da4e9a0d3..6aa937a4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ This is a *work in progress* for the next major release. * Images remain sorted after adding new metadata values * A warning indicator is shown if image files are missing * Use the 'Skip file checks in projects' preference if you need to turn this off (e.g. your images are on a slow network) +* Simplify `TileClassificationsToAnnotationsPlugin` implementation (https://github.com/qupath/qupath/pull/1563) +* Add methods to `PathObjectHierarchy` to simplify requesting objects for regions (https://github.com/qupath/qupath/pull/1563) ### Bugs fixed * Tile export to .ome.tif can convert to 8-bit unnecessarily (https://github.com/qupath/qupath/issues/1494) diff --git a/qupath-core-processing/src/main/java/qupath/lib/plugins/objects/TileClassificationsToAnnotationsPlugin.java b/qupath-core-processing/src/main/java/qupath/lib/plugins/objects/TileClassificationsToAnnotationsPlugin.java index 8b6466b65..35ff0aff4 100644 --- a/qupath-core-processing/src/main/java/qupath/lib/plugins/objects/TileClassificationsToAnnotationsPlugin.java +++ b/qupath-core-processing/src/main/java/qupath/lib/plugins/objects/TileClassificationsToAnnotationsPlugin.java @@ -4,7 +4,7 @@ * %% * Copyright (C) 2014 - 2016 The Queen's University of Belfast, Northern Ireland * Contact: IP Management (ipmanagement@qub.ac.uk) - * Copyright (C) 2018 - 2023 QuPath developers, The University of Edinburgh + * Copyright (C) 2018 - 2024 QuPath developers, The University of Edinburgh * %% * QuPath is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as @@ -23,8 +23,6 @@ package qupath.lib.plugins.objects; -import java.awt.geom.Area; -import java.awt.geom.Path2D; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; @@ -51,9 +49,7 @@ import qupath.lib.plugins.AbstractDetectionPlugin; import qupath.lib.plugins.PathTask; import qupath.lib.plugins.parameters.ParameterList; -import qupath.lib.regions.ImagePlane; import qupath.lib.roi.RoiTools; -import qupath.lib.roi.PolygonROI; import qupath.lib.roi.interfaces.ROI; /** @@ -192,24 +188,15 @@ public void run() { PathObject pathSingleAnnotation = null; List tiles = new ArrayList<>(); if (pathClass != null && !PathClassTools.isIgnoredClass(pathClass)) { - Path2D path = null; + List roisToMerge = new ArrayList<>(); for (PathObject pathObject : parentObject.getChildObjectsAsArray()) { if ((pathObject instanceof PathTileObject) && (RoiTools.isShapeROI(pathObject.getROI())) && pathClass.equals(pathObject.getPathClass())) { - ROI pathShape = pathObject.getROI(); - if (path == null) - path = new Path2D.Float(RoiTools.getShape(pathShape)); - else - path.append(RoiTools.getShape(pathShape), false); + roisToMerge.add(pathObject.getROI()); tiles.add(pathObject); } } if (!tiles.isEmpty()) { - ROI pathROINew = null; - ROI parentROI = parentObject.getROI(); - if (parentROI != null) - pathROINew = RoiTools.getShapeROI(new Area(path), parentROI.getImagePlane()); - else - pathROINew = RoiTools.getShapeROI(new Area(path), ImagePlane.getDefaultPlane()); + ROI pathROINew = RoiTools.union(roisToMerge); pathSingleAnnotation = PathObjects.createAnnotationObject(pathROINew, pathClass); if (!deleteTiles) pathSingleAnnotation.addChildObjects(tiles); @@ -222,44 +209,13 @@ public void run() { // Split if necessary if (doSplit) { - ROI pathShape = pathSingleAnnotation.getROI(); - Area area = RoiTools.getArea(pathShape); - if (area.isSingular()) { - pathAnnotations.add(pathSingleAnnotation); -// resultsString = "Created 1 annotation from " + tiles.size() + " tiles: " + pathSingleAnnotation; - } - else { - PolygonROI[][] polygons = RoiTools.splitAreaToPolygons(area, pathShape.getC(), pathShape.getZ(), pathShape.getT()); - for (PolygonROI poly : polygons[1]) { - ROI shape = poly; - Iterator iter = tiles.iterator(); - List children = new ArrayList<>(); - if (!deleteTiles) { - while (iter.hasNext()) { - PathObject next = iter.next(); - ROI roi = next.getROI(); - if (poly.contains(roi.getCentroidX(), roi.getCentroidY())) { - iter.remove(); - children.add(next); - } - } - } - - for (PolygonROI hole : polygons[0]) { - if (PathObjectTools.containsROI(poly, hole)) - shape = RoiTools.combineROIs(shape, hole, RoiTools.CombineOp.SUBTRACT); - } -// PathObjectTools.containsObject(pathSingleAnnotation, childObject) - PathObject annotation = PathObjects.createAnnotationObject(shape, pathClass); - if (!deleteTiles) - annotation.addChildObjects(children); - pathAnnotations.add(annotation); - } - } + RoiTools.splitROI(pathSingleAnnotation.getROI()) + .stream() + .map(p -> PathObjects.createAnnotationObject(p, pathClass)) + .forEach(pathAnnotations::add); } else { pathAnnotations.add(pathSingleAnnotation); -// resultsString = "Created annotation from " + tiles.size() + " tiles: " + pathSingleAnnotation; } } diff --git a/qupath-core-processing/src/main/java/qupath/opencv/DetectCytokeratinCV.java b/qupath-core-processing/src/main/java/qupath/opencv/DetectCytokeratinCV.java deleted file mode 100644 index b75168663..000000000 --- a/qupath-core-processing/src/main/java/qupath/opencv/DetectCytokeratinCV.java +++ /dev/null @@ -1,399 +0,0 @@ -/*- - * #%L - * This file is part of QuPath. - * %% - * Copyright (C) 2014 - 2016 The Queen's University of Belfast, Northern Ireland - * Contact: IP Management (ipmanagement@qub.ac.uk) - * Copyright (C) 2018 - 2020 QuPath developers, The University of Edinburgh - * %% - * QuPath 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 3 of the - * License, or (at your option) any later version. - * - * QuPath 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 QuPath. If not, see . - * #L% - */ - -package qupath.opencv; - - -import java.awt.geom.AffineTransform; -import java.awt.geom.Area; -import java.awt.geom.Path2D; -import java.awt.image.BufferedImage; -import java.io.IOException; -import java.util.ArrayList; -import java.util.Collection; -import java.util.Collections; -import java.util.List; - -import static org.bytedeco.opencv.global.opencv_core.*; -import org.bytedeco.opencv.global.opencv_imgproc; -import org.bytedeco.javacpp.indexer.Indexer; -import org.bytedeco.javacpp.indexer.IntIndexer; -import org.bytedeco.opencv.opencv_core.Mat; -import org.bytedeco.opencv.opencv_core.MatVector; -import org.bytedeco.opencv.opencv_core.Scalar; -import org.bytedeco.opencv.opencv_core.Size; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import qupath.lib.color.ColorDeconvolutionStains; -import qupath.lib.color.ColorTransformer; -import qupath.lib.color.ColorTransformer.ColorTransformMethod; -import qupath.lib.common.GeneralTools; -import qupath.lib.images.ImageData; -import qupath.lib.images.servers.PixelCalibration; -import qupath.lib.objects.PathObject; -import qupath.lib.objects.PathObjects; -import qupath.lib.objects.classes.PathClass; -import qupath.lib.plugins.AbstractDetectionPlugin; -import qupath.lib.plugins.DetectionPluginTools; -import qupath.lib.plugins.ObjectDetector; -import qupath.lib.plugins.parameters.ParameterList; -import qupath.lib.regions.RegionRequest; -import qupath.lib.roi.RoiTools; -import qupath.lib.roi.RectangleROI; -import qupath.lib.roi.ShapeSimplifier; -import qupath.lib.roi.interfaces.ROI; -import qupath.opencv.tools.OpenCVTools; - -/** - * Simple command to detect tumor regions stained with cytokeratin. - * - * @author Pete Bankhead - * - */ -public class DetectCytokeratinCV extends AbstractDetectionPlugin { - - private static final Logger logger = LoggerFactory.getLogger(DetectCytokeratinCV.class); - - private transient CytokeratinDetector detector; - - - static class CytokeratinDetector implements ObjectDetector { - - // TODO: REQUEST DOWNSAMPLE IN PLUGINS - private List< PathObject> pathObjects = new ArrayList<>(); - - private transient RegionRequest lastRequest = null; - private transient BufferedImage img = null; - - private String lastResultsDescription = null; - - - @Override - public Collection runDetection(final ImageData imageData, ParameterList params, ROI pathROI) throws IOException { - // Reset any detected objects - pathObjects.clear(); - - - // Parse parameters - double downsample = Math.max(1, params.getIntParameterValue("downsampleFactor")); - - // .addIntParameter("downsampleFactor", "Downsample factor", 2, "", 1, 8, "Amount to downsample image prior to detection - higher values lead to smaller images (and faster but less accurate processing)") - // .addDoubleParameter("gaussianSigmaMicrons", "Gaussian sigma", 5, GeneralTools.micrometerSymbol(), "Gaussian filter size - higher values give a smoother (less-detailed) result") - // .addDoubleParameter("thresholdTissue", "Tissue threshold", 0.1, "OD units", "Threshold to use for tissue detection (used to create stroma annotation)") - // .addDoubleParameter("thresholdDAB", "DAB threshold", 0.1, "OD units", "Threshold to use for cytokeratin detection (used to create tumour annotation)") - // .addIntParameter("separationDistanceMicrons", "Separation distance", 1, GeneralTools.micrometerSymbol(), "Approximate space to create between tumour & stroma classes when they occur side-by-side"); - - - double thresholdTissue = params.getDoubleParameterValue("thresholdTissue"); - double thresholdDAB = params.getDoubleParameterValue("thresholdDAB"); - double gaussianSigmaMicrons = params.getDoubleParameterValue("gaussianSigmaMicrons"); - double separationDistanceMicrons = params.getDoubleParameterValue("separationDistanceMicrons"); - - // Derive more useful values - PixelCalibration cal = imageData.getServer().getPixelCalibration(); - double pixelSize = cal.getAveragedPixelSizeMicrons() * downsample; - double gaussianSigma = gaussianSigmaMicrons / pixelSize; - int separationDiameter = 0; - if (separationDistanceMicrons > 0) { - separationDiameter = (int)(separationDistanceMicrons / pixelSize * 2 + .5); - // Ensure we have an odd value or zero (will be used for filter size if non-zero) - if (separationDiameter > 0 && separationDiameter % 2 == 0) - separationDiameter++; - } - - // Read the image, if necessary - RegionRequest request = RegionRequest.createInstance(imageData.getServerPath(), downsample, pathROI); - if (img == null || !request.equals(lastRequest)) { - img = imageData.getServer().readRegion(request); - lastRequest = request; - } - - int w = img.getWidth(); - int h = img.getHeight(); - - // Extract the color deconvolved channels - // TODO: Support alternative stain vectors - if (!imageData.isBrightfield()) { - logger.error("Only brightfield images are supported!"); - return Collections.emptyList(); - } - ColorDeconvolutionStains stains = imageData.getColorDeconvolutionStains(); - // Since we relaxed the strict rule this needs to be H-DAB, at least print a warning if it is not - if (!stains.isH_DAB()) { - logger.warn("{} was originally designed for H-DAB staining - here, {} will be used in place of hematoxylin and {} in place of DAB", - this.getClass().getSimpleName(), stains.getStain(1).getName(), stains.getStain(2).getName()); - } - int[] rgb = img.getRGB(0, 0, w, h, null, 0, w); - - float[] pxHematoxylin = ColorTransformer.getTransformedPixels(rgb, ColorTransformMethod.Stain_1, null, stains); - float[] pxDAB = ColorTransformer.getTransformedPixels(rgb, ColorTransformMethod.Stain_2, null, stains); - -// float[] pxHematoxylin = ColorDeconvolution.colorDeconvolveRGBArray(rgb, stains, 0, null); -// float[] pxDAB = ColorDeconvolution.colorDeconvolveRGBArray(rgb, stains, 1, null); - - // Create OpenCV Mats - Mat matOD = new Mat(h, w, CV_32FC1); - Mat matDAB = new Mat(h, w, CV_32FC1); - OpenCVTools.putPixelsFloat(matOD, pxHematoxylin); - OpenCVTools.putPixelsFloat(matDAB, pxDAB); - - // Add the DAB to the haematoxylin values - add(matOD, matDAB, matOD); - - // If the third channel isn't a residual channel, add it too - if (!stains.getStain(3).isResidual()) { - float[] pxThird = ColorTransformer.getTransformedPixels(rgb, ColorTransformMethod.Stain_3, null, stains); -// float[] pxThird = ColorDeconvolution.colorDeconvolveRGBArray(rgb, stains, 2, null); - Mat matThird = new Mat(h, w, CV_32FC1); - OpenCVTools.putPixelsFloat(matThird, pxThird); - add(matOD, matThird, matOD); - } - - // Apply Gaussian filter - Size gaussSize = new Size(); - opencv_imgproc.GaussianBlur(matOD, matOD, gaussSize, gaussianSigma); - opencv_imgproc.GaussianBlur(matDAB, matDAB, gaussSize, gaussianSigma); - - // Threshold - Mat matBinaryTissue = new Mat(); - if (thresholdTissue > 0) - compare(matOD, new Mat(1, 1, CV_32FC1, Scalar.all(thresholdTissue)), matBinaryTissue, CMP_GT); - Mat matBinaryDAB = new Mat(); - if (thresholdDAB > 0) - compare(matDAB, new Mat(1, 1, CV_32FC1, Scalar.all(thresholdDAB)), matBinaryDAB, CMP_GT); - - // Ensure everything in the DAB image is removed from the tissue image - if (!matBinaryTissue.empty() && !matBinaryDAB.empty()) - subtract(matBinaryTissue, matBinaryDAB, matBinaryTissue); - - // Cleanup as required - if (separationDiameter > 0 && !matBinaryTissue.empty() && !matBinaryDAB.empty()) { - Mat strel = opencv_imgproc.getStructuringElement(opencv_imgproc.MORPH_ELLIPSE, new Size(separationDiameter, separationDiameter)); - opencv_imgproc.erode(matBinaryTissue, matBinaryTissue, strel); - opencv_imgproc.erode(matBinaryDAB, matBinaryDAB, strel); - } - - - - - - Area areaTissue = getArea(matBinaryTissue); - Area areaDAB = getArea(matBinaryDAB); - AffineTransform transform = AffineTransform.getTranslateInstance(request.getX(), request.getY()); - transform.scale(downsample, downsample); - - Area areaROI = null; - if (pathROI != null && !(pathROI instanceof RectangleROI)) { - areaROI = RoiTools.getArea(pathROI); - } - - - double simplifyAmount = downsample * 1.5; // May want to revise this... - if (areaTissue != null) { - areaTissue = areaTissue.createTransformedArea(transform); - if (areaROI != null) - areaTissue.intersect(areaROI); - - if (!areaTissue.isEmpty()) { - ROI roiTissue = RoiTools.getShapeROI(areaTissue, request.getImagePlane()); - roiTissue = ShapeSimplifier.simplifyShape(roiTissue, simplifyAmount); - pathObjects.add(PathObjects.createAnnotationObject(roiTissue, PathClass.StandardPathClasses.STROMA)); - } - } - if (areaDAB != null) { - areaDAB = areaDAB.createTransformedArea(transform); - if (areaROI != null) - areaDAB.intersect(areaROI); - - if (!areaDAB.isEmpty()) { - ROI roiDAB = RoiTools.getShapeROI(areaDAB, request.getImagePlane()); - roiDAB = ShapeSimplifier.simplifyShape(roiDAB, simplifyAmount); - pathObjects.add(PathObjects.createAnnotationObject(roiDAB, PathClass.StandardPathClasses.TUMOR)); - } - } - - - matOD.close(); - matDAB.close(); - matBinaryDAB.close(); - matBinaryTissue.close(); - - lastResultsDescription = String.format("Detected %s", pathObjects.toString()); - - return pathObjects; - } - - - - @Override - public String getLastResultsDescription() { - return lastResultsDescription; - } - - - } - - - - /** - * Get an Area object corresponding to contours in a binary image from OpenCV. - * @param mat - * @return - */ - private static Area getArea(final Mat mat) { - if (mat.empty()) - return null; - - // Identify all contours - MatVector contours = new MatVector(); - Mat hierarchy = new Mat(); - opencv_imgproc.findContours(mat, contours, hierarchy, opencv_imgproc.RETR_TREE, opencv_imgproc.CHAIN_APPROX_SIMPLE); - if (contours.empty()) { - hierarchy.close(); - return null; - } - - Area area = new Area(); - updateArea(contours, hierarchy, area, 0, 0); - - hierarchy.close(); - - return area; - } - - - - private static void updateArea(final MatVector contours, final Mat hierarchy, final Area area, int row, int depth) { - IntIndexer indexer = hierarchy.createIndexer(); - while (row >= 0) { - int[] data = new int[4]; - // TODO: Check indexing after switch to JavaCPP!!! - indexer.get(0, row, data); -// hierarchy.get(0, row, data); - - Mat contour = contours.get(row); - - // Don't include isolated pixels - otherwise add or remove, as required - if (contour.rows() > 2) { - Path2D path = getContour(contour); - if (depth % 2 == 0) - area.add(new Area(path)); - else - area.subtract(new Area(path)); - } - - // Deal with any sub-contours - if (data[2] >= 0) - updateArea(contours, hierarchy, area, data[2], depth+1); - - // Move to next contour in this hierarchy level - row = data[0]; - } - } - - - - private static Path2D getContour(Mat contour) { - // Create a path for the contour - Path2D path = new Path2D.Float(); - boolean firstPoint = true; - Indexer indexer = contour.createIndexer(); - for (int r = 0; r < contour.rows(); r++) { - double px = indexer.getDouble(r, 0L, 0L); - double py = indexer.getDouble(r, 0L, 1L); - if (firstPoint) { - path.moveTo(px, py); - firstPoint = false; - } else { - path.lineTo(px, py); - } - } - return path; - } - - - - - @Override - public ParameterList getDefaultParameterList(final ImageData imageData) { - String stain2Name = imageData.getColorDeconvolutionStains() == null ? "DAB" : imageData.getColorDeconvolutionStains().getStain(2).getName(); - String stain2Prompt = stain2Name + " threshold"; - ParameterList params = new ParameterList() - .addIntParameter("downsampleFactor", "Downsample factor", 4, "", 1, 32, "Amount to downsample image prior to detection - higher values lead to smaller images (and faster but less accurate processing)") - .addDoubleParameter("gaussianSigmaMicrons", "Gaussian sigma", 5, GeneralTools.micrometerSymbol(), "Gaussian filter size - higher values give a smoother (less-detailed) result") - .addDoubleParameter("thresholdTissue", "Tissue threshold", 0.1, "OD units", "Threshold to use for tissue detection (used to create stroma annotation) - if zero, no stroma annotation is created") - .addDoubleParameter("thresholdDAB", stain2Prompt, 0.25, "OD units", "Threshold to use for cytokeratin detection (used to create tumor annotation) - if zero, no tumor annotation is created") - .addDoubleParameter("separationDistanceMicrons", "Separation distance", 0.5, GeneralTools.micrometerSymbol(), "Approximate space to create between tumour & stroma classes when they occur side-by-side"); - - // double thresholdTissue = 0.1; - // double thresholdDAB = 0.1; - // double gaussianSigmaMicrons = 5; - // int separationRadius = 1; - - // TODO: Support parameters properly! - // - // if (imageData.getServer().hasPixelSizeMicrons()) { - // String um = GeneralTools.micrometerSymbol(); - // params.addDoubleParameter("medianRadius", "Median radius", 1, um). - // addDoubleParameter("gaussianSigma", "Gaussian sigma", 1.5, um). - // addDoubleParameter("openingRadius", "Opening radius", 8, um). - // addDoubleParameter("threshold", "Threshold", 0.1, null, 0, 1.0). - // addDoubleParameter("minArea", "Minimum area", 25, um+"^2"); - // } else { - // params.addDoubleParameter("medianRadius", "Median radius", 1, "px"). - // addDoubleParameter("gaussianSigma", "Gaussian sigma", 2, "px"). - // addDoubleParameter("openingRadius", "Opening radius", 20, "px"). - // addDoubleParameter("threshold", "Threshold", 0.1, null, 0, 1.0). - // addDoubleParameter("minArea", "Minimum area", 100, "px^2"); - // } - // params.addBooleanParameter("splitShape", "Split by shape", true); - return params; - } - - @Override - public String getName() { - return "Cytokeratin annotation creation (TMA, IHC)"; - } - - @Override - public String getDescription() { - return "Create tumor/non-tumor annotations by thresholding a cytokeratin staining"; - } - - @Override - public String getLastResultsDescription() { - return detector == null ? "" : detector.getLastResultsDescription(); - } - - @Override - protected void addRunnableTasks(ImageData imageData, PathObject parentObject, List tasks) { - // if (detector == null) - detector = new CytokeratinDetector(); - tasks.add(DetectionPluginTools.createRunnableTask(detector, getParameterList(imageData), imageData, parentObject)); - } - - -} - - diff --git a/qupath-core-processing/src/main/java/qupath/opencv/WatershedNucleiCV.java b/qupath-core-processing/src/main/java/qupath/opencv/WatershedNucleiCV.java deleted file mode 100644 index f19da9419..000000000 --- a/qupath-core-processing/src/main/java/qupath/opencv/WatershedNucleiCV.java +++ /dev/null @@ -1,503 +0,0 @@ -/*- - * #%L - * This file is part of QuPath. - * %% - * Copyright (C) 2014 - 2016 The Queen's University of Belfast, Northern Ireland - * Contact: IP Management (ipmanagement@qub.ac.uk) - * Copyright (C) 2018 - 2020 QuPath developers, The University of Edinburgh - * %% - * QuPath 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 3 of the - * License, or (at your option) any later version. - * - * QuPath 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 QuPath. If not, see . - * #L% - */ - -package qupath.opencv; - - -import java.awt.Rectangle; -import java.awt.image.BufferedImage; -import java.io.IOException; -import java.util.ArrayList; -import java.util.Collection; -import java.util.List; - -import static org.bytedeco.opencv.global.opencv_core.*; -import org.bytedeco.opencv.global.opencv_imgproc; -import org.bytedeco.opencv.opencv_core.Mat; -import org.bytedeco.opencv.opencv_core.MatVector; -import org.bytedeco.opencv.opencv_core.Point; -import org.bytedeco.opencv.opencv_core.Rect; -import org.bytedeco.opencv.opencv_core.RotatedRect; -import org.bytedeco.opencv.opencv_core.Scalar; -import org.bytedeco.opencv.opencv_core.Size; -import org.bytedeco.opencv.opencv_core.Size2f; -import org.bytedeco.javacpp.indexer.Indexer; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import qupath.lib.analysis.stats.RunningStatistics; -import qupath.lib.awt.common.AwtTools; -import qupath.lib.color.ColorDeconvMatrix3x3; -import qupath.lib.color.ColorDeconvolutionHelper; -import qupath.lib.color.ColorDeconvolutionStains; -import qupath.lib.color.StainVector; -import qupath.lib.common.GeneralTools; -import qupath.lib.geom.Point2; -import qupath.lib.images.ImageData; -import qupath.lib.images.servers.ImageServer; -import qupath.lib.images.servers.PixelCalibration; -import qupath.lib.measurements.MeasurementList; -import qupath.lib.measurements.MeasurementListFactory; -import qupath.lib.objects.PathObject; -import qupath.lib.objects.PathObjectTools; -import qupath.lib.objects.PathObjects; -import qupath.lib.plugins.AbstractTileableDetectionPlugin; -import qupath.lib.plugins.ObjectDetector; -import qupath.lib.plugins.parameters.ParameterList; -import qupath.lib.regions.ImagePlane; -import qupath.lib.regions.RegionRequest; -import qupath.lib.roi.PolygonROI; -import qupath.lib.roi.ROIs; -import qupath.lib.roi.RectangleROI; -import qupath.lib.roi.RoiTools; -import qupath.lib.roi.interfaces.ROI; -import qupath.opencv.tools.OpenCVTools; -import qupath.opencv.tools.ProcessingCV; - -/** - * Alternative (incomplete) attempt at nucleus segmentation. - *

    - * It's reasonably fast... but not particularly good. - * - * @author Pete Bankhead - * - */ -public class WatershedNucleiCV extends AbstractTileableDetectionPlugin { - - private static Logger logger = LoggerFactory.getLogger(WatershedNucleiCV.class); - - private transient WatershedNuclei detector; - - - class WatershedNuclei implements ObjectDetector { - - // TODO: REQUEST DOWNSAMPLE IN PLUGINS - private List< PathObject> pathObjects = new ArrayList<>(); - - - @Override - public Collection runDetection(final ImageData imageData, ParameterList params, ROI pathROI) throws IOException { - // Reset any detected objects - pathObjects.clear(); - - boolean splitShape = params.getBooleanParameterValue("splitShape"); - // double downsample = params.getIntParameterValue("downsampleFactor"); - - PixelCalibration cal = imageData.getServer().getPixelCalibration(); - double downsample = cal.hasPixelSizeMicrons() ? - getPreferredPixelSizeMicrons(imageData, params) / cal.getAveragedPixelSizeMicrons() : - 1; - downsample = Math.max(downsample, 1); - - double threshold = params.getDoubleParameterValue("threshold"); - // Extract size-dependent parameters - int medianRadius, openingRadius; - double gaussianSigma, minArea; - ImageServer server = imageData.getServer(); - if (cal.hasPixelSizeMicrons()) { - double pixelSize = 0.5 * downsample * (cal.getPixelHeightMicrons() + cal.getPixelWidthMicrons()); - medianRadius = (int)(params.getDoubleParameterValue("medianRadius") / pixelSize + .5); - gaussianSigma = params.getDoubleParameterValue("gaussianSigma") / pixelSize; - openingRadius = (int)(params.getDoubleParameterValue("openingRadius") / pixelSize + .5); - minArea = params.getDoubleParameterValue("minArea") / (pixelSize * pixelSize); - logger.trace(String.format("Sizes: %d, %.2f, %d, %.2f", medianRadius, gaussianSigma, openingRadius, minArea)); - } else { - medianRadius = (int)(params.getDoubleParameterValue("medianRadius") + .5); - gaussianSigma = params.getDoubleParameterValue("gaussianSigma"); - openingRadius = (int)(params.getDoubleParameterValue("openingRadius") + .5); - minArea = params.getDoubleParameterValue("minArea"); - } - - // TODO: Avoid hard-coding downsample - Rectangle bounds = AwtTools.getBounds(pathROI); - double x = bounds.getX(); - double y = bounds.getY(); - - // logger.info("BOUNDS: " + bounds); - - // Read the buffered image - BufferedImage img = server.readRegion(RegionRequest.createInstance(server.getPath(), downsample, pathROI)); - - // Extract the color deconvolved channels - // TODO: Support alternative stain vectors - ColorDeconvolutionStains stains = imageData.getColorDeconvolutionStains(); - boolean isH_DAB = stains.isH_DAB(); - float[][] pxDeconvolved = colorDeconvolve(img, stains.getStain(1).getArray(), stains.getStain(2).getArray(), null, 2); - float[] pxHematoxylin = pxDeconvolved[0]; - float[] pxDAB = isH_DAB ? pxDeconvolved[1] : null; - - // Convert to OpenCV Mat - int width = img.getWidth(); - int height = img.getHeight(); - Mat mat = new Mat(height, width, CV_32FC1); - - // It seems OpenCV doesn't use the array directly, so no need to copy... - OpenCVTools.putPixelsFloat(mat, pxHematoxylin); - - Mat matBackground = new Mat(); - - opencv_imgproc.medianBlur(mat, mat, 1); - opencv_imgproc.GaussianBlur(mat, mat, new Size(5, 5), 0.75); - opencv_imgproc.morphologyEx(mat, matBackground, opencv_imgproc.MORPH_CLOSE, OpenCVTools.getCircularStructuringElement(1)); - ProcessingCV.morphologicalReconstruction(mat, matBackground); - - // Apply opening by reconstruction & subtraction to reduce background - opencv_imgproc.morphologyEx(mat, matBackground, opencv_imgproc.MORPH_OPEN, OpenCVTools.getCircularStructuringElement(openingRadius)); - ProcessingCV.morphologicalReconstruction(matBackground, mat); - subtract(mat, matBackground, mat); - - // Apply Gaussian filter - int gaussianWidth = (int)(Math.ceil(gaussianSigma * 3) * 2 + 1); - opencv_imgproc.GaussianBlur(mat, mat, new Size(gaussianWidth, gaussianWidth), gaussianSigma); - - // Apply Laplacian filter - Mat matLoG = matBackground; - opencv_imgproc.Laplacian(mat, matLoG, mat.depth(), 1, -1, 0, BORDER_DEFAULT); - - // Threshold - Mat matBinaryLoG = new Mat(); - compare(matLoG, new Mat(1, 1, CV_32FC1, Scalar.ZERO), matBinaryLoG, CMP_GT); - - // Watershed transform - Mat matBinary = matBinaryLoG.clone(); - OpenCVTools.watershedIntensitySplit(matBinary, matLoG, 0, 1); - - // Identify all contours - MatVector contours = new MatVector(); - opencv_imgproc.findContours(matBinary, contours, new Mat(), opencv_imgproc.RETR_EXTERNAL, opencv_imgproc.CHAIN_APPROX_SIMPLE); - - // Create a labelled image for each contour - Mat matLabels = new Mat(matBinary.size(), CV_32F, Scalar.ZERO); - List statsList = new ArrayList<>(); - int label = 0; - Point offset = new Point(0, 0); - for (int c = 0; c < contours.size(); c++) { - label++; - opencv_imgproc.drawContours(matLabels, contours, c, Scalar.all(label), -1, opencv_imgproc.LINE_8, null, Integer.MAX_VALUE, offset); - statsList.add(new RunningStatistics()); - } - // Compute mean for each contour, keep those that are sufficiently intense - float[] labels = new float[(int)matLabels.total()]; - OpenCVTools.extractPixels(matLabels, labels); - computeRunningStatistics(pxHematoxylin, labels, statsList); - int ind = 0; - Scalar color = Scalar.WHITE; - matBinary.put(Scalar.ZERO); - for (RunningStatistics stats : statsList) { - if (stats.getMean() > threshold) { - opencv_imgproc.drawContours(matBinary, contours, ind, color, -1, opencv_imgproc.LINE_8, null, Integer.MAX_VALUE, offset); - } - ind++; - } - - // Dilate binary image & extract remaining contours - opencv_imgproc.dilate(matBinary, matBinary, opencv_imgproc.getStructuringElement(opencv_imgproc.CV_SHAPE_RECT, new Size(3, 3))); - min(matBinary, matBinaryLoG, matBinary); - - OpenCVTools.fillSmallHoles(matBinary, minArea*4); - - // Split using distance transform, if necessary - if (splitShape) - watershedDistanceTransformSplit(matBinary, openingRadius/4); - - // Create path objects from contours - contours = new MatVector(); - Mat hierarchy = new Mat(); - opencv_imgproc.findContours(matBinary, contours, hierarchy, opencv_imgproc.RETR_EXTERNAL, opencv_imgproc.CHAIN_APPROX_SIMPLE); - ArrayList points = new ArrayList<>(); - - // Create label image - matLabels.put(Scalar.ZERO); - - // Update the labels to correspond with the contours, and compute statistics - label = 0; - List statsHematoxylinList = new ArrayList<>((int)contours.size()); - List statsDABList = new ArrayList<>((int)contours.size()); - for (int c = 0; c < contours.size(); c++){ - Mat contour = contours.get(c); - - // Discard single pixels / lines - if (contour.rows() <= 2) - continue; - - // Simplify the contour slightly - Mat contourApprox = new Mat(); - opencv_imgproc.approxPolyDP(contour, contourApprox, 0.5, true); - contour = contourApprox; - contours.put(c, contour); - - // Create a polygon ROI - points.clear(); - Indexer indexerContour = contour.createIndexer(); - for (int r = 0; r < contour.rows(); r++) { - double px = indexerContour.getDouble(r, 0L, 0L); - double py = indexerContour.getDouble(r, 0L, 1L); - points.add(new Point2(px * downsample + x, py * downsample + y)); - } - - // Add new polygon if it is contained within the ROI & measurable - PolygonROI pathPolygon = ROIs.createPolygonROI(points, ImagePlane.getPlaneWithChannel(pathROI)); - if (!(pathPolygon.getArea() >= minArea)) { - // Don't do a simpler < because we also want to discard the region if the area couldn't be measured (although this is unlikely) - continue; - } - - // logger.info("Area comparison: " + opencv_imgproc.contourArea(contour) + ",\t" + (pathPolygon.getArea() / downsample / downsample)); - // Mat matSmall = new Mat(); - if (pathROI instanceof RectangleROI || PathObjectTools.containsROI(pathROI, pathPolygon)) { - MeasurementList measurementList = MeasurementListFactory.createMeasurementList(20, MeasurementList.MeasurementListType.FLOAT); - PathObject pathObject = PathObjects.createDetectionObject(pathPolygon, null, measurementList); - - measurementList.put("Area", pathPolygon.getArea()); - measurementList.put("Perimeter", pathPolygon.getLength()); - measurementList.put("Circularity", RoiTools.getCircularity(pathPolygon)); - measurementList.put("Solidity", pathPolygon.getSolidity()); - - // I am making an assumption regarding square pixels here... - RotatedRect rrect = opencv_imgproc.minAreaRect(contour); - Size2f size = rrect.size(); - measurementList.put("Min axis", Math.min(size.width(), size.height()) * downsample); - measurementList.put("Max axis", Math.max(size.width(), size.height()) * downsample); - - // Store the object - pathObjects.add(pathObject); - - // Create a statistics object & paint a label in preparation for intensity stat computations later - label++; - statsHematoxylinList.add(new RunningStatistics()); - if (pxDAB != null) - statsDABList.add(new RunningStatistics()); - opencv_imgproc.drawContours(matLabels, contours, c, Scalar.all(label), -1, opencv_imgproc.LINE_8, null, Integer.MAX_VALUE, offset); - } - } - - // Compute intensity statistics - OpenCVTools.extractPixels(matLabels, labels); - computeRunningStatistics(pxHematoxylin, labels, statsHematoxylinList); - if (pxDAB != null) - computeRunningStatistics(pxDAB, labels, statsDABList); - ind = 0; - for (PathObject pathObject : pathObjects) { - MeasurementList measurementList = pathObject.getMeasurementList(); - RunningStatistics statsHaem = statsHematoxylinList.get(ind); - // pathObject.addMeasurement("Area (px)", statsHaem.nPixels() * downsample * downsample); - measurementList.put("Hematoxylin mean", statsHaem.getMean()); - measurementList.put("Hematoxylin std dev", statsHaem.getStdDev()); - measurementList.put("Hematoxylin min", statsHaem.getMin()); - measurementList.put("Hematoxylin max", statsHaem.getMax()); - measurementList.put("Hematoxylin range", statsHaem.getRange()); - - if (pxDAB != null) { - RunningStatistics statsDAB = statsDABList.get(ind); - measurementList.put("DAB mean", statsDAB.getMean()); - measurementList.put("DAB std dev", statsDAB.getStdDev()); - measurementList.put("DAB min", statsDAB.getMin()); - measurementList.put("DAB max", statsDAB.getMax()); - measurementList.put("DAB range", statsDAB.getRange()); - } - - measurementList.close(); - ind++; - } - logger.info("Found " + pathObjects.size() + " contours"); - - return pathObjects; - } - - - - - @Override - public String getLastResultsDescription() { - return String.format("Detected %d nuclei", pathObjects.size()); - } - - - } - - - private static void watershedDistanceTransformSplit(Mat matBinary, int maxFilterRadius) { - Mat matWatershedSeedsBinary; - - // Create a background mask - Mat matBackground = new Mat(); - compare(matBinary, new Mat(1, 1, CV_32FC1, Scalar.WHITE), matBackground, CMP_NE); - - // Separate by shape using the watershed transform - Mat matDistanceTransform = new Mat(); - opencv_imgproc.distanceTransform(matBinary, matDistanceTransform, opencv_imgproc.CV_DIST_L2, opencv_imgproc.CV_DIST_MASK_PRECISE); - // Find local maxima - matWatershedSeedsBinary = new Mat(); - opencv_imgproc.dilate(matDistanceTransform, matWatershedSeedsBinary, OpenCVTools.getCircularStructuringElement(maxFilterRadius)); - compare(matDistanceTransform, matWatershedSeedsBinary, matWatershedSeedsBinary, CMP_EQ); - matWatershedSeedsBinary.setTo(new Mat(1, 1, matWatershedSeedsBinary.type(), Scalar.ZERO), matBackground); - // Dilate slightly to merge nearby maxima - opencv_imgproc.dilate(matWatershedSeedsBinary, matWatershedSeedsBinary, OpenCVTools.getCircularStructuringElement(2)); - - // Create labels for watershed - Mat matLabels = new Mat(matDistanceTransform.size(), CV_32F, Scalar.ZERO); - OpenCVTools.labelImage(matWatershedSeedsBinary, matLabels, opencv_imgproc.RETR_CCOMP); - - // Remove everything outside the thresholded region - matLabels.setTo(new Mat(1, 1, matLabels.type(), Scalar.ZERO), matBackground); - - // Do watershed - // 8-connectivity is essential for the watershed lines to be preserved - otherwise OpenCV's findContours could not be used - ProcessingCV.doWatershed(matDistanceTransform, matLabels, 0.1, true); - - // Update the binary image to remove the watershed lines - multiply(matBinary, matLabels, matBinary, 1, matBinary.type()); - } - - @Override - public ParameterList getDefaultParameterList(final ImageData imageData) { - ParameterList params = new ParameterList(); - params.addDoubleParameter("preferredMicrons", "Preferred pixel size", 0.5, GeneralTools.micrometerSymbol(), - "Preferred image resolution for detection (higher values mean lower resolution)"); - // addIntParameter("downsampleFactor", "Downsample factor", 2, "", 1, 4); - - if (imageData.getServer().getPixelCalibration().hasPixelSizeMicrons()) { - String um = GeneralTools.micrometerSymbol(); - params.addDoubleParameter("medianRadius", "Median radius", 1, um, "Median filter radius"). - addDoubleParameter("gaussianSigma", "Gaussian sigma", 1.5, um, "Gaussian filter sigma"). - addDoubleParameter("openingRadius", "Opening radius", 8, um, "Morphological opening filter radius"). - addDoubleParameter("threshold", "Threshold", 0.1, null, 0, 1.0, "Intensity threshold"). - addDoubleParameter("minArea", "Minimum area", 25, um+"^2", "Minimum area threshold"); - } else { - params.setHiddenParameters(true, "preferredMicrons"); - params.addDoubleParameter("medianRadius", "Median radius", 1, "px", "Median filter radius"). - addDoubleParameter("gaussianSigma", "Gaussian sigma", 2, "px", "Gaussian filter sigma"). - addDoubleParameter("openingRadius", "Opening radius", 20, "px", "Morphological opening filter radius"). - addDoubleParameter("threshold", "Threshold", 0.1, null, 0, 1.0, "Intensity threshold"). - addDoubleParameter("minArea", "Minimum area", 100, "px^2", "Minimum area threshold"); - } - params.addBooleanParameter("splitShape", "Split by shape", true); - return params; - } - - @Override - public String getName() { - return "OpenCV nucleus experiment"; - } - - private RunningStatistics computeRunningStatistics(float[] pxIntensities, byte[] pxMask, int width, Rect bounds) { - RunningStatistics stats = new RunningStatistics(); - for (int i = 0; i < pxMask.length; i++) { - if (pxMask[i] == 0) - continue; - // Compute the image index - int x = i % bounds.width() + bounds.x(); - int y = i % bounds.width() + bounds.y(); - // Add the value - stats.addValue(pxIntensities[y * width + x]); - } - return stats; - } - - - - @Override - public String getLastResultsDescription() { - return detector == null ? "" : detector.getLastResultsDescription(); - } - - @Override - public String getDescription() { - return "Alternative nucleus detection"; - } - - - - // TODO: If this ever becomes important, switch to using the QuPath core implementation instead of this one - @Deprecated - private static float[][] colorDeconvolve(BufferedImage img, double[] stain1, double[] stain2, double[] stain3, int nStains) { - // TODO: Precompute the default matrix inversion - if (stain3 == null) - stain3 = StainVector.cross3(stain1, stain2); - double[][] stainMat = new double[][]{stain1, stain2, stain3}; - ColorDeconvMatrix3x3 mat3x3 = new ColorDeconvMatrix3x3(stainMat); - double[][] matInv = mat3x3.inverse(); - double[] stain1Inv = matInv[0]; - double[] stain2Inv = matInv[1]; - double[] stain3Inv = matInv[2]; - - // Extract the buffered image pixels - int[] buf = img.getRGB(0, 0, img.getWidth(), img.getHeight(), null, 0, img.getWidth()); - // Preallocate the output - float[][] output = new float[nStains][buf.length]; - - // Apply color deconvolution - double[] od_lut = ColorDeconvolutionHelper.makeODLUT(255, 256); - for (int i = 0; i < buf.length; i++) { - int c = buf[i]; - // Extract RGB values & convert to optical densities using a lookup table - double r = od_lut[(c & 0xff0000) >> 16]; - double g = od_lut[(c & 0xff00) >> 8]; - double b = od_lut[c & 0xff]; - // Apply deconvolution & store the results - for (int s = 0; s < nStains; s++) { - output[s][i] = (float)(r * stain1Inv[s] + g * stain2Inv[s] + b * stain3Inv[s]); - } - } - return output; - } - - @Override - protected double getPreferredPixelSizeMicrons(ImageData imageData, ParameterList params) { - PixelCalibration cal = imageData.getServer().getPixelCalibration(); - if (cal.hasPixelSizeMicrons()) - return Math.max(params.getDoubleParameterValue("preferredMicrons"), cal.getAveragedPixelSizeMicrons()); - return 0.5; - } - - @Override - protected ObjectDetector createDetector(ImageData imageData, ParameterList params) { - return new WatershedNuclei(); - } - - @Override - protected int getTileOverlap(ImageData imageData, ParameterList params) { - return 50; - } - - - - private static void computeRunningStatistics(float[] pxIntensities, float[] pxLabels, List statsList) { - float lastLabel = Float.NaN; - int nLabels = statsList.size(); - RunningStatistics stats = null; - for (int i = 0; i < pxIntensities.length; i++) { - float label = pxLabels[i]; - if (label == 0 || label > nLabels) - continue; - // Get a new statistics object if necessary - if (label != lastLabel) { - stats = statsList.get((int)label-1); - lastLabel = label; - } - // Add the value - stats.addValue(pxIntensities[i]); - } - } - -} \ No newline at end of file diff --git a/qupath-extension-processing/src/main/java/qupath/process/gui/ProcessingExtension.java b/qupath-extension-processing/src/main/java/qupath/process/gui/ProcessingExtension.java index cfbc2b7a0..04e202405 100644 --- a/qupath-extension-processing/src/main/java/qupath/process/gui/ProcessingExtension.java +++ b/qupath-extension-processing/src/main/java/qupath/process/gui/ProcessingExtension.java @@ -105,7 +105,6 @@ public static class OpenCVCommands { private OpenCVCommands(QuPathGUI qupath) { actionDelaunay = qupath.createPluginAction("Delaunay cluster features 2D", DelaunayClusteringPlugin.class, null); -// actionCytokeratin = qupath.createPluginAction("Create cytokeratin annotations (experimental)", DetectCytokeratinCV.class, null); actionFastCellCounts = qupath.createPluginAction("Fast cell counts (brightfield)", CellCountsCV.class, null); var densityMapCommand = new DensityMapCommand(qupath); actionDensityMap = qupath.createImageDataAction(imageData -> densityMapCommand.run()); From 59b633d22d3aba78f689067e149ba158f2eea3bd Mon Sep 17 00:00:00 2001 From: Pete Date: Thu, 1 Aug 2024 17:54:05 +0100 Subject: [PATCH 140/866] Add ROI filter tests --- .../hierarchy/PathObjectHierarchy.java | 38 +--------- .../lib/objects/TestPathObjectTools.java | 74 ++++++++++++++++--- 2 files changed, 65 insertions(+), 47 deletions(-) diff --git a/qupath-core/src/main/java/qupath/lib/objects/hierarchy/PathObjectHierarchy.java b/qupath-core/src/main/java/qupath/lib/objects/hierarchy/PathObjectHierarchy.java index 8e15e7463..c50e77694 100644 --- a/qupath-core/src/main/java/qupath/lib/objects/hierarchy/PathObjectHierarchy.java +++ b/qupath-core/src/main/java/qupath/lib/objects/hierarchy/PathObjectHierarchy.java @@ -78,26 +78,6 @@ public final class PathObjectHierarchy implements Serializable { private static final long serialVersionUID = 1L; private static final Logger logger = LoggerFactory.getLogger(PathObjectHierarchy.class); - - // TODO: Make this a choice - currently a cell object is considered 'inside' if its nucleus is fully contained (as cell boundaries themselves are a little more questionable) - /* - * TODO: Consider how to explain this... - * The idea is that cell nuclei are used to determine whether an object is 'inside' another object, - * which is important when adding annotations etc. to the object hierarchy. - * - * @return - */ - static boolean useCellNucleiForInsideTest = true; - /* - * TODO: Consider how to explain this... - * The idea is that tile centroids are used to determine whether an object is 'inside' another object, - * which is important when adding annotations etc. to the object hierarchy. - * - * @return - */ - static boolean useTileCentroidsForInsideTest = true; - - private TMAGrid tmaGrid = null; private PathObject rootObject = new PathRootObject(); @@ -193,20 +173,6 @@ public PathObjectSelectionModel getSelectionModel() { return selectionModel; } -// /** -// * Check if the hierarchy is changing. This can occur, for example, if a plugin is running -// * that modifies the hierarchy frequently, and so listeners may want to avoid responding to -// * events for performance reasons. -// * @return -// */ -// public boolean isChanging() { -// return changing; -// } -// -// public void setChanging(boolean changing) { -// this.changing = changing; -// } - /** * Set the tma grid for this hierarchy. * If identical to the current TMA grid, nothing will happen. @@ -837,7 +803,7 @@ public synchronized void setHierarchy(PathObjectHierarchy hierarchy) { /** * Get the objects within a specified ROI, as defined by the general rules for resolving the hierarchy. - * This relies on centroids for detections, and a 'covers' rule for others. + * This relies on centroids for detections (including subclasses), and a 'covers' rule for others (annotations, TMA cores). * * @param cls class of PathObjects (e.g. PathDetectionObject), or null to accept all * @param roi @@ -966,7 +932,7 @@ Collection filterObjectsForROI(ROI roi, Collection pathO * @param checkChannel * @return */ - static boolean samePlane(ROI roi1, ROI roi2, boolean checkChannel) { + private static boolean samePlane(ROI roi1, ROI roi2, boolean checkChannel) { if (checkChannel) return roi1.getImagePlane().equals(roi2.getImagePlane()); else diff --git a/qupath-core/src/test/java/qupath/lib/objects/TestPathObjectTools.java b/qupath-core/src/test/java/qupath/lib/objects/TestPathObjectTools.java index 60a2d7263..41a6370a3 100644 --- a/qupath-core/src/test/java/qupath/lib/objects/TestPathObjectTools.java +++ b/qupath-core/src/test/java/qupath/lib/objects/TestPathObjectTools.java @@ -2,7 +2,7 @@ * #%L * This file is part of QuPath. * %% - * Copyright (C) 2022 QuPath developers, The University of Edinburgh + * Copyright (C) 2022-2024 QuPath developers, The University of Edinburgh * %% * QuPath is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as @@ -19,8 +19,8 @@ * #L% */ -package qupath.lib.objects; - +package qupath.lib.objects; + import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotEquals; @@ -28,6 +28,7 @@ import java.util.Arrays; import java.util.Collections; import java.util.HashSet; +import java.util.List; import java.util.Set; import java.util.stream.Collectors; @@ -35,16 +36,17 @@ import com.google.common.collect.Streams; +import qupath.lib.geom.Point2; import qupath.lib.objects.classes.PathClass; import qupath.lib.regions.ImagePlane; import qupath.lib.regions.ImageRegion; import qupath.lib.roi.ROIs; -import qupath.lib.roi.interfaces.ROI; +import qupath.lib.roi.interfaces.ROI; -@SuppressWarnings("javadoc") -public class TestPathObjectTools extends TestPathObjectMethods { - - @Test +@SuppressWarnings("javadoc") +public class TestPathObjectTools extends TestPathObjectMethods { + + @Test public void test_BasicPO() { // Check that duplicating objects works @@ -69,7 +71,7 @@ public void test_BasicPO() { } } - var duplicateObjectsNewIds = pathObjects.stream().map(p -> PathObjectTools.transformObject(p, null, true, true)).toList(); + var duplicateObjectsNewIds = pathObjects.stream().map(p -> PathObjectTools.transformObject(p, null, true, true)).toList(); var duplicateObjectsSameIds = pathObjects.stream().map(p -> PathObjectTools.transformObject(p, null, true, false)).toList(); for (int i = 0; i < pathObjects.size(); i++) { @@ -169,7 +171,57 @@ public void testFindOutside() { private static Set createObjects(ROI...rois) { return Arrays.asList(rois).stream().map(r -> PathObjects.createDetectionObject(r)).collect(Collectors.toSet()); } + + + @Test + public void testRoiFilters() { + var roiRect = ROIs.createRectangleROI(1, 2, 100, 200, ImagePlane.getDefaultPlane()); + var roiEllipse = ROIs.createEllipseROI(1, 2, 100, 200, ImagePlane.getDefaultPlane()); + var roiDiamond = ROIs.createPolygonROI(List.of( + new Point2(roiRect.getBoundsX(), roiRect.getCentroidY()), + new Point2(roiRect.getCentroidX(), roiRect.getBoundsY()), + new Point2(roiRect.getBoundsX()+roiRect.getBoundsWidth(), roiRect.getCentroidY()), + new Point2(roiRect.getBoundsX(), roiRect.getBoundsY()+roiRect.getBoundsHeight()) + ), ImagePlane.getDefaultPlane()); + var roiOverlaps = roiRect.translate(10, 0); + var roiOverlaps2 = roiRect.translate(0, 10); + var roiSeparate = roiRect.translate(1000, 1000); + + var allRois = List.of(roiRect, roiEllipse, roiDiamond, roiOverlaps, roiOverlaps2, roiSeparate); + var pathObjects = allRois + .stream() + .map(r -> PathObjects.createDetectionObject(r)) + .toList(); + + // Note that we can test list equality since we don't expect any reordering + + // Test covers + assertEquals(List.of(roiRect, roiEllipse, roiDiamond), + PathObjectTools.filterByRoiCovers(roiRect, pathObjects).stream().map(PathObject::getROI).toList()); + // Every other ROI only completely covers itself + for (var roi : List.of(roiEllipse, roiDiamond, roiOverlaps, roiOverlaps2, roiSeparate)) { + assertEquals(List.of(roi), + PathObjectTools.filterByRoiCovers(roi, pathObjects).stream().map(PathObject::getROI).toList()); + } + + // Test intersects + // One ROI is separate, all other intersect + assertEquals(List.of(roiSeparate), + PathObjectTools.filterByRoiIntersects(roiSeparate, pathObjects).stream().map(PathObject::getROI).toList()); + for (var roi : List.of(roiRect, roiEllipse, roiDiamond, roiOverlaps, roiOverlaps2)) { + assertEquals(List.of(roiRect, roiEllipse, roiDiamond, roiOverlaps, roiOverlaps2), + PathObjectTools.filterByRoiIntersects(roi, pathObjects).stream().map(PathObject::getROI).toList()); + } + + // Test contains centroid + // One ROI is separate, all other intersect + assertEquals(List.of(roiSeparate), + PathObjectTools.filterByRoiContainsCentroid(roiSeparate, pathObjects).stream().map(PathObject::getROI).toList()); + for (var roi : List.of(roiRect, roiEllipse, roiDiamond, roiOverlaps, roiOverlaps2)) { + assertEquals(List.of(roiRect, roiEllipse, roiDiamond, roiOverlaps, roiOverlaps2), + PathObjectTools.filterByRoiContainsCentroid(roi, pathObjects).stream().map(PathObject::getROI).toList()); + } + } - -} +} From 0547891ed96aba37bfd5350e61ce0e467d05c9a8 Mon Sep 17 00:00:00 2001 From: Pete Date: Thu, 1 Aug 2024 19:51:46 +0100 Subject: [PATCH 141/866] Hierarchy methods to query regions --- .../hierarchy/PathObjectHierarchy.java | 118 ++++++++++++++++-- 1 file changed, 109 insertions(+), 9 deletions(-) diff --git a/qupath-core/src/main/java/qupath/lib/objects/hierarchy/PathObjectHierarchy.java b/qupath-core/src/main/java/qupath/lib/objects/hierarchy/PathObjectHierarchy.java index c50e77694..203cf117e 100644 --- a/qupath-core/src/main/java/qupath/lib/objects/hierarchy/PathObjectHierarchy.java +++ b/qupath-core/src/main/java/qupath/lib/objects/hierarchy/PathObjectHierarchy.java @@ -656,19 +656,34 @@ public synchronized void clearAll() { * Get objects that contain Point ROIs. * @param cls * @return + * @deprecated v0.6.0; use {@link #getAllPointObjects()} instead, and filter by object type if required. */ + @Deprecated public synchronized Collection getPointObjects(Class cls) { Collection pathObjects = getObjects(null, cls); if (!pathObjects.isEmpty()) { - Iterator iter = pathObjects.iterator(); - while (iter.hasNext()) { - if (!PathObjectTools.hasPointROI(iter.next())) { - iter.remove(); - } - } + pathObjects.removeIf(pathObject -> !PathObjectTools.hasPointROI(pathObject)); } return pathObjects; } + + /** + * Get all objects in the hierarchy that have a point (or multi-point) ROI. + * @return + */ + public Collection getAllPointObjects() { + return getAllObjects(false).stream().filter(PathObjectTools::hasPointROI).toList(); + } + + /** + * Get all annotation objects in the hierarchy that have a point (or multi-point) ROI. + * @return + */ + public Collection getAllPointAnnotations() { + return getAnnotationObjects().stream() + .filter(PathObjectTools::hasPointROI) + .toList(); + } /** * Get all cell objects in the hierarchy. @@ -874,7 +889,8 @@ public Collection getTilesForROI(ROI roi) { * @implSpec This does not return all cells that intersect with the ROI, * but rather only those whose centroid falls within the ROI - consistent with the * behavior of {@link #resolveHierarchy()}. - */ public Collection getCellsForROI(ROI roi) { + */ + public Collection getCellsForROI(ROI roi) { return getObjectsForROI(PathCellObject.class, roi); } @@ -938,7 +954,7 @@ private static boolean samePlane(ROI roi1, ROI roi2, boolean checkChannel) { else return roi1.getZ() == roi2.getZ() && roi1.getT() == roi2.getT(); } - + /** * Get the objects overlapping or close to a specified region. @@ -948,10 +964,59 @@ private static boolean samePlane(ROI roi1, ROI roi2, boolean checkChannel) { * @param region requested region overlapping the objects ROI * @param pathObjects optionally collection to which objects will be added * @return collection containing identified objects (same as the input collection, if provided) + * @deprecated v0.6.0, use {@link #getAllObjectsForRegion(ImageRegion, Collection)} or its related methods instead. + * @see #getAllObjectsForRegion(ImageRegion, Collection) + * @see #getAnnotationsForRegion(ImageRegion, Collection) + * @see #getAllDetectionsForRegion(ImageRegion, Collection) */ + @Deprecated public Collection getObjectsForRegion(Class cls, ImageRegion region, Collection pathObjects) { return tileCache.getObjectsForRegion(cls, region, pathObjects, true); } + + /** + * Get all the objects overlapping or close to a specified region. + * Note that this performs a quick check; the results typically should be filtered if a more strict test for overlapping is applied. + * + * @param region requested region overlapping the objects ROI + * @param pathObjects optional collection to which objects will be added + * @return collection containing identified objects (same as the input collection, if provided) + * @see #getAllObjectsForROI(ROI) + * @see PathObjectTools#filterByRoiCovers(ROI, Collection) + * @see PathObjectTools#filterByRoiIntersects(ROI, Collection) (ROI, Collection) + * @see PathObjectTools#filterByRoiContainsCentroid(ROI, Collection) (ROI, Collection) (ROI, Collection) + */ + public Collection getAllObjectsForRegion(ImageRegion region, Collection pathObjects) { + return tileCache.getObjectsForRegion(null, region, pathObjects, true); + } + + /** + * Get all the annotation objects overlapping or close to a specified region. + * Note that this performs a quick check; the results typically should be filtered if a more strict test for overlapping is applied. + * + * @param region requested region overlapping the objects ROI + * @param pathObjects optional collection to which objects will be added + * @return collection containing identified objects (same as the input collection, if provided) + * @see #getAllObjectsForRegion(ImageRegion, Collection) + * @see #getAnnotationsForROI(ROI) + */ + public Collection getAnnotationsForRegion(ImageRegion region, Collection pathObjects) { + return tileCache.getObjectsForRegion(PathAnnotationObject.class, region, pathObjects, true); + } + + /** + * Get all the detection objects overlapping or close to a specified region. + * Note that this performs a quick check; the results typically should be filtered if a more strict test for overlapping is applied. + * + * @param region requested region overlapping the objects ROI + * @param pathObjects optional collection to which objects will be added + * @return collection containing identified objects (same as the input collection, if provided) + * @see #getAllObjectsForRegion(ImageRegion, Collection) + * @see #getAllDetectionsForROI(ROI) (ROI) + */ + public Collection getAllDetectionsForRegion(ImageRegion region, Collection pathObjects) { + return tileCache.getObjectsForRegion(PathDetectionObject.class, region, pathObjects, true); + } /** * Returns true if the hierarchy contains objects overlapping a specific region, optionally filtering to identify only @@ -963,7 +1028,42 @@ public Collection getObjectsForRegion(Class cl public boolean hasObjectsForRegion(Class cls, ImageRegion region) { return tileCache.hasObjectsForRegion(cls, region, true); } - + + /** + * Returns true if the hierarchy contains any objects intersecting a specific region. + * This is similar to {@link #getAllObjectsForRegion(ImageRegion, Collection)}, + * but does not return the objects themselves. + * @param region + * @return true if objects are found, false otherwise. + * @see #getAllObjectsForRegion(ImageRegion, Collection) + */ + public boolean hasObjectsForRegion(ImageRegion region) { + return tileCache.hasObjectsForRegion(null, region, true); + } + + /** + * Returns true if the hierarchy contains any annotation objects intersecting a specific region. + * This is similar to {@link #getAnnotationsForRegion(ImageRegion, Collection)}, + * but does not return the objects themselves. + * @param region + * @return true if annotations are found, false otherwise. + * @see #getAnnotationsForRegion(ImageRegion, Collection) + */ + public boolean hasAnnotationsForRegion(ImageRegion region) { + return tileCache.hasObjectsForRegion(PathAnnotationObject.class, region, true); + } + + /** + * Returns true if the hierarchy contains any detection objects (including subclasses) intersecting a specific region. + * This is similar to {@link #getAllDetectionsForRegion(ImageRegion, Collection)}, + * but does not return the objects themselves. + * @param region + * @return true if detections are found, false otherwise. + * @see #getAllDetectionsForRegion(ImageRegion, Collection) + */ + public boolean hasDetectionsForRegion(ImageRegion region) { + return tileCache.hasObjectsForRegion(PathDetectionObject.class, region, true); + } void fireObjectRemovedEvent(Object source, PathObject pathObject, PathObject previousParent) { PathObjectHierarchyEvent event = PathObjectHierarchyEvent.createObjectRemovedEvent(source, this, previousParent, pathObject); From 567ea680d3d6ae0ab24c6ed157504b9ca6e98a53 Mon Sep 17 00:00:00 2001 From: Pete Date: Thu, 1 Aug 2024 20:57:20 +0100 Subject: [PATCH 142/866] Add more tests --- .../hierarchy/TestPathObjectHierarchy.java | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/qupath-core/src/test/java/qupath/lib/objects/hierarchy/TestPathObjectHierarchy.java b/qupath-core/src/test/java/qupath/lib/objects/hierarchy/TestPathObjectHierarchy.java index 33be256c2..5ee215ff7 100644 --- a/qupath-core/src/test/java/qupath/lib/objects/hierarchy/TestPathObjectHierarchy.java +++ b/qupath-core/src/test/java/qupath/lib/objects/hierarchy/TestPathObjectHierarchy.java @@ -157,6 +157,16 @@ public void testGetObjects() { assertTrue(hierarchy.getAllDetectionsForROI(ROIs.createRectangleROI(region1)).stream().allMatch(PathObject::isDetection)); assertFalse(hierarchy.getAllDetectionsForROI(ROIs.createRectangleROI(region1)).stream().allMatch(PathObject::isAnnotation)); assertFalse(hierarchy.getAllDetectionsForROI(ROIs.createRectangleROI(region1)).stream().allMatch(PathObject::isTile)); + + + // Get for region + assertTrue(hierarchy.getAnnotationsForRegion(region1, null).stream().allMatch(PathObject::isAnnotation)); + assertFalse(hierarchy.getAnnotationsForRegion(region1, null).stream().allMatch(PathObject::isDetection)); + assertEquals(2, hierarchy.getAnnotationsForRegion(region1, null).size()); + assertEquals(1, hierarchy.getAnnotationsForRegion(region1, null).size()); + assertEquals(2, hierarchy.getAnnotationsForRegion(region1, null).size()); + assertEquals(1, hierarchy.getAnnotationsForRegion(region1, null).size()); + } private static List createObjects(Collection regions, Function roiCreator, Function objectCreator) { @@ -177,4 +187,24 @@ private static PathObject updateT(PathObject pathObject, int t) { false, true); } + + @Test + public void testGetPoints() { + var points = ROIs.createPointsROI(1, 2, ImagePlane.getDefaultPlane()); + var points2 = ROIs.createPointsROI(new double[]{1, 2}, new double[]{3, 4}, ImagePlane.getDefaultPlane()); + var rect = ROIs.createRectangleROI(0, 0, 10, 10, ImagePlane.getDefaultPlane()); + + var annotations = List.of(points, points2, rect).stream().map(PathObjects::createAnnotationObject).toList(); + var detections = List.of(points, points2, rect).stream().map(PathObjects::createDetectionObject).toList(); + var hierarchy = new PathObjectHierarchy(); + hierarchy.addObjects(annotations); + hierarchy.addObjects(detections); + + assertEquals(7, hierarchy.getAllObjects(true).size()); + assertEquals(6, hierarchy.getAllObjects(false).size()); + assertEquals(4, hierarchy.getAllPointObjects().size()); + assertEquals(2, hierarchy.getAllPointAnnotations().size()); + assertTrue(hierarchy.getAllPointAnnotations().stream().allMatch(PathObject::isAnnotation)); + } + } From 6a36efeb00429e286d48fd7f8cd4be278abdc98d Mon Sep 17 00:00:00 2001 From: Pete Date: Thu, 1 Aug 2024 21:18:57 +0100 Subject: [PATCH 143/866] User newer hierarchy methods `getObjectsForROI` is no longer deprecated as it is still used to make measurements, but advice not to use it is added to the javadoc. --- .../analysis/heatmaps/DensityMapDataOp.java | 2 +- .../images/servers/LabeledImageServer.java | 4 +- .../lib/images/writers/TileExporter.java | 2 +- .../qupath/lib/objects/PathObjectTools.java | 6 ++- .../hierarchy/PathObjectHierarchy.java | 44 ++++++++++++++----- .../java/qupath/imagej/gui/IJExtension.java | 2 +- .../gui/commands/ObjectClassifierCommand.java | 2 +- .../commands/ml/PixelClassifierTraining.java | 2 +- .../qupath/lib/extension/svg/SvgTools.java | 2 +- .../qupath/lib/gui/commands/CountingPane.java | 2 +- .../servers/PathHierarchyImageServer.java | 2 +- .../ObservableMeasurementTableData.java | 2 +- .../qupath/lib/gui/viewer/RegionFilter.java | 4 +- .../gui/viewer/overlays/HierarchyOverlay.java | 6 +-- .../overlays/PixelClassificationOverlay.java | 2 +- .../AbstractPathROIToolEventHandler.java | 2 +- .../AbstractPathToolEventHandler.java | 2 +- .../handlers/PointsToolEventHandler.java | 2 +- 18 files changed, 58 insertions(+), 32 deletions(-) diff --git a/qupath-core-processing/src/main/java/qupath/lib/analysis/heatmaps/DensityMapDataOp.java b/qupath-core-processing/src/main/java/qupath/lib/analysis/heatmaps/DensityMapDataOp.java index 327d8769a..c6e8b1023 100644 --- a/qupath-core-processing/src/main/java/qupath/lib/analysis/heatmaps/DensityMapDataOp.java +++ b/qupath-core-processing/src/main/java/qupath/lib/analysis/heatmaps/DensityMapDataOp.java @@ -240,7 +240,7 @@ public Mat apply(ImageData imageData, RegionRequest request) thro } // Get all objects within the padded region - var allPathObjects = imageData.getHierarchy().getObjectsForRegion(null, request, null) + var allPathObjects = imageData.getHierarchy().getAllObjectsForRegion(request, null) .stream() .filter(allObjects) .toList(); diff --git a/qupath-core/src/main/java/qupath/lib/images/servers/LabeledImageServer.java b/qupath-core/src/main/java/qupath/lib/images/servers/LabeledImageServer.java index 59727a2c8..40b0a6aaf 100644 --- a/qupath-core/src/main/java/qupath/lib/images/servers/LabeledImageServer.java +++ b/qupath-core/src/main/java/qupath/lib/images/servers/LabeledImageServer.java @@ -836,7 +836,7 @@ public boolean isEmptyRegion(RegionRequest request) { * @return a list of objects with ROIs that intersect the specified region */ public List getObjectsForRegion(ImageRegion region) { - return hierarchy.getObjectsForRegion(null, region, null).stream() + return hierarchy.getAllObjectsForRegion(region, null).stream() .filter(params.objectFilter) .filter(p -> params.createInstanceLabels || params.labels.containsKey(p.getPathClass()) || params.boundaryLabels.containsKey(p.getPathClass())) .toList(); @@ -875,7 +875,7 @@ protected BufferedImage createDefaultRGBImage(int width, int height) { protected BufferedImage readTile(TileRequest tileRequest) throws IOException { long startTime = System.currentTimeMillis(); - var pathObjects = hierarchy.getObjectsForRegion(null, tileRequest.getRegionRequest(), null) + var pathObjects = hierarchy.getAllObjectsForRegion(tileRequest.getRegionRequest(), null) .stream() .filter(params.objectFilter) .toList(); diff --git a/qupath-core/src/main/java/qupath/lib/images/writers/TileExporter.java b/qupath-core/src/main/java/qupath/lib/images/writers/TileExporter.java index 602bd6e75..adedf54fb 100644 --- a/qupath-core/src/main/java/qupath/lib/images/writers/TileExporter.java +++ b/qupath-core/src/main/java/qupath/lib/images/writers/TileExporter.java @@ -537,7 +537,7 @@ private Collection createRequests() { continue; } } else if (imageData != null) { - if (!imageData.getHierarchy().getObjectsForRegion(PathAnnotationObject.class, r, null) + if (!imageData.getHierarchy().getAnnotationsForRegion(r, null) .stream().anyMatch(p -> RoiTools.intersectsRegion(p.getROI(), r))) { iterator.remove(); continue; diff --git a/qupath-core/src/main/java/qupath/lib/objects/PathObjectTools.java b/qupath-core/src/main/java/qupath/lib/objects/PathObjectTools.java index d2926cf6e..ecea51a8f 100644 --- a/qupath-core/src/main/java/qupath/lib/objects/PathObjectTools.java +++ b/qupath-core/src/main/java/qupath/lib/objects/PathObjectTools.java @@ -301,6 +301,7 @@ public static List findObjectsOutsideRegion(Collection + * @since v0.6.0 */ public static Collection filterByRoiCovers(ROI roi, Collection pathObjects) { Predicate predicate; @@ -320,6 +321,7 @@ public static Collection filterByRoiCovers(ROI roi, Co * @param pathObjects the objects to filter * @return a new collection that contains only the objects that intersect with the ROI * @param + * @since v0.6.0 */ public static Collection filterByRoiIntersects(ROI roi, Collection pathObjects) { Predicate predicate; @@ -341,6 +343,7 @@ public static Collection filterByRoiIntersects(ROI roi * @param pathObjects the collection to filter * @return * @param + * @since v0.6.0 */ private static Collection filterByRoiPredicate(ROI roi, Predicate predicate, Collection pathObjects) { var planePredicate = createPlanePredicate(roi.getImagePlane()); @@ -366,6 +369,7 @@ private static Predicate createGeometryPredicate(Predicate * @param pathObjects the objects to filter * @return a new collection that contains only the objects with centroids that fall within the ROI * @param + * @since v0.6.0 */ public static Collection filterByRoiContainsCentroid(ROI roi, Collection pathObjects) { return filterByRoiPredicate(roi, p -> roi.contains(p.getROI().getCentroidX(), p.getROI().getCentroidY()), pathObjects); @@ -774,7 +778,7 @@ public static Collection getObjectsForLocation(final PathObjectHiera // Introduce searchWidth to address https://github.com/qupath/qupath/issues/1552 - // previously points were often missed int searchWidth = (int)Math.ceil(Math.max(vertexDistance * 2, 2)); - hierarchy.getObjectsForRegion(PathObject.class, ImageRegion.createInstance( + hierarchy.getAllObjectsForRegion(ImageRegion.createInstance( (int)(x - searchWidth/2), (int)(y - searchWidth/2), searchWidth, diff --git a/qupath-core/src/main/java/qupath/lib/objects/hierarchy/PathObjectHierarchy.java b/qupath-core/src/main/java/qupath/lib/objects/hierarchy/PathObjectHierarchy.java index 203cf117e..54c9527dd 100644 --- a/qupath-core/src/main/java/qupath/lib/objects/hierarchy/PathObjectHierarchy.java +++ b/qupath-core/src/main/java/qupath/lib/objects/hierarchy/PathObjectHierarchy.java @@ -660,6 +660,7 @@ public synchronized void clearAll() { */ @Deprecated public synchronized Collection getPointObjects(Class cls) { + LogTools.warnOnce(logger, "getPointObjects() is deprecated, use getAllPointObjects() instead"); Collection pathObjects = getObjects(null, cls); if (!pathObjects.isEmpty()) { pathObjects.removeIf(pathObject -> !PathObjectTools.hasPointROI(pathObject)); @@ -819,19 +820,27 @@ public synchronized void setHierarchy(PathObjectHierarchy hierarchy) { /** * Get the objects within a specified ROI, as defined by the general rules for resolving the hierarchy. * This relies on centroids for detections (including subclasses), and a 'covers' rule for others (annotations, TMA cores). - * + *

    + * Note: Since v0.6.0 use of this method is discouraged, and it may be deprecated and/or removed in a future + * release. + * Instead use {@link #getAllObjectsForROI(ROI)} and filter the returned collection; + * or, alternatively, use {@link #getAnnotationsForROI(ROI)}, {@link #getCellsForROI(ROI)}, + * {@link #getAllDetectionsForROI(ROI)} or {@link #getTilesForROI(ROI)}. + *

    * @param cls class of PathObjects (e.g. PathDetectionObject), or null to accept all * @param roi * @return - * @deprecated v0.6.0; use {@link #getAllObjectsForROI(ROI)} instead and filter the returned collection, - * or use {@link #getAnnotationsForROI(ROI)}, {@link #getCellsForROI(ROI)}, - * {@link #getAllDetectionsForROI(ROI)} or {@link #getTilesForROI(ROI)}. */ - @Deprecated public Collection getObjectsForROI(Class cls, ROI roi) { + return getObjectsOfClassForROI(cls, roi); + } + + + private Collection getObjectsOfClassForROI(Class cls, ROI roi) { + LogTools.warnOnce(logger, "getObjectsForROI(Class, ROI) is deprecated, use getAllObjectsForROI(ROI) instead"); if (roi.isEmpty() || !roi.isArea()) return Collections.emptyList(); - + Collection pathObjects = tileCache.getObjectsForRegion(cls, ImageRegion.createInstance(roi), new HashSet<>(), true); return filterObjectsForROI(roi, pathObjects); } @@ -846,9 +855,10 @@ public Collection getObjectsForROI(Class cls, * @see #getCellsForROI(ROI) * @see #getAllDetectionsForROI(ROI) * @see #getTilesForROI(ROI) + * @since v0.6.0 */ public Collection getAllObjectsForROI(ROI roi) { - return getObjectsForROI(null, roi); + return getObjectsOfClassForROI(null, roi); } /** @@ -859,9 +869,10 @@ public Collection getAllObjectsForROI(ROI roi) { * @implSpec This does not return all annotations that intersect with the ROI, * but rather only those that are covered by the ROI - consistent with the * behavior of {@link #resolveHierarchy()}. + * @since v0.6.0 */ public Collection getAnnotationsForROI(ROI roi) { - return getObjectsForROI(PathAnnotationObject.class, roi); + return getObjectsOfClassForROI(PathAnnotationObject.class, roi); } /** @@ -874,9 +885,10 @@ public Collection getAnnotationsForROI(ROI roi) { * @implSpec This does not return all tiles that intersect with the ROI, * but rather only those whose centroid falls within the ROI - consistent with the * behavior of {@link #resolveHierarchy()}. + * @since v0.6.0 */ public Collection getTilesForROI(ROI roi) { - return getObjectsForROI(PathTileObject.class, roi); + return getObjectsOfClassForROI(PathTileObject.class, roi); } /** @@ -889,9 +901,10 @@ public Collection getTilesForROI(ROI roi) { * @implSpec This does not return all cells that intersect with the ROI, * but rather only those whose centroid falls within the ROI - consistent with the * behavior of {@link #resolveHierarchy()}. + * @since v0.6.0 */ public Collection getCellsForROI(ROI roi) { - return getObjectsForROI(PathCellObject.class, roi); + return getObjectsOfClassForROI(PathCellObject.class, roi); } /** @@ -905,9 +918,10 @@ public Collection getCellsForROI(ROI roi) { * @implSpec This does not return all cells that intersect with the ROI, * but rather only those whose centroid falls within the ROI - consistent with the * behavior of {@link #resolveHierarchy()}. + * @since v0.6.0 */ public Collection getAllDetectionsForROI(ROI roi) { - return getObjectsForROI(PathDetectionObject.class, roi); + return getObjectsOfClassForROI(PathDetectionObject.class, roi); } /** @@ -968,6 +982,7 @@ private static boolean samePlane(ROI roi1, ROI roi2, boolean checkChannel) { * @see #getAllObjectsForRegion(ImageRegion, Collection) * @see #getAnnotationsForRegion(ImageRegion, Collection) * @see #getAllDetectionsForRegion(ImageRegion, Collection) + * @since v0.6.0 */ @Deprecated public Collection getObjectsForRegion(Class cls, ImageRegion region, Collection pathObjects) { @@ -985,6 +1000,7 @@ public Collection getObjectsForRegion(Class cl * @see PathObjectTools#filterByRoiCovers(ROI, Collection) * @see PathObjectTools#filterByRoiIntersects(ROI, Collection) (ROI, Collection) * @see PathObjectTools#filterByRoiContainsCentroid(ROI, Collection) (ROI, Collection) (ROI, Collection) + * @since v0.6.0 */ public Collection getAllObjectsForRegion(ImageRegion region, Collection pathObjects) { return tileCache.getObjectsForRegion(null, region, pathObjects, true); @@ -999,6 +1015,7 @@ public Collection getAllObjectsForRegion(ImageRegion region, Collect * @return collection containing identified objects (same as the input collection, if provided) * @see #getAllObjectsForRegion(ImageRegion, Collection) * @see #getAnnotationsForROI(ROI) + * @since v0.6.0 */ public Collection getAnnotationsForRegion(ImageRegion region, Collection pathObjects) { return tileCache.getObjectsForRegion(PathAnnotationObject.class, region, pathObjects, true); @@ -1013,6 +1030,7 @@ public Collection getAnnotationsForRegion(ImageRegion region, Collec * @return collection containing identified objects (same as the input collection, if provided) * @see #getAllObjectsForRegion(ImageRegion, Collection) * @see #getAllDetectionsForROI(ROI) (ROI) + * @since v0.6.0 */ public Collection getAllDetectionsForRegion(ImageRegion region, Collection pathObjects) { return tileCache.getObjectsForRegion(PathDetectionObject.class, region, pathObjects, true); @@ -1024,6 +1042,7 @@ public Collection getAllDetectionsForRegion(ImageRegion region, Coll * @param cls * @param region * @return + * @since v0.6.0 */ public boolean hasObjectsForRegion(Class cls, ImageRegion region) { return tileCache.hasObjectsForRegion(cls, region, true); @@ -1036,6 +1055,7 @@ public boolean hasObjectsForRegion(Class cls, ImageRegion * @param region * @return true if objects are found, false otherwise. * @see #getAllObjectsForRegion(ImageRegion, Collection) + * @since v0.6.0 */ public boolean hasObjectsForRegion(ImageRegion region) { return tileCache.hasObjectsForRegion(null, region, true); @@ -1048,6 +1068,7 @@ public boolean hasObjectsForRegion(ImageRegion region) { * @param region * @return true if annotations are found, false otherwise. * @see #getAnnotationsForRegion(ImageRegion, Collection) + * @since v0.6.0 */ public boolean hasAnnotationsForRegion(ImageRegion region) { return tileCache.hasObjectsForRegion(PathAnnotationObject.class, region, true); @@ -1060,6 +1081,7 @@ public boolean hasAnnotationsForRegion(ImageRegion region) { * @param region * @return true if detections are found, false otherwise. * @see #getAllDetectionsForRegion(ImageRegion, Collection) + * @since v0.6.0 */ public boolean hasDetectionsForRegion(ImageRegion region) { return tileCache.hasObjectsForRegion(PathDetectionObject.class, region, true); diff --git a/qupath-extension-processing/src/main/java/qupath/imagej/gui/IJExtension.java b/qupath-extension-processing/src/main/java/qupath/imagej/gui/IJExtension.java index 94a947dbf..0182291e1 100644 --- a/qupath-extension-processing/src/main/java/qupath/imagej/gui/IJExtension.java +++ b/qupath-extension-processing/src/main/java/qupath/imagej/gui/IJExtension.java @@ -404,7 +404,7 @@ public static Overlay extractOverlay(PathObjectHierarchy hierarchy, RegionReques double yOrigin = -request.getY() / downsample; // TODO: Permit filling/unfilling ROIs - for (PathObject child : hierarchy.getObjectsForRegion(PathObject.class, request, null)) { + for (PathObject child : hierarchy.getAllObjectsForRegion(request, null)) { if (filter != null && !filter.test(child)) continue; diff --git a/qupath-extension-processing/src/main/java/qupath/process/gui/commands/ObjectClassifierCommand.java b/qupath-extension-processing/src/main/java/qupath/process/gui/commands/ObjectClassifierCommand.java index fb8f9ff22..5ca545dab 100644 --- a/qupath-extension-processing/src/main/java/qupath/process/gui/commands/ObjectClassifierCommand.java +++ b/qupath-extension-processing/src/main/java/qupath/process/gui/commands/ObjectClassifierCommand.java @@ -723,7 +723,7 @@ private static TrainingData createTrainingData( set.addAll(pathObjectsTemp); } } else { - var pathObjectsTemp = hierarchy.getObjectsForROI(PathDetectionObject.class, annotation.getROI()); + var pathObjectsTemp = hierarchy.getAllDetectionsForROI(annotation.getROI()); pathObjectsTemp.removeIf(filterNegated); set.addAll(pathObjectsTemp); } diff --git a/qupath-extension-processing/src/main/java/qupath/process/gui/commands/ml/PixelClassifierTraining.java b/qupath-extension-processing/src/main/java/qupath/process/gui/commands/ml/PixelClassifierTraining.java index 9e89cb8be..4e344fa74 100644 --- a/qupath-extension-processing/src/main/java/qupath/process/gui/commands/ml/PixelClassifierTraining.java +++ b/qupath-extension-processing/src/main/java/qupath/process/gui/commands/ml/PixelClassifierTraining.java @@ -349,7 +349,7 @@ private static TileFeatures getTileFeatures(RegionRequest request, ImageDataServ TileFeatures features = cache.get(request); Map rois = null; - var annotations = featureServer.getImageData().getHierarchy().getObjectsForRegion(PathAnnotationObject.class, request, null); + var annotations = featureServer.getImageData().getHierarchy().getAllObjectsForRegion(request, null); if (annotations != null && !annotations.isEmpty()) { rois = new HashMap<>(); for (var annotation : annotations) { diff --git a/qupath-extension-svg/src/main/java/qupath/lib/extension/svg/SvgTools.java b/qupath-extension-svg/src/main/java/qupath/lib/extension/svg/SvgTools.java index d476ab324..04c6b64ae 100644 --- a/qupath-extension-svg/src/main/java/qupath/lib/extension/svg/SvgTools.java +++ b/qupath-extension-svg/src/main/java/qupath/lib/extension/svg/SvgTools.java @@ -472,7 +472,7 @@ else if (viewer != null) if (hierarchy == null) pathObjects = Collections.emptyList(); else - pathObjects = hierarchy.getObjectsForRegion(null, region, null); + pathObjects = hierarchy.getAllObjectsForRegion(region, null); } // Important! Needed to determine where to draw objects and overlays diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/CountingPane.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/CountingPane.java index 482679a0b..40f378458 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/CountingPane.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/CountingPane.java @@ -311,7 +311,7 @@ public void hierarchyChanged(PathObjectHierarchyEvent event) { return; } - Collection newList = hierarchy.getPointObjects(PathAnnotationObject.class); + Collection newList = hierarchy.getAllPointAnnotations(); // We want to avoid shuffling the list if possible we adding points var items = listCounts.getItems(); diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/images/servers/PathHierarchyImageServer.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/images/servers/PathHierarchyImageServer.java index afa71a1a5..f910b8b68 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/images/servers/PathHierarchyImageServer.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/images/servers/PathHierarchyImageServer.java @@ -150,7 +150,7 @@ protected String createID() { private Collection getObjectsToPaint(RegionRequest request) { // Rectangle region = request.getBounds(); - return hierarchy.getObjectsForRegion(PathDetectionObject.class, request, null); + return hierarchy.getAllDetectionsForRegion(request, null); } /** diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/measure/ObservableMeasurementTableData.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/measure/ObservableMeasurementTableData.java index 82bfce0bb..a46ebe637 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/measure/ObservableMeasurementTableData.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/measure/ObservableMeasurementTableData.java @@ -1753,7 +1753,7 @@ static class DetectionPathClassCounts { if (parentObject.isRootObject()) pathObjects = hierarchy.getDetectionObjects(); else - pathObjects = hierarchy.getObjectsForROI(PathDetectionObject.class, parentObject.getROI()); + pathObjects = hierarchy.getAllDetectionsForROI(parentObject.getROI()); for (PathObject child : pathObjects) { if (child == parentObject || !child.isDetection()) diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/RegionFilter.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/RegionFilter.java index 13d42325f..f995f58b7 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/RegionFilter.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/RegionFilter.java @@ -92,10 +92,10 @@ public String toString() { public boolean test(ImageData imageData, RegionRequest region) { switch (this) { case ANY_ANNOTATIONS: - var annotations = imageData.getHierarchy().getObjectsForRegion(PathAnnotationObject.class, region, null); + var annotations = imageData.getHierarchy().getAnnotationsForRegion(region, null); return overlapsObjects(annotations, region); case ANY_OBJECTS: - var pathObjects = imageData.getHierarchy().getObjectsForRegion(null, region, null); + var pathObjects = imageData.getHierarchy().getAllObjectsForRegion(region, null); return overlapsObjects(pathObjects, region); case IMAGE: return !imageData.getServer().isEmptyRegion(region); diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/overlays/HierarchyOverlay.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/overlays/HierarchyOverlay.java index ba87d772a..5d2fb31e8 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/overlays/HierarchyOverlay.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/overlays/HierarchyOverlay.java @@ -211,12 +211,12 @@ public void paintOverlay(final Graphics2D g2d, final ImageRegion imageRegion, fi Collection pathObjects; try { Set pathObjectsToPaint = new TreeSet<>(comparator); - pathObjects = hierarchy.getObjectsForRegion(PathDetectionObject.class, region, pathObjectsToPaint); + pathObjects = hierarchy.getAllDetectionsForRegion(region, pathObjectsToPaint); } catch (IllegalArgumentException e) { // This can happen (rarely) in a multithreaded environment if the level of a detection changes. // However, protecting against this fully by caching the level with integer boxing/unboxing would be expensive. logger.debug("Exception requesting detections to paint: " + e.getLocalizedMessage(), e); - pathObjects = hierarchy.getObjectsForRegion(PathDetectionObject.class, region, null); + pathObjects = hierarchy.getAllDetectionsForRegion(region, null); } g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_OFF); PathObjectPainter.paintSpecifiedObjects(g2d, pathObjects, overlayOptions, hierarchy.getSelectionModel(), downsampleFactor); @@ -254,7 +254,7 @@ public void paintOverlay(final Graphics2D g2d, final ImageRegion imageRegion, fi // Prepare to handle labels, if we need to Collection objectsWithNames = new ArrayList<>(); - Collection annotations = hierarchy.getObjectsForRegion(PathAnnotationObject.class, region, null); + Collection annotations = hierarchy.getAnnotationsForRegion(region, null); for (var iterator = annotations.iterator(); iterator.hasNext(); ) { var next = iterator.next(); if ((next.getName() != null && !next.getName().isBlank())) diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/overlays/PixelClassificationOverlay.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/overlays/PixelClassificationOverlay.java index 3385e353c..ded47d1c8 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/overlays/PixelClassificationOverlay.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/overlays/PixelClassificationOverlay.java @@ -552,7 +552,7 @@ void requestTile(TileRequest tile, ImageData imageData, ImageServ // if (pendingRequests.size() <= 1) // changed.add(hierarchy.getRootObject()); changed.add(hierarchy.getRootObject()); - hierarchy.getObjectsForRegion(PathAnnotationObject.class, tile.getRegionRequest(), changed); + hierarchy.getAnnotationsForRegion(tile.getRegionRequest(), changed); // changed.addAll(hierarchy.getAnnotationObjects()); } } diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/tools/handlers/AbstractPathROIToolEventHandler.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/tools/handlers/AbstractPathROIToolEventHandler.java index 63743ad6a..47441b60b 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/tools/handlers/AbstractPathROIToolEventHandler.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/tools/handlers/AbstractPathROIToolEventHandler.java @@ -195,7 +195,7 @@ void commitObjectToHierarchy(MouseEvent e, PathObject pathObject) { // If we are in selection mode, try to get objects to select if (PathPrefs.selectionModeProperty().get()) { var pathClass = PathPrefs.autoSetAnnotationClassProperty().get(); - var toSelect = hierarchy.getObjectsForROI(null, currentROI); + var toSelect = hierarchy.getAllObjectsForROI(currentROI); if (!toSelect.isEmpty() && pathClass != null) { boolean retainIntensityClass = !(PathClassTools.isPositiveOrGradedIntensityClass(pathClass) || PathClassTools.isNegativeClass(pathClass)); var reclassified = toSelect.stream() diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/tools/handlers/AbstractPathToolEventHandler.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/tools/handlers/AbstractPathToolEventHandler.java index a357d3a30..6abe34c31 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/tools/handlers/AbstractPathToolEventHandler.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/tools/handlers/AbstractPathToolEventHandler.java @@ -312,7 +312,7 @@ public Collection getConstrainedRemoveGeometries() { if (fullImage) toRemove = hierarchy.getAnnotationObjects(); else - toRemove = hierarchy.getObjectsForRegion(PathAnnotationObject.class, ImageRegion.createInstance(constrainedParentObject.getROI()), null); + toRemove = hierarchy.getAnnotationsForRegion(ImageRegion.createInstance(constrainedParentObject.getROI()), null); logger.debug("Constrained ROI drawing: identifying objects to remove"); diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/tools/handlers/PointsToolEventHandler.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/tools/handlers/PointsToolEventHandler.java index 14c0261ca..074a4165d 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/tools/handlers/PointsToolEventHandler.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/tools/handlers/PointsToolEventHandler.java @@ -173,7 +173,7 @@ private boolean handleAltClick(QuPathViewer viewer, double x, double y, PathObje // Activate a points object if there is one boolean currentIsValid = false; - for (PathObject pathObject : hierarchy.getPointObjects(PathObject.class)) { + for (PathObject pathObject : hierarchy.getAllPointObjects()) { if (!pathObject.getROI().getImagePlane().equals(viewerPlane)) continue; // See if we've almost clicked on a point From f5d58b4e07798e905b192ae7e8e23118d84bc4e4 Mon Sep 17 00:00:00 2001 From: Pete Date: Thu, 1 Aug 2024 21:25:33 +0100 Subject: [PATCH 144/866] Fix test --- .../hierarchy/PathObjectHierarchy.java | 2 +- .../hierarchy/TestPathObjectHierarchy.java | 28 +++++++++++++++---- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/qupath-core/src/main/java/qupath/lib/objects/hierarchy/PathObjectHierarchy.java b/qupath-core/src/main/java/qupath/lib/objects/hierarchy/PathObjectHierarchy.java index 54c9527dd..b009c9a43 100644 --- a/qupath-core/src/main/java/qupath/lib/objects/hierarchy/PathObjectHierarchy.java +++ b/qupath-core/src/main/java/qupath/lib/objects/hierarchy/PathObjectHierarchy.java @@ -4,7 +4,7 @@ * %% * Copyright (C) 2014 - 2016 The Queen's University of Belfast, Northern Ireland * Contact: IP Management (ipmanagement@qub.ac.uk) - * Copyright (C) 2018 - 2020 QuPath developers, The University of Edinburgh + * Copyright (C) 2018 - 2024 QuPath developers, The University of Edinburgh * %% * QuPath is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as diff --git a/qupath-core/src/test/java/qupath/lib/objects/hierarchy/TestPathObjectHierarchy.java b/qupath-core/src/test/java/qupath/lib/objects/hierarchy/TestPathObjectHierarchy.java index 5ee215ff7..65ea15937 100644 --- a/qupath-core/src/test/java/qupath/lib/objects/hierarchy/TestPathObjectHierarchy.java +++ b/qupath-core/src/test/java/qupath/lib/objects/hierarchy/TestPathObjectHierarchy.java @@ -1,17 +1,35 @@ +/*- + * #%L + * This file is part of QuPath. + * %% + * Copyright (C) 2024 QuPath developers, The University of Edinburgh + * %% + * QuPath 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 3 of the + * License, or (at your option) any later version. + * + * QuPath 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 QuPath. If not, see . + * #L% + */ + package qupath.lib.objects.hierarchy; -import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; import qupath.lib.objects.PathObject; import qupath.lib.objects.PathObjectTools; import qupath.lib.objects.PathObjects; import qupath.lib.regions.ImagePlane; import qupath.lib.regions.ImageRegion; -import qupath.lib.regions.RegionRequest; import qupath.lib.roi.ROIs; import qupath.lib.roi.interfaces.ROI; -import java.util.Arrays; import java.util.Collection; import java.util.List; import java.util.function.Function; @@ -163,9 +181,7 @@ public void testGetObjects() { assertTrue(hierarchy.getAnnotationsForRegion(region1, null).stream().allMatch(PathObject::isAnnotation)); assertFalse(hierarchy.getAnnotationsForRegion(region1, null).stream().allMatch(PathObject::isDetection)); assertEquals(2, hierarchy.getAnnotationsForRegion(region1, null).size()); - assertEquals(1, hierarchy.getAnnotationsForRegion(region1, null).size()); - assertEquals(2, hierarchy.getAnnotationsForRegion(region1, null).size()); - assertEquals(1, hierarchy.getAnnotationsForRegion(region1, null).size()); + assertEquals(2, hierarchy.getAnnotationsForRegion(region1Smaller, null).size()); } From f2456a740acb66623b6296e652c61bf0d06d5453 Mon Sep 17 00:00:00 2001 From: Pete Date: Fri, 2 Aug 2024 08:53:09 +0100 Subject: [PATCH 145/866] Optionally filter objects with nucleus ROI --- .../qupath/lib/objects/PathObjectTools.java | 85 ++++++++++++++++--- 1 file changed, 74 insertions(+), 11 deletions(-) diff --git a/qupath-core/src/main/java/qupath/lib/objects/PathObjectTools.java b/qupath-core/src/main/java/qupath/lib/objects/PathObjectTools.java index ecea51a8f..43082a36b 100644 --- a/qupath-core/src/main/java/qupath/lib/objects/PathObjectTools.java +++ b/qupath-core/src/main/java/qupath/lib/objects/PathObjectTools.java @@ -297,40 +297,84 @@ public static List findObjectsOutsideRegion(Collection + * @return a new collection that contains only the objects that are covered by the ROI * @since v0.6.0 */ public static Collection filterByRoiCovers(ROI roi, Collection pathObjects) { + return filterByRoiCovers(roi, pathObjects, PathObject::getROI); + } + + /** + * Filter a collection of PathObjects to identify those that have ROIs that are covered by a specified ROI, using + * the object's nucleus ROI where available. + * + * @param roi the ROI to test against + * @param pathObjects the objects to filter + * @param + * @return a new collection that contains only the objects that are covered by the ROI, using the nucleus ROI + * where available and main ROI otherwise + * @since v0.6.0 + */ + public static Collection filterByRoiCoversNucleus(ROI roi, Collection pathObjects) { + return filterByRoiCovers(roi, pathObjects, PathObjectTools::getNucleusOrMainROI); + } + + private static Collection filterByRoiCovers(ROI roi, Collection pathObjects, + Function roiExtractor) { Predicate predicate; var geom = roi.getGeometry(); if (pathObjects.size() > 1) { var prepared = PreparedGeometryFactory.prepare(geom); - predicate = createGeometryPredicate(prepared::covers); + predicate = createGeometryPredicate(prepared::covers, roiExtractor); } else { - predicate = createGeometryPredicate(geom::covers); + predicate = createGeometryPredicate(geom::covers, roiExtractor); } return filterByRoiPredicate(roi, predicate, pathObjects); } /** * Filter a collection of PathObjects to identify those that intersect with a specified ROI. - * @param roi the ROI to test against + * + * @param roi the ROI to test against * @param pathObjects the objects to filter - * @return a new collection that contains only the objects that intersect with the ROI * @param + * @return a new collection that contains only the objects that intersect with the ROI * @since v0.6.0 + * @see #filterByRoiIntersectsNucleus(ROI, Collection) */ public static Collection filterByRoiIntersects(ROI roi, Collection pathObjects) { + return filterByRoiIntersects(roi, pathObjects, PathObject::getROI); + } + + /** + * Filter a collection of PathObjects to identify those that intersect with a specified ROI, using the object's + * nucleus ROI where available. + * + * @param roi the ROI to test against + * @param pathObjects the objects to filter + * @param + * @return a new collection that contains only the objects that intersect with the ROI, using the nucleus ROI + * where available and main ROI otherwise + * @since v0.6.0 + * @see #filterByRoiIntersects(ROI, Collection) + */ + public static Collection filterByRoiIntersectsNucleus(ROI roi, Collection pathObjects) { + return filterByRoiIntersects(roi, pathObjects, PathObjectTools::getNucleusOrMainROI); + } + + private static Collection filterByRoiIntersects(ROI roi, Collection pathObjects, + Function roiExtractor) { Predicate predicate; var geom = roi.getGeometry(); if (pathObjects.size() > 1) { var prepared = PreparedGeometryFactory.prepare(geom); - predicate = createGeometryPredicate(prepared::intersects); + predicate = createGeometryPredicate(prepared::intersects, roiExtractor); } else { - predicate = createGeometryPredicate(geom::intersects); + predicate = createGeometryPredicate(geom::intersects, roiExtractor); } return filterByRoiPredicate(roi, predicate, pathObjects); } @@ -359,12 +403,14 @@ private static Predicate createPlanePredicate(ImagePlane plane) { return p -> p.getROI().getZ() == plane.getZ() && p.getROI().getT() == plane.getT(); } - private static Predicate createGeometryPredicate(Predicate predicate) { - return (PathObject p) -> predicate.test(p.getROI().getGeometry()); + private static Predicate createGeometryPredicate(Predicate predicate, Function roiFunction) { + return (PathObject p) -> predicate.test(roiFunction.apply(p).getGeometry()); } /** * Filter a collection of PathObjects to identify those with centroids that fall within specified ROI. + * Note that, for cell objects, this tests the main (outer) ROI only. + * If you want to use the nucleus centroid, use {@link #filterByRoiContainsNucleusCentroid(ROI, Collection)}. * @param roi the ROI to test against * @param pathObjects the objects to filter * @return a new collection that contains only the objects with centroids that fall within the ROI @@ -372,7 +418,24 @@ private static Predicate createGeometryPredicate(Predicate * @since v0.6.0 */ public static Collection filterByRoiContainsCentroid(ROI roi, Collection pathObjects) { - return filterByRoiPredicate(roi, p -> roi.contains(p.getROI().getCentroidX(), p.getROI().getCentroidY()), pathObjects); + return filterByRoiPredicate(roi, p -> roi.contains( + p.getROI().getCentroidX(), + p.getROI().getCentroidY()), pathObjects); + } + + /** + * Filter a collection of PathObjects to identify those with centroids that fall within specified ROI. + * This uses the nucleus ROI for cell objects, where available, and the main ROI otherwise. + * @param roi the ROI to test against + * @param pathObjects the objects to filter + * @return a new collection that contains only the objects with centroids that fall within the ROI + * @param + * @since v0.6.0 + */ + public static Collection filterByRoiContainsNucleusCentroid(ROI roi, Collection pathObjects) { + return filterByRoiPredicate(roi, p -> roi.contains( + getNucleusOrMainROI(p).getCentroidX(), + getNucleusOrMainROI(p).getCentroidY()), pathObjects); } From eb5590a716fafb78ead5e12206ed48f68354b775 Mon Sep 17 00:00:00 2001 From: Pete Date: Fri, 2 Aug 2024 08:53:56 +0100 Subject: [PATCH 146/866] More hierarchy get by region methods Provide overloaded versions that don't require passing a collection (or null). --- .../hierarchy/PathObjectHierarchy.java | 57 +++++++++++++++++-- 1 file changed, 53 insertions(+), 4 deletions(-) diff --git a/qupath-core/src/main/java/qupath/lib/objects/hierarchy/PathObjectHierarchy.java b/qupath-core/src/main/java/qupath/lib/objects/hierarchy/PathObjectHierarchy.java index b009c9a43..d9c2e2506 100644 --- a/qupath-core/src/main/java/qupath/lib/objects/hierarchy/PathObjectHierarchy.java +++ b/qupath-core/src/main/java/qupath/lib/objects/hierarchy/PathObjectHierarchy.java @@ -837,7 +837,6 @@ public Collection getObjectsForROI(Class cls, private Collection getObjectsOfClassForROI(Class cls, ROI roi) { - LogTools.warnOnce(logger, "getObjectsForROI(Class, ROI) is deprecated, use getAllObjectsForROI(ROI) instead"); if (roi.isEmpty() || !roi.isArea()) return Collections.emptyList(); @@ -990,13 +989,14 @@ public Collection getObjectsForRegion(Class cl } /** - * Get all the objects overlapping or close to a specified region. + * Get all the objects overlapping or close to a specified region, optionally adding to an existing collection. * Note that this performs a quick check; the results typically should be filtered if a more strict test for overlapping is applied. * * @param region requested region overlapping the objects ROI * @param pathObjects optional collection to which objects will be added * @return collection containing identified objects (same as the input collection, if provided) * @see #getAllObjectsForROI(ROI) + * @see #getAllObjectsForRegion(ImageRegion) * @see PathObjectTools#filterByRoiCovers(ROI, Collection) * @see PathObjectTools#filterByRoiIntersects(ROI, Collection) (ROI, Collection) * @see PathObjectTools#filterByRoiContainsCentroid(ROI, Collection) (ROI, Collection) (ROI, Collection) @@ -1007,12 +1007,30 @@ public Collection getAllObjectsForRegion(ImageRegion region, Collect } /** - * Get all the annotation objects overlapping or close to a specified region. + * Get all the objects overlapping or close to a specified region. + * Note that this performs a quick check; the results typically should be filtered if a more strict test for overlapping is applied. + * + * @param region requested region overlapping the objects ROI + * @return collection containing identified objects (same as the input collection, if provided) + * @see #getAllObjectsForROI(ROI) + * @see #getAllObjectsForRegion(ImageRegion, Collection) + * @see PathObjectTools#filterByRoiCovers(ROI, Collection) + * @see PathObjectTools#filterByRoiIntersects(ROI, Collection) (ROI, Collection) + * @see PathObjectTools#filterByRoiContainsCentroid(ROI, Collection) (ROI, Collection) (ROI, Collection) + * @since v0.6.0 + */ + public Collection getAllObjectsForRegion(ImageRegion region) { + return getAllObjectsForRegion(region, null); + } + + /** + * Get all the annotation objects overlapping or close to a specified region, optionally adding to an existing collection. * Note that this performs a quick check; the results typically should be filtered if a more strict test for overlapping is applied. * * @param region requested region overlapping the objects ROI * @param pathObjects optional collection to which objects will be added * @return collection containing identified objects (same as the input collection, if provided) + * @see #getAnnotationsForRegion(ImageRegion) * @see #getAllObjectsForRegion(ImageRegion, Collection) * @see #getAnnotationsForROI(ROI) * @since v0.6.0 @@ -1022,12 +1040,28 @@ public Collection getAnnotationsForRegion(ImageRegion region, Collec } /** - * Get all the detection objects overlapping or close to a specified region. + * Get all the annotation objects overlapping or close to a specified region. + * Note that this performs a quick check; the results typically should be filtered if a more strict test for overlapping is applied. + * + * @param region requested region overlapping the objects ROI + * @return collection containing identified objects (same as the input collection, if provided) + * @see #getAnnotationsForRegion(ImageRegion, Collection) + * @see #getAllObjectsForRegion(ImageRegion) + * @see #getAnnotationsForROI(ROI) + * @since v0.6.0 + */ + public Collection getAnnotationsForRegion(ImageRegion region) { + return getAnnotationsForRegion(region, null); + } + + /** + * Get all the detection objects overlapping or close to a specified region, optionally adding to an existing collection. * Note that this performs a quick check; the results typically should be filtered if a more strict test for overlapping is applied. * * @param region requested region overlapping the objects ROI * @param pathObjects optional collection to which objects will be added * @return collection containing identified objects (same as the input collection, if provided) + * @see #getAllDetectionsForRegion(ImageRegion) * @see #getAllObjectsForRegion(ImageRegion, Collection) * @see #getAllDetectionsForROI(ROI) (ROI) * @since v0.6.0 @@ -1035,6 +1069,21 @@ public Collection getAnnotationsForRegion(ImageRegion region, Collec public Collection getAllDetectionsForRegion(ImageRegion region, Collection pathObjects) { return tileCache.getObjectsForRegion(PathDetectionObject.class, region, pathObjects, true); } + + /** + * Get all the detection objects overlapping or close to a specified region. + * Note that this performs a quick check; the results typically should be filtered if a more strict test for overlapping is applied. + * + * @param region requested region overlapping the objects ROI + * @return collection containing identified objects (same as the input collection, if provided) + * @see #getAllDetectionsForRegion(ImageRegion, Collection) + * @see #getAllObjectsForRegion(ImageRegion) + * @see #getAllDetectionsForROI(ROI) (ROI) + * @since v0.6.0 + */ + public Collection getAllDetectionsForRegion(ImageRegion region) { + return getAllDetectionsForRegion(region, null); + } /** * Returns true if the hierarchy contains objects overlapping a specific region, optionally filtering to identify only From 96d76d8d3028ec2d62ffa2081a97a727df88d2f7 Mon Sep 17 00:00:00 2001 From: Pete Date: Fri, 2 Aug 2024 13:23:23 +0100 Subject: [PATCH 147/866] Update Roi to ROI in PathObjectTools methods --- .../qupath/lib/objects/PathObjectTools.java | 36 +++++++++---------- .../hierarchy/PathObjectHierarchy.java | 13 ++++--- .../lib/objects/TestPathObjectTools.java | 12 +++---- 3 files changed, 30 insertions(+), 31 deletions(-) diff --git a/qupath-core/src/main/java/qupath/lib/objects/PathObjectTools.java b/qupath-core/src/main/java/qupath/lib/objects/PathObjectTools.java index 43082a36b..a5de8790b 100644 --- a/qupath-core/src/main/java/qupath/lib/objects/PathObjectTools.java +++ b/qupath-core/src/main/java/qupath/lib/objects/PathObjectTools.java @@ -304,8 +304,8 @@ public static List findObjectsOutsideRegion(Collection Collection filterByRoiCovers(ROI roi, Collection pathObjects) { - return filterByRoiCovers(roi, pathObjects, PathObject::getROI); + public static Collection filterByROICovers(ROI roi, Collection pathObjects) { + return filterByROICovers(roi, pathObjects, PathObject::getROI); } /** @@ -319,11 +319,11 @@ public static Collection filterByRoiCovers(ROI roi, Co * where available and main ROI otherwise * @since v0.6.0 */ - public static Collection filterByRoiCoversNucleus(ROI roi, Collection pathObjects) { - return filterByRoiCovers(roi, pathObjects, PathObjectTools::getNucleusOrMainROI); + public static Collection filterByROICoversNucleus(ROI roi, Collection pathObjects) { + return filterByROICovers(roi, pathObjects, PathObjectTools::getNucleusOrMainROI); } - private static Collection filterByRoiCovers(ROI roi, Collection pathObjects, + private static Collection filterByROICovers(ROI roi, Collection pathObjects, Function roiExtractor) { Predicate predicate; var geom = roi.getGeometry(); @@ -333,7 +333,7 @@ private static Collection filterByRoiCovers(ROI roi, C } else { predicate = createGeometryPredicate(geom::covers, roiExtractor); } - return filterByRoiPredicate(roi, predicate, pathObjects); + return filterByROIPredicate(roi, predicate, pathObjects); } /** @@ -344,10 +344,10 @@ private static Collection filterByRoiCovers(ROI roi, C * @param * @return a new collection that contains only the objects that intersect with the ROI * @since v0.6.0 - * @see #filterByRoiIntersectsNucleus(ROI, Collection) + * @see #filterByROIIntersectsNucleus(ROI, Collection) */ - public static Collection filterByRoiIntersects(ROI roi, Collection pathObjects) { - return filterByRoiIntersects(roi, pathObjects, PathObject::getROI); + public static Collection filterByROIIntersects(ROI roi, Collection pathObjects) { + return filterByROIIntersects(roi, pathObjects, PathObject::getROI); } /** @@ -360,13 +360,13 @@ public static Collection filterByRoiIntersects(ROI roi * @return a new collection that contains only the objects that intersect with the ROI, using the nucleus ROI * where available and main ROI otherwise * @since v0.6.0 - * @see #filterByRoiIntersects(ROI, Collection) + * @see #filterByROIIntersects(ROI, Collection) */ - public static Collection filterByRoiIntersectsNucleus(ROI roi, Collection pathObjects) { - return filterByRoiIntersects(roi, pathObjects, PathObjectTools::getNucleusOrMainROI); + public static Collection filterByROIIntersectsNucleus(ROI roi, Collection pathObjects) { + return filterByROIIntersects(roi, pathObjects, PathObjectTools::getNucleusOrMainROI); } - private static Collection filterByRoiIntersects(ROI roi, Collection pathObjects, + private static Collection filterByROIIntersects(ROI roi, Collection pathObjects, Function roiExtractor) { Predicate predicate; var geom = roi.getGeometry(); @@ -376,7 +376,7 @@ private static Collection filterByRoiIntersects(ROI ro } else { predicate = createGeometryPredicate(geom::intersects, roiExtractor); } - return filterByRoiPredicate(roi, predicate, pathObjects); + return filterByROIPredicate(roi, predicate, pathObjects); } /** @@ -389,7 +389,7 @@ private static Collection filterByRoiIntersects(ROI ro * @param * @since v0.6.0 */ - private static Collection filterByRoiPredicate(ROI roi, Predicate predicate, Collection pathObjects) { + private static Collection filterByROIPredicate(ROI roi, Predicate predicate, Collection pathObjects) { var planePredicate = createPlanePredicate(roi.getImagePlane()); return pathObjects .parallelStream() @@ -417,8 +417,8 @@ private static Predicate createGeometryPredicate(Predicate * @param * @since v0.6.0 */ - public static Collection filterByRoiContainsCentroid(ROI roi, Collection pathObjects) { - return filterByRoiPredicate(roi, p -> roi.contains( + public static Collection filterByROIContainsCentroid(ROI roi, Collection pathObjects) { + return filterByROIPredicate(roi, p -> roi.contains( p.getROI().getCentroidX(), p.getROI().getCentroidY()), pathObjects); } @@ -433,7 +433,7 @@ public static Collection filterByRoiContainsCentroid(R * @since v0.6.0 */ public static Collection filterByRoiContainsNucleusCentroid(ROI roi, Collection pathObjects) { - return filterByRoiPredicate(roi, p -> roi.contains( + return filterByROIPredicate(roi, p -> roi.contains( getNucleusOrMainROI(p).getCentroidX(), getNucleusOrMainROI(p).getCentroidY()), pathObjects); } diff --git a/qupath-core/src/main/java/qupath/lib/objects/hierarchy/PathObjectHierarchy.java b/qupath-core/src/main/java/qupath/lib/objects/hierarchy/PathObjectHierarchy.java index d9c2e2506..db68dc353 100644 --- a/qupath-core/src/main/java/qupath/lib/objects/hierarchy/PathObjectHierarchy.java +++ b/qupath-core/src/main/java/qupath/lib/objects/hierarchy/PathObjectHierarchy.java @@ -30,7 +30,6 @@ import java.util.Comparator; import java.util.HashMap; import java.util.HashSet; -import java.util.Iterator; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; @@ -997,9 +996,9 @@ public Collection getObjectsForRegion(Class cl * @return collection containing identified objects (same as the input collection, if provided) * @see #getAllObjectsForROI(ROI) * @see #getAllObjectsForRegion(ImageRegion) - * @see PathObjectTools#filterByRoiCovers(ROI, Collection) - * @see PathObjectTools#filterByRoiIntersects(ROI, Collection) (ROI, Collection) - * @see PathObjectTools#filterByRoiContainsCentroid(ROI, Collection) (ROI, Collection) (ROI, Collection) + * @see PathObjectTools#filterByROICovers(ROI, Collection) + * @see PathObjectTools#filterByROIIntersects(ROI, Collection) (ROI, Collection) + * @see PathObjectTools#filterByROIContainsCentroid(ROI, Collection) (ROI, Collection) (ROI, Collection) * @since v0.6.0 */ public Collection getAllObjectsForRegion(ImageRegion region, Collection pathObjects) { @@ -1014,9 +1013,9 @@ public Collection getAllObjectsForRegion(ImageRegion region, Collect * @return collection containing identified objects (same as the input collection, if provided) * @see #getAllObjectsForROI(ROI) * @see #getAllObjectsForRegion(ImageRegion, Collection) - * @see PathObjectTools#filterByRoiCovers(ROI, Collection) - * @see PathObjectTools#filterByRoiIntersects(ROI, Collection) (ROI, Collection) - * @see PathObjectTools#filterByRoiContainsCentroid(ROI, Collection) (ROI, Collection) (ROI, Collection) + * @see PathObjectTools#filterByROICovers(ROI, Collection) + * @see PathObjectTools#filterByROIIntersects(ROI, Collection) (ROI, Collection) + * @see PathObjectTools#filterByROIContainsCentroid(ROI, Collection) (ROI, Collection) (ROI, Collection) * @since v0.6.0 */ public Collection getAllObjectsForRegion(ImageRegion region) { diff --git a/qupath-core/src/test/java/qupath/lib/objects/TestPathObjectTools.java b/qupath-core/src/test/java/qupath/lib/objects/TestPathObjectTools.java index 41a6370a3..fec5a6f48 100644 --- a/qupath-core/src/test/java/qupath/lib/objects/TestPathObjectTools.java +++ b/qupath-core/src/test/java/qupath/lib/objects/TestPathObjectTools.java @@ -197,29 +197,29 @@ public void testRoiFilters() { // Test covers assertEquals(List.of(roiRect, roiEllipse, roiDiamond), - PathObjectTools.filterByRoiCovers(roiRect, pathObjects).stream().map(PathObject::getROI).toList()); + PathObjectTools.filterByROICovers(roiRect, pathObjects).stream().map(PathObject::getROI).toList()); // Every other ROI only completely covers itself for (var roi : List.of(roiEllipse, roiDiamond, roiOverlaps, roiOverlaps2, roiSeparate)) { assertEquals(List.of(roi), - PathObjectTools.filterByRoiCovers(roi, pathObjects).stream().map(PathObject::getROI).toList()); + PathObjectTools.filterByROICovers(roi, pathObjects).stream().map(PathObject::getROI).toList()); } // Test intersects // One ROI is separate, all other intersect assertEquals(List.of(roiSeparate), - PathObjectTools.filterByRoiIntersects(roiSeparate, pathObjects).stream().map(PathObject::getROI).toList()); + PathObjectTools.filterByROIIntersects(roiSeparate, pathObjects).stream().map(PathObject::getROI).toList()); for (var roi : List.of(roiRect, roiEllipse, roiDiamond, roiOverlaps, roiOverlaps2)) { assertEquals(List.of(roiRect, roiEllipse, roiDiamond, roiOverlaps, roiOverlaps2), - PathObjectTools.filterByRoiIntersects(roi, pathObjects).stream().map(PathObject::getROI).toList()); + PathObjectTools.filterByROIIntersects(roi, pathObjects).stream().map(PathObject::getROI).toList()); } // Test contains centroid // One ROI is separate, all other intersect assertEquals(List.of(roiSeparate), - PathObjectTools.filterByRoiContainsCentroid(roiSeparate, pathObjects).stream().map(PathObject::getROI).toList()); + PathObjectTools.filterByROIContainsCentroid(roiSeparate, pathObjects).stream().map(PathObject::getROI).toList()); for (var roi : List.of(roiRect, roiEllipse, roiDiamond, roiOverlaps, roiOverlaps2)) { assertEquals(List.of(roiRect, roiEllipse, roiDiamond, roiOverlaps, roiOverlaps2), - PathObjectTools.filterByRoiContainsCentroid(roi, pathObjects).stream().map(PathObject::getROI).toList()); + PathObjectTools.filterByROIContainsCentroid(roi, pathObjects).stream().map(PathObject::getROI).toList()); } } From 9d5790e0efca9edcdb6de2d321aaef58d8c52b2c Mon Sep 17 00:00:00 2001 From: Pete Date: Fri, 2 Aug 2024 14:05:22 +0100 Subject: [PATCH 148/866] Update PathObjectTools.java --- .../src/main/java/qupath/lib/objects/PathObjectTools.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/qupath-core/src/main/java/qupath/lib/objects/PathObjectTools.java b/qupath-core/src/main/java/qupath/lib/objects/PathObjectTools.java index a5de8790b..3c84e4804 100644 --- a/qupath-core/src/main/java/qupath/lib/objects/PathObjectTools.java +++ b/qupath-core/src/main/java/qupath/lib/objects/PathObjectTools.java @@ -410,7 +410,7 @@ private static Predicate createGeometryPredicate(Predicate /** * Filter a collection of PathObjects to identify those with centroids that fall within specified ROI. * Note that, for cell objects, this tests the main (outer) ROI only. - * If you want to use the nucleus centroid, use {@link #filterByRoiContainsNucleusCentroid(ROI, Collection)}. + * If you want to use the nucleus centroid, use {@link #filterByROIContainsNucleusCentroid(ROI, Collection)}. * @param roi the ROI to test against * @param pathObjects the objects to filter * @return a new collection that contains only the objects with centroids that fall within the ROI @@ -432,7 +432,7 @@ public static Collection filterByROIContainsCentroid(R * @param * @since v0.6.0 */ - public static Collection filterByRoiContainsNucleusCentroid(ROI roi, Collection pathObjects) { + public static Collection filterByROIContainsNucleusCentroid(ROI roi, Collection pathObjects) { return filterByROIPredicate(roi, p -> roi.contains( getNucleusOrMainROI(p).getCentroidX(), getNucleusOrMainROI(p).getCentroidY()), pathObjects); @@ -1298,13 +1298,13 @@ public static Map> splitAreasByBufferedLines(Collec private static List splitObjectBySubtraction(PathObject pathObject, ROI... roisToSubtract) { - var rois = splitRoiBySubtraction(pathObject.getROI(), roisToSubtract); + var rois = splitROIBySubtraction(pathObject.getROI(), roisToSubtract); return rois.stream() .map(roi -> PathObjectTools.createLike(pathObject, roi)) .toList(); } - private static List splitRoiBySubtraction(ROI roi, ROI... roisToSubtract) { + private static List splitROIBySubtraction(ROI roi, ROI... roisToSubtract) { if (roi == null) return Collections.emptyList(); if (roisToSubtract.length == 0) From 67ea52e2000ea95f9aca132d15fcc4fc27aef507 Mon Sep 17 00:00:00 2001 From: Pete Date: Fri, 2 Aug 2024 14:45:27 +0100 Subject: [PATCH 149/866] Dependency updates --- CHANGELOG.md | 6 +++--- gradle/libs.versions.toml | 6 +++--- gradle/wrapper/gradle-wrapper.properties | 2 +- gradlew | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d31d6fa04..c0a9d4235 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,13 +31,13 @@ This is a *work in progress* for the next major release. * QuPath doesn't always use the specified file extension when exporting snapshots (https://github.com/qupath/qupath/issues/1567) ### Dependency updates -* Bio-Formats 7.3.0 +* Bio-Formats 7.3.1 * Commonmark 0.22.0 -* DeepJavaLibrary 0.28.0 +* DeepJavaLibrary 0.29.0 * Groovy 4.0.22 * Gson 2.11.0 * Guava 33.2.0-jre -* JavaFX 22.0.1 +* JavaFX 22.0.2 * JFreeSVG 5.0.6 * JNA 5.14.0 * Picocli 4.7.6 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index dad520cca..0ada58d00 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,6 @@ [versions] -bioformats = "7.3.0" +bioformats = "7.3.1" bioimageIoSpec = "0.1.0" omeZarrReader = "0.5.1" blosc = "1.21.5" @@ -10,7 +10,7 @@ commonsMath3 = "3.6.1" commonsText = "1.10.0" controlsFX = "11.1.2" -deepJavaLibrary = "0.28.0" +deepJavaLibrary = "0.29.0" groovy = "4.0.22" gson = "2.11.0" @@ -28,7 +28,7 @@ opencv = "4.9.0-1.5.10" cuda = "12.3-8.9-1.5.10" # Warning! JavaFX 20.0.1 and later seem to break search links in Javadocs -javafx = "22.0.1" +javafx = "22.0.2" jna = "5.14.0" jfreeSvg = "5.0.6" jfxtras = "17-r1" diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index a4413138c..09523c0e5 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew index 1aa94a426..b740cf133 100755 --- a/gradlew +++ b/gradlew @@ -55,7 +55,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. From 2eae08cb33ec2564f42acd3383468a10075ab824 Mon Sep 17 00:00:00 2001 From: Pete Date: Fri, 2 Aug 2024 16:51:04 +0100 Subject: [PATCH 150/866] ImageData constructors and javadocs --- .../java/qupath/lib/images/ImageData.java | 71 ++++++++----------- .../src/main/java/qupath/lib/io/PathIO.java | 2 +- .../qupath/lib/projects/DefaultProject.java | 2 +- 3 files changed, 33 insertions(+), 42 deletions(-) diff --git a/qupath-core/src/main/java/qupath/lib/images/ImageData.java b/qupath-core/src/main/java/qupath/lib/images/ImageData.java index 29f447f44..d5a9c4a14 100644 --- a/qupath-core/src/main/java/qupath/lib/images/ImageData.java +++ b/qupath-core/src/main/java/qupath/lib/images/ImageData.java @@ -138,11 +138,16 @@ public String toString() { /** * Create a new ImageData with a specified object hierarchy and type. - * @param server - * @param hierarchy - * @param type + * @param supplier supplier to use if the server is to be loaded lazily; may be null to se server instead + * @param server server to use directly; may be null to use supplier instead + * @param hierarchy an object hierarchy, or null to create a new one + * @param type the image type, or null to default to ImageType.UNSET + * @throws IllegalArgumentException if neither a server nor a server supplier is provided */ - public ImageData(Supplier> supplier, ImageServer server, PathObjectHierarchy hierarchy, ImageType type) { + private ImageData(Supplier> supplier, ImageServer server, PathObjectHierarchy hierarchy, ImageType type) + throws IllegalArgumentException { + if (server == null && supplier == null) + throw new IllegalArgumentException("Cannot create ImageData without a server or server supplier"); this.pcs = new PropertyChangeSupport(this); this.serverSupplier = supplier; this.server = server; @@ -161,6 +166,26 @@ public ImageData(Supplier> supplier, ImageServer server, PathO changes = false; } + /** + * Create a new ImageData with a lazily-loaded server, hierarchy and type. + * The supplier provides the ImageServer required to access pixels and metadata on demand. + *

    + * If the server is never requested, then the supplier is not used - which can save time and resources. + * + * @param supplier object to supply the ImageServer + * @param hierarchy an object hierarchy, or null to create a new one + * @param type the image type, or null to default to ImageType.UNSET + */ + public ImageData(Supplier> supplier, PathObjectHierarchy hierarchy, ImageType type) { + this(supplier, null, hierarchy, type); + } + + /** + * Create a new ImageData with a specified server, hierarchy and type. + * @param server server to use to access pixels and metadata + * @param hierarchy an object hierarchy, or null to create a new one + * @param type the image type, or null to default to ImageType.UNSET + */ public ImageData(ImageServer server, PathObjectHierarchy hierarchy, ImageType type) { this(null, server, hierarchy, type); } @@ -184,7 +209,6 @@ public Workflow getHistoryWorkflow() { } - /** * Create a new ImageData with ImageType.UNKNOWN. * @@ -227,8 +251,7 @@ public void setColorDeconvolutionStains(ColorDeconvolutionStains stains) { pcs.firePropertyChange("stains", stainsOld, stains); addColorDeconvolutionStainsToWorkflow(this); -// logger.error("WARNING: Setting color deconvolution stains is not yet scriptable!!!!"); - + changes = true; } @@ -256,14 +279,6 @@ public void updateServerMetadata(ImageServerMetadata newMetadata) { changes = changes || !oldMetadata.equals(newMetadata); } -// public void setColorDeconvolutionStains(final String stainsString) { -// setColorDeconvolutionStains(ColorDeconvolutionStains.parseColorDeconvolutionStainsArg(stainsString)); -// } -// -// public void setImageType(final String type) { -// setImageType(ImageType.valueOf(type)); -// } - /** * Returns true if the image type is set to brightfield. * @return @@ -316,10 +331,7 @@ public void setImageType(final ImageType type) { - // TODO: REINTRODUCE LOGGING! private static void addColorDeconvolutionStainsToWorkflow(ImageData imageData) { -// logger.warn("Color deconvolution stain logging not currently enabled!"); - ColorDeconvolutionStains stains = imageData.getColorDeconvolutionStains(); if (stains == null) { return; @@ -333,29 +345,8 @@ private static void addColorDeconvolutionStainsToWorkflow(ImageData imageData map, "setColorDeconvolutionStains(\'" + arg + "');"); -// if (lastStep != null && commandName.equals(lastStep.getName())) -// imageData.getHistoryWorkflow().replaceLastStep(newStep); -// else if (!Objects.equals(newStep, lastStep)) imageData.getHistoryWorkflow().addStep(newStep); - - -// ColorDeconvolutionStains stains = imageData.getColorDeconvolutionStains(); -// if (stains == null) -// return; -// -// String arg = ColorDeconvolutionStains.getColorDeconvolutionStainsAsString(imageData.getColorDeconvolutionStains(), 5); -// Map map = GeneralTools.parseArgStringValues(arg); -// WorkflowStep lastStep = imageData.getWorkflow().getLastStep(); -// String commandName = "Set color deconvolution stains"; -// WorkflowStep newStep = new DefaultScriptableWorkflowStep(commandName, -// map, -// QP.class.getSimpleName() + ".setColorDeconvolutionStains(\'" + arg + "');"); -// -// if (lastStep != null && commandName.equals(lastStep.getName())) -// imageData.getWorkflow().replaceLastStep(newStep); -// else -// imageData.getWorkflow().addStep(newStep); } /** @@ -453,7 +444,6 @@ public Object setProperty(String key, Object value) { else changes = changes || !oldValue.equals(value); logger.trace("Setting property: {}: {}", key, value); -// System.err.println(changes + " setting " + key + " to " + value); if (oldValue != value) pcs.firePropertyChange(key, oldValue, value); return oldValue; @@ -538,6 +528,7 @@ public void workflowUpdated(Workflow workflow) { /** * Close the server if it has been loaded. + * Note that this should not be called if the server is still in use. * @throws Exception */ @Override diff --git a/qupath-core/src/main/java/qupath/lib/io/PathIO.java b/qupath-core/src/main/java/qupath/lib/io/PathIO.java index 29f194935..4c779d9bb 100644 --- a/qupath-core/src/main/java/qupath/lib/io/PathIO.java +++ b/qupath-core/src/main/java/qupath/lib/io/PathIO.java @@ -430,7 +430,7 @@ else if (input == null) { hierarchy2.setHierarchy(hierarchy); hierarchy = hierarchy2; - imageData = new ImageData<>(serverSupplier, null, hierarchy, imageType); + imageData = new ImageData<>(serverSupplier, hierarchy, imageType); } else { if (imageType != null) imageData.setImageType(imageType); diff --git a/qupath-core/src/main/java/qupath/lib/projects/DefaultProject.java b/qupath-core/src/main/java/qupath/lib/projects/DefaultProject.java index 0dac92430..56f8aee90 100644 --- a/qupath-core/src/main/java/qupath/lib/projects/DefaultProject.java +++ b/qupath-core/src/main/java/qupath/lib/projects/DefaultProject.java @@ -729,7 +729,7 @@ public synchronized ImageData readImageData() throws IOException } if (imageData == null) - imageData = new ImageData<>(serverSupplier, null, new PathObjectHierarchy(), ImageType.UNSET); + imageData = new ImageData<>(serverSupplier, new PathObjectHierarchy(), ImageType.UNSET); imageData.setProperty(IMAGE_ID, getFullProjectEntryID()); // Required to be able to test for the ID later imageData.setChanged(false); return imageData; From 372e836a6bcfab45fd8bfcb201314337ee264006 Mon Sep 17 00:00:00 2001 From: Pete Date: Fri, 2 Aug 2024 17:27:33 +0100 Subject: [PATCH 151/866] Fix failing test We no longer permit an ImageData with a null server. --- qupath-core/src/main/java/qupath/lib/images/ImageData.java | 4 +--- .../lib/gui/measure/TestObservableMeasurementTableData.java | 5 ++++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/qupath-core/src/main/java/qupath/lib/images/ImageData.java b/qupath-core/src/main/java/qupath/lib/images/ImageData.java index d5a9c4a14..95a770e57 100644 --- a/qupath-core/src/main/java/qupath/lib/images/ImageData.java +++ b/qupath-core/src/main/java/qupath/lib/images/ImageData.java @@ -118,7 +118,6 @@ public String toString() { private String lastSavedPath = null; - private String serverPath; private PathObjectHierarchy hierarchy; private ImageType type = ImageType.UNSET; @@ -152,7 +151,6 @@ private ImageData(Supplier> supplier, ImageServer server, Path this.serverSupplier = supplier; this.server = server; this.hierarchy = hierarchy == null ? new PathObjectHierarchy() : hierarchy; - this.serverPath = server == null ? null : server.getPath(); // TODO: Deal with sub image servers initializeStainMap(); if (type == null) type = ImageType.UNSET; @@ -373,7 +371,7 @@ public ImageServer getServer() { * @return */ public String getServerPath() { - return serverPath; + return getServer().getPath(); } /** diff --git a/qupath-gui-fx/src/test/java/qupath/lib/gui/measure/TestObservableMeasurementTableData.java b/qupath-gui-fx/src/test/java/qupath/lib/gui/measure/TestObservableMeasurementTableData.java index 99eef4a74..ded1f476e 100644 --- a/qupath-gui-fx/src/test/java/qupath/lib/gui/measure/TestObservableMeasurementTableData.java +++ b/qupath-gui-fx/src/test/java/qupath/lib/gui/measure/TestObservableMeasurementTableData.java @@ -31,6 +31,7 @@ import org.junit.jupiter.api.Test; import qupath.lib.images.ImageData; +import qupath.lib.images.servers.WrappedBufferedImageServer; import qupath.lib.objects.PathObject; import qupath.lib.objects.PathObjects; import qupath.lib.objects.classes.PathClass; @@ -60,7 +61,9 @@ public void test() { // See https://github.com/locationtech/jts/issues/571 for (int counter = 0; counter < 50; counter++) { - ImageData imageData = new ImageData<>(null); + ImageData imageData = new ImageData<>( + new WrappedBufferedImageServer("Dummy", + new BufferedImage(50, 50, BufferedImage.TYPE_INT_RGB))); PathClass tumorClass = PathClass.StandardPathClasses.TUMOR; PathClass stromaClass = PathClass.StandardPathClasses.STROMA; From cfd832ab41c72438108524ed855f0e6f38ce1eef Mon Sep 17 00:00:00 2001 From: Pete Date: Fri, 2 Aug 2024 17:52:30 +0100 Subject: [PATCH 152/866] New ImageData.getServerMetadata() method Support accessing the metadata for an ImageServer directly for the `ImageData`. This is a step towards making metadata accessible without having to load the full image. --- .../detect/cells/WatershedCellDetection.java | 4 +-- .../algorithms/HaralickFeaturesPlugin.java | 2 +- .../main/java/qupath/lib/scripting/QP.java | 19 +++++----- .../main/java/qupath/opencv/CellCountsCV.java | 2 +- .../java/qupath/opencv/ops/ImageOpServer.java | 2 +- .../main/java/qupath/opencv/ops/ImageOps.java | 4 +-- .../java/qupath/lib/images/ImageData.java | 17 +++++++++ .../CreateChannelTrainingImagesCommand.java | 2 +- ...ingleMeasurementClassificationCommand.java | 4 +-- .../java/qupath/lib/gui/ToolBarComponent.java | 2 +- .../gui/commands/ExportObjectsCommand.java | 2 +- .../BrightnessContrastChannelPane.java | 2 +- .../images/servers/RenderedImageServer.java | 2 +- .../ObservableMeasurementTableData.java | 35 +++++++++---------- .../java/qupath/lib/gui/scripting/QPEx.java | 2 +- .../java/qupath/lib/gui/tools/GuiTools.java | 5 +-- .../lib/gui/viewer/recording/ViewTracker.java | 2 +- 17 files changed, 59 insertions(+), 49 deletions(-) diff --git a/qupath-core-processing/src/main/java/qupath/imagej/detect/cells/WatershedCellDetection.java b/qupath-core-processing/src/main/java/qupath/imagej/detect/cells/WatershedCellDetection.java index 293421a6a..3c15e89e0 100644 --- a/qupath-core-processing/src/main/java/qupath/imagej/detect/cells/WatershedCellDetection.java +++ b/qupath-core-processing/src/main/java/qupath/imagej/detect/cells/WatershedCellDetection.java @@ -292,7 +292,7 @@ public Collection runDetection(final ImageData imageD } //else { if (fpDetection == null) { - List imageChannels = imageData.getServer().getMetadata().getChannels(); + List imageChannels = imageData.getServerMetadata().getChannels(); if (ip instanceof ColorProcessor) { for (int c = 0; c < 3; c++) { String name = imageChannels.get(c).getName(); @@ -415,7 +415,7 @@ private ParameterList buildParameterList(final ImageData imageDat String defaultChannel = null; List channelNames = new ArrayList<>(); String[] nucleusGuesses = new String[] {"dapi", "hoechst", "nucleus", "nuclei", "nuclear", "hematoxylin", "haematoxylin"}; - for (ImageChannel channel : imageData.getServer().getMetadata().getChannels()) { + for (ImageChannel channel : imageData.getServerMetadata().getChannels()) { String name = channel.getName(); channelNames.add(name); if (defaultChannel == null) { diff --git a/qupath-core-processing/src/main/java/qupath/lib/algorithms/HaralickFeaturesPlugin.java b/qupath-core-processing/src/main/java/qupath/lib/algorithms/HaralickFeaturesPlugin.java index e0e8dd907..52c9337d0 100644 --- a/qupath-core-processing/src/main/java/qupath/lib/algorithms/HaralickFeaturesPlugin.java +++ b/qupath-core-processing/src/main/java/qupath/lib/algorithms/HaralickFeaturesPlugin.java @@ -446,7 +446,7 @@ static void addHaralickFeatures(final HaralickFeatures haralickFeatures, final M @Override public ParameterList getDefaultParameterList(final ImageData imageData) { boolean hasMicrons = imageData.getServer().getPixelCalibration().hasPixelSizeMicrons(); - boolean hasMagnification = !Double.isNaN(imageData.getServer().getMetadata().getMagnification()); + boolean hasMagnification = !Double.isNaN(imageData.getServerMetadata().getMagnification()); params.getParameters().get("tileSizeMicrons").setHidden(!hasMicrons); params.getParameters().get("tileSizePx").setHidden(hasMicrons); diff --git a/qupath-core-processing/src/main/java/qupath/lib/scripting/QP.java b/qupath-core-processing/src/main/java/qupath/lib/scripting/QP.java index 73d3a95dd..eae827bde 100644 --- a/qupath-core-processing/src/main/java/qupath/lib/scripting/QP.java +++ b/qupath-core-processing/src/main/java/qupath/lib/scripting/QP.java @@ -1331,7 +1331,7 @@ public static void setChannelNames(String... names) { * @param names */ public static void setChannelNames(ImageData imageData, String... names) { - List oldChannels = imageData.getServer().getMetadata().getChannels(); + List oldChannels = imageData.getServerMetadata().getChannels(); List newChannels = new ArrayList<>(oldChannels); for (int i = 0; i < names.length; i++) { String name = names[i]; @@ -1368,7 +1368,7 @@ public static void setChannelColors(Integer... colors) { * @see #setChannelNames(ImageData, String...) */ public static void setChannelColors(ImageData imageData, Integer... colors) { - List oldChannels = imageData.getServer().getMetadata().getChannels(); + List oldChannels = imageData.getServerMetadata().getChannels(); List newChannels = new ArrayList<>(oldChannels); for (int i = 0; i < colors.length; i++) { Integer color = colors[i]; @@ -1406,11 +1406,11 @@ public static void setChannels(ImageChannel... channels) { * @see #setChannelColors(ImageData, Integer...) */ public static void setChannels(ImageData imageData, ImageChannel... channels) { - ImageServer server = imageData.getServer(); - if (server.isRGB()) { + var metadata = imageData.getServerMetadata(); + if (metadata.isRGB()) { throw new IllegalArgumentException("Cannot set channels for RGB images"); } - List oldChannels = server.getMetadata().getChannels(); + List oldChannels = metadata.getChannels(); List newChannels = Arrays.asList(channels); if (oldChannels.equals(newChannels)) { logger.trace("Setting channels to the same values (no changes)"); @@ -1420,7 +1420,6 @@ public static void setChannels(ImageData imageData, ImageChannel... channels) throw new IllegalArgumentException("Cannot set channels - require " + oldChannels.size() + " channels but you provided " + channels.length); // Set the metadata - var metadata = server.getMetadata(); var metadata2 = new ImageServerMetadata.Builder(metadata) .channels(newChannels) .build(); @@ -3486,17 +3485,17 @@ public static void detectionToAnnotationDistancesSigned(boolean splitClassNames) * @return true if the size was set, false otherwise */ public static boolean setPixelSizeMicrons(ImageData imageData, Number pixelWidthMicrons, Number pixelHeightMicrons, Number zSpacingMicrons) { - var server = imageData.getServer(); if (isFinite(pixelWidthMicrons) && !isFinite(pixelHeightMicrons)) pixelHeightMicrons = pixelWidthMicrons; else if (isFinite(pixelHeightMicrons) && !isFinite(pixelWidthMicrons)) pixelWidthMicrons = pixelHeightMicrons; - - var metadataNew = new ImageServerMetadata.Builder(server.getMetadata()) + + var serverMetadata = imageData.getServerMetadata(); + var metadataNew = new ImageServerMetadata.Builder(serverMetadata) .pixelSizeMicrons(pixelWidthMicrons, pixelHeightMicrons) .zSpacingMicrons(zSpacingMicrons) .build(); - if (server.getMetadata().equals(metadataNew)) + if (serverMetadata.equals(metadataNew)) return false; imageData.updateServerMetadata(metadataNew); return true; diff --git a/qupath-core-processing/src/main/java/qupath/opencv/CellCountsCV.java b/qupath-core-processing/src/main/java/qupath/opencv/CellCountsCV.java index 9bed37ae3..a92c1a2af 100644 --- a/qupath-core-processing/src/main/java/qupath/opencv/CellCountsCV.java +++ b/qupath-core-processing/src/main/java/qupath/opencv/CellCountsCV.java @@ -115,7 +115,7 @@ public Collection runDetection(final ImageData imageD // Get the filter size & calculate a suitable downsample value double gaussianSigma; double backgroundRadius; - double downsample = imageData.getServer().getMetadata().getMagnification() / magnification; + double downsample = imageData.getServerMetadata().getMagnification() / magnification; if (downsample < 1) downsample = 1; if (hasMicrons) { diff --git a/qupath-core-processing/src/main/java/qupath/opencv/ops/ImageOpServer.java b/qupath-core-processing/src/main/java/qupath/opencv/ops/ImageOpServer.java index a516b3f5a..4d77757f7 100644 --- a/qupath-core-processing/src/main/java/qupath/opencv/ops/ImageOpServer.java +++ b/qupath-core-processing/src/main/java/qupath/opencv/ops/ImageOpServer.java @@ -63,7 +63,7 @@ class ImageOpServer extends AbstractTileableImageServer implements ImageDataServ // Update channels according to the op var channels = dataOp.getChannels(imageData); - metadata = new ImageServerMetadata.Builder(imageData.getServer().getMetadata()) + metadata = new ImageServerMetadata.Builder(imageData.getServerMetadata()) .levelsFromDownsamples(downsample) .preferredTileSize(tileWidth, tileHeight) .pixelType(pixelType) diff --git a/qupath-core-processing/src/main/java/qupath/opencv/ops/ImageOps.java b/qupath-core-processing/src/main/java/qupath/opencv/ops/ImageOps.java index 4a33ce377..81c8b7c7b 100644 --- a/qupath-core-processing/src/main/java/qupath/opencv/ops/ImageOps.java +++ b/qupath-core-processing/src/main/java/qupath/opencv/ops/ImageOps.java @@ -333,9 +333,9 @@ public Mat apply(ImageData imageData, RegionRequest request) thro @Override public List getChannels(ImageData imageData) { if (op == null) - return imageData.getServer().getMetadata().getChannels(); + return imageData.getServerMetadata().getChannels(); else - return op.getChannels(imageData.getServer().getMetadata().getChannels()); + return op.getChannels(imageData.getServerMetadata().getChannels()); } @Override diff --git a/qupath-core/src/main/java/qupath/lib/images/ImageData.java b/qupath-core/src/main/java/qupath/lib/images/ImageData.java index 95a770e57..896e57fdd 100644 --- a/qupath-core/src/main/java/qupath/lib/images/ImageData.java +++ b/qupath-core/src/main/java/qupath/lib/images/ImageData.java @@ -276,6 +276,23 @@ public void updateServerMetadata(ImageServerMetadata newMetadata) { pcs.firePropertyChange("serverMetadata", oldMetadata, newMetadata); changes = changes || !oldMetadata.equals(newMetadata); } + + /** + * Get the metadata for the server. + *

    + * If the server has not yet been lazy-loaded and {@code updateServerMetadata} has been called to specify the + * metadata that should be used, then that cached metadata will be returned directly without loading the server. + *

    + * In all other cases this is equivalent to {@code getServer().getMetadata()}. + * @return + */ + public ImageServerMetadata getServerMetadata() { + if (server == null && lazyMetadata != null) { + logger.trace("Returning lazy metadata"); + return lazyMetadata; + } + return getServer().getMetadata(); + } /** * Returns true if the image type is set to brightfield. diff --git a/qupath-extension-processing/src/main/java/qupath/process/gui/commands/CreateChannelTrainingImagesCommand.java b/qupath-extension-processing/src/main/java/qupath/process/gui/commands/CreateChannelTrainingImagesCommand.java index fa9615519..ff78f3451 100644 --- a/qupath-extension-processing/src/main/java/qupath/process/gui/commands/CreateChannelTrainingImagesCommand.java +++ b/qupath-extension-processing/src/main/java/qupath/process/gui/commands/CreateChannelTrainingImagesCommand.java @@ -78,7 +78,7 @@ public void run() { return; } - var channels = new ArrayList<>(imageData.getServer().getMetadata().getChannels()); + var channels = new ArrayList<>(imageData.getServerMetadata().getChannels()); var list = new CheckListView(); list.getItems().setAll(channels); diff --git a/qupath-extension-processing/src/main/java/qupath/process/gui/commands/SingleMeasurementClassificationCommand.java b/qupath-extension-processing/src/main/java/qupath/process/gui/commands/SingleMeasurementClassificationCommand.java index a475ab442..afbf0ca6e 100644 --- a/qupath-extension-processing/src/main/java/qupath/process/gui/commands/SingleMeasurementClassificationCommand.java +++ b/qupath-extension-processing/src/main/java/qupath/process/gui/commands/SingleMeasurementClassificationCommand.java @@ -466,7 +466,7 @@ void refreshTitle() { imageName = entry.getImageName(); } if (imageName == null) - imageName = imageData.getServer().getMetadata().getName(); + imageName = imageData.getServerMetadata().getName(); titleProperty.set(title + " (" + imageName + ")"); } } @@ -495,7 +495,7 @@ void refreshChannels() { list.add(stain.getName()); } } - for (var channel : imageData.getServer().getMetadata().getChannels()) { + for (var channel : imageData.getServerMetadata().getChannels()) { list.add(channel.getName()); } } diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/ToolBarComponent.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/ToolBarComponent.java index 475ef003d..813fa23fb 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/ToolBarComponent.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/ToolBarComponent.java @@ -356,7 +356,7 @@ private void refreshMagnificationTooltip() { if (tooltipMag == null || viewer == null) return; var imageData = viewer.getImageData(); - var mag = imageData == null ? null : imageData.getServer().getMetadata().getMagnification(); + var mag = imageData == null ? null : imageData.getServerMetadata().getMagnification(); if (imageData == null) tooltipMag.setText(getName("magnification")); else if (mag != null && !Double.isNaN(mag)) diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/ExportObjectsCommand.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/ExportObjectsCommand.java index 44510b4bf..3ce418908 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/ExportObjectsCommand.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/ExportObjectsCommand.java @@ -120,7 +120,7 @@ public static boolean runGeoJsonExport(QuPathGUI qupath) throws IOException { File outFile; // Get default name & output directory var project = qupath.getProject(); - String defaultName = imageData.getServer().getMetadata().getName(); + String defaultName = imageData.getServerMetadata().getName(); if (project != null) { var entry = project.getEntry(imageData); if (entry != null) diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/display/BrightnessContrastChannelPane.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/display/BrightnessContrastChannelPane.java index b22beb0b6..d53cf2e6c 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/display/BrightnessContrastChannelPane.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/display/BrightnessContrastChannelPane.java @@ -455,7 +455,7 @@ private void handleTableRowMouseClick(TableRow row, MouseEve var imageData = getImageData(); if (imageData != null && info instanceof DirectServerChannelInfo multiInfo) { int c = multiInfo.getChannel(); - var channel = imageData.getServer().getMetadata().getChannel(c); + var channel = imageData.getServerMetadata().getChannel(c); Color color = ColorToolsFX.getCachedColor(multiInfo.getColor()); picker.setValue(color); diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/images/servers/RenderedImageServer.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/images/servers/RenderedImageServer.java index 7a6b22ae3..a073f9219 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/images/servers/RenderedImageServer.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/images/servers/RenderedImageServer.java @@ -85,7 +85,7 @@ private RenderedImageServer(DefaultImageRegionStore store, ImageData 1)) { + var serverMetadata = imageData == null ? null : imageData.getServerMetadata(); + if (containsMultiZ || (containsROIs && serverMetadata != null && serverMetadata.getSizeZ() > 1)) { builderMap.put("Z index", new ZSliceMeasurementBuilder()); } - if (containsMultiT || (containsROIs && imageServer != null && imageServer.nTimepoints() > 1)) { + if (containsMultiT || (containsROIs && serverMetadata != null && serverMetadata.getSizeT() > 1)) { builderMap.put("Time index", new TimepointMeasurementBuilder()); } @@ -712,7 +712,7 @@ else if (PathClassTools.isPositiveClass(pathClass) || PathClassTools.isNegativeC for (PathClass pathClass : pathClassList) { if (PathClassTools.isPositiveClass(pathClass) && pathClass.getBaseClass() == pathClass) // if (!(PathClassFactory.isDefaultIntensityClass(pathClass) || PathClassFactory.isNegativeClass(pathClass))) - builders.add(new ClassDensityMeasurementBuilder(imageData.getServer(), pathClass)); + builders.add(new ClassDensityMeasurementBuilder(pathClass)); } } @@ -757,12 +757,10 @@ protected int computeValue() { class ClassDensityMeasurementPerMM extends DoubleBinding { - private ImageServer server; private PathObject pathObject; private PathClass pathClass; - public ClassDensityMeasurementPerMM(final ImageServer server, final PathObject pathObject, final PathClass pathClass) { - this.server = server; + public ClassDensityMeasurementPerMM(final PathObject pathObject, final PathClass pathClass) { this.pathObject = pathObject; this.pathClass = pathClass; } @@ -791,13 +789,14 @@ protected double computeValue() { int n = counts.getCountForAncestor(pathClass); ROI roi = pathObjectTemp.getROI(); // For the root, we can measure density only for 2D images of a single time-point - if (pathObjectTemp.isRootObject() && server.nZSlices() == 1 && server.nTimepoints() == 1) - roi = ROIs.createRectangleROI(0, 0, server.getWidth(), server.getHeight(), ImagePlane.getDefaultPlane()); + var serverMetadata = imageData.getServerMetadata(); + if (pathObjectTemp.isRootObject() && serverMetadata.getSizeZ() == 1 && serverMetadata.getSizeT() == 1) + roi = ROIs.createRectangleROI(0, 0, serverMetadata.getWidth(), serverMetadata.getHeight(), ImagePlane.getDefaultPlane()); if (roi != null && roi.isArea()) { double pixelWidth = 1; double pixelHeight = 1; - PixelCalibration cal = server == null ? null : server.getPixelCalibration(); + PixelCalibration cal = serverMetadata == null ? null : serverMetadata.getPixelCalibration(); if (cal != null && cal.hasPixelSizeMicrons()) { pixelWidth = cal.getPixelWidthMicrons() / 1000; pixelHeight = cal.getPixelHeightMicrons() / 1000; @@ -970,17 +969,15 @@ public String toString() { class ClassDensityMeasurementBuilder extends NumericMeasurementBuilder { - private ImageServer server; private PathClass pathClass; - ClassDensityMeasurementBuilder(final ImageServer server, final PathClass pathClass) { - this.server = server; + ClassDensityMeasurementBuilder(final PathClass pathClass) { this.pathClass = pathClass; } @Override public String getName() { - if (server != null && server.getPixelCalibration().hasPixelSizeMicrons()) + if (imageData != null && imageData.getServerMetadata().getPixelCalibration().hasPixelSizeMicrons()) return String.format("Num %s per mm^2", pathClass.toString()); // return String.format("Num %s per %s^2", pathClass.toString(), GeneralTools.micrometerSymbol()); else @@ -991,7 +988,7 @@ public String getName() { public Binding createMeasurement(final PathObject pathObject) { // Only return density measurements for annotations if (pathObject.isAnnotation() || (pathObject.isTMACore() && pathObject.nChildObjects() == 1)) - return new ClassDensityMeasurementPerMM(server, pathObject, pathClass); + return new ClassDensityMeasurementPerMM(pathObject, pathClass); return Bindings.createDoubleBinding(() -> Double.NaN); } @@ -1410,7 +1407,7 @@ public String getMeasurementValue(PathObject pathObject) { return null; var hierarchy = imageData.getHierarchy(); if (PathObjectTools.hierarchyContainsObject(hierarchy, pathObject)) { - return imageData.getServer().getMetadata().getName(); + return imageData.getServerMetadata().getName(); } return null; } @@ -1490,18 +1487,18 @@ abstract static class RoiMeasurementBuilder extends NumericMeasurementBuilder { } boolean hasPixelSizeMicrons() { - return imageData != null && imageData.getServer() != null && imageData.getServer().getPixelCalibration().hasPixelSizeMicrons(); + return imageData != null && imageData.getServerMetadata().getPixelCalibration().hasPixelSizeMicrons(); } double pixelWidthMicrons() { if (hasPixelSizeMicrons()) - return imageData.getServer().getPixelCalibration().getPixelWidthMicrons(); + return imageData.getServerMetadata().getPixelCalibration().getPixelWidthMicrons(); return Double.NaN; } double pixelHeightMicrons() { if (hasPixelSizeMicrons()) - return imageData.getServer().getPixelCalibration().getPixelHeightMicrons(); + return imageData.getServerMetadata().getPixelCalibration().getPixelHeightMicrons(); return Double.NaN; } diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/scripting/QPEx.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/scripting/QPEx.java index ffce51809..9920a0347 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/scripting/QPEx.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/scripting/QPEx.java @@ -466,7 +466,7 @@ public static void setChannelDisplayRange(ImageData imageData, St } var available = display.availableChannels(); ChannelDisplayInfo info = null; - var serverChannels = imageData.getServer().getMetadata().getChannels(); + var serverChannels = imageData.getServerMetadata().getChannels(); for (var c : available) { if (channelName.equals(c.getName())) { info = c; diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/tools/GuiTools.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/tools/GuiTools.java index 387fff085..ddecb7f3a 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/tools/GuiTools.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/tools/GuiTools.java @@ -629,10 +629,7 @@ public static BufferedImage makeFullScreenshot() { public static String getMagnificationString(final QuPathViewer viewer) { if (viewer == null || !viewer.hasServer()) return ""; -// if (Double.isFinite(viewer.getServer().getMetadata().getMagnification())) - return String.format("%.2fx", viewer.getMagnification()); -// else -// return String.format("Scale %.2f", viewer.getDownsampleFactor()); + return String.format("%.2fx", viewer.getMagnification()); } /** diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/recording/ViewTracker.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/recording/ViewTracker.java index 7e0101393..e7805201d 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/recording/ViewTracker.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/recording/ViewTracker.java @@ -234,7 +234,7 @@ public boolean isRecording() { private void initializeRecording() { var imageData = viewer.getImageData(); - hasZAndT = imageData.getServer().getMetadata().getSizeZ() != 1 || imageData.getServer().getMetadata().getSizeT() != 1; + hasZAndT = imageData.getServerMetadata().getSizeZ() != 1 || imageData.getServerMetadata().getSizeT() != 1; frames.clear(); startTime = System.currentTimeMillis(); lastFrame = null; From aeddbf971182675039ee13dc638275d211638250 Mon Sep 17 00:00:00 2001 From: Pete Date: Fri, 2 Aug 2024 18:42:57 +0100 Subject: [PATCH 153/866] Switch to ServerBuilder, not supplier --- .../java/qupath/lib/images/ImageData.java | 66 +++++++++++-------- .../images/servers/ImageServerBuilder.java | 29 +++++--- .../src/main/java/qupath/lib/io/PathIO.java | 40 ++++------- .../qupath/lib/projects/DefaultProject.java | 22 ++----- 4 files changed, 76 insertions(+), 81 deletions(-) diff --git a/qupath-core/src/main/java/qupath/lib/images/ImageData.java b/qupath-core/src/main/java/qupath/lib/images/ImageData.java index 896e57fdd..a5328b65d 100644 --- a/qupath-core/src/main/java/qupath/lib/images/ImageData.java +++ b/qupath-core/src/main/java/qupath/lib/images/ImageData.java @@ -25,6 +25,7 @@ import java.beans.PropertyChangeListener; import java.beans.PropertyChangeSupport; +import java.io.IOException; import java.util.Collections; import java.util.HashMap; import java.util.Map; @@ -38,6 +39,7 @@ import qupath.lib.color.ColorDeconvolutionStains.DefaultColorDeconvolutionStains; import qupath.lib.common.GeneralTools; import qupath.lib.images.servers.ImageServer; +import qupath.lib.images.servers.ImageServerBuilder; import qupath.lib.images.servers.ImageServerMetadata; import qupath.lib.images.servers.ServerTools; import qupath.lib.objects.hierarchy.PathObjectHierarchy; @@ -111,7 +113,7 @@ public String toString() { private transient PropertyChangeSupport pcs; - private transient Supplier> serverSupplier; + private transient ImageServerBuilder.ServerBuilder serverBuilder; private transient ImageServerMetadata lazyMetadata; private transient ImageServer server; @@ -137,18 +139,18 @@ public String toString() { /** * Create a new ImageData with a specified object hierarchy and type. - * @param supplier supplier to use if the server is to be loaded lazily; may be null to se server instead - * @param server server to use directly; may be null to use supplier instead + * @param serverBuilder builder to use if the server is to be loaded lazily; may be null to se server instead + * @param server server to use directly; may be null to use builder instead * @param hierarchy an object hierarchy, or null to create a new one * @param type the image type, or null to default to ImageType.UNSET - * @throws IllegalArgumentException if neither a server nor a server supplier is provided + * @throws IllegalArgumentException if neither a server nor a server builder is provided */ - private ImageData(Supplier> supplier, ImageServer server, PathObjectHierarchy hierarchy, ImageType type) + private ImageData(ImageServerBuilder.ServerBuilder serverBuilder, ImageServer server, PathObjectHierarchy hierarchy, ImageType type) throws IllegalArgumentException { - if (server == null && supplier == null) - throw new IllegalArgumentException("Cannot create ImageData without a server or server supplier"); + if (server == null && serverBuilder == null) + throw new IllegalArgumentException("Cannot create ImageData without a server or server builder"); this.pcs = new PropertyChangeSupport(this); - this.serverSupplier = supplier; + this.serverBuilder = serverBuilder; this.server = server; this.hierarchy = hierarchy == null ? new PathObjectHierarchy() : hierarchy; initializeStainMap(); @@ -166,16 +168,16 @@ private ImageData(Supplier> supplier, ImageServer server, Path /** * Create a new ImageData with a lazily-loaded server, hierarchy and type. - * The supplier provides the ImageServer required to access pixels and metadata on demand. + * The server builder provides the ImageServer required to access pixels and metadata on demand. *

    - * If the server is never requested, then the supplier is not used - which can save time and resources. + * If the server is never requested, then the builder is not used - which can save time and resources. * - * @param supplier object to supply the ImageServer + * @param serverBuilder builder to create the ImageServer * @param hierarchy an object hierarchy, or null to create a new one * @param type the image type, or null to default to ImageType.UNSET */ - public ImageData(Supplier> supplier, PathObjectHierarchy hierarchy, ImageType type) { - this(supplier, null, hierarchy, type); + public ImageData(ImageServerBuilder.ServerBuilder serverBuilder, PathObjectHierarchy hierarchy, ImageType type) { + this(serverBuilder, null, hierarchy, type); } /** @@ -262,8 +264,8 @@ public void setColorDeconvolutionStains(ColorDeconvolutionStains stains) { public void updateServerMetadata(ImageServerMetadata newMetadata) { Objects.requireNonNull(newMetadata); if (server == null) { - if (serverSupplier == null) - throw new IllegalStateException("Cannot update server metadata without a server or server supplier"); + if (serverBuilder == null) + throw new IllegalStateException("Cannot update server metadata without a server or server builder"); else { logger.debug("Setting serve metadata lazily (no change will be fired)"); lazyMetadata = newMetadata; @@ -280,16 +282,20 @@ public void updateServerMetadata(ImageServerMetadata newMetadata) { /** * Get the metadata for the server. *

    - * If the server has not yet been lazy-loaded and {@code updateServerMetadata} has been called to specify the - * metadata that should be used, then that cached metadata will be returned directly without loading the server. - *

    - * In all other cases this is equivalent to {@code getServer().getMetadata()}. + * This is equivalent to {@code getServer().getMetadata()}, unless the server is being loaded lazily + * and it is possible to query the metadata without loading the server. * @return */ public ImageServerMetadata getServerMetadata() { - if (server == null && lazyMetadata != null) { - logger.trace("Returning lazy metadata"); - return lazyMetadata; + if (server == null) { + if (lazyMetadata != null) { + logger.trace("Returning lazy metadata"); + return lazyMetadata; + } else if (serverBuilder != null) { + var metadata = serverBuilder.getMetadata(); + if (metadata != null) + return metadata; + } } return getServer().getMetadata(); } @@ -369,13 +375,17 @@ private static void addColorDeconvolutionStainsToWorkflow(ImageData imageData * @return */ public ImageServer getServer() { - if (server == null && serverSupplier != null) { + if (server == null && serverBuilder != null) { synchronized (this) { if (server == null) { - logger.debug("Lazily requesting image server"); - server = serverSupplier.get(); - if (lazyMetadata != null && !lazyMetadata.equals(server.getMetadata())) { - updateServerMetadata(lazyMetadata); + try { + logger.debug("Lazily requesting image server: {}", serverBuilder); + server = serverBuilder.build(); + if (lazyMetadata != null && !lazyMetadata.equals(server.getMetadata())) { + updateServerMetadata(lazyMetadata); + } + } catch (Exception e) { + throw new RuntimeException("Failed to lazy-load ImageServer", e); } } } @@ -557,7 +567,7 @@ public void close() throws Exception { public String toString() { String serverName; if (server == null) { - if (serverSupplier == null) { + if (serverBuilder == null) { serverName = "no server"; } else if (lazyMetadata != null){ serverName = lazyMetadata.getName() + " (not yet loaded)"; diff --git a/qupath-core/src/main/java/qupath/lib/images/servers/ImageServerBuilder.java b/qupath-core/src/main/java/qupath/lib/images/servers/ImageServerBuilder.java index 0fe9b8aba..37efec770 100644 --- a/qupath-core/src/main/java/qupath/lib/images/servers/ImageServerBuilder.java +++ b/qupath-core/src/main/java/qupath/lib/images/servers/ImageServerBuilder.java @@ -118,14 +118,14 @@ public default boolean matchClassName(String... classNames) { * * @param */ - public static interface ServerBuilder { + interface ServerBuilder { /** * Build a new ImageServer instance. * @return * @throws Exception */ - public ImageServer build() throws Exception; + ImageServer build() throws Exception; /** * Get a list of URIs required by this builder. @@ -134,7 +134,7 @@ public static interface ServerBuilder { * * @see #updateURIs(Map) */ - public Collection getURIs(); + Collection getURIs(); /** * Update the URIs required by this builder. @@ -145,8 +145,20 @@ public static interface ServerBuilder { * * @see #getURIs() */ - public ServerBuilder updateURIs(Map updateMap); - + ServerBuilder updateURIs(Map updateMap); + + /** + * Optional method to get metadata associated with the image. + * The default implementation returns null, which indicates that the server itself must be built before + * metadata is available. + *

    + * Subclasses may override this to provide metadata more efficiently. + * @return the metadata, or null if this is not available + */ + default ImageServerMetadata getMetadata() { + return null; + } + } @@ -155,7 +167,7 @@ public static interface ServerBuilder { * * @param */ - abstract static class AbstractServerBuilder implements ServerBuilder { + abstract class AbstractServerBuilder implements ServerBuilder { private ImageServerMetadata metadata; @@ -164,8 +176,9 @@ abstract static class AbstractServerBuilder implements ServerBuilder { } protected abstract ImageServer buildOriginal() throws Exception; - - protected ImageServerMetadata getMetadata() { + + @Override + public ImageServerMetadata getMetadata() { return metadata; } diff --git a/qupath-core/src/main/java/qupath/lib/io/PathIO.java b/qupath-core/src/main/java/qupath/lib/io/PathIO.java index 4c779d9bb..c70c9d211 100644 --- a/qupath-core/src/main/java/qupath/lib/io/PathIO.java +++ b/qupath-core/src/main/java/qupath/lib/io/PathIO.java @@ -292,25 +292,25 @@ static ServerBuilderWrapper create(ServerBuilder builder, String id) private static ImageData readImageDataSerialized(final Path path, ImageData imageData, ImageServer server, Class cls) throws FileNotFoundException, IOException { - imageData = readImageDataSerialized(path, imageData, () -> server, cls); + imageData = readImageDataSerialized(path, imageData, server, cls); imageData.getServer(); // Ensure the server is loaded return imageData; } private static ImageData readImageDataSerialized(final InputStream stream, ImageData imageData, ImageServer server, Class cls) throws FileNotFoundException, IOException { - imageData = readImageDataSerialized(stream, imageData, () -> server, cls); + imageData = readImageDataSerialized(stream, imageData, server, cls); imageData.getServer(); // Ensure the server is loaded return imageData; } private static ImageData readImageDataSerialized(final Path path, ImageData imageData, - Supplier> serverSupplier, Class cls) throws FileNotFoundException, IOException { + ServerBuilder serverBuilder, Class cls) throws FileNotFoundException, IOException { if (path == null) return null; logger.info("Reading data from {}...", path.getFileName().toString()); try (InputStream stream = Files.newInputStream(path)) { - imageData = readImageDataSerialized(stream, imageData, serverSupplier, cls); + imageData = readImageDataSerialized(stream, imageData, serverBuilder, cls); // Set the last saved path (actually the path from which this was opened) if (imageData != null) imageData.setLastSavedPath(path.toAbsolutePath().toString(), true); @@ -322,7 +322,8 @@ private static ImageData readImageDataSerialized(final Path path, ImageDa } @SuppressWarnings("unchecked") - private static ImageData readImageDataSerialized(final InputStream stream, ImageData imageData, Supplier> serverSupplier, Class cls) throws IOException { + private static ImageData readImageDataSerialized(final InputStream stream, ImageData imageData, + ServerBuilder requestedServerBuilder, Class cls) throws IOException { long startTime = System.currentTimeMillis(); Locale locale = Locale.getDefault(Category.FORMAT); @@ -330,7 +331,7 @@ private static ImageData readImageDataSerialized(final InputStream stream try (ObjectInputStream inStream = createObjectInputStream(new BufferedInputStream(stream))) { - ServerBuilder serverBuilder = null; + ServerBuilder serverBuilder = requestedServerBuilder; PathObjectHierarchy hierarchy = null; ImageData.ImageType imageType = null; ColorDeconvolutionStains stains = null; @@ -353,7 +354,8 @@ private static ImageData readImageDataSerialized(final InputStream stream String serverString = (String)inStream.readObject(); // Don't log warnings if we are provided with a server - serverBuilder = extractServerBuilder(serverString, serverSupplier == null); + if (serverBuilder == null) + serverBuilder = extractServerBuilder(serverString, true); while (true) { // logger.debug("Starting read: " + inStream.available()); @@ -410,27 +412,11 @@ else if (input == null) { var existingBuilder = imageData == null || imageData.getServer() == null ? null : imageData.getServer().getBuilder(); if (imageData == null || !Objects.equals(serverBuilder, existingBuilder)) { // Create a new server if we need to - if (serverSupplier == null) { - // Load the server lazily - var builder = serverBuilder; - serverSupplier = () -> { - try { - return builder.build(); - } catch (Exception e) { - logger.error("Warning: Unable to build server with " + builder); - if (e instanceof RuntimeException runtimeException) - throw runtimeException; - else - throw new RuntimeException(e); - } - }; - } // TODO: Make this less clumsy... but for now we need to ensure we have a fully-initialized hierarchy (which deserialization alone doesn't achieve) PathObjectHierarchy hierarchy2 = new PathObjectHierarchy(); hierarchy2.setHierarchy(hierarchy); hierarchy = hierarchy2; - - imageData = new ImageData<>(serverSupplier, hierarchy, imageType); + imageData = new ImageData<>(serverBuilder, hierarchy, imageType); } else { if (imageType != null) imageData.setImageType(imageType); @@ -510,14 +496,14 @@ public static ImageData readImageData(final InputStream stream, ImageData /** * Read an ImageData with lazy image loading. * @param stream - * @param serverSupplier + * @param serverBuilder * @param cls * @return * @param * @throws IOException */ - public static ImageData readLazyImageData(final InputStream stream, Supplier> serverSupplier, Class cls) throws IOException { - return readImageDataSerialized(stream, null, serverSupplier, cls); + public static ImageData readLazyImageData(final InputStream stream, ServerBuilder serverBuilder, Class cls) throws IOException { + return readImageDataSerialized(stream, null, serverBuilder, cls); } diff --git a/qupath-core/src/main/java/qupath/lib/projects/DefaultProject.java b/qupath-core/src/main/java/qupath/lib/projects/DefaultProject.java index 56f8aee90..bf5f2e416 100644 --- a/qupath-core/src/main/java/qupath/lib/projects/DefaultProject.java +++ b/qupath-core/src/main/java/qupath/lib/projects/DefaultProject.java @@ -689,25 +689,11 @@ private Path getThumbnailPath() { @Override public synchronized ImageData readImageData() throws IOException { Path path = getImageDataPath(); - Supplier> serverSupplier = () -> { - try { - var server = getServerBuilder().build(); - // Ensure the names match - var name = getOriginalImageName(); - if (name != null) - ServerTools.setImageName(server, name); - return server; - } catch (Exception e) { - if (e instanceof RuntimeException exception) - throw exception; - else - throw new RuntimeException(e); - } - }; ImageData imageData = null; + // TODO: Consider whether we can set the image name for the lazy-loaded server if (Files.exists(path)) { try (var stream = Files.newInputStream(path)) { - imageData = PathIO.readLazyImageData(stream, serverSupplier, BufferedImage.class); + imageData = PathIO.readLazyImageData(stream, getServerBuilder(), BufferedImage.class); imageData.setLastSavedPath(path.toString(), true); } catch (Exception e) { logger.error("Error reading image data from " + path, e); @@ -719,7 +705,7 @@ public synchronized ImageData readImageData() throws IOException var pathBackup = getBackupImageDataPath(); if (Files.exists(pathBackup)) { try (var stream = Files.newInputStream(pathBackup)) { - imageData = PathIO.readLazyImageData(stream, serverSupplier, BufferedImage.class); + imageData = PathIO.readLazyImageData(stream, getServerBuilder(), BufferedImage.class); imageData.setLastSavedPath(pathBackup.toString(), true); logger.warn("Restored previous ImageData from {}", pathBackup); } catch (IOException e) { @@ -729,7 +715,7 @@ public synchronized ImageData readImageData() throws IOException } if (imageData == null) - imageData = new ImageData<>(serverSupplier, new PathObjectHierarchy(), ImageType.UNSET); + imageData = new ImageData<>(getServerBuilder(), new PathObjectHierarchy(), ImageType.UNSET); imageData.setProperty(IMAGE_ID, getFullProjectEntryID()); // Required to be able to test for the ID later imageData.setChanged(false); return imageData; From cc5962e72481ba5acbfb9e6ca97064c9d01216ed Mon Sep 17 00:00:00 2001 From: Pete Date: Sat, 3 Aug 2024 11:06:51 +0100 Subject: [PATCH 154/866] Fix typos --- qupath-core/src/main/java/qupath/lib/images/ImageData.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/qupath-core/src/main/java/qupath/lib/images/ImageData.java b/qupath-core/src/main/java/qupath/lib/images/ImageData.java index a5328b65d..0caa035d1 100644 --- a/qupath-core/src/main/java/qupath/lib/images/ImageData.java +++ b/qupath-core/src/main/java/qupath/lib/images/ImageData.java @@ -139,7 +139,7 @@ public String toString() { /** * Create a new ImageData with a specified object hierarchy and type. - * @param serverBuilder builder to use if the server is to be loaded lazily; may be null to se server instead + * @param serverBuilder builder to use if the server is to be loaded lazily; may be null to use server instead * @param server server to use directly; may be null to use builder instead * @param hierarchy an object hierarchy, or null to create a new one * @param type the image type, or null to default to ImageType.UNSET @@ -267,7 +267,7 @@ public void updateServerMetadata(ImageServerMetadata newMetadata) { if (serverBuilder == null) throw new IllegalStateException("Cannot update server metadata without a server or server builder"); else { - logger.debug("Setting serve metadata lazily (no change will be fired)"); + logger.debug("Setting server metadata lazily (no change will be fired)"); lazyMetadata = newMetadata; return; } From 9f5a77b54148d55e4c287917e36c70fb0ac7f05d Mon Sep 17 00:00:00 2001 From: Pete Date: Mon, 5 Aug 2024 10:29:56 +0100 Subject: [PATCH 155/866] Add GeometryTools.findLargestPolygon --- .../java/qupath/lib/roi/GeometryTools.java | 36 ++++++++ .../qupath/lib/roi/TestGeometryTools.java | 83 +++++++++++++++++++ 2 files changed, 119 insertions(+) diff --git a/qupath-core/src/main/java/qupath/lib/roi/GeometryTools.java b/qupath-core/src/main/java/qupath/lib/roi/GeometryTools.java index e69318685..41fb9ad5b 100644 --- a/qupath-core/src/main/java/qupath/lib/roi/GeometryTools.java +++ b/qupath-core/src/main/java/qupath/lib/roi/GeometryTools.java @@ -612,6 +612,42 @@ public static Geometry fillHoles(Geometry geometry) { return GeometryTools.union(filtered); // return geometry.getFactory().buildGeometry(filtered); } + + /** + * Find the polygon with the largest area in a Geometry. + *

    + * If the input is a polygon, then it is returned unchanged. + *

    + * Otherwise, polygons are extracted and the one with the largest area is returned - + * or the first encountered polygon with the largest area in the case of a tie. + *

    + * If no polygons are found, then the method returns null. + * @param geometry + * @return + */ + public static Polygon findLargestPolygon(Geometry geometry) { + if (geometry instanceof Polygon) + return (Polygon)geometry; + var polygons = flatten(geometry, null) + .stream() + .filter(g -> g instanceof Polygon) + .map(g -> (Polygon)g) + .toList(); + if (polygons.isEmpty()) + return null; + else if (polygons.size() == 1) + return polygons.getFirst(); + double maxArea = polygons.getFirst().getArea(); + int maxInd = 0; + for (int i = 1; i < polygons.size(); i++) { + double area = polygons.get(i).getArea(); + if (area > maxArea) { + maxArea = area; + maxInd = i; + } + } + return polygons.get(maxInd); + } /** * Remove fragments smaller than the specified area from a Geometry, ignoring internal rings. diff --git a/qupath-core/src/test/java/qupath/lib/roi/TestGeometryTools.java b/qupath-core/src/test/java/qupath/lib/roi/TestGeometryTools.java index 4008b34ec..2b0b1f88f 100644 --- a/qupath-core/src/test/java/qupath/lib/roi/TestGeometryTools.java +++ b/qupath-core/src/test/java/qupath/lib/roi/TestGeometryTools.java @@ -24,6 +24,7 @@ import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.fail; @@ -33,10 +34,12 @@ import java.io.ObjectInputStream; import java.nio.file.Files; import java.util.ArrayList; +import java.util.List; import java.util.stream.Collectors; import org.junit.jupiter.api.Test; import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.Geometry; import org.locationtech.jts.geom.Polygon; import org.locationtech.jts.geom.util.AffineTransformation; @@ -172,6 +175,86 @@ public void testUnion() { // The fast polygon union discards lines assertEquals(1, FastPolygonUnion.union(g1, gLine).getNumGeometries()); } + + + @Test + public void testFindLargestPolygonLineString() { + var factory = GeometryTools.getDefaultFactory(); + var coords = new Coordinate[]{new Coordinate(0, 0), new Coordinate(0, 1), new Coordinate(1, 1)}; + assertNull(GeometryTools.findLargestPolygon(factory.createLineString(coords))); + } + + @Test + public void testFindLargestPolygonLinearRing() { + var factory = GeometryTools.getDefaultFactory(); + var rectangle = GeometryTools.createRectangle(0, 0, 10, 10); + assertNull(GeometryTools.findLargestPolygon(factory.createLinearRing(rectangle.getCoordinates()))); + } + + @Test + public void testFindLargestPolygonPoints() { + var factory = GeometryTools.getDefaultFactory(); + var coords = new Coordinate[]{new Coordinate(0, 0), new Coordinate(0, 1), new Coordinate(1, 1)}; + assertNull(GeometryTools.findLargestPolygon(factory.createMultiPointFromCoords(coords))); + } + + @Test + public void testFindLargestPolygonSingle() { + var rectangle = GeometryTools.createRectangle(0, 0, 10, 10); + assertEquals(rectangle, GeometryTools.findLargestPolygon(rectangle)); + + var factory = GeometryTools.getDefaultFactory(); + var multipolygon = factory.createMultiPolygon(new Polygon[]{rectangle}); + assertEquals(rectangle, GeometryTools.findLargestPolygon(multipolygon)); + + var geomCollection = factory.createGeometryCollection(new Geometry[]{rectangle}); + assertEquals(rectangle, GeometryTools.findLargestPolygon(geomCollection)); + } + + @Test + public void testFindLargestPolygonMulti() { + var small = GeometryTools.createRectangle(0, 0, 10, 10); + var large = GeometryTools.createRectangle(0, 0, 20, 20); + // Same area, but at the end of the list (so 'large' should be encountered first) + var largeTranslated = GeometryTools.createRectangle(10, 10, 20, 20); + + var factory = GeometryTools.getDefaultFactory(); + var multipolygon1 = factory.createMultiPolygon(new Polygon[]{small, large, largeTranslated}); + var multipolygon2 = factory.createMultiPolygon(new Polygon[]{large, small, largeTranslated}); + var geomcollection1 = factory.createGeometryCollection(new Geometry[]{small, large, largeTranslated}); + var geomcollection2 = factory.createGeometryCollection(new Geometry[]{large, small, largeTranslated}); + + assertEquals(large, GeometryTools.findLargestPolygon(multipolygon1)); + assertEquals(large, GeometryTools.findLargestPolygon(multipolygon2)); + assertEquals(large, GeometryTools.findLargestPolygon(geomcollection1)); + assertEquals(large, GeometryTools.findLargestPolygon(geomcollection2)); + } + + @Test + public void testFindLargestPolygonHoles() { + var outer = GeometryTools.createRectangle(0, 0, 100, 100); + var hole = GeometryTools.createRectangle(10, 10, 20, 20); + var withHole = outer.difference(hole); + var large = GeometryTools.createRectangle(0, 0, 99, 99); + + var factory = GeometryTools.getDefaultFactory(); + var geomcollection1 = factory.createGeometryCollection(new Geometry[]{withHole, large}); + var geomcollection2 = factory.createGeometryCollection(new Geometry[]{large, withHole}); + + assertEquals(large, GeometryTools.findLargestPolygon(geomcollection1)); + assertEquals(large, GeometryTools.findLargestPolygon(geomcollection2)); + } + + @Test + public void testFillHoles() { + var outer = GeometryTools.createRectangle(0, 0, 100, 100).norm(); + var hole = GeometryTools.createRectangle(10, 10, 20, 20).norm(); + var withHole = outer.difference(hole).norm(); + + assertEquals(100*100 - 20*20, withHole.getArea(), 0.001); + assertNotEquals(outer, withHole); + assertEquals(outer, GeometryTools.fillHoles(withHole).norm()); + } } \ No newline at end of file From 131e7764e65a5a1e8a7b9bbcec8dd2dd02432e47 Mon Sep 17 00:00:00 2001 From: Pete Date: Mon, 5 Aug 2024 10:33:44 +0100 Subject: [PATCH 156/866] Hmmm, thanks IDE for that tip --- qupath-core/src/main/java/qupath/lib/roi/GeometryTools.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/qupath-core/src/main/java/qupath/lib/roi/GeometryTools.java b/qupath-core/src/main/java/qupath/lib/roi/GeometryTools.java index 41fb9ad5b..c6fdc8736 100644 --- a/qupath-core/src/main/java/qupath/lib/roi/GeometryTools.java +++ b/qupath-core/src/main/java/qupath/lib/roi/GeometryTools.java @@ -636,8 +636,8 @@ public static Polygon findLargestPolygon(Geometry geometry) { if (polygons.isEmpty()) return null; else if (polygons.size() == 1) - return polygons.getFirst(); - double maxArea = polygons.getFirst().getArea(); + return polygons.get(0); + double maxArea = polygons.get(0).getArea(); int maxInd = 0; for (int i = 1; i < polygons.size(); i++) { double area = polygons.get(i).getArea(); From e73201c2bbe136527fd7b50658f37609c1f2bcc3 Mon Sep 17 00:00:00 2001 From: Pete Date: Mon, 5 Aug 2024 16:38:48 +0100 Subject: [PATCH 157/866] Support TMA core classification * TMA cores can now have classifications assigned to them * Default color for TMA cores is lighter (to make it easier to see on both bright and dark backgrounds) * TMA core 'missing' status is now shown using opacity, not a different color, to preserve any classification color * Viewer location string shows TMA core classifications, where available --- CHANGELOG.md | 3 +++ .../lib/gui/commands/TMADataImporter.java | 7 ++----- .../ObservableMeasurementTableData.java | 2 +- .../qupath/lib/gui/panes/PathClassPane.java | 4 ++-- .../gui/panes/PathObjectHierarchyView.java | 2 +- .../qupath/lib/gui/panes/PreferencePane.java | 4 ++-- .../java/qupath/lib/gui/prefs/PathPrefs.java | 12 +++++------ .../qupath/lib/gui/tools/ColorToolsFX.java | 21 ++++++++++++------- .../qupath/lib/gui/viewer/QuPathViewer.java | 16 +++++++++++--- .../qupath-gui-strings.properties | 4 ++-- 10 files changed, 45 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c0a9d4235..62364d382 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,9 @@ This is a *work in progress* for the next major release. * Create a new channel as a linear combination of other channels (https://github.com/qupath/qupath/pull/1566) * Simplify `TileClassificationsToAnnotationsPlugin` implementation (https://github.com/qupath/qupath/pull/1563) * Add methods to `PathObjectHierarchy` to simplify requesting objects for regions (https://github.com/qupath/qupath/pull/1563) +* TMA cores can now have classifications assigned to them + * Default color for TMA cores is lighter (to make it easier to see on both bright and dark backgrounds) + * TMA core 'missing' status is now shown using opacity, not a different color, to preserve any classification color ### Bugs fixed * Tile export to .ome.tif can convert to 8-bit unnecessarily (https://github.com/qupath/qupath/issues/1494) diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/TMADataImporter.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/TMADataImporter.java index 1fd395097..29b0b7435 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/TMADataImporter.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/TMADataImporter.java @@ -383,11 +383,8 @@ public void updateItem(TMACoreObject item, boolean empty) { setTooltip(null); return; } - if (item.isMissing()) - setTextFill(ColorToolsFX.getCachedColor(PathPrefs.colorTMAMissingProperty().get())); - else - setTextFill(ColorToolsFX.getCachedColor(PathPrefs.colorTMAProperty().get())); - + setTextFill(ColorToolsFX.getDisplayedColor(item)); + setAlignment(Pos.CENTER); setTextAlignment(TextAlignment.CENTER); setContentDisplay(ContentDisplay.CENTER); diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/measure/ObservableMeasurementTableData.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/measure/ObservableMeasurementTableData.java index a46ebe637..903a0788b 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/measure/ObservableMeasurementTableData.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/measure/ObservableMeasurementTableData.java @@ -219,7 +219,7 @@ public synchronized void updateMeasurementList() { builderMap.put("Name", new ObjectNameMeasurementBuilder()); // Include the class - if (containsAnnotations || containsDetections) { + if (containsAnnotations || containsDetections || containsTMACores) { builderMap.put("Classification", new PathClassMeasurementBuilder()); // Get the name of the containing TMA core if we have anything other than cores if (imageData != null && imageData.getHierarchy().getTMAGrid() != null) { diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/panes/PathClassPane.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/panes/PathClassPane.java index 304fd1c5c..d8684e9f7 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/panes/PathClassPane.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/panes/PathClassPane.java @@ -195,8 +195,8 @@ private void promptToSetClass() { var pathObjects = new ArrayList<>(hierarchy.getSelectionModel().getSelectedObjects()); List changed = new ArrayList<>(); for (PathObject pathObject : pathObjects) { - if (pathObject.isTMACore()) - continue; + // Previously we didn't allow TMA core objects to be classified this way, + // but since v0.6.0 we do if (pathObject.getPathClass() == pathClass) continue; pathObject.setPathClass(pathClass); diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/panes/PathObjectHierarchyView.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/panes/PathObjectHierarchyView.java index e2f05ecdc..5e99afe6a 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/panes/PathObjectHierarchyView.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/panes/PathObjectHierarchyView.java @@ -107,7 +107,7 @@ public PathObjectHierarchyView(final QuPathGUI qupath, ObservableValue treeView.refresh()); PathPrefs.colorTMAProperty().addListener((v, o, n) -> treeView.refresh()); - PathPrefs.colorTMAMissingProperty().addListener((v, o, n) -> treeView.refresh()); + PathPrefs.opacityTMAMissingProperty().addListener((v, o, n) -> treeView.refresh()); PathPrefs.colorTileProperty().addListener((v, o, n) -> treeView.refresh()); treeView.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE); diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/panes/PreferencePane.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/panes/PreferencePane.java index 8675cc4f1..706f0ac16 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/panes/PreferencePane.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/panes/PreferencePane.java @@ -507,8 +507,8 @@ public static class ObjectPreferences { @ColorPref("Prefs.Objects.tmaCoreColor") public final IntegerProperty tmaColor = PathPrefs.colorTMAProperty(); - @ColorPref("Prefs.Objects.tmaCoreMissingColor") - public final IntegerProperty tmaMissingColor = PathPrefs.colorTMAMissingProperty(); + @DoublePref("Prefs.Objects.tmaCoreMissingOpacity") + public final DoubleProperty tmaMissingOpacity = PathPrefs.opacityTMAMissingProperty(); } diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/prefs/PathPrefs.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/prefs/PathPrefs.java index b3129e691..3dd9efc83 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/prefs/PathPrefs.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/prefs/PathPrefs.java @@ -1258,8 +1258,8 @@ public static IntegerProperty viewerBackgroundColorProperty() { private static IntegerProperty colorDefaultObjects = createPersistentPreference("colorDefaultAnnotations", ColorTools.packRGB(255, 0, 0)); private static IntegerProperty colorSelectedObject = createPersistentPreference("colorSelectedObject", ColorTools.packRGB(255, 255, 0)); - private static IntegerProperty colorTMA = createPersistentPreference("colorTMA", ColorTools.packRGB(20, 20, 180)); - private static IntegerProperty colorTMAMissing = createPersistentPreference("colorTMAMissing", ColorTools.packARGB(50, 20, 20, 180)); + private static IntegerProperty colorTMA = createPersistentPreference("colorTMA", ColorTools.packRGB(102, 128, 230)); + private static DoubleProperty opacityTMAMissing = createPersistentPreference("opacityTMAMissing", 0.4); private static IntegerProperty colorTile = createPersistentPreference("colorTile", ColorTools.packRGB(80, 80, 80)); /** @@ -1286,13 +1286,13 @@ public static IntegerProperty colorSelectedObjectProperty() { public static IntegerProperty colorTMAProperty() { return colorTMA; } - + /** - * The default color used to display missing TMA core objects. + * The default opacity to use when display TMA core objects, between 0 and 1. * @return */ - public static IntegerProperty colorTMAMissingProperty() { - return colorTMAMissing; + public static DoubleProperty opacityTMAMissingProperty() { + return opacityTMAMissing; } /** diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/tools/ColorToolsFX.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/tools/ColorToolsFX.java index 8c150e90c..7b478c229 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/tools/ColorToolsFX.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/tools/ColorToolsFX.java @@ -30,6 +30,7 @@ import javafx.scene.paint.Color; import qupath.lib.common.ColorTools; +import qupath.lib.common.GeneralTools; import qupath.lib.gui.prefs.PathPrefs; import qupath.lib.objects.PathObject; import qupath.lib.objects.PathTileObject; @@ -233,17 +234,21 @@ public static Integer getDisplayedColorARGB(final PathObject pathObject) { PathClass pathClass = pathObject.getPathClass(); if (pathClass != null) color = pathClass.getColor(); - if (color != null) + else if (pathObject instanceof TMACoreObject) + color = PathPrefs.colorTMAProperty().getValue();; + + if (color != null) { + // Make missing TMA cores translucent + if (pathObject instanceof TMACoreObject core && core.isMissing()) { + color = ColorTools.packARGB( + GeneralTools.clipValue((int)Math.round(255 * PathPrefs.opacityTMAMissingProperty().get()), 0, 255), + ColorTools.red(color), ColorTools.green(color), ColorTools.blue(color) + ); + } return color; - + } if (pathObject instanceof PathTileObject) return PathPrefs.colorTileProperty().getValue(); - if (pathObject instanceof TMACoreObject) { - if (((TMACoreObject)pathObject).isMissing()) - return PathPrefs.colorTMAMissingProperty().getValue(); - else - return PathPrefs.colorTMAProperty().getValue(); - } return PathPrefs.colorDefaultObjectsProperty().getValue(); } diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/QuPathViewer.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/QuPathViewer.java index c6e6be0ce..de63dd216 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/QuPathViewer.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/QuPathViewer.java @@ -789,7 +789,7 @@ private QuPathViewer(DefaultImageRegionStore regionStore, OverlayOptions overlay manager.attachListener(PathPrefs.colorSelectedObjectProperty(), repainter); manager.attachListener(PathPrefs.colorTileProperty(), repainter); manager.attachListener(PathPrefs.colorTMAProperty(), repainter); - manager.attachListener(PathPrefs.colorTMAMissingProperty(), repainter); + manager.attachListener(PathPrefs.opacityTMAMissingProperty(), repainter); manager.attachListener(PathPrefs.alwaysPaintSelectedObjectsProperty(), repainter); manager.attachListener(PathPrefs.locationFontSizeProperty(), repainter); manager.attachListener(PathPrefs.scalebarFontSizeProperty(), repainter); @@ -2716,8 +2716,18 @@ private String getImageLocationString(double xx, double yy, boolean useCalibrate TMAGrid tmaGrid = getHierarchy().getTMAGrid(); if (tmaGrid != null) { TMACoreObject core = PathObjectTools.getTMACoreForPixel(tmaGrid, xx, yy); - if (core != null && core.getName() != null) - prefix = "Core: " + core.getName() + "\n"; + if (core != null) { + if (core.getName() != null) + prefix = "Core: " + core.getName(); + else + prefix = "TMA core"; + var pathClass = core.getPathClass(); + if (pathClass != null) + prefix += " (" + pathClass + ")"; + if (core.isMissing()) + prefix += " (missing)"; + prefix += "\n"; + } } String s = null; diff --git a/qupath-gui-fx/src/main/resources/qupath/lib/gui/localization/qupath-gui-strings.properties b/qupath-gui-fx/src/main/resources/qupath/lib/gui/localization/qupath-gui-strings.properties index 835fbf80b..100f056ea 100644 --- a/qupath-gui-fx/src/main/resources/qupath/lib/gui/localization/qupath-gui-strings.properties +++ b/qupath-gui-fx/src/main/resources/qupath/lib/gui/localization/qupath-gui-strings.properties @@ -828,8 +828,8 @@ Prefs.Objects.defaultColor = Default object color Prefs.Objects.defaultColor.description = Set the default color for any objects that don't have a color of classification set Prefs.Objects.tmaCoreColor = TMA core color Prefs.Objects.tmaCoreColor.description = Set the default color for TMA core objects -Prefs.Objects.tmaCoreMissingColor = TMA missing color color -Prefs.Objects.tmaCoreMissingColor.description = Set the default color for missing TMA core objects +Prefs.Objects.tmaCoreMissingOpacity = TMA missing opacity +Prefs.Objects.tmaCoreMissingOpacity.description = Set the opacity to use when drawing missing TMA core objects Prefs.Viewer.backgroundColor = Viewer background color From cb42a0c8499b77827dcf81077c45308b0a1ab8a9 Mon Sep 17 00:00:00 2001 From: Alan O'Callaghan Date: Tue, 6 Aug 2024 11:49:40 +0100 Subject: [PATCH 158/866] Make script language fromString ignore case --- .../languages/ScriptLanguageProvider.java | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/scripting/languages/ScriptLanguageProvider.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/scripting/languages/ScriptLanguageProvider.java index 3273f9347..8ef507d60 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/scripting/languages/ScriptLanguageProvider.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/scripting/languages/ScriptLanguageProvider.java @@ -23,22 +23,20 @@ package qupath.lib.gui.scripting.languages; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import qupath.lib.gui.ExtensionClassLoader; +import qupath.lib.scripting.languages.ScriptLanguage; + +import javax.script.ScriptEngine; +import javax.script.ScriptEngineFactory; +import javax.script.ScriptEngineManager; import java.util.Collection; import java.util.Collections; import java.util.LinkedHashSet; import java.util.ServiceLoader; import java.util.Set; -import javax.script.ScriptEngine; -import javax.script.ScriptEngineFactory; -import javax.script.ScriptEngineManager; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import qupath.lib.gui.ExtensionClassLoader; -import qupath.lib.scripting.languages.ScriptLanguage; - /** * Class with static methods to fetch all the available {@linkplain ScriptLanguage ScriptLanguages}. * @@ -137,7 +135,10 @@ public static ScriptLanguage getLanguageFromName(String name) { * @return corresponding script language, or {@link PlainLanguage} if no match */ public static ScriptLanguage fromString(String languageString) { - return getAvailableLanguages().stream().filter(l -> l.getName().equals(languageString)).findFirst().orElseGet(() -> PlainLanguage.getInstance()); + return getAvailableLanguages().stream() + .filter(l -> l.getName().equalsIgnoreCase(languageString)) + .findFirst() + .orElseGet(PlainLanguage::getInstance); } /** From 5eef5aee4c3bdd429736f282541274edd03c6375 Mon Sep 17 00:00:00 2001 From: Pete Date: Wed, 7 Aug 2024 10:54:17 +0100 Subject: [PATCH 159/866] Log less eagerly in DnnModel Don't need an info-level log every time a model can't be built, but worth warning if no builder works. --- .../src/main/java/qupath/opencv/dnn/DnnModels.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/qupath-core-processing/src/main/java/qupath/opencv/dnn/DnnModels.java b/qupath-core-processing/src/main/java/qupath/opencv/dnn/DnnModels.java index 9d9eea8e2..3ec27baed 100644 --- a/qupath-core-processing/src/main/java/qupath/opencv/dnn/DnnModels.java +++ b/qupath-core-processing/src/main/java/qupath/opencv/dnn/DnnModels.java @@ -131,18 +131,20 @@ private static Set getBuilders() { * @return a new DnnModel, or null if no model could be built */ public static DnnModel buildModel(DnnModelParams params) { - for (DnnModelBuilder builder : getBuilders()) { + var allBuilders = getBuilders(); + for (DnnModelBuilder builder : allBuilders) { try { var model = builder.buildModel(params); if (model != null) return model; else { - logger.info("Cannot build model with {}", builder); + logger.debug("Cannot build model with {}", builder); } } catch (Exception e) { logger.error(e.getLocalizedMessage(), e); } } + logger.warn("Cannot build model with any of the available builders: {}", allBuilders); return null; } From cdd214775968ac5c0e2592e1c81a7a1bf842192d Mon Sep 17 00:00:00 2001 From: Pete Date: Wed, 7 Aug 2024 14:26:48 +0100 Subject: [PATCH 160/866] Improve GsonTools Avoid deprecated `setLenient()` and add `getPrettyPrintInstance()` to avoid relying on the mysterious `getInstance(boolean)`. --- .../main/java/qupath/lib/io/GsonTools.java | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/qupath-core/src/main/java/qupath/lib/io/GsonTools.java b/qupath-core/src/main/java/qupath/lib/io/GsonTools.java index 3500aae46..4b4c6b1b2 100644 --- a/qupath-core/src/main/java/qupath/lib/io/GsonTools.java +++ b/qupath-core/src/main/java/qupath/lib/io/GsonTools.java @@ -29,6 +29,7 @@ import java.util.Map; import java.util.Objects; +import com.google.gson.Strictness; import org.locationtech.jts.geom.Geometry; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -80,7 +81,7 @@ public class GsonTools { private static GsonBuilder builder = new GsonBuilder() .serializeSpecialFloatingPointValues() - .setLenient() + .setStrictness(Strictness.LENIENT) .registerTypeAdapterFactory(new QuPathTypeAdapterFactory()) .registerTypeAdapter(AffineTransform.class, AffineTransformTypeAdapter.INSTANCE); //.create(); @@ -317,14 +318,24 @@ public synchronized SubTypeAdapterFactory registerSubtype(Class /** - * Get default Gson, capable of serializing/deserializing some key QuPath classes. + * Get default Gson instance, capable of serializing/deserializing some key QuPath classes. * @return * - * @see #getInstance(boolean) + * @see #getPrettyPrintInstance() */ public static Gson getInstance() { return builder.create(); } + + /** + * Get Gson instance capable of serializing/deserializing some key QuPath classes, and configured for pretty printing. + * @return + * @see #getInstance(boolean) + * @since v0.6.0 + */ + public static Gson getPrettyPrintInstance() { + return getInstance().newBuilder().setPrettyPrinting().create(); + } /** * Get default Gson, optionally with pretty printing enabled. @@ -333,10 +344,11 @@ public static Gson getInstance() { * @return * * @see #getInstance() + * @see #getPrettyPrintInstance() */ public static Gson getInstance(boolean pretty) { if (pretty) - return getInstance().newBuilder().setPrettyPrinting().create(); + return getPrettyPrintInstance(); return getInstance(); } From 18dbfaba61b1626a626835ec2c7c3f01af4fc3ef Mon Sep 17 00:00:00 2001 From: Pete Date: Wed, 7 Aug 2024 14:27:12 +0100 Subject: [PATCH 161/866] Update ProjectBrowser.java Fix (hopefully) but whereby the wrong thumbnail image could sometimes be shown. --- .../src/main/java/qupath/lib/gui/panes/ProjectBrowser.java | 4 ---- 1 file changed, 4 deletions(-) diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/panes/ProjectBrowser.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/panes/ProjectBrowser.java index bc6bac3a9..a43f9260e 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/panes/ProjectBrowser.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/panes/ProjectBrowser.java @@ -1329,10 +1329,6 @@ public void updateItem(ProjectTreeRow item, boolean empty) { // Fetch the thumbnail or generate it if not present BufferedImage img = entry.getThumbnail(); if (img != null) { - // If the cell contains the same object, no need to repaint the graphic - if (objectCell == item && getGraphic() != null) - return; - Image image = SwingFXUtils.toFXImage(img, null); viewTooltip.setImage(image); tooltip.setGraphic(viewTooltip); From e5632a2ef6afe20ace507740e41998cd5f6a6afb Mon Sep 17 00:00:00 2001 From: Pete Date: Wed, 7 Aug 2024 14:29:55 +0100 Subject: [PATCH 162/866] Improve ImageServer serialization in projects Serialize the `ServerBuilder` and not the `ImageServer`, which means we can serialize without needing to build the server itself. Also support creating an `ImageServer` from a `ServerBuilder` JSON representation directly. This means that an`ImageServer` can be created with `fromJson(json, ImageServer.class)` applied to the output of both `toJson(obj, ServerBuilder.class)` and `toJson(obj, ImageServer.class)` --- .../lib/images/servers/ImageServers.java | 40 ++++++++++++------- .../qupath/lib/projects/DefaultProject.java | 34 ++++++++++------ 2 files changed, 47 insertions(+), 27 deletions(-) diff --git a/qupath-core/src/main/java/qupath/lib/images/servers/ImageServers.java b/qupath-core/src/main/java/qupath/lib/images/servers/ImageServers.java index 1b2deb926..491694ed1 100644 --- a/qupath-core/src/main/java/qupath/lib/images/servers/ImageServers.java +++ b/qupath-core/src/main/java/qupath/lib/images/servers/ImageServers.java @@ -38,6 +38,7 @@ import java.util.function.Function; import java.util.stream.Collectors; +import com.google.gson.Strictness; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -785,9 +786,9 @@ static class ImageServerTypeAdapter extends TypeAdapter server) throws IOException { - boolean lenient = out.isLenient(); + var strictness = out.getStrictness(); try { - out.setLenient(true); + out.setStrictness(Strictness.LENIENT); var builder = server.getBuilder(); out.beginObject(); out.name("builder"); @@ -802,33 +803,44 @@ public void write(JsonWriter out, ImageServer server) throws IOEx } catch (Exception e) { throw new IOException(e); } finally { - out.setLenient(lenient); + out.setStrictness(strictness); } } @Override + @SuppressWarnings("unchecked") public ImageServer read(JsonReader in) throws IOException { - boolean lenient = in.isLenient(); + var strictness = in.getStrictness(); try { - in.setLenient(true); + in.setStrictness(Strictness.LENIENT); JsonElement element = JsonParser.parseReader(in); JsonObject obj = element.getAsJsonObject(); // Create from builder - ImageServer server = GsonTools.getInstance().fromJson(obj.get("builder"), ServerBuilder.class).build(); - - // Set metadata, if we have any - if (obj.has("metadata")) { - ImageServerMetadata metadata = GsonTools.getInstance().fromJson(obj.get("metadata"), ImageServerMetadata.class); - if (metadata != null) - server.setMetadata(metadata); + ImageServer server; + if (obj.has("builder")) { + // We have ImageServer serialized - the builder is within its own field + server = GsonTools.getInstance().fromJson(obj.get("builder"), ServerBuilder.class).build(); + // We may have metadata serialized separately - ensure that it is set + if (obj.has("metadata")) { + ImageServerMetadata metadata = GsonTools.getInstance().fromJson(obj.get("metadata"), ImageServerMetadata.class); + if (metadata != null) + server.setMetadata(metadata); + } + } else if (obj.has("builderType")) { + // We have a ServerBuilder serialized - but we can still use that for deserialization + var serverBuilder = GsonTools.getInstance().fromJson(obj, ServerBuilder.class); + server = serverBuilder.build(); + } else { + logger.error("No ImageServer builder found in JSON object: {}", obj); + throw new IOException("No ImageServer builder found in JSON object"); } - + return server; } catch (Exception e) { throw new IOException(e); } finally { - in.setLenient(lenient); + in.setStrictness(strictness); } } diff --git a/qupath-core/src/main/java/qupath/lib/projects/DefaultProject.java b/qupath-core/src/main/java/qupath/lib/projects/DefaultProject.java index bf5f2e416..7307d6230 100644 --- a/qupath-core/src/main/java/qupath/lib/projects/DefaultProject.java +++ b/qupath-core/src/main/java/qupath/lib/projects/DefaultProject.java @@ -47,7 +47,6 @@ import java.util.Map.Entry; import java.util.Objects; import java.util.concurrent.atomic.AtomicLong; -import java.util.function.Supplier; import java.util.stream.Collectors; import javax.imageio.ImageIO; @@ -64,7 +63,6 @@ import qupath.lib.images.ImageData; import qupath.lib.images.ImageData.ImageType; import qupath.lib.images.servers.ImageServer; -import qupath.lib.images.servers.ServerTools; import qupath.lib.images.servers.ImageServerBuilder.ServerBuilder; import qupath.lib.io.GsonTools; import qupath.lib.io.PathIO; @@ -527,6 +525,8 @@ void copyDataFromEntry(final DefaultProjectImageEntry entry) throws IOException Files.copy(entry.getImageDataPath(), getImageDataPath(), StandardCopyOption.REPLACE_EXISTING); if (Files.exists(entry.getDataSummaryPath())) Files.copy(entry.getDataSummaryPath(), getDataSummaryPath(), StandardCopyOption.REPLACE_EXISTING); + if (Files.exists(entry.getServerPath())) + Files.copy(entry.getServerPath(), getServerPath(), StandardCopyOption.REPLACE_EXISTING); if (getThumbnail() == null && Files.exists(entry.getThumbnailPath())) Files.copy(entry.getThumbnailPath(), getThumbnailPath(), StandardCopyOption.REPLACE_EXISTING); } @@ -562,6 +562,8 @@ public boolean updateURIs(Map replacements) throws IOException { var builderBefore = serverBuilder; serverBuilder = serverBuilder.updateURIs(replacements); boolean changes = builderBefore != serverBuilder; + if (changes) + writeServerBuilder(); return changes; } @@ -765,17 +767,8 @@ public synchronized void saveImageData(ImageData imageData) throw var currentServerBuilder = server.getBuilder(); if (currentServerBuilder != null && !currentServerBuilder.equals(this.serverBuilder)) { this.serverBuilder = currentServerBuilder; - // Write the server - it isn't used, but it may enable us to rebuild the server from the data directory - // if the project is lost. - // Note that before v0.5.0, this actually wrote the server builder - but this was missing type - // information, so recovery of the actual server was difficult. - var pathServer = getServerPath(); - try (var out = Files.newBufferedWriter(pathServer, StandardCharsets.UTF_8)) { - GsonTools.getInstance().toJson(server, out); - } catch (Exception e) { - logger.warn("Unable to write server to {}", pathServer); - Files.deleteIfExists(pathServer); - } + writeServerBuilder(); + // This ensures that the metadata is updated in the project file // syncChanges(); } @@ -786,6 +779,21 @@ public synchronized void saveImageData(ImageData imageData) throw } + private void writeServerBuilder() throws IOException { + // Write the server - it isn't used, but it may enable us to rebuild the server from the data directory + // if the project is lost. + // Note that before v0.5.0, this actually wrote the server builder - but this was missing type + // information, so recovery of the actual server was difficult. + var pathServer = getServerPath(); + try (var out = Files.newBufferedWriter(pathServer, StandardCharsets.UTF_8)) { + // Important to specify the class as ServerBuilder, so that the type adapter writes the type! + GsonTools.getInstance(true).toJson(this.serverBuilder, ServerBuilder.class, out); + } catch (Exception e) { + logger.warn("Unable to write server to {}", pathServer); + Files.deleteIfExists(pathServer); + } + } + @Override public boolean hasImageData() { return Files.exists(getImageDataPath()); From c51c63a980fe5b2d152e3e93f6f150eff5121e66 Mon Sep 17 00:00:00 2001 From: Pete Date: Wed, 7 Aug 2024 14:56:27 +0100 Subject: [PATCH 163/866] Reduce some overly-enthusiastic logging --- qupath-core/src/main/java/qupath/lib/io/PathIO.java | 2 +- .../lib/gui/scripting/richtextfx/stylers/GroovyStyler.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/qupath-core/src/main/java/qupath/lib/io/PathIO.java b/qupath-core/src/main/java/qupath/lib/io/PathIO.java index c70c9d211..e9f53a5ef 100644 --- a/qupath-core/src/main/java/qupath/lib/io/PathIO.java +++ b/qupath-core/src/main/java/qupath/lib/io/PathIO.java @@ -714,7 +714,7 @@ public static PathObjectHierarchy readHierarchy(final InputStream fileIn) throws try { // Try to read a relevant object from the stream Object input = inStream.readObject(); - logger.debug("Read: {}", input); + logger.trace("Read object: {}", input); // Set locale - may be needed (although probably isn't...) if (input instanceof Locale) { diff --git a/qupath-extension-script-editor/src/main/java/qupath/lib/gui/scripting/richtextfx/stylers/GroovyStyler.java b/qupath-extension-script-editor/src/main/java/qupath/lib/gui/scripting/richtextfx/stylers/GroovyStyler.java index a3a0d3968..55d0b9b4c 100644 --- a/qupath-extension-script-editor/src/main/java/qupath/lib/gui/scripting/richtextfx/stylers/GroovyStyler.java +++ b/qupath-extension-script-editor/src/main/java/qupath/lib/gui/scripting/richtextfx/stylers/GroovyStyler.java @@ -230,7 +230,7 @@ else if (isLastCharacter) var styles = visitor.buildStyles(); long endTime = System.currentTimeMillis(); - logger.debug("Style time: {} (length={})", endTime - startTime, n); + logger.trace("Style time: {} (length={})", endTime - startTime, n); return styles; } From d300259f53fccff6e4dbe9a6b4a54fd8c0f74a0b Mon Sep 17 00:00:00 2001 From: Pete Date: Wed, 7 Aug 2024 15:18:40 +0100 Subject: [PATCH 164/866] Lazy-load server on any metadata change This includes setting the server name when the project entry is being loaded. Usually, this should already have been set previously - but could be out of sync if the name was changed and the data not saved. --- .../main/java/qupath/lib/images/ImageData.java | 16 ++++++++-------- .../lib/images/servers/ImageServerBuilder.java | 5 ++++- .../java/qupath/lib/projects/DefaultProject.java | 8 ++++++++ 3 files changed, 20 insertions(+), 9 deletions(-) diff --git a/qupath-core/src/main/java/qupath/lib/images/ImageData.java b/qupath-core/src/main/java/qupath/lib/images/ImageData.java index 0caa035d1..a41a31ce4 100644 --- a/qupath-core/src/main/java/qupath/lib/images/ImageData.java +++ b/qupath-core/src/main/java/qupath/lib/images/ImageData.java @@ -263,16 +263,16 @@ public void setColorDeconvolutionStains(ColorDeconvolutionStains stains) { */ public void updateServerMetadata(ImageServerMetadata newMetadata) { Objects.requireNonNull(newMetadata); - if (server == null) { - if (serverBuilder == null) - throw new IllegalStateException("Cannot update server metadata without a server or server builder"); - else { - logger.debug("Setting server metadata lazily (no change will be fired)"); - lazyMetadata = newMetadata; - return; - } + // Try to check if metadata can be dropped - this is important for lazy loading + var currentMetadata = getServerMetadata(); + if (Objects.equals(currentMetadata, newMetadata)) { + logger.trace("Call to updateServerMetadata ignored - metadata is unchanged"); + return; } + // Request the server if we need to update the metadata. + // This can trigger lazy-loading, but reduces the risk of inconsistent metadata. logger.trace("Updating server metadata"); + var server = getServer(); var oldMetadata = server.getMetadata(); server.setMetadata(newMetadata); pcs.firePropertyChange("serverMetadata", oldMetadata, newMetadata); diff --git a/qupath-core/src/main/java/qupath/lib/images/servers/ImageServerBuilder.java b/qupath-core/src/main/java/qupath/lib/images/servers/ImageServerBuilder.java index 37efec770..4367f6dcc 100644 --- a/qupath-core/src/main/java/qupath/lib/images/servers/ImageServerBuilder.java +++ b/qupath-core/src/main/java/qupath/lib/images/servers/ImageServerBuilder.java @@ -401,7 +401,10 @@ public ServerBuilder updateURIs(Map updateMap) { @Override public String toString() { - return String.format("DefaultImageServerBuilder (classname=%s, uri=%s, args=%s)", providerClassName, uri.toString(), String.join(", ", args)); + return String.format("DefaultServerBuilder (classname=%s, uri=%s, args=%s)", + providerClassName, + uri.toString(), + "[" + String.join(", ", args) + "]"); } @Override diff --git a/qupath-core/src/main/java/qupath/lib/projects/DefaultProject.java b/qupath-core/src/main/java/qupath/lib/projects/DefaultProject.java index 7307d6230..ae32b6179 100644 --- a/qupath-core/src/main/java/qupath/lib/projects/DefaultProject.java +++ b/qupath-core/src/main/java/qupath/lib/projects/DefaultProject.java @@ -64,6 +64,7 @@ import qupath.lib.images.ImageData.ImageType; import qupath.lib.images.servers.ImageServer; import qupath.lib.images.servers.ImageServerBuilder.ServerBuilder; +import qupath.lib.images.servers.ImageServerMetadata; import qupath.lib.io.GsonTools; import qupath.lib.io.PathIO; import qupath.lib.objects.PathObject; @@ -720,6 +721,13 @@ public synchronized ImageData readImageData() throws IOException imageData = new ImageData<>(getServerBuilder(), new PathObjectHierarchy(), ImageType.UNSET); imageData.setProperty(IMAGE_ID, getFullProjectEntryID()); // Required to be able to test for the ID later imageData.setChanged(false); + // I don't like it either - but we want to ensure that the server name matches with the image entry name. + // This can trigger lazy-loading of the server, but it's necessary to ensure that the server name is correct. + imageData.updateServerMetadata( + new ImageServerMetadata.Builder(imageData.getServerMetadata()) + .name(getImageName()) + .build() + ); return imageData; } From 136467f7f799cf2963a97faab0d378fed0473903 Mon Sep 17 00:00:00 2001 From: lleplat Date: Wed, 7 Aug 2024 15:51:25 +0100 Subject: [PATCH 165/866] Close store if created in RenderedImageServer --- .../images/servers/RenderedImageServer.java | 25 ++++++++++++++----- 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/images/servers/RenderedImageServer.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/images/servers/RenderedImageServer.java index 7a6b22ae3..97c323307 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/images/servers/RenderedImageServer.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/images/servers/RenderedImageServer.java @@ -62,7 +62,8 @@ * @author Pete Bankhead */ public class RenderedImageServer extends AbstractTileableImageServer implements GeneratingImageServer { - + + private final boolean dedicatedStore; private DefaultImageRegionStore store; private ImageData imageData; private List overlayLayers = new ArrayList<>(); @@ -76,7 +77,8 @@ public class RenderedImageServer extends AbstractTileableImageServer implements private RenderedImageServer(DefaultImageRegionStore store, ImageData imageData, List overlayLayers, ImageRenderer renderer, - double[] downsamples, Color backgroundColor, double overlayOpacity) { + double[] downsamples, Color backgroundColor, double overlayOpacity, + boolean dedicatedStore) { super(); this.store = store; this.overlayOpacity = overlayOpacity; @@ -93,6 +95,7 @@ private RenderedImageServer(DefaultImageRegionStore store, ImageData 0) builder = builder.levelsFromDownsamples(downsamples); this.metadata = builder.build(); + this.dedicatedStore = dedicatedStore; } /** @@ -145,6 +148,7 @@ public static class Builder { private double overlayOpacity = 1.0; private Color backgroundColor; private double[] downsamples; + private boolean dedicatedStore = false; /** * Create a rendered image server build using viewer defaults. @@ -280,8 +284,9 @@ public ImageServer build() throws IOException { // Try to use existing store/display if possible var store = getStore(); var renderer = getRenderer(); - return new RenderedImageServer(store, imageData, overlayLayers, renderer, downsamples, backgroundColor, - overlayOpacity); + return new RenderedImageServer( + store, imageData, overlayLayers, renderer, downsamples, backgroundColor, overlayOpacity, dedicatedStore + ); } private ImageRenderer getRenderer() throws IOException { @@ -306,10 +311,12 @@ private DefaultImageRegionStore getStore() { if (this.store != null) return store; var viewer = findViewer(imageData); - if (viewer == null) + if (viewer == null) { + dedicatedStore = true; return ImageRegionStoreFactory.createImageRegionStore(Runtime.getRuntime().maxMemory() / 4L); - else + } else { return viewer.getImageRegionStore(); + } } private QuPathViewer findViewer(ImageData imageData) { @@ -402,4 +409,10 @@ protected String createID() { return UUID.randomUUID().toString(); } + @Override + public void close() { + if (dedicatedStore) { + store.close(); + } + } } \ No newline at end of file From 01828b67e0eeac01af3b8c5dd15504578e55ea15 Mon Sep 17 00:00:00 2001 From: Pete Date: Wed, 7 Aug 2024 16:41:22 +0100 Subject: [PATCH 166/866] Write server.json on import --- .../src/main/java/qupath/lib/projects/DefaultProject.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/qupath-core/src/main/java/qupath/lib/projects/DefaultProject.java b/qupath-core/src/main/java/qupath/lib/projects/DefaultProject.java index ae32b6179..87b28958a 100644 --- a/qupath-core/src/main/java/qupath/lib/projects/DefaultProject.java +++ b/qupath-core/src/main/java/qupath/lib/projects/DefaultProject.java @@ -475,7 +475,9 @@ class DefaultProjectImageEntry implements ProjectImageEntry { setDescription(description); if (metadataMap != null) - metadata.putAll(metadataMap); + metadata.putAll(metadataMap); + + writeServerBuilder(); } DefaultProjectImageEntry(final DefaultProjectImageEntry entry) { @@ -792,6 +794,7 @@ private void writeServerBuilder() throws IOException { // if the project is lost. // Note that before v0.5.0, this actually wrote the server builder - but this was missing type // information, so recovery of the actual server was difficult. + getEntryPath(true); // Ensure the directory exists var pathServer = getServerPath(); try (var out = Files.newBufferedWriter(pathServer, StandardCharsets.UTF_8)) { // Important to specify the class as ServerBuilder, so that the type adapter writes the type! From 81fd3edc696f25f99d3ab422c9d41921772d9c45 Mon Sep 17 00:00:00 2001 From: Pete Date: Wed, 7 Aug 2024 16:44:39 +0100 Subject: [PATCH 167/866] Reduce logging again --- qupath-core/src/main/java/qupath/lib/io/PathIO.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qupath-core/src/main/java/qupath/lib/io/PathIO.java b/qupath-core/src/main/java/qupath/lib/io/PathIO.java index e9f53a5ef..48228360e 100644 --- a/qupath-core/src/main/java/qupath/lib/io/PathIO.java +++ b/qupath-core/src/main/java/qupath/lib/io/PathIO.java @@ -362,7 +362,7 @@ private static ImageData readImageDataSerialized(final InputStream stream try { // Try to read a relevant object from the stream Object input = inStream.readObject(); - logger.debug("Read: {}", input); + logger.trace("Read object: {}", input); // If we have a Locale, then set it if (input instanceof Locale) { From bda44d089097e3bc04e5c6f4eef15e204aa7676c Mon Sep 17 00:00:00 2001 From: Pete Date: Wed, 7 Aug 2024 17:14:03 +0100 Subject: [PATCH 168/866] Update RenderedImageServer.java Call `super.close()` and make some more variables `final`. --- .../images/servers/RenderedImageServer.java | 24 +++++++++---------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/images/servers/RenderedImageServer.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/images/servers/RenderedImageServer.java index 97c323307..91f3a8320 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/images/servers/RenderedImageServer.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/images/servers/RenderedImageServer.java @@ -2,7 +2,7 @@ * #%L * This file is part of QuPath. * %% - * Copyright (C) 2018 - 2020 QuPath developers, The University of Edinburgh + * Copyright (C) 2018 - 2024 QuPath developers, The University of Edinburgh * %% * QuPath is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as @@ -58,22 +58,19 @@ /** * An ImageServer that can display a rendered image, with optional overlays. * This is intended for use when exporting 'flattened' RGB images. - * - * @author Pete Bankhead */ public class RenderedImageServer extends AbstractTileableImageServer implements GeneratingImageServer { private final boolean dedicatedStore; - private DefaultImageRegionStore store; - private ImageData imageData; - private List overlayLayers = new ArrayList<>(); - private ImageRenderer renderer; + private final DefaultImageRegionStore store; + private final ImageData imageData; + private final List overlayLayers = new ArrayList<>(); + private final ImageRenderer renderer; - private double overlayOpacity = 1.0; - - private Color backgroundColor = Color.WHITE; - - private ImageServerMetadata metadata; + private final double overlayOpacity; + private final Color backgroundColor; + + private final ImageServerMetadata metadata; private RenderedImageServer(DefaultImageRegionStore store, ImageData imageData, List overlayLayers, ImageRenderer renderer, @@ -410,9 +407,10 @@ protected String createID() { } @Override - public void close() { + public void close() throws Exception { if (dedicatedStore) { store.close(); } + super.close(); } } \ No newline at end of file From e4d2406bd8979ca2acb7ddb08a508768f9bc2dea Mon Sep 17 00:00:00 2001 From: Leo Leplat <60394504+Rylern@users.noreply.github.com> Date: Thu, 8 Aug 2024 09:49:06 +0100 Subject: [PATCH 169/866] Create store outside Builder --- .../images/servers/RenderedImageServer.java | 29 +++++++++---------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/images/servers/RenderedImageServer.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/images/servers/RenderedImageServer.java index 91f3a8320..f5025e3f8 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/images/servers/RenderedImageServer.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/images/servers/RenderedImageServer.java @@ -61,23 +61,27 @@ */ public class RenderedImageServer extends AbstractTileableImageServer implements GeneratingImageServer { - private final boolean dedicatedStore; private final DefaultImageRegionStore store; private final ImageData imageData; private final List overlayLayers = new ArrayList<>(); private final ImageRenderer renderer; - private final double overlayOpacity; private final Color backgroundColor; - private final ImageServerMetadata metadata; + private final boolean dedicatedStore; private RenderedImageServer(DefaultImageRegionStore store, ImageData imageData, List overlayLayers, ImageRenderer renderer, - double[] downsamples, Color backgroundColor, double overlayOpacity, - boolean dedicatedStore) { + double[] downsamples, Color backgroundColor, double overlayOpacity) { super(); - this.store = store; + + if (store == null) { + this.store = ImageRegionStoreFactory.createImageRegionStore(Runtime.getRuntime().maxMemory() / 4L); + this.dedicatedStore = true; + } else { + this.store = store; + this.dedicatedStore = false; + } this.overlayOpacity = overlayOpacity; if (overlayLayers != null) this.overlayLayers.addAll(overlayLayers); @@ -92,7 +96,6 @@ private RenderedImageServer(DefaultImageRegionStore store, ImageData 0) builder = builder.levelsFromDownsamples(downsamples); this.metadata = builder.build(); - this.dedicatedStore = dedicatedStore; } /** @@ -145,7 +148,6 @@ public static class Builder { private double overlayOpacity = 1.0; private Color backgroundColor; private double[] downsamples; - private boolean dedicatedStore = false; /** * Create a rendered image server build using viewer defaults. @@ -282,7 +284,7 @@ public ImageServer build() throws IOException { var store = getStore(); var renderer = getRenderer(); return new RenderedImageServer( - store, imageData, overlayLayers, renderer, downsamples, backgroundColor, overlayOpacity, dedicatedStore + store, imageData, overlayLayers, renderer, downsamples, backgroundColor, overlayOpacity ); } @@ -309,8 +311,7 @@ private DefaultImageRegionStore getStore() { return store; var viewer = findViewer(imageData); if (viewer == null) { - dedicatedStore = true; - return ImageRegionStoreFactory.createImageRegionStore(Runtime.getRuntime().maxMemory() / 4L); + return null; } else { return viewer.getImageRegionStore(); } @@ -326,11 +327,7 @@ private QuPathViewer findViewer(ImageData imageData) { } else return null; } - - - } - @Override public Collection getURIs() { @@ -408,9 +405,9 @@ protected String createID() { @Override public void close() throws Exception { + super.close(); if (dedicatedStore) { store.close(); } - super.close(); } } \ No newline at end of file From 539010395cb64163ff568eb8bb69bdea0a4b5c3b Mon Sep 17 00:00:00 2001 From: Leo Leplat <60394504+Rylern@users.noreply.github.com> Date: Thu, 8 Aug 2024 09:50:24 +0100 Subject: [PATCH 170/866] Add final --- .../qupath/lib/gui/images/servers/RenderedImageServer.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/images/servers/RenderedImageServer.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/images/servers/RenderedImageServer.java index f5025e3f8..7c47c010b 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/images/servers/RenderedImageServer.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/images/servers/RenderedImageServer.java @@ -141,8 +141,8 @@ public static class Builder { private static final Logger logger = LoggerFactory.getLogger(Builder.class); private DefaultImageRegionStore store; - private ImageData imageData; - private List overlayLayers = new ArrayList<>(); + private final ImageData imageData; + private final List overlayLayers = new ArrayList<>(); private ImageRenderer renderer; private ImageDisplaySettings settings; private double overlayOpacity = 1.0; From 41e769764a600dca075717de76c378d226623b80 Mon Sep 17 00:00:00 2001 From: Pete Date: Thu, 8 Aug 2024 14:55:11 +0100 Subject: [PATCH 171/866] Use Optional with serverBuilder.getMetadata() Make clearer that this is permitted to return null. --- .../java/qupath/lib/images/ImageData.java | 2 +- .../images/servers/ImageServerBuilder.java | 19 ++++++++++-------- .../lib/images/servers/ImageServers.java | 20 +++++++++---------- 3 files changed, 22 insertions(+), 19 deletions(-) diff --git a/qupath-core/src/main/java/qupath/lib/images/ImageData.java b/qupath-core/src/main/java/qupath/lib/images/ImageData.java index a41a31ce4..f2936db5a 100644 --- a/qupath-core/src/main/java/qupath/lib/images/ImageData.java +++ b/qupath-core/src/main/java/qupath/lib/images/ImageData.java @@ -292,7 +292,7 @@ public ImageServerMetadata getServerMetadata() { logger.trace("Returning lazy metadata"); return lazyMetadata; } else if (serverBuilder != null) { - var metadata = serverBuilder.getMetadata(); + var metadata = serverBuilder.getMetadata().orElse(null); if (metadata != null) return metadata; } diff --git a/qupath-core/src/main/java/qupath/lib/images/servers/ImageServerBuilder.java b/qupath-core/src/main/java/qupath/lib/images/servers/ImageServerBuilder.java index 4367f6dcc..ddf6cce8e 100644 --- a/qupath-core/src/main/java/qupath/lib/images/servers/ImageServerBuilder.java +++ b/qupath-core/src/main/java/qupath/lib/images/servers/ImageServerBuilder.java @@ -34,6 +34,7 @@ import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Optional; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -149,14 +150,16 @@ interface ServerBuilder { /** * Optional method to get metadata associated with the image. - * The default implementation returns null, which indicates that the server itself must be built before - * metadata is available. + * The default implementation returns an empty optional, which indicates that the server itself + * must be built before metadata is available. *

    * Subclasses may override this to provide metadata more efficiently. - * @return the metadata, or null if this is not available + *

    + * + * @return the metadata, or an empty optional if this is not available */ - default ImageServerMetadata getMetadata() { - return null; + default Optional getMetadata() { + return Optional.empty(); } } @@ -178,8 +181,8 @@ abstract class AbstractServerBuilder implements ServerBuilder { protected abstract ImageServer buildOriginal() throws Exception; @Override - public ImageServerMetadata getMetadata() { - return metadata; + public Optional getMetadata() { + return Optional.ofNullable(metadata); } @Override @@ -396,7 +399,7 @@ public ServerBuilder updateURIs(Map updateMap) { URI uriNew = updateMap.getOrDefault(uri, null); if (uriNew == null) return this; - return new DefaultImageServerBuilder<>(providerClassName, uriNew, args, getMetadata()); + return new DefaultImageServerBuilder<>(providerClassName, uriNew, args, getMetadata().orElse(null)); } @Override diff --git a/qupath-core/src/main/java/qupath/lib/images/servers/ImageServers.java b/qupath-core/src/main/java/qupath/lib/images/servers/ImageServers.java index 491694ed1..fd293504c 100644 --- a/qupath-core/src/main/java/qupath/lib/images/servers/ImageServers.java +++ b/qupath-core/src/main/java/qupath/lib/images/servers/ImageServers.java @@ -158,7 +158,7 @@ public ServerBuilder updateURIs(Map updateMap) { } newRegions.add(new SparseImageServerManagerRegion(region.getRegion(), newResolutions)); } - return new SparseImageServerBuilder(getMetadata(), newRegions, path); + return new SparseImageServerBuilder(getMetadata().orElse(null), newRegions, path); } } @@ -175,7 +175,7 @@ static class PyramidGeneratingServerBuilder extends AbstractServerBuilder buildOriginal() throws Exception { - var metadata = getMetadata(); + var metadata = getMetadata().orElseThrow(); return new PyramidGeneratingImageServer(builder.build(), metadata.getPreferredTileWidth(), metadata.getPreferredTileHeight(), @@ -192,7 +192,7 @@ public ServerBuilder updateURIs(Map updateMap) { ServerBuilder newBuilder = builder.updateURIs(updateMap); if (newBuilder == builder) return this; - return new PyramidGeneratingServerBuilder(getMetadata(), newBuilder); + return new PyramidGeneratingServerBuilder(getMetadata().orElse(null), newBuilder); } } @@ -506,7 +506,7 @@ public ServerBuilder updateURIs(Map updateMap) { ServerBuilder newBuilder = builder.updateURIs(updateMap); if (newBuilder == builder) return this; - return new CroppedImageServerBuilder(getMetadata(), newBuilder, region); + return new CroppedImageServerBuilder(getMetadata().orElse(null), newBuilder, region); } } @@ -546,7 +546,7 @@ public ServerBuilder updateURIs(Map updateMap) { ServerBuilder newBuilder = builder.updateURIs(updateMap); if (newBuilder == builder) return this; - return new AffineTransformImageServerBuilder(getMetadata(), newBuilder, transform); + return new AffineTransformImageServerBuilder(getMetadata().orElse(null), newBuilder, transform); } } @@ -599,7 +599,7 @@ public ServerBuilder updateURIs(Map updateMap) { } if (!changes) return this; - return new ConcatChannelsImageServerBuilder(getMetadata(), newBuilder, newChannels); + return new ConcatChannelsImageServerBuilder(getMetadata().orElse(null), newBuilder, newChannels); } } @@ -630,7 +630,7 @@ public ServerBuilder updateURIs(Map updateMap) { ServerBuilder newBuilder = builder.updateURIs(updateMap); if (newBuilder == builder) return this; - return new ChannelTransformFeatureServerBuilder(getMetadata(), newBuilder, transforms); + return new ChannelTransformFeatureServerBuilder(getMetadata().orElse(null), newBuilder, transforms); } } @@ -663,7 +663,7 @@ public ServerBuilder updateURIs(Map updateMap) { ServerBuilder newBuilder = builder.updateURIs(updateMap); if (newBuilder == builder) return this; - return new ColorDeconvolutionServerBuilder(getMetadata(), newBuilder, stains, channels); + return new ColorDeconvolutionServerBuilder(getMetadata().orElse(null), newBuilder, stains, channels); } } @@ -694,7 +694,7 @@ public ServerBuilder updateURIs(Map updateMap) { ServerBuilder newBuilder = builder.updateURIs(updateMap); if (newBuilder == builder) return this; - return new RotatedImageServerBuilder(getMetadata(), newBuilder, rotation); + return new RotatedImageServerBuilder(getMetadata().orElse(null), newBuilder, rotation); } } @@ -725,7 +725,7 @@ public ServerBuilder updateURIs(Map updateMap) { ServerBuilder newBuilder = builder.updateURIs(updateMap); if (newBuilder == builder) return this; - return new ReorderRGBServerBuilder(getMetadata(), newBuilder, order); + return new ReorderRGBServerBuilder(getMetadata().orElse(null), newBuilder, order); } } From 4c8ba404df0b0bcea71e10942bb3185dc6db6ca0 Mon Sep 17 00:00:00 2001 From: Pete Date: Thu, 8 Aug 2024 15:02:44 +0100 Subject: [PATCH 172/866] Update CHANGELOG.md --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c0a9d4235..768c9ed2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,8 @@ This is a *work in progress* for the next major release. * Create a new channel as a linear combination of other channels (https://github.com/qupath/qupath/pull/1566) * Simplify `TileClassificationsToAnnotationsPlugin` implementation (https://github.com/qupath/qupath/pull/1563) * Add methods to `PathObjectHierarchy` to simplify requesting objects for regions (https://github.com/qupath/qupath/pull/1563) +* Faster *Run for project (without save)* scripts when the image doesn't need to be loaded (https://github.com/qupath/qupath/pull/1489) + * This can substantially speed up _some_ scripts that don't need to access the image data (e.g. for some measurement export) ### Bugs fixed * Tile export to .ome.tif can convert to 8-bit unnecessarily (https://github.com/qupath/qupath/issues/1494) From ebde27d9a5dc241fd89548b99eb64930abba8435 Mon Sep 17 00:00:00 2001 From: Leo Leplat <60394504+Rylern@users.noreply.github.com> Date: Thu, 8 Aug 2024 15:30:18 +0100 Subject: [PATCH 173/866] Changed fireHierarchyChangedEvent to fireObjectAddedEvent --- .../java/qupath/lib/objects/hierarchy/PathObjectHierarchy.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qupath-core/src/main/java/qupath/lib/objects/hierarchy/PathObjectHierarchy.java b/qupath-core/src/main/java/qupath/lib/objects/hierarchy/PathObjectHierarchy.java index db68dc353..38000e285 100644 --- a/qupath-core/src/main/java/qupath/lib/objects/hierarchy/PathObjectHierarchy.java +++ b/qupath-core/src/main/java/qupath/lib/objects/hierarchy/PathObjectHierarchy.java @@ -623,7 +623,7 @@ public synchronized boolean addObjects(Collection pathObje counter++; } if (changes) - fireHierarchyChangedEvent(getRootObject()); + fireObjectAddedEvent(getRootObject(), pathObjects.iterator().next()); // fireChangeEvent(getRootObject()); return changes; } From 6475527a87cd52fadc58a9387f85028ea39d119b Mon Sep 17 00:00:00 2001 From: Pete Date: Thu, 8 Aug 2024 15:46:00 +0100 Subject: [PATCH 174/866] Update changelog --- CHANGELOG.md | 2 ++ .../src/main/java/qupath/lib/images/servers/ImageServers.java | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 17757a2c3..a3c5886b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,8 @@ This is a *work in progress* for the next major release. * TMA core 'missing' status is now shown using opacity, not a different color, to preserve any classification color * Faster *Run for project (without save)* scripts when the image doesn't need to be loaded (https://github.com/qupath/qupath/pull/1489) * This can substantially speed up _some_ scripts that don't need to access the image data (e.g. for some measurement export) +* Initial core support for stain normalization and background subtraction (https://github.com/qupath/qupath/pull/1554) + * Experimental - not yet a full feature or available through the user interface! ### Bugs fixed * Tile export to .ome.tif can convert to 8-bit unnecessarily (https://github.com/qupath/qupath/issues/1494) diff --git a/qupath-core/src/main/java/qupath/lib/images/servers/ImageServers.java b/qupath-core/src/main/java/qupath/lib/images/servers/ImageServers.java index 579f71e2a..04dfb594c 100644 --- a/qupath-core/src/main/java/qupath/lib/images/servers/ImageServers.java +++ b/qupath-core/src/main/java/qupath/lib/images/servers/ImageServers.java @@ -743,7 +743,7 @@ public ServerBuilder updateURIs(Map updateMap) { ServerBuilder newBuilder = builder.updateURIs(updateMap); if (newBuilder == builder) return this; - return new NormalizedImageServerBuilder(getMetadata(), newBuilder, normalizer); + return new NormalizedImageServerBuilder(getMetadata().orElse(null), newBuilder, normalizer); } } From 979be6cdd0d16e32fdc081940b92dd5e7917ed78 Mon Sep 17 00:00:00 2001 From: Pete Date: Thu, 8 Aug 2024 19:28:25 +0100 Subject: [PATCH 175/866] Image server to convert pixel type New methods in `BufferedImageTools` to help with creating images and converting types, with rounding and clipping as required. --- .../lib/awt/common/BufferedImageTools.java | 122 ++++++++++++++++++ .../qupath/lib/color/ColorModelFactory.java | 2 +- .../lib/images/servers/ImageServers.java | 31 +++++ .../servers/TransformedServerBuilder.java | 23 +++- .../servers/TypeConvertImageServer.java | 99 ++++++++++++++ .../awt/common/TestBufferedImageTools.java | 73 +++++++++++ 6 files changed, 347 insertions(+), 3 deletions(-) create mode 100644 qupath-core/src/main/java/qupath/lib/images/servers/TypeConvertImageServer.java diff --git a/qupath-core/src/main/java/qupath/lib/awt/common/BufferedImageTools.java b/qupath-core/src/main/java/qupath/lib/awt/common/BufferedImageTools.java index 9f107ba5a..a264adba4 100644 --- a/qupath-core/src/main/java/qupath/lib/awt/common/BufferedImageTools.java +++ b/qupath-core/src/main/java/qupath/lib/awt/common/BufferedImageTools.java @@ -28,19 +28,26 @@ import java.awt.Image; import java.awt.Rectangle; import java.awt.Shape; +import java.awt.image.BandedSampleModel; import java.awt.image.BufferedImage; +import java.awt.image.ColorModel; import java.awt.image.DataBuffer; +import java.awt.image.Raster; import java.awt.image.WritableRaster; import java.util.Hashtable; +import java.util.List; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import ij.process.FloatProcessor; import ij.process.ImageProcessor; +import qupath.lib.color.ColorModelFactory; import qupath.lib.common.ColorTools; import qupath.lib.common.GeneralTools; import qupath.lib.images.servers.AbstractTileableImageServer; +import qupath.lib.images.servers.ImageChannel; +import qupath.lib.images.servers.PixelType; import qupath.lib.regions.RegionRequest; import qupath.lib.roi.RoiTools; import qupath.lib.roi.interfaces.ROI; @@ -656,6 +663,121 @@ public static long computeAboveThresholdCounts(WritableRaster raster, int band, return count; } + /** + * Convert a raster to have a specified pixel type. + * Note that this is not intended for RGB images, and the output will not be a specific RGB type. + * @param img the input image + * @param targetPixelType the required pixel type of the output + * @param channels the channels used to create the color model; if null, the default channels for the pixel type will be used + * @return a new image containing pixels converted to the required type + * @throws UnsupportedOperationException if the pixel type is not supported (e.g. INT8 or UINT32). + */ + public static BufferedImage convertImageType(BufferedImage img, PixelType targetPixelType, List channels) { + var raster = img.getRaster(); + var newRaster = convertRasterType(raster, targetPixelType); + if (channels == null) { + channels = ImageChannel.getDefaultChannelList(newRaster.getNumBands()); + } + var colorModel = ColorModelFactory.createColorModel(targetPixelType, channels); + return new BufferedImage(colorModel, newRaster, img.isAlphaPremultiplied(), null); + } + + /** + * Convert a raster to have a specified pixel type. + * If the output is an integer type, rounding will be applied and values will be clipped to the permitted range. + * @param inputRaster raster containing the input pixels + * @param targetPixelType pixel type to which the raster should be converted + * @return a new raster containing pixels converted to the required type + * @throws UnsupportedOperationException if the pixel type is not supported (e.g. INT8 or UINT32). + */ + public static WritableRaster convertRasterType(Raster inputRaster, PixelType targetPixelType) throws UnsupportedOperationException { + int width = inputRaster.getWidth(); + int height = inputRaster.getHeight(); + int numBands = inputRaster.getNumBands(); + + var sampleModel = new BandedSampleModel(getDataBufferType(targetPixelType), width, height, numBands); + var buffer = sampleModel.createDataBuffer(); + var outputRaster = WritableRaster.createWritableRaster(sampleModel, buffer, null); + + double[] values = null; + double minValue = targetPixelType.isFloatingPoint() ? Double.NEGATIVE_INFINITY : targetPixelType.getLowerBound().doubleValue(); + double maxValue = targetPixelType.isFloatingPoint() ? Double.POSITIVE_INFINITY : targetPixelType.getUpperBound().doubleValue(); + boolean doRounding = targetPixelType.isSignedInteger() || targetPixelType.isUnsignedInteger(); + for (int i = 0; i < numBands; i++) { + values = inputRaster.getSamples(0, 0, width, height, i, values); + if (doRounding) + roundAndClip(values, minValue, maxValue); + outputRaster.setSamples(0, 0, width, height, i, values); + } + return outputRaster; + } + + /** + * Get the appropriate DataBuffer type for a given PixelType. + * @param pixelType the pixel type for which the DataBuffer type is required + * @return the integer corresponding to the DataBuffer type. + * @see DataBuffer + * @throws UnsupportedOperationException if the pixel type is not supported (e.g. INT8 or UINT32). + */ + public static int getDataBufferType(PixelType pixelType) throws UnsupportedOperationException { + switch (pixelType) { + case UINT8: + return DataBuffer.TYPE_BYTE; + case INT16: + return DataBuffer.TYPE_SHORT; + case UINT16: + return DataBuffer.TYPE_USHORT; + case INT32: + return DataBuffer.TYPE_INT; + case FLOAT32: + return DataBuffer.TYPE_FLOAT; + case FLOAT64: + return DataBuffer.TYPE_DOUBLE; + case INT8: + case UINT32: + default: + throw new IllegalArgumentException("DataBuffer does not support " + pixelType); + } + } + + + private static void roundAndClip(double[] values, double min, double max) { + for (int i = 0; i < values.length; i++) { + values[i] = GeneralTools.clipValue(Math.round(values[i]), min, max); + } + } + + /** + * Create a new image with the specified width, height, pixel type, and number of channels. + * Note that this is not intended for RGB images, which already have straightforward BufferedImage constructors. + * @param width the width of the image + * @param height the height of the image + * @param pixelType the pixel type of the image + * @param nChannels the number of channels to use in the image; the default channel colors will be used + * @return a new image with the specified properties + */ + public static BufferedImage createImage(int width, int height, PixelType pixelType, int nChannels) { + return createImage(width, height, pixelType, ImageChannel.getDefaultChannelList(nChannels)); + } + + /** + * Create a new image with the specified width, height, pixel type, and channels. + * Note that this is not intended for RGB images, which already have straightforward BufferedImage constructors. + * @param width the width of the image + * @param height the height of the image + * @param pixelType the pixel type of the image + * @param channels the channels to use in the image + * @return a new image with the specified properties + */ + public static BufferedImage createImage(int width, int height, PixelType pixelType, List channels) { + var sampleModel = new BandedSampleModel(getDataBufferType(pixelType), width, height, channels.size()); + var buffer = sampleModel.createDataBuffer(); + var raster = WritableRaster.createWritableRaster(sampleModel, buffer, null); + var colorModel = ColorModelFactory.createColorModel(pixelType, channels); + return new BufferedImage(colorModel, raster, false, null); + } + + // /** // * Resize the image to have the requested width/height, using area averaging and bilinear interpolation. // * diff --git a/qupath-core/src/main/java/qupath/lib/color/ColorModelFactory.java b/qupath-core/src/main/java/qupath/lib/color/ColorModelFactory.java index 55d4fdb4a..8a09fe45c 100644 --- a/qupath-core/src/main/java/qupath/lib/color/ColorModelFactory.java +++ b/qupath-core/src/main/java/qupath/lib/color/ColorModelFactory.java @@ -325,7 +325,7 @@ public static ColorModel createColorModel(final PixelType type, final int nChann * @param channels * @return */ - public static ColorModel createColorModel(final PixelType type, final List channels) { + public static ColorModel createColorModel(final PixelType type, final List channels) { return new DefaultColorModel(type, channels.size(), false, channels.stream().mapToInt(c -> { Integer color = c.getColor(); if (color == null) diff --git a/qupath-core/src/main/java/qupath/lib/images/servers/ImageServers.java b/qupath-core/src/main/java/qupath/lib/images/servers/ImageServers.java index 04dfb594c..4c11b9060 100644 --- a/qupath-core/src/main/java/qupath/lib/images/servers/ImageServers.java +++ b/qupath-core/src/main/java/qupath/lib/images/servers/ImageServers.java @@ -747,6 +747,37 @@ public ServerBuilder updateURIs(Map updateMap) { } } + + static class TypeConvertImageServerBuilder extends AbstractServerBuilder { + + private ServerBuilder builder; + private PixelType pixelType; + + TypeConvertImageServerBuilder(ImageServerMetadata metadata, ServerBuilder builder, PixelType pixelType) { + super(metadata); + this.builder = builder; + this.pixelType = pixelType; + } + + @Override + protected ImageServer buildOriginal() throws Exception { + return new TypeConvertImageServer(builder.build(), pixelType); + } + + @Override + public Collection getURIs() { + return builder.getURIs(); + } + + @Override + public ServerBuilder updateURIs(Map updateMap) { + ServerBuilder newBuilder = builder.updateURIs(updateMap); + if (newBuilder == builder) + return this; + return new TypeConvertImageServerBuilder(getMetadata().orElse(null), newBuilder, pixelType); + } + + } static class ReorderRGBServerBuilder extends AbstractServerBuilder { diff --git a/qupath-core/src/main/java/qupath/lib/images/servers/TransformedServerBuilder.java b/qupath-core/src/main/java/qupath/lib/images/servers/TransformedServerBuilder.java index 28fcfb1c5..b5e24cfb4 100644 --- a/qupath-core/src/main/java/qupath/lib/images/servers/TransformedServerBuilder.java +++ b/qupath-core/src/main/java/qupath/lib/images/servers/TransformedServerBuilder.java @@ -43,7 +43,6 @@ * Note: This is an early-stage experimental class, which may well change! * * @author Pete Bankhead - * */ public class TransformedServerBuilder { @@ -214,6 +213,7 @@ public TransformedServerBuilder concatChannels(ImageServer... add * Subtract a constant offset from all channels, without clipping. * @param offsets a single offset to subtract from all channels, or an array of offsets to subtract from each channel. * @return + * @since v0.6.0 */ public TransformedServerBuilder subtractOffset(double... offsets) { return normalize(SubtractOffsetAndScaleNormalizer.createSubtractOffset(offsets)); @@ -223,6 +223,7 @@ public TransformedServerBuilder subtractOffset(double... offsets) { * Subtract a constant offset from all channels, clipping the result to be ≥ 0. * @param offsets a single offset to subtract from all channels, or an array of offsets to subtract from each channel. * @return + * @since v0.6.0 */ public TransformedServerBuilder subtractOffsetAndClipZero(double... offsets) { return normalize(SubtractOffsetAndScaleNormalizer.createSubtractOffsetAndClipZero(offsets)); @@ -233,6 +234,7 @@ public TransformedServerBuilder subtractOffsetAndClipZero(double... offsets) { * @param offsets a single offset to subtract from all channels, or an array of offsets to subtract from each channel. * @param scales a single scale factor to apply to all channels, or an array of scale factors to apply to each channel. * @return + * @since v0.6.0 */ public TransformedServerBuilder subtractOffsetAndScale(double[] offsets, double[] scales) { return normalize(SubtractOffsetAndScaleNormalizer.create(offsets, scales)); @@ -242,6 +244,7 @@ public TransformedServerBuilder subtractOffsetAndScale(double[] offsets, double[ * Scale all channels by a constant factor. * @param scales a single scale factor to apply to all channels, or an array of scale factors to apply to each channel. * @return + * @since v0.6.0 */ public TransformedServerBuilder scaleChannels(double... scales) { return normalize(SubtractOffsetAndScaleNormalizer.create(null, scales)); @@ -254,6 +257,7 @@ public TransformedServerBuilder scaleChannels(double... scales) { * @param scales optional array of scale factors to apply to each deconvolved channel. * A scale factor of 1.0 will leave the channel unchanged, while a scale of 0.0 will suppress the channel. * @return + * @since v0.6.0 */ public TransformedServerBuilder stainNormalize(ColorDeconvolutionStains stainsInput, ColorDeconvolutionStains stainsOutput, double... scales) { return normalize(ColorDeconvolutionNormalizer.create(stainsInput, stainsOutput, scales)); @@ -265,6 +269,7 @@ public TransformedServerBuilder stainNormalize(ColorDeconvolutionStains stainsIn * @return * @ImplNote To use this method to create an image that can be added to a project, the normalizers must be JSON-serializable * and registered under {@link ImageServers#getNormalizerFactory()}. + * @since v0.6.0 */ public TransformedServerBuilder normalize(BufferedImageNormalizer normalizer) { this.server = new NormalizedImageServer(server, normalizer); @@ -276,6 +281,7 @@ public TransformedServerBuilder normalize(BufferedImageNormalizer normalizer) { * * @param transforms the transforms to apply * @return this builder + * @since v0.6.0 */ public TransformedServerBuilder applyColorTransforms(Collection transforms) { server = new ChannelTransformFeatureServer(server, new ArrayList<>(transforms)); @@ -287,14 +293,27 @@ public TransformedServerBuilder applyColorTransforms(Collection build() { return server; diff --git a/qupath-core/src/main/java/qupath/lib/images/servers/TypeConvertImageServer.java b/qupath-core/src/main/java/qupath/lib/images/servers/TypeConvertImageServer.java new file mode 100644 index 000000000..53984a505 --- /dev/null +++ b/qupath-core/src/main/java/qupath/lib/images/servers/TypeConvertImageServer.java @@ -0,0 +1,99 @@ +/*- + * #%L + * This file is part of QuPath. + * %% + * Copyright (C) 2024 QuPath developers, The University of Edinburgh + * %% + * QuPath 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 3 of the + * License, or (at your option) any later version. + * + * QuPath 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 QuPath. If not, see . + * #L% + */ + +package qupath.lib.images.servers; + +import qupath.lib.awt.common.BufferedImageTools; + +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.net.URI; +import java.util.Collection; +import java.util.Set; + +/** + * An ImageServer implementation that converts the pixel type of an image. + * + * @since v0.6.0 + */ +public class TypeConvertImageServer extends AbstractTileableImageServer { + + private static final Set INVALID_TYPES = Set.of( + PixelType.INT8, PixelType.UINT32 + ); + + private final ImageServer server; + private final PixelType pixelType; + private final ImageServerMetadata metadata; +// + protected TypeConvertImageServer(ImageServer server, PixelType outputType) { + super(); + if (INVALID_TYPES.contains(outputType)) { + throw new IllegalArgumentException("Invalid pixel type: " + outputType); + } + this.server = server; + this.pixelType = outputType; + this.metadata = new ImageServerMetadata.Builder(server.getMetadata()) + .pixelType(outputType) + .name(server.getMetadata().getName() + " (" + outputType + ")") + .build(); + } + + /** + * Get underlying ImageServer, i.e. the one that is being wrapped. + * + * @return + */ + protected ImageServer getWrappedServer() { + return server; + } + + @Override + public Collection getURIs() { + return getWrappedServer().getURIs(); + } + + @Override + public String getServerType() { + return "Type convert image server (" + pixelType + ")"; + } + + @Override + public ImageServerMetadata getOriginalMetadata() { + return metadata; + } + + @Override + protected BufferedImage readTile(TileRequest tileRequest) throws IOException { + var img = getWrappedServer().readRegion(tileRequest.getRegionRequest()); + return BufferedImageTools.convertImageType(img, pixelType, getMetadata().getChannels()); + } + + @Override + protected ImageServerBuilder.ServerBuilder createServerBuilder() { + return new ImageServers.TypeConvertImageServerBuilder(getMetadata(), getWrappedServer().getBuilder(), pixelType); + } + + @Override + protected String createID() { + return "Type converted: " + getWrappedServer().getPath() + " (" + pixelType + ")"; + } +} diff --git a/qupath-core/src/test/java/qupath/lib/awt/common/TestBufferedImageTools.java b/qupath-core/src/test/java/qupath/lib/awt/common/TestBufferedImageTools.java index 51a1749f0..1cb5783ee 100644 --- a/qupath-core/src/test/java/qupath/lib/awt/common/TestBufferedImageTools.java +++ b/qupath-core/src/test/java/qupath/lib/awt/common/TestBufferedImageTools.java @@ -24,6 +24,7 @@ import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; import java.awt.Color; import java.awt.Image; @@ -45,6 +46,7 @@ import org.junit.jupiter.api.Test; import qupath.lib.common.ColorTools; +import qupath.lib.images.servers.PixelType; @SuppressWarnings("javadoc") public class TestBufferedImageTools { @@ -591,4 +593,75 @@ public void test_computeAboveThresholdCounts() { } } } + + + @Test + public void test_getDataBufferType() { + assertEquals(DataBuffer.TYPE_BYTE, BufferedImageTools.getDataBufferType(PixelType.UINT8)); + assertEquals(DataBuffer.TYPE_USHORT, BufferedImageTools.getDataBufferType(PixelType.UINT16)); + assertEquals(DataBuffer.TYPE_SHORT, BufferedImageTools.getDataBufferType(PixelType.INT16)); + assertEquals(DataBuffer.TYPE_INT, BufferedImageTools.getDataBufferType(PixelType.INT32)); + assertEquals(DataBuffer.TYPE_FLOAT, BufferedImageTools.getDataBufferType(PixelType.FLOAT32)); + assertEquals(DataBuffer.TYPE_DOUBLE, BufferedImageTools.getDataBufferType(PixelType.FLOAT64)); + assertThrows(IllegalArgumentException.class, () -> BufferedImageTools.getDataBufferType(PixelType.INT8)); + assertThrows(IllegalArgumentException.class, () -> BufferedImageTools.getDataBufferType(PixelType.UINT32)); + } + + @Test + public void test_createImage() { + checkImageProperties(BufferedImageTools.createImage(100, 200, PixelType.UINT8, 4), + DataBuffer.TYPE_BYTE, 100, 200, 4); + checkImageProperties(BufferedImageTools.createImage(100, 200, PixelType.INT16, 5), + DataBuffer.TYPE_SHORT, 100, 200, 5); + checkImageProperties(BufferedImageTools.createImage(100, 200, PixelType.FLOAT32, 1), + DataBuffer.TYPE_FLOAT, 100, 200, 1); + checkImageProperties(BufferedImageTools.createImage(100, 200, PixelType.FLOAT64, 2), + DataBuffer.TYPE_DOUBLE, 100, 200, 2); + } + + @Test + public void test_convertImage() { + int w = 10; + int h = 20; + int nBands = 4; + var img = BufferedImageTools.createImage(w, h, PixelType.UINT16, nBands); + checkImageProperties(BufferedImageTools.convertImageType(img, PixelType.INT16, null), + DataBuffer.TYPE_SHORT, img.getWidth(), img.getHeight(), nBands); + checkImageProperties(BufferedImageTools.convertImageType(img, PixelType.FLOAT32, null), + DataBuffer.TYPE_FLOAT, img.getWidth(), img.getHeight(), nBands); + checkImageProperties(BufferedImageTools.convertImageType(img, PixelType.UINT8, null), + DataBuffer.TYPE_BYTE, img.getWidth(), img.getHeight(), nBands); + } + + private static void checkImageProperties(BufferedImage img, int dataType, int width, int height, int nBands) { + assertEquals(dataType, img.getRaster().getDataBuffer().getDataType()); + assertEquals(width, img.getWidth()); + assertEquals(height, img.getHeight()); + assertEquals(nBands, img.getRaster().getNumBands()); + } + + @Test + public void test_convertFloatRaster() { + int w = 1; + int h = 1; + int nBands = 2; + var img = BufferedImageTools.createImage(w, h, PixelType.FLOAT64, nBands); + var raster = img.getRaster(); + raster.setPixels(0, 0, 1, 1, new double[]{1000.5, -1000.5}); + + assertArrayEquals(new double[]{1000.5, -1000.5}, img.getRaster().getPixels(0, 0, 1, 1, (double[])null)); + assertArrayEquals(new double[]{1000.5, -1000.5}, + BufferedImageTools.convertRasterType(raster, PixelType.FLOAT64).getPixels(0, 0, 1, 1, (double[])null)); + assertArrayEquals(new double[]{1000.5, -1000.5}, + BufferedImageTools.convertRasterType(raster, PixelType.FLOAT32).getPixels(0, 0, 1, 1, (double[])null)); + assertArrayEquals(new double[]{1001, -1000.0}, + BufferedImageTools.convertRasterType(raster, PixelType.INT16).getPixels(0, 0, 1, 1, (double[])null)); + assertArrayEquals(new double[]{1001, -1000.0}, + BufferedImageTools.convertRasterType(raster, PixelType.INT32).getPixels(0, 0, 1, 1, (double[])null)); + assertArrayEquals(new double[]{1001, 0.0}, + BufferedImageTools.convertRasterType(raster, PixelType.UINT16).getPixels(0, 0, 1, 1, (double[])null)); + assertArrayEquals(new double[]{255.0, 0.0}, + BufferedImageTools.convertRasterType(raster, PixelType.UINT8).getPixels(0, 0, 1, 1, (double[])null)); + } + } \ No newline at end of file From cb90c93b4bfba2d59fbe272b987ac1f0dd8a30a2 Mon Sep 17 00:00:00 2001 From: Carlo Castoldi Date: Thu, 8 Aug 2024 20:34:45 +0200 Subject: [PATCH 176/866] avoid server loading during ImageData write if it was never loaded before --- .../main/java/qupath/lib/images/ImageData.java | 17 +++++++++++++++++ .../src/main/java/qupath/lib/io/PathIO.java | 14 ++++++++++---- .../qupath/lib/projects/DefaultProject.java | 16 +++++++++++++--- 3 files changed, 40 insertions(+), 7 deletions(-) diff --git a/qupath-core/src/main/java/qupath/lib/images/ImageData.java b/qupath-core/src/main/java/qupath/lib/images/ImageData.java index f2936db5a..413092cd2 100644 --- a/qupath-core/src/main/java/qupath/lib/images/ImageData.java +++ b/qupath-core/src/main/java/qupath/lib/images/ImageData.java @@ -369,6 +369,23 @@ private static void addColorDeconvolutionStainsToWorkflow(ImageData imageData if (!Objects.equals(newStep, lastStep)) imageData.getHistoryWorkflow().addStep(newStep); } + + /** + * Whether the corresponding ImageServer was lazy-loaded or not, + * @return + */ + public boolean isLoaded() { + return this.server != null; + } + + /** + * Get the ServerBuilder corresponding to the newest ImageServer known associated to this ImageData. + * @return + * @see #getServer() + */ + public ImageServerBuilder.ServerBuilder getServerBuilder() { + return this.isLoaded() ? getServer().getBuilder() : this.serverBuilder; + } /** * Get the ImageServer. diff --git a/qupath-core/src/main/java/qupath/lib/io/PathIO.java b/qupath-core/src/main/java/qupath/lib/io/PathIO.java index 48228360e..9eaa80730 100644 --- a/qupath-core/src/main/java/qupath/lib/io/PathIO.java +++ b/qupath-core/src/main/java/qupath/lib/io/PathIO.java @@ -607,7 +607,7 @@ private static void writeImageDataSerialized(final OutputStream stream, final Im outStream.writeUTF("Data file version " + DATA_FILE_VERSION); // Try to write a backwards-compatible image path - var server = imageData.getServer(); +// var server = imageData.getServer(); // var uris = server.getURIs(); // String path; // if (uris.size() == 1) { @@ -623,10 +623,16 @@ private static void writeImageDataSerialized(final OutputStream stream, final Im // Write JSON object including QuPath version and ServerBuilder // Note that the builder may be null, in which case the server cannot be recreated - var builder = server.getBuilder(); - if (builder == null) + var builder = imageData.getServerBuilder(); + String serverPath; + if (builder == null) { + var server = imageData.getServer(); logger.warn("Server {} does not provide a builder - it will not be possible to recover the ImageServer from this data file", server); - var wrapper = ServerBuilderWrapper.create(builder, server.getPath()); + serverPath = server.getPath(); + } else { + serverPath = imageData.getLastSavedPath(); + } + var wrapper = ServerBuilderWrapper.create(builder, serverPath); String json = GsonTools.getInstance().toJson(wrapper); outStream.writeObject(json); diff --git a/qupath-core/src/main/java/qupath/lib/projects/DefaultProject.java b/qupath-core/src/main/java/qupath/lib/projects/DefaultProject.java index 87b28958a..e533b4e4c 100644 --- a/qupath-core/src/main/java/qupath/lib/projects/DefaultProject.java +++ b/qupath-core/src/main/java/qupath/lib/projects/DefaultProject.java @@ -773,8 +773,7 @@ public synchronized void saveImageData(ImageData imageData) throw } // If successful, write the server (including metadata) - var server = imageData.getServer(); - var currentServerBuilder = server.getBuilder(); + var currentServerBuilder = imageData.getServerBuilder(); if (currentServerBuilder != null && !currentServerBuilder.equals(this.serverBuilder)) { this.serverBuilder = currentServerBuilder; writeServerBuilder(); @@ -920,7 +919,10 @@ static class ImageDataSummary { ImageDataSummary(ImageData imageData, long timestamp) { this.imageType = imageData.getImageType(); - this.server = new ServerSummary(imageData.getServer()); + if (imageData.isLoaded()) + this.server = new ServerSummary(imageData.getServer()); + else + this.server = new ServerSummary(imageData.getServerMetadata()); this.timestamp = timestamp; this.hierarchy = new HierarchySummary(imageData.getHierarchy()); } @@ -949,6 +951,14 @@ static class ServerSummary { this.sizeZ = server.nZSlices(); this.sizeT = server.nTimepoints(); } + + ServerSummary(ImageServerMetadata metadata) { + this.width = metadata.getWidth(); + this.height = metadata.getHeight(); + this.sizeC = metadata.getChannels().size(); + this.sizeZ = metadata.getSizeZ(); + this.sizeT = metadata.getSizeZ(); + } } From 2d74b9a436c8cd5d995cf0fa720c9c743284edc8 Mon Sep 17 00:00:00 2001 From: Leo Leplat <60394504+Rylern@users.noreply.github.com> Date: Thu, 8 Aug 2024 20:43:08 +0100 Subject: [PATCH 177/866] Addressed comments --- .../qupath/lib/objects/hierarchy/PathObjectHierarchy.java | 2 +- .../src/main/java/qupath/lib/gui/UndoRedoManager.java | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/qupath-core/src/main/java/qupath/lib/objects/hierarchy/PathObjectHierarchy.java b/qupath-core/src/main/java/qupath/lib/objects/hierarchy/PathObjectHierarchy.java index 38000e285..db68dc353 100644 --- a/qupath-core/src/main/java/qupath/lib/objects/hierarchy/PathObjectHierarchy.java +++ b/qupath-core/src/main/java/qupath/lib/objects/hierarchy/PathObjectHierarchy.java @@ -623,7 +623,7 @@ public synchronized boolean addObjects(Collection pathObje counter++; } if (changes) - fireObjectAddedEvent(getRootObject(), pathObjects.iterator().next()); + fireHierarchyChangedEvent(getRootObject()); // fireChangeEvent(getRootObject()); return changes; } diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/UndoRedoManager.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/UndoRedoManager.java index 1d8c83ebc..a34755b8d 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/UndoRedoManager.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/UndoRedoManager.java @@ -471,7 +471,12 @@ public void viewerClosed(QuPathViewer viewer) { @Override public void hierarchyChanged(PathObjectHierarchyEvent event) { // Try to avoid calling too often - if (undoingOrRedoing || event.isChanging() || maxUndoHierarchySize.get() <= 0 || event.getChangedObjects().stream().allMatch(p -> p instanceof ParallelTileObject)) + if (undoingOrRedoing || event.isChanging() || maxUndoHierarchySize.get() <= 0) + return; + + // During processing, we have ParallelTileObjects changing to show which part of the image is being handled + // - but we don't want to record these + if (!event.getChangedObjects().isEmpty() && event.getChangedObjects().stream().allMatch(p -> p instanceof ParallelTileObject)) return; // *Potentially* we might have the same hierarchy in multiple viewers From 73db029e3627ac26d58ae3e1967225dfd0b01657 Mon Sep 17 00:00:00 2001 From: Pete Date: Fri, 9 Aug 2024 08:11:10 +0100 Subject: [PATCH 178/866] Fix convert type with RGB images --- CHANGELOG.md | 1 + .../src/main/java/qupath/lib/scripting/QP.java | 3 +++ .../main/java/qupath/lib/images/servers/ImageServers.java | 1 + .../qupath/lib/images/servers/TypeConvertImageServer.java | 5 ++++- 4 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a3c5886b8..21496e17a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ This is a *work in progress* for the next major release. * This can substantially speed up _some_ scripts that don't need to access the image data (e.g. for some measurement export) * Initial core support for stain normalization and background subtraction (https://github.com/qupath/qupath/pull/1554) * Experimental - not yet a full feature or available through the user interface! +* Add `TransformedServerBuilder.convertType(PixelType)` to convert pixel types ### Bugs fixed * Tile export to .ome.tif can convert to 8-bit unnecessarily (https://github.com/qupath/qupath/issues/1494) diff --git a/qupath-core-processing/src/main/java/qupath/lib/scripting/QP.java b/qupath-core-processing/src/main/java/qupath/lib/scripting/QP.java index eae827bde..4306cb7bb 100644 --- a/qupath-core-processing/src/main/java/qupath/lib/scripting/QP.java +++ b/qupath-core-processing/src/main/java/qupath/lib/scripting/QP.java @@ -96,6 +96,7 @@ import qupath.lib.images.servers.LabeledImageServer; import qupath.lib.images.servers.PixelType; import qupath.lib.images.servers.ServerTools; +import qupath.lib.images.servers.TransformedServerBuilder; import qupath.lib.images.writers.ImageWriterTools; import qupath.lib.images.writers.TileExporter; import qupath.lib.io.GsonTools; @@ -265,6 +266,8 @@ public class QP { PathObject.class, PathObjectHierarchy.class, PathClass.class, + + TransformedServerBuilder.class, ImageRegion.class, RegionRequest.class, diff --git a/qupath-core/src/main/java/qupath/lib/images/servers/ImageServers.java b/qupath-core/src/main/java/qupath/lib/images/servers/ImageServers.java index 4c11b9060..c6a0a0763 100644 --- a/qupath-core/src/main/java/qupath/lib/images/servers/ImageServers.java +++ b/qupath-core/src/main/java/qupath/lib/images/servers/ImageServers.java @@ -100,6 +100,7 @@ public class ImageServers { .registerSubtype(ReorderRGBServerBuilder.class, "swapRedBlue") .registerSubtype(ColorDeconvolutionServerBuilder.class, "color_deconvolved") .registerSubtype(NormalizedImageServerBuilder.class, "normalized") + .registerSubtype(TypeConvertImageServerBuilder.class, "typeConvert") ; private static GsonTools.SubTypeAdapterFactory normalizerFactory = diff --git a/qupath-core/src/main/java/qupath/lib/images/servers/TypeConvertImageServer.java b/qupath-core/src/main/java/qupath/lib/images/servers/TypeConvertImageServer.java index 53984a505..80abf57e7 100644 --- a/qupath-core/src/main/java/qupath/lib/images/servers/TypeConvertImageServer.java +++ b/qupath-core/src/main/java/qupath/lib/images/servers/TypeConvertImageServer.java @@ -27,6 +27,7 @@ import java.io.IOException; import java.net.URI; import java.util.Collection; +import java.util.Objects; import java.util.Set; /** @@ -53,6 +54,8 @@ protected TypeConvertImageServer(ImageServer server, PixelType ou this.pixelType = outputType; this.metadata = new ImageServerMetadata.Builder(server.getMetadata()) .pixelType(outputType) + .rgb(outputType == PixelType.UINT8 && + Objects.equals(server.getMetadata().getChannels(), ImageChannel.getDefaultRGBChannels())) .name(server.getMetadata().getName() + " (" + outputType + ")") .build(); } @@ -73,7 +76,7 @@ public Collection getURIs() { @Override public String getServerType() { - return "Type convert image server (" + pixelType + ")"; + return "Type convert (" + pixelType + ")"; } @Override From 4baa09a12eb1ea565f3b436843f311ea37758c7f Mon Sep 17 00:00:00 2001 From: Pete Date: Fri, 9 Aug 2024 09:26:08 +0100 Subject: [PATCH 179/866] Update javadocs --- CHANGELOG.md | 4 ++-- .../main/java/qupath/lib/images/ImageData.java | 15 +++++++++++---- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a3c5886b8..d60ef1b59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,8 +20,8 @@ This is a *work in progress* for the next major release. * TMA cores can now have classifications assigned to them * Default color for TMA cores is lighter (to make it easier to see on both bright and dark backgrounds) * TMA core 'missing' status is now shown using opacity, not a different color, to preserve any classification color -* Faster *Run for project (without save)* scripts when the image doesn't need to be loaded (https://github.com/qupath/qupath/pull/1489) - * This can substantially speed up _some_ scripts that don't need to access the image data (e.g. for some measurement export) +* Faster *Run for project* scripts when the image doesn't need to be loaded (https://github.com/qupath/qupath/pull/1489) + * This can substantially speed up _some_ scripts that don't need to access pixel data (e.g. for most measurement export) * Initial core support for stain normalization and background subtraction (https://github.com/qupath/qupath/pull/1554) * Experimental - not yet a full feature or available through the user interface! diff --git a/qupath-core/src/main/java/qupath/lib/images/ImageData.java b/qupath-core/src/main/java/qupath/lib/images/ImageData.java index 413092cd2..963829595 100644 --- a/qupath-core/src/main/java/qupath/lib/images/ImageData.java +++ b/qupath-core/src/main/java/qupath/lib/images/ImageData.java @@ -371,7 +371,9 @@ private static void addColorDeconvolutionStainsToWorkflow(ImageData imageData } /** - * Whether the corresponding ImageServer was lazy-loaded or not, + * Query whether the corresponding ImageServer was lazy-loaded or not, + * If {@code isLoaded()} returns false, then calls to {@link #getServer()} will result + * in an attempt to load the server. * @return */ public boolean isLoaded() { @@ -379,7 +381,10 @@ public boolean isLoaded() { } /** - * Get the ServerBuilder corresponding to the newest ImageServer known associated to this ImageData. + * Get the ServerBuilde corresponding to the ImageServer associated with this ImageData. + *

    + * If the server has not yet been loaded, this will return a cached server builder + * if it is available and null otherwise. * @return * @see #getServer() */ @@ -388,7 +393,9 @@ public ImageServerBuilder.ServerBuilder getServerBuilder() { } /** - * Get the ImageServer. + * Get the ImageServer, loading it if necessary. + *

    + * If no server is available and loading fails, this method may throw an unchecked exception. * @return */ public ImageServer getServer() { @@ -402,7 +409,7 @@ public ImageServer getServer() { updateServerMetadata(lazyMetadata); } } catch (Exception e) { - throw new RuntimeException("Failed to lazy-load ImageServer", e); + throw new RuntimeException("Failed to load ImageServer", e); } } } From 7863067e4d9370826961b9c63084c3e399495bc4 Mon Sep 17 00:00:00 2001 From: Pete Date: Fri, 9 Aug 2024 10:43:37 +0100 Subject: [PATCH 180/866] Improve object name display in viewer Fix the behavior in the viewer so that object names aren't shown if the object itself is not shown. Also support showing named detections, not just annotations. --- .../gui/viewer/overlays/HierarchyOverlay.java | 109 +++++++++++------- 1 file changed, 70 insertions(+), 39 deletions(-) diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/overlays/HierarchyOverlay.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/overlays/HierarchyOverlay.java index 5d2fb31e8..f631eecb8 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/overlays/HierarchyOverlay.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/overlays/HierarchyOverlay.java @@ -37,12 +37,15 @@ import java.util.Collection; import java.util.Collections; import java.util.Comparator; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Set; import java.util.TreeSet; import java.util.WeakHashMap; import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; +import java.util.stream.Stream; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -65,11 +68,13 @@ import qupath.lib.objects.PathDetectionObject; import qupath.lib.objects.PathObject; import qupath.lib.objects.PathObjectConnections; +import qupath.lib.objects.PathObjectTools; import qupath.lib.objects.hierarchy.PathObjectHierarchy; import qupath.lib.regions.ImageRegion; import qupath.lib.regions.RegionRequest; import qupath.lib.roi.EllipseROI; import qupath.lib.roi.LineROI; +import qupath.lib.roi.ROIs; import qupath.lib.roi.RectangleROI; import qupath.lib.roi.interfaces.ROI; @@ -196,30 +201,49 @@ public void paintOverlay(final Graphics2D g2d, final ImageRegion imageRegion, fi if (boundsDisplayed.width <= 0 || boundsDisplayed.height <= 0) return; + // Determine the visible region + ImageRegion region = AwtTools.getImageRegion(boundsDisplayed, z, t); + // Get the annotations & selected objects (which must be painted directly) - Collection selectedObjects = new ArrayList<>(hierarchy.getSelectionModel().getSelectedObjects()); - selectedObjects.removeIf(p -> p == null || !p.hasROI() || (p.getROI().getZ() != z || p.getROI().getT() != t)); + Set visibleSelectedObjects = new LinkedHashSet<>(hierarchy.getSelectionModel().getSelectedObjects()); + visibleSelectedObjects.removeIf(p -> p == null || !p.hasROI() || (p.getROI().getZ() != z || p.getROI().getT() != t || + !region.intersects(p.getROI().getBoundsX(), p.getROI().getBoundsY(), p.getROI().getBoundsWidth(), p.getROI().getBoundsHeight()))); + + // Get all visible objects (including selected ones) + boolean showAnnotations = overlayOptions.getShowAnnotations(); + boolean showDetections = overlayOptions.getShowDetections(); + Collection visibleDetections = showDetections ? + hierarchy.getAllDetectionsForRegion(region) : + Collections.emptyList(); + + Collection visibleAnnotations = showAnnotations ? + hierarchy.getAnnotationsForRegion(region) : + Collections.emptyList(); + + // Return if nothing visible + if (visibleSelectedObjects.isEmpty() && visibleDetections.isEmpty() && visibleAnnotations.isEmpty()) + return; - ImageRegion region = AwtTools.getImageRegion(boundsDisplayed, z, t); - - // Paint detection objects + // Paint detection objects, if required long startTime = System.currentTimeMillis(); - if (overlayOptions.getShowDetections() && !hierarchy.isEmpty()) { + if (overlayOptions.getShowDetections() && !visibleDetections.isEmpty()) { // If we aren't downsampling by much, or we're upsampling, paint directly - making sure to paint the right number of times, and in the right order if (overlayServer == null || regionStore == null || downsampleFactor < 1.0) { - Collection pathObjects; + Collection detectionsToPaint; try { - Set pathObjectsToPaint = new TreeSet<>(comparator); - pathObjects = hierarchy.getAllDetectionsForRegion(region, pathObjectsToPaint); + detectionsToPaint = new TreeSet<>(comparator); + detectionsToPaint.addAll(visibleDetections); } catch (IllegalArgumentException e) { // This can happen (rarely) in a multithreaded environment if the level of a detection changes. // However, protecting against this fully by caching the level with integer boxing/unboxing would be expensive. logger.debug("Exception requesting detections to paint: " + e.getLocalizedMessage(), e); - pathObjects = hierarchy.getAllDetectionsForRegion(region, null); + detectionsToPaint = visibleDetections; } + // Paint selected objects at the end + detectionsToPaint.removeIf(p -> visibleSelectedObjects.contains(p)); g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_OFF); - PathObjectPainter.paintSpecifiedObjects(g2d, pathObjects, overlayOptions, hierarchy.getSelectionModel(), downsampleFactor); + PathObjectPainter.paintSpecifiedObjects(g2d, detectionsToPaint, overlayOptions, hierarchy.getSelectionModel(), downsampleFactor); if (overlayOptions.getShowConnections()) { Object connections = imageData.getProperty(DefaultPathObjectConnectionGroup.KEY_OBJECT_CONNECTIONS); @@ -252,39 +276,38 @@ public void paintOverlay(final Graphics2D g2d, final ImageRegion imageRegion, fi g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, defaultAntiAlias); g2d.setRenderingHint(RenderingHints.KEY_STROKE_CONTROL, defaultStroke); - // Prepare to handle labels, if we need to - Collection objectsWithNames = new ArrayList<>(); - Collection annotations = hierarchy.getAnnotationsForRegion(region, null); - for (var iterator = annotations.iterator(); iterator.hasNext(); ) { - var next = iterator.next(); - if ((next.getName() != null && !next.getName().isBlank())) - objectsWithNames.add(next); - if (selectedObjects.contains(next)) - iterator.remove(); - } - - // Paint the annotations - List pathObjectList = new ArrayList<>(annotations); - Collections.sort(pathObjectList, Comparator.comparingInt(PathObject::getLevel).reversed() - .thenComparing(Comparator.comparingDouble((PathObject p) -> -p.getROI().getArea()))); - PathObjectPainter.paintSpecifiedObjects(g2d, pathObjectList, overlayOptions, null, downsampleFactor); + // Paint annotations, using a defined order based on hierarchy level and area + var annotationsToPaint = visibleAnnotations.stream() + .filter(p -> !visibleSelectedObjects.contains(p)) + .sorted(Comparator.comparingInt(PathObject::getLevel).reversed() + .thenComparing((PathObject p) -> -p.getROI().getArea())) + .toList(); + PathObjectPainter.paintSpecifiedObjects(g2d, annotationsToPaint, overlayOptions, null, downsampleFactor); // Ensure that selected objects are painted last, to make sure they aren't obscured - if (!selectedObjects.isEmpty()) { + if (!visibleSelectedObjects.isEmpty()) { Composite previousComposite = g2d.getComposite(); float opacity = overlayOptions.getOpacity(); if (opacity < 1) { g2d.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER)); - PathObjectPainter.paintSpecifiedObjects(g2d, selectedObjects, overlayOptions, hierarchy.getSelectionModel(), downsampleFactor); + PathObjectPainter.paintSpecifiedObjects(g2d, visibleSelectedObjects, overlayOptions, hierarchy.getSelectionModel(), downsampleFactor); g2d.setComposite(previousComposite); } else { - PathObjectPainter.paintSpecifiedObjects(g2d, selectedObjects, overlayOptions, hierarchy.getSelectionModel(), downsampleFactor); + PathObjectPainter.paintSpecifiedObjects(g2d, visibleSelectedObjects, overlayOptions, hierarchy.getSelectionModel(), downsampleFactor); } } // Paint labels - if (overlayOptions.getShowNames() && !objectsWithNames.isEmpty()) { - + if (overlayOptions.getShowNames()) { + + // Get all objects with names that we might need to paint + Set objectsWithNames = new LinkedHashSet<>(); + visibleDetections.stream().filter(p -> p.getName() != null).forEach(objectsWithNames::add); + visibleAnnotations.stream().filter(p -> p.getName() != null).forEach(objectsWithNames::add); + visibleSelectedObjects.stream().filter(p -> p.getName() != null).forEach(objectsWithNames::add); + + var detectionDisplayMode = overlayOptions.getDetectionDisplayMode(); + double requestedFontSize = overlayOptions.getFontSize(); if (requestedFontSize <= 0 || !Double.isFinite(requestedFontSize)) { // Get it from the location font size instead @@ -319,12 +342,20 @@ public void paintOverlay(final Graphics2D g2d, final ImageRegion imageRegion, fi g2d.setRenderingHint(RenderingHints.KEY_FRACTIONALMETRICS, RenderingHints.VALUE_FRACTIONALMETRICS_ON); g2d.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON); - for (var annotation : objectsWithNames) { - var name = annotation.getName(); - - var roi = annotation.getROI(); + for (var namedObject : objectsWithNames) { + var name = namedObject.getName(); + + var roi = namedObject.getROI(); + if (namedObject.isCell()) { + if (detectionDisplayMode == OverlayOptions.DetectionDisplayMode.NUCLEI_ONLY) { + roi = PathObjectTools.getNucleusOrMainROI(namedObject); + } else if (detectionDisplayMode == OverlayOptions.DetectionDisplayMode.CENTROIDS) { + roi = PathObjectTools.getNucleusOrMainROI(namedObject); + roi = ROIs.createPointsROI(roi.getCentroidX(), roi.getCentroidY(), roi.getImagePlane()); + } + } - if (name != null && !name.isBlank() && roi != null && !overlayOptions.isPathClassHidden(annotation.getPathClass())) { + if (name != null && !name.isBlank() && roi != null && !overlayOptions.isPathClassHidden(namedObject.getPathClass())) { var bounds = metrics.getStringBounds(name, g2d); @@ -338,10 +369,10 @@ public void paintOverlay(final Graphics2D g2d, final ImageRegion imageRegion, fi // Get the object color int objectColorInt; - if (hierarchy.getSelectionModel().isSelected(annotation) && PathPrefs.useSelectedColorProperty().get()) + if (hierarchy.getSelectionModel().isSelected(namedObject) && PathPrefs.useSelectedColorProperty().get()) objectColorInt = PathPrefs.colorSelectedObjectProperty().get(); else - objectColorInt = ColorToolsFX.getDisplayedColorARGB(annotation).intValue(); + objectColorInt = ColorToolsFX.getDisplayedColorARGB(namedObject).intValue(); // Draw a line to where the name box will be var objectColor = ColorToolsAwt.getCachedColor(objectColorInt); From 2037364fd121ac8629b6544215bba77509cc0136 Mon Sep 17 00:00:00 2001 From: lleplat Date: Fri, 9 Aug 2024 11:53:53 +0100 Subject: [PATCH 181/866] Added possibility to copy measurement map color map --- .../lib/gui/panes/MeasurementMapPane.java | 29 +++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/panes/MeasurementMapPane.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/panes/MeasurementMapPane.java index 5cfc93991..782300801 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/panes/MeasurementMapPane.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/panes/MeasurementMapPane.java @@ -31,6 +31,11 @@ import java.util.HashMap; import java.util.Map; import java.util.Set; + +import javafx.scene.control.ContextMenu; +import javafx.scene.control.MenuItem; +import javafx.scene.input.Clipboard; +import javafx.scene.input.ClipboardContent; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -50,7 +55,6 @@ import javafx.scene.control.Label; import javafx.scene.control.ListView; import javafx.scene.control.Slider; -import javafx.scene.control.TextField; import javafx.scene.control.ToggleButton; import javafx.scene.control.Tooltip; import javafx.scene.image.Image; @@ -223,7 +227,28 @@ public void resize(double width, double height) { } }; Tooltip.install(colorMapKey, new Tooltip("Measurement map key")); - + + ContextMenu colorMapContextMenu = new ContextMenu(); + MenuItem copyColorMap = new MenuItem("Copy"); + copyColorMap.setOnAction(event -> { + if (colorMapKeyImage != null) { + Clipboard clipboard = Clipboard.getSystemClipboard(); + ClipboardContent content = new ClipboardContent(); + content.putImage(colorMapKeyImage); + clipboard.setContent(content); + Dialogs.showInfoNotification( + "Color map", + "Color map copied to clipboard" + ); + } + }); + colorMapContextMenu.getItems().add(copyColorMap); + colorMapKey.setOnMousePressed(event -> { + if (event.isSecondaryButtonDown()) { + colorMapContextMenu.show(pane, event.getScreenX(), event.getScreenY()); + } + }); + // Filter to reduce visible measurements var tfFilter = new PredicateTextField(); var tooltip = new Tooltip("Enter text to filter measurement list"); From 4275d173e14d223021574ad7ed0c86d5f0c4a24e Mon Sep 17 00:00:00 2001 From: lleplat Date: Fri, 9 Aug 2024 16:17:04 +0100 Subject: [PATCH 182/866] Save project sorting in manager --- .../java/qupath/lib/projects/Project.java | 14 ++++++++++++++ .../qupath/lib/gui/panes/ProjectBrowser.java | 19 +++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/qupath-core/src/main/java/qupath/lib/projects/Project.java b/qupath-core/src/main/java/qupath/lib/projects/Project.java index 4abb838ef..5f085fee1 100644 --- a/qupath-core/src/main/java/qupath/lib/projects/Project.java +++ b/qupath-core/src/main/java/qupath/lib/projects/Project.java @@ -268,6 +268,20 @@ public default Manager> getObjectClassifiers() { public default Manager getPixelClassifiers() { return getResources(PixelClassifier.PROJECT_LOCATION, PixelClassifier.class, "json"); } + + /** + * Get the manager for sort keys saved within this project. + *

    + * Sort keys can be used to group and sort project entries. + *

    + * The names of this manager correspond to keys, while their values are string representations + * of booleans corresponding to whether the key is in ascending or descending order. + * + * @return the manager for sort keys + */ + default Manager getSortKeys() { + return getResources("sort_keys", String.class, "txt"); + } /** * Get a manager for objects of a specified class within this project. diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/panes/ProjectBrowser.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/panes/ProjectBrowser.java index a43f9260e..162c7024d 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/panes/ProjectBrowser.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/panes/ProjectBrowser.java @@ -1172,6 +1172,14 @@ private class ProjectImageTreeModel { private ProjectImageTreeModel(final Project project) { this.root = new ProjectTreeRowItem(new ProjectTreeRow.RootRow(project)); + + try { + if (project != null && !project.getSortKeys().getNames().isEmpty()) { + this.metadataKey = project.getSortKeys().getNames().iterator().next(); + } + } catch (IOException e) { + logger.warn("Error while getting metadata key", e); + } } private String getMetadataKey() { @@ -1184,6 +1192,17 @@ private String getMetadataKey() { */ private void setMetadataKey(String metadataKey) { this.metadataKey = metadataKey; + + try { + for (String keyName: project.getSortKeys().getNames()) { + project.getSortKeys().remove(keyName); + } + if (metadataKey != null) { + project.getSortKeys().put(metadataKey, String.valueOf(true)); + } + } catch (IOException e) { + logger.warn("Error while setting new metadata key", e); + } } private ProjectTreeRowItem getRoot() { From 4893331cc4e5811b63263639cc4ea1e3e96b7578 Mon Sep 17 00:00:00 2001 From: lleplat Date: Fri, 9 Aug 2024 16:22:45 +0100 Subject: [PATCH 183/866] Added null checks --- .../src/main/java/qupath/lib/projects/Project.java | 2 +- .../java/qupath/lib/gui/panes/ProjectBrowser.java | 14 ++++++++------ 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/qupath-core/src/main/java/qupath/lib/projects/Project.java b/qupath-core/src/main/java/qupath/lib/projects/Project.java index 5f085fee1..e624e3e5b 100644 --- a/qupath-core/src/main/java/qupath/lib/projects/Project.java +++ b/qupath-core/src/main/java/qupath/lib/projects/Project.java @@ -277,7 +277,7 @@ public default Manager getPixelClassifiers() { * The names of this manager correspond to keys, while their values are string representations * of booleans corresponding to whether the key is in ascending or descending order. * - * @return the manager for sort keys + * @return the manager for sort keys, or {@code null} if the project does not support storing sort keys */ default Manager getSortKeys() { return getResources("sort_keys", String.class, "txt"); diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/panes/ProjectBrowser.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/panes/ProjectBrowser.java index 162c7024d..064982441 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/panes/ProjectBrowser.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/panes/ProjectBrowser.java @@ -1174,7 +1174,7 @@ private ProjectImageTreeModel(final Project project) { this.root = new ProjectTreeRowItem(new ProjectTreeRow.RootRow(project)); try { - if (project != null && !project.getSortKeys().getNames().isEmpty()) { + if (project != null && project.getSortKeys() != null && !project.getSortKeys().getNames().isEmpty()) { this.metadataKey = project.getSortKeys().getNames().iterator().next(); } } catch (IOException e) { @@ -1194,11 +1194,13 @@ private void setMetadataKey(String metadataKey) { this.metadataKey = metadataKey; try { - for (String keyName: project.getSortKeys().getNames()) { - project.getSortKeys().remove(keyName); - } - if (metadataKey != null) { - project.getSortKeys().put(metadataKey, String.valueOf(true)); + if (project != null && project.getSortKeys() != null) { + for (String keyName: project.getSortKeys().getNames()) { + project.getSortKeys().remove(keyName); + } + if (metadataKey != null) { + project.getSortKeys().put(metadataKey, String.valueOf(true)); + } } } catch (IOException e) { logger.warn("Error while setting new metadata key", e); From dc2ce6c0e0c555c37ca7fe99709737096b67668d Mon Sep 17 00:00:00 2001 From: lleplat Date: Fri, 9 Aug 2024 16:31:32 +0100 Subject: [PATCH 184/866] Typo --- .../src/main/java/qupath/lib/projects/Project.java | 12 ++++++------ .../java/qupath/lib/gui/panes/ProjectBrowser.java | 12 ++++++------ 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/qupath-core/src/main/java/qupath/lib/projects/Project.java b/qupath-core/src/main/java/qupath/lib/projects/Project.java index e624e3e5b..a918bc46a 100644 --- a/qupath-core/src/main/java/qupath/lib/projects/Project.java +++ b/qupath-core/src/main/java/qupath/lib/projects/Project.java @@ -270,17 +270,17 @@ public default Manager getPixelClassifiers() { } /** - * Get the manager for sort keys saved within this project. + * Get the manager for sorting keys saved within this project. *

    - * Sort keys can be used to group and sort project entries. + * Sorting keys can be used to group and sort project entries. *

    - * The names of this manager correspond to keys, while their values are string representations + * The names of this manager correspond to sorting keys, while their values are string representations * of booleans corresponding to whether the key is in ascending or descending order. * - * @return the manager for sort keys, or {@code null} if the project does not support storing sort keys + * @return the manager for sorting keys, or {@code null} if the project does not support storing sorting keys */ - default Manager getSortKeys() { - return getResources("sort_keys", String.class, "txt"); + default Manager getSortingKeys() { + return getResources("sorting_keys", String.class, "txt"); } /** diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/panes/ProjectBrowser.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/panes/ProjectBrowser.java index 064982441..6d8bc6657 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/panes/ProjectBrowser.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/panes/ProjectBrowser.java @@ -1174,8 +1174,8 @@ private ProjectImageTreeModel(final Project project) { this.root = new ProjectTreeRowItem(new ProjectTreeRow.RootRow(project)); try { - if (project != null && project.getSortKeys() != null && !project.getSortKeys().getNames().isEmpty()) { - this.metadataKey = project.getSortKeys().getNames().iterator().next(); + if (project != null && project.getSortingKeys() != null && !project.getSortingKeys().getNames().isEmpty()) { + this.metadataKey = project.getSortingKeys().getNames().iterator().next(); } } catch (IOException e) { logger.warn("Error while getting metadata key", e); @@ -1194,12 +1194,12 @@ private void setMetadataKey(String metadataKey) { this.metadataKey = metadataKey; try { - if (project != null && project.getSortKeys() != null) { - for (String keyName: project.getSortKeys().getNames()) { - project.getSortKeys().remove(keyName); + if (project != null && project.getSortingKeys() != null) { + for (String keyName: project.getSortingKeys().getNames()) { + project.getSortingKeys().remove(keyName); } if (metadataKey != null) { - project.getSortKeys().put(metadataKey, String.valueOf(true)); + project.getSortingKeys().put(metadataKey, String.valueOf(true)); } } } catch (IOException e) { From 1a555587541796ed245c7ef41564386eb103830f Mon Sep 17 00:00:00 2001 From: Pete Date: Fri, 9 Aug 2024 17:21:22 +0100 Subject: [PATCH 185/866] Simplify PathIO Fix the infinite recursion caused by a recent change, and occurring when attempting to open a .qpdata file outside a project. Also clean up some other parts of the class, and add simpler `readImageData` methods that don't require so many parameters. Deprecate the previous `readImageData` methods and log warnings when they are called. --- .../main/java/qupath/lib/scripting/QP.java | 2 +- .../src/main/java/qupath/lib/io/PathIO.java | 404 ++++++++++-------- .../qupath/lib/projects/DefaultProject.java | 14 +- .../main/java/qupath/lib/gui/QuPathGUI.java | 12 +- .../qupath/lib/gui/commands/Commands.java | 2 +- .../lib/gui/commands/ProjectCommands.java | 3 +- .../commands/ProjectImportImagesCommand.java | 2 +- .../qupath/lib/gui/tma/TMASummaryViewer.java | 2 +- 8 files changed, 237 insertions(+), 204 deletions(-) diff --git a/qupath-core-processing/src/main/java/qupath/lib/scripting/QP.java b/qupath-core-processing/src/main/java/qupath/lib/scripting/QP.java index 4306cb7bb..7016a1fb0 100644 --- a/qupath-core-processing/src/main/java/qupath/lib/scripting/QP.java +++ b/qupath-core-processing/src/main/java/qupath/lib/scripting/QP.java @@ -553,7 +553,7 @@ static Project getBatchProject() { */ @Deprecated public static ImageData loadImageData(final String path, final boolean setBatchData) throws IOException { - ImageData imageData = PathIO.readImageData(new File(resolvePath(path)), null, null, BufferedImage.class); + ImageData imageData = PathIO.readImageData(new File(resolvePath(path))); if (setBatchData && imageData != null) setBatchImageData(imageData); return imageData; diff --git a/qupath-core/src/main/java/qupath/lib/io/PathIO.java b/qupath-core/src/main/java/qupath/lib/io/PathIO.java index 9eaa80730..c4ec67cac 100644 --- a/qupath-core/src/main/java/qupath/lib/io/PathIO.java +++ b/qupath-core/src/main/java/qupath/lib/io/PathIO.java @@ -31,6 +31,7 @@ import org.slf4j.LoggerFactory; import qupath.lib.color.ColorDeconvolutionStains; import qupath.lib.common.GeneralTools; +import qupath.lib.common.LogTools; import qupath.lib.images.ImageData; import qupath.lib.images.servers.ImageServer; import qupath.lib.images.servers.ImageServerBuilder.DefaultImageServerBuilder; @@ -78,7 +79,6 @@ import java.util.Map.Entry; import java.util.Objects; import java.util.Set; -import java.util.function.Supplier; import java.util.stream.Collectors; import java.util.zip.GZIPInputStream; import java.util.zip.GZIPOutputStream; @@ -87,9 +87,6 @@ /** * Primary class for loading/saving {@link ImageData} objects. - * - * @author Pete Bankhead - * */ public class PathIO { @@ -108,16 +105,6 @@ public class PathIO { * Input filter for deserialization that is limited to QuPath-related classes. */ private static final ObjectInputFilter QUPATH_INPUT_FILTER = PathIO::qupathInputFilter; - // Less restrictive ObjectInputFilter -// private static final ObjectInputFilter CLASS_LOADER_INPUT_FILTER = PathIO::classLoaderInputFilter; - -// static { -// /** -// * TODO: Consider setting global filter. -// * However note that this then impacts scripts and extensions, so could become restrictive. -// */ -// ObjectInputFilter.Config.setSerialFilter(OBJECT_INPUT_FILTER); -// } private PathIO() {} @@ -151,7 +138,7 @@ public static int getCurrentDataFileVersion() { *

  1. 1.0 Initial version stored in very early .qpdata files (no longer supported)
  2. *
  3. 2 Switched versions to use integers, added Locale information (used in QuPath v0.1.2)
  4. *
  5. 3 Switched {@link ImageServer} paths to be a JSON representation rather than a single path/URL
  6. - *
  7. 4 Added support for UUID to be stored in each {@link PathObject} (introducted QuPath v0.4.0)
  8. + *
  9. 4 Added support for UUID to be stored in each {@link PathObject} (introduced QuPath v0.4.0)
  10. * * * @param version integer representation of the requested version @@ -229,7 +216,7 @@ public static ServerBuilder extractServerBuilder(Path file) throws IOExce * @return * @throws IOException */ - public static final ObjectInputStream createObjectInputStream(InputStream stream) throws IOException { + public static ObjectInputStream createObjectInputStream(InputStream stream) throws IOException { ObjectInputStream inStream = new ObjectInputStream(stream); inStream.setObjectInputFilter(QUPATH_INPUT_FILTER); return inStream; @@ -241,7 +228,7 @@ public static final ObjectInputStream createObjectInputStream(InputStream stream * This may represent an image path (for v0.2 and earlier) or JSON (from v0.3). * * @param - * @param serverString + * @param serverString string including the builder as JSON, or a legacy image path * @param warnIfInvalid log warnings if the server is a different version * @return * @throws IOException @@ -290,40 +277,24 @@ static ServerBuilderWrapper create(ServerBuilder builder, String id) } - private static ImageData readImageDataSerialized(final Path path, ImageData imageData, - ImageServer server, Class cls) throws FileNotFoundException, IOException { - imageData = readImageDataSerialized(path, imageData, server, cls); - imageData.getServer(); // Ensure the server is loaded - return imageData; - } - - private static ImageData readImageDataSerialized(final InputStream stream, ImageData imageData, - ImageServer server, Class cls) throws FileNotFoundException, IOException { - imageData = readImageDataSerialized(stream, imageData, server, cls); - imageData.getServer(); // Ensure the server is loaded - return imageData; - } - - private static ImageData readImageDataSerialized(final Path path, ImageData imageData, - ServerBuilder serverBuilder, Class cls) throws FileNotFoundException, IOException { - if (path == null) - return null; - logger.info("Reading data from {}...", path.getFileName().toString()); - try (InputStream stream = Files.newInputStream(path)) { - imageData = readImageDataSerialized(stream, imageData, serverBuilder, cls); + private static ImageData readImageDataSerialized(final Path path, ImageServer server) throws IOException { + try (var stream = new BufferedInputStream(Files.newInputStream(path))) { + var imageData = readImageDataSerialized(stream, server); // Set the last saved path (actually the path from which this was opened) - if (imageData != null) - imageData.setLastSavedPath(path.toAbsolutePath().toString(), true); + imageData.setLastSavedPath(path.toAbsolutePath().toString(), true); return imageData; -// } catch (IOException e) { -// logger.error("Error reading ImageData from file", e); -// return null; } } - + + private static ImageData readImageDataSerialized(final InputStream stream, + ImageServer server) throws FileNotFoundException, IOException { + return readImageDataSerialized(stream, server, null); + } + + @SuppressWarnings("unchecked") - private static ImageData readImageDataSerialized(final InputStream stream, ImageData imageData, - ServerBuilder requestedServerBuilder, Class cls) throws IOException { + private static ImageData readImageDataSerialized(final InputStream stream, ImageServer server, + ServerBuilder requestedServerBuilder) throws IOException { long startTime = System.currentTimeMillis(); Locale locale = Locale.getDefault(Category.FORMAT); @@ -358,7 +329,6 @@ private static ImageData readImageDataSerialized(final InputStream stream serverBuilder = extractServerBuilder(serverString, true); while (true) { - // logger.debug("Starting read: " + inStream.available()); try { // Try to read a relevant object from the stream Object input = inStream.readObject(); @@ -367,33 +337,31 @@ private static ImageData readImageDataSerialized(final InputStream stream // If we have a Locale, then set it if (input instanceof Locale) { if (input != locale) { - Locale.setDefault(Category.FORMAT, (Locale)input); + Locale.setDefault(Category.FORMAT, (Locale) input); localeChanged = true; } - } else if (input instanceof PathObjectHierarchy) - hierarchy = (PathObjectHierarchy)input; - else if (input instanceof ImageData.ImageType) - imageType = (ImageData.ImageType)input; - else if (input instanceof String && "EOF".equals(input)) { - // if (serverPath == null) // serverPath should be first string - // serverPath = (String)input; - // else if ("EOF".equals(input)) { + } else if (input instanceof PathObjectHierarchy readHierarchy) { + // Clumsy... but we need to ensure we have a fully-initialized hierarchy + // (which deserialization alone doesn't achieve) + hierarchy = new PathObjectHierarchy(); + hierarchy.setHierarchy(readHierarchy); + } else if (input instanceof ImageData.ImageType readImageType) { + imageType = readImageType; + } else if ("EOF".equals(input)) { break; - // } - } - else if (input instanceof ColorDeconvolutionStains) - stains = (ColorDeconvolutionStains)input; - else if (input instanceof Workflow) - workflow = (Workflow)input; - else if (input instanceof Map) - propertyMap = (Map)input; - else if (input == null) { + } else if (input instanceof ColorDeconvolutionStains readStains) { + stains = readStains; + } else if (input instanceof Workflow readWorkflow) { + workflow = readWorkflow; + } else if (input instanceof Map readPropertyMap) { + propertyMap = (Map)readPropertyMap; + } else if (input == null) { logger.debug("Null object will be skipped"); } else logger.warn("Unsupported object of class {} will be skipped: {}", input.getClass().getName(), input); } catch (ClassNotFoundException e) { - logger.error("Unable to find class: " + e.getLocalizedMessage(), e); + logger.error("Unable to find class: {}", e.getLocalizedMessage(), e); } catch (EOFException e) { // Try to recover from EOFExceptions - we may already have enough info logger.error("Reached end of file..."); @@ -408,25 +376,17 @@ else if (input == null) { } } - // Create an entirely new ImageData if necessary - var existingBuilder = imageData == null || imageData.getServer() == null ? null : imageData.getServer().getBuilder(); - if (imageData == null || !Objects.equals(serverBuilder, existingBuilder)) { - // Create a new server if we need to - // TODO: Make this less clumsy... but for now we need to ensure we have a fully-initialized hierarchy (which deserialization alone doesn't achieve) - PathObjectHierarchy hierarchy2 = new PathObjectHierarchy(); - hierarchy2.setHierarchy(hierarchy); - hierarchy = hierarchy2; + // Create a new ImageData + ImageData imageData; + if (server != null) + imageData = new ImageData<>(server, hierarchy, imageType); + else if (serverBuilder != null) imageData = new ImageData<>(serverBuilder, hierarchy, imageType); - } else { - if (imageType != null) - imageData.setImageType(imageType); - // Set the new hierarchy - if (hierarchy != null) - imageData.getHierarchy().setHierarchy(hierarchy); - } + else + throw new IOException("Can't read ImageData without a server or server builder"); + // Set the other properties we have just read if (workflow != null) { - imageData.getHistoryWorkflow().clear(); imageData.getHistoryWorkflow().addSteps(workflow.getSteps()); } if (stains != null) { @@ -438,107 +398,201 @@ else if (input == null) { } long endTime = System.currentTimeMillis(); + if (hierarchy != null) + logger.debug(String.format("Hierarchy with %d object(s) read in %.2f seconds", + hierarchy.nObjects(), + (endTime - startTime)/1000.)); - // if (hierarchy == null) { - // logger.error(String.format("%s does not contain a valid QUPath object hierarchy!", file.getAbsolutePath())); - // return null; - // } - logger.debug(String.format("Hierarchy with %d object(s) read in %.2f seconds", hierarchy.nObjects(), (endTime - startTime)/1000.)); - + return imageData; } catch (ClassNotFoundException e1) { - logger.warn("Class not found reading image data", e1); + logger.warn("Stream does not appear to be a valid .qpdata file", e1); + throw new IOException("Cannot read ImageData from stream", e1); } finally { if (localeChanged) Locale.setDefault(Category.FORMAT, locale); } - return imageData; } - - -// /** -// * Test if a specified file can be identified as a zip file. -// * -// * Zip 'magic number' contents are tested rather than file extension. -// * -// * @param file -// * @return -// */ -// public static boolean isZipFile(final File file) { -// if (!file.canRead() || file.length() < 4) -// return false; -// -// try (DataInputStream in = new DataInputStream(new BufferedInputStream(new FileInputStream(file)))) { -// int zipTest = in.readInt(); -// in.close(); -// return zipTest == 0x504b0304; -// } catch (IOException e) { -// return false; -// } -// } + + + /** + * Try to update an original ImageData object to match a new one. + * @param imageDataOriginal + * @param imageDataNew + * @return the updated original if they refer to the same image, or the new ImageData otherwise + * @param + */ + private static ImageData tryToUpdateImageData(ImageData imageDataOriginal, ImageData imageDataNew) { + if (Objects.equals(imageDataOriginal.getServerBuilder(), imageDataNew.getServerBuilder())) { + imageDataOriginal.setImageType(imageDataNew.getImageType()); + imageDataOriginal.getHierarchy().setHierarchy(imageDataNew.getHierarchy()); + imageDataOriginal.getHistoryWorkflow().clear(); + imageDataOriginal.getHistoryWorkflow().addSteps(imageDataNew.getHistoryWorkflow().getSteps()); + imageDataOriginal.setColorDeconvolutionStains(imageDataNew.getColorDeconvolutionStains()); + for (var entry : imageDataNew.getProperties().entrySet()) { + imageDataOriginal.setProperty(entry.getKey(), entry.getValue()); + } + imageDataOriginal.setLastSavedPath(imageDataNew.getLastSavedPath(), false); + return imageDataOriginal; + } else { + return imageDataNew; + } + } + + /** + * Legacy method to read an {@link ImageData} from a file. + *

    + * This previously tried to reuse an existing ImageData if it referred to the same ImageServer, but in practice + * it usually returned a new ImageData object instead. + * Therefore this method is deprecated, and {@link #readImageData(Path, ImageServer)} should be used instead. + * + * @param file the .qpdata file + * @param imageData optional existing ImageData, to update if possible + * @param server an ImageServer to use rather than any that might be stored within the serialized data. Should be null to use the serialized path to build a new server. + * The main purpose of this is to make it possible to open ImageData where the original image location has been moved, so the + * stored path is no longer accurate. + * @param cls the generic class for the ImageServer + * @return + * @throws IOException + * @deprecated since v0.6.0 + */ + @Deprecated + public static ImageData readImageData(final File file, ImageData imageData, ImageServer server, Class cls) throws IOException { + return readImageData(file.toPath(), imageData, server, cls); + } + + /** + * Legacy method to read an {@link ImageData} from a file. + *

    + * This previously tried to reuse an existing ImageData if it referred to the same ImageServer, but in practice + * it usually returned a new ImageData object instead. + * Therefore this method is deprecated, and {@link #readImageData(Path, ImageServer)} should be used instead. + * + * @param path the .qpdata file + * @param imageData optional existing ImageData, to update if possible + * @param server an ImageServer to use rather than any that might be stored within the serialized data. Should be null to use the serialized path to build a new server. + * The main purpose of this is to make it possible to open ImageData where the original image location has been moved, so the + * stored path is no longer accurate. + * @param cls the generic class for the ImageServer + * @return + * @throws IOException + * @deprecated since v0.6.0 + */ + @Deprecated + public static ImageData readImageData(final Path path, ImageData imageData, ImageServer server, Class cls) throws IOException { + LogTools.warnOnce(logger, "readImageData(Path, ImageData, ImageServer, Class) is deprecated and will be removed in a future version"); + var newImageData = readImageDataSerialized(path, server); + if (imageData != null) + return tryToUpdateImageData(imageData, newImageData); + else + return newImageData; + } /** - * Read ImageData from an InputStream into an existing ImageData object, or creating a new one if required. - * - * @param stream - * @param imageData + * Legacy method to read an {@link ImageData} from a stream. + *

    + * This previously tried to reuse an existing ImageData if it referred to the same ImageServer, but in practice + * it usually returned a new ImageData object instead. + * Therefore this method is deprecated, and {@link #readImageData(Path, ImageServer)} should be used instead. + * + * @param stream data stream containing the serialized ImageData + * @param imageData optional existing ImageData, to update if possible * @param server an ImageServer to use rather than any that might be stored within the serialized data. Should be null to use the serialized path to build a new server. * The main purpose of this is to make it possible to open ImageData where the original image location has been moved, so the * stored path is no longer accurate. - * @param cls + * @param cls the generic class for the ImageServer * @return * @throws IOException + * @deprecated since v0.6.0 */ + @Deprecated public static ImageData readImageData(final InputStream stream, ImageData imageData, ImageServer server, Class cls) throws IOException { - return readImageDataSerialized(stream, imageData, server, cls); + LogTools.warnOnce(logger, "readImageData(InputStream, ImageData, ImageServer, Class) is deprecated and will be removed in a future version"); + var newImageData = readImageDataSerialized(stream, server); + if (imageData != null) + return tryToUpdateImageData(imageData, newImageData); + else + return newImageData; } + /** + * Read an ImageData, optionally providing a serverBuilder for lazy image loading. + * @param path the .qpdata file to read + * @param serverBuilder the server builder to use, or null to use the one stored in the file + * @return + * @throws IOException + */ + public static ImageData readImageData(final Path path, ServerBuilder serverBuilder) throws IOException { + try (var stream = new BufferedInputStream(Files.newInputStream(path))) { + var imageData = readImageDataSerialized(stream, null, serverBuilder); + // Set the last saved path (actually the path from which this was opened) + imageData.setLastSavedPath(path.toAbsolutePath().toString(), true); + return imageData; + } + } /** - * Read an ImageData with lazy image loading. - * @param stream - * @param serverBuilder - * @param cls + * Read an ImageData, optionally providing a serverBuilder for lazy image loading. + * @param stream the .qpdata file to read + * @param serverBuilder the server builder to use, or null to use the one stored in the file * @return - * @param * @throws IOException */ - public static ImageData readLazyImageData(final InputStream stream, ServerBuilder serverBuilder, Class cls) throws IOException { - return readImageDataSerialized(stream, null, serverBuilder, cls); + public static ImageData readImageData(final InputStream stream, ServerBuilder serverBuilder) throws IOException { + return readImageDataSerialized(stream, null, serverBuilder); + } + + + /** + * Read and initialize an {@link ImageData} from a data file. + * @param file the .qpdata file to read + * @return + * @throws IOException + */ + public static ImageData readImageData(final File file) throws IOException { + return readImageData(file.toPath()); + } + + /** + * Read and initialize an {@link ImageData} from a data file. + * @param path the .qpdata file to read + * @return + * @throws IOException + */ + public static ImageData readImageData(final Path path) throws IOException { + return readImageDataSerialized(path, null); } /** - * Read ImageData from a File into an existing ImageData object, or create a new one if required. - * @param - * - * @param file - * @param imageData + * Read and initialize an {@link ImageData} from a data file. + * + * @param file the .qpdata file to read * @param server an ImageServer to use rather than any that might be stored within the serialized data. Should be null to use the serialized path to build a new server. * The main purpose of this is to make it possible to open ImageData where the original image location has been moved, so the * stored path is no longer accurate. - * @param cls * @return - * @throws IOException + * @throws IOException + * @param generic parameter for the {@link ImageServer}, usually BufferedImage */ - public static ImageData readImageData(final File file, ImageData imageData, ImageServer server, Class cls) throws IOException { - return readImageData(file.toPath(), imageData, server, cls); + public static ImageData readImageData(final File file, ImageServer server) throws IOException { + return readImageData(file.toPath(), server); } /** - * Read {@link ImageData} from a File into an existing ImageData object, or create a new one if required. - * @param + * Read and initialize an {@link ImageData} from a data file. + * @param the generic parameter, usually BufferedImage * - * @param path - * @param imageData - * @param server an ImageServer to use rather than any that might be stored within the serialized data. Should be null to use the serialized path to build a new server. - * The main purpose of this is to make it possible to open ImageData where the original image location has been moved, so the - * stored path is no longer accurate. - * @param cls + * @param path path to data file + * @param server an ImageServer to use rather than any that might be stored within the serialized data. + * Should be null to use the serialized path to build a new server. + * The main purpose of this is to make it possible to open ImageData where the original image location has been moved, so the + * stored path is no longer accurate. * @return - * @throws IOException + * @throws IOException + * @param generic parameter for the {@link ImageServer}, usually BufferedImage */ - public static ImageData readImageData(final Path path, ImageData imageData, ImageServer server, Class cls) throws IOException { - return readImageDataSerialized(path, imageData, server, cls); + public static ImageData readImageData(final Path path, ImageServer server) throws IOException { + return readImageDataSerialized(path, server); } /** @@ -606,21 +660,6 @@ private static void writeImageDataSerialized(final OutputStream stream, final Im // Write the identifier outStream.writeUTF("Data file version " + DATA_FILE_VERSION); - // Try to write a backwards-compatible image path -// var server = imageData.getServer(); -// var uris = server.getURIs(); -// String path; -// if (uris.size() == 1) { -// var uri = uris.iterator().next(); -// var serverPath = GeneralTools.toPath(uri); -// if (serverPath != null && Files.exists(serverPath)) -// path = serverPath.toFile().getAbsolutePath(); -// else -// path = uri.toString(); -// } else -// path = server.getPath(); -// outStream.writeObject("Image path: " + path); - // Write JSON object including QuPath version and ServerBuilder // Note that the builder may be null, in which case the server cannot be recreated var builder = imageData.getServerBuilder(); @@ -676,7 +715,7 @@ private static void writeImageDataSerialized(final OutputStream stream, final Im * @throws IOException * @throws FileNotFoundException */ - public static PathObjectHierarchy readHierarchy(final File file) throws FileNotFoundException, IOException { + public static PathObjectHierarchy readHierarchy(final File file) throws IOException { return readHierarchy(file.toPath()); } @@ -686,15 +725,11 @@ public static PathObjectHierarchy readHierarchy(final File file) throws FileNotF * @param path * @return * @throws IOException - * @throws FileNotFoundException */ - public static PathObjectHierarchy readHierarchy(final Path path) throws FileNotFoundException, IOException { - logger.info("Reading hierarchy from {}...", path.getFileName().toString()); + public static PathObjectHierarchy readHierarchy(final Path path) throws IOException { + logger.info("Reading hierarchy from {}", path.getFileName().toString()); try (var stream = Files.newInputStream(path)) { - var hierarchy = readHierarchy(stream); - if (hierarchy == null) - logger.error("Unable to find object hierarchy in " + path); - return hierarchy; + return readHierarchy(stream); } } @@ -728,12 +763,13 @@ public static PathObjectHierarchy readHierarchy(final InputStream fileIn) throws Locale.setDefault(Category.FORMAT, (Locale)input); localeChanged = true; } - } else if (input instanceof PathObjectHierarchy) { + } else if (input instanceof PathObjectHierarchy newHierarchy) { /* This would ideally be unnecessary, but it's needed to ensure that the PathObjectHierarchy - * has been property initialized. We can't count on the deserialized hierarchy being immediately functional. + * has been property initialized. + * We can't count on the deserialized hierarchy being immediately functional. */ PathObjectHierarchy hierarchy = new PathObjectHierarchy(); - hierarchy.setHierarchy((PathObjectHierarchy)input); + hierarchy.setHierarchy(newHierarchy); return hierarchy; } @@ -802,7 +838,7 @@ public static List readObjects(Path path) throws IOException { return allObjects; } } - + try (var stream = new BufferedInputStream(Files.newInputStream(path))) { InputStream stream2; @@ -821,7 +857,7 @@ public static List readObjects(Path path) throws IOException { } } - logger.debug("Unable to read objects from {}", path.toString()); + logger.debug("Unable to read objects from {}", path); return Collections.emptyList(); } @@ -854,11 +890,11 @@ public static List readObjectsFromGeoJSON(InputStream stream) throws } - private static String EXT_ZIP = ".zip"; - private static String EXT_GZIP = ".gz"; - private static String EXT_JSON = ".json"; - private static String EXT_GEOJSON = ".geojson"; - private static String EXT_DATA = ".qpdata"; + private static final String EXT_ZIP = ".zip"; + private static final String EXT_GZIP = ".gz"; + private static final String EXT_JSON = ".json"; + private static final String EXT_GEOJSON = ".geojson"; + private static final String EXT_DATA = ".qpdata"; /** * Get a list of known file extensions that may contain objects, optionally including compressed files. @@ -888,7 +924,7 @@ public static List getObjectFileExtensions() { /** * Options to customize the export of PathObjects as GeoJSON. */ - public static enum GeoJsonExportOptions { + public enum GeoJsonExportOptions { /** * Request pretty-printing for the JSON. This is more readable, but results in larger files. */ @@ -1017,7 +1053,7 @@ public static Set unzippedExtensions(Path path, String... zipExtensions) } } - private static final boolean serializableObject(Object obj) { + private static boolean serializableObject(Object obj) { if (obj == null) return true; if (obj instanceof Serializable) @@ -1030,7 +1066,7 @@ private static final boolean serializableObject(Object obj) { * @param serialClass * @return */ - private static final boolean checkQuPathSerializableClass(Class serialClass) { + private static boolean checkQuPathSerializableClass(Class serialClass) { if (serialClass == null) return true; @@ -1060,18 +1096,18 @@ private static final boolean checkQuPathSerializableClass(Class serialClass) * @param serialClass * @return */ - private static final boolean checkClassLoader(Class serialClass) { + private static boolean checkClassLoader(Class serialClass) { if (serialClass == null) return true; var classloader = serialClass.getClassLoader(); return classloader == null || classloader == ClassLoader.getPlatformClassLoader() || classloader == ClassLoader.getSystemClassLoader(); } - private static final Status classLoaderInputFilter(FilterInfo filterInfo) { + private static Status classLoaderInputFilter(FilterInfo filterInfo) { return checkClassLoader(filterInfo.serialClass()) ? Status.ALLOWED : Status.REJECTED; } - private static final Status qupathInputFilter(FilterInfo filterInfo) { + private static Status qupathInputFilter(FilterInfo filterInfo) { return checkQuPathSerializableClass(filterInfo.serialClass()) ? Status.ALLOWED : Status.REJECTED; } diff --git a/qupath-core/src/main/java/qupath/lib/projects/DefaultProject.java b/qupath-core/src/main/java/qupath/lib/projects/DefaultProject.java index e533b4e4c..890e4f4bb 100644 --- a/qupath-core/src/main/java/qupath/lib/projects/DefaultProject.java +++ b/qupath-core/src/main/java/qupath/lib/projects/DefaultProject.java @@ -697,11 +697,10 @@ public synchronized ImageData readImageData() throws IOException ImageData imageData = null; // TODO: Consider whether we can set the image name for the lazy-loaded server if (Files.exists(path)) { - try (var stream = Files.newInputStream(path)) { - imageData = PathIO.readLazyImageData(stream, getServerBuilder(), BufferedImage.class); - imageData.setLastSavedPath(path.toString(), true); + try { + imageData = PathIO.readImageData(path, getServerBuilder()); } catch (Exception e) { - logger.error("Error reading image data from " + path, e); + logger.error("Error reading image data from {}", path, e); } } // If we find a backup file, try to restore what we can from it @@ -709,12 +708,11 @@ public synchronized ImageData readImageData() throws IOException if (imageData == null) { var pathBackup = getBackupImageDataPath(); if (Files.exists(pathBackup)) { - try (var stream = Files.newInputStream(pathBackup)) { - imageData = PathIO.readLazyImageData(stream, getServerBuilder(), BufferedImage.class); - imageData.setLastSavedPath(pathBackup.toString(), true); + try { + imageData = PathIO.readImageData(pathBackup, getServerBuilder()); logger.warn("Restored previous ImageData from {}", pathBackup); } catch (IOException e) { - logger.error("Error reading backup image data from " + pathBackup, e); + logger.error("Error reading backup image data from {}", pathBackup, e); } } } diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/QuPathGUI.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/QuPathGUI.java index 0ad90a5ed..f9e47daca 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/QuPathGUI.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/QuPathGUI.java @@ -1374,7 +1374,7 @@ public boolean openSavedData(QuPathViewer viewer, final File file, final boolean try { serverBuilder = PathIO.extractServerBuilder(file.toPath()); } catch (Exception e) { - logger.warn("Unable to read server path from file: {}", e.getLocalizedMessage()); + logger.warn("Unable to read image server from file: {}", e.getLocalizedMessage()); } var existingBuilder = imageData == null || imageData.getServer() == null ? null : imageData.getServer().getBuilder(); boolean sameServer = Objects.equals(existingBuilder, serverBuilder); @@ -1417,12 +1417,12 @@ public boolean openSavedData(QuPathViewer viewer, final File file, final boolean try { server = serverBuilder.build(); } catch (Exception e) { - logger.error("Unable to build server " + serverBuilder, e); + logger.error("Unable to build server {}", serverBuilder, e); } } if (server == null) return false; - // + // Small optimization... put in a thumbnail request early in a background thread. // This way that it will be fetched while the image data is being read - // generally leading to improved performance in the viewer's setImageData method @@ -1438,10 +1438,8 @@ public boolean openSavedData(QuPathViewer viewer, final File file, final boolean } try { - ImageData imageData2 = PathIO.readImageData(file, imageData, server, BufferedImage.class); - if (imageData2 != imageData) { - viewer.setImageData(imageData2); - } + ImageData imageData2 = PathIO.readImageData(file, server); + viewer.setImageData(imageData2); } catch (IOException e) { Dialogs.showErrorMessage("Read image data", "Error reading image data\n" + e.getLocalizedMessage()); logger.error(e.getMessage(), e); diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/Commands.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/Commands.java index 283ab58d7..67b4ee4f8 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/Commands.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/Commands.java @@ -1619,7 +1619,7 @@ public static void reloadImageData(QuPathGUI qupath, ImageData im imageDataNew = entry.readImageData(); } else { logger.info("Reverting to last saved version: {}", savedFile.getAbsolutePath()); - imageDataNew = PathIO.readImageData(savedFile, null, imageData.getServer(), BufferedImage.class); + imageDataNew = PathIO.readImageData(savedFile, imageData.getServer()); } viewer.setImageData(imageDataNew); } catch (Exception e) { diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/ProjectCommands.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/ProjectCommands.java index 2b2bcd63e..a63691a16 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/ProjectCommands.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/ProjectCommands.java @@ -47,6 +47,7 @@ import qupath.fx.dialogs.Dialogs; import qupath.lib.gui.prefs.PathPrefs; import qupath.lib.gui.tools.GuiTools; +import qupath.lib.images.ImageData; import qupath.lib.images.ImageData.ImageType; import qupath.lib.images.servers.ImageServer; import qupath.lib.images.servers.ImageServerBuilder; @@ -317,7 +318,7 @@ protected Integer call() throws Exception { // Save the data if needed if (fileData.exists()) { logger.debug("Reading image data found for {}", name); - var imageData = PathIO.readImageData(fileData, null, server, BufferedImage.class); + ImageData imageData = PathIO.readImageData(fileData, server); entry.saveImageData(imageData); } else { logger.warn("No image data found for {}", name); diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/ProjectImportImagesCommand.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/ProjectImportImagesCommand.java index 3ba0c2f72..b9ca129c1 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/ProjectImportImagesCommand.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/ProjectImportImagesCommand.java @@ -417,7 +417,7 @@ protected List> call() throws Exception { updateMessage("Importing " + existingDataFiles.size() + " images from existing data files"); for (var file : existingDataFiles) { try { - var imageData = PathIO.readImageData(file, null, null, BufferedImage.class); + ImageData imageData = PathIO.readImageData(file); var entry = project.addImage(imageData.getServer().getBuilder()); initializeEntry(entry, imageData.getImageType(), false, false); entry.saveImageData(imageData); diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/tma/TMASummaryViewer.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/tma/TMASummaryViewer.java index 80d6d1100..d7240e399 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/tma/TMASummaryViewer.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/tma/TMASummaryViewer.java @@ -1242,7 +1242,7 @@ public void setInputFile(File file) { if (file.getName().toLowerCase().endsWith(PathPrefs.getSerializationExtension())) { try { - ImageData imageData = PathIO.readImageData(file, null, null, BufferedImage.class); + ImageData imageData = PathIO.readImageData(file); setTMAEntriesFromImageData(imageData); } catch (IOException e) { logger.error("Error reading image data", e); From 3a2e9deb6f8bde7f8de4721f44bad4613a970dba Mon Sep 17 00:00:00 2001 From: Pete Date: Mon, 12 Aug 2024 10:03:08 +0100 Subject: [PATCH 186/866] Update CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d661726f..fc794d9f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ This is a *work in progress* for the next major release. * Initial core support for stain normalization and background subtraction (https://github.com/qupath/qupath/pull/1554) * Experimental - not yet a full feature or available through the user interface! * Add `TransformedServerBuilder.convertType(PixelType)` to convert pixel types +* Right-click on 'Measurement map' colorbar to copy it to the system clipboard (https://github.com/qupath/qupath/pull/1583) ### Bugs fixed * Tile export to .ome.tif can convert to 8-bit unnecessarily (https://github.com/qupath/qupath/issues/1494) From fd1c747eef53c300f778277615f754649cedc2ac Mon Sep 17 00:00:00 2001 From: lleplat Date: Mon, 12 Aug 2024 15:23:43 +0100 Subject: [PATCH 187/866] Added metadata to projects --- .../qupath/lib/projects/DefaultProject.java | 48 ++++++++++++++++++- .../java/qupath/lib/projects/Project.java | 47 +++++++++--------- .../qupath/lib/gui/panes/ProjectBrowser.java | 28 ++++------- 3 files changed, 80 insertions(+), 43 deletions(-) diff --git a/qupath-core/src/main/java/qupath/lib/projects/DefaultProject.java b/qupath-core/src/main/java/qupath/lib/projects/DefaultProject.java index e533b4e4c..ac2d62272 100644 --- a/qupath-core/src/main/java/qupath/lib/projects/DefaultProject.java +++ b/qupath-core/src/main/java/qupath/lib/projects/DefaultProject.java @@ -28,6 +28,7 @@ import java.io.BufferedReader; import java.io.File; import java.io.IOException; +import java.io.Reader; import java.lang.ref.SoftReference; import java.net.URI; import java.net.URISyntaxException; @@ -40,9 +41,11 @@ import java.util.Arrays; import java.util.Collection; import java.util.Collections; +import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.UUID; import java.util.Map.Entry; import java.util.Objects; @@ -1233,7 +1236,6 @@ public Manager getResources(String location, Class cls, S return null; } - @Override public Project createSubProject(String name, Collection> entries) { if (!name.endsWith(ext)) { @@ -1249,5 +1251,47 @@ public Project createSubProject(String name, Collection metadata = getStoredMetadata(); + metadata.put(key, value); + setStoredMetadata(metadata); + } + + @Override + public Optional getMetadataValue(String key) { + return Optional.ofNullable(getStoredMetadata().get(key)); + } + + @Override + public void removeMetadataValue(String key) { + Map metadata = getStoredMetadata(); + metadata.remove(key); + setStoredMetadata(metadata); + } + + private Map getStoredMetadata() { + if (getMetadataPath().toFile().exists()) { + try (Reader reader = Files.newBufferedReader(getMetadataPath(), StandardCharsets.UTF_8)) { + return new Gson().fromJson(reader, new TypeToken>(){}.getType()); + } catch (IOException e) { + logger.error("Error while retrieving project metadata", e); + } + } + + return new HashMap<>(); + } + + private void setStoredMetadata(Map metadata) { + try (var writer = Files.newBufferedWriter(getMetadataPath(), StandardCharsets.UTF_8)) { + new Gson().toJson(metadata, writer); + } catch (IOException e) { + logger.error("Error while saving project metadata", e); + } + } + + private Path getMetadataPath() { + return Paths.get(getBasePath().toString(), "metadata.json"); + } } diff --git a/qupath-core/src/main/java/qupath/lib/projects/Project.java b/qupath-core/src/main/java/qupath/lib/projects/Project.java index a918bc46a..035c3b2af 100644 --- a/qupath-core/src/main/java/qupath/lib/projects/Project.java +++ b/qupath-core/src/main/java/qupath/lib/projects/Project.java @@ -28,6 +28,7 @@ import java.nio.file.Path; import java.util.Collection; import java.util.List; +import java.util.Optional; import qupath.lib.classifiers.object.ObjectClassifier; import qupath.lib.classifiers.pixel.PixelClassifier; @@ -268,20 +269,6 @@ public default Manager> getObjectClassifiers() { public default Manager getPixelClassifiers() { return getResources(PixelClassifier.PROJECT_LOCATION, PixelClassifier.class, "json"); } - - /** - * Get the manager for sorting keys saved within this project. - *

    - * Sorting keys can be used to group and sort project entries. - *

    - * The names of this manager correspond to sorting keys, while their values are string representations - * of booleans corresponding to whether the key is in ascending or descending order. - * - * @return the manager for sorting keys, or {@code null} if the project does not support storing sorting keys - */ - default Manager getSortingKeys() { - return getResources("sorting_keys", String.class, "txt"); - } /** * Get a manager for objects of a specified class within this project. @@ -297,13 +284,29 @@ default Manager getSortingKeys() { public default Manager getResources(String location, Class cls, String ext) { return null; } - - -// public List listPixelClassifiers(); -// -// public PixelClassifier loadPixelClassifier(String name); -// -// public void savePixelClassifier(String name, String PixelClassifier); - + /** + * Store a metadata key-value pair in this project. + * If a value already exists for the provided key, it will be overridden. + * + * @param key the key to store + * @param value the value to store + */ + void putMetadataValue(String key, String value); + + /** + * Request a metadata value stored in this project. + * + * @param key the key associated with the value to retrieve + * @return the value associated with the provided key, or an empty Optional if no such value exists + */ + Optional getMetadataValue(String key); + + /** + * Remove a metadata key-value pair from this project. + * This function does nothing if the provided key is not present in this project. + * + * @param key the key associated with the value to remove + */ + void removeMetadataValue(String key); } diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/panes/ProjectBrowser.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/panes/ProjectBrowser.java index 6d8bc6657..0053d3cf2 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/panes/ProjectBrowser.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/panes/ProjectBrowser.java @@ -1166,19 +1166,16 @@ private List getAllImageRows() { } private class ProjectImageTreeModel { - - private ProjectTreeRowItem root; + + private static final String SORTING_KEY = "sortingKey"; + private final ProjectTreeRowItem root; private String metadataKey; private ProjectImageTreeModel(final Project project) { this.root = new ProjectTreeRowItem(new ProjectTreeRow.RootRow(project)); - try { - if (project != null && project.getSortingKeys() != null && !project.getSortingKeys().getNames().isEmpty()) { - this.metadataKey = project.getSortingKeys().getNames().iterator().next(); - } - } catch (IOException e) { - logger.warn("Error while getting metadata key", e); + if (project != null) { + this.metadataKey = project.getMetadataValue(SORTING_KEY).orElse(null); } } @@ -1193,17 +1190,10 @@ private String getMetadataKey() { private void setMetadataKey(String metadataKey) { this.metadataKey = metadataKey; - try { - if (project != null && project.getSortingKeys() != null) { - for (String keyName: project.getSortingKeys().getNames()) { - project.getSortingKeys().remove(keyName); - } - if (metadataKey != null) { - project.getSortingKeys().put(metadataKey, String.valueOf(true)); - } - } - } catch (IOException e) { - logger.warn("Error while setting new metadata key", e); + if (metadataKey == null) { + project.removeMetadataValue(SORTING_KEY); + } else { + project.putMetadataValue(SORTING_KEY, metadataKey); } } From 6d5625b4d491bed2971ce01052f7e51dbe76e4a9 Mon Sep 17 00:00:00 2001 From: Leo Leplat <60394504+Rylern@users.noreply.github.com> Date: Tue, 13 Aug 2024 10:41:09 +0100 Subject: [PATCH 188/866] Updated MetadataStore --- .../qupath/lib/io/QuPathTypeAdapters.java | 5 +- .../qupath/lib/objects/MetadataStore.java | 46 +++++++++++++------ .../qupath/lib/objects/PathObjectTools.java | 2 +- .../qupath/lib/objects/TMACoreObject.java | 2 +- .../qupath/lib/gui/commands/TMACommands.java | 2 +- .../lib/gui/commands/TMADataImporter.java | 5 +- 6 files changed, 40 insertions(+), 22 deletions(-) diff --git a/qupath-core/src/main/java/qupath/lib/io/QuPathTypeAdapters.java b/qupath-core/src/main/java/qupath/lib/io/QuPathTypeAdapters.java index f5c936f7c..91aec054f 100644 --- a/qupath-core/src/main/java/qupath/lib/io/QuPathTypeAdapters.java +++ b/qupath-core/src/main/java/qupath/lib/io/QuPathTypeAdapters.java @@ -408,8 +408,7 @@ public void write(JsonWriter out, PathObject value) throws IOException { out.value(measurements.getMeasurementValue(i)); } } - if (value instanceof MetadataStore) { - MetadataStore store = (MetadataStore)value; + if (value instanceof MetadataStore store) { Set keys = store.getMetadataKeys(); if (!keys.isEmpty()) { out.name("Metadata count"); @@ -428,7 +427,7 @@ public void write(JsonWriter out, PathObject value) throws IOException { if (value instanceof MetadataStore) { MetadataStore store = (MetadataStore)value; - Map map = store.getMetadataMap(); + Map map = store.getMetadata(); if (!map.isEmpty()) { out.name("metadata"); gson.toJson(map, Map.class, out); diff --git a/qupath-core/src/main/java/qupath/lib/objects/MetadataStore.java b/qupath-core/src/main/java/qupath/lib/objects/MetadataStore.java index c64c974f1..31af904b4 100644 --- a/qupath-core/src/main/java/qupath/lib/objects/MetadataStore.java +++ b/qupath-core/src/main/java/qupath/lib/objects/MetadataStore.java @@ -27,50 +27,70 @@ import java.util.Set; /** - * Interface that may be used to indicate that a {@link PathObject} (or other object) can store metadata. + * Interface indicating that an object can store metadata. *

    * Implementing classes should ensure that entries are stored in insertion order. * * @author Pete Bankhead - * */ public interface MetadataStore { + + /** + * Returns a modifiable map containing the metadata. + * + * @return the metadata of this store + */ + Map getMetadata(); /** * Store a new metadata value. + * * @param key * @param value * @return + * @deprecated as of v0.6.0. Use {@link #getMetadata()} with the {@link Map#put(Object, Object)} + * method instead */ - public Object putMetadataValue(final String key, final String value); + @Deprecated + default Object putMetadataValue(String key, String value) { + return getMetadata().put(key, value); + } /** * Get a metadata value, cast as a String if possible. * @param key * @return + * @deprecated as of v0.6.0. Use {@link #getMetadata()} with the {@link Map#get(Object)} + * method instead */ - public String getMetadataString(final String key); + @Deprecated + default String getMetadataString(String key) { + return getMetadata().get(key); + } /** * Get a metadata value of any kind. * * @param key * @return + * @deprecated as of v0.6.0. Use {@link #getMetadata()} with the {@link Map#get(Object)} + * method instead */ - public Object getMetadataValue(final String key); + @Deprecated + default Object getMetadataValue(String key) { + return getMetadata().get(key); + } /** * Get all metadata keys. * * @return + * @deprecated as of v0.6.0. Use {@link #getMetadata()} with the {@link Map#keySet()} + * method instead */ - public Set getMetadataKeys(); - - /** - * Returns an unmodifiable map containing the metadata. - * - * @return - */ - public Map getMetadataMap(); + @Deprecated + default Set getMetadataKeys() { + return getMetadata().keySet(); + } } diff --git a/qupath-core/src/main/java/qupath/lib/objects/PathObjectTools.java b/qupath-core/src/main/java/qupath/lib/objects/PathObjectTools.java index 3c84e4804..4ca2d9e51 100644 --- a/qupath-core/src/main/java/qupath/lib/objects/PathObjectTools.java +++ b/qupath-core/src/main/java/qupath/lib/objects/PathObjectTools.java @@ -1525,7 +1525,7 @@ private static PathObject transformObjectImpl(PathObject pathObject, Function getMetadataKeys() { } @Override - public Map getMetadataMap() { + public Map getMetadata() { return super.getUnmodifiableMetadataMap(); } diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/TMACommands.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/TMACommands.java index bb6a817c3..73532c94b 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/TMACommands.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/TMACommands.java @@ -107,7 +107,7 @@ public static void promptToAddNoteToSelectedCores(ImageData imageData) { if (selectedCores.size() == 1) { var core = selectedCores.get(0); prompt = core.getName() == null || core.getName().trim().isEmpty() ? "Core" : core.getName(); - currentText = core.getMetadataMap().get(NOTE_NAME); + currentText = core.getMetadata().get(NOTE_NAME); } else { prompt = selectedCores.size() + " cores"; } diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/TMADataImporter.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/TMADataImporter.java index 29b0b7435..681d6bf01 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/TMADataImporter.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/TMADataImporter.java @@ -59,7 +59,6 @@ import qupath.lib.common.GeneralTools; import qupath.lib.gui.QuPathGUI; import qupath.fx.dialogs.Dialogs; -import qupath.lib.gui.prefs.PathPrefs; import qupath.lib.gui.tools.ColorToolsFX; import qupath.fx.utils.GridPaneUtils; import qupath.lib.gui.tools.GuiTools; @@ -547,7 +546,7 @@ private void synchronizeCoreToFields() { core.getMeasurementList().put(name, getMeasurementList().get(name)); } core.getMeasurementList().close(); - for (Entry entry : getMetadataMap().entrySet()) { + for (Entry entry : this.getMetadata().entrySet()) { core.putMetadataValue(entry.getKey(), (String)entry.getValue()); } } @@ -591,7 +590,7 @@ static String getExtendedDescription(final TMACoreObject core) { // else // sb.append("-"); sb.append("\n"); - for (Entry entry : core.getMetadataMap().entrySet()) { + for (Entry entry : core.getMetadata().entrySet()) { sb.append(entry.getKey()).append("\t").append(entry.getValue()).append("\n"); } for (String name : core.getMeasurementList().getMeasurementNames()) { From 5c0edc5f1ec181544b81c6835f5e05c38bd1d9a4 Mon Sep 17 00:00:00 2001 From: Leo Leplat <60394504+Rylern@users.noreply.github.com> Date: Tue, 13 Aug 2024 11:42:10 +0100 Subject: [PATCH 189/866] Make Project implements MetadataStore --- .../qupath/lib/objects/MetadataStore.java | 3 + .../qupath/lib/projects/DefaultProject.java | 63 ++++++++++--------- .../java/qupath/lib/projects/Project.java | 29 +-------- .../qupath/lib/gui/panes/ProjectBrowser.java | 6 +- 4 files changed, 42 insertions(+), 59 deletions(-) diff --git a/qupath-core/src/main/java/qupath/lib/objects/MetadataStore.java b/qupath-core/src/main/java/qupath/lib/objects/MetadataStore.java index 31af904b4..a2d208d94 100644 --- a/qupath-core/src/main/java/qupath/lib/objects/MetadataStore.java +++ b/qupath-core/src/main/java/qupath/lib/objects/MetadataStore.java @@ -37,6 +37,9 @@ public interface MetadataStore { /** * Returns a modifiable map containing the metadata. + *

    + * The returned map may or may not be thread-safe. Implementing classes must + * document the thread-safeness of the map. * * @return the metadata of this store */ diff --git a/qupath-core/src/main/java/qupath/lib/projects/DefaultProject.java b/qupath-core/src/main/java/qupath/lib/projects/DefaultProject.java index ac2d62272..39cebce79 100644 --- a/qupath-core/src/main/java/qupath/lib/projects/DefaultProject.java +++ b/qupath-core/src/main/java/qupath/lib/projects/DefaultProject.java @@ -29,6 +29,7 @@ import java.io.File; import java.io.IOException; import java.io.Reader; +import java.io.Writer; import java.lang.ref.SoftReference; import java.net.URI; import java.net.URISyntaxException; @@ -41,11 +42,9 @@ import java.util.Arrays; import java.util.Collection; import java.util.Collections; -import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; -import java.util.Optional; import java.util.UUID; import java.util.Map.Entry; import java.util.Objects; @@ -92,13 +91,14 @@ class DefaultProject implements Project { private static Logger logger = LoggerFactory.getLogger(DefaultProject.class); private final String LATEST_VERSION = GeneralTools.getVersion(); + private final Map metadata; private String version = null; /** * Base directory. */ - private File dirBase; + private final File dirBase; /** * Project file. @@ -136,6 +136,7 @@ class DefaultProject implements Project { this.dirBase = file.getParentFile(); creationTimestamp = System.currentTimeMillis(); modificationTimestamp = System.currentTimeMillis(); + this.metadata = Collections.synchronizedMap(getStoredMetadata(this.dirBase.toPath())); } @Override @@ -342,6 +343,7 @@ public void removeAllImages(final Collection> e public synchronized void syncChanges() throws IOException { writeProject(getFile()); writePathClasses(pathClasses); + setStoredMetadata(getBasePath(), metadata); // if (file.isDirectory()) // file = new File(dirBase, "project.qpproj"); // var json = new GsonBuilder().setLenient().setPrettyPrinting().create().toJson(this); @@ -1252,46 +1254,49 @@ public Project createSubProject(String name, Collection + * Modifications to this map are saved when calling {@link #syncChanges()}. + * + * @return the metadata of this project + */ @Override - public void putMetadataValue(String key, String value) { - Map metadata = getStoredMetadata(); - metadata.put(key, value); - setStoredMetadata(metadata); - } - - @Override - public Optional getMetadataValue(String key) { - return Optional.ofNullable(getStoredMetadata().get(key)); + public Map getMetadata() { + return metadata; } - @Override - public void removeMetadataValue(String key) { - Map metadata = getStoredMetadata(); - metadata.remove(key); - setStoredMetadata(metadata); - } + private static Map getStoredMetadata(Path projectPath) { + Map metadata = new LinkedHashMap<>(); - private Map getStoredMetadata() { - if (getMetadataPath().toFile().exists()) { - try (Reader reader = Files.newBufferedReader(getMetadataPath(), StandardCharsets.UTF_8)) { - return new Gson().fromJson(reader, new TypeToken>(){}.getType()); + Path metadataPath = getMetadataPath(projectPath); + if (Files.exists(metadataPath)) { + try (Reader reader = Files.newBufferedReader(metadataPath, StandardCharsets.UTF_8)) { + metadata.putAll(GsonTools.getInstance().fromJson(reader, new TypeToken>(){}.getType())); } catch (IOException e) { logger.error("Error while retrieving project metadata", e); } } - return new HashMap<>(); + return metadata; } - private void setStoredMetadata(Map metadata) { - try (var writer = Files.newBufferedWriter(getMetadataPath(), StandardCharsets.UTF_8)) { - new Gson().toJson(metadata, writer); + private static void setStoredMetadata(Path projectPath, Map metadata) { + Path metadataPath = getMetadataPath(projectPath); + + try { + Files.createDirectories(metadataPath.getParent()); + + try (Writer writer = Files.newBufferedWriter(metadataPath, StandardCharsets.UTF_8)) { + GsonTools.getInstance().toJson(metadata, writer); + } } catch (IOException e) { - logger.error("Error while saving project metadata", e); + logger.error("Error while creating project data directory", e); } } - private Path getMetadataPath() { - return Paths.get(getBasePath().toString(), "metadata.json"); + private static Path getMetadataPath(Path projectPath) { + return Paths.get(projectPath.toString(), "data", "metadata.json"); } } diff --git a/qupath-core/src/main/java/qupath/lib/projects/Project.java b/qupath-core/src/main/java/qupath/lib/projects/Project.java index 035c3b2af..8f54c635b 100644 --- a/qupath-core/src/main/java/qupath/lib/projects/Project.java +++ b/qupath-core/src/main/java/qupath/lib/projects/Project.java @@ -28,12 +28,12 @@ import java.nio.file.Path; import java.util.Collection; import java.util.List; -import java.util.Optional; import qupath.lib.classifiers.object.ObjectClassifier; import qupath.lib.classifiers.pixel.PixelClassifier; import qupath.lib.images.ImageData; import qupath.lib.images.servers.ImageServerBuilder.ServerBuilder; +import qupath.lib.objects.MetadataStore; import qupath.lib.objects.classes.PathClass; import qupath.lib.projects.ResourceManager.Manager; @@ -44,7 +44,7 @@ * * @param */ -public interface Project { +public interface Project extends MetadataStore { /** * Get an unmodifiable list representing the PathClasses associated with this project. @@ -284,29 +284,4 @@ public default Manager getPixelClassifiers() { public default Manager getResources(String location, Class cls, String ext) { return null; } - - /** - * Store a metadata key-value pair in this project. - * If a value already exists for the provided key, it will be overridden. - * - * @param key the key to store - * @param value the value to store - */ - void putMetadataValue(String key, String value); - - /** - * Request a metadata value stored in this project. - * - * @param key the key associated with the value to retrieve - * @return the value associated with the provided key, or an empty Optional if no such value exists - */ - Optional getMetadataValue(String key); - - /** - * Remove a metadata key-value pair from this project. - * This function does nothing if the provided key is not present in this project. - * - * @param key the key associated with the value to remove - */ - void removeMetadataValue(String key); } diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/panes/ProjectBrowser.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/panes/ProjectBrowser.java index 0053d3cf2..15208f3e6 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/panes/ProjectBrowser.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/panes/ProjectBrowser.java @@ -1175,7 +1175,7 @@ private ProjectImageTreeModel(final Project project) { this.root = new ProjectTreeRowItem(new ProjectTreeRow.RootRow(project)); if (project != null) { - this.metadataKey = project.getMetadataValue(SORTING_KEY).orElse(null); + this.metadataKey = project.getMetadata().get(SORTING_KEY); } } @@ -1191,9 +1191,9 @@ private void setMetadataKey(String metadataKey) { this.metadataKey = metadataKey; if (metadataKey == null) { - project.removeMetadataValue(SORTING_KEY); + project.getMetadata().remove(SORTING_KEY); } else { - project.putMetadataValue(SORTING_KEY, metadataKey); + project.getMetadata().put(SORTING_KEY, metadataKey); } } From 87f511c51704687a88b7b28c120b6f1f336e1924 Mon Sep 17 00:00:00 2001 From: Leo Leplat <60394504+Rylern@users.noreply.github.com> Date: Tue, 13 Aug 2024 12:03:44 +0100 Subject: [PATCH 190/866] Typo --- .../src/main/java/qupath/lib/projects/DefaultProject.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qupath-core/src/main/java/qupath/lib/projects/DefaultProject.java b/qupath-core/src/main/java/qupath/lib/projects/DefaultProject.java index 39cebce79..034c73dc8 100644 --- a/qupath-core/src/main/java/qupath/lib/projects/DefaultProject.java +++ b/qupath-core/src/main/java/qupath/lib/projects/DefaultProject.java @@ -1292,7 +1292,7 @@ private static void setStoredMetadata(Path projectPath, Map meta GsonTools.getInstance().toJson(metadata, writer); } } catch (IOException e) { - logger.error("Error while creating project data directory", e); + logger.error("Error while saving project metadata", e); } } From 288f4fc9b35d796797a1dcf89dc4e0b3b623d22c Mon Sep 17 00:00:00 2001 From: Alan O'Callaghan Date: Wed, 14 Aug 2024 13:44:03 +0100 Subject: [PATCH 191/866] TMA grid view draft --- .../lib/gui/commands/PathObjectGridView.java | 145 +++++++----------- 1 file changed, 59 insertions(+), 86 deletions(-) diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/PathObjectGridView.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/PathObjectGridView.java index dd821776f..538886e5e 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/PathObjectGridView.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/PathObjectGridView.java @@ -27,6 +27,7 @@ import java.util.ArrayList; import java.util.Collection; import java.util.Collections; +import java.util.Comparator; import java.util.List; import java.util.Map.Entry; import java.util.concurrent.ForkJoinPool; @@ -101,31 +102,29 @@ public class PathObjectGridView implements ChangeListener comboMeasurement; - private ObservableList backingList = FXCollections.observableArrayList(); - private FilteredList filteredList = new FilteredList<>(backingList); + private final ObservableList backingList = FXCollections.observableArrayList(); + private final FilteredList filteredList = new FilteredList<>(backingList); - private ObservableMeasurementTableData model = new ObservableMeasurementTableData(); + private final ObservableMeasurementTableData model = new ObservableMeasurementTableData(); + + private final ObjectProperty> imageDataProperty = new SimpleObjectProperty<>(); + private final StringProperty measurement = new SimpleStringProperty(); + private final BooleanProperty showMeasurement = new SimpleBooleanProperty(true); + private final BooleanProperty descending = new SimpleBooleanProperty(false); + private final BooleanProperty doAnimate = new SimpleBooleanProperty(true); + + private final Function> objectExtractor; - private ObjectProperty> imageDataProperty = new SimpleObjectProperty<>(); - - - private StringProperty measurement = new SimpleStringProperty(); - private BooleanProperty showMeasurement = new SimpleBooleanProperty(true); - private BooleanProperty descending = new SimpleBooleanProperty(false); - private BooleanProperty doAnimate = new SimpleBooleanProperty(true); - - private Function> objectExtractor; - public static enum GridDisplaySize { TINY("Tiny", 60), SMALL("Small", 100), @@ -249,9 +248,18 @@ public ObjectProperty> imageDataProperty() { private static void sortPathObjects(final ObservableList cores, final ObservableMeasurementTableData model, final String measurementName, final boolean doDescending) { - cores.sort((t1, t2) -> { - double m1 = model.getNumericValue(t1, measurementName); - double m2 = model.getNumericValue(t2, measurementName); + if (measurementName.equals("PathClass")) { + cores.sort((po1, po2) -> { + if (po1.getPathClass() == null || po2.getPathClass() == null) return 0; + Comparator comp = Comparator.comparing((po) -> po.getPathClass().toString()); + comp = doDescending ? comp.reversed() : comp; + return comp.compare(po1, po2); + }); + return; + } + cores.sort((po1, po2) -> { + double m1 = model.getNumericValue(po1, measurementName); + double m2 = model.getNumericValue(po2, measurementName); int comp; if (doDescending) comp = -Double.compare(m1, m2); @@ -264,9 +272,9 @@ private static void sortPathObjects(final ObservableList c return doDescending ? -1 : 1; if (doDescending) - comp = t2.getDisplayedName().compareTo(t1.getDisplayedName()); + comp = po2.getDisplayedName().compareTo(po1.getDisplayedName()); else - comp = t1.getDisplayedName().compareTo(t2.getDisplayedName()); + comp = po1.getDisplayedName().compareTo(po2.getDisplayedName()); } return comp; }); @@ -312,12 +320,10 @@ private void initializeData(ImageData imageData) { model.setImageData(imageData, pathObjects); backingList.setAll(pathObjects); - + String m = measurement.getValue(); - sortPathObjects(backingList, model, m, descending.get()); - filteredList.setPredicate(p -> m == null || !(isMissingCore(p) || Double.isNaN(model.getNumericValue(p, m)))); - grid.getItems().setAll(filteredList); - + sortAndFilter(); + // Select the first measurement if necessary var names = model.getMeasurementNames(); if (m == null || !names.contains(m)) { @@ -329,18 +335,11 @@ private void initializeData(ImageData imageData) { private void initializeGUI() { - -// grid.setVerticalCellSpacing(10); -// grid.setHorizontalCellSpacing(5); - - + ComboBox comboDisplaySize = new ComboBox<>(); comboDisplaySize.getItems().setAll(GridDisplaySize.values()); comboDisplaySize.getSelectionModel().selectedItemProperty().addListener((v, o, n) -> { grid.imageSize.set(n.getSize()); -// grid.setCellWidth(n.getSize()); -// grid.setCellHeight(n.getSize()); -// updateGridDisplay(); }); comboDisplaySize.getSelectionModel().select(GridDisplaySize.SMALL); @@ -353,45 +352,25 @@ private void initializeGUI() { comboMeasurement = new ComboBox<>(); comboMeasurement.setPlaceholder(createPlaceholderText("No measurements!")); - comboMeasurement.setItems(model.getMeasurementNames()); + var measureList = FXCollections.observableArrayList(model.getMeasurementNames()); + measureList.add("PathClass"); + comboMeasurement.setItems(measureList); if (!comboMeasurement.getItems().isEmpty()) comboMeasurement.getSelectionModel().select(0); - - + measurement.bind(comboMeasurement.getSelectionModel().selectedItemProperty()); - - comboOrder.getSelectionModel().selectedItemProperty().addListener((v, o, n) -> { - String m = measurement.getValue(); - sortPathObjects(backingList, model, m, descending.get()); - filteredList.setPredicate(p -> { - return m == null || !(isMissingCore(p) || Double.isNaN(model.getNumericValue(p, m))); - }); - - grid.getItems().setAll(filteredList); - }); - - comboMeasurement.getSelectionModel().selectedItemProperty().addListener((v, o, n) -> { - String m = measurement.getValue(); - sortPathObjects(backingList, model, m, descending.get()); - filteredList.setPredicate(p -> { - return m == null || !(isMissingCore(p) || Double.isNaN(model.getNumericValue(p, m))); - }); - grid.getItems().setAll(filteredList); - }); - - + addSortAndFilterer(comboOrder); + addSortAndFilterer(comboMeasurement); + CheckBox cbShowMeasurement = new CheckBox("Show measurement"); showMeasurement.bind(cbShowMeasurement.selectedProperty()); showMeasurement.addListener(c -> updateMeasurement()); // Force an update - - + CheckBox cbAnimation = new CheckBox("Animate"); cbAnimation.setSelected(doAnimate.get()); doAnimate.bindBidirectional(cbAnimation.selectedProperty()); - - - + BorderPane pane = new BorderPane(); ToolBar paneTop = new ToolBar(); @@ -413,25 +392,18 @@ private void initializeGUI() { ((Label) item).setMinWidth(Label.USE_PREF_SIZE); } } -// paneTop.setHgap(5); -// paneTop.setVgap(5); - + comboMeasurement.setMaxWidth(Double.MAX_VALUE); comboOrder.setMaxWidth(Double.MAX_VALUE); -// GridPane.setHgrow(comboMeasurement, Priority.SOMETIMES); - + pane.setTop(paneTop); var scrollPane = new ScrollPane(grid); scrollPane.setFitToWidth(true); -// scrollPane.setFitToHeight(true); scrollPane.setVbarPolicy(ScrollBarPolicy.AS_NEEDED); pane.setCenter(scrollPane); -// if (grid.getSkin() != null) -// ((GridViewSkin)grid.getSkin()).updateGridViewItems(); - - + Scene scene = new Scene(pane, 640, 480); stage = new Stage(); @@ -443,15 +415,25 @@ private void initializeGUI() { stage.show(); } + private void addSortAndFilterer(ComboBox comboMeasurement) { + comboMeasurement.getSelectionModel().selectedItemProperty().addListener((v, o, n) -> { + sortAndFilter(); + }); + } + private void updateMeasurement() { + sortAndFilter(); + } + + private void sortAndFilter() { String m = measurement.getValue(); sortPathObjects(backingList, model, m, descending.get()); - filteredList.setPredicate(p -> m == null || !(isMissingCore(p) || Double.isNaN(model.getNumericValue(p, m)))); + filteredList.setPredicate(p -> m == null || m.equals("PathClass") || !(isMissingCore(p) || Double.isNaN(model.getNumericValue(p, m)))); grid.getItems().setAll(filteredList); } - - + + /** * Check if an object is a TMA core flagged as missing * @param pathObject @@ -641,22 +623,13 @@ protected void layoutChildren() { node.setTranslateX(x); node.setTranslateY(y); } - -// node.setLayoutX(x); -// node.setLayoutY(y); x += (dx + spaceX); } -// setHeight(y); setHeight(y + dx); setPrefHeight(y + dx); } - - - - + } - - } From dea72a6c658e65dfd2f02e748f9e9979bcbe5249 Mon Sep 17 00:00:00 2001 From: Alan O'Callaghan Date: Wed, 14 Aug 2024 15:30:33 +0100 Subject: [PATCH 192/866] Working but draft grid filtering --- .../lib/gui/commands/PathObjectGridView.java | 47 +++++++++++++++++-- 1 file changed, 43 insertions(+), 4 deletions(-) diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/PathObjectGridView.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/PathObjectGridView.java index 538886e5e..863a8ed1f 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/PathObjectGridView.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/PathObjectGridView.java @@ -30,10 +30,13 @@ import java.util.Comparator; import java.util.List; import java.util.Map.Entry; +import java.util.Set; import java.util.concurrent.ForkJoinPool; import java.util.WeakHashMap; import java.util.function.Function; +import java.util.stream.Collectors; +import org.controlsfx.control.CheckComboBox; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -83,10 +86,13 @@ import qupath.lib.images.ImageData; import qupath.lib.objects.PathObject; import qupath.lib.objects.TMACoreObject; +import qupath.lib.objects.classes.PathClass; +import qupath.lib.objects.classes.PathClassTools; import qupath.lib.objects.hierarchy.PathObjectHierarchy; import qupath.lib.objects.hierarchy.events.PathObjectHierarchyEvent; import qupath.lib.objects.hierarchy.events.PathObjectHierarchyListener; import qupath.lib.roi.interfaces.ROI; +import qupath.lib.scripting.QP; /** * Grid display of objects. @@ -123,6 +129,7 @@ public class PathObjectGridView implements ChangeListener> objectExtractor; + private ObservableList selectedClasses; public static enum GridDisplaySize { @@ -248,6 +255,7 @@ public ObjectProperty> imageDataProperty() { private static void sortPathObjects(final ObservableList cores, final ObservableMeasurementTableData model, final String measurementName, final boolean doDescending) { + if (measurementName == null) return; if (measurementName.equals("PathClass")) { cores.sort((po1, po2) -> { if (po1.getPathClass() == null || po2.getPathClass() == null) return 0; @@ -352,7 +360,14 @@ private void initializeGUI() { comboMeasurement = new ComboBox<>(); comboMeasurement.setPlaceholder(createPlaceholderText("No measurements!")); - var measureList = FXCollections.observableArrayList(model.getMeasurementNames()); + + var measureNames = model.getMeasurementNames(); + ObservableList measureList = FXCollections.observableArrayList(measureNames); + measureNames.addListener((ListChangeListener) c -> { + measureList.clear(); + measureList.add("PathClass"); + measureList.addAll(measureNames); + }); measureList.add("PathClass"); comboMeasurement.setItems(measureList); if (!comboMeasurement.getItems().isEmpty()) @@ -371,6 +386,20 @@ private void initializeGUI() { cbAnimation.setSelected(doAnimate.get()); doAnimate.bindBidirectional(cbAnimation.selectedProperty()); + CheckComboBox classComboBox = new CheckComboBox<>(); + selectedClasses = classComboBox.getCheckModel().getCheckedItems(); + selectedClasses.addListener((ListChangeListener) c -> sortAndFilter()); + Set representedClasses = qupath.getImageData().getHierarchy().getFlattenedObjectList(null).stream() + .filter(p -> !p.isRootObject()) + .map(PathObject::getPathClass) + .filter(p -> p != null && p != PathClass.NULL_CLASS) + .collect(Collectors.toSet()); + // todo: refresh on hierarchy update? + classComboBox.getItems().clear(); + classComboBox.getItems().addAll(representedClasses); + FXUtils.installSelectAllOrNoneMenu(classComboBox); + classComboBox.getCheckModel().checkAll(); + BorderPane pane = new BorderPane(); ToolBar paneTop = new ToolBar(); @@ -385,6 +414,9 @@ private void initializeGUI() { paneTop.getItems().add(new Label("Size")); paneTop.getItems().add(comboDisplaySize); paneTop.getItems().add(new Separator(Orientation.VERTICAL)); + paneTop.getItems().add(new Label("Classes")); + paneTop.getItems().add(classComboBox); + paneTop.getItems().add(new Separator(Orientation.VERTICAL)); paneTop.getItems().add(cbAnimation); paneTop.setPadding(new Insets(10, 10, 10, 10)); for (var item : paneTop.getItems()) { @@ -429,7 +461,10 @@ private void updateMeasurement() { private void sortAndFilter() { String m = measurement.getValue(); sortPathObjects(backingList, model, m, descending.get()); - filteredList.setPredicate(p -> m == null || m.equals("PathClass") || !(isMissingCore(p) || Double.isNaN(model.getNumericValue(p, m)))); + filteredList.setPredicate(p -> (m == null || m.equals("PathClass") || + !(isMissingCore(p) || Double.isNaN(model.getNumericValue(p, m)))) && + selectedClasses.contains(p.getPathClass()) + ); grid.getItems().setAll(filteredList); } @@ -558,8 +593,12 @@ void updateMeasurementText() { if (m == null || !showMeasurement.get()) entry.getValue().setText(" "); else { - double val = model.getNumericValue(entry.getKey(), m); - entry.getValue().setText(GeneralTools.formatNumber(val, 3)); + if (m.equals("PathClass")) { + entry.getValue().setText(entry.getKey().getPathClass().toString()); + } else { + double val = model.getNumericValue(entry.getKey(), m); + entry.getValue().setText(GeneralTools.formatNumber(val, 3)); + } } entry.getValue().setContentDisplay(ContentDisplay.TOP); } From 8f5b7f2c246e5ccdf29d315c375f2e87c3f6affe Mon Sep 17 00:00:00 2001 From: Pete Date: Thu, 15 Aug 2024 10:57:39 +0100 Subject: [PATCH 193/866] Improve HierarchyOverlay performance Don't paint detection names if we aren't at full resolution or above. This enables us to avoid introducing sluggishness in the UI when there are very large numbers of detections, by needing to access them on every repaint irrespective of whether there are names to show. --- .../gui/viewer/overlays/HierarchyOverlay.java | 75 +++++++++++-------- 1 file changed, 43 insertions(+), 32 deletions(-) diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/overlays/HierarchyOverlay.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/overlays/HierarchyOverlay.java index f631eecb8..aa01cc214 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/overlays/HierarchyOverlay.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/overlays/HierarchyOverlay.java @@ -195,9 +195,6 @@ public void paintOverlay(final Graphics2D g2d, final ImageRegion imageRegion, fi // Note: the following was commented out for v0.4.0, because objects becoming invisible // when outside the image turned out to be problematic more than helpful - // Ensure the bounds do not extend beyond what the server actually contains -// boundsDisplayed = boundsDisplayed.intersection(serverBounds); - if (boundsDisplayed.width <= 0 || boundsDisplayed.height <= 0) return; @@ -205,58 +202,60 @@ public void paintOverlay(final Graphics2D g2d, final ImageRegion imageRegion, fi ImageRegion region = AwtTools.getImageRegion(boundsDisplayed, z, t); // Get the annotations & selected objects (which must be painted directly) - Set visibleSelectedObjects = new LinkedHashSet<>(hierarchy.getSelectionModel().getSelectedObjects()); - visibleSelectedObjects.removeIf(p -> p == null || !p.hasROI() || (p.getROI().getZ() != z || p.getROI().getT() != t || - !region.intersects(p.getROI().getBoundsX(), p.getROI().getBoundsY(), p.getROI().getBoundsWidth(), p.getROI().getBoundsHeight()))); + Set paintableSelectedObjects = new LinkedHashSet<>(hierarchy.getSelectionModel().getSelectedObjects()); + paintableSelectedObjects.removeIf(p -> p == null || !roiBoundsIntersectsRegion(p.getROI(), region)); // Get all visible objects (including selected ones) boolean showAnnotations = overlayOptions.getShowAnnotations(); boolean showDetections = overlayOptions.getShowDetections(); - Collection visibleDetections = showDetections ? + // We only need detections if we're painting them directly - otherwise we use cached tiles instead. + // This means that detection names don't display when zoomed out, but that's probably OK and possibly even desirable. + // Without this, repainting with a large number of detections is made more sluggish by continually requesting + // the detections from the hierarchy, even when viewing the image at a low resolution & the only purpose of + // requesting them is to check if they have names (which is almost never the case). + Collection paintableDetections = showDetections && downsampleFactor <= 1.0 ? hierarchy.getAllDetectionsForRegion(region) : Collections.emptyList(); - Collection visibleAnnotations = showAnnotations ? + Collection paintableAnnotations = showAnnotations ? hierarchy.getAnnotationsForRegion(region) : Collections.emptyList(); // Return if nothing visible - if (visibleSelectedObjects.isEmpty() && visibleDetections.isEmpty() && visibleAnnotations.isEmpty()) + if (paintableSelectedObjects.isEmpty() && paintableDetections.isEmpty() && paintableAnnotations.isEmpty()) return; // Paint detection objects, if required long startTime = System.currentTimeMillis(); - if (overlayOptions.getShowDetections() && !visibleDetections.isEmpty()) { - + if (overlayOptions.getShowDetections()) { // If we aren't downsampling by much, or we're upsampling, paint directly - making sure to paint the right number of times, and in the right order - if (overlayServer == null || regionStore == null || downsampleFactor < 1.0) { + if (overlayServer == null || regionStore == null || !paintableDetections.isEmpty()) { Collection detectionsToPaint; try { detectionsToPaint = new TreeSet<>(comparator); - detectionsToPaint.addAll(visibleDetections); + detectionsToPaint.addAll(paintableDetections); } catch (IllegalArgumentException e) { // This can happen (rarely) in a multithreaded environment if the level of a detection changes. // However, protecting against this fully by caching the level with integer boxing/unboxing would be expensive. logger.debug("Exception requesting detections to paint: " + e.getLocalizedMessage(), e); - detectionsToPaint = visibleDetections; + detectionsToPaint = paintableDetections; } // Paint selected objects at the end - detectionsToPaint.removeIf(p -> visibleSelectedObjects.contains(p)); + detectionsToPaint.removeIf(paintableSelectedObjects::contains); g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_OFF); PathObjectPainter.paintSpecifiedObjects(g2d, detectionsToPaint, overlayOptions, hierarchy.getSelectionModel(), downsampleFactor); - + if (overlayOptions.getShowConnections()) { Object connections = imageData.getProperty(DefaultPathObjectConnectionGroup.KEY_OBJECT_CONNECTIONS); if (connections instanceof PathObjectConnections) - PathObjectPainter.paintConnections((PathObjectConnections)connections, - hierarchy, - g2d, - imageData.isFluorescence() ? ColorToolsAwt.TRANSLUCENT_WHITE : ColorToolsAwt.TRANSLUCENT_BLACK, - downsampleFactor, - imageRegion.getImagePlane()); + PathObjectPainter.paintConnections((PathObjectConnections) connections, + hierarchy, + g2d, + imageData.isFluorescence() ? ColorToolsAwt.TRANSLUCENT_WHITE : ColorToolsAwt.TRANSLUCENT_BLACK, + downsampleFactor, + imageRegion.getImagePlane()); } - - } else { + } else { // If the image hasn't been updated, then we are viewing the stationary image - we want to wait for a full repaint then to avoid flickering; // On the other hand, if a large image has been updated then we may be browsing quickly - better to repaint quickly while tiles may still be loading if (paintCompletely) { @@ -277,23 +276,23 @@ public void paintOverlay(final Graphics2D g2d, final ImageRegion imageRegion, fi g2d.setRenderingHint(RenderingHints.KEY_STROKE_CONTROL, defaultStroke); // Paint annotations, using a defined order based on hierarchy level and area - var annotationsToPaint = visibleAnnotations.stream() - .filter(p -> !visibleSelectedObjects.contains(p)) + var annotationsToPaint = paintableAnnotations.stream() + .filter(p -> !paintableSelectedObjects.contains(p)) .sorted(Comparator.comparingInt(PathObject::getLevel).reversed() .thenComparing((PathObject p) -> -p.getROI().getArea())) .toList(); PathObjectPainter.paintSpecifiedObjects(g2d, annotationsToPaint, overlayOptions, null, downsampleFactor); // Ensure that selected objects are painted last, to make sure they aren't obscured - if (!visibleSelectedObjects.isEmpty()) { + if (!paintableSelectedObjects.isEmpty()) { Composite previousComposite = g2d.getComposite(); float opacity = overlayOptions.getOpacity(); if (opacity < 1) { g2d.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER)); - PathObjectPainter.paintSpecifiedObjects(g2d, visibleSelectedObjects, overlayOptions, hierarchy.getSelectionModel(), downsampleFactor); + PathObjectPainter.paintSpecifiedObjects(g2d, paintableSelectedObjects, overlayOptions, hierarchy.getSelectionModel(), downsampleFactor); g2d.setComposite(previousComposite); } else { - PathObjectPainter.paintSpecifiedObjects(g2d, visibleSelectedObjects, overlayOptions, hierarchy.getSelectionModel(), downsampleFactor); + PathObjectPainter.paintSpecifiedObjects(g2d, paintableSelectedObjects, overlayOptions, hierarchy.getSelectionModel(), downsampleFactor); } } @@ -302,9 +301,9 @@ public void paintOverlay(final Graphics2D g2d, final ImageRegion imageRegion, fi // Get all objects with names that we might need to paint Set objectsWithNames = new LinkedHashSet<>(); - visibleDetections.stream().filter(p -> p.getName() != null).forEach(objectsWithNames::add); - visibleAnnotations.stream().filter(p -> p.getName() != null).forEach(objectsWithNames::add); - visibleSelectedObjects.stream().filter(p -> p.getName() != null).forEach(objectsWithNames::add); + paintableDetections.stream().filter(p -> p.getName() != null).forEach(objectsWithNames::add); + paintableAnnotations.stream().filter(p -> p.getName() != null).forEach(objectsWithNames::add); + paintableSelectedObjects.stream().filter(p -> p.getName() != null).forEach(objectsWithNames::add); var detectionDisplayMode = overlayOptions.getDetectionDisplayMode(); @@ -396,6 +395,18 @@ public void paintOverlay(final Graphics2D g2d, final ImageRegion imageRegion, fi } } + /** + * Quick test to see if an ROI's bounds intersect a specified region. + * @param roi + * @param region + * @return true if the roi is non-null and its bounds intersect region, false otherwise + */ + private static boolean roiBoundsIntersectsRegion(ROI roi, ImageRegion region) { + return roi != null && roi.getZ() == region.getZ() && roi.getT() == region.getT() && + region.intersects(roi.getBoundsX(), roi.getBoundsY(), roi.getBoundsWidth(), roi.getBoundsHeight()); + } + + /** * Get a color to use to fill the bounding box when showing an object's name * @param objectColorInt From 14419ae32536f2d7c503f10723304226282dab8f Mon Sep 17 00:00:00 2001 From: Alan O'Callaghan Date: Thu, 15 Aug 2024 15:19:11 +0100 Subject: [PATCH 194/866] Refresh on hierarchy change --- .../lib/gui/commands/PathObjectGridView.java | 57 ++++++++++--------- 1 file changed, 30 insertions(+), 27 deletions(-) diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/PathObjectGridView.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/PathObjectGridView.java index 863a8ed1f..df3fd87f9 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/PathObjectGridView.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/PathObjectGridView.java @@ -23,23 +23,6 @@ package qupath.lib.gui.commands; -import java.awt.image.BufferedImage; -import java.util.ArrayList; -import java.util.Collection; -import java.util.Collections; -import java.util.Comparator; -import java.util.List; -import java.util.Map.Entry; -import java.util.Set; -import java.util.concurrent.ForkJoinPool; -import java.util.WeakHashMap; -import java.util.function.Function; -import java.util.stream.Collectors; - -import org.controlsfx.control.CheckComboBox; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - import javafx.animation.Interpolator; import javafx.animation.TranslateTransition; import javafx.application.Platform; @@ -79,6 +62,9 @@ import javafx.scene.text.Text; import javafx.stage.Stage; import javafx.util.Duration; +import org.controlsfx.control.CheckComboBox; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import qupath.fx.utils.FXUtils; import qupath.lib.common.GeneralTools; import qupath.lib.gui.QuPathGUI; @@ -87,12 +73,23 @@ import qupath.lib.objects.PathObject; import qupath.lib.objects.TMACoreObject; import qupath.lib.objects.classes.PathClass; -import qupath.lib.objects.classes.PathClassTools; import qupath.lib.objects.hierarchy.PathObjectHierarchy; import qupath.lib.objects.hierarchy.events.PathObjectHierarchyEvent; import qupath.lib.objects.hierarchy.events.PathObjectHierarchyListener; import qupath.lib.roi.interfaces.ROI; -import qupath.lib.scripting.QP; + +import java.awt.image.BufferedImage; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Map.Entry; +import java.util.Set; +import java.util.WeakHashMap; +import java.util.concurrent.ForkJoinPool; +import java.util.function.Function; +import java.util.stream.Collectors; /** * Grid display of objects. @@ -389,15 +386,11 @@ private void initializeGUI() { CheckComboBox classComboBox = new CheckComboBox<>(); selectedClasses = classComboBox.getCheckModel().getCheckedItems(); selectedClasses.addListener((ListChangeListener) c -> sortAndFilter()); - Set representedClasses = qupath.getImageData().getHierarchy().getFlattenedObjectList(null).stream() - .filter(p -> !p.isRootObject()) - .map(PathObject::getPathClass) - .filter(p -> p != null && p != PathClass.NULL_CLASS) - .collect(Collectors.toSet()); - // todo: refresh on hierarchy update? - classComboBox.getItems().clear(); - classComboBox.getItems().addAll(representedClasses); FXUtils.installSelectAllOrNoneMenu(classComboBox); + + updateClasses(classComboBox); + qupath.getImageData().getHierarchy().addListener(event -> updateClasses(classComboBox)); + classComboBox.getCheckModel().checkAll(); BorderPane pane = new BorderPane(); @@ -447,6 +440,16 @@ private void initializeGUI() { stage.show(); } + private void updateClasses(CheckComboBox classComboBox) { + Set representedClasses = qupath.getImageData().getHierarchy().getFlattenedObjectList(null).stream() + .filter(p -> !p.isRootObject()) + .map(PathObject::getPathClass) + .filter(p -> p != null && p != PathClass.NULL_CLASS) + .collect(Collectors.toSet()); + classComboBox.getItems().clear(); + classComboBox.getItems().addAll(representedClasses); + } + private void addSortAndFilterer(ComboBox comboMeasurement) { comboMeasurement.getSelectionModel().selectedItemProperty().addListener((v, o, n) -> { sortAndFilter(); From e98aa1f6a8235538fe97550e58ce11e8010e6a94 Mon Sep 17 00:00:00 2001 From: Pete Date: Thu, 15 Aug 2024 15:49:37 +0100 Subject: [PATCH 195/866] Improve metadata handling Introduce a `MinimalMetadataStore` interface (may be renamed) and use it with `PathObject. This adds support for String key/value metadata to be added to all `PathObject`, and enables us to deprecate some old metadata-related code. --- CHANGELOG.md | 8 +++ .../lib/interfaces/MinimalMetadataStore.java | 41 ++++++++++++ .../qupath/lib/io/QuPathTypeAdapters.java | 32 ++++------ .../java/qupath/lib/objects/MetadataMap.java | 28 +++++---- .../qupath/lib/objects/MetadataStore.java | 27 ++++---- .../lib/objects/PathAnnotationObject.java | 20 +++--- .../lib/objects/PathDetectionObject.java | 19 +----- .../java/qupath/lib/objects/PathObject.java | 27 +++++--- .../qupath/lib/objects/PathObjectTools.java | 5 +- .../qupath/lib/objects/TMACoreObject.java | 63 ++++++++++--------- .../java/qupath/lib/projects/Project.java | 52 +++++++-------- .../qupath/lib/objects/TestPathObject.java | 2 +- .../ObservableMeasurementTableData.java | 11 ++-- 13 files changed, 190 insertions(+), 145 deletions(-) create mode 100644 qupath-core/src/main/java/qupath/lib/interfaces/MinimalMetadataStore.java diff --git a/CHANGELOG.md b/CHANGELOG.md index fc794d9f9..af757f4e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,14 @@ This is a *work in progress* for the next major release. * `ImageOps.Core.replace()` does not work as expected (https://github.com/qupath/qupath/issues/1564) * QuPath doesn't always use the specified file extension when exporting snapshots (https://github.com/qupath/qupath/issues/1567) +### API changes +* `PathObject` now has a `getMetadata()` method to access key/value `String` pairs + * Use judiciously! Adding metadata to any object can increase its memory footprint - + for detections, it is best to try to avoid adding even a single value because then no + storage needs to be created. + * `qupath.lib.objects.MetadataStore` is deprecated and will be removed in the next release - + along with older metadata-related methods in `PathObject`. + ### Dependency updates * Bio-Formats 7.3.1 * Commonmark 0.22.0 diff --git a/qupath-core/src/main/java/qupath/lib/interfaces/MinimalMetadataStore.java b/qupath-core/src/main/java/qupath/lib/interfaces/MinimalMetadataStore.java new file mode 100644 index 000000000..520070255 --- /dev/null +++ b/qupath-core/src/main/java/qupath/lib/interfaces/MinimalMetadataStore.java @@ -0,0 +1,41 @@ +/*- + * #%L + * This file is part of QuPath. + * %% + * Copyright (C) 2024 QuPath developers, The University of Edinburgh + * %% + * QuPath 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 3 of the + * License, or (at your option) any later version. + * + * QuPath 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 QuPath. If not, see . + * #L% + */ + +package qupath.lib.interfaces; + +import java.util.Map; + +/** + * Minimal interface to indicate that an object can provide a map of metadata key/value pairs. + */ +public interface MinimalMetadataStore { + + /** + * Returns a modifiable map containing the metadata. + *

    + * The returned map may or may not be thread-safe. Implementing classes must + * document the thread-safeness of the map. + * + * @return the metadata of this store + */ + Map getMetadata(); + +} diff --git a/qupath-core/src/main/java/qupath/lib/io/QuPathTypeAdapters.java b/qupath-core/src/main/java/qupath/lib/io/QuPathTypeAdapters.java index 91aec054f..06f9fd68e 100644 --- a/qupath-core/src/main/java/qupath/lib/io/QuPathTypeAdapters.java +++ b/qupath-core/src/main/java/qupath/lib/io/QuPathTypeAdapters.java @@ -34,9 +34,9 @@ import java.util.Map; import java.util.Map.Entry; import java.util.stream.Collectors; -import java.util.Set; import java.util.UUID; +import com.google.gson.Strictness; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -57,7 +57,6 @@ import qupath.lib.measurements.MeasurementList; import qupath.lib.measurements.MeasurementList.MeasurementListType; import qupath.lib.measurements.MeasurementListFactory; -import qupath.lib.objects.MetadataStore; import qupath.lib.objects.PathAnnotationObject; import qupath.lib.objects.PathCellObject; import qupath.lib.objects.PathDetectionObject; @@ -75,7 +74,7 @@ class QuPathTypeAdapters { static Gson gson = new GsonBuilder() - .setLenient() + .setStrictness(Strictness.LENIENT) .serializeSpecialFloatingPointValues() .create(); @@ -408,15 +407,13 @@ public void write(JsonWriter out, PathObject value) throws IOException { out.value(measurements.getMeasurementValue(i)); } } - if (value instanceof MetadataStore store) { - Set keys = store.getMetadataKeys(); - if (!keys.isEmpty()) { - out.name("Metadata count"); - out.value(keys.size()); - for (String key : keys) { - out.name(key); - out.value(store.getMetadataString(key)); - } + var map = value.getMetadata(); + if (!map.isEmpty()) { + out.name("Metadata count"); + out.value(map.size()); + for (var entry : map.entrySet()) { + out.name(entry.getKey()); + out.value(entry.getValue()); } } } else { @@ -425,13 +422,10 @@ public void write(JsonWriter out, PathObject value) throws IOException { MeasurementListTypeAdapter.INSTANCE.write(out, measurements); } - if (value instanceof MetadataStore) { - MetadataStore store = (MetadataStore)value; - Map map = store.getMetadata(); - if (!map.isEmpty()) { - out.name("metadata"); - gson.toJson(map, Map.class, out); - } + Map map = value.getMetadata(); + if (!map.isEmpty()) { + out.name("metadata"); + gson.toJson(map, Map.class, out); } } diff --git a/qupath-core/src/main/java/qupath/lib/objects/MetadataMap.java b/qupath-core/src/main/java/qupath/lib/objects/MetadataMap.java index e4729b7ff..805a334ba 100644 --- a/qupath-core/src/main/java/qupath/lib/objects/MetadataMap.java +++ b/qupath-core/src/main/java/qupath/lib/objects/MetadataMap.java @@ -4,7 +4,7 @@ * %% * Copyright (C) 2014 - 2016 The Queen's University of Belfast, Northern Ireland * Contact: IP Management (ipmanagement@qub.ac.uk) - * Copyright (C) 2018 - 2022 QuPath developers, The University of Edinburgh + * Copyright (C) 2018 - 2024 QuPath developers, The University of Edinburgh * %% * QuPath is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as @@ -39,12 +39,14 @@ /** * Helper class for storing metadata key/value pairs. *

    - * Currently wraps a {@link LinkedHashMap}, but this implementation may change. + * This currently wraps a synchronized {@link LinkedHashMap}, but the implementation may change. *

    - * Serializes itself reasonably efficiently by using Object arrays. - * - * @author Pete Bankhead - * + * The main features of this class are: + *

      + *
    • It can be serialized reasonably efficiently using Object arrays
    • + *
    • It tries to minimize overhead by creating its internal map implementation lazily, + * only whenever anything is being added.
    • + *
    */ class MetadataMap implements Map, Externalizable { @@ -58,12 +60,12 @@ public MetadataMap() {} @Override public int size() { - return getMapInternal().size(); + return map == null ? 0 : getMapInternal().size(); } @Override public boolean isEmpty() { - return getMapInternal().isEmpty(); + return map == null || getMapInternal().isEmpty(); } @Override @@ -105,22 +107,24 @@ public void clear() { if (map == null) return; map.clear(); - map = null; } @Override public Set keySet() { - return getMapInternalOrCreate().keySet(); + // Returned set does only needs to support removal, so Collections.emptyMap() is ok + return getMapInternal().keySet(); } @Override public Collection values() { - return getMapInternalOrCreate().values(); + // Returned set does only needs to support removal, so Collections.emptyMap() is ok + return getMapInternal().values(); } @Override public Set> entrySet() { - return getMapInternalOrCreate().entrySet(); + // Returned set does only needs to support removal, so Collections.emptyMap() is ok + return getMapInternal().entrySet(); } @Override diff --git a/qupath-core/src/main/java/qupath/lib/objects/MetadataStore.java b/qupath-core/src/main/java/qupath/lib/objects/MetadataStore.java index a2d208d94..b11803292 100644 --- a/qupath-core/src/main/java/qupath/lib/objects/MetadataStore.java +++ b/qupath-core/src/main/java/qupath/lib/objects/MetadataStore.java @@ -23,6 +23,9 @@ package qupath.lib.objects; +import qupath.lib.interfaces.MinimalMetadataStore; + +import java.util.Collections; import java.util.Map; import java.util.Set; @@ -31,19 +34,11 @@ *

    * Implementing classes should ensure that entries are stored in insertion order. * - * @author Pete Bankhead + * @deprecated v0.6.0. Use {@link MinimalMetadataStore} instead. */ -public interface MetadataStore { +@Deprecated +public interface MetadataStore extends MinimalMetadataStore { - /** - * Returns a modifiable map containing the metadata. - *

    - * The returned map may or may not be thread-safe. Implementing classes must - * document the thread-safeness of the map. - * - * @return the metadata of this store - */ - Map getMetadata(); /** * Store a new metadata value. @@ -95,5 +90,15 @@ default Object getMetadataValue(String key) { default Set getMetadataKeys() { return getMetadata().keySet(); } + + /** + * Returns an unmodifiable map containing the metadata. + * + * @return + */ + @Deprecated + default Map getMetadataMap() { + return Collections.unmodifiableMap(getMetadata()); + } } diff --git a/qupath-core/src/main/java/qupath/lib/objects/PathAnnotationObject.java b/qupath-core/src/main/java/qupath/lib/objects/PathAnnotationObject.java index b2a9070d4..5327285a1 100644 --- a/qupath-core/src/main/java/qupath/lib/objects/PathAnnotationObject.java +++ b/qupath-core/src/main/java/qupath/lib/objects/PathAnnotationObject.java @@ -4,7 +4,7 @@ * %% * Copyright (C) 2014 - 2016 The Queen's University of Belfast, Northern Ireland * Contact: IP Management (ipmanagement@qub.ac.uk) - * Copyright (C) 2018 - 2020 QuPath developers, The University of Edinburgh + * Copyright (C) 2018 - 2024 QuPath developers, The University of Edinburgh * %% * QuPath is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as @@ -23,8 +23,6 @@ package qupath.lib.objects; -import java.util.Objects; - import qupath.lib.measurements.MeasurementList; import qupath.lib.objects.classes.PathClass; import qupath.lib.roi.interfaces.ROI; @@ -69,14 +67,14 @@ public PathAnnotationObject() { /** * Set a free text description for this annotation. * - * @param text + * @param text to use as a description; if null, any existing description will be removed */ public void setDescription(final String text) { // Don't store unless we need to (which can also help avoid creating unnecessary metadata stores) - Object existing = retrieveMetadataValue(KEY_ANNOTATION_TEXT); - if (Objects.equals(text, existing)) - return; - this.storeMetadataValue(KEY_ANNOTATION_TEXT, text); + if (text == null) + getMetadata().remove(KEY_ANNOTATION_TEXT); + else + getMetadata().put(KEY_ANNOTATION_TEXT, text); } /** @@ -84,11 +82,7 @@ public void setDescription(final String text) { * @return */ public String getDescription() { - return (String)retrieveMetadataValue(KEY_ANNOTATION_TEXT); + return getMetadata().get(KEY_ANNOTATION_TEXT); } -// PathAnnotationObject(PathAnnotationObject pathObject, PathROI pathROI) { -// super(pathROI, pathObject.getPathClass(), pathObject.getMeasurementList()); -// } - } diff --git a/qupath-core/src/main/java/qupath/lib/objects/PathDetectionObject.java b/qupath-core/src/main/java/qupath/lib/objects/PathDetectionObject.java index 8d5569516..a24367c4e 100644 --- a/qupath-core/src/main/java/qupath/lib/objects/PathDetectionObject.java +++ b/qupath-core/src/main/java/qupath/lib/objects/PathDetectionObject.java @@ -4,7 +4,7 @@ * %% * Copyright (C) 2014 - 2016 The Queen's University of Belfast, Northern Ireland * Contact: IP Management (ipmanagement@qub.ac.uk) - * Copyright (C) 2018 - 2020 QuPath developers, The University of Edinburgh + * Copyright (C) 2018 - 2024 QuPath developers, The University of Edinburgh * %% * QuPath is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as @@ -23,8 +23,6 @@ package qupath.lib.objects; -import java.util.Map; - import qupath.lib.measurements.MeasurementList; import qupath.lib.measurements.MeasurementList.MeasurementListType; import qupath.lib.measurements.MeasurementListFactory; @@ -73,10 +71,6 @@ protected PathDetectionObject(ROI pathROI, PathClass pathClass) { super(pathROI, pathClass); } - PathDetectionObject(ROI pathROI) { - this(pathROI, null); - } - /** * Always returns false - detection objects shouldn't be edited. */ @@ -93,15 +87,4 @@ protected MeasurementList createEmptyMeasurementList() { return MeasurementListFactory.createMeasurementList(0, MeasurementListType.FLOAT); } - /** - * Get a map of metadata values. - * Note that, for detection objects, this map is unmodifiable to avoid excessive memory use. - * @since v0.5.0 - */ - @Override - public Map getMetadata() { - return getUnmodifiableMetadataMap(); - } - - } diff --git a/qupath-core/src/main/java/qupath/lib/objects/PathObject.java b/qupath-core/src/main/java/qupath/lib/objects/PathObject.java index 72be3d373..e5dd940b0 100644 --- a/qupath-core/src/main/java/qupath/lib/objects/PathObject.java +++ b/qupath-core/src/main/java/qupath/lib/objects/PathObject.java @@ -4,7 +4,7 @@ * %% * Copyright (C) 2014 - 2016 The Queen's University of Belfast, Northern Ireland * Contact: IP Management (ipmanagement@qub.ac.uk) - * Copyright (C) 2018 - 2022 QuPath developers, The University of Edinburgh + * Copyright (C) 2018 - 2024 QuPath developers, The University of Edinburgh * %% * QuPath is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as @@ -43,6 +43,7 @@ import qupath.lib.common.ColorTools; import qupath.lib.common.LogTools; +import qupath.lib.interfaces.MinimalMetadataStore; import qupath.lib.io.PathIO; import qupath.lib.measurements.MeasurementList; import qupath.lib.measurements.MeasurementListFactory; @@ -58,7 +59,7 @@ * @author Pete Bankhead * */ -public abstract class PathObject implements Externalizable { +public abstract class PathObject implements Externalizable, MinimalMetadataStore { private static final long serialVersionUID = 1L; @@ -1022,7 +1023,9 @@ void ensureChildList(int capacity) { * @return * * @see #retrieveMetadataValue + * @deprecated v0.6.0, use {@link #getMetadata()} to directly access the metadata instead. */ + @Deprecated protected Object storeMetadataValue(final String key, final String value) { if (metadata == null) metadata = new MetadataMap(); @@ -1036,7 +1039,9 @@ protected Object storeMetadataValue(final String key, final String value) { * @return the metadata value if set, or null if not * * @see #storeMetadataValue + * @deprecated v0.6.0, use {@link #getMetadata()} to directly access the metadata instead. */ + @Deprecated protected Object retrieveMetadataValue(final String key) { return metadata == null ? null : metadata.get(key); } @@ -1045,7 +1050,9 @@ protected Object retrieveMetadataValue(final String key) { * Get the set of metadata keys. * * @return + * @deprecated v0.6.0, use {@link #getMetadata()} to directly access the metadata instead. */ + @Deprecated protected Set retrieveMetadataKeys() { return metadata == null ? Collections.emptySet() : metadata.keySet(); } @@ -1054,14 +1061,18 @@ protected Set retrieveMetadataKeys() { * Get an unmodifiable map of the metadata. * * @return + * @deprecated v0.6.0, use {@link #getMetadata()} to directly access the metadata instead. */ + @Deprecated protected Map getUnmodifiableMetadataMap() { return metadata == null ? Collections.emptyMap() : Collections.unmodifiableMap(metadata); } /** * Remove all stored metadata values. + * @deprecated v0.6.0, use {@link #getMetadata()} to directly access the metadata instead. */ + @Deprecated protected void clearMetadataMap() { if (metadata != null) metadata.clear(); @@ -1073,6 +1084,7 @@ protected void clearMetadataMap() { * @since v0.5.0 * @implNote This is an experimental API change that may be further modified before v0.5.0 is available. */ + @Override public Map getMetadata() { if (metadata == null) metadata = new MetadataMap(); @@ -1097,7 +1109,7 @@ public void writeExternal(ObjectOutput out) throws IOException { nFields++; } out.writeInt(nFields); - if (metadata != null) + if (metadata != null && !metadata.isEmpty()) out.writeObject(metadata); out.writeObject(measurements); } else { @@ -1155,13 +1167,14 @@ public void readExternal(ObjectInput in) throws IOException, ClassNotFoundExcept int nFields = in.readInt(); for (int i = 0; i < nFields; i++) { nextObject = in.readObject(); - if (nextObject instanceof MetadataMap) { + if (nextObject instanceof MetadataMap mm) { // Read metadata, if we have it - metadata = (MetadataMap)nextObject; - } else if (nextObject instanceof MeasurementList) { + if (!mm.isEmpty()) + metadata = mm; + } else if (nextObject instanceof MeasurementList ml) { // Read a measurement list, if we have one // This is rather hack-ish... but re-closing a list can prompt it to be stored more efficiently - measurements = (MeasurementList)nextObject; + measurements = ml; measurements.close(); } else if (nextObject != null) { logger.debug("Unsupported field during deserialization {}", nextObject); diff --git a/qupath-core/src/main/java/qupath/lib/objects/PathObjectTools.java b/qupath-core/src/main/java/qupath/lib/objects/PathObjectTools.java index 4ca2d9e51..079fd5944 100644 --- a/qupath-core/src/main/java/qupath/lib/objects/PathObjectTools.java +++ b/qupath-core/src/main/java/qupath/lib/objects/PathObjectTools.java @@ -54,6 +54,7 @@ import qupath.lib.geom.Point2; import qupath.lib.images.ImageData; import qupath.lib.images.servers.ImageServer; +import qupath.lib.interfaces.MinimalMetadataStore; import qupath.lib.measurements.MeasurementList; import qupath.lib.objects.classes.PathClass; import qupath.lib.objects.classes.PathClassTools; @@ -1524,8 +1525,8 @@ private static PathObject transformObjectImpl(PathObject pathObject, Function getMetadataKeys() { return super.retrieveMetadataKeys(); } - @Override - public Map getMetadata() { - return super.getUnmodifiableMetadataMap(); - } - /** * Clear all associated metadata. + * @deprecated v0.6.0. Use {@link #getMetadata()} to directly access metadata instead. */ + @Deprecated public void clearMetadata() { super.clearMetadataMap(); } @@ -174,27 +198,6 @@ public String toString() { return getDisplayedName() + objectCountPostfix(); } - -// /** -// * TMA core cannot be edited if it contains any detections. -// * (Removed for v0.4.0 to rely on locking like other objects instead - -// * see https://github.com/qupath/qupath/issues/1021 -// */ -// @Override -// public boolean isEditable() { -// return super.isEditable() && !containsChildOfClass(this, PathDetectionObject.class, true); -// } -// -// private static boolean containsChildOfClass(final PathObject pathObject, final Class cls, final boolean allDescendants) { -// for (PathObject childObject : pathObject.getChildObjectsAsArray()) { -// if (cls.isAssignableFrom(childObject.getClass())) -// return true; -// if (childObject.hasChildren() && allDescendants && containsChildOfClass(childObject, cls, allDescendants)) -// return true; -// } -// return false; -// } - @Override public void writeExternal(ObjectOutput out) throws IOException { super.writeExternal(out); diff --git a/qupath-core/src/main/java/qupath/lib/projects/Project.java b/qupath-core/src/main/java/qupath/lib/projects/Project.java index 8f54c635b..fdbece288 100644 --- a/qupath-core/src/main/java/qupath/lib/projects/Project.java +++ b/qupath-core/src/main/java/qupath/lib/projects/Project.java @@ -33,7 +33,7 @@ import qupath.lib.classifiers.pixel.PixelClassifier; import qupath.lib.images.ImageData; import qupath.lib.images.servers.ImageServerBuilder.ServerBuilder; -import qupath.lib.objects.MetadataStore; +import qupath.lib.interfaces.MinimalMetadataStore; import qupath.lib.objects.classes.PathClass; import qupath.lib.projects.ResourceManager.Manager; @@ -44,13 +44,13 @@ * * @param */ -public interface Project extends MetadataStore { +public interface Project extends MinimalMetadataStore { /** * Get an unmodifiable list representing the PathClasses associated with this project. * @return */ - public List getPathClasses(); + List getPathClasses(); /** * Query whether 'true' or masked image names are being returned. @@ -59,7 +59,7 @@ public interface Project extends MetadataStore { * * @see #setMaskImageNames(boolean) */ - public boolean getMaskImageNames(); + boolean getMaskImageNames(); /** * Request that entries return masked image names, rather than the 'true' image names. @@ -71,7 +71,7 @@ public interface Project extends MetadataStore { * * @see #getMaskImageNames() */ - public void setMaskImageNames(boolean maskNames); + void setMaskImageNames(boolean maskNames); /** * Update the available PathClasses. @@ -79,14 +79,14 @@ public interface Project extends MetadataStore { * @param pathClasses * @return true if the stored values changed, false otherwise. */ - public boolean setPathClasses(Collection pathClasses); + boolean setPathClasses(Collection pathClasses); /** * Get a URI that can be used when saving/reloading this project. * * @return */ - public URI getURI(); + URI getURI(); /** * Sometimes projects move (unfortunately). This returns the previous URI, if known - @@ -95,7 +95,7 @@ public interface Project extends MetadataStore { * * @return */ - public URI getPreviousURI(); + URI getPreviousURI(); /** * Extract a usable project name from a URI. @@ -128,7 +128,7 @@ public static String getNameFromURI(URI uri) { * * @return */ - public String getVersion(); + String getVersion(); /** * Get a path to this project, or null if this project is not on a local file system. @@ -138,7 +138,7 @@ public static String getNameFromURI(URI uri) { * @return * @see ProjectImageEntry#getEntryPath() */ - public Path getPath(); + Path getPath(); /** * Create a sub-project that provides a view on the specified entries. @@ -150,13 +150,13 @@ public static String getNameFromURI(URI uri) { * @param entries the entries to retain within the sub-project * @return */ - public Project createSubProject(final String name, final Collection> entries); + Project createSubProject(final String name, final Collection> entries); /** * Test if the project contains any images. * @return */ - public boolean isEmpty(); + boolean isEmpty(); /** * Add an image for a particular ImageServer. @@ -164,7 +164,7 @@ public static String getNameFromURI(URI uri) { * @return * @throws IOException */ - public ProjectImageEntry addImage(final ServerBuilder server) throws IOException; + ProjectImageEntry addImage(final ServerBuilder server) throws IOException; /** * Add an image by duplicating an existing entry. @@ -176,14 +176,14 @@ public static String getNameFromURI(URI uri) { * @return the new entry that has been added to the project * @throws IOException */ - public ProjectImageEntry addDuplicate(final ProjectImageEntry entry, boolean copyData) throws IOException; + ProjectImageEntry addDuplicate(final ProjectImageEntry entry, boolean copyData) throws IOException; /** * Request a {@link ProjectImageEntry} associated with an {@link ImageData} * @param imageData * @return */ - public ProjectImageEntry getEntry(final ImageData imageData); + ProjectImageEntry getEntry(final ImageData imageData); /** * Remove an image from the project, optionally including associated data. @@ -191,7 +191,7 @@ public static String getNameFromURI(URI uri) { * @param entry * @param removeAllData */ - public void removeImage(final ProjectImageEntry entry, boolean removeAllData); + void removeImage(final ProjectImageEntry entry, boolean removeAllData); /** * Remove multiple images from the project, optionally including associated data. @@ -199,34 +199,34 @@ public static String getNameFromURI(URI uri) { * @param entries * @param removeAllData */ - public void removeAllImages(final Collection> entries, boolean removeAllData); + void removeAllImages(final Collection> entries, boolean removeAllData); /** * Save the project. * * @throws IOException */ - public void syncChanges() throws IOException; + void syncChanges() throws IOException; /** * Get a list of image entries for the project. * * @return */ - public List> getImageList(); + List> getImageList(); /** * Get the name of the project. * * @return */ - public String getName(); + String getName(); /** * Request a timestamp from when the project was created. * @return */ - public long getCreationTimestamp(); + long getCreationTimestamp(); /** * Request a timestamp from when the project was last synchronized. @@ -234,7 +234,7 @@ public static String getNameFromURI(URI uri) { * * @see #syncChanges() */ - public long getModificationTimestamp(); + long getModificationTimestamp(); @@ -246,7 +246,7 @@ public static String getNameFromURI(URI uri) { * @return * @see #getResources(String, Class, String) */ - public default Manager getScripts() { + default Manager getScripts() { return getResources("scripts", String.class, "groovy"); } @@ -256,7 +256,7 @@ public default Manager getScripts() { * @return * @see #getResources(String, Class, String) */ - public default Manager> getObjectClassifiers() { + default Manager> getObjectClassifiers() { return getResources(ObjectClassifier.PROJECT_LOCATION, ObjectClassifier.class, "json"); } @@ -266,7 +266,7 @@ public default Manager> getObjectClassifiers() { * @return * @see #getResources(String, Class, String) */ - public default Manager getPixelClassifiers() { + default Manager getPixelClassifiers() { return getResources(PixelClassifier.PROJECT_LOCATION, PixelClassifier.class, "json"); } @@ -281,7 +281,7 @@ public default Manager getPixelClassifiers() { * @return a {@link Manager} for the specified resource, or {@code null} if the project does not support the resource or extension. * @implNote the default implementation returns null. Subclasses should override this. */ - public default Manager getResources(String location, Class cls, String ext) { + default Manager getResources(String location, Class cls, String ext) { return null; } } diff --git a/qupath-core/src/test/java/qupath/lib/objects/TestPathObject.java b/qupath-core/src/test/java/qupath/lib/objects/TestPathObject.java index e21b4c1fe..d91d8b8a1 100644 --- a/qupath-core/src/test/java/qupath/lib/objects/TestPathObject.java +++ b/qupath-core/src/test/java/qupath/lib/objects/TestPathObject.java @@ -251,7 +251,7 @@ public void testTMACoreSerialization() { // Set the legacy ID - don't automatically update it var core3 = PathObjects.createTMACoreObject(0, 10, 20, 30, false); - ((MetadataStore)core).putMetadataValue(TMACoreObject.LEGACY_KEY_UNIQUE_ID, id); + core.getMetadata().put(TMACoreObject.LEGACY_KEY_UNIQUE_ID, id); assertNull(core3.getCaseID()); // Update legacy ID during deserialization diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/measure/ObservableMeasurementTableData.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/measure/ObservableMeasurementTableData.java index 79c41438d..e2cedeb0a 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/measure/ObservableMeasurementTableData.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/measure/ObservableMeasurementTableData.java @@ -61,7 +61,7 @@ import qupath.lib.images.servers.ImageServer; import qupath.lib.images.servers.ImageServerMetadata; import qupath.lib.images.servers.PixelCalibration; -import qupath.lib.objects.MetadataStore; +import qupath.lib.interfaces.MinimalMetadataStore; import qupath.lib.objects.PathAnnotationObject; import qupath.lib.objects.PathDetectionObject; import qupath.lib.objects.PathObject; @@ -264,8 +264,8 @@ public synchronized void updateMeasurementList() { Set metadataNames = new LinkedHashSet<>(); metadataNames.addAll(builderMap.keySet()); for (PathObject pathObject : pathObjectListCopy) { - if (pathObject instanceof MetadataStore) { - metadataNames.addAll(((MetadataStore)pathObject).getMetadataKeys()); + if (pathObject instanceof MinimalMetadataStore metadataStore) { + metadataNames.addAll(metadataStore.getMetadata().keySet()); } } // Ensure we have suitable builders @@ -1347,9 +1347,8 @@ public String getName() { @Override public String getMeasurementValue(PathObject pathObject) { - if (pathObject instanceof MetadataStore) { - MetadataStore store = (MetadataStore)pathObject; - return store.getMetadataString(name); + if (pathObject instanceof MinimalMetadataStore store) { + return store.getMetadata().getOrDefault(name, null); } return null; } From cb1d0372b5efb44768a436c51120b16d4b41a714 Mon Sep 17 00:00:00 2001 From: Pete Date: Thu, 15 Aug 2024 16:06:12 +0100 Subject: [PATCH 196/866] Update ObservableMeasurementTableData.java --- .../lib/gui/measure/ObservableMeasurementTableData.java | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/measure/ObservableMeasurementTableData.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/measure/ObservableMeasurementTableData.java index e2cedeb0a..95837c4b7 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/measure/ObservableMeasurementTableData.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/measure/ObservableMeasurementTableData.java @@ -264,9 +264,7 @@ public synchronized void updateMeasurementList() { Set metadataNames = new LinkedHashSet<>(); metadataNames.addAll(builderMap.keySet()); for (PathObject pathObject : pathObjectListCopy) { - if (pathObject instanceof MinimalMetadataStore metadataStore) { - metadataNames.addAll(metadataStore.getMetadata().keySet()); - } + metadataNames.addAll(pathObject.getMetadata().keySet()); } // Ensure we have suitable builders for (String name : metadataNames) { @@ -1347,8 +1345,8 @@ public String getName() { @Override public String getMeasurementValue(PathObject pathObject) { - if (pathObject instanceof MinimalMetadataStore store) { - return store.getMetadata().getOrDefault(name, null); + if (pathObject != null) { + return pathObject.getMetadata().getOrDefault(name, null); } return null; } From 034cddcc42f290640bd76c50c250b63e79766fef Mon Sep 17 00:00:00 2001 From: Pete Date: Fri, 16 Aug 2024 07:53:01 +0100 Subject: [PATCH 197/866] Improve ProjectImageEntry metadata support * Make consistent with `Project` and `PathObject`, and deprecate older methods * Ensure consistency of the metadata map class in `ProjectImageEntry`, and make the map synchronized --- CHANGELOG.md | 9 +- .../qupath/lib/projects/DefaultProject.java | 131 ++---------------- .../lib/projects/ProjectImageEntry.java | 120 ++++++++-------- 3 files changed, 78 insertions(+), 182 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index af757f4e0..d728f0fa7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ This is a *work in progress* for the next major release. * Images remain sorted after adding new metadata values * A warning indicator is shown if image files are missing * Use the 'Skip file checks in projects' preference if you need to turn this off (e.g. your images are on a slow network) +* Project sorting is persistent when projects are re-opened (https://github.com/qupath/qupath/pull/1584) * Create a new channel as a linear combination of other channels (https://github.com/qupath/qupath/pull/1566) * Simplify `TileClassificationsToAnnotationsPlugin` implementation (https://github.com/qupath/qupath/pull/1563) * Add methods to `PathObjectHierarchy` to simplify requesting objects for regions (https://github.com/qupath/qupath/pull/1563) @@ -40,12 +41,14 @@ This is a *work in progress* for the next major release. * QuPath doesn't always use the specified file extension when exporting snapshots (https://github.com/qupath/qupath/issues/1567) ### API changes -* `PathObject` now has a `getMetadata()` method to access key/value `String` pairs - * Use judiciously! Adding metadata to any object can increase its memory footprint - +* New `Map getMetadata()` method added to `PathObject`, `Project` and `ProjectImageEntry` (https://github.com/qupath/qupath/pull/1587) + * Supports storing key/value `String` pairs + * Use judiciously with `PathObject`! Adding metadata to any object can increase its memory footprint - for detections, it is best to try to avoid adding even a single value because then no storage needs to be created. * `qupath.lib.objects.MetadataStore` is deprecated and will be removed in the next release - - along with older metadata-related methods in `PathObject`. + along with older metadata-related methods in `PathObject` and `ProjectImageEntry` + * _Extensions with subclasses may need updated for compatibity by implementing `getMetadata()`_ ### Dependency updates * Bio-Formats 7.3.1 diff --git a/qupath-core/src/main/java/qupath/lib/projects/DefaultProject.java b/qupath-core/src/main/java/qupath/lib/projects/DefaultProject.java index 3e314f081..a16b4bceb 100644 --- a/qupath-core/src/main/java/qupath/lib/projects/DefaultProject.java +++ b/qupath-core/src/main/java/qupath/lib/projects/DefaultProject.java @@ -224,11 +224,7 @@ private boolean addImage(final ProjectImageEntry entry) { } private boolean addImage(final DefaultProjectImageEntry entry) { - images.add(entry); -// if (images.containsKey(entry.getServerPath())) -// return false; -// images.put(entry.getServerPath(), entry); - return true; + return images.add(entry); } private File getFile() { @@ -304,6 +300,11 @@ public ProjectImageEntry addDuplicate(final ProjectImageEntry addDuplicate(final ProjectImageEntry getEntry(final ImageData imageData) { Object id = imageData.getProperty(IMAGE_ID); -// String id = imageData.getServer().getPath(); for (var entry : images) { if (entry.getFullProjectEntryID().equals(id)) return entry; } return null; -// return images.get(imageData.getServer().getPath()); -// return images.get(imageData.getProperty(IMAGE_ID)); } @Override public void removeImage(final ProjectImageEntry entry, boolean removeAllData) { boolean couldRemove = images.remove(entry); -// images.remove(entry.getServerPath()); // Need to make sure we only delete data if it's really inside this project! if (couldRemove && removeAllData && entry instanceof DefaultProjectImageEntry) { ((DefaultProjectImageEntry)entry).moveDataToTrash(); @@ -344,11 +341,6 @@ public synchronized void syncChanges() throws IOException { writeProject(getFile()); writePathClasses(pathClasses); setStoredMetadata(getBasePath(), metadata); -// if (file.isDirectory()) -// file = new File(dirBase, "project.qpproj"); -// var json = new GsonBuilder().setLenient().setPrettyPrinting().create().toJson(this); -// Files.writeString(file.toPath(), json); -// logger.warn("Syncing project not yet implemented!"); } @Override @@ -362,15 +354,13 @@ public void setMaskImageNames(boolean maskNames) { } /** - * Get a list of image entries for the project. + * Get an unmodifiable list of image entries for the project. * * @return */ @Override public List> getImageList() { - List> list = new ArrayList<>(images); -// list.sort(ImageEntryComparator.instance); - return list; + return Collections.unmodifiableList(images); } @@ -451,7 +441,7 @@ class DefaultProjectImageEntry implements ProjectImageEntry { /** * Map of associated metadata for the entry. */ - private Map metadata = new LinkedHashMap<>(); + private Map metadata = Collections.synchronizedMap(new LinkedHashMap<>()); /** * Store a soft reference to the thumbnail, so that it can be garbage collected if necessary. @@ -490,19 +480,8 @@ class DefaultProjectImageEntry implements ProjectImageEntry { this.entryID = entry.entryID; this.imageName = entry.imageName; this.description = entry.description; - this.metadata = entry.metadata; - } - - /** - * Copy the name, description and metadata from another entry. - * @param entry - * @throws IOException - */ - void copyPropertiesFromEntry(final ProjectImageEntry entry) throws IOException { - setImageName(entry.getImageName()); - setDescription(entry.getDescription()); - for (String key : entry.getMetadataKeys()) - putMetadataValue(key, entry.getMetadataValue(key)); + if (entry.metadata != null) + this.metadata.putAll(entry.metadata); } /** @@ -579,11 +558,6 @@ public boolean updateURIs(Map replacements) throws IOException { String getUniqueName() { return Long.toString(entryID); } - -// @Override -// public String getServerPath() { -// return serverID; -// } @Override public String getImageName() { @@ -610,26 +584,6 @@ public void setImageName(String name) { this.imageName = name; } - @Override - public String removeMetadataValue(final String key) { - return metadata.remove(key); - } - - @Override - public String getMetadataValue(final String key) { - return metadata.get(key); - } - - @Override - public String putMetadataValue(final String key, final String value) { - return metadata.put(key, value); - } - - @Override - public boolean containsMetadata(final String key) { - return metadata.containsKey(key); - } - @Override public String getDescription() { return description; @@ -641,18 +595,8 @@ public void setDescription(final String description) { } @Override - public void clearMetadata() { - this.metadata.clear(); - } - - @Override - public Map getMetadataMap() { - return Collections.unmodifiableMap(metadata); - } - - @Override - public Collection getMetadataKeys() { - return Collections.unmodifiableSet(metadata.keySet()); + public Map getMetadata() { + return metadata; } @Override @@ -1007,36 +951,15 @@ synchronized void writeProject(final File fileProject) throws IOException { Gson gson = GsonTools.getInstance(true); -// List pathClasses = project.getPathClasses(); -// JsonArray pathClassArray = null; -// if (!pathClasses.isEmpty()) { -// pathClassArray = new JsonArray(); -// for (PathClass pathClass : pathClasses) { -// JsonObject jsonEntry = new JsonObject(); -// jsonEntry.addProperty("name", pathClass.toString()); -// jsonEntry.addProperty("color", pathClass.getColor()); -// pathClassArray.add(jsonEntry); -// } -// } - JsonObject builder = new JsonObject(); builder.addProperty("version", LATEST_VERSION); builder.addProperty("createTimestamp", getCreationTimestamp()); builder.addProperty("modifyTimestamp", getModificationTimestamp()); builder.addProperty("uri", fileProject.toURI().toString()); builder.addProperty("lastID", counter.get()); -// if (pathClassArray != null) { -// builder.add("pathClasses", pathClassArray); -// } - -// JsonElement array = new JsonArray(); -// for (var entry : images.values()) { -// entry. -// builder.add("images", array); -// } + builder.add("images", gson.toJsonTree(images)); - // Write project to a new file var pathProject = fileProject.toPath(); var pathTempNew = new File(fileProject.getAbsolutePath() + ".tmp").toPath(); @@ -1044,11 +967,6 @@ synchronized void writeProject(final File fileProject) throws IOException { try (var writer = Files.newBufferedWriter(pathTempNew, StandardCharsets.UTF_8)) { gson.toJson(builder, writer); } -// // In Java 12 we could check if there is a mismatch - to avoid writing unnecessarily (and reducing the usefulness of any backup) -// if (Files.mismatch(pathTempNew, pathProject) == -1) { -// logger.debug("Project contents are unchanged - no need to overwrite file"); -// return; -// } // If we already have a project, back it up if (fileProject.exists()) { @@ -1059,26 +977,7 @@ synchronized void writeProject(final File fileProject) throws IOException { // If this succeeded, rename files logger.debug("Renaming project to {}", pathProject); - Files.move(pathTempNew, pathProject, StandardCopyOption.REPLACE_EXISTING); - - -// // TODO: Consider the (admittedly unexpected) case where the JSON is too long for a String -// var jsonString = gson.toJson(builder); -// -// // If we already have a project, back it up -// if (fileProject.exists()) { -// File fileBackup = new File(fileProject.getAbsolutePath() + ".backup"); -// if (fileProject.renameTo(fileBackup)) -// logger.debug("Existing project file backed up at {}", fileBackup.getAbsolutePath()); -// else -// logger.debug("Unable to backup existing project to {}", fileBackup.getAbsolutePath()); -// } -// -// // Write project -// logger.info("Writing project to {}", fileProject.getAbsolutePath()); -// try (PrintWriter writer = new PrintWriter(fileProject, StandardCharsets.UTF_8)) { -// writer.write(jsonString); -// } + Files.move(pathTempNew, pathProject, StandardCopyOption.REPLACE_EXISTING); } diff --git a/qupath-core/src/main/java/qupath/lib/projects/ProjectImageEntry.java b/qupath-core/src/main/java/qupath/lib/projects/ProjectImageEntry.java index 6b32b265d..fae373796 100644 --- a/qupath-core/src/main/java/qupath/lib/projects/ProjectImageEntry.java +++ b/qupath-core/src/main/java/qupath/lib/projects/ProjectImageEntry.java @@ -4,7 +4,7 @@ * %% * Copyright (C) 2014 - 2016 The Queen's University of Belfast, Northern Ireland * Contact: IP Management (ipmanagement@qub.ac.uk) - * Copyright (C) 2018 - 2020 QuPath developers, The University of Edinburgh + * Copyright (C) 2018 - 2024 QuPath developers, The University of Edinburgh * %% * QuPath is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as @@ -24,15 +24,16 @@ package qupath.lib.projects; import java.io.IOException; -import java.net.URI; import java.nio.file.Path; import java.util.Collection; +import java.util.Collections; import java.util.Map; import java.util.Map.Entry; import qupath.lib.images.ImageData; import qupath.lib.images.servers.ImageServer; import qupath.lib.images.servers.ImageServerBuilder.ServerBuilder; +import qupath.lib.interfaces.MinimalMetadataStore; import qupath.lib.io.UriResource; import qupath.lib.objects.hierarchy.PathObjectHierarchy; import qupath.lib.projects.ResourceManager.Manager; @@ -46,27 +47,20 @@ * * @param Depends upon the project used; typically BufferedImage for QuPath */ -public interface ProjectImageEntry extends UriResource { - -// /** -// * Get the path used to represent this image, which can be used to construct an ImageServer. -// * -// * @return -// */ -// public String getServerPath(); +public interface ProjectImageEntry extends UriResource, MinimalMetadataStore { /** * Get a unique ID to represent this entry. * @return */ - public String getID(); + String getID(); /** * Set the image name for this project entry. * * @param name */ - public void setImageName(String name); + void setImageName(String name); /** * Get a name that may be used for this entry. @@ -80,7 +74,7 @@ public interface ProjectImageEntry extends UriResource { * @see qupath.lib.projects.Project#setMaskImageNames(boolean) * @see qupath.lib.projects.Project#getMaskImageNames() */ - public String getImageName(); + String getImageName(); /** * Get the original image name, without any randomization. Most UI elements should prefer {@link #getImageName} to @@ -88,7 +82,7 @@ public interface ProjectImageEntry extends UriResource { * * @return */ - public String getOriginalImageName(); + String getOriginalImageName(); /** @@ -100,7 +94,7 @@ public interface ProjectImageEntry extends UriResource { * * @return */ - public Path getEntryPath(); + Path getEntryPath(); /** @@ -108,8 +102,12 @@ public interface ProjectImageEntry extends UriResource { * * @param key * @return + * @deprecated v0.6.0, use {@link #getMetadata()} instead to directly access the metadata. */ - public String removeMetadataValue(final String key); + @Deprecated + default String removeMetadataValue(final String key) { + return getMetadata().remove(key); + } /** * Request a metadata value. @@ -118,8 +116,12 @@ public interface ProjectImageEntry extends UriResource { * * @param key * @return + * @deprecated v0.6.0, use {@link #getMetadata()} instead to directly access the metadata. */ - public String getMetadataValue(final String key); + @Deprecated + default String getMetadataValue(final String key) { + return getMetadata().get(key); + } /** * Store a metadata value. @@ -130,22 +132,30 @@ public interface ProjectImageEntry extends UriResource { * @param key * @param value * @return + * @deprecated v0.6.0, use {@link #getMetadata()} instead to directly access the metadata. */ - public String putMetadataValue(final String key, final String value); + @Deprecated + default String putMetadataValue(final String key, final String value) { + return getMetadata().put(key, value); + } /** * Check if a metadata value is present for a specified key. * * @param key * @return true if getDescription() does not return null or an empty string, false otherwise. + * @deprecated v0.6.0, use {@link #getMetadata()} instead to directly access the metadata. */ - public boolean containsMetadata(final String key); + @Deprecated + default boolean containsMetadata(final String key) { + return getMetadata().containsKey(key); + } /** * Get a description; this is free text describing the image. * @return */ - public String getDescription(); + String getDescription(); /** * Set the description. @@ -153,33 +163,45 @@ public interface ProjectImageEntry extends UriResource { * @see #getDescription * @param description */ - public void setDescription(final String description); + void setDescription(final String description); /** * Remove all metadata. + * @deprecated v0.6.0, use {@link #getMetadata()} instead to directly access the metadata. */ - public void clearMetadata(); + @Deprecated + default void clearMetadata() { + getMetadata().clear(); + } /** * Get an unmodifiable view of the underlying metadata map. * * @return + * @deprecated v0.6.0, use {@link #getMetadata()} instead to directly access the metadata. */ - public Map getMetadataMap(); + @Deprecated + default Map getMetadataMap() { + return Collections.unmodifiableMap(getMetadata()); + } /** * Get an unmodifiable collection of the metadata map's keys. * * @return + * @deprecated v0.6.0, use {@link #getMetadata()} instead to directly access the metadata. */ - public Collection getMetadataKeys(); + @Deprecated + default Collection getMetadataKeys() { + return getMetadata().keySet(); + } /** * Get a {@link ServerBuilder} that can be used to open this image. * * @return */ - public ServerBuilder getServerBuilder(); + ServerBuilder getServerBuilder(); /** * Read the {@link ImageData} associated with this entry, or create a new ImageData if none is currently present. @@ -191,14 +213,14 @@ public interface ProjectImageEntry extends UriResource { * * @see #readHierarchy() */ - public ImageData readImageData() throws IOException; + ImageData readImageData() throws IOException; /** * Save the {@link ImageData} for this entry using the default storage location for the project. * @param imageData * @throws IOException */ - public void saveImageData(ImageData imageData) throws IOException; + void saveImageData(ImageData imageData) throws IOException; /** * Read the {@link PathObjectHierarchy} for this entry, or return an empty hierarchy if none is available. @@ -208,20 +230,20 @@ public interface ProjectImageEntry extends UriResource { * @see #readImageData() * @see #hasImageData() */ - public PathObjectHierarchy readHierarchy() throws IOException; + PathObjectHierarchy readHierarchy() throws IOException; /** * Check if this entry has saved {@link ImageData} already available. * * @return */ - public boolean hasImageData(); + boolean hasImageData(); /** * Get a summary string representing this image entry. * @return */ - public String getSummary(); + String getSummary(); /** * Request a thumbnail for the image. @@ -229,7 +251,7 @@ public interface ProjectImageEntry extends UriResource { * @return a thumbnail if one has already been set, otherwise null. * @throws IOException */ - public T getThumbnail() throws IOException; + T getThumbnail() throws IOException; /** * Set a thumbnail for the image. This will replace any existing thumbnail. @@ -237,45 +259,17 @@ public interface ProjectImageEntry extends UriResource { * @param img * @throws IOException */ - public void setThumbnail(T img) throws IOException; - - /** - * Get a collection of the URIs required by this project's ImageServer. - *

    - * The purpose of this is to help query if they can be found. They might not be - * e.g. if the images have been moved. - * - * @return - * @throws IOException - * @deprecated use instead {@link #getURIs()} - */ - @Deprecated - public default Collection getServerURIs() throws IOException { - return getURIs(); - } - - /** - * Update the URIs for the server (optional operation). - * - * @param replacements a map with current URIs as keys, and desired URIs as values. - * @return true if changes were made - * @throws IOException - * @deprecated use instead {@link #updateURIs(Map)} - */ - @Deprecated - public default boolean updateServerURIs(Map replacements) throws IOException { - return updateURIs(replacements); - } + void setThumbnail(T img) throws IOException; /** * Get a formatted string representation of the metadata map's contents. * * @return */ - public default String getMetadataSummaryString() { + default String getMetadataSummaryString() { StringBuilder sb = new StringBuilder(); sb.append("{"); - for (Entry entry : getMetadataMap().entrySet()) { + for (Entry entry : getMetadata().entrySet()) { if (sb.length() > 1) sb.append(", "); sb.append(entry.getKey()); @@ -293,7 +287,7 @@ public default String getMetadataSummaryString() { * * @return */ - public Manager> getImages(); + Manager> getImages(); From ce66e31d06d15c222751cf72135b7b337710f1e6 Mon Sep 17 00:00:00 2001 From: Pete Date: Fri, 16 Aug 2024 08:58:08 +0100 Subject: [PATCH 198/866] Document thread-safety & metadata conventions Suggest using "_" as the first character of metadata values that are for internal use, and update `ObservableMeasurementTableData` to adhere to this. --- .../lib/interfaces/MinimalMetadataStore.java | 5 +++++ .../java/qupath/lib/objects/PathObject.java | 19 ++++++++++++++----- .../qupath/lib/projects/DefaultProject.java | 9 ++++++++- .../ObservableMeasurementTableData.java | 7 ++++++- .../qupath/lib/gui/panes/ProjectBrowser.java | 8 ++++---- 5 files changed, 37 insertions(+), 11 deletions(-) diff --git a/qupath-core/src/main/java/qupath/lib/interfaces/MinimalMetadataStore.java b/qupath-core/src/main/java/qupath/lib/interfaces/MinimalMetadataStore.java index 520070255..56a6c8d26 100644 --- a/qupath-core/src/main/java/qupath/lib/interfaces/MinimalMetadataStore.java +++ b/qupath-core/src/main/java/qupath/lib/interfaces/MinimalMetadataStore.java @@ -25,6 +25,11 @@ /** * Minimal interface to indicate that an object can provide a map of metadata key/value pairs. + *

    + * In some use-cases, there will be a need to store some metadata values for internal use + * and others that should be visible to the user. + * When this is the case, it is suggested to use the convention that metadata keys that start with {@code "_"} + * are to be used internally, but this is not enforced by this interface. */ public interface MinimalMetadataStore { diff --git a/qupath-core/src/main/java/qupath/lib/objects/PathObject.java b/qupath-core/src/main/java/qupath/lib/objects/PathObject.java index e5dd940b0..dbe866495 100644 --- a/qupath-core/src/main/java/qupath/lib/objects/PathObject.java +++ b/qupath-core/src/main/java/qupath/lib/objects/PathObject.java @@ -73,7 +73,7 @@ public abstract class PathObject implements Externalizable, MinimalMetadataStore private Collection childList = null; // Collections.synchronizedList(new ArrayList<>(0)); private MeasurementList measurements = null; - private MetadataMap metadata = null; + private volatile MetadataMap metadata = null; private String name = null; private Integer color; @@ -1080,15 +1080,24 @@ protected void clearMetadataMap() { /** * Get a key/value pair map for object metadata. + *

    + * Note that the returned map currently is not threadsafe. + * This may change in future versions. + *

    + * When adding metadata, * @return * @since v0.5.0 - * @implNote This is an experimental API change that may be further modified before v0.5.0 is available. */ @Override public Map getMetadata() { - if (metadata == null) - metadata = new MetadataMap(); - return metadata; + var map = metadata; + if (map == null) { + synchronized (this) { + if (metadata == null) + metadata = map = new MetadataMap(); + } + } + return map; } diff --git a/qupath-core/src/main/java/qupath/lib/projects/DefaultProject.java b/qupath-core/src/main/java/qupath/lib/projects/DefaultProject.java index a16b4bceb..5ce3dd7a0 100644 --- a/qupath-core/src/main/java/qupath/lib/projects/DefaultProject.java +++ b/qupath-core/src/main/java/qupath/lib/projects/DefaultProject.java @@ -593,7 +593,14 @@ public String getDescription() { public void setDescription(final String description) { this.description = description; } - + /** + * Returns a thread-safe and modifiable map containing the metadata + * of this entry. + *

    + * Modifications to this map are saved when calling {@link #syncChanges()}. + * + * @return the metadata of this entry + */ @Override public Map getMetadata() { return metadata; diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/measure/ObservableMeasurementTableData.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/measure/ObservableMeasurementTableData.java index 95837c4b7..2138ea6f4 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/measure/ObservableMeasurementTableData.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/measure/ObservableMeasurementTableData.java @@ -264,7 +264,12 @@ public synchronized void updateMeasurementList() { Set metadataNames = new LinkedHashSet<>(); metadataNames.addAll(builderMap.keySet()); for (PathObject pathObject : pathObjectListCopy) { - metadataNames.addAll(pathObject.getMetadata().keySet()); + // Don't show metadata keys that start with "_" + pathObject.getMetadata() + .keySet() + .stream() + .filter(key -> key != null && !key.startsWith("_")) + .forEach(metadataNames::add); } // Ensure we have suitable builders for (String name : metadataNames) { diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/panes/ProjectBrowser.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/panes/ProjectBrowser.java index 15208f3e6..0aa2f29c9 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/panes/ProjectBrowser.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/panes/ProjectBrowser.java @@ -1167,7 +1167,7 @@ private List getAllImageRows() { private class ProjectImageTreeModel { - private static final String SORTING_KEY = "sortingKey"; + private static final String SORT_KEY = "_SORT_KEY"; private final ProjectTreeRowItem root; private String metadataKey; @@ -1175,7 +1175,7 @@ private ProjectImageTreeModel(final Project project) { this.root = new ProjectTreeRowItem(new ProjectTreeRow.RootRow(project)); if (project != null) { - this.metadataKey = project.getMetadata().get(SORTING_KEY); + this.metadataKey = project.getMetadata().get(SORT_KEY); } } @@ -1191,9 +1191,9 @@ private void setMetadataKey(String metadataKey) { this.metadataKey = metadataKey; if (metadataKey == null) { - project.getMetadata().remove(SORTING_KEY); + project.getMetadata().remove(SORT_KEY); } else { - project.getMetadata().put(SORTING_KEY, metadataKey); + project.getMetadata().put(SORT_KEY, metadataKey); } } From a34a934b4115ac74d51eb85e3aac359dc41b0305 Mon Sep 17 00:00:00 2001 From: Pete Date: Fri, 16 Aug 2024 14:55:51 +0100 Subject: [PATCH 199/866] Update dependencies Including gradle wrapper, so should build on Java 23 --- gradle/libs.versions.toml | 4 ++-- gradle/wrapper/gradle-wrapper.jar | Bin 43453 -> 43504 bytes gradle/wrapper/gradle-wrapper.properties | 2 +- gradlew | 5 ++++- gradlew.bat | 2 ++ 5 files changed, 9 insertions(+), 4 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0ada58d00..01c5b73e3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,7 +2,7 @@ bioformats = "7.3.1" bioimageIoSpec = "0.1.0" -omeZarrReader = "0.5.1" +omeZarrReader = "0.5.2" blosc = "1.21.5" commonmark = "0.22.0" @@ -128,4 +128,4 @@ javafx = { id = "org.openjfx.javafxplugin", version = "0 #javafx = { id = "org.openjfx.javafxplugin", version = "0.0.14" } jpackage = { id = "org.beryx.runtime", version = "1.13.0" } # Non-modular # jpackage = { id = "org.beryx.jlink", version = "2.26.0" } # Modular -license-report = { id = "com.github.jk1.dependency-license-report", version = "2.8" } +license-report = { id = "com.github.jk1.dependency-license-report", version = "2.9" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index e6441136f3d4ba8a0da8d277868979cfbc8ad796..2c3521197d7c4586c843d1d3e9090525f1898cde 100644 GIT binary patch delta 8703 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 Ojj7^59?Sdh$^Qpp*~F>< delta 8662 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 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 09523c0e5..9355b4155 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.10-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew index b740cf133..f5feea6d6 100755 --- a/gradlew +++ b/gradlew @@ -15,6 +15,8 @@ # See the License for the specific language governing permissions and # limitations under the License. # +# SPDX-License-Identifier: Apache-2.0 +# ############################################################################## # @@ -84,7 +86,8 @@ done # shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) -APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum diff --git a/gradlew.bat b/gradlew.bat index 7101f8e46..9b42019c7 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -13,6 +13,8 @@ @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem @if "%DEBUG%"=="" @echo off @rem ########################################################################## From 5498e7c564f2b61d3e8b9d6f6ce28a19c9aa529f Mon Sep 17 00:00:00 2001 From: Pete Date: Sat, 17 Aug 2024 07:07:43 +0100 Subject: [PATCH 200/866] Improve MeasurementList implementation Relates to * https://github.com/qupath/qupath/issues/1444 * https://github.com/qupath/qupath/issues/1591 partly by incorporating synchronization from https://github.com/qupath/qupath/pull/1466 and partly by making more defensive copies of measurement names (rather than returning an unmodifiable list that wraps a list that can still change). Alongside this, deprecated methods are removed from MeasurementList and there is some refactoring/cleanup. --- .../measurements/DefaultMeasurementList.java | 81 +++---- .../lib/measurements/MeasurementList.java | 208 +++++------------- .../lib/measurements/MeasurementsMap.java | 22 +- .../measurements/NumericMeasurementList.java | 88 ++++---- .../lib/objects/TestPathAnnotationObject.java | 2 +- .../qupath/lib/objects/TestPathObject.java | 4 +- 6 files changed, 131 insertions(+), 274 deletions(-) diff --git a/qupath-core/src/main/java/qupath/lib/measurements/DefaultMeasurementList.java b/qupath-core/src/main/java/qupath/lib/measurements/DefaultMeasurementList.java index 2121669a5..c6e155f56 100644 --- a/qupath-core/src/main/java/qupath/lib/measurements/DefaultMeasurementList.java +++ b/qupath-core/src/main/java/qupath/lib/measurements/DefaultMeasurementList.java @@ -4,7 +4,7 @@ * %% * Copyright (C) 2014 - 2016 The Queen's University of Belfast, Northern Ireland * Contact: IP Management (ipmanagement@qub.ac.uk) - * Copyright (C) 2018 - 2020 QuPath developers, The University of Edinburgh + * Copyright (C) 2018 - 2024 QuPath developers, The University of Edinburgh * %% * QuPath is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as @@ -24,9 +24,9 @@ package qupath.lib.measurements; import java.util.ArrayList; -import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.stream.Collectors; /** * A MeasurementList implementation that simply stores a list of Measurement objects. @@ -62,10 +62,14 @@ public synchronized void clear() { @Override public synchronized List getMeasurementNames() { - List names = new ArrayList<>(); - for (Measurement m : list) - names.add(m.getName()); - return Collections.unmodifiableList(names); + return list.stream() + .map(Measurement::getName) + .toList(); + } + + @Override + public synchronized String getMeasurementName(int ind) { + return list.get(ind).getName(); } @Override @@ -91,22 +95,6 @@ public synchronized boolean containsKey(String measurement) { return true; return false; } - - @Override - public synchronized String getMeasurementName(int ind) { - return list.get(ind).getName(); - } - -// @Override -// public void setMeasurement(int ind, double value) { -// set(ind, MeasurementFactory.createMeasurement( -// getMeasurementName(ind), value)); -// } - - @Override - public boolean supportsDynamicMeasurements() { - return true; - } @Override public int size() { @@ -118,19 +106,19 @@ public boolean isEmpty() { return list.isEmpty(); } -// @Override -// public void clear() { -// list.clear(); -// } - private void compactStorage() { list.trimToSize(); } @Override - public synchronized Measurement putMeasurement(Measurement measurement) { + public synchronized void close() { + compactStorage(); + } + + @Override + public synchronized void put(String name, double value) { // Ensure we aren't adding duplicate measurements - String name = measurement.getName(); + var measurement = MeasurementFactory.createMeasurement(name, value); int ind = 0; for (Measurement m : list) { if (m.getName().equals(name)) @@ -138,25 +126,9 @@ public synchronized Measurement putMeasurement(Measurement measurement) { ind++; } if (ind < list.size()) { - return list.set(ind, measurement); + list.set(ind, measurement); } list.add(measurement); - return null; - } - -// @Override -// public boolean remove(Object o) { -// return list.remove(o); -// } - - @Override - public synchronized void close() { - compactStorage(); - } - - @Override - public synchronized void put(String name, double value) { - putMeasurement(MeasurementFactory.createMeasurement(name, value)); } @Override @@ -187,16 +159,13 @@ public Map asMap() { @Override public synchronized String toString() { - StringBuilder sb = new StringBuilder(); - int n = size(); - sb.append("["); - for (int i = 0; i < n; i++) { - sb.append(getMeasurementName(i)).append(": ").append(getMeasurementValue(i)); - if (i < n - 1) - sb.append(", "); - } - sb.append("]"); - return sb.toString(); + return "[" + list.stream() + .map(DefaultMeasurementList::toString) + .collect(Collectors.joining(", ")) + "]"; + } + + private static String toString(Measurement m) { + return m.getName() + ": " + m.getValue(); } } \ No newline at end of file diff --git a/qupath-core/src/main/java/qupath/lib/measurements/MeasurementList.java b/qupath-core/src/main/java/qupath/lib/measurements/MeasurementList.java index 8f49ffc75..e5492e1be 100644 --- a/qupath-core/src/main/java/qupath/lib/measurements/MeasurementList.java +++ b/qupath-core/src/main/java/qupath/lib/measurements/MeasurementList.java @@ -4,7 +4,7 @@ * %% * Copyright (C) 2014 - 2016 The Queen's University of Belfast, Northern Ireland * Contact: IP Management (ipmanagement@qub.ac.uk) - * Copyright (C) 2018 - 2020 QuPath developers, The University of Edinburgh + * Copyright (C) 2018 - 2024 QuPath developers, The University of Edinburgh * %% * QuPath is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as @@ -25,28 +25,20 @@ import java.io.Serializable; import java.util.Collections; -import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Set; import org.slf4j.LoggerFactory; -import qupath.lib.common.LogTools; - /** * Interface defining a feature measurement list, consisting of key value pairs. *

    * To help enable efficiency for large sets of PathObjects requiring measurement lists, * only String keys and numeric values are included. *

    - * QuPath v0.4.0: MeasurementList was updated to have more map-like behavior, - * while still using primitive values. In particular, {@link #addMeasurement(String, double)} - * was deprecated and now simply defers to {@link #put(String, double)}. - *

    - * Additionally, the wordy {@link #putMeasurement(String, double)} and {@link #getMeasurementValue(String)} - * were deprecated in favor of {@link #put(String, double)} and {@link #get(String)} - which do the same thing, - * but with more familiar syntax. + * In QuPath v0.4.0 several methods were deprecated, and these were removed in v0.6.0. + * The main aim was to make the API more consistent and easier to use. * * @author Pete Bankhead * @@ -57,7 +49,7 @@ public interface MeasurementList extends Serializable, AutoCloseable { * Enum representing different types of measurement list, with different priorities regarding * flexibility and efficiency. */ - public enum MeasurementListType { + enum MeasurementListType { /** * A general list, which can contain any kind of measurement - at the expense of * being rather memory-hungry. @@ -73,55 +65,6 @@ public enum MeasurementListType { FLOAT } - /** - * Add a new measurement. No check is made to ensure the name is unique, and - * in general {@link #put(String, double)} is to be preferred. - * @param name - * @param value - * @return - * - * @see #put(String, double) - * @deprecated v0.4.0 use {@link #put(String, double)} instead - * @implNote Since v0.4.0 the default implementation delegates to {@link #put(String, double)}, - * and will therefore replace any existing value with the same name. - * This different behavior is introduced to facilitate moving measurement lists towards - * a map implementation for improved performance, consistency and scripting. - */ - @Deprecated - public default boolean addMeasurement(String name, double value) { - synchronized (this) { - boolean contains = containsKey(name); - var logger = LoggerFactory.getLogger(getClass()); - if (contains) { - logger.warn("Duplicate '{}' not allowed - previous measurement will be dropped (duplicate names no longer permitted since v0.4.0)", name); - logger.warn("MeasurementList.addMeasurement(String, double) is deprecated in QuPath v0.4.0 - calling putMeasurement(String, double) instead"); - } else { - LogTools.warnOnce(logger, - "MeasurementList.addMeasurement(String, double) is deprecated in QuPath v0.4.0 - calling putMeasurement(String, double) instead"); - } - put(name, value); - return contains; - } - } - - /** - * Put a measurement into the list, replacing any previous measurement with the same name. - *

    - * This is similar to add, but with a check to remove any existing measurement with the same name - * (if multiple measurements have the same name, the first will be replaced). - *

    - * While it's probably a good idea for measurements to always have unique names, for some implementations - * putMeasurement can be must slower than add or addMeasurement - so adding should be preferred if it is - * known that a measurement with the same name is not present. - * - * @param measurement - * @return - * @deprecated since v0.4.0, since there is no real need to create a {@link Measurement} object and - * we don't currently use dynamic measurements - */ - @Deprecated - public Measurement putMeasurement(Measurement measurement); - /** * Put a measurement value into the list, replacing any previous measurement with the same name. *

    @@ -132,8 +75,26 @@ public default boolean addMeasurement(String name, double value) { * @param value * @since v0.4.0 */ - public void put(String name, double value); - + void put(String name, double value); + + /** + * Get name for the measurement at the specified index in the list. + * @param ind + * @return + * @deprecated since v0.4.0; using names is preferred over indexing but {@link #getMeasurementNames()} can still be used + */ + @Deprecated + String getMeasurementName(int ind); + + /** + * Get value for the measurement at the specified index in the list. + * @param ind + * @return + * @deprecated since v0.4.0; using {@link #get(String)} is preferred over using an index + */ + @Deprecated + double getMeasurementValue(int ind); + /** * Get the specified measurement, or the provided default value if it is not contained in the list. *

    @@ -144,7 +105,7 @@ public default boolean addMeasurement(String name, double value) { * @return * @since v0.4.0 */ - public default double getOrDefault(String name, double defaultValue) { + default double getOrDefault(String name, double defaultValue) { synchronized (this) { double val = get(name); if (Double.isNaN(val)) { @@ -157,23 +118,12 @@ public default double getOrDefault(String name, double defaultValue) { } } - /** - * Query if a value with the specified name is in the list. - * @param name - * @return - * @deprecated since v0.4.0; replaced by {@link #containsKey(String)} - */ - @Deprecated - public default boolean containsNamedMeasurement(String name) { - return containsKey(name); - } - /** * Get all measurement values as a double array * @return * @since v0.4.0 */ - public default double[] values() { + default double[] values() { synchronized(this) { double[] values = new double[size()]; for (int i = 0; i < size(); i++) @@ -182,35 +132,13 @@ public default double[] values() { } } - /** - * Get the measurement with the specified name. - * @param name - * @return the value, or Double.NaN if no measurement is available with the specified name - * @deprecated since v0.4.0; use {@link #get(String)} instead - */ - @Deprecated - public default double getMeasurementValue(String name) { - return get(name); - } - - /** - * Alternative method to call {@link #putMeasurement(String, double)} - * @param name - * @param value - * @deprecated since v0.4.0; replaced by {@link #put(String, double)} - */ - @Deprecated - public default void putMeasurement(String name, double value) { - put(name, value); - } - /** * Remove a named measurement * @param name * @return the value that was removed, or Double.NaN if the value was not in the list * @since v0.4.0 */ - public default double remove(String name) { + default double remove(String name) { synchronized (this) { int sizeBefore = size(); int ind = getMeasurementNames().indexOf(name); @@ -225,11 +153,11 @@ public default double remove(String name) { } /** - * Put all the values from the specified map into this list + * Put all the values from the specified map into this list * @param map * @since v0.4.0 */ - public default void putAll(Map map) { + default void putAll(Map map) { for (var entry : map.entrySet()) { put(entry.getKey(), entry.getValue().doubleValue()); } @@ -240,10 +168,10 @@ public default void putAll(Map map) { * @param list * @since v0.4.0 */ - public default void putAll(MeasurementList list) { + default void putAll(MeasurementList list) { synchronized (list) { for (String name : list.getMeasurementNames()) { - put(name, list.getMeasurementValue(name)); + put(name, list.get(name)); } } } @@ -259,39 +187,21 @@ public default void putAll(MeasurementList list) { * a pre-v0.4.0 QuPath version is deserialized (or there is a bad bug somewhere here - if so, * please report it!). */ - public default Set keySet() { + default Set keySet() { var names = getMeasurementNames(); - var set = new LinkedHashSet<>(names); - // Shouldn't happen now that addMeasurements is ineffective... but conceivably could with legacy lists + var set = Set.copyOf(getMeasurementNames()); + // Shouldn't ever happen, but we certainly want to know if it does... if (set.size() < names.size()) { LoggerFactory.getLogger(getClass()).warn("Duplicate measurement names detected! Set size {}, list size {}", set.size(), names.size()); } - return Collections.unmodifiableSet(set); + return set; } /** * Get the names of all measurements currently in the list. * @return */ - public List getMeasurementNames(); - - /** - * Get name for the measurement at the specified index in the list. - * @param ind - * @return - * @deprecated since v0.4.0; using names is preferred over indexing but {@link #getMeasurementNames()} can still be used - */ - @Deprecated - public String getMeasurementName(int ind); - - /** - * Get value for the measurement at the specified index in the list. - * @param ind - * @return - * @deprecated since v0.4.0; using {@link #get(String)} is preferred over using an index - */ - @Deprecated - public double getMeasurementValue(int ind); + List getMeasurementNames(); /** * Get value for the measurement with the specified name. @@ -300,7 +210,7 @@ public default Set keySet() { * @see #put(String, double) * @since v0.4.0 */ - public double get(String name); + double get(String name); /** * Returns true if this list contains a measurement with the specified name. @@ -308,50 +218,46 @@ public default Set keySet() { * @return * @since v0.4.0 */ - public boolean containsKey(String name); + default boolean containsKey(String name) { + return getMeasurementNames().contains(name); + } /** * Returns true if the list does not contain any measurements. * @return */ - public boolean isEmpty(); + default boolean isEmpty() { + return size() == 0; + } /** * Returns the number of measurements in the list. * @return */ - public int size(); - - /** - * Returns true if the list supports dynamic measurements. - * Dynamic measurements can change their values, and in the interests of efficiency - * are not supported by all MeasurementList implementations. - *

    - * Use of this is strongly discouraged. - * - * @return - * @deprecated since v0.4.0; the initial implementation of dynamic measurements was never used - */ - @Deprecated - public boolean supportsDynamicMeasurements(); + default int size() { + return getMeasurementNames().size(); + } /** - * Close the list. Depending on the implementation, the list may then adjust its internal storage to be + * Close the list. + * Depending on the implementation, the list may then adjust its internal storage to be * more efficient. */ @Override - public void close(); + default void close() { + // Default implementation does nothing + } /** * Remove all the measurements with the specified names. * @param measurementNames */ - public void removeMeasurements(String... measurementNames); + void removeMeasurements(String... measurementNames); /** * Remove all the measurements from the list. */ - public void clear(); + void clear(); /** * Get a map view of this measurements list. @@ -362,15 +268,9 @@ public default Set keySet() { * @return a map view of this measurement list * @implSpec The returned map should already be synchronized. */ - public default Map asMap() { + default Map asMap() { return Collections.synchronizedMap(new MeasurementsMap(this)); } -// /** -// * Return a Map view over this list. Changes made to the map will be stored in the list. -// * @return -// */ -// public Map asMap(); - } diff --git a/qupath-core/src/main/java/qupath/lib/measurements/MeasurementsMap.java b/qupath-core/src/main/java/qupath/lib/measurements/MeasurementsMap.java index 05987fa47..087768042 100644 --- a/qupath-core/src/main/java/qupath/lib/measurements/MeasurementsMap.java +++ b/qupath-core/src/main/java/qupath/lib/measurements/MeasurementsMap.java @@ -2,7 +2,7 @@ * #%L * This file is part of QuPath. * %% - * Copyright (C) 2022 QuPath developers, The University of Edinburgh + * Copyright (C) 2022-2024 QuPath developers, The University of Edinburgh * %% * QuPath is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as @@ -37,7 +37,7 @@ */ class MeasurementsMap extends AbstractMap implements Map { - private MeasurementList list; + private final MeasurementList list; public MeasurementsMap(MeasurementList list) { this.list = list; @@ -87,12 +87,11 @@ public void clear() { @Override public Double get(Object key) { - if (!(key instanceof String)) - return null; - String name = (String)key; - synchronized(list) { - if (list.containsKey(name)) - return list.get(name); + if (key instanceof String name) { + synchronized (list) { + if (list.containsKey(name)) + return list.get(name); + } } return null; } @@ -108,7 +107,7 @@ public Double get(Object key) { public Number put(String name, Number value) { Objects.requireNonNull(value); Number current = null; - synchronized(list) { + synchronized (list) { if (list.containsKey(name)) current = list.get(name); list.put(name, value.doubleValue()); @@ -164,7 +163,9 @@ public boolean hasNext() { @Override public Entry next() { - SimpleEntry entry = new SimpleEntry<>(list.getMeasurementName(i), list.getMeasurementValue(i)); + SimpleEntry entry = new SimpleEntry<>( + list.getMeasurementName(i), + list.getMeasurementValue(i)); i++; return entry; } @@ -178,6 +179,5 @@ public void remove() { } } - } diff --git a/qupath-core/src/main/java/qupath/lib/measurements/NumericMeasurementList.java b/qupath-core/src/main/java/qupath/lib/measurements/NumericMeasurementList.java index e0013bde7..f3dfde918 100644 --- a/qupath-core/src/main/java/qupath/lib/measurements/NumericMeasurementList.java +++ b/qupath-core/src/main/java/qupath/lib/measurements/NumericMeasurementList.java @@ -4,7 +4,7 @@ * %% * Copyright (C) 2014 - 2016 The Queen's University of Belfast, Northern Ireland * Contact: IP Management (ipmanagement@qub.ac.uk) - * Copyright (C) 2018 - 2020 QuPath developers, The University of Edinburgh + * Copyright (C) 2018 - 2024 QuPath developers, The University of Edinburgh * %% * QuPath is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as @@ -46,9 +46,9 @@ * can be rather slow. Therefore while 'adding' is fast, 'putting' is not. *

    * However, upon calling {@code close()}, name lists are shared between similarly closed NumericMeasurementLists, - * and a map used to improve random access of measurements. Therefore if many lists of the same measurements - * are made, remembering to close each list when it is fully populated can improve performance and greatly - * reduce memory requirements. + * and a map used to improve random access of measurements. + * Therefore, if many lists of the same measurements are made, remembering to close each list when it is fully + * populated can improve performance and greatly reduce memory requirements. *

    * These lists can be instantiated through the {@link MeasurementListFactory} class. * @@ -59,7 +59,7 @@ class NumericMeasurementList { private static final Logger logger = LoggerFactory.getLogger(NumericMeasurementList.class); - private static Map, NameMap> namesPool = Collections.synchronizedMap(new WeakHashMap<>()); + private static final Map, NameMap> namesPool = Collections.synchronizedMap(new WeakHashMap<>()); private static class NameMap { @@ -68,7 +68,7 @@ private static class NameMap { private Map map; NameMap(List names) { - this.names = Collections.unmodifiableList(new ArrayList<>(names)); // Make a defensive copy + this.names = List.copyOf(names); // Make an unmodifiable defensive copy createHashMap(); } @@ -105,7 +105,7 @@ private abstract static class AbstractNumericMeasurementList implements Measurem private Map map; // Optional map for fast measurement lookup - private transient Map mapView; + private transient volatile Map mapView; AbstractNumericMeasurementList(int capacity) { names = new ArrayList<>(capacity); @@ -125,7 +125,7 @@ boolean isClosed() { } @Override - public void close() { + public synchronized void close() { if (isClosed()) return; compactStorage(); @@ -154,7 +154,7 @@ private NameMap getNameMap() { @Override - public boolean isEmpty() { + public synchronized boolean isEmpty() { return names.isEmpty(); } @@ -167,13 +167,13 @@ int getMeasurementIndex(String name) { // Read from map, if possible if (map != null) { Integer ind = map.get(name); - return ind == null ? -1 : ind.intValue(); + return ind == null ? -1 : ind; } return names.indexOf(name); } @Override - public final int size() { + public final synchronized int size() { return names.size(); } @@ -188,32 +188,34 @@ public synchronized List getMeasurementNames() { namesUnmodifiable = nameMap.getUnmodifiableNames(); } } - if (namesUnmodifiable == null) - namesUnmodifiable = Collections.unmodifiableList(names); - else + if (namesUnmodifiable == null) { + // We need to make a defensive copy, since the underlying list may be modified + return List.copyOf(names); +// namesUnmodifiable = Collections.unmodifiableList(names); + } else assert names.size() == namesUnmodifiable.size(); return namesUnmodifiable; } @Override - public double get(String name) { + public synchronized double get(String name) { return getMeasurementValue(getMeasurementIndex(name)); } @Override - public boolean containsKey(String measurementName) { + public synchronized boolean containsKey(String measurementName) { if (!isClosed) - logger.trace("containsNamedMeasurement called on open NumericMeasurementList - consider closing list earlier for efficiency"); + logger.trace("containsKey called on open NumericMeasurementList - consider closing list earlier for efficiency"); return names.contains(measurementName); } @Override - public String getMeasurementName(int ind) { + public synchronized String getMeasurementName(int ind) { return names.get(ind); } @Override - public void clear() { + public synchronized void clear() { ensureListOpen(); names.clear(); namesUnmodifiable = null; @@ -243,27 +245,13 @@ public synchronized void put(String name, double value) { } } - - @Override - public boolean supportsDynamicMeasurements() { - return false; - } - void compactStorage() { if (isClosed()) return; if (names instanceof ArrayList) ((ArrayList)names).trimToSize(); } - - - @Override - public Measurement putMeasurement(Measurement measurement) { - if (measurement.isDynamic()) - throw new UnsupportedOperationException("This MeasurementList does not support dynamic measurements"); - put(measurement.getName(), measurement.getValue()); - return null; - } + @Override public Map asMap() { @@ -309,7 +297,7 @@ public DoubleList(int capacity) { } @Override - public double getMeasurementValue(int ind) { + public synchronized double getMeasurementValue(int ind) { if (ind >= 0 && ind < size()) return values[ind]; return Double.NaN; @@ -321,20 +309,21 @@ private void ensureArraySize(int length) { } @Override - protected void setValue(int index, double value) { + protected synchronized void setValue(int index, double value) { ensureArraySize(index + 1); values[index] = (float)value; } @Override - public void compactStorage() { + public synchronized void compactStorage() { super.compactStorage(); - if (size() < values.length) - values = Arrays.copyOf(values, size()); + int size = size(); + if (size < values.length) + values = Arrays.copyOf(values, size); } @Override - public void removeMeasurements(String... measurementNames) { + public synchronized void removeMeasurements(String... measurementNames) { ensureListOpen(); for (String name : measurementNames) { int ind = getMeasurementIndex(name); @@ -344,7 +333,6 @@ public void removeMeasurements(String... measurementNames) { System.arraycopy(values, ind+1, values, ind, values.length-ind-1); } } - } @@ -363,33 +351,34 @@ public FloatList(int capacity) { } @Override - public double getMeasurementValue(int ind) { + public synchronized double getMeasurementValue(int ind) { if (ind >= 0 && ind < size()) return values[ind]; return Double.NaN; } - private void ensureArraySize(int length) { + private synchronized void ensureArraySize(int length) { if (values.length < length) values = Arrays.copyOf(values, Math.max(values.length + EXPAND, length)); } @Override - protected void setValue(int index, double value) { + protected synchronized void setValue(int index, double value) { ensureArraySize(index + 1); values[index] = (float)value; } @Override - public void compactStorage() { + public synchronized void compactStorage() { super.compactStorage(); - if (size() < values.length) - values = Arrays.copyOf(values, size()); + int size = size(); + if (size < values.length) + values = Arrays.copyOf(values, size); } @Override - public void removeMeasurements(String... measurementNames) { + public synchronized void removeMeasurements(String... measurementNames) { ensureListOpen(); for (String name : measurementNames) { int ind = getMeasurementIndex(name); @@ -401,6 +390,5 @@ public void removeMeasurements(String... measurementNames) { } } - -} \ No newline at end of file +} diff --git a/qupath-core/src/test/java/qupath/lib/objects/TestPathAnnotationObject.java b/qupath-core/src/test/java/qupath/lib/objects/TestPathAnnotationObject.java index d215c854c..2ca5fca4a 100644 --- a/qupath-core/src/test/java/qupath/lib/objects/TestPathAnnotationObject.java +++ b/qupath-core/src/test/java/qupath/lib/objects/TestPathAnnotationObject.java @@ -86,7 +86,7 @@ public void test_NamesAndColors() { @Test public void test_MeasurementList() { MeasurementList tML = MeasurementListFactory.createMeasurementList(16, MeasurementList.MeasurementListType.GENERAL); - tML.putMeasurement(MeasurementFactory.createMeasurement(nameML, valueML)); + tML.put(nameML, valueML); PathAnnotationObject tPO = new PathAnnotationObject(myROI, myPC, tML); test_hasMeasurements(myPO, Boolean.FALSE); // no measurements test_nMeasurements(myPO, 0); // no measurements diff --git a/qupath-core/src/test/java/qupath/lib/objects/TestPathObject.java b/qupath-core/src/test/java/qupath/lib/objects/TestPathObject.java index d91d8b8a1..6f0002046 100644 --- a/qupath-core/src/test/java/qupath/lib/objects/TestPathObject.java +++ b/qupath-core/src/test/java/qupath/lib/objects/TestPathObject.java @@ -134,9 +134,9 @@ public void test_measurementMapSynchronization(PathObject p) { @MethodSource("provideObjects") public void test_measurementMapAndList(PathObject p) { - p.getMeasurementList().addMeasurement("added", 1); + p.getMeasurementList().put("added", 1); assertEquals(1, p.getMeasurementList().size()); - p.getMeasurementList().addMeasurement("added", 2); + p.getMeasurementList().put("added", 2); assertEquals(1, p.getMeasurementList().size()); assertEquals(2, p.getMeasurementList().get("added")); From e55ca50e115def1636d094ed460deef2ebbd4dac Mon Sep 17 00:00:00 2001 From: Pete Date: Sat, 17 Aug 2024 07:13:49 +0100 Subject: [PATCH 201/866] Update DefaultMeasurementList.java Fix regression leading to duplicate measurements. --- .../java/qupath/lib/measurements/DefaultMeasurementList.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/qupath-core/src/main/java/qupath/lib/measurements/DefaultMeasurementList.java b/qupath-core/src/main/java/qupath/lib/measurements/DefaultMeasurementList.java index c6e155f56..2b6ad5d4a 100644 --- a/qupath-core/src/main/java/qupath/lib/measurements/DefaultMeasurementList.java +++ b/qupath-core/src/main/java/qupath/lib/measurements/DefaultMeasurementList.java @@ -127,8 +127,9 @@ public synchronized void put(String name, double value) { } if (ind < list.size()) { list.set(ind, measurement); + } else { + list.add(measurement); } - list.add(measurement); } @Override From f05107e8562907cf592d74712f646c4c1b69ea72 Mon Sep 17 00:00:00 2001 From: Pete Date: Sat, 17 Aug 2024 08:01:12 +0100 Subject: [PATCH 202/866] Add measurement list unit tests --- .../lib/measurements/MeasurementList.java | 2 + .../lib/measurements/TestMeasurementList.java | 142 ++++++++++++++++++ 2 files changed, 144 insertions(+) create mode 100644 qupath-core/src/test/java/qupath/lib/measurements/TestMeasurementList.java diff --git a/qupath-core/src/main/java/qupath/lib/measurements/MeasurementList.java b/qupath-core/src/main/java/qupath/lib/measurements/MeasurementList.java index e5492e1be..280fda138 100644 --- a/qupath-core/src/main/java/qupath/lib/measurements/MeasurementList.java +++ b/qupath-core/src/main/java/qupath/lib/measurements/MeasurementList.java @@ -199,6 +199,8 @@ default Set keySet() { /** * Get the names of all measurements currently in the list. + * Note that this method should return an unmodifiable snapshot of the current names, and not be affected by + * changes to the list. * @return */ List getMeasurementNames(); diff --git a/qupath-core/src/test/java/qupath/lib/measurements/TestMeasurementList.java b/qupath-core/src/test/java/qupath/lib/measurements/TestMeasurementList.java new file mode 100644 index 000000000..1299dd425 --- /dev/null +++ b/qupath-core/src/test/java/qupath/lib/measurements/TestMeasurementList.java @@ -0,0 +1,142 @@ +/*- + * #%L + * This file is part of QuPath. + * %% + * Copyright (C) 2024 QuPath developers, The University of Edinburgh + * %% + * QuPath 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 3 of the + * License, or (at your option) any later version. + * + * QuPath 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 QuPath. If not, see . + * #L% + */ + + +package qupath.lib.measurements; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +import java.util.Set; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class TestMeasurementList { + + enum ListType { + GENERAL, + DOUBLE, + FLOAT, + GENERAL_CLOSED, + DOUBLE_CLOSED, + FLOAT_CLOSED; + + private MeasurementList.MeasurementListType toMeasurementListType() { + return switch (this) { + case GENERAL, GENERAL_CLOSED -> MeasurementList.MeasurementListType.GENERAL; + case DOUBLE, DOUBLE_CLOSED -> MeasurementList.MeasurementListType.DOUBLE; + case FLOAT, FLOAT_CLOSED -> MeasurementList.MeasurementListType.FLOAT; + }; + } + + private boolean isClosed() { + return switch (this) { + case FLOAT_CLOSED, DOUBLE_CLOSED, GENERAL_CLOSED -> true; + default -> false; + }; + } + } + + private static MeasurementList createMeasurementList(ListType type, int nMeasurements) { + // Create list, permitting resize + var list = MeasurementListFactory.createMeasurementList(Math.max(1, nMeasurements / 2), type.toMeasurementListType()); + for (int i = 0; i < nMeasurements; i++) { + list.put("Measurement " + (i+1), i+1); + } + if (type.isClosed()) + list.close(); + return list; + } + + @ParameterizedTest + @EnumSource(ListType.class) + void checkNamesUnchanged(ListType type) { + var list = createMeasurementList(type, 5); + // This should give an unmodifiable snapshot of names + var names = list.getMeasurementNames(); + list.put(UUID.randomUUID().toString(), 1.0); + var names2 = list.getMeasurementNames(); + assertNotEquals(names, names2); + assertEquals(5, names.size()); + assertEquals(6, names2.size()); + } + + @ParameterizedTest + @EnumSource(ListType.class) + void test_noDuplicates(ListType type) { + var list = createMeasurementList(type, 0); + list.put("First", 1.0); + list.put("Second", 2.0); + list.put("Third", 3.0); + assertEquals(3, list.size()); + + list.put("Third", 6.0); + list.put("Second", 5.0); + list.put("First", 4.0); + assertEquals(3, list.size()); + + assertEquals(4.0, list.get("First")); + assertEquals(5.0, list.get("Second")); + assertEquals(6.0, list.get("Third")); + } + + + @ParameterizedTest + @EnumSource(ListType.class) + void test_mapAccess(ListType type) { + var list = createMeasurementList(type, 0); + var mapOriginal = list.asMap(); + + list.put("First", 1.0); + list.put("Second", 2.0); + list.put("Third", 3.0); + assertEquals(3, list.size()); + + // Ensure map requested before modification is correct + assertEquals(Set.of("First", "Second", "Third"), mapOriginal.keySet()); + assertArrayEquals(new double[]{1.0, 2.0, 3.0}, mapOriginal.values().stream().mapToDouble(Number::doubleValue).toArray()); + + // Ensure map requested after modification is correct + var map = list.asMap(); + assertEquals(Set.of("First", "Second", "Third"), map.keySet()); + assertEquals(list.keySet(), map.keySet()); + assertArrayEquals(new double[]{1.0, 2.0, 3.0}, map.values().stream().mapToDouble(Number::doubleValue).toArray()); + } + + @ParameterizedTest + @EnumSource(ListType.class) + void test_clear(ListType type) { + int n = 5; + var list = createMeasurementList(type, n); + assertEquals(n, list.size()); + assertFalse(list.isEmpty()); + list.clear(); + assertEquals(0, list.size()); + assertTrue(list.isEmpty()); + assertTrue(list.getMeasurementNames().isEmpty()); + } + +} From 66503bd074f879f3d3bfb066c947f77d99b2747a Mon Sep 17 00:00:00 2001 From: Pete Date: Sat, 17 Aug 2024 16:02:48 +0100 Subject: [PATCH 203/866] Most MeasurementList improvements & tests --- .../qupath/lib/io/QuPathTypeAdapters.java | 6 +- .../measurements/DefaultMeasurementList.java | 32 +++- .../qupath/lib/measurements/Measurement.java | 20 +-- .../lib/measurements/MeasurementFactory.java | 48 +++++- .../lib/measurements/MeasurementList.java | 60 +++---- .../lib/measurements/MeasurementsMap.java | 36 ++-- .../measurements/NumericMeasurementList.java | 154 ++++++++++++------ .../java/qupath/lib/io/TestPathObjectIO.java | 4 +- .../lib/measurements/TestMeasurementList.java | 153 ++++++++++++++++- .../lib/measurements/TestMeasurements.java | 81 +++++++++ .../lib/objects/TestPathAnnotationObject.java | 1 - .../qupath/lib/objects/TestPathObject.java | 5 +- .../java/qupath/lib/gui/tma/TMAExplorer.java | 14 +- 13 files changed, 458 insertions(+), 156 deletions(-) create mode 100644 qupath-core/src/test/java/qupath/lib/measurements/TestMeasurements.java diff --git a/qupath-core/src/main/java/qupath/lib/io/QuPathTypeAdapters.java b/qupath-core/src/main/java/qupath/lib/io/QuPathTypeAdapters.java index 06f9fd68e..ce22fa9e0 100644 --- a/qupath-core/src/main/java/qupath/lib/io/QuPathTypeAdapters.java +++ b/qupath-core/src/main/java/qupath/lib/io/QuPathTypeAdapters.java @@ -402,9 +402,9 @@ public void write(JsonWriter out, PathObject value) throws IOException { if (!measurements.isEmpty()) { out.name("Measurement count"); out.value(measurements.size()); - for (int i = 0; i < measurements.size(); i++) { - out.name(measurements.getMeasurementName(i)); - out.value(measurements.getMeasurementValue(i)); + for (var m : measurements.getMeasurements()) { + out.name(m.getName()); + out.value(m.getValue()); } } var map = value.getMetadata(); diff --git a/qupath-core/src/main/java/qupath/lib/measurements/DefaultMeasurementList.java b/qupath-core/src/main/java/qupath/lib/measurements/DefaultMeasurementList.java index 2b6ad5d4a..a95976d65 100644 --- a/qupath-core/src/main/java/qupath/lib/measurements/DefaultMeasurementList.java +++ b/qupath-core/src/main/java/qupath/lib/measurements/DefaultMeasurementList.java @@ -26,6 +26,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.stream.Collectors; /** @@ -68,14 +69,32 @@ public synchronized List getMeasurementNames() { } @Override - public synchronized String getMeasurementName(int ind) { - return list.get(ind).getName(); + public List getMeasurements() { + return List.copyOf(list); } - + + @Override + public Measurement getMeasurement(int ind) { + return list.get(ind); + } + @Override - public synchronized double getMeasurementValue(int ind) { - if (ind >= 0 && ind < size()) - return list.get(ind).getValue(); + public synchronized double[] values() { + return list.stream() + .mapToDouble(Measurement::getValue) + .toArray(); + } + + @Override + public synchronized double remove(String name) { + var iter = list.iterator(); + while (iter.hasNext()) { + var next = iter.next(); + if (next.getName().equals(name)) { + iter.remove(); + return next.getValue(); + } + } return Double.NaN; } @@ -117,6 +136,7 @@ public synchronized void close() { @Override public synchronized void put(String name, double value) { + Objects.requireNonNull(name, "Measurement name cannot be null"); // Ensure we aren't adding duplicate measurements var measurement = MeasurementFactory.createMeasurement(name, value); int ind = 0; diff --git a/qupath-core/src/main/java/qupath/lib/measurements/Measurement.java b/qupath-core/src/main/java/qupath/lib/measurements/Measurement.java index b6a7acf54..0374abbc6 100644 --- a/qupath-core/src/main/java/qupath/lib/measurements/Measurement.java +++ b/qupath-core/src/main/java/qupath/lib/measurements/Measurement.java @@ -40,20 +40,12 @@ public interface Measurement extends Serializable { * Get the name of the measurement. * @return */ - public String getName(); + String getName(); - /** - * Get the numeric value of the measurement. - * @return - */ - public double getValue(); - - /** - * Returns true if a measurement can change its value, for example because of changes in - * a object or hierarchy. - * @return - */ - @Deprecated - public boolean isDynamic(); + /** + * Get the numeric value of the measurement. + * @return + */ + double getValue(); } diff --git a/qupath-core/src/main/java/qupath/lib/measurements/MeasurementFactory.java b/qupath-core/src/main/java/qupath/lib/measurements/MeasurementFactory.java index 6acbec355..38511a20f 100644 --- a/qupath-core/src/main/java/qupath/lib/measurements/MeasurementFactory.java +++ b/qupath-core/src/main/java/qupath/lib/measurements/MeasurementFactory.java @@ -23,6 +23,8 @@ package qupath.lib.measurements; +import java.util.Objects; + /** * Factory for creating new Measurement objects. *

    @@ -33,8 +35,7 @@ * @author Pete Bankhead * */ -@Deprecated -public class MeasurementFactory { +class MeasurementFactory { /** * Create a measurement with a double value. @@ -55,6 +56,16 @@ public static Measurement createMeasurement(final String name, final double valu public static Measurement createMeasurement(final String name, final float value) { return new FloatMeasurement(name, (float)value); } + + static boolean isEqual(Measurement m1, Measurement m2) { + if (m1 == m2) + return true; + return Objects.equals(m1.getName(), m2.getName()) && m1.getValue() == m2.getValue(); + } + + static int hash(Measurement m) { + return Objects.hash(m.getName(), m.getValue()); + } } @@ -78,7 +89,7 @@ public String getName() { @Override public String toString() { - return getName() + ": " + Double.toString(getValue()); + return getName() + ": " + getValue(); } @Override @@ -87,10 +98,20 @@ public double getValue() { } @Override - public boolean isDynamic() { - return false; + public boolean equals(Object o) { + if (this == o) + return true; + if (o instanceof Measurement that) { + return MeasurementFactory.isEqual(this, that); + } else { + return false; + } } + @Override + public int hashCode() { + return MeasurementFactory.hash(this); + } } @@ -115,7 +136,7 @@ public String getName() { @Override public String toString() { - return getName() + ": " + Double.toString(getValue()); + return getName() + ": " + getValue(); } @Override @@ -124,8 +145,19 @@ public double getValue() { } @Override - public boolean isDynamic() { - return false; + public boolean equals(Object o) { + if (this == o) + return true; + if (o instanceof Measurement that) { + return MeasurementFactory.isEqual(this, that); + } else { + return false; + } + } + + @Override + public int hashCode() { + return MeasurementFactory.hash(this); } } \ No newline at end of file diff --git a/qupath-core/src/main/java/qupath/lib/measurements/MeasurementList.java b/qupath-core/src/main/java/qupath/lib/measurements/MeasurementList.java index 280fda138..cf9bcc817 100644 --- a/qupath-core/src/main/java/qupath/lib/measurements/MeasurementList.java +++ b/qupath-core/src/main/java/qupath/lib/measurements/MeasurementList.java @@ -24,6 +24,7 @@ package qupath.lib.measurements; import java.io.Serializable; +import java.util.Collection; import java.util.Collections; import java.util.List; import java.util.Map; @@ -78,22 +79,18 @@ enum MeasurementListType { void put(String name, double value); /** - * Get name for the measurement at the specified index in the list. - * @param ind + * Get an unmodifiable list of all measurements. + * This provides a snapshot of the current measurements, and should not be affected by changes to the list. * @return - * @deprecated since v0.4.0; using names is preferred over indexing but {@link #getMeasurementNames()} can still be used */ - @Deprecated - String getMeasurementName(int ind); + List getMeasurements(); /** - * Get value for the measurement at the specified index in the list. - * @param ind + * Get an immutable representation of a single measurement. + * This provides a snapshot of the current measurement, and should not be affected by changes to the list. * @return - * @deprecated since v0.4.0; using {@link #get(String)} is preferred over using an index */ - @Deprecated - double getMeasurementValue(int ind); + Measurement getMeasurement(int ind); /** * Get the specified measurement, or the provided default value if it is not contained in the list. @@ -119,18 +116,12 @@ default double getOrDefault(String name, double defaultValue) { } /** - * Get all measurement values as a double array + * Get a snapshot of all measurement values as a double array. + * Changes to the array will not impact the measurement list. * @return * @since v0.4.0 */ - default double[] values() { - synchronized(this) { - double[] values = new double[size()]; - for (int i = 0; i < size(); i++) - values[i] = getMeasurementValue(i); - return values; - } - } + double[] values(); /** * Remove a named measurement @@ -138,19 +129,7 @@ default double[] values() { * @return the value that was removed, or Double.NaN if the value was not in the list * @since v0.4.0 */ - default double remove(String name) { - synchronized (this) { - int sizeBefore = size(); - int ind = getMeasurementNames().indexOf(name); - double val = Double.NaN; - if (ind >= 0) { - val = getMeasurementValue(ind); - removeMeasurements(name); - } - assert sizeBefore == size() + 1; - return val; - } - } + double remove(String name); /** * Put all the values from the specified map into this list @@ -164,14 +143,23 @@ default void putAll(Map map) { } /** - * Put all the values from the specified list into this one + * Put all the measurements from the specified list into this one * @param list * @since v0.4.0 */ default void putAll(MeasurementList list) { - synchronized (list) { - for (String name : list.getMeasurementNames()) { - put(name, list.get(name)); + putAll(list.getMeasurements()); + } + + /** + * Put all the measurements from the specified list into this one + * @param list + * @since v0.4.0 + */ + default void putAll(Collection list) { + synchronized (this) { + for (var measurement : list) { + put(measurement.getName(), measurement.getValue()); } } } diff --git a/qupath-core/src/main/java/qupath/lib/measurements/MeasurementsMap.java b/qupath-core/src/main/java/qupath/lib/measurements/MeasurementsMap.java index 087768042..a217e4d99 100644 --- a/qupath-core/src/main/java/qupath/lib/measurements/MeasurementsMap.java +++ b/qupath-core/src/main/java/qupath/lib/measurements/MeasurementsMap.java @@ -67,17 +67,13 @@ public boolean containsKey(Object key) { @Override public boolean containsValue(Object value) { double val; - if (value instanceof Number) - val = ((Number)value).doubleValue(); + if (value instanceof Number number) + val = number.doubleValue(); else return false; - synchronized(list) { - for (int i = 0; i < list.size(); i++) { - if (list.getMeasurementValue(i) == val) - return true; - } - } - return false; + return list.getMeasurements() + .stream() + .anyMatch(m -> m.getValue() == val); } @Override @@ -132,13 +128,12 @@ public void putAll(Map map) { @Override public Double remove(Object key) { synchronized (list) { - int ind = list.getMeasurementNames().indexOf(key); - Double val = null; - if (ind >= 0) { - val = list.getMeasurementValue(ind); - list.removeMeasurements((String)key); + if (key instanceof String name) { + if (list.containsKey(name)) { + return list.remove(name); + } } - return val; + return null; } } @@ -163,16 +158,19 @@ public boolean hasNext() { @Override public Entry next() { - SimpleEntry entry = new SimpleEntry<>( - list.getMeasurementName(i), - list.getMeasurementValue(i)); + var measurement = list.getMeasurement(i); + Entry entry = new SimpleImmutableEntry<>( + measurement.getName(), measurement.getValue()); i++; return entry; } @Override public void remove() { - list.removeMeasurements(list.getMeasurementName(i - 1)); + if (i <= 0) + throw new IllegalStateException(); + list.remove(list.getMeasurementNames().get(i - 1)); + i--; } }; diff --git a/qupath-core/src/main/java/qupath/lib/measurements/NumericMeasurementList.java b/qupath-core/src/main/java/qupath/lib/measurements/NumericMeasurementList.java index f3dfde918..ac19c5ec9 100644 --- a/qupath-core/src/main/java/qupath/lib/measurements/NumericMeasurementList.java +++ b/qupath-core/src/main/java/qupath/lib/measurements/NumericMeasurementList.java @@ -23,6 +23,7 @@ package qupath.lib.measurements; +import java.lang.reflect.Array; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -30,6 +31,8 @@ import java.util.List; import java.util.Map; import java.util.WeakHashMap; +import java.util.stream.Collectors; +import java.util.stream.IntStream; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -196,11 +199,6 @@ public synchronized List getMeasurementNames() { assert names.size() == namesUnmodifiable.size(); return namesUnmodifiable; } - - @Override - public synchronized double get(String name) { - return getMeasurementValue(getMeasurementIndex(name)); - } @Override public synchronized boolean containsKey(String measurementName) { @@ -208,11 +206,6 @@ public synchronized boolean containsKey(String measurementName) { logger.trace("containsKey called on open NumericMeasurementList - consider closing list earlier for efficiency"); return names.contains(measurementName); } - - @Override - public synchronized String getMeasurementName(int ind) { - return names.get(ind); - } @Override public synchronized void clear() { @@ -233,7 +226,6 @@ void ensureListOpen() { @Override public synchronized void put(String name, double value) { - ensureListOpen(); int index = getMeasurementIndex(name); if (index >= 0) setValue(index, value); @@ -263,22 +255,42 @@ public Map asMap() { } return mapView; } - - + @Override - public String toString() { - StringBuilder sb = new StringBuilder(); - int n = size(); - sb.append("["); - for (int i = 0; i < n; i++) { - sb.append(getMeasurementName(i)).append(": ").append(getMeasurementValue(i)); - if (i < n - 1) - sb.append(", "); + public synchronized String toString() { + return "[" + getMeasurements().stream() + .map(AbstractNumericMeasurementList::toString) + .collect(Collectors.joining(", ")) + "]"; + } + + private static String toString(Measurement m) { + return m.getName() + ": " + m.getValue(); + } + + protected abstract Object getValuesArray(); + + public synchronized double remove(String name) { + int ind = getMeasurementIndex(name); + if (ind < 0) + return Double.NaN; + ensureListOpen(); + var values = getValuesArray(); + names.remove(ind); + double value = Array.getDouble(values, ind); + System.arraycopy(values, ind+1, values, ind, Array.getLength(values)-ind-1); + return value; + } + + @Override + public synchronized void removeMeasurements(String... measurementNames) { + isClosed = isClosed(); + for (String name : measurementNames) { + remove(name); } - sb.append("]"); - return sb.toString(); + if (isClosed) + close(); } - + } @@ -297,12 +309,36 @@ public DoubleList(int capacity) { } @Override - public synchronized double getMeasurementValue(int ind) { + public synchronized double get(String name) { + int ind = getMeasurementIndex(name); if (ind >= 0 && ind < size()) return values[ind]; return Double.NaN; } + @Override + public synchronized List getMeasurements() { + int n = size(); + if (n == 0) + return Collections.emptyList(); + else if (n == 1) + return List.of(getMeasurement(0)); + else + return IntStream.range(0, n) + .mapToObj(this::getMeasurement) + .toList(); + } + + @Override + public synchronized Measurement getMeasurement(int ind) { + return MeasurementFactory.createMeasurement(names.get(ind), values[ind]); + } + + @Override + public synchronized double[] values() { + return Arrays.copyOf(values, size()); + } + private void ensureArraySize(int length) { if (values.length < length) values = Arrays.copyOf(values, Math.max(values.length + EXPAND, length)); @@ -313,7 +349,7 @@ protected synchronized void setValue(int index, double value) { ensureArraySize(index + 1); values[index] = (float)value; } - + @Override public synchronized void compactStorage() { super.compactStorage(); @@ -322,18 +358,10 @@ public synchronized void compactStorage() { values = Arrays.copyOf(values, size); } - @Override - public synchronized void removeMeasurements(String... measurementNames) { - ensureListOpen(); - for (String name : measurementNames) { - int ind = getMeasurementIndex(name); - if (ind < 0) - continue; - names.remove(name); - System.arraycopy(values, ind+1, values, ind, values.length-ind-1); - } + protected Object getValuesArray() { + return values; } - + } @@ -350,13 +378,6 @@ public FloatList(int capacity) { close(); } - @Override - public synchronized double getMeasurementValue(int ind) { - if (ind >= 0 && ind < size()) - return values[ind]; - return Double.NaN; - } - private synchronized void ensureArraySize(int length) { if (values.length < length) values = Arrays.copyOf(values, Math.max(values.length + EXPAND, length)); @@ -376,17 +397,44 @@ public synchronized void compactStorage() { values = Arrays.copyOf(values, size); } + @Override + public synchronized double get(String name) { + int ind = getMeasurementIndex(name); + if (ind >= 0 && ind < size()) + return values[ind]; + return Double.NaN; + } + + @Override + public synchronized List getMeasurements() { + int n = size(); + if (n == 0) + return Collections.emptyList(); + else if (n == 1) + return List.of(getMeasurement(0)); + else + return IntStream.range(0, n) + .mapToObj(this::getMeasurement) + .toList(); + } + + @Override + public synchronized Measurement getMeasurement(int ind) { + return MeasurementFactory.createMeasurement(names.get(ind), values[ind]); + } @Override - public synchronized void removeMeasurements(String... measurementNames) { - ensureListOpen(); - for (String name : measurementNames) { - int ind = getMeasurementIndex(name); - if (ind < 0) - continue; - names.remove(name); - System.arraycopy(values, ind+1, values, ind, values.length-ind-1); - } + protected Object getValuesArray() { + return values; + } + + @Override + public synchronized double[] values() { + int n = size(); + double[] result = new double[n]; + for (int i = 0; i < n; i++) + result[i] = values[i]; + return result; } } diff --git a/qupath-core/src/test/java/qupath/lib/io/TestPathObjectIO.java b/qupath-core/src/test/java/qupath/lib/io/TestPathObjectIO.java index bf1e95439..12e23c3de 100644 --- a/qupath-core/src/test/java/qupath/lib/io/TestPathObjectIO.java +++ b/qupath-core/src/test/java/qupath/lib/io/TestPathObjectIO.java @@ -171,8 +171,8 @@ private static void assertSameMeasurements(MeasurementList ml1, MeasurementList assertEquals(ml1.size(), ml2.size()); assertEquals(ml1.getMeasurementNames(), ml2.getMeasurementNames()); for (int i = 0; i < ml1.size(); i++) { - double val1 = ml1.getMeasurementValue(i); - double val2 = ml2.getMeasurementValue(i); + double val1 = ml1.getMeasurement(i).getValue(); + double val2 = ml2.getMeasurement(i).getValue(); if (Double.isNaN(val1)) assertTrue(Double.isNaN(val2)); else diff --git a/qupath-core/src/test/java/qupath/lib/measurements/TestMeasurementList.java b/qupath-core/src/test/java/qupath/lib/measurements/TestMeasurementList.java index 1299dd425..5d5260489 100644 --- a/qupath-core/src/test/java/qupath/lib/measurements/TestMeasurementList.java +++ b/qupath-core/src/test/java/qupath/lib/measurements/TestMeasurementList.java @@ -19,7 +19,6 @@ * #L% */ - package qupath.lib.measurements; import org.junit.jupiter.params.ParameterizedTest; @@ -32,6 +31,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; public class TestMeasurementList { @@ -60,11 +60,17 @@ private boolean isClosed() { } } + /** + * Create a measurement list of the specified type, closed or not. + * @param type + * @param nMeasurements + * @return + */ private static MeasurementList createMeasurementList(ListType type, int nMeasurements) { // Create list, permitting resize var list = MeasurementListFactory.createMeasurementList(Math.max(1, nMeasurements / 2), type.toMeasurementListType()); for (int i = 0; i < nMeasurements; i++) { - list.put("Measurement " + (i+1), i+1); + list.put("Measurement " + i, i); } if (type.isClosed()) list.close(); @@ -73,7 +79,7 @@ private static MeasurementList createMeasurementList(ListType type, int nMeasure @ParameterizedTest @EnumSource(ListType.class) - void checkNamesUnchanged(ListType type) { + void test_namesSnapshot(ListType type) { var list = createMeasurementList(type, 5); // This should give an unmodifiable snapshot of names var names = list.getMeasurementNames(); @@ -139,4 +145,145 @@ void test_clear(ListType type) { assertTrue(list.getMeasurementNames().isEmpty()); } + @ParameterizedTest + @EnumSource(ListType.class) + void test_removeMissing(ListType type) { + var list = createMeasurementList(type, 0); + list.put("First", 1.0); + list.put("Second", 2.0); + list.put("Third", 3.0); + assertEquals(3, list.size()); + // Failed remove + var result = list.remove("Not found"); + assertEquals(3, list.size()); + assertTrue(Double.isNaN(result)); + } + + @ParameterizedTest + @EnumSource(ListType.class) + void test_removeFirst(ListType type) { + var list = createMeasurementList(type, 0); + list.put("First", 1.0); + list.put("Second", 2.0); + list.put("Third", 3.0); + assertEquals(3, list.size()); + // Successful remove + var result = list.remove("First"); + assertEquals(2, list.size()); + assertEquals(1.0, result); + assertFalse(list.containsKey("First")); + assertEquals(2.0, list.get("Second")); + assertEquals(3.0, list.get("Third")); + } + + @ParameterizedTest + @EnumSource(ListType.class) + void test_removeLast(ListType type) { + var list = createMeasurementList(type, 0); + list.put("First", 1.0); + list.put("Second", 2.0); + list.put("Third", 3.0); + assertEquals(3, list.size()); + // Successful remove + var result = list.remove("Third"); + assertEquals(2, list.size()); + assertEquals(3.0, result); + assertFalse(list.containsKey("Third")); + assertEquals(1.0, list.get("First")); + assertEquals(2.0, list.get("Second")); + } + + @ParameterizedTest + @EnumSource(ListType.class) + void test_removeMiddle(ListType type) { + var list = createMeasurementList(type, 0); + list.put("First", 1.0); + list.put("Second", 2.0); + list.put("Third", 3.0); + assertEquals(3, list.size()); + // Successful remove + var result = list.remove("Second"); + assertEquals(2, list.size()); + assertEquals(2.0, result); + assertFalse(list.containsKey("Second")); + assertEquals(1.0, list.get("First")); + assertEquals(3.0, list.get("Third")); + } + + @ParameterizedTest + @EnumSource(ListType.class) + void test_removeAll(ListType type) { + int n = 5; + var list = createMeasurementList(type, n); + assertEquals(n, list.size()); + // Successful remove + var names = list.getMeasurementNames(); + var toRemove = names.subList(1, 3); + list.removeMeasurements(toRemove.toArray(String[]::new)); + var newNames = list.getMeasurementNames(); + assertEquals(n - toRemove.size(), list.size()); + for (var name : toRemove) { + assertFalse(newNames.contains(name)); + } + } + + @ParameterizedTest + @EnumSource(ListType.class) + void test_putAllMeasurements(ListType type) { + int n = 5; + var list = createMeasurementList(type, n); + // Check putting measurements into all list types + for (var typeNew : ListType.values()) { + var newList = createMeasurementList(typeNew, 0); + assertEquals(0, newList.size()); + newList.putAll(list); + assertEquals(n, newList.size()); + assertEquals(list.getMeasurements(), newList.getMeasurements()); + } + } + + @ParameterizedTest + @EnumSource(ListType.class) + void test_putAllMeasurementsFromMap(ListType type) { + int n = 5; + var list = createMeasurementList(type, n); + // Check putting measurements into all list types + for (var typeNew : ListType.values()) { + var newList = createMeasurementList(typeNew, 0); + assertEquals(0, newList.size()); + newList.putAll(list.asMap()); + assertEquals(n, newList.size()); + assertEquals(list.getMeasurements(), newList.getMeasurements()); + } + } + + @ParameterizedTest + @EnumSource(ListType.class) + void test_mapEntriesImmutable(ListType type) { + int n = 5; + var list = createMeasurementList(type, n); + for (var entry : list.asMap().entrySet()) { + assertThrows(UnsupportedOperationException.class, () -> entry.setValue(0)); + } + } + + @ParameterizedTest + @EnumSource(ListType.class) + void test_mapEntryIteration(ListType type) { + int n = 5; + var list = createMeasurementList(type, n); + var entries = list.asMap().entrySet(); + var iter = entries.iterator(); + int i = 0; + while (iter.hasNext()) { + assertEquals(n - i, list.size()); + var entry = iter.next(); + iter.remove(); + i++; + assertEquals(n - i, list.size()); + } + assertEquals(n, i); + assertTrue(entries.isEmpty()); + } + } diff --git a/qupath-core/src/test/java/qupath/lib/measurements/TestMeasurements.java b/qupath-core/src/test/java/qupath/lib/measurements/TestMeasurements.java new file mode 100644 index 000000000..7f0b7c480 --- /dev/null +++ b/qupath-core/src/test/java/qupath/lib/measurements/TestMeasurements.java @@ -0,0 +1,81 @@ +/*- + * #%L + * This file is part of QuPath. + * %% + * Copyright (C) 2024 QuPath developers, The University of Edinburgh + * %% + * QuPath 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 3 of the + * License, or (at your option) any later version. + * + * QuPath 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 QuPath. If not, see . + * #L% + */ + +package qupath.lib.measurements; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; + +public class TestMeasurements { + + @Test + void test_MeasurementsEquals() { + var mDouble = MeasurementFactory.createMeasurement("m1", 1.0d); + var mDouble2 = MeasurementFactory.createMeasurement("m1", 1.0f); + var mFloat = MeasurementFactory.createMeasurement("m1", 1.0d); + var mFloat2 = MeasurementFactory.createMeasurement("m1", 1.0f); + assertEquals(mDouble, mDouble); + assertEquals(mDouble, mDouble2); + assertEquals(mFloat, mFloat); + assertEquals(mFloat, mFloat2); + assertEquals(mDouble, mFloat); + assertEquals(mDouble.hashCode(), mFloat.hashCode()); + } + + @Test + void test_MeasurementsDoubleDifferentNames() { + var mDouble = MeasurementFactory.createMeasurement("m1", 1.0d); + var mDouble2 = MeasurementFactory.createMeasurement("m2", 1.0d); + assertNotEquals(mDouble.getName(), mDouble2.getName()); + assertEquals(mDouble.getValue(), mDouble2.getValue()); + assertNotEquals(mDouble, mDouble2); + } + + @Test + void test_MeasurementsFloatDifferentNames() { + var mFloat = MeasurementFactory.createMeasurement("m1", 1.0f); + var mFloat2 = MeasurementFactory.createMeasurement("m2", 1.0f); + assertNotEquals(mFloat.getName(), mFloat2.getName()); + assertEquals(mFloat.getValue(), mFloat2.getValue()); + assertNotEquals(mFloat, mFloat2); + } + + @Test + void test_MeasurementsDoubleDifferentValues() { + var mDouble = MeasurementFactory.createMeasurement("m1", 1.0d); + var mDouble2 = MeasurementFactory.createMeasurement("m1", 2.0d); + assertEquals(mDouble.getName(), mDouble2.getName()); + assertNotEquals(mDouble.getValue(), mDouble2.getValue()); + assertNotEquals(mDouble, mDouble2); + } + + @Test + void test_MeasurementsFloatDifferentValues() { + var mFloat = MeasurementFactory.createMeasurement("m1", 1.0f); + var mFloat2 = MeasurementFactory.createMeasurement("m1", 2.0f); + assertEquals(mFloat.getName(), mFloat2.getName()); + assertNotEquals(mFloat.getValue(), mFloat2.getValue()); + assertNotEquals(mFloat, mFloat2); + } + +} diff --git a/qupath-core/src/test/java/qupath/lib/objects/TestPathAnnotationObject.java b/qupath-core/src/test/java/qupath/lib/objects/TestPathAnnotationObject.java index 2ca5fca4a..a67bc3fcd 100644 --- a/qupath-core/src/test/java/qupath/lib/objects/TestPathAnnotationObject.java +++ b/qupath-core/src/test/java/qupath/lib/objects/TestPathAnnotationObject.java @@ -29,7 +29,6 @@ import org.junit.jupiter.api.Test; -import qupath.lib.measurements.MeasurementFactory; import qupath.lib.measurements.MeasurementList; import qupath.lib.measurements.MeasurementListFactory; import qupath.lib.objects.classes.PathClass; diff --git a/qupath-core/src/test/java/qupath/lib/objects/TestPathObject.java b/qupath-core/src/test/java/qupath/lib/objects/TestPathObject.java index 6f0002046..33acd768b 100644 --- a/qupath-core/src/test/java/qupath/lib/objects/TestPathObject.java +++ b/qupath-core/src/test/java/qupath/lib/objects/TestPathObject.java @@ -196,8 +196,9 @@ private static void checkSameKeysAndValues(PathObject p) { double[] listValuesByName = new double[p.getMeasurementList().size()]; double[] listValuesAsArray = p.getMeasurementList().values(); for (int i = 0; i < listValues.length; i++) { - listValues[i] = p.getMeasurementList().getMeasurementValue(i); - listValuesByName[i] = p.getMeasurementList().get(p.getMeasurementList().getMeasurementName(i)); + var m = p.getMeasurementList().getMeasurement(i); + listValues[i] = m.getValue(); + listValuesByName[i] = p.getMeasurementList().get(m.getName()); } double[] mapValues = p.getMeasurements().values().stream().mapToDouble(v -> v.doubleValue()).toArray(); double[] mapValuesByIterator = new double[p.getMeasurements().size()]; diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/tma/TMAExplorer.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/tma/TMAExplorer.java index f80610ef9..ccd899202 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/tma/TMAExplorer.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/tma/TMAExplorer.java @@ -154,17 +154,13 @@ private void createAndShowStage() { core.isMissing()); MeasurementList ml = core.getMeasurementList(); - for (int i = 0; i < ml.size(); i++) { - String measurement = ml.getMeasurementName(i); - double val = ml.getMeasurementValue(i); + for (var m : ml.getMeasurements()) { + String measurement = m.getName(); + double val = m.getValue(); entry.putMeasurement(measurement, val); if (!Double.isNaN(val)) { - RunningStatistics stats = statsMap.get(measurement); - if (stats == null) { - stats = new RunningStatistics(); - statsMap.put(measurement, stats); - } - stats.addValue(val); + RunningStatistics stats = statsMap.computeIfAbsent(measurement, k -> new RunningStatistics()); + stats.addValue(val); } } entries.add(entry); From dfdf0cce361b670e8b2c97c896266960e0169592 Mon Sep 17 00:00:00 2001 From: Pete Date: Sat, 17 Aug 2024 16:07:58 +0100 Subject: [PATCH 204/866] getMeasurementNames() -> getNames() Makes it less unwieldy... add default (deprecated) version of getMeasurementNames() to avoid immediately breaking other code. --- .../detect/cells/SubcellularDetection.java | 2 +- .../features/DelaunayTriangulation.java | 2 +- .../measurements/DefaultMeasurementList.java | 2 +- .../lib/measurements/MeasurementList.java | 24 +++- .../lib/measurements/MeasurementsMap.java | 2 +- .../measurements/NumericMeasurementList.java | 2 +- .../qupath/lib/objects/PathObjectTools.java | 3 +- .../java/qupath/lib/io/TestPathObjectIO.java | 2 +- .../lib/measurements/TestMeasurementList.java | 10 +- .../TestMeasurementListFactory.java | 8 +- .../qupath/lib/objects/TestPathObject.java | 4 +- .../lib/objects/TestPathObjectMethods.java | 110 +++++++++--------- .../lib/gui/commands/MeasurementManager.java | 2 +- .../lib/gui/commands/TMADataImporter.java | 4 +- .../gui/scripting/DefaultScriptEditor.java | 2 +- 15 files changed, 95 insertions(+), 84 deletions(-) diff --git a/qupath-core-processing/src/main/java/qupath/imagej/detect/cells/SubcellularDetection.java b/qupath-core-processing/src/main/java/qupath/imagej/detect/cells/SubcellularDetection.java index bf3ce0f48..cfd0f6979 100644 --- a/qupath-core-processing/src/main/java/qupath/imagej/detect/cells/SubcellularDetection.java +++ b/qupath-core-processing/src/main/java/qupath/imagej/detect/cells/SubcellularDetection.java @@ -194,7 +194,7 @@ static boolean processObject(final PathObject pathObject, final ParameterList pa pathObject.clearChildObjects(); // Ensure we have no existing subcellular detection measurements - if we do, remove them - String[] existingMeasurements = pathObject.getMeasurementList().getMeasurementNames().stream().filter(n -> n.startsWith("Subcellular:")).toArray(n -> new String[n]); + String[] existingMeasurements = pathObject.getMeasurementList().getNames().stream().filter(n -> n.startsWith("Subcellular:")).toArray(n -> new String[n]); if (existingMeasurements.length > 0) { pathObject.getMeasurementList().removeMeasurements(existingMeasurements); pathObject.getMeasurementList().close(); diff --git a/qupath-core-processing/src/main/java/qupath/opencv/features/DelaunayTriangulation.java b/qupath-core-processing/src/main/java/qupath/opencv/features/DelaunayTriangulation.java index 8824b4f25..d816acfbd 100644 --- a/qupath-core-processing/src/main/java/qupath/opencv/features/DelaunayTriangulation.java +++ b/qupath-core-processing/src/main/java/qupath/opencv/features/DelaunayTriangulation.java @@ -502,7 +502,7 @@ public void addNodeMeasurements() { node.addNeighborsToSet(neighborSet, averagingSeparation); // Get the smoothed measurements now, since access is likely to be much faster we start modifying it - measurementNames = measurementList.getMeasurementNames().toArray(measurementNames); + measurementNames = measurementList.getNames().toArray(measurementNames); if (averagedMeasurements.length < measurementNames.length) averagedMeasurements = new double[measurementNames.length]; for (int i = 0; i < measurementNames.length; i++) { diff --git a/qupath-core/src/main/java/qupath/lib/measurements/DefaultMeasurementList.java b/qupath-core/src/main/java/qupath/lib/measurements/DefaultMeasurementList.java index a95976d65..9b4712eb8 100644 --- a/qupath-core/src/main/java/qupath/lib/measurements/DefaultMeasurementList.java +++ b/qupath-core/src/main/java/qupath/lib/measurements/DefaultMeasurementList.java @@ -62,7 +62,7 @@ public synchronized void clear() { } @Override - public synchronized List getMeasurementNames() { + public synchronized List getNames() { return list.stream() .map(Measurement::getName) .toList(); diff --git a/qupath-core/src/main/java/qupath/lib/measurements/MeasurementList.java b/qupath-core/src/main/java/qupath/lib/measurements/MeasurementList.java index cf9bcc817..9a748d2d0 100644 --- a/qupath-core/src/main/java/qupath/lib/measurements/MeasurementList.java +++ b/qupath-core/src/main/java/qupath/lib/measurements/MeasurementList.java @@ -167,7 +167,7 @@ default void putAll(Collection list) { /** * Get all available names as a set. * @return - * @implNote the current implementation is much less efficient than {@link #getMeasurementNames()}, + * @implNote the current implementation is much less efficient than {@link #getNames()}, * but is included to more closely resemble Map behavior. * The list of names and size of the returned set here should be identical; if they aren't, * duplicate names seem to be present and a warning is logged. @@ -176,8 +176,8 @@ default void putAll(Collection list) { * please report it!). */ default Set keySet() { - var names = getMeasurementNames(); - var set = Set.copyOf(getMeasurementNames()); + var names = getNames(); + var set = Set.copyOf(getNames()); // Shouldn't ever happen, but we certainly want to know if it does... if (set.size() < names.size()) { LoggerFactory.getLogger(getClass()).warn("Duplicate measurement names detected! Set size {}, list size {}", set.size(), names.size()); @@ -191,7 +191,19 @@ default Set keySet() { * changes to the list. * @return */ - List getMeasurementNames(); + List getNames(); + + /** + * Get the names of all measurements currently in the list. + * Note that this method should return an unmodifiable snapshot of the current names, and not be affected by + * changes to the list. + * @return + * @deprecated v0.6.0 use {@link #getNames()} instead + */ + @Deprecated + default List getMeasurementNames() { + return getNames(); + } /** * Get value for the measurement with the specified name. @@ -209,7 +221,7 @@ default Set keySet() { * @since v0.4.0 */ default boolean containsKey(String name) { - return getMeasurementNames().contains(name); + return getNames().contains(name); } /** @@ -225,7 +237,7 @@ default boolean isEmpty() { * @return */ default int size() { - return getMeasurementNames().size(); + return getNames().size(); } /** diff --git a/qupath-core/src/main/java/qupath/lib/measurements/MeasurementsMap.java b/qupath-core/src/main/java/qupath/lib/measurements/MeasurementsMap.java index a217e4d99..b366a66b5 100644 --- a/qupath-core/src/main/java/qupath/lib/measurements/MeasurementsMap.java +++ b/qupath-core/src/main/java/qupath/lib/measurements/MeasurementsMap.java @@ -169,7 +169,7 @@ public Entry next() { public void remove() { if (i <= 0) throw new IllegalStateException(); - list.remove(list.getMeasurementNames().get(i - 1)); + list.remove(list.getNames().get(i - 1)); i--; } diff --git a/qupath-core/src/main/java/qupath/lib/measurements/NumericMeasurementList.java b/qupath-core/src/main/java/qupath/lib/measurements/NumericMeasurementList.java index ac19c5ec9..1ccb224d7 100644 --- a/qupath-core/src/main/java/qupath/lib/measurements/NumericMeasurementList.java +++ b/qupath-core/src/main/java/qupath/lib/measurements/NumericMeasurementList.java @@ -181,7 +181,7 @@ public final synchronized int size() { } @Override - public synchronized List getMeasurementNames() { + public synchronized List getNames() { if (names.isEmpty()) return Collections.emptyList(); // Try to return the same unmodifiable list of names if we can - this speeds up comparisons diff --git a/qupath-core/src/main/java/qupath/lib/objects/PathObjectTools.java b/qupath-core/src/main/java/qupath/lib/objects/PathObjectTools.java index 079fd5944..6e5a71cd1 100644 --- a/qupath-core/src/main/java/qupath/lib/objects/PathObjectTools.java +++ b/qupath-core/src/main/java/qupath/lib/objects/PathObjectTools.java @@ -54,7 +54,6 @@ import qupath.lib.geom.Point2; import qupath.lib.images.ImageData; import qupath.lib.images.servers.ImageServer; -import qupath.lib.interfaces.MinimalMetadataStore; import qupath.lib.measurements.MeasurementList; import qupath.lib.objects.classes.PathClass; import qupath.lib.objects.classes.PathClassTools; @@ -2057,7 +2056,7 @@ public static Set getAvailableFeatures(final Collection list = pathObject.getMeasurementList().getMeasurementNames(); + List list = pathObject.getMeasurementList().getNames(); if (lastNames != list) featureSet.addAll(list); lastNames = list; diff --git a/qupath-core/src/test/java/qupath/lib/io/TestPathObjectIO.java b/qupath-core/src/test/java/qupath/lib/io/TestPathObjectIO.java index 12e23c3de..eac9f4ad3 100644 --- a/qupath-core/src/test/java/qupath/lib/io/TestPathObjectIO.java +++ b/qupath-core/src/test/java/qupath/lib/io/TestPathObjectIO.java @@ -169,7 +169,7 @@ private void test_IOObjectsGeoJSONImpl(boolean keepMeasurements, GeoJsonExportOp private static void assertSameMeasurements(MeasurementList ml1, MeasurementList ml2) { assertEquals(ml1.size(), ml2.size()); - assertEquals(ml1.getMeasurementNames(), ml2.getMeasurementNames()); + assertEquals(ml1.getNames(), ml2.getNames()); for (int i = 0; i < ml1.size(); i++) { double val1 = ml1.getMeasurement(i).getValue(); double val2 = ml2.getMeasurement(i).getValue(); diff --git a/qupath-core/src/test/java/qupath/lib/measurements/TestMeasurementList.java b/qupath-core/src/test/java/qupath/lib/measurements/TestMeasurementList.java index 5d5260489..8d0bf1ffa 100644 --- a/qupath-core/src/test/java/qupath/lib/measurements/TestMeasurementList.java +++ b/qupath-core/src/test/java/qupath/lib/measurements/TestMeasurementList.java @@ -82,9 +82,9 @@ private static MeasurementList createMeasurementList(ListType type, int nMeasure void test_namesSnapshot(ListType type) { var list = createMeasurementList(type, 5); // This should give an unmodifiable snapshot of names - var names = list.getMeasurementNames(); + var names = list.getNames(); list.put(UUID.randomUUID().toString(), 1.0); - var names2 = list.getMeasurementNames(); + var names2 = list.getNames(); assertNotEquals(names, names2); assertEquals(5, names.size()); assertEquals(6, names2.size()); @@ -142,7 +142,7 @@ void test_clear(ListType type) { list.clear(); assertEquals(0, list.size()); assertTrue(list.isEmpty()); - assertTrue(list.getMeasurementNames().isEmpty()); + assertTrue(list.getNames().isEmpty()); } @ParameterizedTest @@ -217,10 +217,10 @@ void test_removeAll(ListType type) { var list = createMeasurementList(type, n); assertEquals(n, list.size()); // Successful remove - var names = list.getMeasurementNames(); + var names = list.getNames(); var toRemove = names.subList(1, 3); list.removeMeasurements(toRemove.toArray(String[]::new)); - var newNames = list.getMeasurementNames(); + var newNames = list.getNames(); assertEquals(n - toRemove.size(), list.size()); for (var name : toRemove) { assertFalse(newNames.contains(name)); diff --git a/qupath-core/src/test/java/qupath/lib/measurements/TestMeasurementListFactory.java b/qupath-core/src/test/java/qupath/lib/measurements/TestMeasurementListFactory.java index 36faef497..599c216df 100644 --- a/qupath-core/src/test/java/qupath/lib/measurements/TestMeasurementListFactory.java +++ b/qupath-core/src/test/java/qupath/lib/measurements/TestMeasurementListFactory.java @@ -65,7 +65,7 @@ static void testList(MeasurementList list) { } // Change same names - assertEquals(names, list.getMeasurementNames()); + assertEquals(names, list.getNames()); // Check access Collections.shuffle(names, rand); @@ -82,7 +82,7 @@ static void testList(MeasurementList list) { list.removeMeasurements(name); } assertTrue(list.size() == names.size()); - assertTrue(list.getMeasurementNames().containsAll(names)); + assertTrue(list.getNames().containsAll(names)); assertTrue(checkAgreement(list, map, names)); // Check list after adding (some with same name as previously) @@ -102,11 +102,11 @@ static void testList(MeasurementList list) { assertTrue(checkAgreement(list, map, names)); assertTrue(list.size() == names.size()); - assertTrue(list.getMeasurementNames().containsAll(names)); + assertTrue(list.getNames().containsAll(names)); list.clear(); assertTrue(list.isEmpty()); - assertTrue(list.getMeasurementNames().isEmpty()); + assertTrue(list.getNames().isEmpty()); } diff --git a/qupath-core/src/test/java/qupath/lib/objects/TestPathObject.java b/qupath-core/src/test/java/qupath/lib/objects/TestPathObject.java index 33acd768b..40b2d3863 100644 --- a/qupath-core/src/test/java/qupath/lib/objects/TestPathObject.java +++ b/qupath-core/src/test/java/qupath/lib/objects/TestPathObject.java @@ -189,8 +189,8 @@ public void test_measurementMapAndList(PathObject p) { * @param p */ private static void checkSameKeysAndValues(PathObject p) { - assertEquals(p.getMeasurementList().getMeasurementNames(), new ArrayList<>(p.getMeasurements().keySet())); - assertEquals(new LinkedHashSet<>(p.getMeasurementList().getMeasurementNames()), p.getMeasurements().keySet()); + assertEquals(p.getMeasurementList().getNames(), new ArrayList<>(p.getMeasurements().keySet())); + assertEquals(new LinkedHashSet<>(p.getMeasurementList().getNames()), p.getMeasurements().keySet()); double[] listValues = new double[p.getMeasurementList().size()]; double[] listValuesByName = new double[p.getMeasurementList().size()]; diff --git a/qupath-core/src/test/java/qupath/lib/objects/TestPathObjectMethods.java b/qupath-core/src/test/java/qupath/lib/objects/TestPathObjectMethods.java index e3f101ce5..b01344761 100644 --- a/qupath-core/src/test/java/qupath/lib/objects/TestPathObjectMethods.java +++ b/qupath-core/src/test/java/qupath/lib/objects/TestPathObjectMethods.java @@ -1,43 +1,43 @@ -/*- - * #%L - * This file is part of QuPath. - * %% - * Copyright (C) 2014 - 2016 The Queen's University of Belfast, Northern Ireland - * Contact: IP Management (ipmanagement@qub.ac.uk) - * Copyright (C) 2018 - 2022 QuPath developers, The University of Edinburgh - * %% - * QuPath 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 3 of the - * License, or (at your option) any later version. - * - * QuPath 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 QuPath. If not, see . - * #L% - */ - +/*- + * #%L + * This file is part of QuPath. + * %% + * Copyright (C) 2014 - 2016 The Queen's University of Belfast, Northern Ireland + * Contact: IP Management (ipmanagement@qub.ac.uk) + * Copyright (C) 2018 - 2022 QuPath developers, The University of Edinburgh + * %% + * QuPath 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 3 of the + * License, or (at your option) any later version. + * + * QuPath 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 QuPath. If not, see . + * #L% + */ + package qupath.lib.objects; -import static org.junit.jupiter.api.Assertions.assertArrayEquals; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; - +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + import java.io.ByteArrayOutputStream; -import java.util.Collection; -import java.util.HashSet; +import java.util.Collection; +import java.util.HashSet; import qupath.lib.measurements.MeasurementList; -import qupath.lib.objects.classes.PathClass; +import qupath.lib.objects.classes.PathClass; import qupath.lib.roi.interfaces.ROI; - -/** - * These tests are actually called from other classes. + +/** + * These tests are actually called from other classes. */ -class TestPathObjectMethods { +class TestPathObjectMethods { private final Double epsilon = 1e-15; // error for double comparison @@ -66,14 +66,14 @@ public void test_getMeasurementList(PathObject myPO) { public void test_getMeasurementList(PathObject myPO, MeasurementList ML) { MeasurementList myPOML = myPO.getMeasurementList(); assertEquals(myPOML, ML); - } - //@Test - public void test_equalMeasurementListContent(MeasurementList ML, MeasurementList ML2) { - var keys = ML.getMeasurementNames(); - assertArrayEquals(keys.toArray(), ML2.getMeasurementNames().toArray()); - for (String name: keys) { - assertEquals(ML.get(name), ML2.get(name)); - } + } + //@Test + public void test_equalMeasurementListContent(MeasurementList ML, MeasurementList ML2) { + var keys = ML.getNames(); + assertArrayEquals(keys.toArray(), ML2.getNames().toArray()); + for (String name: keys) { + assertEquals(ML.get(name), ML2.get(name)); + } } //@Test public void test_nMeasurements(PathObject myPO, Integer nmeasurements) { @@ -147,15 +147,15 @@ public void test_isTile(PathObject myPO, Boolean istile) { //@Test public void test_isEditable(PathObject myPO, Boolean iseditable) { assertEquals(myPO.isEditable(), iseditable); - } - - /** - * This tests the child objects have the same elements, but ignores order. - * @param myPO - * @param listPO + } + + /** + * This tests the child objects have the same elements, but ignores order. + * @param myPO + * @param listPO */ //@Test - public void test_comparePathObjectListContents(PathObject myPO, Collection listPO) { + public void test_comparePathObjectListContents(PathObject myPO, Collection listPO) { assertEquals(new HashSet<>(myPO.getChildObjects()), new HashSet<>(listPO)); // assertEquals(myPO.getChildObjects(), listPO); } @@ -199,11 +199,11 @@ public void test_setName(PathObject myPO, String name) { //@Test public void test_getROI(PathObject myPO, ROI roi) { assertEquals(myPO.getROI(), roi); - } - //@Test - public void test_equalROIRegions(ROI roi, ROI roi2) { - assertEquals(roi.getAllPoints(), roi2.getAllPoints()); - assertEquals(roi.getImagePlane(), roi.getImagePlane()); + } + //@Test + public void test_equalROIRegions(ROI roi, ROI roi2) { + assertEquals(roi.getAllPoints(), roi2.getAllPoints()); + assertEquals(roi.getImagePlane(), roi.getImagePlane()); } //@Test public void test_getColorRGB(PathObject myPO, Integer colorrgb) { @@ -213,6 +213,6 @@ public void test_getColorRGB(PathObject myPO, Integer colorrgb) { public void test_setColorRGB(PathObject myPO, Integer colorrgb) { myPO.setColor(colorrgb); assertEquals((Integer)myPO.getColor(), colorrgb); - } + } } diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/MeasurementManager.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/MeasurementManager.java index da97e76dc..12075e9ed 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/MeasurementManager.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/MeasurementManager.java @@ -146,7 +146,7 @@ private synchronized void refreshMeasurements() { for (var entry : map.entrySet()) { var set = new LinkedHashSet(); for (var pathObject : entry.getValue()) - set.addAll(pathObject.getMeasurementList().getMeasurementNames()); + set.addAll(pathObject.getMeasurementList().getNames()); mapMeasurements.put(entry.getKey(), set); } if (comboBox != null) { diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/TMADataImporter.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/TMADataImporter.java index 681d6bf01..422b36cfd 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/TMADataImporter.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/TMADataImporter.java @@ -542,7 +542,7 @@ private void synchronizeCoreToFields() { core.setMissing(isMissing()); core.setName(getName()); core.setCaseID(getCaseID()); - for (String name : getMeasurementList().getMeasurementNames()) { + for (String name : getMeasurementList().getNames()) { core.getMeasurementList().put(name, getMeasurementList().get(name)); } core.getMeasurementList().close(); @@ -593,7 +593,7 @@ static String getExtendedDescription(final TMACoreObject core) { for (Entry entry : core.getMetadata().entrySet()) { sb.append(entry.getKey()).append("\t").append(entry.getValue()).append("\n"); } - for (String name : core.getMeasurementList().getMeasurementNames()) { + for (String name : core.getMeasurementList().getNames()) { sb.append(name).append("\t").append(core.getMeasurementList().get(name)).append("\n"); } return sb.toString(); diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/scripting/DefaultScriptEditor.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/scripting/DefaultScriptEditor.java index c1687e345..75319f26f 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/scripting/DefaultScriptEditor.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/scripting/DefaultScriptEditor.java @@ -2040,7 +2040,7 @@ Action createInsertAction(final String name) { measurements = imageData.getHierarchy() .getDetectionObjects() .stream() - .flatMap(d -> d.getMeasurementList().getMeasurementNames().stream()) + .flatMap(d -> d.getMeasurementList().getNames().stream()) .distinct() .map(m -> "\"" + m + "\"") .collect(Collectors.joining(join)) From af6854679dfe84db09f87a8a0839de3f68a0c043 Mon Sep 17 00:00:00 2001 From: Pete Date: Sat, 17 Aug 2024 16:13:17 +0100 Subject: [PATCH 205/866] Most measurement/map tests --- .../lib/measurements/TestMeasurementList.java | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/qupath-core/src/test/java/qupath/lib/measurements/TestMeasurementList.java b/qupath-core/src/test/java/qupath/lib/measurements/TestMeasurementList.java index 8d0bf1ffa..11d06c59c 100644 --- a/qupath-core/src/test/java/qupath/lib/measurements/TestMeasurementList.java +++ b/qupath-core/src/test/java/qupath/lib/measurements/TestMeasurementList.java @@ -286,4 +286,54 @@ void test_mapEntryIteration(ListType type) { assertTrue(entries.isEmpty()); } + @ParameterizedTest + @EnumSource(ListType.class) + void test_mapKeyIteration(ListType type) { + int n = 5; + var list = createMeasurementList(type, n); + var keys = list.asMap().keySet(); + var iter = keys.iterator(); + int i = 0; + while (iter.hasNext()) { + assertEquals(n - i, list.size()); + var entry = iter.next(); + iter.remove(); + i++; + assertEquals(n - i, list.size()); + } + assertEquals(n, i); + assertTrue(keys.isEmpty()); + } + + @ParameterizedTest + @EnumSource(ListType.class) + void test_mapKeyRemoval(ListType type) { + int n = 5; + var list = createMeasurementList(type, n); + var names = list.getNames(); + var toRemove = names.subList(1, 3); + var keys = list.asMap().keySet(); + keys.removeAll(toRemove); + assertEquals(n-2, list.size()); + } + + @ParameterizedTest + @EnumSource(ListType.class) + void test_mapValueIteration(ListType type) { + int n = 5; + var list = createMeasurementList(type, n); + var values = list.asMap().values(); + var iter = values.iterator(); + int i = 0; + while (iter.hasNext()) { + assertEquals(n - i, list.size()); + var entry = iter.next(); + iter.remove(); + i++; + assertEquals(n - i, list.size()); + } + assertEquals(n, i); + assertTrue(values.isEmpty()); + } + } From a09954013b2596b1553407fa8923744d6b8f1b2e Mon Sep 17 00:00:00 2001 From: Pete Date: Sat, 17 Aug 2024 16:23:25 +0100 Subject: [PATCH 206/866] removeMeasurements -> removeAll --- .../imagej/detect/cells/SubcellularDetection.java | 2 +- .../src/main/java/qupath/lib/scripting/QP.java | 2 +- .../lib/measurements/DefaultMeasurementList.java | 4 ++-- .../qupath/lib/measurements/MeasurementList.java | 14 ++++++++++++-- .../qupath/lib/measurements/MeasurementsMap.java | 2 +- .../lib/measurements/NumericMeasurementList.java | 14 +++++++------- .../test/java/qupath/lib/io/TestPathObjectIO.java | 4 ++-- .../lib/measurements/TestMeasurementList.java | 2 +- .../measurements/TestMeasurementListFactory.java | 2 +- .../java/qupath/lib/objects/TestPathObject.java | 6 +++--- 10 files changed, 31 insertions(+), 21 deletions(-) diff --git a/qupath-core-processing/src/main/java/qupath/imagej/detect/cells/SubcellularDetection.java b/qupath-core-processing/src/main/java/qupath/imagej/detect/cells/SubcellularDetection.java index cfd0f6979..23b0c247e 100644 --- a/qupath-core-processing/src/main/java/qupath/imagej/detect/cells/SubcellularDetection.java +++ b/qupath-core-processing/src/main/java/qupath/imagej/detect/cells/SubcellularDetection.java @@ -196,7 +196,7 @@ static boolean processObject(final PathObject pathObject, final ParameterList pa // Ensure we have no existing subcellular detection measurements - if we do, remove them String[] existingMeasurements = pathObject.getMeasurementList().getNames().stream().filter(n -> n.startsWith("Subcellular:")).toArray(n -> new String[n]); if (existingMeasurements.length > 0) { - pathObject.getMeasurementList().removeMeasurements(existingMeasurements); + pathObject.getMeasurementList().removeAll(existingMeasurements); pathObject.getMeasurementList().close(); } diff --git a/qupath-core-processing/src/main/java/qupath/lib/scripting/QP.java b/qupath-core-processing/src/main/java/qupath/lib/scripting/QP.java index 7016a1fb0..117d5b2ec 100644 --- a/qupath-core-processing/src/main/java/qupath/lib/scripting/QP.java +++ b/qupath-core-processing/src/main/java/qupath/lib/scripting/QP.java @@ -2975,7 +2975,7 @@ public static void removeMeasurements(final PathObjectHierarchy hierarchy, final continue; // Remove the measurements try (var ml = pathObject.getMeasurementList()) { - ml.removeMeasurements(measurementNames); + ml.removeAll(measurementNames); } } hierarchy.fireObjectMeasurementsChangedEvent(null, pathObjects); diff --git a/qupath-core/src/main/java/qupath/lib/measurements/DefaultMeasurementList.java b/qupath-core/src/main/java/qupath/lib/measurements/DefaultMeasurementList.java index 9b4712eb8..7885cf727 100644 --- a/qupath-core/src/main/java/qupath/lib/measurements/DefaultMeasurementList.java +++ b/qupath-core/src/main/java/qupath/lib/measurements/DefaultMeasurementList.java @@ -74,7 +74,7 @@ public List getMeasurements() { } @Override - public Measurement getMeasurement(int ind) { + public Measurement getByIndex(int ind) { return list.get(ind); } @@ -153,7 +153,7 @@ public synchronized void put(String name, double value) { } @Override - public synchronized void removeMeasurements(String... measurementNames) { + public synchronized void removeAll(String... measurementNames) { for (String name : measurementNames) { int ind = 0; for (Measurement m : list) { diff --git a/qupath-core/src/main/java/qupath/lib/measurements/MeasurementList.java b/qupath-core/src/main/java/qupath/lib/measurements/MeasurementList.java index 9a748d2d0..6023af4bb 100644 --- a/qupath-core/src/main/java/qupath/lib/measurements/MeasurementList.java +++ b/qupath-core/src/main/java/qupath/lib/measurements/MeasurementList.java @@ -90,7 +90,7 @@ enum MeasurementListType { * This provides a snapshot of the current measurement, and should not be affected by changes to the list. * @return */ - Measurement getMeasurement(int ind); + Measurement getByIndex(int ind); /** * Get the specified measurement, or the provided default value if it is not contained in the list. @@ -254,7 +254,17 @@ default void close() { * Remove all the measurements with the specified names. * @param measurementNames */ - void removeMeasurements(String... measurementNames); + void removeAll(String... measurementNames); + + /** + * Remove all the measurements with the specified names. + * @param measurementNames + * @deprecated v0.6.0 use {@link #removeAll(String...)} instead + */ + @Deprecated + default void removeMeasurements(String... measurementNames) { + removeAll(measurementNames); + } /** * Remove all the measurements from the list. diff --git a/qupath-core/src/main/java/qupath/lib/measurements/MeasurementsMap.java b/qupath-core/src/main/java/qupath/lib/measurements/MeasurementsMap.java index b366a66b5..3eb8078e9 100644 --- a/qupath-core/src/main/java/qupath/lib/measurements/MeasurementsMap.java +++ b/qupath-core/src/main/java/qupath/lib/measurements/MeasurementsMap.java @@ -158,7 +158,7 @@ public boolean hasNext() { @Override public Entry next() { - var measurement = list.getMeasurement(i); + var measurement = list.getByIndex(i); Entry entry = new SimpleImmutableEntry<>( measurement.getName(), measurement.getValue()); i++; diff --git a/qupath-core/src/main/java/qupath/lib/measurements/NumericMeasurementList.java b/qupath-core/src/main/java/qupath/lib/measurements/NumericMeasurementList.java index 1ccb224d7..ca4a22b8f 100644 --- a/qupath-core/src/main/java/qupath/lib/measurements/NumericMeasurementList.java +++ b/qupath-core/src/main/java/qupath/lib/measurements/NumericMeasurementList.java @@ -282,7 +282,7 @@ public synchronized double remove(String name) { } @Override - public synchronized void removeMeasurements(String... measurementNames) { + public synchronized void removeAll(String... measurementNames) { isClosed = isClosed(); for (String name : measurementNames) { remove(name); @@ -322,15 +322,15 @@ public synchronized List getMeasurements() { if (n == 0) return Collections.emptyList(); else if (n == 1) - return List.of(getMeasurement(0)); + return List.of(getByIndex(0)); else return IntStream.range(0, n) - .mapToObj(this::getMeasurement) + .mapToObj(this::getByIndex) .toList(); } @Override - public synchronized Measurement getMeasurement(int ind) { + public synchronized Measurement getByIndex(int ind) { return MeasurementFactory.createMeasurement(names.get(ind), values[ind]); } @@ -411,15 +411,15 @@ public synchronized List getMeasurements() { if (n == 0) return Collections.emptyList(); else if (n == 1) - return List.of(getMeasurement(0)); + return List.of(getByIndex(0)); else return IntStream.range(0, n) - .mapToObj(this::getMeasurement) + .mapToObj(this::getByIndex) .toList(); } @Override - public synchronized Measurement getMeasurement(int ind) { + public synchronized Measurement getByIndex(int ind) { return MeasurementFactory.createMeasurement(names.get(ind), values[ind]); } diff --git a/qupath-core/src/test/java/qupath/lib/io/TestPathObjectIO.java b/qupath-core/src/test/java/qupath/lib/io/TestPathObjectIO.java index eac9f4ad3..28b7bb773 100644 --- a/qupath-core/src/test/java/qupath/lib/io/TestPathObjectIO.java +++ b/qupath-core/src/test/java/qupath/lib/io/TestPathObjectIO.java @@ -171,8 +171,8 @@ private static void assertSameMeasurements(MeasurementList ml1, MeasurementList assertEquals(ml1.size(), ml2.size()); assertEquals(ml1.getNames(), ml2.getNames()); for (int i = 0; i < ml1.size(); i++) { - double val1 = ml1.getMeasurement(i).getValue(); - double val2 = ml2.getMeasurement(i).getValue(); + double val1 = ml1.getByIndex(i).getValue(); + double val2 = ml2.getByIndex(i).getValue(); if (Double.isNaN(val1)) assertTrue(Double.isNaN(val2)); else diff --git a/qupath-core/src/test/java/qupath/lib/measurements/TestMeasurementList.java b/qupath-core/src/test/java/qupath/lib/measurements/TestMeasurementList.java index 11d06c59c..d497df065 100644 --- a/qupath-core/src/test/java/qupath/lib/measurements/TestMeasurementList.java +++ b/qupath-core/src/test/java/qupath/lib/measurements/TestMeasurementList.java @@ -219,7 +219,7 @@ void test_removeAll(ListType type) { // Successful remove var names = list.getNames(); var toRemove = names.subList(1, 3); - list.removeMeasurements(toRemove.toArray(String[]::new)); + list.removeAll(toRemove.toArray(String[]::new)); var newNames = list.getNames(); assertEquals(n - toRemove.size(), list.size()); for (var name : toRemove) { diff --git a/qupath-core/src/test/java/qupath/lib/measurements/TestMeasurementListFactory.java b/qupath-core/src/test/java/qupath/lib/measurements/TestMeasurementListFactory.java index 599c216df..c3388604f 100644 --- a/qupath-core/src/test/java/qupath/lib/measurements/TestMeasurementListFactory.java +++ b/qupath-core/src/test/java/qupath/lib/measurements/TestMeasurementListFactory.java @@ -79,7 +79,7 @@ static void testList(MeasurementList list) { // Check access after removing for (int i = 0; i < 10; i++) { String name = names.remove(i); - list.removeMeasurements(name); + list.removeAll(name); } assertTrue(list.size() == names.size()); assertTrue(list.getNames().containsAll(names)); diff --git a/qupath-core/src/test/java/qupath/lib/objects/TestPathObject.java b/qupath-core/src/test/java/qupath/lib/objects/TestPathObject.java index 40b2d3863..10670d602 100644 --- a/qupath-core/src/test/java/qupath/lib/objects/TestPathObject.java +++ b/qupath-core/src/test/java/qupath/lib/objects/TestPathObject.java @@ -162,11 +162,11 @@ public void test_measurementMapAndList(PathObject p) { // Not expected to pass! val is unboxed internally, precise value not stored // assertSame(val, p.getMeasurements().get("mapAdded")); - p.getMeasurementList().removeMeasurements("Not there"); + p.getMeasurementList().removeAll("Not there"); assertEquals(3, p.getMeasurementList().size()); assertEquals(3, p.getMeasurements().size()); - p.getMeasurementList().removeMeasurements("put"); + p.getMeasurementList().removeAll("put"); assertEquals(2, p.getMeasurementList().size()); assertEquals(2, p.getMeasurements().size()); @@ -196,7 +196,7 @@ private static void checkSameKeysAndValues(PathObject p) { double[] listValuesByName = new double[p.getMeasurementList().size()]; double[] listValuesAsArray = p.getMeasurementList().values(); for (int i = 0; i < listValues.length; i++) { - var m = p.getMeasurementList().getMeasurement(i); + var m = p.getMeasurementList().getByIndex(i); listValues[i] = m.getValue(); listValuesByName[i] = p.getMeasurementList().get(m.getName()); } From 160620a12e02332fe9dbd33e616590e523e7c8e5 Mon Sep 17 00:00:00 2001 From: Pete Date: Sat, 17 Aug 2024 16:34:50 +0100 Subject: [PATCH 207/866] Remove default MeasurementList.asMap() --- .../qupath/lib/measurements/DefaultMeasurementList.java | 5 +++-- .../main/java/qupath/lib/measurements/MeasurementList.java | 6 ++---- .../qupath/lib/measurements/NumericMeasurementList.java | 2 +- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/qupath-core/src/main/java/qupath/lib/measurements/DefaultMeasurementList.java b/qupath-core/src/main/java/qupath/lib/measurements/DefaultMeasurementList.java index 7885cf727..92776e339 100644 --- a/qupath-core/src/main/java/qupath/lib/measurements/DefaultMeasurementList.java +++ b/qupath-core/src/main/java/qupath/lib/measurements/DefaultMeasurementList.java @@ -24,6 +24,7 @@ package qupath.lib.measurements; import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Objects; @@ -46,7 +47,7 @@ class DefaultMeasurementList implements MeasurementList { private ArrayList list; - private transient Map mapView; + private transient volatile Map mapView; DefaultMeasurementList() { list = new ArrayList<>(); @@ -172,7 +173,7 @@ public Map asMap() { if (mapView == null) { synchronized(this) { if (mapView == null) - mapView = new MeasurementsMap(this); + mapView = Collections.synchronizedMap(new MeasurementsMap(this)); } } return mapView; diff --git a/qupath-core/src/main/java/qupath/lib/measurements/MeasurementList.java b/qupath-core/src/main/java/qupath/lib/measurements/MeasurementList.java index 6023af4bb..3f55243b1 100644 --- a/qupath-core/src/main/java/qupath/lib/measurements/MeasurementList.java +++ b/qupath-core/src/main/java/qupath/lib/measurements/MeasurementList.java @@ -280,9 +280,7 @@ default void removeMeasurements(String... measurementNames) { * @return a map view of this measurement list * @implSpec The returned map should already be synchronized. */ - default Map asMap() { - return Collections.synchronizedMap(new MeasurementsMap(this)); - } - + Map asMap(); + } diff --git a/qupath-core/src/main/java/qupath/lib/measurements/NumericMeasurementList.java b/qupath-core/src/main/java/qupath/lib/measurements/NumericMeasurementList.java index ca4a22b8f..f3b261035 100644 --- a/qupath-core/src/main/java/qupath/lib/measurements/NumericMeasurementList.java +++ b/qupath-core/src/main/java/qupath/lib/measurements/NumericMeasurementList.java @@ -250,7 +250,7 @@ public Map asMap() { if (mapView == null) { synchronized(this) { if (mapView == null) - mapView = new MeasurementsMap(this); + mapView = Collections.synchronizedMap(new MeasurementsMap(this)); } } return mapView; From f04d727470c49ce4bc0fab3170d2e361acfa5625 Mon Sep 17 00:00:00 2001 From: Pete Date: Sat, 17 Aug 2024 16:47:24 +0100 Subject: [PATCH 208/866] Use String.intern() with measurement lists --- .../lib/measurements/NumericMeasurementList.java | 2 +- .../lib/measurements/TestMeasurementList.java | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/qupath-core/src/main/java/qupath/lib/measurements/NumericMeasurementList.java b/qupath-core/src/main/java/qupath/lib/measurements/NumericMeasurementList.java index f3b261035..5daeac67e 100644 --- a/qupath-core/src/main/java/qupath/lib/measurements/NumericMeasurementList.java +++ b/qupath-core/src/main/java/qupath/lib/measurements/NumericMeasurementList.java @@ -232,7 +232,7 @@ public synchronized void put(String name, double value) { else { // If the list is closed, we have to reopen it ensureListOpen(); - names.add(name); + names.add(name.intern()); setValue(size()-1, value); } } diff --git a/qupath-core/src/test/java/qupath/lib/measurements/TestMeasurementList.java b/qupath-core/src/test/java/qupath/lib/measurements/TestMeasurementList.java index d497df065..476d7b683 100644 --- a/qupath-core/src/test/java/qupath/lib/measurements/TestMeasurementList.java +++ b/qupath-core/src/test/java/qupath/lib/measurements/TestMeasurementList.java @@ -336,4 +336,17 @@ void test_mapValueIteration(ListType type) { assertTrue(values.isEmpty()); } + @ParameterizedTest + @EnumSource(ListType.class) + void test_stringInterning(ListType type) { + var list = createMeasurementList(type, 1); + var list2 = createMeasurementList(type, 1); + // Shouldn't normally test strings like this! + // But here we want to make sure that String.intern() has been called to keep + // memory usage down. + // Interning is less important when lists are 'closed', so names are reused, + // but if the user forgets to do this then memory usage could become *much* higher. + assertTrue(list.getNames().get(0) == list2.getNames().get(0)); + } + } From 4831e46435a3167fe3f23e49e036c092c726f6b8 Mon Sep 17 00:00:00 2001 From: Pete Date: Sat, 17 Aug 2024 16:54:35 +0100 Subject: [PATCH 209/866] Update CHANGELOG.md --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d728f0fa7..041972d74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,9 @@ This is a *work in progress* for the next major release. * `qupath.lib.objects.MetadataStore` is deprecated and will be removed in the next release - along with older metadata-related methods in `PathObject` and `ProjectImageEntry` * _Extensions with subclasses may need updated for compatibity by implementing `getMetadata()`_ +* The `MeasurementList` interface has been substantially revised (https://github.com/qupath/qupath/pulls) + * Removed methods had been deprecated since v0.4.0; the changes mostly focus on simplifying the API, improving + thread-safety, and squashing some bugs. ### Dependency updates * Bio-Formats 7.3.1 From 812fdeb47e1dccc786d526153685ce6ef3f026ba Mon Sep 17 00:00:00 2001 From: Pete Date: Sat, 17 Aug 2024 17:18:28 +0100 Subject: [PATCH 210/866] Update PathObject.java Remove deprecated methods. --- .../java/qupath/lib/objects/PathObject.java | 98 ++----------------- 1 file changed, 7 insertions(+), 91 deletions(-) diff --git a/qupath-core/src/main/java/qupath/lib/objects/PathObject.java b/qupath-core/src/main/java/qupath/lib/objects/PathObject.java index dbe866495..447259ef8 100644 --- a/qupath-core/src/main/java/qupath/lib/objects/PathObject.java +++ b/qupath-core/src/main/java/qupath/lib/objects/PathObject.java @@ -301,17 +301,7 @@ public synchronized void addChildObject(PathObject pathObject) { throw new IllegalArgumentException("PathRootObject cannot be added as child to another PathObject"); //J addChildObjectImpl(pathObject); } - - /** - * Legacy method to add a single child object. - * @param pathObject - * @deprecated since v0.4.0, replaced by {@link #addChildObject(PathObject)} - */ - @Deprecated - public void addPathObject(PathObject pathObject) { - LogTools.warnOnce(logger, "addPathObject(Collection) is deprecated - use addChildObject(Collection) instead"); - addChildObject(pathObject); - } + private synchronized void addChildObjectImpl(PathObject pathObject) { ensureChildList(nChildObjects() + 1); @@ -411,17 +401,7 @@ private static void removeAllQuickly(Collection list, Collection toRem public synchronized void addChildObjects(Collection pathObjects) { addChildObjectsImpl(pathObjects); } - - /** - * Legacy method to add child objects. - * @param pathObjects - * @deprecated since v0.4.0, replaced by {@link #addChildObjects(Collection)} - */ - @Deprecated - public void addPathObjects(Collection pathObjects) { - LogTools.warnOnce(logger, "addPathObjects(Collection) is deprecated - use addChildObjects(Collection) instead"); - addChildObjects(pathObjects); - } + /** * Remove a single object from the child list of this object. @@ -435,17 +415,7 @@ public void removeChildObject(PathObject pathObject) { pathObject.parent = null; //.setParent(null); childList.remove(pathObject); } - - /** - * Legacy method to remove a single child object. - * @param pathObject - * @deprecated since v0.4.0, replaced by {@link #removeChildObject(PathObject)} - */ - @Deprecated - public void removePathObject(PathObject pathObject) { - LogTools.warnOnce(logger, "removePathObject(PathObject) is deprecated - use removeChildObject(PathObject) instead"); - removeChildObject(pathObject); - } + /** * Remove multiple objects from the child list of this object. @@ -463,17 +433,7 @@ public synchronized void removeChildObjects(Collection pathObjects) removeAllQuickly(childList, pathObjects); } } - - /** - * Legacy method to remove specified child objects. - * @param pathObjects - * @deprecated since v0.4.0, replaced by {@link #removeChildObjects(Collection)} - */ - @Deprecated - public void removePathObjects(Collection pathObjects) { - LogTools.warnOnce(logger, "removePathObjects(Collection) is deprecated - use removeChildObjects(Collection) instead"); - removeChildObjects(pathObjects); - } + /** * Remove all child objects. @@ -490,16 +450,7 @@ public void clearChildObjects() { childList.clear(); } } - - /** - * Legacy method to remove all child objects. - * @deprecated since v0.4.0, replaced by {@link #clearChildObjects()} - */ - @Deprecated - public void clearPathObjects() { - LogTools.warnOnce(logger, "clearPathObjects() is deprecated, use clearChildObjects() instead"); - clearChildObjects(); - } + /** * Total number of child objects. @@ -535,22 +486,12 @@ public int nDescendants() { /** * Check if this object has children, or if its child object list is empty. * @return - * @since v0.4.0, replaces {@link #hasChildren()} for more consistent naming + * @since v0.4.0 */ public boolean hasChildObjects() { return childList != null && !childList.isEmpty(); } - - /** - * Legacy method to check for child objects. - * @return - * @deprecated since v0.4.0, replaced by {@link #hasChildObjects()} - */ - @Deprecated - public boolean hasChildren() { - LogTools.warnOnce(logger, "hasChildren() is deprecated - use hasChildObjects() instead"); - return hasChildObjects(); - } + /** * Returns true if this object has a ROI. @@ -871,7 +812,6 @@ public void setName(String name) { *

    * This may be null if no color has been set. * @return - * @see #setColorRGB(Integer) * @see ColorTools#red(int) * @see ColorTools#green(int) * @see ColorTools#blue(int) @@ -881,30 +821,6 @@ public Integer getColor() { return color; } - /** - * Return any stored color as a packed RGB value. - *

    - * This may be null if no color has been set - * @return - * @deprecated since v0.4.0, use {@link #getColor()} instead. - */ - @Deprecated - public Integer getColorRGB() { - LogTools.warnOnce(logger, "PathObject.getColorRGB() is deprecated since v0.4.0 - use getColor() instead"); - return getColor(); - } - - /** - * Set the display color. - * @param color - * @deprecated since v0.4.0, use {@link #setColor(Integer)} instead. - */ - @Deprecated - public void setColorRGB(Integer color) { - LogTools.warnOnce(logger, "PathObject.setColorRGB(Integer) is deprecated since v0.4.0 - use setColor(Integer) instead"); - setColor(color); - } - /** * Set the display color as a packed (A)RGB integer (alpha may not be used * by viewing code). From d73a652d10a8508f9922d1b151f3aa0fc1924989 Mon Sep 17 00:00:00 2001 From: Pete Date: Sat, 17 Aug 2024 18:55:31 +0100 Subject: [PATCH 211/866] Add PathObject.get/setClassification This is a convenience method to help work with classifications easily within scripts. --- .../java/qupath/lib/objects/PathObject.java | 65 +++++++++++++++++-- .../qupath/lib/objects/TestPathObject.java | 50 +++++++++++++- 2 files changed, 108 insertions(+), 7 deletions(-) diff --git a/qupath-core/src/main/java/qupath/lib/objects/PathObject.java b/qupath-core/src/main/java/qupath/lib/objects/PathObject.java index 447259ef8..4f8e811e1 100644 --- a/qupath-core/src/main/java/qupath/lib/objects/PathObject.java +++ b/qupath-core/src/main/java/qupath/lib/objects/PathObject.java @@ -646,16 +646,36 @@ public PathObject[] getChildObjectsAsArray() { /** * Get the classification of the object. + *

    + * The {@code PathClass} object is used as the internal representation of the object's classification, + * encapsulating both the different string components of the classification and the color used for display. + *

    + * For convenience, {@link #getClassification()} and {@link }{@link #getClassifications()} provide a simpler way to interact with + * classifications as one or more strings. * @return + * @see #setPathClass(PathClass) + * @see #getClassification() + * @see #getClassifications() */ public abstract PathClass getPathClass(); /** * Set the classification of the object, without specifying any classification probability. - * @param pc + *

    + * The {@code PathClass} object is used as the internal representation of the object's classification, + * encapsulating both the different string components of the classification and the color used for display. + *

    + * If the classification is null, the object is considered to be unclassified. + *

    + * For convenience, {@link #setClassification(String)} ()} and {@link }{@link #setClassifications(Collection)} ()} + * provide alternative ways to set classifications using strings - but this does not allow for setting the color, + * and internally a {@code PathClass} object will still be used. + * @param pathClass + * @see #setClassification(String) + * @see #setClassifications(Collection) */ - public void setPathClass(PathClass pc) { - setPathClass(pc, Double.NaN); + public void setPathClass(PathClass pathClass) { + setPathClass(pathClass, Double.NaN); } /** @@ -666,7 +686,7 @@ public boolean resetPathClass() { var previous = getPathClass(); if (previous == null) return false; - setPathClass((PathClass)null); + setPathClass(null); return true; } @@ -736,7 +756,39 @@ public Set getClassifications() { else return pc.toSet(); } - + + /** + * Convenience method to get a string representation of the classification (PathClass). + *

    + * It returns null if there is no classification; otherwise, it is equivalent to calling + * {@code getPathClass().toString()} + * @return + * @see #setClassification(String) + * @see #getPathClass() + * @see #getClassifications() + * @since v0.6.0 + */ + public String getClassification() { + var pc = getPathClass(); + return pc == null || pc == PathClass.NULL_CLASS ? null : pc.toString(); + } + + /** + * Convenience method to et the classification of the object from a string representation. + * If the string is null or empty, the classification is reset. + * Otherwise, it is equivalent to calling {@code setPathClass(PathClass.fromString(classification))} + * @param classification + * @see #getClassification() + * @see #setPathClass(PathClass) + * @see #setClassifications(Collection) + * @since v0.6.0 + */ + public void setClassification(String classification) { + if (classification == null || classification.isEmpty()) + resetPathClass(); + else + setPathClass(PathClass.fromString(classification)); + } /** * Set the classification of the object, specifying a classification probability. @@ -744,6 +796,9 @@ public Set getClassifications() { * The probability is expected to be between 0 and 1, or Double.NaN if no probability should be set. * @param pathClass * @param classProbability + * @see #setPathClass(PathClass) + * @see #setClassification(String) + * @see #setClassifications(Collection) */ public abstract void setPathClass(PathClass pathClass, double classProbability); diff --git a/qupath-core/src/test/java/qupath/lib/objects/TestPathObject.java b/qupath-core/src/test/java/qupath/lib/objects/TestPathObject.java index 10670d602..155d6cd33 100644 --- a/qupath-core/src/test/java/qupath/lib/objects/TestPathObject.java +++ b/qupath-core/src/test/java/qupath/lib/objects/TestPathObject.java @@ -2,7 +2,7 @@ * #%L * This file is part of QuPath. * %% - * Copyright (C) 2022 QuPath developers, The University of Edinburgh + * Copyright (C) 2022-2024 QuPath developers, The University of Edinburgh * %% * QuPath is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as @@ -39,6 +39,7 @@ import java.util.Arrays; import java.util.LinkedHashSet; import java.util.List; +import java.util.Set; import java.util.stream.IntStream; import org.junit.jupiter.api.Test; @@ -47,6 +48,7 @@ import qupath.lib.measurements.MeasurementList.MeasurementListType; import qupath.lib.measurements.MeasurementListFactory; +import qupath.lib.objects.classes.PathClass; import qupath.lib.roi.ROIs; public class TestPathObject { @@ -314,7 +316,51 @@ private static void addChildrenDelayed(PathObject pathObject, int n, int delayMi pathObject.addChildObject(child); } } - + + + @Test + void test_setClassification() { + var pathObject = PathObjects.createAnnotationObject(ROIs.createEmptyROI()); + assertNull(pathObject.getClassification()); + assertNull(pathObject.getPathClass()); + assertTrue(pathObject.getClassifications().isEmpty()); + + pathObject.setClassification("Something"); + assertEquals("Something", pathObject.getClassification()); + assertEquals(PathClass.getInstance("Something"), pathObject.getPathClass()); + assertEquals(Set.of("Something"), pathObject.getClassifications()); + + pathObject.setClassification("Something: else"); + assertEquals("Something: else", pathObject.getClassification()); + assertEquals(PathClass.fromString("Something: else"), pathObject.getPathClass()); + assertEquals(PathClass.getInstance("Something"), pathObject.getPathClass().getBaseClass()); + assertEquals(Set.of("Something", "else"), pathObject.getClassifications()); + + pathObject.setClassification(null); + assertNull(pathObject.getClassification()); + assertNull(pathObject.getPathClass()); + assertTrue(pathObject.getClassifications().isEmpty()); + + pathObject.setClassification(""); + assertNull(pathObject.getClassification()); + assertNull(pathObject.getPathClass()); + assertTrue(pathObject.getClassifications().isEmpty()); + + pathObject.setClassifications(Set.of()); + assertNull(pathObject.getClassification()); + assertNull(pathObject.getPathClass()); + assertTrue(pathObject.getClassifications().isEmpty()); + + pathObject.setPathClass(null); + assertNull(pathObject.getClassification()); + assertNull(pathObject.getPathClass()); + assertTrue(pathObject.getClassifications().isEmpty()); + + pathObject.setPathClass(PathClass.NULL_CLASS); + assertNull(pathObject.getClassification()); + assertNull(pathObject.getPathClass()); + assertTrue(pathObject.getClassifications().isEmpty()); + } From 51705b916803fe2a235c2f2f563d32771c65ef58 Mon Sep 17 00:00:00 2001 From: Pete Date: Sat, 17 Aug 2024 20:49:02 +0100 Subject: [PATCH 212/866] Cleanup, remove deprecated methods --- CHANGELOG.md | 3 + .../java/qupath/lib/objects/PathObject.java | 2 +- .../hierarchy/PathObjectHierarchy.java | 127 ++++-------------- 3 files changed, 28 insertions(+), 104 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 041972d74..4c30a871c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,9 @@ This is a *work in progress* for the next major release. * The `MeasurementList` interface has been substantially revised (https://github.com/qupath/qupath/pulls) * Removed methods had been deprecated since v0.4.0; the changes mostly focus on simplifying the API, improving thread-safety, and squashing some bugs. +* `PathObject` and `PathObjectHierarchy` have also been revised, with deprecated methods removed + * New `PathObject.getClassification()` and `PathObject.setClassification(String)` methods + to simplify working with classifications in scripts (https://github.com/qupath/qupath/pull/1593) ### Dependency updates * Bio-Formats 7.3.1 diff --git a/qupath-core/src/main/java/qupath/lib/objects/PathObject.java b/qupath-core/src/main/java/qupath/lib/objects/PathObject.java index 4f8e811e1..47d4a8d60 100644 --- a/qupath-core/src/main/java/qupath/lib/objects/PathObject.java +++ b/qupath-core/src/main/java/qupath/lib/objects/PathObject.java @@ -723,7 +723,7 @@ public void setClassifications(Collection classifications) { if (classifications.isEmpty()) resetPathClass(); else if (classifications instanceof Set) { - setPathClass(PathClass.fromCollection((Set)classifications)); + setPathClass(PathClass.fromCollection(classifications)); } else { // Use LinkedHashSet to maintain ordering var set = new LinkedHashSet<>(classifications); diff --git a/qupath-core/src/main/java/qupath/lib/objects/hierarchy/PathObjectHierarchy.java b/qupath-core/src/main/java/qupath/lib/objects/hierarchy/PathObjectHierarchy.java index db68dc353..3f857c0ad 100644 --- a/qupath-core/src/main/java/qupath/lib/objects/hierarchy/PathObjectHierarchy.java +++ b/qupath-core/src/main/java/qupath/lib/objects/hierarchy/PathObjectHierarchy.java @@ -23,6 +23,7 @@ package qupath.lib.objects.hierarchy; +import java.io.Serial; import java.io.Serializable; import java.util.ArrayList; import java.util.Collection; @@ -73,7 +74,8 @@ * */ public final class PathObjectHierarchy implements Serializable { - + + @Serial private static final long serialVersionUID = 1L; private static final Logger logger = LoggerFactory.getLogger(PathObjectHierarchy.class); @@ -81,11 +83,11 @@ public final class PathObjectHierarchy implements Serializable { private TMAGrid tmaGrid = null; private PathObject rootObject = new PathRootObject(); - private transient PathObjectSelectionModel selectionModel = new PathObjectSelectionModel(); - private transient List listeners = new ArrayList<>(); + private final transient PathObjectSelectionModel selectionModel = new PathObjectSelectionModel(); + private final transient List listeners = new ArrayList<>(); // Cache enabling faster access of objects according to location - private transient PathObjectTileCache tileCache = new PathObjectTileCache(this); + private final transient PathObjectTileCache tileCache = new PathObjectTileCache(this); /** * Default constructor, creates an empty hierarchy. @@ -107,29 +109,19 @@ public synchronized boolean isEmpty() { /** * Add a hierarchy change listener. * @param listener - * @since v0.4.0; replaced {@link #addPathObjectListener(PathObjectHierarchyListener)} + * @since v0.4.0; replaced {@code addPathObjectListener(PathObjectHierarchyListener)} */ public void addListener(PathObjectHierarchyListener listener) { synchronized(listeners) { listeners.add(listener); } } - - /** - * Legacy method to add a hierarchy change listener; use {@link #addListener(PathObjectHierarchyListener)} instead. - * @param listener - * @deprecated since v0.4.0 (the name was confusing because it wasn't intended primarily to listen to changes within individual PathObjects) - */ - @Deprecated - public void addPathObjectListener(PathObjectHierarchyListener listener) { - LogTools.warnOnce(logger, "addPathObjectListener() is deprecated, use addListener() instead"); - addListener(listener); - } + /** * Remove a hierarchy change listener. * @param listener - * @since v0.4.0; replaced {@link #removePathObjectListener(PathObjectHierarchyListener)} + * @since v0.4.0; replaced {@code removePathObjectListener(PathObjectHierarchyListener)} */ public void removeListener(PathObjectHierarchyListener listener) { synchronized(listeners) { @@ -137,16 +129,6 @@ public void removeListener(PathObjectHierarchyListener listener) { } } - /** - * Legacy method to remove a hierarchy change listener; use {@link #removeListener(PathObjectHierarchyListener)} instead. - * @param listener - * @deprecated since v0.4.0 (the name was confusing because it wasn't intended primarily to listen to changes within individual PathObjects) - */ - @Deprecated - public void removePathObjectListener(PathObjectHierarchyListener listener) { - LogTools.warnOnce(logger, "removePathObjectListener() is deprecated, use removeListener() instead"); - removeListener(listener); - } /** * Get the root object. All other objects in the hierarchy are descendants of the root. @@ -233,7 +215,7 @@ public synchronized boolean insertPathObject(PathObject pathObject, boolean fire public synchronized boolean insertPathObjects(Collection pathObjects) { var selectedObjects = new ArrayList<>(pathObjects); int nObjects = selectedObjects.size(); - selectedObjects.removeIf(p -> p.isTMACore()); + selectedObjects.removeIf(PathObject::isTMACore); if (selectedObjects.size() < nObjects) logger.warn("TMA core objects cannot be inserted - use resolveHierarchy() instead"); @@ -243,7 +225,7 @@ public synchronized boolean insertPathObjects(Collection p selectedObjects.sort(PathObjectHierarchy.HIERARCHY_COMPARATOR.reversed()); boolean singleObject = selectedObjects.size() == 1; // We don't want to reset caches for every object if we have only detections, since previously-inserted objects don't impact the potential parent - boolean allDetections = selectedObjects.stream().allMatch(p -> p.isDetection()); + boolean allDetections = selectedObjects.stream().allMatch(PathObject::isDetection); for (var pathObject : selectedObjects) { // hierarchy.insertPathObject(pathObject, true); insertPathObject(getRootObject(), pathObject, singleObject, !singleObject && !allDetections); @@ -310,7 +292,7 @@ private synchronized boolean insertPathObject(PathObject pathObjectParent, PathO } var possibleParentObjects = new ArrayList<>(tempSet); - Collections.sort(possibleParentObjects, HIERARCHY_COMPARATOR); + possibleParentObjects.sort(HIERARCHY_COMPARATOR); for (PathObject possibleParent : possibleParentObjects) { if (possibleParent == pathObject || possibleParent.isDetection()) @@ -337,10 +319,10 @@ private synchronized boolean insertPathObject(PathObject pathObjectParent, PathO // Reassign child objects if we need to Collection previousChildren = pathObject.isDetection() ? new ArrayList<>() : new ArrayList<>(possibleParent.getChildObjects()); // Can't reassign TMA core objects (these must be directly below the root object) - previousChildren.removeIf(p -> p.isTMACore()); + previousChildren.removeIf(PathObject::isTMACore); // Beware that we could have 'orphaned' detections if (possibleParent.isTMACore()) - possibleParent.getParent().getChildObjects().stream().filter(p -> p.isDetection()).forEach(previousChildren::add); + possibleParent.getParent().getChildObjects().stream().filter(PathObject::isDetection).forEach(previousChildren::add); possibleParent.addChildObject(pathObject); if (!previousChildren.isEmpty()) { pathObject.addChildObjects(filterObjectsForROI(pathObject.getROI(), previousChildren)); @@ -435,12 +417,8 @@ public synchronized void removeObjects(Collection pathObje PathObject parent = pathObject.getParent(); if (parent == null) continue; - List list = map.get(parent); - if (list == null) { - list = new ArrayList<>(); - map.put(parent, list); - } - list.add(pathObject); + List list = map.computeIfAbsent(parent, k -> new ArrayList<>()); + list.add(pathObject); } if (map.isEmpty()) @@ -526,52 +504,13 @@ public boolean addObject(PathObject pathObject) { * @param pathObject * @param fireUpdate * @return - * @since v0.4.0; replaces {@link #addPathObjectWithoutUpdate(PathObject)} + * @since v0.4.0; replaces {@code addPathObjectWithoutUpdate(PathObject)} */ public boolean addObject(PathObject pathObject, boolean fireUpdate) { return addPathObjectImpl(pathObject, fireUpdate); } - - - /** - * Legacy method to add an object to the hierarchy, firing an event. - * @param pathObject - * @return - * @deprecated since v0.4.0; use {@link #addObject(PathObject)} instead (for naming consistency) - */ - @Deprecated - public boolean addPathObject(PathObject pathObject) { - LogTools.warnOnce(logger, "addPathObject(PathObject) is deprecated - use addObject(PathObject) instead"); - return addObject(pathObject); - } - - /** - * Legacy method to add an object to the hierarchy, without firing an event. - * @param pathObject - * @return - * @deprecated since v0.4.0, use {@link #addObject(PathObject, boolean)} instead (for naming consistency) - */ - @Deprecated - public boolean addPathObjectWithoutUpdate(PathObject pathObject) { - LogTools.warnOnce(logger, "addPathObjectWithoutUpdate(PathObject) is deprecated - use addObject(PathObject, false) instead"); - return addObject(pathObject, false); - } - /** - * Legacy method to path object as descendant of the requested parent. - * @param pathObjectParent - * @param pathObject - * @param fireUpdate - * @return - * @deprecated since v0.4.0; use {@link #addObjectBelowParent(PathObject, PathObject, boolean)} - */ - @Deprecated - public boolean addPathObjectBelowParent(PathObject pathObjectParent, PathObject pathObject, boolean fireUpdate) { - LogTools.warnOnce(logger, "addPathObjectBelowParent is deprecated - use addObjectBelowParent instead"); - return addObjectBelowParent(pathObjectParent, pathObject, fireUpdate); - } - /** * Add path object as descendant of the requested parent. * @@ -579,7 +518,7 @@ public boolean addPathObjectBelowParent(PathObject pathObjectParent, PathObject * @param pathObject * @param fireUpdate * @return - * @since v0.4.0 (replaces {@link #addPathObjectBelowParent(PathObject, PathObject, boolean)} + * @since v0.4.0 (replaces {@code addPathObjectBelowParent(PathObject, PathObject, boolean)} */ public synchronized boolean addObjectBelowParent(PathObject pathObjectParent, PathObject pathObject, boolean fireUpdate) { if (pathObjectParent == pathObject) @@ -607,7 +546,7 @@ private synchronized boolean addPathObjectImpl(PathObject pathObject, boolean fi * Add multiple objects to the hierarchy. * @param pathObjects * @return - * @since v0.4.0; replaces {@link #addPathObjects(Collection)} + * @since v0.4.0; replaces {@code addPathObjects(Collection)} */ public synchronized boolean addObjects(Collection pathObjects) { boolean changes = false; @@ -627,18 +566,6 @@ public synchronized boolean addObjects(Collection pathObje // fireChangeEvent(getRootObject()); return changes; } - - /** - * Legacy method to add multiple objects to the hierarchy. - * @param pathObjects - * @return - * @deprecated since v0.4.0; use {@link #addObjects(Collection)} instead - */ - @Deprecated - public boolean addPathObjects(Collection pathObjects) { - LogTools.warnOnce(logger, "addPathObjects(Collection) is deprecated - use addObjects(Collection) instead"); - return addObjects(pathObjects); - } /** @@ -799,8 +726,7 @@ public synchronized Collection getAllObjects(boolean includeRoot) { * @return */ public synchronized int nObjects() { - int count = PathObjectTools.countDescendants(getRootObject()); - return count; + return PathObjectTools.countDescendants(getRootObject()); } /** @@ -941,7 +867,7 @@ Collection filterObjectsForROI(ROI roi, Collection pathO // A change in getLocator() overcomes this - but watch out for future problems return pathObjects.parallelStream().filter(child -> { // Test plane first - if (!samePlane(roi, child.getROI(), false)) + if (!sameZT(roi, child.getROI())) return false; if (child.isDetection()) @@ -954,17 +880,13 @@ Collection filterObjectsForROI(ROI roi, Collection pathO /** - * Check if two ROIs fall in the same plane, optionally testing the channel as well. + * Check if two ROIs fall in the same plane, i.e. have the same Z and T values. * @param roi1 * @param roi2 - * @param checkChannel * @return */ - private static boolean samePlane(ROI roi1, ROI roi2, boolean checkChannel) { - if (checkChannel) - return roi1.getImagePlane().equals(roi2.getImagePlane()); - else - return roi1.getZ() == roi2.getZ() && roi1.getT() == roi2.getT(); + private static boolean sameZT(ROI roi1, ROI roi2) { + return roi1.getZ() == roi2.getZ() && roi1.getT() == roi2.getT(); } @@ -980,7 +902,6 @@ private static boolean samePlane(ROI roi1, ROI roi2, boolean checkChannel) { * @see #getAllObjectsForRegion(ImageRegion, Collection) * @see #getAnnotationsForRegion(ImageRegion, Collection) * @see #getAllDetectionsForRegion(ImageRegion, Collection) - * @since v0.6.0 */ @Deprecated public Collection getObjectsForRegion(Class cls, ImageRegion region, Collection pathObjects) { From 124950b90f2ede084ee36a0f82d06f30682fa02f Mon Sep 17 00:00:00 2001 From: Pete Date: Sun, 18 Aug 2024 14:34:43 +0100 Subject: [PATCH 213/866] Fix display of underscores in class names Fixes https://forum.image.sc/t/a-minor-issue-with-underscore-in-class-name-of-annotation-in-qupath/100602 --- .../src/main/java/qupath/lib/gui/commands/CountingPane.java | 1 + .../src/main/java/qupath/lib/gui/viewer/ViewerManager.java | 1 + 2 files changed, 2 insertions(+) diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/CountingPane.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/CountingPane.java index 40f378458..15f949c1e 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/CountingPane.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/CountingPane.java @@ -196,6 +196,7 @@ public CountingPane(final QuPathGUI qupath, final PathObjectHierarchy hierarchy) MenuItem createPathClassMenuItem(PathClass pathClass) { var mi = new MenuItem(pathClass.toString()); + mi.setMnemonicParsing(false); // Fix display of underscores in menu items var color = pathClass.getColor(); var rect = new Rectangle(8, 8, color == null ? ColorToolsFX.TRANSLUCENT_WHITE_FX : ColorToolsFX.getCachedColor(color)); mi.setGraphic(rect); diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/ViewerManager.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/ViewerManager.java index 1a6e72b16..b0c4656ca 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/ViewerManager.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/ViewerManager.java @@ -1557,6 +1557,7 @@ private void updateSetAnnotationPathClassMenu(final ObservableList men } // actionSetClass.setGraphic(r); RadioMenuItem item = ActionUtils.createRadioMenuItem(actionSetClass); + item.setMnemonicParsing(false); // Fix display of underscores in classification names item.graphicProperty().unbind(); item.setGraphic(shape); item.setToggleGroup(group); From 6c76f9ee3aaf0feba70dbe5f2f26a0f28ca5013b Mon Sep 17 00:00:00 2001 From: Pete Date: Mon, 19 Aug 2024 14:58:26 +0100 Subject: [PATCH 214/866] Update PathObjectTileCache.java Fix bug when calling get/hasObjectsForRegion without including subclasses. This was never used in practice, so the bug had no effect... but if if the method was called in that way. Also removed commented out code. --- .../hierarchy/PathObjectTileCache.java | 90 ++----------------- 1 file changed, 5 insertions(+), 85 deletions(-) diff --git a/qupath-core/src/main/java/qupath/lib/objects/hierarchy/PathObjectTileCache.java b/qupath-core/src/main/java/qupath/lib/objects/hierarchy/PathObjectTileCache.java index 24c59ce86..71c491f62 100644 --- a/qupath-core/src/main/java/qupath/lib/objects/hierarchy/PathObjectTileCache.java +++ b/qupath-core/src/main/java/qupath/lib/objects/hierarchy/PathObjectTileCache.java @@ -4,7 +4,7 @@ * %% * Copyright (C) 2014 - 2016 The Queen's University of Belfast, Northern Ireland * Contact: IP Management (ipmanagement@qub.ac.uk) - * Copyright (C) 2018 - 2020 QuPath developers, The University of Edinburgh + * Copyright (C) 2018 - 2024 QuPath developers, The University of Edinburgh * %% * QuPath is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as @@ -30,6 +30,7 @@ import java.util.List; import java.util.Map; import java.util.Map.Entry; +import java.util.Objects; import java.util.WeakHashMap; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantReadWriteLock; @@ -202,16 +203,6 @@ Geometry getGeometry(PathObject pathObject) { if (pathObject.isAnnotation() || pathObject.isTMACore()) { geometryMap.put(roi, geometry); } -//// long startTime = System.currentTimeMillis(); -// if (!geometry.isValid()) { -// int nVertices = geometry.getNumPoints(); -// if (geometry.getNumPoints() < 100) -// logger.warn("{} is not a valid geometry! Actual geometry {}", pathObject, geometry); -// else -// logger.warn("{} is not a valid geometry! Actual geometry {} ({} vertices)", pathObject, geometry.getGeometryType(), nVertices); -// } -//// long endTime = System.currentTimeMillis(); -//// System.err.println("Testing " + (endTime - startTime) + " ms for " + geometry); } return geometry; } @@ -220,13 +211,6 @@ private Coordinate getCentroidCoordinate(PathObject pathObject) { ROI roi = PathObjectTools.getROI(pathObject, true); // It's faster not to rely on a synchronized map return new Coordinate(roi.getCentroidX(), roi.getCentroidY()); -// Coordinate coordinate = centroidMap.get(roi); -// if (coordinate == null) { -// coordinate = new Coordinate(roi.getCentroidX(), roi.getCentroidY()); -//// coordinate = getGeometry(pathObject).getCentroid().getCoordinate(); -// centroidMap.put(roi, coordinate); -// } -// return coordinate; } PointOnGeometryLocator getLocator(ROI roi, boolean addToCache) { @@ -244,10 +228,6 @@ PointOnGeometryLocator getLocator(ROI roi, boolean addToCache) { return locator; } -// public boolean covers(PathObject possibleParent, PathObject possibleChild) { -// return getGeometry(possibleParent).covers(getGeometry(possibleChild)); -// } - private Map preparedGeometryMap = new WeakHashMap<>(); PreparedGeometry getPreparedGeometry(Geometry geometry) { @@ -281,17 +261,6 @@ boolean covers(PreparedGeometry parent, Geometry child) { } boolean covers(Geometry parent, Geometry child) { -// if (!parent.getEnvelopeInternal().covers(child.getEnvelopeInternal())) -// return false; -// if (parent instanceof Polygon) -// return parent.covers(child); -// if (parent instanceof MultiPolygon) { -// for (int i = 0; i < parent.getNumGeometries(); i++) { -// if (covers(parent.getGeometryN(i), child)) -// return true; -// } -// return false; -// } return covers(getPreparedGeometry(parent), child); } @@ -387,16 +356,7 @@ private void removeFromCache(PathObject pathObject, boolean removeChildren) { constructCache(null); } } - - -// /** -// * Add a PathObject to the cache. Child objects are not added. -// * @param pathObject -// */ -// private void addToCache(PathObject pathObject) { -// addToCache(pathObject, false); -// } - + /** * Get all the PathObjects stored in this cache of a specified type and having ROIs with bounds overlapping a specified region. @@ -427,7 +387,7 @@ public Collection getObjectsForRegion(Class cl try { // Iterate through all the classes, getting objects of the specified class or subclasses thereof for (Entry, SpatialIndex> entry : map.entrySet()) { - if (cls == null || (includeSubclasses && cls.isAssignableFrom(entry.getKey())) || cls.isInstance(entry.getKey())) { + if (cls == null || (includeSubclasses && cls.isAssignableFrom(entry.getKey())) || Objects.equals(cls, entry.getKey())) { if (entry.getValue() != null) { var list = entry.getValue().query(envelope); if (list.isEmpty()) @@ -447,10 +407,8 @@ public Collection getObjectsForRegion(Class cl } } } -// pathObjects = entry.getValue().getObjectsForRegion(region, pathObjects); } } - // logger.info("Objects for " + region + ": " + (pathObjects == null ? 0 : pathObjects.size())); if (pathObjects == null) return Collections.emptySet(); return pathObjects; @@ -470,7 +428,7 @@ public boolean hasObjectsForRegion(Class cls, ImageRegion try { // Iterate through all the classes, getting objects of the specified class or subclasses thereof for (Entry, SpatialIndex> entry : map.entrySet()) { - if (cls == null || cls.isInstance(entry.getKey()) || (includeSubclasses && cls.isAssignableFrom(entry.getKey()))) { + if (cls == null || Objects.equals(cls, entry.getKey()) || (includeSubclasses && cls.isAssignableFrom(entry.getKey()))) { if (entry.getValue() != null) { var list = (List)entry.getValue().query(envelope); for (var pathObject : list) { @@ -498,37 +456,6 @@ public boolean hasObjectsForRegion(Class cls, ImageRegion r.unlock(); } } - -// public synchronized Collection getObjectsForRegion(Class cls, Rectangle region, Collection pathObjects) { -// ensureCacheConstructed(); -// -// if (pathObjects == null) -// pathObjects = new HashSet<>(); -// -// // Iterate through all the classes, getting objects of the specified class or subclasses thereof -// if (cls == null) { -// for (Class tempClass : map.keySet()) -// getObjectsForRegion(tempClass, region, pathObjects); -// return pathObjects; -// } -// -// // Extract the map for the type -// PathObjectTileMap mapObjects = map.get(cls); -// if (mapObjects == null) -// return pathObjects; -// -// // Get the objects -// return mapObjects.getObjectsForRegion(region, pathObjects); -// } - - - -// @Override -// public void pathObjectChanged(PathObjectHierarchy pathObjectHierarchy, PathObject pathObject) { -// // Remove, then re-add the object - ignoring children (which shouldn't be changed, as no structural change is associated with this event) -// removeFromCache(pathObject, false); -// addToCache(pathObject, false); -// } @Override @@ -543,19 +470,12 @@ public void hierarchyChanged(final PathObjectHierarchyEvent event) { } else if (singleChange && event.getEventType() == HierarchyEventType.REMOVED) { removeFromCache(singleObject, false); } else if (event.getEventType() == HierarchyEventType.OTHER_STRUCTURE_CHANGE || event.getEventType() == HierarchyEventType.CHANGE_OTHER) { -// if (singleChange && !singleObject.isRootObject()) { -// removeFromCache(singleObject, false); -// addToCache(singleObject, false, singleObject.getClass()); -// } else -// System.err.println(event); if (!event.isChanging()) resetCache(); } } finally { w.unlock(); } -// else if (event.getEventType() == HierarchyEventType.OBJECT_CHANGE) -// resetCache(); // TODO: Check if full change is necessary for object change events } } From 9bcad4403a7b756928d4517f7bc669c518e5ea6f Mon Sep 17 00:00:00 2001 From: Pete Date: Tue, 20 Aug 2024 20:12:43 +0100 Subject: [PATCH 215/866] Update DelaunayTools.java Some code cleanup --- .../qupath/lib/analysis/DelaunayTools.java | 49 +++++++++---------- 1 file changed, 23 insertions(+), 26 deletions(-) diff --git a/qupath-core/src/main/java/qupath/lib/analysis/DelaunayTools.java b/qupath-core/src/main/java/qupath/lib/analysis/DelaunayTools.java index ebff3a50f..557458410 100644 --- a/qupath-core/src/main/java/qupath/lib/analysis/DelaunayTools.java +++ b/qupath-core/src/main/java/qupath/lib/analysis/DelaunayTools.java @@ -2,7 +2,7 @@ * #%L * This file is part of QuPath. * %% - * Copyright (C) 2018 - 2020 QuPath developers, The University of Edinburgh + * Copyright (C) 2018 - 2024 QuPath developers, The University of Edinburgh * %% * QuPath is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as @@ -123,11 +123,8 @@ private static Function> createGeometryExtrac if (!geom2.isEmpty()) geom = geom2; } - -// if (!(geom instanceof Polygon)) -// logger.warn("Unexpected Geometry: {}", geom); - - // Making precise is essential! Otherwise there can be small artifacts occurring + + // Making precise is essential! Otherwise, small artifacts can occur var coords = geom.getCoordinates(); var output = new LinkedHashSet(); var p2 = precision; @@ -192,7 +189,7 @@ public static Builder newBuilder(Collection pathObjects) { */ public static class Builder { - private static enum ExtractorType {CUSTOM, CENTROIDS, ROI} + private enum ExtractorType {CUSTOM, CENTROIDS, ROI} private ExtractorType extractorType = ExtractorType.CENTROIDS; private boolean preferNucleusROI = true; @@ -202,7 +199,7 @@ private static enum ExtractorType {CUSTOM, CENTROIDS, ROI} private double erosion = 1.0; - private ImagePlane plane = ImagePlane.getDefaultPlane(); + private ImagePlane plane; private Collection pathObjects = new ArrayList<>(); private Function> coordinateExtractor; @@ -308,8 +305,8 @@ public Subdivision build() { case ROI: extractor = createGeometryExtractor(cal, preferNucleusROI, densify, erosion); break; - default: case CUSTOM: + default: break; } @@ -593,24 +590,21 @@ public static class Subdivision { private static final Logger logger = LoggerFactory.getLogger(Subdivision.class); - private Set pathObjects = new LinkedHashSet<>(); - private Map coordinateMap = new HashMap<>(); - private Map> objectCoordinateMap = new HashMap<>(); - private QuadEdgeSubdivision subdivision; + private final Set pathObjects; + private final Map coordinateMap; + private final QuadEdgeSubdivision subdivision; - private ImagePlane plane; + private final ImagePlane plane; - private transient Map> neighbors; - private transient Map voronoiFaces; + private transient volatile Map> neighbors; + private transient volatile Map voronoiFaces; private Subdivision(QuadEdgeSubdivision subdivision, Collection pathObjects, Map coordinateMap, ImagePlane plane) { this.subdivision = subdivision; this.plane = plane; - this.pathObjects.addAll(pathObjects); - this.coordinateMap.putAll(coordinateMap); - this.pathObjects = Collections.unmodifiableSet(this.pathObjects); - this.coordinateMap = Collections.unmodifiableMap(this.coordinateMap); + this.pathObjects = Collections.unmodifiableSet(new LinkedHashSet<>(pathObjects)); + this.coordinateMap = Map.copyOf(coordinateMap); } /** @@ -750,9 +744,12 @@ public Map> getAllNeighbors() { } return neighbors; } - - - + + + /** + * Return a map of PathObjects and their neighbors, sorted by distance. + * @return + */ private synchronized Map> calculateAllNeighbors() { logger.debug("Calculating all neighbors for {} objects", getPathObjects().size()); @@ -760,7 +757,7 @@ private synchronized Map> calculateAllNeighbors() { @SuppressWarnings("unchecked") var edges = (List)subdivision.getVertexUniqueEdges(false); Map> map = new HashMap<>(); - var distanceMap = new HashMap(); + Map distanceMap = new HashMap<>(); int missing = 0; for (var edge : edges) { @@ -787,8 +784,8 @@ private synchronized Map> calculateAllNeighbors() { list.add(destObject); } } while ((next = next.oNext()) != edge); - Collections.sort(list, Comparator.comparingDouble(p -> distanceMap.get(p))); - + + list.sort(Comparator.comparingDouble(distanceMap::get)); map.put(pathObject, Collections.unmodifiableList(list)); } if (missing > 0) From 28a29f282d186b9e05d25bb5a86dc53e96f5841c Mon Sep 17 00:00:00 2001 From: Pete Date: Tue, 20 Aug 2024 20:18:49 +0100 Subject: [PATCH 216/866] Add findNeighbors support to hierarchy --- .../hierarchy/PathObjectHierarchy.java | 128 +++++++++++++++--- 1 file changed, 106 insertions(+), 22 deletions(-) diff --git a/qupath-core/src/main/java/qupath/lib/objects/hierarchy/PathObjectHierarchy.java b/qupath-core/src/main/java/qupath/lib/objects/hierarchy/PathObjectHierarchy.java index 3f857c0ad..2e36cb17e 100644 --- a/qupath-core/src/main/java/qupath/lib/objects/hierarchy/PathObjectHierarchy.java +++ b/qupath-core/src/main/java/qupath/lib/objects/hierarchy/PathObjectHierarchy.java @@ -36,11 +36,13 @@ import java.util.Map; import java.util.Map.Entry; import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import java.util.stream.Collectors; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import qupath.lib.analysis.DelaunayTools; import qupath.lib.common.LogTools; import qupath.lib.objects.DefaultPathObjectComparator; import qupath.lib.objects.PathAnnotationObject; @@ -55,6 +57,7 @@ import qupath.lib.objects.hierarchy.events.PathObjectHierarchyListener; import qupath.lib.objects.hierarchy.events.PathObjectSelectionModel; import qupath.lib.objects.hierarchy.events.PathObjectHierarchyEvent.HierarchyEventType; +import qupath.lib.regions.ImagePlane; import qupath.lib.regions.ImageRegion; import qupath.lib.roi.interfaces.ROI; @@ -89,6 +92,9 @@ public final class PathObjectHierarchy implements Serializable { // Cache enabling faster access of objects according to location private final transient PathObjectTileCache tileCache = new PathObjectTileCache(this); + // A map to store subdivisions, useful for finding neighbors + private transient SubdivisionManager subdivisionManager = new SubdivisionManager(); + /** * Default constructor, creates an empty hierarchy. */ @@ -278,7 +284,8 @@ private synchronized boolean insertPathObject(PathObject pathObjectParent, PathO logger.warn("TMA core objects cannot be inserted - use resolveHierarchy() instead"); return false; } - + resetNeighborsForClass(pathObject.getClass()); + // Get all the annotations that might be a parent of this object var region = ImageRegion.createInstance(pathObject.getROI()); Collection tempSet = new HashSet<>(); @@ -393,6 +400,7 @@ private synchronized boolean removeObject(PathObject pathObject, boolean keepChi else fireHierarchyChangedEvent(this, pathObjectParent); } + resetNeighborsForClass(pathObject.getClass()); return true; } @@ -441,26 +449,10 @@ public synchronized void removeObjects(Collection pathObje for (PathObject pathObject : childrenToKeep) { addPathObjectImpl(pathObject, false); } + for (var cls : pathObjects.stream().map(PathObject::getClass).distinct().toList()) { + resetNeighborsForClass(cls); + } fireHierarchyChangedEvent(this); - - // This previously could result in child objects being deleted even if keepChildren was - // true, depending upon the order in which objects were removed. -// // Loop through and remove objects -// for (Entry> entry : map.entrySet()) { -// PathObject parent = entry.getKey(); -// List children = entry.getValue(); -// parent.removePathObjects(children); -// if (keepChildren) { -// for (PathObject child : children) { -// if (child.hasChildren()) { -// List newChildList = new ArrayList<>(child.getChildObjects()); -// newChildList.removeAll(pathObjects); -// parent.addPathObjects(newChildList); -// } -// } -// } -// } -// fireHierarchyChangedEvent(this); } @@ -486,6 +478,7 @@ private synchronized boolean addPathObjectToList(PathObject pathObjectParent, Pa // Notify listeners of changes, if required if (fireChangeEvents) fireObjectAddedEvent(this, pathObject); + resetNeighborsForClass(pathObject.getClass()); return true; } @@ -561,9 +554,13 @@ public synchronized boolean addObjects(Collection pathObje changes = addPathObjectToList(getRootObject(), pathObject, false) || changes; counter++; } - if (changes) + if (changes) { fireHierarchyChangedEvent(getRootObject()); // fireChangeEvent(getRootObject()); + for (var cls : pathObjects.stream().map(PathObject::getClass).distinct().toList()) { + resetNeighborsForClass(cls); + } + } return changes; } @@ -574,6 +571,7 @@ public synchronized boolean addObjects(Collection pathObje public synchronized void clearAll() { getRootObject().clearChildObjects(); tmaGrid = null; + resetNeighbors(); fireHierarchyChangedEvent(getRootObject()); } @@ -677,6 +675,7 @@ public void updateObject(PathObject pathObject, boolean isChanging) { if (inHierarchy(pathObject)) removeObject(pathObject, true, false); addPathObjectImpl(pathObject, false); + resetNeighborsForClass(pathObject.getClass()); fireObjectsChangedEvent(this, Collections.singletonList(pathObject), isChanging); // fireHierarchyChangedEvent(this, pathObject); } @@ -739,6 +738,7 @@ public synchronized void setHierarchy(PathObjectHierarchy hierarchy) { return; rootObject = hierarchy.getRootObject(); tmaGrid = hierarchy.tmaGrid; + resetNeighbors(); fireHierarchyChangedEvent(rootObject); } @@ -1143,11 +1143,95 @@ synchronized void fireEvent(PathObjectHierarchyEvent event) { listener.hierarchyChanged(event); } } - + + private synchronized void resetNeighborsForClass(Class cls) { + subdivisionManager.clear(); + } + + private synchronized void resetNeighbors() { + subdivisionManager.clear(); + } + + /** + * Find all neighbors of a PathObject, having the same class as the object (e.g. detection, cell, annotation). + * This is based on centroids and Delaunay triangulation. + * It also assumes 'square' pixels, and searches for neighbors only on the same 2D plane (z and t). + * @param pathObject + * @return + */ + public synchronized List findAllNeighbors(PathObject pathObject) { + var subdivision = getSubdivision(pathObject); + return subdivision == null ? Collections.emptyList() : subdivision.getNeighbors(pathObject); + } + + /** + * Find the nearest neighbor of a PathObject, having the same class as the object (e.g. detection, cell, annotation). + * This is based on centroids and Delaunay triangulation. + * It also assumes 'square' pixels, and searches for neighbors only on the same 2D plane (z and t). + * @param pathObject + * @return + */ + public synchronized PathObject findNearestNeighbor(PathObject pathObject) { + var subdivision = getSubdivision(pathObject); + return subdivision == null ? null : subdivision.getNearestNeighbor(pathObject); + } + + /** + * Get the subdivision containing a specific PathObject. + * This is based on centroids and Delaunay triangulation in 2D. + * It supports #findAllNeighbors(PathObject) and #findNearestNeighbor(PathObject); obtaining the subdivision + * enables a wider range of spatial queries. + * @param pathObject + * @return + */ + public synchronized DelaunayTools.Subdivision getSubdivision(PathObject pathObject) { + return subdivisionManager.getSubdivision(pathObject); + } + + private DelaunayTools.Subdivision computeSubdivision(Class cls) { + var pathObjects = tileCache.getObjectsForRegion(cls, + null, null, false); + return DelaunayTools.createFromCentroids(pathObjects, true); + } + @Override public String toString() { return "Hierarchy: " + nObjects() + " objects"; } + + private class SubdivisionManager { + + private static final DelaunayTools.Subdivision EMPTY = DelaunayTools.createFromCentroids(Collections.emptyList(), true); + + private static final Map, + Map> subdivisionMap = new ConcurrentHashMap<>(); + + synchronized DelaunayTools.Subdivision getSubdivision(PathObject pathObject) { + if (pathObject == null || !pathObject.hasROI()) { + return EMPTY; + } + var map = subdivisionMap.computeIfAbsent(pathObject.getClass(), k -> new ConcurrentHashMap<>()); + var plane = pathObject.getROI().getImagePlane(); + return map.computeIfAbsent(plane, k -> computeSubdivision(pathObject.getClass(), plane)); + } + + private DelaunayTools.Subdivision computeSubdivision(Class cls, ImagePlane plane) { + var pathObjects = tileCache.getObjectsForRegion(cls, + ImageRegion.createInstance(-Integer.MAX_VALUE/2, -Integer.MAX_VALUE/2, + Integer.MAX_VALUE, Integer.MAX_VALUE, plane.getZ(), plane.getT()), + null, false); + return DelaunayTools.createFromCentroids(pathObjects, true); + } + + private synchronized void clear() { + subdivisionMap.clear(); + } + + private synchronized void clearClass(Class cls) { + subdivisionMap.getOrDefault(cls, Collections.emptyMap()).clear(); + } + + } } From a3c2702db67d7a546fa3d9a5393aab289196d6c6 Mon Sep 17 00:00:00 2001 From: Pete Date: Wed, 21 Aug 2024 07:50:04 +0100 Subject: [PATCH 217/866] Remove commented-out code --- .../detect/cells/WatershedCellDetection.java | 2 - .../cells/WatershedCellMembraneDetection.java | 77 ++++++++----------- .../MorphologicalReconstruction.java | 13 +--- .../qupath/imagej/processing/Watershed.java | 15 ---- .../java/qupath/imagej/tools/IJTools.java | 2 - .../algorithms/CoherenceFeaturePlugin.java | 16 +--- .../algorithms/HaralickFeaturesPlugin.java | 7 +- .../algorithms/IntensityFeaturesPlugin.java | 9 --- .../algorithms/LocalBinaryPatternsPlugin.java | 10 +-- .../MorphologicalReconstruction.java | 2 - .../lib/analysis/algorithms/Watershed.java | 1 - .../lib/analysis/features/CoocMatrix.java | 10 --- .../features/CoocurranceMatrices.java | 2 - .../features/LocalBinaryPatterns.java | 32 -------- .../features/DelaunayTriangulation.java | 27 ------- .../java/qupath/lib/color/StainVector.java | 10 --- .../main/java/qupath/lib/common/Timeit.java | 6 +- .../TestPathObjectHierarchyLegacy.java | 5 +- .../bioformats/TestBioFormatsImageServer.java | 9 +-- 19 files changed, 41 insertions(+), 214 deletions(-) diff --git a/qupath-core-processing/src/main/java/qupath/imagej/detect/cells/WatershedCellDetection.java b/qupath-core-processing/src/main/java/qupath/imagej/detect/cells/WatershedCellDetection.java index 3c15e89e0..e81a9c2df 100644 --- a/qupath-core-processing/src/main/java/qupath/imagej/detect/cells/WatershedCellDetection.java +++ b/qupath-core-processing/src/main/java/qupath/imagej/detect/cells/WatershedCellDetection.java @@ -1251,7 +1251,6 @@ protected ObjectDetector createDetector(ImageData @Override protected int getTileOverlap(ImageData imageData, ParameterList params) { -// double pxSize = getPreferredPixelSizeMicrons(imageData, params); double pxSize = imageData.getServer().getPixelCalibration().getAveragedPixelSizeMicrons(); if (!Double.isFinite(pxSize)) return params.getDoubleParameterValue("cellExpansion") > 0 ? 25 : 10; @@ -1261,7 +1260,6 @@ protected int getTileOverlap(ImageData imageData, ParameterList p if (cellExpansion > 0) expansionMicrons += params.getDoubleParameterValue("cellExpansionMicrons"); int overlap = (int)(expansionMicrons / pxSize * 2.0); -// System.out.println("Tile overlap: " + overlap + " pixels"); return overlap; } diff --git a/qupath-core-processing/src/main/java/qupath/imagej/detect/cells/WatershedCellMembraneDetection.java b/qupath-core-processing/src/main/java/qupath/imagej/detect/cells/WatershedCellMembraneDetection.java index 98051bb82..ad36c449c 100644 --- a/qupath-core-processing/src/main/java/qupath/imagej/detect/cells/WatershedCellMembraneDetection.java +++ b/qupath-core-processing/src/main/java/qupath/imagej/detect/cells/WatershedCellMembraneDetection.java @@ -230,49 +230,40 @@ public Collection runDetection(final ImageData imageD if (pathROI == null) return null; // Get a PathImage if we have a new ROI -// boolean imageChanged = false; PathImage pathImage = null; -// if (pathImage == null || lastServerPath == null || !lastServerPath.equals(imageData.getServerPath()) || !pathROI.equals(this.pathROI)) { - ImageServer server = imageData.getServer(); - pathImage = IJTools.convertToImagePlus(server, RegionRequest.createInstance(server.getPath(), - ServerTools.getDownsampleFactor(server, getPreferredPixelSizeMicrons(imageData, params)), - pathROI)); -// System.out.println("Downsample: " + pathImage.getDownsampleFactor()); -// this.pathROI = pathROI; -// lastServerPath = imageData.getServerPath(); -// imageChanged = true; -// } + ImageServer server = imageData.getServer(); + pathImage = IJTools.convertToImagePlus(server, RegionRequest.createInstance(server.getPath(), + ServerTools.getDownsampleFactor(server, getPreferredPixelSizeMicrons(imageData, params)), + pathROI)); // Create a detector if we don't already have one for this image boolean isBrightfield = imageData.isBrightfield(); FloatProcessor fpDetection = null, fpH = null, fpDAB = null; -// if (detector2 == null || imageChanged || stains != imageData.getColorDeconvolutionStains()) { - ImageProcessor ip = pathImage.getImage().getProcessor(); - ColorDeconvolutionStains stains = imageData.getColorDeconvolutionStains(); - if (ip instanceof ColorProcessor && stains != null) { - - FloatProcessor[] fps = IJTools.colorDeconvolve((ColorProcessor)ip, stains); - fpH = fps[0]; - if (stains.isH_DAB()) - fpDAB = fps[1]; - else - fpDAB = null; // At this point, only DAB is quantified (eosin ignored for H&E) - - if (!params.getParameters().get("detectionImageBrightfield").isHidden()) { - if (params.getChoiceParameterValue("detectionImageBrightfield").equals(IMAGE_OPTICAL_DENSITY)) - fpDetection = IJTools.convertToOpticalDensitySum((ColorProcessor)ip, stains.getMaxRed(), stains.getMaxGreen(), stains.getMaxBlue()); - else - fpDetection = (FloatProcessor)fpH.duplicate(); - } + ImageProcessor ip = pathImage.getImage().getProcessor(); + ColorDeconvolutionStains stains = imageData.getColorDeconvolutionStains(); + if (ip instanceof ColorProcessor && stains != null) { - - } - if (fpDetection == null) { - // TODO: Deal with fluorescence - fpDetection = ip.convertToFloatProcessor(); - fpH = ip.convertToFloatProcessor(); - fpDAB = null; + FloatProcessor[] fps = IJTools.colorDeconvolve((ColorProcessor)ip, stains); + fpH = fps[0]; + if (stains.isH_DAB()) + fpDAB = fps[1]; + else + fpDAB = null; // At this point, only DAB is quantified (eosin ignored for H&E) + + if (!params.getParameters().get("detectionImageBrightfield").isHidden()) { + if (params.getChoiceParameterValue("detectionImageBrightfield").equals(IMAGE_OPTICAL_DENSITY)) + fpDetection = IJTools.convertToOpticalDensitySum((ColorProcessor)ip, stains.getMaxRed(), stains.getMaxGreen(), stains.getMaxBlue()); + else + fpDetection = (FloatProcessor)fpH.duplicate(); } -// } + + + } + if (fpDetection == null) { + // TODO: Deal with fluorescence + fpDetection = ip.convertToFloatProcessor(); + fpH = ip.convertToFloatProcessor(); + fpDAB = null; + } Roi roi = null; if (pathROI != null) roi = IJTools.convertToIJRoi(pathROI, pathImage); @@ -466,10 +457,7 @@ public static ByteProcessor limitedOpeningByReconstruction(final ImageProcessor if (bpMask.getf(i) != 0f) { ipBackground.setf(i, Float.NEGATIVE_INFINITY); } - } -// } else { -// // Don't return a mask - all pixels are ok -// System.out.println("Skipping background mask!"); + } } } @@ -799,10 +787,10 @@ private void doDetection(boolean regenerateROIs) { PolygonROI pathROI = IJTools.convertToPolygonROI(r, cal, pathImage.getDownsampleFactor(), plane); if (smoothBoundaries) { -// int nBefore = pathROI.nVertices(); + int nBefore = pathROI.nVertices(); pathROI = ShapeSimplifier.simplifyPolygon(pathROI, pathImage.getDownsampleFactor()/4.0); -// int nAfter = pathROI.nVertices(); -// System.out.println("Vertices removed: " + (nBefore - nAfter)); + int nAfter = pathROI.nVertices(); + logger.trace("Vertices removed: {}", (nBefore - nAfter)); } // Create a new shared measurement list @@ -1129,7 +1117,6 @@ protected int getTileOverlap(ImageData imageData, ParameterList p return params.getDoubleParameterValue("cellExpansion") > 0 ? 25 : 10; double cellExpansion = params.getDoubleParameterValue("cellExpansionMicrons") / pxSize; int overlap = cellExpansion > 0 ? (int)(cellExpansion * 2) : 10; -// System.out.println("Tile overlap: " + overlap + " pixels"); return overlap; } diff --git a/qupath-core-processing/src/main/java/qupath/imagej/processing/MorphologicalReconstruction.java b/qupath-core-processing/src/main/java/qupath/imagej/processing/MorphologicalReconstruction.java index 25714e1a2..315b048a9 100644 --- a/qupath-core-processing/src/main/java/qupath/imagej/processing/MorphologicalReconstruction.java +++ b/qupath-core-processing/src/main/java/qupath/imagej/processing/MorphologicalReconstruction.java @@ -278,30 +278,21 @@ public static boolean morphologicalReconstruction(final ImageProcessor ipMarker, // repeat propagation steps so long as they are changing a sufficiently high proportion of the pixels // at each step (here, 10%) -// long startTime = System.currentTimeMillis(); final int nPixels = ipMarker.getWidth() * ipMarker.getHeight(); // Apply forward propagation, counting the number of pixels that changed int nChanges = dilateAndCompare(ipMarker, ipMask, false, null); // For as long as > ?% pixels change, continue propagations -// int counter = 0; while (nChanges/(double)nPixels > 0.1) { -// System.out.println(String.format("Changes as propagation iteration %d: %.2f%%", counter++, 100.*nChanges/nPixels)); // Apply backwards propagation dilateAndCompare(ipMarker, ipMask, true, null); // Apply forward propagation again nChanges = dilateAndCompare(ipMarker, ipMask, false, null); } -// System.out.println(String.format("Changes as propagation iteration %d: %.2f%%", counter++, 100.*nChanges/nPixels)); // Apply backwards propagation, filling the queue final IntDequeue queue = new IntDequeue(nPixels/4); dilateAndCompare(ipMarker, ipMask, true, queue); -// long endTime = System.currentTimeMillis(); -// System.out.println("Queue setup time: " + (endTime - startTime)/1000.); - -// startTime = System.currentTimeMillis(); processQueue(ipMarker, ipMask, queue); -// endTime = System.currentTimeMillis(); -// System.out.println("Queue process time: " + (endTime - startTime)/1000.); + // Process pixels in the queue (FIFO) return processQueue(ipMarker, ipMask, queue); } @@ -569,8 +560,6 @@ private void add(int val) { array = array2; array[tail] = val; tail++; -// IJ.log("Expanding IntDeque to length " + array.length); -// System.out.println("Expanding IntDeque to length " + array.length); } } diff --git a/qupath-core-processing/src/main/java/qupath/imagej/processing/Watershed.java b/qupath-core-processing/src/main/java/qupath/imagej/processing/Watershed.java index 802fb76f8..43da3d9e3 100644 --- a/qupath-core-processing/src/main/java/qupath/imagej/processing/Watershed.java +++ b/qupath-core-processing/src/main/java/qupath/imagej/processing/Watershed.java @@ -311,18 +311,10 @@ public PixelWithValue(final int x, final int y, final float value, final long co this.y = y; this.value = value; this.count = count; - -// System.out.println("My count: " + count); } @Override public int compareTo(final PixelWithValue pwv) { -// // Profiling indicates that the many comparisons are the slowest part of the algorithm... -// if (value == pwv.value) -// return count > pwv.count ? 1 : -1; -// else -// return value > pwv.value ? -1 : 1; - // Profiling indicates that the many comparisons are the slowest part of the algorithm... if (value < pwv.value) { return 1; @@ -331,13 +323,6 @@ else if (value > pwv.value) { return -1; } return count > pwv.count ? 1 : -1; - -// // Profiling indicates that the many comparisons are the slowest part of the algorithm... -// if (value > pwv.value) -// return 1; -// else if (value < pwv.value) -// return -1; -// return count > pwv.count ? 1 : -1; } } diff --git a/qupath-core-processing/src/main/java/qupath/imagej/tools/IJTools.java b/qupath-core-processing/src/main/java/qupath/imagej/tools/IJTools.java index c9fde56a6..3c01bc9c6 100644 --- a/qupath-core-processing/src/main/java/qupath/imagej/tools/IJTools.java +++ b/qupath-core-processing/src/main/java/qupath/imagej/tools/IJTools.java @@ -1027,8 +1027,6 @@ public static > ROI convertToROI(Roi ro */ public static PolygonROI convertToPolygonROI(PolygonRoi roi, Calibration cal, double downsampleFactor, final ImagePlane plane) { List points = ROIConverterIJ.convertToPointsList(roi.getFloatPolygon(), cal, downsampleFactor); - if (points == null) - return null; return ROIs.createPolygonROI(points, plane); } diff --git a/qupath-core-processing/src/main/java/qupath/lib/algorithms/CoherenceFeaturePlugin.java b/qupath-core-processing/src/main/java/qupath/lib/algorithms/CoherenceFeaturePlugin.java index ab52368af..533aea38d 100644 --- a/qupath-core-processing/src/main/java/qupath/lib/algorithms/CoherenceFeaturePlugin.java +++ b/qupath-core-processing/src/main/java/qupath/lib/algorithms/CoherenceFeaturePlugin.java @@ -181,9 +181,6 @@ static boolean processObject(final PathObject pathObject, final ParameterList pa int height = Math.min(server.getHeight(), yStart + size.height) - yStart; RegionRequest region = RegionRequest.createInstance(server.getPath(), downsample, xStart, yStart, width, height, pathROI.getT(), pathROI.getZ()); -// System.out.println(bounds); -// System.out.println("Size: " + size); - BufferedImage img = server.readRegion(region); // Get a buffer containing the image pixels @@ -239,16 +236,12 @@ static void processTransformedImage(SimpleModifiableImage pxImg, int[] buf, floa double cy = (h-1) / 2; double radius = Math.max(w, h) * .5; double distThreshold = radius * radius; -// int count = 0; for (int y = 0; y < h; y++) { for (int x = 0; x < w; x++) { if ((cx - x)*(cx - x) + (cy - y)*(cy - y) > distThreshold) pxImg.setValue(x, y, Float.NaN); -// else -// count++; - } + } } -// System.out.println("Masked count: " + count + " for dimension " + w + ", " + h); } @@ -311,13 +304,6 @@ static double computeCoherence(final SimpleImage img) { double ratio = (l1 - l2) / (l1 + l2); return ratio*ratio; - -// denominator = (fxx + fyy); -// numerator = ((fyy - fxx)*(fyy - fxx) + 4*fxy); -// -// System.out.println(numerator); -// return Math.sqrt(numerator) / denominator; -//// return numerator / (denominator * denominator); } diff --git a/qupath-core-processing/src/main/java/qupath/lib/algorithms/HaralickFeaturesPlugin.java b/qupath-core-processing/src/main/java/qupath/lib/algorithms/HaralickFeaturesPlugin.java index 52c9337d0..c887137f0 100644 --- a/qupath-core-processing/src/main/java/qupath/lib/algorithms/HaralickFeaturesPlugin.java +++ b/qupath-core-processing/src/main/java/qupath/lib/algorithms/HaralickFeaturesPlugin.java @@ -248,9 +248,6 @@ else if (cal.hasPixelSizeMicrons()) { if (region.getWidth() / downsample < 3 || region.getHeight() / downsample < 3) return false; -// System.out.println(bounds); -// System.out.println("Size: " + size); - BufferedImage img = server.readRegion(region); if (img == null) { @@ -424,9 +421,7 @@ static void addBasicStatistics(final SimpleImage img, final MeasurementList meas kurtosis += d3*d/n; } } - // TODO: Reinsert skewness & kurtosis measurements, after checking -//// double sigma = stats.getStdDev(); -//// System.out.println("Variance difference: " + variance + ", " + stats.getVariance()); + // TODO: Consider reinstating skewness & kurtosis measurements, after checking //// measurementList.putMeasurement(name + " Variance (again)", variance); measurementList.put(name + " Skewness", skewness/(variance*Math.sqrt(variance))); measurementList.put(name + " Kurtosis", kurtosis/(variance*variance)); diff --git a/qupath-core-processing/src/main/java/qupath/lib/algorithms/IntensityFeaturesPlugin.java b/qupath-core-processing/src/main/java/qupath/lib/algorithms/IntensityFeaturesPlugin.java index a849c0d8b..209931e29 100644 --- a/qupath-core-processing/src/main/java/qupath/lib/algorithms/IntensityFeaturesPlugin.java +++ b/qupath-core-processing/src/main/java/qupath/lib/algorithms/IntensityFeaturesPlugin.java @@ -559,15 +559,6 @@ static boolean processObject(final PathObject pathObject, final ParameterList pa region = RegionRequest.createInstance(server.getPath(), downsample, xStart, yStart, width, height, pathROI.getT(), pathROI.getZ()); } -// // Check image large enough to do *anything* of value -// if (region.getWidth() / downsample < 1 || region.getHeight() / downsample < 1) { -// logger.trace("Requested region is too small! {}", region); -// return false; -// } - - // System.out.println(bounds); - // System.out.println("Size: " + size); - BufferedImage img = server.readRegion(region); if (img == null) { logger.error("Could not read image - unable to compute intensity features for {}", pathObject); diff --git a/qupath-core-processing/src/main/java/qupath/lib/algorithms/LocalBinaryPatternsPlugin.java b/qupath-core-processing/src/main/java/qupath/lib/algorithms/LocalBinaryPatternsPlugin.java index 7b050ce35..38adeb7c8 100644 --- a/qupath-core-processing/src/main/java/qupath/lib/algorithms/LocalBinaryPatternsPlugin.java +++ b/qupath-core-processing/src/main/java/qupath/lib/algorithms/LocalBinaryPatternsPlugin.java @@ -179,13 +179,8 @@ static boolean processObject(final PathObject pathObject, final ParameterList pa RegionRequest region = RegionRequest.createInstance(server.getPath(), downsample, (int)(pathROI.getCentroidX() + .5) - size.width/2, (int)(pathROI.getCentroidY() + .5) - size.height/2, size.width, size.height, pathROI.getT(), pathROI.getZ()); -// System.out.println(bounds); -// System.out.println("Size: " + size); - BufferedImage img = server.readRegion(region); -// System.out.println("Image size: " + img.getWidth() + " x " + img.getHeight() + " pixels"); - // Get a buffer containing the image pixels int w = img.getWidth(); int h = img.getHeight(); @@ -244,11 +239,8 @@ static void processTransformedImage(SimpleModifiableImage pxImg, int[] buf, floa for (int x = 0; x < w; x++) { if ((cx - x)*(cx - x) + (cy - y)*(cy - y) > distThreshold) pxImg.setValue(x, y, Float.NaN); -// else -// count++; - } + } } -// System.out.println("Masked count: " + count + " for dimension " + w + ", " + h); } diff --git a/qupath-core-processing/src/main/java/qupath/lib/analysis/algorithms/MorphologicalReconstruction.java b/qupath-core-processing/src/main/java/qupath/lib/analysis/algorithms/MorphologicalReconstruction.java index 18229a04f..2d70d4b49 100644 --- a/qupath-core-processing/src/main/java/qupath/lib/analysis/algorithms/MorphologicalReconstruction.java +++ b/qupath-core-processing/src/main/java/qupath/lib/analysis/algorithms/MorphologicalReconstruction.java @@ -178,8 +178,6 @@ private static boolean processQueue(final SimpleModifiableImage ipMarker, final while (!queue.isEmpty()) { counter++; -// System.out.println("Counter " + counter); - // If we were interrupted, stop if (counter % 2500 == 0 && currentThread.isInterrupted()) return false; diff --git a/qupath-core-processing/src/main/java/qupath/lib/analysis/algorithms/Watershed.java b/qupath-core-processing/src/main/java/qupath/lib/analysis/algorithms/Watershed.java index 569ec094d..9e36dd7bb 100644 --- a/qupath-core-processing/src/main/java/qupath/lib/analysis/algorithms/Watershed.java +++ b/qupath-core-processing/src/main/java/qupath/lib/analysis/algorithms/Watershed.java @@ -263,7 +263,6 @@ public PixelWithValue(final int x, final int y, final float value, final long co this.y = y; this.value = value; this.count = count; -// System.out.println("My count: " + count); } @Override diff --git a/qupath-core-processing/src/main/java/qupath/lib/analysis/features/CoocMatrix.java b/qupath-core-processing/src/main/java/qupath/lib/analysis/features/CoocMatrix.java index e04fdcd23..b20ec6ac7 100644 --- a/qupath-core-processing/src/main/java/qupath/lib/analysis/features/CoocMatrix.java +++ b/qupath-core-processing/src/main/java/qupath/lib/analysis/features/CoocMatrix.java @@ -71,16 +71,6 @@ public void addToEntry(int row, int col) { */ public void finalizeMatrix() { logSum = Math.log(sum); -//J int max = 0; -//J for (int v : mat) -//J if (v > max) -//J max = v; //J this is not really leading anywhere! -// System.out.println("Logs with max " + max); -// logTable = new double[max+1]; -// logTable[0] = Math.log(0.000001); -// for (int i = 1; i < logTable.length; i++) { -// logTable[i] = Math.log(i); -// } } /** diff --git a/qupath-core-processing/src/main/java/qupath/lib/analysis/features/CoocurranceMatrices.java b/qupath-core-processing/src/main/java/qupath/lib/analysis/features/CoocurranceMatrices.java index 484b5d732..970a0be1a 100644 --- a/qupath-core-processing/src/main/java/qupath/lib/analysis/features/CoocurranceMatrices.java +++ b/qupath-core-processing/src/main/java/qupath/lib/analysis/features/CoocurranceMatrices.java @@ -97,8 +97,6 @@ public void computeFeatures() { features[1] = new HaralickFeatures(mat45); features[2] = new HaralickFeatures(mat90); features[3] = new HaralickFeatures(mat135); -// double n2 = mat0.getN()*mat0.getN(); -// System.out.println("Num entries: " + mat0.getMean()*n2 + ", " + mat45.getMean()*n2 + ", " + mat90.getMean()*n2 + ", " + mat135.getMean()*n2); } /** diff --git a/qupath-core-processing/src/main/java/qupath/lib/analysis/features/LocalBinaryPatterns.java b/qupath-core-processing/src/main/java/qupath/lib/analysis/features/LocalBinaryPatterns.java index 3ee7eb261..a295c9c25 100644 --- a/qupath-core-processing/src/main/java/qupath/lib/analysis/features/LocalBinaryPatterns.java +++ b/qupath-core-processing/src/main/java/qupath/lib/analysis/features/LocalBinaryPatterns.java @@ -158,38 +158,6 @@ private static void tidyDoubleUnitArray(final double[] arr) { } - - -// public static void main(String[] args) { -// System.out.println("Size: " + nLBF8); -// System.out.println(Arrays.toString(lbf8map)); -// -// System.out.println("Size uniform: " + nLBF8Uniform); -// System.out.println(Arrays.toString(lbf8UniformMap)); -// -// System.out.println("Size uniform 16: " + nLBF16Uniform); -//// // Just to check there really are the correct number of entries... -//// Arrays.sort(lbf16UniformMap); -//// System.out.println(Arrays.toString(lbf16UniformMap)); -// -// System.out.println("X offsets (8): " + Arrays.toString(xo8)); -// System.out.println("Y offsets (8): " + Arrays.toString(yo8)); -// -// System.out.println("X offsets (16): " + Arrays.toString(xo16)); -// System.out.println("Y offsets (16): " + Arrays.toString(yo16)); -// -// -// // TODO: Move to test code location -// SimpleImage img = SimpleImages.createFloatImage(new float[]{1f, 2f, 3f, 4f}, 2, 2); -// double[] xx = {0, 0.25, 0.5, 0.75, 1}; -// for (double y = 0; y <= 1; y += 0.25) { -// for (double x : xx) -//// for (double x : new double[]{0.5}) -// System.out.println(String.format("(%.2f, %.2f): %.4f", x, y, getInterpolatedPixel(img, x, y))); -// } -// } - - private static int computeLocalBinaryPattern(final SimpleImage img, final int x, final int y) { float val = img.getValue(x, y); diff --git a/qupath-core-processing/src/main/java/qupath/opencv/features/DelaunayTriangulation.java b/qupath-core-processing/src/main/java/qupath/opencv/features/DelaunayTriangulation.java index d816acfbd..d6dc149ed 100644 --- a/qupath-core-processing/src/main/java/qupath/opencv/features/DelaunayTriangulation.java +++ b/qupath-core-processing/src/main/java/qupath/opencv/features/DelaunayTriangulation.java @@ -277,22 +277,6 @@ void updateNodeMap(Subdiv2D subdiv, final double pixelWidth, final double pixelH destinationNode.addEdge(node); } - // Unused code exploring how a similarity test could be included -// if (ignoreDistance || distance(pathObject.getROI(), destination.getROI()) < distanceThreshold) { -// MeasurementList m1 = pathObject.getMeasurementList(); -// MeasurementList m2 = destination.getMeasurementList(); -// double d2 = 0; -// for (String name : new String[]{"Nucleus: Area", "Nucleus: DAB OD mean", "Nucleus: Eccentricity"}) { -// double t1 = m1.getMeasurementValue(name); -// double t2 = m2.getMeasurementValue(name); -// double temp = ((t1 - t2) / (t1 + t2)) * 2; -// d2 += temp*temp; -// } -// if (d2 < 1) -//// System.out.println(d2); -// node.addEdge(factory.getNode(destination)); -// } - edge = subdiv.getEdge(edge, Subdiv2D.NEXT_AROUND_ORG); if (edge == firstEdge) break; @@ -790,18 +774,7 @@ public double getArea() { double ay = node1.y - node3.y; double bx = node2.x - node3.x; double by = node2.y - node3.y; - -// // Little bit of checking... -// List points = new ArrayList<>(); -// points.add(new Point2(node1.x, node1.y)); -// points.add(new Point2(node2.x, node2.y)); -// points.add(new Point2(node3.x, node3.y)); -// double area = Math.abs(ax * by - ay * bx)/2; -// PolygonROI polygon = new PolygonROI(points); -// System.out.println(area + "\t-\t" + polygon.getArea() + "\tDiff: " + (area - polygon.getArea())); - return Math.abs(ax * by - ay * bx)/2; - } } diff --git a/qupath-core/src/main/java/qupath/lib/color/StainVector.java b/qupath-core/src/main/java/qupath/lib/color/StainVector.java index 634d0aa8b..de84b99d2 100644 --- a/qupath-core/src/main/java/qupath/lib/color/StainVector.java +++ b/qupath-core/src/main/java/qupath/lib/color/StainVector.java @@ -273,16 +273,6 @@ public static double computeAngle(StainVector s1, StainVector s2) { if (Math.abs(1 - Math.sqrt(n1) * Math.sqrt(n2)) < 0.001) return Math.acos(dot) / Math.PI * 180; return Math.acos(dot / (Math.sqrt(n1) * Math.sqrt(n2))) / Math.PI * 180; - - -// double val = Math.acos(dot / (Math.sqrt(n1) * Math.sqrt(n2))) / Math.PI * 180; -// if (Double.isNaN(val)) { -// System.out.println(dot / (Math.sqrt(n1) * Math.sqrt(n2))); -// System.out.println(s1 + ", " + s2); -//// System.out.println(s2); -// } -//// System.out.println(val); -// return Math.acos(dot / (Math.sqrt(n1) * Math.sqrt(n2))) / Math.PI * 180; } diff --git a/qupath-core/src/main/java/qupath/lib/common/Timeit.java b/qupath-core/src/main/java/qupath/lib/common/Timeit.java index c0346cc2c..204623f04 100644 --- a/qupath-core/src/main/java/qupath/lib/common/Timeit.java +++ b/qupath-core/src/main/java/qupath/lib/common/Timeit.java @@ -454,7 +454,7 @@ public static void main(String[] args) { var timeit = new Timeit() .microseconds() .checkpointAndRun("Greeting", () -> { - System.out.println("Hello!"); + logger.info("Hello!"); try { Thread.sleep(10L); } catch (Exception e) {} @@ -464,10 +464,10 @@ public static void main(String[] args) { Thread.sleep(100L); - System.out.println(timeit); + logger.info("{}", timeit); } catch (Exception e) { - e.printStackTrace(); + logger.error(e.getMessage(), e); } } diff --git a/qupath-core/src/test/java/qupath/lib/objects/hierarchy/TestPathObjectHierarchyLegacy.java b/qupath-core/src/test/java/qupath/lib/objects/hierarchy/TestPathObjectHierarchyLegacy.java index 52e2193fd..b57e13d5e 100644 --- a/qupath-core/src/test/java/qupath/lib/objects/hierarchy/TestPathObjectHierarchyLegacy.java +++ b/qupath-core/src/test/java/qupath/lib/objects/hierarchy/TestPathObjectHierarchyLegacy.java @@ -399,13 +399,10 @@ public void setFiredState(int state) { @Override public void hierarchyChanged(PathObjectHierarchyEvent event) { if (event.getEventType() == PathObjectHierarchyEvent.HierarchyEventType.ADDED) - //System.out.println("Added!"); - this.firedState = 1; + this.firedState = 1; else if (event.getEventType() == PathObjectHierarchyEvent.HierarchyEventType.REMOVED) - //System.out.println("Removed!"); this.firedState = 2; else if (event.getEventType() == PathObjectHierarchyEvent.HierarchyEventType.OTHER_STRUCTURE_CHANGE) - //System.out.println("Other!"); this.firedState = 3; } diff --git a/qupath-extension-bioformats/src/test/java/qupath/lib/images/servers/bioformats/TestBioFormatsImageServer.java b/qupath-extension-bioformats/src/test/java/qupath/lib/images/servers/bioformats/TestBioFormatsImageServer.java index 135a8f3e7..dd6b4ce23 100644 --- a/qupath-extension-bioformats/src/test/java/qupath/lib/images/servers/bioformats/TestBioFormatsImageServer.java +++ b/qupath-extension-bioformats/src/test/java/qupath/lib/images/servers/bioformats/TestBioFormatsImageServer.java @@ -250,14 +250,8 @@ void testProject(Project project) { DebugTools.setRootLevel("error"); List> entries = project.getImageList(); - System.out.println("Testing project with " + entries.size() + " entries: " + Project.getNameFromURI(project.getURI())); + logger.info("Testing project with {} entries: {}", entries.size(), Project.getNameFromURI(project.getURI())); for (ProjectImageEntry entry : entries) { -// String serverPath = entry.getServerPath(); -// System.out.println("Opening: " + serverPath); -// String pathFile = BioFormatsImageServer.splitFilePathAndSeriesName(serverPath)[0]; -// if (!new File(pathFile).exists()) { -// System.err.println("File does not exist for server path " + serverPath + " - will skip"); -// } BioFormatsImageServer server = null; BufferedImage img = null; BufferedImage imgThumbnail = null; @@ -268,7 +262,6 @@ void testProject(Project project) { try { // Create the server server = (BioFormatsImageServer)entry.getServerBuilder().build(); -// server = (BioFormatsImageServer)ImageServerProvider.buildServer(serverPath, BufferedImage.class, "--classname", BioFormatsServerBuilder.class.getName()); // Read a thumbnail imgThumbnail = server.getDefaultThumbnail(server.nZSlices()/2, 0); // Read from the center of the image From 8615923818483507bb67887ca4af0749d87c783c Mon Sep 17 00:00:00 2001 From: Pete Date: Wed, 21 Aug 2024 08:06:25 +0100 Subject: [PATCH 218/866] Remove more System.out comments --- .../plugins/objects/SmoothFeaturesPlugin.java | 14 -- .../qupath/lib/analysis/stats/Histogram.java | 12 +- .../qupath/lib/color/ColorTransformer.java | 4 - .../main/java/qupath/lib/roi/RoiEditor.java | 6 - .../display/AbstractSingleChannelInfo.java | 5 +- .../java/qupath/lib/display/ImageDisplay.java | 9 -- .../servers/PathHierarchyImageServer.java | 5 - .../stores/AbstractImageRegionStore.java | 3 - .../stores/DefaultImageRegionStore.java | 7 - .../gui/images/stores/DefaultRegionCache.java | 7 - .../stores/ImageRegionStoreHelpers.java | 137 +----------------- .../lib/gui/tma/KaplanMeierChartWrapper.java | 37 ----- .../qupath/lib/gui/viewer/ImageOverview.java | 10 -- .../qupath/lib/gui/viewer/QuPathViewer.java | 6 +- .../java/qupath/lib/gui/viewer/Scalebar.java | 2 - .../tools/handlers/MoveToolEventHandler.java | 3 - 16 files changed, 5 insertions(+), 262 deletions(-) diff --git a/qupath-core-processing/src/main/java/qupath/lib/plugins/objects/SmoothFeaturesPlugin.java b/qupath-core-processing/src/main/java/qupath/lib/plugins/objects/SmoothFeaturesPlugin.java index 982efea0a..0ab75b915 100644 --- a/qupath-core-processing/src/main/java/qupath/lib/plugins/objects/SmoothFeaturesPlugin.java +++ b/qupath-core-processing/src/main/java/qupath/lib/plugins/objects/SmoothFeaturesPlugin.java @@ -134,7 +134,6 @@ public void run() { try { if (!parentObject.hasChildObjects()) return; - // System.out.println("Smoothing with FWHM " +fwhmPixels); // TODO: MAKE A MORE ELEGANT LIST!!!! List pathObjects = PathObjectTools.getFlattenedObjectList(parentObject, null, false); Iterator iterObjects = pathObjects.iterator(); @@ -215,7 +214,6 @@ public static void smoothMeasurements(List pathObjects, List double maxDistSq = maxDist * maxDist; // Maximum separation int nObjects = pathObjects.size(); - // int counter = 0; // Sort by x-coordinate - this gives us a method of breaking early Collections.sort(pathObjects, new Comparator<>() { @@ -223,19 +221,7 @@ public static void smoothMeasurements(List pathObjects, List public int compare(PathObject o1, PathObject o2) { double x1 = o1.getROI().getCentroidX(); double x2 = o2.getROI().getCentroidX(); -// int value = Double.compare(x1, x2); -// if (value == 0) { -// System.out.println(x1 + " vs. " + x2); -// System.out.println(String.format("(%.2f, %.2f) vs (%.2f, %.2f)", o1.getROI().getCentroidX(), o1.getROI().getCentroidY(), o2.getROI().getCentroidX(), o2.getROI().getCentroidY())); } return Double.compare(x1, x2); -// if (x1 > x2) -// return 1; -// if (x2 < x1) -// return -1; -// System.out.println(x1 + " vs. " + x2); -// System.out.println(String.format("(%.2f, %.2f) vs (%.2f, %.2f)", o1.getROI().getCentroidX(), o1.getROI().getCentroidY(), o2.getROI().getCentroidX(), o2.getROI().getCentroidY())); -// return 0; -// return (int)Math.signum(o1.getROI().getCentroidX() - o2.getROI().getCentroidX()); } }); diff --git a/qupath-core/src/main/java/qupath/lib/analysis/stats/Histogram.java b/qupath-core/src/main/java/qupath/lib/analysis/stats/Histogram.java index a18c14a4a..db33d7ac8 100644 --- a/qupath-core/src/main/java/qupath/lib/analysis/stats/Histogram.java +++ b/qupath-core/src/main/java/qupath/lib/analysis/stats/Histogram.java @@ -343,7 +343,6 @@ private void buildHistogram(final ArrayWrappers.ArrayWrapper values, int nBins, // Compute running statistics as we iterate through the values // If we don't know for sure if we have integer values, perform a check as we go -// System.out.println(values.getClass()); isInteger = values.isIntegerWrapper(); boolean maybeInteger = !isInteger; stats = new RunningStatistics(); @@ -358,8 +357,7 @@ private void buildHistogram(final ArrayWrappers.ArrayWrapper values, int nBins, } if (!isInteger) isInteger = maybeInteger; -// System.out.println("Time " + (counter++) + ": " + (System.currentTimeMillis() - t)); t = System.currentTimeMillis(); - + // Set min/max values, if required if (Double.isNaN(minEdge)) edgeMin = stats.getMin(); @@ -378,11 +376,8 @@ private void buildHistogram(final ArrayWrappers.ArrayWrapper values, int nBins, if (!Double.isFinite(binWidth)) nBins = 0; else if (binWidth < 1 && isInteger) { -// boolean is8Bit = edgeMin == 0 && edgeMax == 255 && nBins == 256; -// if (!is8Bit) { binWidth = 1; nBins = (int)(edgeMax - edgeMin + 1); -// } } @@ -397,8 +392,6 @@ else if (binWidth < 1 && isInteger) { for (int i = 0; i <= nBins; i++) edges[i] = edgeMin + i * binWidth; -// System.out.println("Time " + (counter++) + ": " + (System.currentTimeMillis() - t)); t = System.currentTimeMillis(); - // Compute counts maxCount = 0; countSum = 0; @@ -414,9 +407,6 @@ else if (binWidth < 1 && isInteger) { maxCount = count; countSum++; } - -// System.out.println("Time " + (counter++) + ": " + (System.currentTimeMillis() - t)); t = System.currentTimeMillis(); - } /** diff --git a/qupath-core/src/main/java/qupath/lib/color/ColorTransformer.java b/qupath-core/src/main/java/qupath/lib/color/ColorTransformer.java index afa503f3a..fa31596d5 100644 --- a/qupath-core/src/main/java/qupath/lib/color/ColorTransformer.java +++ b/qupath-core/src/main/java/qupath/lib/color/ColorTransformer.java @@ -959,8 +959,6 @@ public static ColorModel getDefaultColorModel(ColorTransformer.ColorTransformMet * @see qupath.lib.common.ColorTools */ public static void transformRGB(int[] buf, int[] bufOutput, ColorTransformer.ColorTransformMethod method, float offset, float scale, boolean useColorLUT) { - // System.out.println("Scale and offset: " + scale + ", " + offset); - // int[] buf = img.getRGB(0, 0, img.getWidth(), img.getHeight(), buf, 0, img.getWidth()); ColorModel cm = useColorLUT ? COLOR_MODEL_MAP.get(method) : null; switch (method) { case Original: @@ -974,8 +972,6 @@ public static void transformRGB(int[] buf, int[] bufOutput, ColorTransformer.Col int r = ColorTools.do8BitRangeCheck((ColorTools.red(rgb) - offset) * scale); int g = ColorTools.do8BitRangeCheck((ColorTools.green(rgb) - offset) * scale); int b = ColorTools.do8BitRangeCheck((ColorTools.blue(rgb) - offset) * scale); - // if (r != g) - // System.out.println(r + ", " + g + ", " + b); bufOutput[i] = ((r<<16) + (g<<8) + b) & ~ColorTools.MASK_ALPHA | (buf[i] & ColorTools.MASK_ALPHA); } return; diff --git a/qupath-core/src/main/java/qupath/lib/roi/RoiEditor.java b/qupath-core/src/main/java/qupath/lib/roi/RoiEditor.java index 177e7e199..b05b43b99 100644 --- a/qupath-core/src/main/java/qupath/lib/roi/RoiEditor.java +++ b/qupath-core/src/main/java/qupath/lib/roi/RoiEditor.java @@ -97,9 +97,6 @@ public void setROI(ROI roi) { * Normally this should be true, but it may be false if the new ROI being set is part of the same translation event. */ public void setROI(ROI roi, boolean stopTranslating) { -// if (stopTranslating) -// System.out.println("Stopping translating: " + stopTranslating + " - " + pathROI); - if (this.pathROI == roi) return; if (isTranslating() && stopTranslating) { @@ -666,7 +663,6 @@ PolygonROI updateActiveHandleLocation(double xNew, double yNew, boolean shiftDow return roi; activeHandle.setLocation(xNew, yNew); roi = new PolygonROI(createPoint2List(handles), roi.getImagePlane()); -// System.out.println("UPDATED HANDLES: " + handles.size() + ", " + roi.nVertices()); return roi; } @@ -749,7 +745,6 @@ PolylineROI updateActiveHandleLocation(double xNew, double yNew, boolean shiftDo return roi; activeHandle.setLocation(xNew, yNew); roi = new PolylineROI(createPoint2List(handles), roi.getImagePlane()); -// System.out.println("UPDATED HANDLES: " + handles.size() + ", " + roi.getNumPoints()); return roi; } @@ -786,7 +781,6 @@ public PolylineROI requestNewHandle(double x, double y) { activeHandle = new MutablePoint(x, y); roi = new PolylineROI(createPoint2List(handles), roi.getImagePlane()); handles.add(activeHandle); -// System.out.println("UPDATED HANDLES BY REQUEST: " + handles.size()); return roi; } diff --git a/qupath-gui-fx/src/main/java/qupath/lib/display/AbstractSingleChannelInfo.java b/qupath-gui-fx/src/main/java/qupath/lib/display/AbstractSingleChannelInfo.java index 1943cee67..6db1ccfd0 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/display/AbstractSingleChannelInfo.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/display/AbstractSingleChannelInfo.java @@ -80,10 +80,9 @@ private int[] getRGB(float[] values, int[] rgb, ChannelDisplayMode mode) { else if (rgb.length < n) n = rgb.length; - // long start = System.currentTimeMillis(); - for (int i = 0; i < n; i++) + for (int i = 0; i < n; i++) { rgb[i] = getRGB(values[i], mode); - // System.out.println("Time: " + (System.currentTimeMillis() - start)); + } return rgb; } diff --git a/qupath-gui-fx/src/main/java/qupath/lib/display/ImageDisplay.java b/qupath-gui-fx/src/main/java/qupath/lib/display/ImageDisplay.java index 404d79b7b..204945664 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/display/ImageDisplay.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/display/ImageDisplay.java @@ -800,15 +800,7 @@ public static BufferedImage applyTransforms(BufferedImage imgInput, BufferedImag if (mode.invertColors()) { invertRGB(pixels); } - imgOutput.getRaster().setDataElements(0, 0, imgOutput.getWidth(), imgOutput.getHeight(), pixels); - -// imgOutput.setRGB(0, 0, imgOutput.getWidth(), imgOutput.getHeight(), pixels, 0, imgOutput.getWidth()); - - - // imgOutput.setRGB(0, 0, width, height, pixels, 0, width); - // long endTime = System.currentTimeMillis(); - // System.out.println("Time taken: " + (endTime - startTime)/1000.); return imgOutput; } @@ -910,7 +902,6 @@ private void autoSetDisplayRange(ChannelDisplayInfo info, Histogram histogram, d // TODO: Look at other times whenever no histogram will be provided if (!(info instanceof RGBDirectChannelInfo)) logger.warn("Cannot set display range for {} - no histogram found", info); - // System.out.println("Cannot set display range for " + info + " - no histogram found"); return; } // For unsupported saturation values, just set to the min/max diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/images/servers/PathHierarchyImageServer.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/images/servers/PathHierarchyImageServer.java index f910b8b68..a26fb9f52 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/images/servers/PathHierarchyImageServer.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/images/servers/PathHierarchyImageServer.java @@ -248,12 +248,7 @@ protected BufferedImage readTile(TileRequest tileRequest) throws IOException { downsampleFactor, tileRequest.getImagePlane()); } - - g2d.dispose(); -// long endTime = System.currentTimeMillis(); -// System.out.println("Number of objects: " + pathObjects.size()); -// System.out.println("Single tile image creation time: " + (endTime - startTime)/1000.); return img; } diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/images/stores/AbstractImageRegionStore.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/images/stores/AbstractImageRegionStore.java index 0477566ac..298b49cfc 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/images/stores/AbstractImageRegionStore.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/images/stores/AbstractImageRegionStore.java @@ -767,10 +767,7 @@ public T call() throws Exception { // Check if we still need the tile... if not, and we go searching, there can be a backlog // making any requests slower to fulfill // (Also, grab a snapshot of the listener list to avoid concurrent modifications) -// long t1 = System.currentTimeMillis(); T img = server.readRegion(request); -// long t2 = System.currentTimeMillis(); -// System.out.println("Tile request time: " + (t2 - t1)); return img; } diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/images/stores/DefaultImageRegionStore.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/images/stores/DefaultImageRegionStore.java index 156d98634..08d12a87d 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/images/stores/DefaultImageRegionStore.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/images/stores/DefaultImageRegionStore.java @@ -112,7 +112,6 @@ public void paintRegionCompletely(ImageServer server, Graphics g, g.drawImage((BufferedImage)result, request.getX(), request.getY(), request.getWidth(), request.getHeight(), observer); } else if (result instanceof TileWorker) { // If we've a tile worker, prepare for requesting its results soon... -// System.out.println(((TileWorker)result).getRegion()); workers.add((TileWorker)result); } } @@ -182,15 +181,9 @@ public void paintRegion(ImageServer server, Graphics g, Shape cli private void paintRegionInternal(ImageServer server, Graphics g, Shape clipShapeVisible, int zPosition, int tPosition, double downsampleFactor, BufferedImage imgThumbnail, ImageObserver observer, ImageRenderer imageDisplay) { -// // We don't need it... but try to request the thumbnail to keep it present in the cache, if it is there -// cache.get(getThumbnailRequest(server, zPosition, tPosition)); - // Check if we have all the regions required for this request List requests = ImageRegionStoreHelpers.getTilesToRequest(server, clipShapeVisible, downsampleFactor, zPosition, tPosition, null); -// System.out.println("Requesting tiles: " + requests.size()); -// System.out.println("Requesting tiles " + server.getServerPath() + ": " + requests.size()); - // If we should be painting recursively, ending up with the thumbnail, do so if (imgThumbnail != null) { Rectangle missingBounds = null; diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/images/stores/DefaultRegionCache.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/images/stores/DefaultRegionCache.java index 080fd897b..f19e439f8 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/images/stores/DefaultRegionCache.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/images/stores/DefaultRegionCache.java @@ -125,13 +125,6 @@ public synchronized T put(RegionRequest request, T img) { memoryBytes -= sizeEstimator.getApproxImageSize(imgPrevious); nonNullSize--; } -// System.err.println( -// String.format("Cache: %d entries, %.1f/%.1f GB, %.1f%%", map.size(), -// memoryBytes/1024.0/1024.0/1024.0, -// maxMemoryBytes/1024.0/1024.0/1024.0, -// memoryBytes * 100.0/maxMemoryBytes)); -// else -// System.out.println("PUTTING NEW: " + nonNullSize + ", " + request + ", " + Thread.currentThread()); return imgPrevious; } diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/images/stores/ImageRegionStoreHelpers.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/images/stores/ImageRegionStoreHelpers.java index 7edb28f1d..8aacd6611 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/images/stores/ImageRegionStoreHelpers.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/images/stores/ImageRegionStoreHelpers.java @@ -91,141 +91,6 @@ public static List getTilesToRequest(ImageServer server, Shape regions.sort((r1, r2) -> Double.compare(distanceToCentroid(r1, x, y), distanceToCentroid(r2, x, y))); return regions; - -// double downsamplePreferred = server.getPreferredDownsampleFactor(downsampleFactor); -// -// // Determine what the tile size will be in the original image space for the requested downsample -// // Aim for a round number - preferred downsamples can be a bit off due to rounding -// if (tileWidth <= 0) -// tileWidth = server.getPreferredTileWidth(); -// if (tileHeight <= 0) -// tileHeight = server.getPreferredTileHeight(); -// // If the preferred sizes are out of range, use defaults -// if (tileWidth <= 0) -// tileWidth = 256; -// if (tileHeight <= 0) -// tileHeight = 256; -// // System.out.println("Tile size: " + tileWidth + ", " + tileHeight); -// int tileWidthForLevel; -// int tileHeightForLevel; -// if (GeneralTools.almostTheSame(downsamplePreferred, (int)(downsamplePreferred + .5), 0.001)) { -// tileWidthForLevel = (int)(tileWidth * (int)(downsamplePreferred + .5) + .5); -// tileHeightForLevel = (int)(tileHeight * (int)(downsamplePreferred + .5) + .5); -// } -// else { -// tileWidthForLevel = (int)(tileWidth * downsamplePreferred + .5); -// tileHeightForLevel = (int)(tileHeight * downsamplePreferred + .5); -// } -// -// // Get the current bounds -// // Shape clipShapeVisible = getDisplayedClipShape(clip); -// Rectangle boundsVisible; -// if (clipShape instanceof Rectangle) -// boundsVisible = (Rectangle)clipShape; -// else -// boundsVisible = clipShape.getBounds(); -// -// // Get the starting indices, shifted to actual tile boundaries -// int xStart = (int)Math.max(0, (int)(boundsVisible.x / tileWidthForLevel) * tileWidthForLevel); -// int yStart = (int)Math.max(0, (int)(boundsVisible.y / tileHeightForLevel) * tileHeightForLevel); -// // Determine the visible image dimensions at the current downsample -// double visibleWidth = boundsVisible.width; -// double visibleHeight = boundsVisible.height; -// -// int serverWidth = server.getWidth(); -// int serverHeight = server.getHeight(); -// -// // Get the ending image indices (non-inclusive), again shifted to actual tile boundaries or the image end -// int xEnd = (int)Math.min(serverWidth, Math.ceil(boundsVisible.x + visibleWidth)); -// int yEnd = (int)Math.min(serverHeight, Math.ceil(boundsVisible.y + visibleHeight)); -// -// // Try to ensure that we have at least one full tile -// if (serverWidth - xStart < tileWidthForLevel && serverWidth >= tileWidthForLevel) -// xStart = serverWidth - tileWidthForLevel; -// if (serverHeight - yStart < tileHeightForLevel && serverHeight >= tileHeightForLevel) -// yStart = serverHeight - tileHeightForLevel; -// -// // // Loop through and create the tiles -// // for (int yy = yStart; yy < yEnd; yy += tileHeightForLevel) { -// // for (int xx = xStart; xx < xEnd; xx += tileWidthForLevel) { -// // -// // RegionRequest request = RegionRequest.createInstance(server.getPath(), downsamplePreferred, xx, yy, (int)Math.min(serverWidth, (xx+tileWidthForLevel))-xx, -// // (int)Math.min(serverHeight, (yy+tileHeightForLevel))-yy, zPosition, tPosition); -// // -// // // Check if this is worth loading - might be outside the clip bounds -// // if (clipShape != null && !clipShape.intersects(request.getBounds())) -// // continue; -// // -// // // Add region to the list -// // regions.add(request); -// // } -// // } -// -// -// // Loop through and create the tile requests -// // Here, I've attempted to request central regions first in order to improve the perception of image loading -// int nx = (int)Math.ceil((double)(xEnd - xStart) / tileWidthForLevel); -// int ny = (int)Math.ceil((double)(yEnd - yStart) / tileHeightForLevel); -// -// int xc = nx/2; -// int yc = ny/2; -// int maxDisplacement = Math.max(nx - xc, ny - yc); -// -// for (int d = 0; d <= maxDisplacement; d++) { -// -// for (int yi = -d; yi <= d; yi++) { -// for (int xi = -d; xi <= d; xi++) { -// -// if ((Math.abs(xi) != d && Math.abs(yi) != d) || xc + xi < 0 || yc + yi < 0) -// continue; -// -// // Create central region -// int xx = xStart + (xc + xi) * tileWidthForLevel; -// int yy = yStart + (yc + yi) * tileHeightForLevel; -// -// int ww = tileWidthForLevel; -// int hh = tileHeightForLevel; -// -// // Check, if we have a partial tile - if so, then skip -// // Otherwise, if we have a tile that is right next to the image boundary, expand it to include the rest of the image -// int xRemainder = serverWidth - (xx + tileWidthForLevel); -// if (xRemainder < 0 && nx > 1) { -// continue; -// } else if (xRemainder < tileWidthForLevel) { -// ww = serverWidth - xx; -// } -// int yRemainder = serverHeight - (yy + tileHeightForLevel); -// if (yRemainder < 0 && ny > 1) { -// continue; -// } else if (yRemainder < tileHeightForLevel) { -// hh = serverHeight - yy; -// } -// -// RegionRequest request = RegionRequest.createInstance(server.getPath(), downsamplePreferred, xx, yy, -// ww, hh, -// zPosition, tPosition); -// -// // Check if this is worth loading - might be outside the clip bounds -// if (clipShape == null || clipShape.intersects(request.getX(), request.getY(), request.getWidth(), request.getHeight())) -// regions.add(request); -// } -// } -// -// } -// -// -// -//// String s = ""; -//// Point2D pCenter = new Point2D.Double(clipShape.getBounds2D().getCenterX(), clipShape.getBounds2D().getCenterY()); -//// for (RegionRequest region : regions) { -//// s += pCenter.distance(region.getBounds().getCenterX(), region.getBounds().getCenterY()) + ", "; -//// } -//// System.out.println(s); -// -//// System.out.println(regions.size() + ", " + new HashSet<>(regions).size()); -// -// -// return regions; } /** @@ -257,7 +122,7 @@ public static RegionRequest getTileRequest(ImageServer server, do tileWidth = 256; if (tileHeight < 0) tileHeight = 256; - // System.out.println("Tile size: " + tileWidth + ", " + tileHeight); + int tileWidthForLevel; int tileHeightForLevel; if (GeneralTools.almostTheSame(downsamplePreferred, (int)(downsamplePreferred + .5), 0.001)) { diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/tma/KaplanMeierChartWrapper.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/tma/KaplanMeierChartWrapper.java index f4f596aa0..42db55e80 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/tma/KaplanMeierChartWrapper.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/tma/KaplanMeierChartWrapper.java @@ -509,44 +509,7 @@ else if (data.getNode() != null) .concat(String.format(": p = %.4f", logRankResult.getPValue()))); series.setNode(null); chart.getData().add(series); - -// series = new Series<>(); -// series.nameProperty().bind( -// chart.getData().get(0).nameProperty() -// .concat(" vs ") -// .concat(chart.getData().get(1).nameProperty()) -// .concat(": p = " + GeneralTools.getFormatter(3).format(logRankResult.getPValue()))); -// series.setNode(null); -// chart.getData().add(series); -// -// logRankResult = LogRankTest.computeLogRankTest(kmList.get(0), kmList.get(2)); -// series = new Series<>(); -// series.nameProperty().bind( -// chart.getData().get(0).nameProperty() -// .concat(" vs ") -// .concat(chart.getData().get(2).nameProperty()) -// .concat(": p = " + GeneralTools.getFormatter(3).format(logRankResult.getPValue()))); -// series.setNode(null); -// chart.getData().add(series); -// -// logRankResult = LogRankTest.computeLogRankTest(kmList.get(1), kmList.get(2)); -// series = new Series<>(); -// series.nameProperty().bind( -// chart.getData().get(1).nameProperty() -// .concat(" vs ") -// .concat(chart.getData().get(2).nameProperty()) -// .concat(": p = " + GeneralTools.getFormatter(3).format(logRankResult.getPValue()))); -// series.setNode(null); -// chart.getData().add(series); } - - -// for (Series series : chart.getData()) { -// System.out.println(series.getName()); -// for (Data data : series.getData()) { -// System.err.println(data); -// } -// } } diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/ImageOverview.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/ImageOverview.java index d1f20a891..503c94e50 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/ImageOverview.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/ImageOverview.java @@ -212,17 +212,7 @@ private void setImage(BufferedImage img) { canvas.setWidth(imgPreview.getWidth()); canvas.setHeight(imgPreview.getHeight()); -// if (imgPreview == null || imgPreview.getWidth() != preferredWidth || imgPreview.getHeight() != preferredHeight) { -// // imgPreview = GraphicsEnvironment.getLocalGraphicsEnvironment().getDefaultScreenDevice().getDefaultConfiguration().createCompatibleImage(preferredWidth, preferredHeight, Transparency.OPAQUE); -// imgPreview = new BufferedImage(preferredWidth, preferredHeight, BufferedImage.TYPE_INT_RGB); -// } -// Graphics2D g2d = imgPreview.createGraphics(); -// g2d.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR); -// g2d.drawImage(img, 0, 0, preferredWidth, preferredHeight, this); -// g2d.dispose(); -// // imgPreview = img.getScaledInstance(preferredWidth, preferredHeight, BufferedImage.SCALE_SMOOTH); imgLastThumbnail = img; -// System.out.println("I resized from " + img.getWidth() + " to " + imgPreview.getWidth()); } updateTransform(); } diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/QuPathViewer.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/QuPathViewer.java index de63dd216..66bb0f1d6 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/QuPathViewer.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/QuPathViewer.java @@ -2981,9 +2981,7 @@ public DoubleProperty rotationProperty() { @Override public void tileAvailable(String serverPath, ImageRegion region, BufferedImage tile) { - // if (serverPath == null || serverPath.equals(getServerPath())) -// System.out.println(region); - + if (!hasServer()) return; @@ -2991,8 +2989,6 @@ public void tileAvailable(String serverPath, ImageRegion region, BufferedImage t if (serverPath == null || serverPath.contains(getServerPath())) repaintImageRegion(AwtTools.getBounds(region), true);//!serverPath.startsWith(PathHierarchyImageServer.DEFAULT_PREFIX)); - // imageUpdated = true; - // repaint(); } diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/Scalebar.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/Scalebar.java index 2a6f5bc57..43b9f127e 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/Scalebar.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/Scalebar.java @@ -138,8 +138,6 @@ public void updateScalebar() { if (lastDownsample == currentDownsample && lastLineThickness == lineThickness) return; -// System.out.println("UPDATING SCALE BAR! " + currentDownsample); - ImageServer server = viewer.getServer(); // The scalebar is shown horizontally - so request the horizontal scale, if known double scale = 1.0; diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/tools/handlers/MoveToolEventHandler.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/tools/handlers/MoveToolEventHandler.java index 667cf04c7..673efc5b8 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/tools/handlers/MoveToolEventHandler.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/tools/handlers/MoveToolEventHandler.java @@ -376,7 +376,6 @@ void handleUpdate() { if (scale <= 0) return; -// System.out.println("Timestamp: " + timestamp + ", New timestamp: " + newTimestamp + ", dx: " + dx + ", dy: " + dy + ", scale: " + scale); timestamp = newTimestamp; dx *= scale; dy *= scale; @@ -386,7 +385,6 @@ void handleUpdate() { return; } -// System.err.println("Call by distance: " + (Math.sqrt(dx*dx + dy*dy) / Math.sqrt(downsample*downsample))); viewer.setCenterPixelLocation(viewer.getCenterPixelX() - dx, viewer.getCenterPixelY() - dy); } @@ -400,7 +398,6 @@ public void startMoving(final double dx, final double dy, final boolean constant this.dx = dx; this.dy = dy; this.constantVelocity = constantVelocity; -// System.out.println("Starting: " + dx + ", " + dy); if (timer == null) { timer = new Timeline( new KeyFrame( From b79746dec964d0a91373122eb0250a99b70a83e2 Mon Sep 17 00:00:00 2001 From: Pete Date: Wed, 21 Aug 2024 08:36:16 +0100 Subject: [PATCH 219/866] Avoid e.printStackTrace() Use logging instead --- .../cells/WatershedCellMembraneDetection.java | 2 +- .../lib/plugins/objects/ShapeFeaturesPlugin.java | 2 +- .../lib/plugins/objects/SmoothFeaturesPlugin.java | 2 +- .../java/qupath/opencv/io/OpenCVTypeAdapters.java | 4 ---- .../lib/analysis/images/ContourTracing.java | 15 --------------- .../lib/plugins/parameters/ChoiceParameter.java | 7 ------- .../test/java/qupath/lib/common/TestTimeit.java | 8 ++++++-- .../lib/images/writers/TestImageWriterIJ.java | 6 ++---- .../java/qupath/lib/roi/TestGeometryTools.java | 3 +-- .../src/test/java/qupath/lib/roi/TestROIs.java | 11 +++++++---- .../bioformats/TestBioFormatsImageServer.java | 2 +- .../bioformats/TestOMETiffImageWriter.java | 6 ++---- .../java/qupath/imagej/gui/ImageJMacroRunner.java | 11 +++-------- .../images/stores/DefaultImageRegionStore.java | 1 - .../gui/viewer/recording/ViewTrackerTools.java | 8 ++++++-- 15 files changed, 31 insertions(+), 57 deletions(-) diff --git a/qupath-core-processing/src/main/java/qupath/imagej/detect/cells/WatershedCellMembraneDetection.java b/qupath-core-processing/src/main/java/qupath/imagej/detect/cells/WatershedCellMembraneDetection.java index ad36c449c..fcfeb9c56 100644 --- a/qupath-core-processing/src/main/java/qupath/imagej/detect/cells/WatershedCellMembraneDetection.java +++ b/qupath-core-processing/src/main/java/qupath/imagej/detect/cells/WatershedCellMembraneDetection.java @@ -1082,7 +1082,7 @@ public void runDetection(double backgroundRadius, double maxBackground, double m try { doDetection(updateNucleusROIs); } catch (Exception e) { - e.printStackTrace(); + logger.error(e.getMessage(), e); } } diff --git a/qupath-core-processing/src/main/java/qupath/lib/plugins/objects/ShapeFeaturesPlugin.java b/qupath-core-processing/src/main/java/qupath/lib/plugins/objects/ShapeFeaturesPlugin.java index ab1e9b1b2..6fe57086e 100644 --- a/qupath-core-processing/src/main/java/qupath/lib/plugins/objects/ShapeFeaturesPlugin.java +++ b/qupath-core-processing/src/main/java/qupath/lib/plugins/objects/ShapeFeaturesPlugin.java @@ -136,7 +136,7 @@ public void run() { measurementList.close(); } catch (Exception e) { - e.printStackTrace(); + logger.error(e.getMessage(), e); throw(e); } } diff --git a/qupath-core-processing/src/main/java/qupath/lib/plugins/objects/SmoothFeaturesPlugin.java b/qupath-core-processing/src/main/java/qupath/lib/plugins/objects/SmoothFeaturesPlugin.java index 0ab75b915..32281c1b3 100644 --- a/qupath-core-processing/src/main/java/qupath/lib/plugins/objects/SmoothFeaturesPlugin.java +++ b/qupath-core-processing/src/main/java/qupath/lib/plugins/objects/SmoothFeaturesPlugin.java @@ -176,7 +176,7 @@ public void run() { // } } catch (Exception e) { - e.printStackTrace(); + logger.error(e.getMessage(), e); throw(e); } } diff --git a/qupath-core-processing/src/main/java/qupath/opencv/io/OpenCVTypeAdapters.java b/qupath-core-processing/src/main/java/qupath/opencv/io/OpenCVTypeAdapters.java index 31c014bef..4497539c1 100644 --- a/qupath-core-processing/src/main/java/qupath/opencv/io/OpenCVTypeAdapters.java +++ b/qupath-core-processing/src/main/java/qupath/opencv/io/OpenCVTypeAdapters.java @@ -197,10 +197,6 @@ public void write(JsonWriter out, T value) throws IOException { JsonObject element = gson.fromJson(json.trim(), JsonObject.class); gson.toJson(element, out); - -// out.jsonValue(json); -// } catch (Throwable e) { -// e.printStackTrace(); } finally { out.setLenient(lenient); } diff --git a/qupath-core/src/main/java/qupath/lib/analysis/images/ContourTracing.java b/qupath-core/src/main/java/qupath/lib/analysis/images/ContourTracing.java index 67122d92e..04a823035 100644 --- a/qupath-core/src/main/java/qupath/lib/analysis/images/ContourTracing.java +++ b/qupath-core/src/main/java/qupath/lib/analysis/images/ContourTracing.java @@ -984,21 +984,6 @@ public static Map traceGeometries(ImageServer } return traceGeometriesImpl(server, tiles, clipArea, thresholds); - - // TODO: Consider restricting parallelization -// int nThreads = Math.min(Math.max(1, Math.max(thresholds.length, tiles.size())), Runtime.getRuntime().availableProcessors()); -// var pool = new ForkJoinPool(nThreads); -// var task = pool.submit(() -> traceGeometriesImpl(server, tiles, clipArea, thresholds)); -// pool.shutdown(); -// try { -// return task.get(); -// } catch (InterruptedException e) { -// // TODO Auto-generated catch block -// e.printStackTrace(); -// } catch (ExecutionException e) { -// // TODO Auto-generated catch block -// e.printStackTrace(); -// } } diff --git a/qupath-core/src/main/java/qupath/lib/plugins/parameters/ChoiceParameter.java b/qupath-core/src/main/java/qupath/lib/plugins/parameters/ChoiceParameter.java index b7b438272..169294eaf 100644 --- a/qupath-core/src/main/java/qupath/lib/plugins/parameters/ChoiceParameter.java +++ b/qupath-core/src/main/java/qupath/lib/plugins/parameters/ChoiceParameter.java @@ -95,13 +95,6 @@ public boolean setStringLastValue(Locale locale, String value) { } } return false; -// try { -// return setValue((S)value); -// } catch (ClassCastException e) { -// System.err.println(e.getLocalizedMessage()); -// e.printStackTrace(); -// } -// return false; } @Override diff --git a/qupath-core/src/test/java/qupath/lib/common/TestTimeit.java b/qupath-core/src/test/java/qupath/lib/common/TestTimeit.java index 7f3f6f40c..c520c46ce 100644 --- a/qupath-core/src/test/java/qupath/lib/common/TestTimeit.java +++ b/qupath-core/src/test/java/qupath/lib/common/TestTimeit.java @@ -2,7 +2,7 @@ * #%L * This file is part of QuPath. * %% - * Copyright (C) 2022 QuPath developers, The University of Edinburgh + * Copyright (C) 2022-2024 QuPath developers, The University of Edinburgh * %% * QuPath is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as @@ -29,8 +29,12 @@ import java.util.Set; import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; public class TestTimeit { + + private static final Logger logger = LoggerFactory.getLogger(TestTimeit.class); @Test public void test_timeit() { @@ -74,7 +78,7 @@ static void doSomething(long durationMillis) { try { Thread.sleep(durationMillis); } catch (InterruptedException e) { - e.printStackTrace(); + logger.error(e.getMessage(), e); } } diff --git a/qupath-core/src/test/java/qupath/lib/images/writers/TestImageWriterIJ.java b/qupath-core/src/test/java/qupath/lib/images/writers/TestImageWriterIJ.java index c0a86e11c..07b7f8d1d 100644 --- a/qupath-core/src/test/java/qupath/lib/images/writers/TestImageWriterIJ.java +++ b/qupath-core/src/test/java/qupath/lib/images/writers/TestImageWriterIJ.java @@ -60,16 +60,14 @@ void testWriter(ImageWriter writer, BufferedImage img) { writer.writeImage(img, stream); bytes = stream.toByteArray(); } catch (IOException e) { - e.printStackTrace(); - fail("Error writing to byte array: " + e.getLocalizedMessage()); + fail("Error writing to byte array: " + e.getLocalizedMessage(), e); return; } try (var stream = new ByteArrayInputStream(bytes)) { var imgRead = ImageIO.read(stream); compareImages(img, imgRead); } catch (IOException e) { - e.printStackTrace(); - fail("Error reading from byte array: " + e.getLocalizedMessage()); + fail("Error reading from byte array: " + e.getLocalizedMessage(), e); return; } } diff --git a/qupath-core/src/test/java/qupath/lib/roi/TestGeometryTools.java b/qupath-core/src/test/java/qupath/lib/roi/TestGeometryTools.java index 2b0b1f88f..e565cdc14 100644 --- a/qupath-core/src/test/java/qupath/lib/roi/TestGeometryTools.java +++ b/qupath-core/src/test/java/qupath/lib/roi/TestGeometryTools.java @@ -131,8 +131,7 @@ public void testComplexROIs() { var geom2 = GeometryTools.union(geometries); assertTrue(geom2.isValid()); } catch (Exception e) { - e.printStackTrace(); - fail(e.getLocalizedMessage()); + fail(e); } } diff --git a/qupath-core/src/test/java/qupath/lib/roi/TestROIs.java b/qupath-core/src/test/java/qupath/lib/roi/TestROIs.java index 61152d52e..654a45fe1 100644 --- a/qupath-core/src/test/java/qupath/lib/roi/TestROIs.java +++ b/qupath-core/src/test/java/qupath/lib/roi/TestROIs.java @@ -35,6 +35,8 @@ import org.junit.jupiter.api.Test; import org.locationtech.jts.geom.Geometry; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import qupath.lib.geom.Point2; import qupath.lib.objects.hierarchy.PathObjectHierarchy; import qupath.lib.regions.ImagePlane; @@ -46,6 +48,8 @@ */ @SuppressWarnings("javadoc") public class TestROIs { + + private static final Logger logger = LoggerFactory.getLogger(TestROIs.class); /** * Compare areas as returned from ROIs and after converting to JTS Geometry objects. @@ -122,8 +126,7 @@ public void testAreas() { } } } catch (Exception e) { - e.printStackTrace(); - fail(e.getLocalizedMessage()); + fail(e); } } @@ -242,7 +245,7 @@ private static byte[] objectToBytes(Object o) { new ObjectOutputStream(stream).writeObject(o); return stream.toByteArray(); } catch (IOException e) { - e.printStackTrace(); + logger.error(e.getMessage(), e); return null; } } @@ -251,7 +254,7 @@ private static Object objectFromBytes(byte[] bytes) { try (ByteArrayInputStream stream = new ByteArrayInputStream(bytes)) { return new ObjectInputStream(stream).readObject(); } catch (IOException | ClassNotFoundException e) { - e.printStackTrace(); + logger.error(e.getMessage(), e); return null; } } diff --git a/qupath-extension-bioformats/src/test/java/qupath/lib/images/servers/bioformats/TestBioFormatsImageServer.java b/qupath-extension-bioformats/src/test/java/qupath/lib/images/servers/bioformats/TestBioFormatsImageServer.java index dd6b4ce23..f49f2f457 100644 --- a/qupath-extension-bioformats/src/test/java/qupath/lib/images/servers/bioformats/TestBioFormatsImageServer.java +++ b/qupath-extension-bioformats/src/test/java/qupath/lib/images/servers/bioformats/TestBioFormatsImageServer.java @@ -301,7 +301,7 @@ void testProject(Project project) { } } catch (Exception e) { - e.printStackTrace(); + logger.error(e.getMessage(), e); } // Check if we got a server at all assertNotNull(server); diff --git a/qupath-extension-bioformats/src/test/java/qupath/lib/images/servers/bioformats/TestOMETiffImageWriter.java b/qupath-extension-bioformats/src/test/java/qupath/lib/images/servers/bioformats/TestOMETiffImageWriter.java index 9c7649645..07c80b655 100644 --- a/qupath-extension-bioformats/src/test/java/qupath/lib/images/servers/bioformats/TestOMETiffImageWriter.java +++ b/qupath-extension-bioformats/src/test/java/qupath/lib/images/servers/bioformats/TestOMETiffImageWriter.java @@ -63,8 +63,7 @@ void testWriter(ImageWriter writer, BufferedImage img) { assertNotNull(bytes); assertTrue(bytes.length > 0); } catch (IOException e) { - e.printStackTrace(); - fail("Error writing to byte array: " + e.getLocalizedMessage()); + fail("Error writing to byte array: " + e.getLocalizedMessage(), e); return; } // Haven't been able to read from in-memory image with Bio-Formats yet... @@ -78,8 +77,7 @@ void testWriter(ImageWriter writer, BufferedImage img) { // compareImages(img, imgRead); // bufferedReader.close(); // } catch (Exception e) { -// e.printStackTrace(); -// fail("Error reading from byte array: " + e.getLocalizedMessage()); +// fail("Error reading from byte array: " + e.getMessage()); // return; // } } diff --git a/qupath-extension-processing/src/main/java/qupath/imagej/gui/ImageJMacroRunner.java b/qupath-extension-processing/src/main/java/qupath/imagej/gui/ImageJMacroRunner.java index fcc5f73fa..9290bfa78 100644 --- a/qupath-extension-processing/src/main/java/qupath/imagej/gui/ImageJMacroRunner.java +++ b/qupath-extension-processing/src/main/java/qupath/imagej/gui/ImageJMacroRunner.java @@ -343,8 +343,7 @@ static void runMacro(final ParameterList params, final ImageData // }); } catch (Exception e) { - // TODO Auto-generated catch block - e.printStackTrace(); + logger.error(e.getMessage(), e); } } @@ -410,12 +409,8 @@ public void run() { else { try { SwingUtilities.invokeAndWait(() -> runMacro(params, imageData, null, parentObject, macroText)); - } catch (InvocationTargetException e) { - // TODO Auto-generated catch block - e.printStackTrace(); - } catch (InterruptedException e) { - // TODO Auto-generated catch block - e.printStackTrace(); + } catch (InvocationTargetException | InterruptedException e) { + logger.error(e.getMessage(), e); } // TODO: Deal with logging macro text properly } } diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/images/stores/DefaultImageRegionStore.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/images/stores/DefaultImageRegionStore.java index 08d12a87d..1091b5367 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/images/stores/DefaultImageRegionStore.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/images/stores/DefaultImageRegionStore.java @@ -123,7 +123,6 @@ public void paintRegionCompletely(ImageServer server, Graphics g, imgTile = worker.get(timeoutMilliseconds, TimeUnit.MILLISECONDS); } catch (CancellationException e) { logger.debug("Repaint skipped..."); -// e.printStackTrace(); continue; } catch (InterruptedException e) { logger.warn("Tile request interrupted in 'paintRegionCompletely': {}", e.getLocalizedMessage()); diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/recording/ViewTrackerTools.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/recording/ViewTrackerTools.java index db31e3ed8..8a0ee0102 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/recording/ViewTrackerTools.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/recording/ViewTrackerTools.java @@ -2,7 +2,7 @@ * #%L * This file is part of QuPath. * %% - * Copyright (C) 2018 - 2022 QuPath developers, The University of Edinburgh + * Copyright (C) 2018 - 2024 QuPath developers, The University of Edinburgh * %% * QuPath is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as @@ -33,6 +33,8 @@ import java.util.Scanner; import java.util.concurrent.TimeUnit; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import qupath.fx.dialogs.FileChoosers; import qupath.lib.common.GeneralTools; import qupath.fx.dialogs.Dialogs; @@ -47,6 +49,8 @@ * @author Melvin Gelbard */ final class ViewTrackerTools { + + private static final Logger logger = LoggerFactory.getLogger(ViewTrackerTools.class); // Suppressed default constructor for non-instantiability private ViewTrackerTools() { @@ -243,7 +247,7 @@ static void handleExport(final ViewTracker tracker) { out.print(getSummary(frame, "\t", tracker.hasCursorTrackingData(), tracker.hasActiveToolTrackingData(), tracker.hasEyeTrackingData(), tracker.hasZAndT())); } catch (FileNotFoundException e) { - e.printStackTrace(); + logger.error(e.getMessage(), e); } } From edd427d35d9fe7edd0a2677c7d3960baae62f8e4 Mon Sep 17 00:00:00 2001 From: Pete Date: Wed, 21 Aug 2024 09:52:37 +0100 Subject: [PATCH 220/866] Remove System.err.print & commented code --- .../imagej/detect/dearray/TMADearrayer.java | 21 +------- .../imagej/images/servers/ImageJServer.java | 5 -- .../superpixels/SLICSuperpixelsPlugin.java | 27 ---------- .../algorithms/IntensityFeaturesPlugin.java | 6 --- .../main/java/qupath/opencv/CellCountsCV.java | 3 -- .../features/DelaunayTriangulation.java | 1 - .../qupath/opencv/ml/OpenCVClassifiers.java | 53 ------------------- .../ml/objects/features/Preprocessing.java | 1 - .../main/java/qupath/opencv/ops/ImageOps.java | 17 ------ .../opencv/tools/MultiscaleFeatures.java | 13 ----- .../java/qupath/opencv/tools/OpenCVTools.java | 9 +--- .../java/qupath/opencv/ops/TestImageOps.java | 7 --- .../qupath/opencv/tools/TestGroovyCV.java | 2 - .../lib/analysis/images/ContourTracing.java | 3 +- .../lib/awt/common/BufferedImageTools.java | 3 -- .../java/qupath/lib/common/GeneralTools.java | 2 - .../lib/images/servers/SparseImageServer.java | 12 +---- .../java/qupath/lib/objects/CellTools.java | 14 ----- .../qupath/lib/objects/PathObjectTools.java | 6 --- .../hierarchy/PathObjectTileCache.java | 1 - .../src/main/java/qupath/lib/roi/AreaROI.java | 5 -- .../main/java/qupath/lib/roi/PolygonROI.java | 3 -- .../main/java/qupath/lib/roi/RoiEditor.java | 15 ------ .../main/java/qupath/lib/roi/RoiTools.java | 3 -- .../lib/objects/TestPathObjectPredicates.java | 18 +++---- .../lib/objects/TestPathObjectTools.java | 2 - .../TestPathObjectHierarchyLegacy.java | 7 +-- .../gui/commands/ml/PixelClassifierPane.java | 3 -- .../commands/ml/PixelClassifierTraining.java | 4 -- .../lib/display/DirectServerChannelInfo.java | 2 - .../java/qupath/lib/display/ImageDisplay.java | 4 -- .../java/qupath/lib/gui/charts/Charts.java | 12 +++-- .../lib/gui/charts/ExportChartPane.java | 5 -- .../gui/commands/PathObjectImageManagers.java | 1 - .../commands/RigidObjectEditorCommand.java | 3 +- .../stores/AbstractImageRegionStore.java | 1 - .../stores/DefaultImageRegionStore.java | 1 - .../qupath/lib/gui/panes/AnnotationPane.java | 5 -- .../lib/gui/prefs/QuPathStyleManager.java | 2 - .../gui/scripting/DefaultScriptEditor.java | 2 - .../lib/gui/tma/KaplanMeierChartWrapper.java | 28 +--------- .../lib/gui/tma/KaplanMeierDisplay.java | 2 +- .../qupath/lib/gui/tma/TMASummaryViewer.java | 37 +------------ .../qupath/lib/gui/viewer/QuPathViewer.java | 12 ----- .../qupath/lib/gui/viewer/ViewerManager.java | 2 - .../overlays/PixelClassificationOverlay.java | 2 - .../tools/handlers/MoveToolEventHandler.java | 3 -- 47 files changed, 27 insertions(+), 363 deletions(-) diff --git a/qupath-core-processing/src/main/java/qupath/imagej/detect/dearray/TMADearrayer.java b/qupath-core-processing/src/main/java/qupath/imagej/detect/dearray/TMADearrayer.java index f8e434828..d4ec99cf8 100644 --- a/qupath-core-processing/src/main/java/qupath/imagej/detect/dearray/TMADearrayer.java +++ b/qupath-core-processing/src/main/java/qupath/imagej/detect/dearray/TMADearrayer.java @@ -270,17 +270,6 @@ public static ByteProcessor makeBinaryImage(ImageProcessor ip, double coreDiamet if (!isFluorescence) ip.invert(); -// // Subtract from a morphological-opened image, with the filter size slightly bigger than the core size -// double filterRadius = coreDiameterPx * 0.6; -// ImageProcessor ip2 = ip.duplicate(); -// System.err.println("Starting"); -// long start = System.currentTimeMillis(); -// rf.rank(ip2, filterRadius, RankFilters.MIN); -// rf.rank(ip2, filterRadius, RankFilters.MAX); -// long end = System.currentTimeMillis(); -// System.err.println("Duration: " + (end - start)); -// ip.copyBits(ip2, 0, 0, Blitter.SUBTRACT); - // Subtract from a morphological-opened image, with the filter size slightly bigger than the core size // Update 15/10/2016 - Because the filter size is likely to be very large (maybe radius 40-50 pixels?), downsample first for performance double filterRadius = coreDiameterPx * 0.6; @@ -288,12 +277,9 @@ public static ByteProcessor makeBinaryImage(ImageProcessor ip, double coreDiamet double downsample = Math.round(filterRadius / 10); if (downsample > 1) { ip2 = ip.resize((int)(ip.getWidth() / downsample + 0.5), (int)(ip.getHeight() / downsample + 0.5), true); -// long start = System.currentTimeMillis(); rf.rank(ip2, filterRadius/downsample, RankFilters.MIN); rf.rank(ip2, filterRadius/downsample, RankFilters.MAX); ip2 = ip2.resize(ip.getWidth(), ip.getHeight()); -// long end = System.currentTimeMillis(); -// System.err.println("Duration: " + (end - start)); } ip.copyBits(ip2, 0, 0, Blitter.SUBTRACT); @@ -595,14 +581,9 @@ private static void refineGridCoordinatesByShifting(ByteProcessor bp, Polygon po } // Apply a mean filter to determine local unassigned densities -// new ImagePlus("Density_before", fpDensity.duplicate()).show(); -// long start = System.currentTimeMillis(); // Note: this is another bottleneck... filter size can be large new RankFilters().rank(fpDensity, coreDiameterPx * 0.5, RankFilters.MEAN); -// System.err.println("Time: " + (System.currentTimeMillis() - start)); -// fpDensity.min(-1); -// new ImagePlus("Density", fpDensity.duplicate()).show(); - + // Find local maxima within each unassigned core region, with a preference towards the maximum closest to the original estimate for (int i = 0; i < polyGrid.npoints; i++) { if (!confirmed[i]) { diff --git a/qupath-core-processing/src/main/java/qupath/imagej/images/servers/ImageJServer.java b/qupath-core-processing/src/main/java/qupath/imagej/images/servers/ImageJServer.java index 04cbcf75c..311d002d4 100644 --- a/qupath-core-processing/src/main/java/qupath/imagej/images/servers/ImageJServer.java +++ b/qupath-core-processing/src/main/java/qupath/imagej/images/servers/ImageJServer.java @@ -306,8 +306,6 @@ protected String createID() { @Override public BufferedImage readRegion(RegionRequest request) { -// long startTime = System.nanoTime(); - int z = request.getZ()+1; int t = request.getT()+1; int nChannels = nChannels(); @@ -389,9 +387,6 @@ public BufferedImage readRegion(RegionRequest request) { if (colorModel == null) colorModel = img.getColorModel(); - -// long endTime = System.nanoTime(); -// System.err.println("Duration: " + GeneralTools.formatNumber((endTime - startTime)/1000000.0, 1)); return img; } diff --git a/qupath-core-processing/src/main/java/qupath/imagej/superpixels/SLICSuperpixelsPlugin.java b/qupath-core-processing/src/main/java/qupath/imagej/superpixels/SLICSuperpixelsPlugin.java index 56fd683ac..8c24d634f 100644 --- a/qupath-core-processing/src/main/java/qupath/imagej/superpixels/SLICSuperpixelsPlugin.java +++ b/qupath-core-processing/src/main/java/qupath/imagej/superpixels/SLICSuperpixelsPlugin.java @@ -200,9 +200,6 @@ public Collection runDetection(final ImageData imageD ColorProcessor cp = (ColorProcessor)imp.getProcessor(); if (doDeconvolve && imageData.isBrightfield() && imageData.getColorDeconvolutionStains() != null) { ipColor = IJTools.colorDeconvolve(cp, imageData.getColorDeconvolutionStains()); -// fpDeconvolved = Arrays.copyOf(fpDeconvolved, 1); -// for (ImageProcessor fp : fpDeconvolved) -// System.err.println(fp.getStatistics().stdDev); m = m / 2; mergeThreshold = mergeThreshold / 2; } else { @@ -439,37 +436,13 @@ static boolean maybeMergeClusters(final ClusterCenter center, final ClusterCente dist += (f1-f2)*(f1-f2); } dist = Math.sqrt(dist); -// System.err.println(dist); boolean doMerge = dist <= distanceThreshold; - - -// // Check merge based on cosine distance -// double distanceThreshold = 0.9999; -// double dist = 0; -// double mag1 = 0; -// double mag2 = 0; -// for (int i = 0; i < center.features.length; i++) { -// double f1 = center.features[i]; -// mag1 += f1*f1; -// double f2 = center2.features[i]; -// mag2 += f2*f2; -// dist += f1*f2; -// } -// dist = dist / (Math.sqrt(mag1) * Math.sqrt(mag2)); -//// dist = 1-2*Math.acos(dist)/Math.PI; -// boolean doMerge = dist >= distanceThreshold; - -// // Check merge based on distance standard deviations within each cluster -// double distanceThreshold = Math.min(center.getColorDistanceStdDev(), center2.getColorDistanceStdDev()); -// double dist = center.colorDistance(center2.features); -// boolean doMerge = dist < distanceThreshold; // Perform the merge if (doMerge) { for (int label : center2.getLabels()) labels[label] = center.primaryLabel; center2.primaryLabel = center.primaryLabel; -// center2.labels.addAll(center.labels); return true; } return false; diff --git a/qupath-core-processing/src/main/java/qupath/lib/algorithms/IntensityFeaturesPlugin.java b/qupath-core-processing/src/main/java/qupath/lib/algorithms/IntensityFeaturesPlugin.java index 209931e29..b06846ab1 100644 --- a/qupath-core-processing/src/main/java/qupath/lib/algorithms/IntensityFeaturesPlugin.java +++ b/qupath-core-processing/src/main/java/qupath/lib/algorithms/IntensityFeaturesPlugin.java @@ -964,14 +964,8 @@ else if (bin < 0) else histogram[bin]++; n++; - -// testList.add(val); } } - -// Collections.sort(testList); -// System.err.println("Exact median for " + transform + ": " + testList.get(testList.size()/2)); - } @Override diff --git a/qupath-core-processing/src/main/java/qupath/opencv/CellCountsCV.java b/qupath-core-processing/src/main/java/qupath/opencv/CellCountsCV.java index a92c1a2af..6c22d5ef3 100644 --- a/qupath-core-processing/src/main/java/qupath/opencv/CellCountsCV.java +++ b/qupath-core-processing/src/main/java/qupath/opencv/CellCountsCV.java @@ -126,7 +126,6 @@ public Collection runDetection(final ImageData imageD // aiming for a sigma value of at approximately 1.25 pixels if (!Double.isFinite(downsample)) { downsample = Math.max(1, Math.round(gaussianSigma / 1.25)); -// System.err.println("Downsample: " + downsample); } // Update filter sizes according to downsampling factor gaussianSigma /= downsample; @@ -141,9 +140,7 @@ public Collection runDetection(final ImageData imageD backgroundRadius = params.getDoubleParameterValue("backgroundRadiusPixels") / downsample; } logger.debug("Fast cell counting with Gaussian sigma {} pixels, downsample {}", gaussianSigma, downsample); -// System.err.println("ACTUAL Downsample: " + downsample); - // Read the buffered image ImageServer server = imageData.getServer(); RegionRequest request = RegionRequest.createInstance(server.getPath(), downsample, pathROI); diff --git a/qupath-core-processing/src/main/java/qupath/opencv/features/DelaunayTriangulation.java b/qupath-core-processing/src/main/java/qupath/opencv/features/DelaunayTriangulation.java index d6dc149ed..40236e56e 100644 --- a/qupath-core-processing/src/main/java/qupath/opencv/features/DelaunayTriangulation.java +++ b/qupath-core-processing/src/main/java/qupath/opencv/features/DelaunayTriangulation.java @@ -454,7 +454,6 @@ public void addClusterMeasurements() { if (!missing.isEmpty()) { logger.warn("Some objects have missing measurements! Statistics will calculated only for objects with measurements available."); logger.warn("Missing measurements: {}", missing); -// System.err.println("Missing measurements will be ignored! " + nMissing); } } diff --git a/qupath-core-processing/src/main/java/qupath/opencv/ml/OpenCVClassifiers.java b/qupath-core-processing/src/main/java/qupath/opencv/ml/OpenCVClassifiers.java index 74ee58a06..8c7ba2807 100644 --- a/qupath-core-processing/src/main/java/qupath/opencv/ml/OpenCVClassifiers.java +++ b/qupath-core-processing/src/main/java/qupath/opencv/ml/OpenCVClassifiers.java @@ -1314,59 +1314,6 @@ Class getStatModelClass() { return ANN_MLP.class; } - // This shows how to potentially re-weight training samples -// @Override -// public void trainWithLock(TrainData trainData) { -// var statModel = getStatModel(); -// updateModel(statModel, getParameterList(), trainData); -// statModel.train(trainData, getTrainFlags()); -// -// // Retrain -// Mat results = new Mat(); -// Mat probabilities = new Mat(); -// var samples = trainData.getTrainSamples(); -// var targets = trainData.getTrainResponses(); -// long n = samples.rows(); -// Mat weights = new Mat((int)n, 1, opencv_core.CV_32FC1); -// FloatIndexer idxTargets = targets.createIndexer(); -// for (int i = 0; i < 5; i++) { -// predictWithLock(samples, results, probabilities); -// -// IntIndexer idxResults = results.createIndexer(); -// FloatIndexer idxProbabilities = probabilities.createIndexer(); -// FloatIndexer idxWeights = weights.createIndexer(); -// int correct = 0; -// for (long j = 0; j < n; j++) { -// int col = idxResults.get(j); -// double pt = idxProbabilities.get(j, col); -// boolean isCorrect = idxTargets.get(j, col) == 1f; -// if (isCorrect) { -// correct++; -// } else { -// pt = 1 - pt; -// } -// // TODO: Calculate weights in a smarter way! -// double weight = 1-Math.log(Math.max(pt, 0.1)); -// idxWeights.put(j, (float)weight); -// } -// System.err.println(String.format("Correct: %.2f %%", correct * 100.0 / n)); -// idxResults.release(); -// idxProbabilities.release(); -// idxWeights.release(); -// -// trainData = TrainData.create( -// samples, -// trainData.getLayout(), -// targets, -// new Mat(), -// new Mat(), -// weights, -// trainData.getVarType()); -// statModel.train(trainData, getTrainFlags() + ANN_MLP.UPDATE_WEIGHTS); -// } -// } - - @Override public TrainData createTrainData(Mat samples, Mat targets, Mat weights, boolean doMulticlass) { if (doMulticlass) { diff --git a/qupath-core-processing/src/main/java/qupath/opencv/ml/objects/features/Preprocessing.java b/qupath-core-processing/src/main/java/qupath/opencv/ml/objects/features/Preprocessing.java index 3713429ce..a839b365a 100644 --- a/qupath-core-processing/src/main/java/qupath/opencv/ml/objects/features/Preprocessing.java +++ b/qupath-core-processing/src/main/java/qupath/opencv/ml/objects/features/Preprocessing.java @@ -203,7 +203,6 @@ public static class PCAProjector implements AutoCloseable { PCAProjector(Mat data, double retainedVariance, boolean normalize) { this.normalize = normalize; opencv_core.PCACompute2(data, mean, eigenvectors, eigenvalues, retainedVariance); - // System.err.println(mean.createIndexer()); logger.info("Reduced dimensions from {} to {}",data.cols(), eigenvectors.rows()); } diff --git a/qupath-core-processing/src/main/java/qupath/opencv/ops/ImageOps.java b/qupath-core-processing/src/main/java/qupath/opencv/ops/ImageOps.java index 81c8b7c7b..9c2ba2106 100644 --- a/qupath-core-processing/src/main/java/qupath/opencv/ops/ImageOps.java +++ b/qupath-core-processing/src/main/java/qupath/opencv/ops/ImageOps.java @@ -2356,23 +2356,6 @@ public Mat apply(Mat input) { // Use FastMath - there are too many caveats with OpenCV's log implementation OpenCVTools.apply(input, d -> FastMath.log(d)); return input; -// System.err.println("BEFORE: " + input.createIndexer()); -// -// Mat maskZero = opencv_core.equals(input, 0.0).asMat(); -// Mat maskInvalid = OpenCVTools.createMask(input, d -> d < 0 || !Double.isFinite(d)); -// -// -// -// System.err.println("BEFORE LATER: " + input.createIndexer()); -// -// opencv_core.log(input, input); -// -// OpenCVTools.fill(input, maskZero, Double.NEGATIVE_INFINITY); -// OpenCVTools.fill(input, maskInvalid, Double.NaN); -// maskZero.close(); -// maskInvalid.close(); -// System.err.println(input.createIndexer()); -// return input; } } diff --git a/qupath-core-processing/src/main/java/qupath/opencv/tools/MultiscaleFeatures.java b/qupath-core-processing/src/main/java/qupath/opencv/tools/MultiscaleFeatures.java index 36e20903c..0b121982f 100644 --- a/qupath-core-processing/src/main/java/qupath/opencv/tools/MultiscaleFeatures.java +++ b/qupath-core-processing/src/main/java/qupath/opencv/tools/MultiscaleFeatures.java @@ -1394,19 +1394,6 @@ private static class EigenSymm2 implements AutoCloseable { opencv_core.patchNaNs(eigvalMin, 0.0); opencv_core.patchNaNs(eigvalMax, 0.0); - // Try to debug a lot of zeros in the output (turned out normalization was applied too late) -// double total = eigvalMin.total(); -// double zeroPercentMin = (total - opencv_core.countNonZero(eigvalMin))/total * 100; -// double zeroPercentMax = (total - opencv_core.countNonZero(eigvalMax))/total * 100; -// System.err.println(String.format("Zeros min: %.1f%%, max: %.1f%%", zeroPercentMin, zeroPercentMax)); -// if (zeroPercentMax > 5 && zeroPercentMin > 5) { -// var imp = OpenCVTools.matToImagePlus("Temp", eigvalMin.clone(), eigvalMax.clone(), det.asMat(), trace.asMat()); -// var imp2 = new CompositeImage(imp, CompositeImage.GRAYSCALE); -// imp2.setDimensions(imp.getStackSize(), 1, 1); -// imp2.resetDisplayRanges(); -// imp2.show(); -// } - if (doEigenvectors) { int width = dxx.cols(); int height = dxx.rows(); diff --git a/qupath-core-processing/src/main/java/qupath/opencv/tools/OpenCVTools.java b/qupath-core-processing/src/main/java/qupath/opencv/tools/OpenCVTools.java index 4505be4f8..c6fbde7ad 100644 --- a/qupath-core-processing/src/main/java/qupath/opencv/tools/OpenCVTools.java +++ b/qupath-core-processing/src/main/java/qupath/opencv/tools/OpenCVTools.java @@ -1187,24 +1187,17 @@ private static double[] reduceMat(Mat mat, int reduction, boolean byChannel) { private static double reduceMat(Mat mat, int reduction) { double[] values = OpenCVTools.extractDoubles(mat); -// System.err.println("Total: " + mat.total()); -// System.err.println("Size: " + mat.arraySize()/mat.elemSize()); -// System.err.println("Calculated: " + mat.cols() * mat.rows() * mat.channels()); - + // If using StatUtils, average and sum have different behavior with NaNs switch (reduction) { case opencv_core.REDUCE_AVG: return Arrays.stream(values).filter(d -> !Double.isNaN(d)).average().orElse(Double.NaN); -// return StatUtils.mean(values); case opencv_core.REDUCE_MAX: return Arrays.stream(values).filter(d -> !Double.isNaN(d)).max().orElse(Double.NaN); -// return StatUtils.max(values); case opencv_core.REDUCE_MIN: return Arrays.stream(values).filter(d -> !Double.isNaN(d)).min().orElse(Double.NaN); -// return StatUtils.min(values); case opencv_core.REDUCE_SUM: return Arrays.stream(values).filter(d -> !Double.isNaN(d)).sum(); -// return StatUtils.sum(values); default: throw new IllegalArgumentException("Unknown reduction type " + reduction); } diff --git a/qupath-core-processing/src/test/java/qupath/opencv/ops/TestImageOps.java b/qupath-core-processing/src/test/java/qupath/opencv/ops/TestImageOps.java index d297877bc..fdbf502f1 100644 --- a/qupath-core-processing/src/test/java/qupath/opencv/ops/TestImageOps.java +++ b/qupath-core-processing/src/test/java/qupath/opencv/ops/TestImageOps.java @@ -170,7 +170,6 @@ public void testFilters() { var idxStd = matStdDev.createIndexer(); var matOrig = new Mat(); for (int i = 0; i < mat.channels(); i++) { -// System.err.println("Channel " + i + ", radius " + radius); // Extract channels opencv_core.extractChannel(mat, matOrig, i); // Extract first filtered location @@ -182,9 +181,6 @@ public void testFilters() { opencv_core.meanStdDev(matOrig, matLocalMean, matLocalStd, kernel); double localStd = OpenCVTools.extractDoubles(matLocalStd)[0]; double localMean = OpenCVTools.extractDoubles(matLocalMean)[0]; -// OpenCVTools.matToImagePlus(""+radius, mat, matMean, matStdDev).show(); -// System.err.println("Mean: " + localMean + ": " + mean); -// System.err.println("Std.dev: " + localStd + ": " + std); assertEquals(localStd, std, eps); assertEquals(localMean, mean, eps); } @@ -253,9 +249,6 @@ public void testCore() { compareValues(m1, ImageOps.Core.clip(0.25, 2.5), Math.min(2.5, Math.max(0.25, v1))); compareValues(m2, ImageOps.Core.clip(0.25, 2.5), Math.min(2.5, Math.max(0.25, v2))); - // System.err.println("v1: " + v1); - // System.err.println("v2: " + v2); - compareValues(m1, ImageOps.Core.exp(), Math.exp(v1)); compareValues(m2, ImageOps.Core.exp(), Math.exp(v2)); diff --git a/qupath-core-processing/src/test/java/qupath/opencv/tools/TestGroovyCV.java b/qupath-core-processing/src/test/java/qupath/opencv/tools/TestGroovyCV.java index 58ff81a06..3c899601a 100644 --- a/qupath-core-processing/src/test/java/qupath/opencv/tools/TestGroovyCV.java +++ b/qupath-core-processing/src/test/java/qupath/opencv/tools/TestGroovyCV.java @@ -79,8 +79,6 @@ void testOperations() { var m1 = OpenCVTools.scalarMatWithType(v1, opencv_core.CV_64FC(c)); var m2 = OpenCVTools.scalarMatWithType(v2, opencv_core.CV_64FC(c)); -// System.err.println("v1: " + v1 + ", v2: " + v2 + ", c: " + c); - checkAllValues(GroovyCV.plus(m1, m1), v1 + v1, c, eps); checkAllValues(GroovyCV.plus(m1, m2), v1 + v2, c, eps); checkAllValues(GroovyCV.plus(m2, m1), v2 + v1, c, eps); diff --git a/qupath-core/src/main/java/qupath/lib/analysis/images/ContourTracing.java b/qupath-core/src/main/java/qupath/lib/analysis/images/ContourTracing.java index 04a823035..481ee7b3f 100644 --- a/qupath-core/src/main/java/qupath/lib/analysis/images/ContourTracing.java +++ b/qupath-core/src/main/java/qupath/lib/analysis/images/ContourTracing.java @@ -1111,8 +1111,7 @@ private static Geometry mergeGeometryWrappers(List list, int[] if (!toMerge.isEmpty()) { logger.debug("Computing union for {}/{} polygons", toMerge.size(), allPolygons.size()); var mergedGeometry = GeometryTools.union(toMerge); -// System.err.println("To merge: " + toMerge.size()); -// var mergedGeometry = factory.buildGeometry(toMerge).buffer(0); + var iter = allPolygons.iterator(); while (iter.hasNext()) { if (toMerge.contains(iter.next())) diff --git a/qupath-core/src/main/java/qupath/lib/awt/common/BufferedImageTools.java b/qupath-core/src/main/java/qupath/lib/awt/common/BufferedImageTools.java index a264adba4..4b2566b4e 100644 --- a/qupath-core/src/main/java/qupath/lib/awt/common/BufferedImageTools.java +++ b/qupath-core/src/main/java/qupath/lib/awt/common/BufferedImageTools.java @@ -478,9 +478,6 @@ public static BufferedImage resize(final BufferedImage img, final int finalWidth } raster2.setSamples(0, 0, finalWidth, finalHeight, b, pixelsOut); } - -// System.err.println(String.format("Resizing from %d x %d to %d x %d", w, h, finalWidth, finalHeight)); - return new BufferedImage(img.getColorModel(), raster2, img.isAlphaPremultiplied(), null); } diff --git a/qupath-core/src/main/java/qupath/lib/common/GeneralTools.java b/qupath-core/src/main/java/qupath/lib/common/GeneralTools.java index c2ee0a708..82f756303 100644 --- a/qupath-core/src/main/java/qupath/lib/common/GeneralTools.java +++ b/qupath-core/src/main/java/qupath/lib/common/GeneralTools.java @@ -920,8 +920,6 @@ public static void smartStringSort(Collection collection) { * @param extractor function used to convert each element of the collection to a String representation */ public static void smartStringSort(Collection collection, Function extractor) { -// for (var temp : collection) -// System.err.println(new StringPartsSorter(temp, temp.toString())); var list = collection.stream().map(c -> new StringPartsSorter<>(c, extractor.apply(c))).sorted().map(s -> s.obj).toList(); collection.clear(); collection.addAll(list); diff --git a/qupath-core/src/main/java/qupath/lib/images/servers/SparseImageServer.java b/qupath-core/src/main/java/qupath/lib/images/servers/SparseImageServer.java index c80b942dc..e2f5b50e4 100644 --- a/qupath-core/src/main/java/qupath/lib/images/servers/SparseImageServer.java +++ b/qupath-core/src/main/java/qupath/lib/images/servers/SparseImageServer.java @@ -246,16 +246,7 @@ protected BufferedImage readTile(final TileRequest tileRequest) throws IOExcepti int xr2 = x2 - subRegion.getX(); int yr2 = y2 - subRegion.getY(); double requestDownsample = downsample; -// if (requestDownsample > 1 && serverTemp.nResolutions() == 1) { -// requestDownsample = serverTemp.getDownsampleForResolution(0); -// double scale = requestDownsample / downsample; -// xr = (int)Math.round(xr * scale); -// yr = (int)Math.round(yr * scale); -// xr2 = (int)Math.round(xr2 * scale); -// yr2 = (int)Math.round(yr2 * scale); -// System.err.println(downsample + ", " + scale + ": " + serverTemp.getPath()); -// } - + RegionRequest requestTemp = RegionRequest.createInstance( serverTemp.getPath(), requestDownsample, xr, yr, xr2-xr, yr2-yr, tileRequest.getZ() + originZ, tileRequest.getT() + originT); @@ -286,7 +277,6 @@ protected BufferedImage readTile(final TileRequest tileRequest) throws IOExcepti if (raster == null) { return getEmptyTile(tileRequest.getTileWidth(), tileRequest.getTileHeight(), true); } -// System.err.println(String.format("%.2f - %.2f", (double)tileRequest.getImageHeight()/raster.getHeight(), tileRequest.getDownsample())); return new BufferedImage(colorModel, raster, false, null); } diff --git a/qupath-core/src/main/java/qupath/lib/objects/CellTools.java b/qupath-core/src/main/java/qupath/lib/objects/CellTools.java index 50eeb9665..2921efbd4 100644 --- a/qupath-core/src/main/java/qupath/lib/objects/CellTools.java +++ b/qupath-core/src/main/java/qupath/lib/objects/CellTools.java @@ -269,20 +269,6 @@ private static List detectionsToCells(Collection detecti geomCell = GeometryTools.ensurePolygonal(geomCell); geomCell = VWSimplifier.simplify(geomCell, 1.0); } catch (Exception e) { - // Debugging code used to create GeoJSON objects that could later be import for visualization - // if (bounds instanceof GeometryCollection) - // System.err.println("Collection FAILED"); - // else - // System.err.println("Non-collection FAILED"); - // - // var gson = GsonTools.getInstance(true); - // var failedObjects = Arrays.asList( - // detection, - // PathObjects.createAnnotationObject(GeometryTools.geometryToROI(bounds, ImagePlane.getDefaultPlane()), PathClassFactory.getPathClass("Temporary")), - // PathObjects.createAnnotationObject(GeometryTools.geometryToROI(face, ImagePlane.getDefaultPlane()), PathClassFactory.getPathClass("Temporary face")) - // ); - // System.err.println(gson.toJson(GsonTools.wrapFeatureCollection(failedObjects))); - if (face.getArea() > bounds.getArea()) { geomCell = bounds; logger.warn("Error computing intersection between cell boundary and Voronoi face - will use bounds result: " + e.getLocalizedMessage(), e); diff --git a/qupath-core/src/main/java/qupath/lib/objects/PathObjectTools.java b/qupath-core/src/main/java/qupath/lib/objects/PathObjectTools.java index 6e5a71cd1..add17ac72 100644 --- a/qupath-core/src/main/java/qupath/lib/objects/PathObjectTools.java +++ b/qupath-core/src/main/java/qupath/lib/objects/PathObjectTools.java @@ -233,12 +233,6 @@ public static List getFlattenedObjectList(PathObject parentObject, L * @return */ public static int countDescendants(final PathObject pathObject) { -// int count = pathObject.nChildObjects(); -// for (PathObject childObject : pathObject.getChildObjectsAsArray()) -// count += countDescendants(childObject); -// if (count > 0) -// System.err.println(count); -// return count; return pathObject.nDescendants(); } diff --git a/qupath-core/src/main/java/qupath/lib/objects/hierarchy/PathObjectTileCache.java b/qupath-core/src/main/java/qupath/lib/objects/hierarchy/PathObjectTileCache.java index 71c491f62..c1f7cc5dd 100644 --- a/qupath-core/src/main/java/qupath/lib/objects/hierarchy/PathObjectTileCache.java +++ b/qupath-core/src/main/java/qupath/lib/objects/hierarchy/PathObjectTileCache.java @@ -342,7 +342,6 @@ private void removeFromCache(PathObject pathObject, boolean removeChildren) { } else { logger.debug("No envelope found for {}", pathObject); } -// System.err.println("After: " + mapObjects.query(MAX_ENVELOPE).size()); // Remove the children if (removeChildren) { for (PathObject child : pathObject.getChildObjectsAsArray()) diff --git a/qupath-core/src/main/java/qupath/lib/roi/AreaROI.java b/qupath-core/src/main/java/qupath/lib/roi/AreaROI.java index b39b82e85..34f3bbeb4 100644 --- a/qupath-core/src/main/java/qupath/lib/roi/AreaROI.java +++ b/qupath-core/src/main/java/qupath/lib/roi/AreaROI.java @@ -174,12 +174,7 @@ public boolean contains(double x, double y) { int sum = 0; for (Vertices v : vertices) { sum += WindingTest.getWindingNumber(v, x, y); -// if (WindingTest.getWindingNumber(v, x, y) != 0) { -// System.err.println(WindingTest.getWindingNumber(v, x, y) + "(" + ind + " of " + vertices.size() + ")"); -//// return true; -// } } -// return false; return sum != 0; } diff --git a/qupath-core/src/main/java/qupath/lib/roi/PolygonROI.java b/qupath-core/src/main/java/qupath/lib/roi/PolygonROI.java index a4ced745b..7e452bdcd 100644 --- a/qupath-core/src/main/java/qupath/lib/roi/PolygonROI.java +++ b/qupath-core/src/main/java/qupath/lib/roi/PolygonROI.java @@ -442,9 +442,6 @@ private static class SerializationProxy implements Serializable { private Object readResolve() { PolygonROI roi = new PolygonROI(x, y, ImagePlane.getPlaneWithChannel(c, z, t), false); roi.stats = this.stats; // Doesn't matter if this is null... -// if (roi.stats == null) { -// System.err.println("Null count: " + (++nullCounter)); -// } return roi; } diff --git a/qupath-core/src/main/java/qupath/lib/roi/RoiEditor.java b/qupath-core/src/main/java/qupath/lib/roi/RoiEditor.java index b05b43b99..c69c70a6d 100644 --- a/qupath-core/src/main/java/qupath/lib/roi/RoiEditor.java +++ b/qupath-core/src/main/java/qupath/lib/roi/RoiEditor.java @@ -291,13 +291,9 @@ public void resetActiveHandle() { * @return */ public ROI requestNewHandle(double x, double y) { -// System.err.println("Requesting new handle: " + activeHandle); if (adjuster == null) return pathROI; -// setROI(adjuster.requestNewHandle(x, y), false); - pathROI = adjuster.requestNewHandle(x, y); - return pathROI; } @@ -336,14 +332,10 @@ public boolean grabHandle(double x, double y, double maxDist, boolean shiftDown) * @return */ public ROI setActiveHandlePosition(double x, double y, double minDisplacement, boolean shiftDown) { -// System.err.println("Set position: " + activeHandle); // Check if we have an active handle, or have moved it anything worth considering if (adjuster == null || (activeHandle != null && activeHandle.distanceSq(x, y) < minDisplacement*minDisplacement)) return pathROI; - -// setROI(adjuster.updateActiveHandleLocation(x, y, shiftDown), false); pathROI = adjuster.updateActiveHandleLocation(x, y, shiftDown); - return pathROI; } @@ -770,12 +762,6 @@ public PolylineROI requestNewHandle(double x, double y) { // // Don't add a handle at almost the sample place as an existing handle // if (handles.size() >= 2 && activeHandle == handles.get(handles.size() - 1) && handles.get(handles.size() - 2).distanceSq(x, y) < 0.5) { // return roi; -// } - -// // If we have 2 points, which are identical, shift instead of creating -// if (handles.size() >= 2 && activeHandle == handles.get(handles.size() - 1) && activeHandle.distanceSq(handles.get(handles.size() - 2)) < 0.000001) { -// System.err.println("UPDATING HANDLE"); -// return updateActiveHandleLocation(x, y, false); // } activeHandle = new MutablePoint(x, y); @@ -829,7 +815,6 @@ PointsROI updateActiveHandleLocation(double xNew, double yNew, boolean shiftDown roi = new PointsROI(createPoint2List(handles), roi.getImagePlane()); ensureHandlesUpdated(); activeHandle = grabHandle(xNew, yNew, Double.POSITIVE_INFINITY, shiftDown); -// System.err.println("Calling: " + activeHandle + " - " + (handles == null ? 0 : handles.size())); return roi; } diff --git a/qupath-core/src/main/java/qupath/lib/roi/RoiTools.java b/qupath-core/src/main/java/qupath/lib/roi/RoiTools.java index a0067eb71..e4dab0176 100644 --- a/qupath-core/src/main/java/qupath/lib/roi/RoiTools.java +++ b/qupath-core/src/main/java/qupath/lib/roi/RoiTools.java @@ -899,9 +899,6 @@ else if (prepared2.covers(col)) if (geometryLocal.isEmpty()) continue; -// if (geometry != geometryLocal) -// System.err.println("Using row or column geometry!"); - // Create the tile var rect = GeometryTools.createRectangle(x, y, w + overlap*2, h + overlap*2); diff --git a/qupath-core/src/test/java/qupath/lib/objects/TestPathObjectPredicates.java b/qupath-core/src/test/java/qupath/lib/objects/TestPathObjectPredicates.java index 2f30a2e7d..e3c62b8ce 100644 --- a/qupath-core/src/test/java/qupath/lib/objects/TestPathObjectPredicates.java +++ b/qupath-core/src/test/java/qupath/lib/objects/TestPathObjectPredicates.java @@ -19,8 +19,8 @@ * #L% */ -package qupath.lib.objects; - +package qupath.lib.objects; + import static org.junit.jupiter.api.Assertions.*; import java.util.ArrayList; @@ -38,10 +38,10 @@ import qupath.lib.roi.ROIs; -@SuppressWarnings("javadoc") -public class TestPathObjectPredicates { - - @Test +@SuppressWarnings("javadoc") +public class TestPathObjectPredicates { + + @Test public void test_predicates() { // Create lots of objects @@ -190,12 +190,10 @@ static long total(List... list) { } static long total(List allObjects, Predicate predicate) { -// System.err.println("Calling general"); return allObjects.stream().filter(predicate).count(); } static long total(List allObjects, PathObjectPredicate predicate) { -// System.err.println("Calling specific"); return allObjects.stream().filter(predicate).count(); } @@ -212,5 +210,5 @@ static List createAnnotations(int n, PathClass pathClass) { .toList(); } - -} + +} diff --git a/qupath-core/src/test/java/qupath/lib/objects/TestPathObjectTools.java b/qupath-core/src/test/java/qupath/lib/objects/TestPathObjectTools.java index fec5a6f48..1e383b6f5 100644 --- a/qupath-core/src/test/java/qupath/lib/objects/TestPathObjectTools.java +++ b/qupath-core/src/test/java/qupath/lib/objects/TestPathObjectTools.java @@ -156,11 +156,9 @@ public void testFindOutside() { var outsideOrIntersects = Streams.concat(pathObjectsOverlaps.stream(), pathObjectsOutside.stream()).collect(Collectors.toSet()); var foundOutside = PathObjectTools.findObjectsOutsideRegion(allObjects, region, 0, region.getZ()+1, 0, region.getT()+1, true); -// System.err.println(foundOutside.size() + " / " + pathObjectsOutside.size()); assertEquals(pathObjectsOutside, new HashSet<>(foundOutside)); var foundOutsideStrict = PathObjectTools.findObjectsOutsideRegion(allObjects, region, 0, region.getZ()+1, 0, region.getT()+1, false); -// System.err.println(foundOutsideStrict.size() + " / " + outsideOrIntersects.size()); assertEquals(outsideOrIntersects, new HashSet<>(foundOutsideStrict)); } diff --git a/qupath-core/src/test/java/qupath/lib/objects/hierarchy/TestPathObjectHierarchyLegacy.java b/qupath-core/src/test/java/qupath/lib/objects/hierarchy/TestPathObjectHierarchyLegacy.java index b57e13d5e..0735543d9 100644 --- a/qupath-core/src/test/java/qupath/lib/objects/hierarchy/TestPathObjectHierarchyLegacy.java +++ b/qupath-core/src/test/java/qupath/lib/objects/hierarchy/TestPathObjectHierarchyLegacy.java @@ -229,9 +229,7 @@ public void test_resolveHierarchyWithTMA() { // Add another detection inside the core but outside of any annotations var detectionInCore = PathObjects.createDetectionObject( ROIs.createRectangleROI(1000, 510, 1, 1, ImagePlane.getDefaultPlane()) -// ROIs.createPointsROI(1000, 510, ImagePlane.getDefaultPlane()) ); -// System.err.println("Centroid: " + detectionInCore.getROI().getCentroidX() + ", " + detectionInCore.getROI().getCentroidY()); detectionInCore.setName("Detection in core only"); hierarchy.addObject(detectionInCore); mapDetections.put(detectionInCore, core); @@ -271,8 +269,7 @@ public void test_resolveHierarchyWithTMA() { assertEquals(annotationOutsideCore.getParent(), hierarchy.getRootObject()); for (var entry : mapDetections.entrySet()) { -// System.err.println(entry.getKey() + ": " + entry.getKey().getParent() + ", " + entry.getValue()); - assertEquals(entry.getKey().getParent(), entry.getValue()); + assertEquals(entry.getKey().getParent(), entry.getValue()); } } @@ -338,9 +335,7 @@ public void test_resolveHierarchy() { // Add another detection inside the core but outside of any annotations var detectionInCore = PathObjects.createDetectionObject( ROIs.createRectangleROI(1000, 510, 1, 1, ImagePlane.getDefaultPlane()) -// ROIs.createPointsROI(1000, 510, ImagePlane.getDefaultPlane()) ); -// System.err.println("Centroid: " + detectionInCore.getROI().getCentroidX() + ", " + detectionInCore.getROI().getCentroidY()); detectionInCore.setName("Detection in core only"); hierarchy.addObject(detectionInCore); diff --git a/qupath-extension-processing/src/main/java/qupath/process/gui/commands/ml/PixelClassifierPane.java b/qupath-extension-processing/src/main/java/qupath/process/gui/commands/ml/PixelClassifierPane.java index 56ac4799b..14ea86630 100644 --- a/qupath-extension-processing/src/main/java/qupath/process/gui/commands/ml/PixelClassifierPane.java +++ b/qupath-extension-processing/src/main/java/qupath/process/gui/commands/ml/PixelClassifierPane.java @@ -971,9 +971,6 @@ private void doClassification() { else trainData.shuffleTrainTest(); -// System.err.println("Train: " + trainData.getTrainResponses()); -// System.err.println("Test: " + trainData.getTestResponses()); - // Apply normalization, if we need to FeaturePreprocessor preprocessor = normalization.build(trainData.getTrainSamples(), false); if (preprocessor.doesSomething()) { diff --git a/qupath-extension-processing/src/main/java/qupath/process/gui/commands/ml/PixelClassifierTraining.java b/qupath-extension-processing/src/main/java/qupath/process/gui/commands/ml/PixelClassifierTraining.java index 4e344fa74..7da0abf14 100644 --- a/qupath-extension-processing/src/main/java/qupath/process/gui/commands/ml/PixelClassifierTraining.java +++ b/qupath-extension-processing/src/main/java/qupath/process/gui/commands/ml/PixelClassifierTraining.java @@ -397,7 +397,6 @@ private static TileFeatures getTileFeatures(RegionRequest request, ImageDataServ // Calculate new features try { -// System.err.println("Calculating " + request); features = new TileFeatures(request, featureServer, strategy, rois, labels); cache.put(request, features); } catch (IOException e) { @@ -518,9 +517,6 @@ private void ensureFeaturesCalculated() throws IOException { matTargets = new Mat(n, 1, opencv_core.CV_32SC1); if (n == 0) { -// new ImagePlus("Mask", IJTools.convertToImageProcessor(imgLabels, 0)).show(); -// for (var r : rois.keySet()) -// System.err.println(r.getArea()); // This can happen if a training annotation falls exactly on a tile boundary // (However note that the boundary strategy can still make some annotations useful sometimes) logger.debug("I thought I'd have features but I don't! " + rois.size() + " - " + request); diff --git a/qupath-gui-fx/src/main/java/qupath/lib/display/DirectServerChannelInfo.java b/qupath-gui-fx/src/main/java/qupath/lib/display/DirectServerChannelInfo.java index 0b8e1942c..881e95de3 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/display/DirectServerChannelInfo.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/display/DirectServerChannelInfo.java @@ -171,9 +171,7 @@ public float getValue(BufferedImage img, int x, int y) { public float[] getValues(BufferedImage img, int x, int y, int w, int h, float[] array) { if (array == null || array.length < w * h) array = new float[w * h]; - // long start = System.currentTimeMillis(); float[] samples = img.getRaster().getSamples(x, y, w, h, channel, array); - // System.err.println("Time here: " + (System.currentTimeMillis() - start)); return samples; } diff --git a/qupath-gui-fx/src/main/java/qupath/lib/display/ImageDisplay.java b/qupath-gui-fx/src/main/java/qupath/lib/display/ImageDisplay.java index 204945664..5dcb2824b 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/display/ImageDisplay.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/display/ImageDisplay.java @@ -696,11 +696,7 @@ public void setChannelSelected(ChannelDisplayInfo channel, boolean selected) { */ @Override public BufferedImage applyTransforms(BufferedImage imgInput, BufferedImage imgOutput) { -// long startTime = System.currentTimeMillis(); - BufferedImage imgResult = applyTransforms(imgInput, imgOutput, selectedChannels, displayMode().getValue()); -// long endTime = System.currentTimeMillis(); -// System.err.println("Transform time: " + (endTime - startTime)); return imgResult; } diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/charts/Charts.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/charts/Charts.java index e7df3c991..720b2c3c2 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/charts/Charts.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/charts/Charts.java @@ -38,6 +38,8 @@ import javafx.scene.paint.Color; import javafx.stage.Stage; import javafx.stage.Window; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import qupath.fx.utils.FXUtils; import qupath.lib.common.ColorTools; import qupath.lib.common.GeneralTools; @@ -448,7 +450,9 @@ public T yLabel(String label) { abstract static class XYNumberChartBuilder, S extends XYChart> extends XYChartBuilder { - + + private static final Logger logger = LoggerFactory.getLogger(XYNumberChartBuilder.class); + protected abstract S createNewChart(Axis xAxis, Axis yAxis); private Double xLower, xUpper; @@ -548,7 +552,9 @@ private static void setBoundIfValid(DoubleProperty prop, Double val) { * Builder for creating scatter charts. */ public static class ScatterChartBuilder extends XYNumberChartBuilder> { - + + private static final Logger logger = LoggerFactory.getLogger(ScatterChartBuilder.class); + private ObservableList> series = FXCollections.observableArrayList(); private Integer DEFAULT_MAX_DATAPOINTS = 10_000; @@ -728,7 +734,7 @@ public ScatterChartBuilder series(String name, Collection> } else n = maxDatapoints.intValue(); if (data.size() > n) { - System.err.println("Subsampling " + data.size() + " data points to " + n); + logger.warn("Subsampling {} data points to {}", data.size(), n); var list = new ArrayList<>(data); Collections.shuffle(list); data = list.subList(0, n); diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/charts/ExportChartPane.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/charts/ExportChartPane.java index e475d8e03..8b2de8d57 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/charts/ExportChartPane.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/charts/ExportChartPane.java @@ -256,11 +256,6 @@ public double getScale() { public ExportChartPane(final Chart chart) { this.chart = chart; - // Node node = chart2.lookup(".chart-legend"); - // System.err.println(node); - // Node node = chart2.lookup(".chart-legend"); - // System.err.println(((Region)node).getChildrenUnmodifiable()); - Button btnCopy = new Button("Copy"); btnCopy.setOnAction(e -> { Image img = getChartImage(); diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/PathObjectImageManagers.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/PathObjectImageManagers.java index 60ba8d680..10bcba4f5 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/PathObjectImageManagers.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/PathObjectImageManagers.java @@ -81,7 +81,6 @@ private static class PathObjectTableCell painter, double padding) { super(); -// System.err.println("New cell: " + counter.incrementAndGet()); logger.trace("Creating new cell ({})", + System.identityHashCode(this)); this.setContentDisplay(ContentDisplay.CENTER); this.setAlignment(Pos.CENTER); diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/RigidObjectEditorCommand.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/RigidObjectEditorCommand.java index 77a6c40e1..7a0798dd5 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/RigidObjectEditorCommand.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/RigidObjectEditorCommand.java @@ -566,8 +566,7 @@ public Geometry getTransformedBounds() { public void setRotationByVector(final double x, final double y) { double vecY = (anchorY + dy) - y; double vecX = x - (anchorX + dx); - theta = Math.atan2(vecX, vecY);// + Math.PI/2; -// System.err.println(String.format("Theta: %.2f (%.2f, %.2f)", theta, vecX, vecY)); + theta = Math.atan2(vecX, vecY); resetCachedShapes(); } diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/images/stores/AbstractImageRegionStore.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/images/stores/AbstractImageRegionStore.java index 298b49cfc..a7b11a821 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/images/stores/AbstractImageRegionStore.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/images/stores/AbstractImageRegionStore.java @@ -129,7 +129,6 @@ protected AbstractImageRegionStore(final SizeEstimator sizeEstimator, final i .concurrencyLevel(concurrencyLevel) // .recordStats() .removalListener(n -> { -// System.err.println(n.getKey() + " (" + cache.size() + ")"); if (n.getCause() == RemovalCause.COLLECTED) logger.debug("Cached tile collected" + n.getKey() + " (cache size=" + cache.size()+")"); }) diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/images/stores/DefaultImageRegionStore.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/images/stores/DefaultImageRegionStore.java index 1091b5367..9cffb3b85 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/images/stores/DefaultImageRegionStore.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/images/stores/DefaultImageRegionStore.java @@ -283,7 +283,6 @@ else if (server != null) } img = imgTemp; } -// System.err.println(String.format("%dx%d, %.2f - %.2f", img.getWidth(), img.getHeight(), (double)request.getHeight()/img.getHeight(), request.getDownsample())); g.drawImage(img, request.getX(), request.getY(), request.getWidth(), request.getHeight(), observer); if (DEBUG_TILES) { g.setColor(Color.RED); diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/panes/AnnotationPane.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/panes/AnnotationPane.java index c0e011418..3ff24ae08 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/panes/AnnotationPane.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/panes/AnnotationPane.java @@ -345,16 +345,11 @@ else if (pathObject.isAnnotation()) return; } -// System.err.println("Starting..."); -// System.err.println(hierarchy.getAnnotationObjects().size()); -// System.err.println(hierarchySelected.size()); -// System.err.println(listAnnotations.getItems().size()); if (hierarchySelected.containsAll(listAnnotations.getItems())) { model.selectAll(); return; } - // System.err.println("Setting " + currentlySelected + " to " + selected); int[] inds = new int[selected.size()]; int i = 0; model.clearSelection(); diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/prefs/QuPathStyleManager.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/prefs/QuPathStyleManager.java index a5edd597e..5696ffa38 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/prefs/QuPathStyleManager.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/prefs/QuPathStyleManager.java @@ -564,7 +564,6 @@ private static void removePreviousStyleSheets(String... urls) { m.invoke(styleManager, url); logger.debug("Stylesheet removed {}", url); } -// System.err.println("After removal: " + previouslyAddedStyleSheets); } catch (Exception e) { logger.error("Unable to call removeUserAgentStylesheet", e); } @@ -582,7 +581,6 @@ private static void addStyleSheets(String... urls) { previouslyAddedStyleSheets.add(url); logger.debug("Stylesheet added {}", url); } -// System.err.println("After adding: " + previouslyAddedStyleSheets); } catch (Exception e) { logger.error("Unable to call addUserAgentStylesheet", e); } diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/scripting/DefaultScriptEditor.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/scripting/DefaultScriptEditor.java index 75319f26f..a30404e76 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/scripting/DefaultScriptEditor.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/scripting/DefaultScriptEditor.java @@ -1456,8 +1456,6 @@ public synchronized void flush() throws IOException { doc.appendText(s); else Platform.runLater(() -> doc.appendText(s)); -// flushCount++; -// System.err.println("Flush called: " + flushCount); } @Override diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/tma/KaplanMeierChartWrapper.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/tma/KaplanMeierChartWrapper.java index 42db55e80..109f1689c 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/tma/KaplanMeierChartWrapper.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/tma/KaplanMeierChartWrapper.java @@ -188,22 +188,11 @@ public Number fromString(String s) { text.setTextAlignment(TextAlignment.CENTER); } } - -// for (TickMark mark : v.getList()) { -// mark. -// TickMark.class.getField("") -// } -// System.err.println(v.getList().get(0).getClass()); }); -// xAxis.lay - - + showAtRisk.addListener((v, o, n) -> { updateChart(); chart.layout(); -// xAxis.requestAxisLayout(); -// chart.requestLayout(); -// chart.layout(); }); @@ -252,25 +241,10 @@ public Number fromString(String s) { SnapshotParameters params = new SnapshotParameters(); params.setTransform(Transform.scale(scale, scale)); - // for (Node node : chart.getChildrenUnmodifiable()) { - // node.setStyle("-fx-background-color: white"); - // System.err.println(node.getClass().getName()); - // } - // chart.setStyle(".chart-content {\n" - // + "-fx-background-color: white;\n" - // + "}"); - WritableImage img = region.snapshot(params, new WritableImage(w, h)); ClipboardContent content = new ClipboardContent(); content.putImage(img); Clipboard.getSystemClipboard().setContent(content); - - // for (Node node : chart.getChildrenUnmodifiable()) { - // node.setStyle(null); - // System.err.println(node.getClass().getName()); - // } - // chart.setStyle(""); - }); MenuItem miCopy = new MenuItem("Chart only"); diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/tma/KaplanMeierDisplay.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/tma/KaplanMeierDisplay.java index 2e8960903..91fad85cd 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/tma/KaplanMeierDisplay.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/tma/KaplanMeierDisplay.java @@ -1170,7 +1170,7 @@ void setSurvivalCurves(final double[] thresholds, final boolean correctPValues, NormalDistribution dist = new NormalDistribution(); z = dist.inverseCumulativeProbability(1 - pValue/2); double phi = dist.density(z); - // System.err.println("PHI: " + phi + " for " + z); + double pValueAdjusted = phi * (z - 1/z) * Math.log((1 - epsilon)*(1 - epsilon)/(epsilon*epsilon)) + 4 * phi/z; values.add(df4.format(pValueAdjusted)); diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/tma/TMASummaryViewer.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/tma/TMASummaryViewer.java index d7240e399..8ce1f3df8 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/tma/TMASummaryViewer.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/tma/TMASummaryViewer.java @@ -776,8 +776,6 @@ else if (model.getAllNames().contains("Censored")) { // Generate a pseudo TMA core hierarchy Map> scoreMap = createScoresMap(model.getItems(), colScore, colID); -// System.err.println("Score map size: " + scoreMap.size() + "\tEntries: " + model.getEntries().size()); - List cores = new ArrayList<>(scoreMap.size()); double[] scores = new double[15]; for (Entry> entry : scoreMap.entrySet()) { @@ -803,15 +801,12 @@ else if (model.getAllNames().contains("Censored")) { score = (scores[n/2-1] + scores[n/2]) / 2; core.putMetadataValue(TMACoreObject.KEY_CASE_ID, entry.getKey()); -// System.err.println("Putting: " + list.get(0).getMeasurement(colSurvival).doubleValue() + " LIST: " + list.size()); ml.put(colSurvival, list.get(0).getMeasurementAsDouble(colSurvival)); ml.put(colCensoredRequested, list.get(0).getMeasurementAsDouble(colCensored)); if (colScore != null) ml.put(colScore, score); cores.add(core); - -// logger.info(entry.getKey() + "\t" + score); } TMAGrid grid = DefaultTMAGrid.create(cores, 1); @@ -1298,31 +1293,13 @@ void setTMAEntries(final Collection newEntries) { // Store the names of any currently hidden columns lastHiddenColumns = table.getColumns().stream().filter(c -> !c.isVisible()).map(c -> c.getText()).collect(Collectors.toSet()); - -// this.table.getColumns().clear(); - -// // Useful for a paper, but not generally... -// int count = 0; -// int nCells = 0; -// int nTumor = 0; -// for (TMAEntry entry : entriesBase) { -// if (!entry.isMissing() && (predicate.get() == null || predicate.get().test(entry))) { -// count++; -// nCells += (int)(entry.getMeasurement("Num Tumor").doubleValue() + entry.getMeasurement("Num Stroma").doubleValue()); -// nTumor += (int)(entry.getMeasurement("Num Tumor").doubleValue()); -// } -// } -// System.err.println(String.format("Num entries:\t%d\tNum tumor:\t%d\tNum cells:\t%d", count, nTumor, nCells)); - - + // Update measurement names Set namesMeasurements = new LinkedHashSet<>(); Set namesMetadata = new LinkedHashSet<>(); -// boolean containsSummaries = false; for (TMAEntry entry : newEntries) { namesMeasurements.addAll(entry.getMeasurementNames()); namesMetadata.addAll(entry.getMetadataNames()); -// containsSummaries = containsSummaries || entry instanceof TMASummaryEntry; } // Get the available survival columns @@ -1402,17 +1379,7 @@ private void handleHierarchyChange(PathObjectHierarchyEvent event) { * @see #refreshTable() */ private void refreshTableData() { - -// int nn = 0; -// double nPositive = 0; -// for (TMAEntry entry : entriesBase) { -// if (entry.isMissing()) -// continue; -// nPositive += entry.getMeasurementAsDouble("Num Positive"); -// nn++; -// } -// System.err.println(nPositive + " positive cells across " + nn + " tissue samples"); - + Collection entries = groupByIDProperty.get() ? createSummaryEntries(entriesBase) : entriesBase; // Ensure that we don't try to modify a filtered list diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/QuPathViewer.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/QuPathViewer.java index 66bb0f1d6..9b6e05dd0 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/QuPathViewer.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/QuPathViewer.java @@ -740,12 +740,6 @@ public void detach() { } -// protected void finalize() throws Throwable { -// System.err.println("Viewer being removed!"); -// super.finalize(); -// } - - private ListenerManager manager = new ListenerManager(); private ListenerManager overlayOptionsManager = new ListenerManager(); @@ -2878,12 +2872,6 @@ public void setCenterPixelLocation(double x, double y) { if ((this.xCenter == x && this.yCenter == y) || Double.isNaN(x + y)) return; -// double dx = xCenter - x; -// double dy = yCenter - y; -// if (dx*dx + dy*dy > 1000) { -// System.err.println("Moving a lot"); -// } - this.xCenter = x; this.yCenter = y; updateAffineTransform(); diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/ViewerManager.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/ViewerManager.java index b0c4656ca..4d36088c9 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/ViewerManager.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/ViewerManager.java @@ -381,8 +381,6 @@ protected QuPathViewerPlus createViewer() { * @return true if the row was removed, false otherwise */ public boolean removeRow(final QuPathViewer viewer) { - // if (viewer.getServer() != null) - // System.err.println(viewer.getServer().getShortServerName()); // Note: These are the internal row numbers... these don't necessarily match with the displayed row (?) int row = splitPaneGrid.getRow(viewer); if (row < 0) { diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/overlays/PixelClassificationOverlay.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/overlays/PixelClassificationOverlay.java index ded47d1c8..ccccd457a 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/overlays/PixelClassificationOverlay.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/overlays/PixelClassificationOverlay.java @@ -452,7 +452,6 @@ public void paintOverlay(Graphics2D g2d, ImageRegion imageRegion, double downsam gCopy.drawImage(imgRGB, request.getX(), request.getY(), request.getWidth(), request.getHeight(), null); // g2d.setColor(Color.RED); // g2d.drawRect(request.getX(), request.getY(), request.getWidth(), request.getHeight()); -// System.err.println(request.getHeight() == imgRGB.getHeight()); continue; } @@ -540,7 +539,6 @@ void requestTile(TileRequest tile, ImageData imageData, ImageServ if (!pendingRequests.contains(tile) || !currentRequests.add(tile)) { return; } -// System.err.println(tile.hashCode() + " - " + ImageRegion.createInstance(tile.getImageX(), tile.getImageY(), tile.getImageWidth(), tile.getImageHeight(), tile.getZ(), tile.getT())); var changed = new ArrayList(); var hierarchy = imageData == null ? null : imageData.getHierarchy(); try { diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/tools/handlers/MoveToolEventHandler.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/tools/handlers/MoveToolEventHandler.java index 673efc5b8..108464b02 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/tools/handlers/MoveToolEventHandler.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/tools/handlers/MoveToolEventHandler.java @@ -204,9 +204,6 @@ public void mouseDragged(MouseEvent e) { ((PathROIObject)viewer.getSelectedObject()).setROI(translatedROI); viewer.getHierarchy().fireObjectsChangedEvent(this, Collections.singleton(viewer.getSelectedObject()), true); - // System.err.println("Changing... " + viewer.getHierarchy().nObjects()); - -// viewer.repaintImageRegion(boundsIntersection, false); } pDragging = null; return; From 3592d6aa546beed5f004f15166bc24b30ab00902 Mon Sep 17 00:00:00 2001 From: Pete Date: Thu, 22 Aug 2024 07:34:06 +0100 Subject: [PATCH 221/866] Support subdivision display This does slow down viewer repainting when we have many objects, so required optimization. We'd also need a button to turn on/off showing neighbors. --- .../qupath/lib/analysis/DelaunayTools.java | 17 ++++- .../hierarchy/PathObjectHierarchy.java | 39 ++++++++++- .../servers/PathHierarchyImageServer.java | 13 ++++ .../lib/gui/viewer/PathObjectPainter.java | 68 +++++++++++++++++++ .../gui/viewer/overlays/HierarchyOverlay.java | 32 ++++++--- 5 files changed, 157 insertions(+), 12 deletions(-) diff --git a/qupath-core/src/main/java/qupath/lib/analysis/DelaunayTools.java b/qupath-core/src/main/java/qupath/lib/analysis/DelaunayTools.java index 557458410..bfda457e1 100644 --- a/qupath-core/src/main/java/qupath/lib/analysis/DelaunayTools.java +++ b/qupath-core/src/main/java/qupath/lib/analysis/DelaunayTools.java @@ -632,7 +632,7 @@ public Map getVoronoiFaces() { } /** - * Get a map of Voronoi faces, convered to {@link ROI} objects. + * Get a map of Voronoi faces, converted to {@link ROI} objects. * @param clip optional region used to clip the total extent of the ROIs * @return * @see #getVoronoiFaces() @@ -745,6 +745,21 @@ public Map> getAllNeighbors() { return neighbors; } + /** + * Query if the subdivision is empty, i.e. it contains no objects. + * @return + */ + public boolean isEmpty() { + return pathObjects.isEmpty(); + } + + /** + * Get the number of objects in this subdivision. + * @return + */ + public int size() { + return pathObjects.size(); + } /** * Return a map of PathObjects and their neighbors, sorted by distance. diff --git a/qupath-core/src/main/java/qupath/lib/objects/hierarchy/PathObjectHierarchy.java b/qupath-core/src/main/java/qupath/lib/objects/hierarchy/PathObjectHierarchy.java index 2e36cb17e..086107eb1 100644 --- a/qupath-core/src/main/java/qupath/lib/objects/hierarchy/PathObjectHierarchy.java +++ b/qupath-core/src/main/java/qupath/lib/objects/hierarchy/PathObjectHierarchy.java @@ -26,6 +26,7 @@ import java.io.Serial; import java.io.Serializable; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.Comparator; @@ -1188,6 +1189,35 @@ public synchronized DelaunayTools.Subdivision getSubdivision(PathObject pathObje return subdivisionManager.getSubdivision(pathObject); } + /** + * Get a subdivision containing detections. + * This does not< include sub-classes such as 'cell' or 'tile'. + * @param plane + * @return + */ + public synchronized DelaunayTools.Subdivision getDetectionSubdivision(ImagePlane plane) { + return subdivisionManager.getSubdivision(PathDetectionObject.class, plane); + } + + /** + * Get a subdivision containing cell objects. + * @param plane + * @return + */ + public synchronized DelaunayTools.Subdivision getCellSubdivision(ImagePlane plane) { + return subdivisionManager.getSubdivision(PathCellObject.class, plane); + } + + /** + * Get a subdivision containing annotation objects. + * @param plane + * @return + */ + public synchronized DelaunayTools.Subdivision getAnnotationSubdivision(ImagePlane plane) { + return subdivisionManager.getSubdivision(PathAnnotationObject.class, plane); + } + + private DelaunayTools.Subdivision computeSubdivision(Class cls) { var pathObjects = tileCache.getObjectsForRegion(cls, null, null, false); @@ -1211,9 +1241,12 @@ synchronized DelaunayTools.Subdivision getSubdivision(PathObject pathObject) { if (pathObject == null || !pathObject.hasROI()) { return EMPTY; } - var map = subdivisionMap.computeIfAbsent(pathObject.getClass(), k -> new ConcurrentHashMap<>()); - var plane = pathObject.getROI().getImagePlane(); - return map.computeIfAbsent(plane, k -> computeSubdivision(pathObject.getClass(), plane)); + return getSubdivision(pathObject.getClass(), pathObject.getROI().getImagePlane()); + } + + synchronized DelaunayTools.Subdivision getSubdivision(Class cls, ImagePlane plane) { + var map = subdivisionMap.computeIfAbsent(cls, k -> new ConcurrentHashMap<>()); + return map.computeIfAbsent(plane, k -> computeSubdivision(cls, plane)); } private DelaunayTools.Subdivision computeSubdivision(Class cls, ImagePlane plane) { diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/images/servers/PathHierarchyImageServer.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/images/servers/PathHierarchyImageServer.java index a26fb9f52..89cf11423 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/images/servers/PathHierarchyImageServer.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/images/servers/PathHierarchyImageServer.java @@ -247,6 +247,19 @@ protected BufferedImage readTile(TileRequest tileRequest) throws IOException { imageData.isFluorescence() ? ColorToolsAwt.TRANSLUCENT_WHITE : ColorToolsAwt.TRANSLUCENT_BLACK, downsampleFactor, tileRequest.getImagePlane()); + } else if (options.getShowConnections()) { + // If we have cells, show them + // Otherwise, show any detections we have + var subdiv = hierarchy.getCellSubdivision(tileRequest.getImagePlane()); + if (subdiv.isEmpty()) + subdiv = hierarchy.getDetectionSubdivision(tileRequest.getImagePlane()); + PathObjectPainter.paintConnections( + subdiv, + hierarchy, + g2d, + imageData.isFluorescence() ? ColorToolsAwt.TRANSLUCENT_WHITE : ColorToolsAwt.TRANSLUCENT_BLACK, + downsampleFactor, + tileRequest.getImagePlane()); } g2d.dispose(); return img; diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/PathObjectPainter.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/PathObjectPainter.java index fd8d8f9c1..f1fe321bd 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/PathObjectPainter.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/PathObjectPainter.java @@ -55,6 +55,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import qupath.lib.analysis.DelaunayTools; import qupath.lib.awt.common.AwtTools; import qupath.lib.color.ColorToolsAwt; import qupath.lib.geom.Point2; @@ -1159,4 +1160,71 @@ public static void paintConnections(final PathObjectConnections connections, fin } + /** + * Paint connections between objects from a {@link qupath.lib.analysis.DelaunayTools.Subdivision}. + * + * @param subdivision + * @param hierarchy + * @param g2d + * @param color + * @param downsampleFactor + * @param plane + */ + public static void paintConnections(final DelaunayTools.Subdivision subdivision, final PathObjectHierarchy hierarchy, Graphics2D g2d, final Color color, final double downsampleFactor, final ImagePlane plane) { + if (hierarchy == null || subdivision.size() <= 1) + return; + + float alpha = (float)(1f - downsampleFactor / 5); + alpha = Math.min(alpha, 0.4f); + double thickness = PathPrefs.detectionStrokeThicknessProperty().get(); + if (alpha < .1f || thickness / downsampleFactor <= 0.25) + return; + + g2d = (Graphics2D)g2d.create(); + + g2d.setStroke(getCachedStroke(thickness)); + + g2d.setColor(ColorToolsAwt.getColorWithOpacity(color.getRGB(), alpha)); + + // We only need to draw connections that intersect with the bounds + Rectangle bounds = g2d.getClipBounds(); + + g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_OFF); + g2d.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_SPEED); + + // Keep reference to visited objects, to avoid painting the same line twice + Set vistedObjects = new HashSet<>(); + + // Reuse the line and record counts + Line2D line = new Line2D.Double(); + int nDrawn = 0; + int nSkipped = 0; + + long startTime = System.currentTimeMillis(); + for (var pathObject : subdivision.getPathObjects()) { + vistedObjects.add(pathObject); + ROI roi = PathObjectTools.getROI(pathObject, true); + double x1 = roi.getCentroidX(); + double y1 = roi.getCentroidY(); + for (var neighbor : subdivision.getNeighbors(pathObject)) { + if (vistedObjects.contains(neighbor)) + continue; + ROI roi2 = PathObjectTools.getROI(neighbor, true); + double x2 = roi2.getCentroidX(); + double y2 = roi2.getCentroidY(); + if (bounds.intersectsLine(x1, y1, x2, y2)) { + line.setLine(x1, y1, x2, y2); + g2d.draw(line); + nDrawn++; + } else { + nSkipped++; + } + } + } + long endTime = System.currentTimeMillis(); + logger.trace("Drawn {} connections in {} ms ({} skipped)", nDrawn, endTime - startTime, nSkipped); + g2d.dispose(); + } + + } diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/overlays/HierarchyOverlay.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/overlays/HierarchyOverlay.java index aa01cc214..06a2ca64d 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/overlays/HierarchyOverlay.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/overlays/HierarchyOverlay.java @@ -246,14 +246,30 @@ public void paintOverlay(final Graphics2D g2d, final ImageRegion imageRegion, fi PathObjectPainter.paintSpecifiedObjects(g2d, detectionsToPaint, overlayOptions, hierarchy.getSelectionModel(), downsampleFactor); if (overlayOptions.getShowConnections()) { - Object connections = imageData.getProperty(DefaultPathObjectConnectionGroup.KEY_OBJECT_CONNECTIONS); - if (connections instanceof PathObjectConnections) - PathObjectPainter.paintConnections((PathObjectConnections) connections, - hierarchy, - g2d, - imageData.isFluorescence() ? ColorToolsAwt.TRANSLUCENT_WHITE : ColorToolsAwt.TRANSLUCENT_BLACK, - downsampleFactor, - imageRegion.getImagePlane()); + + // If we have cells, show them + // Otherwise, show any detections we have + var subdiv = hierarchy.getCellSubdivision(imageRegion.getImagePlane()); + if (subdiv.isEmpty()) + subdiv = hierarchy.getDetectionSubdivision(imageRegion.getImagePlane()); + + PathObjectPainter.paintConnections( + subdiv, + hierarchy, + g2d, + imageData.isFluorescence() ? ColorToolsAwt.TRANSLUCENT_WHITE : ColorToolsAwt.TRANSLUCENT_BLACK, + downsampleFactor, + imageRegion.getImagePlane() + ); + +// Object connections = imageData.getProperty(DefaultPathObjectConnectionGroup.KEY_OBJECT_CONNECTIONS); +// if (connections instanceof PathObjectConnections) +// PathObjectPainter.paintConnections((PathObjectConnections) connections, +// hierarchy, +// g2d, +// imageData.isFluorescence() ? ColorToolsAwt.TRANSLUCENT_WHITE : ColorToolsAwt.TRANSLUCENT_BLACK, +// downsampleFactor, +// imageRegion.getImagePlane()); } } else { // If the image hasn't been updated, then we are viewing the stationary image - we want to wait for a full repaint then to avoid flickering; From d61a913fdf0288047b017eb36d418a983292877c Mon Sep 17 00:00:00 2001 From: Pete Date: Thu, 22 Aug 2024 08:15:04 +0100 Subject: [PATCH 222/866] Small Delaunay performance improvements --- .../qupath/lib/analysis/DelaunayTools.java | 15 ++++++----- .../servers/PathHierarchyImageServer.java | 27 ++++++++++++------- 2 files changed, 25 insertions(+), 17 deletions(-) diff --git a/qupath-core/src/main/java/qupath/lib/analysis/DelaunayTools.java b/qupath-core/src/main/java/qupath/lib/analysis/DelaunayTools.java index bfda457e1..63899cc47 100644 --- a/qupath-core/src/main/java/qupath/lib/analysis/DelaunayTools.java +++ b/qupath-core/src/main/java/qupath/lib/analysis/DelaunayTools.java @@ -590,7 +590,7 @@ public static class Subdivision { private static final Logger logger = LoggerFactory.getLogger(Subdivision.class); - private final Set pathObjects; + private final Collection pathObjects; private final Map coordinateMap; private final QuadEdgeSubdivision subdivision; @@ -603,7 +603,7 @@ public static class Subdivision { private Subdivision(QuadEdgeSubdivision subdivision, Collection pathObjects, Map coordinateMap, ImagePlane plane) { this.subdivision = subdivision; this.plane = plane; - this.pathObjects = Collections.unmodifiableSet(new LinkedHashSet<>(pathObjects)); + this.pathObjects = pathObjects.stream().distinct().toList(); this.coordinateMap = Map.copyOf(coordinateMap); } @@ -775,6 +775,7 @@ private synchronized Map> calculateAllNeighbors() { Map distanceMap = new HashMap<>(); int missing = 0; + var reusableList = new ArrayList(); for (var edge : edges) { var origin = edge.orig(); distanceMap.clear(); @@ -784,8 +785,8 @@ private synchronized Map> calculateAllNeighbors() { logger.warn("No object found for {}", pathObject); continue; } - - var list = new ArrayList(); + + reusableList.clear(); var next = edge; do { var dest = next.dest(); @@ -796,12 +797,12 @@ private synchronized Map> calculateAllNeighbors() { missing++; } else { distanceMap.put(destObject, next.getLength()); - list.add(destObject); + reusableList.add(destObject); } } while ((next = next.oNext()) != edge); - list.sort(Comparator.comparingDouble(distanceMap::get)); - map.put(pathObject, Collections.unmodifiableList(list)); + reusableList.sort(Comparator.comparingDouble(distanceMap::get)); + map.put(pathObject, List.copyOf(reusableList)); } if (missing > 0) logger.debug("Number of missing neighbors: {}", missing); diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/images/servers/PathHierarchyImageServer.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/images/servers/PathHierarchyImageServer.java index 89cf11423..26075255e 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/images/servers/PathHierarchyImageServer.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/images/servers/PathHierarchyImageServer.java @@ -35,6 +35,7 @@ import java.util.UUID; import java.util.stream.Collectors; +import qupath.lib.analysis.DelaunayTools; import qupath.lib.awt.common.AwtTools; import qupath.lib.color.ColorToolsAwt; import qupath.lib.gui.viewer.OverlayOptions; @@ -198,13 +199,24 @@ protected BufferedImage readTile(TileRequest tileRequest) throws IOException { // Get connections Object o = options.getShowConnections() ? imageData.getProperty(DefaultPathObjectConnectionGroup.KEY_OBJECT_CONNECTIONS) : null; PathObjectConnections connections = (o instanceof PathObjectConnections) ? (PathObjectConnections)o : null; - + + // If we have cells, show them + // Otherwise, show any detections we have + DelaunayTools.Subdivision subdivision = null; + if (options.getShowConnections()) { + subdivision = hierarchy.getCellSubdivision(tileRequest.getImagePlane()); + if (subdivision.isEmpty()) + subdivision = hierarchy.getDetectionSubdivision(tileRequest.getImagePlane()); + } + List pathObjects = new ArrayList<>(getObjectsToPaint(request)); - if (pathObjects == null || pathObjects.isEmpty()) { + if (pathObjects.isEmpty()) { // We can only return null if no connections - otherwise we might still need to draw something - if (connections == null) { + if (!options.getShowConnections()) { return null; } + if (connections == null && (subdivision == null || subdivision.isEmpty())) + return null; } // Because levels *can* change, we need to extract them first to avoid breaking the contract for comparable @@ -247,14 +259,9 @@ protected BufferedImage readTile(TileRequest tileRequest) throws IOException { imageData.isFluorescence() ? ColorToolsAwt.TRANSLUCENT_WHITE : ColorToolsAwt.TRANSLUCENT_BLACK, downsampleFactor, tileRequest.getImagePlane()); - } else if (options.getShowConnections()) { - // If we have cells, show them - // Otherwise, show any detections we have - var subdiv = hierarchy.getCellSubdivision(tileRequest.getImagePlane()); - if (subdiv.isEmpty()) - subdiv = hierarchy.getDetectionSubdivision(tileRequest.getImagePlane()); + } else if (subdivision != null) { PathObjectPainter.paintConnections( - subdiv, + subdivision, hierarchy, g2d, imageData.isFluorescence() ? ColorToolsAwt.TRANSLUCENT_WHITE : ColorToolsAwt.TRANSLUCENT_BLACK, From 514413659ace4901a6638150c7094635643411d8 Mon Sep 17 00:00:00 2001 From: Pete Date: Thu, 22 Aug 2024 09:20:21 +0100 Subject: [PATCH 223/866] Speed up subdivision painting Add a filter to identify objects with edges that may intersect a region, so it's no longer necessary to test all objects to draw each individual tile. --- .../qupath/lib/analysis/DelaunayTools.java | 104 ++++++++++++++---- .../lib/gui/viewer/PathObjectPainter.java | 3 +- 2 files changed, 84 insertions(+), 23 deletions(-) diff --git a/qupath-core/src/main/java/qupath/lib/analysis/DelaunayTools.java b/qupath-core/src/main/java/qupath/lib/analysis/DelaunayTools.java index 63899cc47..74cc017c4 100644 --- a/qupath-core/src/main/java/qupath/lib/analysis/DelaunayTools.java +++ b/qupath-core/src/main/java/qupath/lib/analysis/DelaunayTools.java @@ -47,6 +47,8 @@ import org.locationtech.jts.geom.PrecisionModel; import org.locationtech.jts.geom.util.AffineTransformation; import org.locationtech.jts.geom.util.GeometryCombiner; +import org.locationtech.jts.index.SpatialIndex; +import org.locationtech.jts.index.hprtree.HPRtree; import org.locationtech.jts.index.quadtree.Quadtree; import org.locationtech.jts.precision.GeometryPrecisionReducer; import org.locationtech.jts.triangulate.DelaunayTriangulationBuilder; @@ -59,12 +61,14 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import qupath.lib.common.LogTools; import qupath.lib.images.servers.PixelCalibration; import qupath.lib.objects.PathObject; import qupath.lib.objects.PathObjectTools; import qupath.lib.objects.PathObjects; import qupath.lib.objects.classes.PathClass; import qupath.lib.regions.ImagePlane; +import qupath.lib.regions.ImageRegion; import qupath.lib.roi.GeometryTools; import qupath.lib.roi.RoiTools; import qupath.lib.roi.interfaces.ROI; @@ -199,8 +203,8 @@ private enum ExtractorType {CUSTOM, CENTROIDS, ROI} private double erosion = 1.0; - private ImagePlane plane; - private Collection pathObjects = new ArrayList<>(); + private final ImagePlane plane; + private final Collection pathObjects = new ArrayList<>(); private Function> coordinateExtractor; @@ -444,8 +448,7 @@ static QuadEdgeLocator getDefaultLocator(QuadEdgeSubdivision subdiv) { * @return */ private static Collection prepareCoordinates(Collection coords) { - var list = DelaunayTriangulationBuilder.unique(coords.toArray(Coordinate[]::new)); - return list; + return DelaunayTriangulationBuilder.unique(coords.toArray(Coordinate[]::new)); } /** @@ -598,7 +601,13 @@ public static class Subdivision { private transient volatile Map> neighbors; private transient volatile Map voronoiFaces; - + + /** + * An edge index to speed up finding objects where the edge intersects a specific rectangle. + * This is used to speed object painting. + */ + private transient SpatialIndex edgeIndex; + private Subdivision(QuadEdgeSubdivision subdivision, Collection pathObjects, Map coordinateMap, ImagePlane plane) { this.subdivision = subdivision; @@ -654,10 +663,50 @@ public Map getVoronoiROIs(Geometry clip) { /** * Get all the objects associated with this subdivision. * @return + * @deprecated v0.6.0 use {@link #getObjects()} instead. */ + @Deprecated public Collection getPathObjects() { + LogTools.warnOnce(logger, "getPathObjects() is deprecated; use getObjects() instead"); return pathObjects; } + + /** + * Get all the objects associated with this subdivision. + * @return + */ + public Collection getObjects() { + return pathObjects; + } + + /** + * Get objects with edges that may intersect a specific region. + *

    + * This is especially useful for requesting objects that should be considered when drawing edges for a + * specific region, where the objects themselves don't need to fall within the region - but their edge might. + *

    + * The method should return all objects that have an edge that intersects the region, but it may also return + * additional objects that are not strictly necessary for drawing the region. + * @param region + * @return + */ + public Collection getObjectsForRegion(ImageRegion region) { + if (region.getZ() != plane.getZ() || region.getT() != plane.getT()) + return Collections.emptyList(); + var env = new Envelope( + region.getX(), + region.getX() + region.getWidth(), + region.getY(), + region.getY() + region.getHeight()); + var edges = getEdgeIndex().query(env); + List pathObjects = new ArrayList<>(); + for (var item : edges) { + QuadEdge edge = (QuadEdge) item; + pathObjects.add(getPathObject(edge.orig())); + pathObjects.add(getPathObject(edge.dest())); + } + return pathObjects.stream().distinct().toList(); + } /** * Get the nearest neighbor for the specified object. @@ -767,19 +816,22 @@ public int size() { */ private synchronized Map> calculateAllNeighbors() { - logger.debug("Calculating all neighbors for {} objects", getPathObjects().size()); + logger.debug("Calculating all neighbors for {} objects", size()); @SuppressWarnings("unchecked") var edges = (List)subdivision.getVertexUniqueEdges(false); Map> map = new HashMap<>(); Map distanceMap = new HashMap<>(); - + + // TODO: Don't make this a side effect! + edgeIndex = new HPRtree(); + int missing = 0; var reusableList = new ArrayList(); - for (var edge : edges) { - var origin = edge.orig(); + for (QuadEdge edge : edges) { + Vertex origin = edge.orig(); distanceMap.clear(); - + var pathObject = getPathObject(origin); if (pathObject == null) { logger.warn("No object found for {}", pathObject); @@ -787,9 +839,9 @@ private synchronized Map> calculateAllNeighbors() { } reusableList.clear(); - var next = edge; + QuadEdge next = edge; do { - var dest = next.dest(); + Vertex dest = next.dest(); var destObject = getPathObject(dest); if (destObject == pathObject) { continue; @@ -798,6 +850,9 @@ private synchronized Map> calculateAllNeighbors() { } else { distanceMap.put(destObject, next.getLength()); reusableList.add(destObject); + // Store the edge in the spatial index + var env = new Envelope(next.orig().getCoordinate(), next.dest().getCoordinate()); + edgeIndex.insert(env, next); } } while ((next = next.oNext()) != edge); @@ -808,7 +863,12 @@ private synchronized Map> calculateAllNeighbors() { logger.debug("Number of missing neighbors: {}", missing); return map; } - + + private SpatialIndex getEdgeIndex() { + if (edgeIndex == null) + getAllNeighbors(); // Ensure the index is created + return edgeIndex; + } private PathObject getPathObject(Vertex vertex) { @@ -822,7 +882,7 @@ private PathObject getPathObject(Vertex vertex) { */ private synchronized Map calculateVoronoiFacesByLocations() { - logger.debug("Calculating Voronoi faces for {} objects by location", getPathObjects().size()); + logger.debug("Calculating Voronoi faces for {} objects by location", size()); // We use a new GeometryFactory because we need floating point precision (it seems) to avoid // invalid polygons being returned @@ -904,7 +964,7 @@ private synchronized Map calculateVoronoiFaces() { return calculateVoronoiFacesByLocations(); } - logger.debug("Calculating Voronoi faces for {} objects", getPathObjects().size()); + logger.debug("Calculating Voronoi faces for {} objects", size()); @SuppressWarnings("unchecked") var polygons = (List)subdivision.getVoronoiCellPolygons(GeometryTools.getDefaultFactory()); @@ -978,7 +1038,7 @@ public List> getClusters(BiPredicate(); var output = new ArrayList>(); var neighbors = getFilteredNeighbors(predicate); - for (var pathObject : getPathObjects()) { + for (var pathObject : getObjects()) { if (!alreadyClustered.contains(pathObject)) { var cluster = buildCluster(pathObject, neighbors, alreadyClustered); output.add(cluster); @@ -1015,8 +1075,8 @@ private Collection buildCluster(PathObject parent, Map existingEdges; + private final Set existingEdges; private int calledFirst = 0; private int usedCache = 0; diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/PathObjectPainter.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/PathObjectPainter.java index f1fe321bd..edb04a342 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/PathObjectPainter.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/PathObjectPainter.java @@ -1188,6 +1188,7 @@ public static void paintConnections(final DelaunayTools.Subdivision subdivision, // We only need to draw connections that intersect with the bounds Rectangle bounds = g2d.getClipBounds(); + ImageRegion region = ImageRegion.createInstance(bounds.x, bounds.y, bounds.width, bounds.height, plane.getZ(), plane.getT()); g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_OFF); g2d.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_SPEED); @@ -1201,7 +1202,7 @@ public static void paintConnections(final DelaunayTools.Subdivision subdivision, int nSkipped = 0; long startTime = System.currentTimeMillis(); - for (var pathObject : subdivision.getPathObjects()) { + for (var pathObject : subdivision.getObjectsForRegion(region)) { vistedObjects.add(pathObject); ROI roi = PathObjectTools.getROI(pathObject, true); double x1 = roi.getCentroidX(); From 87111ea5f32c87addb5f66482a9626c7a74e57bd Mon Sep 17 00:00:00 2001 From: Pete Date: Thu, 22 Aug 2024 16:53:19 +0100 Subject: [PATCH 224/866] Fix subdivision updating --- .../qupath/lib/analysis/DelaunayTools.java | 34 +++++++++---------- .../hierarchy/PathObjectHierarchy.java | 27 ++++++++------- 2 files changed, 30 insertions(+), 31 deletions(-) diff --git a/qupath-core/src/main/java/qupath/lib/analysis/DelaunayTools.java b/qupath-core/src/main/java/qupath/lib/analysis/DelaunayTools.java index 74cc017c4..f5a3d9e0b 100644 --- a/qupath-core/src/main/java/qupath/lib/analysis/DelaunayTools.java +++ b/qupath-core/src/main/java/qupath/lib/analysis/DelaunayTools.java @@ -599,14 +599,16 @@ public static class Subdivision { private final ImagePlane plane; - private transient volatile Map> neighbors; private transient volatile Map voronoiFaces; /** - * An edge index to speed up finding objects where the edge intersects a specific rectangle. + * A map to lookup neighbors, and an edge index to speed up finding objects where the edge intersects a + * specific rectangle. * This is used to speed object painting. */ - private transient SpatialIndex edgeIndex; + private record NeighborMap(Map> neighbors, SpatialIndex index) {} + + private transient volatile NeighborMap neighbors; private Subdivision(QuadEdgeSubdivision subdivision, Collection pathObjects, Map coordinateMap, ImagePlane plane) { @@ -785,10 +787,14 @@ public Map> getFilteredNeighbors(BiPredicate> getAllNeighbors() { + return getNeighborMap().neighbors(); + } + + private NeighborMap getNeighborMap() { if (neighbors == null) { synchronized (this) { if (neighbors == null) - neighbors = Collections.unmodifiableMap(calculateAllNeighbors()); + neighbors = calculateAllNeighbors(); } } return neighbors; @@ -814,7 +820,7 @@ public int size() { * Return a map of PathObjects and their neighbors, sorted by distance. * @return */ - private synchronized Map> calculateAllNeighbors() { + private synchronized NeighborMap calculateAllNeighbors() { logger.debug("Calculating all neighbors for {} objects", size()); @@ -824,7 +830,7 @@ private synchronized Map> calculateAllNeighbors() { Map distanceMap = new HashMap<>(); // TODO: Don't make this a side effect! - edgeIndex = new HPRtree(); + var edgeIndex = new HPRtree(); int missing = 0; var reusableList = new ArrayList(); @@ -861,16 +867,14 @@ private synchronized Map> calculateAllNeighbors() { } if (missing > 0) logger.debug("Number of missing neighbors: {}", missing); - return map; + + return new NeighborMap(Map.copyOf(map), edgeIndex); } private SpatialIndex getEdgeIndex() { - if (edgeIndex == null) - getAllNeighbors(); // Ensure the index is created - return edgeIndex; + return getNeighborMap().index; } - private PathObject getPathObject(Vertex vertex) { return coordinateMap.get(vertex.getCoordinate()); } @@ -1014,12 +1018,6 @@ private synchronized Map calculateVoronoiFaces() { } map.put(pathObject, geometry); } - -// // Finally now reduce precision -// var reducer = new GeometryPrecisionReducer(GeometryTools.getDefaultFactory().getPrecisionModel()); -// for (var key : map.keySet().toArray(PathObject[]::new)) -// map.put(key, reducer.reduce(map.get(key))); - return map; } @@ -1067,7 +1065,7 @@ private Collection buildCluster(PathObject parent, Map pathObje for (PathObject pathObject : childrenToKeep) { addPathObjectImpl(pathObject, false); } - for (var cls : pathObjects.stream().map(PathObject::getClass).distinct().toList()) { - resetNeighborsForClass(cls); - } fireHierarchyChangedEvent(this); } @@ -479,7 +474,6 @@ private synchronized boolean addPathObjectToList(PathObject pathObjectParent, Pa // Notify listeners of changes, if required if (fireChangeEvents) fireObjectAddedEvent(this, pathObject); - resetNeighborsForClass(pathObject.getClass()); return true; } @@ -557,10 +551,6 @@ public synchronized boolean addObjects(Collection pathObje } if (changes) { fireHierarchyChangedEvent(getRootObject()); -// fireChangeEvent(getRootObject()); - for (var cls : pathObjects.stream().map(PathObject::getClass).distinct().toList()) { - resetNeighborsForClass(cls); - } } return changes; } @@ -572,7 +562,6 @@ public synchronized boolean addObjects(Collection pathObje public synchronized void clearAll() { getRootObject().clearChildObjects(); tmaGrid = null; - resetNeighbors(); fireHierarchyChangedEvent(getRootObject()); } @@ -676,7 +665,6 @@ public void updateObject(PathObject pathObject, boolean isChanging) { if (inHierarchy(pathObject)) removeObject(pathObject, true, false); addPathObjectImpl(pathObject, false); - resetNeighborsForClass(pathObject.getClass()); fireObjectsChangedEvent(this, Collections.singletonList(pathObject), isChanging); // fireHierarchyChangedEvent(this, pathObject); } @@ -739,7 +727,6 @@ public synchronized void setHierarchy(PathObjectHierarchy hierarchy) { return; rootObject = hierarchy.getRootObject(); tmaGrid = hierarchy.tmaGrid; - resetNeighbors(); fireHierarchyChangedEvent(rootObject); } @@ -1140,6 +1127,20 @@ public void fireHierarchyChangedEvent(Object source) { synchronized void fireEvent(PathObjectHierarchyEvent event) { synchronized(listeners) { + if (!event.isChanging()) { + if (event.isStructureChangeEvent()) { + var changed = event.getChangedObjects(); + var classes = changed.stream().map(PathObject::getClass).distinct().toList(); + if (classes.isEmpty() || classes.contains(PathRootObject.class)) + resetNeighbors(); + else { + for (var cls : classes) { + resetNeighborsForClass(cls); + } + } + } + } + for (PathObjectHierarchyListener listener : listeners) listener.hierarchyChanged(event); } From 5557ab10c2c233bdd3940f0fefaed59bca4ce052 Mon Sep 17 00:00:00 2001 From: Pete Date: Thu, 22 Aug 2024 17:59:11 +0100 Subject: [PATCH 225/866] Add connection toolbar button --- .../java/qupath/lib/gui/ToolBarComponent.java | 1 + .../lib/gui/actions/OverlayActions.java | 3 +- .../qupath/lib/gui/tools/IconFactory.java | 54 +++++++++++++++++-- 3 files changed, 53 insertions(+), 5 deletions(-) diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/ToolBarComponent.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/ToolBarComponent.java index 813fa23fb..33b900be6 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/ToolBarComponent.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/ToolBarComponent.java @@ -142,6 +142,7 @@ class ToolBarComponent { nodes.add(ActionTools.createToggleButtonWithGraphicOnly(overlayActions.SHOW_TMA_GRID)); nodes.add(ActionTools.createToggleButtonWithGraphicOnly(overlayActions.SHOW_DETECTIONS)); nodes.add(ActionTools.createToggleButtonWithGraphicOnly(overlayActions.FILL_DETECTIONS)); + nodes.add(ActionTools.createToggleButtonWithGraphicOnly(overlayActions.SHOW_CONNECTIONS)); nodes.add(ActionTools.createToggleButtonWithGraphicOnly(overlayActions.SHOW_PIXEL_CLASSIFICATION)); final Slider sliderOpacity = new Slider(0, 1, 1); diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/actions/OverlayActions.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/actions/OverlayActions.java index e31e8e0ab..39c9835b5 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/actions/OverlayActions.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/actions/OverlayActions.java @@ -104,7 +104,8 @@ public class OverlayActions { @ActionAccelerator("f") @ActionConfig("OverlayActions.fillDetections") public final Action FILL_DETECTIONS; - + + @ActionIcon(PathIcons.SHOW_CONNECTIONS) @ActionConfig("OverlayActions.showConnections") public final Action SHOW_CONNECTIONS; diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/tools/IconFactory.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/tools/IconFactory.java index 3e10a692b..370fb8dc4 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/tools/IconFactory.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/tools/IconFactory.java @@ -27,11 +27,13 @@ import java.awt.geom.Path2D; import java.awt.geom.PathIterator; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import java.util.Map; import java.util.WeakHashMap; import java.util.function.IntFunction; import java.util.function.Supplier; +import java.util.stream.Stream; import javafx.geometry.Insets; import javafx.geometry.Pos; @@ -70,6 +72,7 @@ import javafx.scene.shape.Rectangle; import javafx.scene.shape.Shape; import javafx.scene.text.Text; +import qupath.lib.geom.Point2; import qupath.lib.gui.prefs.PathPrefs; import qupath.lib.objects.PathObject; import qupath.lib.objects.PathObjectTools; @@ -253,6 +256,10 @@ static IntFunction pixelClassifierOverlayIcon() { return i -> new DuplicatableNode(() -> drawPixelClassificationIcon(i)); } + static IntFunction drawConnectionsIcon() { + return i -> new DuplicatableNode(() -> IconFactory.drawConnectionsIcon(i)); + } + static IntFunction showNamesIcon() { return i -> new DuplicatableNode(() -> drawShowNamesIcon(i)); } @@ -330,6 +337,7 @@ public enum PathIcons { ACTIVE_SERVER(IconSuppliers.icoMoon('\ue915', ColorTools SHOW_NAMES(IconSuppliers.showNamesIcon()), SHOW_SCALEBAR(IconSuppliers.icoMoon('\ue917')), + SHOW_CONNECTIONS(IconSuppliers.drawConnectionsIcon()), SCREENSHOT(IconSuppliers.icoMoon('\ue918')), TRACKING_REWIND(IconSuppliers.fontAwesome(FontAwesome.Glyph.BACKWARD)), @@ -513,8 +521,6 @@ private static Node drawBrushIcon(int size) { new QuadCurveTo(size-size/8.0, 0, size/2.0, 0), new ClosePath() ); -// var transform = Affine.rotate(30.0, size/2.0, size/2.0); -// path.getTransforms().add(transform); path.setRotate(30.0); bindShapeColorToObjectColor(path); return wrapInGroup(size, path); @@ -535,7 +541,6 @@ private static Node drawPolylineIcon(int size) { bindShapeColorToObjectColor(path); return wrapInGroup(size, addNodesToPath(path, Math.max(2.0, size/10.0))); -// return path; } @@ -563,7 +568,48 @@ private static Group addNodesToPath(Path path, double nodeSize) { } return group; } - + + private static Node drawConnectionsIcon(int size) { + double padX = 2; + double padY = 2; + + var ptl = new Point2(padX, padY); + var ptr = new Point2(size-padX, padY); + var pbl = new Point2(padX, size-padY); + var pbr = new Point2(size-padX, size-padY); + var pc = new Point2(size/2.0, size/2.0); + + Path path = new Path(); + path.getElements().setAll( + move(ptl), line(ptr), + line(pbr), line(pbl), + line(ptl), line(pc), + line(ptr), + move(pc), line(pbl), + move(pc), line(pbr) + ); + + path.setStyle("-fx-stroke: -fx-text-fill; -fx-opacity: 0.4;"); + var group = new Group(Stream.of(ptl, ptr, pbl, pbr, pc) + .map(p -> { + var circle = new Circle(p.getX(), p.getY(), 2.0, DETECTION_COLOR); + var fillColor = ColorToolsFX.getColorWithOpacity(DETECTION_COLOR, 0.75); + circle.setFill(fillColor); +// bindShapeColorToObjectColor(circle); + return circle; + }) + .toArray(Node[]::new)); + + return wrapInGroup(size, path, group); + } + + private static MoveTo move(Point2 p) { + return new MoveTo(p.getX(), p.getY()); + } + + private static LineTo line(Point2 p) { + return new LineTo(p.getX(), p.getY()); + } private static Node drawEllipseIcon(int size) { double padX = 2.0; From 7ae170333e5848449f5e81efd5186e409b73f24b Mon Sep 17 00:00:00 2001 From: Pete Date: Thu, 22 Aug 2024 18:42:27 +0100 Subject: [PATCH 226/866] Update ProcessingExtension.java --- .../src/main/java/qupath/process/gui/ProcessingExtension.java | 1 + 1 file changed, 1 insertion(+) diff --git a/qupath-extension-processing/src/main/java/qupath/process/gui/ProcessingExtension.java b/qupath-extension-processing/src/main/java/qupath/process/gui/ProcessingExtension.java index 04e202405..f9494ed53 100644 --- a/qupath-extension-processing/src/main/java/qupath/process/gui/ProcessingExtension.java +++ b/qupath-extension-processing/src/main/java/qupath/process/gui/ProcessingExtension.java @@ -86,6 +86,7 @@ public static class OpenCVCommands { @ActionMenu("Menu.Analyze.Spatial") @ActionConfig("Action.Processing.Spatial.delaunay") + @Deprecated public final Action actionDelaunay; @ActionMenu("Menu.Analyze.CellDetection") From 1cc84fa59131bca6b2acde86c6ab5e6a426ff3bb Mon Sep 17 00:00:00 2001 From: Pete Date: Thu, 22 Aug 2024 20:53:13 +0100 Subject: [PATCH 227/866] Make selection mode more obvious Switch to dashed lines for drawing tools when selection mode is turned on --- .../qupath/lib/gui/tools/IconFactory.java | 90 ++++++++++++++++++- .../qupath-gui-strings.properties | 2 +- 2 files changed, 89 insertions(+), 3 deletions(-) diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/tools/IconFactory.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/tools/IconFactory.java index 3e10a692b..a5ad0e01a 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/tools/IconFactory.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/tools/IconFactory.java @@ -33,6 +33,7 @@ import java.util.function.IntFunction; import java.util.function.Supplier; +import javafx.collections.ObservableList; import javafx.geometry.Insets; import javafx.geometry.Pos; import javafx.scene.Scene; @@ -41,7 +42,12 @@ import javafx.scene.image.Image; import javafx.scene.image.WritableImage; import javafx.scene.layout.Region; +import javafx.scene.layout.StackPane; +import javafx.scene.shape.Arc; +import javafx.scene.shape.CubicCurveTo; +import javafx.scene.shape.QuadCurve; import javafx.scene.text.TextAlignment; +import javafx.scene.text.TextBoundsType; import javafx.scene.transform.Transform; import org.controlsfx.glyphfont.FontAwesome; import org.controlsfx.glyphfont.Glyph; @@ -409,6 +415,8 @@ private static Node drawLineOrArrowIcon(int width, int height, String cap) { if (path.getClip() == null) path.setClip(new Rectangle(0, 0, width, height)); + bindStrokeToSelectionMode(path.getStrokeDashArray()); + return wrapInGroup(width, height, path); } @@ -418,10 +426,21 @@ private static Node drawRectangleIcon(int size) { var shape = new Rectangle(padX, padY, size-padX*2.0, size-padY*2.0); shape.setStrokeWidth(1.0); bindShapeColorToObjectColor(shape); + bindStrokeToSelectionMode(shape.getStrokeDashArray()); shape.setFill(Color.TRANSPARENT); return wrapInGroup(size, shape); } + private static void bindStrokeToSelectionMode(ObservableList dashList) { + PathPrefs.selectionModeProperty().addListener((obs, oldVal, newVal) -> { + if (newVal) { + dashList.setAll(3.0, 3.0); + } else { + dashList.clear(); + } + }); + } + /** * Wrap nodes in a group that also contains a fixed-size transparent square. * This can be used to help ensure consistent icon sizes. @@ -499,6 +518,8 @@ private static Node drawPolygonIcon(int size) { addNodesToPath(path, Math.max(3.0, size/10.0)); bindShapeColorToObjectColor(path); + bindStrokeToSelectionMode(path.getStrokeDashArray()); + return wrapInGroup(size, addNodesToPath(path, Math.max(2.0, size/10.0))); } @@ -516,6 +537,7 @@ private static Node drawBrushIcon(int size) { // var transform = Affine.rotate(30.0, size/2.0, size/2.0); // path.getTransforms().add(transform); path.setRotate(30.0); + bindStrokeToSelectionMode(path.getStrokeDashArray()); bindShapeColorToObjectColor(path); return wrapInGroup(size, path); } @@ -533,7 +555,9 @@ private static Node drawPolylineIcon(int size) { ); bindShapeColorToObjectColor(path); - + + bindStrokeToSelectionMode(path.getStrokeDashArray()); + return wrapInGroup(size, addNodesToPath(path, Math.max(2.0, size/10.0))); // return path; } @@ -576,6 +600,7 @@ private static Node drawEllipseIcon(int size) { shape.setStrokeWidth(1.0); bindShapeColorToObjectColor(shape); shape.setFill(Color.TRANSPARENT); + bindStrokeToSelectionMode(shape.getStrokeDashArray()); return wrapInGroup(size, shape); } @@ -638,7 +663,68 @@ private static Node drawSelectionModeIcon(int size) { // Because the default selection color yellow, it's not very prominent // bindColorPropertyToRGB(text.fillProperty(), PathPrefs.colorSelectedObjectProperty()); bindColorPropertyToRGB(text.fillProperty(), PathPrefs.colorDefaultObjectsProperty()); - return text; + + var circle = new Circle(size/2.0, size/2.0, size/2.0, Color.TRANSPARENT); + bindColorPropertyToRGB(circle.strokeProperty(), PathPrefs.colorDefaultObjectsProperty()); + circle.getStrokeDashArray().setAll(3.0, 3.0); + circle.setOpacity(0.5); + + var stack = new StackPane(circle, text); + text.setBoundsType(TextBoundsType.VISUAL); + + return wrapInGroup(size, stack); + } + + private static Node drawDashedS(int size) { + int pad = 2; + + var path = new Path(); + double unit = (size - pad) / 6.0; + path.getElements().setAll( + new MoveTo(-unit*1.5, unit*2), + new CubicCurveTo( + unit, unit*2.5, + unit*3, unit, + 0, 0), + new CubicCurveTo( + -unit*3, -unit, + -unit, -unit*2.5, + unit*1, -unit*2) + ); + path.setTranslateX(size/2.0); + path.setTranslateY(size/2.0); + + path.getStrokeDashArray().setAll(2.0, 2.0); + path.setFill(Color.TRANSPARENT); + + bindColorPropertyToRGB(path.strokeProperty(), PathPrefs.colorDefaultObjectsProperty()); + + return wrapInGroup(size, path); + } + + + private static Node drawLassoNode(int size) { + int pad = 2; + var ellipse = new Ellipse(size/2.0+pad, pad*2, size/2.0, size/4.0); + ellipse.getStrokeDashArray().setAll(3.0, 3.0); + ellipse.setFill(Color.TRANSPARENT); + + var circle = new Circle(ellipse.getCenterX(), ellipse.getCenterY()+ellipse.getRadiusY(), size/20.0); + + var arc = new QuadCurve( + circle.getCenterX(), circle.getCenterY(), + size-pad, size-pad, + pad, size-pad); + arc.setFill(Color.TRANSPARENT); + arc.setStrokeDashOffset(1.0); + arc.getStrokeDashArray().setAll(3.0, 3.0); + + bindColorPropertyToRGB(ellipse.strokeProperty(), PathPrefs.colorDefaultObjectsProperty()); + bindColorPropertyToRGB(circle.strokeProperty(), PathPrefs.colorDefaultObjectsProperty()); + bindColorPropertyToRGB(circle.fillProperty(), PathPrefs.colorDefaultObjectsProperty()); + bindColorPropertyToRGB(arc.strokeProperty(), PathPrefs.colorDefaultObjectsProperty()); + + return wrapInGroup(size, ellipse, circle, arc); } private static void bindShapeColorToObjectColor(Shape shape) { diff --git a/qupath-gui-fx/src/main/resources/qupath/lib/gui/localization/qupath-gui-strings.properties b/qupath-gui-fx/src/main/resources/qupath/lib/gui/localization/qupath-gui-strings.properties index 100f056ea..88c54c2a6 100644 --- a/qupath-gui-fx/src/main/resources/qupath/lib/gui/localization/qupath-gui-strings.properties +++ b/qupath-gui-fx/src/main/resources/qupath/lib/gui/localization/qupath-gui-strings.properties @@ -606,7 +606,7 @@ Action.ImageJ.cellDetection = Cell detection Action.ImageJ.cellDetection.description = Default cell detection in QuPath.\nNote that this is general-purpose method, not optimized for any particular staining.\n\nIt is essential to set the image type first (e.g. brightfield or fluorescence) before running this command. Action.ImageJ.positiveCellDetection = Positive cell detection Action.ImageJ.positiveCellDetection.description = Equivalent to 'Cell detection', with additional parameters to set a threshold during detection to identify single-positive cells. -Action.ImageJ.cellAndMembraneDetection = "Cell + membrane detection" +Action.ImageJ.cellAndMembraneDetection = Cell + membrane detection Action.ImageJ.cellAndMembraneDetection.description = Cell detection that uses membrane information to constrain cell boundary expansion.\n\nThis was designed specifically for hematoxylin and DAB staining, and works only where membrane staining is either very clear or absent. It is not recommended in general. Action.ImageJ.subcellularDetection = Subcellular detection (experimental) Action.ImageJ.subcellularDetection.description = Identify subcellular structures (e.g. spots of all kinds) within detected cells. From 57faead35dc6b77bc125d3fe36cbacc197ff0884 Mon Sep 17 00:00:00 2001 From: Pete Date: Thu, 22 Aug 2024 21:18:16 +0100 Subject: [PATCH 228/866] Try to fix metadata bug --- .../src/main/java/qupath/lib/objects/PathObject.java | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/qupath-core/src/main/java/qupath/lib/objects/PathObject.java b/qupath-core/src/main/java/qupath/lib/objects/PathObject.java index 47d4a8d60..b4d8dd9d7 100644 --- a/qupath-core/src/main/java/qupath/lib/objects/PathObject.java +++ b/qupath-core/src/main/java/qupath/lib/objects/PathObject.java @@ -1061,14 +1061,13 @@ protected void clearMetadataMap() { */ @Override public Map getMetadata() { - var map = metadata; - if (map == null) { + if (metadata == null) { synchronized (this) { if (metadata == null) - metadata = map = new MetadataMap(); + metadata = new MetadataMap(); } } - return map; + return metadata; } From 7c1020eba5ddf968024c4e98865a58df687a29ce Mon Sep 17 00:00:00 2001 From: Pete Date: Thu, 22 Aug 2024 21:18:34 +0100 Subject: [PATCH 229/866] Fix selection mode accelerator --- qupath-gui-fx/src/main/java/qupath/lib/gui/QuPathGUI.java | 1 + qupath-gui-fx/src/main/java/qupath/lib/gui/ToolManager.java | 4 +--- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/QuPathGUI.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/QuPathGUI.java index f9e47daca..2b314d8fb 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/QuPathGUI.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/QuPathGUI.java @@ -753,6 +753,7 @@ private void registerAcceleratorsForAllTools() { var action = toolManager.getToolAction(t); registerAccelerator(action); } + registerAccelerator(toolManager.getSelectionModeAction()); } diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/ToolManager.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/ToolManager.java index 71bb8d158..3ce90492b 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/ToolManager.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/ToolManager.java @@ -167,9 +167,7 @@ public boolean installTool(PathTool tool, KeyCodeCombination accelerator) { // Keep the points tool last if (accelerator != null) { var action = getToolAction(tool); - if (accelerator != null) { - action.setAccelerator(accelerator); - } + action.setAccelerator(accelerator); } int ind = tools.indexOf(PathTools.POINTS); if (ind < 0) From bf4134e66ee9e9db8e10357660cac0826986e19c Mon Sep 17 00:00:00 2001 From: Pete Date: Thu, 22 Aug 2024 21:18:49 +0100 Subject: [PATCH 230/866] Support line ROIs with selection mode Select intersecting ROIs --- .../AbstractPathROIToolEventHandler.java | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/tools/handlers/AbstractPathROIToolEventHandler.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/tools/handlers/AbstractPathROIToolEventHandler.java index 47441b60b..36ad5f9bc 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/tools/handlers/AbstractPathROIToolEventHandler.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/tools/handlers/AbstractPathROIToolEventHandler.java @@ -24,7 +24,11 @@ package qupath.lib.gui.viewer.tools.handlers; import java.awt.geom.Point2D; +import java.util.ArrayList; +import java.util.Collection; import java.util.Collections; +import java.util.HashSet; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -35,11 +39,13 @@ import qupath.lib.gui.viewer.tools.PathTools; import qupath.lib.objects.PathAnnotationObject; import qupath.lib.objects.PathObject; +import qupath.lib.objects.PathObjectTools; import qupath.lib.objects.PathObjects; import qupath.lib.objects.classes.PathClassTools; import qupath.lib.objects.classes.Reclassifier; import qupath.lib.objects.hierarchy.PathObjectHierarchy; import qupath.lib.regions.ImagePlane; +import qupath.lib.regions.ImageRegion; import qupath.lib.roi.PolygonROI; import qupath.lib.roi.PolylineROI; import qupath.lib.roi.RoiEditor; @@ -195,7 +201,24 @@ void commitObjectToHierarchy(MouseEvent e, PathObject pathObject) { // If we are in selection mode, try to get objects to select if (PathPrefs.selectionModeProperty().get()) { var pathClass = PathPrefs.autoSetAnnotationClassProperty().get(); - var toSelect = hierarchy.getAllObjectsForROI(currentROI); + Collection toSelect; + if (currentROI.isArea()) { + toSelect = hierarchy.getAllObjectsForROI(currentROI); + } else if (currentROI.isPoint()) { +// toSelect = hierarchy.getObjectsAtPoint(currentROI.getCentroidX(), currentROI.getCentroidY()); + toSelect = new HashSet<>(); + for (var p : currentROI.getAllPoints()) { + toSelect.addAll( + PathObjectTools.getObjectsForLocation(hierarchy, p.getX(), p.getY(), currentROI.getZ(), currentROI.getT(), 0.0) + ); + } + } else { + var geom = currentROI.getGeometry(); + toSelect = hierarchy.getAllDetectionsForRegion(ImageRegion.createInstance(currentROI)) + .parallelStream() + .filter(p -> geom.intersects(p.getROI().getGeometry())) + .toList(); + } if (!toSelect.isEmpty() && pathClass != null) { boolean retainIntensityClass = !(PathClassTools.isPositiveOrGradedIntensityClass(pathClass) || PathClassTools.isNegativeClass(pathClass)); var reclassified = toSelect.stream() From 68e2169c2edf2e9b067e7c5eba84c8a3fb8e31b0 Mon Sep 17 00:00:00 2001 From: Pete Date: Thu, 22 Aug 2024 21:21:58 +0100 Subject: [PATCH 231/866] Update CHANGELOG.md --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c30a871c..bc0fe5ccf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,9 @@ This is a *work in progress* for the next major release. * Experimental - not yet a full feature or available through the user interface! * Add `TransformedServerBuilder.convertType(PixelType)` to convert pixel types * Right-click on 'Measurement map' colorbar to copy it to the system clipboard (https://github.com/qupath/qupath/pull/1583) +* Improvements to 'Selection mode' + * New icon & other drawing icons change to indicate when they are active in selection mode + * Selection mode works with line ROIs, selecting any intersecting objects ### Bugs fixed * Tile export to .ome.tif can convert to 8-bit unnecessarily (https://github.com/qupath/qupath/issues/1494) From f58b642faed17f48e3ed37d3c254124ff1208f19 Mon Sep 17 00:00:00 2001 From: Pete Date: Fri, 23 Aug 2024 10:00:09 +0100 Subject: [PATCH 232/866] Support temporary selection mode Temporarily active 'Selection mode' by holding down the `S` key while interacting with a viewer --- CHANGELOG.md | 1 + .../qupath/lib/gui/commands/Commands.java | 2 +- .../java/qupath/lib/gui/prefs/PathPrefs.java | 21 +++++++++++++++++++ .../qupath/lib/gui/tools/IconFactory.java | 2 +- .../lib/gui/viewer/PathObjectPainter.java | 2 +- .../qupath/lib/gui/viewer/ViewerManager.java | 14 ++++++++++++- .../AbstractPathROIToolEventHandler.java | 6 +++--- .../tools/handlers/BrushToolEventHandler.java | 8 +++---- 8 files changed, 45 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bc0fe5ccf..a2997b61b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ This is a *work in progress* for the next major release. * Improvements to 'Selection mode' * New icon & other drawing icons change to indicate when they are active in selection mode * Selection mode works with line ROIs, selecting any intersecting objects + * Temporarily active 'Selection mode' by pressing the `S` key while interacting with a viewer ### Bugs fixed * Tile export to .ome.tif can convert to 8-bit unnecessarily (https://github.com/qupath/qupath/issues/1494) diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/Commands.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/Commands.java index 67b4ee4f8..a45e36128 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/Commands.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/Commands.java @@ -199,7 +199,7 @@ public static void promptToResolveHierarchy(ImageData imageData) { */ public static void createFullImageAnnotation(QuPathViewer viewer) { // If we are using selection mode, we should select objects rather that create an annotation - if (PathPrefs.selectionModeProperty().get()) { + if (PathPrefs.selectionModeStatus().get()) { logger.debug("Select all objects (create full image annotation with selection mode on)"); selectObjectsOnCurrentPlane(viewer); return; diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/prefs/PathPrefs.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/prefs/PathPrefs.java index 3dd9efc83..73b55de59 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/prefs/PathPrefs.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/prefs/PathPrefs.java @@ -43,7 +43,9 @@ import java.util.prefs.InvalidPreferencesFormatException; import java.util.prefs.Preferences; +import javafx.beans.binding.BooleanBinding; import javafx.beans.property.*; +import javafx.beans.value.ObservableBooleanValue; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -659,11 +661,30 @@ public static BooleanProperty useTileBrushProperty() { /** * Convert drawing tools to select objects, rather than creating new objects. * @return + * @see #tempSelectionModeProperty() */ public static BooleanProperty selectionModeProperty() { return selectionMode; } + private static BooleanProperty tempSelectionMode = MANAGER.createTransientBooleanProperty("tempSelectionMode", false); + + /** + * Temporarily request selection mode, without changing the value of #selectionModeProperty(). + * This can be used by a key-down shortcut to temporarily switch to selection mode, without changing the main toggle. + * @return + * @see #selectionModeProperty() + */ + public static BooleanProperty tempSelectionModeProperty() { + return tempSelectionMode; + } + + private static BooleanBinding selectionModeStatus = selectionModeProperty().or(tempSelectionModeProperty()); + + public static ObservableBooleanValue selectionModeStatus() { + return selectionModeStatus; + } + private static BooleanProperty clipROIsForHierarchy = createPersistentPreference("clipROIsForHierarchy", false); diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/tools/IconFactory.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/tools/IconFactory.java index a5ad0e01a..467acf979 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/tools/IconFactory.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/tools/IconFactory.java @@ -432,7 +432,7 @@ private static Node drawRectangleIcon(int size) { } private static void bindStrokeToSelectionMode(ObservableList dashList) { - PathPrefs.selectionModeProperty().addListener((obs, oldVal, newVal) -> { + PathPrefs.selectionModeStatus().addListener((obs, oldVal, newVal) -> { if (newVal) { dashList.setAll(3.0, 3.0); } else { diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/PathObjectPainter.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/PathObjectPainter.java index fd8d8f9c1..13e942588 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/PathObjectPainter.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/PathObjectPainter.java @@ -374,7 +374,7 @@ private static Stroke calculateStroke(PathObject pathObject, double downsample, } else { double thicknessScale = downsample * (isSelected && !PathPrefs.useSelectedColorProperty().get() ? 1.6 : 1); float thickness = (float)(PathPrefs.annotationStrokeThicknessProperty().get() * thicknessScale); - if (isSelected && pathObject.getParent() == null && PathPrefs.selectionModeProperty().get()) { + if (isSelected && pathObject.getParent() == null && PathPrefs.selectionModeStatus().get()) { return getCachedStrokeDashed(thickness); } else { return getCachedStroke(thickness); diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/ViewerManager.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/ViewerManager.java index 4d36088c9..817ecc2af 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/ViewerManager.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/ViewerManager.java @@ -846,8 +846,10 @@ private void setupViewer(final QuPathViewerPlus viewer) { viewer.getView().addEventHandler(KeyEvent.KEY_PRESSED, e -> { + if (e.isConsumed()) + return; PathObject pathObject = viewer.getSelectedObject(); - if (!e.isConsumed() && pathObject != null) { + if (pathObject != null) { if (pathObject.isTMACore()) { TMACoreObject core = (TMACoreObject)pathObject; if (e.getCode() == KeyCode.ENTER) { @@ -865,6 +867,16 @@ private void setupViewer(final QuPathViewerPlus viewer) { } } } + // For temporarily setting selection mode, we want to grab any S key presses eagerly + if (e.getCode() == KeyCode.S && e.getEventType() == KeyEvent.KEY_PRESSED) { + PathPrefs.tempSelectionModeProperty().set(true); + e.consume(); + } + }); + viewer.getView().addEventHandler(KeyEvent.KEY_RELEASED, e -> { + // For temporarily setting selection mode, we want to switch off the mode quickly for any key release events - + // to reduce the risk of accidentally leaving selection mode 'stuck' on if the S key release is missed + PathPrefs.tempSelectionModeProperty().set(false); }); } diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/tools/handlers/AbstractPathROIToolEventHandler.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/tools/handlers/AbstractPathROIToolEventHandler.java index 36ad5f9bc..3de47fc41 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/tools/handlers/AbstractPathROIToolEventHandler.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/tools/handlers/AbstractPathROIToolEventHandler.java @@ -100,7 +100,7 @@ protected PathObject createNewAnnotation(MouseEvent e, double x, double y) { PathObject pathObject = PathObjects.createAnnotationObject(roi, PathPrefs.autoSetAnnotationClassProperty().get()); var selectionModel = hierarchy.getSelectionModel(); - if (PathPrefs.selectionModeProperty().get() && !selectionModel.noSelection()) + if (PathPrefs.selectionModeStatus().get() && !selectionModel.noSelection()) viewer.setSelectedObject(pathObject, true); else viewer.setSelectedObject(pathObject); @@ -144,7 +144,7 @@ public void mousePressed(MouseEvent e) { return; // If we are double-clicking & we don't have a polygon, see if we can access a ROI - if (!PathPrefs.selectionModeProperty().get() && e.getClickCount() > 1) { + if (!PathPrefs.selectionModeStatus().get() && e.getClickCount() > 1) { // Reset parent... for now resetConstrainingObjects(); ToolUtils.tryToSelect(viewer, xx, yy, e.getClickCount()-2, false); @@ -199,7 +199,7 @@ void commitObjectToHierarchy(MouseEvent e, PathObject pathObject) { var currentROI = pathObject.getROI(); // If we are in selection mode, try to get objects to select - if (PathPrefs.selectionModeProperty().get()) { + if (PathPrefs.selectionModeStatus().get()) { var pathClass = PathPrefs.autoSetAnnotationClassProperty().get(); Collection toSelect; if (currentROI.isArea()) { diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/tools/handlers/BrushToolEventHandler.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/tools/handlers/BrushToolEventHandler.java index bc8f4626a..b0b81c528 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/tools/handlers/BrushToolEventHandler.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/tools/handlers/BrushToolEventHandler.java @@ -170,7 +170,7 @@ public void mousePressed(MouseEvent e) { // Ignore the current object if it belongs to a different image plane if (currentObject == null || - PathPrefs.selectionModeProperty().get() || + PathPrefs.selectionModeStatus().get() || !(currentObject instanceof PathAnnotationObject) || (!currentObject.isEditable()) || currentObject.getROI().getZ() != viewer.getZPosition() || @@ -204,7 +204,7 @@ public void mousePressed(MouseEvent e) { // See if, rather than creating something, we can instead reactivate a current object boolean multipleClicks = e.getClickCount() > 1; - if (!PathPrefs.selectionModeProperty().get() && (multipleClicks || (createNew && !e.isShiftDown()))) { + if (!PathPrefs.selectionModeStatus().get() && (multipleClicks || (createNew && !e.isShiftDown()))) { // See if, rather than creating something, we can instead reactivate a current object if (multipleClicks) { PathObject objectSelectable = ToolUtils.getSelectableObject(viewer, p.getX(), p.getY(), e.getClickCount() - 1); @@ -216,7 +216,7 @@ public void mousePressed(MouseEvent e) { viewer.setSelectedObject(null); currentObject = null; } - } else if (!PathPrefs.selectionModeProperty().get()) { + } else if (!PathPrefs.selectionModeStatus().get()) { List listSelectable = ToolUtils.getSelectableObjectList(viewer, p.getX(), p.getY()); PathObject objectSelectable = null; for (int i = 0; i < listSelectable.size(); i++) { @@ -308,7 +308,7 @@ public void mouseDragged(MouseEvent e) { PathObject pathObjectUpdated = getUpdatedObject(e, shapeROI, pathObject, -1); if (pathObject != pathObjectUpdated) { - viewer.setSelectedObject(pathObjectUpdated, PathPrefs.selectionModeProperty().get()); + viewer.setSelectedObject(pathObjectUpdated, PathPrefs.selectionModeStatus().get()); } else { viewer.repaint(); } From cf8b3f66e705253d90af4988e5700e52559a99d9 Mon Sep 17 00:00:00 2001 From: Pete Date: Fri, 23 Aug 2024 10:20:17 +0100 Subject: [PATCH 233/866] Temp selection mode with viewer not in focus --- qupath-gui-fx/src/main/java/qupath/lib/gui/QuPathGUI.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/QuPathGUI.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/QuPathGUI.java index 2b314d8fb..c08eb7975 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/QuPathGUI.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/QuPathGUI.java @@ -888,6 +888,10 @@ else if (e.getEventType() == KeyEvent.KEY_RELEASED) if (active != null) active.setSpaceDown(pressed.booleanValue()); } + } else if (e.getCode() == KeyCode.S && e.getEventType() == KeyEvent.KEY_PRESSED) { + PathPrefs.tempSelectionModeProperty().set(true); + } else if (e.getEventType() == KeyEvent.KEY_RELEASED) { + PathPrefs.tempSelectionModeProperty().set(false); } } From b87dc7ccd4d8acb646c9bba3bf0d73b402d3ecbe Mon Sep 17 00:00:00 2001 From: Pete Date: Fri, 23 Aug 2024 14:09:52 +0100 Subject: [PATCH 234/866] Fix bug with clustering by roiBounds Fixes the main issue reported at https://github.com/qupath/qupath/issues/1530 Note that pixel calibration is still not extensively used. --- .../qupath/lib/analysis/DelaunayTools.java | 63 ++++++++----------- .../java/qupath/lib/roi/GeometryTools.java | 30 ++++++++- 2 files changed, 55 insertions(+), 38 deletions(-) diff --git a/qupath-core/src/main/java/qupath/lib/analysis/DelaunayTools.java b/qupath-core/src/main/java/qupath/lib/analysis/DelaunayTools.java index f5a3d9e0b..0d94691f7 100644 --- a/qupath-core/src/main/java/qupath/lib/analysis/DelaunayTools.java +++ b/qupath-core/src/main/java/qupath/lib/analysis/DelaunayTools.java @@ -475,7 +475,7 @@ public static BiPredicate centroidDistancePredicate(doub /** * BiPredicate that returns true for objects with ROI boundaries within a specified distance. - * @param maxDistance maximum separation between ROI boundaries + * @param maxDistance maximum separation between ROI boundaries, in pixels * @param preferNucleus if true, prefer nucleus ROIs for cell objects * @return true for object pairs with close boundaries */ @@ -825,50 +825,39 @@ private synchronized NeighborMap calculateAllNeighbors() { logger.debug("Calculating all neighbors for {} objects", size()); @SuppressWarnings("unchecked") - var edges = (List)subdivision.getVertexUniqueEdges(false); - Map> map = new HashMap<>(); - Map distanceMap = new HashMap<>(); + var edges = (List)subdivision.getEdges() + .parallelStream() + .sorted(Comparator.comparingDouble(QuadEdge::getLength)) + .toList(); - // TODO: Don't make this a side effect! - var edgeIndex = new HPRtree(); + Map> neighbors = new HashMap<>(); - int missing = 0; - var reusableList = new ArrayList(); + var edgeIndex = new HPRtree(); for (QuadEdge edge : edges) { - Vertex origin = edge.orig(); - distanceMap.clear(); - - var pathObject = getPathObject(origin); - if (pathObject == null) { - logger.warn("No object found for {}", pathObject); + var pathOrigin = getPathObject(edge.orig()); + var pathDest = getPathObject(edge.dest()); + if (pathOrigin == null || pathDest == null || pathDest == pathOrigin || + neighbors.getOrDefault(pathOrigin, Collections.emptyList()).contains(pathDest)) { continue; } + neighbors.computeIfAbsent(pathOrigin, a -> new ArrayList<>()).add(pathDest); + neighbors.computeIfAbsent(pathDest, a -> new ArrayList<>()).add(pathOrigin); - reusableList.clear(); - QuadEdge next = edge; - do { - Vertex dest = next.dest(); - var destObject = getPathObject(dest); - if (destObject == pathObject) { - continue; - } else if (destObject == null) { - missing++; - } else { - distanceMap.put(destObject, next.getLength()); - reusableList.add(destObject); - // Store the edge in the spatial index - var env = new Envelope(next.orig().getCoordinate(), next.dest().getCoordinate()); - edgeIndex.insert(env, next); - } - } while ((next = next.oNext()) != edge); - - reusableList.sort(Comparator.comparingDouble(distanceMap::get)); - map.put(pathObject, List.copyOf(reusableList)); + var env = createEnvelope(pathOrigin.getROI(), pathDest.getROI()); + edgeIndex.insert(env, edge); } - if (missing > 0) - logger.debug("Number of missing neighbors: {}", missing); + for (var entry : neighbors.entrySet()) { + entry.setValue(List.copyOf(entry.getValue())); + } + return new NeighborMap(Map.copyOf(neighbors), edgeIndex); + } - return new NeighborMap(Map.copyOf(map), edgeIndex); + private static Envelope createEnvelope(ROI roi1, ROI roi2) { + double x1 = Math.min(roi1.getBoundsX(), roi2.getBoundsX()); + double x2 = Math.max(roi1.getBoundsX() + roi1.getBoundsWidth(), roi2.getBoundsX() + roi2.getBoundsWidth()); + double y1 = Math.min(roi1.getBoundsY(), roi2.getBoundsY()); + double y2 = Math.max(roi1.getBoundsY() + roi1.getBoundsHeight(), roi2.getBoundsY() + roi2.getBoundsHeight()); + return new Envelope(x1, x2, y1, y2); } private SpatialIndex getEdgeIndex() { diff --git a/qupath-core/src/main/java/qupath/lib/roi/GeometryTools.java b/qupath-core/src/main/java/qupath/lib/roi/GeometryTools.java index c6fdc8736..46d635711 100644 --- a/qupath-core/src/main/java/qupath/lib/roi/GeometryTools.java +++ b/qupath-core/src/main/java/qupath/lib/roi/GeometryTools.java @@ -188,7 +188,7 @@ public static Geometry shapeToGeometry(Shape shape) { /** * Convert an {@link Envelope} to an {@link ImageRegion}. - * @param env envelop + * @param env envelope * @param z z index for the region (default is 0) * @param t timepoint for the region (default is 0) * @return the smallest {@link ImageRegion} that contains the specified envelop @@ -200,6 +200,34 @@ public static ImageRegion envelopToRegion(Envelope env, int z, int t) { int height = (int)Math.ceil(env.getMaxY()) - y; return ImageRegion.createInstance(x, y, width, height, z, t); } + + /** + * Convert an {@link ImageRegion} to an {@link Envelope}. + * This will lose z and t information. + * @param region the region += * @return the smallest {@link Envelope} that contains the specified region + */ + public static Envelope regionToEnvelope(ImageRegion region) { + return new Envelope( + region.getMinX(), region.getMaxX(), region.getMinY(), region.getMaxY() + ); + } + + /** + * Convert the bounding box of a {@link ROI} to an {@link Envelope}. + * This will lose z and t information. + * @param roi the ROI + * @return the smallest {@link Envelope} that contains the specified ROI. + * Note that this does not involve any use of a precision model. + */ + public static Envelope roiToEnvelope(ROI roi) { + return new Envelope( + roi.getBoundsX(), + roi.getBoundsX() + roi.getBoundsWidth(), + roi.getBoundsY(), + roi.getBoundsY() + roi.getBoundsHeight() + ); + } /** From d8804c118d343f6c4d663b77a95c3b6fc235270a Mon Sep 17 00:00:00 2001 From: Pete Date: Fri, 23 Aug 2024 16:58:36 +0100 Subject: [PATCH 235/866] Add Delaunay test --- .../hierarchy/PathObjectHierarchy.java | 18 ++++++ .../hierarchy/TestPathObjectHierarchy.java | 56 +++++++++++++++++++ .../qupath/lib/gui/viewer/OverlayOptions.java | 2 +- 3 files changed, 75 insertions(+), 1 deletion(-) diff --git a/qupath-core/src/main/java/qupath/lib/objects/hierarchy/PathObjectHierarchy.java b/qupath-core/src/main/java/qupath/lib/objects/hierarchy/PathObjectHierarchy.java index 9570c4d5c..4aee372ff 100644 --- a/qupath-core/src/main/java/qupath/lib/objects/hierarchy/PathObjectHierarchy.java +++ b/qupath-core/src/main/java/qupath/lib/objects/hierarchy/PathObjectHierarchy.java @@ -1159,7 +1159,10 @@ private synchronized void resetNeighbors() { * This is based on centroids and Delaunay triangulation. * It also assumes 'square' pixels, and searches for neighbors only on the same 2D plane (z and t). * @param pathObject + *

    + * This is an experimental method added in v0.6.0, subject to change. * @return + * @since v0.6.0 */ public synchronized List findAllNeighbors(PathObject pathObject) { var subdivision = getSubdivision(pathObject); @@ -1170,8 +1173,11 @@ public synchronized List findAllNeighbors(PathObject pathObject) { * Find the nearest neighbor of a PathObject, having the same class as the object (e.g. detection, cell, annotation). * This is based on centroids and Delaunay triangulation. * It also assumes 'square' pixels, and searches for neighbors only on the same 2D plane (z and t). + *

    + * This is an experimental method added in v0.6.0, subject to change. * @param pathObject * @return + * @since v0.6.0 */ public synchronized PathObject findNearestNeighbor(PathObject pathObject) { var subdivision = getSubdivision(pathObject); @@ -1183,8 +1189,11 @@ public synchronized PathObject findNearestNeighbor(PathObject pathObject) { * This is based on centroids and Delaunay triangulation in 2D. * It supports #findAllNeighbors(PathObject) and #findNearestNeighbor(PathObject); obtaining the subdivision * enables a wider range of spatial queries. + *

    + * This is an experimental method added in v0.6.0, subject to change. * @param pathObject * @return + * @since v0.6.0 */ public synchronized DelaunayTools.Subdivision getSubdivision(PathObject pathObject) { return subdivisionManager.getSubdivision(pathObject); @@ -1193,8 +1202,11 @@ public synchronized DelaunayTools.Subdivision getSubdivision(PathObject pathObje /** * Get a subdivision containing detections. * This does not< include sub-classes such as 'cell' or 'tile'. + *

    + * This is an experimental method added in v0.6.0, subject to change. * @param plane * @return + * @since v0.6.0 */ public synchronized DelaunayTools.Subdivision getDetectionSubdivision(ImagePlane plane) { return subdivisionManager.getSubdivision(PathDetectionObject.class, plane); @@ -1202,8 +1214,11 @@ public synchronized DelaunayTools.Subdivision getDetectionSubdivision(ImagePlane /** * Get a subdivision containing cell objects. + *

    + * This is an experimental method added in v0.6.0, subject to change. * @param plane * @return + * @since v0.6.0 */ public synchronized DelaunayTools.Subdivision getCellSubdivision(ImagePlane plane) { return subdivisionManager.getSubdivision(PathCellObject.class, plane); @@ -1211,8 +1226,11 @@ public synchronized DelaunayTools.Subdivision getCellSubdivision(ImagePlane plan /** * Get a subdivision containing annotation objects. + *

    + * This is an experimental method added in v0.6.0, subject to change. * @param plane * @return + * @since v0.6.0 */ public synchronized DelaunayTools.Subdivision getAnnotationSubdivision(ImagePlane plane) { return subdivisionManager.getSubdivision(PathAnnotationObject.class, plane); diff --git a/qupath-core/src/test/java/qupath/lib/objects/hierarchy/TestPathObjectHierarchy.java b/qupath-core/src/test/java/qupath/lib/objects/hierarchy/TestPathObjectHierarchy.java index 65ea15937..fbc41690a 100644 --- a/qupath-core/src/test/java/qupath/lib/objects/hierarchy/TestPathObjectHierarchy.java +++ b/qupath-core/src/test/java/qupath/lib/objects/hierarchy/TestPathObjectHierarchy.java @@ -32,6 +32,7 @@ import java.util.Collection; import java.util.List; +import java.util.Set; import java.util.function.Function; import java.util.stream.Stream; @@ -223,4 +224,59 @@ public void testGetPoints() { assertTrue(hierarchy.getAllPointAnnotations().stream().allMatch(PathObject::isAnnotation)); } + @Test + public void test_neighbors() { + var hierarchy = new PathObjectHierarchy(); + var plane = ImagePlane.getDefaultPlane(); + assertTrue(hierarchy.getDetectionSubdivision(plane).isEmpty()); + + var topLeft = PathObjects.createDetectionObject(ROIs.createRectangleROI(0, 0, 1, 1, plane)); + var topRight = PathObjects.createDetectionObject(ROIs.createRectangleROI(10, 0, 1, 1, plane)); + var bottomLeft = PathObjects.createDetectionObject(ROIs.createRectangleROI(0, 10, 1, 1, plane)); + var bottomRight = PathObjects.createDetectionObject(ROIs.createRectangleROI(10, 10, 1, 1, plane)); + + // Add 4 corner objects + hierarchy.addObjects(List.of(topLeft, topRight, bottomLeft, bottomRight)); + + // For a square, it's not obvious *which* two triangles will be created by the subdivision - + // so we only require that two triangles *are* created + if (hierarchy.findAllNeighbors(topLeft).size() == 2) { + assertEquals(3, hierarchy.findAllNeighbors(topRight).size()); + assertEquals(3, hierarchy.findAllNeighbors(bottomLeft).size()); + assertEquals(2, hierarchy.findAllNeighbors(bottomRight).size()); + } else { + assertEquals(3, hierarchy.findAllNeighbors(topLeft).size()); + assertEquals(2, hierarchy.findAllNeighbors(topRight).size()); + assertEquals(2, hierarchy.findAllNeighbors(bottomLeft).size()); + assertEquals(3, hierarchy.findAllNeighbors(bottomRight).size()); + } + + // Add center object - so requests to the subdivision should give updated results + var center = PathObjects.createDetectionObject(ROIs.createRectangleROI(5, 5, 1, 1, plane)); + hierarchy.addObject(center); + assertEquals(5, hierarchy.getDetectionSubdivision(plane).size()); + + // Other planes should have empty subdivisions + assertTrue(hierarchy.getDetectionSubdivision(ImagePlane.getPlane(1, 0)).isEmpty()); + assertTrue(hierarchy.getDetectionSubdivision(ImagePlane.getPlane(0, 1)).isEmpty()); + + // Other object types should have empty subdivisions + assertTrue(hierarchy.getCellSubdivision(plane).isEmpty()); + + // Corners have center as nearest neighbor + assertEquals(center, hierarchy.findNearestNeighbor(topLeft)); + assertEquals(center, hierarchy.findNearestNeighbor(topRight)); + assertEquals(center, hierarchy.findNearestNeighbor(bottomLeft)); + assertEquals(center, hierarchy.findNearestNeighbor(bottomRight)); + + // Corners have 3 neighbors + assertEquals(Set.of(topRight, center, bottomLeft), Set.copyOf(hierarchy.findAllNeighbors(topLeft))); + assertEquals(Set.of(topRight, center, bottomLeft), Set.copyOf(hierarchy.findAllNeighbors(bottomRight))); + + // Center has all neighbors + assertEquals(Set.of(topLeft, topRight, bottomLeft, bottomRight), Set.copyOf(hierarchy.findAllNeighbors(center))); + + assertTrue(hierarchy.getCellSubdivision(plane).isEmpty()); + } + } diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/OverlayOptions.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/OverlayOptions.java index 57a22894e..c3c3fff9e 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/OverlayOptions.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/OverlayOptions.java @@ -75,7 +75,7 @@ public enum DetectionDisplayMode { private BooleanProperty showNames = new SimpleBooleanProperty(true); private BooleanProperty showTMAGrid = new SimpleBooleanProperty(true); private BooleanProperty showDetections = new SimpleBooleanProperty(true); - private BooleanProperty showConnections = new SimpleBooleanProperty(true); + private BooleanProperty showConnections = new SimpleBooleanProperty(false); private BooleanProperty fillDetections = new SimpleBooleanProperty(false); private BooleanProperty fillAnnotations = new SimpleBooleanProperty(false); private BooleanProperty showTMACoreLabels = new SimpleBooleanProperty(false); From dc0cb118606c2eed6e8074a632905bd13436eae7 Mon Sep 17 00:00:00 2001 From: Pete Date: Fri, 23 Aug 2024 17:00:04 +0100 Subject: [PATCH 236/866] Update CHANGELOG.md --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a2997b61b..0e77b7f82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,8 @@ This is a *work in progress* for the next major release. * New icon & other drawing icons change to indicate when they are active in selection mode * Selection mode works with line ROIs, selecting any intersecting objects * Temporarily active 'Selection mode' by pressing the `S` key while interacting with a viewer +* New toolbar button to show/hide 'neighbors' in the viewer (https://github.com/qupath/qupath/pull/1597) + * *Experimental* new code to help with querying neighbors ### Bugs fixed * Tile export to .ome.tif can convert to 8-bit unnecessarily (https://github.com/qupath/qupath/issues/1494) From e5fc8cf3f9dace05d7edb42cd62b1f51faaedcc7 Mon Sep 17 00:00:00 2001 From: Pete Date: Fri, 23 Aug 2024 17:20:27 +0100 Subject: [PATCH 237/866] Update changelog, deprecate Delaunay2D Continue to show Delaunay 2D connections by default if they are available, but report in the changelog that this may change. --- CHANGELOG.md | 3 ++ .../features/DelaunayClusteringPlugin.java | 4 +- .../features/DelaunayTriangulation.java | 3 +- .../DefaultPathObjectConnectionGroup.java | 3 +- .../objects/PathObjectConnectionGroup.java | 3 +- .../process/gui/ProcessingExtension.java | 5 +- .../servers/PathHierarchyImageServer.java | 1 + .../lib/gui/viewer/PathObjectPainter.java | 5 ++ .../gui/viewer/overlays/HierarchyOverlay.java | 49 ++++++++++--------- 9 files changed, 45 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e77b7f82..1cff0cf5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,9 @@ This is a *work in progress* for the next major release. * Temporarily active 'Selection mode' by pressing the `S` key while interacting with a viewer * New toolbar button to show/hide 'neighbors' in the viewer (https://github.com/qupath/qupath/pull/1597) * *Experimental* new code to help with querying neighbors + * Note that the *Delaunay cluster features 2D* command is now deprecated - see https://github.com/qupath/qupath/issues/1590 for details + * If you use this command, the calculated connections are displayed instead of the default neighbor connections for compatibility. + However, this support will be removed in a future version. ### Bugs fixed * Tile export to .ome.tif can convert to 8-bit unnecessarily (https://github.com/qupath/qupath/issues/1494) diff --git a/qupath-core-processing/src/main/java/qupath/opencv/features/DelaunayClusteringPlugin.java b/qupath-core-processing/src/main/java/qupath/opencv/features/DelaunayClusteringPlugin.java index 4ef2cfc10..0b5b5287e 100644 --- a/qupath-core-processing/src/main/java/qupath/opencv/features/DelaunayClusteringPlugin.java +++ b/qupath-core-processing/src/main/java/qupath/opencv/features/DelaunayClusteringPlugin.java @@ -56,8 +56,10 @@ * * @author Pete Bankhead * @param - * + * @deprecated v0.6.0 to be replaced by {@link qupath.lib.analysis.DelaunayTools}. + * See https://github.com/qupath/qupath/issues/1590 for discussion of the problems with this command. */ +@Deprecated public class DelaunayClusteringPlugin extends AbstractInteractivePlugin { private static final Logger logger = LoggerFactory.getLogger(DelaunayClusteringPlugin.class); diff --git a/qupath-core-processing/src/main/java/qupath/opencv/features/DelaunayTriangulation.java b/qupath-core-processing/src/main/java/qupath/opencv/features/DelaunayTriangulation.java index 40236e56e..a1cf2bf1c 100644 --- a/qupath-core-processing/src/main/java/qupath/opencv/features/DelaunayTriangulation.java +++ b/qupath-core-processing/src/main/java/qupath/opencv/features/DelaunayTriangulation.java @@ -56,8 +56,9 @@ * Compute Delaunay triangulation using OpenCV. * * @author Pete Bankhead - * + * @deprecated v0.6.0, to be replaced by {@link qupath.lib.analysis.DelaunayTools.Subdivision} */ +@Deprecated public class DelaunayTriangulation implements PathObjectConnectionGroup { private static final Logger logger = LoggerFactory.getLogger(DelaunayTriangulation.class); diff --git a/qupath-core/src/main/java/qupath/lib/objects/DefaultPathObjectConnectionGroup.java b/qupath-core/src/main/java/qupath/lib/objects/DefaultPathObjectConnectionGroup.java index c7f9bd2ef..33fdeb918 100644 --- a/qupath-core/src/main/java/qupath/lib/objects/DefaultPathObjectConnectionGroup.java +++ b/qupath-core/src/main/java/qupath/lib/objects/DefaultPathObjectConnectionGroup.java @@ -48,8 +48,9 @@ * Simple, default implementation of {@link PathObjectConnectionGroup}. * * @author Pete Bankhead - * + * @deprecated v0.6.0, to be replaced by {@link qupath.lib.analysis.DelaunayTools.Subdivision} */ +@Deprecated public class DefaultPathObjectConnectionGroup implements PathObjectConnectionGroup, Externalizable { private static final long serialVersionUID = 1L; diff --git a/qupath-core/src/main/java/qupath/lib/objects/PathObjectConnectionGroup.java b/qupath-core/src/main/java/qupath/lib/objects/PathObjectConnectionGroup.java index 5c07790d6..66395de6f 100644 --- a/qupath-core/src/main/java/qupath/lib/objects/PathObjectConnectionGroup.java +++ b/qupath-core/src/main/java/qupath/lib/objects/PathObjectConnectionGroup.java @@ -36,8 +36,9 @@ * Such connections can be represented on an overlay by drawing lines between object centroids. * * @author Pete Bankhead - * + * @deprecated v0.6.0, to be replaced by {@link qupath.lib.analysis.DelaunayTools.Subdivision} */ +@Deprecated public interface PathObjectConnectionGroup { /** diff --git a/qupath-extension-processing/src/main/java/qupath/process/gui/ProcessingExtension.java b/qupath-extension-processing/src/main/java/qupath/process/gui/ProcessingExtension.java index f9494ed53..e90cca549 100644 --- a/qupath-extension-processing/src/main/java/qupath/process/gui/ProcessingExtension.java +++ b/qupath-extension-processing/src/main/java/qupath/process/gui/ProcessingExtension.java @@ -84,7 +84,7 @@ public class ProcessingExtension implements QuPathExtension { @ActionMenu("Menu.Analyze") public static class OpenCVCommands { - @ActionMenu("Menu.Analyze.Spatial") + @ActionMenu("Deprecated") @ActionConfig("Action.Processing.Spatial.delaunay") @Deprecated public final Action actionDelaunay; @@ -105,14 +105,13 @@ public static class OpenCVCommands { private OpenCVCommands(QuPathGUI qupath) { - actionDelaunay = qupath.createPluginAction("Delaunay cluster features 2D", DelaunayClusteringPlugin.class, null); + actionDelaunay = qupath.createPluginAction("Delaunay cluster features 2D (deprecated)", DelaunayClusteringPlugin.class, null); actionFastCellCounts = qupath.createPluginAction("Fast cell counts (brightfield)", CellCountsCV.class, null); var densityMapCommand = new DensityMapCommand(qupath); actionDensityMap = qupath.createImageDataAction(imageData -> densityMapCommand.run()); var commandLoad = LoadResourceCommand.createLoadDensityMapCommand(qupath); actionDensityMapLoad = qupath.createImageDataAction(imageData -> commandLoad.run()); - } } diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/images/servers/PathHierarchyImageServer.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/images/servers/PathHierarchyImageServer.java index 26075255e..86b73cf0e 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/images/servers/PathHierarchyImageServer.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/images/servers/PathHierarchyImageServer.java @@ -252,6 +252,7 @@ protected BufferedImage readTile(TileRequest tileRequest) throws IOException { // See if we have any connections to draw if (connections != null) { + // If we have connections from the legacy 'Delaunay cluster features 2D' command, show these PathObjectPainter.paintConnections( connections, hierarchy, diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/PathObjectPainter.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/PathObjectPainter.java index 8b1f2cbe8..ff69b46a4 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/PathObjectPainter.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/PathObjectPainter.java @@ -58,6 +58,7 @@ import qupath.lib.analysis.DelaunayTools; import qupath.lib.awt.common.AwtTools; import qupath.lib.color.ColorToolsAwt; +import qupath.lib.common.LogTools; import qupath.lib.geom.Point2; import qupath.lib.gui.prefs.PathPrefs; import qupath.lib.gui.tools.ColorToolsFX; @@ -1087,11 +1088,15 @@ public static void paintHandles(final List handles, final Graphics2D g2d * @param color * @param downsampleFactor * @param plane + * @deprecated v0.6.0 as #paintConnections(DelaunayTools.Subdivision, PathObjectHierarchy, Graphics2D, Color, double, ImagePlane) is preferred */ + @Deprecated public static void paintConnections(final PathObjectConnections connections, final PathObjectHierarchy hierarchy, Graphics2D g2d, final Color color, final double downsampleFactor, final ImagePlane plane) { if (hierarchy == null || connections == null || connections.isEmpty()) return; + LogTools.warnOnce(logger, "Legacy 'Delaunay cluster features 2D' connections are being shown in the viewer - this command is deprecated, and support will be removed in a future version"); + float alpha = (float)(1f - downsampleFactor / 5); alpha = Math.min(alpha, 0.4f); double thickness = PathPrefs.detectionStrokeThicknessProperty().get(); diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/overlays/HierarchyOverlay.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/overlays/HierarchyOverlay.java index 06a2ca64d..340a4b2e0 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/overlays/HierarchyOverlay.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/overlays/HierarchyOverlay.java @@ -246,30 +246,31 @@ public void paintOverlay(final Graphics2D g2d, final ImageRegion imageRegion, fi PathObjectPainter.paintSpecifiedObjects(g2d, detectionsToPaint, overlayOptions, hierarchy.getSelectionModel(), downsampleFactor); if (overlayOptions.getShowConnections()) { - - // If we have cells, show them - // Otherwise, show any detections we have - var subdiv = hierarchy.getCellSubdivision(imageRegion.getImagePlane()); - if (subdiv.isEmpty()) - subdiv = hierarchy.getDetectionSubdivision(imageRegion.getImagePlane()); - - PathObjectPainter.paintConnections( - subdiv, - hierarchy, - g2d, - imageData.isFluorescence() ? ColorToolsAwt.TRANSLUCENT_WHITE : ColorToolsAwt.TRANSLUCENT_BLACK, - downsampleFactor, - imageRegion.getImagePlane() - ); - -// Object connections = imageData.getProperty(DefaultPathObjectConnectionGroup.KEY_OBJECT_CONNECTIONS); -// if (connections instanceof PathObjectConnections) -// PathObjectPainter.paintConnections((PathObjectConnections) connections, -// hierarchy, -// g2d, -// imageData.isFluorescence() ? ColorToolsAwt.TRANSLUCENT_WHITE : ColorToolsAwt.TRANSLUCENT_BLACK, -// downsampleFactor, -// imageRegion.getImagePlane()); + // If we have connections from the legacy 'Delaunay cluster features 2D', show those + Object connections = imageData.getProperty(DefaultPathObjectConnectionGroup.KEY_OBJECT_CONNECTIONS); + if (connections instanceof PathObjectConnections conn) + PathObjectPainter.paintConnections(conn, + hierarchy, + g2d, + imageData.isFluorescence() ? ColorToolsAwt.TRANSLUCENT_WHITE : ColorToolsAwt.TRANSLUCENT_BLACK, + downsampleFactor, + imageRegion.getImagePlane()); + else { + // If we have cells, show them + // Otherwise, show any detections we have + var subdiv = hierarchy.getCellSubdivision(imageRegion.getImagePlane()); + if (subdiv.isEmpty()) + subdiv = hierarchy.getDetectionSubdivision(imageRegion.getImagePlane()); + + PathObjectPainter.paintConnections( + subdiv, + hierarchy, + g2d, + imageData.isFluorescence() ? ColorToolsAwt.TRANSLUCENT_WHITE : ColorToolsAwt.TRANSLUCENT_BLACK, + downsampleFactor, + imageRegion.getImagePlane() + ); + } } } else { // If the image hasn't been updated, then we are viewing the stationary image - we want to wait for a full repaint then to avoid flickering; From 04807606fc3a8ce00350063500846c9a9bd73f86 Mon Sep 17 00:00:00 2001 From: Pete Date: Fri, 23 Aug 2024 17:51:26 +0100 Subject: [PATCH 238/866] Make viewer options persistent Addresses https://forum.image.sc/t/removing-the-cursor-location-ui-element-via-script/100008 --- CHANGELOG.md | 1 + .../qupath/lib/gui/viewer/OverlayOptions.java | 67 +++++++++++++------ .../qupath/lib/gui/viewer/ViewerManager.java | 6 +- .../gui/viewer/ViewerPlusDisplayOptions.java | 31 +++++++-- 4 files changed, 75 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1cff0cf5d..d1c386fc0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,7 @@ This is a *work in progress* for the next major release. * Note that the *Delaunay cluster features 2D* command is now deprecated - see https://github.com/qupath/qupath/issues/1590 for details * If you use this command, the calculated connections are displayed instead of the default neighbor connections for compatibility. However, this support will be removed in a future version. +* More viewer options are persistent (e.g. show/hide the overview thumbnail, location text, or scalebar) ### Bugs fixed * Tile export to .ome.tif can convert to 8-bit unnecessarily (https://github.com/qupath/qupath/issues/1494) diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/OverlayOptions.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/OverlayOptions.java index c3c3fff9e..46f223d21 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/OverlayOptions.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/OverlayOptions.java @@ -4,7 +4,7 @@ * %% * Copyright (C) 2014 - 2016 The Queen's University of Belfast, Northern Ireland * Contact: IP Management (ipmanagement@qub.ac.uk) - * Copyright (C) 2018 - 2020 QuPath developers, The University of Edinburgh + * Copyright (C) 2018 - 2024 QuPath developers, The University of Edinburgh * %% * QuPath is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as @@ -40,6 +40,8 @@ import qupath.lib.objects.classes.PathClass; import qupath.lib.objects.classes.PathClassTools; +import java.util.Arrays; + /** * Default class for storing overlay display options. * @@ -71,37 +73,58 @@ public enum DetectionDisplayMode { }; private ObjectProperty measurementMapper = new SimpleObjectProperty<>(); - private BooleanProperty showAnnotations = new SimpleBooleanProperty(true); - private BooleanProperty showNames = new SimpleBooleanProperty(true); - private BooleanProperty showTMAGrid = new SimpleBooleanProperty(true); - private BooleanProperty showDetections = new SimpleBooleanProperty(true); - private BooleanProperty showConnections = new SimpleBooleanProperty(false); - private BooleanProperty fillDetections = new SimpleBooleanProperty(false); - private BooleanProperty fillAnnotations = new SimpleBooleanProperty(false); - private BooleanProperty showTMACoreLabels = new SimpleBooleanProperty(false); - private BooleanProperty showGrid = new SimpleBooleanProperty(false); - private ObjectProperty gridLines = new SimpleObjectProperty<>(new GridLines()); + private BooleanProperty showAnnotations = new SimpleBooleanProperty(null, "showAnnotations", true); + private BooleanProperty showNames = new SimpleBooleanProperty(null, "showAnnotationNames", true); + private BooleanProperty showTMAGrid = new SimpleBooleanProperty(null, "showTMAGrid", true); + private BooleanProperty showDetections = new SimpleBooleanProperty(null, "showDetections", true); + private BooleanProperty showConnections = new SimpleBooleanProperty(null, "showConnections", false); + private BooleanProperty fillDetections = new SimpleBooleanProperty(null, "fillDetections", false); + private BooleanProperty fillAnnotations = new SimpleBooleanProperty(null, "fillAnnotations", false); + private BooleanProperty showTMACoreLabels = new SimpleBooleanProperty(null, "showTMACoreLabels", false); + private BooleanProperty showGrid = new SimpleBooleanProperty(null, "showGrid", false); + private ObjectProperty gridLines = new SimpleObjectProperty<>(null, "showGridLines", new GridLines()); - private BooleanProperty showPixelClassification = new SimpleBooleanProperty(true); - private ObjectProperty pixelClassificationFilter = new SimpleObjectProperty<>(RegionFilter.StandardRegionFilters.EVERYWHERE); + private BooleanProperty showPixelClassification = new SimpleBooleanProperty(null, "showPixelClassification", true); + private ObjectProperty pixelClassificationFilter = new SimpleObjectProperty<>(null, "pixelClassificationFilter", RegionFilter.StandardRegionFilters.EVERYWHERE); private FloatProperty fontSize = new SimpleFloatProperty(); private ObservableSet hiddenClasses = FXCollections.observableSet(); - private ObjectProperty cellDisplayMode = new SimpleObjectProperty<>(DetectionDisplayMode.NUCLEI_AND_BOUNDARIES); + private ObjectProperty cellDisplayMode = new SimpleObjectProperty<>(null, "cellDisplayMode", DetectionDisplayMode.NUCLEI_AND_BOUNDARIES); private FloatProperty opacity = new SimpleFloatProperty(1.0f); private LongProperty timestamp = new SimpleLongProperty(System.currentTimeMillis()); - -// public void addPropertyChangeListener(PropertyChangeListener listener) { -// this.pcs.addPropertyChangeListener(listener); -// } -// -// public void removePropertyChangeListener(PropertyChangeListener listener) { -// this.pcs.removePropertyChangeListener(listener); -// } + + private static final OverlayOptions SHARED_INSTANCE = createSharedInstance(); + + /** + * Create a shared instance that makes some properties persistent. + * Not all can/should be persistent, e.g. because a confused user who has hidden annotations will be relieved + * to find them back when they restart QuPath. + * @return + */ + private static OverlayOptions createSharedInstance() { + var options = new OverlayOptions(); + for (var prop : Arrays.asList(options.showNames, options.showConnections, options.fillDetections, + options.fillAnnotations, options.showTMACoreLabels, + options.showGrid, options.showAnnotations, options.showDetections, + options.showPixelClassification, options.showTMAGrid)) { + prop.bindBidirectional(PathPrefs.createPersistentPreference("overlayOptions_" + prop.getName(), prop.get())); + } + options.cellDisplayMode.bindBidirectional(PathPrefs.createPersistentPreference("overlayOptions_cellDisplayMode", options.cellDisplayMode.get(), DetectionDisplayMode.class)); + options.fontSize.bindBidirectional(PathPrefs.createPersistentPreference("overlayOptions_fontSize", options.fontSize.get())); + return options; + } + + /** + * Get a shared OverlayOptions instance that makes some properties persistent. + * @return + */ + public static OverlayOptions getSharedInstance() { + return SHARED_INSTANCE; + } /** * Constructor, using default values. diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/ViewerManager.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/ViewerManager.java index 817ecc2af..33bf4bea3 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/ViewerManager.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/ViewerManager.java @@ -4,7 +4,7 @@ * %% * Copyright (C) 2014 - 2016 The Queen's University of Belfast, Northern Ireland * Contact: IP Management (ipmanagement@qub.ac.uk) - * Copyright (C) 2018 - 2023 QuPath developers, The University of Edinburgh + * Copyright (C) 2018 - 2024 QuPath developers, The University of Edinburgh * %% * QuPath is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as @@ -132,8 +132,8 @@ public class ViewerManager implements QuPathViewerListener { private SplitPaneGrid splitPaneGrid; - private ViewerPlusDisplayOptions viewerDisplayOptions = new ViewerPlusDisplayOptions(); - private OverlayOptions overlayOptions = new OverlayOptions(); + private ViewerPlusDisplayOptions viewerDisplayOptions = ViewerPlusDisplayOptions.getSharedInstance(); + private OverlayOptions overlayOptions = OverlayOptions.getSharedInstance(); /** * Since v0.5.0, this uses a Reference so that we can potentially allow garbage collection is memory is scare diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/ViewerPlusDisplayOptions.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/ViewerPlusDisplayOptions.java index db916cddb..ea3aa964d 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/ViewerPlusDisplayOptions.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/ViewerPlusDisplayOptions.java @@ -4,7 +4,7 @@ * %% * Copyright (C) 2014 - 2016 The Queen's University of Belfast, Northern Ireland * Contact: IP Management (ipmanagement@qub.ac.uk) - * Copyright (C) 2018 - 2020 QuPath developers, The University of Edinburgh + * Copyright (C) 2018 - 2024 QuPath developers, The University of Edinburgh * %% * QuPath is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as @@ -25,6 +25,9 @@ import javafx.beans.property.BooleanProperty; import javafx.beans.property.SimpleBooleanProperty; +import qupath.lib.gui.prefs.PathPrefs; + +import java.util.List; /** * A group of properties determining what should be displayed for each viewer. @@ -34,10 +37,28 @@ */ public class ViewerPlusDisplayOptions { - private BooleanProperty showOverview = new SimpleBooleanProperty(true); - private BooleanProperty showLocation = new SimpleBooleanProperty(true); - private BooleanProperty showScalebar = new SimpleBooleanProperty(true); - + private BooleanProperty showOverview = new SimpleBooleanProperty(null, "showOverview", true); + private BooleanProperty showLocation = new SimpleBooleanProperty(null, "showLocation", true); + private BooleanProperty showScalebar = new SimpleBooleanProperty(null, "showScalebar", true); + + private static ViewerPlusDisplayOptions SHARED_INSTANCE = createSharedInstance(); + + private static ViewerPlusDisplayOptions createSharedInstance() { + ViewerPlusDisplayOptions options = new ViewerPlusDisplayOptions(); + for (var prop : List.of(options.showOverview, options.showLocation, options.showScalebar)) { + prop.bindBidirectional(PathPrefs.createPersistentPreference("viewerOptions_" + prop.getName(), prop.get())); + } + return options; + } + + /** + * Get a shared instance with persistence properties. + * @return + */ + public static ViewerPlusDisplayOptions getSharedInstance() { + return SHARED_INSTANCE; + } + /** * Show the overview image. * @return From 857777cfd3c9545be9e7ece2e322d4bd76227db2 Mon Sep 17 00:00:00 2001 From: Pete Date: Fri, 23 Aug 2024 18:19:55 +0100 Subject: [PATCH 239/866] Warn if similar classification & default colors Show a warning with the Context Help if a classification is added that has a similar color to the default object color --- CHANGELOG.md | 1 + .../lib/gui/commands/ContextHelpViewer.java | 42 ++++++++++++++++++- .../qupath/lib/gui/panes/PathClassPane.java | 8 +++- .../qupath-gui-strings.properties | 2 + 4 files changed, 50 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d1c386fc0..3c29d8707 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ This is a *work in progress* for the next major release. * Experimental - not yet a full feature or available through the user interface! * Add `TransformedServerBuilder.convertType(PixelType)` to convert pixel types * Right-click on 'Measurement map' colorbar to copy it to the system clipboard (https://github.com/qupath/qupath/pull/1583) +* Context help can warn if a classification color is similar to the default object color * Improvements to 'Selection mode' * New icon & other drawing icons change to indicate when they are active in selection mode * Selection mode works with line ROIs, selecting any intersecting objects diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/ContextHelpViewer.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/ContextHelpViewer.java index 4de5b87ad..b3997a3ab 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/ContextHelpViewer.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/ContextHelpViewer.java @@ -57,16 +57,19 @@ import javafx.stage.Window; import qupath.fx.utils.FXUtils; import qupath.fx.utils.GridPaneUtils; +import qupath.lib.common.ColorTools; import qupath.lib.display.ImageDisplay; import qupath.lib.gui.QuPathGUI; import qupath.lib.gui.actions.InfoMessage; import qupath.lib.gui.localization.QuPathResources; import qupath.lib.gui.prefs.PathPrefs; +import qupath.lib.gui.tools.ColorToolsFX; import qupath.lib.gui.tools.IconFactory; import qupath.lib.gui.tools.IconFactory.PathIcons; import qupath.lib.gui.viewer.QuPathViewer; import qupath.lib.images.ImageData; import qupath.lib.images.servers.PixelCalibration; +import qupath.lib.objects.classes.PathClass; import java.beans.PropertyChangeEvent; import java.beans.PropertyChangeListener; @@ -141,6 +144,8 @@ else if (hasInfo.get()) private BooleanProperty largeNonPyramidalImage = new SimpleBooleanProperty(false); + private BooleanProperty classificationAndDefaultObjectColorsSimilar = new SimpleBooleanProperty(false); + private ContextHelpViewer(QuPathGUI qupath) { this.qupath = qupath; this.imageDataProperty.addListener(this::imageDataChanged); @@ -190,6 +195,31 @@ private void imageDataPropertyChange(PropertyChangeEvent evt) { } } + private void updateSimilarClassificationColors() { + var pathClasses = qupath.getAvailablePathClasses(); + var defaultColor = PathPrefs.colorDefaultObjectsProperty(); + for (var pathClass : pathClasses) { + if (similarColors(pathClass.getColor(), defaultColor.get())) { + classificationAndDefaultObjectColorsSimilar.set(true); + return; + } + } + classificationAndDefaultObjectColorsSimilar.set(false); + } + + private static boolean similarColors(Integer rgba1, Integer rgba2) { + if (rgba1 == null || rgba2 == null) + return false; + double tol = 10.0; + if (Math.abs(ColorTools.red(rgba1) - ColorTools.red(rgba2)) > tol) + return false; + if (Math.abs(ColorTools.green(rgba1) - ColorTools.green(rgba2)) > tol) + return false; + if (Math.abs(ColorTools.blue(rgba1) - ColorTools.blue(rgba2)) > tol) + return false; + return true; + } + private void updateLargeNonPyramidalProperty() { var imageData = imageDataProperty.get(); if (imageData == null) @@ -249,7 +279,8 @@ private List createHelpEntries() { createOpacityZeroEntry(), createGammaNotDefault(), createInvertedColors(), - createNoChannelsVisible() + createNoChannelsVisible(), + createColorsTooSimilar() ); } @@ -612,6 +643,15 @@ private HelpListEntry createNoChannelsVisible() { return entry; } + private HelpListEntry createColorsTooSimilar() { + var entry = HelpListEntry.createWarning( + "ContextHelp.warning.colorsSimilar"); + qupath.getAvailablePathClasses().addListener((Change c) -> updateSimilarClassificationColors()); + PathPrefs.colorDefaultObjectsProperty().addListener((v, o, n) -> updateSimilarClassificationColors()); + entry.visibleProperty().bind(classificationAndDefaultObjectColorsSimilar); + return entry; + } + private HelpListEntry createPixelSizeMissing() { var entry = HelpListEntry.createWarning( "ContextHelp.warning.pixelSizeMissing"); diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/panes/PathClassPane.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/panes/PathClassPane.java index d8684e9f7..d920132c6 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/panes/PathClassPane.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/panes/PathClassPane.java @@ -138,8 +138,12 @@ private Pane createClassPane() { // Intercept space presses because we handle them elsewhere listClasses.addEventFilter(KeyEvent.KEY_PRESSED, this::filterKeyPresses); listClasses.setOnMouseClicked(e -> { - if (!e.isPopupTrigger() && e.getClickCount() == 2) - promptToEditSelectedClass(); + if (!e.isPopupTrigger() && e.getClickCount() == 2) { + if (promptToEditSelectedClass()) { + // This fires a change event to notify any listeners + availablePathClasses.setAll(availablePathClasses.stream().toList()); + } + } }); ContextMenu menuClasses = createClassesMenu(); diff --git a/qupath-gui-fx/src/main/resources/qupath/lib/gui/localization/qupath-gui-strings.properties b/qupath-gui-fx/src/main/resources/qupath/lib/gui/localization/qupath-gui-strings.properties index 88c54c2a6..a984960db 100644 --- a/qupath-gui-fx/src/main/resources/qupath/lib/gui/localization/qupath-gui-strings.properties +++ b/qupath-gui-fx/src/main/resources/qupath/lib/gui/localization/qupath-gui-strings.properties @@ -18,6 +18,8 @@ ContextHelp.warning.invertedBackground = Image is being shown with inverted back Change this option in the brightness/contrast dialog. ContextHelp.warning.noChannels = No channels are selected!\n\ Use the 'Brightness/Contrast' command to select channels. +ContextHelp.warning.colorsSimilar = At least one classification color is similar to the default object color.\n\ + It will be hard to see whether objects are classified. ContextHelp.warning.unseenErrors = There are errors reported in the log that have not yet been seen.\n\ Please check the log for details. ContextHelp.warning.pixelSizeMissing = Pixel size information is not set.\n\ From e7f54955de11f3fbc15d0657b1081e64bd9d2907 Mon Sep 17 00:00:00 2001 From: Pete Date: Sun, 25 Aug 2024 08:54:48 +0100 Subject: [PATCH 240/866] Update to Java 21 We already default to using Java 21, but we were still building artifacts with Java 17. This meant we couldn't actually use any features from after Java 17. Things we can now use (roughly in order of relevance): - Sequenced collections - https://openjdk.org/jeps/431 - Code snippets in Javadocs - https://openjdk.org/jeps/431 - Pattern matching for switch - https://openjdk.org/jeps/441 - Virtual threads - https://openjdk.org/jeps/444 - Record patterns - https://openjdk.org/jeps/440 Note that this may require updates in any extensions that declare v0.6.0 dependencies, to ensure they also use Java 21 (and have a suitably recent Gradle wrapper). --- .github/workflows/gradle.yml | 6 +++--- .github/workflows/maven-release.yml | 4 ++-- .github/workflows/maven-snapshot.yml | 4 ++-- CHANGELOG.md | 2 ++ buildSrc/build.gradle | 1 - qupath-app/src/main/java/qupath/QuPath.java | 4 ++-- 6 files changed, 11 insertions(+), 10 deletions(-) diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index 8a0732199..f5992082f 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -14,14 +14,14 @@ runs-on: ${{ matrix.platform }} steps: - uses: actions/checkout@v3 - - name: Set up JDK 17 + - name: Set up JDK 21 uses: actions/setup-java@v3 with: - java-version: '17' + java-version: '21' distribution: 'temurin' - name: Validate Gradle wrapper uses: gradle/wrapper-validation-action@v1 - name: Build with Gradle uses: gradle/gradle-build-action@v2 with: - arguments: build -P toolchain=17 + arguments: build -P toolchain=21 diff --git a/.github/workflows/maven-release.yml b/.github/workflows/maven-release.yml index 564ec0706..ff056038b 100644 --- a/.github/workflows/maven-release.yml +++ b/.github/workflows/maven-release.yml @@ -13,14 +13,14 @@ jobs: - uses: actions/checkout@v3 - uses: actions/setup-java@v3 with: - java-version: '17' + java-version: '21' distribution: 'temurin' - name: Validate Gradle wrapper uses: gradle/wrapper-validation-action@e6e38bacfdf1a337459f332974bb2327a31aaf4b - name: Publish package uses: gradle/gradle-build-action@67421db6bd0bf253fb4bd25b31ebb98943c375e1 with: - arguments: publish -P toolchain=17 -P release=true + arguments: publish -P toolchain=21 -P release=true env: MAVEN_USER: ${{ secrets.MAVEN_USER }} MAVEN_PASS: ${{ secrets.MAVEN_PASS }} diff --git a/.github/workflows/maven-snapshot.yml b/.github/workflows/maven-snapshot.yml index f41aceb7d..f221b14bf 100644 --- a/.github/workflows/maven-snapshot.yml +++ b/.github/workflows/maven-snapshot.yml @@ -13,14 +13,14 @@ jobs: - uses: actions/checkout@v3 - uses: actions/setup-java@v3 with: - java-version: '17' + java-version: '21' distribution: 'temurin' - name: Validate Gradle wrapper uses: gradle/wrapper-validation-action@e6e38bacfdf1a337459f332974bb2327a31aaf4b - name: Publish snapshot uses: gradle/gradle-build-action@67421db6bd0bf253fb4bd25b31ebb98943c375e1 with: - arguments: publish -P toolchain=17 + arguments: publish -P toolchain=21 env: MAVEN_USER: ${{ secrets.MAVEN_USER }} MAVEN_PASS: ${{ secrets.MAVEN_PASS }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c29d8707..9038d4b47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ This is a *work in progress* for the next major release. +*QuPath v0.6.0 requires Java 21 or later* + ### Enhancements * Read and write OME-Zarr images (https://github.com/qupath/qupath/pull/1474) * Improved display of annotation names (https://github.com/qupath/qupath/pull/1532) diff --git a/buildSrc/build.gradle b/buildSrc/build.gradle index 7211a7eb0..c89f6c728 100644 --- a/buildSrc/build.gradle +++ b/buildSrc/build.gradle @@ -9,7 +9,6 @@ *

    * gradlew.bat clean jpackage *

    - * NOTE: This build requires OpenJDK 17+ (since it contains jpackage). * Gradle's toolchain options are used to overcome this: if you run gradlew with a different JDK, * gradle will use a different JDK for building QuPath itself (downloading it if necessary). */ diff --git a/qupath-app/src/main/java/qupath/QuPath.java b/qupath-app/src/main/java/qupath/QuPath.java index 675d82c46..b4162c0d6 100644 --- a/qupath-app/src/main/java/qupath/QuPath.java +++ b/qupath-app/src/main/java/qupath/QuPath.java @@ -80,8 +80,8 @@ @Command(name = "QuPath", subcommands = {HelpCommand.class, ScriptCommand.class, GenerateCompletion.class}, footer = {"", "Copyright(c) The Queen's University Belfast (2014-2016)", - "Copyright(c) QuPath developers (2017-2022)", - "Copyright(c) The University of Edinburgh (2018-2022)" + "Copyright(c) QuPath developers (2017-2024)", + "Copyright(c) The University of Edinburgh (2018-2024)" }, mixinStandardHelpOptions = true, versionProvider = QuPath.VersionProvider.class) public class QuPath { From a450a9c630e1032425d438262dfbe03924d338c3 Mon Sep 17 00:00:00 2001 From: Pete Date: Mon, 26 Aug 2024 18:03:21 +0100 Subject: [PATCH 241/866] Javadoc fixes --- qupath-core/src/main/java/qupath/lib/io/PathIO.java | 3 +-- qupath-core/src/main/java/qupath/lib/objects/PathObject.java | 4 ++-- .../qupath/lib/objects/hierarchy/PathObjectHierarchy.java | 2 +- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/qupath-core/src/main/java/qupath/lib/io/PathIO.java b/qupath-core/src/main/java/qupath/lib/io/PathIO.java index c4ec67cac..17760ba89 100644 --- a/qupath-core/src/main/java/qupath/lib/io/PathIO.java +++ b/qupath-core/src/main/java/qupath/lib/io/PathIO.java @@ -580,8 +580,7 @@ public static ImageData readImageData(final File file, ImageServer ser /** * Read and initialize an {@link ImageData} from a data file. - * @param the generic parameter, usually BufferedImage - * + * * @param path path to data file * @param server an ImageServer to use rather than any that might be stored within the serialized data. * Should be null to use the serialized path to build a new server. diff --git a/qupath-core/src/main/java/qupath/lib/objects/PathObject.java b/qupath-core/src/main/java/qupath/lib/objects/PathObject.java index b4d8dd9d7..f923efbcf 100644 --- a/qupath-core/src/main/java/qupath/lib/objects/PathObject.java +++ b/qupath-core/src/main/java/qupath/lib/objects/PathObject.java @@ -650,7 +650,7 @@ public PathObject[] getChildObjectsAsArray() { * The {@code PathClass} object is used as the internal representation of the object's classification, * encapsulating both the different string components of the classification and the color used for display. *

    - * For convenience, {@link #getClassification()} and {@link }{@link #getClassifications()} provide a simpler way to interact with + * For convenience, {@link #getClassification()} and {@link #getClassifications()} provide a simpler way to interact with * classifications as one or more strings. * @return * @see #setPathClass(PathClass) @@ -667,7 +667,7 @@ public PathObject[] getChildObjectsAsArray() { *

    * If the classification is null, the object is considered to be unclassified. *

    - * For convenience, {@link #setClassification(String)} ()} and {@link }{@link #setClassifications(Collection)} ()} + * For convenience, {@link #setClassification(String)} and {@link #setClassifications(Collection)} * provide alternative ways to set classifications using strings - but this does not allow for setting the color, * and internally a {@code PathClass} object will still be used. * @param pathClass diff --git a/qupath-core/src/main/java/qupath/lib/objects/hierarchy/PathObjectHierarchy.java b/qupath-core/src/main/java/qupath/lib/objects/hierarchy/PathObjectHierarchy.java index 4aee372ff..96b02bd3c 100644 --- a/qupath-core/src/main/java/qupath/lib/objects/hierarchy/PathObjectHierarchy.java +++ b/qupath-core/src/main/java/qupath/lib/objects/hierarchy/PathObjectHierarchy.java @@ -1201,7 +1201,7 @@ public synchronized DelaunayTools.Subdivision getSubdivision(PathObject pathObje /** * Get a subdivision containing detections. - * This does not< include sub-classes such as 'cell' or 'tile'. + * This does not include sub-classes such as 'cell' or 'tile'. *

    * This is an experimental method added in v0.6.0, subject to change. * @param plane From b579fb9ca77dc40d9017e6876b0568f7b2bc966b Mon Sep 17 00:00:00 2001 From: Pete Date: Mon, 26 Aug 2024 18:23:04 +0100 Subject: [PATCH 242/866] Reinstate mergedJavadocs --- build.gradle | 30 +++++++++++++++++++ .../groovy/qupath.java-conventions.gradle | 7 +++++ 2 files changed, 37 insertions(+) diff --git a/build.gradle b/build.gradle index 95f3159d7..d76ca2c9b 100644 --- a/build.gradle +++ b/build.gradle @@ -7,6 +7,36 @@ plugins { // We don't want to generate javadocs for the root project javadoc.enabled = false +// See https://discuss.gradle.org/t/best-approach-gradle-multi-module-project-generate-just-one-global-javadoc/18657 +tasks.register('mergedJavadocs', Javadoc) { + dependsOn subprojects.tasks.collect { it.withType(Javadoc) } + description 'Generate merged javadocs for all projects' + group 'Documentation' + + destinationDir = layout.buildDirectory.dir("docs-merged/javadoc").get().getAsFile() + title = "QuPath $gradle.ext.qupathVersion" + + // See https://docs.gradle.org/current/javadoc/org/gradle/external/javadoc/StandardJavadocDocletOptions.html + options.author(true) + options.addStringOption('Xdoclint:none', '-quiet') + + options.encoding = 'UTF-8' + + options.links "https://docs.oracle.com/en/java/javase/${libs.versions.jdk.get()}/docs/api/" + // Need to use the major version only with javafx + options.links "https://openjfx.io/javadoc/${libs.versions.javafx.get().split('\\.')[0]}/" + options.links "https://javadoc.io/doc/org.bytedeco/javacpp/${libs.versions.javacpp.get()}/" + options.links "https://javadoc.io/doc/org.bytedeco/opencv/${libs.versions.opencv.get()}/" + options.links "https://javadoc.io/doc/com.google.code.gson/gson/${libs.versions.gson.get()}/" + options.links "https://javadoc.io/doc/org.locationtech.jts/jts-core/${libs.versions.jts.get()}/" + options.links "https://javadoc.io/doc/net.imagej/ij/${libs.versions.imagej.get()}/" + options.links "https://javadoc.scijava.org/Bio-Formats/" + options.links "https://javadoc.io/doc/ai.djl/api/${libs.versions.deepJavaLibrary.get()}/" + + // Don't fail on error, because this happened too often due to a javadoc link being temporarily down + failOnError = false +} + /* * Get version catalog */ diff --git a/buildSrc/src/main/groovy/qupath.java-conventions.gradle b/buildSrc/src/main/groovy/qupath.java-conventions.gradle index d1794cecc..fd32a07b5 100644 --- a/buildSrc/src/main/groovy/qupath.java-conventions.gradle +++ b/buildSrc/src/main/groovy/qupath.java-conventions.gradle @@ -106,6 +106,13 @@ tasks.withType(Javadoc).each { javadocTask -> javadocTask.options.links "https://javadoc.io/doc/net.imagej/ij/${libs.versions.imagej.get()}/" javadocTask.options.links "https://javadoc.scijava.org/Bio-Formats/" javadocTask.options.links "https://javadoc.io/doc/ai.djl/api/${libs.versions.deepJavaLibrary.get()}/" + + rootProject.tasks.withType(Javadoc) { rootTask -> + rootTask.source += javadocTask.source + rootTask.classpath += javadocTask.classpath + rootTask.excludes += javadocTask.excludes + rootTask.includes += javadocTask.includes + } } /* From 5d381314dac68ff1b3db2665d668e9258eabc18c Mon Sep 17 00:00:00 2001 From: Alan O'Callaghan Date: Tue, 27 Aug 2024 10:24:20 +0100 Subject: [PATCH 243/866] Use "Classification", not "PathClass" --- .../qupath/lib/gui/commands/PathObjectGridView.java | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/PathObjectGridView.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/PathObjectGridView.java index df3fd87f9..e289beddf 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/PathObjectGridView.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/PathObjectGridView.java @@ -253,7 +253,7 @@ public ObjectProperty> imageDataProperty() { private static void sortPathObjects(final ObservableList cores, final ObservableMeasurementTableData model, final String measurementName, final boolean doDescending) { if (measurementName == null) return; - if (measurementName.equals("PathClass")) { + if (measurementName.equals("Classification")) { cores.sort((po1, po2) -> { if (po1.getPathClass() == null || po2.getPathClass() == null) return 0; Comparator comp = Comparator.comparing((po) -> po.getPathClass().toString()); @@ -362,10 +362,10 @@ private void initializeGUI() { ObservableList measureList = FXCollections.observableArrayList(measureNames); measureNames.addListener((ListChangeListener) c -> { measureList.clear(); - measureList.add("PathClass"); + measureList.add("Classification"); measureList.addAll(measureNames); }); - measureList.add("PathClass"); + measureList.add("Classification"); comboMeasurement.setItems(measureList); if (!comboMeasurement.getItems().isEmpty()) comboMeasurement.getSelectionModel().select(0); @@ -464,7 +464,7 @@ private void updateMeasurement() { private void sortAndFilter() { String m = measurement.getValue(); sortPathObjects(backingList, model, m, descending.get()); - filteredList.setPredicate(p -> (m == null || m.equals("PathClass") || + filteredList.setPredicate(p -> (m == null || m.equals("Classification") || !(isMissingCore(p) || Double.isNaN(model.getNumericValue(p, m)))) && selectedClasses.contains(p.getPathClass()) ); @@ -596,7 +596,7 @@ void updateMeasurementText() { if (m == null || !showMeasurement.get()) entry.getValue().setText(" "); else { - if (m.equals("PathClass")) { + if (m.equals("Classification")) { entry.getValue().setText(entry.getKey().getPathClass().toString()); } else { double val = model.getNumericValue(entry.getKey(), m); From 784f0731ad46395e1592aca2230cf9420ba1b99e Mon Sep 17 00:00:00 2001 From: Pete Date: Tue, 27 Aug 2024 11:45:37 +0100 Subject: [PATCH 244/866] Fix out-of-bounds cell detection Fixes https://github.com/qupath/qupath/issues/1606 Note that this makes the change at a higher level than cell detection, so potential impacts (fixes?) other commands. Note also that it can potentially change how tiling operates, by adjusting the parent ROI temporarily. In other words, large regions may be tiled with a different origin - and so have boundaries in a different place. --- CHANGELOG.md | 1 + .../detect/cells/WatershedCellDetection.java | 3 ++- .../AbstractTileableDetectionPlugin.java | 24 ++++++++++++++++--- 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9038d4b47..484427447 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,7 @@ This is a *work in progress* for the next major release. * Search distance when selecting points in the viewer is too low (https://github.com/qupath/qupath/issues/1552) * `ImageOps.Core.replace()` does not work as expected (https://github.com/qupath/qupath/issues/1564) * QuPath doesn't always use the specified file extension when exporting snapshots (https://github.com/qupath/qupath/issues/1567) +* Out-of-bounds tiles can result in detected cells being in the wrong place (https://github.com/qupath/qupath/issues/1606) ### API changes * New `Map getMetadata()` method added to `PathObject`, `Project` and `ProjectImageEntry` (https://github.com/qupath/qupath/pull/1587) diff --git a/qupath-core-processing/src/main/java/qupath/imagej/detect/cells/WatershedCellDetection.java b/qupath-core-processing/src/main/java/qupath/imagej/detect/cells/WatershedCellDetection.java index e81a9c2df..9d8e18fb3 100644 --- a/qupath-core-processing/src/main/java/qupath/imagej/detect/cells/WatershedCellDetection.java +++ b/qupath-core-processing/src/main/java/qupath/imagej/detect/cells/WatershedCellDetection.java @@ -213,7 +213,8 @@ public Collection runDetection(final ImageData imageD ImageServer server = imageData.getServer(); lastServerPath = imageData.getServerPath(); double downsample = ServerTools.getDownsampleFactor(server, getPreferredPixelSizeMicrons(imageData, params)); - pathImage = IJTools.convertToImagePlus(server, RegionRequest.createInstance(server.getPath(), downsample, pathROI)); + var request = RegionRequest.createInstance(server.getPath(), downsample, pathROI); + pathImage = IJTools.convertToImagePlus(server, request); // pathImage = IJTools.createPathImage(server, pathROI, ServerTools.getDownsampleFactor(server, getPreferredPixelSizeMicrons(imageData, params), false)); logger.trace("Cell detection with downsample: {}", pathImage.getDownsampleFactor()); this.pathROI = pathROI; diff --git a/qupath-core/src/main/java/qupath/lib/plugins/AbstractTileableDetectionPlugin.java b/qupath-core/src/main/java/qupath/lib/plugins/AbstractTileableDetectionPlugin.java index e28e4b6a5..a8c6bdb1f 100644 --- a/qupath-core/src/main/java/qupath/lib/plugins/AbstractTileableDetectionPlugin.java +++ b/qupath-core/src/main/java/qupath/lib/plugins/AbstractTileableDetectionPlugin.java @@ -28,12 +28,15 @@ import java.util.List; import java.util.concurrent.atomic.AtomicInteger; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import qupath.lib.geom.ImmutableDimension; import qupath.lib.images.ImageData; import qupath.lib.images.servers.ServerTools; import qupath.lib.objects.PathObject; import qupath.lib.plugins.parameters.ParameterList; import qupath.lib.regions.ImagePlane; +import qupath.lib.regions.ImageRegion; import qupath.lib.roi.ROIs; import qupath.lib.roi.RoiTools; import qupath.lib.roi.interfaces.ROI; @@ -50,7 +53,9 @@ * @param */ public abstract class AbstractTileableDetectionPlugin extends AbstractDetectionPlugin { - + + private static final Logger logger = LoggerFactory.getLogger(AbstractTileableDetectionPlugin.class); + private static int PREFERRED_TILE_SIZE = 2048; private static int MAX_TILE_SIZE = 3072; @@ -110,7 +115,8 @@ protected void addRunnableTasks(ImageData imageData, PathObject parentObject, // Determine appropriate sizes // Note, for v0.1.2 and earlier the downsample was restricted to be a power of 2 - double downsampleFactor = ServerTools.getDownsampleFactor(imageData.getServer(), getPreferredPixelSizeMicrons(imageData, params)); + var server = imageData.getServer(); + double downsampleFactor = ServerTools.getDownsampleFactor(server, getPreferredPixelSizeMicrons(imageData, params)); int preferred = (int)(PREFERRED_TILE_SIZE * downsampleFactor); int max = (int)(MAX_TILE_SIZE * downsampleFactor); ImmutableDimension sizePreferred = ImmutableDimension.getInstance(preferred, preferred); @@ -121,7 +127,19 @@ protected void addRunnableTasks(ImageData imageData, PathObject parentObject, // Extract (or create) suitable ROI ROI parentROI = parentObject.getROI(); if (parentROI == null) - parentROI = ROIs.createRectangleROI(0, 0, imageData.getServer().getWidth(), imageData.getServer().getHeight(), ImagePlane.getDefaultPlane()); + parentROI = ROIs.createRectangleROI(0, 0, server.getWidth(), server.getHeight(), ImagePlane.getDefaultPlane()); + else if (parentROI.getBoundsX() < 0 || + parentROI.getBoundsY() < 0 || + parentROI.getBoundsX() + parentROI.getBoundsWidth() >= server.getWidth() || + parentROI.getBoundsY() + parentROI.getBoundsHeight() >= server.getHeight()) { + // Ensure the request is within the image bounds - + // see https://forum.image.sc/t/qupath-wrongly-placed-detections-with-out-of-bounds-annotations/100914 + logger.debug("Parent ROI is out of bounds; adjusting to image bounds"); + parentROI = RoiTools.combineROIs( + parentROI, + ROIs.createRectangleROI(0, 0, server.getWidth(), server.getHeight(), parentROI.getImagePlane()), + RoiTools.CombineOp.INTERSECT); + } // Make tiles Collection pathROIs = RoiTools.computeTiledROIs(parentROI, sizePreferred, sizeMax, false, getTileOverlap(imageData, params)); From f7c41c26c73374e39c65e25bf6121af5897632ef Mon Sep 17 00:00:00 2001 From: Pete Date: Tue, 27 Aug 2024 13:14:31 +0100 Subject: [PATCH 245/866] Fix minor bugs, warnings & update gradlew - Don't warn TensorFlow not available on Apple Silicon (it is with DJL v0.29.0) - Fix Delaunay display for empty tiles - Fix creating an empty Delaunay subdivision - Add DuplicatesStrategy so that builds don't fail when using `importFlat` during development --- .../main/groovy/qupath.djl-conventions.gradle | 8 -------- gradle/wrapper/gradle-wrapper.jar | Bin 43504 -> 43583 bytes qupath-app/build.gradle | 3 +++ .../qupath/lib/analysis/DelaunayTools.java | 7 ++++++- .../servers/PathHierarchyImageServer.java | 19 ++++++++++++++++-- .../gui/viewer/overlays/HierarchyOverlay.java | 6 ------ 6 files changed, 26 insertions(+), 17 deletions(-) diff --git a/buildSrc/src/main/groovy/qupath.djl-conventions.gradle b/buildSrc/src/main/groovy/qupath.djl-conventions.gradle index af43f0a98..97b26af93 100644 --- a/buildSrc/src/main/groovy/qupath.djl-conventions.gradle +++ b/buildSrc/src/main/groovy/qupath.djl-conventions.gradle @@ -15,14 +15,6 @@ else if (djlEnginesProp == "none") djlEngines = [] else djlEngines = djlEnginesProp.split(",").collect(e -> e.toLowerCase().strip()).findAll(e -> !e.isBlank()) - -// Check for Apple Silicon - TensorFlow currently doesn't work there -if ('tensorflow' in djlEngines && properties['platform.shortName'] == 'mac' && System.properties['os.arch'] == 'aarch64') { - // In v0.4.x we removed TensorFlow, but it's kept here because it is technically possible - // to get it working by building TensorFlow Java with Apple Silicon & putting the resulting - // dylibs into ~/.djl.ai/tensorflow/2.10.1-cpu-osx-aarch64 - println 'TensorFlow is not supported on Apple Silicon - engine will not work unless built separately' -} def djlApi = !djlEngines.isEmpty() || project.findProperty('djl.api') def djlZero = djlApi && project.findProperty('djl.zero') diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 2c3521197d7c4586c843d1d3e9090525f1898cde..a4b76b9530d66f5e68d973ea569d8e19de379189 100644 GIT binary patch delta 3990 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 diff --git a/qupath-app/build.gradle b/qupath-app/build.gradle index ad855a587..4acbe444d 100644 --- a/qupath-app/build.gradle +++ b/qupath-app/build.gradle @@ -188,6 +188,9 @@ tasks.startScripts.dependsOn("generateLicenseReport") distributions { main { contents { + // We need a DuplicatesStrategy if settings.gradle uses includeFlat for extra extensions + // (which require QuPath as a dependency) + duplicatesStrategy DuplicatesStrategy.EXCLUDE into('lib') { from project.rootDir include 'CHANGELOG.md' diff --git a/qupath-core/src/main/java/qupath/lib/analysis/DelaunayTools.java b/qupath-core/src/main/java/qupath/lib/analysis/DelaunayTools.java index 0d94691f7..cae5e492f 100644 --- a/qupath-core/src/main/java/qupath/lib/analysis/DelaunayTools.java +++ b/qupath-core/src/main/java/qupath/lib/analysis/DelaunayTools.java @@ -613,8 +613,13 @@ private record NeighborMap(Map> neighbors, SpatialI private Subdivision(QuadEdgeSubdivision subdivision, Collection pathObjects, Map coordinateMap, ImagePlane plane) { this.subdivision = subdivision; - this.plane = plane; this.pathObjects = pathObjects.stream().distinct().toList(); + this.plane = plane == null ? pathObjects.stream() + .filter(PathObject::hasROI) + .map(PathObject::getROI) + .map(ROI::getImagePlane) + .findFirst() + .orElse(ImagePlane.getDefaultPlane()) : plane; this.coordinateMap = Map.copyOf(coordinateMap); } diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/images/servers/PathHierarchyImageServer.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/images/servers/PathHierarchyImageServer.java index 86b73cf0e..2452c2b1b 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/images/servers/PathHierarchyImageServer.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/images/servers/PathHierarchyImageServer.java @@ -159,9 +159,24 @@ private Collection getObjectsToPaint(RegionRequest request) { */ @Override public boolean isEmptyRegion(RegionRequest request) { - return !hierarchy.hasObjectsForRegion(PathDetectionObject.class, request) && (!options.getShowConnections() || imageData.getProperty(DefaultPathObjectConnectionGroup.KEY_OBJECT_CONNECTIONS) == null); + // We have detections in the region + if (hierarchy.hasObjectsForRegion(PathDetectionObject.class, request)) + return false; + // We don't have detections, and we definitely don't have connections to paint + if (!options.getShowConnections()) + return true; + // We have legacy connections to paint - these may overlap the region + if (imageData.getProperty(DefaultPathObjectConnectionGroup.KEY_OBJECT_CONNECTIONS) != null) + return false; + // Check if we have a subdivision that overlaps the region + if (hierarchy.getCellSubdivision(request.getImagePlane()).getObjectsForRegion(request).isEmpty() && + hierarchy.getDetectionSubdivision(request.getImagePlane()).getObjectsForRegion(request).isEmpty()) + return true; + else + return false; } - + + @Override public void close() {} diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/overlays/HierarchyOverlay.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/overlays/HierarchyOverlay.java index 340a4b2e0..fd5517d21 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/overlays/HierarchyOverlay.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/overlays/HierarchyOverlay.java @@ -33,19 +33,15 @@ import java.awt.geom.Line2D; import java.awt.geom.Rectangle2D; import java.awt.image.BufferedImage; -import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.Comparator; import java.util.LinkedHashSet; -import java.util.List; import java.util.Map; import java.util.Set; import java.util.TreeSet; import java.util.WeakHashMap; import java.util.concurrent.ConcurrentHashMap; -import java.util.stream.Collectors; -import java.util.stream.Stream; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -64,8 +60,6 @@ import qupath.lib.images.ImageData; import qupath.lib.objects.DefaultPathObjectComparator; import qupath.lib.objects.DefaultPathObjectConnectionGroup; -import qupath.lib.objects.PathAnnotationObject; -import qupath.lib.objects.PathDetectionObject; import qupath.lib.objects.PathObject; import qupath.lib.objects.PathObjectConnections; import qupath.lib.objects.PathObjectTools; From e28bab6c478ba36e294dad216201dd337878b577 Mon Sep 17 00:00:00 2001 From: Alan O'Callaghan Date: Tue, 27 Aug 2024 16:56:09 +0100 Subject: [PATCH 246/866] Externalise strings --- .../lib/gui/commands/PathObjectGridView.java | 52 +++++++++---------- .../qupath-gui-strings.properties | 15 ++++++ 2 files changed, 41 insertions(+), 26 deletions(-) diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/PathObjectGridView.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/PathObjectGridView.java index e289beddf..4fd7fd28e 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/PathObjectGridView.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/PathObjectGridView.java @@ -68,6 +68,7 @@ import qupath.fx.utils.FXUtils; import qupath.lib.common.GeneralTools; import qupath.lib.gui.QuPathGUI; +import qupath.lib.gui.localization.QuPathResources; import qupath.lib.gui.measure.ObservableMeasurementTableData; import qupath.lib.images.ImageData; import qupath.lib.objects.PathObject; @@ -108,7 +109,7 @@ public class PathObjectGridView implements ChangeListener getAnnotations(PathObjectHierarchy hierarchy) { /** * Get the stage used to show the grid view. - * @return + * @return The stage */ public Stage getStage() { if (stage == null) { @@ -253,7 +254,7 @@ public ObjectProperty> imageDataProperty() { private static void sortPathObjects(final ObservableList cores, final ObservableMeasurementTableData model, final String measurementName, final boolean doDescending) { if (measurementName == null) return; - if (measurementName.equals("Classification")) { + if (measurementName.equals(QuPathResources.getString("GridView.classification"))) { cores.sort((po1, po2) -> { if (po1.getPathClass() == null || po2.getPathClass() == null) return 0; Comparator comp = Comparator.comparing((po) -> po.getPathClass().toString()); @@ -350,22 +351,26 @@ private void initializeGUI() { ComboBox comboOrder = new ComboBox<>(); - comboOrder.getItems().setAll("Ascending", "Descending"); - comboOrder.getSelectionModel().select("Descending"); - descending.bind(Bindings.createBooleanBinding(() -> "Descending".equals(comboOrder.getSelectionModel().getSelectedItem()), comboOrder.getSelectionModel().selectedItemProperty())); + comboOrder.getItems().setAll( + QuPathResources.getString("GridView.ascending"), + QuPathResources.getString("GridView.descending")); + comboOrder.getSelectionModel().select(QuPathResources.getString("GridView.descending")); + descending.bind(Bindings.createBooleanBinding(() -> + QuPathResources.getString("GridView.descending").equals(comboOrder.getSelectionModel().getSelectedItem()), + comboOrder.getSelectionModel().selectedItemProperty())); comboMeasurement = new ComboBox<>(); - comboMeasurement.setPlaceholder(createPlaceholderText("No measurements!")); + comboMeasurement.setPlaceholder(createPlaceholderText(QuPathResources.getString("GridView.noMeasurements"))); var measureNames = model.getMeasurementNames(); ObservableList measureList = FXCollections.observableArrayList(measureNames); measureNames.addListener((ListChangeListener) c -> { measureList.clear(); - measureList.add("Classification"); + measureList.add(QuPathResources.getString("GridView.classification")); measureList.addAll(measureNames); }); - measureList.add("Classification"); + measureList.add(QuPathResources.getString("GridView.classification")); comboMeasurement.setItems(measureList); if (!comboMeasurement.getItems().isEmpty()) comboMeasurement.getSelectionModel().select(0); @@ -375,11 +380,11 @@ private void initializeGUI() { addSortAndFilterer(comboOrder); addSortAndFilterer(comboMeasurement); - CheckBox cbShowMeasurement = new CheckBox("Show measurement"); + CheckBox cbShowMeasurement = new CheckBox(QuPathResources.getString("GridView.showMeasurement")); showMeasurement.bind(cbShowMeasurement.selectedProperty()); showMeasurement.addListener(c -> updateMeasurement()); // Force an update - CheckBox cbAnimation = new CheckBox("Animate"); + CheckBox cbAnimation = new CheckBox(QuPathResources.getString("GridView.animate")); cbAnimation.setSelected(doAnimate.get()); doAnimate.bindBidirectional(cbAnimation.selectedProperty()); @@ -396,18 +401,18 @@ private void initializeGUI() { BorderPane pane = new BorderPane(); ToolBar paneTop = new ToolBar(); - paneTop.getItems().add(new Label("Measurement")); + paneTop.getItems().add(new Label(QuPathResources.getString("GridView.measurement"))); paneTop.getItems().add(comboMeasurement); paneTop.getItems().add(new Separator(Orientation.VERTICAL)); - paneTop.getItems().add(new Label("Order")); + paneTop.getItems().add(new Label(QuPathResources.getString("GridView.order"))); paneTop.getItems().add(comboOrder); paneTop.getItems().add(new Separator(Orientation.VERTICAL)); paneTop.getItems().add(cbShowMeasurement); paneTop.getItems().add(new Separator(Orientation.VERTICAL)); - paneTop.getItems().add(new Label("Size")); + paneTop.getItems().add(new Label(QuPathResources.getString("GridView.size"))); paneTop.getItems().add(comboDisplaySize); paneTop.getItems().add(new Separator(Orientation.VERTICAL)); - paneTop.getItems().add(new Label("Classes")); + paneTop.getItems().add(new Label(QuPathResources.getString("GridView.classes"))); paneTop.getItems().add(classComboBox); paneTop.getItems().add(new Separator(Orientation.VERTICAL)); paneTop.getItems().add(cbAnimation); @@ -464,7 +469,7 @@ private void updateMeasurement() { private void sortAndFilter() { String m = measurement.getValue(); sortPathObjects(backingList, model, m, descending.get()); - filteredList.setPredicate(p -> (m == null || m.equals("Classification") || + filteredList.setPredicate(p -> (m == null || m.equals(QuPathResources.getString("GridView.classification")) || !(isMissingCore(p) || Double.isNaN(model.getNumericValue(p, m)))) && selectedClasses.contains(p.getPathClass()) ); @@ -525,18 +530,13 @@ class QuPathGridView extends StackPane { private IntegerProperty imageSize = new SimpleIntegerProperty(); - private Text textEmpty = createPlaceholderText("No objects available!"); + private Text textEmpty = createPlaceholderText(QuPathResources.getString("GridView.noObjectsAvailable")); QuPathGridView() { imageSize.addListener(v -> { updateChildren(); }); - list.addListener(new ListChangeListener<>() { - @Override - public void onChanged(javafx.collections.ListChangeListener.Change c) { - updateChildren(); - } - }); + list.addListener((ListChangeListener) c -> updateChildren()); updateChildren(); StackPane.setAlignment(textEmpty, Pos.CENTER); } @@ -596,7 +596,7 @@ void updateMeasurementText() { if (m == null || !showMeasurement.get()) entry.getValue().setText(" "); else { - if (m.equals("Classification")) { + if (m.equals(QuPathResources.getString("GridView.classification"))) { entry.getValue().setText(entry.getKey().getPathClass().toString()); } else { double val = model.getNumericValue(entry.getKey(), m); diff --git a/qupath-gui-fx/src/main/resources/qupath/lib/gui/localization/qupath-gui-strings.properties b/qupath-gui-fx/src/main/resources/qupath/lib/gui/localization/qupath-gui-strings.properties index 100f056ea..8c859eeeb 100644 --- a/qupath-gui-fx/src/main/resources/qupath/lib/gui/localization/qupath-gui-strings.properties +++ b/qupath-gui-fx/src/main/resources/qupath/lib/gui/localization/qupath-gui-strings.properties @@ -972,3 +972,18 @@ ExtensionControlPane.name = Name ExtensionControlPane.datePublished = Date published ExtensionControlPane.description = Description ExtensionControlPane.notCompatible = (not compatible) + +GridView.title = Object grid view +GridView.TMAGridView = TMA core grid view +GridView.AnnotationGridView = Annotation object grid view +GridView.noObjectsAvailable = No objects available! +GridView.classification = Classification +GridView.ascending = Ascending +GridView.descending = Descending +GridView.noMeasurements = No measurements! +GridView.showMeasurement = Show measurement +GridView.animate = Animate +GridView.measurement = Measurement +GridView.order = Order +GridView.size = Size +GridView.classes = Classes From 57ccc227a1a121c185c735f7e819c6b450e610b6 Mon Sep 17 00:00:00 2001 From: Pete Date: Thu, 29 Aug 2024 06:11:59 +0100 Subject: [PATCH 247/866] Fix spacebar locked on in viewers Fixes https://github.com/qupath/qupath/issues/1610 --- qupath-gui-fx/src/main/java/qupath/lib/gui/QuPathGUI.java | 2 +- .../src/main/java/qupath/lib/gui/viewer/QuPathViewer.java | 6 ++---- .../src/main/java/qupath/lib/gui/viewer/ViewerManager.java | 7 +++++-- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/QuPathGUI.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/QuPathGUI.java index c08eb7975..7ae0480ab 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/QuPathGUI.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/QuPathGUI.java @@ -886,7 +886,7 @@ else if (e.getEventType() == KeyEvent.KEY_RELEASED) // tools, and we don't want tools to be registered to inactive viewers...) var active = viewerManager.getActiveViewer(); if (active != null) - active.setSpaceDown(pressed.booleanValue()); + active.setSpaceDown(pressed); } } else if (e.getCode() == KeyCode.S && e.getEventType() == KeyEvent.KEY_PRESSED) { PathPrefs.tempSelectionModeProperty().set(true); diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/QuPathViewer.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/QuPathViewer.java index 9b6e05dd0..c0a4272ad 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/QuPathViewer.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/QuPathViewer.java @@ -1024,11 +1024,9 @@ public void setSpaceDown(boolean spaceDown) { if (activeTool != PathTools.MOVE && activeTool != null) { if (spaceDown) { // Temporarily switch to 'move' tool - if (activeTool != null) - activeTool.deregisterTool(this); + activeTool.deregisterTool(this); activeTool = PathTools.MOVE; - if (activeTool != null) - activeTool.registerTool(this); + activeTool.registerTool(this); } else { // Reset tool, as required PathTools.MOVE.deregisterTool(this); diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/ViewerManager.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/ViewerManager.java index 33bf4bea3..8c79758d0 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/ViewerManager.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/ViewerManager.java @@ -248,10 +248,11 @@ public void setActiveViewer(final QuPathViewer viewer) { return; ImageData imageDataNew = viewer == null ? null : viewer.getImageData(); + boolean spaceDown = false; if (previousActiveViewer != null) { + spaceDown = previousActiveViewer.isSpaceDown(); + previousActiveViewer.setSpaceDown(false); previousActiveViewer.setBorderColor(null); - // activeViewer.setBorder(BorderFactory.createLineBorder(colorTransparent, borderWidth)); - // activeViewer.setBorder(null); deactivateTools(previousActiveViewer); // Grab reference to the current annotation, if there is one @@ -267,6 +268,8 @@ public void setActiveViewer(final QuPathViewer viewer) { viewer.setBorderColor(colorBorder); if (viewer.getServer() != null) { getLastViewerPosition(viewer).update(viewer); + if (spaceDown) + viewer.setSpaceDown(true); } } logger.debug("Active viewer set to {}", viewer); From 326b385d7ef87d2c288e0c4235aa1646c40642c6 Mon Sep 17 00:00:00 2001 From: Pete Date: Thu, 29 Aug 2024 06:12:31 +0100 Subject: [PATCH 248/866] Update CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 484427447..e218fc51e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,7 @@ This is a *work in progress* for the next major release. * `ImageOps.Core.replace()` does not work as expected (https://github.com/qupath/qupath/issues/1564) * QuPath doesn't always use the specified file extension when exporting snapshots (https://github.com/qupath/qupath/issues/1567) * Out-of-bounds tiles can result in detected cells being in the wrong place (https://github.com/qupath/qupath/issues/1606) +* Spacebar can be locked in an 'on' position within viewers (https://github.com/qupath/qupath/issues/1610) ### API changes * New `Map getMetadata()` method added to `PathObject`, `Project` and `ProjectImageEntry` (https://github.com/qupath/qupath/pull/1587) From 97eb4a918b8e42d23f7d3d4c5376d4bed26e969b Mon Sep 17 00:00:00 2001 From: Pete Date: Thu, 29 Aug 2024 06:16:05 +0100 Subject: [PATCH 249/866] Use headings in templates --- .github/ISSUE_TEMPLATE/bug_report.md | 12 ++++++------ .github/ISSUE_TEMPLATE/feature_request.md | 8 ++++---- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 19bc738dc..4dacaa0cc 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -26,25 +26,25 @@ Before submitting your bug report, please check the following: ## Bug report -**Describe the bug** +### Describe the bug A clear and concise description of what the bug is. -**To Reproduce** +### To Reproduce Steps to reproduce the behavior: 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' 4. See error -**Expected behavior** +### Expected behavior A clear and concise description of what you expected to happen. -**Screenshots** +### Screenshots If applicable, add screenshots to help explain your problem. -**Desktop (please complete the following information):** +### Desktop (please complete the following information): - OS: [e.g. Windows, macOS, Ubuntu...] - QuPath Version: [e.g. 0.2.2] -**Additional context** +### Additional context Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index 9d7c80b93..e5aa6e4a6 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -19,14 +19,14 @@ If in doubt, use [image.sc](https://forum.image.sc/tags/qupath). This helps us k ## Feature request -**Is your feature request related to a problem? Please describe.** +### Is your feature request related to a problem? Please describe. A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] -**Describe the solution you'd like** +### Describe the solution you'd like A clear and concise description of what you want to happen. -**Describe alternatives you've considered** +### Describe alternatives you've considered A clear and concise description of any alternative solutions or features you've considered. -**Additional context** +### Additional context Add any other context or screenshots about the feature request here. From 2dce5d0b6a39ac88db25b8ad74296dec8812a171 Mon Sep 17 00:00:00 2001 From: Pete Date: Thu, 29 Aug 2024 07:02:38 +0100 Subject: [PATCH 250/866] Right-click to change TMA core classification Support changing classifications for TMA cores via right-click. Previously this only worked for annotations. We could extend this to detections, but for now I don't want to make that *too* easy. --- .../java/qupath/lib/gui/viewer/ViewerManager.java | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/ViewerManager.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/ViewerManager.java index 8c79758d0..fdb606b75 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/ViewerManager.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/ViewerManager.java @@ -1374,8 +1374,8 @@ private void setViewerPopupMenu(final QuPathViewerPlus viewer) { Collection selectedObjects = viewer.getAllSelectedObjects(); PathObject pathObject = viewer.getSelectedObject(); menuTMA.setVisible(false); - if (pathObject instanceof TMACoreObject) { - boolean isMissing = ((TMACoreObject)pathObject).isMissing(); + if (pathObject instanceof TMACoreObject core) { + boolean isMissing = core.isMissing(); miTMAValid.setSelected(!isMissing); miTMAMissing.setSelected(isMissing); menuTMA.setVisible(true); @@ -1398,7 +1398,8 @@ private void setViewerPopupMenu(final QuPathViewerPlus viewer) { menuTools.getItems().addAll(createToolMenu(qupath.getToolManager())); } - boolean hasAnnotations = pathObject instanceof PathAnnotationObject || (!selectedObjects.isEmpty() && selectedObjects.stream().allMatch(p -> p.isAnnotation())); + boolean hasAnnotations = pathObject instanceof PathAnnotationObject || + (!selectedObjects.isEmpty() && selectedObjects.stream().allMatch(PathObject::isAnnotation)); updateSetAnnotationPathClassMenu(menuSetClass, viewer); menuAnnotations.setVisible(hasAnnotations); @@ -1523,7 +1524,8 @@ private void updateSetAnnotationPathClassMenu(final CirclePopupMenu menuSetClass private void updateSetAnnotationPathClassMenu(final ObservableList menuSetClassItems, final QuPathViewer viewer, final boolean useFancyIcons) { // We need a viewer and an annotation, as well as some PathClasses, otherwise we just need to ensure the menu isn't visible var availablePathClasses = qupath.getAvailablePathClasses(); - if (viewer == null || !(viewer.getSelectedObject() instanceof PathAnnotationObject) || availablePathClasses.isEmpty()) { + if (viewer == null || availablePathClasses.isEmpty() || viewer.getSelectedObject() == null || + !(viewer.getSelectedObject().isAnnotation() || viewer.getSelectedObject().isTMACore())) { menuSetClassItems.clear(); return; } @@ -1542,7 +1544,7 @@ private void updateSetAnnotationPathClassMenu(final ObservableList men Action actionSetClass = new Action(name, e -> { List changed = new ArrayList<>(); for (PathObject pathObject : viewer.getAllSelectedObjects()) { - if (!pathObject.isAnnotation() || pathObject.getPathClass() == pathClassToSet) + if (pathObject.getPathClass() == pathClassToSet) continue; pathObject.setPathClass(pathClassToSet); changed.add(pathObject); From 6e4e6832b499c57968d066985cbb7f2215559c0b Mon Sep 17 00:00:00 2001 From: Alan O'Callaghan Date: Thu, 29 Aug 2024 17:31:30 +0100 Subject: [PATCH 251/866] Show unclassified cores as Unclassified --- .../java/qupath/lib/gui/commands/PathObjectGridView.java | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/PathObjectGridView.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/PathObjectGridView.java index 4fd7fd28e..fca3c3d55 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/PathObjectGridView.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/PathObjectGridView.java @@ -469,9 +469,10 @@ private void updateMeasurement() { private void sortAndFilter() { String m = measurement.getValue(); sortPathObjects(backingList, model, m, descending.get()); - filteredList.setPredicate(p -> (m == null || m.equals(QuPathResources.getString("GridView.classification")) || + filteredList.setPredicate(p -> + (m == null || m.equals(QuPathResources.getString("GridView.classification")) || !(isMissingCore(p) || Double.isNaN(model.getNumericValue(p, m)))) && - selectedClasses.contains(p.getPathClass()) + (selectedClasses.contains(p.getPathClass()) || p.getPathClass() == null) ); grid.getItems().setAll(filteredList); } @@ -597,7 +598,9 @@ void updateMeasurementText() { entry.getValue().setText(" "); else { if (m.equals(QuPathResources.getString("GridView.classification"))) { - entry.getValue().setText(entry.getKey().getPathClass().toString()); + PathClass pc = entry.getKey().getPathClass(); + String text = pc == null ? "Unclassified" : pc.toString(); + entry.getValue().setText(text); } else { double val = model.getNumericValue(entry.getKey(), m); entry.getValue().setText(GeneralTools.formatNumber(val, 3)); From 4fe7d0c6ff6e293c0cfb54d5daac5b975adafd93 Mon Sep 17 00:00:00 2001 From: Alan O'Callaghan Date: Thu, 29 Aug 2024 17:42:35 +0100 Subject: [PATCH 252/866] Keep PathObjects with NaN measurement --- .../main/java/qupath/lib/gui/commands/PathObjectGridView.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/PathObjectGridView.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/PathObjectGridView.java index fca3c3d55..7b4e3435e 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/PathObjectGridView.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/PathObjectGridView.java @@ -470,8 +470,7 @@ private void sortAndFilter() { String m = measurement.getValue(); sortPathObjects(backingList, model, m, descending.get()); filteredList.setPredicate(p -> - (m == null || m.equals(QuPathResources.getString("GridView.classification")) || - !(isMissingCore(p) || Double.isNaN(model.getNumericValue(p, m)))) && + (m == null || m.equals(QuPathResources.getString("GridView.classification")) || !isMissingCore(p)) && (selectedClasses.contains(p.getPathClass()) || p.getPathClass() == null) ); grid.getItems().setAll(filteredList); From 3a493b77ecebb0a23e68a48e7cfa7a03d59f7623 Mon Sep 17 00:00:00 2001 From: Pete Date: Thu, 29 Aug 2024 19:25:21 +0100 Subject: [PATCH 253/866] ROI-geometry conversion An unsatisfying exploration into ROI-to-Geometry conversion. I learned that `Polygonizer` has non-deterministic behavior, and this can't be corrected externally. It can also fail for complex, randomly-generated polygons - although the behavior seems 'stable enough' for real-world data. --- .../java/qupath/lib/roi/GeometryTools.java | 23 ++- .../qupath/lib/roi/TestGeometryTools.java | 131 +++++++++++++++++- 2 files changed, 145 insertions(+), 9 deletions(-) diff --git a/qupath-core/src/main/java/qupath/lib/roi/GeometryTools.java b/qupath-core/src/main/java/qupath/lib/roi/GeometryTools.java index 46d635711..782d3fbc2 100644 --- a/qupath-core/src/main/java/qupath/lib/roi/GeometryTools.java +++ b/qupath-core/src/main/java/qupath/lib/roi/GeometryTools.java @@ -41,6 +41,7 @@ import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.StringTokenizer; import java.util.function.Function; import org.locationtech.jts.algorithm.locate.SimplePointInAreaLocator; @@ -952,21 +953,28 @@ private Geometry areaToGeometry(ROI roi) { return shapeFactory.createRectangle(); } } - // TODO: Test if this is as reliable - // Exploratory code for v0.4.0, but rejected to reduce risk. - // Seems marginally faster, but not by a huge amount + // TODO: Test if this is as reliable + // Update August 2024... it is not. Tests added to TestGeometryTools show it + // fails faster for complex (random) polygons than the old method, which + // converts via a java.awt.geom.Area. + // + // Exploratory code for v0.4.0, but rejected to reduce risk. + // Seems marginally faster, but not by a huge amount // if (roi instanceof PolygonROI) { // PrecisionModel precisionModel = factory.getPrecisionModel(); // Polygonizer polygonizer = new Polygonizer(true); // List coords = new ArrayList<>(); +// Coordinate lastCoord = null; // for (var p : roi.getAllPoints()) { // var c = new Coordinate(p.getX(), p.getY()); // precisionModel.makePrecise(c); -// coords.add(c); +// if (!Objects.equals(lastCoord, c)) +// coords.add(c); +// lastCoord = c; // } // // Close if needed -// if (!coords.get(0).equals(coords.get(coords.size()-1))) -// coords.add(coords.get(0).copy()); +// if (!coords.getFirst().equals(coords.getLast())) +// coords.add(coords.getFirst().copy()); // LineString lineString = factory.createLineString(coords.toArray(Coordinate[]::new)); // polygonizer.add(lineString.union()); // return polygonizer.getGeometry(); @@ -1041,7 +1049,8 @@ private static Geometry convertAreaToGeometry(final Area area, final AffineTrans LineString lineString = factory.createLineString(array); geometries.add(lineString); } - polygonizer.add(factory.buildGeometry(geometries).union()); + var geom = factory.buildGeometry(geometries).union(); + polygonizer.add(geom); return polygonizer.getGeometry(); } diff --git a/qupath-core/src/test/java/qupath/lib/roi/TestGeometryTools.java b/qupath-core/src/test/java/qupath/lib/roi/TestGeometryTools.java index e565cdc14..27c4513cc 100644 --- a/qupath-core/src/test/java/qupath/lib/roi/TestGeometryTools.java +++ b/qupath-core/src/test/java/qupath/lib/roi/TestGeometryTools.java @@ -35,21 +35,34 @@ import java.nio.file.Files; import java.util.ArrayList; import java.util.List; +import java.util.Random; import java.util.stream.Collectors; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.locationtech.jts.geom.Coordinate; import org.locationtech.jts.geom.Geometry; +import org.locationtech.jts.geom.GeometryFactory; import org.locationtech.jts.geom.Polygon; +import org.locationtech.jts.geom.PrecisionModel; import org.locationtech.jts.geom.util.AffineTransformation; +import org.locationtech.jts.operation.polygonize.Polygonizer; +import org.locationtech.jts.operation.valid.IsValidOp; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import qupath.lib.geom.Point2; import qupath.lib.objects.hierarchy.PathObjectHierarchy; +import qupath.lib.regions.ImagePlane; +import qupath.lib.roi.interfaces.ROI; /** * Test {@link GeometryTools}. Note that most of the relevant tests for ROI conversion are in {@link TestROIs}. */ public class TestGeometryTools { - + + private static final Logger logger = LoggerFactory.getLogger(TestGeometryTools.class); + /** * Compare conversion of {@link AffineTransform} and {@link AffineTransformation} objects. */ @@ -254,6 +267,120 @@ public void testFillHoles() { assertNotEquals(outer, withHole); assertEquals(outer, GeometryTools.fillHoles(withHole).norm()); } - + + @Test + public void testConvertBowtie() { + var polygon = ROIs.createPolygonROI( + List.of( + new Point2(0, 0), + new Point2(10, 0), + new Point2(0, 10), + new Point2(10, 10) + ), + ImagePlane.getDefaultPlane() + ); + double eps = 1e-6; + assertEquals(50, polygon.getArea(), eps); + assertEquals(50, polygon.getGeometry().getArea(), eps); + assertTrue(polygon.getGeometry().isValid()); + assertEquals(50, GeometryTools.roiToGeometry(polygon).getArea(), eps); + assertTrue(GeometryTools.roiToGeometry(polygon).isValid()); + assertEquals(50, GeometryTools.shapeToGeometry(RoiTools.getShape(polygon)).getArea(), eps); + assertTrue(GeometryTools.shapeToGeometry(RoiTools.getShape(polygon)).isValid()); + } + + + /** + * The behavior of Polygonizer is rather... confusing. + * And non-deterministic. + * See https://github.com/locationtech/jts/issues/1063 + * This test is ignored, but can be used to investigate the behavior of Polygonizer. + */ + @Test + @Disabled + public void randomPolygonize() { + Random rng = new Random(100); + PrecisionModel pm = new PrecisionModel(100.0); + GeometryFactory factory = new GeometryFactory(pm); + // Note that increasing this to 1000 will cause the test to fail. + int n = 100; + Coordinate[] coords = new Coordinate[n]; + for (int i = 0; i < n-1; i++) { + Coordinate c = new Coordinate(rng.nextDouble() * 1000, rng.nextDouble() * 1000); + pm.makePrecise(c); + coords[i] = c; + } + coords[coords.length-1] = coords[0]; + + var lineString = factory.createLineString(coords).union(); + assertTrue(lineString.isValid()); + for (int k = 0; k < 100; k++) { + var polygonizer = new Polygonizer(true); + polygonizer.add(lineString); + var polygons = polygonizer.getGeometry(); + var err = new IsValidOp(polygons).getValidationError(); + if (err != null) { + logger.warn("Polygonizer gives {} points, error: {}", polygons.getNumPoints(), err); + } + assertTrue(polygons.isValid()); + } + } + + /** + * Note that Polygonizer is unreliable for complex polygons, but we expect it to succeed + * for simple polygons. + */ + private static ROI createRandomPolygon() { + var rng = new Random(100); + var pm = GeometryTools.getDefaultFactory().getPrecisionModel(); + // Note that increasing this to 1000 will cause the test to fail, and the resulting (multi)polygon will be invalid. + // I don't see a good solution to this - it can be considered a limitation of our ROI conversation that it + // can't handle *extremely* complicated polygons. + int n = 10; + double[] x = rng.doubles(n, 0, 1000) + .map(pm::makePrecise).toArray(); + double[] y = rng.doubles(n, 0, 1000) + .map(pm::makePrecise).toArray(); + return ROIs.createPolygonROI(x, y, ImagePlane.getDefaultPlane()); + } + + @Test + public void testConvertRandomPolygonDirect() { + var polygon = createRandomPolygon(); + double area = polygon.getArea(); + double eps = Math.max(1e-6, area * 0.0001); + + var geom = GeometryTools.roiToGeometry(polygon); + var err = new IsValidOp(geom).getValidationError(); + assertNull(err); + assertTrue(geom.isValid()); + assertEquals(area, geom.getArea(), eps); + } + + @Test + public void testConvertRandomPolygon() { + var polygon = createRandomPolygon(); + double area = polygon.getArea(); + double eps = Math.max(1e-6, area * 0.0001); + + var geom = GeometryTools.roiToGeometry(polygon); + var err = new IsValidOp(geom).getValidationError(); + assertNull(err); + assertTrue(geom.isValid()); + assertEquals(area, geom.getArea(), eps); + } + + @Test + public void testConvertRandomPolygonViaShape() { + var polygon = createRandomPolygon(); + double area = polygon.getArea(); + double eps = Math.max(1e-6, area * 0.0001); + + var geom = GeometryTools.shapeToGeometry(RoiTools.getShape(polygon)); + var err = new IsValidOp(geom).getValidationError(); + assertNull(err); + assertTrue(geom.isValid()); + assertEquals(area, geom.getArea(), eps); + } } \ No newline at end of file From b75a39753249485197281c797299e29d9b18a1eb Mon Sep 17 00:00:00 2001 From: Pete Date: Thu, 29 Aug 2024 19:26:21 +0100 Subject: [PATCH 254/866] Update TestGeometryTools.java --- qupath-core/src/test/java/qupath/lib/roi/TestGeometryTools.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qupath-core/src/test/java/qupath/lib/roi/TestGeometryTools.java b/qupath-core/src/test/java/qupath/lib/roi/TestGeometryTools.java index 27c4513cc..ba7b79ba8 100644 --- a/qupath-core/src/test/java/qupath/lib/roi/TestGeometryTools.java +++ b/qupath-core/src/test/java/qupath/lib/roi/TestGeometryTools.java @@ -350,7 +350,7 @@ public void testConvertRandomPolygonDirect() { double area = polygon.getArea(); double eps = Math.max(1e-6, area * 0.0001); - var geom = GeometryTools.roiToGeometry(polygon); + var geom = polygon.getGeometry(); var err = new IsValidOp(geom).getValidationError(); assertNull(err); assertTrue(geom.isValid()); From f17f7337900e6a1ce7133c2021d0d91f3eab802c Mon Sep 17 00:00:00 2001 From: Pete Date: Thu, 29 Aug 2024 20:48:24 +0100 Subject: [PATCH 255/866] Reorder cell measurement names Use the `Compartment: Channel: Measurement` with `ObjectMeasurements`. This is to group measurements by compartment, which seems more intuitive and easier to work with than grouping by channel. --- CHANGELOG.md | 3 + .../analysis/features/ObjectMeasurements.java | 81 ++++++++++++------- .../lib/images/writers/TileExporter.java | 1 - 3 files changed, 53 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e218fc51e..168879e97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,6 +70,9 @@ This is a *work in progress* for the next major release. * `PathObject` and `PathObjectHierarchy` have also been revised, with deprecated methods removed * New `PathObject.getClassification()` and `PathObject.setClassification(String)` methods to simplify working with classifications in scripts (https://github.com/qupath/qupath/pull/1593) +* `ObjectMeasurements` names cell intensity measurements in the order `Compartment: Channel: Measurement` + * This is a change from `Channel: Compartment: Measurement` to make it easier to find measurements in the list + * *This will affect the use of the QuPath StarDist extension* ### Dependency updates * Bio-Formats 7.3.1 diff --git a/qupath-core-processing/src/main/java/qupath/lib/analysis/features/ObjectMeasurements.java b/qupath-core-processing/src/main/java/qupath/lib/analysis/features/ObjectMeasurements.java index 600faadb6..44fef8fe3 100644 --- a/qupath-core-processing/src/main/java/qupath/lib/analysis/features/ObjectMeasurements.java +++ b/qupath-core-processing/src/main/java/qupath/lib/analysis/features/ObjectMeasurements.java @@ -198,8 +198,8 @@ public static void addShapeMeasurements(Collection pathObj Collection featureCollection = features.length == 0 ? ALL_SHAPE_FEATURES : Arrays.asList(features); pathObjects.parallelStream().filter(p -> p.hasROI()).forEach(pathObject -> { - if (pathObject instanceof PathCellObject) { - addCellShapeMeasurements((PathCellObject)pathObject, calibration, featureCollection); + if (pathObject instanceof PathCellObject cell) { + addCellShapeMeasurements(cell, calibration, featureCollection); } else { try (var ml = pathObject.getMeasurementList()) { addShapeMeasurements(ml, pathObject.getROI(), calibration, "", featureCollection); @@ -450,8 +450,7 @@ public static void addIntensityMeasurements( var roiIJ = IJTools.convertToIJRoi(roi, pathImage); bpCell.fill(roiIJ); - if (pathObject instanceof PathCellObject) { - var cell = (PathCellObject)pathObject; + if (pathObject instanceof PathCellObject cell) { ByteProcessor bpNucleus = new ByteProcessor(imp.getWidth(), imp.getHeight()); if (cell.getNucleusROI() != null) { bpNucleus.setValue(1.0); @@ -513,17 +512,53 @@ private static void measureCells( var imgCells = new PixelImageIJ(ipCells); var imgCytoplasm = new PixelImageIJ(ipCytoplasm); var imgMembrane = new PixelImageIJ(ipMembrane); - - for (var entry : channels.entrySet()) { - var img = new PixelImageIJ(entry.getValue()); - if (compartments.contains(Compartments.NUCLEUS)) - measureObjects(img, imgNuclei, array, entry.getKey().trim() + ": " + "Nucleus", measurements); - if (compartments.contains(Compartments.CYTOPLASM)) - measureObjects(img, imgCytoplasm, array, entry.getKey().trim() + ": " + "Cytoplasm", measurements); - if (compartments.contains(Compartments.MEMBRANE)) - measureObjects(img, imgMembrane, array, entry.getKey().trim() + ": " + "Membrane", measurements); - if (compartments.contains(Compartments.CELL)) - measureObjects(img, imgCells, array, entry.getKey().trim() + ": " + "Cell", measurements); + + // Use legacy names, from before QuPath v0.6.0 + // These encoded the channel name first, rather than after the cell compartment, but this made them less + boolean useLegacyNames = Boolean.parseBoolean(System.getProperty("OBJECT_MEASUREMENTS_USE_LEGACY_NAMES", "false").strip()); + + if (useLegacyNames) { + for (var entry : channels.entrySet()) { + var img = new PixelImageIJ(entry.getValue()); + if (compartments.contains(Compartments.NUCLEUS)) + measureObjects(img, imgNuclei, array, entry.getKey().trim() + ": " + "Nucleus", measurements); + if (compartments.contains(Compartments.CYTOPLASM)) + measureObjects(img, imgCytoplasm, array, entry.getKey().trim() + ": " + "Cytoplasm", measurements); + if (compartments.contains(Compartments.MEMBRANE)) + measureObjects(img, imgMembrane, array, entry.getKey().trim() + ": " + "Membrane", measurements); + if (compartments.contains(Compartments.CELL)) + measureObjects(img, imgCells, array, entry.getKey().trim() + ": " + "Cell", measurements); + } + } else { + // 'New' names group measurements by compartment first, then channel + if (compartments.contains(Compartments.NUCLEUS)) { + for (var entry : channels.entrySet()) { + var img = new PixelImageIJ(entry.getValue()); + String channelName = entry.getKey().trim(); + measureObjects(img, imgNuclei, array, "Nucleus: " + channelName, measurements); + } + } + if (compartments.contains(Compartments.CYTOPLASM)) { + for (var entry : channels.entrySet()) { + var img = new PixelImageIJ(entry.getValue()); + String channelName = entry.getKey().trim(); + measureObjects(img, imgCytoplasm, array, "Cytoplasm: " + channelName, measurements); + } + } + if (compartments.contains(Compartments.MEMBRANE)) { + for (var entry : channels.entrySet()) { + var img = new PixelImageIJ(entry.getValue()); + String channelName = entry.getKey().trim(); + measureObjects(img, imgMembrane, array, "Membrane: " + channelName, measurements); + } + } + if (compartments.contains(Compartments.CELL)) { + for (var entry : channels.entrySet()) { + var img = new PixelImageIJ(entry.getValue()); + String channelName = entry.getKey().trim(); + measureObjects(img, imgCells, array, "Cell: " + channelName, measurements); + } + } } } @@ -557,22 +592,6 @@ private static PathObject[] mapToArray(Map pathObjects, -// String baseName, Collection measurements) { -// -// measureObjects(img, imgLabels, mapToArray(pathObjects), baseName, measurements); -// } - /** * Measure objects within the specified image, adding them to the corresponding measurement lists. * @param img intensity values to measure diff --git a/qupath-core/src/main/java/qupath/lib/images/writers/TileExporter.java b/qupath-core/src/main/java/qupath/lib/images/writers/TileExporter.java index adedf54fb..6f3f77847 100644 --- a/qupath-core/src/main/java/qupath/lib/images/writers/TileExporter.java +++ b/qupath-core/src/main/java/qupath/lib/images/writers/TileExporter.java @@ -54,7 +54,6 @@ import qupath.lib.images.servers.ImageServerMetadata.ChannelType; import qupath.lib.images.servers.TransformedServerBuilder; import qupath.lib.io.GsonTools; -import qupath.lib.objects.PathAnnotationObject; import qupath.lib.objects.PathObject; import qupath.lib.objects.PathObjectTools; import qupath.lib.objects.classes.PathClass; From 27ac40e444f61af577d58154257d31f352b16968 Mon Sep 17 00:00:00 2001 From: Pete Date: Fri, 30 Aug 2024 16:27:20 +0100 Subject: [PATCH 256/866] Update Guava, JTS --- CHANGELOG.md | 3 ++- gradle/libs.versions.toml | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 168879e97..cacd4a75f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -80,8 +80,9 @@ This is a *work in progress* for the next major release. * DeepJavaLibrary 0.29.0 * Groovy 4.0.22 * Gson 2.11.0 -* Guava 33.2.0-jre +* Guava 33.3.0-jre * JavaFX 22.0.2 +* Java Topology Suite 1.20.0 * JFreeSVG 5.0.6 * JNA 5.14.0 * Picocli 4.7.6 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 01c5b73e3..b53f4f4eb 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -14,7 +14,7 @@ deepJavaLibrary = "0.29.0" groovy = "4.0.22" gson = "2.11.0" -guava = "33.2.0-jre" +guava = "33.3.0-jre" ikonli = "12.3.1" imagej = "1.54f" @@ -32,7 +32,7 @@ javafx = "22.0.2" jna = "5.14.0" jfreeSvg = "5.0.6" jfxtras = "17-r1" -jts = "1.19.0" +jts = "1.20.0" junit = "5.9.2" logback = "1.3.11" From d9edf3571804c29923d7bf58750408475a0428ce Mon Sep 17 00:00:00 2001 From: Pete Date: Fri, 30 Aug 2024 17:18:33 +0100 Subject: [PATCH 257/866] Remove ImageServer.finalize() Avoids warnings about deprecation. Only the Bio-Formats and OpenSlide servers relied upon this as a backup in case `ImageServer.close()`, and both now use `Cleaner`. Note that this should be more reliable anyway, since servers that wrapped other servers don't reliably call `close()` on the servers that they wrap. They arguably should, but if multiple servers wrap the same core server then there's a chance it is closed even while still in use... so it's safer to rely on the `Cleaner` (unfortunately). --- CHANGELOG.md | 2 ++ .../qupath/opencv/ops/ImageDataServer.java | 2 +- .../images/servers/AbstractImageServer.java | 18 +---------- .../images/servers/LabeledImageServer.java | 3 -- .../images/servers/RotatedImageServer.java | 2 +- .../bioformats/BioFormatsImageServer.java | 25 +++++++-------- .../openslide/OpenslideImageServer.java | 31 +++++++++++++++++-- .../servers/PathHierarchyImageServer.java | 4 --- 8 files changed, 44 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cacd4a75f..8a81d868f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -73,6 +73,8 @@ This is a *work in progress* for the next major release. * `ObjectMeasurements` names cell intensity measurements in the order `Compartment: Channel: Measurement` * This is a change from `Channel: Compartment: Measurement` to make it easier to find measurements in the list * *This will affect the use of the QuPath StarDist extension* +* `ImageServer.finalize()` is no longer overridden to call `close()` in case the caller forgets + * `finalize()` is deprecated for removal; any class that relied on this should consider using `Cleaner` ### Dependency updates * Bio-Formats 7.3.1 diff --git a/qupath-core-processing/src/main/java/qupath/opencv/ops/ImageDataServer.java b/qupath-core-processing/src/main/java/qupath/opencv/ops/ImageDataServer.java index 089a731f4..806e8d4ff 100644 --- a/qupath-core-processing/src/main/java/qupath/opencv/ops/ImageDataServer.java +++ b/qupath-core-processing/src/main/java/qupath/opencv/ops/ImageDataServer.java @@ -41,6 +41,6 @@ public interface ImageDataServer extends ImageServer { * Get the {@link ImageData} wrapped by the {@link ImageDataServer}. * @return */ - public ImageData getImageData(); + ImageData getImageData(); } \ No newline at end of file diff --git a/qupath-core/src/main/java/qupath/lib/images/servers/AbstractImageServer.java b/qupath-core/src/main/java/qupath/lib/images/servers/AbstractImageServer.java index b8f69bf19..3b4b14dbb 100644 --- a/qupath-core/src/main/java/qupath/lib/images/servers/AbstractImageServer.java +++ b/qupath-core/src/main/java/qupath/lib/images/servers/AbstractImageServer.java @@ -141,7 +141,7 @@ public double getDownsampleForResolution(int level) { @Override public void close() throws Exception { - logger.trace("Server " + this + " being closed now..."); + logger.trace("Server {} is being closed now", this); } @Override @@ -173,22 +173,6 @@ public PixelType getPixelType() { } - /** - * Attempt to close the server. While not at all a good idea to rely on this, it may help clean up after some forgotten servers. - */ - @Override - protected void finalize() throws Throwable { - // Ensure we close... - try{ - close(); - } catch(Throwable t){ - throw t; - } finally{ - super.finalize(); - } - } - - /** * Always returns false. */ diff --git a/qupath-core/src/main/java/qupath/lib/images/servers/LabeledImageServer.java b/qupath-core/src/main/java/qupath/lib/images/servers/LabeledImageServer.java index 40b0a6aaf..1585ccabc 100644 --- a/qupath-core/src/main/java/qupath/lib/images/servers/LabeledImageServer.java +++ b/qupath-core/src/main/java/qupath/lib/images/servers/LabeledImageServer.java @@ -842,9 +842,6 @@ public List getObjectsForRegion(ImageRegion region) { .toList(); } - @Override - public void close() {} - @Override public String getServerType() { return "Labelled image"; diff --git a/qupath-core/src/main/java/qupath/lib/images/servers/RotatedImageServer.java b/qupath-core/src/main/java/qupath/lib/images/servers/RotatedImageServer.java index ea33b8218..e053b2de9 100644 --- a/qupath-core/src/main/java/qupath/lib/images/servers/RotatedImageServer.java +++ b/qupath-core/src/main/java/qupath/lib/images/servers/RotatedImageServer.java @@ -42,7 +42,7 @@ public class RotatedImageServer extends TransformingImageServer { /** * Enum for rotations in increments of 90 degrees. */ - public static enum Rotation{ + public enum Rotation{ /** * No rotation. diff --git a/qupath-extension-bioformats/src/main/java/qupath/lib/images/servers/bioformats/BioFormatsImageServer.java b/qupath-extension-bioformats/src/main/java/qupath/lib/images/servers/bioformats/BioFormatsImageServer.java index 181cb896e..a198d079c 100644 --- a/qupath-extension-bioformats/src/main/java/qupath/lib/images/servers/bioformats/BioFormatsImageServer.java +++ b/qupath-extension-bioformats/src/main/java/qupath/lib/images/servers/bioformats/BioFormatsImageServer.java @@ -1331,7 +1331,8 @@ else if (!memoFileExists) } - cleanables.add(cleaner.register(this, new ReaderCleaner(Integer.toString(cleanables.size()+1), imageReader))); + cleanables.add(cleaner.register(this, + new ReaderCleaner(Integer.toString(cleanables.size()+1), imageReader))); return imageReader; } @@ -1542,6 +1543,7 @@ private static ClassList unwrapClasslist(IFormatReader reader) { @Override public void close() throws Exception { + logger.debug("Closing ReaderManager"); isClosed = true; if (task != null && !task.isDone()) task.cancel(true); @@ -1549,19 +1551,14 @@ public void close() throws Exception { try { c.clean(); } catch (Exception e) { - logger.error("Exception during cleanup: " + e.getLocalizedMessage()); - logger.debug(e.getLocalizedMessage(), e); + logger.error("Exception during cleanup: {}", e.getMessage(), e); } } - // Allow the queue to be garbage collected - clearing could result in a queue.poll() - // lingering far too long -// queue.clear(); } + - - - private static Cleaner cleaner = Cleaner.create(); - private List cleanables = new ArrayList<>(); + private static final Cleaner cleaner = Cleaner.create(); + private final List cleanables = new ArrayList<>(); /** @@ -1677,8 +1674,8 @@ private static boolean deleteEmptyDirectories(File dir) { */ static class ReaderCleaner implements Runnable { - private String name; - private IFormatReader reader; + private final String name; + private final IFormatReader reader; ReaderCleaner(String name, IFormatReader reader) { this.name = name; @@ -1687,11 +1684,11 @@ static class ReaderCleaner implements Runnable { @Override public void run() { - logger.debug("Cleaner " + name + " called for " + reader + " (" + reader.getCurrentFile() + ")"); + logger.debug("Cleaner {} called for {} ({})", name, reader, reader.getCurrentFile()); try { this.reader.close(false); } catch (IOException e) { - logger.warn("Error when calling cleaner for " + name, e); + logger.warn("Error when calling cleaner for {}", name, e); } } diff --git a/qupath-extension-openslide/src/main/java/qupath/lib/images/servers/openslide/OpenslideImageServer.java b/qupath-extension-openslide/src/main/java/qupath/lib/images/servers/openslide/OpenslideImageServer.java index abe08d017..a9b780edd 100644 --- a/qupath-extension-openslide/src/main/java/qupath/lib/images/servers/openslide/OpenslideImageServer.java +++ b/qupath-extension-openslide/src/main/java/qupath/lib/images/servers/openslide/OpenslideImageServer.java @@ -28,6 +28,7 @@ import java.awt.image.BufferedImage; import java.awt.image.DataBufferInt; import java.io.IOException; +import java.lang.ref.Cleaner; import java.net.URI; import java.nio.file.Files; import java.nio.file.Path; @@ -64,6 +65,30 @@ public class OpenslideImageServer extends AbstractTileableImageServer { private static final Logger logger = LoggerFactory.getLogger(OpenslideImageServer.class); + /** + * Use a Cleaner to ensure that the OpenSlide object is closed when the server is no longer needed. + * This is necessary because OpenSlide uses native resources that need to be released, + * and finalize() is both a bad idea and deprecated for removal. + */ + private static final class OpenSlideState implements Runnable { + + private final OpenSlide osr; + + private OpenSlideState(OpenSlide osr) { + this.osr = osr; + } + + @Override + public void run() { + logger.debug("Closing OpenSlide instance"); + osr.close(); + } + } + + private static final Cleaner cleaner = Cleaner.create(); + private final OpenSlideState state; + private final Cleaner.Cleanable cleanable; + private static boolean useBoundingBoxes = true; private ImageServerMetadata originalMetadata; @@ -121,6 +146,8 @@ public OpenslideImageServer(URI uri, String...args) throws IOException { osr = OpenSlideLoader.openImage(uri.toString()); name = null; } + state = new OpenSlideState(osr); + cleanable = cleaner.register(this, state); // Parse the parameters int width = (int)osr.getLevel0Width(); @@ -261,9 +288,7 @@ protected String createID() { @Override public void close() { - if (osr != null) { - osr.close(); - } + cleanable.clean(); } /** diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/images/servers/PathHierarchyImageServer.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/images/servers/PathHierarchyImageServer.java index 2452c2b1b..deb56f102 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/images/servers/PathHierarchyImageServer.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/images/servers/PathHierarchyImageServer.java @@ -176,10 +176,6 @@ public boolean isEmptyRegion(RegionRequest request) { return false; } - - @Override - public void close() {} - @Override public String getServerType() { return "Overlay"; From 280613d4d30a808cf0f55b29a6744da168bfd3ec Mon Sep 17 00:00:00 2001 From: Pete Date: Fri, 30 Aug 2024 21:36:32 +0100 Subject: [PATCH 258/866] Add Intersection-over-Minimum merger --- .../lib/objects/utils/ObjectMerger.java | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/qupath-core/src/main/java/qupath/lib/objects/utils/ObjectMerger.java b/qupath-core/src/main/java/qupath/lib/objects/utils/ObjectMerger.java index af5b1388e..bc077437c 100644 --- a/qupath-core/src/main/java/qupath/lib/objects/utils/ObjectMerger.java +++ b/qupath-core/src/main/java/qupath/lib/objects/utils/ObjectMerger.java @@ -393,6 +393,40 @@ private static BiPredicate createIoUMergeTest(double iouThre }; } + /** + * Create an object merger that can merge together any objects with sufficiently large intersection over minimum. + *

    + * Objects must also have the same classification and be on the same image plane to be mergeable. + *

    + * IoM is calculated using Java Topology Suite intersection, union, and getArea calls. + *

    + * This merger assumes that you are using an OutputHandler that doesn't clip to tile boundaries (only to region + * requests) and that you are using sufficient padding to ensure that objects are being detected in more than on + * tile/region request. + * You should probably also remove any objects that touch the regionRequest boundaries, as these will probably be + * clipped, and merging them will result in weirdly shaped detections. + * @param iomThreshold Intersection over minimum threshold; any pairs with values greater than or equal to this are merged. + * @return an object merger that can merge together any objects with sufficiently high IoM and the same classification + */ + public static ObjectMerger createIoMinMerger(double iomThreshold) { + return new ObjectMerger( + ObjectMerger::sameClassTypePlaneTest, + createIoMinMergeTest(iomThreshold), + 0.0625); + } + + private static BiPredicate createIoMinMergeTest(double iomThreshold) { + return (geom, geomOverlap) -> { + var i = geom.intersection(geomOverlap); + var intersection = i.getArea(); + double minArea = Math.min(geom.getArea(), geomOverlap.getArea()); + if (minArea == 0) { + return false; + } + return (intersection / minArea) >= iomThreshold; + }; + } + /** * Method to use as a predicate, indicating that two geometries have the same dimension and also touch. * @param geom From 0d33f85e5eb244f845637846ca7384516ba17066 Mon Sep 17 00:00:00 2001 From: Pete Date: Mon, 2 Sep 2024 10:28:34 +0100 Subject: [PATCH 259/866] Add IoM test --- .../lib/objects/utils/TestObjectMerger.java | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/qupath-core/src/test/java/qupath/lib/objects/utils/TestObjectMerger.java b/qupath-core/src/test/java/qupath/lib/objects/utils/TestObjectMerger.java index b2c780920..bf4d482a5 100644 --- a/qupath-core/src/test/java/qupath/lib/objects/utils/TestObjectMerger.java +++ b/qupath-core/src/test/java/qupath/lib/objects/utils/TestObjectMerger.java @@ -30,6 +30,7 @@ import qupath.lib.roi.interfaces.ROI; import java.util.Arrays; +import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -197,6 +198,33 @@ public void test_mergeBoundaryOverlappingNonMatchingTypes() { assertEquals(3, mergedByTouching.size()); } + @Test + public void test_intesectionOverMinimum() { + // Create objects with an IoM of 100%, 50%, 75%, and 25% + var poBase = createRectangleObject(0, 0, 100, 100); + var poIoM100 = createRectangleObject(0, 0, 100, 200); + var poIoM50 = createRectangleObject(50, 0, 100, 200); + var poIoM75 = createRectangleObject(25, 0, 100, 200); + var poIoM25 = createRectangleObject(75, 0, 100, 200); + var poIoM0 = createRectangleObject(100, 0, 100, 200); + + // Test for IoM of 0.5 + assertEquals(1, ObjectMerger.createIoMinMerger(0.5).merge(List.of(poBase, poIoM100)).size()); + assertEquals(1, ObjectMerger.createIoMinMerger(0.5).merge(List.of(poBase, poIoM75)).size()); + assertEquals(1, ObjectMerger.createIoMinMerger(0.5).merge(List.of(poBase, poIoM50)).size()); + assertEquals(2, ObjectMerger.createIoMinMerger(0.5).merge(List.of(poBase, poIoM25)).size()); + assertEquals(2, ObjectMerger.createIoMinMerger(0.5).merge(List.of(poBase, poIoM0)).size()); + + // Check close to the threshold + assertEquals(2, ObjectMerger.createIoMinMerger(0.5 + 1e-6).merge(List.of(poBase, poIoM50)).size()); + assertEquals(1, ObjectMerger.createIoMinMerger(0.5 - 1e-6).merge(List.of(poBase, poIoM50)).size()); + } + + private static PathObject createRectangleObject(double x, double y, double width, double height) { + return PathObjects.createDetectionObject( + ROIs.createRectangleROI(x, y, width, height, ImagePlane.getDefaultPlane())); + } + private static PathObject createAnnotation(double x, double y, String classification) { return createAnnotation(x, y, classification, ImagePlane.getDefaultPlane()); From 71a3a98571879e2a091e8d4d3b4b96cca4db67d4 Mon Sep 17 00:00:00 2001 From: Pete Date: Mon, 2 Sep 2024 10:53:18 +0100 Subject: [PATCH 260/866] Improve IoMin javadocs Clarify that ROIs with zero area are not merged (including lines and empty objects). --- .../qupath/lib/objects/utils/ObjectMerger.java | 14 +++++++++++--- .../qupath/lib/objects/utils/TestObjectMerger.java | 4 ++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/qupath-core/src/main/java/qupath/lib/objects/utils/ObjectMerger.java b/qupath-core/src/main/java/qupath/lib/objects/utils/ObjectMerger.java index bc077437c..c12e74215 100644 --- a/qupath-core/src/main/java/qupath/lib/objects/utils/ObjectMerger.java +++ b/qupath-core/src/main/java/qupath/lib/objects/utils/ObjectMerger.java @@ -394,7 +394,12 @@ private static BiPredicate createIoUMergeTest(double iouThre } /** - * Create an object merger that can merge together any objects with sufficiently large intersection over minimum. + * Create an object merger that can merge together any objects with sufficiently large intersection over minimum + * area (IoMin). + * This is similar to IoU, but uses the minimum area of the two objects as the denominator. + *

    + * This is useful in the (common) case where we are happy for small objects falling within larger objects to be + * swallowed up by the larger object. *

    * Objects must also have the same classification and be on the same image plane to be mergeable. *

    @@ -407,6 +412,7 @@ private static BiPredicate createIoUMergeTest(double iouThre * clipped, and merging them will result in weirdly shaped detections. * @param iomThreshold Intersection over minimum threshold; any pairs with values greater than or equal to this are merged. * @return an object merger that can merge together any objects with sufficiently high IoM and the same classification + * @implNote This method does not currently merge objects with zero area. It is assumed that they will be handled separately. */ public static ObjectMerger createIoMinMerger(double iomThreshold) { return new ObjectMerger( @@ -417,12 +423,14 @@ public static ObjectMerger createIoMinMerger(double iomThreshold) { private static BiPredicate createIoMinMergeTest(double iomThreshold) { return (geom, geomOverlap) -> { - var i = geom.intersection(geomOverlap); - var intersection = i.getArea(); double minArea = Math.min(geom.getArea(), geomOverlap.getArea()); + // If the minimum area is zero, then we can't calculate the IoM + // Here, we don't merge - assuming that empty ROIs should be handled separately if (minArea == 0) { return false; } + var i = geom.intersection(geomOverlap); + var intersection = i.getArea(); return (intersection / minArea) >= iomThreshold; }; } diff --git a/qupath-core/src/test/java/qupath/lib/objects/utils/TestObjectMerger.java b/qupath-core/src/test/java/qupath/lib/objects/utils/TestObjectMerger.java index bf4d482a5..1d37447de 100644 --- a/qupath-core/src/test/java/qupath/lib/objects/utils/TestObjectMerger.java +++ b/qupath-core/src/test/java/qupath/lib/objects/utils/TestObjectMerger.java @@ -207,6 +207,7 @@ public void test_intesectionOverMinimum() { var poIoM75 = createRectangleObject(25, 0, 100, 200); var poIoM25 = createRectangleObject(75, 0, 100, 200); var poIoM0 = createRectangleObject(100, 0, 100, 200); + var poEmpty = createRectangleObject(50, 0, 0, 0); // Test for IoM of 0.5 assertEquals(1, ObjectMerger.createIoMinMerger(0.5).merge(List.of(poBase, poIoM100)).size()); @@ -215,6 +216,9 @@ public void test_intesectionOverMinimum() { assertEquals(2, ObjectMerger.createIoMinMerger(0.5).merge(List.of(poBase, poIoM25)).size()); assertEquals(2, ObjectMerger.createIoMinMerger(0.5).merge(List.of(poBase, poIoM0)).size()); + // Empty object always retained + assertEquals(2, ObjectMerger.createIoMinMerger(0.5).merge(List.of(poBase, poEmpty)).size()); + // Check close to the threshold assertEquals(2, ObjectMerger.createIoMinMerger(0.5 + 1e-6).merge(List.of(poBase, poIoM50)).size()); assertEquals(1, ObjectMerger.createIoMinMerger(0.5 - 1e-6).merge(List.of(poBase, poIoM50)).size()); From dcdf88d858ae032d065a5e5d1780ec018896981c Mon Sep 17 00:00:00 2001 From: Alan O'Callaghan Date: Mon, 2 Sep 2024 16:00:19 +0100 Subject: [PATCH 261/866] Fix sorting and behaviour when classes are added --- .../lib/gui/commands/PathObjectGridView.java | 142 +++++++++++------- .../qupath-gui-strings.properties | 9 +- 2 files changed, 95 insertions(+), 56 deletions(-) diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/PathObjectGridView.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/PathObjectGridView.java index 7b4e3435e..ba8f200fd 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/PathObjectGridView.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/PathObjectGridView.java @@ -81,11 +81,13 @@ import java.awt.image.BufferedImage; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.Comparator; import java.util.List; import java.util.Map.Entry; +import java.util.Objects; import java.util.Set; import java.util.WeakHashMap; import java.util.concurrent.ForkJoinPool; @@ -113,7 +115,7 @@ public class PathObjectGridView implements ChangeListener comboMeasurement; + private ComboBox comboSortBy; private final ObservableList backingList = FXCollections.observableArrayList(); private final FilteredList filteredList = new FilteredList<>(backingList); @@ -250,40 +252,47 @@ public void refresh() { public ObjectProperty> imageDataProperty() { return imageDataProperty; } - + + private static void sortPathObjects(final ObservableList cores, final ObservableMeasurementTableData model, final String measurementName, final boolean doDescending) { if (measurementName == null) return; + + Comparator sorter; + if (measurementName.equals(QuPathResources.getString("GridView.classification"))) { - cores.sort((po1, po2) -> { - if (po1.getPathClass() == null || po2.getPathClass() == null) return 0; - Comparator comp = Comparator.comparing((po) -> po.getPathClass().toString()); - comp = doDescending ? comp.reversed() : comp; + sorter = (po1, po2) -> { + Comparator comp = Comparator.comparing(po -> po.getPathClass() == null ? "Unclassified" : po.getPathClass().toString()); return comp.compare(po1, po2); - }); - return; - } - cores.sort((po1, po2) -> { - double m1 = model.getNumericValue(po1, measurementName); - double m2 = model.getNumericValue(po2, measurementName); - int comp; - if (doDescending) - comp = -Double.compare(m1, m2); - else + }; + } else if (measurementName.equals(QuPathResources.getString("GridView.name"))) { + sorter = (po1, po2) -> { + Comparator comp = Comparator.comparing(PathObject::getDisplayedName); + return comp.compare(po1, po2); + }; + } else { + // if it's a measurement, then we're numeric sorting + sorter = (po1, po2) -> { + double m1 = model.getNumericValue(po1, measurementName); + double m2 = model.getNumericValue(po2, measurementName); + int comp; comp = Double.compare(m1, m2); - if (comp == 0) { - if (Double.isNaN(m1) && !Double.isNaN(m2)) - return doDescending ? 1 : -1; - if (Double.isNaN(m2) && !Double.isNaN(m1)) - return doDescending ? -1 : 1; - - if (doDescending) - comp = po2.getDisplayedName().compareTo(po1.getDisplayedName()); - else + // resolve ties by checking missingness, and then names + if (comp == 0) { + // todo: should missing values always be last? + if (Double.isNaN(m1) && !Double.isNaN(m2)) + return 1; + if (Double.isNaN(m2) && !Double.isNaN(m1)) + return -1; comp = po1.getDisplayedName().compareTo(po2.getDisplayedName()); - } - return comp; - }); + } + return comp; + }; + } + if (doDescending) { + sorter = sorter.reversed(); + } + cores.sort(sorter); } @@ -333,8 +342,8 @@ private void initializeData(ImageData imageData) { // Select the first measurement if necessary var names = model.getMeasurementNames(); if (m == null || !names.contains(m)) { - if (!comboMeasurement.getItems().isEmpty()) - comboMeasurement.getSelectionModel().selectFirst(); + if (!comboSortBy.getItems().isEmpty()) + comboSortBy.getSelectionModel().selectFirst(); } } @@ -358,10 +367,13 @@ private void initializeGUI() { descending.bind(Bindings.createBooleanBinding(() -> QuPathResources.getString("GridView.descending").equals(comboOrder.getSelectionModel().getSelectedItem()), comboOrder.getSelectionModel().selectedItemProperty())); - + descending.addListener((v, o, n) -> sortAndFilter()); + comboOrder.getSelectionModel().selectedItemProperty().addListener((v, o, n) -> sortAndFilter()); - comboMeasurement = new ComboBox<>(); - comboMeasurement.setPlaceholder(createPlaceholderText(QuPathResources.getString("GridView.noMeasurements"))); + comboSortBy = new ComboBox<>(); + // todo: never needed now because we always have class? + comboSortBy.setPlaceholder(createPlaceholderText(QuPathResources.getString("GridView.noMeasurements"))); + comboSortBy.getSelectionModel().selectedItemProperty().addListener((v, o, n) -> sortAndFilter()); var measureNames = model.getMeasurementNames(); ObservableList measureList = FXCollections.observableArrayList(measureNames); @@ -371,16 +383,14 @@ private void initializeGUI() { measureList.addAll(measureNames); }); measureList.add(QuPathResources.getString("GridView.classification")); - comboMeasurement.setItems(measureList); - if (!comboMeasurement.getItems().isEmpty()) - comboMeasurement.getSelectionModel().select(0); - - measurement.bind(comboMeasurement.getSelectionModel().selectedItemProperty()); + measureList.add(QuPathResources.getString("GridView.name")); + comboSortBy.setItems(measureList); + if (!comboSortBy.getItems().isEmpty()) + comboSortBy.getSelectionModel().select(0); - addSortAndFilterer(comboOrder); - addSortAndFilterer(comboMeasurement); + measurement.bind(comboSortBy.getSelectionModel().selectedItemProperty()); - CheckBox cbShowMeasurement = new CheckBox(QuPathResources.getString("GridView.showMeasurement")); + CheckBox cbShowMeasurement = new CheckBox(QuPathResources.getString("GridView.showValue")); showMeasurement.bind(cbShowMeasurement.selectedProperty()); showMeasurement.addListener(c -> updateMeasurement()); // Force an update @@ -392,6 +402,10 @@ private void initializeGUI() { selectedClasses = classComboBox.getCheckModel().getCheckedItems(); selectedClasses.addListener((ListChangeListener) c -> sortAndFilter()); FXUtils.installSelectAllOrNoneMenu(classComboBox); + classComboBox.getCheckModel().getCheckedItems().addListener((ListChangeListener) c -> { + classComboBox.setTitle(getCheckComboBoxText(classComboBox)); + }); + updateClasses(classComboBox); qupath.getImageData().getHierarchy().addListener(event -> updateClasses(classComboBox)); @@ -401,8 +415,8 @@ private void initializeGUI() { BorderPane pane = new BorderPane(); ToolBar paneTop = new ToolBar(); - paneTop.getItems().add(new Label(QuPathResources.getString("GridView.measurement"))); - paneTop.getItems().add(comboMeasurement); + paneTop.getItems().add(new Label(QuPathResources.getString("GridView.sortBy"))); + paneTop.getItems().add(comboSortBy); paneTop.getItems().add(new Separator(Orientation.VERTICAL)); paneTop.getItems().add(new Label(QuPathResources.getString("GridView.order"))); paneTop.getItems().add(comboOrder); @@ -423,7 +437,7 @@ private void initializeGUI() { } } - comboMeasurement.setMaxWidth(Double.MAX_VALUE); + comboSortBy.setMaxWidth(Double.MAX_VALUE); comboOrder.setMaxWidth(Double.MAX_VALUE); pane.setTop(paneTop); @@ -446,21 +460,34 @@ private void initializeGUI() { } private void updateClasses(CheckComboBox classComboBox) { - Set representedClasses = qupath.getImageData().getHierarchy().getFlattenedObjectList(null).stream() + // if a new class is added to the hierarchy, then update the list but leave the set of checked classes unchanged + var previouslyChecked = new ArrayList<>(classComboBox.getCheckModel().getCheckedItems()); + List representedClasses = qupath.getImageData().getHierarchy().getFlattenedObjectList(null).stream() .filter(p -> !p.isRootObject()) .map(PathObject::getPathClass) .filter(p -> p != null && p != PathClass.NULL_CLASS) - .collect(Collectors.toSet()); + .distinct() + .collect(Collectors.toList()); + representedClasses.add(PathClass.NULL_CLASS); classComboBox.getItems().clear(); classComboBox.getItems().addAll(representedClasses); + classComboBox.getCheckModel().clearChecks(); + int[] inds = previouslyChecked.stream().mapToInt(representedClasses::indexOf).toArray(); + classComboBox.getCheckModel().checkIndices(inds); } - private void addSortAndFilterer(ComboBox comboMeasurement) { - comboMeasurement.getSelectionModel().selectedItemProperty().addListener((v, o, n) -> { - sortAndFilter(); - }); - } + private static String getCheckComboBoxText(CheckComboBox comboBox) { + int n = comboBox.getCheckModel().getCheckedItems().stream() + .filter(Objects::nonNull) + .toList() + .size(); + if (n == 0) + return QuPathResources.getString("GridView.noClassSelected"); + if (n == 1) + return comboBox.getCheckModel().getCheckedItems().getFirst().toString(); + return String.format(QuPathResources.getString("GridView.nClassSelected"), n); + } private void updateMeasurement() { sortAndFilter(); @@ -470,8 +497,13 @@ private void sortAndFilter() { String m = measurement.getValue(); sortPathObjects(backingList, model, m, descending.get()); filteredList.setPredicate(p -> - (m == null || m.equals(QuPathResources.getString("GridView.classification")) || !isMissingCore(p)) && - (selectedClasses.contains(p.getPathClass()) || p.getPathClass() == null) + // no measurement selected, we're going by classification or name, or it's not missing, then keep + (m == null + || m.equals(QuPathResources.getString("GridView.classification")) + || m.equals(QuPathResources.getString("GridView.name")) + || !isMissingCore(p)) + // pathclass is present and selected, or missing and we're showing unclassifier + && (selectedClasses.contains(p.getPathClass()) || (p.getPathClass() == null && selectedClasses.contains(PathClass.NULL_CLASS))) ); grid.getItems().setAll(filteredList); } @@ -598,8 +630,10 @@ void updateMeasurementText() { else { if (m.equals(QuPathResources.getString("GridView.classification"))) { PathClass pc = entry.getKey().getPathClass(); - String text = pc == null ? "Unclassified" : pc.toString(); + String text = pc == null ? PathClass.getNullClass().toString() : pc.toString(); entry.getValue().setText(text); + } else if (m.equals(QuPathResources.getString("GridView.name"))) { + entry.getValue().setText(entry.getKey().getDisplayedName()); } else { double val = model.getNumericValue(entry.getKey(), m); entry.getValue().setText(GeneralTools.formatNumber(val, 3)); diff --git a/qupath-gui-fx/src/main/resources/qupath/lib/gui/localization/qupath-gui-strings.properties b/qupath-gui-fx/src/main/resources/qupath/lib/gui/localization/qupath-gui-strings.properties index 8c859eeeb..00bdf6ea6 100644 --- a/qupath-gui-fx/src/main/resources/qupath/lib/gui/localization/qupath-gui-strings.properties +++ b/qupath-gui-fx/src/main/resources/qupath/lib/gui/localization/qupath-gui-strings.properties @@ -978,12 +978,17 @@ GridView.TMAGridView = TMA core grid view GridView.AnnotationGridView = Annotation object grid view GridView.noObjectsAvailable = No objects available! GridView.classification = Classification +GridView.unclassified = Unclassified +GridView.name = Name GridView.ascending = Ascending GridView.descending = Descending GridView.noMeasurements = No measurements! -GridView.showMeasurement = Show measurement +GridView.showValue = Show value GridView.animate = Animate -GridView.measurement = Measurement +GridView.sortBy = Sort by GridView.order = Order GridView.size = Size GridView.classes = Classes +GridView.noClassSelected = No classes selected +GridView.nClassSelected = %d classes selected + From e7734cb9a912a6278e956de6bde66dedb6d95fb4 Mon Sep 17 00:00:00 2001 From: Pete Date: Mon, 2 Sep 2024 18:59:23 +0100 Subject: [PATCH 262/866] Normalize converted geometries Comparison requires normalized geometries, so we may save some time by doing this more eagerly. --- .../src/main/java/qupath/lib/roi/GeometryTools.java | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/qupath-core/src/main/java/qupath/lib/roi/GeometryTools.java b/qupath-core/src/main/java/qupath/lib/roi/GeometryTools.java index 782d3fbc2..2817bbafc 100644 --- a/qupath-core/src/main/java/qupath/lib/roi/GeometryTools.java +++ b/qupath-core/src/main/java/qupath/lib/roi/GeometryTools.java @@ -897,15 +897,20 @@ private GeometryConverter(final GeometryFactory factory, final double pixelWidth * * @param roi * @return + * @implNote since v0.6.0 this returns a normalized geometry. */ public Geometry roiToGeometry(ROI roi) { + Geometry geom = null; if (roi.isPoint()) - return pointsToGeometry(roi); + geom = pointsToGeometry(roi); if (roi.isArea()) - return areaToGeometry(roi); + geom = areaToGeometry(roi); if (roi.isLine()) - return lineToGeometry(roi); - throw new UnsupportedOperationException("Unknown ROI " + roi + " - cannot convert to a Geometry!"); + geom = lineToGeometry(roi); + if (geom == null) + throw new UnsupportedOperationException("Unknown ROI " + roi + " - cannot convert to a Geometry!"); + else + return geom.norm(); } private Geometry lineToGeometry(ROI roi) { From e5bb308bcb4dd0e7900260e64897290a026f1703 Mon Sep 17 00:00:00 2001 From: Pete Date: Mon, 2 Sep 2024 18:59:36 +0100 Subject: [PATCH 263/866] Add more PixelProcessor comments --- .../java/qupath/lib/experimental/pixels/PixelProcessor.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/qupath-core-processing/src/main/java/qupath/lib/experimental/pixels/PixelProcessor.java b/qupath-core-processing/src/main/java/qupath/lib/experimental/pixels/PixelProcessor.java index 3ccb834eb..6fd901310 100644 --- a/qupath-core-processing/src/main/java/qupath/lib/experimental/pixels/PixelProcessor.java +++ b/qupath-core-processing/src/main/java/qupath/lib/experimental/pixels/PixelProcessor.java @@ -194,13 +194,17 @@ private void processTiled(TaskRunner runner, Tiler tiler, ImageData mergeTasks = new ArrayList<>(); for (var entry : tempObjects.entrySet()) { + // Get the original parent object var pathObject = entry.getKey(); + // Get all new objects detected from the tile var proxyList = entry.getValue().stream() .flatMap(proxy -> proxy.getChildObjects().stream()) .toList(); if (merger != null) { + // Use the merger if we have one mergeTasks.add(() -> mergeAndAddObjects(merger, pathObject, proxyList)); } else { + // Just add the new objects if we have no merger pathObject.clearChildObjects(); pathObject.addChildObjects(proxyList); pathObject.setLocked(true); From 3da46a42a89a7aeade7bc07551a4c0ee0f43c077 Mon Sep 17 00:00:00 2001 From: Pete Date: Mon, 2 Sep 2024 19:00:32 +0100 Subject: [PATCH 264/866] Provisional OverlapFixer Initial code to provide different ways to handle overlapping objects. Despite the various caches, the performance is still not good enough... --- .../lib/objects/utils/OverlapFixer.java | 300 ++++++++++++++++++ 1 file changed, 300 insertions(+) create mode 100644 qupath-core/src/main/java/qupath/lib/objects/utils/OverlapFixer.java diff --git a/qupath-core/src/main/java/qupath/lib/objects/utils/OverlapFixer.java b/qupath-core/src/main/java/qupath/lib/objects/utils/OverlapFixer.java new file mode 100644 index 000000000..aef5cc51c --- /dev/null +++ b/qupath-core/src/main/java/qupath/lib/objects/utils/OverlapFixer.java @@ -0,0 +1,300 @@ +package qupath.lib.objects.utils; + +import org.locationtech.jts.geom.Envelope; +import org.locationtech.jts.geom.Geometry; +import org.locationtech.jts.geom.prep.PreparedGeometryFactory; +import org.locationtech.jts.index.quadtree.Quadtree; +import qupath.lib.objects.DefaultPathObjectComparator; +import qupath.lib.objects.PathObject; +import qupath.lib.objects.PathObjectTools; +import qupath.lib.roi.RoiTools; +import qupath.lib.roi.interfaces.ROI; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Supplier; +import java.util.stream.Collectors; + +public class OverlapFixer { + + public enum Strategy { + KEEP_OVERLAPS, + DROP_OVERLAPS, + CLIP_OVERLAPS + } + + private enum ComparatorType { + AREA, + SOLIDITY + } + + /** + * Comparator to choose which object to keep unchanged when two or more overlap. + * Objects returned first are kept unchanged, objects returned later are dropped or clipped. + */ + private final Supplier> comparatorSupplier; + + /** + * Minimum area for objects to be retained. + */ + private final double minArea; + + private final Strategy strategy; + + private OverlapFixer(Strategy strategy, double minArea, Supplier> comparatorSupplier) { + this.strategy = strategy; + this.minArea = minArea; + this.comparatorSupplier = comparatorSupplier; + } + + + + public List fix(Collection pathObjects) { + var index = new Quadtree(); + + // Sort objects in *reverse* order using the comparator. + // This is because removals from the end of the list are faster than the start. + // We don't use a queue because then we can't insert objects in the middle, + // and we don't use a priority queue because it doesn't have great performance. + var comparator = comparatorSupplier.get(); + var reversedComparator = comparator.reversed(); + List list = pathObjects.parallelStream() + .filter(p -> p.hasROI() && p.getROI().getArea() >= minArea) + .sorted(reversedComparator) + .collect(Collectors.toCollection(ArrayList::new)); + + // Nothing else to do if we're keeping overlaps + if (strategy == Strategy.KEEP_OVERLAPS) { + return list; + } + + // Precompute envelopes - it's better to do it in parallel since it requests geometries, + // which can sometimes be expensive to compute + GeometryCache cache = new GeometryCache(); + pathObjects.parallelStream().forEach(cache::add); + + // Build the spatial index + for (var pathObject : pathObjects) { + index.insert(cache.getEnvelope(pathObject), pathObject); + } + + // Query the index to find overlapping objects + // We are iterating in order of the objects we want to keep + List output = new ArrayList<>(); + while (!list.isEmpty()) { + PathObject pathObject = list.removeLast(); + var envelope = cache.getEnvelope(pathObject); + // Query returns *potentially* overlapping objects (including the current object), + // but we can proceed quickly if there are no others + List overlapping = index.query(envelope); + if (!overlapping.contains(pathObject)) { + // Object has already been removed - skip + continue; + } + // Keep this object + output.add(pathObject); + if (overlapping.size() > 1) { + // Perform stricter overlap check + var geom = cache.getGeometry(pathObject); + if (overlapping.size() > 2) { + var prepared = PreparedGeometryFactory.prepare(geom); + overlapping = overlapping.stream() + .filter(p -> p != pathObject) + .filter(p -> prepared.overlaps(cache.getGeometry(p)) || geom.equalsExact(cache.getGeometry(p))) + .sorted(comparator) + .toList(); + } else { + overlapping = overlapping.stream() + .filter(p -> p != pathObject) + .filter(p -> geom.overlaps(cache.getGeometry(p)) || geom.equalsExact(cache.getGeometry(p))) + .sorted(comparator) + .toList(); + } + } + if (overlapping.isEmpty()) { + // No overlaps - continue + continue; + } + // Drop all overlapping objects + // We only need to remove them from the index (to avoid the cost of removing them from the list) + for (var overlap : overlapping) { + index.remove(cache.getEnvelope(overlap), overlap); + } + if (strategy == Strategy.CLIP_OVERLAPS) { + // Clip overlapping objects, inserting them back into the list if they are big enough + List previousROIs = new ArrayList<>(); + previousROIs.add(pathObject.getROI()); + for (var overlap : overlapping) { + // Subtract the union from the current object + ROI roiCurrent = overlap.getROI(); + roiCurrent = RoiTools.subtract(roiCurrent, previousROIs); + ROI roiNucleus = PathObjectTools.getNucleusROI(overlap); + if (roiNucleus != null) { + roiNucleus = RoiTools.subtract(roiNucleus, previousROIs); + } + // Retain the ROI if it is big enough + if (!roiCurrent.isEmpty() && roiCurrent.isArea() && roiCurrent.getArea() >= minArea) { + var clippedObject = PathObjectTools.createLike(pathObject, roiCurrent, roiNucleus); + output.add(clippedObject); + index.insert(cache.getEnvelope(clippedObject), clippedObject); + previousROIs.add(roiCurrent); + // Insert into the list, while ensuring it remains sorted + int ind = Collections.binarySearch(list, clippedObject, reversedComparator); + if (ind >= 0) { + list.add(ind, clippedObject); + } else { + list.add(-ind - 1, clippedObject); + } + } + } + } + } + return output; + } + + /** + * A cache of normalized geometries and envelopes. + * This can be useful for a short time to avoid unnecessary recomputation. + */ + private static class GeometryCache { + + private final Map geometryMap = new ConcurrentHashMap<>(); + private final Map envelopMap = new ConcurrentHashMap<>(); + + private void add(PathObject pathObject) { + var roi = pathObject.getROI(); + var geom = roi.getGeometry().norm(); + geometryMap.put(roi, geom); + envelopMap.put(roi, getEnvelope(pathObject)); + } + + private Geometry getGeometry(PathObject pathObject) { + return getGeometry(pathObject.getROI()); + } + + private Geometry getGeometry(ROI roi) { + return geometryMap.computeIfAbsent(roi, r -> r.getGeometry().norm()); + } + + private Envelope getEnvelope(PathObject pathObject) { + return getEnvelope(pathObject.getROI()); + } + + private Envelope getEnvelope(ROI roi) { + return envelopMap.computeIfAbsent(roi, r -> getGeometry(r).getEnvelopeInternal()); + } + + } + + + /** + * Create a new builder for the OverlapFixer. + * @return + */ + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + + private Supplier> comparator = () -> Comparators.createAreaFirstComparator(); + + private double minArea = Double.NEGATIVE_INFINITY; + + private Strategy strategy = Strategy.CLIP_OVERLAPS; + + private Builder() {} + + public Builder setComparator(Comparator comparator) { + this.comparator = () -> comparator; + return this; + } + + public Builder setMinArea(double minArea) { + this.minArea = minArea; + return this; + } + + public Builder sortBySolidity() { + this.comparator = () -> Comparators.createSolidityFirstComparator(); + return this; + } + + public Builder sortByArea() { + this.comparator = () -> Comparators.createAreaFirstComparator(); + return this; + } + + public Builder setStrategy(Strategy strategy) { + this.strategy = strategy; + return this; + } + + public Builder clipOverlaps() { + this.strategy = Strategy.CLIP_OVERLAPS; + return this; + } + + public Builder dropOverlaps() { + this.strategy = Strategy.DROP_OVERLAPS; + return this; + } + + public OverlapFixer build() { + return new OverlapFixer(strategy, minArea, comparator); + } + + } + + + + private static class Comparators { + + private static Comparator createAreaFirstComparator() { + var c = new Comparators(); + return c.compareByArea() + .thenComparing(c.compareByLength()) + .thenComparing(c.compareByPoints()) + .thenComparing(DefaultPathObjectComparator.getInstance()); + } + + private static Comparator createSolidityFirstComparator() { + var c = new Comparators(); + return c.compareBySolidity() + .thenComparing(c.compareByArea()) + .thenComparing(c.compareByLength()) + .thenComparing(c.compareByPoints()) + .thenComparing(DefaultPathObjectComparator.getInstance()); + } + + private Map solidityMap = new ConcurrentHashMap<>(); + private Map areaMap = new ConcurrentHashMap<>(); + private Map lengthMap = new ConcurrentHashMap<>(); + private Map pointsMap = new ConcurrentHashMap<>(); + + // We use negative values as a cheap way to sort in descending order + + private Comparator compareBySolidity() { + return Comparator.comparingDouble(p -> -solidityMap.computeIfAbsent(p.getROI(), ROI::getSolidity)); + } + + private Comparator compareByArea() { + return Comparator.comparingDouble(p -> -areaMap.computeIfAbsent(p.getROI(), ROI::getArea)); + } + + private Comparator compareByLength() { + return Comparator.comparingDouble(p -> -lengthMap.computeIfAbsent(p.getROI(), ROI::getArea)); + } + + private Comparator compareByPoints() { + return Comparator.comparingInt(p -> -pointsMap.computeIfAbsent(p.getROI(), ROI::getNumPoints)); + } + + } + +} From cc7aa02cfb75cfbca5f92ee0ce77a2d2ad9b1ea4 Mon Sep 17 00:00:00 2001 From: Pete Date: Mon, 2 Sep 2024 21:13:28 +0100 Subject: [PATCH 265/866] Improved OverlapFixer More comments and (much) better performance. --- .../lib/objects/utils/ObjectMerger.java | 2 +- .../lib/objects/utils/OverlapFixer.java | 237 +++++++++++++++--- 2 files changed, 201 insertions(+), 38 deletions(-) diff --git a/qupath-core/src/main/java/qupath/lib/objects/utils/ObjectMerger.java b/qupath-core/src/main/java/qupath/lib/objects/utils/ObjectMerger.java index c12e74215..d108d63bf 100644 --- a/qupath-core/src/main/java/qupath/lib/objects/utils/ObjectMerger.java +++ b/qupath-core/src/main/java/qupath/lib/objects/utils/ObjectMerger.java @@ -2,7 +2,7 @@ * #%L * This file is part of QuPath. * %% - * Copyright (C) 2023 QuPath developers, The University of Edinburgh + * Copyright (C) 2023-2024 QuPath developers, The University of Edinburgh * %% * QuPath is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as diff --git a/qupath-core/src/main/java/qupath/lib/objects/utils/OverlapFixer.java b/qupath-core/src/main/java/qupath/lib/objects/utils/OverlapFixer.java index aef5cc51c..99b48ccec 100644 --- a/qupath-core/src/main/java/qupath/lib/objects/utils/OverlapFixer.java +++ b/qupath-core/src/main/java/qupath/lib/objects/utils/OverlapFixer.java @@ -1,9 +1,34 @@ +/*- + * #%L + * This file is part of QuPath. + * %% + * Copyright (C) 2024 QuPath developers, The University of Edinburgh + * %% + * QuPath 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 3 of the + * License, or (at your option) any later version. + * + * QuPath 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 QuPath. If not, see . + * #L% + */ + package qupath.lib.objects.utils; import org.locationtech.jts.geom.Envelope; import org.locationtech.jts.geom.Geometry; import org.locationtech.jts.geom.prep.PreparedGeometryFactory; +import org.locationtech.jts.index.SpatialIndex; import org.locationtech.jts.index.quadtree.Quadtree; +import org.locationtech.jts.index.strtree.STRtree; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import qupath.lib.objects.DefaultPathObjectComparator; import qupath.lib.objects.PathObject; import qupath.lib.objects.PathObjectTools; @@ -16,12 +41,15 @@ import java.util.Comparator; import java.util.List; import java.util.Map; +import java.util.TreeSet; import java.util.concurrent.ConcurrentHashMap; import java.util.function.Supplier; import java.util.stream.Collectors; public class OverlapFixer { + private static final Logger logger = LoggerFactory.getLogger(OverlapFixer.class); + public enum Strategy { KEEP_OVERLAPS, DROP_OVERLAPS, @@ -44,29 +72,32 @@ private enum ComparatorType { */ private final double minArea; + private final boolean keepFragments; + private final Strategy strategy; - private OverlapFixer(Strategy strategy, double minArea, Supplier> comparatorSupplier) { + private OverlapFixer(Strategy strategy, double minArea, Supplier> comparatorSupplier, boolean keepFragments) { this.strategy = strategy; this.minArea = minArea; this.comparatorSupplier = comparatorSupplier; + this.keepFragments = keepFragments; } - - + /** + * Fix overlaps in a collection of PathObjects, by the criteria specified in the builder. + * This method is thread-safe. + * @param pathObjects the input objects + * @return the output objects. This may be the same as the input objects, or contain fewer objects - + * possibly with new (clipped) ROIs - but no object will be added or have its properties changed. + */ public List fix(Collection pathObjects) { - var index = new Quadtree(); - // Sort objects in *reverse* order using the comparator. - // This is because removals from the end of the list are faster than the start. - // We don't use a queue because then we can't insert objects in the middle, - // and we don't use a priority queue because it doesn't have great performance. - var comparator = comparatorSupplier.get(); - var reversedComparator = comparator.reversed(); + int nInput = pathObjects.size(); + + // Apply the area filter List list = pathObjects.parallelStream() .filter(p -> p.hasROI() && p.getROI().getArea() >= minArea) - .sorted(reversedComparator) - .collect(Collectors.toCollection(ArrayList::new)); + .collect(Collectors.toList()); // Nothing else to do if we're keeping overlaps if (strategy == Strategy.KEEP_OVERLAPS) { @@ -78,16 +109,43 @@ public List fix(Collection pathObjects) { GeometryCache cache = new GeometryCache(); pathObjects.parallelStream().forEach(cache::add); - // Build the spatial index + // Build a first spatial index for efficient overlap detection - this can be immutable for performance + SpatialIndex immutableIndex = new STRtree(); + populateSpatialIndex(pathObjects, immutableIndex, cache); for (var pathObject : pathObjects) { - index.insert(cache.getEnvelope(pathObject), pathObject); + immutableIndex.insert(cache.getEnvelope(pathObject), pathObject); } + // Split the objects into two groups: those with overlaps and those without + // Ensure the outputs are ArrayLists so that they are modifiable + Map> overlapMap = pathObjects.parallelStream() + .collect( + Collectors.groupingBy(p -> containsOverlaps(p, immutableIndex, cache), + Collectors.toCollection(ArrayList::new))); + + // Initialize the output to contain all objects with no overlaps + List output = overlapMap.computeIfAbsent(Boolean.FALSE, b -> new ArrayList<>()); + + // If we've got no objects with overlaps, we're done + if (overlapMap.getOrDefault(Boolean.TRUE, Collections.emptyList()).isEmpty()) { + logger.debug("No overlaps found in {} objects", nInput); + return output; + } + + // Create a sorted set to store the objects to process, ordered using the comparator + var comparator = comparatorSupplier.get(); + var toProcess = new TreeSet<>(comparator); + toProcess.addAll(overlapMap.get(Boolean.TRUE)); + + // Build a new (hopefully much smaller!) modifiable spatial index for the objects with overlaps + // This must be mutable, so that we can both remove and add objects + SpatialIndex index = new Quadtree(); + populateSpatialIndex(toProcess, index, cache); + // Query the index to find overlapping objects // We are iterating in order of the objects we want to keep - List output = new ArrayList<>(); - while (!list.isEmpty()) { - PathObject pathObject = list.removeLast(); + while (!toProcess.isEmpty()) { + PathObject pathObject = toProcess.removeFirst(); var envelope = cache.getEnvelope(pathObject); // Query returns *potentially* overlapping objects (including the current object), // but we can proceed quickly if there are no others @@ -122,8 +180,12 @@ public List fix(Collection pathObjects) { } // Drop all overlapping objects // We only need to remove them from the index (to avoid the cost of removing them from the list) + // TODO: Consider looking for non-overlapping clusters of objects to handle together for (var overlap : overlapping) { - index.remove(cache.getEnvelope(overlap), overlap); + if (!index.remove(cache.getEnvelope(overlap), overlap)) { + logger.warn("Failed to remove object from index: " + overlap); + } + toProcess.remove(overlap); } if (strategy == Strategy.CLIP_OVERLAPS) { // Clip overlapping objects, inserting them back into the list if they are big enough @@ -132,31 +194,55 @@ public List fix(Collection pathObjects) { for (var overlap : overlapping) { // Subtract the union from the current object ROI roiCurrent = overlap.getROI(); - roiCurrent = RoiTools.subtract(roiCurrent, previousROIs); + int nPiecesOriginally = roiCurrent.getGeometry().getNumGeometries(); + var roiUpdated = RoiTools.subtract(roiCurrent, previousROIs); ROI roiNucleus = PathObjectTools.getNucleusROI(overlap); if (roiNucleus != null) { roiNucleus = RoiTools.subtract(roiNucleus, previousROIs); } - // Retain the ROI if it is big enough - if (!roiCurrent.isEmpty() && roiCurrent.isArea() && roiCurrent.getArea() >= minArea) { - var clippedObject = PathObjectTools.createLike(pathObject, roiCurrent, roiNucleus); - output.add(clippedObject); - index.insert(cache.getEnvelope(clippedObject), clippedObject); - previousROIs.add(roiCurrent); - // Insert into the list, while ensuring it remains sorted - int ind = Collections.binarySearch(list, clippedObject, reversedComparator); - if (ind >= 0) { - list.add(ind, clippedObject); - } else { - list.add(-ind - 1, clippedObject); + // Only keep the object if it is big enough, and optionally check the number of fragments + int nPieces = roiUpdated.getGeometry().getNumGeometries(); + if (keepFragments || nPieces <= nPiecesOriginally) { + if (!roiUpdated.isEmpty() && roiUpdated.isArea() && roiUpdated.getArea() >= minArea) { + var clippedObject = PathObjectTools.createLike(pathObject, roiUpdated, roiNucleus); + // Don't add clipped objects to the output list! + // Rather, add to the set of objects to process & index - and they *might* end up in the output + index.insert(cache.getEnvelope(clippedObject), clippedObject); + previousROIs.add(roiCurrent); + toProcess.add(clippedObject); } } } } } + logger.debug("Processed {} objects to fix overlaps, retaining {} objects", nInput, output.size()); return output; } + private static void populateSpatialIndex(Collection pathObjects, SpatialIndex index, GeometryCache cache) { + for (var pathObject : pathObjects) { + index.insert(cache.getEnvelope(pathObject), pathObject); + } + } + + + private static boolean containsOverlaps(PathObject pathObject, SpatialIndex index, GeometryCache cache) { + var envelope = cache.getEnvelope(pathObject); + List maybeOverlapping = index.query(envelope); + if (maybeOverlapping.size() <= 1) + return false; + var geom = cache.getGeometry(pathObject); + for (var maybe : maybeOverlapping) { + if (maybe == pathObject) + continue; + var geomMaybe = cache.getGeometry(maybe); + if (geom.overlaps(geomMaybe) || geom.equalsExact(geomMaybe)) + return true; + } + return false; + } + + /** * A cache of normalized geometries and envelopes. * This can be useful for a short time to avoid unnecessary recomputation. @@ -200,6 +286,9 @@ public static Builder builder() { return new Builder(); } + /** + * Builder for the OverlapFixer. + */ public static class Builder { private Supplier> comparator = () -> Comparators.createAreaFirstComparator(); @@ -208,51 +297,125 @@ public static class Builder { private Strategy strategy = Strategy.CLIP_OVERLAPS; + private boolean keepFragments = false; + private Builder() {} - public Builder setComparator(Comparator comparator) { - this.comparator = () -> comparator; + /** + * Set the minimum area for objects to be retained, in pixels. + * Objects with an area less than this (either before or after clipping) will be dropped. + * @param minArea + * @return + */ + public Builder setMinArea(double minArea) { + this.minArea = minArea; return this; } - public Builder setMinArea(double minArea) { - this.minArea = minArea; + /** + * Set the comparator to use for sorting objects. + * This assigns a 'priority' to objects, which is used to determine which objects are kept when overlaps occur. + * Objects that are sorted to be earlier in the list are considered to have a higher priority. + * @param comparator + * @return + */ + public Builder setComparator(Comparator comparator) { + this.comparator = () -> comparator; return this; } + /** + * Set the comparator to sort by solidity, with the most solid objects given a higher priority. + * Subsequent sorting is by area, length, number of points, and finally by the default comparator. + * @return + */ public Builder sortBySolidity() { this.comparator = () -> Comparators.createSolidityFirstComparator(); return this; } + /** + * Set the comparator to sort by area, with the largest objects given a higher priority. + * Subsequent sorting is by length, number of points, and finally by the default comparator. + * @return + */ public Builder sortByArea() { this.comparator = () -> Comparators.createAreaFirstComparator(); return this; } + /** + * Equivalent to keepFragments(true). + * @return + */ + public Builder keepFragments() { + return keepFragments(true); + } + + /** + * Set whether to keep fragments when clipping objects. + * Fragments are defined as objects that are split into more pieces after clipping than they were before. + * @param doKeep + * @return + */ + public Builder keepFragments(boolean doKeep) { + this.keepFragments = doKeep; + return this; + } + + /** + * Equivalent to keepFragments(false). + * @return + */ + public Builder discardFragments() { + return keepFragments(false); + } + + /** + * Set the strategy for handling overlaps. + * @param strategy + * @return + */ public Builder setStrategy(Strategy strategy) { this.strategy = strategy; return this; } + /** + * Clip overlapping objects, excluding the parts that overlap with a 'higher priority' object + * according to the comparator. + * @return + */ public Builder clipOverlaps() { this.strategy = Strategy.CLIP_OVERLAPS; return this; } + /** + * Retain only the 'highest priority' objects when overlaps occur, and drop the others. + * Priority is determined by the comparator. + * @return + */ public Builder dropOverlaps() { this.strategy = Strategy.DROP_OVERLAPS; return this; } + /** + * Build the overlap fixer. + * @return + */ public OverlapFixer build() { - return new OverlapFixer(strategy, minArea, comparator); + return new OverlapFixer(strategy, minArea, comparator, keepFragments); } } - + /** + * Class to create comparators for PathObjects based on different criteria. + * This can cache measurement values, to ensure they don't need to be recomputed. + */ private static class Comparators { private static Comparator createAreaFirstComparator() { From f1b1e45649d65e8a0e0e5e8163fbda5089ff4caf Mon Sep 17 00:00:00 2001 From: Pete Date: Mon, 2 Sep 2024 21:42:40 +0100 Subject: [PATCH 266/866] Add tests to OverlapFixer --- .../lib/objects/utils/OverlapFixer.java | 12 ++ .../lib/objects/utils/TestOverlapFixer.java | 104 ++++++++++++++++++ 2 files changed, 116 insertions(+) create mode 100644 qupath-core/src/test/java/qupath/lib/objects/utils/TestOverlapFixer.java diff --git a/qupath-core/src/main/java/qupath/lib/objects/utils/OverlapFixer.java b/qupath-core/src/main/java/qupath/lib/objects/utils/OverlapFixer.java index 99b48ccec..b3f576920 100644 --- a/qupath-core/src/main/java/qupath/lib/objects/utils/OverlapFixer.java +++ b/qupath-core/src/main/java/qupath/lib/objects/utils/OverlapFixer.java @@ -83,6 +83,18 @@ private OverlapFixer(Strategy strategy, double minArea, Supplier fix(PathObject... pathObjects) { + return fix(List.of(pathObjects)); + } + + /** * Fix overlaps in a collection of PathObjects, by the criteria specified in the builder. * This method is thread-safe. diff --git a/qupath-core/src/test/java/qupath/lib/objects/utils/TestOverlapFixer.java b/qupath-core/src/test/java/qupath/lib/objects/utils/TestOverlapFixer.java new file mode 100644 index 000000000..13fd0a3b6 --- /dev/null +++ b/qupath-core/src/test/java/qupath/lib/objects/utils/TestOverlapFixer.java @@ -0,0 +1,104 @@ +/*- + * #%L + * This file is part of QuPath. + * %% + * Copyright (C) 2024 QuPath developers, The University of Edinburgh + * %% + * QuPath 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 3 of the + * License, or (at your option) any later version. + * + * QuPath 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 QuPath. If not, see . + * #L% + */ + +package qupath.lib.objects.utils; + +import org.junit.jupiter.api.Test; +import qupath.lib.objects.PathObject; +import qupath.lib.objects.PathObjects; +import qupath.lib.regions.ImagePlane; +import qupath.lib.roi.ROIs; +import qupath.lib.roi.interfaces.ROI; + +import java.util.Collection; +import java.util.Collections; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class TestOverlapFixer { + + @Test + public void test_keepFragments() { + var large = createRectangle(0, 0, 100, 100); + var thin = createRectangle(-10, 45, 120, 10); + + var fixer = OverlapFixer.builder() + .keepFragments() + .build(); + assertEquals(2, fixer.fix(large, thin).size()); + } + + @Test + public void test_discardFragments() { + var large = createRectangle(0, 0, 100, 100); + var thin = createRectangle(-10, 45, 120, 10); + + var fixer = OverlapFixer.builder() + .discardFragments() + .build(); + assertEquals(1, fixer.fix(large, thin).size()); + } + + @Test + public void test_dropOverlaps() { + var large = createRectangle(0, 0, 100, 100); + var small = createRectangle(40, 0, 80, 100); + + var fixer = OverlapFixer.builder() + .dropOverlaps() + .build(); + assertEquals(Collections.singletonList(large), fixer.fix(large, small)); + } + + @Test + public void test_clipOverlaps() { + var large = createRectangle(0, 0, 100, 100); + var small = createRectangle(40, 0, 80, 100); + + var fixer = OverlapFixer.builder() + .clipOverlaps() + .build(); + assertEquals(2, fixer.fix(large, small).size()); + assertEquals(120 * 100, sumAreas(fixer.fix(large, small))); + } + + @Test + public void test_disconnected() { + var large = createRectangle(0, 0, 100, 100); + var small = createRectangle(140, 120, 80, 100); + + var fixer = OverlapFixer.builder() + .clipOverlaps() + .build(); + var set = Set.of(large, small); + assertEquals(set, Set.copyOf(fixer.fix(large, small))); + } + + private static double sumAreas(Collection pathObjects) { + return pathObjects.stream().map(PathObject::getROI).mapToDouble(ROI::getArea).sum(); + } + + private static PathObject createRectangle(double x, double y, double width, double height) { + return PathObjects.createDetectionObject(ROIs.createRectangleROI(x, y, width, height, ImagePlane.getDefaultPlane())); + } + +} From df92877807135f8856c53c8649db1dfa7e9970c3 Mon Sep 17 00:00:00 2001 From: Pete Date: Wed, 4 Sep 2024 09:07:30 +0100 Subject: [PATCH 267/866] Better handle symlinks Proposed fix for https://github.com/qupath/qupath/issues/1586 --- CHANGELOG.md | 1 + .../servers/bioformats/BioFormatsImageServer.java | 5 +++-- .../lib/gui/viewer/DragDropImportListener.java | 14 ++++++++------ 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a81d868f..3d0485186 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,7 @@ This is a *work in progress* for the next major release. * If you use this command, the calculated connections are displayed instead of the default neighbor connections for compatibility. However, this support will be removed in a future version. * More viewer options are persistent (e.g. show/hide the overview thumbnail, location text, or scalebar) +* Better support for symbolic links (https://github.com/qupath/qupath/issues/1586) ### Bugs fixed * Tile export to .ome.tif can convert to 8-bit unnecessarily (https://github.com/qupath/qupath/issues/1494) diff --git a/qupath-extension-bioformats/src/main/java/qupath/lib/images/servers/bioformats/BioFormatsImageServer.java b/qupath-extension-bioformats/src/main/java/qupath/lib/images/servers/bioformats/BioFormatsImageServer.java index a198d079c..8e1d120ac 100644 --- a/qupath-extension-bioformats/src/main/java/qupath/lib/images/servers/bioformats/BioFormatsImageServer.java +++ b/qupath-extension-bioformats/src/main/java/qupath/lib/images/servers/bioformats/BioFormatsImageServer.java @@ -4,7 +4,7 @@ * %% * Copyright (C) 2014 - 2016 The Queen's University of Belfast, Northern Ireland * Contact: IP Management (ipmanagement@qub.ac.uk) - * Copyright (C) 2018 - 2023 QuPath developers, The University of Edinburgh + * Copyright (C) 2018 - 2024 QuPath developers, The University of Edinburgh * %% * QuPath is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as @@ -317,7 +317,8 @@ static BioFormatsImageServer checkSupport(URI uri, final BioFormatsServerOptions try { var path = GeneralTools.toPath(uri); if (path != null) { - filePathOrUrl = path.toString(); + // Use toRealPath to resolve any symbolic links + filePathOrUrl = path.toRealPath().toString(); } } catch (Exception e) { logger.error(e.getLocalizedMessage(), e); diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/DragDropImportListener.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/DragDropImportListener.java index a0da965d6..31ebf4ca5 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/DragDropImportListener.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/DragDropImportListener.java @@ -314,13 +314,14 @@ private void handleFileDropImpl(QuPathViewer viewer, List list) throws IOE logger.warn("No files given!"); return; } - + // Check if we have only jar or css files int nJars = 0; int nCss = 0; int nJson = 0; for (File file : list) { - var ext = GeneralTools.getExtension(file).orElse("").toLowerCase(); + // Use the canonical file in case we have a symlink + var ext = GeneralTools.getExtension(file.getCanonicalFile()).orElse("").toLowerCase(); if (ext.equals(".jar")) nJars++; else if (ext.equals(".css")) @@ -378,7 +379,7 @@ else if (ext.equals(".json")) // This helps us determine whether or not a zip file contains an image or objects, for example Set allUnzippedExtensions = list.stream().flatMap(f -> { try { - return PathIO.unzippedExtensions(f.toPath()).stream(); + return PathIO.unzippedExtensions(f.getCanonicalFile().toPath()).stream(); } catch (IOException e) { logger.debug(e.getLocalizedMessage(), e); return Arrays.stream(new String[0]); @@ -386,9 +387,10 @@ else if (ext.equals(".json")) }).collect(Collectors.toSet()); // Extract the first (and possibly only) file - File file = list.get(0); - - String fileName = file.getName().toLowerCase(); + File file = list.getFirst(); + + // Get the name of the file using the canonical file, in case we have a symlink + String fileName = file.getCanonicalFile().getName().toLowerCase(); // Check if this is a hierarchy file if (singleFile && (fileName.endsWith(PathPrefs.getSerializationExtension()))) { From 6559df1e83413126173846ed983fc21c6e77cc5c Mon Sep 17 00:00:00 2001 From: Pete Date: Wed, 4 Sep 2024 09:20:25 +0100 Subject: [PATCH 268/866] Handle symlinks with OpenSlide --- .../lib/images/servers/openslide/OpenslideImageServer.java | 7 ++++--- .../images/servers/openslide/OpenslideServerBuilder.java | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/qupath-extension-openslide/src/main/java/qupath/lib/images/servers/openslide/OpenslideImageServer.java b/qupath-extension-openslide/src/main/java/qupath/lib/images/servers/openslide/OpenslideImageServer.java index a9b780edd..107295b5c 100644 --- a/qupath-extension-openslide/src/main/java/qupath/lib/images/servers/openslide/OpenslideImageServer.java +++ b/qupath-extension-openslide/src/main/java/qupath/lib/images/servers/openslide/OpenslideImageServer.java @@ -4,7 +4,7 @@ * %% * Copyright (C) 2014 - 2016 The Queen's University of Belfast, Northern Ireland * Contact: IP Management (ipmanagement@qub.ac.uk) - * Copyright (C) 2018 - 2020 QuPath developers, The University of Edinburgh + * Copyright (C) 2018 - 2024 QuPath developers, The University of Edinburgh * %% * QuPath is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as @@ -139,8 +139,9 @@ public OpenslideImageServer(URI uri, String...args) throws IOException { Path filePath = GeneralTools.toPath(uri); String name; // OpenSlide conventionally expects a file path, but some builds might accept a URI - if (Files.exists(filePath)) { - osr = OpenSlideLoader.openImage(filePath.toAbsolutePath().toString()); + if (filePath != null && Files.exists(filePath)) { + // We need to use the real path to resolve symlinks + osr = OpenSlideLoader.openImage(filePath.toRealPath().toString()); name = filePath.getFileName().toString(); } else { osr = OpenSlideLoader.openImage(uri.toString()); diff --git a/qupath-extension-openslide/src/main/java/qupath/lib/images/servers/openslide/OpenslideServerBuilder.java b/qupath-extension-openslide/src/main/java/qupath/lib/images/servers/openslide/OpenslideServerBuilder.java index 91036f70e..47dc4907c 100644 --- a/qupath-extension-openslide/src/main/java/qupath/lib/images/servers/openslide/OpenslideServerBuilder.java +++ b/qupath-extension-openslide/src/main/java/qupath/lib/images/servers/openslide/OpenslideServerBuilder.java @@ -81,7 +81,7 @@ private static float supportLevel(URI uri, String...args) { return 0; try { - File file = Paths.get(uri).toFile(); + File file = Paths.get(uri).toFile().getCanonicalFile(); String vendor = OpenSlideLoader.detectVendor(file.toString()); if (vendor == null) return 0; From c5da30bd172e6f6f2e12b13eacd034e11ec6a1d1 Mon Sep 17 00:00:00 2001 From: Pete Date: Wed, 4 Sep 2024 12:10:41 +0100 Subject: [PATCH 269/866] Minor cleanup --- .../src/main/java/qupath/lib/objects/utils/Tiler.java | 3 ++- .../main/java/qupath/lib/plugins/AbstractTaskRunner.java | 4 ++-- .../src/main/java/qupath/lib/gui/ScriptMenuLoader.java | 2 +- .../src/main/java/qupath/lib/gui/TaskRunnerFX.java | 6 ++---- 4 files changed, 7 insertions(+), 8 deletions(-) diff --git a/qupath-core/src/main/java/qupath/lib/objects/utils/Tiler.java b/qupath-core/src/main/java/qupath/lib/objects/utils/Tiler.java index 7b0033ca9..727b481e7 100644 --- a/qupath-core/src/main/java/qupath/lib/objects/utils/Tiler.java +++ b/qupath-core/src/main/java/qupath/lib/objects/utils/Tiler.java @@ -37,6 +37,7 @@ import java.util.ArrayList; import java.util.List; +import java.util.Objects; import java.util.function.Function; import java.util.stream.Collectors; @@ -212,7 +213,7 @@ public List createGeometries(Geometry parent) { var preparedParent = PreparedGeometryFactory.prepare(parent); return tiles.parallelStream() .map(createTileFilter(preparedParent, cropToParent, filterByCentroid)) - .filter(g -> g != null) + .filter(Objects::nonNull) .collect(Collectors.toList()); } diff --git a/qupath-core/src/main/java/qupath/lib/plugins/AbstractTaskRunner.java b/qupath-core/src/main/java/qupath/lib/plugins/AbstractTaskRunner.java index 42bd3b491..6eae6e569 100644 --- a/qupath-core/src/main/java/qupath/lib/plugins/AbstractTaskRunner.java +++ b/qupath-core/src/main/java/qupath/lib/plugins/AbstractTaskRunner.java @@ -4,7 +4,7 @@ * %% * Copyright (C) 2014 - 2016 The Queen's University of Belfast, Northern Ireland * Contact: IP Management (ipmanagement@qub.ac.uk) - * Copyright (C) 2018 - 2023 QuPath developers, The University of Edinburgh + * Copyright (C) 2018 - 2024 QuPath developers, The University of Edinburgh * %% * QuPath is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as @@ -151,7 +151,7 @@ protected void awaitCompletion() { pendingTasks.remove(future); } if (monitor != null) - monitor.pluginCompleted("Tasks completed!"); + monitor.pluginCompleted(""); } catch (InterruptedException e) { logger.error("Plugin interrupted: {}", e.getMessage(), e); monitor.pluginCompleted("Completed with error " + e.getMessage()); diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/ScriptMenuLoader.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/ScriptMenuLoader.java index 8e6364861..289e3b1af 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/ScriptMenuLoader.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/ScriptMenuLoader.java @@ -154,7 +154,7 @@ private void updateMenu() { else menu.getItems().setAll(miOpenDirectory, miCreateScript, new SeparatorMenuItem()); Path path = Paths.get(scriptDir); - if (path != null && path.getFileName() != null) { + if (path.getFileName() != null) { addMenuItemsForPath(menu, path); } } else if (miSetPath != null) diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/TaskRunnerFX.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/TaskRunnerFX.java index 06c3a7eb1..9d851554b 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/TaskRunnerFX.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/TaskRunnerFX.java @@ -4,7 +4,7 @@ * %% * Copyright (C) 2014 - 2016 The Queen's University of Belfast, Northern Ireland * Contact: IP Management (ipmanagement@qub.ac.uk) - * Copyright (C) 2018 - 2023 QuPath developers, The University of Edinburgh + * Copyright (C) 2018 - 2024 QuPath developers, The University of Edinburgh * %% * QuPath is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as @@ -23,7 +23,6 @@ package qupath.lib.gui; -import java.awt.image.BufferedImage; import java.util.Collection; import java.util.concurrent.ExecutionException; import java.util.concurrent.FutureTask; @@ -47,7 +46,6 @@ import javafx.stage.Stage; import javafx.util.Duration; import qupath.fx.utils.GridPaneUtils; -import qupath.lib.images.ImageData; import qupath.lib.plugins.CommandLineTaskRunner; import qupath.lib.plugins.PathTask; import qupath.lib.plugins.AbstractTaskRunner; @@ -308,7 +306,7 @@ void stopMonitoring(final String message) { } long endTime = System.currentTimeMillis(); logger.info(String.format("Processing complete in %.2f seconds", (endTime - startTimeMS)/1000.)); - if (message != null && message.trim().length() > 0) + if (message != null && !message.trim().isEmpty()) logger.info(message); } From 70c571eac465064b699e5e0779382d197bb45707 Mon Sep 17 00:00:00 2001 From: Pete Date: Wed, 4 Sep 2024 12:11:38 +0100 Subject: [PATCH 270/866] Introduce ObjectProcessor Enables `ObjectMerger` and `OverlapFixer` to implement the same interface, and means they can easily be applied sequentially. --- .../experimental/pixels/PixelProcessor.java | 43 ++++++++++++------- .../lib/objects/utils/ObjectMerger.java | 25 ++++++++--- .../lib/objects/utils/ObjectProcessor.java | 31 +++++++++++++ .../lib/objects/utils/OverlapFixer.java | 15 +------ .../lib/objects/utils/TestOverlapFixer.java | 11 ++--- 5 files changed, 86 insertions(+), 39 deletions(-) create mode 100644 qupath-core/src/main/java/qupath/lib/objects/utils/ObjectProcessor.java diff --git a/qupath-core-processing/src/main/java/qupath/lib/experimental/pixels/PixelProcessor.java b/qupath-core-processing/src/main/java/qupath/lib/experimental/pixels/PixelProcessor.java index 6fd901310..c6affaf04 100644 --- a/qupath-core-processing/src/main/java/qupath/lib/experimental/pixels/PixelProcessor.java +++ b/qupath-core-processing/src/main/java/qupath/lib/experimental/pixels/PixelProcessor.java @@ -2,7 +2,7 @@ * #%L * This file is part of QuPath. * %% - * Copyright (C) 2023 QuPath developers, The University of Edinburgh + * Copyright (C) 2024 QuPath developers, The University of Edinburgh * %% * QuPath is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as @@ -28,6 +28,7 @@ import qupath.lib.images.servers.PixelCalibration; import qupath.lib.objects.PathObject; import qupath.lib.objects.utils.ObjectMerger; +import qupath.lib.objects.utils.ObjectProcessor; import qupath.lib.objects.utils.Tiler; import qupath.lib.plugins.PathTask; import qupath.lib.plugins.TaskRunner; @@ -86,14 +87,14 @@ public class PixelProcessor { private final DownsampleCalculator downsampleCalculator; private final Tiler tiler; - private final ObjectMerger merger; + private final ObjectProcessor objectProcessor; private PixelProcessor(ImageSupplier imageSupplier, MaskSupplier maskSupplier, OutputHandler outputHandler, Processor processor, Tiler tiler, - ObjectMerger merger, + ObjectProcessor objectProcessor, Padding padding, DownsampleCalculator downsampleCalculator) { Objects.requireNonNull(imageSupplier, "Image supplier cannot be null"); @@ -105,7 +106,7 @@ private PixelProcessor(ImageSupplier imageSupplier, this.outputHandler = outputHandler; this.processor = processor; this.tiler = tiler; - this.merger = merger; + this.objectProcessor = objectProcessor; this.padding = padding; this.downsampleCalculator = downsampleCalculator; } @@ -200,9 +201,9 @@ private void processTiled(TaskRunner runner, Tiler tiler, ImageData proxy.getChildObjects().stream()) .toList(); - if (merger != null) { + if (objectProcessor != null) { // Use the merger if we have one - mergeTasks.add(() -> mergeAndAddObjects(merger, pathObject, proxyList)); + mergeTasks.add(() -> postprocessObjects(objectProcessor, pathObject, proxyList)); } else { // Just add the new objects if we have no merger pathObject.clearChildObjects(); @@ -215,13 +216,13 @@ private void processTiled(TaskRunner runner, Tiler tiler, ImageData childObjects) { - var toAdd = merger.merge(childObjects); + private static void postprocessObjects(ObjectProcessor objectProcessor, PathObject parent, List childObjects) { + var toAdd = objectProcessor.process(childObjects); parent.clearChildObjects(); parent.addChildObjects(toAdd); parent.setLocked(true); @@ -231,7 +232,7 @@ private void mergeAndAddObjects(ObjectMerger merger, PathObject parent, List imageData; private final PathObject pathObject; @@ -319,7 +320,7 @@ public static class Builder { private Processor processor; private Tiler tiler; - private ObjectMerger merger; + private ObjectProcessor objectProcessor; private Padding padding = Padding.empty(); private DownsampleCalculator downsampleCalculator = DownsampleCalculator.createForDownsample(1.0); @@ -440,9 +441,21 @@ public Builder tile(int tileWidth, int tileHeight) { * @param merger * @return * @see #mergeSharedBoundaries(double) + * @deprecated v0.6.0, use {@link #postProcess(ObjectProcessor)} instead */ + @Deprecated public Builder merger(ObjectMerger merger) { - this.merger = merger; + return postProcess(merger); + } + + /** + * Set an object post-processor to apply to any objects created when using a tiler. + * This may be handle overlaps, e.g. by merging or clipping. + * @param objectProcessor + * @return + */ + public Builder postProcess(ObjectProcessor objectProcessor) { + this.objectProcessor = objectProcessor; return this; } @@ -454,7 +467,7 @@ public Builder merger(ObjectMerger merger) { * @see #merger(ObjectMerger) */ public Builder mergeSharedBoundaries(double threshold) { - return merger(ObjectMerger.createSharedTileBoundaryMerger(threshold)); + return postProcess(ObjectMerger.createSharedTileBoundaryMerger(threshold)); } /** @@ -464,7 +477,7 @@ public Builder mergeSharedBoundaries(double threshold) { */ public PixelProcessor build() { return new PixelProcessor<>(imageSupplier, maskSupplier, outputHandler, processor, - tiler, merger, padding, downsampleCalculator); + tiler, objectProcessor, padding, downsampleCalculator); } diff --git a/qupath-core/src/main/java/qupath/lib/objects/utils/ObjectMerger.java b/qupath-core/src/main/java/qupath/lib/objects/utils/ObjectMerger.java index d108d63bf..88f7cdf6c 100644 --- a/qupath-core/src/main/java/qupath/lib/objects/utils/ObjectMerger.java +++ b/qupath-core/src/main/java/qupath/lib/objects/utils/ObjectMerger.java @@ -49,11 +49,9 @@ import java.util.Queue; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ConcurrentLinkedDeque; import java.util.function.BiPredicate; import java.util.function.Function; import java.util.stream.Collectors; -import java.util.stream.IntStream; /** * Helper class for merging objects using different criteria. @@ -62,7 +60,7 @@ * * @since v0.5.0 */ -public class ObjectMerger { +public class ObjectMerger implements ObjectProcessor { private static final Logger logger = LoggerFactory.getLogger(ObjectMerger.class); @@ -85,6 +83,17 @@ private ObjectMerger(BiPredicate compatibilityTest, BiPr this.searchDistance = searchDistance; } + /** + * Merge the input objects using the merging strategy. + * @param pathObjects the input objects for which merges should be calculated + * @return a list of objects, with the same number or fewer than the input + * @deprecated Use {@link #process(Collection)} instead + */ + @Deprecated + public List merge(Collection pathObjects) { + return process(pathObjects); + } + /** * Calculate the result of applying the merging strategy to the input objects. *

    @@ -99,7 +108,7 @@ private ObjectMerger(BiPredicate compatibilityTest, BiPr * @param pathObjects the input objects for which merges should be calculated * @return a list of objects, with the same number or fewer than the input */ - public List merge(Collection pathObjects) { + public List process(Collection pathObjects) { if (pathObjects == null || pathObjects.isEmpty()) return Collections.emptyList(); @@ -526,11 +535,14 @@ private static PathObject mergeObjects(List pathObjects) { if (pathObjects.isEmpty()) return null; - var pathObject = pathObjects.get(0); + var pathObject = pathObjects.getFirst(); if (pathObjects.size() == 1) return pathObject; - var allROIs = pathObjects.stream().map(PathObject::getROI).filter(Objects::nonNull).collect(Collectors.toList()); + var allROIs = pathObjects.stream().map(PathObject::getROI) + .filter(Objects::nonNull) + .distinct() + .collect(Collectors.toList()); ROI mergedROI = RoiTools.union(allROIs); PathObject mergedObject = null; @@ -540,6 +552,7 @@ private static PathObject mergeObjects(List pathObjects) { var nucleusROIs = pathObjects.stream() .map(PathObjectTools::getNucleusROI) .filter(Objects::nonNull) + .distinct() .collect(Collectors.toList()); ROI nucleusROI = nucleusROIs.isEmpty() ? null : RoiTools.union(nucleusROIs); mergedObject = PathObjects.createCellObject(mergedROI, nucleusROI, pathObject.getPathClass(), null); diff --git a/qupath-core/src/main/java/qupath/lib/objects/utils/ObjectProcessor.java b/qupath-core/src/main/java/qupath/lib/objects/utils/ObjectProcessor.java new file mode 100644 index 000000000..8fcda735e --- /dev/null +++ b/qupath-core/src/main/java/qupath/lib/objects/utils/ObjectProcessor.java @@ -0,0 +1,31 @@ +package qupath.lib.objects.utils; + +import qupath.lib.objects.PathObject; + +import java.util.Collection; +import java.util.List; + +/** + * Minimal interface for processing one or more objects. + *

    + * This is intended for tasks such as merging, splitting, filtering, etc. + */ +public interface ObjectProcessor { + + /** + * Process a collection of objects and return the result. + * @param input the input objects + * @return the output objects; this should always be a new collection (even if it contains the same objects) + */ + List process(Collection input); + + /** + * Create a new ObjectProcessor that applies (at least) two processors sequentially. + * @param after the processor to apply next + * @return a processor that applies this processor first, and then the given processor + */ + default ObjectProcessor then(ObjectProcessor after) { + return (input) -> after.process(process(input)); + } + +} diff --git a/qupath-core/src/main/java/qupath/lib/objects/utils/OverlapFixer.java b/qupath-core/src/main/java/qupath/lib/objects/utils/OverlapFixer.java index b3f576920..e3d776617 100644 --- a/qupath-core/src/main/java/qupath/lib/objects/utils/OverlapFixer.java +++ b/qupath-core/src/main/java/qupath/lib/objects/utils/OverlapFixer.java @@ -46,7 +46,7 @@ import java.util.function.Supplier; import java.util.stream.Collectors; -public class OverlapFixer { +public class OverlapFixer implements ObjectProcessor { private static final Logger logger = LoggerFactory.getLogger(OverlapFixer.class); @@ -83,17 +83,6 @@ private OverlapFixer(Strategy strategy, double minArea, Supplier fix(PathObject... pathObjects) { - return fix(List.of(pathObjects)); - } - /** * Fix overlaps in a collection of PathObjects, by the criteria specified in the builder. @@ -102,7 +91,7 @@ public List fix(PathObject... pathObjects) { * @return the output objects. This may be the same as the input objects, or contain fewer objects - * possibly with new (clipped) ROIs - but no object will be added or have its properties changed. */ - public List fix(Collection pathObjects) { + public List process(Collection pathObjects) { int nInput = pathObjects.size(); diff --git a/qupath-core/src/test/java/qupath/lib/objects/utils/TestOverlapFixer.java b/qupath-core/src/test/java/qupath/lib/objects/utils/TestOverlapFixer.java index 13fd0a3b6..d01983a8f 100644 --- a/qupath-core/src/test/java/qupath/lib/objects/utils/TestOverlapFixer.java +++ b/qupath-core/src/test/java/qupath/lib/objects/utils/TestOverlapFixer.java @@ -30,6 +30,7 @@ import java.util.Collection; import java.util.Collections; +import java.util.List; import java.util.Set; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -44,7 +45,7 @@ public void test_keepFragments() { var fixer = OverlapFixer.builder() .keepFragments() .build(); - assertEquals(2, fixer.fix(large, thin).size()); + assertEquals(2, fixer.process(List.of(large, thin)).size()); } @Test @@ -66,7 +67,7 @@ public void test_dropOverlaps() { var fixer = OverlapFixer.builder() .dropOverlaps() .build(); - assertEquals(Collections.singletonList(large), fixer.fix(large, small)); + assertEquals(Collections.singletonList(large), fixer.process(List.of(large, small))); } @Test @@ -77,8 +78,8 @@ public void test_clipOverlaps() { var fixer = OverlapFixer.builder() .clipOverlaps() .build(); - assertEquals(2, fixer.fix(large, small).size()); - assertEquals(120 * 100, sumAreas(fixer.fix(large, small))); + assertEquals(2, fixer.process(List.of(large, small)).size()); + assertEquals(120 * 100, sumAreas(fixer.process(List.of(large, small)))); } @Test @@ -90,7 +91,7 @@ public void test_disconnected() { .clipOverlaps() .build(); var set = Set.of(large, small); - assertEquals(set, Set.copyOf(fixer.fix(large, small))); + assertEquals(set, Set.copyOf(fixer.process(List.of(large, small)))); } private static double sumAreas(Collection pathObjects) { From 9f643f110ac8414d313bf00622d5071e897cfa87 Mon Sep 17 00:00:00 2001 From: Pete Date: Wed, 4 Sep 2024 12:14:48 +0100 Subject: [PATCH 271/866] Update ObjectProcessor.java Use `andThen` to improve similarity to other methods --- .../src/main/java/qupath/lib/objects/utils/ObjectProcessor.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qupath-core/src/main/java/qupath/lib/objects/utils/ObjectProcessor.java b/qupath-core/src/main/java/qupath/lib/objects/utils/ObjectProcessor.java index 8fcda735e..ffda96224 100644 --- a/qupath-core/src/main/java/qupath/lib/objects/utils/ObjectProcessor.java +++ b/qupath-core/src/main/java/qupath/lib/objects/utils/ObjectProcessor.java @@ -24,7 +24,7 @@ public interface ObjectProcessor { * @param after the processor to apply next * @return a processor that applies this processor first, and then the given processor */ - default ObjectProcessor then(ObjectProcessor after) { + default ObjectProcessor andThen(ObjectProcessor after) { return (input) -> after.process(process(input)); } From eb767c23588e7d7a2651849879ab1e73f188824a Mon Sep 17 00:00:00 2001 From: Pete Date: Wed, 4 Sep 2024 12:29:50 +0100 Subject: [PATCH 272/866] Fix fixer's fix --- .../test/java/qupath/lib/objects/utils/TestOverlapFixer.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qupath-core/src/test/java/qupath/lib/objects/utils/TestOverlapFixer.java b/qupath-core/src/test/java/qupath/lib/objects/utils/TestOverlapFixer.java index d01983a8f..35282dd22 100644 --- a/qupath-core/src/test/java/qupath/lib/objects/utils/TestOverlapFixer.java +++ b/qupath-core/src/test/java/qupath/lib/objects/utils/TestOverlapFixer.java @@ -56,7 +56,7 @@ public void test_discardFragments() { var fixer = OverlapFixer.builder() .discardFragments() .build(); - assertEquals(1, fixer.fix(large, thin).size()); + assertEquals(1, fixer.process(List.of(large, thin)).size()); } @Test From f0e8e57725bb6370d4d8a7f8031ed4af5f163832 Mon Sep 17 00:00:00 2001 From: Pete Date: Wed, 4 Sep 2024 12:42:30 +0100 Subject: [PATCH 273/866] Thanks Alan --- .../java/qupath/lib/experimental/pixels/PixelProcessor.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qupath-core-processing/src/main/java/qupath/lib/experimental/pixels/PixelProcessor.java b/qupath-core-processing/src/main/java/qupath/lib/experimental/pixels/PixelProcessor.java index c6affaf04..6f75600dd 100644 --- a/qupath-core-processing/src/main/java/qupath/lib/experimental/pixels/PixelProcessor.java +++ b/qupath-core-processing/src/main/java/qupath/lib/experimental/pixels/PixelProcessor.java @@ -94,7 +94,7 @@ private PixelProcessor(ImageSupplier imageSupplier, OutputHandler outputHandler, Processor processor, Tiler tiler, - ObjectProcessor objectProcessor, + ObjectProcessor objectProcessor, Padding padding, DownsampleCalculator downsampleCalculator) { Objects.requireNonNull(imageSupplier, "Image supplier cannot be null"); From 0ed0a8e68864760607b2f7e1d8d6e7a849bbca5e Mon Sep 17 00:00:00 2001 From: Pete Date: Thu, 5 Sep 2024 20:56:57 +0100 Subject: [PATCH 274/866] Fix thumbnail reappearing bug in projects --- .../src/main/java/qupath/lib/gui/panes/ProjectBrowser.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/panes/ProjectBrowser.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/panes/ProjectBrowser.java index 0aa2f29c9..2d4cc4bd5 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/panes/ProjectBrowser.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/panes/ProjectBrowser.java @@ -1348,6 +1348,8 @@ public void updateItem(ProjectTreeRow item, boolean empty) { if (getGraphic() == null) setGraphic(viewPane); } else if (!serversFailed.contains(item)) { + tooltip.setGraphic(viewTooltip); + viewCanvas.getGraphicsContext2D().clearRect(0, 0, viewCanvas.getWidth(), viewCanvas.getHeight()); executor.submit(() -> { final ProjectTreeRow objectTemp = getItem(); final ProjectImageEntry entryTemp = ProjectTreeRow.getEntry(objectTemp); From fbf8cc6bd856db1f6de4a9937b52994dfaf8fa40 Mon Sep 17 00:00:00 2001 From: Pete Date: Thu, 5 Sep 2024 21:04:01 +0100 Subject: [PATCH 275/866] Change detection line thickness when upsampling Possibly controversial, possibly very welcome... Usually, the annotation line thickness remains constant at all zoom levels - whereas the detection line thickness increases and decreases when zooming in and out. This is partly to avoid having thick detection lines when zoomed out, and partly for performance reasons: we cache detections rendered at different resolutions. *However*, detections *are* painted 'live' (like annotations) when zooming in beyond full resolution. I find this can be annoying at times, because the default detection line thickness of 2 can be too much - and obscures details. So this PR causes detections to be painted more like annotations when upsampling. --- .../java/qupath/lib/gui/viewer/PathObjectPainter.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/PathObjectPainter.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/PathObjectPainter.java index ff69b46a4..59de4e660 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/PathObjectPainter.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/PathObjectPainter.java @@ -4,7 +4,7 @@ * %% * Copyright (C) 2014 - 2016 The Queen's University of Belfast, Northern Ireland * Contact: IP Management (ipmanagement@qub.ac.uk) - * Copyright (C) 2018 - 2020 QuPath developers, The University of Edinburgh + * Copyright (C) 2018 - 2024 QuPath developers, The University of Edinburgh * %% * QuPath is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as @@ -367,7 +367,7 @@ private static Color getBaseObjectColor(PathObject pathObject, OverlayOptions ov private static Stroke calculateStroke(PathObject pathObject, double downsample, boolean isSelected) { - if (pathObject.isDetection()) { + if (pathObject.isDetection() && downsample > 1) { // Detections inside detections get half the line width if (pathObject.getParent() instanceof PathDetectionObject) return getCachedStroke(PathPrefs.detectionStrokeThicknessProperty().get() / 2.0); @@ -409,7 +409,7 @@ private static Double tryToParseDouble(Object obj) { return ((Number)obj).doubleValue(); } } catch (Exception e) { - logger.warn("Unable to parse double from " + obj); + logger.warn("Unable to parse double from {}", obj); } return null; } @@ -1181,7 +1181,7 @@ public static void paintConnections(final DelaunayTools.Subdivision subdivision, float alpha = (float)(1f - downsampleFactor / 5); alpha = Math.min(alpha, 0.4f); - double thickness = PathPrefs.detectionStrokeThicknessProperty().get(); + double thickness = PathPrefs.detectionStrokeThicknessProperty().get() * Math.min(1, downsampleFactor); if (alpha < .1f || thickness / downsampleFactor <= 0.25) return; From abcf79a1c5c2137f3232c3bac2be5d966a614892 Mon Sep 17 00:00:00 2001 From: Pete Date: Fri, 6 Sep 2024 05:20:31 +0100 Subject: [PATCH 276/866] Toggle dynamic detection thickness New preference to toggle the dynamic detection, to make it easier to try out (and potentially refine). --- CHANGELOG.md | 11 +++-- .../qupath/lib/gui/panes/PreferencePane.java | 3 ++ .../java/qupath/lib/gui/prefs/PathPrefs.java | 12 +++++ .../lib/gui/viewer/PathObjectPainter.java | 49 +++++++++++++++---- .../qupath/lib/gui/viewer/QuPathViewer.java | 34 +------------ .../qupath-gui-strings.properties | 2 + 6 files changed, 67 insertions(+), 44 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d0485186..e7f793624 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,13 +34,18 @@ This is a *work in progress* for the next major release. * New icon & other drawing icons change to indicate when they are active in selection mode * Selection mode works with line ROIs, selecting any intersecting objects * Temporarily active 'Selection mode' by pressing the `S` key while interacting with a viewer +* More viewer options are persistent (e.g. show/hide the overview thumbnail, location text, or scalebar) +* Better support for symbolic links (https://github.com/qupath/qupath/issues/1586) + +### Experimental features +These features are included for testing and feedback. +They may change or be removed in future versions. +* 'Dynamic detection line thickness (experimental)' preference (https://github.com/qupath/qupath/pull/1623) + * Experimental preference to adjust how detections are displayed when zoomed in * New toolbar button to show/hide 'neighbors' in the viewer (https://github.com/qupath/qupath/pull/1597) - * *Experimental* new code to help with querying neighbors * Note that the *Delaunay cluster features 2D* command is now deprecated - see https://github.com/qupath/qupath/issues/1590 for details * If you use this command, the calculated connections are displayed instead of the default neighbor connections for compatibility. However, this support will be removed in a future version. -* More viewer options are persistent (e.g. show/hide the overview thumbnail, location text, or scalebar) -* Better support for symbolic links (https://github.com/qupath/qupath/issues/1586) ### Bugs fixed * Tile export to .ome.tif can convert to 8-bit unnecessarily (https://github.com/qupath/qupath/issues/1494) diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/panes/PreferencePane.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/panes/PreferencePane.java index 706f0ac16..fd8b3fd6b 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/panes/PreferencePane.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/panes/PreferencePane.java @@ -495,6 +495,9 @@ public static class ObjectPreferences { @DoublePref("Prefs.Objects.detectionLineThickness") public final DoubleProperty detectonStrokeThickness = PathPrefs.detectionStrokeThicknessProperty(); + @BooleanPref("Prefs.Objects.newDetectionRendering") + public final BooleanProperty newDetectionRendering = PathPrefs.newDetectionRenderingProperty(); + @BooleanPref("Prefs.Objects.useSelectedColor") public final BooleanProperty useSelectedColor = PathPrefs.useSelectedColorProperty(); diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/prefs/PathPrefs.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/prefs/PathPrefs.java index 73b55de59..67ef8548d 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/prefs/PathPrefs.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/prefs/PathPrefs.java @@ -1712,6 +1712,18 @@ public static DoubleProperty detectionStrokeThicknessProperty() { public static DoubleProperty annotationStrokeThicknessProperty() { return strokeThickThickness; } + + private static BooleanProperty newDetectionRendering = new SimpleBooleanProperty(false); + + /** + * Flag to enable the new rendering strategy for detections. + * This can be used to temporarily turn on/off the rendering, to help refine the behavior. + * @return + * @since v0.6.0 + */ + public static BooleanProperty newDetectionRenderingProperty() { + return newDetectionRendering; + } private static final BooleanProperty usePixelSnapping = createPersistentPreference("usePixelSnapping", true); diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/PathObjectPainter.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/PathObjectPainter.java index 59de4e660..57c06ac77 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/PathObjectPainter.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/PathObjectPainter.java @@ -366,8 +366,13 @@ private static Color getBaseObjectColor(PathObject pathObject, OverlayOptions ov } + private static boolean useDetectionStrokeWidth(double downsample) { + return downsample >= 1 || !PathPrefs.newDetectionRenderingProperty().get(); + } + + private static Stroke calculateStroke(PathObject pathObject, double downsample, boolean isSelected) { - if (pathObject.isDetection() && downsample > 1) { + if (pathObject.isDetection() && useDetectionStrokeWidth(downsample)) { // Detections inside detections get half the line width if (pathObject.getParent() instanceof PathDetectionObject) return getCachedStroke(PathPrefs.detectionStrokeThicknessProperty().get() / 2.0); @@ -1079,6 +1084,34 @@ public static void paintHandles(final List handles, final Graphics2D g2d } } + /** + * Return the stroke thickness to use for drawing connection lines between objects. + * @param downsample + * @return + */ + private static double getConnectionStrokeThickness(double downsample) { + double thickness = PathPrefs.detectionStrokeThicknessProperty().get(); + // Don't try to draw connections if the line is too thin + if (thickness / downsample <= 0.25) + return 0; + // Check if we're using the 'standard' stroke width, or the experimental new rendering + if (useDetectionStrokeWidth(downsample)) + return thickness; + else + return thickness * Math.min(1, downsample); + } + + /** + * Adjust the opacity of connection lines according to the downsample (since rendering a huge number + * is slow, and makes the image look cluttered). + * @param downsample + * @return + */ + private static float getConnectionAlpha(double downsample) { + float alpha = (float)(1f - downsample / 5); + return Math.min(alpha, 0.4f); + } + /** * Paint connections between objects (e.g. from Delaunay triangulation). * @@ -1097,10 +1130,9 @@ public static void paintConnections(final PathObjectConnections connections, fin LogTools.warnOnce(logger, "Legacy 'Delaunay cluster features 2D' connections are being shown in the viewer - this command is deprecated, and support will be removed in a future version"); - float alpha = (float)(1f - downsampleFactor / 5); - alpha = Math.min(alpha, 0.4f); - double thickness = PathPrefs.detectionStrokeThicknessProperty().get(); - if (alpha < .1f || thickness / downsampleFactor <= 0.25) + float alpha = getConnectionAlpha(downsampleFactor); + double thickness = getConnectionStrokeThickness(downsampleFactor); + if (alpha < .1f || thickness <= 0.0) return; g2d = (Graphics2D)g2d.create(); @@ -1179,10 +1211,9 @@ public static void paintConnections(final DelaunayTools.Subdivision subdivision, if (hierarchy == null || subdivision.size() <= 1) return; - float alpha = (float)(1f - downsampleFactor / 5); - alpha = Math.min(alpha, 0.4f); - double thickness = PathPrefs.detectionStrokeThicknessProperty().get() * Math.min(1, downsampleFactor); - if (alpha < .1f || thickness / downsampleFactor <= 0.25) + float alpha = getConnectionAlpha(downsampleFactor); + double thickness = getConnectionStrokeThickness(downsampleFactor); + if (alpha < .1f || thickness <= 0.0) return; g2d = (Graphics2D)g2d.create(); diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/QuPathViewer.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/QuPathViewer.java index c0a4272ad..6c5016874 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/QuPathViewer.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/QuPathViewer.java @@ -443,42 +443,11 @@ void paintCanvas() { context.strokeRect(0, 0, canvas.getWidth(), canvas.getHeight()); } - - -// // Basic code for including the TMA core name -// if (getHierarchy().getTMAGrid() != null) { -// Point2D pSource = new Point2D.Double(); -// Point2D pDest = new Point2D.Double(); -// context.setTextAlign(TextAlignment.CENTER); -// context.setTextBaseline(VPos.CENTER); -// for (TMACoreObject core : getHierarchy().getTMAGrid().getTMACoreList()) { -// if (core.getName() == null) -// continue; -// double x = core.getROI().getBoundsX() + core.getROI().getBoundsWidth()/2; -// double y = core.getROI().getBoundsY() + core.getROI().getBoundsHeight()/2; -// pSource.setLocation(x, y); -// transform.transform(pSource, pDest); -// context.setFill(getSuggestedOverlayColorFX()); -// context.setStroke(javafx.scene.paint.Color.WHITE); -// double xf = pDest.getX(); -// double yf = pDest.getY(); -// context.fillText(core.getName(), xf, yf, core.getROI().getBoundsWidth()/getDownsampleFactor()*0.5); -// } -// } - - -// if (getServer() == null) { -// context.setStroke(javafx.scene.paint.Color.GREENYELLOW); -// context.setLineWidth(borderLineWidth); -// context.strokeRect(0, 0, canvas.getWidth(), canvas.getHeight()); -// } - long time = System.currentTimeMillis(); logger.trace("Time since last repaint: {} ms", (time - lastPaint)); lastPaint = System.currentTimeMillis(); imageDataChanging.set(false); -// repaintRequested = false; } /** @@ -768,7 +737,8 @@ private QuPathViewer(DefaultImageRegionStore regionStore, OverlayOptions overlay // We need a simple repaint for color changes & simple (thick) line changes manager.attachListener(PathPrefs.annotationStrokeThicknessProperty(), repainter); - + manager.attachListener(PathPrefs.newDetectionRenderingProperty(), repainter); + gammaProperty.set(PathPrefs.viewerGammaProperty().get()); gammaProperty.bind(PathPrefs.viewerGammaProperty()); manager.attachListener(gammaProperty, repainterEntire); diff --git a/qupath-gui-fx/src/main/resources/qupath/lib/gui/localization/qupath-gui-strings.properties b/qupath-gui-fx/src/main/resources/qupath/lib/gui/localization/qupath-gui-strings.properties index 80f4936a4..b3d39fc54 100644 --- a/qupath-gui-fx/src/main/resources/qupath/lib/gui/localization/qupath-gui-strings.properties +++ b/qupath-gui-fx/src/main/resources/qupath/lib/gui/localization/qupath-gui-strings.properties @@ -822,6 +822,8 @@ Prefs.Objects.annotationLineThickness = Annotation line thickness Prefs.Objects.annotationLineThickness.description = Thickness (in display pixels) for annotation/TMA core object outlines (default = 2) Prefs.Objects.detectionLineThickness = Detection line thickness Prefs.Objects.detectionLineThickness.description = Thickness (in image pixels) for detection object outlines (default = 2) +Prefs.Objects.newDetectionRendering = Dynamic detection line thickness (experimental) +Prefs.Objects.newDetectionRendering.description = Experimental feature to render detection objects with a stroke thickness that scales with the zoom level Prefs.Objects.useSelectedColor = Use selected color Prefs.Objects.useSelectedColor.description = Highlight selected objects by recoloring them; otherwise, a slightly thicker line thickness will be used Prefs.Objects.selectedColor = Selected object color From 3fec4ee52aaecf538a8d7213df9214ca85f6db22 Mon Sep 17 00:00:00 2001 From: Pete Date: Fri, 6 Sep 2024 08:02:38 +0100 Subject: [PATCH 277/866] Minor fixes - Fix QuPath version compatibility logging when extensions can't be installed - Support `GeneralTools.isAppleSilicon()` - Update the welcome message --- .../src/main/java/qupath/lib/common/GeneralTools.java | 10 +++++++++- .../main/java/qupath/lib/gui/ExtensionManager.java | 11 ++++++----- .../src/main/java/qupath/lib/gui/WelcomeStage.java | 6 +++--- .../gui/localization/qupath-gui-strings.properties | 4 +++- 4 files changed, 21 insertions(+), 10 deletions(-) diff --git a/qupath-core/src/main/java/qupath/lib/common/GeneralTools.java b/qupath-core/src/main/java/qupath/lib/common/GeneralTools.java index 82f756303..925cbe98f 100644 --- a/qupath-core/src/main/java/qupath/lib/common/GeneralTools.java +++ b/qupath-core/src/main/java/qupath/lib/common/GeneralTools.java @@ -724,7 +724,15 @@ public static boolean checkExtensions(final String path, String... extensions) { */ public static boolean isMac() { String os = System.getProperty("os.name").toLowerCase(); - return os.indexOf("mac") >= 0 || os.indexOf("darwin") >= 0; + return os.contains("mac") || os.contains("darwin"); + } + + /** + * Return true if running on macOS and Apple Silicon. + * @return + */ + public static boolean isAppleSilicon() { + return isMac() && "aarch64".equals(System.getProperty("os.arch")); } /** diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/ExtensionManager.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/ExtensionManager.java index 5264da5bc..500f740cf 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/ExtensionManager.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/ExtensionManager.java @@ -161,13 +161,14 @@ public synchronized void refreshExtensions(final boolean showNotification) { if (showNotification) Dialogs.showErrorNotification("Extension error", message); logger.error("Error loading extension " + extension + ": " + e.getLocalizedMessage(), e); - if (!Objects.equals(qupathVersion, version)) { - if (version == null || Version.UNKNOWN.equals(version)) + var compatibleQuPathVersion = extension.getQuPathVersion(); + if (!Objects.equals(qupathVersion, compatibleQuPathVersion)) { + if (compatibleQuPathVersion == null || Version.UNKNOWN.equals(compatibleQuPathVersion)) logger.warn("QuPath version for which the '{}' was written is unknown!", extension.getName()); - else if (version.equals(qupathVersion)) - logger.warn("'{}' reports that it is compatible with the current QuPath version {}", extension.getName(), qupathVersion); + else if (compatibleQuPathVersion.getMajor() == qupathVersion.getMajor() && compatibleQuPathVersion.getMinor() == qupathVersion.getMinor()) + logger.warn("'{}' reports that it is compatible with QuPath {}; the current QuPath version is {}", extension.getName(), compatibleQuPathVersion, qupathVersion); else - logger.warn("'{}' was written for QuPath {} but current version is {}", extension.getName(), version, qupathVersion); + logger.warn("'{}' was written for QuPath {} but current version is {}", extension.getName(), compatibleQuPathVersion, qupathVersion); } try { logger.warn("It is recommended that you delete {} and restart QuPath", diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/WelcomeStage.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/WelcomeStage.java index ea4212864..364a2fa50 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/WelcomeStage.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/WelcomeStage.java @@ -241,7 +241,7 @@ private static Stage buildStage(QuPathGUI qupath) { GridPaneUtils.setToExpandGridPaneWidth(comboThemes, comboUpdates, cbShowStartup, btnStarted, labelExplanation); - if (GeneralTools.isMac() && "aarch64".equals(System.getProperty("os.arch"))) { //$NON-NLS-2$ + if (GeneralTools.isAppleSilicon()) { //$NON-NLS-2$ var textSilicon = makeMacAarch64Message(); textSilicon.setTextAlignment(TextAlignment.CENTER); textSilicon.setOpacity(0.9); @@ -306,8 +306,8 @@ private static void updateMessageTextFlow(String text, Text textSiliconExperimen int ind1 = text.indexOf("{{"); int ind2 = text.lastIndexOf("}}"); String startText = text.substring(0, ind1); - String linkText = text.substring(ind1+2, ind2); - String endText = text.substring(ind2+2); + String linkText = text.substring(ind1+2, ind2).strip(); + String endText = text.substring(ind2+2).strip(); textSiliconExperimental.setText(startText); linkSilicon.setText(linkText); textSiliconExperimental2.setText(endText); diff --git a/qupath-gui-fx/src/main/resources/qupath/lib/gui/localization/qupath-gui-strings.properties b/qupath-gui-fx/src/main/resources/qupath/lib/gui/localization/qupath-gui-strings.properties index 80f4936a4..96a9f8b15 100644 --- a/qupath-gui-fx/src/main/resources/qupath/lib/gui/localization/qupath-gui-strings.properties +++ b/qupath-gui-fx/src/main/resources/qupath/lib/gui/localization/qupath-gui-strings.properties @@ -46,7 +46,9 @@ Welcome.cite = Don't forget to cite the latest QuPath paper when you use the sof Welcome.clickForDetails = Click here for details Welcome.getStarted = Get started! Welcome.defaultMessage = Find out more about QuPath, customize key options,\nor click 'Get started!' to close this message -Welcome.macOsAarch64 = Bio-Formats does not yet fully support Apple Silicon -\nsome image formats (e.g. .czi) may not work\n{{Click here}} for more information, or try the Intel build instead. +Welcome.macOsAarch64 = Bio-Formats does not yet fully support Apple Silicon -\n\ + a few image formats (e.g. .czi, .ndpis) only work with the Intel build\n\ + {{Click here}} for more information. Startup.scriptTitle = Startup script Startup.scriptRun = Running startup script\n%s From f2a2de466a452b9c5636e9e71b099da4855f6af6 Mon Sep 17 00:00:00 2001 From: Leo Leplat <60394504+Rylern@users.noreply.github.com> Date: Fri, 6 Sep 2024 16:37:09 +0200 Subject: [PATCH 278/866] Added SlicedImageServer --- .../lib/images/servers/SlicedImageServer.java | 113 +++++++ .../images/servers/TestSlicedImageServer.java | 288 ++++++++++++++++++ 2 files changed, 401 insertions(+) create mode 100644 qupath-core/src/main/java/qupath/lib/images/servers/SlicedImageServer.java create mode 100644 qupath-core/src/test/java/qupath/lib/images/servers/TestSlicedImageServer.java diff --git a/qupath-core/src/main/java/qupath/lib/images/servers/SlicedImageServer.java b/qupath-core/src/main/java/qupath/lib/images/servers/SlicedImageServer.java new file mode 100644 index 000000000..ddfbbfdb6 --- /dev/null +++ b/qupath-core/src/main/java/qupath/lib/images/servers/SlicedImageServer.java @@ -0,0 +1,113 @@ +package qupath.lib.images.servers; + +import qupath.lib.io.GsonTools; +import qupath.lib.regions.RegionRequest; + +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.util.Map; + +/** + * ImageServer that treats a particular set of z-slices and timepoints of another ImageServer + * as a full image. + */ +public class SlicedImageServer extends TransformingImageServer { + + private final ImageServerMetadata metadata; + private final int zStart; + private final int zEnd; + private final int tStart; + private final int tEnd; + + /** + * Create an ImageServer that represents a particular set of z-slices and timepoints of another ImageServer. + *

    + * Index parameters of this function are not taken into account if their values is out of range (for example, + * a zStart of -1, or a tEnd of 10 with an image having only 5 timepoints). + * + * @param inputServer the input image to slice + * @param zStart the inclusive 0-based index of the first slice to consider + * @param zEnd the inclusive 0-based index of the last slide to consider + * @param tStart the inclusive 0-based index of the first timepoint to consider + * @param tEnd the inclusive 0-based index of the last timepoint to consider + * @throws IllegalArgumentException when a start index is greater than its corresponding end index + */ + public SlicedImageServer( + ImageServer inputServer, + int zStart, + int zEnd, + int tStart, + int tEnd + ) { + super(inputServer); + + this.zStart = setNumberInRange(zStart, inputServer.nZSlices() - 1); + this.zEnd = setNumberInRange(zEnd, inputServer.nZSlices() - 1); + this.tStart = setNumberInRange(tStart, inputServer.nTimepoints() - 1); + this.tEnd = setNumberInRange(tEnd, inputServer.nTimepoints() - 1); + + checkOrder(this.zStart, this.zEnd, "z-slice"); + checkOrder(this.tStart, this.tEnd, "timepoint"); + + metadata = new ImageServerMetadata.Builder(inputServer.getMetadata()) + .sizeZ(this.zEnd - this.zStart + 1) + .sizeT(this.tEnd - this.tStart + 1) + .build(); + } + + @Override + protected ImageServerBuilder.ServerBuilder createServerBuilder() { + return new ImageServers.SlicedImageServerBuilder( + getMetadata(), + getWrappedServer().getBuilder(), + zStart, + zEnd, + tStart, + tEnd + ); + } + + @Override + protected String createID() { + return getClass().getName() + ": + " + getWrappedServer().getPath() + " " + GsonTools.getInstance().toJson(Map.of( + "minZSlice", zStart, + "maxZSlice", zEnd, + "minTimepoint", tStart, + "maxTimepoint", tEnd + )); + } + + @Override + public String getServerType() { + return "Sliced image server"; + } + + @Override + public ImageServerMetadata getOriginalMetadata() { + return metadata; + } + + @Override + public BufferedImage readRegion(final RegionRequest request) throws IOException { + return getWrappedServer().readRegion(RegionRequest.createInstance( + request.getPath(), + request.getDownsample(), + request.getX(), + request.getY(), + request.getWidth(), + request.getHeight(), + request.getZ() + zStart, + request.getT() + tStart + )); + } + + private static int setNumberInRange(int number, int max) { + return Math.max(0, Math.min(number, max)); + } + + private static void checkOrder(int min, int max, String name) { + if (min > max) { + throw new IllegalArgumentException(String.format("The min %s is greater than the max %s", name, name)); + } + } +} diff --git a/qupath-core/src/test/java/qupath/lib/images/servers/TestSlicedImageServer.java b/qupath-core/src/test/java/qupath/lib/images/servers/TestSlicedImageServer.java new file mode 100644 index 000000000..810f50380 --- /dev/null +++ b/qupath-core/src/test/java/qupath/lib/images/servers/TestSlicedImageServer.java @@ -0,0 +1,288 @@ +package qupath.lib.images.servers; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import qupath.lib.color.ColorModelFactory; +import qupath.lib.regions.RegionRequest; + +import java.awt.image.BandedSampleModel; +import java.awt.image.BufferedImage; +import java.awt.image.DataBuffer; +import java.awt.image.DataBufferDouble; +import java.awt.image.WritableRaster; +import java.net.URI; +import java.util.Collection; +import java.util.List; + +public class TestSlicedImageServer { + + @Test + void Check_Number_Of_Z_Slices() throws Exception { + ImageServer sampleServer = new SampleImageServer(); + int zStart = 1; + int zEnd = 3; + int expectedNumberOfZSlices = zEnd - zStart + 1; + ImageServer slicedServer = new SlicedImageServer(sampleServer, zStart, zEnd, 0, 0); + + int numberOfZSlices = slicedServer.nZSlices(); + + Assertions.assertEquals(expectedNumberOfZSlices, numberOfZSlices); + + slicedServer.close(); + sampleServer.close(); + } + + @Test + void Check_Number_Of_Z_Slices_When_Out_Of_Bound() throws Exception { + ImageServer sampleServer = new SampleImageServer(); + int zStart = -1; + int zEnd = sampleServer.nZSlices() + 10; + int expectedNumberOfZSlices = sampleServer.nZSlices(); + ImageServer slicedServer = new SlicedImageServer(sampleServer, zStart, zEnd, 0, 0); + + int numberOfZSlices = slicedServer.nZSlices(); + + Assertions.assertEquals(expectedNumberOfZSlices, numberOfZSlices); + + slicedServer.close(); + sampleServer.close(); + } + + @Test + void Check_Number_Of_Z_Slices_When_Min_Greater_Than_Max() throws Exception { + ImageServer sampleServer = new SampleImageServer(); + int zStart = 5; + int zEnd = zStart - 2; + + Assertions.assertThrows(IllegalArgumentException.class, () -> { + ImageServer slicedServer = new SlicedImageServer(sampleServer, zStart, zEnd, 0, 0); + slicedServer.close(); + }); + + sampleServer.close(); + } + + @Test + void Check_Correct_Slice_Read() throws Exception { + ImageServer sampleServer = new SampleImageServer(); + int zStart = 1; + int zEnd = 3; + int zToRead = 1; + ImageServer slicedServer = new SlicedImageServer(sampleServer, zStart, zEnd, 0, 0); + BufferedImage expectedImage = sampleServer.readRegion(RegionRequest.createInstance( + slicedServer.getPath(), + 1, + 0, + 0, + slicedServer.getWidth(), + slicedServer.getHeight(), + zToRead + zStart, + 0 + )); + + BufferedImage image = slicedServer.readRegion(RegionRequest.createInstance( + slicedServer.getPath(), + 1, + 0, + 0, + slicedServer.getWidth(), + slicedServer.getHeight(), + zToRead, + 0 + )); + + Assertions.assertTrue(bufferedImagesEqual(expectedImage, image)); + + slicedServer.close(); + sampleServer.close(); + } + + @Test + void Check_Number_Of_Timepoints() throws Exception { + ImageServer sampleServer = new SampleImageServer(); + int tStart = 1; + int tEnd = 3; + int expectedNumberOfTimepoints = tEnd - tStart + 1; + ImageServer slicedServer = new SlicedImageServer(sampleServer, 0, 0, tStart, tEnd); + + int numberOfTimepoints = slicedServer.nTimepoints(); + + Assertions.assertEquals(expectedNumberOfTimepoints, numberOfTimepoints); + + slicedServer.close(); + sampleServer.close(); + } + + @Test + void Check_Number_Of_Timepoints_When_Out_Of_Bound() throws Exception { + ImageServer sampleServer = new SampleImageServer(); + int tStart = -1; + int tEnd = sampleServer.nTimepoints() + 10; + int expectedNumberOfTimepoints = sampleServer.nTimepoints(); + ImageServer slicedServer = new SlicedImageServer(sampleServer, 0, 0, tStart, tEnd); + + int numberOfTimepoints = slicedServer.nTimepoints(); + + Assertions.assertEquals(expectedNumberOfTimepoints, numberOfTimepoints); + + slicedServer.close(); + sampleServer.close(); + } + + @Test + void Check_Number_Of_Timepoints_When_Min_Greater_Than_Max() throws Exception { + ImageServer sampleServer = new SampleImageServer(); + int tStart = 5; + int tEnd = tStart - 2; + + Assertions.assertThrows(IllegalArgumentException.class, () -> { + ImageServer slicedServer = new SlicedImageServer(sampleServer, 0, 0, tStart, tEnd); + slicedServer.close(); + }); + + sampleServer.close(); + } + + @Test + void Check_Correct_Timepoint_Read() throws Exception { + ImageServer sampleServer = new SampleImageServer(); + int tStart = 1; + int tEnd = 3; + int tToRead = 1; + ImageServer slicedServer = new SlicedImageServer(sampleServer, 0, 0, tStart, tEnd); + BufferedImage expectedImage = sampleServer.readRegion(RegionRequest.createInstance( + slicedServer.getPath(), + 1, + 0, + 0, + slicedServer.getWidth(), + slicedServer.getHeight(), + 0, + tToRead + tStart + )); + + BufferedImage image = slicedServer.readRegion(RegionRequest.createInstance( + slicedServer.getPath(), + 1, + 0, + 0, + slicedServer.getWidth(), + slicedServer.getHeight(), + 0, + tToRead + )); + + Assertions.assertTrue(bufferedImagesEqual(expectedImage, image)); + + slicedServer.close(); + sampleServer.close(); + } + + private static class SampleImageServer extends AbstractImageServer { + + private static final int IMAGE_WIDTH = 50; + private static final int IMAGE_HEIGHT = 25; + private static final int NUMBER_OF_Z_SLICES = 10; + private static final int NUMBER_OF_TIMEPOINTS = 5; + + public SampleImageServer() { + super(BufferedImage.class); + } + + @Override + protected ImageServerBuilder.ServerBuilder createServerBuilder() { + return null; + } + + @Override + protected String createID() { + return null; + } + + @Override + public Collection getURIs() { + return List.of(); + } + + @Override + public String getServerType() { + return "Sample server"; + } + + @Override + public ImageServerMetadata getOriginalMetadata() { + return new ImageServerMetadata.Builder() + .width(IMAGE_WIDTH) + .height(IMAGE_HEIGHT) + .sizeZ(NUMBER_OF_Z_SLICES) + .sizeT(NUMBER_OF_TIMEPOINTS) + .pixelType(PixelType.FLOAT64) + .channels(List.of( + ImageChannel.getInstance("c1", 1), + ImageChannel.getInstance("c2", 2), + ImageChannel.getInstance("c3", 3), + ImageChannel.getInstance("c4", 4), + ImageChannel.getInstance("c5", 5) + )) + .name("name") + .build(); + } + + @Override + public BufferedImage readRegion(RegionRequest request) { + DataBuffer dataBuffer = createDataBuffer(request); + + return new BufferedImage( + ColorModelFactory.createColorModel(getMetadata().getPixelType(), getMetadata().getChannels()), + WritableRaster.createWritableRaster( + new BandedSampleModel(dataBuffer.getDataType(), request.getWidth(), request.getHeight(), nChannels()), + dataBuffer, + null + ), + false, + null + ); + } + + private DataBuffer createDataBuffer(RegionRequest request) { + double[][] array = new double[nChannels()][]; + + for (int c = 0; c < array.length; c++) { + array[c] = getPixels(request, c); + } + + return new DataBufferDouble(array, request.getWidth() * request.getHeight() / 8); + } + + private double[] getPixels(RegionRequest request, int channel) { + double[] pixels = new double[request.getWidth() * request.getHeight()]; + + for (int y=0; y Date: Fri, 6 Sep 2024 16:37:29 +0200 Subject: [PATCH 279/866] Added SlicedImageServerBuilder --- .../lib/images/servers/ImageServers.java | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/qupath-core/src/main/java/qupath/lib/images/servers/ImageServers.java b/qupath-core/src/main/java/qupath/lib/images/servers/ImageServers.java index c6a0a0763..f02b10ca4 100644 --- a/qupath-core/src/main/java/qupath/lib/images/servers/ImageServers.java +++ b/qupath-core/src/main/java/qupath/lib/images/servers/ImageServers.java @@ -529,6 +529,60 @@ public ServerBuilder updateURIs(Map updateMap) { } } + + static class SlicedImageServerBuilder extends AbstractServerBuilder { + + private final ServerBuilder builder; + private final int zStart; + private final int zEnd; + private final int tStart; + private final int tEnd; + + public SlicedImageServerBuilder( + ImageServerMetadata metadata, + ServerBuilder builder, + int zStart, + int zEnd, + int tStart, + int tEnd + ) { + super(metadata); + + this.builder = builder; + this.zStart = zStart; + this.zEnd = zEnd; + this.tStart = tStart; + this.tEnd = tEnd; + } + + @Override + protected ImageServer buildOriginal() throws Exception { + return new SlicedImageServer(builder.build(), zStart, zEnd, tStart, tEnd); + } + + @Override + public Collection getURIs() { + return builder.getURIs(); + } + + @Override + public ServerBuilder updateURIs(Map updateMap) { + ServerBuilder newBuilder = builder.updateURIs(updateMap); + if (newBuilder == builder) { + return this; + } else { + return new SlicedImageServerBuilder( + getMetadata().orElse(null), + newBuilder, + zStart, + zEnd, + tStart, + tEnd + ); + } + } + + } static class AffineTransformImageServerBuilder extends AbstractServerBuilder { From 1e3fb86cca539c402eae3a231655297f4799d3d6 Mon Sep 17 00:00:00 2001 From: Leo Leplat <60394504+Rylern@users.noreply.github.com> Date: Fri, 6 Sep 2024 16:38:28 +0200 Subject: [PATCH 280/866] Improved OMEZarrWriter --- .../writers/ome/zarr/OMEZarrWriter.java | 71 ++++++++++++++++++- 1 file changed, 69 insertions(+), 2 deletions(-) diff --git a/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriter.java b/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriter.java index 7a8993af2..db36a0305 100644 --- a/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriter.java +++ b/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriter.java @@ -10,15 +10,19 @@ import loci.formats.gui.AWTImageTools; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import qupath.lib.images.servers.CroppedImageServer; import qupath.lib.images.servers.ImageServer; import qupath.lib.images.servers.ImageServers; import qupath.lib.images.servers.PixelCalibration; +import qupath.lib.images.servers.SlicedImageServer; import qupath.lib.images.servers.TileRequest; import qupath.lib.images.servers.TileRequestManager; +import qupath.lib.regions.ImageRegion; import java.awt.image.BufferedImage; import java.io.IOException; import java.util.ArrayList; +import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -49,7 +53,7 @@ public class OMEZarrWriter implements AutoCloseable { private final ExecutorService executorService; private OMEZarrWriter(Builder builder) throws IOException { - server = ImageServers.pyramidalizeTiled( + ImageServer pyramidalServer = ImageServers.pyramidalizeTiled( builder.server, getChunkSize( builder.tileWidth > 0 ? builder.tileWidth : builder.server.getMetadata().getPreferredTileWidth(), @@ -63,6 +67,23 @@ private OMEZarrWriter(Builder builder) throws IOException { ), builder.downsamples.length == 0 ? builder.server.getPreferredDownsamples() : builder.downsamples ); + ImageServer pyramidalAndSlicedServer; + if (builder.zStart == 0 && builder.zEnd == builder.server.nZSlices()-1 && builder.tStart == 0 && builder.tEnd == builder.server.nTimepoints()-1) { + pyramidalAndSlicedServer = pyramidalServer; + } else { + pyramidalAndSlicedServer = new SlicedImageServer( + pyramidalServer, + builder.zStart, + builder.zEnd, + builder.tStart, + builder.tEnd + ); + } + if (builder.boundingBox == null) { + server = pyramidalAndSlicedServer; + } else { + server = new CroppedImageServer(pyramidalAndSlicedServer, builder.boundingBox); + } OMEZarrAttributesCreator attributes = new OMEZarrAttributesCreator( server.getMetadata().getName(), @@ -180,6 +201,11 @@ public static class Builder { private int maxNumberOfChunks = 50; private int tileWidth = 512; private int tileHeight = 512; + private ImageRegion boundingBox = null; + private int zStart = 0; + private int zEnd; + private int tStart = 0; + private int tEnd; /** * Create the builder. @@ -195,6 +221,8 @@ public Builder(ImageServer server, String path) { this.server = server; this.path = path; + this.zEnd = this.server.nZSlices() - 1; + this.tEnd = this.server.nTimepoints() - 1; } /** @@ -298,6 +326,45 @@ public Builder setTileHeight(int tileHeight) { return this; } + /** + * Define a region (on the x-axis and y-axis) of the input image to consider. + * + * @param boundingBox the region to consider. Only the x, y, width, and height + * of this region are taken into account. Can be null to use + * the entire image + * @return this builder + */ + public Builder setBoundingBox(ImageRegion boundingBox) { + this.boundingBox = boundingBox; + return this; + } + + /** + * Define the z-slices of the input image to consider. + * + * @param zStart the 0-based inclusive index of the first z-slice to consider + * @param zEnd the 0-based inclusive index of the last z-slice to consider + * @return this builder + */ + public Builder setZSlices(int zStart, int zEnd) { + this.zStart = zStart; + this.zEnd = zEnd; + return this; + } + + /** + * Define the timepoints of the input image to consider. + * + * @param tStart the 0-based inclusive index of the first timepoint to consider + * @param tEnd the 0-based inclusive index of the last timepoint to consider + * @return this builder + */ + public Builder setTimepoints(int tStart, int tEnd) { + this.tStart = tStart; + this.tEnd = tEnd; + return this; + } + /** * Create a new instance of {@link OMEZarrWriter}. This will also * create an empty image on the provided path. @@ -376,7 +443,7 @@ private static int[] getChunksOfImage(ImageServer server) { chunks.add(1); } if (server.nZSlices() > 1) { - chunks.add(Math.max(server.getMetadata().getPreferredTileWidth(), server.getMetadata().getPreferredTileHeight())); + chunks.add(1); } chunks.add(server.getMetadata().getPreferredTileHeight()); chunks.add(server.getMetadata().getPreferredTileWidth()); From c817130d8df7c5668c841f41ccd9aa48a2d0ca2a Mon Sep 17 00:00:00 2001 From: Leo Leplat <60394504+Rylern@users.noreply.github.com> Date: Fri, 6 Sep 2024 16:40:10 +0200 Subject: [PATCH 281/866] Improved ConvertCommand and added tests --- .../images/writers/ome/ConvertCommand.java | 380 ++++++++++-------- .../writers/ome/TestConvertCommand.java | 355 ++++++++++++++++ 2 files changed, 567 insertions(+), 168 deletions(-) create mode 100644 qupath-extension-bioformats/src/test/java/qupath/lib/images/writers/ome/TestConvertCommand.java diff --git a/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/ConvertCommand.java b/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/ConvertCommand.java index 677af8faf..e1eee6dd0 100644 --- a/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/ConvertCommand.java +++ b/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/ConvertCommand.java @@ -23,9 +23,9 @@ import java.awt.image.BufferedImage; import java.io.File; -import java.io.IOException; -import java.util.Arrays; +import java.util.Optional; import java.util.regex.Pattern; +import java.util.stream.DoubleStream; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -41,47 +41,62 @@ import qupath.lib.images.servers.ImageServerProvider; import qupath.lib.images.servers.ImageServers; import qupath.lib.images.servers.bioformats.BioFormatsServerBuilder; -import qupath.lib.images.writers.ome.OMEPyramidWriter.Builder; -import qupath.lib.images.writers.ome.OMEPyramidWriter.CompressionType; +import qupath.lib.images.writers.ome.zarr.OMEZarrWriter; +import qupath.lib.regions.ImageRegion; /** - * Allows command line option to convert an input image to OME-TIFF + * Allows command line option to convert an input image to OME-TIFF or OME-Zarr. * * @author Melvin Gelbard */ -@Command(name = "convert-ome", description = "Converts an input image to OME-TIFF.", sortOptions = false) +@Command(name = "convert-ome", description = "Converts an input image to OME-TIFF or OME-Zarr.", sortOptions = false) public class ConvertCommand implements Runnable, Subcommand { private static final Logger logger = LoggerFactory.getLogger(ConvertCommand.class); - @Parameters(index = "0", description="Path to the file to convert.", paramLabel = "input") + @Parameters(index = "0", paramLabel = "input", description="Path of the file to convert.") private File inputFile; - @Parameters(index = "1", description="Path of the output file.", paramLabel = "output") + @Parameters(index = "1", paramLabel = "output", description={ + "Path of the output file.", + "The extension of the file must be .ome.zarr to create a Zarr file", + "or .ome.tiff to create an OME TIFF file." + }) private File outputFile; - @Option(names = {"-r", "--crop"}, defaultValue = "", description = {"Bounding box to crop the input image.", - "Defined in terms of full-resolution pixel coordinates in the form x,y,w,h. ", - "If empty (default), the full image will be exported."}) + @Option(names = {"-r", "--crop"}, defaultValue = "", description = { + "Bounding box to crop the input image.", + "Defined in terms of full-resolution pixel coordinates in the form x,y,w,h.", + "If empty (default), the full image will be exported." + }) private String crop; - @Option(names = {"-z", "--zslices"}, defaultValue = "all", description = {"Request which z-slice(s) will be exported.", - "Value may be \"all\" (the default), a single number (e.g. \"1\") or a range (e.g. \"1-5\"). Indices and 1-based and ranges are inclusive."}) + @Option(names = {"-z", "--zslices"}, defaultValue = "all", description = { + "Request which z-slice(s) will be exported.", + "Value may be \"all\" (the default), a single number (e.g. \"1\") or a range (e.g. \"1-5\"). Indices are 1-based and ranges are inclusive." + }) private String zSlices; - @Option(names = {"-t", "--timepoints"}, defaultValue = "all", description = {"Request which timepoints will be exported.", - "Value may be \"all\" (the default), a single number (e.g. \"1\") or a range (e.g. \"1-5\"). Indices and 1-based and ranges are inclusive."}) + @Option(names = {"-t", "--timepoints"}, defaultValue = "all", description = { + "Request which timepoints will be exported.", + "Value may be \"all\" (the default), a single number (e.g. \"1\") or a range (e.g. \"1-5\"). Indices are 1-based and ranges are inclusive." + }) private String timepoints; @Option(names = {"-d", "--downsample"}, defaultValue = "1.0", description = "Downsample the input image by the given factor (default=1).") private double downsample; - @Option(names = {"-y", "--pyramid-scale"}, defaultValue = "1.0", description = {"Scale factor for pyramidal images.", - "Each pyramidal level is scaled down by the specified factor (> 1)."}) + @Option(names = {"-y", "--pyramid-scale"}, defaultValue = "1.0", description = { + "Scale factor for pyramidal images.", + "Each pyramidal level is scaled down by the specified factor (> 1)." + }) private double pyramid; - @Option(names = {"--big-tiff"}, defaultValue = Option.NULL_VALUE, description = {"Request to write a big tiff, which is required when writing a TIFF images > 4GB.", - "Default is to automatically decide based on image size. Choose --big-tiff=false to force a non-big-tiff to be written."}) + @Option(names = {"--big-tiff"}, defaultValue = Option.NULL_VALUE, description = { + "Request to write a big tiff, which is required when writing a TIFF images > 4GB.", + "Default is to automatically decide based on image size. Choose --big-tiff=false to force a non-big-tiff to be written.", + "Only relevant for TIFF files." + }) private Boolean bigTiff; @Option(names = {"--tile-size"}, defaultValue = "-1", description = "Set the tile size (of equal height and width).") @@ -93,44 +108,67 @@ public class ConvertCommand implements Runnable, Subcommand { @Option(names = {"--tile-height"}, defaultValue = "512", description = "Set the tile height.") private int tileHeight; - @Option(names = {"-c", "--compression"}, defaultValue = "DEFAULT", description = {"Type of compression to use for conversion.", - "Options: ${COMPLETION-CANDIDATES}"}) - private CompressionType compression; + @Option(names = {"-c", "--compression"}, defaultValue = "DEFAULT", description = { + "Type of compression to use for writing TIFF files.", + "Only relevant for TIFF files", + "Options: ${COMPLETION-CANDIDATES}" + }) + private OMEPyramidWriter.CompressionType compression; - @Option(names = {"-p", "--parallelize"}, defaultValue = "false", description = "Parallelize tile export if possible.", paramLabel = "parallelization") + @Option(names = {"-p", "--parallelize"}, defaultValue = "false", paramLabel = "parallelization", description = "Parallelize tile export if possible.") private boolean parallelize; @Option(names = {"--overwrite"}, defaultValue = "false", description = "Overwrite any existing file with the same name as the output.") private boolean overwrite = false; - @Option(names = {"--series"}, description = "Series number. Setting this will ensure the image is opened using Bio-Formats and control which image is read from the file. " - + "If it is not specified, the default image will be read (typically series 0).") + @Option(names = {"--series"}, description = { + "Series number.", + "Setting this will ensure the image is opened using Bio-Formats and control which image is read from the file.", + "If it is not specified, the default image will be read (typically series 0)." + }) private int series = -1; @Option(names = {"-h", "--help"}, usageHelp = true, description = "Show this help message and exit.") private boolean usageHelpRequested; - + private enum OutputType { + TIFF(".ome.tif"), + ZARR(".ome.zarr"); + private final String extension; + + OutputType(String extension) { + this.extension = extension; + } + + public String getExtension() { + return extension; + } + } + + private record Range(int start, int end) {} + @Override public void run() { - long startTime = System.currentTimeMillis(); - - try { - if (inputFile == null || outputFile == null) - throw new IOException("Incorrect given path(s)"); - } catch (IOException e) { - logger.error(e.getLocalizedMessage()); + + if (inputFile == null || outputFile == null) { + logger.error("Incorrect given path(s)"); System.exit(-1); } - - // Change name if not ending with .ome.tif - if (!outputFile.getAbsolutePath().toLowerCase().endsWith(".ome.tif")) - outputFile = new File(outputFile.getParentFile(), GeneralTools.getNameWithoutExtension(outputFile) + ".ome.tif"); + + // Set output type to TIFF if output extension not recognized + OutputType outputType = outputFile.getAbsolutePath().toLowerCase().endsWith(OutputType.ZARR.getExtension()) ? OutputType.ZARR : OutputType.TIFF; + + // Change name if not ending with correct extension + if (!outputFile.getAbsolutePath().toLowerCase().endsWith(outputType.getExtension())) { + outputFile = new File(outputFile.getParentFile(), GeneralTools.getNameWithoutExtension(outputFile) + outputType.getExtension()); + } + if (outputFile.exists() && !overwrite) { logger.error("Output file " + outputFile + " exists!"); System.exit(-1); } + if (inputFile.equals(outputFile)) { logger.error("Input and output files are the same!"); System.exit(-1); @@ -145,151 +183,90 @@ public void run() { createTileCache(); try (ImageServer server = ImageServers.buildServer(inputFile.toURI(), args)) { - - // Get compression from user (or CompressionType.DEFAULT) -// CompressionType compressionType = stringToCompressionType(compression); - CompressionType compressionType = compression; - - // Check that compression is compatible with image - if (!Arrays.stream(CompressionType.values()).filter(c -> c.supportsImage(server)).anyMatch(c -> c == compressionType)) { - logger.error("Chosen compression " + compressionType.toString() + " is not compatible with the input image."); - } - - // No longer needed because of a494568f - AbstractTileableImageServer now better handles simultaneous tile requests, - // and parallelization can still help when requesting regions from an ImageServer that contains an in-memory image -// // Check if output will be a single tile -// boolean singleTile = server.getTileRequestManager().getTileRequests(RegionRequest.createInstance(server)).size() == 1; -// -// if (singleTile) -// parallelize = false; - if (tileSize > -1) { tileWidth = tileSize; tileHeight = tileSize; } - - Builder builder = new OMEPyramidWriter.Builder(server) - .compression(compressionType) - .tileSize(tileWidth, tileHeight) - .parallelize(parallelize); - - if (bigTiff != null) - builder = builder.bigTiff(bigTiff.booleanValue()); - - // Make pyramidal, if requested - if (downsample < 1) + + if (downsample < 1) { downsample = server.getDownsampleForResolution(0); - - if (pyramid > 1) - builder.scaledDownsampling(downsample, pyramid); - else - builder.downsamples(downsample); - - String patternRange = "(\\d+)-(\\d+)"; - String patternInteger = "\\d+"; - - // Parse z-slices, remembering to convert from 1-based (inclusive) to 0-based (upper value exclusive) indexing - if (zSlices == null || zSlices.isBlank() || "all".equals(zSlices)) { - builder.allZSlices(); - } else if (zSlices.matches(patternRange)) { - int zStart = Integer.parseInt(zSlices.substring(0, zSlices.indexOf("-"))); - int zEnd = Integer.parseInt(zSlices.substring(zSlices.indexOf("-")+1)); - if (zEnd == zStart) - builder.zSlice(zStart-1); - else if (zStart > zEnd) { - logger.error("Invalid range of --zslices (must be ascending): " + zSlices); - System.exit(-1); - } else - builder.zSlices(zStart-1, zEnd); - } else if (zSlices.matches(patternInteger)) { - int z = Integer.parseInt(zSlices); - builder.zSlice(z-1); - } else { - logger.error("Unknown value for --zslices: " + zSlices); - System.exit(-1); } - - // Parse timepoints, remembering to convert from 1-based (inclusive) to 0-based (upper value exclusive) indexing - if ("all".equals(timepoints)) { - builder.allTimePoints(); - } else if (timepoints.matches(patternRange)) { - int tStart = Integer.parseInt(timepoints.substring(0, timepoints.indexOf("-"))); - int tEnd = Integer.parseInt(timepoints.substring(timepoints.indexOf("-")+1)); - if (tStart == tEnd) - builder.timePoint(tStart-1); - else if (tStart > tEnd) { - logger.error("Invalid range of --timepoints (must be ascending): " + timepoints); - System.exit(-1); - } else - builder.timePoints(tStart-1, tEnd); - } else if (timepoints.matches(patternInteger)) { - int t = Integer.parseInt(timepoints); - builder.timePoint(t-1); - } else { - logger.error("Unknown value for --timepoints: " + timepoints); - System.exit(-1); - } - - // Parse the bounding box, if required - if (crop != null && !crop.isBlank()) { - var matcher = Pattern.compile("(\\d+),(\\d+),(\\d+),(\\d+)").matcher(crop); - if (matcher.matches()) { - int x = Integer.parseInt(matcher.group(1)); - int y = Integer.parseInt(matcher.group(2)); - int w = Integer.parseInt(matcher.group(3)); - int h = Integer.parseInt(matcher.group(4)); - builder.region(x, y, w, h); - } else { - logger.error("Unknown value for --crop: " + crop); - System.exit(-1); + + Range zSlicesRange = getRange(zSlices, server.nZSlices(), "zslices"); + Range timepointsRange = getRange(timepoints, server.nTimepoints(), "timepoints"); + Optional boundingBox = getBoundingBox(crop); + + switch (outputType) { + case TIFF -> { + if (!compression.supportsImage(server)) { + logger.error("Chosen compression " + compression.toString() + " is not compatible with the input image."); + System.exit(-1); + } + + OMEPyramidWriter.Builder builder = new OMEPyramidWriter.Builder(server) + .compression(compression) + .tileSize(tileWidth, tileHeight) + .parallelize(parallelize) + .zSlices(zSlicesRange.start(), zSlicesRange.end()) + .timePoints(timepointsRange.start(), timepointsRange.end()); + + if (bigTiff != null) { + builder = builder.bigTiff(bigTiff); + } + + if (pyramid > 1) { + builder.scaledDownsampling(downsample, pyramid); + } else { + builder.downsamples(downsample); + } + + if (boundingBox.isPresent()) { + builder.region( + boundingBox.get().getX(), + boundingBox.get().getY(), + boundingBox.get().getWidth(), + boundingBox.get().getHeight() + ); + } + + builder.build().writeSeries(outputFile.getAbsolutePath()); + } + case ZARR -> { + OMEZarrWriter.Builder builder = new OMEZarrWriter.Builder(server, outputFile.getAbsolutePath()) + .setTileWidth(tileWidth) + .setTileHeight(tileHeight) + .setBoundingBox(boundingBox.orElse(null)) + .setZSlices(zSlicesRange.start(), zSlicesRange.end()-1) + .setTimepoints(timepointsRange.start(), timepointsRange.end()-1); + + if (!parallelize) { + builder.setNumberOfThreads(1); + } + + if (pyramid > 1) { + builder.setDownsamples(DoubleStream.iterate( + downsample, + d -> (int) (server.getWidth() / d) > tileWidth && + (int) (server.getHeight() / d) > tileHeight, + d -> d * pyramid + ).toArray()); + } else { + builder.setDownsamples(downsample); + } + + try (OMEZarrWriter writer = builder.build()) { + writer.writeImage(); + } } } - - builder.build().writeSeries(outputFile.getPath()); - long duration = System.currentTimeMillis() - startTime; logger.info(String.format("%s written in %.1f seconds", outputFile.getAbsolutePath(), duration/1000.0)); - } catch (Exception e) { - logger.error(e.getLocalizedMessage(), e); + logger.error("Error while creating image", e); System.exit(-1); } } - -// private CompressionType stringToCompressionType(String compressionParam) { -// switch (compressionParam.toLowerCase()) { -// case "default": -// return CompressionType.DEFAULT; -// -// case "jpeg-2000": -// case "jpeg2000": -// case "j2k": -// return CompressionType.J2K; -// -// case "jpeg-2000-lossy": -// case "jpeg2000lossy": -// case "j2k-lossy": -// return CompressionType.J2K_LOSSY; -// -// case "jpeg": -// return CompressionType.JPEG; -// -// case "lzw": -// return CompressionType.LZW; -// -// case "uncompressed": -// return CompressionType.UNCOMPRESSED; -// -// case "zlib": -// return CompressionType.ZLIB; -// -// default: -// return CompressionType.DEFAULT; -// } -// } - - /** * The tile cache is usually set when initializing the GUI; here, we need to create one for performance */ @@ -313,5 +290,72 @@ private void createTileCache() { var imageRegionStore = ImageRegionStoreFactory.createImageRegionStore(tileCacheSize); ImageServerProvider.setCache(imageRegionStore.getCache(), BufferedImage.class); } - + + /** + * Parse the provided range text and return the 0-based + * start (inclusive) and end (exclusive) indices contained in this text. + * + * @param rangeText the text containing the indices to parse. It can be "all", + * a single number (e.g. "1") or a range (e.g. "1-5"). + * Indices are 1-based and ranges are inclusive. + * @param maxRange the maximum value the exclusive end index can have + * @param rangeLabel a text indicating what this range represent + * @return the 0-based start (inclusive) and end (exclusive) indices contained in the provided range text + */ + private static Range getRange(String rangeText, int maxRange, String rangeLabel) { + String patternRange = "(\\d+)-(\\d+)"; + String patternInteger = "\\d+"; + + if (rangeText == null || rangeText.isBlank() || "all".equals(rangeText)) { + return new Range(0, maxRange); + } else if (rangeText.matches(patternRange)) { + int start = Integer.parseInt(rangeText.substring(0, rangeText.indexOf("-"))); + int end = Integer.parseInt(rangeText.substring(rangeText.indexOf("-")+1)); + + if (start == end) { + return new Range(start-1, start); + } else if (start > end) { + logger.error(String.format("Invalid range of --%s (must be ascending): %s", rangeLabel, rangeText)); + System.exit(-1); + return null; + } else { + return new Range(start-1, end); + } + } else if (rangeText.matches(patternInteger)) { + int v = Integer.parseInt(rangeText); + return new Range(v-1, v); + } else { + logger.error(String.format("Unknown value for --%s: %s", rangeLabel, rangeText)); + System.exit(-1); + return null; + } + } + + /** + * Parse the provided bounding box text and return an ImageRegion + * corresponding to it. + * + * @param crop the bounding box text to parse + * @return an ImageRegion corresponding to the input text, or an empty + * Optional if the region couldn't be created. Only the x, y, width, and height + * of the returned region should be considered + */ + private static Optional getBoundingBox(String crop) { + if (crop != null && !crop.isBlank()) { + var matcher = Pattern.compile("(\\d+),(\\d+),(\\d+),(\\d+)").matcher(crop); + if (matcher.matches()) { + int x = Integer.parseInt(matcher.group(1)); + int y = Integer.parseInt(matcher.group(2)); + int w = Integer.parseInt(matcher.group(3)); + int h = Integer.parseInt(matcher.group(4)); + return Optional.of(ImageRegion.createInstance(x, y, w, h, 0, 0)); + } else { + logger.error("Unknown value for --crop: " + crop); + System.exit(-1); + return Optional.empty(); + } + } else { + return Optional.empty(); + } + } } \ No newline at end of file diff --git a/qupath-extension-bioformats/src/test/java/qupath/lib/images/writers/ome/TestConvertCommand.java b/qupath-extension-bioformats/src/test/java/qupath/lib/images/writers/ome/TestConvertCommand.java new file mode 100644 index 000000000..0a6f8ce4b --- /dev/null +++ b/qupath-extension-bioformats/src/test/java/qupath/lib/images/writers/ome/TestConvertCommand.java @@ -0,0 +1,355 @@ +package qupath.lib.images.writers.ome; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import picocli.CommandLine; +import qupath.lib.color.ColorModelFactory; +import qupath.lib.images.servers.AbstractImageServer; +import qupath.lib.images.servers.ImageChannel; +import qupath.lib.images.servers.ImageServer; +import qupath.lib.images.servers.ImageServerBuilder; +import qupath.lib.images.servers.ImageServerMetadata; +import qupath.lib.images.servers.ImageServerProvider; +import qupath.lib.images.servers.PixelType; +import qupath.lib.images.writers.ome.zarr.OMEZarrWriter; +import qupath.lib.regions.RegionRequest; + +import java.awt.image.BandedSampleModel; +import java.awt.image.BufferedImage; +import java.awt.image.DataBuffer; +import java.awt.image.DataBufferDouble; +import java.awt.image.WritableRaster; +import java.io.File; +import java.net.URI; +import java.nio.file.Paths; +import java.util.Collection; +import java.util.List; +import java.util.UUID; + +public class TestConvertCommand { + + private static final String inputImagePath = Paths.get(System.getProperty("java.io.tmpdir"), "image.ome.zarr").toString(); + + abstract static class GenericImage { + + abstract String getImageExtension(); + + @BeforeAll + static void createInputImage() throws Exception { + deleteDir(new File(inputImagePath)); + + try ( + ImageServer sampleServer = new SampleImageServer(); + OMEZarrWriter writer = new OMEZarrWriter.Builder(sampleServer, inputImagePath).build() + ) { + writer.writeImage(); + } + } + + @Test + void Check_Image_Not_Cropped() throws Exception { + ConvertCommand convertCommand = new ConvertCommand(); + CommandLine cmd = new CommandLine(convertCommand); + String outputImagePath = Paths.get(System.getProperty("java.io.tmpdir"), UUID.randomUUID() + getImageExtension()).toString(); + int expectedWidth = SampleImageServer.IMAGE_WIDTH; + + cmd.execute(inputImagePath, outputImagePath, "-r", ""); + + int imageWidth; + try (ImageServer server = ImageServerProvider.buildServer(outputImagePath, BufferedImage.class)) { + imageWidth = server.getWidth(); + } + Assertions.assertEquals(expectedWidth, imageWidth); + } + + @Test + void Check_Image_Cropped() throws Exception { + ConvertCommand convertCommand = new ConvertCommand(); + CommandLine cmd = new CommandLine(convertCommand); + String outputImagePath = Paths.get(System.getProperty("java.io.tmpdir"), UUID.randomUUID() + getImageExtension()).toString(); + int expectedWidth = 2; + + cmd.execute(inputImagePath, outputImagePath, "-r", String.format("0,0,%d,1", expectedWidth)); + + int imageWidth; + try (ImageServer server = ImageServerProvider.buildServer(outputImagePath, BufferedImage.class)) { + imageWidth = server.getWidth(); + } + Assertions.assertEquals(expectedWidth, imageWidth); + } + + @Test + void Check_Image_Not_Z_Sliced() throws Exception { + ConvertCommand convertCommand = new ConvertCommand(); + CommandLine cmd = new CommandLine(convertCommand); + String outputImagePath = Paths.get(System.getProperty("java.io.tmpdir"), UUID.randomUUID() + getImageExtension()).toString(); + int expectedZSlices = SampleImageServer.NUMBER_OF_Z_SLICES; + + cmd.execute(inputImagePath, outputImagePath, "-z", "all"); + + int zSlices; + try (ImageServer server = ImageServerProvider.buildServer(outputImagePath, BufferedImage.class)) { + zSlices = server.nZSlices(); + } + Assertions.assertEquals(expectedZSlices, zSlices); + } + + @Test + void Check_Image_Z_Sliced() throws Exception { + ConvertCommand convertCommand = new ConvertCommand(); + CommandLine cmd = new CommandLine(convertCommand); + String outputImagePath = Paths.get(System.getProperty("java.io.tmpdir"), UUID.randomUUID() + getImageExtension()).toString(); + int expectedZSlices = 1; + + cmd.execute(inputImagePath, outputImagePath, "-z", "1"); + + int zSlices; + try (ImageServer server = ImageServerProvider.buildServer(outputImagePath, BufferedImage.class)) { + zSlices = server.nZSlices(); + } + Assertions.assertEquals(expectedZSlices, zSlices); + } + + @Test + void Check_Image_Z_Sliced_By_Range() throws Exception { + ConvertCommand convertCommand = new ConvertCommand(); + CommandLine cmd = new CommandLine(convertCommand); + String outputImagePath = Paths.get(System.getProperty("java.io.tmpdir"), UUID.randomUUID() + getImageExtension()).toString(); + int expectedZSlices = 2; + + cmd.execute(inputImagePath, outputImagePath, "-z", "1-2"); + + int zSlices; + try (ImageServer server = ImageServerProvider.buildServer(outputImagePath, BufferedImage.class)) { + zSlices = server.nZSlices(); + } + Assertions.assertEquals(expectedZSlices, zSlices); + } + + @Test + void Check_Image_Not_T_Sliced() throws Exception { + ConvertCommand convertCommand = new ConvertCommand(); + CommandLine cmd = new CommandLine(convertCommand); + String outputImagePath = Paths.get(System.getProperty("java.io.tmpdir"), UUID.randomUUID() + getImageExtension()).toString(); + int expectedTimepoints = SampleImageServer.NUMBER_OF_TIMEPOINTS; + + cmd.execute(inputImagePath, outputImagePath, "-t", "all"); + + int timepoints; + try (ImageServer server = ImageServerProvider.buildServer(outputImagePath, BufferedImage.class)) { + timepoints = server.nTimepoints(); + } + Assertions.assertEquals(expectedTimepoints, timepoints); + } + + @Test + void Check_Image_T_Sliced() throws Exception { + ConvertCommand convertCommand = new ConvertCommand(); + CommandLine cmd = new CommandLine(convertCommand); + String outputImagePath = Paths.get(System.getProperty("java.io.tmpdir"), UUID.randomUUID() + getImageExtension()).toString(); + int expectedTimepoints = 1; + + cmd.execute(inputImagePath, outputImagePath, "-t", "1"); + + int timepoints; + try (ImageServer server = ImageServerProvider.buildServer(outputImagePath, BufferedImage.class)) { + timepoints = server.nTimepoints(); + } + Assertions.assertEquals(expectedTimepoints, timepoints); + } + + @Test + void Check_Image_T_Sliced_By_Range() throws Exception { + ConvertCommand convertCommand = new ConvertCommand(); + CommandLine cmd = new CommandLine(convertCommand); + String outputImagePath = Paths.get(System.getProperty("java.io.tmpdir"), UUID.randomUUID() + getImageExtension()).toString(); + int expectedTimepoints = 2; + + cmd.execute(inputImagePath, outputImagePath, "-t", "1-2"); + + int timepoints; + try (ImageServer server = ImageServerProvider.buildServer(outputImagePath, BufferedImage.class)) { + timepoints = server.nTimepoints(); + } + Assertions.assertEquals(expectedTimepoints, timepoints); + } + + @Test + void Check_Image_Not_Downsampled() throws Exception { + ConvertCommand convertCommand = new ConvertCommand(); + CommandLine cmd = new CommandLine(convertCommand); + String outputImagePath = Paths.get(System.getProperty("java.io.tmpdir"), UUID.randomUUID() + getImageExtension()).toString(); + int expectedWidth = SampleImageServer.IMAGE_WIDTH; + + cmd.execute(inputImagePath, outputImagePath, "-d", "1.0"); + + int width; + try (ImageServer server = ImageServerProvider.buildServer(outputImagePath, BufferedImage.class)) { + width = server.getWidth(); + } + Assertions.assertEquals(expectedWidth, width); + } + + @Test + void Check_Image_Downsampled() throws Exception { + ConvertCommand convertCommand = new ConvertCommand(); + CommandLine cmd = new CommandLine(convertCommand); + String outputImagePath = Paths.get(System.getProperty("java.io.tmpdir"), UUID.randomUUID() + getImageExtension()).toString(); + float downsample = 2; + int expectedWidth = (int) (SampleImageServer.IMAGE_WIDTH / downsample); + + cmd.execute(inputImagePath, outputImagePath, "-d", String.valueOf(downsample)); + + int width; + try (ImageServer server = ImageServerProvider.buildServer(outputImagePath, BufferedImage.class)) { + width = server.getWidth(); + } + Assertions.assertEquals(expectedWidth, width); + } + + @Test + void Check_Overwritten() { + ConvertCommand convertCommand = new ConvertCommand(); + CommandLine cmd = new CommandLine(convertCommand); + String outputImagePath = Paths.get(System.getProperty("java.io.tmpdir"), UUID.randomUUID() + getImageExtension()).toString(); + cmd.execute(inputImagePath, outputImagePath); + + int exitCode = cmd.execute(inputImagePath, outputImagePath, "--overwrite"); + + Assertions.assertEquals(0, exitCode); + } + } + + @Nested + class ZarrImage extends GenericImage { + + @Override + String getImageExtension() { + return ".ome.zarr"; + } + } + + @Nested + class TiffImage extends GenericImage { + + @Override + String getImageExtension() { + return ".ome.tif"; + } + } + + private static class SampleImageServer extends AbstractImageServer { + + private static final int IMAGE_WIDTH = 6; + private static final int IMAGE_HEIGHT = 5; + private static final int NUMBER_OF_Z_SLICES = 3; + private static final int NUMBER_OF_TIMEPOINTS = 2; + private static final double[] DOWNSAMPLES = new double[] {1.0, 2.0}; + private static final int TILE_SIZE = 1; + + public SampleImageServer() { + super(BufferedImage.class); + } + + @Override + protected ImageServerBuilder.ServerBuilder createServerBuilder() { + return null; + } + + @Override + protected String createID() { + return getClass().getName(); + } + + @Override + public Collection getURIs() { + return List.of(); + } + + @Override + public String getServerType() { + return "Sample server"; + } + + @Override + public ImageServerMetadata getOriginalMetadata() { + return new ImageServerMetadata.Builder() + .width(IMAGE_WIDTH) + .height(IMAGE_HEIGHT) + .sizeZ(NUMBER_OF_Z_SLICES) + .sizeT(NUMBER_OF_TIMEPOINTS) + .pixelType(PixelType.FLOAT64) + .preferredTileSize(TILE_SIZE, TILE_SIZE) + .channels(List.of( + ImageChannel.getInstance("c1", 1), + ImageChannel.getInstance("c2", 2), + ImageChannel.getInstance("c3", 3), + ImageChannel.getInstance("c4", 4), + ImageChannel.getInstance("c5", 5) + )) + .name("name") + .levelsFromDownsamples(DOWNSAMPLES) + .build(); + } + + @Override + public BufferedImage readRegion(RegionRequest request) { + DataBuffer dataBuffer = createDataBuffer(request); + + return new BufferedImage( + ColorModelFactory.createColorModel(getMetadata().getPixelType(), getMetadata().getChannels()), + WritableRaster.createWritableRaster( + new BandedSampleModel(dataBuffer.getDataType(), request.getWidth(), request.getHeight(), nChannels()), + dataBuffer, + null + ), + false, + null + ); + } + + private DataBuffer createDataBuffer(RegionRequest request) { + double[][] array = new double[nChannels()][]; + + for (int c = 0; c < array.length; c++) { + array[c] = getPixels(request, c); + } + + return new DataBufferDouble(array, request.getWidth() * request.getHeight() / 8); + } + + private double[] getPixels(RegionRequest request, int channel) { + double[] pixels = new double[request.getWidth() * request.getHeight()]; + + for (int y=0; y Date: Fri, 6 Sep 2024 16:40:16 +0200 Subject: [PATCH 282/866] Typo --- .../java/qupath/lib/images/writers/ome/zarr/OMEZarrWriter.java | 1 - qupath-extension-bioformats/src/test/java/.gitkeep | 0 2 files changed, 1 deletion(-) delete mode 100644 qupath-extension-bioformats/src/test/java/.gitkeep diff --git a/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriter.java b/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriter.java index db36a0305..7f71e14f0 100644 --- a/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriter.java +++ b/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriter.java @@ -22,7 +22,6 @@ import java.awt.image.BufferedImage; import java.io.IOException; import java.util.ArrayList; -import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; diff --git a/qupath-extension-bioformats/src/test/java/.gitkeep b/qupath-extension-bioformats/src/test/java/.gitkeep deleted file mode 100644 index e69de29bb..000000000 From 0ff4de6b662868424fc21d02b29ae6362c049573 Mon Sep 17 00:00:00 2001 From: lleplat Date: Mon, 9 Sep 2024 14:35:24 +0100 Subject: [PATCH 283/866] Added tests for OMEZarrWriter --- .../images/servers/TestSlicedImageServer.java | 27 +- .../writers/ome/zarr/OMEZarrWriter.java | 9 +- .../writers/ome/TestConvertCommand.java | 8 +- .../writers/ome/zarr/TestOMEZarrWriter.java | 500 ++++++++++++++++++ 4 files changed, 523 insertions(+), 21 deletions(-) create mode 100644 qupath-extension-bioformats/src/test/java/qupath/lib/images/writers/ome/zarr/TestOMEZarrWriter.java diff --git a/qupath-core/src/test/java/qupath/lib/images/servers/TestSlicedImageServer.java b/qupath-core/src/test/java/qupath/lib/images/servers/TestSlicedImageServer.java index 810f50380..74eada7dc 100644 --- a/qupath-core/src/test/java/qupath/lib/images/servers/TestSlicedImageServer.java +++ b/qupath-core/src/test/java/qupath/lib/images/servers/TestSlicedImageServer.java @@ -91,7 +91,7 @@ void Check_Correct_Slice_Read() throws Exception { 0 )); - Assertions.assertTrue(bufferedImagesEqual(expectedImage, image)); + assertDoubleBufferedImagesEqual(expectedImage, image); slicedServer.close(); sampleServer.close(); @@ -172,7 +172,7 @@ void Check_Correct_Timepoint_Read() throws Exception { tToRead )); - Assertions.assertTrue(bufferedImagesEqual(expectedImage, image)); + assertDoubleBufferedImagesEqual(expectedImage, image); slicedServer.close(); sampleServer.close(); @@ -271,18 +271,19 @@ private static double getPixel(int x, int y, int channel, int z, int t) { } } - private boolean bufferedImagesEqual(BufferedImage expectedImage, BufferedImage actualImage) { - if (expectedImage.getWidth() == actualImage.getWidth() && expectedImage.getHeight() == actualImage.getHeight()) { - for (int x = 0; x < expectedImage.getWidth(); x++) { - for (int y = 0; y < expectedImage.getHeight(); y++) { - if (expectedImage.getRGB(x, y) != actualImage.getRGB(x, y)) { - return false; - } - } + private void assertDoubleBufferedImagesEqual(BufferedImage expectedImage, BufferedImage actualImage) { + Assertions.assertEquals(expectedImage.getWidth(), actualImage.getWidth()); + Assertions.assertEquals(expectedImage.getHeight(), actualImage.getHeight()); + + double[] expectedPixels = new double[expectedImage.getSampleModel().getNumBands()]; + double[] actualPixels = new double[actualImage.getSampleModel().getNumBands()]; + for (int x = 0; x < expectedImage.getWidth(); x++) { + for (int y = 0; y < expectedImage.getHeight(); y++) { + Assertions.assertArrayEquals( + (double[]) expectedImage.getData().getDataElements(x, y, expectedPixels), + (double[]) actualImage.getData().getDataElements(x, y, actualPixels) + ); } - return true; - } else { - return false; } } } diff --git a/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriter.java b/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriter.java index 7f71e14f0..93fe6d85b 100644 --- a/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriter.java +++ b/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriter.java @@ -378,9 +378,12 @@ public OMEZarrWriter build() throws IOException { } private static int getChunkSize(int tileSize, int maxNumberOfChunks, int imageSize) { - return maxNumberOfChunks > 0 ? - Math.max(tileSize, imageSize / maxNumberOfChunks) : - tileSize; + return Math.min( + imageSize, + maxNumberOfChunks > 0 ? + Math.max(tileSize, imageSize / maxNumberOfChunks) : + tileSize + ); } private static Map createLevelArrays( diff --git a/qupath-extension-bioformats/src/test/java/qupath/lib/images/writers/ome/TestConvertCommand.java b/qupath-extension-bioformats/src/test/java/qupath/lib/images/writers/ome/TestConvertCommand.java index 0a6f8ce4b..a8e26ac65 100644 --- a/qupath-extension-bioformats/src/test/java/qupath/lib/images/writers/ome/TestConvertCommand.java +++ b/qupath-extension-bioformats/src/test/java/qupath/lib/images/writers/ome/TestConvertCommand.java @@ -246,8 +246,6 @@ private static class SampleImageServer extends AbstractImageServer new OMEZarrWriter.Builder(sampleImageServer, outputImagePath) + ); + + sampleImageServer.close(); + } + + @Test + void Check_Full_Image_Pixels() throws Exception { + String outputImagePath = Paths.get(System.getProperty("java.io.tmpdir"), UUID.randomUUID() + ".ome.zarr").toString(); + SampleImageServer sampleImageServer = new SampleImageServer(); + int level = 0; + int z = 2; + int t = 1; + BufferedImage expectedImage = sampleImageServer.readRegion( + sampleImageServer.getDownsampleForResolution(level), + 0, + 0, + sampleImageServer.getWidth(), + sampleImageServer.getHeight(), + z, + t + ); + + OMEZarrWriter writer = new OMEZarrWriter.Builder(sampleImageServer, outputImagePath) + .build(); + writer.writeImage(); + + BufferedImage image; + try (ImageServer server = ImageServerProvider.buildServer(outputImagePath, BufferedImage.class)) { + image = server.readRegion( + server.getDownsampleForResolution(level), + 0, + 0, + server.getWidth(), + server.getHeight(), + z, + t + ); + } + assertDoubleBufferedImagesEqual(expectedImage, image); + + writer.close(); + sampleImageServer.close(); + } + + @Test + void Check_Downsampled_Image_Pixels() throws Exception { + String outputImagePath = Paths.get(System.getProperty("java.io.tmpdir"), UUID.randomUUID() + ".ome.zarr").toString(); + SampleImageServer sampleImageServer = new SampleImageServer(); + int level = 1; + int z = 2; + int t = 1; + BufferedImage expectedImage = sampleImageServer.readRegion( + sampleImageServer.getDownsampleForResolution(level), + 0, + 0, + sampleImageServer.getWidth(), + sampleImageServer.getHeight(), + z, + t + ); + + OMEZarrWriter writer = new OMEZarrWriter.Builder(sampleImageServer, outputImagePath) + .build(); + writer.writeImage(); + + BufferedImage image; + try (ImageServer server = ImageServerProvider.buildServer(outputImagePath, BufferedImage.class)) { + image = server.readRegion( + server.getDownsampleForResolution(level), + 0, + 0, + server.getWidth(), + server.getHeight(), + z, + t + ); + } + assertDoubleBufferedImagesEqual(expectedImage, image); + + writer.close(); + sampleImageServer.close(); + } + + @Test + void Check_Downsamples_When_Not_Specified() throws Exception { + String outputImagePath = Paths.get(System.getProperty("java.io.tmpdir"), UUID.randomUUID() + ".ome.zarr").toString(); + SampleImageServer sampleImageServer = new SampleImageServer(); + double[] expectedDownsamples = sampleImageServer.getPreferredDownsamples(); + + OMEZarrWriter writer = new OMEZarrWriter.Builder(sampleImageServer, outputImagePath) + .setDownsamples() + .build(); + writer.writeImage(); + + double[] downsamples; + try (ImageServer server = ImageServerProvider.buildServer(outputImagePath, BufferedImage.class)) { + downsamples = server.getPreferredDownsamples(); + } + Assertions.assertArrayEquals(expectedDownsamples, downsamples); + + writer.close(); + sampleImageServer.close(); + } + + @Test + void Check_Downsamples_When_Specified() throws Exception { + String outputImagePath = Paths.get(System.getProperty("java.io.tmpdir"), UUID.randomUUID() + ".ome.zarr").toString(); + SampleImageServer sampleImageServer = new SampleImageServer(); + double[] expectedDownsamples = new double[] {1, 2, 4}; + + OMEZarrWriter writer = new OMEZarrWriter.Builder(sampleImageServer, outputImagePath) + .setDownsamples(expectedDownsamples) + .build(); + writer.writeImage(); + + double[] downsamples; + try (ImageServer server = ImageServerProvider.buildServer(outputImagePath, BufferedImage.class)) { + downsamples = server.getPreferredDownsamples(); + } + Assertions.assertArrayEquals(expectedDownsamples, downsamples); + + writer.close(); + sampleImageServer.close(); + } + + @Test + void Check_Default_Tile_Width() throws Exception { + String outputImagePath = Paths.get(System.getProperty("java.io.tmpdir"), UUID.randomUUID() + ".ome.zarr").toString(); + SampleImageServer sampleImageServer = new SampleImageServer(); + int expectedTileWidth = sampleImageServer.getMetadata().getPreferredTileWidth(); + + OMEZarrWriter writer = new OMEZarrWriter.Builder(sampleImageServer, outputImagePath) + .setTileWidth(-1) + .build(); + writer.writeImage(); + + int tileWidth; + try (ImageServer server = ImageServerProvider.buildServer(outputImagePath, BufferedImage.class)) { + tileWidth = server.getMetadata().getPreferredTileWidth(); + } + Assertions.assertEquals(expectedTileWidth, tileWidth); + + writer.close(); + sampleImageServer.close(); + } + + @Test + void Check_Custom_Tile_Width() throws Exception { + String outputImagePath = Paths.get(System.getProperty("java.io.tmpdir"), UUID.randomUUID() + ".ome.zarr").toString(); + SampleImageServer sampleImageServer = new SampleImageServer(); + int expectedTileWidth = 64; + + OMEZarrWriter writer = new OMEZarrWriter.Builder(sampleImageServer, outputImagePath) + .setTileWidth(expectedTileWidth) + .build(); + writer.writeImage(); + + int tileWidth; + try (ImageServer server = ImageServerProvider.buildServer(outputImagePath, BufferedImage.class)) { + tileWidth = server.getMetadata().getPreferredTileWidth(); + } + Assertions.assertEquals(expectedTileWidth, tileWidth); + + writer.close(); + sampleImageServer.close(); + } + + @Test + void Check_Default_Tile_Height() throws Exception { + String outputImagePath = Paths.get(System.getProperty("java.io.tmpdir"), UUID.randomUUID() + ".ome.zarr").toString(); + SampleImageServer sampleImageServer = new SampleImageServer(); + int expectedTileHeight = sampleImageServer.getMetadata().getPreferredTileHeight(); + + OMEZarrWriter writer = new OMEZarrWriter.Builder(sampleImageServer, outputImagePath) + .setTileHeight(-1) + .build(); + writer.writeImage(); + + int tileHeight; + try (ImageServer server = ImageServerProvider.buildServer(outputImagePath, BufferedImage.class)) { + tileHeight = server.getMetadata().getPreferredTileHeight(); + } + Assertions.assertEquals(expectedTileHeight, tileHeight); + + writer.close(); + sampleImageServer.close(); + } + + @Test + void Check_Custom_Tile_Height() throws Exception { + String outputImagePath = Paths.get(System.getProperty("java.io.tmpdir"), UUID.randomUUID() + ".ome.zarr").toString(); + SampleImageServer sampleImageServer = new SampleImageServer(); + int expectedTileHeight = 64; + + OMEZarrWriter writer = new OMEZarrWriter.Builder(sampleImageServer, outputImagePath) + .setTileHeight(expectedTileHeight) + .build(); + writer.writeImage(); + + int tileHeight; + try (ImageServer server = ImageServerProvider.buildServer(outputImagePath, BufferedImage.class)) { + tileHeight = server.getMetadata().getPreferredTileHeight(); + } + Assertions.assertEquals(expectedTileHeight, tileHeight); + + writer.close(); + sampleImageServer.close(); + } + + @Test + void Check_Bounding_Box() throws Exception { + String outputImagePath = Paths.get(System.getProperty("java.io.tmpdir"), UUID.randomUUID() + ".ome.zarr").toString(); + SampleImageServer sampleImageServer = new SampleImageServer(); + int z = 2; + int t = 1; + ImageRegion boundingBox = ImageRegion.createInstance(5, 5, 20, 25, z, t); + BufferedImage expectedImage = sampleImageServer.readRegion(RegionRequest.createInstance(sampleImageServer.getPath(), 1, boundingBox)); + + OMEZarrWriter writer = new OMEZarrWriter.Builder(sampleImageServer, outputImagePath) + .setBoundingBox(boundingBox) + .build(); + writer.writeImage(); + + BufferedImage image; + try (ImageServer server = ImageServerProvider.buildServer(outputImagePath, BufferedImage.class)) { + image = server.readRegion(1, 0, 0, server.getWidth(), server.getHeight(), z, t); + } + assertDoubleBufferedImagesEqual(expectedImage, image); + + writer.close(); + sampleImageServer.close(); + } + + @Test + void Check_Z_Sliced_Image_Number_Of_Z_Stacks() throws Exception { + String outputImagePath = Paths.get(System.getProperty("java.io.tmpdir"), UUID.randomUUID() + ".ome.zarr").toString(); + SampleImageServer sampleImageServer = new SampleImageServer(); + int zStart = 1; + int zEnd = 2; + int expectedNumberOfZStacks = zEnd - zStart + 1; + + OMEZarrWriter writer = new OMEZarrWriter.Builder(sampleImageServer, outputImagePath) + .setZSlices(zStart, zEnd) + .build(); + writer.writeImage(); + + int numberOfZStacks; + try (ImageServer server = ImageServerProvider.buildServer(outputImagePath, BufferedImage.class)) { + numberOfZStacks = server.nZSlices(); + } + Assertions.assertEquals(expectedNumberOfZStacks, numberOfZStacks); + + writer.close(); + sampleImageServer.close(); + } + + @Test + void Check_Z_Sliced_Image_Pixels() throws Exception { + String outputImagePath = Paths.get(System.getProperty("java.io.tmpdir"), UUID.randomUUID() + ".ome.zarr").toString(); + SampleImageServer sampleImageServer = new SampleImageServer(); + int zStart = 1; + int zEnd = 2; + int z = 1; + int t = 1; + BufferedImage expectedImage = sampleImageServer.readRegion(RegionRequest.createInstance( + sampleImageServer.getPath(), + 1, + 0, + 0, + sampleImageServer.getWidth(), + sampleImageServer.getHeight(), + z, + t + )); + + OMEZarrWriter writer = new OMEZarrWriter.Builder(sampleImageServer, outputImagePath) + .setZSlices(zStart, zEnd) + .build(); + writer.writeImage(); + + BufferedImage image; + try (ImageServer server = ImageServerProvider.buildServer(outputImagePath, BufferedImage.class)) { + image = server.readRegion(1, 0, 0, server.getWidth(), server.getHeight(), z - zStart, t); + } + assertDoubleBufferedImagesEqual(expectedImage, image); + + writer.close(); + sampleImageServer.close(); + } + + @Test + void Check_T_Sliced_Image_Number_Of_Timepoints() throws Exception { + String outputImagePath = Paths.get(System.getProperty("java.io.tmpdir"), UUID.randomUUID() + ".ome.zarr").toString(); + SampleImageServer sampleImageServer = new SampleImageServer(); + int tStart = 1; + int tEnd = 1; + int expectedNumberOfTimepoints = tEnd - tStart + 1; + + OMEZarrWriter writer = new OMEZarrWriter.Builder(sampleImageServer, outputImagePath) + .setTimepoints(tStart, tEnd) + .build(); + writer.writeImage(); + + int numberOfTimepoints; + try (ImageServer server = ImageServerProvider.buildServer(outputImagePath, BufferedImage.class)) { + numberOfTimepoints = server.nTimepoints(); + } + Assertions.assertEquals(expectedNumberOfTimepoints, numberOfTimepoints); + + writer.close(); + sampleImageServer.close(); + } + + @Test + void Check_T_Sliced_Image_Pixels() throws Exception { + String outputImagePath = Paths.get(System.getProperty("java.io.tmpdir"), UUID.randomUUID() + ".ome.zarr").toString(); + SampleImageServer sampleImageServer = new SampleImageServer(); + int tStart = 1; + int tEnd = 1; + int z = 1; + int t = 1; + BufferedImage expectedImage = sampleImageServer.readRegion(RegionRequest.createInstance( + sampleImageServer.getPath(), + 1, + 0, + 0, + sampleImageServer.getWidth(), + sampleImageServer.getHeight(), + z, + t + )); + + OMEZarrWriter writer = new OMEZarrWriter.Builder(sampleImageServer, outputImagePath) + .setTimepoints(tStart, tEnd) + .build(); + writer.writeImage(); + + BufferedImage image; + try (ImageServer server = ImageServerProvider.buildServer(outputImagePath, BufferedImage.class)) { + image = server.readRegion(1, 0, 0, server.getWidth(), server.getHeight(), z, t - tStart); + } + assertDoubleBufferedImagesEqual(expectedImage, image); + + writer.close(); + sampleImageServer.close(); + } + + private static class SampleImageServer extends AbstractImageServer { + + + public SampleImageServer() { + super(BufferedImage.class); + } + + @Override + protected ImageServerBuilder.ServerBuilder createServerBuilder() { + return null; + } + + @Override + protected String createID() { + return getClass().getName(); + } + + @Override + public Collection getURIs() { + return List.of(); + } + + @Override + public String getServerType() { + return "Sample server"; + } + + @Override + public ImageServerMetadata getOriginalMetadata() { + return new ImageServerMetadata.Builder() + .width(64) + .height(64) + .sizeZ(3) + .sizeT(2) + .pixelType(PixelType.FLOAT64) + .preferredTileSize(32, 32) + .channels(List.of( + ImageChannel.getInstance("c1", 1), + ImageChannel.getInstance("c2", 2), + ImageChannel.getInstance("c3", 3), + ImageChannel.getInstance("c4", 4), + ImageChannel.getInstance("c5", 5) + )) + .name("name") + .levelsFromDownsamples(1, 2) + .build(); + } + + @Override + public BufferedImage readRegion(RegionRequest request) { + DataBuffer dataBuffer = createDataBuffer(request); + + return new BufferedImage( + ColorModelFactory.createColorModel(getMetadata().getPixelType(), getMetadata().getChannels()), + WritableRaster.createWritableRaster( + new BandedSampleModel( + dataBuffer.getDataType(), + (int) (request.getWidth() / request.getDownsample()), + (int) (request.getHeight() / request.getDownsample()), + nChannels() + ), + dataBuffer, + null + ), + false, + null + ); + } + + private DataBuffer createDataBuffer(RegionRequest request) { + double[][] array = new double[nChannels()][]; + + for (int c = 0; c < array.length; c++) { + array[c] = getPixels(request, c); + } + + return new DataBufferDouble(array, (int) (request.getWidth() * request.getHeight() / 8 / (request.getDownsample() * request.getDownsample()))); + } + + private double[] getPixels(RegionRequest request, int channel) { + int originX = (int) (request.getX() / request.getDownsample()); + int originY = (int) (request.getY() / request.getDownsample()); + int width = (int) (request.getWidth() / request.getDownsample()); + int height = (int) (request.getHeight() / request.getDownsample()); + double[] pixels = new double[width * height]; + + for (int y=0; y Date: Mon, 9 Sep 2024 15:22:14 +0100 Subject: [PATCH 284/866] Typo --- .../lib/images/writers/ome/zarr/TestOMEZarrWriter.java | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/qupath-extension-bioformats/src/test/java/qupath/lib/images/writers/ome/zarr/TestOMEZarrWriter.java b/qupath-extension-bioformats/src/test/java/qupath/lib/images/writers/ome/zarr/TestOMEZarrWriter.java index f3474a9b8..162e599f4 100644 --- a/qupath-extension-bioformats/src/test/java/qupath/lib/images/writers/ome/zarr/TestOMEZarrWriter.java +++ b/qupath-extension-bioformats/src/test/java/qupath/lib/images/writers/ome/zarr/TestOMEZarrWriter.java @@ -384,6 +384,8 @@ void Check_T_Sliced_Image_Pixels() throws Exception { private static class SampleImageServer extends AbstractImageServer { + private static final int IMAGE_WIDTH = 64; + private static final int IMAGE_HEIGHT = 64; public SampleImageServer() { super(BufferedImage.class); @@ -412,8 +414,8 @@ public String getServerType() { @Override public ImageServerMetadata getOriginalMetadata() { return new ImageServerMetadata.Builder() - .width(64) - .height(64) + .width(IMAGE_WIDTH) + .height(IMAGE_HEIGHT) .sizeZ(3) .sizeT(2) .pixelType(PixelType.FLOAT64) @@ -477,8 +479,8 @@ private double[] getPixels(RegionRequest request, int channel) { return pixels; } - private double getPixel(int x, int y, int channel, int z, int t) { - return z + t + channel + ((double) x / getWidth() + (double) y / getHeight()) / 2; + private static double getPixel(int x, int y, int channel, int z, int t) { + return z + t + channel + ((double) x / IMAGE_WIDTH + (double) y / IMAGE_HEIGHT) / 2; } } From ba1f3f6636b53ded1bf77d79f9a929a666956eb1 Mon Sep 17 00:00:00 2001 From: lleplat Date: Mon, 9 Sep 2024 16:46:46 +0100 Subject: [PATCH 285/866] Changed temporary folder creation --- .../writers/ome/TestConvertCommand.java | 26 +++++++++-------- .../writers/ome/zarr/TestOMEZarrWriter.java | 29 ++++++++++--------- 2 files changed, 29 insertions(+), 26 deletions(-) diff --git a/qupath-extension-bioformats/src/test/java/qupath/lib/images/writers/ome/TestConvertCommand.java b/qupath-extension-bioformats/src/test/java/qupath/lib/images/writers/ome/TestConvertCommand.java index a8e26ac65..3e16d4dbd 100644 --- a/qupath-extension-bioformats/src/test/java/qupath/lib/images/writers/ome/TestConvertCommand.java +++ b/qupath-extension-bioformats/src/test/java/qupath/lib/images/writers/ome/TestConvertCommand.java @@ -22,7 +22,9 @@ import java.awt.image.DataBufferDouble; import java.awt.image.WritableRaster; import java.io.File; +import java.io.IOException; import java.net.URI; +import java.nio.file.Files; import java.nio.file.Paths; import java.util.Collection; import java.util.List; @@ -52,7 +54,7 @@ static void createInputImage() throws Exception { void Check_Image_Not_Cropped() throws Exception { ConvertCommand convertCommand = new ConvertCommand(); CommandLine cmd = new CommandLine(convertCommand); - String outputImagePath = Paths.get(System.getProperty("java.io.tmpdir"), UUID.randomUUID() + getImageExtension()).toString(); + String outputImagePath = Paths.get(Files.createTempDirectory(UUID.randomUUID().toString()).toString(), "image" + getImageExtension()).toString(); int expectedWidth = SampleImageServer.IMAGE_WIDTH; cmd.execute(inputImagePath, outputImagePath, "-r", ""); @@ -68,7 +70,7 @@ void Check_Image_Not_Cropped() throws Exception { void Check_Image_Cropped() throws Exception { ConvertCommand convertCommand = new ConvertCommand(); CommandLine cmd = new CommandLine(convertCommand); - String outputImagePath = Paths.get(System.getProperty("java.io.tmpdir"), UUID.randomUUID() + getImageExtension()).toString(); + String outputImagePath = Paths.get(Files.createTempDirectory(UUID.randomUUID().toString()).toString(), "image" + getImageExtension()).toString(); int expectedWidth = 2; cmd.execute(inputImagePath, outputImagePath, "-r", String.format("0,0,%d,1", expectedWidth)); @@ -84,7 +86,7 @@ void Check_Image_Cropped() throws Exception { void Check_Image_Not_Z_Sliced() throws Exception { ConvertCommand convertCommand = new ConvertCommand(); CommandLine cmd = new CommandLine(convertCommand); - String outputImagePath = Paths.get(System.getProperty("java.io.tmpdir"), UUID.randomUUID() + getImageExtension()).toString(); + String outputImagePath = Paths.get(Files.createTempDirectory(UUID.randomUUID().toString()).toString(), "image" + getImageExtension()).toString(); int expectedZSlices = SampleImageServer.NUMBER_OF_Z_SLICES; cmd.execute(inputImagePath, outputImagePath, "-z", "all"); @@ -100,7 +102,7 @@ void Check_Image_Not_Z_Sliced() throws Exception { void Check_Image_Z_Sliced() throws Exception { ConvertCommand convertCommand = new ConvertCommand(); CommandLine cmd = new CommandLine(convertCommand); - String outputImagePath = Paths.get(System.getProperty("java.io.tmpdir"), UUID.randomUUID() + getImageExtension()).toString(); + String outputImagePath = Paths.get(Files.createTempDirectory(UUID.randomUUID().toString()).toString(), "image" + getImageExtension()).toString(); int expectedZSlices = 1; cmd.execute(inputImagePath, outputImagePath, "-z", "1"); @@ -116,7 +118,7 @@ void Check_Image_Z_Sliced() throws Exception { void Check_Image_Z_Sliced_By_Range() throws Exception { ConvertCommand convertCommand = new ConvertCommand(); CommandLine cmd = new CommandLine(convertCommand); - String outputImagePath = Paths.get(System.getProperty("java.io.tmpdir"), UUID.randomUUID() + getImageExtension()).toString(); + String outputImagePath = Paths.get(Files.createTempDirectory(UUID.randomUUID().toString()).toString(), "image" + getImageExtension()).toString(); int expectedZSlices = 2; cmd.execute(inputImagePath, outputImagePath, "-z", "1-2"); @@ -132,7 +134,7 @@ void Check_Image_Z_Sliced_By_Range() throws Exception { void Check_Image_Not_T_Sliced() throws Exception { ConvertCommand convertCommand = new ConvertCommand(); CommandLine cmd = new CommandLine(convertCommand); - String outputImagePath = Paths.get(System.getProperty("java.io.tmpdir"), UUID.randomUUID() + getImageExtension()).toString(); + String outputImagePath = Paths.get(Files.createTempDirectory(UUID.randomUUID().toString()).toString(), "image" + getImageExtension()).toString(); int expectedTimepoints = SampleImageServer.NUMBER_OF_TIMEPOINTS; cmd.execute(inputImagePath, outputImagePath, "-t", "all"); @@ -148,7 +150,7 @@ void Check_Image_Not_T_Sliced() throws Exception { void Check_Image_T_Sliced() throws Exception { ConvertCommand convertCommand = new ConvertCommand(); CommandLine cmd = new CommandLine(convertCommand); - String outputImagePath = Paths.get(System.getProperty("java.io.tmpdir"), UUID.randomUUID() + getImageExtension()).toString(); + String outputImagePath = Paths.get(Files.createTempDirectory(UUID.randomUUID().toString()).toString(), "image" + getImageExtension()).toString(); int expectedTimepoints = 1; cmd.execute(inputImagePath, outputImagePath, "-t", "1"); @@ -164,7 +166,7 @@ void Check_Image_T_Sliced() throws Exception { void Check_Image_T_Sliced_By_Range() throws Exception { ConvertCommand convertCommand = new ConvertCommand(); CommandLine cmd = new CommandLine(convertCommand); - String outputImagePath = Paths.get(System.getProperty("java.io.tmpdir"), UUID.randomUUID() + getImageExtension()).toString(); + String outputImagePath = Paths.get(Files.createTempDirectory(UUID.randomUUID().toString()).toString(), "image" + getImageExtension()).toString(); int expectedTimepoints = 2; cmd.execute(inputImagePath, outputImagePath, "-t", "1-2"); @@ -180,7 +182,7 @@ void Check_Image_T_Sliced_By_Range() throws Exception { void Check_Image_Not_Downsampled() throws Exception { ConvertCommand convertCommand = new ConvertCommand(); CommandLine cmd = new CommandLine(convertCommand); - String outputImagePath = Paths.get(System.getProperty("java.io.tmpdir"), UUID.randomUUID() + getImageExtension()).toString(); + String outputImagePath = Paths.get(Files.createTempDirectory(UUID.randomUUID().toString()).toString(), "image" + getImageExtension()).toString(); int expectedWidth = SampleImageServer.IMAGE_WIDTH; cmd.execute(inputImagePath, outputImagePath, "-d", "1.0"); @@ -196,7 +198,7 @@ void Check_Image_Not_Downsampled() throws Exception { void Check_Image_Downsampled() throws Exception { ConvertCommand convertCommand = new ConvertCommand(); CommandLine cmd = new CommandLine(convertCommand); - String outputImagePath = Paths.get(System.getProperty("java.io.tmpdir"), UUID.randomUUID() + getImageExtension()).toString(); + String outputImagePath = Paths.get(Files.createTempDirectory(UUID.randomUUID().toString()).toString(), "image" + getImageExtension()).toString(); float downsample = 2; int expectedWidth = (int) (SampleImageServer.IMAGE_WIDTH / downsample); @@ -210,10 +212,10 @@ void Check_Image_Downsampled() throws Exception { } @Test - void Check_Overwritten() { + void Check_Overwritten() throws IOException { ConvertCommand convertCommand = new ConvertCommand(); CommandLine cmd = new CommandLine(convertCommand); - String outputImagePath = Paths.get(System.getProperty("java.io.tmpdir"), UUID.randomUUID() + getImageExtension()).toString(); + String outputImagePath = Paths.get(Files.createTempDirectory(UUID.randomUUID().toString()).toString(), "image" + getImageExtension()).toString(); cmd.execute(inputImagePath, outputImagePath); int exitCode = cmd.execute(inputImagePath, outputImagePath, "--overwrite"); diff --git a/qupath-extension-bioformats/src/test/java/qupath/lib/images/writers/ome/zarr/TestOMEZarrWriter.java b/qupath-extension-bioformats/src/test/java/qupath/lib/images/writers/ome/zarr/TestOMEZarrWriter.java index 162e599f4..69e142084 100644 --- a/qupath-extension-bioformats/src/test/java/qupath/lib/images/writers/ome/zarr/TestOMEZarrWriter.java +++ b/qupath-extension-bioformats/src/test/java/qupath/lib/images/writers/ome/zarr/TestOMEZarrWriter.java @@ -19,6 +19,7 @@ import java.awt.image.DataBufferDouble; import java.awt.image.WritableRaster; import java.net.URI; +import java.nio.file.Files; import java.nio.file.Paths; import java.util.Collection; import java.util.List; @@ -29,7 +30,7 @@ public class TestOMEZarrWriter { @Test void Check_Error_When_Extension_Incorrect() throws Exception { String extension = ".wrong.extension"; - String outputImagePath = Paths.get(System.getProperty("java.io.tmpdir"), UUID.randomUUID() + extension).toString(); + String outputImagePath = Paths.get(Files.createTempDirectory(UUID.randomUUID().toString()).toString(), "image" + extension).toString(); SampleImageServer sampleImageServer = new SampleImageServer(); Assertions.assertThrows( @@ -42,7 +43,7 @@ void Check_Error_When_Extension_Incorrect() throws Exception { @Test void Check_Full_Image_Pixels() throws Exception { - String outputImagePath = Paths.get(System.getProperty("java.io.tmpdir"), UUID.randomUUID() + ".ome.zarr").toString(); + String outputImagePath = Paths.get(Files.createTempDirectory(UUID.randomUUID().toString()).toString(), "image.ome.zarr").toString(); SampleImageServer sampleImageServer = new SampleImageServer(); int level = 0; int z = 2; @@ -81,7 +82,7 @@ void Check_Full_Image_Pixels() throws Exception { @Test void Check_Downsampled_Image_Pixels() throws Exception { - String outputImagePath = Paths.get(System.getProperty("java.io.tmpdir"), UUID.randomUUID() + ".ome.zarr").toString(); + String outputImagePath = Paths.get(Files.createTempDirectory(UUID.randomUUID().toString()).toString(), "image.ome.zarr").toString(); SampleImageServer sampleImageServer = new SampleImageServer(); int level = 1; int z = 2; @@ -120,7 +121,7 @@ void Check_Downsampled_Image_Pixels() throws Exception { @Test void Check_Downsamples_When_Not_Specified() throws Exception { - String outputImagePath = Paths.get(System.getProperty("java.io.tmpdir"), UUID.randomUUID() + ".ome.zarr").toString(); + String outputImagePath = Paths.get(Files.createTempDirectory(UUID.randomUUID().toString()).toString(), "image.ome.zarr").toString(); SampleImageServer sampleImageServer = new SampleImageServer(); double[] expectedDownsamples = sampleImageServer.getPreferredDownsamples(); @@ -141,7 +142,7 @@ void Check_Downsamples_When_Not_Specified() throws Exception { @Test void Check_Downsamples_When_Specified() throws Exception { - String outputImagePath = Paths.get(System.getProperty("java.io.tmpdir"), UUID.randomUUID() + ".ome.zarr").toString(); + String outputImagePath = Paths.get(Files.createTempDirectory(UUID.randomUUID().toString()).toString(), "image.ome.zarr").toString(); SampleImageServer sampleImageServer = new SampleImageServer(); double[] expectedDownsamples = new double[] {1, 2, 4}; @@ -162,7 +163,7 @@ void Check_Downsamples_When_Specified() throws Exception { @Test void Check_Default_Tile_Width() throws Exception { - String outputImagePath = Paths.get(System.getProperty("java.io.tmpdir"), UUID.randomUUID() + ".ome.zarr").toString(); + String outputImagePath = Paths.get(Files.createTempDirectory(UUID.randomUUID().toString()).toString(), "image.ome.zarr").toString(); SampleImageServer sampleImageServer = new SampleImageServer(); int expectedTileWidth = sampleImageServer.getMetadata().getPreferredTileWidth(); @@ -183,7 +184,7 @@ void Check_Default_Tile_Width() throws Exception { @Test void Check_Custom_Tile_Width() throws Exception { - String outputImagePath = Paths.get(System.getProperty("java.io.tmpdir"), UUID.randomUUID() + ".ome.zarr").toString(); + String outputImagePath = Paths.get(Files.createTempDirectory(UUID.randomUUID().toString()).toString(), "image.ome.zarr").toString(); SampleImageServer sampleImageServer = new SampleImageServer(); int expectedTileWidth = 64; @@ -204,7 +205,7 @@ void Check_Custom_Tile_Width() throws Exception { @Test void Check_Default_Tile_Height() throws Exception { - String outputImagePath = Paths.get(System.getProperty("java.io.tmpdir"), UUID.randomUUID() + ".ome.zarr").toString(); + String outputImagePath = Paths.get(Files.createTempDirectory(UUID.randomUUID().toString()).toString(), "image.ome.zarr").toString(); SampleImageServer sampleImageServer = new SampleImageServer(); int expectedTileHeight = sampleImageServer.getMetadata().getPreferredTileHeight(); @@ -225,7 +226,7 @@ void Check_Default_Tile_Height() throws Exception { @Test void Check_Custom_Tile_Height() throws Exception { - String outputImagePath = Paths.get(System.getProperty("java.io.tmpdir"), UUID.randomUUID() + ".ome.zarr").toString(); + String outputImagePath = Paths.get(Files.createTempDirectory(UUID.randomUUID().toString()).toString(), "image.ome.zarr").toString(); SampleImageServer sampleImageServer = new SampleImageServer(); int expectedTileHeight = 64; @@ -246,7 +247,7 @@ void Check_Custom_Tile_Height() throws Exception { @Test void Check_Bounding_Box() throws Exception { - String outputImagePath = Paths.get(System.getProperty("java.io.tmpdir"), UUID.randomUUID() + ".ome.zarr").toString(); + String outputImagePath = Paths.get(Files.createTempDirectory(UUID.randomUUID().toString()).toString(), "image.ome.zarr").toString(); SampleImageServer sampleImageServer = new SampleImageServer(); int z = 2; int t = 1; @@ -270,7 +271,7 @@ void Check_Bounding_Box() throws Exception { @Test void Check_Z_Sliced_Image_Number_Of_Z_Stacks() throws Exception { - String outputImagePath = Paths.get(System.getProperty("java.io.tmpdir"), UUID.randomUUID() + ".ome.zarr").toString(); + String outputImagePath = Paths.get(Files.createTempDirectory(UUID.randomUUID().toString()).toString(), "image.ome.zarr").toString(); SampleImageServer sampleImageServer = new SampleImageServer(); int zStart = 1; int zEnd = 2; @@ -293,7 +294,7 @@ void Check_Z_Sliced_Image_Number_Of_Z_Stacks() throws Exception { @Test void Check_Z_Sliced_Image_Pixels() throws Exception { - String outputImagePath = Paths.get(System.getProperty("java.io.tmpdir"), UUID.randomUUID() + ".ome.zarr").toString(); + String outputImagePath = Paths.get(Files.createTempDirectory(UUID.randomUUID().toString()).toString(), "image.ome.zarr").toString(); SampleImageServer sampleImageServer = new SampleImageServer(); int zStart = 1; int zEnd = 2; @@ -327,7 +328,7 @@ void Check_Z_Sliced_Image_Pixels() throws Exception { @Test void Check_T_Sliced_Image_Number_Of_Timepoints() throws Exception { - String outputImagePath = Paths.get(System.getProperty("java.io.tmpdir"), UUID.randomUUID() + ".ome.zarr").toString(); + String outputImagePath = Paths.get(Files.createTempDirectory(UUID.randomUUID().toString()).toString(), "image.ome.zarr").toString(); SampleImageServer sampleImageServer = new SampleImageServer(); int tStart = 1; int tEnd = 1; @@ -350,7 +351,7 @@ void Check_T_Sliced_Image_Number_Of_Timepoints() throws Exception { @Test void Check_T_Sliced_Image_Pixels() throws Exception { - String outputImagePath = Paths.get(System.getProperty("java.io.tmpdir"), UUID.randomUUID() + ".ome.zarr").toString(); + String outputImagePath = Paths.get(Files.createTempDirectory(UUID.randomUUID().toString()).toString(), "image.ome.zarr").toString(); SampleImageServer sampleImageServer = new SampleImageServer(); int tStart = 1; int tEnd = 1; From 04f5e158dcf241435f18ec60d400643790f03233 Mon Sep 17 00:00:00 2001 From: lleplat Date: Mon, 9 Sep 2024 17:13:14 +0100 Subject: [PATCH 286/866] Addressed comments --- .../lib/images/servers/SlicedImageServer.java | 22 +++++++------- .../servers/TransformedServerBuilder.java | 15 ++++++++++ .../images/servers/TestSlicedImageServer.java | 4 +-- .../writers/ome/zarr/OMEZarrWriter.java | 30 ++++++++----------- .../writers/ome/zarr/TestOMEZarrWriter.java | 12 ++++---- 5 files changed, 46 insertions(+), 37 deletions(-) diff --git a/qupath-core/src/main/java/qupath/lib/images/servers/SlicedImageServer.java b/qupath-core/src/main/java/qupath/lib/images/servers/SlicedImageServer.java index ddfbbfdb6..891481698 100644 --- a/qupath-core/src/main/java/qupath/lib/images/servers/SlicedImageServer.java +++ b/qupath-core/src/main/java/qupath/lib/images/servers/SlicedImageServer.java @@ -27,12 +27,12 @@ public class SlicedImageServer extends TransformingImageServer { * * @param inputServer the input image to slice * @param zStart the inclusive 0-based index of the first slice to consider - * @param zEnd the inclusive 0-based index of the last slide to consider + * @param zEnd the exclusive 0-based index of the last slide to consider * @param tStart the inclusive 0-based index of the first timepoint to consider - * @param tEnd the inclusive 0-based index of the last timepoint to consider + * @param tEnd the exclusive 0-based index of the last timepoint to consider * @throws IllegalArgumentException when a start index is greater than its corresponding end index */ - public SlicedImageServer( + SlicedImageServer( ImageServer inputServer, int zStart, int zEnd, @@ -41,17 +41,17 @@ public SlicedImageServer( ) { super(inputServer); - this.zStart = setNumberInRange(zStart, inputServer.nZSlices() - 1); - this.zEnd = setNumberInRange(zEnd, inputServer.nZSlices() - 1); - this.tStart = setNumberInRange(tStart, inputServer.nTimepoints() - 1); - this.tEnd = setNumberInRange(tEnd, inputServer.nTimepoints() - 1); + this.zStart = setNumberInRange(zStart, 0, inputServer.nZSlices() - 1); + this.zEnd = setNumberInRange(zEnd, 1, inputServer.nZSlices()); + this.tStart = setNumberInRange(tStart, 0, inputServer.nTimepoints() - 1); + this.tEnd = setNumberInRange(tEnd, 1, inputServer.nTimepoints()); checkOrder(this.zStart, this.zEnd, "z-slice"); checkOrder(this.tStart, this.tEnd, "timepoint"); metadata = new ImageServerMetadata.Builder(inputServer.getMetadata()) - .sizeZ(this.zEnd - this.zStart + 1) - .sizeT(this.tEnd - this.tStart + 1) + .sizeZ(this.zEnd - this.zStart) + .sizeT(this.tEnd - this.tStart) .build(); } @@ -101,8 +101,8 @@ public BufferedImage readRegion(final RegionRequest request) throws IOException )); } - private static int setNumberInRange(int number, int max) { - return Math.max(0, Math.min(number, max)); + private static int setNumberInRange(int number, int min, int max) { + return Math.max(min, Math.min(number, max)); } private static void checkOrder(int min, int max, String name) { diff --git a/qupath-core/src/main/java/qupath/lib/images/servers/TransformedServerBuilder.java b/qupath-core/src/main/java/qupath/lib/images/servers/TransformedServerBuilder.java index b5e24cfb4..44864ade1 100644 --- a/qupath-core/src/main/java/qupath/lib/images/servers/TransformedServerBuilder.java +++ b/qupath-core/src/main/java/qupath/lib/images/servers/TransformedServerBuilder.java @@ -66,6 +66,21 @@ public TransformedServerBuilder crop(ImageRegion region) { server = new CroppedImageServer(server, region); return this; } + + /** + * Slice a specific region along the z or the t axis. + * + * @param zStart the inclusive 0-based index of the first slice to consider + * @param zEnd the exclusive 0-based index of the last slide to consider + * @param tStart the inclusive 0-based index of the first timepoint to consider + * @param tEnd the exclusive 0-based index of the last timepoint to consider + * @return this builder + * @throws IllegalArgumentException when a start index is greater than its corresponding end index + */ + public TransformedServerBuilder slice(int zStart, int zEnd, int tStart, int tEnd) { + server = new SlicedImageServer(server, zStart, zEnd, tStart, tEnd); + return this; + } /** * Apply an {@link AffineTransform} to the server. diff --git a/qupath-core/src/test/java/qupath/lib/images/servers/TestSlicedImageServer.java b/qupath-core/src/test/java/qupath/lib/images/servers/TestSlicedImageServer.java index 74eada7dc..284a30c0c 100644 --- a/qupath-core/src/test/java/qupath/lib/images/servers/TestSlicedImageServer.java +++ b/qupath-core/src/test/java/qupath/lib/images/servers/TestSlicedImageServer.java @@ -21,7 +21,7 @@ void Check_Number_Of_Z_Slices() throws Exception { ImageServer sampleServer = new SampleImageServer(); int zStart = 1; int zEnd = 3; - int expectedNumberOfZSlices = zEnd - zStart + 1; + int expectedNumberOfZSlices = zEnd - zStart; ImageServer slicedServer = new SlicedImageServer(sampleServer, zStart, zEnd, 0, 0); int numberOfZSlices = slicedServer.nZSlices(); @@ -102,7 +102,7 @@ void Check_Number_Of_Timepoints() throws Exception { ImageServer sampleServer = new SampleImageServer(); int tStart = 1; int tEnd = 3; - int expectedNumberOfTimepoints = tEnd - tStart + 1; + int expectedNumberOfTimepoints = tEnd - tStart; ImageServer slicedServer = new SlicedImageServer(sampleServer, 0, 0, tStart, tEnd); int numberOfTimepoints = slicedServer.nTimepoints(); diff --git a/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriter.java b/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriter.java index 93fe6d85b..5396f7387 100644 --- a/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriter.java +++ b/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriter.java @@ -10,13 +10,12 @@ import loci.formats.gui.AWTImageTools; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import qupath.lib.images.servers.CroppedImageServer; import qupath.lib.images.servers.ImageServer; import qupath.lib.images.servers.ImageServers; import qupath.lib.images.servers.PixelCalibration; -import qupath.lib.images.servers.SlicedImageServer; import qupath.lib.images.servers.TileRequest; import qupath.lib.images.servers.TileRequestManager; +import qupath.lib.images.servers.TransformedServerBuilder; import qupath.lib.regions.ImageRegion; import java.awt.image.BufferedImage; @@ -52,7 +51,7 @@ public class OMEZarrWriter implements AutoCloseable { private final ExecutorService executorService; private OMEZarrWriter(Builder builder) throws IOException { - ImageServer pyramidalServer = ImageServers.pyramidalizeTiled( + TransformedServerBuilder transformedServerBuilder = new TransformedServerBuilder(ImageServers.pyramidalizeTiled( builder.server, getChunkSize( builder.tileWidth > 0 ? builder.tileWidth : builder.server.getMetadata().getPreferredTileWidth(), @@ -65,24 +64,19 @@ private OMEZarrWriter(Builder builder) throws IOException { builder.server.getHeight() ), builder.downsamples.length == 0 ? builder.server.getPreferredDownsamples() : builder.downsamples - ); - ImageServer pyramidalAndSlicedServer; - if (builder.zStart == 0 && builder.zEnd == builder.server.nZSlices()-1 && builder.tStart == 0 && builder.tEnd == builder.server.nTimepoints()-1) { - pyramidalAndSlicedServer = pyramidalServer; - } else { - pyramidalAndSlicedServer = new SlicedImageServer( - pyramidalServer, + )); + if (builder.zStart != 0 || builder.zEnd != builder.server.nZSlices() || builder.tStart != 0 || builder.tEnd != builder.server.nTimepoints()) { + transformedServerBuilder.slice( builder.zStart, builder.zEnd, builder.tStart, builder.tEnd ); } - if (builder.boundingBox == null) { - server = pyramidalAndSlicedServer; - } else { - server = new CroppedImageServer(pyramidalAndSlicedServer, builder.boundingBox); + if (builder.boundingBox != null) { + transformedServerBuilder.crop(builder.boundingBox); } + server = transformedServerBuilder.build(); OMEZarrAttributesCreator attributes = new OMEZarrAttributesCreator( server.getMetadata().getName(), @@ -220,8 +214,8 @@ public Builder(ImageServer server, String path) { this.server = server; this.path = path; - this.zEnd = this.server.nZSlices() - 1; - this.tEnd = this.server.nTimepoints() - 1; + this.zEnd = this.server.nZSlices(); + this.tEnd = this.server.nTimepoints(); } /** @@ -342,7 +336,7 @@ public Builder setBoundingBox(ImageRegion boundingBox) { * Define the z-slices of the input image to consider. * * @param zStart the 0-based inclusive index of the first z-slice to consider - * @param zEnd the 0-based inclusive index of the last z-slice to consider + * @param zEnd the 0-based exclusive index of the last z-slice to consider * @return this builder */ public Builder setZSlices(int zStart, int zEnd) { @@ -355,7 +349,7 @@ public Builder setZSlices(int zStart, int zEnd) { * Define the timepoints of the input image to consider. * * @param tStart the 0-based inclusive index of the first timepoint to consider - * @param tEnd the 0-based inclusive index of the last timepoint to consider + * @param tEnd the 0-based exclusive index of the last timepoint to consider * @return this builder */ public Builder setTimepoints(int tStart, int tEnd) { diff --git a/qupath-extension-bioformats/src/test/java/qupath/lib/images/writers/ome/zarr/TestOMEZarrWriter.java b/qupath-extension-bioformats/src/test/java/qupath/lib/images/writers/ome/zarr/TestOMEZarrWriter.java index 69e142084..b451ff06e 100644 --- a/qupath-extension-bioformats/src/test/java/qupath/lib/images/writers/ome/zarr/TestOMEZarrWriter.java +++ b/qupath-extension-bioformats/src/test/java/qupath/lib/images/writers/ome/zarr/TestOMEZarrWriter.java @@ -274,8 +274,8 @@ void Check_Z_Sliced_Image_Number_Of_Z_Stacks() throws Exception { String outputImagePath = Paths.get(Files.createTempDirectory(UUID.randomUUID().toString()).toString(), "image.ome.zarr").toString(); SampleImageServer sampleImageServer = new SampleImageServer(); int zStart = 1; - int zEnd = 2; - int expectedNumberOfZStacks = zEnd - zStart + 1; + int zEnd = 3; + int expectedNumberOfZStacks = zEnd - zStart; OMEZarrWriter writer = new OMEZarrWriter.Builder(sampleImageServer, outputImagePath) .setZSlices(zStart, zEnd) @@ -297,7 +297,7 @@ void Check_Z_Sliced_Image_Pixels() throws Exception { String outputImagePath = Paths.get(Files.createTempDirectory(UUID.randomUUID().toString()).toString(), "image.ome.zarr").toString(); SampleImageServer sampleImageServer = new SampleImageServer(); int zStart = 1; - int zEnd = 2; + int zEnd = 3; int z = 1; int t = 1; BufferedImage expectedImage = sampleImageServer.readRegion(RegionRequest.createInstance( @@ -331,8 +331,8 @@ void Check_T_Sliced_Image_Number_Of_Timepoints() throws Exception { String outputImagePath = Paths.get(Files.createTempDirectory(UUID.randomUUID().toString()).toString(), "image.ome.zarr").toString(); SampleImageServer sampleImageServer = new SampleImageServer(); int tStart = 1; - int tEnd = 1; - int expectedNumberOfTimepoints = tEnd - tStart + 1; + int tEnd = 2; + int expectedNumberOfTimepoints = tEnd - tStart; OMEZarrWriter writer = new OMEZarrWriter.Builder(sampleImageServer, outputImagePath) .setTimepoints(tStart, tEnd) @@ -354,7 +354,7 @@ void Check_T_Sliced_Image_Pixels() throws Exception { String outputImagePath = Paths.get(Files.createTempDirectory(UUID.randomUUID().toString()).toString(), "image.ome.zarr").toString(); SampleImageServer sampleImageServer = new SampleImageServer(); int tStart = 1; - int tEnd = 1; + int tEnd = 2; int z = 1; int t = 1; BufferedImage expectedImage = sampleImageServer.readRegion(RegionRequest.createInstance( From b57121aaec2d6a152209bf24e445df0f2f0910ec Mon Sep 17 00:00:00 2001 From: lleplat Date: Mon, 9 Sep 2024 17:19:44 +0100 Subject: [PATCH 287/866] Typo --- .../java/qupath/lib/images/writers/ome/ConvertCommand.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/ConvertCommand.java b/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/ConvertCommand.java index e1eee6dd0..299a48bce 100644 --- a/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/ConvertCommand.java +++ b/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/ConvertCommand.java @@ -236,8 +236,8 @@ public void run() { .setTileWidth(tileWidth) .setTileHeight(tileHeight) .setBoundingBox(boundingBox.orElse(null)) - .setZSlices(zSlicesRange.start(), zSlicesRange.end()-1) - .setTimepoints(timepointsRange.start(), timepointsRange.end()-1); + .setZSlices(zSlicesRange.start(), zSlicesRange.end()) + .setTimepoints(timepointsRange.start(), timepointsRange.end()); if (!parallelize) { builder.setNumberOfThreads(1); From 121bd5752e3f600ce9dfa67d5c81acc29a256beb Mon Sep 17 00:00:00 2001 From: Pete Date: Tue, 10 Sep 2024 08:30:39 +0100 Subject: [PATCH 288/866] Upgrade gradle, dependences Attempted to update ControlsFX to 11.2.1, but it had some very weird `CheckComboBox` behavior (returning null checked items). --- CHANGELOG.md | 5 ++++- gradle/libs.versions.toml | 12 ++++++------ gradle/wrapper/gradle-wrapper.properties | 2 +- qupath-app/build.gradle | 3 ++- qupath-extension-script-editor/.gitignore | 1 - settings.gradle | 4 ++-- 6 files changed, 15 insertions(+), 12 deletions(-) delete mode 100644 qupath-extension-script-editor/.gitignore diff --git a/CHANGELOG.md b/CHANGELOG.md index e7f793624..e873bd3c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -93,11 +93,14 @@ They may change or be removed in future versions. * Java Topology Suite 1.20.0 * JFreeSVG 5.0.6 * JNA 5.14.0 +* JUnit 5.11.0 +* Logback 1.5.8 * Picocli 4.7.6 * OpenCV 4.9.0 * OpenJDK 21 * RichTextFX 0.11.3 -* slf4j 2.0.12 +* slf4j 2.0.16 +* snakeyaml 2.3 ## Version 0.5.1 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b53f4f4eb..600daccf0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -33,9 +33,9 @@ jna = "5.14.0" jfreeSvg = "5.0.6" jfxtras = "17-r1" jts = "1.20.0" -junit = "5.9.2" +junit = "5.11.0" -logback = "1.3.11" +logback = "1.5.8" logviewer = "0.2.0-SNAPSHOT" javadocviewer = "0.1.1-SNAPSHOT" @@ -43,12 +43,12 @@ javadocviewer = "0.1.1-SNAPSHOT" openslide = "4.0.0.3" picocli = "4.7.6" -qupath-fxtras = "0.1.5-SNAPSHOT" +qupath-fxtras = "0.1.5" richtextfx = "0.11.3" -slf4j = "2.0.12" -snakeyaml = "2.2" +slf4j = "2.0.16" +snakeyaml = "2.3" [libraries] @@ -126,6 +126,6 @@ javacpp = { id = "org.bytedeco.gradle-javacpp-platform", version.ref # If javafx plugin causes trouble, see https://github.com/openjfx/javafx-gradle-plugin#migrating-from-0014-to-010 javafx = { id = "org.openjfx.javafxplugin", version = "0.1.0" } #javafx = { id = "org.openjfx.javafxplugin", version = "0.0.14" } -jpackage = { id = "org.beryx.runtime", version = "1.13.0" } # Non-modular +jpackage = { id = "org.beryx.runtime", version = "1.13.1" } # Non-modular # jpackage = { id = "org.beryx.jlink", version = "2.26.0" } # Modular license-report = { id = "com.github.jk1.dependency-license-report", version = "2.9" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 9355b4155..0aaefbcaf 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.10-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/qupath-app/build.gradle b/qupath-app/build.gradle index 4acbe444d..62774e99c 100644 --- a/qupath-app/build.gradle +++ b/qupath-app/build.gradle @@ -157,6 +157,7 @@ tasks.register("assembleJavadocs", Copy) { } into layout.buildDirectory.dir("javadocs") } +tasks.distTar.dependsOn("assembleJavadocs") tasks.installDist.dependsOn("assembleJavadocs") /** @@ -240,7 +241,7 @@ runtime { '--no-header-files', '--no-man-pages', '--strip-native-commands', - '--compress', '2', + '--compress', 'zip-6', // jlink option; can be zip-0 (no compression) to zip-9; default is zip-6 '--bind-services' ] modules = [ diff --git a/qupath-extension-script-editor/.gitignore b/qupath-extension-script-editor/.gitignore deleted file mode 100644 index b83d22266..000000000 --- a/qupath-extension-script-editor/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/target/ diff --git a/settings.gradle b/settings.gradle index 5d43ef8f6..e6ceb27e8 100644 --- a/settings.gradle +++ b/settings.gradle @@ -29,9 +29,9 @@ dependencyResolutionManagement { versionCatalogs { libs { def javafxOverride = System.properties.getOrDefault('javafx-version', null) - if (javafxOverride) { + if (javafxOverride instanceof String) { println "Overriding JavaFX version to request $javafxOverride" - version('javafx', javafxOverride) + version('javafx', javafxOverride as String) } } } From 099aa185ffdab7d25497983f959fa19f9a5a7723 Mon Sep 17 00:00:00 2001 From: Pete Date: Tue, 10 Sep 2024 09:23:36 +0100 Subject: [PATCH 289/866] Update libs.versions.toml --- gradle/libs.versions.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 600daccf0..4544d6131 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -36,9 +36,9 @@ jts = "1.20.0" junit = "5.11.0" logback = "1.5.8" -logviewer = "0.2.0-SNAPSHOT" +logviewer = "0.2.0" -javadocviewer = "0.1.1-SNAPSHOT" +javadocviewer = "0.1.1" openslide = "4.0.0.3" From 2622b3576c940d8aaf02e8729bd6779aeb9ac5f7 Mon Sep 17 00:00:00 2001 From: Leo Leplat <60394504+Rylern@users.noreply.github.com> Date: Tue, 10 Sep 2024 15:09:48 +0100 Subject: [PATCH 290/866] Removed zarr writer tests --- .../writers/ome/zarr/TestOMEZarrWriter.java | 503 ------------------ 1 file changed, 503 deletions(-) delete mode 100644 qupath-extension-bioformats/src/test/java/qupath/lib/images/writers/ome/zarr/TestOMEZarrWriter.java diff --git a/qupath-extension-bioformats/src/test/java/qupath/lib/images/writers/ome/zarr/TestOMEZarrWriter.java b/qupath-extension-bioformats/src/test/java/qupath/lib/images/writers/ome/zarr/TestOMEZarrWriter.java deleted file mode 100644 index b451ff06e..000000000 --- a/qupath-extension-bioformats/src/test/java/qupath/lib/images/writers/ome/zarr/TestOMEZarrWriter.java +++ /dev/null @@ -1,503 +0,0 @@ -package qupath.lib.images.writers.ome.zarr; - -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; -import qupath.lib.color.ColorModelFactory; -import qupath.lib.images.servers.AbstractImageServer; -import qupath.lib.images.servers.ImageChannel; -import qupath.lib.images.servers.ImageServer; -import qupath.lib.images.servers.ImageServerBuilder; -import qupath.lib.images.servers.ImageServerMetadata; -import qupath.lib.images.servers.ImageServerProvider; -import qupath.lib.images.servers.PixelType; -import qupath.lib.regions.ImageRegion; -import qupath.lib.regions.RegionRequest; - -import java.awt.image.BandedSampleModel; -import java.awt.image.BufferedImage; -import java.awt.image.DataBuffer; -import java.awt.image.DataBufferDouble; -import java.awt.image.WritableRaster; -import java.net.URI; -import java.nio.file.Files; -import java.nio.file.Paths; -import java.util.Collection; -import java.util.List; -import java.util.UUID; - -public class TestOMEZarrWriter { - - @Test - void Check_Error_When_Extension_Incorrect() throws Exception { - String extension = ".wrong.extension"; - String outputImagePath = Paths.get(Files.createTempDirectory(UUID.randomUUID().toString()).toString(), "image" + extension).toString(); - SampleImageServer sampleImageServer = new SampleImageServer(); - - Assertions.assertThrows( - IllegalArgumentException.class, - () -> new OMEZarrWriter.Builder(sampleImageServer, outputImagePath) - ); - - sampleImageServer.close(); - } - - @Test - void Check_Full_Image_Pixels() throws Exception { - String outputImagePath = Paths.get(Files.createTempDirectory(UUID.randomUUID().toString()).toString(), "image.ome.zarr").toString(); - SampleImageServer sampleImageServer = new SampleImageServer(); - int level = 0; - int z = 2; - int t = 1; - BufferedImage expectedImage = sampleImageServer.readRegion( - sampleImageServer.getDownsampleForResolution(level), - 0, - 0, - sampleImageServer.getWidth(), - sampleImageServer.getHeight(), - z, - t - ); - - OMEZarrWriter writer = new OMEZarrWriter.Builder(sampleImageServer, outputImagePath) - .build(); - writer.writeImage(); - - BufferedImage image; - try (ImageServer server = ImageServerProvider.buildServer(outputImagePath, BufferedImage.class)) { - image = server.readRegion( - server.getDownsampleForResolution(level), - 0, - 0, - server.getWidth(), - server.getHeight(), - z, - t - ); - } - assertDoubleBufferedImagesEqual(expectedImage, image); - - writer.close(); - sampleImageServer.close(); - } - - @Test - void Check_Downsampled_Image_Pixels() throws Exception { - String outputImagePath = Paths.get(Files.createTempDirectory(UUID.randomUUID().toString()).toString(), "image.ome.zarr").toString(); - SampleImageServer sampleImageServer = new SampleImageServer(); - int level = 1; - int z = 2; - int t = 1; - BufferedImage expectedImage = sampleImageServer.readRegion( - sampleImageServer.getDownsampleForResolution(level), - 0, - 0, - sampleImageServer.getWidth(), - sampleImageServer.getHeight(), - z, - t - ); - - OMEZarrWriter writer = new OMEZarrWriter.Builder(sampleImageServer, outputImagePath) - .build(); - writer.writeImage(); - - BufferedImage image; - try (ImageServer server = ImageServerProvider.buildServer(outputImagePath, BufferedImage.class)) { - image = server.readRegion( - server.getDownsampleForResolution(level), - 0, - 0, - server.getWidth(), - server.getHeight(), - z, - t - ); - } - assertDoubleBufferedImagesEqual(expectedImage, image); - - writer.close(); - sampleImageServer.close(); - } - - @Test - void Check_Downsamples_When_Not_Specified() throws Exception { - String outputImagePath = Paths.get(Files.createTempDirectory(UUID.randomUUID().toString()).toString(), "image.ome.zarr").toString(); - SampleImageServer sampleImageServer = new SampleImageServer(); - double[] expectedDownsamples = sampleImageServer.getPreferredDownsamples(); - - OMEZarrWriter writer = new OMEZarrWriter.Builder(sampleImageServer, outputImagePath) - .setDownsamples() - .build(); - writer.writeImage(); - - double[] downsamples; - try (ImageServer server = ImageServerProvider.buildServer(outputImagePath, BufferedImage.class)) { - downsamples = server.getPreferredDownsamples(); - } - Assertions.assertArrayEquals(expectedDownsamples, downsamples); - - writer.close(); - sampleImageServer.close(); - } - - @Test - void Check_Downsamples_When_Specified() throws Exception { - String outputImagePath = Paths.get(Files.createTempDirectory(UUID.randomUUID().toString()).toString(), "image.ome.zarr").toString(); - SampleImageServer sampleImageServer = new SampleImageServer(); - double[] expectedDownsamples = new double[] {1, 2, 4}; - - OMEZarrWriter writer = new OMEZarrWriter.Builder(sampleImageServer, outputImagePath) - .setDownsamples(expectedDownsamples) - .build(); - writer.writeImage(); - - double[] downsamples; - try (ImageServer server = ImageServerProvider.buildServer(outputImagePath, BufferedImage.class)) { - downsamples = server.getPreferredDownsamples(); - } - Assertions.assertArrayEquals(expectedDownsamples, downsamples); - - writer.close(); - sampleImageServer.close(); - } - - @Test - void Check_Default_Tile_Width() throws Exception { - String outputImagePath = Paths.get(Files.createTempDirectory(UUID.randomUUID().toString()).toString(), "image.ome.zarr").toString(); - SampleImageServer sampleImageServer = new SampleImageServer(); - int expectedTileWidth = sampleImageServer.getMetadata().getPreferredTileWidth(); - - OMEZarrWriter writer = new OMEZarrWriter.Builder(sampleImageServer, outputImagePath) - .setTileWidth(-1) - .build(); - writer.writeImage(); - - int tileWidth; - try (ImageServer server = ImageServerProvider.buildServer(outputImagePath, BufferedImage.class)) { - tileWidth = server.getMetadata().getPreferredTileWidth(); - } - Assertions.assertEquals(expectedTileWidth, tileWidth); - - writer.close(); - sampleImageServer.close(); - } - - @Test - void Check_Custom_Tile_Width() throws Exception { - String outputImagePath = Paths.get(Files.createTempDirectory(UUID.randomUUID().toString()).toString(), "image.ome.zarr").toString(); - SampleImageServer sampleImageServer = new SampleImageServer(); - int expectedTileWidth = 64; - - OMEZarrWriter writer = new OMEZarrWriter.Builder(sampleImageServer, outputImagePath) - .setTileWidth(expectedTileWidth) - .build(); - writer.writeImage(); - - int tileWidth; - try (ImageServer server = ImageServerProvider.buildServer(outputImagePath, BufferedImage.class)) { - tileWidth = server.getMetadata().getPreferredTileWidth(); - } - Assertions.assertEquals(expectedTileWidth, tileWidth); - - writer.close(); - sampleImageServer.close(); - } - - @Test - void Check_Default_Tile_Height() throws Exception { - String outputImagePath = Paths.get(Files.createTempDirectory(UUID.randomUUID().toString()).toString(), "image.ome.zarr").toString(); - SampleImageServer sampleImageServer = new SampleImageServer(); - int expectedTileHeight = sampleImageServer.getMetadata().getPreferredTileHeight(); - - OMEZarrWriter writer = new OMEZarrWriter.Builder(sampleImageServer, outputImagePath) - .setTileHeight(-1) - .build(); - writer.writeImage(); - - int tileHeight; - try (ImageServer server = ImageServerProvider.buildServer(outputImagePath, BufferedImage.class)) { - tileHeight = server.getMetadata().getPreferredTileHeight(); - } - Assertions.assertEquals(expectedTileHeight, tileHeight); - - writer.close(); - sampleImageServer.close(); - } - - @Test - void Check_Custom_Tile_Height() throws Exception { - String outputImagePath = Paths.get(Files.createTempDirectory(UUID.randomUUID().toString()).toString(), "image.ome.zarr").toString(); - SampleImageServer sampleImageServer = new SampleImageServer(); - int expectedTileHeight = 64; - - OMEZarrWriter writer = new OMEZarrWriter.Builder(sampleImageServer, outputImagePath) - .setTileHeight(expectedTileHeight) - .build(); - writer.writeImage(); - - int tileHeight; - try (ImageServer server = ImageServerProvider.buildServer(outputImagePath, BufferedImage.class)) { - tileHeight = server.getMetadata().getPreferredTileHeight(); - } - Assertions.assertEquals(expectedTileHeight, tileHeight); - - writer.close(); - sampleImageServer.close(); - } - - @Test - void Check_Bounding_Box() throws Exception { - String outputImagePath = Paths.get(Files.createTempDirectory(UUID.randomUUID().toString()).toString(), "image.ome.zarr").toString(); - SampleImageServer sampleImageServer = new SampleImageServer(); - int z = 2; - int t = 1; - ImageRegion boundingBox = ImageRegion.createInstance(5, 5, 20, 25, z, t); - BufferedImage expectedImage = sampleImageServer.readRegion(RegionRequest.createInstance(sampleImageServer.getPath(), 1, boundingBox)); - - OMEZarrWriter writer = new OMEZarrWriter.Builder(sampleImageServer, outputImagePath) - .setBoundingBox(boundingBox) - .build(); - writer.writeImage(); - - BufferedImage image; - try (ImageServer server = ImageServerProvider.buildServer(outputImagePath, BufferedImage.class)) { - image = server.readRegion(1, 0, 0, server.getWidth(), server.getHeight(), z, t); - } - assertDoubleBufferedImagesEqual(expectedImage, image); - - writer.close(); - sampleImageServer.close(); - } - - @Test - void Check_Z_Sliced_Image_Number_Of_Z_Stacks() throws Exception { - String outputImagePath = Paths.get(Files.createTempDirectory(UUID.randomUUID().toString()).toString(), "image.ome.zarr").toString(); - SampleImageServer sampleImageServer = new SampleImageServer(); - int zStart = 1; - int zEnd = 3; - int expectedNumberOfZStacks = zEnd - zStart; - - OMEZarrWriter writer = new OMEZarrWriter.Builder(sampleImageServer, outputImagePath) - .setZSlices(zStart, zEnd) - .build(); - writer.writeImage(); - - int numberOfZStacks; - try (ImageServer server = ImageServerProvider.buildServer(outputImagePath, BufferedImage.class)) { - numberOfZStacks = server.nZSlices(); - } - Assertions.assertEquals(expectedNumberOfZStacks, numberOfZStacks); - - writer.close(); - sampleImageServer.close(); - } - - @Test - void Check_Z_Sliced_Image_Pixels() throws Exception { - String outputImagePath = Paths.get(Files.createTempDirectory(UUID.randomUUID().toString()).toString(), "image.ome.zarr").toString(); - SampleImageServer sampleImageServer = new SampleImageServer(); - int zStart = 1; - int zEnd = 3; - int z = 1; - int t = 1; - BufferedImage expectedImage = sampleImageServer.readRegion(RegionRequest.createInstance( - sampleImageServer.getPath(), - 1, - 0, - 0, - sampleImageServer.getWidth(), - sampleImageServer.getHeight(), - z, - t - )); - - OMEZarrWriter writer = new OMEZarrWriter.Builder(sampleImageServer, outputImagePath) - .setZSlices(zStart, zEnd) - .build(); - writer.writeImage(); - - BufferedImage image; - try (ImageServer server = ImageServerProvider.buildServer(outputImagePath, BufferedImage.class)) { - image = server.readRegion(1, 0, 0, server.getWidth(), server.getHeight(), z - zStart, t); - } - assertDoubleBufferedImagesEqual(expectedImage, image); - - writer.close(); - sampleImageServer.close(); - } - - @Test - void Check_T_Sliced_Image_Number_Of_Timepoints() throws Exception { - String outputImagePath = Paths.get(Files.createTempDirectory(UUID.randomUUID().toString()).toString(), "image.ome.zarr").toString(); - SampleImageServer sampleImageServer = new SampleImageServer(); - int tStart = 1; - int tEnd = 2; - int expectedNumberOfTimepoints = tEnd - tStart; - - OMEZarrWriter writer = new OMEZarrWriter.Builder(sampleImageServer, outputImagePath) - .setTimepoints(tStart, tEnd) - .build(); - writer.writeImage(); - - int numberOfTimepoints; - try (ImageServer server = ImageServerProvider.buildServer(outputImagePath, BufferedImage.class)) { - numberOfTimepoints = server.nTimepoints(); - } - Assertions.assertEquals(expectedNumberOfTimepoints, numberOfTimepoints); - - writer.close(); - sampleImageServer.close(); - } - - @Test - void Check_T_Sliced_Image_Pixels() throws Exception { - String outputImagePath = Paths.get(Files.createTempDirectory(UUID.randomUUID().toString()).toString(), "image.ome.zarr").toString(); - SampleImageServer sampleImageServer = new SampleImageServer(); - int tStart = 1; - int tEnd = 2; - int z = 1; - int t = 1; - BufferedImage expectedImage = sampleImageServer.readRegion(RegionRequest.createInstance( - sampleImageServer.getPath(), - 1, - 0, - 0, - sampleImageServer.getWidth(), - sampleImageServer.getHeight(), - z, - t - )); - - OMEZarrWriter writer = new OMEZarrWriter.Builder(sampleImageServer, outputImagePath) - .setTimepoints(tStart, tEnd) - .build(); - writer.writeImage(); - - BufferedImage image; - try (ImageServer server = ImageServerProvider.buildServer(outputImagePath, BufferedImage.class)) { - image = server.readRegion(1, 0, 0, server.getWidth(), server.getHeight(), z, t - tStart); - } - assertDoubleBufferedImagesEqual(expectedImage, image); - - writer.close(); - sampleImageServer.close(); - } - - private static class SampleImageServer extends AbstractImageServer { - - private static final int IMAGE_WIDTH = 64; - private static final int IMAGE_HEIGHT = 64; - - public SampleImageServer() { - super(BufferedImage.class); - } - - @Override - protected ImageServerBuilder.ServerBuilder createServerBuilder() { - return null; - } - - @Override - protected String createID() { - return getClass().getName(); - } - - @Override - public Collection getURIs() { - return List.of(); - } - - @Override - public String getServerType() { - return "Sample server"; - } - - @Override - public ImageServerMetadata getOriginalMetadata() { - return new ImageServerMetadata.Builder() - .width(IMAGE_WIDTH) - .height(IMAGE_HEIGHT) - .sizeZ(3) - .sizeT(2) - .pixelType(PixelType.FLOAT64) - .preferredTileSize(32, 32) - .channels(List.of( - ImageChannel.getInstance("c1", 1), - ImageChannel.getInstance("c2", 2), - ImageChannel.getInstance("c3", 3), - ImageChannel.getInstance("c4", 4), - ImageChannel.getInstance("c5", 5) - )) - .name("name") - .levelsFromDownsamples(1, 2) - .build(); - } - - @Override - public BufferedImage readRegion(RegionRequest request) { - DataBuffer dataBuffer = createDataBuffer(request); - - return new BufferedImage( - ColorModelFactory.createColorModel(getMetadata().getPixelType(), getMetadata().getChannels()), - WritableRaster.createWritableRaster( - new BandedSampleModel( - dataBuffer.getDataType(), - (int) (request.getWidth() / request.getDownsample()), - (int) (request.getHeight() / request.getDownsample()), - nChannels() - ), - dataBuffer, - null - ), - false, - null - ); - } - - private DataBuffer createDataBuffer(RegionRequest request) { - double[][] array = new double[nChannels()][]; - - for (int c = 0; c < array.length; c++) { - array[c] = getPixels(request, c); - } - - return new DataBufferDouble(array, (int) (request.getWidth() * request.getHeight() / 8 / (request.getDownsample() * request.getDownsample()))); - } - - private double[] getPixels(RegionRequest request, int channel) { - int originX = (int) (request.getX() / request.getDownsample()); - int originY = (int) (request.getY() / request.getDownsample()); - int width = (int) (request.getWidth() / request.getDownsample()); - int height = (int) (request.getHeight() / request.getDownsample()); - double[] pixels = new double[width * height]; - - for (int y=0; y Date: Tue, 10 Sep 2024 16:12:25 +0100 Subject: [PATCH 291/866] Parallel writing and image downsamples by default --- .../images/writers/ome/ConvertCommand.java | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/ConvertCommand.java b/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/ConvertCommand.java index 299a48bce..b150dbe1e 100644 --- a/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/ConvertCommand.java +++ b/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/ConvertCommand.java @@ -23,6 +23,7 @@ import java.awt.image.BufferedImage; import java.io.File; +import java.util.Arrays; import java.util.Optional; import java.util.regex.Pattern; import java.util.stream.DoubleStream; @@ -83,12 +84,15 @@ public class ConvertCommand implements Runnable, Subcommand { }) private String timepoints; - @Option(names = {"-d", "--downsample"}, defaultValue = "1.0", description = "Downsample the input image by the given factor (default=1).") + @Option(names = {"-d", "--downsample"}, defaultValue = "1.0", description = { + "Downsample the input image by the given factor (default=1)." + }) private double downsample; @Option(names = {"-y", "--pyramid-scale"}, defaultValue = "1.0", description = { "Scale factor for pyramidal images.", - "Each pyramidal level is scaled down by the specified factor (> 1)." + "Each pyramidal level is scaled down by the specified factor (> 1).", + "The downsamples of the original image are used if <= 1." }) private double pyramid; @@ -115,7 +119,7 @@ public class ConvertCommand implements Runnable, Subcommand { }) private OMEPyramidWriter.CompressionType compression; - @Option(names = {"-p", "--parallelize"}, defaultValue = "false", paramLabel = "parallelization", description = "Parallelize tile export if possible.") + @Option(names = {"-p", "--parallelize"}, defaultValue = "true", paramLabel = "parallelization", description = "Parallelize tile export if possible.") private boolean parallelize; @Option(names = {"--overwrite"}, defaultValue = "false", description = "Overwrite any existing file with the same name as the output.") @@ -217,7 +221,10 @@ public void run() { if (pyramid > 1) { builder.scaledDownsampling(downsample, pyramid); } else { - builder.downsamples(downsample); + builder.downsamples(DoubleStream.concat( + DoubleStream.of(downsample), + Arrays.stream(server.getPreferredDownsamples()).filter(d -> d > downsample) + ).toArray()); } if (boundingBox.isPresent()) { @@ -251,7 +258,10 @@ public void run() { d -> d * pyramid ).toArray()); } else { - builder.setDownsamples(downsample); + builder.setDownsamples(DoubleStream.concat( + DoubleStream.of(downsample), + Arrays.stream(server.getPreferredDownsamples()).filter(d -> d > downsample) + ).toArray()); } try (OMEZarrWriter writer = builder.build()) { From 2f7df7b79a2cd771f07d0f47128cc20f64f423fb Mon Sep 17 00:00:00 2001 From: Pete Date: Tue, 10 Sep 2024 16:19:34 +0100 Subject: [PATCH 292/866] Recognize (ome).zarr with drag & drop Makes opening .ome.zarr images much easier --- .../gui/viewer/DragDropImportListener.java | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/DragDropImportListener.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/DragDropImportListener.java index 31ebf4ca5..412c346e1 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/DragDropImportListener.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/DragDropImportListener.java @@ -430,19 +430,26 @@ else if (ext.equals(".json")) } // Check if this is a directory - if so, look for a single project file - if (singleFile && file.isDirectory()) { + boolean maybeZarr = file.isDirectory() && file.getName().toLowerCase().endsWith(".zarr"); + if (singleFile && file.isDirectory() && !maybeZarr) { // Identify all files in the directory, and also all potential project files File[] filesInDirectory = file.listFiles(f -> !f.isHidden()); - List projectFiles = Arrays.stream(filesInDirectory).filter(f -> f.isFile() && - f.getAbsolutePath().toLowerCase().endsWith(ProjectIO.getProjectExtension())).toList(); + if (filesInDirectory == null) { + // This shouldn't happen because we already checked if it's a directory + logger.warn("Could not list files in directory {}", file); + filesInDirectory = new File[0]; + return; + } + List projectFiles = Arrays.stream(filesInDirectory).filter(f -> f.isFile() && + f.getAbsolutePath().toLowerCase().endsWith(ProjectIO.getProjectExtension())).toList(); if (projectFiles.size() == 1) { - file = projectFiles.get(0); + file = projectFiles.getFirst(); fileName = file.getName().toLowerCase(); logger.warn("Selecting project file {}", file); } else if (projectFiles.size() > 1) { // Prompt to select which project file to open logger.debug("Multiple project files found in directory {}", file); - String[] fileNames = projectFiles.stream().map(f -> f.getName()).toArray(n -> new String[n]); + String[] fileNames = projectFiles.stream().map(File::getName).toArray(String[]::new); String selectedName = Dialogs.showChoiceDialog( QuPathResources.getString("DragDrop.selectProject"), QuPathResources.getString("DragDrop.selectProjectToOpen"), fileNames, fileNames[0]); @@ -528,7 +535,7 @@ else if (ext.equals(".json")) } // Assume we have images - if (singleFile && file.isFile()) { + if (singleFile && (file.isFile() || maybeZarr)) { // Try to open as an image, if the extension is known if (viewer == null) { Dialogs.showErrorMessage( @@ -540,7 +547,7 @@ else if (ext.equals(".json")) return; } else if (qupath.getProject() != null) { // Try importing multiple images to a project - String[] potentialFiles = list.stream().filter(f -> f.isFile()).map(f -> f.getAbsolutePath()).toArray(String[]::new); + String[] potentialFiles = list.stream().filter(File::isFile).map(File::getAbsolutePath).toArray(String[]::new); if (potentialFiles.length > 0) { ProjectCommands.promptToImportImages(qupath, potentialFiles); return; From 023d5f5bc4fe1b7e331c434bff547047d0b98994 Mon Sep 17 00:00:00 2001 From: Pete Date: Tue, 10 Sep 2024 16:47:30 +0100 Subject: [PATCH 293/866] Update DragDropImportListener.java --- .../main/java/qupath/lib/gui/viewer/DragDropImportListener.java | 1 - 1 file changed, 1 deletion(-) diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/DragDropImportListener.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/DragDropImportListener.java index 412c346e1..adbadf2a9 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/DragDropImportListener.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/DragDropImportListener.java @@ -438,7 +438,6 @@ else if (ext.equals(".json")) // This shouldn't happen because we already checked if it's a directory logger.warn("Could not list files in directory {}", file); filesInDirectory = new File[0]; - return; } List projectFiles = Arrays.stream(filesInDirectory).filter(f -> f.isFile() && f.getAbsolutePath().toLowerCase().endsWith(ProjectIO.getProjectExtension())).toList(); From 634e1390f39fcc7e842094ba955a3efd3abce188 Mon Sep 17 00:00:00 2001 From: Leo Leplat <60394504+Rylern@users.noreply.github.com> Date: Tue, 10 Sep 2024 16:54:09 +0100 Subject: [PATCH 294/866] Improved zarr writer --- .../ome/zarr/OMEZarrAttributesCreator.java | 23 +++++++++++-------- .../writers/ome/zarr/OMEZarrWriter.java | 3 +-- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrAttributesCreator.java b/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrAttributesCreator.java index 3bdb92966..963167cbc 100644 --- a/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrAttributesCreator.java +++ b/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrAttributesCreator.java @@ -2,6 +2,7 @@ import qupath.lib.common.ColorTools; import qupath.lib.images.servers.ImageChannel; +import qupath.lib.images.servers.PixelCalibration; import qupath.lib.images.servers.PixelType; import java.util.ArrayList; @@ -22,7 +23,7 @@ class OMEZarrAttributesCreator { private final int numberOfZSlices; private final int numberOfTimePoints; private final int numberOfChannels; - private final boolean pixelSizeInMicrometer; + private final PixelCalibration pixelCalibration; private final TimeUnit timeUnit; private final double[] downsamples; private final List channels; @@ -43,7 +44,7 @@ private enum Dimension { * @param numberOfZSlices the number of z-stacks * @param numberOfTimePoints the number of time points * @param numberOfChannels the number of channels - * @param pixelSizeInMicrometer whether pixel sizes are in micrometer + * @param pixelCalibration the pixel calibration * @param timeUnit the unit of the time dimension of the image * @param downsamples the downsamples of the image * @param channels the channels of the image @@ -55,7 +56,7 @@ public OMEZarrAttributesCreator( int numberOfZSlices, int numberOfTimePoints, int numberOfChannels, - boolean pixelSizeInMicrometer, + PixelCalibration pixelCalibration, TimeUnit timeUnit, double[] downsamples, List channels, @@ -66,7 +67,7 @@ public OMEZarrAttributesCreator( this.numberOfZSlices = numberOfZSlices; this.numberOfTimePoints = numberOfTimePoints; this.numberOfChannels = numberOfChannels; - this.pixelSizeInMicrometer = pixelSizeInMicrometer; + this.pixelCalibration = pixelCalibration; this.timeUnit = timeUnit; this.downsamples = downsamples; this.channels = channels; @@ -196,7 +197,7 @@ private Map getAxis(Dimension dimension) { switch (dimension) { case X, Y, Z -> { - if (pixelSizeInMicrometer) { + if (pixelCalibration.getPixelWidthUnit().equals(PixelCalibration.MICROMETER)) { axis.put("unit", "micrometer"); } } @@ -217,16 +218,20 @@ private Map getAxis(Dimension dimension) { private Map getCoordinateTransformation(float downsample) { List scales = new ArrayList<>(); if (numberOfTimePoints > 1) { - scales.add(1F); + if (!Double.isNaN(pixelCalibration.getTimepoint(0)) && !Double.isNaN(pixelCalibration.getTimepoint(1))) { + scales.add((float) (pixelCalibration.getTimepoint(1) - pixelCalibration.getTimepoint(0))); + } else { + scales.add(1F); + } } if (numberOfChannels > 1) { scales.add(1F); } if (numberOfZSlices > 1) { - scales.add(1F); + scales.add(pixelCalibration.getZSpacing().floatValue()); } - scales.add(downsample); - scales.add(downsample); + scales.add(pixelCalibration.getPixelHeight().floatValue() * downsample); + scales.add(pixelCalibration.getPixelWidth().floatValue() * downsample); return Map.of( "type", "scale", diff --git a/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriter.java b/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriter.java index 5396f7387..dcef4e49f 100644 --- a/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriter.java +++ b/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriter.java @@ -12,7 +12,6 @@ import org.slf4j.LoggerFactory; import qupath.lib.images.servers.ImageServer; import qupath.lib.images.servers.ImageServers; -import qupath.lib.images.servers.PixelCalibration; import qupath.lib.images.servers.TileRequest; import qupath.lib.images.servers.TileRequestManager; import qupath.lib.images.servers.TransformedServerBuilder; @@ -83,7 +82,7 @@ private OMEZarrWriter(Builder builder) throws IOException { server.nZSlices(), server.nTimepoints(), server.nChannels(), - server.getMetadata().getPixelCalibration().getPixelWidthUnit().equals(PixelCalibration.MICROMETER), + server.getMetadata().getPixelCalibration(), server.getMetadata().getTimeUnit(), server.getPreferredDownsamples(), server.getMetadata().getChannels(), From 72c08443f8e0d6d24b342cc93ee1886a9f0f3a08 Mon Sep 17 00:00:00 2001 From: Pete Date: Wed, 11 Sep 2024 06:52:59 +0100 Subject: [PATCH 295/866] Improve parsing of timepoints Fixes https://github.com/qupath/qupath/issues/1628 (at least for mitosis.tif - more examples needed) --- CHANGELOG.md | 1 + .../imagej/images/servers/ImageJServer.java | 69 ++++++++++++++++--- .../bioformats/BioFormatsImageServer.java | 31 +++------ 3 files changed, 70 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e873bd3c4..6737b5f6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,6 +60,7 @@ They may change or be removed in future versions. * QuPath doesn't always use the specified file extension when exporting snapshots (https://github.com/qupath/qupath/issues/1567) * Out-of-bounds tiles can result in detected cells being in the wrong place (https://github.com/qupath/qupath/issues/1606) * Spacebar can be locked in an 'on' position within viewers (https://github.com/qupath/qupath/issues/1610) +* Timepoint data is rarely available (or correct) (https://github.com/qupath/qupath/issues/1628) ### API changes * New `Map getMetadata()` method added to `PathObject`, `Project` and `ProjectImageEntry` (https://github.com/qupath/qupath/pull/1587) diff --git a/qupath-core-processing/src/main/java/qupath/imagej/images/servers/ImageJServer.java b/qupath-core-processing/src/main/java/qupath/imagej/images/servers/ImageJServer.java index 311d002d4..71a738151 100644 --- a/qupath-core-processing/src/main/java/qupath/imagej/images/servers/ImageJServer.java +++ b/qupath-core-processing/src/main/java/qupath/imagej/images/servers/ImageJServer.java @@ -143,19 +143,15 @@ public ImageJServer(final URI uri, final String...args) throws IOException { double xMicrons = IJTools.tryToParseMicrons(cal.pixelWidth, cal.getXUnit()); double yMicrons = IJTools.tryToParseMicrons(cal.pixelHeight, cal.getYUnit()); double zMicrons = IJTools.tryToParseMicrons(cal.pixelDepth, cal.getZUnit()); - TimeUnit timeUnit = null; + TimeUnit timeUnit = parseTimeUnit(cal.getTimeUnit()); double[] timepoints = null; - for (TimeUnit temp : TimeUnit.values()) { - if (temp.toString().toLowerCase().equals(cal.getTimeUnit())) { - timeUnit = temp; - timepoints = new double[imp.getNFrames()]; - for (int i = 0; i < timepoints.length; i++) { - timepoints[i] = i * cal.frameInterval; - } - break; + if (timeUnit != null) { + timepoints = new double[imp.getNFrames()]; + for (int i = 0; i < timepoints.length; i++) { + timepoints[i] = i * cal.frameInterval; } } - + PixelType pixelType; boolean isRGB = false; switch (imp.getType()) { @@ -252,7 +248,58 @@ public ImageJServer(final URI uri, final String...args) throws IOException { // if ((!isRGB() && nChannels() > 1) || getBitsPerPixel() == 32) // throw new IOException("Sorry, currently only RGB & single-channel 8 & 16-bit images supported using ImageJ server"); } - + + /** + * Attempt to parse a time unit from an ImageJ calibration string. + * @param unit + * @return a time unit if possible, or null if none could be found + */ + private static TimeUnit parseTimeUnit(String unit) { + if (unit == null || unit.isBlank()) + return null; + unit = unit.toLowerCase().strip(); + switch (unit) { + case "s": + case "sec": + case "second": + case "seconds": + return TimeUnit.SECONDS; + case "ms": + case "msec": + case "millisecond": + case "milliseconds": + return TimeUnit.MILLISECONDS; + case "us": + case "usec": + case "microsecond": + case "microseconds": + return TimeUnit.MICROSECONDS; + case "ns": + case "nsec": + case "nanosecond": + case "nanoseconds": + return TimeUnit.NANOSECONDS; + case "min": + case "minute": + case "minutes": + return TimeUnit.MINUTES; + case "h": + case "hr": + case "hour": + case "hours": + return TimeUnit.HOURS; + case "d": + case "day": + case "days": + return TimeUnit.DAYS; + } + for (TimeUnit timeUnit : TimeUnit.values()) { + if (timeUnit.toString().equalsIgnoreCase(unit)) + return timeUnit; + } + return null; + } + @Override public Collection readPathObjects() { diff --git a/qupath-extension-bioformats/src/main/java/qupath/lib/images/servers/bioformats/BioFormatsImageServer.java b/qupath-extension-bioformats/src/main/java/qupath/lib/images/servers/bioformats/BioFormatsImageServer.java index 8e1d120ac..27bf29b69 100644 --- a/qupath-extension-bioformats/src/main/java/qupath/lib/images/servers/bioformats/BioFormatsImageServer.java +++ b/qupath-extension-bioformats/src/main/java/qupath/lib/images/servers/bioformats/BioFormatsImageServer.java @@ -276,7 +276,6 @@ static BioFormatsImageServer checkSupport(URI uri, final BioFormatsServerOptions // Create variables for metadata int width = 0, height = 0, nChannels = 1, nZSlices = 1, nTimepoints = 1, tileWidth = 0, tileHeight = 0; double pixelWidth = Double.NaN, pixelHeight = Double.NaN, zSpacing = Double.NaN, magnification = Double.NaN; - TimeUnit timeUnit = null; // Zarr images can be opened by selecting the .zattrs or .zgroup file // In that case, the parent directory contains the whole image @@ -666,7 +665,8 @@ static BioFormatsImageServer checkSupport(URI uri, final BioFormatsServerOptions } // Try parsing pixel sizes in micrometers - double[] timepoints; + double[] timepoints = null; + TimeUnit timeUnit = null; try { Length xSize = meta.getPixelsPhysicalSizeX(series); Length ySize = meta.getPixelsPhysicalSizeY(series); @@ -687,25 +687,16 @@ static BioFormatsImageServer checkSupport(URI uri, final BioFormatsServerOptions } // TODO: Check the Bioformats TimeStamps if (nTimepoints > 1) { - logger.warn("Time stamps read from Bioformats have not been fully verified & should not be relied upon"); - // Here, we don't try to separate timings by z-slice & channel... - int lastTimepoint = -1; - int count = 0; - timepoints = new double[nTimepoints]; - logger.debug("Plane count: " + meta.getPlaneCount(series)); - for (int plane = 0; plane < meta.getPlaneCount(series); plane++) { - int timePoint = meta.getPlaneTheT(series, plane).getValue(); - logger.debug("Checking " + timePoint); - if (timePoint != lastTimepoint) { - timepoints[count] = meta.getPlaneDeltaT(series, plane).value(UNITS.SECOND).doubleValue(); - logger.debug(String.format("Timepoint %d: %.3f seconds", count, timepoints[count])); - lastTimepoint = timePoint; - count++; + logger.warn("Time stamps read from Bioformats have not been fully verified & should not be relied upon (values updated in v0.6.0)"); + var timeIncrement = meta.getPixelsTimeIncrement(series); + if (timeIncrement != null) { + timepoints = new double[nTimepoints]; + double timeIncrementSeconds = timeIncrement.value(UNITS.SECOND).doubleValue(); + for (int t = 0; t < nTimepoints; t++) { + timepoints[t] = t * timeIncrementSeconds; } + timeUnit = TimeUnit.SECONDS; } - timeUnit = TimeUnit.SECONDS; - } else { - timepoints = new double[0]; } } catch (Exception e) { logger.error("Error parsing metadata", e); @@ -799,7 +790,7 @@ static BioFormatsImageServer checkSupport(URI uri, final BioFormatsServerOptions if (Double.isFinite(magnification)) builder = builder.magnification(magnification); - if (timeUnit != null) + if (timeUnit != null && timepoints != null) builder = builder.timepoints(timeUnit, timepoints); if (Double.isFinite(pixelWidth + pixelHeight)) From 3e1b3f54d10d7da5bfd9b8b5d2acb015c70237a9 Mon Sep 17 00:00:00 2001 From: Pete Date: Wed, 11 Sep 2024 07:28:17 +0100 Subject: [PATCH 296/866] Update ConvertCommand.java Change range is valid. Tell user defaults for main options where that is relevant. --- .../images/writers/ome/ConvertCommand.java | 25 ++++++++++++++++--- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/ConvertCommand.java b/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/ConvertCommand.java index b150dbe1e..842544f23 100644 --- a/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/ConvertCommand.java +++ b/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/ConvertCommand.java @@ -106,10 +106,10 @@ public class ConvertCommand implements Runnable, Subcommand { @Option(names = {"--tile-size"}, defaultValue = "-1", description = "Set the tile size (of equal height and width).") private int tileSize; - @Option(names = {"--tile-width"}, defaultValue = "512", description = "Set the tile width.") + @Option(names = {"--tile-width"}, defaultValue = "512", description = "Set the tile width (default=512).") private int tileWidth; - @Option(names = {"--tile-height"}, defaultValue = "512", description = "Set the tile height.") + @Option(names = {"--tile-height"}, defaultValue = "512", description = "Set the tile height (default=512).") private int tileHeight; @Option(names = {"-c", "--compression"}, defaultValue = "DEFAULT", description = { @@ -119,10 +119,13 @@ public class ConvertCommand implements Runnable, Subcommand { }) private OMEPyramidWriter.CompressionType compression; - @Option(names = {"-p", "--parallelize"}, defaultValue = "true", paramLabel = "parallelization", description = "Parallelize tile export if possible.") + @Option(names = {"-p", "--parallelize"}, defaultValue = "true", paramLabel = "parallelization", + description = "Parallelize tile export if possible (default=true).", + negatable = true) private boolean parallelize; - @Option(names = {"--overwrite"}, defaultValue = "false", description = "Overwrite any existing file with the same name as the output.") + @Option(names = {"--overwrite"}, defaultValue = "false", + description = "Overwrite any existing file with the same name as the output (default=false).") private boolean overwrite = false; @Option(names = {"--series"}, description = { @@ -197,7 +200,16 @@ public void run() { } Range zSlicesRange = getRange(zSlices, server.nZSlices(), "zslices"); + if (!isValidRange(zSlicesRange, server.nZSlices())) { + logger.error("Invalid range of --zslices: {}, image supports {}-{}", zSlices, 1, server.nZSlices()); + System.exit(-1); + } + Range timepointsRange = getRange(timepoints, server.nTimepoints(), "timepoints"); + if (!isValidRange(timepointsRange, server.nTimepoints())) { + logger.error("Invalid range of --timepoints: {}, image supports {}-{}", timepoints, 1, server.nTimepoints()); + System.exit(-1); + } Optional boundingBox = getBoundingBox(crop); switch (outputType) { @@ -341,6 +353,10 @@ private static Range getRange(String rangeText, int maxRange, String rangeLabel) } } + static boolean isValidRange(Range range, int maxRange) { + return range.start() >= 0 && range.end() <= maxRange && range.start() < range.end(); + } + /** * Parse the provided bounding box text and return an ImageRegion * corresponding to it. @@ -368,4 +384,5 @@ private static Optional getBoundingBox(String crop) { return Optional.empty(); } } + } \ No newline at end of file From 864f400c260fd8f002eb5a4af471e3cfd0eb30ec Mon Sep 17 00:00:00 2001 From: Pete Date: Wed, 11 Sep 2024 10:17:44 +0100 Subject: [PATCH 297/866] Update ConvertCommand.java --- .../main/java/qupath/lib/images/writers/ome/ConvertCommand.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/ConvertCommand.java b/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/ConvertCommand.java index 842544f23..06b2e1f5c 100644 --- a/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/ConvertCommand.java +++ b/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/ConvertCommand.java @@ -353,7 +353,7 @@ private static Range getRange(String rangeText, int maxRange, String rangeLabel) } } - static boolean isValidRange(Range range, int maxRange) { + private static boolean isValidRange(Range range, int maxRange) { return range.start() >= 0 && range.end() <= maxRange && range.start() < range.end(); } From 29181a087754ba93fd8d6fedbb10a44dc5ff4c85 Mon Sep 17 00:00:00 2001 From: Pete Date: Wed, 11 Sep 2024 15:40:31 +0100 Subject: [PATCH 298/866] Attempt to bundle extensions If this work, we can bundle 2 extra extensions with rc1. --- CHANGELOG.md | 9 ++++++++- qupath-app/build.gradle | 2 ++ settings.gradle | 10 ++++++++++ 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6737b5f6f..cd467f296 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,17 @@ This is a *work in progress* for the next major release. +Some things may be added, some things may be removed, and some things may look different before the final release. + *QuPath v0.6.0 requires Java 21 or later* -### Enhancements +### Major features +* Support for InstanSeg segmentation (https://github.com/instanseg/instanseg) * Read and write OME-Zarr images (https://github.com/qupath/qupath/pull/1474) + +### Enhancements +(These are not yet ordered by interestingness) + * Improved display of annotation names (https://github.com/qupath/qupath/pull/1532) * Support log10 counts with histograms (no longer use natural log) (https://github.com/qupath/qupath/pull/1540) * Log counts also now available in measurement tables diff --git a/qupath-app/build.gradle b/qupath-app/build.gradle index 62774e99c..7178a01ba 100644 --- a/qupath-app/build.gradle +++ b/qupath-app/build.gradle @@ -108,6 +108,8 @@ def includedProjects = rootProject.subprojects.findAll { !excludedProjects.conta dependencies { implementation includedProjects implementation libs.picocli + + implementation extraLibs.bundles.extensions } diff --git a/settings.gradle b/settings.gradle index e6ceb27e8..19ceaa700 100644 --- a/settings.gradle +++ b/settings.gradle @@ -34,5 +34,15 @@ dependencyResolutionManagement { version('javafx', javafxOverride as String) } } + + // Extra version catalog for bundled extensions + // This can be useful to make custom QuPath builds with specific versions of extensions + extraLibs { + library('djl', 'io.github.qupath', 'qupath-extension-djl').version('0.4.0-20240911.081508-1') + library('instanseg', 'io.github.qupath', 'qupath-extension-instanseg').version('0.0.1-20240911.143129-1') + +// bundle('extensions', ['djl']) + bundle('extensions', ['djl', 'instanseg']) + } } } From 5a345aeb7e4a5be0eec675313b0c588b89035b08 Mon Sep 17 00:00:00 2001 From: Pete Date: Wed, 11 Sep 2024 16:02:27 +0100 Subject: [PATCH 299/866] Update for rc1 --- qupath-app/src/main/resources/VERSION | 2 +- qupath-core-processing/src/main/resources/VERSION | 2 +- qupath-core/src/main/resources/VERSION | 2 +- qupath-extension-bioformats/src/main/resources/VERSION | 2 +- qupath-extension-openslide/src/main/resources/VERSION | 2 +- qupath-extension-processing/src/main/resources/VERSION | 2 +- qupath-extension-script-editor/src/main/resources/VERSION | 2 +- qupath-extension-svg/src/main/resources/VERSION | 2 +- qupath-gui-fx/src/main/resources/VERSION | 2 +- settings.gradle | 2 +- 10 files changed, 10 insertions(+), 10 deletions(-) diff --git a/qupath-app/src/main/resources/VERSION b/qupath-app/src/main/resources/VERSION index e75e24346..8d1042ece 100644 --- a/qupath-app/src/main/resources/VERSION +++ b/qupath-app/src/main/resources/VERSION @@ -1 +1 @@ -0.6.0-SNAPSHOT \ No newline at end of file +0.6.0-rc1 \ No newline at end of file diff --git a/qupath-core-processing/src/main/resources/VERSION b/qupath-core-processing/src/main/resources/VERSION index e75e24346..8d1042ece 100644 --- a/qupath-core-processing/src/main/resources/VERSION +++ b/qupath-core-processing/src/main/resources/VERSION @@ -1 +1 @@ -0.6.0-SNAPSHOT \ No newline at end of file +0.6.0-rc1 \ No newline at end of file diff --git a/qupath-core/src/main/resources/VERSION b/qupath-core/src/main/resources/VERSION index e75e24346..8d1042ece 100644 --- a/qupath-core/src/main/resources/VERSION +++ b/qupath-core/src/main/resources/VERSION @@ -1 +1 @@ -0.6.0-SNAPSHOT \ No newline at end of file +0.6.0-rc1 \ No newline at end of file diff --git a/qupath-extension-bioformats/src/main/resources/VERSION b/qupath-extension-bioformats/src/main/resources/VERSION index e75e24346..8d1042ece 100644 --- a/qupath-extension-bioformats/src/main/resources/VERSION +++ b/qupath-extension-bioformats/src/main/resources/VERSION @@ -1 +1 @@ -0.6.0-SNAPSHOT \ No newline at end of file +0.6.0-rc1 \ No newline at end of file diff --git a/qupath-extension-openslide/src/main/resources/VERSION b/qupath-extension-openslide/src/main/resources/VERSION index e75e24346..8d1042ece 100644 --- a/qupath-extension-openslide/src/main/resources/VERSION +++ b/qupath-extension-openslide/src/main/resources/VERSION @@ -1 +1 @@ -0.6.0-SNAPSHOT \ No newline at end of file +0.6.0-rc1 \ No newline at end of file diff --git a/qupath-extension-processing/src/main/resources/VERSION b/qupath-extension-processing/src/main/resources/VERSION index e75e24346..8d1042ece 100644 --- a/qupath-extension-processing/src/main/resources/VERSION +++ b/qupath-extension-processing/src/main/resources/VERSION @@ -1 +1 @@ -0.6.0-SNAPSHOT \ No newline at end of file +0.6.0-rc1 \ No newline at end of file diff --git a/qupath-extension-script-editor/src/main/resources/VERSION b/qupath-extension-script-editor/src/main/resources/VERSION index e75e24346..8d1042ece 100644 --- a/qupath-extension-script-editor/src/main/resources/VERSION +++ b/qupath-extension-script-editor/src/main/resources/VERSION @@ -1 +1 @@ -0.6.0-SNAPSHOT \ No newline at end of file +0.6.0-rc1 \ No newline at end of file diff --git a/qupath-extension-svg/src/main/resources/VERSION b/qupath-extension-svg/src/main/resources/VERSION index e75e24346..8d1042ece 100644 --- a/qupath-extension-svg/src/main/resources/VERSION +++ b/qupath-extension-svg/src/main/resources/VERSION @@ -1 +1 @@ -0.6.0-SNAPSHOT \ No newline at end of file +0.6.0-rc1 \ No newline at end of file diff --git a/qupath-gui-fx/src/main/resources/VERSION b/qupath-gui-fx/src/main/resources/VERSION index e75e24346..8d1042ece 100644 --- a/qupath-gui-fx/src/main/resources/VERSION +++ b/qupath-gui-fx/src/main/resources/VERSION @@ -1 +1 @@ -0.6.0-SNAPSHOT \ No newline at end of file +0.6.0-rc1 \ No newline at end of file diff --git a/settings.gradle b/settings.gradle index 19ceaa700..900a38dc7 100644 --- a/settings.gradle +++ b/settings.gradle @@ -2,7 +2,7 @@ plugins { id 'org.gradle.toolchains.foojay-resolver-convention' version '0.7.0' // to download if needed } -gradle.ext.qupathVersion = "0.6.0-SNAPSHOT" +gradle.ext.qupathVersion = "0.6.0-rc1" rootProject.name = 'qupath' From 234573a9a5df10aaa87d9b51d812831138952e80 Mon Sep 17 00:00:00 2001 From: Pete Date: Wed, 11 Sep 2024 18:37:48 +0100 Subject: [PATCH 300/866] Update extensions, exclude transitive --- qupath-app/build.gradle | 5 ++++- settings.gradle | 7 ++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/qupath-app/build.gradle b/qupath-app/build.gradle index 7178a01ba..b1e4a7dbb 100644 --- a/qupath-app/build.gradle +++ b/qupath-app/build.gradle @@ -109,7 +109,10 @@ dependencies { implementation includedProjects implementation libs.picocli - implementation extraLibs.bundles.extensions + implementation(extraLibs.bundles.extensions) { + // We don't want to bring in snapshot versions + exclude group: 'io.github.qupath' + } } diff --git a/settings.gradle b/settings.gradle index 900a38dc7..32b6dee6b 100644 --- a/settings.gradle +++ b/settings.gradle @@ -38,11 +38,12 @@ dependencyResolutionManagement { // Extra version catalog for bundled extensions // This can be useful to make custom QuPath builds with specific versions of extensions extraLibs { - library('djl', 'io.github.qupath', 'qupath-extension-djl').version('0.4.0-20240911.081508-1') - library('instanseg', 'io.github.qupath', 'qupath-extension-instanseg').version('0.0.1-20240911.143129-1') + library('djl', 'io.github.qupath', 'qupath-extension-djl').version('0.4.0-20240911.172830-2') + library('instanseg', 'io.github.qupath', 'qupath-extension-instanseg').version('0.0.1-20240911.172453-2') -// bundle('extensions', ['djl']) +// bundle('extensions', []) bundle('extensions', ['djl', 'instanseg']) } } } + From 06c76780ee5e3bfb4966d7e12f3c3423ac2a4672 Mon Sep 17 00:00:00 2001 From: lleplat Date: Thu, 12 Sep 2024 11:09:38 +0100 Subject: [PATCH 301/866] Delete test images --- .../writers/ome/TestConvertCommand.java | 59 +++++++++++++------ 1 file changed, 42 insertions(+), 17 deletions(-) diff --git a/qupath-extension-bioformats/src/test/java/qupath/lib/images/writers/ome/TestConvertCommand.java b/qupath-extension-bioformats/src/test/java/qupath/lib/images/writers/ome/TestConvertCommand.java index 3e16d4dbd..1d9803f2a 100644 --- a/qupath-extension-bioformats/src/test/java/qupath/lib/images/writers/ome/TestConvertCommand.java +++ b/qupath-extension-bioformats/src/test/java/qupath/lib/images/writers/ome/TestConvertCommand.java @@ -1,5 +1,6 @@ package qupath.lib.images.writers.ome; +import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Nested; @@ -29,6 +30,7 @@ import java.util.Collection; import java.util.List; import java.util.UUID; +import org.apache.commons.io.FileUtils; public class TestConvertCommand { @@ -40,7 +42,7 @@ abstract static class GenericImage { @BeforeAll static void createInputImage() throws Exception { - deleteDir(new File(inputImagePath)); + deleteImage(inputImagePath); try ( ImageServer sampleServer = new SampleImageServer(); @@ -50,6 +52,11 @@ static void createInputImage() throws Exception { } } + @AfterAll + static void deleteInputImage() throws IOException { + deleteImage(inputImagePath); + } + @Test void Check_Image_Not_Cropped() throws Exception { ConvertCommand convertCommand = new ConvertCommand(); @@ -64,6 +71,8 @@ void Check_Image_Not_Cropped() throws Exception { imageWidth = server.getWidth(); } Assertions.assertEquals(expectedWidth, imageWidth); + + deleteImage(outputImagePath); } @Test @@ -80,6 +89,8 @@ void Check_Image_Cropped() throws Exception { imageWidth = server.getWidth(); } Assertions.assertEquals(expectedWidth, imageWidth); + + deleteImage(outputImagePath); } @Test @@ -96,6 +107,8 @@ void Check_Image_Not_Z_Sliced() throws Exception { zSlices = server.nZSlices(); } Assertions.assertEquals(expectedZSlices, zSlices); + + deleteImage(outputImagePath); } @Test @@ -112,6 +125,8 @@ void Check_Image_Z_Sliced() throws Exception { zSlices = server.nZSlices(); } Assertions.assertEquals(expectedZSlices, zSlices); + + deleteImage(outputImagePath); } @Test @@ -128,6 +143,8 @@ void Check_Image_Z_Sliced_By_Range() throws Exception { zSlices = server.nZSlices(); } Assertions.assertEquals(expectedZSlices, zSlices); + + deleteImage(outputImagePath); } @Test @@ -144,6 +161,8 @@ void Check_Image_Not_T_Sliced() throws Exception { timepoints = server.nTimepoints(); } Assertions.assertEquals(expectedTimepoints, timepoints); + + deleteImage(outputImagePath); } @Test @@ -160,6 +179,8 @@ void Check_Image_T_Sliced() throws Exception { timepoints = server.nTimepoints(); } Assertions.assertEquals(expectedTimepoints, timepoints); + + deleteImage(outputImagePath); } @Test @@ -176,6 +197,8 @@ void Check_Image_T_Sliced_By_Range() throws Exception { timepoints = server.nTimepoints(); } Assertions.assertEquals(expectedTimepoints, timepoints); + + deleteImage(outputImagePath); } @Test @@ -192,6 +215,8 @@ void Check_Image_Not_Downsampled() throws Exception { width = server.getWidth(); } Assertions.assertEquals(expectedWidth, width); + + deleteImage(outputImagePath); } @Test @@ -209,6 +234,8 @@ void Check_Image_Downsampled() throws Exception { width = server.getWidth(); } Assertions.assertEquals(expectedWidth, width); + + deleteImage(outputImagePath); } @Test @@ -221,6 +248,20 @@ void Check_Overwritten() throws IOException { int exitCode = cmd.execute(inputImagePath, outputImagePath, "--overwrite"); Assertions.assertEquals(0, exitCode); + + deleteImage(outputImagePath); + } + + private static void deleteImage(String imagePath) throws IOException { + File image = new File(imagePath); + + if (image.exists()) { + if (image.isDirectory()) { + FileUtils.deleteDirectory(image); + } else { + Files.delete(image.toPath()); + } + } } } @@ -336,20 +377,4 @@ private static double getPixel(int x, int y, int channel, int z, int t) { return z + t + channel + ((double) x / IMAGE_WIDTH + (double) y / IMAGE_HEIGHT) / 2; } } - - private static boolean deleteDir(File dir) { - if (dir.isDirectory()) { - String[] children = dir.list(); - - if (children != null) { - for (String child : children) { - boolean success = deleteDir(new File(dir, child)); - if (!success) { - return false; - } - } - } - } - return dir.delete(); - } } From 5a6d061ac9fb8c63dee007a01d78739a4f292f7f Mon Sep 17 00:00:00 2001 From: lleplat Date: Thu, 12 Sep 2024 11:54:50 +0100 Subject: [PATCH 302/866] Delete existing image if overwrite --- .../images/writers/ome/ConvertCommand.java | 15 ++++++++++ .../writers/ome/TestConvertCommand.java | 28 +++++++++---------- 2 files changed, 29 insertions(+), 14 deletions(-) diff --git a/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/ConvertCommand.java b/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/ConvertCommand.java index 06b2e1f5c..cd991ed8c 100644 --- a/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/ConvertCommand.java +++ b/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/ConvertCommand.java @@ -23,11 +23,14 @@ import java.awt.image.BufferedImage; import java.io.File; +import java.io.IOException; +import java.nio.file.Files; import java.util.Arrays; import java.util.Optional; import java.util.regex.Pattern; import java.util.stream.DoubleStream; +import org.apache.commons.io.FileUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -180,6 +183,18 @@ public void run() { logger.error("Input and output files are the same!"); System.exit(-1); } + + try { + if (overwrite && outputFile.exists()) { + if (outputFile.isDirectory()) { + FileUtils.deleteDirectory(outputFile); + } else { + Files.delete(outputFile.toPath()); + } + } + } catch (IOException e) { + logger.error("Error while deleting existing image", e); + } String[] args; if (series >= 0) diff --git a/qupath-extension-bioformats/src/test/java/qupath/lib/images/writers/ome/TestConvertCommand.java b/qupath-extension-bioformats/src/test/java/qupath/lib/images/writers/ome/TestConvertCommand.java index 1d9803f2a..b79a138d1 100644 --- a/qupath-extension-bioformats/src/test/java/qupath/lib/images/writers/ome/TestConvertCommand.java +++ b/qupath-extension-bioformats/src/test/java/qupath/lib/images/writers/ome/TestConvertCommand.java @@ -42,7 +42,7 @@ abstract static class GenericImage { @BeforeAll static void createInputImage() throws Exception { - deleteImage(inputImagePath); + deleteFileOrDirectory(inputImagePath); try ( ImageServer sampleServer = new SampleImageServer(); @@ -54,7 +54,7 @@ static void createInputImage() throws Exception { @AfterAll static void deleteInputImage() throws IOException { - deleteImage(inputImagePath); + deleteFileOrDirectory(inputImagePath); } @Test @@ -72,7 +72,7 @@ void Check_Image_Not_Cropped() throws Exception { } Assertions.assertEquals(expectedWidth, imageWidth); - deleteImage(outputImagePath); + deleteFileOrDirectory(outputImagePath); } @Test @@ -90,7 +90,7 @@ void Check_Image_Cropped() throws Exception { } Assertions.assertEquals(expectedWidth, imageWidth); - deleteImage(outputImagePath); + deleteFileOrDirectory(outputImagePath); } @Test @@ -108,7 +108,7 @@ void Check_Image_Not_Z_Sliced() throws Exception { } Assertions.assertEquals(expectedZSlices, zSlices); - deleteImage(outputImagePath); + deleteFileOrDirectory(outputImagePath); } @Test @@ -126,7 +126,7 @@ void Check_Image_Z_Sliced() throws Exception { } Assertions.assertEquals(expectedZSlices, zSlices); - deleteImage(outputImagePath); + deleteFileOrDirectory(outputImagePath); } @Test @@ -144,7 +144,7 @@ void Check_Image_Z_Sliced_By_Range() throws Exception { } Assertions.assertEquals(expectedZSlices, zSlices); - deleteImage(outputImagePath); + deleteFileOrDirectory(outputImagePath); } @Test @@ -162,7 +162,7 @@ void Check_Image_Not_T_Sliced() throws Exception { } Assertions.assertEquals(expectedTimepoints, timepoints); - deleteImage(outputImagePath); + deleteFileOrDirectory(outputImagePath); } @Test @@ -180,7 +180,7 @@ void Check_Image_T_Sliced() throws Exception { } Assertions.assertEquals(expectedTimepoints, timepoints); - deleteImage(outputImagePath); + deleteFileOrDirectory(outputImagePath); } @Test @@ -198,7 +198,7 @@ void Check_Image_T_Sliced_By_Range() throws Exception { } Assertions.assertEquals(expectedTimepoints, timepoints); - deleteImage(outputImagePath); + deleteFileOrDirectory(outputImagePath); } @Test @@ -216,7 +216,7 @@ void Check_Image_Not_Downsampled() throws Exception { } Assertions.assertEquals(expectedWidth, width); - deleteImage(outputImagePath); + deleteFileOrDirectory(outputImagePath); } @Test @@ -235,7 +235,7 @@ void Check_Image_Downsampled() throws Exception { } Assertions.assertEquals(expectedWidth, width); - deleteImage(outputImagePath); + deleteFileOrDirectory(outputImagePath); } @Test @@ -249,10 +249,10 @@ void Check_Overwritten() throws IOException { Assertions.assertEquals(0, exitCode); - deleteImage(outputImagePath); + deleteFileOrDirectory(outputImagePath); } - private static void deleteImage(String imagePath) throws IOException { + private static void deleteFileOrDirectory(String imagePath) throws IOException { File image = new File(imagePath); if (image.exists()) { From 3a42c3ea9d2f3bbe32acfe43d21ac911f654dd9e Mon Sep 17 00:00:00 2001 From: lleplat Date: Thu, 12 Sep 2024 13:01:36 +0100 Subject: [PATCH 303/866] Started bioformats2raw.layout --- .../ome/zarr/OMEZarrAttributesCreator.java | 3 ++- .../writers/ome/zarr/OMEZarrWriter.java | 22 +++++++++++++++---- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrAttributesCreator.java b/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrAttributesCreator.java index 963167cbc..747b42c96 100644 --- a/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrAttributesCreator.java +++ b/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrAttributesCreator.java @@ -96,7 +96,8 @@ public Map getGroupAttributes() { "defaultZ", 0, "model", "color" ) - ) + ), + "bioformats2raw.layout" , 3 ); } diff --git a/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriter.java b/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriter.java index dcef4e49f..20210d94a 100644 --- a/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriter.java +++ b/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriter.java @@ -19,6 +19,9 @@ import java.awt.image.BufferedImage; import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -89,12 +92,16 @@ private OMEZarrWriter(Builder builder) throws IOException { server.isRGB(), server.getPixelType() ); + + ZarrGroup root = ZarrGroup.create( + builder.path, + attributes.getGroupAttributes() + ); + + createOmeSubGroup(root, builder.path); levelArrays = createLevelArrays( server, - ZarrGroup.create( - builder.path, - attributes.getGroupAttributes() - ), + root, attributes.getLevelAttributes(), builder.compressor ); @@ -379,6 +386,13 @@ private static int getChunkSize(int tileSize, int maxNumberOfChunks, int imageSi ); } + private static void createOmeSubGroup(ZarrGroup root, String imagePath) throws IOException { + String name = "OME"; + root.createSubGroup(name); + + Path omeroMetadataPath = Files.createFile(Paths.get(imagePath, name, "METADATA.ome.xml")); + } + private static Map createLevelArrays( ImageServer server, ZarrGroup root, From 779d913ac0d43a08685028c9d0d3c77a5ef6fb14 Mon Sep 17 00:00:00 2001 From: lleplat Date: Thu, 12 Sep 2024 14:44:29 +0100 Subject: [PATCH 304/866] Added tests for OME zarr writer --- .../writers/ome/zarr/TestOMEZarrWriter.java | 542 ++++++++++++++++++ 1 file changed, 542 insertions(+) create mode 100644 qupath-extension-bioformats/src/test/java/qupath/lib/images/writers/ome/zarr/TestOMEZarrWriter.java diff --git a/qupath-extension-bioformats/src/test/java/qupath/lib/images/writers/ome/zarr/TestOMEZarrWriter.java b/qupath-extension-bioformats/src/test/java/qupath/lib/images/writers/ome/zarr/TestOMEZarrWriter.java new file mode 100644 index 000000000..2e28b677b --- /dev/null +++ b/qupath-extension-bioformats/src/test/java/qupath/lib/images/writers/ome/zarr/TestOMEZarrWriter.java @@ -0,0 +1,542 @@ +package qupath.lib.images.writers.ome.zarr; + +import org.apache.commons.io.FileUtils; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import qupath.lib.color.ColorModelFactory; +import qupath.lib.images.servers.AbstractImageServer; +import qupath.lib.images.servers.ImageChannel; +import qupath.lib.images.servers.ImageServer; +import qupath.lib.images.servers.ImageServerBuilder; +import qupath.lib.images.servers.ImageServerMetadata; +import qupath.lib.images.servers.ImageServerProvider; +import qupath.lib.images.servers.PixelType; +import qupath.lib.regions.ImageRegion; +import qupath.lib.regions.RegionRequest; + +import java.awt.image.BandedSampleModel; +import java.awt.image.BufferedImage; +import java.awt.image.DataBuffer; +import java.awt.image.DataBufferDouble; +import java.awt.image.WritableRaster; +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Collection; +import java.util.List; +import java.util.UUID; + +public class TestOMEZarrWriter { + + @Test + void Check_Error_When_Extension_Incorrect() throws Exception { + String extension = ".wrong.extension"; + Path path = Files.createTempDirectory(UUID.randomUUID().toString()); + String outputImagePath = Paths.get(path.toString(), "image" + extension).toString(); + SampleImageServer sampleImageServer = new SampleImageServer(); + + Assertions.assertThrows( + IllegalArgumentException.class, + () -> new OMEZarrWriter.Builder(sampleImageServer, outputImagePath) + ); + + sampleImageServer.close(); + FileUtils.deleteDirectory(path.toFile()); + } + + @Test + void Check_Full_Image_Pixels() throws Exception { + Path path = Files.createTempDirectory(UUID.randomUUID().toString()); + String outputImagePath = Paths.get(path.toString(), "image.ome.zarr").toString(); + SampleImageServer sampleImageServer = new SampleImageServer(); + int level = 0; + int z = 2; + int t = 1; + BufferedImage expectedImage = sampleImageServer.readRegion( + sampleImageServer.getDownsampleForResolution(level), + 0, + 0, + sampleImageServer.getWidth(), + sampleImageServer.getHeight(), + z, + t + ); + + try (OMEZarrWriter writer = new OMEZarrWriter.Builder(sampleImageServer, outputImagePath).build()) { + writer.writeImage(); + } + + BufferedImage image; + try (ImageServer server = ImageServerProvider.buildServer(outputImagePath, BufferedImage.class)) { + image = server.readRegion( + server.getDownsampleForResolution(level), + 0, + 0, + server.getWidth(), + server.getHeight(), + z, + t + ); + } + assertDoubleBufferedImagesEqual(expectedImage, image); + + sampleImageServer.close(); + FileUtils.deleteDirectory(path.toFile()); + } + + @Test + void Check_Downsampled_Image_Pixels() throws Exception { + Path path = Files.createTempDirectory(UUID.randomUUID().toString()); + String outputImagePath = Paths.get(path.toString(), "image.ome.zarr").toString(); + SampleImageServer sampleImageServer = new SampleImageServer(); + int level = 1; + int z = 2; + int t = 1; + BufferedImage expectedImage = sampleImageServer.readRegion( + sampleImageServer.getDownsampleForResolution(level), + 0, + 0, + sampleImageServer.getWidth(), + sampleImageServer.getHeight(), + z, + t + ); + + try (OMEZarrWriter writer = new OMEZarrWriter.Builder(sampleImageServer, outputImagePath).build()) { + writer.writeImage(); + } + + BufferedImage image; + try (ImageServer server = ImageServerProvider.buildServer(outputImagePath, BufferedImage.class)) { + image = server.readRegion( + server.getDownsampleForResolution(level), + 0, + 0, + server.getWidth(), + server.getHeight(), + z, + t + ); + } + assertDoubleBufferedImagesEqual(expectedImage, image); + + sampleImageServer.close(); + FileUtils.deleteDirectory(path.toFile()); + } + + @Test + void Check_Downsamples_When_Not_Specified() throws Exception { + Path path = Files.createTempDirectory(UUID.randomUUID().toString()); + String outputImagePath = Paths.get(path.toString(), "image.ome.zarr").toString(); + SampleImageServer sampleImageServer = new SampleImageServer(); + double[] expectedDownsamples = sampleImageServer.getPreferredDownsamples(); + + try (OMEZarrWriter writer = new OMEZarrWriter.Builder(sampleImageServer, outputImagePath) + .setDownsamples() + .build() + ) { + writer.writeImage(); + } + + double[] downsamples; + try (ImageServer server = ImageServerProvider.buildServer(outputImagePath, BufferedImage.class)) { + downsamples = server.getPreferredDownsamples(); + } + Assertions.assertArrayEquals(expectedDownsamples, downsamples); + + sampleImageServer.close(); + FileUtils.deleteDirectory(path.toFile()); + } + + @Test + void Check_Downsamples_When_Specified() throws Exception { + Path path = Files.createTempDirectory(UUID.randomUUID().toString()); + String outputImagePath = Paths.get(path.toString(), "image.ome.zarr").toString(); + SampleImageServer sampleImageServer = new SampleImageServer(); + double[] expectedDownsamples = new double[] {1, 2, 4}; + + try (OMEZarrWriter writer = new OMEZarrWriter.Builder(sampleImageServer, outputImagePath) + .setDownsamples(expectedDownsamples) + .build() + ) { + writer.writeImage(); + } + + double[] downsamples; + try (ImageServer server = ImageServerProvider.buildServer(outputImagePath, BufferedImage.class)) { + downsamples = server.getPreferredDownsamples(); + } + Assertions.assertArrayEquals(expectedDownsamples, downsamples); + + sampleImageServer.close(); + FileUtils.deleteDirectory(path.toFile()); + } + + @Test + void Check_Default_Tile_Width() throws Exception { + Path path = Files.createTempDirectory(UUID.randomUUID().toString()); + String outputImagePath = Paths.get(path.toString(), "image.ome.zarr").toString(); + SampleImageServer sampleImageServer = new SampleImageServer(); + int expectedTileWidth = sampleImageServer.getMetadata().getPreferredTileWidth(); + + try (OMEZarrWriter writer = new OMEZarrWriter.Builder(sampleImageServer, outputImagePath) + .setTileWidth(-1) + .build() + ) { + writer.writeImage(); + } + + int tileWidth; + try (ImageServer server = ImageServerProvider.buildServer(outputImagePath, BufferedImage.class)) { + tileWidth = server.getMetadata().getPreferredTileWidth(); + } + Assertions.assertEquals(expectedTileWidth, tileWidth); + + sampleImageServer.close(); + FileUtils.deleteDirectory(path.toFile()); + } + + @Test + void Check_Custom_Tile_Width() throws Exception { + Path path = Files.createTempDirectory(UUID.randomUUID().toString()); + String outputImagePath = Paths.get(path.toString(), "image.ome.zarr").toString(); + SampleImageServer sampleImageServer = new SampleImageServer(); + int expectedTileWidth = 64; + + try (OMEZarrWriter writer = new OMEZarrWriter.Builder(sampleImageServer, outputImagePath) + .setTileWidth(expectedTileWidth) + .build() + ) { + writer.writeImage(); + } + + int tileWidth; + try (ImageServer server = ImageServerProvider.buildServer(outputImagePath, BufferedImage.class)) { + tileWidth = server.getMetadata().getPreferredTileWidth(); + } + Assertions.assertEquals(expectedTileWidth, tileWidth); + + sampleImageServer.close(); + FileUtils.deleteDirectory(path.toFile()); + } + + @Test + void Check_Default_Tile_Height() throws Exception { + Path path = Files.createTempDirectory(UUID.randomUUID().toString()); + String outputImagePath = Paths.get(path.toString(), "image.ome.zarr").toString(); + SampleImageServer sampleImageServer = new SampleImageServer(); + int expectedTileHeight = sampleImageServer.getMetadata().getPreferredTileHeight(); + + try (OMEZarrWriter writer = new OMEZarrWriter.Builder(sampleImageServer, outputImagePath) + .setTileHeight(-1) + .build() + ) { + writer.writeImage(); + } + + int tileHeight; + try (ImageServer server = ImageServerProvider.buildServer(outputImagePath, BufferedImage.class)) { + tileHeight = server.getMetadata().getPreferredTileHeight(); + } + Assertions.assertEquals(expectedTileHeight, tileHeight); + + sampleImageServer.close(); + FileUtils.deleteDirectory(path.toFile()); + } + + @Test + void Check_Custom_Tile_Height() throws Exception { + Path path = Files.createTempDirectory(UUID.randomUUID().toString()); + String outputImagePath = Paths.get(path.toString(), "image.ome.zarr").toString(); + SampleImageServer sampleImageServer = new SampleImageServer(); + int expectedTileHeight = 64; + + try (OMEZarrWriter writer = new OMEZarrWriter.Builder(sampleImageServer, outputImagePath) + .setTileHeight(expectedTileHeight) + .build() + ) { + writer.writeImage(); + } + + int tileHeight; + try (ImageServer server = ImageServerProvider.buildServer(outputImagePath, BufferedImage.class)) { + tileHeight = server.getMetadata().getPreferredTileHeight(); + } + Assertions.assertEquals(expectedTileHeight, tileHeight); + + sampleImageServer.close(); + FileUtils.deleteDirectory(path.toFile()); + } + + @Test + void Check_Bounding_Box() throws Exception { + Path path = Files.createTempDirectory(UUID.randomUUID().toString()); + String outputImagePath = Paths.get(path.toString(), "image.ome.zarr").toString(); + SampleImageServer sampleImageServer = new SampleImageServer(); + int z = 2; + int t = 1; + ImageRegion boundingBox = ImageRegion.createInstance(5, 5, 20, 25, z, t); + BufferedImage expectedImage = sampleImageServer.readRegion(RegionRequest.createInstance(sampleImageServer.getPath(), 1, boundingBox)); + + try (OMEZarrWriter writer = new OMEZarrWriter.Builder(sampleImageServer, outputImagePath) + .setBoundingBox(boundingBox) + .build() + ) { + writer.writeImage(); + } + + BufferedImage image; + try (ImageServer server = ImageServerProvider.buildServer(outputImagePath, BufferedImage.class)) { + image = server.readRegion(1, 0, 0, server.getWidth(), server.getHeight(), z, t); + } + assertDoubleBufferedImagesEqual(expectedImage, image); + + sampleImageServer.close(); + FileUtils.deleteDirectory(path.toFile()); + } + + @Test + void Check_Z_Sliced_Image_Number_Of_Z_Stacks() throws Exception { + Path path = Files.createTempDirectory(UUID.randomUUID().toString()); + String outputImagePath = Paths.get(path.toString(), "image.ome.zarr").toString(); + SampleImageServer sampleImageServer = new SampleImageServer(); + int zStart = 1; + int zEnd = 3; + int expectedNumberOfZStacks = zEnd - zStart; + + try (OMEZarrWriter writer = new OMEZarrWriter.Builder(sampleImageServer, outputImagePath) + .setZSlices(zStart, zEnd) + .build() + ) { + writer.writeImage(); + } + + int numberOfZStacks; + try (ImageServer server = ImageServerProvider.buildServer(outputImagePath, BufferedImage.class)) { + numberOfZStacks = server.nZSlices(); + } + Assertions.assertEquals(expectedNumberOfZStacks, numberOfZStacks); + + sampleImageServer.close(); + FileUtils.deleteDirectory(path.toFile()); + } + + @Test + void Check_Z_Sliced_Image_Pixels() throws Exception { + Path path = Files.createTempDirectory(UUID.randomUUID().toString()); + String outputImagePath = Paths.get(path.toString(), "image.ome.zarr").toString(); + SampleImageServer sampleImageServer = new SampleImageServer(); + int zStart = 1; + int zEnd = 3; + int z = 1; + int t = 1; + BufferedImage expectedImage = sampleImageServer.readRegion(RegionRequest.createInstance( + sampleImageServer.getPath(), + 1, + 0, + 0, + sampleImageServer.getWidth(), + sampleImageServer.getHeight(), + z, + t + )); + + try (OMEZarrWriter writer = new OMEZarrWriter.Builder(sampleImageServer, outputImagePath) + .setZSlices(zStart, zEnd) + .build() + ) { + writer.writeImage(); + } + + BufferedImage image; + try (ImageServer server = ImageServerProvider.buildServer(outputImagePath, BufferedImage.class)) { + image = server.readRegion(1, 0, 0, server.getWidth(), server.getHeight(), z - zStart, t); + } + assertDoubleBufferedImagesEqual(expectedImage, image); + + sampleImageServer.close(); + FileUtils.deleteDirectory(path.toFile()); + } + + @Test + void Check_T_Sliced_Image_Number_Of_Timepoints() throws Exception { + Path path = Files.createTempDirectory(UUID.randomUUID().toString()); + String outputImagePath = Paths.get(path.toString(), "image.ome.zarr").toString(); + SampleImageServer sampleImageServer = new SampleImageServer(); + int tStart = 1; + int tEnd = 2; + int expectedNumberOfTimepoints = tEnd - tStart; + + try (OMEZarrWriter writer = new OMEZarrWriter.Builder(sampleImageServer, outputImagePath) + .setTimepoints(tStart, tEnd) + .build() + ) { + writer.writeImage(); + } + + int numberOfTimepoints; + try (ImageServer server = ImageServerProvider.buildServer(outputImagePath, BufferedImage.class)) { + numberOfTimepoints = server.nTimepoints(); + } + Assertions.assertEquals(expectedNumberOfTimepoints, numberOfTimepoints); + + sampleImageServer.close(); + FileUtils.deleteDirectory(path.toFile()); + } + + @Test + void Check_T_Sliced_Image_Pixels() throws Exception { + Path path = Files.createTempDirectory(UUID.randomUUID().toString()); + String outputImagePath = Paths.get(path.toString(), "image.ome.zarr").toString(); + SampleImageServer sampleImageServer = new SampleImageServer(); + int tStart = 1; + int tEnd = 2; + int z = 1; + int t = 1; + BufferedImage expectedImage = sampleImageServer.readRegion(RegionRequest.createInstance( + sampleImageServer.getPath(), + 1, + 0, + 0, + sampleImageServer.getWidth(), + sampleImageServer.getHeight(), + z, + t + )); + + try (OMEZarrWriter writer = new OMEZarrWriter.Builder(sampleImageServer, outputImagePath) + .setTimepoints(tStart, tEnd) + .build() + ) { + writer.writeImage(); + } + + BufferedImage image; + try (ImageServer server = ImageServerProvider.buildServer(outputImagePath, BufferedImage.class)) { + image = server.readRegion(1, 0, 0, server.getWidth(), server.getHeight(), z, t - tStart); + } + assertDoubleBufferedImagesEqual(expectedImage, image); + + sampleImageServer.close(); + FileUtils.deleteDirectory(path.toFile()); + } + + private static class SampleImageServer extends AbstractImageServer { + + private static final int IMAGE_WIDTH = 64; + private static final int IMAGE_HEIGHT = 64; + + public SampleImageServer() { + super(BufferedImage.class); + } + + @Override + protected ImageServerBuilder.ServerBuilder createServerBuilder() { + return null; + } + + @Override + protected String createID() { + return getClass().getName(); + } + + @Override + public Collection getURIs() { + return List.of(); + } + + @Override + public String getServerType() { + return "Sample server"; + } + + @Override + public ImageServerMetadata getOriginalMetadata() { + return new ImageServerMetadata.Builder() + .width(IMAGE_WIDTH) + .height(IMAGE_HEIGHT) + .sizeZ(3) + .sizeT(2) + .pixelType(PixelType.FLOAT64) + .preferredTileSize(32, 32) + .channels(List.of( + ImageChannel.getInstance("c1", 1), + ImageChannel.getInstance("c2", 2), + ImageChannel.getInstance("c3", 3), + ImageChannel.getInstance("c4", 4), + ImageChannel.getInstance("c5", 5) + )) + .name("name") + .levelsFromDownsamples(1, 2) + .build(); + } + + @Override + public BufferedImage readRegion(RegionRequest request) { + DataBuffer dataBuffer = createDataBuffer(request); + + return new BufferedImage( + ColorModelFactory.createColorModel(getMetadata().getPixelType(), getMetadata().getChannels()), + WritableRaster.createWritableRaster( + new BandedSampleModel( + dataBuffer.getDataType(), + (int) (request.getWidth() / request.getDownsample()), + (int) (request.getHeight() / request.getDownsample()), + nChannels() + ), + dataBuffer, + null + ), + false, + null + ); + } + + private DataBuffer createDataBuffer(RegionRequest request) { + double[][] array = new double[nChannels()][]; + + for (int c = 0; c < array.length; c++) { + array[c] = getPixels(request, c); + } + + return new DataBufferDouble(array, (int) (request.getWidth() * request.getHeight() / 8 / (request.getDownsample() * request.getDownsample()))); + } + + private double[] getPixels(RegionRequest request, int channel) { + int originX = (int) (request.getX() / request.getDownsample()); + int originY = (int) (request.getY() / request.getDownsample()); + int width = (int) (request.getWidth() / request.getDownsample()); + int height = (int) (request.getHeight() / request.getDownsample()); + double[] pixels = new double[width * height]; + + for (int y=0; y Date: Fri, 13 Sep 2024 10:13:38 +0100 Subject: [PATCH 305/866] Back to snapshot version --- qupath-app/src/main/resources/VERSION | 2 +- qupath-core-processing/src/main/resources/VERSION | 2 +- qupath-core/src/main/resources/VERSION | 2 +- qupath-extension-bioformats/src/main/resources/VERSION | 2 +- qupath-extension-openslide/src/main/resources/VERSION | 2 +- qupath-extension-processing/src/main/resources/VERSION | 2 +- qupath-extension-script-editor/src/main/resources/VERSION | 2 +- qupath-extension-svg/src/main/resources/VERSION | 2 +- qupath-gui-fx/src/main/resources/VERSION | 2 +- settings.gradle | 2 +- 10 files changed, 10 insertions(+), 10 deletions(-) diff --git a/qupath-app/src/main/resources/VERSION b/qupath-app/src/main/resources/VERSION index 8d1042ece..e75e24346 100644 --- a/qupath-app/src/main/resources/VERSION +++ b/qupath-app/src/main/resources/VERSION @@ -1 +1 @@ -0.6.0-rc1 \ No newline at end of file +0.6.0-SNAPSHOT \ No newline at end of file diff --git a/qupath-core-processing/src/main/resources/VERSION b/qupath-core-processing/src/main/resources/VERSION index 8d1042ece..e75e24346 100644 --- a/qupath-core-processing/src/main/resources/VERSION +++ b/qupath-core-processing/src/main/resources/VERSION @@ -1 +1 @@ -0.6.0-rc1 \ No newline at end of file +0.6.0-SNAPSHOT \ No newline at end of file diff --git a/qupath-core/src/main/resources/VERSION b/qupath-core/src/main/resources/VERSION index 8d1042ece..e75e24346 100644 --- a/qupath-core/src/main/resources/VERSION +++ b/qupath-core/src/main/resources/VERSION @@ -1 +1 @@ -0.6.0-rc1 \ No newline at end of file +0.6.0-SNAPSHOT \ No newline at end of file diff --git a/qupath-extension-bioformats/src/main/resources/VERSION b/qupath-extension-bioformats/src/main/resources/VERSION index 8d1042ece..e75e24346 100644 --- a/qupath-extension-bioformats/src/main/resources/VERSION +++ b/qupath-extension-bioformats/src/main/resources/VERSION @@ -1 +1 @@ -0.6.0-rc1 \ No newline at end of file +0.6.0-SNAPSHOT \ No newline at end of file diff --git a/qupath-extension-openslide/src/main/resources/VERSION b/qupath-extension-openslide/src/main/resources/VERSION index 8d1042ece..e75e24346 100644 --- a/qupath-extension-openslide/src/main/resources/VERSION +++ b/qupath-extension-openslide/src/main/resources/VERSION @@ -1 +1 @@ -0.6.0-rc1 \ No newline at end of file +0.6.0-SNAPSHOT \ No newline at end of file diff --git a/qupath-extension-processing/src/main/resources/VERSION b/qupath-extension-processing/src/main/resources/VERSION index 8d1042ece..e75e24346 100644 --- a/qupath-extension-processing/src/main/resources/VERSION +++ b/qupath-extension-processing/src/main/resources/VERSION @@ -1 +1 @@ -0.6.0-rc1 \ No newline at end of file +0.6.0-SNAPSHOT \ No newline at end of file diff --git a/qupath-extension-script-editor/src/main/resources/VERSION b/qupath-extension-script-editor/src/main/resources/VERSION index 8d1042ece..e75e24346 100644 --- a/qupath-extension-script-editor/src/main/resources/VERSION +++ b/qupath-extension-script-editor/src/main/resources/VERSION @@ -1 +1 @@ -0.6.0-rc1 \ No newline at end of file +0.6.0-SNAPSHOT \ No newline at end of file diff --git a/qupath-extension-svg/src/main/resources/VERSION b/qupath-extension-svg/src/main/resources/VERSION index 8d1042ece..e75e24346 100644 --- a/qupath-extension-svg/src/main/resources/VERSION +++ b/qupath-extension-svg/src/main/resources/VERSION @@ -1 +1 @@ -0.6.0-rc1 \ No newline at end of file +0.6.0-SNAPSHOT \ No newline at end of file diff --git a/qupath-gui-fx/src/main/resources/VERSION b/qupath-gui-fx/src/main/resources/VERSION index 8d1042ece..e75e24346 100644 --- a/qupath-gui-fx/src/main/resources/VERSION +++ b/qupath-gui-fx/src/main/resources/VERSION @@ -1 +1 @@ -0.6.0-rc1 \ No newline at end of file +0.6.0-SNAPSHOT \ No newline at end of file diff --git a/settings.gradle b/settings.gradle index 32b6dee6b..d0e35f6a1 100644 --- a/settings.gradle +++ b/settings.gradle @@ -2,7 +2,7 @@ plugins { id 'org.gradle.toolchains.foojay-resolver-convention' version '0.7.0' // to download if needed } -gradle.ext.qupathVersion = "0.6.0-rc1" +gradle.ext.qupathVersion = "0.6.0-SNAPSHOT" rootProject.name = 'qupath' From 3ac0530a03d1d1cfa1af85f2cc49b6a960f8f1bc Mon Sep 17 00:00:00 2001 From: Pete Date: Fri, 13 Sep 2024 10:15:12 +0100 Subject: [PATCH 306/866] Support remote ome-zarr First steps to handle remote Zarr --- .../lib/images/servers/bioformats/BioFormatsImageServer.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/qupath-extension-bioformats/src/main/java/qupath/lib/images/servers/bioformats/BioFormatsImageServer.java b/qupath-extension-bioformats/src/main/java/qupath/lib/images/servers/bioformats/BioFormatsImageServer.java index 27bf29b69..eb483ba59 100644 --- a/qupath-extension-bioformats/src/main/java/qupath/lib/images/servers/bioformats/BioFormatsImageServer.java +++ b/qupath-extension-bioformats/src/main/java/qupath/lib/images/servers/bioformats/BioFormatsImageServer.java @@ -1198,9 +1198,12 @@ private IFormatReader createReader(final BioFormatsServerOptions options, final } IFormatReader imageReader; - if (new File(id).isDirectory()) { + if (new File(id).isDirectory() || id.toLowerCase().endsWith(".zarr")) { // Using new ImageReader() on a directory won't work imageReader = new ZarrReader(); + if (id.startsWith("https") && imageReader.getMetadataOptions() instanceof DynamicMetadataOptions zarrOptions) { + zarrOptions.set("omezarr.alt_store", id); + } } else { if (classList != null) { imageReader = new ImageReader(classList); From 4fb2f16d5dfe3d7d82352fbacd6ca0114be5bdc0 Mon Sep 17 00:00:00 2001 From: Pete Date: Fri, 13 Sep 2024 13:18:47 +0100 Subject: [PATCH 307/866] Speed up object masking Addresses https://github.com/qupath/qupath-extension-instanseg/issues/88 It feels awkward to use a static `WeakHashMap` but it seems the easiest way to solve the performance issue without complicating the API with more methods to mask objects (taking `Geometry` or `PreparedGeometry`). With this change, the original problematic image becomes bound by the inference speed rather than the geometry operations - as it should be. --- .../pixels/PixelProcessorUtils.java | 30 +++++++++++++++---- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/qupath-core-processing/src/main/java/qupath/lib/experimental/pixels/PixelProcessorUtils.java b/qupath-core-processing/src/main/java/qupath/lib/experimental/pixels/PixelProcessorUtils.java index 4503fe773..b71c2150f 100644 --- a/qupath-core-processing/src/main/java/qupath/lib/experimental/pixels/PixelProcessorUtils.java +++ b/qupath-core-processing/src/main/java/qupath/lib/experimental/pixels/PixelProcessorUtils.java @@ -22,6 +22,8 @@ package qupath.lib.experimental.pixels; import org.locationtech.jts.geom.Geometry; +import org.locationtech.jts.geom.prep.PreparedGeometry; +import org.locationtech.jts.geom.prep.PreparedGeometryFactory; import qupath.lib.images.servers.ColorTransforms; import qupath.lib.objects.PathObject; import qupath.lib.objects.PathObjectTools; @@ -35,6 +37,8 @@ import java.util.Arrays; import java.util.Collections; import java.util.List; +import java.util.Map; +import java.util.WeakHashMap; import java.util.function.Function; /** @@ -43,6 +47,11 @@ */ public class PixelProcessorUtils { + // Introduced because of https://github.com/qupath/qupath-extension-instanseg/issues/88 + // We could remove it, but then we need a more efficient way to apply ROI masking that + // accepts a Geometry (or PreparedGeometry) as input instead of a ROI. + private static final Map preparedGeometryCache = Collections.synchronizedMap(new WeakHashMap<>()); + /** * Extract the pixels from one channel of an image using the specified transform, and applying any ROI mask. * @param params processor parameters @@ -121,9 +130,9 @@ public static List maskObject(ROI parentROI, PathObject child) { return Collections.singletonList(child); if (!childOnSamePlane(parentROI, child.getROI()) || parentROI.isEmpty()) return Collections.emptyList(); - var geom = parentROI.getGeometry(); + var geom = getPreparedGeometry(parentROI); var childGeom = child.getROI().getGeometry(); - var geomOutput = GeometryTools.homogenizeGeometryCollection(geom.intersection(childGeom)); + var geomOutput = GeometryTools.homogenizeGeometryCollection(computeIntersection(geom, childGeom)); if (geomOutput.isEmpty() || geomOutput.getDimension() < childGeom.getDimension()) return Collections.emptyList(); else if (childGeom.equals(geomOutput)) @@ -135,7 +144,7 @@ else if (childGeom.equals(geomOutput)) var nucleusROI = PathObjectTools.getNucleusROI(child); if (nucleusROI != null) { var nucleusGeom = nucleusROI.getGeometry(); - var nucleusOutput = GeometryTools.homogenizeGeometryCollection(geom.intersection(nucleusGeom)); + var nucleusOutput = GeometryTools.homogenizeGeometryCollection(computeIntersection(geom, nucleusGeom)); if (nucleusOutput.isEmpty() || nucleusOutput.getDimension() < nucleusGeom.getDimension()) nucleusROI = null; else @@ -158,8 +167,8 @@ public static List maskObjectAndSplit(ROI parentROI, PathObject chil Geometry geomOutput; var childGeom = child.getROI().getGeometry(); if (parentROI != null) { - var geom = parentROI.getGeometry(); - geomOutput = GeometryTools.homogenizeGeometryCollection(geom.intersection(childGeom)); + var geom = getPreparedGeometry(parentROI); + geomOutput = GeometryTools.homogenizeGeometryCollection(computeIntersection(geom, childGeom)); } else { geomOutput = GeometryTools.homogenizeGeometryCollection(childGeom); } @@ -173,6 +182,17 @@ else if (childGeom.equals(geomOutput) && childGeom.getNumGeometries() == 1) } } + private static PreparedGeometry getPreparedGeometry(ROI roi) { + return preparedGeometryCache.computeIfAbsent(roi, r -> PreparedGeometryFactory.prepare(r.getGeometry())); + } + + private static Geometry computeIntersection(PreparedGeometry parent, Geometry child) { + if (parent.covers(child)) + return child; + else + return parent.getGeometry().intersection(child); + } + /** * Check whether a child object's centroid is contained within a ROI. * @param roi From 4fc926db91dff649da7b00c1aa9f885866eec301 Mon Sep 17 00:00:00 2001 From: lleplat Date: Fri, 13 Sep 2024 13:48:35 +0100 Subject: [PATCH 308/866] Add OME XML file to zarr writer --- .../writers/ome/zarr/OMEXMLCreator.java | 169 ++++++++++++++++++ .../ome/zarr/OMEZarrAttributesCreator.java | 94 +++------- .../writers/ome/zarr/OMEZarrWriter.java | 40 ++--- .../writers/ome/zarr/TestOMEZarrWriter.java | 16 ++ 4 files changed, 232 insertions(+), 87 deletions(-) create mode 100644 qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEXMLCreator.java diff --git a/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEXMLCreator.java b/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEXMLCreator.java new file mode 100644 index 000000000..6b8801a6c --- /dev/null +++ b/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEXMLCreator.java @@ -0,0 +1,169 @@ +package qupath.lib.images.writers.ome.zarr; + +import ome.units.UNITS; +import ome.units.quantity.Length; +import ome.units.quantity.Time; +import ome.xml.model.Channel; +import ome.xml.model.Image; +import ome.xml.model.OME; +import ome.xml.model.Pixels; +import ome.xml.model.enums.DimensionOrder; +import ome.xml.model.enums.PixelType; +import ome.xml.model.primitives.Color; +import ome.xml.model.primitives.PositiveInteger; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import qupath.lib.common.ColorTools; +import qupath.lib.images.servers.ImageChannel; +import qupath.lib.images.servers.ImageServerMetadata; + +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.transform.TransformerFactory; +import javax.xml.transform.dom.DOMSource; +import javax.xml.transform.stream.StreamResult; +import java.io.ByteArrayOutputStream; +import java.io.OutputStreamWriter; +import java.nio.charset.StandardCharsets; +import java.util.Optional; + +/** + * Create the content of an OME XML file as described by the + * Open Microscopy Environment OME Schema. + */ +class OMEXMLCreator { + + private static final Logger logger = LoggerFactory.getLogger(OMEXMLCreator.class); + private static final String NAMESPACE = "http://www.openmicroscopy.org/Schemas/OME/2016-06"; + + private OMEXMLCreator() { + throw new RuntimeException("This class is not instantiable."); + } + + /** + * Create the content of an OME XML file that corresponds to the June 2016 Open Microscopy Environment OME Schema + * applied to the provided metadata. + * + * @param metadata the metadata of the image + * @return a text representing the provided metadata according the June 2016 Open Microscopy Environment OME Schema, + * or an empty optional if the creation failed + */ + public static Optional create(ImageServerMetadata metadata) { + OME ome = new OME(); + ome.addImage(createImage(metadata)); + + try { + Document document = DocumentBuilderFactory.newInstance().newDocumentBuilder().newDocument(); + Element root = ome.asXMLElement(document); + root.setAttribute("xmlns", NAMESPACE); + document.appendChild(root); + + try (ByteArrayOutputStream os = new ByteArrayOutputStream()) { + TransformerFactory.newInstance().newTransformer().transform( + new DOMSource(document), + new StreamResult(new OutputStreamWriter(os, StandardCharsets.UTF_8)) + ); + return Optional.ofNullable(os.toString()); + } + } catch (Exception e) { + logger.error("Error while creating OME XML content", e); + return Optional.empty(); + } + } + + private static Image createImage(ImageServerMetadata metadata) { + Image image = new Image(); + + image.setID("Image:0"); + image.setPixels(createPixels(metadata)); + + return image; + } + + private static Pixels createPixels(ImageServerMetadata metadata) { + Pixels pixels = new Pixels(); + + pixels.setID("Pixels:0"); + + pixels.setSizeX(new PositiveInteger(metadata.getWidth())); + pixels.setSizeY(new PositiveInteger(metadata.getHeight())); + pixels.setSizeZ(new PositiveInteger(metadata.getSizeZ())); + pixels.setSizeC(new PositiveInteger(metadata.getSizeC())); + pixels.setSizeT(new PositiveInteger(metadata.getSizeT())); + + pixels.setDimensionOrder(DimensionOrder.XYZCT); + + pixels.setType(switch (metadata.getPixelType()) { + case UINT8 -> PixelType.UINT8; + case INT8 -> PixelType.INT8; + case UINT16 -> PixelType.UINT16; + case INT16 -> PixelType.INT16; + case UINT32 -> PixelType.UINT32; + case INT32 -> PixelType.INT32; + case FLOAT32 -> PixelType.FLOAT; + case FLOAT64 -> PixelType.DOUBLE; + }); + + if (!Double.isNaN(metadata.getPixelCalibration().getPixelWidthMicrons())) { + pixels.setPhysicalSizeX(new Length( + metadata.getPixelCalibration().getPixelWidthMicrons(), + UNITS.MICROMETRE + )); + } + if (!Double.isNaN(metadata.getPixelCalibration().getPixelHeightMicrons())) { + pixels.setPhysicalSizeY(new Length( + metadata.getPixelCalibration().getPixelHeightMicrons(), + UNITS.MICROMETRE + )); + } + if (!Double.isNaN(metadata.getPixelCalibration().getZSpacingMicrons())) { + pixels.setPhysicalSizeZ(new Length( + metadata.getPixelCalibration().getZSpacingMicrons(), + UNITS.MICROMETRE + )); + } + if (metadata.getSizeT() > 1 && !Double.isNaN(metadata.getTimepoint(0)) && !Double.isNaN(metadata.getTimepoint(1))) { + pixels.setTimeIncrement(new Time( + Math.abs(metadata.getTimepoint(1) - metadata.getTimepoint(0)), + switch (metadata.getTimeUnit()) { + case NANOSECONDS -> UNITS.NANOSECOND; + case MICROSECONDS -> UNITS.MICROSECOND; + case MILLISECONDS -> UNITS.MILLISECOND; + case SECONDS -> UNITS.SECOND; + case MINUTES -> UNITS.MINUTE; + case HOURS -> UNITS.HOUR; + case DAYS -> UNITS.DAY; + } + )); + } + + for (ImageChannel channel: metadata.getChannels()) { + pixels.addChannel(createChannel(metadata, channel)); + } + + return pixels; + } + + private static Channel createChannel(ImageServerMetadata metadata, ImageChannel imageChannel) { + Channel channel = new Channel(); + + channel.setID("Channel:" + metadata.getChannels().indexOf(imageChannel)); + + channel.setColor(new Color(packRGBA( + ColorTools.red(imageChannel.getColor()), + ColorTools.green(imageChannel.getColor()), + ColorTools.blue(imageChannel.getColor()) + ))); + channel.setName(imageChannel.getName()); + + return channel; + } + + private static int packRGBA(int r, int g, int b) { + return ((r & 0xff)<<24) + + ((g & 0xff)<<16) + + ((b & 0xff)<<8) + + (1 & 0xff); + } +} diff --git a/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrAttributesCreator.java b/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrAttributesCreator.java index 747b42c96..902fb71bb 100644 --- a/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrAttributesCreator.java +++ b/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrAttributesCreator.java @@ -1,15 +1,13 @@ package qupath.lib.images.writers.ome.zarr; import qupath.lib.common.ColorTools; -import qupath.lib.images.servers.ImageChannel; +import qupath.lib.images.servers.ImageServerMetadata; import qupath.lib.images.servers.PixelCalibration; -import qupath.lib.images.servers.PixelType; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.concurrent.TimeUnit; import java.util.stream.IntStream; /** @@ -19,16 +17,7 @@ class OMEZarrAttributesCreator { private static final String VERSION = "0.4"; - private final String imageName; - private final int numberOfZSlices; - private final int numberOfTimePoints; - private final int numberOfChannels; - private final PixelCalibration pixelCalibration; - private final TimeUnit timeUnit; - private final double[] downsamples; - private final List channels; - private final boolean isRGB; - private final PixelType pixelType; + private final ImageServerMetadata metadata; private enum Dimension { X, Y, @@ -40,39 +29,10 @@ private enum Dimension { /** * Create an instance of the attributes' creator. * - * @param imageName the name of the image - * @param numberOfZSlices the number of z-stacks - * @param numberOfTimePoints the number of time points - * @param numberOfChannels the number of channels - * @param pixelCalibration the pixel calibration - * @param timeUnit the unit of the time dimension of the image - * @param downsamples the downsamples of the image - * @param channels the channels of the image - * @param isRGB whether the image stores pixel values with the RGB format - * @param pixelType the type of the pixel values of the image + * @param metadata the metadata of the image */ - public OMEZarrAttributesCreator( - String imageName, - int numberOfZSlices, - int numberOfTimePoints, - int numberOfChannels, - PixelCalibration pixelCalibration, - TimeUnit timeUnit, - double[] downsamples, - List channels, - boolean isRGB, - PixelType pixelType - ) { - this.imageName = imageName; - this.numberOfZSlices = numberOfZSlices; - this.numberOfTimePoints = numberOfTimePoints; - this.numberOfChannels = numberOfChannels; - this.pixelCalibration = pixelCalibration; - this.timeUnit = timeUnit; - this.downsamples = downsamples; - this.channels = channels; - this.isRGB = isRGB; - this.pixelType = pixelType; + public OMEZarrAttributesCreator(ImageServerMetadata metadata) { + this.metadata = metadata; } /** @@ -84,11 +44,11 @@ public Map getGroupAttributes() { "multiscales", List.of(Map.of( "axes", getAxes(), "datasets", getDatasets(), - "name", imageName, + "name", metadata.getName(), "version", VERSION )), "omero", Map.of( - "name", imageName, + "name", metadata.getName(), "version", VERSION, "channels", getChannels(), "rdefs", Map.of( @@ -107,13 +67,13 @@ public Map getGroupAttributes() { */ public Map getLevelAttributes() { List arrayDimensions = new ArrayList<>(); - if (numberOfTimePoints > 1) { + if (metadata.getSizeT() > 1) { arrayDimensions.add("t"); } - if (numberOfChannels > 1) { + if (metadata.getSizeC() > 1) { arrayDimensions.add("c"); } - if (numberOfZSlices > 1) { + if (metadata.getSizeZ() > 1) { arrayDimensions.add("z"); } arrayDimensions.add("y"); @@ -125,13 +85,13 @@ public Map getLevelAttributes() { private List> getAxes() { List> axes = new ArrayList<>(); - if (numberOfTimePoints > 1) { + if (metadata.getSizeT() > 1) { axes.add(getAxis(Dimension.T)); } - if (numberOfChannels > 1) { + if (metadata.getSizeC() > 1) { axes.add(getAxis(Dimension.C)); } - if (numberOfZSlices > 1) { + if (metadata.getSizeZ() > 1) { axes.add(getAxis(Dimension.Z)); } axes.add(getAxis(Dimension.Y)); @@ -141,16 +101,16 @@ private List> getAxes() { } private List> getDatasets() { - return IntStream.range(0, downsamples.length) + return IntStream.range(0, metadata.getPreferredDownsamplesArray().length) .mapToObj(level -> Map.of( "path", "s" + level, - "coordinateTransformations", List.of(getCoordinateTransformation((float) downsamples[level])) + "coordinateTransformations", List.of(getCoordinateTransformation((float) metadata.getPreferredDownsamplesArray()[level])) )) .toList(); } private List> getChannels() { - Object maxValue = isRGB ? Integer.MAX_VALUE : switch (pixelType) { + Object maxValue = metadata.isRGB() ? Integer.MAX_VALUE : switch (metadata.getPixelType()) { case UINT8, INT8 -> Byte.MAX_VALUE; case UINT16, INT16 -> Short.MAX_VALUE; case UINT32, INT32 -> Integer.MAX_VALUE; @@ -158,7 +118,7 @@ private List> getChannels() { case FLOAT64 -> Double.MAX_VALUE; }; - return channels.stream() + return metadata.getChannels().stream() .map(channel -> Map.of( "active", true, "coefficient", 1d, @@ -198,11 +158,11 @@ private Map getAxis(Dimension dimension) { switch (dimension) { case X, Y, Z -> { - if (pixelCalibration.getPixelWidthUnit().equals(PixelCalibration.MICROMETER)) { + if (metadata.getPixelCalibration().getPixelWidthUnit().equals(PixelCalibration.MICROMETER)) { axis.put("unit", "micrometer"); } } - case T -> axis.put("unit", switch (timeUnit) { + case T -> axis.put("unit", switch (metadata.getTimeUnit()) { case NANOSECONDS -> "nanosecond"; case MICROSECONDS -> "microsecond"; case MILLISECONDS -> "millisecond"; @@ -218,21 +178,21 @@ private Map getAxis(Dimension dimension) { private Map getCoordinateTransformation(float downsample) { List scales = new ArrayList<>(); - if (numberOfTimePoints > 1) { - if (!Double.isNaN(pixelCalibration.getTimepoint(0)) && !Double.isNaN(pixelCalibration.getTimepoint(1))) { - scales.add((float) (pixelCalibration.getTimepoint(1) - pixelCalibration.getTimepoint(0))); + if (metadata.getSizeT() > 1) { + if (!Double.isNaN(metadata.getPixelCalibration().getTimepoint(0)) && !Double.isNaN(metadata.getPixelCalibration().getTimepoint(1))) { + scales.add((float) (metadata.getPixelCalibration().getTimepoint(1) - metadata.getPixelCalibration().getTimepoint(0))); } else { scales.add(1F); } } - if (numberOfChannels > 1) { + if (metadata.getSizeC() > 1) { scales.add(1F); } - if (numberOfZSlices > 1) { - scales.add(pixelCalibration.getZSpacing().floatValue()); + if (metadata.getSizeZ() > 1) { + scales.add(metadata.getPixelCalibration().getZSpacing().floatValue()); } - scales.add(pixelCalibration.getPixelHeight().floatValue() * downsample); - scales.add(pixelCalibration.getPixelWidth().floatValue() * downsample); + scales.add(metadata.getPixelCalibration().getPixelHeight().floatValue() * downsample); + scales.add(metadata.getPixelCalibration().getPixelWidth().floatValue() * downsample); return Map.of( "type", "scale", diff --git a/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriter.java b/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriter.java index 20210d94a..c8646c470 100644 --- a/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriter.java +++ b/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriter.java @@ -18,9 +18,10 @@ import qupath.lib.regions.ImageRegion; import java.awt.image.BufferedImage; +import java.io.FileOutputStream; import java.io.IOException; +import java.io.OutputStream; import java.nio.file.Files; -import java.nio.file.Path; import java.nio.file.Paths; import java.util.ArrayList; import java.util.HashMap; @@ -80,25 +81,14 @@ private OMEZarrWriter(Builder builder) throws IOException { } server = transformedServerBuilder.build(); - OMEZarrAttributesCreator attributes = new OMEZarrAttributesCreator( - server.getMetadata().getName(), - server.nZSlices(), - server.nTimepoints(), - server.nChannels(), - server.getMetadata().getPixelCalibration(), - server.getMetadata().getTimeUnit(), - server.getPreferredDownsamples(), - server.getMetadata().getChannels(), - server.isRGB(), - server.getPixelType() - ); + OMEZarrAttributesCreator attributes = new OMEZarrAttributesCreator(server.getMetadata()); ZarrGroup root = ZarrGroup.create( builder.path, attributes.getGroupAttributes() ); - createOmeSubGroup(root, builder.path); + OMEXMLCreator.create(server.getMetadata()).ifPresent(omeXML -> createOmeSubGroup(root, builder.path, omeXML)); levelArrays = createLevelArrays( server, root, @@ -210,13 +200,16 @@ public static class Builder { * Create the builder. * * @param server the image to write - * @param path the path where to write the image. It must end with ".ome.zarr" - * @throws IllegalArgumentException when the provided path doesn't end with ".ome.zarr" + * @param path the path where to write the image. It must end with ".ome.zarr" and shouldn't already exist + * @throws IllegalArgumentException when the provided path doesn't end with ".ome.zarr" or a file/directory already exists at this location */ public Builder(ImageServer server, String path) { if (!path.endsWith(FILE_EXTENSION)) { throw new IllegalArgumentException(String.format("The provided path (%s) does not have the OME-Zarr extension (%s)", path, FILE_EXTENSION)); } + if (Files.exists(Paths.get(path))) { + throw new IllegalArgumentException(String.format("The provided path (%s) already exists", path)); + } this.server = server; this.path = path; @@ -386,11 +379,18 @@ private static int getChunkSize(int tileSize, int maxNumberOfChunks, int imageSi ); } - private static void createOmeSubGroup(ZarrGroup root, String imagePath) throws IOException { - String name = "OME"; - root.createSubGroup(name); + private static void createOmeSubGroup(ZarrGroup mainGroup, String imagePath, String omeXMLContent) { + String fileName = "OME"; + + try { + mainGroup.createSubGroup(fileName); - Path omeroMetadataPath = Files.createFile(Paths.get(imagePath, name, "METADATA.ome.xml")); + try (OutputStream outputStream = new FileOutputStream(Files.createFile(Paths.get(imagePath, fileName, "METADATA.ome.xml")).toString())) { + outputStream.write(omeXMLContent.getBytes()); + } + } catch (IOException e) { + logger.error("Error while creating OME group or metadata XML file", e); + } } private static Map createLevelArrays( diff --git a/qupath-extension-bioformats/src/test/java/qupath/lib/images/writers/ome/zarr/TestOMEZarrWriter.java b/qupath-extension-bioformats/src/test/java/qupath/lib/images/writers/ome/zarr/TestOMEZarrWriter.java index 2e28b677b..366bd0ac0 100644 --- a/qupath-extension-bioformats/src/test/java/qupath/lib/images/writers/ome/zarr/TestOMEZarrWriter.java +++ b/qupath-extension-bioformats/src/test/java/qupath/lib/images/writers/ome/zarr/TestOMEZarrWriter.java @@ -45,6 +45,22 @@ void Check_Error_When_Extension_Incorrect() throws Exception { FileUtils.deleteDirectory(path.toFile()); } + @Test + void Check_Error_When_Path_Already_Exists() throws Exception { + Path path = Files.createTempDirectory(UUID.randomUUID().toString()); + String outputImagePath = Paths.get(path.toString(), "image.ome.zarr").toString(); + Files.createFile(Paths.get(outputImagePath)); + SampleImageServer sampleImageServer = new SampleImageServer(); + + Assertions.assertThrows( + IllegalArgumentException.class, + () -> new OMEZarrWriter.Builder(sampleImageServer, outputImagePath) + ); + + sampleImageServer.close(); + FileUtils.deleteDirectory(path.toFile()); + } + @Test void Check_Full_Image_Pixels() throws Exception { Path path = Files.createTempDirectory(UUID.randomUUID().toString()); From 0973c79f42d556963d2a3c0c1dd9a1403debfb4b Mon Sep 17 00:00:00 2001 From: lleplat Date: Fri, 13 Sep 2024 16:02:21 +0100 Subject: [PATCH 309/866] Add OME XML tests --- .../writers/ome/zarr/OMEZarrWriter.java | 1 + .../writers/ome/zarr/TestOMEXMLCreator.java | 285 ++++++++++++++++++ .../writers/ome/zarr/TestOMEZarrWriter.java | 16 + 3 files changed, 302 insertions(+) create mode 100644 qupath-extension-bioformats/src/test/java/qupath/lib/images/writers/ome/zarr/TestOMEXMLCreator.java diff --git a/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriter.java b/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriter.java index c8646c470..b52a92150 100644 --- a/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriter.java +++ b/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriter.java @@ -35,6 +35,7 @@ *

    * Create an OME-Zarr file writer as described by version 0.4 of the specifications of the * Next-generation file formats (NGFF). + * The transitional "bioformats2raw.layout" and "omero" metadata are also considered. *

    *

    * Use a {@link Builder} to create an instance of this class. diff --git a/qupath-extension-bioformats/src/test/java/qupath/lib/images/writers/ome/zarr/TestOMEXMLCreator.java b/qupath-extension-bioformats/src/test/java/qupath/lib/images/writers/ome/zarr/TestOMEXMLCreator.java new file mode 100644 index 000000000..48d55c217 --- /dev/null +++ b/qupath-extension-bioformats/src/test/java/qupath/lib/images/writers/ome/zarr/TestOMEXMLCreator.java @@ -0,0 +1,285 @@ +package qupath.lib.images.writers.ome.zarr; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.xml.sax.SAXException; +import qupath.lib.common.ColorTools; +import qupath.lib.images.servers.ImageChannel; +import qupath.lib.images.servers.ImageServerMetadata; +import qupath.lib.images.servers.PixelType; + +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.parsers.ParserConfigurationException; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.concurrent.TimeUnit; + +public class TestOMEXMLCreator { + + private static final ImageServerMetadata sampleMetadata = new ImageServerMetadata.Builder() + .width(23) + .height(45) + .rgb(false) + .pixelType(PixelType.FLOAT32) + .levelsFromDownsamples(1, 4) + .sizeZ(4) + .sizeT(6) + .pixelSizeMicrons(2.4, 9.7) + .zSpacingMicrons(6.5) + .timepoints(TimeUnit.MICROSECONDS, 0, 2) + .channels(List.of( + ImageChannel.getInstance("c1", ColorTools.GREEN), + ImageChannel.getInstance("c2", ColorTools.BLUE), + ImageChannel.getInstance("c3", ColorTools.CYAN), + ImageChannel.getInstance("c4", ColorTools.RED) + )) + .name("some name") + .build(); + + @Test + void Check_Namespace() throws ParserConfigurationException, IOException, SAXException { + String expectedNamespace = "http://www.openmicroscopy.org/Schemas/OME/2016-06"; + + String xmlContent = OMEXMLCreator.create(sampleMetadata).orElse(""); + + Assertions.assertEquals( + expectedNamespace, + getRootOfXMLText(xmlContent).getAttribute("xmlns") + ); + } + + @Test + void Check_Image_Element_Exists() throws IOException, ParserConfigurationException, SAXException { + String xmlContent = OMEXMLCreator.create(sampleMetadata).orElse(""); + + Assertions.assertEquals( + 1, + getRootOfXMLText(xmlContent).getElementsByTagName("Image").getLength() + ); + } + + @Test + void Check_Pixels_Element_Exists() throws IOException, ParserConfigurationException, SAXException { + String xmlContent = OMEXMLCreator.create(sampleMetadata).orElse(""); + + Element imageElement = (Element) getRootOfXMLText(xmlContent).getFirstChild(); + Assertions.assertEquals( + 1, + imageElement.getElementsByTagName("Pixels").getLength() + ); + } + + @Test + void Check_Width() throws IOException, ParserConfigurationException, SAXException { + String expectedWidth = String.valueOf(sampleMetadata.getWidth()); + + String xmlContent = OMEXMLCreator.create(sampleMetadata).orElse(""); + + Element imageElement = (Element) getRootOfXMLText(xmlContent).getFirstChild(); + Element pixelsElement = (Element) imageElement.getFirstChild(); + Assertions.assertEquals(expectedWidth, pixelsElement.getAttribute("SizeX")); + } + + @Test + void Check_Height() throws IOException, ParserConfigurationException, SAXException { + String expectedWidth = String.valueOf(sampleMetadata.getHeight()); + + String xmlContent = OMEXMLCreator.create(sampleMetadata).orElse(""); + + Element imageElement = (Element) getRootOfXMLText(xmlContent).getFirstChild(); + Element pixelsElement = (Element) imageElement.getFirstChild(); + Assertions.assertEquals(expectedWidth, pixelsElement.getAttribute("SizeY")); + } + + @Test + void Check_Number_Of_Z_Stacks() throws IOException, ParserConfigurationException, SAXException { + String expectedWidth = String.valueOf(sampleMetadata.getSizeZ()); + + String xmlContent = OMEXMLCreator.create(sampleMetadata).orElse(""); + + Element imageElement = (Element) getRootOfXMLText(xmlContent).getFirstChild(); + Element pixelsElement = (Element) imageElement.getFirstChild(); + Assertions.assertEquals(expectedWidth, pixelsElement.getAttribute("SizeZ")); + } + + @Test + void Check_Number_Of_Channels() throws IOException, ParserConfigurationException, SAXException { + String expectedWidth = String.valueOf(sampleMetadata.getSizeC()); + + String xmlContent = OMEXMLCreator.create(sampleMetadata).orElse(""); + + Element imageElement = (Element) getRootOfXMLText(xmlContent).getFirstChild(); + Element pixelsElement = (Element) imageElement.getFirstChild(); + Assertions.assertEquals(expectedWidth, pixelsElement.getAttribute("SizeC")); + } + + @Test + void Check_Number_Of_Timepoints() throws IOException, ParserConfigurationException, SAXException { + String expectedWidth = String.valueOf(sampleMetadata.getSizeT()); + + String xmlContent = OMEXMLCreator.create(sampleMetadata).orElse(""); + + Element imageElement = (Element) getRootOfXMLText(xmlContent).getFirstChild(); + Element pixelsElement = (Element) imageElement.getFirstChild(); + Assertions.assertEquals(expectedWidth, pixelsElement.getAttribute("SizeT")); + } + + @Test + void Check_Pixel_Type() throws IOException, ParserConfigurationException, SAXException { + String expectedPixelType = "float"; + + String xmlContent = OMEXMLCreator.create(sampleMetadata).orElse(""); + + Element imageElement = (Element) getRootOfXMLText(xmlContent).getFirstChild(); + Element pixelsElement = (Element) imageElement.getFirstChild(); + Assertions.assertEquals(expectedPixelType, pixelsElement.getAttribute("Type")); + } + + @Test + void Check_Pixel_Width_Value() throws IOException, ParserConfigurationException, SAXException { + String expectedValue = String.valueOf(sampleMetadata.getPixelCalibration().getPixelWidthMicrons()); + + String xmlContent = OMEXMLCreator.create(sampleMetadata).orElse(""); + + Element imageElement = (Element) getRootOfXMLText(xmlContent).getFirstChild(); + Element pixelsElement = (Element) imageElement.getFirstChild(); + Assertions.assertEquals(expectedValue, pixelsElement.getAttribute("PhysicalSizeX")); + } + + @Test + void Check_Pixel_Width_Unit() throws IOException, ParserConfigurationException, SAXException { + String expectedUnit = String.valueOf(sampleMetadata.getPixelCalibration().getPixelHeightUnit()); + + String xmlContent = OMEXMLCreator.create(sampleMetadata).orElse(""); + + Element imageElement = (Element) getRootOfXMLText(xmlContent).getFirstChild(); + Element pixelsElement = (Element) imageElement.getFirstChild(); + Assertions.assertEquals(expectedUnit, pixelsElement.getAttribute("PhysicalSizeXUnit")); + } + + @Test + void Check_Pixel_Height_Value() throws IOException, ParserConfigurationException, SAXException { + String expectedValue = String.valueOf(sampleMetadata.getPixelCalibration().getPixelHeightMicrons()); + + String xmlContent = OMEXMLCreator.create(sampleMetadata).orElse(""); + + Element imageElement = (Element) getRootOfXMLText(xmlContent).getFirstChild(); + Element pixelsElement = (Element) imageElement.getFirstChild(); + Assertions.assertEquals(expectedValue, pixelsElement.getAttribute("PhysicalSizeY")); + } + + @Test + void Check_Pixel_Height_Unit() throws IOException, ParserConfigurationException, SAXException { + String expectedUnit = String.valueOf(sampleMetadata.getPixelCalibration().getPixelHeightUnit()); + + String xmlContent = OMEXMLCreator.create(sampleMetadata).orElse(""); + + Element imageElement = (Element) getRootOfXMLText(xmlContent).getFirstChild(); + Element pixelsElement = (Element) imageElement.getFirstChild(); + Assertions.assertEquals(expectedUnit, pixelsElement.getAttribute("PhysicalSizeYUnit")); + } + + @Test + void Check_Pixel_Z_Spacing_Value() throws IOException, ParserConfigurationException, SAXException { + String expectedValue = String.valueOf(sampleMetadata.getPixelCalibration().getZSpacingMicrons()); + + String xmlContent = OMEXMLCreator.create(sampleMetadata).orElse(""); + + Element imageElement = (Element) getRootOfXMLText(xmlContent).getFirstChild(); + Element pixelsElement = (Element) imageElement.getFirstChild(); + Assertions.assertEquals(expectedValue, pixelsElement.getAttribute("PhysicalSizeZ")); + } + + @Test + void Check_Pixel_Z_Spacing_Unit() throws IOException, ParserConfigurationException, SAXException { + String expectedUnit = String.valueOf(sampleMetadata.getPixelCalibration().getZSpacingUnit()); + + String xmlContent = OMEXMLCreator.create(sampleMetadata).orElse(""); + + Element imageElement = (Element) getRootOfXMLText(xmlContent).getFirstChild(); + Element pixelsElement = (Element) imageElement.getFirstChild(); + Assertions.assertEquals(expectedUnit, pixelsElement.getAttribute("PhysicalSizeZUnit")); + } + + @Test + void Check_Pixel_T_Spacing_Value() throws IOException, ParserConfigurationException, SAXException { + String expectedValue = String.valueOf( + sampleMetadata.getPixelCalibration().getTimepoint(1) - sampleMetadata.getPixelCalibration().getTimepoint(0) + ); + + String xmlContent = OMEXMLCreator.create(sampleMetadata).orElse(""); + + Element imageElement = (Element) getRootOfXMLText(xmlContent).getFirstChild(); + Element pixelsElement = (Element) imageElement.getFirstChild(); + Assertions.assertEquals(expectedValue, pixelsElement.getAttribute("TimeIncrement")); + } + + @Test + void Check_Pixel_T_Spacing_Unit() throws IOException, ParserConfigurationException, SAXException { + String expectedUnit = "µs"; + + String xmlContent = OMEXMLCreator.create(sampleMetadata).orElse(""); + + Element imageElement = (Element) getRootOfXMLText(xmlContent).getFirstChild(); + Element pixelsElement = (Element) imageElement.getFirstChild(); + Assertions.assertEquals(expectedUnit, pixelsElement.getAttribute("TimeIncrementUnit")); + } + + @Test + void Check_Channels_Element_Exist() throws IOException, ParserConfigurationException, SAXException { + int expectedNumberOfChannels = sampleMetadata.getSizeC(); + + String xmlContent = OMEXMLCreator.create(sampleMetadata).orElse(""); + + Element imageElement = (Element) getRootOfXMLText(xmlContent).getFirstChild(); + Element pixelsElement = (Element) imageElement.getFirstChild(); + Assertions.assertEquals( + expectedNumberOfChannels, + pixelsElement.getElementsByTagName("Channel").getLength() + ); + } + + @Test + void Check_Channel_Name() throws IOException, ParserConfigurationException, SAXException { + int channelIndex = 2; + String expectedName = sampleMetadata.getChannel(channelIndex).getName(); + + String xmlContent = OMEXMLCreator.create(sampleMetadata).orElse(""); + + Element imageElement = (Element) getRootOfXMLText(xmlContent).getFirstChild(); + Element pixelsElement = (Element) imageElement.getFirstChild(); + Element channelElement = (Element) pixelsElement.getChildNodes().item(channelIndex); + Assertions.assertEquals(expectedName, channelElement.getAttribute("Name")); + } + + @Test + void Check_Channel_Color() throws IOException, ParserConfigurationException, SAXException { + int channelIndex = 3; + int expectedColorRGB = sampleMetadata.getChannel(channelIndex).getColor(); + + String xmlContent = OMEXMLCreator.create(sampleMetadata).orElse(""); + + Element imageElement = (Element) getRootOfXMLText(xmlContent).getFirstChild(); + Element pixelsElement = (Element) imageElement.getFirstChild(); + Element channelElement = (Element) pixelsElement.getChildNodes().item(channelIndex); + int colorRGBA = Integer.parseInt(channelElement.getAttribute("Color")); + int colorRGB = ColorTools.packRGB( + (colorRGBA >> 24) & 0xff, + (colorRGBA >> 16) & 0xff, + (colorRGBA >> 8) & 0xff + ); + Assertions.assertEquals(expectedColorRGB, colorRGB); + } + + private Element getRootOfXMLText(String xmlText) throws IOException, ParserConfigurationException, SAXException { + Document document; + try (ByteArrayInputStream input = new ByteArrayInputStream(xmlText.getBytes(StandardCharsets.UTF_8))) { + document = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(input); + } + return document.getDocumentElement(); + } +} diff --git a/qupath-extension-bioformats/src/test/java/qupath/lib/images/writers/ome/zarr/TestOMEZarrWriter.java b/qupath-extension-bioformats/src/test/java/qupath/lib/images/writers/ome/zarr/TestOMEZarrWriter.java index 366bd0ac0..2e0600684 100644 --- a/qupath-extension-bioformats/src/test/java/qupath/lib/images/writers/ome/zarr/TestOMEZarrWriter.java +++ b/qupath-extension-bioformats/src/test/java/qupath/lib/images/writers/ome/zarr/TestOMEZarrWriter.java @@ -61,6 +61,22 @@ void Check_Error_When_Path_Already_Exists() throws Exception { FileUtils.deleteDirectory(path.toFile()); } + @Test + void Check_OME_XML_File_Exists() throws Exception { + Path path = Files.createTempDirectory(UUID.randomUUID().toString()); + String outputImagePath = Paths.get(path.toString(), "image.ome.zarr").toString(); + SampleImageServer sampleImageServer = new SampleImageServer(); + + try (OMEZarrWriter writer = new OMEZarrWriter.Builder(sampleImageServer, outputImagePath).build()) { + writer.writeImage(); + } + + Assertions.assertTrue(Files.exists(Paths.get(outputImagePath, "OME", "METADATA.ome.xml"))); + + sampleImageServer.close(); + FileUtils.deleteDirectory(path.toFile()); + } + @Test void Check_Full_Image_Pixels() throws Exception { Path path = Files.createTempDirectory(UUID.randomUUID().toString()); From 66bbdab29e6b88f8407583817355d1ff8a6fec93 Mon Sep 17 00:00:00 2001 From: lleplat Date: Mon, 16 Sep 2024 13:44:50 +0100 Subject: [PATCH 310/866] Add OME Zarr attributes tests --- .../zarr/TestOMEZarrAttributesCreator.java | 886 ++++++++++++++++++ 1 file changed, 886 insertions(+) create mode 100644 qupath-extension-bioformats/src/test/java/qupath/lib/images/writers/ome/zarr/TestOMEZarrAttributesCreator.java diff --git a/qupath-extension-bioformats/src/test/java/qupath/lib/images/writers/ome/zarr/TestOMEZarrAttributesCreator.java b/qupath-extension-bioformats/src/test/java/qupath/lib/images/writers/ome/zarr/TestOMEZarrAttributesCreator.java new file mode 100644 index 000000000..798b1e9db --- /dev/null +++ b/qupath-extension-bioformats/src/test/java/qupath/lib/images/writers/ome/zarr/TestOMEZarrAttributesCreator.java @@ -0,0 +1,886 @@ +package qupath.lib.images.writers.ome.zarr; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import qupath.lib.common.ColorTools; +import qupath.lib.images.servers.ImageChannel; +import qupath.lib.images.servers.ImageServerMetadata; +import qupath.lib.images.servers.PixelType; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +public class TestOMEZarrAttributesCreator { + + private static final ImageServerMetadata sampleMetadata = new ImageServerMetadata.Builder() + .width(23) + .height(45) + .rgb(false) + .pixelType(PixelType.FLOAT32) + .levelsFromDownsamples(1, 4) + .sizeZ(4) + .sizeT(6) + .pixelSizeMicrons(2.4, 9.7) + .zSpacingMicrons(6.5) + .timepoints(TimeUnit.MICROSECONDS, 0, 2) + .channels(List.of( + ImageChannel.getInstance("c1", ColorTools.GREEN), + ImageChannel.getInstance("c2", ColorTools.BLUE), + ImageChannel.getInstance("c3", ColorTools.CYAN), + ImageChannel.getInstance("c4", ColorTools.RED) + )) + .name("some name") + .build(); + + @Test + void Check_Group_Attribute_Has_Multiscales_Array() { + String label = "multiscales"; + + Map groupAttributes = new OMEZarrAttributesCreator(sampleMetadata).getGroupAttributes(); + + JsonObject root = new Gson().toJsonTree(groupAttributes).getAsJsonObject(); + JsonElement multiscalesElement = root.get(label); + Assertions.assertTrue(multiscalesElement.isJsonArray()); + } + + @Test + void Check_Multiscale_Array_Has_Axes_Array() { + String label = "axes"; + + Map groupAttributes = new OMEZarrAttributesCreator(sampleMetadata).getGroupAttributes(); + + JsonObject root = new Gson().toJsonTree(groupAttributes).getAsJsonObject(); + JsonArray multiscale = root.getAsJsonArray("multiscales"); + JsonElement axesElement = multiscale.get(0).getAsJsonObject().get(label); + Assertions.assertTrue(axesElement.isJsonArray()); + } + + @Test + void Check_Multiscale_Array_Time_Axe_Unit() { + String expectedUnit = "microsecond"; + int axeIndex = 0; + String label = "unit"; + + Map groupAttributes = new OMEZarrAttributesCreator(sampleMetadata).getGroupAttributes(); + + JsonObject root = new Gson().toJsonTree(groupAttributes).getAsJsonObject(); + JsonArray multiscale = root.getAsJsonArray("multiscales"); + JsonArray axes = multiscale.get(0).getAsJsonObject().get("axes").getAsJsonArray(); + String unit = axes.get(axeIndex).getAsJsonObject().get(label).getAsString(); + Assertions.assertEquals(expectedUnit, unit); + } + + @Test + void Check_Multiscale_Array_Time_Axe_Name() { + String expectedName = "t"; + int axeIndex = 0; + String label = "name"; + + Map groupAttributes = new OMEZarrAttributesCreator(sampleMetadata).getGroupAttributes(); + + JsonObject root = new Gson().toJsonTree(groupAttributes).getAsJsonObject(); + JsonArray multiscale = root.getAsJsonArray("multiscales"); + JsonArray axes = multiscale.get(0).getAsJsonObject().get("axes").getAsJsonArray(); + String name = axes.get(axeIndex).getAsJsonObject().get(label).getAsString(); + Assertions.assertEquals(expectedName, name); + } + + @Test + void Check_Multiscale_Array_Time_Axe_Type() { + String expectedType = "time"; + int axeIndex = 0; + String label = "type"; + + Map groupAttributes = new OMEZarrAttributesCreator(sampleMetadata).getGroupAttributes(); + + JsonObject root = new Gson().toJsonTree(groupAttributes).getAsJsonObject(); + JsonArray multiscale = root.getAsJsonArray("multiscales"); + JsonArray axes = multiscale.get(0).getAsJsonObject().get("axes").getAsJsonArray(); + String type = axes.get(axeIndex).getAsJsonObject().get(label).getAsString(); + Assertions.assertEquals(expectedType, type); + } + + @Test + void Check_Multiscale_Array_Channel_Axe_Name() { + String expectedName = "c"; + int axeIndex = 1; + String label = "name"; + + Map groupAttributes = new OMEZarrAttributesCreator(sampleMetadata).getGroupAttributes(); + + JsonObject root = new Gson().toJsonTree(groupAttributes).getAsJsonObject(); + JsonArray multiscale = root.getAsJsonArray("multiscales"); + JsonArray axes = multiscale.get(0).getAsJsonObject().get("axes").getAsJsonArray(); + String name = axes.get(axeIndex).getAsJsonObject().get(label).getAsString(); + Assertions.assertEquals(expectedName, name); + } + + @Test + void Check_Multiscale_Array_Channel_Axe_Type() { + String expectedType = "channel"; + int axeIndex = 1; + String label = "type"; + + Map groupAttributes = new OMEZarrAttributesCreator(sampleMetadata).getGroupAttributes(); + + JsonObject root = new Gson().toJsonTree(groupAttributes).getAsJsonObject(); + JsonArray multiscale = root.getAsJsonArray("multiscales"); + JsonArray axes = multiscale.get(0).getAsJsonObject().get("axes").getAsJsonArray(); + String type = axes.get(axeIndex).getAsJsonObject().get(label).getAsString(); + Assertions.assertEquals(expectedType, type); + } + + @Test + void Check_Multiscale_Array_Z_Axe_Unit() { + String expectedUnit = "micrometer"; + int axeIndex = 2; + String label = "unit"; + + Map groupAttributes = new OMEZarrAttributesCreator(sampleMetadata).getGroupAttributes(); + + JsonObject root = new Gson().toJsonTree(groupAttributes).getAsJsonObject(); + JsonArray multiscale = root.getAsJsonArray("multiscales"); + JsonArray axes = multiscale.get(0).getAsJsonObject().get("axes").getAsJsonArray(); + String unit = axes.get(axeIndex).getAsJsonObject().get(label).getAsString(); + Assertions.assertEquals(expectedUnit, unit); + } + + @Test + void Check_Multiscale_Array_Z_Axe_Name() { + String expectedName = "z"; + int axeIndex = 2; + String label = "name"; + + Map groupAttributes = new OMEZarrAttributesCreator(sampleMetadata).getGroupAttributes(); + + JsonObject root = new Gson().toJsonTree(groupAttributes).getAsJsonObject(); + JsonArray multiscale = root.getAsJsonArray("multiscales"); + JsonArray axes = multiscale.get(0).getAsJsonObject().get("axes").getAsJsonArray(); + String name = axes.get(axeIndex).getAsJsonObject().get(label).getAsString(); + Assertions.assertEquals(expectedName, name); + } + + @Test + void Check_Multiscale_Array_Z_Axe_Type() { + String expectedType = "space"; + int axeIndex = 2; + String label = "type"; + + Map groupAttributes = new OMEZarrAttributesCreator(sampleMetadata).getGroupAttributes(); + + JsonObject root = new Gson().toJsonTree(groupAttributes).getAsJsonObject(); + JsonArray multiscale = root.getAsJsonArray("multiscales"); + JsonArray axes = multiscale.get(0).getAsJsonObject().get("axes").getAsJsonArray(); + String type = axes.get(axeIndex).getAsJsonObject().get(label).getAsString(); + Assertions.assertEquals(expectedType, type); + } + + @Test + void Check_Multiscale_Array_Y_Axe_Unit() { + String expectedUnit = "micrometer"; + int axeIndex = 3; + String label = "unit"; + + Map groupAttributes = new OMEZarrAttributesCreator(sampleMetadata).getGroupAttributes(); + + JsonObject root = new Gson().toJsonTree(groupAttributes).getAsJsonObject(); + JsonArray multiscale = root.getAsJsonArray("multiscales"); + JsonArray axes = multiscale.get(0).getAsJsonObject().get("axes").getAsJsonArray(); + String unit = axes.get(axeIndex).getAsJsonObject().get(label).getAsString(); + Assertions.assertEquals(expectedUnit, unit); + } + + @Test + void Check_Multiscale_Array_Y_Axe_Name() { + String expectedName = "y"; + int axeIndex = 3; + String label = "name"; + + Map groupAttributes = new OMEZarrAttributesCreator(sampleMetadata).getGroupAttributes(); + + JsonObject root = new Gson().toJsonTree(groupAttributes).getAsJsonObject(); + JsonArray multiscale = root.getAsJsonArray("multiscales"); + JsonArray axes = multiscale.get(0).getAsJsonObject().get("axes").getAsJsonArray(); + String name = axes.get(axeIndex).getAsJsonObject().get(label).getAsString(); + Assertions.assertEquals(expectedName, name); + } + + @Test + void Check_Multiscale_Array_Y_Axe_Type() { + String expectedType = "space"; + int axeIndex = 3; + String label = "type"; + + Map groupAttributes = new OMEZarrAttributesCreator(sampleMetadata).getGroupAttributes(); + + JsonObject root = new Gson().toJsonTree(groupAttributes).getAsJsonObject(); + JsonArray multiscale = root.getAsJsonArray("multiscales"); + JsonArray axes = multiscale.get(0).getAsJsonObject().get("axes").getAsJsonArray(); + String type = axes.get(axeIndex).getAsJsonObject().get(label).getAsString(); + Assertions.assertEquals(expectedType, type); + } + + @Test + void Check_Multiscale_Array_X_Axe_Unit() { + String expectedUnit = "micrometer"; + int axeIndex = 4; + String label = "unit"; + + Map groupAttributes = new OMEZarrAttributesCreator(sampleMetadata).getGroupAttributes(); + + JsonObject root = new Gson().toJsonTree(groupAttributes).getAsJsonObject(); + JsonArray multiscale = root.getAsJsonArray("multiscales"); + JsonArray axes = multiscale.get(0).getAsJsonObject().get("axes").getAsJsonArray(); + String unit = axes.get(axeIndex).getAsJsonObject().get(label).getAsString(); + Assertions.assertEquals(expectedUnit, unit); + } + + @Test + void Check_Multiscale_Array_X_Axe_Name() { + String expectedName = "x"; + int axeIndex = 4; + String label = "name"; + + Map groupAttributes = new OMEZarrAttributesCreator(sampleMetadata).getGroupAttributes(); + + JsonObject root = new Gson().toJsonTree(groupAttributes).getAsJsonObject(); + JsonArray multiscale = root.getAsJsonArray("multiscales"); + JsonArray axes = multiscale.get(0).getAsJsonObject().get("axes").getAsJsonArray(); + String name = axes.get(axeIndex).getAsJsonObject().get(label).getAsString(); + Assertions.assertEquals(expectedName, name); + } + + @Test + void Check_Multiscale_Array_X_Axe_Type() { + String expectedType = "space"; + int axeIndex = 4; + String label = "type"; + + Map groupAttributes = new OMEZarrAttributesCreator(sampleMetadata).getGroupAttributes(); + + JsonObject root = new Gson().toJsonTree(groupAttributes).getAsJsonObject(); + JsonArray multiscale = root.getAsJsonArray("multiscales"); + JsonArray axes = multiscale.get(0).getAsJsonObject().get("axes").getAsJsonArray(); + String type = axes.get(axeIndex).getAsJsonObject().get(label).getAsString(); + Assertions.assertEquals(expectedType, type); + } + + @Test + void Check_Multiscale_Array_Has_Datasets_Array() { + String label = "datasets"; + + Map groupAttributes = new OMEZarrAttributesCreator(sampleMetadata).getGroupAttributes(); + + JsonObject root = new Gson().toJsonTree(groupAttributes).getAsJsonObject(); + JsonArray multiscale = root.getAsJsonArray("multiscales"); + JsonElement datasetsElement = multiscale.get(0).getAsJsonObject().get(label); + Assertions.assertTrue(datasetsElement.isJsonArray()); + } + + @Test + void Check_Multiscale_Array_Number_Of_Datasets() { + int expectedNumberOfDatasets = sampleMetadata.nLevels(); + + Map groupAttributes = new OMEZarrAttributesCreator(sampleMetadata).getGroupAttributes(); + + JsonObject root = new Gson().toJsonTree(groupAttributes).getAsJsonObject(); + JsonArray multiscale = root.getAsJsonArray("multiscales"); + JsonArray datasetsElement = multiscale.get(0).getAsJsonObject().get("datasets").getAsJsonArray(); + int numberOfDatasets = datasetsElement.size(); + Assertions.assertEquals(expectedNumberOfDatasets, numberOfDatasets); + } + + @Test + void Check_Multiscale_Array_Full_Image_Datasets_Has_Path() { + String label = "path"; + int level = 0; + + Map groupAttributes = new OMEZarrAttributesCreator(sampleMetadata).getGroupAttributes(); + + JsonObject root = new Gson().toJsonTree(groupAttributes).getAsJsonObject(); + JsonArray multiscale = root.getAsJsonArray("multiscales"); + JsonArray datasetsElement = multiscale.get(0).getAsJsonObject().get("datasets").getAsJsonArray(); + JsonObject levelDatasetsElement = datasetsElement.get(level).getAsJsonObject(); + Assertions.assertTrue(levelDatasetsElement.has(label)); + } + + @Test + void Check_Multiscale_Array_Full_Image_Datasets_Path() { + String label = "path"; + int level = 0; + String expectedPath = "s0"; + + Map groupAttributes = new OMEZarrAttributesCreator(sampleMetadata).getGroupAttributes(); + + JsonObject root = new Gson().toJsonTree(groupAttributes).getAsJsonObject(); + JsonArray multiscale = root.getAsJsonArray("multiscales"); + JsonArray datasetsElement = multiscale.get(0).getAsJsonObject().get("datasets").getAsJsonArray(); + JsonObject levelDatasetsElement = datasetsElement.get(level).getAsJsonObject(); + String path = levelDatasetsElement.get(label).getAsString(); + Assertions.assertEquals(expectedPath, path); + } + + @Test + void Check_Multiscale_Array_Full_Image_Datasets_Has_Coordinate_Transformations_Json_Array() { + String label = "coordinateTransformations"; + int level = 0; + + Map groupAttributes = new OMEZarrAttributesCreator(sampleMetadata).getGroupAttributes(); + + JsonObject root = new Gson().toJsonTree(groupAttributes).getAsJsonObject(); + JsonArray multiscale = root.getAsJsonArray("multiscales"); + JsonArray datasetsElement = multiscale.get(0).getAsJsonObject().get("datasets").getAsJsonArray(); + JsonObject levelDatasetsElement = datasetsElement.get(level).getAsJsonObject(); + Assertions.assertTrue(levelDatasetsElement.get(label).isJsonArray()); + } + + @Test + void Check_Multiscale_Array_Full_Image_Datasets_Coordinate_Transformations_Has_Type() { + String label = "type"; + int level = 0; + + Map groupAttributes = new OMEZarrAttributesCreator(sampleMetadata).getGroupAttributes(); + + JsonObject root = new Gson().toJsonTree(groupAttributes).getAsJsonObject(); + JsonArray multiscale = root.getAsJsonArray("multiscales"); + JsonArray datasetsElement = multiscale.get(0).getAsJsonObject().get("datasets").getAsJsonArray(); + JsonObject levelDatasetsElement = datasetsElement.get(level).getAsJsonObject(); + JsonArray coordinateTransformationsElement = levelDatasetsElement.get("coordinateTransformations").getAsJsonArray(); + Assertions.assertTrue(coordinateTransformationsElement.get(0).getAsJsonObject().has(label)); + } + + @Test + void Check_Multiscale_Array_Full_Image_Datasets_Coordinate_Transformations_Type() { + String label = "type"; + int level = 0; + String expectedType = "scale"; + + Map groupAttributes = new OMEZarrAttributesCreator(sampleMetadata).getGroupAttributes(); + + JsonObject root = new Gson().toJsonTree(groupAttributes).getAsJsonObject(); + JsonArray multiscale = root.getAsJsonArray("multiscales"); + JsonArray datasetsElement = multiscale.get(0).getAsJsonObject().get("datasets").getAsJsonArray(); + JsonObject levelDatasetsElement = datasetsElement.get(level).getAsJsonObject(); + JsonArray coordinateTransformationsElement = levelDatasetsElement.get("coordinateTransformations").getAsJsonArray(); + String type = coordinateTransformationsElement.get(0).getAsJsonObject().get(label).getAsString(); + Assertions.assertEquals(expectedType, type); + } + + @Test + void Check_Multiscale_Array_Full_Image_Datasets_Coordinate_Transformations_Has_Scale() { + String label = "scale"; + int level = 0; + + Map groupAttributes = new OMEZarrAttributesCreator(sampleMetadata).getGroupAttributes(); + + JsonObject root = new Gson().toJsonTree(groupAttributes).getAsJsonObject(); + JsonArray multiscale = root.getAsJsonArray("multiscales"); + JsonArray datasetsElement = multiscale.get(0).getAsJsonObject().get("datasets").getAsJsonArray(); + JsonObject levelDatasetsElement = datasetsElement.get(level).getAsJsonObject(); + JsonArray coordinateTransformationsElement = levelDatasetsElement.get("coordinateTransformations").getAsJsonArray(); + Assertions.assertTrue(coordinateTransformationsElement.get(0).getAsJsonObject().has(label)); + } + + @Test + void Check_Multiscale_Array_Full_Image_Datasets_Coordinate_Transformations_Time_Scale() { + String label = "scale"; + int level = 0; + int scaleIndex = 0; + float expectedScale = 2; + + Map groupAttributes = new OMEZarrAttributesCreator(sampleMetadata).getGroupAttributes(); + + JsonObject root = new Gson().toJsonTree(groupAttributes).getAsJsonObject(); + JsonArray multiscale = root.getAsJsonArray("multiscales"); + JsonArray datasetsElement = multiscale.get(0).getAsJsonObject().get("datasets").getAsJsonArray(); + JsonObject levelDatasetsElement = datasetsElement.get(level).getAsJsonObject(); + JsonArray coordinateTransformationsElement = levelDatasetsElement.get("coordinateTransformations").getAsJsonArray(); + float scale = coordinateTransformationsElement.get(0).getAsJsonObject().get(label).getAsJsonArray().get(scaleIndex).getAsFloat(); + Assertions.assertEquals(expectedScale, scale); + } + + @Test + void Check_Multiscale_Array_Full_Image_Datasets_Coordinate_Transformations_Channel_Scale() { + String label = "scale"; + int level = 0; + int scaleIndex = 1; + float expectedScale = 1; + + Map groupAttributes = new OMEZarrAttributesCreator(sampleMetadata).getGroupAttributes(); + + JsonObject root = new Gson().toJsonTree(groupAttributes).getAsJsonObject(); + JsonArray multiscale = root.getAsJsonArray("multiscales"); + JsonArray datasetsElement = multiscale.get(0).getAsJsonObject().get("datasets").getAsJsonArray(); + JsonObject levelDatasetsElement = datasetsElement.get(level).getAsJsonObject(); + JsonArray coordinateTransformationsElement = levelDatasetsElement.get("coordinateTransformations").getAsJsonArray(); + float scale = coordinateTransformationsElement.get(0).getAsJsonObject().get(label).getAsJsonArray().get(scaleIndex).getAsFloat(); + Assertions.assertEquals(expectedScale, scale); + } + + @Test + void Check_Multiscale_Array_Full_Image_Datasets_Coordinate_Transformations_Z_Scale() { + String label = "scale"; + int level = 0; + int scaleIndex = 2; + float expectedScale = sampleMetadata.getPixelCalibration().getZSpacing().floatValue(); + + Map groupAttributes = new OMEZarrAttributesCreator(sampleMetadata).getGroupAttributes(); + + JsonObject root = new Gson().toJsonTree(groupAttributes).getAsJsonObject(); + JsonArray multiscale = root.getAsJsonArray("multiscales"); + JsonArray datasetsElement = multiscale.get(0).getAsJsonObject().get("datasets").getAsJsonArray(); + JsonObject levelDatasetsElement = datasetsElement.get(level).getAsJsonObject(); + JsonArray coordinateTransformationsElement = levelDatasetsElement.get("coordinateTransformations").getAsJsonArray(); + float scale = coordinateTransformationsElement.get(0).getAsJsonObject().get(label).getAsJsonArray().get(scaleIndex).getAsFloat(); + Assertions.assertEquals(expectedScale, scale); + } + + @Test + void Check_Multiscale_Array_Full_Image_Datasets_Coordinate_Transformations_Y_Scale() { + String label = "scale"; + int level = 0; + int scaleIndex = 3; + float expectedScale = sampleMetadata.getPixelCalibration().getPixelHeight().floatValue() * (float) sampleMetadata.getDownsampleForLevel(level); + + Map groupAttributes = new OMEZarrAttributesCreator(sampleMetadata).getGroupAttributes(); + + JsonObject root = new Gson().toJsonTree(groupAttributes).getAsJsonObject(); + JsonArray multiscale = root.getAsJsonArray("multiscales"); + JsonArray datasetsElement = multiscale.get(0).getAsJsonObject().get("datasets").getAsJsonArray(); + JsonObject levelDatasetsElement = datasetsElement.get(level).getAsJsonObject(); + JsonArray coordinateTransformationsElement = levelDatasetsElement.get("coordinateTransformations").getAsJsonArray(); + float scale = coordinateTransformationsElement.get(0).getAsJsonObject().get(label).getAsJsonArray().get(scaleIndex).getAsFloat(); + Assertions.assertEquals(expectedScale, scale); + } + + @Test + void Check_Multiscale_Array_Full_Image_Datasets_Coordinate_Transformations_X_Scale() { + String label = "scale"; + int level = 0; + int scaleIndex = 4; + float expectedScale = sampleMetadata.getPixelCalibration().getPixelWidth().floatValue() * (float) sampleMetadata.getDownsampleForLevel(level); + + Map groupAttributes = new OMEZarrAttributesCreator(sampleMetadata).getGroupAttributes(); + + JsonObject root = new Gson().toJsonTree(groupAttributes).getAsJsonObject(); + JsonArray multiscale = root.getAsJsonArray("multiscales"); + JsonArray datasetsElement = multiscale.get(0).getAsJsonObject().get("datasets").getAsJsonArray(); + JsonObject levelDatasetsElement = datasetsElement.get(level).getAsJsonObject(); + JsonArray coordinateTransformationsElement = levelDatasetsElement.get("coordinateTransformations").getAsJsonArray(); + float scale = coordinateTransformationsElement.get(0).getAsJsonObject().get(label).getAsJsonArray().get(scaleIndex).getAsFloat(); + Assertions.assertEquals(expectedScale, scale); + } + + @Test + void Check_Multiscale_Array_Lower_Resolution_Image_Datasets_Has_Path() { + String label = "path"; + int level = 1; + + Map groupAttributes = new OMEZarrAttributesCreator(sampleMetadata).getGroupAttributes(); + + JsonObject root = new Gson().toJsonTree(groupAttributes).getAsJsonObject(); + JsonArray multiscale = root.getAsJsonArray("multiscales"); + JsonArray datasetsElement = multiscale.get(0).getAsJsonObject().get("datasets").getAsJsonArray(); + JsonObject levelDatasetsElement = datasetsElement.get(level).getAsJsonObject(); + Assertions.assertTrue(levelDatasetsElement.has(label)); + } + + @Test + void Check_Multiscale_Array_Lower_Resolution_Image_Datasets_Path() { + String label = "path"; + int level = 1; + String expectedPath = "s1"; + + Map groupAttributes = new OMEZarrAttributesCreator(sampleMetadata).getGroupAttributes(); + + JsonObject root = new Gson().toJsonTree(groupAttributes).getAsJsonObject(); + JsonArray multiscale = root.getAsJsonArray("multiscales"); + JsonArray datasetsElement = multiscale.get(0).getAsJsonObject().get("datasets").getAsJsonArray(); + JsonObject levelDatasetsElement = datasetsElement.get(level).getAsJsonObject(); + String path = levelDatasetsElement.get(label).getAsString(); + Assertions.assertEquals(expectedPath, path); + } + + @Test + void Check_Multiscale_Array_Lower_Resolution_Image_Datasets_Has_Coordinate_Transformations_Json_Array() { + String label = "coordinateTransformations"; + int level = 1; + + Map groupAttributes = new OMEZarrAttributesCreator(sampleMetadata).getGroupAttributes(); + + JsonObject root = new Gson().toJsonTree(groupAttributes).getAsJsonObject(); + JsonArray multiscale = root.getAsJsonArray("multiscales"); + JsonArray datasetsElement = multiscale.get(0).getAsJsonObject().get("datasets").getAsJsonArray(); + JsonObject levelDatasetsElement = datasetsElement.get(level).getAsJsonObject(); + Assertions.assertTrue(levelDatasetsElement.get(label).isJsonArray()); + } + + @Test + void Check_Multiscale_Array_Lower_Resolution_Image_Datasets_Coordinate_Transformations_Has_Type() { + String label = "type"; + int level = 1; + + Map groupAttributes = new OMEZarrAttributesCreator(sampleMetadata).getGroupAttributes(); + + JsonObject root = new Gson().toJsonTree(groupAttributes).getAsJsonObject(); + JsonArray multiscale = root.getAsJsonArray("multiscales"); + JsonArray datasetsElement = multiscale.get(0).getAsJsonObject().get("datasets").getAsJsonArray(); + JsonObject levelDatasetsElement = datasetsElement.get(level).getAsJsonObject(); + JsonArray coordinateTransformationsElement = levelDatasetsElement.get("coordinateTransformations").getAsJsonArray(); + Assertions.assertTrue(coordinateTransformationsElement.get(0).getAsJsonObject().has(label)); + } + + @Test + void Check_Multiscale_Array_Lower_Resolution_Image_Datasets_Coordinate_Transformations_Type() { + String label = "type"; + int level = 1; + String expectedType = "scale"; + + Map groupAttributes = new OMEZarrAttributesCreator(sampleMetadata).getGroupAttributes(); + + JsonObject root = new Gson().toJsonTree(groupAttributes).getAsJsonObject(); + JsonArray multiscale = root.getAsJsonArray("multiscales"); + JsonArray datasetsElement = multiscale.get(0).getAsJsonObject().get("datasets").getAsJsonArray(); + JsonObject levelDatasetsElement = datasetsElement.get(level).getAsJsonObject(); + JsonArray coordinateTransformationsElement = levelDatasetsElement.get("coordinateTransformations").getAsJsonArray(); + String type = coordinateTransformationsElement.get(0).getAsJsonObject().get(label).getAsString(); + Assertions.assertEquals(expectedType, type); + } + + @Test + void Check_Multiscale_Array_Lower_Resolution_Image_Datasets_Coordinate_Transformations_Has_Scale() { + String label = "scale"; + int level = 1; + + Map groupAttributes = new OMEZarrAttributesCreator(sampleMetadata).getGroupAttributes(); + + JsonObject root = new Gson().toJsonTree(groupAttributes).getAsJsonObject(); + JsonArray multiscale = root.getAsJsonArray("multiscales"); + JsonArray datasetsElement = multiscale.get(0).getAsJsonObject().get("datasets").getAsJsonArray(); + JsonObject levelDatasetsElement = datasetsElement.get(level).getAsJsonObject(); + JsonArray coordinateTransformationsElement = levelDatasetsElement.get("coordinateTransformations").getAsJsonArray(); + Assertions.assertTrue(coordinateTransformationsElement.get(0).getAsJsonObject().has(label)); + } + + @Test + void Check_Multiscale_Array_Lower_Resolution_Image_Datasets_Coordinate_Transformations_Time_Scale() { + String label = "scale"; + int level = 1; + int scaleIndex = 0; + float expectedScale = 2; + + Map groupAttributes = new OMEZarrAttributesCreator(sampleMetadata).getGroupAttributes(); + + JsonObject root = new Gson().toJsonTree(groupAttributes).getAsJsonObject(); + JsonArray multiscale = root.getAsJsonArray("multiscales"); + JsonArray datasetsElement = multiscale.get(0).getAsJsonObject().get("datasets").getAsJsonArray(); + JsonObject levelDatasetsElement = datasetsElement.get(level).getAsJsonObject(); + JsonArray coordinateTransformationsElement = levelDatasetsElement.get("coordinateTransformations").getAsJsonArray(); + float scale = coordinateTransformationsElement.get(0).getAsJsonObject().get(label).getAsJsonArray().get(scaleIndex).getAsFloat(); + Assertions.assertEquals(expectedScale, scale); + } + + @Test + void Check_Multiscale_Array_Lower_Resolution_Image_Datasets_Coordinate_Transformations_Channel_Scale() { + String label = "scale"; + int level = 1; + int scaleIndex = 1; + float expectedScale = 1; + + Map groupAttributes = new OMEZarrAttributesCreator(sampleMetadata).getGroupAttributes(); + + JsonObject root = new Gson().toJsonTree(groupAttributes).getAsJsonObject(); + JsonArray multiscale = root.getAsJsonArray("multiscales"); + JsonArray datasetsElement = multiscale.get(0).getAsJsonObject().get("datasets").getAsJsonArray(); + JsonObject levelDatasetsElement = datasetsElement.get(level).getAsJsonObject(); + JsonArray coordinateTransformationsElement = levelDatasetsElement.get("coordinateTransformations").getAsJsonArray(); + float scale = coordinateTransformationsElement.get(0).getAsJsonObject().get(label).getAsJsonArray().get(scaleIndex).getAsFloat(); + Assertions.assertEquals(expectedScale, scale); + } + + @Test + void Check_Multiscale_Array_Lower_Resolution_Image_Datasets_Coordinate_Transformations_Z_Scale() { + String label = "scale"; + int level = 1; + int scaleIndex = 2; + float expectedScale = sampleMetadata.getPixelCalibration().getZSpacing().floatValue(); + + Map groupAttributes = new OMEZarrAttributesCreator(sampleMetadata).getGroupAttributes(); + + JsonObject root = new Gson().toJsonTree(groupAttributes).getAsJsonObject(); + JsonArray multiscale = root.getAsJsonArray("multiscales"); + JsonArray datasetsElement = multiscale.get(0).getAsJsonObject().get("datasets").getAsJsonArray(); + JsonObject levelDatasetsElement = datasetsElement.get(level).getAsJsonObject(); + JsonArray coordinateTransformationsElement = levelDatasetsElement.get("coordinateTransformations").getAsJsonArray(); + float scale = coordinateTransformationsElement.get(0).getAsJsonObject().get(label).getAsJsonArray().get(scaleIndex).getAsFloat(); + Assertions.assertEquals(expectedScale, scale); + } + + @Test + void Check_Multiscale_Array_Lower_Resolution_Image_Datasets_Coordinate_Transformations_Y_Scale() { + String label = "scale"; + int level = 1; + int scaleIndex = 3; + float expectedScale = sampleMetadata.getPixelCalibration().getPixelHeight().floatValue() * (float) sampleMetadata.getDownsampleForLevel(level); + + Map groupAttributes = new OMEZarrAttributesCreator(sampleMetadata).getGroupAttributes(); + + JsonObject root = new Gson().toJsonTree(groupAttributes).getAsJsonObject(); + JsonArray multiscale = root.getAsJsonArray("multiscales"); + JsonArray datasetsElement = multiscale.get(0).getAsJsonObject().get("datasets").getAsJsonArray(); + JsonObject levelDatasetsElement = datasetsElement.get(level).getAsJsonObject(); + JsonArray coordinateTransformationsElement = levelDatasetsElement.get("coordinateTransformations").getAsJsonArray(); + float scale = coordinateTransformationsElement.get(0).getAsJsonObject().get(label).getAsJsonArray().get(scaleIndex).getAsFloat(); + Assertions.assertEquals(expectedScale, scale); + } + + @Test + void Check_Multiscale_Array_Lower_Resolution_Image_Datasets_Coordinate_Transformations_X_Scale() { + String label = "scale"; + int level = 1; + int scaleIndex = 4; + float expectedScale = sampleMetadata.getPixelCalibration().getPixelWidth().floatValue() * (float) sampleMetadata.getDownsampleForLevel(level); + + Map groupAttributes = new OMEZarrAttributesCreator(sampleMetadata).getGroupAttributes(); + + JsonObject root = new Gson().toJsonTree(groupAttributes).getAsJsonObject(); + JsonArray multiscale = root.getAsJsonArray("multiscales"); + JsonArray datasetsElement = multiscale.get(0).getAsJsonObject().get("datasets").getAsJsonArray(); + JsonObject levelDatasetsElement = datasetsElement.get(level).getAsJsonObject(); + JsonArray coordinateTransformationsElement = levelDatasetsElement.get("coordinateTransformations").getAsJsonArray(); + float scale = coordinateTransformationsElement.get(0).getAsJsonObject().get(label).getAsJsonArray().get(scaleIndex).getAsFloat(); + Assertions.assertEquals(expectedScale, scale); + } + + @Test + void Check_Multiscale_Array_Has_Name() { + String label = "name"; + + Map groupAttributes = new OMEZarrAttributesCreator(sampleMetadata).getGroupAttributes(); + + JsonObject root = new Gson().toJsonTree(groupAttributes).getAsJsonObject(); + JsonArray multiscale = root.getAsJsonArray("multiscales"); + Assertions.assertTrue(multiscale.get(0).getAsJsonObject().has(label)); + } + + @Test + void Check_Multiscale_Array_Name() { + String label = "name"; + String expectedName = sampleMetadata.getName(); + + Map groupAttributes = new OMEZarrAttributesCreator(sampleMetadata).getGroupAttributes(); + + JsonObject root = new Gson().toJsonTree(groupAttributes).getAsJsonObject(); + JsonArray multiscale = root.getAsJsonArray("multiscales"); + String name = multiscale.get(0).getAsJsonObject().get(label).getAsString(); + Assertions.assertEquals(expectedName, name); + } + + @Test + void Check_Multiscale_Array_Has_Version() { + String label = "version"; + + Map groupAttributes = new OMEZarrAttributesCreator(sampleMetadata).getGroupAttributes(); + + JsonObject root = new Gson().toJsonTree(groupAttributes).getAsJsonObject(); + JsonArray multiscale = root.getAsJsonArray("multiscales"); + Assertions.assertTrue(multiscale.get(0).getAsJsonObject().has(label)); + } + + @Test + void Check_Multiscale_Array_Version() { + String label = "version"; + String expectedVersion = "0.4"; + + Map groupAttributes = new OMEZarrAttributesCreator(sampleMetadata).getGroupAttributes(); + + JsonObject root = new Gson().toJsonTree(groupAttributes).getAsJsonObject(); + JsonArray multiscale = root.getAsJsonArray("multiscales"); + String version = multiscale.get(0).getAsJsonObject().get(label).getAsString(); + Assertions.assertEquals(expectedVersion, version); + } + + @Test + void Check_Group_Attribute_Has_Omero_Object() { + String label = "omero"; + + Map groupAttributes = new OMEZarrAttributesCreator(sampleMetadata).getGroupAttributes(); + + JsonObject root = new Gson().toJsonTree(groupAttributes).getAsJsonObject(); + JsonElement omeroElement = root.get(label); + Assertions.assertTrue(omeroElement.isJsonObject()); + } + + @Test + void Check_Omero_Object_Has_Name() { + String label = "name"; + + Map groupAttributes = new OMEZarrAttributesCreator(sampleMetadata).getGroupAttributes(); + + JsonObject root = new Gson().toJsonTree(groupAttributes).getAsJsonObject(); + JsonObject omeroElement = root.get("omero").getAsJsonObject(); + Assertions.assertTrue(omeroElement.has(label)); + } + + @Test + void Check_Omero_Object_Name() { + String label = "name"; + String expectedName = sampleMetadata.getName(); + + Map groupAttributes = new OMEZarrAttributesCreator(sampleMetadata).getGroupAttributes(); + + JsonObject root = new Gson().toJsonTree(groupAttributes).getAsJsonObject(); + JsonObject omeroElement = root.get("omero").getAsJsonObject(); + String name = omeroElement.get(label).getAsString(); + Assertions.assertEquals(expectedName, name); + } + + @Test + void Check_Omero_Object_Has_Version() { + String label = "version"; + + Map groupAttributes = new OMEZarrAttributesCreator(sampleMetadata).getGroupAttributes(); + + JsonObject root = new Gson().toJsonTree(groupAttributes).getAsJsonObject(); + JsonObject omeroElement = root.get("omero").getAsJsonObject(); + Assertions.assertTrue(omeroElement.has(label)); + } + + @Test + void Check_Omero_Object_Version() { + String label = "version"; + String expectedVersion = "0.4"; + + Map groupAttributes = new OMEZarrAttributesCreator(sampleMetadata).getGroupAttributes(); + + JsonObject root = new Gson().toJsonTree(groupAttributes).getAsJsonObject(); + JsonObject omeroElement = root.get("omero").getAsJsonObject(); + String name = omeroElement.get(label).getAsString(); + Assertions.assertEquals(expectedVersion, name); + } + + @Test + void Check_Omero_Object_Has_Channels_Array() { + String label = "channels"; + + Map groupAttributes = new OMEZarrAttributesCreator(sampleMetadata).getGroupAttributes(); + + JsonObject root = new Gson().toJsonTree(groupAttributes).getAsJsonObject(); + JsonObject omeroElement = root.get("omero").getAsJsonObject(); + JsonElement channelsElement = omeroElement.get(label); + Assertions.assertTrue(channelsElement.isJsonArray()); + } + + @Test + void Check_Omero_Object_Channels_Has_Label() { + int channelIndex = 2; + String label = "label"; + + Map groupAttributes = new OMEZarrAttributesCreator(sampleMetadata).getGroupAttributes(); + + JsonObject root = new Gson().toJsonTree(groupAttributes).getAsJsonObject(); + JsonObject omeroElement = root.get("omero").getAsJsonObject(); + JsonArray channelsElement = omeroElement.get("channels").getAsJsonArray(); + Assertions.assertTrue(channelsElement.get(channelIndex).getAsJsonObject().has(label)); + } + + @Test + void Check_Omero_Object_Channels_Label() { + int channelIndex = 2; + String expectedLabel = sampleMetadata.getChannel(channelIndex).getName(); + + Map groupAttributes = new OMEZarrAttributesCreator(sampleMetadata).getGroupAttributes(); + + JsonObject root = new Gson().toJsonTree(groupAttributes).getAsJsonObject(); + JsonObject omeroElement = root.get("omero").getAsJsonObject(); + JsonArray channelsElement = omeroElement.get("channels").getAsJsonArray(); + String label = channelsElement.get(channelIndex).getAsJsonObject().get("label").getAsString(); + Assertions.assertEquals(expectedLabel, label); + } + + @Test + void Check_Level_Attributes_Has_Array_Dimensions() { + String label = "_ARRAY_DIMENSIONS"; + + Map levelAttributes = new OMEZarrAttributesCreator(sampleMetadata).getLevelAttributes(); + + JsonObject root = new Gson().toJsonTree(levelAttributes).getAsJsonObject(); + JsonElement arrayDimensionsElement = root.get(label); + Assertions.assertTrue(arrayDimensionsElement.isJsonArray()); + } + + @Test + void Check_Level_Attributes_Array_T_Dimension() { + String label = "_ARRAY_DIMENSIONS"; + int dimensionIndex = 0; + String expectedDimension = "t"; + + Map levelAttributes = new OMEZarrAttributesCreator(sampleMetadata).getLevelAttributes(); + + JsonObject root = new Gson().toJsonTree(levelAttributes).getAsJsonObject(); + JsonArray arrayDimensionsElement = root.get(label).getAsJsonArray(); + String dimension = arrayDimensionsElement.get(dimensionIndex).getAsString(); + Assertions.assertEquals(expectedDimension, dimension); + } + + @Test + void Check_Level_Attributes_Array_C_Dimension() { + String label = "_ARRAY_DIMENSIONS"; + int dimensionIndex = 1; + String expectedDimension = "c"; + + Map levelAttributes = new OMEZarrAttributesCreator(sampleMetadata).getLevelAttributes(); + + JsonObject root = new Gson().toJsonTree(levelAttributes).getAsJsonObject(); + JsonArray arrayDimensionsElement = root.get(label).getAsJsonArray(); + String dimension = arrayDimensionsElement.get(dimensionIndex).getAsString(); + Assertions.assertEquals(expectedDimension, dimension); + } + + @Test + void Check_Level_Attributes_Array_Z_Dimension() { + String label = "_ARRAY_DIMENSIONS"; + int dimensionIndex = 2; + String expectedDimension = "z"; + + Map levelAttributes = new OMEZarrAttributesCreator(sampleMetadata).getLevelAttributes(); + + JsonObject root = new Gson().toJsonTree(levelAttributes).getAsJsonObject(); + JsonArray arrayDimensionsElement = root.get(label).getAsJsonArray(); + String dimension = arrayDimensionsElement.get(dimensionIndex).getAsString(); + Assertions.assertEquals(expectedDimension, dimension); + } + + @Test + void Check_Level_Attributes_Array_Y_Dimension() { + String label = "_ARRAY_DIMENSIONS"; + int dimensionIndex = 3; + String expectedDimension = "y"; + + Map levelAttributes = new OMEZarrAttributesCreator(sampleMetadata).getLevelAttributes(); + + JsonObject root = new Gson().toJsonTree(levelAttributes).getAsJsonObject(); + JsonArray arrayDimensionsElement = root.get(label).getAsJsonArray(); + String dimension = arrayDimensionsElement.get(dimensionIndex).getAsString(); + Assertions.assertEquals(expectedDimension, dimension); + } + + @Test + void Check_Level_Attributes_Array_X_Dimension() { + String label = "_ARRAY_DIMENSIONS"; + int dimensionIndex = 4; + String expectedDimension = "x"; + + Map levelAttributes = new OMEZarrAttributesCreator(sampleMetadata).getLevelAttributes(); + + JsonObject root = new Gson().toJsonTree(levelAttributes).getAsJsonObject(); + JsonArray arrayDimensionsElement = root.get(label).getAsJsonArray(); + String dimension = arrayDimensionsElement.get(dimensionIndex).getAsString(); + Assertions.assertEquals(expectedDimension, dimension); + } +} From a48f435cfa331ee343d5bea1943e7688181dadae Mon Sep 17 00:00:00 2001 From: lleplat Date: Mon, 16 Sep 2024 17:18:54 +0100 Subject: [PATCH 311/866] Added magnification + ome zarr writer metadata tests --- .../writers/ome/zarr/OMEXMLCreator.java | 51 ++++++++- .../writers/ome/zarr/TestOMEXMLCreator.java | 103 ++++++++++++------ .../writers/ome/zarr/TestOMEZarrWriter.java | 97 ++++++++++++++++- 3 files changed, 205 insertions(+), 46 deletions(-) diff --git a/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEXMLCreator.java b/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEXMLCreator.java index 6b8801a6c..696882ef7 100644 --- a/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEXMLCreator.java +++ b/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEXMLCreator.java @@ -5,7 +5,10 @@ import ome.units.quantity.Time; import ome.xml.model.Channel; import ome.xml.model.Image; +import ome.xml.model.Instrument; import ome.xml.model.OME; +import ome.xml.model.Objective; +import ome.xml.model.ObjectiveSettings; import ome.xml.model.Pixels; import ome.xml.model.enums.DimensionOrder; import ome.xml.model.enums.PixelType; @@ -35,7 +38,12 @@ class OMEXMLCreator { private static final Logger logger = LoggerFactory.getLogger(OMEXMLCreator.class); + private static final String NAMESPACE_ATTRIBUTE = "xmlns"; private static final String NAMESPACE = "http://www.openmicroscopy.org/Schemas/OME/2016-06"; + private static final String INSTRUMENT_ID = "Instrument:0"; + private static final String IMAGE_ID = "Image:0"; + private static final String OBJECTIVE_ID = "Objective:0"; + private static final String PIXELS_ID = "Pixels:0"; private OMEXMLCreator() { throw new RuntimeException("This class is not instantiable."); @@ -51,12 +59,17 @@ private OMEXMLCreator() { */ public static Optional create(ImageServerMetadata metadata) { OME ome = new OME(); - ome.addImage(createImage(metadata)); + + Instrument instrument = Double.isNaN(metadata.getMagnification()) ? null : createInstrument(metadata.getMagnification()); + if (instrument != null) { + ome.addInstrument(instrument); + } + ome.addImage(createImage(metadata, instrument)); try { Document document = DocumentBuilderFactory.newInstance().newDocumentBuilder().newDocument(); Element root = ome.asXMLElement(document); - root.setAttribute("xmlns", NAMESPACE); + root.setAttribute(NAMESPACE_ATTRIBUTE, NAMESPACE); document.appendChild(root); try (ByteArrayOutputStream os = new ByteArrayOutputStream()) { @@ -72,19 +85,45 @@ public static Optional create(ImageServerMetadata metadata) { } } - private static Image createImage(ImageServerMetadata metadata) { + private static Instrument createInstrument(double magnification) { + Instrument instrument = new Instrument(); + + instrument.setID(INSTRUMENT_ID); + instrument.addObjective(createObjective(magnification)); + + return instrument; + } + + private static Image createImage(ImageServerMetadata metadata, Instrument instrument) { Image image = new Image(); - image.setID("Image:0"); + image.setID(IMAGE_ID); image.setPixels(createPixels(metadata)); + if (instrument != null) { + image.linkInstrument(instrument); + + ObjectiveSettings settings = new ObjectiveSettings(); + settings.setID(OBJECTIVE_ID); + image.setObjectiveSettings(settings); + } + return image; } + private static Objective createObjective(double magnification) { + Objective objective = new Objective(); + + objective.setID(OBJECTIVE_ID); + objective.setNominalMagnification(magnification); + + return objective; + } + private static Pixels createPixels(ImageServerMetadata metadata) { Pixels pixels = new Pixels(); - pixels.setID("Pixels:0"); + pixels.setID(PIXELS_ID); pixels.setSizeX(new PositiveInteger(metadata.getWidth())); pixels.setSizeY(new PositiveInteger(metadata.getHeight())); @@ -164,6 +203,6 @@ private static int packRGBA(int r, int g, int b) { return ((r & 0xff)<<24) + ((g & 0xff)<<16) + ((b & 0xff)<<8) + - (1 & 0xff); + (0xff); } } diff --git a/qupath-extension-bioformats/src/test/java/qupath/lib/images/writers/ome/zarr/TestOMEXMLCreator.java b/qupath-extension-bioformats/src/test/java/qupath/lib/images/writers/ome/zarr/TestOMEXMLCreator.java index 48d55c217..dbc3aed10 100644 --- a/qupath-extension-bioformats/src/test/java/qupath/lib/images/writers/ome/zarr/TestOMEXMLCreator.java +++ b/qupath-extension-bioformats/src/test/java/qupath/lib/images/writers/ome/zarr/TestOMEXMLCreator.java @@ -38,6 +38,7 @@ public class TestOMEXMLCreator { ImageChannel.getInstance("c4", ColorTools.RED) )) .name("some name") + .magnification(4.8) .build(); @Test @@ -52,6 +53,38 @@ void Check_Namespace() throws ParserConfigurationException, IOException, SAXExce ); } + @Test + void Check_Instrument_Element_Exists() throws IOException, ParserConfigurationException, SAXException { + String xmlContent = OMEXMLCreator.create(sampleMetadata).orElse(""); + + Assertions.assertEquals( + 1, + getRootOfXMLText(xmlContent).getElementsByTagName("Instrument").getLength() + ); + } + + @Test + void Check_Objective_Element_Exists() throws IOException, ParserConfigurationException, SAXException { + String xmlContent = OMEXMLCreator.create(sampleMetadata).orElse(""); + + Element instrumentElement = (Element) getRootOfXMLText(xmlContent).getElementsByTagName("Instrument").item(0); + Assertions.assertEquals( + 1, + instrumentElement.getElementsByTagName("Objective").getLength() + ); + } + + @Test + void Check_Magnification() throws IOException, ParserConfigurationException, SAXException { + String expectedMagnification = String.valueOf(sampleMetadata.getMagnification()); + + String xmlContent = OMEXMLCreator.create(sampleMetadata).orElse(""); + + Element instrumentElement = (Element) getRootOfXMLText(xmlContent).getElementsByTagName("Instrument").item(0); + Element objectiveElement = (Element) instrumentElement.getElementsByTagName("Objective").item(0); + Assertions.assertEquals(expectedMagnification, objectiveElement.getAttribute("NominalMagnification")); + } + @Test void Check_Image_Element_Exists() throws IOException, ParserConfigurationException, SAXException { String xmlContent = OMEXMLCreator.create(sampleMetadata).orElse(""); @@ -66,7 +99,7 @@ void Check_Image_Element_Exists() throws IOException, ParserConfigurationExcepti void Check_Pixels_Element_Exists() throws IOException, ParserConfigurationException, SAXException { String xmlContent = OMEXMLCreator.create(sampleMetadata).orElse(""); - Element imageElement = (Element) getRootOfXMLText(xmlContent).getFirstChild(); + Element imageElement = (Element) getRootOfXMLText(xmlContent).getElementsByTagName("Image").item(0); Assertions.assertEquals( 1, imageElement.getElementsByTagName("Pixels").getLength() @@ -79,8 +112,8 @@ void Check_Width() throws IOException, ParserConfigurationException, SAXExceptio String xmlContent = OMEXMLCreator.create(sampleMetadata).orElse(""); - Element imageElement = (Element) getRootOfXMLText(xmlContent).getFirstChild(); - Element pixelsElement = (Element) imageElement.getFirstChild(); + Element imageElement = (Element) getRootOfXMLText(xmlContent).getElementsByTagName("Image").item(0); + Element pixelsElement = (Element) imageElement.getElementsByTagName("Pixels").item(0); Assertions.assertEquals(expectedWidth, pixelsElement.getAttribute("SizeX")); } @@ -90,8 +123,8 @@ void Check_Height() throws IOException, ParserConfigurationException, SAXExcepti String xmlContent = OMEXMLCreator.create(sampleMetadata).orElse(""); - Element imageElement = (Element) getRootOfXMLText(xmlContent).getFirstChild(); - Element pixelsElement = (Element) imageElement.getFirstChild(); + Element imageElement = (Element) getRootOfXMLText(xmlContent).getElementsByTagName("Image").item(0); + Element pixelsElement = (Element) imageElement.getElementsByTagName("Pixels").item(0); Assertions.assertEquals(expectedWidth, pixelsElement.getAttribute("SizeY")); } @@ -101,8 +134,8 @@ void Check_Number_Of_Z_Stacks() throws IOException, ParserConfigurationException String xmlContent = OMEXMLCreator.create(sampleMetadata).orElse(""); - Element imageElement = (Element) getRootOfXMLText(xmlContent).getFirstChild(); - Element pixelsElement = (Element) imageElement.getFirstChild(); + Element imageElement = (Element) getRootOfXMLText(xmlContent).getElementsByTagName("Image").item(0); + Element pixelsElement = (Element) imageElement.getElementsByTagName("Pixels").item(0); Assertions.assertEquals(expectedWidth, pixelsElement.getAttribute("SizeZ")); } @@ -112,8 +145,8 @@ void Check_Number_Of_Channels() throws IOException, ParserConfigurationException String xmlContent = OMEXMLCreator.create(sampleMetadata).orElse(""); - Element imageElement = (Element) getRootOfXMLText(xmlContent).getFirstChild(); - Element pixelsElement = (Element) imageElement.getFirstChild(); + Element imageElement = (Element) getRootOfXMLText(xmlContent).getElementsByTagName("Image").item(0); + Element pixelsElement = (Element) imageElement.getElementsByTagName("Pixels").item(0); Assertions.assertEquals(expectedWidth, pixelsElement.getAttribute("SizeC")); } @@ -123,8 +156,8 @@ void Check_Number_Of_Timepoints() throws IOException, ParserConfigurationExcepti String xmlContent = OMEXMLCreator.create(sampleMetadata).orElse(""); - Element imageElement = (Element) getRootOfXMLText(xmlContent).getFirstChild(); - Element pixelsElement = (Element) imageElement.getFirstChild(); + Element imageElement = (Element) getRootOfXMLText(xmlContent).getElementsByTagName("Image").item(0); + Element pixelsElement = (Element) imageElement.getElementsByTagName("Pixels").item(0); Assertions.assertEquals(expectedWidth, pixelsElement.getAttribute("SizeT")); } @@ -134,8 +167,8 @@ void Check_Pixel_Type() throws IOException, ParserConfigurationException, SAXExc String xmlContent = OMEXMLCreator.create(sampleMetadata).orElse(""); - Element imageElement = (Element) getRootOfXMLText(xmlContent).getFirstChild(); - Element pixelsElement = (Element) imageElement.getFirstChild(); + Element imageElement = (Element) getRootOfXMLText(xmlContent).getElementsByTagName("Image").item(0); + Element pixelsElement = (Element) imageElement.getElementsByTagName("Pixels").item(0); Assertions.assertEquals(expectedPixelType, pixelsElement.getAttribute("Type")); } @@ -145,8 +178,8 @@ void Check_Pixel_Width_Value() throws IOException, ParserConfigurationException, String xmlContent = OMEXMLCreator.create(sampleMetadata).orElse(""); - Element imageElement = (Element) getRootOfXMLText(xmlContent).getFirstChild(); - Element pixelsElement = (Element) imageElement.getFirstChild(); + Element imageElement = (Element) getRootOfXMLText(xmlContent).getElementsByTagName("Image").item(0); + Element pixelsElement = (Element) imageElement.getElementsByTagName("Pixels").item(0); Assertions.assertEquals(expectedValue, pixelsElement.getAttribute("PhysicalSizeX")); } @@ -156,8 +189,8 @@ void Check_Pixel_Width_Unit() throws IOException, ParserConfigurationException, String xmlContent = OMEXMLCreator.create(sampleMetadata).orElse(""); - Element imageElement = (Element) getRootOfXMLText(xmlContent).getFirstChild(); - Element pixelsElement = (Element) imageElement.getFirstChild(); + Element imageElement = (Element) getRootOfXMLText(xmlContent).getElementsByTagName("Image").item(0); + Element pixelsElement = (Element) imageElement.getElementsByTagName("Pixels").item(0); Assertions.assertEquals(expectedUnit, pixelsElement.getAttribute("PhysicalSizeXUnit")); } @@ -167,8 +200,8 @@ void Check_Pixel_Height_Value() throws IOException, ParserConfigurationException String xmlContent = OMEXMLCreator.create(sampleMetadata).orElse(""); - Element imageElement = (Element) getRootOfXMLText(xmlContent).getFirstChild(); - Element pixelsElement = (Element) imageElement.getFirstChild(); + Element imageElement = (Element) getRootOfXMLText(xmlContent).getElementsByTagName("Image").item(0); + Element pixelsElement = (Element) imageElement.getElementsByTagName("Pixels").item(0); Assertions.assertEquals(expectedValue, pixelsElement.getAttribute("PhysicalSizeY")); } @@ -178,8 +211,8 @@ void Check_Pixel_Height_Unit() throws IOException, ParserConfigurationException, String xmlContent = OMEXMLCreator.create(sampleMetadata).orElse(""); - Element imageElement = (Element) getRootOfXMLText(xmlContent).getFirstChild(); - Element pixelsElement = (Element) imageElement.getFirstChild(); + Element imageElement = (Element) getRootOfXMLText(xmlContent).getElementsByTagName("Image").item(0); + Element pixelsElement = (Element) imageElement.getElementsByTagName("Pixels").item(0); Assertions.assertEquals(expectedUnit, pixelsElement.getAttribute("PhysicalSizeYUnit")); } @@ -189,8 +222,8 @@ void Check_Pixel_Z_Spacing_Value() throws IOException, ParserConfigurationExcept String xmlContent = OMEXMLCreator.create(sampleMetadata).orElse(""); - Element imageElement = (Element) getRootOfXMLText(xmlContent).getFirstChild(); - Element pixelsElement = (Element) imageElement.getFirstChild(); + Element imageElement = (Element) getRootOfXMLText(xmlContent).getElementsByTagName("Image").item(0); + Element pixelsElement = (Element) imageElement.getElementsByTagName("Pixels").item(0); Assertions.assertEquals(expectedValue, pixelsElement.getAttribute("PhysicalSizeZ")); } @@ -200,8 +233,8 @@ void Check_Pixel_Z_Spacing_Unit() throws IOException, ParserConfigurationExcepti String xmlContent = OMEXMLCreator.create(sampleMetadata).orElse(""); - Element imageElement = (Element) getRootOfXMLText(xmlContent).getFirstChild(); - Element pixelsElement = (Element) imageElement.getFirstChild(); + Element imageElement = (Element) getRootOfXMLText(xmlContent).getElementsByTagName("Image").item(0); + Element pixelsElement = (Element) imageElement.getElementsByTagName("Pixels").item(0); Assertions.assertEquals(expectedUnit, pixelsElement.getAttribute("PhysicalSizeZUnit")); } @@ -213,8 +246,8 @@ void Check_Pixel_T_Spacing_Value() throws IOException, ParserConfigurationExcept String xmlContent = OMEXMLCreator.create(sampleMetadata).orElse(""); - Element imageElement = (Element) getRootOfXMLText(xmlContent).getFirstChild(); - Element pixelsElement = (Element) imageElement.getFirstChild(); + Element imageElement = (Element) getRootOfXMLText(xmlContent).getElementsByTagName("Image").item(0); + Element pixelsElement = (Element) imageElement.getElementsByTagName("Pixels").item(0); Assertions.assertEquals(expectedValue, pixelsElement.getAttribute("TimeIncrement")); } @@ -224,8 +257,8 @@ void Check_Pixel_T_Spacing_Unit() throws IOException, ParserConfigurationExcepti String xmlContent = OMEXMLCreator.create(sampleMetadata).orElse(""); - Element imageElement = (Element) getRootOfXMLText(xmlContent).getFirstChild(); - Element pixelsElement = (Element) imageElement.getFirstChild(); + Element imageElement = (Element) getRootOfXMLText(xmlContent).getElementsByTagName("Image").item(0); + Element pixelsElement = (Element) imageElement.getElementsByTagName("Pixels").item(0); Assertions.assertEquals(expectedUnit, pixelsElement.getAttribute("TimeIncrementUnit")); } @@ -235,8 +268,8 @@ void Check_Channels_Element_Exist() throws IOException, ParserConfigurationExcep String xmlContent = OMEXMLCreator.create(sampleMetadata).orElse(""); - Element imageElement = (Element) getRootOfXMLText(xmlContent).getFirstChild(); - Element pixelsElement = (Element) imageElement.getFirstChild(); + Element imageElement = (Element) getRootOfXMLText(xmlContent).getElementsByTagName("Image").item(0); + Element pixelsElement = (Element) imageElement.getElementsByTagName("Pixels").item(0); Assertions.assertEquals( expectedNumberOfChannels, pixelsElement.getElementsByTagName("Channel").getLength() @@ -250,8 +283,8 @@ void Check_Channel_Name() throws IOException, ParserConfigurationException, SAXE String xmlContent = OMEXMLCreator.create(sampleMetadata).orElse(""); - Element imageElement = (Element) getRootOfXMLText(xmlContent).getFirstChild(); - Element pixelsElement = (Element) imageElement.getFirstChild(); + Element imageElement = (Element) getRootOfXMLText(xmlContent).getElementsByTagName("Image").item(0); + Element pixelsElement = (Element) imageElement.getElementsByTagName("Pixels").item(0); Element channelElement = (Element) pixelsElement.getChildNodes().item(channelIndex); Assertions.assertEquals(expectedName, channelElement.getAttribute("Name")); } @@ -263,8 +296,8 @@ void Check_Channel_Color() throws IOException, ParserConfigurationException, SAX String xmlContent = OMEXMLCreator.create(sampleMetadata).orElse(""); - Element imageElement = (Element) getRootOfXMLText(xmlContent).getFirstChild(); - Element pixelsElement = (Element) imageElement.getFirstChild(); + Element imageElement = (Element) getRootOfXMLText(xmlContent).getElementsByTagName("Image").item(0); + Element pixelsElement = (Element) imageElement.getElementsByTagName("Pixels").item(0); Element channelElement = (Element) pixelsElement.getChildNodes().item(channelIndex); int colorRGBA = Integer.parseInt(channelElement.getAttribute("Color")); int colorRGB = ColorTools.packRGB( diff --git a/qupath-extension-bioformats/src/test/java/qupath/lib/images/writers/ome/zarr/TestOMEZarrWriter.java b/qupath-extension-bioformats/src/test/java/qupath/lib/images/writers/ome/zarr/TestOMEZarrWriter.java index 2e0600684..0c4fcf304 100644 --- a/qupath-extension-bioformats/src/test/java/qupath/lib/images/writers/ome/zarr/TestOMEZarrWriter.java +++ b/qupath-extension-bioformats/src/test/java/qupath/lib/images/writers/ome/zarr/TestOMEZarrWriter.java @@ -4,12 +4,14 @@ import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; import qupath.lib.color.ColorModelFactory; +import qupath.lib.common.ColorTools; import qupath.lib.images.servers.AbstractImageServer; import qupath.lib.images.servers.ImageChannel; import qupath.lib.images.servers.ImageServer; import qupath.lib.images.servers.ImageServerBuilder; import qupath.lib.images.servers.ImageServerMetadata; import qupath.lib.images.servers.ImageServerProvider; +import qupath.lib.images.servers.PixelCalibration; import qupath.lib.images.servers.PixelType; import qupath.lib.regions.ImageRegion; import qupath.lib.regions.RegionRequest; @@ -77,6 +79,90 @@ void Check_OME_XML_File_Exists() throws Exception { FileUtils.deleteDirectory(path.toFile()); } + @Test + void Check_Pixel_Type() throws Exception { + Path path = Files.createTempDirectory(UUID.randomUUID().toString()); + String outputImagePath = Paths.get(path.toString(), "image.ome.zarr").toString(); + SampleImageServer sampleImageServer = new SampleImageServer(); + PixelType expectedPixelType = sampleImageServer.getMetadata().getPixelType(); + + try (OMEZarrWriter writer = new OMEZarrWriter.Builder(sampleImageServer, outputImagePath).build()) { + writer.writeImage(); + } + + PixelType pixelType; + try (ImageServer server = ImageServerProvider.buildServer(outputImagePath, BufferedImage.class)) { + pixelType = server.getMetadata().getPixelType(); + } + Assertions.assertEquals(expectedPixelType, pixelType); + + sampleImageServer.close(); + FileUtils.deleteDirectory(path.toFile()); + } + + @Test + void Check_Pixel_Calibration() throws Exception { + Path path = Files.createTempDirectory(UUID.randomUUID().toString()); + String outputImagePath = Paths.get(path.toString(), "image.ome.zarr").toString(); + SampleImageServer sampleImageServer = new SampleImageServer(); + PixelCalibration expectedPixelCalibration = sampleImageServer.getMetadata().getPixelCalibration(); + + try (OMEZarrWriter writer = new OMEZarrWriter.Builder(sampleImageServer, outputImagePath).build()) { + writer.writeImage(); + } + + PixelCalibration pixelCalibration; + try (ImageServer server = ImageServerProvider.buildServer(outputImagePath, BufferedImage.class)) { + pixelCalibration = server.getMetadata().getPixelCalibration(); + } + Assertions.assertEquals(expectedPixelCalibration, pixelCalibration); + + sampleImageServer.close(); + FileUtils.deleteDirectory(path.toFile()); + } + + @Test + void Check_Channels() throws Exception { + Path path = Files.createTempDirectory(UUID.randomUUID().toString()); + String outputImagePath = Paths.get(path.toString(), "image.ome.zarr").toString(); + SampleImageServer sampleImageServer = new SampleImageServer(); + List expectedChannels = sampleImageServer.getMetadata().getChannels(); + + try (OMEZarrWriter writer = new OMEZarrWriter.Builder(sampleImageServer, outputImagePath).build()) { + writer.writeImage(); + } + + List channels; + try (ImageServer server = ImageServerProvider.buildServer(outputImagePath, BufferedImage.class)) { + channels = server.getMetadata().getChannels(); + } + Assertions.assertEquals(expectedChannels, channels); + + sampleImageServer.close(); + FileUtils.deleteDirectory(path.toFile()); + } + + @Test + void Check_Magnification() throws Exception { + Path path = Files.createTempDirectory(UUID.randomUUID().toString()); + String outputImagePath = Paths.get(path.toString(), "image.ome.zarr").toString(); + SampleImageServer sampleImageServer = new SampleImageServer(); + double expectedMagnification = sampleImageServer.getMetadata().getMagnification(); + + try (OMEZarrWriter writer = new OMEZarrWriter.Builder(sampleImageServer, outputImagePath).build()) { + writer.writeImage(); + } + + double magnification; + try (ImageServer server = ImageServerProvider.buildServer(outputImagePath, BufferedImage.class)) { + magnification = server.getMetadata().getMagnification(); + } + Assertions.assertEquals(expectedMagnification, magnification); + + sampleImageServer.close(); + FileUtils.deleteDirectory(path.toFile()); + } + @Test void Check_Full_Image_Pixels() throws Exception { Path path = Files.createTempDirectory(UUID.randomUUID().toString()); @@ -493,14 +579,15 @@ public ImageServerMetadata getOriginalMetadata() { .pixelType(PixelType.FLOAT64) .preferredTileSize(32, 32) .channels(List.of( - ImageChannel.getInstance("c1", 1), - ImageChannel.getInstance("c2", 2), - ImageChannel.getInstance("c3", 3), - ImageChannel.getInstance("c4", 4), - ImageChannel.getInstance("c5", 5) + ImageChannel.getInstance("c1", ColorTools.CYAN), + ImageChannel.getInstance("c2", ColorTools.BLUE), + ImageChannel.getInstance("c3", ColorTools.RED), + ImageChannel.getInstance("c4", ColorTools.GREEN), + ImageChannel.getInstance("c5", ColorTools.MAGENTA) )) .name("name") .levelsFromDownsamples(1, 2) + .magnification(2.4) .build(); } From fb7efdf6bf1862d9b182cbceb245052478e1067f Mon Sep 17 00:00:00 2001 From: Pete Date: Mon, 16 Sep 2024 17:37:12 +0100 Subject: [PATCH 312/866] Update to DJL v0.30.0 This also defaults to PyTorch 2.4.0 --- CHANGELOG.md | 2 +- gradle/libs.versions.toml | 2 +- settings.gradle | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cd467f296..f23e424b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -93,7 +93,7 @@ They may change or be removed in future versions. ### Dependency updates * Bio-Formats 7.3.1 * Commonmark 0.22.0 -* DeepJavaLibrary 0.29.0 +* DeepJavaLibrary 0.30.0 * Groovy 4.0.22 * Gson 2.11.0 * Guava 33.3.0-jre diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4544d6131..ae83b6d94 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,7 +10,7 @@ commonsMath3 = "3.6.1" commonsText = "1.10.0" controlsFX = "11.1.2" -deepJavaLibrary = "0.29.0" +deepJavaLibrary = "0.30.0" groovy = "4.0.22" gson = "2.11.0" diff --git a/settings.gradle b/settings.gradle index d0e35f6a1..ed601c26c 100644 --- a/settings.gradle +++ b/settings.gradle @@ -41,9 +41,9 @@ dependencyResolutionManagement { library('djl', 'io.github.qupath', 'qupath-extension-djl').version('0.4.0-20240911.172830-2') library('instanseg', 'io.github.qupath', 'qupath-extension-instanseg').version('0.0.1-20240911.172453-2') -// bundle('extensions', []) - bundle('extensions', ['djl', 'instanseg']) + // Include or exclude bundled extensions + bundle('extensions', []) +// bundle('extensions', ['djl', 'instanseg']) } } } - From f747084cda25aa23058d4ba1e882b09f1c6c2505 Mon Sep 17 00:00:00 2001 From: Pete Date: Mon, 16 Sep 2024 19:07:42 +0100 Subject: [PATCH 313/866] Handle ARGB in OMEPixelParser Avoid incompatible color model exceptions when expecting conversion of byte channels to a packed ARGB representation. --- .../servers/bioformats/OMEPixelParser.java | 52 +++++++++++++++---- 1 file changed, 42 insertions(+), 10 deletions(-) diff --git a/qupath-extension-bioformats/src/main/java/qupath/lib/images/servers/bioformats/OMEPixelParser.java b/qupath-extension-bioformats/src/main/java/qupath/lib/images/servers/bioformats/OMEPixelParser.java index 4600bd126..7f7b07521 100644 --- a/qupath-extension-bioformats/src/main/java/qupath/lib/images/servers/bioformats/OMEPixelParser.java +++ b/qupath-extension-bioformats/src/main/java/qupath/lib/images/servers/bioformats/OMEPixelParser.java @@ -4,7 +4,7 @@ * %% * Copyright (C) 2014 - 2016 The Queen's University of Belfast, Northern Ireland * Contact: IP Management (ipmanagement@qub.ac.uk) - * Copyright (C) 2018 - 2023 QuPath developers, The University of Edinburgh + * Copyright (C) 2018 - 2024 QuPath developers, The University of Edinburgh * %% * QuPath is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as @@ -86,16 +86,48 @@ private OMEPixelParser(Builder builder) { * @return the corresponding image */ public BufferedImage parse(byte[][] pixels, int width, int height, int nChannels, ColorModel colorModel) { - DataBuffer dataBuffer = bytesToDataBuffer(pixels); - SampleModel sampleModel = createSampleModel(width, height, nChannels, dataBuffer.getDataType()); - WritableRaster raster = WritableRaster.createWritableRaster(sampleModel, dataBuffer, null); + DataBuffer dataBuffer; + WritableRaster raster; + if (pixelType == PixelType.UINT8 && colorModel.equals(ColorModel.getRGBdefault()) && (pixels.length == 3 || pixels.length == 4)) { + // Special case where we need to convert UINT8 RGB(A) to packed ARGB + var argb = bytesToPackedARGB(pixels); + var img = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB); + img.setRGB(0, 0, width, height, argb, 0, width); + return img; + } else { + dataBuffer = bytesToDataBuffer(pixels); + var sampleModel = createSampleModel(width, height, nChannels, dataBuffer.getDataType()); + raster = WritableRaster.createWritableRaster(sampleModel, dataBuffer, null); + return new BufferedImage( + colorModel, + raster, + false, + null + ); + } + } - return new BufferedImage( - colorModel, - raster, - false, - null - ); + /** + * Convert a byte array to ARGB pixel values. + * @param pixels + * @return + */ + private static int[] bytesToPackedARGB(byte[][] pixels) { + // Special case for RGB images - we want a packed byte array + int n = pixels[0].length; + int[] argb = new int[n]; + if (pixels.length == 3) { + // We assume RGB (no alpha) + for (int i = 0; i < n; i++) { + argb[i] = (255 << 24) | (pixels[0][i] & 0xFF) << 16 | (pixels[1][i] & 0xFF) << 8 | (pixels[2][i] & 0xFF); + } + } else if (pixels.length == 4) { + // We assume alpha is last (RGBA) + for (int i = 0; i < n; i++) { + argb[i] = (pixels[3][i] & 0xFF) << 24 | (pixels[0][i] & 0xFF) << 16 | (pixels[1][i] & 0xFF) << 8 | (pixels[2][i] & 0xFF); + } + } + return argb; } private DataBuffer bytesToDataBuffer(byte[][] bytes) { From b71f352d620e9809a5ce292878c3d31b8aafac4e Mon Sep 17 00:00:00 2001 From: Pete Date: Mon, 16 Sep 2024 19:49:18 +0100 Subject: [PATCH 314/866] Ensure tests use UTF-8 Tests were failing when Gradle was run using Java 17 on Windows. --- .../java/qupath/lib/images/writers/ome/zarr/OMEXMLCreator.java | 2 +- .../qupath/lib/images/writers/ome/zarr/TestOMEXMLCreator.java | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEXMLCreator.java b/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEXMLCreator.java index 696882ef7..8a14e2ffd 100644 --- a/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEXMLCreator.java +++ b/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEXMLCreator.java @@ -77,7 +77,7 @@ public static Optional create(ImageServerMetadata metadata) { new DOMSource(document), new StreamResult(new OutputStreamWriter(os, StandardCharsets.UTF_8)) ); - return Optional.ofNullable(os.toString()); + return Optional.ofNullable(os.toString(StandardCharsets.UTF_8)); } } catch (Exception e) { logger.error("Error while creating OME XML content", e); diff --git a/qupath-extension-bioformats/src/test/java/qupath/lib/images/writers/ome/zarr/TestOMEXMLCreator.java b/qupath-extension-bioformats/src/test/java/qupath/lib/images/writers/ome/zarr/TestOMEXMLCreator.java index dbc3aed10..d5d5fc49c 100644 --- a/qupath-extension-bioformats/src/test/java/qupath/lib/images/writers/ome/zarr/TestOMEXMLCreator.java +++ b/qupath-extension-bioformats/src/test/java/qupath/lib/images/writers/ome/zarr/TestOMEXMLCreator.java @@ -253,7 +253,8 @@ void Check_Pixel_T_Spacing_Value() throws IOException, ParserConfigurationExcept @Test void Check_Pixel_T_Spacing_Unit() throws IOException, ParserConfigurationException, SAXException { - String expectedUnit = "µs"; + // Required only for Java 17 and earlier, on platforms where UTF-8 is not the default + String expectedUnit = new String("µs".getBytes(), StandardCharsets.UTF_8); String xmlContent = OMEXMLCreator.create(sampleMetadata).orElse(""); From eaa11e678fdff9705007f51cd2b62d2667144ad0 Mon Sep 17 00:00:00 2001 From: lleplat Date: Wed, 18 Sep 2024 14:30:04 +0100 Subject: [PATCH 315/866] Tile size fix --- .../servers/bioformats/BioFormatsImageServer.java | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/qupath-extension-bioformats/src/main/java/qupath/lib/images/servers/bioformats/BioFormatsImageServer.java b/qupath-extension-bioformats/src/main/java/qupath/lib/images/servers/bioformats/BioFormatsImageServer.java index eb483ba59..0ca9706c5 100644 --- a/qupath-extension-bioformats/src/main/java/qupath/lib/images/servers/bioformats/BioFormatsImageServer.java +++ b/qupath-extension-bioformats/src/main/java/qupath/lib/images/servers/bioformats/BioFormatsImageServer.java @@ -489,8 +489,18 @@ static BioFormatsImageServer checkSupport(URI uri, final BioFormatsServerOptions // The first resolution is the highest, i.e. the largest image width = reader.getSizeX(); height = reader.getSizeY(); + + // When opening Zarr images, reader.getOptimalTileWidth/Height() returns by default + // the chunk width/height of the lowest resolution image, which can be too low + // for the full resolution image, which makes the image slow to read (especially + // for remote images). + // A workaround to get the chunk size of the full resolution image is to set the resolution + // to 0 and read some bytes from the full resolution image, like below: + reader.setResolution(0); + reader.openBytes(reader.getIndex(0, 0, 0), 0, 0, 1, 1); tileWidth = reader.getOptimalTileWidth(); tileHeight = reader.getOptimalTileHeight(); + nChannels = reader.getSizeC(); // Make sure tile sizes are within range From 6a81ee9e5b13326e7fe18bc08b62433e32e7b7ed Mon Sep 17 00:00:00 2001 From: lleplat Date: Wed, 18 Sep 2024 15:00:56 +0100 Subject: [PATCH 316/866] Tile size fix --- .../lib/images/servers/bioformats/BioFormatsImageServer.java | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/qupath-extension-bioformats/src/main/java/qupath/lib/images/servers/bioformats/BioFormatsImageServer.java b/qupath-extension-bioformats/src/main/java/qupath/lib/images/servers/bioformats/BioFormatsImageServer.java index 0ca9706c5..19eccff5a 100644 --- a/qupath-extension-bioformats/src/main/java/qupath/lib/images/servers/bioformats/BioFormatsImageServer.java +++ b/qupath-extension-bioformats/src/main/java/qupath/lib/images/servers/bioformats/BioFormatsImageServer.java @@ -491,9 +491,8 @@ static BioFormatsImageServer checkSupport(URI uri, final BioFormatsServerOptions height = reader.getSizeY(); // When opening Zarr images, reader.getOptimalTileWidth/Height() returns by default - // the chunk width/height of the lowest resolution image, which can be too low - // for the full resolution image, which makes the image slow to read (especially - // for remote images). + // the chunk width/height of the lowest resolution image. See + // https://github.com/qupath/qupath/pull/1645#issue-2533834067 for why it may be a problem. // A workaround to get the chunk size of the full resolution image is to set the resolution // to 0 and read some bytes from the full resolution image, like below: reader.setResolution(0); From c45929253a83fef6667a15d231c5476dc5514db5 Mon Sep 17 00:00:00 2001 From: lleplat Date: Wed, 18 Sep 2024 15:47:58 +0100 Subject: [PATCH 317/866] Remove pixel reading --- .../images/servers/bioformats/BioFormatsImageServer.java | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/qupath-extension-bioformats/src/main/java/qupath/lib/images/servers/bioformats/BioFormatsImageServer.java b/qupath-extension-bioformats/src/main/java/qupath/lib/images/servers/bioformats/BioFormatsImageServer.java index 19eccff5a..3dc7488da 100644 --- a/qupath-extension-bioformats/src/main/java/qupath/lib/images/servers/bioformats/BioFormatsImageServer.java +++ b/qupath-extension-bioformats/src/main/java/qupath/lib/images/servers/bioformats/BioFormatsImageServer.java @@ -494,9 +494,10 @@ static BioFormatsImageServer checkSupport(URI uri, final BioFormatsServerOptions // the chunk width/height of the lowest resolution image. See // https://github.com/qupath/qupath/pull/1645#issue-2533834067 for why it may be a problem. // A workaround to get the chunk size of the full resolution image is to set the resolution - // to 0 and read some bytes from the full resolution image, like below: - reader.setResolution(0); - reader.openBytes(reader.getIndex(0, 0, 0), 0, 0, 1, 1); + // to 0 with the Zarr reader + if (reader instanceof ZarrReader zarrReader) { + zarrReader.setResolution(0, true); + } tileWidth = reader.getOptimalTileWidth(); tileHeight = reader.getOptimalTileHeight(); From 1ae4f82e90f9093a2a2654ae4edcf81d8e3d7e5f Mon Sep 17 00:00:00 2001 From: Pete Date: Wed, 18 Sep 2024 17:30:58 +0100 Subject: [PATCH 318/866] Delay firing menu items from command list Fixes https://github.com/qupath/qupath/issues/1647 --- CHANGELOG.md | 1 + .../lib/gui/tools/CommandFinderTools.java | 24 ++++++++++++------- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f23e424b3..7561dd154 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,6 +68,7 @@ They may change or be removed in future versions. * Out-of-bounds tiles can result in detected cells being in the wrong place (https://github.com/qupath/qupath/issues/1606) * Spacebar can be locked in an 'on' position within viewers (https://github.com/qupath/qupath/issues/1610) * Timepoint data is rarely available (or correct) (https://github.com/qupath/qupath/issues/1628) +* Some commands cannot be run from 'Command List' (https://github.com/qupath/qupath/issues/1647) ### API changes * New `Map getMetadata()` method added to `PathObject`, `Project` and `ProjectImageEntry` (https://github.com/qupath/qupath/pull/1587) diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/tools/CommandFinderTools.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/tools/CommandFinderTools.java index 241b6cdcd..b7ee7c43f 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/tools/CommandFinderTools.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/tools/CommandFinderTools.java @@ -389,7 +389,8 @@ private static Stage createCommandFinderDialog(final MenuBar menubar, final Wind table.setOnMouseClicked(e -> { if (e.getClickCount() > 1 && !(e.getTarget() instanceof TableColumnHeader)) { - if (runSelectedCommand(table.getSelectionModel().getSelectedItem())) { + var selected = table.getSelectionModel().getSelectedItem(); + if (runSelectedCommand(selected)) { if (cbAutoClose.isSelected()) { stage.hide(); } @@ -566,10 +567,12 @@ private static TextField createTextField(final TableView table, fi if (!runSelectedCommand(table.getSelectionModel().getSelectedItem())) return; - if (clearTextOnRun) + if (clearTextOnRun) { textField.clear(); - if (hideDialogOnRun != null && hideDialogOnRun.get() && dialog != null) + } + if (hideDialogOnRun != null && hideDialogOnRun.get() && dialog != null) { dialog.hide(); + } e.consume(); } else if (e.getCode() == KeyCode.DOWN) { if (table.getItems().size() == 1) @@ -775,12 +778,15 @@ static boolean fireMenuItem(final MenuItem menuItem) { logger.error("'{}' command is not currently available!", menuItem.getText()); return false; } - if (menuItem instanceof CheckMenuItem) - fireMenuItem((CheckMenuItem)menuItem); - else if (menuItem instanceof RadioMenuItem) - fireMenuItem((RadioMenuItem)menuItem); - else - menuItem.fire(); + if (menuItem instanceof CheckMenuItem) { + fireMenuItem((CheckMenuItem) menuItem); + } else if (menuItem instanceof RadioMenuItem) { + fireMenuItem((RadioMenuItem) menuItem); + } else { + // Running this later helps deal with + // https://github.com/qupath/qupath/issues/1647 + Platform.runLater(menuItem::fire); + } return true; } From 4e65d6771965e8598655fc406d19e5e7ead34c31 Mon Sep 17 00:00:00 2001 From: Leo Leplat <60394504+Rylern@users.noreply.github.com> Date: Thu, 19 Sep 2024 15:11:33 +0100 Subject: [PATCH 319/866] Added Zarr writer GUI command --- .../BioFormatsOptionsExtension.java | 20 +- .../writers/ome/zarr/OMEZarrWriter.java | 12 +- .../ome/zarr/OMEZarrWriterCommand.java | 213 ++++++++++++++++++ .../qupath-gui-strings.properties | 19 ++ 4 files changed, 256 insertions(+), 8 deletions(-) create mode 100644 qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriterCommand.java diff --git a/qupath-extension-bioformats/src/main/java/qupath/lib/images/servers/bioformats/BioFormatsOptionsExtension.java b/qupath-extension-bioformats/src/main/java/qupath/lib/images/servers/bioformats/BioFormatsOptionsExtension.java index 0c1830992..374dcf2a2 100644 --- a/qupath-extension-bioformats/src/main/java/qupath/lib/images/servers/bioformats/BioFormatsOptionsExtension.java +++ b/qupath-extension-bioformats/src/main/java/qupath/lib/images/servers/bioformats/BioFormatsOptionsExtension.java @@ -51,6 +51,7 @@ import qupath.fx.prefs.annotations.IntegerPref; import qupath.fx.prefs.annotations.PrefCategory; import qupath.lib.images.writers.ome.OMEPyramidWriterCommand; +import qupath.lib.images.writers.ome.zarr.OMEZarrWriterCommand; /** * A QuPath extension that adds options relating to the BioFormatsImageServer to the main QuPath preference pane. @@ -79,6 +80,7 @@ public void installExtension(QuPathGUI qupath) { var actions = new OmeTiffWriterAction(qupath); qupath.installActions(ActionTools.getAnnotatedActions(actions)); + qupath.installActions(ActionTools.getAnnotatedActions(new OmeZarrWriterAction(qupath))); var prefs = new BioFormatsPreferences(); qupath.getPreferencePane() @@ -182,14 +184,9 @@ private BioFormatsPreferences() { pathMemoization.addListener((v, o, n) -> options.setPathMemoization(n)); useExtensions.addListener((v, o, n) -> fillCollectionWithTokens(n, options.getUseAlwaysExtensions())); skipExtensions.addListener((v, o, n) -> fillCollectionWithTokens(n, options.getSkipAlwaysExtensions())); - } - - } - - public static class OmeTiffWriterAction { @ActionMenu(value = {"Menu.File", "Menu.File.ExportImage"}) @@ -199,7 +196,16 @@ public static class OmeTiffWriterAction { OmeTiffWriterAction(QuPathGUI qupath) { actionWriter = ActionTools.createAction(new OMEPyramidWriterCommand(qupath), "OME TIFF"); } - } - + + public static class OmeZarrWriterAction { + + @ActionMenu(value = {"Menu.File", "Menu.File.ExportImage"}) + @ActionConfig("Action.BioFormats.omeZarr") + public final Action actionWriter; + + public OmeZarrWriterAction(QuPathGUI qupath) { + actionWriter = ActionTools.createAction(new OMEZarrWriterCommand(qupath), "OME Zarr"); + } + } } diff --git a/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriter.java b/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriter.java index b52a92150..4ec6e5509 100644 --- a/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriter.java +++ b/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriter.java @@ -103,13 +103,23 @@ private OMEZarrWriter(Builder builder) throws IOException { /** * Close this writer. This will wait until all pending tiles * are written. + *

    + * If this function is interrupted, all pending and active tasks + * are cancelled. * * @throws InterruptedException when the waiting is interrupted */ @Override public void close() throws InterruptedException { executorService.shutdown(); - executorService.awaitTermination(Long.MAX_VALUE, TimeUnit.NANOSECONDS); + + try { + executorService.awaitTermination(Long.MAX_VALUE, TimeUnit.NANOSECONDS); + } catch (InterruptedException e) { + logger.debug("Waiting interrupted. Stopping tasks", e); + executorService.shutdownNow(); + throw e; + } } /** diff --git a/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriterCommand.java b/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriterCommand.java new file mode 100644 index 000000000..3dccc6508 --- /dev/null +++ b/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriterCommand.java @@ -0,0 +1,213 @@ +package qupath.lib.images.writers.ome.zarr; + +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.IntegerProperty; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import qupath.fx.dialogs.Dialogs; +import qupath.fx.dialogs.FileChoosers; +import qupath.lib.gui.QuPathGUI; +import qupath.lib.gui.localization.QuPathResources; +import qupath.lib.gui.prefs.PathPrefs; +import qupath.lib.gui.tools.GuiTools; +import qupath.lib.images.ImageData; +import qupath.lib.images.servers.ImageServerMetadata; +import qupath.lib.objects.PathObject; +import qupath.lib.plugins.parameters.ParameterList; +import qupath.lib.regions.ImageRegion; + +import java.awt.image.BufferedImage; +import java.io.File; +import java.io.IOException; +import java.text.MessageFormat; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.stream.DoubleStream; + +/** + * A GUI command to export the current ImageServer to an OME Zarr file + */ +public class OMEZarrWriterCommand implements Runnable { + + private static final Logger logger = LoggerFactory.getLogger(OMEZarrWriterCommand.class); + private static final IntegerProperty scaledDownsample = PathPrefs.createPersistentPreference( + "ome-zarr-scaled-downsample", + 4 + ); + private static final IntegerProperty tileSize = PathPrefs.createPersistentPreference( + "ome-zarr-tile-size", + 512 + ); + private static final BooleanProperty allZ = PathPrefs.createPersistentPreference("ome-zarr-all-z", true); + private static final BooleanProperty allT = PathPrefs.createPersistentPreference("ome-zarr-all-t", true); + private static final IntegerProperty numberOfThreads = PathPrefs.createPersistentPreference( + "ome-zarr-number-threads", + Runtime.getRuntime().availableProcessors() + ); + private final ExecutorService executor = Executors.newSingleThreadExecutor(); + private final QuPathGUI qupath; + private Future task; + + /** + * Create the command + * + * @param qupath the QuPath that owns this command + */ + public OMEZarrWriterCommand(QuPathGUI qupath) { + this.qupath = qupath; + } + + @Override + public void run() { + if (task != null && !task.isDone()) { + if (Dialogs.showConfirmDialog( + QuPathResources.getString("Action.BioFormats.omeZarrWriter"), + QuPathResources.getString("Action.BioFormats.stopCurrentExport") + )) { + task.cancel(true); + } else { + return; + } + } + + ImageData imageData = qupath.getViewer().getImageData(); + if (imageData == null) { + GuiTools.showNoImageError(QuPathResources.getString("Action.BioFormats.omeZarrWriter")); + return; + } + + ParameterList parameters = createParameters(imageData.getServerMetadata()); + if (!GuiTools.showParameterDialog(QuPathResources.getString("Action.BioFormats.exportOMEZarr"), parameters)) { + return; + } + updatePreferences(parameters); + + File fileOutput = FileChoosers.promptToSaveFile( + QuPathResources.getString("Action.BioFormats.writeOMEZarr"), + null, + FileChoosers.createExtensionFilter(QuPathResources.getString("Action.BioFormats.omeZarr"), ".ome.zarr") + ); + if (fileOutput == null) { + return; + } + if (!fileOutput.getAbsolutePath().endsWith(".ome.zarr")) { + Dialogs.showErrorMessage( + QuPathResources.getString("Action.BioFormats.omeZarrWriter"), + QuPathResources.getString("Action.BioFormats.invalidZarrExtension") + ); + return; + } + if (fileOutput.exists()) { + Dialogs.showErrorMessage( + QuPathResources.getString("Action.BioFormats.omeZarrWriter"), + QuPathResources.getString("Action.BioFormats.directoryAlreadyExists") + ); + return; + } + + OMEZarrWriter.Builder builder = new OMEZarrWriter.Builder(imageData.getServer(), fileOutput.getAbsolutePath()) + .setNumberOfThreads(parameters.getIntParameterValue("numberOfThreads")) + .setTileWidth(parameters.getIntParameterValue("tileSize")) + .setTileHeight(parameters.getIntParameterValue("tileSize")) + .setDownsamples(DoubleStream.iterate( + 1, + d -> (int) (imageData.getServer().getWidth() / d) > parameters.getIntParameterValue("tileSize") && + (int) (imageData.getServer().getHeight() / d) > parameters.getIntParameterValue("tileSize"), + d -> d * parameters.getIntParameterValue("scaledDownsample")).toArray() + ); + + if (!parameters.getBooleanParameterValue("allZ")) { + builder.setZSlices(qupath.getViewer().getZPosition(), qupath.getViewer().getZPosition()+1); + } + if (!parameters.getBooleanParameterValue("allT")) { + builder.setTimepoints(qupath.getViewer().getTPosition(), qupath.getViewer().getTPosition()+1); + } + + PathObject selected = imageData.getHierarchy().getSelectionModel().getSelectedObject(); + if (selected != null && selected.hasROI() && selected.getROI().isArea()) { + builder.setBoundingBox(ImageRegion.createInstance(selected.getROI())); + } + + task = executor.submit(() -> { + try (OMEZarrWriter writer = builder.build()) { + Dialogs.showInfoNotification( + QuPathResources.getString("Action.BioFormats.omeZarrWriter"), + MessageFormat.format( + QuPathResources.getString("Action.BioFormats.exportingTo"), + fileOutput.getAbsolutePath() + ) + ); + writer.writeImage(); + } catch (IOException e) { + logger.error("Error while writing Zarr image", e); + Dialogs.showErrorMessage(QuPathResources.getString("Action.BioFormats.omeZarrWriter"), e.getLocalizedMessage()); + return; + } catch (InterruptedException e) { + logger.warn("OME Zarr writer closed by interrupt (possibly due to user cancelling it)", e); + return; + } + Dialogs.showInfoNotification( + QuPathResources.getString("Action.BioFormats.omeZarrWriter"), + MessageFormat.format( + QuPathResources.getString("Action.BioFormats.exportComplete"), + fileOutput.getName() + ) + ); + }); + } + + private static ParameterList createParameters(ImageServerMetadata metadata) { + ParameterList parameters = new ParameterList() + .addIntParameter( + "scaledDownsample", + QuPathResources.getString("Action.BioFormats.pyramidalDownsample"), + scaledDownsample.getValue(), + "", + 1, + 8, + QuPathResources.getString("Action.BioFormats.pyramidalDownsampleDetail") + ) + .addIntParameter( + "tileSize", + QuPathResources.getString("Action.BioFormats.tileSize"), + tileSize.getValue(), + "px", + QuPathResources.getString("Action.BioFormats.tileSizeDetail") + ) + .addBooleanParameter( + "allZ", + QuPathResources.getString("Action.BioFormats.allZSlices"), + allZ.getValue(), + QuPathResources.getString("Action.BioFormats.allZSlicesDetail") + ) + .addBooleanParameter( + "allT", + QuPathResources.getString("Action.BioFormats.allTimepoints"), + allT.getValue(), + QuPathResources.getString("Action.BioFormats.allTimepointsDetail") + ) + .addIntParameter( + "numberOfThreads", + QuPathResources.getString("Action.BioFormats.numberOfThreads"), + numberOfThreads.getValue(), + "", + 1, + Runtime.getRuntime().availableProcessors(), + QuPathResources.getString("Action.BioFormats.numberOfThreadsDetail") + ); + + parameters.setHiddenParameters(metadata.getSizeZ() == 1, "allZ"); + parameters.setHiddenParameters(metadata.getSizeT() == 1, "allT"); + + return parameters; + } + + private static void updatePreferences(ParameterList parameters) { + scaledDownsample.set(parameters.getIntParameterValue("scaledDownsample")); + tileSize.set(parameters.getIntParameterValue("tileSize")); + allZ.set(parameters.getBooleanParameterValue("allZ")); + allT.set(parameters.getBooleanParameterValue("allT")); + numberOfThreads.set(parameters.getIntParameterValue("numberOfThreads")); + } +} diff --git a/qupath-gui-fx/src/main/resources/qupath/lib/gui/localization/qupath-gui-strings.properties b/qupath-gui-fx/src/main/resources/qupath/lib/gui/localization/qupath-gui-strings.properties index 659df3bb9..3a4f6a339 100644 --- a/qupath-gui-fx/src/main/resources/qupath/lib/gui/localization/qupath-gui-strings.properties +++ b/qupath-gui-fx/src/main/resources/qupath/lib/gui/localization/qupath-gui-strings.properties @@ -644,6 +644,25 @@ Extension.BioFormats.description = Installs support to read and write images usi Extension.BioFormats.missing.description = Cannot find the Bio-Formats library required by this extension! Action.BioFormats.exportOmeTif = OME TIFF Action.BioFormats.exportOmeTif.description = Write regions as OME-TIFF images. This supports writing image pyramids. +Action.BioFormats.omeZarr = OME Zarr +Action.BioFormats.omeZarrWriter = OME Zarr writer +Action.BioFormats.stopCurrentExport = Do you want to stop the current export? +Action.BioFormats.exportOMEZarr = Export OME-Zarr +Action.BioFormats.writeOMEZarr = Write OME-Zarr +Action.BioFormats.invalidZarrExtension = The extension of the file must be .ome.zarr +Action.BioFormats.directoryAlreadyExists = The directory must not already exist +Action.BioFormats.exportingTo = Exporting to {0} -\nPlease keep QuPath running until export is complete! +Action.BioFormats.exportComplete = {0} export complete! +Action.BioFormats.pyramidalDownsample = Pyramidal downsample +Action.BioFormats.pyramidalDownsampleDetail = Amount to downsample each consecutive pyramidal level; use 1 to indicate the image should not be pyramidal +Action.BioFormats.tileSize = Tile size +Action.BioFormats.tileSizeDetail = Tile width and height for export +Action.BioFormats.allZSlices = All z-slices +Action.BioFormats.allZSlicesDetail = Include all z-slices in the stack. If unchecked, only the current z-slice is considered +Action.BioFormats.allTimepoints = All timepoints +Action.BioFormats.allTimepointsDetail = Include all timepoints in the time-series. If unchecked, only the current timepoint is considered +Action.BioFormats.numberOfThreads = Number of threads +Action.BioFormats.numberOfThreadsDetail = Number of threads to use for export From b35980d5bca8e4e6d02b379aae4765e906cba6bb Mon Sep 17 00:00:00 2001 From: Leo Leplat <60394504+Rylern@users.noreply.github.com> Date: Thu, 19 Sep 2024 15:53:21 +0100 Subject: [PATCH 320/866] Cleaned code --- .../ome/zarr/OMEZarrWriterCommand.java | 96 +++++++++++-------- 1 file changed, 55 insertions(+), 41 deletions(-) diff --git a/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriterCommand.java b/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriterCommand.java index 3dccc6508..6795ddbe5 100644 --- a/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriterCommand.java +++ b/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriterCommand.java @@ -83,51 +83,12 @@ public void run() { } updatePreferences(parameters); - File fileOutput = FileChoosers.promptToSaveFile( - QuPathResources.getString("Action.BioFormats.writeOMEZarr"), - null, - FileChoosers.createExtensionFilter(QuPathResources.getString("Action.BioFormats.omeZarr"), ".ome.zarr") - ); + File fileOutput = promptOutputDirectory(); if (fileOutput == null) { return; } - if (!fileOutput.getAbsolutePath().endsWith(".ome.zarr")) { - Dialogs.showErrorMessage( - QuPathResources.getString("Action.BioFormats.omeZarrWriter"), - QuPathResources.getString("Action.BioFormats.invalidZarrExtension") - ); - return; - } - if (fileOutput.exists()) { - Dialogs.showErrorMessage( - QuPathResources.getString("Action.BioFormats.omeZarrWriter"), - QuPathResources.getString("Action.BioFormats.directoryAlreadyExists") - ); - return; - } - OMEZarrWriter.Builder builder = new OMEZarrWriter.Builder(imageData.getServer(), fileOutput.getAbsolutePath()) - .setNumberOfThreads(parameters.getIntParameterValue("numberOfThreads")) - .setTileWidth(parameters.getIntParameterValue("tileSize")) - .setTileHeight(parameters.getIntParameterValue("tileSize")) - .setDownsamples(DoubleStream.iterate( - 1, - d -> (int) (imageData.getServer().getWidth() / d) > parameters.getIntParameterValue("tileSize") && - (int) (imageData.getServer().getHeight() / d) > parameters.getIntParameterValue("tileSize"), - d -> d * parameters.getIntParameterValue("scaledDownsample")).toArray() - ); - - if (!parameters.getBooleanParameterValue("allZ")) { - builder.setZSlices(qupath.getViewer().getZPosition(), qupath.getViewer().getZPosition()+1); - } - if (!parameters.getBooleanParameterValue("allT")) { - builder.setTimepoints(qupath.getViewer().getTPosition(), qupath.getViewer().getTPosition()+1); - } - - PathObject selected = imageData.getHierarchy().getSelectionModel().getSelectedObject(); - if (selected != null && selected.hasROI() && selected.getROI().isArea()) { - builder.setBoundingBox(ImageRegion.createInstance(selected.getROI())); - } + OMEZarrWriter.Builder builder = createBuilder(parameters, imageData, fileOutput); task = executor.submit(() -> { try (OMEZarrWriter writer = builder.build()) { @@ -210,4 +171,57 @@ private static void updatePreferences(ParameterList parameters) { allT.set(parameters.getBooleanParameterValue("allT")); numberOfThreads.set(parameters.getIntParameterValue("numberOfThreads")); } + + private static File promptOutputDirectory() { + File fileOutput = FileChoosers.promptToSaveFile( + QuPathResources.getString("Action.BioFormats.writeOMEZarr"), + null, + FileChoosers.createExtensionFilter(QuPathResources.getString("Action.BioFormats.omeZarr"), ".ome.zarr") + ); + + if (fileOutput == null) { + return null; + } else if (!fileOutput.getAbsolutePath().endsWith(".ome.zarr")) { + Dialogs.showErrorMessage( + QuPathResources.getString("Action.BioFormats.omeZarrWriter"), + QuPathResources.getString("Action.BioFormats.invalidZarrExtension") + ); + return null; + } else if (fileOutput.exists()) { + Dialogs.showErrorMessage( + QuPathResources.getString("Action.BioFormats.omeZarrWriter"), + QuPathResources.getString("Action.BioFormats.directoryAlreadyExists") + ); + return null; + } else { + return fileOutput; + } + } + + private OMEZarrWriter.Builder createBuilder(ParameterList parameters, ImageData imageData, File fileOutput) { + OMEZarrWriter.Builder builder = new OMEZarrWriter.Builder(imageData.getServer(), fileOutput.getAbsolutePath()) + .setNumberOfThreads(parameters.getIntParameterValue("numberOfThreads")) + .setTileWidth(parameters.getIntParameterValue("tileSize")) + .setTileHeight(parameters.getIntParameterValue("tileSize")) + .setDownsamples(DoubleStream.iterate( + 1, + d -> (int) (imageData.getServer().getWidth() / d) > parameters.getIntParameterValue("tileSize") && + (int) (imageData.getServer().getHeight() / d) > parameters.getIntParameterValue("tileSize"), + d -> d * parameters.getIntParameterValue("scaledDownsample")).toArray() + ); + + if (!parameters.getBooleanParameterValue("allZ")) { + builder.setZSlices(qupath.getViewer().getZPosition(), qupath.getViewer().getZPosition()+1); + } + if (!parameters.getBooleanParameterValue("allT")) { + builder.setTimepoints(qupath.getViewer().getTPosition(), qupath.getViewer().getTPosition()+1); + } + + PathObject selected = imageData.getHierarchy().getSelectionModel().getSelectedObject(); + if (selected != null && selected.hasROI() && selected.getROI().isArea()) { + builder.setBoundingBox(ImageRegion.createInstance(selected.getROI())); + } + + return builder; + } } From 2b26d9a1c1dd80338137cd0c656b79747b7cea2e Mon Sep 17 00:00:00 2001 From: lleplat Date: Fri, 20 Sep 2024 09:14:26 +0100 Subject: [PATCH 321/866] Typo --- .../lib/images/writers/ome/zarr/OMEZarrWriterCommand.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriterCommand.java b/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriterCommand.java index 6795ddbe5..ca187e8e8 100644 --- a/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriterCommand.java +++ b/qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/zarr/OMEZarrWriterCommand.java @@ -26,7 +26,7 @@ import java.util.stream.DoubleStream; /** - * A GUI command to export the current ImageServer to an OME Zarr file + * A GUI command to export the current ImageServer to an OME Zarr directory. */ public class OMEZarrWriterCommand implements Runnable { From 5c9cce29f38fe4dc17f6427145377d46e2ed6179 Mon Sep 17 00:00:00 2001 From: Pete Date: Fri, 20 Sep 2024 11:29:56 +0100 Subject: [PATCH 322/866] Update changelog, naming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * OME Zarr → OME-Zarr and similar for TIFF (following OME publications) * Include .ome.zarr as a default extension in `GeneralTools` --- CHANGELOG.md | 2 ++ .../src/main/java/qupath/lib/common/GeneralTools.java | 2 +- .../src/test/java/qupath/lib/common/TestGeneralTools.java | 2 +- .../servers/bioformats/BioFormatsOptionsExtension.java | 4 ++-- .../lib/gui/localization/qupath-gui-strings.properties | 6 +++--- 5 files changed, 9 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7561dd154..9bb71547d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ Some things may be added, some things may be removed, and some things may look d ### Major features * Support for InstanSeg segmentation (https://github.com/instanseg/instanseg) * Read and write OME-Zarr images (https://github.com/qupath/qupath/pull/1474) + * Use `convert-ome` to write OME-Zarr from a command line + * Use *File → Export images... → OME-Zarr* to export images from the user interface ### Enhancements (These are not yet ordered by interestingness) diff --git a/qupath-core/src/main/java/qupath/lib/common/GeneralTools.java b/qupath-core/src/main/java/qupath/lib/common/GeneralTools.java index 925cbe98f..4ee4bd694 100644 --- a/qupath-core/src/main/java/qupath/lib/common/GeneralTools.java +++ b/qupath-core/src/main/java/qupath/lib/common/GeneralTools.java @@ -182,7 +182,7 @@ private GeneralTools() { private static List DEFAULT_EXTENSIONS = Arrays.asList( - ".ome.tif", ".ome.tiff", ".tar.gz" + ".ome.tif", ".ome.tiff", ".tar.gz", ".ome.zarr" ); /** diff --git a/qupath-core/src/test/java/qupath/lib/common/TestGeneralTools.java b/qupath-core/src/test/java/qupath/lib/common/TestGeneralTools.java index de96bf12a..ca2979fa1 100644 --- a/qupath-core/src/test/java/qupath/lib/common/TestGeneralTools.java +++ b/qupath-core/src/test/java/qupath/lib/common/TestGeneralTools.java @@ -76,7 +76,7 @@ public void test_fileExtensions() { assertNull(GeneralTools.getExtension(noExt).orElse(null)); String baseName = "anything a all. here or there"; - for (String ext : Arrays.asList(".ext", ".tif", ".ome.tiff", ".tar.gz", ".ome.tif")) { + for (String ext : Arrays.asList(".ext", ".tif", ".ome.tiff", ".tar.gz", ".ome.tif", ".ome.zarr")) { File file = new File(baseName + ext); String parsed = GeneralTools.getExtension(file).orElse(null); assertEquals(ext, parsed); diff --git a/qupath-extension-bioformats/src/main/java/qupath/lib/images/servers/bioformats/BioFormatsOptionsExtension.java b/qupath-extension-bioformats/src/main/java/qupath/lib/images/servers/bioformats/BioFormatsOptionsExtension.java index 374dcf2a2..51f13bdf2 100644 --- a/qupath-extension-bioformats/src/main/java/qupath/lib/images/servers/bioformats/BioFormatsOptionsExtension.java +++ b/qupath-extension-bioformats/src/main/java/qupath/lib/images/servers/bioformats/BioFormatsOptionsExtension.java @@ -194,7 +194,7 @@ public static class OmeTiffWriterAction { public final Action actionWriter; OmeTiffWriterAction(QuPathGUI qupath) { - actionWriter = ActionTools.createAction(new OMEPyramidWriterCommand(qupath), "OME TIFF"); + actionWriter = ActionTools.createAction(new OMEPyramidWriterCommand(qupath), "OME-TIFF"); } } @@ -205,7 +205,7 @@ public static class OmeZarrWriterAction { public final Action actionWriter; public OmeZarrWriterAction(QuPathGUI qupath) { - actionWriter = ActionTools.createAction(new OMEZarrWriterCommand(qupath), "OME Zarr"); + actionWriter = ActionTools.createAction(new OMEZarrWriterCommand(qupath), "OME-Zarr"); } } } diff --git a/qupath-gui-fx/src/main/resources/qupath/lib/gui/localization/qupath-gui-strings.properties b/qupath-gui-fx/src/main/resources/qupath/lib/gui/localization/qupath-gui-strings.properties index 3a4f6a339..256bfa743 100644 --- a/qupath-gui-fx/src/main/resources/qupath/lib/gui/localization/qupath-gui-strings.properties +++ b/qupath-gui-fx/src/main/resources/qupath/lib/gui/localization/qupath-gui-strings.properties @@ -642,10 +642,10 @@ Action.SVG.exportSnapshot.description = Export an RGB snapshot of the current vi Extension.BioFormats = Bio-Formats extension Extension.BioFormats.description = Installs support to read and write images using Bio-Formats reading and writing options using Bio-Formats (https://www.openmicroscopy.org/bio-formats/) Extension.BioFormats.missing.description = Cannot find the Bio-Formats library required by this extension! -Action.BioFormats.exportOmeTif = OME TIFF +Action.BioFormats.exportOmeTif = OME-TIFF Action.BioFormats.exportOmeTif.description = Write regions as OME-TIFF images. This supports writing image pyramids. -Action.BioFormats.omeZarr = OME Zarr -Action.BioFormats.omeZarrWriter = OME Zarr writer +Action.BioFormats.omeZarr = OME-Zarr +Action.BioFormats.omeZarrWriter = OME-Zarr writer Action.BioFormats.stopCurrentExport = Do you want to stop the current export? Action.BioFormats.exportOMEZarr = Export OME-Zarr Action.BioFormats.writeOMEZarr = Write OME-Zarr From 9da111b8c1c0e4d4b2001fbbff593d8f7e7fe2aa Mon Sep 17 00:00:00 2001 From: Pete Date: Fri, 20 Sep 2024 11:51:19 +0100 Subject: [PATCH 323/866] Support message for TaskRunner This makes it possible to have more informative messages during processing, so that the user knows what is happening along the way (e.g. 'Detecting', 'Processing', 'Measuring'). PixelProcessor updated to use this to record the number of tiles being processed. --- .../lib/experimental/pixels/PixelProcessor.java | 5 +++-- .../qupath/lib/plugins/AbstractTaskRunner.java | 4 ++-- .../main/java/qupath/lib/plugins/TaskRunner.java | 15 +++++++++++++-- .../main/java/qupath/lib/gui/TaskRunnerFX.java | 6 +++--- 4 files changed, 21 insertions(+), 9 deletions(-) diff --git a/qupath-core-processing/src/main/java/qupath/lib/experimental/pixels/PixelProcessor.java b/qupath-core-processing/src/main/java/qupath/lib/experimental/pixels/PixelProcessor.java index 6f75600dd..06d14bfc2 100644 --- a/qupath-core-processing/src/main/java/qupath/lib/experimental/pixels/PixelProcessor.java +++ b/qupath-core-processing/src/main/java/qupath/lib/experimental/pixels/PixelProcessor.java @@ -185,7 +185,8 @@ private void processTiled(TaskRunner runner, Tiler tiler, ImageData tasks) { + public synchronized void runTasks(String message, Collection tasks) { if (tasks.isEmpty()) return; @@ -101,7 +101,7 @@ public synchronized void runTasks(Collection tasks) { service = new ExecutorCompletionService<>(pool); monitor = makeProgressMonitor(); - monitor.startMonitoring(null, tasks.size(), true); + monitor.startMonitoring(message, tasks.size(), true); for (Runnable task : tasks) { // If a task if null, then skip it - otherwise the monitor can get stuck if (task == null) { diff --git a/qupath-core/src/main/java/qupath/lib/plugins/TaskRunner.java b/qupath-core/src/main/java/qupath/lib/plugins/TaskRunner.java index 27f5a371b..dda7f5dd9 100644 --- a/qupath-core/src/main/java/qupath/lib/plugins/TaskRunner.java +++ b/qupath-core/src/main/java/qupath/lib/plugins/TaskRunner.java @@ -4,7 +4,7 @@ * %% * Copyright (C) 2014 - 2016 The Queen's University of Belfast, Northern Ireland * Contact: IP Management (ipmanagement@qub.ac.uk) - * Copyright (C) 2018 - 2023 QuPath developers, The University of Edinburgh + * Copyright (C) 2018 - 2024 QuPath developers, The University of Edinburgh * %% * QuPath is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as @@ -42,6 +42,17 @@ public interface TaskRunner { * @param tasks the tasks to run. If these are instances of {@link PathTask} then * an optional postprocessing may be applied after all tasks are complete. */ - void runTasks(Collection tasks); + default void runTasks(Collection tasks) { + runTasks(null, tasks); + } + + /** + * Pass a collection of parallelizable tasks to run. + * @param message optional message to display to the user when running tasks; may be null + * @param tasks the tasks to run. If these are instances of {@link PathTask} then + * an optional postprocessing may be applied after all tasks are complete. + * @since v0.6.0 + */ + void runTasks(String message, Collection tasks); } \ No newline at end of file diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/TaskRunnerFX.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/TaskRunnerFX.java index 9d851554b..d63b58241 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/TaskRunnerFX.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/TaskRunnerFX.java @@ -96,12 +96,12 @@ public SimpleProgressMonitor makeProgressMonitor() { } @Override - public synchronized void runTasks(Collection tasks) { + public synchronized void runTasks(String message, Collection tasks) { var viewer = qupath == null || repaintDelayMillis <= 0 ? null : qupath.getViewer(); if (viewer != null) viewer.setMinimumRepaintSpacingMillis(repaintDelayMillis); try { - super.runTasks(tasks); + super.runTasks(message, tasks); } catch (Exception e) { throw(e); } finally { @@ -319,7 +319,7 @@ private void updateDialog() { int progressPercent = (int)Math.round((double)progressValue / maxProgress * 100.0); // Update the display // Don't update the label if cancel was pressed, since this is probably already giving a more informative message - if (!cancelPressed) + if (!cancelPressed && STARTING_MESSAGE.equals(progressDialog.getDialogPane().getHeaderText())) progressDialog.getDialogPane().setHeaderText(RUNNING_MESSAGE); if (lastMessage == null) From 7bad8bee377cea250b6815085aec7d4d51df7723 Mon Sep 17 00:00:00 2001 From: Pete Date: Fri, 20 Sep 2024 13:02:43 +0100 Subject: [PATCH 324/866] Prepare for v0.6.0-rc2 --- qupath-app/src/main/resources/VERSION | 2 +- qupath-core-processing/src/main/resources/VERSION | 2 +- qupath-core/src/main/resources/VERSION | 2 +- qupath-extension-bioformats/src/main/resources/VERSION | 2 +- qupath-extension-openslide/src/main/resources/VERSION | 2 +- qupath-extension-processing/src/main/resources/VERSION | 2 +- .../src/main/resources/VERSION | 2 +- qupath-extension-svg/src/main/resources/VERSION | 2 +- qupath-gui-fx/src/main/resources/VERSION | 2 +- settings.gradle | 9 ++++----- 10 files changed, 13 insertions(+), 14 deletions(-) diff --git a/qupath-app/src/main/resources/VERSION b/qupath-app/src/main/resources/VERSION index e75e24346..673adfb9f 100644 --- a/qupath-app/src/main/resources/VERSION +++ b/qupath-app/src/main/resources/VERSION @@ -1 +1 @@ -0.6.0-SNAPSHOT \ No newline at end of file +0.6.0-rc2 \ No newline at end of file diff --git a/qupath-core-processing/src/main/resources/VERSION b/qupath-core-processing/src/main/resources/VERSION index e75e24346..673adfb9f 100644 --- a/qupath-core-processing/src/main/resources/VERSION +++ b/qupath-core-processing/src/main/resources/VERSION @@ -1 +1 @@ -0.6.0-SNAPSHOT \ No newline at end of file +0.6.0-rc2 \ No newline at end of file diff --git a/qupath-core/src/main/resources/VERSION b/qupath-core/src/main/resources/VERSION index e75e24346..673adfb9f 100644 --- a/qupath-core/src/main/resources/VERSION +++ b/qupath-core/src/main/resources/VERSION @@ -1 +1 @@ -0.6.0-SNAPSHOT \ No newline at end of file +0.6.0-rc2 \ No newline at end of file diff --git a/qupath-extension-bioformats/src/main/resources/VERSION b/qupath-extension-bioformats/src/main/resources/VERSION index e75e24346..673adfb9f 100644 --- a/qupath-extension-bioformats/src/main/resources/VERSION +++ b/qupath-extension-bioformats/src/main/resources/VERSION @@ -1 +1 @@ -0.6.0-SNAPSHOT \ No newline at end of file +0.6.0-rc2 \ No newline at end of file diff --git a/qupath-extension-openslide/src/main/resources/VERSION b/qupath-extension-openslide/src/main/resources/VERSION index e75e24346..673adfb9f 100644 --- a/qupath-extension-openslide/src/main/resources/VERSION +++ b/qupath-extension-openslide/src/main/resources/VERSION @@ -1 +1 @@ -0.6.0-SNAPSHOT \ No newline at end of file +0.6.0-rc2 \ No newline at end of file diff --git a/qupath-extension-processing/src/main/resources/VERSION b/qupath-extension-processing/src/main/resources/VERSION index e75e24346..673adfb9f 100644 --- a/qupath-extension-processing/src/main/resources/VERSION +++ b/qupath-extension-processing/src/main/resources/VERSION @@ -1 +1 @@ -0.6.0-SNAPSHOT \ No newline at end of file +0.6.0-rc2 \ No newline at end of file diff --git a/qupath-extension-script-editor/src/main/resources/VERSION b/qupath-extension-script-editor/src/main/resources/VERSION index e75e24346..673adfb9f 100644 --- a/qupath-extension-script-editor/src/main/resources/VERSION +++ b/qupath-extension-script-editor/src/main/resources/VERSION @@ -1 +1 @@ -0.6.0-SNAPSHOT \ No newline at end of file +0.6.0-rc2 \ No newline at end of file diff --git a/qupath-extension-svg/src/main/resources/VERSION b/qupath-extension-svg/src/main/resources/VERSION index e75e24346..673adfb9f 100644 --- a/qupath-extension-svg/src/main/resources/VERSION +++ b/qupath-extension-svg/src/main/resources/VERSION @@ -1 +1 @@ -0.6.0-SNAPSHOT \ No newline at end of file +0.6.0-rc2 \ No newline at end of file diff --git a/qupath-gui-fx/src/main/resources/VERSION b/qupath-gui-fx/src/main/resources/VERSION index e75e24346..673adfb9f 100644 --- a/qupath-gui-fx/src/main/resources/VERSION +++ b/qupath-gui-fx/src/main/resources/VERSION @@ -1 +1 @@ -0.6.0-SNAPSHOT \ No newline at end of file +0.6.0-rc2 \ No newline at end of file diff --git a/settings.gradle b/settings.gradle index ed601c26c..a7001c2e8 100644 --- a/settings.gradle +++ b/settings.gradle @@ -2,7 +2,7 @@ plugins { id 'org.gradle.toolchains.foojay-resolver-convention' version '0.7.0' // to download if needed } -gradle.ext.qupathVersion = "0.6.0-SNAPSHOT" +gradle.ext.qupathVersion = "0.6.0-rc2" rootProject.name = 'qupath' @@ -39,11 +39,10 @@ dependencyResolutionManagement { // This can be useful to make custom QuPath builds with specific versions of extensions extraLibs { library('djl', 'io.github.qupath', 'qupath-extension-djl').version('0.4.0-20240911.172830-2') - library('instanseg', 'io.github.qupath', 'qupath-extension-instanseg').version('0.0.1-20240911.172453-2') - + library('instanseg', 'io.github.qupath', 'qupath-extension-instanseg').version('0.0.1-20240920.115536-3') // Include or exclude bundled extensions - bundle('extensions', []) -// bundle('extensions', ['djl', 'instanseg']) +// bundle('extensions', []) + bundle('extensions', ['djl', 'instanseg']) } } } From b68d495cf1552bfcce751445b4ee2e5472b623ef Mon Sep 17 00:00:00 2001 From: Leo Leplat <60394504+Rylern@users.noreply.github.com> Date: Tue, 24 Sep 2024 10:45:17 +0100 Subject: [PATCH 325/866] Set BioFormats local only pref false by default --- .../lib/images/servers/bioformats/BioFormatsServerOptions.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qupath-extension-bioformats/src/main/java/qupath/lib/images/servers/bioformats/BioFormatsServerOptions.java b/qupath-extension-bioformats/src/main/java/qupath/lib/images/servers/bioformats/BioFormatsServerOptions.java index 44c243d43..6088b9dbd 100644 --- a/qupath-extension-bioformats/src/main/java/qupath/lib/images/servers/bioformats/BioFormatsServerOptions.java +++ b/qupath-extension-bioformats/src/main/java/qupath/lib/images/servers/bioformats/BioFormatsServerOptions.java @@ -116,7 +116,7 @@ enum UseBioformats { private Set useExtensions = new TreeSet<>(); private boolean requestParallelization = true; - private boolean filesOnly = true; + private boolean filesOnly = false; private int memoizationTimeMillis = -1; // private boolean requestParallelizeMultichannel = false; private String pathMemoization; From f9bba00968c95a94890448ed0051a6ba2ae4f77e Mon Sep 17 00:00:00 2001 From: Alan O'Callaghan Date: Wed, 25 Sep 2024 13:28:25 +0100 Subject: [PATCH 326/866] Only update grid view on FX thread and fix padding --- .../lib/gui/commands/PathObjectGridView.java | 60 +++++++++---------- 1 file changed, 28 insertions(+), 32 deletions(-) diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/PathObjectGridView.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/PathObjectGridView.java index ba8f200fd..e41836f12 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/PathObjectGridView.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/PathObjectGridView.java @@ -505,7 +505,7 @@ private void sortAndFilter() { // pathclass is present and selected, or missing and we're showing unclassifier && (selectedClasses.contains(p.getPathClass()) || (p.getPathClass() == null && selectedClasses.contains(PathClass.NULL_CLASS))) ); - grid.getItems().setAll(filteredList); + Platform.runLater(() -> grid.getItems().setAll(filteredList)); } @@ -584,24 +584,22 @@ private void updateChildren() { } List images = new ArrayList<>(); for (PathObject pathObject : list) { - Label viewNode = nodeMap.get(pathObject); - if (viewNode == null) { - + Label viewNode = nodeMap.computeIfAbsent(pathObject, po -> { var painter = PathObjectImageManagers.createImageViewPainter( - qupath.getViewer(), imageDataProperty.get().getServer(), true, - ForkJoinPool.commonPool()); + qupath.getViewer(), imageDataProperty.get().getServer(), true, + ForkJoinPool.commonPool()); var imageView = painter.getNode(); imageView.fitWidthProperty().bind(imageSize); imageView.fitHeightProperty().bind(imageSize); - - painter.setPathObject(pathObject); - - viewNode = new Label("", imageView); - StackPane.setAlignment(viewNode, Pos.TOP_LEFT); - - Tooltip.install(viewNode, new Tooltip(pathObject.getName())); - viewNode.setOnMouseClicked(e -> { + + painter.setPathObject(po); + + var out = new Label("", imageView); + StackPane.setAlignment(out, Pos.TOP_LEFT); + + Tooltip.install(out, new Tooltip(pathObject.getName())); + out.setOnMouseClicked(e -> { var imageData = imageDataProperty.get(); if (imageData != null) { imageData.getHierarchy().getSelectionModel().setSelectedObject(pathObject); @@ -612,10 +610,8 @@ private void updateChildren() { } } }); - - viewNode.setEffect(new DropShadow(8, -2, 2, Color.GRAY)); - nodeMap.put(pathObject, viewNode); - } + return out; + }); images.add(viewNode); } updateMeasurementText(); @@ -655,22 +651,23 @@ protected void layoutChildren() { } int padding = 5; - int dx = imageSize.get() + padding; - int w = Math.max(dx, (int)getWidth()); - int nx = (int)Math.floor(w / dx); + int dxy = imageSize.get() + padding; + int w = Math.max(dxy, (int)getWidth()); + int nx = (int) (double) (w / dxy); nx = Math.max(1, nx); - int spaceX = (int)((w - (dx) * nx) / (nx)); // Space to divide equally + int spaceX = (w - dxy * nx) / nx; // Space to divide equally int x = spaceX/2; int y = padding; - + + double h = imageSize.get(); for (Node node : getChildren()) { - if (x + dx > w) { + if (node instanceof Label label) { + h = label.getHeight(); + } + if (x + dxy > w) { x = spaceX/2; - if (node instanceof Label label) - y += label.getHeight() + spaceX + 2; - else - y += imageSize.get() + spaceX + 2; + y += (int) (h + spaceX + 2); } if (doAnimate.get()) { @@ -701,11 +698,10 @@ protected void layoutChildren() { node.setTranslateX(x); node.setTranslateY(y); } - x += (dx + spaceX); + x += dxy + spaceX; } - - setHeight(y + dx); - setPrefHeight(y + dx); + setHeight(y + h + padding); + setPrefHeight(y + h + padding); } } From 91730f4a7668297b537848bcb1b1114f80b00095 Mon Sep 17 00:00:00 2001 From: Alan O'Callaghan Date: Wed, 25 Sep 2024 13:29:14 +0100 Subject: [PATCH 327/866] Linting --- .../lib/gui/commands/PathObjectGridView.java | 31 ++++++------------- 1 file changed, 9 insertions(+), 22 deletions(-) diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/PathObjectGridView.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/PathObjectGridView.java index e41836f12..55d5a90c9 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/PathObjectGridView.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/PathObjectGridView.java @@ -55,10 +55,8 @@ import javafx.scene.control.Separator; import javafx.scene.control.ToolBar; import javafx.scene.control.Tooltip; -import javafx.scene.effect.DropShadow; import javafx.scene.layout.BorderPane; import javafx.scene.layout.StackPane; -import javafx.scene.paint.Color; import javafx.scene.text.Text; import javafx.stage.Stage; import javafx.util.Duration; @@ -81,14 +79,12 @@ import java.awt.image.BufferedImage; import java.util.ArrayList; -import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.Comparator; import java.util.List; import java.util.Map.Entry; import java.util.Objects; -import java.util.Set; import java.util.WeakHashMap; import java.util.concurrent.ForkJoinPool; import java.util.function.Function; @@ -138,8 +134,8 @@ public static enum GridDisplaySize { MEDIUM("Medium", 200), LARGE("Large", 300); - private String name; - private int size; + private final String name; + private final int size; GridDisplaySize(final String name, final int size) { this.name = name; @@ -155,7 +151,7 @@ public int getSize() { return size; } - }; + } private PathObjectGridView(final QuPathGUI qupath, final Function> extractor) { @@ -169,7 +165,6 @@ private PathObjectGridView(final QuPathGUI qupath, final Function> objectExtractor) { return new PathObjectGridView(qupath, objectExtractor); @@ -177,8 +172,6 @@ public static PathObjectGridView createGridView(QuPathGUI qupath, Function list = FXCollections.observableArrayList(); - private WeakHashMap translationMap = new WeakHashMap<>(); - private WeakHashMap nodeMap = new WeakHashMap<>(); + private final ObservableList list = FXCollections.observableArrayList(); + private final WeakHashMap translationMap = new WeakHashMap<>(); + private final WeakHashMap nodeMap = new WeakHashMap<>(); - private IntegerProperty imageSize = new SimpleIntegerProperty(); + private final IntegerProperty imageSize = new SimpleIntegerProperty(); - private Text textEmpty = createPlaceholderText(QuPathResources.getString("GridView.noObjectsAvailable")); + private final Text textEmpty = createPlaceholderText(QuPathResources.getString("GridView.noObjectsAvailable")); QuPathGridView() { - imageSize.addListener(v -> { - updateChildren(); - }); + imageSize.addListener(v -> updateChildren()); list.addListener((ListChangeListener) c -> updateChildren()); updateChildren(); StackPane.setAlignment(textEmpty, Pos.CENTER); From 05e404ae6cf3c35ed02e057bd96365078132a155 Mon Sep 17 00:00:00 2001 From: Pete Date: Wed, 25 Sep 2024 17:07:18 +0100 Subject: [PATCH 328/866] Update CHANGELOG.md --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bb71547d..073d9772b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,8 @@ Some things may be added, some things may be removed, and some things may look d * Temporarily active 'Selection mode' by pressing the `S` key while interacting with a viewer * More viewer options are persistent (e.g. show/hide the overview thumbnail, location text, or scalebar) * Better support for symbolic links (https://github.com/qupath/qupath/issues/1586) +* Bio-Formats preference to open remote images is now turned on by default (https://github.com/qupath/qupath/pull/1653) + * This is needed to open remote ome.zarr images - but can be turned off in the preferences if necessary ### Experimental features These features are included for testing and feedback. From 38574f3ea303233b9f900a08d18d66fa4c2d4262 Mon Sep 17 00:00:00 2001 From: Pete Date: Wed, 25 Sep 2024 17:29:18 +0100 Subject: [PATCH 329/866] Dependency updates JavaFX, commonmark & guava - also revert version to 0.6.0-SNAPSHOT --- CHANGELOG.md | 6 +++--- gradle/libs.versions.toml | 6 +++--- qupath-app/src/main/resources/VERSION | 2 +- qupath-core-processing/src/main/resources/VERSION | 2 +- qupath-core/src/main/resources/VERSION | 2 +- qupath-extension-bioformats/src/main/resources/VERSION | 2 +- qupath-extension-openslide/src/main/resources/VERSION | 2 +- qupath-extension-processing/src/main/resources/VERSION | 2 +- qupath-extension-script-editor/src/main/resources/VERSION | 2 +- qupath-extension-svg/src/main/resources/VERSION | 2 +- qupath-gui-fx/src/main/resources/VERSION | 2 +- settings.gradle | 6 +++--- 12 files changed, 18 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 073d9772b..c78254199 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -97,12 +97,12 @@ They may change or be removed in future versions. ### Dependency updates * Bio-Formats 7.3.1 -* Commonmark 0.22.0 +* Commonmark 0.23.0 * DeepJavaLibrary 0.30.0 * Groovy 4.0.22 * Gson 2.11.0 -* Guava 33.3.0-jre -* JavaFX 22.0.2 +* Guava 33.3.1-jre +* JavaFX 23 * Java Topology Suite 1.20.0 * JFreeSVG 5.0.6 * JNA 5.14.0 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ae83b6d94..93b3eeca8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,7 +5,7 @@ bioimageIoSpec = "0.1.0" omeZarrReader = "0.5.2" blosc = "1.21.5" -commonmark = "0.22.0" +commonmark = "0.23.0" commonsMath3 = "3.6.1" commonsText = "1.10.0" controlsFX = "11.1.2" @@ -14,7 +14,7 @@ deepJavaLibrary = "0.30.0" groovy = "4.0.22" gson = "2.11.0" -guava = "33.3.0-jre" +guava = "33.3.1-jre" ikonli = "12.3.1" imagej = "1.54f" @@ -28,7 +28,7 @@ opencv = "4.9.0-1.5.10" cuda = "12.3-8.9-1.5.10" # Warning! JavaFX 20.0.1 and later seem to break search links in Javadocs -javafx = "22.0.2" +javafx = "23" jna = "5.14.0" jfreeSvg = "5.0.6" jfxtras = "17-r1" diff --git a/qupath-app/src/main/resources/VERSION b/qupath-app/src/main/resources/VERSION index 673adfb9f..e75e24346 100644 --- a/qupath-app/src/main/resources/VERSION +++ b/qupath-app/src/main/resources/VERSION @@ -1 +1 @@ -0.6.0-rc2 \ No newline at end of file +0.6.0-SNAPSHOT \ No newline at end of file diff --git a/qupath-core-processing/src/main/resources/VERSION b/qupath-core-processing/src/main/resources/VERSION index 673adfb9f..e75e24346 100644 --- a/qupath-core-processing/src/main/resources/VERSION +++ b/qupath-core-processing/src/main/resources/VERSION @@ -1 +1 @@ -0.6.0-rc2 \ No newline at end of file +0.6.0-SNAPSHOT \ No newline at end of file diff --git a/qupath-core/src/main/resources/VERSION b/qupath-core/src/main/resources/VERSION index 673adfb9f..e75e24346 100644 --- a/qupath-core/src/main/resources/VERSION +++ b/qupath-core/src/main/resources/VERSION @@ -1 +1 @@ -0.6.0-rc2 \ No newline at end of file +0.6.0-SNAPSHOT \ No newline at end of file diff --git a/qupath-extension-bioformats/src/main/resources/VERSION b/qupath-extension-bioformats/src/main/resources/VERSION index 673adfb9f..e75e24346 100644 --- a/qupath-extension-bioformats/src/main/resources/VERSION +++ b/qupath-extension-bioformats/src/main/resources/VERSION @@ -1 +1 @@ -0.6.0-rc2 \ No newline at end of file +0.6.0-SNAPSHOT \ No newline at end of file diff --git a/qupath-extension-openslide/src/main/resources/VERSION b/qupath-extension-openslide/src/main/resources/VERSION index 673adfb9f..e75e24346 100644 --- a/qupath-extension-openslide/src/main/resources/VERSION +++ b/qupath-extension-openslide/src/main/resources/VERSION @@ -1 +1 @@ -0.6.0-rc2 \ No newline at end of file +0.6.0-SNAPSHOT \ No newline at end of file diff --git a/qupath-extension-processing/src/main/resources/VERSION b/qupath-extension-processing/src/main/resources/VERSION index 673adfb9f..e75e24346 100644 --- a/qupath-extension-processing/src/main/resources/VERSION +++ b/qupath-extension-processing/src/main/resources/VERSION @@ -1 +1 @@ -0.6.0-rc2 \ No newline at end of file +0.6.0-SNAPSHOT \ No newline at end of file diff --git a/qupath-extension-script-editor/src/main/resources/VERSION b/qupath-extension-script-editor/src/main/resources/VERSION index 673adfb9f..e75e24346 100644 --- a/qupath-extension-script-editor/src/main/resources/VERSION +++ b/qupath-extension-script-editor/src/main/resources/VERSION @@ -1 +1 @@ -0.6.0-rc2 \ No newline at end of file +0.6.0-SNAPSHOT \ No newline at end of file diff --git a/qupath-extension-svg/src/main/resources/VERSION b/qupath-extension-svg/src/main/resources/VERSION index 673adfb9f..e75e24346 100644 --- a/qupath-extension-svg/src/main/resources/VERSION +++ b/qupath-extension-svg/src/main/resources/VERSION @@ -1 +1 @@ -0.6.0-rc2 \ No newline at end of file +0.6.0-SNAPSHOT \ No newline at end of file diff --git a/qupath-gui-fx/src/main/resources/VERSION b/qupath-gui-fx/src/main/resources/VERSION index 673adfb9f..e75e24346 100644 --- a/qupath-gui-fx/src/main/resources/VERSION +++ b/qupath-gui-fx/src/main/resources/VERSION @@ -1 +1 @@ -0.6.0-rc2 \ No newline at end of file +0.6.0-SNAPSHOT \ No newline at end of file diff --git a/settings.gradle b/settings.gradle index a7001c2e8..6dca624be 100644 --- a/settings.gradle +++ b/settings.gradle @@ -2,7 +2,7 @@ plugins { id 'org.gradle.toolchains.foojay-resolver-convention' version '0.7.0' // to download if needed } -gradle.ext.qupathVersion = "0.6.0-rc2" +gradle.ext.qupathVersion = "0.6.0-SNAPSHOT" rootProject.name = 'qupath' @@ -41,8 +41,8 @@ dependencyResolutionManagement { library('djl', 'io.github.qupath', 'qupath-extension-djl').version('0.4.0-20240911.172830-2') library('instanseg', 'io.github.qupath', 'qupath-extension-instanseg').version('0.0.1-20240920.115536-3') // Include or exclude bundled extensions -// bundle('extensions', []) - bundle('extensions', ['djl', 'instanseg']) + bundle('extensions', []) +// bundle('extensions', ['djl', 'instanseg']) } } } From 5e08fbbd46d86115ca31defbbdc06a7242b48c02 Mon Sep 17 00:00:00 2001 From: Alan O'Callaghan Date: Wed, 25 Sep 2024 18:50:36 +0100 Subject: [PATCH 330/866] Use smart sorting when sorting by name #1657 --- .../java/qupath/lib/gui/commands/PathObjectGridView.java | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/PathObjectGridView.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/PathObjectGridView.java index 55d5a90c9..f11c0802f 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/PathObjectGridView.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/PathObjectGridView.java @@ -249,6 +249,15 @@ public ObjectProperty> imageDataProperty() { private static void sortPathObjects(final ObservableList cores, final ObservableMeasurementTableData model, final String measurementName, final boolean doDescending) { if (measurementName == null) return; + // special case for eg A-1, A-10 for TMA cores + if (measurementName.equals(QuPathResources.getString("GridView.name"))) { + GeneralTools.smartStringSort(cores, PathObject::getDisplayedName); + if (!doDescending) { + Collections.reverse(cores); + } + return; + } + Comparator sorter; if (measurementName.equals(QuPathResources.getString("GridView.classification"))) { From 44236028d4ce17bab425e252be833b4277bf5c81 Mon Sep 17 00:00:00 2001 From: Alan O'Callaghan Date: Wed, 25 Sep 2024 18:51:50 +0100 Subject: [PATCH 331/866] Fix application thread use and extract method --- .../lib/gui/commands/PathObjectGridView.java | 73 ++++++++++--------- 1 file changed, 37 insertions(+), 36 deletions(-) diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/PathObjectGridView.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/PathObjectGridView.java index f11c0802f..377384063 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/PathObjectGridView.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/PathObjectGridView.java @@ -259,17 +259,11 @@ private static void sortPathObjects(final ObservableList c } Comparator sorter; - if (measurementName.equals(QuPathResources.getString("GridView.classification"))) { sorter = (po1, po2) -> { Comparator comp = Comparator.comparing(po -> po.getPathClass() == null ? "Unclassified" : po.getPathClass().toString()); return comp.compare(po1, po2); }; - } else if (measurementName.equals(QuPathResources.getString("GridView.name"))) { - sorter = (po1, po2) -> { - Comparator comp = Comparator.comparing(PathObject::getDisplayedName); - return comp.compare(po1, po2); - }; } else { // if it's a measurement, then we're numeric sorting sorter = (po1, po2) -> { @@ -505,7 +499,12 @@ private void sortAndFilter() { // pathclass is present and selected, or missing and we're showing unclassifier && (selectedClasses.contains(p.getPathClass()) || (p.getPathClass() == null && selectedClasses.contains(PathClass.NULL_CLASS))) ); - Platform.runLater(() -> grid.getItems().setAll(filteredList)); + Runnable r = () -> grid.getItems().setAll(filteredList); + if (Platform.isFxApplicationThread()) { + r.run(); + } else { + Platform.runLater(r); + } } @@ -580,40 +579,42 @@ private void updateChildren() { } List images = new ArrayList<>(); for (PathObject pathObject : list) { - Label viewNode = nodeMap.computeIfAbsent(pathObject, po -> { - var painter = PathObjectImageManagers.createImageViewPainter( - qupath.getViewer(), imageDataProperty.get().getServer(), true, - ForkJoinPool.commonPool()); - - var imageView = painter.getNode(); - imageView.fitWidthProperty().bind(imageSize); - imageView.fitHeightProperty().bind(imageSize); - - painter.setPathObject(po); - - var out = new Label("", imageView); - StackPane.setAlignment(out, Pos.TOP_LEFT); - - Tooltip.install(out, new Tooltip(pathObject.getName())); - out.setOnMouseClicked(e -> { - var imageData = imageDataProperty.get(); - if (imageData != null) { - imageData.getHierarchy().getSelectionModel().setSelectedObject(pathObject); - if (e.getClickCount() > 1 && pathObject.hasROI()) { - ROI roi = pathObject.getROI(); - if (roi != null && qupath.getViewer().getImageData() == imageData) - qupath.getViewer().setCenterPixelLocation(roi.getCentroidX(), roi.getCentroidY()); - } - } - }); - return out; - }); + Label viewNode = nodeMap.computeIfAbsent(pathObject, po -> getLabel(pathObject)); images.add(viewNode); } updateMeasurementText(); getChildren().setAll(images); } - + + private Label getLabel(PathObject pathObject) { + var painter = PathObjectImageManagers.createImageViewPainter( + qupath.getViewer(), imageDataProperty.get().getServer(), true, + ForkJoinPool.commonPool()); + + var imageView = painter.getNode(); + imageView.fitWidthProperty().bind(imageSize); + imageView.fitHeightProperty().bind(imageSize); + + painter.setPathObject(pathObject); + + var out = new Label("", imageView); + StackPane.setAlignment(out, Pos.TOP_LEFT); + + Tooltip.install(out, new Tooltip(pathObject.getName())); + out.setOnMouseClicked(e -> { + var imageData = imageDataProperty.get(); + if (imageData != null) { + imageData.getHierarchy().getSelectionModel().setSelectedObject(pathObject); + if (e.getClickCount() > 1 && pathObject.hasROI()) { + ROI roi = pathObject.getROI(); + if (roi != null && qupath.getViewer().getImageData() == imageData) + qupath.getViewer().setCenterPixelLocation(roi.getCentroidX(), roi.getCentroidY()); + } + } + }); + return out; + } + void updateMeasurementText() { String m = measurement == null ? null : measurement.get(); for (Entry entry : nodeMap.entrySet()) { From dfcc952d6593b2a039f558f53ae0df882e224a32 Mon Sep 17 00:00:00 2001 From: petebankhead Date: Fri, 27 Sep 2024 17:55:46 +0100 Subject: [PATCH 332/866] Fix dark mode bugs - Placeholder for B&C dialog - Measurement MenuButton (in toolbar) when using toolbar overflow - Increment buttons for all scrollbars --- .../display/BrightnessContrastSettingsPane.java | 4 +++- qupath-gui-fx/src/main/resources/css/dark.css | 14 ++++++++++++++ qupath-gui-fx/src/main/resources/css/main.css | 2 +- 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/display/BrightnessContrastSettingsPane.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/display/BrightnessContrastSettingsPane.java index b48392723..c5df8d6d1 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/display/BrightnessContrastSettingsPane.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/display/BrightnessContrastSettingsPane.java @@ -135,7 +135,9 @@ public ImageDisplaySettings fromString(String string) { }); comboSettings.setCellFactory(c -> FXUtils.createCustomListCell(ImageDisplaySettings::getName)); comboSettings.setButtonCell(new SettingListCell(settingsChanged)); - comboSettings.setPlaceholder(new Text("No compatible settings")); + var placeholder = new Text("No compatible settings"); + placeholder.setStyle("-fx-fill: -fx-text-base-color;"); + comboSettings.setPlaceholder(placeholder); resourceManagerProperty.addListener((v, o, n) -> refreshResources()); var btnSave = new Button("Save"); btnSave.setTooltip(new Tooltip("Save the current display settings in the project")); diff --git a/qupath-gui-fx/src/main/resources/css/dark.css b/qupath-gui-fx/src/main/resources/css/dark.css index dc5d32d72..575f80bda 100644 --- a/qupath-gui-fx/src/main/resources/css/dark.css +++ b/qupath-gui-fx/src/main/resources/css/dark.css @@ -86,6 +86,20 @@ -fx-body-color; } +/* Default theme always darkens scrollbar buttons, but we need to lighten */ +.scroll-bar > .increment-button > .increment-arrow, +.scroll-bar > .decrement-button > .decrement-arrow { + -fx-background-color: -fx-mark-highlight-color,derive(-fx-base,45%); +} +.scroll-bar > .increment-button:hover > .increment-arrow, +.scroll-bar > .decrement-button:hover > .decrement-arrow { + -fx-background-color: -fx-mark-highlight-color,derive(-fx-base,50%); +} +.scroll-bar > .increment-button:pressed > .increment-arrow, +.scroll-bar > .decrement-button:pressed > .decrement-arrow { + -fx-background-color: -fx-mark-highlight-color,derive(-fx-base,55%); +} + .table-view, .tree-table-view { -fx-table-header-border-color: -fx-box-border; diff --git a/qupath-gui-fx/src/main/resources/css/main.css b/qupath-gui-fx/src/main/resources/css/main.css index 2c4aa4c3d..06b42f99d 100644 --- a/qupath-gui-fx/src/main/resources/css/main.css +++ b/qupath-gui-fx/src/main/resources/css/main.css @@ -52,7 +52,7 @@ } /* Adapt menu icon colors */ -.menu-item > * > .qupath-icon { +.qupath-icon { -fx-text-fill: -fx-text-base-color; } From 4c6de6a5d04c92ee3242c5ab8fc6e1cd6d51ae52 Mon Sep 17 00:00:00 2001 From: petebankhead Date: Sat, 28 Sep 2024 17:00:39 +0100 Subject: [PATCH 333/866] Change channel names for RGB images Proposed support changing channel names for RGB images. Changing colors is still not allowed. This aims to reduce the inconvenience of QuPath sometimes creating an RGB representation for fluorescence images where it would be desirable to use different channel names. --- .../main/java/qupath/lib/scripting/QP.java | 15 ++++++++---- .../java/qupath/lib/display/ImageDisplay.java | 14 +++++------ .../lib/display/RBGColorTransformInfo.java | 12 ++++++++++ .../BrightnessContrastChannelPane.java | 24 ++++++++++++------- 4 files changed, 46 insertions(+), 19 deletions(-) diff --git a/qupath-core-processing/src/main/java/qupath/lib/scripting/QP.java b/qupath-core-processing/src/main/java/qupath/lib/scripting/QP.java index 117d5b2ec..f23f752a2 100644 --- a/qupath-core-processing/src/main/java/qupath/lib/scripting/QP.java +++ b/qupath-core-processing/src/main/java/qupath/lib/scripting/QP.java @@ -1410,9 +1410,6 @@ public static void setChannels(ImageChannel... channels) { */ public static void setChannels(ImageData imageData, ImageChannel... channels) { var metadata = imageData.getServerMetadata(); - if (metadata.isRGB()) { - throw new IllegalArgumentException("Cannot set channels for RGB images"); - } List oldChannels = metadata.getChannels(); List newChannels = Arrays.asList(channels); if (oldChannels.equals(newChannels)) { @@ -1421,7 +1418,17 @@ public static void setChannels(ImageData imageData, ImageChannel... channels) } if (oldChannels.size() != newChannels.size()) throw new IllegalArgumentException("Cannot set channels - require " + oldChannels.size() + " channels but you provided " + channels.length); - + + // Can't adjust channel colors for RGB images - but changing names is permitted + if (metadata.isRGB()) { + if (!Arrays.equals( + oldChannels.stream().mapToInt(ImageChannel::getColor).toArray(), + newChannels.stream().mapToInt(ImageChannel::getColor).toArray() + )) { + throw new IllegalArgumentException("Cannot set channel colors for RGB images"); + } + } + // Set the metadata var metadata2 = new ImageServerMetadata.Builder(metadata) .channels(newChannels) diff --git a/qupath-gui-fx/src/main/java/qupath/lib/display/ImageDisplay.java b/qupath-gui-fx/src/main/java/qupath/lib/display/ImageDisplay.java index 5dcb2824b..ab83284a0 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/display/ImageDisplay.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/display/ImageDisplay.java @@ -377,13 +377,13 @@ private void createRGBChannels(final ImageData imageData) { rgbDirectChannelInfo = new RGBDirectChannelInfo(imageData); rgbNormalizedChannelInfo = new RGBNormalizedChannelInfo(imageData); - // Add simple channel separation - rgbBasicChannels.add(new RBGColorTransformInfo(imageData, ColorTransformMethod.Red, false)); - rgbBasicChannels.add(new RBGColorTransformInfo(imageData, ColorTransformMethod.Green, false)); - rgbBasicChannels.add(new RBGColorTransformInfo(imageData, ColorTransformMethod.Blue, false)); -// rgbBasicChannels.add(new ChannelDisplayInfo.MultiChannelInfo("Red", 8, 0, 255, 0, 0)); -// rgbBasicChannels.add(new ChannelDisplayInfo.MultiChannelInfo("Green", 8, 1, 0, 255, 0)); -// rgbBasicChannels.add(new ChannelDisplayInfo.MultiChannelInfo("Blue", 8, 2, 0, 0, 255)); + // Add simple channel separation (changed for v0.6.0) + rgbBasicChannels.add(new DirectServerChannelInfo(imageData, 0)); + rgbBasicChannels.add(new DirectServerChannelInfo(imageData, 1)); + rgbBasicChannels.add(new DirectServerChannelInfo(imageData, 2)); +// rgbBasicChannels.add(new RBGColorTransformInfo(imageData, ColorTransformMethod.Red, false)); +// rgbBasicChannels.add(new RBGColorTransformInfo(imageData, ColorTransformMethod.Green, false)); +// rgbBasicChannels.add(new RBGColorTransformInfo(imageData, ColorTransformMethod.Blue, false)); rgbBasicChannels.add(new RBGColorTransformInfo(imageData, ColorTransformer.ColorTransformMethod.Hue, false)); rgbBasicChannels.add(new RBGColorTransformInfo(imageData, ColorTransformer.ColorTransformMethod.Saturation, false)); rgbBasicChannels.add(new RBGColorTransformInfo(imageData, ColorTransformer.ColorTransformMethod.RGB_mean, false)); diff --git a/qupath-gui-fx/src/main/java/qupath/lib/display/RBGColorTransformInfo.java b/qupath-gui-fx/src/main/java/qupath/lib/display/RBGColorTransformInfo.java index d14fe8be5..7eed6e2cb 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/display/RBGColorTransformInfo.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/display/RBGColorTransformInfo.java @@ -78,6 +78,18 @@ private static IndexColorModel invertColorModel(IndexColorModel cm) { @Override public String getName() { + // For RGB images, the channel names can sometimes be specified + var server = getImageServer(); + if (server != null) { + switch (method) { + case Red: + return server.getChannel(0).getName(); + case Green: + return server.getChannel(1).getName(); + case Blue: + return server.getChannel(2).getName(); + } + } return method.toString(); } diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/display/BrightnessContrastChannelPane.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/display/BrightnessContrastChannelPane.java index d53cf2e6c..4be9bd075 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/display/BrightnessContrastChannelPane.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/display/BrightnessContrastChannelPane.java @@ -453,14 +453,18 @@ private void handleTableRowMouseClick(TableRow row, MouseEve ChannelDisplayInfo info = row.getItem(); var imageData = getImageData(); - if (imageData != null && info instanceof DirectServerChannelInfo multiInfo) { + if (imageData == null) + return; + + if (info instanceof DirectServerChannelInfo multiInfo) { int c = multiInfo.getChannel(); var channel = imageData.getServerMetadata().getChannel(c); + boolean canChangeColor = !imageData.getServerMetadata().isRGB(); + Color color = ColorToolsFX.getCachedColor(multiInfo.getColor()); picker.setValue(color); - Dialog colorDialog = new Dialog<>(); colorDialog.setTitle("Channel properties"); @@ -473,11 +477,18 @@ private void handleTableRowMouseClick(TableRow row, MouseEve labelName.setLabelFor(tfName); GridPaneUtils.addGridRow(paneColor, r++, 0, "Enter a name for the current channel", labelName, tfName); + + // Only show color if it can be changed var labelColor = new Label("Channel color"); labelColor.setLabelFor(picker); + String colorTooltipText = "Choose the color for the current channel"; + if (!canChangeColor) { + picker.setDisable(true); + colorTooltipText = "Color cannot be changed for RGB images"; + } GridPaneUtils.setFillWidth(Boolean.TRUE, picker, tfName); GridPaneUtils.addGridRow(paneColor, r++, 0, - "Choose the color for the current channel", labelColor, picker); + colorTooltipText, labelColor, picker); paneColor.setVgap(5.0); paneColor.setHgap(5.0); @@ -508,10 +519,6 @@ private void updateChannelColor(DirectServerChannelInfo channel, return; } var server = imageData.getServer(); - if (server.isRGB()) { - logger.warn("Cannot update channel color for RGB images"); - return; - } Objects.requireNonNull(channel, "Channel cannot be null"); Objects.requireNonNull(newName, "Channel name cannot be null"); Objects.requireNonNull(newColor, "Channel color cannot be null"); @@ -521,7 +528,8 @@ private void updateChannelColor(DirectServerChannelInfo channel, var channels = new ArrayList<>(metadata.getChannels()); channels.set(channelIndex, ImageChannel.getInstance(newName, ColorToolsFX.getRGB(newColor))); var metadata2 = new ImageServerMetadata.Builder(metadata) - .channels(channels).build(); + .channels(channels) + .build(); imageData.updateServerMetadata(metadata2); From 726f53a6b852775dbb96e5664a1cf92471ba24c8 Mon Sep 17 00:00:00 2001 From: lleplat Date: Mon, 30 Sep 2024 11:28:20 +0100 Subject: [PATCH 334/866] Added condition for zarr files --- .../lib/images/servers/bioformats/BioFormatsImageServer.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qupath-extension-bioformats/src/main/java/qupath/lib/images/servers/bioformats/BioFormatsImageServer.java b/qupath-extension-bioformats/src/main/java/qupath/lib/images/servers/bioformats/BioFormatsImageServer.java index 3dc7488da..cc1b5ef60 100644 --- a/qupath-extension-bioformats/src/main/java/qupath/lib/images/servers/bioformats/BioFormatsImageServer.java +++ b/qupath-extension-bioformats/src/main/java/qupath/lib/images/servers/bioformats/BioFormatsImageServer.java @@ -1208,7 +1208,7 @@ private IFormatReader createReader(final BioFormatsServerOptions options, final } IFormatReader imageReader; - if (new File(id).isDirectory() || id.toLowerCase().endsWith(".zarr")) { + if (new File(id).isDirectory() || id.toLowerCase().endsWith(".zarr") || id.toLowerCase().endsWith(".zarr/")) { // Using new ImageReader() on a directory won't work imageReader = new ZarrReader(); if (id.startsWith("https") && imageReader.getMetadataOptions() instanceof DynamicMetadataOptions zarrOptions) { From c6bb6ef8ef7ba249aa40fec2f7eab4f3bcc86bef Mon Sep 17 00:00:00 2001 From: Pete Date: Mon, 30 Sep 2024 12:55:15 +0100 Subject: [PATCH 335/866] Don't show all RGB transforms for fluorescence This makes the 'fluorescence' image type consistent between RGB and non-RGB images within the B/C dialog *except* that the colors cannot be changed. --- .../java/qupath/lib/display/ImageDisplay.java | 30 ++++++++++--------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/qupath-gui-fx/src/main/java/qupath/lib/display/ImageDisplay.java b/qupath-gui-fx/src/main/java/qupath/lib/display/ImageDisplay.java index ab83284a0..75df5906f 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/display/ImageDisplay.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/display/ImageDisplay.java @@ -99,7 +99,8 @@ public class ImageDisplay extends AbstractImageRenderer { // Lists to store the different kinds of channels we might need private RGBDirectChannelInfo rgbDirectChannelInfo; private RGBNormalizedChannelInfo rgbNormalizedChannelInfo; - private List rgbBasicChannels = new ArrayList<>(); + private List rgbDirectChannels = new ArrayList<>(); + private List rgbHsvChannels = new ArrayList<>(); private List rgbBrightfieldChannels = new ArrayList<>(); private List rgbChromaticityChannels = new ArrayList<>(); @@ -367,7 +368,8 @@ private void createRGBChannels(final ImageData imageData) { rgbDirectChannelInfo = null; rgbNormalizedChannelInfo = null; - rgbBasicChannels.clear(); + rgbDirectChannels.clear(); + rgbHsvChannels.clear(); rgbBrightfieldChannels.clear(); rgbChromaticityChannels.clear(); @@ -378,15 +380,12 @@ private void createRGBChannels(final ImageData imageData) { rgbNormalizedChannelInfo = new RGBNormalizedChannelInfo(imageData); // Add simple channel separation (changed for v0.6.0) - rgbBasicChannels.add(new DirectServerChannelInfo(imageData, 0)); - rgbBasicChannels.add(new DirectServerChannelInfo(imageData, 1)); - rgbBasicChannels.add(new DirectServerChannelInfo(imageData, 2)); -// rgbBasicChannels.add(new RBGColorTransformInfo(imageData, ColorTransformMethod.Red, false)); -// rgbBasicChannels.add(new RBGColorTransformInfo(imageData, ColorTransformMethod.Green, false)); -// rgbBasicChannels.add(new RBGColorTransformInfo(imageData, ColorTransformMethod.Blue, false)); - rgbBasicChannels.add(new RBGColorTransformInfo(imageData, ColorTransformer.ColorTransformMethod.Hue, false)); - rgbBasicChannels.add(new RBGColorTransformInfo(imageData, ColorTransformer.ColorTransformMethod.Saturation, false)); - rgbBasicChannels.add(new RBGColorTransformInfo(imageData, ColorTransformer.ColorTransformMethod.RGB_mean, false)); + rgbDirectChannels.add(new DirectServerChannelInfo(imageData, 0)); + rgbDirectChannels.add(new DirectServerChannelInfo(imageData, 1)); + rgbDirectChannels.add(new DirectServerChannelInfo(imageData, 2)); + rgbHsvChannels.add(new RBGColorTransformInfo(imageData, ColorTransformer.ColorTransformMethod.Hue, false)); + rgbHsvChannels.add(new RBGColorTransformInfo(imageData, ColorTransformer.ColorTransformMethod.Saturation, false)); + rgbHsvChannels.add(new RBGColorTransformInfo(imageData, ColorTransformer.ColorTransformMethod.RGB_mean, false)); // Add optical density & color deconvolution options for brightfield images rgbBrightfieldChannels.add(new RBGColorDeconvolutionInfo(imageData, ColorTransformMethod.Stain_1)); @@ -467,15 +466,18 @@ private void updateChannelOptions(boolean serverChanged) { tempChannelOptions.addAll(rgbBrightfieldChannels); tempChannelOptions.add(rgbNormalizedChannelInfo); } - if (showAllRGBTransforms.get()) { - tempChannelOptions.addAll(rgbBasicChannels); + tempChannelOptions.addAll(rgbDirectChannels); + if (imageData.getImageType() != ImageData.ImageType.FLUORESCENCE && showAllRGBTransforms.get()) { + // Change v0.6.0 - don't show all channels for fluorescence (as they are more distracting than helpful) + // If they are needed, using ImageType.OTHER + tempChannelOptions.addAll(rgbHsvChannels); tempChannelOptions.addAll(rgbChromaticityChannels); } // Remove any invalid channels tempSelectedChannels.retainAll(tempChannelOptions); // Select the original channel (RGB) if (tempSelectedChannels.isEmpty()) - tempSelectedChannels.add(tempChannelOptions.get(0)); + tempSelectedChannels.add(tempChannelOptions.getFirst()); } else if (serverChanged) { if (server.nChannels() == 1) { tempChannelOptions.add(new DirectServerChannelInfo(imageData, 0)); From 5560545413e0e71b45cd59ba72521dbfb81ac20e Mon Sep 17 00:00:00 2001 From: Pete Date: Mon, 30 Sep 2024 14:38:41 +0100 Subject: [PATCH 336/866] Fix javadoc, simplify 'if' --- .../src/main/java/qupath/lib/scripting/QP.java | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/qupath-core-processing/src/main/java/qupath/lib/scripting/QP.java b/qupath-core-processing/src/main/java/qupath/lib/scripting/QP.java index f23f752a2..419e4f922 100644 --- a/qupath-core-processing/src/main/java/qupath/lib/scripting/QP.java +++ b/qupath-core-processing/src/main/java/qupath/lib/scripting/QP.java @@ -1400,7 +1400,7 @@ public static void setChannels(ImageChannel... channels) { * Set the channels for the specified ImageData. * Note that number of channels provided must match the number of channels of the current image. *

    - * Also, currently it is not possible to set channels for RGB images - attempting to do so + * Also, currently it is not possible to set channel colors for RGB images - attempting to do so * will throw an IllegalArgumentException. * * @param imageData @@ -1421,10 +1421,9 @@ public static void setChannels(ImageData imageData, ImageChannel... channels) // Can't adjust channel colors for RGB images - but changing names is permitted if (metadata.isRGB()) { - if (!Arrays.equals( - oldChannels.stream().mapToInt(ImageChannel::getColor).toArray(), - newChannels.stream().mapToInt(ImageChannel::getColor).toArray() - )) { + int[] oldColors = oldChannels.stream().mapToInt(ImageChannel::getColor).toArray(); + int[] newColors = newChannels.stream().mapToInt(ImageChannel::getColor).toArray(); + if (!Arrays.equals(oldColors, newColors)) { throw new IllegalArgumentException("Cannot set channel colors for RGB images"); } } From 5e33197686edbdf9b78756be1114b455aaa243cb Mon Sep 17 00:00:00 2001 From: Pete Date: Mon, 30 Sep 2024 14:48:08 +0100 Subject: [PATCH 337/866] Don't permit channel color changes for RGB --- .../display/BrightnessContrastChannelPane.java | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/display/BrightnessContrastChannelPane.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/display/BrightnessContrastChannelPane.java index 4be9bd075..919225558 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/display/BrightnessContrastChannelPane.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/display/BrightnessContrastChannelPane.java @@ -720,15 +720,19 @@ protected void updateItem(ChannelDisplayInfo item, boolean empty) { setGraphic(colorPicker); updateStyle(); - Integer rgb = item.getColor(); + // Check if we have an RGB image - if we do, we shouldn't allow the colors to change + var imageData = getImageData(); + var isRGB = imageData != null && imageData.getServerMetadata().isRGB(); + + Integer channelRGB = item.getColor(); // Can only set the color for direct, non-RGB channels - boolean canChangeColor = rgb != null && item instanceof DirectServerChannelInfo; + boolean canChangeColor = !isRGB && channelRGB != null && item instanceof DirectServerChannelInfo; colorPicker.setDisable(!canChangeColor); colorPicker.setOnShowing(null); - if (rgb == null) { + if (channelRGB == null) { colorPicker.setValue(Color.TRANSPARENT); } else { - Color color = ColorToolsFX.getCachedColor(rgb); + Color color = ColorToolsFX.getCachedColor(channelRGB); setColorQuietly(color); colorPicker.setOnShowing(e -> { if (customColors == null) From ee7083432194648b5d63b22581a8163a26869a8b Mon Sep 17 00:00:00 2001 From: Pete Date: Mon, 30 Sep 2024 15:00:28 +0100 Subject: [PATCH 338/866] Hide script editor on close requests Potential fix for https://github.com/qupath/qupath/issues/1660 This hides the script editor when either pressing the corner button to close the window, or calling 'File > Close editor' - but does *not* close the scripts. However, when QuPath is being quit entirely, if there are unsaved scripts then the script editor should appear again - with prompts to save. --- .../qupath/lib/gui/scripting/DefaultScriptEditor.java | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/scripting/DefaultScriptEditor.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/scripting/DefaultScriptEditor.java index a30404e76..be7665691 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/scripting/DefaultScriptEditor.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/scripting/DefaultScriptEditor.java @@ -539,6 +539,9 @@ public boolean requestClose() { if (tab == null) { break; } + // We're probably quitting QuPath - if we're prompting for changes, then we should show the tab + if (dialog != null && !dialog.isShowing() && tab.isModifiedProperty().get() && tab.hasScript()) + dialog.show(); ret = promptToClose(tab); } if (ret && dialog != null) { @@ -741,7 +744,8 @@ private void createDialog() { }); dialog.setOnCloseRequest(e -> { - if (!requestClose()) { + if (dialog != null) { + dialog.hide(); e.consume(); } }); @@ -1998,7 +2002,9 @@ Action createFindAction(final String name) { Action createExitAction(final String name) { Action action = new Action(name, e -> { - requestClose(); +// requestClose(); + if (dialog != null) + dialog.hide(); e.consume(); }); action.setAccelerator(new KeyCodeCombination(KeyCode.W, KeyCombination.SHORTCUT_DOWN, KeyCombination.SHIFT_DOWN)); From e0f2b861feb7c66c92450fc84af71641dc42967c Mon Sep 17 00:00:00 2001 From: Pete Date: Mon, 30 Sep 2024 15:30:33 +0100 Subject: [PATCH 339/866] Remove commented-out line --- .../main/java/qupath/lib/gui/scripting/DefaultScriptEditor.java | 1 - 1 file changed, 1 deletion(-) diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/scripting/DefaultScriptEditor.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/scripting/DefaultScriptEditor.java index be7665691..c4fd5c5c3 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/scripting/DefaultScriptEditor.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/scripting/DefaultScriptEditor.java @@ -2002,7 +2002,6 @@ Action createFindAction(final String name) { Action createExitAction(final String name) { Action action = new Action(name, e -> { -// requestClose(); if (dialog != null) dialog.hide(); e.consume(); From ad075c12d875a9c800226196131996f63d827144 Mon Sep 17 00:00:00 2001 From: Pete Date: Tue, 1 Oct 2024 08:47:59 +0100 Subject: [PATCH 340/866] Fix RGB tile resize bug Fixes https://github.com/qupath/qupath/issues/1665 Bug introduced around https://github.com/qupath/qupath/pull/1531 It was *supposed* to only adjust the width & height of a tile by a single pixel. In practice, it would change both the width AND height if EITHER required an adjustment of a single pixel... which could sometimes cause the other to be adjusted by far too much. --- .../servers/AbstractTileableImageServer.java | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/qupath-core/src/main/java/qupath/lib/images/servers/AbstractTileableImageServer.java b/qupath-core/src/main/java/qupath/lib/images/servers/AbstractTileableImageServer.java index ccd988695..e9456b068 100644 --- a/qupath-core/src/main/java/qupath/lib/images/servers/AbstractTileableImageServer.java +++ b/qupath-core/src/main/java/qupath/lib/images/servers/AbstractTileableImageServer.java @@ -481,11 +481,19 @@ private BufferedImage createRGBImage(RegionRequest request, Collection Date: Wed, 2 Oct 2024 15:12:30 +0100 Subject: [PATCH 341/866] Added step in SlicedImageServer --- .../lib/images/servers/ImageServers.java | 14 +- .../lib/images/servers/SlicedImageServer.java | 49 +++- .../servers/TransformedServerBuilder.java | 19 +- .../images/servers/TestSlicedImageServer.java | 218 ++++++++++++------ 4 files changed, 216 insertions(+), 84 deletions(-) diff --git a/qupath-core/src/main/java/qupath/lib/images/servers/ImageServers.java b/qupath-core/src/main/java/qupath/lib/images/servers/ImageServers.java index f02b10ca4..4a872bf54 100644 --- a/qupath-core/src/main/java/qupath/lib/images/servers/ImageServers.java +++ b/qupath-core/src/main/java/qupath/lib/images/servers/ImageServers.java @@ -535,29 +535,35 @@ static class SlicedImageServerBuilder extends AbstractServerBuilder builder; private final int zStart; private final int zEnd; + private final int zStep; private final int tStart; private final int tEnd; + private final int tStep; public SlicedImageServerBuilder( ImageServerMetadata metadata, ServerBuilder builder, int zStart, int zEnd, + int zStep, int tStart, - int tEnd + int tEnd, + int tStep ) { super(metadata); this.builder = builder; this.zStart = zStart; this.zEnd = zEnd; + this.zStep = zStep; this.tStart = tStart; this.tEnd = tEnd; + this.tStep = tStep; } @Override protected ImageServer buildOriginal() throws Exception { - return new SlicedImageServer(builder.build(), zStart, zEnd, tStart, tEnd); + return new SlicedImageServer(builder.build(), zStart, zEnd, zStep, tStart, tEnd, tStep); } @Override @@ -576,8 +582,10 @@ public ServerBuilder updateURIs(Map updateMap) { newBuilder, zStart, zEnd, + zStep, tStart, - tEnd + tEnd, + tStep ); } } diff --git a/qupath-core/src/main/java/qupath/lib/images/servers/SlicedImageServer.java b/qupath-core/src/main/java/qupath/lib/images/servers/SlicedImageServer.java index 891481698..ea1dff178 100644 --- a/qupath-core/src/main/java/qupath/lib/images/servers/SlicedImageServer.java +++ b/qupath-core/src/main/java/qupath/lib/images/servers/SlicedImageServer.java @@ -6,6 +6,7 @@ import java.awt.image.BufferedImage; import java.io.IOException; import java.util.Map; +import java.util.stream.Stream; /** * ImageServer that treats a particular set of z-slices and timepoints of another ImageServer @@ -13,11 +14,13 @@ */ public class SlicedImageServer extends TransformingImageServer { - private final ImageServerMetadata metadata; private final int zStart; private final int zEnd; + private final int zStep; private final int tStart; private final int tEnd; + private final int tStep; + private final ImageServerMetadata metadata; /** * Create an ImageServer that represents a particular set of z-slices and timepoints of another ImageServer. @@ -28,30 +31,46 @@ public class SlicedImageServer extends TransformingImageServer { * @param inputServer the input image to slice * @param zStart the inclusive 0-based index of the first slice to consider * @param zEnd the exclusive 0-based index of the last slide to consider + * @param zStep a step to indicate which slides to consider * @param tStart the inclusive 0-based index of the first timepoint to consider * @param tEnd the exclusive 0-based index of the last timepoint to consider - * @throws IllegalArgumentException when a start index is greater than its corresponding end index + * @param tStep a step to indicate which timepoints to consider + * @throws IllegalArgumentException when a start index is greater than its corresponding end index, + * or when a step is less than or equal to 0 */ SlicedImageServer( ImageServer inputServer, int zStart, int zEnd, + int zStep, int tStart, - int tEnd + int tEnd, + int tStep ) { super(inputServer); this.zStart = setNumberInRange(zStart, 0, inputServer.nZSlices() - 1); this.zEnd = setNumberInRange(zEnd, 1, inputServer.nZSlices()); + this.zStep = zStep; this.tStart = setNumberInRange(tStart, 0, inputServer.nTimepoints() - 1); this.tEnd = setNumberInRange(tEnd, 1, inputServer.nTimepoints()); + this.tStep = tStep; checkOrder(this.zStart, this.zEnd, "z-slice"); + checkStep(this.zStep); checkOrder(this.tStart, this.tEnd, "timepoint"); + checkStep(this.tStep); metadata = new ImageServerMetadata.Builder(inputServer.getMetadata()) - .sizeZ(this.zEnd - this.zStart) - .sizeT(this.tEnd - this.tStart) + .sizeZ((this.zEnd - this.zStart + this.zStep - 1) / this.zStep) + .sizeT((this.tEnd - this.tStart + this.tStep - 1) / this.tStep) + .zSpacingMicrons(inputServer.getMetadata().getZSpacingMicrons() * this.zStep) + .timepoints( + inputServer.getMetadata().getPixelCalibration().getTimeUnit(), + Stream.iterate(this.tStart, t -> t < this.tEnd, t -> t + this.tStep) + .mapToDouble(i -> inputServer.getMetadata().getPixelCalibration().getTimepoint(i)) + .toArray() + ) .build(); } @@ -62,8 +81,10 @@ protected ImageServerBuilder.ServerBuilder createServerBuilder() getWrappedServer().getBuilder(), zStart, zEnd, + zStep, tStart, - tEnd + tEnd, + tStep ); } @@ -72,8 +93,10 @@ protected String createID() { return getClass().getName() + ": + " + getWrappedServer().getPath() + " " + GsonTools.getInstance().toJson(Map.of( "minZSlice", zStart, "maxZSlice", zEnd, + "stepZSlice", zStep, "minTimepoint", tStart, - "maxTimepoint", tEnd + "maxTimepoint", tEnd, + "stepTimepoint", tStep )); } @@ -96,8 +119,8 @@ public BufferedImage readRegion(final RegionRequest request) throws IOException request.getY(), request.getWidth(), request.getHeight(), - request.getZ() + zStart, - request.getT() + tStart + request.getZ() * zStep + zStart, + request.getT() * tStep + tStart )); } @@ -110,4 +133,10 @@ private static void checkOrder(int min, int max, String name) { throw new IllegalArgumentException(String.format("The min %s is greater than the max %s", name, name)); } } -} + + private static void checkStep(int step) { + if (step < 1) { + throw new IllegalArgumentException(String.format("The step %s is less than or equal to 0", step)); + } + } +} \ No newline at end of file diff --git a/qupath-core/src/main/java/qupath/lib/images/servers/TransformedServerBuilder.java b/qupath-core/src/main/java/qupath/lib/images/servers/TransformedServerBuilder.java index 44864ade1..fd9f530ad 100644 --- a/qupath-core/src/main/java/qupath/lib/images/servers/TransformedServerBuilder.java +++ b/qupath-core/src/main/java/qupath/lib/images/servers/TransformedServerBuilder.java @@ -78,7 +78,24 @@ public TransformedServerBuilder crop(ImageRegion region) { * @throws IllegalArgumentException when a start index is greater than its corresponding end index */ public TransformedServerBuilder slice(int zStart, int zEnd, int tStart, int tEnd) { - server = new SlicedImageServer(server, zStart, zEnd, tStart, tEnd); + return slice(zStart, zEnd, 1, tStart, tEnd, 1); + } + + /** + * Slice a specific region along the z or the t axis with a step. + * + * @param zStart the inclusive 0-based index of the first slice to consider + * @param zEnd the exclusive 0-based index of the last slide to consider + * @param zStep a step to indicate which slides to consider + * @param tStart the inclusive 0-based index of the first timepoint to consider + * @param tEnd the exclusive 0-based index of the last timepoint to consider + * @param tStep a step to indicate which timepoints to consider + * @return this builder + * @throws IllegalArgumentException when a start index is greater than its corresponding end index, + * or when a step is less than or equal to 0 + */ + public TransformedServerBuilder slice(int zStart, int zEnd, int zStep, int tStart, int tEnd, int tStep) { + server = new SlicedImageServer(server, zStart, zEnd, zStep, tStart, tEnd, tStep); return this; } diff --git a/qupath-core/src/test/java/qupath/lib/images/servers/TestSlicedImageServer.java b/qupath-core/src/test/java/qupath/lib/images/servers/TestSlicedImageServer.java index 284a30c0c..d43cb0fc5 100644 --- a/qupath-core/src/test/java/qupath/lib/images/servers/TestSlicedImageServer.java +++ b/qupath-core/src/test/java/qupath/lib/images/servers/TestSlicedImageServer.java @@ -13,20 +13,22 @@ import java.net.URI; import java.util.Collection; import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.stream.IntStream; public class TestSlicedImageServer { @Test void Check_Number_Of_Z_Slices() throws Exception { ImageServer sampleServer = new SampleImageServer(); - int zStart = 1; - int zEnd = 3; - int expectedNumberOfZSlices = zEnd - zStart; - ImageServer slicedServer = new SlicedImageServer(sampleServer, zStart, zEnd, 0, 0); + int zStart = 3; + int zEnd = 8; + int zStep = 2; + int expectedNumberOfZSlices = 3; - int numberOfZSlices = slicedServer.nZSlices(); + ImageServer slicedServer = new SlicedImageServer(sampleServer, zStart, zEnd, zStep, 0, 1, 1); - Assertions.assertEquals(expectedNumberOfZSlices, numberOfZSlices); + Assertions.assertEquals(expectedNumberOfZSlices, slicedServer.nZSlices()); slicedServer.close(); sampleServer.close(); @@ -38,11 +40,10 @@ void Check_Number_Of_Z_Slices_When_Out_Of_Bound() throws Exception { int zStart = -1; int zEnd = sampleServer.nZSlices() + 10; int expectedNumberOfZSlices = sampleServer.nZSlices(); - ImageServer slicedServer = new SlicedImageServer(sampleServer, zStart, zEnd, 0, 0); - int numberOfZSlices = slicedServer.nZSlices(); + ImageServer slicedServer = new SlicedImageServer(sampleServer, zStart, zEnd, 1, 0, 1, 1); - Assertions.assertEquals(expectedNumberOfZSlices, numberOfZSlices); + Assertions.assertEquals(expectedNumberOfZSlices, slicedServer.nZSlices()); slicedServer.close(); sampleServer.close(); @@ -55,43 +56,73 @@ void Check_Number_Of_Z_Slices_When_Min_Greater_Than_Max() throws Exception { int zEnd = zStart - 2; Assertions.assertThrows(IllegalArgumentException.class, () -> { - ImageServer slicedServer = new SlicedImageServer(sampleServer, zStart, zEnd, 0, 0); + ImageServer slicedServer = new SlicedImageServer(sampleServer, zStart, zEnd, 1,0, 1, 1); slicedServer.close(); }); sampleServer.close(); } + @Test + void Check_Number_Of_Z_Slices_When_Step_Invalid() throws Exception { + ImageServer sampleServer = new SampleImageServer(); + int zStep = 0; + + Assertions.assertThrows(IllegalArgumentException.class, () -> { + ImageServer slicedServer = new SlicedImageServer(sampleServer, 0, 1, zStep,0, 1, 1); + slicedServer.close(); + }); + + sampleServer.close(); + } + + @Test + void Check_Z_Spacing_With_Step() throws Exception { + ImageServer sampleServer = new SampleImageServer(); + int zStep = 4; + double expectedZSpacing = sampleServer.getMetadata().getPixelCalibration().getZSpacingMicrons() * zStep; + + ImageServer slicedServer = new SlicedImageServer(sampleServer, 0, 1, zStep,0, 1, 1); + + Assertions.assertEquals(expectedZSpacing, slicedServer.getMetadata().getZSpacingMicrons()); + + slicedServer.close(); + sampleServer.close(); + } + @Test void Check_Correct_Slice_Read() throws Exception { ImageServer sampleServer = new SampleImageServer(); - int zStart = 1; - int zEnd = 3; + int zStart = 3; + int zEnd = 8; + int zStep = 2; int zToRead = 1; - ImageServer slicedServer = new SlicedImageServer(sampleServer, zStart, zEnd, 0, 0); BufferedImage expectedImage = sampleServer.readRegion(RegionRequest.createInstance( - slicedServer.getPath(), - 1, - 0, - 0, - slicedServer.getWidth(), - slicedServer.getHeight(), - zToRead + zStart, - 0 - )); - - BufferedImage image = slicedServer.readRegion(RegionRequest.createInstance( - slicedServer.getPath(), + sampleServer.getPath(), 1, 0, 0, - slicedServer.getWidth(), - slicedServer.getHeight(), - zToRead, + sampleServer.getWidth(), + sampleServer.getHeight(), + zToRead * zStep + zStart, 0 )); - assertDoubleBufferedImagesEqual(expectedImage, image); + ImageServer slicedServer = new SlicedImageServer(sampleServer, zStart, zEnd, zStep, 0, 1, 1); + + assertDoubleBufferedImagesEqual( + expectedImage, + slicedServer.readRegion(RegionRequest.createInstance( + slicedServer.getPath(), + 1, + 0, + 0, + slicedServer.getWidth(), + slicedServer.getHeight(), + zToRead, + 0 + )) + ); slicedServer.close(); sampleServer.close(); @@ -101,13 +132,13 @@ void Check_Correct_Slice_Read() throws Exception { void Check_Number_Of_Timepoints() throws Exception { ImageServer sampleServer = new SampleImageServer(); int tStart = 1; - int tEnd = 3; - int expectedNumberOfTimepoints = tEnd - tStart; - ImageServer slicedServer = new SlicedImageServer(sampleServer, 0, 0, tStart, tEnd); + int tEnd = 6; + int tStep = 2; + int expectedNumberOfTimepoints = 3; - int numberOfTimepoints = slicedServer.nTimepoints(); + ImageServer slicedServer = new SlicedImageServer(sampleServer, 0, 1, 1, tStart, tEnd, tStep); - Assertions.assertEquals(expectedNumberOfTimepoints, numberOfTimepoints); + Assertions.assertEquals(expectedNumberOfTimepoints, slicedServer.nTimepoints()); slicedServer.close(); sampleServer.close(); @@ -119,7 +150,7 @@ void Check_Number_Of_Timepoints_When_Out_Of_Bound() throws Exception { int tStart = -1; int tEnd = sampleServer.nTimepoints() + 10; int expectedNumberOfTimepoints = sampleServer.nTimepoints(); - ImageServer slicedServer = new SlicedImageServer(sampleServer, 0, 0, tStart, tEnd); + ImageServer slicedServer = new SlicedImageServer(sampleServer, 0, 1, 1, tStart, tEnd, 1); int numberOfTimepoints = slicedServer.nTimepoints(); @@ -136,43 +167,83 @@ void Check_Number_Of_Timepoints_When_Min_Greater_Than_Max() throws Exception { int tEnd = tStart - 2; Assertions.assertThrows(IllegalArgumentException.class, () -> { - ImageServer slicedServer = new SlicedImageServer(sampleServer, 0, 0, tStart, tEnd); + ImageServer slicedServer = new SlicedImageServer(sampleServer, 0, 1, 1, tStart, tEnd, 1); slicedServer.close(); }); sampleServer.close(); } + @Test + void Check_Number_Of_Timepoints_When_Step_Invalid() throws Exception { + ImageServer sampleServer = new SampleImageServer(); + int tStep = 0; + + Assertions.assertThrows(IllegalArgumentException.class, () -> { + ImageServer slicedServer = new SlicedImageServer(sampleServer, 0, 1, 1,0, 0, tStep); + slicedServer.close(); + }); + + sampleServer.close(); + } + + @Test + void Check_Timepoints_With_Step() throws Exception { + ImageServer sampleServer = new SampleImageServer(); + int tStart = 2; + int tEnd = 7; + int tStep = 4; + double[] expectedTimepoints = new double[] { + sampleServer.getMetadata().getTimepoint(tStart), + sampleServer.getMetadata().getTimepoint(tStart + tStep) + }; + + ImageServer slicedServer = new SlicedImageServer(sampleServer, 0, 1, 1, tStart, tEnd, tStep); + + Assertions.assertArrayEquals( + expectedTimepoints, + IntStream.range(0, slicedServer.getMetadata().getPixelCalibration().nTimepoints()) + .mapToDouble(t -> slicedServer.getMetadata().getTimepoint(t)) + .toArray() + ); + + slicedServer.close(); + sampleServer.close(); + } + @Test void Check_Correct_Timepoint_Read() throws Exception { ImageServer sampleServer = new SampleImageServer(); int tStart = 1; - int tEnd = 3; + int tEnd = 6; + int tStep = 2; int tToRead = 1; - ImageServer slicedServer = new SlicedImageServer(sampleServer, 0, 0, tStart, tEnd); BufferedImage expectedImage = sampleServer.readRegion(RegionRequest.createInstance( - slicedServer.getPath(), - 1, - 0, - 0, - slicedServer.getWidth(), - slicedServer.getHeight(), - 0, - tToRead + tStart - )); - - BufferedImage image = slicedServer.readRegion(RegionRequest.createInstance( - slicedServer.getPath(), + sampleServer.getPath(), 1, 0, 0, - slicedServer.getWidth(), - slicedServer.getHeight(), + sampleServer.getWidth(), + sampleServer.getHeight(), 0, - tToRead + tToRead * tStep + tStart )); - assertDoubleBufferedImagesEqual(expectedImage, image); + ImageServer slicedServer = new SlicedImageServer(sampleServer, 0, 1, 1, tStart, tEnd, tStep); + + assertDoubleBufferedImagesEqual( + expectedImage, + slicedServer.readRegion(RegionRequest.createInstance( + slicedServer.getPath(), + 1, + 0, + 0, + slicedServer.getWidth(), + slicedServer.getHeight(), + 0, + tToRead + )) + ); slicedServer.close(); sampleServer.close(); @@ -183,7 +254,28 @@ private static class SampleImageServer extends AbstractImageServer Math.random()) + .toArray() + ) + .build(); public SampleImageServer() { super(BufferedImage.class); @@ -211,21 +303,7 @@ public String getServerType() { @Override public ImageServerMetadata getOriginalMetadata() { - return new ImageServerMetadata.Builder() - .width(IMAGE_WIDTH) - .height(IMAGE_HEIGHT) - .sizeZ(NUMBER_OF_Z_SLICES) - .sizeT(NUMBER_OF_TIMEPOINTS) - .pixelType(PixelType.FLOAT64) - .channels(List.of( - ImageChannel.getInstance("c1", 1), - ImageChannel.getInstance("c2", 2), - ImageChannel.getInstance("c3", 3), - ImageChannel.getInstance("c4", 4), - ImageChannel.getInstance("c5", 5) - )) - .name("name") - .build(); + return metadata; } @Override From 842e947a9585651a27924b2e6c1aa31211ab1148 Mon Sep 17 00:00:00 2001 From: lleplat Date: Wed, 2 Oct 2024 15:15:03 +0100 Subject: [PATCH 342/866] Clearer test --- .../java/qupath/lib/images/servers/TestSlicedImageServer.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/qupath-core/src/test/java/qupath/lib/images/servers/TestSlicedImageServer.java b/qupath-core/src/test/java/qupath/lib/images/servers/TestSlicedImageServer.java index d43cb0fc5..fff1cd763 100644 --- a/qupath-core/src/test/java/qupath/lib/images/servers/TestSlicedImageServer.java +++ b/qupath-core/src/test/java/qupath/lib/images/servers/TestSlicedImageServer.java @@ -104,7 +104,7 @@ void Check_Correct_Slice_Read() throws Exception { 0, sampleServer.getWidth(), sampleServer.getHeight(), - zToRead * zStep + zStart, + 5, 0 )); @@ -226,7 +226,7 @@ void Check_Correct_Timepoint_Read() throws Exception { sampleServer.getWidth(), sampleServer.getHeight(), 0, - tToRead * tStep + tStart + 3 )); ImageServer slicedServer = new SlicedImageServer(sampleServer, 0, 1, 1, tStart, tEnd, tStep); From 7862c530f056836eb52948719e04fb6c399654a4 Mon Sep 17 00:00:00 2001 From: Pete Date: Wed, 2 Oct 2024 17:12:40 +0100 Subject: [PATCH 343/866] Auto-update URIs for self-contained projects Avoid showing the URI dialog if all missing image paths can be updated using relative links, and the links are all within the existing project directory. This can help make projects more self-contained. --- .../main/java/qupath/lib/io/UriUpdater.java | 12 +- .../lib/gui/commands/ProjectCommands.java | 31 +++++- .../lib/gui/commands/UpdateUrisCommand.java | 104 +++++++++--------- 3 files changed, 91 insertions(+), 56 deletions(-) diff --git a/qupath-core/src/main/java/qupath/lib/io/UriUpdater.java b/qupath-core/src/main/java/qupath/lib/io/UriUpdater.java index 130743454..2aad548c2 100644 --- a/qupath-core/src/main/java/qupath/lib/io/UriUpdater.java +++ b/qupath-core/src/main/java/qupath/lib/io/UriUpdater.java @@ -2,7 +2,7 @@ * #%L * This file is part of QuPath. * %% - * Copyright (C) 2021 QuPath developers, The University of Edinburgh + * Copyright (C) 2021, 2024 QuPath developers, The University of Edinburgh * %% * QuPath is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as @@ -244,7 +244,7 @@ public UriUpdater searchDepth(int maxDepth) { } /** - * Identify replacements for missing URIs by relativizing URI. + * Identify replacements for missing URIs by relativizing the URI. * This is generally used to make corrections whenever a project has been moved. * * @param uriOriginal the previous path (usually for the project) @@ -316,7 +316,9 @@ public UriUpdater makeReplacement(URI originalItem, URI updatedItem) { */ public Map getReplacements() { return Collections.unmodifiableMap( - replacements.entrySet().stream().filter(s -> s.getValue() != null).collect(Collectors.toMap(s -> s.getKey().getURI(), s -> s.getValue().getURI())) + replacements.entrySet().stream() + .filter(s -> s.getValue() != null) + .collect(Collectors.toMap(s -> s.getKey().getURI(), s -> s.getValue().getURI())) ); } @@ -385,10 +387,10 @@ public Collection getItems(UriStatus status) { private static int updateReplacementsRelative(Collection items, Path pathPrevious, Path pathBase, Map replacements) { // We care about the directory rather than the actual file - if (pathBase != null && !Files.isDirectory(pathBase)) { + if (pathBase != null && Files.exists(pathBase) && !Files.isDirectory(pathBase)) { pathBase = pathBase.getParent(); } - if (pathPrevious != null && !Files.isDirectory(pathPrevious)) { + if (pathPrevious != null && Files.exists(pathPrevious) && !Files.isDirectory(pathPrevious)) { pathPrevious = pathPrevious.getParent(); } boolean tryRelative = pathBase != null && pathPrevious != null && !pathBase.equals(pathPrevious); diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/ProjectCommands.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/ProjectCommands.java index a63691a16..24079622e 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/ProjectCommands.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/ProjectCommands.java @@ -2,7 +2,7 @@ * #%L * This file is part of QuPath. * %% - * Copyright (C) 2018 - 2021 QuPath developers, The University of Edinburgh + * Copyright (C) 2018 - 2021, 2024 QuPath developers, The University of Edinburgh * %% * QuPath is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as @@ -27,6 +27,7 @@ import java.io.IOException; import java.io.PrintWriter; import java.net.URI; +import java.net.URISyntaxException; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Collection; @@ -76,13 +77,39 @@ public class ProjectCommands { * @throws IOException */ public static boolean promptToCheckURIs(Project project, boolean onlyIfMissing) throws IOException { - int n = UpdateUrisCommand.promptToUpdateUris(project.getImageList(), project.getPreviousURI(), project.getURI(), onlyIfMissing); + int n = UpdateUrisCommand.promptToUpdateUris( + project.getImageList(), + getProjectUriPath(project.getPreviousURI()), + getProjectUriPath(project.getURI()), + onlyIfMissing); if (n < 0) return false; if (n > 0) project.syncChanges(); return true; } + + /** + * For a project URI, we want to strip off any file name to get the base directory. + * @param uri + * @return + */ + private static URI getProjectUriPath(URI uri) { + if (uri == null) + return null; + var path = uri.getPath(); + if (!path.endsWith("/")) { + int ind = path.lastIndexOf('/'); + if (ind >= 0) + path = path.substring(0, ind+1); + } + try { + return new URI(uri.getScheme(), uri.getHost(), path, null); + } catch (URISyntaxException e) { + logger.warn("Exception getting project base URI: {}", e.getMessage(), e); + return uri; + } + } /** * Show prompt for the user to select images to import into the current project in QuPath, choosing a suitable {@link ImageServerBuilder}. diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/UpdateUrisCommand.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/UpdateUrisCommand.java index a319d2ca2..f9b9a5345 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/UpdateUrisCommand.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/UpdateUrisCommand.java @@ -2,7 +2,7 @@ * #%L * This file is part of QuPath. * %% - * Copyright (C) 2018 - 2021 QuPath developers, The University of Edinburgh + * Copyright (C) 2018 - 2021, 2024 QuPath developers, The University of Edinburgh * %% * QuPath is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as @@ -82,21 +82,21 @@ */ public class UpdateUrisCommand { - private static Logger logger = LoggerFactory.getLogger(UpdateUrisCommand.class); + private static final Logger logger = LoggerFactory.getLogger(UpdateUrisCommand.class); private static int maxRecursiveSearchDepth = 8; - private UriUpdater updater; + private final UriUpdater updater; - private ObservableMap replacements = FXCollections.observableMap(new HashMap<>()); + private final ObservableMap replacements = FXCollections.observableMap(new HashMap<>()); private GridPane pane; - private TableView table = new TableView<>(); - private ObservableList allItems = FXCollections.observableArrayList(); + private final TableView table = new TableView<>(); + private final ObservableList allItems = FXCollections.observableArrayList(); - private BooleanProperty showMissing = new SimpleBooleanProperty(true); - private BooleanProperty showValid = new SimpleBooleanProperty(true); - private BooleanProperty showUnknown = new SimpleBooleanProperty(true); + private final BooleanProperty showMissing = new SimpleBooleanProperty(true); + private final BooleanProperty showValid = new SimpleBooleanProperty(true); + private final BooleanProperty showUnknown = new SimpleBooleanProperty(true); private UpdateUrisCommand(Collection resources) throws IOException { updater = UriUpdater.create(resources, allItems, replacements); @@ -110,10 +110,15 @@ Pane getPane() { } return pane; } + + UriUpdater getUpdater() { + return updater; + } /** * Show dialog prompting the user to update URIs for missing files. - * Optionally provide previous and current base URIs. If not null, these will be used to relativize paths when searching for potential replacements URIs. + * Optionally provide previous and current base URIs. + * If not null, these will be used to relativize paths when searching for potential replacements URIs. * Usually, these correspond to the current and previous paths for a project. * * @param @@ -124,17 +129,34 @@ Pane getPane() { * @return the number of changes made, or -1 if the user cancelled the dialog. * @throws IOException if there was a problem accessing the URIs */ - public static int promptToUpdateUris(Collection items, URI basePrevious, URI baseCurrent, boolean onlyPromptIfMissing) throws IOException { + public static int promptToUpdateUris(Collection items, URI basePrevious, URI baseCurrent, + boolean onlyPromptIfMissing) throws IOException { var manager = new UpdateUrisCommand<>(items); - - if (onlyPromptIfMissing && manager.updater.getMissingItems().isEmpty()) + + var updater = manager.getUpdater(); + int nMissing = updater.countMissing(); + if (onlyPromptIfMissing && nMissing == 0) return 0; - - if (basePrevious != null && baseCurrent != null) - manager.updater.relative(basePrevious, baseCurrent); - - + + if (basePrevious != null && baseCurrent != null) { + updater.relative(basePrevious, baseCurrent); + // Check if relativization updated all URIs *and* all were inside the current base. + // If so, we assume that we have a self-contained project (or similar), + // and so we can accept all changes without prompting the user. + int nReplacements = updater.countReplacements(); + var replacements = updater.getReplacements(); + if (onlyPromptIfMissing && nReplacements == nMissing && + replacements.values().stream().allMatch(u -> u.getPath() != null && u.getPath().startsWith(baseCurrent.getPath()))) { + if (nReplacements == 1) + logger.info("Updated 1 relative URI"); + else + logger.info("Updated {} relative URIs", nReplacements); + updater.applyReplacements(); + return nReplacements; + } + } + Dialog dialog = new Dialog<>(); dialog.setHeaderText("Files may have been deleted or moved!\nFix broken paths here by double-clicking on red entries and/or accepting QuPath's suggestions."); dialog.getDialogPane().getButtonTypes().setAll(ButtonType.YES, ButtonType.NO, ButtonType.CANCEL); @@ -152,13 +174,14 @@ public static int promptToUpdateUris(Collection items int n = 0; try { - n = manager.updater.applyReplacements(); + n = updater.applyReplacements(); if (n <= 0) { Dialogs.showInfoNotification("Update URIs", "No URIs updated!"); - } else if (n == 1) + } else if (n == 1) { Dialogs.showInfoNotification("Update URIs", "1 URI updated"); - else if (n > 1) + } else { Dialogs.showInfoNotification("Update URIs", n + " URIs updated"); + } } catch (IOException e) { Dialogs.showErrorMessage("Update URIs", e); logger.error(e.getMessage(), e); @@ -174,7 +197,7 @@ private void initialize() { // Create a table view TableColumn colOriginal = new TableColumn<>("Original URI"); - colOriginal.setCellValueFactory(item -> Bindings.createObjectBinding(() -> item.getValue())); + colOriginal.setCellValueFactory(item -> Bindings.createObjectBinding(item::getValue)); colOriginal.setCellFactory(col -> new UriCell()); table.getColumns().add(colOriginal); @@ -192,7 +215,7 @@ private void initialize() { showUnknown.addListener((v, o, n) -> filteredList.setPredicate(new TableFilter())); showValid.addListener((v, o, n) -> filteredList.setPredicate(new TableFilter())); - table.setColumnResizePolicy(TableView.CONSTRAINED_RESIZE_POLICY); + table.setColumnResizePolicy(TableView.CONSTRAINED_RESIZE_POLICY_FLEX_LAST_COLUMN); table.setPrefSize(600, 400); @@ -221,7 +244,6 @@ private void initialize() { return; } updater.searchPath(dir.toPath()); -// UriUpdater.searchDirectoriesRecursive(dir, allItems, maxRecursiveSearchDepth, replacements); }); int row = 0; @@ -253,29 +275,15 @@ private int countOriginalItems(UriStatus status) { return n; } - // private int countReplacedItems(UriStatus status) { - // int n = 0; - // for (var item : allItems) { - // var item2 = replacements.getOrDefault(item, item); - // if (item2.getStatus() == status) - // n++; - // } - // return n; - // } - class TableFilter implements Predicate { @Override public boolean test(SingleUriItem item) { - switch (item.getStatus()) { - case EXISTS: - return showValid.get(); - case MISSING: - return showMissing.get(); - case UNKNOWN: - default: - return showUnknown.get(); - } + return switch (item.getStatus()) { + case EXISTS -> showValid.get(); + case MISSING -> showMissing.get(); + default -> showUnknown.get(); + }; } } @@ -283,8 +291,8 @@ public boolean test(SingleUriItem item) { class TableCopyPasteHandler implements EventHandler { - private KeyCombination copyCombo = new KeyCodeCombination(KeyCode.C, KeyCombination.SHORTCUT_DOWN); - private KeyCombination pasteCombo = new KeyCodeCombination(KeyCode.V, KeyCombination.SHORTCUT_DOWN); + private final KeyCombination copyCombo = new KeyCodeCombination(KeyCode.C, KeyCombination.SHORTCUT_DOWN); + private final KeyCombination pasteCombo = new KeyCodeCombination(KeyCode.V, KeyCombination.SHORTCUT_DOWN); @Override public void handle(KeyEvent event) { @@ -300,7 +308,7 @@ boolean handleCopy() { StringBuilder sb = new StringBuilder(); for (var uriItem : table.getSelectionModel().getSelectedItems()) { var uri = uriItem.getURI(); - if (sb.length() > 0) + if (!sb.isEmpty()) sb.append(System.lineSeparator()); sb.append(uri.toString()); sb.append("\t"); @@ -308,7 +316,7 @@ boolean handleCopy() { if (uri2 != null) sb.append(uri2.getURI().toString()); } - if (sb.length() == 0) + if (sb.isEmpty()) return false; var content = new ClipboardContent(); content.putString(sb.toString()); @@ -402,11 +410,9 @@ public void handle(MouseEvent event) { Dialogs.showErrorMessage("Change URI", "Unable to parse URI from " + path); } else updater.makeReplacement(uriOriginal.getURI(), uri); -// replacements.put(uriOriginal, new SingleUriItem(uri)); } else updater.makeReplacement(uriOriginal.getURI(), null); table.refresh(); -// replacements.remove(uriOriginal); } } From f0c2748b44fb060548b40036795b4a87d72f3079 Mon Sep 17 00:00:00 2001 From: Pete Date: Thu, 3 Oct 2024 06:34:22 +0100 Subject: [PATCH 344/866] Update CHANGELOG.md --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c78254199..320de7f31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,9 @@ Some things may be added, some things may be removed, and some things may look d * Better support for symbolic links (https://github.com/qupath/qupath/issues/1586) * Bio-Formats preference to open remote images is now turned on by default (https://github.com/qupath/qupath/pull/1653) * This is needed to open remote ome.zarr images - but can be turned off in the preferences if necessary +* Self-contained projects that contain all images inside the project directory no longer prompt the user to update URIs if moved (https://github.com/qupath/qupath/pull/1668) +* Channel names can now be set for all fluorescence images, even if they are RGB (https://github.com/qupath/qupath/pull/1659) + * Note that channel colors still only be set for non-RGB images ### Experimental features These features are included for testing and feedback. From 13d3509876ffe69cca1566d3db742f5e2ce2ba50 Mon Sep 17 00:00:00 2001 From: Pete Date: Thu, 3 Oct 2024 11:38:50 +0100 Subject: [PATCH 345/866] Handle color changes for RGB fluorescence images Latest attempt to fix https://github.com/qupath/qupath-extension-omero/issues/25 --- .../main/java/qupath/lib/scripting/QP.java | 12 --- .../java/qupath/lib/display/ImageDisplay.java | 77 ++++++++++--------- .../lib/display/RBGColorTransformInfo.java | 12 --- .../commands/BrightnessContrastCommand.java | 10 ++- .../BrightnessContrastChannelPane.java | 10 +-- 5 files changed, 53 insertions(+), 68 deletions(-) diff --git a/qupath-core-processing/src/main/java/qupath/lib/scripting/QP.java b/qupath-core-processing/src/main/java/qupath/lib/scripting/QP.java index 419e4f922..5ea2f103b 100644 --- a/qupath-core-processing/src/main/java/qupath/lib/scripting/QP.java +++ b/qupath-core-processing/src/main/java/qupath/lib/scripting/QP.java @@ -1399,9 +1399,6 @@ public static void setChannels(ImageChannel... channels) { /** * Set the channels for the specified ImageData. * Note that number of channels provided must match the number of channels of the current image. - *

    - * Also, currently it is not possible to set channel colors for RGB images - attempting to do so - * will throw an IllegalArgumentException. * * @param imageData * @param channels @@ -1419,15 +1416,6 @@ public static void setChannels(ImageData imageData, ImageChannel... channels) if (oldChannels.size() != newChannels.size()) throw new IllegalArgumentException("Cannot set channels - require " + oldChannels.size() + " channels but you provided " + channels.length); - // Can't adjust channel colors for RGB images - but changing names is permitted - if (metadata.isRGB()) { - int[] oldColors = oldChannels.stream().mapToInt(ImageChannel::getColor).toArray(); - int[] newColors = newChannels.stream().mapToInt(ImageChannel::getColor).toArray(); - if (!Arrays.equals(oldColors, newColors)) { - throw new IllegalArgumentException("Cannot set channel colors for RGB images"); - } - } - // Set the metadata var metadata2 = new ImageServerMetadata.Builder(metadata) .channels(newChannels) diff --git a/qupath-gui-fx/src/main/java/qupath/lib/display/ImageDisplay.java b/qupath-gui-fx/src/main/java/qupath/lib/display/ImageDisplay.java index 75df5906f..81279f4a3 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/display/ImageDisplay.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/display/ImageDisplay.java @@ -97,11 +97,19 @@ public class ImageDisplay extends AbstractImageRenderer { private static final String PROPERTY_DISPLAY = ImageDisplay.class.getName(); // Lists to store the different kinds of channels we might need + // Pack RGB (all channels in one image, only adjustable together) private RGBDirectChannelInfo rgbDirectChannelInfo; + // Normalized optical density channels; useful to find the 'predominant' color when selecting stain vectors private RGBNormalizedChannelInfo rgbNormalizedChannelInfo; + // Direct (editable) RGB channels private List rgbDirectChannels = new ArrayList<>(); + // Split (uneditable) RGB channels + private List rgbSplitChannels = new ArrayList<>(); + // Hue, Saturation, Value private List rgbHsvChannels = new ArrayList<>(); + // Color-deconvolved channels private List rgbBrightfieldChannels = new ArrayList<>(); + // Chromaticity channels private List rgbChromaticityChannels = new ArrayList<>(); // Image & color transform-related variables @@ -368,6 +376,7 @@ private void createRGBChannels(final ImageData imageData) { rgbDirectChannelInfo = null; rgbNormalizedChannelInfo = null; + rgbSplitChannels.clear(); rgbDirectChannels.clear(); rgbHsvChannels.clear(); rgbBrightfieldChannels.clear(); @@ -380,9 +389,14 @@ private void createRGBChannels(final ImageData imageData) { rgbNormalizedChannelInfo = new RGBNormalizedChannelInfo(imageData); // Add simple channel separation (changed for v0.6.0) + rgbSplitChannels.add(new RBGColorTransformInfo(imageData, ColorTransformMethod.Red, false)); + rgbSplitChannels.add(new RBGColorTransformInfo(imageData, ColorTransformMethod.Green, false)); + rgbSplitChannels.add(new RBGColorTransformInfo(imageData, ColorTransformMethod.Blue, false)); + rgbDirectChannels.add(new DirectServerChannelInfo(imageData, 0)); rgbDirectChannels.add(new DirectServerChannelInfo(imageData, 1)); rgbDirectChannels.add(new DirectServerChannelInfo(imageData, 2)); + rgbHsvChannels.add(new RBGColorTransformInfo(imageData, ColorTransformer.ColorTransformMethod.Hue, false)); rgbHsvChannels.add(new RBGColorTransformInfo(imageData, ColorTransformer.ColorTransformMethod.Saturation, false)); rgbHsvChannels.add(new RBGColorTransformInfo(imageData, ColorTransformer.ColorTransformMethod.RGB_mean, false)); @@ -460,24 +474,34 @@ private void updateChannelOptions(boolean serverChanged) { List tempSelectedChannels = new ArrayList<>(this.selectedChannels); if (server.isRGB()) { createRGBChannels(imageData); - tempChannelOptions.add(rgbDirectChannelInfo); - // Add color deconvolution options if we have a brightfield image - if (imageData.isBrightfield()) { - tempChannelOptions.addAll(rgbBrightfieldChannels); - tempChannelOptions.add(rgbNormalizedChannelInfo); - } - tempChannelOptions.addAll(rgbDirectChannels); - if (imageData.getImageType() != ImageData.ImageType.FLUORESCENCE && showAllRGBTransforms.get()) { - // Change v0.6.0 - don't show all channels for fluorescence (as they are more distracting than helpful) - // If they are needed, using ImageType.OTHER - tempChannelOptions.addAll(rgbHsvChannels); - tempChannelOptions.addAll(rgbChromaticityChannels); + if (imageData.isFluorescence()) { + tempChannelOptions.addAll(rgbDirectChannels); + } else { + // Remove joint RGB display as an option for fluorescence + tempChannelOptions.add(rgbDirectChannelInfo); + // Add color deconvolution options if we have a brightfield image + if (imageData.isBrightfield()) { + tempChannelOptions.addAll(rgbBrightfieldChannels); + tempChannelOptions.add(rgbNormalizedChannelInfo); + } + tempChannelOptions.addAll(rgbSplitChannels); + if (showAllRGBTransforms.get()) { + // Change v0.6.0 - don't show all channels for fluorescence (as they are more distracting than helpful) + // If they are needed, using ImageType.OTHER + tempChannelOptions.addAll(rgbHsvChannels); + tempChannelOptions.addAll(rgbChromaticityChannels); + } } // Remove any invalid channels tempSelectedChannels.retainAll(tempChannelOptions); // Select the original channel (RGB) - if (tempSelectedChannels.isEmpty()) - tempSelectedChannels.add(tempChannelOptions.getFirst()); + if (tempSelectedChannels.isEmpty()) { + // Default to all channels + if (!useGrayscaleLuts.get() && tempChannelOptions.stream().allMatch(c -> c instanceof DirectServerChannelInfo)) + tempSelectedChannels.addAll(tempChannelOptions); + else + tempSelectedChannels.add(tempChannelOptions.getFirst()); + } } else if (serverChanged) { if (server.nChannels() == 1) { tempChannelOptions.add(new DirectServerChannelInfo(imageData, 0)); @@ -509,7 +533,7 @@ private void updateChannelOptions(boolean serverChanged) { if (serverChanged) { tempSelectedChannels.clear(); if (server.isRGB() || !useColorLUTs()) - tempSelectedChannels.add(tempChannelOptions.get(0)); + tempSelectedChannels.add(tempChannelOptions.getFirst()); else if (useColorLUTs()) tempSelectedChannels.addAll(tempChannelOptions); selectedChannels.clear(); @@ -565,7 +589,7 @@ private boolean loadChannelColorProperties() { if (n == 1) logger.info("Loaded color channel info for one channel"); else if (n > 1) - logger.info("Loaded color channel info for " + n + " channels"); + logger.info("Loaded color channel info for {} channels", n); return n > 0; } @@ -600,7 +624,7 @@ void setMinMaxDisplay(final ChannelDisplayInfo info , float minDisplay, float ma */ public void saveChannelColorProperties() { // Don't process a change if we're still setting the image data - if (settingImageData == true) + if (settingImageData) return; if (imageData == null) { logger.warn("Cannot save color channel properties - no ImageData available"); @@ -610,11 +634,7 @@ public void saveChannelColorProperties() { imageData.setProperty(PROPERTY_DISPLAY, toJSON(false)); changeTimestamp.set(System.currentTimeMillis()); } - -// public List getAvailableChannels() { -// return Collections.unmodifiableList(channelOptions); -// } private ObservableList selectedChannelsReadOnly = FXCollections.unmodifiableObservableList(selectedChannels); @@ -653,9 +673,6 @@ public void setChannelSelected(ChannelDisplayInfo channel, boolean selected) { // Try to minimize the number of events fired List tempSelectedChannels = new ArrayList<>(selectedChannels); if (selected) { - // If the channel is already selected, or wouldn't be valid anyway, we've got nothing to do - // if (selectedChannels.contains(channel) || !getAvailableChannels().contains(channel)) - // return getSelectedChannels(); // If this channel can't be combined with existing channels, clear the existing ones if (!useColorLUTs() || !channel.isAdditive() || (!tempSelectedChannels.isEmpty()) && !tempSelectedChannels.get(0).isAdditive()) tempSelectedChannels.clear(); @@ -741,18 +758,6 @@ public static BufferedImage applyTransforms(BufferedImage imgInput, BufferedImag boolean invertBackground = mode.invertColors(); boolean isGrayscale = mode == ChannelDisplayMode.GRAYSCALE || mode == ChannelDisplayMode.INVERTED_GRAYSCALE; - -// // If we don't have anything, just give a black image -// if (selectedChannels.isEmpty()) { -// Graphics2D g2d = imgOutput.createGraphics(); -//// if (invertBackground) // Don't really know if it should be black or white at this point -// g2d.setColor(Color.BLACK); -//// else -//// g2d.setColor(Color.WHITE); // TODO: Check if this is sensible -// g2d.fillRect(0, 0, width, height); -// g2d.dispose(); -// return imgOutput; -// } // Check if we have any changes to make - if not, just copy the image // Sometimes the first entry of selectedChannels was null... not sure why... this test is therefore to paper over the cracks... diff --git a/qupath-gui-fx/src/main/java/qupath/lib/display/RBGColorTransformInfo.java b/qupath-gui-fx/src/main/java/qupath/lib/display/RBGColorTransformInfo.java index 7eed6e2cb..d14fe8be5 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/display/RBGColorTransformInfo.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/display/RBGColorTransformInfo.java @@ -78,18 +78,6 @@ private static IndexColorModel invertColorModel(IndexColorModel cm) { @Override public String getName() { - // For RGB images, the channel names can sometimes be specified - var server = getImageServer(); - if (server != null) { - switch (method) { - case Red: - return server.getChannel(0).getName(); - case Green: - return server.getChannel(1).getName(); - case Blue: - return server.getChannel(2).getName(); - } - } return method.toString(); } diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/BrightnessContrastCommand.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/BrightnessContrastCommand.java index 2cb616239..52a0f322a 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/BrightnessContrastCommand.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/BrightnessContrastCommand.java @@ -573,8 +573,16 @@ public void propertyChange(PropertyChangeEvent evt) { var imageDisplay = imageDisplayProperty.getValue(); if (imageDisplay != null) { if (evt.getPropertyName().equals("serverMetadata") || - ((evt.getSource() instanceof ImageData) && evt.getPropertyName().equals("imageType"))) + ((evt.getSource() instanceof ImageData) && evt.getPropertyName().equals("imageType"))) { + var available = List.copyOf(imageDisplay.availableChannels()); imageDisplay.refreshChannelOptions(); + // When channels change (e.g. setting RGB image to fluorescence), + // this is needed to trigger viewer repaint & to save the channels in the properties - + // otherwise we can get a black image if we save now and reload. + if (!available.equals(imageDisplay.availableChannels())) { + imageDisplay.saveChannelColorProperties(); + } + } } table.updateTable(); diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/display/BrightnessContrastChannelPane.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/display/BrightnessContrastChannelPane.java index 919225558..664f13c4e 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/display/BrightnessContrastChannelPane.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/commands/display/BrightnessContrastChannelPane.java @@ -460,8 +460,6 @@ private void handleTableRowMouseClick(TableRow row, MouseEve int c = multiInfo.getChannel(); var channel = imageData.getServerMetadata().getChannel(c); - boolean canChangeColor = !imageData.getServerMetadata().isRGB(); - Color color = ColorToolsFX.getCachedColor(multiInfo.getColor()); picker.setValue(color); @@ -482,10 +480,7 @@ private void handleTableRowMouseClick(TableRow row, MouseEve var labelColor = new Label("Channel color"); labelColor.setLabelFor(picker); String colorTooltipText = "Choose the color for the current channel"; - if (!canChangeColor) { - picker.setDisable(true); - colorTooltipText = "Color cannot be changed for RGB images"; - } + GridPaneUtils.setFillWidth(Boolean.TRUE, picker, tfName); GridPaneUtils.addGridRow(paneColor, r++, 0, colorTooltipText, labelColor, picker); @@ -726,7 +721,8 @@ protected void updateItem(ChannelDisplayInfo item, boolean empty) { Integer channelRGB = item.getColor(); // Can only set the color for direct, non-RGB channels - boolean canChangeColor = !isRGB && channelRGB != null && item instanceof DirectServerChannelInfo; +// boolean canChangeColor = !isRGB && channelRGB != null && item instanceof DirectServerChannelInfo; + boolean canChangeColor = channelRGB != null && item instanceof DirectServerChannelInfo; colorPicker.setDisable(!canChangeColor); colorPicker.setOnShowing(null); if (channelRGB == null) { From 914f53ded9b8b4d29bd43a8ed420d569a2b78dea Mon Sep 17 00:00:00 2001 From: Pete Date: Thu, 3 Oct 2024 14:14:43 +0100 Subject: [PATCH 346/866] Fix ROI subtraction with ROIs don't intersect Fixes the issue reported at https://forum.image.sc/t/trouble-getting-gpu-to-work-with-instanseg-qupath/102042/32 --- qupath-core/src/main/java/qupath/lib/roi/RoiTools.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/qupath-core/src/main/java/qupath/lib/roi/RoiTools.java b/qupath-core/src/main/java/qupath/lib/roi/RoiTools.java index e4dab0176..106855065 100644 --- a/qupath-core/src/main/java/qupath/lib/roi/RoiTools.java +++ b/qupath-core/src/main/java/qupath/lib/roi/RoiTools.java @@ -274,7 +274,8 @@ public static ROI subtract(ROI roiMain, Collection roisToSubtract // Quick method using the union of ROIs to subtract // Could *possibly* be improved by iteratively removing ROIs if they are large - roiMain = difference(roiMain, union(roisToSubtract2)); + if (!roisToSubtract2.isEmpty()) + roiMain = difference(roiMain, union(roisToSubtract2)); return roiMain; } From a14fbef163f25dabaaa499790c755b8dc78b1c28 Mon Sep 17 00:00:00 2001 From: Pete Date: Thu, 3 Oct 2024 16:38:37 +0100 Subject: [PATCH 347/866] Support system light/dark mode Also use this by default. --- CHANGELOG.md | 1 + .../java/qupath/lib/gui/dialogs/Dialogs.java | 3 +- .../lib/gui/prefs/QuPathStyleManager.java | 206 ++++++++++++------ 3 files changed, 142 insertions(+), 68 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 320de7f31..965f10298 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ Some things may be added, some things may be removed, and some things may look d ### Enhancements (These are not yet ordered by interestingness) +* Support system light/dark color themes * Improved display of annotation names (https://github.com/qupath/qupath/pull/1532) * Support log10 counts with histograms (no longer use natural log) (https://github.com/qupath/qupath/pull/1540) * Log counts also now available in measurement tables diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/dialogs/Dialogs.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/dialogs/Dialogs.java index f8bd53665..07c25e1b7 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/dialogs/Dialogs.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/dialogs/Dialogs.java @@ -33,6 +33,7 @@ import java.util.WeakHashMap; import java.util.stream.Collectors; +import javafx.application.ColorScheme; import org.controlsfx.control.Notifications; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -445,7 +446,7 @@ private static Notifications createNotifications() { if (stage == null) return notifications; - if (!QuPathStyleManager.isDefaultStyle()) + if (QuPathStyleManager.getStyleColorScheme() == ColorScheme.DARK) notifications = notifications.darkStyle(); return notifications.owner(stage); diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/prefs/QuPathStyleManager.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/prefs/QuPathStyleManager.java index 5696ffa38..08ebf669b 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/prefs/QuPathStyleManager.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/prefs/QuPathStyleManager.java @@ -4,7 +4,7 @@ * %% * Copyright (C) 2014 - 2016 The Queen's University of Belfast, Northern Ireland * Contact: IP Management (ipmanagement@qub.ac.uk) - * Copyright (C) 2018 - 2023 QuPath developers, The University of Edinburgh + * Copyright (C) 2018 - 2024 QuPath developers, The University of Edinburgh * %% * QuPath is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as @@ -42,6 +42,8 @@ import java.util.Objects; import java.util.concurrent.ThreadFactory; +import javafx.application.ColorScheme; +import javafx.beans.property.ReadOnlyObjectProperty; import javafx.scene.control.ButtonType; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -70,7 +72,7 @@ */ public class QuPathStyleManager { - private static Logger logger = LoggerFactory.getLogger(QuPathStyleManager.class); + private static final Logger logger = LoggerFactory.getLogger(QuPathStyleManager.class); /** * Main stylesheet, used to define new colors for QuPath. @@ -86,24 +88,33 @@ public class QuPathStyleManager { /** * Default JavaFX stylesheet */ - private static final StyleOption DEFAULT_STYLE = new JavaFXStylesheet("Modena Light", Application.STYLESHEET_MODENA); + private static final StyleOption DEFAULT_LIGHT_STYLE = new JavaFXStylesheet("Modena Light", Application.STYLESHEET_MODENA); /** * Default QuPath stylesheet used for 'dark mode' */ - private static final StyleOption DEFAULT_DARK_STYLE = new CustomStylesheet("Modena Dark", "Darker version of JavaFX Modena stylesheet", STYLESHEET_DARK); + private static final StyleOption DEFAULT_DARK_STYLE = new CustomStylesheet( + "Modena Dark", + "Darker version of JavaFX Modena stylesheet", + ColorScheme.DARK, + STYLESHEET_DARK); // Maintain a record of what stylesheets we've added, so we can try to clean up later if needed private static final List previouslyAddedStyleSheets = new ArrayList<>(); + private static final StyleOption DEFAULT_SYSTEM_STYLE = new SystemStylesheet(DEFAULT_LIGHT_STYLE, DEFAULT_DARK_STYLE); + + private static final ReadOnlyObjectProperty systemColorScheme = Platform.getPreferences().colorSchemeProperty(); + private static final ObservableList styles = FXCollections.observableArrayList( - DEFAULT_STYLE, + DEFAULT_SYSTEM_STYLE, + DEFAULT_LIGHT_STYLE, DEFAULT_DARK_STYLE ); private static final ObservableList stylesUnmodifiable = FXCollections.unmodifiableObservableList(styles); - private static ObjectProperty selectedStyle; + private static final ObjectProperty selectedStyle; /** * Find the first available {@link StyleOption} with the specified name. @@ -111,7 +122,7 @@ public class QuPathStyleManager { * @return */ private static StyleOption findByName(String name) { - return styles.stream().filter(s -> Objects.equals(s.getName(), name)).findFirst().orElse(DEFAULT_STYLE); + return styles.stream().filter(s -> Objects.equals(s.getName(), name)).findFirst().orElse(DEFAULT_LIGHT_STYLE); } /** @@ -122,7 +133,7 @@ private static StyleOption findByName(String name) { /** * Available font families. */ - public static enum Fonts { + public enum Fonts { /** * JavaFX default. May not look great on macOS, which lacks support for bold font weight by default. */ @@ -137,36 +148,28 @@ public static enum Fonts { SERIF; private String getURL() { - switch(this) { - case SANS_SERIF: - return "css/sans-serif.css"; - case SERIF: - return "css/serif.css"; - case DEFAULT: - default: - return null; - } + return switch (this) { + case SANS_SERIF -> "css/sans-serif.css"; + case SERIF -> "css/serif.css"; + default -> null; + }; } @Override public String toString() { - switch(this) { - case SANS_SERIF: - return "Sans-serif"; - case SERIF: - return "Serif"; - case DEFAULT: - default: - return "Default"; - } + return switch (this) { + case SANS_SERIF -> "Sans-serif"; + case SERIF -> "Serif"; + default -> "Default"; + }; } } - private static ObservableList availableFonts = + private static final ObservableList availableFonts = FXCollections.unmodifiableObservableList( FXCollections.observableArrayList(Fonts.values())); - private static ObjectProperty selectedFont = PathPrefs.createPersistentPreference("selectedFont", + private static final ObjectProperty selectedFont = PathPrefs.createPersistentPreference("selectedFont", GeneralTools.isMac() ? Fonts.SANS_SERIF : Fonts.DEFAULT, Fonts.class); static { @@ -176,8 +179,15 @@ public String toString() { * We need to do this before setting the default (since the last used style might be one of these). */ updateAvailableStyles(); - selectedStyle = PathPrefs.createPersistentPreference("qupathStylesheet", DEFAULT_STYLE, s -> s.getName(), QuPathStyleManager::findByName); - + selectedStyle = PathPrefs.createPersistentPreference("qupathStylesheet", DEFAULT_SYSTEM_STYLE, StyleOption::getName, QuPathStyleManager::findByName); + + systemColorScheme.addListener((v, o, n) -> { + if (selectedStyle.get() == DEFAULT_SYSTEM_STYLE) { + updateStyle(); + } + }); + + // Add listener to adjust style as required selectedStyle.addListener((v, o, n) -> updateStyle()); selectedFont.addListener((v, o, n) -> updateStyle()); @@ -228,7 +238,7 @@ public static void updateAvailableStyles() { updateAvailableStyles(); }); } catch (Exception e) { - logger.warn("Exception searching for css files: " + e.getLocalizedMessage(), e); + logger.warn("Exception searching for css files: {}", e.getMessage(), e); } } else if (!Objects.equals(watcher.cssPath, cssPath)) { watcher.setCssPath(cssPath); @@ -241,20 +251,20 @@ public static void updateAvailableStyles() { // Update all available styles if (watcher == null || watcher.styles.isEmpty()) - styles.setAll(DEFAULT_STYLE, DEFAULT_DARK_STYLE); + styles.setAll(DEFAULT_SYSTEM_STYLE, DEFAULT_LIGHT_STYLE, DEFAULT_DARK_STYLE); else { var temp = new ArrayList(); - temp.add(DEFAULT_STYLE); + temp.add(DEFAULT_SYSTEM_STYLE); + temp.add(DEFAULT_LIGHT_STYLE); temp.add(DEFAULT_DARK_STYLE); temp.addAll(watcher.styles); -// temp.sort(Comparator.comparing(StyleOption::getName)); styles.setAll(temp); } // Reinstate the selection, or use the default if necessary if (selectedStyle != null) { if (previouslySelected == null || !styles.contains(previouslySelected)) - selectedStyle.set(DEFAULT_STYLE); + selectedStyle.set(DEFAULT_LIGHT_STYLE); else selectedStyle.set(previouslySelected); } @@ -318,7 +328,7 @@ else if (response == ButtonType.NO) nInstalled++; } } catch (IOException e) { - logger.error("Exception installing CSS files: " + e.getLocalizedMessage(), e); + logger.error("Exception installing CSS files: {}", e.getLocalizedMessage(), e); return false; } if (nInstalled > 0) @@ -338,11 +348,12 @@ public static void refresh() { } /** - * Check if the default JavaFX style is used. - * @return true if the default style is used, false otherwise. + * Get the color scheme of the current style, or the system color scheme if no other is available. + * @return */ - public static boolean isDefaultStyle() { - return DEFAULT_STYLE.equals(selectedStyle.get()); + public static ColorScheme getStyleColorScheme() { + var selected = selectedStyle.get(); + return selected == null ? Platform.getPreferences().getColorScheme() : selected.getColorScheme(); } /** @@ -383,26 +394,74 @@ public static ObjectProperty fontProperty() { /** * Interface defining a style that may be applied to QuPath. */ - public static interface StyleOption { + public interface StyleOption { /** * Set the style for the QuPath application. */ - public void setStyle(); + void setStyle(); /** * Get a user-friendly description of the style. * @return */ - public String getDescription(); + String getDescription(); /** * Get a user-friendly name for the style. * @return */ - public String getName(); + String getName(); + + /** + * Get the color scheme. By default this will return the color scheme from the Platform preferences, + * but implementations may return a different one for the specific theme. + * @return + */ + default ColorScheme getColorScheme() { + return Platform.getPreferences().getColorScheme(); + } } + + /** + * Default JavaFX stylesheet. + */ + static class SystemStylesheet implements StyleOption { + + private final StyleOption defaultLight; + private final StyleOption defaultDark; + + private SystemStylesheet(StyleOption defaultLight, StyleOption defaultDark) { + this.defaultLight = defaultLight; + this.defaultDark = defaultDark; + } + + @Override + public void setStyle() { + if (Platform.getPreferences().getColorScheme() == ColorScheme.DARK) { + defaultDark.setStyle(); + } else { + defaultLight.setStyle(); + } + } + + @Override + public String getDescription() { + return "Use a style based on the system-wide light/dark setting"; + } + + @Override + public String getName() { + return "System theme"; + } + + @Override + public String toString() { + return getName(); + } + + } /** @@ -434,7 +493,12 @@ public String getDescription() { public String getName() { return name; } - + + @Override + public ColorScheme getColorScheme() { + return ColorScheme.LIGHT; + } + @Override public String toString() { return getName(); @@ -465,18 +529,20 @@ public boolean equals(Object obj) { */ static class CustomStylesheet implements StyleOption { - private String name; - private String description; - private String[] urls; + private final String name; + private final String description; + private final String[] urls; + private final ColorScheme colorScheme; - CustomStylesheet(final String name, final String description, final String... urls) { + CustomStylesheet(final String name, final String description, final ColorScheme colorScheme, final String... urls) { this.name = name; this.description = description; this.urls = urls.clone(); + this.colorScheme = colorScheme; } CustomStylesheet(final Path path) { - this(GeneralTools.getNameWithoutExtension(path.toFile()), path.toString(), path.toUri().toString()); + this(GeneralTools.getNameWithoutExtension(path.toFile()), path.toString(), null, path.toUri().toString()); } @Override @@ -498,7 +564,20 @@ public String getName() { public String toString() { return getName(); } - + + @Override + public ColorScheme getColorScheme() { + if (colorScheme != null) + return colorScheme; + String name = getName().toLowerCase(); + if (name.contains("dark")) + return ColorScheme.DARK; + else if (name.contains("light")) + return ColorScheme.LIGHT; + else + return Platform.getPreferences().getColorScheme(); + } + /** * Check if a specified url is used as part of this stylesheet. * @param url @@ -538,10 +617,7 @@ public boolean equals(Object obj) { private static void setStyleSheets(String... urls) { Application.setUserAgentStylesheet(null); -// // Check if we need to do anything -// var toAdd = Arrays.asList(urls); -// if (previouslyAddedStyleSheets.equals(toAdd)) -// return; + // Replace previous stylesheets with the new ones removePreviousStyleSheets(); @@ -608,7 +684,7 @@ private CssStylesWatcher(Path cssPath) { watcher = FileSystems.getDefault().newWatchService(); logger.debug("Watching for changes in {}", cssPath); } catch (IOException e) { - logger.error("Exception setting up CSS watcher: " + e.getLocalizedMessage(), e); + logger.error("Exception setting up CSS watcher: {}", e.getMessage(), e); } setCssPath(cssPath); thread.start(); @@ -626,7 +702,7 @@ private void setCssPath(Path cssPath) { StandardWatchEventKinds.ENTRY_DELETE); logger.debug("Watching for changes in {}", cssPath); } catch (IOException e) { - logger.error("Exception setting up CSS watcher: " + e.getLocalizedMessage(), e); + logger.error("Exception setting up CSS watcher: {}", e.getMessage(), e); } } refreshStylesheets(); @@ -664,9 +740,8 @@ public void run() { if (ev.kind() == StandardWatchEventKinds.ENTRY_MODIFY && Files.isRegularFile(path)) { try { var currentStyle = selectedStyle.get(); - if (currentStyle instanceof CustomStylesheet) { - var currentCustomStyle = ((CustomStylesheet)currentStyle); - var url = path.toUri().toString(); + if (currentStyle instanceof CustomStylesheet currentCustomStyle) { + var url = path.toUri().toString(); if (currentCustomStyle.containsUrl(url)) { logger.info("Refreshing style {}", currentStyle.getName()); refresh(); @@ -674,7 +749,7 @@ public void run() { break; } } catch (Exception e) { - logger.warn("Exception processing CSS refresh: " + e.getLocalizedMessage(), e); + logger.warn("Exception processing CSS refresh: {}", e.getMessage(), e); } } else { // For everything else, refresh the available stylesheets @@ -696,7 +771,7 @@ private void refreshStylesheets() { if (Files.isDirectory(cssPath)) { var newStyles = Files.list(cssPath) .filter(p -> Files.isRegularFile(p) && p.getFileName().toString().toLowerCase().endsWith(".css")) - .map(path -> new CustomStylesheet(path)) + .map(CustomStylesheet::new) .sorted(Comparator.comparing(StyleOption::getName)) .toList(); FXUtils.runOnApplicationThread(() -> styles.setAll(newStyles)); @@ -707,10 +782,7 @@ private void refreshStylesheets() { } FXUtils.runOnApplicationThread(() -> styles.clear()); } - - } - - -} \ No newline at end of file + +} From fcfc72652ab8d01335dd72ea17f463bc35be7151 Mon Sep 17 00:00:00 2001 From: Pete Date: Fri, 4 Oct 2024 18:53:13 +0100 Subject: [PATCH 348/866] Format blockquotes in webviews We can only apply one CSS stylesheet to WebViews - which is used by the `WebViews` class to bind to dark mode changes. Therefore we need to add any interesting custom formatting to those shared stylesheets. This PR then adds some provisional support for nicer blockquote formatting, so that this is available elsewhere if needed (note that it's subject to change when someone who is better at css looks at it). --- .../resources/css/web-sans-serif-dark.css | 31 ++++++++++++++++++- .../resources/css/web-sans-serif-light.css | 31 ++++++++++++++++++- .../src/main/resources/css/web-serif-dark.css | 31 ++++++++++++++++++- .../main/resources/css/web-serif-light.css | 31 ++++++++++++++++++- 4 files changed, 120 insertions(+), 4 deletions(-) diff --git a/qupath-gui-fx/src/main/resources/css/web-sans-serif-dark.css b/qupath-gui-fx/src/main/resources/css/web-sans-serif-dark.css index 70f485070..8c8f6973f 100644 --- a/qupath-gui-fx/src/main/resources/css/web-sans-serif-dark.css +++ b/qupath-gui-fx/src/main/resources/css/web-sans-serif-dark.css @@ -7,4 +7,33 @@ body { .sub-nav .nav-list-search { /* Don't display built-in search, as it fails within a WebView */ display: none; -} \ No newline at end of file +} + +/*CSS that can be copied to different files*/ + +blockquote { + background: #f9f9f909; + border-left: 10px solid #ccc4; + margin: 1.5em 10px; + padding: 0.25em 10px; +} + +blockquote.tip { + background: rgba(40, 200, 40, 0.1); + border-left: 10px solid rgba(40, 200, 40, 0.4); +} + +blockquote.info { + background: rgba(80, 100, 200, 0.1); + border-left: 10px solid rgba(80, 100, 200, 0.4); +} + +blockquote.warn { + background: rgba(200, 40, 40, 0.1); + border-left: 10px solid rgba(200, 40, 40, 0.4); +} + +blockquote.caution { + background: rgba(220, 180, 40, 0.1); + border-left: 10px solid rgba(220, 180, 40, 0.4); +} diff --git a/qupath-gui-fx/src/main/resources/css/web-sans-serif-light.css b/qupath-gui-fx/src/main/resources/css/web-sans-serif-light.css index 13a9d6f11..7b0be575d 100644 --- a/qupath-gui-fx/src/main/resources/css/web-sans-serif-light.css +++ b/qupath-gui-fx/src/main/resources/css/web-sans-serif-light.css @@ -7,4 +7,33 @@ body { .sub-nav .nav-list-search { /* Don't display built-in search, as it fails within a WebView */ display: none; -} \ No newline at end of file +} + +/*CSS that can be copied to different files*/ + +blockquote { + background: #f9f9f909; + border-left: 10px solid #ccc4; + margin: 1.5em 10px; + padding: 0.25em 10px; +} + +blockquote.tip { + background: rgba(40, 200, 40, 0.1); + border-left: 10px solid rgba(40, 200, 40, 0.4); +} + +blockquote.info { + background: rgba(80, 100, 200, 0.1); + border-left: 10px solid rgba(80, 100, 200, 0.4); +} + +blockquote.warn { + background: rgba(200, 40, 40, 0.1); + border-left: 10px solid rgba(200, 40, 40, 0.4); +} + +blockquote.caution { + background: rgba(220, 180, 40, 0.1); + border-left: 10px solid rgba(220, 180, 40, 0.4); +} diff --git a/qupath-gui-fx/src/main/resources/css/web-serif-dark.css b/qupath-gui-fx/src/main/resources/css/web-serif-dark.css index f72aca087..799ec8cdc 100644 --- a/qupath-gui-fx/src/main/resources/css/web-serif-dark.css +++ b/qupath-gui-fx/src/main/resources/css/web-serif-dark.css @@ -7,4 +7,33 @@ body { .sub-nav .nav-list-search { /* Don't display built-in search, as it fails within a WebView */ display: none; -} \ No newline at end of file +} + +/*CSS that can be copied to different files*/ + +blockquote { + background: #f9f9f909; + border-left: 10px solid #ccc4; + margin: 1.5em 10px; + padding: 0.25em 10px; +} + +blockquote.tip { + background: rgba(40, 200, 40, 0.1); + border-left: 10px solid rgba(40, 200, 40, 0.4); +} + +blockquote.info { + background: rgba(80, 100, 200, 0.1); + border-left: 10px solid rgba(80, 100, 200, 0.4); +} + +blockquote.warn { + background: rgba(200, 40, 40, 0.1); + border-left: 10px solid rgba(200, 40, 40, 0.4); +} + +blockquote.caution { + background: rgba(220, 180, 40, 0.1); + border-left: 10px solid rgba(220, 180, 40, 0.4); +} diff --git a/qupath-gui-fx/src/main/resources/css/web-serif-light.css b/qupath-gui-fx/src/main/resources/css/web-serif-light.css index 90e200cf6..854d870ac 100644 --- a/qupath-gui-fx/src/main/resources/css/web-serif-light.css +++ b/qupath-gui-fx/src/main/resources/css/web-serif-light.css @@ -7,4 +7,33 @@ body { .sub-nav .nav-list-search { /* Don't display built-in search, as it fails within a WebView */ display: none; -} \ No newline at end of file +} + +/*CSS that can be copied to different files*/ + +blockquote { + background: #f9f9f909; + border-left: 10px solid #ccc4; + margin: 1.5em 10px; + padding: 0.25em 10px; +} + +blockquote.tip { + background: rgba(40, 200, 40, 0.1); + border-left: 10px solid rgba(40, 200, 40, 0.4); +} + +blockquote.info { + background: rgba(80, 100, 200, 0.1); + border-left: 10px solid rgba(80, 100, 200, 0.4); +} + +blockquote.warn { + background: rgba(200, 40, 40, 0.1); + border-left: 10px solid rgba(200, 40, 40, 0.4); +} + +blockquote.caution { + background: rgba(220, 180, 40, 0.1); + border-left: 10px solid rgba(220, 180, 40, 0.4); +} From d8fa19002b65ea68b3540fb4cbcc97c56ba57d02 Mon Sep 17 00:00:00 2001 From: Pete Date: Tue, 8 Oct 2024 07:23:00 +0100 Subject: [PATCH 349/866] IJ extract region persistent prefs Make the preferences persistent when sending a region to ImageJ. --- .../imagej/gui/ExtractRegionCommand.java | 60 ++++++++++++------- 1 file changed, 38 insertions(+), 22 deletions(-) diff --git a/qupath-extension-processing/src/main/java/qupath/imagej/gui/ExtractRegionCommand.java b/qupath-extension-processing/src/main/java/qupath/imagej/gui/ExtractRegionCommand.java index 1160af870..cc634dcc1 100644 --- a/qupath-extension-processing/src/main/java/qupath/imagej/gui/ExtractRegionCommand.java +++ b/qupath-extension-processing/src/main/java/qupath/imagej/gui/ExtractRegionCommand.java @@ -4,7 +4,7 @@ * %% * Copyright (C) 2014 - 2016 The Queen's University of Belfast, Northern Ireland * Contact: IP Management (ipmanagement@qub.ac.uk) - * Copyright (C) 2018 - 2022 QuPath developers, The University of Edinburgh + * Copyright (C) 2018 - 2022, 2024 QuPath developers, The University of Edinburgh * %% * QuPath is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as @@ -41,6 +41,11 @@ import java.util.List; import javax.swing.SwingUtilities; +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.DoubleProperty; +import javafx.beans.property.SimpleDoubleProperty; +import javafx.beans.property.SimpleStringProperty; +import javafx.beans.property.StringProperty; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -52,6 +57,7 @@ import qupath.lib.gui.QuPathGUI; import qupath.fx.dialogs.Dialogs; import qupath.lib.gui.images.servers.ChannelDisplayTransformServer; +import qupath.lib.gui.prefs.PathPrefs; import qupath.lib.gui.tools.GuiTools; import qupath.lib.gui.viewer.OverlayOptions; import qupath.lib.gui.viewer.QuPathViewer; @@ -82,13 +88,13 @@ class ExtractRegionCommand implements Runnable { private static final String PIXELS_UNIT = "Pixels (downsample)"; - private double resolution = 1; - private String resolutionUnit = PIXELS_UNIT; - private boolean includeROI = true; - private boolean includeOverlay = true; - private boolean doTransforms = false; - private boolean doZ = false; - private boolean doT = false; + private DoubleProperty resolution = PathPrefs.createPersistentPreference("ext.ij.extract.resolution", 1.0); + private StringProperty resolutionUnit = PathPrefs.createPersistentPreference("ext.ij.extract.resolutionUnit", PIXELS_UNIT); + private BooleanProperty includeROI = PathPrefs.createPersistentPreference("ext.ij.extract.includeROI", true); + private BooleanProperty includeOverlay = PathPrefs.createPersistentPreference("ext.ij.extract.includeOverlay", true); + private BooleanProperty doTransforms = PathPrefs.createPersistentPreference("ext.ij.extract.doTransforms", false); + private BooleanProperty doZ = PathPrefs.createPersistentPreference("ext.ij.extract.doZ", false); + private BooleanProperty doT = PathPrefs.createPersistentPreference("ext.ij.extract.doT", false); /** * Constructor. @@ -122,18 +128,19 @@ public void run() { String unit = server.getPixelCalibration().getPixelWidthUnit(); if (unit.equals(server.getPixelCalibration().getPixelHeightUnit()) && !unit.equals(PixelCalibration.PIXEL)) unitOptions.add(unit); - + + String resolutionUnit = this.resolutionUnit.get(); if (!unitOptions.contains(resolutionUnit)) resolutionUnit = PIXELS_UNIT; ParameterList params = new ParameterList() - .addDoubleParameter("resolution", "Resolution", resolution, null, "Resolution at which the image will be exported, defined as the 'pixel size' in Resolution units") + .addDoubleParameter("resolution", "Resolution", resolution.get(), null, "Resolution at which the image will be exported, defined as the 'pixel size' in Resolution units") .addChoiceParameter("resolutionUnit", "Resolution unit", resolutionUnit, unitOptions, "Units defining the export resolution; if 'pixels' then the resolution is the same as a downsample value") - .addBooleanParameter("includeROI", "Include ROI", includeROI, "Include the primary object defining the exported region as an active ROI in ImageJ") - .addBooleanParameter("includeOverlay", "Include overlay", includeOverlay, "Include any objects overlapping the exported region as ROIs on an ImageJ overlay") - .addBooleanParameter("doTransforms", "Apply color transforms", doTransforms, "Optionally apply any color transforms when sending the pixels to ImageJ") - .addBooleanParameter("doZ", "All z-slices", doZ, "Optionally include all slices of a z-stack") - .addBooleanParameter("doT", "All timepoints", doT, "Optionally include all timepoints of a time series") + .addBooleanParameter("includeROI", "Include ROI", includeROI.get(), "Include the primary object defining the exported region as an active ROI in ImageJ") + .addBooleanParameter("includeOverlay", "Include overlay", includeOverlay.get(), "Include any objects overlapping the exported region as ROIs on an ImageJ overlay") + .addBooleanParameter("doTransforms", "Apply color transforms", doTransforms.get(), "Optionally apply any color transforms when sending the pixels to ImageJ") + .addBooleanParameter("doZ", "All z-slices", doZ.get(), "Optionally include all slices of a z-stack") + .addBooleanParameter("doT", "All timepoints", doT.get(), "Optionally include all timepoints of a time series") ; // params.setHiddenParameters(unitOptions.size() <= 1, "resolutionUnit"); @@ -143,14 +150,23 @@ public void run() { if (!GuiTools.showParameterDialog("Send region to ImageJ", params)) return; - // Parse values - resolution = params.getDoubleParameterValue("resolution"); + // Parse values - store as local variables now, make persistent later + double resolution = params.getDoubleParameterValue("resolution"); resolutionUnit = (String)params.getChoiceParameterValue("resolutionUnit"); - includeROI = params.getBooleanParameterValue("includeROI"); - includeOverlay = params.getBooleanParameterValue("includeOverlay"); - doTransforms = params.getBooleanParameterValue("doTransforms"); - doZ = params.getBooleanParameterValue("doZ"); - doT = params.getBooleanParameterValue("doT"); + boolean includeROI = params.getBooleanParameterValue("includeROI"); + boolean includeOverlay = params.getBooleanParameterValue("includeOverlay"); + boolean doTransforms = params.getBooleanParameterValue("doTransforms"); + boolean doZ = params.getBooleanParameterValue("doZ"); + boolean doT = params.getBooleanParameterValue("doT"); + + // Now make persistent + this.resolution.set(resolution); + this.resolutionUnit.set(resolutionUnit); + this.includeROI.set(includeROI); + this.includeOverlay.set(includeOverlay); + this.doTransforms.set(doTransforms); + this.doZ.set(doZ); + this.doT.set(doT); // Calculate downsample double downsample = resolution; From 0c74b839203ca892561f2121ddff06329709520b Mon Sep 17 00:00:00 2001 From: Pete Date: Tue, 8 Oct 2024 11:58:10 +0100 Subject: [PATCH 350/866] Improve ImageJ Roi conversion Fixes https://github.com/qupath/qupath/issues/1674 Also add "Non-rectangles only" as an option when sending ROIs to ImageJ, since rectangles tend to correspond to the entire image region... and do more harm than good. --- .../java/qupath/imagej/tools/IJTools.java | 45 +++++-------- .../qupath/imagej/tools/ROIConverterIJ.java | 62 ++++++++---------- .../imagej/gui/ExtractRegionCommand.java | 63 ++++++++++++++----- 3 files changed, 93 insertions(+), 77 deletions(-) diff --git a/qupath-core-processing/src/main/java/qupath/imagej/tools/IJTools.java b/qupath-core-processing/src/main/java/qupath/imagej/tools/IJTools.java index 3c01bc9c6..302e061a9 100644 --- a/qupath-core-processing/src/main/java/qupath/imagej/tools/IJTools.java +++ b/qupath-core-processing/src/main/java/qupath/imagej/tools/IJTools.java @@ -4,7 +4,7 @@ * %% * Copyright (C) 2014 - 2016 The Queen's University of Belfast, Northern Ireland * Contact: IP Management (ipmanagement@qub.ac.uk) - * Copyright (C) 2018 - 2022 QuPath developers, The University of Edinburgh + * Copyright (C) 2018 - 2022, 2024 QuPath developers, The University of Edinburgh * %% * QuPath is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as @@ -24,8 +24,6 @@ package qupath.imagej.tools; import java.awt.Color; -import java.awt.Shape; -import java.awt.geom.AffineTransform; import java.awt.image.BufferedImage; import java.awt.image.DataBuffer; import java.awt.image.SampleModel; @@ -41,6 +39,7 @@ import javax.swing.SwingUtilities; +import org.locationtech.jts.geom.Polygon; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -94,7 +93,6 @@ import qupath.lib.roi.PolylineROI; import qupath.lib.roi.ROIs; import qupath.lib.roi.RectangleROI; -import qupath.lib.roi.RoiTools; import qupath.lib.roi.interfaces.ROI; /** @@ -173,7 +171,7 @@ public static boolean isMemorySufficient(RegionRequest region, final ImageData 2147480000L || approxMemory > presumableFreeMemory * MEMORY_THRESHOLD); + return approxMemory > presumableFreeMemory * MEMORY_THRESHOLD; } @@ -1037,11 +1035,11 @@ public static PolygonROI convertToPolygonROI(PolygonRoi roi, Calibration cal, do * @param xOrigin x-origin indicating relationship of ImagePlus to the original image, as stored in ImageJ Calibration object * @param yOrigin y-origin indicating relationship of ImagePlus to the original image, as stored in ImageJ Calibration object * @param downsampleFactor downsample factor at which the ImagePlus was extracted from the full-resolution image - * @return + * @return the converted ROI, or null if no input ROI was provided */ public static > Roi convertToIJRoi(ROI pathROI, double xOrigin, double yOrigin, double downsampleFactor) { - if (pathROI instanceof PolygonROI) - return ROIConverterIJ.convertToPolygonROI((PolygonROI)pathROI, xOrigin, yOrigin, downsampleFactor); + if (pathROI == null) + return null; if (pathROI instanceof RectangleROI) return ROIConverterIJ.getRectangleROI((RectangleROI)pathROI, xOrigin, yOrigin, downsampleFactor); if (pathROI instanceof EllipseROI) @@ -1052,15 +1050,18 @@ public static > Roi convertToIJRoi(ROI pathROI, d return ROIConverterIJ.convertToPolygonROI((PolylineROI)pathROI, xOrigin, yOrigin, downsampleFactor); if (pathROI instanceof PointsROI) return ROIConverterIJ.convertToPointROI((PointsROI)pathROI, xOrigin, yOrigin, downsampleFactor); - // If we have any other kind of shape, create a general shape roi - if (pathROI != null && pathROI.isArea()) { // TODO: Deal with non-AWT area ROIs! - Shape shape = RoiTools.getArea(pathROI); - // "scaleX", "shearY", "shearX", "scaleY", "translateX", "translateY" - shape = new AffineTransform(1.0/downsampleFactor, 0, 0, 1.0/downsampleFactor, xOrigin, yOrigin).createTransformedShape(shape); - return ROIConverterIJ.setIJRoiProperties(new ShapeRoi(shape), pathROI); + if (pathROI instanceof PolygonROI) { + // We should only use a PolygonROI if we have a simple polygon, without holes or self-intersections + // See https://github.com/qupath/qupath/issues/1674 + var geom = pathROI.getGeometry(); + if (geom instanceof Polygon && geom.getNumGeometries() == 1 && ((Polygon) geom).getNumInteriorRing() == 0) + return ROIConverterIJ.convertToPolygonROI((PolygonROI) pathROI, xOrigin, yOrigin, downsampleFactor); + } + // If we have any other kind of area, create a general shape roi + if (pathROI.isArea()) { + return ROIConverterIJ.convertToShapeRoi(pathROI, xOrigin, yOrigin, downsampleFactor); } - // TODO: Integrate ROI not supported exception...? - return null; + throw new UnsupportedOperationException("Unknown ROI " + pathROI + " - cannot convert to ImageJ Roi"); } /** @@ -1143,16 +1144,4 @@ public static FloatProcessor[] colorDeconvolve(ColorProcessor cp, ColorDeconvolu return new FloatProcessor[] {fpStain1, fpStain2, fpStain3}; } -// private static PathImage createPathImage(final ImageServer server, final RegionRequest request) { -// return new PathImagePlus(server, request, null); -// } -// -// private static PathImage createPathImage(final ImageServer server, final double downsample) { -// return createPathImage(server, RegionRequest.createInstance(server.getPath(), downsample, 0, 0, server.getWidth(), server.getHeight())); -// } -// -// private static PathImage createPathImage(final ImageServer server, final ROI pathROI, final double downsample) { -// return createPathImage(server, RegionRequest.createInstance(server.getPath(), downsample, pathROI)); -// } - } diff --git a/qupath-core-processing/src/main/java/qupath/imagej/tools/ROIConverterIJ.java b/qupath-core-processing/src/main/java/qupath/imagej/tools/ROIConverterIJ.java index da5729633..27ae1d7f2 100644 --- a/qupath-core-processing/src/main/java/qupath/imagej/tools/ROIConverterIJ.java +++ b/qupath-core-processing/src/main/java/qupath/imagej/tools/ROIConverterIJ.java @@ -4,7 +4,7 @@ * %% * Copyright (C) 2014 - 2016 The Queen's University of Belfast, Northern Ireland * Contact: IP Management (ipmanagement@qub.ac.uk) - * Copyright (C) 2018 - 2020 QuPath developers, The University of Edinburgh + * Copyright (C) 2018 - 2020, 2024 QuPath developers, The University of Edinburgh * %% * QuPath is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as @@ -41,6 +41,7 @@ import qupath.lib.roi.PolylineROI; import qupath.lib.roi.ROIs; import qupath.lib.roi.RectangleROI; +import qupath.lib.roi.RoiTools; import qupath.lib.roi.interfaces.ROI; import ij.gui.Line; import ij.gui.OvalRoi; @@ -67,14 +68,6 @@ private static double convertYtoIJ(double y, double yOrigin, double downsample) return y / downsample + yOrigin; } - @Deprecated - static T setIJRoiProperties(T roi, ROI pathROI) { -//// roi.setStrokeColor(pathROI.getStrokeColor()); -//// roi.setStrokeWidth(pathROI.getStrokeWidth()); -// roi.setName(pathROI.getName()); - return roi; - } - private static Rectangle2D getTransformedBounds(ROI pathROI, double xOrigin, double yOrigin, double downsampleFactor) { Rectangle2D bounds = AwtTools.getBounds2D(pathROI); double x1 = convertXtoIJ(bounds.getMinX(), xOrigin, downsampleFactor); @@ -109,34 +102,49 @@ private static float[][] getTransformedPoints(Collection points, double static Roi getRectangleROI(RectangleROI pathRectangle, double xOrigin, double yOrigin, double downsampleFactor) { Rectangle2D bounds = getTransformedBounds(pathRectangle, xOrigin, yOrigin, downsampleFactor); - return setIJRoiProperties(new Roi(bounds.getX(), bounds.getY(), bounds.getWidth(), bounds.getHeight()), pathRectangle); + return new Roi(bounds.getX(), bounds.getY(), bounds.getWidth(), bounds.getHeight()); } static OvalRoi convertToOvalROI(EllipseROI pathOval, double xOrigin, double yOrigin, double downsampleFactor) { Rectangle2D bounds = getTransformedBounds(pathOval, xOrigin, yOrigin, downsampleFactor); - return setIJRoiProperties(new OvalRoi(bounds.getX(), bounds.getY(), bounds.getWidth(), bounds.getHeight()), pathOval); + return new OvalRoi(bounds.getX(), bounds.getY(), bounds.getWidth(), bounds.getHeight()); } static Line convertToLineROI(LineROI pathLine, double xOrigin, double yOrigin, double downsampleFactor) { - return setIJRoiProperties(new Line(convertXtoIJ(pathLine.getX1(), xOrigin, downsampleFactor), + return new Line(convertXtoIJ(pathLine.getX1(), xOrigin, downsampleFactor), convertYtoIJ(pathLine.getY1(), yOrigin, downsampleFactor), convertXtoIJ(pathLine.getX2(), xOrigin, downsampleFactor), - convertYtoIJ(pathLine.getY2(), yOrigin, downsampleFactor)), pathLine); + convertYtoIJ(pathLine.getY2(), yOrigin, downsampleFactor)); } static PointRoi convertToPointROI(PointsROI pathPoints, double xOrigin, double yOrigin, double downsampleFactor) { float[][] points = getTransformedPoints(pathPoints.getAllPoints(), xOrigin, yOrigin, downsampleFactor); - return setIJRoiProperties(new PointRoi(points[0], points[1]), pathPoints); + return new PointRoi(points[0], points[1]); } static PolygonRoi convertToPolygonROI(PolygonROI pathPolygon, double xOrigin, double yOrigin, double downsampleFactor) { float[][] points = getTransformedPoints(pathPolygon.getAllPoints(), xOrigin, yOrigin, downsampleFactor); - return setIJRoiProperties(new PolygonRoi(points[0], points[1], Roi.POLYGON), pathPolygon); + return new PolygonRoi(points[0], points[1], Roi.POLYGON); } static PolygonRoi convertToPolygonROI(PolylineROI pathPolygon, double xOrigin, double yOrigin, double downsampleFactor) { float[][] points = getTransformedPoints(pathPolygon.getAllPoints(), xOrigin, yOrigin, downsampleFactor); - return setIJRoiProperties(new PolygonRoi(points[0], points[1], Roi.POLYLINE), pathPolygon); + return new PolygonRoi(points[0], points[1], Roi.POLYLINE); + } + + static ShapeRoi convertToShapeRoi(ROI roi, double xOrigin, double yOrigin, double downsampleFactor) { + if (roi != null && roi.isArea()) { // TODO: Deal with non-AWT area ROIs! + Shape shape = RoiTools.getArea(roi); + return convertToShapeRoi(shape, xOrigin, yOrigin, downsampleFactor); + } else { + throw new UnsupportedOperationException("Only ROIs representing areas can be converted to a ShapeRoi!"); + } + } + + private static ShapeRoi convertToShapeRoi(Shape shape, double xOrigin, double yOrigin, double downsampleFactor) { + shape = new AffineTransform(1.0/downsampleFactor, 0, 0, 1.0/downsampleFactor, xOrigin, yOrigin) + .createTransformedShape(shape); + return new ShapeRoi(shape); } /** @@ -161,8 +169,8 @@ static ROI convertToPolylineROI(PolygonRoi roi, double xOrigin, double yOrigin, static ROI convertToPolygonOrAreaROI(Roi roi, double xOrigin, double yOrigin, double downsampleFactor, final int c, final int z, final int t) { Shape shape; - if (roi instanceof ShapeRoi) - shape = ((ShapeRoi)roi).getShape(); + if (roi instanceof ShapeRoi shapeRoi) + shape = shapeRoi.getShape(); else shape = new ShapeRoi(roi).getShape(); AffineTransform transform = new AffineTransform(); @@ -170,7 +178,6 @@ static ROI convertToPolygonOrAreaROI(Roi roi, double xOrigin, double yOrigin, do transform.translate(roi.getXBase(), roi.getYBase()); transform.translate(-xOrigin, -yOrigin); return ROIs.createAreaROI(new Area(transform.createTransformedShape(shape)), ImagePlane.getPlaneWithChannel(c, z, t)); -// return setPathROIProperties(new PathAreaROI(transform.createTransformedShape(shape)), roi); } static ROI convertToAreaROI(ShapeRoi roi, double xOrigin, double yOrigin, double downsampleFactor, final int c, final int z, final int t) { @@ -179,7 +186,6 @@ static ROI convertToAreaROI(ShapeRoi roi, double xOrigin, double yOrigin, double transform.scale(downsampleFactor, downsampleFactor); transform.translate(roi.getXBase(), roi.getYBase()); transform.translate(-xOrigin, -yOrigin); -// return setPathROIProperties(PathROIHelpers.getShapeROI(new Area(transform.createTransformedShape(shape)), 0, 0, 0), roi); return ROIs.createAreaROI(new Area(transform.createTransformedShape(shape)), ImagePlane.getPlaneWithChannel(c, z, t)); } @@ -192,12 +198,6 @@ protected static Rectangle2D getTransformedBoundsFromIJ(Roi roi, double xOrigin, double y2 = convertLocationfromIJ(bounds.getMaxY(), yOrigin, downsampleFactor); return new Rectangle2D.Double( x1, y1, x2-x1, y2-y1); - -// return new Rectangle2D.Double( -// convertXfromIJ(bounds.getX(), cal, downsampleFactor), -// convertYfromIJ(bounds.getY(), cal, downsampleFactor), -// convertXfromIJ(bounds.getWidth(), null, downsampleFactor), -// convertYfromIJ(bounds.getHeight(), null, downsampleFactor)); } @@ -218,18 +218,10 @@ static ROI convertToLineROI(Line roi, double xOrigin, double yOrigin, double dow double y2 = convertLocationfromIJ(roi.y2d, yOrigin, downsampleFactor); return ROIs.createLineROI(x1, y1, x2, y2, ImagePlane.getPlaneWithChannel(c, z, t)); } - - private static ROI convertToPointROI(PolygonRoi roi, Calibration cal, double downsampleFactor, final int c, final int z, final int t) { - double x = cal == null ? 0 : cal.xOrigin; - double y = cal == null ? 0 : cal.yOrigin; - return convertToPointROI(roi, x, y, downsampleFactor, c, z, t); - } static ROI convertToPointROI(PolygonRoi roi, double xOrigin, double yOrigin, double downsampleFactor, final int c, final int z, final int t) { List points = convertToPointsList(roi.getFloatPolygon(), xOrigin, yOrigin, downsampleFactor); - if (points == null) - return null; - return ROIs.createPointsROI(points, ImagePlane.getPlaneWithChannel(c, z, t)); + return ROIs.createPointsROI(points, ImagePlane.getPlaneWithChannel(c, z, t)); } static List convertToPointsList(FloatPolygon polygon, Calibration cal, double downsampleFactor) { diff --git a/qupath-extension-processing/src/main/java/qupath/imagej/gui/ExtractRegionCommand.java b/qupath-extension-processing/src/main/java/qupath/imagej/gui/ExtractRegionCommand.java index cc634dcc1..a0adbed67 100644 --- a/qupath-extension-processing/src/main/java/qupath/imagej/gui/ExtractRegionCommand.java +++ b/qupath-extension-processing/src/main/java/qupath/imagej/gui/ExtractRegionCommand.java @@ -36,6 +36,7 @@ import java.awt.image.BufferedImage; import java.io.IOException; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.List; @@ -43,8 +44,7 @@ import javafx.beans.property.BooleanProperty; import javafx.beans.property.DoubleProperty; -import javafx.beans.property.SimpleDoubleProperty; -import javafx.beans.property.SimpleStringProperty; +import javafx.beans.property.ObjectProperty; import javafx.beans.property.StringProperty; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -68,6 +68,7 @@ import qupath.lib.objects.hierarchy.PathObjectHierarchy; import qupath.lib.plugins.parameters.ParameterList; import qupath.lib.regions.RegionRequest; +import qupath.lib.roi.RectangleROI; import qupath.lib.roi.interfaces.ROI; /** @@ -83,14 +84,42 @@ class ExtractRegionCommand implements Runnable { private static Logger logger = LoggerFactory.getLogger(ExtractRegionCommand.class); - + + /** + * Enum to control whether ROIs are included when sending regions to ImageJ. + */ + public enum RoiInclude { + /** + * Include all ROIs + */ + YES, + /** + * Include no ROIs + */ + NO, + /** + * Only include non-rectangular ROIs. + * Because the image is a rectangle, a rectangular ROI includes the entire image - + * so is often not very useful. + */ + NON_RECTANGLE; + + public String toString() { + return switch (this) { + case YES -> "Yes"; + case NO -> "No"; + case NON_RECTANGLE -> "Non-rectangles only"; + }; + } + } + private QuPathGUI qupath; private static final String PIXELS_UNIT = "Pixels (downsample)"; private DoubleProperty resolution = PathPrefs.createPersistentPreference("ext.ij.extract.resolution", 1.0); private StringProperty resolutionUnit = PathPrefs.createPersistentPreference("ext.ij.extract.resolutionUnit", PIXELS_UNIT); - private BooleanProperty includeROI = PathPrefs.createPersistentPreference("ext.ij.extract.includeROI", true); + private ObjectProperty includeROI = PathPrefs.createPersistentPreference("ext.ij.extract.includeROI", RoiInclude.YES, RoiInclude.class); private BooleanProperty includeOverlay = PathPrefs.createPersistentPreference("ext.ij.extract.includeOverlay", true); private BooleanProperty doTransforms = PathPrefs.createPersistentPreference("ext.ij.extract.doTransforms", false); private BooleanProperty doZ = PathPrefs.createPersistentPreference("ext.ij.extract.doZ", false); @@ -136,13 +165,13 @@ public void run() { ParameterList params = new ParameterList() .addDoubleParameter("resolution", "Resolution", resolution.get(), null, "Resolution at which the image will be exported, defined as the 'pixel size' in Resolution units") .addChoiceParameter("resolutionUnit", "Resolution unit", resolutionUnit, unitOptions, "Units defining the export resolution; if 'pixels' then the resolution is the same as a downsample value") - .addBooleanParameter("includeROI", "Include ROI", includeROI.get(), "Include the primary object defining the exported region as an active ROI in ImageJ") + .addChoiceParameter("includeROI", "Include ROI", includeROI.get(), Arrays.asList(RoiInclude.values()), "Include the primary object defining the exported region as an active ROI in ImageJ") .addBooleanParameter("includeOverlay", "Include overlay", includeOverlay.get(), "Include any objects overlapping the exported region as ROIs on an ImageJ overlay") .addBooleanParameter("doTransforms", "Apply color transforms", doTransforms.get(), "Optionally apply any color transforms when sending the pixels to ImageJ") .addBooleanParameter("doZ", "All z-slices", doZ.get(), "Optionally include all slices of a z-stack") .addBooleanParameter("doT", "All timepoints", doT.get(), "Optionally include all timepoints of a time series") ; - + // params.setHiddenParameters(unitOptions.size() <= 1, "resolutionUnit"); params.setHiddenParameters(server.nZSlices() == 1, "doZ"); params.setHiddenParameters(server.nTimepoints() == 1, "doT"); @@ -153,7 +182,7 @@ public void run() { // Parse values - store as local variables now, make persistent later double resolution = params.getDoubleParameterValue("resolution"); resolutionUnit = (String)params.getChoiceParameterValue("resolutionUnit"); - boolean includeROI = params.getBooleanParameterValue("includeROI"); + RoiInclude includeROI = (RoiInclude)params.getChoiceParameterValue("includeROI"); boolean includeOverlay = params.getBooleanParameterValue("includeOverlay"); boolean doTransforms = params.getBooleanParameterValue("doTransforms"); boolean doZ = params.getBooleanParameterValue("doZ"); @@ -246,7 +275,14 @@ public void run() { if (pathObjects.size() == 1 && !Dialogs.showYesNoDialog("Send region to ImageJ", String.format("Attempting to extract this region is likely to require > %.2f MB - are you sure you want to continue?", memory/1024/1024))) return; } - + + // Determine if we really do what to send the ROI + boolean doIncludeRoi = switch (includeROI) { + case NON_RECTANGLE -> roi != null && !(roi instanceof RectangleROI); + case YES -> true; + case NO -> false; + }; + // We should switch to the event dispatch thread when interacting with ImageJ try { ImagePlus imp; @@ -255,7 +291,7 @@ public void run() { if (zEnd - zStart > 1 || tEnd - tStart > 1) { // TODO: Handle overlays imp = IJTools.extractHyperstack(server, region, zStart, zEnd, tStart, tEnd); - if (includeROI && roi != null) { + if (doIncludeRoi && roi != null) { Roi roiIJ = IJTools.convertToIJRoi(roi, imp.getCalibration(), region.getDownsample()); imp.setRoi(roiIJ); } @@ -279,23 +315,22 @@ public void run() { imp.setOverlay(overlay); } } else if (includeOverlay) - imp = IJExtension.extractROIWithOverlay(server, pathObject, hierarchy, region, includeROI, options).getImage(); + imp = IJExtension.extractROIWithOverlay(server, pathObject, hierarchy, region, doIncludeRoi, options).getImage(); else - imp = IJExtension.extractROIWithOverlay(server, pathObject, null, region, includeROI, options).getImage(); + imp = IJExtension.extractROIWithOverlay(server, pathObject, null, region, doIncludeRoi, options).getImage(); // Set display ranges and invert LUTs if we should (and can) boolean invertLUTs = imageDisplay.useInvertedBackground(); // We can't set the LUTs for an RGB image in ImageJ // if (invertLUTs) // imp = CompositeConverter.makeComposite(imp); - if (viewer != null && imp instanceof CompositeImage) { + if (imp instanceof CompositeImage impComp) { var tempChannels = channels == null ? imageDisplay.availableChannels() : channels; var availableSingleChannels = tempChannels.stream() .filter(c -> c instanceof SingleChannelDisplayInfo) .map(c -> (SingleChannelDisplayInfo)c) .toList(); - CompositeImage impComp = (CompositeImage)imp; // If we're displaying with an inverted background, we need to set this property for the composite mode if (invertLUTs) { impComp.setProp("CompositeProjection", "invert"); @@ -329,7 +364,7 @@ public void run() { } } else if (selectedChannels.size() == 1 && imp.getType() != ImagePlus.COLOR_RGB) { // Setting the display range for non-RGB images can give unexpected results (changing pixel values) - var channel = selectedChannels.get(0); + var channel = selectedChannels.getFirst(); imp.setDisplayRange(channel.getMinDisplay(), channel.getMaxDisplay()); } imps.add(imp); From e9b4f8df16d80448e51a1e6b4e9701cf11433458 Mon Sep 17 00:00:00 2001 From: Pete Date: Thu, 10 Oct 2024 16:08:46 +0100 Subject: [PATCH 351/866] Fix BufferedImageTools Fix `ensureBufferedImage(Image)` method, which was changing dimensions for non-square images. Add `stripEmptyAlpha` method. --- .../lib/awt/common/BufferedImageTools.java | 28 ++++++++++- .../awt/common/TestBufferedImageTools.java | 46 ++++++++++++++++--- 2 files changed, 67 insertions(+), 7 deletions(-) diff --git a/qupath-core/src/main/java/qupath/lib/awt/common/BufferedImageTools.java b/qupath-core/src/main/java/qupath/lib/awt/common/BufferedImageTools.java index 4b2566b4e..044b86c61 100644 --- a/qupath-core/src/main/java/qupath/lib/awt/common/BufferedImageTools.java +++ b/qupath-core/src/main/java/qupath/lib/awt/common/BufferedImageTools.java @@ -36,6 +36,7 @@ import java.awt.image.WritableRaster; import java.util.Hashtable; import java.util.List; +import java.util.stream.IntStream; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -187,12 +188,37 @@ public static BufferedImage ensureBufferedImageType(final BufferedImage img, int public static BufferedImage ensureBufferedImage(Image image) { if (image instanceof BufferedImage) return (BufferedImage)image; - var imgBuf = new BufferedImage(image.getWidth(null), image.getWidth(null), BufferedImage.TYPE_INT_ARGB); + var imgBuf = new BufferedImage(image.getWidth(null), image.getHeight(null), BufferedImage.TYPE_INT_ARGB); var g2d = imgBuf.createGraphics(); g2d.drawImage(image, 0, 0, null); g2d.dispose(); return imgBuf; } + + /** + * Remove the alpha channel for an 8-bit color BufferedImage if it contains only 255 for all pixels. + * Does nothing if {@code is8bitColorType(img.getType())} returns false. + *

    + * The purpose of this is to get rid of an alpha channel if it has no effect, which may improve + * efficiency or reduce file sizes if the image is saved. + * @param img + * @return the input image unchanged, or a converted image of type {@code TYPE_INT_RGB}. + */ + public static BufferedImage stripEmptyAlpha(BufferedImage img) { + if (!is8bitColorType(img.getType())) { + logger.debug("stripEmptyAlpha requires an 8-bit color type - will skip type {}", img.getType()); + return img; + } + var raster = img.getAlphaRaster(); + if (raster == null) + return img; + int[] alpha = raster.getSamples(0, 0, raster.getWidth(), raster.getHeight(), 0, (int[])null); + if (!IntStream.of(alpha).allMatch(i -> i == 255)) + return img; + logger.debug("stripEmptyAlpha converting from type {} to {}", img.getType(), BufferedImage.TYPE_INT_RGB); + return BufferedImageTools.ensureBufferedImageType(img, BufferedImage.TYPE_INT_RGB); + } + /** * Duplicate a BufferedImage. This retains the same color model, but copies the raster. diff --git a/qupath-core/src/test/java/qupath/lib/awt/common/TestBufferedImageTools.java b/qupath-core/src/test/java/qupath/lib/awt/common/TestBufferedImageTools.java index 1cb5783ee..028e90bbd 100644 --- a/qupath-core/src/test/java/qupath/lib/awt/common/TestBufferedImageTools.java +++ b/qupath-core/src/test/java/qupath/lib/awt/common/TestBufferedImageTools.java @@ -235,15 +235,47 @@ public void test_ensureBufferedImageType() { assertEquals(BufferedImage.TYPE_INT_BGR, BufferedImageTools.ensureBufferedImageType(img, BufferedImage.TYPE_INT_BGR).getType()); assertEquals(BufferedImage.TYPE_INT_ARGB, BufferedImageTools.ensureBufferedImageType(img, BufferedImage.TYPE_INT_ARGB).getType()); } - + @Test public void test_ensureBufferedImage() { - Image img = new BufferedImage(250, 250, BufferedImage.TYPE_INT_RGB); - assertEquals(img, BufferedImageTools.ensureBufferedImage(img)); - // TODO: Check with other types of images - + var img = createEllipseImage(100, 200, BufferedImage.TYPE_INT_RGB, Color.MAGENTA, Color.GREEN); + var imgScaled = img.getScaledInstance(50, -1, Image.SCALE_SMOOTH); + var imgConverted = BufferedImageTools.ensureBufferedImage(imgScaled); + assertEquals(BufferedImage.class, imgConverted.getClass()); + assertEquals(50, imgConverted.getWidth()); + assertEquals(100, imgConverted.getHeight()); } - + + @Test + public void test_doNotStripAlpha() { + var img = new BufferedImage(50, 50, BufferedImage.TYPE_INT_ARGB); + var g2d = img.createGraphics(); + g2d.setBackground(new Color(0, 0, 0, 0)); + g2d.clearRect(0, 0, 50, 50); + g2d.setColor(Color.RED); + g2d.fillOval(0, 0, 50, 50); + g2d.dispose(); + assertEquals(BufferedImage.TYPE_INT_ARGB, img.getType()); + var imgConverted = BufferedImageTools.stripEmptyAlpha(img); + assertEquals(BufferedImage.TYPE_INT_ARGB, imgConverted.getType()); + } + + @Test + public void test_doStripAlpha() { + var img = new BufferedImage(50, 50, BufferedImage.TYPE_INT_ARGB); + var g2d = img.createGraphics(); + g2d.setBackground(new Color(0, 0, 0, 255)); + g2d.clearRect(0, 0, 50, 50); + g2d.setColor(Color.RED); + g2d.fillOval(0, 0, 50, 50); + g2d.dispose(); + assertEquals(BufferedImage.TYPE_INT_ARGB, img.getType()); + var imgConverted = BufferedImageTools.stripEmptyAlpha(img); + assertEquals(BufferedImage.TYPE_INT_RGB, imgConverted.getType()); + } + + + @Test public void test_duplicate() { @@ -664,4 +696,6 @@ public void test_convertFloatRaster() { BufferedImageTools.convertRasterType(raster, PixelType.UINT8).getPixels(0, 0, 1, 1, (double[])null)); } + + } \ No newline at end of file From 665e97dab443c3fb6d9e175cb6596cff92eb0f5b Mon Sep 17 00:00:00 2001 From: Pete Date: Fri, 11 Oct 2024 07:37:25 +0100 Subject: [PATCH 352/866] Improve ImageJ ROI conversion Addresses https://github.com/qupath/qupath/issues/1674 Don't convert a PolygonROI (QuPath) to a PolygonRoi (ImageJ) unless the polygon is simple (1 outer ring, no holes); instead, use a ShapeRoi for consistent results. --- .../java/qupath/imagej/tools/IJTools.java | 168 ++++++++++-------- 1 file changed, 91 insertions(+), 77 deletions(-) diff --git a/qupath-core-processing/src/main/java/qupath/imagej/tools/IJTools.java b/qupath-core-processing/src/main/java/qupath/imagej/tools/IJTools.java index 302e061a9..30e9bfc45 100644 --- a/qupath-core-processing/src/main/java/qupath/imagej/tools/IJTools.java +++ b/qupath-core-processing/src/main/java/qupath/imagej/tools/IJTools.java @@ -1005,14 +1005,10 @@ public static ImagePlane getImagePlane(Roi roi, ImagePlus imp) { * @return */ public static > ROI convertToROI(Roi roi, T pathImage) { - Calibration cal = null; - double downsampleFactor = 1; ImageRegion region = pathImage.getImageRegion(); - if (pathImage != null) { - cal = pathImage.getImage().getCalibration(); - downsampleFactor = pathImage.getDownsampleFactor(); - } - return convertToROI(roi, cal, downsampleFactor, region.getImagePlane()); + Calibration cal = pathImage.getImage().getCalibration(); + double downsampleFactor = pathImage.getDownsampleFactor(); + return convertToROI(roi, cal, downsampleFactor, region.getImagePlane()); } /** @@ -1029,80 +1025,98 @@ public static PolygonROI convertToPolygonROI(PolygonRoi roi, Calibration cal, do } /** - * Convert a QuPath ROI to an ImageJ Roi. - * @param - * @param pathROI - * @param xOrigin x-origin indicating relationship of ImagePlus to the original image, as stored in ImageJ Calibration object - * @param yOrigin y-origin indicating relationship of ImagePlus to the original image, as stored in ImageJ Calibration object - * @param downsampleFactor downsample factor at which the ImagePlus was extracted from the full-resolution image - * @return the converted ROI, or null if no input ROI was provided - */ - public static > Roi convertToIJRoi(ROI pathROI, double xOrigin, double yOrigin, double downsampleFactor) { - if (pathROI == null) - return null; - if (pathROI instanceof RectangleROI) - return ROIConverterIJ.getRectangleROI((RectangleROI)pathROI, xOrigin, yOrigin, downsampleFactor); - if (pathROI instanceof EllipseROI) - return ROIConverterIJ.convertToOvalROI((EllipseROI)pathROI, xOrigin, yOrigin, downsampleFactor); - if (pathROI instanceof LineROI) - return ROIConverterIJ.convertToLineROI((LineROI)pathROI, xOrigin, yOrigin, downsampleFactor); - if (pathROI instanceof PolylineROI) - return ROIConverterIJ.convertToPolygonROI((PolylineROI)pathROI, xOrigin, yOrigin, downsampleFactor); - if (pathROI instanceof PointsROI) - return ROIConverterIJ.convertToPointROI((PointsROI)pathROI, xOrigin, yOrigin, downsampleFactor); - if (pathROI instanceof PolygonROI) { - // We should only use a PolygonROI if we have a simple polygon, without holes or self-intersections - // See https://github.com/qupath/qupath/issues/1674 - var geom = pathROI.getGeometry(); - if (geom instanceof Polygon && geom.getNumGeometries() == 1 && ((Polygon) geom).getNumInteriorRing() == 0) - return ROIConverterIJ.convertToPolygonROI((PolygonROI) pathROI, xOrigin, yOrigin, downsampleFactor); - } - // If we have any other kind of area, create a general shape roi - if (pathROI.isArea()) { - return ROIConverterIJ.convertToShapeRoi(pathROI, xOrigin, yOrigin, downsampleFactor); - } - throw new UnsupportedOperationException("Unknown ROI " + pathROI + " - cannot convert to ImageJ Roi"); + * Convert a QuPath ROI to an ImageJ Roi. + * @param roi the QuPath ROI + * @param request the request encoding the bounding box and downsample of the image for which the Roi should be + * scaled and translated. If null, no scaling or translation will be applied. + * @return the converted ROI, or null if no input ROI was provided + */ + public static Roi convertToIJRoi(ROI roi, RegionRequest request) { + double xOrigin = 0; + double yOrigin = 0; + double downsampleFactor = 1.0; + if (request != null) { + downsampleFactor = request.getDownsample(); + xOrigin = -request.getX() / downsampleFactor; + yOrigin = -request.getY() / downsampleFactor; } + return convertToIJRoi(roi, xOrigin, yOrigin, downsampleFactor); + } /** - * Create a ROI from an ImageJ Roi. - * - * @param roi ImageJ Roi - * @param xOrigin x-origin, as stored in an ImageJ Calibration object - * @param yOrigin y-origin, as stored in an ImageJ Calibration object - * @param downsampleFactor - * @param plane plane defining c, z and t indices - * @return - */ - public static ROI convertToROI(Roi roi, double xOrigin, double yOrigin, double downsampleFactor, ImagePlane plane) { - if (plane == null) - plane = getImagePlane(roi, null); - int c = plane.getC(); - int z = plane.getZ(); - int t = plane.getT(); - // if (roi.getType() == Roi.POLYGON || roi.getType() == Roi.TRACED_ROI) - // return convertToPolygonROI((PolygonRoi)roi, cal, downsampleFactor); - if (roi.getType() == Roi.RECTANGLE && roi.getCornerDiameter() == 0) - return ROIConverterIJ.getRectangleROI(roi, xOrigin, yOrigin, downsampleFactor, c, z, t); - if (roi.getType() == Roi.OVAL) - return ROIConverterIJ.convertToEllipseROI(roi, xOrigin, yOrigin, downsampleFactor, c, z, t); - if (roi instanceof Line) - return ROIConverterIJ.convertToLineROI((Line)roi, xOrigin, yOrigin, downsampleFactor, c, z, t); - if (roi instanceof PointRoi) - return ROIConverterIJ.convertToPointROI((PolygonRoi)roi, xOrigin, yOrigin, downsampleFactor, c, z, t); - // if (roi instanceof ShapeRoi) - // return convertToAreaROI((ShapeRoi)roi, cal, downsampleFactor); - // // Shape ROIs should be able to handle most eventualities - if (roi instanceof ShapeRoi) - return ROIConverterIJ.convertToAreaROI((ShapeRoi)roi, xOrigin, yOrigin, downsampleFactor, c, z, t); - if (roi.isArea()) - return ROIConverterIJ.convertToPolygonOrAreaROI(roi, xOrigin, yOrigin, downsampleFactor, c, z, t); - if (roi instanceof PolygonRoi) { - if (roi.getType() == Roi.FREELINE || roi.getType() == Roi.POLYLINE) - return ROIConverterIJ.convertToPolylineROI((PolygonRoi)roi, xOrigin, yOrigin, downsampleFactor, c, z, t); - } - throw new IllegalArgumentException("Unknown Roi: " + roi); + * Convert a QuPath ROI to an ImageJ Roi. + * @param pathROI + * @param xOrigin x-origin indicating relationship of ImagePlus to the original image, as stored in ImageJ Calibration object + * @param yOrigin y-origin indicating relationship of ImagePlus to the original image, as stored in ImageJ Calibration object + * @param downsampleFactor downsample factor at which the ImagePlus was extracted from the full-resolution image + * @return the converted ROI, or null if no input ROI was provided + */ + public static Roi convertToIJRoi(ROI pathROI, double xOrigin, double yOrigin, double downsampleFactor) { + if (pathROI == null) + return null; + if (pathROI instanceof RectangleROI) + return ROIConverterIJ.getRectangleROI((RectangleROI)pathROI, xOrigin, yOrigin, downsampleFactor); + if (pathROI instanceof EllipseROI) + return ROIConverterIJ.convertToOvalROI((EllipseROI)pathROI, xOrigin, yOrigin, downsampleFactor); + if (pathROI instanceof LineROI) + return ROIConverterIJ.convertToLineROI((LineROI)pathROI, xOrigin, yOrigin, downsampleFactor); + if (pathROI instanceof PolylineROI) + return ROIConverterIJ.convertToPolygonROI((PolylineROI)pathROI, xOrigin, yOrigin, downsampleFactor); + if (pathROI instanceof PointsROI) + return ROIConverterIJ.convertToPointROI((PointsROI)pathROI, xOrigin, yOrigin, downsampleFactor); + if (pathROI instanceof PolygonROI) { + // We should only use a PolygonROI if we have a simple polygon, without holes or self-intersections + // See https://github.com/qupath/qupath/issues/1674 + var geom = pathROI.getGeometry(); + if (geom instanceof Polygon && geom.getNumGeometries() == 1 && ((Polygon) geom).getNumInteriorRing() == 0) + return ROIConverterIJ.convertToPolygonROI((PolygonROI) pathROI, xOrigin, yOrigin, downsampleFactor); + } + // If we have any other kind of area, create a general shape roi + if (pathROI.isArea()) { + return ROIConverterIJ.convertToShapeRoi(pathROI, xOrigin, yOrigin, downsampleFactor); + } + throw new UnsupportedOperationException("Unknown ROI " + pathROI + " - cannot convert to ImageJ Roi"); + } + + /** + * Create a ROI from an ImageJ Roi. + * + * @param roi ImageJ Roi + * @param xOrigin x-origin, as stored in an ImageJ Calibration object + * @param yOrigin y-origin, as stored in an ImageJ Calibration object + * @param downsampleFactor + * @param plane plane defining c, z and t indices + * @return + */ + public static ROI convertToROI(Roi roi, double xOrigin, double yOrigin, double downsampleFactor, ImagePlane plane) { + if (plane == null) + plane = getImagePlane(roi, null); + int c = plane.getC(); + int z = plane.getZ(); + int t = plane.getT(); +// if (roi.getType() == Roi.POLYGON || roi.getType() == Roi.TRACED_ROI) +// return convertToPolygonROI((PolygonRoi)roi, cal, downsampleFactor); + if (roi.getType() == Roi.RECTANGLE && roi.getCornerDiameter() == 0) + return ROIConverterIJ.getRectangleROI(roi, xOrigin, yOrigin, downsampleFactor, c, z, t); + if (roi.getType() == Roi.OVAL) + return ROIConverterIJ.convertToEllipseROI(roi, xOrigin, yOrigin, downsampleFactor, c, z, t); + if (roi instanceof Line) + return ROIConverterIJ.convertToLineROI((Line)roi, xOrigin, yOrigin, downsampleFactor, c, z, t); + if (roi instanceof PointRoi) + return ROIConverterIJ.convertToPointROI((PolygonRoi)roi, xOrigin, yOrigin, downsampleFactor, c, z, t); +// if (roi instanceof ShapeRoi) +// return convertToAreaROI((ShapeRoi)roi, cal, downsampleFactor); +// // Shape ROIs should be able to handle most eventualities + if (roi instanceof ShapeRoi) + return ROIConverterIJ.convertToAreaROI((ShapeRoi)roi, xOrigin, yOrigin, downsampleFactor, c, z, t); + if (roi.isArea()) + return ROIConverterIJ.convertToPolygonOrAreaROI(roi, xOrigin, yOrigin, downsampleFactor, c, z, t); + if (roi instanceof PolygonRoi) { + if (roi.getType() == Roi.FREELINE || roi.getType() == Roi.POLYLINE) + return ROIConverterIJ.convertToPolylineROI((PolygonRoi)roi, xOrigin, yOrigin, downsampleFactor, c, z, t); } + throw new IllegalArgumentException("Unknown Roi: " + roi); + } /** * Calculate optical density values for the red, green and blue channels, then add these all together. From 2765c1a0c3f23261305e48205eeb6c35e2b797b6 Mon Sep 17 00:00:00 2001 From: Pete Date: Fri, 11 Oct 2024 14:54:53 +0100 Subject: [PATCH 353/866] Minor fixes, prompt for ImageJ dir Several minor fixes, including - use Roi group names for classifications, where available - rename setting the plugins directory to 'Set local ImageJ directory' - because it's needed also for LUTs as well Note that setting the Roi group logs an error if this hasn't been done, because it makes a request for the Glasbey LUT that can't be fulfilled. --- .../java/qupath/imagej/tools/IJTools.java | 11 ++- .../java/qupath/imagej/gui/IJExtension.java | 82 +++++++++++++++---- .../QuPath_Send_Overlay_to_QuPath.java | 27 +----- .../qupathj/QuPath_Send_ROI_to_QuPath.java | 9 +- .../qupath-gui-strings.properties | 6 +- 5 files changed, 88 insertions(+), 47 deletions(-) diff --git a/qupath-core-processing/src/main/java/qupath/imagej/tools/IJTools.java b/qupath-core-processing/src/main/java/qupath/imagej/tools/IJTools.java index 30e9bfc45..4296a3c43 100644 --- a/qupath-core-processing/src/main/java/qupath/imagej/tools/IJTools.java +++ b/qupath-core-processing/src/main/java/qupath/imagej/tools/IJTools.java @@ -461,7 +461,7 @@ public static PathObject convertToPathObject(Roi roi, double downsampleFactor, F * @since v0.4.0 */ public static PathObject convertToAnnotation(Roi roi, double xOrigin, double yOrigin, double downsampleFactor, ImagePlane plane) { - return convertToPathObject(roi, xOrigin, yOrigin, downsampleFactor, r -> PathObjects.createAnnotationObject(r), plane); + return convertToPathObject(roi, xOrigin, yOrigin, downsampleFactor, PathObjects::createAnnotationObject, plane); } /** @@ -571,9 +571,14 @@ public static void calibrateObject(PathObject pathObject, Roi roi) { String name = roi.getName(); if (name != null && !name.isBlank()) { pathObject.setName(name); - } else if (roi.getGroup() > 0) { + } + if (roi.getGroup() > 0) { // If the group is set, use it as a classification - pathObject.setPathClass(PathClass.getInstance("Group " + roi.getGroup(), colorRGB)); + int group = roi.getGroup(); + var groupName = Roi.getGroupName(group); + if (groupName == null) + groupName = "Group " + group; + pathObject.setPathClass(PathClass.getInstance(groupName, colorRGB)); } if (colorRGB != null && pathObject.getPathClass() == null) { pathObject.setColor(colorRGB); diff --git a/qupath-extension-processing/src/main/java/qupath/imagej/gui/IJExtension.java b/qupath-extension-processing/src/main/java/qupath/imagej/gui/IJExtension.java index 0182291e1..ce06d6b11 100644 --- a/qupath-extension-processing/src/main/java/qupath/imagej/gui/IJExtension.java +++ b/qupath-extension-processing/src/main/java/qupath/imagej/gui/IJExtension.java @@ -35,7 +35,6 @@ import javafx.beans.property.StringProperty; import javafx.geometry.Orientation; import javafx.scene.control.Menu; -import javafx.scene.control.MenuBar; import javafx.scene.control.MenuButton; import javafx.scene.control.Separator; import javafx.scene.control.Tooltip; @@ -51,6 +50,11 @@ import java.io.File; import java.io.IOException; import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Collection; import java.util.Collections; import java.util.List; import java.util.Set; @@ -135,11 +139,7 @@ public class IJExtension implements QuPathExtension { private static final AwtMenuBarBlocker menuBarBlocker = new AwtMenuBarBlocker(); static { - // Try to default to the most likely ImageJ path on a Mac - if (GeneralTools.isMac() && new File("/Applications/ImageJ/").isDirectory()) - imageJPath = PathPrefs.createPersistentPreference("ijPath", "/Applications/ImageJ"); - else - imageJPath = PathPrefs.createPersistentPreference("ijPath", null); + imageJPath = PathPrefs.createPersistentPreference("ijPath", null); } /** @@ -186,7 +186,7 @@ public static synchronized ImageJ getImageJInstance() { return getImageJInstanceOnEDT(); } - private static Set installedPlugins = Collections.newSetFromMap(new WeakHashMap<>()); + private static final Set installedPlugins = Collections.newSetFromMap(new WeakHashMap<>()); /** * Ensure we have installed the necessary plugins. @@ -527,8 +527,8 @@ public static class IJExtensionCommands { public final Action SEP_3 = ActionTools.createSeparator(); @ActionMenu(value = {"Menu.Extensions", "ImageJ>"}) - @ActionConfig("Action.ImageJ.setPluginsDirectory") - public final Action actionPlugins = ActionTools.createAction(() -> promptToSetPluginsDirectory()); + @ActionConfig("Action.ImageJ.setImageJDirectory") + public final Action actionImageJDirectory = ActionTools.createAction(IJExtension::promptToSetImageJDirectory); @ActionMenu(value = {"Menu.Extensions", "ImageJ>"}) public final Action SEP_4 = ActionTools.createSeparator(); @@ -579,13 +579,67 @@ private Action createPluginAction(PathPlugin plugin) { } - - static void promptToSetPluginsDirectory() { - String path = getImageJPath(); - File dir = FileChoosers.promptForDirectory("Set ImageJ plugins directory", path == null ? null : new File(path)); + + + private static void promptToSetImageJDirectory() { + String ijPath = getImageJPath(); + if (ijPath == null) { + var likelyPath = searchForDefaultImageJPath(); + if (likelyPath != null) { + ijPath = likelyPath.toString(); + } + } + File dir = FileChoosers.promptForDirectory("Set ImageJ directory", ijPath == null ? null : new File(ijPath)); if (dir != null && dir.isDirectory()) setImageJPath(dir.getAbsolutePath()); } + + /** + * Search for a potential ImageJ directory. + * This looks in a collection of (possibly system-dependent) paths to try to find an ImageJ installation. + * @return + */ + private static Path searchForDefaultImageJPath() { + // App names, in order of preference + List appNames = List.of("ImageJ.app", "ImageJ", "Fiji", "Fiji.app"); + List possiblePaths = new ArrayList<>(); + for (var appName : appNames) { + if (GeneralTools.isMac()) { + possiblePaths.add(Paths.get("Applications", appName)); + } + String home = System.getProperty("user.home"); + if (home != null && !home.isBlank()) { + possiblePaths.add(Paths.get(home, appName)); + possiblePaths.add(Paths.get(home, "Documents", appName)); + possiblePaths.add(Paths.get(home, "Desktop", appName)); + } + } + return findPotentialImageJDirectory(possiblePaths); + } + + /** + * Find the first path in a collection that is likely to be a valid ImageJ directory. + * @param paths + * @return + */ + private static Path findPotentialImageJDirectory(Collection paths) { + return paths.stream() + .filter(IJExtension::isImageJDirectory) + .findFirst() + .orElse(null); + } + + /** + * Check whether a path corresponds to a directory that is likely to be a suitable ImageJ directory. + * @param path + * @return + */ + private static boolean isImageJDirectory(Path path) { + if (!Files.isDirectory(path)) + return false; + return Files.isDirectory(path.resolve("plugins")) && + Files.isDirectory(path.resolve("luts")); + } private boolean extensionInstalled = false; @@ -667,7 +721,7 @@ private boolean handleRois(QuPathViewer viewer, List files) { if (imageData == null) return false; - var roiFiles = files.stream().filter(f -> IJTools.containsImageJRois(f)).toList(); + var roiFiles = files.stream().filter(IJTools::containsImageJRois).toList(); if (roiFiles.isEmpty()) return false; diff --git a/qupath-extension-processing/src/main/java/qupathj/QuPath_Send_Overlay_to_QuPath.java b/qupath-extension-processing/src/main/java/qupathj/QuPath_Send_Overlay_to_QuPath.java index a50afcbfb..defa26075 100644 --- a/qupath-extension-processing/src/main/java/qupathj/QuPath_Send_Overlay_to_QuPath.java +++ b/qupath-extension-processing/src/main/java/qupathj/QuPath_Send_Overlay_to_QuPath.java @@ -4,7 +4,7 @@ * %% * Copyright (C) 2014 - 2016 The Queen's University of Belfast, Northern Ireland * Contact: IP Management (ipmanagement@qub.ac.uk) - * Copyright (C) 2018 - 2020 QuPath developers, The University of Edinburgh + * Copyright (C) 2018 - 2020, 2024 QuPath developers, The University of Edinburgh * %% * QuPath is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as @@ -31,7 +31,6 @@ import qupath.imagej.tools.IJTools; import qupath.lib.gui.QuPathGUI; import qupath.lib.images.ImageData; -import qupath.lib.images.servers.ImageServer; import qupath.lib.measurements.MeasurementList; import qupath.lib.objects.PathObject; import qupath.lib.regions.ImagePlane; @@ -93,8 +92,8 @@ public void run(String arg) { if (rois.isEmpty()) return; - var gui = QuPathGUI.getInstance(); - var viewer = gui == null ? null : gui.getViewer(); + var qupath = QuPathGUI.getInstance(); + var viewer = qupath == null ? null : qupath.getViewer(); var imageData = viewer == null ? null : viewer.getImageData(); if (imageData == null) { IJ.showMessage("No image selected in QuPath!"); @@ -127,7 +126,7 @@ private void promptToImportRois(ImageData imageData, ImagePlus imp, Collectio ImagePlane plane = currentPlane; if (imp == null) plane = null; - else if (imp != null && server.nZSlices() * server.nTimepoints() > 1) { + else if (server.nZSlices() * server.nTimepoints() > 1) { if (imp.getNSlices() == server.nZSlices() && imp.getNFrames() == server.nTimepoints()) plane = null; } @@ -142,24 +141,6 @@ else if (imp != null && server.nZSlices() * server.nTimepoints() > 1) { }); } } - - /** - * Legacy method to turn an array of ImageJ ROIs into a list of QuPath PathObjects, optionally adding measurements as well. - * @param imp - * @param rois - * @param server - * @param downsample - * @param asDetection - * @param includeMeasurements - * @param plane - * @return - * @deprecated use instead {@link #createObjectsFromROIs(ImagePlus, Collection, double, boolean, boolean, ImagePlane)} - */ - @Deprecated - public static List createPathObjectsFromROIs(final ImagePlus imp, final Roi[] rois, final ImageServer server, - final double downsample, final boolean asDetection, final boolean includeMeasurements, final ImagePlane plane) { - return createObjectsFromROIs(imp, Arrays.asList(rois), downsample, asDetection, includeMeasurements, plane); - } /** * Turn an array of ImageJ ROIs into a list of QuPath PathObjects, optionally adding measurements as well. diff --git a/qupath-extension-processing/src/main/java/qupathj/QuPath_Send_ROI_to_QuPath.java b/qupath-extension-processing/src/main/java/qupathj/QuPath_Send_ROI_to_QuPath.java index 014bed533..d01a363ef 100644 --- a/qupath-extension-processing/src/main/java/qupathj/QuPath_Send_ROI_to_QuPath.java +++ b/qupath-extension-processing/src/main/java/qupathj/QuPath_Send_ROI_to_QuPath.java @@ -4,7 +4,7 @@ * %% * Copyright (C) 2014 - 2016 The Queen's University of Belfast, Northern Ireland * Contact: IP Management (ipmanagement@qub.ac.uk) - * Copyright (C) 2018 - 2020 QuPath developers, The University of Edinburgh + * Copyright (C) 2018 - 2020, 2024 QuPath developers, The University of Edinburgh * %% * QuPath is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as @@ -52,10 +52,10 @@ public void run(String arg) { return; } - var gui = QuPathGUI.getInstance(); - var viewer = gui == null ? null : gui.getViewer(); + var qupath = QuPathGUI.getInstance(); + var viewer = qupath == null ? null : qupath.getViewer(); var imageData = viewer == null ? null : viewer.getImageData(); - if (gui == null) { + if (imageData == null) { IJ.showMessage("No active image found in QuPath!"); return; } @@ -74,7 +74,6 @@ public void run(String arg) { } var pathObject = IJTools.convertToAnnotation(roi, cal.xOrigin, cal.yOrigin, downsample, plane); -// PathObject pathObject = IJTools.convertToAnnotation(imp, server, roi, downsample, viewer.getImagePlane()); if (pathObject == null) { IJ.error("Sorry, I couldn't convert " + roi + " to a valid QuPath object"); return; diff --git a/qupath-gui-fx/src/main/resources/qupath/lib/gui/localization/qupath-gui-strings.properties b/qupath-gui-fx/src/main/resources/qupath/lib/gui/localization/qupath-gui-strings.properties index 256bfa743..27c8d9548 100644 --- a/qupath-gui-fx/src/main/resources/qupath/lib/gui/localization/qupath-gui-strings.properties +++ b/qupath-gui-fx/src/main/resources/qupath/lib/gui/localization/qupath-gui-strings.properties @@ -617,8 +617,10 @@ Action.ImageJ.subcellularDetection.description = Identify subcellular structures Action.ImageJ.macroRunner = ImageJ macro runner Action.ImageJ.macroRunner.description = Run ImageJ macros within QuPath -Action.ImageJ.setPluginsDirectory = Set plugins directory -Action.ImageJ.setPluginsDirectory.description = Set the plugins directory to use with QuPath's embedded version of ImageJ.\n\nThis can be set to the plugins directory of an existing ImageJ installation, to make the plugins associated with that installation available within QuPath. +Action.ImageJ.setImageJDirectory = Set local ImageJ directory +Action.ImageJ.setImageJDirectory.description = Set the directory to use with QuPath's embedded version of ImageJ.\n\n\ + When this is set, plugins from an existing ImageJ installation are made available to QuPath's embedded version of ImageJ \ + (as long as they have no incompatible dependencies). Action.ImageJ.extractRegion = Send region to ImageJ Action.ImageJ.extractRegion.description = Extract the selected image region and send it to ImageJ From b42260357aed95024f0e86c8cfd901cad184bff9 Mon Sep 17 00:00:00 2001 From: Pete Date: Fri, 11 Oct 2024 16:52:30 +0100 Subject: [PATCH 354/866] Improve ImageJ UI management - Improve quitting ImageJ properly without prompting to save changes for any open images or frames - Hopefully fix the persistent issue with the ImageJ menubar conflicting with QuPath's on macOS --- .../qupath/imagej/gui/AwtMenuBarBlocker.java | 2 +- .../java/qupath/imagej/gui/IJExtension.java | 75 ++++-------------- .../imagej/gui/ImageJQuitCommandListener.java | 79 +++++++++++++++++++ 3 files changed, 96 insertions(+), 60 deletions(-) create mode 100644 qupath-extension-processing/src/main/java/qupath/imagej/gui/ImageJQuitCommandListener.java diff --git a/qupath-extension-processing/src/main/java/qupath/imagej/gui/AwtMenuBarBlocker.java b/qupath-extension-processing/src/main/java/qupath/imagej/gui/AwtMenuBarBlocker.java index 1e6f82b01..70671241b 100644 --- a/qupath-extension-processing/src/main/java/qupath/imagej/gui/AwtMenuBarBlocker.java +++ b/qupath-extension-processing/src/main/java/qupath/imagej/gui/AwtMenuBarBlocker.java @@ -125,7 +125,7 @@ private void restoreMenuBar(MenuBar menuBar) { if (menuBar == null) return; logger.debug("Adding menubar notifications {}", menuBar); - menuBar.removeNotify(); + menuBar.addNotify(); } diff --git a/qupath-extension-processing/src/main/java/qupath/imagej/gui/IJExtension.java b/qupath-extension-processing/src/main/java/qupath/imagej/gui/IJExtension.java index ce06d6b11..3a446d97d 100644 --- a/qupath-extension-processing/src/main/java/qupath/imagej/gui/IJExtension.java +++ b/qupath-extension-processing/src/main/java/qupath/imagej/gui/IJExtension.java @@ -4,7 +4,7 @@ * %% * Copyright (C) 2014 - 2016 The Queen's University of Belfast, Northern Ireland * Contact: IP Management (ipmanagement@qub.ac.uk) - * Copyright (C) 2018 - 2020 QuPath developers, The University of Edinburgh + * Copyright (C) 2018 - 2020, 2024 QuPath developers, The University of Edinburgh * %% * QuPath is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as @@ -23,12 +23,12 @@ package qupath.imagej.gui; +import ij.Executer; import ij.IJ; import ij.ImageJ; import ij.ImagePlus; import ij.Menus; import ij.Prefs; -import ij.gui.ImageWindow; import ij.gui.Overlay; import ij.gui.Roi; import javafx.application.Platform; @@ -42,10 +42,6 @@ import javafx.scene.image.ImageView; import java.awt.Color; -import java.awt.Frame; -import java.awt.Rectangle; -import java.awt.event.WindowAdapter; -import java.awt.event.WindowEvent; import java.awt.image.BufferedImage; import java.io.File; import java.io.IOException; @@ -78,7 +74,6 @@ import qupath.imagej.superpixels.DoGSuperpixelsPlugin; import qupath.imagej.superpixels.SLICSuperpixelsPlugin; import qupath.imagej.tools.IJTools; -import qupath.lib.awt.common.AwtTools; import qupath.lib.color.ColorToolsAwt; import qupath.lib.common.GeneralTools; import qupath.lib.common.Version; @@ -128,6 +123,9 @@ public class IJExtension implements QuPathExtension { // Path to ImageJ - used to determine plugins directory private static StringProperty imageJPath = null; + // Handle quitting ImageJ quietly, without prompts to save images + private static ImageJQuitCommandListener quitCommandListener; + /** * It is necessary to block MenuBars created with AWT on macOS, otherwise shortcuts * can be fired twice and menus confused. @@ -234,16 +232,19 @@ private static synchronized ImageJ getImageJInstanceOnEDT() { // so here ensure that all remaining displayed images are closed final ImageJ ij = ijTemp; ij.exitWhenQuitting(false); - var windowListener = new ImageJWindowListener(ij); - ij.addWindowListener(windowListener); + if (quitCommandListener == null) { + quitCommandListener = new ImageJQuitCommandListener(); + Executer.addCommandListener(quitCommandListener); + } // Attempt to block the AWT menu bar when ImageJ is not in focus. // Also try to work around a macOS issue where ImageJ's menubar and QuPath's don't work nicely together, // by ensuring that any system menubar request by QuPath is (temporarily) overridden. if (blockAwtMenuBars) menuBarBlocker.startBlocking(); - if (ij.isShowing()) - SystemMenuBar.setOverrideSystemMenuBar(true); + if (ij.isShowing()) { + Platform.runLater(() -> SystemMenuBar.setOverrideSystemMenuBar(true)); + } logger.debug("Created ImageJ instance: {}", ijTemp); } @@ -255,43 +256,6 @@ private static synchronized ImageJ getImageJInstanceOnEDT() { } - private static class ImageJWindowListener extends WindowAdapter { - - private final ImageJ ij; - - private ImageJWindowListener(ImageJ ij) { - this.ij = ij; - } - - @Override - public void windowClosing(WindowEvent e) { - ij.requestFocus(); - for (Frame frame : Frame.getFrames()) { - // Close any images we have open - if (frame instanceof ImageWindow) { - ImageWindow win = (ImageWindow) frame; - ImagePlus imp = win.getImagePlus(); - if (imp != null) - imp.setIJMenuBar(false); - win.setVisible(false); - if (imp != null) { - // Save message still appears... - imp.changes = false; - // Initially tried to close, but then ImageJ hung - // Flush was ok, unless it was selected to save changes - in which case that didn't work out - // imp.flush(); - // imp.close(); - // imp.flush(); - } else - win.dispose(); - } - } - SystemMenuBar.setOverrideSystemMenuBar(false); - } - - } - - /** * Extract a region of interest from an image as an ImageJ ImagePlus. * @param server the image @@ -304,13 +268,10 @@ public void windowClosing(WindowEvent e) { public static PathImage extractROI(ImageServer server, ROI pathROI, RegionRequest request, boolean setROI) throws IOException { setROI = setROI && (pathROI != null); // Ensure the ROI bounds & ensure it fits within the image - Rectangle bounds = AwtTools.getBounds(request); - if (bounds != null) - bounds = bounds.intersection(new Rectangle(0, 0, server.getWidth(), server.getHeight())); - if (bounds == null) { + if (!request.intersects(0, 0, server.getWidth(), server.getHeight())) { return null; } - + PathImage pathImage = IJTools.convertToImagePlus(server, request); if (pathImage == null || pathImage.getImage() == null) return null; @@ -319,10 +280,8 @@ public static PathImage extractROI(ImageServer server, if (setROI) { ImagePlus imp = pathImage.getImage(); -// if (!(pathROI instanceof RectangleROI)) { - Roi roi = IJTools.convertToIJRoi(pathROI, pathImage); - imp.setRoi(roi); -// } + Roi roi = IJTools.convertToIJRoi(pathROI, pathImage); + imp.setRoi(roi); } return pathImage; } @@ -363,8 +322,6 @@ public static PathImage extractROIWithOverlay(ImageServer. + * #L% + */ + +package qupath.imagej.gui; + +import ij.CommandListener; +import ij.IJ; +import ij.ImagePlus; +import ij.WindowManager; +import ij.gui.ImageWindow; +import javafx.application.Platform; +import qupath.lib.gui.prefs.SystemMenuBar; + +import java.awt.*; +import java.util.Set; + +/** + * We need to use a CommandListener rather than a WindowListener, + * because this is the only way we can intercept calls to save changes for any open images. + */ +class ImageJQuitCommandListener implements CommandListener { + + ImageJQuitCommandListener() {} + + @Override + public String commandExecuting(String command) { + if ("quit".equalsIgnoreCase(command)) + closeWindowsQuietly(); + return command; + } + + /** + * Close all windows associated with ImageJ quietly, without prompting to save changes. + */ + private void closeWindowsQuietly() { + var ij = IJ.getInstance(); + if (ij == null) { + return; + } + ij.requestFocus(); + var nonImageFrames = Set.of(WindowManager.getNonImageWindows()); + for (var frame : Frame.getFrames()) { + if (frame instanceof ImageWindow win) { + var imp = win.getImagePlus(); + if (imp != null) { + imp.setIJMenuBar(false); + imp.changes = false; + imp.close(); + } + } else if (nonImageFrames.contains(frame)) { + frame.setVisible(false); + frame.dispose(); + WindowManager.removeWindow(frame); + } + } + ij.setMenuBar(null); + ij.setVisible(false); + Platform.runLater(() -> SystemMenuBar.setOverrideSystemMenuBar(false)); + } + +} From cd934109c6793a4487bb3bb67f46e477c9b6d1b7 Mon Sep 17 00:00:00 2001 From: Pete Date: Fri, 11 Oct 2024 16:54:21 +0100 Subject: [PATCH 355/866] Some minor code cleanup --- qupath-app/src/main/java/qupath/QuPath.java | 4 +- .../gui/commands/ml/PixelClassifierPane.java | 63 ++++++------------- .../java/qupath/lib/gui/ScriptMenuLoader.java | 2 +- .../java/qupath/lib/gui/tools/GuiTools.java | 5 +- 4 files changed, 24 insertions(+), 50 deletions(-) diff --git a/qupath-app/src/main/java/qupath/QuPath.java b/qupath-app/src/main/java/qupath/QuPath.java index b4162c0d6..ef68f73f1 100644 --- a/qupath-app/src/main/java/qupath/QuPath.java +++ b/qupath-app/src/main/java/qupath/QuPath.java @@ -220,8 +220,8 @@ private static void initializeProperties() { private static void initializeDJL() { // Set offline mode - used to prevent DJL downloading anything // except when explicitly requested - if (System.getProperty("offline", null) == null) - System.setProperty("offline", "true"); + if (System.getProperty("ai.djl.offline", null) == null) + System.setProperty("ai.djl.offline", "true"); } /** diff --git a/qupath-extension-processing/src/main/java/qupath/process/gui/commands/ml/PixelClassifierPane.java b/qupath-extension-processing/src/main/java/qupath/process/gui/commands/ml/PixelClassifierPane.java index 14ea86630..40afa47cd 100644 --- a/qupath-extension-processing/src/main/java/qupath/process/gui/commands/ml/PixelClassifierPane.java +++ b/qupath-extension-processing/src/main/java/qupath/process/gui/commands/ml/PixelClassifierPane.java @@ -112,6 +112,7 @@ import qupath.lib.images.servers.PixelCalibration; import qupath.lib.images.servers.ServerTools; import qupath.lib.images.servers.ImageServerMetadata.ChannelType; +import qupath.lib.objects.PathObject; import qupath.lib.objects.classes.PathClass; import qupath.lib.objects.hierarchy.events.PathObjectHierarchyEvent; import qupath.lib.objects.hierarchy.events.PathObjectHierarchyListener; @@ -142,8 +143,7 @@ public class PixelClassifierPane { private QuPathGUI qupath; -// private QuPathViewer viewer; - + private GridPane pane; private ObservableList resolutions = FXCollections.observableArrayList(); @@ -155,8 +155,6 @@ public class PixelClassifierPane { private Slider sliderFeatureOpacity = new Slider(0.0, 1.0, 1.0); private Spinner spinFeatureMin = FXUtils.createDynamicStepSpinner(-Double.MAX_VALUE, Double.MAX_VALUE, 0, 0.1, 1); private Spinner spinFeatureMax = FXUtils.createDynamicStepSpinner(-Double.MAX_VALUE, Double.MAX_VALUE, 1, 0.1, 1); -// private Spinner spinFeatureMin = new Spinner<>(-Double.MAX_VALUE, Double.MAX_VALUE, 0); -// private Spinner spinFeatureMax = new Spinner<>(-Double.MAX_VALUE, Double.MAX_VALUE, 1.0); private String DEFAULT_CLASSIFICATION_OVERLAY = "Show classification"; /** @@ -192,23 +190,20 @@ public class PixelClassifierPane { private PixelClassificationOverlay featureOverlay; private FeatureRenderer featureRenderer; - private ChangeListener> imageDataListener = new ChangeListener<>() { - - @Override - public void changed(ObservableValue> observable, - ImageData oldValue, ImageData newValue) { - if (oldValue != null) - oldValue.getHierarchy().removeListener(hierarchyListener); - if (newValue != null) - newValue.getHierarchy().addListener(hierarchyListener); - updateTitle(); - updateAvailableResolutions(newValue); - } + private ChangeListener> imageDataListener = this::handleImageDataChange; - }; - private Stage stage; - + + private void handleImageDataChange(ObservableValue> observable, + ImageData oldValue, ImageData newValue) { + if (oldValue != null) + oldValue.getHierarchy().removeListener(hierarchyListener); + if (newValue != null) + newValue.getHierarchy().addListener(hierarchyListener); + updateTitle(); + updateAvailableResolutions(newValue); + } + /** * Constructor. * @param qupath the current {@link QuPathGUI} that will be used for interactive training. @@ -265,7 +260,6 @@ private void initialize() { labelFeatures.setLabelFor(comboFeatures); selectedFeatureCalculatorBuilder = comboFeatures.getSelectionModel().selectedItemProperty(); -// var labelFeaturesSummary = new Label("No features selected"); var btnShowFeatures = new Button("Show"); btnShowFeatures.setOnAction(e -> showFeatures()); @@ -284,8 +278,7 @@ private void initialize() { comboFeatures.getSelectionModel().select(0); comboFeatures.getSelectionModel().selectedItemProperty().addListener((v, o, n) -> updateFeatureCalculator()); -// btnCustomizeFeatures.setOnAction(e -> showFeatures()); - + GridPaneUtils.addGridRow(pane, row++, 0, "Select features for the classifier", labelFeatures, comboFeatures, btnCustomizeFeatures, btnShowFeatures); @@ -311,7 +304,6 @@ private void initialize() { // Region var labelRegion = new Label("Region"); var comboRegionFilter = PixelClassifierUI.createRegionFilterCombo(qupath.getOverlayOptions()); -// var nodeLimit = PixelClassifierTools.createLimitToAnnotationsControl(qupath.getOverlayOptions()); GridPaneUtils.addGridRow(pane, row++, 0, "Control where the pixel classification is applied during preview", labelRegion, comboRegionFilter, comboRegionFilter, comboRegionFilter); @@ -357,38 +349,19 @@ private void initialize() { var panePredict = GridPaneUtils.createColumnGridControls(btnProject, btnAdvancedOptions); pane.add(panePredict, 0, row++, pane.getColumnCount(), 1); - -// addGridRow(pane, row++, 0, btnPredict, btnPredict, btnPredict); - -// var btnUpdate = new Button("Update classifier"); -// btnUpdate.setMaxWidth(Double.MAX_VALUE); -// btnUpdate.setOnAction(e -> updateClassifier(true)); -// btnUpdate.disableProperty().bind(qupath.imageDataProperty().isNull().or(btnLive.selectedProperty())); pane.add(btnLive, 0, row++, pane.getColumnCount(), 1); pieChart = new PieChart(); pieChart.getStyleClass().add("training-chart"); pieChart.setAnimated(false); -// var hierarchy = viewer.getHierarchy(); -// Map> map = hierarchy == null ? Collections.emptyMap() : PathClassificationLabellingHelper.getClassificationMap(hierarchy, false); - pieChart.setLabelsVisible(false); pieChart.setLegendVisible(true); pieChart.setMinSize(40, 40); pieChart.setPrefSize(120, 120); -// pieChart.setMaxSize(Double.MAX_VALUE, Double.MAX_VALUE); pieChart.setLegendSide(Side.RIGHT); -// GridPane.setVgrow(pieChart, Priority.ALWAYS); -// Tooltip.install(pieChart, new Tooltip("View training classes by proportion")); var paneChart = new BorderPane(pieChart); -// paneChart.setMaxSize(Double.MAX_VALUE, Double.MAX_VALUE); - -// PaneTools.addGridRow(pane, row++, 0, -//// null, -// "View information about the current classifier training", -// paneChart, paneChart, paneChart); - + GridPaneUtils.setFillWidth(Boolean.TRUE, paneChart); GridPaneUtils.setFillHeight(Boolean.TRUE, paneChart); GridPaneUtils.setVGrowPriority(Priority.ALWAYS, paneChart); @@ -1454,8 +1427,8 @@ class HierarchyListener implements PathObjectHierarchyListener { public void hierarchyChanged(PathObjectHierarchyEvent event) { if (!event.isChanging() && !event.isObjectMeasurementEvent() && (event.isStructureChangeEvent() || event.isObjectClassificationEvent() || !event.getChangedObjects().isEmpty())) { if (event.isObjectClassificationEvent() || event.getChangedObjects().stream().anyMatch(p -> p.getPathClass() != null)) { - if (event.getChangedObjects().stream().anyMatch(p -> p.isAnnotation()) && - !(event.isAddedOrRemovedEvent() && event.getChangedObjects().stream().allMatch(p -> p.isLocked()))) + if (event.getChangedObjects().stream().anyMatch(PathObject::isAnnotation) && + !(event.isAddedOrRemovedEvent() && event.getChangedObjects().stream().allMatch(PathObject::isLocked))) updateClassifier(); } } diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/ScriptMenuLoader.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/ScriptMenuLoader.java index 289e3b1af..e15c9ca77 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/ScriptMenuLoader.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/ScriptMenuLoader.java @@ -221,7 +221,7 @@ private void addMenuItemsForPath(final Menu menu, final Path dir) { } if (!items.isEmpty()) { // Remove placeholder if available - if (menu.getItems().size() == 1 && NO_SCRIPTS_NAME.equals(menu.getItems().get(0).getText())) + if (menu.getItems().size() == 1 && NO_SCRIPTS_NAME.equals(menu.getItems().getFirst().getText())) menu.getItems().setAll(items); else menu.getItems().addAll(items); diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/tools/GuiTools.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/tools/GuiTools.java index ddecb7f3a..077656b39 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/tools/GuiTools.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/tools/GuiTools.java @@ -1236,7 +1236,7 @@ public static Menu createRecentItemsMenu(String menuTitle, ObservableList r Menu menuRecent = MenuTools.createMenu(menuTitle); EventHandler validationHandler = e -> { - menuRecent.getItems().clear(); + List items = new ArrayList<>(); for (URI uri : recentItems) { if (uri == null) continue; @@ -1244,8 +1244,9 @@ public static Menu createRecentItemsMenu(String menuTitle, ObservableList r name = ".../" + name; MenuItem item = new MenuItem(name); item.setOnAction(event -> consumer.accept(uri)); - menuRecent.getItems().add(item); + items.add(item); } + menuRecent.getItems().setAll(items); }; // Ensure the menu is populated From ca1d10444eae06b7328dfd8a6fd480f1c561fac5 Mon Sep 17 00:00:00 2001 From: Pete Date: Fri, 11 Oct 2024 17:31:41 +0100 Subject: [PATCH 356/866] Prompt when quitting ImageJ Give opportunity to save changes - but in one step, instead of looping through all images (which is the default behavior). This tries to strike a balance between making it quick and easy to close ImageJ after use, while not making it *so* quick and easy that unsaved changes are lost often. --- CHANGELOG.md | 1 + .../java/qupath/imagej/gui/IJExtension.java | 2 +- .../imagej/gui/ImageJQuitCommandListener.java | 25 +++++++++++++++++-- 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 965f10298..20140418c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,7 @@ Some things may be added, some things may be removed, and some things may look d * Self-contained projects that contain all images inside the project directory no longer prompt the user to update URIs if moved (https://github.com/qupath/qupath/pull/1668) * Channel names can now be set for all fluorescence images, even if they are RGB (https://github.com/qupath/qupath/pull/1659) * Note that channel colors still only be set for non-RGB images +* Improved ImageJ integration (https://github.com/qupath/qupath/pull/1676 https://github.com/qupath/qupath/pull/1677) ### Experimental features These features are included for testing and feedback. diff --git a/qupath-extension-processing/src/main/java/qupath/imagej/gui/IJExtension.java b/qupath-extension-processing/src/main/java/qupath/imagej/gui/IJExtension.java index 3a446d97d..c6f96c521 100644 --- a/qupath-extension-processing/src/main/java/qupath/imagej/gui/IJExtension.java +++ b/qupath-extension-processing/src/main/java/qupath/imagej/gui/IJExtension.java @@ -724,7 +724,7 @@ private boolean handleMacro(File file) { public static Image getImageJIcon(final int width, final int height) { try { URL url = ImageJ.class.getClassLoader().getResource("microscope.gif"); - return new Image(url.toString(), width, height, true, true); + return url == null ? null : new Image(url.toString(), width, height, true, true); } catch (Exception e) { logger.error("Unable to load ImageJ icon!", e); } diff --git a/qupath-extension-processing/src/main/java/qupath/imagej/gui/ImageJQuitCommandListener.java b/qupath-extension-processing/src/main/java/qupath/imagej/gui/ImageJQuitCommandListener.java index a81398ace..a4a9adc67 100644 --- a/qupath-extension-processing/src/main/java/qupath/imagej/gui/ImageJQuitCommandListener.java +++ b/qupath-extension-processing/src/main/java/qupath/imagej/gui/ImageJQuitCommandListener.java @@ -23,9 +23,9 @@ import ij.CommandListener; import ij.IJ; -import ij.ImagePlus; import ij.WindowManager; import ij.gui.ImageWindow; +import ij.plugin.frame.Editor; import javafx.application.Platform; import qupath.lib.gui.prefs.SystemMenuBar; @@ -42,11 +42,32 @@ class ImageJQuitCommandListener implements CommandListener { @Override public String commandExecuting(String command) { - if ("quit".equalsIgnoreCase(command)) + if ("quit".equalsIgnoreCase(command)) { + if (hasUnsavedChanges()) { + if (!IJ.showMessageWithCancel("Close ImageJ", "Close all ImageJ windows without saving changes?")) + return null; + } closeWindowsQuietly(); + } return command; } + private boolean hasUnsavedChanges() { + for (var frame : Frame.getFrames()) { + if (!frame.isShowing()) + continue; + if (frame instanceof ImageWindow win) { + var imp = win.getImagePlus(); + if (imp != null && imp.changes) + return true; + } else if (frame instanceof Editor editor) { + if (editor.fileChanged()) + return true; + } + } + return false; + } + /** * Close all windows associated with ImageJ quietly, without prompting to save changes. */ From 0a63081a0ef4ec356681877e7df311835e83c00f Mon Sep 17 00:00:00 2001 From: Pete Date: Tue, 15 Oct 2024 10:32:16 +0100 Subject: [PATCH 357/866] Update ImageJ Seeing if this works... some dependencies in between caused failing tests when run headlessly. --- CHANGELOG.md | 1 + gradle/libs.versions.toml | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 20140418c..69c9b79dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -107,6 +107,7 @@ They may change or be removed in future versions. * Groovy 4.0.22 * Gson 2.11.0 * Guava 33.3.1-jre +* ImageJ 1.54k * JavaFX 23 * Java Topology Suite 1.20.0 * JFreeSVG 5.0.6 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 93b3eeca8..be67d7a76 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -17,7 +17,7 @@ gson = "2.11.0" guava = "33.3.1-jre" ikonli = "12.3.1" -imagej = "1.54f" +imagej = "1.54k" # Compatibility with Java 21 with QuPath v0.6.x jdk = "21" From c9fba7e12e016c447f4c8511c222a0b9d81a13d9 Mon Sep 17 00:00:00 2001 From: Pete Date: Tue, 15 Oct 2024 11:18:55 +0100 Subject: [PATCH 358/866] Simplify SimpleThresholding Take thresholds as double instead of float. This reduces the need to make unnecessary casts in the calling code. --- .../detect/cells/SubcellularDetection.java | 2 +- .../detect/cells/WatershedCellDetection.java | 4 +- .../detect/tissue/SimpleTissueDetection2.java | 4 +- .../imagej/processing/SimpleThresholding.java | 96 ++++++++++--------- .../qupath/imagej/processing/Watershed.java | 2 +- 5 files changed, 59 insertions(+), 49 deletions(-) diff --git a/qupath-core-processing/src/main/java/qupath/imagej/detect/cells/SubcellularDetection.java b/qupath-core-processing/src/main/java/qupath/imagej/detect/cells/SubcellularDetection.java index 23b0c247e..d389acc51 100644 --- a/qupath-core-processing/src/main/java/qupath/imagej/detect/cells/SubcellularDetection.java +++ b/qupath-core-processing/src/main/java/qupath/imagej/detect/cells/SubcellularDetection.java @@ -319,7 +319,7 @@ static boolean processObject(final PathObject pathObject, final ParameterList pa if (splitByIntensity) bpSpots = new MaximumFinder().findMaxima(fpDetection, detectionThreshold/10.0, detectionThreshold, MaximumFinder.SEGMENTED, false, false); else - bpSpots = SimpleThresholding.thresholdAboveEquals(fpDetection, (float)detectionThreshold); + bpSpots = SimpleThresholding.thresholdAboveEquals(fpDetection, detectionThreshold); if (splitByShape) { new EDM().toWatershed(bpSpots); diff --git a/qupath-core-processing/src/main/java/qupath/imagej/detect/cells/WatershedCellDetection.java b/qupath-core-processing/src/main/java/qupath/imagej/detect/cells/WatershedCellDetection.java index 9d8e18fb3..c40db5c9b 100644 --- a/qupath-core-processing/src/main/java/qupath/imagej/detect/cells/WatershedCellDetection.java +++ b/qupath-core-processing/src/main/java/qupath/imagej/detect/cells/WatershedCellDetection.java @@ -781,7 +781,7 @@ private void doDetection(boolean regenerateROIs) { } // Threshold the main LoG image - bpLoG = SimpleThresholding.thresholdAbove(fpLoG, 0f); + bpLoG = SimpleThresholding.thresholdAbove(fpLoG, 0.0); // Need to set the threshold very slightly above zero for ImageJ // TODO: DECIDE ON USING MY WATERSHED OR IMAGEJ'S.... fpLoG.setRoi(roi); @@ -868,7 +868,7 @@ private void doDetection(boolean regenerateROIs) { FloatProcessor fpBoundaryCleanup = (FloatProcessor)fpDetection.duplicate(); fpBoundaryCleanup.blurGaussian(1); fpBoundaryCleanup.convolve(new float[]{0, -1, 0, -1, 4, -1, 0, -1, 0}, 3, 3); - ByteProcessor bp2 = SimpleThresholding.thresholdAbove(fpBoundaryCleanup, 0f); + ByteProcessor bp2 = SimpleThresholding.thresholdAbove(fpBoundaryCleanup, 0.0); bp2.copyBits(bp, 0, 0, Blitter.MIN); // Remove everything not detected in bp bp.filter(ByteProcessor.MIN); bp.copyBits(bp2, 0, 0, Blitter.MAX); diff --git a/qupath-core-processing/src/main/java/qupath/imagej/detect/tissue/SimpleTissueDetection2.java b/qupath-core-processing/src/main/java/qupath/imagej/detect/tissue/SimpleTissueDetection2.java index d501a03f7..98b15eaa4 100644 --- a/qupath-core-processing/src/main/java/qupath/imagej/detect/tissue/SimpleTissueDetection2.java +++ b/qupath-core-processing/src/main/java/qupath/imagej/detect/tissue/SimpleTissueDetection2.java @@ -182,9 +182,9 @@ public Collection runDetection(final ImageData imageD // Apply threshold if (darkBackground) - bp = SimpleThresholding.thresholdAbove(bp, (float)threshold); + bp = SimpleThresholding.thresholdAbove(bp, threshold); else - bp = SimpleThresholding.thresholdBelow(bp, (float)threshold); + bp = SimpleThresholding.thresholdBelow(bp, threshold); if (Thread.currentThread().isInterrupted()) return null; diff --git a/qupath-core-processing/src/main/java/qupath/imagej/processing/SimpleThresholding.java b/qupath-core-processing/src/main/java/qupath/imagej/processing/SimpleThresholding.java index 68538aa61..bb62f0a8c 100644 --- a/qupath-core-processing/src/main/java/qupath/imagej/processing/SimpleThresholding.java +++ b/qupath-core-processing/src/main/java/qupath/imagej/processing/SimpleThresholding.java @@ -4,7 +4,7 @@ * %% * Copyright (C) 2014 - 2016 The Queen's University of Belfast, Northern Ireland * Contact: IP Management (ipmanagement@qub.ac.uk) - * Copyright (C) 2018 - 2020 QuPath developers, The University of Edinburgh + * Copyright (C) 2018 - 2020, 2024 QuPath developers, The University of Edinburgh * %% * QuPath is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as @@ -26,6 +26,7 @@ import java.awt.image.BufferedImage; import java.awt.image.Raster; +import ij.gui.Roi; import ij.plugin.filter.ThresholdToSelection; import ij.process.ByteProcessor; import ij.process.FloatProcessor; @@ -85,7 +86,7 @@ public static ByteProcessor greaterThan(ImageProcessor ip1, ImageProcessor ip2) * @param threshold * @return */ - public static ByteProcessor thresholdBelow(ImageProcessor ip, float threshold) { + public static ByteProcessor thresholdBelow(ImageProcessor ip, double threshold) { ByteProcessor bp = new ByteProcessor(ip.getWidth(), ip.getHeight()); byte[] bpPixels = (byte[])bp.getPixels(); for (int i = 0; i < bpPixels.length; i++) { @@ -96,12 +97,12 @@ public static ByteProcessor thresholdBelow(ImageProcessor ip, float threshold) { } /** - * Created a binary image by thresholding pixels to find where ip1 <= threshold + * Created a binary image by thresholding pixels to find where ip1 ≤ threshold * @param ip * @param threshold * @return */ - public static ByteProcessor thresholdBelowEquals(ImageProcessor ip, float threshold) { + public static ByteProcessor thresholdBelowEquals(ImageProcessor ip, double threshold) { ByteProcessor bp = new ByteProcessor(ip.getWidth(), ip.getHeight()); byte[] bpPixels = (byte[])bp.getPixels(); for (int i = 0; i < bpPixels.length; i++) { @@ -133,7 +134,7 @@ public static ByteProcessor imagesEqual(ImageProcessor ip1, ImageProcessor ip2) * @param threshold * @return */ - public static ByteProcessor thresholdAbove(ImageProcessor ip, float threshold) { + public static ByteProcessor thresholdAbove(ImageProcessor ip, double threshold) { ByteProcessor bp = new ByteProcessor(ip.getWidth(), ip.getHeight()); byte[] bpPixels = (byte[])bp.getPixels(); for (int i = 0; i < bpPixels.length; i++) { @@ -143,12 +144,13 @@ public static ByteProcessor thresholdAbove(ImageProcessor ip, float threshold) { return bp; } - /** - * Created a binary image by thresholding pixels to find where ip1 >= threshold - * @param ip - * @param threshold - * @return - */ public static ByteProcessor thresholdAboveEquals(ImageProcessor ip, float threshold) { + /** + * Created a binary image by thresholding pixels to find where ip1 >= threshold + * @param ip + * @param threshold + * @return + */ + public static ByteProcessor thresholdAboveEquals(ImageProcessor ip, double threshold) { ByteProcessor bp = new ByteProcessor(ip.getWidth(), ip.getHeight()); byte[] bpPixels = (byte[])bp.getPixels(); for (int i = 0; i < bpPixels.length; i++) { @@ -165,11 +167,11 @@ public static ByteProcessor thresholdAbove(ImageProcessor ip, float threshold) { * @param highThreshold * @return */ - public static ByteProcessor thresholdBetween(ImageProcessor ip, float lowThreshold, float highThreshold) { + public static ByteProcessor thresholdBetween(ImageProcessor ip, double lowThreshold, double highThreshold) { ByteProcessor bp = new ByteProcessor(ip.getWidth(), ip.getHeight()); byte[] bpPixels = (byte[])bp.getPixels(); for (int i = 0; i < bpPixels.length; i++) { - float val = ip.getf(i); + double val = ip.getf(i); if (val >= lowThreshold && val <= highThreshold) bpPixels[i] = (byte)255; } @@ -235,23 +237,12 @@ public static ROI thresholdToROI(Raster raster, double minThreshold, double maxT * @return */ public static ROI thresholdToROI(ImageProcessor ip, RegionRequest request) { - // Need to check we have any above-threshold pixels at all - int n = ip.getWidth() * ip.getHeight(); - boolean noPixels = true; - double min = ip.getMinThreshold(); - double max = ip.getMaxThreshold(); - for (int i = 0; i < n; i++) { - double val = ip.getf(i); - if (val >= min && val <= max) { - noPixels = false; - break; - } - } - if (noPixels) + // Generate a shape, using the TileRequest if we can + var roiIJ = thresholdToRoiIJ(ip); + if (roiIJ == null) return null; // Generate a shape, using the RegionRequest if we can - var roiIJ = new ThresholdToSelection().convert(ip); if (request == null) return IJTools.convertToROI(roiIJ, 0, 0, 1, ImagePlane.getDefaultPlane()); return IJTools.convertToROI( @@ -261,31 +252,50 @@ public static ROI thresholdToROI(ImageProcessor ip, RegionRequest request) { request.getDownsample(), request.getImagePlane()); } - static ROI thresholdToROI(ImageProcessor ip, TileRequest request) { + private static ROI thresholdToROI(ImageProcessor ip, TileRequest request) { + // Generate a shape, using the TileRequest if we can + var roiIJ = thresholdToRoiIJ(ip); + if (roiIJ == null) + return null; + + if (request == null) + return IJTools.convertToROI(roiIJ, 0, 0, 1, ImagePlane.getDefaultPlane()); + return IJTools.convertToROI( + roiIJ, + -request.getTileX(), + -request.getTileY(), + request.getDownsample(), request.getImagePlane()); + } + + /** + * Create an ImageJ ROI from a thresholded image. + *

    + * This makes use of {@link ThresholdToSelection}, and returns null if no Roi is found. + * @param ip the image, with min and/or max thresholds already set. + * @return a Roi generated by applying the threshold, or null if there are no thresholded pixels + */ + public static Roi thresholdToRoiIJ(ImageProcessor ip) { + if (!ip.isThreshold()) + return null; + // Need to check we have any above-threshold pixels at all int n = ip.getWidth() * ip.getHeight(); boolean noPixels = true; double min = ip.getMinThreshold(); double max = ip.getMaxThreshold(); - for (int i = 0; i < n; i++) { - double val = ip.getf(i); - if (val >= min && val <= max) { - noPixels = false; - break; + if (min <= max) { + for (int i = 0; i < n; i++) { + double val = ip.getf(i); + if (val >= min && val <= max) { + noPixels = false; + break; + } } } if (noPixels) return null; - - // Generate a shape, using the TileRequest if we can - var roiIJ = new ThresholdToSelection().convert(ip); - if (request == null) - return IJTools.convertToROI(roiIJ, 0, 0, 1, ImagePlane.getDefaultPlane()); - return IJTools.convertToROI( - roiIJ, - -request.getTileX(), - -request.getTileY(), - request.getDownsample(), request.getImagePlane()); + + return new ThresholdToSelection().convert(ip); } } diff --git a/qupath-core-processing/src/main/java/qupath/imagej/processing/Watershed.java b/qupath-core-processing/src/main/java/qupath/imagej/processing/Watershed.java index 43da3d9e3..b6128b332 100644 --- a/qupath-core-processing/src/main/java/qupath/imagej/processing/Watershed.java +++ b/qupath-core-processing/src/main/java/qupath/imagej/processing/Watershed.java @@ -48,7 +48,7 @@ public class Watershed { * @param conn8 if true, use 8-connectivity */ public static void watershedExpandLabels(final ImageProcessor ipLabels, final double maxDistance, final boolean conn8) { - var bp = SimpleThresholding.thresholdAbove(ipLabels, 0f); + var bp = SimpleThresholding.thresholdAbove(ipLabels, 0.0); FloatProcessor fpEDM = new EDM().makeFloatEDM(bp, (byte)255, false); fpEDM.multiply(-1); doWatershed(fpEDM, ipLabels, -maxDistance, conn8); From caba8d3b7a5e119e564c099fef4571fa67b01527 Mon Sep 17 00:00:00 2001 From: Pete Date: Tue, 15 Oct 2024 11:59:00 +0100 Subject: [PATCH 359/866] Cleanup macro runner, add to menubutton More ImageJ commands are now accessible from the menu button on the toolbar --- .../java/qupath/imagej/gui/IJExtension.java | 12 +- .../qupath/imagej/gui/ImageJMacroRunner.java | 156 ++++++++---------- .../java/qupath/lib/gui/tools/MenuTools.java | 45 ++--- 3 files changed, 88 insertions(+), 125 deletions(-) diff --git a/qupath-extension-processing/src/main/java/qupath/imagej/gui/IJExtension.java b/qupath-extension-processing/src/main/java/qupath/imagej/gui/IJExtension.java index c6f96c521..fa2181921 100644 --- a/qupath-extension-processing/src/main/java/qupath/imagej/gui/IJExtension.java +++ b/qupath-extension-processing/src/main/java/qupath/imagej/gui/IJExtension.java @@ -89,6 +89,7 @@ import qupath.lib.gui.prefs.SystemMenuBar; import qupath.lib.gui.tools.ColorToolsFX; import qupath.lib.gui.tools.IconFactory.PathIcons; +import qupath.lib.gui.tools.MenuTools; import qupath.lib.gui.viewer.OverlayOptions; import qupath.lib.gui.viewer.QuPathViewer; import qupath.lib.gui.viewer.DragDropImportListener.DropHandler; @@ -632,9 +633,14 @@ private void addQuPathCommands(final QuPathGUI qupath) { MenuButton btnImageJ = new MenuButton(); btnImageJ.setGraphic(imageView); btnImageJ.setTooltip(new Tooltip("ImageJ commands")); - btnImageJ.getItems().addAll( - ActionTools.createMenuItem(commands.actionExtractRegion), - ActionTools.createMenuItem(commands.actionSnapshot) + MenuTools.addMenuItems( + btnImageJ.getItems(), + commands.actionExtractRegion, + commands.actionSnapshot, + null, + commands.actionImageJDirectory, + null, + commands.actionMacroRunner ); toolbar.getItems().add(btnImageJ); } catch (Exception e) { diff --git a/qupath-extension-processing/src/main/java/qupath/imagej/gui/ImageJMacroRunner.java b/qupath-extension-processing/src/main/java/qupath/imagej/gui/ImageJMacroRunner.java index 9290bfa78..b865b085e 100644 --- a/qupath-extension-processing/src/main/java/qupath/imagej/gui/ImageJMacroRunner.java +++ b/qupath-extension-processing/src/main/java/qupath/imagej/gui/ImageJMacroRunner.java @@ -4,7 +4,7 @@ * %% * Copyright (C) 2014 - 2016 The Queen's University of Belfast, Northern Ireland * Contact: IP Management (ipmanagement@qub.ac.uk) - * Copyright (C) 2018 - 2023 QuPath developers, The University of Edinburgh + * Copyright (C) 2018 - 2024 QuPath developers, The University of Edinburgh * %% * QuPath is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as @@ -46,6 +46,7 @@ import java.util.Arrays; import java.util.Collection; import java.util.List; +import java.util.Objects; import javax.swing.SwingUtilities; import org.slf4j.Logger; @@ -141,7 +142,6 @@ public boolean runPlugin(final TaskRunner runner, final ImageData if (macroText != null) textArea.setText(macroText); BorderPane panelMacro = new BorderPane(); -// panelMacro.setBorder(BorderFactory.createTitledBorder("Macro")); panelMacro.setCenter(textArea); @@ -154,7 +154,7 @@ public boolean runPlugin(final TaskRunner runner, final ImageData btnRun.setOnAction(e -> { macroText = textArea.getText().trim(); - if (macroText.length() == 0) + if (macroText.isEmpty()) return; // TODO: Consider that we're requesting a new ImageData here (probably right, but need to check) @@ -169,9 +169,6 @@ public boolean runPlugin(final TaskRunner runner, final ImageData viewer.getImageDisplay(), pathObject, macroText); }); } else { - // DisplayHelpers.showErrorMessage(getClass().getSimpleName(), "Sorry, ImageJ macros can only be run for single selected images"); -// logger.warn("ImageJ macro being run in current thread"); -// runPlugin(runner, arg); // TODO: Consider running in a background thread? // Run in a background thread Collection parents = getParentObjects(imageDataLocal); if (parents.isEmpty()) { @@ -241,7 +238,7 @@ static void runMacro(final ParameterList params, final ImageData else pathImage = IJExtension.extractROI(server, pathObject, region, sendROI); } catch (IOException e) { - logger.error("Unable to extract image region " + region, e); + logger.error("Unable to extract image region {}", region, e); return; } @@ -252,9 +249,6 @@ static void runMacro(final ParameterList params, final ImageData else argument = String.format("Region (%d, %d, %d, %d)", region.getX(), region.getY(), region.getWidth(), region.getHeight()); - // Check if we have an image already - if so, we need to be more cautious so we don't accidentally use it... -// boolean hasImage = WindowManager.getCurrentImage() != null; - // Actually run the macro final ImagePlus imp = pathImage.getImage(); imp.setProperty("QuPath region", argument); @@ -263,85 +257,77 @@ static void runMacro(final ParameterList params, final ImageData // TODO: Pay attention to how threading should be done... I think Swing EDT ok? try { -// SwingUtilities.invokeAndWait(() -> { - boolean cancelled = false; - ImagePlus impResult = null; - try { - IJ.redirectErrorMessages(); - Interpreter interpreter = new Interpreter(); - impResult = interpreter.runBatchMacro(macroText, imp); - - // If we had an error, return - if (interpreter.wasError()) { - Thread.currentThread().interrupt(); - return; - } - - // Get the resulting image, if available - if (impResult == null) - impResult = WindowManager.getCurrentImage(); - } catch (RuntimeException e) { - logger.error(e.getLocalizedMessage()); - // DisplayHelpers.showErrorMessage("ImageJ macro error", e.getLocalizedMessage()); + boolean cancelled = false; + ImagePlus impResult = null; + try { + IJ.redirectErrorMessages(); + Interpreter interpreter = new Interpreter(); + impResult = interpreter.runBatchMacro(macroText, imp); + + // If we had an error, return + if (interpreter.wasError()) { Thread.currentThread().interrupt(); - cancelled = true; - } finally { - // IJ.runMacro(macroText, argument); - WindowManager.setTempCurrentImage(null); -// IJ.run("Close all"); - } - if (cancelled) return; - - - // Get the current image when the macro has finished - which may or may not be the same as the original - if (impResult == null) - impResult = imp; - - - boolean changes = false; - if (params.getBooleanParameterValue("clearObjects") && pathObject.hasChildObjects()) { - pathObject.clearChildObjects(); - changes = true; } - if (params.getBooleanParameterValue("getROI") && impResult.getRoi() != null) { - Roi roi = impResult.getRoi(); - Calibration cal = impResult.getCalibration(); - PathObject pathObjectNew = roi == null ? null : IJTools.convertToAnnotation(roi, cal.xOrigin, cal.yOrigin, downsampleFactor, region.getImagePlane()); - if (pathObjectNew != null) { - // If necessary, trim any returned annotation - if (pathROI != null && !(pathROI instanceof RectangleROI) && pathObjectNew.isAnnotation() && RoiTools.isShapeROI(pathROI) && RoiTools.isShapeROI(pathObjectNew.getROI())) { - ROI roiNew = RoiTools.combineROIs(pathROI, pathObjectNew.getROI(), CombineOp.INTERSECT); - ((PathAnnotationObject)pathObjectNew).setROI(roiNew); - } - // Only add if we have something - if (pathObjectNew.getROI() instanceof LineROI || !pathObjectNew.getROI().isEmpty()) { - pathObject.addChildObject(pathObjectNew); - // imageData.getHierarchy().addPathObject(IJHelpers.convertToPathObject(imp, imageData.getServer(), imp.getRoi(), downsampleFactor, false), true); - changes = true; - } + + // Get the resulting image, if available + if (impResult == null) + impResult = WindowManager.getCurrentImage(); + } catch (RuntimeException e) { + logger.error(e.getLocalizedMessage()); + // DisplayHelpers.showErrorMessage("ImageJ macro error", e.getLocalizedMessage()); + Thread.currentThread().interrupt(); + cancelled = true; + } finally { + // IJ.runMacro(macroText, argument); + WindowManager.setTempCurrentImage(null); +// IJ.run("Close all"); + } + if (cancelled) + return; + + + // Get the current image when the macro has finished - which may or may not be the same as the original + if (impResult == null) + impResult = imp; + + + boolean changes = false; + if (params.getBooleanParameterValue("clearObjects") && pathObject.hasChildObjects()) { + pathObject.clearChildObjects(); + changes = true; + } + if (params.getBooleanParameterValue("getROI") && impResult.getRoi() != null) { + Roi roi = impResult.getRoi(); + Calibration cal = impResult.getCalibration(); + PathObject pathObjectNew = roi == null ? null : IJTools.convertToAnnotation(roi, cal.xOrigin, cal.yOrigin, downsampleFactor, region.getImagePlane()); + if (pathObjectNew != null) { + // If necessary, trim any returned annotation + if (pathROI != null && !(pathROI instanceof RectangleROI) && pathObjectNew.isAnnotation() && RoiTools.isShapeROI(pathROI) && RoiTools.isShapeROI(pathObjectNew.getROI())) { + ROI roiNew = RoiTools.combineROIs(pathROI, pathObjectNew.getROI(), CombineOp.INTERSECT); + ((PathAnnotationObject)pathObjectNew).setROI(roiNew); } - } - - boolean exportAsDetection = ((String) params.getChoiceParameterValue("getOverlayAs")).equals("Detections") ? true : false; - if (params.getBooleanParameterValue("getOverlay") && impResult.getOverlay() != null) { - var overlay = impResult.getOverlay(); - List childObjects = QuPath_Send_Overlay_to_QuPath.createObjectsFromROIs(imp, Arrays.asList(overlay.toArray()), downsampleFactor, exportAsDetection, true, region.getImagePlane()); - if (!childObjects.isEmpty()) { - pathObject.addChildObjects(childObjects); + // Only add if we have something + if (pathObjectNew.getROI() instanceof LineROI || !pathObjectNew.getROI().isEmpty()) { + pathObject.addChildObject(pathObjectNew); changes = true; } -// for (Roi roi : impResult.getOverlay().toArray()) { -// pathObject.addPathObject(IJTools.convertToPathObject(imp, imageData.getServer(), roi, downsampleFactor, true)); -// changes = true; -// } } - - if (changes) { - Platform.runLater(() -> imageData.getHierarchy().fireHierarchyChangedEvent(null)); + } + + boolean exportAsDetection = Objects.equals(params.getChoiceParameterValue("getOverlayAs"), "Detections"); + if (params.getBooleanParameterValue("getOverlay") && impResult.getOverlay() != null) { + var overlay = impResult.getOverlay(); + List childObjects = QuPath_Send_Overlay_to_QuPath.createObjectsFromROIs(imp, Arrays.asList(overlay.toArray()), downsampleFactor, exportAsDetection, true, region.getImagePlane()); + if (!childObjects.isEmpty()) { + pathObject.addChildObjects(childObjects); + changes = true; } - -// }); + } + + if (changes) { + Platform.runLater(() -> imageData.getHierarchy().fireHierarchyChangedEvent(null)); + } } catch (Exception e) { logger.error(e.getMessage(), e); } @@ -429,13 +415,5 @@ protected Collection getParentObjects(final ImageData(hierarchy.getSelectionModel().getSelectedObjects()); } return pathObjects; - -// // TODO: Give option to analyse annotations, even when TMA grid is present -// ImageData imageData = runner.getImageData(); -// TMAGrid tmaGrid = imageData.getHierarchy().getTMAGrid(); -// if (tmaGrid != null && tmaGrid.nCores() > 0) -// return PathObjectTools.getTMACoreObjects(imageData.getHierarchy(), false); -// else -// return imageData.getHierarchy().getObjects(null, PathAnnotationObject.class); } } \ No newline at end of file diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/tools/MenuTools.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/tools/MenuTools.java index 077a184a9..a2e733ae3 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/tools/MenuTools.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/tools/MenuTools.java @@ -73,35 +73,12 @@ public static Menu createMenu(final String name, final Object... items) { *

  11. {@code null} (indicating that a separator should be added)
  12. * * - * @param menu - * @param items + * @param menu menu to which items should be added + * @param items the items that should be provided (MenuItems or Actions, or null to insert a separator) * @return the provided menu, so that this method can be nested inside other calls. */ public static Menu addMenuItems(final Menu menu, final Object... items) { - // Check if the last item was a separator - - // we don't want two adjacent separators, since this looks a bit weird - boolean lastIsSeparator = menu.getItems().isEmpty() ? false : menu.getItems().get(menu.getItems().size()-1) instanceof SeparatorMenuItem; - - List newItems = new ArrayList<>(); - for (Object item : items) { - if (item == null) { - if (!lastIsSeparator) - newItems.add(new SeparatorMenuItem()); - lastIsSeparator = true; - } - else if (item instanceof MenuItem) { - newItems.add((MenuItem)item); - lastIsSeparator = false; - } - else if (item instanceof Action) { - newItems.add(ActionTools.createMenuItem((Action)item)); - lastIsSeparator = false; - } else - logger.warn("Could not add menu item {}", item); - } - if (!newItems.isEmpty()) { - menu.getItems().addAll(newItems); - } + addMenuItems(menu.getItems(), items); return menu; } @@ -110,14 +87,16 @@ else if (item instanceof Action) { * possible to work also with a {@link ContextMenu} in addition to a standard {@link Menu}. * * @param menuItems existing list to which items should be added, or null if a new list should be created - * @param items the items that should be provided (MenuItems or Actions) + * @param items the items that should be provided (MenuItems or Actions, or null to insert a separator) * @return the list containing the adding items (same as the original if provided) */ public static List addMenuItems(List menuItems, final Object... items) { if (menuItems == null) menuItems = new ArrayList<>(); - - boolean lastIsSeparator = menuItems.isEmpty() ? false : menuItems.get(menuItems.size()-1) instanceof SeparatorMenuItem; + + // Check if the last item was a separator - + // we don't want two adjacent separators, since this looks a bit weird + boolean lastIsSeparator = !menuItems.isEmpty() && menuItems.getLast() instanceof SeparatorMenuItem; List newItems = new ArrayList<>(); for (Object item : items) { @@ -126,12 +105,12 @@ public static List addMenuItems(List menuItems, final Object newItems.add(new SeparatorMenuItem()); lastIsSeparator = true; } - else if (item instanceof MenuItem) { - newItems.add((MenuItem)item); + else if (item instanceof MenuItem menuItem) { + newItems.add(menuItem); lastIsSeparator = false; } - else if (item instanceof Action) { - newItems.add(ActionTools.createMenuItem((Action)item)); + else if (item instanceof Action action) { + newItems.add(ActionTools.createMenuItem(action)); lastIsSeparator = false; } else logger.warn("Could not add menu item {}", item); From 7d977d64fd8912713efa3cda70f99b0835aa13d8 Mon Sep 17 00:00:00 2001 From: Pete Date: Tue, 15 Oct 2024 16:33:01 +0100 Subject: [PATCH 360/866] Fix detection display bug Detections could sometimes not appear in the viewer. This was caused by an overly-enthusiastic attempted optimization. --- .../java/qupath/lib/gui/viewer/overlays/HierarchyOverlay.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/overlays/HierarchyOverlay.java b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/overlays/HierarchyOverlay.java index fd5517d21..b248cd3b2 100644 --- a/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/overlays/HierarchyOverlay.java +++ b/qupath-gui-fx/src/main/java/qupath/lib/gui/viewer/overlays/HierarchyOverlay.java @@ -216,7 +216,7 @@ public void paintOverlay(final Graphics2D g2d, final ImageRegion imageRegion, fi Collections.emptyList(); // Return if nothing visible - if (paintableSelectedObjects.isEmpty() && paintableDetections.isEmpty() && paintableAnnotations.isEmpty()) + if (!showDetections && paintableSelectedObjects.isEmpty() && paintableDetections.isEmpty() && paintableAnnotations.isEmpty()) return; // Paint detection objects, if required From c21037e48690349e804b6a6c766441a1e266cc14 Mon Sep 17 00:00:00 2001 From: Pete Date: Tue, 15 Oct 2024 16:59:17 +0100 Subject: [PATCH 361/866] Beginnings of a new ImageJ macro runner --- .../java/qupath/imagej/gui/IJExtension.java | 6 +- .../gui/macro/NewImageJMacroRunner.java | 412 ++++++++++++++++++ .../downsamples/DownsampleCalculator.java | 22 + .../FixedDownsampleCalculator.java | 27 ++ .../MaxDimensionDownsampleCalculator.java | 30 ++ .../PixelCalibrationDownsampleCalculator.java | 48 ++ .../qupath/imagej/gui/macro/package-info.java | 4 + .../QuPath_Send_Overlay_to_QuPath.java | 45 +- 8 files changed, 581 insertions(+), 13 deletions(-) create mode 100644 qupath-extension-processing/src/main/java/qupath/imagej/gui/macro/NewImageJMacroRunner.java create mode 100644 qupath-extension-processing/src/main/java/qupath/imagej/gui/macro/downsamples/DownsampleCalculator.java create mode 100644 qupath-extension-processing/src/main/java/qupath/imagej/gui/macro/downsamples/FixedDownsampleCalculator.java create mode 100644 qupath-extension-processing/src/main/java/qupath/imagej/gui/macro/downsamples/MaxDimensionDownsampleCalculator.java create mode 100644 qupath-extension-processing/src/main/java/qupath/imagej/gui/macro/downsamples/PixelCalibrationDownsampleCalculator.java create mode 100644 qupath-extension-processing/src/main/java/qupath/imagej/gui/macro/package-info.java diff --git a/qupath-extension-processing/src/main/java/qupath/imagej/gui/IJExtension.java b/qupath-extension-processing/src/main/java/qupath/imagej/gui/IJExtension.java index fa2181921..6f3915d38 100644 --- a/qupath-extension-processing/src/main/java/qupath/imagej/gui/IJExtension.java +++ b/qupath-extension-processing/src/main/java/qupath/imagej/gui/IJExtension.java @@ -382,14 +382,14 @@ public static Overlay extractOverlay(PathObjectHierarchy hierarchy, RegionReques Roi roi = IJTools.convertToIJRoi(child.getROI(), xOrigin, yOrigin, downsample); roi.setStrokeColor(color); roi.setName(child.getDisplayedName()); - // roi.setStrokeWidth(2); overlay.add(roi); } if (isCell && (options == null || options.getShowCellNuclei())) { - ROI nucleus = ((PathCellObject)child).getNucleusROI(); + PathCellObject cell = (PathCellObject) child; + ROI nucleus = cell.getNucleusROI(); if (nucleus == null) continue; - Roi roi = IJTools.convertToIJRoi(((PathCellObject)child).getNucleusROI(), xOrigin, yOrigin, downsample); + Roi roi = IJTools.convertToIJRoi(nucleus, xOrigin, yOrigin, downsample); roi.setStrokeColor(color); roi.setName(child.getDisplayedName() + " - nucleus"); overlay.add(roi); diff --git a/qupath-extension-processing/src/main/java/qupath/imagej/gui/macro/NewImageJMacroRunner.java b/qupath-extension-processing/src/main/java/qupath/imagej/gui/macro/NewImageJMacroRunner.java new file mode 100644 index 000000000..c096c436b --- /dev/null +++ b/qupath-extension-processing/src/main/java/qupath/imagej/gui/macro/NewImageJMacroRunner.java @@ -0,0 +1,412 @@ +package qupath.imagej.gui.macro; + +import ij.IJ; +import ij.ImagePlus; +import ij.WindowManager; +import ij.gui.Roi; +import ij.macro.Interpreter; +import ij.measure.Calibration; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import qupath.fx.dialogs.Dialogs; +import qupath.fx.utils.FXUtils; +import qupath.imagej.gui.IJExtension; +import qupath.imagej.gui.macro.downsamples.DownsampleCalculator; +import qupath.imagej.gui.macro.downsamples.FixedDownsampleCalculator; +import qupath.imagej.gui.macro.downsamples.MaxDimensionDownsampleCalculator; +import qupath.imagej.gui.macro.downsamples.PixelCalibrationDownsampleCalculator; +import qupath.imagej.tools.IJTools; +import qupath.lib.display.ChannelDisplayInfo; +import qupath.lib.gui.images.servers.ChannelDisplayTransformServer; +import qupath.lib.images.ImageData; +import qupath.lib.images.PathImage; +import qupath.lib.images.servers.ImageServer; +import qupath.lib.images.servers.PixelCalibration; +import qupath.lib.io.GsonTools; +import qupath.lib.objects.PathObject; +import qupath.lib.objects.PathObjects; +import qupath.lib.objects.TMACoreObject; +import qupath.lib.regions.ImagePlane; +import qupath.lib.regions.ImageRegion; +import qupath.lib.regions.RegionRequest; +import qupath.lib.roi.RoiTools; +import qupath.lib.roi.interfaces.ROI; +import qupathj.QuPath_Send_Overlay_to_QuPath; + +import java.awt.image.BufferedImage; +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +public class NewImageJMacroRunner { + + private static final Logger logger = LoggerFactory.getLogger(NewImageJMacroRunner.class); + + private enum PathObjectType { + ANNOTATION, DETECTION, TILE + } + + public static class MacroParameters { + + private List channels; + private String macroText; + + private DownsampleCalculator downsample = new MaxDimensionDownsampleCalculator(1024); + private boolean setRoi = true; + private boolean setOverlay = true; + + // Result parameters + private boolean clearChildObjects = true; + private PathObjectType activeRoiObjectType = PathObjectType.ANNOTATION; + private PathObjectType overlayRoiObjectType = PathObjectType.DETECTION; + + private MacroParameters() {} + + private MacroParameters(MacroParameters params) { + channels = params.channels == null ? Collections.emptyList() : List.copyOf(params.channels); + macroText = params.macroText; + setRoi = params.setRoi; + setOverlay = params.setOverlay; + clearChildObjects = params.clearChildObjects; + activeRoiObjectType = params.activeRoiObjectType; + overlayRoiObjectType = params.overlayRoiObjectType; + } + + public List getChannels() { + return channels == null ? Collections.emptyList() : channels; + } + + public String getMacroText() { + return macroText; + } + + public DownsampleCalculator getDownsample() { + return downsample; + } + + public boolean doSetRoi() { + return setRoi; + } + + public boolean doSetOverlay() { + return setOverlay; + } + + public boolean doRemoveChildObjects() { + return clearChildObjects; + } + + public Function getActiveRoiToObjectFunction() { + return getObjectFunction(activeRoiObjectType); + } + + public Function getOverlayRoiToObjectFunction() { + return getObjectFunction(overlayRoiObjectType); + } + + private Function getObjectFunction(PathObjectType type) { + return switch (type) { + case ANNOTATION -> PathObjects::createAnnotationObject; + case DETECTION -> PathObjects::createDetectionObject; + case TILE -> PathObjects::createTileObject; + }; + } + + } + + public static class Builder { + + private MacroParameters params = new MacroParameters(); + + private Builder() {} + + public Builder macroText(String macroText) { + params.macroText = macroText; + return this; + } + + public Builder macro(File file) throws IOException { + return macro(file.toPath()); + } + + public Builder macro(Path path) throws IOException { + var text = Files.readString(path, StandardCharsets.UTF_8); + return macroText(text); + } + + public Builder setImageJRoi() { + return setImageJRoi(true); + } + + public Builder setImageJRoi(boolean doSet) { + params.setRoi = doSet; + return this; + } + + public Builder setImageJOverlay() { + return setImageJOverlay(true); + } + + public Builder setImageJOverlay(boolean doSet) { + params.setOverlay = doSet; + return this; + } + + public Builder roiToDetection() { + params.activeRoiObjectType = PathObjectType.DETECTION; + return this; + } + + public Builder roiToAnnotation() { + params.activeRoiObjectType = PathObjectType.ANNOTATION; + return this; + } + + public Builder roiToTile() { + params.activeRoiObjectType = PathObjectType.TILE; + return this; + } + + public Builder overlayToAnnotations() { + params.overlayRoiObjectType = PathObjectType.ANNOTATION; + return this; + } + + public Builder overlayToTiles() { + params.overlayRoiObjectType = PathObjectType.TILE; + return this; + } + + public Builder overlayToDetections() { + params.overlayRoiObjectType = PathObjectType.DETECTION; + return this; + } + + public Builder fixedDownsample(double downsample) { + return downsample(new FixedDownsampleCalculator(downsample)); + } + + public Builder maxDimension(double maxDim) { + return downsample(new MaxDimensionDownsampleCalculator(maxDim)); + } + + public Builder pixelSizeMicrons(double pixelSizeMicrons) { + var cal = new PixelCalibration.Builder() + .pixelSizeMicrons(pixelSizeMicrons, pixelSizeMicrons) + .build(); + return pixelSize(cal); + } + + public Builder pixelSize(PixelCalibration targetCalibration) { + return downsample(new PixelCalibrationDownsampleCalculator(targetCalibration)); + } + + public Builder downsample(DownsampleCalculator downsample) { + params.downsample = downsample; + return this; + } + + public NewImageJMacroRunner build() { + return fromParams(params); + } + + } + + public static Builder builder() { + return new Builder(); + } + + public static NewImageJMacroRunner fromParams(MacroParameters params) { + if (params == null) + throw new IllegalArgumentException("Macro parameters cannot be null"); + if (params.getMacroText() == null) + throw new IllegalArgumentException("Macro text cannot be null"); + return new NewImageJMacroRunner(params); + } + + public static NewImageJMacroRunner fromJson(String json) { + return fromParams(GsonTools.getInstance().fromJson(json, MacroParameters.class)); + } + + public static NewImageJMacroRunner fromMap(Map paramMap) { + return fromJson(GsonTools.getInstance().toJson(paramMap)); + } + + + private final MacroParameters params; + + public NewImageJMacroRunner(MacroParameters params) { + this.params = params; + } + + public void runMacro(final ImageData imageData, final PathObject pathObject) { + + // Don't try if interrupted + if (Thread.currentThread().isInterrupted()) { + logger.warn("Skipping macro for {} - thread interrupted", pathObject); + return; + } + + PathImage pathImage; + + // Extract parameters + ROI pathROI = pathObject.getROI(); + + ImageServer server = getServer(imageData); + + ImageRegion region = pathROI == null ? RegionRequest.createInstance(server) : ImageRegion.createInstance(pathROI); + double downsampleFactor = params.getDownsample().getDownsample(server, region); + RegionRequest request = RegionRequest.createInstance(server.getPath(), downsampleFactor, region); + + // Check the size of the region to extract - abort if it is too large of if ther isn't enough RAM + try { + IJTools.isMemorySufficient(request, imageData); + } catch (Exception e1) { + Dialogs.showErrorMessage("ImageJ macro error", e1.getMessage()); + return; + } + + try { + boolean sendROI = params.doSetRoi(); + if (params.doSetOverlay()) + pathImage = IJExtension.extractROIWithOverlay(server, pathObject, imageData.getHierarchy(), request, sendROI, null); + else + pathImage = IJExtension.extractROI(server, pathObject, request, sendROI); + } catch (IOException e) { + logger.error("Unable to extract image region {}", region, e); + return; + } + + // Determine a sensible argument to pass + String argument; + if (pathObject instanceof TMACoreObject || !pathObject.hasROI()) + argument = pathObject.getDisplayedName(); + else + argument = String.format("Region (%d, %d, %d, %d)", region.getX(), region.getY(), region.getWidth(), region.getHeight()); + + // Actually run the macro + final ImagePlus imp = pathImage.getImage(); + imp.setProperty("QuPath region", argument); + WindowManager.setTempCurrentImage(imp); + IJExtension.getImageJInstance(); // Ensure we've requested an instance, since this also loads any required extra plugins + + try { + boolean cancelled = false; + ImagePlus impResult = null; + try { + IJ.redirectErrorMessages(); + Interpreter interpreter = new Interpreter(); + impResult = interpreter.runBatchMacro(params.getMacroText(), imp); + + // If we had an error, return + if (interpreter.wasError()) { + Thread.currentThread().interrupt(); + return; + } + + // Get the resulting image, if available + if (impResult == null) + impResult = WindowManager.getCurrentImage(); + } catch (RuntimeException e) { + logger.error("Exception running ImageJ macro: {}", e.getMessage(), e); + Thread.currentThread().interrupt(); + cancelled = true; + } finally { + WindowManager.setTempCurrentImage(null); + } + if (cancelled) + return; + + + // Get the current image when the macro has finished - which may or may not be the same as the original + if (impResult == null) + impResult = imp; + + + boolean changes = false; + if (params.doRemoveChildObjects()) { + pathObject.clearChildObjects(); + changes = true; + } + var activeRoiToObject = params.getActiveRoiToObjectFunction(); + if (activeRoiToObject != null && impResult.getRoi() != null) { + Roi roi = impResult.getRoi(); + Calibration cal = impResult.getCalibration(); + var pathObjectNew = createNewObject(activeRoiToObject, roi, cal, downsampleFactor, region.getImagePlane(), pathROI); + if (pathObjectNew != null) { + pathObject.addChildObject(pathObjectNew); + changes = true; + } + } + + var overlayRoiToObject = params.getOverlayRoiToObjectFunction(); + if (overlayRoiToObject != null && impResult.getOverlay() != null) { + var overlay = impResult.getOverlay(); + List childObjects = QuPath_Send_Overlay_to_QuPath.createObjectsFromROIs(imp, + List.of(overlay.toArray()), downsampleFactor, overlayRoiToObject, true, region.getImagePlane()); + if (!childObjects.isEmpty()) { + pathObject.addChildObjects(childObjects); + changes = true; + } + } + + if (changes) { + FXUtils.runOnApplicationThread(() -> imageData.getHierarchy().fireHierarchyChangedEvent(null)); + } + } catch (Exception e) { + logger.error(e.getMessage(), e); + } + + } + + + private ImageServer getServer(ImageData imageData) { + var server = imageData.getServer(); + var channels = params.getChannels(); + if (channels.isEmpty()) { + return server; + } else { + return ChannelDisplayTransformServer.createColorTransformServer(server, channels); + } + } + + + private static PathObject createNewObject(Function creator, Roi roi, Calibration cal, + double downsampleFactor, ImagePlane plane, ROI clipROI) { + ROI newROI = IJTools.convertToROI(roi, cal.xOrigin, cal.yOrigin, downsampleFactor, plane); + if (newROI != null && clipROI != null && RoiTools.isShapeROI(clipROI) && RoiTools.isShapeROI(newROI)) { + newROI = RoiTools.combineROIs(clipROI, newROI, RoiTools.CombineOp.INTERSECT); + } + if (newROI == null || newROI.isEmpty()) + return null; + var pathObjectNew = creator.apply(newROI); + if (pathObjectNew != null) { + IJTools.calibrateObject(pathObjectNew, roi); + pathObjectNew.setLocked(true); + return pathObjectNew; + } else { + return null; + } + } + + /** + * Function to create an annotation object from any {@link qupath.lib.roi.PointsROI}, and + * detection object from any other {@link ROI}. + * @param roi the input ROI (must not be null) + * @return a new object with the specified ROI + */ + public static PathObject createDetectionOrPointAnnotation(ROI roi) { + if (roi.isPoint()) + return PathObjects.createAnnotationObject(roi); + else + return PathObjects.createDetectionObject(roi); + } + + +} diff --git a/qupath-extension-processing/src/main/java/qupath/imagej/gui/macro/downsamples/DownsampleCalculator.java b/qupath-extension-processing/src/main/java/qupath/imagej/gui/macro/downsamples/DownsampleCalculator.java new file mode 100644 index 000000000..cb55b0a88 --- /dev/null +++ b/qupath-extension-processing/src/main/java/qupath/imagej/gui/macro/downsamples/DownsampleCalculator.java @@ -0,0 +1,22 @@ +package qupath.imagej.gui.macro.downsamples; + +import qupath.lib.images.servers.ImageServer; +import qupath.lib.regions.ImageRegion; + +/** + * Interface for classes that can calculate a downsample value to use when requesting pixels from an image. + *

    + * This is used to support different ways to defining how a resolution could be calculated, + * e.g. using a fixed value, based upon a target pixel size, or based upon a target image size. + */ +public interface DownsampleCalculator { + + /** + * Calculate the downsample value to use when requesting a region from the server. + * @param server the input server; this must not be null + * @param region the region to request; this may be null, indicating that the entire image is requested + * @return the downsample value to use + */ + double getDownsample(ImageServer server, ImageRegion region); + +} diff --git a/qupath-extension-processing/src/main/java/qupath/imagej/gui/macro/downsamples/FixedDownsampleCalculator.java b/qupath-extension-processing/src/main/java/qupath/imagej/gui/macro/downsamples/FixedDownsampleCalculator.java new file mode 100644 index 000000000..da99dd5c8 --- /dev/null +++ b/qupath-extension-processing/src/main/java/qupath/imagej/gui/macro/downsamples/FixedDownsampleCalculator.java @@ -0,0 +1,27 @@ +package qupath.imagej.gui.macro.downsamples; + +import qupath.lib.images.servers.ImageServer; +import qupath.lib.regions.ImageRegion; + +/** + * Downsample calculator that does not actually calculate anything: it simply uses a fixed downsample. + */ +public class FixedDownsampleCalculator implements DownsampleCalculator { + + private final double downsample; + + public FixedDownsampleCalculator(double downsample) { + this.downsample = downsample; + } + + @Override + public double getDownsample(ImageServer server, ImageRegion region) { + return downsample; + } + + @Override + public String toString() { + return "FixedDownsampleCalculator[downsample=" + downsample + "]"; + } + +} diff --git a/qupath-extension-processing/src/main/java/qupath/imagej/gui/macro/downsamples/MaxDimensionDownsampleCalculator.java b/qupath-extension-processing/src/main/java/qupath/imagej/gui/macro/downsamples/MaxDimensionDownsampleCalculator.java new file mode 100644 index 000000000..3e936289e --- /dev/null +++ b/qupath-extension-processing/src/main/java/qupath/imagej/gui/macro/downsamples/MaxDimensionDownsampleCalculator.java @@ -0,0 +1,30 @@ +package qupath.imagej.gui.macro.downsamples; + +import qupath.lib.images.servers.ImageServer; +import qupath.lib.regions.ImageRegion; + +/** + * Downsample calculator that aims to ensure that the width and height of an output image + * are no more than a specified fixed size. + */ +public class MaxDimensionDownsampleCalculator implements DownsampleCalculator { + + private final double maxDimension; + + public MaxDimensionDownsampleCalculator(double maxDimension) { + this.maxDimension = maxDimension; + } + + @Override + public double getDownsample(ImageServer server, ImageRegion region) { + int width = region == null ? server.getWidth() : region.getWidth(); + int height = region == null ? server.getHeight() : region.getHeight(); + return Math.max(1.0, Math.max(width, height) / maxDimension); + } + + @Override + public String toString() { + return "MaxDimensionDownsampleCalculator[maxDimension=" + maxDimension + "]"; + } + +} diff --git a/qupath-extension-processing/src/main/java/qupath/imagej/gui/macro/downsamples/PixelCalibrationDownsampleCalculator.java b/qupath-extension-processing/src/main/java/qupath/imagej/gui/macro/downsamples/PixelCalibrationDownsampleCalculator.java new file mode 100644 index 000000000..160960130 --- /dev/null +++ b/qupath-extension-processing/src/main/java/qupath/imagej/gui/macro/downsamples/PixelCalibrationDownsampleCalculator.java @@ -0,0 +1,48 @@ +package qupath.imagej.gui.macro.downsamples; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import qupath.lib.images.servers.ImageServer; +import qupath.lib.images.servers.PixelCalibration; +import qupath.lib.regions.ImageRegion; + +import java.util.Objects; + +/** + * Downsample calculator that aims to match the resolution of the output image with a target pixel size. + *

    + * Note that the pixel calibration unit of the target resolution should match the unit of the input image. + * If it does not, the pixel sizes will still be used but a warning will be logged. + */ +public class PixelCalibrationDownsampleCalculator implements DownsampleCalculator { + + private static final Logger logger = LoggerFactory.getLogger(PixelCalibrationDownsampleCalculator.class); + + private final PixelCalibration targetCalibration; + + public PixelCalibrationDownsampleCalculator(PixelCalibration targetCalibration) { + this.targetCalibration = targetCalibration; + } + + @Override + public double getDownsample(ImageServer server, ImageRegion region) { + var cal = server.getPixelCalibration(); + if (!unitsMatch(cal)) { + logger.warn("Unmatched pixel width & height units - expected {}, {} but found {}, {}", + targetCalibration.getPixelWidthUnit(), targetCalibration.getPixelHeightUnit(), + cal.getPixelWidthUnit(), cal.getPixelHeightUnit()); + } + return targetCalibration.getAveragedPixelSize().doubleValue() / cal.getAveragedPixelSize().doubleValue(); + } + + private boolean unitsMatch(PixelCalibration newCal) { + return Objects.equals(newCal.getPixelWidthUnit(), targetCalibration.getPixelWidthUnit()) && + !Objects.equals(newCal.getPixelHeightUnit(), targetCalibration.getPixelHeightUnit()); + } + + @Override + public String toString() { + return "PixelCalibrationDownsampleCalculator[target=" + targetCalibration.getAveragedPixelSize() + "]"; + } + +} diff --git a/qupath-extension-processing/src/main/java/qupath/imagej/gui/macro/package-info.java b/qupath-extension-processing/src/main/java/qupath/imagej/gui/macro/package-info.java new file mode 100644 index 000000000..2367a1955 --- /dev/null +++ b/qupath-extension-processing/src/main/java/qupath/imagej/gui/macro/package-info.java @@ -0,0 +1,4 @@ +/** + * Classes for running ImageJ macros through QuPath. + */ +package qupath.imagej.gui.macro; \ No newline at end of file diff --git a/qupath-extension-processing/src/main/java/qupathj/QuPath_Send_Overlay_to_QuPath.java b/qupath-extension-processing/src/main/java/qupathj/QuPath_Send_Overlay_to_QuPath.java index defa26075..365ef84bc 100644 --- a/qupath-extension-processing/src/main/java/qupathj/QuPath_Send_Overlay_to_QuPath.java +++ b/qupath-extension-processing/src/main/java/qupathj/QuPath_Send_Overlay_to_QuPath.java @@ -27,12 +27,15 @@ import java.util.Arrays; import java.util.Collection; import java.util.List; +import java.util.function.Function; +import qupath.imagej.gui.macro.NewImageJMacroRunner; import qupath.imagej.tools.IJTools; import qupath.lib.gui.QuPathGUI; import qupath.lib.images.ImageData; import qupath.lib.measurements.MeasurementList; import qupath.lib.objects.PathObject; +import qupath.lib.objects.PathObjects; import qupath.lib.regions.ImagePlane; import ij.IJ; import ij.ImagePlus; @@ -49,6 +52,7 @@ import ij.process.ImageProcessor; import ij.process.ImageStatistics; import javafx.application.Platform; +import qupath.lib.roi.interfaces.ROI; /** * ImageJ plugin for sending back all the ROIs on an ImageJ overlay to QuPath. @@ -141,21 +145,46 @@ else if (server.nZSlices() * server.nTimepoints() > 1) { }); } } - + /** * Turn an array of ImageJ ROIs into a list of QuPath PathObjects, optionally adding measurements as well. - * + * * @param imp * @param rois * @param downsample * @param asDetection * @param includeMeasurements - * @param plane + * @param plane * @return * @since v0.4.0 */ - public static List createObjectsFromROIs(final ImagePlus imp, final Collection rois, - final double downsample, final boolean asDetection, final boolean includeMeasurements, final ImagePlane plane) { + public static List createObjectsFromROIs(final ImagePlus imp, final Collection rois, + final double downsample, final boolean asDetection, final boolean includeMeasurements, final ImagePlane plane) { + Function creator; + if (asDetection) { + // Legacy behavior - create detections if requested, unless we have a point ROI - in which case we can only create annotations + creator = NewImageJMacroRunner::createDetectionOrPointAnnotation; + } else { + creator = PathObjects::createAnnotationObject; + } + return createObjectsFromROIs(imp, rois, downsample, creator, includeMeasurements, plane); + } + + /** + * Turn an array of ImageJ ROIs into a list of QuPath PathObjects, optionally adding measurements as well. + * + * @param imp the image to use for measurements + * @param rois the ROIs to convert + * @param downsample the downsample factor of the image + * @param creator the function to create QuPath objects from ROIs + * @param includeMeasurements request to include measurements for the objects that are created + * @param plane the image plane for the created objects + * @return + * @since v0.6.0 + */ + public static List createObjectsFromROIs(final ImagePlus imp, final Collection rois, + final double downsample, final Function creator, + final boolean includeMeasurements, final ImagePlane plane) { List pathObjects = new ArrayList<>(); ResultsTable rt = new ResultsTable(); Analyzer analyzer = imp == null ? null : new Analyzer(imp, Analyzer.getMeasurements(), rt); @@ -164,11 +193,7 @@ public static List createObjectsFromROIs(final ImagePlus imp, final var xOrigin = cal == null ? 0 : cal.xOrigin; var yOrigin = cal == null ? 0 : cal.yOrigin; for (Roi roi : rois) { - PathObject pathObject; - if (asDetection && !(roi instanceof PointRoi)) - pathObject = IJTools.convertToDetection(roi, xOrigin, yOrigin, downsample, plane); - else - pathObject = IJTools.convertToAnnotation(roi, xOrigin, yOrigin, downsample, plane); + PathObject pathObject = IJTools.convertToPathObject(roi, xOrigin, yOrigin, downsample, creator, plane); if (pathObject == null) IJ.log("Sorry, I couldn't convert " + roi + " to a valid QuPath object"); else { From e00fd4e5ac14113d26bf1dd1683e3ec10e5c1e0a Mon Sep 17 00:00:00 2001 From: Pete Date: Tue, 15 Oct 2024 20:17:19 +0100 Subject: [PATCH 362/866] More steps towards a new macro runner --- .../gui/macro/NewImageJMacroRunner.java | 488 +++++++++++------- .../downsamples/DownsampleCalculators.java | 38 ++ .../FixedDownsampleCalculator.java | 4 +- .../MaxDimensionDownsampleCalculator.java | 4 +- .../PixelCalibrationDownsampleCalculator.java | 4 +- 5 files changed, 355 insertions(+), 183 deletions(-) create mode 100644 qupath-extension-processing/src/main/java/qupath/imagej/gui/macro/downsamples/DownsampleCalculators.java diff --git a/qupath-extension-processing/src/main/java/qupath/imagej/gui/macro/NewImageJMacroRunner.java b/qupath-extension-processing/src/main/java/qupath/imagej/gui/macro/NewImageJMacroRunner.java index c096c436b..297b984fa 100644 --- a/qupath-extension-processing/src/main/java/qupath/imagej/gui/macro/NewImageJMacroRunner.java +++ b/qupath-extension-processing/src/main/java/qupath/imagej/gui/macro/NewImageJMacroRunner.java @@ -1,5 +1,9 @@ package qupath.imagej.gui.macro; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; import ij.IJ; import ij.ImagePlus; import ij.WindowManager; @@ -12,16 +16,14 @@ import qupath.fx.utils.FXUtils; import qupath.imagej.gui.IJExtension; import qupath.imagej.gui.macro.downsamples.DownsampleCalculator; -import qupath.imagej.gui.macro.downsamples.FixedDownsampleCalculator; -import qupath.imagej.gui.macro.downsamples.MaxDimensionDownsampleCalculator; -import qupath.imagej.gui.macro.downsamples.PixelCalibrationDownsampleCalculator; +import qupath.imagej.gui.macro.downsamples.DownsampleCalculators; import qupath.imagej.tools.IJTools; -import qupath.lib.display.ChannelDisplayInfo; -import qupath.lib.gui.images.servers.ChannelDisplayTransformServer; import qupath.lib.images.ImageData; import qupath.lib.images.PathImage; +import qupath.lib.images.servers.ColorTransforms; import qupath.lib.images.servers.ImageServer; import qupath.lib.images.servers.PixelCalibration; +import qupath.lib.images.servers.TransformedServerBuilder; import qupath.lib.io.GsonTools; import qupath.lib.objects.PathObject; import qupath.lib.objects.PathObjects; @@ -31,6 +33,7 @@ import qupath.lib.regions.RegionRequest; import qupath.lib.roi.RoiTools; import qupath.lib.roi.interfaces.ROI; +import qupath.lib.scripting.QP; import qupathj.QuPath_Send_Overlay_to_QuPath; import java.awt.image.BufferedImage; @@ -39,10 +42,14 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; import java.util.Collections; import java.util.List; import java.util.Map; import java.util.function.Function; +import java.util.stream.IntStream; public class NewImageJMacroRunner { @@ -52,174 +59,10 @@ private enum PathObjectType { ANNOTATION, DETECTION, TILE } - public static class MacroParameters { - - private List channels; - private String macroText; - - private DownsampleCalculator downsample = new MaxDimensionDownsampleCalculator(1024); - private boolean setRoi = true; - private boolean setOverlay = true; - - // Result parameters - private boolean clearChildObjects = true; - private PathObjectType activeRoiObjectType = PathObjectType.ANNOTATION; - private PathObjectType overlayRoiObjectType = PathObjectType.DETECTION; - - private MacroParameters() {} - - private MacroParameters(MacroParameters params) { - channels = params.channels == null ? Collections.emptyList() : List.copyOf(params.channels); - macroText = params.macroText; - setRoi = params.setRoi; - setOverlay = params.setOverlay; - clearChildObjects = params.clearChildObjects; - activeRoiObjectType = params.activeRoiObjectType; - overlayRoiObjectType = params.overlayRoiObjectType; - } - - public List getChannels() { - return channels == null ? Collections.emptyList() : channels; - } - - public String getMacroText() { - return macroText; - } - - public DownsampleCalculator getDownsample() { - return downsample; - } - - public boolean doSetRoi() { - return setRoi; - } - - public boolean doSetOverlay() { - return setOverlay; - } - - public boolean doRemoveChildObjects() { - return clearChildObjects; - } - - public Function getActiveRoiToObjectFunction() { - return getObjectFunction(activeRoiObjectType); - } - - public Function getOverlayRoiToObjectFunction() { - return getObjectFunction(overlayRoiObjectType); - } - - private Function getObjectFunction(PathObjectType type) { - return switch (type) { - case ANNOTATION -> PathObjects::createAnnotationObject; - case DETECTION -> PathObjects::createDetectionObject; - case TILE -> PathObjects::createTileObject; - }; - } - - } - - public static class Builder { - - private MacroParameters params = new MacroParameters(); - - private Builder() {} - - public Builder macroText(String macroText) { - params.macroText = macroText; - return this; - } - - public Builder macro(File file) throws IOException { - return macro(file.toPath()); - } - - public Builder macro(Path path) throws IOException { - var text = Files.readString(path, StandardCharsets.UTF_8); - return macroText(text); - } - - public Builder setImageJRoi() { - return setImageJRoi(true); - } - - public Builder setImageJRoi(boolean doSet) { - params.setRoi = doSet; - return this; - } - - public Builder setImageJOverlay() { - return setImageJOverlay(true); - } - - public Builder setImageJOverlay(boolean doSet) { - params.setOverlay = doSet; - return this; - } - - public Builder roiToDetection() { - params.activeRoiObjectType = PathObjectType.DETECTION; - return this; - } - - public Builder roiToAnnotation() { - params.activeRoiObjectType = PathObjectType.ANNOTATION; - return this; - } - - public Builder roiToTile() { - params.activeRoiObjectType = PathObjectType.TILE; - return this; - } - - public Builder overlayToAnnotations() { - params.overlayRoiObjectType = PathObjectType.ANNOTATION; - return this; - } - - public Builder overlayToTiles() { - params.overlayRoiObjectType = PathObjectType.TILE; - return this; - } - - public Builder overlayToDetections() { - params.overlayRoiObjectType = PathObjectType.DETECTION; - return this; - } - - public Builder fixedDownsample(double downsample) { - return downsample(new FixedDownsampleCalculator(downsample)); - } - - public Builder maxDimension(double maxDim) { - return downsample(new MaxDimensionDownsampleCalculator(maxDim)); - } - - public Builder pixelSizeMicrons(double pixelSizeMicrons) { - var cal = new PixelCalibration.Builder() - .pixelSizeMicrons(pixelSizeMicrons, pixelSizeMicrons) - .build(); - return pixelSize(cal); - } - - public Builder pixelSize(PixelCalibration targetCalibration) { - return downsample(new PixelCalibrationDownsampleCalculator(targetCalibration)); - } - - public Builder downsample(DownsampleCalculator downsample) { - params.downsample = downsample; - return this; - } - - public NewImageJMacroRunner build() { - return fromParams(params); - } - - } + private final MacroParameters params; - public static Builder builder() { - return new Builder(); + public NewImageJMacroRunner(MacroParameters params) { + this.params = params; } public static NewImageJMacroRunner fromParams(MacroParameters params) { @@ -238,14 +81,30 @@ public static NewImageJMacroRunner fromMap(Map paramMap) { return fromJson(GsonTools.getInstance().toJson(paramMap)); } + public void run() { + run(QP.getCurrentImageData()); + } - private final MacroParameters params; + public void run(final ImageData imageData) { + if (imageData == null) + throw new IllegalArgumentException("No image data available"); + var selected = List.copyOf(imageData.getHierarchy().getSelectionModel().getSelectedObjects()); + if (selected.isEmpty()) + selected = List.of(imageData.getHierarchy().getRootObject()); + run(imageData, selected); + } - public NewImageJMacroRunner(MacroParameters params) { - this.params = params; + public void run(final ImageData imageData, final Collection pathObjects) { + for (var parent : pathObjects) { + run(imageData, parent); + } + addScriptToWorkflow(pathObjects); } - public void runMacro(final ImageData imageData, final PathObject pathObject) { + private void run(final ImageData imageData, final PathObject pathObject) { + + if (imageData == null) + throw new IllegalArgumentException("No image data available"); // Don't try if interrupted if (Thread.currentThread().isInterrupted()) { @@ -366,13 +225,96 @@ public void runMacro(final ImageData imageData, final PathObject } + private void addScriptToWorkflow(Collection parents) { + var sb = new StringBuilder(); + if (!parents.isEmpty()) { + if (parents.stream().allMatch(PathObject::isAnnotation)) { + sb.append("// selectAnnotations()\n"); + } else if (parents.stream().allMatch(PathObject::isDetection)) { + sb.append("// selectDetections()\n"); + } else if (parents.stream().allMatch(PathObject::isTile)) { + sb.append("// selectTiles()\n"); + } else if (parents.stream().allMatch(PathObject::isTMACore)) { + sb.append("// selectTMACores()\n"); + } else if (parents.stream().allMatch(PathObject::isCell)) { + sb.append("// selectCells()\n"); + } + } + var gson = GsonTools.getInstance(); + var obj = gson.fromJson(gson.toJson(params), JsonObject.class); + + sb.append(NewImageJMacroRunner.class.getName()).append(".fromMap("); + sb.append(toGroovy(obj)); +// boolean isFirst = true; +// for (var entry : obj.asMap().entrySet()) { +// if (!isFirst) { +// sb.append(","); +// } +// sb.append("\n "); +// sb.append(entry.getKey()); +// sb.append(": "); +// appendValue(sb, entry.getValue()); +// isFirst = false; +// } + sb.append(").run()"); + + logger.info(sb.toString()); + } + + private static String toGroovy(JsonElement element) { + var sb = new StringBuilder(); + appendValue(sb, element); + return sb.toString(); + } + + + private static String appendValue(StringBuilder sb, JsonElement val) { + switch (val) { + case JsonPrimitive primitive -> { + if (primitive.isString()) { + String str = val.getAsString(); + String quote = "\""; + if (str.contains(quote)) + quote = "\"\"\""; + sb.append(quote).append(primitive.getAsString()).append(quote); + } else + sb.append(primitive.getAsString()); + } + case JsonArray array -> { + for (int i = 0; i < array.size(); i++) { + sb.append(appendValue(sb, array.get(i))); + } + } + case JsonObject obj -> { + sb.append("["); + boolean isFirst = true; + for (var entry : obj.asMap().entrySet()) { + if (!isFirst) { + sb.append(", "); + } + sb.append(entry.getKey()).append(": "); + appendValue(sb, entry.getValue()); + isFirst = false; + } + sb.append("]"); + } + case null, default -> { + sb.append("null"); + } + } + return sb.toString(); + } + + private ImageServer getServer(ImageData imageData) { var server = imageData.getServer(); var channels = params.getChannels(); if (channels.isEmpty()) { return server; } else { - return ChannelDisplayTransformServer.createColorTransformServer(server, channels); + return new TransformedServerBuilder(server) + .applyColorTransforms(channels.toArray(ColorTransforms.ColorTransform[]::new)) + .build(); } } @@ -409,4 +351,196 @@ public static PathObject createDetectionOrPointAnnotation(ROI roi) { } + public static class MacroParameters { + + private String macroText; + private List channels; + + private DownsampleCalculator downsample = DownsampleCalculators.maxDimension(1024); + private boolean setRoi = true; + private boolean setOverlay = true; + + // Result parameters + private boolean clearChildObjects = true; + private PathObjectType activeRoiObjectType = null; + private PathObjectType overlayRoiObjectType = null; + + private MacroParameters() {} + + private MacroParameters(MacroParameters params) { + // Store null since then it'll be skipped with json serialization + channels = params.channels == null || params.channels.isEmpty() ? null : List.copyOf(params.channels); + macroText = params.macroText; + setRoi = params.setRoi; + setOverlay = params.setOverlay; + clearChildObjects = params.clearChildObjects; + activeRoiObjectType = params.activeRoiObjectType; + overlayRoiObjectType = params.overlayRoiObjectType; + } + + public List getChannels() { + return channels == null ? Collections.emptyList() : channels; + } + + public String getMacroText() { + return macroText; + } + + public DownsampleCalculator getDownsample() { + return downsample; + } + + public boolean doSetRoi() { + return setRoi; + } + + public boolean doSetOverlay() { + return setOverlay; + } + + public boolean doRemoveChildObjects() { + return clearChildObjects; + } + + public Function getActiveRoiToObjectFunction() { + return getObjectFunction(activeRoiObjectType); + } + + public Function getOverlayRoiToObjectFunction() { + return getObjectFunction(overlayRoiObjectType); + } + + private Function getObjectFunction(PathObjectType type) { + return switch (type) { + case ANNOTATION -> PathObjects::createAnnotationObject; + case DETECTION -> PathObjects::createDetectionObject; + case TILE -> PathObjects::createTileObject; + }; + } + + } + + public static class Builder { + + private MacroParameters params = new MacroParameters(); + + private Builder() {} + + public Builder macroText(String macroText) { + params.macroText = macroText; + return this; + } + + public Builder macro(File file) throws IOException { + return macro(file.toPath()); + } + + public Builder macro(Path path) throws IOException { + var text = Files.readString(path, StandardCharsets.UTF_8); + return macroText(text); + } + + public Builder setImageJRoi() { + return setImageJRoi(true); + } + + public Builder setImageJRoi(boolean doSet) { + params.setRoi = doSet; + return this; + } + + public Builder setImageJOverlay() { + return setImageJOverlay(true); + } + + public Builder setImageJOverlay(boolean doSet) { + params.setOverlay = doSet; + return this; + } + + public Builder roiToDetection() { + params.activeRoiObjectType = PathObjectType.DETECTION; + return this; + } + + public Builder roiToAnnotation() { + params.activeRoiObjectType = PathObjectType.ANNOTATION; + return this; + } + + public Builder roiToTile() { + params.activeRoiObjectType = PathObjectType.TILE; + return this; + } + + public Builder overlayToAnnotations() { + params.overlayRoiObjectType = PathObjectType.ANNOTATION; + return this; + } + + public Builder overlayToTiles() { + params.overlayRoiObjectType = PathObjectType.TILE; + return this; + } + + public Builder overlayToDetections() { + params.overlayRoiObjectType = PathObjectType.DETECTION; + return this; + } + + public Builder fixedDownsample(double downsample) { + return downsample(DownsampleCalculators.fixedDownsample(downsample)); + } + + public Builder maxDimension(int maxDim) { + return downsample(DownsampleCalculators.maxDimension(maxDim)); + } + + public Builder pixelSizeMicrons(double pixelSizeMicrons) { + return downsample(DownsampleCalculators.pixelSizeMicrons(pixelSizeMicrons)); + } + + public Builder pixelSize(PixelCalibration targetCalibration) { + return downsample(DownsampleCalculators.pixelSize(targetCalibration)); + } + + public Builder downsample(DownsampleCalculator downsample) { + params.downsample = downsample; + return this; + } + + public Builder channelIndices(int... inds) { + return channels( + IntStream.of(inds).mapToObj(ColorTransforms::createChannelExtractor).toList() + ); + } + + public Builder channelNames(String... names) { + return channels( + Arrays.stream(names).map(ColorTransforms::createChannelExtractor).toList() + ); + } + + public Builder channels(ColorTransforms.ColorTransform channel, ColorTransforms.ColorTransform... channels) { + var list = new ArrayList(); + list.add(channel); + Collections.addAll(list, channels); + return channels(list); + } + + public Builder channels(Collection channels) { + params.channels = channels == null ? null : List.copyOf(channels); + return this; + } + + public NewImageJMacroRunner build() { + return fromParams(params); + } + + } + + public static Builder builder() { + return new Builder(); + } + } diff --git a/qupath-extension-processing/src/main/java/qupath/imagej/gui/macro/downsamples/DownsampleCalculators.java b/qupath-extension-processing/src/main/java/qupath/imagej/gui/macro/downsamples/DownsampleCalculators.java new file mode 100644 index 000000000..56c668bce --- /dev/null +++ b/qupath-extension-processing/src/main/java/qupath/imagej/gui/macro/downsamples/DownsampleCalculators.java @@ -0,0 +1,38 @@ +package qupath.imagej.gui.macro.downsamples; + +import com.google.gson.TypeAdapterFactory; +import qupath.lib.images.servers.PixelCalibration; +import qupath.lib.io.GsonTools; + +public class DownsampleCalculators { + + private static final TypeAdapterFactory factory = GsonTools.createSubTypeAdapterFactory( + DownsampleCalculator.class, "downsampleType") + .registerSubtype(MaxDimensionDownsampleCalculator.class, "maxDim") + .registerSubtype(FixedDownsampleCalculator.class, "fixed") + .registerSubtype(PixelCalibrationDownsampleCalculator.class, "pixelSize"); + + static { + GsonTools.getDefaultBuilder().registerTypeAdapterFactory(factory); + } + + public static DownsampleCalculator maxDimension(final int maxDimension) { + return new MaxDimensionDownsampleCalculator(maxDimension); + } + + public static DownsampleCalculator fixedDownsample(final double downsample) { + return new FixedDownsampleCalculator(downsample); + } + + public static DownsampleCalculator pixelSizeMicrons(double pixelSizeMicrons) { + var cal = new PixelCalibration.Builder() + .pixelSizeMicrons(pixelSizeMicrons, pixelSizeMicrons) + .build(); + return pixelSize(cal); + } + + public static DownsampleCalculator pixelSize(PixelCalibration cal) { + return new PixelCalibrationDownsampleCalculator(cal); + } + +} diff --git a/qupath-extension-processing/src/main/java/qupath/imagej/gui/macro/downsamples/FixedDownsampleCalculator.java b/qupath-extension-processing/src/main/java/qupath/imagej/gui/macro/downsamples/FixedDownsampleCalculator.java index da99dd5c8..4f5c00493 100644 --- a/qupath-extension-processing/src/main/java/qupath/imagej/gui/macro/downsamples/FixedDownsampleCalculator.java +++ b/qupath-extension-processing/src/main/java/qupath/imagej/gui/macro/downsamples/FixedDownsampleCalculator.java @@ -6,11 +6,11 @@ /** * Downsample calculator that does not actually calculate anything: it simply uses a fixed downsample. */ -public class FixedDownsampleCalculator implements DownsampleCalculator { +class FixedDownsampleCalculator implements DownsampleCalculator { private final double downsample; - public FixedDownsampleCalculator(double downsample) { + FixedDownsampleCalculator(double downsample) { this.downsample = downsample; } diff --git a/qupath-extension-processing/src/main/java/qupath/imagej/gui/macro/downsamples/MaxDimensionDownsampleCalculator.java b/qupath-extension-processing/src/main/java/qupath/imagej/gui/macro/downsamples/MaxDimensionDownsampleCalculator.java index 3e936289e..34b3e2fbe 100644 --- a/qupath-extension-processing/src/main/java/qupath/imagej/gui/macro/downsamples/MaxDimensionDownsampleCalculator.java +++ b/qupath-extension-processing/src/main/java/qupath/imagej/gui/macro/downsamples/MaxDimensionDownsampleCalculator.java @@ -7,11 +7,11 @@ * Downsample calculator that aims to ensure that the width and height of an output image * are no more than a specified fixed size. */ -public class MaxDimensionDownsampleCalculator implements DownsampleCalculator { +class MaxDimensionDownsampleCalculator implements DownsampleCalculator { private final double maxDimension; - public MaxDimensionDownsampleCalculator(double maxDimension) { + MaxDimensionDownsampleCalculator(double maxDimension) { this.maxDimension = maxDimension; } diff --git a/qupath-extension-processing/src/main/java/qupath/imagej/gui/macro/downsamples/PixelCalibrationDownsampleCalculator.java b/qupath-extension-processing/src/main/java/qupath/imagej/gui/macro/downsamples/PixelCalibrationDownsampleCalculator.java index 160960130..e1e149745 100644 --- a/qupath-extension-processing/src/main/java/qupath/imagej/gui/macro/downsamples/PixelCalibrationDownsampleCalculator.java +++ b/qupath-extension-processing/src/main/java/qupath/imagej/gui/macro/downsamples/PixelCalibrationDownsampleCalculator.java @@ -14,13 +14,13 @@ * Note that the pixel calibration unit of the target resolution should match the unit of the input image. * If it does not, the pixel sizes will still be used but a warning will be logged. */ -public class PixelCalibrationDownsampleCalculator implements DownsampleCalculator { +class PixelCalibrationDownsampleCalculator implements DownsampleCalculator { private static final Logger logger = LoggerFactory.getLogger(PixelCalibrationDownsampleCalculator.class); private final PixelCalibration targetCalibration; - public PixelCalibrationDownsampleCalculator(PixelCalibration targetCalibration) { + PixelCalibrationDownsampleCalculator(PixelCalibration targetCalibration) { this.targetCalibration = targetCalibration; } From 812c46dab8bab5c418bcecc09c4df23503aa3b73 Mon Sep 17 00:00:00 2001 From: Pete Date: Wed, 16 Oct 2024 09:22:52 +0100 Subject: [PATCH 363/866] An almost-function macro runner Almost... but still not. --- .../java/qupath/imagej/gui/IJExtension.java | 44 +++- .../gui/macro/MacroRunnerController.java | 192 ++++++++++++++++++ .../gui/macro/MappedStringConverter.java | 42 ++++ .../gui/macro/NewImageJMacroRunner.java | 7 +- .../qupath/imagej/gui/macro/macro-runner.css | 7 + .../qupath/imagej/gui/macro/macro-runner.fxml | 88 ++++++++ .../imagej/gui/macro/strings.properties | 0 7 files changed, 377 insertions(+), 3 deletions(-) create mode 100644 qupath-extension-processing/src/main/java/qupath/imagej/gui/macro/MacroRunnerController.java create mode 100644 qupath-extension-processing/src/main/java/qupath/imagej/gui/macro/MappedStringConverter.java create mode 100644 qupath-extension-processing/src/main/resources/qupath/imagej/gui/macro/macro-runner.css create mode 100644 qupath-extension-processing/src/main/resources/qupath/imagej/gui/macro/macro-runner.fxml create mode 100644 qupath-extension-processing/src/main/resources/qupath/imagej/gui/macro/strings.properties diff --git a/qupath-extension-processing/src/main/java/qupath/imagej/gui/IJExtension.java b/qupath-extension-processing/src/main/java/qupath/imagej/gui/IJExtension.java index 6f3915d38..f4bff32b5 100644 --- a/qupath-extension-processing/src/main/java/qupath/imagej/gui/IJExtension.java +++ b/qupath-extension-processing/src/main/java/qupath/imagej/gui/IJExtension.java @@ -34,6 +34,7 @@ import javafx.application.Platform; import javafx.beans.property.StringProperty; import javafx.geometry.Orientation; +import javafx.scene.Scene; import javafx.scene.control.Menu; import javafx.scene.control.MenuButton; import javafx.scene.control.Separator; @@ -58,6 +59,8 @@ import java.util.function.Predicate; import javax.swing.SwingUtilities; +import javafx.scene.layout.BorderPane; +import javafx.stage.Stage; import org.controlsfx.control.action.Action; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -71,6 +74,7 @@ import qupath.imagej.detect.dearray.TMADearrayerPluginIJ; import qupath.imagej.detect.tissue.PositivePixelCounterIJ; import qupath.imagej.detect.tissue.SimpleTissueDetection2; +import qupath.imagej.gui.macro.MacroRunnerController; import qupath.imagej.superpixels.DoGSuperpixelsPlugin; import qupath.imagej.superpixels.SLICSuperpixelsPlugin; import qupath.imagej.tools.IJTools; @@ -506,7 +510,8 @@ public static class IJExtensionCommands { var screenshotCommand = new ScreenshotCommand(qupath); actionSnapshot = ActionTools.createAction(screenshotCommand); - actionMacroRunner = createPluginAction(new ImageJMacroRunner(qupath)); +// actionMacroRunner = createPluginAction(new ImageJMacroRunner(qupath)); + actionMacroRunner = new MacroRunnerWrapper(qupath).createAction(); actionSLIC = createPluginAction(SLICSuperpixelsPlugin.class); actionDoG = createPluginAction(DoGSuperpixelsPlugin.class); @@ -538,6 +543,43 @@ private Action createPluginAction(PathPlugin plugin) { } + private static class MacroRunnerWrapper { + + private final QuPathGUI qupath; + + private Stage stage; + + private MacroRunnerWrapper(QuPathGUI qupath) { + this.qupath = qupath; + } + + Action createAction() { + return new Action(e -> showStage()); + } + + private void showStage() { + if (stage == null) { + String title = "ImageJ macro runner"; + try { + stage = new Stage(); + var pane = MacroRunnerController.createInstance(qupath); + Scene scene = new Scene(new BorderPane(pane)); +// pane.heightProperty().addListener((v, o, n) -> handleStageHeightChange()); + stage.setScene(scene); + stage.initOwner(QuPathGUI.getInstance().getStage()); +// stage.setTitle(resources.getString("title")); + stage.setTitle(title); + stage.setResizable(true); + } catch (IOException e) { + Dialogs.showErrorMessage(title, "GUI loading failed"); + logger.error("Unable to load InstanSeg FXML", e); + } + } + stage.show(); + } + + } + private static void promptToSetImageJDirectory() { String ijPath = getImageJPath(); diff --git a/qupath-extension-processing/src/main/java/qupath/imagej/gui/macro/MacroRunnerController.java b/qupath-extension-processing/src/main/java/qupath/imagej/gui/macro/MacroRunnerController.java new file mode 100644 index 000000000..172a7fdfb --- /dev/null +++ b/qupath-extension-processing/src/main/java/qupath/imagej/gui/macro/MacroRunnerController.java @@ -0,0 +1,192 @@ +package qupath.imagej.gui.macro; + +import javafx.event.ActionEvent; +import javafx.fxml.FXML; +import javafx.fxml.FXMLLoader; +import javafx.scene.control.Button; +import javafx.scene.control.CheckBox; +import javafx.scene.control.ChoiceBox; +import javafx.scene.control.Label; +import javafx.scene.control.TextArea; +import javafx.scene.control.TextField; +import javafx.scene.control.TitledPane; +import javafx.scene.layout.BorderPane; +import qupath.fx.dialogs.Dialogs; +import qupath.imagej.gui.macro.downsamples.DownsampleCalculator; +import qupath.imagej.gui.macro.downsamples.DownsampleCalculators; +import qupath.lib.gui.QuPathGUI; + +import java.io.IOException; +import java.util.List; +import java.util.ResourceBundle; + +public class MacroRunnerController extends BorderPane { + + private final QuPathGUI qupath; + + private ResourceBundle resources = ResourceBundle.getBundle("qupath.imagej.gui.macro.strings"); + + private static final String title = "Macro runner"; + + public enum ResolutionOption { + + FIXED_DOWNSAMPLE, PIXEL_SIZE, LARGEST_DIMENSION; + + public String toString() { + return switch (this) { + case PIXEL_SIZE -> "Pixel size (µm)"; + case FIXED_DOWNSAMPLE -> "Fixed downsample"; + case LARGEST_DIMENSION -> "Largest dimensions"; + }; + } + + public DownsampleCalculator createCalculator(double value) { + return switch (this) { + case PIXEL_SIZE -> DownsampleCalculators.pixelSizeMicrons(value); + case FIXED_DOWNSAMPLE -> DownsampleCalculators.fixedDownsample(value); + case LARGEST_DIMENSION -> DownsampleCalculators.maxDimension((int)Math.round(value)); + }; + } + } + + @FXML + private Button btnMakeSelection; + + @FXML + private Button btnRunMacro; + + @FXML + private CheckBox cbAddToHistory; + + @FXML + private CheckBox cbDeleteExistingObjects; + + @FXML + private CheckBox cbSetImageJOverlay; + + @FXML + private CheckBox cbSetImageJRoi; + + @FXML + private ChoiceBox choiceResolution; + + @FXML + private ChoiceBox choiceReturnOverlay; + + @FXML + private ChoiceBox choiceReturnRoi; + + @FXML + private ChoiceBox choiceSelectAll; + + @FXML + private Label labelResolution; + + @FXML + private TextArea textAreaMacro; + + @FXML + private TextField tfResolution; + + @FXML + private TitledPane titledMacro; + + @FXML + private TitledPane titledOptions; + + + private MacroRunnerController(QuPathGUI qupath) throws IOException { + this.qupath = qupath; + var url = MacroRunnerController.class.getResource("macro-runner.fxml"); + FXMLLoader loader = new FXMLLoader(url, resources); + loader.setRoot(this); + loader.setController(this); + loader.load(); + + init(); + } + + private void init() { + initReturnObjectTypeChoices(); + initSelectObjectTypeChoices(); + } + + + private void initReturnObjectTypeChoices() { + var availableTypes = List.of( + NewImageJMacroRunner.PathObjectType.NONE, + NewImageJMacroRunner.PathObjectType.ANNOTATION, + NewImageJMacroRunner.PathObjectType.DETECTION, + NewImageJMacroRunner.PathObjectType.TILE, + NewImageJMacroRunner.PathObjectType.CELL); + choiceReturnRoi.getItems().setAll(availableTypes); + choiceReturnRoi.setConverter( + MappedStringConverter.createFromFunction( + MacroRunnerController::typeToName, NewImageJMacroRunner.PathObjectType.values())); + // TODO: Create persistent preference + choiceReturnRoi.getSelectionModel().selectFirst(); + + choiceReturnOverlay.getItems().setAll(availableTypes); + choiceReturnOverlay.setConverter( + MappedStringConverter.createFromFunction( + MacroRunnerController::typeToPluralName, NewImageJMacroRunner.PathObjectType.values())); + // TODO: Create persistent preference + choiceReturnOverlay.getSelectionModel().selectFirst(); + } + + private static String typeToName(NewImageJMacroRunner.PathObjectType type) { + if (type == NewImageJMacroRunner.PathObjectType.NONE) + return "-"; + String name = type.name(); + return name.substring(0, 1).toUpperCase() + name.substring(1).toLowerCase(); + } + + private static String typeToPluralName(NewImageJMacroRunner.PathObjectType type) { + if (type == NewImageJMacroRunner.PathObjectType.NONE) + return "-"; + return typeToName(type) + "s"; + } + + + private void initSelectObjectTypeChoices() { + var availableTypes = List.of( + NewImageJMacroRunner.PathObjectType.ANNOTATION, + NewImageJMacroRunner.PathObjectType.DETECTION, + NewImageJMacroRunner.PathObjectType.TILE, + NewImageJMacroRunner.PathObjectType.CELL, + NewImageJMacroRunner.PathObjectType.TMA_CORE); + choiceSelectAll.getItems().setAll(availableTypes); + choiceSelectAll.setConverter( + MappedStringConverter.createFromFunction( + MacroRunnerController::typeToPluralName, NewImageJMacroRunner.PathObjectType.values())); + // TODO: Create persistent preference + choiceSelectAll.getSelectionModel().selectFirst(); + + var selectObjectsChoice = choiceSelectAll.getSelectionModel().selectedItemProperty(); + btnMakeSelection.disableProperty().bind( + selectObjectsChoice.isNull() + ); + } + + + public static MacroRunnerController createInstance(QuPathGUI qupath) throws IOException { + return new MacroRunnerController(qupath); + } + + @FXML + void handleMakeSelection(ActionEvent event) { + + } + + @FXML + void handleRun(ActionEvent event) { + Dialogs.showInfoNotification(title, "Run pressed!"); + var runner = NewImageJMacroRunner.builder() + .setImageJRoi(cbSetImageJRoi.isSelected()) + .setImageJOverlay(cbSetImageJOverlay.isSelected()) + .macroText(textAreaMacro.getText()) + .build(); + new Thread(runner::run, "macro-runner").start(); + } + +} diff --git a/qupath-extension-processing/src/main/java/qupath/imagej/gui/macro/MappedStringConverter.java b/qupath-extension-processing/src/main/java/qupath/imagej/gui/macro/MappedStringConverter.java new file mode 100644 index 000000000..89fe3929f --- /dev/null +++ b/qupath-extension-processing/src/main/java/qupath/imagej/gui/macro/MappedStringConverter.java @@ -0,0 +1,42 @@ +package qupath.imagej.gui.macro; + +import javafx.util.StringConverter; + +import java.util.Arrays; +import java.util.Map; +import java.util.Objects; +import java.util.function.Function; +import java.util.stream.Collectors; + +public class MappedStringConverter extends StringConverter { + + private final Map map; + + private MappedStringConverter(Map map) { + this.map = Map.copyOf(map); + } + + public static StringConverter createFromFunction(Function fun, T... values) { + var map = Arrays.stream(values) + .collect(Collectors.toMap(t -> t, fun)); + return create(map); + } + + public static StringConverter create(Map map) { + return new MappedStringConverter<>(map); + } + + @Override + public String toString(T object) { + return object == null ? null : map.getOrDefault(object, null); + } + + @Override + public T fromString(String string) { + for (var entry : map.entrySet()) { + if (Objects.equals(string, entry.getValue())) + return entry.getKey(); + } + throw new IllegalArgumentException("No mapping for " + string); + } +} diff --git a/qupath-extension-processing/src/main/java/qupath/imagej/gui/macro/NewImageJMacroRunner.java b/qupath-extension-processing/src/main/java/qupath/imagej/gui/macro/NewImageJMacroRunner.java index 297b984fa..b0c889de6 100644 --- a/qupath-extension-processing/src/main/java/qupath/imagej/gui/macro/NewImageJMacroRunner.java +++ b/qupath-extension-processing/src/main/java/qupath/imagej/gui/macro/NewImageJMacroRunner.java @@ -55,8 +55,8 @@ public class NewImageJMacroRunner { private static final Logger logger = LoggerFactory.getLogger(NewImageJMacroRunner.class); - private enum PathObjectType { - ANNOTATION, DETECTION, TILE + public enum PathObjectType { + NONE, ANNOTATION, DETECTION, TILE, CELL, TMA_CORE } private final MacroParameters params; @@ -415,6 +415,9 @@ private Function getObjectFunction(PathObjectType type) { case ANNOTATION -> PathObjects::createAnnotationObject; case DETECTION -> PathObjects::createDetectionObject; case TILE -> PathObjects::createTileObject; + case CELL -> r -> PathObjects.createCellObject(r, null); + case NONE -> null; + case TMA_CORE -> throw new IllegalArgumentException("TMA core is not a valid object type!"); }; } diff --git a/qupath-extension-processing/src/main/resources/qupath/imagej/gui/macro/macro-runner.css b/qupath-extension-processing/src/main/resources/qupath/imagej/gui/macro/macro-runner.css new file mode 100644 index 000000000..c7abe41e8 --- /dev/null +++ b/qupath-extension-processing/src/main/resources/qupath/imagej/gui/macro/macro-runner.css @@ -0,0 +1,7 @@ +.title { + -fx-font-weight: bold; +} + +.macro { + -fx-font-family: monospace; +} \ No newline at end of file diff --git a/qupath-extension-processing/src/main/resources/qupath/imagej/gui/macro/macro-runner.fxml b/qupath-extension-processing/src/main/resources/qupath/imagej/gui/macro/macro-runner.fxml new file mode 100644 index 000000000..044388a2c --- /dev/null +++ b/qupath-extension-processing/src/main/resources/qupath/imagej/gui/macro/macro-runner.fxml @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + +

    + + +