图像基础:像素、通道与色彩空间


文档摘要

图像基础:像素、通道与色彩空间 本节摘要:一张图像就是一组光强采样的张量(Tensor)。你将来要用的每一个视觉模型,都从这个事实出发。本节把后续 27 节都赖以建立的「地基」打牢——像素到底是不是一个方块、为什么每个像素是三个数而不是一个、HWC 与 CHW 这两种布局各自为谁服务、「按 ImageNet 统计量归一化」到底做了什么、RGB/HSV/YCbCr/灰度各自存在的理由。读完本节,你能用 NumPy 切片、检查任何形状的张量,在四种色彩空间之间自由切换,并产出预训练模型所期望的那种「标准化浮点张量」,而不再因为少了某一步归一化让指标悄悄崩盘。 对应原课程:Phase 4 · Lesson 01 · (原英文 )。

图像基础:像素、通道与色彩空间

本节摘要:一张图像就是一组光强采样的张量(Tensor)。你将来要用的每一个视觉模型,都从这个事实出发。本节把后续 27 节都赖以建立的「地基」打牢——像素到底是不是一个方块、为什么每个像素是三个数而不是一个、HWC 与 CHW 这两种布局各自为谁服务、「按 ImageNet 统计量归一化」到底做了什么、RGB/HSV/YCbCr/灰度各自存在的理由。读完本节,你能用 NumPy 切片、检查任何形状的张量,在四种色彩空间之间自由切换,并产出预训练模型所期望的那种「标准化浮点张量」,而不再因为少了某一步归一化让指标悄悄崩盘。

对应原课程:Phase 4 · Lesson 01 · image-fundamentals(原英文 phases/04-computer-vision/01-image-fundamentals/docs/en.md)。

学习目标

阅读完本节,你应当能够:

  1. 说清一个连续场景如何被采样与量化为像素,以及这两步为何决定了所有下游模型的上限。
  2. 用 NumPy 数组读取、切片、检查图像,并在 HWC 与 CHW 布局之间熟练切换。
  3. RGB、灰度、HSV、YCbCr 之间互相转换,并解释每个色彩空间存在的理由。
  4. torchvision 期望的方式完成像素级预处理(归一化、标准化、缩放、通道优先)。

一、问题与直觉

你将要读的每一篇论文、下载的每一份预训练权重、调用的每一个视觉 API,都假设输入采用某种特定编码。把 uint8 图像喂给期望 float32 的模型,程序照样能跑——然后悄无声息地输出垃圾。把 BGR 喂给在 RGB 上训练的网络,准确率掉十个百分点。给一个期望通道优先(channel-first)的模型喂通道后置(channel-last)的数据,第一层卷积会把「高度」当成特征通道。这些都不会报错,只会毁掉你的指标,然后你花一周去追一个藏在「文件怎么加载」里的 bug。

卷积本身并不复杂,难的是搞清楚「一张图像」在相机、JPEG 解码器、PIL、OpenCV、torchvision 和一个 CUDA kernel 眼里是不同的东西。每个软件栈都有自己的轴顺序、字节范围与通道约定。一个分不清这些的视觉工程师,交付的必然是坏掉的流水线。

本节把地基修好,让后续章节可以放心往上盖。读完本节,你会知道像素是什么、为什么是三个数、「按 ImageNet 统计量归一化」到底做了什么,以及如何在后面每一节都会用到的两三种布局之间自由穿行。

完整预处理流水线一览

每一个生产级视觉系统都是同一串可逆变换。其中一步错了,模型看到的输入就和训练时完全不同。

红蓝两个方框,藏着 80% 的静默失败:漏了标准化布局搞反

像素是采样,不是方块

相机传感器统计落在一片微小探测器网格上的光子。每个探测器在零点几秒内累积光,输出一个与击中它的光子数成正比的电压;随后传感器把这个电压离散化成一个整数。一个探测器变成一个像素。

连续场景 传感器网格 数字图像 (无穷细节) (H x W 探测器) (H x W 整数) ~~~~~ +--+--+--+--+--+ 210 198 180 155 120 ~ ~ ~ | | | | | | 205 195 178 152 118 ~ light ~ ----> +--+--+--+--+--+ ----> 200 190 175 150 115 ~~~~~ | | | | | | 195 185 170 148 112 +--+--+--+--+--+ 188 180 165 145 108

这一步做了两个选择,它们锁死了后续一切的上限:

  • 空间采样 决定每度场景有多少个探测器。太少,边缘出现锯齿(混叠 aliasing);太多,存储与算力爆炸。
  • 亮度量化 决定电压被分成多细的桶。8 位给出 256 级,是显示标准;10、12、16 位给出更平滑的梯度,对医学影像、HDR 和原始传感器流水线很重要。

像素不是一块带颜色的方块,而是一个单次测量。当你缩放或旋转时,你是在对这个测量网格重采样

为什么是三个通道

一个探测器统计整个可见光谱上的光子——这就是灰度。要得到颜色,传感器在网格上盖一层红、绿、蓝滤镜的马赛克。去马赛克(demosaicing)之后,每个空间位置都有三个整数:附近的红滤镜、绿滤镜、蓝滤镜探测器的响应。这三个整数就是该像素的 RGB 三元组。

内存中的一个像素: (R, G, B) = (210, 140, 30) <- 偏红橙色 一张 H x W 的 RGB 图像: shape (H, W, 3) 存储为 H 行 × W 像素 × 3 个值 uint8 时每个值在 [0, 255]

「三」不是魔法。深度相机多一个 Z 通道;卫星多红外、紫外波段;医学扫描常常只有一通道(X 光、CT)或很多通道(高光谱)。通道数是最后一个轴,卷积层会学习在它上面混合。

两种布局约定:HWC 与 CHW

同一个张量,两种顺序,每个库都自己挑一种。

HWC(高、宽、通道) CHW(通道、高、宽) W -> H -> +-----+-----+-----+ +-----+-----+ H |R G B|R G B|R G B| C |R R R R R R| | +-----+-----+-----+ | +-----+-----+ v |R G B|R G B|R G B| v |G G G G G G| +-----+-----+-----+ +-----+-----+ |B B B B B B| +-----+-----+ PIL、OpenCV、matplotlib, PyTorch、多数深度学习框架、 以及磁盘上几乎每一种图像格式 cuDNN kernel

CHW 的存在是因为卷积核要在 H 和 W 上滑动。把通道轴放在最前,每个核在每个通道上看到的就是一块连续的二维平面,向量化得干净利落。磁盘格式用 HWC,是因为它和传感器读出的扫描行顺序一致。

你会打一千遍的那行转换:

img_chw = img_hwc.transpose(2, 0, 1) # NumPy img_chw = img_hwc.permute(2, 0, 1) # PyTorch 张量

内存布局的直观图:

字节范围与 dtype

主流的是三种约定:

约定 dtype 范围 出现场景
原始 uint8 [0, 255] 磁盘文件、PIL、OpenCV 输出
归一化 float32 [0.0, 1.0] img.astype('float32') / 255 之后
标准化 float32 约为 [-2, +2] 减均值、除标准差之后

卷积网络是在标准化输入上训练的。ImageNet 统计量 mean=[0.485, 0.456, 0.406]std=[0.229, 0.224, 0.225] 是在 [0, 1] 归一化像素上、对整个 ImageNet 训练集三通道算出的均值和标准差。把原始 uint8 喂给期望标准化浮点的模型,是应用视觉里最常见的一种静默失败

色彩空间及其存在理由

RGB 是采集格式,但不一定是对模型最有用的表示。

RGB HSV YCbCr / YUV R 红 H 色相(角度 0-360) Y 亮度 G 绿 S 饱和度(0-1) Cb 蓝黄色度 B 蓝 V 明度/亮度(0-1) Cr 红绿色度 与传感器输出 把颜色和亮度分开。 把亮度和颜色分开。 呈线性。 适合颜色阈值化、 JPEG 和多数视频编解码 UI 滑块、简单滤波 把色度通道压得更狠, 因为人眼对色度细节 比对亮度 Y 更不敏感。

对大多数现代 CNN 你喂 RGB。其他空间在以下场合登场:

  • HSV —— 经典 CV 代码、基于颜色的分割、白平衡。
  • YCbCr —— 读 JPEG 内部、视频流水线、只在 Y 上操作的超分辨率模型。
  • 灰度 —— OCR、文档模型,以及任何颜色是干扰变量而非信号的场合。

从 RGB 到灰度是加权求和,不是平均,因为人眼对绿比对红或蓝更敏感:

Y = 0.299 R + 0.587 G + 0.114 B (ITU-R BT.601,经典系数)

长宽比、缩放与插值

每个模型都有固定输入尺寸(大多数 ImageNet 分类器是 224×224,现代检测器是 384×384 或 512×512)。你的图像很少正好匹配。三种值得关心的缩放选择:

  • 缩放短边,再中心裁剪 —— 标准 ImageNet 配方。保留长宽比,扔掉一圈边缘像素。
  • 缩放并填充 —— 保留长宽比和每一个像素,加黑边。检测和 OCR 的标准做法。
  • 直接缩放到目标 —— 拉伸图像。便宜,扭曲几何,对很多分类任务够用。

新网格与旧网格不对齐时,插值方法决定中间像素怎么算:

最近邻 最快、块状、掩码/标签的唯一选择 双线性 快、平滑、多数图像缩放的默认 双三次 较慢、放大时更锐利 Lanczos 最慢、质量最好、用于最终展示

经验法则:训练用双线性,要看的素材用双三次或 Lanczos,含整数类别 ID 的东西一律用最近邻

二、从零实现

步骤 1:加载图像并检查形状

用 Pillow 加载任意 JPEG 或 PNG,转成 NumPy,打印出来。为了一个能离线跑的确定性示例,我们合成一张。

import numpy as np from PIL import Image def synthetic_rgb(h=128, w=192, seed=0): rng = np.random.default_rng(seed) yy, xx = np.meshgrid(np.linspace(0, 1, h), np.linspace(0, 1, w), indexing="ij") r = (np.sin(xx * 6) * 0.5 + 0.5) * 255 g = yy * 255 b = (1 - yy) * xx * 255 rgb = np.stack([r, g, b], axis=-1) + rng.normal(0, 6, (h, w, 3)) return np.clip(rgb, 0, 255).astype(np.uint8) arr = synthetic_rgb() # 也可以从磁盘加载: # arr = np.asarray(Image.open("your_image.jpg").convert("RGB")) print(f"type: {type(arr).__name__}") print(f"dtype: {arr.dtype}") print(f"shape: {arr.shape} # (H, W, C)") print(f"min: {arr.min()}") print(f"max: {arr.max()}") print(f"pixel at (0, 0): {arr[0, 0]}")

预期输出:shape: (H, W, 3)dtype: uint8、范围 [0, 255]。无论字节来自相机、JPEG 解码器还是合成生成器,这都是规范的磁盘表示。

步骤 2:拆分通道并重排布局

把 R、G、B 分别取出,再从 HWC 转成 CHW 给 PyTorch。

R = arr[:, :, 0] G = arr[:, :, 1] B = arr[:, :, 2] print(f"R shape: {R.shape}, mean: {R.mean():.1f}") print(f"G shape: {G.shape}, mean: {G.mean():.1f}") print(f"B shape: {B.shape}, mean: {B.mean():.1f}") arr_chw = arr.transpose(2, 0, 1) print(f"\nHWC shape: {arr.shape}") print(f"CHW shape: {arr_chw.shape}")

三个灰度平面,每通道一个。CHW 只是重排了轴;在内存布局允许时,严格来说不需要拷贝数据。

步骤 3:灰度与 HSV 转换

加权求和的灰度,再手写一个 RGB→HSV。

def rgb_to_grayscale(rgb): weights = np.array([0.299, 0.587, 0.114], dtype=np.float32) return (rgb.astype(np.float32) @ weights).astype(np.uint8) def rgb_to_hsv(rgb): rgb_f = rgb.astype(np.float32) / 255.0 r, g, b = rgb_f[..., 0], rgb_f[..., 1], rgb_f[..., 2] cmax = np.max(rgb_f, axis=-1) cmin = np.min(rgb_f, axis=-1) delta = cmax - cmin h = np.zeros_like(cmax) mask = delta > 0 rmax = mask & (cmax == r) gmax = mask & (cmax == g) bmax = mask & (cmax == b) h[rmax] = ((g[rmax] - b[rmax]) / delta[rmax]) % 6 h[gmax] = ((b[gmax] - r[gmax]) / delta[gmax]) + 2 h[bmax] = ((r[bmax] - g[bmax]) / delta[bmax]) + 4 h = h * 60.0 s = np.where(cmax > 0, delta / cmax, 0) v = cmax return np.stack([h, s, v], axis=-1) gray = rgb_to_grayscale(arr) hsv = rgb_to_hsv(arr) print(f"gray shape: {gray.shape}, range: [{gray.min()}, {gray.max()}]") print(f"hsv shape: {hsv.shape}") print(f"hue range: [{hsv[..., 0].min():.1f}, {hsv[..., 0].max():.1f}] degrees") print(f"sat range: [{hsv[..., 1].min():.2f}, {hsv[..., 1].max():.2f}]") print(f"val range: [{hsv[..., 2].min():.2f}, {hsv[..., 2].max():.2f}]")

色相以角度给出,饱和度和明度在 [0, 1]。这与 OpenCV 的 hsv_full 约定一致。

步骤 4:归一化、标准化,再还原

从原始字节走到预训练 ImageNet 模型期望的张量,再走回来。

mean = np.array([0.485, 0.456, 0.406], dtype=np.float32) std = np.array([0.229, 0.224, 0.225], dtype=np.float32) def preprocess_imagenet(rgb_uint8): x = rgb_uint8.astype(np.float32) / 255.0 x = (x - mean) / std x = x.transpose(2, 0, 1) return x def deprocess_imagenet(chw_float32): x = chw_float32.transpose(1, 2, 0) x = x * std + mean x = np.clip(x * 255.0, 0, 255).astype(np.uint8) return x x = preprocess_imagenet(arr) print(f"preprocessed shape: {x.shape} # (C, H, W)") print(f"preprocessed dtype: {x.dtype}") print(f"preprocessed mean per channel: {x.mean(axis=(1, 2)).round(3)}") print(f"preprocessed std per channel: {x.std(axis=(1, 2)).round(3)}") roundtrip = deprocess_imagenet(x) max_diff = np.abs(roundtrip.astype(int) - arr.astype(int)).max() print(f"roundtrip max pixel diff: {max_diff} # 应为 0 或 1")

每通道均值应接近零,标准差接近一。这对 preprocess/deprocess 函数,正是 torchvision 每一次 transforms.Normalize 调用在背后做的事。

步骤 5:用三种插值方法缩放

在放大场景下对比最近邻、双线性、双三次,差异才看得见。

target = (arr.shape[0] * 3, arr.shape[1] * 3) nearest = np.asarray(Image.fromarray(arr).resize(target[::-1], Image.NEAREST)) bilinear = np.asarray(Image.fromarray(arr).resize(target[::-1], Image.BILINEAR)) bicubic = np.asarray(Image.fromarray(arr).resize(target[::-1], Image.BICUBIC)) def local_roughness(x): gy = np.diff(x.astype(float), axis=0) gx = np.diff(x.astype(float), axis=1) return float(np.abs(gy).mean() + np.abs(gx).mean()) for name, out in [("nearest", nearest), ("bilinear", bilinear), ("bicubic", bicubic)]: print(f"{name:>8} shape={out.shape} roughness={local_roughness(out):6.2f}")

最近邻的粗糙度最高,因为它保留硬边缘;双线性最平滑;双三次居中,既保留主观锐度又不产生阶梯伪影。

三、框架对比

torchvision.transforms 把上面这一切打包成一条可组合的流水线。下面的代码完全复刻preprocess_imagenet 的行为,再加上缩放与裁剪。

import torch from torchvision import transforms from PIL import Image img = Image.fromarray(synthetic_rgb(256, 256)) pipeline = transforms.Compose([ transforms.Resize(256), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ]) x = pipeline(img) print(f"tensor type: {type(x).__name__}") print(f"tensor dtype: {x.dtype}") print(f"tensor shape: {tuple(x.shape)} # (C, H, W)") print(f"per-channel mean: {x.mean(dim=(1, 2)).tolist()}") print(f"per-channel std: {x.std(dim=(1, 2)).tolist()}") batch = x.unsqueeze(0) print(f"\nbatched shape: {tuple(batch.shape)} # (N, C, H, W) — 可以送进模型了")

四步,严格按此顺序:Resize(256) 把短边缩到 256;CenterCrop(224) 从中间取 224×224;ToTensor() 除以 255 并把 HWC 换成 CHW;Normalize 减 ImageNet 均值、除标准差。把顺序反过来,模型收到的就悄悄变了样

四、可复用产物

本节产出两个可复用文件(位于原课程 outputs/):

  • prompt-vision-preprocessing-audit.md:一个提示词,把任何模型卡或数据集卡转成一份清单,列出团队必须遵守的精确预处理不变量。
  • skill-image-tensor-inspector.md:一个技能——给定任何图像形状的张量或数组,报告其 dtype、布局、范围,并判断它看起来是原始、归一化还是标准化数据。

五、练习

  1. (简单) 用 OpenCV(cv2.imread)和 Pillow 分别加载一张 JPEG。打印两者的形状和 (0, 0) 处的像素。解释通道顺序的差异,再写一行转换,让 OpenCV 数组与 Pillow 数组完全一致。
  2. (中等) 实现 standardize(img, mean, std) 及其逆函数,使两者组合后能通过 roundtrip_max_diff <= 1 的测试(对任意 uint8 图像)。你的函数必须既能作用于单张 HWC 图像,也能作用于 NCHW 批次,且调用方式相同。
  3. (困难) 取一个 3 通道、按 ImageNet 标准化的张量,送进一个 1×1 卷积,让它学习把 RGB 按权重混合成单个灰度通道。把权重初始化为 [0.299, 0.587, 0.114] 并冻结,验证输出与你手写的 rgb_to_grayscale 在浮点误差内一致。还有哪些经典色彩空间变换可以写成 1×1 卷积?

本节要点回顾

  1. 图像是光强采样的张量——你将来用的每个视觉模型都从这里出发;地基不稳,后续全崩。
  2. 像素是采样,不是方块——空间采样与亮度量化两步,锁死了下游所有模型的上限。
  3. 三通道不神秘——RGB 是马赛克滤镜的去马赛克结果;通道数是最后一个轴,卷积层在上面混合。
  4. 两种布局——HWC(PIL/OpenCV/磁盘)与 CHW(PyTorch/cuDNN),一行 transpose(2, 0, 1) 切换。
  5. 三种字节范围——uint8 [0,255]、归一化 float32 [0,1]、标准化 float32 ≈[-2,+2];喂错是头号静默失败。
  6. 色彩空间各有用处——RGB 给现代 CNN;HSV 做颜色阈值;YCbCr 用于 JPEG/视频;灰度用于 OCR/文档。
  7. 灰度是加权求和——0.299R + 0.587G + 0.114B,不是平均,因为人眼对绿更敏感。
  8. 缩放三选择——缩放短边+中心裁剪(分类)、缩放+填充(检测/OCR)、直接拉伸(图省事);插值:训练双线性,展示双三次/Lanczos,标签最近邻。
  9. 标准化 = 减均值除标准差——ImageNet 统计量是全训练集的均值方差,顺序错了就毁指标。
  10. torchvision 四步——Resize→CenterCrop→ToTensor→Normalize,顺序不可颠倒。

下一节,我们将从零实现卷积——理解一个核在 H×W 平面上滑动时到底做了什么,以及为什么这一操作会成为整个深度视觉的基石。


发布者: 作者: Rohit Gupta 转发
评论区 (0)
U