Skip to content
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

Use imageio v3 API #6764

Merged
merged 7 commits into from
May 11, 2023
Merged

Use imageio v3 API #6764

merged 7 commits into from
May 11, 2023

Conversation

stefanv
Copy link
Member

@stefanv stefanv commented Feb 21, 2023

@FirefoxMetzger
Copy link
Contributor

I had a quick look at CI and it looks like the tests that break call imsave. This was an alias for imwrite, which we dropped in v3 to make the API cleaner.

Instead of imwrite, imsave, mimwrite, mimsave, volwrite, and volsave you now just have imwrite (much simpler mental model). They all do the same thing on different inputs or are aliases of each other, and we can differentiate which behavior the user wants by simply inspecting the input.

@stefanv
Copy link
Member Author

stefanv commented Feb 22, 2023

Paletted read is still broken:

    def test_imageio_palette():
        img = imread(fetch('data/palette_color.png'))
>       assert img.ndim == 3
E       assert 2 == 3
E        +  where 2 = array([[191, 108,  72, 194,  73,  65,  79, 130, 216, 131],\n       [ 90,  96, 104,  92,  29, 172,  51,  29, 102, 119],\n...  22,  95, 171, 220,  87, 109,  61, 205,  24],\n       [ 77, 102,  32,  55, 190, 132,  50,  26, 172, 156]], dtype=uint8).ndim

@stefanv
Copy link
Member Author

stefanv commented Feb 22, 2023

Matplotlib reads this image correctly; PIL does too, but you need to handle im.mode == 'P' correctly:

im = PIL.Image.open('skimage/data/palette_color.png')
A = np.array(im)
pal = np.array(im.getpalette()).reshape(-1, 3)
return pal[np.array(im)]

https://github.com/scikit-image/scikit-image/raw/main/skimage/data/palette_color.png

@FirefoxMetzger would you consider this to be a bug?

@FirefoxMetzger
Copy link
Contributor

Interesting image @stefanv . Would we be allowed to adopt it in our own test suite?

Currently, we have a warning/note for this behavior in the pillow plugin's read function:

Notes
-----
If you open a GIF - or any other format using color pallets - you may
wish to manually set the `mode` parameter. Otherwise, the numbers in
the returned image will refer to the entries in the color pallet, which
is discarded during conversion to ndarray.

and you can have ImageIO reconstruct it for you by asking for a result in RGB:

img = iio.imread("palette_color.png", mode="RGB")
img.shape  # (10, 10, 3)

That said, we have changed the behavior for GIF a while ago, i.e., you get a reconstructed image (RGB/A depending on the palette) instead of palette indices. This came after listening to a frequent stream of feedback asking if GIF was broken/buggy. I think we could extend this behavior to other paletted formats including PNG since you, too, are asking if the behavior for palette PNG is a bug 😄.

@FirefoxMetzger
Copy link
Contributor

@FirefoxMetzger would you consider this to be a bug?

@stefanv This will be fixed by imageio/imageio#955 . That PR is ready except for a regression test; would I be allowed to add https://github.com/scikit-image/scikit-image/raw/main/skimage/data/palette_color.png to our test suite (and distribute it alongside the suite), or should I look into generating my own palette PNG image?

@FirefoxMetzger
Copy link
Contributor

FirefoxMetzger commented Mar 3, 2023

The other failure is that ImageIO sometimes returns a read-only ndarray that is a view into a PIL buffer instead of making a copy of the data. This is a performance optimization done internally by PIL; however, it seems to break some of the filters here which assume that the image is writable.

Does skimage.io.imread expect the produced result to be writable? If so, we could discuss adding a flag along the lines of force_writable=True, which will make a copy of the image before returning it if the result turns out to be read-only. This obviously has a negative impact on performance and memory requirements (especially if we are forced to copy large images), but if this is behavior that users rely on then ImageIO can (optionally) allow this, and you can set that flag internally.

@lagru lagru added the 🔧 type: Maintenance Refactoring and maintenance of internals label Mar 5, 2023
@stefanv
Copy link
Member Author

stefanv commented Apr 5, 2023

@FirefoxMetzger Yes, indeed, the idea is that you get a standard ndarray back that you can manipulate however you wish; so that force_writable or similar flag (maybe copy=True or view=False) would be helpful!

Note that we can also very easily add this to our wrapper, which may be easier?

@stefanv
Copy link
Member Author

stefanv commented Apr 5, 2023

@FirefoxMetzger would you consider this to be a bug?

@stefanv This will be fixed by imageio/imageio#955 . That PR is ready except for a regression test; would I be allowed to add https://github.com/scikit-image/scikit-image/raw/main/skimage/data/palette_color.png to our test suite (and distribute it alongside the suite), or should I look into generating my own palette PNG image?

You can go ahead and use that image.

@stefanv
Copy link
Member Author

stefanv commented Apr 5, 2023

The changes I pushed should work around the read-only behavior. I should probably test for that explicitly (I don't know which images show this behavior), but I know it's being tested for implicitly in the test suite.

@stefanv
Copy link
Member Author

stefanv commented Apr 5, 2023

This failure I don't understand. On my local machine, imageio correctly returns a 2D image. Under which circumstances would it return a 3D image?

    def test_eagle():
        """ Test that "eagle" image can be loaded. """
        # Fetching the data through the testing module will
        # cause the test to skip if pooch isn't installed.
        fetch('data/eagle.png')
        eagle = data.eagle()
>       assert_equal(eagle.ndim, 2)
E       AssertionError: 
E       Items are not equal:
E        ACTUAL: 3
E        DESIRED: 2

@stefanv
Copy link
Member Author

stefanv commented Apr 10, 2023

@FirefoxMetzger This seems like a genuine bug in imageio:

In [1]: from imageio.v3 import imread

In [2]: imread('/tmp/eagle.png').shape
Out[2]: (2019, 1826, 3)

Eagle image

@FirefoxMetzger
Copy link
Contributor

Sorry, @stefanv; I didn't get around to checking this until now.

The image is again a paletted PNG and its palette is RGB. I.e., the palette sets all color channels to the same level to give the user the impression of a gray image but internally stores RGB colors. Storing data as RGB currently means that get data back as RGB since that's what most users expect. In the case of eagle, this means (2019, 1826, 3).

Our old pillow plugin had an extra step of custom logic that would scan palettes and run a check for grayscale. If the palette only contains gray values, it would then reconstruct the RGB image as usual, but then add an additional post-processing step to convert to grayscale before returning the result.

The new plugin does not do this additional step and, at first glance, this seems intuitive. For 8-bit depth, there is no benefit in palettizing a gray image. It won't compress the image (we still need 8-bit per pixel) and now we also have to store the palette, which takes up extra space. If, despite this, a user still stores gray images as a palette then their reason for doing so likely revolves around the fact that palettes are RGB internally, which is the key difference to storing grayscale directly. To me, having ImageIO undo this internally seems counterproductive.

That said, there is (as always) utility in continuity and backward compatibility. We could reintroduce the RGB->gray palette check and add a flag to turn it on/off.

I'm curious what you think about this @stefanv . Based on the unit test you clearly expected a grayscale image, but I'm curious if this is mainly an appeal to tradition ("it has always been this way") or if there is deeper reasoning for this check that I am not aware of.

@lagru
Copy link
Member

lagru commented Apr 24, 2023

Cross-referencing #6908 here. I haven't investigated yet. Would this PR address #6908?

lagru added a commit to lagru/scikit-image that referenced this pull request Apr 25, 2023
It seems like the recent release of imageio v2.28 breaks some of our
tests [1]. It's being worked on [2],[3] but in the meantime let's pin
the dependency below 2.28 so that our CI is useful again.

[1] scikit-image#6908
[2] scikit-image#6764
[3] scikit-image#6907 (comment)
lagru added a commit to lagru/scikit-image that referenced this pull request Apr 25, 2023
It seems like the recent release of imageio v2.28 breaks some of our
tests [1]. It's being worked on [2],[3] but in the meantime let's pin
the dependency below 2.28 so that our CI is useful again.

[1] scikit-image#6908
[2] scikit-image#6764
[3] scikit-image#6907 (comment)
lagru added a commit to lagru/scikit-image that referenced this pull request Apr 25, 2023
It seems like the recent release of imageio v2.28 breaks some of our
tests [1]. It's being worked on [2],[3] but in the meantime let's pin
the dependency below 2.28 so that our CI is useful again.

[1] scikit-image#6908
[2] scikit-image#6764
[3] scikit-image#6907 (comment)
@FirefoxMetzger
Copy link
Contributor

Cross-referencing #6908 here. I haven't investigated yet. Would this PR address #6908?

@lagru Yes, because v2.28 only affects the imageio.v2 namespace. imageio.v3 works just like before.

There are still some discussion points in this PR though that need resolution before it can be merged. In particular, we need to decide how to deal with RGB paletted images that store "grey as RGB", i.e., the entire palette consists of gray values encoded as RGB. The old ImageIO had an explicit (and slow) check for this and did RGB->gray conversion if needed; however, we currently don't do this, because the image was explicitly stored as RGB which is probably intentional so you get things back as stored, which means as RGB.

jarrodmillman pushed a commit that referenced this pull request Apr 25, 2023
It seems like the recent release of imageio v2.28 breaks some of our
tests [1]. It's being worked on [2],[3] but in the meantime let's pin
the dependency below 2.28 so that our CI is useful again.

[1] #6908
[2] #6764
[3] #6907 (comment)
@stefanv
Copy link
Member Author

stefanv commented Apr 25, 2023

I suspect the new imageio behavior is more correct, and we should fix our eagle data file instead.

@lagru
Copy link
Member

lagru commented Apr 25, 2023

With how you summarize the issue in #6908 (comment) and here, I would also consider the new v3 behavior to be more "correct". If the image is stored as RGB, returning RGB seems like an expected thing to do. And at least this part should be an easy enough update on the side of scikit-image. We could just drop the 2 channels after loading it, or do the proper thing and update the file itself. :)

@lagru
Copy link
Member

lagru commented Apr 27, 2023

See https://gitlab.com/scikit-image/data/-/merge_requests/22. Once that's merged we can update the registry entry for eagle.png here. The we don't have to worry about that image anymore regardless how imageio decides to proceed with cases such as this. Also saves a few MiB of download (2.2 to 1.4 MiB).

@FirefoxMetzger
Copy link
Contributor

Awesome @lagru once that is merged all the CI failures here should resolve themself (at least as far as I can see).

@stefanv
Copy link
Member Author

stefanv commented May 9, 2023

@lagru Merged on gitlab; do you mind updating the data repo? You are welcome to push to this PR what you need to.

lagru added 2 commits May 9, 2023 21:30
Updates the registry entry for eagle.png to the updated version from
[1]. The original file contained a RGB palette whose contents were
identical. The image is therefore effectively grayscale. Previous to
imageio v2.28, their pillow plugin had custom logic to check for this
case in which case it would remove the duplicate channels. However, this
behavior was removed in v2.28, which means that reading the image will
return an array with those RGB channels as the last dimension which
breaks scikit-image's existing API and tests. [2]

[1] https://gitlab.com/scikit-image/data/-/merge_requests/22
[2] scikit-image#6764 (comment)
@stefanv
Copy link
Member Author

stefanv commented May 9, 2023

Thanks! Should be good to go on green.

@stefanv
Copy link
Member Author

stefanv commented May 10, 2023

@FirefoxMetzger We're almost good to go, but with latest imageio we have no_time_for_that_tiny.gif be RGB, whereas it used to be RGBA.

Imagemagick tells me:

$ identify -format '%[channels] ' skimage/data/no_time_for_that_tiny.gif
srgb srgba srgba srgba srgba srgba srgba srgba srgba srgba srgba srgba srgba srgba srgba srgba srgba srgba srgba srgba srgba srgba srgba srgba

Any idea what the correct output here should be?

@lagru
Copy link
Member

lagru commented May 11, 2023

srgb srgba srgba ... makes it seem a bit like only the first frame is lacking an alpha channel? Did imageio perhaps fill in this channel previously while it is now dropping the alpha channel for all other frames (it seems like this locally).

Edit: With @stefanv's command I actually get srgb 4.0 srgba 5.0 ... srgba 5.0 srgba for ImageMagick 7.1.1-8. Interestingly identify gives

Click to expand
identify ~/.cache/scikit-image/main/data/no_time_for_that_tiny.gif
~/.cache/scikit-image/main/data/no_time_for_that_tiny.gif[0] GIF 14x25 14x25+0+0 8-bit sRGB 256c 0.000u 0:00.000
~/.cache/scikit-image/main/data/no_time_for_that_tiny.gif[1] GIF 10x25 14x25+4+0 8-bit sRGB 256c 0.000u 0:00.001
~/.cache/scikit-image/main/data/no_time_for_that_tiny.gif[2] GIF 10x25 14x25+4+0 8-bit sRGB 256c 0.000u 0:00.001
~/.cache/scikit-image/main/data/no_time_for_that_tiny.gif[3] GIF 10x25 14x25+4+0 8-bit sRGB 256c 0.000u 0:00.001
~/.cache/scikit-image/main/data/no_time_for_that_tiny.gif[4] GIF 10x25 14x25+4+0 8-bit sRGB 256c 0.000u 0:00.000
~/.cache/scikit-image/main/data/no_time_for_that_tiny.gif[5] GIF 10x25 14x25+4+0 8-bit sRGB 256c 0.000u 0:00.000
~/.cache/scikit-image/main/data/no_time_for_that_tiny.gif[6] GIF 10x25 14x25+4+0 8-bit sRGB 256c 0.000u 0:00.000
~/.cache/scikit-image/main/data/no_time_for_that_tiny.gif[7] GIF 10x25 14x25+4+0 8-bit sRGB 256c 0.000u 0:00.000
~/.cache/scikit-image/main/data/no_time_for_that_tiny.gif[8] GIF 10x25 14x25+4+0 8-bit sRGB 256c 0.000u 0:00.000
~/.cache/scikit-image/main/data/no_time_for_that_tiny.gif[9] GIF 10x25 14x25+4+0 8-bit sRGB 256c 0.000u 0:00.000
~/.cache/scikit-image/main/data/no_time_for_that_tiny.gif[10] GIF 10x25 14x25+4+0 8-bit sRGB 256c 0.000u 0:00.000
~/.cache/scikit-image/main/data/no_time_for_that_tiny.gif[11] GIF 10x25 14x25+4+0 8-bit sRGB 256c 0.000u 0:00.000
~/.cache/scikit-image/main/data/no_time_for_that_tiny.gif[12] GIF 10x25 14x25+4+0 8-bit sRGB 256c 0.000u 0:00.000
~/.cache/scikit-image/main/data/no_time_for_that_tiny.gif[13] GIF 9x25 14x25+5+0 8-bit sRGB 256c 0.000u 0:00.000
~/.cache/scikit-image/main/data/no_time_for_that_tiny.gif[14] GIF 10x25 14x25+4+0 8-bit sRGB 256c 0.000u 0:00.000
~/.cache/scikit-image/main/data/no_time_for_that_tiny.gif[15] GIF 10x25 14x25+4+0 8-bit sRGB 256c 0.000u 0:00.000
~/.cache/scikit-image/main/data/no_time_for_that_tiny.gif[16] GIF 10x25 14x25+4+0 8-bit sRGB 256c 0.000u 0:00.000
~/.cache/scikit-image/main/data/no_time_for_that_tiny.gif[17] GIF 10x25 14x25+4+0 8-bit sRGB 256c 0.000u 0:00.000
~/.cache/scikit-image/main/data/no_time_for_that_tiny.gif[18] GIF 10x25 14x25+4+0 8-bit sRGB 256c 0.000u 0:00.000
~/.cache/scikit-image/main/data/no_time_for_that_tiny.gif[19] GIF 10x25 14x25+4+0 8-bit sRGB 256c 0.000u 0:00.000
~/.cache/scikit-image/main/data/no_time_for_that_tiny.gif[20] GIF 10x25 14x25+4+0 8-bit sRGB 256c 0.000u 0:00.000
~/.cache/scikit-image/main/data/no_time_for_that_tiny.gif[21] GIF 10x25 14x25+4+0 8-bit sRGB 256c 0.000u 0:00.000
~/.cache/scikit-image/main/data/no_time_for_that_tiny.gif[22] GIF 10x25 14x25+4+0 8-bit sRGB 256c 0.000u 0:00.000
~/.cache/scikit-image/main/data/no_time_for_that_tiny.gif[23] GIF 10x25 14x25+4+0 8-bit sRGB 256c 4438B 0.000u 0:00.000

The shape of 0 and 13 seems off.

@stefanv
Copy link
Member Author

stefanv commented May 11, 2023

I've re-introduced the <2.28 pin, since we do not have time to figure out this latest change in behavior before the release.

@jarrodmillman jarrodmillman merged commit e8a92e3 into scikit-image:main May 11, 2023
@FirefoxMetzger
Copy link
Contributor

Yes, I noticed this as well last week. Unfortunately, I didn't yet find the time to think through the important scenarios and decide the correct behavior. I started a new job last week and getting set up didn’t leave much free time.

The short version: You already identified that the first plane of this GIF is RGB and all others are RGBA. This is likely due to the writer taking advantage of one of GIF's more obscure compression features, which is commonly "misused" to create transparent GIFs. In a nutshell returning RGB is the technically correct thing to do (read: it strictly follows the GIF standard), but it may be more user friendly to return RGBA instead (read: strictly following GIF might be surprising here).


(sorry that the below response turned out so long. It's again one of those edge cases that doesn't have a clear default and depends on "what the average user expects". My hope with this lengthy explanation is that it gives you enough context to give me feedback on what skimage's "average user" would expect.)

The longer version: A GIF palette can’t store RGBA; it's always RGB. Instead, one may set a byte (a unit8) in the frame’s metadata to flag an index as "transparent". If set, it means “if you encounter this index in the index array, don’t look it up in the color palette and instead reuse the value of the previous pixel”. If there is no previous pixel (first frame or disposal set to “restore background”) then we default to the GIFs chosen background color, and if that too is not set, the reader gets to choose the color (aka we have undefined behavior). In practice, readers have converged to understand this lack of background color to mean “the background has alpha=0”. This is nothing a GIF can represent explicitly (aka its non-standard), but it’s so popular and well supported that this behavior is nowadays ubiquitous.

The obligatory piece of trivia: The original intent of this transparency flag was to allow better compression (smaller per-frame palettes) and to circumvent the 256-color limitation. If we can reuse 2+ colors from the previous frame, we can choose 1+ new colors for the current frame and create a 257+ color image (255 new colors, and 2+ reused/old). It's an ingenious idea, but not many writers ever took advantage of it because it means a lot of GIF-specific custom logic when creating a frame-level palette. Instead people found a loophole in the definition of this feature (the implicit background color), which eventually became RGBA support in GIF.

This brings us back to the ultimate question of: What is the expected behavior of a reader for this particular image? Since, for this image, the first frame is RGB (not RGB + transparency flag) we can always trace the final value to a valid RGB color and avoid the “undefined behavior/transparent pixel hack” territory. Also, for each index, users expect a fully decoded frame. This means that - by default - for index=5 they want the full frame, not only those pixels which are new/changed and transparent everywhere else. At the moment, this translates to “return an RGB, since we will never have a pixel with alpha != 255”, but I am happy to change this and append an effectively empty alpha channel if that is more intuitive.

@lagru
Copy link
Member

lagru commented May 12, 2023

@FirefoxMetzger, no worries and thank you so much for taking the time to give a detailed answer anyway! It's very much appreciated. Hope can settle nicely into the new job. :)

Regarding no_time_for_that_tiny.gif (I am finding the name very ironic right now 😂) and what to do from scikit-image's side: If find it also noteworthy that we already have a test case that actually expects the last dimension to have the size 3 (as opposed to the failing test expecting 4) 😄

def test_multi_page_gif():
img = imread(fetch('data/no_time_for_that_tiny.gif'))
assert img.shape == (24, 25, 14, 3), img.shape

I don't think our tests actually care for the properties of this GIF that we are currently discussing as long as the shape can be asserted. In this context I think we are free to just update the unit test and / or the file. Do you know a quick way to generate a similar file without the ambiguous first channel? I don't think the content actually matters to our tests...

I'm not sure which behavior imageio should aim for but I find it noteworthy that PIL has been returning only 3 channels for this GIF for some time.

@FirefoxMetzger
Copy link
Contributor

FirefoxMetzger commented May 12, 2023

Regarding no_time_for_that_tiny.gif (I am finding the name very ironic right now 😂) [...] we already have a test case that actually expects the last dimension to have the size 3 (as opposed to the failing test expecting 4)

RIP ⚰️ 🤣

Do you know a quick way to generate a similar file without the ambiguous first channel?

If you want the same pixels, loading the image in the mode you want (RGB/RGBA) and saving it again should do the trick.

# if you want to have RGB consistently
img = iio.imread("no_time_for_that_tiny.gif", mode="RBG")

# if you want to have RGBA consistently
img = iio.imread("no_time_for_that_tiny.gif", mode="RBG")

iio.imwrite("new_test_file.gif", img)

If you don't care about the actual pixel values, you can also opt to generate a GIF in memory:

desired_shape = (24, 25, 14, 3)
img = np.random.randint(0, 255, desired_shape, dtype=np.uint8)
# img = np.full(desired_shape, 42, dtype=np.uint8)
buffer = iio.imwrite("<bytes>", img, extension=".gif")

result = iio.imread(buffer)
assert result.shape == desired_shape

I'm not sure which behavior imageio should aim for but I find it noteworthy that PIL has been returning only 3 channels for this GIF for some time.

Hm. That combined with the fact that RGB is the standard conform thing to do makes two arguments towards the current behavior being what we should do. I'll think about it some more.

lagru added a commit to lagru/scikit-image that referenced this pull request May 13, 2023
in favor of a fixture. Bypasses problems due to the file returning
a different number of channels depending on imageio's version [1].
Continuation of scikit-image#6764 [2].

Also remove its mention in ImageCollection's docstring and simplify /
update the example section.

[1] scikit-image#6764 (comment)
[2] scikit-image#6764
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
🔧 type: Maintenance Refactoring and maintenance of internals
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants