- Canvas-based dual display (image + text) - Grid rendering system with layout support - Uniform font size rendering - Double-line small character handling - Comprehensive documentation of OCR rules and algorithms 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
14 KiB
14 KiB
古籍OCR数据规则文档
一、基本概念
1.1 古籍版式
- 古籍采用固定版式,每页 M列 × N行(如10列×25行)
- 阅读顺序:从右往左,从上往下
- 每列内文字连续排列,无空格(同列内不会出现空行)
1.2 物理列 vs 逻辑列
- 物理列:页面上实际的一列位置(如"第3列")
- 逻辑列:JSON中
line_id标识的一组字符
重要约束:
- 一个物理列可能包含多个逻辑列(line_id),尤其是包含双行小字时
- ⭐ 一个逻辑列永远不会跨越两个物理列 - 逻辑列完整地属于一个物理列
- 一个物理列内的多个逻辑列按y坐标排列,可能之间有空行
1.3 大字与小字
- 大字:正文字符,
charMarking = [](空数组) - 小字:双行注释/夹注,
charMarking = [0](非空数组) - 判断方式:只需判断空/非空,不会有其他复杂情况
二、双行小字规则
2.1 基本概念
双行小字是古籍中的夹注,特点:
- 字高与大字相同(不是缩小的字)
- 一个大字格子竖向从中间分成两半
- 右半格一个字,左半格一个字
- 两个小字共占一格
2.2 在JSON中的表示
双行小字在JSON中是两个独立的逻辑列:
- 右列:先出现的逻辑列(line_id较小)
- 左列:后出现的逻辑列(line_id较大)
示例(0011B.json):
line_id=1: "舊艸堂" (大字,3个)
line_id=2: "元錢惟善..." (小字右列,14个)
line_id=3: "自號曲江..." (小字左列,13个)
line_id=4: "裏橫河橋..." (大字,25个)
2.3 配对规则
- 右列第1字 配 左列第1字 → 第1格
- 右列第2字 配 左列第2字 → 第2格
- ...
- 如果右列多出来,多出的字配空格(空格在左边)
- 不会出现左列比右列多的情况(º符号表示零次方,0在上/右边)
2.4 物理列内的结构表示
用数字表示一个物理列的内容:
0= 大字(占1格)1= 空格(占1格)8= 两个小字(占1格,因为8有两个0)º= 单个落单小字(占1格的半边)
示例:
00000000- 8个大字00088000- 3大字 + 2格双行小字(4个小字) + 3大字000888º0- 3大字 + 3格双行小字(7个小字,右4左3) + 1大字
三、JSON数据结构
3.1 关键字段
{
"FileName": "0011B",
"Width": 3120, // 图片宽度(像素)
"Height": 6004, // 图片高度(像素)
"CharNumber": 186, // 总字符数
"LineNumber": 14, // 逻辑列数(line_id数量)
"chars": ["聞", "賦", ...], // 字符数组
"coors": [[x1,y1,x2,y2], ...], // 坐标数组
"charMarking": [[], [], [0], ...], // 大小字标记
"line_ids": [0, 0, 0, 1, 1, ...], // 逻辑列ID
"char_probs": [0.99, 0.98, ...], // 识别置信度
"text": "完整文本..." // 带换行的文本
}
3.2 字段说明
charMarking
[]= 大字[0]= 小字- 只有这两种情况
line_ids
- 表示逻辑列的顺序编号
- 不代表物理位置(不是"第几列"的意思)
- 相同line_id的字符属于同一逻辑列
coors
- 格式:
[x1, y1, x2, y2] - 左上角 (x1, y1),右下角 (x2, y2)
- 坐标是人工校对后的准确值
四、物理列聚合规则
4.1 聚合依据
根据逻辑列的x坐标间隔判断是否属于同一物理列:
- 间隔小(如<150px)→ 同一物理列
- 间隔大(如>200px)→ 不同物理列
4.2 聚合后的结构
一个物理列 = 若干个逻辑列的组合
可能的组合:
- 单个大字逻辑列
- 多个大字逻辑列(中间有空行)
- 大字逻辑列 + 小字右列 + 小字左列 + 大字逻辑列
- 其他组合...
4.3 逻辑列类型判断
- 全是大字(charMarking全为空)→ 大字逻辑列
- 全是小字(charMarking全非空)→ 小字逻辑列(需要和相邻小字列配对)
五、网格填充的三大关键问题
5.1 问题1:逻辑列到物理行的映射
场景
版式是8列×20行,有11个逻辑列(line_id 0-10),总共103个字。 如何将这些逻辑列正确映射到20行的网格中?
关键约束
- ⭐ 一个逻辑列永远不会跨越两个物理列
- 人工校对保证了每列字符数不会超过版式限制
正确方法:基于y坐标计算行号
const cellHeight = canvasHeight / rowsPerColumn; // 每行的高度
const row = Math.round(char.yCenter / cellHeight); // 字符的行号
步骤
-
聚合物理列:按x坐标将逻辑列聚合到物理列
- 例如:物理列1 = [line_id=0, line_id=1, line_id=2, line_id=3]
-
合并字符:在物理列内,合并所有逻辑列的字符
-
处理双行小字:识别并配对双行小字(详见2.2节)
-
计算行号:为每个字符/配对计算行号
for (item of items) { const row = Math.round(item.yCenter / cellHeight); grid[col][row] = item; } -
检测空行:相邻字符行号差 > 1 说明中间有空行
示例
版式:20行,行高 = 6000px / 20 = 300px
页面高度:6000px
字符A: y=1500 → row = Math.round(1500/300) = 5 → 第5行
字符B: y=1800 → row = Math.round(1800/300) = 6 → 第6行
字符C: y=2400 → row = Math.round(2400/300) = 8 → 第8行(第7行空)
5.2 问题2:物理列到网格列的映射(空列识别)
场景
- line_id=0 对应物理第1列(最右)
- line_id=1 对应物理第8列(最左)
- 中间的2-7列是空的
如何正确映射到8列网格?
错误做法
// 按物理列顺序简单映射
物理列0 → 网格列0
物理列1 → 网格列1
问题:忽略了空列,导致位置完全错误
正确方法:基于列间距识别空列
步骤:
-
计算所有物理列的x中心
physicalColumns = [ { xCenter: 2900, ... }, { xCenter: 900, ... } ] -
计算相邻物理列的间距
gap1 = 2900 - 900 = 2000px -
计算标准列间距
// 方法1:基于版式 standardGap = canvasWidth / totalColumns = 3200 / 8 = 400px // 方法2:基于实际数据(更准确) // 找出所有"正常间距"(不包含空列的间距),取平均值 // 例如:多个列间距为 [300, 320, 310, 2000] // 过滤掉异常大的 2000,平均 = 310px -
识别空列
if (gap > standardGap * 1.5) { // 这是一个大间距,中间有空列 emptyColumns = Math.round(gap / standardGap) - 1; } -
映射到网格列
物理列0: x=2900 → 网格列0 // 间距2000px,约5个列宽,中间有4个空列 物理列1: x=900 → 网格列5 (0 + 1 + 4空列)
示例(0019A.json)
版式:8列 × 20行
物理列分布:
物理列0: x=2900 → 网格列0
物理列1: x=2600 → 网格列1 (间距300px)
物理列2: x=2300 → 网格列2 (间距300px)
物理列3: x=2000 → 网格列3 (间距300px)
物理列4: x=1700 → 网格列4 (间距300px)
物理列5: x=1400 → 网格列5 (间距300px)
物理列6: x=1100 → 网格列6 (间距300px)
物理列7: x=900 → 网格列7 (间距200px)
5.3 问题3:顶格判断与列内空行
3.1 顶格判断的正确方法
错误理解:
const row = Math.round(y / cellHeight);
if (row === 0) {
console.log("顶格");
}
问题:
- 页面内容不一定从y=0开始
- 实际内容可能从页面中间开始(y=1500)
- 第一个字y=1500,按公式row=5,但它实际上是该列的第一个字
⭐ 关键理解:
我们的数据是和真正的古籍一一对应的,一列的第一个字甚至有可能在页面中间继续。顶格不是指y坐标小,而是指在所有列的对齐关系中,该列没有额外的前导空行。
正确方法:基于多列对齐关系
-
找到"参考基准列"
// 找到所有列中,第一个字y坐标最小的那一列 const topMostY = Math.min(...physicalColumns.map(col => col.firstCharY)); -
计算每列相对偏移
for (col of physicalColumns) { const firstCharY = col.chars[0].yCenter; // 该列第一个字 const offsetRows = Math.round((firstCharY - topMostY) / cellHeight); if (offsetRows === 0) { console.log(`列${col.index}是顶格`); } else { console.log(`列${col.index}前面有${offsetRows}个空行`); } } -
填充网格
for (item of col.items) { const absoluteRow = Math.round(item.yCenter / cellHeight); grid[gridCol][absoluteRow] = item; }
3.2 列内空行检测
场景:一个物理列包含多个逻辑列
物理列1:
line_id=2: 10个大字
line_id=3: 7个大字
问题:line_id=2 和 line_id=3 之间有没有空行?有几个?
方法:合并后按y排序,检查相邻字符的行号差
// 1. 合并所有字符
allChars = [...line_id_2的字符, ...line_id_3的字符];
// 2. 按y坐标排序
allChars.sort((a, b) => a.yCenter - b.yCenter);
// 3. 检测空行
for (let i = 0; i < allChars.length - 1; i++) {
const currentRow = Math.round(allChars[i].yCenter / cellHeight);
const nextRow = Math.round(allChars[i+1].yCenter / cellHeight);
const gap = nextRow - currentRow - 1;
if (gap > 0) {
console.log(`'${allChars[i].char}'(第${currentRow}行) 到 '${allChars[i+1].char}'(第${nextRow}行) 之间有${gap}个空行`);
}
}
3.3 双行小字内部的处理
重要:同一格内的双行小字(左右两个字),它们的y坐标可能略有不同
处理方法:
- 识别连续的两个"全小字"逻辑列(按line_id顺序)
- 第一个逻辑列 = 右列,第二个逻辑列 = 左列
- 各自按y排序,然后配对:右列第i个 配 左列第i个
- 配对后,取右字的y坐标作为这一格的行号
// 右列line_id=2: [字A(y=1500), 字B(y=1800)]
// 左列line_id=3: [字C(y=1510), 字D(y=1790)]
配对1: [A|C] → y=1500 (取右字) → row=5
配对2: [B|D] → y=1800 (取右字) → row=6
5.4 完整的网格填充算法
算法流程:
1. 按line_id分组 → 得到逻辑列列表
2. 按x坐标聚合 → 得到物理列列表
- 间隔 < 150px:同一物理列
- 间隔 >= 150px:不同物理列
3. 计算列间距 → 识别空列 → 映射到网格列
- 计算标准列间距
- 识别大间距(包含空列)
- 映射:物理列索引 → 网格列索引
4. 处理每个物理列:
4.1 识别并配对双行小字
- 找连续的两个"全小字"逻辑列
- 第一个=右列,第二个=左列
- 按y排序后配对
4.2 合并所有项(大字 + 小字配对)
4.3 按y坐标排序
4.4 为每个项计算行号
row = Math.round(yCenter / cellHeight)
4.5 检测空行
相邻项行号差 > 1
4.6 填充网格
grid[gridCol][row] = item
5.5 0019A.json 实际案例
版式:8列 × 20行
正确的填充结果
物理列1 [line_ids: 0,1,2,3] → 网格列0:
格式: 11111800000000000011
第0-4行: 空
第5行: 8 (双行小字 [左|手])
第6-17行: 0 (大字)
第18-19行: 空
物理列2 [line_id: 4] → 网格列1:
格式: 11111000000000111111
第0-4行: 空
第5-12行: 0 (大字13个)
第13-19行: 空
物理列3 [line_id: 5] → 网格列2:
格式: 11111100000001111111
第0-5行: 空
第6-11行: 0 (大字10个)
第12-19行: 空
... (其他列类似)
关键点:
- 所有列第一个字的y坐标都在1400-1650范围
- 通过对齐关系判断:大部分列从第5行开始(前4行空)
- 物理列3从第6行开始(前5行空)
- 列内通过y坐标计算每个字的确切行号
- 相邻字行号差>1表示有空行
六、渲染规则
6.1 字号
- 大字:标准字号(基于格子高度计算)
- 小字:标准字号 × 0.5(宽度只有一半,字号相应缩小)
- 所有字符使用统一字号
6.2 位置
- 大字:格子中心
- 小字右:格子右半边中心(x + width*0.75)
- 小字左:格子左半边中心(x + width*0.25)
6.3 列方向
- 从右往左排列
- 第0列在最右边,第9列在最左边(10列版式)
七、数据保证
以下是JSON数据的保证(人工校对后):
- 字符顺序正确:按阅读顺序排列
- 坐标准确:与物理页面一一对应
- line_id正确:正确分组,不会搞错列
- 小字配对正确:右列在前,左列在后
- 不会出现异常情况:
- 左列不会比右列多
- charMarking只有空/非空
- 同列内无空格
八、待处理问题
- 物理列起始位置推断:如何判断从第几列开始
- 合并阈值自适应:不同分辨率可能需要不同阈值
- 版式参数传入:如何配置列数和行数
九、示例分析
0011B.json 分析
版式:10列 × 25行
逻辑列结构:
line_id=0: 大字25个 "聞賦觀濤..."
line_id=1: 大字3个 "舊艸堂"
line_id=2: 小字14个 "元錢惟善..." (右列)
line_id=3: 小字13个 "自號曲江..." (左列)
line_id=4: 大字25个 "裏橫河橋..."
line_id=5: 大字3个 "夜夜明"
line_id=6: 大字25个 "艸船紙馬..."
line_id=7: 大字3个 "鬧黃昏"
line_id=8: 小字10个 "杭俗信鬼..." (右列)
line_id=9: 小字9个 "大街小巷..." (左列)
line_id=10: 大字25个 "松木場前..."
line_id=11: 大字3个 "不倒翁"
line_id=12: 大字25个 "城郭迴環..."
line_id=13: 大字3个 "十景圖"
物理列聚合(10个物理列):
物理列1: [0] - 25大字
物理列2: [1,2,3] - 3大字 + 14格双行小字(27小字)
物理列3: [4] - 25大字
物理列4: [5] - 3大字
物理列5: [6] - 25大字
物理列6: [7,8,9] - 3大字 + 10格双行小字(19小字)
物理列7: [10] - 25大字
物理列8: [11] - 3大字
物理列9: [12] - 25大字
物理列10: [13] - 3大字