第 10 章 演習解答¶
第 10 章「画像処理の基礎」の演習問題の解答例です。問題は 第 10 章 演習問題 を参照してください。
演習 10-1: グレースケール変換の比較¶
import numpy as np
import matplotlib.pyplot as plt
from PIL import Image
from urllib.request import urlretrieve
# 1. サンプル画像のダウンロードと読み込み(白背景に合成して RGB に)
url = "https://ml.kano.ac/chapters/data/hanpen.png"
urlretrieve(url, "hanpen.png")
img_rgba = Image.open("hanpen.png")
background = Image.new("RGB", img_rgba.size, (255, 255, 255))
background.paste(img_rgba, mask=img_rgba.split()[3])
img_array = np.array(background, dtype=np.float64)
print(f"形状: {img_array.shape}")
# 2. 単純平均法
gray_simple = img_array.mean(axis=2)
# 3. 加重平均法(ITU-R BT.601)
weights = np.array([0.299, 0.587, 0.114])
gray_weighted = np.dot(img_array, weights)
# 2 つの方法の差を確認
diff = np.abs(gray_simple - gray_weighted)
print(f"2 つの方法の差の最大値: {diff.max():.1f}")
# 4. 元のカラー画像と 2 つの変換結果を並べて表示
fig, axes = plt.subplots(1, 3, figsize=(12, 4))
axes[0].imshow(img_array.astype(np.uint8))
axes[0].set_title("カラー画像")
axes[1].imshow(gray_simple, cmap="gray", vmin=0, vmax=255)
axes[1].set_title("単純平均法")
axes[2].imshow(gray_weighted, cmap="gray", vmin=0, vmax=255)
axes[2].set_title("加重平均法")
for ax in axes:
ax.axis("off")
plt.tight_layout()
plt.show()
考察:はんぺんの画像は白い体と茶色の輪郭線が中心で鮮やかな色が少ないため、2 つの方法の差は最大 7.3(255 段階中)にとどまり、見た目ではほとんど区別できません。R が強く B が弱い茶色の輪郭線では、加重平均法のほうがわずかに明るくなります。coffee 画像のような色の多い写真では、赤や青の部分でグレーの濃さがはっきり変わります。加重平均法は、緑に敏感で青に鈍い人間の目の感度に合わせた変換です。
演習 10-2: ノイズ除去とフィルタ強度¶
import numpy as np
import matplotlib.pyplot as plt
from PIL import Image
from urllib.request import urlretrieve
from skimage.filters import gaussian
# サンプル画像のダウンロードと前処理(10.3.1 項と同じ)
url = "https://ml.kano.ac/chapters/data/hanpen.png"
urlretrieve(url, "hanpen.png")
img_rgba = Image.open("hanpen.png")
background = Image.new("RGB", img_rgba.size, (255, 255, 255))
background.paste(img_rgba, mask=img_rgba.split()[3])
img_clean = np.array(background.convert("L")) / 255.0
# ガウスノイズを追加(10.3.1 項と同じ)
rng = np.random.default_rng(42)
noise = rng.normal(0, 0.3, img_clean.shape)
img_noisy = np.clip(img_clean + noise, 0, 1)
print(f"ノイズ画像: 標準偏差 {img_noisy.std():.4f}")
# sigma を変えてガウシアンフィルタを適用
sigmas = [0.5, 2, 5]
fig, axes = plt.subplots(1, 4, figsize=(16, 4))
axes[0].imshow(img_noisy, cmap="gray")
axes[0].set_title("ノイズ画像")
for ax, s in zip(axes[1:], sigmas):
filtered = gaussian(img_noisy, sigma=s)
ax.imshow(filtered, cmap="gray")
ax.set_title(f"sigma = {s}")
print(f"sigma={s}: 標準偏差 {filtered.std():.4f}")
for ax in axes:
ax.axis("off")
plt.tight_layout()
plt.show()
考察:sigma=0.5 ではざらつきがかなり残ります。sigma=2 ではノイズがほぼ目立たず、輪郭のぼやけも少なく、この画像に合った設定です。sigma=5 ではノイズは消えますが、はんぺんの輪郭や顔のパーツも大きくぼやけます。標準偏差も 0.2909 → 0.2272 → 0.1799 と、sigma が大きいほど小さくなります。ノイズを減らすほど細部も失われるので、目的に合わせて sigma を選びます。
演習 10-3: しきい値処理¶
import numpy as np
import matplotlib.pyplot as plt
from PIL import Image
from skimage.filters import threshold_otsu
# サンプル画像(10.3.1 項でダウンロードした hanpen.png)
img_rgba = Image.open("hanpen.png")
background = Image.new("RGB", img_rgba.size, (255, 255, 255))
background.paste(img_rgba, mask=img_rgba.split()[3])
img = np.array(background.convert("L")) # 0〜255 の uint8
# 固定しきい値 64, 128, 192 で二値化して並べて表示
thresholds = [64, 128, 192]
fig, axes = plt.subplots(1, 4, figsize=(16, 4))
axes[0].imshow(img, cmap="gray")
axes[0].set_title("元画像")
for ax, T in zip(axes[1:], thresholds):
binary = img > T
ax.imshow(binary, cmap="gray")
ax.set_title(f"T = {T}")
print(f"T={T}: 白画素の割合 {binary.mean():.1%}")
for ax in axes:
ax.axis("off")
plt.tight_layout()
plt.show()
# 大津の二値化によるしきい値
thresh = threshold_otsu(img)
print(f"大津の二値化によるしきい値: {thresh}")
考察:\(T=64\) では輪郭線の特に濃い部分だけが黒くなり、\(T=128\) では茶色の輪郭線や影まで黒くなります(白画素の割合は 89.8% → 74.5%)。一方、\(T=128\) と \(T=192\) はほとんど変わりません(74.5% → 73.7%)。この画像の画素値は濃い輪郭線(おおむね 128 以下)と白い体・背景(193 以上)に分かれていて、129〜192 の画素は全体の 0.8% しかないためです。大津の二値化はこの 2 つの山の間の 153 を選び、輪郭線と白い部分をうまく分けています。coins のように濃淡が連続的に変わる写真では、固定しきい値の選び方で結果が大きく変わります。
演習 10-4: エッジ検出¶
import cv2
import numpy as np
import matplotlib.pyplot as plt
from PIL import Image
from skimage import filters
# サンプル画像(10.3.1 項でダウンロードした hanpen.png)
img_rgba = Image.open("hanpen.png")
background = Image.new("RGB", img_rgba.size, (255, 255, 255))
background.paste(img_rgba, mask=img_rgba.split()[3])
img = np.array(background.convert("L")) # 0〜255 の uint8
# 4 手法によるエッジ検出
results = [
("Sobel", filters.sobel(img)),
("Prewitt", filters.prewitt(img)),
("Scharr", filters.scharr(img)),
("Canny", cv2.Canny(img, threshold1=50, threshold2=150)),
]
fig, axes = plt.subplots(2, 2, figsize=(8, 8))
for ax, (name, edges) in zip(axes.ravel(), results):
ax.imshow(edges, cmap="gray")
ax.set_title(name)
ax.axis("off")
plt.tight_layout()
plt.show()
# Canny の 2 つのしきい値を変えて比較
params = [(50, 150), (100, 200), (10, 50)]
fig, axes = plt.subplots(1, 3, figsize=(12, 4))
for ax, (t1, t2) in zip(axes, params):
edges = cv2.Canny(img, threshold1=t1, threshold2=t2)
ratio = (edges > 0).mean()
ax.imshow(edges, cmap="gray")
ax.set_title(f"({t1}, {t2})")
ax.axis("off")
print(f"(threshold1, threshold2)=({t1}, {t2}): "
f"エッジ画素の割合 {ratio:.2%}")
plt.tight_layout()
plt.show()
(threshold1, threshold2)=(50, 150): エッジ画素の割合 6.15%
(threshold1, threshold2)=(100, 200): エッジ画素の割合 6.15%
(threshold1, threshold2)=(10, 50): エッジ画素の割合 6.16%
考察:Sobel・Prewitt・Scharr は勾配の強さを濃淡で返すので、輪郭が太めのグレーの線になり、3 つの結果はほとんど同じです(カーネルの重みが少し違うだけです)。Canny は非極大値抑制で 1 画素幅に細くしたエッジを返すので、輪郭が細い白線になります。しきい値を変えても、エッジ画素の割合はどれも約 6.2% でほとんど変わりません。はんぺんのようなイラストは白い面と濃い輪郭線でできていて、輪郭の勾配がとても強いため、どのしきい値でも同じ輪郭が検出されます。camera 画像で同じ比較をすると、(10, 50) で 18.6%、(50, 150) で 11.8%、(100, 200) で 7.5% と大きく変わります。しきい値を下げると細かな模様まで拾い、上げると主な輪郭だけが残ります。
演習 10-5: HOG 特徴量とパラメータ¶
import numpy as np
import matplotlib.pyplot as plt
from PIL import Image
from skimage.feature import hog
# サンプル画像(10.3.1 項でダウンロードした hanpen.png)
img_rgba = Image.open("hanpen.png")
background = Image.new("RGB", img_rgba.size, (255, 255, 255))
background.paste(img_rgba, mask=img_rgba.split()[3])
img = np.array(background.convert("L"))
# pixels_per_cell を変えて HOG 特徴量を計算
params = [(8, 8), (16, 16), (32, 32)]
fig, axes = plt.subplots(2, 2, figsize=(8, 8))
axes[0, 0].imshow(img, cmap="gray")
axes[0, 0].set_title("元画像")
for ax, ppc in zip(axes.ravel()[1:], params):
features, hog_image = hog(
img, orientations=9, pixels_per_cell=ppc,
cells_per_block=(2, 2), block_norm="L2-Hys",
visualize=True, feature_vector=True
)
ax.imshow(hog_image, cmap="gray")
ax.set_title(f"cell={ppc}")
print(f"pixels_per_cell={ppc}: 次元数 {features.shape[0]}")
for ax in axes.ravel():
ax.axis("off")
plt.tight_layout()
plt.show()
pixels_per_cell=(8, 8): 次元数 15876
pixels_per_cell=(16, 16): 次元数 3600
pixels_per_cell=(32, 32): 次元数 576
考察:セルを大きくするとマスの数が減るため、次元数は 15876 → 3600 → 576 と減ります(セルの一辺を 2 倍にすると約 1/4)。可視化画像では、(8, 8) ははんぺんの輪郭や顔の細かい形まで勾配の向きとして残っていますが、(32, 32) では大まかな輪郭の向きしかわかりません。セルを大きくすると次元数(計算コストや過学習のしやすさ)は下がりますが、細かい形の情報は失われます。
発展課題 1: 文字画像の二値化¶
import cv2
import numpy as np
import matplotlib.pyplot as plt
from skimage import data
from skimage.filters import gaussian, threshold_otsu
# 1. page 画像を読み込む
img = data.page()
# 2-a. 大津の二値化のみ
th_otsu = threshold_otsu(img)
binary_otsu = img > th_otsu
print(f"大津のしきい値: {th_otsu}")
# 2-b. ガウシアンフィルタ + 大津の二値化
img_smooth = gaussian(img, sigma=1) # 出力は 0〜1 の float
th_smooth = threshold_otsu(img_smooth)
binary_smooth = img_smooth > th_smooth
print(f"平滑化後の大津のしきい値: {th_smooth:.3f}")
# 2-c. 適応的しきい値処理
binary_adaptive = cv2.adaptiveThreshold(
img, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
cv2.THRESH_BINARY, blockSize=35, C=10
)
# 3. 元画像と 3 通りの結果を 2×2 で並べて表示
results = [
("元画像", img),
("(a) 大津の二値化", binary_otsu),
("(b) ガウシアン + 大津", binary_smooth),
("(c) 適応的しきい値", binary_adaptive),
]
fig, axes = plt.subplots(2, 2, figsize=(10, 7))
for ax, (name, result) in zip(axes.ravel(), results):
ax.imshow(result, cmap="gray")
ax.set_title(name)
ax.axis("off")
plt.tight_layout()
plt.show()
考察:page 画像は左側が暗く右側が明るいため、画像全体で 1 つのしきい値を使う大津の二値化では、左側が文字ごと黒くつぶれます。ガウシアンフィルタを先にかけてもノイズが減るだけで、明るさのムラは残ります。適応的しきい値処理は画素ごとに周囲 blockSize×blockSize の範囲でしきい値を決めるので、画像全体で文字が読めます。blockSize や C を変えて、結果がどう変わるか試してみてください。
発展課題 2: カラー画像の RGB ヒストグラム¶
import numpy as np
import matplotlib.pyplot as plt
from skimage import data
# 1. coffee 画像を読み込む
img = data.coffee()
# 2. R・G・B 各チャンネルのヒストグラムを計算
names = ["R", "G", "B"]
colors = ["red", "green", "blue"]
histograms = []
for i in range(3):
hist, bin_edges = np.histogram(img[:, :, i], bins=256, range=(0, 256))
histograms.append(hist)
# 3. 元画像と RGB ヒストグラムを 1 行 2 列で表示
fig, axes = plt.subplots(1, 2, figsize=(12, 4))
axes[0].imshow(img)
axes[0].set_title("元画像")
axes[0].axis("off")
for hist, name, color in zip(histograms, names, colors):
axes[1].plot(hist, color=color, label=name)
axes[1].set_title("RGB ヒストグラム")
axes[1].set_xlabel("画素値")
axes[1].set_ylabel("頻度")
axes[1].legend()
plt.tight_layout()
plt.show()
# 4. 各チャンネルの最頻値・中央値・平均値を出力
for i, name in enumerate(names):
channel = img[:, :, i].ravel()
mode = np.bincount(channel).argmax()
print(f"{name}: 最頻値 {mode}, 中央値 {np.median(channel):.0f}, "
f"平均値 {channel.mean():.1f}")
考察:R チャンネルは最頻値 196・平均値 158.6 と高い値に寄っていますが、G と B は低い値に集まっています(最頻値はそれぞれ 4 と 2)。R が強く G・B が弱い画素が多く、全体として赤茶色の画像だとわかります。実際、この写真は木のテーブルやコーヒーなど茶色の被写体が大部分です。色ヒストグラムを使うと、画像を見なくても色の傾向をつかめるので、画像検索や分類の特徴量にも使えます。