Django集成TensorFlow构建MNIST手写数字识别Web工程
简介面向计算机专业毕业设计及项目实战学习者的DjangoTensorflow MNIST手写字体识别项目适配课程设计、期末大作业等场景也可作为毕设直接使用也适合想快速理解全栈深度学习应用的开发者。项目以Django提供Web交互界面基于Tensorflow完成手写数字识别涵盖数据处理、模型训练、接口调用与页面展示整体调试完毕下载即可运行。压缩包共72个文件包括28个py源码、前端js/css/html、MNIST数据与索引文件、日志及配置说明等包体大小81.63MB目录结构清晰便于按模块查阅。已有145人学习下载。学习时可结合README和配置文件快速部署借助日志文件排查异常完整走通从数据加载、模型训练到Web识别的全过程目录中的自定义模板标签、静态资源与数据子目录划分清楚便于独立进行二次开发和实验报告撰写。1. 为什么要把MNIST识别放进Django工程而不是简单跑个脚本很多课程设计能跑到 99% 准确率但真正要变成“能提交、能演示、别人能复现”的工程缺的往往不是模型而是 Web 外壳、数据文件、运行入口这一整套东西。这个压缩包把 Django 和 TensorFlow 放在同一个工程里Django 处理路由、表单、模板和后台TensorFlow 负责手写字体识别MNIST 四个 gz 数据文件全部放在本地MNIST_data目录下不用联网下载数据这是它适合直接拿来做毕设或课程设计的主要原因。对想动手写 Django 与 TensorFlow 集成代码的学习者也可以把它当作一个可运行的参照工程顺着 controller、model、tasks.py 的调用链就能看懂请求怎么进来、模型怎么预测、结果怎么返回整个 Web 演示闭环是完整的。2. Django工程分层与MNIST源码包的文件组织先整体认目录。源码包里同时出现了app/models.py和app/model/目录前者是 Django ORM 模型后者放 TensorFlow 网络定义和训练脚本两者职责完全不同一个面向数据库表一个面向张量计算。解压后会看到DjangoTensorflow-主master这个根目录内部最重要的部分如下。DjangoTensorflow-主master ├── manage.py ├── requirements/ │ ├── 参考配置.txt │ ├── dev.txt │ └── test.txt ├── blog/ # Django 工程配置 │ ├── settings.py │ ├── celery.py │ ├── urls.py │ └── wsgi.py ├── app/ # 业务 App │ ├── controller/ # 视图逻辑替代一部分 views.py │ ├── model/ # TensorFlow 模型结构与训练代码 │ ├── models.py # Django ORM 模型 │ ├── views.py │ ├── forms.py │ ├── middleware.py │ ├── tasks.py # Celery 异步识别任务 │ ├── urls.py │ ├── templatetags/ │ ├── templates/ │ ├── migrations/ │ ├── static/ │ └── images/ ├── MNIST_data/ # 本地 MNIST 四件套 │ ├── train-images-idx3-ubyte.gz │ ├── train-labels-idx1-ubyte.gz │ ├── t10k-images-idx3-ubyte.gz │ └── t10k-labels-idx1-ubyte.gz └── logs/ ├── faillog.log ├── django.log └── dberror.log这个结构有一个值得借鉴的地方真正接收 HTTP 请求的代码被放进controller/而不是堆在views.py里。很多 Django 新手默认每个请求都要写函数进views.py时间一长文件上千行这里把页面渲染、手写字体识别、结果下载拆到不同模块再用app/urls.py做映射分工更清楚。2.1 MTV 模式与 TensorFlow 推理的职责边界Django 的 MTV 里Model 管数据库Template 管渲染View 管业务逻辑。MNIST 推理不适合放进 Model 层也不适合直接写在模板标签里。常见做法是把推理封装成独立的 controller 或 service 模块让 ORM 只存识别记录不关心张量怎么算模板只接收label和confidence不需要 import tensorflow。这样将来把模型从训练好的 h5 换成 TensorFlow Lite 或 ONNX页面和数据库表都不用动。各路径的职责和二次开发关注点可以按下表对应。路径职责二次开发时关注点app/controller/接收请求、调用模型、返回响应路由最终指向这里的函数app/model/网络定义、训练、checkpoint 保存改结构时同步改预处理和输入尺寸app/models.py识别记录、日志等 ORM 对象字段变更要记得 makemigrationsapp/tasks.pyCelery 异步识别任务同步转异步时入口在这里blog/settings.pyApp 注册、队列、日志、数据库新增中间件或 App 必须在这里登记MNIST_data/四份 gz 原始数据路径写死时要检查是否在项目根目录之所以把 controller 单独拎出来是因为 MNIST 识别项目很容易陷入“views.py 里又做表单校验又调模型又拼模板”的结构。如果以后要接 API 前端视图逻辑和请求处理混在一起会非常难改现在 controller 层只关心请求参数、预处理和响应views.py保留一层转发前后端分离时只需要新增一组 JSON 接口不动识别逻辑。2.2 settings.py 里最容易影响运行的几个配置整个工程能不能一次跑起来多半是blog/settings.py的配置问题。压缩包自带logs/faillog.log说明日志配置是开着的如果接手后在控制台看不到 TensorFlow 报错先看LOGGING是不是把 ERROR 级别的日志导进了faillog.log。一段常见配置写法如下。INSTALLED_APPS [ django.contrib.admin, django.contrib.auth, django.contrib.contenttypes, django.contrib.sessions, django.contrib.messages, django.contrib.staticfiles, app, ] CELERY_BROKER_URL redis://localhost:6379/0 CELERY_RESULT_BACKEND redis://localhost:6379/0 LOGGING { version: 1, disable_existing_loggers: False, handlers: { fail_file: { level: ERROR, class: logging.FileHandler, filename: logs/faillog.log, }, }, loggers: { django: { handlers: [fail_file], level: ERROR, propagate: True, }, }, }INSTALLED_APPS里注册app后Django 才会扫描models.py、admin.py、templatetags/CELERY_BROKER_URL指向 Redis是tasks.py能异步执行的前提。如果本机没装 Redis同步调用识别任务也能出结果但delay()会一直连不上 broker。LOGGING中的filename是相对工程根目录的路径启动命令所在目录不同日志落点也会不同这就是为什么源码里logs/faillog.log会留下历史记录。依赖文件方面requirements/dev.txt通常包含调试工具test.txt负责测试依赖参考配置.txt里一般写着 pyenv 或 virtualenv 下建议的包版本组合。批量安装时先装dev.txt因为它大概率会依赖test.txt和基础运行依赖但不要直接在部署机上用dev.txt调试工具在线上只会增加额外负担。MNIST 数据既然已经在本地验证数据完整性就看四个 gz 文件是否都在MNIST_data/下缺一个都会在数据读取阶段抛 gzip 相关异常。3. MNIST模型训练与checkpoint导出网络结构、数据加载与准确率调参把工程拆开看最核心的逻辑落在app/model/和app/tasks.py。一般毕设只要把 MNIST 准确率跑到 99% 以上Django 部分接一个表单上传就行真正决定项目完成度的是数据读取和模型导出这两段。MNIST 原始文件是 IDX 格式每张图 28×28 像素像素值 0-255train-labels-idx1-ubyte.gz是训练标签。高版本 TensorFlow 的keras.datasets.mnist.load_data()默认会去网络拉取一份缓存网络不稳时容易失败压缩包自带四个 gz 文件所以最好直接读本地路径。3.1 本地 IDX 数据怎么在不联网的情况下读进来IDX 文件头部是 16 个字节前 4 字节是 magic number接着 4 字节是样本数再 4 字节行数再 4 字节列数真正图像数据从第 16 字节开始。用 gzip 解压后跳过偏移即可还原成 numpy 数组。下面的读取函数可以直接换成项目里的MNIST_data/train-images-idx3-ubyte.gz。import gzip import numpy as np def load_images_from_idx(path: str) - np.ndarray: with gzip.open(path, rb) as f: raw f.read()[16:] # 跳过 magic、样本数、行数、列数 data np.frombuffer(raw, dtypenp.uint8) return data.reshape(-1, 28, 28, 1).astype(float32) / 255.0 def load_labels_from_idx(path: str) - np.ndarray: with gzip.open(path, rb) as f: raw f.read()[8:] # 标签文件只有 magic 和样本数两个头 return np.frombuffer(raw, dtypenp.uint8).astype(int32)offset8是标签格式的关键label 文件头部是 magic number 和样本数共两个 int32从第 8 字节开始才是 0-9 的标签。像素除以 255.0 是把输入归一化到 0-1 区间避免神经网络在反向传播时梯度变化幅度过大。项目里如果保留了老版本 TensorFlow 的input_data.read_data_sets(MNIST_data/, one_hotFalse)在 TF 2.x 上已经移除了这段模块手写上面两个函数更稳妥。3.2 两层卷积加全连接的结构选型MNIST 任务简单但简单不等于退化成单层全连接。全连接网络也能到 97% 左右但参数量集中在 784 个输入节点到隐含层的连接上对偏移、旋转这类形变的容忍度差。用两层Conv2D MaxPooling2D提取局部笔画特征再接一个 128 维全连接层结构上不会过度复杂CPU 训练 10 个 epoch 也就几分钟非常适合毕设展示。import tensorflow as tf def build_mnist_model(): inputs tf.keras.Input(shape(28, 28, 1)) x tf.keras.layers.Conv2D(32, (3, 3), activationrelu, paddingsame)(inputs) x tf.keras.layers.MaxPooling2D((2, 2))(x) x tf.keras.layers.Conv2D(64, (3, 3), activationrelu, paddingsame)(x) x tf.keras.layers.MaxPooling2D((2, 2))(x) x tf.keras.layers.Flatten()(x) x tf.keras.layers.Dense(128, activationrelu)(x) outputs tf.keras.layers.Dense(10, activationsoftmax)(x) return tf.keras.Model(inputs, outputs)第一层 32 个卷积核负责捕捉边缘、弧线等低层特征第二层 64 个卷积核在池化后的特征图上组合出数字的整体结构paddingsame保证池化后特征图尺寸可被 2 整除。输出层用 softmax 输出 10 个类别的概率分布argmax得到预测数字。这个结构和 LeNet-5 的思路接近但没有手写 subsampling 层代码可读性更好。3.3 训练超参与 checkpoint 回调查参训练时我一般把 batch_size 设为 128epochs 先给 10用ModelCheckpoint保存每个阶段的权重。学习率默认 0.001 对 Adam 够用但如果训练后期验证集在 99% 附近震荡可以加ExponentialDecay让学习率逐步衰减收敛更平稳。下面是可直接放进app/model/train.py的训练循环骨架。checkpoint tf.keras.callbacks.ModelCheckpoint( app/model/ckpt/mnist-{epoch:02d}-{val_accuracy:.4f}.weights.h5, save_weights_onlyTrue, save_best_onlyTrue, monitorval_accuracy, ) lr_schedule tf.keras.optimizers.schedules.ExponentialDecay( initial_learning_rate0.001, decay_steps500, decay_rate0.95, staircaseTrue, ) model build_mnist_model() model.compile( optimizertf.keras.optimizers.Adam(learning_ratelr_schedule), losssparse_categorical_crossentropy, metrics[accuracy], ) model.fit( x_train, y_train, batch_size128, epochs10, validation_split0.1, callbacks[checkpoint], )save_weights_onlyTrue保存的是权重文件体积小、便于版本管理save_best_onlyTrue配合monitorval_accuracy只在验证准确率提升时覆盖旧权重避免磁盘被 10 个 epoch 的重复文件占满。decay_steps500表示每 500 步衰减一次staircaseTrue让学习率呈阶梯状下降而不是连续下降训练曲线更容易观察。参数常用值调整方向batch_size128显存小降 64收敛慢升 256epochs10-15到 99% 后继续太久容易过拟合optimizerAdamSGD 需要更细致调 lr不建议毕设用validation_split0.1数据集大时可降到 0.05lr0.001震荡时改用 ExponentialDecay训练结束得到的mnist-10-0.9912.weights.h5或完整 h5 模型会被 Django 推理模块加载。如果只保存权重加载前必须先执行一次build_mnist_model()生成结构如果用model.save(app/model/mnist_keras.h5)保存完整模型Django 端直接tf.keras.models.load_model()就行。源码包里app/model/同时存在两种保存方式的痕迹时优先确认推理代码里是哪一种常见报错unexpected keyword argument training多半就是权重与模型结构不匹配。4. Django视图层把模型推理串成Web接口目录结构与训练脚本都确认后下一步是看请求从浏览器到 TensorFlow 预测的完整路径。MNIST 识别的输入不是普通文件流而是一张手写数字图片需要经过灰度化、缩放、归一化才能进入模型所以 Django 视图里最值得打磨的就是预处理这段。常见做法是在forms.py里定义一个只带image字段的ImageField表单视图层拿到request.FILES后用 Pillow 打开而不是直接交给 TensorFlow避免用户上传的非图片文件触发底层解码异常。4.1 urls.py 路由与 controller 的映射关系app/urls.py负责把 URL 分发给函数blog/urls.py再用include把业务路由挂到总路由表。不要把逻辑全部堆在总路由里否则新加一个/download/接口就要动工程配置。这里按 controller 模块拆分路由后维护成本明显更低。# app/urls.py from django.urls import path from .controller import predict_controller, download_controller urlpatterns [ path(, predict_controller.home, namehome), path(predict/, predict_controller.predict, namepredict), path(download/int:record_id/, download_controller.download, namedownload), ]path(predict/, ...)里的predict/是相对路径最终完整路径取决于blog/urls.py里 include 时给的前缀int:record_id是 Django 2.0 之后推荐的路径参数语法它会把 URL 片段强转成 int比老式正则(?Precord_id\d)更直观。路由里函数名不要和表单字段重名否则在 templates 里做{% url %}反向解析时会引入命名冲突。4.2 图片上传、28×28 预处理与模型预测推理视图主要处理四件事校验表单、打开图片、预处理、预测。MNIST 模型只接受 28×28 的灰度像素所以彩色的手机拍照图必须转成灰度后 resize很多新手直接img.resize((28, 28))如果原图是三通道 RGBnp.asarray(img)的形状会是(28, 28, 3)送到输入层前必须去掉通道或做灰度化。from PIL import Image import numpy as np from .forms import UploadForm from .models import PredictRecord def predict(request): if request.method POST: form UploadForm(request.POST, request.FILES) if form.is_valid(): img Image.open(request.FILES[image]).convert(L) img img.resize((28, 28), Image.LANCZOS) arr np.asarray(img, dtypenp.float32).reshape(1, 28, 28, 1) / 255.0 pred loaded_model.predict(arr, verbose0) label int(np.argmax(pred[0])) confidence float(pred[0][label]) record PredictRecord.objects.create( labellabel, confidenceconfidence, sourcerequest.POST.get(source, web), ) return render(request, app/result.html, { label: label, confidence: f{confidence * 100:.2f}%, record_id: record.id, }) form UploadForm() return render(request, app/upload.html, {form: form})Image.open().convert(L)是灰度化关键即使原图带透明通道转换后也只保留亮度值。reshape(1, 28, 28, 1)为模型增加 batch 维度因为 Keras 模型的预测接口要求输入至少是二维以上单独的(28, 28, 1)会被当成 batch 缺失而报维度错误。verbose0关闭进度条输出避免在 Django 控制台刷屏np.argmax拿到概率最大的索引作为数字标签pred[0][label]就是模型对结果的确信程度。PredictRecord.objects.create把每条识别都落库后续需要按置信度筛选或后台查看都有数据可查。提示MNIST 训练集是黑底白字用户上传的扫描图往往是白底黑字。做灰度化之后可以比较像素均值若均值大于 127就对数组做255 - arr反色否则识别准确率会明显下降。4.3 StreamingHttpResponse 下载识别结果时的 content_type 与 Content-Disposition很多工程到识别页面就结束了但如果后端要生成一张标注了预测结果的新图或把识别结果以文件流形式直接下载给前端Django 的StreamingHttpResponse比HttpResponse更合适。它不一次性把整块内容塞进内存适合给图片文件或生成的大结果加响应头。关键参数是content_type和响应头里的Content-Disposition前者告诉浏览器这是图片后者决定是内联展示还是触发下载。from django.http import StreamingHttpResponse from .models import PredictRecord def download(request, record_id): record PredictRecord.objects.get(idrecord_id) image_data render_result_image(record.label, record.confidence) response StreamingHttpResponse( iter([image_data]), content_typeimage/png ) response[Content-Disposition] fattachment; filenamemnist_result_{record.id}.png return responsecontent_typeimage/png不是随便写的如果写成application/octet-stream浏览器也会下载但图片预览和 embed 引用都会失效。Content-Disposition里的attachment强制浏览器下载改成inline则只会直接展示在页面中filename尽量用 ASCII 文件名中文文件名会因编码规则在部分浏览器里显示成乱码。StreamingHttpResponse要求第一个参数是可迭代对象这里用iter([image_data])包一层确保传给下载组件的 body 是字节流而不是字符串。接口方法参数返回内容/GET无上传页面/predict/POSTimage文件字段识别结果页/download/record_id/GET路径参数PNG 图片流record_id如果不存在PredictRecord.objects.get会抛 DoesNotExist线上要有get_object_or_404或 try/except。图片预处理里还有一个方向性问题MNIST 训练集是黑底白字而普通用户用手机拍的往往是白纸黑字如果不做反色识别准确率会明显下降。视图中可以在convert(L)之后比较像素均值若均值大于 127 就255 - arr这是 MNIST 实用化时最容易踩的一步。5. Celery异步识别、Admin管理与部署前最后一轮排错预测接口直接在请求里跑 TensorFlow 有个隐患加载模型是一次重操作首次请求可能要等几秒到十几秒。项目里app/tasks.py配合blog/celery.py就是要把这种费时推理从 HTTP 请求周期里摘出去。Celery 任务拿到图片路径后执行识别再把结果写回数据库或缓存前端轮询任务状态避免用户在 HTTP 连接上干等。from celery import shared_task from app.model.predictor import load_model_and_predict shared_task(bindTrue, max_retries3) def run_mnist_task(self, image_path): try: label, confidence load_model_and_predict(image_path) return {label: label, confidence: confidence} except Exception as exc: raise self.retry(excexc, countdown5)bindTrue让任务对象可以访问self.retry识别失败后会等待 5 秒再重试max_retries3限制重试次数避免 Redis 里堆积无效任务。开发环境如果不开 Celery worker也可以用run_mnist_task.run(image_path)同步调用但就失去了异步效果。5.1 admin.py 配后台前先把 ORM 字段登进去源码包里app/admin.py里的注册代码决定后台长什么样。默认写法admin.site.register(PredictRecord)只能看到一个对象列表字段排列也乱改成ModelAdmin之后列表页可以直接看到每条识别记录的 label、confidence、创建时间和来源过滤、搜索都在后台完成答辩演示时效果会好很多。from django.contrib import admin from .models import PredictRecord admin.register(PredictRecord) class PredictRecordAdmin(admin.ModelAdmin): list_display (id, label, confidence, source, created_at) list_filter (label, created_at) search_fields (source,)list_display决定列表页展示哪些列字段必须在模型上真实存在search_fields会在后台生成一个搜索框模糊匹配对应字段。对于识别记录这类只增不改的表不需要开list_editable否则一不留神就会把预测结果改成脏数据。5.2 低置信度删除与部署前三个检查后台数据积累后要清理Django 的QuerySet.delete()是批量删除入口。直接把PredictRecord.objects.all().delete()写进业务代码很危险更稳妥的做法是按置信度筛选只删除明显可疑的识别记录。deleted_count, _ PredictRecord.objects.filter(confidence__lt0.6).delete()filter(confidence__lt0.6)表示置信度小于 0.6lt是 less than 的条件表达式delete()返回的是(总删除数量, {模型名: 删除数量})元组用下划线接收第二个返回值。迁移时如果碰到django.db.utils.ProgrammingError: column does not exist多半是改了models.py但没跑makemigrations执行迁移前先确认migrations/目录下有没有对应的新迁移文件。部署前还要检查app/middleware.py和settings.py的MIDDLEWARE列表是否一致。源码把中间件单独放出来开发环境可以用它打印慢请求或异常堆栈但部署时如果中间件里写了调试用逻辑整个站点都会带上额外耗时。最后看logs/faillog.log正常情况它应该只记录 ERROR如果里面反复出现 gzip 解压错误说明 MNIST 数据路径写死在了开发机的绝对路径改成os.path.join(BASE_DIR, MNIST_data)后重启再试。把识别请求耗时和异常堆栈同时写进faillog.log的同一行下次上线前看日志就能直接定位瓶颈。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联
返回资讯列表 →