•
•
•
30fps.net — 计算机图形学与编程,作者 Pekka Väänänen
假设你正在编写一个图像处理程序。程序读入一张图像,将其转换为浮点数,进行一些处理,最后将修改后的像素以 8 位颜色保存到磁盘上。今天的问题在于整数到浮点数的转换到底应该怎么做。有两种方法,用 Python 和 NumPy 写出来是这样的:
标准做法:除以 255
pixels = img / 255.0
result = process(pixels)
output = np.trunc(result * 255 + 0.5)
替代做法:除以 256
pixels = (img + 0.5) / 256.0
result = process(pixels)
output = np.trunc(result * 256)
我假设两种情况下在最终类型转换之前都会对输出值进行钳位:
output_8bit = output.clip(0, 255).astype(np.uint8)
标准做法将整数 0 映射到 0.0,整数 255 映射到 1.0。它工作得非常好,并且是 GPU 的做法。替代做法则加上 0.5 的偏置,然后除以 256,这样整数 0 被映射到 0.5/256 = 0.001953125。这很不方便,因为你的图像处理代码如果不知道这个常数,就无法检测到黑色像素等。结果就是,即使你在浮点数中计算,你的逻辑仍然与 8 位输入绑定。而采用标准做法,你总可以假设黑色是 0.0。
但有些程序员仍然会被替代做法所吸引。这是怎么回事呢?他们看中了什么?
反对除以 255.0 的理由
如果将标准做法画在数轴上,看起来确实很奇怪。下面是一个夸张的版本,用 3 位整数(范围 [0..7])映射到 [0,1]:
*
在 X 轴上我们有一条数轴,棕色圆圈的位置代表解码后的浮点数值。圆圈内的数字是整数输入。每个整数都有箭头指向它;这些箭头表示会四舍五入到该整数的一系列浮点数值。在本文余下部分,我将这些范围称为“箱子”(bins)。
边界处的箱子更小
图中第一个非常明显的问题是,标准公式的边界箱子凸超出了 [0,1] 范围。也许这张图不公平——两种方法都会对输出进行钳位,所以边界箱子可以无限延伸——但它清楚地展示了标准范围被“拉伸”了。拉伸后的范围比图像处理中假定的工作范围 [0,1] 要宽。
这意味着当将 [0,1] 范围内的浮点数值转换回整数时,边界箱子的宽度实际上只有其他箱子的一半。结果是,你的算法输出极端值会“更难”。例如,如果你生成均匀的 [0,1] 噪声,并使用标准公式将其四舍五入,那么值 0 和 255 出现的频率只有其他整数的一半。
我们可以通过生成一百万个均匀随机数,绘制成直方图来实证验证这一点,并观察到 0 和 255 这两个箱子的高度确实只有其他箱子的一半:
*
突出显示的裁剪部分:
*
直方图代码:
import numpy as np
import matplotlib.pyplot as plt
result = np.random.uniform(0, 1, 1000000)
final_values = np.trunc(result * 255 + 0.5).clip(0, 255).astype(np.uint8)
plt.hist(final_values, bins=256, range=(0, 255))
plt.show()
不过,我很难举出一个例子,说明这种偏离极端值的偏置会带来问题。当然,标准做法的浮点数分布在更宽的范围内,但原始图像仍然可以无损地来回转换(uint8 → float → uint8)。
此外,任何刚好超出 0.0 或 1.0 的结果值仍然会四舍五入到正确的箱子,从而均衡输出分布。举个例子:假设你的处理从浮点颜色中减去 0.005。在标准做法中,这会使黑色变成负值——超出 [0,1] 范围——而在替代做法中,值仍然是正的。但最终两者都输出整数 0:
标准:
trunc(255 * (-0.005) + 0.5) = 0
替代:
trunc(256 * (0.5 / 256 - 0.005)) = 0
在标准做法中,0 号箱子只有“一半大小”这一点并不重要。
不精确性
第二个问题是标准做法得到的浮点数值并不精确。例如 128/255.0 ≈ 0.501961,而 128/256.0 = 0.5。由于这种舍入误差,浮点数值之间的距离有微小变化。但这并不是一个真正的麻烦,因为误差确实非常小。一个 32 位浮点数有 23 位小数(“有效数字”)。我们讨论的是其最低有效位中的舍入误差;抖动幅度小于 2^{-23}。即使在最复杂的图像处理任务中,0.00001% 的相对误差也肯定无关紧要。在这种情况下,不精确性是一个美学问题,而不是技术问题。
值不在整数中间
替代做法总是将每个浮点数值精确地放置在两个整数的中间位置。看看上面数轴图中垂直条是如何对齐的。可以认为中间位置是一种折衷;我们不知道原始的量化值到底是多少,因此两个连续整数之间的平均点是一个很好的猜测。
我确信有些应用场景会用到这个性质,尽管我自己很难举出例子。至少,Andrew Kesler 在 2015 年的博客文章 “Converting Color Depth”(以其 名片光线追踪器 而闻名)中认为,抖动用起来更方便。理由是可以在不用担心边界情况的情况下添加噪声。相比之下,标准公式的尴尬边界需要仔细处理,以保持噪声分布一致。
两种量化器
到目前为止,标准的“除以 255”公式看起来仍然稳固,或者至少足够稳固,值得继续使用。另一种思考这个问题的方法是退一步,将两种方法视为两种不同的均匀标量量化器。如果我们查阅 维基百科上关于量化的页面,我们会很快了解到有两种主要类型的量化器:
大多数针对有符号输入数据的均匀量化器可以归类为两种类型之一:中升型(mid-riser)和中平型(mid-tread)。这种术语基于值 0 附近区域的情况,并将量化器的输入输出函数视为一个楼梯。中平型量化器具有值为零的重建电平(对应于楼梯的踏板),而中升型量化器具有值为零的分类阈值(对应于楼梯的竖板)。
维基百科作为来源引用了一篇 1977 年的论文,该论文的标题和摘要排版如此令人难以置信,以至于我必须在这里重现:
- “Quantization” by Allen Gresho. IEEE Communications Society Magazine, September 1977.
无论如何。当画在图上时,中升型和中平型量化器在过零点处的表现不同:
*
确实,中平型将零映射到零,而中升型将零映射到两个整数的中间(听起来熟悉吗?)。维基百科选择的符号用 x 表示输入实数,k 表示其编码(“分类”)整数值,y_k 表示重建实数。对应的量化器公式如下:
| 类型 | 分类(编码) | 重建(解码) |
|---|---|---|
| 中平型楼梯量化器 | k = ext{trunc}(x L + 0.5) | y_k = k/L |
| 中升型楼梯量化器 | k = ext{trunc}(x L) | y_k = (k+0.5)/L |
L 代表不同输出电平的数量(例如 256)。
如果我们将这些定义应用到我们两种竞争的方法中,我们可以称标准公式为 L=255 的“中升型”,替代做法为 L=256 的“中平型”。实际上,我将再次展示它们的代码并附上新的标签,以便清楚地连接到上述公式。代码片段本身与开头相同。
中平型量化器 (L=255)
pixels = img / 255.0
result = process(pixels)
output = np.trunc(result * 255 + 0.5)
中升型量化器 (L=256)
pixels = (img + 0.5) / 256.0
result = process(pixels)
output = np.trunc(result * 256)
从这个角度来看,我们可以说标准做法是一种奇怪的组合:它是对无符号输入的中升型量化器(引用中说的是“对于有符号输入数据”),并且选择了 L=255 个整数码。显然这对于 8 位输入来说不是最优的。同样,这一切都是为了编程的便利,使边界映射到 0.0 和 1.0。这引出了对标准公式的最终批评。
更高的量化误差?其实不然
如果我们设计一个系统,接收均匀分布的实数 x ∈ [0,1],将其编码为 8 位整数 k,最后重建为另一个实数 y_k,那么标准公式会浪费带宽。还记得 0 和 255 号箱子如何稍微凸超出了 [0,1] 范围的边缘吗?在标准做法中,可表示值的范围实际上是 [-0.5/255, 255.5/255],这意味着箱子的间距比严格需要的 [0,1] 输入更大,从而导致更高的重建误差。然而,误差的增加很小。根据 StackOverflow 用户 Peter Mudrievskij 的计算,对于 255 和 256 除数,平均绝对误差分别为 1/1020 和 1/1024。因此,从理论上讲,除以 256 更精确。
微妙之处在于,这种重建并不是我们实际在做的。前提是我们加载 8 位 RGB 图像,对它们进行处理,然后再次保存。我们无法控制它们在保存时是如何量化的;所有丢失的信息都永远消失了。换句话说,如果一幅图像的颜色乘以 255 并四舍五入,那么在加载时除以 256 并不能恢复任何精度。只有当我们同时控制保存和加载时,追求更低的重建误差才有意义。
事实上,使用替代公式来加载别人的图像会引入更多误差。这些图像很可能是通过标准公式量化的,因此理论上用错误的缩放因子解码是不正确的。在实践中,颜色并不是绝对测量值(即使 sRGB 规范声称如此),所有发生的事情就是我们的处理将在稍微更小的范围内进行,并带有一个小的偏移。微妙部分到此结束。
最后,永远不要混淆两种量化器的编码和解码步骤。那只是有 bug 的代码。不过,这是一个很容易犯的错误。
结论
回答标题中提出的问题:如果你处理的是陌生人给你的图像,你应当将 RGB 值除以 255 进行归一化。无论是浮点数值的不精确性,还是某种对更高重建误差的抽象感受,都不是采用替代做法的好理由。但是,如果你同时控制图像的保存和加载,不需要零映射到零,并且愿意将你的处理代码与 8 位动态范围绑定,那么你可以考虑除以 256 来多挤出一点精度。只是当你的同事仍然用标准公式加载你的图像,毁掉你的宏伟计划时,别怪我就行。
其他观点
Jonathan Blow 在 2002 年的文章中讨论了中升型和中平型量化器,但没有提及它们的名称。我从那里得到了图表思路。
之前提到的 2015 年博客文章(Andrew Kesler 著)支持替代公式。不幸的是,它是对标准公式进行比较,但标准公式没有包含取整步骤,这使得大部分分析无效。
我正在写一本关于颜色缩减算法的书。如果你感兴趣,可以在此注册。
**