機械学習ライブラリの 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 補間モードしかサポートしていません.

```py
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 があります(あまり詳しい説明はないが端まわりでの補間が変わる模様).

```py
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

```py
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` の振る舞いを見てみます.

```py
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 個以上の機械学習ライブラリで結果を比較するようにするのがよさそう