一次单据图片处理的优化实践 | 京东物流技术团队
1 引言
日常开发中接到这样的需求,上游系统请求获取一张 A4 单据用于仓库打印及展示,要求 PNG 图片格式,但是我们内部得到的单据格式为 PDF,需要提取 PDF 文档的元素并生成一张 PNG 图片。目前已经有不少开源工具实现了这一功能,我们找了网上使用比较多的 Apache PDFBox 库来实现功能,如下
实际测试时,明显感觉到卡顿,当一次请求的单据数目较多时尤其严重。
经统计,各步骤本机单次运行耗时如下:
pdf 初始化(Step 1):2ms 文档提取及图片绘制(Step 2):520ms 图片编码 (Step 3):3823ms
我们发现,最后一句代码耗时接近 4 秒,拖累了整体性能。我们要如何优化这样一个问题呢?
2 BufferedImage 介绍
在讨论优化问题之前,首先要搞清楚待优化的代码是做什么的。如上代码中,使用 renderImageWithDPI 方法,将文档元素绘制为 BufferedImage 对象。
根据描述,BufferedImage 用来描述一张图片,其内部保存了图片的颜色模型(ColorModel)及像素数据(Raster)。这里简单解释就是,内部的 Raster 实现类中,以某种数据结构(如 Byte 数组)表示图片的所有像素数据,而 ColorModel 实现类,则提供了将每个像素的数据,转换为对应 RGB 颜色的方式。
BufferedImage 的构造函数中,可以传入图片类型来决定使用哪一种 ColorModel 和 Raster。引言的示例中,PDFRender 源码中默认生成的图片类型为 TYPE_INT_RGB,这种类型表示,每一个像素使用 R、G、B 三条数据表示,每条数据使用单字节(0~255)表示。
需要注意的是,BufferedImage 并不表示某一张具体的位图,而是通过描述每个像素的数据,抽象地表达一张图片,因此,它可以在内存中通过操作像素数据,直接改变对应图片。而通过 ImageIO.write 方法,可以将 BufferedImage 编码为具体格式的图片数据流。此方法会根据 formatName 选择该文件格式的编码器,来对 BufferedImage 内部的像素数据进行编码。
以下代码为 BufferedImage 的简单应用
将一个 GIF 图片读取到 BufferedImage 中,在坐标(10,10)位置打出 ABC 三个字符,并重新编码成 PNG 图片
下面这段代码展示了另一类型的例子,它将图片中所有的红色像素点重置成黑色像素点
如果我们想要取得图片的数据,可以通过 BufferedImage 内部的 Raster 对象获得。下面的示例,展示了采用了字节数组形式存储时,取得内部存储的字节数组的方式。注意,当需要查询到某一个像素的数据时,需要综合像素的 x,y 坐标及 ColorModel 模型中像素数据的存储方式来决定数组下标。
那么,现在我们可以通过看源码,了解引言的示例代码的作用。
根据源码可以了解到,PDFRender 对象读取并识别 PDF 文档中的每条语句,利用 BufferedImage 中的 Graphics2D 重新画了一张图片,并编码成 PNG 格式。这里不详细说了。
3 PNG 文件格式浅析
根据上一节的内容可知,把 BufferedImage 编码成 PNG 文件的过程,耗时接近 2 秒。我们需要简单了解下编码 PNG 文件的过程中,究竟在干什么。
以下参考 W3C 上对 PNG 的描述 https://www.w3.org/TR/PNG/#11IHDR ,由于比较复杂,很多东西我也是一知半解,这里仅描述本次优化涉及到的主要内容。
PNG 文件可以包含很多数据块,最主要且必须包含的,是 IHDR,IDAT 及 IEND 三个数据块
我们通过十六进制打开 PNG 文件,就可以看到具体的数据块分布
IEND
IEND 为结束标志
IHDR
IHDR 为文件头,其后紧跟的字节描述了 PNG 文件的一些基础属性,如宽、高各占 4 各字节,而 Color type 和 Bit Depth 分别表示颜色类型和位深。
1.Colour type 颜色类型分为以下几种:
Greyscale 为灰度图,每个像素用单一的灰度值来描述颜色,灰度值由 0(白)到 255(黑)逐步加深。
Truecolor 即为一般的 RGB 三通道图片,R、G、B 每一个通道允许用 8 或 16 个比特来表示。
Indexed-color 为索引色,需要配合调色板 PLTE 数据块使用,这里不多做介绍。
后面两种 Greyscale with alpha, truecolor with alpha,顾名思义,即灰度和 RGB 图像增加透明度通道
2.Bit Depth(位深度),即每个通道使用多少比特来表示。
比如在一张 Colour type=Greyscale 中,一个像素由 1~255 的灰度值来表示,那么这张图片就是单通道 8 位深。
根据上表,我们知道位深度于颜色类型是有相关性的。比如 Greyscale 灰度图只能支持 1,2,4,8,16 位深。
3.Compression Method 压缩算法
后面的 Compression Method 为数据压缩算法,固定为 zlib LZ77 算法。该算法通过编码一定范围内的重复数据来压缩整体数据,有兴趣的同学可以了解一下,这里不多做介绍了。找了一张网上的解说图,通过此图可以大致了解此压缩具体在做些什么。
LZ77 算法可以设置一个压缩级别参数,参数范围为 0 ~ 9,其中 0 为不压缩;1 为最快速度,但压缩率较低;9 为压缩率最高,但速度会相对较慢。
4.Filter Method 过滤方法
过滤方法即压缩前的预处理,主要目的是对于一些颜色变化比较“陡”的图片,通过一些数据的变换增加像素数据的重复度,从而增加压缩率。
试想一个场景,一张图片每一个像素点都是前一个像素颜色的递增,那么这张图片每一个像素点都是不同的数值,按照上面的压缩方法,它将无法被压缩。而如果我们对它进行预处理,以第一个像素为基准,后面每一个像素点均变换为当前像素与前一个像素的差值,那么这个变换是可逆的,并且会人为创造出大量的重复数据便于压缩。
具体这些过滤方法为什么可以增加重复数据,由于不涉及此次优化,我也没有做深入了解。
后面可以看到,因为我们业务场景本身的原因,并不需要预处理。
IDAT
IDAT 数据块为真正的图片像素数据,这部分数据是经过过滤(Filter)及压缩(Compresson)的,这些方法都有比较成熟的实现,我们也不考虑在这里做任何优化了,因此不多做介绍。
4 优化方案
经过上述内容,针对引言中的问题,我们确定了 2 个优化方向
业务上,无论怎样的单据,都是要仓库打印的,基本都是黑白图片。PNG 的颜色类型使用 Truecolor 是冗余的,根据上图中 IHDR 文件头表格内容可知,PNG 图片是支持灰度(Greyscale)同时位深为 1 的,即每个像素点由 1 比特来表示(0 代表白点,1 代表黑点)。这样可以减少 PNG 文件的体积,以及压缩生成 IDAT 块的时间。
调整 zlib 压缩算法的级别为 1,牺牲压缩率来提高速度
经过查看源码,当 BufferedImage 的 imageType=TYPE_BYTE_BINARY(二进制)时,JDK 中的 PNG 编码器会使用灰度的 color type 及 1 位深,而我们发现 PDFRender 类是有参数可控的,当传入 BINARY 时,绘制的 BufferedImage 的类型即为 TYPE_BYTE_BINARY。
使用此方法后,ImageIO.write 编码过程耗时减少到 150ms 左右。
但是这样改后,我们发现生成的 PNG 图像,与原 PDF 文档在观感上相比,有一些发“虚”,如下图
PDF 截图
PNG 截图
由于 TYPE_BYTE_BINARY 类型的 BufferedImage 每个像素只由 0,1 来表示黑白,很容易想到,这个现象的原因是出在判断“多灰才算黑”上。
我们来看一下源码中,BINARY 类型 BufferedImage 的 ColorModel,是如何判断黑白的。
BINARY 类型的 BufferedImage 使用的实现类为 IndexColorModel, 确定颜色的代码段如下,最终由 pix 变量决定颜色的索引号。
由以上代码,在 JDK 的实现中,通过像素的灰度值更靠近 0 和 255 的哪一个,来确定当前像素是黑是白。
这种实现方式对于通用功能来说是合适的,却不适合我们的业务场景,因为我们生成的图片都是单据,大部分需要仓库等场景现场打印,需要优先保证内容的准确性,即不能因为图片上某一处灰得有点“浅”,就不显示它。
对于当前业务场景,我们认为简单地设置一个固定的阈值,来区分灰度值是一个适合的方式。
所以,为解决这个问题,我们设计了 2 种思路
继承实现自己的 ColorModel,通过阈值来指定调色板索引号,所有要编码成 PNG 的 BufferedImage 都使用自己实现的 ColorModel。
不使用 JDK 默认的 PNG 编码器,使用其他开源实现,在编码阶段通过判断 BufferedImage 像素灰度值是否超过阈值,来决定编入 PNG 文件的像素数据是黑是白。
从合理性上看,我认为 1 方案从程序结构角度是更合理的,但是实际应用中,却选择了方案 2,理由如下
BufferedImage 通常不是自己生成的,我们往往控制不了其他开源工具操作生成的 BufferedImage 使用哪种 ColorModel,比如我们的项目里 PDF Box,IcePdf, Apache poi 等开源包都会提供生成 BufferedImage 的方法,针对每个开源工具都要重新更改源代码,生成使用自己实现的 ColorModel 的 BufferedImage,太过于繁琐了,不具有通用性。
JDK 提供的 PNG 编码器不能设置压缩级别
5 实际优化过程
我们通过网上搜到了开源 Java 实现的 PNG 编码器 pngencoder 作为此业务场景下的编码器。
但是我们发现一个问题,开源实现的 PNG 编码器在编码 BufferedImage 时,为了方便整字节进行操作,基本都是只能支持 8 或 16 比特的位深的 PNG,无法支持我们需要的 1 比特的位深. 经过分析,这一点可以通过自己开发简单的代码实现来补充,因为无论使用几位深,最终 PNG 编码都是针对像素数据整理过后,对整字节的数据进行后续的过滤及压缩来生成 IDAT 数据,因此,我们只需要实现对原 BufferedImage 像素数据的提取并转换为 1 比特位深度这一步骤。
因此,我们的需求就是,针对一个 BufferedImage,每个像素的灰度值通过与阈值比较大小,映射为一个 bit 数组,并将 bit 数组转换为 byte 数组。
下面是我们借助这个开源工具内部实现的部分代码:
自定义 1bit 位深取数据方法
最终用修改后的开源 PNG 编码器代替 ImageIO.write 方法,这里使用压缩级别为 1
最终经过优化后测试,和最开始测试时相比,PNG 编码步骤上,无论在耗时还是文件大小上都有很大改善
6 总结
通过对问题的优化,对以 PNG 为例的位图文件结构,和 Java 中对图片的基本操作有了渐进式的理解;同时也意识到,日常工作中,通过对业务本身的理解,清楚知道业务的边界在那里,加上对技术基础知识的深入理解,才能更细致地针对性做出优化。
作者:京东物流 冯凯
来源:京东云开发者社区 自猿其说 Tech 转载请注明来源
版权声明: 本文为 InfoQ 作者【京东科技开发者】的原创文章。
原文链接:【http://xie.infoq.cn/article/10a87db9467df04e3bea0b38d】。文章转载请联系作者。
评论