深度学习实战:CNN与LSTM实现看图说话和微表情识别

发布时间:2026/10/1 1:11:42
深度学习实战:CNN与LSTM实现看图说话和微表情识别 简介一套面向人工智能导论课程的综合项目包聚焦“看图说话”与“微表情识别”两个经典视觉任务适合高校AI课程学习者、计算机视觉入门者以及需要课程设计参考的学生。项目基于TensorFlow与Keras实现核心代码以Jupyter Notebook形式组织包含人脸检测的haar特征XML、checkpoint模型文件以及课程论文报告Word文档可直接运行观察图像描述生成与表情分类效果。资源共9个文件除ipynb与docx外还配有README说明、License及报告文档压缩包大小约5.78MB结构清晰便于按需查阅。目前已有338人学习内容覆盖模型设计、训练验证到论文写作全流程适合作为课程实验复现或毕业设计起步模板。1. 一份课程大作业压缩包凭什么同时装下“看懂图”和“读懂脸”如果你在期末周打开过一个名为“人工智能导论—看图说话微表情识别.zip”的压缩包你大概知道那种感觉里面是一个课程大作业的完整交付物——模型权重、训练脚本、推理Demo和一份写满了公式的实验报告。这类项目包在人工智能大作业里几乎成了标配前半部分用深度学习让机器对着图片“开口说话”后半部分让机器从人脸视频里捕捉一闪而过的情绪。两个任务看似毫不相干实则共享同一套技术底座特征提取、序列建模、小样本训练和结果可视化。这篇文章想帮你搞清楚的不是概念层面的“人工智能是什么”而是这份压缩包里的代码该怎么组织、模型该怎么训练、参数该怎么调、答辩时被问倒的坑又在哪里。2. 看图说话从图像特征到自然语言的完整链路2.1 看图说话的任务拆解这不是“分类”而是“翻译”看图说话Image Captioning的本质是把一张图片映射成一句描述性文本。很多从图像分类转过来的同学会下意识地把它当作“多标签分类”处理但两者有本质差别分类的输出是从固定类别集合里选一个或几个标签而看图说话的输出是一个变长的、有语法结构的句子。这意味着模型不仅要回答“图里有什么”还要回答“它们之间是什么关系”“正在发生什么动作”。把这个问题拆到最底层模型要做的事可以分成三步第一步从像素里提取高层语义特征这通常交给卷积神经网络CNN第二步把语义特征“翻译”成词序列这通常交给循环神经网络RNN/LSTM或Transformer解码器第三步在词表上逐步生成概率分布选择概率最高的词拼成句子。这个“编码器-解码器”结构是绝大多数课程大作业采用的基础框架也是你在答辩时最需要讲清楚的逻辑主线。在课程项目里我一般建议用“预训练CNN编码器 LSTM解码器”的经典组合起步。预训练CNN用的是ImageNet上训练好的ResNet或VGG它已经具备了较强的视觉语义提取能力不需要你从头训视觉特征LSTM负责把视觉特征逐步展开成单词序列。这个方案的好处是训练稳定、代码量小、在CPU上也能跑通一个最小版本适合一张显卡都不一定借得到的期末场景。2.2 数据集的选型和预处理先搞清楚你手里的图长什么样课程大作业的数据集选择通常受限于两个因素显存大小和标注成本。公开数据集里最常出现在这类项目包中的是Flickr8k、Flickr30k和MS COCO Captions。Flickr8k有八千张图、每张配五句人工标注体积小适合快速跑通流程Flickr30k和COCO规模更大但下载和处理成本也更高。数据预处理的路径很固定图片经过Resize到统一尺寸、中心裁剪或随机裁剪、归一化后进入CNN文本则要做分词、建立词表、对句子做padding到统一长度。这里有个容易被忽略的边界问题——词表大小直接影响训练速度和显存占用。Flickr8k的词表大概在三千到五千词去掉出现次数少于5次的低频词后能进一步压缩。对于导论课程的大作业我通常会把词表截断阈值设在5这样在词嵌入维度和LSTM隐层维度都取256时模型参数量能控制在一个比较舒服的范围。分词工具上spaCy或NLTK都行但要注意标点符号和特殊字符的处理。很多翻车现场就是从“把句号当成了一个词”开始的——模型学会了在所有句子结尾输出“.”看起来挺对实际上整个句子生成全靠标点在硬撑。2.3 训练配置与核心参数把LSTM解码器跑起来的完整命令下面这个训练脚本是这类项目包里常见的核心文件结构上覆盖了数据加载、模型定义、训练循环和日志输出基本是“拿到就能改、改完就能跑”的样子。import torch import torch.nn as nn import torch.optim as optim from torch.utils.data import DataLoader from torchvision import models, transforms from vocab import Vocab # 自定义词表类 from dataset import CaptionDataset # 自定义数据集类 # 超参数区在课程答辩里你要能说清楚每一项调整的理由 embed_size 256 # 词向量维度 hidden_size 256 # LSTM隐层维度 num_layers 1 # LSTM层数浅层够用 max_len 30 # 句子最大长度超出截断 batch_size 64 num_epochs 15 learning_rate 3e-4 vocab_threshold 5 # 词表截断阈值低频词不进词典 save_path checkpoints/caption_model.pth # 预训练CNN编码器去掉最后的全连接层只保留特征提取部分 encoder models.resnet18(pretrainedTrue) encoder nn.Sequential(*list(encoder.children())[:-2]) # 输出 512x7x7 特征图平均池化可再加 encoder.avgpool nn.AdaptiveAvgPool2d(1) encoder.fc nn.Identity() # LSTM解码器把视觉特征作为初始状态输入 class DecoderLSTM(nn.Module): def __init__(self, embed_size, hidden_size, vocab_size, num_layers): super().__init__() self.embed nn.Embedding(vocab_size, embed_size) self.lstm nn.LSTM(embed_size, hidden_size, num_layers, batch_firstTrue) self.fc nn.Linear(hidden_size, vocab_size) def forward(self, image_features, captions): embeds self.embed(captions) if image_features.dim() 4: image_features image_features.mean(dim(2, 3)) # 全局平均池化转成特征向量 image_features image_features.unsqueeze(1) # 对齐LSTM输入维度 embeds torch.cat((image_features, embeds), dim1) # 视觉特征作为START标记 outputs, _ self.lstm(embeds) return self.fc(outputs)这段代码的核心设计意图是把CNN提取的特征图通过全局平均池化压缩成一个512维的向量然后在时间维度上把特征向量和词嵌入拼接让LSTM在第一步就“看到”图像内容。参数上embed_size和hidden_size取256是性能和效果的平衡点取512以上在课程项目的数据量下容易过拟合num_layers取1也是刻意为之LSTM层数增加对生成的句子质量提升有限却会显著拉长训练时间。训练过程中的损失函数采用交叉熵损失但需要把padding位置遮蔽掉否则模型会在空白处学出一堆无意义的概率。学习率用3e-4配合Adam优化器是比较稳妥的起步配置。如果你发现loss降得很慢可以把batch_size调到32让梯度更新更频繁如果loss震荡剧烈则优先降低学习率而不是增加epoch数。2.4 生成与评估指标BLEU分数之外还要看的细节训练完成后生成句子的推理代码相对简单输入图片特征以 标记开头每步取概率最高的词作为下一步输入直到输出 或达到max_len。这个贪心解码方式在课程项目里够用但生成的句子容易重复。稍微好一点的做法是使用束搜索Beam Search保留概率最高的前三个候选句子最后选总概率最大的那个。def generate_caption(model, image_tensor, vocab, max_len30): model.eval() with torch.no_grad(): features model.encoder(image_tensor.unsqueeze(0)) features features.mean(dim(2, 3)).unsqueeze(1) states None # 用START标记启动作句 input_word torch.tensor([[vocab.start_id]], deviceimage_tensor.device) sentence [] for _ in range(max_len): output, states model.lstm(torch.cat((features, model.embed(input_word)), dim1), states) output model.fc(output.squeeze(1)) next_word output.argmax(dim1).item() if next_word vocab.end_id: break sentence.append(vocab.itos[next_word]) input_word torch.tensor([[next_word]], deviceimage_tensor.device) return .join(sentence)评估指标上BLEU-1到BLEU-4是这类任务最常见的自动评估标准但BLEU只看n-gram重合度对同义词替换几乎零容忍。比如模型生成“a dog is running in the park”人工标注里写的是“a puppy is playing outside”BLEU会给出一个不匹配你直觉的低分。所以我在项目报告里通常会同时放BLEU分数和8到10张测试图片的人工评价结果后者在答辩时往往更有说服力。3. 微表情识别从人脸视频里捕捉一闪而过的情绪3.1 为什么微表情识别比普通表情识别难一个量级微表情识别的任务对象是持续时间极短、强度极低的面部表情变化通常在1/25秒到1/5秒之间肌肉动作幅度肉眼几乎不可见。它与普通表情识别最大的区别在于普通表情识别是对单帧图片做分类而微表情识别必须处理连续帧序列因为微表情的特征全藏在帧与帧之间的细微变化里。想想这个场景一段视频里有一个人脸表情从平静到微微紧张再到恢复平静全程不到半秒钟。你要让模型判断这段视频表现的是“焦虑”还是“忍耐”。如果你只取某一帧或某几帧模型根本看不到变化信息如果取全段视频计算量又上来了且大量帧是无关的平静帧。所以微表情识别在数据层面就需要解决“时序建模”和“特征增强”两个问题。课程大作业里常见的做法有三条路线第一条是用LSTM或3D-CNN直接处理视频帧序列端到端训练第二条是先用LBP-TOP或光流法提取手工时序特征再用SVM或MLP分类第三条是混合路线——用2D-CNN逐帧提特征再用时序模型聚合帧级特征。对导论级项目我推荐第二条路线起步因为数据量小、收敛快、可解释性强答辩时能把原理讲到让人点头。3.2 微表情数据集和帧序列的处理规则微表情识别公开数据集里最常见的有SMIC、CASME系列和SAMM。CASME II是相对标准的基准包含两百多个自发微表情视频片段标注了起始帧、顶点帧和结束帧类别有开心、惊讶、厌恶、压抑、紧张等。但说实话这些数据集的规模都偏小——和ImageNet的百万级图片完全不是一个量级。因此训练微表情识别模型时过拟合是第一大敌几乎每个调参环节都在和过拟合搏斗。预处理有一个关键细节人脸对齐。不同视频片段里人的头部位置和大小不一样直接送入模型会导致模型学到“位置”而非“表情”。常见做法是用dlib或MTCNN检测人脸关键点根据双眼位置做仿射变换把人脸裁到固定尺寸。帧率也要统一CASME II是200fps采集的而普通摄像头只有30fps左右训练时通常做抽帧对齐把每个视频片段统一到一定帧数范围内。import cv2 import numpy as np def extract_frames(video_path, target_frame_count16): 从视频片段中抽取固定数量的帧。 微表情的顶点帧附近信息量最大因此做均匀采样时 可以以顶点帧为中心做加权采样。 cap cv2.VideoCapture(video_path) frames [] while True: ret, frame cap.read() if not ret: break frames.append(cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)) cap.release() total len(frames) if total 0: return None # 均匀抽取target_frame_count帧如果视频不足则循环填充 indices np.linspace(0, total - 1, target_frame_count, dtypeint) sampled [frames[i] for i in indices] # 统一尺寸到112x112减少人脸位置差异的干扰 resized [cv2.resize(f, (112, 112)) for f in sampled] # 堆叠成形状 (target_frame_count, 112, 112) 的数组 return np.stack(resized, axis0)这段代码做了三件事抽取均匀分布的帧、统一尺寸、返回固定形状的张量。target_frame_count是关键的调参对象取8到16是常见范围太小会丢失微表情的过程信息太大则让无关帧稀释有效表达。尺寸选112是因为很多预训练人脸模型内置了112这个输入尺寸配合后续特征提取不用再做额外适配。3.3 光流特征与LBP-TOP两种手工特征的取舍光流法提取的是相邻帧之间的像素运动向量能直观反映面部肌肉的微小位移LBP-TOP则是在三个正交平面空间XY、时间XT、时间YT上计算局部二值模式捕捉时空纹理变化。两者都是经典的手工特征课程项目中常把它们对比着做实验。光流特征的优点是物理含义清晰能和面部动作单元AU对应起来答辩时容易解释缺点是计算量大且对头部轻微晃动非常敏感。LBP-TOP的优点是鲁棒性好对光照和轻微的头部运动不那么敏感缺点是特征维度高通常需要降维后才能做分类器训练。实操时一个常见的做法是把光流场分成几个区域分别统计每个区域的运动方向和幅度直方图再接一个分类器。这种做法本质上是在说“眉头的肌肉动了多少、嘴角的肌肉动了多少”它可以像特征工程一样被解释对导论级课程项目非常友好。3.4 微表情识别的最小训练闭环特征分类器的朴素实现下面的代码展示的是“光流特征 SVM分类器”的组合这是微表情识别里最容易跑通、也最不容易翻车的基线方案。import cv2 import numpy as np from sklearn.svm import SVC from sklearn.model_selection import train_test_split from sklearn.metrics import accuracy_score, f1_score def compute_optical_flow_features(frames): 输入帧序列输出一个综合光流方向直方图特征。 frames.shape: (N, H, W)N为帧数。 h, w frames.shape[1], frames.shape[2] hist_bins 8 # 把360度方向分成8个区间 mag_sum np.zeros(hist_bins) prev_gray frames[0] for i in range(1, len(frames)): curr_gray frames[i] # calcOpticalFlowFarneback是稠密光流算法参数取经验值 flow cv2.calcOpticalFlowFarneback(prev_gray, curr_gray, None, 0.5, 3, 15, 3, 5, 1.2, 0) mag, ang cv2.cartToPolar(flow[..., 0], flow[..., 1]) # 按角度分桶用幅度加权得到这一帧的方向直方图 bin_idx (ang * hist_bins / (2 * np.pi)).astype(int) % hist_bins for b in range(hist_bins): mag_sum[b] mag[bin_idx b].sum() prev_gray curr_gray # 归一化让特征不受视频长度和光照强度影响 norm mag_sum / (mag_sum.sum() 1e-6) return norm # 主流程遍历所有视频片段提取特征后训练SVM X [] y [] for video_path, label in sample_data: frames extract_frames(video_path, target_frame_count16) if frames is None: continue X.append(compute_optical_flow_features(frames)) y.append(label) X np.array(X) X_train, X_test, y_train, y_test train_test_split(X, y, test_size0.2, random_state42) # SVM用RBF核C取1.0是课程项目里比较稳的起点 clf SVC(kernelrbf, C1.0, gammascale) clf.fit(X_train, y_train) y_pred clf.predict(X_test) print(accuracy:, accuracy_score(y_test, y_pred)) print(f1_score:, f1_score(y_test, y_pred, averagemacro))这段代码的用意是让你在一个晚上就能跑出第一版指标。注意几个参数calcOpticalFlowFarneback的五个参数控制了光流计算的精度和速度在课程项目里保持默认即可不要过度调整hist_bins设为8意味着每个视频片段最终变成一个8维特征特征维度低到SVM根本不会过拟合但也意味着特征表达能力有限——它只能区分“大致运动方向分布”区分不了精细的微表情类别。这也是为什么这个方案只能当基线想拿高分还得上深度学习。数据量少怎么办是个必须面对的现实。微表情数据集的样本量通常在几十到几百之间这点数据喂给深度学习模型几乎必然过拟合。常见的应对策略包括用更多普通表情数据做预训练再微调、数据增强小幅旋转、平移、加噪声、以及用交叉验证而不是固定划分来评估模型。4. 把两个模型装进一个压缩包目录组织、权重管理与一键评测4.1 项目压缩包里应该有什么一份能直接跑通的目录结构这门课程的大作业压缩包本质上是给老师或答辩评委看的“可运行证据”。压缩包里的目录组织如果混乱哪怕模型精度再高也会被质疑工程能力。我见过太多压缩包解压后到处散落着命名不明的时间戳文件这种交付方式非常吃亏。一个我建议的目录结构是根目录下分src、checkpoints、data、results四个目录外加一个README.md和一份实验报告。src存放训练和推理脚本checkpoints放训练好的权重data放预处理后的数据文件或数据说明results放生成示例和评估曲线。README里要写清楚环境依赖、运行命令和复现步骤。这种组织的直接好处是老师拿到压缩包后能沿着README里的命令一步步跑通不需要猜你的代码意图。课程项目和工业项目最大的区别在于课程项目的评委更看重“完整链路是否打通”而不是单点指标是否顶尖。4.2 训练与推理脚本的分离防止“我只想跑一下测试却被要求先训练三天”另一个常见问题是模型推理和训练耦合在一起每次运行测试都要先走一遍训练逻辑。标准做法是把train.py和inference.py分离train.py负责数据加载、训练循环、checkpoint保存inference.py负责加载训练好的权重、对新图片或新视频推理、输出结果。# inference.py加载训练好的看图说话模型并生成描述 import torch from PIL import Image from torchvision import transforms from model import EncoderCNN, DecoderLSTM # 设备选择优先用GPU但CPU也能跑 device torch.device(cuda if torch.cuda.is_available() else cpu) def load_model(encoder_path, decoder_path): encoder EncoderCNN().to(device) decoder DecoderLSTM().to(device) encoder.load_state_dict(torch.load(encoder_path, map_locationdevice)) decoder.load_state_dict(torch.load(decoder_path, map_locationdevice)) encoder.eval() decoder.eval() return encoder, decoder def infer(encoder, decoder, image_path, transform): image Image.open(image_path).convert(RGB) image_tensor transform(image).unsqueeze(0).to(device) with torch.no_grad(): features encoder(image_tensor) sentence decoder.generate(features, max_len30) return sentence这里把加载checkpoint的逻辑独立成函数好处是测试时不需要重新初始化训练状态。实战中我习惯在保存checkpoint时同时保存一个config.json记录当时的超参数组合否则两个星期后回来看代码你根本记不清当前权重是用多少学习率训出来的。4.3 评估脚本与可视化输出让指标和示例同时出现在报告里评估模块建议单独写一个evaluate.py对看图说话输出BLEU分数和示例图对微表情识别输出分类准确率和混淆矩阵。自动指标放在前面人工示例放在后面这个顺序在答辩PPT里就是逻辑链条。# evaluate.py对微表情识别模型输出评估报告 import numpy as np from sklearn.metrics import confusion_matrix, classification_report def evaluate_model(model, dataloader, label_names): model.eval() all_preds, all_labels [], [] for frames, labels in dataloader: with torch.no_grad(): outputs model(frames) preds outputs.argmax(dim1).cpu().numpy() all_preds.extend(preds) all_labels.extend(labels.cpu().numpy()) print(classification_report(all_labels, all_preds, target_nameslabel_names)) print(confusion_matrix(all_labels, all_preds))上面这段代码输出的classification_report包含precision、recall和f1-score比只看accuracy有用得多。微表情识别任务里类别极度不均衡是常态“惊讶”类可能有80个样本“厌恶”类只有10个样本。如果只看准确率模型全预测“惊讶”都能拿到一个虚高的分数而f1-score会把这种懒惰暴露出来。5. 避坑手册看图说话与微表情识别最常见的五个翻车现场5.1 现象loss降不下去一直在5以上徘徊原因看图说话任务里交叉熵损失下不去很多时候不是模型问题而是词表里有大量未登录词或者padding没有在损失函数里被正确掩盖。未登录词会让模型在生成时不断尝试输出一个它从未在训练中见过的词梯度更新变得越发混乱。解决检查词表和训练数据是否一致确认训练时有没有做低频词截断用mask掩码把填充位置在计算交叉熵时剔掉。如果这两点都做了还是没有改善再检查学习率是不是设得太大。5.2 现象微表情模型在训练集上准确率99%测试集上只有50%原因这是典型的过拟合几乎每个做微表情识别的人都会撞上一次。数据量只有几百个样本模型参数量动辄百万训练几个epoch后就开始硬背训练样本。解决第一优先做数据增强——每一帧做小幅平移、旋转、增加高斯噪声第二降低模型复杂度把LSTM隐层维度从512降到128或换成分类层更浅的结构第三用留一交叉验证Leave-One-Subject-Out替代随机划分确保同一个人的不同视频片段不会同时出现在训练集和测试集中。5.3 现象看图说话模型生成的句子语法通顺描述却与图片内容无关原因这是“语言模型压倒视觉特征”的典型症状。LSTM太擅长根据前面的词预测下一个语法合理的词导致视觉特征的作用被稀释。尤其在训练数据量不足的情况下模型会偏向记忆常见句式。解决尝试增大视觉特征在输入中的占比比如把CNN特征在输入维度上复制几份拼接进去或者在LSTM的每一步都输入视觉特征而不是只输入第一步同时可以降低词嵌入维度给视觉特征更多“话语权”。5.4 现象光流特征计算非常慢一个视频片段跑了十几分钟原因calcOpticalFlowFarneback是稠密光流算法对每一帧全分辨率计算光流计算量巨大。数据集的视频如果帧率很高例如200fps的CASME II整段视频有几百帧慢慢算就会等到天荒地老。解决先把frames列表通过np.linspace抽到16帧再做光流计算同时把帧尺寸从原来的高分辨率压缩到112x112再算。这两步能让计算时间缩短一个数量级以上对特征质量几乎没有影响。5.5 现象压缩包在别人电脑上跑不起来报错找不到模块原因最常见的是路径问题。代码里写的路径是“C:/Users/xxx/...”换个环境解压到别的路径就崩了还有一类问题是环境依赖缺失requirements.txt没写全。解决所有路径用相对路径以项目根目录为基准requirements.txt列出关键依赖README里写清楚运行前需要下载的数据以及放置位置。6. 让两个任务共用一个特征提取器一个值得尝试的进阶实验如果你想把这份大作业从“能跑”提升到“有亮点”一个值得投入的进阶方向是让两个任务共享底层的图像特征提取部分。看图说话的编码器用的是CNN微表情识别如果是基于帧序列的模型其帧级特征提取也可以用CNN。两者在“图像特征提取”层面是完全同构的。具体做法是用看图说话里预训练好的ResNet把它的卷积层作为微表情识别模型的主干然后接上时序建模层。这里能借到的最大好处是ResNet在ImageNet上学习到的通用视觉特征——边缘、纹理、形状——对微表情这种微弱变化同样有效。你不必从头在一个只有几百个样本的微表情数据集上训练视觉特征只需要微调后面几层。# 用预训练ResNet作为共享特征提取器 import torchvision.models as models import torch.nn as nn class SharedFeatureExtractor(nn.Module): def __init__(self, pretrained_pathNone): super().__init__() resnet models.resnet18(pretrainedFalse) if pretrained_path: resnet.load_state_dict(torch.load(pretrained_path)) # 去掉最后的全连接层保留卷积特征 self.features nn.Sequential(*list(resnet.children())[:-2]) def forward(self, x): return self.features(x) # 输出 (B, 512, H, W) 特征图做这个尝试时有一个经验值得注意共享特征提取器后两个任务最好交替训练而不是先完整训练看图说话再训练微表情识别。交替训练能让共享层同时适应两个任务的梯度需求但要注意学习率必须比单独训练时调得更小否则共享层很容易被后训练的任务带偏。如果你只有一周时间我建议把你的精力按这个比例分配百分之三十跑通基线百分之四十留给坑——调参、修bug、处理数据百分之二十做消融实验证明“去掉注意力机制分数确实掉了”最后百分之十写README和整理演示截图。我的习惯是永远在交付前留一个晚上只做一件事从零开始按README跑一遍完整流程确保压缩包换台电脑也能复现。这个习惯帮我避免了至少三次期末翻车希望也能帮到你。本文还有配套的精品资源点击获取