RAW显影流程:从传感器到屏幕
前面的章节介绍了光学成像、曝光控制与传感器。这一节继续追问:传感器记录的数字,怎样变成屏幕上的颜色?
本文以 ARW 数字图像处理教程为线索。rawpy 负责解码文件,NumPy 用来展示像素运算的原理。代码片段按处理环节组织,重点是说明各个数组代表什么、为什么要进行下一步,而不是复刻相机厂商的完整显影算法。
本文配图来自实际 ARW 运算
所有场景图均由 DSC00178.ARW 实际处理生成。原始画幅为 7168×5120,有效区域为 7008×4672;白平衡、去马赛克和颜色变换在完整有效区域上计算,配图才缩小。
全套 13 张 WebP 合计约 748 KB,单张约 29~80 KB,最长边不超过 1440 像素。图片与本文位于同一目录,均带有 sRGB 配置;图片内用英文标注,中文说明放在图下。
中间数据不一定可以直接显示:图注会注明显示增益、开平方拉伸或 sRGB 编码。去马赛克小图是局部放大示意,其他场景图保留完整原始画幅或完整有效画幅。
先分清四种“RGB”
同样写成三个数字,并不意味着它们表示相同的颜色。
| 数据 | 含义 | 能否直接当普通图片显示 |
|---|---|---|
| Bayer RAW | 每个位置只有一种滤光通道的传感器读数 | 可以看采样强度,尚无完整彩色像素 |
| 相机 RGB | 去马赛克得到的三个相机响应通道 | 不应直接当作标准 sRGB |
| 线性 sRGB | 已转换到 sRGB 原色定义下、尚未编码的线性值 | 直接交给普通图片显示接口通常显得偏暗 |
| 编码后的 sRGB | 经 sRGB 传递函数编码、用于保存与交换的数值 | 可以按 sRGB 图片解释 |
白平衡、颜色空间转换、色调映射、显示编码是不同的操作。 它们虽然都能改变最终画面的观感,却不能互相替代。
本文的教学流程为:
实际显影器可能调整部分步骤的顺序,还会加入坏点处理、降噪、镜头校正、锐化和高光重建。
传感器记录的不是“颜色名称”
光谱经过滤光片,变成通道响应
设物体到达相机的光谱为
其中
因此,同一个物体在不同光源下会产生不同的通道读数。不同相机使用不同的响应函数,也不一定记录出相同的 RGB 数值。
R、G、B 滤光片的响应范围有重叠,它们不是只接收某一个波长的三个“理想开关”。单个像素的读数可以简化为:
其中
为什么需要 XYZ
人眼的颜色感知也依赖对光谱的积分。CIE XYZ 用标准观察者的配色函数表示三刺激值:
相机响应与标准观察者的配色函数并不相同,所以相机 RGB 需要经过标定和变换,才能与显示颜色建立联系。一个
ARW 解码与 Bayer 排列
解码不等于显影
ARW 是文件容器与编码格式。解码后得到的马赛克数组,并不是已经具有正常色彩和对比度的照片。
from pathlib import Path
import numpy as np
import rawpy
path = Path("data/DSC00178.ARW")
with rawpy.imread(str(path)) as raw:
mosaic = raw.raw_image_visible.copy()
channel_id = raw.raw_colors_visible.copy()
pattern = raw.raw_pattern.copy()
colors = raw.color_desc.decode("ascii", errors="replace")
blacks = np.asarray(raw.black_level_per_channel, dtype=np.float32)
white = float(raw.white_level)
wb = np.asarray(raw.camera_whitebalance, dtype=np.float32)
cam_xyz_coefficients = raw.rgb_xyz_matrix.copy()
sizes = raw.sizes这里保留 channel_id,让每个像素都能对应到其黑电平和白平衡通道。数组在关闭 RAW 对象后仍要使用,因此先复制;raw_image_visible 已经去掉一层边缘,但不应据此认定它等于最终输出的全部有效画幅。rawpy:RawPy 接口

图 1|解码后的 RAW。 左图按白电平归一化后直接灰度显示;右图对同一数据开平方,仅便于看清结构。两侧仍保留外围边缘,并未做彩色显影。
一个像素位置通常只有一个通道
教程中这张 Bayer 图像的索引与颜色关系为:
两个 G 是不同的采样位置,在元数据中可能有独立的校正参数。四个 CFA 索引并不意味着最终图片有四个显示颜色通道。
用字符 R/G/B 做可视化很直观;执行黑电平、白平衡等校正时,保留数值索引更可靠。
为什么原始图会显示成彩色,或者 CFA 图全红
二维数组传给 imshow 时,默认颜色映射可能产生伪彩色。观察强度应显式指定:
plt.imshow(mosaic, cmap="gray", vmin=0, vmax=white)另一个陷阱是直接显示:
mosaic[::4, ::4]因为 CFA 周期是
需要区分两种预览目的:
- 观察 CFA 结构:显示一个小区域,明确画出每个滤光位置。奇数间隔虽能保留交替相位,但缩到屏幕上仍可能产生混叠。
- 观察整张 RAW 的强度结构:先对区域求平均再缩小,避免固定抽取某一种 CFA 相位。
下面只生成预览,不改变处理用的全分辨率数据:
def block_mean_preview(a, factor=4):
h, w = a.shape
hh, ww = h // factor * factor, w // factor * factor
blocks = a[:hh, :ww].astype(np.float32)
return blocks.reshape(hh // factor, factor,
ww // factor, factor).mean(axis=(1, 3))
plt.imshow(block_mean_preview(mosaic), cmap="gray")这种平均只是采样强度示意,并不是经过标定的亮度,也不等于去马赛克。

图 2|CFA 与抽样混叠。 左边是文件的实际 RGGB 相位;中间每隔 4 个像素只取到 R;右边每隔 5 个像素保留交替相位。这是滤光位置示意,不是场景本身的颜色。
黑电平、白电平与有效区域
黑电平是数值基线
对位于通道
原教程用 maximum(..., 0) 把负值截为零,便于学习与显示。更精细的统计处理可能先保留这些负值,因为噪声会让测量值落在基线两侧,过早截零会改变暗部统计。
一定先转浮点数,否则无符号整数相减可能发生下溢。
black_map = blacks[channel_id]
linear = mosaic.astype(np.float32) - black_map
linear = np.maximum(linear, 0.0) # Teaching simplification
图 3|黑电平校正。 两侧使用相同的开平方显示曲线。减去 512 DN 后,暗部基线下降,但边缘不会因此消失;左右分别使用校正前后的归一化分母。
白电平定义可用的数值尺度
归一化应使用与通道一致的分母:
如果文件只采用一个共同白电平
denominator = np.maximum(white - black_map, 1.0)
raw01 = linear / denominator原代码使用 white - black_tile.max()。当所有黑电平相同,例如都为 512 时,这个简化没有差别;若各通道黑电平不同,应改为逐位置分母。若有可靠的逐通道白电平,也应按相同索引匹配。
例如
这表示相对于当前归一化尺度的线性信号,不是“屏幕亮度 4.3%”,也不直接等于物体反射率。
减黑电平不会移除黑边
黑电平校正改变的是数值;裁切改变的是坐标和数组范围。
文件中的 RAW 边缘、可见区域、建议裁切区域不一定完全相同,边缘也不一定全部是光学黑像素。教程里的 crop_top_margin 等字段只能在明确坐标原点后使用,不能把同一组偏移无条件套到所有处理阶段。
裁切时,应让以下数组使用同一组切片:
# row_slice and col_slice must be expressed in the current array coordinates.
mosaic = mosaic[row_slice, col_slice]
linear = linear[row_slice, col_slice]
channel_id = channel_id[row_slice, col_slice]如果还单独维护周期图案,新的相位为:
奇数偏移会改变 RGGB 等排列的相位。直接同步裁切逐像素 channel_id,可以减少相位错位和布尔掩码尺寸不一致的问题。

图 4|保留有效成像区域。 左图用粉色框标出有效区;右图保留其完整范围。此次裁切原点为左侧 12、顶部 8,输出尺寸 7008×4672,没有为构图任意截取局部。
白平衡:先让中性物体保持中性
白平衡是通道增益
白平衡可以写成对角矩阵:
它调整的是通道之间的相对比例,并不是把整张照片变成灰色。
若这张文件的白平衡系数为
对当前 RGBG 通道定义,可以保留两个绿色索引的独立系数:
green_ids = [i for i, label in enumerate(colors) if label == "G"]
green_ref = np.mean(wb[green_ids])
gains_by_id = wb / green_ref
balanced = raw01 * gains_by_id[channel_id]这段假定所需白平衡系数均有效且为正。换相机、换文件时,应先检查通道定义与元数据,不能永远写死 [R,G0,B,G1]。

图 5|白平衡的作用。 左图未加白平衡,右图使用文件系数。为观察颜色,每个 2×2 CFA 单元先组合成一个 RGB 预览点,两侧统一使用 2 倍显示增益和 sRGB 编码。这只是降采样示意,尚未应用相机颜色矩阵。
除以最大值,为什么不是偏色的根源
把所有增益都除以同一个正数,只会改变整体尺度:
两组增益之间只差一个共同倍率,不会单独改变 R/G、B/G 的比值。在后续都保持线性、没有裁剪的前提下,它影响整体明暗,不会凭空导致偏绿。
真正需要排查的是通道顺序、重复白平衡、提前裁剪和颜色矩阵。
白平衡后为什么允许超过 1
若线性红色值为 0.7,乘以 2.16 后约为 1.51。它仍是合法的中间计算结果;此时立即截为 1,会破坏高光的通道比例。
因此,教程在白平衡后保留大于 1 的值是合理的。最终怎样压缩或裁剪,应留到曝光、色调映射和输出阶段处理。
去马赛克:估计缺少的颜色
去马赛克要把每个位置的单通道采样,重建为三个通道:
设
分子汇总邻域内同色采样值,分母统计实际参与的权重,避免把“缺失”当成数值零来平均。最后将原本已有的采样位置恢复为原始值。
numerator = conv2(balanced * mask, kernel)
denominator = conv2(mask.astype(np.float32), kernel)
plane = np.divide(
numerator, denominator,
out=np.zeros_like(numerator),
where=denominator > 0,
)
plane[mask] = balanced[mask]对规则 Bayer 排列,
例如,在红色像素位置估计绿色:
估计蓝色时则使用四个对角方向的蓝色采样。算法容易理解,但在细线、纹理和高反差边缘可能产生伪色、锯齿与拉链状边缘。
去马赛克后看起来有颜色,不代表已经处在正确的显示颜色空间。 此时仍然是相机 RGB。

图 6|缺失通道的重建。 左图把未采到的颜色通道置零,右图填入双线性估计。这里选取同一处 32×32 边缘细节,用相同显示倍率放大;实际去马赛克运算覆盖整张有效图像。
颜色矩阵:给 RGB 数值一个明确的意义
标准 RGB 空间需要定义原色与白点
RGB 数值只有配合原色、白点和传递函数,才能明确表示什么颜色。线性 sRGB 使用以下常见的 D65 RGB 到 XYZ 矩阵:
反方向使用其逆矩阵。矩阵系数与向量约定必须一起看。 若列向量形式为
y = x @ M.T这只是行列向量的换写,不能据此猜测任何一个库返回的矩阵是否已经转置、是否需要先求逆。
教程中的 rgb_xyz_matrix 到底怎样使用
原教程第 8 节的文字描述是“直接把相机 RGB 乘矩阵得到 XYZ”,但实际代码采用了组合、逐行归一化、求伪逆的另一条路径,两者不能混为一谈。
rawpy 给出的矩阵形状为
针对本教程的三通道情况,按 LibRaw cam_xyz_coeff 的基础构造过程理解:组合系数后归一化各相机通道,再反求输出变换,而不是把原始系数直接当成最终相机 RGB → XYZ 矩阵。LibRaw:cam_xyz_coeff 实现
令读出的前三行系数为
在这个构造约定中,
因此,对图像最后一维为 RGB 的 NumPy 数组:
A = cam_xyz_coefficients[:3, :].astype(np.float64)
M_srgb_to_xyz = np.array([
[0.4124564, 0.3575761, 0.1804375],
[0.2126729, 0.7151522, 0.0721750],
[0.0193339, 0.1191920, 0.9503041],
])
C = A @ M_srgb_to_xyz
row_sum = C.sum(axis=1, keepdims=True)
if np.any(row_sum <= 1e-8):
raise ValueError("Invalid camera matrix normalization")
C_normalized = C / row_sum
M_camera_to_srgb = np.linalg.pinv(C_normalized)
linear_srgb = rgb @ M_camera_to_srgb.T这里的 rgb 是完成白平衡和去马赛克的相机 RGB。这个命名比原代码中的 srgb_to_camera = pinv(...) 更清楚:求逆之后,方向已经变成相机通道到输出通道。
本段只解释教程所采用的三通道基础近似,并不等价于完整 LibRaw 显影。白平衡尺度、相机标定、不同光源下的配置与其他校正仍可能造成差异。第四行不为零、通道模型不同或矩阵缺失时,不能直接照搬前三行处理。

图 7|颜色变换。 两侧统一使用 2 倍显示增益和 sRGB 编码,只比较相机颜色变换前后。左图把相机通道暂当显示 RGB,右图已经完成目标颜色变换;中性区域接近,但彩色区域的饱和度与色相会改变。
白平衡与白点适应也不相同
白平衡在相机通道上调增益;色度适应则是在明确源白点、目标白点和颜色空间后进行的转换。通道已经中性,并不足以证明某个未知矩阵已经满足目标白点条件。
同样,画面偏绿也不能直接推出“缺少 Bradford 适应”。应先核对矩阵方向、白平衡是否重复、通道索引与输入尺度,再决定是否需要白点适应。
为什么矩阵输出会有负数
颜色变换后的负值可能来自噪声、标定误差或颜色超出目标 RGB 空间可表示的范围,不一定说明代码立即出错。
原教程在变换后直接截掉负数,是一种显示简化。做分析时可先保留浮点结果;进入教学用色调映射之前再显式处理负值,并知道这不等于完整的色域映射。
曝光和色调映射
计算中的曝光倍率
线性曝光调整为:
所以教程中的 exposure=4.0 相当于对已取得的数据增加两档线性倍率。它不等同于拍摄时真正多收集两档光子,也不能恢复已饱和的细节。

图 8|线性曝光倍率。 右图为左图的 4 倍线性信号,即 +2 EV。两侧都编码为 sRGB,但尚未色调映射;超出显示范围的高光会被直接裁白。
Reinhard 曲线压缩高亮
教程使用:
当
对 RGB 三个通道分别应用这条非线性曲线,会改变颜色比例。 例如:
R/G 的比例从 2 变成
一种教学上的替代做法,是先压缩线性 sRGB 的亮度,再对三个通道使用同一个倍率:
同一像素的 RGB 比例可在后续裁剪之前保留,但结果仍可能超出输出范围,也不代表完成了感知均匀的色域映射。

图 9|色调映射。 曝光均为 4 倍;左边直接裁剪显示,右边先执行逐通道 Reinhard,再编码为 sRGB。高光得到压缩,但这条逐通道曲线也会影响颜色比例。
sRGB 编码:为什么线性图直接显示很暗
普通图片查看流程通常按编码后的数值解释图像。如果把线性值 0.18 直接作为编码值显示,它不会表达预期的线性强度。
sRGB 的正向编码是分段函数:
它不是简单的
def linear_to_srgb(x):
x = np.clip(x, 0.0, 1.0)
return np.where(
x <= 0.0031308,
12.92 * x,
1.055 * np.power(x, 1 / 2.4) - 0.055,
)例如:
显示端按相应传递关系还原后,才对应目标线性强度。它不是通过“伽马”额外创造曝光。
如果把已经编码过的 sRGB 再编码一次,会产生另一种错误。反过来,需要在线性域进行运算时,可使用逆函数:

图 10|显示编码。 左边把色调映射后的线性数值直接交给普通图像显示;右边先做标准 sRGB 编码。两侧曝光、白平衡与色调映射相同,仅输出编码不同。
一段完整的教学输出过程
# linear_srgb: floating-point result of the camera color transform
x = np.maximum(linear_srgb, 0.0) * 4.0
tone = x / (1.0 + x) # Per-channel teaching curve; may change color ratios
encoded_srgb = linear_to_srgb(tone)原教程按 99.5 百分位缩放中间预览,是为了便于观察,并不是标准曝光标定。若每一步都分别自动缩放,跨阶段比较明暗时要格外注意:显示倍率已经变化了。

图 11|两种曲线负责不同的工作。 左边压缩高动态范围,右边规定线性强度怎样编码。右侧虚线是恒等映射,用于说明“线性值直接显示”与正确编码的差别。
保存图片:数值与颜色描述要配套
8 位量化可以写成:
from PIL import Image
pixels = np.uint8(np.clip(encoded_srgb, 0, 1) * 255 + 0.5)
Image.fromarray(pixels).save("arw_preview.png")但数组写成 PNG,并不自动意味着文件已经携带正确的 ICC 配置。生产输出应同时明确颜色空间标记;屏幕显示还取决于查看软件、系统颜色管理和显示器配置。
因此,比较两张照片时至少要确认:它们采用了相同的颜色解释、曝光与显示条件。

图 12|手写流程结果。 完整有效画幅经过白平衡、双线性去马赛克、颜色变换、4 倍曝光、Reinhard 和 sRGB 编码。这里使用压缩 WebP 展示;并不是把原始 ARW 改成了低分辨率。
怎样与 rawpy 参考结果比较
原教程的参考处理很适合做观感对照,但并不是严格控制了所有变量的实验。
尤其要注意:output_color=sRGB 和输出传递曲线不是同一个参数。rawpy 文档给出的默认 gamma 为 (2.222, 4.5),标为 BT.709,而不是上面手写的标准 sRGB 分段编码。rawpy:处理参数
为了把“颜色空间”和“编码曲线”分开观察,可以请求线性参考输出,再手动应用相同的 sRGB 编码:
with rawpy.imread(str(path)) as raw:
reference_u16 = raw.postprocess(
use_camera_wb=True,
output_color=rawpy.ColorSpace.sRGB,
output_bps=16,
gamma=(1, 1),
no_auto_bright=True,
user_flip=0,
)
reference_linear = reference_u16.astype(np.float32) / 65535.0
reference_display = linear_to_srgb(reference_linear)这仍然包含 LibRaw 的内部尺度、高光处理等步骤,且 16 位整数输出不能保留任意大的线性值;它不是无损的浮点相机原始数据。比较时应把双方的曝光和色调曲线进一步对齐。
还要避免两种常见误判:
- 重复裁切:
postprocess的输出尺寸、方向或裁切可能与原始马赛克不同。不要不加判断地再套一次 RAW 阶段的偏移。 - 把系统预览当作唯一真值:查看软件可能使用 RAW 内嵌 JPEG,也可能调用自己的显影引擎;相机风格曲线与色彩配置都会影响观感。
要尽量接近某个软件,必须先明确它实际显示的是哪一种结果,再对齐白平衡、曝光、曲线、颜色配置与输出编码。单靠一条“伽马校正”无法保证一致。

图 13|与 LibRaw 对照。 参考分支关闭自动亮度、请求线性 16 位 sRGB 输出,并保持未旋转方向;根据实际输出尺寸对齐有效区域。两侧统一应用 4 倍倍率、Reinhard 和标准 sRGB 编码。LibRaw 内部尺度仍可能不同,因此这是可解释的观感对照,不是逐像素完全等价的验证。
现象与排查顺序
| 现象 | 优先检查 |
|---|---|
| RAW 强度图带蓝绿色 | 是否用了默认伪彩色映射,而非 cmap="gray" |
| CFA 图全红或全绿 | 是否用偶数步长抽样,只保留一种 CFA 相位 |
| 裁切后布尔索引报错 | 马赛克、CFA 索引、黑电平图是否同步裁切 |
| 去马赛克后整体很暗 | 是否仍是线性相机 RGB;显示范围是否合理 |
| 颜色矩阵后突然偏绿、偏红 | 通道顺序、矩阵实际方向、求逆和转置、白平衡是否重复 |
| 白平衡后高光变色 | 是否过早把大于 1 的通道裁掉 |
| 减黑电平后仍有黑边 | 是否混淆数值基线与有效区域裁切 |
| 色调映射后颜色变淡 | 是否逐通道使用非线性曲线,改变了通道比例 |
| 导出图与参考图不同 | 曝光、自动亮度、色调曲线、传递函数和色彩管理是否一致 |
一个有用的调试习惯,是给中间数组明确命名:raw_dn、black_corrected、camera_rgb、linear_srgb、encoded_srgb。每一步都记录形状、数据类型、范围和通道定义,而不是只凭“看起来像照片”判断是否正确。
回顾
从传感器到屏幕,至少要回答五个不同的问题:
- 采到了什么? CFA 位置决定每个读数来自哪个通道。
- 基线和尺度是什么? 黑电平、白电平与有效区域使数据具备正确起点。
- 光源和缺失采样怎样处理? 白平衡调整中性关系,去马赛克重建完整通道。
- 这些数值代表什么颜色? 相机标定和颜色变换建立与标准颜色空间的联系。
- 怎样输出到屏幕? 曝光、色调映射、sRGB 编码、量化和颜色管理共同决定最终呈现。
理解这五层关系后,偏色、过暗、黑边和预览差异就能被拆成具体问题,而不必反复凭感觉调整矩阵和增益。