怎麼利用Node進行圖片壓縮

2023-03-20 22:00:25

怎麼利用Node進行圖片壓縮?下面本篇文章以PNG圖片為例給大家介紹一下進行圖片壓縮的方法,希望對大家有所幫助!

最近要搞影象處理服務,其中一個是要實現圖片壓縮功能。以前前端開發的時候只要利用canvas現成的API處理下就能實現,後端可能也有現成的API但我並不知道。仔細想想,我從來沒有詳細瞭解過圖片壓縮原理,那剛好趁這次去調研學習下,所以有了這篇文章來記錄。老樣子,如有不對的地方,DDDD(帶帶弟弟)。

我們先把圖片上傳到後端,看看後端接收了什麼樣的引數。這裡後端我用的是Node.js(),圖片我以PNG圖片為例。

介面和引數列印如下:

@Post('/compression')
@UseInterceptors(FileInterceptor('file'))
async imageCompression(@UploadedFile() file: Express.Multer.File) {

return {
file
}
}
登入後複製

要進行壓縮,我們就需要拿到影象資料。可以看到,唯一能藏匿影象資料的就是這串buffer。那這串buffer描述了什麼,就需要先弄清什麼是PNG。【相關教學推薦:、】

PNG

這裡是PNG的地址。

閱讀之後,我瞭解到PNG是由一個8 byte的檔案頭加上多個的塊(chunk)組成。示意圖如下:

其中:

檔案頭是由一個被稱為magic number的組成。值為 89 50 4e 47 0d 0a 1a 0a(16進位制)。它標記了這串資料是PNG格式。

塊分為兩種,一種叫關鍵塊(Critical chunks),一種叫輔助塊(Ancillary chunks)。關鍵塊是必不可少的,沒有關鍵塊,解碼器將不能正確識別並展示圖片。輔助塊是可選的,部分軟體在處理圖片之後就有可能攜帶輔助塊。每個塊都是四部分組成:4 byte 描述這個塊的內容有多長,4 byte 描述這個塊的型別是什麼,n byte 描述塊的內容(n 就是前面4 byte 值的大小,也就是說,一個塊最大長度為28*4),4 byte CRC校驗檢查塊的資料,標記著一個塊的結束。其中,塊型別的4 byte 的值為4個acsii碼,第一個字母大寫表示是關鍵塊小寫表示是輔助塊;第二個字母大寫表示是公有小寫表示是私有;第三個字母必須是大寫,用於PNG後續的擴充套件;第四個字母表示該塊不識別時,能否安全複製,大寫表示未修改關鍵塊時才能安全複製,小寫表示都能安全複製。PNG官方提供很多定義的塊型別,這裡只需要知道關鍵塊的型別即可,分別是IHDR,PLTE,IDAT,IEND。

IHDR

PNG要求第一個塊必須是IHDR。IHDR的塊內容是固定的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)

寬度和高度很容易理解,剩下的幾個好像都很陌生,接下來我將進行說明。

在說明位深之前,我們先來看顏色型別,顏色型別有5種值:

  • 0 表示灰度(grayscale)它只有一個通道(channel),看成rgb的話,可以理解它的三色通道值是相等的,所以不需要多餘兩個通道表示。

  • 2 表示真實色彩(rgb)它有三個通道,分別是R(紅色),G(綠色),B(藍色)。

  • 3 表示顏色索引(indexed)它也只有一個通道,表示顏色的索引值。該型別往往配備一組顏色列表,具體的顏色是根據索引值和顏色列表查詢得到的。

  • 4 表示灰度和alpha 它有兩個通道,除了灰度的通道外,多了一個alpha通道,可以控制透明度。

  • 6 表示真實色彩和alpha 它有四個通道。

之所以要說到通道,是因為它和這裡的位深有關。位深的值就定義了每個通道所佔的位數(bit)。位深跟顏色型別組合,就能知道圖片的顏色格式型別和每個畫素所佔的記憶體大小。PNG官方支援的組合如下表:

2023-03-17_180115.png

過濾和壓縮是因為PNG中儲存的不是影象的原始資料,而是處理後的資料,這也是為什麼PNG圖片所佔記憶體較小的原因。PNG使用了兩步進行了圖片資料的壓縮轉換。

第一步,過濾。過濾的目的是為了讓原始圖片資料經過該規則後,能進行更大的壓縮比。舉個例子,如果有一張漸變圖片,從左往右,顏色依次為[#000000, #000001, #000002, ..., #ffffff],那麼我們就可以約定一條規則,右邊的畫素總是和它前一個左邊的畫素進行比較,那麼處理完的資料就變成了[1, 1, 1, ..., 1],這樣是不是就能進行更好的壓縮。PNG目前只有一種過濾方式,就是基於相鄰畫素作為預測值,用當前畫素減去預測值。過濾的型別一共有五種,(目前我還不知道這個型別值在哪裡儲存,有可能在IDAT裡,找到了再來刪除這條括號裡的已確定該型別值儲存在IDAT資料中)如下表所示:

Type byteFilter namePredicted value
0None不做任何處理
1Sub左側相鄰畫素
2Up上方相鄰畫素
3AverageMath.floor((左側相鄰畫素 + 上方相鄰畫素) / 2)
4Paeth取(左側相鄰畫素 + 上方相鄰畫素 - 左上方畫素)最接近的值

第二步,壓縮。PNG也只有一種壓縮演演算法,使用的是DEFLATE演演算法。這裡不細說,具體看下面的章節。

交錯方式,有兩種值。0表示不處理,1表示使用Adam7 演演算法進行處理。我沒有去詳細瞭解該演演算法,簡單來說,當值為0時,圖片需要所有資料都載入完畢時,圖片才會顯示。而值為1時,Adam7會把圖片劃分多個區域,每個區域逐級載入,顯示效果會有所優化,但通常會降低壓縮效率。載入過程可以看下面這張gif圖。

PLTE

PLTE的塊內容為一組顏色列表,當顏色型別為顏色索引時需要設定。值得注意的是,顏色列表中的顏色一定是每個通道8bit,每個畫素24bit的真實色彩列表。列表的長度,可以比位深約定的少,但不能多。比如位深是2,那麼22,最多4種顏色,列表長度可以為3,但不能為5。

IDAT

IDAT的塊內容是圖片原始資料經過PNG壓縮轉換後的資料,它可能有多個重複的塊,但必須是連續的,並且只有當上一個塊填充滿時,才會有下一個塊。

IEND

IEND的塊內容為0 byte,它表示圖片的結束。

閱讀到這裡,我們把上面的介面改造一下,解析這串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,剛好我去壓縮我那張測試圖之後進行上傳,發現有PLTE塊,可以看一下,結果如下圖。

通過比對這兩張圖,壓縮圖片的方式我們也能窺探一二。

PNG的壓縮

前面說過,PNG使用的是一種叫DEFLATE的無失真壓縮演演算法,它是Huffman Coding跟LZ77的結合。除了PNG,我們經常使用的壓縮檔案,.zip,.gzip也是使用的這種演演算法(7zip演演算法有更高的壓縮比,也可以瞭解下)。要了解DEFLATE,我們首先要了解Huffman Coding和LZ77。

Huffman Coding

哈夫曼編碼忘記在大學的哪門課接觸過了,它是一種根據字元出現頻率,用最少的字元替換出現頻率最高的字元,最終降低平均字元長度的演演算法。

舉個例子,有字串"ABCBCABABADA",如果按照正常空間儲存,所佔記憶體大小為12 * 8bit = 96bit,現對它進行哈夫曼編碼。

1.統計每個字元出現的頻率,得到A 5次 B 4次 C 2次 D 1次

2.對字元按照頻率從小到大排序,將得到一個佇列D1,C2,B4,A5

3.按順序構造哈夫曼樹,先構造一個空節點,最小頻率的字元分給該節點的左側,倒數第二頻率的字元分給右側,然後將頻率相加的值賦值給該節點。接著用賦值後節點的值和倒數第三頻率的字元進行比較,較小的值總是分配在左側,較大的值總是分配在右側,依次類推,直到佇列結束,最後把最大頻率和前面的所有值相加賦值給根節點,得到一棵完整的哈夫曼樹。

4.對每條路徑進行賦值,左側路徑賦值為0,右側路徑賦值為1。從根節點到葉子節點,進行遍歷,遍歷的結果就是該字元編碼後的二進位制表示,得到:A(0)B(11)C(101)D(100)。

完整的哈夫曼樹如下(忽略箭頭,沒找到連線- -!):

壓縮後的字串,所佔記憶體大小為5 * 1bit + 4 * 2bit + 2 * 3bit + 1 * 3bit = 22bit。當然在實際傳輸過程中,還需要把編碼表的資訊(原始字元和出現頻率)帶上。因此最終佔比大小為 4 * 8bit + 4 * 3bit(頻率最大值為5,3bit可以表示)+ 22bit = 66bit(理想狀態),小於原有的96bit。

LZ77

LZ77演演算法還是第一次知道,查了一下是一種基於字典和滑動窗的無所壓縮演演算法。(題外話:因為Lempel和Ziv在1977年提出的演演算法,所以叫LZ77,哈哈哈?)

我們還是以上面這個字串"ABCBCABABADA"為例,現假設有一個4 byte的動態視窗和一個2byte的預讀緩衝區,然後對它進行LZ77演演算法壓縮,過程順序從上往下,示意圖如下:

總結下來,就是預讀緩衝區在動態視窗中找到最長相同項,然後用長度較短的標記來替代這個相同項,從而實現壓縮。從上圖也可以看出,壓縮比跟動態視窗的大小,預讀緩衝區的大小和被壓縮資料的重複度有關。

DEFLATE

DEFLATE【】是先使用LZ77編碼,對編碼後的結果在進行哈夫曼編碼。我們這裡不去討論具體的實現方法,直接使用其推薦庫Zlib,剛好Node.js內建了對Zlib的支援。接下來我們繼續改造上面那個介面,如下:

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,
};

// 因為可能有多個IDAT的塊 需要個陣列快取最後拼接起來
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));

// 這裡原圖片資料太長了 我就只列印了長度
return {
...result,
originFileData: originFileData.length,
};
}
登入後複製

最終列印的結果,我們需要注意紅框的那幾個部分。可以看到上圖,位深和顏色型別決定了每個畫素由4 byte組成,然後由於過濾方式的存在,會在每行的第一個位元組進行標記。因此該圖的原始資料所佔大小為:707 * 475 * 4 byte + 475 * 1 byte = 1343775 byte。正好是我們列印的結果。

我們也可以試試之前TinyPNG壓縮後的圖,如下:

可以看到位深為8,索引顏色型別的圖每畫素佔1 byte。計算得到:707 * 475 * 1 byte + 475 * 1 byte = 336300 byte。結果也正確。

總結

現在再看如何進行圖片壓縮,你可能很容易得到下面幾個結論:

1.減少不必要的輔助塊資訊,因為輔助塊對PNG圖片而言並不是必須的。

2.減少IDAT的塊數,因為每多一個IDAT的塊,就多餘了12 byte。

3.降低每個畫素所佔的記憶體大小,比如當前是4通道8位元深的圖片,可以統計整個圖片色域,得到色階表,設定索引顏色型別,降低通道從而降低每個畫素的記憶體大小。

4.等等....

至於JPEG,WEBP等等格式圖片,有機會再看。溜了溜了~(還是使用現成的庫處理壓縮吧)。

好久沒寫文章,寫完才發現語雀不能免費共用,發在這裡吧。

更多node相關知識,請存取:!

以上就是怎麼利用Node進行圖片壓縮的詳細內容,更多請關注TW511.COM其它相關文章!