尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

网页版手写数字识别全指南:从CNN训练到TensorFlow.js部署

网页版手写数字识别全指南:从CNN训练到TensorFlow.js部署 简介面向Python与PyTorch初学者的手写数字识别完整项目基于CNN实现并配有HTML网页交互界面。压缩包共131个文件、仅3.88MB包含124张分类手写数字图片、3个Python脚本、3个文本文件及1个HTML入口结构紧凑便于快速部署。图片按类别存放脚本01负责读取图片路径与对应标签生成文本脚本02读取文本进行CNN训练自动保存模型并输出每个epoch的验证损失与准确率日志脚本03启动本地网页服务在浏览器复制或输入http://127.0.0.1:4399即可实时打开识别页面。项目完整覆盖从数据整理、模型训练到服务部署的典型链路requirements.txt可辅助搭建PyTorch环境整体流程清晰、代码量精简适合课程实验、毕业设计或CNN入门实践也能为想构建可视化识别demo的开发者提供参考快速验证端到端想法。目前已有94人学习下载可从中掌握图片分类项目从数据集到网页端推理的落地方法。1. web网页html版的手写数字识别到底交付了个什么东西把这个压缩包解压之后你会发现它不是一个 Python 训练脚本就完事而是一整套能直接打开演示的 web 项目浏览器里放一块手写板鼠标或者手指写下一个数字前端加载 CNN 模型马上给出 0 到 9 的预测结果。真正卡住人的地方往往不是“训练出一个高精度模型”而是“怎么让它在网页里稳定地跑起来、并且别把随手写的字认错”。这套方案最常见的用途是课程设计、内部 demo 和技术分享。它把深度学习的最小闭环压缩到了“解压、起服务、打开页面”三步免去了配置 TensorFlow GPU 环境、安装 Jupyter 这类前置成本。但正因为它把训练和推理拆成了 Python 和 JavaScript 两个世界预处理不一致就成了翻车重灾区。我按自己做类似项目时的顺序把原理、复现路径和数据集的坑一次性说清楚。2. 浏览器里跑 CNN 的原理为什么模型能塞进一个网页2.1 浏览器推理的三条路线为什么默认选 TensorFlow.js浏览器里没有 Python 运行时CNN 模型想在网页里前向推理常见做法有三条第一条是把模型转成 TensorFlow.js 格式用tf.loadLayersModel加载计算走 WebGL第二条是导出 ONNX 模型配合 onnxruntime-web 在 worker 里跑第三条是手写卷积和池化只适合教学演示。绝大多数“web网页html版手写数字识别”都选 TensorFlow.js原因很实际Keras 训练出的模型转出去几乎零改动导出命令一条就够。TensorFlow.js 的后端分 WebGL 和 WASM 两档。笔记本和台式机基本都有 WebGL 上下文可以调 GPU 做矩阵运算一个 28×28 的单张 MNIST 样本推理一次通常只要几毫秒。但如果用户浏览器禁用了硬件加速控制台会报WebGL context could not be created这类错误库会自动回退到 CPU 上的 WASM 后端速度慢一点但不会白屏。页面代码里可以主动指定后端优先级await tf.setBackend(webgl); await tf.ready();这行代码要放在加载模型之前。逻辑是先尝试 WebGL失败就用 wasm。参数上不需要额外调优但页面里最好把两个后端都打包否则遇到老机器会直接加载失败。手写数字识别模型非常小就算 WASM 跑单次推理也在几十毫秒内人眼基本无感。2.2 从训练好的 Keras 模型到网页可加载的文件一次导出记录训练脚本里拿到的是一个.h5或 SavedModel 格式的模型浏览器无法直接读取。标准做法是用官方转换器把它变成model.json加一组分片权重文件。模型在 Python 侧保存时输入张量名和形状决定了网页侧预处理的写法这一步在导出前就要想清楚。model tf.keras.Sequential([ tf.keras.layers.Conv2D(32, (3, 3), activationrelu, input_shape(28, 28, 1)), tf.keras.layers.MaxPooling2D((2, 2)), tf.keras.layers.Conv2D(64, (3, 3), activationrelu), tf.keras.layers.MaxPooling2D((2, 2)), tf.keras.layers.Flatten(), tf.keras.layers.Dense(128, activationrelu), tf.keras.layers.Dropout(0.2), tf.keras.layers.Dense(10, activationsoftmax) ]) model.compile(optimizeradam, losssparse_categorical_crossentropy, metrics[accuracy]) model.save(mnist_cnn.h5)这个结构是 MNIST 手写数字识别的经典配置两层卷积加池化参数总量大概二十万出头导出后的权重文件只有几百 KB。输入形状固定在(28, 28, 1)通道是 1 不是 3这正是后面网页 canvas 预处理必须对齐的地方。保存之后执行转换命令tensorflowjs_converter --input_format keras mnist_cnn.h5 tfjs_model转换得到的目录里有model.json和若干group1-shard1of1.bin这样的分片文件。网页端加载时只指定model.json的路径框架会根据里面的weightsManifest自动去拉对应分片。如果你在浏览器里看到网络请求只有model.json回来而.bin全部 pending基本都是路径写错不是模型坏了。2.3 zip 里的图片数据集MNIST 转成 PNG 后带来的连锁影响压缩包里的“图片数据集”通常是把原始 MNIST 文件拆成一张张 PNG。这样做的好处是可见、可编辑标签直接由文件夹名表达data/train/0/里的每张图都是 0data/train/1/里都是 1几乎不需要额外写标签映射。对课程设计和演示场景来说这种组织方式比解析 idx 二进制文件直观得多也方便随时往某个分类里塞几张自己画的样本。但把 MNIST 转成图片会带来一个容易被忽略的问题原始数据是 255 灰度值而网页端从 canvas 读出来的像素是 RGBA 四通道。如果训练时把图片像素除以 255推理时也必须做同样的归一化否则模型输入分布直接被改变。很多项目会在index.html里写一句tf.browser.fromPixels(canvas)就直接丢给模型这个 API 返回的是 0 到 255 的整数张量和训练数据完全不匹配识别准确率会明显掉下来。我在处理图片数据集时习惯把“数据怎么进模型”写在训练脚本开头当注释同时在网页端做同名的预处理函数。这样两边永远对得上也方便后来接手的人一眼看到输入约定。数据量方面如果 zip 里每个类只放几十张图训练出来的模型在测试集上也许能看但泛化到用户随手画的手写体就勉强了有条件就补到每类一两百张以上再用旋转、位移做增强效果会稳定很多。3. 把网页版跑通目录结构、本地服务与一次完整识别3.1 zip 解压后的目录结构每个文件是干嘛的一个合格的“网页版手写数字识别”压缩包目录组织通常类似下面这样虽然不是每份都完全一致但核心文件跑不掉路径作用index.html页面入口放 canvas 手写板和结果显示区域js/app.js页面交互逻辑绘制笔画、调用预处理js/model.js负责加载模型和执行预测model.json模型结构描述group1-shard1of1.bin权重分片必须和 json 放在同一目录data/train/0-data/train/9图片数据集按标签分文件夹train.py训练脚本定义模型结构和数据加载其中最容易踩坑的是model.json和.bin的相对路径。很多人在页面里写loadLayersModel(./model.json)然后把模型文件单独挪到了models/目录结果 json 找到了分片权重全部 404。原因是分片路径写在model.json内部的weightsManifest里是相对model.json所在位置的一旦改了目录层级内部路径没跟着动就会挂。我的习惯是模型文件放固定目录且不单独移动页面里只引用相对 index.html 的路径。3.2 本地起服务file 协议下的跨域和缓存问题很多人拿到 zip 后第一件事是双击index.html结果模型加载报错、控制台飘红。这不是代码坏了是浏览器安全策略不允许file://协议下跨目录读取文件。fetch请求会被直接拦截model.json根本进不来。正确姿势是本地起一个静态服务最省事的是用 Pythoncd 解压目录 python -m http.server 8000启动后访问http://localhost:8000页面就能正常加载模型了。这个命令对 Python 3 直接可用不涉及任何第三方库。如果你电脑装了 Node.js也可以用npx serve -l 8000效果一样。端口 8000 被占用时换成 8080 或者其他高位端口即可。使用本地服务还有一个额外好处后面换模型文件时浏览器的强制缓存可能会把旧的model.json留下导致页面一直用老权重。解决方法是给文件名加版本号比如model_v2.json或者起服务前清一下浏览器站点数据。我这段时间做演示项目干脆用 Python 写了个带禁用缓存参数的行内服务一次性解决反复换模型的问题。3.3 canvas 手写板到 28×28 张量的预处理链路网页端拿到用户手写笔画之后不能直接把原图丢给模型。MNIST 训练集是 28×28 的黑底白字、数字居中所以 canvas 内容要先等比缩放、贴到黑底画布中心再转成模型需要的四维张量。function preprocess(canvas) { const dst document.createElement(canvas); dst.width 28; dst.height 28; const ctx dst.getContext(2d); // 黑底白字和 MNIST 训练集一致 ctx.fillStyle #000; ctx.fillRect(0, 0, 28, 28); const src document.createElement(canvas); src.width 280; src.height 280; const srcCtx src.getContext(2d); srcCtx.drawImage(canvas, 0, 0, 280, 280); const trimmed trimCanvas(src); // 裁剪掉四周全白区域 ctx.drawImage(trimmed, (28 - trimmed.width) / 2, (28 - trimmed.height) / 2, trimmed.width, trimmed.height); const imgData ctx.getImageData(0, 0, 28, 28); const gray new Float32Array(28 * 28); for (let i 0; i 28 * 28; i) { // RGBA 取亮度均值并归一化到 0~1 gray[i] (imgData.data[i * 4] imgData.data[i * 4 1] imgData.data[i * 4 2]) / (3 * 255); } return tf.tensor4d(gray, [1, 28, 28, 1]); }这段预处理有三个关键点。第一是黑底白字训练集里数字像素靠近 255、背景是 0如果你在页面里画白底黑字又不做反转模型看到的分布正好相反第二是居中裁剪用户写字的区域通常只占画板一小块不裁剪直接缩到 28×28 会让数字偏在角落第三是归一化除以 255 是标准做法漏掉这一步的后果前面说过了。预处理完了调用model.predict()得到的输出是长度为 10 的概率数组用argMax取最大下标就是预测数字。我一般同时把置信度显示出来这样看不出是“模型乱猜”还是“真的拿不准”演示现场也能判断要不要让用户再写一遍。4. 换自己的图片数据集重训把普通图片变成 CNN 能吃的样本4.1 批量灰度化、二值化与居中裁剪把杂图整理成训练集很多项目拿到手的不是现成 MNIST而是自己手机拍的照片、扫描件或者从 PDF 里截出来的数字。这些图不进过处理直接进image_dataset_from_directory准确率会惨不忍睹。原因是 CNN 训练时要求输入分布相对稳定背景要干净、数字主体要明显、位置不要乱飘。我自己处理这类图片数据集时会先用一段脚本做四件事转灰度、自动对比度、按内容外接矩形裁剪、贴到 28×28 黑底画布上。裁剪这步很关键它能去掉周围大片空白让数字占满有效区域相当于做了对齐。from PIL import Image, ImageOps import numpy as np import os def normalize_image(src_path, dst_path): img Image.open(src_path).convert(L) img ImageOps.autocontrast(img) # 增强对比度让笔画更清晰 # 找到非零像素的边界框 arr np.array(img) rows np.where(arr 128)[0] cols np.where(arr 128)[1] if len(rows) 0 or len(cols) 0: return False img img.crop((cols.min(), rows.min(), cols.max() 1, rows.max() 1)) # 等比缩放到 20x20再贴到 28x28 黑底画布中心 img.thumbnail((20, 20), Image.LANCZOS) canvas Image.new(L, (28, 28), 0) offset ((28 - img.width) // 2, (28 - img.height) // 2) canvas.paste(img, offset) canvas.save(dst_path) return True这段脚本用 Pillow 实现autocontrast会把像素反差拉开避免浅色笔画在二值化时被吞掉crop按阈值 128 之外接矩形找数字主体。缩放到 20×20 再贴中心而不是直接拉伸到 28×28是因为直接拉伸会改变笔画的比例细长线条容易变形。整个预处理跑完训练集才能接近 MNIST 的分布。4.2 训练脚本的 I/O 约定目录名即标签通道数要写对数据集整理好之后训练脚本的加载方式决定了后续所有工作的复杂度。用image_dataset_from_directory直接从文件夹读图省掉自己写标签映射的麻烦但有两个参数必须显式指定color_modegrayscale和image_size(28, 28)。import tensorflow as tf train_ds tf.keras.utils.image_dataset_from_directory( data/train, image_size(28, 28), color_modegrayscale, batch_size64, shuffleTrue, validation_split0.2, subsettraining, seed42 )color_mode不写默认为 RGB模型输入就变成(28, 28, 3)和网页端预处理不一致导出后必然出错。validation_split会把数据切出 20% 做验证seed固定下来保证每次切分结果一致方便复现。如果你希望模型能识别非手写的印刷体数字可以往data/train的对应目录里塞不同字体渲染出来的图片CNN 对字体变化的鲁棒性比想象中好只要背景和尺寸预处理一致。训练完成后的模型结构和前面第 2 节完全一致直接model.save()即可。这里特别提醒不要用softmax之外的其他输出层网页端argMax依赖的是概率分布如果你训练脚本里为了做对比把输出层换成了 sigmoid导出后网页端取最大下标依然能用但置信度含义就变了。4.3 重训后的模型导出和替换输入张量名最容易出错训练完的新模型要替换网页里的旧模型流程是导出、检查输入形状、替换目录三个动作。很多人在第三步翻车因为新模型虽然结构没变但输入层的名字和旧的不一样。tensorflowjs_converter --input_format keras my_model.h5 tfjs_model_new导出后用一段小脚本确认一下输入输出信息比直接打开页面撞错快得多const model await tf.loadLayersModel(./tfjs_model_new/model.json); console.log(model.inputs[0].shape); console.log(model.outputs[0].shape);控制台打印的输入形状必须是[null, 28, 28, 1]如果有任何一个维度不是这个要么是image_dataset_from_directory里color_mode没写对要么是模型第一层input_shape标错。输出形状是[null, 10]。确认无误后把新目录覆盖到网页引用位置刷新页面即可。如果你的网页代码里对model.inputs[0].name做了硬编码导出模型的输入层名称变了会导致报错。我自己不写名字依赖全用位置索引model.inputs[0]这样以后换多少次模型都不用改代码。5. 避坑指南网页版手写数字识别最容易翻车的 5 个现场5.1 现象训练集准确率 99%在网页上一写就乱认原因几乎都在预处理链路。页面端把 canvas 的颜色通道、归一化范围、背景色和训练时不统一模型拿到的是“没见过”的输入分布。解决方法是把训练脚本里的预处理逻辑和前端preprocess函数逐行对照尤其是背景值是 0 还是 255、像素除没除以 255 这两项。5.2 现象页面打开控制台报 “Failed to fetch model.json”原因分两种一种是直接双击index.html用 file 协议访问被浏览器跨域拦截另一种是model.json路径写错或权重分片文件不在同一目录。解决方法是先起本地服务访问再核对相对路径。改完记得硬刷新一次避免缓存里旧错误路径还在。5.3 现象在触屏设备上画数字笔画老是被识别成别的数观察一下画布笔画很多触屏浏览器对快速滑动事件做了延迟和合并导致笔画断断续续、粗细不均。解决方法是给touchstart事件加preventDefault把触摸点当作鼠标事件处理同时把画笔lineWidth调大到 20 以上、lineCap设为round保证笔画连续实心。模型训练时笔画都是有宽度的细线很容易被当成噪声。5.4 现象图片数据集里明明是 3预测结果总差一位把数据集里的 PNG 打开看是不是“黑底白字”和“数字居中”这两个特征丢了。从 PDF 转出来的图往往白底黑字、位置偏左上虽然人眼能看出是 3但 CNN 的特征分布完全对不上。解决方法是回到第 4 节的预处理脚本先批量过一遍归一化再做训练。5.5 现象换成自己的数据集重训导出 tfjs 时报 op 不支持训练脚本里如果用了比较新的层或自定义函数转换器可能不认识。最常见的是数据增强层如RandomRotation留在了推理模型里或者自定义 loss 函数没有在custom_objects里注册。解决方法是导出前用model tf.keras.models.clone_model(model)重建一个纯净结构只保留卷积、池化、全连接这些标准层再保存导出。6. 把它变成能离线交付的小工具最后一点收尾建议如果这个网页版识别器要交给别人用或者放进作品集里我建议再加三样东西。第一是把训练集里随机抽出的几张图单独放一个test_samples/目录里面混几个类别的 PNG同时在页面加载完后自动跑一次冒烟测试把预测结果打印到控制台。这样可以快速判断模型文件有没有放对、网页端预处理有没有写错别人拿到手不用从头摸索排查。第二是给识别结果加一个置信度阈值低于 0.6 就不显示数字改成提示“请重新书写”比硬猜一个错误答案更有说服力。第三是做一个模型版本常量放在model.js顶部页面上显示出当前加载的版本号以后换模型出了问题能立刻知道是不是开错了页面。我自己的习惯是把model.json和权重分片直接放到和index.html平级的models/目录并且每次新模型用独立目录名比如models/cnn_v2/。这样一来旧版本还能留着对比不会出现覆盖后想回退却找不到原文件的情况。以前就吃过亏训练了一版效果更好的模型图省事直接把旧文件覆盖了结果发现某个演示场景下旧模型更稳想回退时只能重新训练多花了半天时间。最后再唠叨一句网页版手写数字识别这个方向模型结构从来不是瓶颈真正决定体验的是输入预处理链路和目录组织方式。把这两处做好无论是用最小化的图片数据集做教学 demo还是在现有模型基础上换数据重新微调路径都会很顺。希望帮到你。本文还有配套的精品资源点击获取
返回列表