Pillow icon indicating copy to clipboard operation
Pillow copied to clipboard

Issue with PIL.Image.fromarray(..., mode="1") returning a transposed image

Open guillaume-rochette-oxb opened this issue 6 months ago • 8 comments

What did you do?

I have an np.ndarray with shape=(h, w) and dtype=bool that I want to convert to a PIL.Image.Image (and later save on disk disk, although that is not relevant). I have noticed that PIL.Image.fromarray(array, mode="1") does not work correctly, while PIL.Image.fromarray(array, mode=None) does behave correctly.

What did you expect to happen?

Referring to the sample code below, I expect that all the 3 arrays would be equal for all modes.

What actually happened?

It does not work for mode="1".

What are your OS, Python and Pillow versions?

Linux:

--------------------------------------------------------------------
Pillow 11.2.1
Python 3.12.9 | packaged by Anaconda, Inc. | (main, Feb  6 2025, 18:56:27) [GCC 11.2.0]
--------------------------------------------------------------------
Python executable is /home/***/miniconda3/envs/pil-env/bin/python3
System Python files loaded from /home/***/miniconda3/envs/pil-env
--------------------------------------------------------------------
Python Pillow modules loaded from /home/***/miniconda3/envs/pil-env/lib/python3.12/site-packages/PIL
Binary Pillow modules loaded from /home/***/miniconda3/envs/pil-env/lib/python3.12/site-packages/PIL
--------------------------------------------------------------------
--- PIL CORE support ok, compiled for 11.2.1
--- TKINTER support ok, loaded 8.6
--- FREETYPE2 support ok, loaded 2.13.3
--- LITTLECMS2 support ok, loaded 2.17
--- WEBP support ok, loaded 1.5.0
*** AVIF support not installed
--- JPEG support ok, compiled for libjpeg-turbo 3.1.0
--- OPENJPEG (JPEG2000) support ok, loaded 2.5.3
--- ZLIB (PNG/ZIP) support ok, loaded 1.2.13, compiled for zlib-ng 2.2.4
--- LIBTIFF support ok, loaded 4.7.0
--- RAQM (Bidirectional Text) support ok, loaded 0.10.1, fribidi 1.0.8, harfbuzz 11.0.1
*** LIBIMAGEQUANT (Quantization method) support not installed
--- XCB (X protocol) support ok
--------------------------------------------------------------------

macOS:

--------------------------------------------------------------------
Pillow 11.1.0
Python 3.12.9 | packaged by conda-forge | (main, Mar  4 2025, 22:44:42) [Clang 18.1.8 ]
--------------------------------------------------------------------
Python executable is /Users/***/miniconda3/envs/pil-env/bin/python3
System Python files loaded from /Users/***/miniconda3/envs/pil-env
--------------------------------------------------------------------
Python Pillow modules loaded from /Users/***/miniconda3/envs/pil-env/lib/python3.12/site-packages/PIL
Binary Pillow modules loaded from /Users/***/miniconda3/envs/pil-env/lib/python3.12/site-packages/PIL
--------------------------------------------------------------------
--- PIL CORE support ok, compiled for 11.1.0
--- TKINTER support ok, loaded 8.6
--- FREETYPE2 support ok, loaded 2.13.2
--- LITTLECMS2 support ok, loaded 2.16
--- WEBP support ok, loaded 1.5.0
--- JPEG support ok, compiled for libjpeg-turbo 3.1.0
--- OPENJPEG (JPEG2000) support ok, loaded 2.5.3
--- ZLIB (PNG/ZIP) support ok, loaded 1.3.1.zlib-ng, compiled for zlib-ng 2.2.2
--- LIBTIFF support ok, loaded 4.6.0
*** RAQM (Bidirectional Text) support not installed
*** LIBIMAGEQUANT (Quantization method) support not installed
--- XCB (X protocol) support ok
--------------------------------------------------------------------

Sample code

from PIL import Image
from urllib.request import urlopen
import numpy as np


def main():
    url = "https://python-pillow.github.io/assets/images/pillow-logo.png"

    base_image = Image.open(urlopen(url))
    print(base_image.size, base_image.mode)

    modes = [
        "RGBA",
        "RGB",
        "L",
        "I",
        "F",
        "1",
    ]
    for mode in modes:
        image_1 = base_image.convert(mode=mode)
        array_1 = np.array(image_1)
        image_2 = Image.fromarray(array_1, mode=mode)
        array_2 = np.array(image_2)
        image_3 = Image.fromarray(array_1)
        array_3 = np.array(image_3)

        size_equality = image_1.size == image_2.size == image_3.size
        mode_equality = image_1.mode == image_2.mode == image_3.mode
        shape_equality = array_1.shape == array_2.shape == array_3.shape
        dtype_equality = array_1.dtype == array_2.dtype == array_3.dtype

        print(f"image_1.size = {image_1.size}, image_2.size = {image_2.size}, image_3.size = {image_3.size}")
        print(f"image_1.mode = {image_1.mode}, image_2.mode = {image_2.mode}, image_3.mode = {image_3.mode}")
        print(f"array_1.shape = {array_1.shape}, array_2.shape = {array_2.shape}, array_3.shape = {array_3.shape}")
        print(f"array_1.dtype = {array_1.dtype}, array_2.dtype = {array_2.dtype}, array_3.dtype = {array_3.dtype}")
        print(f"size_equality = {size_equality}")
        print(f"mode_equality = {mode_equality}")
        print(f"shape_equality = {shape_equality}")
        print(f"dtype_equality = {dtype_equality}")

        array_1_and_array_2_equality = (array_1 == array_2).all()
        array_1_and_array_3_equality = (array_1 == array_3).all()
        array_2_and_array_3_equality = (array_2 == array_3).all()
        print(f"array_1_and_array_2_equality = {array_1_and_array_2_equality}")
        print(f"array_1_and_array_3_equality = {array_1_and_array_3_equality}")
        print(f"array_2_and_array_3_equality = {array_2_and_array_3_equality}")

        # uncomment to display images
        # if not all([array_1_and_array_2_equality, array_1_and_array_3_equality, array_2_and_array_3_equality]):
        #     image_1.show()
        #     image_2.show()
        #     image_3.show()

        print()


if __name__ == "__main__":
    main()

image_1: Image

image_2: Image

image_3: Image

guillaume-rochette-oxb avatar Jun 09 '25 15:06 guillaume-rochette-oxb

Hi. This question has been raised before as #5723. I suggest having a read of that.

In terms of our code, when determining a mode and rawmode to use for the array, mode 1 is the only one of your scenarios that uses a different rawmode. https://github.com/python-pillow/Pillow/blob/05636dca172e5b2cf7add8ae85fe22ae71a87e0b/src/PIL/Image.py#L3405-L3408

Because you're setting the mode, that is also used as the rawmode.

See also https://github.com/python-pillow/Pillow/issues/5465#issuecomment-831871276

I think that the mode to use parameter is a footgun. It’s not an implicit conversion, it’s a cast.

It seems like you already have a solution, simply to not set the mode in fromarray().

radarhere avatar Jun 10 '25 00:06 radarhere

Hi, Thank you for your response, however I do not think this is a satisfying solution. Why not make a special case for it? Implicit is not better than explicit, c.f. PEP 20. Do you think it would be good to create fromarray2() then?

def fromarray2(obj: SupportsArrayInterface, mode: str | None = None) -> Image:
    if mode == "1":
        mode = None
    return fromarray(obj, mode)

guillaume-rochette-oxb avatar Jun 10 '25 08:06 guillaume-rochette-oxb

Why not make a special case for it?

There is often a reluctance to change things, for the sake of backwards compatibility. It looks like this behaviour has been in place since before we forked from PIL, about 15 years ago.

I can understand that the mode argument doesn't work how you'd expect. However, I'm still not clear on why you need to pass it in.

Implicit is not better than explicit, c.f. PEP 20.

I don't personally think that's a sufficient reason to for the user to provide a mode. The array being passed in has a shape and a type. It more or less has a mode, in the same way that images have modes.

There might actually be a stronger appetite for deprecating the argument altogether - https://github.com/python-pillow/Pillow/issues/5465#issuecomment-831871276

I’m not sure if there is a good use case at all for the mode parameter here, as we need to be pretty sure what we’re getting from numpy to interpret the array correctly.

radarhere avatar Jun 10 '25 08:06 radarhere

Pardon me, but this seems like you're implying that "it's not a bug, it's a feature" :/

I disagree, there is no bijection between mode and dtype, for example both modes RGB and HSV are represented with uint8, maybe instead we should have instead `fromarray(..., array_mode, image_mode) instead?

guillaume-rochette-oxb avatar Jun 10 '25 12:06 guillaume-rochette-oxb

I'm trying to say that regardless of whether it is a bug, it's long-standing behaviour, and it's possible that users have workarounds that rely on it.

Feel free to disagree with me and create a PR, if you'd like to move forward and see what others think.

radarhere avatar Jun 10 '25 12:06 radarhere

Okay.

guillaume-rochette-oxb avatar Jun 11 '25 15:06 guillaume-rochette-oxb

To try and get another opinion, @TheRealQuantam, you created https://github.com/python-pillow/Pillow/discussions/6561 where you were using both L and P mode with fromarray(). Do you have any thoughts on this issue or on deprecating the mode parameter altogether?

We've had #2856 (with 12 likes), #3781, #4887, #5227, #5465 and #5723 - all issues were this parameter has caused confusion. Since then, #5849 improved the documentation, but as per this issue, it's not done causing problems.

radarhere avatar Jun 12 '25 09:06 radarhere

I've created #9018 to suggest deprecating mode.

radarhere avatar Jun 14 '25 06:06 radarhere

https://github.com/python-pillow/Pillow/pull/9063 has partially reverted #9018, only deprecating mode for changing data types.

radarhere avatar Sep 04 '25 22:09 radarhere