Make "overall" annotation based on the presence/absence of detections

Hi! I have detected and classified tumor cells and want to make an annotation around the “general” tumor area based on the presence/absence of tumor detections (and a 150 um smaller annotation for excluding “edge” tumor cells). Is there any way to make an annotation from Smoothed 100 nearby detection count information in the detections? So far I only have an annotation from a pixel classifier thresholder (see below), but should ideally have an annotation around the tumor area. I have > 100 WSIs so I have to do it by scripting/batch processing.
I’ve tried convert detections to annotations, then expand, but this is too heavy to process. I’ve thought about making annotation tiles, then transfering classifications from detections to the tiles, but maybe there is a better strategy.

Tumor detections in black which I want to create annotation around similar to the already present tissue pixel thresholder I have (green, negatively expanded 150 um in pink):

Somewhere in Pete’s gist there was a script to assign each pixel to an annotation based on the nearest cell classification. That is the closest I can think of to something like this.

I would probably approach it by creating a second thresholder or pixel classifier to run inside the first, though.

1 Like

This should do something relevant:

/**
 * QuPath script to create annotations where there is a high density of detections.
 *
 * @author Pete Bankhead
 */

import ij.plugin.filter.EDM
import ij.plugin.filter.RankFilters
import ij.process.*
import qupath.lib.plugins.parameters.ParameterList

import java.awt.Color

double pixelSizeMicrons = 20.0
double radiusMicrons = 100.0
int minCells = 20
boolean tumorOnly = false


def params = new ParameterList()
        .addDoubleParameter("radiusMicrons", "Radius", radiusMicrons, GeneralTools.micrometerSymbol(), "Choose radius of hotspot")
        .addIntParameter("minCells", "Minimum cell count", minCells, "", "Minimum number of cells in hotspot")

if (!Dialogs.showParameterDialog("Parameters", params))
    return

radiusMicrons = params.getDoubleParameterValue("radiusMicrons")
minCells = params.getIntParameterValue("minCells")

def imageData = getCurrentImageData()
def server = imageData.getServer()
def detections = getDetectionObjects()

double downsample = pixelSizeMicrons / server.getPixelCalibration().getAveragedPixelSizeMicrons()
int w = Math.ceil(server.getWidth() / downsample)
int h = Math.ceil(server.getHeight() / downsample)


// Create centroid map
def fpCounts = new FloatProcessor(w, h)
for (detection in detections) {
    def roi = PathObjectTools.getROI(detection, true)
    if (roi.isEmpty())
        continue
    int x = (roi.getCentroidX() / downsample) as int
    int y = (roi.getCentroidY() / downsample) as int
    fpCounts.setf(x, y, fpCounts.getf(x, y) + 1f as float)
}

// Compute sum of filter elements
def rf = new RankFilters()
double radius = radiusMicrons / pixelSizeMicrons
int dim = Math.ceil(radius * 2 + 5)
def fpTemp = new FloatProcessor(dim, dim)
fpTemp.setf(dim/2 as int, dim/2 as int, radius * radius as float)
rf.rank(fpTemp, radius, RankFilters.MEAN)
def pixels = fpTemp.getPixels() as float[]
double n = Arrays.stream(pixels).filter({f -> f > 0}).count()

// Compute sums
rf.rank(fpCounts, radius, RankFilters.MEAN)
fpCounts.multiply(n)

// Threshold
fpCounts.setThreshold(minCells, Double.POSITIVE_INFINITY, ImageProcessor.NO_LUT_UPDATE)
def roiIJ = new ij.plugin.filter.ThresholdToSelection().convert(fpCounts)
if (roiIJ == null) {
    println 'Nothing detected!'
    return
}    
def roi = IJTools.convertToROI(roiIJ, 0, 0, downsample, ImagePlane.getDefaultPlane())
def hotspot = PathObjects.createAnnotationObject(roi)
addObject(hotspot)

It’s adapted from the script I wrote here: Find highest staining region of a slide - #7 by petebankhead

It is a bit tricky to tune, since it’s necessary to make decisions about what kind of cell density is required. But hopefully it helps.

3 Likes

Fantastic - works brilliantly! Thank you so much, Pete.

2 Likes

Hi again Pete! Trying to run the script in batch mode, but get the dialogues activated also when running in batch mode. Any way to remove the diaglogue and just use the parameters spesified in the script?

Just removing these lines should do it:

2 Likes

Thanks, Pete - it worked. Is there any way to have the script only consider detections of a given class (e.g. ‘Tumor’) and ignore the other detections? I see there is a line “boolean tumorOnly = false” in the beginning, but nothing happens when I change it to “boolean tumorOnly = true”.

I think

boolean tumorOnly = false

may just be a demonstration that an earlier version of the script had a different purpose…

You can try replacing

def detections = getDetectionObjects()

with something like this

def detections = getDetectionObjects().findAll {it.getPathClass() == getPathClass('Tumor')}

or, if your tumor cells are subclassified by intensity,

def detections = getDetectionObjects().findAll {it.getPathClass()?.getBaseClass() == getPathClass('Tumor')}
1 Like

Worked like a charm:) If I had only looked a bit further down in the script and not got stuck on the “boolean” line, I should have figured this out myself and not bothered you with it - thank you! This script seems really useful in so many different ways.