ImageJServer is very inefficient with large 2D images
## Bug report

**Describe the bug**
The performance of `ImageJServer` is much worse than it needs to be - to the extent that it can make some images impossible to work with.

This first appeared at https://forum.image.sc/t/qupath-out-of-memory-problem-when-creating-training-image/60985

I was unable to even open a ~3GB image on my computer (with total 16 GB RAM). It turned out that `ImageJServer` was actually requesting the pixels twice: which was extremely slow, and eventually terminated with an OOM.

This occurs because `ImageJServer` first opens the image as a virtual stack, then computes memory requirements/stack size. Based upon this, it may then open the same image as a regular `ImagePlus`. This should be unnecessary, and also temporarily doubles the memory requirements for 2D images because the original has not yet been closed.

When investigating this, three other issues were noticed:

1. The performance impact of using virtual stacks is *much* worse than I had expected, particularly for large 2D images that will frequently be cropped/duplicated. Extracting a region - even for a 2D plane - causes the data to be read from disk again.
2. [`readBufferedImage(request)`](https://github.com/qupath/qupath/blob/75ec9cebe5e3bc5843fc60b07b455ce1215e1fb9/qupath-core-processing/src/main/java/qupath/imagej/images/servers/ImageJServer.java#L298) is synchronized - but the entire method does not need to be. This prevents calls to resize the extracted image being parallelized, even though this should be safe.
3. Resizing is called once per channel, including for RGB images. However, in ImageJ's world the RGB image is treated as a single channel - and so any resizing is actually performed 3x rather than 1x. The last two operations seem to be entirely unnecessary.

**To Reproduce**
The problem should occur when trying to open any large TIFF that has been written by ImageJ. If it is at the bounds of the available memory, this is likely to fail. If enough memory is available, it should succeed but perform badly.

**Expected behavior**
`ImageJServer` should fully read 2D images - since using a virtual stack brings no benefits - and most multidimensional images, as long as they are small enough to fit comfortably in memory.

Synchronization should be reduced, and resizing limited.

It will sometimes still not be possible to open a non-pyramidal image via ImageJ directly. In this case, running `convert-ome` should be able to efficiently generate an OME-TIFF image.

**Desktop (please complete the following information):**
 - OS: All
 - QuPath Version 0.3.0 (and earlier)

**Additional context**
It may be preferable to extend `AbstractTileableImageServer` rather than `AbstractImageServer` to take advantage of the tile caching in the former.

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 51ab6eb2..8e287296 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,7 +5,9 @@ This is a *minor release* that aims to be fully compatible with v0.3.0 while fix
 
 List of bugs fixed:
 * 'Add intensity features' does not reinitialize options (including channels) when new images are opened (https://github.com/qupath/qupath/issues/836)
+* Reading images with ImageJ is too slow and memory-hungry (https://github.com/qupath/qupath/issues/860)
 * 'Keep settings' in Brightness/Contrast dialog does not always retain channel colors (https://github.com/qupath/qupath/issues/843)
+* ImageServers can request the same tile in multiple threads simultaneously (https://github.com/qupath/qupath/issues/861)
 * Up arrow can cause viewer to move beyond nSlices for Z-stack (https://github.com/qupath/qupath/issues/821)
 * Location text does not update when navigating with keyboard (https://github.com/qupath/qupath/issues/819)
 * Multichannel .tif output is broken in TileExporter (https://github.com/qupath/qupath/issues/838)
@@ -15,6 +17,7 @@ List of bugs fixed:
 * 'Automate -> Show workflow command history' displays empty workflow (https://github.com/qupath/qupath/pull/851)
 * Extensions are sometimes loaded too late when running command line scripts (https://github.com/qupath/qupath/issues/852)
 * ICC Profiles could not be set in the viewer (unused preview feature, https://github.com/qupath/qupath/pull/850)
+* convert-ome command doesn't report when it is finished (https://github.com/qupath/qupath/issues/859)
 
 ### Dependency updates
 * JavaFX 17.0.1
diff --git a/qupath-app/src/main/java/qupath/QuPath.java b/qupath-app/src/main/java/qupath/QuPath.java
index 9872ef40..e344f7cc 100644
--- a/qupath-app/src/main/java/qupath/QuPath.java
+++ b/qupath-app/src/main/java/qupath/QuPath.java
@@ -388,7 +388,7 @@ class ScriptCommand implements Runnable {
 			percentage = 10;
 		} else if (percentage > 90) {
 			logger.warn("No more than 90% of available memory can be used for tile caching (you requested {}%)", percentage);
-			percentage = 00;			
+			percentage = 90;			
 		}
 		long tileCacheSize = Math.round(maxAvailable * (percentage / 100.0));
 		logger.info(String.format("Setting tile cache size to %.2f MB (%.1f%% max memory)", tileCacheSize/(1024.*1024.), percentage));
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 de55bc57..93d92683 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
@@ -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 - 2021 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,7 +33,6 @@ import java.awt.image.DataBufferFloat;
 import java.awt.image.DataBufferUShort;
 import java.awt.image.Raster;
 import java.awt.image.SampleModel;
-import java.io.File;
 import java.io.IOException;
 import java.net.URI;
 import java.util.ArrayList;
@@ -52,6 +51,7 @@ import ij.IJ;
 import ij.ImagePlus;
 import ij.ImageStack;
 import ij.gui.Roi;
+import ij.io.Opener;
 import ij.measure.Calibration;
 import ij.plugin.Duplicator;
 import ij.plugin.ImageInfo;
@@ -89,7 +89,7 @@ public class ImageJServer extends AbstractImageServer<BufferedImage> implements
 	
 	private URI uri;
 	private String[] args;
-	
+		
 	private ImagePlus imp;
 		
 	private ColorModel colorModel;
@@ -103,33 +103,42 @@ public class ImageJServer extends AbstractImageServer<BufferedImage> implements
 	public ImageJServer(final URI uri, final String...args) throws IOException {
 		super(BufferedImage.class);
 		this.uri = uri;
-		File file = GeneralTools.toPath(uri).toFile();
-		String path = file.getAbsolutePath();
-		if (path.toLowerCase().endsWith(".tif") || path.toLowerCase().endsWith(".tiff")) {
-			imp = IJ.openVirtual(path);
-			// We only want a virtual stack if we have a large z-stack or time series
-			long bpp = imp.getBitDepth() / 8;
-			if (bpp == 3)
-				bpp = 4; // ImageJ uses 4 bytes for an RGB image, but reports 24-bit
-			long nBytes = (long)imp.getWidth() * imp.getHeight() * imp.getStackSize() * bpp;
-			long maxMemory = Runtime.getRuntime().maxMemory();
-			long allowedMemory;
-			if (maxMemory == Long.MAX_VALUE)
-				allowedMemory = 1024L * 1024L * 1024L;
-			else
-				allowedMemory = maxMemory / 8;
-			if ((imp.getNFrames() == 1 && imp.getNSlices() == 1) || nBytes < allowedMemory) {
-				logger.debug("Opening {} fully, estimated {} MB (max memory {} MB)", uri, nBytes / (1024L * 1024L), maxMemory / (1024L * 1024L));
-				imp = IJ.openImage(path);
-			} else {
-				logger.debug("Opening {} as virtual stack, estimated {} MB (max memory {} MB)", uri, nBytes / (1024L * 1024L), maxMemory / (1024L * 1024L));
+		var filePath = GeneralTools.toPath(uri);
+		var file = filePath != null ? filePath.toFile() : null;
+		String path = file == null ? uri.toString() : file.getAbsolutePath();
+		
+		// Open as a virtual stack if we have 1) a TIFF, with 2) multiple slices and 3) a large file size -
+		// otherwise try to open directly (which is much faster if memory permits)
+		long maxMemory = Runtime.getRuntime().maxMemory();
+		if (file != null && path.toLowerCase().endsWith(".tif") || path.toLowerCase().endsWith(".tiff")) {
+			// Because ImageJ only supports uncompressed TIFFs, we simply use the file size
+			long fileLength = file == null ? Long.MAX_VALUE : file.length();
+			long maxFileLength = Math.max(1024*1024*10, maxMemory / 8);
+			if (fileLength > maxFileLength) {
+				var info = Opener.getTiffFileInfo(path);
+				if (info != null && info.length > 1) {
+					logger.debug("Opening {} as virtual stack", uri);
+					imp = IJ.openVirtual(path);
+				}
 			}
 		}
-		if (imp == null)
+		if (imp == null) {
+			logger.debug("Opening {} as ImagePlus", uri);
 			imp = IJ.openImage(path);
+		}
 		if (imp == null)
 			throw new IOException("Could not open " + path + " with ImageJ");
 		
+		// Log a warning if the image is very large
+		double sizeBytes = imp.getSizeInBytes();
+		if (!imp.getStack().isVirtual() && sizeBytes > maxMemory / 16) {
+			logger.warn("The image is very large relative to the available memory ({} MB / {} MB, {} %)",
+					GeneralTools.formatNumber(sizeBytes / (1024.0 * 1024.0), 1),
+					GeneralTools.formatNumber(maxMemory / (1024.0 * 1024.0), 1),
+					GeneralTools.formatNumber(sizeBytes / maxMemory * 100.0, 1));
+			logger.warn("Consider saving the image in a pyramidal format, e.g. using 'QuPath convert-ome' from the command line to create a pyramidal OME-TIFF.");
+		}
+		
 		Calibration cal = imp.getCalibration();
 		double xMicrons = IJTools.tryToParseMicrons(cal.pixelWidth, cal.getXUnit());
 		double yMicrons = IJTools.tryToParseMicrons(cal.pixelHeight, cal.getYUnit());
@@ -295,63 +304,71 @@ public class ImageJServer extends AbstractImageServer<BufferedImage> implements
 	}
 	
 	@Override
-	public synchronized BufferedImage readBufferedImage(RegionRequest request) {
-		// Deal with any cropping
-//		ImagePlus imp2 = this.imp;
+	public BufferedImage readBufferedImage(RegionRequest request) {
+		
+//		long startTime = System.nanoTime();
 		
 		int z = request.getZ()+1;
 		int t = request.getT()+1;
 		int nChannels = nChannels();
 		
-//		// There would be a possibility to intercept these calls and perform a z-projection...
-//		if (nZSlices() > 1) {
-//			imp.setT(t);
-//			ZProjector zProjector = new ZProjector(imp);
-//			zProjector.setMethod(ZProjector.MAX_METHOD);
-//			zProjector.setStartSlice(1);
-//			zProjector.setStopSlice(nZSlices());
-//			zProjector.doHyperStackProjection(false);
-//			imp = zProjector.getProjection();
-//			z = 1;
-//			t = 1;
-//		}
+		// In ImageJ's world, RGB effectively should be treated as 1 channel
+		if (imp.getType() == ImagePlus.COLOR_RGB)
+			nChannels = 1;
+				
+		double downsample = request.getDownsample();
+		int w = (int)Math.max(1, Math.round(imp.getWidth() / downsample));
+		int h = (int)Math.max(1, Math.round(imp.getHeight() / downsample));
 		
 		ImagePlus imp2;
+		Rectangle roi = null;
 		if (!(request.getX() == 0 && request.getY() == 0 && request.getWidth() == this.imp.getWidth() && request.getHeight() == this.imp.getHeight())) {
-			// Synchronization introduced because of concurrency issues around here!
-			this.imp.setRoi(request.getX(), request.getY(), request.getWidth(), request.getHeight());
-			// Crop for required z and time
-			Duplicator duplicator = new Duplicator();
-			imp2 = duplicator.run(this.imp, 1, nChannels, z, z, t, t);
-			this.imp.killRoi();
+			roi = new Rectangle(request.getX(), request.getY(), request.getWidth(), request.getHeight());
+			// Synchronization introduced because of concurrency issues when cropping!
+			synchronized (imp) {
+				if (nChannels == 1) {
+					int ind = imp.getStackIndex(1, z, t);
+					var ip = imp.getStack().getProcessor(ind);
+					ip.setRoi(roi);
+					ip = ip.crop();
+					imp2 = imp.createImagePlus();
+					imp2.setProcessor(ip);
+					ip.resetRoi();
+				} else {
+					this.imp.setRoi(roi);
+					// Crop for required z and time
+					Duplicator duplicator = new Duplicator();
+					imp2 = duplicator.run(this.imp, 1, nChannels, z, z, t, t);
+					this.imp.killRoi();
+				}
+			}
 			if (imp2.getHeight() != request.getHeight()||
 					imp2.getWidth() != request.getWidth())
 				logger.warn("Unexpected image size {}x{} for request {}", imp.getWidth(), imp.getHeight(), request);
 			z = 1;
 			t = 1;
-//			imp = imp.duplicate();
 			imp2.killRoi();
 		} else
 			imp2 = this.imp;
 		
 		// Deal with any downsampling
-		if (request.getDownsample() != 1) {
-			ImageStack stackNew = null;
-			Rectangle roi = imp2.getProcessor().getRoi();
-			int w = (int)Math.max(1, Math.round(imp.getWidth() / request.getDownsample()));
-			int h = (int)Math.max(1, Math.round(imp.getHeight() / request.getDownsample()));
+		if (downsample != 1) {
 			if (roi != null) {
-				w = (int)Math.max(1, Math.round(roi.getWidth() / request.getDownsample()));
-				h = (int)Math.max(1, Math.round(roi.getHeight() / request.getDownsample()));
+				w = (int)Math.max(1, Math.round(roi.getWidth() / downsample));
+				h = (int)Math.max(1, Math.round(roi.getHeight() / downsample));
 			}
-			for (int i = 1; i <= nChannels; i++) {
-				int ind = imp2.getStackIndex(i, z, t);
-				ImageProcessor ip = imp2.getStack().getProcessor(ind);
-				ip.setInterpolationMethod(ImageProcessor.BILINEAR);
-				ip = ip.resize(w, h, true);
-				if (stackNew == null)
-					stackNew = new ImageStack(ip.getWidth(), ip.getHeight());
-				stackNew.addSlice("Channel " + i, ip);
+			ImageStack stackNew = null;
+			// We synchronize on imp2 because it might be the same as imp - and 'resize' respects any crop ROI
+			synchronized (imp2) {
+				for (int i = 1; i <= nChannels; i++) {
+					int ind = imp2.getStackIndex(i, z, t);
+					ImageProcessor ip = imp2.getStack().getProcessor(ind);
+					ip.setInterpolationMethod(ImageProcessor.BILINEAR);
+					ip = ip.resize(w, h, true);
+					if (stackNew == null)
+						stackNew = new ImageStack(ip.getWidth(), ip.getHeight());
+					stackNew.addSlice("Channel " + i, ip);
+				}
 			}
 			imp2 = new ImagePlus(imp2.getTitle(), stackNew);
 			imp2.setDimensions(nChannels, 1, 1);
@@ -361,9 +378,21 @@ public class ImageJServer extends AbstractImageServer<BufferedImage> implements
 		}
 
 		// If we don't have a color model yet, reuse this one
-		var img = convertToBufferedImage(imp2, z, t, colorModel);
+		BufferedImage img;
+		synchronized (imp2) {
+			img = convertToBufferedImage(imp2, z, t, colorModel);
+		}
+		if (imp != imp2) {
+			imp2.changes = false;
+			imp2.close();
+		}
+		
 		if (colorModel == null)
 			colorModel = img.getColorModel();
+		
+//		long endTime = System.nanoTime();
+//		System.err.println("Duration: " + GeneralTools.formatNumber((endTime - startTime)/1000000.0, 1));
+
 		return img;
 	}
 	
@@ -470,4 +499,14 @@ public class ImageJServer extends AbstractImageServer<BufferedImage> implements
 				args);
 	}
 	
+	@Override
+	public void close() throws Exception {
+		super.close();
+		if (imp != null) {
+			imp.changes = false;
+			imp.close();
+			imp = null;
+		}
+	}
+	
 }
\ No newline at end of file
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 a824d652..3a425fb2 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
@@ -2,7 +2,7 @@
  * #%L
  * This file is part of QuPath.
  * %%
- * Copyright (C) 2018 - 2020 QuPath developers, The University of Edinburgh
+ * Copyright (C) 2018 - 2021 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
@@ -34,6 +34,10 @@ import java.util.HashSet;
 import java.util.Hashtable;
 import java.util.Map;
 import java.util.Set;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.FutureTask;
 
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -157,6 +161,30 @@ public abstract class AbstractTileableImageServer extends AbstractImageServer<Bu
 	protected abstract BufferedImage readTile(final TileRequest tileRequest) throws IOException;
 	
 	
+	/**
+	 * Map of tiles currently being requested, so avoid duplicate requests (wait instead for the first request to return).
+	 */
+	private Map<TileRequest, TileTask> pendingTiles = new ConcurrentHashMap<>();
+	
+	/**
+	 * Count of how many duplicate requests are received for a pending tile.
+	 * QuPath *should* strive to minimize these.
+	 */
+	private int duplicateRequestClashCount = 0;
+	
+	private static class TileTask extends FutureTask<BufferedImage> {
+		
+		private Thread thread;
+
+		public TileTask(Thread thread, Callable<BufferedImage> callable) {
+			super(callable);
+			this.thread = thread;
+		}
+		
+		
+		
+	}
+		
 	/**
 	 * Get a tile for the request - ideally from the cache, but otherwise read it and 
 	 * then add it to the cache.
@@ -181,19 +209,40 @@ public abstract class AbstractTileableImageServer extends AbstractImageServer<Bu
 		}
 		logger.trace("Reading tile: {}", request);
 		
-		var imgCached = readTile(tileRequest);
+		BufferedImage imgCached;
+		var futureTask = pendingTiles.computeIfAbsent(tileRequest, t -> new TileTask(Thread.currentThread(), () -> readTile(t)));
+		var myTask = futureTask.thread == Thread.currentThread();
+		try {
+			if (myTask)
+				futureTask.run();
+			else {
+				duplicateRequestClashCount++;
+				logger.debug("Duplicate request for a pending tile ({} total) - {}", duplicateRequestClashCount, tileRequest.getRegionRequest());
+			}
+			imgCached = futureTask.get();
+		} catch (ExecutionException | InterruptedException e) {
+			if (e.getCause() instanceof IOException)
+				throw (IOException)e.getCause();
+			throw new IOException(e);
+		}
+		
+//		var imgCached = readTile(tileRequest);
 		
 		// Put the tile in the appropriate cache
-		if (imgCached != null) {
-			if (isEmptyTile(imgCached)) {
-				emptyTiles.add(tileRequest);
-			} else if (cache != null) {
-				cache.put(request, imgCached);
-				// Check if we were able to cache the tile; sometimes we can't if it is too big
-				if (!cache.containsKey(request) && failedCacheTiles.add(request))
-					logger.warn("Unable to add {} to cache.\nYou might need to give QuPath more memory, or to increase the 'Percentage memory for tile caching' preference.", request);
+		if (myTask) {
+			if (imgCached != null) {
+				if (isEmptyTile(imgCached)) {
+					emptyTiles.add(tileRequest);
+				} else if (cache != null) {
+					cache.put(request, imgCached);
+					// Check if we were able to cache the tile; sometimes we can't if it is too big
+					if (!cache.containsKey(request) && failedCacheTiles.add(request))
+						logger.warn("Unable to add {} to cache.\nYou might need to give QuPath more memory, or to increase the 'Percentage memory for tile caching' preference.", request);
+				}
 			}
+			pendingTiles.remove(tileRequest);
 		}
+		
 		return imgCached;
 	}
 	
@@ -242,6 +291,9 @@ public abstract class AbstractTileableImageServer extends AbstractImageServer<Bu
 			}
 		}
 		
+		// Ensure all tiles are either cached or pending before we continue
+		prerequestTiles(tiles);
+		
 		long startTime = System.currentTimeMillis();
 		// Handle the general case for RGB
 		int width = (int)Math.max(1, Math.round(request.getWidth() / request.getDownsample()));
@@ -395,6 +447,25 @@ public abstract class AbstractTileableImageServer extends AbstractImageServer<Bu
 		}
 	}
 	
+	/**
+	 * Ensure all tiles in a list are either cached or requested.
+	 * If a tile is neither, then a blocking request is made so that the tile will be present later.
+	 * The purpose of this is to avoid sequentially requesting the same tiles from multiple threads,
+	 * which could cause all threads to block waiting on the same tile - rather than trying to 
+	 * get the next one.
+	 * @param tiles
+	 */
+	private void prerequestTiles(Collection<TileRequest> tiles) {
+		var cache = getCache();
+		for (var tile : tiles) {
+			if (cache == null || !cache.containsKey(tile.getRegionRequest()) && !pendingTiles.containsKey(tile)) {
+				var futureTask = pendingTiles.computeIfAbsent(tile, t -> new TileTask(Thread.currentThread(), () -> readTile(t)));
+				if (futureTask.thread == Thread.currentThread())
+					futureTask.run();
+			}
+		}
+	}
+	
 	/**
 	 * This method essentially wraps a call to {@link WritableRaster#setRect(int, int, Raster)}, while working 
 	 * around an inconvenient JDK bug: https://bugs.openjdk.java.net/browse/JDK-4847156
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 441de221..c80e9290 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
@@ -78,6 +78,8 @@ import qupath.lib.regions.ImageRegion;
  */
 public class ImageServers {
 	
+	private final static Logger logger = LoggerFactory.getLogger(ImageServers.class);
+	
 	private static SubTypeAdapterFactory<ServerBuilder> serverBuilderFactory = 
 			GsonTools.createSubTypeAdapterFactory(ServerBuilder.class, "builderType")
 			.registerSubtype(DefaultImageServerBuilder.class, "uri")
@@ -282,7 +284,11 @@ public class ImageServers {
 		List<Exception> exceptions = new ArrayList<>();
 		for (var support : supports) {
 			try {
-				return support.getBuilders().iterator().next().build();
+				var builders = support.getBuilders();
+				if (!builders.isEmpty())
+					return builders.get(0).build();
+				else
+					logger.debug("No builders available for {}", support);
 			} catch (Exception e) {
 				exceptions.add(e);
 			}
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 d7122557..9f3f9948 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
@@ -35,7 +35,10 @@ import picocli.CommandLine.Option;
 import picocli.CommandLine.Parameters;
 import qupath.lib.common.GeneralTools;
 import qupath.lib.gui.extensions.Subcommand;
+import qupath.lib.gui.images.stores.ImageRegionStoreFactory;
+import qupath.lib.gui.prefs.PathPrefs;
 import qupath.lib.images.servers.ImageServer;
+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;
@@ -107,6 +110,9 @@ public class ConvertCommand implements Runnable, Subcommand {
 	
 	@Override
 	public void run() {
+		
+		long startTime = System.currentTimeMillis();
+		
 		try {
 			if (inputFile == null || outputFile == null)
 				throw new IOException("Incorrect given path(s)");
@@ -133,6 +139,8 @@ public class ConvertCommand implements Runnable, Subcommand {
 		else
 			args = new String[0];
 		
+		createTileCache();
+		
 		try (ImageServer<BufferedImage> server = ImageServers.buildServer(inputFile.toURI(), args)) {
 			
 			// Get compression from user (or CompressionType.DEFAULT)
@@ -144,11 +152,13 @@ public class ConvertCommand implements Runnable, Subcommand {
 				logger.error("Chosen compression " + compressionType.toString() + " is not compatible with the input image.");
 			}
 			
-			// Check if output will be a single tile
-			boolean singleTile = server.getTileRequestManager().getTileRequests(RegionRequest.createInstance(server)).size() == 1;
-			
-			if (singleTile)
-				parallelize = false;
+			// 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;
@@ -228,11 +238,14 @@ public class ConvertCommand implements Runnable, Subcommand {
 					return;
 				}
 			}
-						
+			
 			builder.build().writePyramid(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());
+			logger.error(e.getLocalizedMessage(), e);
 		}
 	}
 	
@@ -268,4 +281,30 @@ public class ConvertCommand implements Runnable, Subcommand {
 //			return CompressionType.DEFAULT;
 //		}
 //	}
+	
+	
+	/**
+	 * The tile cache is usually set when initializing the GUI; here, we need to create one for performance
+	 */
+	private void createTileCache() {
+		// TODO: Refactor this to avoid replicating logic from QuPathGUI private method
+		Runtime rt = Runtime.getRuntime();
+		long maxAvailable = rt.maxMemory(); // Max available memory
+		if (maxAvailable == Long.MAX_VALUE) {
+			logger.warn("No inherent maximum memory set - for caching purposes, will assume 64 GB");
+			maxAvailable = 64L * 1024L * 1024L * 1024L;
+		}
+		double percentage = PathPrefs.tileCachePercentageProperty().get();
+		if (percentage < 10) {
+			percentage = 10;
+		} else if (percentage > 90) {
+			percentage = 90;			
+		}
+		long tileCacheSize = Math.round(maxAvailable * (percentage / 100.0));
+		logger.info(String.format("Setting tile cache size to %.2f MB (%.1f%% max memory)", tileCacheSize/(1024.*1024.), percentage));
+		
+		var imageRegionStore = ImageRegionStoreFactory.createImageRegionStore(tileCacheSize);
+		ImageServerProvider.setCache(imageRegionStore.getCache(), BufferedImage.class);
+	}
+	
 }
\ No newline at end of file
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 bb2f5227..27c7610c 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
@@ -4398,7 +4398,7 @@ public class QuPathGUI {
 			percentage = 10;
 		} else if (percentage > 90) {
 			logger.warn("No more than 90% of available memory can be used for tile caching (you requested {}%)", percentage);
-			percentage = 00;			
+			percentage = 90;			
 		}
 		long tileCacheSize = Math.round(maxAvailable * (percentage / 100.0));
 		logger.info(String.format("Setting tile cache size to %.2f MB (%.1f%% max memory)", tileCacheSize/(1024.*1024.), percentage));
['qupath-extension-bioformats/src/main/java/qupath/lib/images/writers/ome/ConvertCommand.java', 'qupath-gui-fx/src/main/java/qupath/lib/gui/QuPathGUI.java', 'CHANGELOG.md', 'qupath-core/src/main/java/qupath/lib/images/servers/AbstractTileableImageServer.java', 'qupath-core-processing/src/main/java/qupath/imagej/images/servers/ImageJServer.java', 'qupath-core/src/main/java/qupath/lib/images/servers/ImageServers.java', 'qupath-app/src/main/java/qupath/QuPath.java']