☰
文档阴影语义分割实战:从Labelme标注到DeepLabV3+训练与OCR增强
2026/10/14 0:23:09 网站建设 项目流程

简介:这是一份面向计算机视觉研究与开发者的文档阴影语义分割数据集,包含600张真实环境下拍摄的文档图片,覆盖自然光照与人造光源产生的多样化阴影,可用于训练阴影检测与实例分割模型,并为提升OCR等文档图像处理鲁棒性提供关键数据支撑。资源共1208个文件,包括606张JPG原图与602个Labelme标注JSON文件一一对应,压缩包大小约752MB,文件组织便于按图索引与批量加载。目前已有128人学习。借助该数据集,研究者可将JSON标注灵活转换为Pascal VOC或COCO格式,接入主流分割框架训练;标注统计显示共656个实例,涵盖多类型阴影边界与细节,有助于模型在智能办公、电子文档管理、图像编辑等真实场景中实现迁移和落地。对于研究复杂光照下文档图像处理的算法工程师与相关专业学生,这份资源既可用于算法验证,也可作为教学实验的标注样本集,具有较强的实用价值。

1. 阴影对 OCR 的干扰从哪来,数据又从哪来

做过文档影像项目的人基本都遇到过这种情况:一张白底黑字的合同扫描件,拍照时没注意光源方向,页脚压了一片阴影。拿给 OCR 引擎识别,错字率立刻从 2% 飙升到 15% 以上,而且往往是阴影边界那几个字先崩。你第一个念头是调二值化阈值,但全局阈值根本救不回来,因为阴影区域的灰度分布和正常纸张区域是重叠的。真正稳定的解法是把阴影区域先分割出来,再做局部增强或者直接掩膜掉,让后续的 OCR、版面分析只处理干净区域,这就是文档阴影语义分割要解决的问题。

这套数据集就是为此准备的,600 张真实环境下拍摄的文档照片,全部用 Labelme 做了多边形标注,标签记录的是文档区域的位置和形状轮廓,共 656 个标注实例。和纯合成阴影数据集不同,这些图里有自然光投影、人工光源直射、纸张褶皱导致的渐变阴影,噪声和干扰更接近真实业务场景。数据量和动辄几万张的通用分割数据集没法比,但它的价值在深度不在规模——标注质量高、场景聚焦,适合做两件事:一是验证阴影分割算法在真实光照下的表现,二是作为领域微调数据,让通用分割模型在文档这个子类上收敛得更快。

适合用它的人也很明确:做 OCR 前置处理、文档拍照增强、移动端扫描类应用的工程师,以及刚入门语义分割、想找一个标注文件干净、复杂度适中的数据集来跑通完整 pipeline 的研究者。这套数据的标注是 JSON 格式,直接喂给模型不行,需要转成 COCO 或 VOC 结构,后面的章节会逐步拆开讲,从格式解析讲到训练参数再讲到验证技巧。

2. Labelme 标注格式解析与阴影样本的标签结构

2.1 为什么选 Labelme 而不是别的标注工具

先明确一个概念:语义分割和实例分割在这个数据集上的关系。数据集的标签是每个文档区域一个多边形,655 个标签加上背景类,严格来说是实例级别的标注,但如果你把这些多边形直接合并成前景掩码来训练,它就退化成二分类语义分割——前景是文档,背景是阴影和杂物。这个数据集两种用法都支持,关键在于你怎么处理 JSON 里的 shape 数据。

Labelme 是这个场景下的常见选择,因为这批数据的标注对象是「文档」,而文档在图像里是刚性平面物体,边缘可以用多边形精确勾勒。Labelme 输出的 JSON 文件结构非常直白,每条标注记录包含 label、points、shape_type 三个核心字段。points 是二维坐标数组,表示多边形的顶点序列;shape_type 是 polygon;label 是类别名。相比之下,如果换用 COCO 的标注格式来做这件事,标注工具的选择就会偏向 labelme2coco 这类转换工具链。

2.2 JSON 字段结构拆解与标签统计逻辑

随机打开一个标注文件,结构大概是这样的:

{ "version": "5.0.1", "flags": {}, "shapes": [ { "label": "document", "points": [[105.0, 88.0], [122.0, 87.0], [438.0, 121.0], [420.0, 512.0]], "group_id": null, "shape_type": "polygon", "flags": {} } ], "imagePath": "15.jpg", "imageData": null }

这里的逻辑是:一个 JSON 文件对应一张图片,shapes 数组里的每个元素就是一个文档实例。points 里的坐标是绝对坐标,单位是像素,顺序是顺时针或逆时针都有可能,所以在后续生成掩码时要注意多边形顶点顺序的一致性问题——大多数图像处理库对多边形填充不要求严格方向,但如果你自己写 rasterization 逻辑就会踩坑。imageData 字段在这个数据集里是 null,这意味着图片没有内嵌到 JSON 里,读取时按 imagePath 去同级目录找。

标签统计显示共有 656 个标签,而图片只有 600 张,说明有一部分图像包含多个文档实例。训练时有两种处理策略:如果做语义分割,把同一张图里的所有 document 多边形合并为一个前景通道;如果做实例分割,每个多边形是独立实例,需要编号区分。转换脚本里通常保留 shape 的索引作为 instance_id,这一步不要省略,不然后面想从语义分割改做实例分割,得重新标一遍数据。

2.3 读取 JSON 并快速可视化验证标注质量

拿到数据集先别急着训练,标注质量决定了模型性能的上限,用脚本扫一遍最快。下面这段代码遍历所有 JSON 文件,检查坐标是否越界、多边形面积是否过小、是否有重复标注,并输出异常样本的文件名:

import json import os from pathlib import Path label_dir = Path("./labels") img_size_cache = {} # 记录每张图的尺寸,用于越界检查 for json_file in sorted(label_dir.glob("*.json")): with open(json_file, "r", encoding="utf-8") as f: data = json.load(f) img_name = data["imagePath"] h, w = img_size_cache.get(img_name, (0, 0)) for idx, shape in enumerate(data["shapes"]): pts = shape["points"] if len(pts) < 3: print(f"[WARN] {json_file.name} shape{idx}: 顶点少于3个") continue xs = [p[0] for p in pts] ys = [p[1] for p in pts] area = 0.5 * abs(sum(xs[i] * ys[i+1] - xs[i+1] * ys[i] for i in range(len(pts)-1))) if area < 50: print(f"[WARN] {json_file.name} shape{idx}: 面积过小 {area:.1f}px") if h and w: if max(xs) > w or max(ys) > h: print(f"[ERROR] {json_file.name} shape{idx}: 坐标越界")

这段代码做了三件事:遍历目录下所有 JSON,解析 shapes 数组,对每个多边形做顶点数、面积和越界检查。面积计算用的是鞋带公式,也就是格林公式的离散形式,注意它要求顶点按顺序排列且坐标闭合——points 的首尾坐标不需要重复,公式自动闭合。跑完这个脚本如果只有零星警告,说明标注质量基本可靠;如果大面积出现越界或面积异常,就得考虑清洗数据或者手动修正标注文件了。

3. Labelme JSON 转 COCO 格式:从多边形到 RLE 掩码

3.1 语义分割和实例分割的数据格式差异

训练语义分割模型和训练实例分割模型,数据组织方式完全不同。语义分割模型(比如 DeepLabV3+、U-Net)接收的是图像加对应的掩码图,掩码里每个像素值是类别索引,文档区域是 1、背景是 0,是一张和原图同尺寸的单通道 PNG。实例分割模型(比如 Mask R-CNN)接收的则是 COCO JSON,每个标注对象有独立的 segmentation 多边形和 bbox 信息。

这个数据集给的是 Labelme JSON,两个方向都要转。转换的核心逻辑是:从shapes数组里提取每个多边形的顶点坐标,把坐标缩放到原图尺寸(Labelme 存的就是绝对坐标,不需要缩放),然后按目标格式输出。这里有一个常见误区:Labelme 标注时如果图像被缩放显示过,points 坐标也可能被缩放,务必确认坐标是原始分辨率下的值,否则转换出来的掩码会和图像错位。

3.2 转换脚本:Labelme JSON 到 COCO 的完整实现

下面给出一段可直接运行的转换脚本,把 Labelme JSON 批量转为 COCO 格式的 JSON 文件,并生成对应的二值掩码 PNG:

import json import numpy as np import cv2 from pathlib import Path from pycocotools import mask as mask_util def labelme_to_coco(img_dir, label_dir, output_json, output_mask_dir): """将Labelme格式转换为COCO格式,同时导出语义分割掩码""" output_mask_dir = Path(output_mask_dir) output_mask_dir.mkdir(parents=True, exist_ok=True) coco = { "images": [], "annotations": [], "categories": [{"id": 1, "name": "document", "supercategory": "doc"}] } ann_id = 1 img_id = 1 for json_file in sorted(Path(label_dir).glob("*.json")): with open(json_file, "r", encoding="utf-8") as f: data = json.load(f) img_path = Path(img_dir) / data["imagePath"] img = cv2.imread(str(img_path)) if img is None: print(f"[SKIP] 无法读取图像: {img_path}") continue h, w = img.shape[:2] # 构建语义分割掩码(所有文档区域合并为前景) semantic_mask = np.zeros((h, w), dtype=np.uint8) instance_masks = [] for shape in data["shapes"]: if shape["shape_type"] != "polygon": continue pts = np.array(shape["points"], dtype=np.int32).reshape(-1, 2) cv2.fillPoly(semantic_mask, [pts], 1) # 生成实例掩码并转RLE编码 inst_mask = np.zeros((h, w), dtype=np.uint8) cv2.fillPoly(inst_mask, [pts], 1) rle = mask_util.encode(np.asfortranarray(inst_mask)) bbox = mask_util.toBbox(rle).tolist() coco["annotations"].append({ "id": ann_id, "image_id": img_id, "category_id": 1, "segmentation": [pts.flatten().tolist()], "area": float(mask_util.area(rle)), "bbox": [bbox[0], bbox[1], bbox[2], bbox[3]], "iscrowd": 0 }) ann_id += 1 # 保存语义分割掩码 mask_filename = f"{Path(data['imagePath']).stem}_mask.png" cv2.imwrite(str(output_mask_dir / mask_filename), semantic_mask) coco["images"].append({ "id": img_id, "file_name": data["imagePath"], "width": w, "height": h }) img_id += 1 with open(output_json, "w", encoding="utf-8") as f: json.dump(coco, f, indent=2) print(f"完成: {img_id-1}张图片, {ann_id-1}个实例, 掩码保存在 {output_mask_dir}")

这段脚本的核心逻辑分三步:读取 JSON 并加载对应图像,得到图像尺寸;遍历 shapes 数组,用cv2.fillPoly把每个多边形填充成掩码,同时累加到语义分割掩码上;将实例掩码编码为 COCO 的 RLE 格式并计算 bbox 和面积。注意mask_util.encode要求输入是 Fortran 连续数组,所以要用np.asfortranarray做一次内存布局转换,RLE 是基于游程编码的,处理细长多边形比直接用多边形坐标要稳定得多。

3.3 转换后的格式验证与常见报错排查

转换完成后不能直接开训,先用一段验证代码检查转换结果是否对齐:

import json import cv2 import numpy as np from pycocotools.coco import COCO coco = COCO("output.json") img_ids = coco.getImgIds() for img_id in img_ids[:5]: img_info = coco.loadImgs(img_id)[0] img = cv2.imread(f"images/{img_info['file_name']}") ann_ids = coco.getAnnIds(imgIds=img_id) anns = coco.loadAnns(ann_ids) mask = coco.annToMask(anns[0]) # 从RLE还原掩码 overlay = img.copy() overlay[mask > 0] = (0, 255, 0) # 绿色标记 cv2.imwrite(f"verify_{img_info['file_name']}", overlay)

coco.annToMask()会把 RLE 编码还原成一个二值掩码,这是验证路径是否正确的标准做法。常见问题是cv2.fillPoly填充时顶点坐标超出图像边界,OpenCV 会直接报错,处理办法是在填充前对顶点做一次np.clip裁剪。另外要注意 COCO 的 area 字段应该用 RLE 计算出的面积,而不是直接计算多边形面积,两者在凹多边形上会有细微差异,面积字段虽然不参与损失计算,但在评估时会被用作小目标筛选的依据,填错会导致 AP 指标失真。

4. 基于 DeepLabV3+ 的文档阴影分割训练配置

4.1 骨干网络选型和损失函数设计逻辑

拿到 COCO 格式数据后,模型选型上我一般先从 DeepLabV3+ 起步。原因在于 ASPP(空洞空间金字塔池化)模块对多尺度阴影边缘的捕获能力,比普通的 U-Net 结构更稳——阴影的边界经常是渐变的,没有明确的轮廓线,ASPP 通过不同扩张率的空洞卷积并行采样,让网络能同时看到大感受野的全局光照变化和小感受野的局部边缘纹理。

骨干网络推荐 ResNet-50 而不是 ResNet-101,原因不是精度问题,而是训练收敛速度。600 张图的数据量对 ResNet-101 来说参数冗余太大,容易在早期过拟合,ResNet-50 配合 ImageNet 预训练权重已经足够拟合这套数据。损失函数用 CrossEntropyLoss 加 Dice Loss 的组合,比例 1:1。交叉熵处理像素级分类的梯度更平滑,Dice Loss 直接优化区域重叠度,对前景占比小的样本更友好——文档在整张图里可能只占 30% 的面积,如果用纯交叉熵,模型会倾向于保守预测,把边界区域判成背景。

4.2 训练参数完整配置与数据增强策略

下面是一份我在这类小数据集上常用的训练配置,基于 PyTorch 和 torchvision 的 deeplabv3_resnet50:

import torch from torch.utils.data import Dataset, DataLoader import torchvision.transforms as T import albumentations as A from torchvision.models.segmentation import deeplabv3_resnet50 # 数据增强:针对光照不均匀场景的专用策略 train_transform = A.Compose([ A.RandomBrightnessContrast(brightness_limit=0.3, contrast_limit=0.3, p=0.8), A.HueSaturationValue(hue_shift_limit=5, sat_shift_limit=10, val_shift_limit=20, p=0.5), A.OneOf([ A.GaussNoise(var_limit=(5.0, 15.0), p=0.5), A.MotionBlur(blur_limit=5, p=0.5), ], p=0.3), A.ShiftScaleRotate(shift_limit=0.1, scale_limit=0.15, rotate_limit=15, p=0.5), A.RandomCrop(height=384, width=384, p=1.0), # 固定裁剪尺寸,适配训练batch ]) # 训练参数 config = { "backbone": "resnet50", "pretrained": True, # 使用 ImageNet 预训练权重 "num_classes": 2, # 背景 + 文档 "batch_size": 8, "epochs": 60, "lr": 1e-4, # 小数据集用较小的初始学习率 "lr_scheduler": "poly", # 多项式衰减,p=0.9 "crop_size": 384, "loss_weights": [0.5, 0.5] # CE权重, Dice权重 } model = deeplabv3_resnet50(weights="DEFAULT") model.classifier[4] = torch.nn.Conv2d(256, config["num_classes"], kernel_size=1) model.train() # 优化器:SGD+momentum,比Adam更稳 optimizer = torch.optim.SGD( model.parameters(), lr=config["lr"], momentum=0.9, weight_decay=1e-4 )

训练参数里有几个关键设定值得展开说。学习率用 1e-4 而不是常见的 1e-3,因为迁移学习场景下骨干网络已经有较好的特征表达,过大的学习率会破坏预训练权重。学习率调度用 poly 策略——学习率按照lr * (1 - iter/total_iter)^0.9衰减,这是分割任务中的常见做法,比 StepLR 更适合长时间训练,前期保持较高学习率快速收敛,后期把学习率压得很低让模型在局部最优点附近精细化。RandomCrop 固定 384x384 是为了在 batch_size=8 时显存可控,如果你的显卡只到 8GB,把 crop 改成 320、batch_size 改成 4 即可。

4.3 训练过程监控:mIoU 和阴影边缘的专项验证

训练时除了看 loss 曲线,还要单独看验证集上的 mIoU。这里有一个阴影分割特有的细节:阴影边界区域往往是模型最容易混淆的地方,只有 1 到 2 个像素宽度的渐变带。所以验证指标除了整体 mIoU,还建议额外统计边界区域的 IoU——把真实掩码做 Canny 边缘检测后膨胀 3 个像素,只计算这条边界带内的 IoU。

def boundary_iou(pred_mask, gt_mask, dilate_iter=3): """计算阴影边缘区域的IoU,比整体IoU更敏感地反映边界质量""" import cv2 gt_edge = cv2.Canny(gt_mask.astype(np.uint8) * 255, 100, 200) kernel = np.ones((3, 3), dtype=np.uint8) gt_edge_dil = cv2.dilate(gt_edge, kernel, iterations=dilate_iter) if gt_edge_dil.sum() == 0: return 1.0 boundary_gt = gt_mask[gt_edge_dil > 0] boundary_pred = pred_mask[gt_edge_dil > 0] intersection = (boundary_gt & boundary_pred).sum() union = (boundary_gt | boundary_pred).sum() return intersection / (union + 1e-6)

这段代码把真实掩码的边缘提取出来并做了 3 次膨胀,得到一个覆盖边界两侧的带状区域,然后只在这个区域内计算 IoU。为什么是膨胀 3 次?膨胀半径太小覆盖不到模型在边界两侧的预测误差区域,膨胀太大则退化成整体 IoU。这个指标对阴影渐变区域特别敏感,因为文档阴影数据集中最难分割的正是阴影从深到浅的过渡带,如果你发现整体 mIoU 到了 90% 但 boundary IoU 只有 60%,说明模型学到了大致轮廓但没有学到精细的阴影边界,这时候需要调整损失函数的 Dice 权重,或者考虑在训练数据中加入边界增强的预处理。

5. 掩码后处理:阴影分割结果如何服务 OCR 和文档增强

5.1 阴影区域掩码的业务化处理流程

模型输出的掩码是单通道概率图,经过 argmax 得到二值掩码后,不能直接拿去给 OCR 用。直接掩膜会导致阴影区域变成黑色空洞,OCR 引擎在空洞周围会产生新的误判。正确的顺序是:先对预测掩码做形态学处理去除噪点,然后根据掩码区域做局部亮度均衡,最后再送入 OCR。

形态学处理这里推荐先开运算再闭运算的组合——开运算(先腐蚀后膨胀)去掉掩码中的孤立噪点和细长毛刺,闭运算(先膨胀后腐蚀)填补文档内部因光照不均导致的掩码空洞。核大小用 5x5 的椭圆核,迭代次数不要超过 2 次,太多会把阴影边界磨损掉。

做完形态学处理后的掩码有两个用途:如果目标是纯增强,就把阴影区域从图像中分离出来,用周围正常纸张区域的亮度均值和标准差做直方图匹配,重新映射阴影区域的灰度值;如果目标是直接清障,就把阴影区域替换为周围背景的纹理模式。前者对 OCR 更友好,因为保留了文档的原始纹理信息,后者更适合做视觉上的文档净化。

5.2 使用掩码做阴影区域光照归一化

下面这段代码实现了基于掩码的局部光照归一化,是文档阴影处理中最直接可用的手段:

import cv2 import numpy as np def normalize_shadow_region(img, shadow_mask, kernel_size=31): """基于阴影掩码做局部光照归一化,消除阴影对OCR的影响""" # 将掩码转为0-255灰度图 mask = (shadow_mask * 255).astype(np.uint8) mask_bool = shadow_mask.astype(bool) # 计算背景光照模型:对整图做高斯模糊得到低频光照分量 illumination = cv2.GaussianBlur(img, (kernel_size, kernel_size), 0) # 计算全局光照水平(取非阴影区域的中位数) non_shadow_pixels = img[~mask_bool] target_mean = non_shadow_pixels.mean() # 对阴影区域做光照补偿 result = img.copy() shadow_illum = illumination[mask_bool] compensation = target_mean / (shadow_illum + 1e-6) compensation = np.clip(compensation, 0.5, 2.0) # 限制补偿幅度 compensated = img[mask_bool] * compensation result[mask_bool] = np.clip(compensated, 0, 255).astype(np.uint8) return result

这段代码的核心思想是低通滤波估计光照分量——阴影的本质是低频光照不均匀,通过高斯模糊提取光照模型,计算出阴影区域和非阴影区域的光照差异系数,然后只对阴影区域做乘性补偿。参数 kernel_size=31 是经验值,过大(如 101)会把阴影当成背景结构,补偿失效;过小(如 7)会把文字和纸张纹理也归入光照变化,导致文字区域被错误增强。补偿幅度限制在 0.5 到 2.0 之间,避免极端情况下的过曝或欠曝。

5.3 一条可复用的 OCR 增强推理链路

把前序步骤串起来,最终形成一条完整的推理链路:读取图像 → 送入分割模型预测掩码 → 形态学后处理 → 光照归一化 → 送入 PaddleOCR 或 Tesseract 识别。在封装这条链路时有一个细节容易被忽略:模型预测时输入的图像尺寸和训练时一致(384x384),但推理时原始图可能是 3000x4000 的分辨率,直接缩放会丢失阴影的细节特征。正确的做法是滑窗推理——把大图切成 384x384 的块,相邻块之间重叠 64 像素,预测完掩码后拼接回原尺寸,再取重叠区域的平均值来消除边界接缝。

切块推理代码示意:

def sliding_window_infer(model, img, crop_size=384, stride=320, device="cuda"): """滑窗推理:小图模型处理大图,stride小于crop_size保证重叠""" h, w = img.shape[:2] mask = np.zeros((h, w), dtype=np.float32) count = np.zeros((h, w), dtype=np.float32) for y in range(0, h - crop_size + 1, stride): for x in range(0, w - crop_size + 1, stride): crop = img[y:y+crop_size, x:x+crop_size] crop_tensor = preprocess(crop).unsqueeze(0).to(device) with torch.no_grad(): logits = model(crop_tensor)["out"] prob = torch.softmax(logits, dim=1)[0, 1] # 前景类概率 prob_np = prob.cpu().numpy() mask[y:y+crop_size, x:x+crop_size] += prob_np count[y:y+crop_size, x:x+crop_size] += 1 # 重叠区域取平均,消除窗口边界处的预测跳变 mask = mask / np.maximum(count, 1) return (mask > 0.5).astype(np.uint8)

stride 和 crop_size 的差值就是重叠区域宽度,这里 stride=320 意味着每个窗口有 64 像素的边界带会被二次预测。为什么不用 O(n^2)的完全覆盖而用重叠?因为如果两个窗口完全没有重叠,拼接处的掩码会出现像素级的断裂纹路,对后续 OCR 的版面分析会产生干扰。重叠平均法牺牲少量推理速度换取空间连续性,在 600 张图的训练场景下,推理时间本来就不是瓶颈,稳定性才是第一位的。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询