ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

StarGAN表情识别与迁移源码解析:模型结构、训练与推理实践

StarGAN表情识别与迁移源码解析:模型结构、训练与推理实践 简介一套基于StarGAN的深度学习表情交互系统设计源码面向具备一定深度学习与图像处理基础的开发者用于学习表情识别和跨域表情迁移系统的工程实现。压缩包共22个文件包含11个Python源文件以及用于训练或展示的JPEG图像样本、CSV标签数据、许可证和说明文档等整体大小约5.6MB。源码覆盖优化器、训练流程、模型结构、数据加载和表情生成等核心模块将判别器、生成器、损失计算与数据预处理串联起来便于从零理解GAN类项目在实际开发中的组织方式。已有105人学习下载适合正在研究生成对抗网络或希望快速搭建表情交互原型的开发者作为参考也可作为课程设计或毕设的代码基础。1. 一个生成器同时解决“识别”与“转换”这套 StarGAN 表情源码在做什么表情交互系统里最不缺的就是模型人脸检测一个表情分类一个表情迁移又一个。真正让项目崩塌的往往不在深度学习模型本身的精度而在数据流怎么把三个模型缝在一起以及训练时三套参数互相干扰。这套基于 StarGAN 的源码包走的是另一条路——用一个生成器同时承担表情识别和表情迁移两类任务输入同一个人脸输出既可以是情感类别也可以是指定情绪下的换脸图。它面向的是做表情交互 Demo、跑比赛 baseline 或课程设计的开发者想在有限样本上快速验证多域图像转换的效果。包里 11 个 Python 文件覆盖了从数据转换、模型结构、训练求解到推理生成的完整闭环。最值得先看的是 model.py 和 solver.py前者的生成器把下采样、6 个残差块和上采样串成一条通路后者的损失配比直接决定了训练是否稳定。整条链路不依赖额外的第三方服务装好 PyTorch 就能跑适合当作多域 GAN 训练的入门骨架。2. model.py 拆解StarGAN 生成器与判别器的结构设计与参数选择2.1 生成器结构下采样、6 个残差块、上采样StarGAN 的生成器主体是编码器-解码器结构中间穿插残差块。model.py 里的实现大致是三层下采样、6 个残差块、两层上采样最后用一层 7x7 卷积把特征压回单通道注意力图。下面这段是保留了关键注释的结构骨架# model.py 生成器骨架 class Generator(nn.Module): def __init__(self, img_ch1, n_domains7): super().__init__() # 第一个下采样层 stride1在保存空间结构的同时升通道不做缩放 self.down1 nn.Conv2d(img_ch, 32, 7, 1, 3) # 第二、三层 stride2分辨率依次减半H/2 - H/4 self.down2 nn.Conv2d(32, 64, 4, 2, 1) self.down3 nn.Conv2d(64, 128, 4, 2, 1) # 6 个残差块特征图尺寸停在 H/4 self.resblocks nn.ModuleList([ResBlock(128) for _ in range(6)]) # 上采样两层每次分辨率翻倍 self.up1 nn.ConvTranspose2d(128, 64, 4, 2, 1) self.up2 nn.ConvTranspose2d(64, 32, 4, 2, 1) # 7x7 卷积 Sigmoid输出与输入尺寸一致的注意力图 self.attn nn.Sequential( nn.Conv2d(32, 1, 7, 1, 3), nn.Sigmoid() ) def forward(self, x, c): h self.down1(x) h self.down2(h) # 域标签 c 在中间层拼进特征之后的所有层都能看到它 h torch.cat([h, c], dim1) h self.down3(h) for res in self.resblocks: h res(h) h self.up1(h) h self.up2(h) attn self.attn(h) return attn这段代码里第一个 down1 的 stride 设成 1意味着在进入 1/2 分辨率之前就把原始空间信息保留了一份。表情迁移和超分辨率不同不需要在极大感受野上做全局推理而需要在嘴、眼、眉这些局部区域做精细改动所以把下采样只做两层到 H/4 是足够的选择。6 个残差块是生成器的容量核心它决定了多域共享特征的表达上限太少学不动复杂的表情边界太多会让训练抖动明显尤其在 batch size 不大时容易出现模式崩塌。域标签 c 的注入位置值得单独看一眼。代码里是在 down2 之后 concat也就是说编码器第一层还看不到域信息前几层先抽人脸基础结构越往后越能看到“要迁移到哪个表情”的语义。这种“先通用后专用”的编码顺序是这套源码在处理表情跨域转换时的一个隐性设计后面讲训练时你会感受到它的作用。2.2 判别器结构域标签怎么注入 PatchGAN判别器不是简单输出一个 0/1而是接收图像和域标签两个输入。图像经过几层卷积得到 Patch 特征域标签先做 embedding再广播到与特征图相同的空间尺寸最后 concat 进判别器。它的作用是在判断真伪的同时让判别器“知道”当前样本属于哪个域# 判别器域标签注入示意 class Discriminator(nn.Module): def __init__(self, img_ch1, n_domains7, feat_dim64): super().__init__() self.embed nn.Embedding(n_domains, feat_dim) self.conv1 nn.Conv2d(img_ch feat_dim, feat_dim, 4, 2, 1) def forward(self, x, label): # label: (N,) 已经转成整数索引 c self.embed(label) # (N, feat_dim) c c.view(-1, c.size(1), 1, 1).expand(-1, -1, x.size(2), x.size(3)) h torch.cat([x, c], dim1) # 在通道维拼接域条件 h self.conv1(h) return h这里的 embed 维度是特征通道数不是 one-hot 直接 concat。这样设计的理由是判别器在卷积过程中会对条件向量做线性组合它学到的是“该域与真实分布之间的关系”而不是把标签当作一个硬编码开关。如果直接把 one-hot 向量 repeat 到每个空间位置判别器会过拟合到标签索引对相近表情比如惊讶和恐惧的区分能力会迅速退化。判别器在训练中的实际效果也需要关注一个细节当它收敛太快时生成器的梯度会先消失。很多复现 StarGAN 的工程会在判别器上加谱归一化但这套源码包里没有在 model.py 里显式看到独立的 SN 层说明训练稳定性更多是靠在 solver.py 里控制判别器更新次数来维持的。这个我会在第 4 章展开。2.3 一个模型管多个域共享参数带来的结构先验表情域有一个天然特性不同情绪共享同一张人脸的空间结构眼睛、鼻子、嘴的位置不变变化的只是局部肌肉的形变和纹理。如果给每个表情单独训练一个生成器等于让 7 个模型分别学习 7 套“人脸模板”计算开销是一方面更麻烦的是每个模型对同一张脸的编码方式不一致后续做交互系统时无法在同一特征空间里比较“某个人从平静到开心”的连续变化。StarGAN 的思路是把 7 个表情当成 7 个域让同一个编码器在各自域标签的指导下输出不同结果。model.py 里生成器的前半段是共享的只有域标签 c 让后半段产生分支效果。这个结构天然带来了一个约束所有表情的表征必须复用同一组卷积核编码器只能把“不变的”人脸结构存入共享参数把“变化的”表情差异交给标签条件来调节。相比逐域训练这个先验能明显降低过拟合尤其在 RAF-DB 这类几万张规模的中小型数据集上效果比资源堆叠更直接。2.4 尺寸对齐与输出拼接的隐藏要求最后一层 attn 输出的是一个与输入同分辨率的单通道注意力图但注意力图并不直接作为最终结果而是要回到生成器的输入端做一次门控拼接常见的写法是if attn.shape[-2:] ! x.shape[-2:]: attn F.interpolate(attn, sizex.shape[-2:], modebilinear, align_cornersFalse) out x attn * x # 或者 x * (1 - attn) attn * 生成结果这里有个容易被忽略的坑PyTorch 的卷积默认 padding0代码里虽然写了 padding但如果你修改输入尺寸比如从 201 换成 256注意力图的空间尺寸很可能会差一个像素直接 concat 就会报维度错误。align_cornersFalse 是针对角点对齐的插值方式比默认的最近邻插值在人脸边缘更平滑。这个细节在训练时不会立刻暴露但到 predict.py 里加载模型跑任意尺寸图片时就会频繁触发。3. 数据侧convert_rafdb.py 与 OCEMOTION.csv 怎么组织表情域标签3.1 convert_rafdb.py 到底转了什么数据RAF-DB 是单标签人脸表情数据集原始包在官网下载后会拆成 train 和 validation 两个 list 文件每行是“图片名 标签序号”。常见情况是原始图片按编号存放没有显式的目录结构标签序号的取值范围是 1 到 7对应惊讶、恐惧、厌恶、开心、悲伤、愤怒、中性。这个项目的 convert_rafdb.py 就是把官方 list 文件转成 data_loader 更容易读取的 CSV# convert_rafdb.py 的核心转换逻辑示意 with open(list_patition_label.txt, r) as f: lines f.readlines() with open(OCEMOTION.csv, w) as out: out.write(image_path,label\n) for line in lines: img_name, label line.strip().split() # 原始 RAF-DB 标签从 1 开始深度学习训练时习惯从 0 开始 label_idx int(label) - 1 out.write(f{img_name},{label_idx}\n)转换的价值不在格式本身而在于把训练集和验证集合并成一个文件后有几点便利跨域样本的比例可以一眼统计出来做类别重采样时不用同时维护两份 txt在分布式训练时每个 worker 只需要从 CSV 里随机读行不必重复解析官方 list。标签做减一操作是因为 CSV 里存的是 Python 索引而 one-hot 向量的下标必须与之对应。如果你自己改造这套源码去训练新数据集只需要保证 CSV 的第一列是图片路径、第二列是整数标签其他字段可以往后加。注意不要在 CSV 里放中文路径否则 pandas 默认编码在 Windows 下很容易抛 UnicodeDecodeError。3.2 OCEMOTION.csv 在训练里的角色OCEMOTION.csv 是 data_loader.py 读取标签的唯一入口。它的结构虽然简单但在训练循环里承担的任务不少每个 epoch 都要从头到尾遍历一遍按行拆分出图片路径和情感标签再转成模型需要的 one-hot 向量。一个典型的行内容如下image_pathlabelaligned/train_00001.jpg3aligned/train_00002.jpg0label 列的数值直接对应 emotion one-hot 的第几个位置。这段映射关系必须和生成器的输出层维度保持一致。model.py 里 n_domains7判别器和生成器都以此为基准你在 CSV 里给了第八个值训练时会在 embedding 查表那一步直接 index out of range而且报错信息不会提示是数据问题只会告诉你“索引越界”。排查这类问题最快的方法就是先统计 CSV 里 label 列的取值集合确认是从 0 到 6 且没有缺失。3.3 data_loader.py 的 Dataset 实现要点data_loader.py 是实现 PyTorch Dataset 的典型范本重点在于读图方式、预处理顺序和返回值的组织。下面是去掉具体参数后的核心逻辑# data_loader.py 的 Dataset 骨架 class EmotionDataset(Dataset): def __init__(self, csv_path, size201): self.df pd.read_csv(csv_path) self.size size def __len__(self): return len(self.df) def __getitem__(self, idx): row self.df.iloc[idx] # RAF-DB 对齐后的图是灰度图直接单通道读取 image cv2.imread(row[image_path], cv2.IMREAD_GRAYSCALE) image cv2.resize(image, (self.size, self.size)) # (H, W) - (1, H, W)给生成器补通道维 image np.expand_dims(image, axis0).astype(np.float32) / 127.5 - 1.0 label_vec np.eye(7)[row[label]].astype(np.float32) # one-hot return torch.from_numpy(image), torch.from_numpy(label_vec), row[label]归一化用的是除以 127.5 再减 1也就是说把像素范围压到 [-1, 1]这对应生成器最后 Sigmoid 输出前的数据分布。如果哪次训练发现生成图片整体偏暗或偏灰先检查是不是归一化写成了 255.0 而非 127.5。expand_dims 的位置也容易写错在 resize 之后、归一化之前做是正确的否则会先把 0 到 255 的整数数组转成 float再补通道时会多一次无意义的复制。返回值有三个归一化图、one-hot 标签、整数标签。生成器用 one-hot分类辅助任务用整数标签判别器的 embedding 也用整数标签。三个返回类型不统一是代码里比较危险的地方拿到模型里前最好都明确转成 float 或 long。3.4 Text_Image-Emotion_Generator.py 是怎么把文本变成生成条件的这个脚本的名字看起来和图像生成无关但它做的事却很有意思接收一段文本描述比如 “a surprised young woman”用预训练文本编码器转成向量再把它作为生成器的条件输入。这样就把原本离散的域标签扩展成了连续语义交互系统里可以直接用自然语言控制表情# Text_Image-Emotion_Generator.py 的核心调用示意 text a surprised young woman vec text_encoder.encode(text) # 得到文本特征向量 vec vec / np.linalg.norm(vec) # 归一化到单位长度 # 维度对齐后替换原来的 one-hot 域标签 fake generate_with_condition(vec)这个方向的实用价值在于one-hot 只能表达 7 个表情类别但文本可以表达“稍微惊讶”“强忍着不笑”这类连续强度。实现时不需要改动生成器主体只需把域标签 c 从 7 维换成 512 或 768 维的可学习嵌入或者干脆在文本向量后面接一个线性层映射到生成器内部维度。维度不一致时最常见的错误是 conv 层通道不匹配Pytorch 会在 forward 时报given groups1, weight of size... expected input定位方法就是检查拼接处的通道。4. solver.py 与 train.py训练循环、损失配比与灰度图上的调参4.1 train.py 与 solver.py 的调用关系train.py 不是把“训练”两个字写进一个函数就完事而是做参数解析和入口分发真正的训练循环在 solver.py 里。这种拆分的好处是后续做实验时只需要修改 solver 内部逻辑不用动命令行入口。从源码里能看到 train.py 支持 mode 参数切换 train 和 generate 两种状态# train.py 简化流程 parser.add_argument(--mode, defaulttrain, choices[train, generate]) parser.add_argument(--batch_size, default8, typeint) args parser.parse_args() solver Solver(args) if args.mode train: solver.train() else: solver.generate()solver.py 是训练的枢纽它持有生成器、判别器、数据加载器、优化器和日志器。train() 方法里每轮迭代按“先判别器后生成器”的顺序更新参数代码结构大致如下# solver.py 训练循环核心 for epoch in range(args.epochs): for step, (img, label, label_idx) in enumerate(data_loader): # 先更新判别器让判别器对当前 batch 有足够判别力 d_loss self.update_d(x, label, label_idx) # 再更新生成器尽量骗过刚更新的判别器 g_loss self.update_g(x, label, label_idx) # 写入日志保存 checkpoint self.logger.scalar(d_loss, d_loss.item())先 D 后 G 的顺序是 GAN 训练的惯例如果先生成器后判别器判别器会拿着旧标准去评价新样本导致生成器在无效梯度方向上反复震荡。每次迭代先让判别器适应当前生成分布再让生成器针对新判别器学习这个顺序能有效减少 loss 曲线的毛刺。4.2 三类损失的配比与动态调整表情迁移不是只靠对抗损失就能收敛的判别器能区分真图和假图但没法约束“迁移后的脸还是同一个人”。所以 solver.py 里会组合多组损失常见配比如下损失项作用典型权重对抗损失LSGAN让生成图接近真实分布1.0域分类损失判别器对输入域的判别误差1.0循环重构损失跨域迁移后再迁回应还原原图10.0身份保持损失输入和输出属于同域时尽量不变5.0循环重构损失的权重给到 10是把“迁移后还能迁回来”当作强约束。如果只跑表情识别而不管转换质量可以把这项降到 2训练速度会明显提升但生成图像的形变会变大。身份保持损失权重 5 是防止生成器把整张脸重绘比如把中性表情迁移到开心时只改嘴部和颧骨其他区域保持原样。实际调参时先固定对抗损失权重为 1然后调重构损失。重构损失过大生成图会偏向原图情绪变化不明显过小脸部轮廓会漂移看起来像换了个人。调整一次权重后至少跑 5 个 epoch 再评估因为 GAN 的训练曲线波动大短期的 loss 下降不代表最终效果。4.3 判别器更新次数与收敛判断判别器的收敛状态可以通过训练日志判断这个工程用 logger.py 记录 scalar生成曲线后重点看两条线d_loss 和 g_loss。常见情况是 d_loss 一路跌到接近 0同时 g_loss 不降反升这时判别器太强了。解决办法有两个方向一是调低判别器学习率二是每轮先让生成器更新 2 次再更新判别器。如果训练正常两条 loss 会在一定区间内交替波动而不是任何一条持续走平。fid_iteration.png 这张图是用 FID 指标评估生成质量的折线它比肉眼观察更客观。FID 值持续下降说明生成分布正在逼近真实分布训练可以继续如果 FID 在某一轮开始反弹说明发生了灾难性遗忘需要回滚到之前的 checkpoint 并调小学习率。注意 FID 的评估频率不要太高每 10 个 epoch 算一次就够了否则评估本身会吃掉大量训练时间。4.4 灰度图单通道训练的典型坑RAF-DB 对齐后的图像是灰度图这也是 data_loader 里用 IMREAD_GRAYSCALE 的原因。使用灰度图时三个坑需要特别注意第一单通道图在显示时经常出现全绿或全蓝这是 matplotlib 默认把单通道数组当伪彩色处理的结果第二imread 直接读灰度图得到的是 (H, W) 而不是 (H, W, 3)卷积层输入维度对不上会直接报错所以必须手动 expand_dims第三灰度图在归一化后的均值和方差与 RGB 图不同迁移学习时如果用了 ImageNet 预训练权重需要先统计自己的数据集均值。如果要在交互系统里展示生成结果先按下面的方式把单通道扩展回三通道显示# 单通道生成图转三通道展示 rgb np.repeat(attn[0].cpu().detach().numpy(), 3, axis0) # (1,H,W) - (3,H,W) rgb np.transpose(rgb, (1, 2, 0)) # (H,W,3) plt.imshow((rgb 1) / 2)这段代码里的 repeat 在通道维复制三次等价于把灰度图当成 R、G、B 相同的彩色图。最后的(rgb 1) / 2是把模型输出的 [-1, 1] 区间还原到 [0, 1]避免 plt.imshow 把负值显示器成深色。像素值还偏低时不要直接 imwrite否则存出来的图是纯黑这个问题在复现 StarGAN 时非常常见。5. 推理与改造generate.py 跑一次迁移以及把域标签换成风格编码器的思路5.1 用 generate.py 跑一次表情迁移训练完成后generate.py 是验证效果最快的入口。先确认 model.py 里的 n_domains 和训练时一致再在命令行传入图片路径、模型权重和迁移目标域的索引python generate.py --mode generate \ --model results/model.pth \ --image org2.jpg \ --domain 3domain 参数的数值对应训练 CSV 里的 label 列。注意这个索引是从 0 开始还是从 1 开始要看训练时怎么定义的如果直接拿官方 RAF-DB 的 1 到 7 传进去生成结果会整体偏移一个情绪类别。org2.jpg 和 1.jpg 这些图片文件里有一部分就是推理时留的示例可以用来快速验证模型是否还有基本输出能力。5.2 把生成器替换成风格编码器从标签驱动到参考图驱动如果你想把这套源码改造成真正的交互系统最值得动的就是最后一层条件注入把固定维度的 one-hot 域标签换成风格编码器输出。修改思路是在 generate.py 里增加一个 ReferenceEncoder输入一张参考情感图输出一个风格向量替换掉原来的 one-hot 向量# 参考图风格编码的接入方式 ref_feat reference_encoder(ref_image) # (N, style_dim) style ref_feat / ref_feat.norm(dim1, keepdimTrue) fake generator(face_image, style) # 替代原来的 one-hot 输入这里参考图可以是任意一张带表情的用户照片交互系统拿它作为条件就能实现“模仿这张脸的情绪”而不是“固定迁移到某个离散类别”。这是 StarGAN 后续版本里一个很实用的升级方向改造后的人脸结构保持能力会接近模型本身的上限。运行前记得把原来 model.py 里 concat 的通道数从 n_domains 改成 style_dim否则维度不匹配会直接报错。本文还有配套的精品资源点击获取
返回列表