
简介一套基于Python与机器学习技术的书籍内容文本识别项目源码面向希望学习光学字符识别、模型部署及Web后台开发的开发者与学生。项目完整集成了模型训练与识别代码、Flask后台服务和数据库管理识别出的文本可自动写入数据库实现从书籍图像到可检索文字的完整链路。压缩包共96个文件大小约97.08MB主要包含Python脚本、HTML/CSS/JS前端资源、SQL数据库脚本、txt样本语料以及CNN模型权重等目录划分清晰便于按模块查阅。已有88人学习下载。借助源码、测试脚本和分类书籍文本可快速理解深度学习文本识别模型的构建过程、Flask接口如何接收并调用识别服务以及数据库表结构设计与数据持久化方法也适合在此基础上进行二次训练与功能扩展。1. 一张书页图片到一条数据库记录这本书籍内容文本识别项目到底解决了什么纸质书扫描件、手机拍的页面照片、出版社给的 PDF 书影这些内容想变成能检索、能编辑、能入库的文本最省人力的路子不是让录入员逐字敲而是打通一条“图片 → 机器学习识别 → Flask 后台 → 数据库”的完整管线。这个 Python 项目做的就是这件事识别引擎把页面上的文字块检测出来并转成文本Flask 提供上传与查询接口识别出的文本连同页码、置信度一起写进数据库后续做知识库检索、内容校对、全文搜索都直接从库里取数不用再回看图片。这个方案适合正在做数字图书馆、个人知识库或内部 OCR 工具链的人如果你是刚完成机器学习入门、想学怎么用 Flask 部署模型的开发者按这套代码改也能少走弯路。下面从引擎选型拆到排障每步给直接可复制的实现。2. 识别引擎与管线先立住书籍文本识别不是把图丢给 OCR 那么简单2.1 引擎怎么选PaddleOCR、EasyOCR、Tesseract 的实际差距你手头有三类开源识别引擎可选。PaddleOCR 是百度开源的中文 OCR 工具链检测、方向分类、识别三个模型都是现成的对中文和复杂版面支持最好缺点是 PaddlePaddle 这套深度学习框架体积不小安装时要注意 Python 版本与 CUDA 的兼容关系。EasyOCR 基于 PyTorchpip install完就能跑支持语言多但识别速度比 PaddleOCR 慢对长文本和双栏版面的稳定性也弱一些。Tesseract 是老牌开源 OCR轻量、部署简单但中文识别准确率和版面理解能力明显不如前两者适合只识别印刷清晰的英文或简单中文场景。选型上我的建议很明确书籍场景默认选 PaddleOCR。原因有三条。第一书籍文本的主力是中文PaddleOCR 的中文识别准确率在开源方案里属于第一梯队。第二它自带文本检测模型能输出每个文字块的坐标和置信度这是后面按页码、按块存数据库的基础第三它对方向分类竖排、倒置页面有现成支持技术书和古籍扫描件经常出现这类情况。如果你只是临时识别几十页、不要求精细排版用 EasyOCR 也行但我不太推荐 Tesseract 做中文书籍语言包与排版顺序的坑会花掉你大量时间。2.2 识别管线四环节预处理、检测、识别、后处理顺序不能乱机器学习模型做得再准也架不住输入图片质量差。我把书籍识别的标准管线拆成四段预处理、文本检测、文本识别、后处理拼接。预处理做的是灰度化、二值化、去噪和倾斜校正。手机拍的书页经常有阴影和透视变形中值滤波能去掉扫描噪点OTSU 大津法自动计算二值化阈值把文字和背景分开方向分类器则负责把歪的页面转正。这些步骤不是玄学它们直接影响检测模型能不能框出完整的文字块。# preprocess.py import cv2 def preprocess_page(image_path: str) - str: 书籍页面预处理放大、去噪、二值化返回新图片路径。 img cv2.imread(image_path, cv2.IMREAD_GRAYSCALE) h, w img.shape # 低分辨率图先放大宽度目标 2000 像素 if w 2000: scale 2000 / w img cv2.resize(img, None, fxscale, fyscale, interpolationcv2.INTER_CUBIC) # 中值滤波去扫描噪点ksize 必须是奇数 img cv2.medianBlur(img, 3) # OTSU 自动算阈值二值化输出黑白图 _, binary cv2.threshold(img, 0, 255, cv2.THRESH_BINARY cv2.THRESH_OTSU) out_path image_path.rsplit(., 1)[0] _pre.png cv2.imwrite(out_path, binary) return out_path这段代码有四个参数值得你按自己扫描件实际情况调整。2000是目标宽度普通 300 DPI 的 A4 扫描件宽度在 2400 像素以上不需要放大手机照片常常只有 1200 像素左右放大到 2000 后识别率提升明显。medianBlur的核大小3对文字清晰的书页足够如果原图噪点很重可以改成5但核太大会把细笔画磨掉。THRESH_OTSU会自动算阈值适合背景均匀的扫描页如果页面有严重阴影还要再加一步顶帽变换才能分开背景。预处理完的图片建议单独存一份方便回头对比是预处理的问题还是模型的问题。文本检测负责在图片里找出“哪里有字”输出一串坐标框文本识别负责把每个框里的图像变成字符串后处理把识别结果按阅读顺序拼成段落。PaddleOCR 的ocr()接口把检测和识别串在一起但你要清楚它返回的数据结构每个元素是[坐标框, (文本, 置信度)]坐标框是四个顶点的 xy 坐标置信度是 0 到 1 的浮点数。这组数据不能直接丢进数据库得先做排序和过滤——很多人第一次写 OCR 脚本时不知道还有这一步入库后按页码抽文本时才发现段落顺序全乱了。2.3 版面分析小说容易识别、技术书难难点在阅读顺序书籍不是只有一种版面。纯文本小说是单栏正文检测完按从上到下、从左到右排就能得到和原书一致的段落。技术书和杂志就麻烦了标题、正文、代码块、图表注释混在一起检测模型按坐标输出文字块时常常先输出图片注释、再输出正文甚至把左右栏内容交叉串联。这个问题的根因不是识别模型不够好而是没有给输出施加“阅读顺序”约束。我一般用两种办法解决。第一种是后处理排序拿到所有文字块后按块中心点的 y 坐标从大到小排页面坐标系里 y 向下增大所以 y 大的是靠下的块y 相近的块再按 x 从小到大排这样能恢复多数单栏和双栏版面的阅读顺序。第二种是直接用 PaddleOCR 的版面分析能力对小标题、页眉页脚等区域做分类成本高通常只有要做精确版式还原的产品才投入。对大多数书籍数字化场景后处理排序已经够用。# sort_blocks.py def sort_blocks(raw_result): 把 PaddleOCR 的原始输出按阅读顺序排序。 blocks [] for item in raw_result: box item[0] # 四个顶点坐标 text, conf item[1] # 文本和置信度 xs [p[0] for p in box] ys [p[1] for p in box] blocks.append((sum(ys) / 4, # 中心 y sum(xs) / 4, # 中心 x text, conf)) blocks.sort(keylambda b: (b[0] // 20, b[1])) # 按 y 分行组内按 x 排 return [(b[2], b[3]) for b in blocks]排序函数里b[0] // 20是个经验值同一行文字块的中心 y 差距通常在 20 像素以内整除 20 能让同一行的块分到同一组。这个系数和字体大小强相关小五号字和标题的差距就很大如果你的页面字号跨度大改成按行高自适应分组更稳。排序后返回的列表里只剩文本和置信度方便直接传给下一步写库。这个排序逻辑对绝大多数横排的书籍已经够用竖排古籍不适用需要另按 x 从右往左处理。3. 建库、落库、搭 Flask识别结果怎么从内存走进数据库3.1 数据库表设计用 MySQL 存识别结果的可抄作业方案识别结果至少要存住五类信息书籍标识、页码、文字块序号、文本内容、置信度。为什么页码和块序号要单独成字段因为用户后续要“按页提取”“按块校对”如果只存一个大长文本定位成本会非常高。下面这张建表 SQL 是我常用的结构字符集必须用utf8mb4具体原因放避坑章讲。CREATE DATABASE IF NOT EXISTS book_ocr DEFAULT CHARACTER SET utf8mb4; USE book_ocr; CREATE TABLE IF NOT EXISTS book_text ( id INT AUTO_INCREMENT PRIMARY KEY, book_id VARCHAR(64) NOT NULL COMMENT 书籍唯一标识可用 ISBN, page_no INT NOT NULL COMMENT 页码从 1 开始, block_no INT NOT NULL COMMENT 页内文字块序号从 0 开始, content TEXT NOT NULL COMMENT 识别出的整段文本, confidence FLOAT NOT NULL COMMENT 该块识别置信度范围 0~1, need_check TINYINT NOT NULL DEFAULT 0 COMMENT 置信度低于阈值时置 1供人工校对, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_book_page (book_id, page_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这个表设计有两个关键点。book_id我建议放 ISBN 或统一编号而不是自增主键因为批处理同一本书的多页时按书检索的频率远高于按单条记录检索。idx_book_page是复合索引支撑两种高频查询按书取全部内容、按页码取单页文本。书籍数字化场景几乎只有这两种查询不需要再加别的索引。need_check字段是给置信度过滤预留的标记位进阶章节会用到。TEXT 类型足够存一个文字块的内容单页文本量再大也不会超过这个长度。3.2 识别并写入数据库同步版主流程代码下面是一段可以直接跑通的同步识别脚本。它读取单张书页图片调用 PaddleOCR 识别然后把每个文字块连同元数据插入 MySQL。这里先保留pymysql直连方式不引入 ORM目的是让你看清数据是怎么流动的ORM 版本放到下一小节。# sync_ocr_to_db.py import pymysql from paddleocr import PaddleOCR DB_CONFIG { host: 127.0.0.1, port: 3306, user: root, password: your_password, database: book_ocr, charset: utf8mb4, } def recognize_page_to_db(ocr, image_path: str, book_id: str, page_no: int) - int: 识别单页图片并把文字块写入数据库返回写入条数。 result ocr.ocr(image_path, clsTrue) conn pymysql.connect(**DB_CONFIG) written 0 try: with conn.cursor() as cursor: for block_idx, item in enumerate(result): content item[1][0].strip() confidence float(item[1][1]) if not content: continue cursor.execute( INSERT INTO book_text (book_id, page_no, block_no, content, confidence) VALUES (%s, %s, %s, %s, %s), (book_id, page_no, block_idx, content, confidence), ) written 1 conn.commit() except Exception: conn.rollback() raise finally: conn.close() return written if __name__ __main__: ocr PaddleOCR(use_angle_clsTrue, langch, show_logFalse) n recognize_page_to_db(ocr, ./sample_page.jpg, 978-7-0000-0000-0, 1) print(f写入 {n} 条文字块)代码里有三个参数需要你按实际拿捏。use_angle_clsTrue表示开启方向分类器能照顾歪着拍的页面和竖排书页代价是识别时间增加如果输入是排版端正的扫描 PDF可以关掉省时间。langch指定中文模型PaddleOCR 首次运行会自动下载对应模型文件离线机器要提前准备。clsTrue写在ocr()调用里才是把方向分类应用到这次识别如果这句忘写即使初始化时开了分类器实际也不会走分类流程。写入部分我特意做了两件事strip()清掉文本首尾空白空文本块直接跳过这两个细节能避免库里出现大量空白字符串影响后续全文检索。3.3 批量识别一本书按目录遍历并保持页码顺序单页识别跑通后批量处理是顺理成章的需求。常见的目录组织方式是把一本书的扫描页放在一个文件夹里文件名带页码比如book1_001.jpg、book1_002.jpg。遍历时要注意字符串排序问题os.listdir返回的文件名顺序是字典序2.jpg会排在10.jpg前面所以页码要用正则从文件名里抠出来转成 int 再排。# batch_recognize.py import os import re from paddleocr import PaddleOCR from preprocess import preprocess_page from sync_ocr_to_db import recognize_page_to_db def run_batch(book_id: str, page_dir: str): 批量识别一个目录下的所有书页图片。 ocr PaddleOCR(use_angle_clsTrue, langch, show_logFalse) files [f for f in os.listdir(page_dir) if f.lower().endswith((.jpg, .jpeg, .png))] files.sort(keylambda f: int(re.search(r\d, f).group())) for idx, filename in enumerate(files, start1): image_path os.path.join(page_dir, filename) processed_path preprocess_page(image_path) try: n recognize_page_to_db(ocr, processed_path, book_id, idx) print(f{filename} - {n} blocks) except Exception as exc: print(f{filename} - 失败: {exc})这里用re.search(r\d, f)提取第一个连续数字串作为排序依据。文件名如果包含书籍编号和页码两段数字这个方法会取到前者所以更稳的写法是严格匹配_(\d)\.这样的模式。enumerate(files, start1)让页码从 1 开始连续编号和表结构里的page_no对齐。单页失败了记下日志继续跑不要中断整本书这是批处理脚本的基本素养。3.4 用 SQLAlchemy 改造写入为 Flask 后台铺路直连方式适合脚本和批处理Flask 后台我更推荐换成 SQLAlchemy连接池、事务和模型定义都有人管不用自己拼 SQL。下面把写入逻辑改成 ORM 版本。# models.py from sqlalchemy import create_engine, Column, Integer, String, Text, Float from sqlalchemy.orm import declarative_base, sessionmaker engine create_engine( mysqlpymysql://root:your_password127.0.0.1:3306/book_ocr?charsetutf8mb4, pool_size5, echoFalse, ) Base declarative_base() class BookText(Base): __tablename__ book_text id Column(Integer, primary_keyTrue, autoincrementTrue) book_id Column(String(64), nullableFalse, indexTrue) page_no Column(Integer, nullableFalse) block_no Column(Integer, nullableFalse) content Column(Text, nullableFalse) confidence Column(Float, nullableFalse) Session sessionmaker(bindengine) def insert_blocks(book_id: str, page_no: int, blocks: list[dict]) - int: blocks 是 [{content: ..., confidence: ...}, ...] 的列表。 session Session() try: rows [BookText( book_idbook_id, page_nopage_no, block_noi, contentb[content], confidenceb[confidence], ) for i, b in enumerate(blocks) if b[content]] session.add_all(rows) session.commit() return len(rows) except Exception: session.rollback() raise finally: session.close()SQLAlchemy 版本的核心好处是session统一管理事务避免手动cursor.execute和conn.commit的遗漏。pool_size5是连接池大小多线程并发识别时这个值要跟着 Flask 的并发数调整连接池太小会报连接耗尽太大对 MySQL 是负担。echoFalse平时关掉 SQL 日志排查问题时改成True能看到每一条实际执行的语句。Base declarative_base()的写法在 SQLAlchemy 1.4 是标准姿势老项目里看到的declarative_base()也是同一个来源。4. Flask 后台把识别服务暴露成接口接口设计、异步任务与部署4.1 两个接口的分工上传和识别要拆开做成后台服务后得给调用方提供两个接口POST /upload负责接收上传的图片文件并保存到临时目录POST /recognize负责触发识别并把结果入库。为什么拆成两个如果合成一个接口上传大文件时连接要一直保持到识别结束用户体验差且容易超时。拆开后上传接口只做文件接收识别接口只看图片路径职责清晰也方便后面接任务队列。接口返回统一用 JSON至少包含task_id和status两个字段。task_id是这次识别任务的唯一标识调用方拿着它去查状态status有pending、running、done、failed四种流转。为什么不直接返回识别文本原因和上面一样识别是重计算读取结果应该由单独的查询接口负责而不是让识别接口承载全部职责。4.2 长任务处理同步识别会把 Flask 请求卡死如果你把识别直接写在视图函数里第一个调用会把 Flask 的工作线程占住几秒甚至几十秒并发一上来整个服务就不可用了。常见做法是把识别丢到后台线程或任务队列。生产环境我会用 RQ 或 Celery 配 Redis但小项目用一个线程池就能先跑通。下面是一段可运行的简化实现。# app.py import os import threading import uuid from flask import Flask, request, jsonify from paddleocr import PaddleOCR from models import insert_blocks app Flask(__name__) app.config[MAX_CONTENT_LENGTH] 16 * 1024 * 1024 # 上传上限 16MB UPLOAD_DIR ./uploads os.makedirs(UPLOAD_DIR, exist_okTrue) ocr PaddleOCR(use_angle_clsTrue, langch, show_logFalse) OCR_LOCK threading.Lock() TASKS {} def run_task(task_id: str, image_path: str, book_id: str, page_no: int): TASKS[task_id][status] running try: with OCR_LOCK: result ocr.ocr(image_path, clsTrue) blocks [ {content: item[1][0], confidence: float(item[1][1])} for item in result ] insert_blocks(book_id, page_no, blocks) TASKS[task_id][status] done except Exception as exc: TASKS[task_id][status] failed TASKS[task_id][error] str(exc) app.route(/upload, methods[POST]) def upload(): file request.files.get(file) if file is None: return jsonify({error: no file}), 400 task_id uuid.uuid4().hex save_path os.path.join(UPLOAD_DIR, f{task_id}_{file.filename}) file.save(save_path) book_id request.form.get(book_id, unknown) page_no int(request.form.get(page_no, 0)) TASKS[task_id] {status: pending, image_path: save_path} thread threading.Thread( targetrun_task, args(task_id, save_path, book_id, page_no), daemonTrue, ) thread.start() return jsonify({task_id: task_id, status: pending}), 202 app.route(/task/task_id) def task_status(task_id: str): task TASKS.get(task_id) if task is None: return jsonify({error: task not found}), 404 return jsonify({task_id: task_id, status: task[status]}) if __name__ __main__: app.run(debugFalse, port5000)这段代码有几个关键点。OCR_LOCK这个全局锁很必要因为 PaddleOCR 的ocr.ocr()调用不是线程安全的多个线程同时识别会互相干扰甚至报错加了锁之后并发识别能力归零但换来的是稳定。想要真正并发就得按线程各建一个 OCR 实例机器内存够就上CPU 不够还是会排队。TASKS字典存在进程内存里只适用于单进程部署用多 worker 启动时请求可能落到不同 workerTASKS互相不可见状态查询会 404所以生产环境必须换成 Redis 存任务状态。threading.Thread(daemonTrue)用守护线程执行识别主进程退出线程一起结束识别中如果进程被重启任务会丢这也是要接持久化队列的原因。4.3 Flask 部署前要改的默认配置不只是 debugFalse本地调试没问题到部署环节有三个默认配置必须动。第一个是MAX_CONTENT_LENGTHFlask 默认不限制上传大小不设上限会被大文件拖垮按书页图片规格设成 16MB 或 32MB 即可超过上限的请求会直接返回 413。第二个是debug生产必须debugFalse调试器会泄露代码与调用栈信息。第三个是不要用 Flask 自带的app.run()顶在线服务那个内置 server 性能差且不稳定。常见做法是用 waitress 启动from waitress import serve; serve(app, host0.0.0.0, port5000)Windows 和 Linux 都能跑部署省心。上传目录和数据库连接串也要提前规划。uploads目录不能放在程序目录里否则重启或更新代码会被清掉我一般写成绝对路径通过环境变量注入。数据库连接串同样走环境变量不要把密码写进源码。还有一点容易被忽略MySQL 的max_allowed_packet默认值只有 4MB传大图时插入 TEXT 字段可能会报 packet too large需要调大这个参数或者限制单张上传图片的大小。5. 书籍文本识别避坑指南六个现场与对应修复手段5.1 识别结果全乱码英文字母和数字都错现象识别出的中文全是无关字符英文单词字母错乱。原因最常见的是没装对应语言模型。Tesseract 默认只带英文识别中文需要额外下载chi_sim.traineddata放进tessdata目录PaddleOCR 如果lang写错或首次自动下载模型失败也会输出垃圾文本。解决确认引擎的语言包路径初始化时显式设置langch第一次跑通前不要开show_logFalse让日志把模型加载情况打出来。5.2 中文漏字严重长段文本丢了一半现象短标题识别正常长段落漏行、漏字。原因输入图片分辨率不够。手机拍的书籍照片常见文字在低分辨率下连成一片检测框框不住完整的文字块后面的识别自然残缺。解决预处理阶段把图片放大到宽度不低于 2000 像素或扫描时直接用 300 DPI。识别前先肉眼确认文字边缘清晰再进模型。这一步的性价比远高于调模型参数。5.3 代码块和公式识别率特别低现象技术书里代码和正文混排代码部分的缩进、括号、下划线全被识别成奇怪的字公式完全是乱码。原因OCR 模型对印刷体中文和英文效果好但对代码的等宽字体、下划线、大括号、数学符号支持很差。解决版面分析阶段把代码块切出来单独处理代码块要么放弃自动识别交给人工要么用专门的代码 OCR 方案公式部分建议直接留空并标记need_check1不要让错误文本进库污染检索结果。5.4 数据库里读出的中文全变成了问号现象MySQL 表里能查到数据但字段值是?????。原因建库或建表字符集不是utf8mb4或者连接串没带charsetutf8mb4。MySQL 默认的latin1存不了中文写入时就转成了问号。解决建表语句按 3.1 节写连接参数显式加charsetutf8mb4已经建错的库可以用ALTER TABLE book_text CONVERT TO CHARACTER SET utf8mb4;修复。注意这条语句只改表结构已入库的乱码问号救不回来所以在一开始建库就做对才是真正的后悔药。5.5 Flask 接口一调就超时前端一直转圈现象上传图片后请求十几秒不返回前端等不到结果。原因识别是 CPU 密集型计算同步跑在 Flask 视图里会把 worker 占住并发一高整个服务不可用。解决按 4.2 节改成任务队列加状态轮询。如果已经用了线程仍慢看 CPU 和内存是否打满PaddleOCR 在 CPU 机器上单页耗时通常在 3 到 10 秒属于正常水平要提速得换 GPU 推理或者换更轻量的识别模型。5.6 双栏或多栏版面识别顺序错乱现象A 栏第二行排到了 B 栏第一行前面整体文本没法读。原因PaddleOCR 输出的文字块顺序按检测框遍历顺序不是按阅读顺序。解决按 2.3 节的sort_blocks函数做后处理排序先按块中心 y 分行再组内按 x 排序。如果是中文竖排古籍排序规则要反过来按 x 从右往左这时use_angle_clsTrue的方向分类器能帮上忙但最终输出顺序仍要靠自己整理。6. 进阶用置信度量化识别质量别让脏文本悄悄入库识别服务上线后用户最常问的问题就是你凭什么说识别得准单看几页样本没有说服力得有一个可复用的量化手段。PaddleOCR 为每个文字块返回置信度这就是现成的质量信号。我习惯的做法是先定一个阈值比如 0.85低于阈值的文本块不直接入库而是打上need_check1标记之后用脚本把它抽出来交给人工校对。# checker.py from sqlalchemy import create_engine, text engine create_engine( mysqlpymysql://root:your_password127.0.0.1:3306/book_ocr?charsetutf8mb4 ) with engine.connect() as conn: conn.execute(text( UPDATE book_text SET need_check CASE WHEN confidence 0.85 THEN 1 ELSE 0 END )) rows conn.execute(text( SELECT id, page_no, block_no, content, confidence FROM book_text WHERE need_check 1 ORDER BY confidence ASC LIMIT 200 )).fetchall()阈值不要拍脑袋定。我会先随机抽 30 个文字块把置信度和人工校对结果放在一起对比看看这个阈值以下到底有多少比例是错字再决定用 0.8 还是 0.9。有些扫描件质量差阈值定高了会抽出几百个待校对块这是正常现象说明源头图像质量需要提升而不是模型有问题。另一个有价值的验证手段是抽检脚本随机取每页某个文字块把识别文本和原图区域拼成对比图人眼扫一遍就能判断这页是否达到交付标准。这个习惯我做了很久每次全量识别前先跑 30 页样本算平均置信度和错字率错字率超过千分之一就回头调分辨率或换预处理策略而不是盲目重跑全量。置信度只是代理指标真正要紧的是最终错字率两者结合才能把质量关把住。等你把这条“图片入库、置信度打标、按标记校对”的链路跑顺书籍内容文本识别就不再是黑匣子而是可量化、可复盘、可持续优化的工具链了。希望这些方案和踩过的坑能帮到你让书籍数字化的路走得稳一点。本文还有配套的精品资源点击获取