ancient-ocr-viewer/docs/古籍OCR数据规则.md
Yuuko 1018416a7a Initial commit: Ancient OCR Viewer
- 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>
2025-11-19 16:59:40 +08:00

14 KiB
Raw Blame History

古籍OCR数据规则文档

一、基本概念

1.1 古籍版式

  • 古籍采用固定版式,每页 M列 × N行如10列×25行
  • 阅读顺序:从右往左,从上往下
  • 每列内文字连续排列,无空格(同列内不会出现空行)

1.2 物理列 vs 逻辑列

  • 物理列:页面上实际的一列位置(如"第3列"
  • 逻辑列JSON中 line_id 标识的一组字符

重要约束

  1. 一个物理列可能包含多个逻辑列line_id尤其是包含双行小字时
  2. 一个逻辑列永远不会跨越两个物理列 - 逻辑列完整地属于一个物理列
  3. 一个物理列内的多个逻辑列按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 聚合后的结构

一个物理列 = 若干个逻辑列的组合

可能的组合:

  1. 单个大字逻辑列
  2. 多个大字逻辑列(中间有空行)
  3. 大字逻辑列 + 小字右列 + 小字左列 + 大字逻辑列
  4. 其他组合...

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); // 字符的行号

步骤

  1. 聚合物理列按x坐标将逻辑列聚合到物理列

    • 例如物理列1 = [line_id=0, line_id=1, line_id=2, line_id=3]
  2. 合并字符:在物理列内,合并所有逻辑列的字符

  3. 处理双行小字识别并配对双行小字详见2.2节)

  4. 计算行号:为每个字符/配对计算行号

    for (item of items) {
        const row = Math.round(item.yCenter / cellHeight);
        grid[col][row] = item;
    }
    
  5. 检测空行:相邻字符行号差 > 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

问题:忽略了空列,导致位置完全错误

正确方法:基于列间距识别空列

步骤

  1. 计算所有物理列的x中心

    physicalColumns = [
      { xCenter: 2900, ... },
      { xCenter: 900, ... }
    ]
    
  2. 计算相邻物理列的间距

    gap1 = 2900 - 900 = 2000px
    
  3. 计算标准列间距

    // 方法1基于版式
    standardGap = canvasWidth / totalColumns = 3200 / 8 = 400px
    
    // 方法2基于实际数据更准确
    // 找出所有"正常间距"(不包含空列的间距),取平均值
    // 例如:多个列间距为 [300, 320, 310, 2000]
    // 过滤掉异常大的 2000平均 = 310px
    
  4. 识别空列

    if (gap > standardGap * 1.5) {
        // 这是一个大间距,中间有空列
        emptyColumns = Math.round(gap / standardGap) - 1;
    }
    
  5. 映射到网格列

    物理列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坐标小而是指在所有列的对齐关系中该列没有额外的前导空行。

正确方法:基于多列对齐关系

  1. 找到"参考基准列"

    // 找到所有列中第一个字y坐标最小的那一列
    const topMostY = Math.min(...physicalColumns.map(col => col.firstCharY));
    
  2. 计算每列相对偏移

    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}个空行`);
        }
    }
    
  3. 填充网格

    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坐标可能略有不同

处理方法

  1. 识别连续的两个"全小字"逻辑列按line_id顺序
  2. 第一个逻辑列 = 右列,第二个逻辑列 = 左列
  3. 各自按y排序然后配对右列第i个 配 左列第i个
  4. 配对后,取右字的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数据的保证人工校对后

  1. 字符顺序正确:按阅读顺序排列
  2. 坐标准确:与物理页面一一对应
  3. line_id正确:正确分组,不会搞错列
  4. 小字配对正确:右列在前,左列在后
  5. 不会出现异常情况
    • 左列不会比右列多
    • charMarking只有空/非空
    • 同列内无空格

八、待处理问题

  1. 物理列起始位置推断:如何判断从第几列开始
  2. 合并阈值自适应:不同分辨率可能需要不同阈值
  3. 版式参数传入:如何配置列数和行数

九、示例分析

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大字