From e762f578faa269655cbd06b02d48c4c30d48c516 Mon Sep 17 00:00:00 2001 From: Peter Corke Date: Fri, 14 Aug 2026 14:10:36 +1000 Subject: [PATCH] feat: add __array__ protocol to Image and Kernel, ndim to Kernel Neither Image nor Kernel implemented the plain NumPy __array__ protocol. Image already implements __array_ufunc__/__array_function__, which cover ufuncs and the specific np.* functions it opts into, but anything outside that -- SciPy functions, Matplotlib, or any code that calls np.asarray() internally rather than going through those protocols -- falls through to NumPy's default handling. Without __array__, that default silently boxes the whole object into a useless 0-d object-dtype array instead of exposing the real pixel/ kernel data: np.asarray(kernel) # -> 0-d object array np.linalg.matrix_rank(kernel) # -> silently wrong scipy.signal.convolve2d(image, kernel) # -> ValueError Root-caused via RVC3-python's chap11.ipynb, which hands a Kernel directly to scipy.signal.convolve2d(), np.linalg.svd(), and matplotlib's plot_surface() -- all broke the same way. idisp()/ plot_surface() additionally access .ndim directly (duck-typing, no array coercion involved), so Kernel also gets a plain ndim property (kernels are always 2D by the class's own constructor invariant). Image's __array__ mirrors the existing .array property (read-only view by default; dtype conversion or an explicit copy=True produce a new, independent writeable array). This is purely additive -- ufunc and array-function dispatch (already implemented) are tried first and are unaffected; __array__ only kicks in as NumPy's fallback. Added regression tests for both classes covering np.asarray(), SciPy interop (convolve2d), dtype conversion, and the read-only-view default -- all verified to fail against the pre-fix code with the original error shapes. Co-Authored-By: Claude Sonnet 5 --- src/machinevisiontoolbox/ImageCore.py | 31 ++++++++++++++++++ src/machinevisiontoolbox/Kernel.py | 29 +++++++++++++++++ tests/test_image_core.py | 45 +++++++++++++++++++++++++++ tests/test_image_spatial.py | 23 ++++++++++++++ 4 files changed, 128 insertions(+) diff --git a/src/machinevisiontoolbox/ImageCore.py b/src/machinevisiontoolbox/ImageCore.py index 7658c89a..21dbec5c 100644 --- a/src/machinevisiontoolbox/ImageCore.py +++ b/src/machinevisiontoolbox/ImageCore.py @@ -475,6 +475,37 @@ def __iter__(self) -> Iterator["Image"]: """Iterate over image planes.""" return self.planes() + def __array__(self, dtype: Dtype | None = None, copy: bool | None = None) -> np.ndarray: + """ + Convert to a plain NumPy array + + :param dtype: dtype of the returned array, defaults to the image's own dtype + :type dtype: numpy dtype, optional + :param copy: force a copy of the underlying data, defaults to None + :type copy: bool, optional + :return: image pixel data + :rtype: ndarray(H,W) or ndarray(H,W,3) + + Implements the NumPy array protocol, used by ``np.asarray(img)`` and by + any NumPy/SciPy/Matplotlib function that isn't ufunc- or array-function- + aware (those instead go through :meth:`__array_ufunc__` / + :meth:`__array_function__`) and calls ``np.asarray()`` internally on + whatever it's given. Without this, such a call would silently coerce an + :class:`Image` into a useless 0-d object array instead of its pixel data. + + Returns the same read-only view as the :attr:`array` property unless a + dtype conversion or an explicit copy is requested, either of which + produces a new, independent (writeable) array. + + :seealso: :attr:`array` + """ + arr = self.array + if dtype is not None: + arr = arr.astype(dtype) + if copy: + arr = arr.copy() + return arr + def __array_ufunc__(self, ufunc, method: str, *inputs, **kwargs): """ Support NumPy ufunc calls on image data. diff --git a/src/machinevisiontoolbox/Kernel.py b/src/machinevisiontoolbox/Kernel.py index 2d1888a4..82a5cd71 100644 --- a/src/machinevisiontoolbox/Kernel.py +++ b/src/machinevisiontoolbox/Kernel.py @@ -115,6 +115,35 @@ def T(self) -> "Kernel": def shape(self) -> tuple[int, int]: return self.K.shape + @property + def ndim(self) -> int: + return 2 + + def __array__(self, dtype: Dtype | None = None, copy: bool | None = None) -> np.ndarray: + """ + Convert to a plain NumPy array + + :param dtype: dtype of the returned array, defaults to the kernel's own dtype + :type dtype: numpy dtype, optional + :param copy: force a copy of the underlying data, defaults to None + :type copy: bool, optional + :return: kernel weighting matrix + :rtype: ndarray(N,M) + + Implements the NumPy array protocol, used by ``np.asarray(K)`` and by + any NumPy/SciPy/Matplotlib function that calls ``np.asarray()`` + internally on whatever it's given (e.g. ``scipy.signal.convolve2d``, + ``Axes3D.plot_surface``, ``np.linalg.svd``). Without this, such a call + would silently coerce a :class:`Kernel` into a useless 0-d object + array instead of its weighting matrix. + """ + arr = self.K + if dtype is not None: + arr = arr.astype(dtype) + if copy: + arr = arr.copy() + return arr + def print( self, fmt: str | None = None, separator: str = " ", precision: int = 2 ) -> None: diff --git a/tests/test_image_core.py b/tests/test_image_core.py index 880628f4..944b1c5e 100644 --- a/tests/test_image_core.py +++ b/tests/test_image_core.py @@ -67,6 +67,51 @@ def test_array_ufunc_out_rejected(self): ): np.ceil(im, out=im) + def test_array_dunder_asarray(self): + """Regression test: np.asarray(img) used to silently coerce to a + useless 0-d object array (no __array__ implemented) instead of + exposing the pixel data -- same bug class found in Kernel's + missing __array__.""" + im = Image([[1, 2], [3, 4]], dtype="uint8") + + arr = np.asarray(im) + + self.assertEqual(arr.shape, im.array.shape) + nt.assert_array_equal(arr, im.array) + + def test_array_dunder_scipy_interop(self): + """Regression test: functions that aren't ufunc-/array-function- + aware (e.g. scipy.signal.convolve2d) call np.asarray() internally + and used to receive a useless 0-d object array.""" + from scipy.signal import convolve2d + + im = Image.Random(size=(10, 10), dtype="float64") + + out = convolve2d(im, np.ones((3, 3))) + + self.assertEqual(out.ndim, 2) + + def test_array_dunder_dtype_conversion(self): + im = Image([[1, 2], [3, 4]], dtype="uint8") + + arr = np.asarray(im, dtype="float32") + + self.assertEqual(arr.dtype, np.float32) + + def test_array_dunder_returns_readonly_view_by_default(self): + im = Image([[1, 2], [3, 4]], dtype="uint8") + + arr = np.asarray(im) + + self.assertFalse(arr.flags.writeable) + + def test_array_dunder_copy_true_returns_writeable(self): + im = Image([[1, 2], [3, 4]], dtype="uint8") + + arr = np.array(im, copy=True) + + self.assertTrue(arr.flags.writeable) + def test_pixel(self): im = Image(np.arange(80).reshape((10, 8)), dtype="int64") # 8x10 image pix = im.pixel(5, 6) diff --git a/tests/test_image_spatial.py b/tests/test_image_spatial.py index 80e00b7b..b13b234f 100644 --- a/tests/test_image_spatial.py +++ b/tests/test_image_spatial.py @@ -174,6 +174,29 @@ def test_kernel_shape_property(self): K = self.Kernel.Gauss(sigma=2, h=3) self.assertEqual(K.shape, (7, 7)) + def test_kernel_ndim_property(self): + K = self.Kernel.Gauss(sigma=2, h=3) + self.assertEqual(K.ndim, 2) + + def test_kernel_array_protocol(self): + """Regression test: Kernel objects used to silently coerce to a + useless 0-d object array under np.asarray()/SciPy/Matplotlib calls + that aren't ufunc-aware, instead of exposing the real weight + matrix -- e.g. scipy.signal.convolve2d raised "inputs must both be + 2-D arrays" and np.linalg.svd raised "0-dimensional array given".""" + K = self.Kernel.Box(2, normalize=False) + arr = np.asarray(K) + self.assertEqual(arr.shape, K.shape) + nt.assert_array_equal(arr, K.K) + + U, s, Vh = np.linalg.svd(K, full_matrices=True) + self.assertEqual(U.shape[0], K.shape[0]) + + from scipy.signal import convolve2d + + out = convolve2d(K, self.Kernel.Box(1, normalize=False)) + self.assertEqual(out.ndim, 2) + def test_kernel_box(self): K = self.Kernel.Box(2) self.assertEqual(K.shape, (5, 5))