MAINT: make ellipse fitting forward compatible #8054
MAINT: make ellipse fitting forward compatible #8054stefanv merged 3 commits intoscikit-image:mainfrom
Conversation
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (1)
📝 WalkthroughWalkthroughAdds a validation in Changes
Estimated code review effort🎯 2 (Simple) | ⏱️ ~8 minutes 🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
| # https://github.com/scikit-image/scikit-image/issues/7013 | ||
| if np.any(eig_vals.imag != 0) or np.any(eig_vecs.imag != 0): | ||
| raise TypeError( | ||
| "Non-real eigvalues/eigvec are not currently supported. Please " | ||
| "report the reproducer to " | ||
| "https://github.com/scikit-image/scikit-image/issues/7013" | ||
| ) | ||
| eig_vals = eig_vals.real | ||
| eig_vecs = eig_vecs.real |
There was a problem hiding this comment.
raise TypeError breaks the established error-handling contract of _estimate.
Every other failure path in this method (lines 859, 874, 901, 924) returns via _warn_or_msg(…, warn_only), which lets from_estimate wrap it in a FailedEstimation. Raising TypeError here will instead propagate as an unhandled exception, crashing RANSAC loops and any other caller that expects failed estimations to be signaled gracefully.
Additionally, the exact != 0 check on imaginary parts is fragile. Floating-point arithmetic on M can introduce tiny imaginary components (e.g., O(1e-16)) that are numerical noise, not a genuine problem. This would crash the caller on data that could otherwise be fitted successfully—which contradicts the forward-compatibility goal.
Consider using a tolerance-based check and returning through _warn_or_msg instead:
Proposed fix
# https://github.com/scikit-image/scikit-image/issues/7013
- if np.any(eig_vals.imag != 0) or np.any(eig_vecs.imag != 0):
- raise TypeError(
- "Non-real eigvalues/eigvec are not currently supported. Please "
- "report the reproducer to "
- "https://github.com/scikit-image/scikit-image/issues/7013"
+ if not np.allclose(eig_vals.imag, 0) or not np.allclose(eig_vecs.imag, 0):
+ return _warn_or_msg(
+ "Non-real eigenvalues/eigenvectors are not currently supported. "
+ "Please report the reproducer to "
+ "https://github.com/scikit-image/scikit-image/issues/7013",
+ warn_only=warn_only,
)
eig_vals = eig_vals.real
eig_vecs = eig_vecs.real🧰 Tools
🪛 Ruff (0.15.0)
[warning] 909-913: Avoid specifying long messages outside the exception class
(TRY003)
There was a problem hiding this comment.
This bot comment is incorrect. Emitting TypeError is strictly backwards compatible because of gh-7013 and
In [1]: import numpy as np
In [2]: np.complex128(3) % np.pi
---------------------------------------------------------------------------
TypeError Traceback (most recent call last)
Cell In[2], line 1
----> 1 np.complex128(3) % np.pi
TypeError: unsupported operand type(s) for %: 'numpy.complex128' and 'float'
EDIT: As much as I normally am against equality comparison in floating point, and how much I agree that in general it's vastly superior to use a tolerance etc etc, w.imag == 0 is what numpy did: https://github.com/numpy/numpy/blob/main/numpy/linalg/_linalg.py#L1503
There was a problem hiding this comment.
I think it's fine to check for 0 here. I think it's right that we don't expect to see that error type (never used to before the new NumPy nightly), but once you run into complex numbers here you're in trouble and we should abort anyway.
There was a problem hiding this comment.
I modified the code a bit to use np.isreal and np.all.
| # https://github.com/scikit-image/scikit-image/issues/7013 | ||
| if np.any(eig_vals.imag != 0) or np.any(eig_vecs.imag != 0): | ||
| raise TypeError( | ||
| "Non-real eigvalues/eigvec are not currently supported. Please " | ||
| "report the reproducer to " | ||
| "https://github.com/scikit-image/scikit-image/issues/7013" | ||
| ) | ||
| eig_vals = eig_vals.real | ||
| eig_vecs = eig_vecs.real |
There was a problem hiding this comment.
I think it's fine to check for 0 here. I think it's right that we don't expect to see that error type (never used to before the new NumPy nightly), but once you run into complex numbers here you're in trouble and we should abort anyway.
| # https://github.com/scikit-image/scikit-image/issues/7013 | ||
| if np.any(eig_vals.imag != 0) or np.any(eig_vecs.imag != 0): | ||
| raise TypeError( | ||
| "Non-real eigvalues/eigvec are not currently supported. Please " | ||
| "report the reproducer to " | ||
| "https://github.com/scikit-image/scikit-image/issues/7013" | ||
| ) | ||
| eig_vals = eig_vals.real | ||
| eig_vecs = eig_vecs.real |
There was a problem hiding this comment.
I modified the code a bit to use np.isreal and np.all.
There was a problem hiding this comment.
🧹 Nitpick comments (1)
src/skimage/measure/fit.py (1)
907-911: The forward-compatibility fix looks correct.The approach using
np.isreal()and extracting.realparts handles both the current NumPy behavior (sometimes real, sometimes complex) and the planned future behavior (always complex with zero imaginary parts for real results). Per the maintainer's clarification, raising an exception here is backwards compatible since complex values would have caused aTypeErroratphi %= np.pianyway.One minor observation: the PR objectives mention raising an error that asks users to report reproducers to issue
#7013, but the current error message is quite terse. Consider adding the issue link for better user guidance:if not (np.all(np.isreal(eig_vals)) and np.all(np.isreal(eig_vecs))): - raise ValueError("Expected real eigenvalues and -vectors") + raise ValueError( + "Expected real eigenvalues and -vectors. Please report a " + "reproducer to https://github.com/scikit-image/scikit-image/issues/7013" + )This would help users encountering edge-case data that produces non-real eigenvalues to contribute reproducers for further investigation.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 13c65e6f-d466-4249-a0fb-e659d072bd7d
📒 Files selected for processing (1)
src/skimage/measure/fit.py
* origin/main: (27 commits) Move `_shared` to `_skimage2` (#8102) Port `pad_footprint` & `mirror_footprint` to `skimage2` (#8094) Add rescaling API to skimage2 (#8075) Move `skimage2` implementation into `_skimage2` (#8093) Undo (double) mirroring in `ski2.morphology.dilation` (#8060) Grammar (#8091) Move `ensure_spacing` into `skimage2` (#8067) Ensure that `skimage2` does not eagerly import `skimage` (#8087) Use lazy-loader 0.5 (#8080) MAINT: make ellipse fitting forward compatible (#8054) Avoid deprecated assign to ndarray.shape (#8020) Avoid circular import in `feature/corner.py` (#8077) Turn off coderabbit auto-labeling & label checks (#8070) Fix GIL being re-enabled by C++ extensions (#8059) Fix conventions following docstub and misc. (#8055) Port grayscale morphology operators to skimage2 (#8046) Add revised `peak_local_max` to `skimage2` (#8039) Allow read-only arrays as input to remap (#7535) Add `prescale` parameter to "blob functions" (#7858) Try to make coderabbit respect exisiting type labels (#8042) ...
Description
NumPy plans to change it's
linalg.eigroutine to always return complex results, instead of the current complex or real if eigenvalues happen to be on the real axis, numpy/numpy#30411The only effect on scikit-image AFAICS, is in the ellipse estimation routines, which require that eigenvalues are real. In fact, the story seems to be somewhat convoluted:
EllipseModel.fit, Estimating Ellipse Sometimes fails due to np.linalg.eig Casting to complex #7013phi %= np.pi, wherephiis constructed from eigenvalues/eigenvectorseig/eigvalsalways return complex eigenvalues numpy/numpy#29000 (comment) has some discussion of potential fixes/enhancementsThis PR makes a minimal fix to make
EllipseModel.fitboth backwards and forwards compatible: take the real part of eigenvalues/eigenvectors explicitly, and raise a TypeError if either of them has non-zero imaginary parts.This is backwards compatible because
.real.realis a no-opIt is forwards compatible for the numpy change: the output of
eigwill always be complex with zero imaginary parts, and the fix here takes the real part.Checklist
./doc/examplesfor new featuresRelease note
This is not user-visible.