From a7ee2cab423ab49d31e9d00e48ce2cae645c9972 Mon Sep 17 00:00:00 2001 From: Peter Corke Date: Fri, 14 Aug 2026 15:40:57 +1000 Subject: [PATCH] fix: to()/array_as() same dtype-alias bug as the constructor; astype() double-cast Same root cause as the constructor/convert() fix this branch is based on: Image.to()/.array_as() called plain np.dtype(dtype) with no DTYPE_ALIASES lookup, so dtype='float'/'int' resolved to NumPy's own float64/platform-int rather than the Toolbox's float32/uint8 convention. 'double' and 'half' never showed this bug -- NumPy's own np.dtype('double')/('half') already equal float64/float16, coincidentally matching DTYPE_ALIASES for those two. to() also did its own separate premature dtype = np.dtype(dtype) before calling array_as(dtype) -- simplified to just pass dtype through unchanged now that array_as() and the Image constructor both resolve it themselves; no need to resolve it three times. astype() had no test-visible bug (its final output was already correct, self-corrected by the constructor's now-fixed dtype= handling in self.__class__(self._A.astype(dtype), dtype=dtype)) but did an unnecessary intermediate cast to NumPy's raw (wrong) interpretation of the alias before the constructor cast it again to the right one -- e.g. astype('float') cast uint8->float64->float32 instead of directly uint8->float32. Resolved the alias before the first cast to avoid the redundant round-trip. Extended tests/test_dtype_resolution.py's shared parametrized matrix with test methods for these three entry points, rather than new one-off tests -- confirms to()/array_as() fail against pre-fix code for the 'int'/'float' cases specifically (not 'double'/'half', matching the analysis above), and that astype() has no output-level regression to catch. Co-Authored-By: Claude Sonnet 5 --- src/machinevisiontoolbox/ImageCore.py | 19 ++++++++++++++++--- tests/test_dtype_resolution.py | 16 ++++++++++++++-- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/src/machinevisiontoolbox/ImageCore.py b/src/machinevisiontoolbox/ImageCore.py index 7d920df4..da9764f8 100644 --- a/src/machinevisiontoolbox/ImageCore.py +++ b/src/machinevisiontoolbox/ImageCore.py @@ -1966,6 +1966,9 @@ def to(self, dtype: Dtype) -> "Image": :param dtype: Numpy data type :type dtype: str or NumPy dtype + + |dtype_aliases| + :return: image :rtype: :class:`Image` @@ -1988,8 +1991,9 @@ def to(self, dtype: Dtype) -> "Image": :seealso: :meth:`astype` :meth:`to_int` :meth:`to_float` """ # convert image to different type, does rescaling - # as just changes type - dtype = np.dtype(dtype) # convert to dtype if it's a string + # as just changes type. array_as() and the constructor both + # resolve dtype (including the short-name aliases) themselves, + # so just pass it through unchanged rather than resolving twice. return self.__class__(self.array_as(dtype), dtype=dtype) def astype(self, dtype: Dtype) -> "Image": @@ -1998,6 +2002,9 @@ def astype(self, dtype: Dtype) -> "Image": :param dtype: Numpy data type :type dtype: str or NumPy dtype + + |dtype_aliases| + :return: image :rtype: :class:`Image` @@ -2018,6 +2025,8 @@ def astype(self, dtype: Dtype) -> "Image": :seealso: :meth:`to` """ + if isinstance(dtype, str): + dtype = DTYPE_ALIASES.get(dtype, dtype) return self.__class__(self._A.astype(dtype), dtype=dtype) # ---- NumPy array access ---- # @@ -2293,7 +2302,9 @@ def array_as(self, dtype: Dtype | None = None) -> np.ndarray: Convert Image to NumPy array of specified type :param dtype: data type of the output array - :type dtype: str or np.dtype + :type dtype: str or np.dtype, optional + + |dtype_aliases| :return: NumPy array with specified type :rtype: ndarray(H,W) or ndarray(H,W,P) @@ -2335,6 +2346,8 @@ def array_as(self, dtype: Dtype | None = None) -> np.ndarray: if dtype is None: return self._A else: + if isinstance(dtype, str): + dtype = DTYPE_ALIASES.get(dtype, dtype) dtype = np.dtype(dtype) # convert to dtype if it's a string if np.issubdtype(dtype, np.integer) or np.issubdtype(dtype, np.bool_): return int_image(self._A, dtype) diff --git a/tests/test_dtype_resolution.py b/tests/test_dtype_resolution.py index 27728076..41497ad2 100644 --- a/tests/test_dtype_resolution.py +++ b/tests/test_dtype_resolution.py @@ -2,8 +2,8 @@ Consolidated dtype-resolution consistency tests. Multiple entry points resolve a user-supplied dtype string into an actual -NumPy dtype: the Image constructor (via _infer_dtype), convert(), and (once -fixed on their own branches) Image.to()/.array_as()/.astype() and the +NumPy dtype: the Image constructor (via _infer_dtype), convert(), Image.to(), +Image.array_as(), Image.astype(), and (once fixed on its own branch) the ImageConstantsMixin factory methods (Zeros, Constant, Random, ...). All of them are supposed to honour the same short-name aliases (DTYPE_ALIASES: 'int'->uint8, 'float'->float32, 'double'->float64, 'half'->float16) plus @@ -56,6 +56,18 @@ def test_convert(self, dtype_in, expected): arr = convert(np.ones((2, 3), dtype=np.uint8), dtype=dtype_in) assert arr.dtype == expected + def test_image_to(self, dtype_in, expected): + im = Image(np.ones((2, 3), dtype=np.uint8)) + assert im.to(dtype_in).dtype == expected + + def test_image_astype(self, dtype_in, expected): + im = Image(np.ones((2, 3), dtype=np.uint8)) + assert im.astype(dtype_in).dtype == expected + + def test_image_array_as(self, dtype_in, expected): + im = Image(np.ones((2, 3), dtype=np.uint8)) + assert im.array_as(dtype_in).dtype == expected + # ImageConstantsMixin factory methods: a *different* bug from the above -- # they build their raw pixel array with the (now alias-resolved)