Skip to content

Instantly share code, notes, and snippets.

@petebankhead
Last active September 16, 2024 16:01
Show Gist options
  • Save petebankhead/b5a86caa333de1fdcff6bdee72a20abe to your computer and use it in GitHub Desktop.
Save petebankhead/b5a86caa333de1fdcff6bdee72a20abe to your computer and use it in GitHub Desktop.
QuPath script to merge multiple TIFF fields of view to write a single pyramidal OME-TIFF (requires QuPath v0.2.0)
/**
* Convert TIFF fields of view to a pyramidal OME-TIFF.
*
* Locations are parsed from the baseline TIFF tags, therefore these need to be set.
*
* One application of this script is to combine spectrally-unmixed images.
* Be sure to read the script and see where default settings could be changed, e.g.
* - Prompting the user to select files (or using the one currently open the viewer)
* - Using lossy or lossless compression
*
* Note: This version has been updated for QuPath v0.5.x.
* Check the 'Revisions' tab on GitHub to see previous versions.
*
* @author Pete Bankhead
*/
import qupath.fx.dialogs.FileChoosers
import qupath.lib.common.GeneralTools
import qupath.lib.images.servers.ImageServerProvider
import qupath.lib.images.servers.ImageServers
import qupath.lib.images.servers.SparseImageServer
import qupath.lib.images.writers.ome.OMEPyramidWriter
import qupath.lib.regions.ImageRegion
import javax.imageio.ImageIO
import javax.imageio.plugins.tiff.BaselineTIFFTagSet
import javax.imageio.plugins.tiff.TIFFDirectory
import java.awt.image.BufferedImage
import static qupath.lib.gui.scripting.QPEx.*
boolean promptForFiles = true
File dir
List<File> files
String baseName = 'Merged image'
if (promptForFiles) {
files = FileChoosers.promptForMultipleFiles("Choose input files",
FileChoosers.createExtensionFilter("TIFF files", ".tif", ".tiff"))
} else {
// Try to get the URI of the current image that is open
def currentFile = new File(getCurrentServer().getURIs()[0])
dir = currentFile.getParentFile()
// This naming scheme works for me...
String name = currentFile.getName()
int ind = name.indexOf("_[")
if (ind < 0)
ind = name.toLowerCase().lastIndexOf('.tif')
if (ind >= 0)
baseName = currentFile.getName().substring(0, ind)
// Get all the non-OME TIFF files in the same directory
files = dir.listFiles().findAll {
return it.isFile() &&
!it.getName().endsWith('.ome.tif') &&
(baseName == null || it.getName().startsWith(baseName))
}
}
if (!files) {
print 'No TIFF files selected'
return
}
File fileOutput
if (promptForFiles) {
fileOutput = FileChoosers.promptToSaveFile("Output file", null,
FileChoosers.createExtensionFilter("OME-TIFF", ".ome.tif"))
} else {
// Ensure we have a unique output name
fileOutput = new File(dir, baseName+'.ome.tif')
int count = 1
while (fileOutput.exists()) {
fileOutput = new File(dir, baseName+'-'+count+'.ome.tif')
}
}
if (fileOutput == null)
return
// Parse image regions & create a sparse server
print 'Parsing regions from ' + files.size() + ' files...'
def builder = new SparseImageServer.Builder()
files.parallelStream().forEach { f ->
def region = parseRegion(f)
if (region == null) {
print 'WARN: Could not parse region for ' + f
return
}
def serverBuilder = ImageServerProvider.getPreferredUriImageSupport(BufferedImage.class, f.toURI().toString()).getBuilders().get(0)
builder.jsonRegion(region, 1.0, serverBuilder)
}
print 'Building server...'
def server = builder.build()
server = ImageServers.pyramidalize(server)
long startTime = System.currentTimeMillis()
String pathOutput = fileOutput.getAbsolutePath()
new OMEPyramidWriter.Builder(server)
.downsamples(server.getPreferredDownsamples()) // Use pyramid levels calculated in the ImageServers.pyramidalize(server) method
.tileSize(512) // Requested tile size
.channelsInterleaved() // Because SparseImageServer returns all channels in a BufferedImage, it's more efficient to write them interleaved
.parallelize() // Attempt to parallelize requesting tiles (need to write sequentially)
.losslessCompression() // Use lossless compression (often best for fluorescence, by lossy compression may be ok for brightfield)
.build()
.writePyramid(pathOutput)
long endTime = System.currentTimeMillis()
print('Image written to ' + pathOutput + ' in ' + GeneralTools.formatNumber((endTime - startTime)/1000.0, 1) + ' s')
server.close()
static ImageRegion parseRegion(File file, int z = 0, int t = 0) {
if (checkTIFF(file)) {
try {
return parseRegionFromTIFF(file, z, t)
} catch (Exception e) {
print e.getLocalizedMessage()
}
}
}
/**
* Check for TIFF 'magic number'.
* @param file
* @return
*/
static boolean checkTIFF(File file) {
file.withInputStream {
def bytes = it.readNBytes(4)
short byteOrder = toShort(bytes[0], bytes[1])
int val
if (byteOrder == 0x4949) {
// Little-endian
val = toShort(bytes[3], bytes[2])
} else if (byteOrder == 0x4d4d) {
val = toShort(bytes[2], bytes[3])
} else
return false
return val == 42 || val == 43
}
}
/**
* Combine two bytes to create a short, in the given order
* @param b1
* @param b2
* @return
*/
static short toShort(byte b1, byte b2) {
return (b1 << 8) + (b2 << 0)
}
/**
* Parse an ImageRegion from a TIFF image, using the metadata.
* @param file image file
* @param z index of z plane
* @param t index of timepoint
* @return
*/
static ImageRegion parseRegionFromTIFF(File file, int z = 0, int t = 0) {
int x, y, width, height
file.withInputStream {
def reader = ImageIO.getImageReadersByFormatName("TIFF").next()
reader.setInput(ImageIO.createImageInputStream(it))
def metadata = reader.getImageMetadata(0)
def tiffDir = TIFFDirectory.createFromMetadata(metadata)
double xRes = getRational(tiffDir, BaselineTIFFTagSet.TAG_X_RESOLUTION)
double yRes = getRational(tiffDir, BaselineTIFFTagSet.TAG_Y_RESOLUTION)
double xPos = getRational(tiffDir, BaselineTIFFTagSet.TAG_X_POSITION)
double yPos = getRational(tiffDir, BaselineTIFFTagSet.TAG_Y_POSITION)
width = tiffDir.getTIFFField(BaselineTIFFTagSet.TAG_IMAGE_WIDTH).getAsLong(0) as int
height = tiffDir.getTIFFField(BaselineTIFFTagSet.TAG_IMAGE_LENGTH).getAsLong(0) as int
x = Math.round(xRes * xPos) as int
y = Math.round(yRes * yPos) as int
}
return ImageRegion.createInstance(x, y, width, height, z, t)
}
/**
* Helper for parsing rational from TIFF metadata.
* @param tiffDir
* @param tag
* @return
*/
static double getRational(TIFFDirectory tiffDir, int tag) {
long[] rational = tiffDir.getTIFFField(tag).getAsRational(0);
return rational[0] / (double)rational[1];
}
@NJ1989
Copy link

NJ1989 commented Sep 2, 2024

Hi @petebankhead ,When we are working with Qupath V0.5.0, it showed a method that doesn't exist as followed, I was wondering if there is anything wrong here.

"ERROR: It looks like you've tried to access a method that doesn't exist.

ERROR: No signature of method: static qupath.fx.dialogs.Dialogs.promptForMultipleFiles() is applicable for argument types: (String, null, String, String, String) values: [Choose input files, null, TIFF files, .tif, .tiff] in QuPathScript at line number 35
groovy.lang.MetaClassImpl.invokeStaticMissingMethod(MetaClassImpl.java:1642)
groovy.lang.MetaClassImpl.invokeStaticMethod(MetaClassImpl.java:1628)
org.codehaus.groovy.vmplugin.v8.IndyInterface.fromCache(IndyInterface.java:321)
QuPathScript.run(QuPathScript:35)
org.codehaus.groovy.jsr223.GroovyScriptEngineImpl.eval(GroovyScriptEngineImpl.java:331)
org.codehaus.groovy.jsr223.GroovyScriptEngineImpl.eval(GroovyScriptEngineImpl.java:161)
qupath.lib.gui.scripting.languages.DefaultScriptLanguage.execute(DefaultScriptLanguage.java:234)
qupath.lib.gui.scripting.DefaultScriptEditor.executeScript(DefaultScriptEditor.java:1166)
qupath.lib.gui.scripting.DefaultScriptEditor$3.run(DefaultScriptEditor.java:1534)
java.base/java.util.concurrent.Executors$RunnableAdapter.call(Unknown Source)
java.base/java.util.concurrent.FutureTask.run(Unknown Source)
java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(Unknown Source)
java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(Unknown Source)
java.base/java.lang.Thread.run(Unknown Source)"

@petebankhead
Copy link
Author

petebankhead commented Sep 2, 2024

@NJ1989 I've updated the script - but I have no images to test if it works. If you'd like to try it, please let me know the outcome.

(The relevant discussion on the forum is at https://forum.image.sc/t/merge-tiffs-via-script-via-command-line-arguments/90099 )

@NJ1989
Copy link

NJ1989 commented Sep 3, 2024

@petebankhead Hi,the first problem is fine, thanks! But when I try the second apprearing prompt , which ask to name the resulting stitched OME.TIF file and too select where to save it to. An error showed as followed:

ERROR: Cannot invoke "qupath.lib.images.servers.ImageServerMetadata.duplicate()" because "metadata" is null in QuPathScript at line number 92
qupath.lib.images.servers.ImageServerMetadata$Builder.(ImageServerMetadata.java:165)
qupath.lib.images.servers.SparseImageServer.(SparseImageServer.java:152)
qupath.lib.images.servers.SparseImageServer$Builder.build(SparseImageServer.java:333)
org.codehaus.groovy.vmplugin.v8.IndyInterface.fromCache(IndyInterface.java:321)
QuPathScript.run(QuPathScript:92)
org.codehaus.groovy.jsr223.GroovyScriptEngineImpl.eval(GroovyScriptEngineImpl.java:331)
org.codehaus.groovy.jsr223.GroovyScriptEngineImpl.eval(GroovyScriptEngineImpl.java:161)
qupath.lib.gui.scripting.languages.DefaultScriptLanguage.execute(DefaultScriptLanguage.java:234)
qupath.lib.gui.scripting.DefaultScriptEditor.executeScript(DefaultScriptEditor.java:1166)
qupath.lib.gui.scripting.DefaultScriptEditor$3.run(DefaultScriptEditor.java:1534)
java.base/java.util.concurrent.Executors$RunnableAdapter.call(Unknown Source)
java.base/java.util.concurrent.FutureTask.run(Unknown Source)
java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(Unknown Source)
java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(Unknown Source)
java.base/java.lang.Thread.run(Unknown Source)

For help interpreting this error, please search the forum at https://forum.image.sc/tag/qupath
You can also start a new discussion there, including both your script & the messages in this log.

@petebankhead
Copy link
Author

I'm afraid I have no data to test this, and there are too many things I don't know about your images and how you're using the script. I'd suggest posting on the forum (link at the bottom of the error message).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment