Skip to content

Commit

Permalink
Merge pull request #18 from qupath/increase-image-opening-speed
Browse files Browse the repository at this point in the history
Increase image opening speed
  • Loading branch information
Rylern authored Apr 11, 2024
2 parents d8c135d + dd04270 commit 2122b65
Show file tree
Hide file tree
Showing 31 changed files with 1,042 additions and 426 deletions.
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,3 +75,20 @@ gradlew clean build

The output will be under `build/libs`.
You can drag the jar file on top of QuPath to install the extension.

## Running tests

You can run the tests with

```bash
gradlew test
```

Some of the tests require having Docker installed and running.

By default, a new local OMERO server will be created each time this command is run. As it takes
a few minutes, you can instead create a local OMERO server by running the
`qupath-extension-omero/src/test/resources/server.sh` script and setting the
`OmeroServer.IS_LOCAL_OMERO_SERVER_RUNNING` variable to `true`
(`qupath-extension-omero/src/test/java/qupath/ext/omero/OmeroServer` file).
That way, unit tests will use the existing OMERO server instead of creating a new one.
3 changes: 2 additions & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ plugins {
}

ext.moduleName = 'qupath.extension.omero'
version = "0.1.0-rc4"
version = "0.1.0-rc5"
description = "QuPath extension to support image reading using OMERO APIs."
ext.qupathVersion = gradle.ext.qupathVersion
ext.qupathJavaVersion = 17
Expand All @@ -19,6 +19,7 @@ dependencies {
shadow "io.github.qupath:qupath-gui-fx:${qupathVersion}"
shadow "io.github.qupath:qupath-extension-bioformats:${qupathVersion}"
shadow libs.qupath.fxtras
shadow libs.guava

shadow libs.slf4j

Expand Down
172 changes: 172 additions & 0 deletions src/main/java/qupath/ext/omero/core/ObjectPool.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
package qupath.ext.omero.core;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.Optional;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.function.Consumer;
import java.util.function.Supplier;

/**
* <p>
* A pool of objects of fixed size. Objects are created on demand.
* </p>
* <p>
* Once no longer used, this pool must be {@link #close() closed}.
* </p>
* <p>
* This class is thread-safe.
* </p>
*
* @param <E> the type of object to store
*/
public class ObjectPool<E> implements AutoCloseable {

private static final Logger logger = LoggerFactory.getLogger(ObjectPool.class);
private final ExecutorService objectCreationService = Executors.newCachedThreadPool();
private final ArrayBlockingQueue<E> queue;
private final int queueSize;
private final Supplier<E> objectCreator;
private final Consumer<E> objectCloser;
private int numberOfObjectsCreated = 0;
private record ObjectWrapper<V>(Optional<V> object, boolean useThisObject) {}

/**
* Create the pool of objects. This will not create any object yet.
*
* @param size the capacity of the pool (greater than 0)
* @param objectCreator a function to create an object. It is allowed to return null
* @throws IllegalArgumentException when size is less than 1
*/
public ObjectPool(int size, Supplier<E> objectCreator) {
this(size, objectCreator, null);
}

/**
* Create the pool of objects. This will not create any object yet.
*
* @param size the capacity of the pool (greater than 0)
* @param objectCreator a function to create an object. It is allowed to return null
* @param objectCloser a function that will be called on each object of this pool when it is closed
* @throws IllegalArgumentException when size is less than 1
*/
public ObjectPool(int size, Supplier<E> objectCreator, Consumer<E> objectCloser) {
queue = new ArrayBlockingQueue<>(size);
this.queueSize = size;
this.objectCreator = objectCreator;
this.objectCloser = objectCloser;
}

/**
* <p>
* Close this pool. If some objects are being created, this function will wait
* for their creation to end.
* </p>
* <p>
* If defined, the objectCloser parameter of {@link #ObjectPool(int,Supplier,Consumer)} will be
* called on each object currently present in the pool, but not on objects taken from the pool
* and not given back yet.
* </p>
*
* @throws Exception when an error occurs while waiting for the object creation to end
*/
@Override
public void close() throws Exception {
objectCreationService.shutdown();
objectCreationService.awaitTermination(Long.MAX_VALUE, TimeUnit.NANOSECONDS);

if (objectCloser != null) {
queue.forEach(objectCloser);
}
}

/**
* <p>
* Attempt to retrieve an object from this pool.
* <ul>
* <li>
* If an object is available in the pool, it will be directly returned.
* </li>
* <li>
* If no object is available in the pool and the pool capacity allows to create a new
* object, a new object is created and returned. If for some reason the object creation
* fails (or return null), an empty Optional is returned.
* </li>
* <li>
* If no object is available in the pool and the pool capacity doesn't allow creating
* a new object, this function blocks until an object becomes available in the pool.
* </li>
* </ul>
* </p>
* <p>
* The caller of this function will have a full control on the returned object. As soon as the
* object is not used anymore, it must be given back to this pool using the {@link #giveBack(Object)}
* function.
* </p>
*
* @return an object from this pool, or an empty Optional if the creation failed
* @throws InterruptedException when creating an object or waiting for an object to become available is interrupted
* @throws ExecutionException when an error occurs while creating an object
*/
public Optional<E> get() throws InterruptedException, ExecutionException {
E object = queue.poll();

if (object == null) {
ObjectWrapper<E> objectWrapper = computeObjectIfPossible().get();

if (objectWrapper.useThisObject()) {
return objectWrapper.object();
} else {
return Optional.of(queue.take());
}
} else {
return Optional.of(object);
}
}

/**
* Give an object back to this pool. This function must be used once an object
* returned {@link #get()} is not used anymore.
*
* @param object the object to give back. Nothing will happen if the object is null
*/
public void giveBack(E object) {
if (object != null) {
queue.offer(object);
}
}

private synchronized CompletableFuture<ObjectWrapper<E>> computeObjectIfPossible() {
if (numberOfObjectsCreated < queueSize) {
numberOfObjectsCreated++;

return CompletableFuture.supplyAsync(
() -> {
E object = null;

try {
object = objectCreator.get();
} catch (Exception e) {
logger.error("Error when creating object in pool", e);
}

if (object == null) {
synchronized (this) {
numberOfObjectsCreated--;
}
}
return new ObjectWrapper<>(Optional.ofNullable(object), true);
},
objectCreationService
);
} else {
return CompletableFuture.completedFuture(new ObjectWrapper<>(Optional.empty(), false));
}
}
}
5 changes: 5 additions & 0 deletions src/main/java/qupath/ext/omero/core/WebClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,11 @@ public void close() throws Exception {
if (status.equals(Status.SUCCESS)) {
logger.info(String.format("Disconnected from the OMERO.web instance at %s", apisHandler.getWebServerURI()));
}
if (allPixelAPIs != null) {
for (PixelAPI pixelAPI: allPixelAPIs) {
pixelAPI.close();
}
}
if (apisHandler != null) {
apisHandler.close();
}
Expand Down
12 changes: 6 additions & 6 deletions src/main/java/qupath/ext/omero/core/WebUtilities.java
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Optional;
import java.util.OptionalInt;
import java.util.OptionalLong;
import java.util.concurrent.CompletableFuture;
import java.util.regex.Pattern;

Expand Down Expand Up @@ -75,17 +75,17 @@ public static Optional<URI> createURI(String url) {
* @param uri the URI that is supposed to contain the ID. It can be URL encoded
* @return the entity ID, or an empty Optional if it was not found
*/
public static OptionalInt parseEntityId(URI uri) {
public static OptionalLong parseEntityId(URI uri) {
for (Pattern pattern : allPatterns) {
var matcher = pattern.matcher(decodeURI(uri));

if (matcher.find()) {
try {
return OptionalInt.of(Integer.parseInt(matcher.group(1)));
return OptionalLong.of(Long.parseLong(matcher.group(1)));
} catch (Exception ignored) {}
}
}
return OptionalInt.empty();
return OptionalLong.empty();
}

/**
Expand Down Expand Up @@ -119,13 +119,13 @@ public static CompletableFuture<List<URI>> getImagesURIFromEntityURI(URI entityU
var datasetID = parseEntityId(entityURI);

if (datasetID.isPresent()) {
return apisHandler.getImagesURIOfDataset(datasetID.getAsInt());
return apisHandler.getImagesURIOfDataset(datasetID.getAsLong());
}
} else if (projectPattern.matcher(entityURL).find()) {
var projectID = parseEntityId(entityURI);

if (projectID.isPresent()) {
return apisHandler.getImagesURIOfProject(projectID.getAsInt());
return apisHandler.getImagesURIOfProject(projectID.getAsLong());
}
} else if (imagePatterns.stream().anyMatch(pattern -> pattern.matcher(entityURL).find())) {
return CompletableFuture.completedFuture(List.of(entityURI));
Expand Down
51 changes: 34 additions & 17 deletions src/main/java/qupath/ext/omero/core/apis/ApisHandler.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package qupath.ext.omero.core.apis;

import com.drew.lang.annotations.Nullable;
import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.ReadOnlyBooleanProperty;
import javafx.beans.property.ReadOnlyIntegerProperty;
Expand Down Expand Up @@ -48,6 +50,7 @@ public class ApisHandler implements AutoCloseable {

private static final Logger logger = LoggerFactory.getLogger(ApisHandler.class);
private static final int THUMBNAIL_SIZE = 256;
private static final int THUMBNAIL_CACHE_SIZE = 1000;
private static final Map<String, PixelType> PIXEL_TYPE_MAP = Map.of(
"uint8", PixelType.UINT8,
"int8", PixelType.INT8,
Expand All @@ -64,9 +67,12 @@ public class ApisHandler implements AutoCloseable {
private final WebGatewayApi webGatewayApi;
private final IViewerApi iViewerApi;
private final BooleanProperty areOrphanedImagesLoading = new SimpleBooleanProperty(false);
private final Map<Long, BufferedImage> thumbnails = new ConcurrentHashMap<>();
private final Map<Class<? extends RepositoryEntity>, BufferedImage> omeroIcons = new ConcurrentHashMap<>();
private final Cache<IdSizeWrapper, CompletableFuture<Optional<BufferedImage>>> thumbnailsCache = CacheBuilder.newBuilder()
.maximumSize(THUMBNAIL_CACHE_SIZE)
.build();
private final Map<Class<? extends RepositoryEntity>, BufferedImage> omeroIconsCache = new ConcurrentHashMap<>();
private final boolean canSkipAuthentication;
private record IdSizeWrapper(long id, int size) {}

private ApisHandler(URI host, JsonApi jsonApi, boolean canSkipAuthentication) {
this.host = host;
Expand Down Expand Up @@ -436,48 +442,49 @@ public CompletableFuture<Boolean> deleteAttachments(long entityId, Class<? exten
/**
* <p>Attempt to retrieve the icon of an OMERO entity.</p>
* <p>Icons for orphaned folders, projects, datasets, images, screens, plates, and plate acquisitions can be retrieved.</p>
* <p>Icons are cached.</p>
* <p>This function is asynchronous.</p>
*
* @param type the class of the entity whose icon is to be retrieved
* @return a CompletableFuture with the icon if the operation succeeded, or an empty Optional otherwise
*/
public CompletableFuture<Optional<BufferedImage>> getOmeroIcon(Class<? extends RepositoryEntity> type) {
if (omeroIcons.containsKey(type)) {
return CompletableFuture.completedFuture(Optional.of(omeroIcons.get(type)));
if (omeroIconsCache.containsKey(type)) {
return CompletableFuture.completedFuture(Optional.of(omeroIconsCache.get(type)));
} else {
if (type.equals(Project.class)) {
return webGatewayApi.getProjectIcon().thenApply(icon -> {
icon.ifPresent(bufferedImage -> omeroIcons.put(type, bufferedImage));
icon.ifPresent(bufferedImage -> omeroIconsCache.put(type, bufferedImage));
return icon;
});
} else if (type.equals(Dataset.class)) {
return webGatewayApi.getDatasetIcon().thenApply(icon -> {
icon.ifPresent(bufferedImage -> omeroIcons.put(type, bufferedImage));
icon.ifPresent(bufferedImage -> omeroIconsCache.put(type, bufferedImage));
return icon;
});
} else if (type.equals(OrphanedFolder.class)) {
return webGatewayApi.getOrphanedFolderIcon().thenApply(icon -> {
icon.ifPresent(bufferedImage -> omeroIcons.put(type, bufferedImage));
icon.ifPresent(bufferedImage -> omeroIconsCache.put(type, bufferedImage));
return icon;
});
} else if (type.equals(Image.class)) {
return webclientApi.getImageIcon().thenApply(icon -> {
icon.ifPresent(bufferedImage -> omeroIcons.put(type, bufferedImage));
icon.ifPresent(bufferedImage -> omeroIconsCache.put(type, bufferedImage));
return icon;
});
} else if (type.equals(Screen.class)) {
return webclientApi.getScreenIcon().thenApply(icon -> {
icon.ifPresent(bufferedImage -> omeroIcons.put(type, bufferedImage));
icon.ifPresent(bufferedImage -> omeroIconsCache.put(type, bufferedImage));
return icon;
});
} else if (type.equals(Plate.class)) {
return webclientApi.getPlateIcon().thenApply(icon -> {
icon.ifPresent(bufferedImage -> omeroIcons.put(type, bufferedImage));
icon.ifPresent(bufferedImage -> omeroIconsCache.put(type, bufferedImage));
return icon;
});
} else if (type.equals(PlateAcquisition.class)) {
return webclientApi.getPlateAcquisitionIcon().thenApply(icon -> {
icon.ifPresent(bufferedImage -> omeroIcons.put(type, bufferedImage));
icon.ifPresent(bufferedImage -> omeroIconsCache.put(type, bufferedImage));
return icon;
});
} else {
Expand All @@ -496,15 +503,25 @@ public CompletableFuture<Optional<BufferedImage>> getThumbnail(long id) {

/**
* See {@link WebGatewayApi#getThumbnail(long, int)}.
* Thumbnails are cached in a cache of size {@link #THUMBNAIL_CACHE_SIZE}.
*/
public CompletableFuture<Optional<BufferedImage>> getThumbnail(long id, int size) {
if (thumbnails.containsKey(id)) {
return CompletableFuture.completedFuture(Optional.of(thumbnails.get(id)));
} else {
return webGatewayApi.getThumbnail(id, size).thenApply(thumbnail -> {
thumbnail.ifPresent(bufferedImage -> thumbnails.put(id, bufferedImage));
return thumbnail;
try {
IdSizeWrapper key = new IdSizeWrapper(id, size);
CompletableFuture<Optional<BufferedImage>> request = thumbnailsCache.get(
key,
() -> webGatewayApi.getThumbnail(id, size)
);

request.thenAccept(response -> {
if (response.isEmpty()) {
thumbnailsCache.invalidate(key);
}
});
return request;
} catch (ExecutionException e) {
logger.error("Error when retrieving thumbnail", e);
return CompletableFuture.completedFuture(Optional.empty());
}
}

Expand Down
Loading

0 comments on commit 2122b65

Please sign in to comment.