专栏首页AI研习社CV 新手避坑指南:计算机视觉常见的8个错误

CV 新手避坑指南:计算机视觉常见的8个错误

人类并不是完美的,我们经常在编写软件的时候犯错误。有时这些错误很容易找到:你的代码根本不工作,你的应用程序会崩溃。但有些 bug 是隐藏的,很难发现,这使它们更加危险。

在处理深度学习问题时,由于某些不确定性,很容易产生此类错误:很容易看到 web 应用的端点路由请求是否正确,但却不容易检查梯度下降步骤是否正确。然而,在深度学习实践例程中有很多 bug 是可以避免的。

我想和大家分享一下我在过去两年的计算机视觉工作中所发现或产生的错误的一些经验。我在会议上谈到过这个话题,很多人在会后告诉我:「是的,老兄,我也有很多这样的 bug。」我希望我的文章能帮助你避免其中的一些问题。

1.翻转图像和关键点

假设有人在研究关键点检测问题。它们的数据看起来像一对图像和一系列关键点元组,例如 [(0,1),(2,2)],其中每个关键点是一对 x 和 y 坐标。

让我们对这些数据进行基本的增强:

def flip_img_and_keypoints(img: np.ndarray, kpts: Sequence[Sequence[int]]): img = np.fliplr(img) h, w, *_ = img.shape kpts = [(y, w - x) for y, x in kpts] return img, kpts

上面的代码看起来很对,是不是?接下来,让我们对它进行可视化。

image = np.ones((10, 10), dtype=np.float32) kpts = [(0, 1), (2, 2)] image_flipped, kpts_flipped = flip_img_and_keypoints(image, kpts) img1 = image.copy() for y, x in kpts: img1[y, x] = 0 img2 = image_flipped.copy() for y, x in kpts_flipped: img2[y, x] = 0 _ = plt.imshow(np.hstack((img1, img2)))

这个图是不对称的,看起来很奇怪!如果我们检查极值呢?

image = np.ones((10, 10), dtype=np.float32) kpts = [(0, 0), (1, 1)] image_flipped, kpts_flipped = flip_img_and_keypoints(image, kpts) img1 = image.copy() for y, x in kpts: img1[y, x] = 0 img2 = image_flipped.copy() for y, x in kpts_flipped: img2[y, x] = 0 -------------------------------------------------------------------- ------- IndexError Traceback (most recent call last) <ipython-input-5-997162463eae> in <module> 8 img2 = image_flipped.copy() 9 for y, x in kpts_flipped: ---> 10 img2[y, x] = 0 IndexError: index 10 is out of bounds for axis 1 with size 10

不好!这是一个典型的错误。正确的代码如下:

def flip_img_and_keypoints(img: np.ndarray, kpts: Sequence[Sequence[int]]): img = np.fliplr(img) h, w, *_ = img.shape kpts = [(y, w - x - 1) for y, x in kpts] return img, kpts

我们已经通过可视化检测到这个问题,但是,使用 x=0 点的单元测试也会有帮助。一个有趣的事实是:我们团队三个人(包括我自己)各自独立地犯了几乎相同的错误。

2.继续谈谈关键点

即使上述函数已修复,也存在危险。接下来更多的是关于语义,而不仅仅是一段代码。

假设一个人需要用两只手掌来增强图像。看起来很安全——手在左右翻转后会还是手。

但是等等!我们对关键点语义一无所知。如果关键点真的是这样的意思呢:

kpts = [ (20, 20), # left pinky (20, 200), # right pinky ... ]

这意味着增强实际上改变了语义:left 变为 right,right 变为 left,但是我们不交换数组中的 keypoints 索引。它会给训练带来巨大的噪音和更糟糕的指标。

这里应该吸取教训:

  • 在应用增强或其他特性之前,了解并考虑数据结构和语义;
  • 保持你的实验的独立性:添加一个小的变化(例如,一个新的转换),检查它是如何进行的,如果分数提高了再合并。

3.自定义损失函数

熟悉语义分割问题的人可能知道 IoU (intersection over union)度量。不幸的是,我们不能直接用 SGD 来优化它,所以一个常见的技巧是用可微损失函数来逼近它。让我们编写相关代码!

def iou_continuous_loss(y_pred, y_true): eps = 1e-6 def _sum(x): return x.sum(-1).sum(-1) numerator = (_sum(y_true * y_pred) + eps) denominator = (_sum(y_true ** 2) + _sum(y_pred ** 2) - _sum(y_true * y_pred) + eps) return (numerator / denominator).mean()

看起来很不错,让我们做一个小小的检查:

In [3]: ones = np.ones((1, 3, 10, 10)) ...: x1 = iou_continuous_loss(ones * 0.01, ones) ...: x2 = iou_continuous_loss(ones * 0.99, ones) In [4]: x1, x2 Out[4]: (0.010099999897990103, 0.9998990001020204)

在 x1 中,我们计算了与标准答案完全不同的损失,x2 是非常接近标准答案的函数的结果。我们预计 x1 会很大,因为预测结果并不好,x2 应该接近于零。这其中发生了什么?

上面的函数是度量的一个很好的近似。度量不是损失:它通常越高越好。因为我们要用 SGD 把损失降到最低,我们真的应该采用用相反的方法:

v> def iou_continuous(y_pred, y_true): eps = 1e-6 def _sum(x): return x.sum(-1).sum(-1) numerator = (_sum(y_true * y_pred) + eps) denominator = (_sum(y_true ** 2) + _sum(y_pred ** 2) - _sum(y_true * y_pred) + eps) return (numerator / denominator).mean() def iou_continuous_loss(y_pred, y_true): return 1 - iou_continuous(y_pred, y_true)

这些问题可以通过两种方式确定:

  • 编写一个单元测试来检查损失的方向:形式化地表示一个期望,即更接近实际的东西应该输出更低的损失;
  • 做一个全面的检查,尝试过拟合你的模型的 batch。

4.使用 Pytorch

假设一个人有一个预先训练好的模型,并且是一个时序模型。我们基于 ceevee api 编写预测类。

from ceevee.base import AbstractPredictor class MySuperPredictor(AbstractPredictor): def __init__(self, weights_path: str, ): super().__init__() self.model = self._load_model(weights_path=weights_path) def process(self, x, *kw): with torch.no_grad(): res = self.model(x) return res @staticmethod def _load_model(weights_path): model = ModelClass() weights = torch.load(weights_path, map_location='cpu') model.load_state_dict(weights) return model

这个密码正确吗?也许吧!对某些模型来说确实是正确的。例如,当模型没有规范层时,例如 torch.nn.BatchNorm2d;或者当模型需要为每个图像使用实际的 norm 统计信息时(例如,许多基于 pix2pix 的架构需要它)。

但是对于大多数计算机视觉应用程序来说,代码遗漏了一些重要的东西:切换到评估模式。

如果试图将动态 pytorch 图转换为静态 pytorch 图,则很容易识别此问题。有一个 torch.jit 模块是用于这种转换的。

一个简单的修复:

In [4]: model = nn.Sequential( ...: nn.Linear(10, 10), ..: nn.Dropout(.5) ...: ) ...: ...: traced_model = torch.jit.trace(model.eval(), torch.rand(10)) # No more warnings!

此时,torch.jit.trace 多次运行模型并比较结果。这里看起来似乎没有区别。

然而,这里的 torch.jit.trace 不是万能的。这是一种应该知道并记住的细微差别。

5.复制粘贴问题

很多东西都是成对存在的:训练和验证、宽度和高度、纬度和经度……如果仔细阅读,你可以很容易地发现由一对成员之间的复制粘贴引起的错误:

v> def make_dataloaders(train_cfg, val_cfg, batch_size): train = Dataset.from_config(train_cfg) val = Dataset.from_config(val_cfg) shared_params = {'batch_size': batch_size, 'shuffle': True, 'num_workers': cpu_count()} train = DataLoader(train, **shared_params) val = DataLoader(train, **shared_params) return train, val

不仅仅是我犯了愚蠢的错误。在流行库中也有类似的错误。

# https://github.com/albu/albumentations/blob/0.3.0/albumentations/aug mentations/transforms.py def apply_to_keypoint(self, keypoint, crop_height=0, crop_width=0, h_start=0, w_start= 0, rows=0, cols=0, **params): keypoint = F.keypoint_random_crop(keypoint, crop_height, crop_width, h_start, w_start, rows, cols) scale_x = self.width / crop_height scale_y = self.height / crop_height keypoint = F.keypoint_scale(keypoint, scale_x, scale_y) return keypoint

别担心,这个错误已经修复了。如何避免?不要复制粘贴代码,尽量以不要以复制粘贴的方式进行编码。

datasets = [] data_a = get_dataset(MyDataset(config['dataset_a']), config['shared_param'], param_a) datasets.append(data_a) data_b = get_dataset(MyDataset(config['dataset_b']), config['shared_param'], param_b) datasets.append(data_b)

datasets = [] for name, param in zip(('dataset_a', 'dataset_b'), (param_a, param_b), ): datasets.append(get_dataset(MyDataset(config[name]), config['shared_param'], param))

6.合适的数据类型

让我们再做一个增强:

def add_noise(img: np.ndarray) -> np.ndarray: mask = np.random.rand(*img.shape) + .5 img = img.astype('float32') * mask return img.astype('uint8')

图像已经改变了。这是我们期望的吗?嗯,也许改变太多了。

这里有一个危险的操作:将 float32 转到 uint8。这可能导致溢出:

def add_noise(img: np.ndarray) -> np.ndarray: mask = np.random.rand(*img.shape) + .5 img = img.astype('float32') * mask return np.clip(img, 0, 255).astype('uint8') img = add_noise(cv2.imread('two_hands.jpg')[:, :, ::-1]) _ = plt.imshow(img)

看起来好多了,是吧?

顺便说一句,还有一个方法可以避免这个问题:不要重新发明轮子,可以在前人的基础上,修改代码。例如:albumentations.augmentations.transforms.GaussNoise 。

我又产生了同样来源的 bug。

这里出了什么问题?首先,使用三次插值调整 mask 的大小是个坏主意。将 float32 转换为 uint8 也存在同样的问题:三次插值可以输出大于输入的值,并导致溢出。

我发现了这个问题。在你的循环里面有断言也是一个好主意。

7.打字错误

假设需要对全卷积网络(如语义分割问题)和一幅巨大的图像进行处理。图像太大了,你没有机会把它放进你的 gpu 中——例如,它可以是一个医学或卫星图像。

在这种情况下,可以将图像分割成一个网格,独立地对每一块进行推理,最后合并。另外,一些预测交集可以用来平滑边界附近的伪影。

我们来编码吧!

from tqdm import tqdm class GridPredictor: """ This class can be used to predict a segmentation mask for the big image when you have GPU memory limitation """ def __init__(self, predictor: AbstractPredictor, size: int, stride: Optional[int] = None): self.predictor = predictor self.size = size self.stride = stride if stride is not None else size // 2 def __call__(self, x: np.ndarray): h, w, _ = x.shape mask = np.zeros((h, w, 1), dtype='float32') weights = mask.copy() for i in tqdm(range(0, h - 1, self.stride)): for j in range(0, w - 1, self.stride): a, b, c, d = i, min(h, i + self.size), j, min(w, j + self.size) patch = x[a:b, c:d, :] mask[a:b, c:d, :] += np.expand_dims(self.predictor(patch), -1) weights[a:b, c:d, :] = 1 return mask / weights

有一个符号输入错误,代码片段足够大,因此可以很容易地找到它。我怀疑仅仅通过代码就可以快速识别它,很容易检查代码是否正确:

class Model(nn.Module): def forward(self, x): return x.mean(axis=-1) model = Model() grid_predictor = GridPredictor(model, size=128, stride=64) simple_pred = np.expand_dims(model(img), -1) grid_pred = grid_predictor(img) np.testing.assert_allclose(simple_pred, grid_pred, atol=.001)

调用方法的正确版本如下:

def __call__(self, x: np.ndarray): h, w, _ = x.shape mask = np.zeros((h, w, 1), dtype='float32') weights = mask.copy() for i in tqdm(range(0, h - 1, self.stride)): for j in range(0, w - 1, self.stride): a, b, c, d = i, min(h, i + self.size), j, min(w, j + self.size) patch = x[a:b, c:d, :] mask[a:b, c:d, :] += np.expand_dims(self.predictor(patch), -1) weights[a:b, c:d, :] += 1 return mask / weights

如果你仍然没有看出问题所在,请注意线宽 [a:b,c:d,:]+=1。

8.ImageNet 规范化

当一个人需要进行迁移学习时,通常最好像训练 ImageNet 时那样对图像进行标准化。

让我们使用我们已经熟悉的 albumentations 库。

from albumentations import Normalize norm = Normalize() img = cv2.imread('img_small.jpg') mask = cv2.imread('mask_small.png', cv2.IMREAD_GRAYSCALE) mask = np.expand_dims(mask, -1) # shape (64, 64) -> shape (64, 64, 1) normed = norm(image=img, mask=mask) img, mask = [normed[x] for x in ['image', 'mask']] def img_to_batch(x): x = np.transpose(x, (2, 0, 1)).astype('float32') return torch.from_numpy(np.expand_dims(x, 0)) img, mask = map(img_to_batch, (img, mask)) criterion = F.binary_cross_entropy

现在是时候训练一个网络并使其过拟合某一张图像了——正如我所提到的,这是一种很好的调试技术:

model_a = UNet(3, 1) optimizer = torch.optim.Adam(model_a.parameters(), lr=1e-3) losses = [] for t in tqdm(range(20)): loss = criterion(model_a(img), mask) losses.append(loss.item()) optimizer.zero_grad() loss.backward() optimizer.step() _ = plt.plot(losses)

曲率看起来很好,但交叉熵的损失值预计不会是 -300。这是怎么了?

图像的标准化效果很好,需要手动将其缩放到 [0,1]。

model_b = UNet(3, 1) optimizer = torch.optim.Adam(model_b.parameters(), lr=1e-3) losses = [] for t in tqdm(range(20)): loss = criterion(model_b(img), mask / 255.) losses.append(loss.item()) optimizer.zero_grad() loss.backward() optimizer.step() _ = plt.plot(losses)

训练循环中一个简单的断言(例如 assert mask.max()<=1)会很快检测到问题。同样,单元测试也可以检测到问题。

总而言之:

  • 测试很重要;
  • 运行断言可以用于训练管道;
  • 可视化是一种不错的手段;
  • 抄袭是一种诅咒;
  • 没有什么是灵丹妙药,机器学习工程师必须时刻小心。

本文分享自微信公众号 - AI研习社(okweiwu),作者:skura

原文出处及转载信息见文内详细说明,如有侵权,请联系 yunjia_community@tencent.com 删除。

原始发表时间:2019-10-16

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

我来说两句

0 条评论
登录 后参与评论

相关文章

  • 用PyTorch做物体检测和追踪

    在我之前的工作中,我尝试过用自己的图像在PyTorch中训练一个图像分类器,然后用它来进行图像识别。现在,我将向你们展示如何使用预训练的分类器在一张图像中检测多...

    AI研习社
  • 如何使用注意力模型生成图像描述?

    我们的目标是用一句话来描述图片, 比如「一个冲浪者正在冲浪」。 本教程中用到了基于注意力的模型,它使我们很直观地看到当文字生成时模型会关注哪些部分。

    AI研习社
  • 专栏 | 【从零开始学习YOLOv3】7. 教你在YOLOv3模型中添加Attention机制

    正如[convolutional],[maxpool],[net],[route]等层在cfg中的定义一样,我们再添加全新的模块的时候,要规定一下cfg的格式。...

    AI研习社
  • 获取素材图无忧,Pixabay图库网Python多线程采集下载

    图片素材想必是不少人都在寻找的内容,随着版权意识的加深,可供免费使用的图片素材可不是那么好找的哦,不过还是有不少国外知名素材网站可供我们使用,而且国内访问也是比...

    二爷
  • Python: simple drawi

    py3study
  • JQuery分析及实现part6之动画模块功能及实现

    ihoey
  • JavaScript设计模式--代理模式

    代理模式:为一个对象提供一个代用品或占位符,以便控制对它的访问。 代理分为:保护代理和虚拟代理 保护代理:用于控制不同权限的对象对目标对象的访问,在Java...

    奋飛
  • 使用腾讯云 GPU 学习深度学习系列之四:深度学习的特征工程

    本系列文章主要介绍如何使用 腾讯云GPU服务器 进行深度学习运算,前面主要介绍原理部分,后期则以实践为主。

    集智
  • SDWebImageV3.7.5源码解析

    SDWebImage更新到如今这个版本,过程做了许多改进,性能已经非常的好了。以前就粗略的看过SDWebImage的源码,但是未做记录整理。再次阅读还是受益良多...

    Haley_Wong

扫码关注云+社区

领取腾讯云代金券