Search code examples
javabufferedimage

Some operations (Convolve, AffineTransform) on BufferedImage with 16 bit per channel result in garbled data


Some operations on BufferedImages with 16 bit per channel result in images with random colored pixels. Is it possible to avoid this problem?

I see the problem at least with

  • ConvolveOp
  • AffineTransformOp with INTERPOLATION_BICUBIC on images with alpha channel

Sample code:

Kernel kernel = new Kernel(2, 2, new float[] { 0.25f, 0.25f, 0.25f, 0.25f });
ConvolveOp blurOp = new ConvolveOp(kernel, ConvolveOp.EDGE_NO_OP, null);
img = blurOp.filter(img, null);

Input: sample image output image: sample with random colored pixels

The operations work fine when the image is 8 bit per channel.

I tried to convert the image from 16 to 8 bit per channel while keeping the color profile using the following code but this also results in a garbled image.

private static BufferedImage changeTo8BitDepth(BufferedImage bi) {
    ColorModel cm = bi.getColorModel();
    boolean hasAlpha = cm.hasAlpha();
    boolean isAlphaPre = cm.isAlphaPremultiplied();
    int transferType = DataBuffer.TYPE_BYTE;
    int transparency = cm.getTransparency();
    ColorSpace cs = cm.getColorSpace();
    ColorModel newCm = new ComponentColorModel(cs, hasAlpha, isAlphaPre, transparency, transferType);
    WritableRaster newRaster = newCm.createCompatibleWritableRaster(bi.getWidth(), bi.getHeight());
    BufferedImage newBi = new BufferedImage(newCm, newRaster, isAlphaPre, null);
    // convert using setData
    newBi.setData(bi.getRaster());
    return newBi;
}

(It is possible to use ColorConvertOp to convert to an 8-bit sRGB image but I need the non-sRGB color profile.)

I tested on Java 8, 11, and 17 on macOS and Linux. For full source code and images for tests see https://github.com/robcast/java-imaging-test (class Test16BitColor)


Solution

  • After som testing and research, I think the fact that ConvolveOp and AffineTransformOp doesn't work with 16 bits/sample (TYPE_USHORT data type) images out of the box, is a JDK bug. It might be that the underlying native code only works with 8 bits/sample images, but in that case "Op"s should throw an exception (or perhaps add a slower, but correct Java fallback code path). You might want to report that to the OpenJDK community.


    For the 16 to 8 bits/sample conversion, the problem is you can't set 16 bit values into an 8 bit buffer, as there's no normalization done on the samples. I guess you'll just end up with the lower 8 bits of the 16 bits sample, which will typically look like static/noise. This can be fixed, however.

    Here's a version that will convert the values correctly to 8 bit, but otherwise keep the color space/color profile unchanged:

    private static BufferedImage changeTo8BitDepth(BufferedImage original) {
        ColorModel cm = original.getColorModel();
    
        // Create 8 bit color model
        ColorModel newCM = new ComponentColorModel(cm.getColorSpace(), cm.hasAlpha(), cm.isAlphaPremultiplied(), cm.getTransparency(), DataBuffer.TYPE_BYTE);
        WritableRaster newRaster = newCM.createCompatibleWritableRaster(original.getWidth(), original.getHeight());
        BufferedImage newImage = new BufferedImage(newCM, newRaster, newCM.isAlphaPremultiplied(), null);
    
        // convert using createGraphics/dawImage
        Graphics2D graphics = newImage.createGraphics();
        try {
            graphics.drawImage(original, 0, 0, null);
        }
        finally {
            graphics.dispose();
        }
    
        return newImage;
    }
    

    If you prefer conversion using rasters only, it's also possible with some hacks:

    private static BufferedImage changeTo8BitDepth(BufferedImage original) {
        ColorModel cm = original.getColorModel();
    
        // Create 8 bit color model
        ColorModel newCM = new ComponentColorModel(cm.getColorSpace(), cm.hasAlpha(), cm.isAlphaPremultiplied(), cm.getTransparency(), DataBuffer.TYPE_BYTE);
        WritableRaster newRaster = newCM.createCompatibleWritableRaster(original.getWidth(), original.getHeight());
        BufferedImage newImage = new BufferedImage(newCM, newRaster, newCM.isAlphaPremultiplied(), null);
    
        // convert using setData
        // newImage.setData(as8BitRaster(original.getRaster())); // Works
        newRaster.setDataElements(0, 0, as8BitRaster(original.getRaster())); // Faster, requires less conversion
    
        return newImage;
    }
    
    private static Raster as8BitRaster(WritableRaster raster) {
        // Assumption: Raster is TYPE_USHORT (16 bit) and has PixelInterleavedSampleModel
        PixelInterleavedSampleModel sampleModel = (PixelInterleavedSampleModel) raster.getSampleModel();
    
        // We'll create a custom data buffer, that delegates to the original 16 bit buffer
        final DataBuffer buffer = raster.getDataBuffer();
    
        return Raster.createInterleavedRaster(new DataBuffer(DataBuffer.TYPE_BYTE, buffer.getSize()) {
            @Override public int getElem(int bank, int i) {
                return buffer.getElem(bank, i) >>> 8; // We only need the upper 8 bits of the 16 bit sample
            }
    
            @Override public void setElem(int bank, int i, int val) {
                throw new UnsupportedOperationException("Raster is read only!");
            }
        }, raster.getWidth(), raster.getHeight(), sampleModel.getScanlineStride(), sampleModel.getPixelStride(), sampleModel.getBandOffsets(), new Point());
    }