png的故事:獲取圖片信息和像素內容
前言
現在時富媒體時代,圖片的重要性對于數十億互聯網用戶來說不言而喻,圖片本身就是像素點陣的合集,但是為了如何更快更好的存儲圖片而誕生了各種各樣的圖片格式:jpeg、png、gif、webp等,而這次我們要拿來開刀的,就是png。
簡介
首先,png是什么鬼?我們來看看wiki上的一句話簡介:
Portable Network Graphics (PNG) is a raster graphics file format that supports lossless data compression.
也就是說,png是一種使用 無損壓縮 的圖片格式,而大家熟知的另外一種圖片格式——jpeg則是采用有損壓縮的方式。用通俗易懂的方式來講,當原圖片數據被編碼成png格式后,是可以完全還原成原本的圖片數據的,而編碼成jpeg則會損耗一部分圖片數據,這是因為兩者的編碼方式和定位不同。jpeg著重于人眼的觀感,保留更多的亮度信息,去掉一些不影響觀感的色度信息,因此是有損耗的壓縮。png則保留原始所有的顏色信息,并且支持透明/alpha通道,然后采用無損壓縮進行編碼。因此對于jpeg來說,通常適合顏色更豐富、可以在人眼識別不了的情況下盡可能去掉冗余顏色數據的圖片,比如照片之類的圖片;而png適合需要保留原始圖片信息、需要支持透明度的圖片。
以下,我們來嘗試獲取png編碼的圖片數據:
結構
圖片是屬于2進制文件,因此在拿到png圖片并想對其進行解析的話,就得以二進制的方式進行讀取操作。png圖片包含兩部分:文件頭和數據塊。
文件頭
png的文件頭就是png圖片的前8個字節,其值為 [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A] ,人們常常把這個頭稱之為“魔數”。玩過linux的同學估計知道,可以使用 file 命令類判斷一個文件是屬于格式類型,就算我們把這個文件類型的后綴改得亂七八糟也可以識別出來,用的就是判斷“魔數”這個方法。有興趣的同學還可以使用 String.fromCharCode 將這個“魔數”轉成字符串看看,就知道為什么png會取這個值作為文件頭了。
用代碼來判斷也很簡單:
// 讀取指定長度字節
function readBytes(buffer, begin, length) {
return Array.prototype.slice.call(buffer, begin, begin + length);
}
letheader = readBytes(pngBuffer, 0, 8); // [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]
數據塊
去掉了png圖片等前8個字節,剩下的就是存放png數據的數據塊,我們通常稱之為 chunk 。
顧名思義,數據塊就是一段數據,我們按照一定規則對png圖片(這里指的是去掉了頭的png圖片數據,下同)進行切分,其中一段數據就是一個數據塊。每個數據塊的長度是不定的,我們需要通過一定的方法去提取出來,不過我們要先知道有哪些類型的數據塊才好判斷。
數據塊類型
數據塊類型有很多種,但是其中大部分我們都不需要用到,因為里面沒有存儲我們需要用到的數據。我們需要關注的數據塊只有以下四種:
- IHDR:存放圖片信息。
- PLTE:存放索引顏色。
- IDAT:存放圖片數據。
- IEND:圖片數據結束標志。
只要解析這四種數據塊就可以獲取圖片本身的所有數據,因此我們也稱這四種數據塊為 “關鍵數據塊” 。
數據塊格式
數據塊格式如下:
描述 | 長度 |
---|---|
數據塊內容長度 | 4字節 |
數據塊類型 | 4字節 |
數據塊內容 | 不定字節 |
crc冗余校驗碼 | 4字節 |
這樣我們就可以輕易的指導當前數據塊的長度了,即 數據塊內容長度 + 12字節 ,用代碼實現如下:
// 讀取32位無符號整型數
function readInt32(buffer, offset) {
offset = offset || 0;
return (buffer[offset] << 24) + (buffer[offset + 1] << 16) + (buffer[offset + 2] << 8) + (buffer[offset + 3] << 0);
}
letlength = readInt32(readBytes(4)); // 數據塊內容長度
lettype = readBytes(4); // 數據塊類型
letchunkData = readBytes(length); // 數據塊內容
letcrc = readBytes(4); // crc冗余校驗碼
這里的crc冗余校驗碼在我們解碼過程中用不到,所以這里不做詳解。除此之外,數據塊內容長度和數據塊內容好解釋,不過數據塊類型有何作用呢,這里我們先將這個 type 轉成字符串類型:
// 將buffer數組轉為字符串
function bufferToString(buffer) {
letstr = '';
for(let i=0, len=buffer.length; i<len; i++){
str += String.fromCharCode(buffer[i]);
}
return str;
}
type = bufferToString(type);
然后會發現type的值是四個大寫英文字母,沒錯,這就是上面提到的數據塊類型。上面還提到了我們只需要解析關鍵數據塊,因此遇到 type 不等于IHDR、PLTE、IDAT、IEND中任意一個的數據塊就直接舍棄好了。當我們拿到一個關鍵數據塊,就直接解析其數據塊內容就可以了,即上面代碼中的 chunkData 字段。
IHDR
類型為IHDR的數據塊用來存放圖片信息,其長度為固定的13個字節:
描述 | 長度 |
---|---|
圖片寬度 | 4字節 |
圖片高度 | 4字節 |
圖像深度 | 1字節 |
顏色類型 | 1字節 |
壓縮方法 | 1字節 |
過濾方式 | 1字節 |
掃描方式 | 1字節 |
其中寬高很好解釋,直接轉成32位整數,就是這張png圖片等寬高(以像素為單位)。壓縮方法目前只支持一種(deflate/inflate 壓縮算法),其值為0;過濾方式也只有一種(包含標準的5種過濾類型),其值為0;掃描方式有兩種,一種是逐行掃描,值為0,還有一種是Adam7隔行掃描,其值為1,此次只針對普通的逐行掃描方式進行解析,因此暫時不考慮Adam7隔行掃描。
圖片深度是指每個像素點中的每個通道(channel)占用的位數,只有1、2、4、8和16這5個值;顏色類型用來判斷每個像素點中有多少個通道,只有0、2、3、4和6這5個值:
顏色類型的值 | 占用通道數 | 描述 |
---|---|---|
0 | 1 | 灰度圖像,只有1個灰色通道 |
2 | 3 | rgb真彩色圖像,有RGB3色通道 |
3 | 1 | 索引顏色圖像,只有索引值一個通道 |
4 | 2 | 灰度圖像 + alpha通道 |
PLTE
類型為PLTE的數據塊用來存放索引顏色,我們又稱之為“調色板”。
由IHDR數據塊解析出來的圖像信息可知,圖像的數據可能是以索引值的方式進行存儲。當圖片數據采用索引值的時候,調色板就起作用了。調色板的長度和圖像深度有關,假設圖像深度的值是x,則其長度通常為 2的x次冪 * 3 。原因是圖像深度保存的就是通道占用的位數,而在使用索引顏色的時候,通道里存放的就是索引值,2點x次冪就表示這個通道可能存放的索引值有多少個,即調色板里的顏色數。而每個索引顏色是RGB3色通道存放的,因此此處還需要乘以3。
通常使用索引顏色的情況下,圖像深度的值即為8,因而調色板里存放的顏色就只有256種顏色,長度為 256 * 3 個字節。再加上1位布爾值表示透明像素,這就是我們常說的png8圖片了。
IDAT
類型為IDAT的數據塊用來存放圖像數據,跟其他關鍵數據塊不同的是,其數量可以是 連續 的復數個;其他關鍵數據塊在1個png文件里有且只有1個。
這里的數據得按順序把所有連續的IDAT數據塊全部解析并將數據聯合起來才能進行最終處理,這里先略過。
letdataChunks = [];
letlength = 0; // 總數據長度
// ...
while(/* 存在IDAT數據塊 */) {
dataChunks.push(chunkData);
length += chunkData.length;
}
IEND
當解析到類型為IEND的數據塊時,就表明所有的IDAT數據塊已經解析完畢,我們就可以停止解析了。
IEND整個數據塊的值時固定的: [0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82] ,因為IEND數據塊沒有數據塊內容,所以其數據塊內容長度字段(數據塊前4個字節)的值也是0。
解析
解壓縮
當我們收集完IDAT的所有數據塊內容時,我們要先對其進行解壓縮:
const zlib = require('zlib');
letdata = new Buffer(length);
letindex = 0;
dataChunks.forEach((chunkData) => {
chunkData.forEach((item) => {data[index++] = item});
});
// inflate解壓縮
data = zlib.inflateSync(new Buffer(data));
掃描
上面說過,此次我們只考慮逐行掃描的方式:
// 讀取8位無符號整型數
function readInt8(buffer, offset) {
offset = offset || 0;
return buffer[offset] << 0;
}
letwidth; // 解析IHDR數據塊時得到的圖像寬度
letheight; // 解析IHDR數據塊時得到的圖像高度
letcolors; // 解析IHDR數據塊時得到的通道數
letbitDepth; // 解析IHDR數據塊時得到的圖像深度
letbytesPerPixel = Math.max(1, colors * bitDepth / 8); // 每像素字節數
letbytesPerRow = bytesPerPixel * width; // 每行字節數
letpixelsBuffer = new Buffer(bytesPerPixel * width * height); // 存儲過濾后的像素數據
letoffset = 0; // 當前行的偏移位置
// 逐行掃描解析
for(let i=0, len=data.length; i<len; i+=bytesPerRow+1) {
letscanline = Array.prototype.slice.call(data, i+1, i+1+bytesPerRow); // 當前行
letargs = [scanline, bytesPerPixel, bytesPerRow, offset];
// 第一個字節代表過濾類型
switch(readInt8(data, i)) {
case 0:
filterNone(args);
break;
case 1:
filterSub(args);
break;
case 2:
filterUp(args);
break;
case 3:
filterAverage(args);
break;
case 4:
filterPaeth(args);
break;
default:
throw new Error('未知過濾類型!');
}
offset += bytesPerRow;
}
上面代碼前半部分不難理解,就是通過之前解析得到的圖像寬高,再加上圖像深度和通道數計算得出每個像素占用的字節數和每一行數據占用的字節數。因此我們就可以拆分出每一行的數據和每一個像素的數據。
在得到每一行數據后,就要進行這個png編碼里最關鍵的1步——過濾。
過濾
早先我們說過過濾方法只有1種,其中包含5種過濾類型,圖像每一行數據里的第一個字節就表示當前行數什么過濾類型。
png為什么要對圖像數據進行過濾呢?
大多數情況下,圖像的相鄰像素點的色值時很相近的,而且很容易呈現線性變化(相鄰數據的值是相似或有某種規律變化的),因此借由這個特性對圖像的數據進行一定程度的壓縮。針對這種情況我們常常使用一種叫 差分編碼 的編碼方式,即是記錄當前數據和某個標準值的差距來存儲當前數據。
比如說有這么一個數組 [99, 100, 100, 102, 103] ,我們可以將其轉存為 [99, 1, 0, 2, 1] 。轉存的規則就是以數組第1位為標準值,標準值存儲原始數據,后續均存儲以前1位數據的差值。
當我們使用了差分編碼后,再進行 deflate 壓縮的話,效果會更好(deflate壓縮是LZ77延伸出來的一種算法,壓縮頻繁重復出現的數據段的效果是相當不錯的,有興趣的同學可自行去了解)。
好,回到正題來講png的5種過濾類型,首先我們要定義幾個變量以便于說明:
C B
A X
- X:當前像素。
- A:當前像素點左邊的像素。
- B:當前像素點上邊的像素。
- C:當前像素點左上邊的像素。
過濾類型0:None
這個沒啥好解釋的,就是完全不做任何過濾。
function filterNone(scanline, bytesPerPixel, bytesPerRow, offset) {
for(let i=0; i<bytesPerRow; i++) {
pixelsBuffer[offset + i] = scanline[i];
}
}
過濾類型1:Sub
記錄 X – A 的值,即當前像素和左邊像素的差值。左邊起第一個像素是標準值,不做任何過濾。
function filterSub(scanline, bytesPerPixel, bytesPerRow, offset) {
for(let i=0; i<bytesPerRow; i++) {
if(i < bytesPerPixel) {
// 第一個像素,不作解析
pixelsBuffer[offset + i] = scanline[i];
} else {
// 其他像素
let a = pixelsBuffer[offset + i - bytesPerPixel];
letvalue = scanline[i] + a;
pixelsBuffer[offset + i] = value & 0xFF;
}
}
}
過濾類型2:Up
記錄 X – B 的值,即當前像素和上邊像素點差值。如果當前行是第1行,則當前行數標準值,不做任何過濾。
function filterUp(scanline, bytesPerPixel, bytesPerRow, offset) {
if(offset < bytesPerRow) {
// 第一行,不作解析
for(let i=0; i<bytesPerRow; i++) {
pixelsBuffer[offset + i] = scanline[i];
}
} else {
for(let i=0; i<bytesPerRow; i++) {
let b = pixelsBuffer[offset + i - bytesPerRow];
letvalue = scanline[i] + b;
pixelsBuffer[offset + i] = value & 0xFF;
}
}
}
過濾類型3:Average
記錄 X – (A + B) / 2 的值,即當前像素與左邊像素和上邊像素的平均值的差值。
- 如果當前行數第一行:做特殊的Sub過濾,左邊起第一個像素是標準值,不做任何過濾。其他像素記錄該像素與左邊像素的 二分之一 的值的差值。
- 如果當前行數不是第一行:左邊起第一個像素記錄該像素與上邊像素的 二分之一 的值的差值,其他像素做正常的Average過濾。
function filterAverage(scanline, bytesPerPixel, bytesPerRow, offset) {
if(offset < bytesPerRow) {
// 第一行,只做Sub
for(let i=0; i<bytesPerRow; i++) {
if(i < bytesPerPixel) {
// 第一個像素,不作解析
pixelsBuffer[offset + i] = scanline[i];
} else {
// 其他像素
let a = pixelsBuffer[offset + i - bytesPerPixel];
letvalue = scanline[i] + (a >> 1); // 需要除以2
pixelsBuffer[offset + i] = value & 0xFF;
}
}
} else {
for(let i=0; i<bytesPerRow; i++) {
if(i < bytesPerPixel) {
// 第一個像素,只做Up
let b = pixelsBuffer[offset + i - bytesPerRow];
letvalue = scanline[i] + (b >> 1); // 需要除以2
pixelsBuffer[offset + i] = value & 0xFF;
} else {
// 其他像素
let a = pixelsBuffer[offset + i - bytesPerPixel];
let b = pixelsBuffer[offset + i - bytesPerRow];
letvalue = scanline[i] + ((a + b) >> 1);
pixelsBuffer[offset + i] = value & 0xFF;
}
}
}
}
過濾類型4:Paeth
記錄 X – Pr 的值,這種過濾方式比較復雜,Pr的計算方式(偽代碼)如下:
p = a + b - c
pa = abs(p - a)
pb = abs(p - b)
pc = abs(p - c)
if pa <= pband pa <= pcthen Pr = a
else if pb <= pcthen Pr = b
else Pr = c
return Pr
- 如果當前行數第一行:做Sub過濾。
- 如果當前行數不是第一行:左邊起第一個像素記錄該像素與上邊像素的差值,其他像素做正常的Peath過濾。
function filterPaeth(scanline, bytesPerPixel, bytesPerRow, offset) {
if(offset < bytesPerRow) {
// 第一行,只做Sub
for(let i=0; i<bytesPerRow; i++) {
if(i < bytesPerPixel) {
// 第一個像素,不作解析
pixelsBuffer[offset + i] = scanline[i];
} else {
// 其他像素
let a = pixelsBuffer[offset + i - bytesPerPixel];
letvalue = scanline[i] + a;
pixelsBuffer[offset + i] = value & 0xFF;
}
}
} else {
for(let i=0; i<bytesPerRow; i++) {
if(i < bytesPerPixel) {
// 第一個像素,只做Up
let b = pixelsBuffer[offset + i - bytesPerRow];
letvalue = scanline[i] + b;
pixelsBuffer[offset + i] = value & 0xFF;
} else {
// 其他像素
let a = pixelsBuffer[offset + i - bytesPerPixel];
let b = pixelsBuffer[offset + i - bytesPerRow];
let c = pixelsBuffer[offset + i - bytesPerRow - bytesPerPixel];
let p = a + b - c;
letpa = Math.abs(p - a);
letpb = Math.abs(p - b);
letpc = Math.abs(p - c);
letpr;
if (pa <= pb && pa <= pc) pr = a;
else if (pb <= pc) pr = b;
else pr = c;
letvalue = scanline[i] + pr;
pixelsBuffer[offset + i] = value & 0xFF;
}
}
}
}
獲取像素
到這里,解析的工作就做完了,上面代碼里的 pixelsBuffer 數組里存的就是像素的數據了,不過我們要如何獲取具體某個像素的數據呢?方式可參考下面代碼:
letpalette; // PLTE數據塊內容,即調色板內容
letcolorType; // 解析IHDR數據塊時得到的顏色類型
function getPixel(x, y) {
if(x < 0 || x >= width || y < 0 || y >= height) {
throw new Error('x或y的值超出了圖像邊界!');
}
letbytesPerPixel = Math.max(1, colors * bitDepth / 8); // 每像素字節數
letindex = bytesPerPixel * ( y * width + x);
switch(colorType) {
case 0:
// 灰度圖像
return [pixelsBuffer[index], pixelsBuffer[index], pixelsBuffer[index], 255];
case 2:
// rgb真彩色圖像
return [pixelsBuffer[index], pixelsBuffer[index + 1], pixelsBuffer[index + 2], 255];
case 3:
// 索引顏色圖像
return [palette[pixelsBuffer[index] * 3 + 0], palette[pixelsBuffer[index] * 3 + 1], palette[pixelsBuffer[index] * 3 + 2], 255];
case 4:
// 灰度圖像 + alpha通道
return [pixelsBuffer[index], pixelsBuffer[index], pixelsBuffer[index], pixelsBuffer[index + 1]];
case 6:
// rgb真彩色圖像 + alpha通道
return [pixelsBuffer[index], pixelsBuffer[index + 1], pixelsBuffer[index + 2], pixelsBuffer[index + 3]];
}
}
尾聲
png的解析流程可以由這一張圖簡單概括:
此文只對png圖片的格式做了簡單的介紹,我們也知道如何對一張png圖片做簡單的解析。
參考資料:
- https://www.w3.org/TR/PNG/
- http://www.libpng.org/pub/png/
- https://en.wikipedia.org/wiki/Portable_Network_Graphics
來自:http://www.alloyteam.com/2017/03/the-story-of-png-get-images-and-pixel-content/