Created
October 4, 2019 07:40
-
-
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.
This file contains 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 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