问题

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

回答
图片反复压缩后普遍出现偏绿的现象,这与JPEG压缩算法的工作原理、色彩模型以及人类视觉感知紧密相关。下面我将详细解释这个过程:

1. JPEG压缩的核心原理:有损压缩和感知编码

JPEG(Joint Photographic Experts Group)是目前最常用的有损图像压缩标准。它的目标是在尽可能小的文件体积下,保持人眼难以察觉的图像质量损失。为了实现这一点,JPEG采用了以下几个关键技术:

颜色空间转换(YCbCr): JPEG首先将RGB(红、绿、蓝)颜色空间转换为YCbCr颜色空间。
Y (Luminance): 代表亮度信息,包含了图像大部分的细节和结构信息。人眼对亮度变化非常敏感。
Cb (Chrominance Blue): 代表蓝色色度信息,表示颜色与蓝色基准的偏差。
Cr (Chrominance Red): 代表红色色度信息,表示颜色与红色基准的偏差。
为什么要做这个转换? 因为人眼对亮度的敏感度远高于对颜色的敏感度。YCbCr允许对亮度信息进行更精细的处理,而对色度信息进行一定程度的简化,从而实现高效压缩。

色度子采样(Chroma Subsampling): 这是JPEG压缩中最重要的有损步骤之一。为了进一步减小文件大小,JPEG会降低色度信息的分辨率,而保留全分辨率的亮度信息。常见的色度子采样格式有:
4:4:4: 不进行子采样,Y, Cb, Cr的采样率相同。
4:2:2: 水平方向上色度信息是亮度信息的1/2,垂直方向上保持相同。
4:2:0: 水平方向和垂直方向上色度信息都是亮度信息的1/2。这是最常用的格式,因为它能在保持可接受图像质量的同时实现最高的压缩比。
如何理解子采样? 想象一下,在一个2x2像素的区域里,如果采用4:2:0,它会有一个2x2的亮度采样(4个Y值),但只有一个Cb值和Cr值来代表整个2x2区域的颜色。这意味着颜色信息被平均化了。

离散余弦变换(DCT): 将图像分成8x8像素的块,并对每个块进行DCT变换。DCT将图像像素值转换到频率域,将图像信息分解为不同频率的系数。
低频系数代表图像的平滑区域,高频系数代表图像的细节和边缘。

量化(Quantization): 这是另一个关键的有损步骤。DCT系数会被一个量化表除以一个数值,然后取整。量化表中的数值根据频率的高低而不同,高频系数(代表细节)通常会被更大的数值除以,导致信息损失更多(变得更接近于零)。这是JPEG压缩实现高压缩比的主要手段。

熵编码(Entropy Coding): 如哈夫曼编码或算术编码,对量化后的DCT系数进行无损压缩,进一步减小文件大小。

2. 反复压缩过程中的累积误差

每次对图片进行保存或编辑后再压缩时,都会重复执行上述的有损步骤,特别是量化和色度子采样。

量化误差的累积: 每次量化都会引入微小的误差。当这些误差被重复累积时,它们会在图像中体现出来,尤其是在颜色变化不大的平滑区域。

色度子采样带来的影响:
当图像被多次压缩时,尤其是在采用4:2:0子采样的情况下,原始的色彩信息会被进一步平均化。
在某些特定颜色区域,如果原始颜色信息本身就比较微妙或者受到轻微噪声影响,经过反复的色度信息“模糊”后,可能会导致颜色偏移。

3. 为什么是“偏绿”而不是其他颜色?

这涉及到更深层的原因,与JPEG算法的设计以及人类视觉感知对特定颜色失真的敏感度有关:

YCbCr颜色空间和人眼感知:
YCbCr空间的设计是为了更好地匹配人眼的视觉感知特性。人眼对亮度变化非常敏感,但对色度变化的敏感度较低。
Cb和Cr分量代表的是与“中性色”(灰色、黑色、白色)的偏差。它们描述的是颜色偏向红/黄还是蓝/青。

低频色度信息失真:
在低质量的JPEG压缩中,色度信息(Cb和Cr)的量化会更严重,尤其是在低频区域(代表平滑的颜色块)。
人眼对低频色度信息的失真相对不敏感。然而,当这些失真累积时,它们会以一种“不寻常”的方式组合,并超出人眼对“中性色”的容忍范围。

特定颜色区域的敏感性:
某些肤色和灰度区域对绿色失真特别敏感。 这是因为人眼对肤色的感知经过了数万年的进化,对肤色微小的变化非常敏感。
当色度信息(特别是Cb和Cr)在压缩过程中受到不均匀或累积性失真时,它们会影响到像素的实际颜色值。
一个常见的解释是,JPEG算法在处理色度信息时,对某些“中间色调”的偏移可能更容易导致偏绿。 尤其是在没有明确指向红色或蓝色基准的模糊区域,色度信息的平均化和失真可能会导致其向一个“默认”的绿色方向偏移。
想象一下,一个原本是接近灰色或浅棕色的区域,经过多次失真后,其微小的、不精确的色度信息被“混合”后,可能恰好导致其看起来偏向绿色,而不是其他更“突兀”的颜色。例如,原本的Cb和Cr值可能因为量化误差在某个方向上累积,导致最终的RGB值计算出来偏向绿色。

色度子采样的副作用:
在4:2:0子采样中,一个Cb值和Cr值会覆盖4个像素。如果这4个像素的原始颜色略有不同,那么这个单一的色度值就会成为一个平均值。多次压缩意味着这个平均过程会反复进行,使得颜色的精确性进一步下降。
这种平均化过程,尤其是在处理那些本来就包含细微颜色变化的区域时,更容易暴露JPEG算法的局限性。

心理感知和对比度:
人眼并非只是简单地“看到”颜色值,还会受到周围像素的对比度影响。
当图像整体质量下降,出现噪点和色块时,人眼可能会更容易察觉到那些非自然的颜色偏差。偏绿可能是一种比较“不显眼”但普遍出现的偏差模式。

总结来说,图片反复压缩后普遍偏绿的原因是一个多方面因素叠加的结果:

1. JPEG的有损特性: 尤其是色度子采样和量化是信息损失的关键环节。
2. 色度信息的易损性: 相较于亮度信息,色度信息在压缩中更容易被简化和失真。
3. 累积误差: 多次压缩意味着这些失真和误差会不断累积。
4. YCbCr空间与人眼感知: 在特定颜色区域,色度信息的失真以偏绿的形式更容易被我们感知到或触发人眼的“错误”判断。这可能与人眼对特定色调(如肤色)的敏感性,以及JPEG算法在处理中间色调时的特性有关。

需要注意的是,这种“偏绿”现象并非绝对固定,在某些情况下也可能出现偏红、偏蓝等其他颜色失真。但从普遍性和统计学角度来看,偏绿是JPEG压缩反复进行时最常见的一种失真表现。

网友意见

user avatar

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


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

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

网页模拟 by @StarBrilliantJPEGreen 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 压缩。发现还真是那么回事。顺便完善了一下,做成了“效果拔群的绿化器”。


源代码已开放: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。但是问题出在:

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

2、较冒险的符号数移位:根据规范的定义,对符号数(可正可负的数)使用移位的效果将由具体的编译器明确定义决定(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 (lenna.org/full/l_hires.) 做示例图?
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 修订:对符号数移位的定性从“未定义的行为”修正为“由具体实现决定”

user avatar

这是 Android 图像库的一个 bug,

标准的 JPEG 在做色彩空间转换的时候,会用至少 16 比特精度,

Android 图快只用了 8 比特。

这个 bug 同时也影响了 Chrome 浏览器。

这个问题在 Android 7.0 和 Chrome 52 里修复了。

参见

Use libjpeg-turbo for YUV->RGB conversion in jpeg encoder · google/skia@c7d01d3 · GitHub

想体验的可以试试我写的

JPEGreen Simulator

类似的话题

本站所有内容均为互联网搜索引擎提供的公开搜索信息,本站不存储任何数据与内容,任何内容与数据均与本站无关,如有需要请联系相关搜索引擎包括但不限于百度google,bing,sogou

© 2025 tinynews.org All Rights Reserved. 百科问答小站 版权所有