Skip to content

Instantly share code, notes, and snippets.

@romainGuiet
Last active March 12, 2026 07:40
Show Gist options
  • Select an option

  • Save romainGuiet/4ab8701812372b06e0c28a17ae47803c to your computer and use it in GitHub Desktop.

Select an option

Save romainGuiet/4ab8701812372b06e0c28a17ae47803c to your computer and use it in GitHub Desktop.
A groovy script for QuPath to quickly create a graph with channels of interest, to visualize pixel intensities in defined annotations.
import javafx.application.Application
import javafx.scene.Scene
import javafx.scene.control.*
import javafx.scene.layout.GridPane
import javafx.stage.Stage
import javafx.scene.chart.NumberAxis
import javafx.scene.chart.ScatterChart
import javafx.scene.chart.XYChart
import javafx.scene.paint.Color
import javafx.scene.shape.Circle
import javafx.scene.shape.Rectangle
import javafx.scene.shape.Polygon
import javafx.scene.layout.Pane
import javafx.scene.layout.VBox
import javafx.scene.layout.HBox
import java.util.regex.Pattern // necessary to have + in channel names
// Chart size parameters
def INITIAL_CHART_WIDTH = 600
def INITIAL_CHART_HEIGHT = 400
def MIN_CHART_SIZE = 200
def MAX_CHART_SIZE = 1200
// Function to create shapes dynamically based on the number of annotations
// Define shapes for different annotations
def shapes = [
new Circle(5),
new Rectangle(10, 10),
new Polygon(0, 5, 5, 0, 10, 5, 5, 10), // Diamond
new Polygon(0, 0, 10, 0, 5, 10), // Triangle
new Polygon(10, 0, 10, 10, 0, 5), // Triangle
new Polygon(5, 0, 10, 10, 0, 10), // Triangle
]
// GUI Layout
GridPane gridPane = new GridPane()
gridPane.setHgap(10)
gridPane.setVgap(10)
// Labels
Label tritc_stng_lbl = new Label("TRITC staining:")
Label tritc_color_lbl = new Label("TRITC color:")
Label tritc_min_lbl = new Label("TRITC min:")
Label tritc_max_lbl = new Label("TRITC max:")
Label cy5_stng_lbl = new Label("Cy5 staining:")
Label cy5_color_lbl = new Label("Cy5 color:")
Label cy5_min_lbl = new Label("Cy5 min:")
Label cy5_max_lbl = new Label("Cy5 max:")
Label dapi_stng_lbl = new Label("DAPI staining:")
Label dapi_color_lbl = new Label("DAPI color:")
Label dapi_min_lbl = new Label("DAPI min:")
Label dapi_max_lbl = new Label("DAPI max:")
Label choiceBox_label = new Label("Intensity measurement:")
// Chart size controls
Label chartSizeLabel = new Label("Chart Size:")
Label chartWidthLabel = new Label("Width:")
Label chartChannelNumberLabel = new Label("Channels:")
Label chartHeightLabel = new Label("Height:")
// TextFields
TextField tritc_field = new TextField("EMPTY")
TextField cy5_field = new TextField("EMPTY")
TextField dapi_field = new TextField("DAPI")
// ColorPickers
ColorPicker tritc_colorPicker = new ColorPicker(Color.LIME)
ColorPicker cy5_colorPicker = new ColorPicker(Color.MAGENTA)
ColorPicker dapi_colorPicker = new ColorPicker(Color.LIGHTBLUE)
// Integer Spinners
Spinner<Integer> tritc_min_spinner = new Spinner<>(0, Integer.MAX_VALUE, 0)
tritc_min_spinner.setEditable(true)
Spinner<Integer> tritc_max_spinner = new Spinner<>(0, Integer.MAX_VALUE, 3000)
tritc_max_spinner.setEditable(true)
Spinner<Integer> cy5_min_spinner = new Spinner<>(0, Integer.MAX_VALUE, 0)
cy5_min_spinner.setEditable(true)
Spinner<Integer> cy5_max_spinner = new Spinner<>(0, Integer.MAX_VALUE, 3000)
cy5_max_spinner.setEditable(true)
Spinner<Integer> dapi_min_spinner = new Spinner<>(100, Integer.MAX_VALUE, 100)
dapi_min_spinner.setEditable(true)
Spinner<Integer> dapi_max_spinner = new Spinner<>(100, Integer.MAX_VALUE, 2000)
dapi_max_spinner.setEditable(true)
// Chart size spinners
Spinner<Integer> chartWidthSpinner = new Spinner<>(MIN_CHART_SIZE, MAX_CHART_SIZE, INITIAL_CHART_WIDTH)
chartWidthSpinner.setEditable(true)
Spinner<Integer> chartChannelNumber = new Spinner<>(3, 60, 45)
chartChannelNumber.setEditable(true) // Fixed: was incorrectly setting chartWidthSpinner
Spinner<Integer> chartHeightSpinner = new Spinner<>(MIN_CHART_SIZE, MAX_CHART_SIZE, INITIAL_CHART_HEIGHT)
chartHeightSpinner.setEditable(true)
// CheckBoxes
CheckBox tritc_checkBox = new CheckBox("Display TRITC")
CheckBox cy5_checkBox = new CheckBox("Display Cy5")
CheckBox dapi_checkBox = new CheckBox("Display DAPI")
CheckBox elution_checkBox = new CheckBox("Elution ONLY")
// ChoiceBox for Intensity Measurement
ChoiceBox<String> choiceBox = new ChoiceBox<String>()
choiceBox.getItems().addAll("Mean", "Min", "Max", "Median")
choiceBox.setValue("Mean") // Default value
// ScatterChart
NumberAxis xAxis = new NumberAxis(0, chartChannelNumber.getValue(), 1)
NumberAxis yAxis = new NumberAxis()
ScatterChart<Number, Number> sc = new ScatterChart<Number, Number>(xAxis, yAxis)
xAxis.setLabel("Channels")
yAxis.setLabel("Intensity")
sc.setTitle("Intensity Comparison")
// Set initial chart size
sc.setPrefWidth(INITIAL_CHART_WIDTH)
sc.setPrefHeight(INITIAL_CHART_HEIGHT)
// Chart size update function
def updateChartSize = {
def width = chartWidthSpinner.getValue()
def height = chartHeightSpinner.getValue()
sc.setPrefWidth(width)
sc.setPrefHeight(height)
sc.setMinWidth(width)
sc.setMinHeight(height)
sc.setMaxWidth(width)
sc.setMaxHeight(height)
}
// Function to update X-axis limits based on channel number
def updateXAxisLimits = {
def channelCount = chartChannelNumber.getValue()
xAxis.setLowerBound(0)
xAxis.setUpperBound(channelCount)
xAxis.setTickUnit(1)
println("X-axis updated to range: 0 to ${channelCount}")
}
// Add listeners to chart size spinners
chartWidthSpinner.valueProperty().addListener { obs, oldVal, newVal ->
updateChartSize()
}
chartHeightSpinner.valueProperty().addListener { obs, oldVal, newVal ->
updateChartSize()
}
// Add listener to channel number spinner to update X-axis
chartChannelNumber.valueProperty().addListener { obs, oldVal, newVal ->
updateXAxisLimits()
}
// Resize Chart Button
Button resizeButton = new Button("Apply Size")
resizeButton.setOnAction {
updateChartSize()
updateXAxisLimits() // Also update X-axis when applying size
println("Chart resized to: ${chartWidthSpinner.getValue()} x ${chartHeightSpinner.getValue()}")
}
// Run Button
Button runButton = new Button("Update display")
runButton.setOnAction {
sc.getData().clear()
//println("Update button clicked!")
// Get inputs
String tritcStain = tritc_field.getText()
String cy5Stain = cy5_field.getText()
String dapiStain = dapi_field.getText()
Color tritc_color = tritc_colorPicker.getValue()
Color cy5_color = cy5_colorPicker.getValue()
Color dapi_color = dapi_colorPicker.getValue()
Integer tritc_min = tritc_min_spinner.getValue()
Integer tritc_max = tritc_max_spinner.getValue()
Integer cy5_min = cy5_min_spinner.getValue()
Integer cy5_max = cy5_max_spinner.getValue()
Integer dapi_min = dapi_min_spinner.getValue()
Integer dapi_max = dapi_max_spinner.getValue()
Boolean tritc_display = tritc_checkBox.isSelected()
Boolean cy5_display = cy5_checkBox.isSelected()
Boolean dapi_display = dapi_checkBox.isSelected()
Boolean elution_display = elution_checkBox.isSelected()
String int_choice = choiceBox.getValue()
// Stainings map
def stainings_map = [
[name: 'TRITC', altname: tritcStain, min: tritc_min, max: tritc_max, display: tritc_display, color: getColorRGB(tritc_color.getRed() * 255 as int, tritc_color.getGreen() * 255 as int, tritc_color.getBlue() * 255 as int)],
[name: 'Cy5', altname: cy5Stain, min: cy5_min, max: cy5_max, display: cy5_display, color: getColorRGB(cy5_color.getRed() * 255 as int, cy5_color.getGreen() * 255 as int, cy5_color.getBlue() * 255 as int)],
[name: 'DAPI', altname: dapiStain, min: dapi_min, max: dapi_max, display: dapi_display, color: getColorRGB(dapi_color.getRed() * 255 as int, dapi_color.getGreen() * 255 as int, dapi_color.getBlue() * 255 as int)]
]
// Logger
logger.info("Staining is: {}", stainings_map)
// Channel Names
def channelNames = getCurrentServer().getMetadata().getChannels().collect { c -> c.name }
def colors = new ArrayList()
def mins = []
def maxs = []
channelNames.each { channel ->
stainings_map.each { staining ->
if ((channel =~ /$staining.name/) || (channel =~ /$staining.altname/)) {
//print(channel + "," + staining.name)
colors.add(staining.color)
mins.add(staining.min)
maxs.add(staining.max)
}
}
}
setChannelColors(*colors)
[mins, maxs].transpose().eachWithIndex { mima, i -> setChannelDisplayRange(i, mima[0], mima[1]) }
def viewer = getCurrentViewer()
def display = viewer.getImageDisplay()
def available = display.availableChannels()
display_chs = available.findAll {
if (dapi_display && (it =~ /.*DAPI.*/) || (cy5_display && (it =~ /.*Cy5.*/)) || (cy5_display && (it =~ /.*$cy5Stain.*/)) || (tritc_display && (it =~ /.*TRITC.*/)) || (tritc_display && (it =~ /.*$tritcStain.*/))) return it
}
display.selectedChannels.setAll(display_chs)
viewer.repaintEntireImage()
// Make measurements and prepare plot
def annotations = getAnnotationObjects()
//annotations.eachWithIndex { annot, aIdx -> annot.setName("Annotation-" + aIdx) }
annotation_names = annotations.collect{it.getName()}
// Get PathClass names for legend
def annotation_pathclasses = annotations.collect { annotation ->
def pathClass = annotation.getPathClass()
return pathClass != null ? pathClass.getName() : "Unclassified"
}
selectAnnotations()
// here we make a channels_string dynamic to adapt the measurement to the channels number
def channels_string = ''
for (i in (1..available.size()) ) { channels_string += '"channel'+i+'":true,' }
runPlugin('qupath.lib.algorithms.IntensityFeaturesPlugin', '{"pixelSizeMicrons":0.56,"region":"ROI","tileSizeMicrons":25.0,'+channels_string+'"doMean":true,"doStdDev":true,"doMinMax":true,"doMedian":true,"doHaralick":false,"haralickMin":NaN,"haralickMax":NaN,"haralickDistance":1,"haralickBins":32}')
graph_diplayed_ch = display_chs.collect { it.getName() }
if (elution_display) graph_diplayed_ch = graph_diplayed_ch.findAll{ it =~ /.*El.*/ }
channelNames.eachWithIndex { ch, idx ->
if (graph_diplayed_ch.any { it =~ /${Pattern.quote(ch)}/ }) {
//println(ch + " is displayed, so it appears in graph")
XYChart.Series series = new XYChart.Series()
series.setName(ch)
annotations.eachWithIndex { annotation, annoIdx ->
//println("ROI: 0.56 µm per pixel: " + ch + ": " + idx)
meanInt = measurement(annotation, "ROI: 0.56 µm per pixel: " + ch + ": " + int_choice)
def data = new XYChart.Data(idx + 1, meanInt)
series.getData().add(data)
}
sc.getData().addAll(series)
}
}
// Update X-axis limits after adding data
updateXAxisLimits()
// Ensure nodes are set after the chart is rendered
Platform.runLater {
sc.getData().eachWithIndex { series, seriesIdx ->
series.getData().eachWithIndex { data, dataIdx ->
def shape = shapes[dataIdx % shapes.size()]
data.getNode().setShape(shape)
}
}
}
// Clear previous custom legend and add a new one
gridPane.children.removeIf { it instanceof VBox && it.id == "customLegend" }
// Create VBox for vertical legend layout
def customLegend = new VBox(5) // 5px spacing between items
customLegend.id = "customLegend"
// Add each annotation as a separate row with shape and PathClass
annotation_pathclasses.eachWithIndex { pathClassName, shapeIdx ->
if (shapeIdx < shapes.size()) {
// Create a copy of the shape for display
def legendShape = null
def originalShape = shapes[shapeIdx]
// Create appropriate shape copy based on type
if (originalShape instanceof Circle) {
legendShape = new Circle(((Circle)originalShape).getRadius())
legendShape.setFill(Color.BLACK)
} else if (originalShape instanceof Rectangle) {
def rect = (Rectangle)originalShape
legendShape = new Rectangle(rect.getWidth(), rect.getHeight())
legendShape.setFill(Color.BLACK)
} else if (originalShape instanceof Polygon) {
def poly = (Polygon)originalShape
legendShape = new Polygon()
legendShape.getPoints().addAll(poly.getPoints())
legendShape.setFill(Color.BLACK)
}
// Create HBox for this legend item (shape + label on same line)
def legendItem = new HBox(10) // 10px spacing between shape and text
legendItem.children.addAll(legendShape, new Label(pathClassName))
customLegend.children.add(legendItem)
}
}
gridPane.add(customLegend, 0, 15, 4, 1)
}
// Add components to the grid pane
gridPane.add(tritc_stng_lbl, 0, 0)
gridPane.add(tritc_field, 1, 0)
gridPane.add(tritc_color_lbl, 2, 0)
gridPane.add(tritc_colorPicker, 3, 0)
gridPane.add(tritc_min_lbl, 0, 1)
gridPane.add(tritc_min_spinner, 1, 1)
gridPane.add(tritc_max_lbl, 2, 1)
gridPane.add(tritc_max_spinner, 3, 1)
gridPane.add(tritc_checkBox, 0, 2)
gridPane.add(cy5_stng_lbl, 0, 3)
gridPane.add(cy5_field, 1, 3)
gridPane.add(cy5_color_lbl, 2, 3)
gridPane.add(cy5_colorPicker, 3, 3)
gridPane.add(cy5_min_lbl, 0, 4)
gridPane.add(cy5_min_spinner, 1, 4)
gridPane.add(cy5_max_lbl, 2, 4)
gridPane.add(cy5_max_spinner, 3, 4)
gridPane.add(cy5_checkBox, 0, 5)
gridPane.add(dapi_stng_lbl, 0, 6)
gridPane.add(dapi_field, 1, 6)
gridPane.add(dapi_color_lbl, 2, 6)
gridPane.add(dapi_colorPicker, 3, 6)
gridPane.add(dapi_min_lbl, 0, 7)
gridPane.add(dapi_min_spinner, 1, 7)
gridPane.add(dapi_max_lbl, 2, 7)
gridPane.add(dapi_max_spinner, 3, 7)
gridPane.add(dapi_checkBox, 0, 8)
gridPane.add(choiceBox_label, 0, 9)
gridPane.add(choiceBox, 1, 9)
gridPane.add(elution_checkBox, 2, 9)
gridPane.add(runButton, 3, 9)
// Chart size controls
gridPane.add(chartSizeLabel, 0, 10)
gridPane.add(chartWidthLabel, 0, 11)
gridPane.add(chartWidthSpinner, 1, 11)
gridPane.add(chartChannelNumberLabel, 2,11)
gridPane.add(chartChannelNumber, 3,11)
gridPane.add(chartHeightLabel, 0, 12)
gridPane.add(chartHeightSpinner, 1, 12)
gridPane.add(resizeButton, 2, 12)
// Add chart
gridPane.add(sc, 0, 13, 4, 1)
// Custom Legend Title - Changed title text
Label customLegendTitle = new Label("Annotations legends")
gridPane.add(customLegendTitle, 0, 14, 4, 1)
Platform.runLater {
def stage = new Stage()
stage.initOwner(QuPathGUI.getInstance().getStage())
stage.setScene(new Scene(gridPane))
stage.setTitle("Comet GUI")
stage.show()
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment