686. PhysioNet - Digitization of ECG Images | physionet-ecg-image-digitization
首先,非常感谢 PhysioNet 和 Kaggle 组织了这场充满挑战且意义重大的比赛。
我们还要向分享高质量公开基线和见解的社区成员表示诚挚的感谢。特别是 非常感谢 @hengck23, @wasupandceacar 以及其他人的慷慨公开分享,为我们提供了极其坚实的基础。
最后,祝贺获胜的团队 —— 他们的解决方案令人鼓舞且非常有启发性 :).
附注:我们将在清理后公开 notebook 代码。
我们的最终解决方案在私有榜单上取得了 19.59 dB 的信噪比 (SNR),排名第 27 位。
总体而言,我们的 pipeline 遵循标准且经得起验证的结构:
标准化 / 校正 → 分割 → 后处理
阶段 0 和阶段 1 (标准化 / 校正)
直接取自 hengck23 的 pipeline,这仍然是 ECG 图像对齐和网格校正最强健的方法之一。
阶段 2 (分割)
基于 wasupandceacar 发布的 U-Net 风格分割模型,作为像素级 ECG 波形提取的非常强的基线。
在这些基线之上,我们的主要贡献包括:
虽然 hengck23 的阶段 0 和阶段 1 已经很强,但我们观察到输入图像质量(对比度、照明、污渍、阴影)会显著影响下游校正的质量。
受 @sanpier 的 notebook Visual QA for all stages of ECG digitization 启发,我们采用了双路径策略:
我们进一步观察到:
因此,我们训练了自己的图像类型分类器,并设计了一个安全的、门控的预处理函数,它:
这一改进提供了另外 ~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)来自于重新设计像素到序列的转换。
基线方法通常:
我们发现这通常会引入量化噪声、突然跳跃和人工平坦区域。相反,我们构建了一个更稳定的转换函数,包含三个关键想法:
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
我们添加了一些轻量级的后处理保护措施,以提高在困难图像上的鲁棒性。每个都提供了微小但一致的增益 ( individually ≈ +0.01 到 +0.05 dB)。
if src in ['0005', '0006', '0009', '0010']:
mv_to_pixel = 78.3
else:
mv_to_pixel = 79.5
最后,我们引入了一个学习型 refinement 阶段:
这个黑盒 refinement consistently 将结果提高了 +0.4 到 +0.6 dB,取决于配置,事实证明这是最有效的最后步骤之一。
具体来说,在提取 4 行电压序列后,我们应用了一个黑盒 1D refinement 网络来去噪和纠正系统性提取错误。refiner 是一个增强的1D U-Net++(以及其他用于集成的变体)。
实际设置:
在这些图像中,提取的序列是红色的,蓝色序列是 ground-truth 的。
这里分割不太自信,所有值都变为基线。
这里分割创建了奇怪的自信像素(有时是由于网格,有时是由于 B/W 扫描中的噪声)。
这里异常峰值
这里异常是由于将网格误预测为信号
这里异常峰值
这里是我们还没有添加一些特定定制的情况 :)
我们还投入了大量精力从头重建阶段 2 (分割),但没有实现超过公开基线的改进。
对于 ground-truth 生成,我们遵循了与大多数 write-up 略有不同的路径:
我们将提供的 CSV 信号转换为 .dat / .hea 文件,并重用 ecg_image_kit 重新生成 ECG 图像,允许我们从生成的 JSON 文件中提取点级注释。
从这些点开始,我们试验了许多渲染分割掩码的方法:
cv2.LINE_8, 等),我们还尝试了:
然而,即使在干净的图像类型上 (例如 0001),这些生成的掩码在重新提取时仅实现了 ~24–28 dB 上限 SNR,并且性能 across 配置迅速下降。
我们训练了分割模型:
总体而言,尽管进行了广泛的实验,我们无法超过公开的阶段 2 分割模型。
我们怀疑问题可能源于 GT 构建的细微差异(我们的 ecg_image_kit 基于 pipeline 与其他人),但我们仍然好奇为什么重建阶段 2 没有产生增益。
最后,尽管有不成功的地方,我们从这次比赛中学到了很多。
所需的精度——无论是在图像几何还是信号重建方面——都令人印象深刻,它让我们更深刻地体会到 ECG 数字化对甚至子像素错误是多么敏感。
非常感谢你读到最后 🙂
我们非常乐意在评论中进一步讨论想法、失败或细节。