Skip to content

Commit ee58434

Browse files
authored
Merge pull request #84 from petercorke/feat/array-protocol-kernel-image
2 parents 9a46bfc + 74e229c commit ee58434

4 files changed

Lines changed: 128 additions & 0 deletions

File tree

‎src/machinevisiontoolbox/ImageCore.py‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -475,6 +475,37 @@ def __iter__(self) -> Iterator["Image"]:
475475
"""Iterate over image planes."""
476476
return self.planes()
477477

478+
def __array__(self, dtype: Dtype | None = None, copy: bool | None = None) -> np.ndarray:
479+
"""
480+
Convert to a plain NumPy array
481+
482+
:param dtype: dtype of the returned array, defaults to the image's own dtype
483+
:type dtype: numpy dtype, optional
484+
:param copy: force a copy of the underlying data, defaults to None
485+
:type copy: bool, optional
486+
:return: image pixel data
487+
:rtype: ndarray(H,W) or ndarray(H,W,3)
488+
489+
Implements the NumPy array protocol, used by ``np.asarray(img)`` and by
490+
any NumPy/SciPy/Matplotlib function that isn't ufunc- or array-function-
491+
aware (those instead go through :meth:`__array_ufunc__` /
492+
:meth:`__array_function__`) and calls ``np.asarray()`` internally on
493+
whatever it's given. Without this, such a call would silently coerce an
494+
:class:`Image` into a useless 0-d object array instead of its pixel data.
495+
496+
Returns the same read-only view as the :attr:`array` property unless a
497+
dtype conversion or an explicit copy is requested, either of which
498+
produces a new, independent (writeable) array.
499+
500+
:seealso: :attr:`array`
501+
"""
502+
arr = self.array
503+
if dtype is not None:
504+
arr = arr.astype(dtype)
505+
if copy:
506+
arr = arr.copy()
507+
return arr
508+
478509
def __array_ufunc__(self, ufunc, method: str, *inputs, **kwargs):
479510
"""
480511
Support NumPy ufunc calls on image data.

‎src/machinevisiontoolbox/Kernel.py‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,35 @@ def T(self) -> "Kernel":
115115
def shape(self) -> tuple[int, int]:
116116
return self.K.shape
117117

118+
@property
119+
def ndim(self) -> int:
120+
return 2
121+
122+
def __array__(self, dtype: Dtype | None = None, copy: bool | None = None) -> np.ndarray:
123+
"""
124+
Convert to a plain NumPy array
125+
126+
:param dtype: dtype of the returned array, defaults to the kernel's own dtype
127+
:type dtype: numpy dtype, optional
128+
:param copy: force a copy of the underlying data, defaults to None
129+
:type copy: bool, optional
130+
:return: kernel weighting matrix
131+
:rtype: ndarray(N,M)
132+
133+
Implements the NumPy array protocol, used by ``np.asarray(K)`` and by
134+
any NumPy/SciPy/Matplotlib function that calls ``np.asarray()``
135+
internally on whatever it's given (e.g. ``scipy.signal.convolve2d``,
136+
``Axes3D.plot_surface``, ``np.linalg.svd``). Without this, such a call
137+
would silently coerce a :class:`Kernel` into a useless 0-d object
138+
array instead of its weighting matrix.
139+
"""
140+
arr = self.K
141+
if dtype is not None:
142+
arr = arr.astype(dtype)
143+
if copy:
144+
arr = arr.copy()
145+
return arr
146+
118147
def print(
119148
self, fmt: str | None = None, separator: str = " ", precision: int = 2
120149
) -> None:

‎tests/test_image_core.py‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,51 @@ def test_array_ufunc_out_rejected(self):
6767
):
6868
np.ceil(im, out=im)
6969

70+
def test_array_dunder_asarray(self):
71+
"""Regression test: np.asarray(img) used to silently coerce to a
72+
useless 0-d object array (no __array__ implemented) instead of
73+
exposing the pixel data -- same bug class found in Kernel's
74+
missing __array__."""
75+
im = Image([[1, 2], [3, 4]], dtype="uint8")
76+
77+
arr = np.asarray(im)
78+
79+
self.assertEqual(arr.shape, im.array.shape)
80+
nt.assert_array_equal(arr, im.array)
81+
82+
def test_array_dunder_scipy_interop(self):
83+
"""Regression test: functions that aren't ufunc-/array-function-
84+
aware (e.g. scipy.signal.convolve2d) call np.asarray() internally
85+
and used to receive a useless 0-d object array."""
86+
from scipy.signal import convolve2d
87+
88+
im = Image.Random(size=(10, 10), dtype="float64")
89+
90+
out = convolve2d(im, np.ones((3, 3)))
91+
92+
self.assertEqual(out.ndim, 2)
93+
94+
def test_array_dunder_dtype_conversion(self):
95+
im = Image([[1, 2], [3, 4]], dtype="uint8")
96+
97+
arr = np.asarray(im, dtype="float32")
98+
99+
self.assertEqual(arr.dtype, np.float32)
100+
101+
def test_array_dunder_returns_readonly_view_by_default(self):
102+
im = Image([[1, 2], [3, 4]], dtype="uint8")
103+
104+
arr = np.asarray(im)
105+
106+
self.assertFalse(arr.flags.writeable)
107+
108+
def test_array_dunder_copy_true_returns_writeable(self):
109+
im = Image([[1, 2], [3, 4]], dtype="uint8")
110+
111+
arr = np.array(im, copy=True)
112+
113+
self.assertTrue(arr.flags.writeable)
114+
70115
def test_pixel(self):
71116
im = Image(np.arange(80).reshape((10, 8)), dtype="int64") # 8x10 image
72117
pix = im.pixel(5, 6)

‎tests/test_image_spatial.py‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,29 @@ def test_kernel_shape_property(self):
174174
K = self.Kernel.Gauss(sigma=2, h=3)
175175
self.assertEqual(K.shape, (7, 7))
176176

177+
def test_kernel_ndim_property(self):
178+
K = self.Kernel.Gauss(sigma=2, h=3)
179+
self.assertEqual(K.ndim, 2)
180+
181+
def test_kernel_array_protocol(self):
182+
"""Regression test: Kernel objects used to silently coerce to a
183+
useless 0-d object array under np.asarray()/SciPy/Matplotlib calls
184+
that aren't ufunc-aware, instead of exposing the real weight
185+
matrix -- e.g. scipy.signal.convolve2d raised "inputs must both be
186+
2-D arrays" and np.linalg.svd raised "0-dimensional array given"."""
187+
K = self.Kernel.Box(2, normalize=False)
188+
arr = np.asarray(K)
189+
self.assertEqual(arr.shape, K.shape)
190+
nt.assert_array_equal(arr, K.K)
191+
192+
U, s, Vh = np.linalg.svd(K, full_matrices=True)
193+
self.assertEqual(U.shape[0], K.shape[0])
194+
195+
from scipy.signal import convolve2d
196+
197+
out = convolve2d(K, self.Kernel.Box(1, normalize=False))
198+
self.assertEqual(out.ndim, 2)
199+
177200
def test_kernel_box(self):
178201
K = self.Kernel.Box(2)
179202
self.assertEqual(K.shape, (5, 5))

0 commit comments

Comments
 (0)