Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Welcome to the stitching extension for [QuPath](http://qupath.github.io)!

This extension adds support for combining TIFF images based on their "XResolution", "XPosition", "YResolution", "YPosition", "ImageWidth", and "ImageLength" tags.
This extension adds support for combining TIFF images based on their names and "XResolution", "XPosition", "YResolution", "YPosition", "ImageWidth", and "ImageLength" tags.
Comment thread
Rylern marked this conversation as resolved.
Outdated

The extension is intended for QuPath v0.6 and later.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This PR means it is compatible only with v0.7.0.
Is it worth delaying the breaking change, so that we can have a v0.6.0-compatible release?

@Rylern Rylern Aug 21, 2025

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This PR is actually v0.6.0 compatible, but two previous PR weren't: the TIFF writer PR, and the use of the new GeneralTools.moveToTrash()PR.

It is not compatible with earlier QuPath versions.
Expand Down
7 changes: 7 additions & 0 deletions sample-scripts/stitch_images_to_tiff.groovy
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import qupath.ext.stitching.core.ImageStitcher
import qupath.ext.stitching.core.positionfinders.*
Comment thread
Rylern marked this conversation as resolved.
Outdated

/*
* This script will stitch input images and write the result to an output OME-TIFF image.
Expand All @@ -10,8 +11,14 @@ var inputImages = [
// other images...
]
var outputImage = "/path/to/the/output/image.ome.tiff" // the path must ends with ".ome.tiff" and must not already exist
var positionFinder = new TiffTagPositionFinder() // where to find each tile position. TiffTagPositionFinder looks at the TIFF tags of the image. Can also be new PathPositionFinder() to look at the image name
var numberOfThreads = Runtime.getRuntime().availableProcessors() // the number of threads to use when reading and writing files
var pyramidalize = true // whether to create a pyramidal image

new ImageStitcher.Builder(inputImages)
.positionFinder(positionFinder)
.numberOfThreads(numberOfThreads)
.pyramidalize(pyramidalize)
.build()
.writeToTiffFile(outputImage)

Expand Down
9 changes: 8 additions & 1 deletion sample-scripts/stitch_images_to_zarr.groovy
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import qupath.ext.stitching.core.ImageStitcher
import qupath.ext.stitching.core.positionfinders.*

/*
* This script will stitch input images and write the result to an output OME-Zarr image.
Expand All @@ -9,9 +10,15 @@ var inputImages = [
"/path/to/the/input/image2.tiff",
// other images...
]
var outputImage = "/path/to/the/output/image.ome.zarr" // the path must ends with ".ome.zarr" and must not already exist
var outputImage = "/path/to/the/output/image.ome.zarr" // the path must ends with ".ome.zarr" and must not already exist
var positionFinder = new TiffTagPositionFinder() // where to find each tile position. TiffTagPositionFinder looks at the TIFF tags of the image. Can also be new PathPositionFinder() to look at the image name
var numberOfThreads = Runtime.getRuntime().availableProcessors() // the number of threads to use when reading and writing files
var pyramidalize = true // whether to create a pyramidal image

new ImageStitcher.Builder(inputImages)
.positionFinder(positionFinder)
.numberOfThreads(numberOfThreads)
.pyramidalize(pyramidalize)
.build()
.writeToZarrFile(outputImage, null)

Expand Down
2 changes: 1 addition & 1 deletion settings.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ pluginManagement {
}

qupath {
version = "0.6.0"
version = "0.7.0-SNAPSHOT"
}

// Apply QuPath Gradle settings plugin to handle configuration
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ public void Benchmark_Number_Of_Threads_Of_Image_Stitcher_Creation() throws IOEx
String outputPath = Files.createTempDirectory(imagesDirectory, null).resolve("image.ome.zarr").toString();

new ImageStitcher.Builder(imagePaths)
.setNumberOfThreads(Math.max(
.numberOfThreads(Math.max(
(int) (Runtime.getRuntime().availableProcessors() * fractionOfAvailableCores),
1
))
Expand Down
54 changes: 42 additions & 12 deletions src/main/java/qupath/ext/stitching/core/ImageStitcher.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import qupath.ext.stitching.core.positionfinders.PositionFinder;
import qupath.ext.stitching.core.positionfinders.TiffTagPositionFinder;
import qupath.lib.common.ThreadTools;
import qupath.lib.images.servers.ImageServer;
import qupath.lib.images.servers.ImageServerBuilder;
Expand All @@ -16,6 +18,7 @@
import java.io.IOException;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
Expand All @@ -24,9 +27,10 @@
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Consumer;
import java.util.stream.IntStream;

/**
* A class to stitch TIFF images based on tags described in {@link TiffRegionParser#parseRegion(String,int,int)}.
* A class to stitch TIFF images.
* <p>
* Use a {@link Builder} to create an instance of this class.
*/
Expand Down Expand Up @@ -54,6 +58,9 @@ private ImageStitcher(Builder builder) throws InterruptedException, IOException
for (String imagePath: builder.imagePaths) {
executorService.execute(() -> {
try {
logger.debug("Checking if {} is a TIFF file", imagePath);
TiffFileChecker.checkTiffFile(imagePath);

logger.debug("Parsing {}...", imagePath);
ImageServerBuilder.UriImageSupport<BufferedImage> imageSupport = ImageServerProvider.getPreferredUriImageSupport(BufferedImage.class, imagePath);
if (imageSupport == null || imageSupport.getBuilders().isEmpty()) {
Expand All @@ -66,11 +73,19 @@ private ImageStitcher(Builder builder) throws InterruptedException, IOException
ImageServer<BufferedImage> server = serverBuilder.build();
logger.debug("Got server {} for {}", server, imagePath);

List<ImageRegion> regions = TiffRegionParser.parseRegion(
imagePath,
server.getMetadata().getSizeZ(),
server.getMetadata().getSizeT()
);
int[] position = builder.positionFinder.findPosition(server);
List<ImageRegion> regions = IntStream.range(0, server.getMetadata().getSizeZ())
.boxed()
.flatMap(z -> IntStream.range(0, server.getMetadata().getSizeT())
.mapToObj(t -> ImageRegion.createInstance(
position[0],
position[1],
server.getMetadata().getWidth(),
server.getMetadata().getHeight(),
z,
t
))
).toList();
logger.debug("Got regions {} for {}", regions, imagePath);

synchronized (this) {
Expand Down Expand Up @@ -193,6 +208,7 @@ public boolean areSomeInputImagesNotUsed() {
public static class Builder {

private final List<String> imagePaths;
private PositionFinder positionFinder = new TiffTagPositionFinder();
Comment thread
Rylern marked this conversation as resolved.
Outdated
private int numberOfThreads = Runtime.getRuntime().availableProcessors(); // this was determined by running the BenchmarkImageStitching
// benchmark on several machines and taking a good score that
// doesn't require a lot of RAM
Expand All @@ -203,9 +219,23 @@ public static class Builder {
* Create the builder.
*
* @param imagePaths paths of the TIFF files that should be combined
* @throws NullPointerException if the provided parameter is null
*/
public Builder(List<String> imagePaths) {
this.imagePaths = imagePaths;
this.imagePaths = Objects.requireNonNull(imagePaths);
}

/**
* Set the strategy to retrieve tile positions. Take a look at the {@link qupath.ext.stitching.core.positionfinders}
* package for existing implementations. {@link TiffTagPositionFinder} by default.
*
* @param positionFinder the strategy to retrieve tile positions
* @return this builder
* @throws NullPointerException if the provided parameter is null
*/
public Builder positionFinder(PositionFinder positionFinder) {
this.positionFinder = Objects.requireNonNull(positionFinder);
return this;
}

/**
Expand All @@ -214,7 +244,7 @@ public Builder(List<String> imagePaths) {
* @param numberOfThreads the number of threads to use. By default, this is equal to {@link Runtime#availableProcessors()}
* @return this builder
*/
public Builder setNumberOfThreads(int numberOfThreads) {
public Builder numberOfThreads(int numberOfThreads) {
this.numberOfThreads = numberOfThreads;
return this;
}
Expand All @@ -239,10 +269,10 @@ public Builder pyramidalize(boolean pyramidalize) {
* <p>
* This function may be called from any thread.
*
* @param onProgress a function that will be called at different steps when {@link #build()} is called
* @param onProgress a function that will be called at different steps when {@link #build()} is called. Can be null
* @return this builder
*/
public Builder setOnProgress(Consumer<Float> onProgress) {
public Builder onProgress(Consumer<Float> onProgress) {
this.onProgress = onProgress;
return this;
}
Expand All @@ -257,8 +287,8 @@ public Builder setOnProgress(Consumer<Float> onProgress) {
* @return this builder
* @throws IOException if an issue occurs while creating the output image
* @throws InterruptedException if this operation in interrupted
* @throws IllegalArgumentException if no image was given to {@link #Builder(List)}, or if this list doesn't
* contain any TIFF images that have the tags described in {@link TiffRegionParser#parseRegion(String,int,int)}
* @throws IllegalArgumentException if no image was given to {@link #Builder(List)}, or if it wasn't possible to
* retrieve any position from the list
*/
public ImageStitcher build() throws IOException, InterruptedException {
return new ImageStitcher(this);
Expand Down
45 changes: 45 additions & 0 deletions src/main/java/qupath/ext/stitching/core/TiffFileChecker.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package qupath.ext.stitching.core;

import java.io.FileInputStream;
import java.io.IOException;
import java.util.Arrays;
import java.util.Objects;

/**
* A class that checks whether a file represents a TIFF file. This is done by looking at the first four bytes of the
* provided file, which must match: [0x49, 0x49, 0x2A, 0x00] or [0x4D, 0x4D, 0x00, 0x2A].
*/
public class TiffFileChecker {

private TiffFileChecker() {
throw new AssertionError("This class is not instantiable");
}

/**
* Check if the provided path points to a TIFF file. Take a look at the class documentation for more information.
*
* @param path the path pointing to the file to test
* @throws IOException if the path doesn't point to an existing file or if the file cannot be read
* @throws IllegalArgumentException if the provided file is not a TIFF file
* @throws NullPointerException if the provided path is null
*/
public static void checkTiffFile(String path) throws IOException {
byte[] bytes;
try (FileInputStream inputStream = new FileInputStream(Objects.requireNonNull(path))) {
bytes = inputStream.readNBytes(4);
}

if (bytes.length < 4) {
throw new IllegalArgumentException(String.format("%s contains less than 4 bytes, so it cannot be a TIFF file", path));
}

if (!((bytes[0] == 0x49 && bytes[1] == 0x49 && bytes[2] == 0x2A && bytes[3] == 0x00) ||
(bytes[0] == 0x4D && bytes[1] == 0x4D && bytes[2] == 0x00 && bytes[3] == 0x2A))) {
throw new IllegalArgumentException(String.format(
"The first four bytes of %s (%s) don't match the TIFF specifications",
path,
Arrays.toString(bytes)
));
}
}
}
115 changes: 0 additions & 115 deletions src/main/java/qupath/ext/stitching/core/TiffRegionParser.java

This file was deleted.

Loading