1. 数据增强中的同步变换问题拆解
做过目标检测或者实例分割任务的人,大概率都踩过这样一个坑:用torchvision.transforms给图像做随机翻转、随机裁剪或者颜色抖动,图像本身处理得漂漂亮亮,结果训练时模型完全不收敛,或者mAP低得离谱。排查半天才发现,图像翻了,bounding box没翻;图像裁了,mask还停留在原图坐标系里。这种“图文不符”的问题,在检测和分割任务里非常致命。
这个问题的本质是:图像、边界框、实例掩码这三类数据虽然形态不同,但它们共享同一个空间坐标系。任何对图像空间布局产生改变的变换,都必须以完全相同的方式作用到另外两者身上。而颜色类变换(亮度、对比度、色调)只改像素值,不改坐标,所以只需要作用在图像上。理解这个分类,是解决整个问题的第一步。
这篇文章面向的是正在做目标检测、实例分割、关键点检测等密集预测任务的开发者和研究者。不管你用的是Mask R-CNN、YOLO系列、还是自己搭的检测头,只要涉及多模态标注数据的联合增强,这里的内容都能直接参考。我会从设计思路、核心原理、代码实现、常见坑四个层面,把“同步变换”这件事讲透,给出可以直接复现的方案。
2. 为什么不能直接用torchvision的默认变换
2.1 默认变换只认图像,不认标注
torchvision.transforms里的绝大多数变换,设计初衷是处理单张图像做分类任务。RandomHorizontalFlip、RandomCrop、Resize这些类,它们的__call__方法只接收一个PIL Image或者Tensor,返回变换后的图像。它们根本不知道bounding box和mask的存在,自然不会去同步修改这些标注。
有人可能会想:那我手动算一下翻转后的坐标不就行了?对于简单的水平翻转,确实可以手动算。但一旦涉及随机裁剪、随机缩放、旋转、仿射变换,手动推导坐标变换公式就变得非常繁琐,而且极易出错。更麻烦的是,mask的变换不是简单的坐标计算,它需要对像素级掩码做同样的空间重采样,手动实现几乎不现实。
2.2 三类数据的空间变换规则差异
要理解为什么需要统一框架,先得搞清楚三类数据各自的空间变换规则。
| 数据类型 | 形态 | 空间变换方式 | 变换后需要做的后处理 |
|---|---|---|---|
| 图像 | H×W×C 像素矩阵 | 像素重采样(双线性/最近邻) | 无 |
| Bounding Box | 4个坐标值 (x1,y1,x2,y2) | 坐标点做同样的几何变换 | 裁剪越界、过滤无效框 |
| Instance Mask | H×W 二值/类别掩码 | 与图像相同的空间重采样 | 保持二值性、对齐类别 |
关键点在于:图像和mask的变换是“像素级”的,而bbox的变换是“坐标级”的。如果分别处理,就必须保证两者的几何变换参数完全一致。一旦参数有偏差,bbox和mask就会错位。
2.3 同步变换的核心设计原则
基于上面的分析,一个可靠的同步变换方案需要满足几个原则。第一,变换参数必须共享。随机翻转的概率、随机裁剪的窗口、缩放的尺寸,这些参数在图像、bbox、mask三者之间必须是同一套。第二,变换顺序必须一致。先裁剪再翻转和先翻转再裁剪,结果完全不同,三者必须走同一条流水线。第三,后处理要区分对待。bbox变换后需要做越界裁剪和有效性过滤,mask变换后需要保持二值性,图像则不需要这些额外操作。
理解了这三条原则,后面不管是自己写还是用现成库,判断方案是否可靠就有了标准。
3. 主流方案选型与对比
3.1 方案一:torchvision自带的检测变换
从torchvision 0.8版本开始,官方提供了torchvision.transforms.v2以及专门针对检测任务的变换接口。v2版本的设计理念就是支持多模态数据,它引入了TVTensor的概念,bounding box用BoundingBoxes类型表示,mask用Mask类型表示。当你把这些类型和图像一起传给变换时,变换会自动识别数据类型并做相应处理。
这个方案的最大优势是官方维护、API统一、和PyTorch生态无缝衔接。缺点是早期版本功能有限,一些复杂的自定义变换支持不够灵活。不过随着版本迭代,现在v2已经相当成熟,覆盖了翻转、裁剪、缩放、旋转、仿射等常用操作。
3.2 方案二:Albumentations
Albumentations是计算机视觉领域非常流行的增强库,它对检测和分割任务的支持非常完善。它的核心设计是:一个变换对象同时接收image、bboxes、masks等参数,内部保证同步。Albumentations的变换种类极其丰富,性能也经过优化,在Kaggle竞赛和工业界都有大量使用。
它的bbox格式支持多种(pascal_voc、coco、yolo等),使用前需要明确指定格式。mask支持多通道,可以同时处理多个实例的掩码。对于需要复杂增强策略的项目,Albumentations通常是首选。
3.3 方案三:手写同步变换
如果项目对依赖有严格限制,或者只需要少数几种变换,手写也是可行的。手写的核心思路是:把随机参数先采样出来,然后分别应用到图像、bbox、mask上。这种方式最灵活,但工作量大,容易在边界情况上出错,比如裁剪后bbox完全在图像外、mask全黑等情况需要仔细处理。
3.4 三种方案的选型建议
| 方案 | 适用场景 | 优势 | 劣势 |
|---|---|---|---|
| torchvision v2 | 已用PyTorch生态、变换需求常规 | 官方维护、类型安全 | 复杂变换支持有限 |
| Albumentations | 需要丰富增强、竞赛/工业项目 | 变换多、性能好、文档全 | 额外依赖、学习成本 |
| 手写 | 依赖受限、变换简单固定 | 无依赖、完全可控 | 易出错、维护成本高 |
我的建议是:新项目直接用Albumentations,它的同步机制最成熟,踩坑最少。如果团队已经深度绑定PyTorch生态,torchvision v2也完全够用。手写方案只在特殊情况下考虑。
4. 基于Albumentations的完整实现
4.1 环境准备与依赖安装
先把环境搭起来。Albumentations的安装很简单,但要注意版本兼容性。它依赖OpenCV和NumPy,通常pip会自动处理。
pip install albumentations opencv-python numpy如果你用的是较新的Python版本,建议同时确认一下opencv-python的版本,避免和系统里的其他库冲突。我实测下来,albumentations>=1.3.0配合opencv-python>=4.5是比较稳的组合。
4.2 数据格式约定
Albumentations对bbox和mask的格式有明确要求,用之前必须把数据整理成它认识的格式。
bbox方面,它支持pascal_voc(x_min, y_min, x_max, y_max)、coco(x_min, y_min, width, height)、yolo(归一化的x_center, y_center, width, height)等。实例分割任务里,bbox通常是pascal_voc格式,因为和mask的坐标系直接对应。
mask方面,Albumentations期望的输入是numpy数组,形状为(H, W)或(H, W, C)。对于多个实例,有两种组织方式:一种是每个实例一个单独的mask,组成列表;另一种是所有实例堆叠成一个(H, W, num_instances)的数组。后者在批处理时更方便。
import numpy as np import albumentations as A # 假设有一张 480x640 的图像 image = np.random.randint(0, 255, (480, 640, 3), dtype=np.uint8) # 两个实例的bbox,pascal_voc格式 [x_min, y_min, x_max, y_max] bboxes = np.array([ [50, 60, 200, 300], [300, 100, 500, 400] ], dtype=np.float32) # 两个实例的mask,堆叠成 (H, W, 2) mask1 = np.zeros((480, 640), dtype=np.uint8) mask1[60:300, 50:200] = 1 mask2 = np.zeros((480, 640), dtype=np.uint8) mask2[100:400, 300:500] = 1 masks = np.stack([mask1, mask2], axis=-1) # 类别标签 labels = np.array([1, 2], dtype=np.int64)这里有个细节要注意:bbox的坐标必须是浮点数,mask必须是整数类型(通常是uint8)。如果类型不对,Albumentations可能会报错或者静默产生错误结果。
4.3 构建同步变换流水线
下面构建一个典型的训练增强流水线,包含空间变换和颜色变换。关键点是:空间变换要同时处理image、bboxes、masks,颜色变换只处理image。
import albumentations as A from albumentations.pytorch import ToTensorV2 train_transform = A.Compose([ # 空间变换:三者同步 A.HorizontalFlip(p=0.5), A.VerticalFlip(p=0.2), A.RandomRotate90(p=0.3), A.ShiftScaleRotate( shift_limit=0.1, scale_limit=0.2, rotate_limit=15, border_mode=0, p=0.5 ), A.RandomResizedCrop( height=384, width=384, scale=(0.7, 1.0), ratio=(0.8, 1.25), p=0.5 ), # 颜色变换:只作用于图像 A.RandomBrightnessContrast(p=0.3), A.HueSaturationValue(p=0.2), A.GaussNoise(p=0.1), # 归一化和转Tensor A.Normalize(mean=(0.485, 0.456, 0.406), std=(0.229, 0.224, 0.225)), ToTensorV2() ], bbox_params=A.BboxParams( format='pascal_voc', label_fields=['labels'], min_visibility=0.3, min_area=16 ))这段代码里有几个关键配置需要解释。bbox_params里的format='pascal_voc'告诉库bbox的坐标格式。label_fields=['labels']指定类别标签的字段名,这样变换后标签会和bbox一起被正确过滤。min_visibility=0.3表示裁剪后如果bbox可见面积小于原来的30%,就丢弃这个框。min_area=16表示面积小于16像素的框也丢弃。这两个参数非常重要,能避免产生大量无效的小框干扰训练。
4.4 执行变换并验证结果
构建好流水线后,调用方式和普通变换一样,只是多传几个参数。
transformed = train_transform( image=image, bboxes=bboxes, masks=masks, labels=labels ) transformed_image = transformed['image'] # Tensor, (3, 384, 384) transformed_bboxes = transformed['bboxes'] # 变换后的bbox transformed_masks = transformed['masks'] # 变换后的mask transformed_labels = transformed['labels'] # 对应的标签执行完之后,一定要做验证。我习惯写一个可视化函数,把变换后的图像、bbox、mask叠加在一起看,确认三者对齐。
import cv2 def visualize(image, bboxes, masks, labels): img = image.copy() # 画mask for i in range(masks.shape[-1]): m = masks[:, :, i] color = np.array([0, 255, 0]) if labels[i] == 1 else np.array([255, 0, 0]) img[m > 0] = img[m > 0] * 0.5 + color * 0.5 # 画bbox for bbox, label in zip(bboxes, labels): x1, y1, x2, y2 = map(int, bbox) cv2.rectangle(img, (x1, y1), (x2, y2), (0, 255, 255), 2) cv2.putText(img, str(label), (x1, y1 - 5), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 255), 2) return img.astype(np.uint8)如果可视化出来bbox和mask严丝合缝地贴在同一个物体上,说明同步变换生效了。如果发现bbox偏了或者mask错位,就要回头检查格式和参数配置。
5. 基于torchvision v2的实现方案
5.1 v2变换的核心概念
torchvision v2引入了一套新的类型系统。图像、bbox、mask都被包装成特定的TVTensor子类。当你把这些对象一起传给变换时,变换内部会检查每个输入的类型,然后调用对应的处理逻辑。这套机制的好处是类型安全,不容易出现“传了但没处理”的情况。
5.2 数据准备与变换构建
用v2之前,需要把原始数据转换成对应的类型。
import torch from torchvision import tv_tensors from torchvision.transforms import v2 # 图像转Tensor image_tensor = torch.from_numpy(image).permute(2, 0, 1).float() / 255.0 # bbox包装成BoundingBoxes类型 bboxes_tv = tv_tensors.BoundingBoxes( torch.tensor(bboxes, dtype=torch.float32), format="XYXY", canvas_size=(480, 640) ) # mask包装成Mask类型 masks_tv = tv_tensors.Mask( torch.from_numpy(masks).permute(2, 0, 1).float() ) # 构建变换 transform = v2.Compose([ v2.RandomHorizontalFlip(p=0.5), v2.RandomVerticalFlip(p=0.2), v2.RandomResizedCrop(size=(384, 384), scale=(0.7, 1.0)), v2.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2), v2.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) # 执行变换 out_image, out_bboxes, out_masks = transform(image_tensor, bboxes_tv, masks_tv)v2的API设计比较直观,BoundingBoxes的canvas_size参数很重要,它记录了bbox所在的图像尺寸,变换时会根据这个尺寸做坐标换算。如果canvas_size设错了,变换结果就会错。
5.3 v2方案的注意事项
v2方案有几个容易踩的坑。第一,BoundingBoxes的format参数必须和实际数据格式一致,写错了不会报错但结果全错。第二,v2的变换对输入类型检查比较严格,如果传了普通的Tensor而不是TVTensor,变换可能不会正确处理。第三,v2的版本迭代较快,不同版本之间API有差异,建议锁定版本使用。
6. 手写同步变换的完整流程
6.1 随机参数采样与共享
手写方案的核心是:先采样随机参数,再分别应用。以随机水平翻转和随机裁剪为例。
import random import numpy as np def sample_params(image, crop_size=384): h, w = image.shape[:2] # 采样翻转 do_flip = random.random() < 0.5 # 采样裁剪窗口 scale = random.uniform(0.7, 1.0) crop_h = int(h * scale) crop_w = int(w * scale) top = random.randint(0, h - crop_h) left = random.randint(0, w - crop_w) return { 'do_flip': do_flip, 'crop': (top, left, crop_h, crop_w), 'target_size': (crop_size, crop_size) }参数采样出来后,图像、bbox、mask都基于同一套参数做变换,这样就能保证同步。
6.2 图像与mask的像素级变换
图像和mask的变换方式相同,都是先裁剪再翻转再缩放。
import cv2 def transform_image_and_mask(image, masks, params): top, left, crop_h, crop_w = params['crop'] # 裁剪 image_c = image[top:top+crop_h, left:left+crop_w] masks_c = masks[top:top+crop_h, left:left+crop_w] # 翻转 if params['do_flip']: image_c = image_c[:, ::-1] masks_c = masks_c[:, ::-1] # 缩放 th, tw = params['target_size'] image_r = cv2.resize(image_c, (tw, th), interpolation=cv2.INTER_LINEAR) masks_r = np.stack([ cv2.resize(masks_c[:, :, i], (tw, th), interpolation=cv2.INTER_NEAREST) for i in range(masks_c.shape[-1]) ], axis=-1) return image_r, masks_r注意mask的缩放必须用INTER_NEAREST最近邻插值,不能用双线性。因为mask是类别标签,插值会产生0.5这种无意义的中间值,破坏二值性。
6.3 bbox的坐标级变换
bbox的变换是纯坐标计算,不涉及像素重采样。
def transform_bboxes(bboxes, params, orig_h, orig_w): top, left, crop_h, crop_w = params['crop'] th, tw = params['target_size'] scale_x = tw / crop_w scale_y = th / crop_h new_bboxes = [] for bbox in bboxes: x1, y1, x2, y2 = bbox # 裁剪:平移坐标 x1, x2 = x1 - left, x2 - left y1, y2 = y1 - top, y2 - top # 翻转 if params['do_flip']: x1, x2 = crop_w - x2, crop_w - x1 # 缩放 x1, x2 = x1 * scale_x, x2 * scale_x y1, y2 = y1 * scale_y, y2 * scale_y # 越界裁剪 x1 = max(0, min(x1, tw)) x2 = max(0, min(x2, tw)) y1 = max(0, min(y1, th)) y2 = max(0, min(y2, th)) # 过滤无效框 if x2 - x1 < 2 or y2 - y1 < 2: continue new_bboxes.append([x1, y1, x2, y2]) return np.array(new_bboxes, dtype=np.float32)这段代码里,越界裁剪和无效框过滤是必须的。裁剪后bbox可能部分或完全在图像外,如果不处理,训练时就会出问题。
6.4 手写方案的验证要点
手写方案最容易出错的地方是坐标计算。我的经验是:每写一个变换,就用一个已知的简单例子验证。比如图像是100x100,bbox是[10,10,50,50],水平翻转后应该是[50,10,90,50]。用这种小例子把每个变换都验证一遍,比直接上真实数据调试效率高得多。
7. 常见问题与排查技巧实录
7.1 bbox和mask错位的排查
这是最常见的问题。排查思路是:先固定随机种子,让变换可复现;然后可视化变换后的结果,看错位的模式。如果bbox整体偏移,通常是坐标格式搞错了,比如把pascal_voc当成coco。如果bbox和mask的错位方向不一致,可能是变换顺序不同,比如图像先翻转后裁剪,bbox先裁剪后翻转。
7.2 变换后bbox数量对不上
有时候变换后bbox数量变少了,这是正常的,因为越界或面积过小的框被过滤了。但如果数量对不上且没有合理原因,就要检查label_fields配置。Albumentations里,如果label_fields没配好,bbox被过滤时标签不会同步过滤,导致bbox和标签数量不一致。
7.3 mask变换后出现中间值
如果mask变换后出现了0和1之外的值,说明用了错误的插值方式。mask的缩放、旋转必须用最近邻插值。在Albumentations里,大部分空间变换对mask会自动用最近邻,但自定义变换时要自己注意。
7.4 性能问题与优化
同步变换涉及三类数据的处理,如果数据量大,可能成为训练瓶颈。优化方向有几个:一是用num_workers开启多进程数据加载;二是把变换放在GPU上做(Albumentations支持部分GPU变换);三是减少不必要的变换,只保留对任务真正有帮助的。
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| bbox整体偏移 | 坐标格式错误 | 检查format参数 | 统一为pascal_voc |
| bbox与mask错位 | 变换顺序不一致 | 固定种子可视化 | 统一流水线顺序 |
| bbox数量异常 | 过滤参数过严 | 调整min_visibility | 放宽阈值观察 |
| mask出现中间值 | 插值方式错误 | 检查mask dtype | 改用最近邻 |
| 训练速度慢 | 变换成瓶颈 | profile数据加载 | 多进程/GPU变换 |
7.5 几个独家避坑技巧
第一个技巧:永远先在小数据上验证。拿10张图跑一遍完整流水线,可视化检查,确认无误再上全量数据。第二个技巧:固定随机种子做回归测试。每次修改变换配置后,用固定种子跑一遍,对比结果是否和预期一致。第三个技巧:保存变换后的可视化结果。训练前随机抽几张变换后的图存下来,出问题时可以回溯。
8. 不同任务场景下的适配策略
8.1 目标检测任务的适配
目标检测只需要image和bbox同步,不需要mask。这时候可以用更轻量的配置,省去mask处理的开销。但要注意,检测任务对bbox的完整性要求更高,min_visibility可以设得低一些,避免漏掉小目标。
8.2 实例分割任务的适配
实例分割需要三者同步,是本文的重点。额外要注意的是:mask的通道数要和实例数一致,变换后如果某个实例被完全裁掉,对应的mask通道和bbox、label都要一起移除。Albumentations会自动处理这个逻辑,但手写方案要自己实现。
8.3 关键点检测任务的适配
关键点检测是另一个需要同步变换的任务。关键点的变换规则和bbox类似,都是坐标级变换。Albumentations也支持keypoints参数,用法和bbox类似。关键点的特殊之处在于:被裁掉的关键点要标记为不可见,而不是直接删除,因为关键点检测模型需要学习“不可见”这个状态。
8.4 多任务联合训练的适配
如果模型同时做检测和分割,那image、bbox、mask、keypoints可能都要同步。这时候建议统一用Albumentations的Compose,把所有需要的参数都配上,让库统一处理同步逻辑。自己维护多套变换的同步关系,出错概率会高很多。
9. 训练流水线中的集成实践
9.1 Dataset中的变换集成
在实际训练中,变换通常放在Dataset的__getitem__里。下面是一个典型的集成方式。
from torch.utils.data import Dataset class DetectionDataset(Dataset): def __init__(self, image_paths, annotations, transform=None): self.image_paths = image_paths self.annotations = annotations self.transform = transform def __len__(self): return len(self.image_paths) def __getitem__(self, idx): image = cv2.imread(self.image_paths[idx]) image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) ann = self.annotations[idx] bboxes = ann['bboxes'] masks = ann['masks'] labels = ann['labels'] if self.transform: out = self.transform( image=image, bboxes=bboxes, masks=masks, labels=labels ) image = out['image'] bboxes = out['bboxes'] masks = out['masks'] labels = out['labels'] # 转成训练需要的格式 target = { 'boxes': torch.tensor(bboxes, dtype=torch.float32), 'masks': torch.tensor(masks, dtype=torch.float32), 'labels': torch.tensor(labels, dtype=torch.int64) } return image, target9.2 验证集变换的特殊处理
验证集通常不做随机增强,只做确定性的resize和归一化。但resize同样需要同步处理bbox和mask。所以验证集的变换也要用同样的Compose结构,只是把随机变换的p设为0,或者直接用确定性的变换。
val_transform = A.Compose([ A.Resize(height=384, width=384), A.Normalize(mean=(0.485, 0.456, 0.406), std=(0.229, 0.224, 0.225)), ToTensorV2() ], bbox_params=A.BboxParams( format='pascal_voc', label_fields=['labels'] ))9.3 数据加载器的配置
DataLoader的配置对性能影响很大。num_workers建议设为CPU核心数的一半到全部,pin_memory=True可以加速GPU传输,persistent_workers=True避免每个epoch重新创建worker。
from torch.utils.data import DataLoader train_loader = DataLoader( train_dataset, batch_size=8, shuffle=True, num_workers=8, pin_memory=True, persistent_workers=True, collate_fn=collate_fn )注意检测任务的collate_fn需要自定义,因为每张图的bbox和mask数量不同,不能直接stack。通常的做法是把它们组织成列表,在模型内部再处理。
10. 效果验证与调试方法
10.1 可视化验证的标准流程
可视化是验证同步变换最直接的方法。我的标准流程是:从数据集中随机抽5到10张图,对每张图跑一遍变换,把变换前后的图像、bbox、mask都画出来,并排显示。重点看三个地方:bbox是否紧贴目标、mask是否覆盖目标区域、bbox和mask是否对齐。
10.2 数值验证的方法
除了可视化,数值验证也很重要。可以写一些断言,检查变换后的数据是否满足基本约束。比如bbox坐标是否在图像范围内、mask是否只有0和1、bbox和mask的数量是否一致。
def validate_transformed(image, bboxes, masks, labels): h, w = image.shape[-2:] assert len(bboxes) == len(labels), "bbox和label数量不一致" assert masks.shape[-1] == len(bboxes), "mask和bbox数量不一致" for bbox in bboxes: x1, y1, x2, y2 = bbox assert 0 <= x1 < x2 <= w, f"bbox x坐标越界: {bbox}" assert 0 <= y1 < y2 <= h, f"bbox y坐标越界: {bbox}" assert set(np.unique(masks)).issubset({0, 1}), "mask包含非二值像素"这些断言在调试阶段非常有用,能快速定位问题。
10.3 训练指标异常时的排查思路
如果训练时loss异常或者mAP很低,除了模型本身的问题,也要怀疑数据变换。排查顺序是:先确认变换后的数据可视化正常,再确认数据加载没有bug,最后才怀疑模型。我遇到过好几次loss不收敛,最后发现是mask变换用了错误的插值方式,导致mask全是噪声。
11. 个人实操经验与建议
11.1 变换配置的版本管理
变换配置是训练配置的一部分,建议和模型配置一起做版本管理。每次修改变换参数,都记录下修改内容和对应的实验结果。这样当效果波动时,可以快速定位是哪个改动导致的。我习惯用YAML文件管理变换配置,配合实验跟踪工具,回溯起来很方便。
11.2 从简单到复杂的渐进策略
刚开始做同步变换时,不要一上来就配一大堆变换。先用最简单的翻转和resize跑通流程,确认同步机制没问题,再逐步加入裁剪、旋转、颜色变换。每加一个变换,都验证一遍。这样出问题时,范围小,好排查。
11.3 关于增强强度的经验
增强强度不是越大越好。对于检测和分割任务,过强的几何变换会产生大量越界框和残缺mask,反而干扰训练。我的经验是:翻转类变换可以放心用,裁剪和缩放的强度要适中,旋转角度不要超过15度。颜色变换的强度也要控制,过强的颜色抖动会让模型对颜色不敏感,影响某些依赖颜色的任务。
11.4 一个容易被忽视的细节
mask的存储格式对性能有影响。如果mask用(H, W, num_instances)的稠密数组存储,当实例数很多时内存占用会很大。可以考虑用RLE编码存储,变换时先解码再编码。不过RLE的编解码有开销,需要权衡。对于实例数不多的场景,稠密数组更简单直接。
最后再分享一个小技巧:如果你的数据集里有些图像没有标注(负样本),变换时bbox和mask是空的。Albumentations能处理空列表,但要注意min_visibility等参数对空输入的行为。我建议对负样本单独处理,或者确保变换配置对空输入是安全的。这个细节在调试时容易被忽略,但实际训练中负样本很重要,处理不好会影响模型的误检率。