使用javacv给报表图片去白边并打包上线

环境

java1.8.0_191、javacv1.5.2、opencv4.1.2、spring boot 1.5.10、centOS7.2 x64

问题描述

注意:前面是解决问题的一个过程描述,如果想看javacv、linux上线打包的重点部分就直接跳到最后的问题解决中第二种思路

业务场景是将一些报表图片通过彩信发送到手机,因为是发送彩信,所以对每张图片的大小有很大的限制。这里是保存文本表格,它们边缘清晰,有大块相同颜色区域,所以使用了png的图片格式压缩效果是最好的,多一种色彩、图片位深度不同都会导致图片大小的不同。
业务代码大致逻辑是使用httpClient获取到网页table后,通过HtmlImageGenerator这个工具解析html生成png的,然后把png文件发送彩信

        <dependency>
            <groupId>gui.ava</groupId>
            <artifactId>html2image</artifactId>
            <version>0.9</version>
        </dependency>

这种html转img的包有很多,都是大同小异的,使用起来也很方便,但是存在一些坑,比如linux上部署需要字体文件,还有现在在比较宽的图片上会有bug的问题。
在这里插入图片描述
可以看出图片下面有大片白色的区域,十几张图片里面会有几张就是这样的,具体的没有深入分析,感觉矮胖矮胖的图片容易出现这种情况。。。
它是通过Dimension prefSize = editorPane.getPreferredSize();来获取宽高的,getPreferredSize方法计算的高度不准确,导致生成图片有很长空白部分。editorPane是javax.swing里面的,由于对这块不熟悉,所以想到用其他的方式来解决问题。

问题解决

第一种思路

想到它是一个表格,每行的高度是固定的,只需要解析这段html,就可以通过tr的数量*高度来算出总高度,再加上header和footer就行了

				HttpEntity entity = response.getEntity();
                String content = EntityUtils.toString(entity, "utf-8");
                // 通过jsoup解析html,获取dom节点
                Document document = Jsoup.parse(content);
                Element main = document.getElementById("main");
                Elements trList = main.getElementsByTag("tr");
                // head + foot + offset
                int height = 17 + 36 + 167;
                for (Element postItem : trList) {
    
    
                    height += 17;
                }
                // table -> png
                HtmlImageGenerator imageGenerator =HtmlImageGenerator();
                // 重新设定宽高
                imageGenerator.setSize(new Dimension(imageGenerator.getSize().width, height));
                imageGenerator.loadHtml(content);
                // 生成图片
                BufferedImage image = imageGenerator.getBufferedImage();

在这里插入图片描述
这样修改之后,再把固定高度减小一点确实能够贴到底部,达到去白边的效果,下面的白色是一种透明色,在手机上看不是很明显。
好景不长,后面又增加了合并单元格行列的功能,head也不是固定的,这就不好计算高度,把固定高度设置高一点点,最后图片数量的增多,超出彩信内容大小了。
解决方式是使用了24位的色深来节约空间,结果确实是节约了40%左右的大小

		// 这里将色彩空间RGB -> BGR
//      BufferedImage img = new BufferedImage(prefSize.width, editorPane.getHeight(), BufferedImage.TYPE_INT_ARGB);
        BufferedImage img = new BufferedImage(prefSize.width, editorPane.getHeight(), BufferedImage.TYPE_3BYTE_BGR);

但是这样就引发了一个问题,因为色深从32->24,少了alpha通道,就是没有了透明色,原来下面的白色变成了黑色,放在手机上看就很明显,所以问题还是回到了精确计算高度的问题上。

第二种思路

使用了opencv框架,它是著名的计算机视觉库,程序里用的是javacv(封装了opencv等一系列框架,基于javacpp这个框架,通过JNI调用的动态链接库)
javacv的github地址
大致就是通过gray->canny->contours->cut方法截取了图片中最大的矩形(最小外包矩形),也就是去除了超长的边框,达到了精确计算宽高的效果。
下面开始实操
首先引入maven依赖

        <!-- https://mvnrepository.com/artifact/org.bytedeco/javacv-platform -->
        <!-- -Djavacpp.platform.custom -Djavacpp.platform.host -Djavacpp.platform.linux-x86_64 -Djavacpp.platform.windows-x86_64 -->
        <!-- references: https://github.com/bytedeco/javacpp-presets/wiki/Reducing-the-Number-of-Dependencies-->
        <dependency>
            <groupId>org.bytedeco</groupId>
            <artifactId>javacv-platform</artifactId>
            <version>1.5.2</version>
        </dependency>

这个版本号有个对应关系
在这里插入图片描述
点开maven依赖,可以看出javacv-platform与opencv版本的对应关系,根据这个对应关系可以找出需要的opencv版本号,方面后面引入动态链接库。图中1.5.3对应opencv4.3.0,我们这里用的1.5.2版本,对应的opencv4.1.2

这里多提一点,这个javacv-platform集成了一堆框架(ffmpeg、openblas、flycapture、opencv等等,详细的看javacv-platform的pom依赖),如果要精简打包体积,可以把不需要的框架排除掉

核心的是处理图片的逻辑

import lombok.extern.slf4j.Slf4j;
import org.bytedeco.javacv.Java2DFrameConverter;
import org.bytedeco.javacv.OpenCVFrameConverter;
import org.bytedeco.opencv.opencv_core.*;
import org.opencv.imgproc.Imgproc;

import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;

import static org.bytedeco.opencv.global.opencv_imgproc.*;

@Slf4j
public class CVTool {
    
    

    /**
     * BufferImage转byte[]
     *
     * @param original
     * @return
     */
    public static byte[] bufImg2Bytes(BufferedImage original) {
    
    
        ByteArrayOutputStream bStream = new ByteArrayOutputStream();
        try {
    
    
            ImageIO.write(original, "png", bStream);
        } catch (IOException e) {
    
    
            log.error("", e);
            throw new RuntimeException("bugImg读取失败:" + e.getMessage(), e);
        }
        return bStream.toByteArray();
    }

    /**
     * byte[]转BufferImage
     *
     * @param imgBytes
     * @return
     */
    public static BufferedImage bytes2bufImg(byte[] imgBytes) {
    
    
        BufferedImage tagImg = null;
        try {
    
    
            tagImg = ImageIO.read(new ByteArrayInputStream(imgBytes));
            return tagImg;
        } catch (IOException e) {
    
    
            log.error("", e);
            throw new RuntimeException("bugImg写入失败:" + e.getMessage(), e);
        }
    }

    /**
     * BufferedImage 转 mat
     * 参考https://github.com/bytedeco/javacv-examples/blob/master/OpenCV_Cookbook/src/main/scala/opencv_cookbook/OpenCVUtils.scala
     *
     * @param original
     * @return
     */
    public static Mat bufImg2Mat(BufferedImage original) {
    
    
        OpenCVFrameConverter.ToMat openCVConverter = new OpenCVFrameConverter.ToMat();
        Java2DFrameConverter java2DConverter = new Java2DFrameConverter();
        Mat mat = openCVConverter.convert(java2DConverter.convert(original));
        return mat;
    }

    /**
     * mat转BufferedImage
     * 参考https://github.com/bytedeco/javacv-examples/blob/master/OpenCV_Cookbook/src/main/scala/opencv_cookbook/OpenCVUtils.scala
     *
     * @param matrix
     * @return
     */
    public static BufferedImage mat2BufImg(Mat matrix) {
    
    
//        Mat tempMat=new Mat();
//        cvtColor(matrix,tempMat,COLOR_RGB2BGR555);
        // table->png那一步是BufferedImage.TYPE_3BYTE_BGR
        OpenCVFrameConverter.ToMat openCVConverter = new OpenCVFrameConverter.ToMat();
        Java2DFrameConverter java2DConverter = new Java2DFrameConverter();
        return java2DConverter.convert(openCVConverter.convert(matrix));
    }

    public static BufferedImage cutWhite(Mat matrix) {
    
    
        Mat grayMat = matrix.clone();
        // 转灰度
        cvtColor(matrix, grayMat, Imgproc.COLOR_BGR2GRAY);
        // canny化
        Mat cannyOutput = matrix.clone();
        // 参数: image:单通道灰度;edges:单通道黑白;threshold1、2:高低阈值;apertureSize:Sobel 算子大小
        Canny(grayMat, cannyOutput, 5, 10, 3, false);
        // 发现canny后边缘边框断开导致截取的矩形错误,这里膨胀边缘使得边框连接
        dilate(cannyOutput, cannyOutput, new Mat());
        // 保存边缘的向量
        MatVector contours = new MatVector();
        /* 参数(详细的请看文档):
           image: 单通道图像矩阵,可以是灰度图,但更常用的是二值图像,
                  一般是经过Canny、Laplace等边缘检测算子处理过的二值图像
           contours: 定义为“vector<vector<Point>> contours”,向量内每个元素保存了一组由连续的Point点构成的点的集合的向量,
                     每一组Point点集就是一个轮廓。有多少轮廓,向量contours就有多少元素
           hierarchy: 定义为“vector<Vec4i> hierarchy”,一个“向量内每一个元素包含了4个int型变量”的向量,
                      分别表示第i个轮廓的后一个轮廓、前一个轮廓、父轮廓、内嵌轮廓的索引编号。
           mode: 轮廓的检索模式,这里RETR_EXTERNAL只检测最外围轮廓,包含在外围轮廓内的内围轮廓被忽略
           method: 定义轮廓的近似方法,这里CHAIN_APPROX_NONE保存物体边界上所有连续的轮廓点到contours向量内
           offset: Point偏移量,所有的轮廓信息相对于原始图像对应点的偏移量,
                   相当于在每一个检测出的轮廓点上加上该偏移量,并且Point还可以是负值
         */
        findContours(cannyOutput, contours, new Mat(), RETR_EXTERNAL, CHAIN_APPROX_NONE, new Point(0, 0));

        // 筛选contours中的轮廓,我们需要最大的那个轮廓,就是筛选最小外包矩形(MBR)
        // 矩形的最小面积
        float minArea = 0;
        Rect bbox = new Rect();
        // 遍历每一个轮廓
        for (int t = 0; t < contours.size(); ++t) {
    
    
            // 找到每一个轮廓的最小外包旋转矩形,RotatedRect里面包含了中心坐标、尺寸以及旋转角度等信息
            RotatedRect minRect = minAreaRect(contours.get(t));
            // 小于10的丢弃
            if (minRect.size().height() <= 10 || minRect.size().width() <= 10) {
    
    
                continue;
            }
            log.info("处理矩形中,计算图片中最大面积的矩形... height:{}, width: {}", minRect.size().height(), minRect.size().width());
            // 筛选最小外包旋转矩形
            if (minRect.size().width() * minRect.size().height() > minArea) {
    
    
                // 记录最大面积,筛选出最大面积
                minArea = minRect.size().width() * minRect.size().height();
                // 定义一个4行2列的单通道float类型的Mat,用来存储旋转矩形的四个顶点
                Mat vertices = new Mat();
                // 计算旋转矩形的四个顶点坐标
                boxPoints(minRect, vertices);
                // 找到输入点集的最小外包直立矩形,返回Rect类型
                bbox = boundingRect(vertices);
            }
        }
        log.info("图像计算完成,最小外包矩形:{}, 宽度: {}, 高度: {}", bbox, bbox.width(), bbox.height());
        // 如果成功截取到了
        if (bbox.width() > 0 && bbox.height() > 0) {
    
    
            // 从原图中截取兴趣区域
            Mat roiImg = matrix.apply(bbox);
            return mat2BufImg(roiImg);
        }
        // 否则返回原图
        return mat2BufImg(matrix);
    }
}

这就是切图片白边的主要方法,期间遇到过一些问题,这里记录记录
在这里插入图片描述
在表头为白色背景,然后findContours提取不到正确的最大轮廓,发现提取的是里面的内容,把表头和页脚搞掉了。改成蓝色背景就可以,这就很疑惑,开始慢慢调试
在这里插入图片描述
原因是Canny后,外围四边形未闭合,导致无法正确寻找轮廓线,所以这里做了一次膨胀操作

dilate(cannyOutput, cannyOutput, new Mat());

在这里插入图片描述
膨胀之后边缘就连接起来了,就可以正确的寻找轮廓线了。。。

启动程序

注意:使用了javacv-platform的话,它已经把所有平台(win、linux、macos、arm、ios)的动态链接库依赖下载了,无论是线上还是本机,都不需要引入额外的动态链接库文件了,启动时会自动在${user_home}/.javacpp/cache释放这些文件,然后load进来

最后启动的时候需要在官网下载一个dll文件,win版本的,是构建好了的
opencv4.1.2\build\java\x64\opencv_java412.dll

启动的时候增加一个参数: -Djava.library.path=./lib,使其能够找到这个动态链接库

不需要自己去opencv官网下载动态链接库了,用platform自带的最好,以免引发兼容性问题

在启动起来就没有白色的边框了,win下测试成功了,现在可以开始上线了

部署上线

根据 这篇介绍最小化javacv打包的帖子

mvn clean package -DskipTests -Djavacpp.platform.custom -Djavacpp.platform.linux-x86_64

使用这样的mvn命令对spring boot应用进行打包,只针对linux生成javacv的包,大小只有100M左右,如果全量的话就是800多M,里面有兼容各种系统android、linux、win等,实际上部署只需linux的就行了

mvn clean package -DskipTests -Djavacpp.platform.custom -Djavacpp.platform.host -Djavacpp.platform.linux-x86_64

如果想打成jar包本地测试,记得要将当前平台带上,不然会报找不到动态链接库错误

附录

opencv编译

如果想使用opencv原生的JNI调用方式,那么就需要编译opencv

跟win不一样,它需要libopencv_java412.so动态链接库文件,win的是opencv_java412.dll。这个文件是需要自己下载源码编译出来的,不像win可以直接下载编译好的文件用。
在这里插入图片描述
点击下载源码,然后把源码复制到linux虚拟机里

// 首先安装所需要的依赖
yum install -y gcc gcc-c++ make automake ant
// 如果没有安装cmake
wget https://cmake.org/files/v3.12/cmake-3.12.0-rc1.tar.gz
tar -zxvf cmake-3.12.0-rc1.tar.gz
cd cmake-3.12.0-rc1
./bootstrap
gmake
gmake install
// 构建makefile
cd opencv-4.1.2
mkdir build
cd build
cmake -D CMAKE_BUILD_TYPE=RELEASE -D CMAKE_INSTALL_PREFIX=/home/opencv  -D BUILD_DOCS=OFF -D BUILD_EXAMPLES=OFF -D BUILD_TESTS=OFF -D BUILD_PERF_TESTS=OFF -D BUILD_opencv_python=NO -D BUILD_opencv_python2=NO -D BUILD_opencv_python3=NO -DBUILD_SHARED_LIBS=OFF -DBUILD_WITH_STATIC_CRT=ON -DBUILD_TIFF=ON -DBUILD_ZLIB=ON -DBUILD_JASPER=ON -DBUILD_JPEG=ON -DBUILD_PNG=ON -DBUILD_OPENEXR=ON ..
// 然后编译安装
make –j8 (8线程并行编译)
make install

这里cmake命令执行后会显示一个清单,要看一看java模块是否启用,如果没有启用,那么就需要安装java环境,配置JAVA_HOME,安装ant
在这里插入图片描述
在这里插入图片描述
还要注意的是,这是在本地编译安装的opencv,放线上,如果只有libopencv_java412.so文件还不够,可能会说libpng15.so.15、libthai.so.0、libfribidi.so.0等等被libopencv_java412.so依赖的链接库文件找不到,并且把这些文件放进-Djava.library.path=./lib目录下也还是没用, 因为线上的权限是被限制的,也不能去改一些配置和安装软件,所以这里就用了个粗暴的方法,将libopencv_java412.so需要的链接库文件通过System.load全部引入
在这里插入图片描述
通过ldd命令可以看到libopencv_java412.so依赖了其他的很多.so文件,如果出现not found字样的,就说明找不到依赖的链接库,需要自己去引入,我把**/usr/lib64**下的所有文件都拖到lib目录下了,然后下面就开始引用

import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.opencv.core.Core;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;

@Slf4j
@Component
public class OpenCvLibLoader implements CommandLineRunner {
    
    

    @Value("${spring.profiles.active}")
    private String profiles;
    @Value("${libPath}")
    private String libPath;
    @Value("${lib}")
    private String lib;


    @Override
    public void run(String... strings) throws Exception {
    
    
        if(StringUtils.equals(profiles, "prod")) {
    
    
            if(!StringUtils.isEmpty(libPath) && !StringUtils.isEmpty(lib)) {
    
    
                String[] libs = lib.split(",");
                for (String l : libs) {
    
    
                    System.load(libPath + l);
                }
            } else {
    
    
                log.error("没有检测到lib库配置");
            }
        }
        System.loadLibrary(Core.NATIVE_LIBRARY_NAME);
    }
}

还是之前的那个类,这里判断了线上环境还是本地环境,因为线上环境需要引入很多动态链接库,这里改成了通过读取配置文件的方式取加载动态链接库

# lib动态链接库
libPath: /home/xxx/lib/
lib: libpng15.so.15,libthai.so.0,libfribidi.so.0,libglib-2.0.so.0,libgraphite2.so.3,libharfbuzz.so.0,libpango-1.0.so.0,libfontconfig.so.1,libpangoft2-1.0.so.0,libpixman-1.so.0,libGLdispatch.so.0,libEGL.so.1,libXau.so.6,libxcb.so.1,libxcb-shm.so.0,libxcb-render.so.0,libX11.so.6,libXrender.so.1,libXext.so.6,libGLX.so.0,libGL.so.1,libcairo.so.2,libpangocairo-1.0.so.0,libgdk_pixbuf-2.0.so.0,libXfixes.so.3,libXrender.so.1,libXinerama.so.1,libXi.so.6,libXrandr.so.2,libXcursor.so.1,libXcomposite.so.1,libXdamage.so.1,libXext.so.6,libgdk-x11-2.0.so.0,libatk-1.0.so.0,libgtk-x11-2.0.so.0

这是prod线上的配置,我这里需要这一堆链接库

然后放到服务器上运行起来,如果差什么.so文件,或者版本对不上的,都可以自己用System.load引入,期间遇到过libopenblas_nolapck.so.0找不到,是libopenblas.so.0文件名的问题,把文件名改正确就行了

关于编译好的链接库下载地址: libopencv_java412.so

2020/07/21文章已更新
2020/09/10文章已更新

猜你喜欢

转载自blog.csdn.net/w57685321/article/details/104817524