And here’s the code as promised. It creates a toggle button in the toolbar, which can be clicked and unclicked to show or hide the cell density map. Since the calculation is quite fast, I didn’t try to optimise it further.
As per @Research_Associate suggestion, objectClass
really should be user input, to allow checking the density of things other than positive cells (see his example above). So if you right click on the button, a context menu lets you change the detection class and colormap on the fly. Classes are updated each time the density map button is right-clicked to take into account any newly created classes:

If the selected object class is a derived class (e.g. Tumor:Positive), the cells displayed in the map only comprise the parent detections for the selected object class (Tumor:Positive and Tumor Negative).
NOTE For the detection classes, do let me know if this is how you expect it to work. All the colormaps work, but for Jet. No idea what’s going on here…
In the meantime, I will drop this on our unsuspecting colleagues and gather some feedback. Let me know what you think and thanks again for all the help 
// A unique identifier to tag the items we will add to the toolbar
def btnId = "densitymap"
// This is the base64 encoded 32x32 GIF file
String imgString = "R0lGODlhIAAgAIQAMRQOUCWOfZS+NJ/KXPwC/FmLnxRVbKDLu05afUyuZJWOoRBydNDS0ZexvDCidlSulNbKH4x6hPn39BkzadK3t1V0lkymZDw2bHK9pd/UYlqeoy9zhK7CGayWpGy5ajRWeiH5BAEAAAQALAAAAAAgACAABAX+4FYEwaNtm7GM2qIFDvbMQaFNFKHvyLaQGEalgNo0NJpHzGLBIBuA3E4XQQU0BQa2smgwMDGMxeNxBEQUyVSnqFQ2yMCx0KiTHgdHwjM7NCgIawQUFwpEBSMBPy0PATIOeTFHClJrABOIC0aKchpgMAceCWYBFR8RggQRFQgVDUQoKi8we3t5Z5QNgh0frQUGH24jQTF6B8ceZxKnghQArRWGb1oqJLQD2GZ+HR2CF6zBbm4NBtaOGAIQHB5OGhWVO0IaBsAfExUncG8YCQMQ6mXOdFvTAUG+D8EKfNjgaoMCDQ78/eMwAEMpQQ3udRnSZsKHAgo+NMAQigOEDCf+DwT4kOrDPBEFWE240EBYkn7/MmQQgGEBPB2+XCDq5WsDDD0DTE70YOAnAQQGsIgYgiBoiVAekqrLcMCnIFMfEbGKKcJRqGMYPAgQUHGBAkEKALwZwnDIDz2k0tqq6GCgJQQx6TE06skBnhJK9vDD4HTQt5iwyl7xZM3wgTEOKqQaFK5FjQI/uqgs4aCYBTONVU2Ag8IGzBUvGkFyIMrBBzWpeoBWRKRBVyRgwFxm1yhQKgWYfHi+AgNiYVrsFhhPdSF55T4ySEA6ILzL5kGrXTQorcTaAssHEiRo0fS7XBGGySPRUKfYgRnu3m5WgMAljPINTFZCCRY9sNF3ziBERFhPV6igkmEkINKAfqk444ZRTlxBxAkkiHcAAyB999QQBiCCARwr/PLKAQVgIoQu3ylwQQeYNLBAVCIY4Ep/4kTDQAgAOw=="
ByteArrayInputStream inputStream = new ByteArrayInputStream(imgString.decodeBase64())
Image btnImg = new Image(inputStream,QuPathGUI.TOOLBAR_ICON_SIZE, QuPathGUI.TOOLBAR_ICON_SIZE, true, true)
preferredMapperName = PathPrefs.createPersistentPreference("measurementMapperLUT", "Viridis");
preferredClassName = PathPrefs.createTransientPreference("measurementClass", "Positive");
// Here we compute a measurement mapper
def DensityMap(viewer, objectClass='Positive', mapperName='Viridis', requestedPixelSizeMicrons=50, sigma=2, accuracy=0.01)
{
def imageData = viewer.getImageData()
if (imageData == null)
return
def server = imageData.getServer()
// Set the downsample directly (without using the requestedPixelSize) if you want 1.0 indicates the full resolution
double downsample = requestedPixelSizeMicrons / server.getPixelCalibration().getAveragedPixelSizeMicrons()
def request = RegionRequest.createInstance(server, downsample)
def imp = IJTools.convertToImagePlus(server, request).getImage()
// If we have an object of type "Stroma: positive" then it's a derived class and
// the total detections should be that of the parent class
def detections = getQuPath().getImageData().getHierarchy().getDetectionObjects()
pathClass = getPathClass(objectClass)
if (pathClass.getParentClass() == null) {
positiveDetections = detections.findAll {it.getPathClass() == getPathClass(objectClass)}
} else {
positiveDetections = detections.findAll {it.getPathClass() == pathClass}
def filteredDetections = detections.findAll {it.getPathClass().getParentClass() == pathClass.getParentClass()}
detections = filteredDetections
}
// Get the objects you want to count
// Potentially you can add filters for specific objects, e.g. to get only those with a 'Positive' classification
// TODO Do we have an annotation selected? we can limit to the cells inside that object (maybe)
// Create a counts image in ImageJ, where each pixel corresponds to the number of centroids at that pixel
int width = imp.getWidth()
int height = imp.getHeight()
def fp = new FloatProcessor(width, height)
for (detection in positiveDetections) {
// Get ROI for a detection this method gets the nucleus if we have a cell object (and the only ROI for anything else)
def roi = PathObjectTools.getROI(detection, true)
int x = (int)(roi.getCentroidX() / downsample)
int y = (int)(roi.getCentroidY() / downsample)
fp.setf(x, y, fp.getf(x, y) + 1 as float)
}
// Here we blur fp. Increase sigma for more blurring.
def g = new GaussianBlur()
g.blurGaussian(fp, sigma, sigma, accuracy)
for (detection in detections) {
// Get ROI for a detection this method gets the nucleus if we have a cell object (and the only ROI for anything else)
def roi = PathObjectTools.getROI(detection, true)
int x = (int)(roi.getCentroidX() / downsample)
int y = (int)(roi.getCentroidY() / downsample)
detection.getMeasurementList().putMeasurement(objectClass+' Density', fp.getf(x, y))
}
// Choose one of them
def colorMapper = MeasurementMapper.loadDefaultColorMaps().find {it.getName() == mapperName}
// Create a measurement mapper
def measurementMapper = new MeasurementMapper(colorMapper, objectClass+' Density', detections)
def minValue = fp.getMin()
def maxValue = fp.getMax()
measurementMapper.setDisplayMinValue(minValue)
measurementMapper.setDisplayMaxValue(maxValue)
// Show the images
//IJExtension.getImageJInstance()
//imp.show()
//new ImagePlus(imp.getTitle() + "-counts", fp).show()
return measurementMapper
}
// Remove all the additions made to the toolbar based on the id above
def RemoveToolItems(toolbar, id) {
while(1) {
hasElements = false
for (var tbItem : toolbar.getItems()) {
if (tbItem.getId() == id) {
toolbar.getItems().remove(tbItem)
hasElements = true
break
}
}
if (!hasElements) break
}
}
// Create a Submenu from entries and handle the actions
def MakeSubMenu(itemNames, selectedName) {
def menuItems = []
itemNames.each{
CheckMenuItem item = new CheckMenuItem(it)
menuItems << item
if (it == selectedName)
item.setSelected(true)
else
item.setSelected(false)
item.setOnAction( event -> {
def viewer = gui.getViewer()
def overlayOptions = viewer.getOverlayOptions()
if (overlayOptions == null)
return
// Here we retrieve the preferred option stored as userdata in the (sub)menu entry
def preferredOption = event.getTarget().getParentMenu().getUserData()
def itemString = event.getSource().getText()
// We check all the items one by one to find which one was selected.
// It gets a tick and is used to store the entry name into the preferred option
// Tick is removed from the other entries
menuItems.each{
if (it.getText() == itemString) {
it.setSelected(true)
preferredOption.set(itemString)
// User selected an option, so let's toggle the map
// Remove if you don't want this behaviour
btnCustom.setSelected(true)
if (btnCustom.isSelected()) {
def className = preferredClassName.get()
def mapperName = preferredMapperName.get()
measurementMapper = DensityMap(viewer, className, mapperName, 50, 2, 0.01)
// Show the measurement mapper in the current viewer
overlayOptions.setMeasurementMapper(measurementMapper)
overlayOptions.setFillDetections(true)
overlayOptions.setOpacity(0.5)
}
} else
it.setSelected(false)
}
})
}
return menuItems
}
Platform.runLater {
gui = QuPathGUI.getInstance()
toolbar = gui.getToolBar()
// First we remove the items already in place
RemoveToolItems(toolbar,btnId)
// Reset the display
def viewer = gui.getViewer()
def overlayOptions = viewer.getOverlayOptions()
if (overlayOptions == null)
return
//Reset the annotations
overlayOptions.resetMeasurementMapper()
overlayOptions.setFillDetections(false)
overlayOptions.setOpacity(1)
// Here we add a separator
sepCustom = new Separator(Orientation.VERTICAL)
sepCustom.setId(btnId)
toolbar.getItems().add(sepCustom)
// Here we add a toggle button
btnCustom = new ToggleButton()
btnCustom.setId(btnId)
toolbar.getItems().add(btnCustom)
// The button is given an icon encoded as base64 above
ImageView imageView = new ImageView(btnImg)
btnCustom.setGraphic(imageView)
btnCustom.setTooltip(new Tooltip("Overlay a density map"))
// Add the context menu with the colormaps (right click)
ContextMenu contextMenu = new ContextMenu()
Menu item1 = new Menu("Class")
Menu item2 = new Menu("Colormap")
//Here we set the preference paths as userdata
item1.setUserData(preferredClassName)
item2.setUserData(preferredMapperName)
contextMenu.getItems().addAll(item1, item2)
// Here we regenerate the context menu from getAvailablePathClasses()
// every time the context menu is clicked.
contextMenu.setOnShowing((event) -> {
def classNames = ["Positive","Negative"]+QuPathGUI.getInstance().getAvailablePathClasses()*.toString()
classNames.unique()
//Dialogs.showInfoNotification("Custom button", "menu event")
item1.getItems().clear();
def classItems = MakeSubMenu(classNames, preferredClassName.get())
item1.getItems().addAll(classItems)
});
def colorMappers = MeasurementMapper.loadColorMappers()*.getName()
def cmapItems = MakeSubMenu(colorMappers, preferredMapperName.get())
item2.getItems().addAll(cmapItems)
// Button context menu and click action
btnCustom.setContextMenu(contextMenu)
btnCustom.setOnAction {
viewer = gui.getViewer()
overlayOptions = viewer.getOverlayOptions()
if (overlayOptions == null)
return
if (btnCustom.isSelected()) {
def className = preferredClassName.get()
def mapperName = preferredMapperName.get()
measurementMapper = DensityMap(viewer, className, mapperName, 50, 2, 0.01)
// Show the measurement mapper in the current viewer
overlayOptions.setMeasurementMapper(measurementMapper)
overlayOptions.setFillDetections(true)
overlayOptions.setOpacity(0.5)
} else {
overlayOptions.resetMeasurementMapper()
overlayOptions.setFillDetections(false)
overlayOptions.setOpacity(1)
}
}
}
import javafx.application.Platform
import javafx.stage.Stage
import javafx.scene.Scene
import javafx.geometry.Insets
import javafx.geometry.Pos
import javafx.geometry.Orientation
import javafx.scene.control.*
import javafx.scene.layout.*
import javafx.scene.input.MouseEvent
import javafx.beans.value.ChangeListener
import javafx.scene.image.Image
import javafx.scene.image.ImageView
import ij.ImagePlus
import ij.process.FloatProcessor
import ij.plugin.filter.GaussianBlur
import qupath.imagej.gui.IJExtension
import qupath.imagej.tools.IJTools
import qupath.lib.objects.PathObjectTools
import qupath.lib.regions.RegionRequest
import qupath.lib.gui.tools.MeasurementMapper
import qupath.lib.gui.prefs.PathPrefs
import qupath.lib.gui.QuPathGUI
import static qupath.lib.gui.scripting.QPEx.*
EDIT1 Small edit to the code to work when no image is present but the button is clicked.
EDIT2 Added a context menu. Hopefully the class selection works as people expect it to.
EDIT3 Taking into account the parent class for derived classes. Now we need a better way to create the list of classes available in the image. Possibly based on promptToPopulateFromImage()
(these lines) rather than relying on getAvailablePathClasses()
like I’m doing now.