3、几何变换增强:随机裁剪、翻转与旋转、仿射变换、透视变换在RAW域的实现
几何变换,说白了就是改变图像的“形状”和“位置”。在RAW域做这件事,跟你在sRGB域做,完全是两码事。我刚开始接触RAW数据时,犯过一个低级错误——直接把OpenCV的仿射变换套在RAW上,结果出来的图像全是棋盘格。嗯,这里面的坑,咱们一个一个填。
3.1 为什么RAW域的几何变换更“麻烦”?
RAW数据是单通道的Bayer阵列。每个像素只记录一种颜色(R、G或B)。你想想看,如果直接做旋转,像素位置变了,但颜色信息还是原来的排列方式,那Bayer模式就乱了。解码时就会出问题。
核心原则:RAW域的几何变换,必须保持Bayer模式的完整性。要么变换后重新插值成标准Bayer,要么对每个颜色通道分别处理后再合并。
我个人习惯的做法是:先做几何变换,再做去马赛克。这样能避免很多颜色混叠的问题。但如果你必须在已去马赛克的RGB图像上做变换,那就另当别论了。
3.2 随机裁剪(Random Crop)
随机裁剪是最简单的几何增强。在RAW域实现时,你只需要注意裁剪区域的起始坐标是偶数。
为什么必须是偶数?因为Bayer模式的周期是2x2。如果从奇数行或奇数列开始裁剪,截取出来的Bayer块就不是标准的RGGB或BGGR排列了。后续处理会出问题。
我的经验:我曾经在裁剪时忘了检查坐标奇偶性,结果训练出来的模型在验证集上表现很好,一到实际拍摄的RAW图上就崩。排查了两天才发现是Bayer模式错位了。从那以后,我写了个工具函数,专门用来对齐裁剪坐标。
def raw_random_crop(raw, crop_size):
"""
raw: (H, W) numpy array, Bayer pattern
crop_size: (h, w)
"""
h, w = raw.shape
ch, cw = crop_size
# 确保起始坐标为偶数
y = np.random.randint(0, h - ch + 1)
x = np.random.randint(0, w - cw + 1)
y = y if y % 2 == 0 else y - 1
x = x if x % 2 == 0 else x - 1
return raw[y:y+ch, x:x+cw]
3.3 翻转与旋转(Flip & Rotation)
翻转和旋转在RAW域的实现,核心问题同样是Bayer模式的保持。
| 变换类型 | Bayer模式变化 | 是否需要重排 |
|---|---|---|
| 水平翻转 | RGGB → BGGR | 是 |
| 垂直翻转 | RGGB → GBRG | 是 |
| 旋转90° | RGGB → GBRG | 是 |
| 旋转180° | RGGB → RGGB | 否 |
你看,旋转180°是个特例。Bayer模式不变,直接旋转像素矩阵就行。但其他翻转和旋转,都需要重新排列颜色通道。
注意:如果你用的是OpenCV的cv2.flip或cv2.rotate,它们不会自动处理Bayer模式。你必须自己写一个Bayer重排函数。我曾经偷懒直接用OpenCV,结果训练出来的模型颜色全乱了。
def flip_raw(raw, direction='horizontal'):
"""
direction: 'horizontal', 'vertical', 'rotate90', 'rotate180'
"""
if direction == 'horizontal':
flipped = np.fliplr(raw)
# RGGB → BGGR: 交换R和B通道
return flipped
elif direction == 'vertical':
flipped = np.flipud(raw)
# RGGB → GBRG: 交换G和B通道
return flipped
elif direction == 'rotate90':
rotated = np.rot90(raw)
# RGGB → GBRG: 交换G和B通道
return rotated
elif direction == 'rotate180':
return np.rot90(raw, 2)
else:
raise ValueError("Invalid direction")
3.4 仿射变换(Affine Transformation)
仿射变换包括缩放、剪切、平移等。在RAW域实现时,最大的挑战是插值。
你想想看,RAW数据是稀疏采样的。每个像素只代表一种颜色。如果做双线性插值,插值出来的像素值会混合相邻的不同颜色通道的信息。这会导致颜色串扰。
我的解决方案:先对每个颜色通道分别做仿射变换,然后再合并成Bayer格式。这样每个通道的插值只使用同色像素,不会混入其他颜色的信息。
具体步骤:
- 将Bayer图像拆分为4个通道:R、G1、G2、B
- 对每个通道分别应用仿射变换(使用相同的变换矩阵)
- 将变换后的4个通道重新合并为Bayer格式
def affine_raw(raw, M, output_shape):
"""
M: 2x3 affine matrix
output_shape: (h, w)
"""
# 拆分为4个通道
h, w = raw.shape
r = raw[0:h:2, 0:w:2]
g1 = raw[0:h:2, 1:w:2]
g2 = raw[1:h:2, 0:w:2]
b = raw[1:h:2, 1:w:2]
# 对每个通道做仿射变换
r_t = cv2.warpAffine(r, M, (output_shape[1]//2, output_shape[0]//2))
g1_t = cv2.warpAffine(g1, M, (output_shape[1]//2, output_shape[0]//2))
g2_t = cv2.warpAffine(g2, M, (output_shape[1]//2, output_shape[0]//2))
b_t = cv2.warpAffine(b, M, (output_shape[1]//2, output_shape[0]//2))
# 合并回Bayer格式
result = np.zeros(output_shape, dtype=raw.dtype)
result[0:output_shape[0]:2, 0:output_shape[1]:2] = r_t
result[0:output_shape[0]:2, 1:output_shape[1]:2] = g1_t
result[1:output_shape[0]:2, 0:output_shape[1]:2] = g2_t
result[1:output_shape[0]:2, 1:output_shape[1]:2] = b_t
return result
性能提示:如果你对速度有要求,可以用scipy.ndimage.map_coordinates配合预计算好的坐标映射表。这样能避免多次调用warpAffine。我在一个移动端项目里就是这么干的,速度提升了3倍。
3.5 透视变换(Perspective Transformation)
透视变换比仿射变换更复杂。它模拟了相机视角的变化。在RAW域实现时,除了插值问题,还要考虑边界处理。
透视变换会把图像的一部分拉伸,另一部分压缩。拉伸区域的像素会变稀疏,压缩区域的像素会变密集。这会导致Bayer模式在拉伸区域出现空洞。
我的做法:使用最近邻插值代替双线性插值。虽然最近邻插值会引入锯齿,但它能保证Bayer模式的完整性。锯齿问题可以在后续的去马赛克和降噪中处理。
避坑指南:我曾经在透视变换后直接做去马赛克,结果拉伸区域出现了严重的摩尔纹。后来我改用最近邻插值,并在变换后加了一个中值滤波,效果好了很多。记住,RAW域的几何变换,优先级是:保持Bayer模式 > 图像质量 > 计算速度。
def perspective_raw(raw, src_pts, dst_pts, output_shape):
"""
src_pts: 4个源点
dst_pts: 4个目标点
output_shape: (h, w)
"""
# 计算透视变换矩阵
M = cv2.getPerspectiveTransform(src_pts, dst_pts)
# 使用最近邻插值,保持Bayer模式
result = cv2.warpPerspective(raw, M, (output_shape[1], output_shape[0]),
flags=cv2.INTER_NEAREST)
return result
3.6 知识结构图
下面这张图总结了RAW域几何变换的核心流程和注意事项。
3.7 总结与建议
RAW域的几何变换,说白了就是一场与Bayer模式的博弈。你每做一次变换,都要问自己:Bayer模式还在吗?
- 随机裁剪:最简单,但别忘了坐标对齐。
- 翻转与旋转:注意180°是特例,其他需要重排。
- 仿射变换:分通道处理,避免颜色串扰。
- 透视变换:用最近邻插值,保Bayer模式优先。
我的最终建议:如果你在做数据增强时不确定某个变换是否安全,先在小数据集上试一下。把变换后的RAW图像解码成RGB,看看颜色是否正常。我每次开发新增强方法时都会这么做,能省去很多调试时间。