资讯详情

资讯详情

手写识别数据集.pot文件解析:从笔迹坐标到深度学习训练样本

做中文手写识别不管你是研究路线还是已经被业务需求按着头写代码大概率都会碰到 CASIA OLHWDB 这个名字。数据集虽好.pot 这套文件格式对新人极其不友好——它不是图片不是 JSON不是官方给好读接口的数据库打开编辑器全是“看不懂的字符”。我能理解那种状态模型一跑就缺数据数据明明就在服务器上但解析脚本却还没着落人先卡住了。这篇文章就是把你从“卡住”拉到“能动手”的过渡。我把自己解析 .pot 文件时踩过的坑、最后稳定使用的一套流程全部写出来代码也会给你可以直接复制下来跑。适合正在拿 OLHWDB 训练手写识别模型的人、做老系统数据迁移的人以及任何想从原始笔迹数据里提取坐标点给深度学习模型用的开发者。先说一下结论 .pot 文件本质是文本格式但其中中文标签的编码、笔段坐标的表示、样本之间的分隔符都会因为数据版本不同而有细微差异。读完下面这套流程你至少能在 30 分钟内产出一个能用的解析器并且不踩我当初那些坑。1. 别急着写代码先搞清楚 .pot 文件在整个数据集里的角色1.1 在线数据和离线数据并不是一回事OLHWDB 家族里数据分成“在线”和“离线”两类。离线数据是最直观的一张张已经写好的汉字图片你丢给 CNN 或者 OCR 模型直接训练就行。在线数据则不同它记录的是“这一笔是怎么写出来的”也就是原始笔迹轨迹。类比一下你就懂了离线数据是作文纸上的字而在线数据是那只笔在纸上从落到抬的所有运动轨迹点。每个轨迹点通常包含 x 坐标、y 坐标以及时间戳或压力值。 .pot 文件就是存放这些轨迹点的主要后缀之一。解析 .pot 文件的最终目标就是把这一串原始点还原成一段一段的笔画stroke再把所有笔画还原成一个又一个字符样本sample。后续不管是可视化、生成图像还是转成序列数据输入 RNN、Transformer都建立在这一层还原之上。如果这一步做错了后面所有训练数据都是错位的。1.2 先观察再写解析器拿到一个 .pot 文件我建议你先别急着在网上下载现成脚本。先花两分钟搞清楚你自己手里这份文件长什么样。马上在命令行里敲file sample.pot xxd sample.pot | head -n 30 strings sample.pot | head -n 30这三个命令的输出能回答三个问题文件是纯文本还是二进制里面能不能看到可读字符串中文标签有没有可能出现如果xxd显示一长串连续的 16 进制字节那你的 pot 可能是二进制变体如果能看到#、CASIA、SAMPLE这种关键词那么基本可以确定格式偏向于按行组织的文本。我实际处理过的 OLHWDB 在线数据大多是文本格式这也是后面解析思路能一直复用的基础。但不同版本身上的字段分隔符确实有差别别默认自己手里的一定和网上某个教程一致。最好的办法就是先把文件的“肉眼结构”看清楚再决定正则和读取方式。2. 文件内部拆解从字符串到结构化样本的四层结构2.1 头部信息行文本型 .pot 文件开头一般是若干行元信息行首最常见的记号是#或者CASIA-OLHWDB这样的数据集声明。我在不同版本的样本里见过这些字段#File、#DB、#Writer、#Samples、#Pen。它们的作用主要是说明来源解析时可以直接跳过但建议不要无脑丢因为像#DB能告诉你当前这个文件到底是哪个版本方便你在代码里做分支。注意一个很容易踩的坑头部信息行并不总是真的“注释”。有些文件里会有一行#LabelType: GBK之类的东西它其实是在告诉你后面文本的编码方式。如果你忽略这一行直接按 UTF-8 去解码后面的中文标签大概率会看到一堆乱码。正确做法是把头部行里的字段读取出来而不是简单当作注释丢弃。2.2 样本级记录头部之后就是一个一个样本记录。一个样本通常对应“一个汉字”或者“一个手写文本行”。区分两个样本的最好标志是样本起始标记。常见形式有#Sample: 12、#Char: 中、#Strokes: 3这种连续多行描述。我可以给你一个我在实际样本中见过的简化结构方便你建立直觉#Sample: 0 #Char: 中 #StrokeNum: 3 112,311 114,308 118,302 96,411 97,412 98,410 150,500 152,498 155,495先把这种结构记住第一行是样本编号第二行是字符标签第三行是笔画数量后面紧跟着的就是每个笔画的数据行。2.3 笔画数据行每个笔画数据行是一组点坐标。坐标点之间通常用空格分隔x 和 y 之间用逗号分隔。有些格式会在点后面再加一个时间戳比如112,311,34 114,308,87 118,302,122这里第三个数字是相对时间戳。注意时间戳不是每个版本的标配。有的会有压力值有的会多一个按钮状态标记。解析时最稳妥的办法不是固定写死“三个数字”而是把每个点拆分后判断长度只有两个数字就补默认时间戳 0三个数字就把第三个当作时间戳四个数字再尝试解析成压力值。这样做的好处是同一套解析代码能应对更多老数据。2.4 先确保你不被分隔符坑死文本格式的 pot 有一个让人头痛的地方分隔符不统一。有的样本之间用空行分隔有的用#开头的行分隔有的直接在笔画数据后面紧跟下一个#Sample。我建议在写正则前先用字符串操作把整个文件拆成一个列表再逐行标记状态。这个做法比全文件正则匹配更稳因为你的代码不会因为某个样本里多了一个空行就崩掉。3. 完整解析代码从原始文本到干净的样本对象下面这套代码是我现在重建基准数据时会直接用的版本已经处理过编码、样本分隔和笔画拆分三个核心问题。整体结构是“逐行扫描”而不是一次性正则匹配整个文件原因后面会解释。import re from dataclasses import dataclass, field from typing import List, Iterator, Optional dataclass class Point: x: int y: int t: int 0 dataclass class Stroke: points: List[Point] field(default_factorylist) dataclass class POTSample: sample_id: int 0 label: str strokes: List[Stroke] field(default_factorylist) def decode_pot_bytes(raw: bytes) - str: for enc in (utf-8, gb18030, gbk): try: return raw.decode(enc) except UnicodeDecodeError: continue return raw.decode(utf-8, errorsreplace) def parse_point_tokens(token: str) - Optional[Point]: nums token.replace(,, ).split() if len(nums) 2: return None try: x int(nums[0]) y int(nums[1]) t int(nums[2]) if len(nums) 3 else 0 return Point(x, y, t) except ValueError: return None def parse_pot_text(text: str) - List[POTSample]: samples: List[POTSample] [] cur: Optional[POTSample] None pending_strokes: Optional[int] None for raw_line in text.splitlines(): line raw_line.strip() if not line: continue # 样本起始标记兼容多种写法 sample_match re.match(r^#(?:Sample|S)\s*[:#]?\s*(\d), line) if sample_match: if cur is not None: samples.append(cur) cur POTSample(sample_idint(sample_match.group(1))) pending_strokes None continue # 标签行 label_match re.match(r^#(?:Char|Character|Label)\s*[:#]?\s*(.*), line) if label_match and cur is not None: cur.label label_match.group(1).strip() continue # 笔画数量行 stroke_match re.match(r^#(?:StrokeNum|Strokes|Stroke)\s*[:#]?\s*(\d), line) if stroke_match and cur is not None: pending_strokes int(stroke_match.group(1)) cur.strokes [] continue # 头部元信息直接跳过 if line.startswith(#) or line.startswith(CASIA): continue # 如果确认进入笔画阶段这行应该是坐标点行 if cur is not None and pending_strokes is not None and len(cur.strokes) pending_strokes: stroke Stroke() for token in re.split(r[\s;], line): pt parse_point_tokens(token) if pt is not None: stroke.points.append(pt) # 防止空行被误判为空笔画 if stroke.points: cur.strokes.append(stroke) if cur is not None: samples.append(cur) return samples def read_pot_file(path: str) - List[POTSample]: with open(path, rb) as f: raw f.read() text decode_pot_bytes(raw) return parse_pot_text(text)这段代码核心思路是“状态机式解析”当前有没有一个正在累积的样本、还差几个笔画没读、下一行是不是坐标行。你不需要一次性判断整个文件的格式只需要知道此刻处在什么状态。这样遇到各种小的变体都不容易崩。使用方式很简单samples read_pot_file(/path/to/sample.pot) for sample in samples[:5]: print(sample.sample_id, sample.label, len(sample.strokes))如果样本里有大量数据打印前几个就够了。配合一个.pot文件你很快能看到类似这样的输出0 中 3 1 国 2 2 人 4这里要特别提醒一句上面这个解析器是按照我个人整理的常见版本来写的不能保证覆盖所有 OLHWDB 镜像里的奇怪变体。拿到新文件时先打印前 100 行观察有没有你没见过的分隔方式再微调正则。4. 解析到的坐标要怎么变成机器学习能用的数据4.1 编码陷阱与中文标签这是 .pot 解析里最磨人的一块。不少中文样本文件的标签不是 UTF-8而是 GBK 或 GB18030 编码。如果你直接打开就是乱码不要怀疑自己眼睛先做一件事把解码顺序从 UTF-8 改成 GB18030。所以上面代码我特意写了decode_pot_bytes函数采用尝试解码的方式先用 UTF-8 解解不开就转 GB18030。这个顺序能覆盖绝大多数文件。GB18030 不是生僻的怪编码它是 GBK 的超集基本把简体中文里所有常用字符都覆盖了。我的经验是在处理老版本中文笔迹数据时GB18030 的容错率比 UTF-8 高得多。前提是你别忘了在最后一步用errorsreplace兜底防止样本里出现个别异常字节导致整个解析器抛异常。4.2 坐标归一化别让原始坐标骗了你OLHWDB 的在线笔迹坐标来自数位板或者专门的采集设备。这些坐标往往是绝对坐标数值范围可能是 0 到几万。如果你直接把原始坐标丢给模型模型第一眼看到的就是一个极度倾斜的特征空间。训练还没开始梯度方向就已经被你自己的数据搞乱了。正确的做法是做归一化。最简单也最常用的是“最小最大缩放”def normalize_points(sample: POTSample, target_size: int 128) - List[Stroke]: all_x [p.x for st in sample.strokes for p in st.points] all_y [p.y for st in sample.strokes for p in st.points] min_x, max_x min(all_x), max(all_x) min_y, max_y min(all_y), max(all_y) range_x max_x - min_x range_y max_y - min_y scale min(target_size / range_x, target_size / range_y) if range_x and range_y else 1.0 new_strokes [] for st in sample.strokes: new_pts [] for p in st.points: nx int((p.x - min_x) * scale) ny int((p.y - min_y) * scale) new_pts.append(Point(nx, ny, p.t)) new_strokes.append(Stroke(new_pts)) return new_strokes这里scale target_size / max(range_x, range_y)也是一种做法但如果还要保留笔迹的原始比例用min而不是max更合适因为这样长边不超过目标尺寸短边会被压缩到目标尺寸以下留出一侧空白。具体用哪个取决于你的任务是“整图输入分类”还是“序列建模”。别在这上面过度纠结先跑通后期用验证集对比结果再调整。4.3 用 PIL 把笔画渲染成图像很多 OCR 和手写识别任务最后还是要落到像素级输入。把归一化后的笔画渲染成灰度图是一个高频需求。用 PIL 就能干不需要引入额外图形库from PIL import Image, ImageDraw def render_sample(sample: POTSample, size: int 128, margin: int 4) - Image.Image: all_x [p.x for st in sample.strokes for p in st.points] all_y [p.y for st in sample.strokes for p in st.points] min_x, max_x min(all_x), max(all_x) min_y, max_y min(all_y), max(all_y) span max(max_x - min_x, max_y - min_y, 1) scale (size - 2 * margin) / span img Image.new(L, (size, size), 255) draw ImageDraw.Draw(img) for st in sample.strokes: pts [ (int((p.x - min_x) * scale margin), int((p.y - min_y) * scale margin)) for p in st.points ] if len(pts) 1: draw.point(pts[0], fill0) elif len(pts) 2: draw.line(pts, fill0, width2, jointcurve) return imgjointcurve是我后来才注意的一个细节。默认的line在坐标点非常密集时笔画连接处会显得生硬曲线式连接能让渲染出的汉字看起来更圆润接近真实书写过程。模型喂这种图比喂那些有大量锯齿的图收敛更快。4.4 写个 Dataset 接入 PyTorch解析器不稳定后面全白搭。把一个 pot 文件读完只是第一步更常见的是要把整个目录里的 pot 文件都加载进训练管线。封装一个简易 PyTorch Dataset 并不复杂import glob import torch from torch.utils.data import Dataset class OLHWDBPotDataset(Dataset): def __init__(self, pot_paths: List[str], size: int 128): self.samples [] for path in pot_paths: self.samples.extend(read_pot_file(path)) self.size size def __len__(self): return len(self.samples) def __getitem__(self, idx): sample self.samples[idx] img render_sample(sample, sizeself.size) img_tensor torch.from_numpy(np.array(img, dtypenp.float32) / 255.0) # 标签如果是中文先映射成整数 id label char_to_id(sample.label) return img_tensor.unsqueeze(0), labelchar_to_id是你的字符表映射函数。它可以是简单字典也可以是从训练集里统计出来的字符到 id 的映射。这一步看起来简单但对最终模型准确率影响巨大一定要保证训练集和测试集使用同一个映射表。5. 常见问题与排查实录下面是我在解析 OLHWDB 时实际见过的问题整理成速查表你可以直接拿来对照。5.1 乱码不是文件坏了是编码没选对表现原因解决办法标签全变成锟斤拷这种字实际编码是 GBK却按 UTF-8 解了改用decode_pot_bytes的编码尝试顺序头部一切正常但标签乱码文件前半部分 ASCII后半部分 GBK不要整个文件一次性解码尝试逐行解码标签里出现?或方框字符超出当前编码范围用 GB18030 兜底确保生僻字能映射我遇到的最诡异的一回是同一个文件里前半段是 GBK 编码的中文标签后半段却混着 UTF-8 的中文。单靠整个文件解码永远都有乱码。后来我的解法是把整个 .pot 当作字节流逐行处理每一行单独尝试解码。代价是速度稍慢但换来了稳定个人觉得非常值得。5.2 解析结果比预期少了很多样本表现原因解决办法一个文件读出来只有 10 个样本但打开文本看到有几百个样本标记样本起始标记不叫#Sample而是#S或者数字开头先用 strings file.pot同一个文件里相邻样本之间没有标记只有空行样本间的分隔符就是空行不是标记行解析器遇到两个连续换行时自动切新样本读到一半就停了后面还有大量数据代码因为某个异常点直接跳出了文件给解析器加 try/except记录异常行号而不是中断整个流程第一次写 pot 解析器时我因为懒得看标记名直接用固定正则匹配#Sample结果解析完发现只有预期数据的四分之一。后来用编辑器打开原文件一搜发现标记是#S而不是#Sample。从那以后任何数据集的解析代码我都会先打印前 200 个非空行再写正则。5.3 坐标点对不上图像渲染出来缺笔画表现原因解决办法图像里只有第一条笔画笔画数量行没读到状态机一直停留在第一个笔画确认笔画数量行字段名以及坐标行之前有没有额外注释行图像里笔画全部挤在左上角坐标最大值远大于渲染画布归一化错了先打印坐标的最小值和最大值确认范围图像上下颠倒数位板的 y 轴方向和图像坐标方向相反渲染时把 y 换成max_y min_y - y上下颠倒这个问题最容易忽略。屏幕坐标系的 y 轴通常向下而一些采集设备的数据 y 轴向上或者相反。解析后做可视化时我习惯一次性渲染一批样本查看一眼就能判断方向是否是反的。如果 90% 的字符都是镜像翻转的那不是你的解析器坏了只是坐标系需要整体变换。5.4 性能优化不要每条样本都重新打开文件把 pot 数据直接塞进 DataLoader最愚蠢的写法是每次迭代都重新读一遍整个文件。文件大的时候训练速度会直接掉到不可接受。更稳妥的做法是先用一次性脚本把全部样本解析并缓存成 numpy 阵列或者 Parquet 文件训练时直接从缓存读。解析这步只做一次后续所有实验都能受益。python parse_pot_to_npy.py --input_dir ./OLHWDB1.2 --output_dir ./cached缓存文件里最好额外保存一份字符映射表 JSON这样不会因为重新解析而丢失标签顺序。我在实际项目里就是因为重新标定映射表导致前后两次训练结果对不上浪费了两天时间排查。6. 我的实操体会与几个额外建议解析 CASIA OLHWDB 的 .pot 文件本质上不是一个算法问题而是一个工程问题。只要愿意花时间观察文件结构、对编码保持警惕、把状态机写稳它基本不会给你带来真正的技术难度。真正会坑到你的反而是那些不起眼的小事字符标签的编码坐标点之间的分隔符以及样本标记到底是#Sample还是某个冷门写法。最后分享一个我自己的小习惯每次拿到一个新的数据仓库我不会一上来就写完整解析器而是先写一个极简的dump脚本打开文件读取前 100 行把每一行原样打印出来。很多时候问题不是解析算法不够强而是你压根没有看清数据长什么样。先看清楚再动手这比任何“万能解析库”都管用。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →