Detecting Purple chromogen - classifying cells based on green chromaticity

Hello,
I am trying to brightfield IHC analyze images with purple chromogen. The sample contains endogenous melanin pigment which is brown in color. I can “skew” the stain vectors to force the “DAB” channel to pickup the purple chromogen but it still detects the pigment especially when it is strong.

When playing with the different display channels, I found that the green chromaticity channel is able to distinguish the purple chromogen from melanin pigment. However, is there a way to display (or output) the intensity values in the annotations tab pertaining to the green chromaticity channel for the nucleus, cytoplasm and cell so that I can select the appropriate threshold to classify cells as positive? I have the following script to implement this workflow, but when I run it all the cells get labeled as negative. I just don’t know how to access the green chromaticity values for the individual cells.

setImageType('BRIGHTFIELD_OTHER');
clearDetections();
selectAnnotations()
// Stain vectors adjusted to detect purple chromogen in the "DAB" channel
setColorDeconvolutionStains('{"Name" : "Purple", "Stain 1" : "Hematoxylin", "Values 1" : "0.73753 0.6378 0.22194 ", "Stain 2" : "DAB", "Values 2" : "0.61469 0.68774 0.38622 ", "Background" : " 208 198 218 "}');
// Cell detection based on OD sum - gives better nuclear segmentation for my data
runPlugin('qupath.imagej.detect.cells.PositiveCellDetection', '{"detectionImageBrightfield": "Optical density sum",  "requestedPixelSizeMicrons": 0.5,  "backgroundRadiusMicrons": 8.0,  "medianRadiusMicrons": 0.0,  "sigmaMicrons": 1.5,  "minAreaMicrons": 10.0,  "maxAreaMicrons": 400.0,  "threshold": 0.1,  "maxBackground": 10.0,  "watershedPostProcess": true,  "excludeDAB": false,  "cellExpansionMicrons": 3.0,  "includeNuclei": true,  "smoothBoundaries": true,  "makeMeasurements": true,  "thresholdCompartment": "Cell: DAB OD mean",  "thresholdPositive1": 0.5,  "thresholdPositive2": 0.4,  "thresholdPositive3": 0.6000000000000001,  "singleThreshold": true}');


posCell = getPathClass('Positive');
negCell = getPathClass('Negative');
// Label cells as negative green chromaticity signal is greater than 0.22
for (cell in getCellObjects()) 
{
    res1 = measurement(cell, 'Nucleus: Green chromaticity mean')

    if (res1 <= 0.3)
        cell.setPathClass(posCell)
    else
        cell.setPathClass(negCell)
}

Currently, no, by default GUI, but as usual, yes by scripting.

It would help to put all of your code in coding format using the preformatted text option (for readability and copy-ability), but it would be even more helpful to have a sample image, or sample subimage (use the extract to imageJ option and save it there for whole slide images). From the sounds of it, I would likely create subcellular detections based on the purple channel, then remove any that were also had strong “green” values which the purple shouldn’t have. But that is just a guess without seeing the staining.

Note that you could probably calculate the “average chromaticity” per compartment yourself based off of the values you can get through Add intensity features. Each of the chromaticities are made up of the Red Green and Blue values listed there, and the equations are available online. I’ve never used them in QuPath myself, though I have found them useful in Visiopharm. Usually adjusting the color vectors, or using multiple sets of color vectors has been fine for QuPath.

**Note that this would NOT actually be the average of the chromaticity (which would be calculated for each pixel), but the average chromaticity using the means of all of the red green and blue pixel values. I think they should be the same… but I’m not 100% sure.

Examples of adding measurements in various ways here:
https://gist.github.com/Svidro/68dd668af64ad91b2f76022015dd8a45
Including using the Add intensity features to find nuclear and cytoplasmic means. In M9 the nuclear mean can be calculated as part of the Add intensity features.

Though which scripts work will depend on your version of QuPath, which isn’t listed.

Thanks for the feedback. Sorry for not pre-formatting the code. I am using m9. I agree that this will be relatively straightforward in Visiopharm. However, at present I only have access to QuPath and am trying to make the best out of what I have :slight_smile:

I tried to re-use the example code that I found in the link that you provided. I don’t think I fully understand the code, but I put some together and it ran without any errors. However, when I click show detection measurements, I still don’t see the Red, Blue and green intensity values for the nucleus.

Here is the code

setImageType('BRIGHTFIELD_OTHER');
clearDetections();
selectAnnotations()

import qupath.lib.objects.*

def addColors()
{
// Stain vectors adjusted to purple
setColorDeconvolutionStains('{"Name" : "Purple", "Stain 1" : "Hematoxylin", "Values 1" : "0.73753 0.6378 0.22194 ", "Stain 2" : "DAB", "Values 2" : "0.61469 0.68774 0.38622 ", "Background" : " 208 198 218 "}');
//Add R, G, B channel intensity features for all detections
runPlugin('qupath.lib.algorithms.IntensityFeaturesPlugin', '{"pixelSizeMicrons": 0.5,  "region": "Cell nucleus",  "tileSizeMicrons": 25.0,  "colorOD": false,  "colorStain1": false,  "colorStain2": false,  "colorStain3": false,  "colorRed": true,  "colorGreen": true,  "colorBlue": true,  "colorHue": false,  "colorSaturation": false,  "colorBrightness": false,  "doMean": false,  "doStdDev": false,  "doMinMax": false,  "doMedian": false,  "doHaralick": false,  "haralickDistance": 1,  "haralickBins": 32}');
}

// Run cell detection algorithm based on optical density sum
runPlugin('qupath.imagej.detect.cells.PositiveCellDetection', '{"detectionImageBrightfield": "Optical density sum",  "requestedPixelSizeMicrons": 0.5,  "backgroundRadiusMicrons": 8.0,  "medianRadiusMicrons": 0.0,  "sigmaMicrons": 1.5,  "minAreaMicrons": 10.0,  "maxAreaMicrons": 400.0,  "threshold": 0.1,  "maxBackground": 10.0,  "watershedPostProcess": true,  "excludeDAB": false,  "cellExpansionMicrons": 3.0,  "includeNuclei": true,  "smoothBoundaries": true,  "makeMeasurements": true,  "thresholdCompartment": "Cell: DAB OD mean",  "thresholdPositive1": 0.5,  "thresholdPositive2": 0.4,  "thresholdPositive3": 0.6000000000000001,  "singleThreshold": true}');


// Get cells & create temporary nucleus objects - storing link to cell in a map
def cells = getCellObjects()
def map = [:]
for (cell in cells) {
	def detection = new PathDetectionObject(cell.getNucleusROI())
	map[detection] = cell
}

// Get the nuclei as a list
def nuclei = map.keySet() as List
// and then select the nuclei
getCurrentHierarchy().getSelectionModel().setSelectedObjects(nuclei, null)

// Add as many sets of color deconvolution stains and Intensity features plugins as you want here
//This section ONLY adds measurements to the temporary nucleus objects, not the cell
addColors()

// Don't need selection now
clearSelectedObjects()

// Can update measurements generated for the nucleus to the parent cell's measurement list
for (nucleus in nuclei) {
    def cell = map[nucleus]
    def cellMeasurements = cell.getMeasurementList()
    for (key in nucleus.getMeasurementList().getMeasurementNames()) {
        double value = nucleus.getMeasurementList().getMeasurementValue(key)
        def listOfStrings = key.tokenize(':')     
        def baseValueName = listOfStrings[-2]+listOfStrings[-1]
        nuclearName = "Nuclear" + baseValueName
        cellMeasurements.putMeasurement(nuclearName, value)
    }
    //cellMeasurements.closeList()
    cellMeasurements.close()
}    

//I want to remove the original whole cell measurements which contain the mu symbol
// Not yet sure I will find the whole cell useful so not adding it back in yet.
def removalList = []

//Create whole cell measurements for all of the above stains
selectDetections()

//comment out this line if you want the whole cell measurements.
//removalList.each {removeMeasurements(qupath.lib.objects.PathCellObject, it)}

fireHierarchyUpdate()
println "Done!"

Any help would be greatly appreciated. Also, I have uploaded a sample image
purple chromogen sample image.tif (6.1 MB)

1 Like

Yeah, you can’t use the script directly like that, it’s for color vectors. You would have to modify it to pull out other measurements. They are mostly intended as examples of what can be done. Rather than go down the scripting route, however, I tried this:
*Assuming you have an annotation in place already.

setImageType('BRIGHTFIELD_OTHER');
setColorDeconvolutionStains('{"Name" : "H-DAB default", "Stain 1" : "Hematoxylin", "Values 1" : "0.65111 0.70119 0.29049 ", "Stain 2" : "Purple", "Values 2" : "0.52106 0.73609 0.43205 ", "Stain 3" : "DAB", "Values 3" : "0.45688 0.67883 0.57485 ", "Background" : " 255 255 255 "}');
selectAnnotations();
runPlugin('qupath.imagej.detect.cells.PositiveCellDetection', '{"detectionImageBrightfield": "Optical density sum",  "requestedPixelSizeMicrons": 0.5,  "backgroundRadiusMicrons": 0.0,  "medianRadiusMicrons": 0.0,  "sigmaMicrons": 1.5,  "minAreaMicrons": 10.0,  "maxAreaMicrons": 200.0,  "threshold": 0.6,  "maxBackground": 2.0,  "watershedPostProcess": true,  "cellExpansionMicrons": 1.0,  "includeNuclei": true,  "smoothBoundaries": true,  "makeMeasurements": true,  "thresholdCompartment": "Nucleus: DAB OD mean",  "thresholdPositive1": 0.3,  "thresholdPositive2": 0.4,  "thresholdPositive3": 0.6000000000000001,  "singleThreshold": true}');

You may want to tweak the variables, of course. But then you can get rid of the positive cells using one of the other scripts, like this:

positiveCells = getCellObjects().findAll{it.getPathClass() == getPathClass("Positive")}
removeObjects(positiveCells,true)

Maybe followed by setting the intensity classification.

@S_Ram Hope you don’t mind, I edited the first post to format the script :slight_smile:

Does this do something like what you need?

// Add intensity features (cells already detected)
selectCells();
runPlugin('qupath.lib.algorithms.IntensityFeaturesPlugin', '{"pixelSizeMicrons": 1.0,  "region": "Cell nucleus",  "tileSizeMicrons": 25.0,  "colorOD": false,  "colorStain1": false,  "colorStain2": false,  "colorStain3": false,  "colorRed": true,  "colorGreen": true,  "colorBlue": true,  "colorHue": false,  "colorSaturation": false,  "colorBrightness": false,  "doMean": true,  "doStdDev": false,  "doMinMax": false,  "doMedian": false,  "doHaralick": false,  "haralickDistance": 1,  "haralickBins": 32}');
runPlugin('qupath.lib.algorithms.IntensityFeaturesPlugin', '{"pixelSizeMicrons": 1.0,  "region": "ROI",  "tileSizeMicrons": 25.0,  "colorOD": false,  "colorStain1": false,  "colorStain2": false,  "colorStain3": false,  "colorRed": true,  "colorGreen": true,  "colorBlue": true,  "colorHue": false,  "colorSaturation": false,  "colorBrightness": false,  "doMean": true,  "doStdDev": false,  "doMinMax": false,  "doMedian": false,  "doHaralick": false,  "haralickDistance": 1,  "haralickBins": 32}');

// Add chromaticity measurements
def nucleusMeasurement = "Nucleus: 1.00 µm per pixel: %s: Mean"
def cellMeasurement = "ROI: 1.00 µm per pixel: %s: Mean"

for (cell in getCellObjects()) {
    def measurementList = cell.getMeasurementList()
    addGreenChromaticity(measurementList, nucleusMeasurement)
    addGreenChromaticity(measurementList, cellMeasurement)
    measurementList.close()
}
fireHierarchyUpdate()

def addGreenChromaticity(measurementList, measurement) {
    double r = measurementList.getMeasurementValue(String.format(measurement, "Red"))
    double g = measurementList.getMeasurementValue(String.format(measurement, "Green"))
    double b = measurementList.getMeasurementValue(String.format(measurement, "Blue"))
    def name = String.format(measurement, "Green chromaticity")
    measurementList.putMeasurement(name, g/Math.max(1, r+g+b))
}
3 Likes

Sorry for the delayed reply gentlemen.

@Research_Associate - thanks for that elegant solution! Really simple and clean… you added the purple as a 3rd chromogen and of course the math (deconvolution) should still work. I tested your code and it works well. Thanks for your help.

@Pete_Bankhead. Thanks for your suggestion as well. For some reason the green chromaticity values show up as NaN (see pic below). Not sure why. The formula is correct. I am guessing there is something else going on. Any ideas?

1 Like

Script works without NaNs for me on your sample image. Unsure.

  • also did a quick test on another 1.3mil cell data set with zero NaNs.
    ** in case it is something weird, what happens if you get rid of the Math.max and replace it with just (r+g+b)

There will be NaNs if:

  • Any of the measurements being extracted are missing (e.g. a typo/extra whitespace somewhere when extracting red, green or blue values)
  • Somehow 0/0 occurs

The Math.max part should avoid the second happening (so keep it!); my best guess is that it’s the first. You could try adding just r, g and b separately as a measurements to check if which (if any) are NaN.

1 Like

Yep, I was thinking the math thing might be due to linux for unknown reasons, but it looks like both names had a space in the wrong place. Nucleus has an extra one before the Nucleus, and ROI is missing one after the colon.

@petebankhead and @research_associate, thanks again for the feedback. I don’t think its the typos that @research_associate is pointing out, since the results for red, green and blue channels are correctly displayed (see pic below). I am not sure what it is. I am pasting below my entire script for reference. For now, I am ok with using @research_associate solution.

green chromaticity 2

setImageType('BRIGHTFIELD_OTHER');
clearDetections();
selectAnnotations()

// Detect the cells
runPlugin('qupath.imagej.detect.cells.PositiveCellDetection', '{"detectionImageBrightfield": "Optical density sum",  "requestedPixelSizeMicrons": 0.5,  "backgroundRadiusMicrons": 8.0,  "medianRadiusMicrons": 0.0,  "sigmaMicrons": 1.5,  "minAreaMicrons": 10.0,  "maxAreaMicrons": 400.0,  "threshold": 0.1,  "maxBackground": 10.0,  "watershedPostProcess": true,  "excludeDAB": false,  "cellExpansionMicrons": 3.0,  "includeNuclei": true,  "smoothBoundaries": true,  "makeMeasurements": true,  "thresholdCompartment": "Cell: DAB OD mean",  "thresholdPositive1": 0.5,  "thresholdPositive2": 0.4,  "thresholdPositive3": 0.6000000000000001,  "singleThreshold": true}');

// Add intensity features
selectCells();
runPlugin('qupath.lib.algorithms.IntensityFeaturesPlugin', '{"pixelSizeMicrons": 1.0,  "region": "Cell nucleus",  "tileSizeMicrons": 25.0,  "colorOD": false,  "colorStain1": false,  "colorStain2": false,  "colorStain3": false,  "colorRed": true,  "colorGreen": true,  "colorBlue": true,  "colorHue": false,  "colorSaturation": false,  "colorBrightness": false,  "doMean": true,  "doStdDev": false,  "doMinMax": false,  "doMedian": false,  "doHaralick": false,  "haralickDistance": 1,  "haralickBins": 32}');
runPlugin('qupath.lib.algorithms.IntensityFeaturesPlugin', '{"pixelSizeMicrons": 1.0,  "region": "ROI",  "tileSizeMicrons": 25.0,  "colorOD": false,  "colorStain1": false,  "colorStain2": false,  "colorStain3": false,  "colorRed": true,  "colorGreen": true,  "colorBlue": true,  "colorHue": false,  "colorSaturation": false,  "colorBrightness": false,  "doMean": true,  "doStdDev": false,  "doMinMax": false,  "doMedian": false,  "doHaralick": false,  "haralickDistance": 1,  "haralickBins": 32}');

// Add chromaticity measurements
def nucleusMeasurement = "Nucleus: 1.0 µm per pixel: %s: Mean"
def cellMeasurement = "ROI: 1.0 µm per pixel: %s: Mean"

for (cell in getCellObjects()) {
    def measurementList = cell.getMeasurementList()
    addGreenChromaticity(measurementList, nucleusMeasurement)
    addGreenChromaticity(measurementList, cellMeasurement)
    measurementList.close()
}

fireHierarchyUpdate()

def addGreenChromaticity(measurementList, measurement) {
    double r = measurementList.getMeasurementValue(String.format(measurement, "Red"))
    double g = measurementList.getMeasurementValue(String.format(measurement, "Green"))
    double b = measurementList.getMeasurementValue(String.format(measurement, "Blue"))
    def name = String.format(measurement, "Green chromaticity")
    //double gc = g/(r+g+b)
    measurementList.putMeasurement(name, g/Math.max(1, r+g+b))
    //measurementList.putMeasurement(name, gc)
}

@S_Ram There are missing zeros in the measurement names - with the following it works:

// Add chromaticity measurements
def nucleusMeasurement = "Nucleus: 1.00 µm per pixel: %s: Mean"
def cellMeasurement = "ROI: 1.00 µm per pixel: %s: Mean"
1 Like

Oops… my bad… very silly of me :frowning:

Adding the second zero did the trick. It now works. Thanks @petebankhead

2 Likes