Skip to content

Commit 0d4ab4b

Browse files
Update measurement impl
1 parent 5967230 commit 0d4ab4b

File tree

3 files changed

+127
-13
lines changed

3 files changed

+127
-13
lines changed

flamingo_tools/measurements.py

Lines changed: 101 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,15 @@
22
import os
33
from concurrent import futures
44
from functools import partial
5-
from typing import List, Optional
5+
from typing import List, Optional, Tuple
66

77
import numpy as np
88
import pandas as pd
99
import trimesh
10+
from elf.wrapper.resized_volume import ResizedVolume
11+
from nifty.tools import blocking
1012
from skimage.measure import marching_cubes, regionprops_table
13+
from scipy.ndimage import binary_dilation
1114
from tqdm import tqdm
1215

1316
from .file_utils import read_image_data
@@ -29,9 +32,14 @@ def _measure_volume_and_surface(mask, resolution):
2932
return volume, surface
3033

3134

32-
def _get_bounding_box_and_center(table, seg_id, resolution, shape):
35+
def _get_bounding_box_and_center(table, seg_id, resolution, shape, dilation):
3336
row = table[table.label_id == seg_id]
3437

38+
if dilation is not None and dilation > 0:
39+
bb_extension = dilation + 1
40+
else:
41+
bb_extension = 1
42+
3543
bb_min = np.array([
3644
row.bb_min_z.item(), row.bb_min_y.item(), row.bb_min_x.item()
3745
]).astype("float32") / resolution
@@ -43,7 +51,7 @@ def _get_bounding_box_and_center(table, seg_id, resolution, shape):
4351
bb_max = np.round(bb_max, 0).astype("int32")
4452

4553
bb = tuple(
46-
slice(max(bmin - 1, 0), min(bmax + 1, sh))
54+
slice(max(bmin - bb_extension, 0), min(bmax + bb_extension, sh))
4755
for bmin, bmax, sh in zip(bb_min, bb_max, shape)
4856
)
4957

@@ -115,13 +123,15 @@ def _normalize_background(measures, image, mask, center, radius, norm, median_on
115123

116124
def _default_object_features(
117125
seg_id, table, image, segmentation, resolution,
118-
foreground_mask=None, background_radius=None, norm=np.divide, median_only=False,
126+
background_mask=None, background_radius=None, norm=np.divide, median_only=False, dilation=None
119127
):
120-
bb, center = _get_bounding_box_and_center(table, seg_id, resolution, image.shape)
128+
bb, center = _get_bounding_box_and_center(table, seg_id, resolution, image.shape, dilation)
121129

122130
local_image = image[bb]
123131
mask = segmentation[bb] == seg_id
124132
assert mask.sum() > 0, f"Segmentation ID {seg_id} is empty."
133+
if dilation is not None and dilation > 0:
134+
mask = binary_dilation(mask, iterations=dilation)
125135
masked_intensity = local_image[mask]
126136

127137
# Do the base intensity measurements.
@@ -141,7 +151,7 @@ def _default_object_features(
141151
# The resolution is given in micrometer per pixel.
142152
# So we have to divide by the resolution to obtain the radius in pixel.
143153
radius_in_pixel = background_radius / resolution
144-
measures = _normalize_background(measures, image, foreground_mask, center, radius_in_pixel, norm, median_only)
154+
measures = _normalize_background(measures, image, background_mask, center, radius_in_pixel, norm, median_only)
145155

146156
# Do the volume and surface measurement.
147157
if not median_only:
@@ -151,13 +161,15 @@ def _default_object_features(
151161
return measures
152162

153163

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)
164+
def _regionprops_features(seg_id, table, image, segmentation, resolution, background_mask=None, dilation=None):
165+
bb, _ = _get_bounding_box_and_center(table, seg_id, resolution, image.shape, dilation)
156166

157167
local_image = image[bb]
158168
local_segmentation = segmentation[bb]
159169
mask = local_segmentation == seg_id
160170
assert mask.sum() > 0, f"Segmentation ID {seg_id} is empty."
171+
if dilation is not None and dilation > 0:
172+
mask = binary_dilation(mask, iterations=dilation)
161173
local_segmentation[~mask] = 0
162174

163175
features = regionprops_table(
@@ -196,16 +208,16 @@ def _regionprops_features(seg_id, table, image, segmentation, resolution, foregr
196208
"""
197209

198210

199-
# TODO integrate segmentation post-processing, see `_extend_sgns_simple` in `gfp_annotation.py`
200211
def compute_object_measures_impl(
201212
image: np.typing.ArrayLike,
202213
segmentation: np.typing.ArrayLike,
203214
n_threads: Optional[int] = None,
204215
resolution: float = 0.38,
205216
table: Optional[pd.DataFrame] = None,
206217
feature_set: str = "default",
207-
foreground_mask: Optional[np.typing.ArrayLike] = None,
218+
background_mask: Optional[np.typing.ArrayLike] = None,
208219
median_only: bool = False,
220+
dilation: Optional[int] = None,
209221
) -> pd.DataFrame:
210222
"""Compute simple intensity and morphology measures for each segmented cell in a segmentation.
211223
@@ -218,8 +230,10 @@ def compute_object_measures_impl(
218230
resolution: The resolution / voxel size of the data.
219231
table: The segmentation table. Will be computed on the fly if it is not given.
220232
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.
233+
background_mask: An optional mask indicating the area to use for computing background correction values.
222234
median_only: Whether to only compute the median intensity.
235+
dilation: Value for dilating the segmentation before computing measurements.
236+
By default no dilation is applied.
223237
224238
Returns:
225239
The table with per object measurements.
@@ -235,8 +249,9 @@ def compute_object_measures_impl(
235249
image=image,
236250
segmentation=segmentation,
237251
resolution=resolution,
238-
foreground_mask=foreground_mask,
252+
background_mask=background_mask,
239253
median_only=median_only,
254+
dilation=dilation,
240255
)
241256

242257
seg_ids = table.label_id.values
@@ -272,6 +287,9 @@ def compute_object_measures(
272287
feature_set: str = "default",
273288
s3_flag: bool = False,
274289
component_list: List[int] = [],
290+
dilation: Optional[int] = None,
291+
median_only: bool = False,
292+
background_mask: Optional[np.typing.ArrayLike] = None,
275293
) -> None:
276294
"""Compute simple intensity and morphology measures for each segmented cell in a segmentation.
277295
@@ -291,6 +309,12 @@ def compute_object_measures(
291309
resolution: The resolution / voxel size of the data.
292310
force: Whether to overwrite an existing output table.
293311
feature_set: The features to compute for each object. Refer to `FEATURE_FUNCTIONS` for details.
312+
s3_flag:
313+
component_list:
314+
median_only: Whether to only compute the median intensity.
315+
dilation: Value for dilating the segmentation before computing measurements.
316+
By default no dilation is applied.
317+
background_mask: An optional mask indicating the area to use for computing background correction values.
294318
"""
295319
if os.path.exists(output_table_path) and not force:
296320
return
@@ -315,5 +339,70 @@ def compute_object_measures(
315339

316340
measures = compute_object_measures_impl(
317341
image, segmentation, n_threads, resolution, table=table, feature_set=feature_set,
342+
median_only=median_only, dilation=dilation, background_mask=background_mask,
318343
)
319344
measures.to_csv(output_table_path, sep="\t", index=False)
345+
346+
347+
def compute_sgn_background_mask(
348+
image_path: str,
349+
segmentation_path: str,
350+
image_key: Optional[str] = None,
351+
segmentation_key: Optional[str] = None,
352+
threshold_percentile: float = 35.0,
353+
scale_factor: Tuple[int, int, int] = (16, 16, 16),
354+
) -> np.typing.ArrayLike:
355+
"""
356+
357+
Args:
358+
p
359+
360+
Returns:
361+
pass
362+
"""
363+
image = read_image_data(image_path, image_key)
364+
segmentation = read_image_data(segmentation_path, segmentation_key)
365+
assert image.shape == segmentation.shape
366+
367+
original_shape = image.shape
368+
downsampled_shape = tuple(int(np.round(sh / sf)) for sh, sf in zip(original_shape, scale_factor))
369+
370+
low_res_mask = np.zeros(downsampled_shape, dtype="bool")
371+
372+
# This corresponds to a block shape of 128 x 512 x 512 in the original resolution,
373+
# which roughly corresponds to the size of the blocks we use for the GFP annotation.
374+
chunk_shape = (8, 32, 32)
375+
376+
blocks = blocking((0, 0, 0), downsampled_shape, chunk_shape)
377+
n_blocks = blocks.numberOfBlocks
378+
379+
img_resized = ResizedVolume(image, downsampled_shape)
380+
seg_resized = ResizedVolume(segmentation, downsampled_shape, order=0)
381+
382+
def _compute_block(block_id):
383+
block = blocks.getBlock(block_id)
384+
bb = tuple(slice(beg, end) for beg, end in zip(block.begin, block.end))
385+
386+
img = img_resized[bb]
387+
threshold = np.percentile(img, threshold_percentile)
388+
389+
this_mask = img > threshold
390+
this_seg = seg_resized[bb] != 0
391+
this_seg = binary_dilation(this_seg)
392+
this_mask[this_seg] = 0
393+
394+
low_res_mask[bb] = this_mask
395+
396+
# TODO parallelize
397+
for block_id in range(n_blocks):
398+
_compute_block(block_id)
399+
400+
# stain_averaged = downscale_local_mean(stain, factors=(16, 16, 16))
401+
# # The 35th percentile seems to be a decent approximation for the background subtraction.
402+
# threshold = np.percentile(stain_averaged, 35)
403+
# mask = stain_averaged > threshold
404+
# mask = resize(mask, seg_extended.shape, order=0, anti_aliasing=False, preserve_range=True).astype(bool)
405+
# mask[seg_extended != 0] = 0
406+
407+
mask = ResizedVolume(low_res_mask, shape=original_shape, order=0)
408+
return mask

scripts/intensity_annotation/gfp_annotation.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,7 @@ def gfp_annotation(prefix, default_stat="median", background_norm=None, is_otof=
180180
assert mask.shape == seg_extended.shape
181181
feature_set = "default_background_norm" if background_norm == "division" else "default_background_subtract"
182182
statistics = compute_object_measures_impl(
183-
stain1, seg_extended, feature_set=feature_set, foreground_mask=mask, median_only=True
183+
stain1, seg_extended, feature_set=feature_set, background_mask=mask, median_only=True
184184
)
185185

186186
# Open the napari viewer.

test/test_measurements.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,31 @@ def test_compute_object_measures(self):
5555
]:
5656
self.assertTrue(np.allclose(table[col].values, expected_measures[col_exp].values))
5757

58+
# Test the object measurement functionality as it's used for the gfp intensity measurements:
59+
# - computing only median intensity
60+
# - with a dilation of 4
61+
# - with background subtraction
62+
# - and using a mask for the background subtraction
63+
def test_compute_object_measures_gfp(self):
64+
from flamingo_tools.measurements import compute_object_measures, compute_sgn_background_mask
65+
66+
dilation = 4
67+
background_mask = compute_sgn_background_mask(self.image_path, self.seg_path, scale_factor=(2, 4, 4))
68+
69+
output_path = os.path.join(self.folder, "measurements.tsv")
70+
compute_object_measures(
71+
self.image_path, self.seg_path, self.table_path, output_path, n_threads=1,
72+
dilation=dilation, median_only=True, feature_set="default_background_subtract",
73+
background_mask=background_mask,
74+
)
75+
self.assertTrue(os.path.exists(output_path))
76+
77+
table = pd.read_csv(output_path, sep="\t")
78+
self.assertTrue(len(table) >= 1)
79+
expected_columns = ["label_id", "median"]
80+
for col in expected_columns:
81+
self.assertIn(col, table.columns)
82+
5883

5984
if __name__ == "__main__":
6085
unittest.main()

0 commit comments

Comments
 (0)