做机械行业信息化的朋友,十有八九都被同一个问题磨过:车间提交过来的图纸、铭牌、合格证,要录入系统时全靠手工敲。老师傅看得懂图纸,但让他把那一长串型号编码一个个敲进文本框,效率低不说,还经常把O和0、I和1搞混。我最近在给单位的材料管理系统做升级,就接到这样的需求:在常用的umeditor编辑器里,做一个截图就能自动识别文字的插件,把图纸上的参数直接变成可编辑的文本,同时截图本身也留在文档里。这篇文章把整个开发过程复盘一遍,内容包括OCR引擎选型、umeditor插件机制、前后端交互链路,以及在机械行业实际部署中踩过的坑。想给后台编辑器加OCR能力的、或者正在做图纸信息采集系统的人,可以直接参考这里的代码和思路。
1. 机械行业的真实痛点:编辑器、图纸与OCR之间的“最后一公里”
1.1 一张截图里到底藏着什么信息
机械行业后台系统有个共通点:大量结构化信息并不在系统里,而在纸面和电子图纸上。以一张最普通的零件加工图纸为例,标题栏里有图号、材料牌号、设计者、审核者、日期;技术要求栏里有热处理要求、表面处理要求、公差等级;物料清单里则是密密麻麻的零件编码和数量。这些字段一旦要录入后台系统,传统做法就是对着图纸抄。抄的过程看起来只是打字,实际包含很重的注意力负担:要在图形、尺寸线、标注文字之间分辨哪些是需要录入的字段,还要抵抗疲劳带来的抄错风险。
更重要的是,这些信息往往是“混合密度”的。一个图号可能长成这样:DX-6001-E,材质写40Cr,热处理要求写调质HB220-250,表面处理写Cr12MoV,轴承规格写6205-2RS。数字、大写字母、小写字母、连字符、单位符号全部混在短短几个字符里。普通OCR工具拿去识别图书封面、车牌、身份证这种规范文本效果不错,但拿到机械图纸上,面对这种小字号、细笔画、密集排列的工程字体,识别率会明显掉一截。这也是为什么不能直接丢一个通用OCR应用给车间用,必须自己在业务系统里做定制插件的原因。
1.2 为什么是“截图+OCR”而不是“全图识别”
刚开始接到需求时,我也考虑过是不是做一个“上传图纸全图自动识别”的功能。真做起来才发现方向偏了:一张A3扫描图纸,8000x6000像素,里面图形线条、剖面线、虚线一大堆,真正的有效文字只集中在标题栏、技术要求和明细表几个区域。全图识别有几个问题,一是识别耗时太长,二是图形线条会和文字产生大量粘连干扰,三是识别出的坐标、尺寸标注未必是业务系统需要的字段。与其费劲做版面分析,不如让用户自己圈出目标区域,截个图再识别。这个交互模型非常接近机械工程师的日常工作习惯——他们本来就用CAD、看图软件里的框选功能,框哪块看哪块。
截图+OCR还有一个额外好处:识别之后,截图本身可以作为原始凭证跟着文档走。后面就算识别结果被修改了,审单的人也能看到原图长什么样,追溯起来非常方便。综合下来,“截图录入”比“全图识别”在准确率、响应速度、业务可溯源性上都要实用得多。
2. OCR引擎选型:本地部署与线上API的取舍
2.1 Tesseract、PaddleOCR与线上云OCR的对比
选型阶段我实际测过三类方案:Tesseract 5.x、PaddleOCR、百度智能云OCR。测试样本是我从车间要来的80张真实图纸截图,包含标题栏、铭牌、合格证三类图片。结果很有意思:Tesseract对中文长句的识别率还行,但一遇到“DX-6001-E”这种混合编码就频频出错,O识别成0、1识别成I的问题很突出,而且Tesseract对反向、倾斜的文字基本没有修正能力。云OCR的识别精度最高,尤其对印刷体效果很好,但机械行业的图纸有保密要求,不少图纸是供应商内部编号,往外传在合规上就有风险。PaddleOCR在这三者里属于中间最优解:识别精度接近云厂商,模型完全可以本地离线部署,支持方向分类,能自动把倒置或旋转90度的图片转正,而且开源免费。
这里有个容易忽略的成本项:云OCR是按调用次数计费的,一张图纸截图识别一次就是一次计费,如果一个材料管理系统有几十个用户每天都在录入,一年下来的费用不是小数目。本地部署虽然要一台机器,但都是一次性投入。考虑到机械行业后台系统通常跑在内网服务器上,本地部署更顺理成章。
2.2 PaddleOCR新版调用形态:从PaddleOCR到PaddleX Pipeline
PaddleOCR现在主推PaddleX Pipeline方式,底层还是OCR能力,但调用代码更简洁。老的写法是初始化PaddleOCR类,一遍遍传参数;新版直接用create_pipeline创建OCR管线,预测时一行搞定:
from paddlex import create_pipeline pipeline = create_pipeline(pipeline="OCR") result = pipeline.predict("snapshot.jpg") for res in result: # res里的字段在不同版本略有差异,一般包含识别文本和置信度 print(res)我在搭建OCR服务时,用FastAPI把这段能力包成一个HTTP接口。机械行业的业务后台一般用PHP或Java写,前端是HTML+JavaScript,OCR能力必须解耦成独立服务,这样后端语言完全不用绑定Python。FastAPI天然支持异步接收文件,配CORS中间件就能让前端跨域调用。接口的核心逻辑很简单:接收上传图片、存临时文件、调PaddleX识别、返回JSON。
from fastapi import FastAPI, UploadFile, File from fastapi.middleware.cors import CORSMiddleware from paddlex import create_pipeline import uuid, os app = FastAPI() app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_methods=["POST"], allow_headers=["*"], ) pipeline = create_pipeline(pipeline="OCR") @app.post("/api/ocr/recognize") async def recognize(file: UploadFile = File(...)): tmp_path = f"/tmp/{uuid.uuid4().hex}.jpg" with open(tmp_path, "wb") as f: f.write(await file.read()) try: result = pipeline.predict(tmp_path) rec_texts = [] rec_scores = [] for res in result: rec_texts = res.get("rec_texts", []) rec_scores = res.get("rec_scores", []) return {"code": 0, "texts": rec_texts, "scores": rec_scores} finally: os.remove(tmp_path)这里要多说一句:千万不要把临时文件堆在/tmp里不清理。我们第一版就吃过这个亏,识别服务跑了半个月,磁盘被临时图片塞满,整个系统卡死。记住无论识别成功还是失败,都要在finally里删掉临时文件。
3. umeditor插件机制的底层拆解:注册、命令与UI扩展
3.1 registerUI和toolbars配置的关系
umeditor已经很多年没有大版本更新了,但它的插件机制在今天看来依然够用。它把“功能扩展”拆成了两层:UI层和命令层。UI层负责在工具栏上放一个按钮,命令层负责按钮点击之后真正做什么。想要新增一个按钮,只需要做两件事:在配置文件里的toolbars数组中加上按钮名,然后用UE.registerUI注册这个按钮对应的UI对象。
以我这个截图OCR按钮为例:
UE.registerUI('mechOcr', function(editor, uiName) { var btn = new UE.ui.Button({ name: uiName, title: '截图OCR', onclick: function() { editor.execCommand('mechOcr'); } }); return btn; });这段代码的含义是:告诉umeditor,工具栏里如果出现mechOcr这个名字,就用这个工厂函数生成按钮。按钮点击后执行screencapture命令。这里有个很多人第一次接触umeditor会懵的点:registerUI注册的UI名必须和toolbars配置里的名字完全一致,差一个字符都加载不出来。我们曾经在配置里写了一下午都没出来按钮,最后检查发现工具栏名称写成了mechOCR,而UI注册名是mechOcr,大小写不匹配。
3.2 注册命令:execCommand之后发生了什么
按钮的onclick里执行了editor.execCommand('mechOcr'),这一句会把控制权交给命令注册表。命令注册用UE.commands对象:
UE.commands['mechOcr'] = { execCommand: function(cmdName) { openOcrDialog(this); } };this就是当前编辑器实例,在命令回调里可以通过this拿到选中内容、插入内容、设置内容。我实现的openOcrDialog没有直接用umeditor自带的Dialog控件——那套东西年代久远,样式和现在的后台UI搭不起来。我选用的是项目里已经有的Bootstrap模态框,这样点击按钮后弹窗风格和其他页面保持一致。这个取舍很关键:不是所有功能都必须嵌进umeditor的Dialog机制里,业务系统已有的弹窗组件完全可以直接复用。
命令注册完毕之后,还要在umeditor.config.js的toolbars数组里加按钮名。我放在“附件类”按钮后面,这样用户在录入材料时,视觉动线比较顺。
4. 前端插件实现:从截图/粘贴到按钮回填的完整流程
4.1 粘贴图片事件的完整实现
机械工程师用电脑的习惯是,手机拍图纸、微信传电脑、Snipaste或QQ截图复制下来,然后到系统里粘贴。所以插件最核心的入口不是点工具栏按钮,而是“粘贴”。umeditor默认在粘贴时会做格式清理,很多情况下会把图片粘贴事件过滤掉。为了让外部截图能进来,必须在ready事件里提前给编辑器的文档对象挂上粘贴监听:
editor.addListener('ready', function() { var doc = editor.document; if (!doc) return; $(doc).on('paste', function(e) { var clipboardData = e.originalEvent.clipboardData || window.clipboardData; if (!clipboardData) return; var items = clipboardData.items || []; var imageFile = null; for (var i = 0; i < items.length; i++) { if (items[i].type && items[i].type.indexOf('image') !== -1) { imageFile = items[i].getAsFile(); break; } } if (!imageFile) return; e.preventDefault(); processImageFile(imageFile, editor); }); });这里坑比较多,一个一个说。第一,umeditor内部自己也绑定了paste事件来做格式清理,如果你的监听写在它后面,可能还没轮到你的代码执行,编辑器已经把剪贴板内容处理掉了。实测下来我在ready事件里用jQuery绑定,能拿到图片粘贴,但特别老的umeditor版本需要在beforepaste事件里拦截,否则还是会被过滤。第二,e.preventDefault()必须在找到图片文件之后立即调用,否则图片会被浏览器当作默认行为粘贴进去。第三,剪贴板里可能同时有文本和图片,只取第一个图片即可。
4.2 图片压缩与上传:为什么不能盲目压小
拿到图片文件之后,不能直接丢给OCR接口。剪贴板里的截图,尤其是微信截取的高分屏图,往往是一张4000x2000px、体积6-8MB的PNG。这么大的图直接上传,一是请求耗时,二是OCR服务处理慢。但机械图纸不是普通照片,缩小过度会导致小字号字符模糊,OCR识别率断崖式下跌。我的做法是:长边超过2600px才等比缩放,否则保留原尺寸;PNG带透明通道时用白色画布垫底再转JPEG;质量参数固定在0.88,实测肉眼几乎看不出区别。
function processImageFile(file, editor) { var reader = new FileReader(); reader.onload = function(e) { var img = new Image(); img.onload = function() { var maxSide = 2600; var scale = Math.min(1, maxSide / Math.max(img.width, img.height)); var canvas = document.createElement('canvas'); canvas.width = Math.round(img.width * scale); canvas.height = Math.round(img.height * scale); var ctx = canvas.getContext('2d'); ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.drawImage(img, 0, 0, canvas.width, canvas.height); canvas.toBlob(function(blob) { var fd = new FormData(); fd.append('file', blob, 'snapshot.jpg'); // 直接调OCR接口 uploadAndRecognize(fd, editor); }, 'image/jpeg', 0.88); }; img.src = e.target.result; }; reader.readAsDataURL(file); }toBlob的回调是异步的,前端新手特别容易在这里写成同步逻辑导致拿不到blob。另外还要提一句,canvas.toBlob在部分老旧浏览器里不支持,机械行业后台用户用的浏览器版本可能很旧,保险起见补一个兼容降级:直接用canvas.toDataURL('image/jpeg', 0.88)转base64再上传。
4.3 OCR结果回填的策略:先确认再插入
OCR识别的结果直接插入编辑器是危险的。机械行业的字符识别有它固定的易错点:0和O、1和l、Z和2,在工程字体里长得非常接近,纯靠模型很难做到100%正确。如果直接插进去,后面审单的人很难发现这个隐蔽错误。所以我设计了一个“识别确认弹窗”:图片上传识别之后,弹窗里左侧显示原图预览,右侧是可编辑的文本框,里面是OCR识别出的文本。用户看着原图,把识别结果修改确认后,点“确认插入”,才会有图片和文本一起进编辑器。
插入图片用umeditor的原生命令:
editor.execCommand('insertimage', { src: data.url, _src: data.url, style: 'max-width:100%;' });插入文本用insertHtml,注意要对文本做HTML转义,否则识别结果里万一混入尖括号,直接插入会破坏页面结构:
function safeHtml(text) { return text.replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>'); } editor.execCommand('insertHtml', '<div><b>识别结果:</b></div>' + '<div style="margin:6px 0;padding:8px;border-left:3px solid #bbb;">' + safeHtml(recognizedText) + '</div>');图片我建议不要走base64内嵌,量大之后编辑器内容会非常臃肿,数据库存起来也痛苦。后台上传接口把图片保存到服务器,数据库里存URL,这才是常规做法。
5. 机械行业识别场景的特殊处理:图纸、铭牌与低质扫描件
5.1 图像预处理:先让机器“看”清楚再识别
机械图纸和普通照片的像素特征差别很大。图纸上的文字笔画细、字号小,背景是淡黄色或灰白色,旁边还有各种粗细线、剖面线。直接扔给OCR,模型会把很多线条当成文字框。我在这里加了一层OpenCV预处理,核心步骤是:转灰度、降噪、对比度增强、自适应二值化。但要特别提醒,二值化不能一刀切,机械图纸一旦阈值选得太大,细笔画字符会被直接抹掉,整张图变成一堆噪声。
我实际跑下来比较好用的预处理组合是:先做一次高斯模糊去扫描噪点,然后用cv2.createCLAHE做对比度受限自适应增强,最后用自适应阈值而不是全局阈值做二值化。这个组合对低亮度扫描图纸尤其有效,比直接全局阈值稳定得多。
import cv2 def preprocess(image_path): img = cv2.imread(image_path, cv2.IMREAD_GRAYSCALE) img = cv2.GaussianBlur(img, (3, 3), 0) clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)) img = clahe.apply(img) thresh = cv2.adaptiveThreshold( img, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 15, 10) return thresh还有一类特殊问题:图纸里有很多和文字粘在一起的线条边框,比如标题栏的表格线。文字压线之后,OCR识别会非常吃力。我用的方法是在识别前做一些形态学操作,检测并淡化长直线段。具体做法是用cv2.getStructuringElement(cv2.MORPH_RECT, (40,1))和(1,40)分别提取水平和垂直线,然后在原图上把检测到的线区域涂白。这个办法不能完全解决表格线干扰,但能把识别率从六成提升到八成以上。
5.2 用正则和词库把“识别结果”变成“业务数据”
模型输出的文本往往是碎片化的,“DX-6001-E”可能被识别成“DX-6001-E”带个多余的空格,也可能被识别成“DX-6001E”少了横杠。机械行业的数据,最终是要落到物料编码、规格型号这些数据库字段里的。所以在交给编辑器之前,我加了一层规则后处理。
后处理的思路不是去“猜”文字,而是基于已知的业务格式做约束。比如我们系统的物料编码规则是“2位字母-4位数字-可选的1位字母后缀”,那我就可以这样写:
function normalizeCode(text) { return text .replace(/\s+/g, '') .replace(/([A-Z]{1,4})-?([0-9O]{3,5})-?([A-Z0-9]{0,2})/g, function(all, p1, p2, p3) { var digits = p2.replace(/O/g, '0'); return p1 + '-' + digits + (p3 ? '-' + p3 : ''); }) .replace(/[::]\s*/g, ':'); }这里有个重要原则:只对符合已知模式的片段做纠正,不要全局把字母O替换成数字0。因为图纸上可能出现“LOCATION”这样的完整英文单词,全局替换O会闹出“L0CAT10N”的笑话。很多OCR后处理工具就死在这上面,规则写得太激进,反而把原本正确的文本改坏了。
5.3 方向分类与多区域识别的取舍
机械行业的图片来源很杂,有的是手机拍的,有的是扫描仪扫的,甚至有的是传真过来的。图片方向不统一是常态。PaddleOCR内置方向分类器,开启后能把倒置或旋转90度的图片自动转正。我实测下来,对手机拍摄的图纸效果很显著,但对本来就是正放的扫描件影响不大。开启方向分类会增加一定的推理耗时,如果你的系统里图片基本是正放的,可以选择关闭来换速度。
多个图纸区域识别的问题上,我的建议是克制。一开始我做过“自动检测图框内所有文字块然后按位置排序”的功能,貌似很智能,实际用起来反而添乱。真实车间场景里,用户往往只需要某一栏的信息,比方说现在就要录“材质”这一个字段,你把标题栏、明细表、技术要求全部识别出来,用户还得在一堆结果里翻找目标。截图识别最大的价值就是“用户主动框选目标区域”,这个主动性不要用自动化去破坏。
6. 部署上线中的实战坑位与性能优化
6.1 跨域接口与服务架构
前面说过,OCR服务是独立部署的,跟业务后台分离。这时候第一个要处理的就是跨域。我们的FastAPI服务跑在8888端口,业务后台跑在8080端口,前端ajax直接调OCR接口必然触发CORS限制。解决方法是给FastAPI加CORSMiddleware,这在上面的服务端代码里已经有了。但生产环境建议不要把allow_origins设置成["*"],至少限定为后台系统的域名,否则任何网页都能往你的OCR接口上传图片,等于把内网计算资源暴露了出去。
6.2 并发与CPU瓶颈:一张图识别要多久
PaddleOCR识别一张压缩后的截图,在普通4核CPU服务器上大概是0.5到2秒,具体取决于图片文字密度。这个响应速度对单个用户来说能接受,但如果系统里有几十个人同时上传截图,就会挤爆CPU。我第一版部署时直接用uvicorn main:app --host 0.0.0.0 --port 8888,单进程单worker,并发一上来请求队列直接堆到十几秒。
改为gunicorn多worker后问题就缓解多了,但要注意OCR初始化会加载模型到内存,每个worker都要加载一份,内存占用大约每份1GB左右。启动命令长这样:
gunicorn -w 4 -k uvicorn.workers.UvicornWorker main:app -b 0.0.0.0:8888用多少个worker,参考CPU核心数,一个核心开1到2个worker就差不多了。开多了不仅不会变快,反而会因为频繁切换CPU上下文把性能拖垮。如果预算允许,OCR服务加点显存跑GPU,识别时间能从1秒压到300毫秒以内,体验完全是两个级别。
6.3 低置信度结果的人工复核与日志留痕
无论预处理做得再精细,机械图纸里总有一些识别结果是不该被信任的。我的做法是:OCR接口返回每个文本块的同时返回置信度rec_scores,前端拿到分数后做颜色标识。置信度低于0.85的文本块,在确认弹窗里用红色下划线标出来,提醒用户重点核对。这个机制看起来不起眼,实际使用价值很大,比单纯把识别结果插进去让用户事后发现错误要高效得多。
另外,我还给识别请求加了一份结构化日志:记录原图文件名、图片MD5、识别出的完整文本、用户确认后最终插入的文本。日志存在数据库独立表里。之所以做这一步,是因为机械行业对数据溯源要求很严,万一之后发现某个物料编码录错了,能翻出当初的识别日志,判断到底是OCR错了还是用户改错了,这个责任界定在实际工作中太重要了。
6.4 关于浏览器兼容的一个提醒
umeditor本身面向的是老系统环境,很多机械企业的办公电脑还是Windows 7配老浏览器。canvas.toBlob、FormData这些新API在老浏览器里不一定能用。我们部门用的前台管理机有相当一部分还是老版本360安全浏览器,兼容模式下FileReader和canvas都能跑,但toBlob表现不稳定。最后我干脆做了特性检测:if (canvas.toBlob) { ... } else { canvas.toDataURL(...) },两种路径都验证过才能上线。这类“小问题”在技术社区里没人会专门写,但真实项目里它是最大的阻力来源之一。
按我个人的使用体会,这个插件的开发并没有用到什么高深算法,真正的难点全在贴合场景的细节上:图片怎么压缩才不影响识别、识别文本怎么让用户高效复核、机械编码的易混字符怎么约束、老浏览器怎么兼容。把这几点都抠完,插件才算真正能用。后续如果还想扩展,可以在OCR服务端加一个“模板保存”功能,让用户把常见的标题栏截图命名保存,下次识别时直接按模板提取对应字段,方向上值得试试,但前提仍然是先把基础的截图识别链路跑稳。