多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

微信小程序手写汉字识别评分:拍照、OCR与TFLite端上推理实战

微信小程序手写汉字识别评分:拍照、OCR与TFLite端上推理实战 简介这份微信小程序项目源码将拍照功能与手写汉字识别评分系统整合在同一工程内适用于小程序开发者、教育类产品设计者以及书法学习应用研究者。工程包含指定区域拍照、OCR文字识别、基于深度学习模型的手写汉字书写评分并涉及图像预处理、去噪、二值化与笔画追踪等关键算法链路可支撑汉字书写练习反馈与教学辅导场景。压缩包内共38个文件以js逻辑脚本、wxss样式、json配置和wxml页面结构为主附README、说明文件和docx附赠资料整体约73KB目录结构清晰便于按pages模块查阅。当前已有55人进行学习下载适合需要参考拍照交互、识别评分前端实现或着手搭建同类教育辅助工具的小程序开发者。1. 手写汉字识别评分小程序拍照、识别、打分一个闭环在给教育类客户做书法练习工具时我发现最卡进度的不是 UI 也不是后端而是“学生拿手机拍一张字给系统系统怎么知道这字写得好不好”。这个资源包拆出来看正好覆盖完整链路微信小程序拍照功能负责把字拍规整手写汉字识别把练的字认出来汉字书写评分再结合图像处理算法把“好/差”变成一个可解释的分数。它能解决的核心问题是你不用自己从零搭相机页、不用纠结深度学习模型怎么塞进小程序、不用拍脑袋定评分规则。适合正在做书法学习应用、教育辅助工具或 OCR 相关小程序的人以及想搞清楚端上推理和图像特征到底怎么落地的算法工程师。2. 指定区域拍照camera 组件的裁切坐标与归一化2.1 为什么练字场景必须用 camera 组件而不是 chooseImage很多开发者一上来就选相册上传省事。但书法练习场景有一个特殊约束用户拍出来的字必须正、必须大、背景必须干净。相册里挑一张家长随手拍的照片大概率是斜的桌面上还有杂乱的笔袋和橡皮后续识别模型看到这种图直接报废。微信小程序拍照功能里用 camera 组件配合屏幕遮罩相当于把“拍一张能直接进模型的图”这件事前置到拍照环节这是先把产品体验想清楚了再动手。另外还有一层隐私因素。手写作业往往带着孩子真实的笔迹样本家长对上传相册原图很敏感。camera 组件拍完直接进内存经过裁切和灰度化之后再用于识别不保留原始大图到用户相册这个设计在真实使用里明显更容易被接受。资源包里拍照页默认也是 camera 方案建议你不要轻易换成 chooseImage省的那点开发时间会在识别准确率上全部赔回去。2.2 取景遮罩定位与坐标系换算页面结构很直接camera 铺满全屏上面叠一个田字格遮罩。但坑在换算。camera 输出的图片分辨率按相机传感器比例来和屏幕 CSS 像素完全不是一回事。我见过很多半途放弃的人都是卡在这行换算上。遮罩定位一般取屏幕宽高计算格子中心点在 onReady 里算好 gridSize 和 gridTop。const sysInfo wx.getWindowInfo(); const gridSize Math.floor(sysInfo.windowWidth * 0.82); const gridLeft Math.floor((sysInfo.windowWidth - gridSize) / 2); const gridTop Math.floor((sysInfo.windowHeight - gridSize) / 2 - 60);提示格子稍微偏上一点因为手指按快门时下方要留出按钮位置也符合人眼习惯。拍照后拿到的是全屏原图必须按比例裁到遮罩区域。下面是完整裁切代码资源包里对应utils/crop.js。const ctx wx.createCameraContext(); ctx.takePhoto({ quality: high, success(res) { const tempPath res.tempImagePath; wx.getImageInfo({ src: tempPath, success(info) { // 屏幕坐标 - 图片坐标核心就这一步 const scale info.width / sysInfo.windowWidth; const srcX Math.round(gridLeft * scale); const srcY Math.round(gridTop * scale); const srcW Math.round(gridSize * scale); const srcH Math.round(gridSize * scale); const query wx.createSelectorQuery(); query.select(#cropCanvas).fields({ node: true, size: true }).exec((r) { const canvas r[0].node; const ctx2 canvas.getContext(2d); const img canvas.createImage(); img.onload () { canvas.width 224; canvas.height 224; ctx2.drawImage(img, srcX, srcY, srcW, srcH, 0, 0, 224, 224); wx.canvasToTempFilePath({ canvas, success(out) { // out.tempFilePath 就是干净的 224x224 标准图 that.setData({ cropedPath: out.tempFilePath }); } }); }; img.src tempPath; }); } }); } });逻辑说明takePhoto 返回的 tempImagePath 指向的是设备相关原图不同机型分辨率可能差一倍以上所以不能把遮罩坐标直接往图片上套。这里用info.width / windowWidth算出缩放系数把 CSS 坐标换算到图片像素坐标再从原图里抠出正方形区域同时缩放到 224x224。另外注意224 是后续深度学习模型的固定输入尺寸这一步把归一化提前做掉识别模块拿到手就是规整输入。参数说明quality 用 high 是为了保证缩小后笔画边缘不糊如果内存紧张也可以不做 canvasToTempFilePath直接把 canvas 节点传给识别模块读像素数组。2.3 DPR 与输出比例真机模糊和错位的隐藏变量还有一个很多人忽略的变量是 DPR也就是设备像素比。iPhone 的 windowWidth 是 CSS 像素比如 375但 canvas 的物理像素可能是 750 甚至更多。如果直接用 windowWidth 去初始化 canvas 尺寸在 3x DPR 的机型上画出来的图是模糊的。canvas 2d 节点本身有 size 字段需要和 style 宽高分开理解。资源包里的写法是先按 dpr 设置物理宽高再在 drawImage 时做缩放避免输出图发虚。const dpr wx.getWindowInfo().pixelRatio; canvas.width 224 * dpr; canvas.height 224 * dpr; ctx2.scale(dpr, dpr); ctx2.drawImage(img, srcX, srcY, srcW, srcH, 0, 0, 224, 224);逻辑说明canvas.width 设置的是物理像素style 宽高控制的是 CSS 尺寸。先 scale(dpr, dpr) 再画图等于把坐标系放大到物理像素画出来的内容在真机上不会糊。参数说明这里目标逻辑尺寸仍是 224物理尺寸是 224 * dpr后续给模型时注意只取前 224x224 的像素块否则维度对不上。真机测试时优先看 iPhone 和安卓中端机这两类在 DPR 上的表现差异最明显。2.4 camera 参数在练字场景下的取舍参数选项推荐值理由device-positionfront / backback后置拍纸张前置镜像且画质差resolutionlow / medium / highmedium高分辨率对识别没帮助反而放大噪点flashon / off / autooffauto 在低光下会打闪白纸造成过曝frame-sizelarge / medium / smallmedium配合 224 裁切不需要大帧这里有一个容易被忽略的点resolution 设 high 时很多安卓机输出的实际像素比例是 4:3 或 16:9而遮罩是正方形的 1:1scale 换算在横竖比例不一致时会微偏。严谨做法是先用wx.createCameraContext().onCameraFrame拿到相机实际输出尺寸再做换算如果只是快速落地把遮罩也设成 4:3 的局部框就能绕开。资源包里默认给的是 1:1 田字格实际部署建议优先走 scale 换算并且真机多跑几个机型别只在开发工具里看效果。3. 手写汉字识别OCR 选型与 TFLite 端上推理3.1 为什么印刷体 OCR 引擎在手写汉字上直接翻车手写汉字识别和印刷体 OCR 是两个物种。印刷体 OCR 靠“字符切分 模板匹配”的祖传思路就能跑但手写字的笔画变形是连续的“横”可能写成弧线“捺”的收笔带笔锋字典里根本没有精确对应。很多 OCR 文字识别服务在印刷体上准确率 99%换成手写作业本直接掉到 70% 不到。深度学习模型在这里的优势是学笔画局部特征而不是死记模板所以本项目把识别主干放在一个轻量 CNN 小模型上只在低置信度时才走备用的云端文字识别接口。这个资源包也沿用了资源标题里“OCR文字识别 深度学习模型”双路线端上模型负责主路径识别云端接口做兜底。做教育辅助工具时这一条尤其重要因为用户多半在教室或家里网络不稳定而且家长对孩子笔迹上传是敏感的能在本地算完就不要把原图发出去。3.2 端上 TFLite 模型与 2MB 包体控制微信小程序主包限制 2MB模型文件是最大开销。资源包里已经转好了 TFLite 8bit 量化的模型体积大约在 1.6MB 左右配合分包加载能过审。如果以后你自己训练模型导出时要走一遍 int8 量化量化后的精度损失在这种识字任务里几乎看不出来但体积能缩到原来的四分之一。选型上端上模型的优先级高过云端 API原因有三点离线可用、没有上传延迟、不把用户笔迹传出设备。对比项端上 TFLite云端 OCR API首帧延迟50ms 左右加上传和网络通常 800ms离线支持不支持隐私本地推理原图上传包体约 1.6MB 分包无影响识别上限按训练类别通常几千字3.3 预处理灰度、二值化与归一化识别之前先灰度化再二值化目的是把光照和纸张颜色的干扰降到最低。资源包里的utils/preprocess.js就是这一段。// imgData 为 canvas 2d 的 ImageData宽高 224 function toBinary(imgData) { const data imgData.data; for (let i 0; i data.length; i 4) { const gray 0.299 * data[i] 0.587 * data[i 1] 0.114 * data[i 2]; const val gray 128 ? 255 : 0; data[i] data[i 1] data[i 2] val; } return imgData; }逻辑说明灰度系数 0.299/0.587/0.114 是标准亮度加权直接对 RGB 三通道平均会忽略人眼对绿色的敏感度导致笔画识别不稳定。二值化阈值 128 只适合白纸黑字跟偏黄纸张兼容差后面避坑章会讲自适应阈值替换方案。参数说明这里的阈值是全局固定值光照稳定时表现不错一旦换场景就要换算法别在固定阈值上恋战。3.4 TFLite 推理与 Top-K 候选输出推理代码通常长这样资源包里的utils/recognize.js是封装好的版本。const tflite require(tensorflow/tfjs-tflite); const output await model.predict(tensor); const logits Array.from(output.dataSync()); // 取 Top-5 候选按概率降序 const candidates logits .map((score, index) ({ score, index })) .sort((a, b) b.score - a.score) .slice(0, 5) .map(item ({ char: labelList[item.index], score: item.score })); this.setData({ candidates });逻辑说明predict 返回的是概率分布字典长度取决于模型训练时收了多少个字。这个练字小程序不用覆盖全部常用字而是按教材年级生字表收敛到几百个字准确率能高一个档次。Top-5 而不是只取 Top-1是因为后面评分阶段要用候选字对应模板向量做匹配如果第一候选错了但第二候选对了评分还有挽回余地。参数说明labelList 的长度必须和模型输出维度一致不一致时大概率是标签表在导出时按拼音排序而模型输出按训练集顺序排序这个坑在第 5 章展开。3.5 低置信度降级与标签对齐当 Top-1 的概率低于 0.5 时我的习惯是不要硬认直接弹一个“没看清再写一次”或者降级到云端 OCR。降级逻辑在资源包utils/recognize.js里是一个if (maxScore threshold)的判断threshold 默认 0.55。云端接口返回的候选也是按置信度排序接入时注意把云端结果和本地 labelList 对齐两边如果对“字”的编码方式不一致会出现本地候选能看、云端候选乱码的情况。统一的做法是都用 UTF-8 的字符本身做 key而不是用自增序号。4. 汉字书写评分图像处理算法把主观标准变成数字4.1 评分维度拆解识别完之后就是评分。评分的核心不是玄学而是把书法老师审视一张字时的第一反应拆成可计算特征。资源包里实现的四个维度是重心位置、九宫格密度分布、笔画粗细均匀度、田字格填充率。每个维度对应一个直观的书写问题——字写歪了、结构松散、用力忽大忽小、字太大或太小。这四个特征不直接等权相加而是用“模板比对 惩罚项”的组合。结构相似度给基础分重心偏移和填充率超标直接扣分这样单张字的分数才有区分度。资源包score/目录下的 Python 脚本就是完整评分参考实现。维度计算方式权重说明重心偏移质心相对模板质心距离每 0.1 偏移扣 10 分九宫格密度分布81 维向量余弦相似度权重最大占 40%笔画粗细均匀度笔画宽度标准差低于阈值扣 5 分田字格填充率前景像素数 / 总体像素数偏离基准 0.55 按差值扣分4.2 九宫格密度向量与模板比对资源包里的score/features.py是核心特征提取先把缩放到 224x224 的二值图切成 9x9 格子统计每个格子里笔画像素占比得到 81 维向量再与模板向量做余弦相似度。import numpy as np def zone_density(img, grid9): h, w img.shape[:2] cell_h, cell_w h // grid, w // grid feats [] for i in range(grid): for j in range(grid): block img[i*cell_h:(i1)*cell_h, j*cell_w:(j1)*cell_w] 0 feats.append(block.mean()) return np.array(feats, dtypenp.float32) def cosine(a, b): denom np.linalg.norm(a) * np.linalg.norm(b) if denom 1e-8: return 0.0 return float(np.dot(a, b) / denom)逻辑说明为什么是 9 格而不是 3x3 或整图统计因为汉字的间架结构在九宫格尺度上最有区分度左右结构的字左半部分密度高上下结构的字上半部分密度高3x3 太粗有时“林”和“朋”分不开16x16 又太细手写变形很容易错位。参数说明这里依赖模板和待评字已经居中对齐所以在特征提取前要先做二值图像质心对齐把字的中心挪到图片正中心否则任何偏移都会直接变成密度向量的巨大误差。4.3 重心偏移与填充率惩罚项重心偏移的计算很直接统计所有前景像素的均值坐标和标准模板的均值坐标做距离归一化。填充率是前景像素占比偏离基准值就扣分。两个惩罚项合并起来能让“字写歪了”和“字写太大”这类问题直接掉到及格线以下。资源包里score/score.py的评分函数如下。def score(density_stu, density_std, center_offset, fill_rate): sim cosine(density_stu, density_std) center_pen 10 * abs(center_offset) fill_pen 5 * abs(fill_rate - 0.55) base 60 40 * sim return max(0.0, min(100.0, round(base - center_pen - fill_pen, 1)))逻辑说明base 从 60 起跳cosine 接近 1 时逼近满分 100center_pen 的权重 10 意味着重心偏移 0.25 就要扣 2.5 分偏移 1.0 基本不及格。fill_pen 的基准 0.55 是从标准楷书字帖统计来的换成行楷模板时需要重新统计不要沿用。参数说明这个评分输出范围是 0~100 的浮点数前端展示时建议取整并且低于 60 给出评语而不是只给分数用户心理体验差别很大。4.4 骨架细化与评语生成骨架细化在资源包里对应score/skeleton.py它把二值图的笔画压成单像素宽再统计端点和交叉点数量。这一步不参与总分计算而是生成评语比如“横画收笔不稳”“左下笔顺疑似有误”。之前踩过的坑直接用 OpenCV 的 thinning 在小程序端跑不起来所以资源包里放的是纯 Python 服务端版本移动端只需要把特征向量随请求带上去。5. 避坑与常见问题排查5.1 拍照裁切位置偏移现象用户明明把字写进了田字格识别结果却只有半个字裁切出来的图要么偏左要么偏上。原因takePhoto 返回的是相机传感器全尺寸图和屏幕上的遮罩 CSS 坐标比例不一致直接用 windowWidth 做 scale 是近似算法不同机型误差不同。如果相机输出是 4:3而遮罩是 1:1 正方形横向和纵向的缩放系数根本不一样。解决计算 scale 时先用wx.getWindowInfo()拿到 windowWidth 和 windowHeight再根据相机实际输出比例修正。最稳的办法是把遮罩按相机输出比例做成 4:3 的局部框让 srcX/srcY 只做平移缩放不做跨比例换算。开发时多用两台安卓机测试iPhone 的比例通常比较标准安卓千元机才是问题高发区。5.2 主包体积超限上传失败现象开发工具里真机预览正常但点“上传”时提示包体超过 2MB代码被拒收。原因模型 .tflite 放在主包 pages 同级目录下被算进主包体积或者模型文件用 base64 写进了 js包体直接被撑爆。解决把模型放进分包加载时用分包路径模型导出时用 int8 量化1.6MB 模型量化后能压到 1MB 以下。微信小程序规则是主包 2MB、总包 20MB分包这招才是正解。排查时先看详情面板里的“代码包分析”确认到底是模型占了大头还是图片资源占了大头。5.3 黄纸暗光下识别准确率断崖现象白纸黑字识别 95% 的模型换到米黄色书法纸或晚上暖光下准确率掉到 70%笔画全断。原因预处理里固定阈值 128白纸背景灰度约 240黄纸背景灰度只有 170 左右前景笔画和背景对比度整体下移阈值硬切就把浅笔画切断了。解决把固定阈值改成 Otsu 全局阈值或者在线性光照下用“背景灰度估计 - 偏移量”代替固定阈值。不想引入太复杂算法就在拍照页加一条提示请使用白纸、避免暖光直射。产品上克制预期比算法上折腾一整晚更有效这句话是我做识别项目用血泪换来的。5.4 真机上 labelList 乱码现象开发者工具里候选字显示正常真机上全是“”或问号。原因标签表 txt 用 UTF-8 保存Android 上部分小程序环境读取外部 txt 时默认按系统编码解析中文 label 变成乱码。模型 index 没错但映射表读坏了。解决不再读 txt改用 js 数组保存在代码里或者从接口拉 JSON。真机上加一段labelList[index] undefined的兜底判断至少保证不崩溃。资源包里的model/label.js就是改好的版本新增字库时直接维护这个数组。5.5 训练样本不均衡导致偏爱简单字现象整体识别准确率 92%单独看“人”“大”“天”接近 99%“女”“飞”“我”只有 75%。原因训练数据里简单字样本多模型对复杂结构的判别特征没学够。这是深度学习模型训练里最常见的数据偏置。解决每类字至少收 200 份不同书写者样本难字过采样或复制加噪声。验证时按字种分组统计召回率看混淆矩阵而不是只看平均分。平均分是会骗人的它会被大量简单字样本拉高。6. 进阶给评分系统做阈值与模板回归校验评分系统最容易翻车的点不是特征公式是模板和阈值。资源包里的模板向量是从标准打印体生成的但手写识别出来的是人的笔迹两者天然存在差异。如果直接拿打印体模板去比对手写所有认真写字的孩子都会被扣“结构不标准”分因为他们的字再端正也达不到印刷体的几何规整度。我的做法是收 30 张不同孩子的样张按水平分成三档明显歪斜、基本端正、接近字帖。用资源包里的score/calibrate.py跑一遍观察三档分数分布是否明显分开。如果“基本端正”和“接近字帖”混在 85 分附近分不开调的是 fill_penalty 的基准值把 0.55 降到 0.45如果“明显歪斜”也能拿到 80 分说明 center_penalty 权重太小上调到 15。这个调参过程本质是在做阈值校准没有标准答案只能拿真实样张对比着调。校准完还要做一次全流程回放从拍照裁切、识别到评分用同一组 30 张原图重跑。重点看识别 Top-K 里正确字是否进入前 2如果第 3 名才对评分模块按置信度加权时会惩罚过度此时要回头调二值化阈值而不是调评分公式。排名相关性用 Spearman 系数来衡量低于 0.8 就继续调整直到人工排序和系统分排序基本一致。那次调完我才意识到底层阈值对评分的影响比评分公式本身还大。从那以后我每次拿到新字帖模板都会先用 30 张熟手样张做一遍“认真样本平均分不低于 85”的回归测试再动任何评分参数。希望这个习惯对你也有用至少能让你的评分系统在真实用户手里少翻车。本文还有配套的精品资源点击获取
返回列表