一次單據圖片處理的優化實踐

来源:https://www.cnblogs.com/jingdongkeji/archive/2023/10/23/17782047.html
-Advertisement-
Play Games

日常開發中接到這樣的需求,上游系統請求獲取一張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個優化方向

  1. 業務上,無論怎樣的單據,都是要倉庫列印的,基本都是黑白圖片。PNG的顏色類型使用Truecolor是冗餘的,根據上圖中IHDR文件頭表格內容可知,PNG圖片是支持灰度(Greyscale)同時位深為1的,即每個像素點由1比特來表示(0代表白點,1代表黑點)。這樣可以減少PNG文件的體積,以及壓縮生成IDAT塊的時間。
  2. 調整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種思路

  1. 繼承實現自己的ColorModel,通過閾值來指定調色板索引號,所有要編碼成PNG的BufferedImage都使用自己實現的ColorModel。
  2. 不使用JDK預設的PNG編碼器,使用其他開源實現,在編碼階段通過判斷BufferedImage像素灰度值是否超過閾值,來決定編入PNG文件的像素數據是黑是白。

從合理性上看,我認為1方案從程式結構角度是更合理的,但是實際應用中,卻選擇了方案2,理由如下

  1. BufferedImage通常不是自己生成的,我們往往控制不了其他開源工具操作生成的BufferedImage使用哪種ColorModel,比如我們的項目里PDF Box,IcePdf, Apache poi等開源包都會提供生成BufferedImage的方法,針對每個開源工具都要重新更改源代碼,生成使用自己實現的ColorModel的BufferedImage,太過於繁瑣了,不具有通用性。
  2. 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 轉載請註明來源


您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • Python 沒有內置支持數組,但可以使用 Python 列表來代替。 數組 本頁將向您展示如何使用列表作為數組,但要在 Python 中使用數組,您需要導入一個庫,比如 NumPy 庫。數組用於在一個變數中存儲多個值: 示例,創建一個包含汽車名稱的數組: cars = ["Ford", "Volv ...
  • 網上搜索類似的文章有很多,但是一味的複製粘貼總會出現各種奇葩問題,最後然並卵!今天特意自己研究琢磨一下,將最終結果分享給大家,100%親測可用。 ...
  • 來源:https://juejin.cn/post/6844904024332828685 寫在前面 通過閱讀本篇文章你將瞭解到: CompletableFuture的使用 CompletableFure非同步和同步的性能測試 已經有了Future為什麼仍需要在JDK1.8中引入Completable ...
  • List 是 Python 中常用的數據類型,它一個有序集合,即其中的元素始終保持著初始時的定義的順序(除非你對它們進行排序或其他修改操作)。 在Python中,向List添加元素,方法有如下4種方法(append(),extend(),insert(), +加號) 1. append() 追加單個 ...
  • 方式一:使用切片 [:] 列表 # 淺拷貝 [:] old_list = [1, 2, [3, 4]] new_list = old_list[:] old_list.append(5) old_list[2][0] += 97 print("Old list:", old_list, "old l ...
  • 在Word文檔中,可以添加半透明的圖形或文字作為水印,以保護文檔的原創性,防止未經授權的複製或使用。除了提供安全功能外,水印還可以展示文檔創作者的信息、附加的文檔信息,或者僅用於文檔的裝飾。本文將介紹如何使用Spire.Doc for Python在程式中的輕鬆添加文字和圖像水印到Word文檔。 引 ...
  • 本文深入探討了Go語言中代碼斷行的各個方面,從基礎概念到實際應用實踐。 關註【TechLeadCloud】,分享互聯網架構、雲服務技術的全維度知識。作者擁有10+年互聯網服務架構、AI產品研發經驗、團隊管理經驗,同濟本復旦碩,復旦機器人智能實驗室成員,阿裡雲認證的資深架構師,項目管理專業人士,上億營 ...
  • 自定義MyBatis攔截器可以在方法執行前後插入自己的邏輯,這非常有利於擴展和定製 MyBatis 的功能。本篇文章實現自定義一個攔截器去改變要插入或者查詢的數據源。 ...
一周排行
    -Advertisement-
    Play Games
  • 移動開發(一):使用.NET MAUI開發第一個安卓APP 對於工作多年的C#程式員來說,近來想嘗試開發一款安卓APP,考慮了很久最終選擇使用.NET MAUI這個微軟官方的框架來嘗試體驗開發安卓APP,畢竟是使用Visual Studio開發工具,使用起來也比較的順手,結合微軟官方的教程進行了安卓 ...
  • 前言 QuestPDF 是一個開源 .NET 庫,用於生成 PDF 文檔。使用了C# Fluent API方式可簡化開發、減少錯誤並提高工作效率。利用它可以輕鬆生成 PDF 報告、發票、導出文件等。 項目介紹 QuestPDF 是一個革命性的開源 .NET 庫,它徹底改變了我們生成 PDF 文檔的方 ...
  • 項目地址 項目後端地址: https://github.com/ZyPLJ/ZYTteeHole 項目前端頁面地址: ZyPLJ/TreeHoleVue (github.com) https://github.com/ZyPLJ/TreeHoleVue 目前項目測試訪問地址: http://tree ...
  • 話不多說,直接開乾 一.下載 1.官方鏈接下載: https://www.microsoft.com/zh-cn/sql-server/sql-server-downloads 2.在下載目錄中找到下麵這個小的安裝包 SQL2022-SSEI-Dev.exe,運行開始下載SQL server; 二. ...
  • 前言 隨著物聯網(IoT)技術的迅猛發展,MQTT(消息隊列遙測傳輸)協議憑藉其輕量級和高效性,已成為眾多物聯網應用的首選通信標準。 MQTTnet 作為一個高性能的 .NET 開源庫,為 .NET 平臺上的 MQTT 客戶端與伺服器開發提供了強大的支持。 本文將全面介紹 MQTTnet 的核心功能 ...
  • Serilog支持多種接收器用於日誌存儲,增強器用於添加屬性,LogContext管理動態屬性,支持多種輸出格式包括純文本、JSON及ExpressionTemplate。還提供了自定義格式化選項,適用於不同需求。 ...
  • 目錄簡介獲取 HTML 文檔解析 HTML 文檔測試參考文章 簡介 動態內容網站使用 JavaScript 腳本動態檢索和渲染數據,爬取信息時需要模擬瀏覽器行為,否則獲取到的源碼基本是空的。 本文使用的爬取步驟如下: 使用 Selenium 獲取渲染後的 HTML 文檔 使用 HtmlAgility ...
  • 1.前言 什麼是熱更新 游戲或者軟體更新時,無需重新下載客戶端進行安裝,而是在應用程式啟動的情況下,在內部進行資源或者代碼更新 Unity目前常用熱更新解決方案 HybridCLR,Xlua,ILRuntime等 Unity目前常用資源管理解決方案 AssetBundles,Addressable, ...
  • 本文章主要是在C# ASP.NET Core Web API框架實現向手機發送驗證碼簡訊功能。這裡我選擇是一個互億無線簡訊驗證碼平臺,其實像阿裡雲,騰訊雲上面也可以。 首先我們先去 互億無線 https://www.ihuyi.com/api/sms.html 去註冊一個賬號 註冊完成賬號後,它會送 ...
  • 通過以下方式可以高效,並保證數據同步的可靠性 1.API設計 使用RESTful設計,確保API端點明確,並使用適當的HTTP方法(如POST用於創建,PUT用於更新)。 設計清晰的請求和響應模型,以確保客戶端能夠理解預期格式。 2.數據驗證 在伺服器端進行嚴格的數據驗證,確保接收到的數據符合預期格 ...