简介:这是一套基于摄像头输入的智能测谎软件源代码,面向对计算机视觉与情感计算感兴趣的学习者和开发者,借助 MediaPipe 实现面部与手部关键点检测,并结合情感识别技术完成心率监测等分析功能,可用于课堂演示、项目实训或相关课题的原型验证。资源包共 13 个文件,约 1.55MB,包含 3 个 Python 脚本(主程序、启动器与日志查看器)、3 张 png 示意图、environment.yml 与 requirements.txt 环境配置、config.ini 参数文件、README 说明文档及 LICENSE 等,结构紧凑、开箱即用。已有 130 人学习下载。读者可获得完整可运行的项目源码与使用说明,支持视频设备、文件、屏幕区域三种输入源,可显示面部与手部关键点、录制带时间戳的 AVI 视频、设置线索持续帧数并同时接入次要视频设备,还能通过图形界面启动器或命令行灵活运行,便于快速理解测谎与情感识别流程并二次开发。
1. 摄像头测谎这件事,到底靠不靠谱
先说结论:基于普通摄像头的测谎软件,不是玄学,但也绝对不是「看一眼就知道你说没说谎」的黑科技。它的本质是把几路生理与行为信号做融合推断——面部关键点微动、眨眼频率、头部姿态、以及通过远程光电容积脉搏波(rPPG)估算的心率变异性。标题里提到的 MediaPipe 负责前半段,情感识别模型负责中间的情绪维度,心率监测负责后半段的自主神经线索。三者拼起来,才构成一个可落地的「非接触式欺骗线索检测」原型。
它适合谁?做安防问询辅助的、做人机交互研究的、做心理实验数据采集的、以及想拿它当计算机视觉综合练手项目的开发者。不适合谁?想拿它当法庭证据的、想用它替代专业测谎仪的。普通摄像头受光照、运动、压缩噪声影响极大,信号质量决定了这套东西的上限。我一般会把它定位成「线索提示工具」,而不是「判决工具」。下面从原理到代码,把这条链路拆开讲清楚。
2. 信号链路拆解:MediaPipe 关键点、情感维度与 rPPG 心率
2.1 为什么选 MediaPipe 而不是自己训关键点模型
面部测谎的第一环是稳定拿到面部关键点。常见做法有两种:一是自己用标注数据训一个回归网络,二是直接用现成的关键点检测库。我一般会选 MediaPipe Face Mesh,原因是它在 CPU 上就能跑到实时,468 个三维关键点覆盖了眼周、嘴角、眉弓这些和微表情强相关的区域,而且跨平台部署省事。自己训模型的问题在于标注成本高、泛化差,除非你有特定场景的大规模数据,否则不划算。
关键点拿到之后,真正有用的不是绝对坐标,而是相对位移和区域形变。比如眼睑开合度(EAR)、嘴部开合度(MAR)、眉毛到眼睛的垂直距离变化。这些量对光照的鲁棒性比原始像素强得多。MediaPipe 输出的归一化坐标直接可以做差分,省掉了对齐这一步的大部分麻烦。
2.2 情感识别在这里扮演什么角色
情感识别不是用来判断「他紧不紧张所以他在说谎」这种粗暴逻辑的。它的价值在于提供情绪基线。一个人在正常回答时的情绪分布,和在被问到关键问题时的情绪分布,两者的偏移量才是线索。常见做法是用一个轻量分类头输出效价(正负)和唤醒度(高低)两个维度,而不是七类离散情绪。维度模型对连续变化的刻画更细,也更容易和心率信号做时间对齐。
需要强调的是,情感识别模型的输出必须做滑动窗口平滑,否则单帧抖动会让你误判。我一般用 1.5 秒窗口、步长 0.3 秒做均值滤波,再算一阶差分看突变。
2.3 rPPG 心率监测的最小实现
rPPG 的核心思路是:皮肤颜色会随血液容积变化产生极微弱的周期性波动,这个波动在绿色通道最明显。从面部 ROI 提取像素均值序列,做带通滤波,再找频域峰值,就得到心率估计。下面是一个可跑的最小实现。
import cv2 import numpy as np from scipy.signal import butter, filtfilt, welch # 带通滤波:保留 0.7Hz~4Hz,对应 42~240 BPM def bandpass(sig, fs, low=0.7, high=4.0, order=4): nyq = fs / 2.0 b, a = butter(order, [low / nyq, high / nyq], btype='band') return filtfilt(b, a, sig) def estimate_hr(green_series, fps): # green_series: 每帧 ROI 绿色通道均值 sig = np.array(green_series, dtype=np.float64) sig = sig - np.mean(sig) # 去直流 sig = bandpass(sig, fps) # 带通 freqs, psd = welch(sig, fs=fps, nperseg=min(256, len(sig))) band = (freqs >= 0.7) & (freqs <= 4.0) if not np.any(band): return None peak = freqs[band][np.argmax(psd[band])] return peak * 60.0 # 转 BPM逻辑说明:先去直流消除亮度漂移,再带通滤掉呼吸和运动伪影,最后用 Welch 功率谱找主频。参数说明:low=0.7对应 42 BPM 下限,high=4.0对应 240 BPM 上限,覆盖绝大多数静息心率;nperseg控制频率分辨率,窗口太短峰值会糊。ROI 建议取额头或脸颊,避开眼睛和嘴,因为眨眼和说话会污染信号。
2.4 三路信号怎么对齐
关键点、情感、心率三路信号的采样率不同:关键点每帧都有,情感可能每 0.3 秒一次,心率需要至少 5 秒窗口才稳定。对齐方式是统一到时间戳,用最近邻插值把低频信号上采样到帧率,再做窗口聚合。这一步不做,后面融合就是错的。
3. 从零跑通:环境、代码结构与关键参数
3.1 环境依赖与目录结构
依赖不多,核心是 mediapipe、opencv-python、numpy、scipy。情感识别部分可以用 ONNX Runtime 加载一个轻量模型,避免装重型框架。
pip install mediapipe opencv-python numpy scipy onnxruntime目录我一般这样组织:capture/放摄像头读取,features/放关键点和 rPPG 提取,emotion/放情感推理,fusion/放融合与打分,config.yaml放所有阈值。这样调参不用翻代码。
3.2 面部 ROI 与关键点提取
import mediapipe as mp import cv2 mp_face = mp.solutions.face_mesh face_mesh = mp_face.FaceMesh( max_num_faces=1, refine_landmarks=True, # 启用虹膜关键点,眼动更准 min_detection_confidence=0.5, min_tracking_confidence=0.5 ) # 额头 ROI 索引(Face Mesh 中额头区域近似点) FOREHEAD_IDX = [10, 338, 297, 332, 284, 251, 21, 54, 103, 67, 109] def get_forehead_roi(frame, landmarks): h, w = frame.shape[:2] pts = [(int(landmarks[i].x * w), int(landmarks[i].y * h)) for i in FOREHEAD_IDX] x, y, bw, bh = cv2.boundingRect(np.array(pts)) return frame[y:y+bh, x:x+bw]逻辑说明:refine_landmarks=True会额外输出虹膜点,对眨眼和注视估计有帮助。ROI 用凸包外接矩形近似,实际用的时候建议再向内收缩 10%,避开发际线边缘。参数说明:min_detection_confidence调低会更容易检出但误检增多,室内稳定光照下 0.5 够用。
3.3 心率估计的窗口与刷新策略
rPPG 不能每帧都出结果,需要滑动窗口。我一般用 6 秒窗口、每 1 秒刷新一次,这样既有足够频率分辨率,又不会太滞后。
from collections import deque WINDOW_SEC = 6 FPS = 30 green_buf = deque(maxlen=WINDOW_SEC * FPS) def update_hr(frame, landmarks): roi = get_forehead_roi(frame, landmarks) if roi is None or roi.size == 0: return None green_mean = np.mean(roi[:, :, 1]) # 绿色通道 green_buf.append(green_mean) if len(green_buf) < WINDOW_SEC * FPS: return None return estimate_hr(list(green_buf), FPS)参数说明:窗口太短(小于 4 秒)频率分辨率不够,峰值会漂;太长(大于 10 秒)跟不上心率变化。6 秒是实测比较稳的折中。刷新间隔 1 秒是为了给融合层留出反应时间。
3.4 情感维度推理与平滑
import onnxruntime as ort sess = ort.InferenceSession("emotion_dim.onnx") def infer_emotion(face_bgr): inp = cv2.resize(face_bgr, (112, 112)).astype(np.float32) / 255.0 inp = np.transpose(inp, (2, 0, 1))[None, ...] valence, arousal = sess.run(None, {sess.get_inputs()[0].name: inp}) return float(valence), float(arousal) # 滑动平均 emo_buf = deque(maxlen=5) def smooth_emotion(v, a): emo_buf.append((v, a)) mv = np.mean([x[0] for x in emo_buf]) ma = np.mean([x[1] for x in emo_buf]) return mv, ma逻辑说明:输入做归一化和通道转换,输出两个标量。平滑窗口 5 帧对应约 0.5 秒,能压掉单帧噪声又不至于太钝。参数说明:如果模型输出是 logits,记得先过 sigmoid 或 softmax。
3.5 融合打分与阈值设定
融合层把三路特征拼成一个向量:心率相对基线的偏移、情感效价/唤醒度的突变、关键点区域的微动能量。用一个简单的加权和或逻辑回归打分,输出 0~1 的「线索强度」。
def fusion_score(hr_delta, emo_shift, micro_motion): # 权重来自小规模标定,可按场景调 w = np.array([0.4, 0.35, 0.25]) x = np.array([hr_delta, emo_shift, micro_motion]) x = np.clip(x, 0, 1) return float(np.dot(w, x))参数说明:三个权重之和为 1,hr_delta是当前心率相对个人基线的归一化偏移,emo_shift是情绪维度的一阶差分模长,micro_motion是关键点区域的光流能量。阈值我一般设在 0.6 以上才提示,低于这个值只记录不报警,避免误报疲劳。
4. 避坑与排查:那些让原型翻车的细节
4.1 心率读数乱跳,像随机数
现象:BPM 在 50 到 150 之间乱蹦。原因:ROI 里混入了非皮肤区域,或者光照频闪导致绿色通道被调制。解决:先把 ROI 向内收缩并做肤色掩膜,再检查摄像头是否在 50/60Hz 光源下出现工频干扰,必要时在带通里加一个 50Hz 陷波。另外确认窗口长度够 6 秒,太短必跳。
4.2 关键点抖动导致微表情误判
现象:人没动,但微动能量一直偏高。原因:MediaPipe 在低对比度或侧脸时关键点会抖。解决:对关键点序列做卡尔曼或简单指数平滑,再算差分。平滑系数 0.3~0.5 之间试,太大会吃掉真实微动。
4.3 情感模型输出恒定值
现象:valence 和 arousal 几乎不变。原因:输入预处理和训练时不一致,比如训练用了 RGB 而推理用了 BGR,或者归一化范围不对。解决:核对训练脚本的预处理,确保通道顺序和缩放一致。这个坑我踩过,查了半天以为是模型坏了。
4.4 融合分数一直偏高
现象:正常聊天也频繁触发提示。原因:基线没做个人化。每个人的静息心率和情绪表达强度不同,用统一阈值必然误报。解决:开始前先录 30 秒静息基线,后续所有特征都减基线再归一化。
4.5 帧率不稳导致时间对齐错位
现象:心率窗口和情感窗口对不上,融合结果忽高忽低。原因:摄像头实际帧率不是恒定 30,可能掉到 22。解决:用真实时间戳而不是帧计数来驱动窗口,time.time()打点,按秒聚合。
5. 进阶技巧:把线索强度做成可回看的时序证据
到这一步,原型能出分了,但真正有价值的是把分数变成可回看的时序证据。我一般会做两件事:一是把每路特征和融合分数按时间戳写进 CSV,二是画一张多轨时序图,横轴时间,纵轴分别是心率偏移、情绪偏移、微动能量和融合分。这样回看的时候能定位到具体哪一秒出现了异常,而不是只看到一个孤零零的数字。
import csv, time def log_row(writer, hr_delta, emo_shift, micro_motion, score): writer.writerow([ round(time.time(), 3), round(hr_delta, 4), round(emo_shift, 4), round(micro_motion, 4), round(score, 4) ]) with open("session_log.csv", "w", newline="") as f: w = csv.writer(f) w.writerow(["ts", "hr_delta", "emo_shift", "micro_motion", "score"]) # 在主循环里调用 log_row(w, ...)参数说明:时间戳用秒级浮点,方便和外部事件对齐;所有特征保留四位小数,够用又不至于文件爆炸。回看时重点看融合分上升前 2~3 秒哪一路先动,那一路往往就是主导线索。
还有一个技巧是加一个「基线漂移补偿」。长时间录制时,人的静息心率会缓慢变化,如果基线一直用最开始那 30 秒,后面会整体偏移。我一般每 5 分钟用最近 60 秒的静息段更新一次基线,更新时做限幅,单次调整不超过 5 BPM,防止被异常段带偏。
验证方法上,别指望一次就准。我习惯用「已知脚本」做自测:让同一个人按预设回答真话和假话各若干轮,看融合分在假话段的分布是否整体右移。如果分布重叠严重,先别调融合权重,回去查 rPPG 和关键点质量,八成是前端信号不行。前端不行,后端怎么调都是玄学。
最后说个血泪经验:这套东西最大的敌人不是算法,是光照和摄像头位置。我见过太多人花一周调模型,最后发现是顶灯频闪导致心率完全不可用。先把采集环境固定下来,再谈算法优化。希望帮到你。
本文还有配套的精品资源,点击获取