Repository: scikit-image/scikit-image

Issue:
`imsave` handles bool images incorrectly
```
import numpy as np
from skimage import io

image = np.random.random((100, 100)) > 0.5

io.imsave('/tmp/foo.jpg', image, plugin='pil')
```

results in a black image



Discussion:
It appears the original values are being maintained while the dtype is converted to uint8, resulting in an almost-black-on-true-black image if one views without an automatic window. Or,almost maintained; since JPEG is lossy there are a few inaccuracies in the roundtrip. With a PNG file the roundtrip is exact.

What to do here is fraught with peril, as a user might want and expect saved values of 1 through a roundtrip, not 255.

There is no "low-contrast image" warning raised. That would be the lightest fix.

The behavior is broken from a user perspective, though, since they pass in a valid image, and get an image on disk that displays differently.  Even when loading back in, the data type is wrong.

It's not ideal, but since (afaik) PNG and JPEG don't support Boolean format this issue may be beyond our ability to address.

When I ran your example with JPEG (or other lossy formats) I got a few scattered values that were **2**, not just 0 and 1, so "convert 1-bit contrast image to bool on load" is perilous. Even disregarding this, scientists may have ultra-low contrast images after background subtraction that need to remain ultra-low contrast.

Emitting a always-warning when saving a boolean array stating the data will be upconverted to uint8 in the file, or adding a kwarg to control the specific case of boolean arrays are the only good options I can see. I'd favor the former.

A bool array should be converted to uint8 before saved to a format that only supports uint8, no?  We do the same for float images.

@stefanv isn't this the observed behaviour? 

``` python
In [1]: from skimage import io

In [2]: import numpy as np

In [3]: im = np.random.rand(512, 512) > 0.5

In [4]: im.dtype
Out[4]: dtype('bool')

In [5]: io.imsave('Desktop/im.png', im)

In [6]: imload = io.imread('Desktop/im.png')

In [7]: imload.dtype
Out[7]: dtype('uint8')

In [8]: imload.max()
Out[8]: 1
```

As @JDWarner notes, I don't think we can do anything sensible with jpg, but with png, the observed behaviour is the most correct, I think.

Ah, you're probably thinking of using `img_as_ubyte`, which results in values in {0, 255}. Maybe. @JDWarner what do you think about this?

I guess since we have `img_as_bool` I presumed it is a fully supported image type.  But I notice that the docs does not state that.  What is our position?

The definition of "fully supported" isn't locked down, which is probably the main issue.

I'd say we fully support boolean images as Python objects. However, because the common image formats don't support this dtype, we're left with compromises any time they have to touch the disk. If "fully supported" means we must guarantee data integrity _and_ appearance in any viewer of any file written, that's a high bar (and likely impossible for any dtype not explicitly supported by all filetypes).

Automatically bouncing such images to full-range [0, 255] on save makes me uncomfortable. We'd be changing users' data in the name of looking better in some viewers. Meanwhile, users may well consider boolean images analogous to labeled images since `img.astype(bool) == 1` resolves, and there could be (a lot of) code out there which assumes `img = io.imread('./path/to/bool.png') == 1` is going to preserve their data "values" like it always has. We're used to playing fast and loose with types in Python for better or worse and this workflow, well, works.

We'd be OK if and only if we knew the saved file was intended to be boolean at load time. Then we could undo the change. Short of embedding a custom EXIF tag or metadata file alongside everything, though - both bad options - we cannot know this.

I noticed this behavior a long time ago, back in around version 0.9, but thought it was working as intended. My assumption was that this was a deliberate design decision along the lines of what Cython does with `bint` - use the next largest supported dtype where necessary. With any viewer supporting an automatic window, the saved images appear just fine. Is it really a bug?

This often bites me when I'm trying to save binary masks.  I guess
having a warning is the best we can do.

@stefanv Warning would be good. I run into similar situation when saving a multiple label mask image in uint8 last month. ImageJ shows "total" black. I spent quite some time to debug until realizing it's not the code. 

> It's not ideal, but since (afaik) PNG and JPEG don't support Boolean format this issue may be beyond our ability to address.

FYI: PNG supports a bit depth of 1, which could be used when saving a boolean image.  But then what data type and values would you expect to get when reading such an image?  I recently had to deal with this issue in scipy (https://github.com/scipy/scipy/pull/5289).  When reading a 1-bit PNG file, `scipy.misc.imread` now returns an array with dtype `np.uint8` containing the values 0 and 255.  (That's what `skimage.io.imread` does, too--at least with the 'pil' plugin.)  This behavior is consistent with how it handles 2-bit and 4-bit files, where the values are scaled up to the 8-bit range.  For example, when reading a 2-bit file, the array that is returned contains the values 0, 85, 170 and 255.  _That_ behavior is inherited from Pillow.  (It is also the behavior suggested in the PNG specification.)

Faced the issue today myself. Would love to see a warning being raised. :)

@OrkoHunter Looking forward to that PR :)

Myself and @Bernwang will be working on this over the next week

![](https://github.trello.services/images/mini-trello-icon.png) [Incorrect handling of binary images](https://trello.com/c/xCaFvTHj/1-foo)


Resolving patch:
diff --git a/skimage/io/_io.py b/skimage/io/_io.py
--- a/skimage/io/_io.py
+++ b/skimage/io/_io.py
@@ -123,15 +123,17 @@ def imsave(fname, arr, plugin=None, **plugin_args):
     -----
     When saving a JPEG, the compression ratio may be controlled using the
     ``quality`` keyword argument which is an integer with values in [1, 100]
-    where 1 is worst quality and smallest file size, and 100 is best quality and
-    largest file size (default 75).  This is only available when using the PIL
-    and imageio plugins.
+    where 1 is worst quality and smallest file size, and 100 is best quality
+    and largest file size (default 75).  This is only available when using
+    the PIL and imageio plugins.
     """
     if plugin is None and hasattr(fname, 'lower'):
         if fname.lower().endswith(('.tiff', '.tif')):
             plugin = 'tifffile'
     if is_low_contrast(arr):
         warn('%s is a low contrast image' % fname)
+    if arr.dtype == bool:
+        warn('%s is a boolean image: setting True to 1 and False to 0' % fname)
     return call_plugin('imsave', fname, arr, plugin=plugin, **plugin_args)
 
 


Test patch:
diff --git a/skimage/io/_plugins/test_plugin.ini b/skimage/io/_plugins/test_plugin.ini
deleted file mode 100644
--- a/skimage/io/_plugins/test_plugin.ini
+++ /dev/null
@@ -1,3 +0,0 @@
-[test]
-description = Test plugin
-provides = imsave, imshow, imread, imread_collection, imshow_collection
diff --git a/skimage/io/_plugins/test_plugin.py b/skimage/io/_plugins/test_plugin.py
deleted file mode 100644
--- a/skimage/io/_plugins/test_plugin.py
+++ /dev/null
@@ -1,30 +0,0 @@
-# This mock-up is called by ../tests/test_plugin.py
-# to verify the behaviour of the plugin infrastructure
-
-from skimage.io import ImageCollection
-import numpy as np
-
-
-def imread(fname, dtype=None):
-    assert fname == 'test.png'
-    assert dtype == 'i4'
-
-
-def imsave(fname, arr):
-    assert fname == 'test.png'
-    assert arr == [1, 2, 3]
-
-
-def imshow(arr, plugin_arg=None):
-    assert arr == [1, 2, 3]
-    assert plugin_arg == (1, 2)
-
-
-def imread_collection(x, conserve_memory=True):
-    assert conserve_memory == False
-    assert x == '*.png'
-    return ImageCollection([5, 10], load_func=lambda x: np.zeros((x, x)))
-
-
-def imshow_collection(x):
-    assert len(x) == 2
diff --git a/skimage/io/tests/test_pil.py b/skimage/io/tests/test_pil.py
--- a/skimage/io/tests/test_pil.py
+++ b/skimage/io/tests/test_pil.py
@@ -208,7 +208,24 @@ def test_imsave_filelike():
     # read from file-like object
     s.seek(0)
     out = imread(s)
-    assert out.shape == shape
+    assert_equal(out.shape, shape)
+    assert_allclose(out, image)
+
+
+def test_imsave_boolean_input():
+    shape = (2, 2)
+    image = np.eye(*shape, dtype=np.bool)
+    s = BytesIO()
+
+    # save to file-like object
+    with expected_warnings(
+            ['is a boolean image: setting True to 1 and False to 0']):
+        imsave(s, image)
+
+    # read from file-like object
+    s.seek(0)
+    out = imread(s)
+    assert_equal(out.shape, shape)
     assert_allclose(out, image)
 
 
@@ -218,7 +235,7 @@ def test_imexport_imimport():
     with expected_warnings(['precision loss']):
         pil_image = ndarray_to_pil(image)
     out = pil_to_ndarray(pil_image)
-    assert out.shape == shape
+    assert_equal(out.shape, shape)
 
 
 def test_all_color():
diff --git a/skimage/io/tests/test_plugin.py b/skimage/io/tests/test_plugin.py
--- a/skimage/io/tests/test_plugin.py
+++ b/skimage/io/tests/test_plugin.py
@@ -11,10 +11,6 @@
 priority_plugin = 'pil'
 
 
-def setup_module():
-    manage_plugins.use_plugin('test')  # see ../_plugins/test_plugin.py
-
-
 def teardown_module():
     io.reset_plugins()
 
@@ -29,28 +25,6 @@ def protect_preferred_plugins():
         manage_plugins.preferred_plugins = preferred_plugins
 
 
-def test_read():
-    io.imread('test.png', as_grey=True, dtype='i4', plugin='test')
-
-
-def test_save():
-    io.imsave('test.png', [1, 2, 3], plugin='test')
-
-
-def test_show():
-    io.imshow([1, 2, 3], plugin_arg=(1, 2), plugin='test')
-
-
-def test_collection():
-    ic = io.imread_collection('*.png', conserve_memory=False, plugin='test')
-    io.imshow_collection(ic)
-
-
-def test_use():
-    manage_plugins.use_plugin('test')
-    manage_plugins.use_plugin('test', 'imshow')
-
-
 def test_failed_use():
     with testing.raises(ValueError):
         manage_plugins.use_plugin('asd')
@@ -61,37 +35,9 @@ def test_use_priority():
     plug, func = manage_plugins.plugin_store['imread'][0]
     assert_equal(plug, priority_plugin)
 
-    manage_plugins.use_plugin('test')
+    manage_plugins.use_plugin('matplotlib')
     plug, func = manage_plugins.plugin_store['imread'][0]
-    assert_equal(plug, 'test')
-
-
-def test_use_priority_with_func():
-    manage_plugins.use_plugin('pil')
-    plug, func = manage_plugins.plugin_store['imread'][0]
-    assert_equal(plug, 'pil')
-
-    manage_plugins.use_plugin('test', 'imread')
-    plug, func = manage_plugins.plugin_store['imread'][0]
-    assert_equal(plug, 'test')
-
-    plug, func = manage_plugins.plugin_store['imsave'][0]
-    assert_equal(plug, 'pil')
-
-    manage_plugins.use_plugin('test')
-    plug, func = manage_plugins.plugin_store['imsave'][0]
-    assert_equal(plug, 'test')
-
-
-def test_plugin_order():
-    p = io.plugin_order()
-    assert 'imread' in p
-    assert 'test' in p['imread']
-
-
-def test_available():
-    assert 'qt' in io.available_plugins
-    assert 'test' in io.find_available_plugins(loaded=True)
+    assert_equal(plug, 'matplotlib')
 
 
 def test_load_preferred_plugins_all():