Skip to content

Instantly share code, notes, and snippets.

@yau-lim
Last active March 19, 2024 12:17
Show Gist options
  • Save yau-lim/3ed5ae04e82e02939b419afc11dc918c to your computer and use it in GitHub Desktop.
Save yau-lim/3ed5ae04e82e02939b419afc11dc918c to your computer and use it in GitHub Desktop.
QuPath script to apply various auto-threshold methods from ImageJ on annotations. NOTE: This script is now being maintained and developed at https://github.com/yau-lim/QuPath-Auto-Threshold
/*
QuPath script to apply various auto-threshold methods from ImageJ on annotations.
This script has been optimised to apply auto-thresholding on the histogram from pixels strictly within the annotation ROI, instead of the bounding box.
Note that there are two different downsample parameters to set.
* "thresholdDownsample" determines the downsample factor for the image and pixels that are used to calculate the threshold value from the histogram. This is useful to adjust if you have a very large annotation, which you should use a larger downsample factor.
* "classifierDownsample" determines the downsample factor for the objects that are to be created. This is useful to adjust depending on the size/complexity of the resulting object ROI.
You can choose the desired output:
* "threshold value" for just saving the threshold value to manually check the results without creating objects.
* "preview" to show the thresholded area as an overlay (experimental feature).
* "measurement" to save the thresholded area as a measurement in the parent annotation.
* "annotation" to save the thresholded area as an annotation, making use of the classifier object options.
* "detection" to save the thresholded area as a detection, making use of the classifier object options.
@author Yau Mun Lim @yau-lim (2024)
*/
import qupath.lib.images.servers.TransformedServerBuilder
import qupath.lib.roi.interfaces.ROI
import qupath.imagej.tools.IJTools
import qupath.lib.images.PathImage
import qupath.lib.regions.RegionRequest
import ij.ImagePlus
import ij.process.ImageProcessor
import qupath.opencv.ml.pixel.PixelClassifiers
import qupath.lib.gui.viewer.OverlayOptions
import qupath.lib.gui.viewer.RegionFilter
import qupath.lib.gui.viewer.overlays.PixelClassificationOverlay
import qupath.lib.images.servers.ColorTransforms.ColorTransform
import qupath.opencv.ops.ImageOp
import qupath.opencv.ops.ImageOps
/* PARAMETERS */
String channel = "DAB" // "HTX", "DAB", "Residual" for BF ; use channel name for FL ; "Average":Mean of all channels for BF/FL
double thresholdDownsample = 8 // 1:Full, 2:Very high, 4:High, 8:Moderate, 16:Low, 32:Very low, 64:Extremely low
String thresholdMethod = "Triangle" // "Default", "Huang", "Intermodes", "IsoData", "IJ_IsoData", "Li", "MaxEntropy", "Mean", "MinError", "Minimum", "Moments", "Otsu", "Percentile", "RenyiEntropy", "Shanbhag", "Triangle", "Yen"
boolean darkBackground = false // Adapt threshold method for dark backgrounds
def thresholdFloor = null // Set a threshold floor value in case auto threshold is too low. Set null to disable
String output = "preview" // "annotation", "detection", "measurement", "preview", "threshold value"
// Reset preview overlay with "getQuPath().getViewer().resetCustomPixelLayerOverlay()"
double classifierDownsample = 8 // 1:Full, 2:Very high, 4:High, 8:Moderate, 16:Low, 32:Very low, 64:Extremely low
double classifierGaussianSigma = 0.5 // Strength of gaussian blurring for pixel classifier (not used in calculation of threshold)
String classBelow = null // null or "Class Name"; use this for positive "Average" channel on brightfield
String classAbove = "Positive" // null or "Class Name"; use this for positive deconvoluted or fluorescence channels
/* Create object parameters */
double minArea = 0 // Minimum area for annotations to be created
double minHoleArea = 0 // Minimum area for holes in annotations to be created
String classifierObjectOptions = "" // "SPLIT,DELETE_EXISTING,INCLUDE_IGNORED,SELECT_NEW"
def annotations = getSelectedObjects().findAll{it.getPathClass() != getPathClass("Ignore*")}
if (annotations) {
annotations.forEach{ anno ->
autoThreshold(anno, channel, thresholdDownsample, thresholdMethod, darkBackground, thresholdFloor, output, classifierDownsample, classifierGaussianSigma, classBelow, classAbove, minArea, minHoleArea, classifierObjectOptions)
}
} else {
logger.warn("No annotations selected.")
}
/* FUNCTIONS */
def autoThreshold(annotation, channel, thresholdDownsample, thresholdMethod, darkBackground, thresholdFloor, output, classifierDownsample, classifierGaussianSigma, classBelow, classAbove, minArea, minHoleArea, classifierObjectOptions) {
def qupath = getQuPath()
def imageData = getCurrentImageData()
def imageType = imageData.getImageType()
def server = imageData.getServer()
def cal = server.getPixelCalibration()
def classifierChannel
if (imageType.toString().contains("Brightfield")) {
def stains = imageData.getColorDeconvolutionStains()
if (channel == "HTX") {
server = new TransformedServerBuilder(server).deconvolveStains(stains, 1).build()
classifierChannel = ColorTransforms.createColorDeconvolvedChannel(stains, 1)
} else if (channel == "DAB") {
server = new TransformedServerBuilder(server).deconvolveStains(stains, 2).build()
classifierChannel = ColorTransforms.createColorDeconvolvedChannel(stains, 2)
} else if (channel == "Residual") {
server = new TransformedServerBuilder(server).deconvolveStains(stains, 3).build()
classifierChannel = ColorTransforms.createColorDeconvolvedChannel(stains, 3)
} else if (channel == "Average") {
server = new TransformedServerBuilder(server).averageChannelProject().build()
classifierChannel = ColorTransforms.createMeanChannelTransform()
}
} else if (imageType.toString() == "Fluorescence") {
if (channel == "Average") {
server = new TransformedServerBuilder(server).averageChannelProject().build()
classifierChannel = ColorTransforms.createMeanChannelTransform()
} else {
server = new TransformedServerBuilder(server).extractChannels(channel).build()
classifierChannel = ColorTransforms.createChannelExtractor(channel)
}
} else {
logger.error("Current image type not compatible with auto threshold.")
return
}
// Determine threshold value
logger.info("Calculating threshold value using ${thresholdMethod} method on ${annotation}")
ROI pathROI = annotation.getROI() // Get QuPath ROI
PathImage pathImage = IJTools.convertToImagePlus(server, RegionRequest.createInstance(server.getPath(), thresholdDownsample, pathROI)) // Get PathImage within bounding box of annotation
def ijRoi = IJTools.convertToIJRoi(pathROI, pathImage) // Convert QuPath ROI into ImageJ ROI
ImagePlus imagePlus = pathImage.getImage() // Convert PathImage into ImagePlus
ImageProcessor ip = imagePlus.getProcessor() // Get ImageProcessor from ImagePlus
ip.setRoi(ijRoi) // Add ImageJ ROI to the ImageProcessor to limit the histogram to within the ROI only
// Apply the selected algorithm
def validThresholds = ["Default", "Huang", "Intermodes", "IsoData", "IJ_IsoData", "Li", "MaxEntropy", "Mean", "MinError", "Minimum", "Moments", "Otsu", "Percentile", "RenyiEntropy", "Shanbhag", "Triangle", "Yen"]
if (thresholdMethod in validThresholds){
if (darkBackground) {
ip.setAutoThreshold("${thresholdMethod} dark")
} else {
ip.setAutoThreshold("${thresholdMethod}")
}
} else {
logger.error("Invalid auto-threshold method")
return
}
double thresholdValue = ip.getMaxThreshold()
if (thresholdValue != null && thresholdValue < thresholdFloor) {
thresholdValue = thresholdFloor
}
// If specified output is "threshold value, return threshold value in annotation measurements
if (output == "threshold value") {
logger.info("${thresholdMethod} threshold value: ${thresholdValue}")
annotation.measurements.put("${thresholdMethod} threshold value", thresholdValue)
return
}
// Define parameters for pixel classifier
def resolution = cal.createScaledInstance(classifierDownsample, classifierDownsample)
List<ImageOp> ops = new ArrayList<>()
ops.add(ImageOps.Filters.gaussianBlur(classifierGaussianSigma))
ops.add(ImageOps.Threshold.threshold(thresholdValue))
// Assign classification
def classificationBelow
if (classBelow instanceof PathClass) {
classificationBelow = classBelow
} else if (classBelow instanceof String) {
classificationBelow = getPathClass(classBelow)
} else if (classBelow == null) {
classificationBelow = classBelow
}
def classificationAbove
if (classAbove instanceof PathClass) {
classificationAbove = classAbove
} else if (classAbove instanceof String) {
classificationAbove = getPathClass(classAbove)
} else if (classAbove == null) {
classificationAbove = classAbove
}
Map<Integer, PathClass> classifications = new LinkedHashMap<>()
classifications.put(0, classificationBelow)
classifications.put(1, classificationAbove)
// Create pixel classifier
def op = ImageOps.Core.sequential(ops)
def transformer = ImageOps.buildImageDataOp(classifierChannel).appendOps(op)
def classifier = PixelClassifiers.createClassifier(
transformer,
resolution,
classifications
)
// Apply classifier
selectObjects(annotation)
if (output == "annotation") {
logger.info("Creating annotations in ${annotation} from ${thresholdMethod}: ${thresholdValue}")
if (classifierObjectOptions) {
classifierObjectOptions = classifierObjectOptions.split(',')
def allowedOptions = ["SPLIT", "DELETE_EXISTING", "INCLUDE_IGNORED", "SELECT_NEW"]
boolean checkValid = classifierObjectOptions.every{allowedOptions.contains(it)}
if (checkValid) {
createAnnotationsFromPixelClassifier(classifier, minArea, minHoleArea, classifierObjectOptions)
} else {
logger.warn("Invalid create annotation options")
return
}
} else {
createAnnotationsFromPixelClassifier(classifier, minArea, minHoleArea)
}
}
if (output == "detection") {
logger.info("Creating detections in ${annotation} from ${thresholdMethod}: ${thresholdValue}")
if (classifierObjectOptions) {
classifierObjectOptions = classifierObjectOptions.split(',')
def allowedOptions = ["SPLIT", "DELETE_EXISTING", "INCLUDE_IGNORED", "SELECT_NEW"]
boolean checkValid = classifierObjectOptions.every{allowedOptions.contains(it)}
if (checkValid) {
createDetectionsFromPixelClassifier(classifier, minArea, minHoleArea, classifierObjectOptions)
} else {
logger.warn("Invalid create detection options")
return
}
} else {
createDetectionsFromPixelClassifier(classifier, minArea, minHoleArea)
}
}
if (output == "measurement") {
logger.info("Measuring thresholded area in ${annotation} from ${thresholdMethod}: ${thresholdValue}")
def measurementID = "${thresholdMethod} threshold"
addPixelClassifierMeasurements(classifier, measurementID)
}
if (output == "preview") {
logger.info("Showing preview of ${annotation} with ${thresholdMethod}: ${thresholdValue}")
OverlayOptions overlayOption = qupath.getOverlayOptions()
overlayOption.setPixelClassificationRegionFilter(RegionFilter.StandardRegionFilters.ANY_ANNOTATIONS) // RegionFilter.StandardRegionFilters.ANY_ANNOTATIONS
PixelClassificationOverlay previewOverlay = PixelClassificationOverlay.create(overlayOption, classifier)
previewOverlay.setLivePrediction(true)
qupath.getViewer().setCustomPixelLayerOverlay(previewOverlay)
}
if (classificationBelow == null) {
annotation.measurements.put("${thresholdMethod}: ${classificationAbove.toString()} threshold value", thresholdValue)
}
if (classificationAbove == null) {
annotation.measurements.put("${thresholdMethod}: ${classificationBelow.toString()} threshold value", thresholdValue)
}
if (classificationBelow != null && classificationAbove != null) {
annotation.measurements.put("${thresholdMethod} threshold value", thresholdValue)
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment