【Python】OpenCVで画像をアフィン変換【移動・拡大・回転・剪断】
アフィン変換とは、画像の移動や回転、拡大縮小、剪断といった処理を行うものです。
線形代数の写像の項目を学ぶと、アフィン変換への理解が進みます。私は線形代数がとても苦手でしたので、こちらの本でサクッと概要を学んだ程度ですがアフィン変換を使う上での知識としては十分です。おすすめです!
この記事では数学の専門的な用語や、数式についてはさらっと紹介する程度にとどめておき、PythonのOpenCVを使ってアフィン変換で画像処理する実例を中心に紹介していきます。
アフィン変換を使うと、この動画のように簡単に図形を回転させたりできます。
はじめに
フリー画像サイト 「pixabay」 さんからお借りした画像をOpenCVで加工していきます。巷のアフィン変換の記事を拝見すると、皆さんなぜかゴリラ系の画像を使ってらっしゃいます。私も慣習に習って、こちらのオラウータンを選んでみました。
ここではPython3.xとOpenCVを使用しました。
$ pip list | grep opencv
opencv-contrib-python 4.6.0.66
opencv-python 4.5.5.62
OpenCVがまだインストールされてない場合は、pipコマンドでインストールしましょう。numpyも使いますので合わせてインストールします。
$ pip install opencv-python
$ pip install opencv-contrib-python
$ pip install numpy
他にもPython x OpenCVに関する記事を書いてます。
平行移動のアフィン変換
平行移動のアフィン変換を行列式で表すと次の通りです。
\begin{align} \begin{pmatrix} x'\\ y'\\ 1 \\ \end{pmatrix} = \begin{pmatrix} 1 & 0 & T{x} \\ 0 & 1 & T{y} \\ 0 & 0 & 1 \\ \end{pmatrix} \begin{pmatrix} x \\ y \\ 1 \\ \end{pmatrix} \end{align}PythonのOpenCVで warpAffine 関数を使って平行移動を行います。次はアフィン変換で画像の平行移動の例です:
import cv2
import os
import numpy as np
input_path = "../orang-utan.jpg"
img = cv2.imread(input_path)
output_path = '../' + os.path.basename(input_path).split('.')[0] + '-' + os.path.basename(__file__).split('.')[0] + '.jpg'
# --------------------------Begin affine-----------------------------
h, w = img.shape[:2]
tx=200
ty=300
M = np.float32([
[1,0,tx],
[0,1,ty],
])
img = cv2.warpAffine(img, M, (w, h))
# --------------------------End affine-----------------------------
cv2.imshow('img', img)
cv2.imwrite(output_path, img)
cv2.waitKey(0)
cv2.destroyAllWindows()
出力結果がこちらです。
プログラミングでは行列の3行目は省略します。
線形代数の話
ここでは行列への理解を深めるために、線形代数の初歩的なことについて触れておきます。こちらの本を参考に、行列を使った連立1次方程式の解き方を見てみましょう(行列に詳しい方は読み飛ばして構いません)。
xとyを含む次の連立1次方程式の解を考えてみます。
\begin{align} \left\{ \begin{array}{l} x+2y=2\\ -x+y=1 \end{array} \right. \end{align}この連立1次方程式を実際に解いてみましょう。
上+下で次が得られます:
\begin{align} 3y=3 \end{align}この式の両辺を3で割れば\(y=1\)が得られます。 後でやる行列の計算の都合上、この両辺を二倍して連立1次方程式の上段から引き算するとxの解が得られます。
\begin{align} \begin{array}{rr} & x+2y=2\\ +) & 2y=2\\ \hline &x=0 \end{array} \end{align}\(x=0,y=1\)の解が得られました。
行列で解く
今度は先ほどの連立1次方程式を行列で解いてみます。 最初の連立1次方程式を行列で表すと次の通りです。x、yの変数を取り除いて係数を並べただけです。
\begin{align} \begin{bmatrix} 1 & 2 & 2 \\ -1 & 1 & 1 \end{bmatrix} \end{align}これを手動で計算したように、行列ないで計算していきます。
まず、1行目を2行目に足し算します(行和)。
\begin{align} \begin{bmatrix} 1 & 2 & 2 \\ 0 & 3 & 3 \end{bmatrix} \end{align}2行目を1/3倍します(行倍)。
\begin{align} \begin{bmatrix} 1 & 2 & 2 \\ 0 & 1 & 1 \end{bmatrix} \end{align}2行目を2倍にした値を1行目から引きます(行和)。
\begin{align} \begin{bmatrix} 1 & 0 & 0 \\ 0 & 1 & 1 \end{bmatrix} \end{align}1列目がx、2列目がy、3列目が解となり、手計算と同じく\(x=0,y=1\)の解が得られました。
行列式の理解の手がかりになれば幸いです。
拡大縮小のアフィン変換
X軸、Y軸方向の拡大率をそれぞれSx、Syとすると、拡大縮小のアフィン変換を行列式で表すと次の通りです。
\begin{align} \begin{pmatrix} x'\\ y'\\ 1 \\ \end{pmatrix} = \begin{pmatrix} S{x} & 0 & 0 \\ 0 & S{y} & 0 \\ 0 & 0 & 1 \\ \end{pmatrix} \begin{pmatrix} x \\ y \\ 1 \\ \end{pmatrix} \end{align}次はアフィン変換で画像の拡大縮小の例です:
import cv2
import os
import numpy as np
input_path = "../orang-utan.jpg"
img = cv2.imread(input_path)
output_path = '../' + os.path.basename(input_path).split('.')[0] + '-' + os.path.basename(__file__).split('.')[0] + '.jpg'
# --------------------------Begin affine-----------------------------
h, w = img.shape[:2]
sx=2
sy=0.5
M = np.float32([
[sx,0,0],
[0,1,sy]
])
img = cv2.warpAffine(img, M, (w, h))
# --------------------------End affine-----------------------------
cv2.imshow('img', img)
cv2.imwrite(output_path, img)
cv2.waitKey(0)
cv2.destroyAllWindows()
出力結果がこちらです。
回転のアフィン変換
原点を中心に反時計回りにθ°回転させるアフィン変換を、行列式で表すと次の通りです。
\begin{align} \begin{pmatrix} x'\\ y'\\ 1 \\ \end{pmatrix} = \begin{pmatrix} cos\theta & -sin\theta & 0 \\ sin\theta & cos\theta & 0 \\ 0 & 0 & 1 \\ \end{pmatrix} \begin{pmatrix} x \\ y \\ 1 \\ \end{pmatrix} \end{align}三角関数を計算してもよいのですが、GetRotationMatrix2D(center, angle, scale)を使うと便利です。
パラメータ | 意味 |
---|---|
center | 回転の中心位置 |
angle | 度単位で表される回転角度 |
scale | 等方性スケーリング係数 |
次はアフィン変換で画像の拡大縮小の例です:
import cv2
import os
import numpy as np
input_path = "../orang-utan.jpg"
img = cv2.imread(input_path)
output_path = '../' + os.path.basename(input_path).split('.')[0] + '-' + os.path.basename(__file__).split('.')[0] + '.jpg'
# --------------------------Begin affine-----------------------------
h, w = img.shape[:2]
M = cv2.getRotationMatrix2D((0,1000), 60, 1.0)
img = cv2.warpAffine(img, M, (w, h))
# --------------------------End affine-----------------------------
cv2.imshow('img', img)
cv2.imwrite(output_path, img)
cv2.waitKey(0)
cv2.destroyAllWindows()
出力結果がこちらです。
剪断(スキュー)のアフィン変換
剪断またはスキューとは、画像を平行四辺形に変形する処理のことです。 剪断のアフィン変換を行列式で表すと次の通りです。
\begin{align} \begin{pmatrix} x'\\ y'\\ 1 \\ \end{pmatrix} = \begin{pmatrix} 1 & 0 & 0 \\ tan\theta & 1 & 0 \\ 0 & 0 & 1 \\ \end{pmatrix} \begin{pmatrix} x \\ y \\ 1 \\ \end{pmatrix} \end{align}または
\begin{align} \begin{pmatrix} x'\\ y'\\ 1 \\ \end{pmatrix} = \begin{pmatrix} 1 & tan\theta & 0 \\ 0 & 1 & 0 \\ 0 & 0 & 1 \\ \end{pmatrix} \begin{pmatrix} x \\ y \\ 1 \\ \end{pmatrix} \end{align}ここではOpenCVの関数 getAffineTransform(src, dts) を使って剪断を行ってみます。src, dtsには3点分のxy座標を3x2行列のnumpy配列で与えます。
次はアフィン変換で画像の剪断を行う例です:
import cv2
import os
import numpy as np
input_path = "../orang-utan.jpg"
img = cv2.imread(input_path)
output_path = '../' + os.path.basename(input_path).split('.')[0] + '-' + os.path.basename(__file__).split('.')[0] + '.jpg'
# --------------------------Begin affine-----------------------------
h, w = img.shape[:2]
shear = 500
src = np.array([[0.0, 0.0],[0.0, 1.0],[1.0, 0.0]], np.float32)
dts = src.copy()
dts[:,0] += (shear / h * (h - src[:,1])).astype(np.float32)
M = cv2.getAffineTransform(src,dts)
img = cv2.warpAffine(img, M, (w, h))
# --------------------------End affine-----------------------------
cv2.imshow('img', img)
cv2.imwrite(output_path, img)
cv2.waitKey(0)
cv2.destroyAllWindows()
出力結果がこちらです。
4点以上のアフィン変換、estimateAffine2D(estimateRigidTransform)
getAffineTransformのように、アフィン行列は対応する3点があれば求められます。しかし、4点以上の対応点群がある場合には、getAffineTransformでは求められません。 行列解は一意に決まらず推定が入ります。OpenCVでやるにはestimateRigidTransformが利用できます。ただし、この関数はOpenCV4以降で非推奨となってます。代わりにOpenCV4ではestimateAffine2DとestimateAffinePartial2Dが用意されてます。4点以上のアフィン変換の実用例としては、映像処理における手ぶれ補正が挙げられます。 【Python】VidStabで手ぶれ補正【動画編集への道#2】 で紹介したSIMPLE VIDEO STABILIZATION USING OPENCVのC++ソースコードを読むと、映像から特徴点を抽出し、オプティカルフローで前フレームの特徴点と紐付けてestimateAffinePartial2Dでアフィン行列を求めることで手ぶれ補正を実現してるのが分かります。実際はこの後にカルマンフィルタやローパスフィルタなどで平滑化する処理が入ります。
SIMPLE VIDEO STABILIZATION USING OPENCV
estimateAffine2D(from, to) の使い方もgetAffineTransform(src, dts)とほぼ同じです。先ほどの剪断された画像を元に戻してみましょう。3点座標の行列ですが、estimateAffine2Dの使い方に慣れるためにちょうど良いでしょう。先ほどの剪断された画像を estimateAffine2D で元の位置に戻した例です:
import cv2
import os
import numpy as np
input_path = "../orang-utan-affine-shear.jpg"
img = cv2.imread(input_path)
output_path = '../' + os.path.basename(input_path).split('.')[0] + '-' + os.path.basename(__file__).split('.')[0] + '.jpg'
# --------------------------Begin affine-----------------------------
h, w = img.shape[:2]
shear = 500
src = np.array([[0.0, 0.0],[0.0, 1.0],[1.0, 0.0]], np.float32)
dts = src.copy()
dts[:,0] += (shear / h * (h - src[:,1])).astype(np.float32)
M = cv2.estimateAffine2D(dts, src)
img = cv2.warpAffine(img, M[0], (w, h))
# --------------------------End affine-----------------------------
cv2.imshow('img', img)
cv2.imwrite(output_path, img)
cv2.waitKey(0)
cv2.destroyAllWindows()
出力結果がこちらになります。
画面からはみ出た部分はトリミングされてしまいましたが、元の写真と同じ形へアフィン変換で戻すことができました。
estimateAffine2Dへ入力しているdtsとsrcは本来逆ですが、元に戻すという意味で使ってますのでご理解ください。