8000 Update rescale_intensity to prevent under/overflow and produce proper output dtype by jni · Pull Request #4585 · scikit-image/scikit-image · GitHub
[go: up one dir, main page]

Skip to content

Update rescale_intensity to prevent under/overflow and produce proper output dtype #4585

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 15 commits into from
Apr 24, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions doc/release/release_dev.rst
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ API Changes
``'reflect'``, which allows meaningful values at the borders for these
filters. To retain the old behavior, pass
``mask=np.ones(image.shape, dtype=bool)`` (#4347)
- When ``out_range`` is a range of numbers and not a dtype in
:func:`skimage.exposure.rescale_intensity`, the output data type will always
be float (#4585)
- The values returned by :func:`skimage.exposure.equalize_adapthist` will be
slightly different from previous versions due to different rounding behavior
(#4585)


Bugfixes
Expand Down
8 changes: 7 additions & 1 deletion skimage/exposure/_adapthist.py
8000
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@ def equalize_adapthist(image, kernel_size=None,
- The image is converted back to RGB space and returned
* For RGBA images, the original alpha channel is removed.

.. versionchanged:: 0.17
The values returned by this function are slightly shifted upwards
because of an internal change in rounding behavior.

References
----------
.. [1] http://tog.acm.org/resources/GraphicsGems/
Expand All @@ -75,7 +79,9 @@ def equalize_adapthist(image, kernel_size=None,
return img_as_float(image) # convert to float for consistency

image = img_as_uint(image)
image = rescale_intensity(image, out_range=(0, NR_OF_GRAY - 1))
image = np.round(
rescale_intensity(image, out_range=(0, NR_OF_GRAY - 1))
).astype(np.uint16)

if kernel_size is None:
kernel_size = (image.shape[0] // 8, image.shape[1] // 8)
Expand Down
73 changes: 66 additions & 7 deletions skimage/exposure/exposure.py
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,50 @@ def intensity_range(image, range_values='image', clip_negative=False):
return i_min, i_max


def _output_dtype(dtype_or_range):
"""Determine the output dtype for rescale_intensity.

The dtype is determined according to the following rules:
- if ``dtype_or_range`` is a dtype, that is the output dtype.
- if ``dtype_or_range`` is a dtype string, that is the dtype used, unless
it is not a NumPy data type (e.g. 'uint12' for 12-bit unsigned integers),
in which case the data type that can contain it will be used
(e.g. uint16 in this case).
- if ``dtype_or_range`` is a pair of values, the output data type will be
float.

Parameters
----------
dtype_or_range : type, string, or 2-tuple of int/float
The desired range for the output, expressed as either a NumPy dtype or
as a (min, max) pair of numbers.

Returns
-------
out_dtype : type
The data type appropriate for the desired output.
"""
if type(dtype_or_range) in [list, tuple, np.ndarray]:
# pair of values: always return float.
return np.float_
if type(dtype_or_range) == type:
# already a type: return it
return dtype_or_range
if dtype_or_range in DTYPE_RANGE:
# string key in DTYPE_RANGE dictionary
try:
# if it's a canonical numpy dtype, convert
return np.dtype(dtype_or_range).type
except TypeError: # uint10, uint12, uint14
# otherwise, return uint16
return np.uint16
else:
raise ValueError(
'Incorrect value for out_range, should be a valid image data '
f'type or a pair of values, got {dtype_or_range}.'
)

Copy link
Member
@emmanuelle emmanuelle Apr 18, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add an else here to raise a TypeError for example in the case of typos (flat instead of float). With the current version, output_dtype will be None if none of the ifs is True, and np.array([1, 2, 3], dtype=None) does not error...

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I understand and support the first part of your suggestion.

But why should output_dtype be None. The way I understand the new behavior is

  • out_range is an actual range -> output dtype is float64
  • out_range is already a dtype (corresponding to the input dtype) -> use same type for output

None would just result in the returned output being of dtype float64 wouldn't it?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@lagru probably what I wrote was not clear, please tell me if it's clearer now. The function should never return None, it should return a valid value or error out.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, then we are in agreement. 👍


def rescale_intensity(image, in_range='image', out_range='dtype'):
"""Return image after stretching or shrinking its intensity levels.

Expand Down Expand Up @@ -297,6 +341,12 @@ def rescale_intensity(image, in_range='image', out_range='dtype'):
Image array after rescaling its intensity. This image is the same dtype
as the input image.

Notes
-----
.. versionchanged:: 0.17
The dtype of the output array has changed to match the output dtype, or
float if the output range is specified by a pair of floats.

See Also
--------
equalize_hist
Expand Down Expand Up @@ -334,17 +384,26 @@ def rescale_intensity(image, in_range='image', out_range='dtype'):
array([0.5, 1. , 1. ])

If you have an image with signed integers but want to rescale the image to
just the positive range, use the `out_range` parameter:
just the positive range, use the `out_range` parameter. In that case, the
output dtype will be float:

>>> image = np.array([-10, 0, 10], dtype=np.int8)
>>> rescale_intensity(image, out_range=(0, 127))
array([ 0, 63, 127], dtype=int8)
array([ 0. , 63.5, 127. ])

To get the desired range with a specific dtype, use ``.astype()``:

>>> rescale_intensity(image, out_range=(0, 127)).astype(np.int8)
array([ 0, 63, 127], dtype=int8)
"""
dtype = image.dtype.type
if out_range in ['dtype', 'image']:
out_dtype = _output_dtype(image.dtype.type)
else:
out_dtype = _output_dtype(out_range)

imin, imax = intensity_range(image, in_range)
omin, omax = intensity_range(image, out_range, clip_negative=(imin >= 0))
imin, imax = map(float, intensity_range(image, in_range))
omin, omax = map(float, intensity_range(image, out_range,
clip_negative=(imin >= 0)))

# Fast test for multiple values, operations with at least 1 NaN return NaN
if np.isnan(imin + imax + omin + omax):
Expand All @@ -358,8 +417,8 @@ def rescale_intensity(image, in_range='image', out_range='dtype'):
image = np.clip(image, imin, imax)

if imin != imax:
image = (image - imin) / float(imax - imin)
return np.asarray(image * (omax - omin) + omin, dtype=dtype)
image = (image - imin) / (imax - imin)
return np.asarray(image * (omax - omin) + omin, dtype=out_dtype)


def _assert_non_negative(image):
Expand Down
46 changes: 43 additions & 3 deletions skimage/exposure/tests/test_exposure.py
Original file line number Diff line number Diff line change
Expand Up @@ -237,10 +237,16 @@ def test_rescale_in_range_clip():


def test_rescale_out_range():
"""Check that output range is correct.

.. versionchanged:: 0.17
This function used to return dtype matching the input dtype. It now
matches the output.
"""
image = np.array([-10, 0, 10], dtype=np.int8)
out = exposure.rescale_intensity(image, out_range=(0, 127))
assert out.dtype == np.int8
assert_array_almost_equal(out, [0, 63, 127])
assert out.dtype == np.float_
assert_array_almost_equal(out, [0, 63.5, 127])


def test_rescale_named_in_range():
Expand Down Expand Up @@ -312,6 +318,40 @@ def test_rescale_nan_warning(in_range, out_range):
exposure.rescale_intensity(image, in_range, out_range)


@pytest.mark.parametrize(
"out_range, out_dtype", [
('uint8', np.uint8),
('uint10', np.uint16),
('uint12', np.uint16),
('uint16', np.uint16),
('float', np.float_),
]
)
def test_rescale_output_dtype(out_range, out_dtype):
image = np.array([-128, 0, 127], dtype=np.int8)
output_image = exposure.rescale_intensity(image, out_range=out_range)
assert output_image.dtype == out_dtype


def test_rescale_no_overflow():
image = np.array([-128, 0, 127], dtype=np.int8)
output_image = exposure.rescale_intensity(image, out_range=np.uint8)
testing.assert_array_equal(output_image, [0, 128, 255])
assert output_image.dtype == np.uint8


def test_rescale_float_output():
image = np.array([-128, 0, 127], dtype=np.int8)
output_image = exposure.rescale_intensity(image, out_range=(0, 255))
testing.assert_array_equal(output_image, [0, 128, 255])
assert output_image.dtype == np.float_


def test_rescale_raises_on_incorrect_out_range():
image = np.array([-128, 0, 127], dtype=np.int8)
with testing.raises(ValueError):
_ = exposure.rescale_intensity(image, out_range='flat')

# Test adaptive histogram equalization
# ====================================

Expand All @@ -324,7 +364,7 @@ def test_adapthist_grayscale():
adapted = exposure.equalize_adapthist(img, kernel_size=(57, 51),
clip_limit=0.01, nbins=128)
assert img.shape == adapted.shape
assert_almost_equal(peak_snr(img, adapted), 102.078, 3)
assert_almost_equal(peak_snr(img, adapted), 102.019, 3)
assert_almost_equal(norm_brightness_err(img, adapted), 0.0529, 3)


Expand Down
0