OCR 书籍精校与阅读站
把扫描书籍的 OCR 结果,从「能看出是什么字」提升到「可以当正式文本读」,并交付本地网页。
v2.1 在 v2 流水线之上,新增两条能力:
- ⑥ 自动图表/照片裁切:当整本书每页都是一张满版扫描图、正文与图表混排时,用 OpenCV 把页内真实的图(曲线图/示意图/照片)抠出来,单独存 figures/,并把 bbox 写回 JSON。
- ⑦ 静态对照校对网页:零后端、单个自包含 HTML,左图(原扫描页)右文(OCR 文本),下方是裁切出的图,支持翻页/跳页/章节目录/搜索/标记问题,供人眼逐页校对。
> 两种书型请选对流水线:
> - 图文分页型(如《88个最高效记忆方法》):有整页纯插图页,用 ③ 插图分类把文本页扫描图归档,阅读站只显插图页。
> - 满版扫描混排型(如《储能原理与技术》):每页都是满版扫描、正文里嵌图,用 ⑥ 裁切把图抠出来,再用 ⑦ 校对网页逐页对照。
何时使用
- 手头有整本书的扫描 PDF(每页是扫描图、原文件无文字层)
- 需要批量修正识别错误、补缺字、理顺被打乱的语序
- 想要一个能对照原扫描页、边读边改并落库的校对器(⑦ 静态网页,或 v1 服务端版),或一个能连续滚动、带目录/方法/关键词跳转、可记录阅读进度的阅读站(⑤)
整体架构(流水线)
扫描PDF ──①抽取──▶ text.json(每页 text + images[满版扫描相对路径])
──②校正──▶ 原地校正 text + corrections_log.json(审计)
──③插图分类──▶ 仅保留纯插图/照片页,文本页图归档(图文分页型用)
──⑥图表裁切──▶ 从满版扫描抠出真实图 → figures/ + JSON.figures(混排型用)
──④目录重建──▶ 解析目录页 → data.js.toc(可跳转)
──⑤阅读站──▶ index.html + data.js + app.js(连续滚动阅读)
──⑦静态校对网页──▶ proofreader.html(自包含,左图右文+裁切图,零后端)
| 层 | 做什么 | 产物 |
|---|---|---|
| ① 抽取 | PyMuPDF 取每页嵌入图 + RapidOCR 识别;过滤推广水印;原子写;断点续跑 | text.json |
| ② 校正 | 去前 N 页 + 短语级高置信度词表替换 | 校正后 text + corrections_log.json |
| ③ 插图分类 | 纯插图/照片页保留,文本页整页扫描图移入归档 | 仅真实插图 |
| ⑥ 图片裁切 | OpenCV OTSU+形态学+连通域,抠出页内真实图/照片 | figures/ + JSON.figures |
| ④ 目录重建 | 解析目录页 / 运行页眉 → 章节+跳转目标 | data.js.toc |
| ⑤ 阅读站 | 连续滚动、首行缩进、目录/方法/关键词跳转、进度持久化 | 本地网页 |
| ⑦ 校对网页 | 对照原扫描页+裁切图,人工改/标记,回写 JSON | 单个 HTML |
关键设计:② 可以反复重跑而不会丢失人工成果——若采用「text 不可变 + corrected_text + proofed」模型,重跑时先保留 proofed=true 的页。本次对话为求简单采用了「原地改 text + corrections_log.json 审计」,阅读站/校对网页无需选字段。
环境与解释器(血泪陷阱)
- 固定并隔离解释器:用 managed Python 3.13 建 venv,全程锁定,不要中途换。
bash
C:/Users/wingstan/.workbuddy/binaries/python/versions/3.13.12/python.exe -m venv C:/Users/wingstan/.workbuddy/binaries/python/envs/default
C:/Users/wingstan/.workbuddy/binaries/python/envs/default/Scripts/python.exe -m pip install --timeout 120 pymupdf rapidocr-onnxruntime pillow opencv-python-headless numpy
> 本次《储能》对话用的是 Python 3.12(C:\Python312\python.exe),opencv/numpy/rapidocr/pymupdf 装在同目录 pkgs312。两种都行;⑥ 图表裁切只需 opencv-python-headless + numpy,与抽取解释器可共用。
-
Git Bash 下的路径必须用正斜杠:反斜杠会被 shell 吞掉,
C:\Users\...变成C:Users...导致command not found。统一写C:/Users/...或/c/Users/...。 -
pip 偶尔断 / 升级会把自己搞坏:网络不稳加
--timeout;若 pip 损坏用python -m ensurepip --upgrade救回,再重装目标包。 -
RapidOCR 模型内置,装完即可用,CPU 友好(每页 ~4–7s)。校验是否加载:
from rapidocr_onnxruntime import RapidOCR; RapidOCR()。 -
opencc 仅「繁→简」需要:本次是简体书,未用;繁体书先在抽取前转换。
-
PDF 渲染依赖
PyMuPDF(import fitz):缺它时优雅降级,关闭扫描页对照,其余照常。 -
⚠️ OpenCV 中文路径陷阱(⑥ 必踩):
cv2.imread(path)/cv2.imwrite(path, img)在含中文的路径上会静默返回None/ 不写文件,且不抛错,极难排查(表现为「0 张裁切 / 读不到图」)。必须改用cv2.imdecode/cv2.imencode:
```python
import numpy as np, cv2
def imread_cn(path):
# 用 numpy 读字节再 decode,绕过 cv2.imread 的中文路径 bug
with open(path, "rb") as f:
buf = np.frombuffer(f.read(), dtype=np.uint8)
return cv2.imdecode(buf, cv2.IMREAD_COLOR)
def imwrite_cn(path, img):
ok, arr = cv2.imencode(".png", img)
if not ok:
raise IOError("imencode failed: " + path)
with open(path, "wb") as f:
f.write(arr.tobytes())
return True
```
满版扫描图、裁切输出都在中文目录(如 E:/学习/.../extracted/)时,全程用这两个函数,不要碰 cv2.imread/imwrite。
① 抽取(extract_pdf.py 核心)
扫描 PDF 每页是一张嵌入图,无文字层。流程:取每页嵌入图 → RapidOCR 识别 → 过滤页脚水印 → 写出 JSON → 导出该页图为 images/page_NNN.jpg(混排型保留全部页扫描;图文分页型见 ③)。
import fitz, json, re
from rapidocr_onnxruntime import RapidOCR
engine = RapidOCR()
WATERMARK = ("慧眼看", "huiyankan") # 推广水印,识别时剔除
for i, page in enumerate(doc, start=1):
imgs = page.get_images(full=True)
xref = imgs[0][0]
raw = doc.extract_image(xref)["image"] # 整页扫描图 bytes
# 存为满版扫描(混排型:每页都留,供 ⑥ 裁切 / ⑦ 对照)
open(f"images/page_{i:03d}.jpg", "wb").write(raw)
# OCR
result, _ = engine(raw) # result: [[box, text, score], ...]
if result:
lines = [r[1] for r in result
if not any(w in r[1] for w in WATERMARK)]
text = "\n".join(lines)
else:
text = ""
out["pages"].append({
"page": i,
"text": text,
"images": [f"images/page_{i:03d}.jpg"], # 满版扫描列表
"figures": [] # ⑥ 裁切后填充
})
断点续跑 + 原子写:每页处理完即把整个 JSON 以 tmp 写出再 os.replace 覆盖,脚本被杀可重跑跳过已完成页(用 page 去重)。
注意:个别页(如末页)可能无嵌入图,直接渲染高分辨率 page.get_pixmap(matrix=fitz.Matrix(2,2)) 补全,保证「页数 ↔ 插图数」一致。
② 规则校正(correct_and_restructure.py)
三件一起做:去前 N 页、词典替换、插图重分类(图文分页型),并产出 corrections_log.json。
REMOVE_FIRST = 3 # 去原 PDF 前3页(封面/版权/目录前页)
CORRECTIONS = [
("记亿","记忆"),("印像","印象"),("自已","自己"),("己经","已经"),
("商费","商界"),("疲您","疲惫"),("周未","周末"),("知到","知道"),("眼晴","眼睛"),
("深人","深入"),("投人","投入"),("若千","若干"),("时侯","时候"),("究竞","究竟"),
("毕竞","毕竟"),("身休","身体"),("感概","感慨"),("连想","联想"),("现像","现象"),
("部份","部分"),("形像","形象"),("对像","对象"),("凭藉","凭借"),("决对","绝对"),
("上牛","上午"),("下牛","下午"),("中牛","中午"),("膏盲","膏肓"),("慷概","慷慨"),
("色采","色彩"),("精采","精彩"),("布骤","步骤"),("关连","关联"),("连系","联系"),
# …… 共约 60 条短语级高置信度混淆对
]
# 按长度降序,优先替换较长短语,避免被短词部分命中
CORRECTIONS.sort(key=lambda x: -len(x[0]))
COMP = [(w, re.compile(re.escape(w)), r) for w, r in CORRECTIONS]
log = []
for pg in pages:
t = pg["text"]
if not t.strip(): continue
new = t
for w, rx, r in COMP:
if w in new:
idx = new.find(w)
ctx = new[max(0,idx-12):idx+len(w)+12].replace("\n"," ")
log.append({"page": pg["page"], "wrong": w, "right": r, "context": ctx})
new = new.replace(w, r)
pg["text"] = new
# 去前 N 页 + 重新顺序编号
pages = [p for p in pages if p["page"] > REMOVE_FIRST]
for i, p in enumerate(pages, start=1): p["page"] = i
词典选型原则:只收「wrong 几乎从不作为正确词出现」的短语级混淆对,避免误改正确用词(详见下方「四条铁律」)。输出 corrections_log.json 供逐条复核。混排型(⑥)通常不做 ③ 的插图归档,保留 images 作对照底图。
数据结构约定
源:text.json(混排型推荐,满版扫描全留 + 裁切图)
{
"source_file": "储能原理与技术 扫描版.pdf",
"page_count": 310,
"pages": [
{
"page": 1,
"text": "OCR 文本…",
"images": ["images/page_0001_img_001.jpeg"],
"figures": [
{"path": "figures/page_0001_fig_001.png", "bbox": [72, 152, 297, 377], "caption": null}
]
}
]
}
images:列表,存该页满版扫描相对路径(混排型每页都有,供 ⑦ 对照)。figures:⑥ 裁切后填充,元素为{path(相对), bbox[x1,y1,x2,y2](原图像素), caption(可空)}。
变体(图文分页型,image 单数)
{ "page": 1, "text": "OCR 文本…", "image": "images/page_001.jpg" }
文本页 image 置空、整页扫描移入 images/_archive_textpages/(见 ③)。两种模型皆可,按书型取舍;⑥⑦ 流水线统一用上面的列表模型。
内嵌:data.js(避免 file:// 跨域,阅读站直接 <script> 引入)
window.BOOK_DATA = {
"meta": { "title": "…", "author": "…", "page_count": 310 },
"chapters": [ { "num": 1, "page": 10, "title": "第1章 绪论…" } ],
"methods": [ { "name": "联想记忆法", "page": 26 } ],
"tocPages": [4,5,6,7,8],
"toc": [ { "title": "第1章 …", "target": 10,
"methods": [ { "name":"…", "desc":"…", "page_label":2, "target":10 } ] } ],
"pages": [ { "page": 1, "text": "…", "images": ["images/page_001.jpg"], "figures": [] } ]
};
推荐模型(健壮,支持 idempotent 重跑):保留原始 OCR 于 text(只读基准),校正结果写 corrected_text,人工已校页标 proofed=true。阅读站读 corrected_text || text。本次对话为简洁采用「原地改 text + corrections_log.json 审计轨迹」,二者皆可。
③ 插图分类规则(图文分页型)
- 保留:校正后
text仍为空(无文字)的页 = 纯插图/照片页。 - 归档:文本页的整页扫描图(只是内容扫描,并非插图)→
image置空,图片移入images/_archive_textpages/(可恢复,不永久删)。 - 这样阅读站只在真正插图页显示图,文本页不显示「整页扫描」。
- 混排型(⑥⑦)跳过本步,保留全部
images作对照底图。
⑥ 自动图表/照片裁切(混排型核心)
目标:从满版扫描图中,把页内真实的图(曲线图、示意图、框图、照片)抠出来,过滤掉正文段落、标题、页眉页脚。输出 figures/page_{page:04d}_fig_{idx:03d}.png,并把 {path,bbox,caption} 写回 text.json 的 figures。
算法(crop_figures_v2.py 核心)
两路检测 + 合并 + 抑制文本:
import cv2, numpy as np, json
def detect_figures(gray):
h, w = gray.shape
page_area = h * w
# —— 行图画(线图/框图):OTSU 二值 + 形态学闭 → 连通域
_, bin_ = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)
k_close = cv2.getStructuringElement(cv2.MORPH_RECT, (15, 15))
bin_ = cv2.morphologyEx(bin_, cv2.MORPH_CLOSE, k_close, iterations=2)
bin_ = cv2.morphologyEx(bin_, cv2.MORPH_OPEN, (3,3), iterations=1)
# —— 照片(连续调):灰度落在 [30,220] 的掩膜
photo = cv2.inRange(gray, 30, 220)
photo = cv2.morphologyEx(photo, cv2.MORPH_CLOSE, (25,25), iterations=2)
photo = cv2.morphologyEx(photo, cv2.MORPH_OPEN, (5,5), iterations=1)
# 行高估计(用 OTSU 连通域里「瘦高、面积适中」的块)
n, _, stats, _ = cv2.connectedComponentsWithStats(bin_, 8)
chs = [s for s in stats[1:] if 20 <= s[cv2.CC_STAT_AREA] <= 2500
and 12 <= s[cv2.CC_STAT_HEIGHT] <= 80
and 0.25 <= s[cv2.CC_STAT_WIDTH]/max(1,s[cv2.CC_STAT_HEIGHT]) <= 8]
line_height = int(np.median([c[cv2.CC_STAT_HEIGHT] for c in chs]) * 2.3) if chs else int(h*0.03)
cand = [] # 候选框 [x1,y1,x2,y2]
# 行图画块
n, _, stats, _ = cv2.connectedComponentsWithStats(bin_, 8)
for s in stats[1:]:
x,y,bw,bh,a = s[:cv2.CC_STAT_AREA+1]
fill = a / (bw*bh)
aspect = bw/bh
if fill < 0.08 and aspect > 1.4 and a > page_area*0.018: # 文本块,丢弃
continue
if fill < 0.08 and a > page_area*0.12 and bh > line_height*2.5: # 大标题,丢弃
continue
if fill < 0.09 and aspect > 1.8 and bh < line_height*1.8: # 单行文字,丢弃
continue
cand.append([x, y, x+bw, y+bh])
# 照片块
n, _, stats, _ = cv2.connectedComponentsWithStats(photo, 8)
for s in stats[1:]:
x,y,bw,bh,a = s[:cv2.CC_STAT_AREA+1]
if a < page_area*0.002 or bw/bh > 6 or a > page_area*0.30 or bh > h*0.45 or (a/(bw*bh)) < 0.12:
continue
cand.append([x, y, x+bw, y+bh])
return merge_boxes(cand, h, w, page_area, line_height)
合并与最终过滤(merge_boxes):
def merge_boxes(cand, h, w, page_area, line_height):
merge_gap = int(min(h,w)*0.018) # 近邻直接并
fig_gap = int(min(h,w)*0.065) # 同图元素间距上限
cand.sort(key=lambda b:(b[1], b[0]))
merged = []
for b in cand:
hit = None
for m in merged:
# 行重叠足够 + 纵向间距小 → 并
ov = min(b[2],m[2]) - max(b[0],m[0])
if ov > 0.40*min(b[2]-b[0], m[2]-m[0]) and b[1]-m[3] < fig_gap:
hit = m; break
if b[1] <= m[3]+merge_gap and b[0] < m[2] and b[2] > m[0]:
hit = m; break
if hit:
hit[0]=min(hit[0],b[0]); hit[1]=min(hit[1],b[1])
hit[2]=max(hit[2],b[2]); hit[3]=max(hit[3],b[3])
else:
merged.append(b[:])
# 最终过滤
out = []
for (x1,y1,x2,y2) in merged:
bw, bh = x2-x1, y2-y1
if bw*bh < page_area*0.55: # 太小
continue
if bw/bh > 5.5: # 太扁(多为分隔线/页眉)
continue
if bw/bh > 2.2 and bh < line_height*1.5: # 宽而矮 → 正文行带,丢
continue
out.append([x1,y1,x2,y2])
return out
裁剪时按页边距裁掉装订/扫描黑边,再用 imwrite_cn 存盘:
mt, mb, ml = int(h*0.050), int(h*0.030), int(w*0.025)
for idx, (x1,y1,x2,y2) in enumerate(figs, 1):
x1=max(ml,x1); x2=min(w-ml,x2); y1=max(mt,y1); y2=min(h-mb,y2)
crop = img[y1:y2, x1:x2]
imwrite_cn(f"figures/page_{page:04d}_fig_{idx:03d}.png", crop)
page_json["figures"].append({"path": f"figures/page_{page:04d}_fig_{idx:03d}.png",
"bbox":[x1,y1,x2,y2], "caption": extract_caption(text)})
调参口诀(recall ↔ precision)
- 漏照片(如某页整页照片被当成大文本块丢弃):放宽面积上限
page_area*0.30、高度上限bh>h*0.45(本次 page 17 即此修)。照片掩膜阈值inRange(gray,30,220)偏保守,过曝/过暗照片可适当缩窗。 - 文本段落误判为图:收紧
fill(0.09→0.08)、加text-block(aspect>1.4 and area>page_area*0.018)与big-text(area>page_area*0.12 and bh>line_height*2.5)抑制;宽扁短带bw/bh>2.2 and bh<line_height*1.5直接丢。 - 整页被并成 1 个图:闭运算核从
35×35降到15×15、迭代 2;合并间距fig_gap不要过大。 - 跑完后统计每页裁切数 + bbox 面积占比,人工抽 5–10 页判召回/精度,再微调,不要盲调。
运行
cd E:/.../extracted
C:/Python312/python.exe crop_figures.py text.json figures --start 1 --end 310
# 输出:Done. Total crops: 703 / Pages with figures: 284
④ 目录重建(build_data.py 的 toc 部分)
目录页(如本书 pages 4–8)原文含「目录/Contents」页眉与 OCR 错乱页码。解析为结构化、可跳转的 TOC:
TOC_PAGES = [4,5,6,7,8]
GARBAGE = set("V¥N√《入X八九十了人体") # 页码前的 OCR 噪声字符
def clean_page(tok):
s = tok.strip()
if s and not s[0].isdigit() and s[0] in GARBAGE: s = s[1:]
m = re.search(r"(\d+)\s*$", s)
return int(m.group(1)) % 1000 if m else None # 去多余前导1(1127→127)
# 跳转目标:优先精准名称匹配内容方法;否则「印刷页码 + 偏移」回退
name2page = {m["name"]: m["page"] for m in methods} # 方法检测已排除目录页
offset = chap_by_num.get(1, 9) - 1 # 第一章正文≈印刷页1
# 章节范围校验:把 OCR 错位的页码拉回合理区间(142→42、470→70)
def clean_page_label(num, cnum):
lo = chap_by_num.get(cnum,1)-offset-4
hi = chap_next.get(cnum, TOTAL)-offset+4
while num > hi and num >= 100: num = int(str(num)[1:])
return num
要点:
- 方法检测时排除目录页本身,否则会把目录条目误判为书中方法(侧栏「记忆法」会脏)。
- 页码校正:剥离噪声符 → 按所在章节的页码范围把越界数字「去掉首位数」拉回(如 142→42、470→70、124→24)。
- 跳转目标:条目名与内容方法精确匹配最佳;描述性条目(如「衡量记忆力好坏的标准」)用 印刷页码 + offset 估算,落在该章正确区间即可。
运行页眉分章(混排型 / 无目录页时也适用)
满版扫描书常在每页顶部印「第N章 标题」运行页眉,且相邻页重复。据此分章:
import re
pat = re.compile(r'第([一二三四五六七八九十百零\d]+)章')
CN = {'一':1,'二':2,'三':3,'四':4,'五':5,'六':6,'七':7,'八':8,'九':9,'十':10,'百':100,'零':0}
def cn2int(s):
# 简版:处理「十/十一/二十一」等;数字直接 int
if s.isdigit(): return int(s)
n=0,unit=0; # 完整实现见脚本
...
runs = [] # (label, start_page, run_len)
cur=None; cnt=0
for pg in pages:
m = pat.search(pg["text"] or "")
lab = m.group(0) if m else None
if lab == cur: cnt += 1
else:
if cur is not None: runs.append((cur, start, cnt))
cur, start, cnt = lab, pg["page"], 1
if cur is not None: runs.append((cur, start, cnt))
# 只把「连续出现≥2页」的标签认定为章节起始,单页闪现的 OCR 错识标签自动忽略
chapters = [{"label": l, "first_page": s} for (l, s, c) in runs if c >= 2 and l]
> 经验:某页页眉被错识成「第5章」而前后页都是「第1章」时,该标签只出现 1 页,c>=2 直接滤掉,不会污染目录。
⑤ 阅读站(continuous-scroll reader)
index.html + styles.css + app.js + data.js,纯前端、零构建、可 file:// 直接打开。
核心:splitParagraphs 把 OCR 物理行合并为自然段落(顺带修掉「难/度」「深/入」这类被扫描行断开的词),并首行缩进。
var TERM = /[。!?!?;;::“””]$/;
function splitParagraphs(text) {
if (!text || !text.trim()) return [];
var lines = text.split(/\r?\n/), paras = [], cur = [];
for (var i = 0; i < lines.length; i++) {
var line = lines[i].trim();
if (line === "") { if (cur.length) { paras.push(cur.join("")); cur = []; } continue; }
cur.push(line);
if (TERM.test(line) && i < lines.length - 1) { paras.push(cur.join("")); cur = []; }
}
if (cur.length) paras.push(cur.join(""));
return paras.filter(function (p) { return p.trim(); });
}
阅读站功能:
- 连续滚动:一次 DocumentFragment 建完全书 DOM(约数百段 + 8 图),无逐屏虚拟化、无 scroll 重计算,避免卡顿。
- 跳转:目录/方法/关键词点击 → el.scrollIntoView。目录条目(.toc-item / .toc-chap-head)带 data-target;关键词搜索列页码+上下文片段。
- 目录重排:pages 4–8 渲染为正式目录(章标题 + 「方法名 … 页码 描述」可点行),其余目录页作「(目录·续)」占位。
- 阅读进度:IntersectionObserver 进视口即记 readSet;顶栏进度条 + 目录/方法前 ✓;localStorage 持久化(STORE_KEY 随大改升版本,避免旧页码串味);刷新自动恢复到上次位置。
- 插图:loading="lazy" 懒加载;顶栏「隐藏插图」全局开关(.no-image)。
- 章标题:章起始页渲染 第N章 … 大标题(h2.chapter-head),并打 chap-N 锚点。
- 裁切图(混排型):figures 非空时在段落末插 <figure> 缩略图灯箱,呼应 ⑥。
⑦ 静态对照校对网页(零后端)
用途:人眼逐页校对——左看原扫描页、右读 OCR 文本、下看 ⑥ 裁出的图,发现错字/漏字/图漏裁可在页面标记,结果存 localStorage(可选回写 JSON)。
为什么不用服务端:纯静态 file:// 直接双击打开,无需起服务、无跨域。数据用 <script type="application/json" id="bookdata">…</script> 内嵌(与 data.js 同理,规避 file:// 的 fetch 跨域)。
生成器(build_proof_html.py)要点:
import json
data = json.load(open("text.json", encoding="utf-8"))
raw = json.dumps(data, ensure_ascii=False)
raw = raw.replace("</", "<\\/") # 防 </script> 截断
html = """<!doctype html><html lang="zh"><head><meta charset="utf-8">
<title>校对 · {title}</title><style>{css}</style></head><body>
<header>…翻页/跳页/搜索…</header>
<main>
<section id="scan"><img id="pageimg" loading="lazy"></section>
<section id="text"><pre id="pagetext"></pre></section>
<section id="figs"></section>
</main>
<script type="application/json" id="bookdata">{data}</script>
<script>{appjs}</script></body></html>""".format(title=..., css=CSS, data=raw, appjs=APPJS)
open("proofreader.html", "w", encoding="utf-8").write(html)
- HTML 放在
extracted/内,相对路径images/...与figures/...才能直接解析。 - 渲染单页:
images[0]作满版对照图,figures渲染为缩略图画廊(点击放大)。 - 导航:上一页/下一页/页码跳转;
←→键盘翻页;顶部「章节」下拉(用 ④ 的运行页眉分章结果)。 - 搜索:在 OCR 文本里搜词 → 跳到首个命中页并高亮。
- 标记问题:每页「✓ 已校 / ⚠ 有问题」按钮,备注写
localStorage[page],顶栏汇总「已校 X/310,标记 Y」;可一键导出标记页清单(CSV/JSON)。 - 回写(可选):导出的标记清单交给
correct_and_restructure.py或人工在text.json上改。
> 本次《储能》对话未生成该 HTML(裁剪已完成,703 图 / 284 页有图),生成器脚本即上方模板,按需 python build_proof_html.py 即可产出 proofreader.html。
写规则的四条铁律(金科玉律,适用于 ② 与可选校对器)
1. 用足够长的上下文,别用单字
单字替换会误伤。("症疾","疟疾") 会把「重症疾病」改坏。加长上下文或用负向断言:
text = re.sub(r'(?<!重)症\s*疾', '疟疾', text) # 排除「重症疾病」
text = re.sub(r'(?<!胃)肠痛', '肠痈', text) # 排除穴位「胃肠痛」
2. 加规则前先查原始 text
曾写 ("支气管哮","支气管哮喘"),但原文本来就是「支气管哮喘」,规则又补「喘」,全书 16 处变成「支气管哮喘喘」。下手前先确认原文缺没缺字:
python scripts/scan_tokens.py book.json 支气管哮 --field text
3. 缺字和语序错乱,用「整句重建」
规则引擎搞不定单字插入,但可以整片替换。把缺字/乱序片段连同确定上下文一起换掉:
("饮是湿温证候", "小便黄赤为湿热证候。"),
("应考虑疾", "应考虑痢疾"),
("人参、桃各2钱", "人参、桃仁各2钱"),
4. 垃圾行直接置空
OCR 在插图、页眉页脚常吐无意义字串,映射到空串:
("贝量生未都曼业", ""), ("人第治业事斯黄义克会山命病主会普", ""),
排版核心:硬换行 reflow
OCR 按扫描行硬换行,直接渲染完全没法读。两种合并策略:
- 校对器(精确,满行判定):扫描行排满才换行,只有明显短于满行且以句末标点收尾的,才是段末。取本页行长 80 分位数为「满行宽度」:
javascript
const lens = raw.map(l=>l.trim().length).filter(n=>n>0).sort((a,b)=>a-b);
const fullW = lens.length ? lens[Math.floor(lens.length*0.8)] : 24;
const FULL_T = Math.max(11, fullW - 3);
if (END_PUNCT.test(buf) && !OPEN_TAIL.test(buf) && prevLen < FULL_T) flush();
- 阅读站(轻量,句末标点):本次对话用
splitParagraphs(见上),按句末标点切段,足够流畅且代码极简;对「难/度」类断词一并修复。若书有长条目/标题行,需保护结构(条目行(1)1.①【按】、标题行第三章〔治疗〕)独立成块。
验证陷阱:假阳性(永远看上下文)
扫描「残留坏词」时大部分报警是假的——替换后的正确长串仍包含原坏词作子串:
| 扫描到 | 实际内容 | 判定 |
|---|---|---|
不良性水肿 |
营养不良性水肿 | ✅ 假阳性 |
一对立统一关系 |
这一对立统一关系 | ✅ 假阳性 |
氟骨 |
氟骨症 | ✅ 假阳性 |
所以永远不要只看命中数,必须看上下文。scan_tokens.py 默认输出前后 20 字上下文。判定顺序:看上下文 → 确认是正确长串子串 → 标假阳性放过;只有孤立出现的才是真漏改。
⑥⑦ 交付前检查
# 页数完好、插图/裁切路径解析、图表数合理
python -c "
import json, os
d=json.load(open('text.json',encoding='utf-8'))
pages={p['page']:p for p in d['pages']}
print('pages:', len(d['pages']))
miss_img=[p['page'] for p in d['pages'] if p.get('images') and not os.path.exists(p['images'][0])]
miss_fig=[f['path'] for p in d['pages'] for f in p.get('figures',[]) if not os.path.exists(f['path'])]
tot_fig=sum(len(p.get('figures',[])) for p in d['pages'])
print('满版扫描缺失:', miss_img[:5], '...共', len(miss_img))
print('裁切图缺失:', len(miss_fig), '| 总裁切数:', tot_fig)
# bbox 合理性抽检
bad=[(p['page'],f['bbox']) for p in d['pages'] for f in p.get('figures',[]) if not all(isinstance(v,int) and v>=0 for v in f['bbox'])]
print('bbox 异常:', bad[:5])
"
阅读站另查:data.js 解析无误、images/ 仅留真实插图、进度键版本正确、本地服务 curl 全资源 200。校对网页另查:双击 proofreader.html 能开、翻页图片 200、标记可存 localStorage。
参考
references/pitfalls.md—— 踩过的坑与判定方法详解(v1)references/reader-app.md—— 校对器架构、API、前端结构说明(v1)- 本次对话产物示例(混排型 + ⑥⑦):《储能原理与技术》
E:\学习\explore\技能\储能原理与技术\extracted\(含text.json/images//figures/703 张 /figures_manifest.json) - 早期示例(图文分页型 + ⑤):《88个最高效记忆方法》
E:\学习\explore\技能\清华北大状元告诉你的88个最高效记忆方法 谭兴茂 北京理工大学 扫描版_提取\(含text.json/data.js/index.html/app.js/corrections_log.json/images/)
脚本清单
| 脚本 | 用途 |
|---|---|
scripts/extract_pdf.py |
① 抽取:PyMuPDF 取图 + RapidOCR,断点续跑、原子写 |
scripts/correct_and_restructure.py |
② 校正:去前 N 页 + 词典替换 + 插图重分类 + 审计日志 |
scripts/crop_figures.py |
⑥ 图表裁切:OpenCV OTSU+形态学+连通域,抠图写回 JSON |
scripts/build_data.py |
④ 目录重建:章节/方法检测 + TOC 解析(含运行页眉分章),生成 data.js |
scripts/build_proof_html.py |
⑦ 校对网页:内嵌 JSON 生成自包含 proofreader.html |
assets/app.js assets/index.html assets/styles.css |
⑤ 阅读站:连续滚动前端 |
scripts/rule_engine.py(v1) |
阶段1 规则校正引擎模板 |
scripts/dump_pages.py(v1) |
按页范围导出文本供审阅 |
scripts/scan_tokens.py(v1) |
全书坏词扫描(带上下文,假阳性判定用) |
scripts/reader_server.py(v1) |
可选服务端校对器后端(对照原扫描页,回写 JSON) |