由于对资源或网速的要求,在手机游戏或一般的网页游戏中,希望能对图片进最大可能的压缩,以节省资源。最近公司做的项目也有对这方面的需求,于是我在网上 逛了半天,希望能发现现成版的Java方法可以使用(用程序来压缩而不借助于工具,要不然2万多张的图片你想累死人?虽然PS有批量功能,它却无法按原来 的路径存放);失望的是,好像没发现什么能直接使用代码,哪怕是提个解决方案也很少。既然网上找不到合适的,那就自己动手,丰衣足食。
关于PNG图片的格式我在此就不多说,图片压缩方面的理论知识我也不在这多此一举,网上资料一大堆。开门见山,我们的目标是怎样用Java把PNG图片尽最大可能的压缩;当然,不能看出明显的失真。
一:BufferedImage类
在Java中,关于图片处理我们自然而然的想到了BufferedImage类,深入了解它,你会发现其实Java已经帮我们做好了图片压缩了,只是压缩完的图片和我们的需求有一点点偏差.......先看看BufferedImage最常用的构造方法:
public BufferedImage(int width,int height,int imageType);
构造一个类型为预定义图像类型之一的 BufferedImage,其中imageType有以下几种:
BufferedImage.TYPE_INT_RGB:8 位 RGB 颜色分量,不带alpha通道。
BufferedImage.TYPE_INT_ARGB:8 位 RGBA 颜色分量,带alpha通道。
BufferedImage.TYPE_INT_ARGB_PRE:8 位 RGBA 颜色分量,已预乘以 alpha。
BufferedImage.TYPE_INT_BGR:8 位 RGB 颜色分量Windows 或 Solaris 风格的图像,不带alpha通道。
BufferedImage.TYPE_3BYTE_BGR:8位GBA颜色分量,用3字节存储Blue、Green和Red三种颜色,不存在alpha。
BufferedImage.TYPE_4BYTE_ABGR:8位RGBA颜色分量,用3字节存储Blue、Green和Red三种颜色以及1字节alpha。
BufferedImage.TYPE_4BYTE_ABGR_PRE:具有用3字节存储的Blue、Green和Red三种颜色以及1字节alpha。
BufferedImage.TYPE_USHORT_565_RGB:具有5-6-5RGB颜色分量(5位Red、6位Green、5位Blue)的图像,不带alpha。
BufferedImage.TYPE_USHORT_555_RGB:具有5-5-5RGB颜色分量(5位Red、5位Green、5位Blue)的图像,不带alpha。
BufferedImage.TYPE_BYTE_GRAY:表示无符号byte灰度级图像(无索引)。
BufferedImage.TYPE_USHORT_GRAY:表示一个无符号short 灰度级图像(无索引)。
BufferedImage.TYPE_BYTE_BINARY:表示一个不透明的以字节打包的 1、2 或 4 位图像。
BufferedImage.TYPE_BYTE_INDEXED:表示带索引的字节图像。
其实imageType就是对应着Java内不同格式的压缩方法,编号分别为1-13;下面我们将一张原图用下面的几句代码分别调用不同的参数生成图片看看:
- for(int i=1;i<=13;i++){
- tempImage=new BufferedImage(width, height, i);
- g2D = (Graphics2D) tempImage.getGraphics();
- g2D.drawImage(sourceImage, 0, 0, null);
- ImageIO.write(tempImage, "png", new File("cut/c_com_"+i+".png"));
- }
原图如下,PNG格式,大小24.0KB:
压缩后的图片:
从图片看到,黑白照片最小,不过这不是我们想要,排除;最后一张TYPE_BYTE_INDEXED类型的(其实就是PNG8)是彩色,也不大,但是失真太厉害了,排除;剩下的透明的那几个大小都一样,排除;对比剩下背景不透明的那几张,TYPE_USHORT_555_RGB就是我们要的压缩类型了。
二:555格式的位图
555格式其实是16位位图中的一种。16位位图最多有65536种颜色。每个色素用16位(2个字节)表示。这种格式叫作高彩色,或叫增强型16位色, 或64K色。16位中,最低的5位表示蓝色分量,中间的5位表示绿色分量,高的5位表示红色分量,一共占用了15位,最高的一位保留,设为0。在555格 式下,红、绿、蓝的掩码分别是:0x7C00、0x03E0、0x001F(在BufferedImage源码中也有定义)。
三:进一步处理
从图片效果可以看出,555格式非常接近真彩色了,而图像数据又比真彩图像小的多,非常满足我们的要求。但是我们需要背景是透明的,而用TYPE_USHORT_555_RGB生成的图片背景却是不透明的,自然而然的我们想到了把不透明的背景替换成透明的不就行了。
- /**
- * 将背景为黑色不透明的图片转化为背景透明的图片
- * @param image 背景为黑色不透明的图片(用555格式转化后的都是黑色不透明的)
- * @return 转化后的图片
- */
- private static BufferedImage getConvertedImage(BufferedImage image){
- int width=image.getWidth();
- int height=image.getHeight();
- BufferedImage convertedImage=null;
- Graphics2D g2D=null;
- //采用带1 字节alpha的TYPE_4BYTE_ABGR,可以修改像素的布尔透明
- convertedImage=new BufferedImage(width, height, BufferedImage.TYPE_4BYTE_ABGR);
- g2D = (Graphics2D) convertedImage.getGraphics();
- g2D.drawImage(image, 0, 0, null);
- //像素替换,直接把背景颜色的像素替换成0
- for(int i=0;i<width;i++){
- for(int j=0;j<height;j++){
- int rgb=convertedImage.getRGB(i, j);
- if(isBackPixel(rgb)){
- convertedImage.setRGB(i, j,0);
- }
- }
- }
- g2D.drawImage(convertedImage, 0, 0, null);
- return convertedImage;
- }
其中的isBackPixel(rgb)用于判断当前像素是否为背景像素:
- /**
- * 判断当前像素是否为黑色不透明的像素(-16777216)
- * @param pixel 要判断的像素
- * @return 是背景像素返回true,否则返回false
- */
- private static boolean isBackPixel(int pixel){
- int back[]={-16777216};
- for(int i=0;i<back.length;i++){
- if(back[i]==pixel) return true;
- }
- return false;
- }
经转化后的图片如下:
转化后稍微大了一点,这个可以接受;要命的是带了一个黑色边框。为什么呢?原因很简单,原图中边框部分的像素是介于透明和不透明之间的,而经过555格式压缩后所有像素都变成了布尔透明,也就是说所有的像素要么是透明的要么就是不透明的。
最容易想到的方法就是把边框的像素换成原图边框的像素,关键在于怎么判断当前像素是否为图片的边框像素,这个算法可能得花费你一定的时间,下面只是我想到的一种实现:
- /**
- * 图片压缩
- * @param sourceImage 要压缩的图片
- * @return 压缩后的图片
- * @throws IOException 图片读写异常
- */
- public static BufferedImage compressImage(BufferedImage sourceImage) throws IOException{
- if(sourceImage==null) throw new NullPointerException("空图片");
- BufferedImage cutedImage=null;
- BufferedImage tempImage=null;
- BufferedImage compressedImage=null;
- Graphics2D g2D=null;
- //图片自动裁剪
- cutedImage=cutImageAuto(sourceImage);
- int width=cutedImage.getWidth();
- int height=cutedImage.getHeight();
- //图片格式为555格式
- tempImage=new BufferedImage(width, height, BufferedImage.TYPE_USHORT_555_RGB);
- g2D = (Graphics2D) tempImage.getGraphics();
- g2D.drawImage(sourceImage, 0, 0, null);
- compressedImage=getConvertedImage(tempImage);
- //经过像素转化后的图片
- compressedImage=new BufferedImage(width, height, BufferedImage.TYPE_4BYTE_ABGR);
- g2D = (Graphics2D) compressedImage.getGraphics();
- g2D.drawImage(tempImage, 0, 0, null);
- int pixel[]=new int[width*height];
- int sourcePixel[]=new int[width*height];
- int currentPixel[]=new int[width*height];
- sourcePixel=cutedImage.getRGB(0, 0, width, height, sourcePixel, 0, width);
- currentPixel=tempImage.getRGB(0, 0, width, height, currentPixel, 0, width);
- for(int i=0;i<currentPixel.length;i++){
- if(i==0 || i==currentPixel.length-1){
- pixel[i]=0;
- //内部像素
- }else if(i>width && i<currentPixel.length-width){
- int bef=currentPixel[i-1];
- int now=currentPixel[i];
- int aft=currentPixel[i+1];
- int up=currentPixel[i-width];
- int down=currentPixel[i+width];
- //背景像素直接置为0
- if(isBackPixel(now)){
- pixel[i]=0;
- //边框像素和原图一样
- }else if((!isBackPixel(now) && isBackPixel(bef))
- ||(!isBackPixel(now) && isBackPixel(aft))
- ||(!isBackPixel(now) && isBackPixel(up))
- ||(!isBackPixel(now) &&isBackPixel(down))
- ){
- pixel[i]=sourcePixel[i];
- //其他像素和555压缩后的像素一样
- }else{
- pixel[i]=now;
- }
- //边界像素
- }else{
- int bef=currentPixel[i-1];
- int now=currentPixel[i];
- int aft=currentPixel[i+1];
- if(isBackPixel(now)){
- pixel[i]=0;
- }else if((!isBackPixel(now) && isBackPixel(bef))
- ||(!isBackPixel(now) && isBackPixel(aft))){
- pixel[i]=sourcePixel[i];
- }else{
- pixel[i]=now;
- }
- }
- }
- compressedImage.setRGB(0, 0, width, height, pixel, 0, width);
- g2D.drawImage(compressedImage, 0, 0, null);
- ImageIO.write(cutedImage, "png", new File("cut/a_cut.png"));
- ImageIO.write(tempImage, "png", new File("cut/b_555.png"));
- ImageIO.write(compressedImage, "png", new File("cut/c_com.png"));
- return compressedImage;
- }
其中的cutedImage=cutImageAuto(sourceImage);是对原图进行裁剪,代码如下:
- /**
- * 图片自动裁剪
- * @param image 要裁剪的图片
- * @return 裁剪后的图片
- */
- public static BufferedImage cutImageAuto(BufferedImage image){
- Rectangle area=getCutAreaAuto(image);
- return image.getSubimage(area.x, area.y,area.width, area.height);
- }
- /**
- * 获得裁剪图片保留区域
- * @param image 要裁剪的图片
- * @return 保留区域
- */
- private static Rectangle getCutAreaAuto(BufferedImage image){
- if(image==null) throw new NullPointerException("图片为空");
- int width=image.getWidth();
- int height=image.getHeight();
- int startX=width;
- int startY=height;
- int endX=0;
- int endY=0;
- int []pixel=new int[width*height];
- pixel=image.getRGB(0, 0, width, height, pixel, 0, width);
- for(int i=0;i<pixel.length;i++){
- if(isCutBackPixel(pixel[i])) continue;
- else{
- int w=i%width;
- int h=i/width;
- startX=(w<startX)?w:startX;
- startY=(h<startY)?h:startY;
- endX=(w>endX)?w:endX;
- endY=(h>endY)?h:endY;
- }
- }
- if(startX>endX || startY>endY){
- startX=startY=0;
- endX=width;
- endY=height;
- }
- return new Rectangle(startX, startY, endX-startX, endY-startY);
- }
- /**
- * 当前像素是否为背景像素
- * @param pixel
- * @return
- */
- private static boolean isCutBackPixel(int pixel){
- int back[]={0,8224125,16777215,8947848,460551,4141853,8289918};
- for(int i=0;i<back.length;i++){
- if(back[i]==pixel) return true;
- }
- return false;
- }
改善后得到的图片:
实际上,这种方法只适用于图片颜色分明(边框颜色分明,背景颜色唯一),黑色像素不多的图片。一些比较特殊的图片就得特殊处理了,如以下图片:
原因是黑色不透明像素也是图片实体的一部分,这样就把它替换成白色透明的了。可以把代码改一下,但是图片的大小会增加不少,就是把程序认为是背景颜色的像 素替换成原图片的像素;将compressImage()方法中的第33、43、61行改成 pixel[i]=sourcePixel[i]; 即可。