【项目】手写数字识别的Qt实现

【项目】手写数字识别的Qt实现

工作流整理

  • 利用Pytorch训练手写数字识别模型
  • 将模型转化为onnx格式,便于OpenCV调用
  • Qt界面开发
    • 基本功能测试:包含手写板、检测与清除按钮、预测与置信度输出
    • 完整界面化:菜单,多元功能
  • 创新功能
    • 多模态交互:语音/手势辅助、压感支持
    • 模型优化与部署创新:轻量化模型、模型切换、加速推理
    • 实时交互反馈:逐笔画检测、置信度可视化、错误纠正闭环
    • 数据增强与用户参与
    • 界面设计与功能扩展
    • 跨平台与隐私保护
    • 扩展性与生态集成
    • 个性化与用户体验

简单的Pytorch训练

根据【知乎】基于 pytorch 搭建简单网络实现 MNIST 手写数字识别这一指引先简单做一个训练模型

先导入torch相关库

import torchvision.datasets
import torch
from torch.utils import data
from torchvision import transforms
from torch import nn

本项目基于MNIST项目实现,先导入MNIST

trans = transforms.ToTensor()

mnist_train = torchvision.datasets.MNIST(
    root = './data', train = True, transform = trans, download = True
)
mnist_test = torchvision.datasets.MNIST(
    root = './data', train = False, transform = trans, download = True
)

torch有提供比较方便的接口

batch_size = 64
train_loader = data.DataLoader(mnist_train, batch_size = batch_size)
test_loader = data.DataLoader(mnist_test, batch_size = batch_size)

接着构建一个简单的网络

class NeuralNetwork(nn.Module):
    def __init__(self):
        super(NeuralNetwork, self).__init__()
        self.flatten = nn.Flatten()

        self.linear_relu_stack = nn.Sequential(
            nn.Linear(28 * 28, 512),
            nn.ReLU(),

            nn.Linear(512, 512),
            nn.ReLU(),

            nn.Linear(512, 10),
            nn.ReLU()
        )

    def forward(self, X):
        X = self.flatten(X)
        logits = self.linear_relu_stack(X)
        return logits

这是指引上的写法,事实上并不是很好,等下面把训练写完再细讲

损失函数选择交叉熵损失,优化方式选择随机梯度下降

loss_fn = nn.CrossEntropyLoss()
optimizer = torch.optim.SGD(model.parameters(), lr = 1e-3)

然后就是训练和检测函数

def train(dataloader, model, loss_fn, optimizer):
    size = len(dataloader.dataset)
    for batch, (X, y) in enumerate(dataloader):
        X, y = X.to(0), y.to(0)

        pred = model(X)
        loss = loss_fn(pred, y)

        optimizer.zero_grad()
        loss.backward()
        optimizer.step()

        if batch % 100 == 0:
            loss, current = loss.item(), batch * len(X)
            print(f'loss : {loss : >7f} [{current : >5d} / {size : >5d}]')
def test(dataloader, model):
    size = len(dataloader.dataset)
    model.eval()
    test_loss, correct = 0, 0

    with torch.no_grad():
        for X, y in dataloader:
            X, y = X.to(0), y.to(0)
            pred = model(X)
            test_loss += loss_fn(pred, y).item()
            correct += (pred.argmax(1) == y).type(torch.float).sum().item()

        test_loss /= size
        correct /= size
        print(f'Test Error:\n Accuracy : {100 * correct : >0.1f}%, Avg loss: {test_loss : >8f} \n')

主要过程如下

epochs = 50
for t in range(epochs):
    print(f'Epoch {t + 1}\n----------------')

    train(train_loader, model, loss_fn, optimizer)

    test(test_loader, model)

print('Completed.')

这个时候回到一开始的网络设置,先讲一下主要接口

  • nn.Linear是个线性变换,如下

    \[y = Wx + b \]

  • nn.ReLU是个激活函数

    \[ReLU(x) = \max(0, x) \]

前两层网络的工作是做特征提取和特征激活

最后一步是把特征映射到对应的数字,最后这一层的工作和前面是不一样的,只会考虑线性的评分,而不能用ReLU再一次激活

从数学的角度来看,线性映射出是个结果之后,如果再进行ReLU,那么负值就会被截掉,而交叉熵损失又需要用到softmax函数,这个时候softmax的数值会失真,因而训练效果不会很好

就目前的网络而言,修改方案就是直接去掉最后一个nn.ReLU()

给出最后一次的结果

Epoch 50
----------------
loss : 0.226819 [    0 / 60000]
loss : 0.261751 [ 6400 / 60000]
loss : 0.190327 [12800 / 60000]
loss : 0.333068 [19200 / 60000]
loss : 0.221980 [25600 / 60000]
loss : 0.304426 [32000 / 60000]
loss : 0.190971 [38400 / 60000]
loss : 0.370367 [44800 / 60000]
loss : 0.297724 [51200 / 60000]
loss : 0.401518 [57600 / 60000]
Test Error:
 Accuracy : 92.1%, Avg loss: 0.004319 

Completed.

先写个可视化结果看看模型效果

import matplotlib.pyplot as plt

model.eval()
my_test_loader = data.DataLoader(
    mnist_test,
    batch_size = 64,
    shuffle = True
)
X, y = next(iter(my_test_loader))
X, y = X.to(0), y.to(0)

with torch.no_grad():
    pred = model(X)
    pred_label = pred.argmax(1)

plt.figure(figsize = (10, 10))
for i in range(9):
    plt.subplot(3, 3, i + 1)
    plt.imshow(X[i].cpu().squeeze(), cmap = 'gray')
    plt.title(f'true = {y[i].item()}, pred = {pred_label[i].item()}')
    plt.axis('off')

plt.show()

然后就是把模型保存下来

torch.save(model.state_dict(), 'model.pth')
print('Save Pytorch Model State to model.pth')

转化为ONNX格式

不同格式的模型其实都存储着相似的东西,无非就是一些基础信息和权重以及偏置的张量

用onnx接口直接转化就好了

device = 'cpu'
model = NeuralNetwork().to(device)
state_dict = torch.load('model.pth', map_location = device)
model.load_state_dict(state_dict)
model.eval()

dummy_input = torch.randn(1, 1, 28, 28, device = device)

torch.onnx.export(
    model,
    dummy_input,
    'mnist.onnx',
    export_params = True,
    opset_version = 11,
    dynamo = False,
    do_constant_folding = True,
    input_names = ['input'],
    output_names = ['logits'],
    dynamic_axes = {
        'input' : {0 : 'batch_size'},
        'logits' : {0 : 'batch_size'}
    }
)

print('Successfully exported.')

接下来讲解一些参数的含义

dummy_input = (batch, channel, height, width)

这是用于统一格式的

export_params = True

这是用于将权重和偏置写入ONNX,否则只导出结构无法进行推理

offset_version = 11
dynamo = False

推理时要用到OpenCV DNN,而其最高的稳定支持版本就是11

把dynamo关掉也是为了给OpenCV稳定接口,不产生一些无法利用的数据

input_names = ['input'],
output_names = ['logits'],
dynamic_axes = {
    'input'  : {0 : 'batch_size'},
    'logits' : {0 : 'batch_size'}
}

这块区域用来告诉ONNX第0维是动态维,不固定为1,便于后续多目标推理

简单Qt页面开发

界面设计

为了简单测试功能,我设计了下面这个布局

 _________ [button(predict)]
|         |[line edit(predict)]
|DrawBoard|[line edit(confidence)]
 _________ [button(clear)]

这时候我初步接触了基于对象继承的个性化组件设计

DrawBoard在Qt组件中并不存在,但是可以自主设计一个

首先在ui上面放一个QWidget占位,让DrawBoard基于QWidget进行设计,然后QWidget就可以直接提升为DrawBoard了

DrawBoard设计

首先给出接口

#pragma once

#include <QWidget>
#include <QImage>
#include <QPoint>

class DrawBoard : public QWidget {
    Q_OBJECT

private:
    QImage canvas;
    QPoint lastPos;

public:
    explicit DrawBoard(QWidget *parent = nullptr);

    void clear();
    std::vector<float> getNormalizedSize() const;

    void paintEvent(QPaintEvent *) override;
    void mousePressEvent(QMouseEvent *) override;
    void mouseMoveEvent(QMouseEvent *) override;
};

主要的原理就是记录鼠标的移动,通过上一次位置和当下的位置连接画出来

初始化阶段产生一个mnist长宽放大十倍的画布,背景设置为黑色

DrawBoard::DrawBoard(QWidget *parent)
    : QWidget(parent),
    canvas(280, 280, QImage::Format_Grayscale8) {
    canvas.fill(Qt::black);
    setFixedSize(280, 280);
}

清空就是把背景变为全黑

void DrawBoard::clear() {
    canvas.fill(Qt::black);
    update();
}

由于画布大小与mnist训练出的模型格式不同,需要变成目标格式并把数值压平

std::vector<float> DrawBoard::getNormalizedSize() const {
    QImage small = canvas.scaled(28, 28, Qt::IgnoreAspectRatio, Qt::SmoothTransformation);

    std::vector<float> data(28 * 28);
    for (int y = 0; y < 28; y++) {
        for (int x = 0; x < 28; x++) {
            int gray = qGray(small.pixel(x, y));
            data[y * 28 + x] = gray / 255.f;
        }
    }

    return data;
}

然后是正常的显示函数

void DrawBoard::paintEvent(QPaintEvent *) {
    QPainter p(this);
    p.drawImage(0, 0, canvas);
}

接下来处理鼠标行为

规定只有在鼠标左键按下时触发位置设置

void DrawBoard::paintEvent(QPaintEvent *) {
    QPainter p(this);
    p.drawImage(0, 0, canvas);
}

接着是鼠标移动的逻辑

void DrawBoard::mouseMoveEvent(QMouseEvent *e) {
    if (!(e->buttons() & Qt::LeftButton)) return;

    QPainter p(&canvas);
    p.setPen(QPen(Qt::white, 15, Qt::SolidLine, Qt::RoundCap));
    p.drawLine(lastPos, e->pos());
    lastPos = e->pos();
    update();
}

e->buttons()相对于e->button的区别是,它是个状态判别函数,即现在的鼠标左键是按下的时候就可以继续,而非必须“按下”左键才能继续

鼠标移动时,就把上一个点位和当下的点位连起来

按钮行为

clear的按钮操作很简单

void MainWindow::on_btnClear_clicked() {
    ui->editPred->clear();
    ui->editConf->clear();
    ui->Board->clear();
}

然后是predict按钮,这涉及了OpenCV相关的接口

首先初始化的时候加载模型

cv::dnn::Net net; // 位于MainWindow.h
MainWindow::MainWindow(QWidget *parent)
    : QMainWindow(parent)
    , ui(new Ui::MainWindow)
{
    ui->setupUi(this);

    net = cv::dnn::readNetFromONNX("mnist.onnx");

    connect(ui->btnPred, &QPushButton::clicked, this, &MainWindow::on_btnPred_clicked);
    connect(ui->btnClear, &QPushButton::clicked, this, &MainWindow::on_btnClear_clicked);
}

由于ONNX导出的是logits,因此需要自己再写一个softmax函数

\[softmax(x) = \frac{e^{x_i}}{ \sum e^{x_i}} \]

cv::Mat softmax(const cv::Mat& logits) {
    CV_Assert(logits.rows == 1);

    cv::Mat probs;
    logits.copyTo(probs);

    double maxVal;
    cv::minMaxLoc(probs, nullptr, &maxVal); // 定位最大值
    probs -= maxVal; // 减去最大值稳定数值

    cv::exp(probs, probs); // exp(src, tar)

    double sum = cv::sum(probs)[0];
    probs /= sum;

    return probs;
}

接着直接用ONNX的数据就可以推理了

void MainWindow::on_btnPred_clicked() {
    auto data = ui->Board->getNormalizedSize();

    cv::Mat input(1, 28 * 28, CV_32F, data.data());
    cv::Mat blob = input.reshape(1, {1, 1, 28, 28}); // 格式转换

    net.setInput(blob);
    cv::Mat out = net.forward(); // 前向传播得到输出
    cv::Mat prob = softmax(out);

    cv::Point classId;
    double conf;
    cv::minMaxLoc(prob, nullptr, &conf, nullptr, &classId);

    ui->editPred->setText(QString::fromStdString("Number: ") + QString::number(classId.x));
    ui->editConf->setText(QString::fromStdString("Confidence: ") + QString::number(conf, 'f', 4));
}

效果如下

识别模型探索

其实目前用mlp练出来的模型在使用的过程中不太理想,所以我想多跑几个类型的模型看看效果

但是又不能每个网络都单独写一个完整的代码,所以我考虑做一个体系化的lab

于是我设计了一个LAB,项目结构如下:

MNIST_Lab
├─data
│  └─MNIST
├─engine
├─experiments
├─models
├─onnx
├─pth
└─utils

这个文件结构附加地让我对python的路径设置有了更深的认识:python对于“当前”路径的判定取决于,当前“运行”的路径

比如说,我再experiments下有个train.py访问了'../data',如果我在MNIST_Lab下运行:

python experiments/train.py

那这时候data的位置在MNIST_Lab的父文件夹

而如果我在MNIST_Lab/experiments下运行:

python train.py

这时候data的位置就在MNIST_Lab下了

所以我需要先定下一个规则:我只会在MNIST_Lab/experiments文件夹下运行文件

先把一些与模型无关的组件定义好:

models(工具)

在models下定义模型,放一个注册表registry.py方便后续访问

from .mlp import MLP
from .cnn import SimpleCNN
from .lenet5 import LeNet5

MODEL_REGISTRY = {
    "mlp" : MLP,
    "cnn" : SimpleCNN,
    "lenet5" : LeNet5
}

def get_model(name : str):
    if name not in MODEL_REGISTRY:
        raise ValueError(f'Unknown model: {name}')
    
    return MODEL_REGISTRY[name]()

为了统一字符串格式,我规定字符表名和模型对应名称.py都是全小写,而类可以有大写

这里还有一个小细节,返回模型返回的是模型实例T()而非T本身

engine

这里放的是训练和测试的组件

# engine/train.py
import torch

def train_epoch(dataloader, model, loss_fn, optimizer, device):
    model.train()

    for X, y in dataloader:
        X, y = X.to(device), y.to(device)

        pred = model(X)
        loss = loss_fn(pred, y)

        optimizer.zero_grad()
        loss.backward()
        optimizer.step()
# engine/evaluate.py
import torch

def evaluate(dataloader, model, loss_fn, device):
    model.eval()
    correct, loss_sum = 0, 0.0

    with torch.no_grad():
        for X, y in dataloader:
            X, y = X.to(device), y.to(device)
            pred = model(X)
            loss_sum += loss_fn(pred, y).item()
            correct += (pred.argmax(1) == y).sum().item()

    return {
        "accuracy": correct / len(dataloader.dataset),
        "loss": loss_sum / len(dataloader)
    }

为了开包即用,我尽量对大多数变量做了参数化,后面在主程序就可以放心调用了

在evaluate.py中我犯了点错误,lost这个平均值不小心多除了一次,导致lost看起来很夸张,代码审计还是得注意一点……

utils

这是一个通用零件库,我这里放了保存模型和导出ONNX的代码

依旧记住我的原则:只在experiments/下运行

# utils/save_pth.py
from pathlib import Path
import torch

# 在experiments/ 下运行
PTH_DIR = Path('../pth')
PTH_DIR.mkdir(exist_ok = True)

def save_pth(model, name: str):
    path = PTH_DIR / f'{name}.pth'
    torch.save(model.state_dict(), path)
    return path

导出ONNX的部分与先前的大差不差,要注意的是dynamo要关掉才能保证适用OpenCV,而且动态维度其实是暂时没有必要性的,因为目前的手写识别只会接受一个样本

# utils/export_onnx.py
from pathlib import Path
import torch

ONNX_DIR = Path("../onnx")
ONNX_DIR.mkdir(exist_ok=True)


def export_onnx(
    model,
    name: str,
    input_shape=(1, 1, 28, 28),
    opset=11
):
    model.eval()

    dummy = torch.randn(*input_shape)

    path = ONNX_DIR / f"{name}.onnx"
    torch.onnx.export(
        model,
        dummy,
        path,
        dynamo = False,
        opset_version=opset,
        input_names=["input"],
        output_names=["logits"],
    )
    return path

experiments

在这里就是主要的运行代码,对于路径处理先有一个加入工作目录

import sys
sys.path.append('../')

完整代码中尽量解耦不同的组件,留出可以修改的部分

# experiments/train.py
import torch
from torch import nn
from torch.utils.data import DataLoader
from torchvision.datasets import MNIST
from torchvision import transforms

import sys
sys.path.append(r'../')
from models.registry import get_model
from engine.train import train_epoch
from engine.evaluate import evaluate

device = 'cpu'

# 在MNIST_LAB下运行
transform = transforms.ToTensor()
train_ds = MNIST('../data', train = True, download = True, transform = transform)
test_ds = MNIST('../data', train = False, download = True, transform = transform)

train_loader = DataLoader(train_ds, batch_size = 64, shuffle = True)
test_loader = DataLoader(test_ds, batch_size = 64)

modelType = 'mlp'
model = get_model(modelType).to(device)

loss_fn = nn.CrossEntropyLoss()
optimizer = torch.optim.SGD(model.parameters(), lr = 0.0049)

for epoch in range(50):
    train_epoch(train_loader, model, loss_fn, optimizer, device)
    metrics = evaluate(test_loader, model, loss_fn, device)

    print(epoch + 1, metrics)

from utils.save_pth import save_pth
from utils.export_onnx import export_onnx

outputName = "mlp"
save_pth(model, outputName)
export_onnx(model, outputName)

models(模型)

当下的工作任务是横向对比不同的模型,所以我先控制下变量:

  • loss:交叉熵损失
  • optimizer:SGD
  • lr:0.0049【宇宙终极答案,其实是因为MNIST不需要太小的学习率】
  • epochs:50

接着不同的模型就是定义不同的结构,都是继承于nn.Module的

MLP:

MLP的构造前面已经介绍过了,这里再细究一下

MLP的全称是Multilayer Perceptron,多层感知机

其在MNIST的过程是把数据压平,然后通过全连接层和非线性函数进行复合构造网络

全连接层是线性的,非线性函数用来引入非线性的性质

目前用ReLU()是因为更通用,后续可以修改

CNN(简单):

CNN的观念是局部特征常常比整体特征更有意义

于是CNN引入了三件事:

  • 局部连接(卷积核)
  • 权重共享
  • 逐步扩大感受野(即处理的范围)

先给出代码:

# models/cnn.py
import torch.nn as nn

class SimpleCNN(nn.Module):
    def __init__(self):
        super().__init__()
        self.features = nn.Sequential(
            nn.Conv2d(1, 32, 3, 1),
            nn.ReLU(),
            nn.MaxPool2d(2),

            nn.Conv2d(32, 64, 3, 1),
            nn.ReLU(),
            nn.MaxPool2d(2)
        )
        self.classifier = nn.Sequential(
            nn.Flatten(),
            nn.Linear(64 * 5 * 5, 128),
            nn.ReLU(),
            nn.Linear(128, 10)
        )

    def forward(self, x):
        x = self.features(x)
        return self.classifier(x)

在我的代码结构中,抽象出来就是:

self.features = nn.Sequential(
    Conv -> ReLU -> Pool
    Conv -> ReLU -> Pool
)

self.classifier = nn.Sequential(
    Flatten -> Linear -> ReLU -> Linear
)

第一层Conv2d参数是Conv2d(1, 32, 3, 1),有32个滤波器,可以认为是一个3×3的滑动窗口在28×28的图像上计算,这可以用来观察一些局部结构,如4的弯折,8的弧度

接着是ReLU()非线性处理

最后是MaxPool2d()对于每个2×2区域取最大值

第二层就不是个局部特征观察了,而是前一层抽象出了个概要图,做同样的操作提取出更抽象的特征

关于参数的计算也需要细究一下:

  • 3×3的滑动窗口,下标从0开始,所以输出是(N, 32, 26 = 28-3+1, 26)
  • 提取2×2区域,输出为(N, 32, 13=26/2, 13)
  • 再是3×3滑动窗口,输出为(N, 32, 11 = 13-3+1, 11)
  • 卷积给了64个输出通道,最后输出为(N, 64, 5=11/2, 5)

所以在classifier中才是Linear(64 * 5 * 5, 128)

classifier在提取完数据之后再做了一个小的MLP,用于分类任务

LeNet-5

LeNet-5是一种更加完全版本的CNN

# models/lenet5.py
import torch
import torch.nn as nn
import torch.nn.functional as F

class LeNet5(nn.Module):
    def __init__(self, num_classes = 10):
        super().__init__()

        self.conv1 = nn.Conv2d(1, 6, kernel_size = 5)
        self.conv2 = nn.Conv2d(6, 16, kernel_size = 5)

        self.fc1 = nn.Linear(16 * 4 * 4, 120)
        self.fc2 = nn.Linear(120, 84)
        self.fc3 = nn.Linear(84, num_classes)

    def forward(self, x):
        x = F.relu(self.conv1(x))
        x = F.avg_pool2d(x, 2)

        x = F.relu(self.conv2(x))
        x = F.avg_pool2d(x, 2)

        x = torch.flatten(x, 1)
        x = F.relu(self.fc1(x))
        x = F.relu(self.fc2(x))
        x = self.fc3(x)

        return x

相比于CNN有以下的不同点:

  • 卷积核从3×3变成5×5
  • 从最大池化变为平均池化,更强调整体效应
  • 通道数较少

其实这个网络比较久远,所以一些思维跟现今的模型设计有些不同

结果展示

  • MLP:

    50 {'accuracy': 0.9652, 'loss': 0.11397983232041477}
    
  • CNN:

    50 {'accuracy': 0.9882, 'loss': 0.03712443070957781}
    
  • LeNet5:

    50 {'accuracy': 0.9812, 'loss': 0.05748969653345479}
    

手写板算法优化

getNormalized算法优化

其实测试率不理想除了模型问题外,还有我的getNormalizedSize函数的实现问题

在当前的实现中,我粗暴地将280×280的画布缩放到28×28,一方面直接缩放可能导致信息畸变,另一方面,如果用户没有居中书写,那么结果也会大相径庭

所以接下来考虑将QImage转化为cv::Mat来处理

处理的主线就是让图片尽量符合MNIST的数字格式

首先将画布的QImage深拷贝下来

cv::Mat img(canvas.height(), canvas.width(), CV_8UC1,
            const_cast<uchar*>(canvas.bits()),
            canvas.bytesPerLine());

img = img.clone();

然后进行二值化,用来适应MNIST风格

cv::threshold(img, img, 10, 255, cv::THRESH_BINARY);

再通过找到所有白色的点位来确定整个数字的位置,用一个bbox把数字框起来,这样如果用户输入不居中也能正常识别了

std::vector<cv::Point> points;
cv::findNonZero(img, points);

cv::Rect bbox = cv::boundingRect(points);
cv::Mat digit = img(bbox);

接下来就把img缩放到20×20,因为事实上28×28还包含了背景留白,MNIST的数字本身占到约20×20

下一步回到28×28

cv::resize(digit, resized, cv::Size(20, 20), 0, 0, cv::INTER_AREA);
cv::Mat padded = cv::Mat::zeros(28, 28, CV_32F);

接着转化成float再归一化

cv::Mat resized_f;
resized.convertTo(resized_f, CV_32F, 1. / 255.);
resized_f.copyTo(padded(cv::Rect(4, 4, 20, 20))); // 居中拷贝

计算之前进行轻微的高斯模糊也可以模仿MNIST的风格

cv::GaussianBlur(padded, padded, cv::Size(3, 3), 0.5);

最后就是拉平计算,下面是完整代码

std::vector<float> DrawBoard::getNormalizedSize() const {
    // 用cv::Mat处理数据
    cv::Mat img(canvas.height(), canvas.width(), CV_8UC1,
                const_cast<uchar*>(canvas.bits()),
                canvas.bytesPerLine());

    // 防止浅拷贝
    img = img.clone();

    // 二值化
    cv::threshold(img, img, 10, 255, cv::THRESH_BINARY);

    // 查看前景区域
    std::vector<cv::Point> points;
    cv::findNonZero(img, points);

    if (points.empty()) return std::vector<float>(28 * 28, 0.f);

    cv::Rect bbox = cv::boundingRect(points);
    cv::Mat digit = img(bbox);

    // 缩放到 20×20
    cv::Mat resized;
    cv::resize(digit, resized, cv::Size(20, 20), 0, 0, cv::INTER_AREA);

    // 居中填充到 28×28
    cv::Mat padded = cv::Mat::zeros(28, 28, CV_32F);

    cv::Mat resized_f;
    resized.convertTo(resized_f, CV_32F, 1. / 255.);

    resized_f.copyTo(padded(cv::Rect(4, 4, 20, 20)));

    // 高斯模糊:模仿MNIST
    cv::GaussianBlur(padded, padded, cv::Size(3, 3), 0.5);

    std::vector<float> data(28 * 28);
    for (int y = 0; y < 28; y++) {
        for (int x = 0; x < 28; x++) {
            data[y * 28 + x] = padded.at<float>(y, x);
        }
    }

    return data;
}

做完之后看看十个数码的效果

显示resize后结果

我一开始没有注意到手写板转化算法的问题其实是因为我下意识地将这个过程当作黑盒了

但其实这个处理后的结果是可以可视化的

用一个QLabel放在指定的位置,可以输出位图,这样就不需要新增对象类型了,代码如下:

cv::Mat mat28(28, 28, CV_32F, data.data());
cv::Mat mat28_copy = mat28.clone();

cv::Mat img8u;
mat28_copy.convertTo(img8u, CV_8UC1, 255.);

QImage img(img8u.data, 28, 28, img8u.step,
           QImage::Format_Grayscale8);

QPixmap pix = QPixmap::fromImage(img)
                  .scaled(ui->labelPix->size(),
                          Qt::KeepAspectRatio,
                          Qt::FastTransformation);

ui->labelPix->setPixmap(pix);

原本的策略也可以照着写,现在就可以清晰看出两种算法的差异所在了

posted @ 2026-01-17 10:23  R4y  阅读(26)  评论(0)    收藏  举报