彩色图像
灰度图像用一个数表示像素亮暗,彩色图像则必须同时描述亮度与色彩。最直接的做法是记录红、绿、蓝三个分量,但 RGB 数值既依赖设备,又把亮度和色度混在一起:一个 RGB 三元组未必能在另一台显示器或打印机上产生相同视觉结果;只想增强亮度时,分别修改三个通道还可能改变色相。因此彩色图像处理先要回答两个问题:颜色用什么坐标表示,以及当前任务应在哪个颜色空间中完成。“颜色空间”是一种坐标系选择而非自然事实,答案取决于数据从哪里来、将送往哪里以及要做什么运算:需要跨设备传递时选设备无关空间,需要分开明暗与色调时选感知式分解,需要实际显示或打印时则回到设备相关空间。
设图像定义域为 ,具有 个颜色分量的彩色图像是向量值函数
彩色处理既可以逐分量进行,也可以把像素视作颜色空间中的向量统一变换。前者实现简单,却容易破坏分量之间的比例;后者能够显式保持色相、白点或感知色差。本章从设备相关的 RGB、CMYK 出发,经由 HSI 的感知式分解进入设备无关的 CIE 模型,最后讨论校色、直方图处理和假彩色映射。[1]
一、彩色图像的表示
颜色模型不是给同一种颜色随意换三个名称,而是在一个坐标系中规定“哪些数值对应哪些颜色”。选择模型时要看数据从哪里来、将送往哪里以及要执行什么运算:摄像机和显示器通常围绕 RGB 工作,印刷使用 CMYK,基于色相的交互调整适合 HSI/HSV,而跨设备测量和色差计算更适合 XYZ、CIELAB。
1. RGB 彩色模型
RGB 是加色模型。归一化颜色写成
RGB 之所以称为“加色”,是因为光能量相加:红光与绿光叠加产生黄色,绿光与蓝光叠加产生青色,红光与蓝光叠加产生品红,三者共同达到最大值产生白色。RGB 立方体从黑色顶点到白色顶点的主对角线满足 ,表示全部无彩色灰度。

数字图像通常把每个分量量化为 8 位无符号整数,因此共有 种三元组。这里的“约一千六百万种数值组合”不等于人眼能可靠区分同样多种颜色,也不保证这些三元组在任意设备上具有相同的色度。RGB 只给出坐标形式;要确定真实颜色,还必须知道基色的色度、白点和传递函数。例如常见 sRGB 在存储前施加非线性编码,数值 0.5 并不表示线性光强恰好为最大值的一半。
imread 默认返回 BGR,而不是通常书写的 RGB。更容易混淆的是,普通 OpenCV 彩色图像采用通道后置布局,形状为 [M, N, 3]: 是图像高度即行数, 是宽度即列数,最后一维依次存放 B、G、R。因而 image[y, x] 得到一个 BGR 像素,image[y, x, 0] 才是该像素的蓝色分量;image[0] 表示第一行,绝不是整个蓝通道。cv2.split(image) 返回三个形状均为 [M, N] 的二维数组。只有显式执行 transpose(2, 0, 1),数组才变成机器学习框架常见的通道前置布局 [3, M, N]。具体而言,OpenCV 中常见的数组布局如下:
- 灰度图像的形状是
[M, N],没有长度为 1 的通道轴。 - 默认彩色图像的形状是
[M, N, 3],末轴顺序为 B、G、R。 - 使用
cv2.IMREAD_UNCHANGED读取带透明通道的 PNG 时,形状可能是[M, N, 4],末轴顺序为 B、G、R、A。 - Matplotlib、Pillow 等工具通常按 RGB 解释三通道数组,所以显示前应使用
cv2.cvtColor(image, cv2.COLOR_BGR2RGB)。 - 部分深度学习接口要求
[3, M, N]或批量形式[B, 3, M, N];这是张量接口的布局要求,不是 OpenCV 图像本身的默认格式。
下面把同一图像拆成三个加色通道。单通道图不是简单画成灰度,而是只保留对应基色,使“该通道在哪里贡献较大”可以直接观察。

from pathlib import Path
import cv2
import numpy as np
image_bgr = cv2.imread(str(Path("color-scene.png")))
if image_bgr is None:
raise OSError("图像读取失败")
height, width, channel_count = image_bgr.shape
assert channel_count == 3
# 普通索引顺序是 [行 y, 列 x, 通道 c]。
y, x = 40, 70
blue_value = image_bgr[y, x, 0]
green_value = image_bgr[y, x, 1]
red_value = image_bgr[y, x, 2]
blue, green, red = cv2.split(image_bgr)
zeros = np.zeros_like(red)
red_only = cv2.merge([zeros, zeros, red])
green_only = cv2.merge([zeros, green, zeros])
blue_only = cv2.merge([blue, zeros, zeros])
# 给按 RGB 解释数组的绘图库使用,形状仍是 [M, N, 3]。
image_rgb = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2RGB)
# 若神经网络明确要求通道前置,再转换为 [3, M, N]。
image_chw = np.transpose(image_rgb, (2, 0, 1))
image_chw = np.ascontiguousarray(image_chw)2. CMY 与 CMYK 彩色模型
CMY 是减色模型。对于归一化、处于同一编码约定下的 RGB 值,理想转换为
理想情况下 应产生黑色,但真实油墨的光谱吸收不理想,三色叠印通常得到偏褐且耗墨的暗色。因此印刷系统单独加入黑色通道 。一种常见的数学分解是
其中 时令 。这只是“灰成分替换”的基础形式;实际色彩管理还要使用设备 ICC 特性文件、总墨量限制和黑版生成曲线,不能把任意 RGB 图像按上述公式直接当作印刷终稿。


rgb = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2RGB).astype(np.float32)
rgb /= 255.0
cmy = 1.0 - rgb
black = np.min(cmy, axis=2)
denominator = np.maximum(1.0 - black, 1e-6)
cyan = (cmy[..., 0] - black) / denominator
magenta = (cmy[..., 1] - black) / denominator
yellow = (cmy[..., 2] - black) / denominator
fully_black = black >= 1.0 - 1e-6
cyan[fully_black] = 0.0
magenta[fully_black] = 0.0
yellow[fully_black] = 0.03. HSI 彩色模型
RGB 适合生成和存储颜色,却把色相、饱和程度与总体数值尺度耦合在三个基色坐标中。HSI 将三者重新参数化为色相 、饱和度 与强度 ,便于分别讨论这些属性;这种分解是计算模型,不等同于完整的人类颜色感知模型。
色相 是颜色在色轮上的角度,饱和度 表示颜色偏离灰轴的相对程度,强度 是三个 RGB 分量的算术平均。对归一化 RGB,
色相来自 RGB 向量绕灰轴的方向。令
则
当 时,像素位于灰轴上,色相没有定义;程序中通常把 置零,同时通过 表明该角度不应被解释。接近灰轴时,极小噪声也可能使色相剧烈变化,所以不能在低饱和区域盲目增强色相。
这里的 是否能解释为物理光强,取决于输入 RGB 是否已经线性化。若直接把带有 sRGB 非线性编码的数值代入公式, 只是编码值的平均,不是辐射度或光度学亮度;用于定量成像前必须先说明数据的传递函数。

def rgb_to_hsi(rgb_u8: np.ndarray) -> np.ndarray:
rgb = rgb_u8.astype(np.float64) / 255.0
red, green, blue = cv2.split(rgb)
intensity = (red + green + blue) / 3.0
total = red + green + blue
saturation = np.zeros_like(intensity)
non_black = total > 1e-12
saturation[non_black] = 1.0 - (
3.0 * np.minimum.reduce([red, green, blue])[non_black]
/ total[non_black]
)
numerator = 0.5 * ((red - green) + (red - blue))
denominator = np.sqrt(
(red - green) ** 2
+ (red - blue) * (green - blue)
)
cosine = numerator / np.maximum(denominator, 1e-12)
theta = np.arccos(np.clip(cosine, -1.0, 1.0))
hue = np.where(blue <= green, theta, 2.0 * np.pi - theta)
hue[denominator <= 1e-12] = 0.0
return np.dstack([hue, saturation, intensity])cv2.COLOR_BGR2HSV 不能代替上述 HSI 公式。4. 设备无关彩色模型
设备 RGB 的坐标由具体基色和传递函数决定。为了在相机、显示器、打印机之间传递颜色,需要先进入以标准观察者实验为基础的 CIE XYZ。XYZ 的 分量与亮度相关,且能通过线性矩阵承接线性 RGB;但 XYZ 中的欧氏距离并不近似等于视觉色差,于是又有非线性变换得到 CIELAB。[2]
对三刺激值 ,令
以 D65 白点为例,线性 sRGB 到 XYZ 的常用矩阵为
这里必须先去除 sRGB 的非线性编码。直接把 8 位 sRGB 数值乘矩阵会把编码值误当成光强。
CIELAB 以参考白点 归一化:
其中 ,
表示明度, 的正负方向大致对应红与绿, 的正负方向大致对应黄与蓝。CIELAB 被设计为近似感知均匀空间,基础色差可写为 ,但高精度应用还会使用 CIEDE2000 等改进公式。

def srgb_to_xyz(rgb_u8: np.ndarray) -> np.ndarray:
encoded = rgb_u8.astype(np.float64) / 255.0
linear = np.where(
encoded <= 0.04045,
encoded / 12.92,
((encoded + 0.055) / 1.055) ** 2.4,
)
matrix = np.array(
[
[0.4124564, 0.3575761, 0.1804375],
[0.2126729, 0.7151522, 0.0721750],
[0.0193339, 0.1191920, 0.9503041],
]
)
return linear @ matrix.T
def xyz_to_lab(xyz: np.ndarray) -> np.ndarray:
white_d65 = np.array([0.95047, 1.0, 1.08883])
ratio = xyz / white_d65
delta = 6.0 / 29.0
transformed = np.where(
ratio > delta ** 3,
np.cbrt(ratio),
ratio / (3.0 * delta ** 2) + 4.0 / 29.0,
)
fx, fy, fz = cv2.split(transformed)
return np.dstack(
[
116.0 * fy - 16.0,
500.0 * (fx - fy),
200.0 * (fy - fz),
]
)二、彩色变换
彩色变换可分为两类。第一类只改变坐标表达而尽量保持视觉颜色,例如 RGB 转 XYZ;第二类有意改变颜色,例如白平衡、色调调整或直方图增强。二者的判据是“是否要求颜色外观在变换前后保持不变”,而不是“是否改动了数值”——即便是坐标转换,若忽略编码函数或参考白点,数值虽变、外观却未必保持。无论哪一类,都必须明确输入的编码范围、参考白点和通道顺序,否则公式正确也可能得到错误结果。
1. 色彩空间的转换
若两个空间都是线性三刺激空间,转换可写成逐像素矩阵乘法 。若空间含有伽马编码、分段函数或柱坐标,则转换通常由“解码—线性变换—非线性重编码”组成。以 sRGB 到 CIELAB 为例,完整顺序是:
- 把 8 位整数除以 255,得到 编码值。
- 逆 sRGB 传递函数,恢复线性 RGB。
- 依据 sRGB 基色和 D65 白点乘 矩阵,得到 XYZ。
- 以同一参考白点归一化 XYZ,施加 CIELAB 分段非线性函数。
逆变换必须按相反次序执行,并在最终量化前裁剪。中间步骤尽量使用浮点数;反复在 8 位空间之间转换会积累舍入和裁剪误差。OpenCV 的 cvtColor 支持大量常见转换,但不同数据类型对应的数值范围不同,例如 8 位 HSV 的色相常编码到 ,因此使用前必须查明约定。[3]
rgb_u8 = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2RGB)
# 浮点 Lab 转换应先归一化到 [0, 1]。
rgb_f32 = rgb_u8.astype(np.float32) / 255.0
lab_f32 = cv2.cvtColor(rgb_f32, cv2.COLOR_RGB2LAB)
# 变回 RGB 后才量化,避免中间步骤反复损失精度。
reconstructed = cv2.cvtColor(lab_f32, cv2.COLOR_LAB2RGB)
reconstructed_u8 = np.clip(
np.rint(reconstructed * 255.0),
0,
255,
).astype(np.uint8)2. 色轮与补色

色相是圆周量,不能用普通算术平均处理。例如 与 的平均方向应接近 ,而不是 。稳妥做法是把角度转成单位向量 ,先平均向量再用 atan2 求方向。补色也不能脱离用途:为了屏幕反相可以使用 ;为了设计配色可旋转感知色相;为了印刷套色则应通过 CMYK 与设备特性文件计算。
def circular_mean(hue_radians: np.ndarray) -> float:
x = np.cos(hue_radians).mean()
y = np.sin(hue_radians).mean()
return float(np.mod(np.arctan2(y, x), 2.0 * np.pi))
def hsv_complement(image_bgr: np.ndarray) -> np.ndarray:
hsv = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2HSV)
result = hsv.copy()
# 8 位 OpenCV HSV 以 0..179 表示完整色相圆周。
result[..., 0] = (result[..., 0].astype(np.int16) + 90) % 180
return cv2.cvtColor(result, cv2.COLOR_HSV2BGR)3. 色调、彩色校正与彩色平衡
a. 色调与通道变换
最一般的逐像素彩色映射写作
若每个输出只依赖同名输入,便是逐通道曲线;若输出同时依赖多个通道,则可完成颜色旋转、串扰补偿或色盲模拟。曝光式亮度调整应尽量在线性光空间进行,而用于显示外观的 gamma 曲线可以在归一化编码值上实现 。 提亮暗部, 压暗,但裁剪后的高光和阴影信息无法由反向 gamma 恢复。
b. 白平衡与彩色平衡
白平衡试图让场景中本应中性的物体满足 。若图像有可靠的白色或灰色参照,可测量该区域三通道均值并缩放到共同目标。没有参照时,灰度世界假设整幅场景的平均反射近似无彩色:
其中 是通道均值, 是三者平均。该方法对颜色分布丰富的普通场景有效,但在整幅图本来就由单一颜色主导时会过度校正。
彩色平衡比白平衡更广,它还可以分别调整阴影、中间调和高光中的通道比例。实际工作流应先修正白点和曝光,再做审美性色调;否则后续调整会掩盖光源偏色,难以维持中性色。

def gamma_correct(image: np.ndarray, gamma: float) -> np.ndarray:
source = image.astype(np.float32) / 255.0
corrected = source ** gamma
return np.clip(255.0 * corrected, 0, 255).astype(np.uint8)
def gray_world_balance(image_bgr: np.ndarray) -> np.ndarray:
source = image_bgr.astype(np.float32)
means = source.reshape(-1, 3).mean(axis=0)
target = means.mean()
gains = target / np.maximum(means, 1e-6)
balanced = source * gains[None, None, :]
return np.clip(balanced, 0, 255).astype(np.uint8)
def reference_patch_balance(
image_bgr: np.ndarray,
reference_mask: np.ndarray,
) -> np.ndarray:
source = image_bgr.astype(np.float32)
reference = source[reference_mask].mean(axis=0)
target = reference.max()
gains = target / np.maximum(reference, 1e-6)
balanced = source * gains[None, None, :]
return np.clip(balanced, 0, 255).astype(np.uint8)4. 直方图处理
彩色图像不能简单地把灰度均衡化算法独立用于三个通道。三个 RGB 通道的累积分布通常不同,分别均衡化会为它们建立三条不同映射;原本中性的 可能被映射成三个不同数值,从而产生假色。更稳妥的思路是转换到亮度与色度分离的空间,只处理明度分量,再保留色度分量。
以 CIELAB 为例:
- 将 BGR 转换为 Lab,分离 。
- 只对 做全局均衡化或 CLAHE,得到 。
- 将 与原来的 合并。
- 转回 BGR,并检查高饱和区域是否因色域边界发生裁剪。
全局均衡化可能把局部噪声一并增强;CLAHE 先在小块中限制直方图峰值,再对相邻块映射插值,适合照明不均的彩色照片。无论使用哪种方法,“只改亮度”也不意味着视觉色相绝对不变,因为转换回有限 RGB 色域时仍可能裁剪。[4]

def equalize_lab_luminance(image_bgr: np.ndarray) -> np.ndarray:
lab = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2LAB)
luminance, a_channel, b_channel = cv2.split(lab)
equalized = cv2.equalizeHist(luminance)
merged = cv2.merge([equalized, a_channel, b_channel])
return cv2.cvtColor(merged, cv2.COLOR_LAB2BGR)
def clahe_lab_luminance(
image_bgr: np.ndarray,
clip_limit: float = 2.0,
tile_size: int = 8,
) -> np.ndarray:
lab = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2LAB)
luminance, a_channel, b_channel = cv2.split(lab)
clahe = cv2.createCLAHE(
clipLimit=clip_limit,
tileGridSize=(tile_size, tile_size),
)
enhanced = clahe.apply(luminance)
merged = cv2.merge([enhanced, a_channel, b_channel])
return cv2.cvtColor(merged, cv2.COLOR_LAB2BGR)三、假彩色
假彩色与“伪造颜色”不是同义词。温度、深度、海拔、压力、医学扫描强度本来就是标量,把它们映射到颜色能利用人眼的色相分辨能力显示更多层次。但颜色映射同时也是编码规则:没有色标、范围和单位,读者无法由颜色恢复数值含义;同一幅热图若改变归一化上下限,也会呈现完全不同的视觉结论。
1. 强度分层
强度分层把灰度轴划分为若干区间,并为每个区间指定一种颜色。若阈值为 ,颜色表为 ,则
它的优势是区间边界清楚,适合土地等级、组织类别或风险分级;缺点是在阈值处产生不连续颜色边界,可能把连续变化误读成真实轮廓。阈值应来自任务含义或数据分布,而不是只为画面鲜艳。
def intensity_slicing(
gray: np.ndarray,
thresholds: np.ndarray,
colors_bgr: np.ndarray,
) -> np.ndarray:
if len(colors_bgr) != len(thresholds) + 1:
raise ValueError("颜色数量必须比阈值数量多 1")
indices = np.digitize(gray, thresholds, right=False)
return colors_bgr[indices]
thresholds = np.array([43, 86, 128, 171, 214])
colors = np.array(
[
[70, 25, 15],
[155, 70, 30],
[205, 175, 45],
[65, 190, 90],
[40, 215, 235],
[245, 245, 245],
],
dtype=np.uint8,
)
colored = intensity_slicing(gray, thresholds, colors)2. 连续灰度到彩色变换
连续映射为每个灰度 指定颜色向量
最简单的实现是长度为 256 的查找表;三个通道的曲线可以由若干控制点分段线性插值。与强度分层相比,连续映射保留平滑变化,适合显示连续物理量。
颜色映射的选择影响读数。亮度单调的色图使数值增加时视觉明暗也大体单调,更适合定量数据;周期性色图适合方向角或相位;发散色图适合围绕零点的正负偏差。传统彩虹色图在黄色和青色附近的感知变化不均匀,还可能制造并不存在的边界,不应仅因“颜色多”而默认采用。

def continuous_colormap(
gray: np.ndarray,
control_colors_bgr: np.ndarray,
) -> np.ndarray:
positions = np.linspace(0.0, 255.0, len(control_colors_bgr))
samples = np.arange(256, dtype=np.float64)
lookup = np.empty((256, 3), dtype=np.uint8)
for channel in range(3):
lookup[:, channel] = np.clip(
np.interp(
samples,
positions,
control_colors_bgr[:, channel],
),
0,
255,
).astype(np.uint8)
return lookup[gray]
# OpenCV 内置色图也是查找表映射。
turbo = cv2.applyColorMap(gray, cv2.COLORMAP_TURBO)
inferno = cv2.applyColorMap(gray, cv2.COLORMAP_INFERNO)References
- [1] R. C. Gonzalez and R. E. Woods, Digital Image Processing, 4th ed. Pearson, 2018.
- [2] International Commission on Illumination, “CIE 1976 L*a*b* Colour Space.” Accessed: Aug. 27, 2026. [Online]. Available: https://cie.co.at/eilv/157
- [3] OpenCV, “Color Space Conversions.” Accessed: Aug. 27, 2026. [Online]. Available: https://docs.opencv.org/4.12.0/d8/d01/group__imgproc__color__conversions.html
- [4] OpenCV, “Histogram Equalization.” Accessed: Aug. 27, 2026. [Online]. Available: https://docs.opencv.org/4.x/d4/d1b/tutorial_histogram_equalization.html
Illustrated by