Pillow icon indicating copy to clipboard operation
Pillow copied to clipboard

Saving and loading int16 images to PNG format is causing data loss

Open ondrej-kvet opened this issue 6 months ago • 9 comments

What did you do?

Saving the int16 image into a PNG format previously used I mode but was changed to I;16 mode, which leads to a silent data loss. The change was done in this pull request.

What did you expect to happen?

Saved int16 images should be loaded as int32 (mode I), as it was before.

What actually happened?

Image was loaded as uint16 and the data was clipped (see the example code bellow).

What are your OS, Python and Pillow versions?

  • OS: Windows 10
  • Python: 3.13.5
  • Pillow: 11.2.1

PIL report:

--------------------------------------------------------------------
Pillow 11.2.1
Python 3.13.5 (tags/v3.13.5:6cb20a2, Jun 11 2025, 16:15:46) [MSC v.1943 64 bit (AMD64)]
--------------------------------------------------------------------
Python executable is C:\Users\factory\AppData\Local\Programs\Python\Python313\python.exe
System Python files loaded from C:\Users\factory\AppData\Local\Programs\Python\Python313
--------------------------------------------------------------------
Python Pillow modules loaded from C:\Users\factory\AppData\Local\Programs\Python\Python313\Lib\site-packages\PIL
Binary Pillow modules loaded from C:\Users\factory\AppData\Local\Programs\Python\Python313\Lib\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.3.1.zlib-ng, compiled for zlib-ng 2.2.4
--- LIBTIFF support ok, loaded 4.7.0
*** RAQM (Bidirectional Text) support not installed
*** LIBIMAGEQUANT (Quantization method) support not installed
*** XCB (X protocol) support not installed
--------------------------------------------------------------------

Example script:

import numpy
import PIL.Image as PilImage

shape = (1024, 1024)
data = numpy.full(shape=shape, fill_value=-5, dtype=numpy.int16)
pil_image = PilImage.fromarray(data)

print("Mode of the image before save:")
print(pil_image.mode)

print("Printing [0, 0] value of the image before save...")
print(data[0, 0])

print("Saving the image...")
path = r"C:\users\factory\desktop\foo.png"
pil_image.save(path)

print("Loading image...")
with PilImage.open(path) as loaded_image:
    print("Mode of the image after save:")
    print(loaded_image.mode)

    print("Printing [0, 0] value of the image before save (mode is I;16 - so uint16 is assumed)...")
    raw_data = loaded_image.tobytes()
    raw_data_bytes = numpy.frombuffer(raw_data, dtype=numpy.uint16)
    loaded_data = numpy.reshape(raw_data_bytes, (pil_image.height, pil_image.width))
    print(loaded_data[0, 0])

Output:

Mode of the image before save:
I
Printing [0, 0] value of the image before save...
-5
Saving the image...
Loading image...
Mode of the image after save:
I;16
Printing [0, 0] value of the image before save (mode is I;16 - so uint16 is assumed)...
0

ondrej-kvet avatar Jun 16 '25 12:06 ondrej-kvet

Saving the int16 image into a PNG format previously used I mode but was changed to I;16 mode, which leads to a silent data loss.

I'm not convinced it did work previously - if I install Pillow 10.2.0, I get an error - ValueError: cannot reshape array of size 2097152 into shape (1024,1024).

But I'm going to focus on the present instead - I think the real cause of your situation is that the image is saved in 16-bit - https://github.com/python-pillow/Pillow/blob/4d0ebb040a8890eaa86414fda3e63f2ca7d00240/src/PIL/PngImagePlugin.py#L1097-L1105

However, I think this is just a limitation of the PNG format - https://www.libpng.org/pub/png/spec/1.2/PNG-Chunks.html

Bit depth is a single-byte integer giving the number of bits per sample or per palette index (not per pixel). Valid values are 1, 2, 4, 8, and 16, although not all values are allowed for all color types.

If we can't save higher than 16-bits, then we can't save all data from the 32-bit I mode.

radarhere avatar Jun 16 '25 12:06 radarhere

It did work, but in previous version you have to replace this line:

raw_data_bytes = numpy.frombuffer(raw_data, dtype=numpy.uint16)

to

raw_data_bytes = numpy.frombuffer(raw_data, dtype=numpy.int32)

I am checking the mode property to check what raw data type should I expect, with the previous version I am getting I - whitch means int32.

ondrej-kvet avatar Jun 16 '25 13:06 ondrej-kvet

While Pillow might read the data from the image and say the result is "I" mode, that doesn't mean that there is actually 32 bits of data in the image - it just means that Pillow has read the data and placed the values inside containers that can hold 32 bits. #7849 just better reflects the reality of what is there in the image.

Even with the modification you mentioned in your last comment, your script will still print 0 as the pixel value at the end.

radarhere avatar Jun 16 '25 13:06 radarhere

If you would like Pillow to throw an error when trying to save an I mode image as a PNG, because the full range of values cannot be saved, then that sounds like a reasonable request.

Otherwise, I don't think there is anything to do here.

radarhere avatar Jun 17 '25 01:06 radarhere

Even with the modification you mentioned in your last comment, your script will still print 0 as the pixel value at the end.

You are correct. Sorry. This assert was not part of my original test which started this whole investigation. The "old" version I was using was 10.1.0-4 on Python 11.

If you would like Pillow to throw an error when trying to save an I mode image as a PNG, because the full range of values cannot be saved, then that sounds like a reasonable request.

Yes, this sounds reasonable. If PIL can't save the image without a data loss, it should raise an error instead.

ondrej-kvet avatar Jun 17 '25 05:06 ondrej-kvet

Looking at the table you posted. PNG also doesn't support 32bit grayscale images. PIL saves them but the data also doesn't seem to be saved properly... This should raise an error as well?

ondrej-kvet avatar Jun 17 '25 06:06 ondrej-kvet

Yes, this sounds reasonable. If PIL can't save the image without a data loss, it should raise an error instead.

I've created #9023 to deprecate the behaviour.

Looking at the table you posted. PNG also doesn't support 32bit grayscale images. PIL saves them but the data also doesn't seem to be saved properly... This should raise an error as well?

I'm confused. That sounds the same as what we were already talking about with I mode images. I don't see a distinction.

radarhere avatar Jun 17 '25 08:06 radarhere

Perhaps in your mind these two situations are the same, but I have a different mental model, so I wanted to make this clear. In my example I create int16 data using:

data = numpy.full(shape=shape, fill_value=-5, dtype=numpy.int16)

If I create int32 data, I would expect the same behavior we discussed; an error message when trying to save to PNG.

ondrej-kvet avatar Jun 17 '25 10:06 ondrej-kvet

Yes. Once the deprecation period has ended,

import numpy
import PIL.Image as PilImage

shape = (1024, 1024)
data = numpy.full(shape=shape, fill_value=-5, dtype=numpy.int16)
pil_image = PilImage.fromarray(data)

pil_image.save("foo.png")

will raise

Traceback (most recent call last):
  File "PIL/PngImagePlugin.py", line 1368, in _save
    rawmode, bit_depth, color_type = _OUTMODES[outmode]
KeyError: 'I'

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "demo.py", line 8, in <module>
    pil_image.save("foo.png")
  File "PIL/Image.py", line 2586, in save
    save_handler(self, fp, filename)
  File "PngImagePlugin.py", line 1371, in _save
    raise OSError(msg) from e
OSError: cannot write mode I as PNG

radarhere avatar Jun 17 '25 14:06 radarhere