Skip to content

Instantly share code, notes, and snippets.

@romainGuiet
Created October 4, 2019 07:40
Show Gist options
  • Save romainGuiet/03df6ebcac7d75cd29401d9f1dda8c01 to your computer and use it in GitHub Desktop.
Save romainGuiet/03df6ebcac7d75cd29401d9f1dda8c01 to your computer and use it in GitHub Desktop.
A groovy script to use automatic or fixed value threshold in QuPath, via ImageJ.
import qupath.lib.scripting.*
import qupath.lib.regions.*
import qupath.lib.objects.*
import qupath.lib.measurements.*
import qupath.lib.objects.hierarchy.PathObjectHierarchy
import qupath.lib.roi.*
import qupath.imagej.images.servers.*
import qupath.imagej.color.*
import qupath.imagej.gui.*
import qupath.imagej.objects.*
import qupath.imagej.objects.ROIConverterIJ.*
import qupath.imagej.objects.PathImagePlus
import ch.epfl.biop.qupath.utils.*
import ch.epfl.biop.qupath.GUIUtils.*
import ij.*
import ij.process.*
import ij.plugin.filter.*
import ij.plugin.filter.Filler
import ij.gui.Roi
import ij.gui.ShapeRoi
import java.awt.Color
// modified from :
// https://petebankhead.github.io/qupath/scripting/2018/03/08/script-imagej-to-qupath.html
// This scripts allow threholding of a color deconvoltuion channel ('DAB', 'Hematoxylin',...)
// As the thresholding is done in Imagej (which has an image size limitation),
// the script works on annotations and we recommend you to process
// regionshaving a reasonable size and thus to use a downsampled version of the image.
// For example, if the pixel size of your image is 0.34 micron, use a value of 1.38 micron
// for the variable 'requestedPixelSizeMicrons'.
//
// For the threshold, you can either use :
// - an automatic method that will determine it from the histogram
// - a fixed value
//
// The Annotation(s) to analyse without a name will be named 'Annotation-indexNbr'
//
// The region created from the threshold can be detection or annotation.
// Using the detection you can make use of the 'Fill detections' button
// and in combination with the 'Overlay Opacity' slider it will ease viusulisation of the results
// Measure the area and calculate Area Ratio (Stain/Parent)
//
//
// REQUIRES :
// QuPath 0.1.4-SNAPSHOT AND biop extension
// more details : https://c4science.ch/w/bioimaging_and_optics_platform_biop/image-processing/qupath/
// uncomment to get the ij gui
//IJExtension.getImageJInstance()
IJ.run("Close All", "")
// get the micron symbole
um = Utils.um
////////////////////////////////////////////////////////////////////////////
//
// PARAMETERS
//
// The stain that should be thresholded
stainName = 'DAB' // 'Hematoxylin' & 'DAB'
// Decicide if scritp uses an Automatic Threholding Methodd
useAutoTreshold = false
// thus defines the method used by ImageJ
thresholdMethod = AutoThresholder.Method.Moments // Default , Moments, Huang, Otsu ...
// ELSE
// defines the FIXED threshold value for the analysis
min_th = 0.125
max_th = 1.0 // color deconvolution set images values between 0-1
// create a Class for the thresholded region (allow different coloring than default)
createThClass = true
if ( createThClass){
if ( stainName == 'DAB'){
createPathClass( stainName, 255,128,0)
}else if ( stainName == 'Hematoxylin') {
createPathClass( stainName, 0,128,255 )
}
}
// Define the resolution of the image we want to work with
// A typical 'full-resolution, 40x' pixel size for a whole slide image is around 0.25 microns
// Therefore we will be requesting at a *much* lower resolution here
requestedPixelSizeMicrons = 1.38
// Sigma value for a Gaussian filter, used to reduce noise before thresholding
// the higher the value the smoother the region
sigmaMicrons =1
// Decide if the newly created region should be a detection or an annotation
// The Detections result table will contain the Area of the parent annotation
// and the ratio DAB-Area / Parent-Area
threshold_region_as_detection = true
export_result_of_detection = true
////////////////////////////////////////////////////////////////////////////
//
// RUNNER
//
// Access the relevant QuPath data structures
def imageData = QPEx.getCurrentImageData()
def hierarchy = imageData.getHierarchy()
def server = imageData.getServer()
//// Remove existing regions with stainName
//
// get all objects (as the threhold region can be either detection or annotation)
objectList = QPEx.getAllObjects()
// find the one that contains 'stainName'
def removable = objectList.findAll{ it.getName() =~ stainName }
// remove this list of object and update the Hierarchy
removeObjects( removable , false)
fireHierarchyUpdate()
annotationsList = getAnnotationObjects()
// To facilitate results exploration if the annotation(s) have no name
// set them to "Annotation-" with an incrementing index
annotationsList.eachWithIndex{ it , idx ->
if ( it.getName().toString() != null){
it.setName( "Annotation-"+idx)
}
}
// if there is a selected Annotation, process it
selectedObject = getSelectedObject()
if ( selectedObject != null){
print ("Process the selected Annotation")
doTheJob( selectedObject )
// otherwise process all the annotations
} else {
annotationsList = getAnnotationObjects()
if ( annotationsList == []) println ('no annotations to analyse')
else annotationsList.each{ doTheJob( it ) }
}
fireHierarchyUpdate()
// Choose the measurements you want to export with exportResults
def column_headers = [ "Name", "Class", "Parent", "Area-"+stainName+" "+um+"^2","Area-Parent "+um+"^2", 'Area Ratio ('+stainName+' / Parent)']
exportResults( export_result_of_detection , column_headers)
println("Jobs : DONE!")
////////////////////////////////////////////////////////////////////////////
//
// HELPER(s)
//
def createPathClass( pathClassName, R, G, B ){
// get the List of Class
available_class = getQuPath().getAvailablePathClasses()
newPathClass = getPathClass( pathClassName )
// check if the new class is already there ,and add it if needed
if (!( newPathClass in available_class )){
available_class.add( newPathClass )
}
// set the appropriate color
newPathClass.setColor(getColorRGB( R, G ,B))
}
def exportResults( export_result_of_detection, column_headers ){
// Settings to establish, the name of the folder and the name of the results file
def results_folder_name = 'results'
def results_file_name = 'results.txt'
// Get the micrometers name
def um = Utils.um
// Choose the measurements you want to export
// sendResultsToFile will automatically append the image and subimage names as the first columns
//defcolum_headers = [ "Name", "Class", "Parent", "Area-"+stainName+" "+um+"^2","Area-Parent "+um+"^2", 'Area Ratio ('+stainName+' / Parent)']
// Choose the objects for whom you want to have measurements
//def objects = getDetectionObjects()
// or
def objects = getDetectionObjects()
if ( !export_result_of_detection ){
objects = getAnnotationObjects()
}
// --------------------- Magic happens below --------------------- //
// Build the results directory
def res_directory = buildFilePath(PROJECT_BASE_DIR, results_folder_name )
// Build the results file
def res_file = new File( res_directory, results_file_name )
//print (res_file)
// Make sure the folder exists
mkdirs( res_directory )
// The method below creates a results table
// THE TABLE IS CREATED ONCE and THEN APPENDED
Utils.sendResultsToFile( column_headers, objects, res_file)
}
// The main function that does everything
def doTheJob( annot ){
// By default, lock the annotation
annot.setLocked(true)
// Access the relevant QuPath data structures
def imageData = QPEx.getCurrentImageData()
def hierarchy = imageData.getHierarchy()
def server = imageData.getServer()
setSelectedObject( annot )
// For color deconvolution, we need an 8-bit brightfield RGB image, and also stains to be set
// Check for these now, and return if we don't have what we need
def stains = imageData.getColorDeconvolutionStains()
if (!server.isRGB() || !imageData.isBrightfield() || stains == null) {
println 'An 8-bit RGB brightfield image is required!'
return
}
// Get the index of the stain we want, based on the specified name
int stainIndex = -1
for (int i = 0; i < 3; i++) {
// Stains are accessed as 1, 2, 3 (and not 0, 1, 2... sorry...)
if (stains.getStain(i+1).getName() == stainName) {
stainIndex = i
break
}
}
if (stainIndex < 0) {
println 'Could not find stain with name ' + stainName + '!'
return
}
// Convert requestedPixelSizeMicrons into a sensible downsample value
int downsample = requestedPixelSizeMicrons / server.getAveragedPixelSizeMicrons()
// Create a region request, either for the full image or the selected region
def region = RegionRequest.createInstance(server.getPath(), downsample, annot.getROI() )
// get the imp Pete's style (keep calib, origin but it doesn't keep ROI)
def imp_server = ImagePlusServerBuilder.ensureImagePlusWholeSlideServer(server)
def imp_pathImage = imp_server.readImagePlusRegion(region)
def imp = imp_pathImage.getImage()
//imp.show()
// get the imp BIOP style , to get the Annotation roi to clear imp outside of the roi
def annot_imp = GUIUtils.getImagePlus( annot, downsample, true, false)
//annot_imp.show()
def annot_roi = annot_imp.getRoi()
// Get the current ImageProcessor - it should be 8-bit RGB, so we can
// ask Groovy to make sure it's an ImageJ ColorProcessor, required for the next step
def ip = annot_imp.getProcessor() as ColorProcessor
// Apply color deconvolution - this gives a list of 3 stain images
def fpDeconvolved = ColorDeconvolutionIJ.colorDeconvolve(ip, stains)
//print( "stainIndex : "+stainIndex )
// Extract the stain ImageProcessor
def ipStain = fpDeconvolved[stainIndex]
// Convert blur sigma to pixels & apply if > 0
double sigmaPixels = sigmaMicrons / requestedPixelSizeMicrons
if (sigmaPixels > 0)
ipStain.blurGaussian(sigmaPixels)
ipStain.setRoi( annot_roi )
// here we clear outside
// need t oset the color for automatic method on non-rectangle roi
def color = new Color( 0.0,0.0,0.0,1)
ipStain.setColor( color )
ipStain.fillOutside( annot_roi )
def impCleared = new ImagePlus("cleared", ipStain)
//impCleared.show()
// Set the threshold
if ( useAutoTreshold ){
ipStain.setAutoThreshold(thresholdMethod, true)
}else{
ipStain.setThreshold( min_th, max_th, 0)
}
// Create a selection; this will use the current threshold
def tts = new ThresholdToSelection()
def roiIJ = tts.convert(ipStain)
if (roiIJ != null){
ipStain.setRoi(roiIJ)
// Convert ImageJ ROI to a QuPath ROI
// Here, the pathImage comes in handy because it has the calibration info we want
def roi = ROIConverterIJ.convertToPathROI(roiIJ, imp_pathImage)
// Create a QuPath detection
if ( threshold_region_as_detection){
threshold_region = new PathDetectionObject(roi)
// We'll add the Area values in the Detection measurements table
// on method is to get the stat from IJ
//def stats = ImageStatistics.getStatistics(ipStain, ImageStatistics.AREA, imp.getCalibration())
// the other is to get the Area in pixel and calibration
server_pixelSize = server.getPixelWidthMicrons()
th_area_val = threshold_region.getROI().getArea() * ( server_pixelSize * server_pixelSize)
annot_area_val = annot.getROI().getArea() * ( server_pixelSize * server_pixelSize)
ratio = th_area_val / annot_area_val
def measurementList = threshold_region.getMeasurementList()
//measurementList.putMeasurement('Area ('+stainName+')', stats.area)
measurementList.putMeasurement('Area-'+stainName+' '+um+'^2', th_area_val)
measurementList.putMeasurement('Area-Parent '+um+'^2', annot_area_val )
measurementList.putMeasurement('Area Ratio ('+stainName+' / Parent)', ratio )
measurementList.closeList()
// or Annotation
} else {
threshold_region = new PathAnnotationObject(roi)
threshold_region.setLocked(true)
}
// add the region within the hierarchy
imageData.getHierarchy().addPathObjectBelowParent( annot, threshold_region, false, true)
// set the name of threshold_region
def threshold_region_name = stainName
// after the parent name
def parent_name = threshold_region.getParent().getName().toString()
if ( parent_name!= null ) threshold_region_name = parent_name+"-"+stainName
threshold_region.setName( threshold_region_name )
// Optionnal, also set as a Class
if ( createThClass ) threshold_region.setPathClass( getPathClass( stainName ) )
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment