1. XenForo 1.5.14 中文版——支持中文搜索!现已发布!查看详情
  2. Xenforo 爱好者讨论群:215909318 XenForo专区

为什么图片反复压缩后会普遍会变绿而不是其他颜色?

Discussion in '知乎日报' started by 漂亮的石头, 2016-08-29.

  1. 漂亮的石头

    漂亮的石头 版主 Staff Member

    Joined:
    2012-02-10
    Messages:
    487,766
    Likes Received:
    47
    日报标题:一眼望去绿油油的就知道,这图不知道被转多少次了

    [​IMG] Lion Yang,我不是计算机工作者,但是我见得太多了

    业余版概要:安卓的一个核心的部分的代码,为了优化执行速度进行了魔改,结果写错了代码。结果导致 JPG 图片压缩发绿、崩坏。与安卓上的应用无关,它们是受害者(

    专业版概要:问题出在 Android 提供的压缩图片接口上,准确的说是一个 Android 里一个叫做 Skia 的库上。而这个 bug 在 2016 年 4 月中旬被修复了,如果按照 Android 的发行来看,那就是从 Android 7 (Nougat) 开始才消除这个问题。

    前言:刚才在社区里和 @StarBrilliant 等人一起研究,现在应该可以下一个精确的定论了。如他的答案所说,问题出在 RGB 色彩空间转换到 YUV 的时候。但问题不仅仅是精度下降,最大的问题是,错误的舍入(向下取整)。另外,JDCT_IFAST 方法会导致图片严重劣化:“格子状崩坏”、灰块、黑白块、画面粗糙,但是题目问的仅仅是变绿,就不在这上面浪费篇幅了。

    网页模拟 by @StarBrilliant :JPEGreen Simulator

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

    # 是谁的锅?

    百度贴吧是最多人批评的,而且……出事的客户端仅仅是 Android 系统上的。

    我后来注意到 QQ 也有这个问题,特别是上传头像。以前一直不知道为什么有一些图稍微有点绿,以为是打开了新世界的大门(x

    后来做了一点微小的测试,注意到百度贴吧、QQ,都会用 Android 系统提供的接口:

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

    看起来都很干净……难不成是系统的问题?

    我自己做了一个我这辈子写的第一个 Android 的小程序(我真不敢斗胆叫做 App),模仿一个正常的应用,反复 JPEG 压缩。发现还真是那么回事。顺便完善了一下,做成了“效果拔群的绿化器”。

    [​IMG]

    源代码已开放: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,而代码中,采用了右移操作代替除法以提高执行速度(看不懂可以跳过):

    inty=(CYR*r+CYG*g+CYB*b)>>CSHIFT;intu=(CUR*r+CUG*g+CUB*b)>>CSHIFT;intv=(CVR*r+CVG*g+CVB*b)>>CSHIFT;// C?? 是已经扩大到 2^CSHIFT 倍的矩阵参数(-0.5 ~ 0.5),CSHIFT = 8

    这个操作并没有什么问题,数学意义就是除以 256。但是问题出在:

    1、直接截断了小数部分,等价于 trunc()。如果符号数是用补码实现的。即全部往负数方向取整。如:1.2 → 1; 3.9 → 3;0.0 → 0;-5.1 → -6.

    2、不符合规范:符号数(可正可负的数)不应该使用移位。移位是一个符号无关的操作,或者说是一个无符号数(仅正数的)变量才应该使用的操作。其结果也是无符号的,给符号数赋一个无符号数,是未定义的行为。(因为符号数的实现方式是 C++ 没有限制的,尽管我见过的都是用“补码”)

    # 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 这两个决定颜色的值是如何变化的:

    [​IMG]

    (图片来自 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 相同)。

    我们所要做的修复就是,把运算改成能够对数字进行合理四舍五入的运算:

    intR=i[0],G=i[1],B=i[2];#if 1 // Shift or float-divide (shift in Skia)intY=(R*CYR+G*CYG+B*CYB)>>CSHIFT;intU=(R*CUR+G*CUG+B*CUB)>>CSHIFT;intV=(R*CVR+G*CVG+B*CVB)>>CSHIFT;o[0]=Y;o[1]=U+128;o[2]=V+128;#elsedoubleY=(R*CYR+G*CYG+B*CYB)/pow(2,CSHIFT);doubleU=(R*CUR+G*CUG+B*CUB)/pow(2,CSHIFT);doubleV=(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 方法。

    [​IMG]

    画质严重劣化,色彩偏绿。

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

    [​IMG]

    可以看到关闭 JDCT_IFAST 之后画面细腻了。

    继续修复舍入漏洞的效果:8-bit 变换,正常舍入的除法JDCT_FLOAT 方法。

    [​IMG]

    可以看到色彩偏绿的问题被正确四舍五入修正了。

    回归原版 libjpeg-turbo 的压缩效果(现在的新版 Android):16-bit 变换,正常舍入的除法JDCT_FLOAT 方法。(其实原版是 JDCT_ISLOW,但差别不大)

    [​IMG]

    比起 8-bit,少了很多“色斑”,因为精度高了,色彩分辨率更高,或者说颜色的层次更加细腻。

    番外

    Q:为什么不用全身版 Lena 做示例图?

    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 修订:该死的我漏了句号

    阅读原文
     
Loading...