简介:面向手写数字识别与分类的YOLOv8目标检测项目,集成了标注数据集、训练配置与训练好的模型,适用于光学字符识别(OCR)和数字图像处理任务,内含分步使用教程,可帮助学习者从数据准备到模型训练快速上手。压缩包共2000个文件,由1985个xml标注文件、13个md使用说明、1个yaml配置文件和1个txt说明文件组成,整体大小331.69MB。数据集包含4103张已划分好train/val/test的图像,标注类别覆盖数字0至9共十个数字,附带的data.yaml可直接用于YOLOv5、v8、v9、v10、v11、v12等算法训练;同时提供训练好的模型与可视化参考链接,便于验证识别效果并加深对OCR检测流程的理解。目前已有96人学习下载,适合具备一定深度学习基础、希望实践目标检测或OCR方向的开发者使用。
1. 这包到底做了什么:YOLOv8 动手写数字,和纯分类不是一回事
很多人一提手写数字识别,脑海里还是 MNIST 那种“一张图输出一个数字”的分类任务。但这个标题里写的是 ultralytics-yolov8-pred-digits,强调“检测光学字符识别”,方向完全不同:它要用 YOLOv8 在一张图里同时定位和分类多个手写数字。模型输出的是每个数字的边界框、类别和置信度,而不是单个标签。这个方案适合答题卡、票据、快递单号这一类场景,也适合想把目标检测应用到 OCR 方向的人。这个方向的价值在于三件事:可用的数据集、训练好的模型、以及一条从环境搭建到推理都能自己复现的路线。
2. 把 ultralytics 环境跑通:CPU 也能用的最小安装和第一次推理
2.1 Ubuntu 20.04 搭 CPU 版 YOLOv8:pip 装还是源码装
很多人一看到 YOLOv8 就以为必须要有 NVIDIA 显卡,其实手写数字检测对显存要求不高,CPU 也能跑,只是训练慢一点。标题里这套东西如果用普通笔记本做过验证,大概率就是 CPU 训练出来的小模型。我推荐先用 Python 虚拟环境隔离,避免把系统 Python 搞坏,尤其是 Ubuntu 20.04 上通常自带的 Python 3.8 和 3.10 混在一起,直接 pip 装容易把包装进系统目录。
python3 -m venv yolodigits source yolodigits/bin/activate pip install --upgrade pip pip install ultralytics安装完成后,验证一下版本:
python -c "import ultralytics; print(ultralytics.__version__)"这里的逻辑是:ultralytics这个包会同时拉入 torch、torchvision、opencv-python 等依赖。CPU 环境直接 pip 安装即可,默认会装 CPU 版 torch;如果你之前手动装过 CUDA 版 torch,建议先卸载再装,否则会出现“torch 正常 import,跑模型时突然报 device 错误”的诡异问题。很多刚接触 YOLOv8 的人在这一步就放弃了,其实十有八九是 torch 版本和 ultralytics 的预期不一致。
提示:Ubuntu 20.04 上如果 Python 是 3.8,ultralytics 新版还支持;如果系统里同时有多个 Python,建虚拟环境时用
python3.10 -m venv更稳,避免 libpython 版本冲突。
那什么时候不推荐 pip 装?当你要改 YOLOv8 源码(比如改 Head 结构、自定义损失函数)时,才需要git clone然后pip install -e .。但只是训练和推理手写数字,pip 包完全够用,没必要给自己增加维护负担。我见过有人为了学原理把源码整个拉下来,结果连依赖都装不顺,反而忘了先跑通最小流程。
2.2 用 COCO 权重先跑一次预测,理解检测输出结构
环境装好后的第一件事,不是急着训练,而是先拿官方yolov8n.pt跑一张普通图片,确认整个管线能通。这个权重在 COCO 上训过,能检测 80 类物体,里面没有手写数字,但可以帮助我们理解 YOLOv8 的预测输出格式。
yolo predict model=yolov8n.pt source=bus.jpg imgsz=640 conf=0.25命令会从官方来源自动下载权重到~/.cache/ultralytics/weights/。如果下载慢,可以手动下载后放在当前目录。跑完后,输出图片默认保存在runs/detect/predict/。说下命令的用意:model指定权重文件,source可以是图片、视频或目录,imgsz强制输入尺寸,conf是置信度阈值。
用 Python API 更灵活,尤其是后面要接 OCR 管线时,我习惯这么写:
from ultralytics import YOLO model = YOLO("yolov8n.pt") results = model("bus.jpg", imgsz=640, conf=0.25) boxes = results[0].boxes print(boxes.xyxy) # 左上角和右下角坐标,格式 [x1, y1, x2, y2] print(boxes.conf) # 置信度 print(boxes.cls) # 类别索引这段代码的关键是把results[0].boxes看作一个对象,它有三个常用属性:xyxy是目标框坐标,单位是像素;conf是每个框的置信度;cls是类别索引,对应 COCO 的 80 类。对新手来说,最容易踩的坑是只打印results[0].plot(),那是画好框的图片,真正要用的坐标信息反而没拿到。如果要在数字检测里做业务逻辑,一定要从xyxy取坐标,而不是去解析画好框的图片。
2.3 为手写数字调推理参数:imgsz、conf、iou 在数字上怎么设
COCO 演示没问题后,回到手写数字场景。数字和行人、汽车不一样,它尺寸小、数量多、密集排列,推理参数不能直接照搬。
imgsz对目标检测影响很大。手写数字一般是扫描件,原图可能很大,YOLOv8 会先做 letterbox,把图等比缩放到imgsz。设太小,数字缩没了;设太大,CPU 慢到不能接受。我的经验值是 640:单张 A4 扫描图上的身份证号、银行承兑汇票上的金额数字,640 都能覆盖。如果数字非常小(比如 20x20 像素),可以调成 960,代价是 CPU 推理时间翻倍。
conf阈值决定保留多少框。手写数字背景一般干净,0.25 够用;但遇到圆珠笔笔迹浅、连笔多的,数字和背景对比度低,模型置信度会降到 0.1 左右。这时候把阈值调到 0.1,先拿到框,再在后处理里排除低质量结果,而不是在一开始就把它丢掉。
iou是 NMS 的阈值。数字之间基本不重叠,所以保持 0.5 或 0.6 即可。如果扫描件里数字有笔画交叉,或者手写数字歪斜导致框互相压着,就把 iou 调到 0.2~0.3,能减少保留重复框。
| 参数 | 默认值 | 数字场景建议 | 原因 | | imgsz | 640 | 640~960 | 小目标需要尽量保留分辨率 | | conf | 0.25 | 0.1~0.3 | 手写字符置信度波动大 | | iou | 0.7 | 0.5~0.6 | 数字框重叠少,不需要太激进的抑制 |
注意:这里说的是推理参数。训练时会自动用训练集的
imgsz,预测时如果和训练不一致,精度会下降,所以训练和预测尽量保持同一分辨率。
3. 手写数字数据集准备:从 MNIST 到 YOLO 格式的转换与检查
3.1 MNIST / EMNIST 转为 YOLO 格式:合成检测框的脚本
MNIST 是单通道 28x28 图像,它只有图像和标签,没有目标检测框。要拿它来训 YOLOv8,一种常用做法是把多个 MNIST 样本贴到一张大图上,同时生成每个数字的框。这样既借鉴了 MNIST 的标注质量,又能让模型学到“一张图里有多个数字”的检测能力。
我一般会这样写一个合成脚本:
import cv2 import numpy as np import random def paste_digit(canvas, digit_img, box_size=(40, 40)): # 将 28x28 数字缩放到 box_size,并贴到画布随机位置 h, w = box_size digit_resized = cv2.resize(digit_img, (w, h)) x = random.randint(0, canvas.shape[1] - w) y = random.randint(0, canvas.shape[0] - h) canvas[y:y+h, x:x+w] = np.maximum(canvas[y:y+h, x:x+w], digit_resized) x_center = (x + w / 2) / canvas.shape[1] y_center = (y + h / 2) / canvas.shape[0] box_w = w / canvas.shape[1] box_h = h / canvas.shape[0] return [x_center, y_center, box_w, box_h], x, y, w, h这段脚本的核心逻辑是:先把digit_img缩放到 40x40,放在画布随机位置,然后生成 YOLO 格式的归一化框。np.maximum是为了避免数字叠加时直接覆盖掉背景。这里有一个容易被忽略的点:MNIST 的像素值只有 0~255,缩放前最好做一次灰度拉伸,否则贴到大画布上数字会偏淡,训练时模型学到的边缘特征不明显。
MNIST 合成只是其中一种方案。如果标题里的数据集包里已经提供了检测标注的图片,就直接跳过合成,用ultralytics自带的标签校验功能检查即可。如果用的是 EMNIST,注意它是一个字母+数字混合数据集,要过滤出数字类,否则标签索引和 YOLO 的类别定义会对不上。
3.2 用 labelme 标注自己收集的数字图片,并转成 YOLO 格式
如果没有现成数据集,或者要累积自己的业务数据,手工标注是绕不开的。labelme是最容易上手的标注工具,画矩形框很快。
pip install labelme labelmelabelme 默认保存成 JSON 格式,每个矩形框记录一个label字符串。我们把它转成 YOLO 的 txt:
import json import os label_to_id = {str(i): i for i in range(10)} def convert_labelme_to_yolo(json_path, target_dir): with open(json_path, "r", encoding="utf-8") as f: data = json.load(f) image_w, image_h = data["imageWidth"], data["imageHeight"] lines = [] for shape in data["shapes"]: label = shape["label"] if label not in label_to_id: continue # labelme 矩形框给出两点坐标 x1, y1 = shape["points"][0] x2, y2 = shape["points"][1] x1, x2 = min(x1, x2), max(x1, x2) y1, y2 = min(y1, y2), max(y1, y2) x_center = ((x1 + x2) / 2) / image_w y_center = ((y1 + y2) / 2) / image_h box_w = (x2 - x1) / image_w box_h = (y2 - y1) / image_h lines.append(f"{label_to_id[label]} {x_center:.6f} {y_center:.6f} {box_w:.6f} {box_h:.6f}") txt_path = os.path.join(target_dir, os.path.basename(json_path).replace(".json", ".txt")) with open(txt_path, "w") as f: f.write("\n".join(lines))说明一下:YOLO 的标签文件与图片同名,放在labels/下,图片放在images/下。转换时要注意imageWidth和imageHeight必须是原始图片尺寸,如果标注前做过缩放,保存 JSON 时用的是缩放图,但训练时加载原图,框就全偏了。这个坑我踩过,转换出来的框盖不住数字,损失函数看着在降,但精度永远是零,就是因为尺寸不匹配。
3.3 数字图像处理:数据增强与预处理,让模型看到更“干净”的数字
手写数字检测的难点不是类别多,而是同一个数字在不同人笔下差异巨大。7 可能带一横,1 可能带弯钩,0 可能写成小写字母 o。做数据集的时候,我们应该把“采集到的原始图像”处理成更符合检测模型输入的形式。
常见的处理链是:
- 灰度化:去掉彩色干扰;
- 高斯去噪:扫描纹路和纸张颗粒;
- 自适应阈值二值化:把数字和背景分开;
- 形态学闭运算:把断笔连起来。
这一步可以用 OpenCV 快速完成:
import cv2 img = cv2.imread("scan.jpg", cv2.IMREAD_GRAYSCALE) img_blur = cv2.GaussianBlur(img, (5, 5), 0) _, img_bin = cv2.threshold(img_blur, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3)) img_close = cv2.morphologyEx(img_bin, cv2.MORPH_CLOSE, kernel, iterations=1)这段代码产出的二值图,可以直接用来可视化检查,也可以作为数据增强的一部分。但不要把所有训练图都强制二值化再喂给 YOLO,因为 YOLO 自己会在训练时做色彩增强,提前二值化反而会丢失灰度层次,让模型对铅笔浅迹更敏感。
再说增强参数。如果使用albumentations,我会这样配:
import albumentations as A transform = A.Compose([ A.Rotate(limit=15, p=0.8, border_mode=0, value=0), A.ShiftScaleRotate(shift_limit=0.05, scale_limit=0.1, rotate_limit=0, p=0.5), A.RandomBrightnessContrast(brightness_limit=0.2, contrast_limit=0.2, p=0.5), A.GaussNoise(var_limit=(10, 30), p=0.2), ])特别提醒:手写数字旋转不要超过 15~20 度,否则 6 和 9、7 和 1 在视觉上几乎无法区分。旋转适当时,模型对倾斜数字的鲁棒性会提高;旋转过大,反而让测试分数往下掉。这是数字图像处理里最容易“好心办坏事”的一个增强点。
4. 用 ultralytics 训练手写数字模型:模型选择与参数调优
4.1 选 yolov8n 还是 yolov8s:小目标数字的容量权衡
YOLOv8 有 n/s/m/l/x 五个版本。手写数字总共 10 类,特征简单,不需要把模型拉得很大。我一般建议从yolov8n开始:推理速度快、CPU 也能勉强训练,精度损失在数字场景下并不明显。
下面是一个简单对比:
| 模型 | 参数量(M) | CPU 训练速度 | 精度表现 | | yolov8n | ~3.2 | 较快 | 数字分类够用 | | yolov8s | ~11.2 | 慢 | 对模糊、低对比度更稳 | | yolov8m | ~25.9 | 很慢 | 一般不推荐 |
如果你的图片里数字清晰、笔画规整,n 就够了。如果输入是手机拍到的票据,光线不均、透视倾斜,那就换 s。注意 m/l/x 在 CPU 上训练一轮几十张图可能都要几分钟,除非你有 GPU,否则不建议碰。数字检测的核心资产是数据和标签质量,模型容量反而是次要的。
4.2 训练脚本:data.yaml 的写法和关键参数
训练 YOLOv8 分两步。先准备数据描述文件digits.yaml:
path: ./datasets/digits # 数据集根目录 train: images/train val: images/val names: 0: "0" 1: "1" 2: "2" 3: "3" 4: "4" 5: "5" 6: "6" 7: "7" 8: "8" 9: "9"这里的path是相对路径或绝对路径。train和val是图片目录相对 path 的路径,YOLO 会自动去同级的labels目录找标签。最容易出错的是names顺序:如果标注脚本里把“0”放在索引 1,训练时却把它放在索引 0,结果就是模型学了半天,预测结果全部错位。所以训练前最好先跑一次数据可视化校验。
然后执行训练:
yolo detect train model=yolov8n.pt data=digits.yaml epochs=60 imgsz=640 batch=8 device=cpu project=run_digits name=exp1拆解一下这几个参数:
model=yolov8n.pt:这里填预训练权重。可以是官方 COCO 权重,也可以先用yolov8n.yaml从零开始训练。建议用 COCO 权重做迁移学习,虽然 COCO 没有数字,但模型已经学会了边缘和纹理特征,能减少训练轮数。epochs=60:手写数字集通常不大,60 轮足够。如果训练损失还在明显下降,继续加;imgsz=640:训练输入尺寸,尽量和预测一致;batch=8:CPU 内存小,batch 太大容易内存爆炸。8 是安全值;device=cpu:没有 GPU 时明确指定,免得它去试探 CUDA 然后报错。
用 Python API 也可以实现同一件事,并且能拿到训练过程中每个 epoch 的损失指标:
from ultralytics import YOLO model = YOLO("yolov8n.pt") results = model.train( data="digits.yaml", epochs=60, imgsz=640, batch=8, device="cpu", project="run_digits", name="exp1", lr0=0.001, cos_lr=True, )这里加了lr0=0.001。很多新手直接用默认学习率 0.01,在自定义数据集上容易梯度爆炸。尤其我们只做 10 类数字,学习率小一点更稳。cos_lr=True让学习率按余弦曲线下降,长时间训练能避免尾部震荡,训练损失后期会平滑很多。
4.3 训练过程的 4 张图怎么看:损失、精确率、召回率与混淆矩阵
训练结束后,run_digits/exp1/下会生成results.png和confusion_matrix.png。不要只盯着总损失,要看分开的指标:
train/box_loss:目标框回归损失。数值从 1.x 降到 0.2~0.3 是正常的;train/cls_loss:分类损失。数字场景 10 类,应该降到 0.1 以下;metrics/precision(B):精确率,检测出的框里有多少是真正的数字;metrics/recall(B):召回率,图像里所有数字有多少被框出来了。
典型场景是:精确率 0.95、召回率 0.6。这代表模型“框得很准但漏了很多”。原因可能是 conf 阈值设置过高,或者训练数据中每张图数字数量太多,模型只能记住一部分。我会先用yolo detect val验证原始精度,再调整conf,而不是直接怀疑模型没训练好。
如果 val loss 在 20 轮后开始反弹,而 train loss 还一直降,就是过拟合。解决办法依次是:增加数据增强、减少 epochs、换更小的模型(s 改 n)。反过来,如果 train loss 和 val loss 都降不到低位,可能是标签本身有错误,这时应该回看标注可视化。
5. 手写数字识别落地避坑:训练、推理和 OCR 里的 4 个典型问题
5.1 把“7”识别成“1”:目标框松动和标签噪声怎么处理
现象:训练集准确率很高,但在真实扫描件上总是把“7”识别成“1”,而且每张图都稳定错,不是偶发。
原因:训练集里的“7”大多没有横笔,而实际场景里很多人在“7”中间加一横,模型看到带横的字符时,特征匹配到了“1”的竖线。另一种常见原因是目标框太紧,把“7”的横划截在框外,模型看不到完整字形。
解决:第一步,检查标签框是否完全包住数字的墨迹。我一般写一个小脚本,把标注框和原图画在一起,人眼扫一遍。第二步,收集带横笔的“7”样本,至少 50 张,合成到训练集。第三步,如果短期拿不到新数据,可以在后处理里对“7”和“1”做修正:数字“7”通常有左上角的水平笔画,而“1”在这个位置没有。这个判断用 5x5 的核在原图上做形态学角点检测就能完成,不用重新训练。
5.2 CPU 推理很慢:预处理变成了黑匣子
现象:在 Ubuntu 20.04 CPU 机器上,跑一张 6000x4000 的扫描图要十几秒,其中模型只占两秒,大部分时间不知道去哪了。
原因:YOLO.predict()默认会对大图做缩放,OpenCV 在 CPU 上把 6000x4000 的图缩放再填充到 640,这个操作本身就慢。另外,如果你把整页扫描图直接送入模型,YOLOv8 的 letterbox 会做大量图像复制,内存带宽也吃紧。
解决:对于手写数字这种小目标,常见做法是先做图像金字塔或者按区域切图。比如把扫描图用 OpenCV 按 640x640 的窗口滑动,重叠 10%,每个窗口单独检测。这样每个窗口内数字的实际像素面积更大,模型推理更快,召回率也更高。另一个技巧是别用half=True,CPU 上半精度实际上不会省多少时间,别指望它。
5.3 训练 loss 突然出现 NaN:梯度爆炸还是标签脏
现象:训练到第 5 轮时,box_loss突然变成nan,随后所有指标全部失效。
原因:最常见的不是学习率,而是标签里有坐标越界的框。某些转换脚本在图像宽度高度不一致时,误用imageWidth作为所有图片的高,导致y_center > 1。YOLO 的损失函数对越界的坐标算出一个离谱的梯度,几十步内就把权重冲飞。
解决:训练前执行yolo detect train时,YOLO 会自动跳过非法标签,但不会告诉你跳过了多少。我习惯先写脚本读所有 txt,检查每个值是否在 (0,1] 区间:
import os label_dir = "datasets/digits/labels" bad_files = [] for fname in os.listdir(label_dir): with open(os.path.join(label_dir, fname)) as f: lines = f.read().strip().splitlines() for line in lines: parts = line.split() if len(parts) != 5: bad_files.append(fname + " length_error") else: values = list(map(float, parts[1:])) if any(v <= 0 or v > 1 for v in values): bad_files.append(fname + " range_error") print(bad_files)这段脚本的意图是找出所有“解析失败”和“越界”的标签文件。如果bad_files不为空,基本不用怀疑,就是它们导致 loss 变成nan。清洗完再训练。还有一种少见的 NaN 原因是 batch 里含全黑图片,手写数字二值化后如果全图都是 0,模型照样能算出损失,但梯度可能异常。预处理时把所有全黑样本剔除。
5.4 检测框把数字切成了两半:后处理怎么合并
现象:模型对一串连续的数字,只框住了一部分,比如“1234”被识别成“1”、“23”、“4”,或者干脆把相邻的两个数字框在一起。
原因:手写数字间距不统一,当笔画粘连时,检测框的 NMS 把两个数字合并成一个框;而当单个数字笔画较窄时,模型可能用两个框去覆盖一个数字的上下段。
解决:第一种,用较低 confidence + 较高 IOU 阈值,先把所有框取出来,再用“位置关系”做二次分类:如果两个框左右相邻且高度重叠面积超过 60%,就合并;如果同一垂直位置有两个框,取两者中更大的。第二种,用形态学的水平投影法做字符分割,把图片中每一列像素和不为 0 的区域切出来,再逐块送进检测模型。这样检测模型就退化为分类器,避开了检测框切错的问题。针对粘连字符,推荐之后补一个基于连通域的分割步骤:先二值化,再计算每个连通域的外接矩形,矩形内再用模型判断。
6. 把模型接进 OCR 流程:字符分割与置信度校准技巧
在手写数字检测里,模型只是中间一步,前后各有一片传统图像处理的地盘。我最近常推荐一套组合:原图转灰度,Otsu 二值化,再按连通域找候选区,每个候选区缩放到 64x64 后直接丢给 YOLO 模型判断。这样做的优势是:连通域切分天然解决了密集数字的“框错”问题,YOLO 只负责对每个候选区做 0-9 分类。
import cv2 import numpy as np from ultralytics import YOLO model = YOLO("best.pt") # 训练好的数字检测权重 img = cv2.imread("handwritten.png", cv2.IMREAD_GRAYSCALE) _, bin_img = cv2.threshold(img, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU) contours, _ = cv2.findContours(bin_img, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) digits = "" for c in contours: x, y, w, h = cv2.boundingRect(c) if w < 5 or h < 5: continue patch = bin_img[y:y+h, x:x+w] patch = cv2.resize(patch, (64, 64), interpolation=cv2.INTER_NEAREST) patch_3c = cv2.cvtColor(patch, cv2.COLOR_GRAY2BGR) result = model.predict(patch_3c, conf=0.35, imgsz=64, verbose=False) if len(result[0].boxes) > 0: digits += str(int(result[0].boxes.cls[0].item())) print(digits)这段代码里RETR_EXTERNAL只取外边界,适合数字之间没有粘连的情况;如果手写数字连笔严重,横向投影更稳。interpolation=cv2.INTER_NEAREST保留硬边缘,避免二值图缩放后出现模糊。另外,分类模式下imgsz=64要描述清楚,训练时模型在 640 的图上见到的是小尺寸数字,现在输入是 64x64,相当于把数字放大,推理结果一般没问题。
还有一个实用技巧是置信度校准:检测模型在分类 6 和 9 时,置信度往往都在 0.8 左右,很难靠阈值分开。更可靠的是统计每个类别在验证集上的平均置信度,松弛一个分位点作为“低置信度预警线”。比如“6”的 P10 置信度是 0.72,那么低于 0.72 的“6”就自动标为需人工复核。这种校准比单纯调阈值更能让结果可信。
我训练完手写数字模型,一定会在真实场景里随机抽 20 张图,把模型的预测框叠加输出,一页一页看“离散点被当成数字”的情况,而不是只看 mAP。数字检测的翻车往往是工程结构问题,不一定是模型问题。把这些环节处理好,这个标题下的方案才真正落地。希望帮到你。
本文还有配套的精品资源,点击获取