Repository: scikit-image/scikit-image

Issue:
Chan-Vese Segmentation length term energy negative
### Description:

Hi, in the implementation of Chan-Vese segmentation, the length-term is defined as

```
def _cv_edge_length_term(phi, mu):
    """Returns the 'energy' contribution due to the length of the
    edge between regions at each point, multiplied by a factor 'mu'.
    """
    toret = _cv_curvature(phi)
    return mu * toret
```

However, in the original paper, the length related energy should be more like |grad(phi(x, y))| * Delta(Phi(x, y)) dxdy. Here it is only computing the curvature and is sometimes negative. Therefore, the way it currently computes energy

```
    lenenergy = _cv_edge_length_term(phi, mu)
    return np.sum(avgenergy) + np.sum(lenenergy)
```

also seems incorrect? The curvature term is used in the iterative update but for the energy it should be length instead?

### Way to reproduce:

_No response_

### Version information:

_No response_


Discussion:
This implementation is by @GGoussar from https://github.com/scikit-image/scikit-image/pull/1957
Thanks for bringing this to my attention. It has been a long time since I worked on this, so I am a bit out of the loop. I briefly went through the current code, and the "energy" term is only computed as an indication rather than being used to update the result. However, it is concerning that there might be an error.

 I will go through the papers once more, but it might take a bit of time to double check if everything is in order.
 
 Apologies in advance for the delay, and please feel free to send me a reminder if I do not come back with an explanation.
Thanks so much for your effort, @GGoussar. 
I had a look at the code and the papers that I still have access to.
It appears that `_cv_edge_length_term` should indeed be updated such that it returns the absolute value of the current result. As mentioned, `_cv_edge_length_term` is only used to compute the "energy" and not directly involved in the iterative update of the level set itself, and I am using change between iterations as a stopping criterion rather than looking at energy. That code should be fine (assuming the algorithm described by P. Greuter remains correct without the in-place update).

I'll try to create a pull request to fix this in the coming days.

Here are the effects of changing the code on the "energy", when using absolute value rather than the current code.

OLD
![old_energy_evolution](https://user-images.githubusercontent.com/15875608/232284506-560a1b03-23dc-47dd-bb99-21ac0ec33b41.png)

NEW
![new_energy_evolution](https://user-images.githubusercontent.com/15875608/232284513-14448a4f-da19-469b-a509-2a1e2ff57c33.png)


Resolving patch:
diff --git a/skimage/segmentation/_chan_vese.py b/skimage/segmentation/_chan_vese.py
--- a/skimage/segmentation/_chan_vese.py
+++ b/skimage/segmentation/_chan_vese.py
@@ -4,23 +4,23 @@
 from .._shared.utils import _supported_float_type
 
 
-def _cv_curvature(phi):
-    """Returns the 'curvature' of a level set 'phi'.
-    """
-    P = np.pad(phi, 1, mode='edge')
-    fy = (P[2:, 1:-1] - P[:-2, 1:-1]) / 2.0
-    fx = (P[1:-1, 2:] - P[1:-1, :-2]) / 2.0
-    fyy = P[2:, 1:-1] + P[:-2, 1:-1] - 2*phi
-    fxx = P[1:-1, 2:] + P[1:-1, :-2] - 2*phi
-    fxy = .25 * (P[2:, 2:] + P[:-2, :-2] - P[:-2, 2:] - P[2:, :-2])
-    grad2 = fx**2 + fy**2
-    K = ((fxx*fy**2 - 2*fxy*fx*fy + fyy*fx**2) /
-         (grad2*np.sqrt(grad2) + 1e-8))
-    return K
-
-
 def _cv_calculate_variation(image, phi, mu, lambda1, lambda2, dt):
     """Returns the variation of level set 'phi' based on algorithm parameters.
+
+    This corresponds to equation (22) of the paper by Pascal Getreuer,
+    which computes the next iteration of the level set based on a current
+    level set.
+
+    A full explanation regarding all the terms is beyond the scope of the
+    present description, but there is one difference of particular import.
+    In the original algorithm, convergence is accelerated, and required
+    memory is reduced, by using a single array. This array, therefore, is a
+    combination of non-updated and updated values. If this were to be
+    implemented in python, this would require a double loop, where the
+    benefits of having fewer iterations would be outweided by massively
+    increasing the time required to perform each individual iteration. A
+    similar approach is used by Rami Cohen, and it is from there that the
+    C1-4 notation is taken.
     """
     eta = 1e-16
     P = np.pad(phi, 1, mode='edge')
@@ -95,12 +95,26 @@ def _cv_edge_length_term(phi, mu):
     """Returns the 'energy' contribution due to the length of the
     edge between regions at each point, multiplied by a factor 'mu'.
     """
-    toret = _cv_curvature(phi)
-    return mu * toret
+    P = np.pad(phi, 1, mode='edge')
+    fy = (P[2:, 1:-1] - P[:-2, 1:-1]) / 2.0
+    fx = (P[1:-1, 2:] - P[1:-1, :-2]) / 2.0
+    return mu * _cv_delta(phi) * np.sqrt(fx ** 2 + fy ** 2)
 
 
 def _cv_energy(image, phi, mu, lambda1, lambda2):
     """Returns the total 'energy' of the current level set function.
+
+    This corresponds to equation (7) of the paper by Pascal Getreuer,
+    which is the weighted sum of the following:
+    (A) the length of the contour produced by the zero values of the
+    level set,
+    (B) the area of the "foreground" (area of the image where the
+    level set is positive),
+    (C) the variance of the image inside the foreground,
+    (D) the variance of the image outside of the foreground
+
+    Each value is computed for each pixel, and then summed. The weight
+    of (B) is set to 0 in this implementation.
     """
     H = _cv_heavyside(phi)
     avgenergy = _cv_difference_from_average_term(image, H, lambda1, lambda2)


Test patch: