返回列表

27th place solution - Only Pre&Postprocessing

686. PhysioNet - Digitization of ECG Images | physionet-ecg-image-digitization

开始: 2025-10-21 结束: 2026-01-22 医学影像分析 数据算法赛
第 27 名解决方案 - 仅预处理和后处理

第 27 名解决方案 - 仅预处理和后处理

副标题:基于出色的公开作品,添加额外的预处理和后处理

作者: BanhMiMatOng (及 collaborator: Wu Cris, Van Dong Tran)

发布时间: 2026-01-28

竞赛排名: 第 27 名

致谢

首先,非常感谢 PhysioNet 和 Kaggle 组织了这场充满挑战且意义重大的比赛。

我们还要向分享高质量公开基线和见解的社区成员表示诚挚的感谢。特别是 非常感谢 @hengck23, @wasupandceacar 以及其他人的慷慨公开分享,为我们提供了极其坚实的基础。

最后,祝贺获胜的团队 —— 他们的解决方案令人鼓舞且非常有启发性 :).

附注:我们将在清理后公开 notebook 代码。

概述

我们的最终解决方案在私有榜单上取得了 19.59 dB 的信噪比 (SNR),排名第 27 位

总体而言,我们的 pipeline 遵循标准且经得起验证的结构:

标准化 / 校正 → 分割 → 后处理

  • 阶段 0 和阶段 1 (标准化 / 校正)
    直接取自 hengck23 的 pipeline,这仍然是 ECG 图像对齐和网格校正最强健的方法之一。

  • 阶段 2 (分割)
    基于 wasupandceacar 发布的 U-Net 风格分割模型,作为像素级 ECG 波形提取的非常强的基线。

在这些基线之上,我们的主要贡献包括:

  • 改进了阶段 0 之前的预处理
  • 新的像素到序列 (pixel-to-series) 后处理函数
  • 针对异常值和噪声的额外保护措施
  • 后处理应用的学习型信号 refinement 模型

改进阶段 0 之前的预处理

虽然 hengck23 的阶段 0 和阶段 1 已经很强,但我们观察到输入图像质量(对比度、照明、污渍、阴影)会显著影响下游校正的质量。

@sanpier 的 notebook Visual QA for all stages of ECG digitization 启发,我们采用了双路径策略

  • 运行 阶段 0 → 阶段 1 without preprocessing (无预处理)
  • 运行 阶段 0 → 阶段 1 with preprocessing (有预处理)
  • 比较 阶段 1 的质量指标
  • 为阶段 2 选择更好的输出
    仅这一点就将我们的公开榜单分数提高了~0.3 dB

感知图像类型的预处理

我们进一步观察到:

  • 公开图像类型分类器并不总是可靠
  • 不同的 ECG 图像来源以非常不同的方式退化

因此,我们训练了自己的图像类型分类器,并设计了一个安全的、门控的预处理函数,它:

  • 从不无条件地应用破坏性操作
  • 使用照明和对比度作为安全门控
  • 仅应用 mild 的、特定于来源的调整

这一改进提供了另外 ~0.3 dB 的提升(总共 0.6dB)。
代码示例:

def preprocess_by_source(img_bgr, pred_src):
    gray = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2GRAY)
    std0 = float(gray.std())
    illum = illumination_strength(img_bgr, sigma=35)

    # 通用安全修复
    if illum > 0.14:
        img_bgr = bg_correct_lab_l(img_bgr)

    if std0 < 30:
        img_bgr = clahe_luminance_bgr(img_bgr, clip=1.15, tile=8)

    if s in ["0003", "0011"]:  # 彩色扫描 / 模具彩色扫描
        x = grayworld_white_balance(x)
        if float(cv2.cvtColor(x, cv2.COLOR_BGR2GRAY).std()) < 35:
            x = clahe_luminance_bgr(x, clip=1.2, tile=8)

    elif s in ["0006"]:  # 屏幕照片
        x = denoise_bilateral(x, d=5, sigmaColor=25, sigmaSpace=25)
        if float(cv2.cvtColor(x, cv2.COLOR_BGR2GRAY).std()) < 35:
            x = clahe_luminance_bgr(x, clip=1.2, tile=8)

    elif s in ["0005", "0009", "0010"]:  # 移动打印 / 污渍 / 损坏
        if illum > 0.14:
            x = denoise_median(x, k=3)
        elif std0 < 35:
            x = denoise_bilateral(x, d=5, sigmaColor=25, sigmaSpace=25)

        # 除非对比度真的很低,否则不要对 0009 (污渍) 应用强 CLAHE
        if s == "0009":
            if float(cv2.cvtColor(x, cv2.COLOR_BGR2GRAY).std()) < 25:
                x = clahe_luminance_bgr(x, clip=1.1, tile=8)

    elif s in ["0004", "0012", "0001"]:
        pass

    return img_bgr

更多细节和完整代码可在我们的公开 notebook 中找到。

后处理:像素 → 序列

我们最大的单一增益(与基线相比 + ~1.3 dB)来自于重新设计像素到序列的转换。

基线方法通常:

  • 每列取 argmax
  • 用固定基线替换低置信度列

我们发现这通常会引入量化噪声、突然跳跃和人工平坦区域。相反,我们构建了一个更稳定的转换函数,包含三个关键想法:

1) 二次子像素细化 (Quadratic sub-pixel refinement)
对于每一列,我们首先取硬 argmax y₀,然后使用 (y₀−1, y₀, y₀+1) 周围的 3 点二次(抛物线)拟合对其进行细化。这提供了子像素精度,并减少了恢复波形中的阶梯伪影。

2) 通过 NaN + 插值控制间隙处理
没有前景(或置信度非常低)的列被标记为缺失 (NaN),而不是被替换为常数基线。然后我们使用相邻列在这些间隙上插值提取的 y 路径及其置信度。这保持了连续性,避免了引入人工平台。

3) 像素空间中的置信度感知去尖峰 (Confidence-aware despiking)
插值后,我们应用一个基于中值滤波器的小型“去尖峰”步骤,仅在模型置信度低时去除尖锐异常值。这有助于去除由噪声/网格伪影引起的随机跳跃,而不会抑制真实的 ECG 峰值(例如 QRS 波群)。

最后,清理后的子像素轨迹使用已知的 zero_mv 偏移量和 mv_to_pixel 比例转换为 mV,并用 Savitzky–Golay 滤波进行轻度平滑。

def pixel_to_series_v2_47_quadratic_interpnan(pixel, zero_mv, length, mv_to_pixel, miss_thr=0.1):
    """
    将阶段 2 像素概率 (4,H,W) 转换为 4 通道电压序列 (4,W),
    然后可选地重采样到 `length`。

    关键想法:
      - 高斯模糊以稳定 argmax
      - argmax 周围的二次子像素细化
      - 将低置信度/空列标记为 NaN,然后插值(而不是基线填充)
      - 像素空间中的置信度感知去尖峰
      - 转换像素 -> mV 并应用轻度 SavGol 平滑
    """
    _, H, W = pixel.shape
    series = []

    for j in range(4):
        p = pixel[j]  # (H, W)

        # 1) 仅平滑以稳定 argmax (细轨迹有噪声)
        p_smooth = cv2.GaussianBlur(p, (3, 1), 0)

        # 2) 每列硬 argmax (整数 y)
        idx = p_smooth.argmax(axis=0)
        s = idx.astype(np.float32)
        conf = p.max(axis=0).astype(np.float32)

        # 3) 使用 (y-1, y, y+1) 进行二次子像素细化
        for x in range(W):
            y0 = idx[x]
            if 1 < y0 < H - 2 and p[y0, x] > miss_thr:
                vL = float(p[y0 - 1, x])
                vC = float(p[y0,     x])
                vR = float(p[y0 + 1, x])
                denom = 2.0 * (vL - 2.0 * vC + vR)
                if abs(denom) > 1e-6:
                    delta = (vL - vR) / denom
                    if -0.7 <= delta <= 0.7:
                        s[x] = float(y0) + float(delta)

        # 4) 缺失/低置信度列 -> NaN,然后插值
        miss = ((p > miss_thr).sum(axis=0) == 0) | (conf <= miss_thr)
        s = s.astype(float)
        s[miss] = np.nan
        conf = conf.astype(float)
        conf[miss] = np.nan

        if np.isnan(s).all():
            s[:] = float(zero_mv[j])
            conf[:] = 0.0
        else:
            s = interpolate_nans(s)
            conf = interpolate_nans(conf)

        series.append((s.astype(np.float32), conf.astype(np.float32)))

    # 5) 每通道后处理:去尖峰 (像素空间) -> 转换为 mV -> SavGol
    final_series = []
    for k, (s, conf) in enumerate(series):
        s_clean = conf_aware_despike(s, conf, kernel_size=3, threshold=2.0, conf_guard=0.3)
        s_mv = (zero_mv[k] - s_clean) / mv_to_pixel
        s_mv = savgol_filter(s_mv, window_length=7, polyorder=5)
        final_series.append(s_mv)

    series_final = np.stack(final_series).astype(np.float32)

    # 6) 可选沿时间轴重采样
    if length is not None and length != W:
        series_final = torch.from_numpy(series_final).unsqueeze(1)  # (4,1,W)
        series_final = F.interpolate(series_final, size=length, mode="linear", align_corners=False)
        series_final = series_final.squeeze(1).cpu().numpy()

    return series_final

其他 minor 改进

我们添加了一些轻量级的后处理保护措施,以提高在困难图像上的鲁棒性。每个都提供了微小但一致的增益 ( individually ≈ +0.01 到 +0.05 dB)。

  • 边界尖峰清理: 修复导联分割后导联边界处的短人工跳跃。
  • 生理一致性: 在短导联上应用软 Einthoven 校正 (II ≈ I + III)。
  • 依赖图像类型的校准: 根据图像来源调整像素到 mV 的缩放:
if src in ['0005', '0006', '0009', '0010']:
    mv_to_pixel = 78.3
else:
    mv_to_pixel = 79.5

学习型信号 refinement (黑盒)

最后,我们引入了一个学习型 refinement 阶段

这个黑盒 refinement consistently 将结果提高了 +0.4 到 +0.6 dB,取决于配置,事实证明这是最有效的最后步骤之一。

具体来说,在提取 4 行电压序列后,我们应用了一个黑盒 1D refinement 网络来去噪和纠正系统性提取错误。refiner 是一个增强的1D U-Net++(以及其他用于集成的变体)。

实际设置:

  • 直接在以下数据上训练 1D 神经网络:
    • 提取的 ECG 信号(后处理后)
    • Ground-truth ECG 波形
  • 我们针对每个目标采样长度/频率训练了单独的 refiners(例如,2500/2560/5000/5120/10000/10250),
  • 训练了多个 folds/变体,然后在推理时集成它们(简单平均)。
    这个最后的 refinement 步骤是我们最大的增益之一,根据配置改进了大约 +0.4 到 +0.6 dB

一些解释我们想法的 visuals

在这些图像中,提取的序列是红色的,蓝色序列是 ground-truth 的。

提取序列与 ground-truth 对比

这里分割不太自信,所有值都变为基线。

分割置信度低示例

这里分割创建了奇怪的自信像素(有时是由于网格,有时是由于 B/W 扫描中的噪声)。

分割噪声示例

这里异常峰值

异常峰值示例 1

这里异常是由于将网格误预测为信号

网格误预测示例

这里异常峰值

异常峰值示例 2

这里是我们还没有添加一些特定定制的情况 :)

关于我们尝试过但不太成功的事情的讨论 :)

我们还投入了大量精力从头重建阶段 2 (分割),但没有实现超过公开基线的改进。

  • 对于 ground-truth 生成,我们遵循了与大多数 write-up 略有不同的路径:
    我们将提供的 CSV 信号转换为 .dat / .hea 文件,并重用 ecg_image_kit 重新生成 ECG 图像,允许我们从生成的 JSON 文件中提取点级注释。

  • 从这些点开始,我们试验了许多渲染分割掩码的方法:

    • 不同的绘图后端 (OpenCV, Pillow, Matplotlib),
    • 不同的线条模式 (anti-aliasing, cv2.LINE_8, 等),
    • 不同的线条粗细。
      我们观察到较细的线条始终提供更好的提取指标
  • 我们还尝试了:

    • 在绘图之前放大点坐标 (×2, ×4),
    • 重采样点,例如,250 Hz 信号精确映射到 2500 列。
  • 然而,即使在干净的图像类型上 (例如 0001),这些生成的掩码在重新提取时仅实现了 ~24–28 dB 上限 SNR,并且性能 across 配置迅速下降。

  • 我们训练了分割模型:

    • 非校正图像上使用 heavy augmentation (公开榜单 ≈ 13.5 dB),
    • 阶段 1 校正图像上,掩码也必须被校正。
      我们尝试了两种校正策略:
      1) 先绘制掩码,然后使用 homography warp,
      2) 先 warp 点坐标,然后绘制掩码。
      尽管尝试了多种 losses 和 regularizations,这些模型 plateau 在 ~14–15 dB 公开榜单

总体而言,尽管进行了广泛的实验,我们无法超过公开的阶段 2 分割模型。
我们怀疑问题可能源于 GT 构建的细微差异(我们的 ecg_image_kit 基于 pipeline 与其他人),但我们仍然好奇为什么重建阶段 2 没有产生增益。
最后,尽管有不成功的地方,我们从这次比赛中学到了很多。
所需的精度——无论是在图像几何还是信号重建方面——都令人印象深刻,它让我们更深刻地体会到 ECG 数字化对甚至子像素错误是多么敏感。
非常感谢你读到最后 🙂
我们非常乐意在评论中进一步讨论想法、失败或细节。

同比赛其他方案