Hi Carlos,
Thanks for your post, and describing your approach. I had started thinking a bit about this before seeing your message, and come up with 3 ideas...
The one you’ve chosen is the one that I thought I probably shouldn’t suggest, since I expected it would be most awkward and you’d like it least :) But having thought about the other options, they all have their awkwardness… Nevertheless, I would hope/expect they all end with something similar.
In any case, you’re completely correct about the Create cytokeratin annotations command… it exists because it was needed once, for TMAs, with DAB staining. Lacking time and urgency, it never reached the generality that it should have.
So you do have to trick it to apply it here. But so long as it produces the desired results, and you can verify that they are appropriate and that the right cells have been identified and counted, I would say that’s ok.
Anyway, here are the other two methods:
Method #1: Recreate the cytokeratin command with an ImageJ macro
The Create cytokeratin annotations command is quite simple… simple enough that it can be more or less reproduced with an ImageJ macro, which can in turn be run through Extensions -> ImageJ -> ImageJ macro runner
I’ve put together an example of what such an ImageJ macro might look like, and attached it to this message. If you drag the attached file onto QuPath, it should automatically be opened in the ‘ImageJ macro runner’. You should then just need to do three things:
1 - Select the annotation (or TMA core) in which you want to run the command
2 - Select ‘Create annotation from ImageJ ROI’ in the macro runner, to ensure the results are sent back from ImageJ
3 - Press ‘Run'
You may notice that the AP staining doesn’t feature explicitly in the macro. In this case, I skipped color deconvolution and wrote the macro to threshold the result of subtracting the green from the red channel instead. That seemed to work too… at least in this example.
Note, you can use Send Region to ImageJ and run all/part of the macro directly in ImageJ to see what it is doing exactly.
Method #1a: Create annotations, then detection cells
Having done the above, it is then necessary to detect cells inside and outside of the keratin annotation.
Inside is straightforward; outside is not. If you use the ‘parent’ annotation, then when you run cell detection everything inside will be deleted and replaced by cells… including the keratin annotation itself. Therefore you need three annotations; one ‘parent’, containing one keratin annotation and one annotation for everything else. The detection should be applied only inside the ‘child’ annotations and not the ‘parent’.
One way to create the ‘everything else’ annotation is to select the keratin annotation and choose Objects -> Make inverse annotation. In case you want to script that, here’s how it looks in Groovy:
for (keratin in getAnnotationObjects().findAll { it.getName() == "Cytokeratin" }) {
makeInverseAnnotation(keratin).setName("Everything else")
}
fireHierarchyUpdate()
I took the liberty of adding a name for the new annotation, which clearly you might change/remove/improve. The resulting annotation is added to the hierarchy by default.
Now you can detect inside the two regions. The next problem occurs if you want to apply cell detection in batch. If you have a TMA you’re ok; just run the detection over all annotations. But if you don’t have a TMA and you run over all annotations, you risk the same problem as above; the keratin / everything else annotations being deleted whenever the cell detection is applied to the parent annotation containing them.
Again, that should be surmountable. Here’s one Groovy line to select all annotations, which in turn have a parent that is also an annotation.
selectObjects { p -> p.isAnnotation() && p.getParent().isAnnotation() }
Annotations that are not nested inside another annotation will not be selected. You should then be able to run cell detection. Of course, the predicate can be adjusted as needed (e.g. p.getName() == “ Cytokeratin” or similar).
Method #1b: Detect cells, then create annotations, then resolve the results
A disadvantage of 1a is that the cell detections inside & outside the keratin are independent; this means that cells around the boundary can be split/detected twice, or otherwise ‘expand’ into one another. I don’t know how much this would matter for your application.
To avoid this, you could instead do it in the other order; detect cells, then create the keratin annotation.
Again, this works… partially. After running the ImageJ macro to create the annotation, QuPath does not bother trying to ‘resolve the object hierarchy’, meaning it doesn’t look to see whether cells fall inside or outside of the new annotation. This can be computationally expensive, and isn’t always necessary - or even desirable - but it is clearly important in this case.
Fortunately, a short QuPath script can help by simply removing and re-adding the cells:
// Get the cells
cells = getDetectionObjects()
// Remove the cells...
removeObjects(cells, false)
// Add the cells again; the hierarchy will be resolved
// based on the cell centroid location
addObjects(cells)
// Good to print this, so we know the script didn't get stuck
print "Done!”
Method #2: Detect the cells, measure nearby
After all the above, there is potentially an easier way to get a result for this kind of image - one that avoids creating any kind of keratin annotation altogether.
The aim is to detect cells, then measure the AP staining in the area nearby; if this is above a particular threshold, then consider the cells to be positive.
Rather unfortunately, when you run cell detection on a brightfield image QuPath (currently) only automatically makes measurements for the first two channels; here, hematoxylin and DAB. You can supplement this with new measurements of any channel (including AP) with Analyze -> Calculate features -> Add intensity features (experimental), although no longer separately for each cell compartment.
Still, you have 3 choices:
1 - Measure AP within the full cell ROI
2 - Measure AP within a rectangle of fixed size centred on the cell (nucleus) ROI
3 - Measure AP within a circle of fixed size centred on the cell (nucleus) ROI
I would suggest either 1 or 3.
To restrict the AP measurement to a region close to the detected nucleus, run the cell detection with a small ‘Cell expansion’ parameter value. Then use Add intensity features with ‘Region’ set to ‘ROI’. Choose to measure ‘AP’ and (at least) ‘Mean’ and ‘Min & Max’.
You can now train a simple classifier to identify AP cells, or choose a fixed threshold based on one of your AP measurements (likely ‘Mean’ or ‘Max’). Keep in mind that the size of the cell nucleus will ‘dilute’ the mean value, but likely not influence the max. A suitable threshold could be chosen by trial and error, inspecting histograms under Measure -> Show detection measurements or by exploring Measure -> Show measurement manager.
If you decide to train a classifier, QuPath will take care of most things for you. If you want to write a script to classify based on one specific measurement and threshold, be aware that QuPath is extremely unforgiving regarding measurement names; what you type must be an exact match. This is confounded further by a horribly subtle issue that the Add intensity features command sometimes uses 2 spaces instead of 1… which is hard to spot when typing in measurement names.
Therefore it is best to get QuPath to print out the measurements it has - then copy and paste. Select a cell, and use this script to print a list of available measurements:
getSelectedObject().getMeasurementList().getMeasurementNames().each { print it }
In this case, I would like to use ROI: 2.00 µm per pixel: AP: Max
(note the two spaces before ‘Max’…. sigh….)
Now, a script like this would classify based on an AP measurement, then further subclassify by nuclear DAB staining:
getDetectionObjects().each { p ->
if (measurement(p, "ROI: 2.00 µm per pixel: AP: Max") > 0.5)
p.setPathClass(getPathClass("Tumor"))
else
p.setPathClass(getPathClass("Other"))
}
setCellIntensityClassifications("Nucleus: DAB OD mean", 0.15)
fireHierarchyUpdate()
The summary measurements for the parent annotation should contain the positive percentage overall, and also for each class.
Now one final gotcha. On the Mac I used for developing QuPath, this worked fine. I subsequently learned that there is an encoding issue on Windows, where the µ symbol is lost when the script is read back into QuPath… so you may end up needing to replace these values each time you reload your script. Alternatively, if you save your script using an editor that uses a different encoding then you might be able to get around this.
Conclusion
Hopefully that helps choose a method. If you’d like me to create screenshots for any of these parts to show how they look for me, just let me know.
I think all of the approaches are more inconvenient than they ought to be, and thinking them through there are lots of places where small QuPath improvements/bug fixes could help reduce the pain a lot. It’s not an application or type of data I ever had before, so I wasn’t able to design/test for this before. But at least it’s possible… in more ways than one :)
Pete