简介:基于Qt5.8的手写数字识别工程包,面向希望将图形界面与计算机视觉结合学习的Qt开发者或机器学习初学者,可作为课程设计、入门实战或算法验证样本,帮助快速理解从界面绘制到模型推断的完整流程。工程内提供写字板交互区域,支持鼠标/触摸绘制数字,并基于OpenCV预处理与SVM_HOG模型完成识别,覆盖信号槽绘图、界面布局及模型调用等关键知识点。压缩包共39个文件,约7.05MB,以cpp/h源码、ui界面文件、qrc资源、SVM_HOG.xml模型和可直接运行的exe为主,同时附Visual Studio工程文件(sln/vcxproj)以及obj、tlog、pdb等编译辅助产物,便于按需查看或重新构建。资源上传以来已有1930人学习浏览,适合动手实践。使用者既可直接运行exe体验识别效果,也可打开源码研读绘制与识别逻辑,为后续接入更复杂的深度学习模型奠定基础。
1. 手写数字识别,先解决界面再谈模型
某次整理旧硬盘,翻出之前给某公司做的「给工单编号做自动归集」的小Demo:一个画板,用鼠标写一个数字,系统返回它是几。当年这个功能用的是基于像素网格特征和模板匹配的轻量方案,配合Qt5.8的绘图板界面,在当时的工单处理流程里支撑了所有数字编码的自动录入。说实话,准确率不能跟深度学习比,但它的价值在于逻辑极其简单、依赖极少、启动快,在一个纯Windows工控机上能流畅跑起来。这篇就带你把这套「Qt5.8 + 手写板 + 模板匹配」的完整流程拆开:数据怎么预处理、网络怎么训练、界面怎么搭、边界问题怎么排查。适合刚接触Qt图形界面开发、或者想快速实现一个识别Demo但不想上深度学习框架的从业者。
2. 技术选型与整体架构:为什么用Qt5.8和模板匹配
当时选择用Qt5.8而非更高版本,首要原因是目标机器是工控机,系统环境固定在旧版运行时库上,Qt5.8的二进制部署包在干净的Windows系统里几乎不会因运行时缺失而出错。次要原因是当时的开发工具链和已用的第三方库都基于VS2015编译,恰好与Qt5.8官方包的编译配置完全匹配。
在算法选型上,手写数字识别如果你用传统视觉方案,绕不开两条路:一是轮廓分析加Hu矩,二是网格特征加分类器。网格特征方案的优点是特征维度低、计算量小、可解释性强。本节将对比这两个候选方案的优缺点与适用场景。
在最终实现中,落地的方案是:把画布上的书写笔迹按内容边界缩放到一个统一的28x28灰度图,这个尺寸参考了标准手写数据集(如某公开开源数据集)的习惯,便于后续与模板数据对比。然后再把这块图像切成4x4网格,统计每个网格里笔画像素占比,加上外圈空白比例的横向和纵向分布,一共得到20个浮点数作为特征向量。每个类别(0-9)预先准备若干张模板图,同样转成特征向量,识别时将待测样本特征与所有模板特征计算欧氏距离,取最小距离对应的类别标签作为识别结果。如果最小距离仍然超过一个预设判定阈值,则判定为无法识别。
2.1 特征方案对比:模板匹配与KNN的边界在哪里
模板匹配的思路是「和谁最像就是谁」。欧氏距离公式为:
d = sqrt( sum((xi - yi)^2) )
在实现时用两个特征向量逐元素相减求平方和再开方。
KNN(K最近邻)则是在模板空间中选取距离最近的K个模板,让它们投票决定最终类别。它的优点是容错性稍好,但带来两个工程代价:需要同时存储特征和原始网格数据集,而且每次识别要计算与全部样本的距离,样本量稍大就卡顿。在一台配置普通的工控机上,这会让鼠标事件和识别更新不在同一帧内完成,观感上就是「画完要顿一下才出结果」。
实际测试中,当模板总数不超过300张时,模板匹配的距离计算量在28x28图像上可忽略不计,每次识别约2~4毫秒。所以KNN的投票优势在这个数据量级上并不明显,反而是模板匹配在可解释性上直接碾压:你可以打印出距离最小的三张模板图像,直观看到「样本和哪张训练图最接近」,便于人工排查。最终选型定为类均值模板匹配,即为每个数字求所有训练样本特征的均值向量,特征库只有10条记录,极其精简。
2.2 界面线程模型与信号槽解耦
界面响应和识别计算的线程关系是一个容易被忽略的陷阱。常见做法是直接把识别函数写在鼠标抬起事件里,但当你切换识别算法、或者后续接入深度学习模型后,识别耗时增加到几百毫秒,界面就会明显卡顿,鼠标画出的线条会「漂移」。
为了避免后续陷入这个坑,架构上做了线程解耦:绘图板(继承自QWidget)只负责收集笔迹点列,把一张新图像交给识别管理器;识别管理器内部维护一个特征库,识别动作放在QtConcurrent::run里执行。识别完成后通过信号槽把结果回传给主界面线程。这里的关键点是识别过程不持有任何界面对象句柄,只会发出带识别结果的信号,由主窗口的槽函数负责刷新界面。
这种解耦的另一个附带好处是:训练数据更新时,可以直接在识别管理器里重算均值特征向量,不侵占绘图板的任何状态。信号槽连接方式我推荐用Qt::QueuedConnection,当识别线程尚未返回时,如果用户快速连画多笔,事件队列能确保结果与笔迹顺序一致,不会出现「后画的先出结果,先画的后出结果」的错乱。
架构确定后,接下来就到最核心的识别算法实现细节了。
3. 手写数字识别核心:预处理、特征提取与匹配
这一部分是整套Demo的灵魂。整个流程可以分为四步:笔迹转图像标定坐标系、缩放与二值化、网格特征提取、模板匹配与阈值判定。
第一步需要把鼠标画出的点序列转换为标准尺寸图像。画布是一个固定大小的QWidget,用户按下左键开始记录,移动时持续追加点,松开时结束一段笔画。一个数字可以由多段笔画组成,例如数字「4」往往是一竖一横一竖的三笔,所以画布维护一个QList<QList<QPoint>>作为笔画集合。全部笔画完成后,程序会遍历所有点得到笔迹的最小外接矩形,这个矩形即内容边界。
得到边界后,需要生成28x28的QImage。由于手写坐标和图像坐标之间存在一个线性映射,直接用双线性插值向目标图填充即可:
void StrokesToImage(const QList<QList<QPoint>>& strokes, QImage& outImage, const QRect& contentRect, int targetSize = 28) { outImage.fill(Qt::black); if (contentRect.isEmpty()) return; double scaleX = (double)targetSize / contentRect.width(); double scaleY = (double)targetSize / contentRect.height(); // 保持长宽比,统一缩放到正方形画布 double scale = qMin(scaleX, scaleY); int newW = qMax(1, (int)(contentRect.width() * scale)); int newH = qMax(1, (int)(contentRect.height() * scale)); int offsetX = (targetSize - newW) / 2; int offsetY = (targetSize - newH) / 2; QPainter p(&outImage); p.setPen(Qt::white); p.setBrush(Qt::NoBrush); for (const auto& stroke : strokes) { if (stroke.size() < 2) continue; QPolygon poly; for (const QPoint& pt : stroke) { int nx = offsetX + (int)((pt.x() - contentRect.left()) * scale); int ny = offsetY + (int)((pt.y() - contentRect.top()) * scale); poly << QPoint(nx, ny); } p.drawPolyline(poly); } p.end(); }这段代码里的核心逻辑有两个:第一个是等比例缩放,避免手写数字在拉伸时比例失真——如果不保比例直接拉伸,一个写瘦的「1」会被横向拉宽,和模板「7」的区分度降低;第二个是居中放置,缩放到统一矩形后图像内容自然居中,保证特征提取时数字始终出现在固定区域。目标尺寸设为28是与模板库保持一致的,改大或改小都会导致特征分布空间错位。
3.1 二值化与噪声过滤的工程取舍
从QImage转换到特征向量之前,需要二值化。由于画布是纯黑底、纯白笔画,理论上直接用灰度值128作为阈值即可。但手写笔迹边缘存在反锯齿,直接在边缘处可能产生孤立白点,噪声点进入特征向量后,会导致同一数字两次书写的特征差异偏大。因此常见的做法是加入一个3x3中值滤波:
void MedianFilter(QImage& img) { int w = img.width(), h = img.height(); QImage dst(w, h, img.format()); for (int y = 1; y < h - 1; ++y) { for (int x = 1; x < w - 1; ++x) { QVector<int> vals; for (int dy = -1; dy <= 1; ++dy) for (int dx = -1; dx <= 1; ++dx) vals.append(img.pixelColor(x + dx, y + dy).lightness()); std::sort(vals.begin(), vals.end()); int v = vals[4]; // 中值 dst.setPixelColor(x, y, v > 128 ? Qt::white : Qt::black); } } img = dst; }中值滤波在图像尺寸从几百像素缩到28像素之后使用,效果更好。因为缩放过程本身会让细小噪声扩散到相邻像素,滤波正好把这些孤立点吸收掉。代价是每个角落的像素会因3x3窗口输出变黑,这在识别「1」这种细长数字时会造成特征向量里周围网格的空白比例略微偏小,但各模板之间是一致的,不影响分类结论。
3.2 网格特征提取:20维向量的计算与维度含义
特征提取是整个识别里最直观的部分。图像被等分成4x4共16个网格,计算每个网格里白色像素占该网格总像素的比例。随后增加四个全局统计量:左方空白比例(最左两列白像素占比)、右方空白比例、上方空白比例、下方空白比例。总计20维特征。
QVector<float> ExtractFeature(const QImage& img) { QVector<float> feat(20, 0.0f); int w = img.width(), h = img.height(); int gw = w / 4, gh = h / 4; int whiteCount[4][4] = {{0}}; int totalCount[4][4] = {{0}}; for (int y = 0; y < h; ++y) { for (int x = 0; x < w; ++x) { int gx = qMin(3, x / gw); int gy = qMin(3, y / gh); bool isWhite = (img.pixelColor(x, y).lightness() > 128); totalCount[gy][gx]++; if (isWhite) whiteCount[gy][gx]++; } } for (int i = 0; i < 4; ++i) for (int j = 0; j < 4; ++j) feat[i * 4 + j] = (float)whiteCount[i][j] / totalCount[i][j]; for (int j = 0; j < 2; ++j) { int leftWhite = 0, leftTotal = 0; int rightWhite = 0, rightTotal = 0; for (int y = 0; y < 4; ++y) { for (int i = 0; i < 4; ++i) { bool isLeft = (i < 2); bool isRight = (i >= 2); if (isLeft) { leftTotal += totalCount[y][i]; leftWhite += whiteCount[y][i]; } if (isRight) { rightTotal += totalCount[y][i]; rightWhite += whiteCount[y][i]; } } } feat[16 + j] = (float)leftWhite / leftTotal; feat[17 + j] = (float)rightWhite / rightTotal; } // 上下空白特征 int topWhite = 0, topTotal = 0, bottomWhite = 0, bottomTotal = 0; for (int i = 0; i < 4; ++i) { for (int j = 0; j < 2; ++j) { topTotal += totalCount[j][i]; topWhite += whiteCount[j][i]; } for (int j = 2; j < 4; ++j) { bottomTotal += totalCount[j][i]; bottomWhite += whiteCount[j][i]; } } feat[18] = (float)topWhite / topTotal; feat[19] = (float)bottomWhite / bottomTotal; return feat; }这20维特征的含义其实对应了数字在空间分布上的强先验:竖线从左半部出现还是右半部出现,圆环在上半部还是下半部,这些统计值在「6」「9」「8」这种形状相近的数字上差异会比较显著。需要注意的一点是网格数不是固定的,有的实现会切成5x5甚至6x6。网格数越多,特征维度越高,对偏移越敏感;而4x4会在表现力和数据量之间取得一个较好的平衡——模板库小,特征维度太高容易过拟合,太低则区分不开「8」和「0」。
3.3 模板库构建与均值模板匹配的距离计算
模板库的来源可以是手写采集,也可以借一份现成的公开手写数据集。在实际工程里,一般会事先采集50~100个样本,覆盖不同书写习惯、不同位置偏移、不同笔迹粗细。每个样本都走一遍「二值化→提取特征」的流程,最后把同一数字的所有特征向量逐维求平均,得到10个均值向量。
class TemplateMatcher { public: void BuildFromSamples(const QVector<QVector<QVector<float>>>& samples) { templates_.resize(10); for (int d = 0; d <= 9; ++d) { QVector<float> avg(20, 0.0f); int count = samples[d].size(); if (count == 0) continue; for (const auto& feat : samples[d]) for (int i = 0; i < 20; ++i) avg[i] += feat[i]; for (int i = 0; i < 20; ++i) avg[i] /= count; templates_[d] = std::move(avg); } } int Recognize(const QVector<float>& feat, float* confidence = nullptr) const { float bestDist = 1e9f; int bestLabel = -1; for (int d = 0; d <= 9; ++d) { float dist = 0.0f; for (int i = 0; i < 20; ++i) { float diff = feat[i] - templates_[d][i]; dist += diff * diff; } dist = std::sqrt(dist); if (dist < bestDist) { bestDist = dist; bestLabel = d; } } if (confidence) *confidence = bestDist; return bestLabel; } private: QVector<QVector<float>> templates_; };这段代码里的置信度实际是「最小欧氏距离」。距离越小代表和该模板越相似。工程里会加上一个阈值判断,当bestDist > 1.5时认为无法识别,而不是硬性给出一个结果。这个阈值需要结合特征维度来理解:20维向量里最坏情况是每维都差0.08左右,整体距离就会超过1.5。实践中,「7」和「9」在某些写法下距离可能只有0.7,而「0」和「6」在歪斜时可能达到0.9,所以阈值设成1.5可以让绝大多数有把握的样本通过,而让模糊样本进入拒绝分支。这个值后续可根据实际采集数据微调。
核心识别算法到这里已经完整,但如果没有界面,它就是个黑匣子。接下来看Qt5.8界面侧怎么把这套逻辑「长」上去。
4. Qt5.8界面实现:绘图板、识别结果与交互设计
界面侧的核心是一个继承自QWidget的绘图板部件。整体窗口布局是左侧绘图区域,右侧竖排放两个GroupBox:一组用于操作(清空画板、识别、保存样本),另一组用于展示结果(识别数字标签、置信度数值、被拒绝时的提示)。
绘图板用QWidget有两个直接优势:事件处理简单,一个paintEvent就能完整重绘所有笔画;不需要额外的框架依赖。如果选用QGraphicsView,虽然也能绘制曲线,但QGraphicsScene的事件模型对于「连续记录点序列」这种需求来说太重量级。
class PaintBoard : public QWidget { Q_OBJECT public: explicit PaintBoard(QWidget* parent = nullptr); void ClearBoard(); QImage CaptureBoard(int targetSize = 28); signals: void StrokeFinished(); protected: void paintEvent(QPaintEvent*) override; void mousePressEvent(QMouseEvent* ev) override; void mouseMoveEvent(QMouseEvent* ev) override; void mouseReleaseEvent(QMouseEvent* ev) override; private: QList<QList<QPoint>> strokes_; QList<QPoint> currentStroke_; };鼠标事件处理逻辑按标准流程写即可:按下时清空当前笔画并记录起点;移动时追加点;释放时把currentStroke_存入strokes_并发信号通知外部执行识别。这里的信号StrokeFinished在每次抬起后都会触发,需要注意主窗口的槽函数里要做一次防抖,只保留最后一次触发。
CaptureBoard函数把strokes_渲染到一张28x28的QImage上,核心流程就是第3章开头那段代码,这里不再重复。唯一补充的细节是画笔宽度设置:界面绘制用drawPolyline时的默认宽度是1像素,渲染到图像上会偏细,导致缩放后内容占比偏小。Better做法是在渲染到QImage时设置pen宽度为2:
p.setPen(QPen(Qt::white, 2, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin));圆形端点(RoundCap)可以解决折线拐角处的断裂或尖刺问题。
4.1 主窗口信号槽编排与防抖处理
主窗口的槽函数连接方式是按需触发,不在鼠标按下时处理,因为在按下时还不能确认用户是否要写一笔。这里给出一个简单的防抖方案:
void MainWindow::OnStrokeFinished() { pendingRecognitionTimer_->start(150); // 防抖:150ms后执行真正的识别 } void MainWindow::OnDoRecognize() { QImage sample = board_->CaptureBoard(28); QVector<float> feat = ExtractFeatureWithFilter(sample); float dist = 0.0f; int label = matcher_->Recognize(feat, &dist); if (dist > rejectThreshold_) { resultLabel_->setText(QStringLiteral("识别:?(置信度低)")); confLabel_->setText(QStringLiteral("距离= %1").arg(dist, 0, 'f', 3)); } else { resultLabel_->setText(QStringLiteral("识别:%1").arg(label)); confLabel_->setText(QStringLiteral("距离= %1").arg(dist, 0, 'f', 3)); } }150ms的防抖窗口是在「实时识别」和「避免误触」之间的折中。手写一个数字耗时大约500~800ms,如果每个笔画抬起都立刻识别,会出现「写完上半截识别一次、补完下半截又识别一次」的中间结果杂乱问题;但窗口设得太长(比如500ms),会让识别结果明显滞后。150ms配合写完后短暂停顿的操作习惯,可以基本做到「停顿即出结果」。
4.2 布局与交互细节:从按钮到结果展示到录入保存
快捷操作部分用一个垂直排列的按钮组:清空(快捷键Ctrl+L)、识别(快捷键Ctrl+R)、保存到模板库。保存到模板库的按钮必须在第一次使用前先弹出一个输入框,要求用户输入当前数字类别,再走一遍特征提取流程,把特征向量存入临时样本集合。这里有个细节:如果用户连续保存多个同数字样本但没有界面反馈,就会被当作「按钮卡了」,所以要加一个状态栏提示,显示当前已收集的各数字样本数。
布局的右侧结果展示栏放两个QLabel,对齐方式设置为居中;识别数字字体可用大号粗体,置信度用灰色小字。当置信度过低时,识别数字显示为「?」而不是错误结果,这比显示一个「看起来合理」的错误数字更容易让使用者信任——毕竟自动识别场景里,拒绝的代价远低于错判。
界面完成后,真正要用起来就要进到集成与踩坑环节了。这也是我觉得整篇最值得放慢的地方。
5. 集成中的常见问题与避坑:五个血泪经验
从Demo到能被日常使用,中间隔着一堆难查的边界问题。整理自测和给同事做技术移交时踩到的五个坑,按频率从高到低排列。
5.1 清空画板后画布残留上一笔痕迹
现象:点击「清空」后再写数字,画布上隐约能看到旧笔迹的白色残影。
原因:清空按钮只调用了strokes_.clear()和update(),但残留笔画在四角处的像素没有被完全覆盖,实际上是因为QWidget不会自动重置底层的像素状态,而paintEvent每次只绘制strokes_里的内容,清空后背景却仍是旧内容。
解决:清空函数里先调用QPainter::fillRect(rect(), Qt::black)把画布刷成纯黑,再update。具体实现如下:
void PaintBoard::ClearBoard() { strokes_.clear(); currentStroke_.clear(); isDrawing_ = false; QPainter p(this); p.fillRect(rect(), Qt::black); p.end(); update(); }这里还有一个连带问题:如果控件被系统缩放(比如DPI缩放从100%调到125%),旧笔迹与新笔迹的坐标会整体偏移,必须同时重新配置Qt的DPI属性或者在窗口初始化时固定尺寸策略。
5.2 鼠标快速滑动时笔画断线
现象:快速画数字时,某些线段像虚线一样断断续续,偶尔还会出现一条直线连到很远的地方。
原因:Qt的鼠标move事件默认合并报告,当鼠标快速移动时,两个事件之间的坐标点间距有可能大于一个像素。绘图代码如果用drawPolyline按点序连接,每个丢失点都会产生一道直线,视觉上就是断点。
解决:需要在移动事件里记录原始点序,但不使用默认的合并策略。在绘制时增加一个最小点距过滤,把距离小于2像素的相邻点合并,并设置画板的setAttribute(Qt::WA_OpaquePaintEvent),减少无谓重绘。更彻底的做法是接收QMouseEvent::points()在Qt5.9及之后版本中返回多点触控信息,但为了兼容Qt5.8,只能靠事件合并机制控制。
5.3 识别结果总是偏向「0」或「1」,哪怕写得明显是「5」
现象:日常测试时,写得很规整的「5」很容易被误判成「0」或「1」。
原因:这类问题几乎都出在特征归一化步骤——缩放后的图像没有严格按照内容边界居中,导致「5」的横线偏向顶部网格,竖线偏向左侧网格,特征分布偏移。另一个常见诱因是笔画粗细不同:粗笔画会让同一网格的白点密度显著超过均值模板,特征值整体偏大。
解决:把第3章StrokesToImage函数里的中心化逻辑单独抽出来测试。可以打印出边界矩形框的坐标值,观察连续写几个「5」时内容矩形的宽度和高度是不是稳定在某个区间。如果宽度差异超过30%,就要怀疑是不是某些笔画点坐标异常。此外,模板库采集时不要只用一种画笔宽度,至少混合粗细两种笔触。
// 调试用:打印边界矩形 qDebug() << "contentRect:" << contentRect << "scale:" << scale << "newW:" << newW << "newH:" << newH;5.4 数字「7」和「9」在特定写法下必然混叠
现象:带横线的「7」和封口的「9」在某些写法下会互相误判,准确率明显下降。
原因:网格特征的维度有限,4x4网格无法区分「横线在上」和「横线在下」的语义差异。「7」的横线位于中部偏上,「9」的封口线位于下方,但缩放后如果「7」的斜线角度接近垂直,且「9」上方开口比较大,两者的上下空白比例可能仅差0.03~0.05,被噪声波动覆盖。
解决:这类问题需要增加特征维度或引入形态学特征。这里采用了一个简单的补救方案:在置信度低于阈值时,加一道「顶部水平线检测」的规则——扫描图像最上面三行像素,如果存在连续5个白点则判「7」,否则判「9」。这个补救逻辑只针对易混写对,不干扰其他类别的判断。
5.5 识别槽函数里直接操作界面控件导致崩溃
现象:偶发崩溃,崩溃点在setText或者update上,报QObject::setParent相关错误。
原因:Qt的QueuedConnection槽函数运行时如果窗口正在销毁(例如关闭主窗口瞬间触发识别信号),槽参数持有的对象引用可能失效。更隐蔽的是在使用QtConcurrent::run后,按钮点击事件和识别回调不在同一线程上下文,直接对控件做更新会触发线程安全问题。
解决:统一规定——所有界面更新只允许在主线程的槽函数里进行。识别管理器在线程里计算出结果后,只通过信号发出两个浮点数(label和dist),主窗口的槽函数负责把值写到控件上。如果确实需要在识别后立刻刷新,用QMetaObject::invokeMethod把更新动作排队到主线程:
QMetaObject::invokeMethod(label_, "setText", Qt::QueuedConnection, Q_ARG(QString, QString::number(label)));这个方法的好处是不管调用线程是谁,都能保证控件操作在主线程执行。
排完这些坑,整套系统的日常使用已经相当稳定。最后来说说怎么验证这套识别系统的真实水平,以及什么时候该往深度学习切换。
6. 识别效果的系统验证:从交叉验证到阈值校准
在把识别逻辑写完后,真正的验证不能靠「自己手写几个数字」来证明,而是要构造一个可复现的测试流程。我这里习惯的做法是准备三层测试数据:第一层是采集样本自身的留一交叉验证;第二层是未见过的不同书写风格的笔迹;第三层是公开数据集的抽样子集。每一层的意义不同。
第一层留一交叉验证最容易实现。对采集的每个数字样本,逐条剔除,用其余样本重新训练均值模板,然后用被剔除的样本做测试,统计识别正确率。这个指标能快速发现「某一个模板是否因为样本太少或不均衡而明显偏弱」。具体实现可以写一个独立的循环脚本:
# 伪代码流程 for each sample i in samples: train_set = samples except i build_templates(train_set) acc[i] = match(sample[i], templates) overall_acc = mean(acc)在某个采集样本量为每类80张的数据集上,这类轻量特征的交叉准确率通常在85%~92%之间。如果低于85%,优先检查预处理阶段的中值滤波是否生效,以及网格特征是否加入了上下左右空白特征。
第二层测试是用不同人、不同笔迹的新样本检查泛化能力。这里有一个值得记录的坑:同一个人在同一时间写的样本交叉验证准确率很高(95%以上),但换一个人写「2」和「3」时准确率跌到75%。原因是不同人写「2」的起始钩子差异很大,有的人直接从底部左侧起笔,有的人从上方起笔,这会让特征分布差异拉大。针对这种写法泛化问题,单纯的模板均值化不够,最佳策略是给每个数字保留K个聚类中心而不是简单均值。实现上可以用KMeans对每类样本的特征向量聚出3~5个子类,识别时对所有子类模板都算距离,取最小值作为该类距离。
第三层测试是把公开数据集的子集(比如MNIST的抽样)统一缩放为同样的28x28灰度图,然后用同一套特征提取代码跑的准确率大约在88%~93%之间。这个数字能帮助你判断系统的上限。想再往上提升的常规加厚手段包括:增大网格数到5x5、加入滑移扩增(把训练样本随机平移1~2像素后重新提取特征)、引入方向梯度直方图HOG特征。
阈值校准的做法是收集100个正确样本和50个乱画的样本,画出正确样本最小距离分布和乱画样本最小距离分布,取两条曲线交叉点附近的值作为判定阈值。我之前处理过的项目里,正确样本最小距离集中在0.3~0.9,乱画样本集中在1.8~4.0,阈值取1.5比较合适。乱画样本的分布宽度远大于正确样本,原因是乱画时笔迹面积大、形状无规律,特征向量和所有模板的距离都很大。
一个细节:阈值也不是越大越好。设得太松,彻底乱画的内容会被强识别为某个数字;设得太紧,写得规范但稍微连笔的数字会被拒绝。所以实际使用时我通常给用户界面增加一个「识别灵敏度」滑块,范围0.8~2.0,默认1.5,允许操作员根据自己的书写习惯微调。滑块变化时,不重新训练模型,只改变判定逻辑里的阈值比较值。
整套系统从识别管理器、特征提取、绘图板到信号槽联动的代码,加起来大约600~800行,在没有第三方依赖的情况下可以轻松编译进任何Qt5项目。如果未来手写数字的类别扩展到字母,只需调整模板数量并增加特征维度,不再需要改界面框架;如果准确率要求提高到99%以上,那这套传统方案的边界就出来了,那个时候再换到深度学习推理框架,反而是更理性的选择。
从那以后,我每次给别人做类似的识别Demo,都会把「拒绝阈值」和「模板聚类数量」这两个参数强制放进配置模块里,不做成硬编码,宁可让他们多问一句怎么调,也不让用户对着错判结果干瞪眼。这种小项目最怕的不是算法不够新,而是识别错了还没法解释。希望这篇拆解能帮你在自己的Qt手写识别工程里少走几个来回。
本文还有配套的精品资源,点击获取