Repository: scikit-image/scikit-image

Issue:
feature.blob_(log|dog) bug with 3D images
## Description

When using 3D images with```feature.blog_dog``` or ```feature.blob_log``` and a sequence argument for min and max sigma, the sigmas are converted from indexes back to their original values (as in [here](https://github.com/scikit-image/scikit-image/blob/2962429237988cb60b9b317aa020ca3bab100b7f/skimage/feature/blob.py#L301)) and stacked horizontally (as in [here](https://github.com/scikit-image/scikit-image/blob/2962429237988cb60b9b317aa020ca3bab100b7f/skimage/feature/blob.py#L308)) to give an array with 3 spatial coordinates and 3 sigma values.  This is then passed to ```_prune_blobs``` which selects all but the last column for the KDTree construction (```spatial.cKDTree(blobs_array[:, :-1])```) meaning that the tree is considering 3 spatial coordinates and 2 out of 3 sigma values for distance calculations, which seems to be a mistake.

## Way to reproduce
NA

## Version information
master branch




Discussion:
@eric-czech thanks for the report and sorry that it's taken us so long to respond! I imagine this bug was introduced when adding support for anisotropic sigma (#3690). Prior to that, we were discarding the unique sigma, now we are only discarding the final sigma out of `ndim`. So I guess this is failing *also* for 2D now!

The fix would be to replace that `-1` with `-ndim`, and add tests to make sure this is not repeated in the future!
Investigating this now... The code is still correct for isotropic data, because we discard the extra columns of `sigmas_of_peaks` if `scalar_sigma` is true. So that's a relief!

Resolving patch:
diff --git a/skimage/feature/blob.py b/skimage/feature/blob.py
--- a/skimage/feature/blob.py
+++ b/skimage/feature/blob.py
@@ -81,10 +81,11 @@ def _compute_sphere_overlap(d, r1, r2):
     return vol / (4./3 * math.pi * min(r1, r2) ** 3)
 
 
-def _blob_overlap(blob1, blob2):
+def _blob_overlap(blob1, blob2, *, sigma_dim=1):
     """Finds the overlapping area fraction between two blobs.
 
-    Returns a float representing fraction of overlapped area.
+    Returns a float representing fraction of overlapped area. Note that 0.0
+    is *always* returned for dimension greater than 3.
 
     Parameters
     ----------
@@ -98,35 +99,49 @@ def _blob_overlap(blob1, blob2):
         where ``row, col`` (or ``(pln, row, col)``) are coordinates
         of blob and ``sigma`` is the standard deviation of the Gaussian kernel
         which detected the blob.
+    sigma_dim : int, optional
+        The dimensionality of the sigma value. Can be 1 or the same as the
+        dimensionality of the blob space (2 or 3).
 
     Returns
     -------
     f : float
         Fraction of overlapped area (or volume in 3D).
     """
-    n_dim = len(blob1) - 1
-    root_ndim = sqrt(n_dim)
-
-    # extent of the blob is given by sqrt(2)*scale
-    r1 = blob1[-1] * root_ndim
-    r2 = blob2[-1] * root_ndim
+    ndim = len(blob1) - sigma_dim
+    if ndim > 3:
+        return 0.0
+    root_ndim = sqrt(ndim)
+
+    # we divide coordinates by sigma * sqrt(ndim) to rescale space to isotropy,
+    # giving spheres of radius = 1 or < 1.
+    if blob1[-1] > blob2[-1]:
+        max_sigma = blob1[-sigma_dim:]
+        r1 = 1
+        r2 = blob2[-1] / blob1[-1]
+    else:
+        max_sigma = blob2[-sigma_dim:]
+        r2 = 1
+        r1 = blob1[-1] / blob2[-1]
+    pos1 = blob1[:ndim] / (max_sigma * root_ndim)
+    pos2 = blob2[:ndim] / (max_sigma * root_ndim)
 
-    d = sqrt(np.sum((blob1[:-1] - blob2[:-1])**2))
-    if d > r1 + r2:
-        return 0
+    d = np.sqrt(np.sum((pos2 - pos1)**2))
+    if d > r1 + r2:  # centers farther than sum of radii, so no overlap
+        return 0.0
 
-    # one blob is inside the other, the smaller blob must die
+    # one blob is inside the other
     if d <= abs(r1 - r2):
-        return 1
+        return 1.0
 
-    if n_dim == 2:
+    if ndim == 2:
         return _compute_disk_overlap(d, r1, r2)
 
-    else:  # http://mathworld.wolfram.com/Sphere-SphereIntersection.html
+    else:  # ndim=3 http://mathworld.wolfram.com/Sphere-SphereIntersection.html
         return _compute_sphere_overlap(d, r1, r2)
 
 
-def _prune_blobs(blobs_array, overlap):
+def _prune_blobs(blobs_array, overlap, *, sigma_dim=1):
     """Eliminated blobs with area overlap.
 
     Parameters
@@ -141,22 +156,27 @@ def _prune_blobs(blobs_array, overlap):
     overlap : float
         A value between 0 and 1. If the fraction of area overlapping for 2
         blobs is greater than `overlap` the smaller blob is eliminated.
+    sigma_dim : int, optional
+        The number of columns in ``blobs_array`` corresponding to sigmas rather
+        than positions.
 
     Returns
     -------
     A : ndarray
         `array` with overlapping blobs removed.
     """
-    sigma = blobs_array[:, -1].max()
-    distance = 2 * sigma * sqrt(blobs_array.shape[1] - 1)
-    tree = spatial.cKDTree(blobs_array[:, :-1])
+    sigma = blobs_array[:, -sigma_dim:].max()
+    distance = 2 * sigma * sqrt(blobs_array.shape[1] - sigma_dim)
+    tree = spatial.cKDTree(blobs_array[:, :-sigma_dim])
     pairs = np.array(list(tree.query_pairs(distance)))
     if len(pairs) == 0:
         return blobs_array
     else:
         for (i, j) in pairs:
             blob1, blob2 = blobs_array[i], blobs_array[j]
-            if _blob_overlap(blob1, blob2) > overlap:
+            if _blob_overlap(blob1, blob2, sigma_dim=sigma_dim) > overlap:
+                # note: this test works even in the anisotropic case because
+                # all sigmas increase together.
                 if blob1[-1] > blob2[-1]:
                     blob2[-1] = 0
                 else:
@@ -307,7 +327,9 @@ def blob_dog(image, min_sigma=1, max_sigma=50, sigma_ratio=1.6, threshold=2.0,
     # Remove sigma index and replace with sigmas
     lm = np.hstack([lm[:, :-1], sigmas_of_peaks])
 
-    return _prune_blobs(lm, overlap)
+    sigma_dim = sigmas_of_peaks.shape[1]
+
+    return _prune_blobs(lm, overlap, sigma_dim=sigma_dim)
 
 
 def blob_log(image, min_sigma=1, max_sigma=50, num_sigma=10, threshold=.2,
@@ -415,22 +437,20 @@ def blob_log(image, min_sigma=1, max_sigma=50, num_sigma=10, threshold=.2,
     max_sigma = np.asarray(max_sigma, dtype=float)
 
     if log_scale:
-        start = np.log10(min_sigma)
-        stop = np.log10(max_sigma)
-        sigma_list = np.stack([np.logspace(_start, _stop, num_sigma)
-                               for _start, _stop in zip(start, stop)],
-                              axis=1)
-        # The line below may only be used with numpy 1.16 and above
-        # https://github.com/numpy/numpy/commit/58ebb6a7d77cf89afeb888a70aff23e03d213788
-        # sigma_list = np.logspace(start, stop, num_sigma)
+        # for anisotropic data, we use the "highest resolution/variance" axis
+        standard_axis = np.argmax(min_sigma)
+        start = np.log10(min_sigma[standard_axis])
+        stop = np.log10(max_sigma[standard_axis])
+        scale = np.logspace(start, stop, num_sigma)[:, np.newaxis]
+        sigma_list = scale * min_sigma / np.max(min_sigma)
     else:
-        scale = np.linspace(0, 1, num_sigma)[:, None]
+        scale = np.linspace(0, 1, num_sigma)[:, np.newaxis]
         sigma_list = scale * (max_sigma - min_sigma) + min_sigma
 
     # computing gaussian laplace
     # average s**2 provides scale invariance
-    gl_images = [-gaussian_laplace(image, s) * s ** 2
-                 for s in np.mean(sigma_list, axis=1)]
+    gl_images = [-gaussian_laplace(image, s) * np.mean(s) ** 2
+                 for s in sigma_list]
 
     image_cube = np.stack(gl_images, axis=-1)
 
@@ -457,7 +477,9 @@ def blob_log(image, min_sigma=1, max_sigma=50, num_sigma=10, threshold=.2,
     # Remove sigma index and replace with sigmas
     lm = np.hstack([lm[:, :-1], sigmas_of_peaks])
 
-    return _prune_blobs(lm, overlap)
+    sigma_dim = sigmas_of_peaks.shape[1]
+
+    return _prune_blobs(lm, overlap, sigma_dim=sigma_dim)
 
 
 def blob_doh(image, min_sigma=1, max_sigma=30, num_sigma=10, threshold=0.01,


Test patch:
diff --git a/skimage/feature/tests/test_blob.py b/skimage/feature/tests/test_blob.py
--- a/skimage/feature/tests/test_blob.py
+++ b/skimage/feature/tests/test_blob.py
@@ -91,6 +91,11 @@ def test_blob_dog():
     xs, ys = circle(5, 5, 5)
     img[xs, ys] = 255
 
+
+def test_blob_dog_excl_border():
+    img = np.ones((512, 512))
+    xs, ys = circle(5, 5, 5)
+    img[xs, ys] = 255
     blobs = blob_dog(
         img,
         min_sigma=1.5,
@@ -186,6 +191,8 @@ def test_blob_log():
     img_empty = np.zeros((100,100))
     assert blob_log(img_empty).size == 0
 
+
+def test_blob_log_3d():
     # Testing 3D
     r = 6
     pad = 10
@@ -201,6 +208,8 @@ def test_blob_log():
     assert b[2] == r + pad + 1
     assert abs(math.sqrt(3) * b[3] - r) < 1
 
+
+def test_blob_log_3d_anisotropic():
     # Testing 3D anisotropic
     r = 6
     pad = 10
@@ -222,8 +231,8 @@ def test_blob_log():
     assert abs(math.sqrt(3) * b[4] - r) < 1
     assert abs(math.sqrt(3) * b[5] - r) < 1
 
-    # Testing exclude border
 
+def test_blob_log_exclude_border():
     # image where blob is 5 px from borders, radius 5
     img = np.ones((512, 512))
     xs, ys = circle(5, 5, 5)
@@ -294,7 +303,22 @@ def test_blob_doh():
     assert abs(b[1] - 350) <= thresh
     assert abs(radius(b) - 50) <= thresh
 
-    # Testing log scale
+
+def test_blob_doh_log_scale():
+    img = np.ones((512, 512), dtype=np.uint8)
+
+    xs, ys = circle(400, 130, 20)
+    img[xs, ys] = 255
+
+    xs, ys = circle(460, 50, 30)
+    img[xs, ys] = 255
+
+    xs, ys = circle(100, 300, 40)
+    img[xs, ys] = 255
+
+    xs, ys = circle(200, 350, 50)
+    img[xs, ys] = 255
+
     blobs = blob_doh(
         img,
         min_sigma=1,
@@ -303,6 +327,10 @@ def test_blob_doh():
         log_scale=True,
         threshold=.05)
 
+    radius = lambda x: x[2]
+    s = sorted(blobs, key=radius)
+    thresh = 10
+
     b = s[0]
     assert abs(b[0] - 400) <= thresh
     assert abs(b[1] - 130) <= thresh
@@ -323,12 +351,14 @@ def test_blob_doh():
     assert abs(b[1] - 350) <= thresh
     assert abs(radius(b) - 50) <= thresh
 
+
+def test_blob_doh_no_peaks():
     # Testing no peaks
     img_empty = np.zeros((100,100))
     assert blob_doh(img_empty).size == 0
 
 
-def test_blob_overlap():
+def test_blob_doh_overlap():
     img = np.ones((256, 256), dtype=np.uint8)
 
     xs, ys = circle(100, 100, 20)
@@ -342,10 +372,13 @@ def test_blob_overlap():
         min_sigma=1,
         max_sigma=60,
         num_sigma=10,
-        threshold=.05)
+        threshold=.05
+    )
 
     assert len(blobs) == 1
 
+
+def test_blob_log_overlap_3d():
     r1, r2 = 7, 6
     pad1, pad2 = 11, 12
     blob1 = ellipsoid(r1, r1, r1)
@@ -359,6 +392,47 @@ def test_blob_overlap():
     blobs = blob_log(im3,  min_sigma=2, max_sigma=10, overlap=0.1)
     assert len(blobs) == 1
 
+
+def test_blob_overlap_3d_anisotropic():
+    # Two spheres with distance between centers equal to radius
+    # One sphere is much smaller than the other so about half of it is within
+    # the bigger sphere.
+    s3 = math.sqrt(3)
+    overlap = _blob_overlap(np.array([0, 0,  0, 2 / s3, 10 / s3, 10 / s3]),
+                            np.array([0, 0, 10, 0.2 / s3, 1 / s3, 1 / s3]),
+                            sigma_dim=3)
+    assert_almost_equal(overlap, 0.48125)
+    overlap = _blob_overlap(np.array([0, 0, 0, 2 / s3, 10 / s3, 10 / s3]),
+                            np.array([2, 0, 0, 0.2 / s3, 1 / s3, 1 / s3]),
+                            sigma_dim=3)
+    assert_almost_equal(overlap, 0.48125)
+
+
+def test_blob_log_anisotropic():
+    image = np.zeros((50, 50))
+    image[20, 10:20] = 1
+    isotropic_blobs = blob_log(image, min_sigma=0.5, max_sigma=2, num_sigma=3)
+    assert len(isotropic_blobs) > 1  # many small blobs found in line
+    ani_blobs = blob_log(image, min_sigma=[0.5, 5], max_sigma=[2, 20],
+                         num_sigma=3)  # 10x anisotropy, line is 1x10
+    assert len(ani_blobs) == 1  # single anisotropic blob found
+
+
+def test_blob_log_overlap_3d_anisotropic():
+    r1, r2 = 7, 6
+    pad1, pad2 = 11, 12
+    blob1 = ellipsoid(r1, r1, r1)
+    blob1 = util.pad(blob1, pad1, mode='constant')
+    blob2 = ellipsoid(r2, r2, r2)
+    blob2 = util.pad(blob2, [(pad2, pad2), (pad2 - 9, pad2 + 9),
+                             (pad2, pad2)],
+                     mode='constant')
+    im3 = np.logical_or(blob1, blob2)
+
+    blobs = blob_log(im3, min_sigma=[2, 2.01, 2.005],
+                     max_sigma=10, overlap=0.1)
+    assert len(blobs) == 1
+
     # Two circles with distance between centers equal to radius
     overlap = _blob_overlap(np.array([0, 0, 10 / math.sqrt(2)]),
                             np.array([0, 10, 10 / math.sqrt(2)]))