Edited at

機械学習ライブラリの resize_images(bilinear 補間) の振る舞いがバラバラである


背景


  • Chainer で書かれた automatic portrait image matting のモデルを, ONNX にコンバートしてモバイルで動かしたい


  • resize_images を pyramid pooling で利用している.


  • onnx-chainer では resize_images のエクスポートに対応していなかったので, 対応しようとしたところ, resize_images(より正確には upsampling + bilinear 補間)の振る舞いが Chainer と ONNX で異なる(TensorFlow とも異なる)ことがわかった

https://github.com/chainer/onnx-chainer/issues/147

Chainer, ONNX, TensorFlow いずれもドキュメントでは bilinear 補間するとだけ書いてあり, 実際どのような bilinear 補間をするのかという仕様がありません.

実のところ, bilinear 補間は, ピクセルの中心を基準にして補間するのか, ピクセルの左上を基準にするのか, 精度をどれくらい保つのか(e.g. 演算器をケチりたいとき), 端(border)のピクセルをどう扱うのか, etc とで意外と面倒です.

(もっと突き詰めると浮動小数点の丸めとかも出てくるが, そのまでの精度の問題は機械学習用途では出てこないものとします)

その結果各機械学習ライブラリで振る舞いがバラバラというのがわかりました.


resize のふるまい違いにより起こった悲しい例

How Tensorflow’s tf.image.resize stole 60 days of my life

https://hackernoon.com/how-tensorflows-tf-image-resize-stole-60-days-of-my-life-aba5eb093f35

resize_images のバグ(少なくとも cv2.resize と結果がことなる)により, 60 日(!):scream::scream::scream:もの人生のロスに繋がった悲しい例. この post では cv2.resize を使おう, とありますが, でもしかしモバイルとかでモデルを動かしたいときはモデルに resize を組み込んで使いたいので, 機械学習ライブラリ側を直したいですよね.

幸いにも私は onnx-chainer のテストを書いていたのと, 上記の post で問題があることに気づいたので, 1 営業日のロスですみました. ありがとうございます.


テスト構成

2x2 の

[[64, 32], 

[64, 32]]

を 4x4 に bilinear upsample して結果がどうなるか調べます.

1D 配列でもよいかと思いましたが, 一応 bilinear を検証するということで, 2D にしました. 結果は 1D で見比べます.


Chainer

Chainer では resize_images があります.

https://docs.chainer.org/en/stable/reference/generated/chainer.functions.resize_images.html

bilinear 補間モードしかサポートしていません.

import chainer

import chainer.functions as F

import numpy as np

scales=(4, 4)
x = np.array([[[[64, 32],
[64, 32]]]], np.float32)

print(x.shape)
img = F.resize_images(x, scales)

print(img)

[64, 53.33333, 42.6666, 32]

と補間されます.


ONNX

ONNX 自体では, bilinear 補間するものとして, Upsample(deprecated. opset <= 9), Reshape(opset >= 10) があります. onnx-chainer では opset 7 までのサポートなので, Upsampe を使います.

ちなみに, その名前とは裏腹に, output_shape が input_shape より小さいときは Downsample します.

仕様では, 補間では linear を指定できるが, これは bilinear と解釈されるかもね, というあやふやなものです.

https://github.com/onnx/onnx/blob/master/docs/Operators.md#Upsample

issue でいろいろ上がっていますが, ONNX メンテナ?のやる気がないのが見て取れます(仕様を PR してくれとか, PR あってもほったからしとか)

https://github.com/onnx/onnx/issues/1774

https://github.com/onnx/onnx/issues/1558

onnx-chainer では, unit test で onnxruntime と結果を比較していますので, onnxruntime の結果と比較してみます.

リファレンス実装(?)である onnxruntime では, linear モードのときに bilinear 補間します.

結果は

[64, 48, 32, 32]

となります.


Tensorflow r1.13

https://www.tensorflow.org/api_docs/python/tf/image/resize_images

tf.image.resize_images() - weird padding behaviour?

https://github.com/tensorflow/tensorflow/issues/6720

resize_images がおかしいという issue は, 2017 年 1 月に post され, 実に二年の歳月をかけて https://github.com/tensorflow/tensorflow/commit/371c96df55a7b23eb8d8496fb477b473fd137fcc にて修正されました(とはいえなんかあまり十分な検証や仕様定義が github issue 側で行われず, いつのまにかぺろっと修正されているのでもやもやしますね)

ただ, r1.x では後方互換性のためか引き続き問題のある実装のままになります. r2.0 から新しいものになります.

resize_images には align_corners という option があります(あまり詳しい説明はないが端まわりでの補間が変わる模様).

import tensorflow as tf

import numpy as np

print(tf.__version__)

# NCHW
img = np.array([[[[64, 32],
[64, 32]]]], np.float32)
print(img.shape)

# to NHWC
img = tf.transpose(img, [0, 2, 3, 1])
print(img.shape)

img = tf.image.resize_images(img, size=[4, 4])

sess = tf.Session()
ret = sess.run(img)
print(ret)



  • [64, 48, 32, 32] (デフォルト. align_corners = False)


  • [64, 53.333336, 42.666668, 32] (align_corners = True)

となります.

Chainer とは, align_corners = True のときに結果が一致しますね.


TensorFlow lite

ドキュメントでは resize_bilinear op は未サポートとありますが, 少なくとも CPU(+ ARM NEON optimized version)は動きます. ただし align_corners パラメータはありません.

結果は tf 1.x + align_corners=False のときと同じで [64, 48, 32, 32] となります.


TensorFlow r2.0

import tensorflow as tf

import numpy as np

print(tf.__version__)

# NCHW
img = np.array([[[[64, 32],
[64, 32]]]], np.float32)
print(img.shape)

# to NHWC
img = tf.transpose(img, [0, 2, 3, 1])
print(img.shape)

img = tf.image.resize(img, size=[4, 4])

print(img)

TensorFlow 2.0 では, r1.x でのバグ(issue 6720)が修正され,

[64, 54 48, 32]

と補間されます.

v2.0 移行に伴い, tf.image.resize_images は廃止され tf.image.resize になっていますので, 1.x のバージョンの関数を間違えて使うということは無くなっています.


cv2.resize

リファレンスであろう, cv2.resize の振る舞いを見てみます.

import cv2

import numpy as np

img = np.array([[64.0, 64.0],
[32.0, 32.0]], np.float32)

size = (4, 4)

resized_img = cv2.resize(img, size)

print(resized_img)

[64, 56, 40, 32]

となります.


pytorch 1.x

@philopon さまから cv2.resize と同じ振る舞いであるとのコメントいただきました. ありがとうございます.


まとめ

cv2.resize の結果が正しい(リファレンス)とすると


  • tf2.0 と pytorch が正しい振る舞いになる

  • chainer, onnx の結果は cv2.resize と一致しない

  • chainer と tf r1.13 + align_cornes = True で結果は同じになる

  • この違いがどれだけ学習に影響を与えるかはモデルにもよるかもであるが, 画像ピラミッドを作り, 4x4 くらいなどの解像度のレイヤーを作るような場合には, 以外と影響は大きいかもしれません.


何を基準とするのか.

cv2.resize も本当にこれが正しいのかという疑念もありますので, 突き詰めると bilinear の定義(仕様)をどうするのかという問題になります.

グラフィックスの世界では, たとえばテクスチャの bilinear 補間方法が OpenGL 仕様で決まっていますので(正確には, 補間方法が記述されていて,かつ実装は n ビットの精度で一致する(confirmance test に通る)という風に決めていたような), OpenGL の実装(confirmance test を通っているもの)を基準として, OpenGL の結果をリファレンスにするというのをよくやります.

https://www.khronos.org/registry/OpenGL/index_gl.php

機械学習の世界でも, 何かしら仕様を定義する標準化団体が欲しいですね.


おまけ

tf.image.resize_images has aliasing when downsampling and does not have gradients for bicubic mode. This implementation fixes those problems

https://github.com/trevor-m/tensorflow-bicubic-downsample


終わりに



  • resize_images や似たような bilinear 補間の function/op をあなたのモデルに組み込んでいませんか? その場合正しい実装で再学習すると精度があがるかもしれません. tensorflow を使っている場合は r2.0 をつかいましょう(もうちょっとで正式リリース?)

  • 仕様だいじ. 多少面倒でもテストを書いて検証するのがよいですね

  • 少なくとも 2 個以上の機械学習ライブラリで結果を比較するようにするのがよさそう