在 Android 中進行圖片壓縮是非常常見的開發場景,主要的壓縮方法有兩種:其一是品質壓縮,其二是下採樣壓縮。
前者是在不改變圖片尺寸的情況下,改變圖片的儲存體積,而後者則是降低影象尺寸,達到相同目的。
在Android中,對圖片進行品質壓縮,通常我們的實現方式如下所示:
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
//quality 爲0~100,0表示最小體積,100表示最高品質,對應體積也是最大
bitmap.compress(Bitmap.CompressFormat.JPEG, 100, outputStream);
在上述程式碼中,我們選擇的壓縮格式是CompressFormat.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 引擎來對圖片進行編碼壓縮。
一段完整的範例程式碼如下:
// 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());
//壓縮之後圖片佔用的儲存體積
compress.length = 7814
//在記憶體中壓縮前後圖片佔用的大小
bitmap.size = 350000 compress.size = 350000
對比二者,儲存前的圖片儲存體積是 106k,品質設爲 50 並且儲存爲 JPEG 格式之後,圖片儲存大小就只有 8k 了,並且品質設的越低,儲存成檔案之後,檔案的體積也就越小。
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 來說差了一些的原因之一。
哈夫曼演算法是在多媒體處理裡常用的演算法之一。
比如一個檔案中可能會出現五個值 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。
通過權重對應生成的的哈夫曼表爲:
所以這個演算法一個很重要的思路是必須知道每一個元素出現的權重,如果我們能夠知道每一個元素的權重,那麼就能夠根據權重動態生成一個最優的哈夫曼表。
但是怎麼去獲取每一個元素,對於圖片就是每一個畫素中 argb 的權重呢,只能去回圈整個圖片的畫素資訊,這無疑是非常消耗效能的,所以早期 android 就使用了預設的哈夫曼表進行圖片壓縮。
libjpeg 在壓縮影象時,有一個參數叫 optimize_coding。如果設定 optimize_coding 爲 TRUE,將會使得壓縮影象過程中,會先基於影象數據計算哈弗曼表。由於這個計算會顯著消耗空間和時間,預設值被設定爲 FALSE。
那麼 optimize_coding 參數的影響究竟會有多大呢?查閱一些部落格資料介紹,使用相同的原始圖片,分別設定 optimize_coding=TRUE 和 FALSE 進行壓縮,發現 FALSE 時的圖片大小大約是 TRUE 時的 5-10 倍。換言之就是相同檔案體積的圖片,不使用哈夫曼編碼圖片品質會比使用哈夫曼低 5-10 倍。
最終,官方人員修改了這個預設的實現:在 SkImageDecoder_libjpeg.cpp 檔案中給 optimize_code 賦值了一個預設值 TRUE。
那麼在 Android 中有沒有使用哈夫曼變長編碼呢?查閱了 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 版本左右,也做了相應修改,如 Android 7.0 和 Android 6.0 原始碼所示:
經過上面的介紹大家應該瞭解了爲什麼 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%,所以需要提升圖片壓縮率的同樣也可以嘗試從切換成算術編碼這方面入手。
針對圖片尺寸的修改其實就是一個影象重新採樣的過程,放大影象稱爲上採樣(upsamping),縮小影象稱爲下採樣(downsampling)。
在 Android 中圖片重採樣提供了兩種方法,一種叫做鄰近採樣(Nearest Neighbour Resampling),另一種叫做雙線性採樣(Bilinear Resampling)。
除了 Android 中這兩種常用的重採樣方法之外,還有另外比較常見的兩種:雙立方/雙三次採樣(Bicubic Resampling) 和 Lanczos Resampling。除此之外,還有一些其他個人或機構發明的演算法 Hermite Resampling,Bell Resampling,Mitchell Resampling。
我們先來看看在 Android 中使用鄰近採樣的範例程式碼:
BitmapFactory.Options options = new BitmapFactory.Options();
//或者 inDensity 搭配 inTargetDensity 使用,演算法和 inSampleSize 一樣
options.inSampleSize = 2;
Bitmap bitmap = BitmapFactory.decodeFile("/sdcard/test.png");
Bitmap compress = BitmapFactory.decodeFile("/sdcard/test.png", options);
來看看鄰近採樣的圖片效果:
上圖是每個畫素紅綠相間的圖片,可以看到處理之後的圖片已經完全變成了綠色。
我們來看看 inSampleSzie 的官方描述:
If set to a value > 1, requests the decoder to subsample the original image, returning a smaller image to save memory. The sample size is the number of pixels in either dimension that correspond to a single pixel in the decoded bitmap. For example, inSampleSize == 4 returns an image that is 1/4 the width/height of the original, and 1/16 the number of pixels. Any value <= 1 is treated the same as 1. Note: the decoder uses a final value based on powers of 2, any other value will be rounded down to the nearest power of 2.
從官方的解釋中我們可以看到 x(x 爲 2 的倍數)個畫素最後對應一個畫素,由於採樣率設定爲 1/2,所以是兩個畫素生成一個畫素。鄰近採樣的方式比較粗暴,直接選擇其中的一個畫素作爲生成畫素,另一個畫素直接拋棄,這樣就造成了圖片變成了純綠色,也就是紅色畫素被拋棄。
鄰近採樣採用的演算法叫做鄰近點插值演算法。
雙線性採樣(Bilinear Resampling)在 Android 中的使用方式一般有兩種:
Bitmap bitmap = BitmapFactory.decodeFile("/sdcard/test.png");
Bitmap compress = Bitmap.createScaledBitmap(bitmap, bitmap.getWidth()/2, bitmap.getHeight()/2, true);
或者直接使用 matrix 進行縮放:
Bitmap bitmap = BitmapFactory.decodeFile("/sdcard/test.png");
Matrix matrix = new Matrix();
matrix.setScale(0.5f, 0.5f);
bm = Bitmap.createBitmap(bitmap, 0, 0, bit.getWidth(), bit.getHeight(), matrix, true);
看原始碼可以知道 createScaledBitmap 函數最終也是使用第二種方式的 matrix 進行縮放,我們來看看雙線性採樣的表現:
可以看到處理之後的圖片不是像鄰近採樣一樣純粹的一種顏色,而是兩種顏色的混合。雙線性採樣使用的是雙線性內插值演算法,這個演算法不像鄰近點插值演算法一樣,直接粗暴的選擇一個畫素,而是參考了源畫素相應位置周圍 2x2 個點的值,根據相對位置取對應的權重,經過計算之後得到目標影象。
雙線性內插值演算法在影象的縮放處理中具有抗鋸齒功能, 是最簡單和常見的影象縮放演算法,當對相鄰 2x2 個畫素點採用雙線性內插值演算法時,所得表面在鄰域處是吻合的,但斜率不吻合,並且雙線性內插值演算法的平滑作用可能使得影象的細節產生退化,這種現象在上採樣時尤其明顯。
鄰近採樣的方式是最快的,因爲它直接選擇其中一個畫素作爲生成畫素,但是生成的圖片可能會相對比較失真,產生比較明顯的鋸齒,最具有代表性的就是處理文字比較多的圖片在展示效果上的差別,對比:
原圖:
鄰近採樣:
雙線性採樣:
鄰近採樣字的顯示失真對比雙線性採樣來說要嚴重很多。
雙立方/雙三次採樣使用的是雙立方/雙三次插值演算法。鄰近點插值演算法的目標畫素值由源圖上單個畫素決定,雙線性內插值演算法由源畫素某點周圍 2x2 個畫素點按一定權重獲得,而雙立方/雙三次插值演算法更進一步參考了源畫素某點周圍 4x4 個畫素。
這個演算法在 Android 中並沒有原生支援,如果需要使用,可以通過手動編寫演算法或者參照第三方演算法庫,幸運的是這個演算法在 ffmpeg 中已經給到了支援,具體的實現在 libswscale/swscale.c 檔案中:FFmpeg Scaler Documentation。
雙立方/雙三次插值演算法經常用於影象或者視訊的縮放,它能比雙線性內插值演算法保留更好的細節品質。我們看看這個演算法的實際表現和與雙線性內插值演算法的下採樣對比。
原圖:
雙三次採樣:
雙線性採樣:
就下採樣來說,兩者表現很相近,肉眼可見的差距不大,接下來比較一下這兩種演算法的上採樣實際表現。
原圖:
雙三次採樣:
雙線性採樣:
這兩種演算法的上採樣結果我們還是可以看見較爲明顯的差距,雙立方/雙三次採樣的鋸齒是要小一些。
雙立方/雙三次插值演算法在平時的軟體中是很常用的一種圖片處理演算法,但是這個演算法有一個缺點就是計算量會相對比較大,是前三種演算法中計算量最大的,軟體 photoshop 中的圖片縮放功能使用的就是這個演算法。
Lanczos 採樣和 Lanczos 過濾是 Lanczos 演算法的兩種常見應用,它可以用作低通濾波器或者用於平滑地在採樣之間插入數位信號,Lanczos 採樣一般用來增加數位信號的採樣率,或者間隔採樣來降低採樣率。
Lanczos 採樣使用的 Lanczos 演算法也可以用來作爲圖片的縮放,Lanczos 演算法和雙三次插值演算法都是使用折積核來通過輸入畫素計算輸出畫素,只不過在演算法表現上稍有不同。
Lanczos 從演算法角度講理論上會比雙三次/雙立方插值演算法更好一點,先來看看它和雙三次/雙立方採樣的圖片下採樣對比。
原圖:
Lanczos 採樣:
雙三次採樣:
基本看不出差別,然後是這兩種演算法的上採樣對比。
原圖:
Lanczos 採樣:
雙三次採樣:
這兩種演算法的上下採樣結果從肉眼上看差距很小,但是從理論上來說 Lanczos 演算法處理出來的圖片應該是更加平滑少鋸齒的。
同樣的,Lanczos 演算法在 ffmpeg 的 libswscale/swscale.c 中也有實現。其實不光 Lanczos 和上面的三種演算法,ffmpeg 還提供了其他的影象重採樣方法,諸如 area averaging、Gaussian 等等。
通過編譯好的 ffmpeg 庫呼叫這些演算法處理圖片的命令如下:
ffmpeg -s 600x500 -i input.jpg -s 300x250 -sws_flags lanczos lanczos.jpg
-sws_flags 參數根據採樣演算法可以選擇 bilinear/bicubic/lanczos 等等。
這四種圖片重採樣演算法在處理二值化圖片上面的表現差異較大,我們先看看下採樣的對比。
原圖:
鄰近採樣:
雙線性採樣:
雙三次採樣:
Lanczos 採樣:
下採樣的對比一目瞭然,從上到下的影象表現效果逐漸變優,Lanczos 演算法處理後的影象品質屬於最優,接着我們看看這四種演算法的上採樣對比。
原圖:
鄰近採樣:
雙線性採樣:
雙三次採樣:
Lanczos 採樣:
從影象品質上來看,和下採樣結果一致,鄰近採樣效果較差,依次往下效果變優,Lanczos 效果最優。
在 Android 中,前兩種採樣方法根據實際情況去選擇即可,如果對時間要求不高,傾向於使用雙線性採樣去縮放圖片。如果對圖片品質要求很高,雙線性採樣也已經無法滿足要求,則可以考慮引入另外幾種演算法去處理圖片,但是同時需要注意的是後面兩種演算法使用的都是折積核去計算生成畫素,計算量會相對比較大,Lanczos 的計算量則是最大,在實際開發過程中根據需求進行演算法的選擇即可。