Detecting positive cells and other feature based on different channels in multiplexed images

Hi Guys
I would very much appreciate your help in a couple of approaches

  1. detecting positive cells only (based on channel 2 from dapi channel 1 segmentation)
  2. detecting stained area based on Chanel 5 only, independent of cell segmentation then determining %percentage positive of the annotated area

I can accomplish both using built-in feature with some issue mentioned below

  1. I can do this using ‘Positive cell detection’ however I would it so that ONLY positive cells are detected and not negative cells based on nuclei segmented with Dapi no need for cell expansion

2)On the same ROI annotation as 1, I would like to create a new detection based on another Chanel (5)so that I can determine % percentage positive of the annotated area. I’m able to do this using ‘Cell Detection’ however this deletes the object from 1, how can overcome this. Is there a way to add percentage positivity measurement of this segmentation compared to the whole annotation.


End of day here, so a quick one.
First step, you basically can’t if you want to use DAPI, you need to generate all of the cells and then delete the negative ones. See scripts for removing objects here.

The second question is much easier, as you can use the Create Simple Threshold tool shown here (also linked from the general guide) on your channel of interest, with the ROI you are interested in selected. You would then need to divide your classified area by the total area, either manually or as part a script. Both values exist in the Annotation measurements and would be exported along with any saveAnnotationMeasurements() script.

All of this can be scripted into a single script, though it might take a few more posts.

This assumes you are using M8, your options are a bit more limited if you are using an earlier version.

Great! , number one sorted
Number two almost there.
So far I can create those object based on specific channel threshold as advised above, however the created annotation or objects are for the whole image. Can this be limited to currently available annotation/ ROI and that hierarchically those newly created object be placed under the initial annotation .

This would hopefully make my area measurement parts easier

Again Thanks for your prompt reply


Like the other tools, it runs within what you have selected. It will display potential results during the testing phase for the whole image.

Great that works, I selected all the region and then pressed Create objects and done exactly as a wanted

I’m tying to put this on s script so that I can automate it for the large cohort that I have
I cant’ seem to find the Simple threshold on the Create command history script
Do I save it as a classifier and call on the script

Again great appreciate you help


That’s one of the more complicated parts of this to script, and is linked in second post of the Create simple threshold guide linked above. There is no automatic scripting for it, you just have to edit the script provided by @dstevens with what you named your script.

Almost there, I have the first 2 stages working with the adapted scripts, however, sometimes the classified area from 2 is not being inserted into its parent ROI hierarchy

I’m now also trying to get measurements script going whereby AnnotatedFromClassifier/TotalAreaofParentROI*100 I hope you can help with that below is the script I’m working from

//Script is based on started with manuallly annotated ROIs

//Detect cells using Hoescht channel but positive nuclei in an other channel only within ROI
runPlugin('qupath.imagej.detect.cells.PositiveCellDetection', '{"detectionImage": "H3342",  "requestedPixelSizeMicrons": 0.5,  "backgroundRadiusMicrons": 8.0,  "medianRadiusMicrons": 0.0,  "sigmaMicrons": 1.5,  "minAreaMicrons": 10.0,  "maxAreaMicrons": 400.0,  "threshold": 100.0,  "watershedPostProcess": true,  "cellExpansionMicrons": 0.2,  "includeNuclei": true,  "smoothBoundaries": true,  "makeMeasurements": true,  "thresholdCompartment": "Nucleus: FITC mean",  "thresholdPositive1": 2000.0,  "thresholdPositive2": 200.0,  "thresholdPositive3": 300.0,  "singleThreshold": true}');
removal = getCellObjects().findAll{it.getPathClass().toString().contains("Negative")}
removeObjects(removal, true)

//Detect and annotate area within ROI using channel 4
def annotations = getAnnotationObjects()
def project = getProject()
def classifier = project.getPixelClassifiers().get('ADD name of classifier')

//Define image data
def imageData = getCurrentImageData()

//Convert pixel classifier to annotations
PixelClassifierTools.createAnnotationsFromPixelClassifier(imageData, classifier, annotations, 500, 500, false, false)

//Add Measurement of percentage positivity area of the annotated area detected by classifier / Parent ROI
import qupath.lib.objects.PathDetectionObject
def imageData = getCurrentImageData()
def server = imageData.getServer()
def pixelSize = server.getPixelCalibration().getPixelHeightMicrons()
Set classList = []
for (object in getAllObjects().findAll{it.isDetection() /*|| it.isAnnotation()*/}) {
    classList << object.getPathClass()
hierarchy = getCurrentHierarchy()

for (annotation in getAnnotationObjects()){
    def annotationArea = annotation.getROI().getArea()

    for (aClass in classList){
        if (aClass){
            def tiles = hierarchy.getDescendantObjects(annotation,null, PathDetectionObject).findAll{it.getPathClass() == aClass}
            double totalArea = 0
            for (def tile in tiles){
                totalArea += tile.getROI().getArea()
            annotation.getMeasurementList().putMeasurement(aClass.getName()+" area px", totalArea)
            annotation.getMeasurementList().putMeasurement(aClass.getName()+" area um^2", totalArea*pixelSize*pixelSize)

            annotation.getMeasurementList().putMeasurement(aClass.getName()+" area %", annotationArea/totalArea*100)

2 issues remain

  1. some annotation do not insert in hierarchy
  2. measurement of percentage positive ( do I have to create and assign a new class to the area detected by classifier?)
    I’m sure I muddled up somwhere


Basically, yes. I notice you haven’t added the name of the classifier that you saved in the script above, so I wouldn’t expect it to work quite yet. But once you have that name input, you probably will want to use the class you assigned within the classifier… and make sure that class isn’t used anywhere else in your project.

def classifier = project.getPixelClassifiers().get('ADD name of classifier')

It looks like you are creating the annotations variable prior to running the script, so the annotation loop should be fine, and the annotationArea will be the parent variable. You probably don’t need a class loop, and in this case, the class loop will probably be wrong since it only finds classes that exist as detections (cells). If you generate annotations from the pixel classifier


you will ignore the pixel annotation class.

If your project is fairly simple, I would remove the class loop entirely and only get child annotations, sum their area, and divide that by the parent annotation area.
Also, this loop is currently getting detection objects, which will again ignore the annotations you created.

def tiles = hierarchy.getDescendantObjects(annotation,null, **PathDetectionObject**)

An entirely different option would be to generate detections instead of annotations with the pixel classifier. I don’t think either option is necessarily better or worse… but that might depend on your experiment and any downstream things you want to do that I am not aware of. Generating annotations lets you search inside them for other detections, while detections are, in general, easier and faster to work with, especially if you have thousands of them across a whole slide image.

All works fine until here, I think I’m struggling to script for the total annotation area picked by classifier which would be under the parentROI

//Add Measurement of percentage positivity of the annotated area by classfier to the total ParentROI
import qupath.lib.objects.PathDetectionObject
//def imageData = getCurrentImageData() USED ALREADY
def server = imageData.getServer()
def pixelSize = server.getPixelCalibration().getPixelHeightMicrons()

hierarchy = getCurrentHierarchy()

// annotation area annoted by classfier
for (annotation in getAnnotationObjects()){
    def TotalROIArea = annotation.getROI().getArea()
    def tiles = hierarchy.getDescendantObjects(annotation,null)
    double totalArea = 0
    for (def tile in tiles){
        annotatedArea += tile.getROI().getArea()
annotation.getMeasurementList().putMeasurement("Annotated area px", totalArea)
annotation.getMeasurementList().putMeasurement(" area um^2", totalArea*pixelSize*pixelSize)

    annotation.getMeasurementList().putMeasurement("Annotated area %", annotatedArea/TotalROIArea*100)

I get a

 No signature of method: qupath.lib.objects.hierarchy.PathObjectHierarchy.getAnnotationObjects() is applicable for argument types: (qupath.lib.objects.PathAnnotationObject, null)

Trying to adapt this code

Not sure what would be causing that error, getAnnotationObjects should run on its own, but you also probably wouldn’t want to use that in your script. I am pretty sure you can’t strip out the object type from getDescendantObjects though, so you will need something after the null. Probably either pathDetectionObject or pathAnnotationObject, and if you use pathDetectionObject, you will need to specify the class since you will have a lot of cells that are pathDetectionObjects within the annotation.

Hard to judge though without seeing the project and the current state.

Oh wait. That script is for earlier versions of QuPath and won’t work for 0.2.0M8. getDescendantObjects changed completely.

As of M5, it uses this type of format:

I have not gone through and edited all of the scripts. Things are still changing and I’m waiting for a stable 0.2.0 release to really go through and clean everything out. There are also two possible lines in the above script that could apply depending on the version of QuPath you are using =/ One is included in the comment.

    totalCells = []
    //qupath.lib.objects.helpers.PathObjectTools.getDescendantObjects(annotation,totalCells, PathCellObject)
    qupath.lib.objects.PathObjectTools.getDescendantObjects(annotation,totalCells, PathCellObject)

You would want to edit that to something that used PathAnnotationObject rather than PathCellObject, but the overall structure to “get things that are inside of the annotation” should be similar.

Great the scripts now runs beautifully, PathAnnotationObject annotation done the trick

    def tiles = qupath.lib.objects.PathObjectTools.getDescendantObjects(annotation,null,PathAnnotationObject)

One extra measurement that came to mind is it possible to obtain mean pixel intesnisty from a specific channel on the descandant annotation ?


Yep, though you will be easier to add the feature you want to everything:

You will want the Add intensity features, and a selectAnnotations() line first. Add pretty much as many features as you want to deal with.

All is running smoothly,however there is just one more bugbear regarding the hierarchy, it seems that the descendant hierarchy if it touches the parent it doesn’t seem to go under its parent hierarchy even with


Any Advise ?

Nothing great, no. I mentioned this to @petebankhead in an email for another script I was working on, but I think it is working as intended. The best option I can think of is, if you know which annotation is supposed to be on the “inside,” perform a 1 pixel erosion on it. I don’t think you can force a change in something’s place in the hierarchy based on when it was created, just where it is.

Objects->Annotations->Expand annotations

I suppose an alternative would be to Erode the initial annotation before calculating anything, and then re-expand it at the end of the script. That way you are only changing one annotation.

Selecting the initial annotation by classname, after which expanding it slightly then deleted original annotation works and all hierarchies are in order as they should be

selectObjects { p -> p.getPathClass() == getPathClass("OriginalAnnotation") }

runPlugin('qupath.lib.plugins.objects.DilateAnnotationPlugin', '{"radiusMicrons": 0.2,  "lineCap": "Round",  "removeInterior": false,  "constrainToParent": true}');