diff --git a/Labels2Rois.py b/Labels2Rois.py index 2d1468a..5398d18 100644 --- a/Labels2Rois.py +++ b/Labels2Rois.py @@ -11,414 +11,571 @@ import time from collections import deque import sys -# try imports of "extra" packages + try: from skimage.measure import find_contours -except: +except ImportError: print("!! Could not find python package 'scikit-image' !!\n+++++++++++++++++") + try: import omero_rois as omeroi -except: +except ImportError: print("!! Could not find python package 'omero_rois' !!\n+++++++++++++++++") -# main function + def labels2rois(script_params, conn): - inputType = script_params["Data_Type"] - inputIds = script_params["IDs"] - deleteLabelImage = script_params["Delete_Label_Image"] + """ + Main function to convert label images to ROIs. + + Args: + script_params: Dictionary containing script parameters + conn: OMERO connection object + + Returns: + Tuple of (newRois, imagesProcessed) + """ + input_type = script_params["Data_Type"] + input_ids = script_params["IDs"] + delete_label_image = script_params["Delete_Label_Image"] algorithm = script_params["ROI_type"] + + label_suffix = script_params.get("Label_Suffix", "-label") + label_dataset_id = script_params.get("Label_Dataset_ID", None) + search_mode = script_params.get("Search_Mode", "Same Dataset") + clear_rois = script_params.get("Clear_Existing_ROIs", False) + clear_filter = script_params.get("Clear_ROI_Filter", "") + + new_rois = [] + images_processed = 0 + + if input_type == "Dataset": + new_rois, images_processed = process_dataset_input( + input_ids, conn, label_suffix, label_dataset_id, search_mode, + clear_rois, clear_filter, algorithm, delete_label_image + ) + elif input_type == "Image": + new_rois, images_processed = process_image_input( + input_ids, conn, label_suffix, label_dataset_id, search_mode, + clear_rois, clear_filter, algorithm, delete_label_image + ) - newRois = [] - imagesProcessed = 0 - imageDict = {} - - if inputType == "Dataset": - for id in inputIds: - dataset = conn.getObject("Dataset", id) - # get the image dict - for img in list(dataset.listChildren()): - if not isLabelImage(img.name): - imageDict[img.name] = img.id - if len(imageDict)>0: - print("image dict:\n",imageDict) - # main loop - for image in list(dataset.listChildren()): - # check if it is a label image - if not isLabelImage(image.name): - continue - # get label image as numpy array - plane = get_label_image_as_array(image) - print("--------------------------------------") - print(f"processing image '{image.name}' from Dataset '{dataset.name}'") - print(f"shape of plane z=0/t=0/c=0: ", plane.shape) - print("min:", plane.min(), " max:", plane.max(),\ - "pixel type:", plane.dtype.name) - # get the contours - contour_dict, contourTime = create_contours(plane, algorithm) - # find the target image - targetId = get_target_image_id(imageDict, image.name) - if targetId == 0: - pass # TODO: error message if it doesnt find a match - # upload the rois - else: - createdRois, roiTime = upload_ROIs(contour_dict, targetId, algorithm, conn) - newRois = newRois + createdRois - imagesProcessed += 1 - if deleteLabelImage: - delete_image(image, conn) - print(f"{int(contourTime)}s to create contours and {int(roiTime)}s to upload ROIs") - - # empty out the image dictionary - imageDict.clear() + return new_rois, images_processed + + +def process_dataset_input(input_ids, conn, label_suffix, label_dataset_id, search_mode, + clear_rois, clear_filter, algorithm, delete_label_image): + """Process dataset input to create ROIs from label images.""" + new_rois = [] + images_processed = 0 + + for dataset_id in input_ids: + dataset = conn.getObject("Dataset", dataset_id) + dataset_images = list(dataset.listChildren()) + + # Always build image_dict from the target dataset (where we want to create ROIs) + image_dict = build_image_dict(dataset_images, label_suffix) + if image_dict: + print("image dict:\n", image_dict) + + # Get label images from the search dataset + search_images = get_search_dataset_images(dataset, label_dataset_id, search_mode, conn) + label_images = [img for img in search_images if is_label_image(img.name, label_suffix)] - elif inputType == "Image": - imageDict = {} - for id in inputIds: - image = conn.getObject("Image", id) - # assuming that not all images have the same Dataset - dataset = image.getAncestry()[0] - for img in list(dataset.listChildren()): - if not id == img.id and not isLabelImage(img.name): - imageDict[img.name] = img.id - if len(imageDict)>0: - print("image dict:\n",imageDict) - plane = get_label_image_as_array(image) + print(f"Found {len(label_images)} label images: {[img.name for img in label_images]}") + + # Group labels by target - this now works across datasets + target_to_labels = group_labels_by_target(label_images, image_dict, label_suffix) + + print(f"Grouped into {len(target_to_labels)} target groups") + + for target_id, target_labels in target_to_labels.items(): print("--------------------------------------") - print(f"processing image '{image.name}' from Dataset '{dataset.name}'") - print(f"shape of plane z=0/t=0/c=0: ", plane.shape) - print("min:", plane.min(), " max:", plane.max(),\ - "pixel type:", plane.dtype.name) - # get the contours - contour_dict, contourTime = create_contours(plane, algorithm) - # find the target image - targetId = get_target_image_id(imageDict, image.name) - if targetId == 0: - pass # TODO: error message if it doesnt find a match - # upload the rois - else: - createdRois, roiTime = upload_ROIs(contour_dict, targetId, algorithm, conn) - newRois = newRois + createdRois - imagesProcessed += 1 - if deleteLabelImage: - delete_image(image, conn) - print(f"{int(contourTime)}s to create contours and {int(roiTime)}s to upload ROIs") - imageDict.clear() + print(f"Processing target image ID {target_id} with {len(target_labels)} label image(s)") + + # Get target image object + target_image = conn.getObject("Image", target_id) + + if clear_rois: + filter_to_use = clear_filter if clear_filter.strip() else None + cleared_count = clear_existing_rois(target_id, conn, filter_to_use) + print(f"Cleared {cleared_count} existing ROIs from target image {target_id}") - return newRois, imagesProcessed + for label_image in target_labels: + print(f"processing label image '{label_image.name}' from Dataset '{label_image.getAncestry()[0].name}'") + plane = get_label_image_as_array(label_image) + print(f"shape of plane z=0/t=0/c=0: {plane.shape}") + print(f"min: {plane.min()}, max: {plane.max()}, pixel type: {plane.dtype.name}") + + contour_dict, contour_time = create_contours(plane, algorithm) + created_rois, roi_time = upload_rois(contour_dict, target_id, algorithm, conn, label_image, target_image, label_suffix) + new_rois.extend(created_rois) + images_processed += 1 + + if delete_label_image: + delete_image(label_image, conn) + + print(f"{int(contour_time)}s to create contours and {int(roi_time)}s to upload ROIs") + + return new_rois, images_processed + + +def process_image_input(input_ids, conn, label_suffix, label_dataset_id, search_mode, + clear_rois, clear_filter, algorithm, delete_label_image): + """Process individual image input to create ROIs from corresponding label images.""" + new_rois = [] + images_processed = 0 + + for image_id in input_ids: + target_image = conn.getObject("Image", image_id) + dataset = target_image.getAncestry()[0] + dataset_images = list(dataset.listChildren()) + + image_dict = build_image_dict(dataset_images, label_suffix, exclude_id=image_id) + if image_dict: + print("image dict:\n", image_dict) + + search_images = get_search_dataset_images(dataset, label_dataset_id, search_mode, conn) + label_image = find_most_precise_label_image(target_image, search_images, label_suffix) + + if not label_image: + print(f"!! Warning: No label image found for '{target_image.name}' !!") + continue + + print("--------------------------------------") + print(f"processing label image '{label_image.name}' for target '{target_image.name}' from Dataset '{dataset.name}'") + + if clear_rois: + filter_to_use = clear_filter if clear_filter.strip() else None + clear_existing_rois(target_image.id, conn, filter_to_use) + + roi_count = process_single_label_image( + label_image, target_image.id, algorithm, conn, label_suffix, delete_label_image + ) + new_rois.extend(roi_count) + images_processed += 1 + + return new_rois, images_processed -# Create contours that will be uploaded as ROIs from label images -def create_contours(labelimage, algorithm): - contourDict = {} - start = time.time() - if algorithm == "Mask": - for i in range(1, labelimage.max() + 1): - mask = (labelimage == i) - contour = omeroi.mask_from_binary_image(mask, text=str(i)) - contourDict[i] = contour - # check if number of contours equals number of grey values - # assuming that each grey value got correctly converted to a contour - # this will (most likely) only work if the labeled regions do not touch - assert len(contourDict)==labelimage.max(), f"skimage.find_contours() found {len(contourDict)} ROIs instead of {labelimage.max()}." - contourTime = time.time() - start - return contourDict, contourTime +def process_single_label_image(label_image, target_id, algorithm, conn, label_suffix, delete_label_image): + """Process a single label image to create ROIs.""" + print(f"processing label image '{label_image.name}' from Dataset '{label_image.getAncestry()[0].name}'") + + plane = get_label_image_as_array(label_image) + print(f"shape of plane z=0/t=0/c=0: {plane.shape}") + print(f"min: {plane.min()}, max: {plane.max()}, pixel type: {plane.dtype.name}") + + contour_dict, contour_time = create_contours(plane, algorithm) - elif algorithm == "Polygon": - multipleContours = [] - for i in range(1, labelimage.max() + 1): - mask = (labelimage == i) - cropped, xOffset, yOffset = get_cropped_mask(mask) - # make sure some signal is in the cropped mask - assert np.array_equal(np.unique(cropped), [0, 1]), "The cropped array does not both contain 0s and 1s" - # check if scikit-image package has been imported - # otherwise use own function - if "skimage" in sys.modules: - contours = find_contours(cropped, level = 0) - else: - contours = own_find_contours(cropped) - # find_contours tends to find "extra" small contours - # this serves only as a debug option to make sure everything got - # recognized correctly - if len(contours) > 1: - # sort contours to make sure the relevant contour is at the start - multipleContours.append(i) - contours = sorted(contours, key = len, reverse = True) - overlengthContours = [] - for counter, contour in enumerate(contours): - if counter == 0: - continue - # I chose length of 6 as this seemed the most sensible - # threshold after some testing - elif len(contour) < 6: - continue - else: - overlengthContours.append(len(contour)) - if len(overlengthContours) > 0: - print(f" for grey value {i} found {len(overlengthContours)} 'overlength' contour(s) with length(s): {overlengthContours}") - contourDict[i] = [[x+yOffset, y+xOffset] for [x,y] in contours[0]] - contourTime = time.time() - start - if len(multipleContours) > 0: - print(f"found multiple contours for the grey value(s) {multipleContours}") - - return contourDict, contourTime - -# upload the ROIs via ezomero or "direct" -def upload_ROIs(contour_dict, parent_id, algorithm, conn): - start = time.time() - newRois = [] - if algorithm == "Mask": - # the Mask Shapes are already omero.model.ShapeI objects - update = conn.getUpdateService() - for greyValue, shape in contour_dict.items(): - roi = RoiI() - roi.name = rstring(greyValue) - roi.image = conn.getObject("Image",parent_id)._obj - roi.addShape(shape) - update = conn.getUpdateService() - roi = update.saveAndReturnObject(roi) - newRois.append(roi.id.val) + # Get the target image object for ROI naming + target_image = conn.getObject("Image", target_id) + created_rois, roi_time = upload_rois(contour_dict, target_id, algorithm, conn, label_image, target_image, label_suffix) - elif algorithm == "Polygon": - # Polygon objects are lists of tuples of x,y coordinates - for greyValue, coordinates in contour_dict.items(): - # create polygon shape for each - flipped = np.flip(coordinates) - shape = [ez.rois.Polygon(flipped, label=str(greyValue))] - #expects a list of tuples of floats, label has to be grey-value - #as label of shape is displayed as ROI-name in OMERO.iviewer - # create roi and link shape to roi - roi_id = ez.post_roi(conn, int(parent_id), shape, name = str(greyValue)) - # create dict grey_value : Roi_ID - newRois.append(roi_id) + if delete_label_image: + delete_image(label_image, conn) + + print(f"{int(contour_time)}s to create contours and {int(roi_time)}s to upload ROIs") + return created_rois + + +def build_image_dict(dataset_images, label_suffix, exclude_id=None): + """Build dictionary of non-label images.""" + image_dict = {} + for img in dataset_images: + if exclude_id and img.id == exclude_id: + continue + if not is_label_image(img.name, label_suffix): + image_dict[img.name] = img.id + return image_dict + + +def group_labels_by_target(label_images, image_dict, label_suffix): + """Group label images by their target image to avoid multiple ROI clearing.""" + target_to_labels = {} + for label_image in label_images: + print(f"DEBUG: Trying to match label '{label_image.name}' with suffix '{label_suffix}'") + target_id = get_target_image_id(image_dict, label_image.name, label_suffix) + print(f"DEBUG: Found target_id: {target_id}") + if target_id != 0: + if target_id not in target_to_labels: + target_to_labels[target_id] = [] + target_to_labels[target_id].append(label_image) + return target_to_labels + + +def get_search_dataset_images(target_dataset, label_dataset_id, search_mode, conn): + """Get images from the appropriate dataset based on search mode.""" + if search_mode == "Same Dataset": + return list(target_dataset.listChildren()) + elif search_mode == "Specific Dataset" and label_dataset_id: + label_dataset = conn.getObject("Dataset", label_dataset_id) + if label_dataset: + return list(label_dataset.listChildren()) + else: + print(f"!! Error: Label dataset with ID {label_dataset_id} not found !!") + return [] + return [] + + +def find_most_precise_label_image(target_image, dataset_images, label_suffix): + """ + Find the most precise label image for a target image. + Returns the label image with the shortest name that matches the target. + """ + target_base = remove_extension(target_image.name) + label_candidates = [] + + for img in dataset_images: + if is_label_image(img.name, label_suffix) and img.name.startswith(target_base): + label_candidates.append(img) + + if not label_candidates: + return None + + label_candidates.sort(key=lambda x: len(x.name)) + + if len(label_candidates) > 1: + print(f"Found {len(label_candidates)} label candidates for '{target_image.name}':") + for candidate in label_candidates: + print(f" - {candidate.name}") + print(f"Selected most precise: {label_candidates[0].name}") + + return label_candidates[0] + + +def get_target_image_id(image_dict, label_name, label_suffix="-label"): + """ + Find target image ID for a given label image name. + Prioritizes precision while maintaining recall. + """ + label_base = remove_extension(label_name) + possible_targets = [] + temp_name = label_base - roiTime = time.time() - start - print(f"created new Rois: {newRois}") - - return newRois, roiTime - -# get the matching image id -def get_target_image_id(imageDict, labelName): - origName = labelName[:labelName.rfind("-label")].strip() - for name,id in imageDict.items(): - if origName in name: - print(f"found matching image '{name}'") - return id - return 0 - -# determine if an image name comes from a label image -def isLabelImage(name): - # check if the name has any suffix - withoutSuffix = name - if "." in name[-6:]: - withoutSuffix = name[:name.rfind(".")] - # check if it was a .ome.tiff - if withoutSuffix.endswith("ome"): - withoutSuffix = withoutSuffix[:withoutSuffix.rfind(".")] - # check if the "actual" name ends on "-label" - if withoutSuffix.endswith("-label"): - return True - else: - return False - -# helper function to get the image as an numpy array + while label_suffix in temp_name: + suffix_index = temp_name.rfind(label_suffix) + target_base = temp_name[:suffix_index] + if target_base and target_base not in possible_targets: + possible_targets.append(target_base) + temp_name = target_base + + if not possible_targets: + return 0 + + valid_matches = [] + for name, img_id in image_dict.items(): + target_base = remove_extension(name) + if target_base in possible_targets: + valid_matches.append((target_base, name, img_id)) + + if not valid_matches: + return 0 + + valid_matches.sort(key=lambda x: len(x[0]), reverse=True) + best_match = valid_matches[0] + + print(f"found matching image '{best_match[1]}' for label '{label_name}' (base: '{best_match[0]}')") + return best_match[2] + + +def remove_extension(filename): + """Get basename without any extension.""" + return filename[:filename.index('.')] if '.' in filename else filename + + +def is_label_image(name, label_suffix="-label"): + """Check if image name indicates it's a label image.""" + return label_suffix in name + + +def clear_existing_rois(image_id, conn, roi_name_filter=None): + """Clear existing ROIs from an image.""" + roi_service = conn.getRoiService() + result = roi_service.findByImage(image_id, None) + + if not result or not result.rois: + return 0 + + rois_to_delete = [] + for roi in result.rois: + roi_name = roi.name.val if roi.name else "" + if roi_name_filter is None or roi_name_filter in roi_name: + rois_to_delete.append(roi.id.val) + + if rois_to_delete: + delete = Delete2(targetObjects={"Roi": rois_to_delete}) + conn.c.submit(delete, loops=5, ms=2000) + + return len(rois_to_delete) + + def get_label_image_as_array(image): - z, t, c = 0, 0, 0 # first plane of the image + """Get the image as a numpy array.""" + z, t, c = 0, 0, 0 pixels = image.getPrimaryPixels() - # get a numpy array - plane = pixels.getPlane(z, c, t) - return plane + return pixels.getPlane(z, c, t) + -# helper function to delete an image def delete_image(image, conn): - delete = Delete2(targetObjects = {"Image":[image.id]}) + """Delete an image from OMERO.""" + delete = Delete2(targetObjects={"Image": [image.id]}) conn.c.submit(delete, loops=5, ms=2000) -# get a cropped sub-mask from a bool-mask + def get_cropped_mask(mask): - # adapted from omero_rois package + """Get a cropped sub-mask from a boolean mask.""" xmask = mask.sum(0).nonzero()[0] ymask = mask.sum(1).nonzero()[0] - x0 = min(xmask) - # padd everything by one pixel to - # enable find_contours to work better - if x0 != 0: - x0 -= 1 + x0 = max(0, min(xmask) - 1) w = max(xmask) - x0 + 2 - y0 = min(ymask) - if y0 != 0: - y0 -= 1 + y0 = max(0, min(ymask) - 1) h = max(ymask) - y0 + 2 - submask = mask[y0 : (y0 + h), x0 : (x0 + w)] + return mask[y0:(y0 + h), x0:(x0 + w)], x0, y0 + + +def create_contours(label_image, algorithm): + """Create contours that will be uploaded as ROIs from label images.""" + contour_dict = {} + start = time.time() + + if algorithm == "Mask": + contour_dict = create_mask_contours(label_image) + elif algorithm == "Polygon": + contour_dict = create_polygon_contours(label_image) + + contour_time = time.time() - start + return contour_dict, contour_time + + +def create_mask_contours(label_image): + """Create mask-based contours.""" + contour_dict = {} + for i in range(1, label_image.max() + 1): + mask = (label_image == i) + contour = omeroi.mask_from_binary_image(mask, text=str(i)) + contour_dict[i] = contour + + assert len(contour_dict) == label_image.max(), \ + f"Expected {label_image.max()} ROIs, found {len(contour_dict)}." + + return contour_dict + + +def create_polygon_contours(label_image): + """Create polygon-based contours.""" + contour_dict = {} + multiple_contours = [] + + for i in range(1, label_image.max() + 1): + mask = (label_image == i) + cropped, x_offset, y_offset = get_cropped_mask(mask) + + assert np.array_equal(np.unique(cropped), [0, 1]), \ + "The cropped array does not contain both 0s and 1s" + + if "skimage" in sys.modules: + contours = find_contours(cropped, level=0) + else: + contours = own_find_contours(cropped) + + if len(contours) > 1: + multiple_contours.append(i) + contours = sorted(contours, key=len, reverse=True) + overlength_contours = [len(contour) for contour in contours[1:] if len(contour) >= 6] + if overlength_contours: + print(f" for grey value {i} found {len(overlength_contours)} 'overlength' contour(s) with length(s): {overlength_contours}") + + contour_dict[i] = [[x + y_offset, y + x_offset] for [x, y] in contours[0]] + + if multiple_contours: + print(f"found multiple contours for the grey value(s) {multiple_contours}") + + return contour_dict + + +def upload_rois(contour_dict, parent_id, algorithm, conn, label_image, target_image, label_suffix="-label"): + """Upload ROIs to OMERO.""" + start = time.time() + new_rois = [] + + # Get unique prefix from label filename + roi_prefix = get_roi_name_prefix(label_image.name, target_image.name, label_suffix) + + if algorithm == "Mask": + new_rois = upload_mask_rois(contour_dict, parent_id, conn, roi_prefix) + elif algorithm == "Polygon": + new_rois = upload_polygon_rois(contour_dict, parent_id, conn, roi_prefix) + + roi_time = time.time() - start + print(f"created new Rois: {new_rois}") + return new_rois, roi_time + + +def upload_mask_rois(contour_dict, parent_id, conn, clean_suffix): + """Upload mask-based ROIs.""" + new_rois = [] + update = conn.getUpdateService() + + for grey_value, shape in contour_dict.items(): + roi = RoiI() + roi.name = rstring(f"{clean_suffix}_{grey_value}") + roi.image = conn.getObject("Image", parent_id)._obj + roi.addShape(shape) + roi = update.saveAndReturnObject(roi) + new_rois.append(roi.id.val) - return submask, x0, y0 + return new_rois + + +def upload_polygon_rois(contour_dict, parent_id, conn, clean_suffix): + """Upload polygon-based ROIs.""" + new_rois = [] + + for grey_value, coordinates in contour_dict.items(): + flipped = np.flip(coordinates) + roi_name = f"{clean_suffix}_{grey_value}" + shape = [ez.rois.Polygon(flipped, label=roi_name)] + roi_id = ez.post_roi(conn, int(parent_id), shape, name=roi_name) + new_rois.append(roi_id) + + return new_rois + -# custom implementation of skimage.measure.find_contours() def own_find_contours(image): + """Custom implementation of skimage.measure.find_contours().""" segments = _get_contour_segments(image.astype(np.float64)) contours = _assemble_contours(segments) return contours -################################################################################################ -# from scikit-image find_contours() Cython->Python Conversion # -# original code: # -# https://github.com/scikit-image/scikit-image/blob/main/skimage/measure/_find_contours_cy.pyx # -################################################################################################ + def _get_fraction(from_value, to_value): - if to_value == from_value: - return 0 - return (0 - from_value) / (to_value - from_value) + """Calculate fraction for contour interpolation.""" + return 0 if to_value == from_value else (0 - from_value) / (to_value - from_value) + def _get_contour_segments(array): + """Get contour segments from array.""" segments = [] - + for r0 in range(array.shape[0] - 1): for c0 in range(array.shape[1] - 1): r1, c1 = r0 + 1, c0 + 1 - + ul = array[r0, c0] ur = array[r0, c1] ll = array[r1, c0] lr = array[r1, c1] - - square_case = 0 - if ul > 0: square_case += 1 - if ur > 0: square_case += 2 - if ll > 0: square_case += 4 - if lr > 0: square_case += 8 - + + square_case = (ul > 0) + 2 * (ur > 0) + 4 * (ll > 0) + 8 * (lr > 0) + if square_case in [0, 15]: continue - + top = r0, c0 + _get_fraction(ul, ur) bottom = r1, c0 + _get_fraction(ll, lr) left = r0 + _get_fraction(ul, ll), c0 right = r0 + _get_fraction(ur, lr), c1 - - if (square_case == 1): - # top to left - segments.append((top, left)) - elif (square_case == 2): - # right to top - segments.append((right, top)) - elif (square_case == 3): - # right to left - segments.append((right, left)) - elif (square_case == 4): - # left to bottom - segments.append((left, bottom)) - elif (square_case == 5): - # top to bottom - segments.append((top, bottom)) - elif (square_case == 6): - segments.append((right, top)) - segments.append((left, bottom)) - elif (square_case == 7): - # right to bottom - segments.append((right, bottom)) - elif (square_case == 8): - # bottom to right - segments.append((bottom, right)) - elif (square_case == 9): - segments.append((top, left)) - segments.append((bottom, right)) - elif (square_case == 10): - # bottom to top - segments.append((bottom, top)) - elif (square_case == 11): - # bottom to left - segments.append((bottom, left)) - elif (square_case == 12): - # lef to right - segments.append((left, right)) - elif (square_case == 13): - # top to right - segments.append((top, right)) - elif (square_case == 14): - # left to top - segments.append((left, top)) - + + segment_map = { + 1: (top, left), 2: (right, top), 3: (right, left), 4: (left, bottom), + 5: (top, bottom), 6: [(right, top), (left, bottom)], 7: (right, bottom), + 8: (bottom, right), 9: [(top, left), (bottom, right)], 10: (bottom, top), + 11: (bottom, left), 12: (left, right), 13: (top, right), 14: (left, top) + } + + segment = segment_map.get(square_case) + if isinstance(segment, list): + segments.extend(segment) + else: + segments.append(segment) + return segments + def _assemble_contours(segments): + """Assemble contour segments into complete contours.""" current_index = 0 contours = {} starts = {} ends = {} + for from_point, to_point in segments: - # Ignore degenerate segments. - # This happens when (and only when) one vertex of the square is - # exactly the contour level, and the rest are above or below. - # This degenerate vertex will be picked up later by neighboring - # squares. if from_point == to_point: continue - + tail, tail_num = starts.pop(to_point, (None, None)) head, head_num = ends.pop(from_point, (None, None)) - + if tail is not None and head is not None: - # We need to connect these two contours. if tail is head: - # We need to closed a contour: add the end point head.append(to_point) - else: # tail is not head - # We need to join two distinct contours. - # We want to keep the first contour segment created, so that - # the final contours are ordered left->right, top->bottom. + else: if tail_num > head_num: - # tail was created second. Append tail to head. head.extend(tail) - # Remove tail from the detected contours contours.pop(tail_num, None) - # Update starts and ends starts[head[0]] = (head, head_num) ends[head[-1]] = (head, head_num) - else: # tail_num <= head_num - # head was created second. Prepend head to tail. + else: tail.extendleft(reversed(head)) - # Remove head from the detected contours - starts.pop(head[0], None) # head[0] can be == to_point! + starts.pop(head[0], None) contours.pop(head_num, None) - # Update starts and ends starts[tail[0]] = (tail, tail_num) ends[tail[-1]] = (tail, tail_num) elif tail is None and head is None: - # We need to add a new contour new_contour = deque((from_point, to_point)) contours[current_index] = new_contour starts[from_point] = (new_contour, current_index) ends[to_point] = (new_contour, current_index) current_index += 1 - elif head is None: # tail is not None - # tail first element is to_point: the new segment should be - # prepended. + elif head is None: tail.appendleft(from_point) - # Update starts starts[from_point] = (tail, tail_num) - else: # tail is None and head is not None: - # head last element is from_point: the new segment should be - # appended + else: head.append(to_point) - # Update ends ends[to_point] = (head, head_num) - + return [np.array(contour) for _, contour in sorted(contours.items())] -################################################ +def get_roi_name_prefix(label_name, target_name, label_suffix): + """ + Get a unique ROI name prefix based on the label filename. + For label 'test_nuc_cells.tif' with target 'test.tif', returns 'nuc_cells' + For label 'test_nuc.tif' with target 'test.tif', returns 'nuc' + """ + label_base = remove_extension(label_name) + target_base = remove_extension(target_name) + + # Remove the target base from the beginning + if label_base.startswith(target_base): + unique_part = label_base[len(target_base):] + # Remove leading underscores or other separators + unique_part = unique_part.lstrip('_-.') + return unique_part if unique_part else 'label' + + # Fallback to removing suffix + if label_suffix in label_base: + return label_base.replace(label_suffix, '').strip('_-.') + + return 'label' + def run_script(): - data_types = [rstring('Dataset'),rstring('Image')] + """Main script entry point.""" + data_types = [rstring('Dataset'), rstring('Image')] shape_types = [rstring("Polygon"), rstring("Mask")] + search_modes = [rstring("Same Dataset"), rstring("Specific Dataset")] client = scripts.client( 'Labels2Rois', """ - Creates (named) Rois from Label images.\n - For correct mapping of the Rois the Label image must have\n - the same name as the target image and end with '-label.*'\n - and also be in the same Dataset + Creates (named) ROIs from label images. + + For correct mapping of the ROIs, the label image must have + the same name as the target image and contain the specified suffix + (default: '-label'). Label images can be in the same dataset + or a specific dataset. Optionally clear existing ROIs. """, scripts.String( @@ -432,30 +589,45 @@ def run_script(): scripts.String( "ROI_type", optional=False, grouping="3", - description="Select 'Polygon' or 'Mask'." + - " A 'Mask' Shape will cover the segmented region, a 'Polygon'" + - " will create an outline around it.\nIt also determines which " + - "algorithm will be used. The 'Mask' algorithm is faster if the " + - "ROIs do not touch.", values=shape_types, default="Polygon"), + description="Select 'Polygon' or 'Mask'. A 'Mask' shape will cover the segmented region, a 'Polygon' will create an outline around it. It also determines which algorithm will be used. The 'Mask' algorithm is faster if the ROIs do not touch.", + values=shape_types, default="Polygon"), + + scripts.String( + "Search_Mode", optional=False, grouping="4", + description="Where to search for label images", + values=search_modes, default="Same Dataset"), + + scripts.Long( + "Label_Dataset_ID", optional=True, grouping="4.1", + description="Dataset ID for label images (when using 'Specific Dataset')"), + + scripts.String( + "Label_Suffix", optional=True, grouping="5", default="-label", + description="Suffix that identifies label images (default: '-label')"), + + scripts.Bool( + "Clear_Existing_ROIs", optional=False, grouping="6", default=False, + description="Delete existing ROIs before adding new ones"), + + scripts.String( + "Clear_ROI_Filter", optional=True, grouping="6.1", default="", + description="Only delete ROIs containing this text (leave empty for all)"), scripts.Bool( - "Delete_Label_Image", optional=False, grouping="5", default=False, - description="Deletes the Label image(s) after the conversion to Rois is done."), - + "Delete_Label_Image", optional=False, grouping="7", default=False, + description="Delete the label image(s) after conversion to ROIs is complete"), + authors=["Jens Wendt"], contact="https://forum.image.sc/tag/omero, jens.wendt@uni-muenster.de", - version="0.1" - ) + version="0.5" + ) try: script_params = client.getInputs(unwrap=True) conn = BlitzGateway(client_obj=client) - # main function - newRois, imagesProcessed = labels2rois(script_params, conn) - - message = f"created {len(newRois)} ROIs in {imagesProcessed} images" + new_rois, images_processed = labels2rois(script_params, conn) + message = f"created {len(new_rois)} ROIs in {images_processed} images" client.setOutput("Message", rstring(message)) - finally: client.closeSession() diff --git a/README.md b/README.md index 220932a..bbcbe3e 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,34 @@ # Labels2Rois ## 1. Overview This is an OMERO.web script to convert grey scale label images into OMERO ROIs.
-Depending on the python packages installed in the OMERO server virtual environment one can choose between Poylgon or Mask as +Depending on the python packages installed in the OMERO server virtual environment one can choose between Polygon or Mask as a Shape for the ROIs.
+The script supports flexible label image locations, custom naming suffixes, and ROI management features for comparing different segmentation methods. ## 2. Naming convention -For now the script relies on the label images having the same exact name as the original image with "-label" added to the end.
-For example `Larvae_3w_400l_trRL.ome.tiff` will need a label image `Larvae_3w_400l_trRL-label.ome.tif` for the script to recognize it.
-This label image needs to be in the same dataset as the its "original" image.
-The created Shapes and ROIs will be named according to the corresponding grey-value their original label had. +The script identifies label images by a customizable suffix (default: `-label`) in the filename.
+**Examples:** +- `cells.ome.tiff` → `cells-label.ome.tiff` +- `experiment.tif` → `experiment_nuclei.tif` (with suffix `_nuclei`) +- `test.tif` → `test.tif.0.tif` (with suffix `.tif.0`) +### 2.1 Label Image Locations +Label images can be located in: +- **Same Dataset** (default): Original behavior, labels in same dataset as target images +- **Specific Dataset**: Search for labels in a different dataset by providing its ID + +### 2.2 ROI Naming +Created ROIs are named using the label suffix and grey value: `{suffix}_{number}` +- `cells_cellpose.tif` → ROIs: `cellpose_1`, `cellpose_2`... +- `nuclei-stardist.png` → ROIs: `stardist_1`, `stardist_2`... + +This allows easy comparison of different segmentation methods on the same image. + +### 2.3 ROI Management +Optionally clear existing ROIs before adding new ones: +- **Clear All**: Remove all existing ROIs +- **Selective**: Only remove ROIs containing specific text (e.g., "cellpose") ## 3. Package dependencies For the creation of the `Polygon` ROIs I rely on [ezomero](https://github.com/TheJacksonLaboratory/ezomero), a fantastic toolbox to make life easier for OMERO devs.
@@ -22,7 +40,6 @@ An OMERO ROI (`omero.model.Roi`) is a container object consisting of one or mult If you want to avoid additional packages and the related dependency-bloat you can still create Polygon Shapes as is, with just a small increase in script runtime.
To achieve that I refactored the underlying Cython code from the relevant `scikit-image` function into "pure" Python, therefore having only `numpy` as dependency. - ### 3.1 Mask Shape Creating Mask Shapes for the ROIs relies on the package `omero_rois` created by the OME team. @@ -31,7 +48,7 @@ Creating Polygon Shapes for the ROIs relies on the `find_contours()` function fr In short this functions relies on the "marching squares" algorithm. For more details read the comments in the source code linked above.
To install `scikit-image` on our OMERO instance we had to install the following previously uninstalled packages: ``` -PyWavelets, cycler, decorator, imageion, kiwisolver, matplotlib, networkx, scipy, tifffile +PyWavelets, cycler, decorator, imageio, kiwisolver, matplotlib, networkx, scipy, tifffile ```
@@ -40,13 +57,22 @@ Here is the dependency tree (made with `pipdeptree`) for the `scikit-image` inst -## 3. Caveats +## 4. Script Parameters +| Parameter | Description | Default | +|-----------|-------------|---------| +| `Label_Suffix` | Suffix identifying label images | `-label` | +| `Search_Mode` | Where to find labels: "Same Dataset" or "Specific Dataset" | Same Dataset | +| `Label_Dataset_ID` | Dataset ID when using "Specific Dataset" mode | - | +| `Clear_Existing_ROIs` | Remove existing ROIs before adding new ones | False | +| `Clear_ROI_Filter` | Only remove ROIs containing this text | - | + +## 5. Caveats - At the moment the input Data type is limited to Datasets and Images.
This can easily expanded in the future if the need arises, just contact the author via mail or image.sc.
- The script has not been tested on really complex ROI patterns. There might be situations where the underlying `find_contours()` function from `scikit-image` will fail to produce an accurate Polygon ROI.
The underlying function to create Mask ROIs though is independent of shape complexity and might provide a good fallback option in this case. -## 4. Outlook +## 6. Outlook If you use the script and see room for improvement or have a special use case that is not covered by the generic code I wrote, please write an issue here at Github or contact me via mail or [Image.sc](https://forum.image.sc/).
I might implement some logic to artificially create a Polygon Shape from the Mask Shape the is created with `omero_rois` to better deal with complex ROI forms.
To make it more generic, there might also be the option to put in a regex pattern to determine the label images from the selection.