# 古籍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 关键字段 ```json { "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坐标计算行号 ```javascript 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. **计算行号**:为每个字符/配对计算行号 ```javascript 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列网格? #### 错误做法 ```javascript // 按物理列顺序简单映射 物理列0 → 网格列0 物理列1 → 网格列1 ``` **问题**:忽略了空列,导致位置完全错误 #### 正确方法:基于列间距识别空列 **步骤**: 1. **计算所有物理列的x中心** ```javascript physicalColumns = [ { xCenter: 2900, ... }, { xCenter: 900, ... } ] ``` 2. **计算相邻物理列的间距** ```javascript gap1 = 2900 - 900 = 2000px ``` 3. **计算标准列间距** ```javascript // 方法1:基于版式 standardGap = canvasWidth / totalColumns = 3200 / 8 = 400px // 方法2:基于实际数据(更准确) // 找出所有"正常间距"(不包含空列的间距),取平均值 // 例如:多个列间距为 [300, 320, 310, 2000] // 过滤掉异常大的 2000,平均 = 310px ``` 4. **识别空列** ```javascript if (gap > standardGap * 1.5) { // 这是一个大间距,中间有空列 emptyColumns = Math.round(gap / standardGap) - 1; } ``` 5. **映射到网格列** ```javascript 物理列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 顶格判断的正确方法 **错误理解**: ```javascript const row = Math.round(y / cellHeight); if (row === 0) { console.log("顶格"); } ``` **问题**: - 页面内容不一定从y=0开始 - 实际内容可能从页面中间开始(y=1500) - 第一个字y=1500,按公式row=5,但它实际上是该列的第一个字 **⭐ 关键理解**: > 我们的数据是和真正的古籍一一对应的,一列的第一个字甚至有可能在页面中间继续。顶格不是指y坐标小,而是指在所有列的对齐关系中,该列没有额外的前导空行。 **正确方法:基于多列对齐关系** 1. **找到"参考基准列"** ```javascript // 找到所有列中,第一个字y坐标最小的那一列 const topMostY = Math.min(...physicalColumns.map(col => col.firstCharY)); ``` 2. **计算每列相对偏移** ```javascript 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. **填充网格** ```javascript 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排序,检查相邻字符的行号差 ```javascript // 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坐标**作为这一格的行号 ```javascript // 右列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 完整的网格填充算法 ```javascript 算法流程: 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大字 ```