日常開發中接到這樣的需求,上游系統請求獲取一張A4單據用於倉庫列印及展示,要求PNG圖片格式,但是我們內部得到的單據格式為PDF,需要提取PDF文檔的元素並生成一張PNG圖片。目前已經有不少開源工具實現了這一功能,我們找了網上使用比較多的Apache PDFBox庫來實現功能 ...
1 引言
日常開發中接到這樣的需求,上游系統請求獲取一張A4單據用於倉庫列印及展示,要求PNG圖片格式,但是我們內部得到的單據格式為PDF,需要提取PDF文檔的元素並生成一張PNG圖片。目前已經有不少開源工具實現了這一功能,我們找了網上使用比較多的Apache PDFBox庫來實現功能,如下
// Step 1
PDDocument document = PDDocument.load(content);
PDFRenderer pdfRenderer = new PDFRenderer(document);
// 獲取第1頁PDF文檔
OutputStream os = new ByteArrayOutputStream()
// Step 2
// 為了保證圖片的清晰,這裡採用600DPI
BufferedImage image = pdfRenderer.renderImageWithDPI(0, 600);
// Step 3
ImageIO.write(image, "PNG", os);
實際測試時,明顯感覺到卡頓,當一次請求的單據數目較多時尤其嚴重。
經統計,各步驟本機單次運行耗時如下:
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)表示。
public BufferedImage(int width, int height, int imageType)
需要註意的是,BufferedImage並不表示某一張具體的點陣圖,而是通過描述每個像素的數據,抽象地表達一張圖片,因此,它可以在記憶體中通過操作像素數據,直接改變對應圖片。而通過ImageIO.write方法,可以將BufferedImage編碼為具體格式的圖片數據流。此方法會根據formatName選擇該文件格式的編碼器,來對BufferedImage內部的像素數據進行編碼。
public static boolean write(RenderedImage im, String formatName, OutputStream output) throws IOException
以下代碼為BufferedImage的簡單應用
將一個GIF圖片讀取到BufferedImage中,在坐標(10,10)位置打出ABC三個字元,並重新編碼成PNG圖片
BufferedImage image = ImageIO.read(new File("exmaple.gif"));
image.getGraphics().drawString("ABC", 10, 10);
ImageIO.write(image, "PNG", new FileOutputStream("result.png"));
下麵這段代碼展示了另一類型的例子,它將圖片中所有的紅色像素點重置成黑色像素點
BufferedImage image = ImageIO.read(new File("example.gif"));
for(int i = 0 ; i < image.getWidth() ; i++) {
for(int j = 0 ; j < image.getHeight() ; j++) {
if(image.getRGB(i, j) == Color.RED.getRGB()) {
image.setRGB(i, j, Color.BLACK.getRGB());
}
}
}
如果我們想要取得圖片的數據,可以通過BufferedImage內部的Raster對象獲得。下麵的示例,展示了採用了位元組數組形式存儲時,取得內部存儲的位元組數組的方式。註意,當需要查詢到某一個像素的數據時,需要綜合像素的x,y坐標及ColorModel模型中像素數據的存儲方式來決定數組下標。
BufferedImage im = ImageIO.read(new File("exmaple.gif"));
DataBuffer dataBuffer = im.getRaster().getDataBuffer();
if(dataBuffer instanceof DataBufferByte) {
DataBufferByte bufferByte = (DataBufferByte) dataBuffer;
byte[] data = bufferByte.getData();
}
那麼,現在我們可以通過看源碼,瞭解引言的示例代碼的作用。
根據源碼可以瞭解到,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。
BufferedImage image = pdfRenderer.renderImageWithDPI(0, 304, ImageType.BINARY);
使用此方法後,ImageIO.write編碼過程耗時減少到150ms左右。
但是這樣改後,我們發現生成的PNG圖像,與原PDF文檔在觀感上相比,有一些發“虛”,如下圖
PDF截圖
PNG截圖
由於TYPE_BYTE_BINARY類型的BufferedImage每個像素只由0,1來表示黑白,很容易想到,這個現象的原因是出在判斷“多灰才算黑”上。
我們來看一下源碼中,BINARY類型BufferedImage的ColorModel,是如何判斷黑白的。
BINARY類型的BufferedImage使用的實現類為IndexColorModel, 確定顏色的代碼段如下,最終由pix變數決定顏色的索引號。
int minDist = 256;
int d;
// 計算像素的灰度值
int gray = (int) (red*77 + green*150 + blue*29 + 128)/256;
// 在BINARY類型下,map_size = 2
for (int i = 0; i < map_size; i++) {
// rgb數組為調色板,每個數組元素表示一個在圖片中可能出現的顏色
// 在BINARY類型下,rgb只有0x00,0xFE兩個元素
if (this.rgb[i] == 0x0) {
// For allgrayopaque colormaps, entries are 0
// iff they are an invalid color and should be
// ignored during color searches.
continue;
}
// 分別計算黑&白與當前灰度值的差值
d = (this.rgb[i] & 0xff) - gray;
if (d < 0) d = -d;
// 選擇差值較小的一邊
if (d < minDist) {
pix = i;
if (d == 0) {
break;
}
minDist = d;
}
}
由以上代碼,在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作為此業務場景下的編碼器。
<groupId>com.pngencoder</groupId>
<artifactId>pngencoder</artifactId>
<version>0.14.0-SNAPSHOT</version>
但是我們發現一個問題,開源實現的PNG編碼器在編碼BufferedImage時,為了方便整位元組進行操作,基本都是只能支持8或16比特的位深的PNG,無法支持我們需要的1比特的位深. 經過分析,這一點可以通過自己開發簡單的代碼實現來補充,因為無論使用幾位深,最終PNG編碼都是針對像素數據整理過後,對整位元組的數據進行後續的過濾及壓縮來生成IDAT數據,因此,我們只需要實現對原BufferedImage像素數據的提取並轉換為1比特位深度這一步驟。
因此,我們的需求就是,針對一個BufferedImage,每個像素的灰度值通過與閾值比較大小,映射為一個bit數組,並將bit數組轉換為byte數組。
下麵是我們藉助這個開源工具內部實現的部分代碼:
/**
* 在開源工具原有代碼基礎上,判斷1bit位深時,使用另外的像素數據收集方法
*/
case TYPE_BYTE_GRAY:
if(bitDepth == 1) {
// 針對灰度圖像,當位深為1的時候走自己實現的數據獲取方法
// RGB圖像也可用類似方式
getByteOneBitGrey(bufferedImage, yStart, width, heightToStream, consumer);
} else {
// 原代碼
getByteGray(bufferedImage, yStart, width, heightToStream, consumer);
}
break;
自定義1bit位深取數據方法
/**
* 生成使用1bit位深,Greyscale的PNG的像素數據
* 當IHDR中bit Depth為1時,使用這個方法來生成IDAT的原始數據
* @param image 圖片BufferedImage
* @param yStart 從圖片哪一行開始掃描
* @param width 圖像寬度
* @param heightToStream 待處理的高度
* @param consumer 原始數據塊後續處理函數
*/
static void getByteOneBitGrey(BufferedImage image, int yStart, int width, int heightToStream, AbstractPNGLineConsumer consumer)
throws IOException {
// 位元組數組的長度
int rowByteSize = (int) Math.ceil(width / 8.0);
byte[] currLine = new byte[rowByteSize + 1];
// BufferedImage Raster像素數據
byte[] rawBytes = ((DataBufferByte) image.getRaster().getDataBuffer()).getData();
int currLineIndex, bitIndex;
byte currValue = 0;
for(int y = 0 ; y < heightToStream ; y++) {
int start = (yStart + y) * width;
currLineIndex = 0;
bitIndex = 0;
// 這裡有一個坑,PNG數據每行要以一個額外的0x00開頭
currLine[currLineIndex++] = 0;
for (int i = 0; i < width; i++) {
// 查到當前像素的灰度值,150為手動設置的閾值,小於150則認為是白色
byte bitVal = (byte) ((rawBytes[start + i] & 0xFF) < 150 ? 0 : 1);
// 把每個像素的bit合併到一個byte中
currValue |= bitVal << (7 - bitIndex++);
// 當取了8個bit時,將一個完整的byte放入待處理數據
if (bitIndex == 8) {
currLine[currLineIndex++] = currValue;
currValue = 0;
bitIndex = 0;
}
}
// 如果剩餘的bit不夠8個,最後一個byte剩餘位為0
if (bitIndex != 0) {
currLine[currLineIndex++] = currValue;
}
// 調用開源工具的方法對數據做後續處理
consumer.consume(currLine, null);
}
}
最終用修改後的開源PNG編碼器代替ImageIO.write方法,這裡使用壓縮級別為1
byte[] result = new PngEncoder()
.withBufferedImage(image)
.withMultiThreadedCompressionEnabled(false)
// 配置壓縮級別為1
.withCompressionLevel(2)
// 設置位深度為1bit
.withBitDepth(1)
.toBytes();
最終經過優化後測試,和最開始測試時相比,PNG編碼步驟上,無論在耗時還是文件大小上都有很大改善
6 總結
通過對問題的優化,對以PNG為例的點陣圖文件結構,和Java中對圖片的基本操作有了漸進式的理解;同時也意識到,日常工作中,通過對業務本身的理解,清楚知道業務的邊界在那裡,加上對技術基礎知識的深入理解,才能更細緻地針對性做出優化。
作者:京東物流 馮凱
來源:京東雲開發者社區 自猿其說Tech 轉載請註明來源