Skip to content

Commit 2c7d02b

Browse files
authored
Merge pull request #33 from lanl/awitmer_fix_contours
BUG: fix contour plotting for bubblesam detection
2 parents d0f64e7 + cd8e7dc commit 2c7d02b

2 files changed

Lines changed: 48 additions & 14 deletions

File tree

neat_ml/bubblesam/bubblesam.py

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,9 @@ def analyze_and_filter_masks(
131131
if len(props_list) == 0:
132132
continue
133133

134-
rp = props_list[0]
134+
# take the region properties from the segmentation map with the greatest area
135+
rp_areas = [x.area for x in props_list]
136+
rp = props_list[np.argmax(rp_areas)]
135137
area = rp.area
136138
perimeter = rp.perimeter
137139
if perimeter == 0:
@@ -141,19 +143,18 @@ def analyze_and_filter_masks(
141143
major_axis = rp.major_axis_length
142144
minor_axis = rp.minor_axis_length
143145
h, w = seg.shape[:2]
144-
# Using a small margin (2 pixels) to be safe
146+
# Using a small margin (2 pixels) to be safe,
147+
# filter any segmentations with bounding boxes close to the size of the image
148+
# because SAM-2 can sometimes detect the image background itself.
149+
bbox_area = (rp.bbox[2] - rp.bbox[0]) * (rp.bbox[3] - rp.bbox[1])
145150
max_allowed_area = (h - 2) * (w - 2)
146-
if area >= area_threshold and circ >= circularity_threshold:
151+
if (area >= area_threshold and circ >= circularity_threshold
152+
and bbox_area < max_allowed_area):
147153
binary_mask = seg.astype('uint8') * 255
148154
contours, _ = cv2.findContours(binary_mask, cv2.RETR_LIST, cv2.CHAIN_APPROX_NONE)
149-
# reshape contours for plotting and remove any contours
150-
# close to the size of the image because cv2.findContours
151-
# can sometimes detect the image edge itself.
152-
all_contours = [
153-
c.reshape(-1, 2)[:, ::-1]
154-
for c in contours
155-
if cv2.contourArea(c) < max_allowed_area
156-
]
155+
# keep only the largest contour in each segmentation area
156+
# and reshape for plotting
157+
max_contour = max(contours, key=cv2.contourArea).squeeze(axis=1)
157158
radius = np.sqrt(area / np.pi)
158159
euler_number = rp.euler_number
159160
# output of cucim ``rp`` stores values as objects
@@ -164,7 +165,7 @@ def analyze_and_filter_masks(
164165
euler_number = euler_number.item()
165166
mask_info = {
166167
'bbox': rp.bbox,
167-
'contour': all_contours,
168+
'contour': max_contour,
168169
'major_axis': major_axis,
169170
'minor_axis': minor_axis,
170171
'area': area,
@@ -202,7 +203,7 @@ def plot_filtered_masks(
202203
for idx, row in masks_summary_df.iterrows():
203204
contour = row['contour']
204205
bbox = row['bbox']
205-
ax.plot(contour[0][:, 1], contour[0][:, 0], linewidth=1, color='blue')
206+
ax.plot(contour[:, 0], contour[:, 1], linewidth=1, color='blue')
206207
min_row, min_col, max_row, max_col = bbox
207208
rect = Rectangle(
208209
(min_col, min_row),
@@ -271,7 +272,7 @@ def bubblesam_detection(
271272
)
272273

273274
# save filtered dataframe as parquet file
274-
# convert ``contours`` and ``bbox`` columns to list to save as parquet
275+
# convert ``contour`` and ``bbox`` columns to list to save as parquet
275276
save_filtered_df = filtered_df.copy()
276277
save_filtered_df["bbox"] = save_filtered_df["bbox"].apply(list)
277278
save_filtered_df["contour"] = save_filtered_df["contour"].apply(

neat_ml/tests/test_bubblesam.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,3 +318,36 @@ def test_run_bubblesam_model_cfg_error():
318318
"""
319319
with pytest.raises(ValueError, match="Must provide model configuration"):
320320
run_bubblesam(pd.DataFrame(), Path("output"), detection_cfg={})
321+
322+
@pytest.mark.parametrize("seg_params, exp_bbox",
323+
[
324+
# a test case where the segmentation contains two disjoint areas
325+
([[50, 60], [40, 45]], (50, 50, 60, 60)),
326+
# a test case where the segmentation contains a region that touches
327+
# the image boundary at the bottom right corner
328+
([[90, 100]], (90, 90, 100, 100)),
329+
]
330+
)
331+
def test_bubblesam_contours(seg_params, exp_bbox):
332+
"""
333+
test that running `analyze_and_filter_masks` generates a dataframe with
334+
only a single contour per detection and without background areas
335+
"""
336+
# create two segmentation maps, one that takes up the whole image (background)
337+
# and one containing the segmentation map generated using the test case parameters
338+
seg = np.ones((100, 100)).astype(bool)
339+
seg2 = np.zeros((100, 100)).astype(bool)
340+
for seg_param in seg_params:
341+
start = seg_param[0]
342+
end = seg_param[1]
343+
seg2[start:end, start:end] = True
344+
input_df = pd.DataFrame({"segmentation": [seg, seg2]})
345+
# call `analyze_and_filter_masks` to return filtered dataframe
346+
# (the circularity of a perfect square is ~0.8, so lower the
347+
# circularity threshold so that the background only gets filtered
348+
# out by the bounding box area)
349+
df = analyze_and_filter_masks(input_df, 25, 0.7, device="cpu")
350+
# assert that there is only a single dataframe row after filtration
351+
# corresponding to the appropriate segmentation map to keep from `seg2`
352+
assert df.bbox.item() == exp_bbox
353+
assert df.contour.item().shape == (36, 2)

0 commit comments

Comments
 (0)