第一次打开CASIA OLHWDB里那个.pot文件时我盯着满屏的数字看了好几分钟一度以为自己下载错了文件。这份在线手写汉字数据集在文字识别、笔迹分析领域使用频率很高但真正把.pot文件解析明白的中文资料却不多——能搜到的示例代码大多面向老式C字段含义也经常语焉不详。这篇就围绕.pot文件本身把它的存储结构、字段语义、Python解析方案、可视化验证和几个典型的坑一次性讲清楚省得你再在数据加载这一步浪费时间。1. .pot文件到底装了什么索引与数据的分层结构1.1 先给.pot扩展名正名看到.pot就别用办公软件去试了它和PPT模板没有半点关系。在CASIA OLHWDB里.pot是纯文本格式的缩写里面存放的是在线手写笔迹的原始记录。所谓“在线”意思是坐标点是边写边采集的笔尖在电磁屏上每个采样时刻的位置都被记录下来与之相对的“离线”数据则通常把笔迹渲染成图像。正因为有了时间维度和笔迹顺序.pot才能用于在线手写识别、笔画顺序分析这类任务。打开一个.pot文件你会看到的就是几十上百万行数字和汉字字符。文件整体分为两大部分前半部分是索引区后半部分是数据区。索引区记录这个文件里包含了哪些汉字类别、每类有多少份样本数据区则按行存放每份样本的具体笔迹。这种“索引数据”的组织方式和图书目录是一个道理索引负责调度查找数据负责承载主体。1.2 一份样本的层级字符、笔画、点.pot文件里每份样本的层级关系非常清晰从大到小是“字符—样本—笔画—点”。举个例子写一个“一”字时笔尖从落笔到提笔是一个笔画这一个笔画在采集设备上留下几十上百个点如果起笔和收笔有轻微抖动或者中途顿了一下这一个字的笔画数量就可能变成2到3个。.pot把每个点都按先后顺序记录下来笔画之间用一个内部标记切分。具体到存储上一份样本的头部一行通常携带三个关键信息汉字标签、样本序号、笔画总数。接着依次列出每个笔画。每个笔画的起始行是该笔画的点数后续紧跟若干行每行对应一个采样点。点和点之间的顺序不能乱因为在线手写数据的研究价值恰恰体现在“谁先谁后”上——模型要学的就是这套轨迹。1.3 为什么文件头和索引区能帮上大忙索引区不仅告诉你这个文件有多少类别还让你在加载前就能粗略看到数据分布。比如某类的样本数是500另一类是50不用读完整份文件就知道样本不均衡。实际做训练集划分时我会先读一遍.pot的索引按类别样本数决定要不要做类均衡采样这样能省下不少后面的麻烦。索引的存在也给随机读取提供了可能。虽然.pot内部没有字节级的随机索引但解析到数据区时你完全可以只截取自己需要的类别而不是把整个文件全读进内存。对于动辄几十万样本的库来说这种按需加载的策略很实际。2. 字段语义拆解标签、坐标、时间戳、笔压都代表什么2.1 汉字标签字符本体还是Unicode码点大多数.pot文件的标签列直接用汉字字符本身读取方便肉眼检查也直观。但也有些分支版本或转换工具生成的.pot文件第一列写的是Unicode十进制码点比如“一”写成了19968。解析时如果只认汉字就会翻车。我建议解析器对标签列做一次自适应判断能转成数字就按Unicode码点处理再转回字符不能转成数字就原样保留。这样无论面对哪种版本最后你拿到的都是统一的字符结果。2.2 坐标设备原点、Y轴方向和分辨率点数据里最核心的就是坐标。CASIA OLHWDB的在线数据采集设备把页面左上角作为原点X轴向右Y轴向下。这个Y轴方向和数学课上习惯的方向相反可别小看这一点可视化时如果忘了翻转字体会出现上下镜像的效果。坐标单位一般是设备单位不同子集差异很大例如早期子集常见0到1023的范围较新的文本行子集可能到4096甚至更高。解析时不建议直接套用固定范围正确做法是把所有坐标读出来后看全局最大最小值再做归一化。坐标对应的是一块电磁数字化板上的物理位置同一份样本里相邻点之间的距离通常就是几到几十个单位。如果突然出现一千个单位的大跳变多半是数据本身有噪声要么是抬笔检测失败要么是采集过程中笔尖离开板面后又被误记录。这种异常点可以在解析阶段通过相邻点距离阈值过滤掉。2.3 时间戳和笔压容易被忽视的第三、第四维除了坐标在线手写数据通常还附带时间和笔压。时间戳的单位是毫秒方向是递增的它描述的是笔迹的书写速度。同一个字里写横画时如果时间戳间隔很密集说明这一笔写得快反之若点之间间隔大说明写得很慢。很多在线识别方法会把时间戳转换成点速度、加速度特征这一类动态特征是离线图像根本拿不到的。笔压字段表示书写时的下压力度数值越大表示按得越重。笔压受采集设备灵敏度影响不同子集的单位可能不同通常和坐标一样落在某个整数区间。需要提醒的是同一份文件里时间戳零点不一定是真实起点跨样本的时间戳更是没有对齐关系别把不同样本的时间戳当同一个时间轴来用。我一开始就踩过这个坑把两个样本的第一点时间设成同一原点去算相对时间差结果特征分布完全混乱原因就是样本内起始时间不是0。字段含义整理成一张表方便你写解析器时对照字段含义常见类型/范围注意事项标签汉字或Unicode码点str / int建议自适应样本序号同类内第几份样本int从1开始笔画数样本内含多少个笔画int可为1点数当前笔画采样点数量int通常大于5X坐标横向设备坐标int原点左上角Y坐标纵向设备坐标int向下增长时间戳采样时刻int(ms)样本内递增笔压下压力度int范围随设备3. 用Python写一个.pot解析器从零到可用的完整代码3.1 读取文件前先解决编码问题.pot文件在Windows环境下生成有一些还带中文标签所以读取时不能只赌一种编码。我自己的习惯是按UTF-8、GBK、GB2312的顺序依次尝试解码直到成功为止。UTF-8失败的概率很低但一旦失败后面的兜底策略能让你不至于在第一步就被卡住。from pathlib import Path def read_text_auto(path): p Path(path) for enc in (utf-8, gbk, gb2312, latin-1): try: return p.read_text(encodingenc) except UnicodeDecodeError: continue raise ValueError(f无法识别的编码: {path})读取时把空行去掉剩下的有效行按顺序处理。注意有些.pot文件的行尾是\r\n所以用splitlines()而不是按\n硬切能避免留下\r字符。3.2 解析索引区建立字符到样本数的映射拿到所有有效行之后第一件事就是读头部索引。如果文件首行是一个单独的数字说明它是类别总数随后紧跟的每一行是“标签 样本数”。把这两部分读出来就得到了一个字符标签到样本计数的映射同时也就知道了数据区的起点在哪一行。class OLPotSample: __slots__ (label, index, strokes) def __init__(self, label, index, strokes): self.label label self.index index self.strokes strokes3.3 完整解析函数索引与数据区一起处理下面这个函数把索引解析和整个.pot解析打包在一起返回索引字典和样本对象列表。def parse_olhwdb_pot(path): lines [ln.strip() for ln in read_text_auto(path).splitlines() if ln.strip()] if not lines: raise ValueError(文件为空) pos 0 index_map {} first_parts lines[0].split() if len(first_parts) 1 and first_parts[0].isdigit(): num_chars int(first_parts[0]) pos 1 for _ in range(num_chars): label_txt, count_txt lines[pos].split() index_map[_normalize_label(label_txt)] int(count_txt) pos 1 else: raise ValueError(不支持的.pot头部结构请确认文件版本) samples [] while pos len(lines): parts lines[pos].split() pos 1 if len(parts) 3: continue label _normalize_label(parts[0]) sample_idx int(parts[1]) stroke_num int(parts[2]) strokes [] for _ in range(stroke_num): if pos len(lines): break point_num int(lines[pos].split()[0]) pos 1 points [] for _ in range(point_num): nums lines[pos].split() pos 1 x int(nums[0]) y int(nums[1]) t int(nums[2]) if len(nums) 3 else 0 force int(nums[3]) if len(nums) 4 else 0 points.append((x, y, t, force)) strokes.append(points) samples.append(OLPotSample(label, sample_idx, strokes)) return index_map, samples def _normalize_label(txt): if txt.isdigit(): try: return chr(int(txt)) except ValueError: return txt return txt这个函数是整篇博文的核心。遇到点数行时严格转换其它行只要字段数少于预期就直接跳过尽量不让单行脏数据导致整个文件卡死。3.4 从原始数据到结构化字典JSON导出让后续处理更顺手解析器返回的是样本对象列表但很多后续工作根本不需要自己维护一个样本类直接转成dict就行。我习惯把它序列化成JSON结构方便在其它语言或数据处理平台之间流转。import json def pot_to_json(path, output_path): index_map, samples parse_olhwdb_pot(path) payload { index: index_map, samples: [ { label: s.label, index: s.index, strokes: [[list(pt) for pt in stroke] for stroke in s.strokes], } for s in samples ] } Path(output_path).write_text( json.dumps(payload, ensure_asciiFalse, indent1), encodingutf-8 )输出的JSON可以直接喂给PyTorch和训练脚本不用每次重新解析一遍原始文件。对于动辄几十万样本的数据库来说这个中间格式能帮你省下大量重复解析时间。3.5 用一个人造迷你文件验证解析逻辑代码写完后最好先拿一个小样本文件测试再上全量数据。下面是一个完全合法的最小.pot文件包含2个类别、3份样本2 一 2 丁 1 一 1 2 4 1 2 0 0 2 3 16 10 3 3 32 20 4 2 48 5 3 5 4 64 0 6 4 80 8 7 5 96 20 一 2 1 3 8 8 0 0 10 12 16 10 12 14 32 20 丁 1 2 2 10 1 0 0 20 1 16 0 4 10 3 32 0 20 5 48 0 21 4 64 0 22 4 80 0用解析函数跑一遍后断言索引、样本数和第一个点坐标都与预期一致。这种人造样例测试能让你在正式处理真实文件前确认边界逻辑没有大问题后续排查问题时也有个可复现的起点。4. 可视化验证解析结果对不对画出来一目了然4.1 单字轨迹绘制解析器跑通后绝对不能急着去训练模型先随手画几个样本出来看看。坐标和笔画顺序如果哪里不对画图是最直观的验证方式。import matplotlib.pyplot as plt def draw_olhwdb_sample(sample, output_path): fig, ax plt.subplots(figsize(4, 4)) for stroke in sample.strokes: xs [pt[0] for pt in stroke] ys [pt[1] for pt in stroke] ax.plot(xs, ys, colorblack, linewidth1.8) ax.invert_yaxis() ax.set_aspect(equal) ax.axis(off) fig.savefig(output_path, dpi150, bbox_inchestight, pad_inches0.05) plt.close(fig)这里有一个很小但很关键的细节ax.invert_yaxis()。不翻转的话你画出来的汉字是上下颠倒的。我见过不少人解析成功却画出一堆镜像字最后排查半天才发现只是忘了翻转Y轴。翻转之后字的形态、笔画结构、起笔收笔的位置关系就和实际书写一致了。4.2 批量抽样检查与统计核对单字没问题之后再从不同类别里各抽几个样本批量绘制存成一张张缩略图快速翻阅一遍。这一步能发现两类问题一类是数据本身有脏样本比如某个字符的笔画顺序明显乱套、点位全部堆在角落另一类是解析逻辑有边界bug某个类别的样本总能触发异常。同时做一个索引计数核对把解析出的样本按label计数和索引区的数值比对。如果对不上逻辑基本可以确定出了问题。比如索引说某类100个实际解析出99个那大概率是某个样本的笔画数或点数被解析错了导致后面样本错位。这种错位很隐蔽不画图不计数光看数字完全看不出来。4.3 坐标归一化与速度特征预览可视化除了能验证正确性还有一个实际用途检查坐标的分布范围。不同.pot文件的坐标量纲不一致直接送进神经网络会出问题。我看到数据后通常会做一个全局统计看X和Y各自的最小最大范围再决定归一化策略。常见的做法是把整个数据集的坐标统一缩放到0到1区间import numpy as np def normalize_points(samples): all_xy np.array([ [pt[0], pt[1]] for s in samples for stroke in s.strokes for pt in stroke ]) x_min, y_min all_xy.min(axis0) x_max, y_max all_xy.max(axis0) x_span max(x_max - x_min, 1e-6) y_span max(y_max - y_min, 1e-6) def norm(x, y): return (x - x_min) / x_span, (y - y_min) / y_span return norm归一化之后时间戳也可以按单位时间内的位移转成瞬时速度。别小看这个特征在线手写识别里速度和加速度往往比坐标本身更能描述书写习惯。可视化时按速度给轨迹染色一眼就能看出哪个笔画写得快、哪个笔画写得慢这种图在偏学术的场合也很有说服力。5. 实测踩坑与版本差异避开这些坑能少走一半弯路5.1 换行符、空行和“像数据又不像数据”的行.pot文件最烦人的地方不在于格式复杂而在于总有一些边界行长得不像规定的样子。首当其冲就是空行和纯空格行解析时必须过滤掉否则一个空行就足以让你的point_num读数错位。其次是行尾的\r用splitlines()处理就能规避但如果你按\n硬切就会在最后一列坐标里留下\r导致int()解析报错。还有一种情况是索引区结束后直接出现换行或者某个样本被分段写成了两行。这些都不是格式定义里的标准情况但真实数据里就是会遇到。处理策略是遇到点数行时才要求严格转换其它行只要字段数少于预期就直接跳过尽量不让单行脏数据导致整个文件卡死。5.2 笔画数为0或点数为0的脏样本理论上每个样本至少一个笔画、每个笔画至少一个点但实际数据库里就是会出现笔画数为0的样本。这可能来自采集时笔尖只触碰了一下板面就被判定为一次写操作或者设备抖动产生了空轨迹。解析时如果照常循环0次样本的strokes会是空列表后面读数据的指针不会错位但这类样本会污染统计指标训练前应当过滤掉。对于点数少于3的笔画我建议也一律丢弃。少于三个点的笔画没有任何几何形态可言既画不成一条线也算不出什么稳定的速度特征留着只会增加模型训练的噪声。这条规则加在过滤阶段不加在解析阶段保持解析器的纯粹性。5.3 OLHWDB 1.1与2.x系列的实际差异不同子集的.pot文件结构主体一致但细节有差异。1.1这种单字库结构相对简单每份样本对应一个独立字符2.0到2.2这种自然文本行库就不一样了样本对应的是连续书写的一整行文字内部可能还包含分隔符、多个字符对象的标注信息。解析2.x时不能天真地把每一行都当作单字样本否则字段数对不上标签也会变成整行文本。坐标范围也有差异。1系列的.pot点位普遍集中在几百到一千出头的区间2系列文本行的采集范围更大常见的坐标最大值可以到四千以上。如果你的解析器里写死了“坐标范围是0到1023”这样的假设换一个子集立刻翻车。所以我在解析器里从不校验坐标范围只做记录等统计阶段再判断。5.4 向INKML和SVG转换的思路.pot是特定数据集的自定义格式但很多第三方工具、在线书写应用更接受W3C的InkML标准。要想拿.pot里的数据去对接一套通用笔迹处理管线最好转成InkML。转换的思路非常直接把样本名映射成trace把每个笔画的点序列映射成trace里的点坐标串时间戳写进timestamp标签位。InkML的点字符串格式是空格分隔的“x y t f”和.pot数据区几乎是一一对应的转换代码并不难写。如果你更关心视觉呈现可以直接用前面的可视化代码把每个样本输出为SVG而不是PNG压缩后放进前端做汉字书写动画预览也非常顺手。SVG的好处是矢量格式缩放不糊适合做在线演示页面或数据集浏览器。5.5 反向写出.pot文件数据增强后重新入库解析不是单向的。做完数据增强比如对笔画做随机旋转、缩放或者局部抖动之后你大概率还想把增强后的轨迹重新保存成.pot方便和原始数据混合训练。逆方向写文件的结构和解析完全对称先写类别数和索引再逐样本写数据。from collections import Counter def write_olhwdb_pot(samples, output_path): counter Counter(s.label for s in samples) with Path(output_path).open(w, encodingutf-8) as f: f.write(f{len(counter)}\n) for label, cnt in counter.items(): f.write(f{label} {cnt}\n) for idx, s in enumerate(samples, 1): f.write(f{s.label} {idx} {len(s.strokes)}\n) for stroke in s.strokes: f.write(f{len(stroke)}\n) for x, y, t, force in stroke: f.write(f{x} {y} {t} {force}\n)这个函数写出来的文件能够被同一个解析器重新读回形成闭环。增强后的.pot文件可以和原始文件混在一起组织训练集也可以单独保存成一份新库。写这份解析器的时候我最大的体会是格式解析这件事代码只是最后十五分钟的工作前面花在弄清字段语义、做兼容处理上的时间才是大头。如果你准备拿CASIA OLHWDB做实验建议先把上面这套解析器存下来每次换新子集时先用它做一遍可视化抽样确认没问题再继续往后走。那次我被镜像字和错位样本磨到半夜的经历希望你永远不用体会。