
简介面向PyTorch初学者与图像识别爱好者这份压缩包提供了一套完整的CNN手写数字识别网页演示方案。基于Python与PyTorch环境资源内置了图片数据集、环境依赖文件requirement.txt以及三个按顺序运行的Python脚本数据集文本制作、模型训练和HTML服务启动。最终可在本机网页http://127.0.0.1:4399上实时体验手写数字识别效果适合作为深度学习入门或课程设计的参考资料。包体共131个文件以124张JPG图片为主兼有3个Python脚本、3个TXT说明文件和1个HTML页面整体仅3.88MB轻量易部署。训练脚本会自动保存模型和日志便于查看每个epoch的验证集损失与准确率帮助理解模型收敛过程。目前已有96人学习下载若配合作者博文配置环境按编号执行脚本即可快速跑通全流程。1. 用浏览器跑CNN手写识别这份zip到底能给你什么拿到一份名为web网页html版通过cnn训练手写数字识别-含图片数据集.zip的压缩包很多人的第一反应是网页版那是不是不用装Python、不用GPU、双击就能玩老实说这个方向确实能实现「浏览器里写数字、立刻出识别结果」但前提是你要把训练和推理这两件事拆开看。训练的活交给Python推理部署在web端zip里多半就是一套这样的组合图片数据集、训练脚本、一个html页面加模型文件。这套方案适合两类人。一类是刚学完CNN、想知道模型怎么走出实验室变成可交互产品的同学另一类是前端工程师想在不依赖后端服务的情况下给页面加上一个「能用的AI小功能」。它的价值不在于精度有多高——手写数字识别在MNIST上早就被刷到99%以上了——而在于让你在半小时内完整走一遍「采集数据、训练模型、导出、用浏览器加载推理」的全链路。本文把它拆开讲给出可复现的步骤和我在实际部署中踩过的坑。2. 架构与选型为什么训练在Python、识别在浏览器2.1 先看懂这套方案的成分从标题拆解这个zip至少包含三块内容图片数据集、CNN训练相关的源码/脚本、以及一个web网页html。常见的组织方式是这样的dataset/目录放训练和测试图片train.py或train.ipynb负责跑卷积神经网络训练完导出模型文件如model.json加一组*.bin权重文件最后index.html和一段JavaScript负责加载模型、绘制手写板并输出识别结果。有些版本还会把图片数据转成CSV或直接把MNIST的idx格式打包进去。先别急着找index.html双击打开。网页端加载模型有个硬性限制——浏览器安全策略不允许file://协议下加载本地模型文件直接双击html会遭遇跨域报错模型根本起不来。正确做法是在zip解压目录下起一个本地静态服务器这是很多人翻车的第一步。2.2 CNN在这里担任什么角色手写数字识别的标准做法是卷积神经网络全称Convolutional Neural Network也就是热搜词里那个cnn。图片数据集的流程通常从28x28的灰度图开始和MNIST一致。CNN要做的就是从一堆像素里逐层抽象出笔画特征第一层卷积核提取边缘第二层组合出弧线、交点最后接全连接层对10个类别数字0到9打分。这个任务的输入输出都非常规整——输入固定尺寸的单通道图像、输出10维向量所以模型结构可以非常收敛没必要用ResNet这种大模型一个经典的小型CNN就能把精度做到99%上下。选择「训练用Python、推理用浏览器」的根本原因在于生态成熟度。Python侧有TensorFlow/Keras、PyTorch训练流程清晰浏览器侧有TensorFlow.js可直接加载Keras导出的模型做推理。一条命令能把h5格式转成tfjs格式再把model.json和分片权重丢给tf.loadLayersModel()加载前端只需要处理canvas图像的预处理。2.3 为什么不直接在浏览器里训练浏览器确实能跑训练——TensorFlow.js支持tf.train.adam()和fit()API。但实际体验很差训练数据要先下载到浏览器吃满内存没有GPU的机器跑一个epoch要几十秒训练过程中标签页一刷新就前功尽弃。我见过有人把MNIST加载到浏览器里做在线学习结论是「能跑但像用计算器做微积分」。所以行业的普遍做法是离线训练一次导出模型网页端只做推理。这也对应了这份zip的标题结构——训练和web部署是两套环节二者通过模型文件对接。3. 在Python侧把CNN训好完整训练脚本与关键参数3.1 数据准备与归一化动手第一步是确认数据集结构。如果zip里是MNIST原始格式你会看到四个文件train-images-idx3-ubyte、train-labels-idx1-ubyte、t10k-images-idx3-ubyte、t10k-labels-idx1-ubyte。如果是图片文件夹大概率是train/0.png、train/1.png这种按数字分目录的组织方式。两者在读取代码上差异不小这里给出兼容两种情况的读取方案。# load_data.py import os import numpy as np from PIL import Image def load_from_folders(data_dir, img_size(28, 28)): images, labels [], [] for label in range(10): # 0-9 十个目录 label_dir os.path.join(data_dir, str(label)) for fname in os.listdir(label_dir): if not fname.endswith((.png, .jpg, .bmp)): continue path os.path.join(label_dir, fname) img Image.open(path).convert(L).resize(img_size) img_array np.array(img, dtypenp.float32) / 255.0 # 归一化到[0,1] images.append(img_array) labels.append(label) return np.array(images).reshape(-1, 28, 28, 1), np.array(labels) # 用法 # x_train, y_train load_from_folders(dataset/train)归一化的÷255.0是CNN训练里最容易被忽略、影响却最致命的步骤。像素原始值是0到255的整数直接喂给网络会让梯度在反向传播时震荡训练迟迟不收敛。习惯上还要reshape成(样本数, 28, 28, 1)——最后一个维度1表示单通道灰度图TensorFlow要求图像数据必须带通道维。如果你发现loss怎么调都降不下去先检查这一步。3.2 模型结构设计三层卷积足够手写数字识别是入门任务模型不必堆深。我常用的结构是两层卷积加一层全连接参数总量不到10万在CPU上训练一个epoch也就几秒钟。# train.py import tensorflow as tf from tensorflow.keras import layers, models def build_cnn(): model models.Sequential([ layers.Conv2D(32, (3, 3), activationrelu, input_shape(28, 28, 1)), layers.MaxPooling2D((2, 2)), layers.Conv2D(64, (3, 3), activationrelu), layers.MaxPooling2D((2, 2)), layers.Flatten(), layers.Dropout(0.5), layers.Dense(10, activationsoftmax) ]) model.compile( optimizertf.keras.optimizers.Adam(learning_rate1e-3), losssparse_categorical_crossentropy, metrics[accuracy] ) return model几个参数值得解释。Conv2D(32, (3,3))表示第一层有32个卷积核每个核的尺寸是3x3——卷积核数量决定特征图的丰富度32是个起步值调到64精度会微涨但训练时间翻倍。Dropout设在全连接之前、比率取0.5这是故意让网络在训练时随机丢弃一半神经元防止它死记硬背训练集。losssparse_categorical_crossentropy配合整数标签省去了手动one-hot编码的过程但如果你的标签已经被one-hot了就要改成categorical_crossentropy否则会报维度错误。learning_rate用1e-3是Adam优化器的万能默认值一般不用动。3.3 训练流程与回调# train.py 续 from tensorflow.keras.callbacks import ModelCheckpoint, EarlyStopping callbacks [ ModelCheckpoint(mnist_cnn.h5, save_best_onlyTrue, monitorval_accuracy), EarlyStopping(monitorval_accuracy, patience3, restore_best_weightsTrue) ] history model.fit( x_train, y_train, validation_split0.2, batch_size128, epochs20, callbackscallbacks, verbose1 )validation_split0.2会把训练集最后20%的数据留出来做验证而不是单独切分文件。batch_size128是均衡显存和收敛速度的中间值batch太大如512会降低梯度噪声导致收敛变慢太小如16则让每个epoch的时间显著拉长。EarlyStopping盯着验证集准确率连续3个epoch不涨就停能避免过拟合。训练完成后mnist_cnn.h5就是你要导出的模型文件——它是这个zip方案中连接Python训练和web识别之间的桥梁。4. 把模型搬到网页从h5到TensorFlow.js的完整链路4.1 转换模型格式tensorflowjs_converterKeras训练出来的.h5文件浏览器不认识需要转成TensorFlow.js的格式。这一步用官方工具一条命令完成。# 安装转换工具如果还没装 pip install tensorflowjs # 执行转换--quantization_bytes 可选参数 tensorflowjs_converter \ --input_formatkeras \ --output_formattfjs_layers_model \ --quantization_bytes2 \ mnist_cnn.h5 \ web/public/model转换产物是model.json加若干.bin权重分片文件。--quantization_bytes2把每个权重从32位浮点压到16位模型体积缩小一半精度损失在1%以内对0到9的识别几乎无感。文件数量取决于网络参数总量分片是为了让浏览器能并行下载。这一步产出的web/public/model/目录必须保持model.json和.bin的相对路径关系不能只拷贝json文件。4.2 html页面的完整骨架网页端要做的事有两件一是提供画布让用户写数字二是把画布像素预处理成模型需要的(1, 28, 28, 1)张量并推理。!doctype html html langzh-cn head meta charsetutf-8 title手写数字识别/title script srchttps://cdn.jsdelivr.net/npm/tensorflow/tfjs4/script /head body canvas idboard width280 height280 styleborder:1px solid #333; touch-action:none;/canvas p识别结果span idresult等待输入.../span/p button onclickclearBoard()清空/button script srcmain.js/script /body /htmlcanvas的width/height设为280是为了书写体验但模型输入是28x28所以推理前要把画布内容缩采样10倍。touch-action:none是移动端必加的CSS属性否则手指在canvas上滑动时会触发浏览器默认的滚动行为画出来的线会断。4.3 推理逻辑数据预处理是关键// main.js let model null; const canvas document.getElementById(board); const ctx canvas.getContext(2d); async function loadModel() { model await tf.loadLayersModel(/model/model.json); console.log(模型加载完成); } function predict() { if (!model) return; // 1. 把280x280的画布内容缩到28x28 const tempCanvas document.createElement(canvas); tempCanvas.width 28; tempCanvas.height 28; const tempCtx tempCanvas.getContext(2d); tempCtx.drawImage(canvas, 0, 0, 28, 28); // 2. 取像素并归一化 const imageData tempCtx.getImageData(0, 0, 28, 28); const grayscale []; for (let i 0; i imageData.data.length; i 4) { grayscale.push(imageData.data[i] / 255.0); // 只取R通道灰度图三通道值相同 } const tensor tf.tensor4d(grayscale, [1, 28, 28, 1]); // 3. 推理并取概率最大的类别 const pred model.predict(tensor); const logits pred.dataSync(); const result logits.indexOf(Math.max(...logits)); document.getElementById(result).innerText 识别为数字 ${result}置信度 ${(logits[result]*100).toFixed(1)}%; tensor.dispose(); pred.dispose(); }这段代码有三个细节需要注意。第一drawImage把280x280的画布内容直接压到28x28如果用户写的字很小或偏离中心缩采样后笔画会模糊。第二getImageData返回RGBA四通道取R通道即可——因为canvas默认白底黑字RGB三个通道的值相同。第三拿到结果后必须手动dispose()释放张量否则在连续推理时内存会被撑爆这是TensorFlow.js使用中最高频的内存泄漏来源。4.4 canvas画笔与事件绑定光有推理逻辑还不够用户总得能写字。画笔部分有两个小坑值得注意canvas的坐标要随窗口缩放进行映射笔触的颜色和粗细直接影响识别效果。// main.js 续 let isDrawing false; canvas.addEventListener(mousedown, (e) { isDrawing true; ctx.beginPath(); const pos getPos(e); ctx.moveTo(pos.x, pos.y); }); canvas.addEventListener(mousemove, (e) { if (!isDrawing) return; const pos getPos(e); ctx.lineTo(pos.x, pos.y); ctx.strokeStyle black; ctx.lineWidth 18; // 笔画宽度与训练数据保持一致 ctx.lineCap round; ctx.stroke(); }); canvas.addEventListener(mouseup, () isDrawing false); function getPos(e) { const rect canvas.getBoundingClientRect(); return { x: (e.clientX - rect.left) * (canvas.width / rect.width), y: (e.clientY - rect.top) * (canvas.height / rect.height) }; } function clearBoard() { ctx.fillStyle white; ctx.fillRect(0, 0, canvas.width, canvas.height); document.getElementById(result).innerText 等待输入...; }lineWidth18是我反复调出来的经验值。MNIST数据集的笔画占图像宽度比例大约在15%到20%之间280x280的画布对应18到20像素的笔宽最接近训练分布。线太细会丢失笔画结构太粗则把相邻数字粘连。getPos里的坐标映射是移动端适配的关键——clientX是窗口坐标而canvas显示尺寸可能被CSS拉伸必须按比例换算。5. 常见问题排障7个让项目翻车的血泪坑5.1 双击index.html一片空白——跨域问题现象浏览器控制台报Origin null has been blocked by CORS policy或Failed to load model页面白屏。原因浏览器出于安全策略禁止在file://协议下用XHR/fetch加载本地文件。模型权重是异步请求回来的file://下这个请求直接被拦截。解决在zip解压目录启动一个本地静态服务器常见做法是用Python或Node。命令行执行python -m http.server 8080或npx http-server -p 8080然后访问http://localhost:8080/index.html。这个坑几乎是web端加载本地模型的第一个拦路虎。5.2 模型路径404现象控制台只有一行GET http://localhost:8080/model/model.json 404 (Not Found)但明明解压目录里有model文件夹。原因大小写不匹配或者静态服务器的根目录和当前访问路径不一致。tf.loadLayersModel(/model/model.json)开头的斜杠指向服务器根目录如果你的模型放在web/public/model/而你服务的是web/目录路径就变成了web/model/model.json。解决先看model.json里引用的权重文件名再对准目录结构。我一般会开DevTools的Network标签页看请求的实际URL直接复制它的路径来改代码。5.3 训练时准确率99%网页上识别却一塌糊涂现象Python侧验证集准确率0.99但浏览器里画一个端端正正的7结果识别成1或2。原因数据分布不一致。训练数据是MNIST那种「居中的白字黑底灰度图」而canvas是黑笔白底如果转换脚本没有做反色处理前景背景恰好相反。另外MNIST的字符是28x28网格里居中的你的canvas缩采样后如果没有做居中提取字符位置偏移会显著影响CNN的鲁棒性。解决在生成28x28像素前先对280x280的图像做一次边界裁剪找到笔画的包围盒并居中放大再缩放到28x28。反色处理一行的代码即可解决grayscale.push(1 - imageData.data[i] / 255.0)。我见过不少人卡在这一步好几天本质上是没意识到训练数据和实时输入之间的分布鸿沟。5.4 推理速度慢画完要等一两秒才出结果现象点击「识别」按钮圈圈转了半天才有结果每画一笔卡一下。原因TensorFlow.js默认后端是webgl但如果浏览器禁用GPU或机器没有独立显卡TF.js会自动回退到CPU。还得检查是否在每次推理后都释放了张量——如果不做dispose()内存占用持续上升GC频繁触发肉眼感知就是卡顿。解决显式指定后端——await tf.setBackend(webgl);并在推理代码里给每个张量调用dispose()。从经验看GPU后端推理28x28的单张图耗时在1到3毫秒CPU则要50到100毫秒肉眼基本上能感知到差别。5.5 移动端手指写字没反应或误触现象手机上打开页面手指在canvas上画不出线反而页面在滚动。原因触摸事件touchstart、touchmove没有绑定且浏览器默认手势把触摸序列接管了。解决在canvas元素上加touch-action:none同时把监听器从mousedown/mousemove扩展到touchstart/touchmove注意触摸事件的坐标要从e.touches[0]读取而不是e.clientX。5.6 模型载入后白屏、权重文件加载失败但json正常现象model.json加载成功HTTP请求里能看到但紧接着的几个bin权重文件请求显示失败或挂起。原因model.json里的weightsManifest指向的相对路径和实际文件位置不一致。tfjs转换工具生成的文件名是自动编号的如group1-shard1of1.bin如果你手动改了文件名或挪了目录json里的引用不会跟着改。解决不要手动重命名bin文件。移动目录时整个model/目录一起搬保持内部结构不变。如果确实改名了用文本编辑器打开model.json在weightsManifest里同步修改路径。5.7 数字识别对特定人群的字迹特别差现象大多数人识别正常但某几个用户写的4、7、9总是错。原因训练集MNIST和美国人口笔迹高度拟合亚洲人写数字的习惯——比如4的写法、7带横杠、9的竖线——在MNIST里都不是主流CNN学到的特征自然有偏。这是数据集偏差问题不是模型bug。解决给用户提供「加入你的笔迹再训练」的功能。在页面端收集几十张canvas截图的图片和标注追加到训练集里微调模型。更深层的解法是把输入标准化把笔画粗细、中心位置、倾角统一到MNIST分布。6. 进阶调试技巧像工程师一样验证模型状态6.1 把置信度可视化判断模型是「自信的错误」还是「犹豫的猜测」识别结果不能只看最终argmax。把logits数组打印到页面观察分布数字7的logits分布: [0.02, 0.03, 0.01, 0.02, 0.04, 0.02, 0.01, 0.83, 0.01, 0.01]如果最大概率低于0.6且第二名和第一名很接近说明模型在犹豫。这时候不要直接报结果可以在UI上提示「不确定请重新书写」。把阈值设定在max_prob 0.7才显示结果否则提示重写这个简单的规则能把用户体验提升一个档次。6.2 对比实验验证模型有没有过拟合改一个参数跑一组对比把准确率记录下来。我的习惯是固定epoch20分别测试learning_rate1e-2/1e-3/1e-4、batch_size64/128/256、has_dropout/no_dropout三组组合。最典型的结论是没有Dropout的模型在训练集上很快到99%但在验证集只有96%——这就是过拟合的铁证。如果你的验证准确率远低于训练准确率优先检查是不是漏了Dropout层。6.3 保存中间推理张量为图片排查预处理问题当模型识别效果不对时最好的方法是把喂给模型的28x28张量导出为图片亲眼看看到底是什么样。在canvas预处理后加一行console.log(tensor)不够直观我一般直接把28x28的灰度值用Canvas画出缩略图// debug.js 调试用把模型实际看到的像素画出来 const debugCanvas document.createElement(canvas); debugCanvas.width 140; debugCanvas.height 140; const dCtx debugCanvas.getContext(2d); const imgData dCtx.createImageData(28, 28); for (let i 0; i 28 * 28; i) { const v Math.round(grayscale[i] * 255); imgData.data[i * 4] v; // R imgData.data[i * 4 1] v; // G imgData.data[i * 4 2] v; // B imgData.data[i * 4 3] 255; // Alpha } dCtx.putImageData(imgData, 0, 0); // 把140x140的图放上页面 document.body.appendChild(debugCanvas);用这段代码能看到模型输入的「真实长相」——白底黑字还是反了、笔画有没有被压缩变形、位置是否居中。任何预处理阶段的黑匣子在变成一张可见的图片之后都无所遁形。这是我调试所有图像模型时保留的习惯模型的输入端永远要可视化不要直接盲调参数。希望这套思路对你有所帮助。本文还有配套的精品资源点击获取