QuPath script to perform a phenotype classifier (multispectral imaging)

Hello again,
now that I have my wonderfull cell detections, I wondered how to script a complex phenotyping (to earn precious time).
So I rewritted a script “double positive classifier”:

// Get cells & reset all the classifications
def cells = getCellObjects()

cells.each {it.setPathClass(getPathClass('Negative'))}

// Get channel 1 & 2 positives
def ch1Pos = cells.findAll {measurement(it, "CD38: Cytoplasm: Median") > 83.5}
ch1Pos.each {it.setPathClass(getPathClass('CD38+'))}

def ch2Pos = cells.findAll {measurement(it, "CD8: Cytoplasm: Median") > 37.5}
ch2Pos.each {it.setPathClass(getPathClass('CD8+'))}

def ch3Pos = cells.findAll {measurement(it, "BDAC2: Cytoplasm: Median") > 35.5}
ch3Pos.each {it.setPathClass(getPathClass('BDCA2+'))}

def ch4Pos = cells.findAll {measurement(it, "CD20: Cytoplasm: Median") > 65.5}
ch4Pos.each {it.setPathClass(getPathClass('CD20+'))}

def ch5Pos = cells.findAll {measurement(it, "CD3: Cell: Median") > 25.5}
ch5Pos.each {it.setPathClass(getPathClass('CD3+'))}

// Overwrite classifications for double positives/ triple ...
def ch1ch2Pos = ch1Pos.intersect(ch2Pos)
ch1ch2Pos.each {it.setPathClass(getPathClass('CD38+CD8+'))}

def ch1ch3Pos = ch1Pos.intersect(ch3Pos)
ch1ch3Pos.each {it.setPathClass(getPathClass('CD38+BDCA2+'))}

def ch1ch4Pos = ch1Pos.intersect(ch4Pos)
ch1ch4Pos.each {it.setPathClass(getPathClass('CD38+CD20+'))}

def ch1ch5Pos = ch1Pos.intersect(ch5Pos)
ch1ch5Pos.each {it.setPathClass(getPathClass('CD38+CD3+'))}

def ch2ch3Pos = ch2Pos.intersect(ch3Pos)
ch2ch3Pos.each {it.setPathClass(getPathClass('CD8+BDCA2+'))}

def ch2ch4Pos = ch2Pos.intersect(ch4Pos)
ch2ch4Pos.each {it.setPathClass(getPathClass('CD8+CD20+'))}

def ch2ch5Pos = ch2Pos.intersect(ch5Pos)
ch2ch5Pos.each {it.setPathClass(getPathClass('CD8+CD3+'))}

def ch3ch4Pos = ch3Pos.intersect(ch4Pos)
ch3ch4Pos.each {it.setPathClass(getPathClass('BDCA2+CD20+'))}

def ch3ch5Pos = ch3Pos.intersect(ch5Pos)
ch3ch5Pos.each {it.setPathClass(getPathClass('BDCA2+CD3+'))}

def ch4ch5Pos = ch4Pos.intersect(ch5Pos)
ch4ch5Pos.each {it.setPathClass(getPathClass('CD20+CD3+'))}

def ch1ch2ch3Pos = ch1ch2Pos.intersect(ch2ch3Pos)
ch1ch2ch3Pos.each {it.setPathClass(getPathClass('CD38+CD8+BDCA2+'))}

def ch1ch2ch4Pos = ch1ch2Pos.intersect(ch2ch4Pos)
ch1ch2ch4Pos.each {it.setPathClass(getPathClass('CD38+CD8+CD20+'))}

def ch1ch2ch5Pos = ch1ch2Pos.intersect(ch2ch5Pos)
ch1ch2ch5Pos.each {it.setPathClass(getPathClass('CD38+CD8+CD3+'))}

def ch1ch3ch4Pos = ch1ch3Pos.intersect(ch3ch4Pos)
ch1ch3ch4Pos.each {it.setPathClass(getPathClass('CD38+BDCA2+CD20+'))}

def ch1ch3ch5Pos = ch1ch3Pos.intersect(ch3ch5Pos)
ch1ch3ch5Pos.each {it.setPathClass(getPathClass('CD38+BDCA2+CD3+'))}

def ch1ch4ch5Pos = ch1ch4Pos.intersect(ch4ch5Pos)
ch1ch4ch5Pos.each {it.setPathClass(getPathClass('CD38+CD20+CD3+'))}

def ch2ch3ch4Pos = ch2ch3Pos.intersect(ch3ch4Pos)
ch2ch3ch4Pos.each {it.setPathClass(getPathClass('CD8+BDCA2+CD20+'))}

def ch2ch3ch5Pos = ch2ch3Pos.intersect(ch3ch5Pos)
ch2ch3ch5Pos.each {it.setPathClass(getPathClass('CD8+BDCA2+CD3+'))}

def ch2ch4ch5Pos = ch2ch4Pos.intersect(ch4ch5Pos)
ch2ch4ch5Pos.each {it.setPathClass(getPathClass('CD8+CD20+CD3+'))}

def ch3ch4ch5Pos = ch3ch4Pos.intersect(ch4ch5Pos)
ch3ch4ch5Pos.each {it.setPathClass(getPathClass('BDCA2+CD20+CD3+'))}

def ch1ch4ch5ch2Pos = ch2ch4ch5Pos.intersect(ch1ch4ch5Pos)
ch1ch4ch5ch2Pos.each {it.setPathClass(getPathClass('CD38+CD20+CD3+CD8+'))}


println 'Done!'

It works fine.
My question is about my strategie of overwriting, because I don’t know exaclty how it works and I am afraid to loose some information.
Do my overwritting seem fine? For example when I write “def ch1ch2ch4Pos = ch1ch2Pos.intersect(ch2ch4Pos)”, wouldn’t be better to write: “def ch1ch2ch4Pos = ch1ch2Pos.intersect(ch4Pos)”.
Or there is an other way to write it?

Thank you.

Hi @Dam ,

Your code sample is quite long and hard to read for someone who doesn’t know what your project really looks like.

But probably you can benefit from using PathClassFactory.getDerivedPathClass() rather than setting everything manually. Here is an example:

cells.each { it.setPathClass(PathClassFactory.getDerivedPathClass(it.getPathClass(), "SomeClass", null)) }

Meaning that if myCell has CD38+ as a class, running:

myCell.setPathClass(PathClassFactory.getDerivedPathClass(it.getPathClass(), "CD8+", null))

Will give you:

myCell.getPathClass() == "CD38+:CD8+"

Does that make sense? This means that you don’t have to check each combination manually and hopefully it will help you clean out your code. If you want more info about the PathClassFactory class, you can find its source code here. Good luck!

1 Like

Hi @Dam you might also want to check out https://qupath.readthedocs.io/en/0.2/docs/tutorials/multiplex_analysis.html – which shows derived classes in action.


Thank you, I know this tutorial by heart, believe me (and thanks a lot you for that).

Now I am trying an new strategie with scripting to earn some time with my series of cases.

Thank you very much for your answer. I is hard for me to understand your lines, because I don’t know th language, I read it by deduction a logics.
But thank you for your advises, I will try it.

If I add (to your code):

myCell.setPathClass(PathClassFactory.getDerivedPathClass(it.getPathClass(), "CD20+", null))

It will give me:

myCell.getPathClass() == "CD38+:CD8+:CD20+"


myCell.getPathClass() == "CD38+:CD20+"

(Depending on the cell I guess, but do the classifiers add to previous one?)

So if you apply this to a cell with no class, it will set it to CD20+.
If you apply this to a cell with a class (let’s call it firstClass), it will set it to firstClass: CD20+.

Or at least it should… You can do a trial run on some dummy image and check to be sure!


I forgot there was a script tip at the end.

This somewhat amusingly reminds me of my old way of doing this recursively:

Also, I do not recommend trying to use that.

But it does need to be done recursively because your first 5 channels in the original script will all overwrite each other. In the end, you would need 32 manually assigned classes to do it this way, which is why the multiplex classifier @petebankhead linked is nice.

That’s exactly what I fear with overwriting.
But with “double positive script” (https://gist.github.com/Svidro/5b016e192a33c883c0bd20de18eb7764#file-a-simple-classifier-2-groovy), we should have the same problem.

Oh, my mistake. As long as you are not actually applying the class, you don’t need to worry about overwriting the single positives. What you do need to worry about is the order of the final application of classes.
As long as you are using variables to store the cells, and not classifying the cells directly, I think that will actually work. The reason it works is because you will be applying new classifications to the same cell over and over again… but as long as the last classification is the most complete, it will work.

However, this has all been superseded by the multiplex classifier, which does essentially the same thing, but with less chance of mistyping one bit of code.

One issue with the previous script, though, is that you did not complete all possible permutations. There is no 5plex result, for example, which may throw things off. Even if it is not biologically possible, it is possible for the “QuPath cell” itself to have all of those markers (due to overlapping cells, or whatever).

So at the second to the last assignment:

def ch3ch4ch5Pos = ch3ch4Pos.intersect(ch4ch5Pos)
ch3ch4ch5Pos.each {it.setPathClass(getPathClass('BDCA2+CD20+CD3+'))}

A 5x positive cell would have those three classes.
But, once you run the final assignment

def ch1ch4ch5ch2Pos = ch2ch4ch5Pos.intersect(ch1ch4ch5Pos)
ch1ch4ch5ch2Pos.each {it.setPathClass(getPathClass('CD38+CD20+CD3+CD8+'))}

that 5x positive cell changed from BDCA2+CD20+CD3+ to CD38+CD20+CD3+CD8+
You could have similar issues anywhere you have unexpected overlaps.

The purpose was to be able to detect these overlaps.

They are usefull to evaluate cell to cell contact.

I think there is some confusion going on with what your script is doing vs what you intend for it to do. In the first part of your code you create lists of cellsthat are ch1+, ch2+, etc. This line (and all those like it): def ch1ch2Pos = ch1Pos.intersect(ch2Pos) finds the intersections of those lists. So, it finds cells that are members of both the ch1+ and ch2+ groups. There are way easier ways to do this, which is why Pete and Melvin and Research_Associate keep recommending the multiplex classifier.

But, it seems from this last post that what you are trying to do are find instances where a ch1 cell touches a separate ch2 cell. As in, their borders intersect. Is that true? If so, that’s a totally different problem. I’m not sure how to solve it, but at least we can start moving in the right direction.


Excellent point, I had been thinking about what exactly they meant by that last comment, and that is an important distinction.

Hello, thank you for your comment.

I know exactly how to do with QuPath classifiers.
My intend now is to find a way to do it with a script. So that, when I will have my new cases, I just have to run a cell detection (stardist of course), do my mesurment maps for each Opal flurochrome on each slide, change the values. In 10 minutes I could have my phenotyping.
The other cool stuff is that I would control exactly the phenotypes Qupath shows me at the end. And that’s important, because when you have when you run it with several cases, Qupath choose its own oorder.And at the end you have tones and tones of lines. There is an other risk of mistake when dealing with excel datas.

For now I couldn’t try the command they advised me, but I will.

And for superposition of cytoplasm positivity. It is an ineviable artifact, because I deal with inflammatory cells that are in contact or superposed. But I used it as a marker of cell to cell contact, and it was an intersting value, coherent with Pearson coefficient analysis (cytoMAP).


I tried the pathclass code. That is what I managed to do:

// Get cells & reset all the classifications
def cells = getCellObjects()
cells.each {it.setPathClass(getPathClass('Negative'))}

// Define channels positivity values:
def myCell = cells.findAll {measurement(it, "CD38: Cytoplasm: Median") > 83.5}
myCell.each {it.setPathClass(getPathClass('CD38+'))}

def myCell2 = cells.findAll {measurement(it, "CD8: Cytoplasm: Median") > 37.5}
myCell2.each {it.setPathClass(getPathClass('CD8+'))}

def myCell3 = cells.findAll {measurement(it, "BDAC2: Cytoplasm: Median") > 35.5}
myCell3.each {it.setPathClass(getPathClass('BDCA2+'))}

def myCell4 = cells.findAll {measurement(it, "CD20: Cytoplasm: Median") > 65.5}
myCell4.each {it.setPathClass(getPathClass('CD20+'))}

def myCell5 = cells.findAll {measurement(it, "CD3: Cell: Median") > 25.5}
myCell5.each {it.setPathClass(getPathClass('CD3+'))}

//Define pathClass
myCell.each { it.setPathClass(PathClassFactory.getDerivedPathClass(it.getPathClass(), "CD38+", null)) }
//myCell.setPathClass(PathClassFactory.getDerivedPathClass(it.getPathClass(), "CD38+", null))

myCell2.each { it.setPathClass(PathClassFactory.getDerivedPathClass(it.getPathClass(), "CD8+", null)) }

myCell3.each { it.setPathClass(PathClassFactory.getDerivedPathClass(it.getPathClass(), "BDCA2+", null)) }

myCell4.each { it.setPathClass(PathClassFactory.getDerivedPathClass(it.getPathClass(), "CD20+", null)) }

myCell5.each { it.setPathClass(PathClassFactory.getDerivedPathClass(it.getPathClass(), "CD3+", null)) }

The result is nearly good, but it doubles my detection, sometime in a way I cannot understand, like: “CD20: CD3: CD20” for instance.

I don’t think it’s possible to have the combination you mentioned based on the order of the commands in your script.

I am not sure if the logic of what you are trying to do makes much sense to me either. The used of derived classes might help clean your code, but you should try to avoid having duplicate. This can happen if you derive a class from the same class.

Just to point out, you can do the same thing with the built in classifiers, you would just be editing the JSON file instead of the .GROOVY file.

myCell4.each {it.setPathClass(getPathClass('CD20+'))}
myCell4.each { it.setPathClass(PathClassFactory.getDerivedPathClass(it.getPathClass(), "CD20+", null)) }

It looks like you are setting most of your cells twice, except the first set of commands will overwrite themselves.

So if you have a cell that is CD8 and CD3 positive, that cell is in myCell2 and myCell5.

myCell2.each {it.setPathClass(getPathClass('CD8+'))}
  1. = CD8+
myCell5.each {it.setPathClass(getPathClass('CD3+'))}
  1. CD8+ is removed, change to =CD3+
myCell2.each { it.setPathClass(PathClassFactory.getDerivedPathClass(it.getPathClass(), "CD8+", null)) }
  1. now = CD3+:CD8+
myCell5.each { it.setPathClass(PathClassFactory.getDerivedPathClass(it.getPathClass(), "CD3+", null)) }
  1. now = CD3+:CD8+:CD3+

The whole reason I went with the multiplex classifier script was due to how many over-writes and missed classes end up happening with scripts like this.


You are making a good point. I will do it with build in classifier, it seems too tricky throught coding.
But i learned some stuff about java and groovy, and that is great.

Thanks a lot.

1 Like

I am stubborn, I couldn’t leave the thing unfinished.

So cleaning my code and with your piece of advise I came out with that :

// Get cells & reset all the classifications
def cells = getCellObjects()

// Define channels positivity values:
def ch1 = cells.findAll {measurement(it, "CD38: Cytoplasm: Median") > 83.5}
def ch2 = cells.findAll {measurement(it, "CD8: Cytoplasm: Median") > 37.5}
def ch3 = cells.findAll {measurement(it, "BDAC2: Cytoplasm: Median") > 35.5}
def ch4 = cells.findAll {measurement(it, "CD20: Cytoplasm: Median") > 65.5}
def ch5 = cells.findAll {measurement(it, "CD3: Cell: Median") > 25.5}

//Define pathClass  
ch1.each {it.setPathClass(getPathClass('CD38+'))}

ch2.each {it.setPathClass(getPathClass('CD8+'))}
    def ch1ch2 = ch1.intersect(ch2)  
        ch1ch2.each {    
            it.setPathClass(PathClassFactory.getDerivedPathClass(it.getPathClass(), "CD38+", null)) }
ch3.each {it.setPathClass(getPathClass('BDCA2+'))}
    def ch1ch3 = ch1.intersect(ch3)  
        ch1ch3.each {    
        it.setPathClass(PathClassFactory.getDerivedPathClass(it.getPathClass(), "CD38+", null)) }
            def ch2ch3 = ch2.intersect(ch3)  
                ch2ch3.each {    
                it.setPathClass(PathClassFactory.getDerivedPathClass(it.getPathClass(), "CD8+", null)) }
ch4.each {it.setPathClass(getPathClass('CD20+'))}
    def ch1ch4 = ch1.intersect(ch4)  
        ch1ch4.each {    
        it.setPathClass(PathClassFactory.getDerivedPathClass(it.getPathClass(), "CD38+", null)) }
            def ch2ch4 = ch2.intersect(ch4)  
                ch2ch4.each {    
                it.setPathClass(PathClassFactory.getDerivedPathClass(it.getPathClass(), "CD8+", null))}
                    def ch3ch4 = ch3.intersect(ch4)  
                        ch3ch4.each {    
                        it.setPathClass(PathClassFactory.getDerivedPathClass(it.getPathClass(), "BDCA2+", null))}
ch5.each {it.setPathClass(getPathClass('CD3+'))}
    def ch1ch5 = ch1.intersect(ch5)  
        ch1ch5.each {    
        it.setPathClass(PathClassFactory.getDerivedPathClass(it.getPathClass(), "CD38+", null)) }
            def ch2ch5 = ch2.intersect(ch5)  
                ch2ch5.each {    
                it.setPathClass(PathClassFactory.getDerivedPathClass(it.getPathClass(), "CD8+", null))}
                    def ch3ch5 = ch3.intersect(ch5)  
                        ch3ch5.each {    
                        it.setPathClass(PathClassFactory.getDerivedPathClass(it.getPathClass(), "BDCA2+", null))}
                            def ch4ch5 = ch4.intersect(ch5)  
                                ch4ch5.each {    
                                it.setPathClass(PathClassFactory.getDerivedPathClass(it.getPathClass(), "CD20+", null))}


It is easier to read I imagine. And it is exactly what I wanted, I think.

Thank you very much for your time.

As a pathologist, it is a pleasure using this software (thanks @petebankhead).

1 Like