ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

公文PDF扫描件OCR:文本层判定与条款结构化检索

公文PDF扫描件OCR:文本层判定与条款结构化检索 简介这份资源为国家多部门联合印发的《关于印发加强和完善麻醉医疗服务意见的通知》PDF原文面向医院管理者、麻醉科医师、护理人员、医学教育工作者及医疗政策研究者可用于了解麻醉学科建设、人才培养与医疗服务质量提升的政策依据。文件共1个PDF压缩包约448KB体积极小便于在电脑或移动端随时查阅、打印与归档。通知从总体要求和主要目标出发围绕加强麻醉医师培养与队伍建设、优化麻醉专业技术人员结构、拓展麻醉医疗服务领域等方面提出具体意见涉及麻醉科护士与技师岗位设置、住院医师规范化培训、疼痛与日间手术麻醉、麻醉科门诊及术后监护等内容并给出麻醉医师数量与每万人口配置的阶段目标。目前已有145人浏览学习适合作为科室学习、政策解读与论文写作时的参考文本。1. 从一份「关于印发…的通知.pdf」说起公文PDF为什么不能直接当文本用共享盘里扔过来一个文件名字就叫《关于印发加强和完善麻醉医疗服务意见的通知.pdf》任务是把它接进内部知识库让同事能按第几条检索到原文。很多人第一步就是敲pdftotext然后拿到一个 0 字节的 txt —— 这不奇怪。这类通知在流转过程中通常是打印、盖章、再扫描回传的页面上那层文字在 PDF 里其实只是一张 JPEG。文件名里的通知只说明体裁不说明数据形态。真正要解决的是三件事判定这份 PDF 是什么形态、把形态不对的页还原成可检索文本、把还原结果落成能按条款号定位、能回原文高亮的字段。做档案数字化、政务与医疗信息化文档接入、企业知识库数据清洗的人绕不开这条链路。下面按判定 → 还原 → 结构化 → 校验的顺序走一遍命令和参数都可直接抄。2. 先判断这份通知PDF有没有文本层pdfinfo、pdffonts 与 PyMuPDF 的判定链路拿到 PDF 的第一件事不是 OCR也不是调大模型而是判定它属于哪一种。判错的代价很大把原生文本的 PDF 丢进 OCR识别结果反而比直接抽取更差把扫描件当文本 PDF 处理会得到一堆空字符串还找不到报错点。判定成本很低三条命令加一段二十行的 Python 就能得到结论。2.1 用 pdfinfo、pdffonts、pdfimages 做三秒钟初判Poppler 工具集是判定环节里性价比最高的一组命令不需要写代码输出直接可读。# 页数、页面尺寸、是否加密、PDF 版本 pdfinfo 关于印发加强和完善麻醉医疗服务意见的通知.pdf # 关键一步看有没有内嵌字体。表头下面没有数据行 大概率是扫描图 pdffonts 关于印发加强和完善麻醉医疗服务意见的通知.pdf # 每页的图片对象。扫描件基本是一页一张全幅图 pdfimages -list 关于印发加强和完善麻醉医疗服务意见的通知.pdf | head -20pdfinfo里要看三行Pages决定后面批量任务的分片粒度Page size是 A4595 x 842 pt 或 210 x 297 mm还是被裁过的非标尺寸非标尺寸往往意味着这是从某个系统导出的拼版文件后面渲染 DPI 要单独算Encrypted如果是yes先解决口令再谈抽取不要在后面某个库报权限错误时才发现。pdffonts的判定最直接。输出有name/type/emb/sub/uni/object ID六列如果只有表头没有数据行说明整份文件没有内嵌任何字体页面内容全部是矢量图或位图文本层不存在。如果只列出Type3字体或者一堆Embedded Subset的匿名子集说明文本层存在但字符映射可能是坏的直接抽取会得到乱码这种半坏状态比纯扫描件更麻烦。pdfimages -list用来量化。A4 在 300 dpi 下约 2480 x 3508 像素如果每页恰好有一条这个量级的image记录就是标准的打印扫描件如果一页里有七八条小图那多半是电子版盖章后拼接的正文很可能还有文本层属于混合型。2.2 用 PyMuPDF 逐页量化文本密度与图片占比命令行只能看到文件级结论真正要分流到页级。PyMuPDF 不依赖外部进程适合塞进批处理脚本里。import fitz # PyMuPDF def probe(path): doc fitz.open(path) rows [] for i, page in enumerate(doc): text page.get_text(text).strip() # blocks 元素是 7 元组(x0, y0, x1, y1, text, block_no, block_type) blocks page.get_text(blocks) img_area sum((b[2] - b[0]) * (b[3] - b[1]) for b in blocks if b[6] 1) page_area page.rect.width * page.rect.height rows.append({ page: i 1, chars: len(text), img_ratio: round(img_area / page_area, 3), rotation: page.rotation, # 0/90/180/270倒置扫描页会在这里露出来 }) doc.close() return rows for r in probe(关于印发加强和完善麻醉医疗服务意见的通知.pdf): print(r)逻辑很直白chars是该页文本层的字符数img_ratio是图片块面积占页面的比例两者组合就能定位每页的形态。经验阈值是chars 20且img_ratio 0.6判为扫描页chars 300且img_ratio 0.2判为原生文本页两者都在中间地带的是混合页。rotation那一列容易被忽略但实际很常见扫描仪走纸方向不对时整页内容旋转 90 或 180 度但页面对象本身没旋转文本抽取顺序会完全错乱OCR 也会大面积失败。参数上要留意get_text(blocks)返回的 7 元组下标 6 是block_type0 表示文本块、1 表示图片块这是区分内容类型的唯一依据。如果需要更细的信息比如字号、字体名、字符级坐标用get_text(dict)它返回嵌套的 block/line/span 结构span 里带size和font可以用字号反向推断哪些行是标题、哪些是正文 —— 公文的层级标题通常比正文大 2 到 4 pt。2.3 判定矩阵原生文本、纯扫描、混合型三种分流把上面两节的信号合起来就能得到一张可以直接写进代码的分流表。信号组合判定类型处理路径常见误用pdffonts 有字体chars 300img_ratio 0.2原生文本直接抽取 版面排序又跑一遍 OCR结果反而更差pdffonts 无字体chars ≈ 0img_ratio 0.6纯扫描渲染 OCR只抽文本得到空文件还不报错部分页有字体部分页 chars ≈ 0混合型页级分流逐页决定按文件级统一走 OCR浪费且降质有字体但抽出全是\ufffd或方块字体映射损坏放弃文本层按扫描件处理硬做编码转换越转越乱混合型是最需要提防的一类。这类通知常见结构是正文页是电子排版生成的带完整文本层附件表格页是打印后扫描贴进来的只有图像。如果按文件级统一处理正文页会被 OCR 二次识别引入多余错误附件页又可能因为没触发 OCR 而丢内容。正确做法是在 2.2 的循环里逐页打标签把need_ocr作为页级字段传给下游而不是给整个文件设一个开关。3. 扫描版通知的OCR还原渲染DPI、图像预处理与 PaddleOCR 参数怎么调判定完之后扫描页要走的链路是PDF 页 → 位图 → 预处理 → 文字检测 → 文字识别 → 按行输出。这条链路上每一步都有能显著影响最终质量的参数而绝大多数问题出在渲染和预处理这两步不是模型本身。把 200 dpi 的糊图喂给再好的识别模型也出不来干净文本。3.1 渲染DPI的取舍200 还是 300渲染是整条链路的源头源图质量决定了上限。PyMuPDF 里一行就能渲染但 DPI 这个参数值得单独想清楚。import fitz doc fitz.open(关于印发加强和完善麻醉医疗服务意见的通知.pdf) page doc[2] # 0-based第 3 页 pix page.get_pixmap( dpi300, # 渲染分辨率 colorspacefitz.csGRAY, # 灰度省内存OCR 对颜色不敏感 alphaFalse, # 不要透明通道否则部分识别库读图会失败 ) pix.save(page_003.png) print(pix.width, pix.height) # A4300dpi 约 2480 x 3508 doc.close()公文正文一般是三号或四号仿宋、宋体约 15 到 16 pt笔画细但清晰200 dpi 已经足够识别。真正需要往上加的场景有三个正文里夹了手写签字或批注有需要保留的浅色印章有字号很小的附件表格五六号字。这三种情况下 300 dpi 明显更稳。DPIA4像素尺寸单页PNG大小适用场景1501240 x 1754200 KB 左右只做关键词检索不要求逐字准确2001654 x 2339400 KB 左右常规公文体正文速度优先3002480 x 3508900 KB 左右小字号附件表、手写、印章4003307 x 46771.6 MB 左右收益递减明显除非原件本身就糊colorspacefitz.csGRAY在批量场景下能省掉三分之一左右的内存和一半左右的 PNG 体积。alphaFalse是个容易踩的坑带透明通道的 PNG 交给部分识别库会被当成四通道图读取行为不一致直接关掉最省事。3.2 灰度、二值化与倾斜校正的代码与参数预处理的目标只有两个让文字和背景的对比更明确让文字行尽量水平。做得过头会把浅色印章和细笔画一起吃掉。import cv2 import numpy as np def preprocess(img_bgr, max_side2400, block_size31, c15): gray cv2.cvtColor(img_bgr, cv2.COLOR_BGR2GRAY) # 1. 限制长边控制内存与耗时 h, w gray.shape scale max_side / max(h, w) if max(h, w) max_side else 1.0 if scale 1.0: gray cv2.resize(gray, None, fxscale, fyscale, interpolationcv2.INTER_AREA) # 2. 自适应二值化对扫描件的不均匀光照比全局 Otsu 稳 bin_img cv2.adaptiveThreshold( gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, blockSizeblock_size, Cc) # 3. 取反 横向膨胀让文字连成行便于估计倾斜角 inv 255 - bin_img kernel cv2.getStructuringElement(cv2.MORPH_RECT, (30, 5)) dil cv2.dilate(inv, kernel, iterations1) coords np.column_stack(np.where(dil 0)) if len(coords) 100: angle cv2.minAreaRect(coords.astype(np.float32))[-1] angle angle - 90 if angle 45 else angle if abs(angle) 0.5: # 小于 0.5 度不值得旋转重采样反而更糊 m cv2.getRotationMatrix2D((w / 2, h / 2), angle, 1.0) bin_img cv2.warpAffine( bin_img, m, (w, h), flagscv2.INTER_NEAREST, # 二值图不需要插值 borderModecv2.BORDER_REPLICATE) # 边缘复制避免出现黑边 return bin_imgblockSize31必须是奇数含义是参与局部阈值计算的邻域边长对应约 15 像素半径在 300 dpi 下大致覆盖一个字的高度这是比较通用的起点。C15是常数偏移值越大越倾向于把像素判为背景浅色印章和浅灰底纹容易被抹掉如果这批文件里有需要保留的印章把C降到 8 左右再试。倾斜角小于 0.5 度时不要旋转因为旋转本身会引入插值模糊得不偿失。旋转插值用INTER_NEAREST二值图只有 0 和 255 两个值用双线性反而会造出一堆灰色边缘像素干扰后面的检测。3.3 PaddleOCR 的四个必调参数落到识别环节参数调优的收益集中在四个值上。from paddleocr import PaddleOCR ocr PaddleOCR( langch, use_angle_clsTrue, # 文本行方向分类应对倒置扫描页 det_db_thresh0.3, # 检测图二值化阈值调低召回更多候选框 det_db_box_thresh0.5, # 文本框得分阈值 det_db_unclip_ratio1.6, # 框外扩系数中文场景最关键 drop_score0.5, # 识别置信度低于此值的行直接丢弃 show_logFalse, ) result ocr.ocr(page_003.png, clsTrue) for box, (text, score) in result[0]: x0, y0 box[0] print(round(x0), round(y0), round(score, 3), text)det_db_unclip_ratio是中文场景最需要调的一个。仿真和宋体笔画密、行距小这个值低于 1.2 时像的通知这种紧邻的字容易被切成两个框识别结果里就会出现莫名其妙的空格调到 1.6 到 2.0 之间通常最稳。det_db_thresh调低会召回更多文本框但同时引入噪声用drop_score兜住置信度低于 0.5 的行直接不进结果比事后人工挑错更高效。use_angle_cls对 180 度倒置页有效代价是每行多一次前向计算批量跑之前先统计一下倒置页占比占比低于百分之二就不要全局开。参数默认值公文场景建议主要影响det_db_unclip_ratio1.51.6 到 2.0中文粘连字被切开会出现虚假空格det_db_thresh0.30.2 到 0.3调低召回多、噪声多靠 drop_score 过滤det_db_box_thresh0.60.4 到 0.5过低会把表格线识别成文字行drop_score0.50.5 到 0.6决定低质行进不进下游也决定返工量输出的坐标box一定要留下来不能只存文本。条款级检索命中之后要回原文高亮靠的就是这四个点。4. 把条款、附件表和发文信息抽成结构化字段OCR 出来的是一行一行带坐标的文本离可检索的条款还差一层结构化。这一层决定知识库的检索体验是按段落检索还是能精确定位到第三条差别很大。结构化分两块正文条款的层级切分和附件表格的还原。4.1 三层条款编号的正则切分公文的层级编号有固定习惯用正则可以覆盖大部分情况但要处理 OCR 引入的全角半角混用。import re # 常见层级一、二、 / 一二 / 1. 2. / 12 RE_L1 re.compile(r^\s*([一二三四五六七八九十]{1,3})[、.]\s*(.{2,40})$) RE_L2 re.compile(r^\s*[(]([一二三四五六七八九十]{1,3})[)]\s*(.{2,60})$) RE_L3 re.compile(r^\s*(\d{1,2})[、.]\s*(.{2,80})$) def split_clauses(lines): clauses, cur [], None for raw in lines: ln raw.strip() if not ln: continue m RE_L1.match(ln) or RE_L2.match(ln) or RE_L3.match(ln) if m: if cur: clauses.append(cur) cur {no: m.group(1), title: m.group(2)[:40], body: []} elif cur: cur[body].append(ln) if cur: clauses.append(cur) for c in clauses: c[body] \n.join(c[body]) return clauses三个正则按优先级串联是因为 OCR 经常在同一份文件里同时输出一和(一)全角半角都要接住。^\s*必须带识别结果的行首经常残留一两个空格。长度的上界40 / 60 / 80同样重要正文里也会出现一、二、三这种枚举如果不限制后面跟的字符数会把整句正文误判成标题切出一堆空条款。标题之前的内容也就是发文机关、主送单位、正文引导句脚本里是丢弃的实际要单独存一份文档级元数据不要混进条款表。4.2 附件表抽取有框线、只有横线、纯图片三种情况附件表是这类通知里最容易丢信息的部分。先判断表的形态再选工具顺序反了会白费功夫。表格形态判定方法抽取方案有完整框线矢量页面上能取到大量直线指令pdfplumber 的 lines 策略只有横线或无线但有文本层文本块 x 坐标有规律聚集pdfplumber 的 text 策略纯图片无文本层2.2 中该页 chars ≈ 0线检测切格 单元格 OCR有框线的表用 pdfplumber 抽最稳参数不用太多但间距容差要显式给import pdfplumber with pdfplumber.open(关于印发加强和完善麻醉医疗服务意见的通知.pdf) as pdf: page pdf.pages[3] tables page.extract_tables({ vertical_strategy: lines, # 竖线靠矢量线判定 horizontal_strategy: lines, # 横线同上 snap_tolerance: 4, # 4px 内视为同一条线消除轻微偏移 join_tolerance: 4, # 断线拼接容差 edge_min_length: 20, # 短于 20px 的线段当噪点丢弃 intersection_tolerance: 6, # 交点容差扫描后线常错开 }) print(tables[0][:3] if tables else no table)snap_tolerance和intersection_tolerance是这套参数里最影响成败的两个。扫描件转来的矢量线经常因为倾斜被拆成好几段容差给太小就拼不回完整的表格网格抽出来会缺列。edge_min_length20用来过滤页眉页脚的装饰线给太小会把它们当成表格边界。只有横线没有竖线的附件表很常见这时把vertical_strategy换成text靠文字块的 x 坐标聚集来推断列位置。但扫描件根本没有矢量线pdfplumber 一定抽不出东西只能先做直线检测形态学腐蚀出长横线和长竖线切出单元格再逐格送 OCR。这条路径误差更大条件允许的话优先找电子版原件替代扫描版。4.3 字段落库doc_id 为什么用内容哈希结构化结果要能支持两件事按条款号精确定位以及从检索结果回跳原文高亮。表结构必须同时留住这两样东西。CREATE TABLE doc_clause ( doc_id TEXT NOT NULL, -- 文件内容 sha256不用文件名 page_no INT NOT NULL, clause_no TEXT NOT NULL, -- 一 / 一 / 3 level SMALLINT NOT NULL, -- 1/2/3来自命中哪条正则 title TEXT, body TEXT, bbox JSONB, -- [x0,y0,x1,y1]回原文高亮用 ocr_score REAL, -- 该条款命中的最低识别置信度 updated_at TIMESTAMPTZ DEFAULT now(), PRIMARY KEY (doc_id, page_no, clause_no) ); CREATE INDEX idx_doc_clause_body ON doc_clause USING gin (to_tsvector(simple, coalesce(title, ) || || coalesce(body, )));doc_id用文件内容的 sha256 而不是文件名这一点很关键同一份通知在共享盘里往往存在多个命名版本带日期后缀的、标最终版的、被加了括号序号的都有内容哈希能把它们合并成同一条记录避免检索结果里出现三条几乎一样的段落。主键包含page_no是因为 OCR 会把跨页的条款切断比如五落在页尾、内容落在下一页这时两个片段必须分开存不能直接合并覆盖。ocr_score落库的价值在于事后筛选检索时可以把置信度低于 0.85 的条款单独列出来做人工核对而不是全量重跑。5. 批量跑通之后断点续跑、质量指标与一个调参技巧单份文件跑通和中途断掉要能续上是两件事。几百份通知的批量任务动辄跑几小时中途因为一页渲染失败或者接口超时中断从头再来一遍的代价没法接受。除此之外还需要一套能自动算的质量指标不然只能靠人翻几百份文件翻不过来。5.1 用页级完成标记做断点续跑任务粒度定在文件 页码用一张状态表记录重跑时只补没做完的页。def pending_pages(total, done_pages): done_pages 是已完成页码集合返回还需要处理的页码列表 return [p for p in range(1, total 1) if p not in done_pages] def run_doc(doc_id, total, done_pages, handler): for p in pending_pages(total, done_pages): try: handler(doc_id, p) mark_done(doc_id, p) # 立即持久化不要攒到最后一起写 except Exception as e: log_fail(doc_id, p, repr(e)) # 记录失败页继续下一页mark_done必须每页处理完立刻落库攒到最后批量写的话一旦进程被杀前面全部白跑。异常不要终止整个文件记录失败页继续往下走最后用失败页清单做一次补跑比整份重跑省得多。渲染阶段失败多半是页面尺寸异常或损坏OCR 阶段失败多半是内存吃紧两类错误分开统计排查方向完全不同。5.2 三个能自动算出来的抽取质量指标人工抽查只能覆盖样本日常监控要靠指标。指标计算方式健康区间异常时先看什么乱码率命中[\ufffd\u0000-\u001f]的字符占比低于 0.5%字体映射是否损坏是否误走了文本层抽取框重叠率相邻识别框 IoU 超 0.7 的比例低于 5%det_db_unclip_ratio是否过大条款零识别率未命中任何层级正则的文件占比低于 3%二值化是否把笔画抹掉或倾斜校正失败框重叠率是这三个里最有诊断价值的。它偏高几乎总是指向det_db_unclip_ratio给得太大同一个字被两个框包含落库后表现为条款正文里出现重复片段。条款零识别率偏高则通常是预处理环节的问题C值给得太大浅色字被当成背景抹掉了把C从 15 降到 8 再跑同一批文件看指标是否回落。回到开头那份通知一个能直接落地的做法是先按第 2 章跑一遍全量探测把页分成原生文本页和需 OCR 页两类对需 OCR 的页用 300 dpi 渲染、det_db_unclip_ratio1.6起跑跑完之后只挑ocr_score低于 0.85 的页把该参数微调到 1.8 再单独重跑一遍用两次结果里置信度更高的那一版覆盖回去。这一步通常能把整体抽取质量再抬一截而重跑的量只有原来的百分之几。本文还有配套的精品资源点击获取
返回列表