怎么利用Node進(jìn)行圖片壓縮?下面本篇文章以PNG圖片為例給大家介紹一下進(jìn)行圖片壓縮的方法,希望對大家有所幫助!
最近要搞圖像處理服務(wù),其中一個是要實(shí)現(xiàn)圖片壓縮功能。以前前端開發(fā)的時候只要利用canvas現(xiàn)成的API處理下就能實(shí)現(xiàn),后端可能也有現(xiàn)成的API但我并不知道。仔細(xì)想想,我從來沒有詳細(xì)了解過圖片壓縮原理,那剛好趁這次去調(diào)研學(xué)習(xí)下,所以有了這篇文章來記錄。老樣子,如有不對的地方,DDDD(帶帶弟弟)。
(相關(guān)資料圖)
我們先把圖片上傳到后端,看看后端接收了什么樣的參數(shù)。這里后端我用的是Node.js(Nest),圖片我以PNG圖片為例。
接口和參數(shù)打印如下:
@Post("/compression")@UseInterceptors(FileInterceptor("file"))async imageCompression(@UploadedFile() file: Express.Multer.File) { return { file }}要進(jìn)行壓縮,我們就需要拿到圖像數(shù)據(jù)??梢钥吹?,唯一能藏匿圖像數(shù)據(jù)的就是這串buffer。那這串buffer描述了什么,就需要先弄清什么是PNG。【相關(guān)教程推薦:nodejs視頻教程、編程教學(xué)】
這里是PNG的WIKI地址。
閱讀之后,我了解到PNG是由一個8 byte的文件頭加上多個的塊(chunk)組成。示意圖如下:
其中:
文件頭是由一個被稱為magic number的組成。值為 89 50 4e 47 0d 0a 1a 0a(16進(jìn)制)。它標(biāo)記了這串?dāng)?shù)據(jù)是PNG格式。
塊分為兩種,一種叫關(guān)鍵塊(Critical chunks),一種叫輔助塊(Ancillary chunks)。關(guān)鍵塊是必不可少的,沒有關(guān)鍵塊,解碼器將不能正確識別并展示圖片。輔助塊是可選的,部分軟件在處理圖片之后就有可能攜帶輔助塊。每個塊都是四部分組成:4 byte 描述這個塊的內(nèi)容有多長,4 byte 描述這個塊的類型是什么,n byte 描述塊的內(nèi)容(n 就是前面4 byte 值的大小,也就是說,一個塊最大長度為28*4),4 byte CRC校驗(yàn)檢查塊的數(shù)據(jù),標(biāo)記著一個塊的結(jié)束。其中,塊類型的4 byte 的值為4個acsii碼,第一個字母大寫表示是關(guān)鍵塊,小寫表示是輔助塊;第二個字母大寫表示是公有,小寫表示是私有;第三個字母必須是大寫,用于PNG后續(xù)的擴(kuò)展;第四個字母表示該塊不識別時,能否安全復(fù)制,大寫表示未修改關(guān)鍵塊時才能安全復(fù)制,小寫表示都能安全復(fù)制。PNG官方提供很多定義的塊類型,這里只需要知道關(guān)鍵塊的類型即可,分別是IHDR,PLTE,IDAT,IEND。
PNG要求第一個塊必須是IHDR。IHDR的塊內(nèi)容是固定的13 byte,包含了圖片的以下信息:
寬度 width (4 byte) & 高度 height (4 byte)
位深 bit depth (1 byte,值為1,2,4,8或者16) & 顏色類型 color type (1 byte,值為0,2,3,4或者6)
壓縮方法 compression method (1 byte,值為0) & 過濾方式 filter method (1 byte,值為0)
交錯方式 interlace method (1 byte,值為0或者1)
寬度和高度很容易理解,剩下的幾個好像都很陌生,接下來我將進(jìn)行說明。
在說明位深之前,我們先來看顏色類型,顏色類型有5種值:
0 表示灰度(grayscale)它只有一個通道(channel),看成rgb的話,可以理解它的三色通道值是相等的,所以不需要多余兩個通道表示。
2 表示真實(shí)色彩(rgb)它有三個通道,分別是R(紅色),G(綠色),B(藍(lán)色)。
3 表示顏色索引(indexed)它也只有一個通道,表示顏色的索引值。該類型往往配備一組顏色列表,具體的顏色是根據(jù)索引值和顏色列表查詢得到的。
4 表示灰度和alpha 它有兩個通道,除了灰度的通道外,多了一個alpha通道,可以控制透明度。
6 表示真實(shí)色彩和alpha 它有四個通道。
之所以要說到通道,是因?yàn)樗瓦@里的位深有關(guān)。位深的值就定義了每個通道所占的位數(shù)(bit)。位深跟顏色類型組合,就能知道圖片的顏色格式類型和每個像素所占的內(nèi)存大小。PNG官方支持的組合如下表:
過濾和壓縮是因?yàn)镻NG中存儲的不是圖像的原始數(shù)據(jù),而是處理后的數(shù)據(jù),這也是為什么PNG圖片所占內(nèi)存較小的原因。PNG使用了兩步進(jìn)行了圖片數(shù)據(jù)的壓縮轉(zhuǎn)換。
第一步,過濾。過濾的目的是為了讓原始圖片數(shù)據(jù)經(jīng)過該規(guī)則后,能進(jìn)行更大的壓縮比。舉個例子,如果有一張漸變圖片,從左往右,顏色依次為[#000000, #000001, #000002, ..., #ffffff],那么我們就可以約定一條規(guī)則,右邊的像素總是和它前一個左邊的像素進(jìn)行比較,那么處理完的數(shù)據(jù)就變成了[1, 1, 1, ..., 1],這樣是不是就能進(jìn)行更好的壓縮。PNG目前只有一種過濾方式,就是基于相鄰像素作為預(yù)測值,用當(dāng)前像素減去預(yù)測值。過濾的類型一共有五種,(目前我還不知道這個類型值在哪里存儲,有可能在IDAT里,找到了再來刪除這條括號里的已確定該類型值儲存在IDAT數(shù)據(jù)中)如下表所示:
| Type byte | Filter name | Predicted value |
|---|---|---|
| 0 | None | 不做任何處理 |
| 1 | Sub | 左側(cè)相鄰像素 |
| 2 | Up | 上方相鄰像素 |
| 3 | Average | Math.floor((左側(cè)相鄰像素 + 上方相鄰像素) / 2) |
| 4 | Paeth | 取(左側(cè)相鄰像素 + 上方相鄰像素 - 左上方像素)最接近的值 |
第二步,壓縮。PNG也只有一種壓縮算法,使用的是DEFLATE算法。這里不細(xì)說,具體看下面的章節(jié)。
交錯方式,有兩種值。0表示不處理,1表示使用Adam7 算法進(jìn)行處理。我沒有去詳細(xì)了解該算法,簡單來說,當(dāng)值為0時,圖片需要所有數(shù)據(jù)都加載完畢時,圖片才會顯示。而值為1時,Adam7會把圖片劃分多個區(qū)域,每個區(qū)域逐級加載,顯示效果會有所優(yōu)化,但通常會降低壓縮效率。加載過程可以看下面這張gif圖。
PLTE的塊內(nèi)容為一組顏色列表,當(dāng)顏色類型為顏色索引時需要配置。值得注意的是,顏色列表中的顏色一定是每個通道8bit,每個像素24bit的真實(shí)色彩列表。列表的長度,可以比位深約定的少,但不能多。比如位深是2,那么22,最多4種顏色,列表長度可以為3,但不能為5。
IDAT的塊內(nèi)容是圖片原始數(shù)據(jù)經(jīng)過PNG壓縮轉(zhuǎn)換后的數(shù)據(jù),它可能有多個重復(fù)的塊,但必須是連續(xù)的,并且只有當(dāng)上一個塊填充滿時,才會有下一個塊。
IEND的塊內(nèi)容為0 byte,它表示圖片的結(jié)束。
閱讀到這里,我們把上面的接口改造一下,解析這串buffer。
@Post("/compression")@UseInterceptors(FileInterceptor("file"))async imageCompression(@UploadedFile() file: Express.Multer.File) { const buffer = file.buffer; const result = { header: buffer.subarray(0, 8).toString("hex"), chunks: [], size: file.size, }; let pointer = 8; while (pointer < buffer.length) { let chunk = {}; const length = parseInt(buffer.subarray(pointer, pointer + 4).toString("hex"), 16); const chunkType = buffer.subarray(pointer + 4, pointer + 8).toString("ascii"); const crc = buffer.subarray(pointer + length, pointer + length + 4).toString("hex"); chunk = { ...chunk, length, chunkType, crc, }; switch (chunkType) { case "IHDR": const width = parseInt(buffer.subarray(pointer + 8, pointer + 12).toString("hex"), 16); const height = parseInt(buffer.subarray(pointer + 12, pointer + 16).toString("hex"), 16); const bitDepth = parseInt( buffer.subarray(pointer + 16, pointer + 17).toString("hex"), 16, ); const colorType = parseInt( buffer.subarray(pointer + 17, pointer + 18).toString("hex"), 16, ); const compressionMethod = parseInt( buffer.subarray(pointer + 18, pointer + 19).toString("hex"), 16, ); const filterMethod = parseInt( buffer.subarray(pointer + 19, pointer + 20).toString("hex"), 16, ); const interlaceMethod = parseInt( buffer.subarray(pointer + 20, pointer + 21).toString("hex"), 16, ); chunk = { ...chunk, width, height, bitDepth, colorType, compressionMethod, filterMethod, interlaceMethod, }; break; case "PLTE": const colorList = []; const colorListStr = buffer.subarray(pointer + 8, pointer + 8 + length).toString("hex"); for (let i = 0; i < colorListStr.length; i += 6) { colorList.push(colorListStr.slice(i, i + 6)); } chunk = { ...chunk, colorList, }; break; default: break; } result.chunks.push(chunk); pointer = pointer + 4 + 4 + length + 4; } return result;}這里我測試用的圖沒有PLTE,剛好我去TinyPNG壓縮我那張測試圖之后進(jìn)行上傳,發(fā)現(xiàn)有PLTE塊,可以看一下,結(jié)果如下圖。
通過比對這兩張圖,壓縮圖片的方式我們也能窺探一二。
前面說過,PNG使用的是一種叫DEFLATE的無損壓縮算法,它是Huffman Coding跟LZ77的結(jié)合。除了PNG,我們經(jīng)常使用的壓縮文件,.zip,.gzip也是使用的這種算法(7zip算法有更高的壓縮比,也可以了解下)。要了解DEFLATE,我們首先要了解Huffman Coding和LZ77。
哈夫曼編碼忘記在大學(xué)的哪門課接觸過了,它是一種根據(jù)字符出現(xiàn)頻率,用最少的字符替換出現(xiàn)頻率最高的字符,最終降低平均字符長度的算法。
舉個例子,有字符串"ABCBCABABADA",如果按照正常空間存儲,所占內(nèi)存大小為12 * 8bit = 96bit,現(xiàn)對它進(jìn)行哈夫曼編碼。
1.統(tǒng)計(jì)每個字符出現(xiàn)的頻率,得到A 5次 B 4次 C 2次 D 1次
2.對字符按照頻率從小到大排序,將得到一個隊(duì)列D1,C2,B4,A5
3.按順序構(gòu)造哈夫曼樹,先構(gòu)造一個空節(jié)點(diǎn),最小頻率的字符分給該節(jié)點(diǎn)的左側(cè),倒數(shù)第二頻率的字符分給右側(cè),然后將頻率相加的值賦值給該節(jié)點(diǎn)。接著用賦值后節(jié)點(diǎn)的值和倒數(shù)第三頻率的字符進(jìn)行比較,較小的值總是分配在左側(cè),較大的值總是分配在右側(cè),依次類推,直到隊(duì)列結(jié)束,最后把最大頻率和前面的所有值相加賦值給根節(jié)點(diǎn),得到一棵完整的哈夫曼樹。
4.對每條路徑進(jìn)行賦值,左側(cè)路徑賦值為0,右側(cè)路徑賦值為1。從根節(jié)點(diǎn)到葉子節(jié)點(diǎn),進(jìn)行遍歷,遍歷的結(jié)果就是該字符編碼后的二進(jìn)制表示,得到:A(0)B(11)C(101)D(100)。
完整的哈夫曼樹如下(忽略箭頭,沒找到連線- -!):
壓縮后的字符串,所占內(nèi)存大小為5 * 1bit + 4 * 2bit + 2 * 3bit + 1 * 3bit = 22bit。當(dāng)然在實(shí)際傳輸過程中,還需要把編碼表的信息(原始字符和出現(xiàn)頻率)帶上。因此最終占比大小為 4 * 8bit + 4 * 3bit(頻率最大值為5,3bit可以表示)+ 22bit = 66bit(理想狀態(tài)),小于原有的96bit。
LZ77算法還是第一次知道,查了一下是一種基于字典和滑動窗的無所壓縮算法。(題外話:因?yàn)長empel和Ziv在1977年提出的算法,所以叫LZ77,哈哈哈?)
我們還是以上面這個字符串"ABCBCABABADA"為例,現(xiàn)假設(shè)有一個4 byte的動態(tài)窗口和一個2byte的預(yù)讀緩沖區(qū),然后對它進(jìn)行LZ77算法壓縮,過程順序從上往下,示意圖如下:
總結(jié)下來,就是預(yù)讀緩沖區(qū)在動態(tài)窗口中找到最長相同項(xiàng),然后用長度較短的標(biāo)記來替代這個相同項(xiàng),從而實(shí)現(xiàn)壓縮。從上圖也可以看出,壓縮比跟動態(tài)窗口的大小,預(yù)讀緩沖區(qū)的大小和被壓縮數(shù)據(jù)的重復(fù)度有關(guān)。
DEFLATE【RFC 1951】是先使用LZ77編碼,對編碼后的結(jié)果在進(jìn)行哈夫曼編碼。我們這里不去討論具體的實(shí)現(xiàn)方法,直接使用其推薦庫Zlib,剛好Node.js內(nèi)置了對Zlib的支持。接下來我們繼續(xù)改造上面那個接口,如下:
import * as zlib from "zlib";@Post("/compression")@UseInterceptors(FileInterceptor("file"))async imageCompression(@UploadedFile() file: Express.Multer.File) { const buffer = file.buffer; const result = { header: buffer.subarray(0, 8).toString("hex"), chunks: [], size: file.size, }; // 因?yàn)榭赡苡卸鄠€IDAT的塊 需要個數(shù)組緩存最后拼接起來 const fileChunkDatas = []; let pointer = 8; while (pointer < buffer.length) { let chunk = {}; const length = parseInt(buffer.subarray(pointer, pointer + 4).toString("hex"), 16); const chunkType = buffer.subarray(pointer + 4, pointer + 8).toString("ascii"); const crc = buffer.subarray(pointer + length, pointer + length + 4).toString("hex"); chunk = { ...chunk, length, chunkType, crc, }; switch (chunkType) { case "IHDR": const width = parseInt(buffer.subarray(pointer + 8, pointer + 12).toString("hex"), 16); const height = parseInt(buffer.subarray(pointer + 12, pointer + 16).toString("hex"), 16); const bitDepth = parseInt( buffer.subarray(pointer + 16, pointer + 17).toString("hex"), 16, ); const colorType = parseInt( buffer.subarray(pointer + 17, pointer + 18).toString("hex"), 16, ); const compressionMethod = parseInt( buffer.subarray(pointer + 18, pointer + 19).toString("hex"), 16, ); const filterMethod = parseInt( buffer.subarray(pointer + 19, pointer + 20).toString("hex"), 16, ); const interlaceMethod = parseInt( buffer.subarray(pointer + 20, pointer + 21).toString("hex"), 16, ); chunk = { ...chunk, width, height, bitDepth, colorType, compressionMethod, filterMethod, interlaceMethod, }; break; case "PLTE": const colorList = []; const colorListStr = buffer.subarray(pointer + 8, pointer + 8 + length).toString("hex"); for (let i = 0; i < colorListStr.length; i += 6) { colorList.push(colorListStr.slice(i, i + 6)); } chunk = { ...chunk, colorList, }; break; case "IDAT": fileChunkDatas.push(buffer.subarray(pointer + 8, pointer + 8 + length)); break; default: break; } result.chunks.push(chunk); pointer = pointer + 4 + 4 + length + 4; } const originFileData = zlib.unzipSync(Buffer.concat(fileChunkDatas)); // 這里原圖片數(shù)據(jù)太長了 我就只打印了長度 return { ...result, originFileData: originFileData.length, };}最終打印的結(jié)果,我們需要注意紅框的那幾個部分。可以看到上圖,位深和顏色類型決定了每個像素由4 byte組成,然后由于過濾方式的存在,會在每行的第一個字節(jié)進(jìn)行標(biāo)記。因此該圖的原始數(shù)據(jù)所占大小為:707 * 475 * 4 byte + 475 * 1 byte = 1343775 byte。正好是我們打印的結(jié)果。
我們也可以試試之前TinyPNG壓縮后的圖,如下:
可以看到位深為8,索引顏色類型的圖每像素占1 byte。計(jì)算得到:707 * 475 * 1 byte + 475 * 1 byte = 336300 byte。結(jié)果也正確。
現(xiàn)在再看如何進(jìn)行圖片壓縮,你可能很容易得到下面幾個結(jié)論:
1.減少不必要的輔助塊信息,因?yàn)檩o助塊對PNG圖片而言并不是必須的。
2.減少IDAT的塊數(shù),因?yàn)槊慷嘁粋€IDAT的塊,就多余了12 byte。
3.降低每個像素所占的內(nèi)存大小,比如當(dāng)前是4通道8位深的圖片,可以統(tǒng)計(jì)整個圖片色域,得到色階表,設(shè)置索引顏色類型,降低通道從而降低每個像素的內(nèi)存大小。
4.等等....
至于JPEG,WEBP等等格式圖片,有機(jī)會再看。溜了溜了~(還是使用現(xiàn)成的庫處理壓縮吧)。
好久沒寫文章,寫完才發(fā)現(xiàn)語雀不能免費(fèi)共享,發(fā)在這里吧。
更多node相關(guān)知識,請?jiān)L問:nodejs 教程!
以上就是怎么利用Node進(jìn)行圖片壓縮的詳細(xì)內(nèi)容,更多請關(guān)注php中文網(wǎng)其它相關(guān)文章!
關(guān)鍵詞: