简介:面向毕业设计、课程设计与计算机视觉入门人群的完整工程包,基于海康威视网络摄像头实时采集画面,结合OpenCV与HOG+SVM算法实现人体识别与检测。程序采用C++编写,集成Qt图形界面,并划分主窗口、摄像头采集、YV12图像格式转换、人体检测处理与模型训练等模块,代码结构清晰,可直接编译运行并用于二次开发。压缩包共229个文件,大小36.88MB,涵盖dll动态库、exe可执行程序、cpp/h源文件、lib库文件、pdb调试符号及vcxproj工程配置等,便于快速配置环境、对照源码学习与调试运行。目前已有229人浏览学习,适合希望在真实网络摄像头场景下快速搭建人体识别原型、完成课程项目或毕业设计的开发者。
1. 海康 + OpenCV 人体识别:一套能直接跑起来的完整工程,而不是教程代码
这套资源的核心不是教你如何用 OpenCV 读取一个网络摄像头,而是一套能直接在毕业设计或课程设计里跑起来的完整工程。拿到压缩包先别急着编译,按文件名把链路理顺:mainwindow.cpp 是界面入口,camera.cpp 和 Read_Camera02.cpp 负责取流,YV12_RGB.cpp 处理海康威视 SDK 回传的原始视频帧,processpeople.cpp 和 HOG+SVM.cpp 做人体识别,train_main.cpp 是独立训练脚本。也就是说,它覆盖了从海康威视网络摄像头取流、色彩空间转换、OpenCV 行人检测到 Qt 界面显示的整条链路。适合两类人:被摄像头黑屏花屏折腾过的课设党,以及想弄懂 HOG+SVM 工程化边界、而不是只会在 Jupyter 里跑段样例的入门从业者。
2. 取流链路选型:RTSP 直连 vs 海康 SDK 裸流,稳定性差距不小
2.1 RTSP 拉流:先跑通,再谈稳定性
很多教程会直接让你用 OpenCV 的 VideoCapture 拉 RTSP,路径通常是rtsp://用户名:密码@IP:554/Streaming/Channels/101。这套写法在实验室网络环境下确实能出画面,代码量只有三行:
cv::VideoCapture cap; cap.open("rtsp://admin:123456@192.168.1.64:554/Streaming/Channels/101"); if (!cap.isOpened()) { qDebug() << "RTSP open failed"; return; } cv::Mat frame; cap >> frame;这里的101是海康 ISAPI 风格的通道编号,第一位 1 表示通道 1,后两位 01 表示主码流,02 才是子码流。如果你需要低分辨率高帧率来做实时检测,优先用102。老版本固件可能不认这个格式,要退回rtsp://IP:554/ch1/main/av_stream这种旧路径,这是海康威视网络摄像头设置 RTSP 地址时最常见的分叉点。
但 VideoCapture 直连有两个现实问题:一是断流后cap >> frame会一直返回空帧,程序里必须自己处理重连逻辑;二是默认缓冲区设置会导致画面延迟,看着像慢动作。我一般会加一个自动重连循环,并主动把缓冲区压到最小:
while (true) { if (!cap.isOpened()) { cap.open(rtspUrl); // 重新初始化 std::this_thread::sleep_for(std::chrono::seconds(2)); continue; } cap.set(cv::CAP_PROP_BUFFERSIZE, 1); cv::Mat frame; if (!cap.read(frame)) { cap.release(); continue; } // 处理 frame }这段代码的思路很简单:读不到帧就释放连接并重开,避免进程僵在那里。CAP_PROP_BUFFERSIZE设成 1 是为了让 OpenCV 不要积压历史帧,否则你看到的永远是几秒前的画面。如果你只是做验证,RTSP 直连够用;但要做课设演示,建议直接走 SDK 裸流,也就是这套资源里 Read_Camera02.cpp 实际做的那件事。
2.2 海康 SDK 取流与 YV12 转 RGB 的实现
海康 SDK 走的是另一条路:登录设备后通过NET_DVR_RealPlay拿实时流,回调函数里吐出的是 YV12 格式的裸数据,不是封装好的视频帧。YV12 是 YUV 4:2:0 平面格式,排列顺序是 Y 平面、V 平面、U 平面,总大小是width * height * 3 / 2。
OpenCV 里默认的 Mat 是 BGR 三通道排列,所以中间必须经过一次 YV12 到 RGB 的转换,这就是工程里 YV12_RGB.cpp 的来由。转换的核心代码逻辑是这样的:
void YV12ToRGB(const uint8_t* src, uint8_t* dst, int width, int height) { int ySize = width * height; int uvWidth = width / 2; int uvSize = uvWidth * (height / 2); const uint8_t* yPlane = src; const uint8_t* vPlane = src + ySize; // YV12: Y 后紧跟 V const uint8_t* uPlane = src + ySize + uvSize; // 再后面是 U for (int i = 0; i < height; i++) { const uint8_t* yRow = yPlane + i * width; const uint8_t* vRow = vPlane + (i / 2) * uvWidth; const uint8_t* uRow = uPlane + (i / 2) * uvWidth; uint8_t* outRow = dst + i * width * 3; for (int j = 0; j < width; j++) { int Y = yRow[j]; int U = uRow[j / 2] - 128; int V = vRow[j / 2] - 128; outRow[j * 3 + 0] = cv::saturate_cast<uint8_t>(Y + 1.772 * U); outRow[j * 3 + 1] = cv::saturate_cast<uint8_t>(Y - 0.344 * U - 0.714 * V); outRow[j * 3 + 2] = cv::saturate_cast<uint8_t>(Y + 1.402 * V); } } }这段代码每一行都是有讲究的:ySize、uvSize用来定位三个平面的起始地址,j / 2是因为 UV 平面横向只有 Y 平面一半宽。saturate_cast是 OpenCV 提供的截断函数,把超出 0-255 的数值强制收敛到合法范围,手写if判断也行,但可读性和效率都不如它。
如果你用的是较新版本的 OpenCV,也可以不手写,直接把回调数据包成一个 Mat,交给cvtColor处理,效果等价且速度更快:
cv::Mat yuvImg(height * 3 / 2, width, CV_8UC1, srcData); cv::Mat rgbImg; cv::cvtColor(yuvImg, rgbImg, cv::COLOR_YUV2RGB_YV12);需要注意:cvtColor要求宽高必须是偶数,否则 UV 平面尺寸对不上,会直接抛异常。海康主码流一般是 1920x1080 或 1280x720,没问题;但如果有人把分辨率改成奇数,第一件事就是花屏。
2.3 Qt 刷新链路与线程模型:界面卡死的真正来源
资源里是 Qt 工程,那么决定能不能稳定跑起来的关键,不在 OpenCV,而在线程模型。采集回调、YV12 转换、HOG 检测都是耗时的,你要是把这些直接塞进 UI 线程,窗口会瞬间无响应。这套工程的正确做法是:采集线程拿到 RGB 帧,转成 QImage,通过 signal/slot 投递给主线程刷新,检测结果只以数据形式传递,绝不在主线程外操作 QWidget。
// 采集线程 void CaptureWorker::run() { while (m_running) { cv::Mat rgbFrame; if (takeFrame(rgbFrame)) { QImage img(rgbFrame.data, rgbFrame.cols, rgbFrame.rows, rgbFrame.step, QImage::Format_RGB888); emit frameReady(img.copy()); // 拷贝,避免 Mat 生命周期问题 } } } // 主线程 connect(worker, &CaptureWorker::frameReady, this, [this](const QImage& img) { ui->labelCamera->setPixmap(QPixmap::fromImage(img)); });frameReady用QueuedConnection跨线程投递,槽函数只负责刷新界面,不做任何计算。img.copy()看起来多余,实际是必须的:rgbFrame是局部变量,函数结束后 Mat 内存释放,而QImage默认不持有像素数据,不拷贝的话界面上会出现随机花块。这个坑我帮人调过很多次,十有八九是忘了拷贝。
如果你只想跑通功能,不打算深挖 Qt 线程,也建议至少把采集和检测拆到两个线程里,否则海康摄像头的 25 帧每秒回调会把主循环卡死。
3. HOG+SVM 检测代码落地:从默认行人检测器到参数可调
3.1 HOG 为什么够用:特征维度与模型行为
HOG(Histogram of Oriented Gradients)的核心思想是:把图像分成小格子,统计每个格子里的梯度方向分布,用这些统计值刻画目标的外形轮廓。OpenCV 默认的行人检测器配置是检测窗口 64x128、cell 8x8、block 16x16、block stride 8x8、梯度方向 9 个 bin。算下来一个窗口的特征维度是 105 个 block 乘以 36 维,等于 3780 维特征。
为什么这套 2005 年的老方法到现在做课设依然够用?因为它本质上是轻量线性分类器,CPU 上实时跑完全没问题,不需要 GPU,也不需要深度学习框架。对毕设场景来说,可解释性强反而是加分项:答辩时你能说清楚特征怎么提取、SVM 的决策边界是什么,这是拿一个黑匣子神经网络比不了的。
但 HOG 的边界也很清楚,它靠轮廓和梯度区分人体,所以非常吃图像质量。夜间噪点大、逆光、目标太小都会让特征退化,这就是很多人反映海康威视监控摄像头夜晚不灵敏的根本原因——不是 OpenCV 的问题,是 HOG 特征本身的局限。
3.2 核心检测代码与参数语义
工程里 HOG+SVM.cpp 的核心是detectMultiScale,这行代码决定了检测效果的上限:
cv::HOGDescriptor hog; hog.setSVMDetector(cv::HOGDescriptor::getDefaultPeopleDetector()); std::vector<cv::Rect> found; std::vector<double> weights; hog.detectMultiScale(img, found, weights, 0.0, // hitThreshold cv::Size(8, 8), // winStride cv::Size(8, 8), // padding 1.05, // scale 2.0, // finalThreshold true); // useMeanshiftGrouping参数对照表:
| 参数 | 默认值 | 作用 | 调整方向 |
|---|---|---|---|
| hitThreshold | 0.0 | SVM 分类阈值,越大越严格 | 漏检多就调小,误检多就调大 |
| winStride | (8,8) | 检测窗口滑动步长,越小越慢越准 | 卡顿就调大到 (16,16) |
| scale | 1.05 | 图像金字塔缩放系数,越小检测越细 | 太慢就调大到 1.1 |
| finalThreshold | 2.0 | 窗口合并阈值 | 框太碎就调大 |
一个常见误用是忽略 weights 输出。getDefaultPeopleDetector训练出来的模型,detectMultiScale返回的 weights 才是每个框的置信度分数,不用它过滤的话,背景区域的低分框也会全被画出来。正确做法是在后面加一个筛选:
for (size_t i = 0; i < found.size(); i++) { if (weights[i] < -0.5) continue; // 低分框直接丢弃 cv::rectangle(frame, found[i], cv::Scalar(0, 255, 0), 2); }这里hitThreshold设 0.0、weights再卡一道 -0.5,是因为 HOGDescriptor 默认检测器的输出分数不是概率,负数仍可能是有效行人。具体阈值要看你的场景,白天走廊和夜间仓库的最优值能差出一倍。
3.3 多尺度重叠框合并:NMS 该怎么做
用useMeanshiftGrouping=true时 OpenCV 内置了合并逻辑,但实际效果在行人密集场景下依然会输出大量重叠框,另一个办法是自己写 NMS。多尺度检测的原理决定了同一个行人会在金字塔的不同层被多次检测到,这些框尺寸不同但位置重叠,必须按置信度排序后逐个合并:
void nms(std::vector<cv::Rect>& boxes, std::vector<double>& scores, double iouThreshold) { std::vector<int> idx(scores.size()); std::iota(idx.begin(), idx.end(), 0); std::sort(idx.begin(), idx.end(), [&](int a, int b) { return scores[a] > scores[b]; }); std::vector<cv::Rect> keep; for (int i : idx) { bool merged = false; for (auto& r : keep) { double inter = (r & boxes[i]).area(); double uni = r.area() + boxes[i].area() - inter; if (uni > 0 && inter / uni > iouThreshold) { merged = true; break; } } if (!merged) keep.push_back(boxes[i]); } boxes = keep; }NMS 的注意点有两个。第一,IoU阈值一般取 0.4-0.5,太大会把同一个人的两个框都保留,太小会把相邻的人合并掉。第二,boxes[i]在keep循环里不能直接erase,因为keep要参与后续所有比较,正确做法是先收集再替换,上面代码就是这个思路。很多教程只讲groupRectangles,但那个函数对低分框的容忍度很差,自己写 NMS 反而更好控制。
4. 训练自己的模型:train_main.cpp 里的样本、SVM 与两个关键坑
4.1 正负样本与硬负样本挖掘
默认行人检测器是在 INRIA 数据集上训的,应对监控场景的视频画面绰绰有余,但如果你要检测的是某个特定环境里的人员形态,比如工地安全帽下方的半身人像,就需要自己训一个模型。工程里的 train_main.cpp 就是为了这个目的准备的。
训练的第一步永远是样本,不是代码。正样本是包含行人的矩形图,统一缩放到 64x128;负样本是任意不含行人的背景图,同样缩放。数量上建议至少 1000 个正样本、3000 个负样本,比例 1:3 左右,负样本太少 SVM 会学出大量误检。
这里有个课程设计里几乎必讲的技巧叫硬负样本挖掘(Hard Negative Mining)。第一轮训练完,用模型在你准备的负样本图集上跑一遍检测,把误检出来的框截下来,作为新的负样本加入训练集再训一轮,误检率能明显下降。逻辑很简单:SVM 只见过你给的负样本,凡是你没给的背景它都可能当成正样本,那就主动把最难缠的背景喂给它。
# 目录结构约定 train/ pos/ # 正样本,每个文件一个行人图 neg/ # 负样本,背景图 hard_neg/ # 硬负样本挖掘补进来的新样本4.2 特征提取与 SVM 训练参数
train_main.cpp 的流程是先遍历所有样本,用 HOGDescriptor 把每张图变成 3780 维特征向量,组装成特征矩阵,然后送给 SVM 训练。核心代码骨架如下:
cv::HOGDescriptor hog(winSize, blockSize, blockStride, cellSize, nbins); cv::Mat trainData(totalSamples, featureDim, CV_32FC1); cv::Mat labels(totalSamples, 1, CV_32SC1); int row = 0; for (auto& path : positiveImages) { cv::Mat img = cv::imread(path, cv::IMREAD_GRAYSCALE); cv::Mat resized; cv::resize(img, resized, cv::Size(64, 128)); std::vector<float> feat; hog.compute(resized, feat, cv::Size(8, 8), cv::Size(0, 0)); for (int j = 0; j < featureDim; j++) trainData.at<float>(row, j) = feat[j]; labels.at<int>(row, 0) = 1; row++; } // 负样本同上,labels 置 -1 或 0 cv::Ptr<cv::ml::SVM> svm = cv::ml::SVM::create(); svm->setType(cv::ml::SVM::C_SVC); svm->setKernel(cv::ml::SVM::LINEAR); svm->setC(0.01); svm->train(trainData, cv::ml::ROW_SAMPLE, labels); svm->save("svm_model.xml");hog.compute的第三个参数是窗口步长,第四是 padding,这里的设置要和检测端完全一致。C是 SVM 的正则化系数,0.01-0.1 是 HOG+SVM 场景的常用区间,C越大越容易过拟合训练集,监控画面这种背景复杂的场景,建议从小往大试。
一个容易翻车的细节:不要对feat做额外的手工归一化。hog.compute输出的特征本身已经经过 block 级 L2 归一化,如果你再套一层normalize,训练时特征分布变了,检测端HOGDescriptor内部却不会做同样处理,检测效果会直接崩掉。
4.3 把 SVM 系数转成 HOG 检测器向量
这是整套资源里最容易卡住的一步,也是网上大多数教程没讲清楚的地方。svm->save存出来的文件是 OpenCV ML 格式的模型,它不能直接喂给HOGDescriptor::setSVMDetector。你从svm_model.xml里读到的支持向量和偏置,需要手动转换成一个一维数组,格式是:支持向量按权重累加,数组末尾放偏置项。
cv::Ptr<cv::ml::SVM> svm = cv::ml::StatModel::load<cv::ml::SVM>("svm_model.xml"); std::vector<float> alpha; double rho = svm->getDecisionFunction(0, alpha); cv::Mat supportVectors = svm->getSupportVectors(); int dim = supportVectors.cols; std::vector<float> detector(dim + 1, 0.f); for (int i = 0; i < supportVectors.rows; i++) { for (int j = 0; j < dim; j++) { detector[j] += alpha[i] * supportVectors.at<float>(i, j); } } detector[dim] = static_cast<float>(-rho); cv::HOGDescriptor hog(winSize, blockSize, blockStride, cellSize, nbins); hog.setSVMDetector(detector);这里有两件事必须说清楚。第一,getDecisionFunction(0, alpha)返回的alpha是每个支持向量的组合系数,直接用支持向量做加权求和,得到的是 SVM 决策超平面的法向量。第二,末尾偏置的符号在不同 OpenCV 版本里有可能相反,如果你加载模型后检测结果全是框或一个框都没有,把detector[dim]的符号取反再试一次就行,这不是你的代码写错了,是版本差异的玄学。
这个转换做完,你的自训练模型就和getDefaultPeopleDetector一样,可以直接进detectMultiScale跑了。
5. 编译、运行与排查:安装 OpenCV、配置 Qt 之后还有四个现实问题
5.1 构建顺序与 CMake 配置
拿到源码先别急着点编译。这套工程依赖的组件有 Qt(负责界面)、海康 SDK(负责取流)、OpenCV(负责图像处理),三者的构建顺序建议是:先装 OpenCV,再配 Qt,最后接海康 SDK,因为后两者都要引用 OpenCV 的头文件和库路径。
Windows 下最省事的方式是下载 OpenCV 预编译包,解压后把opencv_world450.dll这类库所在目录加进环境变量 PATH,然后在 Qt 工程里用 CMake 指过去:
set(OpenCV_DIR "D:/opencv/build") find_package(OpenCV REQUIRED COMPONENTS core imgproc objdetect ml videoio highgui) add_executable(people_detector mainwindow.cpp Read_Camera02.cpp camera.cpp YV12_RGB.cpp processpeople.cpp HOG+SVM.cpp ) target_link_libraries(people_detector PRIVATE ${OpenCV_LIBS} )注意HOG+SVM.cpp这种带加号的文件名在 CMake 里没问题,但如果你传到 Linux 服务器上编译,文件系统对加号也兼容,真正要小心的是源码文件编码。海康 SDK 的头文件经常是 GBK 编码,Qt 默认 UTF-8,混编时中文注释会变成乱码甚至报错,我一般统一转成 UTF-8 再编译。
5.2 运行期四个高频问题的血泪经验
问题一:RTSP 打不开,VideoCapture 一直返回 false。
现象是cap.isOpened()永远是假。原因有几种:RTSP 地址里的密码含特殊字符没有做 URL 编码,比如@会被解析成账号分隔符;通道号不对,主码流子码流搞混;设备固件太老只支持旧版 RTSP 路径。解决方法是先用 VLC 验证地址能不能直接打开,Win10 浏览器加载不了海康插件是常见问题,但 VLC 能作为独立验证工具判定地址本身是否可用。然后用rtsp://IP:554/Streaming/Channels/102和rtsp://IP:554/ch1/sub/av_stream两套格式都试一遍。
问题二:画面出来了,但颜色完全错乱或整体发绿发紫。
这是 YV12 和 I420 的 UV 平面顺序搞反了。海康部分型号在回调里输出的是 NV12 而不是 YV12,两者看起来一样都是 4:2:0,但 U 和 V 的排列顺序不同。解决方法是把COLOR_YUV2RGB_YV12换成COLOR_YUV2RGB_I420或COLOR_YUV2RGB_NV12再转一次,如果红蓝互换就说明 UV 顺序反了,对调 UV 平面指针即可。
问题三:检测跑起来后 Qt 界面直接白屏无响应。
原因基本是采集和检测都在 UI 线程执行,阻塞了事件循环。解决思路是照 2.3 节的线程模型改造:worker 线程做取流、转换、检测,主线程只接收 QImage 刷新。需要注意queuedConnection传大图像时会拷贝多次,如果帧率掉得厉害,可以把 QImage 改成std::shared_ptr<QImage>传递。
问题四:一个人身上叠了十几个框,或者一个框时有时无。
前者是没做 NMS 或 IoU 阈值太小,后者是hitThreshold太高导致低分帧检测不稳定。解决方法是把weights拿出来做一次阈值过滤,再按 3.3 节的方法做 NMS 合并。调整参数的顺序建议是先固定scale=1.05,改hitThreshold找检测率,最后再动winStride平衡速度。
5.3 性能测量:先量化再调优
很多同学调了半天参数,问起来效果怎么样,答案是“还行”“有点卡”,这等于没调。工程里我建议用std::chrono把三个耗时点独立测出来:单帧取流耗时、YV12 转 RGB 耗时、detectMultiScale 耗时。实现很简单:
auto t0 = std::chrono::steady_clock::now(); // 取流 + 转换 auto t1 = std::chrono::steady_clock::now(); // 检测 auto t2 = std::chrono::steady_clock::now(); double captureMs = std::chrono::duration<double, std::milli>(t1 - t0).count(); double detectMs = std::chrono::duration<double, std::milli>(t2 - t1).count(); qDebug() << "capture:" << captureMs << "ms, detect:" << detectMs << "ms";一般课设场景的合理基线是:640x360 输入分辨率下,取流加转换小于 10ms,HOG 检测小于 50ms。如果 detect 超过 150ms,优先把输入帧resize到 640x360,而不是去调金字塔参数。HOG 检测窗口是 64x128,输入 1080p 意味着要生成十几层金字塔,计算量是指数增长的。
6. 用验证脚本量化漏检与误检:调整阈值不必靠玄学
6.1 一个独立的 Python 验证脚本
HOG 参数调优最怕的就是“凭感觉”。我习惯的做法是录一段 10 秒的室内监控视频,人工标出其中 20 帧里的行人位置,然后用一个独立脚本批量跑不同参数组合,把漏检率、误检率、平均 IoU 和 FPS 全部量化出来。验证脚本用 Python 写,不污染 C++ 工程,只读取录好的视频文件和标注文件。
import cv2 import json import time def compute_iou(a, b): inter = max(0, min(a[2], b[2]) - max(a[0], b[0])) * \ max(0, min(a[3], b[3]) - max(a[1], b[1])) union = (a[2]-a[0]) * (a[3]-a[1]) + (b[2]-b[0]) * (b[3]-b[1]) - inter return inter / union if union > 0 else 0 with open("labels.json") as f: labels = json.load(f) # {"frame_id": [[x1,y1,x2,y2], ...]} for hit_threshold in [0.5, 0.0, -0.5, -1.0]: hog = cv2.HOGDescriptor() hog.setSVMDetector(cv2.HOGDescriptor_getDefaultPeopleDetector()) total_hit, total_miss, total_fp, frames, total_time = 0, 0, 0, 0, 0.0 cap = cv2.VideoCapture("test_clip.mp4") while True: ret, frame = cap.read() if not ret: break t0 = time.time() rects, weights = hog.detectMultiScale( frame, hitThreshold=hit_threshold, winStride=(8, 8), padding=(8, 8), scale=1.05) total_time += time.time() - t0 frames += 1 gt = labels.get(str(frames), []) hits = [False] * len(gt) for r in rects: if weights[r] < -0.5: continue for i, g in enumerate(gt): if compute_iou([r[0], r[1], r[0]+r[2], r[1]+r[3]], g) > 0.5: hits[i] = True # 统计漏检、误检 cap.release()脚本里最关键的是IoU > 0.5这个验收标准,它决定了“检测到了”的判定是否合理。很多课设答辩被追问“你这个框对准了吗”,就是因为没有这一道量化,全凭肉眼加嘴硬。
6.2 用指标反推参数并固化你的调参顺序
拿上面的脚本跑一组参数,会得到类似下面的记录表:
| hitThreshold | 漏检率 | 误检数/帧 | 平均 IoU | 检测耗时 |
|---|---|---|---|---|
| 0.5 | 31% | 0.2 | 0.61 | 42ms |
| 0.0 | 18% | 0.6 | 0.63 | 44ms |
| -0.5 | 11% | 1.4 | 0.64 | 43ms |
| -1.0 | 8% | 3.1 | 0.58 | 45ms |
我的经验是,先定一个可接受的漏检率上限,比如课设要求“视频里出现的人至少有 80% 被框出来”,然后选择满足这个条件的最大hitThreshold,因为阈值再低,误检数量涨得远比赛率涨得快。之后如果 FPS 不够,再调scale从 1.05 升到 1.1,重新跑脚本看漏检率变化。这套流程的好处是每个参数都有数据背书,答辩时能直接展示“为什么选这个值”。
从那以后,我每次调 HOG 检测参数都强制自己先录一段视频、写一个验证脚本、把指标量化出来,再动手改参数。先定验收指标,再谈调参,而不是对着画面反复试,这个习惯帮我少走了很多弯路。希望帮到你,也建议你把这套验证流程写进课程设计报告里,作为“模型评估”章节的实践依据。
本文还有配套的精品资源,点击获取