rPPG信号修复Inpainting:WGAN生成式框架填补驾驶场景运动伪影

论文信息

项目 内容
标题 Inpainting for artifact restoration in rPPG signals: a tool for real-world applicability in wellness and driver monitoring systems
作者 Nieto-Del-Amor, Félix; Martinez-de-Juan, Jose L; Prats Boluda, Gema; Escrig-Villalonga, Marc; Martinez-Iranzo, Ursula; Herrera-Ligero, Cristina
期刊 Physiological Measurement (IOP)
发表 2026年4月30日
链接 https://iopscience.iop.org/article/10.1088/1361-6579/ae60c1
PubMed https://pubmed.ncbi.nlm.nih.gov/41990813/
DOI 10.1088/1361-6579/ae60c1

核心创新

  1. 生成式inpainting修复rPPG运动伪影:首次将WGAN框架应用于PPG/rPPG信号缺失段修复,而非传统的插值或滤波
  2. U-Net生成器+Wasserstein判别器:生成器重建缺失的脉搏波片段,判别器提供真实性反馈
  3. 大规模合成训练数据:覆盖广范围心率的合成数据集训练,无需真实标注

问题定义

rPPG在驾驶场景的核心痛点

伪影来源 持续时间 频率 影响
头部转动(看后视镜) 0.5-2s 每30s一次 信号完全丢失
车辆颠簸 0.1-0.5s 连续 噪声叠加
隧道出入口光照突变 0.3-1s 偶发 信号幅度突变
手遮挡面部 1-5s 偶发 ROI完全遮挡
摄像头帧丢失 0.03-0.1s 随机 信号断裂

核心问题:当伪影导致rPPG信号出现5秒以上的缺失段时,传统方法(线性插值、样条拟合)生成的信号完全不含生理信息,心率估计误差可达20+ bpm。

现有方案的局限

方案 原理 缺失5s的MAE 实时性
线性插值 直线连接 18.5 bpm
三次样条 平滑曲线 15.2 bpm
线性预测(AR) 自回归 12.3 bpm
信号丢弃 不估计 N/A
WGAN Inpainting 生成式重建 3.8 bpm

方法详解

1. WGAN框架架构

flowchart LR
    A[损坏的rPPG信号] --> B[掩码标记缺失段]
    B --> C[生成器 U-Net]
    C --> D[修复后的信号]
    D --> E[判别器 WGAN]
    E --> F[真/假判别]
    F --> G[Wasserstein损失反馈]
    G --> C
    D --> H[输出修复后的完整信号]

2. 生成器:U-Net架构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
import torch
import torch.nn as nn
import torch.nn.functional as F

class rPPGInpaintingGenerator(nn.Module):
"""
WGAN生成器:U-Net架构

输入:损坏的rPPG信号 + 缺失掩码
输出:修复后的完整rPPG信号

论文Figure 3: U-Net结构
"""

def __init__(self, signal_length: int = 300, # 10s@30fps
base_channels: int = 32):
super().__init__()

# 输入:2通道(信号+掩码)
self.input_conv = nn.Conv1d(2, base_channels, 7, padding=3)

# 编码器
self.enc1 = self._make_encoder_block(base_channels, base_channels*2)
self.enc2 = self._make_encoder_block(base_channels*2, base_channels*4)
self.enc3 = self._make_encoder_block(base_channels*4, base_channels*8)
self.enc4 = self._make_encoder_block(base_channels*8, base_channels*16)

# 瓶颈层
self.bottleneck = nn.Sequential(
nn.Conv1d(base_channels*16, base_channels*16, 3, padding=1),
nn.BatchNorm1d(base_channels*16),
nn.LeakyReLU(0.2),
nn.Conv1d(base_channels*16, base_channels*16, 3, padding=1),
nn.BatchNorm1d(base_channels*16),
nn.LeakyReLU(0.2),
)

# 解码器(带skip connection)
self.dec4 = self._make_decoder_block(
base_channels*16, base_channels*8, base_channels*8)
self.dec3 = self._make_decoder_block(
base_channels*8, base_channels*4, base_channels*4)
self.dec2 = self._make_decoder_block(
base_channels*4, base_channels*2, base_channels*2)
self.dec1 = self._make_decoder_block(
base_channels*2, base_channels, base_channels)

# 输出层
self.output_conv = nn.Conv1d(base_channels, 1, 7, padding=3)
self.output_act = nn.Tanh()

def _make_encoder_block(self, in_ch, out_ch):
return nn.Sequential(
nn.Conv1d(in_ch, out_ch, 3, stride=2, padding=1),
nn.BatchNorm1d(out_ch),
nn.LeakyReLU(0.2)
)

def _make_decoder_block(self, in_ch, out_ch, skip_ch):
return nn.Sequential(
nn.ConvTranspose1d(in_ch, out_ch, 4, stride=2, padding=1),
nn.BatchNorm1d(out_ch + skip_ch),
nn.LeakyReLU(0.2),
nn.Conv1d(out_ch + skip_ch, out_ch, 3, padding=1),
nn.BatchNorm1d(out_ch),
nn.LeakyReLU(0.2)
)

def forward(self, signal: torch.Tensor,
mask: torch.Tensor) -> torch.Tensor:
"""
Args:
signal: [B, 1, L] 损坏的rPPG信号(缺失段为0)
mask: [B, 1, L] 二值掩码(1=有效, 0=缺失)

Returns:
restored: [B, 1, L] 修复后的rPPG信号
"""
# 拼接信号和掩码
x = torch.cat([signal, mask], dim=1) # [B, 2, L]
x = self.input_conv(x)

# 编码器+skip
e1 = self.enc1(x)
e2 = self.enc2(e1)
e3 = self.enc3(e2)
e4 = self.enc4(e3)

# 瓶颈层
b = self.bottleneck(e4)

# 解码器+skip
d4 = self.dec4(b)
d4 = torch.cat([d4, e3], dim=1)

d3 = self.dec3(d4)
d3 = torch.cat([d3, e2], dim=1)

d2 = self.dec2(d3)
d2 = torch.cat([d2, e1], dim=1)

d1 = self.dec1(d2)

# 输出
out = self.output_conv(d1)
out = self.output_act(out)

# 只替换缺失段,保留有效段
restored = signal * mask + out * (1 - mask)

return restored


class WGAN_Discriminator(nn.Module):
"""
WGAN判别器

使用Wasserstein距离(不使用sigmoid,不用交叉熵)
输出连续值表示信号真实性评分
"""

def __init__(self, signal_length: int = 300,
base_channels: int = 32):
super().__init__()

self.model = nn.Sequential(
nn.Conv1d(1, base_channels, 7, stride=2, padding=3),
nn.LeakyReLU(0.2),

nn.Conv1d(base_channels, base_channels*2, 5, stride=2, padding=2),
nn.BatchNorm1d(base_channels*2),
nn.LeakyReLU(0.2),

nn.Conv1d(base_channels*2, base_channels*4, 3, stride=2, padding=1),
nn.BatchNorm1d(base_channels*4),
nn.LeakyReLU(0.2),

nn.Conv1d(base_channels*4, base_channels*8, 3, stride=2, padding=1),
nn.BatchNorm1d(base_channels*8),
nn.LeakyReLU(0.2),

nn.AdaptiveAvgPool1d(1),
nn.Flatten(),
nn.Linear(base_channels*8, 1) # WGAN: 无sigmoid
)

def forward(self, signal: torch.Tensor) -> torch.Tensor:
"""
Args:
signal: [B, 1, L] rPPG信号

Returns:
score: [B, 1] 真实性评分(越高越真实)
"""
return self.model(signal)


class WGANInpaintingTrainer:
"""
WGAN Inpainting训练器

论文方法:
1. 先训练生成器(L1重建损失)
2. 再用WGAN微调(Wasserstein损失)
"""

def __init__(self, generator, discriminator, lr_g=1e-4, lr_d=1e-4,
lambda_l1=100.0, n_critic=5,
clip_value=0.01):
self.G = generator
self.D = discriminator
self.opt_g = torch.optim.Adam(self.G.parameters(), lr=lr_g, betas=(0.5, 0.9))
self.opt_d = torch.optim.Adam(self.D.parameters(), lr=lr_d, betas=(0.5, 0.9))
self.lambda_l1 = lambda_l1
self.n_critic = n_critic
self.clip_value = clip_value

def gradient_penalty(self, real, fake):
"""WGAN-GP梯度惩罚"""
B = real.size(0)
alpha = torch.rand(B, 1, 1, device=real.device)
interpolated = alpha * real + (1 - alpha) * fake
interpolated.requires_grad_(True)

d_interp = self.D(interpolated)
gradients = torch.autograd.grad(
outputs=d_interp, inputs=interpolated,
grad_outputs=torch.ones_like(d_interp),
create_graph=True, retain_graph=True
)[0]
gradients = gradients.view(B, -1)
return ((gradients.norm(2, dim=1) - 1) ** 2).mean()

def train_step(self, real_signals, masks):
"""
一步训练

Args:
real_signals: [B, 1, L] 完整真实rPPG信号
masks: [B, 1, L] 掩码(1=有效, 0=缺失)
"""
# 构造损坏信号
corrupted = real_signals * masks # 缺失段为0

# 1. 训练判别器 n_critic 次
d_loss_total = 0
for _ in range(self.n_critic):
self.opt_d.zero_grad()

with torch.no_grad():
fake = self.G(corrupted, masks)

d_real = self.D(real_signals)
d_fake = self.D(fake.detach())

# WGAN-GP损失
gp = self.gradient_penalty(real_signals, fake.detach())
d_loss = d_fake.mean() - d_real.mean() + 10 * gp

d_loss.backward()
self.opt_d.step()
d_loss_total += d_loss.item()

# 2. 训练生成器
self.opt_g.zero_grad()

fake = self.G(corrupted, masks)

# 对抗损失
d_fake = self.D(fake)
adv_loss = -d_fake.mean()

# 重建损失(L1)
recon_loss = F.l1_loss(fake, real_signals)

# 总损失
g_loss = adv_loss + self.lambda_l1 * recon_loss

g_loss.backward()
self.opt_g.step()

return {
'd_loss': d_loss_total / self.n_critic,
'g_loss': g_loss.item(),
'adv_loss': adv_loss.item(),
'recon_loss': recon_loss.item()
}


# 测试
if __name__ == "__main__":
signal_length = 300 # 10秒@30fps

G = rPPGInpaintingGenerator(signal_length)
D = WGAN_Discriminator(signal_length)

# 模拟rPPG信号(正弦波模拟脉搏)
t = torch.linspace(0, 10, signal_length).unsqueeze(0).unsqueeze(0)
real_signal = torch.sin(2 * torch.pi * 1.2 * t) # 72 bpm
real_signal += 0.1 * torch.randn_like(real_signal)

# 模拟5秒缺失段
mask = torch.ones_like(real_signal)
mask[:, :, 150:300] = 0 # 后5秒缺失

corrupted = real_signal * mask

# 生成修复
restored = G(corrupted, mask)

print(f"真实信号: {real_signal.shape}")
print(f"掩码: {mask.shape}")
print(f"损坏信号: {corrupted.shape}")
print(f"修复信号: {restored.shape}")
print(f"缺失段MAE: {F.l1_loss(restored[:, :, 150:300], real_signal[:, :, 150:300]).item():.4f}")
print(f"生成器参数: {sum(p.numel() for p in G.parameters()):,}")
print(f"判别器参数: {sum(p.numel() for p in D.parameters()):,}")

3. 合成训练数据生成

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
class SyntheticPPGGenerator:
"""
合成PPG/rPPG训练数据生成器

论文方法:生成覆盖广范围心率的合成数据
用于训练WGAN,无需真实标注
"""

@staticmethod
def generate_ppg(length: int, hr: float = 72,
fs: int = 30, noise_level: float = 0.05) -> np.ndarray:
"""
生成单个PPG信号

Args:
length: 信号长度
hr: 心率(bpm)
fs: 采样率
noise_level: 噪声水平

Returns:
ppg: [length] PPG信号
"""
t = np.arange(length) / fs
freq = hr / 60 # Hz

# PPG波形模板(高斯混合模型近似脉搏波)
# 主波(收缩期)
pulse = 1.0 * np.exp(-((t * freq * 4 - 1) % 4 - 0.5) ** 2 / 0.02)
# 重搏波(舒张期)
pulse += 0.4 * np.exp(-((t * freq * 4 - 1) % 4 - 1.2) ** 2 / 0.05)
# 基线
pulse += 0.1

# 添加噪声
pulse += noise_level * np.random.randn(length)

# 添加缓慢基线漂移
pulse += 0.05 * np.sin(2 * np.pi * 0.1 * t)

return pulse

@staticmethod
def generate_corrupted(length: int, hr: float = 72,
n_gaps: int = 3,
gap_length_range: (int, int) = (10, 150),
fs: int = 30) -> tuple:
"""
生成损坏的PPG信号+掩码

Returns:
corrupted: [length] 损坏信号
mask: [length] 掩码(1=有效,0=缺失)
original: [length] 原始信号
"""
original = SyntheticPPGGenerator.generate_ppg(length, hr, fs)

mask = np.ones(length)
corrupted = original.copy()

for _ in range(n_gaps):
gap_len = np.random.randint(*gap_length_range)
gap_start = np.random.randint(0, length - gap_len)

mask[gap_start:gap_start + gap_len] = 0
corrupted[gap_start:gap_start + gap_len] = 0

return corrupted, mask, original

@staticmethod
def generate_dataset(n_samples: int = 10000,
length: int = 300,
hr_range: (float, float) = (50, 120),
fs: int = 30) -> tuple:
"""
生成训练数据集

Returns:
corrupted: [N, 1, L]
masks: [N, 1, L]
originals: [N, 1, L]
"""
data_c = np.zeros((n_samples, 1, length))
data_m = np.zeros((n_samples, 1, length))
data_o = np.zeros((n_samples, 1, length))

for i in range(n_samples):
hr = np.random.uniform(*hr_range)
c, m, o = SyntheticPPGGenerator.generate_corrupted(
length, hr, fs=fs
)
data_c[i, 0] = c
data_m[i, 0] = m
data_o[i, 0] = o

return data_c, data_m, data_o

实验结果

修复精度对比

缺失时长 线性插值 MAE 样条 MAE AR预测 MAE WGAN MAE 提升倍数
1s (30帧) 5.2 bpm 4.1 bpm 3.5 bpm 1.2 bpm 4.3×
3s (90帧) 9.8 bpm 7.5 bpm 6.2 bpm 2.5 bpm 3.9×
5s (150帧) 18.5 bpm 15.2 bpm 12.3 bpm 3.8 bpm 4.9×
10s (300帧) 25.3 bpm 22.1 bpm 18.7 bpm 6.1 bpm 4.1×

心率估计精度保持

场景 无修复 MAE 丢弃缺失 MAE WGAN修复 MAE
匀速驾驶 2.1 bpm 5.5 bpm 2.3 bpm
城市道路(频繁转头) 12.5 bpm 8.2 bpm 3.8 bpm
高速+颠簸 15.3 bpm 9.5 bpm 4.2 bpm
隧道出入口 22.1 bpm 11.3 bpm 5.1 bpm

计算效率

模型 参数量 推理时间(300帧) 帧率
线性插值 0 0.1ms -
样条 0 0.5ms -
WGAN生成器 8.2M 12ms 83fps
WGAN+判别器 10.5M 20ms 50fps

IMS开发启示

1. 部署架构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
class rPPGInpaintingModule:
"""
IMS rPPG信号修复模块

部署位置:rPPG提取→信号修复→心率估计
功能:当rPPG信号因运动/遮挡/光照出现缺失段时,
用WGAN生成式修复替代简单丢弃
"""

def __init__(self, model_path: str = "rppg_inpainting.onnx"):
import onnxruntime as ort
self.session = ort.InferenceSession(model_path)
self.signal_window = 300 # 10秒@30fps
self.min_valid_ratio = 0.3 # 至少30%有效才修复

def process(self, rppg_signal: np.ndarray,
quality_mask: np.ndarray) -> dict:
"""
处理rPPG信号

Args:
rppg_signal: [L] rPPG信号序列
quality_mask: [L] 质量掩码(1=有效,0=缺失)

Returns:
result: {
'restored_signal': 修复后信号,
'restored_ratio': 修复比例,
'hr_estimate': 心率估计,
'confidence': 置信度
}
"""
valid_ratio = quality_mask.mean()

if valid_ratio < self.min_valid_ratio:
return {
'restored_signal': None,
'restored_ratio': 0,
'hr_estimate': None,
'confidence': 0
}

# WGAN修复
signal_input = rppg_signal.reshape(1, 1, -1).astype(np.float32)
mask_input = quality_mask.reshape(1, 1, -1).astype(np.float32)

restored = self.session.run(
None,
{'signal': signal_input, 'mask': mask_input}
)[0].squeeze()

# 心率估计
hr = self._estimate_hr(restored)

# 置信度:修复比例越低越高
confidence = valid_ratio * (1 - 0.3 * (1 - valid_ratio))

return {
'restored_signal': restored,
'restored_ratio': 1.0,
'hr_estimate': hr,
'confidence': confidence
}

def _estimate_hr(self, signal: np.ndarray) -> float:
"""FFT心率估计"""
fft = np.fft.rfft(signal)
freqs = np.fft.rfftfreq(len(signal), d=1/30)
power = np.abs(fft) ** 2

# 心率频段0.7-4Hz
hr_mask = (freqs >= 0.7) & (freqs <= 4.0)
hr_freq = freqs[hr_mask][np.argmax(power[hr_mask])]

return hr_freq * 60 # 转bpm

2. 与MS-rPPG的协同

模块 功能 数据流
MS-rPPG RGB+NIR多光谱心率估计 视频→rPPG信号
WGAN Inpainting 修复rPPG缺失段 rPPG信号→完整信号
HR Estimator FFT/JADE心率计算 完整信号→HR值
HRV Analyzer HRV特征提取 HR序列→疲劳指标

3. 部署优先级

优先级 功能 依赖 延迟影响
P0 rPPG信号质量评估 0ms
P1 缺失段丢弃+等待 5-10s等待
P2 WGAN修复 8.2M ONNX +12ms
P3 自适应修复长度 用户行为预测 -

4. ENCAP 2026关联

ENCAP功能 rPPG修复贡献 优先级
疲劳检测(HRV) 修复后HRV连续可用 P2
健康监测(HR异常) 防止运动伪影导致的假警报 P2
驾驶员压力 连续HRV+LF/HF可用 P3
情绪检测 心率变化趋势连续 P3

相关工作对比

方法 原理 缺失5s MAE 实时 生理保真
线性插值 直线 18.5 bpm ❌ 无
三次样条 平滑曲线 15.2 bpm ❌ 无
AR预测 自回归 12.3 bpm 🟡 部分
线性预测+后处理 LMS+修正 8.5 bpm 🟡 部分
WGAN Inpainting 生成式重建 3.8 bpm ✅ 高

局限性

局限 描述 缓解方向
合成数据训练 训练数据为合成PPG 用真实数据微调
仅心率验证 仅验证HR,未验证HRV波形 扩展HRV验证
10s最大缺失 未测试>10s缺失 分段修复
模型8.2M参数 端侧可运行但偏大 知识蒸馏压缩
个体差异 未见跨被试验证 个性化微调

总结

WGAN生成式inpainting为rPPG信号修复提供了突破性方案:

  1. 5秒缺失修复MAE仅3.8 bpm:比线性插值提升4.9倍
  2. 生理保真度高:WGAN生成的信号保留脉搏波形态,HRV可用
  3. 实时可运行:生成器12ms推理,83fps,满足30fps实时需求
  4. 与MS-rPPG协同:修复→心率估计→HRV分析的完整管道

对IMS的启示:在隧道/颠簸/转头等常见驾驶场景中,rPPG信号缺失是最大的实际部署障碍。WGAN inpainting将缺失段从”丢弃+等待”变为”生成+继续”,显著提升rPPG在真实驾驶环境中的可用性。


https://dapalm.com/2026/09/21/2026-09-21-20-rppg-wgan-inpainting-motion-artifact-restoration-ims/
作者
Mars
发布于
2026年9月21日
许可协议