4. 坏点校正的工程实现:基于OpenCV的检测与校正代码

好,前面我们把坏点的原理、分类、统计特性都聊透了。这一章,咱们直接动手——用OpenCV把坏点检测和校正的代码写出来。

我个人习惯,学任何ISP算法,都先用Python+OpenCV快速搭一个原型。为什么?因为调试方便,能立刻看到中间结果。等算法验证通过了,再移植到C++或者硬件描述语言。你想想看,要是直接在C++里调参,编译一次半分钟,那效率太低了。

本章目标:实现一个完整的坏点检测与校正Pipeline,输入一张RAW图(或模拟的RAW图),输出校正后的图像。代码可运行、可调参、可可视化中间结果。

4.1 整体流程设计

先画一张流程图,把我们要做的事情理清楚。我每次写代码前都会画这么一张图,哪怕只是草稿。这能帮你避免写到一半发现逻辑漏洞。

坏点检测与校正流程图 输入RAW图像 Bayer通道分离 坏点检测(阈值法) 生成坏点掩膜 (Mask) 坏点校正(中值替换) 输出校正后图像 关键:检测和校正是分离的——先标记坏点位置,再只对这些位置做校正,不破坏好像素

4.2 模拟一张带坏点的RAW图

真实项目中,我们拿到的RAW图可能已经经过了一些预处理。但为了验证算法,我习惯先自己造一张带坏点的测试图。这样坏点的位置、强度我都清楚,调试起来心里有底。

import numpy as np
import cv2
import matplotlib.pyplot as plt

def generate_test_raw(height=512, width=512, pattern='RGGB'):
    """
    生成一张模拟的Bayer RAW图(12bit,值域0-4095)
    并手动注入一些坏点
    """
    # 生成平滑渐变背景
    x = np.linspace(0, 1, width)
    y = np.linspace(0, 1, height)
    X, Y = np.meshgrid(x, y)
    raw = (1000 + 2000 * X + 500 * Y).astype(np.uint16)  # 平滑渐变

    # 注入坏点:死点(值=0)和亮点(值=4000)
    np.random.seed(42)
    dead_pixels = [(100, 100), (200, 300), (350, 150)]
    hot_pixels  = [(50, 400), (250, 50), (400, 350)]

    for y, x in dead_pixels:
        raw[y, x] = 0
    for y, x in hot_pixels:
        raw[y, x] = 4000

    # 再随机加一些噪声坏点(概率0.1%)
    mask = np.random.random(raw.shape) < 0.001
    raw[mask] = np.random.choice([0, 4095], size=np.sum(mask))

    return raw

# 生成测试图
raw_img = generate_test_raw()
print(f"RAW shape: {raw_img.shape}, dtype: {raw_img.dtype}")
print(f"Value range: {raw_img.min()} - {raw_img.max()}")

小技巧:我刚开始做坏点检测时,直接用真实sensor的RAW图调试,结果死活调不对参数。后来发现是sensor本身有固定模式噪声(FPN)干扰了检测。所以建议你先用模拟图验证算法逻辑,再用真实图调参。

4.3 坏点检测核心函数

检测的思路其实很简单:对每个像素,看它和周围邻居的差异是否超过阈值。如果差异太大,就标记为坏点。

但这里有个坑——Bayer格式下,同通道的像素才有可比性。你不能拿R像素和G像素比,因为它们的响应曲线本来就不一样。所以必须先做通道分离。

def detect_bad_pixels(bayer_img, threshold=500, pattern='RGGB'):
    """
    基于局部邻域比较的坏点检测
    bayer_img: 输入的Bayer RAW图 (H x W)
    threshold: 判定阈值,超过此值认为可能是坏点
    pattern: Bayer模式,默认RGGB
    返回: 坏点掩膜 (H x W),坏点位置为255
    """
    h, w = bayer_img.shape
    mask = np.zeros((h, w), dtype=np.uint8)

    # 根据Bayer模式确定每个像素的通道
    # RGGB: (0,0)=R, (0,1)=G, (1,0)=G, (1,1)=B
    channel_map = np.zeros((h, w), dtype=np.uint8)
    for i in range(2):
        for j in range(2):
            if pattern == 'RGGB':
                if i == 0 and j == 0:
                    ch = 0  # R
                elif i == 0 and j == 1:
                    ch = 1  # G
                elif i == 1 and j == 0:
                    ch = 1  # G
                else:
                    ch = 2  # B
            # 其他pattern类似,这里省略
            channel_map[i::2, j::2] = ch

    # 遍历每个像素,只和同通道的邻居比较
    for y in range(2, h-2):
        for x in range(2, w-2):
            center_val = int(bayer_img[y, x])
            ch = channel_map[y, x]

            # 提取同通道的3x3邻域(实际是隔一个像素取)
            neighbors = []
            for dy in [-2, 0, 2]:
                for dx in [-2, 0, 2]:
                    if dy == 0 and dx == 0:
                        continue
                    ny, nx = y + dy, x + dx
                    if 0 <= ny < h and 0 <= nx < w:
                        if channel_map[ny, nx] == ch:
                            neighbors.append(int(bayer_img[ny, nx]))

            if len(neighbors) < 4:
                continue

            # 计算中值和偏差
            median_val = np.median(neighbors)
            diff = abs(center_val - median_val)

            if diff > threshold:
                mask[y, x] = 255

    return mask

注意:上面的代码为了清晰,用了双重for循环。实际工程中一定要向量化或用C++实现,否则处理一张1200万像素的图要好几秒。我在项目中第一次跑这个代码时,等了10秒才出结果,当时就意识到必须优化。

4.4 坏点校正:中值替换

检测到坏点后,校正就相对简单了。最常用的方法是中值替换——用同通道邻居的中值替代坏点值。为什么用中值而不是均值?因为中值对异常值更鲁棒,不会因为邻域里还有另一个坏点而被带偏。

def correct_bad_pixels(bayer_img, mask, pattern='RGGB'):
    """
    坏点校正:用同通道邻域中值替换坏点
    bayer_img: 输入RAW图
    mask: 坏点掩膜(255表示坏点)
    返回: 校正后的RAW图
    """
    h, w = bayer_img.shape
    corrected = bayer_img.copy().astype(np.uint16)

    # 同样需要通道映射
    channel_map = np.zeros((h, w), dtype=np.uint8)
    for i in range(2):
        for j in range(2):
            if pattern == 'RGGB':
                if i == 0 and j == 0:
                    ch = 0
                elif i == 0 and j == 1:
                    ch = 1
                elif i == 1 and j == 0:
                    ch = 1
                else:
                    ch = 2
            channel_map[i::2, j::2] = ch

    # 只对坏点位置做校正
    bad_y, bad_x = np.where(mask == 255)
    for y, x in zip(bad_y, bad_x):
        ch = channel_map[y, x]

        # 收集同通道邻居
        neighbors = []
        for dy in [-2, 0, 2]:
            for dx in [-2, 0, 2]:
                if dy == 0 and dx == 0:
                    continue
                ny, nx = y + dy, x + dx
                if 0 <= ny < h and 0 <= nx < w:
                    if channel_map[ny, nx] == ch:
                        # 确保邻居本身不是坏点
                        if mask[ny, nx] == 0:
                            neighbors.append(int(bayer_img[ny, nx]))

        if len(neighbors) > 0:
            corrected[y, x] = np.median(neighbors).astype(np.uint16)
        # 如果邻居全是坏点(极端情况),扩大搜索范围
        # 实际项目中这种情况极少,这里先不处理

    return corrected

4.5 完整Pipeline与可视化

把检测和校正串起来,再加上可视化,方便我们观察效果。

def bad_pixel_pipeline(raw_img, threshold=500, pattern='RGGB'):
    """
    坏点校正完整Pipeline
    返回: 校正后图像, 坏点掩膜, 检测到的坏点数量
    """
    # 检测
    mask = detect_bad_pixels(raw_img, threshold, pattern)
    num_bad = np.sum(mask == 255)

    # 校正
    corrected = correct_bad_pixels(raw_img, mask, pattern)

    return corrected, mask, num_bad

# 运行Pipeline
corrected_img, bad_mask, num_bad = bad_pixel_pipeline(raw_img, threshold=500)
print(f"检测到坏点数量: {num_bad}")

# 可视化
plt.figure(figsize=(15, 5))

plt.subplot(1, 3, 1)
plt.title('原始RAW图(含坏点)')
plt.imshow(raw_img, cmap='gray', vmin=0, vmax=4095)
plt.colorbar()

plt.subplot(1, 3, 2)
plt.title(f'坏点掩膜 (共{num_bad}个)')
plt.imshow(bad_mask, cmap='hot')
plt.colorbar()

plt.subplot(1, 3, 3)
plt.title('校正后RAW图')
plt.imshow(corrected_img, cmap='gray', vmin=0, vmax=4095)
plt.colorbar()

plt.tight_layout()
plt.show()

4.6 参数调优与避坑指南

阈值 threshold 是这个算法最重要的参数。设小了会把好像素误判为坏点(假阳性),设大了会漏掉真正的坏点(假阴性)。

阈值设置 效果 适用场景
100-300 非常敏感,容易误检 高信噪比场景,sensor噪声极低
300-800 平衡检测率和误检率 大多数消费级sensor
800-2000 只检测明显坏点 低端sensor或暗光场景

我曾经踩过的坑:有一次在项目中,我用了固定阈值500,结果在暗光场景下把大量正常像素误判为坏点。后来才发现,暗光下sensor的噪声水平会升高,固定阈值根本扛不住。解决方案是让阈值随亮度自适应——暗区用大阈值,亮区用小阈值。这个我们后面章节会详细讲。

4.7 性能优化方向

上面给的代码是教学版,清晰但慢。实际工程中,至少要做这几项优化:

  • 向量化:用NumPy的滑动窗口或卷积操作替代for循环
  • 边界处理:图像边缘的像素可以跳过检测,或者用镜像填充
  • 多级检测:先粗检(大阈值),再在疑似区域细检(小阈值)
  • 查表法:如果sensor的坏点位置是固定的(出厂标定),直接查表比实时检测快得多

我的建议:如果你是在做产品原型,先用上面的代码跑通流程。等算法稳定了,再花一天时间做向量化优化。不要一开始就追求极致性能——算法逻辑都没验证对,优化再快也没用。