Android 中圖片壓縮分析(上)

騰訊雲加社群發表於2017-11-13

作者: shawnzhao,QQ音樂技術團隊 一員

一、前言

在 Android 中進行圖片壓縮是非常常見的開發場景,主要的壓縮方法有兩種:其一是質量壓縮,其二是下采樣壓縮。

前者是在不改變圖片尺寸的情況下,改變圖片的儲存體積,而後者則是降低影像尺寸,達到相同目的。

由於本文的篇幅問題,分為上下兩篇釋出。

二、Android 質量壓縮邏輯

在Android中,對圖片進行質量壓縮,通常我們的實現方式如下所示:

ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
//quality 為0~100,0表示最小體積,100表示最高質量,對應體積也是最大
bitmap.compress(Bitmap.CompressFormat.JPEG, 100, outputStream);複製程式碼

在上述程式碼中,我們選擇的壓縮格式是CompressFormat.JPEG,除此之外還有兩個選擇:

其一,CompressFormat.PNG, PNG 格式是無損的,它無法再進行質量壓縮,quality 這個引數就沒有作用了,會被忽略,所以最後圖片儲存成的檔案大小不會有變化;
其二,CompressFormat.WEBP ,這個格式是 google 推出的圖片格式,它會比 JPEG 更加省空間,經過實測大概可以優化 30% 左右。

由於專案原因和相容性選擇了JPEG,因此接下來的分析也將是圍繞 JPEG 展開。

將 PNG 圖片轉成 JPEG 格式之後不會降低這個圖片的尺寸,但是會降低視覺質量,從而降低儲存體積。同時,由於尺寸不變,所以將這個圖片解碼成相同色彩模式的 bitmap 之後,佔用的記憶體大小和壓縮前是一樣的。

回到最初的程式碼示例,函式 compress 經過一連串的 java 層呼叫之後,最後來到了一個 native 函式,如下:

//Bitmap.cpp
static jboolean Bitmap_compress(JNIEnv* env, jobject clazz, jlong bitmapHandle,
                                jint format, jint quality,
                                jobject jstream, jbyteArray jstorage) {

    LocalScopedBitmap bitmap(bitmapHandle);
    SkImageEncoder::Type fm;

    switch (format) {
    case kJPEG_JavaEncodeFormat:
        fm = SkImageEncoder::kJPEG_Type;
        break;
    case kPNG_JavaEncodeFormat:
        fm = SkImageEncoder::kPNG_Type;
        break;
    case kWEBP_JavaEncodeFormat:
        fm = SkImageEncoder::kWEBP_Type;
        break;
    default:
        return JNI_FALSE;
    }

    if (!bitmap.valid()) {
        return JNI_FALSE;
    }

    bool success = false;

    std::unique_ptr<SkWStream> strm(CreateJavaOutputStreamAdaptor(env, jstream, jstorage));
    if (!strm.get()) {
        return JNI_FALSE;
    }

    std::unique_ptr<SkImageEncoder> encoder(SkImageEncoder::Create(fm));
    if (encoder.get()) {
        SkBitmap skbitmap;
        bitmap->getSkBitmap(&skbitmap);
        success = encoder->encodeStream(strm.get(), skbitmap, quality);
    }
    return success ? JNI_TRUE : JNI_FALSE;
}複製程式碼

可以看到最後呼叫了函式 encoder->encodeStream(....) 編碼儲存本地。該函式是呼叫 skia 引擎來對圖片進行編碼壓縮,對 skia 的介紹將在後文展開。

一段完整的示例程式碼如下:

// R.drawable.thumb 為 png 圖片
Bitmap bitmap = BitmapFactory.decodeResource(getResources(), R.drawable.thumb);
try {
    //儲存壓縮圖片到本地
    File file = new File(Environment.getExternalStorageDirectory(), "aaa.jpg");
    if (!file.exists()) {
        file.createNewFile();
    }
    FileOutputStream fs = new FileOutputStream(file);
    bitmap.compress(Bitmap.CompressFormat.JPEG, 50, fs);
    Log.i(TAG, "onCreate: file.length " + file.length());
    fs.flush();
    fs.close();
} catch (FileNotFoundException e) {
    e.printStackTrace();
} catch (IOException e) {
    e.printStackTrace();
}
//檢視壓縮之後的 Bitmap 大小
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
bitmap.compress(Bitmap.CompressFormat.JPEG, 50, outputStream);
byte[] bytes = outputStream.toByteArray();
Bitmap compress = BitmapFactory.decodeByteArray(bytes, 0, bytes.length);
Log.i(TAG, "onCreate: bitmap.size = " + bitmap.getByteCount() + "   compress.size = " + compress.getByteCount());複製程式碼

首先,我們來看看 quality 引數被設定為 50,質量壓縮前後的圖片對比,可以看到其尺寸大小並沒有變化,但是視覺感受也可以明顯地看到圖片變的模糊了一些。


 

通過日誌也可以看到,在質量壓縮前後圖片轉成 Bitmap 之後在記憶體中的大小也並沒有變化,這是在保持畫素的前提下,改變圖片的位深及透明度等:

//壓縮之後圖片佔用的儲存體積
compress.length = 7814
//在記憶體中壓縮前後圖片佔用的大小
bitmap.size = 350000   compress.size = 350000複製程式碼

對比二者,儲存前的圖片儲存體積是 106k,質量設為 50 並且儲存為 JPEG 格式之後,圖片儲存大小就只有 8k 了,並且質量設的越低,儲存成檔案之後,檔案的體積也就越小。

三、Android Skia 影像引擎

在上文中,提到的Skia是Android 的重要組成部分。

Skia 是一個 Google 自己維護的 c++ 實現的影像引擎,實現了各種影像處理功能,並且廣泛地應用於谷歌自己和其它公司的產品中(如:Chrome、Firefox、 Android等),基於它可以很方便為作業系統、瀏覽器等開發影像處理功能。

Skia 在 Android 中提供了基本的畫圖和簡單的編解碼功能,可以掛接其他的第三方編碼解碼庫或者硬體編解碼庫,例如 libpng 和 libjpeg,libgif 等等。因此,這個函式呼叫bitmap.compress(Bitmap.CompressFormat.JPEG...),實際會呼叫 libjpeg.so 動態庫進行編碼壓縮。

最終 Android 編碼儲存圖片的邏輯是 Java 層函式→Native 函式→Skia函式→對應第三庫函式(例如 libjpeg)。所以 skia 就像一個膠水層,用來連結各種第三方編解碼庫,不過 Android 也會對這些庫做一些修改,比如修改記憶體管理的方式等等。

Android 在之前從某種程度來說使用的算是 libjpeg 的功能閹割版,壓縮圖片預設使用的是 standard huffman,而不是 optimized huffman,也就是說使用的是預設的哈夫曼表,並沒有根據實際圖片去計算相對應的哈夫曼表,Google 在初期考慮到手機的效能瓶頸,計算圖片權重這個階段非常佔用 CPU 資源的同時也非常耗時,因為此時需要計算圖片所有畫素 argb 的權重,這也是 Android 的圖片壓縮率對比 iOS 來說差了一些的原因之一。

四、影像壓縮與 Huffman 演算法

這裡簡單介紹一下哈夫曼演算法,哈夫曼演算法是在多媒體處理裡常用的演算法之一。比如一個檔案中可能會出現五個值 a,b,c,d,e,它們用二進位制表達是:

a. 1010
b. 1011
c. 1100
d. 1101
e. 1110

我們可以看到,最前面的一位數字是 1,其實是浪費掉了,在定長演算法下最優的表示式為:

a. 010
b. 011
c. 100
d. 101
e. 110

這樣我們就能做到節省一位的損耗,那哈夫曼演算法比起定長演算法改進的地方在哪裡呢?在哈夫曼演算法中我們可以給資訊賦予權重,即為資訊加權,假設 a 佔據了 60%,b 佔據了 20%, c 佔據了 20%,d,e 都是 0%:

a:010 (60%)
b:011 (20%)
c:100 (20%)
d:101 (0%)
e:110 (0%)

在這種情況下,我們可以使用哈夫曼樹演算法再次優化為:

a:1
b:01
c:00

所以思路當然就是出現頻率高的字母使用短碼,對出現頻率低的使用長碼,不出現的直接就去掉,最後 abcde 的哈夫曼編碼就對應:1 01 00
通過權重對應生成的的哈夫曼表為:

定長編碼下的abcde:010 011 100 101 110,使用哈夫曼樹加權後的編碼則為 1 01 00,這就是哈夫曼演算法的整體思路(關於演算法的詳細介紹可以去查閱相關資料)。

所以這個演算法一個很重要的思路是必須知道每一個元素出現的權重,如果我們能夠知道每一個元素的權重,那麼就能夠根據權重動態生成一個最優的哈夫曼表。

但是怎麼去獲取每一個元素,對於圖片就是每一個畫素中 argb 的權重呢,只能去迴圈整個圖片的畫素資訊,這無疑是非常消耗效能的,所以早期 android 就使用了預設的哈夫曼表進行圖片壓縮。

五、libjpeg 與 optimize_coding

libjpeg 在壓縮影像時,有一個引數叫 optimize_coding,關於這個引數,libjpeg.doc 有如下解釋:

TRUE causes the compressor to compute optimal Huffman coding tables
for the image. This requires an extra pass over the data and
therefore costs a good deal of space and time. The default is
FALSE, which tells the compressor to use the supplied or default
Huffman tables. In most cases optimal tables save only a few percent
of file size compared to the default tables. Note that when this is
TRUE, you need not supply Huffman tables at all, and any you do
supply will be overwritten.

由上可知,如果設定 optimize_coding 為 TRUE,將會使得壓縮影像過程中,會先基於影像資料計算哈弗曼表。由於這個計算會顯著消耗空間和時間,預設值被設定為 FALSE。

那麼 optimize_coding 引數的影響究竟會有多大呢?查閱一些部落格資料介紹,使用相同的原始圖片,分別設定 optimize_coding=TRUE 和 FALSE 進行壓縮,發現 FALSE 時的圖片大小大約是 TRUE 時的 5-10 倍。換言之就是相同檔案體積的圖片,不使用哈夫曼編碼圖片質量會比使用哈夫曼低 5-10 倍。

關於這個差異我們再去查閱其他資料,發現有兩篇討論非常熱烈:Investigate using “optimize_coding” when encoding to JPEG,About libjpeg optimize_coding,甚至Skia 的官方人員也參與了討論,他據此測試了兩組資料:

sample image 1 (RGB gradients):
default (80): 2.5x slower, 34% smaller
quality 0: 1.7x slower, 52% smaller
quality 20: 2.1x slower, 55% smaller
quality 40: 2.3x slower, 37% smaller
quality 60: 2.5x slower, 36% smaller
quality 100: 3.9x slower, 22% smaller

sample image 2 (photo):
default (80): 2x slower, 8% smaller
quality 0: 1.5x slower, 49% smaller
quality 20: 1.7x slower, 22% smaller
quality 40: 1.9x slower, 15% smaller
quality 60: 1.9x slower, 11% smaller
quality 100: 2x slower, 9% smaller

可以看到效果並不是 5-10 倍的體積差距,最多也就在 2 倍而已,有國人也測試了一下,結果一致:JPEG Optimized Huffman。

儘管如此,社群裡對此的疑慮並沒有徹底打消,最終,官方人員修改了這個預設的實現:skia / skia.git / 0a35620a16b368356888d15771392fb00cbb777d(skia.googlesource.com/skia.git/+/… ) 。在 SkImageDecoder_libjpeg.cpp 檔案中給 optimize_code 賦值了一個預設值 TRUE。

六、Android 與 optimize_coding

那麼在 Android 中有沒有使用哈夫曼變長編碼呢?查閱了 7.0 原始碼,如下:

/* Use Huffman coding, not arithmetic coding, by default */
cinfo->arith_code = FALSE;複製程式碼

可以看到註釋裡面很清楚,預設是哈夫曼變長編碼,而不是算數編碼。同時去查閱 14 年時的 Android 4.4 原始碼,發現依舊如此。

對於optimize_coding,早期的 Android 考慮到效能瓶頸,將其設定為 FALSE。但是,現在 Android 手機效能比以前好很多,所以目前效能往往不是瓶頸,時間和壓縮質量反而成為更重要的指標了。為此,Google 在 Android 7.0 版本左右,也做了相應修改,如 7.0 和 6.0 原始碼所示:

七、Android JPEG VS. iOS JPEG

經過上面的介紹大家應該瞭解了為什麼 Android 的 JPEG 圖片壓縮率會比 iOS 小一些,那麼還有另一個問題就是為什麼同一張 PNG 圖片設定成同樣的壓縮質量壓縮成 JPEG 之後,Android 輸出的影像質量會比 iOS 差一些呢,經過相關資料的查詢,發現造成這個結果有兩方面的因素。

第一個因素是 JPEG 編碼過程中有一個步驟是顏色空間 RGB -> YUV 的轉換,之前的 Android 版本同樣考慮到效能問題,skia 引擎寫了一個函式替代了原來 libjpeg 的轉換函式,好處是提高了編碼速度,壞處就是犧牲了每一個畫素的精度。

第二個因素是離散餘弦變換有三種方式,Skia 引擎選擇了 JDCT_IFAST,JDCT_IFAST 是最快的變換方式,當然也是精度最差的一種。

上面兩種因素第一個會造成色調偏差,第二個會造成色塊的出現,所以如果需要提高壓縮之後的影像質量,可以考慮從這兩方面入手。

八、總結

首先,從 Android 7.0 版本開始,optimize_code 標示已經設定為了 TRUE,也就是預設使用影像生成哈夫曼表,而不是使用預設哈夫曼表。而至於這個標誌所產生的體積差距也沒有 5-10 倍那麼大,大約可以在原圖的基礎上縮小 10%~50% 的體積,經過修改前後不同 Android 版本實測,資料吻合。

其次,如何提高 Android 的壓縮率,這裡需要提到兩個庫,一個是 mozilla/mozjpeg,另一個是 libjpeg-turbo,前者是一個來自 Mozilla 實驗室的 JPEG 影像編碼器專案,目標是在不降低影像質量且相容主流的解碼器的情況下,提供產品級的 JPEG 格式編碼器來提高壓縮率以減小 JPEG 檔案的大小,後者相當於是一個 libjpeg 的增強版,前者也是基於後者,在後者的基礎上進行了一些優化。

所以想要提升圖片壓縮率的可以從這兩個庫著手,網上資料也不少,後續有機會可以測試一下這兩個庫,然後給大家分享一下。
  
最後,編碼方式除了哈夫曼之外,還有定長的算術編碼,這個演算法的詳細介紹大家可以網上查閱一下。對比哈夫曼編碼和算術編碼,網上相關資料顯示算術編碼在壓縮 jpeg 方面可以比哈夫曼編碼體積小 5%~12%,所以需要提升圖片壓縮率的同樣也可以嘗試從切換成算術編碼這方面入手。

九、參考

  1. 為什麼Android的圖片質量會比iPhone的差?(blog.sina.com.cn/s/blog_12ce…
  2. JPEG arithmetic coding(www.rw-designer.com/entry/1311
  3. Comparison Arithmetic Coding versus Huffman(www.binaryessence.com/dct/en00013…
  4. Investigate using "optimize_coding" when encoding to JPEG(bugs.chromium.org/p/skia/issu…
  5. About libjpeg optimize_coding(groups.google.com/forum/#!top…


相關閱讀


此文已由作者授權騰訊雲技術社群釋出,轉載請註明文章出處
原文連結:https://cloud.tencent.com/community/article/427566


相關文章