Skia圖片解析流程與圖片編碼原理初探
這是來自我們組jinyi童鞋前些天的一篇分享,對於圖片編碼原理進行了初步的分析。
Part1 Skia圖片解析流程初探
Skia 圖形庫
在正式解析代碼之前, 先介紹一下 Skia 圖形庫 (https://skia.org/).
Skia 是 Google 研發的 2D 圖形庫, 提供了通用的圖形渲染介面. 被廣泛應用在在 Chrome, Chrome OS, Android 等軟體 / 系統中.
在 Android 中, Skia 包攬了視圖渲染的全部底層實現. 短時間內解析這樣一個龐大的基礎庫並不現實, 所以後文將聚焦在 Skia 的圖片解析部分.
如果對 Skia 有興趣, 推薦閱讀如下系列博客: Skia 深入分析.
下面, 就從 Java 層開始, 自上而下的看一看, 數據流是怎麼轉變為 Bitmap 的.
圖片解析流程 - Java 層
Java 層的內容其實很簡單, 主要為調用 CPP 代碼提供了入口. 以BitmapFactory.java 中的 decodeStream() 方法為例.
// Path: frameworks/base/graphics/java/android/graphics/BitmapFactory.javannpublic static Bitmap decodeStream(InputStream is, Rect outPadding, Options opts) {n // ...n return decodeStreamInternal(is, outPadding, opts);n}nnprivate static Bitmap decodeStreamInternal(InputStream is, Rect outPadding, Options opts) {n // ...n // Native 方法n byte [] tempStorage = new byte[DECODE_BUFFER_SIZE];n return nativeDecodeStream(is, tempStorage, outPadding, opts);n}n
圖片解析流程 - CPP 層
1. BitmapFactory
跟著 nativeDecodeStream() 方法, 我們找到了對應的 CPP 實現.
關鍵方法 doDecode() 的實現比較長 (300+行), 我們只關注核心的圖像解碼部分, 而這部分工作主要由 SkAndroidCodec 完成.
// Path: frameworks/base/core/jni/android/graphics/BitmapFactory.cppnn#include "SkAndroidCodec.h"nnstatic jobject nativeDecodeStream(JNIEnv *env, jobject clazz, jobject is, jbyteArray storage,n jobject padding, jobject options)n{n // ...n return doDecode(env, bufferedStream.release(), padding, options);n}nnstatic jobject doDecode(JNIEnv *env, SkStreamRewindable *stream, jobject padding, jobject options)n{n // [00] 初始化 Option 默認值, 對應 Java 代碼中的 BitmapFactory.Options 結構體n // ...nn // [01] 根據傳入的 Options 更新上述值n // ...nn // [02] 創建解碼器 (重要)n // ...n std::unique_ptr<SkAndroidCodec> codec(SkAndroidCodec::NewFromStream(streamDeleter.release(), &peeker));nn // [03] 輸出的圖片大小, 根據採樣大小, 縮放比例等因素調整 (重要)n // ...nn // [04] 處理復用的 Bitmap, 對應 Java 代碼中的 BitmapFactory.Options 中的 inBitmapn // 如果有可復用的 Bitmap, 且 isMutable == false 才執行n // ...nn // [05] 創建對應的內存分配器n // ...nn // [06] 輸出圖片的色彩類型, 根據請求的類型選擇最合適的類型 (重要)n SkColorType decodeColorType = codec->computeOutputColorType(prefColorType);nn // [07] 構建色碼錶, 以便解碼時使用n // ...nn // [08] 設置透明度等, 並構造對應的解碼器配置n // ...nn // [09] 利用 SkAndroidCodec 完成解碼 (重要)n // CPP 中的 Bitmap, 即 decodingBitmap, 在這一步初步成型n // ...n SkCodec::Result result = codec->getAndroidPixels(n decodeInfo, // 解碼信息 (長度, 寬度, 色彩類型, 透明度等)n decodingBitmap.getPixels(), // Bitmap 的實際內存地址, 即像素們的內存地址n decodingBitmap.rowBytes(), // 每一行的大小, 即每一行所有像素的大小n &codecOptions); // 解碼器配置 (色碼錶, 採樣大小等)n // ...nn // [10] .9.PNG 處理n // ...nn // [11] 縮放 decodingBitmap, 實際上可以在上一步解碼時同步完成n // 放在這裡執行主要是為了兼容 Dalvik 虛擬機n // ...nn // [12] 設置 paddingn // ...nn // [13] 將 CPP 中的 Bitmap 轉換為 Java 中的 Bitmap, 即生成對應的 jobjectn // ...n}n
從代碼中, 我們可以發現, SkAndroidCodec 調用了 getAndroidPixels() 方法. 乍一眼看, 這個方法的返回值是一個表示解碼成功與否的枚舉類型, 但實質的解碼 & 生成 Bitmap 的關鍵步驟也在這個方法的實現中.
因為編碼習慣的不同, CPP 中經常會將返回值以指針 / 引用的形式作為入參, 而 decodingBitmap.getPixels() 所返回的恰是指向 Bitmap 內存地址的指針.
我們跟著 getAndroidPixels() 方法, 看一看具體的實現.
2. SkAndroidCodec
// Path: external/skia/src/codec/SkAndroidCodec.cppnn/**n * 圖像解碼器抽象類 (實現)n */nnSkCodec::Result SkAndroidCodec::getAndroidPixels(const SkImageInfo &info, void *pixels,n size_t rowBytes, const AndroidOptions *options)n{n // ...n return this->onGetAndroidPixels(info, pixels, rowBytes, *options);n}n
通過源碼, 我們發現 getAndroidPixels() 方法最終會調用 onGetAndroidPixels() 方法.
為了更好的解析 onGetAndroidPixels() 方法, 我們先簡要了解一下 SkAndroidCodec 及其子類的一些特性.
// Path: external/skia/include/codec/SkAndroidCodec.hnn/**n * 圖像解碼器抽象類 (頭文件)n */n nclass SkAndroidCodec : SkNoncopyablen{nprivate:n // ...n // SkAndroidCodec 內部負責解碼的 Codecn SkAutoTDelete<SkCodec> fCodec;n}n
通過源碼, 我們可以知道 SkAndroidCodec 更像一個 wrapper, 實際負責解碼的是其內部的 SkCodec, 而我們馬上將看到, SkAndroidCodec 的各個子類也各有一個繼承自 SkCodec 的內部解碼器.
// Path: external/skia/src/codec/SkAndroidCodec.cppnn/**n * 圖像解碼器抽象類 (實現)n */nnSkAndroidCodec *SkAndroidCodec::NewFromStream(SkStream *stream, SkPngChunkReader *chunkReader)n{n SkAutoTDelete<SkCodec> codec(SkCodec::NewFromStream(stream, chunkReader));n // ...n n // 獲取實際負責解碼的 Codec (階段 1)n switch (codec->getEncodedFormat())n {n case kPNG_SkEncodedFormat:n case kICO_SkEncodedFormat:n case kJPEG_SkEncodedFormat:n case kGIF_SkEncodedFormat:n case kBMP_SkEncodedFormat:n case kWBMP_SkEncodedFormat:n return new SkSampledCodec(codec.detach());n case kWEBP_SkEncodedFormat:n return new SkWebpAdapterCodec((SkWebpCodec *)codec.detach());n case kRAW_SkEncodedFormat:n return new SkRawAdapterCodec((SkRawCodec *)codec.detach());n default:n return nullptr;n }n}n
下面的表格羅列了繼承自 SkAndroidCodec 和 SkCodec 的解碼器, 及其對應關係和可處理的圖片類型
從這裡開始, 我們假設輸入的數據流是 JPEG 圖像的, 其所對應的解碼器即 SkSampledCodec 和 SkCodec (實際上是 SkCodec 的子類 SkJpegCodec), 看一看 onGetAndroidPixels() 在其中的具體實現.
3. SkSampledCodec 和 SkCodec
// Path: external/skia/src/codec/SkSampledCodec.cppnnSkCodec::Result SkSampledCodec::onGetAndroidPixels(const SkImageInfo &info, void *pixels,n size_t rowBytes, const AndroidOptions &options)n{n // [00] 創建供解碼器使用的 Options 結構體n // ...nn // [01] 如果無法支持要求的大小, 使用採樣解碼 (Sampled Decode)n // ...nn // [02] 如果支持要求的大小, 使用逐行解碼 (Scanline Decode)n // ...n SkCodec::Result result = this->codec()->startScanlineDecode(info.makeWH(scaledSize.width(),n scaledSize.height()),n &codecOptions, options.fColorPtr, options.fColorCount);n // [03] 處理解碼結果 (成功, 不完全解碼, 失敗等情況)n // ...n}n
可以看到, onGetAndroidPixels() 實際上調用了 startScanlineDecode() 方法.
// Path: external/skia/src/codec/SkCodec.cppnnSkCodec::Result SkCodec::startScanlineDecode(const SkImageInfo &dstInfo,n const SkCodec::Options *options, SkPMColor ctable[], int *ctableCount)n{n // [00] 初始化 & 設置 Optionsn // ...nn // [01] 開始逐行解碼 (初始化)n const Result result = this->onStartScanlineDecode(dstInfo, *options, ctable, ctableCount);n // ...nn // [02] 實際執行逐行解碼, 並將結果寫入對應的內存地址n switch (this->codec()->getScanlineOrder()) {n case SkCodec::kTopDown_SkScanlineOrder:n case SkCodec::kNone_SkScanlineOrder: {n // ...n int decodedLines = this->codec()->getScanlines(pixels, scaledSubsetHeight, rowBytes);n // ...n return SkCodec::kSuccess;n }n default:n // ...n return SkCodec::kUnimplemented;n }n}n
進一步查看 startScanlineDecode(), 其中有兩個關鍵方法: onStartScanlineDecode() 和 getScanlines().
後文中, 我們會看到, onStartScanlineDecode() 負責解壓數據流, 獲得數據流中的圖片基礎信息. 而 getScanlines() 才是實際逐行解壓圖片的關鍵.
但在 SkCodec 中並沒有 getScanlines() 需要的關鍵實現, 仔細一看, 會發現 SkCodec 把任務委託給了自己的子類 SkJpegCodec.
// Path: external/skia/src/codec/SkCodec.cppnnstatic const DecoderProc gDecoderProcs[] = {n {SkJpegCodec::IsJpeg, SkJpegCodec::NewFromStream},n {SkWebpCodec::IsWebp, SkWebpCodec::NewFromStream},n {SkGifCodec::IsGif, SkGifCodec::NewFromStream},n {SkIcoCodec::IsIco, SkIcoCodec::NewFromStream},n {SkBmpCodec::IsBmp, SkBmpCodec::NewFromStream},n {SkWbmpCodec::IsWbmp, SkWbmpCodec::NewFromStream}};nnSkCodec *SkCodec::NewFromStream(SkStream *stream,n SkPngChunkReader *chunkReader)n{n // ...n n for (DecoderProc proc : gDecoderProcs)n {n if (proc.IsFormat(buffer, bytesRead))n {n // 獲取實際負責解碼的 Codec (階段 2)n return proc.NewFromStream(streamDeleter.detach());n }n }n n // ...n}n
從這部分源碼中, 我們可以看到 SkCodec 在初始化的時候, 會逐個匹配對應的子類, 如果圖片格式相符, 並返回對應的子類來處理解碼工作.
所以, 順著這個線索, 我們來看一看 SkJpegCodec 是如何處理 JPEG 解碼的.
4. SkJpegCodec
// Path: external/skia/src/codec/SkJpegCodec.cppnnextern "C" {n #include "jpeglib.h"n}nnSkCodec::Result SkJpegCodec::onStartScanlineDecode(const SkImageInfo &dstInfo,n const Options &options, SkPMColor ctable[], int *ctableCount)n{n // ...nn // 開始圖像解壓, jpeglib 的方法n jpeg_start_decompress(fDecoderMgr->dinfo())nn // ...nn return kSuccess;n}n
首先是 onStartScanlineDecode() 這裡負責逐行解碼的初始化 (解壓數據流), 實際上是調用了 jpeglib 完成的.
// Path: external/skia/src/codec/SkCodec.cppnnint SkCodec::getScanlines(void *dst, int countLines, size_t rowBytes)n{n // ...nn const int linesDecoded = this->onGetScanlines(dst, countLines, rowBytes);n n // ...n}n
回頭看 SkCodec 中的 getScanlines() 方法, 我們會發現實際的執行者是 onGetScanlines(). 而該方法的具體實現是在 SkCodec 的各個子類中.
// Path: external/skia/src/codec/SkJpegCodec.cppnnextern "C" {n #include "jpeglib.h"n}nnint SkJpegCodec::onGetScanlines(void *dst, int count, size_t dstRowBytes)n{n // ...nn for (int y = 0; y < count; y++)n {n // [01] 逐行解壓 & 讀取像素, jpeglib 的方法n uint32_t rowsDecoded = jpeg_read_scanlines(fDecoderMgr->dinfo(), &dstRow, 1);nn // [02] 寫入對應的內存地址n // ...n }n n // ...n}n
從 SkJpegCodec 的源碼中我們可以看到, 實際的解碼工作還是交由 jpeglib 的 jpeg_read_scanlines() 方法完成.
至此, 我對 Skia 庫的圖片解析過程暫時告一段落了. 最初是想看一看 Skia 是否有自己實現的解碼演算法, 是不是有什麼特別之處.
但當發現 Skia 最終調用 jpeglib 來完成最關鍵的步驟之後, Skia 在 Android 的圖像解析中發揮實質作用便簡化成了如下兩件事:
- 適配 Android 的圖像格式 (防禦, 選擇最合適的色彩格式等);
- 分配內存空間 (引入了 Android 適用的內存管理方式), 以便 jpeglib 等經典圖像庫後續使用.
所以Part2將會單獨講解 JPEG, PNG, WebP 等常見格式的編碼 / 壓縮原理.
Part2 圖片編碼原理淺析
JPEG
不考慮採樣的過程, JPEG 的生成大概有以下幾個步驟.
下面簡要介紹一下幾個關鍵的步驟, 其中每一步都是基於大小為 8×88×8 的圖片區域進行的.
1. RGB 轉換 YCbCr
首先是將 RGB 的色彩表示轉換為 YCbCr, 其中 Y 表示亮度 / 照度 (luminance), Cb / Cr 表示藍色 / 紅色的相關信息 (blue-difference / red-difference chroma)
之所以做這樣的轉換是因為, 人類的視覺系統對於亮度更為敏感, 對色彩的敏感程度稍弱.
將 RGB 轉換為 YCbCr 後, 我們可以通過削減 (50% / 75%) 色彩信息的方式來減少圖片的體積.
2. 離散餘弦變換 (DCT)
每一個小的圖片區域都是 8×88×8 的大小, 離散餘弦變換所要做的便是, 找到一個方法來表示任一 8×88×8 的區域.
下圖就是用來表示任一 8×88×8 的區域的基 (basis patterns), 其中每一個小方塊也都是 8×88×8 的大小.
這樣描述起來可能比較抽象, 下面用一個簡單的例子來解釋一下.
明白了 DCT 變換的大致過程, 我們就可以把每一個 8×88×8 的圖像 (左) 都轉變為 8×88×8 的係數矩陣 (右).
3. 量化 (Quantize)
得到係數矩陣之後, 我們便可以做對應的量化計算.
平時, 我們用 PS 等軟體將圖片輸出為 JPEG 前, 都會彈出選擇圖片質量的彈窗. 而這一步, 正好對應這裡的量化計算.
通過將係數矩陣中的數據除以量化陣 (左) 中對應的數值, 就能得到量化後的係數矩陣 (右).
不同的量化陣會輸出不同質量的圖片, 簡單來說, 量化陣中的數值越小, 圖片質量越好, 但相應的圖片體積也越大.
那量化後的數據如何進一步編碼 / 壓縮呢? 首先, 需要按照右圖中箭頭所示的方向, 將 2 維的矩陣轉變為 1 維的數字序列.
以右圖為例的話, 我們將得到如下的數字序列:
15, 0, -2, -1, -1, -1, 0, 0, -1, 0, ...
4. 遊程 (Run-level) 編碼 & Huffman 編碼
獲得上述的數字序列後, 我們將通過 遊程編碼 & Huffman 編碼進行進一步的壓縮.
因為 Huffman 編碼大家都比較熟悉, 我們這裡只介紹遊程編碼.
以特定數字序列為例子:
原始序列: 15, 2, 5, 0, 0, -1, 0, 0, 0, 0, 0, 0, 1, ...
遊程編碼: (0, 15), (0, 2), (0, 5), (2, -1), (6, 1), ...
我們很容易看到, 量化後的係數矩陣中, 包含大量連續的 0, 這也就使得遊程編碼可以最大限度的壓縮數據.
最後, 再將遊程編碼後的數據通過 Huffman 編碼壓縮為最終的結果.
以上, 就是 JPEG 的編碼過程, 後面我們將會看到 WebP 的有損編碼過程有很多借鑒 JPEG 的地方.
PNG
同 JPEG 不同, PNG 作為一種無損的圖片格式, 在其編碼過程中不能隨意的捨棄亮度 / 色彩等信息. 所以, 為了壓縮圖片體積, 只能在編碼方式上動腦筋.
1. 預測
預測作為一種減小體積的方法, 在 DSP 領域內由來已久.
以下圖為例, 左側的圖片是樣本的分布, 下方的圖片則是殘差的分布.
簡單來說, 可以把前者視為實際的數據, 後者視為 (預測值 - 實際值) 的分布. 顯然, 後者更有規律, 壓縮起來效果也更好.
在 PNG 中, 我們引入了如下的預測方法, 下圖中 X 是我們需要預測的像素值, 而預測結果由 A, B 和 C 三個像素值共同決定.
下表列出了 5 種不同模式下預測值的生成方式, 具體選擇哪種模式可以獲得最優的預測, 可以參考: Filter Algorithms & Filter selection.
通過預測的方法, 我們用殘差替代了原始數據. 下一步就是把殘差進一步編碼壓縮.
2. DEFLATE 演算法
DEFLATE 融合了 LZ77 和 Huffman 兩種編碼方式, 除了應用於 PNG 的編碼, DEFLATE 同樣是 gzip 的重要組成部分.
基於同樣的原因, 我們略去 Huffman 編碼, 只介紹 LZ77 演算法.
首先介紹幾個基本概念:
LAB (look-ahead buffer), 表示即將被編碼的內容 (定長);
SB (search buffer), 表示已經被編碼, 可用於檢索的內容 (定長);Token, 表示編碼了的元組.
假設我們需要對字元串 cabbdcbaacbdddabda 進行編碼, 其中 LAB 長度為 3, SB 長度為 7.
通過上述編碼, 我們最終獲得了對應的輸出. 其後, 可再對該輸出進一步進行 Huffman 編碼.
以上, 便是 PNG 格式的編碼原理, 後面我們將看到, WebP 同樣也從中借鑒了部分思想.
WebP
通過上面對 JPEG 和 PNG 的介紹, 我們會發現 WebP 的有損模式也並沒有什麼神秘的.
簡單來說, WebP 的有損編碼分為下面幾步:
1.預測, 然後根據預測值生成殘差;
2.對殘差做離散餘弦變換 & 量化處理;3.對量化的結果進行遊程 & Huffman 編碼.
大體來說, 基本涵蓋了我們介紹過的編碼 / 壓縮思路.
這裡簡要介紹一下 WebP 所採用的預測方法.
Google 官方的介紹中, WebP 會有三種預測模式:
- 亮度 / 照度 (luminance)
- 4×44×4
- 16×1616×16
- 色彩 (chroma)
- 8×88×8
以 4×44×4 的亮度預測為例, 除了傳統的 9 種預測模式 (下側圖), Google 還引入了自創的 True Motion 模式 (上側圖中紅色方框).
以上便是 WebP 的有損編碼的原理.
推薦閱讀:
※[譯] 網路請求框架 Retrofit 2 使用入門
※Android手機 全面屏(18:9屏幕)適配指南
※跪求android藍牙小車的上位機程序?最好能具體的,謝了