pnggroup / libpng

LIBPNG: Portable Network Graphics support, official libpng repository
http://libpng.sf.net
Other
1.25k stars 612 forks source link

Gamma of 16-bit images with no colorspace metadata #515

Closed wareya closed 8 months ago

wareya commented 8 months ago

I'm writing my own PNG implementation from scratch, and I'm automatically testing my decoder against libpng's using the highest-level interface (code at bottom of post). However, when loading 16-bit images that have no gAMA, sRGB, or other colorspace chunks, and asking libpng to load them as 8-bit srgb, libpng seems to apply a linear-to-gamma-compressed conversion:

decoding contrib/testpngs/gray-16.png
libpng:
00 14 1C 21 26 2A 2E 31 35 38 3A ...
vs
my decoder:
00 01 02 03 04 05 06 07 08 09 0A ...

From what I understand, reading the PNG spec, it never says that 16-bit images with no colorspace metadata are linear, and it implies throughout that 16-bit data is proportional to 8-bit data by a simple multiplication factor of 257 (i.e. 0xFFFF/0xFF). Is this a libpng bug, or a spec bug?

Here's the code I'm invoking libpng with:


#ifdef TEST_VS_LIBPNG
        // skip test if original image has gamma and was 16 bit
        if (!(output.was_16bit && output.gamma != -1.0))
        {
            png_image image;
            memset(&image, 0, sizeof(image));
            image.version = PNG_IMAGE_VERSION;

            assert(png_image_begin_read_from_memory(&image, raw_data, file_len) != 0);

            uint8_t components = output.bytes_per_pixel / (output.is_16bit + 1);
            if (components == 1)
                image.format = PNG_FORMAT_GRAY;
            if (components == 2)
                image.format = PNG_FORMAT_GA;
            if (components == 3)
                image.format = PNG_FORMAT_RGB;
            if (components == 4)
                image.format = PNG_FORMAT_RGBA;

            png_bytep buffer;
            size_t libpng_size = PNG_IMAGE_SIZE(image);
            if (libpng_size != output.size)
            {
                printf("%zu %zu (%d (%d %d))\n", libpng_size, output.size, components, output.bytes_per_pixel, output.is_16bit + 1);
                assert(libpng_size == output.size);
            }
            buffer = malloc(libpng_size);

            assert(png_image_finish_read(&image, NULL, buffer, 0, NULL) != 0);

            size_t good_count = 0;
            if (output.gamma == -1.0)
            {
                while (good_count < output.size && output.data[good_count] == buffer[good_count])
                    good_count += 1;
            }
            else
            {
                while (good_count < output.size && abs((int16_t)(uint16_t)output.data[good_count] - (int16_t)(uint16_t)buffer[good_count]) <= 1)
                    good_count += 1;
            }
            if (good_count != output.size)
            {
                for (size_t i = 0; i < libpng_size && i < 512; i += 1)
                    printf("%02X ", buffer[i]);
                puts("");
                for (size_t i = 0; i < libpng_size && i < 512; i += 1)
                    printf("%02X ", output.data[i]);
                puts("");

                printf("%d\n", output.is_16bit);

                printf("%d\n", components);
                printf("%zu %zu\n", good_count, output.size);
                printf("vals: %02X %02X\n", output.data[good_count], buffer[good_count]);
                //assert(good_count == output.size);
            }

            free(buffer);
        }
#endif // TEST_VS_LIBPNG
jbowler commented 8 months ago

libpng seems to apply a linear-to-gamma-compressed conversion

BY DESIGN

This is the behavior of the "simplified" API; the API you are using. It's nothing to do with the PNG spec; this is what the API does in the absence of explicit information. Likewise 8-bit is assumed to be sRGB. See "Section 5: SIMPLIFIED API" around line 2618 of png.h (like it says I don't believe the complete documentation got into libpng-manual.txt, but it may be there too now.)

So the simplified write API outputs linear data if 16 bit (in-memory) input is supplied and sRGB data if 8-bit input is supplied. The read API inputs any PNG file (this is required by the PNG specification) but if no gAMA is given the read side assumes the data matches the API requirements of the write side and the supported in-memory data formats. I.e. the API assumes unqualified data is in the correct format for the app/library which is using the simplified API.

This is what the PNG spec means in "13.13 Decoder gamma handling", in italics:

When the incoming image has unknown gamma (gAMA, sRGB, and iCCP all absent), choose a likely default gamma value, but allow the user to select a new one if the result proves too dark or too light.

You will note that the condition suggesting allowing the "user to select a new one if the result proves too dark or too light" is hardly ever implemented; how do you change the gamma of an image on a web page you are viewing? As png.h says right at the top of section 5 if you want different processing you should use one of the other APIs, one which allows setting image and output gamma values by png_set_gamma. More generally if you know the gamma of the input images then you must use one of the APIs which allows you to control the input gamma (png_set_gamma) or:

You can also insert a gAMA chunk into the input stream and still use the simplified API. In other words edit the original PNG to supply the missing information.

Bear in mind that if a 16-bit PNG is gamma encoded it is quite possible that it requires extensive processing. For example the data may have been encoded with a non-zero black point (i.e. black is a positive number in the encoding) or it may contain a very large gamma encoding, e.g. 1/30, to allow representation of a greater range of intensity values (as in HDR images) and in those cases the data cannot be reduced to an 8 bit encoding in a lossless or near lossless fashion.

wareya commented 8 months ago

Okay, that makes sense! I'm not sure that linear is the "like default gamma value" for 16-bit images, but I imagine linear 16-bit images are common enough in certain fields that it's a reasonable assumption, especially since those are the kinds of use cases that tend to need 16 bit images (rather than merely benefiting from them).

I double-checked the manual, and there seems to be a flag that solves my problem: PNG_IMAGE_FLAG_16BIT_sRGB. Testing seems to confirm that it works, so I'll leave this here in case someone with the same problem finds this issue on a search engine.

jbowler commented 8 months ago

Ha. I had forgotten about that; there was a bug in the original code (1.6.0) whereby the "default" in absence of gAMA was determined by the requested output (memory) format, not the PNG file format. See commit 59ae38984f It meant requesting linear data would treat the input as linear and requesting sRGB data would treat the input as sRGB, which makes no sense given the API. The flag was a fixup for app compatibility. The problem of using it is that two different apps will have two different displays for the same data, hence the comment on the flag about exposing it to the user (as in the PNG spec.)

All files written by the simplified API should have a gAMA chunk including, I believe, the ones that have an sRGB chunk.