Source code for arthropod_describer.plugins.test_plugin.properties.basic_props

import functools
import operator
import time
import typing
from pathlib import Path
from typing import Optional, List
import copy

import cv2
import numpy as np
import skimage.measure
from skimage import io
from skimage.color import rgb2hsv
from skimage.morphology import binary_erosion
import skimage.transform
import scipy.ndimage
from sklearn.decomposition import PCA

from arthropod_describer.common.label_hierarchy import LabelHierarchy
from arthropod_describer.common.label_image import RegionProperty, PropertyType, LabelImg
from arthropod_describer.common.photo import Photo
from arthropod_describer.common.plugin import PropertyComputation
from arthropod_describer.common.common import Info
from arthropod_describer.common.regions_cache import RegionsCache, Region
from arthropod_describer.common.units import Value, Unit, SIPrefix, BaseUnit
from .geodesic_utils import get_longest_geodesic, get_longest_geodesic2, compute_longest_geodesic, find_shortest_path


[docs]class BasicProperties: """ NAME: Basic properties DESCRIPTION: Computes basic region properties. REGION_RESTRICTED USER_PARAMS: PARAM_NAME: ABC PARAM_KEY: abc PARAM_TYPE: INT VALUE: 10 MIN_VALUE: 5 MAX_VALUE: 25 """ def __init__(self, info: Optional[Info] = None): PropertyComputation.__init__(self, info) self.info = Info.load_from_doc_str(self.__doc__) self._px_unit: Unit = Unit(BaseUnit.px, prefix=SIPrefix.none, dim=1) self._no_unit: Unit = Unit(BaseUnit.none, prefix=SIPrefix.none, dim=0) self._available_props = { # 'area': Info('Area', key='area', description='Area of the region (px or mm\u00b2)'), 'mean_intensity': Info('Mean intensity', key='mean_intensity', description='Mean intensity (R, G, B)'), 'circularity': Info('Circularity', key='circularity', description='Circularity (0.0 to 1.0, where 1.0 = perfect circle)'), 'max_feret': Info('Max Feret', key='max_feret', description='Maximum Feret diameter (px or mm)'), 'geodesic_length': Info('Geodesic length', key='geodesic_length', description='Geodesic length (px or mm)'), 'mean_width': Info('Mean width', key='mean_width', description='Mean width of a region (px or mm)'), 'contour': Info('Contour', key='contour', description='Compute contour feature vector'), 'mean_hsv': Info('Mean HSV', key='mean_hsv', description='Mean HSV of a region') } def _compute_mean_intensity(self, region: Region, refl: np.ndarray) -> List[RegionProperty]: # mask = np.logical_xor(lab_img > 0, refl > 0) # label_img = np.where(mask, lab_img, 0) # reg_props = skimage.measure.regionprops_table(label_img, photo, # properties=['label', 'mean_intensity']) top, left, height, width = region.bbox refl_roi = refl[top:top + height, left:left + width] mask = np.logical_xor(region.mask, refl_roi > 0) yy, xx = np.nonzero(mask) pixels = region.image[yy, xx] mean_intensity = np.mean(pixels, axis=0) prop = RegionProperty() prop.info = copy.deepcopy(self.example('mean_intensity').info) prop.label = int(region.label) prop.value = (mean_intensity.tolist(), self._no_unit) prop.prop_type = PropertyType.Intensity prop.num_vals = 3 prop.val_names = ['R', 'G', 'B'] return [prop] props: List[RegionProperty] = [] for idx, label in enumerate(reg_props['label']): if label not in labels: continue prop = RegionProperty() prop.info = copy.deepcopy(self._available_props['mean_intensity']) prop.label = int(label) prop.value = ([ float(reg_props['mean_intensity-0'][idx]), float(reg_props['mean_intensity-1'][idx]), float(reg_props['mean_intensity-2'][idx]) ], self._no_unit) prop.prop_type = PropertyType.Intensity prop.num_vals = 3 prop.val_names = ['R', 'G', 'B'] props.append(prop) return props def _compute_area(self, region: Region, photo: Photo) -> List[RegionProperty]: props = [] prop = RegionProperty() prop.label = region.label prop.info = copy.deepcopy(self._available_props['area']) # prop.value = int(np.count_nonzero(lab_img == label)) value = Value(int(np.count_nonzero(region.mask)), self._px_unit * self._px_unit) if photo.image_scale is not None and photo.image_scale.value > 0: prop.value = value / (photo.image_scale * photo.image_scale) # prop.unit = 'mm\u00b2' # TODO sync unit with the units in Photo else: prop.value = value prop.prop_type = PropertyType.Scalar prop.val_names = ['Area'] prop.num_vals = 1 props.append(prop) return props def _compute_max_feret(self, lab_img: np.ndarray, labels: typing.Set[int], photo: Photo) -> List[RegionProperty]: reg_props = skimage.measure.regionprops_table(lab_img, photo.image, properties=['label', 'feret_diameter_max']) props: List[RegionProperty] = [] for idx, label in enumerate(reg_props['label']): if label not in labels: continue prop = RegionProperty() prop.label = int(label) prop.info = copy.deepcopy(self._available_props['max_feret']) prop.value = Value(float(reg_props['feret_diameter_max'][idx]), self._px_unit) if photo.image_scale is not None and photo.image_scale.value > 0: # prop.value /= photo.image_scale prop.value = prop.value / photo.image_scale prop.unit = 'mm' # TODO sync unit with the units in Photo else: prop.unit = 'px' prop.prop_type = PropertyType.Scalar prop.val_names = ['Max Feret'] prop.num_vals = 1 props.append(prop) return props def _compute_circularity(self, lab_img: np.ndarray, labels: typing.Set[int], photo: Photo, perimeter_measurement_flavor: str) -> List[RegionProperty]: # 'perimeter' or 'perimeter_crofton' is possible as perimeter_measurement_flavor reg_props = skimage.measure.regionprops_table(lab_img, photo.image, properties=['label', perimeter_measurement_flavor]) props: List[RegionProperty] = [] for idx, label in enumerate(reg_props['label']): if label not in labels: continue perimeter = reg_props[perimeter_measurement_flavor][idx] area = int(np.count_nonzero(lab_img == label)) if perimeter == 0: circularity = 0 # TODO: Careful about division by zero (can we somehow return N/A here?) else: circularity = np.clip((4 * np.pi * area) / (perimeter ** 2), 0.0, 1.0) #print(f'idx: {idx}, label: {label}') #print(f' perimeter: {perimeter}') #print(f' area: {area}') #print(f' circularity: {circularity}') prop = RegionProperty() prop.label = int(label) prop.info = copy.deepcopy(self._available_props['circularity']) prop.value = Value(float(circularity), Unit(BaseUnit.none, SIPrefix.none, dim=0)) # prop.unit = '' # TODO: Is this ok for a unitless property? prop.prop_type = PropertyType.Scalar prop.val_names = ['Circularity'] prop.num_vals = 1 props.append(prop) return props def _compute_geodesic_length(self, lab_img: np.ndarray, labels: typing.Set[int], photo: Photo) -> List[RegionProperty]: props: List[RegionProperty] = [] for label in labels: # _, length = get_longest_geodesic(lab_img, label) length, _, _ = compute_longest_geodesic(lab_img == label) # compute_longest_geodesic(lab_img == label) if length < 0: continue prop = RegionProperty() prop.label = int(label) prop.info = copy.deepcopy(self._available_props['geodesic_length']) value = Value(float(length), self._px_unit) if photo.image_scale is not None: # prop.unit = 'mm' prop.value = value / photo.image_scale else: prop.value = value prop.val_names = ['Geodesic length'] prop.num_vals = 1 props.append(prop) return props def _compute_mean_width(self, lab_img: LabelImg, labels: typing.Set[int], photo: Photo) -> List[RegionProperty]: props: List[RegionProperty] = [] for label in labels: bin_img = lab_img.mask_for(label) if not np.any(bin_img): continue # geodesic, length, bbox = get_longest_geodesic2(bin_img) # bin_roi = bin_img[bbox[0]:bbox[1]+1, bbox[2]:bbox[3]+1] gdist, px1, px2 = compute_longest_geodesic(bin_img) geodesic = find_shortest_path(bin_img, px1, px2) geodesic = ([px[1] for px in geodesic], [px[0] for px in geodesic]) outline = np.logical_and(bin_img, binary_erosion(bin_img, footprint=np.ones((3, 3), dtype=np.uint8))) dst: np.ndarray = scipy.ndimage.distance_transform_edt(outline) mean_width = np.mean(2.0 * dst[geodesic[0], geodesic[1]]) if np.isnan(mean_width): # TODO inspect `get_longest_geodesic2` function mean_width = -42.0 # io.imsave(f'C:\\Users\\radoslav\\Desktop\\mean_width\\{label}_bin_roi.png', bin_roi, check_contrast=False) # io.imsave(f'C:\\Users\\radoslav\\Desktop\\mean_width\\{label}_outline.png', outline, check_contrast=False) # io.imsave(f'C:\\Users\\radoslav\\Desktop\\mean_width\\{label}_dst.png', # (255.0 * (dst / (np.max(dst) + 1e-6))).astype(np.uint8), check_contrast=False) prop = RegionProperty() prop.info = copy.deepcopy(self._available_props['mean_width']) prop.prop_type = PropertyType.Scalar prop.label = label if photo.image_scale is not None: prop.value = Value(float(mean_width), self._px_unit) / photo.image_scale # prop.unit = 'mm' else: prop.value = Value(float(mean_width), self._px_unit) # prop.unit = 'px' prop.num_vals = 1 prop.val_names = ['Mean width'] props.append(prop) return props def _compute_contour_feature_vector(self, lab_img: np.ndarray, labels: typing.Set[int], photo: Photo, lab_hier: LabelHierarchy) -> List[RegionProperty]: props: List[RegionProperty] = [] # folder = 'radoslav\\Desktop\\contours' #folder = 'Karel\\Desktop\\arthropods_debug' # path = Path(f'C:\\Users\\{folder}\\{photo.image_name}') # if not path.exists(): # path.mkdir() for label in labels: # lab_path = path / lab_hier.nodes[label].name # if not lab_path.exists(): # lab_path.mkdir() region = lab_img == label if not np.any(region): continue pixels = np.argwhere(region) # format (y, x) top, left = np.min(pixels[:,0]), np.min(pixels[:,1]) bottom, right = np.max(pixels[:,0]), np.max(pixels[:,1]) roi = region[top:bottom+1, left:right+1] roi_rgb = cv2.cvtColor(255 * roi.astype(np.uint8), cv2.COLOR_GRAY2BGR) # cv2.imwrite(str(lab_path / 'region.png'), roi_rgb) # pixels = pixels - np.array([top, left]) # make local to `roi` yy, xx = np.nonzero(roi) x_c, y_c = round(np.mean(xx)), round(np.mean(yy)) # centroid for nonzero pixels in `roi` # roi_cent = cv2.circle(roi_rgb, (x_c, y_c), 3, [0, 255, 0]) # cv2.imwrite(str(lab_path / 'roi_centroid.png'), roi_cent) reg_orient = skimage.measure.regionprops_table(1 * roi, properties=('orientation',)) # get the angle between y-axis and the major axis of the region in `roi` angle = np.rad2deg(reg_orient['orientation'][0]) # pca = PCA(n_components=2) # compute principal axes for `pixels[::-1]` format (x, y) # pca.fit(pixels[::-1]) # angle = np.rad2deg(np.arccos(np.dot(np.array([0.0, -1.0]), pca.components_[-1]))) # axis = cv2.line(roi_rgb, (x_c, y_c), (round(x_c + 100 * pca.components_[-1][0]), # round(y_c + 100 * pca.components_[-1][1])), # [255, 0, 0], 2) # cv2.imwrite(str(lab_path / 'axis.png'), axis) # rotate `roi` so that the major axis coincides with y-axis rotated = skimage.transform.rotate(roi, angle=-angle, center=(x_c, y_c), resize=True) # io.imsave(str(lab_path / 'roi_rotated.png'), rotated) # outline = np.logical_xor(rotated, binary_erosion(rotated, footprint=np.ones((3, 3)))) outline = np.logical_xor(rotated, cv2.erode(255 * rotated.astype(np.uint8), np.ones((3, 3)), borderValue=0, borderType=cv2.BORDER_CONSTANT) > 0) # io.imsave(str(lab_path / 'outline.png'), outline) outline_yy, outline_xx = np.nonzero(outline) xs_by_y: typing.Dict[int, List[int]] = {} for y, x in zip(outline_yy, outline_xx): xs_by_y.setdefault(y, []).append(x) outline_yy = np.unique(outline_yy) # y_sort_inds = np.argsort(outline_yy) step = outline_yy.shape[0] / 40.0 feat_vector: List[float] = [] # indices_ = outline_yy.tolist() #list(outline_yy[y_sort_inds]) # indices = indices_[::step] # print(f'height is {outline_yy.shape} and step is {step}') # print(f'indices are {indices}') # if (last_idx := max(y_sort_inds)) not in indices: # indices.append(last_idx) # viz = cv2.cvtColor(255 * outline.copy().astype(np.uint8), cv2.COLOR_GRAY2BGR) y_start = outline_yy[0] y_curr = y_start offset = 0 for i in range(40): y_curr = min(int(round(y_start + offset)), max(outline_yy)) offset += step # y = [idx] left = min(xs_by_y[y_curr]) right = max(xs_by_y[y_curr]) # viz[y_curr, left] = [255, 0, 0] # viz[y_curr, right] = [0, 255, 0] width = 0.5 * (right - left + 1) if photo.image_scale is not None: width /= photo.image_scale.value feat_vector.append(width) # print(f'computed contour vector for {photo.image_name}: {feat_vector}') # cv2.imwrite(str(lab_path / 'viz.png'), viz) prop = self.example('contour') if photo.image_scale is not None: prop.value = (feat_vector, self._px_unit / photo.image_scale.unit) else: prop.value = (feat_vector, self._px_unit) prop.label = label props.append(prop) return props def _compute_mean_hsv(self, lab_img: np.ndarray, labels: typing.Set[int], photo: Photo, lab_hier: LabelHierarchy) -> List[RegionProperty]: props: List[RegionProperty] = [] hsv_img = rgb2hsv(photo.image) hsv_img[:, :, 0] = 360 * hsv_img[:, :, 0] reflection = photo['Reflections'].label_image for label in labels: region_mask = np.logical_and(lab_img == label, reflection == 0) ys, xs = np.nonzero(region_mask) if len(ys) == 0: continue hues = hsv_img[ys, xs, 0] hue_radians = np.pi * hues / 180.0 avg_sin = np.mean(np.sin(hue_radians)) avg_cos = np.mean(np.cos(hue_radians)) average_vector_angle = np.arctan2(avg_sin, avg_cos) average_vector_degrees = np.mod((180.0 / np.pi) * average_vector_angle, 360) average_vector_length = np.sqrt(avg_sin * avg_sin + avg_cos * avg_cos) mean_sat = np.mean(hsv_img[ys, xs, 1]) mean_val = np.mean(hsv_img[ys, xs, 2]) prop = copy.deepcopy(self.example('mean_hsv')) prop.value = ([float(average_vector_degrees), 100 * float(mean_sat * average_vector_length), 100 * float(mean_val)], self._no_unit) prop.label = label props.append(prop) return props def __call__(self, photo: Photo, prop_labels: typing.Dict[int, typing.Set[str]], regions_cache: RegionsCache) \ -> List[RegionProperty]: reg_img = photo['Labels'] props = [] # all_labels: typing.Set[int] = set(functools.reduce(set.union, prop_labels.keys())) # all_labels -- All labels for which the user requested any property to be computed. level_groups = reg_img.label_hierarchy.group_by_level(set(prop_labels.keys())) now = time.time() for region_label, prop_strs in prop_labels.items(): if region_label not in regions_cache.regions: continue region: Region = regions_cache.regions[region_label] for prop_str in prop_strs: if prop_str == 'mean_intensity': _props = self._compute_mean_intensity(region, photo['Reflections'].label_image) elif prop_str == 'area': _props = self._compute_area(region, photo) # elif prop_str == 'circularity': # _props = self._compute_circularity(level_img, level_labels_for_prop, photo, 'perimeter') # elif prop_str == 'max_feret': # _props = self._compute_max_feret(level_img, level_labels_for_prop, photo) # elif prop_str == 'geodesic_length': # _props = self._compute_geodesic_length(level_img, level_labels_for_prop, photo) # elif prop_str == 'mean_width': # _props = self._compute_mean_width(reg_img, labels, photo) # elif prop_str == 'contour': # _props = self._compute_contour_feature_vector(level_img, level_labels_for_prop, photo, # reg_img.label_hierarchy) # elif prop_str == 'mean_hsv': # _props = self._compute_mean_hsv(level_img, level_labels_for_prop, photo, reg_img.label_hierarchy) else: print(f'property {prop_str} not fully implemented') _props = [] props.extend(_props) print(f'took {time.time() - now} secs') return props for level, level_labels in level_groups.items(): level_img = reg_img[level] for prop_str, labels in prop_labels.items(): level_labels_for_prop = labels.intersection(level_labels) if prop_str == 'mean_intensity': _props = self._compute_mean_intensity(level_img, photo['Reflections'].label_image, photo.image, level_labels_for_prop) elif prop_str == 'area': _props = self._compute_area(level_img, level_labels_for_prop, photo) elif prop_str == 'circularity': _props = self._compute_circularity(level_img, level_labels_for_prop, photo, 'perimeter') elif prop_str == 'max_feret': _props = self._compute_max_feret(level_img, level_labels_for_prop, photo) elif prop_str == 'geodesic_length': _props = self._compute_geodesic_length(level_img, level_labels_for_prop, photo) elif prop_str == 'mean_width': _props = self._compute_mean_width(reg_img, labels, photo) elif prop_str == 'contour': _props = self._compute_contour_feature_vector(level_img, level_labels_for_prop, photo, reg_img.label_hierarchy) elif prop_str == 'mean_hsv': _props = self._compute_mean_hsv(level_img, level_labels_for_prop, photo, reg_img.label_hierarchy) else: print(f'property {prop_str} not fully implemented') _props = [] props.extend(_props) #reg_props = skimage.measure.regionprops(level_img, intensity_image=photo.image) #for prop_str, labels in prop_labels.items(): # example_prop = self.example(prop_str) # for _prop in reg_props: # if _prop.label not in labels: # continue # reg_prop = RegionProperty() # reg_prop.info = copy.deepcopy(prop_info_dict[prop_str]) # reg_prop.label = _prop.label # reg_prop.value = operator.attrgetter(prop_str)(_prop) # reg_prop.prop_type = example_prop.prop_type # reg_prop.num_vals = example_prop.num_vals # reg_prop.val_names = example_prop.val_names # if isinstance(reg_prop.value, np.integer): # reg_prop.value = int(reg_prop.value) # elif isinstance(reg_prop.value, np.float): # reg_prop.value = float(reg_prop.value) # elif isinstance(reg_prop.value, np.ndarray): # reg_prop.value = reg_prop.value.tolist() # props.append(reg_prop) return props #def __call__(self, photo: Photo, prop_labels: typing.Dict[str, typing.Set[int]]): # reg_img = photo['Labels'] # region_props = skimage.measure.regionprops(reg_img.label_image, photo.image) # props = [] # prop_info_dict = {info.key: info for info in self._available_props} # for reg_prop in region_props: # for prop_name, labels in prop_labels.items(): # if reg_prop.label not in labels: # continue # reg_property = RegionProperty() # reg_property.info = copy.deepcopy(prop_info_dict[prop_name]) # reg_property.label = reg_prop.label # reg_property.value = operator.attrgetter(prop_name)(reg_prop) # if reg_property.info.name == 'Area': # reg_property.prop_type = PropertyType.Scalar # else: # reg_property.prop_type = PropertyType.Intensity # reg_property.num_vals = len(reg_property.value) # reg_property.val_names = ['R', 'G', 'B'] if reg_property.num_vals == 3 else [''] # if isinstance(reg_property.value, np.integer): # reg_property.value = int(reg_property.value) # elif isinstance(reg_property.value, np.float): # reg_property.value = float(reg_property.value) # elif isinstance(reg_property.value, np.ndarray): # reg_property.value = reg_property.value.tolist() # props.append(reg_property) # return props @property def computes(self) -> typing.Dict[str, Info]: return self._available_props
[docs] def example(self, prop_key: str) -> RegionProperty: prop = RegionProperty() prop.label = 0 prop.value = None if prop_key == 'area': prop.info = copy.deepcopy(self._available_props['area']) prop.num_vals = 1 prop.prop_type = PropertyType.Scalar prop.val_names = [] elif prop_key == 'mean_intensity': prop.info = copy.deepcopy(self._available_props['mean_intensity']) prop.num_vals = 3 prop.prop_type = PropertyType.Intensity prop.val_names = ['R', 'G', 'B'] elif prop_key == 'circularity': prop.info = copy.deepcopy(self._available_props['circularity']) prop.num_vals = 1 prop.prop_type = PropertyType.Scalar prop.val_names = [] elif prop_key == 'max_feret': prop.info = copy.deepcopy(self._available_props['max_feret']) prop.num_vals = 1 prop.prop_type = PropertyType.Scalar prop.val_names = [] elif prop_key == 'geodesic_length': prop.info = copy.deepcopy(self._available_props['geodesic_length']) prop.num_vals = 1 prop.prop_type = PropertyType.Scalar prop.val_names = [] elif prop_key == 'mean_width': prop.info = copy.deepcopy(self._available_props['mean_width']) prop.num_vals = 1 prop.prop_type = PropertyType.Scalar prop.val_names = [] elif prop_key == 'contour': prop.info = copy.deepcopy(self._available_props['contour']) prop.num_vals = 40 prop.prop_type = PropertyType.Vector prop.val_names = [] elif prop_key == 'mean_hsv': prop.info = copy.deepcopy(self._available_props['mean_hsv']) prop.num_vals = 3 prop.prop_type = PropertyType.IntensityHSV prop.val_names = ['H', 'S', 'V'] else: # Log an error when encountering unknown property key print(f'property {prop_key} not fully implemented') return prop
[docs] def target_worksheet(self, prop_key: str) -> str: if prop_key == 'contour': return 'Contour' return super().target_worksheet(prop_key)