将 TYPE_INT_RGB 转换为 TYPE_BYTE_GRAY 图像会产生错误的结果

Converting TYPE_INT_RGB to TYPE_BYTE_GRAY image creates wrong result

我正在尝试将 24 位 RGB 格式的灰度图像转换为 8 位格式的灰度图像。换句话说,输入和输出在视觉上应该是相同的,只是通道数发生了变化。这是输入图像:

用于将其转换为 8 位的代码:

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);

这是输出图像:

如您所见,略有不同。但它们应该是相同的。对于那些看不到它的人,这里是 the difference between the two images(当在 Gimp 中使用差异混合模式查看时,全黑表示没有差异)。如果我使用 PNG 作为输入和输出,也会出现同样的问题。

完成 grayImage.setRGB 后,我尝试比较两幅图像中相同像素的颜色值:

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

两者颜色相同。但是,如果我对 Gimp 中的图像进行相同的比较,我会分别得到 0xFF6D6D6D0xFF272727... 巨大的差异。

这里发生了什么?有什么方法可以从灰度 24 位图像中获得相同的 8 位图像?我正在使用 Oracle JDK 1.8 作为记录。

我测试的前两件事,我打印了两张图片。

BufferedImage@544fa968: type = 5 ColorModel: #pixelBits = 24 numComponents = 3 color space = java.awt.color.ICC_ColorSpace@68e5eea7 transparency = 1 has alpha = false isAlphaPre = false ByteInterleavedRaster: width = 400 height = 400 #numDataElements 3 dataOff[0] = 2

BufferedImage@11fc564b: type = 10 ColorModel: #pixelBits = 8 numComponents = 1 color space = java.awt.color.ICC_ColorSpace@394a2528 transparency = 1 has alpha = false isAlphaPre = false ByteInterleavedRaster: width = 400 height = 400 #numDataElements 1 dataOff[0] = 0

我们可以看到图像有不同的颜色space,数据偏移也不同。

并且我用了一个图形在输出上绘制了原图

Graphics g = grayImage.getGraphics();
g.drawImage(rgbImage, 0, 0, null);

这很好用。我把图片保存为png,并不是说它改变了你看到的效果,当我对两张图片进行区别时,它们是一样的。

最重要的是,两种不同图像类型的 rgb 值不同。因此,当您使用 get rgb 看到相同的值时,它们在显示时会被解释为不同的值。

使用图形有点慢,但可以得到正确的图像。

我认为这里的区别是 setRGB/getRGB 以非直观的方式对数据进行操作。

DataBuffer rgbBuffer = rgbImage.getRaster().getDataBuffer();
DataBuffer grayBuffer = grayImage.getRaster().getDataBuffer();

System.out.println(grayBuffer.size() + ", " + rgbBuffer.size() );
for(int i = 0; i<10; i++){
    System.out.println(
        grayBuffer.getElem(i) + "\t"
        + rgbBuffer.getElem(3*i) + ", " 
        + rgbBuffer.getElem(3*i+1) + ", " 
        + rgbBuffer.getElem(3*i + 2) );
}

显示我们预期的数据。 rgb buffer是3x的大小,像素直接对应。

160000, 480000
255 255, 255, 255
255 255, 255, 255
254 254, 254, 254
253 253, 253, 253
252 252, 252, 252
252 252, 252, 252
251 251, 251, 251
251 251, 251, 251
250 250, 250, 250
250 250, 250, 250

当我们检查对应的rgb值时。

for(int i = 0; i<10; i++){
    System.out.println( 
        Integer.toHexString( grayImage.getRGB(i, 0) ) + ", "
        +  Integer.toHexString( rgbImage.getRGB(i, 0) ) + "  " );
}

ffffffff, ffffffff
ffffffff, ffffffff
ffffffff, fffefefe
fffefefe, fffdfdfd
fffefefe, fffcfcfc
fffefefe, fffcfcfc
fffdfdfd, fffbfbfb
fffdfdfd, fffbfbfb
fffdfdfd, fffafafa
fffdfdfd, fffafafa

所以要使图像正确,它必须具有不同的 rgb 值。

我深入研究了 Open JDK 实现并发现了这个:

调用setRGB时,图像颜色模型会修改值。在这种情况下,应用了以下公式:

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);

这基本上是试图找到给定颜色的亮度来找到它的灰度。但是我的值已经是灰色的,这应该给出相同的灰色阴影,对吧? 0.2125 + 0.7154 + 0.0721 = 1 因此输入 0xFF1E1E1E 应该会产生 0xFE 的灰度值。

除此之外,使用的 fromsRGB8LUT16 数组不会线性映射值...这是我绘制的图:

所以0xFF1E1E1E的输入实际上得到了0x03的灰度值!我不完全确定为什么它不是线性的,但它肯定解释了为什么我的输出图像与原始图像相比如此暗。

使用 Graphics2D 适用于我给出的示例。但是这个例子已经被简化了,实际上我需要调整一些值,所以我不能使用 Graphics2D。这是我找到的解决方案。我们完全避免颜色模型重新映射值,而是直接在栅格上设置它们。

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);

为什么这行得通? TYPE_BYTE_ARRAY 类型的图像具有 ByteInterleavedRaster 类型的光栅,其中数据存储在 byte[] 中,每个像素值占用一个字节。在栅格上调用 setPixels 时,传递的数组的值被简单地转换为一个字节。所以 0xFF1E1E1E 实际上变成了 0x1E (只保留最低位),这就是我想要的。

编辑:我刚看到 this question 显然非线性只是标准公式的一部分。