环境
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下测试成功了,现在可以开始上线了
部署上线
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文章已更新