☰
Qt与OpenCV结合SQLite的视频监控录像管理系统设计与实现
2026/10/22 5:27:57 网站建设 项目流程

简介:这是一份基于Qt、OpenCV与SQLite三种主流技术构建的视频监控系统毕业设计源码及项目说明,面向计算机、通信、人工智能、自动化等专业的学生与开发者,既适用于课程设计、期末大作业,也可直接作为毕业设计参考。项目代码经过完整调试,答辩评审成绩达到98分,具备较强的完整性与可运行性。压缩包共52个文件,大小约2.14MB,内含14个头文件、12个C++源文件、4个Qt界面文件、2个工程文件、若干运行截图以及README说明,目录按客户端与服务端划分,结构清晰,便于按模块阅读。系统实现了V4L2视频采集、图像传输、用户登录注册等典型功能,并借助SQLite完成数据存储管理,是学习C++、Qt界面搭建与OpenCV图像处理的良好范例。目前已有167人学习下载,基础较好的读者可在源码基础上修改扩展,实现更多自定义监控能力。

1. 这个毕设真正值钱的部分,不在“能看画面”而在“能管录像”

打开任意一个编程论坛,搜“视频监控系统 毕设”,十有八九能看到同一种东西:一个Qt窗口里嵌着一块OpenCV的实时画面,旁边排几个按钮。从功能上挑不出毛病,但从“毕业设计”的角度看,这类项目普遍有一个致命伤——它只是一个带壳的播放器,不是监控系统。监控系统的核心不是“看见画面”,而是“把画面变成有结构的数据”:录下来的视频存到哪里、什么时候开始什么时候结束、摄像头编号和录像片段怎么对应、报警事件如何与录像关联、用户怎么按条件把录像翻出来。题目里的“qt + opencv + sqlite”三件套,解决的正是“界面 + 图像 + 数据”这三层问题。这篇笔记围绕“源码做出了什么、架构怎么组织、参数怎么调、坑在哪儿”几条线展开,目标是让一个能给答辩评委讲清楚系统设计的人,而不是一个只会上传代码压缩包的人。

2. 技术选型推演:为什么偏偏是这三样,组合的边界在哪里

2.1 Qt在监控界面里的角色:用信号槽解决“实时刷新”这个老难题

监控系统的界面和普通桌面软件最大的区别在于:界面必须以固定频率持续变化。一个视频窗口每秒要刷新25到30次,而且底层图像数据来自另一个线程的采集循环。很多第一次做这个题目的人会直接在采集线程里调用label->setPixmap(),结果程序运行十几秒就崩溃,或者窗口直接变成“未响应”。

Qt在这个场景里真正承担的工作不是“画一个窗口”,而是提供一套跨线程的事件传递机制。我一般把采集放在QThread或std::thread里跑,拿到一帧cv::Mat后不直接碰UI,而是通过信号把QImage发出去,由主线程的槽函数完成显示。这套机制在Qt里可以靠自动连接(AutoConnection)实现:信号发送者所在的线程和接收者所在线程不同时,Qt会自动改用队列连接,槽函数排队在主线程里执行,不再产生数据竞争。比手动加锁的方式干净得多。另外,采集驱动我通常用QTimer而不是while循环,QTimer跑在主线程,配合waitKey(1)让出CPU;如果追求更高流畅度再引入多线程方案。对毕设而言,QTimer写法简单、懒人友好,而且不会一上来就撞线程的雷。

2.2 OpenCV在监控系统里负责的“图像侧”工作不止是读摄像头

多数人对OpenCV的印象停留在imread和imshow,但在监控项目里,OpenCV承担的工作至少有四块:一是通过VideoCapture统一摄像头接入;二是把每帧图像从采集格式转成适合显示和编码的格式;三是提供运动检测之类的简单算法能力;四是借助VideoWriter把裸帧序列写成压缩视频文件。这套链路覆盖了“取流—处理—编码—落盘”的完整闭环。

运动检测是最能体现OpenCV价值的点。大部分教材给的方案是帧差法:取当前帧和上一帧做差分,阈值化后统计非零像素数量,超过阈值就判定为画面变化。这个算法朴素,但足够在答辩时讲清楚原理内核,也能支撑“报警联动录像”这种功能。这里要提醒一点:OpenCV返回的图像是BGR顺序,而Qt的QImage::Format_RGB888要求RGB顺序,中间少一步cvtColor就会出现颜色错乱。这是每届毕设里出现频率最高的翻车点,后文避坑章节详细展开。

2.3 SQLite在监控系统里存放的边界:存索引,不存视频流

这个组合里SQLite的存在感最容易被低估。很多学生以为数据库就是存视频文件本身,设计出来的库表里放了个BLOB字段,把整段录像塞进去——这是灾难。SQLite适合存储的是录像的元数据:哪一路摄像头、从几点录到几点、文件保存在哪个路径、报警事件的时间点和类型。视频文件本身放磁盘,数据库只做“登记”。

选SQLite而不选MySQL的理由在毕设场景里非常充分:不需要单独安装数据库服务,驱动在Qt里自带(QSQLITE),一个.db文件随项目走,迁移和答辩演示都方便。它的并发能力偏弱,但监控系统单机写入量并不大——一段录像只在开始和结束时插入两条记录,运动检测报警时插入一条事件记录,这个量级SQLite完全扛得住。需要关心的是正确使用方式,比如写事务要短、避免多线程同时写,后面单独开一节讲。

为了后面讨论方便,这里给出一个经过调整的基础表结构,三张表覆盖“摄像头信息、录像片段、报警事件”三个主体。

CREATE TABLE camera_info ( camera_id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, location TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE record_segment ( segment_id INTEGER PRIMARY KEY AUTOINCREMENT, camera_id INTEGER NOT NULL, start_time DATETIME NOT NULL, end_time DATETIME, file_path TEXT NOT NULL, alarm_count INTEGER DEFAULT 0, FOREIGN KEY (camera_id) REFERENCES camera_info(camera_id) ); CREATE TABLE alarm_event ( alarm_id INTEGER PRIMARY KEY AUTOINCREMENT, camera_id INTEGER, segment_id INTEGER, event_type TEXT, event_time DATETIME, thumbnail_path TEXT, FOREIGN KEY (camera_id) REFERENCES camera_info(camera_id), FOREIGN KEY (segment_id) REFERENCES record_segment(segment_id) );

表结构里有三个容易忽略的设计点:record_segment用start_time和end_time两个字段而不是一个总时长字段,是为了支持“查询某一时刻画面在哪个录像文件里”这类区间检索;alarm_count放在录像片段表里,是为了在录像列表页面直接显示“这段录像里有几次报警”,避免每次都要联表统计;thumbnail_path存的是报警瞬间截图的文件路径,不是图片二进制本身,数据库体积因此保持可控。这套设计能直接支撑毕业设计里“按时间查回放”“按事件查录像”两个核心功能点。

3. 从零搭一个能跑的监控Demo:工程创建、采集回路和录像落盘

3.1 创建Qt工程并接入OpenCV:pro文件里的路径配置是第一个分水岭

接入OpenCV最折腾的不是代码,是工程配置。以qmake为例,先创建一个Qt Widgets Application,然后在.pro文件里加入sql模块和OpenCV的include、lib路径。这里给出一个能用的最小配置。

QT += core gui sql greaterThan(QT_MAJOR_VERSION, 4): QT += widgets CONFIG += c++11 INCLUDEPATH += D:/opencv/install/include LIBS += -LD:/opencv/install/x64/mingw/lib \ -lopencv_core \ -lopencv_imgproc \ -lopencv_highgui \ -lopencv_videoio SOURCES += main.cpp \ mainwindow.cpp HEADERS += mainwindow.h

路径要按你自己的OpenCV实际安装位置改。.pro里有三个最常出问题的点:一是OpenCV的lib目录分mingw和msvc两套,编译器不匹配会报“未定义的引用”或“找不到文件”的链接错误;二是debug和release的库文件名后缀不同,常见命名里d结尾的表示debug版,混用了会出现运行时找不到opencv_world450d.dll这类报错;三是OpenCV 3.x以后推荐直接用opencv_world合集库,按上述方式链接即可。配置完成后,用一个最简单的代码验证环境:cv::Mat m(100, 100, CV_8UC3);能编译过、能跑,才进入下一步。

3.2 用QTimer做采集驱动:最小可运行的实时画面回路

验证环境之后,写一个最简的“显示回路”:打开摄像头,定时取帧,把cv::Mat转成QImage显示到QLabel上。这套代码是后面所有功能的地基,先看完整逻辑。

#include <opencv2/opencv.hpp> #include <QTimer> #include <QLabel> #include <QImage> #include <QPixmap> cv::VideoCapture cap; QTimer timer; void MainWindow::initCamera() { cap.open(0); // 0 号设备,一般是内置摄像头或第一个USB摄像头 if (!cap.isOpened()) { qDebug() << "无法打开摄像头,请检查设备编号或驱动"; return; } cap.set(cv::CAP_PROP_FRAME_WIDTH, 1280); cap.set(cv::CAP_PROP_FRAME_HEIGHT, 720); cap.set(cv::CAP_PROP_FPS, 30); // 33ms 对应约 30fps,取帧频率和摄像头输出帧率尽量保持一致 connect(&timer, &QTimer::timeout, this, &MainWindow::updateFrame); timer.start(33); } void MainWindow::updateFrame() { cv::Mat frame; cap >> frame; if (frame.empty()) { return; // 取帧失败时跳过,避免把空图转成QImage } // OpenCV 输出的是 BGR,Qt 显示需要 RGB,这步不能省 cv::cvtColor(frame, frame, cv::COLOR_BGR2RGB); // 构造QImage时直接引用frame的数据,不用深拷贝 QImage img(frame.data, frame.cols, frame.rows, frame.step, QImage::Format_RGB888); ui->videoLabel->setPixmap(QPixmap::fromImage(img)); }

逻辑并不复杂,但有几个参数值得说清楚。第一,cap.open(0)里的0是设备编号,Windows下多个摄像头时编号不一定按物理顺序;同一台机器换USB口插拔后编号可能变化。第二,frame.step是每行字节数,OpenCV的Mat在内存里可能存在行对齐,直接传入step能保证图像不歪斜。第三,QImage构造时只是引用了frame的内存,所以updateFrame函数结束时frame销毁之前,QPixmap已经完成了数据拷贝;如果反过来先销毁frame再显示就会出现花屏。这套写法的性能足够支撑720p画面的实时预览。

3.3 录像写入:VideoWriter的参数匹配决定文件能不能播

显示回路跑通后,开始写录像。OpenCV的VideoWriter是录像落盘的入口,它的参数匹配是整个模块里最讲究“玄学”的地方。常见做法是,录像开始时创建一个VideoWriter,采集循环里不断writer << frame,结束时调用writer.release()。

#include <opencv2/videoio.hpp> cv::VideoWriter writer; QString currentRecordPath; bool MainWindow::startRecord() { QString fileName = QDateTime::currentDateTime() .toString("yyyyMMdd_HHmmss") + ".avi"; currentRecordPath = saveDir + "/" + fileName; // fourcc 选 'M','J','P','G',兼容性最好,几乎所有播放器都能打开 int fourcc = cv::VideoWriter::fourcc('M', 'J', 'P', 'G'); double fps = 25.0; cv::Size frameSize(1280, 720); bool ok = writer.open(currentRecordPath.toStdString(), fourcc, fps, frameSize); if (!ok) { qDebug() << "VideoWriter 打开失败,检查编码器和路径权限"; return false; } return true; }

fourcc的选择是录像能不能被正常预览的第一道关卡。MJPG(Motion JPEG)是MJPEG编码,特点是兼容性好、CPU开销适中、生成的.avi文件几乎任何播放器都能打开,缺点是压缩率偏低,每分钟720p录像大约占用40到60MB;XVID或DIVX能压得更小,但部分Windows系统上播放器缺解码器,会导致答辩演示时双击文件放不出来。对毕设来说,稳定压倒一切,所以默认选MJPG。帧率参数fps要和采集端保持一致:摄像头设置30帧,录像也写25或30,如果采集端实际只有12帧而写入端声明25帧,生成的文件会出现画面快放。尺寸参数frameSize必须严格等于写入帧的frame.cols和frame.rows,否则writer << frame会直接静默写入失败,文件保持0字节。这个坑在避坑章里单独拿出来讲。

4. 让录像“可查”:SQLite的建表、写入和按时间回放

4.1 在Qt里初始化数据库并建立表结构

工程里引入SQLite的方式很直接:通过QSqlDatabase加载驱动,执行CREATE TABLE IF NOT EXISTS建表。建表语句用第2章给出的三张表,初始化时只执行一次。

#include <QSqlDatabase> #include <QSqlQuery> #include <QSqlError> bool MainWindow::initDatabase() { QSqlDatabase db = QSqlDatabase::addDatabase("QSQLITE"); db.setDatabaseName("monitor.db"); if (!db.open()) { qDebug() << "数据库打开失败:" << db.lastError().text(); return false; } QSqlQuery query; bool ok = query.exec("CREATE TABLE IF NOT EXISTS camera_info (" "camera_id INTEGER PRIMARY KEY AUTOINCREMENT," "name TEXT NOT NULL," "location TEXT," "created_at DATETIME DEFAULT CURRENT_TIMESTAMP)"); if (!ok) { qDebug() << "建表失败:" << query.lastError().text(); return false; } // record_segment 和 alarm_event 的建表语句同构,按第2章的SQL补齐 return true; }

QSQLITE驱动是Qt自带插件,不需要额外的第三方库。这里强调一个习惯:所有exec的返回值都检查一下,打印lastError().text()。数据库初始化阶段的错误如果没有及时暴露,后面会出现“录像明明录了但列表查不到”的怪现象,排查难度比写代码时多花几分钟要高很多。monitor.db文件的落盘路径要留意,Windows下程序工作目录与exe所在目录不一定相同;用QCoreApplication::applicationDirPath()拼绝对路径,可以避免“运行一次出现两个db文件”的困惑。

初始化完成后,往camera_info表插入一行默认记录。这一步的意义是让数据库始终有数据可用,不管之后怎么测试,界面不会因为空表而出现空白。

4.2 录像开始与结束:两个时间点的写入策略

一个录像片段的生命周期分为三个动作:开始录像时插入一条start_time已填、end_time为空、file_path已填的记录;持续录像过程不写数据库;结束后用UPDATE语句补上end_time。这种“先插入后更新”的做法比结束再一次性插入更合理——如果程序在录像中途崩溃,数据库里至少保有一条开始记录,可以据此判断未闭合的录像段,也方便做“异常录像检测”加分功能。

#include <QSqlQuery> #include <QVariant> int MainWindow::insertRecordStart(const QString &filePath, int cameraId) { QSqlQuery query; query.prepare("INSERT INTO record_segment " "(camera_id, start_time, file_path) " "VALUES (:camera_id, :now, :file_path)"); query.bindValue(":camera_id", cameraId); query.bindValue(":now", QDateTime::currentDateTime() .toString("yyyy-MM-dd HH:mm:ss")); query.bindValue(":file_path", filePath); if (!query.exec()) { qDebug() << "插入录像起始记录失败:" << query.lastError().text(); return -1; } return query.lastInsertId().toInt(); } void MainWindow::updateRecordEnd(int segmentId) { QSqlQuery query; query.prepare("UPDATE record_segment SET end_time = :now " "WHERE segment_id = :id"); query.bindValue(":now", QDateTime::currentDateTime() .toString("yyyy-MM-dd HH:mm:ss")); query.bindValue(":id", segmentId); if (!query.exec()) { qDebug() << "更新录像结束时间失败:" << query.lastError().text(); } }

query.lastInsertId().toInt()拿到的是record_segment表的自增主键,后续报警事件写入时要用它做外键关联。时间字段统一存成yyyy-MM-dd HH:mm:ss格式的字符串,逻辑简单、跨平台、可直接显示在界面上,也能被BETWEEN比较语句正确处理。这里不推荐用时间戳整数,是因为调试时直接看字符串更容易发现错误;如果追求查询性能,可以在start_time列上建索引,但对毕设级数据量没实际区别。

4.3 按条件查回放:时间区间与摄像头编号的SQL组装

录像查询是答辩时一定会被问到的功能,它也最能体现“系统设计”思维。查询条件通常是两个:选哪一路摄像头、选哪一段时间区间。对应的SQL写法如下。

QList<RecordInfo> MainWindow::queryRecords(int cameraId, const QDateTime &begin, const QDateTime &end) { QList<RecordInfo> result; QSqlQuery query; query.prepare("SELECT segment_id, start_time, end_time, file_path, " "alarm_count " "FROM record_segment " "WHERE camera_id = :camera_id " "AND start_time <= :end AND end_time >= :begin " "ORDER BY start_time DESC"); query.bindValue(":camera_id", cameraId); query.bindValue(":end", end.toString("yyyy-MM-dd HH:mm:ss")); query.bindValue(":begin", begin.toString("yyyy-MM-dd HH:mm:ss")); if (!query.exec()) { qDebug() << "查询录像列表失败:" << query.lastError().text(); return result; } while (query.next()) { RecordInfo info; info.segmentId = query.value(0).toInt(); info.startTime = query.value(1).toString(); info.endTime = query.value(2).toString(); info.filePath = query.value(3).toString(); info.alarmCount = query.value(4).toInt(); result.append(info); } return result; }

这里的区间条件用的是“start_time <= :end AND end_time >= :begin”,意思是“两段时间有交集”,区别于更直观的“start_time BETWEEN :begin AND :end”。前一个写法能查出跨越查询起点之前就开始的录像,比如查询9点到10点的录像,一条8点50分开始、9点20分结束的片段也应该出现在结果里。这个细节就是毕业设计答辩时能让评委眼前一亮的“系统设计感”。拿到查询结果后,把file_path交给VideoCapture重新打开做回放,就完成了“查得到—点得开—放得出来”的闭环。

5. 避坑:毕设级监控系统最容易翻车的五个细节

5.1 画面颜色整体偏蓝或偏红:BGR与RGB的经典错位

现象:摄像头画面显示正常,但颜色明显失真,红色物体显示成蓝色,皮肤发青。原因:OpenCV读取的帧是BGR颜色顺序,而QImage::Format_RGB888按红绿蓝顺序解释内存数据,两者错位后红色通道和蓝色通道互换。解决:显示前执行cv::cvtColor(frame, frame, cv::COLOR_BGR2RGB);。注意QImage::Format_RGB888和Format_BGR888不能混用,用一种转换即可。这一步看起来小,但所有涉及帧处理的后续功能都要在转换后操作,否则颜色问题会一路传染到录像回放和抓图。

5.2 录像文件打不开或只有0字节:VideoWriter参数全部要匹配

现象:点击“开始录像”后文件生成了,但用播放器打开提示文件损坏,或者文件大小一直为0。原因:VideoWriter打开时指定的frameSize与实际写入帧尺寸不一致,或者fps高于采集实际帧率。<<运算符写入失败时OpenCV默认不抛异常,错误被静默吞掉。解决:录像开始前读取当前帧的cols和rows作为frameSize;采集帧率低于录像帧率时,把录像帧率下调到与采集一致;程序退出前必须调用writer.release(),否则文件尾索引没写完,播放器会提示文件损坏。血泪经验是:先查frameSize,再查fps,最后查release()。

5.3 界面无响应或直接崩溃:跨线程操作UI是高压线

现象:把采集循环放到独立线程后,窗口频繁崩溃或“未响应”,Debug输出大量QObject::setParent: Cannot set parent之类的报错。原因:Qt的UI控件只能由主线程操作,采集线程直接调用setPixmap触发了线程冲突。解决:严格遵循“采集线程发信号、主线程收信号更新UI”的模型。用connect(worker, &Worker::frameReady, this, &MainWindow::showFrame, Qt::QueuedConnection);强制队列连接。如果信号频率太高导致界面卡顿,可以在工作线程里做帧率限制(比如每两帧发一次信号),不要在UI线程里再通过QTimer去拉帧。

5.4 SQLite报“database is locked”:同一时刻只能有一个写事务

现象:录像开始和报警事件同时发生时,控制台打印database is locked,插入失败。原因:SQLite在写操作期间会锁住整个数据库文件,第二个写连接在默认超时内等不到锁就报错。解决:三个方向配合。第一,打开数据库后执行PRAGMA journal_mode=WAL;,让读写并发能力大幅提升;第二,把两条写操作合并到一个事务里执行,缩短锁的持有时间;第三,如果采集线程和UI线程都要写库,建议所有写操作统一封装到一个函数里,通过信号槽调用,让写SQL永远只在一个线程执行。对毕设场景,做到前两条基本不会再遇到锁问题。

5.5 换个USB口摄像头编号变了:设备枚举与配置文件

现象:程序在实验室正常,换一台电脑或在同一台机器上拔插USB后,cap.open(0)打开的是错误摄像头,画面是黑的或不是我想要的那一路。原因:Windows下摄像头编号由驱动枚举顺序决定,和物理端口不保证固定对应。解决:在camera_info表里增加一个device_index字段,首次打开时枚举所有可用设备,让用户在界面上选择“哪一路对应哪个摄像头”,把选择结果写回配置文件。下次启动时按配置文件里的编号打开。这个功能对“多路摄像头”扩展意义重大,也是答辩时能主动讲出来的加分项。

6. 从“能跑”到“高分”:三个必做的进阶点

6.1 用帧率换算做存储量预估,让系统“算得清”

录像功能做完后,加一个存储容量估算面板:输入摄像头分辨率、帧率、编码方式和预期保存天数,自动算出大概需要多少GB磁盘空间。估算公式是“单路每小时GB ≈ 码率(Mbps) × 3600 / 8 / 1024”,MJPEG格式下720p@25fps的码率大约在6到12Mbps。这个功能看似简单,但它体现了“系统能持续运行”的工程意识,而且答辩时评委非常容易追问“你这系统录一天要占多少空间”。

6.2 运动报警联动:用帧差法触发录像事件的完整链路

在现有框架上增加运动检测,让“报警—截图—写库—关联录像”形成完整闭环。实现上做一个detectMotion(const cv::Mat &current, const cv::Mat &previous)函数,差分后计算非零像素比例,超过阈值就抓帧存为jpg,并把alarm_event插入数据库,同时把对应record_segment的alarm_count加一。这个功能衔接了OpenCV的算法能力和SQLite的数据组织能力,是整个毕设最能体现“三件套协作”的部分。

6.3 答辩前的自验清单:按这三项检查再交

我一般会在交项目前按三个维度过一遍:第一,连续运行测试,让系统挂机跑4到6小时,观察内存是否持续增长、录像文件是否连续生成,发现内存涨了就重点检查是否每帧都创建了大对象没有释放;第二,断电模拟测试,把程序运行中直接杀进程,然后重启看数据库里的未闭合录像记录能否被识别出来。第三,回放一致性检查,从录像列表随机选三天前的片段回放,对比系统日志里的记录时间与文件修改时间是否吻合。

做这个题目的过程里,我最大的教训是“只重视画面不重视数据组织”——第一版代码摄像头驱动的非常顺畅,但录像列表、报警事件和回放查询全都没有,答辩一追问就露馅。换个思路,把工程量排成“画面 → 录像落盘 → 数据记录 → 查询回放 → 报警联动”逐层递进,每一步都是系统的一块拼图,而不是把“能出画面”当作终点。希望这些思路和踩过的坑,能帮你把这三件套真正拼成一套说得清、扛得住追问的完整系统。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询