做图像处理的这些年颜色这个话题永远是新手的第一道坎也是老手容易翻车的细节。这个系列走到第5问正好是个分水岭——从这问开始进入真正的色彩科学。今天这篇聊两件事HSV变换和色相反转。核心就一句话把颜色从机器视角翻译成人的直觉然后在色相环上做一次对折。适合刚入门想夯实基础的人也适合写了不少OpenCV代码但一直没想明白为什么调色要先转HSV的老伙计们。1. 为什么调色要先换颜色空间RGB的直觉陷阱1.1 RGB的思维死角颜色距离不等于视觉距离先问一个问题你觉得(255, 0, 0)和(128, 0, 0)更像还是(255, 0, 0)和(0, 255, 0)更像从RGB数值上看前者欧氏距离是127后者是361所以按数值相近来判断红色和暗红色更接近。但人眼看到的其实是红色和暗红色是同一色系的不同明暗红色和绿色是完全不同的两种颜色。这就是RGB作为颜色空间的最大问题三个通道的数值变化和人对颜色的感知根本不是一一对应。RGB本质上是显示器荧光粉的物理发光模型。R通道只管红色发得多亮G通道只管绿色发得多亮B通道只管蓝色发得多亮。三个通道强相关、高耦合你动任何一个数值颜色的明暗、鲜艳程度、色相往往同时改变。想做把红色变成蓝色但保持明暗不变这种操作在RGB空间里得同时调三个通道还不一定调得准。打个比方RGB像用经纬度定位一座城市但你要问的是这座城市的天气怎么样经纬度给不了答案得看气温图、气压图。HSV就是那张更贴合人类直觉的气象图。1.2 HSV的三个分量色相、饱和度、明度HSV把颜色拆成三个独立维度每个维度管一件事HHue色相颜色在色环上的角度0度红、120度绿、240度蓝走一圈回到0度。这是这是什么颜色的答案。SSaturation饱和度颜色里掺了多少白。纯色饱和度最高发灰发白的颜色饱和度低。这是颜色纯不纯的答案。VValue明度颜色整体的明亮程度从黑色到白色。这是颜色亮不亮的答案。三者互不干扰才是HSV最大的价值。改H只动这是什么颜色S和V保持原样改S只动鲜艳程度改V只动明暗。这种解耦特性让调色变成了一件几乎无脑的事——想生成一张色调完全相反但明暗、饱和度和原图一致的图只需要在H通道上做一次数学运算其他通道碰都不用碰。这也是为什么几乎所有的调色软件、图像滤镜、视频后期工具内部都在用HSV或HSL系颜色空间。不是RGB不好而是RGB不适合干这种按人话调色的活。2. HSV转换公式拆解从数学公式到代码落地2.1 RGB到HSV的核心公式要把HSV用好不能只当OpenCV的黑盒调用者至少得知道里面的公式长什么样。以下默认RGB和HSV值都归一化到[0,1]浮点区间。先定义max max(R, G, B) min min(R, G, B) diff max - min明度最简单直接取最大值V max饱和度是偏离灰的程度S diff / max 当 max ≠ 0 时 S 0 当 max 0 时色相是三分段函数看哪个通道是最大值若 diff 0即 max min H 0灰度色色相无定义通常取0 若 max R H 60 × ((G - B) / diff % 6) 若 max G H 60 × ((B - R) / diff 2) 若 max B H 60 × ((R - G) / diff 4)H的取值范围是0到360度% 6是为了保证max R时结果不落到负数。这个% 6是新手最容易看懵的地方它的作用是当G-B之类的结果出现负值或超界时把结果拉回0到6的区间里。2.2 公式里的三个易错点第一个易错点是除法。R、G、B是整数时很多语言里直接做除法会截断。比如(200-100)/(255-100)在整数除法里等于0而不是0.645一上来色相就全错了。所以手写转换时第一件事就是把数值转成浮点。第二个易错点是max 0的情况。如果像素是纯黑max 0计算饱和度S diff/max就是除以零。数学上约定此时S 0因为黑色没有饱和可言。代码里得先判断再计算否则会出现inf或NaN污染周边像素。第三个易错点是diff 0的情况。某个像素RGB它是灰色色相无定义。按惯例直接设H0不影响后续任何计算。批量向量化处理时一定要加一个mask做保护直接让灰色区域参与色相公式会算出奇怪的角度。3. 实操第一站OpenCV自带的HSV变换与色相反转3.1 环境准备与图片读取先装依赖OpenCV、NumPy、Matplotlib三件套pip install opencv-python numpy matplotlib图片读取有一个经典大坑cv2.imread读进来的图片通道顺序是BGR不是RGB。如果你直接用plt.imshow去显示它红色和蓝色会互换整张图变得像被施了魔法一样诡异。我建议所有读取后立刻转成RGB来预览但做颜色空间转换时直接用BGR顺手的很因为OpenCV的cvtColor默认输入输出都是BGR。import cv2 import numpy as np import matplotlib.pyplot as plt # 读入图片注意cv2.imread返回BGR通道顺序 img_bgr cv2.imread(demo.jpg) img_rgb cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB) # 用matplotlib预览 plt.imshow(img_rgb) plt.axis(off) plt.show()3.2 色相反转的完整代码核心思路拆成四步BGR转HSV、拆通道、H通道做取模加法、合并通道转回BGR。# 1. BGR转HSV hsv cv2.cvtColor(img_bgr, cv2.COLOR_BGR2HSV) # 2. 拆出H、S、V三个通道 h, s, v cv2.split(hsv) # 3. 色相反转H通道加90再对180取模 # 为什么是90不是180下一小节详细讲 h_inv (h.astype(np.int16) 90) % 180 h_inv h_inv.astype(np.uint8) # 4. 合并回HSV再转回BGR hsv_inv cv2.merge([h_inv, s, v]) img_inv_bgr cv2.cvtColor(hsv_inv, cv2.COLOR_HSV2BGR) img_inv_rgb cv2.cvtColor(img_inv_bgr, cv2.COLOR_BGR2RGB)跑完看一眼效果原图里的暖色区域全部变成冷色调冷色区域全部变成暖色调而且每块颜色翻转后明暗、饱和程度跟原图保持一致。这种换血不换骨的感觉就是HSV解耦特性的直接体现。3.3 为什么H通道加90而不是加180很多人第一次写色相反转时直接在H上加180结果图花得没法看。原因很简单OpenCV里的H通道取值范围是0到179对应的是色相环0到360度压缩了一半。所以想在色相环上转180度就得加90然后对180取模。再解释一下取模。色相是一圈循环的359度再走1度就变回0度。所以(360 180) % 360 180同理OpenCV里(170 90) % 180 80。如果不取模像素值会超过179转回BGR时OpenCV就把它截断或者溢出结果色相乱掉。还有个细节为什么h要转成int16再运算因为h是uint8类型范围0到255h 90在某些像素上可能超过255比如h220时22090310超过255后uint8自动截断成54等于是绕了一圈又拐回来结果完全不可控。升到int16后不会有溢出的问题运算完再转回uint8是安全的。这个坑我踩过两次第一次结果图上出现了一块一块的花色排查了半天才发现是数据类型在作怪。提示如果你用的是其他库或者自己在做浮点计算色相反转的公式统一写为new_h (h 180) % 360前提是H单位是度、范围是0到360。4. 实操第二站不用库手写一个RGB到HSV转换4.1 纯NumPy手写转换函数OpenCV一行搞定固然方便但我建议每个人都至少手写一次。原因有两个一是面试常考二是只有手写过才能真正理解公式里那些边界条件和单位换算。下面这个版本用全向量化写法避免直接写for循环遍历像素性能也够好import numpy as np def rgb_to_hsv_numpy(rgb): 输入: rgb 形状为 (H, W, 3) 的 uint8 数组 输出: hsv 形状为 (H, W, 3) 的 float32 数组 h 范围 [0, 360), s 范围 [0, 1], v 范围 [0, 1] # 转成浮点并归一化到0-1 rgb rgb.astype(np.float32) / 255.0 r, g, b rgb[..., 0], rgb[..., 1], rgb[..., 2] max_c np.max(rgb, axis-1) min_c np.min(rgb, axis-1) diff max_c - min_c # 明度 v max_c # 饱和度 s np.zeros_like(max_c) mask_nonzero max_c 0 s[mask_nonzero] diff[mask_nonzero] / max_c[mask_nonzero] # 色相 h np.zeros_like(max_c) mask_diff diff 1e-8 # max R mask_r mask_diff (max_c r) h[mask_r] 60.0 * (((g[mask_r] - b[mask_r]) / diff[mask_r]) % 6) # max G mask_g mask_diff (max_c g) h[mask_g] 60.0 * (((b[mask_g] - r[mask_g]) / diff[mask_g]) 2) # max B mask_b mask_diff (max_c b) h[mask_b] 60.0 * (((r[mask_b] - g[mask_b]) / diff[mask_b]) 4) return np.stack([h, s, v], axis-1)这个函数用mask_diff统一处理了灰色区域凡是RGB的像素色相一律保持0不会参与公式计算。代码里的diff 1e-8是为了排除浮点误差造成的假性相等很实用。4.2 手写结果和OpenCV结果对照为了验证写对了把同一张图分别用手写函数和OpenCV转换再转到同一个颜色空间下做像素级比较hsv_manual rgb_to_hsv_numpy(img_rgb) hsv_cv cv2.cvtColor(img_bgr, cv2.COLOR_BGR2HSV) # OpenCV的H范围是0-179S和V范围是0-255先归一化再比较 hsv_cv_norm hsv_cv.astype(np.float32) hsv_cv_norm[..., 0] * 2 # H从0-179变成0-358尽量贴近0-360 hsv_cv_norm[..., 1] / 255 # S从0-255变成0-1 hsv_cv_norm[..., 2] / 255 # V从0-255变成0-1 diff np.abs(hsv_manual - hsv_cv_norm) print(最大差异:, diff.max()) print(平均差异:, diff.mean())输出一般会是一个很小的值比如最大差异: 1.2、平均差异: 0.08。差异来源主要是OpenCV内部对角度做了量化处理比如H的偶数化、S和V的定点乘法舍入。看到这个结果基本可以确认手写公式逻辑正确。手写这版还有个好处你可以自由改变输出范围。想把S和V输出成0到255就在return前乘上255想输出成OpenCV的H范围0到179就把H除以2四舍五入。这种灵活性在某些嵌入式平台和面试现场非常有用。5. 常见问题与排查技巧实录5.1 色相翻转后出现大片花色uint8溢出问题表现翻转后的图像上出现很多随机彩色噪点原本是渐变区域的地方变成五颜六色的马赛克。排查思路先检查H通道的历史变化。(h.astype(np.int16) 90) % 180没有问题但如果你直接写(h 90) % 180h是uint8加90时在H200以上的像素会瞬间溢出到10以下取模运算基于错误数值结果必然乱。解决办法就是先升位再运算运算完再降回uint8。这是最容易踩的坑也是我在所有代码注释里都特意标注先astype(np.int16)的原因。5.2 翻转后画面偏色严重通道顺序搞反问题表现翻转出来的结果明暗正常但是整体颜色诡异比如原本的蓝天变成洋红色而不是互补的橙色。排查思路看看自己是不是用了cv2.merge([h, s, v])后才想起cv2.cvtColor的输入是BGR。实际上与通道顺序直接相关的是如果你从cv2.split(hsv)里拆出来的顺序是h, s, v合并时顺序错了比如cv2.merge([s, h, v])相当于把饱和度和色相数据互换整个颜色逻辑全部乱套。另外注意cv2.imread读入的是BGR如果你用Matplotlib的plt.imshow直接显示最后转回来的BGR图红蓝会互换。正确做法是显示前用cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB)再转一次。5.3 灰色区域没有变化这是正常现象有读者问过为什么翻转后纯黑、纯白和灰色区域在原地纹丝不动。原因很简单灰度的饱和度S为0色相无定义你加多少H都等于没加。这在物理上也合理——灰色没有色相可以翻转。如果你希望灰色区域也能有什么变化那得去动V通道或者S通道而不是H。这不算bug是HSV模型的语义使然。5.4 色相反转的实际应用场景色相反转不只是一道练习题它在真实工程里有几个很实用的落地场景场景需求做法数据增强训练识别模型时需要增加颜色多样性对训练图做色相反转生成互补色版本提升模型对色调变化的鲁棒性UI主题切换同一套素材要适配多个皮肤把素材转到HSV后做H偏移一键切换冷暖主题色彩教学演示展示互补色、色相环规律用色相反转直观展示红色对青色、绿色对品红视觉辅助某些色彩分辨困难人群需要增强对比将难以区分的色相翻转成差异更大的颜色这里面数据增强是最典型的场景。做目标检测时同一类物体的颜色会因光照、场景而变化直接把H通道做一个随机偏移或反转相当于免费扩充了一倍数据量。还有做色偏校正时先转到HSV里检查H通道的分布能快速定位整体偏色方向。写在最后我个人的经验是颜色空间这块知识看十遍文档不如手写一遍实现。写完第4节那个手写转换函数再回头看OpenCV的文档每个参数都觉得有血有肉。色相反转只是HSV应用里的第一课后面还有HSV直方图均衡、按色相分割特定物体、对S和V做非线性拉伸一条线挖下去能玩出很多花活。下一问可以试试把HSV直方图做出来统计一张图的主色调分布那个也非常有意思。
阅读完成 · 觉得有帮助?