如何减少 BufferedImage 的内存占用?

17

我有一个Java程序,它从硬盘中读取jpeg文件并将其用作各种其他事物的背景图像。 图像本身存储在BufferedImage对象中,如下所示:

BufferedImage background
background = ImageIO.read(file)

这样做效果很好 - 问题在于BufferedImage对象本身非常庞大。例如,一个215k的jpeg文件变成了一个超过4兆的BufferedImage对象。所涉及的应用程序可能会加载一些相当大的背景图像,但是虽然JPEG文件从未超过1或2兆字节,用于存储BufferedImage的内存可以很快超过数百兆字节。

我认为这一切都是因为图像被作为原始RGB数据存储在RAM中,没有经过任何压缩或优化。

是否有一种方法可以将图像以更小的格式存储在RAM中呢?由于我在CPU方面拥有更多的松弛余地而不是RAM,因此略微减慢性能以使图像对象的大小接近于JPEG压缩值是非常值得的。

6个回答

13

我的一个项目会在读取图片流时实时对图像进行下采样。这种下采样可以将图像的尺寸缩小到所需的宽度和高度,而不需要进行昂贵的重置计算或修改磁盘上的图像。

由于我将图像下采样到较小的尺寸,因此还显著降低了显示所需的处理能力和内存。为了进一步优化,我也会将缓冲图像以瓦片的方式渲染...但这已经超出了本讨论的范围。您可以尝试以下方法:

public static BufferedImage subsampleImage(
    ImageInputStream inputStream,
    int x,
    int y,
    IIOReadProgressListener progressListener) throws IOException {
    BufferedImage resampledImage = null;

    Iterator<ImageReader> readers = ImageIO.getImageReaders(inputStream);

    if(!readers.hasNext()) {
      throw new IOException("No reader available for supplied image stream.");
    }

    ImageReader reader = readers.next();

    ImageReadParam imageReaderParams = reader.getDefaultReadParam();
    reader.setInput(inputStream);

    Dimension d1 = new Dimension(reader.getWidth(0), reader.getHeight(0));
    Dimension d2 = new Dimension(x, y);
    int subsampling = (int)scaleSubsamplingMaintainAspectRatio(d1, d2);
    imageReaderParams.setSourceSubsampling(subsampling, subsampling, 0, 0);

    reader.addIIOReadProgressListener(progressListener);
    resampledImage = reader.read(0, imageReaderParams);
    reader.removeAllIIOReadProgressListeners();

    return resampledImage;
  }

 public static long scaleSubsamplingMaintainAspectRatio(Dimension d1, Dimension d2) {
    long subsampling = 1;

    if(d1.getWidth() > d2.getWidth()) {
      subsampling = Math.round(d1.getWidth() / d2.getWidth());
    } else if(d1.getHeight() > d2.getHeight()) {
      subsampling = Math.round(d1.getHeight() / d2.getHeight());
    }

    return subsampling;
  }

要从文件中获取ImageInputStream,请使用:

ImageIO.createImageInputStream(new File("C:\\image.jpeg"));

可以看到,这个实现方式也尊重图像的原始宽高比。您可以选择注册一个IIOReadProgressListener,以便跟踪已经读取了图像的多少。如果正在通过网络读取图像,则这非常有用,可以用来显示进度条...但不是必需品,您也可以指定为null。

为什么这对您的情况特别重要?它从不将整个图像读入内存,只读取所需要的部分以使其以所需的分辨率显示。对于巨大的图像,甚至那些在磁盘上达到数十MB的图像,它都能够很好地工作。


这很有用,是一个不错的快速解决方案。但也有一些缺点:它只适用于图像需要缩小2倍或更多的情况(因为采用了简单的算法来最多采样每个源列的另一半),我还注意到在一小部分示例图像中存在一些质量下降的问题。 - Dan Gravell

4
我认为这一切都是因为图像被存储在RAM中作为原始RGB数据,没有进行任何压缩或优化。确切地说,一个1920x1200的JPG图像可以在内存中占用300 KB的空间,而在典型的RGB + alpha,每个组件8位(因此每个像素32位)的情况下,在内存中它将占用更多空间。
1920 x 1200 x 32 / 8 = 9 216 000 bytes 

所以,你的300 KB文件变成了一张需要近9 MB RAM的图片(请注意,这取决于您从Java使用的图像类型以及JVM和操作系统,有时可能是GFX卡RAM)。
如果您想将图片用作1920x1200桌面的背景,则在内存中不需要比该尺寸更大的图片(除非您想要一些特殊效果,如子RGB抽样/颜色反锯齿等)。
所以你有两个选择:
1. 在磁盘上使文件宽度和高度(以像素为单位)更小。 2. 实时减小图像大小。
我通常选择第二种方法,因为在硬盘上减小文件大小意味着您正在失去细节(1920x1200的图片比3940x2400的图片少了一些细节:通过缩小它来“丢失信息”)。
现在,Java在处理如此大的图片方面表现得相当差(无论是从性能角度,内存使用角度还是质量角度[*])。在过去,我会从Java中调用ImageMagick来首先调整磁盘上的图片大小,然后加载调整大小的图像(适合我的屏幕大小)。
现在有Java桥接/ API可直接与ImageMagick进行交互。
[*]首先,使用Java内置API缩小图像的速度和质量绝不可能像ImageMagick提供的那样快,质量也不会那么好。

2

使用imgscalr:

http://www.thebuzzmedia.com/software/imgscalr-java-image-scaling-library/

为什么要使用它?

  1. 遵循最佳实践
  2. 非常简单易懂
  3. 支持插值和抗锯齿
  4. 避免自己编写缩放库

代码:

BufferedImage thumbnail = Scalr.resize(image, 150);

or

BufferedImage thumbnail = Scalr.resize(image, Scalr.Method.SPEED, Scalr.Mode.FIT_TO_WIDTH, 150, 100, Scalr.OP_ANTIALIAS);

此外,在将较大的图像转换后,请使用image.flush()来帮助优化内存利用。

4
Scalr需要将原始图像完全加载为BufferedImage(即变量image)吗?我正在使用imgscalr,但我的原始图像太大了,无法开始处理。 - Kelly

2
你是否必须使用BufferedImage?你能否编写自己的Image实现,将jpg字节存储在内存中,并根据需要转换为BufferedImage,然后丢弃它?结合一些显示感知逻辑(在将图像存储为jpg字节数组之前使用JAI重新缩放图像),这将比每次解码大型jpg更快,并且比当前情况下的占用空间更小(除了处理内存要求)。

1

JPG文件在磁盘上的大小完全无关紧要
文件的像素尺寸才是重点。如果您的图像是1500万像素,那么需要大量的RAM来加载未经压缩的原始版本。
将图像尺寸重新调整到所需的大小,这是您能做的最好的事情,而不必转换为色彩空间较少的表示。


1
棒棒糖:非常抱歉,但是 a) 你没有回答问题(因此得到了 -1),而且 b) 你说这个问题是“无关紧要”的评论是极其误导人的(很遗憾我不能给予 -2)。显然,原帖的问题是某种图像上的JPEG压缩非常高效,因此他对于压缩的JPEG / 未压缩的(A)RGB大小差异感到惊讶。你没有以任何方式提供帮助。 - SyntaxT3rr0r
4
源文件的大小并不重要。就像尝试阅读已压缩的1GB TXT文件一样,压缩文件的大小是无关紧要的。一个磁盘文件的大小与显示未压缩图像所需的内存量__没有任何关系__。 - user177800
1
答案就在那里,将图像调整为较小的尺寸。 - user177800

0
你可以将图像的像素复制到另一个缓冲区中,看看是否比BufferedImage对象占用更少的内存。大概是这样的:
BufferedImage background = new BufferedImage(
   width, 
   height, 
   BufferedImage.TYPE_INT_RGB
);

int[] pixels = background.getRaster().getPixels(
    0, 
    0, 
    imageBuffer.getWidth(), 
    imageBuffer.getHeight(), 
    (int[]) null
);

很棒的代码示例。您介意添加一些换行符以避免滚动吗? - Dinah

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接