├── Examples ├── StarDist │ ├── requirements-stardist-cpu.txt │ ├── stardist_to_ij_rois.py │ └── PythonRoiRunner-StarDist.groovy └── CellPose │ ├── requirements-cellpose-gpu.txt │ ├── requirements-cellpose-cpu.txt │ ├── cellpose_to_ij_rois.py │ └── PythonRoiRunner-CellPose.groovy ├── Readme.md └── PythonRoiRunner.groovy /Examples/StarDist/requirements-stardist-cpu.txt: -------------------------------------------------------------------------------- 1 | tensorflow==1.14.0 2 | stardist==0.6.1 3 | -------------------------------------------------------------------------------- /Examples/CellPose/requirements-cellpose-gpu.txt: -------------------------------------------------------------------------------- 1 | numpy==1.20.0rc1 2 | opencv-python==4.5.1.48 3 | scikit-image==0.18.1 4 | scipy==1.6.0 5 | tifffile==2021.2.1 6 | tqdm==4.56.2 7 | cellpose==0.6.1 8 | stardist==0.6.1 9 | torch==1.7.1+cu101 -------------------------------------------------------------------------------- /Examples/CellPose/requirements-cellpose-cpu.txt: -------------------------------------------------------------------------------- 1 | numpy==1.20.0rc1 2 | numba>=0.43.1 3 | scipy 4 | torch>=1.6 5 | opencv-python-headless 6 | scikit-image==0.18.1 7 | pyqtgraph>=0.11.0rc0 8 | natsort 9 | google-cloud-storage 10 | tqdm 11 | tifffile 12 | cellpose==0.6.1 13 | stardist==0.6.1 -------------------------------------------------------------------------------- /Examples/StarDist/stardist_to_ij_rois.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | 3 | import argparse 4 | 5 | import sys 6 | 7 | from stardist.models import StarDist2D 8 | from stardist import export_imagej_rois 9 | 10 | from pathlib import Path 11 | from csbdeep.utils import normalize, normalize_mi_ma 12 | 13 | from tifffile import imread 14 | 15 | # Define arguments we want to use 16 | parser = argparse.ArgumentParser(description='Run StarDist and Recover IJ Rois') 17 | parser.add_argument('image_folder', type=Path, 18 | help='a folder containing tiff files') 19 | parser.add_argument('--min', type=float, 20 | help='minimum normalization value') 21 | parser.add_argument('--max', type=float, 22 | help='max normalization value') 23 | parser.add_argument('--model_folder', type=Path, 24 | help='path to where the StarDist models are stored') 25 | parser.add_argument('--model', type=str, 26 | help='StarDist model name') 27 | parser.add_argument('--prob_thresh', type=float, 28 | help='Probability threshold [0,1]') 29 | parser.add_argument('--nms_thresh', type=float, 30 | help='Nonmaximum suppression threshold [0,1]') 31 | args = parser.parse_args() 32 | 33 | 34 | # First argument should be image folder and second one should be model path. Third argument is the model name 35 | input_arguments = sys.argv 36 | print( 'Arguments: '+str(input_arguments) ) 37 | 38 | image_folder = args.image_folder 39 | model_folder = args.model_folder 40 | 41 | model_name = args.model 42 | 43 | prob_thresh= args.prob_thresh # If None, then StarDist will use the value stored in the model 44 | 45 | nms_thresh= args.nms_thresh # If None, then StarDist will use the value stored in the model 46 | 47 | is_norm_provided = False 48 | if( ( args.min is not None ) & ( args.max is not None ) ): 49 | min = args.min 50 | max = args.max 51 | is_norm_provided = True 52 | 53 | print( 'Models Folder:', model_folder ) 54 | print( ' Using Model Name:', model_name ) 55 | 56 | # Initialize the StarDist Model 57 | model = StarDist2D(None, name=model_name , basedir = model_folder ) 58 | 59 | # Read All Images 60 | image_files = sorted(image_folder.glob('*.tif')) 61 | 62 | # Normalize Images and predict 63 | for image_file in image_files: 64 | print( 'Processing Image: ', str( image_file.name ), '...' ) 65 | 66 | image = imread( str( image_file ) ) 67 | if ( is_norm_provided ): 68 | norm = normalize_mi_ma( image, min, max ) 69 | else: 70 | norm = normalize( image, 1,99.8, axis = (0,1) ) 71 | 72 | labels, polygons = model.predict_instances( norm, prob_thresh=prob_thresh, nms_thresh=nms_thresh ) 73 | 74 | # Create a 'rois' folder inside the same directory and make sure that 75 | # the images are saved with the same name as the image (with extension), with suffix `_rois.zip` 76 | 77 | roi_path = image_folder / 'rois' / (str( image_file.name )+'_rois') 78 | roi_path.parent.mkdir( exist_ok = True ) 79 | 80 | if ( polygons['coord'].shape[0] > 0 ): 81 | print( 'Exporting Polygons' ) 82 | export_imagej_rois( roi_path , polygons['coord'] ) 83 | 84 | print( 'Script Finished!' ) 85 | -------------------------------------------------------------------------------- /Examples/CellPose/cellpose_to_ij_rois.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import os 3 | from pathlib import Path 4 | from cellpose import models 5 | from tifffile import imread 6 | import cv2 as cv 7 | import numpy as np 8 | from csbdeep.utils import normalize, normalize_mi_ma 9 | from stardist import export_imagej_rois 10 | from scipy.ndimage import find_objects 11 | 12 | 13 | parser = argparse.ArgumentParser(description='Run CellPose and Recover IJ Rois') 14 | parser.add_argument('image_folder', type=Path, 15 | help='a folder containing tiff files') 16 | parser.add_argument('--min', type=float, 17 | help='minimum normalization value') 18 | parser.add_argument('--max', type=float, 19 | help='max normalization value') 20 | parser.add_argument('--diameter', type=float, 21 | help='average cell/nucleus diameter. Highly recommended to set for speed reasons') 22 | parser.add_argument('--model', type=str, 23 | help='cellpose model name') 24 | args = parser.parse_args() 25 | 26 | 27 | image_folder = args.image_folder 28 | model = 'cyto' 29 | diameter = args.diameter # If diameter is None, CellPose will try to estimate it. Slow 30 | 31 | is_norm_provided = False 32 | if( ( args.min is not None ) & ( args.max is not None ) ): 33 | min = args.min 34 | max = args.max 35 | is_norm_provided = True 36 | 37 | if( args.model is not None ): 38 | model = args.model 39 | 40 | # Check if we have GPU Available 41 | use_GPU = models.use_gpu() 42 | print( 'CellPose: GPU activated? %d'%use_GPU ) 43 | 44 | # Prepare CellPose Model 45 | model = models.Cellpose(gpu=use_GPU, model_type=model) 46 | 47 | # Read All Images 48 | image_files = sorted( image_folder.glob('*.tif') ) 49 | 50 | # Normalize Images and predict 51 | images = [] 52 | 53 | # Because CellPose can process multiple images in batch on its own, 54 | # we will be faster if we open and process all images though a single 55 | # cellpose call 56 | for image_file in image_files: 57 | print( 'Opening Image: ', str( image_file.name ), '...' ) 58 | 59 | image = imread( str( image_file ) ) 60 | if ( is_norm_provided ): 61 | print(' Normalizing from manual values') 62 | norm = normalize_mi_ma( image, min, max ) 63 | else: 64 | print(' Normalizing automatically') 65 | norm = normalize( image, 1,99.8, axis= (0,1) ) 66 | 67 | images.append(norm) 68 | 69 | # Run CellPose 70 | masks, flows, styles, diams = model.eval(images, diameter=diameter, flow_threshold=None, channels=None) 71 | 72 | for one_mask, image_file in zip(masks, image_files): 73 | polygons = [] 74 | 75 | slices = find_objects(one_mask.astype(int)) 76 | for i,si in enumerate(slices): 77 | if si is not None: 78 | coords = [[],[]] 79 | sr,sc = si 80 | mask = (one_mask[sr, sc] == (i+1)).astype(np.uint8) 81 | contours = cv.findContours(mask, cv.RETR_EXTERNAL, cv.CHAIN_APPROX_NONE) 82 | pvc, pvr = np.concatenate(contours[-2], axis=0).squeeze().T 83 | vr, vc = pvr + sr.start, pvc + sc.start 84 | coords[0] = vr 85 | coords[1] = vc 86 | 87 | #sub_polygons.append(coords) 88 | polygons.append(coords) 89 | 90 | roi_path = image_folder / 'rois' / (str(image_file.name)+'_rois') 91 | roi_path.parent.mkdir( exist_ok = True ) 92 | 93 | print( ' Exporting Polygons' ) 94 | export_imagej_rois( roi_path , [polygons] ) 95 | 96 | print( 'Script Finished!' ) 97 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | # Running Python Segmentation Pipelines from QuPath 2 | 3 | The goal of this project is to serve as a proof-of-concept that allows for the use of Python to predict segmentations from large images in QuPath. 4 | It was authored by [Olivier Burri](https://github.com/lacan) and [Romain Guiet](https://github.com/romainGuiet) for the BioImaging and Optics Platform (BIOP) at the Ecole Polytechnique Fédérale de Lausanne 5 | 6 | **If you use this project please make sure to cite this repository and the actual deep learning algorithm publication.** 7 | 8 | This script does the following: 9 | 1. Break down a QuPath region into manageable images 10 | 2. Save the images to a temporary folder 11 | 3. Run a python script (ideally from a virtual environment) that will output a series or ImageJ Roi .zip files 12 | 4. Reimport the ROIs into QuPath at the right places while solving overlaps 13 | 14 | ## Getting started: What you need 15 | 16 | To use this tool, you need: 17 | 1. An environment that runs your favorite deep learning algorithm 18 | 2. A python script that takes a directory of tiff images as argument and outputs ImageJ RoiSets as .zip files in a subdirectory called 'rois' 19 | 3. A customized version of the `PythonRoiRunner.groovy` file open in QuPath 20 | 21 | You will find two examples with StarDist and CellPose in the Examples folder of this repository. 22 | 23 | ## From an Image to a ROISet .zip file 24 | While there are other ways to reimport data into Fiji from labeled images, choosing to do this via zip files limits the size of the temporary folders we create 25 | and also shortens the time spent reading and writing intermediate files. 26 | 27 | Thanks to Uwe Schmidt, there is a `export_imagej_rois` method inside StarDist that can be adjusted to suit your needs. 28 | You can see an example in the `cellpose_to_ij_rois.py` file which converts a mask into ImageJ Rois with it 29 | 30 | ## Example: StarDist 31 | 32 | The python script `stardist-to-ij-rois.py` can be placed wherever you want, simply know its location. 33 | Suppose your StarDist environment is located at 34 | `D:\environments\stardist` 35 | And `stardist-to-ij-rois.py` is in 36 | `D:\qupath-groovy-python-runner\Examples\StarDist\stardist_to_ij_rois.py` 37 | 38 | Open the file `PythonRoiRunner-StarDist.groovy` to see how it was configured. Draw a region and hit run! 39 | You may need to adjust the `def preprocess...` step to make sure you export the right channel for StarDist on your data 40 | 41 | ### Important 42 | As this is StarDist as it is run natively from within Python, the model should be the raw StarDist model, not a zip file you obtain from exporting the model. 43 | 44 | ## Example: CellPose 45 | 46 | The python script `cellpose-to-ij-rois.py` can be placed wherever you want, simply know its location. 47 | Suppose your CellPose environment is configured and located at 48 | `D:\environments\cellpose` 49 | And `stardist-to-ij-rois.py` is in 50 | `D:\qupath-groovy-python-runner\Examples\CellPose\cellpose_to_ij_rois.py` 51 | 52 | Open the file `PythonRoiRunner-CellPose.groovy` to see how it was configured. Draw a region and hit run! 53 | You may need to adjust the `def preprocess...` step to make sure you export the right channel for CellPose on your data 54 | 55 | ## How do I create my StarDist or CellPose environment!? 56 | We do not cover how to install your virtual environment with GPU support here, but you do not necessarily need it. CPU is usually enough for inference. 57 | 58 | You can read the [CellPose](http://www.github.com/mouseland/cellpose) 59 | and [StarDist](https://github.com/mpicbg-csbd/stardist) GitHub pages to see how to best install that environment 60 | 61 | **NOTE: In the case of CellPose, you also need to install StarDist in the environment, as we make use of the `export_imagej_rois` method created by Uwe Schmidt to export polygons as ImageJ RoiSets.** 62 | 63 | ### Minimal Requirements and Setup for Windows 64 | Install Python 3.7 or 3.8 on your system and you can run the following commands to get the same environments as shown above 65 | 66 | #### Create a folder that will contain your environments 67 | ``` 68 | mkdir D:\environments 69 | ``` 70 | 71 | #### StarDist Environment 72 | ``` 73 | python -m venv D:\environments\stardist 74 | D:\environments\stardist\Scripts\activate 75 | python -m pip install --upgrade pip 76 | pip install -r D:\qupath-groovy-python-runner\Examples\StarDist\requirements-stardist-cpu.txt 77 | ``` 78 | 79 | ### CellPose Environment 80 | ``` 81 | python -m venv D:\environments\cellpose 82 | D:\environments\cellpose\Scripts\activate 83 | python -m pip install --upgrade pip 84 | pip install -r D:\qupath-groovy-python-runner\Examples\CellPose\requirements-cellpose-cpu.txt 85 | ``` 86 | 87 | ## NOTES 88 | 89 | ### Future Work 90 | The `PythonRoiRunner.groovy` will most likely be ported to a Java Class to use directly within QuPath, so that we do not need to copy the PythonRoiRunner class into each new script 91 | 92 | Creating a minimal PythonRoiRunner to run within Fiji (no tiling needed, just running the python code and getting the RoiSet back) is also planned, but help is always welcome! 93 | -------------------------------------------------------------------------------- /PythonRoiRunner.groovy: -------------------------------------------------------------------------------- 1 | // Here add your pre-configuration steps and initialize an instance of this script 2 | 3 | // The RoiRunner is defined below and nothing should be changed below, normally 4 | class PythonRoiRunner { 5 | File python_executable 6 | File python_script_file 7 | File image_directory 8 | File current_project_temp_dir 9 | int tile_size = 2048 10 | int overlap = 30 11 | int downsample = 1 12 | double cell_thickness = 0.0 13 | ImmutableDimension dims 14 | 15 | // Parameters that are unique to each python script as key value pairs where the key is the command line argument name 16 | def parameters = [:] 17 | 18 | public PythonRoiRunner( String virtualenv_directory_string, String python_script_file_string) { 19 | this.python_executable = new File( virtualenv_directory_string, "Scripts/python" ) 20 | this.python_script_file = new File( python_script_file_string ) 21 | logger.info("Script: {}", python_script_file) 22 | this.current_project_temp_dir = new File( Projects.getBaseDirectory( getProject() ), python_script_file.getName()+"_tmp" ) 23 | current_project_temp_dir.mkdirs() 24 | 25 | // This is needed when requesting tiles 26 | this.dims = new ImmutableDimension( tile_size, tile_size ) 27 | } 28 | 29 | PythonRoiRunner setOverlap(int overlap) { 30 | this.overlap = overlap 31 | return this 32 | } 33 | 34 | PythonRoiRunner setTileSize(int tile_size) { 35 | this.tile_size = tile_size 36 | return this 37 | } 38 | 39 | PythonRoiRunner setParameters( Map parameters ) { 40 | this.parameters = parameters 41 | return this 42 | } 43 | 44 | PythonRoiRunner makeCells( double cell_thickness ) { 45 | this.cell_thickness = cell_thickness 46 | return this 47 | } 48 | 49 | PythonRoiRunner setDownsample( int downsample ) { 50 | this.downsample = downsample 51 | return this 52 | } 53 | 54 | 55 | // This processes the given region, at the desired downsampling and with the chosen code for preprocessing 56 | // Code should take an ImagePlus as input argument and return the same ImagePlus 57 | void processRegion( PathObject region, Closure preprocess ) { 58 | 59 | // Prepare a temporary folder to work 60 | def temp_folder = Files.createTempDirectory( this.current_project_temp_dir.toPath(), "Temp" ); 61 | logger.info( "Storing Temporary images in {}", this.current_project_temp_dir ) 62 | 63 | // Use this as the data folder 64 | def data_folder = temp_folder.toFile() 65 | 66 | // We expect the Python script to store store the resulting RoiSets in 'rois' 67 | def roiset_folder = new File( data_folder, 'rois' ) 68 | 69 | // Make tiles as needed 70 | def regions = RoiTools.computeTiledROIs( region.getROI(), this.dims, this.dims, true, this.overlap ).collect { 71 | return new PathAnnotationObject( it ) 72 | } 73 | 74 | // Show the regions that are being processed 75 | region.addPathObjects( regions ) 76 | fireHierarchyUpdate() 77 | 78 | // Compute global min-max normalization values to use here on the full region. Downsample if the image is tiled 79 | def downsample_corr = 1.0 80 | if ( regions.size() > 1 ) { 81 | downsample_corr = 4.0 82 | } 83 | 84 | logger.info( "Computing Min and Max values for data normalization on {}x downsampled data", this.downsample * downsample_corr as int ) 85 | 86 | // Compute on the whole image 87 | def full_image = GUIUtils.getImagePlus( region, this.downsample * downsample_corr as int, false, false ) 88 | if( preprocess != null ) 89 | full_image = preprocess( full_image ) 90 | 91 | def min_max = getQuantileMinMax( full_image, 1.0, 99.8 ) 92 | 93 | logger.info( "Normalization: Min={}, Max={}", min_max["min"], min_max["max"] ) 94 | 95 | full_image.close() 96 | 97 | // Save all the regions to the data_folder, and keep their information for when we pick up the Rois later 98 | def region_metadata = regions.withIndex().collect { r, idx -> 99 | 100 | ImagePlus imp = GUIUtils.getImagePlus( r, this.downsample, false, false ) 101 | 102 | def cal = imp.getCalibration().clone() 103 | 104 | if( preprocess != null ) 105 | imp = preprocess( imp ) 106 | imp.setCalibration(cal) 107 | 108 | File image_file = new File( data_folder, "region" + IJ.pad( idx, 3 ) ) 109 | 110 | // Save the image 111 | IJ.saveAsTiff( imp, image_file.getAbsolutePath() ) 112 | logger.info( "Saved Image {}.tif", image_file.getName() ) 113 | 114 | imp.close() 115 | 116 | // Return a List with the image name, the region and the calibration ( To get the ROIs properly later ) 117 | return [name: image_file.getName(), region: r, calibration: cal] 118 | } 119 | 120 | // Call the script, this is the magic line 121 | runScript( data_folder, min_max ) 122 | 123 | // After this step, there should be ROIs in the 'rois' folder 124 | // Get ROI Set(s) and import 125 | def rm = RoiManager.getRoiManager() 126 | 127 | 128 | def all_detections = [] 129 | 130 | region_metadata.each { meta -> 131 | def current_name = meta.name 132 | def current_region = meta.region 133 | def cal = meta.calibration 134 | 135 | // The RoiSet should have the same name as the image, with 'rois.zip' appended to it 136 | def roi_file = new File( roiset_folder, current_name + ".tif_rois.zip" ) 137 | 138 | if ( roi_file.exists() ) { 139 | logger.info( "Image {}.tif had a RoiSet", current_name ) 140 | 141 | rm.reset() 142 | rm.runCommand( "Open", roi_file.getAbsolutePath() ) 143 | 144 | def rois = rm.getRoisAsArray() as List 145 | 146 | def detections = rois.collect { 147 | def roi = IJTools.convertToROI( it, cal, downsample, null ) 148 | if ( current_region.getROI().contains( roi.getCentroidX(), roi.getCentroidY() ) ) 149 | return new PathDetectionObject( roi ) 150 | return[] 151 | }.flatten() 152 | 153 | if ( this.cell_thickness > 0 ) { 154 | logger.info( "Creating Cells from {} Detections...", detections.size() ) 155 | def cell_detections = PathUtils.createCellObjects( current_region, detections, this.cell_radius, downsample ) 156 | all_detections.addAll( cell_detections ) 157 | 158 | } else { 159 | logger.info( "Adding {} detections", detections.size() ) 160 | all_detections.addAll( detections ) 161 | } 162 | } 163 | } 164 | 165 | if ( regions.size() > 1 ) { 166 | logger.info( "Removing overlapping objects" ) 167 | // Find the detections that may need to be removed 168 | 169 | def overlaping_detections = getOverlapingDetetections( regions, all_detections ) 170 | 171 | // Remove overlap and add the ones to keep again after 172 | 173 | 174 | // Do some filtering to avoid issues where there is overlap 175 | def removable_regions = getRoisToDetleteByOverlap( overlaping_detections, 40 ) 176 | 177 | // Remove 178 | all_detections.removeAll( removable_regions ) 179 | } 180 | 181 | 182 | region.addPathObjects( all_detections ) 183 | 184 | region.removePathObjects( regions ) 185 | 186 | fireHierarchyUpdate() 187 | 188 | temp_folder.toFile().deleteDir() 189 | 190 | logger.info( "Done" ) 191 | 192 | } 193 | 194 | def getOverlapingDetetections( def regions, def all_detections ) { 195 | 196 | // Get all overlap regions 197 | def overlap_regions = [] 198 | regions.each { r1 -> 199 | regions.each { r2 -> 200 | if ( r1 != r2 ) { 201 | // check overlap 202 | def merge = RoiTools.combineROIs( r1.getROI(), r2.getROI(), RoiTools.CombineOp.INTERSECT ) 203 | if ( !merge.isEmpty() ) { 204 | // Make into an annotation that represents the overlap 205 | overlap_regions.add( new PathAnnotationObject( merge ) ) 206 | } 207 | } 208 | } 209 | } 210 | 211 | // Combine all now 212 | setSelectedObject( null ) 213 | mergeAnnotations( overlap_regions ) 214 | def merged = getSelectedObject() 215 | if( merged != null ) removeObject( merged, false ) 216 | setSelectedObject( null ) 217 | 218 | // Find all annotations that are touching somehow this region, avoid shapes as they are slow 219 | def overlap_detections = all_detections.findAll { 220 | def roi = it.getROI() 221 | 222 | def x1 = roi.getCentroidX() 223 | def y1 = roi.getCentroidY() 224 | 225 | def x2 = roi.getBoundsX() 226 | def y2 = roi.getBoundsY() 227 | 228 | def x3 = roi.getBoundsX() + roi.getBoundsWidth() 229 | def y3 = roi.getBoundsY() 230 | 231 | def x4 = roi.getBoundsX() 232 | def y4 = roi.getBoundsY() + roi.getBoundsHeight() 233 | 234 | 235 | def x5 = roi.getBoundsX() + roi.getBoundsWidth() 236 | def y5 = roi.getBoundsY() + roi.getBoundsHeight() 237 | 238 | return merged.getROI().contains( x1, y1 ) || merged.getROI().contains( x2, y2 ) || merged.getROI().contains( x3, y3 ) || merged.getROI().contains( x4, y4 ) || merged.getROI().contains( x5, y5 ) 239 | } 240 | 241 | // From here get a hashmap of the regions when their bounding boxes match 242 | def temp_overlap_detections = overlap_detections.clone() 243 | 244 | def detections_to_check = [:] 245 | overlap_detections.each { det -> 246 | temp_overlap_detections.remove( det ) 247 | def det_candidates = temp_overlap_detections.collect { det1 -> 248 | if ( hasBBOverlap( det, det1 ) ) { 249 | return det1 250 | } 251 | return [] 252 | }.flatten() 253 | 254 | detections_to_check.put( det, det_candidates ) 255 | } 256 | 257 | logger.info( "There are {} detections that potentially overlap", detections_to_check.size() ) 258 | logger.info( "{}", detections_to_check ) 259 | return detections_to_check 260 | } 261 | 262 | boolean hasBBOverlap( def po1, def po2 ) { 263 | ROI r1 = po1.getROI() 264 | 265 | double r1_left = r1.getBoundsX() 266 | double r1_top = r1.getBoundsY() 267 | double r1_right = r1.getBoundsWidth() + r1_left 268 | double r1_bottom = r1.getBoundsHeight() + r1_top 269 | 270 | ROI r2 = po2.getROI() 271 | 272 | double r2_left = r2.getBoundsX() 273 | double r2_top = r2.getBoundsY() 274 | double r2_right = r2.getBoundsWidth() + r2_left 275 | double r2_bottom = r2.getBoundsHeight() + r2_top 276 | 277 | return !( r2_left > r1_right 278 | || r2_right < r1_left 279 | || r2_top > r1_bottom 280 | || r2_bottom < r1_top ) 281 | } 282 | 283 | // "percentOverlap" : compare the percent overlap of each roi.Areas, 284 | // and delete the roi with the largent percentage (most probably included within the other). 285 | def getRoisToDetleteByOverlap( def roiMap, def percent_overlap_lim ) { 286 | 287 | logger.info( "Overlap Filter: Overlap Limit {}%", percent_overlap_lim ) 288 | 289 | def roisToDelete = [] 290 | 291 | roiMap.each { rA, candidates -> 292 | candidates.each { rB -> 293 | def roiA = rA.getROI() 294 | def roiB = rB.getROI() 295 | 296 | def merge = RoiTools.combineROIs( roiA, roiB, RoiTools.CombineOp.INTERSECT ) 297 | 298 | if ( merge.isEmpty() ) return 299 | 300 | def roiA_ratio = merge.getArea() / roiA.getArea() * 100 301 | def roiB_ratio = merge.getArea() / roiB.getArea() * 100 302 | 303 | if ( ( roiA_ratio > percent_overlap_lim ) || ( roiB_ratio > percent_overlap_lim ) ) 304 | ( roiA_ratio < roiB_ratio ) ? roisToDelete.add( rB ) : roisToDelete.add( rA ) 305 | } 306 | } 307 | 308 | logger.info( "{} overlapping detections to be removed", roisToDelete.size() ) 309 | 310 | return roisToDelete 311 | } 312 | 313 | // Get get quantile values for normalization 314 | def getQuantileMinMax( ImagePlus image, double lower_q, double upper_q ) { 315 | logger.info( "Using {}% lower quantile and {}% upper quantile", lower_q, upper_q ) 316 | def proc = image.getProcessor().convertToFloatProcessor() 317 | def perc = new Percentile() 318 | 319 | def lower_val = perc.evaluate( proc.getPixels() as double[], lower_q ) 320 | def upper_val = perc.evaluate( proc.getPixels() as double[], upper_q ) 321 | 322 | return [min: lower_val, max: upper_val] 323 | } 324 | 325 | public void runScript( File data_folder, def min_max ) { 326 | logger.info( "Running Python Script" ) 327 | 328 | def sout = new StringBuilder() 329 | 330 | def parameters_strs = this.parameters.collect{ key, value -> return "--${key}=${value}" } 331 | 332 | def pb = new ProcessBuilder( this.python_executable.getAbsolutePath(), 333 | this.python_script_file.getAbsolutePath(), 334 | data_folder.getAbsolutePath(), 335 | '--min='+min_max['min'], 336 | '--max='+min_max['max'], 337 | *parameters_strs) 338 | .redirectErrorStream( true ) 339 | def process = pb.start() 340 | 341 | logger.info( "Started command: {}", pb.command().join(" ")) 342 | 343 | process.consumeProcessOutput( sout, sout ) 344 | 345 | // Show what is happening in the log 346 | while ( process.isAlive() ) { 347 | 348 | if ( sout.size() > 0 ) { 349 | logger.info( sout.toString() ) 350 | sout.setLength( 0 ) 351 | } 352 | 353 | sleep( 200 ) 354 | 355 | } 356 | 357 | logger.info( "Running Python Script Complete" ) 358 | } 359 | } 360 | 361 | 362 | // All the Stardist Magic is in the Class below 363 | // All imports 364 | import ch.epfl.biop.qupath.utils.* 365 | import ij.IJ 366 | import ij.ImagePlus 367 | import ij.measure.Calibration 368 | import ij.plugin.frame.RoiManager 369 | 370 | // To compute image normalization 371 | import org.apache.commons.math3.stat.descriptive.rank.Percentile 372 | 373 | // QuPath does not log standard output when declaring Groovy Classes, so we use the logger 374 | import org.slf4j.Logger 375 | import org.slf4j.LoggerFactory 376 | 377 | // ROI <> Roi conversion tools 378 | import qupath.imagej.tools.ROIConverterIJ 379 | 380 | // Needed when requesting tiles from QuPath 381 | import qupath.lib.geom.ImmutableDimension 382 | 383 | // PathObjects 384 | import qupath.lib.objects.* 385 | //import qupath.lib.roi.interfaces.PathArea 386 | import qupath.lib.roi.interfaces.ROI 387 | import qupath.lib.roi.RoiTools 388 | 389 | // Helps create temp directory 390 | import java.nio.file.* 391 | import ij.plugin.ChannelSplitter -------------------------------------------------------------------------------- /Examples/CellPose/PythonRoiRunner-CellPose.groovy: -------------------------------------------------------------------------------- 1 | // Parameters for the CellPose Model 2 | def diameter = 50 3 | def model = "cyto" 4 | 5 | def cellpose_parameters = ["diameter":diameter, "model":model] 6 | 7 | // Define preprocessing step, like having the right image for export. Here we will use channel 1 8 | // This takes an ImagePlus as input and should return another ImagePlus that will be used for export 9 | def preprocess = {image -> 10 | return ChannelSplitter.split(image)[0] 11 | } 12 | 13 | // The virtualenv from which we will run python.exe 14 | def cellpose_env_folder = 'D:\\environments\\cellpose' 15 | 16 | // The location of the script that will process the images and return RoiSets 17 | def cellpose_python_file = 'D:\\qupath-groovy-python-runner\\Examples\\CellPose\\cellpose_to_ij_rois.py' 18 | 19 | // This line make sure that everything is ready to go 20 | def pRCellPose = new PythonRoiRunner( cellpose_env_folder, cellpose_python_file) 21 | .setDownsample(2) 22 | .setParameters( cellpose_parameters ) 23 | 24 | // Process the currently selected object 25 | pRCellPose.processRegion(getSelectedObject(), preprocess ) 26 | 27 | 28 | 29 | // The RoiRunner is defined below and nothing should be changed below, normally 30 | class PythonRoiRunner { 31 | File python_executable 32 | File python_script_file 33 | File image_directory 34 | File current_project_temp_dir 35 | int tile_size = 2048 36 | int overlap = 30 37 | int downsample = 1 38 | double cell_thickness = 0.0 39 | ImmutableDimension dims 40 | 41 | // Parameters that are unique to each python script as key value pairs where the key is the command line argument name 42 | def parameters = [:] 43 | 44 | public PythonRoiRunner( String virtualenv_directory_string, String python_script_file_string) { 45 | this.python_executable = new File( virtualenv_directory_string, "Scripts/python" ) 46 | this.python_script_file = new File( python_script_file_string ) 47 | logger.info("Script: {}", python_script_file) 48 | this.current_project_temp_dir = new File( Projects.getBaseDirectory( getProject() ), python_script_file.getName()+"_tmp" ) 49 | current_project_temp_dir.mkdirs() 50 | 51 | // This is needed when requesting tiles 52 | this.dims = new ImmutableDimension( tile_size, tile_size ) 53 | } 54 | 55 | PythonRoiRunner setOverlap(int overlap) { 56 | this.overlap = overlap 57 | return this 58 | } 59 | 60 | PythonRoiRunner setTileSize(int tile_size) { 61 | this.tile_size = tile_size 62 | return this 63 | } 64 | 65 | PythonRoiRunner setParameters( Map parameters ) { 66 | this.parameters = parameters 67 | return this 68 | } 69 | 70 | PythonRoiRunner makeCells( double cell_thickness ) { 71 | this.cell_thickness = cell_thickness 72 | return this 73 | } 74 | 75 | PythonRoiRunner setDownsample( int downsample ) { 76 | this.downsample = downsample 77 | return this 78 | } 79 | 80 | 81 | // This processes the given region, at the desired downsampling and with the chosen code for preprocessing 82 | // Code should take an ImagePlus as input argument and return the same ImagePlus 83 | void processRegion( PathObject region, Closure preprocess ) { 84 | 85 | // Prepare a temporary folder to work 86 | def temp_folder = Files.createTempDirectory( this.current_project_temp_dir.toPath(), "Temp" ); 87 | logger.info( "Storing Temporary images in {}", this.current_project_temp_dir ) 88 | 89 | // Use this as the data folder 90 | def data_folder = temp_folder.toFile() 91 | 92 | // We expect the Python script to store store the resulting RoiSets in 'rois' 93 | def roiset_folder = new File( data_folder, 'rois' ) 94 | 95 | // Make tiles as needed 96 | def regions = RoiTools.computeTiledROIs( region.getROI(), this.dims, this.dims, true, this.overlap ).collect { 97 | return new PathAnnotationObject( it ) 98 | } 99 | 100 | // Show the regions that are being processed 101 | region.addPathObjects( regions ) 102 | fireHierarchyUpdate() 103 | 104 | // Compute global min-max normalization values to use here on the full region. Downsample if the image is tiled 105 | def downsample_corr = 1.0 106 | if ( regions.size() > 1 ) { 107 | downsample_corr = 4.0 108 | } 109 | 110 | logger.info( "Computing Min and Max values for data normalization on {}x downsampled data", this.downsample * downsample_corr as int ) 111 | 112 | // Compute on the whole image 113 | def full_image = GUIUtils.getImagePlus( region, this.downsample * downsample_corr as int, false, false ) 114 | if( preprocess != null ) 115 | full_image = preprocess( full_image ) 116 | 117 | def min_max = getQuantileMinMax( full_image, 1.0, 99.8 ) 118 | 119 | logger.info( "Normalization: Min={}, Max={}", min_max["min"], min_max["max"] ) 120 | 121 | full_image.close() 122 | 123 | // Save all the regions to the data_folder, and keep their information for when we pick up the Rois later 124 | def region_metadata = regions.withIndex().collect { r, idx -> 125 | 126 | ImagePlus imp = GUIUtils.getImagePlus( r, this.downsample, false, false ) 127 | 128 | def cal = imp.getCalibration().clone() 129 | 130 | if( preprocess != null ) 131 | imp = preprocess( imp ) 132 | imp.setCalibration(cal) 133 | 134 | File image_file = new File( data_folder, "region" + IJ.pad( idx, 3 ) ) 135 | 136 | // Save the image 137 | IJ.saveAsTiff( imp, image_file.getAbsolutePath() ) 138 | logger.info( "Saved Image {}.tif", image_file.getName() ) 139 | 140 | imp.close() 141 | 142 | // Return a List with the image name, the region and the calibration ( To get the ROIs properly later ) 143 | return [name: image_file.getName(), region: r, calibration: cal] 144 | } 145 | 146 | // Call the script, this is the magic line 147 | runScript( data_folder, min_max ) 148 | 149 | // After this step, there should be ROIs in the 'rois' folder 150 | // Get ROI Set(s) and import 151 | def rm = RoiManager.getRoiManager() 152 | 153 | 154 | def all_detections = [] 155 | 156 | region_metadata.each { meta -> 157 | def current_name = meta.name 158 | def current_region = meta.region 159 | def cal = meta.calibration 160 | 161 | // The RoiSet should have the same name as the image, with 'rois.zip' appended to it 162 | def roi_file = new File( roiset_folder, current_name + ".tif_rois.zip" ) 163 | 164 | if ( roi_file.exists() ) { 165 | logger.info( "Image {}.tif had a RoiSet", current_name ) 166 | 167 | rm.reset() 168 | rm.runCommand( "Open", roi_file.getAbsolutePath() ) 169 | 170 | def rois = rm.getRoisAsArray() as List 171 | 172 | def detections = rois.collect { 173 | def roi = IJTools.convertToROI( it, cal, downsample, null ) 174 | if ( current_region.getROI().contains( roi.getCentroidX(), roi.getCentroidY() ) ) 175 | return new PathDetectionObject( roi ) 176 | return[] 177 | }.flatten() 178 | 179 | if ( this.cell_thickness > 0 ) { 180 | logger.info( "Creating Cells from {} Detections...", detections.size() ) 181 | def cell_detections = PathUtils.createCellObjects( current_region, detections, this.cell_radius, downsample ) 182 | all_detections.addAll( cell_detections ) 183 | 184 | } else { 185 | logger.info( "Adding {} detections", detections.size() ) 186 | all_detections.addAll( detections ) 187 | } 188 | } 189 | } 190 | 191 | if ( regions.size() > 1 ) { 192 | logger.info( "Removing overlapping objects" ) 193 | // Find the detections that may need to be removed 194 | 195 | def overlaping_detections = getOverlapingDetetections( regions, all_detections ) 196 | 197 | // Remove overlap and add the ones to keep again after 198 | 199 | 200 | // Do some filtering to avoid issues where there is overlap 201 | def removable_regions = getRoisToDetleteByOverlap( overlaping_detections, 40 ) 202 | 203 | // Remove 204 | all_detections.removeAll( removable_regions ) 205 | } 206 | 207 | 208 | region.addPathObjects( all_detections ) 209 | 210 | region.removePathObjects( regions ) 211 | 212 | fireHierarchyUpdate() 213 | 214 | temp_folder.toFile().deleteDir() 215 | 216 | logger.info( "Done" ) 217 | 218 | } 219 | 220 | def getOverlapingDetetections( def regions, def all_detections ) { 221 | 222 | // Get all overlap regions 223 | def overlap_regions = [] 224 | regions.each { r1 -> 225 | regions.each { r2 -> 226 | if ( r1 != r2 ) { 227 | // check overlap 228 | def merge = RoiTools.combineROIs( r1.getROI(), r2.getROI(), RoiTools.CombineOp.INTERSECT ) 229 | if ( !merge.isEmpty() ) { 230 | // Make into an annotation that represents the overlap 231 | overlap_regions.add( new PathAnnotationObject( merge ) ) 232 | } 233 | } 234 | } 235 | } 236 | 237 | // Combine all now 238 | setSelectedObject( null ) 239 | mergeAnnotations( overlap_regions ) 240 | def merged = getSelectedObject() 241 | if( merged != null ) removeObject( merged, false ) 242 | setSelectedObject( null ) 243 | 244 | // Find all annotations that are touching somehow this region, avoid shapes as they are slow 245 | def overlap_detections = all_detections.findAll { 246 | def roi = it.getROI() 247 | 248 | def x1 = roi.getCentroidX() 249 | def y1 = roi.getCentroidY() 250 | 251 | def x2 = roi.getBoundsX() 252 | def y2 = roi.getBoundsY() 253 | 254 | def x3 = roi.getBoundsX() + roi.getBoundsWidth() 255 | def y3 = roi.getBoundsY() 256 | 257 | def x4 = roi.getBoundsX() 258 | def y4 = roi.getBoundsY() + roi.getBoundsHeight() 259 | 260 | 261 | def x5 = roi.getBoundsX() + roi.getBoundsWidth() 262 | def y5 = roi.getBoundsY() + roi.getBoundsHeight() 263 | 264 | return merged.getROI().contains( x1, y1 ) || merged.getROI().contains( x2, y2 ) || merged.getROI().contains( x3, y3 ) || merged.getROI().contains( x4, y4 ) || merged.getROI().contains( x5, y5 ) 265 | } 266 | 267 | // From here get a hashmap of the regions when their bounding boxes match 268 | def temp_overlap_detections = overlap_detections.clone() 269 | 270 | def detections_to_check = [:] 271 | overlap_detections.each { det -> 272 | temp_overlap_detections.remove( det ) 273 | def det_candidates = temp_overlap_detections.collect { det1 -> 274 | if ( hasBBOverlap( det, det1 ) ) { 275 | return det1 276 | } 277 | return [] 278 | }.flatten() 279 | 280 | detections_to_check.put( det, det_candidates ) 281 | } 282 | 283 | logger.info( "There are {} detections that potentially overlap", detections_to_check.size() ) 284 | logger.info( "{}", detections_to_check ) 285 | return detections_to_check 286 | } 287 | 288 | boolean hasBBOverlap( def po1, def po2 ) { 289 | ROI r1 = po1.getROI() 290 | 291 | double r1_left = r1.getBoundsX() 292 | double r1_top = r1.getBoundsY() 293 | double r1_right = r1.getBoundsWidth() + r1_left 294 | double r1_bottom = r1.getBoundsHeight() + r1_top 295 | 296 | ROI r2 = po2.getROI() 297 | 298 | double r2_left = r2.getBoundsX() 299 | double r2_top = r2.getBoundsY() 300 | double r2_right = r2.getBoundsWidth() + r2_left 301 | double r2_bottom = r2.getBoundsHeight() + r2_top 302 | 303 | return !( r2_left > r1_right 304 | || r2_right < r1_left 305 | || r2_top > r1_bottom 306 | || r2_bottom < r1_top ) 307 | } 308 | 309 | // "percentOverlap" : compare the percent overlap of each roi.Areas, 310 | // and delete the roi with the largent percentage (most probably included within the other). 311 | def getRoisToDetleteByOverlap( def roiMap, def percent_overlap_lim ) { 312 | 313 | logger.info( "Overlap Filter: Overlap Limit {}%", percent_overlap_lim ) 314 | 315 | def roisToDelete = [] 316 | 317 | roiMap.each { rA, candidates -> 318 | candidates.each { rB -> 319 | def roiA = rA.getROI() 320 | def roiB = rB.getROI() 321 | 322 | def merge = RoiTools.combineROIs( roiA, roiB, RoiTools.CombineOp.INTERSECT ) 323 | 324 | if ( merge.isEmpty() ) return 325 | 326 | def roiA_ratio = merge.getArea() / roiA.getArea() * 100 327 | def roiB_ratio = merge.getArea() / roiB.getArea() * 100 328 | 329 | if ( ( roiA_ratio > percent_overlap_lim ) || ( roiB_ratio > percent_overlap_lim ) ) 330 | ( roiA_ratio < roiB_ratio ) ? roisToDelete.add( rB ) : roisToDelete.add( rA ) 331 | } 332 | } 333 | 334 | logger.info( "{} overlapping detections to be removed", roisToDelete.size() ) 335 | 336 | return roisToDelete 337 | } 338 | 339 | // Get get quantile values for normalization 340 | def getQuantileMinMax( ImagePlus image, double lower_q, double upper_q ) { 341 | logger.info( "Using {}% lower quantile and {}% upper quantile", lower_q, upper_q ) 342 | def proc = image.getProcessor().convertToFloatProcessor() 343 | def perc = new Percentile() 344 | 345 | def lower_val = perc.evaluate( proc.getPixels() as double[], lower_q ) 346 | def upper_val = perc.evaluate( proc.getPixels() as double[], upper_q ) 347 | 348 | return [min: lower_val, max: upper_val] 349 | } 350 | 351 | public void runScript( File data_folder, def min_max ) { 352 | logger.info( "Running Python Script" ) 353 | 354 | def sout = new StringBuilder() 355 | 356 | def parameters_strs = this.parameters.collect{ key, value -> return "--${key}=${value}" } 357 | 358 | def pb = new ProcessBuilder( this.python_executable.getAbsolutePath(), 359 | this.python_script_file.getAbsolutePath(), 360 | data_folder.getAbsolutePath(), 361 | '--min='+min_max['min'], 362 | '--max='+min_max['max'], 363 | *parameters_strs) 364 | .redirectErrorStream( true ) 365 | def process = pb.start() 366 | 367 | logger.info( "Started command: {}", pb.command().join(" ")) 368 | 369 | process.consumeProcessOutput( sout, sout ) 370 | 371 | // Show what is happening in the log 372 | while ( process.isAlive() ) { 373 | 374 | if ( sout.size() > 0 ) { 375 | logger.info( sout.toString() ) 376 | sout.setLength( 0 ) 377 | } 378 | 379 | sleep( 200 ) 380 | 381 | } 382 | 383 | logger.info( "Running Python Script Complete" ) 384 | } 385 | } 386 | 387 | 388 | // All the Stardist Magic is in the Class below 389 | // All imports 390 | import ch.epfl.biop.qupath.utils.* 391 | import ij.IJ 392 | import ij.ImagePlus 393 | import ij.measure.Calibration 394 | import ij.plugin.frame.RoiManager 395 | 396 | // To compute image normalization 397 | import org.apache.commons.math3.stat.descriptive.rank.Percentile 398 | 399 | // QuPath does not log standard output when declaring Groovy Classes, so we use the logger 400 | import org.slf4j.Logger 401 | import org.slf4j.LoggerFactory 402 | 403 | // ROI <> Roi conversion tools 404 | import qupath.imagej.tools.ROIConverterIJ 405 | 406 | // Needed when requesting tiles from QuPath 407 | import qupath.lib.geom.ImmutableDimension 408 | 409 | // PathObjects 410 | import qupath.lib.objects.* 411 | //import qupath.lib.roi.interfaces.PathArea 412 | import qupath.lib.roi.interfaces.ROI 413 | import qupath.lib.roi.RoiTools 414 | 415 | // Helps create temp directory 416 | import java.nio.file.* 417 | import ij.plugin.ChannelSplitter 418 | -------------------------------------------------------------------------------- /Examples/StarDist/PythonRoiRunner-StarDist.groovy: -------------------------------------------------------------------------------- 1 | // Parameters for the StarDist Model 2 | def model = "dsb_heavy" 3 | def model_folder = new File ("D:\\StatDist Models") 4 | 5 | def stardist_parameters = [ "model":model, "model_folder":model_folder.getAbsolutePath() ] 6 | 7 | // Define preprocessing step, like having the right image for export. Here we will use channel 1 8 | // This takes an ImagePlus as input and should return another ImagePlus that will be used for export 9 | def preprocess = {image -> 10 | return ChannelSplitter.split(image)[0] 11 | } 12 | 13 | // The virtualenv from which we will run python.exe 14 | def stardist_env_folder = 'D:\\environments\\stardist' 15 | 16 | // The location of the script that will process the images and return RoiSets 17 | def stardist_python_file = 'D:\\qupath-groovy-python-runner\\Examples\\StarDist\\stardist_to_ij_rois.py' 18 | 19 | // This line make sure that everything is ready to go 20 | def pRStarDist = new PythonRoiRunner( stardist_env_folder, stardist_python_file) 21 | .setDownsample(2) 22 | .setParameters( stardist_parameters ) 23 | 24 | // Process the currently selected object 25 | pRStarDist.processRegion(getSelectedObject(), preprocess ) 26 | 27 | 28 | 29 | // The RoiRunner is defined below and nothing should be changed below, normally 30 | class PythonRoiRunner { 31 | File python_executable 32 | File python_script_file 33 | File image_directory 34 | File current_project_temp_dir 35 | int tile_size = 2048 36 | int overlap = 30 37 | int downsample = 1 38 | double cell_thickness = 0.0 39 | ImmutableDimension dims 40 | 41 | // Parameters that are unique to each python script as key value pairs where the key is the command line argument name 42 | def parameters = [:] 43 | 44 | public PythonRoiRunner( String virtualenv_directory_string, String python_script_file_string) { 45 | this.python_executable = new File( virtualenv_directory_string, "Scripts/python" ) 46 | this.python_script_file = new File( python_script_file_string ) 47 | logger.info("Script: {}", python_script_file) 48 | this.current_project_temp_dir = new File( Projects.getBaseDirectory( getProject() ), python_script_file.getName()+"_tmp" ) 49 | current_project_temp_dir.mkdirs() 50 | 51 | // This is needed when requesting tiles 52 | this.dims = new ImmutableDimension( tile_size, tile_size ) 53 | } 54 | 55 | PythonRoiRunner setOverlap(int overlap) { 56 | this.overlap = overlap 57 | return this 58 | } 59 | 60 | PythonRoiRunner setTileSize(int tile_size) { 61 | this.tile_size = tile_size 62 | return this 63 | } 64 | 65 | PythonRoiRunner setParameters( Map parameters ) { 66 | this.parameters = parameters 67 | return this 68 | } 69 | 70 | PythonRoiRunner makeCells( double cell_thickness ) { 71 | this.cell_thickness = cell_thickness 72 | return this 73 | } 74 | 75 | PythonRoiRunner setDownsample( int downsample ) { 76 | this.downsample = downsample 77 | return this 78 | } 79 | 80 | 81 | // This processes the given region, at the desired downsampling and with the chosen code for preprocessing 82 | // Code should take an ImagePlus as input argument and return the same ImagePlus 83 | void processRegion( PathObject region, Closure preprocess ) { 84 | 85 | // Prepare a temporary folder to work 86 | def temp_folder = Files.createTempDirectory( this.current_project_temp_dir.toPath(), "Temp" ); 87 | logger.info( "Storing Temporary images in {}", this.current_project_temp_dir ) 88 | 89 | // Use this as the data folder 90 | def data_folder = temp_folder.toFile() 91 | 92 | // We expect the Python script to store store the resulting RoiSets in 'rois' 93 | def roiset_folder = new File( data_folder, 'rois' ) 94 | 95 | // Make tiles as needed 96 | def regions = RoiTools.computeTiledROIs( region.getROI(), this.dims, this.dims, true, this.overlap ).collect { 97 | return new PathAnnotationObject( it ) 98 | } 99 | 100 | // Show the regions that are being processed 101 | region.addPathObjects( regions ) 102 | fireHierarchyUpdate() 103 | 104 | // Compute global min-max normalization values to use here on the full region. Downsample if the image is tiled 105 | def downsample_corr = 1.0 106 | if ( regions.size() > 1 ) { 107 | downsample_corr = 4.0 108 | } 109 | 110 | logger.info( "Computing Min and Max values for data normalization on {}x downsampled data", this.downsample * downsample_corr as int ) 111 | 112 | // Compute on the whole image 113 | def full_image = GUIUtils.getImagePlus( region, this.downsample * downsample_corr as int, false, false ) 114 | if( preprocess != null ) 115 | full_image = preprocess( full_image ) 116 | 117 | def min_max = getQuantileMinMax( full_image, 1.0, 99.8 ) 118 | 119 | logger.info( "Normalization: Min={}, Max={}", min_max["min"], min_max["max"] ) 120 | 121 | full_image.close() 122 | 123 | // Save all the regions to the data_folder, and keep their information for when we pick up the Rois later 124 | def region_metadata = regions.withIndex().collect { r, idx -> 125 | 126 | ImagePlus imp = GUIUtils.getImagePlus( r, this.downsample, false, false ) 127 | 128 | def cal = imp.getCalibration().clone() 129 | 130 | if( preprocess != null ) 131 | imp = preprocess( imp ) 132 | imp.setCalibration(cal) 133 | 134 | File image_file = new File( data_folder, "region" + IJ.pad( idx, 3 ) ) 135 | 136 | // Save the image 137 | IJ.saveAsTiff( imp, image_file.getAbsolutePath() ) 138 | logger.info( "Saved Image {}.tif", image_file.getName() ) 139 | 140 | imp.close() 141 | 142 | // Return a List with the image name, the region and the calibration ( To get the ROIs properly later ) 143 | return [name: image_file.getName(), region: r, calibration: cal] 144 | } 145 | 146 | // Call the script, this is the magic line 147 | runScript( data_folder, min_max ) 148 | 149 | // After this step, there should be ROIs in the 'rois' folder 150 | // Get ROI Set(s) and import 151 | def rm = RoiManager.getRoiManager() 152 | 153 | 154 | def all_detections = [] 155 | 156 | region_metadata.each { meta -> 157 | def current_name = meta.name 158 | def current_region = meta.region 159 | def cal = meta.calibration 160 | 161 | // The RoiSet should have the same name as the image, with 'rois.zip' appended to it 162 | def roi_file = new File( roiset_folder, current_name + ".tif_rois.zip" ) 163 | 164 | if ( roi_file.exists() ) { 165 | logger.info( "Image {}.tif had a RoiSet", current_name ) 166 | 167 | rm.reset() 168 | rm.runCommand( "Open", roi_file.getAbsolutePath() ) 169 | 170 | def rois = rm.getRoisAsArray() as List 171 | 172 | def detections = rois.collect { 173 | def roi = IJTools.convertToROI( it, cal, downsample, null ) 174 | if ( current_region.getROI().contains( roi.getCentroidX(), roi.getCentroidY() ) ) 175 | return new PathDetectionObject( roi ) 176 | return[] 177 | }.flatten() 178 | 179 | if ( this.cell_thickness > 0 ) { 180 | logger.info( "Creating Cells from {} Detections...", detections.size() ) 181 | def cell_detections = PathUtils.createCellObjects( current_region, detections, this.cell_radius, downsample ) 182 | all_detections.addAll( cell_detections ) 183 | 184 | } else { 185 | logger.info( "Adding {} detections", detections.size() ) 186 | all_detections.addAll( detections ) 187 | } 188 | } 189 | } 190 | 191 | if ( regions.size() > 1 ) { 192 | logger.info( "Removing overlapping objects" ) 193 | // Find the detections that may need to be removed 194 | 195 | def overlaping_detections = getOverlapingDetetections( regions, all_detections ) 196 | 197 | // Remove overlap and add the ones to keep again after 198 | 199 | 200 | // Do some filtering to avoid issues where there is overlap 201 | def removable_regions = getRoisToDetleteByOverlap( overlaping_detections, 40 ) 202 | 203 | // Remove 204 | all_detections.removeAll( removable_regions ) 205 | } 206 | 207 | 208 | region.addPathObjects( all_detections ) 209 | 210 | region.removePathObjects( regions ) 211 | 212 | fireHierarchyUpdate() 213 | 214 | temp_folder.toFile().deleteDir() 215 | 216 | logger.info( "Done" ) 217 | 218 | } 219 | 220 | def getOverlapingDetetections( def regions, def all_detections ) { 221 | 222 | // Get all overlap regions 223 | def overlap_regions = [] 224 | regions.each { r1 -> 225 | regions.each { r2 -> 226 | if ( r1 != r2 ) { 227 | // check overlap 228 | def merge = RoiTools.combineROIs( r1.getROI(), r2.getROI(), RoiTools.CombineOp.INTERSECT ) 229 | if ( !merge.isEmpty() ) { 230 | // Make into an annotation that represents the overlap 231 | overlap_regions.add( new PathAnnotationObject( merge ) ) 232 | } 233 | } 234 | } 235 | } 236 | 237 | // Combine all now 238 | setSelectedObject( null ) 239 | mergeAnnotations( overlap_regions ) 240 | def merged = getSelectedObject() 241 | if( merged != null ) removeObject( merged, false ) 242 | setSelectedObject( null ) 243 | 244 | // Find all annotations that are touching somehow this region, avoid shapes as they are slow 245 | def overlap_detections = all_detections.findAll { 246 | def roi = it.getROI() 247 | 248 | def x1 = roi.getCentroidX() 249 | def y1 = roi.getCentroidY() 250 | 251 | def x2 = roi.getBoundsX() 252 | def y2 = roi.getBoundsY() 253 | 254 | def x3 = roi.getBoundsX() + roi.getBoundsWidth() 255 | def y3 = roi.getBoundsY() 256 | 257 | def x4 = roi.getBoundsX() 258 | def y4 = roi.getBoundsY() + roi.getBoundsHeight() 259 | 260 | 261 | def x5 = roi.getBoundsX() + roi.getBoundsWidth() 262 | def y5 = roi.getBoundsY() + roi.getBoundsHeight() 263 | 264 | return merged.getROI().contains( x1, y1 ) || merged.getROI().contains( x2, y2 ) || merged.getROI().contains( x3, y3 ) || merged.getROI().contains( x4, y4 ) || merged.getROI().contains( x5, y5 ) 265 | } 266 | 267 | // From here get a hashmap of the regions when their bounding boxes match 268 | def temp_overlap_detections = overlap_detections.clone() 269 | 270 | def detections_to_check = [:] 271 | overlap_detections.each { det -> 272 | temp_overlap_detections.remove( det ) 273 | def det_candidates = temp_overlap_detections.collect { det1 -> 274 | if ( hasBBOverlap( det, det1 ) ) { 275 | return det1 276 | } 277 | return [] 278 | }.flatten() 279 | 280 | detections_to_check.put( det, det_candidates ) 281 | } 282 | 283 | logger.info( "There are {} detections that potentially overlap", detections_to_check.size() ) 284 | logger.info( "{}", detections_to_check ) 285 | return detections_to_check 286 | } 287 | 288 | boolean hasBBOverlap( def po1, def po2 ) { 289 | ROI r1 = po1.getROI() 290 | 291 | double r1_left = r1.getBoundsX() 292 | double r1_top = r1.getBoundsY() 293 | double r1_right = r1.getBoundsWidth() + r1_left 294 | double r1_bottom = r1.getBoundsHeight() + r1_top 295 | 296 | ROI r2 = po2.getROI() 297 | 298 | double r2_left = r2.getBoundsX() 299 | double r2_top = r2.getBoundsY() 300 | double r2_right = r2.getBoundsWidth() + r2_left 301 | double r2_bottom = r2.getBoundsHeight() + r2_top 302 | 303 | return !( r2_left > r1_right 304 | || r2_right < r1_left 305 | || r2_top > r1_bottom 306 | || r2_bottom < r1_top ) 307 | } 308 | 309 | // "percentOverlap" : compare the percent overlap of each roi.Areas, 310 | // and delete the roi with the largent percentage (most probably included within the other). 311 | def getRoisToDetleteByOverlap( def roiMap, def percent_overlap_lim ) { 312 | 313 | logger.info( "Overlap Filter: Overlap Limit {}%", percent_overlap_lim ) 314 | 315 | def roisToDelete = [] 316 | 317 | roiMap.each { rA, candidates -> 318 | candidates.each { rB -> 319 | def roiA = rA.getROI() 320 | def roiB = rB.getROI() 321 | 322 | def merge = RoiTools.combineROIs( roiA, roiB, RoiTools.CombineOp.INTERSECT ) 323 | 324 | if ( merge.isEmpty() ) return 325 | 326 | def roiA_ratio = merge.getArea() / roiA.getArea() * 100 327 | def roiB_ratio = merge.getArea() / roiB.getArea() * 100 328 | 329 | if ( ( roiA_ratio > percent_overlap_lim ) || ( roiB_ratio > percent_overlap_lim ) ) 330 | ( roiA_ratio < roiB_ratio ) ? roisToDelete.add( rB ) : roisToDelete.add( rA ) 331 | } 332 | } 333 | 334 | logger.info( "{} overlapping detections to be removed", roisToDelete.size() ) 335 | 336 | return roisToDelete 337 | } 338 | 339 | // Get get quantile values for normalization 340 | def getQuantileMinMax( ImagePlus image, double lower_q, double upper_q ) { 341 | logger.info( "Using {}% lower quantile and {}% upper quantile", lower_q, upper_q ) 342 | def proc = image.getProcessor().convertToFloatProcessor() 343 | def perc = new Percentile() 344 | 345 | def lower_val = perc.evaluate( proc.getPixels() as double[], lower_q ) 346 | def upper_val = perc.evaluate( proc.getPixels() as double[], upper_q ) 347 | 348 | return [min: lower_val, max: upper_val] 349 | } 350 | 351 | public void runScript( File data_folder, def min_max ) { 352 | logger.info( "Running Python Script" ) 353 | 354 | def sout = new StringBuilder() 355 | 356 | def parameters_strs = this.parameters.collect{ key, value -> return "--${key}=${value}" } 357 | 358 | def pb = new ProcessBuilder( this.python_executable.getAbsolutePath(), 359 | this.python_script_file.getAbsolutePath(), 360 | data_folder.getAbsolutePath(), 361 | '--min='+min_max['min'], 362 | '--max='+min_max['max'], 363 | *parameters_strs) 364 | .redirectErrorStream( true ) 365 | def process = pb.start() 366 | 367 | logger.info( "Started command: {}", pb.command().join(" ")) 368 | 369 | process.consumeProcessOutput( sout, sout ) 370 | 371 | // Show what is happening in the log 372 | while ( process.isAlive() ) { 373 | 374 | if ( sout.size() > 0 ) { 375 | logger.info( sout.toString() ) 376 | sout.setLength( 0 ) 377 | } 378 | 379 | sleep( 200 ) 380 | 381 | } 382 | 383 | logger.info( "Running Python Script Complete" ) 384 | } 385 | } 386 | 387 | 388 | // All the Stardist Magic is in the Class below 389 | // All imports 390 | import ch.epfl.biop.qupath.utils.* 391 | import ij.IJ 392 | import ij.ImagePlus 393 | import ij.measure.Calibration 394 | import ij.plugin.frame.RoiManager 395 | 396 | // To compute image normalization 397 | import org.apache.commons.math3.stat.descriptive.rank.Percentile 398 | 399 | // QuPath does not log standard output when declaring Groovy Classes, so we use the logger 400 | import org.slf4j.Logger 401 | import org.slf4j.LoggerFactory 402 | 403 | // ROI <> Roi conversion tools 404 | import qupath.imagej.tools.ROIConverterIJ 405 | 406 | // Needed when requesting tiles from QuPath 407 | import qupath.lib.geom.ImmutableDimension 408 | 409 | // PathObjects 410 | import qupath.lib.objects.* 411 | //import qupath.lib.roi.interfaces.PathArea 412 | import qupath.lib.roi.interfaces.ROI 413 | import qupath.lib.roi.RoiTools 414 | 415 | // Helps create temp directory 416 | import java.nio.file.* 417 | import ij.plugin.ChannelSplitter 418 | --------------------------------------------------------------------------------