Change Annotation Hierarchy in QuPath

In case of large overlapping annotations with hierarchy, is it possible to change the hierarchy by script?
I´d like to descide by my own which annotation is on top (covers the other one. That will descide which color is shown in masks after extraction of overlays als tiles.
Many thanks for any ideas.

How are you doing this bit?

with a script you once posted in the google user forum:

/**
 * Script to export pixels & annotations for whole slide images.
 *
 * The image can optionally be tiled during export, so that even large images can be exported at high resolution.
 * (Note: In this case 'tiled' means as separate, non-overlapping images... not a single, tiled pyramidal image.)
 *
 * The downsample value and coordinates are encoded in each image file name.
 *
 * The annotations are exported as 8-bit labelled images.
 * These labels depend upon annotation classifications; a text file giving the key is written for reference.
 *
 * The labelled image can also optionally use indexed colors to depict the colors of the
 * original classifications within QuPath for easier visualization & comparison.
 *
 * @author Pete Bankhead
 */


import qupath.lib.common.ColorTools
import qupath.lib.objects.classes.PathClass
import qupath.lib.regions.RegionRequest
import qupath.lib.roi.PathROIToolsAwt
import qupath.lib.scripting.QPEx

import javax.imageio.ImageIO
import java.awt.Color
import java.awt.image.BufferedImage
import java.awt.image.DataBufferByte
import java.awt.image.IndexColorModel

// Requested pixel size - used to define output resolution
// Set <= 0 to use the full resolution (whatever that may be)
// (But be careful with this - it could take a long time to run!)
double requestedPixelSizeMicrons = 0

// Maximum size of an image tile when exporting
int maxTileSize = 288

// Export the original pixels (assumed ot be RGB) for each tile
boolean exportOriginalPixels = true

// Export a labelled image for each tile containing annotations
boolean exportAnnotationLabelledImage = true

// NOTE: The following parameters only matter if exportAnnotationLabelledImage == true
// Ignore annotations that don't have a classification set
boolean skipUnclassifiedAnnotations = true
// Skip tiles without annotations (only applies to label exports - all image tiles will be written)
boolean skipUnannotatedTiles = true
// Create an 8-bit indexed image
// This is very useful for display/previewing - although need to be careful when importing into other software,
// which can prefer to replaces labels with the RGB colors they refer to
boolean createIndexedImageLabels = true

// NOTE: The following parameter only matters if exportOriginalPixels == true
// Define the format for the export image
// def imageFormat = 'JPG'
def imageFormat = 'png'

// Output directory for storing the tiles
def pathOutput = QPEx.buildFilePath(QPEx.PROJECT_BASE_DIR, 'exported_tiles')
QPEx.mkdirs(pathOutput)

//------------------------------------------------------------------------------------

// Get the main QuPath data structures
def imageData = QPEx.getCurrentImageData()
def hierarchy = imageData.getHierarchy()
def server = imageData.getServer()

// Get the annotations that have ROIs & are have classifications (if required)
def annotations = hierarchy.getFlattenedObjectList(null).findAll {
    it.isAnnotation() && it.hasROI() && (!skipUnclassifiedAnnotations || it.getPathClass() != null) }

// Get all the represented classifications
def pathClasses = annotations.collect({it.getPathClass()}) as Set

// We can't handle more than 255 classes (because of 8-bit representation)
if (pathClasses.size() > 255) {
    print 'Sorry! Cannot handle > 255 classications - number here is ' + pathClasses.size()
    return
}

// For each classification, create an integer label >= 1 & cache a color for drawing it
// Also, create a LUT for visualizing more easily
def labelKey = new StringBuilder()
def pathClassColors = [:]
int n = pathClasses.size() + 1
def r = ([(byte)0] * n) as byte[]
def g = r.clone()
def b = r.clone()
def a = r.clone()
pathClasses.eachWithIndex{ PathClass entry, int i ->
    // Record integer label for key
    int label = i+1
    String name = entry == null ? 'None' : entry.toString()
    labelKey << name << '\t' << label << System.lineSeparator()
    // Store Color based on label - needed for painting with Graphics2D
    pathClassColors.put(entry, new Color(label, label, label))
    // Update RGB values for IndexColorModel
    // Use gray as the default color indicating no classification
    int rgb = entry == null ? ColorTools.makeRGB(127, 127, 127) : entry.getColor()
    r[label] = ColorTools.red(rgb)
    g[label] = ColorTools.green(rgb)
    b[label] = ColorTools.blue(rgb)
    a[label] = 255
}

// Check if we've anything to do
if (!exportAnnotationLabelledImage && !exportOriginalPixels) {
    print 'Nothing to export!'
    return
}

// Calculate the downsample value
double downsample = 1
if (requestedPixelSizeMicrons > 0)
    downsample = requestedPixelSizeMicrons / server.getAveragedPixelSizeMicrons()
    
// Calculate the tile spacing in full resolution pixels
int spacing = (int)(maxTileSize * downsample)

// Create the RegionRequests
def requests = new ArrayList<RegionRequest>()
for (int y = 0; y < server.getHeight(); y += spacing) {
    int h = spacing
    if (y + h > server.getHeight())
        h = server.getHeight() - y
    for (int x = 0; x < server.getWidth(); x += spacing) {
        int w = spacing
        if (x + w > server.getWidth())
            w = server.getWidth() - x
        requests << RegionRequest.createInstance(server.getPath(), downsample, x, y, w, h)
    }
}

// Write the label 'key'
println labelKey
def keyName = String.format('%s_(downsample=%.3f,tiles=%d)-key.txt', server.getShortServerName(), downsample, maxTileSize)
def fileLabels = new File(pathOutput, keyName)
fileLabels.text = labelKey.toString()

// Handle the requests in parallel
requests.parallelStream().forEach { request ->
    // Create a suitable base image name
    String name = String.format('%s_(%.2f,%d,%d,%d,%d)',
            server.getShortServerName(),
            request.getDownsample(),
            request.getX(),
            request.getY(),
            request.getWidth(),
            request.getHeight()
    )

    // Export the raw image pixels if necessary
    // If we do this, store the width & height - to make sure we have an exact match
    int width = -1
    int height = -1
    if (exportOriginalPixels) {
        def img = server.readBufferedImage(request)
        width = img.getWidth()
        height = img.getHeight()
        def fileOutput = new File(pathOutput, name + '.' + imageFormat.toLowerCase())
        ImageIO.write(img, imageFormat, fileOutput)
    }

    // Export the labelled tiles if necessary
    if (exportAnnotationLabelledImage) {
        // Calculate dimensions if we don't know them already
        if (width < 0 || height < 0) {
            width = Math.round(request.getWidth() / downsample)
            height = Math.round(request.getHeight() / downsample)
        }
        // Fill the annotations with the appropriate label
        def imgMask = new BufferedImage(width, height, BufferedImage.TYPE_BYTE_GRAY)
        def g2d = imgMask.createGraphics()
        g2d.setClip(0, 0, width, height)
        g2d.scale(1.0/downsample, 1.0/downsample)
        g2d.translate(-request.getX(), -request.getY())
        int count = 0
        for (annotation in annotations) {
            def roi = annotation.getROI()
            if (!request.intersects(roi.getBoundsX(), roi.getBoundsY(), roi.getBoundsWidth(), roi.getBoundsHeight()))
                continue
            def shape = PathROIToolsAwt.getShape(roi)
            def color = pathClassColors.get(annotation.getPathClass())
            g2d.setColor(color)
            g2d.fill(shape)
            count++
        }
        g2d.dispose()
        if (count > 0 || !skipUnannotatedTiles) {
            // Extract the bytes from the image
            def buf = imgMask.getRaster().getDataBuffer() as DataBufferByte
            def bytes = buf.getData()
            // Check if we actually have any non-zero pixels, if necessary -
            // we might not if the annotation bounding box intersected the region, but the annotation itself does not
            if (skipUnannotatedTiles && !bytes.any { it != (byte)0 })
                return
            // If we want an indexed color image, create one using the data buffer & LUT
            if (createIndexedImageLabels) {
                def colorModel = new IndexColorModel(8, n, r, g, b, a)
                def imgMaskColor = new BufferedImage(width, height, BufferedImage.TYPE_BYTE_INDEXED, colorModel)
                System.arraycopy(bytes, 0, imgMaskColor.getRaster().getDataBuffer().getData(), 0, width*height)
                imgMask = imgMaskColor
            }
            // Write the mask
            def fileOutput = new File(pathOutput, name + '-labels.png')
            ImageIO.write(imgMask, 'PNG', fileOutput)
        }
    }
}
print 'Done!'g

Just a suggestion, but using the preformatted text option is the best way to share code in posts. Or link the original post.
image

I took the liberty of reformatting it, hope you don’t mind :slight_smile:

@David_C You don’t really want to change the hierarchy, you only need to change the order of the list annotations… I think.

Something like this might do the trick:

// List your classes in order here (you probably need to make sure they are *all* here)
def orderedClasses = [
    getPathClass('Tumor'),
    getPathClass('Necrosis'),
    getPathClass('Stroma'),
    getPathClass('Immune cells')
]

// Sort the list
annotations.sort {a1, a2 -> Integer.compare(orderedClasses.indexOf(a1.getPathClass()), orderedClasses.indexOf(a2.getPathClass())) }

You’ll need to add it high in your script, after after

// Get the annotations that have ROIs & are have classifications (if required)
def annotations = hierarchy.getFlattenedObjectList(null).findAll {
    it.isAnnotation() && it.hasROI() && (!skipUnclassifiedAnnotations || it.getPathClass() != null) }

I admit that I haven’t tried this for the full script… so please don’t be too surprised if it turns out I’ve got it wrong somewhere, and let me know how it goes if you try it.

1 Like

Hi Pete,

after a lot of trying I succeeded with this combination. It works!
Many thanks. I am happy :slight_smile:

/**
 * Script to export pixels & annotations for whole slide images.
 *
 * The image can optionally be tiled during export, so that even large images can be exported at high resolution.
 * (Note: In this case 'tiled' means as separate, non-overlapping images... not a single, tiled pyramidal image.)
 *
 * The downsample value and coordinates are encoded in each image file name.
 *
 * The annotations are exported as 8-bit labelled images.
 * These labels depend upon annotation classifications; a text file giving the key is written for reference.
 *
 * The labelled image can also optionally use indexed colors to depict the colors of the
 * original classifications within QuPath for easier visualization & comparison.
 *
 * @author Pete Bankhead
 */


import qupath.lib.common.ColorTools
import qupath.lib.objects.classes.PathClass
import qupath.lib.regions.RegionRequest
import qupath.lib.roi.PathROIToolsAwt
import qupath.lib.scripting.QPEx

import javax.imageio.ImageIO
import java.awt.Color
import java.awt.image.BufferedImage
import java.awt.image.DataBufferByte
import java.awt.image.IndexColorModel


import qupath.lib.roi.*
import qupath.lib.objects.*

def imageData = QPEx.getCurrentImageData()
def hierarchy = imageData.getHierarchy()
def server = imageData.getServer()
// Get the annotations that have ROIs & are have classifications (if required)
// def annotations = hierarchy.getFlattenedObjectList(null).findAll {
//     it.isAnnotation() && it.hasROI() && (!skipUnclassifiedAnnotations || it.getPathClass() != null) }
// Requested pixel size - used to define output resolution
// Set <= 0 to use the full resolution (whatever that may be)
// (But be careful with this - it could take a long time to run!)
double requestedPixelSizeMicrons = 0

// Maximum size of an image tile when exporting
int maxTileSize = 288

// Export the original pixels (assumed ot be RGB) for each tile
boolean exportOriginalPixels = true

// Export a labelled image for each tile containing annotations
boolean exportAnnotationLabelledImage = true

// NOTE: The following parameters only matter if exportAnnotationLabelledImage == true
// Ignore annotations that don't have a classification set
boolean skipUnclassifiedAnnotations = true
// Skip tiles without annotations (only applies to label exports - all image tiles will be written)
boolean skipUnannotatedTiles = true
// Create an 8-bit indexed image
// This is very useful for display/previewing - although need to be careful when importing into other software,
// which can prefer to replaces labels with the RGB colors they refer to
boolean createIndexedImageLabels = true

// NOTE: The following parameter only matters if exportOriginalPixels == true
// Define the format for the export image
// def imageFormat = 'JPG'
def imageFormat = 'png'

// Output directory for storing the tiles
def pathOutput = QPEx.buildFilePath(QPEx.PROJECT_BASE_DIR, 'exported_tiles')
QPEx.mkdirs(pathOutput)

//------------------------------------------------------------------------------------

// Get the main QuPath data structures
//def imageData = QPEx.getCurrentImageData()
//def hierarchy = imageData.getHierarchy()
//def server = imageData.getServer()

// Get the annotations that have ROIs & are have classifications (if required)
//def annotations = hierarchy.getFlattenedObjectList(null).findAll {
//    it.isAnnotation() && it.hasROI() && (!skipUnclassifiedAnnotations || it.getPathClass() != null) }


//=====
def annotations = hierarchy.getFlattenedObjectList(null).findAll {
     it.isAnnotation() && it.hasROI() && (!skipUnclassifiedAnnotations || it.getPathClass() != null) }

// List your classes in order here (you probably need to make sure they are *all* here)
def orderedClasses = [
    getPathClass('Background'),
    getPathClass('Stroma'),
    getPathClass('Lumen'),
    getPathClass('Nucleus'),
    getPathClass('Empty'),
    getPathClass('Druese'),
    getPathClass('Membrane'),
    getPathClass('Cell'),
]

// Sort the list
annotations.sort {a1, a2 -> Integer.compare(orderedClasses.indexOf(a1.getPathClass()), orderedClasses.indexOf(a2.getPathClass())) }

//=====



// Get all the represented classifications
def pathClasses = annotations.collect({it.getPathClass()}) as Set

// We can't handle more than 255 classes (because of 8-bit representation)
if (pathClasses.size() > 255) {
    print 'Sorry! Cannot handle > 255 classications - number here is ' + pathClasses.size()
    return
}

// For each classification, create an integer label >= 1 & cache a color for drawing it
// Also, create a LUT for visualizing more easily
def labelKey = new StringBuilder()
def pathClassColors = [:]
int n = pathClasses.size() + 1
def r = ([(byte)0] * n) as byte[]
def g = r.clone()
def b = r.clone()
def a = r.clone()
pathClasses.eachWithIndex{ PathClass entry, int i ->
    // Record integer label for key
    int label = i+1
    String name = entry == null ? 'None' : entry.toString()
    labelKey << name << '\t' << label << System.lineSeparator()
    // Store Color based on label - needed for painting with Graphics2D
    pathClassColors.put(entry, new Color(label, label, label))
    // Update RGB values for IndexColorModel
    // Use gray as the default color indicating no classification
    int rgb = entry == null ? ColorTools.makeRGB(127, 127, 127) : entry.getColor()
    r[label] = ColorTools.red(rgb)
    g[label] = ColorTools.green(rgb)
    b[label] = ColorTools.blue(rgb)
    a[label] = 255
}

// Check if we've anything to do
if (!exportAnnotationLabelledImage && !exportOriginalPixels) {
    print 'Nothing to export!'
    return
}

// Calculate the downsample value
double downsample = 1
if (requestedPixelSizeMicrons > 0)
    downsample = requestedPixelSizeMicrons / server.getAveragedPixelSizeMicrons()
    
// Calculate the tile spacing in full resolution pixels
int spacing = (int)(maxTileSize * downsample)

// Create the RegionRequests
def requests = new ArrayList<RegionRequest>()
for (int y = 0; y < server.getHeight(); y += spacing) {
    int h = spacing
    if (y + h > server.getHeight())
        h = server.getHeight() - y
    for (int x = 0; x < server.getWidth(); x += spacing) {
        int w = spacing
        if (x + w > server.getWidth())
            w = server.getWidth() - x
        requests << RegionRequest.createInstance(server.getPath(), downsample, x, y, w, h)
    }
}

// Write the label 'key'
println labelKey
def keyName = String.format('%s_(downsample=%.3f,tiles=%d)-key.txt', server.getShortServerName(), downsample, maxTileSize)
def fileLabels = new File(pathOutput, keyName)
fileLabels.text = labelKey.toString()

// Handle the requests in parallel
requests.parallelStream().forEach { request ->
    // Create a suitable base image name
    String name = String.format('%s_(%.2f,%d,%d,%d,%d)',
            server.getShortServerName(),
            request.getDownsample(),
            request.getX(),
            request.getY(),
            request.getWidth(),
            request.getHeight()
    )

    // Export the raw image pixels if necessary
    // If we do this, store the width & height - to make sure we have an exact match
    int width = -1
    int height = -1
    if (exportOriginalPixels) {
        def img = server.readBufferedImage(request)
        width = img.getWidth()
        height = img.getHeight()
        def fileOutput = new File(pathOutput, name + '.' + imageFormat.toLowerCase())
        ImageIO.write(img, imageFormat, fileOutput)
    }

    // Export the labelled tiles if necessary
    if (exportAnnotationLabelledImage) {
        // Calculate dimensions if we don't know them already
        if (width < 0 || height < 0) {
            width = Math.round(request.getWidth() / downsample)
            height = Math.round(request.getHeight() / downsample)
        }
        // Fill the annotations with the appropriate label
        def imgMask = new BufferedImage(width, height, BufferedImage.TYPE_BYTE_GRAY)
        def g2d = imgMask.createGraphics()
        g2d.setClip(0, 0, width, height)
        g2d.scale(1.0/downsample, 1.0/downsample)
        g2d.translate(-request.getX(), -request.getY())
        int count = 0
        for (annotation in annotations) {
            def roi = annotation.getROI()
            if (!request.intersects(roi.getBoundsX(), roi.getBoundsY(), roi.getBoundsWidth(), roi.getBoundsHeight()))
                continue
            def shape = PathROIToolsAwt.getShape(roi)
            def color = pathClassColors.get(annotation.getPathClass())
            g2d.setColor(color)
            g2d.fill(shape)
            count++
        }
        g2d.dispose()
        if (count > 0 || !skipUnannotatedTiles) {
            // Extract the bytes from the image
            def buf = imgMask.getRaster().getDataBuffer() as DataBufferByte
            def bytes = buf.getData()
            // Check if we actually have any non-zero pixels, if necessary -
            // we might not if the annotation bounding box intersected the region, but the annotation itself does not
            if (skipUnannotatedTiles && !bytes.any { it != (byte)0 })
                return
            // If we want an indexed color image, create one using the data buffer & LUT
            if (createIndexedImageLabels) {
                def colorModel = new IndexColorModel(8, n, r, g, b, a)
                def imgMaskColor = new BufferedImage(width, height, BufferedImage.TYPE_BYTE_INDEXED, colorModel)
                System.arraycopy(bytes, 0, imgMaskColor.getRaster().getDataBuffer().getData(), 0, width*height)
                imgMask = imgMaskColor
            }
            // Write the mask
            def fileOutput = new File(pathOutput, name + '-labels.png')
            ImageIO.write(imgMask, 'PNG', fileOutput)
        }
    }
}
print 'Done!'
1 Like