Last active
March 12, 2026 07:40
-
-
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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