@@ -29,7 +29,7 @@ def _measure_volume_and_surface(mask, resolution):
2929 return volume , surface
3030
3131
32- def _get_bounding_box (table , seg_id , resolution , shape ):
32+ def _get_bounding_box_and_center (table , seg_id , resolution , shape ):
3333 row = table [table .label_id == seg_id ]
3434
3535 bb_min = np .array ([
@@ -46,38 +46,113 @@ def _get_bounding_box(table, seg_id, resolution, shape):
4646 slice (max (bmin - 1 , 0 ), min (bmax + 1 , sh ))
4747 for bmin , bmax , sh in zip (bb_min , bb_max , shape )
4848 )
49- return bb
5049
50+ center = (
51+ int (row .anchor_z .item () / resolution ),
52+ int (row .anchor_y .item () / resolution ),
53+ int (row .anchor_x .item () / resolution ),
54+ )
55+
56+ return bb , center
57+
58+
59+ def _spherical_mask (shape , radius , center = None ):
60+ if center is None :
61+ center = tuple (s // 2 for s in shape )
62+ if len (shape ) != len (center ):
63+ raise ValueError ("`shape` and `center` must have same length" )
64+
65+ # Build a 1-D open grid for every axis
66+ grids = np .ogrid [tuple (slice (0 , s ) for s in shape )]
67+ dist2 = sum ((g - c ) ** 2 for g , c in zip (grids , center ))
68+ return (dist2 <= radius ** 2 ).astype (bool )
5169
52- def _default_object_features (seg_id , table , image , segmentation , resolution ):
53- bb = _get_bounding_box (table , seg_id , resolution , image .shape )
70+
71+ def _normalize_background (measures , image , mask , center , radius , norm , median_only ):
72+ # Compute the bounding box and get the local image data.
73+ bb = tuple (
74+ slice (max (0 , int (ce - radius )), min (int (ce + radius ), sh )) for ce , sh in zip (center , image .shape )
75+ )
76+ local_image = image [bb ]
77+
78+ # Create a mask with radius around the center.
79+ radius_mask = _spherical_mask (local_image .shape , radius )
80+
81+ # Intersect the radius mask with the foreground mask (if given).
82+ if mask is not None :
83+ assert mask .shape == image .shape , f"{ mask .shape } , { image .shape } "
84+ local_mask = mask [bb ]
85+ radius_mask = np .logical_and (radius_mask , local_mask )
86+
87+ # For debugging.
88+ # import napari
89+ # v = napari.Viewer()
90+ # v.add_image(local_image)
91+ # v.add_labels(local_mask)
92+ # v.add_labels(radius_mask)
93+ # napari.run()
94+
95+ # Compute the features over the mask.
96+ masked_intensity = local_image [radius_mask ]
97+
98+ # Standardize the measures.
99+ bg_measures = {"median" : np .median (masked_intensity )}
100+ if not median_only :
101+ bg_measures = {
102+ "mean" : np .mean (masked_intensity ),
103+ "stdev" : np .std (masked_intensity ),
104+ "min" : np .min (masked_intensity ),
105+ "max" : np .max (masked_intensity ),
106+ }
107+ for percentile in (5 , 10 , 25 , 75 , 90 , 95 ):
108+ bg_measures [f"percentile-{ percentile } " ] = np .percentile (masked_intensity , percentile )
109+
110+ for measure , val in bg_measures .items ():
111+ measures [measure ] = norm (measures [measure ], val )
112+
113+ return measures
114+
115+
116+ def _default_object_features (
117+ seg_id , table , image , segmentation , resolution ,
118+ foreground_mask = None , background_radius = None , norm = np .divide , median_only = False ,
119+ ):
120+ bb , center = _get_bounding_box_and_center (table , seg_id , resolution , image .shape )
54121
55122 local_image = image [bb ]
56123 mask = segmentation [bb ] == seg_id
57124 assert mask .sum () > 0 , f"Segmentation ID { seg_id } is empty."
58125 masked_intensity = local_image [mask ]
59126
60127 # Do the base intensity measurements.
61- measures = {
62- "label_id" : seg_id ,
63- "mean" : np .mean (masked_intensity ),
64- "stdev" : np .std (masked_intensity ),
65- "min" : np .min (masked_intensity ),
66- "max" : np .max (masked_intensity ),
67- "median" : np .median (masked_intensity ),
68- }
69- for percentile in (5 , 10 , 25 , 75 , 90 , 95 ):
70- measures [f"percentile-{ percentile } " ] = np .percentile (masked_intensity , percentile )
128+ measures = {"label_id" : seg_id , "median" : np .median (masked_intensity )}
129+ if not median_only :
130+ measures .update ({
131+ "mean" : np .mean (masked_intensity ),
132+ "stdev" : np .std (masked_intensity ),
133+ "min" : np .min (masked_intensity ),
134+ "max" : np .max (masked_intensity ),
135+ })
136+ for percentile in (5 , 10 , 25 , 75 , 90 , 95 ):
137+ measures [f"percentile-{ percentile } " ] = np .percentile (masked_intensity , percentile )
138+
139+ if background_radius is not None :
140+ # The radius passed is given in micrometer.
141+ # The resolution is given in micrometer per pixel.
142+ # So we have to divide by the resolution to obtain the radius in pixel.
143+ radius_in_pixel = background_radius / resolution
144+ measures = _normalize_background (measures , image , foreground_mask , center , radius_in_pixel , norm , median_only )
71145
72146 # Do the volume and surface measurement.
73- volume , surface = _measure_volume_and_surface (mask , resolution )
74- measures ["volume" ] = volume
75- measures ["surface" ] = surface
147+ if not median_only :
148+ volume , surface = _measure_volume_and_surface (mask , resolution )
149+ measures ["volume" ] = volume
150+ measures ["surface" ] = surface
76151 return measures
77152
78153
79- def _regionprops_features (seg_id , table , image , segmentation , resolution ):
80- bb = _get_bounding_box (table , seg_id , resolution , image .shape )
154+ def _regionprops_features (seg_id , table , image , segmentation , resolution , foreground_mask = None ):
155+ bb , _ = _get_bounding_box_and_center (table , seg_id , resolution , image .shape )
81156
82157 local_image = image [bb ]
83158 local_segmentation = segmentation [bb ]
@@ -106,21 +181,31 @@ def _regionprops_features(seg_id, table, image, segmentation, resolution):
106181FEATURE_FUNCTIONS = {
107182 "default" : _default_object_features ,
108183 "skimage" : _regionprops_features ,
184+ "default_background_norm" : partial (_default_object_features , background_radius = 75 , norm = np .divide ),
185+ "default_background_subtract" : partial (_default_object_features , background_radius = 75 , norm = np .subtract ),
109186}
110187"""The different feature functions that are supported in `compute_object_measures` and
111188that can be selected via the feature_set argument. Currently this supports:
112189- 'default': The default features which compute standard intensity statistics and volume + surface.
113190- 'skimage': The scikit image regionprops features.
191+ - 'default_background_norm': The default features with background normalization.
192+ - 'default_background_subtract': The default features with background subtraction.
193+
194+ For the background normalized measures, we compute the background intensity in a sphere with radius of 75 micrometer
195+ around each object.
114196"""
115197
116198
199+ # TODO integrate segmentation post-processing, see `_extend_sgns_simple` in `gfp_annotation.py`
117200def compute_object_measures_impl (
118201 image : np .typing .ArrayLike ,
119202 segmentation : np .typing .ArrayLike ,
120203 n_threads : Optional [int ] = None ,
121204 resolution : float = 0.38 ,
122205 table : Optional [pd .DataFrame ] = None ,
123206 feature_set : str = "default" ,
207+ foreground_mask : Optional [np .typing .ArrayLike ] = None ,
208+ median_only : bool = False ,
124209) -> pd .DataFrame :
125210 """Compute simple intensity and morphology measures for each segmented cell in a segmentation.
126211
@@ -133,6 +218,8 @@ def compute_object_measures_impl(
133218 resolution: The resolution / voxel size of the data.
134219 table: The segmentation table. Will be computed on the fly if it is not given.
135220 feature_set: The features to compute for each object. Refer to `FEATURE_FUNCTIONS` for details.
221+ foreground_mask: An optional mask indicating the area to use for computing background correction values.
222+ median_only: Whether to only compute the median intensity.
136223
137224 Returns:
138225 The table with per object measurements.
@@ -147,12 +234,19 @@ def compute_object_measures_impl(
147234 table = table ,
148235 image = image ,
149236 segmentation = segmentation ,
150- resolution = resolution
237+ resolution = resolution ,
238+ foreground_mask = foreground_mask ,
239+ median_only = median_only ,
151240 )
152241
153242 seg_ids = table .label_id .values
154243 assert len (seg_ids ) > 0 , "The segmentation table is empty."
244+ measure_function (seg_ids [0 ])
155245 n_threads = mp .cpu_count () if n_threads is None else n_threads
246+
247+ # For debugging.
248+ # measure_function(seg_ids[0])
249+
156250 with futures .ThreadPoolExecutor (n_threads ) as pool :
157251 measures = list (tqdm (
158252 pool .map (measure_function , seg_ids ), total = len (seg_ids ), desc = "Compute intensity measures"
@@ -206,14 +300,14 @@ def compute_object_measures(
206300 table = None
207301 elif s3_flag :
208302 seg_table , fs = s3_utils .get_s3_path (segmentation_table_path )
209- with fs .open (seg_table , 'r' ) as f :
303+ with fs .open (seg_table , "r" ) as f :
210304 table = pd .read_csv (f , sep = "\t " )
211305 else :
212306 table = pd .read_csv (segmentation_table_path , sep = "\t " )
213307
214308 # filter table with largest component
215309 if len (component_list ) != 0 and "component_labels" in table .columns :
216- table = table [table [' component_labels' ].isin (component_list )]
310+ table = table [table [" component_labels" ].isin (component_list )]
217311
218312 # Then, open the volumes.
219313 image = read_image_data (image_path , image_key )
0 commit comments