1. 程式人生 > >使用Node.js解析PNG檔案

使用Node.js解析PNG檔案

寫上篇部落格前對Node的Stream的官方文件掃了一遍,之後還想繼續使用Stream寫些demo,就選擇了寫個小程式使用Node讀取解析PNG圖片(想的是如果可以方便地解析、生成PNG圖片,那就可以很方便地生成驗證碼圖片發給前端),結果就把自己坑了。。。PNG還是比較複雜的(以前 數字影象處理 的課中接觸的主要就是bmp、tiff,要麼就直接用OpenCV、GDAL直接讀取各種格式的圖片,還沒有仔細看過PNG的具體格式),由於時間關係我只解析了“非隔行掃描、非索引顏色、FilterMethod為0”的PNG圖片-_-||
使用Node的fs.createReadStream()可以建立一個檔案讀取流,在這裡我使用的是Paused模式(Paused模式和Flowing模式可以看上一篇的介紹),通過stream.read()方法可以比較精細地讀取readable流中的資料:

this.path = path;
this.stream = fs.createReadStream(this.path);
//使用paused模式
this.stream.pause();
this.stream.once('readable', ()=>{
   //使用stream.read()消耗readable資料流
   // ......
});

PNG 全稱是 Portable Network Graphics,即“行動式網路圖形”,是一種無失真壓縮的點陣圖圖形格式。其設計目的是試圖替代GIF和TIFF檔案格式,同時增加一些GIF檔案格式所不具備的特性。

PNG檔案結構

一個完整的PNG資料都是以一個PNG signature開頭和一系列資料塊(chunk)組成,其中第一個chunk為IHDR,最後一個chunk為IEDN。

PNG結構:
signature
chunk (IHDR)
chunk
chunk (IEDN)

官方文件的描述是:This signature indicates that the remainder of the datastream contains a single PNG image, consisting of a series of chunks beginning with an IHDR chunk and ending with an IEND chunk.

PNG Signature

PNG signature 位於PNG檔案的最開頭,佔8個位元組,每個位元組用十進位制可以表示為 [137, 80, 78, 71, 13, 10, 26, 10] ,通過下面的函式可以驗證signature的正確性:

checkSignature(){
     //PNG的Signature長度為8位元組, 1Byte = 8bit
     let buffer = this.stream.read(8);
     let signature = [137, 80, 78, 71, 13, 10, 26, 10];
     for(let i=0; i<signature.length; i++){
         let v = buffer.readUInt8(i);
         if(v !== signature[i]) 
             throw new Error('It is not PNG file !');
     }
     return true;
 }

PNG Chunk

PNG定義了兩種型別的資料塊,一種是稱為關鍵資料塊(critical chunk),這是標準的資料塊,另一種叫做輔助資料塊(ancillary chunks),這是可選的資料塊。關鍵資料塊定義了4個標準資料塊(IHDR, PLTE, IDAT, IEND),每個PNG檔案都必須包含它們(沒有PLTE的話就預設為RGB色),PNG讀寫軟體也都必須要支援這些資料塊。雖然PNG檔案規範沒有要求PNG編譯碼器對可選資料塊進行編碼和譯碼,但規範提倡支援可選資料塊。
下表就是PNG中資料塊的類別,其中,關鍵資料塊是前4個。

Chunk name Multiple allowed Ordering constraints
IHDR No Shall be first 檔案頭資料塊
PLTE No Before first IDAT 調色盤資料塊
IDAT Yes Multiple IDAT chunks shall be consecutive 影象資料塊
IEND No Shall be last 影象結束資料
cHRM No Before PLTE and IDAT 基色和白色點資料塊
gAMA No Before PLTE and IDAT 影象γ資料塊
iCCP No Before PLTE and IDAT. If the iCCP chunk is present, the sRGB chunk should not be present. ICCP
sBIT No Before PLTE and IDAT 樣本有效位資料塊
sRGB No Before PLTE and IDAT. If the sRGB chunk is present, the iCCP chunk should not be present. 標準RPG顏色
bKGD No After PLTE; before IDAT 背景顏色資料塊
hIST No After PLTE; before IDAT 影象直方圖資料塊
tRNS No After PLTE; before IDAT 影象透明資料塊
pHYs No Before IDAT 物理畫素尺寸資料塊
sPLT Yes Before IDAT 建議調色盤
tIME No None 影象最後修改時間資料塊
iTXt Yes None 國際文字資料
tEXt Yes None 文字資訊資料塊
zTXt Yes None 壓縮文字資料塊

每個chunk由4個部分組成(當Length=0時,就沒有chunk data),如下:

name meaning
Length A four-byte unsigned integer giving the number of bytes in the chunk’s data field. The length counts only the data field, not itself, the chunk type, or the CRC. Zero is a valid length. Although encoders and decoders should treat the length as unsigned, its value shall not exceed 2^31-1 bytes.
Chunk Type A sequence of four bytes defining the chunk type. Each byte of a chunk type is restricted to the decimal values 65 to 90 and 97 to 122. These correspond to the uppercase and lowercase ISO 646 letters (A-Z and a-z) respectively for convenience in description and examination of PNG datastreams. Encoders and decoders shall treat the chunk types as fixed binary values, not character strings. For example, it would not be correct to represent the chunk type IDAT by the equivalents of those letters in the UCS 2 character set.
Chunk Data The data bytes appropriate to the chunk type, if any. This field can be of zero length.
CRC A four-byte CRC (Cyclic Redundancy Code) calculated on the preceding bytes in the chunk, including the chunk type field and chunk data fields, but not including the length field. The CRC can be used to check for corruption of the data. The CRC is always present, even for chunks containing no data.

由於Length,Chunk Type,CRC的長度都是固定的(都是4位元組),而Chunk Data的長度由Length的值確定。因此解析每個Chunk時都需要確定Chunk的type和其data的長度。

  /**
   * 讀取資料塊的名稱和長度
   * Length 和 Name(Chunk type) 位於每個資料塊開頭
   * Length, Chunk type 各佔4bytes
   * @returns {{name: string, length: *}}
   */
  readHeadAndLength(){
      let buffer = this.stream.read(8);
      // 將Length的4bytes讀成一個32bits的整數
      let length = buffer.readInt32BE(0);
      let name = buffer.toString(undefined, 4, 8);
      return {name, length};
  }

我的demo中解析的主要chunk是IHDR和IDAT,後者相對複雜一點。通過遞迴逐個解析chunk:

 readChunk({name, length}){
     if(!length || !name){
         console.log(name, length);
         return;
     }

     switch(name){
         case 'IHDR':
             this.readChunk(this.readIHDR(name, length));
             break;
         case 'IDAT':
             this.readChunk(this.readIDAT(name, length));
             break;
         case 'PLTE':
             // 還不支援調色盤PLTE資料塊
             throw new Error('PLTE');
             break;
         default:
             // 跳過其他資料塊
             console.log('Skip',name,length);
             // length+4為data+CRC的資料長度
             this.stream.read(length+4);
             this.readChunk(this.readHeadAndLength());
     }
 }

IHDR 資料塊

IHDR資料塊是PNG資料的第一個資料塊,它是PNG檔案的標頭檔案資料,其Chunk Data由以下資訊組成:

Name Length
Width 4 bytes 影象寬度,以畫素為單位
Height 4 bytes 影象高度,以畫素為單位
Bit depth 1 bytes 影象深度。索引彩色影象: 1,2,4或8; 灰度影象: 1,2,4,8或16;真彩色影象:8或16
Colour type 1 bytes 顏色型別。0:灰度影象;2:真彩色影象;3:索引彩色影象;4:帶α通道資料的灰度影象;6:帶α通道資料的真彩色影象
Compression method 1 bytes 壓縮方法(壓縮IDAT的Chunk Data)
Filter method 1 bytes 濾波器方法
Interlace method 1 bytes 隔行掃描方法。0:非隔行掃描;1: Adam7

知道IHDR的data部分的組成後,可以使用以下程式碼可以解析IHDR資料塊的資訊,這些資訊對於解析IDAT資料十分重要:

  readIHDR(name, length){
      if(name !== 'IHDR') throw new Error('IHDR ERROR !');

      this.info = {};
      this.info.width = this.stream.read(4).readInt32BE(0);
      this.info.height = this.stream.read(4).readInt32BE(0);
      this.info.bitDepth = this.stream.read(1).readUInt8(0);
      this.info.coloType = this.stream.read(1).readUInt8(0);
      this.info.compression = this.stream.read(1).readUInt8(0);
      this.info.filter = this.stream.read(1).readUInt8(0);
      this.info.interlace = this.stream.read(1).readUInt8(0);
      console.log(this.info);
      //bands表示每個畫素包含的波段數(如RGBA為4波段)
      switch(this.info.coloType){
          case 0:
              this.info.bands = 1;
              break;
          case 2:
              this.info.bands = 3;
              break;
          case 3:
              // 不支援索引色
              throw new Error('Do not support this color type !');
              break;
          case 4:
              this.info.bands = 2;
              break;
          case 6:
              this.info.bands = 4;
              break;
          default:
              throw new Error('Unknown color type !');
      }
      // CRC
      this.stream.read(4);
  }

以截圖中的圖片為例,這是一張包含透明通道的5*5大小的PNG圖片,通過上面的程式碼得到其IHDR裡面的資訊:

測試圖片

{ width: 5,
  height: 5,
  bitDepth: 8,
  coloType: 6,
  compression: 0,
  filter: 0,
  interlace: 0 }

由IHDR的資訊可以知道,這張圖片是採用非隔行掃描、filter Method 為 0,帶α通道資料的真彩色影象,每個通道佔8位元,所以一個畫素佔4*8位元。

IDAT 資料塊

IDAT是影象資料塊,它儲存PNG實際的資料,在資料流中可包含多個連續順序的影象資料塊。IDAT存放著影象真正的資料資訊,因此,如果能夠了解IDAT中Chunk Data的結構,我們就可以很方便地解析、生成PNG影象。具體的步驟包括解壓、濾波等。

IDAT 資料塊 解壓

影象資料塊中的影象資料可能是經過變種的LZ77壓縮編碼DEFLATE壓縮的,關於DEFLATE詳細介紹可以參考《DEFLATE Compressed Data Format Specification version 1.3》,網址:http://www.ietf.org/rfc/rfc1951.txt 。可以使用Node的zlib模組直接解壓。zlib模組提供通過 Gzip 和 Deflate/Inflate 實現的壓縮、解壓功能,可以通過這樣使用它:

const zlib = require('zlib');
通過下面的程式碼可以將Chunk  Data解壓成濾波後的資料:
readIDAT(name, length){
      if(name !== 'IDAT') throw new Error('IDAT ERROR !');

      let buffer = this.stream.read(length);
      //解壓資料塊中data部分,得到真正的影象資料
      this.data = zlib.unzipSync(buffer);
      console.log("Unzip length", this.data.length);

      // CRC
      this.stream.read(4);
      return this.readHeadAndLength();
  }

對於前文提到的圖片,解壓前IDAT的Chunk Data大小為49位元組,解壓後的大小為105位元組。解壓後的資料是以左上角為起點。對於我這張圖片而言(非隔行掃描、filter Method 為 0,帶α通道資料的真彩色影象),按照RGBA RGBA RGBA排列資料,每行的開頭有一個Filter Type標識(佔1位元組)。下面的程式碼可以獲得每行的Filter Type:

 /**
  * 獲取每行的filter type
  * 每行有個1位元組長度的filterType
  * @param row
  * @returns {*}
  */
 getFilterType(row){
     let offset = this.info.bitDepth/8;
     let pointer = row * this.info.width * offset * this.info.bands + row;
     //讀每行最開頭的1位元組
     return this.readNum(this.data, pointer, 8);
 }

下面是解壓後的IDAT Chunk Data(濾波後的每個波段以及每行的Filter Type):

------Row0------
Filter type:1
[ 255, 0, 0, 255 ]
[ 0, 255, 255, 0 ]
[ 0, 1, 1, 0 ]
[ 0, 0, 0, 0 ]
[ 0, 0, 0, 0 ]
------Row1------
Filter type:2
[ 0, 0, 0, 0 ]
[ 0, 0, 0, 0 ]
[ 0, 0, 0, 0 ]
[ 0, 0, 0, 0 ]
[ 0, 0, 0, 0 ]
------Row2------
Filter type:4
[ 0, 255, 255, 0 ]
[ 0, 0, 0, 0 ]
[ 1, 0, 0, 0 ]
[ 0, 0, 0, 0 ]
[ 0, 0, 0, 0 ]
------Row3------
Filter type:1
[ 0, 0, 0, 255 ]
[ 252, 0, 0, 0 ]
[ 0, 0, 0, 0 ]
[ 0, 0, 0, 0 ]
[ 3, 255, 255, 1 ]
------Row4------
Filter type:4
[ 255, 255, 255, 0 ]
[ 0, 0, 0, 1 ]
[ 0, 0, 0, 0 ]
[ 0, 0, 0, 0 ]
[ 1, 1, 1, 255 ]

從中可以發現,原本第二行應該與第一行一模一樣,這裡卻全是0,其Filter Type為2,指Up濾波,也就是其值與上面一行對應。這樣的好處就是便於壓縮,減少空間。

IDAT 資料塊 濾波處理

PNG的具體濾波方法可以參考官方文件:PNG Filtering
知道了PNG的濾波方法後就可以恢復真正的影象資料。對於FilterMethod=0的濾波而言,定義了5種FilterType:

Type Name
0 None
1 Sub
2 Up
3 Average
4 Paeth

根據官方文件的介紹,我寫了下面的恢復濾波前的資料的方法:


/**
 * 處理filterMethod=0時整個影象中的一行
 * 這時每行都對應一種具體的FilterType
 * @param index
 * @param start
 * @param filterType
 * @param colByteLength
 * @returns {*}
 */
reconForNoneFilter(index, start, filterType, colByteLength){
    let pixelByteLength = this.info.bands*this.info.bitDepth/8;
    switch(filterType){
        case 0:
            //None
            return this.data[index];
            break;
        case 1:
            //Sub
            if(index-start-1<pixelByteLength)return this.data[index];
            else return this.data[index] + this.data[index-pixelByteLength];
        case 2:
            //Up
            return this.data[index] + this.data[index-colByteLength];
        case 3:
            //Average
            {
                let a=0,b=0;
                a = index-start-1<pixelByteLength?a:this.data[index-pixelByteLength];
                b = this.data[index-colByteLength];
                return this.data[index] + Math.floor((a+b)/2);
            }
        case 4:
            //Paeth
            {
                let a=0,b=0,c=0;
                b = this.data[index-colByteLength];
                if(index-start-1<pixelByteLength){
                    a = c =0;
                }else{
                    a = this.data[index-pixelByteLength];
                    if(start>=colByteLength){
                        c = this.data[index-pixelByteLength-colByteLength];
                    }
                }
                //PaethPredictor function
                let p = a + b - c;
                let pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c);
                let Pr = 0;
                if(pa <= pb && pa <= pc)Pr = a;
                else if(pb <= pc)Pr = b;
                else Pr = c;

                return Pr;
            }
        default:
            throw new Error('recon failed');
    }
}

恢復後的資料如下:

------Row0------
Filter type:1
[ 255, 0, 0, 255 ]
[ 255, 255, 255, 255 ]
[ 255, 0, 0, 255 ]
[ 255, 0, 0, 255 ]
[ 255, 0, 0, 255 ]
------Row1------
Filter type:2
[ 255, 0, 0, 255 ]
[ 255, 255, 255, 255 ]
[ 255, 0, 0, 255 ]
[ 255, 0, 0, 255 ]
[ 255, 0, 0, 255 ]
------Row2------
Filter type:4
[ 255, 0, 0, 255 ]
[ 255, 255, 255, 255 ]
[ 255, 0, 0, 255 ]
[ 255, 0, 0, 255 ]
[ 255, 0, 0, 255 ]
------Row3------
Filter type:1
[ 0, 0, 0, 255 ]
[ 252, 0, 0, 255 ]
[ 252, 0, 0, 255 ]
[ 252, 0, 0, 255 ]
[ 255, 255, 255, 0 ]
------Row4------
Filter type:4
[ 0, 0, 0, 255 ]
[ 252, 0, 0, 255 ]
[ 252, 0, 0, 255 ]
[ 252, 0, 0, 255 ]
[ 255, 255, 255, 0 ]

這時剛好能和前面提到的圖片對應上。^_^