diff --git a/Readme.md b/Readme.md index 0fe0414..61ae48f 100644 --- a/Readme.md +++ b/Readme.md @@ -106,12 +106,14 @@ optional arguments: -f, --force force overwriting of existing files -b BATCH, --batch BATCH break results file into subsets of this size - -s SEED, --seed SEED, + -s SEED, --seed SEED set a seed used to produce a random number in all modules -n NPROCESSES, --nprocesses NPROCESSES number of processes to launch --symlink TARGET_DIR create symlink to outdir in TARGET_DIR + + ``` Installed or simply git-cloned, a typical command line for running the tool thus looks like: diff --git a/histoqc/SaveModule.py b/histoqc/SaveModule.py index a831ad3..f3ad272 100644 --- a/histoqc/SaveModule.py +++ b/histoqc/SaveModule.py @@ -1,12 +1,14 @@ import logging import os +import cv2 +import uuid +import json +import numpy as np from skimage import io, img_as_ubyte from distutils.util import strtobool -from skimage import color -import numpy as np - -import matplotlib.pyplot as plt - +from skimage import color, measure +from copy import deepcopy +from geojson import Polygon, Feature, FeatureCollection, dump def blend2Images(img, mask): if (img.ndim == 3): @@ -18,6 +20,62 @@ def blend2Images(img, mask): out = np.concatenate((mask, img, mask), 2) return out +def binaryMask2Geojson(s, mask): + # get the dimension of slide + (dim_width, dim_height) = s['os_handle'].dimensions + # get the dimension of mask + (mask_height, mask_width) = mask.shape + + # convert binary mask to contours + # contours = measure.find_contours(mask) + contours, hierarchy = cv2.findContours(img_as_ubyte(mask), cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE) + children = [[] for i in range(len(contours))] + for i, cnt in enumerate(contours): + # first_child_idx = hier[0, i, 2] + parent_idx = hierarchy[0, i, 3] + if (parent_idx == -1): + continue + # add contour to parent's children node + children[parent_idx].append(cnt) + + # no contours detected + if not len(contours): + # set default format + ann_format = "xml" + # warning msg + msg = f"No contour detected at use mask image. Geojson annotation won't be generated." + logging.warning(f"{s['filename']} - {msg}") + s["warnings"].append(msg) + return None + + features = [] + for i, contour in enumerate(contours): + first_child_idx = hierarchy[0, i, 2] + parent_idx = hierarchy[0, i, 3] + + if (parent_idx != -1): + continue + + geometry = [] + points = np.asarray(contour / [mask_height, mask_width] * [dim_height, dim_width],dtype="int") + points = np.append(points, [points[0]], axis=0) + points = points[:,0].tolist() + points = [tuple(p) for p in points] + geometry.append(points) + if first_child_idx != -1: + for child in children[i]: + child_points = np.asarray(child / [mask_height, mask_width] * [dim_height, dim_width],dtype="int") + child_points = np.append(child_points, [child_points[0]], axis=0) + child_points = child_points[:,0].tolist() + child_points = [tuple(p) for p in child_points] + geometry.append(child_points) + new_feature = Feature(id=uuid.uuid4().hex, geometry=Polygon(geometry),properties={"objectType": "annotation"}) + + features.append(new_feature) + feature_collection = FeatureCollection(features) + + return feature_collection + def saveFinalMask(s, params): logging.info(f"{s['filename']} - \tsaveUsableRegion") @@ -28,6 +86,7 @@ def saveFinalMask(s, params): io.imsave(s["outdir"] + os.sep + s["filename"] + "_mask_use.png", img_as_ubyte(mask)) + if strtobool(params.get("use_mask", "True")): # should we create and save the fusion mask? img = s.getImgThumb(s["image_work_size"]) out = blend2Images(img, mask) @@ -70,7 +129,6 @@ def saveMacro(s, params): def saveMask(s, params): logging.info(f"{s['filename']} - \tsaveMaskUse") suffix = params.get("suffix", None) - # check suffix param if not suffix: msg = f"{s['filename']} - \tPlease set the suffix for mask use." @@ -80,6 +138,32 @@ def saveMask(s, params): # save mask io.imsave(f"{s['outdir']}{os.sep}{s['filename']}_{suffix}.png", img_as_ubyte(s["img_mask_use"])) + +def saveMask2Geojson(s, params): + + mask_name = params.get('mask_name', 'img_mask_use') + suffix = params.get("suffix", None) + logging.info(f"{s['filename']} - \tsaveMaskUse2Geojson: {mask_name}") + # check suffix param + if not suffix: + msg = f"{s['filename']} - \tPlease set the suffix for mask use in geojson." + logging.error(msg) + return + + # check if the mask name exists + if s.get(mask_name, None) is None: + msg = f"{s['filename']} - \tThe `{mask_name}` mask dosen't exist. Please use correct mask name." + logging.error(msg) + return + # convert mask to geojson + geojson = binaryMask2Geojson(s, s[mask_name]) + + # save mask as genjson file + with open(f"{s['outdir']}{os.sep}{s['filename']}_{suffix}.geojson", 'w') as f: + dump(geojson, f) + + + def saveThumbnails(s, params): logging.info(f"{s['filename']} - \tsaveThumbnail") # we create 2 thumbnails for usage in the front end, one relatively small one, and one larger one diff --git a/histoqc/__main__.py b/histoqc/__main__.py index 1324cf2..a0f54a6 100644 --- a/histoqc/__main__.py +++ b/histoqc/__main__.py @@ -65,7 +65,10 @@ def main(argv=None): parser.add_argument('--symlink', metavar="TARGET_DIR", help="create symlink to outdir in TARGET_DIR", default=None) + parser.add_argument('--debug', action='store_true', help="trigger debugging behavior") + + args = parser.parse_args(argv) # --- multiprocessing and logging setup ----------------------------------- @@ -75,7 +78,6 @@ def main(argv=None): lm = MultiProcessingLogManager('histoqc', manager=mpm) # --- parse the pipeline configuration ------------------------------------ - config = configparser.ConfigParser() if not args.config: lm.logger.warning(f"Configuration file not set (--config), using default") diff --git a/histoqc/config/config_clinical.ini b/histoqc/config/config_clinical.ini index f0c471c..0bf4382 100644 --- a/histoqc/config/config_clinical.ini +++ b/histoqc/config/config_clinical.ini @@ -6,10 +6,12 @@ steps= BasicModule.getBasicStats LightDarkModule.getIntensityThresholdPercent:darktissue BubbleRegionByRegion.detectSmoothness SaveModule.saveMask:first + SaveModule.saveMask2Geojson:geo_first MorphologyModule.removeFatlikeTissue MorphologyModule.fillSmallHoles MorphologyModule.removeSmallObjects SaveModule.saveMask:second + SaveModule.saveMask2Geojson:geo_second BlurDetectionModule.identifyBlurryRegions BasicModule.finalProcessingSpur BasicModule.finalProcessingArea @@ -20,6 +22,7 @@ steps= BasicModule.getBasicStats BrightContrastModule.getBrightnessByChannelinColorSpace:RGB BrightContrastModule.getBrightnessByChannelinColorSpace:YUV SaveModule.saveMask:third + SaveModule.saveMask2Geojson:geo_third DeconvolutionModule.separateStains SaveModule.saveFinalMask SaveModule.saveMacro @@ -206,5 +209,18 @@ suffix: first [SaveModule.saveMask:second] suffix: second + [SaveModule.saveMask:third] -suffix: third \ No newline at end of file +suffix: third + + +[SaveModule.saveMask2Geojson:geo_first] +suffix: geo_first +mask_name: img_mask_flat + +[SaveModule.saveMask2Geojson:geo_second] +suffix: geo_second +mask_name: img_mask_small_filled + +[SaveModule.saveMask2Geojson:geo_third] +suffix: geo_third \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index aa2d094..bc63292 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,5 +15,5 @@ Pillow~=9.4.0 setuptools~=65.6.3 shapely~=2.0.1 flask~=2.3.2 - +geojson~=3.1.0 cohortfinder==1.0.1 \ No newline at end of file