基于umeditor的机械行业截图OCR识别插件开发实战
搞机械行业信息化的朋友应该都经历过这种场景工艺员拿到一张零件图要把标题栏里的图号、材料、表面处理逐行敲进ERP技术员翻着几百页的《机械设计手册》要把里面的参数、公式抄到技术文档里。字一多、符号一杂光输入就占了大半天还免不了抄错。我前几年帮一家零部件厂做内部工艺管理系统时就被提了一个需求能不能在编辑文档的富文本框里随手截个图就把里面的文字识别出来直接填进去于是就有了这个给umeditor做的截图OCR识别插件。先说明一下这个插件到底是干什么的它不是一个通用的截图工具而是嵌入在umeditor富文本编辑器里的一个功能按钮用户点击按钮后框选屏幕上的任意文字区域图纸标题栏、零件标签、技术手册页面系统自动调用OCR引擎识别文字并把结果回填到编辑器的光标位置。对机械行业来说这句话翻译过来就是不用再手敲“GB/T 1804—m级”这种又长又容易抄错的参数了。这篇文章写给谁如果你是机械企业里负责信息化、工艺系统开发的人或者你正要把富文本编辑器和OCR能力打通那这份从需求拆解到代码落地的完整记录可以帮你少走不少弯路。我会把当时的方案选型、核心代码、踩过的坑都摊开来讲尤其是机械场景下识别特殊符号和表格顺序这些非通用需求会很详细。1. 先把需求说清楚机械行业的OCR和普通OCR有什么不一样1.1 真正的使用场景对接需求之前我蹲在工艺室看了半天他们怎么干活。工程师最频繁的录入场景有这么几种第一图纸标题栏录入。每张零件图右下角都有标题栏图号、零件名称、材料、表面处理、比例、重量这些字段格式固定但各不相同。ERP系统里建物料档案时这些信息全要手敲。第二外购件铭牌参数。电机、减速机、气动元件铭牌上往往是一堆型号编码加电气参数比如“YE3-112M-4 4kW 380V 50Hz”。采购员要在系统里建档最痛苦的就是这一串串字母数字组合。第三技术手册/样本的摘录。设计选型时翻样本手册要把某页的尺寸参数表抄到设计说明里过去都是对着PDF手动誊写数字多的时候一份表格能抄一下午。第四工艺卡片整理。老师傅手写的工艺卡、外协厂的纸质工序卡要电子化存档。这些场景有一个共同点文字密集、含特殊符号Φ、±、°、×、以数字字母混排为主还需要保证录入后能被系统识别和检索。这跟识别一段普通文章完全是两码事。1.2 机械领域OCR的识别难点与应对思路通用OCR引擎对街景招牌、身份证、发票的识别做得很好但碰到机械行业的内容就露怯了原因很具体符号问题。直径符号Φ经常被识别成“中”、“φ”或者“O”公差里的±容易被吞掉乘号×经常变成英文字母“x”。一个“Φ10±0.01”识别成“中10士0.01”录入系统就是废数据。中英混排。型号里“GB/T 1096-2003”、材料牌号“45钢”、表面处理“Ra 3.2”这种中英混排通用引擎容易给整段识别成中文或英文边界特别容易丢。多列顺序错乱。参数表、标题栏都是多列结构OCR引擎默认按从上到下、从左到右输出两列参数会交叉串行。识别出一堆文字顺序却对不上比手抄还难整理。低质量图像。图纸是折叠扫描件、铭牌是斜着拍的、样本是复印机二次复印的模糊和倾斜是常态。应对思路也清晰选一个支持中英文混排、字典可定制的OCR引擎识别后做专门的符号后处理映射按坐标信息做结构化排序必要的时候做方向矫正。这些后面都会展开讲。2. 方案选型umeditor插件机制、截图方式和OCR引擎怎么选2.1 umeditor插件机制要点umeditor是百度UEditor的轻量版本内部保留了完整的命令和UI注册机制。开发自定义功能最稳妥的方式是两条路配合用UM.registerUI注册工具栏按钮用UM.registerCommand注册具体命令逻辑。先看工具栏注册。在umeditor.config.js的toolbars数组里加一个自定义按钮名// umeditor.config.js UMEDITOR_CONFIG.toolbars [ [source, undo, redo, bold, screenshotOcr, insertimage] ];然后单独建一个JS文件比如screenshotOcr.js在页面引入umeditor之后再引入这个文件(function () { UM.registerUI(screenshotOcr, function (name) { var me this; var $btn $.eduibutton({ icon: screenshot-ocr, title: 截图OCR识别, click: function () { me.execCommand(screenshotOcr); } }); return $btn; }); UM.registerCommand(screenshotOcr, { execCommand: function () { startCapture(); } }); })();这里有个关键点UM.registerUI回调里的this就是编辑器实例按钮点击后通过execCommand触发命令。umeditor所有内置功能都走这套命令机制所以这是最标准的扩展方式不用改任何源代码。按钮图标需要额外加CSS。umeditor按钮的class命名规则是edui-for-按钮名给图标指定背景图就行.edui-button.edui-for-screenshotOcr .edui-icon { background: url(../images/ocr-icon.png) no-repeat center center; background-size: 16px 16px; }如果工具栏里不显示按钮检查两点一是toolbars里的字符串必须和registerUI注册的名字完全一致二是自定义JS文件必须在umeditor.js之后加载。2.2 截图交互方案的取舍截图是插件里最矛盾的模块。浏览器网页天然限制了你“截整个屏幕”的能力——纯网页JS只能截到页面内容截不到浏览器之外的软件窗口。机械行业实际用下来有三种可行的落地方式方式一页面内遮罩框选。点击按钮后弹出全屏半透明遮罩用户在遮罩上拖拽框选区域然后用html2canvas把网页可视区域绘制成canvas再裁剪出框选区。优点是纯前端搞定、部署简单缺点是只能截页面内容截不了PDF阅读器、CAD软件等其他窗口。方式二外部截图工具粘贴识别。让用户用系统截图工具WinShiftS、Snipaste截图图片自动进剪贴板在编辑器里CtrlV粘贴时拦截剪贴板图像送去识别。这是最贴合工程师习惯的路线因为大家早就用Snipaste截图画图了。方式三Electron或浏览器扩展。用Chrome扩展的captureVisibleTab或Electron的desktopCapturer可以截全屏。但机械行业很多是内网浏览器环境推广一个浏览器扩展或桌面壳子运维成本高大部分项目直接放弃。我实际落地用的是方式一加方式二组合插件内提供页面内框选截图作为兜底主力路径则是引导用户用Snipaste截图后直接在编辑器里粘贴。后面会分别给出两种方案的代码。2.3 OCR引擎选型对比OCR引擎是插件的核心当时我对比了几个主流方案重点考察内网部署能力、机械符号识别效果和运维成本引擎部署方式机械符号支持内网可用备注PaddleOCR 2.xPython本地服务中等需定制字典是识别精度高中文效果好RapidOCR (ONNX)Python本地服务中等需后处理是模型体积小离线推理快Tesseract.js纯前端较弱是部署最简单但机械场景准确率不足百度/腾讯云OCRAPI调用较好否需要联网机械行业内网环境基本排除机械行业特殊性决定了绝大多数场景要求内网部署所以云端API基本出局。剩下PaddleOCR和RapidOCR里我最终选了RapidOCR做主力。原因有三模型是PP-OCR系列转换的ONNX格式识别效果和PaddleOCR接近部署只需要装一个rapidocr_onnxruntime的pip包没有Paddle那种推理框架依赖在老旧服务器上安装省心推理速度快普通CPU单张图几百毫秒批量录入时体感好。PaddleOCR也不是不能用如果团队已经跑着Paddle的服务直接用也行后面代码我会两个版本都给。3. 插件从0到1完整的实现流程和代码3.1 注册工具栏按钮上面2.1已经把注册代码贴了这里补充一个细节如果希望按钮不动配置文件只用UM.registerUI的第三个参数直接插入到某个已有按钮旁边也是可以的。但强烈建议用配置文件方式团队其他人接手时能一眼看到工具栏里有这个功能。编辑器初始化时还要在回调里绑定粘贴拦截事件这个放到3.5节细说。3.2 截图交互与图片生成页面内框选截图的完整实现如下。遮罩层监听鼠标拖拽框选结束后获取区域坐标再调用html2canvas绘制整个可视区域并裁剪function startCapture() { var mask document.createElement(div); mask.id ocr-capture-mask; mask.style.cssText position:fixed;top:0;left:0;width:100%;height:100%; background:rgba(0,0,0,0.45);z-index:99999;cursor:crosshair;; document.body.appendChild(mask); var startX 0, startY 0; var selBox document.createElement(div); selBox.style.cssText position:fixed;border:2px solid #00b3ff; background:rgba(0,179,255,0.2);display:none;z-index:100000;; document.body.appendChild(selBox); mask.addEventListener(mousedown, function (e) { startX e.clientX; startY e.clientY; selBox.style.display block; selBox.style.left startX px; selBox.style.top startY px; selBox.style.width 0px; selBox.style.height 0px; }); mask.addEventListener(mousemove, function (e) { if (selBox.style.display none) return; selBox.style.left Math.min(startX, e.clientX) px; selBox.style.top Math.min(startY, e.clientY) px; selBox.style.width Math.abs(e.clientX - startX) px; selBox.style.height Math.abs(e.clientY - startY) px; }); mask.addEventListener(mouseup, function (e) { var x Math.min(startX, e.clientX); var y Math.min(startY, e.clientY); var w Math.abs(e.clientX - startX); var h Math.abs(e.clientY - startY); document.body.removeChild(mask); document.body.removeChild(selBox); if (w 8 || h 8) return; // 过滤误点击 captureRegion(x, y, w, h); }); } function captureRegion(x, y, w, h) { html2canvas(document.body, { useCORS: true, scale: 2 }).then(function (canvas) { var regionCanvas document.createElement(canvas); var scale 2; regionCanvas.width w * scale; regionCanvas.height h * scale; var ctx regionCanvas.getContext(2d); ctx.drawImage(canvas, x * scale, y * scale, w * scale, h * scale, 0, 0, w * scale, h * scale); var base64 regionCanvas.toDataURL(image/png); doOcr(base64); }).catch(function (err) { alert(截图生成失败可能页面存在跨域图片 err.message); }); }注意几个细节遮罩层和选区框的z-index必须足够大否则会被工具栏或弹层盖住截图用2倍scale是为了保证OCR识别清晰度后面到OCR服务端还可以再压缩选区宽度小于8像素的误点击直接忽略避免用户不小心点一下就弹出请求。3.3 后端OCR服务前端拿到base64图片后通过AJAX POST到后端OCR服务这是整个插件里最简单也最关键的一环。服务端我用Python Flask搭一个接口内部调用RapidOCR# ocr_server.py import base64 import tempfile import os from flask import Flask, request, jsonify from rapidocr_onnxruntime import RapidOCR app Flask(__name__) engine RapidOCR() app.route(/api/ocr, methods[POST]) def do_ocr(): data request.get_json() if not data or image not in data: return jsonify(code1, msg缺少图片参数), 400 base64_image data[image] # data:image/png;base64,xxx raw base64_image.split(,)[-1] image_bytes base64.b64decode(raw) with tempfile.NamedTemporaryFile(suffix.png, deleteFalse) as f: f.write(image_bytes) tmp_path f.name try: result, _ engine(tmp_path) lines [] if result: for item in result: box item[0] # 四点坐标 text item[1] # 识别文本 score item[2] # 置信度 if score 0.5: # 置信度过滤 lines.append({ text: text, score: float(score), box: box }) return jsonify(code0, data{lines: lines}) finally: os.remove(tmp_path) if __name__ __main__: app.run(host0.0.0.0, port5000, threadedTrue)如果团队已部署PaddleOCR 2.x接口改成from paddleocr import PaddleOCR ocr PaddleOCR(use_angle_clsTrue, langch, show_logFalse) result ocr.ocr(tmp_path, clsTrue) for line in result: for item in line: text item[1][0] score item[1][1]要特别留意PaddleOCR 3.x的API变化比较大ocr.ocr()在3.x里被调整如果用的新版请查官方文档的predict接口。我在项目里就吃过版本不一致的亏同事的2.6代码在3.0环境里直接跑不起来。3.4 识别结果回填编辑器OCR返回的是多行文本列表回填编辑器有一个必须处理的问题焦点和选区。用户先点击工具栏按钮然后操作遮罩框选这时候编辑器焦点已经丢了直接插会把文字插入到错误位置。正确的做法是进入截图流程前先把当前光标位置保存下来识别完成后恢复var savedBookmark null; UM.registerCommand(screenshotOcr, { execCommand: function () { var me this; // 保存当前选区位置 me.focus(); savedBookmark me.selection.getBookmark(); startCapture(); } }); function insertText(lines) { var editor UM.getEditor(myEditor); // 通过实例ID获取 editor.focus(); // 恢复到截图前的光标位置 if (savedBookmark) { editor.selection.moveToBookmark(savedBookmark); savedBookmark null; } var text lines.map(function (item) { return item.text; }).join(\n); var html text.replace(//g, amp;) .replace(//g, lt;) .replace(//g, gt;) .replace(/\n/g, br/); editor.execCommand(inserthtml, html); }这里用editor.selection.getBookmark()保存书签、moveToBookmark恢复是umeditor自带能力比手动存range对象再恢复靠谱得多。另外回填前必须做HTML转义否则图纸上的“M12×1.5”里的“×”没问题但万一识别出“”或者“”符号直接插HTML会被浏览器当标签吃掉。3.5 粘贴即识别提高日常使用效率页面内框选截图用的人其实不多工程师真正爱用的是“Snipaste截图编辑器粘贴”这条路。实现方式是在umeditor的ready事件里拦截粘贴事件从剪贴板里取出图片文件转成base64后直接识别UM.registerUI(screenshotOcr, function (name) { var me this; var $btn $.eduibutton({...}); me.addListener(ready, function () { var doc me.document; $(doc).on(paste, function (e) { var clipboardData e.originalEvent.clipboardData; if (!clipboardData || !clipboardData.items) return; for (var i 0; i clipboardData.items.length; i) { var item clipboardData.items[i]; if (item.kind file item.type.indexOf(image) ! -1) { var file item.getAsFile(); var reader new FileReader(); reader.onload function (ev) { var base64 ev.target.result; // 识别成功就替换粘贴内容失败则恢复默认粘贴行为 doOcr(base64, function (success) { if (success) { e.preventDefault(); } }); }; reader.readAsDataURL(file); } } }); }); return $btn; });这里要注意一个异步陷阱paste事件处理函数是先读完图片再决定要不要preventDefault所以不能同步拦截。实际逻辑是OCR请求返回成功并回填文字后再调用preventDefault这样如果OCR服务超时或失败图片还能按umeditor默认逻辑插入编辑器不至于把用户的截图搞丢。这个细节我是在一次线上事故后补上的刚开始直接在paste里无条件preventDefaultOCR服务一挂用户整个粘贴就废了。3.6 OCR请求的封装前端doOcr函数统一封装请求所以页面内截屏和粘贴识别两条路径都复用它function doOcr(base64Image, callback) { // 压缩图片避免请求体积过大 var compressed compressImage(base64Image, 1600, 0.85); $.ajax({ url: /api/ocr, type: POST, contentType: application/json, data: JSON.stringify({ image: compressed }), dataType: json, timeout: 15000, success: function (res) { if (res.code 0) { insertText(res.data.lines); if (callback) callback(true); } else { showTip(OCR识别失败 res.msg); if (callback) callback(false); } }, error: function () { showTip(OCR服务请求失败); if (callback) callback(false); } }); } function compressImage(base64, maxWidth, quality) { return new Promise(function (resolve) { var img new Image(); img.onload function () { var scale 1; if (img.width maxWidth) scale maxWidth / img.width; var canvas document.createElement(canvas); canvas.width img.width * scale; canvas.height img.height * scale; var ctx canvas.getContext(2d); ctx.drawImage(img, 0, 0, canvas.width, canvas.height); resolve(canvas.toDataURL(image/jpeg, quality)); }; img.src base64; }); }压缩这一步特别重要。截图出来的PNG动辄好几MB直接用base64 JSON传过去后端接口容易被大请求拖垮内网千兆还好跨网段传输时更是灾难。统一压到宽1600以内、JPEG质量0.85对印刷体文字OCR来说几乎无损。4. 机械场景增强识别后处理与细节优化4.1 特殊符号映射和单位保留通用OCR引擎对机械符号的识别总会有固定错误。我在一次测试里拿20张真实图纸标题栏截图跑RapidOCR发现Φ被识别成“中”的概率超过60%±被识别成“土”的约四成乘号“×”被识别成英文字母“x”的比例更高。所以识别结果不能直接落库必须过一层符号映射var SYMBOL_MAP { 中: Φ, φ: Φ, ①: Φ, 土: ±, 士: ±, Ⅹ: ×, X: ×, x: ×, 。: ., : ., : (, : ), : , ≤: ≤, ≥: ≥, ℃: ℃ }; function normalizeSymbols(text) { var chars text.split(); for (var i 0; i chars.length; i) { if (SYMBOL_MAP[chars[i]]) { chars[i] SYMBOL_MAP[chars[i]]; } } return chars.join(); }但是“x”映射成“×”要慎重。图纸里如果出现“M12x1.5”这里的x确实是乘号可以替换但如果识别到的是“XYZ坐标轴”里的“X”一替换就错了。我的做法是把这个映射做成可配置项默认只开启高置信度的符号映射中→Φ、土→±、。→.“X→×”这种放到“型号规范模式”里由用户勾选避免误伤。想过用正则做上下文判断比如“数字 x 数字”才替换但实测下来机械图纸的标注格式太杂反而误判更多。不如让用户在结果确认弹窗里手动修正个别符号比写一堆复杂规则更靠谱。4.2 批量框选与Excel导出机械行业录入经常不是一条一条来而是一整张表一整张表来。比如样本手册里的尺寸参数表二十多行数据逐条识别回填到编辑器再复制到Excel效率提升有限。后来我加了一个“批量框选”模式用户连续圈选多个区域OCR请求全部完成后把结果按区域顺序拼装可以选择插入编辑器或直接导出成CSV。var captureRegions []; function addCaptureRegion(base64) { captureRegions.push(base64); updateRegionTip(已框选 captureRegions.length 个区域点击完成开始识别); } function batchOcrAndExport() { var tasks captureRegions.map(function (img) { return $.ajax({ url: /api/ocr, type: POST, contentType: application/json, data: JSON.stringify({ image: img }), dataType: json }); }); $.when.apply($, tasks).done(function () { var results []; for (var i 0; i arguments.length; i) { results.push(arguments[i][0].data.lines); } // 根据坐标和顺序生成CSV generateCsv(results); }); }实际使用中批量框选模式做工艺卡片录入特别好用。上游供应商发来一张工艺参数表工艺员按顺序圈出每个参数区域几十行数据一两分钟就进系统了。4.3 识别质量控制和置信度过滤OCR引擎每条结果都带置信度分数但机械行业不能只靠这个分数。我遇到过置信度0.92但把“寿命”识别成“寿台”的情况数字“8”在低分辨率下被识别成“3”也时有发生。质量控制我用了三道闸门。第一道在后端置信度低于0.5的文本直接丢弃。第二道在前端回填前把置信度低于0.75的内容用特殊颜色标记用户能一眼看到哪些字是机器“猜”的需要人工确认。第三道是对特定字段做正则校验比如图号格式var PATTERN_GOODS_CODE /^[A-Z0-9]{2,}-[A-Z0-9]{2,}(-[A-Z0-9]{1,})?$/; function validateTypeCode(text) { if (!PATTERN_GOODS_CODE.test(text.trim())) { return 疑似图号格式异常; } return ; }这套机制上线后用户反馈说“虽然还是要点一遍确认但至少不会被错误数据直接写进ERP了”。在数据源头就拦截比在ERP里做二次校验成本低得多。5. 上线后我踩过的坑和排查记录5.1 表格/图纸区域识别顺序错乱现象识别一段两列参数表输出的文字A1、B1、A2、B2交叉排列而不是按列或按行分组。原因OCR引擎的文本排序规则默认按y坐标从上到下、再按x坐标从左到右。两列表格在y坐标上重叠就出现了交错。解决后端拿到识别结果后对文本行按坐标做聚类。先按y坐标分桶两个框的中心点y坐标差小于一个阈值就认为同一行再对同一桶内的文本按x坐标排序。这样表格能还原成正确的行def sort_ocr_lines(lines): lines: [{text, score, box}]box是四点坐标 def line_top_y(item): box item[box] ys [p[1] for p in box] return min(ys) def line_left_x(item): box item[box] xs [p[0] for p in box] return min(xs) # 先按y排序便于分桶 lines sorted(lines, keyline_top_y) buckets [] current [] last_y None threshold 12 # 像素可根据图片尺寸调整 for item in lines: y line_top_y(item) if last_y is None or abs(y - last_y) threshold: current.append(item) else: buckets.append(current) current [item] last_y y if current: buckets.append(current) ordered [] for bucket in buckets: bucket.sort(keyline_left_x) ordered.extend(bucket) return ordered这个功能上线之后标题栏和参数表的识别结果基本就能直接用了。阈值12像素是拿真实图纸试出来的如果截图倍率不同可以按screenshotScale动态调整。5.2 html2canvas跨域污染现象页面里如果引用了外域图片比如图纸缩略图来自图片服务器html2canvas绘制出的canvas会被标记为“污染”调用toDataURL时报错SecurityError。解决图片服务器配置CORS头同时在页面里给所有img标签加crossoriginanonymous属性。如果改动面太大就直接放弃页面内框选截屏引导用户用Snipaste粘贴路径。我们在内网场景下图纸图片都在同一域名还好但接外部供应商数据时确实被跨域卡过。5.3 编辑器焦点丢失导致回填错位现象用户框选完区域OCR结果回来插入后文字跑到了文档末尾或者编辑器开头而不是截图前光标位置。原因用遮罩层框选时编辑器失焦原有选区所有框架各不相同umeditor也不例外。解决就是上文提到的getBookmark和moveToBookmark。但还有一个辅助细节进入截图模式前先editor.focus()一次确保选区状态是完整的。另外如果用户框选截图期间在编辑器外点击过其他页面元素bookmark恢复也可能失效所以回填时要做一次判断如果moveToBookmark失败就放弃回填提示用户手动点一下光标位置再插入。5.4 图片过大请求超时现象页面拿到的base64图片未压缩时有5MB多后端接口偶发超时内网环境压力测试时更明显。解决前端统一压缩到1600px宽、JPEG质量0.85后转base64后端再限制单个请求体不超过4MB。在这个前提下OCR耗时基本稳定在200-500ms加上网络传输也就一两秒用户的体感很好。还有一次问题是后端临时目录写入大图失败用tempfile.NamedTemporaryFile改成系统临时目录后就好了。5.5 常见问题速查表问题现象可能原因排查方法按钮不显示toolbar配置名字和registerUI名字不一致检查两端名称是否完全一致点击按钮无反应自定义JS未加载或命令未注册F12看Console确认UM.registerCommand执行截图框不出来遮罩层z-index被编辑器弹层覆盖检查mask的z-index是否大于所有弹层toDataURL报SecurityError页面有跨域图片污染canvas服务器配CORSimg加crossoriginOCR返回空结果图片过小或模糊提高截图scale避免8px以下选区识别文本乱序多列表格未按坐标排序后端按y分桶x排序粘贴图片无反应粘贴事件绑定在非编辑器document上确认绑定在me.document上粘贴图片后原图内容丢失OCR失败但已经preventDefault异步逻辑改为成功后拦截最后再分享一个小技巧OCR服务单独跑一个端口不要和业务系统混在一起。机械企业内网环境复杂把OCR独立成服务主业务系统重启、升级都不影响OCR而且后续想接别的模型比如专门识别手写工艺卡、专门识别图纸标题栏的模型直接起一个新服务就好插件的前后端都可以复用。我个人实际用下来的体会是这个插件真正改变的不是打字速度而是“抄写-检查-返工”这个循环。以前工艺员录入一张标题栏要抬头看三次图纸现在截个图识别结果回填后扫一眼置信度标记就行。如果你也在做类似的编辑器集成建议先把“粘贴即识别”这条路做通它比页面内框选截图实用得多用户粘性也高得多。至于后续想扩展加上批量表格导出、图纸标题栏模板化识别这些能力方向我都写在上面了可以按需取用。