医院HIS系统里的检验报告要上Web端,最让人头疼的往往不是数据接口打通,也不是权限模型设计,而是那些混在报告单里的公式。我去年接了一个老HIS系统改造的活儿,检验报告模块要从C/S架构迁到B/S架构,业务方提的需求里有一条写得轻飘飘的:“把报告里的公式转成网页能正常显示的样子。”结果一打开存量数据,三千多张报告单里嵌着各式各样的计算式、化学式、参考区间条件式,有的是图片,有的是老系统私有编辑器留下的对象,还有的是纯文本硬写出来的。这一篇就把我整个处理过程梳理一遍,给正在做同类迁移的同行一个可参考的路线。
1. 检验报告里的“公式”究竟长什么样:先分清存量形态再动手
1.1 一张报告单上会出现的三类计算公式
先说业务侧。检验报告里的公式不是数学课本里那种,绝大多数是临床计算项目、参考区间分段条件和单位换算逻辑。
临床计算项目最常见。比如肾内科每天都要用的估算肾小球滤过率eGFR,CKD-EPI公式带性别、血肌酐、年龄三个变量,老系统里通常把公式画成带分数线的图片挂在报告尾部;心内科的低密度脂蛋白胆固醇,用的是Friedewald公式,LDL = TC - HDL - TG/5,这个“TG/5”在很多老报告里就是一小张图片;还有阴离子间隙AG = Na⁺ - Cl⁻ - HCO₃⁻,钙离子校正公式,肌酐清除率的Cockcroft-Gault公式,MELD评分里那个带ln的式子。这类公式有一个共同点:它们会影响最终打印出来的参考值,所以不能丢、不能错,错了是会出医疗事故的。
第二类是参考区间条件式。比如某些生化项目男性女性参考范围不同,年龄大于60岁和小于60岁参考范围不同。老系统会把这种分段条件写成一串类似“IF(SEX='M' AND AGE<60, 20-40, …)”的逻辑,展示型报告里有时候会以文本形式残留在报告底部。
第三类是单位换算关系,尤其出现在酶学、激素类项目上。老报告里经常写“1 ng/mL = 0.25 nmol/L”这种换算式,迁移后要在Web端保留完整的溯源信息。
1.2 存量公式的三种技术形态
这三种业务公式,落到技术形态上,我盘完之后发现基本只有三类。
第一类:图片。占了我这三千多张里的七成。来源五花八门,有的老检验软件直接把公式截图存库,有的是当年从LIS系统打印成纸质报告再扫描回来的,还有的是报告模板里用Word插入公式后整体导出成图。图片格式从JPG、BMP到TIFF都有,分辨率参差不齐。
第二类:私有编辑器对象。一些老HIS自带“公式域”功能,把公式做为OLE对象嵌入报告模板,或者用一套私有标记语言存在模板表里。这类最难搞,因为供应商早就不维护了,文档也没有,只能靠导出现象反推。
第三类:纯文本公式。最典型的就是AG这个项目,报告里直接写“AG = Na - Cl - HCO3”,没有上下标、没有离子符号。这种看着简单,但直接搬到Web上会很难看,而且容易被临床医生误读成化学式。
动手之前一定要先对存量做盘点,把“公式”按业务类型和技术形态分好类。不要一上来就找OCR工具批量识别,那样遇到私有对象根本无从下手,返工成本极高。
2. 转换路线的分型处理:图片、私有格式、文本各有各的走法
2.1 图片公式:OCR识别加人工复核,先跑通再规模化
图片类公式的转换路线基本是:图片预处理 → OCR识别 → LaTeX结果 → 人工复核 → 入库。
预处理这一步最容易翻车。老报告扫描件往往带有灰底、表格线、盖章痕迹,直接送OCR,识别率惨不忍睹。我用的方案是用Python加OpenCV做一次灰度化、二值化和降噪。
import cv2 import numpy as np def preprocess_formula_image(img_path): img = cv2.imread(img_path, cv2.IMREAD_GRAYSCALE) # 先放大,老报告扫描件分辨率经常只有150dpi img = cv2.resize(img, None, fx=2, fy=2, interpolation=cv2.INTER_CUBIC) # 大津法二值化,把灰底去掉 _, binary = cv2.threshold(img, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) # 去孤立噪点 binary = cv2.medianBlur(binary, 3) return binaryOCR引擎我对比过两条路:一条是调用商业化公式识别服务,像Mathpix、百度、腾讯都有。准确率高,但医院内网环境要考虑服务和数据出境问题,而且一次批量跑下来费用不小。另一条是用开源的PaddleOCR自己训练一个公式识别模型,适合大批量、高频次场景,但前期要标注大量样本。
我当时走了混合路线:先拿500张图片抽样测,公共云服务识别率在90%左右,开源模型标了800张样本后能做到85%。考虑到存量只有几千张,最后选了公共云服务跑全量,再加上人工复核兜底。
提示:识别率90%不是拿过来能直接用的数字。公式是错一个符号就满盘皆输的东西,eGFR公式里一个系数写错,计算结果就偏了。所以低于95%置信度的识别结果,一律进人工复核队列,复核人由检验科老技师来当。
2.2 私有公式对象:导出转图片再走OCR是最实在的路径
私有OLE对象最麻烦的地方在于它依赖原宿主程序。我遇到的情况是老系统把公式存在Word文档的OLE域里,XML结构里写着{EMBED Equation.3}。理论上可以直接用Word的VBA宏把所有OLE对象导出成图片,但这里有坑。
坑在字体。公式编辑器生成的内容依赖宿主机器安装的字体,如果导出环境没装原公式字体,导出图片就是一堆乱码框。我当时的排查链路是这样的:先在开发机上写VBA宏批量导出,出来200多张全部乱码;怀疑是Word版本不一致,换了老版本Word再试还是乱码;最后对比了原报告生成机的字体列表,发现缺了MT Extra和MS Extra这两个公式字体,装上之后导出才正常。
Sub ExportOLEAsImage() Dim doc As Document Set doc = ActiveDocument Dim i As Long For i = 1 To doc.InlineShapes.Count If doc.InlineShapes(i).Type = wdInlineShapeEmbeddedOLEObject Then doc.InlineShapes(i).Range.Copy ' 粘贴为图片后再导出 End If Next i End Sub导出成图片之后,走2.1的OCR流程就行。这条路线适合对象数量不多的情况,如果私有对象多到上万,就得考虑逆向它的私有标记格式了。
2.3 文本公式:正则规范化是性价比最高的方式
文本公式看着简单,做起来反而琐碎。老系统里写的是“Na - Cl - HCO3”,Web端希望显示成带离子符号上下标的样子。我的做法是先定一套规范化的LaTeX模板,然后用正则做符号替换。
import re def normalize_text_formula(text): # 常见的离子和化学式替换 rules = [ (r'Na', r'Na^{+}'), (r'Cl', r'Cl^{-}'), (r'HCO3', r'HCO_3^{-}'), (r'K', r'K^{+}'), (r'Ca', r'Ca^{2+}'), (r'Mg', r'Mg^{2+}'), ] for pattern, repl in rules: text = re.sub(pattern, repl, text) return text但正则不能瞎写。有一次我贪快,把“Ca”替换成“Ca^{2+}”,结果把报告里“Carcinoma”这个词的“Ca”也给替换了,临床医生立刻打电话来问。后来学乖了,加了两条约束:一是只在公式上下文里做替换,二是替换前先分词确认独立符号。
文本公式这条路适合存量少、结构简单的情况。真正要做好,建议把替换规则整理成配置表,每个规则配一个备注字段说明业务来源,后续LIS升级时还能复用。
3. Web端渲染选型:我为什么最终选了KaTeX而不是MathJax
3.1 先明确存储格式:LaTeX作为唯一权威源
转换出来的公式,最终要以什么格式存库,这个决定比渲染引擎更前置。我的答案是LaTeX。原因有三点:第一,LaTeX文本可读性强,出了问题能直接肉眼排查;第二,KaTeX和MathJax两个主流渲染引擎都支持它,换引擎成本低;第三,后续如果要导出版本给论文或报告复用,LaTeX是通用语言。
不建议直接用MathML存。MathML虽然也是W3C标准,但冗长到你根本没法直接读,数据库里存错一个节点,排查就是一整天。也不建议直接把渲染后的HTML片段存库,那等于把展示层和存储层耦合死了,业务系统一升级样式就崩。
标准格式我建议这样定:latex: eGFR=186×SCr^{-1.154}×age^{-0.203},这是MDRD公式的典型写法。所有OCR结果、私有对象转换结果、文本规范化结果,统一在这一步转成LaTeX,再落到新表里。
3.2 渲染引擎对比实测
这一步很多人纠结。我的对比结论很直接:
| 对比项 | KaTeX | MathJax 3 |
|---|---|---|
| 渲染速度 | 快,首次渲染毫秒级 | 稍慢,复杂公式有明显延迟 |
| 离线内网部署 | 好,纯静态资源 | 好,但体积更大 |
| 容错能力 | 语法错误直接报错 | 容错强,尽量渲染不报错 |
| 支持范围 | 覆盖绝大多数场景 | 覆盖最全 |
| 集成复杂度 | 低,一个render方法搞定 | 略高,需要配置加载器 |
检验报告场景有个特殊性:同一页报告里可能有十来个公式,页面滚动时重新触发展示地方会很快。病历浏览端天天看报告,渲染慢一步都觉得卡。所以我最后选了KaTeX。代价是容错性差,OCR识别结果里如果混入一个非法符号,KaTeX就整个公式不渲染,白屏一块。这个靠上一节的识别后处理加人工复核解决,问题不大。
3.3 前端集成的具体做法和展示层的兜底方案
前端集成的核心思路是:不要直接在接口层返回渲染好的HTML,而是返回LaTeX文本加一个类型标记,前端负责渲染。这样后端不依赖前端框架,后续加移动端也方便。
以Vue项目为例,我写了一个轻量组件:
// FormulaDisplay.vue <template> <span class="formula-container" v-html="renderedContent"></span> </template> <script> import katex from 'katex'; import 'katex/dist/katex.min.css'; export default { name: 'FormulaDisplay', props: { latex: { type: String, required: true } }, computed: { renderedContent() { try { return katex.renderToString(this.latex, { throwOnError: false, displayMode: false, output: 'html' }); } catch (e) { // 兜底:渲染失败时显示原始LaTeX,保证临床可读 return `<span class="latex-fallback">${this.latex}</span>`; } } } } </script>这里两个细节值得注意。throwOnError: false不要省,它保证有一个非法公式也不会阻断整个页面渲染,而是退回到原始文本显示。另外要设定trust: false,显式关掉KaTeX对危险命令的支持,防止报告内容被注入恶意公式代码,医院系统安全审计会查这一点。
提示:渲染兜底方案不能是“白屏”,也不能是“报错弹窗”。临床上打开一份报告看到公式位置一片空白,第一反应是数据丢了,电话马上打到信息科。退回原始LaTeX文本虽然丑,但至少可读,还能让医生把代码反馈给运维定位问题。
4. 批量迁移中的真实踩坑记录:4万条报告是怎么跑完的
4.1 预处理阶段的两大坑
项目不止那三千多张存量,后面把历史报告全量纳入迁移范围后,总共4万条报告涉及公式。批量跑起来之后,第一个坑就是图片质量。
老报告扫描件有大量“反白”情况,公式是白底黑字没错,但有一部分报告是蓝底白字,还有的盖了红色印章直接压在公式上。二值化处理之后,印章变成一团黑斑,OCR识别出的内容就是乱的。我加的解法是:识别前先用颜色通道分离,把红色通道单独去掉,再走二值化。
第二个坑藏在导出环节。私有OLE对象批量导出时,我在开发机跑通了,扔到生产环境的导出服务器上又全部乱码。查了一下午,最后发现是导出服务器上Office套件是精简版,缺了公式编辑器组件。这台机器不是给人用的,部署时没人会想着给精简版Office补公式组件。这个问题的通用解法是:导出操作统一在有完整Office环境的专用机器上做,而不是随便选一台便利的服务器。
4.2 识别后处理:化学式和计量单位最容易出错
批量识别的错误模式很集中,主要集中在化学式下标和计量单位空格上。
“CO₂”被识别成“CO2”这个错误出现频率最高。检验项目里的二氧化碳结合力、血气分析里的PaCO₂,全部中招。补救方案是写一个基于上下文的规则:当识别文本里出现CO2且前面是血气相关项目名时,自动转成CO_2。还有“H2O”同理。
计量单位的问题是OCR会把“mmol/L”识别成“mmol/ L”或者“mmol/L”。这种看似小问题,在检索和质控里会出大事,比如“mmol/L”一个单位字符串不同,查询条件就匹配不上。规范化单位字符串也是批量后处理里必做的一步,我整理了单位正则库,把工具里常见的几十个单位变体统一收敛。
下面这段规则用于对报告里所有单位类文本做收敛:
def normalize_units(text): text = re.sub(r'mmol\s*/\s*L', 'mmol/L', text) text = re.sub(r'ng\s*/\s*mL', 'ng/mL', text) text = re.sub(r'μmol\s*/\s*L', 'μmol/L', text) text = re.sub(r'pg\s*/\s*mL', 'pg/mL', text) return text4.3 性能和限流:OCR API并发控制
4万条批量跑OCR,最大的瓶颈不是计算资源,是API限流。我当时用的识别服务单账号QPS限制是5,理论上一小时能跑18000条,但真实情况是单条图片的识别耗时在2秒到5秒之间,实际吞吐不到理论的四分之一。
解法是分两批跑:第一批是抽样500条,全人工复核,跑通全流程和验收标准;第二批是剩余存量,用一个带断点续跑的分批脚本。关键经验是脚本一定要支持“断点续跑”和“失败重试”,OCR服务不定时抽风会返回空结果,没有断点续跑的脚本跑一大半崩掉,真的是会崩溃的。
import time import requests def run_ocr_batch(image_list, checkpoint_path, qps_limit=5): done_ids = load_checkpoint(checkpoint_path) pending = [img for img in image_list if img.id not in done_ids] batch_interval = 1.0 / qps_limit for img in pending: try: result = ocr_recognize(img.file_path) save_formula_to_db(img.id, result.latex) done_ids.add(img.id) save_checkpoint(checkpoint_path, done_ids) except Exception as e: log_failure(img.id, str(e)) time.sleep(batch_interval)4.4 渲染阶段的坑:行高、换行、打印
批量渲染上线后,最刺眼的三个问题都出在CSS上,而不是公式本身。
第一个是“公式与文字不对齐”,这也是被临床医生反馈最多的一条。原因是KaTeX默认的vertical-align是baseline,但公式本身有高度,基线对齐会让公式顶部超出正文行高,视觉上确实歪。解决方案是给公式加CSS:
.formula-container .katex { vertical-align: -0.25em; }这个值不是拍脑袋定的,需要拿最长的公式和最短的文本在页面上实际量。我当时调了两轮才定到-0.25em。
第二个问题出现在长公式换行。阶乘式、分数式在窄屏上会撑破卡片。我在报告卡片的CSS里加了overflow-x: auto,让公式在超宽时横向滚动而不是撑破布局,这样手机端看报告也不会乱。
第三个问题严重一些:打印PDF。医院对检验报告打印有硬性要求,排版不能变。直接用浏览器的打印,KaTeX渲染出来的公式在特定缩放下会出现行高被裁剪的情况,底部的下标被切掉。我的解法是打印样式单独适配:
@media print { .formula-container .katex { font-size: 0.95em; line-height: 1.6; } }注意,不要试图用jsPDF把KaTeX生成的HTML转成PDF再打印,那会丢样式丢到怀疑人生。Web端打印PDF统一走浏览器原生打印能力,兼容性和保真度都好得多。
5. 交付之后的一点体会:公式台账与抽检机制比转换本身更重要
5.1 建立“公式台账”和抽检机制
转换上线一个月后,我复盘时最大的感悟是:转换本身是一次性的,但“保证公式没错”是长期的事。所以我在系统里加了一张公式台账表,字段包括:原始报告ID、业务项目名称、公式原形态(图片/对象/文本)、转换后LaTeX、识别置信度、复核人、复核状态、抽检状态、备注。
这个台账有三重用途。第一,它让质控有据可查,检察人员可以按批次抽查复核记录;第二,它是后续LIS升级的重要资料,公式改了哪些、谁改的,全在里面;第三,它是问题复现的抓手,临床反馈某个公式显示异常时,先在台账里定位这个公式曾经是什么形态、走没走过复核,排查效率翻倍。
抽检机制的规则也不复杂:每天从新增转换记录里随机抽10%,由检验科指定人员复核并签字确认。一个月下来,基本能保证转换链路是稳定的。有同行问我这个机制是不是过度设计了,我的观点是:检验报告上的公式错误属于高危风险项,宁可过度设计,不要事后补救。
5.2 新报告入口处的规范化拦截
存量迁移解决的是历史问题,真正要防止“公式问题复发”,还得在入口处做文章。旧HIS系统之所以公式形态乱七八糟,就是因为报告模板编辑器放开了自由输入,检验科老师用图片凑,用文本硬写,时间一长就失控了。
新系统的做法是:条码申请和报告模板维护环节,公式输入只暴露三种规范入口——
- 计算项目:在LIS端维护计算式时,用公式编辑器直接生成LaTeX,保存时校验LaTeX语法合法性,不合法不允许保存。
- 参考区间条件式:用可视化的条件配置界面生成,不允许手敲文本。
- 需引用的历史报告公式:只能从台账里带ID引用,不允许复制粘贴。
这条设计上线之后,新产生的报告公式100%是规范化LaTeX,和存量转换结果完全走同一条渲染链路,后续运维压力大幅下降。
干了这一轮之后,我最大的体会是:公式转Web格式不是把一张张图片换成一段段代码那么简单,它牵扯到存量形态盘点、OCR工程化、渲染选型、批量处理、质控机制、入口规范一整条链路。每一步都有人踩坑,每一步也都有标准解法。如果你正在做同类改造,先别急着找工具,找个下午把那堆老报告单翻一遍,比任何调研都有用。