From 799f2ea538b390a3725ec38ca44e2a1cebac8594 Mon Sep 17 00:00:00 2001 From: Christopher Rhodes <christopher.rhodes@embl.de> Date: Thu, 1 Aug 2024 16:46:06 +0200 Subject: [PATCH] Implemented and tested creation of RoiSet from polygon coordinates --- model_server/base/roiset.py | 50 ++++++++++++++++++++++++++----------- tests/base/test_roiset.py | 22 ++++++++++------ 2 files changed, 49 insertions(+), 23 deletions(-) diff --git a/model_server/base/roiset.py b/model_server/base/roiset.py index dcd8cfa7..bdbd2c8c 100644 --- a/model_server/base/roiset.py +++ b/model_server/base/roiset.py @@ -10,6 +10,7 @@ from pydantic import BaseModel from scipy.stats import moment from skimage.filters import sobel +from skimage import draw from skimage.measure import approximate_polygon, find_contours, label, points_in_poly, regionprops, regionprops_table, shannon_entropy from skimage.morphology import binary_dilation, disk @@ -181,10 +182,6 @@ def make_df_from_object_ids(acc_raw, acc_obj_ids, expand_box_by) -> pd.DataFrame ) return df -# TODO: implement -def make_df_from_polygons(acc_raw, polygons:np.ndarray) -> pd.DataFrame: - pass - def df_insert_slices(df: pd.DataFrame, sd: dict, expand_box_by) -> pd.DataFrame: h = sd['Y'] @@ -345,20 +342,43 @@ class RoiSet(object): Create a RoiSet from a binary segmentation mask (either 2D or 3D) :param acc_raw: accessor to a generally a multichannel z-stack :param acc_seg: accessor of a binary segmentation mask (mono) of either two or three dimensions - :param allow_3d: return a 3D map if True; return a 2D map of the mask's maximum intensity project if False + :param allow_3d: use a 3D map if True; use a 2D map of the mask's maximum intensity project if False :param connect_3d: objects can span multiple z-positions if True; objects are unique to a single z if False :param params: optional arguments that influence the definition and representation of ROIs - :return: object identities map """ - return RoiSet.from_object_ids(acc_raw, get_label_ids(acc_seg, allow_3d=allow_3d, connect_3d=connect_3d), params) + return RoiSet.from_object_ids( + acc_raw, + get_label_ids( + acc_seg, + allow_3d=allow_3d, + connect_3d=connect_3d + ), + params + ) @staticmethod - #TODO: implement - def from_polygons(acc_raw, polygons: np.ndarray): - binary_poly = np.zeros(acc_raw.mono(0).shape, dtype=bool) - for p in poly: - pidcs = draw.polygon(p[:, 1], p[:, 0]) - binary_poly[pidcs] = True + def from_polygons_2d( + acc_raw, + polygons: List[np.ndarray], + params: RoiSetMetaParams = RoiSetMetaParams() + ): + """ + Create a RoiSet where objects are defined from a list of polygon coordinates + :param acc_raw: accessor to a generally a multichannel z-stack + :param polygons: list of (variable x 2) np.ndarrays describing (x, y) polymer coordinates + :param params: optional arguments that influence the definition and representation of ROIs + """ + mask = np.zeros(acc_raw.get_mono(0, mip=True).shape, dtype=bool) + for p in polygons: + sl = draw.polygon(p[:, 1], p[:, 0]) + mask[sl] = True + return RoiSet.from_binary_mask( + acc_raw, + InMemoryDataAccessor(mask), + allow_3d=False, + connect_3d=False, + params=params, + ) # TODO: get overlapping segments @@ -394,7 +414,7 @@ class RoiSet(object): write_accessor_data_to_file(fp, annotated) return (prefix + '.tif') - def get_zmask(self, mask_type='boxes'): + def get_zmask(self, mask_type='boxes') -> np.ndarray: """ Return a mask of same dimensionality as raw data @@ -780,7 +800,7 @@ class RoiSet(object): return record def get_polygons(self, poly_threshold=0, dilation_radius=1) -> pd.DataFrame: - """ + self.coordinates_ = """ Fit polygons to all object boundaries in the RoiSet :param poly_threshold: threshold distance for polygon fit; a smaller number follows sharp features more closely :param dilation_radius: radius of binary dilation to apply before fitting polygon diff --git a/tests/base/test_roiset.py b/tests/base/test_roiset.py index 1626aca0..3f31948d 100644 --- a/tests/base/test_roiset.py +++ b/tests/base/test_roiset.py @@ -659,7 +659,7 @@ class TestRoiSetSerialization(unittest.TestCase): class TestRoiSetPolygons(BaseTestRoiSetMonoProducts, unittest.TestCase): def test_compute_polygons(self): - roiset = RoiSet.from_binary_mask( + roiset_ref = RoiSet.from_binary_mask( self.stack_ch_pa, self.seg_mask, params=RoiSetMetaParams( @@ -668,19 +668,25 @@ class TestRoiSetPolygons(BaseTestRoiSetMonoProducts, unittest.TestCase): ) ) - poly = roiset.get_polygons() - binary_poly = np.zeros(self.seg_mask.hw, dtype=bool) - for p in poly: - pidcs = draw.polygon(p[:, 1], p[:, 0]) - binary_poly[pidcs] = True + poly = roiset_ref.get_polygons() + roiset_test = RoiSet.from_polygons_2d(self.stack_ch_pa, poly) + binary_poly = (roiset_test.acc_obj_ids.get_mono(0, mip=True).data > 0) + self.assertEqual(self.seg_mask.shape, binary_poly.shape) + + # most mask pixels are within in fitted polygon test_mask = np.logical_and( np.logical_not(binary_poly), - (self.seg_mask.data[:, :, 0, 0] == 255) + (self.seg_mask.data == 255) ) - # most mask pixels are within in fitted polygon self.assertLess(test_mask.sum() / test_mask.size, 0.001) + # output results + od = output_path / 'polygons' + write_accessor_data_to_file(od / 'from_polygons.tif', InMemoryDataAccessor(binary_poly)) + write_accessor_data_to_file(od / 'ref_mask.tif', self.seg_mask) + write_accessor_data_to_file(od / 'diff.tif', InMemoryDataAccessor(test_mask)) + def test_serialize_coco(self): roiset = RoiSet.from_binary_mask( self.stack_ch_pa, -- GitLab