简介这是一份面向人工智能导论课程学习者的看图说话与微表情识别综合实践资源适合需要完成图像描述和人脸情绪识别实验的高校学生及计算机视觉入门者。资源包共9个文件包含3个Jupyter Notebook源码含checkpoint版本及Colaboratory使用指引、2个Markdown说明文档、1篇课程论文报告文档、1个人脸检测级联分类器XML及许可证等文件总大小仅5.78MB结构清晰便于按需查阅。已有338人浏览学习。整套资料基于TensorFlow和Keras实现可帮助读者理解图像描述caption生成模型的搭建流程同时借助Haar Cascade完成人脸检测并对多类情绪进行识别报告与代码结合呈现既可作为课程论文写作参考也能直接运行复现实验其中人脸检测XML分类器可直接调用方便在课程设计中快速复用适合作为人工智能导论大作业的完整方案。1. 一份「人工智能导论」大作业 ZIP里面装着什么每年期末人工智能导论的作业群里都会准时出现一个名为“看图说话与微表情识别.zip”的压缩包。下载、解压、跑起来这三步看着简单却常让人在第一晚就卡住代码路径带中文、训练脚本要改六个参数、跑一个 epoch 要半小时。这道题真正考察的是同一个视觉基础如何在两类任务里被复用——一方面是从图像生成自然语言另一方面是从短视频里判断情绪类别。对刚入门的人来说它是把卷积网络、循环结构、数据增强串起来的一次完整练习对工作多年的工程师它反而是重新从解压、配路径这类脏活开始的好机会。把它当成一条压缩过的学习路径比当成一次作业更有意思。2. 拿到 ZIP 先别急着训练解压、伪加密与目录规划先说解压。这个环节大部分人都不会觉得有技术含量但大作业 zip 里最常出现的三个问题——中文文件名乱码、伪加密、路径写死——全在这几十秒里发生。我一般会先看压缩包内容再解压而不是双击或者直接unzip。一是防止脚本把文件撒得满目录都是二是提前确认里面有没有模型权重文件因为动辄几百 MB 的东西解压到临时目录会很快把磁盘占满。2.1 Linux 与 Windows 下解压 .zip 的稳妥命令以及中文乱码处理先用列表模式看一眼压缩包里有什么再决定解压到哪个目录# 先列出内容重点看文件数量和是否包含 checkpoint unzip -l 人工智能导论_看图说话与微表情识别.zip | head -40 # 常规解压到指定目录 unzip -q 人工智能导论_看图说话与微表情识别.zip -d ./ai_courseunzip -l用了head -40是因为压缩包可能包含上千个图片文件只取前 40 行足够判断结构-q是安静模式避免解压几百个文件时刷屏-d ./ai_course指定解压目录避免文件散落在当前路径。如果 Linux 下发现解压出的中文文件名全是乱码通常是因为 Windows 压缩时用了 GBK 编码而系统按 UTF-8 解码。较新版本的 Info-ZIP 支持-O GBK不支持时改用 7-Zip7z x 人工智能导论_看图说话与微表情识别.zip -o./ai_course注意 7-Zip 的-o参数后面不能加空格这是它的命令行惯例。若两条路都走不通用 Python 标准库zipfile手动按字节流重新解码import os import zipfile SRC 人工智能导论_看图说话与微表情识别.zip DST ai_course os.makedirs(DST, exist_okTrue) with zipfile.ZipFile(SRC) as zf: for info in zf.infolist(): zf.extract(info, DST) # 先按原条目解出 raw info.filename.encode(cp437) # zip 内部常按 cp437 存文件名 try: real raw.decode(utf-8) except UnicodeDecodeError: real raw.decode(gbk) src_path os.path.join(DST, info.filename) dst_path os.path.join(DST, real) if src_path ! dst_path: os.renames(src_path, dst_path)这段代码先把每个文件解压出来再根据 ZIP 规范里文件名默认的 CP437 编码还原原始字节依次尝试 UTF-8 和 GBK。os.renames会在目标路径包含多级子目录时自动补齐目录比os.rename更稳。如果机器完全离线可以先在有网环境执行apt download p7zip-full拿到 deb 包再拷入安装或者干脆依赖 Python 标准库不用系统命令。2.2 解压后先看目录骨架别急着打开报告 PPT这类作业包一般长这样目录/文件常见内容需要动哪里data/图片、视频帧、annotations.csv重点核对路径和标签格式src/或model/模型定义、训练脚本改参数别先改结构checkpoints/预训练权重、训练中间结果确认与代码版本匹配reports/报告、PPT、答辩材料只看不动最容易被忽略的是data/下的路径。很多脚本写死的是/home/student/data这样的绝对路径换机后直接报文件不存在。常见的做法是把数据路径抽到配置文件里例如configs/caption.yamldata: image_root: ./data/images ann_file: ./data/annotations/captions.json train: batch_size: 32 epochs: 20路径写相对路径比写绝对路径好保证压缩包换目录后不用改第二处。检查标签格式时随手head -5 annotations.json看一眼字段名通常能避免后面KeyError带来的三小时困惑。2.3 zip 伪加密的判别与清除一个几百行的小脚本能省下一晚解压时如果弹窗要密码而你确定原始文件没加密大概率遇到的是 zip 伪加密。这是把 local file header 和 central directory 里的通用位标志第 0 位手工置 1文件体本身并没有加密。7-Zip 这类解压器看到标志位就会要求输入密码但用 Python 可以直接绕过import zipfile SRC 人工智能导论_看图说话与微表情识别.zip DST fixed.zip def is_fake_crypt(zip_path): with zipfile.ZipFile(zip_path) as zf: return all(bool(i.flag_bits 0x1) for i in zf.infolist()) def strip_fake_crypt(src, dst): zin zipfile.ZipFile(src) zout zipfile.ZipFile(dst, w, zipfile.ZIP_DEFLATED) for item in zin.infolist(): if item.flag_bits 0x1: item.flag_bits 0xFFFE # 只清第 0 位加密标志 zout.writestr(item, zin.read(item.filename)) zout.close() if is_fake_crypt(SRC): strip_fake_crypt(SRC, DST) print(fixed -, DST) else: print(not fake crypt)判断逻辑就是检查每个条目的flag_bits是否都带0x1修复时把它与0xFFFE按位与清零加密位后重新写入新 zip。如果文件是真的加密zin.read会抛RuntimeError说明这条路走不通别去碰来路不明的加密资源。压缩包问题解决后才轮到真正的模型代码。3. 看图说话从 CNN 特征到束搜索把训练命令跑通看图说话不是把图像扔进分类器输出一个标签而是输出一句通顺的自然语言描述。课程场景里常见做法是编码器-解码器结构图像进 CNN 变成特征语言模型按词逐个生成前一个词是后一个词的条件。这两部分可以分开预训练也可以端到端一起微调但显存有限时通常先冻结编码器。3.1 图像编码——为什么课程作业常用 ResNet101 而不是更大的模型编码器的作用是压缩视觉信息。ResNet101 去掉最后的全连接层后对一张 224×224 的图输出 7×7×2048 的特征图如果走平均池化就降成一个 2048 维向量。这个向量或特征图就是解码器看到的“画面”。编码器输出特征显存开销课程场景判断ResNet502048 维或 7×7×2048中等最稳妥训练快ResNet101同上通道仍 2048偏高数据量够时精度略好ViT-B/16196×768 的 patch 序列高数据少时容易欠拟合ViT 把图像切成 16×16 的 patch输出 196 个 token天然适合接 Transformer 解码器但 196 个 token 的自注意力计算量比 CNN 的 7×7 特征图大很多微表情那种千级样本量没法得到太多收益。所以作业场景一般选 ResNet101 作为编码器用 ImageNet 预训练权重初始化而不是从零训。3.2 解码器与束搜索——生成句子的地方最容易死循环解码器逐词生成时训练阶段用 teacher forcing把真实词作为下一步输入收敛快推理阶段只能自回归把上一步生成结果喂回去。一条句子生成得对不对劲很大程度取决于束搜索和结束符处理。束搜索不是全局最优解它每步只保留概率最高的 beam 个候选用对数概率累加避免连乘下溢import torch import torch.nn.functional as F def beam_search_decode(model, image_feat, word2idx, idx2word, beam3, max_len32): start torch.tensor([[word2idx[sos]]], deviceimage_feat.device) seqs [(0.0, start)] # (累计对数概率, 已生成序列) for _ in range(max_len): new_seqs [] for score, seq in seqs: logits model.decode(image_feat, seq) # (1, seq_len, vocab) log_probs F.log_softmax(logits[:, -1, :], dim-1) top_scores, top_ids torch.topk(log_probs[0], beam) for s, tid in zip(top_scores, top_ids): new_seq torch.cat([seq, tid.view(1, 1)], dim1) new_seqs.append((score s.item(), new_seq)) seqs sorted(new_seqs, keylambda x: x[0], reverseTrue)[:beam] return seqs[0][1]这段代码顶掉了真正的“长度归一化”和“EOS 停止判断”实操中必须补上否则模型会倾向于输出短句也可能一路生成到max_len才被截断。model.decode(image_feat, seq)是示意接口实际项目里通常传整段序列做一次前向然后只取最后一个时间步每步重新算全部历史虽然慢但代码简单课程作业完全够用。3.3 最小训练命令与三个必调参数训练脚本的入口长这样python train_caption.py \ --config configs/caption.yaml \ --encoder resnet101 \ --decoder_lr 1e-4 \ --encoder_lr 1e-5 \ --batch_size 32 \ --grad_accum 4 \ --epochs 20 \ --max_len 32三个容易忽略的点第一encoder_lr要比decoder_lr低一个数量级预训练 backbone 已经收敛微调幅度一大就忘掉原有特征第二grad_accum 4让有效 batch size 变成 128显存不够时靠梯度累积补偿而不是直接调小 batch 导致 BN 统计抖动第三max_len 32必须显式给否则词表里若缺 EOS模型会无限生成。验证时用 BLEU-4 和 CIDEr 看句子质量python eval_caption.py \ --checkpoint checkpoints/best.pt \ --split val \ --beam_size 3 \ --length_penalty 1.0 \ --metric bleu4,ciderbeam_size 3通常比 greedy 解码提升明显再往上收益递减length_penalty大于 1 时会偏向长句小于 1 偏向短句先固定 1.0 再看输出。4. 微表情识别帧预处理、小样本训练和类别不平衡的通用方案微表情识别与看图说话的难点方向相反看图说话要处理长尾词汇和句子结构微表情要处理的是“信息太薄”。普通表情有夸张的嘴型和眼型变化微表情持续只有几帧且幅度很小公开数据集全部样本加起来可能不到一千段。这决定了你没法直接照搬一个大分类网络每一步都要量体裁衣。4.1 为什么不直接套普通表情识别——数据规模与时序长度主流公开数据集如 CASME II、SAMM、SMIC样本量都是百级到几百级标签来自脸部动作单元 AU 或情绪类别。视频帧率不低但微表情持续时间通常不到一秒真正有用的帧可能只有几帧到十几帧。模型选型上ResNet18 或 MobileNetV3 这类轻量 backbone 足够没必要上 ResNet50 以上的大网络方案输入参数量/开销适合场景ResNet18 GRU帧特征序列小样本少的课程作业MobileNetV3 时序卷积逐帧 CNN 特征更小低显存环境3D-CNN16 帧片段直接输入大数据充足时VideoMAE 预训练 微调视频 token最大显存充足且有权重4.2 分帧、人脸对齐与光流三步预处理视频不能整段直接进模型。要先把人脸裁出来固定成大小时序片段再决定用原始灰度帧还是光流。人脸对齐很关键因为头部轻微晃动产生的光流噪声会淹没真正的肌肉运动import cv2 import numpy as np def load_frames(video_path, target_len16, size(112, 112)): cap cv2.VideoCapture(video_path) frames [] while True: ok, frame cap.read() if not ok: break face align_face(frame) # 人脸检测对齐项目里一般是封装函数 if face is not None: gray cv2.cvtColor(face, cv2.COLOR_BGR2GRAY) gray cv2.resize(gray, size) frames.append(gray) cap.release() if len(frames) 0: raise ValueError(fno frames in {video_path}) idx np.linspace(0, len(frames) - 1, target_len).astype(int) clip np.stack([frames[i] for i in idx]) # (16, 112, 112) return clip.astype(np.float32) / 255.0align_face是占位函数实际可以用人脸关键点检测拿到双眼位置后做仿射变换。np.linspace等间隔取 16 帧比从开头硬截 16 帧更能保留完整的表情启动和高峰过程。如果视频不足 16 帧通常做循环或插值补齐别直接 resize 整帧否则模型学到的是背景和头发。光流是另一个常见输入prev clip[0] flows [] for i in range(1, len(clip)): flow cv2.calcOpticalFlowFarneback( prev, clip[i], None, 0.5, 3, 15, 3, 5, 1.2, 0) flows.append(flow) # (112, 112, 2) prev clip[i] flows np.stack(flows) # (15, 112, 112, 2)calcOpticalFlowFarneback返回 H、V 两个方向的光流场拼成两通道输入。光流能放大微小的肌肉位移比原始灰度帧更突出动作单元变化这是微表情领域的常见做法代价是计算时间上涨。4.3 模型结构与防止过拟合的四个设置特征提取部分用 ResNet18 逐帧过一遍拿到 16 个帧特征时序部分接一个 GRU隐层 256输出用最后时间步或全部隐层的平均池化再接分类头。训练命令python train_micro.py \ --dataset casme2 \ --backbone resnet18 \ --frame_len 16 \ --seq_model gru \ --hidden 256 \ --dropout 0.5 \ --lr 5e-4 \ --weight_decay 1e-4 \ --epochs 60防止过拟合的四个设置dropout 0.5放在 GRU 输出和分类头之前weight_decay 1e-4限制 backbone 权重的更新幅度训练时做随机时移每轮从视频不同位置取 16 帧窗口类别不平时给 loss 加权重或者对正样本过采样。评估用留一被试者交叉验证比固定划分更可信因为同一个人的录制环境、摄像头位置高度相似固定划分容易高估模型能力。指标要看 macro-F1不能只看 accuracy否则类别少的那几类被忽略也不自知。5. 训练不出好结果先查显存、束搜索和类别分布这三个参数面训练跑了一晚第二天起来发现 loss 下降但没有收敛或者一推理就输出乱码这类问题集中在三个地方显存爆不爆、句子生成有没有死循环、分类结果是不是被多数类吃掉。逐个排查比重新调参更有效率。5.1 CUDA out of memory 与 batch size、序列长度、图像分辨率的三角关系显存占用大致由三部分组成backbone 的特征图、解码器/时序模块的中间状态、优化器维护的梯度动量。改动其中一个参数影响面完全不同改动显存影响训练时间影响batch_size减半几乎线性减半几乎不变max_len从 32 降到 16解码部分减少明显加速图像从 224 降到 112编码器大幅减少明显加速遇到 OOM 最优先降batch_size然后用梯度累积找回有效 batch。混合精度是另一个常用手段scaler torch.cuda.amp.GradScaler() with torch.cuda.amp.autocast(): out model(feat, caption) loss criterion(out, target) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update()autocast让前向计算自动把部分算子切到 FP16显存占用接近减半GradScaler负责把 loss 放大再缩小梯度避免 FP16 下小梯度被清零。注意优化器要配合使用否则梯度更新异常。5.2 看图说话全输出空句子或重复死循环——先查 EOS 和长度惩罚输出全空先看词表里eos是否存在以及它对应的 id 是不是 0。有些词表把pad放在 0eos放 1程序里写死了vocab[]就会取到 pad。训练阶段如果大量样本没有正确学习到在句尾输出 EOSbeam search 会一直生成到max_len截断看起来像超长句——实际是“没学会停”。重复死循环另一个常见原因是词频过滤设太低min_count如果设成 1测试集出现大量unk模型只能反复输出高频词。先把词表里出现次数小于 5 的词统一映射成unk再检查生成的句子是否还有连续重复三元组。5.3 微表情结果全是“平静”类——从混淆矩阵开始查微表情数据集里中性帧往往远多于真正的微表情片段正负比可能接近 1:10模型学到最后就是把所有样本判成多数类。先打印分类报告python eval_micro.py --checkpoint checkpoints/best.pt --split test输出里看每一类的 precision 和 recall。如果某一类 recall 接近 0优先用sklearn检查混淆矩阵确定是被谁吃掉。然后做两件事第一loss 里加类别权重例如torch.nn.CrossEntropyLoss(weightclass_weight)第二在数据加载阶段对少数类做随机时移把一段 1 秒的视频按不同起始帧切成多个片段正样本量可以翻好几倍。6. 统一推理脚本把两个任务接到一条命令下的验证技巧课程作业到后期往往有两套代码、两种输入格式、两个 checkpoint答辩演示时来回切换很费劲。常见做法是做一个统一入口infer.py用--task区分看图说话和微表情识别输出统一的 JSON这样验证脚本、答辩 demo、自动评分都能用同一条链路python infer.py \ --task caption \ --config configs/caption.yaml \ --checkpoint checkpoints/caption_best.pt \ --input data/sample.jpg \ --output out.json python infer.py \ --task micro \ --config configs/micro.yaml \ --checkpoint checkpoints/micro_best.pt \ --input data/sample.mp4 \ --output out.json输出结构保持一致只看result字段就能比对{ task: caption, input: data/sample.jpg, result: a man is sitting on the grass with a laptop, confidence: 0.87 }微表情任务则把result换成disgust、surprise这类标签。两套模型的前处理差很多但后处理可以共用一段代码看图说话做束搜索解码微表情做 softmax 输出 top-1。把数据加载、前处理、模型推理拆成三个函数--task只切换前两个验证时就可以写一个metrics.py统一读out.jsoncaption 算 BLEU-4/CIDEr微表情算 macro-F1。这样跑实验时只改配置文件不动入口代码最后把这两条命令连同输出样例写进 README答辩时打开终端敲一遍比翻 PPT 更有说服力。本文还有配套的精品资源点击获取
