公司动态

数字图像处理实战:甲骨文拓片图符自动提取完整流程

📅 2026/9/2 1:56:37
数字图像处理实战:甲骨文拓片图符自动提取完整流程
简介基于Matlab的Muc数字图像处理课程甲骨文图符提取实验代码与配套图料面向数字图像处理、古文字数字化和文化遗产保护相关研究者与爱好者。资源围绕甲骨文图符提取的完整技术链路先进行边缘检测与质心定位继而开展方向校正使文字走向归正随后去除背景噪声与图像内部杂质通过膨胀、腐蚀及形态学运算获取二值图像完成图符分离与提取最后对外轮廓做多边形拟合并实现文字分割。压缩包共三个文件内含两张PNG原始图料与一个.m脚本整体大小约1.96MB便于快速下载运行。目前已有136人学习。这一实现能为古代文字机器识别、考古数据整理及数字人文研究提供可直接复用的代码基础与图像样例适合作为课程设计、科研入门或算法对比的参考整体实验流程清晰、结构完整。 做甲骨文拓片数字化整理的时候我最怕的不是算法跑不起来而是辛辛苦苦调好参数最后从一张糊成一片的拓片里啥也没抠出来。这篇就来复盘一下我用数字图像处理做甲骨文图符提取的完整流程包括核心代码、参数调优思路和实际踩过的坑算是给做古籍图像处理、文物数字化方向的朋友一份可以直接参考的实操记录。这个项目要解决的问题很具体一批甲骨文拓片扫描图需要把上面一个个图符从背景纹理里分离出来存成独立的小图供后续建立图符库或做识别训练。人工抠图当然能做但几十张甚至几百张拓片每张上头几十个字符工作量太大了。所以目标很明确——写一套图像处理流水线自动完成提取人工只负责最后复核一次。1. 项目背景与整体设计思路1.1 甲骨文拓片图像的主要特征与提取难点先说说原料长什么样。甲骨文拓片图不是干净的“白底黑字”它往往有几类让人头疼的特征背景不均匀扫描光源、拍摄角度导致同一张图亮暗不均四角偏暗、中间发白是常态纸张/骨纹纹理重拓片上的纸纤维、骨裂纹路和墨迹混在一起灰度上难以直接分开墨迹深浅不一同一个字的不同笔画墨色可能差得很远浅的几乎融进背景笔画断裂与粘连年代久远的拓片常有残损同一笔可能断成几截相邻字也可能黏在一起噪声杂点多灰尘、折痕、扫描仪坏点这些在二值化后都会变成“伪图符”。搞清楚了这些特征算法选型的逻辑就自然出来了。早期我也试过直接用最简单的全局阈值结果就是两个字——翻车。后来老老实实把每一步拆开先预处理改善输入质量再二值化分离目标接着用形态学修复最后按连通域裁剪才把提取率稳定拉上去。1.2 整体处理流程与预期目标整套流水线我设计成五个环节预处理灰度化 → 去噪 → 对比度增强二值化灰度图转成黑白掩膜图符为白色、背景为黑色形态学修复闭运算填断裂开运算去毛刺连通域分析找出所有白色区域按面积、宽高比过滤掉噪声裁剪归档按原图坐标裁剪图符批量保存并生成标注文件。流程设计成模块化最大的好处是每一层输入输出都明确调试时能直接看中间结果定位问题。这一步出问题了就调这一步不用从头到尾跑盲调。预期目标是在保证召回率的前提下把每张拓片提取出的“疑似图符”控制在可人工复核的数量级内并且每张裁剪图周边带上少量边距方便人眼识别。2. 技术选型与核心算法原理2.1 为什么选择 Python OpenCV选型上其实没太多犹豫。Python OpenCV 是数字图像处理项目里最稳的组合OpenCV 把灰度化、滤波、形态学、连通域分析这些操作都封装好了底层是 C 实现跑批处理几百张图速度完全够用Python 做快速原型和可视化调试又非常顺手脚本改两行就能重新出一批结果。相比之下MATLAB 的 Image Processing Toolbox 也很好用但版权和部署是个问题而且后面想接深度学习方法做图符分类生态不如 Python 成熟。而纯深度学习语义分割方案比如 U-Net虽然对粘连和断裂更鲁棒但需要人工标注训练数据项目第一版根本等不起。传统图像处理是“用规则换数据”对甲骨文这种强结构、规则相对固定的目标反而是投入产出比最高的路径。2.2 预处理阶段的核心算法用大白话讲透灰度化是第一步。如果原图本身是 JPG 的彩色扫描件直接用cv2.cvtColor转成单通道灰度图可以显著减少计算量。有些拓片扫描出来压根就是黑白模式我就直接cv2.imread(path, 0)少一步是一步。中值滤波为什么排在去噪第一位我的理解是扫描图像最常见的噪声是椒盐噪声——也就是纸面上的灰尘点、扫描仪感光元件坏点这些点跟周围像素差异极大。中值滤波的核心思想是“少数服从多数”取邻域内所有像素的中值作为中心像素的值一个孤立的黑点落在白色背景里邻域中值直接被周围白色拉回正常值噪声就抹平了。而且它比高斯滤波更能保住笔画边缘不会把图符轮廓磨糊。核大小我是从 3×3 起手笔画粗的拓片可以上 5×5再大就伤边缘了。**CLAHE限制对比度自适应直方图均衡化**是这个项目的救星。拓片光照不均匀全局直方图均衡化会把暗区的噪声同时放大效果反而更差。CLAHE 的做法是先把图像切成一堆小块每个小块内部单独做直方图均衡化再限制单个灰度级的增强幅度这样墨迹和背景纸色的对比被均匀拉开但角落的阴影不会被瞎放大。用 OpenCV 的cv2.createCLAHE只需要调clipLimit和tileGridSize两个参数我通常从clipLimit2.0, tileGridSize(8,8)起步。到二值化这步很多人习惯直接上 Otsu 全局阈值但对拓片这种背景亮度不平的图像全局阈值往往顾此失彼——亮区背景被当成了墨迹暗区墨迹又丢得干净。这里我用的是cv2.adaptiveThreshold它走的是“本地化”路线每个像素的阈值由它邻域的加权均值减去一个常量 C 得到等于在每个局部都单独定一条标准。效果上墨迹自动浮出来背景阴影则被压住。形态学处理是整个流程里最靠经验、也最容易调崩的一环。闭运算先膨胀后腐蚀的作用是填缺口拓片墨迹断掉的笔画会重新连起来开运算先腐蚀后膨胀则是反过来把图符边缘的毛刺和零散小噪点削掉。一个容易犯的错误是顺序颠倒或者核心太大闭运算核太大会把相邻两个字糊成一个开运算核太大会把细笔画的图符整个腐蚀没。我的经验是椭圆核从 5×5 起调根据笔画粗细微调。最后的连通域分析是收尾动作。二值图里每个白色的连通块就是一个候选图符用connectedComponentsWithStats可以直接拿到每个块的外接矩形x, y, w, h和面积area。接下来只需要按面积和宽高比过滤——面积太小的块基本是噪声宽高比极端扁的要么是裂痕要么是边框线。3. 核心代码实现与参数调优实录3.1 预处理模块从原始拓片到可分析的灰度图这一段是整条流水线的入口也是最容易忽略的一环。很多人上来就二值化结果发现效果差其实问题往往出在预处理没做扎实。我的代码长这样import cv2 import numpy as np import os import glob def preprocess(image_path): # 读取原图转灰度 img cv2.imread(image_path) gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 第一步去噪中值滤波核大小根据噪声程度调 3 或 5 denoised cv2.medianBlur(gray, 3) # 第二步增强CLAHE提升墨迹和背景的对比度 clahe cv2.createCLAHE(clipLimit2.0, tileGridSize(8, 8)) enhanced clahe.apply(denoised) return enhanced用这段处理完灰度图的直方图会从原先“一大坨挤在中间”变成“两端都有分布”这正是后续二值化最需要的状态。调试时建议把enhanced保存出来看一眼如果墨迹和背景已经能肉眼分得差不多了二值化基本八九不离十。这里要提醒一句中值滤波核大了一定要试跑我曾经在某张精细化拓片上用 5×5结果把刻痕细节给抹掉了退回 3×3 才正常。3.2 二值化与形态学修复让图符真正浮出来预处理完就进核心环节二值化加形态学处理。这一步我习惯写成一个函数方便单独调参def binarize_and_fix(img): # 自适应阈值blockSize 必须是奇数C 是阈值偏移量 binary cv2.adaptiveThreshold( img, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, 31, 15 ) # 先闭后开用椭圆核避免矩形核带来的直角畸变 kernel cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5)) closed cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel) cleaned cv2.morphologyEx(closed, cv2.MORPH_OPEN, kernel) return cleanedadaptiveThreshold里两个核心参数blockSize是局部邻域大小决定了“看多远”。笔画粗、字形大blockSize 可以往 41、51 调字形小就 15、21。C 值是阈值向下偏置的量C 越大越多的浅墨迹会被吃掉噪声也更少。我这批图用 31 和 15 是测出来的你上手时先跑这两个值再根据结果微调。形态学这里我坚持“先闭后开”。原因很直白闭运算先把断开笔画和内部的小孔洞补上再交给开运算去清理外围毛刺和孤立噪点。如果次序反了开运算先削一遍会把很多本来就细的笔画边缘进一步削弱再闭运算也只是在削过的骨架上补洞最终形态会差一截。核我选了椭圆因为笔画截面接近圆润椭圆核更贴合矩形核容易把转折处带出方角。3.3 连通域分析与图符裁剪把候选图符抠出来拿到干净的掩膜图后下一步就是找连通域并裁剪。这一步直接决定了图料库长什么样所以过滤条件我会写得比较细def extract_regions(binary, min_area80, max_ratio20): num_labels, labels, stats, centroids cv2.connectedComponentsWithStats( binary, connectivity8 ) regions [] for i in range(1, num_labels): x, y, w, h, area stats[i] # 面积过滤太小是噪声太大可能是整块残墨 if area min_area: continue # 宽高比过滤太扁的通常是裂痕、边框线 ratio max(w / h, h / w) if ratio max_ratio: continue regions.append((x, y, w, h, area)) return regions def save_regions(img, regions, output_dir, prefix, pad8): os.makedirs(output_dir, exist_okTrue) for idx, (x, y, w, h, area) in enumerate(regions): # 四周放宽 pad 像素避免裁掉笔画边缘 x0 max(0, x - pad) y0 max(0, y - pad) x1 min(img.shape[1], x w pad) y1 min(img.shape[0], y h pad) crop img[y0:y1, x0:x1] save_path os.path.join(output_dir, f{prefix}_{idx:04d}_area{area}.png) cv2.imwrite(save_path, crop)关于min_area这个参数我强烈建议你先跑一轮不设阈值的版本然后把所有连通域的面积打出来看分布再找一个明显的断档作为阈值。直接拍脑袋定 80 或 100 其实也很快但容易漏掉小字或放进大量杂点。pad参数要稍微给大一点比如 8 像素这样裁剪后的图符周围有一圈背景边距做标注或者给人眼复核时舒服很多不会显得抠得太死。3.4 批量处理一次性搞定整批拓片单张图跑通后批量处理就是顺手的事。我一般用一个主脚本循环所有图片每张图片的输出单独建一个子目录避免不同拓片的图符混在一起if __name__ __main__: input_dir oracle_raw output_dir extracted for path in glob.glob(os.path.join(input_dir, *.jpg)): name os.path.splitext(os.path.basename(path))[0] print(fprocessing: {name}) img cv2.imread(path) enhanced preprocess(path) binary binarize_and_fix(enhanced) # 调试用保存中间掩膜图 cv2.imwrite(os.path.join(output_dir, f{name}_binary.png), binary) regions extract_regions(binary, min_area80, max_ratio20) save_regions(img, regions, os.path.join(output_dir, name), name) print(f - {len(regions)} regions extracted)跑完一批我通常会先抽查三四张重点看两点一是有没有把纸裂纹当成字框出来二是有没有把相邻字粘连成一个块。如果发现这类问题回来调C值、形态学核大小或max_ratio比盲跑下一轮强得多。别嫌这一步麻烦我在第一版时跳过抽查直接跑完两百多张后来发现有一批图的blockSize明显偏大字全粘连了等于白跑一遍。4. 常见问题与排查技巧实录4.1 我的问题速查表把这几周调参遇到的高频问题整理成表格方便后面直接对号入座现象可能原因处理方案图符笔画断裂严重字碎成一截截闭运算核太小或没做闭运算增大椭圆核到 (7,7) 或 (9,9)确保先闭后开很多小噪声块被当成图符二值化 C 值偏小或 min_area 太低C 值往上调 3~5 个量级同时提高 min_area相邻字被粘连成一个大块闭运算核过大或图像本身墨迹晕染缩小闭运算核考虑先做一次轻微开运算分隔背景阴影区域被整块挖出光照不均自适应阈值 blockSize 过小增大 blockSize 到 41/51或先跑 CLAHE 再二值化某些浅墨迹图符直接消失C 值过大把浅色墨迹当背景降低 C 值同时用 CLAHE 增强时加大 clipLimit裁剪出来的图符偏斜或缺边连通域外接矩形不含旋转笔画横向超出对单块区域考虑用 minAreaRect 重新取旋转框或调大 pad4.2 两个值得展开的调参案例第一个案例闭运算过度导致粘连。我当时把一张字形密集的拓片拿去处理开开心心把闭运算核调到了 9×9结果数字之间笔画间距本来就近闭运算一膨胀直接把相邻的字符连成了一条黑块。一查area都上千了就发现问题了。后来把闭运算核降回 5×5同时在闭运算前加了一次cv2.dilate的轻量膨胀效果好很多。这个教训让我学到一个原则形态学操作要“能小就不大”核越大修复能力越强但粘连风险也线性上升。第二个案例低对比度拓片直接二值化后全白。我遇到一张整体灰蒙蒙的拓片字体颜色和背景几乎黏在一起adaptiveThreshold 跑完结果图符全消失了只剩一片白。问题出在预处理没做够。后来我先用 CLAHE 把对比度拉开又加了一步cv2.normalize做灰度归一化再进二值化图符立刻清晰了。对这类图预处理阶段投入多一分钟能给二值化省下一小时的调参时间真的不划算跳过。5. 图料管理从散图到可复用的数据集5.1 目录结构与命名规范代码跑通只是第一步图料管理才是后续能持续使用的关键。我最后落地的目录结构是这样oracle_project/ ├── oracle_raw/ # 原始拓片图jpg/png 均可 │ ├── H001.jpg │ └── H002.jpg ├── extracted/ # 提取结果 │ ├── H001_binary.png # 中间掩膜方便回溯 │ ├── H001/ # 每张原图对应一个目录 │ │ ├── H001_0000_area120.png │ │ ├── H001_0001_area345.png │ │ └── ... │ └── H002/ └── review/ # 人工复核通过后的最终图料命名规范上我保留了前缀原图编号 序号0000起 面积area值三段信息。之所以在文件名里带area是因为后续如果想按面积档位筛选图符、或者回看某个噪点混进来的原因文件名直接就能给出线索不用重新打开图像看一眼才知道。中间掩膜图也必须留哪步参数调崩了对照掩膜图能快速定位是二值化问题还是形态学问题。5.2 自动抽检与人工复核的工作流全自动提取难免有漏网之鱼我的做法是“自动粗筛 人工精审”两层。自动层写一个脚本统计每个候选图符的面积、宽高比分布自动标出面积落在整体分布两端的离群块这些通常是噪声或者特大粘连块。人工层把提取出来的小图拼成一张大网格图比如每行8张一次看一眼就能快速扫掉不合适的块比一个一个翻文件夹高效得多。拼图用 OpenCV 的cv2.hconcat和vconcat半小时就能写完但复核效率能提升好几倍。复核通过的图料我会再做一步统一预处理所有图符缩放到固定高度比如 128 像素保持宽高比不变再存成统一格式。这样后面如果要接深度学习模型做图符分类数据输入尺寸是对齐的省去很多转换麻烦。6. 收尾聊点实际体会这套流水线前前后后迭代了差不多三版。第一版能跑通但提取结果很粗糙第二版加了 CLAHE 和自适应阈值效果质变第三版主要是在过滤规则和参数细节上打磨。三版下来最深的体会是数字图像处理这类项目调参的时间远超写代码的时间而调参的底气来自于每个中间结果你都能看懂。另外有个小建议处理文物类图像时永远保留好原始图料的干净副本不要在原始图上直接覆盖保存任何中间结果。同一个提取需求换个参数或者换批图可能完全又是另一套调优过程原始图料一旦污染后面想重做就麻烦了。最后再分享一个我一直在用的检查习惯每次调完参固定用同一张“最难处理”的拓片作为验收标准图。这张图光照最不均、笔画最浅、噪声最多只要它在你的流水线上提取效果达标其他大部分图基本都能过。这比随机抽几张图看效果要靠谱得多也省时间。本文还有配套的精品资源点击获取