Skip to content

10. 画像処理の基礎

本章では、画像をNumPy配列として読み込み、色空間の変換、リサイズ、エッジ検出、HOG(Histogram of Oriented Gradients)の順に処理します。各処理で画素値や配列の形状がどう変わるかをコードで確認します。

10.1 画像データの基礎

10.1.1 デジタル画像とは

デジタル画像は、画素(ピクセル) と呼ばれる小さな点の集まりで表現されます。各画素は色や明るさの情報を持っており、これらを格子状に配置することで画像を構成します。

graph LR
    A("デジタル画像<br/>(例: 1920×1080)")
    B("画素の集まり<br/>(ピクセル配列)")
    C("各画素の値<br/>(R, G, B: 0-255)")
    D("数値データ<br/>(行列として処理)")

    A --> B --> C --> D

    classDef data fill:#D9EAF3,stroke:#0072B2,stroke-width:2px,color:#000
    classDef proc fill:#D9F0EA,stroke:#009E73,stroke-width:2px,color:#000
    classDef model fill:#F7EBF2,stroke:#CC79A7,stroke-width:2px,color:#000
    classDef out fill:#F5ECE2,stroke:#BF8040,stroke-width:2px,color:#000
    class A data
    class B proc
    class C model
    class D out

画像の主要な要素

  • 画素(ピクセル):画像を構成する最小単位
  • 解像度:画像の幅 × 高さのピクセル数(例:1920×1080)
  • 色深度:各画素で表現できる色の数(例:8bit = 256 色)

10.1.2 画像の種類

デジタル画像は、色の表現方法によって主に 3 つの種類に分けられます。

graph TB
    A("デジタル画像の種類")

    B("グレースケール画像<br/>(1チャンネル)<br/>例: 白黒写真")
    C("カラー画像 RGB<br/>(3チャンネル)<br/>例: 通常の写真")
    D("RGBA画像<br/>(4チャンネル)<br/>例: PNG透過画像")

    A --> B & C & D

    B --> B1("明るさのみ<br/>0(黒) ~ 255(白)")
    C --> C1("Red, Green, Blue<br/>各 0~255 の組み合わせ")
    D --> D1("RGB + Alpha不透明度<br/>0(透明) ~ 255(不透明)")

    classDef data fill:#D9EAF3,stroke:#0072B2,stroke-width:2px,color:#000
    classDef proc fill:#D9F0EA,stroke:#009E73,stroke-width:2px,color:#000
    classDef model fill:#F7EBF2,stroke:#CC79A7,stroke-width:2px,color:#000
    classDef neu fill:#F2F2F2,stroke:#8C8C8C,stroke-width:2px,color:#000
    class A neu
    class B,B1 data
    class C,C1 proc
    class D,D1 model

グレースケール画像

  • 各画素が 1 つの値(明るさ)を持つ
  • 0(黒)から 255(白)の範囲
  • 形状:(高さ, 幅)

カラー画像(RGB)

  • 各画素が R(赤)、G(緑)、B(青)の 3 つの値を持つ
  • 各チャンネルが 0〜255 の範囲(本書で扱う 8bit 画像の場合)
  • 形状:(高さ, 幅, 3)

RGBA 画像

  • RGB + Alpha(不透明度)
  • 形状:(高さ, 幅, 4)

10.1.3 Python での画像の扱い

Python で画像を扱うには、主に以下のライブラリを使用します。それぞれ得意分野が異なるため、本章では役割ごとに使い分けていきます。

主要なライブラリと役割分担

ライブラリ名 インポート名 主な用途
Pillow PIL 画像の読み込み・保存・基本変換
NumPy numpy 画像データの配列操作
Matplotlib matplotlib 画像の表示・可視化
scikit-image skimage 高度な画像処理・フィルタリング
OpenCV cv2 高速な画像・動画処理(リアルタイム処理にも対応)

本章では、画像の読み込み・保存・基本変換には Pillow、配列としての演算には NumPy、表示には Matplotlib を使います。しきい値の自動決定(大津の二値化)やフィルタリングといった高度な処理には scikit-image、エッジ検出(Sobel・Canny)やリサイズ・幾何変換のような高速処理には OpenCV を使います。

OpenCV のインストール

OpenCV は標準では入っていないため、以下のコマンドでインストールします。

pip install opencv-python

※ CHIKUWA Editor にはインストール済みです。

OpenCV を使う場合は BGR 順序に注意

OpenCV の cv2.imread() 関数は、色チャンネルを BGR(青・緑・赤) の順序で読み込みます(歴史的な経緯による仕様です)。Matplotlib の imshow() 関数は RGB 順序 を前提とするため、そのまま表示すると色が反転して見えます。

本章では画像の読み込みに Pillow を使うためこの問題は起こりませんが、cv2.imread() 関数を使う場合は表示前に cv2.cvtColor() 関数で変換してください。

img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
graph TB
    A("画像ファイル<br/>(PNG, JPEG, etc.)")
    B("読み込み:<br/>Pillow (Image.open)")
    C("NumPy配列に変換<br/>(height, width, channels)")
    D("画像処理・演算<br/>(NumPy / scikit-image / OpenCV)")
    E1("表示: Matplotlib")
    E2("保存: Pillow (Image.fromarray → save)")

    A --> B --> C --> D
    D --> E1
    D --> E2

    classDef data fill:#D9EAF3,stroke:#0072B2,stroke-width:2px,color:#000
    classDef proc fill:#D9F0EA,stroke:#009E73,stroke-width:2px,color:#000
    classDef out fill:#F5ECE2,stroke:#BF8040,stroke-width:2px,color:#000
    class A data
    class B,C,D proc
    class E1,E2 out

10.1.4 Pillow による画像の読み込み

Pillow(PIL) は、シンプルで扱いやすい定番の画像処理ライブラリです。画像ファイルの読み込みや保存、基本的な変換操作を手軽に行えます。

まず、プログラム中で使用するサンプル画像を NumPy で生成します。以下のコードでは、赤・緑・青の 3 色で構成されたシンプルなカラー画像を作成し、PNG ファイルとして保存します。Image.fromarray() 関数に NumPy 配列を渡すことで Pillow 形式の画像オブジェクトに変換でき、その save() メソッドでファイルとして保存できます。

import numpy as np
from PIL import Image

# 60×60 のRGB画像を作成(3色の縦ストライプ)
img_array = np.zeros((60, 60, 3), dtype=np.uint8)
img_array[:, :20, :] = [255, 0, 0]  # 左:赤
img_array[:, 20:40, :] = [0, 255, 0]  # 中:緑
img_array[:, 40:, :] = [0, 0, 255]  # 右:青

# NumPy配列からPillow画像を作成して保存
img = Image.fromarray(img_array)
img.save('sample.png')
print("画像を保存しました")
Output
画像を保存しました

次に、保存した画像を Image.open() 関数で読み込み、基本情報を確認します。

from PIL import Image

# 画像の読み込み
img = Image.open('sample.png')

# 画像の基本情報
print(f"フォーマット: {img.format}")
print(f"サイズ: {img.size}")  # (幅, 高さ)
print(f"モード: {img.mode}")  # RGB, L, RGBA
Output
フォーマット: PNG
サイズ: (60, 60)
モード: RGB

Pillow 画像を NumPy 配列に変換することで、画素値に直接アクセスしたり、数値演算を適用したりできます。

import numpy as np
from PIL import Image

img = Image.open('sample.png')

# NumPy配列に変換
img_array = np.array(img)

print(f"データ型: {img_array.dtype}")
print(f"形状: {img_array.shape}")
print(f"最小値: {img_array.min()}")
print(f"最大値: {img_array.max()}")

# 左上の画素 (0, 0) の RGB 値
print(f"左上の画素値: {img_array[0, 0]}")
Output
データ型: uint8
形状: (60, 60, 3)
最小値: 0
最大値: 255
左上の画素値: [255   0   0]

size と shape の順序の違い

Pillow 画像の size は (幅, 高さ) の順ですが、NumPy 配列の shape は (高さ, 幅, チャンネル数) の順です。混同しやすいので注意してください。

10.1.5 サンプル画像の生成

実習用に、NumPy を使ってサンプル画像を生成してみましょう。

import numpy as np
import matplotlib.pyplot as plt

# グレースケール画像の生成(グラデーション)
height, width = 200, 300
gradient = np.linspace(0, 255, width, dtype=np.uint8)
gray_img = np.tile(gradient, (height, 1))

# カラー画像の生成(RGB)
color_img = np.zeros((200, 300, 3), dtype=np.uint8)
color_img[:, :100, 0] = 255  # 左側を赤
color_img[:, 100:200, 1] = 255  # 中央を緑
color_img[:, 200:, 2] = 255  # 右側を青

# 表示
fig, axes = plt.subplots(1, 2, figsize=(12, 5))

axes[0].imshow(gray_img, cmap='gray')
axes[0].set_title('グレースケール画像')
axes[0].axis('off')

axes[1].imshow(color_img)
axes[1].set_title('カラー画像(RGB)')
axes[1].axis('off')

plt.tight_layout()
plt.show()

print(f"グレースケール画像の形状: {gray_img.shape}")
print(f"カラー画像の形状: {color_img.shape}")

10-1

10.1.6 RGB 色空間

RGB 色空間 は、赤(Red)、緑(Green)、青(Blue)の 3 つの原色を組み合わせて色を表現する方法です。

graph TB
    A("RGB 色空間<br/>(加法混色)")
    B("R: 0-255<br/>(赤チャンネル)")
    C("G: 0-255<br/>(緑チャンネル)")
    D("B: 0-255<br/>(青チャンネル)")
    E("色の組み合わせ<br/>256³ = 16,777,216 色")

    A --> B & C & D
    B & C & D --> E

    classDef data fill:#D9EAF3,stroke:#0072B2,stroke-width:2px,color:#000
    classDef proc fill:#D9F0EA,stroke:#009E73,stroke-width:2px,color:#000
    classDef hl fill:#F9E7D9,stroke:#D55E00,stroke-width:2px,color:#000
    classDef neu fill:#F2F2F2,stroke:#8C8C8C,stroke-width:2px,color:#000
    classDef out fill:#F5ECE2,stroke:#BF8040,stroke-width:2px,color:#000
    class A neu
    class B hl
    class C proc
    class D data
    class E out

主要な色の例

色 R G B
黒 0 0 0
白 255 255 255
赤 255 0 0
緑 0 255 0
青 0 0 255
黄 255 255 0
紫 255 0 255
水色 0 255 255

実際のカラー画像を R・G・B のチャンネルごとに分解して眺めてみましょう。題材には、キャラクター「ちくわ」のドット絵を使います。この画像は透明背景の RGBA 画像(10.1.2 項)なので、白背景に合成して RGB 画像として読み込みます。

import numpy as np
import matplotlib.pyplot as plt
from PIL import Image
from urllib.request import urlretrieve

# サンプル画像のダウンロード
url = "https://ml.kano.ac/chapters/data/chikuwa.png"
urlretrieve(url, "chikuwa.png")

# RGBA 画像を白背景に合成して RGB 画像として読み込む
img_rgba = Image.open('chikuwa.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)

print(f"形状: {img_array.shape}")

# カラー画像と R・G・B の各チャンネルをグレースケールの明暗として表示
fig, axes = plt.subplots(1, 4, figsize=(12, 3.4))

axes[0].imshow(img_array)
axes[0].set_title('カラー画像')

for i, name in enumerate(['R チャンネル', 'G チャンネル', 'B チャンネル']):
    axes[i + 1].imshow(img_array[:, :, i], cmap='gray', vmin=0, vmax=255)
    axes[i + 1].set_title(name)

for ax in axes:
    ax.axis('off')

plt.tight_layout()
plt.show()
Output
形状: (1024, 1024, 3)

10-2

各チャンネルは、その原色の強さを明暗で表したグレースケール画像として表示しています。体の暖色(ベージュや茶色)は R の成分が強いため R チャンネルでは全体的に明るく、B チャンネルでは暗く表示されます。特に胸元のオレンジ色の部分は、R チャンネルでは明るいのに対し、G や B のチャンネルでは暗くなっており、色の違いがチャンネルの値の違いとして現れていることがわかります。

10.1.7 グレースケール変換

カラー画像をグレースケール画像に変換する方法は複数あります。ここでは、3 つの代表的な方法を紹介します。題材には、10.1.4 項で作成した sample.png(赤・緑・青の縦ストライプ)を使います。

方法 1:単純平均法

R、G、B の 3 チャンネルの値を単純に平均する方法です。3 色の平均的な強さがそのまま明るさになります。

\[ Y = \frac{R + G + B}{3} \]
import numpy as np
from PIL import Image

img = Image.open('sample.png')
img_array = np.array(img, dtype=np.float64)

# 単純平均法
gray_simple = img_array.mean(axis=2)

print(f"形状: {gray_simple.shape}")
print(f"左上: {gray_simple[0, 0]:.1f}")
print(f"中央: {gray_simple[30, 30]:.1f}")
Output
形状: (60, 60)
左上: 85.0
中央: 85.0

方法 2:加重平均法(ITU-R BT.601 係数)

人間の目は緑色に最も敏感であり、青色には最も鈍感です。この特性を反映した重み付き平均を用いることで、人間の知覚に近いグレースケール変換が行えます。ITU-R BT.601 規格で定められた以下の係数がよく使われます。

\[ Y = 0.299 \, R + 0.587 \, G + 0.114 \, B \]
import numpy as np
from PIL import Image

img = Image.open('sample.png')
img_array = np.array(img, dtype=np.float64)

# 加重平均法(ITU-R BT.601)
weights = np.array([0.299, 0.587, 0.114])
gray_weighted = np.dot(img_array, weights)

print(f"形状: {gray_weighted.shape}")
print(f"赤領域: {gray_weighted[30, 10]:.4f}")
print(f"緑領域: {gray_weighted[30, 30]:.4f}")
print(f"青領域: {gray_weighted[30, 50]:.4f}")
Output
形状: (60, 60)
赤領域: 76.2450
緑領域: 149.6850
青領域: 29.0700

加重平均法では、赤・緑・青の各領域で異なるグレー値が得られます。緑は最も明るく、青は最も暗く変換されることがわかります。

方法 3:Pillow の convert メソッド

Pillow の convert("L") メソッドを使うと、内部的に ITU-R BT.601 の係数で加重平均を計算してグレースケール変換を行います。最も簡単な方法です。

from PIL import Image
import numpy as np

img = Image.open('sample.png')

# Pillowのconvertメソッドでグレースケール変換
gray_img = img.convert('L')

print(f"モード: {gray_img.mode}")
print(f"サイズ: {gray_img.size}")

gray_array = np.array(gray_img)
print(f"配列の形状: {gray_array.shape}")
print(f"赤領域: {gray_array[30, 10]}")
print(f"緑領域: {gray_array[30, 30]}")
print(f"青領域: {gray_array[30, 50]}")
Output
モード: L
サイズ: (60, 60)
配列の形状: (60, 60)
赤領域: 76
緑領域: 150
青領域: 29

Pillow の convert("L") の結果は整数値に丸められますが、方法 2 の加重平均法とほぼ同じ値が得られています。

変換前のカラー画像と、3 つの方法による変換結果を並べて表示してみましょう。

import matplotlib.pyplot as plt

fig, axes = plt.subplots(2, 2, figsize=(12, 10))

axes[0, 0].imshow(img_array.astype(np.uint8))
axes[0, 0].set_title('元のカラー画像')
axes[0, 0].axis('off')

axes[0, 1].imshow(gray_simple, cmap='gray', vmin=0, vmax=255)
axes[0, 1].set_title('単純平均法')
axes[0, 1].axis('off')

axes[1, 0].imshow(gray_weighted, cmap='gray', vmin=0, vmax=255)
axes[1, 0].set_title('加重平均法')
axes[1, 0].axis('off')

axes[1, 1].imshow(gray_array, cmap='gray', vmin=0, vmax=255)
axes[1, 1].set_title('convert("L")')
axes[1, 1].axis('off')

plt.tight_layout()
plt.show()

10-3

単純平均法では 3 色が同じ明るさのグレーになるのに対し、加重平均法と convert("L") では緑が明るく、青が暗く変換されていることがわかります。

色空間の使い分け

  • RGB:画像の表示、一般的な画像処理、ニューラルネットワークの入力
  • グレースケール:形状認識、高速処理、計算量削減

Playground:色空間

色空間 の Playground では、同じ画像を R・G・B の各チャンネルやグレースケール、HSV の色相・彩度・明度に切り替えて表示できます。画像の上にカーソルを乗せるとその画素の実際の数値が表示されるので、「画像とは数値の並びである」ことと、チャンネルごとの見え方の違いを合わせて確かめてみましょう。

10.1.8 機械学習のための画像前処理

機械学習モデルに画像を入力する前に、モデルの入力仕様に合わせて前処理します。このセクションでは、代表的な処理を学びます。

画像のリサイズ

多くの画像分類モデルは入力サイズが決まっているため、その場合は画像を指定サイズへ変換します。画像のリサイズには cv2.resize() 関数を使用します。

import cv2
import numpy as np
import matplotlib.pyplot as plt
from skimage import data

# サンプル画像の読み込み
original = data.astronaut()  # (512, 512, 3)

# 画像をリサイズ(cv2.resize の引数は (幅, 高さ) の順序)
resized = cv2.resize(original, (224, 224))  # よく使われるサイズ

print(f"元の画像: {original.shape}")
print(f"リサイズ後: {resized.shape}")

# 表示
fig, axes = plt.subplots(1, 2, figsize=(12, 5))

axes[0].imshow(original)
axes[0].set_title(f'元の画像 {original.shape[:2]}')
axes[0].axis('off')

axes[1].imshow(resized)
axes[1].set_title(f'リサイズ後 {resized.shape[:2]}')
axes[1].axis('off')

plt.tight_layout()
plt.show()

10-4

よく使われる画像サイズ

  • 224×224:VGG、ResNetなどの標準サイズ
  • 299×299:Inception系のネットワーク
  • 32×32:CIFAR-10データセット
  • 28×28:MNISTデータセット(手書き数字)

正規化(0-1 変換)

画像の画素値(0-255)を(0-1)に正規化するには、以下のようにします。

import numpy as np
import matplotlib.pyplot as plt
from skimage import data

# サンプル画像の読み込み
img = data.camera()

# 正規化: 0-255 → 0-1
img_normalized = img / 255.0

print(f"元の画像の範囲: {img.min()} ~ {img.max()}")
print(f"元の画像のデータ型: {img.dtype}")
print(f"正規化後の範囲: {img_normalized.min():.2f} ~ {img_normalized.max():.2f}")
print(f"正規化後のデータ型: {img_normalized.dtype}")

# 表示(matplotlib は 0-1 と 0-255 の両方に対応)
fig, axes = plt.subplots(1, 2, figsize=(12, 5))

axes[0].imshow(img, cmap='gray')
axes[0].set_title(f'元の画像 (0-255)\n{img.dtype}')
axes[0].axis('off')

axes[1].imshow(img_normalized, cmap='gray')
axes[1].set_title(f'正規化後 (0-1)\n{img_normalized.dtype}')
axes[1].axis('off')

plt.tight_layout()
plt.show()

正規化の重要性

正規化が必要な理由

  1. 学習の安定性:大きな値(0-255)はニューラルネットワークの学習を不安定にする
  2. 勾配の制御:適切な範囲の値により、勾配消失・爆発を防ぐ
  3. 収束速度:正規化により学習が速く収束する

標準的な正規化方法

  • img / 255.0 → [0, 1] の範囲
  • (img - mean) / std → 平均0、標準偏差1(標準化)

データ拡張の基礎

データ拡張は、訓練データを人工的に増やす技術です。過学習を防ぎ、モデルの汎化性能を向上させることができます。OpenCV では cv2.flip() 関数で反転、cv2.getRotationMatrix2D() 関数と cv2.warpAffine() 関数で回転を行います。

import cv2
import numpy as np
import matplotlib.pyplot as plt
from skimage import data

# サンプル画像の読み込み
img = data.chelsea()  # 猫の画像

# 15 度回転用のアフィン変換行列を作成
h, w = img.shape[:2]
M = cv2.getRotationMatrix2D((w / 2, h / 2), 15, 1.0)  # (中心, 角度, 拡大率)

# データ拡張の例
augmented = {
    '元の画像': img,
    '左右反転': cv2.flip(img, 1),                    # 1=水平、0=垂直、-1=両方
    '回転 15度': cv2.warpAffine(img, M, (w, h)),
    '明るさ +50': np.clip(img + 50, 0, 255).astype(np.uint8),
}

# 表示
fig, axes = plt.subplots(2, 2, figsize=(12, 10))
axes = axes.ravel()

for idx, (title, aug_img) in enumerate(augmented.items()):
    axes[idx].imshow(aug_img)
    axes[idx].set_title(title)
    axes[idx].axis('off')

plt.tight_layout()
plt.show()

10-5

データ拡張のポイント

よく使われる拡張手法

  • 左右反転:最も一般的な拡張
  • 回転:物体の向きに依存しない認識
  • 明るさ調整:照明条件の変化に対応
  • クロッピング:ランダムな位置から切り取り

注意点

  • 拡張は訓練データのみに適用(テストデータには適用しない)
  • タスクに応じて適切な拡張を選択(例:文字認識では上下反転は不適切)

10.1.9 scikit-image のサンプル画像

scikit-image は、画像処理のための Python ライブラリです。本章ではフィルタリングや大津の二値化などの高度な処理に使いますが、data モジュールに含まれる実験用のサンプル画像もファイル不要で読み込めるため、演習や実習で活用します。

主なサンプル画像

関数名 説明 サイズ 種類 用途例
data.camera() カメラマンの写真 512×512 グレースケール 一般的な画像処理の実験
data.astronaut() 宇宙飛行士の写真 512×512×3 カラー(RGB) カラー画像処理、チャンネル分離
data.coins() 複数のコインの写真 303×384 グレースケール しきい値処理、物体検出
data.chelsea() 猫の写真 300×451×3 カラー(RGB) データ拡張、リサイズ
data.coffee() コーヒーカップの写真 400×600×3 カラー(RGB) 色空間の変換
data.moon() 月面の写真 512×512 グレースケール コントラスト調整
data.text() テキストの画像 172×448 グレースケール 二値化、文字認識
data.rocket() ロケット打ち上げの写真 427×640×3 カラー(RGB) リサイズ、データ拡張、物体検出
data.horse() 馬のシルエット 328×400 二値(bool) 形状解析、マスク処理
data.immunohistochemistry() 免疫組織化学染色の医療画像 512×512×3 カラー(RGB) 色空間の変換、医療画像処理
data.checkerboard() チェッカーボード(合成) 200×200 グレースケール 特徴点検出、コーナー検出

使用例

from skimage import data
import matplotlib.pyplot as plt

# サンプル画像の読み込み(RGB 順序で返される)
img = data.astronaut()

# 画像の表示
plt.figure(figsize=(8, 6))
plt.imshow(img)
plt.title('Astronaut')
plt.axis('off')
plt.show()

# 画像情報の確認
print(f"形状: {img.shape}")
print(f"データ型: {img.dtype}")
print(f"値の範囲: {img.min()} ~ {img.max()}")

scikit-image と OpenCV の組み合わせ

skimage.data は RGB 順序 で画像を返すため、cv2 の関数で処理した後もそのまま Matplotlib で表示できます(10.4.2 項の Sobel フィルタはこの組み合わせです)。cv2.imread() 関数で読み込んだ場合のみ BGR → RGB 変換が必要になります。

10.2 しきい値処理

しきい値処理 は、画像を二値化(白と黒の 2 値に分ける)する基本的な画像処理手法です。物体検出や領域分割の前処理として広く使われます。

graph LR
    A("グレースケール画像<br/>(画素値: 0-255)")
    B("しきい値と比較<br/>(例: T=128)")
    C{"画素値 > T?"}
    D("白 (255)<br/>(前景)")
    E("黒 (0)<br/>(背景)")
    F("二値画像<br/>(0 or 255)")

    A --> B --> C
    C -->|Yes| D
    C -->|No| E
    D --> F
    E --> F

    classDef data fill:#D9EAF3,stroke:#0072B2,stroke-width:2px,color:#000
    classDef proc fill:#D9F0EA,stroke:#009E73,stroke-width:2px,color:#000
    classDef out fill:#F5ECE2,stroke:#BF8040,stroke-width:2px,color:#000
    classDef neu fill:#F2F2F2,stroke:#8C8C8C,stroke-width:2px,color:#000
    class A data
    class B,C proc
    class D,E neu
    class F out

しきい値処理の用途:

  • 物体と背景の分離
  • 文字認識の前処理
  • 物体の輪郭抽出
  • 特定の明るさ範囲の抽出

10.2.1 固定しきい値処理

最も基本的な方法は、しきい値 \(T\) を手動で設定し、各画素値と比較する方法です。

\[ g(x, y) = \begin{cases} 255 & (f(x, y) \geq T) \\ 0 & (f(x, y) < T) \end{cases} \]

ここで \(f(x, y)\) は入力画像の画素値、\(g(x, y)\) は出力画像の画素値です。この比較は NumPy の np.where() 関数だけで書けます。ここでは、左から右へ 0〜255 に変化するグラデーション画像を作って試してみましょう。

import numpy as np
from PIL import Image

# グレースケールのグラデーション画像を作成
gradient = np.tile(np.arange(256, dtype=np.uint8), (100, 1))
img_gray = Image.fromarray(gradient)
img_gray.save('gradient.png')

# NumPy配列に変換
gray_array = np.array(img_gray)

# 固定しきい値 T=128 で二値化
T = 128
binary = np.where(gray_array >= T, 255, 0).astype(np.uint8)

print(f"入力画像の画素値の範囲: {gray_array.min()} - {gray_array.max()}")
print(f"しきい値: {T}")
print(f"二値化後のユニーク値: {np.unique(binary)}")

binary_img = Image.fromarray(binary)
binary_img.save('binary_fixed.png')
print("二値化画像を保存しました")
Output
入力画像の画素値の範囲: 0 - 255
しきい値: 128
二値化後のユニーク値: [  0 255]
二値化画像を保存しました

保存された二値化画像を、元のグラデーション画像と並べて表示します。

import matplotlib.pyplot as plt

fig, axes = plt.subplots(1, 2, figsize=(12, 5))

# 白い領域が背景と同化しないよう、枠線を残して表示する
axes[0].imshow(gray_array, cmap='gray', vmin=0, vmax=255)
axes[0].set_title('元の画像(グラデーション)')
axes[0].set_xticks([])
axes[0].set_yticks([])

axes[1].imshow(binary, cmap='gray', vmin=0, vmax=255)
axes[1].set_title(f'しきい値 = {T}')
axes[1].set_xticks([])
axes[1].set_yticks([])

plt.tight_layout()
plt.show()

10-6

しきい値 \(T=128\) を境に、左半分が黒、右半分が白にはっきりと分かれています。固定しきい値処理では、適切なしきい値を手動で選択する必要があります。画像の明るさや照明条件によって最適なしきい値は異なるため、汎用的には使いにくいという欠点があります。

10.2.2 大津の二値化

大津の二値化(Otsu's method)は、画像のヒストグラムから自動的に最適なしきい値を決定する手法です。クラス内分散を最小化(クラス間分散を最大化)することで、最適なしきい値を求めます。scikit-image の threshold_otsu() 関数を使うと、大津の二値化によるしきい値を容易に求められます。

graph TB
    A("ヒストグラム分析<br/>(画素値の分布)")
    B("全候補を探索<br/>(T = 0 ~ 255)")
    C("分散計算<br/>(クラス間分散σ²)")
    D("最大値を選択<br/>(max σ²)")
    E("最適しきい値<br/>(自動決定)")

    A --> B --> C --> D --> E

    classDef data fill:#D9EAF3,stroke:#0072B2,stroke-width:2px,color:#000
    classDef proc fill:#D9F0EA,stroke:#009E73,stroke-width:2px,color:#000
    classDef model fill:#F7EBF2,stroke:#CC79A7,stroke-width:2px,color:#000
    classDef out fill:#F5ECE2,stroke:#BF8040,stroke-width:2px,color:#000
    class A data
    class B,C proc
    class D model
    class E out

ここでは、明るさの分布が双峰性(2 つの山を持つ)になるテスト用の画像を作って試してみましょう。上半分が暗く(画素値 80)、下半分が明るい(画素値 200)画像にノイズを加えたものを入力とします。

import numpy as np
import matplotlib.pyplot as plt
from skimage.filters import threshold_otsu

# テスト用の画像を作成(上半分が暗く、下半分が明るい画像)
img_array = np.zeros((100, 100), dtype=np.uint8)
img_array[:50, :] = 80   # 上半分: 暗い
img_array[50:, :] = 200  # 下半分: 明るい

# ノイズを追加(平均 0、標準偏差 20)
rng = np.random.default_rng(42)
noise = rng.normal(0, 20, img_array.shape)
img_noisy = np.clip(img_array + noise, 0, 255).astype(np.uint8)

# 大津の二値化でしきい値を自動決定
thresh = threshold_otsu(img_noisy)
print(f"大津の二値化で求めたしきい値: {thresh}")

# 二値化
binary_otsu = (img_noisy >= thresh).astype(np.uint8) * 255
print(f"二値化後のユニーク値: {np.unique(binary_otsu)}")

# 表示
fig, axes = plt.subplots(1, 3, figsize=(15, 4.5))

axes[0].imshow(img_noisy, cmap='gray')
axes[0].set_title('元の画像')
axes[0].set_xticks([])
axes[0].set_yticks([])

# ヒストグラム:2つの山とその谷にしきい値が決まる様子を確認する
axes[1].hist(img_noisy.ravel(), bins=64, range=(0, 255), color='#8d6e63')
axes[1].axvline(thresh, color='#c0574d', linestyle='--', linewidth=2,
                label=f'しきい値 = {thresh}')
axes[1].set_title('画素値のヒストグラム')
axes[1].set_xlabel('画素値')
axes[1].set_ylabel('画素数')
axes[1].legend()

# 白い領域が背景と同化しないよう、枠線を残して表示する
axes[2].imshow(binary_otsu, cmap='gray')
axes[2].set_title(f'大津の二値化 (しきい値 = {thresh})')
axes[2].set_xticks([])
axes[2].set_yticks([])

plt.tight_layout()
plt.show()
Output
大津の二値化で求めたしきい値: 139
二値化後のユニーク値: [  0 255]

10-7

暗い領域と明るい領域に対応する 2 つの山の谷付近(139)に、しきい値が自動的に定められていることがわかります。大津の二値化は、このように明るさの分布が双峰性である場合に特に効果的です。

しきい値処理のポイント

しきい値の選び方

  • 固定しきい値:照明が均一で、対象物と背景のコントラストが明確な場合
  • 大津の二値化:最適なしきい値を自動で決定したい場合

注意点

  • しきい値処理の前に、ノイズ除去(ガウシアンフィルタなど)を適用すると効果的
  • カラー画像の場合は、グレースケールに変換してから処理する

Playground:しきい値処理

しきい値処理 の Playground では、しきい値スライダーを動かすと二値化の結果と輝度ヒストグラム上の赤い線が連動します。ヒストグラムの 2 つの山のあいだの谷にしきい値を置けたときにうまく分かれること、「大津の二値化」ボタンがその谷を自動で探すことを確認できます。サンプルを「照明ムラのある文字」に変えると、1 つのしきい値では画像全体に対応できない限界も体験できます。

10.3 フィルタリング

画像のノイズを除去するために、フィルタリング(平滑化)を行います。機械学習の前処理としても重要です。

10.3.1 ガウシアンフィルタ

ガウシアンフィルタ は、ガウス関数(正規分布の形状)に基づく重み付き平均を計算するフィルタです。周囲の画素値を滑らかに平均化することで、ノイズを除去する効果があります。2 次元ガウス関数は次のように定義されます。

\[ G(x, y) = \frac{1}{2\pi\sigma^2} \exp\left(-\frac{x^2 + y^2}{2\sigma^2}\right) \]

ここで \(\sigma\) は標準偏差であり、フィルタの強さを制御するパラメータです。\(\sigma\) が大きいほど平滑化が強くなりますが、画像のエッジ(輪郭)もぼやけてしまいます。

ノイズ除去は、それ自体が目的というより、後続の処理の精度を上げるための前処理として重要です。たとえばノイズの多い画像をそのまま二値化するとノイズまで白黒の点として残ってしまいますが、先にガウシアンフィルタでノイズを抑えてから二値化すると、対象の形をきれいに取り出せます。

scikit-image の gaussian() 関数を使ってガウシアンフィルタを適用できます。ここでは、キャラクター「はんぺん」の画像を題材に、画像にノイズを加えてから除去してみましょう。この画像も透明背景の RGBA 画像なので、10.1.6 項のちくわの画像と同様に白背景に合成して読み込んだのち、グレースケールに変換して使います。また、画素値を 255 で割って 0〜1 の浮動小数点数に変換(正規化)し、標準偏差 0.3 のノイズを加えています。

import numpy as np
from PIL import Image
from urllib.request import urlretrieve
from skimage.filters import gaussian

# サンプル画像のダウンロード
url = "https://ml.kano.ac/chapters/data/hanpen.png"
urlretrieve(url, "hanpen.png")

# RGBA 画像を白背景に合成してからグレースケール化(0〜1に正規化)
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

# ガウスノイズを追加
rng = np.random.default_rng(42)
noise = rng.normal(0, 0.3, img_clean.shape)
img_noisy = np.clip(img_clean + noise, 0, 1)

# ガウシアンフィルタでノイズ除去
img_filtered_s1 = gaussian(img_noisy, sigma=1)
img_filtered_s2 = gaussian(img_noisy, sigma=2)

print(f"ノイズ画像 - 平均: {img_noisy.mean():.4f}", end=", ")
print(f"標準偏差: {img_noisy.std():.4f}")
print(f"sigma=1   - 平均: {img_filtered_s1.mean():.4f}", end=", ")
print(f"標準偏差: {img_filtered_s1.std():.4f}")
print(f"sigma=2   - 平均: {img_filtered_s2.mean():.4f}", end=", ")
print(f"標準偏差: {img_filtered_s2.std():.4f}")
Output
ノイズ画像 - 平均: 0.7167, 標準偏差: 0.3329
sigma=1   - 平均: 0.7167, 標準偏差: 0.2553
sigma=2   - 平均: 0.7168, 標準偏差: 0.2272

\(\sigma\) の値を大きくすると、画素値のばらつき(標準偏差)が小さくなり、画像がより滑らかになります。一方、フィルタ適用前後で平均値はほぼ変わっていません。

import matplotlib.pyplot as plt

fig, axes = plt.subplots(1, 4, figsize=(12, 3.4))

axes[0].imshow(img_clean, cmap='gray', vmin=0, vmax=1)
axes[0].set_title('元画像')

axes[1].imshow(img_noisy, cmap='gray', vmin=0, vmax=1)
axes[1].set_title('ノイズあり')

axes[2].imshow(img_filtered_s1, cmap='gray', vmin=0, vmax=1)
axes[2].set_title('ぼかし (sigma=1)')

axes[3].imshow(img_filtered_s2, cmap='gray', vmin=0, vmax=1)
axes[3].set_title('ぼかし (sigma=2)')

for ax in axes:
    ax.axis('off')

plt.tight_layout()
plt.show()

10-8

\(\sigma=1\) ではノイズがかなり除去され、輪郭もはっきり残っています。\(\sigma=2\) ではさらに滑らかになりますが、そのぶん輪郭もぼやけています。ノイズ除去の強さとエッジの保存はトレードオフの関係にあることがわかります。

sigma パラメータ

sigma はフィルタの強さを表し、大きいほど強くぼかします。

  • sigma=1:弱いぼかし(ノイズ除去とエッジ保持のバランスが良い)
  • sigma=3:中程度のぼかし
  • sigma=5:強いぼかし(輪郭も大きくぼやける)

なお skimage.filters.gaussian() 関数は 0〜1 に正規化した float 画像を前提とします。0〜255 の uint8 画像を渡すと内部で自動的に変換されます。

Playground:画像フィルタ

画像フィルタ の Playground では、フィルタの正体である 畳み込み(3×3 のカーネルを画像の各位置に重ね、9 画素との積和を取る計算)を、カーネルの中身を見ながら試せます。平均値・ガウシアン・シャープ化・エッジ強調などを切り替えると、同じ仕組みでまったく違う効果が得られることが分かります。この演算は第 11 章・第 14 章で学ぶ CNN の中身そのものです。

10.4 エッジ検出

10.4.1 エッジとは

エッジ(Edge) は、画像内で明るさが急激に変化する箇所です。物体の輪郭や境界を表現します。

graph LR
    A("エッジ検出") --> B("明るさの変化<br/>を検出")
    B --> C("物体の輪郭<br/>を抽出")
    C --> D("形状認識<br/>物体検出")

    classDef proc fill:#D9F0EA,stroke:#009E73,stroke-width:2px,color:#000
    classDef model fill:#F7EBF2,stroke:#CC79A7,stroke-width:2px,color:#000
    classDef out fill:#F5ECE2,stroke:#BF8040,stroke-width:2px,color:#000
    classDef neu fill:#F2F2F2,stroke:#8C8C8C,stroke-width:2px,color:#000
    class A neu
    class B proc
    class C model
    class D out

エッジが現れる場所

  • 物体の輪郭
  • 表面の境界
  • 影の境界
  • テクスチャの変化

10.4.2 Sobel フィルタ

Sobel フィルタ は、最も基本的なエッジ検出手法の一つです。画像の水平方向および垂直方向の輝度勾配を計算してエッジを検出します。以下の \(3 \times 3\) のカーネル行列を用いて、画像との畳み込み演算(カーネルを画像上で少しずつスライドさせながら積和演算を繰り返す処理)を行い、各画素のまわりの輝度の変化を捉えます。

Sobel フィルタのカーネル

水平方向(x 方向):

\[ G_x = \begin{bmatrix} -1 & 0 & +1 \\ -2 & 0 & +2 \\ -1 & 0 & +1 \end{bmatrix} \]

垂直方向(y 方向):

\[ G_y = \begin{bmatrix} -1 & -2 & -1 \\ 0 & 0 & 0 \\ +1 & +2 & +1 \end{bmatrix} \]

これらのカーネルを適用して得られる、ある画素での \(x\) 方向・\(y\) 方向の勾配値をそれぞれ \(g_x\)、\(g_y\) とすると、エッジの強度(勾配の大きさ)\(G\) は次の式で計算されます。

\[ G = \sqrt{g_x^2 + g_y^2} \]

ここからは OpenCV を使用します。OpenCV は、エッジ検出をはじめとする高速な画像処理機能を幅広く備えたライブラリです(10.1.3 項)。Sobel フィルタの適用には cv2.Sobel() 関数を使用します。

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

# サンプル画像(scikit-image 付属のカメラマン画像、グレースケール)
from skimage.data import camera
img = camera()

# Sobelフィルタの適用
sobel_x = cv2.Sobel(img, cv2.CV_64F, 1, 0, ksize=3)  # x方向
sobel_y = cv2.Sobel(img, cv2.CV_64F, 0, 1, ksize=3)  # y方向

# エッジ強度の計算
sobel_mag = np.sqrt(sobel_x**2 + sobel_y**2)
sobel_mag = np.uint8(sobel_mag / sobel_mag.max() * 255)

# 結果の表示
fig, axes = plt.subplots(1, 4, figsize=(12, 3.4))

axes[0].imshow(img, cmap='gray')
axes[0].set_title('元画像')

axes[1].imshow(np.abs(sobel_x), cmap='gray')
axes[1].set_title('Sobel X')

axes[2].imshow(np.abs(sobel_y), cmap='gray')
axes[2].set_title('Sobel Y')

axes[3].imshow(sobel_mag, cmap='gray')
axes[3].set_title('エッジ強度')

for ax in axes:
    ax.axis('off')

plt.tight_layout()
plt.show()

10-9

Sobel X では縦方向のエッジ、Sobel Y では横方向のエッジが強調され、両者を組み合わせたエッジ強度では、向きによらず輪郭全体が浮かび上がっていることがわかります。

cv2.CV_64F とは

cv2.Sobel() 関数の第 2 引数 cv2.CV_64F は出力のデータ型の指定です。勾配は負の値をとり得るため、符号付きの 64bit 浮動小数点型を指定しています。uint8 を指定すると負の勾配が 0 に切り捨てられ、片側のエッジが失われてしまいます。

10.4.3 Canny エッジ検出

Canny エッジ検出 は、Sobel フィルタによる勾配計算を一段階として含む、多段階のエッジ検出アルゴリズムです。ノイズに強く、細いエッジを検出できます。

Canny のアルゴリズム:

  1. ガウシアンフィルタによるノイズ除去
  2. Sobel フィルタによる勾配の計算
  3. 非極大値抑制(Non-Maximum Suppression)
  4. ダブルしきい値処理によるエッジの選別
  5. ヒステリシスしきい値によるエッジの連結

なお、OpenCV の cv2.Canny() 関数が行うのは手順 2〜5 で、手順 1 の平滑化は含まれません。そのため下のコードでは、cv2.Canny() 関数を呼ぶ前に cv2.GaussianBlur() 関数で平滑化しています。

OpenCV では cv2.Canny() 関数を使用します。2 つのしきい値(threshold1 と threshold2)を指定し、勾配の大きさが threshold2 を超える画素は強いエッジとみなされ、threshold1 と threshold2 の間の画素(弱いエッジ)は、弱いエッジどうしをたどって強いエッジにつながる場合のみエッジとして採用されます。

ここでは、10.3.1 項でも使った「はんぺん」の画像に適用してみましょう。なお、cv2.Canny() 関数の入力は 0〜255 の uint8 型画像なので、10.3.1 項と異なり 255 で割ることによる正規化は行いません。

import cv2
import numpy as np
from PIL import Image
import matplotlib.pyplot as plt

# サンプル画像(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

# ガウシアン平滑化でノイズを抑えてからCannyエッジ検出を適用
img_blur = cv2.GaussianBlur(img, (5, 5), 0)
edges = cv2.Canny(img_blur, threshold1=50, threshold2=150)

# 結果の表示
fig, axes = plt.subplots(1, 2, figsize=(10, 4))

axes[0].imshow(img, cmap='gray')
axes[0].set_title('元画像')

axes[1].imshow(edges, cmap='gray')
axes[1].set_title('Cannyエッジ')

for ax in axes:
    ax.axis('off')

plt.tight_layout()
plt.show()

10-10

キャラクターの輪郭や顔のパーツが、細い線のエッジとして抽出されています。輪郭線そのものに幅があるため、線の両側がそれぞれエッジとして検出されている点にも注目してください。

2 つのしきい値の選び方

  • 低いしきい値(例:10, 50): 細かいエッジまで検出(ノイズも拾いやすい)
  • 高いしきい値(例:100, 200): 主要なエッジのみ検出(ノイズに強い)

一般に threshold2 は threshold1 の 2〜3 倍程度に設定することが多いです。

10.4.4 Prewitt フィルタと Scharr フィルタ

Sobel 以外にも、エッジ検出のためのフィルタがいくつかあります。

Prewitt フィルタには filters.prewitt() 関数を、Scharr フィルタには filters.scharr() 関数を使用します。

Prewitt フィルタ:

\[ G_x = \begin{bmatrix} -1 & 0 & +1 \\ -1 & 0 & +1 \\ -1 & 0 & +1 \end{bmatrix} \]

Scharr フィルタ:

\[ G_x = \begin{bmatrix} -3 & 0 & +3 \\ -10 & 0 & +10 \\ -3 & 0 & +3 \end{bmatrix} \]
from skimage import data, filters
import matplotlib.pyplot as plt

# サンプル画像の読み込み
img = data.camera()

# 各種エッジ検出フィルタ
sobel = filters.sobel(img)
prewitt = filters.prewitt(img)
scharr = filters.scharr(img)

# 表示
fig, axes = plt.subplots(2, 2, figsize=(8, 8))

axes[0, 0].imshow(img, cmap='gray')
axes[0, 0].set_title('元画像')
axes[0, 0].axis('off')

axes[0, 1].imshow(sobel, cmap='gray')
axes[0, 1].set_title('Sobel')
axes[0, 1].axis('off')

axes[1, 0].imshow(prewitt, cmap='gray')
axes[1, 0].set_title('Prewitt')
axes[1, 0].axis('off')

axes[1, 1].imshow(scharr, cmap='gray')
axes[1, 1].set_title('Scharr')
axes[1, 1].axis('off')

plt.tight_layout()
plt.show()

10-11

Playground:エッジ検出

エッジ検出 の Playground では、Sobel・Prewitt・ラプラシアン・Canny を切り替え、しきい値を動かしながら結果を比べられます。x 方向の勾配 \(G_x\)(縦線に反応)と y 方向の勾配 \(G_y\)(横線に反応)が並べて表示されるので、この 2 つを合成してエッジ強度が求まる流れを目で追えます。ノイズの量を増やすと Sobel はザラつくのに Canny は耐えます。その理由も確かめてみましょう。

10.4.5 コーナー検出

コーナー(Corner) は、2 つ以上のエッジが交わる点です。画像内で特徴的な点として利用されます。

graph LR
    A("コーナー検出") --> B("エッジの交点<br/>を検出")
    B --> C("特徴点として<br/>利用")
    C --> D("物体追跡<br/>画像マッチング")

    classDef proc fill:#D9F0EA,stroke:#009E73,stroke-width:2px,color:#000
    classDef model fill:#F7EBF2,stroke:#CC79A7,stroke-width:2px,color:#000
    classDef out fill:#F5ECE2,stroke:#BF8040,stroke-width:2px,color:#000
    classDef neu fill:#F2F2F2,stroke:#8C8C8C,stroke-width:2px,color:#000
    class A neu
    class B proc
    class C model
    class D out

コーナーの特徴

  • 位置が安定している
  • 回転や拡大縮小に強い
  • 画像マッチングに有用

Harris コーナー検出

Harris コーナー検出 は、最も有名なコーナー検出アルゴリズムの一つです。

コーナー検出には feature.corner_harris() 関数を、検出されたコーナーの座標を取得するには feature.corner_peaks() 関数を使用します。

from skimage import data, feature, color
import matplotlib.pyplot as plt

# サンプル画像の読み込み
img = data.checkerboard()

# Harrisコーナー検出
coords = feature.corner_harris(img)
coords_binary = coords > 0.01 * coords.max()

# コーナーの座標を取得
corner_coords = feature.corner_peaks(coords, min_distance=10)

# 表示
fig, axes = plt.subplots(1, 3, figsize=(13, 5))

axes[0].imshow(img, cmap='gray')
axes[0].set_title('元画像')
axes[0].axis('off')

axes[1].imshow(coords, cmap='gray')
axes[1].set_title('Harris応答')
axes[1].axis('off')

axes[2].imshow(img, cmap='gray')
axes[2].plot(corner_coords[:, 1], corner_coords[:, 0], 'r.', markersize=10)
axes[2].set_title(f'検出されたコーナー({len(corner_coords)}個)')
axes[2].axis('off')

plt.tight_layout()
plt.show()

10-12

FAST(Features from Accelerated Segment Test)

FAST は、高速なコーナー検出アルゴリズムです。リアルタイム処理に適しています。

FAST 特徴点の検出には feature.corner_fast() 関数を使用します。

from skimage import data, feature
import matplotlib.pyplot as plt

# サンプル画像の読み込み
img = data.camera()

# FAST特徴点検出
fast = feature.corner_fast(img, threshold=0.15)
keypoints = feature.corner_peaks(fast)

# 表示
plt.figure(figsize=(8, 8))
plt.imshow(img, cmap='gray')
plt.plot(keypoints[:, 1], keypoints[:, 0], 'r.', markersize=8)
plt.title(f'FAST特徴点({len(keypoints)}個)')
plt.axis('off')
plt.tight_layout()
plt.show()

10-13

コーナー検出の使い分け

  • Harris: 高精度で安定したコーナー検出、画像マッチングに適する
  • FAST: 高速処理が必要な場合(リアルタイムアプリケーション)

Playground:コーナー検出

コーナー検出 の Playground では、Harris のコーナー応答 \(R\) に対するしきい値を動かすと、検出される角の数が変わります。図形は背景とのコントラストを変えてあるため、薄い図形の角ほど応答が小さく、しきい値を上げると弱い角から順に消えていきます。理論上の角の数と比べながら、感度パラメータ \(k\) の効果も試してみましょう。

10.5 HOG 特徴量

特徴抽出 は、画像から重要な情報(エッジ、コーナー、テクスチャなど)を取り出す処理です。これらの特徴は、物体認識や画像分類などの機械学習タスクで利用されます。この節では、特徴抽出の考え方を整理したうえで、ヒストグラム特徴と HOG(Histogram of Oriented Gradients)などの古典的手法を学びます。

10.5.1 特徴抽出の目的

特徴抽出(Feature Extraction) は、生の画像データから、機械学習モデルに有用な情報を抽出する処理です。

graph LR
    A("生の画像<br/>大量の画素データ") --> B("特徴抽出")
    B --> C("特徴ベクトル<br/>少数の重要な情報")
    C --> D("機械学習<br/>モデル")
    D --> E("予測")

    classDef data fill:#D9EAF3,stroke:#0072B2,stroke-width:2px,color:#000
    classDef proc fill:#D9F0EA,stroke:#009E73,stroke-width:2px,color:#000
    classDef model fill:#F7EBF2,stroke:#CC79A7,stroke-width:2px,color:#000
    classDef out fill:#F5ECE2,stroke:#BF8040,stroke-width:2px,color:#000
    class A,C data
    class B proc
    class D model
    class E out

特徴抽出の利点

  • 次元削減: 大量の画素データを少数の特徴量に圧縮
  • 不変性: 照明や位置の変化に強い
  • 解釈性: どんな特徴を使っているか理解しやすい

10.5.2 古典的手法とディープラーニング

graph TB
    A("特徴抽出の手法") --> B("古典的手法<br/>手動で設計")
    A --> C("ディープラーニング<br/>自動で学習")

    B --> B1("エッジ検出<br/>コーナー検出<br/>HOG, SIFT")
    C --> C1("CNN<br/>自動的に特徴を学習")

    B1 --> D("明示的で解釈しやすい")
    C1 --> E("高精度だが<br/>ブラックボックス")

    classDef data fill:#D9EAF3,stroke:#0072B2,stroke-width:2px,color:#000
    classDef proc fill:#D9F0EA,stroke:#009E73,stroke-width:2px,color:#000
    classDef neu fill:#F2F2F2,stroke:#8C8C8C,stroke-width:2px,color:#000
    class A neu
    class B,B1,D data
    class C,C1,E proc

古典的手法

  • 人間が設計した特徴(エッジ、コーナーなど)
  • 解釈しやすい
  • 計算コストが低い

ディープラーニング

  • データから自動的に特徴を学習
  • 高精度
  • 大量のデータと計算資源が必要

ディープラーニング(CNN)については、次章で紹介します。

10.5.3 ヒストグラム特徴

色ヒストグラム

色ヒストグラム は、画像内の色の分布を表現する特徴量です。画像全体の色の傾向を捉えます。

元画像と RGB ヒストグラム

カラー画像から RGB 3 チャンネルそれぞれのヒストグラムを描き、画像の色の傾向を 1 つのグラフで重ねて見るには、以下のようにします。img[:, :, i] は i 番目のチャンネル(R=0, G=1, B=2)を 2 次元配列として取り出し、.ravel() メソッドでそれを 1 次元に平坦化したうえで numpy.histogram() 関数に渡します。bins=256 で 0〜255 の画素値を 256 個のビンに分け、各ビンに含まれる画素数を数えます。

from skimage import data
import matplotlib.pyplot as plt
import numpy as np

# カラー画像の読み込み(形状は (高さ, 幅, 3) の 3 次元配列)
img = data.astronaut()

# 元画像と RGB ヒストグラムを 1 行 2 列で表示
fig, axes = plt.subplots(1, 2, figsize=(12, 5))

axes[0].imshow(img)
axes[0].set_title('元画像')
axes[0].axis('off')

# RGB 各チャンネルのヒストグラムを同じグラフに重ね描き
colors = ('red', 'green', 'blue')
for i, color in enumerate(colors):
    # img[:, :, i] で i 番目のチャンネル、.ravel() で 1 次元化
    hist, bins = np.histogram(img[:, :, i].ravel(), bins=256, range=(0, 256))
    axes[1].plot(bins[:-1], hist, color=color, alpha=0.7, label=color.upper())

axes[1].set_title('RGB ヒストグラム')
axes[1].set_xlabel('画素値')
axes[1].set_ylabel('頻度')
axes[1].legend()
axes[1].grid(alpha=0.3)

plt.tight_layout()
plt.show()

10-14

各チャンネルの表示

各チャンネルが画像のどこに強く出ているかを視覚的に確認するために、1 チャンネルだけを残して他をゼロに塗り潰したカラー画像を作ります。numpy.zeros_like(img) で元画像と同じ形状・データ型のゼロ配列を作り、目的のチャンネルにだけ元画像の値をコピーすることで、そのチャンネルの分布を該当する色で表現できます。

# R / G / B を 1 行 3 列で表示
fig, axes = plt.subplots(1, 3, figsize=(13, 4))

for i, color in enumerate(colors):
    # 元画像と同じ形状のゼロ配列を作り、i 番目のチャンネルだけを元から
    # コピー → そのチャンネルの強度が R/G/B の色で見える
    channel = np.zeros_like(img)
    channel[:, :, i] = img[:, :, i]

    axes[i].imshow(channel)
    axes[i].set_title(f'{color.upper()} チャンネル')
    axes[i].axis('off')

plt.tight_layout()
plt.show()

10-15

グレースケールヒストグラム

グレースケール画像のヒストグラムは、明るさの分布を表します。

from skimage import data
import matplotlib.pyplot as plt
import numpy as np

# 異なる画像のヒストグラムを比較
img1 = data.camera()
img2 = data.coins()

fig, axes = plt.subplots(2, 2, figsize=(12, 10))

# 画像1
axes[0, 0].imshow(img1, cmap='gray')
axes[0, 0].set_title('Camera')
axes[0, 0].axis('off')

axes[1, 0].hist(img1.ravel(), bins=256, range=(0, 256), color='blue', alpha=0.7)
axes[1, 0].set_title('Camera のヒストグラム')
axes[1, 0].set_xlabel('画素値')
axes[1, 0].set_ylabel('頻度')
axes[1, 0].grid(alpha=0.3)

# 画像2
axes[0, 1].imshow(img2, cmap='gray')
axes[0, 1].set_title('Coins')
axes[0, 1].axis('off')

axes[1, 1].hist(img2.ravel(), bins=256, range=(0, 256), color='red', alpha=0.7)
axes[1, 1].set_title('Coins のヒストグラム')
axes[1, 1].set_xlabel('画素値')
axes[1, 1].set_ylabel('頻度')
axes[1, 1].grid(alpha=0.3)

plt.tight_layout()
plt.show()

10-16

10.5.4 HOG とは

HOG(Histogram of Oriented Gradients) は、画像の局所的な勾配の方向をヒストグラムとして表現する特徴量です。各セルでは、各画素の勾配の大きさを重みとして方向ごとに集計します。物体検出(特に人物検出)で広く使われています。

graph LR
    A("画像") --> B("勾配の計算")
    B --> C("セルに分割")
    C --> D("各セルで勾配の大きさを<br/>重みとして方向を集計")
    D --> E("HOG特徴量<br/>ベクトル")

    classDef data fill:#D9EAF3,stroke:#0072B2,stroke-width:2px,color:#000
    classDef proc fill:#D9F0EA,stroke:#009E73,stroke-width:2px,color:#000
    classDef out fill:#F5ECE2,stroke:#BF8040,stroke-width:2px,color:#000
    class A data
    class B,C,D proc
    class E out

HOG の特徴

  • 局所的な形状を捉える
  • ブロック単位で勾配を正規化するため、画像の明るさの変化の影響を受けにくい
  • 人物検出で高い性能

10.5.5 HOG の実装

HOG 特徴量の抽出には scikit-image の feature.hog() 関数を使用します。この関数は、画像から勾配方向のヒストグラムを計算し、特徴ベクトルとして出力します。

import numpy as np
from skimage import feature, exposure
import matplotlib.pyplot as plt
from PIL import Image

# サンプル画像(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'))

# HOG特徴量の抽出
fd, hog_image = feature.hog(img, orientations=9, pixels_per_cell=(8, 8),
 cells_per_block=(2, 2), visualize=True)

# 可視化のためコントラストを調整
hog_image_rescaled = exposure.rescale_intensity(hog_image, in_range=(0, 10))

# 表示
fig, axes = plt.subplots(1, 2, figsize=(9, 5))

axes[0].imshow(img, cmap='gray')
axes[0].set_title('元画像')
axes[0].axis('off')

axes[1].imshow(hog_image_rescaled, cmap='gray')
axes[1].set_title('HOG 特徴量(可視化)')
axes[1].axis('off')

plt.tight_layout()
plt.show()

print(f"HOG特徴量の次元: {len(fd)}")

10-17

Output
HOG特徴量の次元: 15876

可視化画像を見ると、キャラクターの輪郭に沿って、各セルのエッジの向き(勾配の方向と直交する向き)が短い線分として表現されていることがわかります。

HOG 特徴量の次元

HOG 特徴量は画像のサイズやパラメータによって次元数が変わります。

  • orientations: 勾配方向のビン数
  • pixels_per_cell: セルのサイズ
  • cells_per_block: ブロックのサイズ

例: 64×128の画像で orientations=9, pixels_per_cell=(8,8), cells_per_block=(2,2) の場合、ブロック数は縦 128÷8−2+1=15、横 64÷8−2+1=7 なので、15×7×2×2×9=3,780 次元の特徴ベクトルになります。

Playground:HOG 特徴量

HOG特徴量 の Playground では、セルごとの勾配方向のヒストグラムが線分として描かれ、線分が輪郭をなぞって物体の形が「向きの集まり」だけで表現される様子が見えます。セルの細かさ(pixels_per_cell に相当)と方向ビン数(orientations に相当)を動かすと特徴ベクトルの次元数が変わるので、次項のパラメータの話と合わせて試してみましょう。セルをクリックすると、そのセルの方向ヒストグラムを確認できます。

10.5.6 HOG のパラメータ

主要なパラメータ

  • orientations: 勾配方向のビン数(通常 9)
  • pixels_per_cell: セルのサイズ(通常 8×8)
  • cells_per_block: ブロックのサイズ(通常 2×2 セル)
import numpy as np
from skimage import feature, exposure
import matplotlib.pyplot as plt
from PIL import Image

# サンプル画像(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_gray = np.array(background.convert('L'))

# 異なるパラメータでHOGを抽出
params = [
    {'orientations': 9, 'pixels_per_cell': (8, 8)},
    {'orientations': 9, 'pixels_per_cell': (16, 16)},
    {'orientations': 3, 'pixels_per_cell': (8, 8)},
]

fig, axes = plt.subplots(2, 2, figsize=(8, 8))

axes[0, 0].imshow(img_gray, cmap='gray')
axes[0, 0].set_title('元画像')
axes[0, 0].axis('off')

for idx, param in enumerate(params):
    fd, hog_image = feature.hog(img_gray, cells_per_block=(2, 2), visualize=True, **param)
    hog_image_rescaled = exposure.rescale_intensity(hog_image, in_range=(0, 10))

    row, col = (idx + 1) // 2, (idx + 1) % 2
    axes[row, col].imshow(hog_image_rescaled, cmap='gray')
    axes[row, col].set_title(f"orientations={param['orientations']}, " f"cell={param['pixels_per_cell']}")
    axes[row, col].axis('off')

plt.tight_layout()
plt.show()

10-18

10.5.7 HOG を使った分類

HOG 特徴量と機械学習を組み合わせることで、画像分類のタスクを実装することができます。以下の画像は、縞模様の方向の分類タスクの実装例です。画像分類の実践的な演習は、次章で行います。

10-19

HOG + 機械学習による分類

HOG 特徴量とロジスティック回帰などの機械学習アルゴリズムを組み合わせることで、画像分類を行うことができます。この組み合わせは、ディープラーニングが普及する前に広く使われていました。

利点

  • 解釈性が高い(どの特徴を使っているか分かる)
  • 少ないデータでも学習可能
  • 計算コストが低い
  • シンプルで理解しやすい

欠点

  • ディープラーニングに比べて精度が低い
  • 特徴設計が必要(人間が設計する必要がある)

10.5.8 その他の特徴抽出手法

LBP(Local Binary Pattern)

LBP(Local Binary Pattern) は、テクスチャを表現する特徴量の一つです。各画素の周辺画素との明暗関係を 2 値パターンで表現します。

LBP の特徴

  • テクスチャ(模様)の表現に優れる
  • 照明変化に強い
  • 計算が高速
  • 顔認識やテクスチャ分類で利用される

LBP 特徴量の抽出には feature.local_binary_pattern() 関数を使用します。詳細は次のセクションの比較コードで確認できます。

10.5.9 画像特徴量の比較

異なる特徴抽出手法を比較してみましょう。

from skimage import data, feature, filters
import matplotlib.pyplot as plt
import numpy as np

# サンプル画像の読み込み
img = data.camera()

# 各種特徴抽出
edges_sobel = filters.sobel(img)
edges_canny = feature.canny(img, sigma=2)
corners = feature.corner_harris(img)
hog_fd, hog_image = feature.hog(img, visualize=True)
lbp = feature.local_binary_pattern(img, 8, 1, method='uniform')

# 表示
fig, axes = plt.subplots(2, 3, figsize=(15, 10))

axes[0, 0].imshow(img, cmap='gray')
axes[0, 0].set_title('元画像')
axes[0, 0].axis('off')

axes[0, 1].imshow(edges_sobel, cmap='gray')
axes[0, 1].set_title('エッジ(Sobel)')
axes[0, 1].axis('off')

axes[0, 2].imshow(edges_canny, cmap='gray')
axes[0, 2].set_title('エッジ(Canny)')
axes[0, 2].axis('off')

axes[1, 0].imshow(corners, cmap='gray')
axes[1, 0].set_title('コーナー(Harris)')
axes[1, 0].axis('off')

axes[1, 1].imshow(hog_image, cmap='gray')
axes[1, 1].set_title('HOG特徴量')
axes[1, 1].axis('off')

axes[1, 2].imshow(lbp, cmap='gray')
axes[1, 2].set_title('LBP特徴量')
axes[1, 2].axis('off')

plt.tight_layout()
plt.show()

10-20

特徴抽出手法の使い分け

  • エッジ検出(Sobel, Canny): 物体の輪郭抽出、形状認識
  • コーナー検出(Harris, FAST): 特徴点マッチング、物体追跡
  • HOG: 物体検出(特に人物検出)
  • LBP: テクスチャ認識、顔認識
  • 色ヒストグラム: 色に基づく画像検索、画像分類

10.6 演習問題

演習 10-1: グレースケール変換の比較

演習 10-1

10.3.1 項でダウンロードするキャラクター「はんぺん」の画像(hanpen.png)を使って、グレースケール変換の違いを確認してください。はんぺんの画像の代わりに、scikit-image 付属の coffee 画像(data.coffee() 関数)でも実施できます。

タスク:

  1. hanpen.png をダウンロードし、白背景に合成して RGB 画像として読み込み、形状を確認する(10.1.6 項と同じ手順)
  2. 単純平均法(mean(axis=2))でグレースケールに変換する
  3. 加重平均法(ITU-R BT.601 係数、np.dot() 関数)でグレースケールに変換する
  4. 元のカラー画像、単純平均法、加重平均法の 3 つを 1 行 3 列で並べて表示する
テンプレートコード
import numpy as np
import matplotlib.pyplot as plt
from PIL import Image
from urllib.request import urlretrieve

# 1. hanpen.png をダウンロードし、白背景に合成して RGB 画像として読み込む

# 2. 単純平均法でグレースケールに変換

# 3. 加重平均法(ITU-R BT.601)でグレースケールに変換

# 4. 元のカラー画像、単純平均法、加重平均法の 3 つを 1 行 3 列で並べて表示
解答例
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()
形状: (180, 180, 3)
2 つの方法の差の最大値: 7.3

考察:はんぺんの画像は白い体と茶色の輪郭線が中心で鮮やかな色が少ないため、2 つの方法の差は最大 7.3(255 段階中)にとどまり、見た目ではほとんど区別できません。R が強く B が弱い茶色の輪郭線では、加重平均法のほうがわずかに明るくなります。coffee 画像のような色の多い写真では、赤や青の部分でグレーの濃さがはっきり変わります。加重平均法は、緑に敏感で青に鈍い人間の目の感度に合わせた変換です。

演習 10-2: ノイズ除去とフィルタ強度

演習 10-2

10.3.1 項と同じ手順で「はんぺん」の画像にガウシアンノイズを追加し、skimage.filters.gaussian() 関数の sigma を 3 通り変えてノイズ除去の強度の違いを比較してください。scikit-image 付属の chelsea 画像(猫のカラー画像)でも実施できます(カラー画像には channel_axis=-1 を指定します)。

タスク:

  1. hanpen.png を白背景に合成してグレースケール化し、0〜1 に正規化する(10.3.1 項と同じ手順)
  2. ガウシアンノイズを追加する(平均 0、標準偏差 0.3、np.random.default_rng(42))
  3. gaussian() 関数を sigma=0.5, 2, 5 の 3 通りで適用する
  4. ノイズあり画像と 3 通りのフィルタ結果(合計 4 枚)を 1 行 4 列で並べて表示する
  5. sigma を大きくしたときに何が起こるか(ノイズの減り方・輪郭のぼやけ方)をコメントで考察する
テンプレートコード
import numpy as np
import matplotlib.pyplot as plt
from PIL import Image
from urllib.request import urlretrieve
from skimage.filters import gaussian

# 1. hanpen.png を白背景に合成してグレースケール化し、0〜1 に正規化

# 2. ガウシアンノイズを追加(平均 0、標準偏差 0.3、seed=42)

# 3. sigma=0.5, 2, 5 で gaussian を適用

# 4. ノイズあり + フィルタ 3 通りを 1 行 4 列で並べて表示

# 5. 考察(sigma を大きくしたときの影響)をコメントで記述
解答例
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()
ノイズ画像: 標準偏差 0.3329
sigma=0.5: 標準偏差 0.2909
sigma=2: 標準偏差 0.2272
sigma=5: 標準偏差 0.1799

考察:sigma=0.5 ではざらつきがかなり残ります。sigma=2 ではノイズがほぼ目立たず、輪郭のぼやけも少なく、この画像に合った設定です。sigma=5 ではノイズは消えますが、はんぺんの輪郭や顔のパーツも大きくぼやけます。標準偏差も 0.2909 → 0.2272 → 0.1799 と、sigma が大きいほど小さくなります。ノイズを減らすほど細部も失われるので、目的に合わせて sigma を選びます。

演習 10-3: しきい値処理

演習 10-3

「はんぺん」の画像をグレースケール(0〜255)で読み込み、固定しきい値と大津の二値化を比較してください。scikit-image 付属の coins 画像でも実施できます。

タスク:

  1. hanpen.png を白背景に合成してグレースケールで読み込む(10.4.3 項と同じ手順、0〜255 の uint8)
  2. 固定しきい値 T=64, 128, 192 の 3 通りで二値化し、元画像と並べて表示する
  3. しきい値によって抽出される領域がどのように変わるか観察する
  4. skimage.filters.threshold_otsu() 関数で大津の二値化のしきい値を求め、固定しきい値の結果と比較する
テンプレートコード
import numpy as np
import matplotlib.pyplot as plt
from PIL import Image
from skimage.filters import threshold_otsu

# 1. hanpen.png を白背景に合成してグレースケールで読み込む(0〜255 の uint8)

# 2. 固定しきい値 T=64, 128, 192 で二値化し、元画像と並べて表示

# 3. しきい値による違いを観察

# 4. 大津の二値化のしきい値を求め、固定しきい値と比較
解答例
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: 白画素の割合 89.8%
T=128: 白画素の割合 74.5%
T=192: 白画素の割合 73.7%
大津の二値化によるしきい値: 153

考察:\(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: エッジ検出

演習 10-4

「はんぺん」の画像に対して、4 種類のエッジ検出手法(Sobel・Prewitt・Scharr・Canny)の結果を比較してください。scikit-image 付属の camera 画像でも実施できます。

タスク:

  1. hanpen.png を白背景に合成してグレースケールで読み込む(10.4.3 項と同じ手順)
  2. filters.sobel() 関数・filters.prewitt() 関数・filters.scharr() 関数・cv2.Canny() 関数(しきい値 (50, 150))の 4 種類を適用し、2×2 で並べて表示する
  3. Canny の 2 つのしきい値を (50, 150), (100, 200), (10, 50) と変えて適用し、検出されるエッジの量がどのように変わるか比較する
テンプレートコード
import cv2
import numpy as np
import matplotlib.pyplot as plt
from PIL import Image
from skimage import filters

# 1. hanpen.png を白背景に合成してグレースケールで読み込む

# 2. Sobel・Prewitt・Scharr・Canny(しきい値 (50, 150))を適用し、2×2 で表示

# 3. Canny のしきい値を (50, 150), (100, 200), (10, 50) と変えて比較
解答例
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 特徴量とパラメータ

演習 10-5

「はんぺん」の画像に対して HOG 特徴量を pixels_per_cell パラメータを 3 通りに変えて抽出し、可視化と特徴量の次元数を比較してください。scikit-image 付属の chelsea 画像(グレースケールに変換して使用)でも実施できます。

タスク:

  1. hanpen.png を白背景に合成してグレースケールで読み込む(10.5.5 項と同じ手順)
  2. pixels_per_cell を (8, 8), (16, 16), (32, 32) の 3 通りで HOG 特徴量を抽出する(orientations=9, cells_per_block=(2, 2), visualize=True)
  3. 元画像と 3 つの HOG 可視化結果を 2×2 で並べて表示する
  4. 各設定での特徴量の次元数を出力する
  5. セルを大きくすると何が変わるか(次元数・失われる情報)をコメントで考察する
テンプレートコード
import numpy as np
import matplotlib.pyplot as plt
from PIL import Image
from skimage.feature import hog

# 1. hanpen.png を白背景に合成してグレースケールで読み込む

# 2. pixels_per_cell を (8, 8), (16, 16), (32, 32) と変えて HOG を抽出

# 3. 元画像 + 3 通りの HOG 可視化を 2×2 で表示

# 4. 各設定での特徴量の次元数を出力

# 5. 考察(セルを大きくすると失われる情報)をコメントで記述
解答例
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: 文字画像の二値化

発展課題 1

scikit-image のサンプル画像(page)は、スキャンしたページの画像で、左側が暗く右側が明るいという明るさのムラがあります。この画像に対して、以下の 3 通りで二値化を行い、結果を比較してください。

  • (a) 大津の二値化
  • (b) ガウシアンフィルタ + 大津の二値化
  • (c) 適応的しきい値処理 cv2.adaptiveThreshold() 関数(使い方・パラメータは公式ドキュメントを参照)
テンプレートコード
import cv2
import numpy as np
import matplotlib.pyplot as plt
from skimage import data
from skimage.filters import gaussian, threshold_otsu

# 1. page 画像を読み込む

# 2-a. 大津の二値化のみ(threshold_otsu)

# 2-b. ガウシアンフィルタ(gaussian) + 大津の二値化

# 2-c. 適応的しきい値処理

# 3. 元画像と 3 通りの結果を 2×2 で並べて表示
解答例
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()
大津のしきい値: 157
平滑化後の大津のしきい値: 0.657

考察:page 画像は左側が暗く右側が明るいため、画像全体で 1 つのしきい値を使う大津の二値化では、左側が文字ごと黒くつぶれます。ガウシアンフィルタを先にかけてもノイズが減るだけで、明るさのムラは残ります。適応的しきい値処理は画素ごとに周囲 blockSize×blockSize の範囲でしきい値を決めるので、画像全体で文字が読めます。blockSize や C を変えて、結果がどう変わるか試してみてください。

発展課題 2: カラー画像の RGB ヒストグラム

発展課題 2

scikit-image のサンプル画像(coffee)の RGB 各チャンネルのヒストグラムを描画し、画像の色の傾向を読み取ってください。

タスク:

  1. coffee 画像を読み込む
  2. R・G・B 各チャンネルのヒストグラムを numpy.histogram() 関数で計算する
  3. 元画像と 3 色のヒストグラムを 1 行 2 列で並べて表示する
  4. 各チャンネルの 最頻値・中央値・平均値 を計算して出力する
  5. ヒストグラムと出力した統計量から coffee 画像の色の傾向を読み取り、コメントで考察する
テンプレートコード
from skimage import data
import matplotlib.pyplot as plt
import numpy as np

# 1. coffee 画像を読み込む

# 2. R・G・B 各チャンネルのヒストグラムを計算

# 3. 元画像と RGB ヒストグラムを 1 行 2 列で表示

# 4. 各チャンネルの最頻値・中央値・平均値を計算して出力

# 5. 考察(色の傾向)をコメントで記述
解答例
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, 中央値 176, 平均値 158.6
G: 最頻値 4, 中央値 82, 平均値 85.8
B: 最頻値 2, 中央値 37, 平均値 51.5

考察:R チャンネルは最頻値 196・平均値 158.6 と高い値に寄っていますが、G と B は低い値に集まっています(最頻値はそれぞれ 4 と 2)。R が強く G・B が弱い画素が多く、全体として赤茶色の画像だとわかります。実際、この写真は木のテーブルやコーヒーなど茶色の被写体が大部分です。色ヒストグラムを使うと、画像を見なくても色の傾向をつかめるので、画像検索や分類の特徴量にも使えます。

まとめ

本章では、画像処理の基礎と古典的な特徴抽出手法について学びました。

第 10 章のまとめ

画像データの基礎

  • デジタル画像は画素(ピクセル)の集まり
  • グレースケール(1チャンネル)とカラー画像(RGB: 3チャンネル)
  • Python では NumPy 配列として画像を扱う

ライブラリの役割分担

  • Pillow (PIL):画像の読み込み・保存・基本変換(Image.open() 関数 / Image.fromarray() 関数 / save() メソッド / convert() メソッド)
  • NumPy:画像データの配列操作
  • Matplotlib:画像の表示・可視化
  • scikit-image:高度な画像処理・フィルタリング(gaussian() 関数, threshold_otsu() 関数, hog() 関数)
  • OpenCV (cv2):高速な画像処理(cv2.Sobel() 関数, cv2.Canny() 関数, cv2.resize() 関数)
  • Pillow の size は (幅, 高さ)、NumPy の shape は (高さ, 幅, チャンネル数)
  • cv2.imread() 関数を使う場合は BGR 順序 のため、Matplotlib で表示する前に cv2.cvtColor() 関数で RGB に変換する

色空間

  • RGB:赤・緑・青の3原色で表現(ニューラルネットワークの標準入力)
  • グレースケール:明るさのみで表現(計算量削減、形状認識)

機械学習のための画像前処理

  • リサイズ:ニューラルネットワークの固定サイズ入力に対応(224×224など)
  • 正規化:0-255 → 0-1 の変換(学習を安定させる代表的な方法)
  • 標準化:平均0、標準偏差1への変換
  • データ拡張:左右反転、回転、明るさ調整で訓練データを増やす

しきい値処理

フィルタリング

  • ガウシアンフィルタ (skimage.filters.gaussian() 関数):ノイズ除去とエッジ保持のバランスが良い
  • sigma が大きいほど強くぼかす(ノイズ除去とエッジ保存はトレードオフ)

エッジ検出

  • Sobel フィルタ (cv2.Sobel() 関数): 基本的なエッジ検出、x・y 方向の勾配から強度を計算
  • Canny (cv2.Canny() 関数): 高度なエッジ検出、ノイズに強い、2 つのしきい値で制御
  • Prewitt, Scharr: Sobel の変種

コーナー検出

  • Harris: 古典的なコーナー検出
  • FAST: 高速なコーナー検出

特徴抽出の基礎

  • 生の画像データから有用な情報を抽出
  • 次元削減、不変性、解釈性の利点
  • 古典的手法とディープラーニングの違い

ヒストグラム特徴

  • 色ヒストグラム: 色の分布を表現
  • グレースケールヒストグラム: 明るさの分布

HOG(Histogram of Oriented Gradients)

  • 局所的な勾配の方向をヒストグラムで表現
  • 物体検出(特に人物検出)で広く使用
  • パラメータ調整が重要

その他の手法

  • LBP: テクスチャ認識、顔認識などに利用される

特徴抽出の意義

  • ディープラーニング以前の主要な画像認識手法
  • 解釈性が高く、少ないデータでも動作する
  • 現在でも特定の用途で有用

機械学習で最も重要なポイント

  1. 入力仕様に合わせて正規化する:本章の例では img / 255.0 で0-1に変換
  2. リサイズ:モデルに合わせたサイズに統一
  3. データ拡張:訓練データのみに適用
  4. グレースケール変換:計算量削減が必要な場合

次章では、画像分類の実践(MNIST、HOG + 機械学習、CNN)について学びます。