PythonによるNumpy, Pillow, OpenCVを用いたアフィン画像変換

今回は、画像にアフィン変換を施すとはどういうことか、Pythonでどのように行うかを説明します。まず、Numpyで低レベルの演算を実演し、詳細な幾何学的実装を説明します。その後、Python PillowとOpenCVライブラリのより実用的な使い方に移行していきます。

この記事はJupyterノートブックを使って書きました。ソースは私のGitHubレポにありますので、自由にクローン/フォークしてコードを試してみてください。

アフィン変換とは

Wikipedia によると、アフィン変換とは、点、直線、平行線、および点間の比率を保持する 2 つの幾何学的(アフィン)空間間の関数マッピングであるとのことです。この数学的に抽象的な言葉を要約すると、少なくとも画像処理の文脈では、変換行列を適用することによって、回転、反転、拡大縮小、せん断といった1つまたは複数の操作をもたらす、大雑把に言えば線形変換ということになります。

1つの良い点は、これが本質的に2Dの幾何学的な操作であるため、それを視覚化できることです。まず、幾何学的操作の各タイプを説明するアフィン変換の表をあげてみましょう。

| 変換の種類|変換行列|ピクセルマッピング式
| — | — | — |
| アイデンティティ
⎡⎢⎣100010001⎤⎥⎦[100010001]
⑭ビームマトリックス
1 & 0 & 0 ?
0 & 1 & 0 ?
0 & 0 & 1
⑭end{bmatrix} ⑯end{bmatrix
|
x′=xx′=xx^{‘} = x y′=yy′=yy^{‘} = y
| スケーリング
⎡⎢⎣cx000cy0001⎤⎥⎦[cx000cy0001]
begin{bmatrix}
c_{x} & 0 & 0 ╱︎0
0 & c_{y} & 0 .
0 & 0 & 1
୧⃛(๑⃙⃘◡̈๑⃙⃘)
|
x′=cx∗xx′=cx∗xx^{‘} = centa_{x} | y′=cy∗yy′=cy∗yy^{‘} = c_{y} * y |
| 回転数
⎡⎢⎣cosΘsinΘ0-sinΘcosΘ0001↩⎤[cosΘsinΘ0-sinΘcosΘ0001] ⎥Sm_23A6

cos ↵ sin ↵ 0 ↵ -sin ↵ -cos
-sin Theta & cos Theta & 0 ?
0 & 0 & 1
Ίταμμα
|
x′=x∗cosΘ-y∗sinΘx′=x∗cosΘ-y∗sinΘx^{‘} = x * cos ta – y * sin ta y′=x∗cosΘ+y∗sinΘy′=x∗cosΘ+y∗sinΘy^{‘} = x * cos ta + y * sin theta || 翻訳|株式会社日立製作所|システム構築やトータルソリューションをお探しなら、日立ソリューションズをご利用ください。
| 翻訳
⎡⎢⎣10tx01ty001⎤⎥⎦[10tx01ty001]
ㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤ。
1 & 0 & tả_{x}
0 & 1 & t___{y}
0 & 0 & 1
Ίταμμα
|
x′=x+txx′=x+txx^{‘} = x + t_{x} y′=y+tyy′=y+tyy^{‘} = y + t_{y} |
| 水平方向せん断
⎡⎢⎣1sh0010001⎤⎥⎦[1sh0010001]
⊖begin{bmatrix}
1 & s_{h} & 0 .
0 & 1 & 0 ?
0 & 0 & 1
୧⃛(๑⃙⃘◡̈๑⃙⃘)
|
x′=x+sv∗yx′=x+sv∗yx^{‘} = x + s︓v} y y′=yy′=yy^{‘} = y
| 垂直方向せん断
⎡⎢⎣100sv10001⎤⎥⎦[100sv10001]
⊖Bm_2296↩Bm_2296
1 & 0 & 0
s_{v} & 1 & 0 .
0 & 0 & 1
ㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤ
|
x′=xx′=xx^{‘} = x y′=x∗sh+yy′=x∗sh+yy^{‘} = x * s_{h}. + y |

* アフィン変換は時計回りの回転角を使いますが、一般的な幾何学の単位円ではX軸を正として反時計回りに角度を測りますので、角度の負が適用されることが多いようです。

ここでいう ' 表記は、微分のための微積分表記ではなく、変換された出力座標であるxまたはyを指しているだけです

簡単なデモンストレーションのために、以下の点のxとyの座標を操作するために2つの変換を適用します。これらの点は、画像のピクセルがx、y、周波数(または強度)の3次元成分を持つように、x、y、アスキー文字インデックスという3次元の要素を持ちます。

a = (0, 1, 0)

b = (1, 0, 1)

c = (0, -1, 2)

d = (-1, 0, 3)

この例では、すべての方向に2倍のスケーリングと時計回りに90度の回転を行います。まず、それぞれの変換を個別に実行して、点の移動に直接効果があることを示し、次に変換を組み合わせて1回の操作で適用することにします。

まず始めに、各行が点を表すNumpy配列(行列と呼ぶ人もいるかもしれません)を作りたいと思います。1列目はx、2列目はy、3列目は以下の表のようなアスキー文字セットにおける文字のインデックスです。次に私は Matplotlib を使って(不変の恒等式を適用した後)点をプロットし、私たちの立ち位置のベースラインを視覚化します。

| 点|x (行)|y (列)|asciiインデックス|…といった具合です。
| — | — | — | — |
| a | 0 | 1 | 0 |
| b | 1 | 0 | 1 |
| c | 0 | -1 | 2 |
| d | -1 | 0 | 3 |

import matplotlib.pyplot as plt
import numpy as np
import string


# points a, b and, c
a, b, c, d = (0, 1, 0), (1, 0, 1), (0, -1, 2), (-1, 0, 3)


# matrix with row vectors of points
A = np.array([a, b, c, d])


# 3x3 Identity transformation matrix
I = np.eye(3)


color_lut = 'rgbc'
fig = plt.figure()
ax = plt.gca()
xs = []
ys = []
for row in A:
    output_row = I @ row
    x, y, i = output_row
    xs.append(x)
    ys.append(y)
    i = int(i) # convert float to int for indexing
    c = color_lut[i]
    plt.scatter(x, y, color=c)
    plt.text(x + 0.15, y, f"{string.ascii_letters[i]}")
xs.append(xs[0])
ys.append(ys[0])
plt.plot(xs, ys, color="gray", linestyle='dotted')
ax.set_xticks(np.arange(-2.5, 3, 0.5))
ax.set_yticks(np.arange(-2.5, 3, 0.5))
plt.grid()
plt.show()


3点a,b,cをグリッド上にプロットし、単純なベクトル行列の内積によって恒等変換を適用した後、それらは変更されないままです。

次に、点の配置を全方向に拡大縮小するスケーリング変換行列TsTsT_sを以下のように作成します。

Ts=⎡⎢⎣200020001⎤⎥⎦Ts=[200020001]
T_s = begin{bmatrix}
2 & 0 & 0 ?
0 & 2 & 0 ?
0 & 0 & 1
end{bmatrix}

ここで、恒等式変換によって変更されていない元の点に対して行ったのと同様に、変換された点をプロットすることに移りますが、今回は上で定義したスケーリング変換行列を適用します。より良く可視化するために、点線で点を結んでプロットします。

# create the scaling transformation matrix
T_s = np.array([[2, 0, 0], [0, 2, 0], [0, 0, 1]])


fig = plt.figure()
ax = plt.gca()
xs_s = []
ys_s = []
for row in A:
    output_row = T_s @ row
    x, y, i = row
    x_s, y_s, i_s = output_row
    xs_s.append(x_s)
    ys_s.append(y_s)
    i, i_s = int(i), int(i_s) # convert float to int for indexing
    c, c_s = color_lut[i], color_lut[i_s] # these are the same but, its good to be explicit
    plt.scatter(x, y, color=c)
    plt.scatter(x_s, y_s, color=c_s)
    plt.text(x + 0.15, y, f"{string.ascii_letters[int(i)]}")
    plt.text(x_s + 0.15, y_s, f"{string.ascii_letters[int(i_s)]}'")


xs_s.append(xs_s[0])
ys_s.append(ys_s[0])
plt.plot(xs, ys, color="gray", linestyle='dotted')
plt.plot(xs_s, ys_s, color="gray", linestyle='dotted')
ax.set_xticks(np.arange(-2.5, 3, 0.5))
ax.set_yticks(np.arange(-2.5, 3, 0.5))
plt.grid()
plt.show()


上のプロットから、xとyの次元は単純に2倍に拡大され、ASCII文字のインデックスを担う3番目の次元は変更されていないことがよくわかると思います。実際、行列代数に詳しい人なら、最初の表に挙げたすべてのアフィン変換について、3番目の次元で表される値が

画像を扱う

ここまでで、アフィン変換が2次元空間の点を単純に移動するためにどのように使われるか、ある程度直感的に理解できたと思います。

また、アフィン変換のもう一つの重要なトピックである3次元の扱いについても説明します。画像の3次元データは、実際の画素値を表し、強度領域と呼ばれることもありますが、他の2次元の画素の物理的な2次元の位置は空間領域と呼ばれます。

まず始めに、matplotlib を使って画像を読み込んで表示します。matplotlib は単に大きな大文字の R です。

# create the rotation transformation matrix
T_r = np.array([[0, 1, 0], [-1, 0, 0], [0, 0, 1]])


fig = plt.figure()
ax = plt.gca()
for row in A:
    output_row = T_r @ row
    x_r, y_r, i_r = output_row
    i_r = int(i_r) # convert float to int for indexing
    c_r = color_lut[i_r] # these are the same but, its good to be explicit
    letter_r = string.ascii_letters[i_r]
    plt.scatter(x_r, y_r, color=c_r)
    plt.text(x_r + 0.15, y_r, f"{letter_r}'")


plt.plot(xs, ys, color="gray", linestyle='dotted')
ax.set_xticks(np.arange(-2.5, 3, 0.5))
ax.set_yticks(np.arange(-2.5, 3, 0.5))
plt.grid()
plt.show()


imread(…)` メソッドを使うと、大文字のRを表すJPG画像をnumpyのndarrayに読み込むことができます。配列のサイズは1000行×1000列で、空間領域で1,000,000ピクセルの位置を構成しています。個々のピクセルデータは、赤、緑、青、アルファチャンネル(またはサンプル)を表す4つの符号なし整数の配列で、各ピクセルの強度データを一緒に提供します。

# create combined tranformation matrix
T = T_s @ T_r


fig = plt.figure()
ax = plt.gca()


xs_comb = []
ys_comb = []
for row in A:
    output_row = T @ row
    x, y, i = row
    x_comb, y_comb, i_comb = output_row
    xs_comb.append(x_comb)
    ys_comb.append(y_comb)
    i, i_comb = int(i), int(i_comb) # convert float to int for indexing
    c, c_comb = color_lut[i], color_lut[i_comb] # these are the same but, its good to be explicit
    letter, letter_comb = string.ascii_letters[i], string.ascii_letters[i_comb]
    plt.scatter(x, y, color=c)
    plt.scatter(x_comb, y_comb, color=c_comb)
    plt.text(x + 0.15 , y, f"{letter}")
    plt.text(x_comb + 0.15, y_comb, f"{letter_comb}'")
xs_comb.append(xs_comb[0])
ys_comb.append(ys_comb[0])
plt.plot(xs, ys, color="gray", linestyle='dotted')
plt.plot(xs_comb, ys_comb, color="gray", linestyle='dotted')
ax.set_xticks(np.arange(-2.5, 3, 0.5))
ax.set_yticks(np.arange(-2.5, 3, 0.5))
plt.grid()
plt.show()


次に、画像データの空間領域に対して、先ほどの拡大縮小と回転を適用し、先ほどの点データと同様にピクセルの位置を変換してみたいと思います。ただし、画像データは先ほどの点データ列とは構成が異なるので、少し違ったアプローチが必要です。画像データの場合、先ほど定義した変換行列Tを用いて、入力データの各ピクセルのインデックスを変換後の出力インデックスに対応付ける必要があります。

img = plt.imread('letterR.jpg')
img.shape #  (1000, 1000, 4)


変換後の画像をプロットすると、元の画像は時計回りに90度回転し、2倍に拡大されていることがよくわかります。しかし、画素の強度が不連続になっているのが簡単にわかるように、結果は明らかに低下しています。

この理由を理解するために、もう一度簡単な格子状のプロットを使って説明します。2×2画像の空間領域に似た2×2グリッドにある4つの正方形のプロットを考えてみましょう。

plt.figure(figsize=(5, 5))
plt.imshow(img)


ここで、下図のように2倍のスケーリング変換を行うとどうなるかを見てみましょう。思い出してください。

Ts=⎡⎢⎣200020001⎤⎥⎦Ts=[200020001]
T_s = begin{bmatrix}
2 & 0 & 0 ?
0 & 2 & 0 ?
0 & 0 & 1
end{bmatrix}

このような空間変換を行うと・・・わかりやすく言うと「隙間」ができることに気づきますが、これは座標に疑問符をプロットすることでわかりやすくしています。2×2グリッドは、3×3グリッドに変換され、元のマスは、適用された線形変換に基づいて再配置されます。つまり、(0,0) * TsTsT_s は 0 ベクトルなので (0,0) のままですが、それ以外は (1,1) * TsTsT_s – (2,2) というように 2 倍にスケールアップされます。

# 2x scaling requires a tranformation image array 2x the original image
img_transformed = np.empty((2000, 2000, 4), dtype=np.uint8)
for i, row in enumerate(img):
    for j, col in enumerate(row):
        pixel_data = img[i, j, :]
        input_coords = np.array([i, j, 1])
        i_out, j_out, _ = T @ input_coords
        img_transformed[i_out, j_out, :] = pixel_data


plt.figure(figsize=(5, 5))
plt.imshow(img_transformed)


ここで、導入されたギャップをどうするかという問題が残る。直感的には、原画を見れば答えが出るはずです。たまたま、出力の座標に変換の逆を適用すれば、元の入力の対応する位置が得られるのです。

逆マッピングのような行列演算では、次のようになります。

(x,y,1)=T-1s∗(x′y′1)(x,y,1)=Ts-1∗(x′y′1)
(x, y, 1) = T_s^{-1} * (x’ y’ 1)

ここで、x’, y’ は上記変換後の3×3グリッドの座標、具体的には(2, 1)のような欠損位置、T-1sTs-1T_s^{-1}(以下実数)は2xスケーリング行列TsT_sの逆数、x, yは元の2×2グリッドに存在する座標である。

T−1s=⎡⎢⎣1/20001/20001⎤⎥⎦−1Ts−1=[1/20001/20001]−1
T_s^{-1} = begin{bmatrix}
1/2 & 0 & 0 .
0 & 1/2 & 0 ?
0 & 0 & 1
⑭end{bmatrix}^{-1} となります。

ただし、ギャップの各座標が2×2座標系の小数値にマップバックされるため、まだ少し整理が必要なことにすぐ気が付くでしょう。画像データの場合、1ピクセルの何分の1ということはあり得ません。このことは、(2,1)のギャップを元の2×2空間にマッピングし直す例で、次のように理解できるだろう。

T−1s∗(2,1,1)=(1,1/2,1)Ts−1∗(2,1,1)=(1,1/2,1)
T_s^{-1} * (2, 1, 1) = (1, 1/2, 1)

この場合、y’ = 1/2を0に丸めて、(1, 0)に写像すると言うことになります。このように、元の2×2グリッドの値を選択して、変換後の3×3グリッドの隙間に入れる方法を一般に補間といいますが、この例では最近傍補間を簡略化した方法を用いています。

さて、画像データに戻りましょう。拡大縮小して回転させたRの文字の隙間を修正するために、今何をすべきかは明らかでしょう。私は、変換の逆マッピングを使用した最近傍補間の実装を開発しなければなりません

ピローを使ったアフィン変換

このセクションでは、Pythonの優れた画像処理ライブラリであるPillowを使ってアフィン変換を行う方法を簡単に説明します。

まず最初に、Pillowをインストールする必要があります。私はpipを使って以下のようにインストールしました。

def plot_box(plt, x0, y0, txt, w=1, h=1):
    plt.scatter(x0, y0)
    plt.scatter(x0, y0 + h)
    plt.scatter(x0 + w, y0 + h)
    plt.scatter(x0 + w, y0)
    plt.plot([x0, x0, x0 + w, x0 + w, x0], [y0, y0 + h, y0 + h, y0, y0], color="gray", linestyle='dotted')
    plt.text(x0 + (.33 * w), y0 + (.5 * h), txt)


#             x0, y0, letter
a = np.array((0,  1,  0))
b = np.array((1,  1,  1))
c = np.array((0,  0,  2))
d = np.array((1,  0,  3))


A = np.array([a, b, c, d])
fig = plt.figure()
ax = plt.gca()
for pt in A:
    x0, y0, i = I @ pt
    x0, y0, i = int(x0), int(y0), int(i)
    plot_box(plt, x0, y0, f"{string.ascii_letters[int(i)]} ({x0}, {y0})")


ax.set_xticks(np.arange(-1, 5, 1))
ax.set_yticks(np.arange(-1, 5, 1))
plt.grid()
plt.show()


まず、PIL(Pillowに付随するPythonモジュールの名前)モジュールから Image クラスをインポートして、画像を読み込みます。

fig = plt.figure()
ax = plt.gca()
for pt in A:
    xt, yt, i = T_s @ pt
    xt, yt, i = int(xt), int(yt), int(i)
    plot_box(plt, xt, yt, f"{string.ascii_letters[i]}' ({xt}, {yt})")


delta_w, delta_h = 0.33, 0.5
plt.text(0 + delta_w, 1 + delta_h, "? (0, 1)")
plt.text(1 + delta_w, 0 + delta_h, "? (1, 0)")
plt.text(1 + delta_w, 1 + delta_h, "? (1, 1)")
plt.text(1 + delta_w, 2 + delta_h, "? (1, 2)")
plt.text(2 + delta_w, 1 + delta_h, "? (2, 1)")


ax.set_xticks(np.arange(-1, 5, 1))
ax.set_yticks(np.arange(-1, 5, 1))
plt.grid()
plt.show()


サンプル画像ファイル名 “letterR.jpg” を読み込むために、クラスメソッド Image.open(...) を呼び出し、ファイル名を渡すと、 Image クラスのインスタンスが返されます。

T_inv = np.linalg.inv(T)


# nearest neighbors interpolation
def nearest_neighbors(i, j, M, T_inv):
    x_max, y_max = M.shape[0] - 1, M.shape[1] - 1
    x, y, _ = T_inv @ np.array([i, j, 1])
    if np.floor(x) == x and np.floor(y) == y:
        x, y = int(x), int(y)
        return M[x, y]
    if np.abs(np.floor(x) - x) < np.abs(np.ceil(x) - x):
        x = int(np.floor(x))
    else:
        x = int(np.ceil(x))
    if np.abs(np.floor(y) - y) < np.abs(np.ceil(y) - y):
        y = int(np.floor(y))
    else:
        y = int(np.ceil(y))
    if x > x_max:
        x = x_max
    if y > y_max:
        y = y_max
    return M[x, y,]


img_nn = np.empty((2000, 2000, 4), dtype=np.uint8)
for i, row in enumerate(img_transformed):
    for j, col in enumerate(row):
        img_nn[i, j, :] = nearest_neighbors(i, j, img, T_inv)


plt.figure(figsize=(5, 5))
plt.imshow(img_nn)


ピローの Image クラスは transform(...) という便利なメソッドを持っていて、これを使うと細かいアフィン変換ができるのですが、いくつかの奇妙な点があるので、そのデモに入る前に説明しなければなりません。Transform(…)メソッドは、高さと幅のタプルであるsizeを表す2つの必須パラメータから始まり、適用する変換のmethodを指定します(この例ではImage.AFFINE` となります)。

残りのパラメータはオプションのキーワード引数で、変換をどのように実行するかを制御します。この例では、アフィン変換行列の最初の 2 行を指定する data パラメータを利用します。

例えば、私がこれまで扱ってきた 2x のスケーリング変換行列を、最初の 2 行だけに切り詰めたものは、次のようになります。

Ts=[200020]Ts=[200020]
T_s = begin{bmatrix} ・・・・・・。
2 & 0 & 0 ?
0 & 2 & 0
⑭end{bmatrix} ⑯end{bmatrix

これは、ピクセル補間アルゴリズムを Image.NEAREST (最近傍)、 Image.BILINEAR 、または Image.BICUBIC から選択するために使用されます。この選択肢は、適用される変換によって異なることがよくあります。しかし、バイリニアとバイキュービックは一般的に最近傍よりも良い結果をもたらしますが、この例ですでに実証されているように、最近傍は非常にうまく機能します。

私が初めて Image.transform(...) メソッドを使用したとき、特に最後の行が奇妙に切り捨てられたアフィン変換行列の構築に関連して、本当に困ったことになったいくつかの特殊性があります。そこで、なぜこのように動作するのか、その過程を少し説明したいと思います。

まず、原点(0, 0)が画像の中央に来るように画像を平行移動させなければなりません。この例では、文字Rの1000×1000の画像の場合、XとYに-500の変換を意味します。

以下に、一般的な平行移動変換行列TtranslateTtranslateT_{translate}と、この例で使う行列Tneg500Tneg500T_{neg500}を示す。

Ttranslate=⎡⎢⎣10tx01ty001⎤⎥⎦Ttranslate=[10tx01ty001]
T_{translate} = begin{bmatrix}
1 & 0 & t_x .
0 & 1 & t Θ_y Θ
0 & 0 & 1
୧⃛(๑⃙⃘◡̈๑⃙⃘)

Tneg500=[10-500 01-500 001]Tneg500=[10-500 01-500 001] となります。
T_{neg500} = begin{bmatrix}
1 & 0 & -500 Ⅾ(◍-ᴗ-◍)
0 & 1 & -500 .
0 & 0 & 1
⑭end{bmatrix}

あとは、先ほどの2倍スケーリングTscaleTscaleT_{scale}と90度回転TrotateTrotateT_{rotate}の行列がありますね。しかし、Pillowライブラリは、実際には、先に説明した時計回りの回転ではなく、標準的な幾何学的角度(すなわち、反時計回り)を使用することにしたので、sin関数の符号が反転しています。その結果、個々の変換行列は次のようになります。

Trotate=⎡⎢⎣0−10100001⎤⎥⎦Trotate=[0−10100001]
T_{rotate} = begin{bmatrix}
0 & -1 & 0 ?
1 & 0 & 0 ?
0 & 0 & 1
୧⃛(๑⃙⃘◡̈๑⃙⃘)

Tscale=[200 020 001]Tscale=[200 020 001]です。
T_{scale} = begin{bmatrix}
2 & 0 & 0 .
0 & 2 & 0 .
0 & 0 & 1
⑭end{bmatrix} ⑯end{bmatrix

次に、別の移動行列を適用する必要があります。これは、原点を中心とした最初の行列を否定するように、ピクセルの空間領域を再配置する働きをします。この場合、xとyに1000の正の移動が必要で、1000は2倍にスケールアップされているため、元の2倍からきています。

Tpos1000=⎡⎢⎣101000011000001⎤⎥⎦Tpos1000=[101000011000001]
T_{pos1000} = begin{bmatrix}
1 & 0 & 1000 ?
0 & 1 & 1000 ?
0 & 0 & 1
⑭end{bmatrix}

以上で各変換ステップは完了ですので、あとは右から左へ順番に掛け算していくだけです。

T=Tpos1000∗Trotate∗Tscale∗Tneg500T=Tpos1000∗Trotate∗Tscale∗Tneg500 となります。
T=T_{pos1000} * γ T_{rotate} * γ T_{scale} * Ȃ T_{neg500} です。

さて、実は最後にもう一つおかしな点があります。Image.transform(…)メソッドは、実際には変換行列の逆行列をdata` パラメータに最後の行を除いて平らにした配列(またはタプル)として提供する必要があります。

Tinv=T-1Tinv=T-1
T_{inv} = T^{-1}.

コードでは以下のようになります。

$ pip install pillow


from PIL import Image


OpenCV2によるアフィン変換

引き続き、画像処理とコンピュータビジョンのライブラリとして有名なOpenCVを使って、このアフィン変換を実行する方法を簡単に説明したいと思います。ここで簡単という言葉を使ったのは、前回のPillowを使ったデモとほぼ同じだからです。

まず最初に、このようにインストールする必要があります。

img = Image.open('letterR.jpg')
plt.figure(figsize=(5, 5))
plt.imshow(np.asarray(img))


前述したように、Pillowを使った方法とOpenCVを使った方法には重複する部分が多くあります。例えば、ピクセルの配列を原点にセンタリングする変換行列を作成することや、変換行列の最初の2行のみを使用することは変わりません。大きな違いは、OpenCVでは逆行列ではなく、標準の行列を与えることです。

それでは,この説明を踏まえて,まず opencv-python モジュールをインポートし, cv2 という名前のコードを書き始めましょう.



# recenter resultant image
T_pos1000 = np.array([
    [1, 0, 1000],
    [0, 1, 1000],
    [0, 0, 1]])
# rotate - opposite angle
T_rotate = np.array([
    [0, -1, 0],
    [1, 0, 0],
    [0, 0, 1]])
# scale
T_scale = np.array([
    [2, 0, 0],
    [0, 2, 0],
    [0, 0, 1]])
# center original to 0,0
T_neg500 = np.array([
    [1, 0, -500],
    [0, 1, -500],
    [0, 0, 1]])
T = T_pos1000 @ T_rotate @ T_scale @ T_neg500
T_inv = np.linalg.inv(T)


画像の読み込みは,ファイル名を引数として cv2.imread(...) メソッドを呼び出すだけで簡単に行うことができます.これは,画像データを3次元の numpy 配列として返します.matplotlib と同様に動作しますが,3次元のピクセルデータは,matplotlib で読み込んだ場合のように red, green, blue, alpha ではなく, blue, green, red の順番で並ぶチャンネル配列で構成されています.

したがって,OpenCVライブラリから出力されたnumpy画像データを描画するためには,ピクセルチャンネルの順序を逆にする必要があります.幸運なことに,OpenCV は以下に示すように,これを行うための確信犯的なメソッド cvtColor(...) を提供します(ただし,numpy 純粋主義者は, img[:,:,::-1] が同じことを行うことを知っていると思われます).

img_transformed = img.transform((2000, 2000), Image.AFFINE, data=T_inv.flatten()[:6], resample=Image.NEAREST)
plt.imshow(np.asarray(img_transformed))


最後に,OpenCV は変換行列のデータをデフォルトの 64 bit float 型ではなく,32 bit float 型であることを要求します.したがって, numpy.float32(...) で 32 bit に変換することを忘れないようにしましょう.また,cv2.warpAffine(...) の API には,適用するピクセル補間アルゴリズムの種類を指定する機能がなく,ドキュメントを読んでもどのようなものが利用されているのか分かりませんでした.もし、ご存知の方、お分かりの方がいらっしゃいましたら、以下のコメント欄に投稿してください。

$ pip install opencv-python


結論

この記事では、アフィン変換とは何か、そしてそれがPythonを使ってどのように画像処理に適用できるかを説明しました。純粋なnumpyとmatplotlibを使用して、アフィン変換がどのように機能するかについて低レベルで直観的な説明を行いました。最後に、PillowとOpenCVの2つの有名なPythonライブラリを使って、同じことができることを実演してみました。

読んでくれてありがとうございます。また、いつも通り、コメントや批評を遠慮なくどうぞ。

リソース

  • デジタル画像処理 Gonzalez and Woods著
  • OpenCVとPythonでコンピュータビジョンのハンズオン
タイトルとURLをコピーしました