本节摘要:写一个带标签联动的随机水平翻转与随机裁剪增强,处理归一化统计量的来源问题。图像增强最容易忽略的不是像素变换本身,而是"标签必须跟着变"与"验证集绝不能被增强"两条铁律。本节把第 2 章的数据泄漏话题搬进视觉场景。
CV 专场第一题从数据段开考,符合真实项目的工作顺序——数据管道的坑永远比模型多。
"写一个随机水平翻转的增强函数,输入是图像和检测框标签,输出变换后的图像和标签。然后回答两个问题:归一化的均值方差从哪来?增强要不要加在验证集上?"
三问连环:第一问考联动,第二问考泄漏,第三问考对"增强"本质的理解——它是人为制造的训练分布扰动。
候选人第一版只翻转了图像。面试官看着代码问:"翻转后框还在原图位置吗?"候选人愣了两秒,自己发现了问题。第二版:
import numpy as np np.random.seed(3) # 固定种子,保证输出可复算 def random_hflip(img, boxes, p=0.5): """img: HxWx3 数组;boxes: 每行是 xmin,ymin,xmax,ymax(像素坐标)""" if np.random.rand() >= p: # 不翻转:原样返回 return img, boxes flipped = img[:, ::-1, :] # 水平镜像:反转宽度维 W = img.shape[1] new_boxes = boxes.copy() new_boxes[:, [0, 2]] = W - boxes[:, [2, 0]] # 新xmin = W - 旧xmax,新xmax = W - 旧xmin return flipped, new_boxes img = np.zeros((100, 200, 3), dtype=np.uint8) img[:, :10] = 255 # 左侧一条白带,便于核对 boxes = np.array([[10., 20., 30., 60.]]) # 框贴着白带 f_img, f_boxes = random_hflip(img, boxes, p=1.0) # p=1 强制翻转,便于演示 print(f_img[:, 0].max(), f_img[:, -1].max()) # 翻转后白带应贴右边 print(f_boxes.tolist())
0 255 [170.0, 20.0, 190.0, 60.0]
白带跑到了右缘(第零列为零、末列为二百五十五),框从左缘十到三十变成右缘一百七十到一百九十:二百减三十得一百七十,二百减十得一百九十。坐标换算的核心是"最大最小要互换着减"——只做平移不减宽,框会整体偏出一个自身宽度。候选人把这句写在了白板边上。
第一问:随机裁剪呢? 裁剪更麻烦:框可能被裁掉一部分,也可能整框出局,过滤规则要写清楚。候选人给出"保留与裁剪后窗口交集足够大的框,并平移坐标"的版本:
def random_crop(img, boxes, margin=20): H, W = img.shape[:2] x0 = np.random.randint(0, margin + 1) # 裁剪窗口左上角 y0 = np.random.randint(0, margin + 1) x1 = W - np.random.randint(0, margin + 1) # 右下角 y1 = H - np.random.randint(0, margin + 1) crop = img[y0:y1, x0:x1] nb = boxes.copy() nb[:, [0, 2]] -= x0 # 坐标平移到新窗口 nb[:, [1, 3]] -= y0 # 与窗口求交:裁掉出界的边 nb[:, [0, 2]] = np.clip(nb[:, [0, 2]], 0, x1 - x0) nb[:, [1, 3]] = np.clip(nb[:, [1, 3]], 0, y1 - y0) keep = (nb[:, 2] - nb[:, 0] > 5) & (nb[:, 3] - nb[:, 1] > 5) # 太小的丢弃 return crop, nb[keep] crop, nb = random_crop(np.zeros((100, 200, 3)), np.array([[0., 0., 199., 99.]])) print(crop.shape, nb.round(0).tolist())
(80, 180, 3) [[0.0, 0.0, 179.0, 79.0]]
窗口最大裁去上下左右各二十像素,输出形状一百八十乘八十;满幅框被裁到与新窗口同大(宽一百七十九点九约一百八十),平移与裁剪逻辑自洽。
第二问:归一化的均值方差从哪来? 候选人答:"从训练集统计,一次算好写死成常数;验证与测试用同一组数。最不该做的是在 DataLoader 里对每个批次现算——统计量随批漂移,等于评估集在跟着测试数据变形,这是第 2 章泄漏话题的图像版。"他补了一段可复算的小演示,把口径钉死:
rng = np.random.default_rng(0) tr = rng.uniform(0, 255, size=(4, 2)) # 只有训练集参与统计 va = rng.uniform(0, 255, size=(2, 2)) # 验证集只被变换 mu, sd = tr.mean(axis=0), tr.std(axis=0) print('训练集均值:', mu.round(1).tolist(), '标准差:', sd.round(1).tolist()) print('验证集套用:', ((va - mu) / sd).round(2).tolist())
训练集均值: [133.7, 122.9] 标准差: [74.0, 90.9] 验证集套用: [[0.07, 1.27], [1.0, -1.34]]
均值与标准差是训练集的常数,验证集只是被映射的对象——代码只有几行,口径立场全在里面。面试官追问"为什么很多预训练模型用固定的千分类数据集统计量",他答:"为了让输入分布与预训练时一致,微调也沿用,分布一致性比'更准的统计'重要。"
第三问:增强为什么只加训练集? 增强的本质是人为扩大训练分布:翻转后的猫还是猫,模型该学会不依赖左右;但验证与测试的使命是模拟真实部署分布,加增强等于"用训练技巧篡改考卷"。他补了一个反例:"任务若天生有方向性,比如交通标志的左右转箭头,水平翻转就把标签翻错了——增强选项要逐任务审。"
高频翻车点:翻转只换坐标不减互换,框整体平移错位;裁剪后不过滤空框与越界框,训练时损失算出负的宽高才暴露;把增强写进验证管道还觉得"指标更稳了"——那是在自己造的数据上考试;颜色类增强(色相、亮度)作用在检测框标签上没有联动问题,但作用在需要颜色的任务(红绿灯识别)上会毁标签语义。还有一个工程坑:增强在 CPU 上做, DataLoader 的工人数量不够时 GPU 在等数据,训练吞吐被数据管道卡死——面试官爱问"训练慢你先查什么",答案常在数据段而不在模型段。
主线候选人这一场的亮点是发现联动问题后立刻把函数签名改成双输入双输出,并主动说"凡是几何变换都要问标签跟不跟"。缺陷是裁剪版一开始忘了过滤空框,被提示"裁完一个框都不剩会怎样"后补上。
关键直觉:数据增强是"图像与标签的同变换系统",不是图像的单机特效——写任何增强前先问一句:标签需要跟着变吗。