Qupath: Multichannel script- color assignement for detections

Hello Qupath developers!

I have been trying to run Pete’s script for multichannel fluorescence & multiple classifications on my RNAscope (mFISH) analysis. So far it works like a charm and Qupath is seriously GOD sent! It allows me to do a quick count of RNAscope positive cells quickly from very large .vsi whole slide images.

The only thing I can’t seem to work out is how to assign the proper colors to my classified detections. More importantly, the script is supposed to assign a different color to double class detections but this doesn’t happen for me. In the image below, you see the nuclei detected, classified as cell, cell (red), cell (green) and cell (green:red) selected in yellow.

Maybe I missed a detail in the explanation on Pete’s blog?

I am using version 0.2.0-m6.
Here is the script modified for my purposes:

runPlugin('qupath.imagej.detect.cells.WatershedCellDetection', '{"detectionImage": "DAPI",  "requestedPixelSizeMicrons": 0.5,  "backgroundRadiusMicrons": 8.0,  "medianRadiusMicrons": 0.0,  "sigmaMicrons": 1.5,  "minAreaMicrons": 30.0,  "maxAreaMicrons": 400.0,  "threshold": 100.0,  "watershedPostProcess": true,  "cellExpansionMicrons": 3.0,  "includeNuclei": true,  "smoothBoundaries": true,  "makeMeasurements": true}');
import qupath.lib.objects.PathObject
import qupath.lib.objects.classes.PathClassFactory
import qupath.lib.gui.scripting.QPEx


print 'Warning! This script requires the latest QuPath updates (currently v0.1.3 beta)'
def version = qupath.lib.gui.QuPathGUI.getInstance().versionString
if (version == null) {
    print 'Could not find the version string, so will give it the benefit of the doubt...'
} else if (version <= '0.1.2') {
    print 'Sorry, this QuPath version is not supported!'
    return
}
print 'here'


double k_1 = 4000
double k_2 = 4000

def kThreshold = {def a, def b, def c -> return MeasurementClassifier.createFromK(a, b, c)}
def absThreshold = {def a, def b, def c -> return MeasurementClassifier.createFromAbsoluteThreshold(a, b, c)}
def classifiers = [
        absThreshold('GREEN', 'Cell: FITC max', k_1),
        absThreshold('RED', 'Cell: TRITC max', k_2),
 
]



// Apply classifications
def cells = QPEx.getCellObjects()
cells.each {it.setPathClass(null)}
for (classifier in classifiers) {
    println classifier.classifyObjects(cells)
}
println 'None: \t' + cells.count {it.getPathClass() == null}
QPEx.fireHierarchyUpdate()


/**
 * Helper class to calculate & apply thresholds, resulting in object classifications being set.
 */
class MeasurementClassifier {

    String classificationName
    String measurementName
    double threshold = Double.NaN
    double k
    Integer defaultColor

    /**
     * Create a classifier using a calculated threshold value applied to a single measurement.
     * The actual threshold is derived from the measurement of a collection of objects
     * as median + k x sigma, where sigma is a standard deviation estimate derived from the median
     * absolute deviation.
     *
     * @param classificationName
     * @param measurementName
     * @param threshold
     * @return
     */
    static MeasurementClassifier createFromK(String classificationName, String measurementName, double k) {
        def mc = new MeasurementClassifier()
        mc.classificationName = classificationName
        mc.measurementName = measurementName
        mc.k = k
        return mc
    }

    /**
     * Create a classifier using a specific absolute threshold value applied to a single measurement.
     *
     * @param classificationName
     * @param measurementName
     * @param threshold
     * @return
     */
    static MeasurementClassifier createFromAbsoluteThreshold(String classificationName, String measurementName, double threshold) {
        def mc = new MeasurementClassifier()
        mc.classificationName = classificationName
        mc.measurementName = measurementName
        mc.threshold = threshold
        return mc
    }

    /**
     * Calculate threshold for measurementName as median + k x sigma,
     * where sigma is a standard deviation estimate
     * derived from the median absolute deviation.
     *
     * @param pathObjects
     * @return
     */
    double calculateThresholdFromK(Collection<PathObject> pathObjects) {
        // Create an array of all non-NaN values
        def allMeasurements = pathObjects.stream()
                .mapToDouble({p -> p.getMeasurementList().getMeasurementValue(measurementName)})
                .filter({d -> !Double.isNaN(d)})
                .toArray()
        double median = getMedian(allMeasurements)

        // Subtract median & get absolute value
        def absMedianSubtracted = Arrays.stream(allMeasurements).map({d -> Math.abs(d - median)}).toArray()
        Arrays.sort(absMedianSubtracted)

        // Compute median absolute deviation & convert to standard deviation approximation
        double medianAbsoluteDeviation = getMedian(absMedianSubtracted)
        double sigma = medianAbsoluteDeviation / 0.6745

        // Return threshold
        return median + k * sigma
    }

    /**
     * Get median value from array (this will sort the array!)
     */
    double getMedian(double[] vals) {
        if (vals.length == 0)
            return Double.NaN
        Arrays.sort(vals)
        if (vals.length % 2 == 1)
            return vals[(int)(vals.length / 2)]
        else
            return (vals[(int)(vals.length / 2)-1] + vals[(int)(vals.length / 2)]) / 2.0
    }

    /**
     * Classify objects using the threshold defined here, calculated if necessary.
     *
     * @param pathObjects
     * @return
     */
    ClassificationResults classifyObjects(Collection<PathObject> pathObjects) {
        double myThreshold = Double.isNaN(threshold) ? calculateThresholdFromK(pathObjects) : threshold
        def positive = pathObjects.findAll {it.getMeasurementList().getMeasurementValue(measurementName) > myThreshold}
        positive.each {
            def currentClass = it.getPathClass()
            def pathClass
            // Create a classifier - only assign the color if this is a single classification
            if (currentClass == null) {
                pathClass = PathClassFactory.getPathClass(classificationName, defaultColor)
                if (defaultColor != null)
                    pathClass.setColor(defaultColor)
            }
            else
                pathClass = PathClassFactory.getDerivedPathClass(currentClass, classificationName, null)
            it.setPathClass(pathClass)
        }
        return new ClassificationResults(actualThreshold: myThreshold, nPositive: positive.size())
    }


    class ClassificationResults {

        double actualThreshold
        int nPositive

        String toString() {
            String.format('%s: \t %d \t (%s > %.3f)', classificationName, nPositive, measurementName, actualThreshold)
        }

    }

}

Disclaimer: extremely novice programmer!
Thanks for the help!

M.

Couple of things that might help:

  1. Using subcellular detections rather than max intensity, as max might only be a single pixel that happened to “sparkle” and throw off your cell classification. It’s a very awkward measurement to use. More information on cytoplasmic measurements and classification here: QuPath-Accurate cytoplasmic stain measurements

  2. You can recolor or rename any class using this script here: https://gist.github.com/Svidro/e00021dff92ea1173e535008854be72e#file-rename-and-recolor-a-class-groovy
    Alternatively, any class you add to the Annotation tab on the left, you can double click on it and adjust the class display color.
    image
    There are a few other color modification scripts there as well detailed in the table of contents. The simplest option might be:

//If you have a class already created, you can alter the color for that class (replace pathClass with the class)

pathClass.setColor(getColorRGB(0, 200, 0))

//This requires having the class as a variable, for example:

stroma = getPathClass('Stroma')

//recolored would be:

stroma.setColor(getColorRGB(0, 200, 0))
  1. You may want to also consider the multiplex classifier in case you want to do separation by “levels,” for example, spot counts of 0-5,5-10, etc. and all combinations between your two markers.
    QuPath Multiplex Classifier
    Guide: QuPath Multiplex analysis workflow (detailed)

This is great. Thanks!

  1. Subcellular detections will be our next step. For now we are just interested in answering some preliminary questions i.e. rough % of double positive cells. I have also found that the resolution of the image is very important for subcellular detection (for clusters in particular). Since we have post-mortem human brain tissue we are imaging this at lower resolution for now and will in the future acquire higher resolution images for this.

2.Is it possible to classify detections within annotations using the classifier tab? I was under the impression that this was only possible for whole annotations. If so, it would be great to just train a classier to detect these detected cells using many more parameters than just the Intensity max values…

As for my original question, for now this works well!

I still can’t get the cells that don’t belong to any class to change into another color. What would this negative “class” be called within the code?

// Access phenotype sub-classifications
FITC = getPathClass('GREEN')
TRITC = getPathClass('RED')
Positive = getPathClass('GREEN:RED')
Negative = getPathClass('???')



// Set the color, using a packed RGB value
color = getColorRGB(0, 255, 0)
FITC.setColor(color)

// Update the GUI
fireHierarchyUpdate()

// Set the color, using a packed RGB value
color = getColorRGB(255, 0, 0)
TRITC.setColor(color)

// Update the GUI
fireHierarchyUpdate()


// Set the color, using a packed RGB value
color = getColorRGB(255, 0, 255)
Positive.setColor(color)

// Update the GUI
fireHierarchyUpdate()

// Set the color, using a packed RGB value
color = getColorRGB(0, 0, 255)
Negative.setColor(color)

// Update the GUI
fireHierarchyUpdate()

Thanks as usual for your prompt responses!

M.

Oh I will definitely try the Multiplex classifier next!

M.

Yes, you can train a classifier using all or several values, but I tend to dislike that when what you are looking for is simple, because the classifier might not be working the way you think that it is. Nor will you have exact control over the thresholds, which is something I definitely prefer when looking at IF images due to the complexity of background staining vs what is of biological interest.
More details on that here though if you want to get into it.

I haven’t used that particular classifier script, but based on the red color being the default, they are likely “null.” When running scripts that involve classification, I often set all cells to “negative” at the beginning of the script so that they all have some base class. The rest of the script takes some of the negative cells and turns them various degrees of positive. In Pete’s script, you can see this in the second line after the Apply classifications comment.

// Apply classifications
def cells = QPEx.getCellObjects()
cells.each {it.setPathClass(null)}

Change that to

// Apply classifications
def cells = QPEx.getCellObjects()
cells.each {it.setPathClass("Negative")}

and I think it will give you the remaining cells as negative. Have not tested it though.

Hi again,

I can’t change the default color of the unclassified cells from to red to something else yet. I used “null”, “none”, “negative” but none of these work. It’s not crucial to the script though.

Thanks for all your help!

Melina

Unclassified is the default in the preferences. Seen here in red.

The recommendation was to not use null as the unclassified cells, but to classify them as negative instead. In which case you can change the negative class the same way as any other class, by double clicking on the name of the class in the annotation tab (adding it if necessary)..

Null isn’t really a class, and so can’t be assigned a color normally.

Yup! that was it. Right in my face this whole time :expressionless:
Thank you!

Melina