扫描版PDF转可检索文档:PyMuPDF、OpenCV与PaddleOCR实战

扫描版PDF转可检索文档:PyMuPDF、OpenCV与PaddleOCR实战 简介南京大学《软件需求工程》期末考试试卷附部分答案面向软件工程专业学生及需求工程课程备考者。资源包仅含一个文件为PDF格式体积约2.9MB内容聚焦课程核心知识点覆盖需求获取、需求分析、需求建模、需求规格说明书编写、需求变更管理与需求验证等模块。具体涉及访谈、问卷、观察、用例分析等获取方法数据流图、UML用例图与活动图等分析工具状态机模型、类图等建模手段以及功能性与非功能性需求、需求优先级与范围管理、沟通协商等重要议题。题型通常结合案例分析、理论问答和实践应用能够帮助读者熟悉南大课程考核风格与答题逻辑部分附带答案便于自我检测和查漏补缺资源标签中的“软件框架”也提示试卷会涉及框架结构在需求工程中的组织与设计思路。目前已有2115人浏览学习适合期末复习和课程考核准备。1. 一份扫描版试卷 PDF先别急着用眼睛刷题拿到“南京大学《软件需求工程》期末考试试卷部分答案.pdf”这个文件第一反应是双击打开、逐页翻。翻到第三页就发现问题整份是扫描图文字一个都选不中想搜“功能需求”四个字PDF 阅读器直接提示未找到个别页面偏了十几度还有几页曝光过度灰底把宋体字的笔画吃掉了。这类扫描版试卷在课程资料里占了相当比例。这篇文章把处理流程拆开讲用 PyMuPDF 解析 PDF 的文字层把扫描页渲染成图片用 OpenCV 做歪斜校正和漂白加深用 PaddleOCR 做中文识别最后把流水文本整理成 Markdown 笔记并回写成带文字层的可检索 PDF。适合整理复习资料的学生也适合经常处理中文扫描件的工程师照着搭一套自己的 pdf 解析流水线。2. 先拆 PDF用 PyMuPDF 判断需求工程试卷是文字版还是扫描版再按 300 DPI 导出页面图片2.1 为什么第一步不是直接上 OCROCR 是最后一招不是第一招。原因很直接OCR 存在识别错误率需求工程试卷里的“需求规格说明书”“数据流图”这类术语一旦被识别错一个词后面整理笔记时很难察觉排查成本远高于重新识别。而 PDF 本身可能自带文字层很多试卷文件是从 Word 或 LaTeX 导出的文字层完好直接抽取就是 100% 准确的文本只有真正的扫描版才需要走 OCR。所以第一步是给 PDF 做“分层体检”按文字层长度把所有页面分成两类能直接提文本的页绝不浪费算力。2.2 用 page.get_text() 给每页做文字层体检PyMuPDF 是处理 PDF 最顺手的库打开文件、逐页取文本即可。下面这段代码把每页的文字层长度打印出来长度大于 0 说明该页是文字版等于 0 说明该页只有图片import fitz pdf_path 南京大学《软件需求工程》期末考试试卷部分答案.pdf doc fitz.open(pdf_path) print(总页数:, doc.page_count) for page_no in range(doc.page_count): page doc[page_no] text page.get_text(text).strip() print(fpage {page_no 1:02d}: 文字层长度 {len(text)})get_text(text)返回这一页所有文本按阅读顺序拼接的字符串用于判断“有没有文字层”已经足够。如果某几页长度为 0先确认这几页是否整页都是扫描图有些 PDF 会在图片底下叠一层 OCR 生成的透明文字那种页面的get_text能取到内容只是文字顺序可能乱需要配合坐标排序这就是 5.3 里往回写文字层的作用。另外可以用page.get_text(words)取带坐标的词列表观察文本块的整体偏移情况对判断页面是否歪斜也有参考价值。2.3 把扫描页渲染成 PNGDPI 不是越高越好确认是扫描页之后要把页面渲染成位图交给 OCR。渲染分辨率由缩放矩阵控制PDF 坐标系的基准是 72 DPIzoom 300 / 72就是按 300 DPI 输出zoom 300 / 72 mat fitz.Matrix(zoom, zoom) for page_no in range(doc.page_count): page doc[page_no] pix page.get_pixmap(matrixmat, alphaFalse) pix.save(fpage_{page_no 1:03d}.png)alphaFalse一定要写否则输出带透明通道的 RGBA 图后面cv2.imread读进来就是四通道数组灰度转换要额外处理。文件名用零填充的三位数字是为了让page_002.png按字典序排序时不会排到page_010.png后面后续批量 OCR 的循环依赖这个顺序。DPI 的选择是权衡不是越高越好常用取值参考下表使用场景DPI说明快速预览、版面粗看150单页几十 KB五号字以下开始糊标准中文 OCR300约 10.5pt 宋体是底线识别率可接受小字号、密排表格400单页约 1~2 MBOCR 耗时明显增加超过 400不建议模型输入端会缩放收益趋近于零实际处理时我一般先统一渲染 300 DPI 跑一遍 OCR检查有没有连续三行以上的错字或丢行有问题只把出错的页面提到 400 DPI 重跑而不是全部重来。上百页的文档这样做时间能省一大半。提示后续步骤都基于导出的 PNG原始 PDF 保持只读不在原文件上做任何写入避免损坏唯一一份资料。3. 中文 OCR用 PaddleOCR 把软件需求工程试卷图像变成可检索文本并调好 3 个关键参数3.1 为什么选 PaddleOCR 而不是 Tesseract 或在线转换中文 OCR 的可用选项不多。Tesseract 的 chi_sim 模型部署简单但对宋体小字号、密排标点和扫描噪点敏感同一页试卷往往要试好几组预处理参数才能有可用输出。在线 PDF 转 Word 工具只对带文字层的 PDF 有效扫描版传上去多数乱码而且整份试卷传到第三方服务器涉及隐私和版权问题。PaddleOCR 的 PP-OCR 系列模型对中文场景的针对性更强检测、识别、方向分类三段式结构里方向分类器能处理扫描时放反的页面整个模型在 CPU 上跑也够用。更重要的是它的输出带每个文本行的坐标这个坐标是后面区分题干、答案、页眉页脚的关键材料在线工具给不了。3.2 最小可跑代码安装、初始化、识别一张图先装依赖默认的 CPU 版足够处理扫描件pip install paddlepaddle paddleocr初始化时把语言设为简体中文打开方向分类器然后对页面图片逐张调用识别from paddleocr import PaddleOCR ocr PaddleOCR( use_angle_clsTrue, # 打开方向分类处理 180° 倒置页 langch, # 简体中文 show_logFalse # 关掉模型加载日志 ) result ocr.ocr(page_001.png, clsTrue) for line in result[0]: box, (text, score) line print(f{score:.2f} {text})box是文本行的四点坐标text是识别出的文本score是置信度范围 0 到 1。注意 PaddleOCR 的 API 在 2.x 和 3.x 之间有破坏性变更2.x 用ocr.ocr(图片路径, clsTrue)返回嵌套列表3.x 改成ocr.predict(图片路径)返回字典字段名变成rec_texts和rec_scores。网上搜到的代码两个版本混着用直接复制容易报错所以在封装函数里用hasattr(ocr, predict)做版本分支if hasattr(ocr, predict): r ocr.predict(page_001.png) texts, scores r[0][rec_texts], r[0][rec_scores] else: r ocr.ocr(page_001.png, clsTrue) texts [line[1][0] for line in r[0]] scores [line[1][1] for line in r[0]] for t, s in zip(texts, scores): if s 0.65: print(f{s:.2f} {t})3.3 实际要调的 3 个参数drop_score、det_db_thresh、use_angle_cls参数不是调得越多越好处理这类试卷时我一般只动下面三个参数默认值调整建议drop_score0.5背景噪点多时抬到 0.6~0.7过滤低置信行det_db_thresh0.3文本行检测置信度斜排、浅色文字降到 0.2use_angle_clsTrue长文档建议打开每页耗时增加约 15%drop_score过滤低分结果时要注意手写答案的置信度通常就在 0.4~0.7 之间正好落在阈值附近。所以对“部分答案”这份文件不要把低分文本直接丢掉而是单独落盘人工扫一眼再决定删不删。det_db_thresh管的是“哪里有一行字”调太低会把背景颗粒当成文字输出一堆空行试卷的试卷头、密封线、页脚页码都属于检测对象属于正常输出。3.4 批量跑完整个目录落盘成带页码的纯文本单张图识别没意义要处理的是整个页面目录。按文件名排序循环调用把每页分隔线和页码写进输出方便后面切题时定位from pathlib import Path def ocr_page(png_path): if hasattr(ocr, predict): r ocr.predict(png_path) return r[0][rec_texts], r[0][rec_scores] r ocr.ocr(png_path, clsTrue) return [line[1][0] for line in r[0]], [line[1][1] for line in r[0]] out_lines [] for png in sorted(Path(.).glob(page_*.png)): texts, scores ocr_page(str(png)) out_lines.append(f\n {png.stem} \n) out_lines.extend(f{t}\t{s:.2f} for t, s in zip(texts, scores)) Path(ocr_result.tsv).write_text(\n.join(out_lines), encodingutf-8)输出用 TSV 而不是纯文本因为“文本 置信度”按制表符分隔后可以直接拖进表格软件也方便脚本按列处理。OCR 环节常见的失败点竖排题号会被识别成带空格的字串公式和数据流图里的箭头符号大概率乱码红章覆盖区域的文字基本会错。这些留给人工校正即可OCR 的目标是“能搜到、能跳转”不是替代人工阅读。4. 图像预处理软件需求工程扫描件的歪斜校正、漂白加深与二值化参数4.1 什么时候需要动图像处理顺序是什么OCR 的瓶颈往往不在模型而在输入图像。扫描件常见三种毛病整页歪斜三五度的偏转就让文本行检测框斜着框错字率立刻上升曝光过度灰底和字迹粘在一起宋体的横细线被吃掉局部反光和阴影手机拍的试卷尤其明显。处理顺序是固定的先校正角度再增强对比度最后决定要不要二值化。顺序不能反——先二值化再旋转插值会把二值图边缘弄出锯齿状噪点识别效果反而更差。这一节就是把 PDF 工具箱里的“歪斜校正纠偏”“漂白加深清晰”两个功能用 OpenCV 手动展开好处是参数可控、能嵌入批量流程。4.2 用 Hough 变换投票估计倾斜角再做仿射旋转整页倾斜角度的估计常见做法是用霍夫直线检测找出文本行的走向再对角度做统计。需要修的角度一般在正负十度以内所以过滤掉明显不是水平线的结果取中位数最可靠import cv2 import numpy as np def estimate_angle(gray): edges cv2.Canny(gray, 50, 150) lines cv2.HoughLinesP(edges, 1, np.pi / 180, threshold200, minLineLength150, maxLineGap30) if lines is None: return 0.0 angles [] for line in lines: x1, y1, x2, y2 line[0] if abs(x2 - x1) 50: # 过滤表格竖线 continue angle np.degrees(np.arctan2(y2 - y1, x2 - x1)) if -15 angle 15: # 只保留接近水平的线段 angles.append(angle) return float(np.median(angles)) if angles else 0.0 def rotate_image(image, angle): h, w image.shape[:2] center (w // 2, h // 2) m cv2.getRotationMatrix2D(center, angle, 1.0) return cv2.warpAffine(image, m, (w, h), flagscv2.INTER_CUBIC, borderModecv2.BORDER_CONSTANT, borderValue255) gray cv2.imread(page_001.png, cv2.IMREAD_GRAYSCALE) angle estimate_angle(gray) rotated rotate_image(gray, angle) print(f估计倾斜角: {angle:.2f}°)过滤条件里全是细节。minLineLength150过滤过短的线段避免标点间的连线干扰投票abs(x2 - x1) 50过滤接近垂直的线段试卷里的表格竖线和密封线都是竖线不滤掉会严重拉偏角度中位数。borderValue255用白色填充旋转后四角的露白如果默认填黑后续二值化会把露白区域全变成前景色四边多出一圈黑条。4.3 漂白加深CLAHE 拉伸对比度再按需二值化角度修好后处理对比度。CLAHE 是限制对比度的自适应直方图均衡适合处理整页灰底但局部明暗不均的扫描件clipLimit控制拉伸强度调太大会放大噪点一般取 2.0~3.0clahe cv2.createCLAHE(clipLimit2.5, tileGridSize(8, 8)) enhanced clahe.apply(rotated) thresh cv2.adaptiveThreshold( enhanced, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 31, 15 ) cv2.imwrite(page_001_clean.png, thresh) cv2.imwrite(page_001_enhanced.png, enhanced)adaptiveThreshold的blockSize必须是非零奇数它决定每个像素的阈值参考邻域大小笔画越粗blockSize 越大C是常数项从邻域均值或标准差里减掉C越大背景越容易变白灰底去除越干净。不同页面状况的取舍可以参考这张表页面状况处理方案参数整体偏暗、灰底重CLAHE 增强clipLimit 2.5tile 8x8对比度差、笔画发虚自适应二值化blockSize 31~41C 12~18只是略歪、对比度正常只做 deskew倾斜角小于 0.5° 可跳过红章、红笔批注压字彩色通道分离HSV 空间 S 通道掩码后单独处理二值化后的图适合人工校对但不一定适合 OCR。我的习惯是同一页同时保留 enhanced 和 clean 两个版本先拿增强版跑 OCR遇到低分结果再用二值化版对照检查红章页面不做二值化红色印章在灰度图里的灰度和浅色字迹相近二值化会把字和章粘在一起。验证效果有个现成办法把同一页清洗前后的图分别 OCR对比输出行数和明显错字量清洗后应当多识别出几行且错字更少没有改善就回退参数不要迷信某一步是万能药。5. 把答案整理成 Markdown 笔记回写为带文字层的可检索 PDF5.1 用最小启发式把流水文本切回“题 答”OCR 输出是一长串流水文本没有结构。要变成能复习的笔记先得切回“第几题、题干、答案”的基本单位。常见做法是按题号模式切行正则匹配行首的数字加顿号或点号从命中行开始到下一个命中行之前的文本归为一题。需求工程试卷的题号风格常见“1.”“2、”“三、”混用所以用兼容正则import re block_re re.compile(r^\s*(\d{1,2})\s*[.、]) blocks [] current None with open(ocr_result.tsv, encodingutf-8) as f: for line in f: text, score line.rstrip(\n).split(\t) m block_re.match(text) if m and float(score) 0.6: current {no: m.group(1), lines: [text]} blocks.append(current) elif current: current[lines].append(text) print(切分出的题目块:, len(blocks))“题号 置信度大于 0.6”双重条件触发新块能过滤掉正文里恰好以编号开头的杂行。切分结果写进 Markdown 时每题当二级标题题干和答案用列表分层再顺手给“功能需求”“非功能需求”“用例”“数据流图”这类术语做标记复习时就能按考点过滤。原始 PDF 是图片动不了文本区间和坐标是自己提取的数据想怎么组合都行。5.2 回写成 PDFxelatex 配中文字体或浏览器打印整理好的 Markdown 要回到 PDF 形态方便手机看常见做法是 pandoc 加 xelatex。中文必须显式指定字体否则 PDF 里中文全是空白pandoc notes.md -o notes.pdf \ --pdf-enginexelatex \ -V CJKmainfontNoto Serif CJK SC \ -V geometry:margin2.5cmCJKmainfont填系统里已安装的中文字体名先用fc-list :langzh查可用字体。不想碰 LaTeX 的话把 Markdown 渲染成 HTML 后用浏览器打开CtrlP 调出打印对话框目标打印机选系统自带的 “Microsoft Print to PDF”页边距和缩放都能可视化调整这也是 PDF 虚拟打印最常见的一条路VSCode 装 Markdown PDF 插件导出走的是同一条路线。注意打印驱动的默认缩放是“适合页面”选择题多的版式建议改成 100%避免整体缩小后手机上看不清。5.3 验证回写结果进阶技巧是透明文字层生成后要验证产物确实带文字层而不是又变成图片。验证脚本和第二章的体检代码同源只是预期变成每一页的文字层长度都大于 0check fitz.open(notes.pdf) total sum(len(p.get_text(text)) for p in check) print(新 PDF 文字层字符数:, total)如果数字明显大于 0说明新 PDF 支持全文搜索和选中复制最初“搜不到、选不中”的问题闭环解决。想保留原卷扫描图又想可搜索还有一个进阶做法用page.insert_text(point, text, render_mode3)把 OCR 结果按坐标逐行写回原 PDF 页面渲染模式 3 的文字在阅读器里不可见但get_text(text)能取到文字层、原卷笔迹各司其职文件体积也比整页重渲染小得多。注意写入时要带上中文字体和 fontsize否则字符位置和字号对不上原图。本文还有配套的精品资源点击获取