Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save petebankhead/db3a3c199546cadc49a6c73c2da14d6c to your computer and use it in GitHub Desktop.
Save petebankhead/db3a3c199546cadc49a6c73c2da14d6c to your computer and use it in GitHub Desktop.
Merge images along the channels dimension in QuPath v0.2.0
/**
* Merge images along the channels dimension in QuPath v0.2.0.
*
* This shows how multiple images can be combined by channel concatenation,
* optionally applying color deconvolution or affine transformations along the way.
* It may be applied to either brightfield images (with stains set) or fluorescence images.
*
* The result can be written to a file (if 'pathOutput' is defined) or opened in the QuPath viewer.
*
* Writing to a file is *strongly recommended* to ensure the result is preserved.
* Opening in the viewer directly will have quite slow performance (as the transforms are applied dynamically)
* and there is no guarantee the image can be reopened later, since the representation of the
* transforms might change in future versions... so this is really only to preview results.
*
* Note QuPath does *not* offer full whole slide image registration - and there are no
* plans to change this. If you require image registration, you probably need to use other
* software to achieve this, and perhaps then import the registered images into QuPath later.
*
* Rather, this script is limited to applying a pre-defined affine transformation to align two or more
* images. In the case where image registration has already been applied, it can be used to
* concatenate images along the channel dimension without any addition transformation.
*
* In its current form, the script assumes you have an open project containing the images
* OS-2.ndpi and OS-3.ndpi from the OpenSlide freely-distributable test data,
* and the image type (and color deconvolution stains) have been set.
* The script will apply a pre-defined affine transform to align the images (*very* roughly!),
* and write their deconvolved channels together as a single 6-channel pseudo-fluorescence image.
*
* You will need to change the image names & add the correct transforms to apply it elsewhere.
*
* USE WITH CAUTION!
* This uses still-in-development parts of QuPath that are not officially documented,
* and may change or be removed in future versions.
*
* Made available due to frequency of questions, not readiness of code.
*
* For these reasons, I ask that you refrain from posting the script elsewhere, and instead link to this
* Gist so that anyone requiring it can get the latest version.
*
* @author Pete Bankhead
*/
import javafx.application.Platform
import org.locationtech.jts.geom.util.AffineTransformation
import qupath.lib.images.ImageData
import qupath.lib.images.servers.ImageChannel
import qupath.lib.images.servers.ImageServer
import qupath.lib.images.servers.ImageServers
import qupath.lib.roi.GeometryTools
import java.awt.geom.AffineTransform
import java.awt.image.BufferedImage
import java.util.stream.Collectors
import static qupath.lib.gui.scripting.QPEx.*
import qupath.lib.images.servers.TransformedServerBuilder
// Define a transform, e.g. with the (also unfinished) 'Interactive image alignment' command
// Note: you may need to remove .createInverse() depending upon how the transform is created
def os3Transform = GeometryTools.convertTransform(new AffineTransformation([-0.989, 0.067, 121405.675,
-0.067, -0.989, 80740.763] as double[])).createInverse()
// Define a map from the image name to the transform that should be applied to that image
def transforms = [
'OS-2.ndpi': new AffineTransform(), // Identity transform (use this if no transform is needed)
'OS-3.ndpi': os3Transform
]
// Define an output path where the merged file should be written
// Recommended to use extension .ome.tif (required for a pyramidal image)
// If null, the image will be opened in a viewer
String pathOutput = null
//String pathOutput = buildFilePath(PROJECT_BASE_DIR, 'merged.ome.tif')
// Choose how much to downsample the output (can be *very* slow to export large images with downsample 1!)
double outputDownsample = 1
// Loop through the transforms to create a server that merges these
def project = getProject()
def servers = []
def channels = []
int c = 0
for (def mapEntry : transforms.entrySet()) {
// Find the next image & transform
def name = mapEntry.getKey()
def transform = mapEntry.getValue()
if (transform == null)
transform = new AffineTransform()
def entry = project.getImageList().find {it.getImageName() == name}
// Read the image & check if it has stains (for deconvolution)
def imageData = entry.readImageData()
def currentServer = imageData.getServer()
def stains = imageData.getColorDeconvolutionStains()
// Nothing more to do if we have the identity trainform & no stains
if (transform.isIdentity() && stains == null) {
channels.addAll(updateChannelNames(name, currentServer.getMetadata().getChannels()))
servers << currentServer
continue
} else {
// Create a server to apply transforms
def builder = new TransformedServerBuilder(currentServer)
if (!transform.isIdentity())
builder.transform(transform)
// If we have stains, deconvolve them
if (stains != null) {
builder.deconvolveStains(stains)
for (int i = 1; i <= 3; i++)
channels << ImageChannel.getInstance(name + "-" + stains.getStain(i).getName(), ImageChannel.getDefaultChannelColor(c++))
} else {
channels.addAll(updateChannelNames(name, currentServer.getMetadata().getChannels()))
}
servers << builder.build()
}
}
println 'Channels: ' + channels.size()
// Remove the first server - we need to use it as a basis (defining key metadata, size)
ImageServer<BufferedImage> server = servers.remove(0)
// If anything else remains, concatenate along the channels dimension
if (!servers.isEmpty())
server = new TransformedServerBuilder(server)
.concatChannels(servers)
.build()
// Write the image or open it in the viewer
if (pathOutput != null) {
if (outputDownsample > 1)
server = ImageServers.pyramidalize(server, outputDownsample)
writeImage(server, pathOutput)
} else {
// Create the new image & add to the project
def imageData = new ImageData<BufferedImage>(server)
setChannels(imageData, channels as ImageChannel[])
Platform.runLater {
getCurrentViewer().setImageData(imageData)
}
}
// Prepend a base name to channel names
List<ImageChannel> updateChannelNames(String name, Collection<ImageChannel> channels) {
return channels
.stream()
.map( c -> {
return ImageChannel.getInstance(name + '-' + c.getName(), c.getColor())
}
).collect(Collectors.toList())
}
@gianna-raimondi
Copy link

Hi Pete - how would I edit this script to apply to images that have already been transformed? I know it says it in the header, that it can be done, I'm just not exactly sure which parts of the code should be deleted for that. Basically, I just want to merge 3 channels in a folder that has 5 registered images total (so 15 files in here).

@petebankhead
Copy link
Author

From the code, you just won't specify a particular transform for the images you want, i.e. use something like

// Define a map from the image name to the transform that should be applied to that image
def transforms = [
        'Name 2': new AffineTransform(), // Identity transform (use this if no transform is needed)
        'Name 3': new AffineTransform() // Identity transform (use this if no transform is needed)
]

If you need more help with the specifics of your case, please post on the forum at https://forum.image.sc/tag/qupath

There might also be some useful posts via https://forum.image.sc/search?q=TransformedServerBuilder%20concatchannels

@gianna-raimondi
Copy link

Thanks so much, Pete. I've been digging through those forums for a couple days, trying to figure out how to customize it for my workflow just by others' comments. I may end up caving in today and posting on the forum if I'm having more trouble. I'm new to this, so I very much appreciate your explanation!! Thanks again!

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