From 92a61aa0131c6e2903cd7b4b5fe927c01e3681c6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 5 Nov 2025 19:26:36 +0000 Subject: [PATCH 01/16] Initial plan From 7846f73e1c7e1e251e946208c5c8f99603f27f8a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 5 Nov 2025 19:35:05 +0000 Subject: [PATCH 02/16] Fix BlobDetector 2D indexing bug causing incorrect y-values and radius Co-authored-by: shachafl <66333410+shachafl@users.noreply.github.com> --- starfish/core/spots/FindSpots/blob.py | 4 +- .../FindSpots/test/test_spot_detection.py | 50 +++++++++++++++++++ 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/starfish/core/spots/FindSpots/blob.py b/starfish/core/spots/FindSpots/blob.py index 93d548dab..df81ee3c9 100644 --- a/starfish/core/spots/FindSpots/blob.py +++ b/starfish/core/spots/FindSpots/blob.py @@ -144,11 +144,11 @@ def image_to_spots( radius = np.round(fitted_blobs_array[:, 3] * np.sqrt(3)) intensities = data_image[tuple([z_inds, y_inds, x_inds])] else: - z_inds = np.asarray([0 for x in range(len(fitted_blobs_array))]) y_inds = fitted_blobs_array[:, 0].astype(int) x_inds = fitted_blobs_array[:, 1].astype(int) radius = np.round(fitted_blobs_array[:, 2] * np.sqrt(2)) - intensities = data_image[tuple([z_inds, y_inds, x_inds])] + z_inds = np.zeros(len(fitted_blobs_array), dtype=int) + intensities = data_image[y_inds, x_inds] # construct dataframe spot_data = pd.DataFrame( diff --git a/starfish/core/spots/FindSpots/test/test_spot_detection.py b/starfish/core/spots/FindSpots/test/test_spot_detection.py index bbaeac66f..6567445cd 100644 --- a/starfish/core/spots/FindSpots/test/test_spot_detection.py +++ b/starfish/core/spots/FindSpots/test/test_spot_detection.py @@ -158,3 +158,53 @@ def test_spot_detection_with_image_with_labeled_axes(): data_stack = _make_labeled_image() spot_results = gaussian_spot_detector.run(image_stack=data_stack) return spot_results + + +def test_blob_detector_2d_spot_coordinates(): + """Test that BlobDetector with is_volume=False produces correct y, x coordinates and radius. + + This is a regression test for the bug where y-values were all 0 and radius was incorrect + due to incorrect indexing of 2D images. + """ + # Create a simple 2D image with a bright spot + image_2d = np.zeros((100, 100), dtype=np.float32) + # Add a bright spot at y=50, x=60 + image_2d[48:53, 58:63] = 1.0 + + # Create an ImageStack with this 2D image + image_stack = ImageStack.from_numpy(image_2d.reshape(1, 1, 1, 100, 100)) + + # Create a BlobDetector with is_volume=False + detector_2d = BlobDetector( + min_sigma=1, + max_sigma=3, + num_sigma=5, + threshold=0.01, + is_volume=False, + measurement_type='max' + ) + + # Run detection + spot_results = detector_2d.run(image_stack=image_stack) + + # Get the spot attributes + spots = spot_results.spot_attrs + + # Verify we found at least one spot + assert len(spots.data) > 0, "No spots detected" + + # Check that y-values are not all 0 (the bug symptom) + y_values = spots.data['y'].values + assert not np.all(y_values == 0), "All y-values are 0, indicating the bug is present" + + # Check that the detected spot is near the expected location (y=50, x=60) + # Allow some tolerance since blob detection may not be exact + assert np.any(np.abs(y_values - 50) < 10), f"No spot found near y=50, found: {y_values}" + + x_values = spots.data['x'].values + assert np.any(np.abs(x_values - 60) < 10), f"No spot found near x=60, found: {x_values}" + + # Check that radius is reasonable (not extremely large like 843.0 in the bug) + radius_values = spots.data['radius'].values + assert np.all(radius_values < 100), f"Radius values too large: {radius_values}" + assert np.all(radius_values > 0), f"Radius values should be positive: {radius_values}" From d30c08273d400e6ccef3d7ef99e8ab99ab799fca Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 5 Nov 2025 19:37:16 +0000 Subject: [PATCH 03/16] Fix whitespace linting issues in test file Co-authored-by: shachafl <66333410+shachafl@users.noreply.github.com> --- .../FindSpots/test/test_spot_detection.py | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/starfish/core/spots/FindSpots/test/test_spot_detection.py b/starfish/core/spots/FindSpots/test/test_spot_detection.py index 6567445cd..12627ea1f 100644 --- a/starfish/core/spots/FindSpots/test/test_spot_detection.py +++ b/starfish/core/spots/FindSpots/test/test_spot_detection.py @@ -162,7 +162,7 @@ def test_spot_detection_with_image_with_labeled_axes(): def test_blob_detector_2d_spot_coordinates(): """Test that BlobDetector with is_volume=False produces correct y, x coordinates and radius. - + This is a regression test for the bug where y-values were all 0 and radius was incorrect due to incorrect indexing of 2D images. """ @@ -170,10 +170,10 @@ def test_blob_detector_2d_spot_coordinates(): image_2d = np.zeros((100, 100), dtype=np.float32) # Add a bright spot at y=50, x=60 image_2d[48:53, 58:63] = 1.0 - + # Create an ImageStack with this 2D image image_stack = ImageStack.from_numpy(image_2d.reshape(1, 1, 1, 100, 100)) - + # Create a BlobDetector with is_volume=False detector_2d = BlobDetector( min_sigma=1, @@ -183,27 +183,27 @@ def test_blob_detector_2d_spot_coordinates(): is_volume=False, measurement_type='max' ) - + # Run detection spot_results = detector_2d.run(image_stack=image_stack) - + # Get the spot attributes spots = spot_results.spot_attrs - + # Verify we found at least one spot assert len(spots.data) > 0, "No spots detected" - + # Check that y-values are not all 0 (the bug symptom) y_values = spots.data['y'].values assert not np.all(y_values == 0), "All y-values are 0, indicating the bug is present" - + # Check that the detected spot is near the expected location (y=50, x=60) # Allow some tolerance since blob detection may not be exact assert np.any(np.abs(y_values - 50) < 10), f"No spot found near y=50, found: {y_values}" - + x_values = spots.data['x'].values assert np.any(np.abs(x_values - 60) < 10), f"No spot found near x=60, found: {x_values}" - + # Check that radius is reasonable (not extremely large like 843.0 in the bug) radius_values = spots.data['radius'].values assert np.all(radius_values < 100), f"Radius values too large: {radius_values}" From 38a5aed270179ecc3f80a0f9bd6a5c0d0ab7992c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 5 Nov 2025 19:41:15 +0000 Subject: [PATCH 04/16] Address code review feedback - improve test documentation and tolerance Co-authored-by: shachafl <66333410+shachafl@users.noreply.github.com> --- starfish/core/spots/FindSpots/test/test_spot_detection.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/starfish/core/spots/FindSpots/test/test_spot_detection.py b/starfish/core/spots/FindSpots/test/test_spot_detection.py index 12627ea1f..93ce0a186 100644 --- a/starfish/core/spots/FindSpots/test/test_spot_detection.py +++ b/starfish/core/spots/FindSpots/test/test_spot_detection.py @@ -172,6 +172,7 @@ def test_blob_detector_2d_spot_coordinates(): image_2d[48:53, 58:63] = 1.0 # Create an ImageStack with this 2D image + # Shape: (rounds=1, channels=1, z-planes=1, height=100, width=100) image_stack = ImageStack.from_numpy(image_2d.reshape(1, 1, 1, 100, 100)) # Create a BlobDetector with is_volume=False @@ -198,11 +199,11 @@ def test_blob_detector_2d_spot_coordinates(): assert not np.all(y_values == 0), "All y-values are 0, indicating the bug is present" # Check that the detected spot is near the expected location (y=50, x=60) - # Allow some tolerance since blob detection may not be exact - assert np.any(np.abs(y_values - 50) < 10), f"No spot found near y=50, found: {y_values}" + # Use tighter tolerance (5 pixels) for more robust testing + assert np.any(np.abs(y_values - 50) < 5), f"No spot found near y=50, found: {y_values}" x_values = spots.data['x'].values - assert np.any(np.abs(x_values - 60) < 10), f"No spot found near x=60, found: {x_values}" + assert np.any(np.abs(x_values - 60) < 5), f"No spot found near x=60, found: {x_values}" # Check that radius is reasonable (not extremely large like 843.0 in the bug) radius_values = spots.data['radius'].values From 4bb2161c66c8e0ed8b395efea845ff5a9caafcfc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 5 Nov 2025 19:42:44 +0000 Subject: [PATCH 05/16] Polish test comments based on final code review feedback Co-authored-by: shachafl <66333410+shachafl@users.noreply.github.com> --- starfish/core/spots/FindSpots/test/test_spot_detection.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/starfish/core/spots/FindSpots/test/test_spot_detection.py b/starfish/core/spots/FindSpots/test/test_spot_detection.py index 93ce0a186..0306212df 100644 --- a/starfish/core/spots/FindSpots/test/test_spot_detection.py +++ b/starfish/core/spots/FindSpots/test/test_spot_detection.py @@ -168,7 +168,7 @@ def test_blob_detector_2d_spot_coordinates(): """ # Create a simple 2D image with a bright spot image_2d = np.zeros((100, 100), dtype=np.float32) - # Add a bright spot at y=50, x=60 + # Add a 5x5 bright spot centered approximately at y=50, x=60 image_2d[48:53, 58:63] = 1.0 # Create an ImageStack with this 2D image @@ -199,7 +199,7 @@ def test_blob_detector_2d_spot_coordinates(): assert not np.all(y_values == 0), "All y-values are 0, indicating the bug is present" # Check that the detected spot is near the expected location (y=50, x=60) - # Use tighter tolerance (5 pixels) for more robust testing + # Allow 5 pixel tolerance since blob detection may not find exact center assert np.any(np.abs(y_values - 50) < 5), f"No spot found near y=50, found: {y_values}" x_values = spots.data['x'].values From 5ae8c57a6f896ec48a606f910949ed8fece286b5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 6 Nov 2025 08:09:03 +0000 Subject: [PATCH 06/16] Fix test to correctly access spot_attrs via round/channel indexing Co-authored-by: shachafl <66333410+shachafl@users.noreply.github.com> --- starfish/core/spots/FindSpots/test/test_spot_detection.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/starfish/core/spots/FindSpots/test/test_spot_detection.py b/starfish/core/spots/FindSpots/test/test_spot_detection.py index 0306212df..97468ca66 100644 --- a/starfish/core/spots/FindSpots/test/test_spot_detection.py +++ b/starfish/core/spots/FindSpots/test/test_spot_detection.py @@ -188,8 +188,8 @@ def test_blob_detector_2d_spot_coordinates(): # Run detection spot_results = detector_2d.run(image_stack=image_stack) - # Get the spot attributes - spots = spot_results.spot_attrs + # Get the spot attributes for round 0, channel 0 + spots = spot_results[{Axes.ROUND: 0, Axes.CH: 0}].spot_attrs # Verify we found at least one spot assert len(spots.data) > 0, "No spots detected" From 590e7c859b1cc1eaaeb786b075f1414662e3f5ac Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 20 Nov 2025 12:28:16 +0000 Subject: [PATCH 07/16] Handle both 2D and 3D data_image when is_volume=False When using BlobDetector with is_volume=False and a reference_image that has multiple z-planes, the data_image can be 3D after squeezing ROUND and CH dims. This fix checks data_image.ndim and uses appropriate indexing: - 3D (z, y, x): use data_image[z_inds, y_inds, x_inds] - 2D (y, x): use data_image[y_inds, x_inds] Co-authored-by: shachafl <66333410+shachafl@users.noreply.github.com> --- starfish/core/spots/FindSpots/blob.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/starfish/core/spots/FindSpots/blob.py b/starfish/core/spots/FindSpots/blob.py index df81ee3c9..6e49b0ac5 100644 --- a/starfish/core/spots/FindSpots/blob.py +++ b/starfish/core/spots/FindSpots/blob.py @@ -148,7 +148,12 @@ def image_to_spots( x_inds = fitted_blobs_array[:, 1].astype(int) radius = np.round(fitted_blobs_array[:, 2] * np.sqrt(2)) z_inds = np.zeros(len(fitted_blobs_array), dtype=int) - intensities = data_image[y_inds, x_inds] + # Handle both 2D and 3D data_image when is_volume=False + # (3D can occur when using reference_image with multiple z-planes) + if data_image.ndim == 3: + intensities = data_image[z_inds, y_inds, x_inds] + else: + intensities = data_image[y_inds, x_inds] # construct dataframe spot_data = pd.DataFrame( From 5358bfbb3e8f3b19ba09e09f4776be95f8545e48 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 20 Nov 2025 12:29:57 +0000 Subject: [PATCH 08/16] Add test for BlobDetector with is_volume=False and reference_image Tests the case where reference_image has multiple z-planes, ensuring the fix correctly handles 3D data_image after squeezing ROUND and CH dimensions. Co-authored-by: shachafl <66333410+shachafl@users.noreply.github.com> --- .../FindSpots/test/test_spot_detection.py | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/starfish/core/spots/FindSpots/test/test_spot_detection.py b/starfish/core/spots/FindSpots/test/test_spot_detection.py index 97468ca66..35d5ec22f 100644 --- a/starfish/core/spots/FindSpots/test/test_spot_detection.py +++ b/starfish/core/spots/FindSpots/test/test_spot_detection.py @@ -209,3 +209,57 @@ def test_blob_detector_2d_spot_coordinates(): radius_values = spots.data['radius'].values assert np.all(radius_values < 100), f"Radius values too large: {radius_values}" assert np.all(radius_values > 0), f"Radius values should be positive: {radius_values}" + + +def test_blob_detector_2d_with_reference_image(): + """Test BlobDetector with is_volume=False and a reference_image. + + This tests the case where reference_image has multiple z-planes, which results in + a 3D data_image after squeezing ROUND and CH dimensions. + """ + # Create a 3D reference image with multiple z-planes + reference_3d = np.zeros((3, 100, 100), dtype=np.float32) + # Add a bright spot at z=0, y=30, x=40 + reference_3d[0, 28:33, 38:43] = 1.0 + + # Create ImageStacks with the reference image (3 z-planes, 1 round, 1 channel) + reference_stack = ImageStack.from_numpy(reference_3d.reshape(1, 1, 3, 100, 100)) + # Create a primary image stack (same dimensions) + primary_stack = ImageStack.from_numpy(reference_3d.reshape(1, 1, 3, 100, 100)) + + # Create a BlobDetector with is_volume=False + detector_2d = BlobDetector( + min_sigma=1, + max_sigma=3, + num_sigma=5, + threshold=0.01, + is_volume=False, + measurement_type='max' + ) + + # Run detection with reference_image + spot_results = detector_2d.run(image_stack=primary_stack, reference_image=reference_stack) + + # The reference image approach produces spots for all (round, ch) combinations + # For our case: 1 round x 1 channel = 1 combination + total_spots = spot_results.count_total_spots() + assert total_spots > 0, "No spots detected with reference image" + + # Check a specific round/channel + spots = spot_results[{Axes.ROUND: 0, Axes.CH: 0}].spot_attrs + + # Verify spot coordinates and radius are correct + y_values = spots.data['y'].values + x_values = spots.data['x'].values + radius_values = spots.data['radius'].values + + # Check that y-values are not all 0 + assert not np.all(y_values == 0), "All y-values are 0, bug is present" + + # Check that the spot is near the expected location (y=30, x=40) + assert np.any(np.abs(y_values - 30) < 5), f"No spot found near y=30, found: {y_values}" + assert np.any(np.abs(x_values - 40) < 5), f"No spot found near x=40, found: {x_values}" + + # Check that radius is reasonable + assert np.all(radius_values < 100), f"Radius values too large: {radius_values}" + assert np.all(radius_values > 0), f"Radius values should be positive: {radius_values}" From 8753c83b1df172670deb110a9fbd08d66327d063 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 20 Nov 2025 12:53:13 +0000 Subject: [PATCH 09/16] Fix blob coordinate extraction based on fitted_blobs_array shape The blob detector (blob_log) automatically detects image dimensionality and returns: - 4 columns [z, y, x, sigma] for 3D images - 3 columns [y, x, sigma] for 2D images This is independent of the is_volume parameter. When is_volume=False with a reference_image having multiple z-planes, the squeezed reference is 3D, so blob_log returns 4 columns, not 3. Changed from checking is_volume to checking fitted_blobs_array.shape[1] to determine whether we have 3D or 2D blob detection results. Co-authored-by: shachafl <66333410+shachafl@users.noreply.github.com> --- starfish/core/spots/FindSpots/blob.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/starfish/core/spots/FindSpots/blob.py b/starfish/core/spots/FindSpots/blob.py index 6e49b0ac5..6af459161 100644 --- a/starfish/core/spots/FindSpots/blob.py +++ b/starfish/core/spots/FindSpots/blob.py @@ -137,19 +137,24 @@ def image_to_spots( # measure intensities data_image = np.asarray(data_image) - if self.is_volume: + # Check the number of columns in fitted_blobs_array to determine dimensionality + # - 4 columns: [z, y, x, sigma] from 3D blob detection + # - 3 columns: [y, x, sigma] from 2D blob detection + # Note: This can differ from is_volume when using reference_image with multiple z-planes + if fitted_blobs_array.shape[1] == 4: + # 3D blob detection result z_inds = fitted_blobs_array[:, 0].astype(int) y_inds = fitted_blobs_array[:, 1].astype(int) x_inds = fitted_blobs_array[:, 2].astype(int) radius = np.round(fitted_blobs_array[:, 3] * np.sqrt(3)) intensities = data_image[tuple([z_inds, y_inds, x_inds])] else: + # 2D blob detection result y_inds = fitted_blobs_array[:, 0].astype(int) x_inds = fitted_blobs_array[:, 1].astype(int) radius = np.round(fitted_blobs_array[:, 2] * np.sqrt(2)) z_inds = np.zeros(len(fitted_blobs_array), dtype=int) - # Handle both 2D and 3D data_image when is_volume=False - # (3D can occur when using reference_image with multiple z-planes) + # For 2D results, handle both 2D and 3D data_image if data_image.ndim == 3: intensities = data_image[z_inds, y_inds, x_inds] else: From 6c6ca05acec8dd84d7c07838f44e31774dda740e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 20 Nov 2025 15:27:43 +0000 Subject: [PATCH 10/16] Squeeze singleton z-dimension before blob detection for consistency When data_image has shape (1, y, x), squeeze it to (y, x) before calling blob_log to ensure consistent results. This prevents blob_log from treating singleton z-dimensions as 3D, which produces slightly different detection results compared to true 2D images. After detection, restore the original shape for consistent intensity indexing. Co-authored-by: shachafl <66333410+shachafl@users.noreply.github.com> --- starfish/core/spots/FindSpots/blob.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/starfish/core/spots/FindSpots/blob.py b/starfish/core/spots/FindSpots/blob.py index 6af459161..2059b83ed 100644 --- a/starfish/core/spots/FindSpots/blob.py +++ b/starfish/core/spots/FindSpots/blob.py @@ -125,6 +125,15 @@ def image_to_spots( if self.detector_method == blob_dog: del spot_finding_args['num_sigma'] + # Squeeze out singleton z-dimension for consistency + # This ensures (1, y, x) produces same results as (y, x) + data_image = np.asarray(data_image) + original_shape = data_image.shape + squeezed = False + if data_image.ndim == 3 and data_image.shape[0] == 1: + data_image = np.squeeze(data_image, axis=0) + squeezed = True + fitted_blobs_array: np.ndarray = self.detector_method( data_image, **spot_finding_args @@ -136,7 +145,9 @@ def image_to_spots( return PerImageSliceSpotResults(spot_attrs=empty_spot_attrs, extras=None) # measure intensities - data_image = np.asarray(data_image) + # Restore original shape if it was squeezed for consistency in indexing + if squeezed: + data_image = data_image.reshape(original_shape) # Check the number of columns in fitted_blobs_array to determine dimensionality # - 4 columns: [z, y, x, sigma] from 3D blob detection # - 3 columns: [y, x, sigma] from 2D blob detection From ff9c5edc41a1f74abd33625b657f50415261bbdc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 20 Nov 2025 16:10:21 +0000 Subject: [PATCH 11/16] Refactor singleton z-dimension handling for clarity Use separate variable data_image_for_detection to make it clearer that the squeezed data is only used for blob detection, while the original data_image is used for intensity extraction. This should make debugging easier and the code more maintainable. Co-authored-by: shachafl <66333410+shachafl@users.noreply.github.com> --- starfish/core/spots/FindSpots/blob.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/starfish/core/spots/FindSpots/blob.py b/starfish/core/spots/FindSpots/blob.py index 2059b83ed..785a6b187 100644 --- a/starfish/core/spots/FindSpots/blob.py +++ b/starfish/core/spots/FindSpots/blob.py @@ -125,17 +125,19 @@ def image_to_spots( if self.detector_method == blob_dog: del spot_finding_args['num_sigma'] - # Squeeze out singleton z-dimension for consistency + # Convert to numpy array and handle singleton z-dimension for consistency # This ensures (1, y, x) produces same results as (y, x) data_image = np.asarray(data_image) - original_shape = data_image.shape - squeezed = False + squeeze_z = False if data_image.ndim == 3 and data_image.shape[0] == 1: - data_image = np.squeeze(data_image, axis=0) - squeezed = True + # Squeeze out the singleton z-dimension before blob detection + data_image_for_detection = np.squeeze(data_image, axis=0) + squeeze_z = True + else: + data_image_for_detection = data_image fitted_blobs_array: np.ndarray = self.detector_method( - data_image, + data_image_for_detection, **spot_finding_args ) # type: ignore # error: Cannot call function of unknown type [operator] @@ -145,9 +147,6 @@ def image_to_spots( return PerImageSliceSpotResults(spot_attrs=empty_spot_attrs, extras=None) # measure intensities - # Restore original shape if it was squeezed for consistency in indexing - if squeezed: - data_image = data_image.reshape(original_shape) # Check the number of columns in fitted_blobs_array to determine dimensionality # - 4 columns: [z, y, x, sigma] from 3D blob detection # - 3 columns: [y, x, sigma] from 2D blob detection From 21658b76e702668b2ae379dbe2b4d578e7a239a3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 21 Nov 2025 15:53:05 +0000 Subject: [PATCH 12/16] Fix anisotropic sigma handling when squeezing singleton z-dimension When data_image is squeezed from (1, y, x) to (y, x), adjust 3-element sigma tuples (z, y, x) to 2-element tuples (y, x) by dropping the z component. This prevents IndexError when using anisotropic sigma with singleton z-planes. Co-authored-by: shachafl <66333410+shachafl@users.noreply.github.com> --- starfish/core/spots/FindSpots/blob.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/starfish/core/spots/FindSpots/blob.py b/starfish/core/spots/FindSpots/blob.py index 785a6b187..046543627 100644 --- a/starfish/core/spots/FindSpots/blob.py +++ b/starfish/core/spots/FindSpots/blob.py @@ -133,6 +133,13 @@ def image_to_spots( # Squeeze out the singleton z-dimension before blob detection data_image_for_detection = np.squeeze(data_image, axis=0) squeeze_z = True + + # Adjust sigma parameters for 2D detection if they were specified for 3D + # If sigma is a 3-element tuple (z, y, x), drop the z component to get (y, x) + if isinstance(spot_finding_args["min_sigma"], tuple) and len(spot_finding_args["min_sigma"]) == 3: + spot_finding_args["min_sigma"] = spot_finding_args["min_sigma"][1:] + if isinstance(spot_finding_args["max_sigma"], tuple) and len(spot_finding_args["max_sigma"]) == 3: + spot_finding_args["max_sigma"] = spot_finding_args["max_sigma"][1:] else: data_image_for_detection = data_image From e1e6f51e9851f2509a1b03928aff6a65ef1f705d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 21 Nov 2025 16:14:00 +0000 Subject: [PATCH 13/16] Fix anisotropic sigma detection logic using data dimensionality Changed from using fitted_blobs_array.shape[1] to check data_image_for_detection.ndim to determine if 2D or 3D blob detection was performed. This correctly handles: - 3D with scalar sigma: (n, 4) = [z, y, x, sigma] - 2D with anisotropic sigma: (n, 4) = [y, x, sigma_y, sigma_x] - 3D with anisotropic sigma: (n, 6) = [z, y, x, sigma_z, sigma_y, sigma_x] For anisotropic sigma, radius is computed as average of sigma values. Co-authored-by: shachafl <66333410+shachafl@users.noreply.github.com> --- starfish/core/spots/FindSpots/blob.py | 32 +++++++++++++++++++-------- 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/starfish/core/spots/FindSpots/blob.py b/starfish/core/spots/FindSpots/blob.py index 046543627..2b86acf1d 100644 --- a/starfish/core/spots/FindSpots/blob.py +++ b/starfish/core/spots/FindSpots/blob.py @@ -154,22 +154,36 @@ def image_to_spots( return PerImageSliceSpotResults(spot_attrs=empty_spot_attrs, extras=None) # measure intensities - # Check the number of columns in fitted_blobs_array to determine dimensionality - # - 4 columns: [z, y, x, sigma] from 3D blob detection - # - 3 columns: [y, x, sigma] from 2D blob detection - # Note: This can differ from is_volume when using reference_image with multiple z-planes - if fitted_blobs_array.shape[1] == 4: - # 3D blob detection result + # Determine dimensionality from the data passed to blob detector + # blob_log returns: + # - Scalar sigma: (n_blobs, ndim + 1) where columns are [coords..., sigma] + # - Anisotropic sigma: (n_blobs, 2*ndim) where columns are [coords..., sigmas...] + # We use data_image_for_detection.ndim to know if we did 2D or 3D detection + is_3d_detection = data_image_for_detection.ndim == 3 + if is_3d_detection: + # 3D blob detection result: [z, y, x, sigma] or [z, y, x, sigma_z, sigma_y, sigma_x] z_inds = fitted_blobs_array[:, 0].astype(int) y_inds = fitted_blobs_array[:, 1].astype(int) x_inds = fitted_blobs_array[:, 2].astype(int) - radius = np.round(fitted_blobs_array[:, 3] * np.sqrt(3)) + # For radius, use first sigma column (scalar sigma) or average of sigma columns (anisotropic) + if fitted_blobs_array.shape[1] == 4: + # Scalar sigma + radius = np.round(fitted_blobs_array[:, 3] * np.sqrt(3)) + else: + # Anisotropic sigma - average the three sigma values + radius = np.round(fitted_blobs_array[:, 3:6].mean(axis=1) * np.sqrt(3)) intensities = data_image[tuple([z_inds, y_inds, x_inds])] else: - # 2D blob detection result + # 2D blob detection result: [y, x, sigma] or [y, x, sigma_y, sigma_x] y_inds = fitted_blobs_array[:, 0].astype(int) x_inds = fitted_blobs_array[:, 1].astype(int) - radius = np.round(fitted_blobs_array[:, 2] * np.sqrt(2)) + # For radius, use first sigma column (scalar sigma) or average of sigma columns (anisotropic) + if fitted_blobs_array.shape[1] == 3: + # Scalar sigma + radius = np.round(fitted_blobs_array[:, 2] * np.sqrt(2)) + else: + # Anisotropic sigma - average the two sigma values + radius = np.round(fitted_blobs_array[:, 2:4].mean(axis=1) * np.sqrt(2)) z_inds = np.zeros(len(fitted_blobs_array), dtype=int) # For 2D results, handle both 2D and 3D data_image if data_image.ndim == 3: From 0c98005121b8a5bc8b2b2b782633c5fc2d04b271 Mon Sep 17 00:00:00 2001 From: shachafl Date: Fri, 21 Nov 2025 23:48:36 +0200 Subject: [PATCH 14/16] cleaning up after copilot --- starfish/core/spots/FindSpots/blob.py | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/starfish/core/spots/FindSpots/blob.py b/starfish/core/spots/FindSpots/blob.py index 2b86acf1d..540ec320d 100644 --- a/starfish/core/spots/FindSpots/blob.py +++ b/starfish/core/spots/FindSpots/blob.py @@ -128,18 +128,9 @@ def image_to_spots( # Convert to numpy array and handle singleton z-dimension for consistency # This ensures (1, y, x) produces same results as (y, x) data_image = np.asarray(data_image) - squeeze_z = False if data_image.ndim == 3 and data_image.shape[0] == 1: # Squeeze out the singleton z-dimension before blob detection data_image_for_detection = np.squeeze(data_image, axis=0) - squeeze_z = True - - # Adjust sigma parameters for 2D detection if they were specified for 3D - # If sigma is a 3-element tuple (z, y, x), drop the z component to get (y, x) - if isinstance(spot_finding_args["min_sigma"], tuple) and len(spot_finding_args["min_sigma"]) == 3: - spot_finding_args["min_sigma"] = spot_finding_args["min_sigma"][1:] - if isinstance(spot_finding_args["max_sigma"], tuple) and len(spot_finding_args["max_sigma"]) == 3: - spot_finding_args["max_sigma"] = spot_finding_args["max_sigma"][1:] else: data_image_for_detection = data_image @@ -159,13 +150,13 @@ def image_to_spots( # - Scalar sigma: (n_blobs, ndim + 1) where columns are [coords..., sigma] # - Anisotropic sigma: (n_blobs, 2*ndim) where columns are [coords..., sigmas...] # We use data_image_for_detection.ndim to know if we did 2D or 3D detection - is_3d_detection = data_image_for_detection.ndim == 3 - if is_3d_detection: + if data_image_for_detection.ndim == 3: # 3D blob detection result: [z, y, x, sigma] or [z, y, x, sigma_z, sigma_y, sigma_x] z_inds = fitted_blobs_array[:, 0].astype(int) y_inds = fitted_blobs_array[:, 1].astype(int) x_inds = fitted_blobs_array[:, 2].astype(int) - # For radius, use first sigma column (scalar sigma) or average of sigma columns (anisotropic) + # For radius, use first sigma column (scalar sigma) + # or average of sigma columns (anisotropic) if fitted_blobs_array.shape[1] == 4: # Scalar sigma radius = np.round(fitted_blobs_array[:, 3] * np.sqrt(3)) @@ -177,7 +168,8 @@ def image_to_spots( # 2D blob detection result: [y, x, sigma] or [y, x, sigma_y, sigma_x] y_inds = fitted_blobs_array[:, 0].astype(int) x_inds = fitted_blobs_array[:, 1].astype(int) - # For radius, use first sigma column (scalar sigma) or average of sigma columns (anisotropic) + # For radius, use first sigma column (scalar sigma) + # or average of sigma columns (anisotropic) if fitted_blobs_array.shape[1] == 3: # Scalar sigma radius = np.round(fitted_blobs_array[:, 2] * np.sqrt(2)) From c005f33307a1d1be71462b7860939f6c89436af4 Mon Sep 17 00:00:00 2001 From: shachafl Date: Fri, 21 Nov 2025 23:50:07 +0200 Subject: [PATCH 15/16] updating some tests due to improved blob detection --- starfish/test/full_pipelines/api/test_iss_api.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/starfish/test/full_pipelines/api/test_iss_api.py b/starfish/test/full_pipelines/api/test_iss_api.py index 1b0466b1b..a7907b8e8 100644 --- a/starfish/test/full_pipelines/api/test_iss_api.py +++ b/starfish/test/full_pipelines/api/test_iss_api.py @@ -103,14 +103,14 @@ def test_iss_pipeline_cropped_data(tmpdir): # decode decoded = iss.decoded - # decoding identifies 4 genes, each with 1 count + # decoding identifies 15 genes, each with at least 1 count genes, gene_counts = iss.genes, iss.counts - assert np.array_equal(genes, np.array(['ACTB', 'CD68', 'CTSL2', 'EPCAM', + assert np.array_equal(genes, np.array(['ACTB', 'CCNB1', 'CD68', 'CTSL2', 'EPCAM', 'ETV4', 'GAPDH', 'GUS', 'HER2', 'RAC1', - 'TFRC', 'TP53', 'VEGF'])) + 'RPLP0', 'STK15', 'TFRC', 'TP53', 'VEGF'])) - assert np.array_equal(gene_counts, [19, 1, 5, 2, 1, 11, 1, 3, 2, 1, 1, 2]) - assert decoded.sizes[Features.AXIS] == 99 + assert np.array_equal(gene_counts, [21, 1, 1, 6, 2, 1, 12, 1, 3, 3, 1, 1, 1, 1, 2]) + assert decoded.sizes[Features.AXIS] == 102 masks = iss.masks @@ -145,11 +145,11 @@ def test_iss_pipeline_cropped_data(tmpdir): assert pipeline_log[2]['method'] == 'BlobDetector' assert pipeline_log[3]['method'] == 'PerRoundMaxChannel' - # 28 of the spots are assigned to cell 0 (although most spots do not decode!) - assert np.sum(assigned['cell_id'] == '1') == 28 + # 29 of the spots are assigned to cell 0 (although most spots do not decode!) + assert np.sum(assigned['cell_id'] == '1') == 29 expression_matrix = iss.cg # test that nans were properly removed from the expression matrix assert 'nan' not in expression_matrix.genes.data # test the number of spots that did not decode per cell - assert np.array_equal(expression_matrix.number_of_undecoded_spots.data, [13, 1, 36]) + assert np.array_equal(expression_matrix.number_of_undecoded_spots.data, [10, 1, 34]) From 2e542d0472e57c858f73dfe7d54a2a8d3c085dff Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Nov 2025 12:25:38 +0200 Subject: [PATCH 16/16] 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> --- starfish/core/spots/FindSpots/blob.py | 16 ++-- .../FindSpots/test/test_spot_detection.py | 90 +++++++++++++++++++ 2 files changed, 99 insertions(+), 7 deletions(-) diff --git a/starfish/core/spots/FindSpots/blob.py b/starfish/core/spots/FindSpots/blob.py index 540ec320d..1e65b215b 100644 --- a/starfish/core/spots/FindSpots/blob.py +++ b/starfish/core/spots/FindSpots/blob.py @@ -251,13 +251,15 @@ def run( merged_z_tables[(r, ch)] = pd.concat( [merged_z_tables[(r, ch)], spot_attributes_list[i][0].spot_attrs.data]) new = [] - r_chs = sorted([*merged_z_tables]) - selectors = list(image_stack._iter_axes({Axes.ROUND, Axes.CH})) - for i, (r, ch) in enumerate(r_chs): - merged_z_tables[(r, ch)]['spot_id'] = range(len(merged_z_tables[(r, ch)])) - spot_attrs = SpotAttributes(merged_z_tables[(r, ch)].reset_index(drop=True)) - new.append((PerImageSliceSpotResults(spot_attrs=spot_attrs, extras=None), - selectors[i])) + # Iterate through the merged tables in the order expected by _iter_axes + for selector in image_stack._iter_axes({Axes.ROUND, Axes.CH}): + r = selector[Axes.ROUND] + ch = selector[Axes.CH] + if (r, ch) in merged_z_tables: + merged_z_tables[(r, ch)]['spot_id'] = range(len(merged_z_tables[(r, ch)])) + spot_attrs = SpotAttributes(merged_z_tables[(r, ch)].reset_index(drop=True)) + new.append((PerImageSliceSpotResults(spot_attrs=spot_attrs, extras=None), + selector)) spot_attributes_list = new diff --git a/starfish/core/spots/FindSpots/test/test_spot_detection.py b/starfish/core/spots/FindSpots/test/test_spot_detection.py index 35d5ec22f..766a4ca54 100644 --- a/starfish/core/spots/FindSpots/test/test_spot_detection.py +++ b/starfish/core/spots/FindSpots/test/test_spot_detection.py @@ -263,3 +263,93 @@ def test_blob_detector_2d_with_reference_image(): # Check that radius is reasonable assert np.all(radius_values < 100), f"Radius values too large: {radius_values}" assert np.all(radius_values > 0), f"Radius values should be positive: {radius_values}" + + +def test_blob_detector_round_channel_assignment(): + """Test that BlobDetector with is_volume=False assigns spots to correct (round, channel) pairs. + + This is a regression test for a bug where spots were assigned to incorrect round/channel + combinations due to a mismatch between the sorted (r, ch) tuples and the iteration order + from _iter_axes({Axes.ROUND, Axes.CH}). + """ + # Create a test ImageStack with 2 rounds, 3 channels, 1 z-plane + # Each (round, channel) pair gets a spot at a unique location + data = np.zeros((2, 3, 1, 100, 100), dtype=np.float32) + + # Add spots at different y-coordinates for each (round, channel) + # Round 0, Channel 0: y=10 + data[0, 0, 0, 8:13, 8:13] = 1.0 + # Round 0, Channel 1: y=20 + data[0, 1, 0, 18:23, 18:23] = 1.0 + # Round 0, Channel 2: y=30 + data[0, 2, 0, 28:33, 28:33] = 1.0 + # Round 1, Channel 0: y=40 + data[1, 0, 0, 38:43, 38:43] = 1.0 + # Round 1, Channel 1: y=50 + data[1, 1, 0, 48:53, 48:53] = 1.0 + # Round 1, Channel 2: y=60 + data[1, 2, 0, 58:63, 58:63] = 1.0 + + image_stack = ImageStack.from_numpy(data) + + # Test with is_volume=False + detector_2d = BlobDetector( + min_sigma=1, + max_sigma=3, + num_sigma=5, + threshold=0.01, + is_volume=False, + measurement_type='mean' + ) + + spots_2d = detector_2d.run(image_stack=image_stack) + + # Test with is_volume=True for comparison + detector_3d = BlobDetector( + min_sigma=1, + max_sigma=3, + num_sigma=5, + threshold=0.01, + is_volume=True, + measurement_type='mean' + ) + + spots_3d = detector_3d.run(image_stack=image_stack) + + # Define expected y-coordinates for each (round, channel) pair + expected_y = { + (0, 0): 10, + (0, 1): 20, + (0, 2): 30, + (1, 0): 40, + (1, 1): 50, + (1, 2): 60, + } + + # Check that both is_volume=False and is_volume=True produce the same results + for r in range(2): + for ch in range(3): + # Get spots for this (round, channel) + spots_2d_data = spots_2d[{Axes.ROUND: r, Axes.CH: ch}].spot_attrs.data + spots_3d_data = spots_3d[{Axes.ROUND: r, Axes.CH: ch}].spot_attrs.data + + # Both should have found the spot + assert len(spots_2d_data) > 0, \ + f"No spots found for Round={r}, CH={ch} with is_volume=False" + assert len(spots_3d_data) > 0, \ + f"No spots found for Round={r}, CH={ch} with is_volume=True" + + # Check that the y-coordinate matches the expected value + y_2d = spots_2d_data['y'].values[0] + y_3d = spots_3d_data['y'].values[0] + expected = expected_y[(r, ch)] + + assert np.abs(y_2d - expected) < 5, \ + f"is_volume=False: Round={r}, CH={ch} has y={y_2d:.0f}, expected ~{expected}" + assert np.abs(y_3d - expected) < 5, \ + f"is_volume=True: Round={r}, CH={ch} has y={y_3d:.0f}, expected ~{expected}" + + # Both should produce the same y-coordinate + assert np.abs(y_2d - y_3d) < 2, \ + f"Round={r}, CH={ch}: is_volume=False has y={y_2d:.0f} " \ + f"but is_volume=True has y={y_3d:.0f}"