简介:这是一套面向嵌入式开发与智能硬件初学者的红外人脸测温系统完整实现方案,融合STM32底层驱动、OpenCV人脸检测与QT跨平台上位机开发,解决非接触式体温筛查场景下的实时测温、异常告警与图像留存需求。资源包共687个文件,涵盖191个C++头文件(hpp/h)用于算法与通信模块封装、70个C源文件(c)实现STM32外设驱动、68个动态链接库(dll)支撑OpenCV 3.4.7核心功能(如imgproc、dnn、calib3d等),以及16个可执行文件(含主程序FaceTemperatureCheck.exe)和配套UI资源(ui/qrc/ico)、语音提示(wav)、Haar级联分类器(xml)与使用文档(pdf/txt),整体压缩包大小为76.66MB。已有1016人学习下载,提供开箱即用的可执行程序、完整源码工程(含uvprojx项目文件)、串口通信协议说明及人脸检测+温度联动逻辑实现细节,特别适合课程设计、毕业设计或IoT项目快速原型开发。 把一块STM32单片机、一颗MLX90640红外热像传感器和一个QT写出来的上位机拼在一起,做出一台能实时捕捉人脸并显示额头温度的设备,是我这段时间一直在折腾的项目。这东西最终的效果是:对着摄像头坐好,电脑屏幕上会框出人脸,右下角直接跳出温度读数,整体精度能控制在±0.3℃以内。整条链路从单片机I2C读取温度矩阵,到串口协议上传,再到上位机做人脸检测和热力图绘制,每个环节都有不少值得记录的取舍和坑点。这篇文章就把这套基于STM32的红外人脸测温仪的完整实现思路、关键代码片段、标定流程和调试心得整理出来,给准备做类似项目的朋友一个参考。
这套系统最适合两类人:一类是正在做单片机课设或电子设计竞赛题目,需要把下位机和上位机串起来做展示的学生;另一类是打算在工业巡检、门禁考勤或小型门店入场测温场景里做个快速原型的技术爱好者。如果你手里已经有一块STM32开发板,又愿意花点时间在PC端写QT程序,这篇文章能帮你少走很多弯路。
1. 项目整体设计:为什么我选了MLX90640加QT,而不是直接买测温枪
1.1 需求到底是什么
先说清楚这个设备要解决的实际问题。普通的红外测温枪只能手动瞄准,测的是一个小圆点区域,如果要对准额头还得靠人自己调整距离和位置。而红外人脸测温仪则要解决两个核心问题:第一,自动找到人的脸在哪里;第二,从脸部对应的红外区域里取出一个稳定的、能代表体温的数值。这两个问题如果分开做都不难,但合在一起就有意思了——可见光摄像头负责找人脸,红外传感器负责测温度,两者必须同步配合,否则测的温度点很可能是背景墙或者衣领。
我最初的需求很简单:在PC端看到一个实时热力图,画面中能框出人脸,并且自动显示面部最高温度或平均温度。没有做考勤、记录等附加功能,先把核心链路跑通。
1.2 方案选型对比:从单点测温到热像仪
市面上常见的红外测温传感器大概分三类:
- MLX90614:单点红外测温传感器,只能测一个点的温度,价格便宜,适合做测温枪。但它没有图像信息,无法区分被测物体和背景。
- AMG8833:8x8像素的红外热像传感器,分辨率太低,勉强能看到热源轮廓,但脸部细节几乎没有,测温意义不大。
- MLX90640:32x24像素红外热像传感器,分辨率在可接受范围内,已经能看出人脸的热量轮廓,是小型热像仪常用的方案。
我最终选了MLX90640,理由很直接:32x24像素虽然跟可见光摄像头没法比,但做额头区域的平均温度计算已经绰绰有余。而且MLX90640自带校准数据,每个像素都有独立的灵敏度修正,精度理论上能到±1℃,配合标定操作可以进一步提高。
1.3 人脸识别到底放在哪一端
刚开始纠结过一个问题:人脸识别是放在STM32上跑,还是放在QT上位机跑?
我查过一些STM32端跑轻量级人脸检测的方案,有在F407上部署TinyML模型的,也有外接K210模块的。但这些方案都要额外引入摄像头、模型文件系统、显示屏幕等一堆外设,而且在单片机上调模型推理速度容易翻车。我的项目目标是快速做出可用的测温仪,不是研究边缘端推理性能,所以最后决定把识别任务放在PC端的上位机里完成。
这样设计还有一个明显的好处:调试方便。人脸检测用的OpenCV DNN模型可以在PC上随便换,串口和热力数据可以通过软件模拟,不用每次改一点代码就重新编译下载STM32固件。STM32只负责稳定采集温度矩阵并上传,QT负责显示和计算,两者职责清晰,排查问题的时候不用来回猜。
1.4 系统架构总览
整个系统的数据流是这样的:
- STM32通过I2C接口读取MLX90640的1920个温度原始点(32x24)。
- STM32根据EEPROM中的校准参数把原始值换算成摄氏度,得到32x24的温度矩阵。
- STM32通过串口(UART)按自定义帧协议发送这768个温度值到PC。
- QT上位机通过QSerialPort接收数据,解析出温度矩阵。
- 同时,QT上位机通过OpenCV调用USB摄像头,用DNN人脸检测模型识别画面中的人脸框。
- 上位机将人脸框坐标映射到32x24的温度矩阵上,截取额头区域计算平均温度。
- 界面实时绘制伪彩色热力图,并把温度数据叠加显示在摄像头画面上。
硬件连接上,MLX90640是模块化的,引脚引出SDA、SCL、VCC、GND。我用的主控是STM32F407VET6,因为手头刚好有这个板子,实际上用F103也能驱动MLX90640,只要I2C速度能到1MHz左右就行。选F407的另一个原因是它自带硬件浮点单元,计算温度矩阵时用浮点数运算会快很多。
2. STM32下位机:MLX90640的I2C驱动、EEPROM校准与温度矩阵计算
2.1 硬件连接与引脚分配
MLX90640模块需要4根线,跟STM32开发板的连接方式如下:
| MLX90640引脚 | STM32F407引脚 | 说明 |
|---|---|---|
| VIN | 3.3V | 模块供电,注意MLX90640需要3.3V供电,不要接5V |
| GND | GND | 共地 |
| SCL | PB8 | I2C1时钟 |
| SDA | PB9 | I2C1数据 |
| VDD | 3.3V | 如果模块分开供电需要连接,多数模块与VIN共用 |
这里有个非常关键却被很多人忽略的点:MLX90640模块的SDA和SCL必须有上拉电阻。很多现成模块上装了4.7kΩ上拉电阻,但如果你自己画板子或者买的模块是裸板,一定要在I2C两条线上各接一个4.7kΩ电阻到3.3V,否则I2C通信会非常不稳定。我在调试时曾经因为用了杜邦线加裸传感器,结果数据间歇性读错,后来加上拉电阻后问题瞬间消失。
2.2 I2C时序与EEPROM参数加载
MLX90640的本质是一个挂在I2C总线上的从设备,芯片内部有两块存储区域:EEPROM和RAM。EEPROM里存放出厂校准数据,RAM里的0x0400到0x07FF地址存放当前的测量数据,也就是768个16位原始温度数据。
在读取新数据之前,需要先设置读取页码。MLX90640有page 0和page 1两页,每一页包含32行、24列中的一半像素,也就是说连续的1920个像素数据被分成两页存储。读取一帧完整图像时,要先把控制寄存器(地址0x8000)的page bit设为0,读取page0的像素,再把page bit设为1,读取page1的像素,然后合并成完整的32x24矩阵。
I2C读取EEPROM的代码逻辑大致是这样:
// 设置MLX90640的当前页 uint8_t reg_data[2] = {0x00, 0x00}; // 低字节,高字节,page=0 HAL_I2C_Mem_Write(&hi2c1, 0x33<<1, 0x8000, I2C_MEMADD_SIZE_16BIT, reg_data, 2, 100); // 读取page0的像素数据,共512个16位字 // 寄存器地址从0x0400开始,每个数据占2字节,高字节在前 HAL_I2C_Mem_Read(&hi2c1, 0x33<<1, 0x0400, I2C_MEMADD_SIZE_16BIT, frame_data, 1024, 100);注意:MLX90640的I2C地址是0x33,但HAL库中的地址要左移一位变成0x66,因为HAL的Mem_Read接口里地址参数是8bit从机地址加上R/W位后的值。如果你忘记左移,通信会直接超时。
2.3 MLX90640的温度换算公式
拿到原始16位数据后,还必须结合EEPROM中的参数才能换算成摄氏度。MLX90640的换算过程相当繁琐,官方提供了计算方法,我在代码里主要用了几个关键参数:
- Vdd:芯片供电电压,用于补偿电压波动。
- Ta:环境温度,由芯片内部热敏电阻测得。
- Kv、Kta:补偿系数。
- Alpha:每个像素的灵敏度系数。
- 各像素的偏移量、间距等。
实际开发时不需要自己从头实现完整算法,Melexis官方提供了一个名为MLX90640_API的C库,里面有MLX90640_CalculateTo()函数,直接输入EEPROM数据和RAM数据就能得到温度矩阵。我建议直接移植这个库,不要手工重写公式,因为里面涉及大量查找表和像素补偿,自己写容易漏项。
调用示例:
float_t emissivity = 0.98; // 人体皮肤发射率 float_t tr = 23.5; // 反射温度,即环境物体反射的红外温度 float_t emissivity_window = 1.0f; MLX90640_CalculateTo(frame_data, eeprom_data, emissivity, tr, emissivity_window, mlx90640_to, &temperature_data);这里的temperature_data就是32x24矩阵中每个像素的温度值,单位是摄氏度,类型是float。温度计算出来后,再通过串口发送给上位机。
2.4 原始数据处理与发送频率
MLX90640支持0.5Hz、1Hz、2Hz、4Hz几种刷新率,通过写控制寄存器0x8000里的刷新率位来设置。我最终用的是2Hz。为什么不是4Hz?因为4Hz模式下I2C读取时间会变得很紧张,而且STM32还同时要处理温度计算和串口发送,如果芯片散热量大,温度噪声也会增加。2Hz对于体温测量已经足够,人站在摄像头前短暂停留就能取到几帧有效数据。
每帧32x24矩阵共768个温度值,如果用float32直接发送,每帧数据量为3072字节。在460800波特率下传输大约需要53毫秒,完全来得及。我最后采用了压缩成int16的方案,温度值乘以100存储,一帧数据量降为1536字节,传输时间更短,而且上位机解析也更简单。
3. QT上位机的双线程架构:一边跑人脸检测,一边收串口数据
3.1 为什么必须用双线程
刚开始写QT上位机时,我把串口接收、摄像头读取、人脸检测和界面刷新全部塞在同一个线程里,结果发现界面卡成了PPT。原因很简单:OpenCV的DNN人脸检测在CPU上跑一帧大概需要100到200毫秒,这个期间串口数据会源源不断进缓冲区,UI线程被阻塞,画面无法刷新。
正确的做法是拆成两个线程:
- 工作线程A负责读串口、解析温度矩阵,通过信号槽把温度数据发送给主界面。
- 工作线程B负责读摄像头、做人脸检测,把检测到的人脸框通过信号槽发送给主界面。
- 主界面线程只做一件事:把收到的数据绘制出来。
在QT里不要直接用moveToThread去处理QSerialPort,因为QSerialPort的readyRead信号触发频率很高,如果处理不当依然会阻塞线程。更稳妥的做法是在一个独立的QThread对象里创建一个QSerialPort实例,并只在该线程内进行串口读写。
3.2 集成OpenCV人脸检测
我使用的是OpenCV的DNN模块,模型是res10_300x300_ssd_iter_140000_fp16.caffemodel,这个模型在CPU上跑一帧640x480的画面大概需要50到100毫秒,精度比Haar级联好很多,而且能抵抗一定程度的侧脸。
初始化代码:
cv::dnn::Net net = cv::dnn::readNetFromCaffe( "deploy.prototxt", "res10_300x300_ssd_iter_140000_fp16.caffemodel" );在检测线程的循环里:
cv::Mat frame; cap.read(frame); cv::Mat blob = cv::dnn::blobFromImage(frame, 1.0, cv::Size(300, 300), cv::Scalar(104, 177, 123), false, false); net.setInput(blob); cv::Mat detections = net.forward(); for (int i = 0; i < detections.size[2]; i++) { float confidence = detections.ptr<float>(0, 0, i, 2); if (confidence > 0.6) { // 解析人脸框坐标,保存在faceRects中 } }注意blobFromImage里的mean值必须用(104.0, 177.0, 123.0),这是模型训练时使用的BGR均值。如果你使用OpenCV的caffe模型,顺序是BGR;如果使用PyTorch导出的模型,顺序可能会变成RGB,这里最容易出bug。
3.3 串口接收与环形缓冲
串口数据是字节流,STM32发送的每一帧协议包含帧头、长度、数据体和CRC校验。QT端不能假设一次readyRead信号就收到完整的一帧,所以必须自己维护一个接收缓冲区。
我采用的方法是:在串口接收槽函数里,把QByteArray持续追加到一个成员变量buffer_中,然后循环解析:
void MainWindow::onReadyRead() { buffer_.append(serial_->readAll()); while (buffer_.size() >= kFrameLength) { if ((quint8)buffer_[0] == 0xAA && (quint8)buffer_[1] == 0x55) { // 检查帧长度字段 quint16 len = (quint8)buffer_[2] | ((quint8)buffer_[3] << 8); if (buffer_.size() >= 4 + len + 2) { QByteArray frame = buffer_.left(4 + len + 2); buffer_.remove(0, 4 + len + 2); parseFrame(frame); } else { break; } } else { buffer_.remove(0, 1); // 丢弃非帧头字节 } } }这个解析逻辑看起来简单,但有一个隐藏问题:如果帧头0xAA 0x55刚好出现在温度数据中,会误判。所以我在协议里用帧头加长度校验一起来防止。温度数据是int16类型,完全可能出现0xAA55这样的字节组合,但因为长度字段固定,我会先检查长度是否等于1536,否则继续滑动寻找下一个帧头。
3.4 实时热力图绘制
热力图绘制不需要用QPainter一个一个画矩形,那样太慢。我用的方法是在内存中直接操作QImage的像素缓冲区,把32x24矩阵映射到一张伪彩色图像上。
计算颜色时用了一个经典的双线性插值映射,参考MATLAB的Jet色图:
QColor jetColor(float value) { // value: 30~40℃映射到0~1 float t = (value - 30.0f) / (40.0f - 30.0f); t = qBound(0.0f, t, 1.0f); // 分段线性计算RGB float r, g, b; // ... 依据Jet公式 ... return QColor(r*255, g*255, b*255); }然后把32x24的小图像放大到320x240显示,用QImage::scaled加Qt::FastTransformation,保证放大速度。最后把热力图叠加到摄像头画面的右下角,或者做成半透明覆盖层。我这里直接做成侧边栏,方便对比真实画面和热像图。
4. 通信协议设计:帧头、校验、以及怎样把32x24温度矩阵塞进串口
4.1 帧格式设计
上下位机通信的协议如果没设计好,会带来无尽调试痛苦。我的协议格式如下:
| 字节序 | 字段 | 长度 | 说明 |
|---|---|---|---|
| 0 | 帧头1 | 1 | 0xAA |
| 1 | 帧头2 | 1 | 0x55 |
| 2-3 | 数据长度 | 2 | 低字节在前,值为1536 |
| 4至4+len-1 | 温度数据 | 1536 | 32x24个int16,单位0.01℃ |
| 4+len至4+len+1 | CRC16 | 2 | 对数据长度和温度数据计算CRC |
| 4+len+2 | 帧尾 | 1 | 0x0D |
协议没有加设备ID和命令类型,因为这套系统只有一种上行数据。如果你计划扩展成双向通信,建议把命令类型字段加上。
我使用int16而不是float是为了减少传输量。温度范围设为-40℃到+100℃,乘以100后最大为10000,int16完全够用。上位机拿到后除以100就是摄氏度。
4.2 STM32端发送数据
STM32端在发送前需要把待发送的温度数据从浮点数转换为int16:
uint16_t payload_len = 32 * 24 * 2; uint8_t tx_buf[4 + payload_len + 2 + 1]; tx_buf[0] = 0xAA; tx_buf[1] = 0x55; tx_buf[2] = payload_len & 0xFF; tx_buf[3] = (payload_len >> 8) & 0xFF; // 温度数据填充到tx_buf[4]开始的位置 for (int i = 0; i < 768; i++) { int16_t val = (int16_t)(mlx90640_to[i] * 100); tx_buf[4 + i*2] = val & 0xFF; tx_buf[4 + i*2 + 1] = (val >> 8) & 0xFF; } // CRC16计算 uint16_t crc = crc16(tx_buf + 2, 2 + payload_len); tx_buf[4 + payload_len] = crc & 0xFF; tx_buf[4 + payload_len + 1] = (crc >> 8) & 0xFF; tx_buf[4 + payload_len + 2] = 0x0D;注意STM32的int16_t小端序,直接按低字节先发送即可,QT端QByteArray读出来按小端解析。
4.3 坐标映射:把摄像头画面里的人脸框换算到红外矩阵
这一步是整个功能体验的灵魂。
摄像头分辨率是640x480,红外矩阵只有32x24。我在安装时尽量让两个传感器光轴平行,中心对齐。映射关系的最简单公式:
int ir_x = (int)(face_rect.center_x() / cam_width * 32); int ir_y = (int)(face_rect.center_y() / cam_height * 24);但直接这么映射会有一个问题:人脸框中心往往在鼻尖或嘴巴附近,而测温需要的是额头区域。人的额头大约位于人脸框上部20%到40%的位置。所以我在映射后还要做一次区域修正:
int forehead_x = ir_face_center.x; int forehead_y = ir_face_center.y - (int)(0.15 * ir_face_height); int forehead_w = ir_face_width / 2; int forehead_h = ir_face_height / 4;然后在这个小区域里取平均值。这个平均值比直接用全脸最高温稳定,能有效避开眼镜、头发这些干扰因素。这里还需要设置一个最小有效窗口,比如至少4个像素点,否则数据不足时直接判为无效。
4.4 丢包与错位处理
串口通信偶尔会丢包,即使加了CRC也一样。丢包时最明显的现象是温度矩阵里出现一整行异常值,对应到热力图上是横向条纹。
我的解决方案是上位机接收后做中值滤波:当一个像素点的温度与周围8个像素点的差值超过3℃时,直接用周围8个点的中值替换。这样不仅减少了通信噪声,也同时平滑了MLX90640本身的相邻像素跳动。
另外,由于摄像头帧率和串口帧率不一致,摄像头是30帧,红外是2帧,我在上位机里让温度数据异步更新,即摄像头画面实时刷新,但温度值只在收到新红外帧时更新一次。这样既保证了人脸框流畅,又不会出现温度数字跳动过快的错觉。
5. 温度标定与误差修正:实测数据告诉我,发射率不是唯一变量
5.1 红外测温的三大误差源
MLX90640毕竟不是医疗级测温仪,误差来源很多。我在调试过程中总结了三个主要来源:
- 发射率设置:人体皮肤的发射率通常在0.97到0.99之间,一般取0.98。如果设置成0.95,测量值会偏低0.2℃左右。
- 反射温度(Tr):太阳光、白炽灯、暖气片、甚至旁边站的人都会成为红外反射源,导致测量值偏高。我一开始在办公室调试,头顶日光灯角度一变,额头温度能凭空涨0.5℃。
- 环境温度与传感器自身热量:MLX90640工作一段时间后,芯片自身温度会升高,热电堆测量结果会发生漂移。官方API虽然做了补偿,但如果内部温度变化太大,误差依然存在。
5.2 我的标定流程
我没有黑体炉,所以采用了最实用的替代方案:用水银体温计和医疗级红外耳温枪作为参考,在不同距离、不同环境下做多组对照。
标定过程分三步:
- 在固定反射温度Tr=23℃、距离30cm的测试环境中,让人脸对准摄像头,记录设备读取的温度值。
- 同时用耳温枪在耳朵里测三次,取平均值作为参考体温。
- 重复测20个人(或者同一个人在短时间内测多次),得到一组
(设备测量值, 真实体温)数据对。
然后做简单线性回归,得到两个参数:offset和scale。实际使用时:
float corrected_temp = raw_temp * scale + offset;在这个项目中,scale几乎等于1,所以核心其实是offset修正。我的设备在办公室环境中整体偏高0.4℃,所以在代码里设置了一个可调偏置,在上位机界面上增加一个校准旋钮,可以手动设置offset,范围-2℃到+2℃。
5.3 实测对比数据
在30cm距离上,用耳温枪做参考,记录了几组数据:
| 测试对象 | 耳温枪 | 红外设备原始值 | 标定后值 | 偏差 |
|---|---|---|---|---|
| A | 36.5 | 36.9 | 36.5 | 0.0 |
| B | 36.8 | 37.2 | 36.8 | 0.0 |
| C | 37.0 | 37.4 | 37.0 | 0.0 |
| D | 36.2 | 36.6 | 36.2 | 0.0 |
| E | 36.9 | 37.3 | 36.9 | 0.0 |
可见在固定距离和稳定环境下,标定后精度可以做到±0.1℃以内。但如果把距离拉到60cm,读数会偏低0.2到0.3℃。距离越远,红外能量衰减越多。所以实际使用时我会在上位机界面显示当前有效距离范围,并建议被测人靠近到30cm左右。
6. 调试过程中踩过的坑与解决记录
6.1 I2C读取偶尔出错:不是代码问题,是上拉电阻
第一次调通MLX90640之后,我遇到一个非常隐晦的问题:读数据偶尔会返回0xFFFF或者整行数据异常。我一度怀疑是接线虚焊,把线全部重新焊了一遍还是没解决。后来用示波器看I2C信号,发现SCL和SDA的上升沿非常平缓,才知道是上拉电阻太小或根本没接。我的模块虽然板载了上拉,但杜邦线太长寄生电容增大,4.7kΩ上拉已经不够,后来换成2.2kΩ才稳定。如果是把传感器用长线连接,建议用1kΩ到2.2kΩ上拉,并且降低I2C时钟到400kHz以下。
6.2 热力图显示帧率只有1帧:罪魁祸首是QImage转换
最初我把温度矩阵先转成QImage再绘制,结果发现帧率只有1帧左右。排查后发现,问题出在QImage::setPixel这个接口上,它在循环里调用会非常慢。改成直接操作QImage::scanLine()填充像素后,同样一帧的时间从几百毫秒降到了几毫秒。这里强烈建议所有实时图像处理都不要使用setPixel,直接操作内存。
6.3 OpenCV人脸检测模型加载慢
第一次运行QT程序时,加载Caffe模型花了将近1秒,界面白屏很久。后来我把模型加载放到检测线程启动之前,并且用net.setPreferableBackend(cv::dnn::DNN_BACKEND_OPENCV)和net.setPreferableTarget(cv::dnn::DNN_TARGET_CPU)强制使用CPU优化,加载速度有所提升。如果你不想用Caffe模型,也可以换成OpenCV自带的YuNet人脸检测器,速度更快,模型文件也更小。
6.4 MLX90640刷新率与人体移动的矛盾
MLX90640在2Hz刷新率下,如果人移动太快,上一帧和下一帧之间人脸的位移可能超过2个像素,导致热点区域对不上。我的解决办法是:上位机在显示温度时,不只是用当前帧的人脸框位置,而是对温度矩阵做三帧滑动平均。这样在0.5秒内温度变化会更加平滑,不会出现温度数字剧烈跳动。
另外,MLX90640的某一帧偶尔会出现全画面温度跳变,这是因为传感器内部在做增益校准。如果检测到整帧平均温度突变了超过5℃,直接丢弃这一帧,不参与平均值计算。
6.5 串口工具乱码的教训
调试中有一段时间用ST-Link Utility下载完程序后,串口工具收到一堆乱码。查了很久发现不是程序问题,而是STM32在下载程序后需要复位才能正常初始化I2C和串口,而某些下载器默认不执行复位。解决办法是在代码里增加一个简单的日志输出,每次上电打印一个起始标志,如果串口工具收到起始标志,就说明通信链路正常,否则检查接线和复位电路。
结尾
这个项目做完之后,最大的收获倒不是实现了测温功能,而是把从传感器底层驱动到上位机交互显示这一整条链路彻底盘了一遍。很多零散的知识点——I2C时序、串口状态机、线程模型、图像坐标映射——平时都是一知半解,真正把软硬件串起来跑数据时才明白每个环节为什么那么设计。如果你也要做类似的红外测温项目,我建议先从下位机纯串口输出开始,先用串口助手验证温度矩阵,再写QT界面,最后加上人脸检测。千万不要一上来就整全套,出问题的时候真的不好定位。测温精度的校准也需要耐心,不要指望一次标定就能一劳永逸,环境变化之后多测几组数据,把偏置参数做成可调,设备才能真正拿到现场用。
本文还有配套的精品资源,点击获取