1. 为什么医疗单据脱敏不能靠现成的“打码滤镜”直接搞定
1.1 一个看似简单,实际翻车的场景:拍照单据
我接手过一个医疗单据批量归档项目,甲方最初的需求很轻描淡写:“把单据上的姓名、身份证号、手机号、家庭住址打码就行。”我一开始也觉得这活儿简单,找个现成的OCR抽文字,正则匹配一下身份证和手机号,再用矩形覆盖掉,半天就能交付。
真正拿到样张之后,才发现事情完全不是这样。这些医疗单据不是干净的扫描件,而是工作人员用手机拍的:有的单据被折过一道,有的拍摄角度偏了30度,有的局部还有阴影和反光。姓名在表格里,地址跨了三行,病历描述里藏着“患者自述”“既往史”这类不能公开的叙述。如果用固定位置打码,稍微旋转一点就对不上;如果用矩形框粗暴覆盖,纸上的表格线、印章、二维码都会被连带涂掉,片子根本没法看。
那时候我才意识到,医疗单据脱敏本质上不是一个图像处理问题,而是一个“先理解版面,再理解语义,最后精确破坏敏感区域”的复合问题。标题里那句“OCR + Vision LLM + 四点定位打码”并不是三个模块的简单叠加,而是分别解决“文字在哪”“哪些算隐私”“怎样打码不伤版面”这三件事。
1.2 通用脱敏方案的三大缺陷:漏检、错检、破坏版面
市面上现成的脱敏工具我也试用过几轮,对干净扫描件还行,一旦落到复杂拍摄场景,问题就暴露出来了。
第一个缺陷是漏检。手机拍照产生的透视变形,会让OCR的检测框发生偏移。角度稍微大一点,文字就容易粘连在一起,身份证号被识别成乱码,手机号漏掉中间几位。漏检意味着敏感信息原样留在图片里,这在医疗场景是绝对不能接受的。
第二个缺陷是错检。很多工具为了求稳,把所有数字都当成潜在手机号,把“2024-03-15”“门诊号:0382910”也一并打码。结果病人姓名没遮住,反而把就诊日期和单据编号全涂了,归档审核时直接被打回。
第三个缺陷是破坏版面。矩形框打码在水平文档上还能用,但医疗单据上的敏感字段往往紧挨着表格线、印章或者手写签名。一旦矩形框稍有偏差,就会涂掉无关内容,或者留下半个字在外面。更麻烦的是,弯折单据上的文字区域是变形的,矩形框根本包不住一个倾斜的多边形文字块。所以后来我把方案定成了“四点定位打码”——每个敏感字段用任意四边形框住,而不是用轴对齐矩形。
1.3 拆解敏感信息类型:字段级语义比正则更可靠
脱敏的第一步,其实是搞清楚一张医疗单据上到底哪些内容需要处理。常见的敏感类型包括:
- 姓名、性别、年龄、出生日期等基本身份字段;
- 身份证号、医保卡号、就诊卡号、住院号等证件编号;
- 手机号、家庭住址、联系人信息;
- 既往病史、过敏史、主诉、诊断描述等病历叙述;
- 检查报告中的“患者姓名”“送检医生”“报告医生”等关联字段。
前两类靠正则还能勉强覆盖,但“既往病史”“主诉”这种自由文本,正则没有办法判断哪个词该打码。我把这些字段梳理了一遍之后,结论很明确:必须引入一个有语义理解能力的视觉模型,让它直接看图说话,判断某个OCR文本框对应的内容属于哪一类敏感字段。这就是Vision LLM在这个项目里的核心位置。
2. 方案选型思路:OCR、视觉大模型和四点定位各自承担什么
2.1 OCR选型与适用边界:要的是文本框,不是纯文本
项目的第一步是定位文字位置。这里有个很容易走偏的点:很多人把OCR理解成“把图变成文字”,但在这个项目里,OCR输出的文字内容只是辅助信息,真正重要的是每个文字块对应的坐标。
我测试过几种主流的开源OCR框架,最后留下了两个候选:一种是学术型检测+识别分离的框架,检测模型输出文本框,识别模型再输出文字;另一种是端到端模型,直接输出带坐标的文字结果。在复杂拍摄场景下,检测+识别分离的框架更容易调优,因为多数漏检问题出在检测环节,我可以单独强化检测模型,而不影响识别逻辑。
选型时有几个关键参数需要关注:
- 文本框格式:是否支持任意四边形四点坐标,还是只有水平矩形。支持四点坐标的模型能更好地贴合倾斜、旋转的文字块。
- 多方向文本支持:医疗单据上既有横向打印文字,也有竖向手写备注,甚至印章里的文字是旋转排列的。选型时要看模型训练数据里是否包含多方向文本。
- 推理速度:单片单据的目标延迟最好控制在2秒以内,否则批量归档跑不动。
2.2 视觉大模型负责理解语义:哪些文字是隐私
OCR只能回答“这里有文字”,不能回答“这句话是不是隐私”。医疗单据里,“张某某”可能是患者名,也可能是医生名;“138XXXX1234”可能是患者手机号,也可能是医院咨询电话。这些都需要语义判断。
Vision LLM在我的方案里承担三件事:
- 对每个OCR文本框做字段类型分类,输出“姓名/证件号/联系电话/地址/病史描述/非敏感”等标签;
- 综合上下文判断遮挡范围,比如遇到“联系人:李某某,关系:夫妻”这种一行里出现两个字段的情况,要分别识别;
- 对OCR漏检做二次补充,视觉模型可以同时看整张图和所有文本框,如果发现某段文字明显像身份证号但OCR没框出来,就触发重新检测。
实际使用中,Vision LLM的输入不是整张大图直接输入,而是把OCR裁剪出的文本块图像和对应的文字内容一起送进去。这样能减少干扰,也能让模型把“图像里的文字样式”和“文字语义”结合起来判断。比如手写的“主诉:咳嗽三天”会被判定为敏感病历信息,而表格里的“项目名称:血常规”则不会。
2.3 四点定位打码:解决多边形旋转不规则区域,避免矩形误伤
为什么不用矩形框?我用一个具体例子说明。一张门诊收费票据上,“姓名:张某”这个字段在表格里紧挨着竖线,如果按OCR框的水平外接矩形打码,矩形会向右扩展,把旁边的金额数字也盖住。但如果用OCR检测出的四边坐标直接形成任意四边形,就能只盖住文字本身,不碰到表格线。
四点定位的核心是维护好“四边形的四个角点”到“实际图像像素区域”的映射关系。在预处理阶段,图片可能被矫正、缩放,所以所有坐标都必须在一个统一的坐标系里跟踪。我的做法是:
- OCR输出的坐标先按原始图片尺寸归一化;
- 预处理环节的所有变换(旋转、透视矫正、缩放)都记录变换矩阵;
- 打码前用变换矩阵逆运算把OCR坐标映射回最终输出图的坐标空间;
- 对四边形做整体外扩,避免只盖住文字主体而漏掉边缘笔画。
这套流程跑通之后,即便单据被弯折过,只要OCR能检测出文字块,四点坐标就能贴合文字的实际形状,打码边缘不会出现“有些笔画盖住了,有些笔画还露在外面”的情况。
3. 复杂拍摄场景的适配:弯曲、旋转、透视畸变的预处理链路
3.1 为什么模型直接跑会掉点:拍摄角度的透视变形
刚开始我用原始图片直接跑OCR,结果让我很意外:印刷体的识别准确率尚可,但文本框的位置经常整体向右下方偏移,导致后边的打码框跟不上。仔细分析了原因,手机拍摄单据时,镜头平面和单据平面不平行,产生了透视变形。文字在图像上不再是一行水平排列,而是近大远小、行线收敛于远处某一点。
透视变形最直接的影响是OCR检测框的“倾斜角”变大。模型能检测出斜框,但置信度下降,偶尔会把一行文字断成两块,或者把相邻两行粘连在一起。如果不在预处理阶段做矫正,后面每一步的误差都会累积。
3.2 弯曲矫正:基于文本行几何分布的矫正思路
单据被折叠或弯折时,文字行会变成曲线。比如一张横向折过的化验单,折痕附近的文字会被压缩,下面的行会轻微扭曲。对这种弯曲,我尝试过传统的网格矫正,但效果一般,因为单据上的表格线会干扰网格生成。
后来换了一个思路:先跑一遍OCR拿到所有文本行的四点坐标,然后按文本行中心点拟合弯曲曲线,再根据曲线把图像展开。这个方法不需要额外的矫正模型,纯粹基于OCR结果做几何变换,实现起来更稳。具体流程是:
- 收集所有文本行的中心点坐标;
- 按行分组,对每一行拟合二次曲线;
- 以曲线为基准,估计行之间的间距变化;
- 用分段仿射变换把弯曲区域映射到水平规则网格。
这套方法对轻度到中度弯折效果很好,对严重弯折(比如单据被揉成一团再展开)效果有限,那种情况只能提示用户重新拍摄。
3.3 旋转和透视处理的统一坐标映射
旋转和透视可以统一用矩阵运算处理。实际操作中,我维护了一个全局变换栈:每次对图像做旋转、缩放或透视矫正,都会把一个对应的变换矩阵压入栈中。最终输出打码结果时,把OCR原始坐标经过所有矩阵变换,得到当前图像坐标。
这里最容易踩的坑是图像尺寸变化。有些图像处理库在执行透视变换后,会自动扩大画布以保证内容不丢失,导致图片尺寸改变。如果代码里仍然用原始坐标打码,所有框都会偏移。我的解决办法是:每次变换后都重新获取图像的新尺寸,并把变换矩阵更新为“从原始图像坐标到当前图像坐标”的完整映射。
3.4 预处理链路的实际效果对比
预处理链路处理好之后,OCR的F1值在测试集上提升非常明显。我用500张手机拍摄的医疗单据做对比,未预处理时,文本检测的召回率大概只有82%;加完旋转和透视矫正后,召回率提升到93%;再加上弯曲矫正,提升到96%。最关键的是文本框的稳定性好了很多,不再出现同一张单据跑两次结果不一样的情况。
值得注意的是,预处理不能做得太激进。过度矫正会把单据拉成奇怪的形状,反而让OCR更困惑。我最后采用的策略是:只有当检测到文本行倾斜角度中位数超过15度,或者文本行中心点拟合曲线的残差超过阈值时,才触发矫正。在大部分轻度倾斜的场景下,直接让OCR自适应反而更快。
4. 核心实现:从检测到脱敏的完整流程
4.1 第一层:OCR文本框提取与置信度过滤
整个脱敏流水线分三层。第一层是OCR文本检测与识别。
代码结构大致如下:
def extract_text_boxes(image): # image: 经过预处理的BGR图像 result = ocr_detector.predict(image) boxes = [] for line in result: box = line["box"] # 四个点的坐标 [[x1,y1],...] text = line["text"] score = line["score"] if score < 0.45: continue boxes.append({ "box": box, "text": text, "score": score }) return boxes置信度过滤不能一刀切。对于打印体字段,阈值调到0.5都没问题;但对于手写体、印章、带背景纹理的文字,阈值如果设太高,会把该检测的字段漏掉。我最终的策略是:先把所有大于0.3的框全部留下来,交给视觉大模型做过滤;视觉模型认为某个区域重要但置信度低时,会触发二次检测。
4.2 第二层:视觉大模型语义分类(内部/公开字段)
拿到所有文本框后,把每块文本的裁剪图像和OCR文字组合起来,送给视觉大模型。这里的Prompt设计很关键。我早期的做法是让模型直接输出JSON标签,但模型容易把“既往史”里的“史”误判成“时间”。后来我把问题改成更具体的分类:
- 如果是身份字段,返回包含“姓名”“证件号”“手机号”等具体类型;
- 如果是医疗叙述字段,返回“主诉/现病史/既往史”等子类型;
- 如果既不属于身份也不属于医疗叙述,返回“公开”。
Prompt长这样:
你正在处理一张医疗单据。下面每个文本块可能包含敏感信息。 请对每个文本块进行分类,类别包括: 姓名、性别、年龄、证件号码、联系电话、家庭住址、医保信息、主诉、现病史、既往史、诊断结论、检查报告、非敏感。 输出JSON数组,包含每个文本块的id和category。 注意: 根据文本内容和上下文综合判断,不要仅凭单一关键词。为了让模型不“乱联想”,我会把整张单据的OCR文本按阅读顺序拼成上下文,让模型参考上下文来分类。比如“患者:张某某,联系电话:138...”两个字段在一个文本块里,单看前半句可能是姓名,后半句又是电话,模型会切开成两条。这一步是纯规则很难做到的。
4.3 第三层:四点坐标定位与膨胀掩码
分类完成后,敏感字段进入打码模块。打码不是直接把四个点围成的区域涂黑,而是要把坐标应用到目标图像上,并做膨胀和边界羽化。
膨胀的原因很简单:OCR检测框通常紧贴文字主体,但有些字符的笔画会略微超出检测框,比如“张”的捺、“电”的竖弯钩。如果不膨胀,打码后边缘会留下明显的笔画残影。我的默认膨胀参数是文字高度的10%,最小膨胀6像素,最大膨胀20像素。
生成掩码的代码片段:
import numpy as np import cv2 def mask_quad(image, quad_points, expand_ratio=0.1): pts = np.array(quad_points, dtype=np.float32) center = pts.mean(axis=0) expanded_pts = center + (pts - center) * (1 + expand_ratio) mask = np.zeros(image.shape[:2], dtype=np.uint8) cv2.fillPoly(mask, [expanded_pts.astype(np.int32)], 255) return mask注意这句center + (pts - center) * (1 + expand_ratio),它是以四边形中心为基准向外扩展,相比单纯在每个坐标加减像素值,能更好地保持四边形的形状。
打码最终采用“掩码+马赛克平均”的组合。直接涂纯色块在票据上是明显的干预痕迹,归档时没那么好看;用马赛克则保留了“这里被遮住了”的信息,又不至于太突兀。实测下来,马赛克颗粒取8像素时,既能掩盖信息,又不太影响版面阅读。
4.4 脱敏后图像质量控制:Mask和模糊的选择
打码后的图像质量,直接影响项目能不能验收。我试过三种方式:
- 纯色块覆盖:像素替换为灰白色,简单粗暴,但会在彩色印章旁边显得非常刺眼;
- 高斯模糊:对四边形区域做高斯模糊,边缘自然,但模糊半径不够时,文字的轮廓还会隐约看到;
- 马赛克化:将区域分割成小方块,每个方块填平均色,既不可读,又保留了一定的“不确定性”。
最终我采用“高斯模糊+马赛克混合”:先用较大半径的高斯模糊把文字信息彻底抹掉,再做一层轻量马赛克。这样既没有明显色块边界,也不会被还原出文字轮廓。
4.5 开源项目目录与模块划分
项目开源之后,我把代码划分成五个模块,方便不同需求的用户只取其中一部分:
preprocess/ # 图像预处理:矫正、去噪、透视变换 ocr_detector/ # 文本检测与识别封装 semantic_filter/ # 视觉大模型语义分类 masking/ # 四点定位打码与图像后处理 pipeline/ # 端到端串联这样划分的目的是,有的人只需要OCR模块,有的人只想用语义分类模块,有的人干脆自己写OCR,只借用四点打码的代码。模块之间通过标准的JSON数据结构传递,不强依赖某一个具体OCR框架。
5. 实测效果与被忽略的坑
5.1 测试集构建:模拟弯折、旋转、拍摄模糊场景
为了验证方案,我专门构建了一个测试集:把1000张医疗单据(脱敏过的测试数据)分别做旋转、弯折、透视变换、加噪声、模拟反光等处理,生成2000张测试图。测试集一定要覆盖真实环境,否则指标再高也没用。
我的测试集分几类:
- 正常平铺拍摄:基线;
- 15度到30度的透视倾斜:模拟手持拍摄;
- 横向对折后展开:模拟单据折痕;
- 局部反光与阴影:模拟灯光不均匀;
- 低分辨率手机图:模拟老旧设备。
5.2 指标统计:召回率、误杀率、版面保持度
评估指标不能只看OCR准确率。脱敏项目更关心的是:
- 敏感字段召回率:所有敏感字段中,有多少被成功打码;
- 误杀率:非敏感字段中,有多少被错误打码;
- 版面保持度:打码后,非敏感区域的像素变化比例。
我的实测结果大致是:敏感字段召回率97.8%,误杀率1.9%,版面保持度约96%。这个结果是在包含倾斜和弯折的测试集上跑出来的。召回率没有到99%,主要问题出在极端反光和手写小字上,后面会细说。
5.3 几个经典翻车案例:低对比度、反光、手写体
第一个翻车案例是低对比度单据。有些用热敏纸打印的单据,过一段时间字迹变淡,OCR检测置信度极低。视觉大模型虽然能看到模糊的痕迹,但无法准确判断文字边界,导致打码框偏大,把旁边表格里的“年月日”也包进去。针对这类情况,我的对策是增加一个图像增强分支:检测到图像整体对比度偏低时,先做自适应直方图均衡化,再送OCR。
第二个是反光区域。反光处会出现高光斑块,文字被亮斑切断,OCR识别出来是“1 3 8 ****”,中间全是星号。视觉大模型根据上下文推断这是手机号,但四个点坐标已经不准确了。这种情况下,我做了“区域重新检测”:以反光影响区域为中心,截取局部图,用全局阈值分割找出文字笔画,再用笔画的最小外接多边形替换原来的四点框。
第三个是手写体。医疗单据上的医生签名和手写备注很难识别。打码这类区域时,我不依赖OCR识别的文字内容,而是让视觉模型判断“这块区域包含手写且属于敏感类别”,只要判断是敏感类别,就用手写区域的连通域掩码来做打码,效果比OCR框好很多。
5.4 视觉大模型误判:地址拆行识别为多个字段
Vision LLM最大的问题不是不会分类,而是容易把同一个字段拆成多个不完整的字段。比如“北京市朝阳区某某街道某小区3栋502室”在单据上可能分成三行显示,模型会把每行都识别成独立地址,打码时三块之间留下缝隙,或者把相邻的“联系人”字段也划进去。
我的解决办法是把OCR的布局信息注入Prompt:把属于同一行、同一列的文本块合并成“段”,让模型在段落级别上做分类,而不是逐个文本块分类。段落合并的依据是文本块中心点垂直方向上的重叠程度。如果两个文本块在垂直方向有较多重叠,且水平间距不大,就合并成同一个逻辑段。合并之后,地址、主诉这类跨行文本的分类准确率明显提升。
6. 工程落地建议:性能优化与离线部署
6.1 各环节的耗时占比与优化空间
我在CPU机器上先跑了一版,单张单据的总耗时接近15秒,其中OCR检测占5秒,识别占4秒,视觉大模型分类占5秒,图像处理占1秒。这个速度完全没法用,于是开始逐环节优化。
优化后,单张单据在CPU上的耗时压到4秒左右,GPU上能到1.5秒。主要手段包括:把OCR检测和识别拆成并行、视觉大模型用量化后的轻量版本、图像尺寸动态缩放。
各环节耗时参考:
| 环节 | 优化前耗时 | 优化后耗时 | 主要优化手段 |
|---|---|---|---|
| OCR检测 | 5.0s | 1.2s | 限制检测图像最长边,设置信度阈值 |
| OCR识别 | 4.0s | 1.0s | 只识别检测出的文本块,裁剪小图 |
| 视觉大模型分类 | 5.0s | 1.5s | 量化,减少输入Token数 |
| 图像预处理与打码 | 1.0s | 0.3s | 使用轻量图像处理库,避免重复编解码 |
6.2 OCR结果缓存与视觉模型裁剪
医疗单据有一个特点:同一家医院、同一类单据的版式相对固定。所以我在工程上加了OCR结果缓存。缓存键是图像的感知哈希和文件大小组合,如果同一张图片重复提交,直接复用之前的检测结果和脱敏掩码,省去全部模型计算。
视觉模型部分,我用的是开源视觉语言模型权重的蒸馏版本,从原来的单精度浮点模型量化到8位整数,显存占用降低了一半多,分类准确率只下降不到1%。输入图片也裁剪成短边512像素,对于大多数单据文字识别来说,这个分辨率足够。
6.3 批量任务、异步队列、GPU/CPU混合调度
归档场景是典型的海量离线任务,必须做异步队列。我用了一个简单的生产者消费者模型:
def process_queue(image_queue, result_queue, workers=4): with ThreadPoolExecutor(max_workers=workers) as executor: futures = [executor.submit(worker, image_queue, result_queue) for _ in range(workers)] for f in futures: f.result()这里要注意,OCR和视觉模型最好分开跑。GPU资源紧张时,OCR在GPU上,视觉模型可以退到CPU;如果两个模型抢同一个GPU显存,容易出现内存不足。
我的调度策略是:前处理和后处理用多线程并行;模型推理部分,如果机器有多个GPU,按照显存余量分配模型实例;如果只有一个GPU,把视觉模型放进批处理模式,每批4到8张图,吞吐量比单张推理提升2倍以上。
6.4 私有化部署与隐私合规要点
脱敏工具本身处理的就是敏感数据,部署时必须考虑数据不能出内网。我的开源版本只提供离线推理方案,所有模型权重和推理脚本都放在本地运行。服务端不保存原始图像,处理完成后只保留脱敏后的结果图,并且处理日志里不写入姓名、证件号这类明文。
另外,单据测试数据在项目里全部是公开的模拟数据。真正涉及真实患者信息时,必须先做好数据授权。这个原则是底线,技术方案再完善,也不能越过去。
7. 开源项目可以怎么上手用
7.1 最小运行步骤
拿到开源仓库之后,最快跑通的方式是:
- 安装依赖库,下载OCR和视觉模型权重;
- 准备一张测试单据图片;
- 运行命令行工具:
python run_pipeline.py --input test.jpg --output result.jpg - 默认参数会输出两张图:一张带检测框的标注图,一张打码后的结果图。
如果只想用某个模块,也可以单独调用。比如只做OCR检测:
python run_pipeline.py --input test.jpg --output result.json --module ocr7.2 如何调整敏感字段范围
不同机构的脱敏要求不一样。有的只要遮姓名和身份证号,有的连检查项目里的特定词都要处理。开源代码里把所有分类规则统一放在一个配置文件里,可以按需调整:
{ "sensitive_categories": ["姓名", "证件号码", "联系电话", "家庭住址", "主诉", "现病史"], "mask_method": "mosaic", "expand_ratio": 0.1 }调整后不需要重新训练模型,视觉大模型会按照新的分类要求输出标签,打码逻辑跟着标签走。要注意的是,如果增加一个原本不在Prompt里的新类别,需要同步修改Prompt模板,否则模型不知道新类别是什么意思。
7.3 数据回流与模型微调思路
虽然默认权重在大多数单据上表现不错,但每个机构的单据版式都有自己的“土特产”:某些医院喜欢在单据角落盖一个大红章,有些社区中心的表格线又细又密。如果希望准确率更高,需要做模型微调。
我的建议是优先微调OCR检测模型,采集50到100张该机构的脱敏后单据,标注出文字框,然后用检测模型继续训练少量轮次。视觉大模型除非分类错误特别集中,否则不建议直接微调,成本高且容易过拟合。先用规则修正和Prompt调整来兜底,大部分问题都能解决。
一点个人实测体会
项目做完之后,我最深的感受是:医疗单据脱敏这种需求,表面看是OCR加图像处理,实际做下来会倒逼你把“识别、理解、图像变换”三块能力全部打通。尤其四点定位打码,看起来只是坐标映射,但一旦和弯曲矫正、透视变换、OCR框不确定性叠加在一起,细节就会成倍增加。建议后来者先不要追求完美,把流水线跑通,再用真实数据慢慢磨边界情况。这套思路不只是医疗单据能用,合同、发票、档案、表单脱敏都可以参照,只是字段分类需要重新调整。我把这个项目整理成开源仓库之后,也一直在持续补充新的测试场景,希望能让更多人在处理这类问题时少走点弯路。