Skip to content

Commit 74451d7

Browse files
Merge pull request #44 from computational-cell-analytics/intensity-measurement-updates
Update intensity measurement tool
2 parents b03b42e + 5f28bb1 commit 74451d7

File tree

2 files changed

+155
-26
lines changed

2 files changed

+155
-26
lines changed

flamingo_tools/measurements.py

Lines changed: 116 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -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):
106181
FEATURE_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
111188
that 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`
117200
def 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)

scripts/intensity_annotation/gfp_annotation.py

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ def __init__(self, statistics, default_stat, bins: int = 32, parent=None):
2727
self.canvas = FigureCanvasQTAgg(self.fig)
2828

2929
# We exclude the label id and the volume / surface measurements.
30-
self.stat_names = statistics.columns[1:-2]
30+
self.stat_names = statistics.columns[1:-2] if len(statistics.columns) > 2 else statistics.columns[1:]
3131
self.param_choices = self.stat_names
3232

3333
self.param_box = QComboBox()
@@ -110,7 +110,30 @@ def _extend_sgns_simple(gfp, sgns, dilation):
110110
return sgns_extended
111111

112112

113-
def gfp_annotation(prefix, default_stat="median"):
113+
def _create_mask(sgns_extended, gfp):
114+
from skimage.transform import downscale_local_mean, resize
115+
116+
gfp_averaged = downscale_local_mean(gfp, factors=(16, 16, 16))
117+
# The 35th percentile seems to be a decent approximation for the background subtraction.
118+
threshold = np.percentile(gfp_averaged, 35)
119+
mask = gfp_averaged > threshold
120+
mask = resize(mask, sgns_extended.shape, order=0, anti_aliasing=False, preserve_range=True).astype(bool)
121+
mask[sgns_extended != 0] = 0
122+
123+
# v = napari.Viewer()
124+
# v.add_image(gfp)
125+
# v.add_image(gfp_averaged, scale=(16, 16, 16))
126+
# v.add_labels(mask)
127+
# # v.add_labels(mask, scale=(16, 16, 16))
128+
# v.add_labels(sgns_extended)
129+
# napari.run()
130+
131+
return mask
132+
133+
134+
def gfp_annotation(prefix, default_stat="median", background_norm=None):
135+
assert background_norm in (None, "division", "subtraction")
136+
114137
gfp = imageio.imread(f"{prefix}_GFP_resized.tif")
115138
sgns = imageio.imread(f"{prefix}_SGN_resized_v2.tif")
116139
pv = imageio.imread(f"{prefix}_PV_resized.tif")
@@ -125,7 +148,16 @@ def gfp_annotation(prefix, default_stat="median"):
125148
sgns_extended = _extend_sgns_simple(gfp, sgns, dilation=4)
126149

127150
# Compute the intensity statistics.
128-
statistics = compute_object_measures_impl(gfp, sgns_extended)
151+
if background_norm is None:
152+
mask = None
153+
feature_set = "default"
154+
else:
155+
mask = _create_mask(sgns_extended, gfp)
156+
assert mask.shape == sgns_extended.shape
157+
feature_set = "default_background_norm" if background_norm == "division" else "default_background_subtract"
158+
statistics = compute_object_measures_impl(
159+
gfp, sgns_extended, feature_set=feature_set, foreground_mask=mask, median_only=True
160+
)
129161

130162
# Open the napari viewer.
131163
v = napari.Viewer()
@@ -135,6 +167,8 @@ def gfp_annotation(prefix, default_stat="median"):
135167
v.add_image(pv, visible=False, name="PV")
136168
v.add_labels(sgns, visible=False, name="SGNs")
137169
v.add_labels(sgns_extended, name="SGNs-extended")
170+
if mask is not None:
171+
v.add_labels(mask, name="mask-for-background", visible=False)
138172

139173
# Add additional layers for intensity coloring and classification
140174
# data_numerical = np.zeros(gfp.shape, dtype="float32")
@@ -212,9 +246,10 @@ def threshold_widget(viewer: napari.Viewer, threshold: float = (max_val - min_va
212246
def main():
213247
parser = argparse.ArgumentParser()
214248
parser.add_argument("prefix")
249+
parser.add_argument("-b", "--background_norm")
215250
args = parser.parse_args()
216251

217-
gfp_annotation(args.prefix)
252+
gfp_annotation(args.prefix, background_norm=args.background_norm)
218253

219254

220255
if __name__ == "__main__":

0 commit comments

Comments
 (0)