背景
- Chainer で書かれた automatic portrait image matting のモデルを, ONNX にコンバートしてモバイルで動かしたい
-
resize_images
を pyramid pooling で利用している. -
onnx-chainer
ではresize_images
のエクスポートに対応していなかったので, 対応しようとしたところ,resize_images
(より正確には upsampling + bilinear 補間)の振る舞いが Chainer と ONNX で異なる(TensorFlow とも異なる)ことがわかった
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 日(!)もの人生のロスに繋がった悲しい例. この post では cv2.resize
を使おう, とありますが, でもしかしモバイルとかでモデルを動かしたいときはモデルに resize を組み込んで使いたいので, 機械学習ライブラリ側を直したいですよね.
幸いにも私は onnx-chainer
のテストを書いていたのと, 上記の post で問題があることに気づいたので, 1 営業日のロスですみました. ありがとうございます.
テスト構成
2x2 の
[[64, 32],
[64, 32]]
を 4x4 に bilinear upsample して結果がどうなるか調べます.
1D 配列でもよいかと思いましたが, 一応 bilinear を検証するということで, 2D にしました. 結果は 1D で見比べます.
Chainer
Chainer では resize_images
があります.
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
と解釈されるかもね, というあやふやなものです.
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
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 の結果をリファレンスにするというのをよくやります.
機械学習の世界でも, 何かしら仕様を定義する標準化団体が欲しいですね.
おまけ
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 個以上の機械学習ライブラリで結果を比較するようにするのがよさそう