課題3、5では、濃度の閾値を決めて2値化を行ってきたが、ここでは疑似濃淡表示を行うための2値化を行う。原画像として、課題2で使用した原画像の中央部分を取り出して正方形にし、縦256画素、横256画素に縮小した画像を使用する。原画像を図1に示す。
# 環境準備
%run -i prelude.ipynb
# 画像を読み込む
original = PIL.Image.open(IMAGE1_PATH)
# 中央部を切り出し 600x400 -> 400x400
original = original.crop(box=(100, 0, 500, 400))
# 縮小
original = original.resize((256, 256), resample=PIL.Image.LANCZOS)
# グレースケール化
original = original.convert(mode='L')
# 表示
def imshow(img, vmax, title):
fig, ax = plt.subplots(figsize=(12, 10))
m = ax.imshow(img, cmap='gray', vmin=0, vmax=vmax)
fig.colorbar(m)
ax.text(0.5, -0.05, title,
transform=ax.transAxes,
horizontalalignment='center',
verticalalignment='top')
imshow(original, 255, '図1: 原画像')
濃度パターン法は、入力のグレースケール画像に対して、1画素あたり $n \times n$ のサブマトリックスを使用して、黒と白の比率で濃淡を表す方法である。例えば、 $2 \times 2$ のサブマトリックスを用いるとすると、黒が濃い順に
■■
■■
■■
■□
■□
□■
■□
□□
□□
□□
といったサブマトリックスを用意して、入力画像の各画素に当てはめればよい。
プログラムでこれを実現する場合は、入力画像をパターン数分の階調で多値化し、その結果に対応するサブマトリックスを用いて画像を生成すればよい。この方法で、2値化を行った結果を、図2に示す。
# サブマトリックスのパターン
patterns = np.array([
[
[0, 0],
[0, 0]
],
[
[0, 0],
[0, 1]
],
[
[0, 1],
[1, 0]
],
[
[0, 1],
[1, 1]
],
[
[1, 1],
[1, 1]
]
])
# パターンの縦と横の長さ
pat_height = patterns.shape[1]
pat_width = patterns.shape[2]
# 課題2で使用した、n階調に変換する関数
def conv_level(img, n):
# すべてゼロの画像を作成
new_img = np.zeros(img.shape, dtype=np.uint)
# 区切り目を求める
# 入力画像は256階調であることを想定する
split_points = np.linspace(0, 256, n, endpoint=False)[1:]
# 区切り目の値ごとにループ
for s in split_points:
# 原画像の画素の濃度がs以上の画素についてインクリメント
new_img += (img >= s).astype(np.uint8)
return new_img
# 5(パターン数)階調に変換
img_5levels = conv_level(np.array(original), patterns.shape[0])
# 縦横ともに2倍(パターンが2x2なので)の画素数の画像を出力とする
new_img_pat = np.empty(
(
img_5levels.shape[0] * pat_height,
img_5levels.shape[1] * pat_width
),
dtype=np.uint8)
# i: 縦のインデックス
# j: 横のインデックス
# l: 5階調の変換結果(0~4)
for (i, j), l in np.ndenumerate(img_5levels):
# パターンを選ぶ
pat = patterns[l]
# パターンを出力画像にコピーする
new_img_pat[
i * pat_height : (i + 1) * pat_height,
j * pat_width : (j + 1) * pat_width] = pat
# 表示
imshow(new_img_pat, 1, '図2: 濃度パターン法(2x2)で2値化した結果')
出力画像の大きさは、縦横ともに原画像の2倍の、512画素になっている。5階調へ変換を行ったため、疑似輪郭が観測できるが、白と黒のみでの画像なのに濃淡を感じることができる画像になっている。
ディザ法は、ある画素を白にするか黒にするかを決める閾値の行列であるディザマトリクスを用いて、出力画素を決定する方法である。入力画像を $f(i,j)$、ディザマトリクスを $T$、出力画像を $g(i,j)$ とすると、ディザ法を用いて出力画像を生成することは、次の式で表される。
$$ g(i,j) = \begin{cases} 0 & f(i,j) \lt T(i,j) \\ 1 & f(i,j) \ge T(i,j) \end{cases} $$特徴として、濃度パターン法と違い、出力画像の画素数は、入力画像の画素数と同じであることが挙げられる。
まず、ディザ法を用いると、どのような画像が得られるのか調べるため、使用している画像ライブラリ(Pillow)の機能を用いて、ディザ法で2値化を行ってみる。この実装では、 Floyd–Steinberg 法が用いられている。結果を図3に示す。
# ライブラリの機能を使ったディザ法による2値化
dither_pil = original.convert(mode='1', dither=PIL.Image.FLOYDSTEINBERG)
# 表示
imshow(dither_pil, 1, '図3: ライブラリの機能を使ったディザ法による2値化結果')
出力された画像は、原画像と同じく、縦256画素、横256画素の大きさである。図3は、近くから見ると、たくさんの点が見え、あまり鮮明ではないが、遠くから見ると、原画像に近い画像に見える。
平均値制限法は、注目している画素の周囲の画素を用いて、ディザマトリクスを作成する。ディザマトリクスは次の式で与えられる。
$$ T(i,j) = K + \left( 1 - \frac{2K}{R} \right) \mu(i,j) $$ここで、 $R$ は入力画像の濃度の最高値($R=\max f(i,j)$)、 $\mu(i,j)$ は、画素 $(i,j)$ の周囲 $n \times n$ 画素の濃度の平均値、 $K$ は階調表現を調節する定数である。
このディザマトリクスの作成をプログラムで行うことを考える。 $\mu(i,j)$ を計算するにあたって、 $(i,j)$ が画像の四隅のとき、周囲 $n \times n$ 画素を取得することができない。そこで、ここでは $(i,j)$ の周囲の画素のうち、取得できるものだけを使って平均を計算するものとする。ディザマトリクスを作成するプログラムを関数 avg_dither_matrix とした。
def avg_dither_matrix(img, n, k):
def mu(i, j):
# (i, j) の周囲の画素を取得する
# 範囲外となってしまうものは含めない
vstart = i - n // 2
hstart = j - n // 2
px = img[
max(vstart, 0) : min(vstart + n, img.shape[0]),
max(hstart, 0) : min(hstart + n, img.shape[1])
]
# 平均値を求める
return px.mean()
# 濃度の最高値を求める
r = img.max()
# 入力画像と同じ大きさのディザマトリクスを作成する
t = np.empty(img.shape)
for (i, j) in np.ndindex(img.shape):
# すべての画素について、示した式を計算する
t[i, j] = k + (1 - 2 * k / r) * mu(i, j)
return t
avg_dither_matrix で作成したディザマトリクスを用いて、実際に原画像を2値化する。ディザマトリクスを適用した画像を作成するには、入力画像の画素に対して、ディザマトリクスの閾値以上ならば1、閾値未満ならば0とすればよい。 $n=4, K=0$ としたときの生成結果を図4に示す。 $n=16, K=0$ としたときの生成結果を図5に示す。
dither_avg4 = original >= avg_dither_matrix(np.array(original), 4, 0)
imshow(dither_avg4, 1, '図4: 平均制限法による2値化結果(n=4, K=0)')
dither_avg16 = original >= avg_dither_matrix(np.array(original), 16, 0)
imshow(dither_avg16, 1, '図5: 平均制限法による2値化結果(n=16, K=0)')
図4では、$n$が小さく、あまり周囲の画素を調べないため、細かく白と黒が入り乱れている。対して、図5では、周囲の画素を多く調べるため、図4よりもべたっと塗られた感じになった。(図5の少年の頬がニワトリに見えて仕方ない。)
組織的ディザ法では、 0 から $n \times n - 1$ までの整数が、ばらばらの場所に、すべて含まれているような $n \times n$ のディザマトリクスを用いる方法である。組織的ディザ法で用いるディザマトリクス $D_n$ の定義は、次のようになっている。
$$ \begin{eqnarray*} D_1 & = & \left[ 0 \right] \\ D_{2n} & = & \left[ \begin{array} 4D_n & 4D_n + 2U_n \\ 4D_n + 3U_n & 4D_n + U_n \end{array} \right] \end{eqnarray*} $$ここで、 $U_k$ は $k \times k$ の 1 で埋め尽くされた行列である。
$D_n$ を作成するプログラムを関数 orderd_dither_matrix に示す。
def ordered_dither_matrix(n):
if n == 1:
return 0
# n は偶数である必要がある
assert n % 2 == 0
# 再帰的に求める
d = ordered_dither_matrix(n // 2)
# D_2n の式に従って行列を作る
# U_k は 1 なので、 numpy ではそのままスカラー値を使えば良い
return np.block([
[4*d, 4*d+2],
[4*d+3, 4*d+1]
])
実際にこのディザマトリクスを使用するには、ディザマトリクスの最大値を入力画像の濃度値の最大値に合わせて正規化し、入力画像の大きさに合わせて行列を敷き詰める必要がある。この処理を行うプログラムを関数 ordered_dither に示す。
def ordered_dither(img, n):
# 基本のディザマトリクスを作成
d = ordered_dither_matrix(n).astype(np.float)
# ディザマトリクスの最大値で割り、
# 入力画像の濃度値の最大である 255 をかけて正規化
d *= 255 / (d.size - 1)
# 入力画像以上の大きさにする
t = np.vstack([d] * ((img.shape[0] + d.shape[0] - 1) // d.shape[0]))
t = np.hstack([t] * ((img.shape[1] + d.shape[1] - 1) // d.shape[1]))
# 大きすぎる分をカットする
t = t[0:img.shape[0], 0:img.shape[1]]
# ディザマトリクスを適用する
return img >= t
これを用いて、 $n=2,4,8,16$ として2値化を行った結果を、それぞれ図6、図7、図8、図9に示す。
dither_ordered2 = ordered_dither(np.array(original), 2)
imshow(dither_ordered2, 1, '図6: 組織的ディザ法による2値化結果(n=2)')
dither_ordered4 = ordered_dither(np.array(original), 4)
imshow(dither_ordered4, 1, '図7: 組織的ディザ法による2値化結果(n=4)')
dither_ordered8 = ordered_dither(np.array(original), 8)
imshow(dither_ordered8, 1, '図8: 組織的ディザ法による2値化結果(n=8)')
dither_ordered16 = ordered_dither(np.array(original), 16)
imshow(dither_ordered16, 1, '図9: 組織的ディザ法による2値化結果(n=16)')
$n=2$ のとき(図6)は、疑似輪郭が表れていて、表現力が足りていないように感じられるが、 $n=4$ 以上では、結果にほとんど変化はなく、特に図8と図9で大きな違いを見つけることはできなかった。