爲何圖片反覆壓縮後廣泛會變綠,而不是其餘顏色?

做者:Lion Yang
連接:https://www.zhihu.com/question/29355920/answer/119088684
來源:知乎
著做權歸做者全部。商業轉載請聯繫做者得到受權,非商業轉載請註明出處。
 

業餘版概要:安卓的一個核心的部分的代碼,爲了優化執行速度進行了魔改,結果寫錯了代碼。結果致使 JPG 圖片壓縮發綠、崩壞。與安卓上的應用無關,它們是受害者(


專業版概要:問題出在 Android 提供的壓縮圖片接口上,準確的說是一個 Android 裏一個叫作 Skia 的庫上。而這個 bug 在 2016 年 4 月中旬被修復了,若是按照 Android 的發行來看,那就是從 Android 7 (Nougat) 開始才消除這個問題。
(不是百度的陰謀。(認真)java

前言:剛纔在社區裏和git

等人一塊兒研究,如今應該能夠下一個精確的定論了。如他的答案所說,問題出在 RGB 色彩空間轉換到 YUV 的時候。但問題不只僅是精度降低,最大的問題是,錯誤的舍入(向下取整)。另外,JDCT_IFAST 方法會致使圖片嚴重劣化:「格子狀崩壞」、灰塊、黑白塊、畫面粗糙,可是題目問的僅僅是變綠,就不在這上面浪費篇幅了。

 

網頁模擬 bygithub

JPEGreen Simulator

 

歷史性的修復:Use libjpeg-turbo for YUV->RGB conversion in jpeg encoder · google/skia@c7d01d3 · GitHub算法

=================================
# 是誰的鍋?小程序

百度貼吧是最多人批評的,並且……出事的客戶端僅僅是 Android 系統上的。app

我後來注意到 QQ 也有這個問題,特別是上傳頭像。之前一直不知道爲何有一些圖稍微有點綠,覺得是打開了新世界的大門(xide

後來作了一點微小的測試,注意到百度貼吧、QQ,都會用 Android 系統提供的接口:svg

Bitmap.compress(Bitmap.CompressFormat.JPEG, quality, outputStream);

看起來都很乾淨……難不成是系統的問題?工具

我本身作了一個我這輩子寫的第一個 Android 的小程序(我真不敢斗膽叫作 App),模仿一個正常的應用,反覆 JPEG 壓縮。發現還真是那麼回事。順便完善了一下,作成了「效果拔羣的綠化器」。測試

 

源代碼已開放:terribleGreen/MainActivity.java at master · LionNatsu/terribleGreen · GitHub(開源許可證:Apache License Version 2.0,歡迎提供 PR)

如今就要說到 Android 系統到底爲何出了這個問題了。Android 系統自起誕生以來就引入了名爲 Skia 的圖像庫(Google 自家產品),用於處理圖像,其中包括把圖片壓縮成 JPEG(平時說的 JPG)。而 Skia 又是調用 libjpeg-turbo 來實現真正的壓縮過程的。爲了達到更好的壓縮效果,JPEG 算法自己,將一般屏幕上表示顏色的 RGB(紅綠藍)數值,轉換爲 YUV 數值(亮度,藍色份量,紅色份量)。正常狀況下這個算法是輕微有損的。

可是 Skia 不走尋常路,將這個變換算法的各個常數複製到本身的代碼裏(固然是合法地),而後下降了精度,以達到更高的速度(專業準確地說,從 16 位定點數,下降到了 8 位定點數),這致使了更大的損傷。

最可怕的是……在進行這個變換運算的最後一步,須要除以 256,而代碼中,採用了右移操做代替除法以提升執行速度(看不懂能夠跳過):

int  y = ( CYR*r + CYG*g + CYB*b ) >> CSHIFT;
int  u = ( CUR*r + CUG*g + CUB*b ) >> CSHIFT;
int  v = ( CVR*r + CVG*g + CVB*b ) >> CSHIFT;
// C?? 是已經擴大到 2^CSHIFT 倍的矩陣參數(-0.5 ~ 0.5),CSHIFT = 8 

這個操做並無什麼問題,數學意義就是除以 256。可是問題出在:

一、直接截斷了小數部分,等價於 trunc()。若是符號數是用補碼實現的。即所有往負數方向取整。如:1.2 → 1; 3.9 → 3;0.0 → 0;-5.1 → -6.

二、較冒險的符號數移位:根據規範的定義,對符號數(可正可負的數)使用移位的效果將由具體的編譯器明肯定義決定(implementation-defined)。由於移位是一個符號無關的操做,對符號數移位將依賴於符號數的具體表現形式。而這個形式 C++ 沒有給出一個限定,由具體的編譯器自行決定,對於非「補碼」(2's complement)的狀況結果可能並非所期待的那樣數值整除2的冪。這裏假設了編譯器都能「正確」理解爲整除。

=================================
# YUV 值向負方向取整致使什麼?

複習一下 YUV 的定義:

  1. Y,亮度,0.0 ~ 1.0;
  2. U,或者叫作 Cb,藍色份量,-0.5 ~ 0.5;
  3. V,或者叫作 Cr,紅色份量,-0.5 ~ 0.5。

在 Skia 的代碼裏,YUV 三個值均對應到 0~255 的範圍。
由於向下取整,因此偏差在 1 一個單位之內:0/256 到 1/256 也就是,YUV 三個值都變小 0.00% 到 0.39% 這個範圍。

看一下 U, V 這兩個決定顏色的值是如何變化的:

(圖片來自 Tonyle, Wikimedia Commons, File:YUV UV plane.svg

顯然,YUV 值向負方向取整,結果是呼之欲出的:變暗,變綠。(這裏的變暗是 YUV 裏的 Y 減少,並不徹底準確對應人類視覺的明暗概念)

這個錯誤的舍入,使得:全部在 0 ~ 255 範圍內非整數的 YUV 值都受到影響。那麼某個像素被舍入到整數以後,下一次再壓縮 JPEG 應該會好一些吧?很不幸的是,隨之而來的大量其餘有損操做(好比 DCT 變換以後濾去高頻)又會使得 YUV 值發生變化:若是發生變化,假設隨機產生關於 0 對稱的偏差,那麼實際上也有 50% 的機率使得這個數值 -1,由於只要比原來的值小,都會被向下捨去。

這使得,圖片隨着 Skia 缺陷的色彩空間變換算法反覆壓縮,愈來愈綠。

=================================
## 假如咱們是 Skia 開發者,如何修復這個問題?(閱讀本節須要 C/C++ 常識)

交回給 libjpeg-turbo 庫本身來作色彩空間變換。這也正是本文開頭提到的那個歷史性的修復具體作的:把本來 Skia 庫 YUV 轉換代碼所有刪掉了,把這個過程留給整個過程最底層的 libjpeg-turbo 庫本身來作,而且用默認的 JDCT_ISLOW 方法代替 JDCT_IFAST 方法,那麼天然就沒這個問題了。

注:libjpeg-turbo 是個運用極其普遍的庫。能夠說,基本上電腦上手機上能見到的 JPEG 壓縮的地方用的通常都是 libjpeg-turbo。(iOS 應該也是吧?我沒有蘋果設備抱歉……Adobe 公司的魔法多是另外一回事)

若是不刪除呢?本身搗鼓:
* 本節所提到的代碼以及示例圖片能夠在這裏找到:GitHub - LionNatsu/greenError: Discover the reason how `terribleGreen`(my another repo.) works on Android.

首先咱們要模擬一個 Skia 的 libjpeg-turbo 操做(略),而後,在把圖片遞交給 libjpeg-turbo 以前,把色彩空間像 Skia 同樣,作一個變換(矩陣數據徹底與 Skia 相同)。

咱們所要作的修復就是,把運算改爲可以對數字進行合理四捨五入的運算:

int R=i[0], G=i[1], B=i[2];

#if 1 // Shift or float-divide (shift in Skia)   int Y = (R*CYR + G*CYG + B*CYB) >> CSHIFT;
  int U = (R*CUR + G*CUG + B*CUB) >> CSHIFT;
  int V = (R*CVR + G*CVG + B*CVB) >> CSHIFT;

  o[0] = Y;
  o[1] = U + 128;
  o[2] = V + 128;
#else   double Y = (R*CYR + G*CYG + B*CYB) / pow(2, CSHIFT);
  double U = (R*CUR + G*CUG + B*CUB) / pow(2, CSHIFT);
  double V = (R*CVR + G*CVG + B*CVB) / pow(2, CSHIFT);

  o[0] = round(Y);
  o[1] = round(U + 128);
  o[2] = round(V + 128);
#endif 

這裏我把原版操做和修正版操做都寫在一塊兒了,把 #if 1 改爲 #if 0 便可切換。(爲何我要說這些= =)

示例:左邊爲原版 Lena 醬,右邊均爲壓縮質量設置爲 80%,重複 30 次。

徹底 Skia 原版效果(即 Android 的):8-bit 變換,移位除法,JDCT_IFAST 方法。

畫質嚴重劣化,色彩偏綠。

不辣眼睛修正效果:8-bit 變換,移位除法,JDCT_FLOAT 方法。

能夠看到關閉 JDCT_IFAST 以後畫面細膩了。

繼續修復舍入漏洞的效果:8-bit 變換,正常舍入的除法JDCT_FLOAT 方法。

能夠看到色彩偏綠的問題被正確四捨五入修正了。

迴歸原版 libjpeg-turbo 的壓縮效果(如今的新版 Android):16-bit 變換,正常舍入的除法JDCT_FLOAT 方法。(其實原版是JDCT_ISLOW,但差異不大)

比起 8-bit 少了不少色斑,由於數字範圍更大,不溢出了。

=======
番外
Q:爲何不用全身版 Lena (http://www.lenna.org/full/l_hires.jpg) 作示例圖?
A:……

(二營長,你他孃的意大利炮呢?!)

=================================
來一個小的總結,給非專業的旁友們看:
圖片變綠是安卓系統一直以來的問題,直到 Android 7 才修復。緣由是安卓系統內部的一個核心部件的代碼,爲了優化手機上運行的速度——寫錯了 = =。

2016.8.26, 21:54 發佈

2016.8.26, 22:32 修訂:修正表述錯誤,高亮

2016.8.26, 22:34 修訂:添加 S.B. 的網頁模擬工具地址

2016.8.26, 23:05 修訂:添加概要

2016.8.26, 23:56 修訂:同步示例代碼

2016.8.27, 00:38 修訂:調整使人困惑的表述

2016.8.27, 14:38 修訂:訂正錯字

2016.8.27, 23:29 修訂:明確闡述各修復步驟的變化

2016.8.27, 23:31 修訂:該死的我漏了句號

2016.8.30, 00:45 修訂:對符號數移位的定性從「未定義的行爲」修正爲「由具體實現決定」

相關文章
相關標籤/搜索