简介压缩包内是一套完整的微信小程序拍照功能开发与手写汉字识别评分系统源码面向教育类小程序开发者、学习OCR与深度学习应用的学生。项目包含基础拍照、指定区域拍照等图像捕捉功能并借助OCR技术对手写汉字进行识别结合深度学习模型实现书写评分适用场景覆盖汉字练习、书法教学和作业批改。包内共38个文件以9个js、8个wxss、8个json、7个wxml等小程序前端文件为主构成页面逻辑、样式、配置与视图层另有说明文件txt、文档docx、md及示例图片压缩包整体仅73KB结构清晰便于直接阅读与二次开发。目前已有55人学习下载。通过该资源可获取完整项目目录、页面交互逻辑含index、exam、takePhoto等模块、图像处理与识别评分的实现思路以及附赠的说明文档适合作为课程设计或竞赛项目的参考模板。1. 一张手写照片如何变成可量化的汉字评分做教育辅助工具的人应该都遇到过这种需求用户在微信小程序里对着练字本拍一张照系统自动圈出田字格里写的字然后告诉用户“你写的‘永’字结构得分86捺画收笔太快”。这条链路的入口就是微信小程序拍照功能后面接着手写汉字识别、汉字书写评分。项目的核心其实不在拍照而在指定区域拍照之后的图像处理算法和深度学习模型——拍照只负责把一方田字格干净地截下来识别负责把字认出来评分负责把字的好坏讲清楚。最适合这个形态的产品是一个嵌入书法学习应用里的“练字点评”能力不是让用户发一张照片等AI海报而是让用户对着取景框写完、立即得到可解释的评分回馈。我写这篇笔记时假设你手上已经有一份以“汉字.zip”分发的资源包里面通常包含小程序端代码、识别与评分模块说明、以及可微调的标注数据样例没有的话按文中提到的公共数据集和自建数据方案也能把链路搭起来。这条链路适合三类人想在小程序里做拍照取字的开发者、想把OCR和深度学习模型真正跑进业务的教育工具团队以及需要给现有App做书法练习模块的前端工程师。2. 小程序拍照与指定区域取图从camera组件到区域裁剪拍照这件事在小程序里有两套主流做法一是直接调起系统相机用wx.chooseMedia或wx.chooseImage二是用camera组件把相机预览嵌进页面。做指定区域拍照时我会毫不犹豫选camera组件。2.1 为什么指定区域拍照要用camera组件而不是chooseMediawx.chooseMedia让用户跳转到系统相机拍完返回一个临时文件路径。它的核心问题是用户拍照时看不到“应该把字放在哪个位置”。如果只告诉他“把田字格放在中间”拍回来的图大概率是倾斜、偏移、带着手掌阴影的。调起系统相机时小程序页面完全退出你无法在取景器上叠加任何引导框。camera组件则把相机预览嵌到页面上一个指定大小的视图里可以在这个视图上叠加半透明遮罩也可以用CSS在预览层之上画一个田字格。用户写字时只要把纸上的田字格与屏幕上的田字格对齐再按快门拍出来的就是一张“几乎不用再裁剪”的图。这一步对后面的识别和评分影响巨大——评分算法最怕的输入就是混进桌面纹理、手指、笔杆投影这些干扰物。从实践看取景框对齐方案能把后续图像预处理的难度降低一半以上。提示camera组件在部分安卓机型上预览会有1到2帧延迟叠加遮罩后要对齐真实取景区域做坐标换算不能直接拿屏幕像素坐标去裁剪原图。camera组件有两个值得注意的属性type指定normal/back/front拍照场景用backframe-size虽然能设置取景帧尺寸但它只影响bindscancode回调的帧数据不影响takePhoto返回的照片分辨率。拍照的落点通常是wx.createCameraContext().takePhoto它返回的tempImagePath是完整相机分辨率的原始帧。要拿到“田字格内”那一块必须在takePhoto之后用canvas做裁剪。2.2 用离屏Canvas裁出田字格区域坐标换算与代码实现先理清坐标系camera组件的取景画面会在组件区域内等比缩放显示不在取景框内的画面不会显示但takePhoto返回的是完整相机分辨率图像。遮罩层覆盖在camera组件之上遮罩上田字格的位置是页面坐标px而我们要在完整照片上裁出对应区域就需要把遮罩坐标换算到照片坐标。换算比例取决于组件显示尺寸和实际照片尺寸的关系。常见的实现是把遮罩田字格固定居中比如组件宽375、高375田字格框在组件内居中、边长为300。完整照片的分辨率可能是1080×1920竖拍或1920×1080横拍组件内看到的是取景画面等比缩放后的结果。例如竖拍1080×1920组件是正方形375×375相机画面宽高比约0.56塞进375×375的框里时实际显示的是照片中间宽度为1080、高度为1080的方形区域上下各裁掉420再缩放到375×375。于是照片坐标 遮罩坐标 / 375 × 1080并且要加上在照片上的纵向起始偏移420。这段坐标关系是小程序拍照里最容易被绕晕的地方关键认知是不要直接用相机分辨率去比先算出组件内画面与完整照片的对应关系。为了躲开这套换算里繁琐的细节我一般会把camera组件设计为正方形并让用户保持纸张横向或纵向一致这样换算只需要一组比例系数。// 拍照后裁出田字格区域离屏Canvas 2D实现 function cropRegion(tempImagePath, region) { return new Promise((resolve) { // region: { startX, startY, width, height }单位是照片原始像素 const canvas wx.createOffscreenCanvas({ type: 2d, width: region.width, height: region.height }) const ctx canvas.getContext(2d) const img canvas.createImage() img.onload () { // 源图从(startX, startY)开始挖一块width*height铺满画布 ctx.drawImage(img, region.startX, region.startY, region.width, region.height, 0, 0, region.width, region.height) // 导出jpg而不是pngjpg体积小很多识别接口传输更快 resolve(canvas.toDataURL(image/jpeg, 0.92)) } img.src tempImagePath }) }这段代码有三个重点。第一wx.createOffscreenCanvas在小程序基础库2.16.1以上才稳定低于这个版本建议改用页面内隐藏canvas组件配合wx.createCanvasContext去drawImage。第二导出用JPEG、质量0.92手写识别和评分算法对JPEG压缩的容忍度很高但png在弱网下的传输体积会让用户等得更久。第三drawImage的九个参数里前四个是源图裁剪坐标后四个是目标区域坐标。很多人在这里把前四后四搞混导致裁出来是一张拉伸变形的图——这个错误在控制台不会报错只有对比原图才能发现。2.3 拍照参数与预处理把“歪图”拉正指定区域拍照能解决构图问题但解决不了所有物理世界的问题。最常见的三种输入干扰纸张倾斜超过10度、桌面上有阴影、字迹与背景对比度低。倾斜问题我通常在服务端做预处理但更好的做法是让遮罩自带对齐提示在田字格四角画四个角标用户把纸上的格线与角标对齐后再拍。这属于产品级的方案技术上却很轻量。服务端的图像预处理建议放到识别环节之前统一做小程序端只做两件事把原图里除田字格之外的区域裁掉顺便做一次压缩上传。// 拍照成功后压缩并上传 wx.compressImage({ src: tempImagePath, quality: 85, success(res) { wx.uploadFile({ url: https://api.example.com/recognize, filePath: res.tempFilePath, name: image, formData: { scene: hanzi_crop }, success(res2) { // 服务端返回值: { code, word, score, detail } } }) } })compressImage的quality不建议低于80。练字场景里铅笔字是浅灰色的压缩率太低会把笔画和纸色拉近识别模型的特征被压缩掉85到92之间既能控制体积又能保留笔画边缘。formData里的scene字段是给服务端分流用的同一个接口可能服务“整行识别”和“田字格单字评分”两种场景用scene区分可以避免为每个场景拆一个接口。这里把链路边界说清楚小程序端只负责采集、裁剪、上传和结果展示识别与评分都在服务端做。原因有两点一是模型直接跑在小程序端会踩iOS上WebGL/WASM的性能和稳定性坑二是评分算法需要频繁调整特征放服务端才能随时更新而不发新版本。如果是纯局域网或离线使用场景再考虑用TensorFlow.js的WASM后端把模型搬到端上。参数推荐值说明compressImage quality85-92低于80笔画变淡识别率明显下降裁剪导出格式JPEG 0.92相比PNG体积小约70%遮罩田字格边宽组件宽度的80%留出边距便于对齐纸张格线上传场景字段hanzi_crop服务端据此分流识别与评分逻辑3. 手写汉字识别选型为什么通用OCR做不好“单个练字”先给结论如果直接用常见的通用OCR文字识别接口去识别用户拍下来的田字格手写汉字大概率得到一个尴尬的结果。通用接口能识别印刷体、能识别行云流水的行书但识别“小学初学者的歪扭临摹字”时经常出错。这不是接口不好而是任务根本不匹配。3.1 架构选型整图OCR vs 单字分类模型通用OCR的典型链路是“检测识别”先用检测模型把图里的每行文字框出来再用识别模型通常是CRNNCTC或者Transformer解码器输出文字序列。这套链路为“图片里有若干行字”设计。而我们的场景里图片经过指定区域拍照裁剪后理论上只有田字格里的一个字偶尔有半个没裁干净的临格字。这时候有两类做法。做法A继续走检测识别把检测框调成田字格大小把区域内文字按序列模型识别。适合用户可能写多个字、或一个格子里出现两个字的情况。做法B把识别当作图像分类来做裁剪后的田字格图片直接送进轻量CNN分类网络输出在常用汉字类别上的概率分布。我推荐做法B。理由有三个第一单字分类模型的输出是3755类或更多对应GB2312常用字表每类一个概率实现简单CRNN要处理不定长序列模型复杂度和调参成本都更高。第二分类模型的错误模式可控——输出一个置信度分布你可以设阈值低于阈值时告诉用户“没能认出这个字”而不是给一个不确定的硬结果。第三评分模块需要“这个字是哪个字”的信息分类结果同时告诉你标准字类别评分时就可以取对应范字的模板特征去比对。3.2 用轻量CNN分类模型数据集、训练与服务端部署模型层面我一般用ResNet18或MobileNetV3-small做backbone把最后的全连接层换成类别数。输入统一缩放到224×224。为什么不用更大模型因为单字识别的信息量不大大模型在小数据集上更容易过拟合而且服务端推理要扛住并发MobileNetV3在CPU上的单次推理只有几十毫秒能省下GPU成本。训练数据建议这样搭用CASIA-HWDB公开数据集做预训练底座再用自己采集的田字格练习字微调。CASIA-HWDB覆盖大量离线手写汉字样本量级在百万以上足够让模型学到笔画结构但它的样本主要是正常手写体缺少“初学者的歪扭字”所以必须用真实练习数据微调否则识别率和评分的可靠性都上不去。# 单字分类模型的训练配置PyTorch骨架 import torch import torchvision.models as models model models.mobilenet_v3_small(weightsmodels.MobileNet_V3_Small_Weights.IMAGENET1K_V1) num_classes 3755 # GB2312一级汉字按业务裁剪 model.classifier[3] torch.nn.Linear(model.classifier[3].in_features, num_classes) # 数据增强重点是模拟拍照环境的抖动 transform transforms.Compose([ transforms.RandomAffine(degrees(-8, 8), translate(0.03, 0.03)), transforms.ColorJitter(brightness0.35, contrast0.35), transforms.GaussianBlur(kernel_size(3, 3), sigma(0.1, 1.0)), transforms.Resize((224, 224)), transforms.ToTensor(), ])这里有两个容易踩的点。一是负样本问题分类模型只输出3755类概率如果用户拍进来英文字母或字表外的异体字模型会强行归类成“最像的一个常见字”。所以分类输出之后必须带置信度阈值和拒绝逻辑宁可告诉用户认不出也不能硬给一个错误的字去评分。二是ColorJitter参数的选择练字纸张拍照最常见的干扰是阴影和偏色亮度扰动要覆盖到35%左右对比度同样太小了覆盖不住真实场景太大的话把白纸也扰动成了灰纸。部署路径上有两种常见选择自建服务或微信云开发。云开发环境可以直接部署云函数但云函数冷启动可能达到1到3秒对“拍完立即评分”的体验影响不小更稳妥的方案是让几个云函数实例常驻或者把识别评分做成独立HTTP服务小程序端通过wx.request请求。模型文件放服务端用ONNX Runtime推理完全绕开小程序主包2MB、总包20MB的包体限制也方便后续换模型版本。3.3 从识别到评分的接口设计一次返回两段处理识别和评分虽然是两个阶段但小程序端最好只调一个接口。上传照片后服务端先做裁剪校准、再做识别、再做评分一次响应里带上word、score、detail三个字段。这样前端不需要为“先识别再评分”设计中间loading态用户感知到的就是“拍照后约1秒出结果”。// 识别评分一次返回的响应结构 { code: 0, word: 永, confidence: 0.93, score: { total: 84, dimensions: { structure: 86, stroke: 78, rhythm: 82 } }, tips: [重心略偏左, 捺画的末端收笔过快] }产品层面有个细节值得注意评分只给一个总分用户看完就走给维度分解和具体提示用户才会留下来练下一个字。score.dimensions里的三个维度对应下一章要讲的图像特征而不是模型胡猜的数字。这要求评分模块的前置依赖——识别结果——必须准确所以置信度低于0.7时我一般直接返回“请重新书写”而不是硬给一个低分。4. 汉字书写评分不靠审美玄学靠可解释的图像特征评分是这个项目里最容易被误解的部分。外界会以为评分用的是深度学习模型“学会”了书法审美实际上第一版评分完全可以由规则和图像处理算法叠加出来——深度学习模型只用在识别环节评分靠的是可计算、可解释的几何特征。这个选择不是保守而是因为用户会质疑“凭什么给我85分”如果你用一个端到端的黑匣子评分网络来解释解释不出来教育产品就失去了价值。先讲清楚这个设计逻辑后面实现才不会跑偏。4.1 评分前处理从照片到笔画骨架拿到裁剪好的田字格图片第一步是二值化。练字纸通常是白色或米黄色字迹可能是黑色钢笔或灰色铅笔。全局阈值在光照均匀时够用但如果有阴影穿过格子OTSU自适应阈值也可能把阴影当成前景。我一般先做直方图均衡化再用高斯核对图片做轻度平滑然后才进入OTSU如果纸的底色不均匀改成局部自适应阈值。二值化之后有一个必做步骤清理孤立噪点和边缘碎片。做法是连通域分析标记面积小于图片总面积0.5%的连通域直接置为背景。不清理的话噪点会在骨架算法里生成伪枝严重影响后续特征提取。这一步经常被新手跳过等到评分结果忽高忽低时才开始排查。骨架化我用经典的Zhang-Suen细化算法它能把笔画逐步腐蚀成单像素宽的骨架。骨架图的价值在于它让“笔画比例、弯折、端点分布”这些特征变得更稳定——粗笔和细笔在像素级图像上差异很大但在骨架级上几乎一样这样评分就不会被“用户用粗笔还是细笔”干扰。# 二值化 骨架化依赖skimage import numpy as np from skimage import filters, morphology def to_skeleton(gray): # 局部均衡化后再做OTSU避免阴影干扰 norm filters.rank.equalize(gray, footprintnp.ones((15, 15))) binary gray filters.threshold_otsu(norm) # Zhang-Suen细化skimage的skeletonize基于该算法 skeleton morphology.skeletonize(binary) return skeleton, binary代码里有个关键细节rank.equalize是局部均衡化能处理纸张上从一侧到另一侧的亮度渐变这在拍照练字场景中几乎必现。skeletonize的返回值是布尔数组后续统计端点、交叉点都基于它。如果发现骨架上有大量小毛刺说明前面的连通域清理没做干净回头去调面积阈值而不是在骨架层面反复滤波。4.2 三个评分维度的特征工程位置、结构、笔画质量评分维度定成三个和前面接口里的structure/stroke/rhythm对应。第一是结构计算字块重心的位置偏差以及字块在田字格中的占格比例。标准规范字一般占格面积的60%到85%太大显得溢出太小显得虚弱。第二是笔画质量统计骨架上的端点数量、交叉点数量、相邻笔画之间的平均间距以及笔画宽度的一致性。第三是节奏感这个听起来玄学实际上统计笔画长度分布的方差——把握好的字各笔画长度比例和范字接近歪扭的字笔画长度偏离范字更远。“重合度”这一项需要先对齐不能直接比较像素。用户字和范字即使写得再像重心也可能有几像素偏移直接按原位置比对重合度会把该得的分也扣掉。常见做法是先计算两个骨架质心的平移向量把用户骨架平移至与范字质心重合再做像素比对。这一步不做结构维度永远偏低而且用户会反馈“明明我写得很居中”。# 计算用户骨架与范字骨架的重合率先对齐再比 def skeleton_overlap(user_sk, ref_sk): uy, ux np.where(user_sk) ry, rx np.where(ref_sk) dy, dx int(ry.mean() - uy.mean()), int(rx.mean() - ux.mean()) moved np.roll(user_sk.astype(np.uint8), shift(dy, dx), axis(0, 1)) inter np.logical_and(moved 0, ref_sk).sum() union np.logical_or(moved 0, ref_sk).sum() return inter / unionnp.roll在图像边缘会产生换行伪影但骨架边缘噪点在前面已清理过影响可控想更严谨可以把超出边界的像素先mask掉再计算。重合率在0.75以上说明写得很接近范字0.55以下基本是结构问题——重心偏移、笔画比例失调、字块大小异常。实际调参时我会在每个维度里放三到五个类似特征再观察哪个特征在用户反馈里最贴近“差在哪”的主观感受。特征计算方式对应评分维度重心偏移骨架质心与田字格中心距离结构占格比例前景像素外接矩形面积 / 格子面积结构骨架端点数量8邻域内只有1个相邻点的像素数笔画质量笔画宽度方差二值图中笔画宽度分布标准差笔画质量长度分布方差各骨架分量长度与范字比值的方差节奏4.3 从特征到分数规则加权 回归校正有了十几个特征后有两种手段把它变成0到100的总分。第一种是规则加权按专家经验定权重结构占40%笔画质量占40%节奏占20%每个特征先做min-max归一化再线性映射到0到100。第二种是回归校正准备两三百份人工评分样本以特征为输入、人工分为标签训练一个轻量GBDT回归器比如XGBoost或LightGBM。我一般两种结合先用规则加权做“保底解释版”再用回归器做“精度版”。纯规则在边界样本上不够准比如一个字写得很局促但每个细节都到位规则分容易偏低纯回归又牺牲了可解释性。因此真实做法是回归器出总分规则加权出“每维解释了扣分在哪”把tips字段替换成规则分里最大的扣分项来源。这样用户看到的是“结构86、笔画78、节奏82”的分解而不是一个冷冰冰的84。注意回归器训练时不要直接用人工打的绝对分当标签要用“相对分差”。不同老师的评分尺度差异很大有人手松给90有人手紧给65直接训练会把主观尺度学进去。做法是先对人工分做Z-score标准化再映射回最终展示区间。另外要提一个容易翻车的点评分模型不要用评分结果微调识别模型。识别模型一旦为了迎合评分而改动整个系统的错误传导会很难排查。识别和评分两个模型解耦后各自版本可以独立升级出问题时也容易定位是识别错了还是评分特征出了问题。5. 避坑手册小程序拍照评分项目的高频翻车现场5.1 现象裁剪区域总是偏了半个田字格测试人员反馈真机上拍出来的结果显示区域总比实际书写区域偏右下方。原因不是canvas代码写错而是camera组件在不同机型上的预览比例不一样。iPhone上取景画面是完整传感器输出部分安卓机型camera组件的预览为了适配宽度做了裁剪屏幕上的田字格遮罩和takePhoto返回的原图区域对应不上。解决办法是拍照前先读取系统信息根据屏幕宽高比动态计算遮罩框与照片坐标的换算系数而不是写死一组375和1080。我在2.2节里给出了换算公式实际落地时要把这组系数放到config里针对每个机型调试一轮。5.2 现象识别的字和用户写的字对不上还带繁体用户写了个“万”识别结果显示“萬”。原因是分类模型的类别表里包含了繁体字而训练样本里繁体字的字形特征和简体混在一起模型在概率相近时选错了。解决方法是建一张简体-繁体映射表输出层把繁体类归并到简体标签上如果业务只面向大陆用户直接在数据准备阶段把繁体样本剔除。另外手写“飞”“风”这类字形接近的字时模型经常在概率0.6附近摆动我一般把置信度阈值调到0.75低于阈值提示用户重写而不是硬给一个识别结果。5.3 现象评分接口在晚高峰响应要3秒以上上线后某一天用户密集使用时接口响应突然变慢。原因是云函数实例在并发上来后被冷启动拖累模型加载在每次冷启动时都要重来一遍。解决思路分两层一是把模型文件放到服务端独立常驻进程里不用云函数用容器或虚拟机跑HTTP服务二是如果坚持用云函数要配置预留并发实例并把模型加载放到初始化逻辑里避免每次请求都加载一次。识别加评分整条链路在常驻服务里应控制在800毫秒以内超过这个数字用户就明显感觉到卡。5.4 现象评分分数用户不认投诉“我写得比他好”用户拿两个人的同一字对比发现分数的高低和他们的直观感受不一致。原因是评分特征里缺少“笔画顺序”的信息。拍照是静态输入无从得知笔顺是否正确而笔顺恰恰是练字评分的重要一环。解决办法是不要试图从静态图里推笔顺而是增加一个“真迹模式”在小程序里让用户直接在屏幕上书写通过touch事件记录轨迹再把轨迹数据上传服务端用轨迹时序数据做笔顺分析和评分。这个功能同时还能支撑笔画书空练习扩展产品价值。如果只做拍照评分需要在UI上明说“本评分不含笔顺维度”避免用户拿它和人工点评对比。5.5 现象模型文件太大小程序包体超限把识别模型放到小程序端后主包超过2MB限制开发工具直接拒绝上传。原因很简单一个MobileNet的onnx或tflite文件通常在10MB以上主包根本塞不下。解决办法是把模型彻底放回服务端小程序端只保留上传和展示逻辑或者用小程序的“分包加载”机制把拍照评分模块放到独立分包里主包只留首页和导航。还有一个折中如果一定要端上识别用TensorFlow.js的WASM后端加量化模型把模型压到3MB以内但iOS上的WASM性能不稳定这个方案我劝你只做离线演示用别上生产环境。6. 上线前必做的三个验证从标注校准到灰度6.1 人工标注校准用少量样本测分差评分系统上线前我会找三到五位熟悉书法或语文教育的同事让他们对50份真实练字照片按自己的标准打分再拿系统评分和人工分做对比。重点关注两件事一是平均绝对偏差是否在10分以内二是偏差是否集中在某一类字上比如左中右结构的字系统普遍给低分、上下结构的字给高分。如果发现系统性偏差回到特征工程里检查对应结构的占格比例或重心特征是否计算有偏。这个校准过程是评分系统的“后悔药”上线后再想改评分逻辑就只能在灰度里小心验证了。6.2 端到端自动化巡检模拟真实拍照链路我会在服务端写一个巡检脚本输入是三组事先拍好的固定图片一组写得端正的、一组歪斜的、一组带阴影干扰的。脚本逐张调用识别和评分接口断言返回结果的word字段与预期一致评分total落在预设区间内。巡检脚本挂在定时任务里每晚跑一次一旦接口返回异常或分数偏离超过15分就触发告警。这比人工每天点开小程序快得多能及时发现模型或特征代码被意外改动。6.3 后端版本灰度与回滚大版本变更用开关切换识别或评分模型升级时不要直接全部切到新版本。我在接口层做一个version参数线上默认v1新版本以v2灰度给5%的用户对比v1和v2在相同输入下的评分分差如果某类字的分差超过12分就需要人工介入排查是bug还是有意调整。灰度逻辑可以用配置文件控制发布平台也支持按用户比例分流。回滚时只需要把version参数改回v1不需要重新发小程序版本。这套从拍照到评分的链路最后会收在我自己的一个习惯上每次改动评分特征我会先拿自己手写的同一个字跑一遍对比新旧分数然后问自己“这个分差能解释给我的用户听吗”。解释得通才能发版。希望这篇笔记能帮你在小程序拍照与汉字评分这条路上少走几步弯路把更多时间花到真正影响用户体验的评分解释和练习反馈上。本文还有配套的精品资源点击获取