Search code examples
javajpegbufferedimagejavax.imageiocolor-channel

Converting TYPE_INT_RGB to TYPE_BYTE_GRAY image creates wrong result


I'm trying to convert a grayscale image in 24-bit RGB format to a grayscale image in 8-bit format. In other words, input and output should be visually identical, only the number of channels changes. Here's the input image:

input

The code used to convert it to 8-bit:

File input = new File("input.jpg");
File output = new File("output.jpg");

// Read 24-bit RGB input JPEG.
BufferedImage rgbImage = ImageIO.read(input);
int w = rgbImage.getWidth();
int h = rgbImage.getHeight();

// Create 8-bit gray output image from input.
BufferedImage grayImage = new BufferedImage(w, h, BufferedImage.TYPE_BYTE_GRAY);
int[] rgbArray = rgbImage.getRGB(0, 0, w, h, null, 0, w);
grayImage.setRGB(0, 0, w, h, rgbArray, 0, w);

// Save output.
ImageIO.write(grayImage, "jpg", output);

And here's the output image:

output

As you can see, there's a slight difference. But they should be identical. For those who can't see it, here's the difference between the two images (when viewed with Difference blending mode in Gimp, full black would indicate no difference). The same problem happens if I use PNG instead for input and output.

After doing grayImage.setRGB, I tried comparing color values for the same pixel in both images:

int color1 = rgbImage.getRGB(230, 150);  // This returns 0xFF6D6D6D.
int color2 = grayImage.getRGB(230, 150);  // This also returns 0xFF6D6D6D.

Same color for both. However, if I do the same comparison with the images in Gimp, I get 0xFF6D6D6D and 0xFF272727 respectively... huge difference.

What's happening here? Is there any way I can obtain an identical 8-bit image from a grayscale 24-bit image? I'm using Oracle JDK 1.8 for the record.


Solution

  • I dived a little into Open JDK implementation and found this:

    When calling setRGB, values are modified by the image color model. In this case, the following formula was being applied:

    float red = fromsRGB8LUT16[red] & 0xffff;
    float grn = fromsRGB8LUT16[grn] & 0xffff;
    float blu = fromsRGB8LUT16[blu] & 0xffff;
    float gray = ((0.2125f * red) +
                  (0.7154f * grn) +
                  (0.0721f * blu)) / 65535.0f;
    intpixel[0] = (int) (gray * ((1 << nBits[0]) - 1) + 0.5f);
    

    This basically tries to find the luminosity of a given color to find its gray shade. But with my values already being gray, this should give the same gray shade, right? 0.2125 + 0.7154 + 0.0721 = 1 so with an input of 0xFF1E1E1E should result in a gray value of 0xFE.

    Except, the fromsRGB8LUT16 array used doesn't map values linearly... Here's a plot I made:

    enter image description here

    So an input of 0xFF1E1E1E actually results in a gray value of 0x03! I'm not entirely sure why it's not linear, but it certainly explains why my output image was so dark compared with the original.

    Using Graphics2D works for the example I gave. But this example had been simplified and in reality I needed to tweak some values, so I can't used Graphics2D. Here's the solution I found. We completely avoid the color model remapping the values and instead sets them directly on the raster.

    BufferedImage grayImage = new BufferedImage(w, h, BufferedImage.TYPE_BYTE_GRAY);
    int[] rgbArray = buffImage.getRGB(0, 0, w, h, null, 0, w);
    grayImage.getRaster().setPixels(0, 0, w, h, rgbArray);
    

    Why does this work? An image of type TYPE_BYTE_ARRAY has a raster of type ByteInterleavedRaster where data is stored in byte[] and each pixel value take a single byte. When calling setPixels on the raster, the values of the passed array are simply cast to a byte. So 0xFF1E1E1E effectively becomes 0x1E (only lowest bits are kept), which is what I wanted.

    EDIT: I just saw this question and apparently the non linearity is just part of the standard formula.