Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Increase image opening speed #18

Merged
merged 17 commits into from
Apr 11, 2024
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
Loading