Skip to content

Commit 2e542d0

Browse files
Copilotshachafl
andauthored
Fix BlobDetector round/channel assignment when is_volume=False (#2160)
* Fix BlobDetector round/channel assignment bug with is_volume=False Co-authored-by: shachafl <66333410+shachafl@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: shachafl <66333410+shachafl@users.noreply.github.com>
1 parent c005f33 commit 2e542d0

2 files changed

Lines changed: 99 additions & 7 deletions

File tree

starfish/core/spots/FindSpots/blob.py

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -251,13 +251,15 @@ def run(
251251
merged_z_tables[(r, ch)] = pd.concat(
252252
[merged_z_tables[(r, ch)], spot_attributes_list[i][0].spot_attrs.data])
253253
new = []
254-
r_chs = sorted([*merged_z_tables])
255-
selectors = list(image_stack._iter_axes({Axes.ROUND, Axes.CH}))
256-
for i, (r, ch) in enumerate(r_chs):
257-
merged_z_tables[(r, ch)]['spot_id'] = range(len(merged_z_tables[(r, ch)]))
258-
spot_attrs = SpotAttributes(merged_z_tables[(r, ch)].reset_index(drop=True))
259-
new.append((PerImageSliceSpotResults(spot_attrs=spot_attrs, extras=None),
260-
selectors[i]))
254+
# Iterate through the merged tables in the order expected by _iter_axes
255+
for selector in image_stack._iter_axes({Axes.ROUND, Axes.CH}):
256+
r = selector[Axes.ROUND]
257+
ch = selector[Axes.CH]
258+
if (r, ch) in merged_z_tables:
259+
merged_z_tables[(r, ch)]['spot_id'] = range(len(merged_z_tables[(r, ch)]))
260+
spot_attrs = SpotAttributes(merged_z_tables[(r, ch)].reset_index(drop=True))
261+
new.append((PerImageSliceSpotResults(spot_attrs=spot_attrs, extras=None),
262+
selector))
261263

262264
spot_attributes_list = new
263265

starfish/core/spots/FindSpots/test/test_spot_detection.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,3 +263,93 @@ def test_blob_detector_2d_with_reference_image():
263263
# Check that radius is reasonable
264264
assert np.all(radius_values < 100), f"Radius values too large: {radius_values}"
265265
assert np.all(radius_values > 0), f"Radius values should be positive: {radius_values}"
266+
267+
268+
def test_blob_detector_round_channel_assignment():
269+
"""Test that BlobDetector with is_volume=False assigns spots to correct (round, channel) pairs.
270+
271+
This is a regression test for a bug where spots were assigned to incorrect round/channel
272+
combinations due to a mismatch between the sorted (r, ch) tuples and the iteration order
273+
from _iter_axes({Axes.ROUND, Axes.CH}).
274+
"""
275+
# Create a test ImageStack with 2 rounds, 3 channels, 1 z-plane
276+
# Each (round, channel) pair gets a spot at a unique location
277+
data = np.zeros((2, 3, 1, 100, 100), dtype=np.float32)
278+
279+
# Add spots at different y-coordinates for each (round, channel)
280+
# Round 0, Channel 0: y=10
281+
data[0, 0, 0, 8:13, 8:13] = 1.0
282+
# Round 0, Channel 1: y=20
283+
data[0, 1, 0, 18:23, 18:23] = 1.0
284+
# Round 0, Channel 2: y=30
285+
data[0, 2, 0, 28:33, 28:33] = 1.0
286+
# Round 1, Channel 0: y=40
287+
data[1, 0, 0, 38:43, 38:43] = 1.0
288+
# Round 1, Channel 1: y=50
289+
data[1, 1, 0, 48:53, 48:53] = 1.0
290+
# Round 1, Channel 2: y=60
291+
data[1, 2, 0, 58:63, 58:63] = 1.0
292+
293+
image_stack = ImageStack.from_numpy(data)
294+
295+
# Test with is_volume=False
296+
detector_2d = BlobDetector(
297+
min_sigma=1,
298+
max_sigma=3,
299+
num_sigma=5,
300+
threshold=0.01,
301+
is_volume=False,
302+
measurement_type='mean'
303+
)
304+
305+
spots_2d = detector_2d.run(image_stack=image_stack)
306+
307+
# Test with is_volume=True for comparison
308+
detector_3d = BlobDetector(
309+
min_sigma=1,
310+
max_sigma=3,
311+
num_sigma=5,
312+
threshold=0.01,
313+
is_volume=True,
314+
measurement_type='mean'
315+
)
316+
317+
spots_3d = detector_3d.run(image_stack=image_stack)
318+
319+
# Define expected y-coordinates for each (round, channel) pair
320+
expected_y = {
321+
(0, 0): 10,
322+
(0, 1): 20,
323+
(0, 2): 30,
324+
(1, 0): 40,
325+
(1, 1): 50,
326+
(1, 2): 60,
327+
}
328+
329+
# Check that both is_volume=False and is_volume=True produce the same results
330+
for r in range(2):
331+
for ch in range(3):
332+
# Get spots for this (round, channel)
333+
spots_2d_data = spots_2d[{Axes.ROUND: r, Axes.CH: ch}].spot_attrs.data
334+
spots_3d_data = spots_3d[{Axes.ROUND: r, Axes.CH: ch}].spot_attrs.data
335+
336+
# Both should have found the spot
337+
assert len(spots_2d_data) > 0, \
338+
f"No spots found for Round={r}, CH={ch} with is_volume=False"
339+
assert len(spots_3d_data) > 0, \
340+
f"No spots found for Round={r}, CH={ch} with is_volume=True"
341+
342+
# Check that the y-coordinate matches the expected value
343+
y_2d = spots_2d_data['y'].values[0]
344+
y_3d = spots_3d_data['y'].values[0]
345+
expected = expected_y[(r, ch)]
346+
347+
assert np.abs(y_2d - expected) < 5, \
348+
f"is_volume=False: Round={r}, CH={ch} has y={y_2d:.0f}, expected ~{expected}"
349+
assert np.abs(y_3d - expected) < 5, \
350+
f"is_volume=True: Round={r}, CH={ch} has y={y_3d:.0f}, expected ~{expected}"
351+
352+
# Both should produce the same y-coordinate
353+
assert np.abs(y_2d - y_3d) < 2, \
354+
f"Round={r}, CH={ch}: is_volume=False has y={y_2d:.0f} " \
355+
f"but is_volume=True has y={y_3d:.0f}"

0 commit comments

Comments
 (0)