准确的颜色转换(2023年)

Lobsters Hottest 新闻

摘要

Chris Lomont 解释了在 C++ 中整数与浮点数颜色值之间准确转换的方法,突出常见陷阱,并提供一个往返安全且具有均匀映射的解决方案。

<p><a href="https://lobste.rs/s/zyuk9c/accurate_color_conversions_2023">评论</a></p>
查看原文
查看缓存全文

缓存时间: 2026/08/17 16:09

# 精确的颜色转换 · Lomont.org 来源:http://lomont.org/posts/2023/accuratecolorconversions/ 这是一篇关于在 0~255 的字节值颜色与 0.0~1.0 的浮点数颜色之间进行来回转换的简要说明,旨在避免常见错误。 ## 精确的颜色转换 Chris Lomont, 2023年6月 本文简要介绍了如何在 {0, 1, ..., 255} 范围内的字节值颜色与 [0.0, 1.0] 范围内的浮点值颜色之间进行精确转换。令人惊讶的是,要做好这件事颇具挑战,网上最常见的方法都存在重大问题。这个结论是我在多年为一些小玩意(比如 Hypnocube (https://hypnocube.com/) 相关的东西)开发时反复推导出来的。为了避免再次推导,我决定一次性把它写下来。 ## 简而言之 使用本文底部的 C++ 代码即可。它避免了网上常见的许多陷阱。 ## 问题描述 抽象来看,我们要在整数表示的颜色值(这是图像格式中通常的存储方式)与浮点数表示的颜色值(这是图像处理任务中常用的操作方式)之间进行来回转换。最常见的范围是表示红、绿、蓝(有时还有 alpha)的字节值,整数范围 {0, 1, ..., 255} 与浮点范围 [0.0, 1.0] 之间。在本文中,我将用小数点表示浮点值,不带小数点表示整数。另外请注意,抽象的整数范围可以是 N 个颜色值 0 到 N-1,下面的分析同样适用。此处 N=256。 我们将从颜色转换的一个需求开始,并在发现更多需求时逐步增加。 1. **往返一致性**:从整数到浮点再回到整数的往返转换,必须返回原始整数值。 ## 错误的方法 最常见的建议是用简单的公式将整数 i 转换为浮点数 f: $$ f = i / 255.0 $$ 这个方法有一个优点:0 -> 0.0 且 255 -> 1.0,看起来是正确的。下一个问题是如何将浮点值转回去,这就会导致麻烦了。由于第一步用了除以 255.0,大多数地方会建议反向操作乘以 255.0,这似乎很合理。因此我们将考虑第一步为: $$ \hat{f} = f \times 255.0 $$ $\hat{f}$ 的范围是 [0.0, 255.0]。如何将它们转换为整数呢?我们可以尝试四舍五入、向下取整、向上取整等方法。向下取整将以下半开整数区间(除了最后一个,它是一个单点)映射为整数: $$ \begin{align*} [0.0, 1.0) & \rightarrow 0 \\ [1.0, 2.0) & \rightarrow 1 \\ [2.0, 3.0) & \rightarrow 2 \\ & ... \\ [254.0, 255.0) & \rightarrow 254 \\ \{255.0\} & \rightarrow 255 \end{align*} $$ 立刻就能看出问题。每个整数 n 来自一个浮点范围 [n, n+1),除了最大的整数 255,它只来自一个单点。这种不均匀性是坏事,意味着在浮点数上对图像进行的操作会偏向远离 255 这个值。使用向上取整有同样的问题,只不过它偏向远离 0。 四舍五入(我们选择舍入时向上,任何舍入模式都有类似问题)导致: $$ \begin{align*} [0.0, 0.5) & \rightarrow 0 \\ [0.5, 1.5) & \rightarrow 1 \\ [1.5, 2.5) & \rightarrow 2 \\ & ... \\ [253.5, 254.5) & \rightarrow 254 \\ [254.5, 255.0] & \rightarrow 255 \end{align*} $$ 这好一些了,但是整数 0 和 255 对应的浮点范围区间大小是其他整数的一半。这意味着图像处理会丢失一些端点颜色的表示,这是不好的。 这引出了第二个需求: 1. **均匀性**:每个整数应来自一个大小均匀的浮点范围(尽可能)。 我们将这种方法称为**错误的方法**(这是网上最常见的) $$ f = \frac{i}{255.0} \quad i = \text{round}(f \times 255.0) $$ ## 更好的方法 你可以尝试很多其他映射方法,很快就会意识到乘以 255.0 是罪魁祸首。你需要 256 个等大小的“分箱”,而不是 255 个。将 [0.0, 1.0) 分成 256 个箱,每个宽度为 $\Delta = \frac{1.0}{256.0}$。注意这里我暂时移除了单点结束值 1.0。类似地,将整数视为 256 个箱,每个形式为 [n, n+1)。 现在让我们将每个整数箱的中心映射到每个浮点数箱的中心。我们可以选择不同的映射,但这些中心映射具有很好的属性。 这种映射看起来像: $$ f = \frac{i + 0.5}{256.0} $$ 一个合理的逆映射将是: $$ i = f \times 256.0 - 0.5 $$ 这将精确地映射回去,即使在 IEEE 754 浮点数中也是如此。但是浮点值 0.0 和 1.0 会映射到合法范围 0, 255 之外,所以我们需要更谨慎一些。 我们想要的映射是: $$ [n\Delta, (n+1)\Delta) \rightarrow n $$ 将区间乘以 256.0 得到期望的 [n, n+1) -> n),所以我们可以使用逆映射: $$ i = \lfloor f \times 256.0 \rfloor $$ 其中 $\lfloor t \rfloor$ 是向下取整函数。对于唯一映射到 256(超出合法范围)的浮点值 1.0,我们可以将结果截断到 [0, 255]。务必处理好这个情况。 这种方法可以在许多已经深入分析到这一步的网站上找到,它比第一种方法好得多。它是均匀的,将整数映射到区间的中间,将区间映射回整数,允许浮点计算中发生一些误差,同时仍能达到最佳像素。 这种模糊安全性以及处理任一端错误的需求在实践中非常重要,我们将增加一个需求: 1. **鲁棒性**:该方法必须正确处理超出范围的情况,并且对往返转换中的微小精度误差具有鲁棒性。 我们将这种方法称为**更好的方法**: $$ f = \frac{i + 0.5}{256.0} \quad i = \text{clamp}(\lfloor f \times 256.0 \rfloor, 0, 255) $$ 但还有更好的方法,我目前还没在别处见过。上面这种方法有什么问题呢?考虑 0(最暗,能量最小)和 255(最亮,能量最大)映射到的位置: $$ 0 \rightarrow \frac{0.5}{256.0} \neq 0.0 \\ 255 \rightarrow \frac{255.5}{256.0} \neq 1.0 $$ 让最暗的、最低的字节值不映射到最暗的、最低的浮点值会导致一些问题。考虑检查两张图像的差异——通常方法是值相减,然后缩放差值以增加可见度。如果有一个非零值,然后相乘,就会引入不应存在的错误。在很多小地方,这个略大于 0.0 的“最低值”会带来麻烦,同样略小于 1.0 的“最高值”也是如此。 所以让我们再增加一个需求。 1. **全范围**:端点应映射到端点。 ## 最佳方法 以下是满足所有这些需求的方法,**最佳方法**: $$ f = \frac{i}{255.0} \quad i = \text{clamp}(\lfloor f \times 256.0 \rfloor, 0, 255) $$ 因此,我们必须满足的需求总结如下: 1. **往返一致性**:从整数到浮点再回到整数的往返转换必须返回原始值。 2. **均匀性**:每个整数应来自一个大小均匀的浮点范围(尽可能)。 3. **鲁棒性**:该方法必须正确处理超出范围的情况,并且对往返转换中的微小精度误差具有鲁棒性。 4. **全范围**:端点应映射到端点。 让我们证明这些要求都得到了满足。设 $\Delta = \frac{1}{256}$。对于 $i \in \{0,1,2,...,254\}$,令 $I_i$ 为半开区间 $[i\Delta, (i+1)\Delta)$;对于 $i=255$,令 $I_{255}=[255\Delta, 256\Delta]$,这是一个闭区间。那么我断言: 1. $i$ 映射到 $I_i$ 内。0 映射到 $I_0$ 的左边界,255 映射到 $I_{255}$ 的右边界,对于 $i=1,2,...,254$,$i$ 映射到远离 $I_i$ 端点的位置,事实上,$i$ 映射到区间 $I_i$ 内 $\frac{i}{255 \times 256}$ 的位置。 2. $I_i$ 映射回 $i$。 如果这些成立,那么需求就得到了满足: 1. 往返一致性:对于 $i = 0,1,2,...,255$,$i \rightarrow I_i \rightarrow i$ 是显然的。 2. 均匀性:$I_i$ 互不相交,每个都包含在 [0.0, 1.0] 内,它们覆盖了 [0.0, 1.0],并且每个的宽度都是 $\Delta$。 3. 鲁棒性:每个 $i$ 映射到一个点,该点有一个邻域可以映射回 $i$。端点由 `clamp(_, 0, 255)` 部分处理,其他的 $i$ 值映射到区间内 $\frac{i}{255.0 \times 256.0} > 0$ 的位置。 4. 全范围:对于 $i=0$ 和 $i=255$ 很容易手动验证。 断言证明: 1. $\frac{i}{266.0} \leq \frac{i}{255.0} \leq \frac{i+1}{256.0}$ 给出 $i \rightarrow I_i$(通分、简化)。区间左边界到 $i$ 映射点的距离是 $\frac{i}{255.0} - \frac{i}{256.0} = \frac{i}{255.0 \times 256.0}$,对于 $i \neq 0, 255$,这不是端点。 2. 对于 $i < 255$: $$ \begin{eqnarray*} \text{clamp}(\lfloor I_i \times 256.0 \rfloor, 0, 255) &=& \text{clamp}(\lfloor [i, i+1) \rfloor, 0, 255) \\ &=& \text{clamp}(i, 0, 255) \\ &=& i \end{eqnarray*} $$ 对于 $i=255$,向下取整返回 255 或 256,clamp 将两者都映射为 255。 因此该算法满足所有需求。 ## 最终代码 对于**最佳方法**的 C++ 实现,我们可以将转换函数设为 `constexpr`。由于 `std::floor` 直到 C++ 23 才是 `constexpr`,我将使用另一种方法。将函数设为 `constexpr` 允许通过使用 `static_assert` 进行编译时测试。C++ 标准保证将浮点值转换为整数类型时会向零截断,这对于负值与向下取整不同,但在本例中,来自数值误差的任何负值向 0 截断是可以接受的。 这引出了 **C++ 最佳方法**: ```cpp #pragma once /* MIT License Copyright 2023 Chris Lomont, www.lomont.org Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ // 用于在 0-255 的字节颜色和 0.0 到 1.0f 的浮点颜色之间进行精确转换的代码 // 参见 https://lomont.org/posts/2023/accuratecolorconversions/ #include <cstdint> // uint8_t #include <algorithm> // std::clamp since C++ 17 // 在 {0,1,...,255} 的字节颜色和 [0,1] 的 float32 颜色之间转换的代码。 // 这些方法是均匀、稳定且高质量的。 // 将 0-255 的字节颜色转换为 0-1 的 32 位浮点数 constexpr float ColorU8ToF32(uint8_t colorB) { constexpr float inv255 = 1.0f/255.0f; return colorB * inv255; } // 将 [0,1] 的浮点颜色转换为 0-255 的字节颜色 // 输出时超出范围的浮点值会被截断 constexpr uint8_t ColorF32ToU8(float colorF) { // 向零截断与向下取整略有不同, // 但在本例中 <= 0 的值会被截断到 0,所以效果可以接受。 int32_t i32 = static_cast<int32_t>(colorF * 256.0f); return static_cast<uint8_t>(std::clamp(i32, 0, 255)); } // 一些测试以确保一切正确 // static assert 仅在 constexpr 成功时对编译时有效 static_assert(ColorF32ToU8(-0.01f) == 0); // 被截断 static_assert(ColorF32ToU8(0.0f) == 0); static_assert(ColorF32ToU8(0.5f-1.0f/65536.0f) == 127); static_assert(ColorF32ToU8(0.5f) == 128); static_assert(ColorF32ToU8(1.0f) == 255); static_assert(ColorF32ToU8(1.01f) == 255); // 被截断 // 以下所有作为 float32 都应该是精确的 static_assert(ColorU8ToF32(0) == 0.0f); static_assert(ColorU8ToF32(255) == 1.0f); static_assert(ColorU8ToF32(2) - ColorU8ToF32(1) == 1.0f/255.0f); static_assert(ColorU8ToF32(127) == 127.0f*(1.0f/255.0f)); // 注意,作为浮点数,1.0f/255.0f != 1.0f*(1.0f/255.0f),它们略有不同 ``` 你可以在 [godbolt.org](https://godbolt.org/) 上摆弄这段代码,看看你的编译器如何处理它。 ### 代码注释 在将数学运算转换为浮点数时需要思考的一些要点。另见我的浮点数笔记()。我这里假设 IEEE 754 浮点格式。 1. 实数如果能精确存储为浮点数,则称为**可表示的**。可表示的值必须能表示为 2 的幂的和(并满足其他一些技术关系)。例如,0.3, 1.9, 9.1 是不可表示的。0.5, 0.75, 23.125 是可表示的。 2. 从浮点数转换为整数时会向零截断(这在大多数语言如 C/C++, C#, Java 等中是成立的)。注意这与向 $-\infty$ 方向移动的 `floor` 不同。 3. IEEE 754 标准保证,可表示数的加、减、乘、除运算如同拥有无限精度并在最后根据格式的有限位数进行舍入。打破舍入平局可以有几种方法。我会小心避免这里的错误。 4. 浮点数舍入为整数的常用方法 `(int)(floatVal + 0.5)` 可能无法正确舍入。首先,C/C++ 中的转换是向零截断的,这会导致问题(虽然这里不是),其次某些值会导致浮点精度问题从而舍入错误,例如将上述方法应用于浮点值 0.49999997f 得到的不是 0,而是 1。(这个值是 0.5f 的前驱,可通过 C++ 11 起的 `nextafter` 和 `nexttoward` (https://en.cppreference.com/w/cpp/numeric/math/nextafter) 例程获得)。这是大约 8388609.0f 之前唯一会失败的正值。对于很多负数它会失败。 5. 你可以将浮点数的乘除以 2 的幂转换为使用类似 C++ 11 `scalbn` (https://en.cppreference.com/w/cpp/numeric/math/scalbn) 风格函数的更快方法。 6. 编译器可能会做除以乘的技巧,你甚至可以通过在指数上使用技巧手工编码以完全消除所有乘法和除法。 7. `<charconv>` 头文件中的 `to_chars` (https://en.cppreference.com/w/cpp/utility/to_chars) 和 `from_chars` 特别有用。它们在浮点数和字符数组之间转换,并且是 C++ 标准库中唯一保证能正确往返转换这些值的方法,这意味着你可以将浮点数输出为文本,然后再转换回相同的浮点数。令人惊讶的是,标准库中的其他方法并不保证这一点,并且经常失败。而且,既然是 C++,即使是这些方法也有一个重要的陷阱:你不能跨实现使用它们,因为跨实现的往返转换是不保证的! ## 附录 ### 现有实现 A: `f = i*255.0 且 i = func(f * 255.0)` 1. Apple Metal 着色语言使用 A (https://developer.apple.com/metal/Metal-Shading-Language-Specification.pdf, table 7.6, 7.7, 浮点数转整数)。规范指定 i->f 必须是全范围的,我们同意这一点,但 i = intRTNE(max(f*255.0, 0.0), 255.0) 是错误的(intRTNE 是舍入模式?) 2. OpenGL 也是错误的(参见他们的文档...) 3. Unity 论坛:(byte)(float*255.0) 是最佳答案 (https://answers.unity.com/q...)

相似文章

应该用255还是256来归一化RGB值?

Lobsters Hottest

文章比较了归一化RGB值的两种方法(除以255 vs 除以256),并解释了浮点数转换和舍入的后果,包括在极端值处不均匀的区间宽度。

量化色彩

Hacker News Top

本文探讨了如何通过物理测量光波长来量化颜色,以实现显示器上的精确颜色再现,涵盖了历史命名挑战和科学原理。

Oklch颜色选取(凡人指南)

Lobsters Hottest

一本以通俗易懂的方式解释Oklch颜色空间的指南,强调其感知均匀性,以便在设计中实现一致的颜色选取。