第19章 グラフィックス

Pythonは、画像処理からデータ可視化、ゲーム開発、3Dグラフィックス、さらには機械学習を活用した画像生成まで、グラフィックス関連の幅広い領域をカバーできる言語です。本章では、各分野で使用される主要なライブラリの特徴と実践的な使い方を解説します。

グラフィックライブラリの全体像

Pythonで利用できるグラフィックライブラリは用途によって大きく分類できます。プロジェクトの目的に応じて適切なライブラリを選択することが重要です。

分野 主要ライブラリ 用途
画像処理 Pillow, OpenCV 画像の読み込み・加工・保存
データ可視化 Matplotlib, Seaborn, Plotly グラフ・チャートの作成
GUI開発 Tkinter, PyQt, Kivy デスクトップアプリケーション
ゲーム開発 Pygame, Arcade 2Dゲーム・アニメーション
3Dグラフィックス PyOpenGL, Panda3D, VPython 3D描画・シミュレーション
コンピュータビジョン OpenCV, scikit-image 画像解析・物体検出
画像生成AI TensorFlow, PyTorch GAN・拡散モデルによる画像生成


Pillow(PIL Fork)による画像処理

Pillowの概要

PillowはPython Imaging Library(PIL)の後継ライブラリで、画像の読み込み、編集、保存、フォーマット変換など基本的な画像処理を行うための標準的なツールです。

pip install Pillow


基本操作

画像ファイルを開いて、サイズ確認・リサイズ・回転・切り抜き・保存といった基本的な操作を行う

from PIL import Image

# 画像を開く
image = Image.open('photo.jpg')

# 画像の基本情報を取得
print(f"フォーマット: {image.format}")
print(f"サイズ: {image.size}")  # (width, height)
print(f"モード: {image.mode}")  # RGB, RGBA, L(グレースケール)など

# 画像のリサイズ
resized = image.resize((800, 600))

# アスペクト比を維持したリサイズ
image.thumbnail((800, 800))  # 元画像を直接変更

# 画像の回転
rotated = image.rotate(45, expand=True)  # expand=Trueで画像が切れないようにする

# 画像の切り抜き(左, 上, 右, 下)
cropped = image.crop((100, 100, 400, 400))

# 画像の保存(フォーマットは拡張子から自動判定)
image.save('output.png')

# 品質を指定してJPEG保存
image.save('output.jpg', quality=85, optimize=True)


画像フィルタリングと加工

ぼかし、シャープ化、エッジ検出、明るさ・コントラスト調整など、画像に視覚的な効果を適用する

from PIL import Image, ImageFilter, ImageEnhance

image = Image.open('photo.jpg')

# ぼかし処理
blurred = image.filter(ImageFilter.BLUR)
gaussian_blur = image.filter(ImageFilter.GaussianBlur(radius=5))

# シャープ化
sharpened = image.filter(ImageFilter.SHARPEN)

# エッジ検出
edges = image.filter(ImageFilter.FIND_EDGES)

# エンボス効果
embossed = image.filter(ImageFilter.EMBOSS)

# コントラスト調整
enhancer = ImageEnhance.Contrast(image)
high_contrast = enhancer.enhance(1.5)  # 1.0が元の値

# 明るさ調整
brightness = ImageEnhance.Brightness(image)
brighter = brightness.enhance(1.2)

# 彩度調整
color = ImageEnhance.Color(image)
saturated = color.enhance(1.3)

# グレースケール変換
grayscale = image.convert('L')

# RGBAに変換(透過情報を追加)
rgba = image.convert('RGBA')


画像の合成とテキスト描画

白紙のキャンバスに図形(四角、円、線、多角形)を描き、テキストを追加し、複数画像を重ね合わせる

from PIL import Image, ImageDraw, ImageFont

# 新しい画像を作成(白背景)
canvas = Image.new('RGB', (800, 600), color='white')

# 描画オブジェクトを作成
draw = ImageDraw.Draw(canvas)

# 図形の描画
draw.rectangle([50, 50, 200, 150], fill='blue', outline='black', width=2)
draw.ellipse([250, 50, 400, 150], fill='red', outline='black', width=2)
draw.line([50, 200, 400, 200], fill='green', width=3)
draw.polygon([(500, 50), (600, 150), (400, 150)], fill='yellow', outline='black')

# テキストの描画
try:
    # システムフォントを使用
    font = ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', 36)
except OSError:
    font = ImageFont.load_default()

draw.text((50, 250), 'Hello, Pillow!', fill='black', font=font)

# 画像の合成(透過画像を重ねる)
background = Image.open('background.jpg')
overlay = Image.open('overlay.png').convert('RGBA')
background.paste(overlay, (100, 100), overlay)  # 第3引数がマスク

canvas.save('drawing.png')


画像のバッチ処理

フォルダ内の全JPEG画像を一括でリサイズして別フォルダに保存する自動化スクリプト

from PIL import Image
from pathlib import Path

def batch_resize_images(input_dir, output_dir, max_size=(800, 800)):
    """ディレクトリ内の全画像をリサイズ"""
    input_path = Path(input_dir)
    output_path = Path(output_dir)
    output_path.mkdir(parents=True, exist_ok=True)

    for img_file in input_path.glob('*.jpg'):
        with Image.open(img_file) as img:
            img.thumbnail(max_size, Image.Resampling.LANCZOS)
            output_file = output_path / img_file.name
            img.save(output_file, quality=85, optimize=True)
            print(f"処理完了: {img_file.name}")

batch_resize_images('input_images', 'output_images')


Matplotlibによるデータ可視化

Matplotlibの基本構造

Matplotlibは科学計算やデータ分析で広く使用される可視化ライブラリです。Figure(図全体)とAxes(個々のプロット領域)という2つの主要な概念を理解することが重要です。

pip install matplotlib numpy

正弦波のグラフを描画し、軸ラベル・タイトル・凡例・グリッドを設定する基本的な流れを示す

import matplotlib.pyplot as plt
import numpy as np

# Figure と Axes の明示的な作成
fig, ax = plt.subplots(figsize=(10, 6))

# データの準備
x = np.linspace(0, 10, 100)
y = np.sin(x)

# プロット
ax.plot(x, y, label='sin(x)', color='blue', linewidth=2)

# 装飾
ax.set_xlabel('X軸', fontsize=12)
ax.set_ylabel('Y軸', fontsize=12)
ax.set_title('正弦波のグラフ', fontsize=14)
ax.legend()
ax.grid(True, alpha=0.3)

plt.tight_layout()
plt.savefig('sine_wave.png', dpi=150)
plt.show()


様々なグラフの種類

折れ線、散布図、棒グラフ、ヒストグラム、円グラフ、箱ひげ図の6種類を1画面に並べて描画する

import matplotlib.pyplot as plt
import numpy as np

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

# 1. 折れ線グラフ
x = np.linspace(0, 10, 50)
axes[0, 0].plot(x, np.sin(x), 'b-', label='sin')
axes[0, 0].plot(x, np.cos(x), 'r--', label='cos')
axes[0, 0].set_title('折れ線グラフ')
axes[0, 0].legend()

# 2. 散布図
np.random.seed(42)
x = np.random.randn(100)
y = x + np.random.randn(100) * 0.5
colors = np.random.rand(100)
axes[0, 1].scatter(x, y, c=colors, cmap='viridis', alpha=0.7)
axes[0, 1].set_title('散布図')

# 3. 棒グラフ
categories = ['A', 'B', 'C', 'D', 'E']
values = [23, 45, 56, 78, 32]
axes[0, 2].bar(categories, values, color='steelblue', edgecolor='black')
axes[0, 2].set_title('棒グラフ')

# 4. ヒストグラム
data = np.random.randn(1000)
axes[1, 0].hist(data, bins=30, color='green', alpha=0.7, edgecolor='black')
axes[1, 0].set_title('ヒストグラム')

# 5. 円グラフ
sizes = [30, 25, 20, 15, 10]
labels = ['製品A', '製品B', '製品C', '製品D', 'その他']
axes[1, 1].pie(sizes, labels=labels, autopct='%1.1f%%', startangle=90)
axes[1, 1].set_title('円グラフ')

# 6. 箱ひげ図
data = [np.random.randn(100) + i for i in range(4)]
axes[1, 2].boxplot(data, labels=['Group1', 'Group2', 'Group3', 'Group4'])
axes[1, 2].set_title('箱ひげ図')

plt.tight_layout()
plt.savefig('various_charts.png', dpi=150)
plt.show()


サブプロットと複合グラフ

GridSpecを使って、大きさの異なる複数のグラフを1つの図に柔軟にレイアウトする

import matplotlib.pyplot as plt
import numpy as np

# グリッドレイアウトの活用
fig = plt.figure(figsize=(12, 8))

# GridSpecでより柔軟なレイアウト
gs = fig.add_gridspec(2, 3, hspace=0.3, wspace=0.3)

# 大きなメインプロット
ax_main = fig.add_subplot(gs[0, :2])
x = np.linspace(0, 10, 100)
ax_main.plot(x, np.sin(x) * np.exp(-x/10))
ax_main.set_title('メインプロット')

# 右側のサブプロット
ax_right = fig.add_subplot(gs[0, 2])
ax_right.bar(['A', 'B', 'C'], [10, 20, 15])
ax_right.set_title('サブプロット1')

# 下段のプロット
for i in range(3):
    ax = fig.add_subplot(gs[1, i])
    ax.plot(np.random.randn(20))
    ax.set_title(f'サブプロット{i+2}')

plt.savefig('complex_layout.png', dpi=150)
plt.show()


スタイルとカスタマイズ

グラフ全体のスタイル(フォントサイズ、線の太さ、配色など)を一括でカスタマイズする

import matplotlib.pyplot as plt
import numpy as np

# 利用可能なスタイル一覧
print(plt.style.available)

# スタイルの適用
plt.style.use('seaborn-v0_8-darkgrid')

# カスタムスタイルの設定
plt.rcParams.update({
    'font.size': 12,
    'axes.labelsize': 14,
    'axes.titlesize': 16,
    'lines.linewidth': 2,
    'figure.figsize': (10, 6),
    'axes.grid': True,
    'grid.alpha': 0.3
})

# 日本語フォントの設定(環境によって異なる)
plt.rcParams['font.family'] = 'DejaVu Sans'
# Windows: 'MS Gothic', 'Meiryo'
# Mac: 'Hiragino Sans'
# Linux: 'Noto Sans CJK JP'

x = np.linspace(0, 10, 100)
plt.plot(x, np.sin(x))
plt.title('カスタマイズされたグラフ')
plt.xlabel('X軸')
plt.ylabel('Y軸')
plt.show()


Seabornによる統計的可視化

Seabornの特徴

Seabornは Matplotlib をベースにした統計的データ可視化ライブラリで、美しいデフォルトスタイルと統計グラフの簡単な作成が特徴です。

pip install seaborn pandas

Seaborn組み込みのサンプルデータセット(tips)を読み込み、スタイルを設定する

import seaborn as sns
import matplotlib.pyplot as plt
import pandas as pd
import numpy as np

# サンプルデータセットの読み込み
tips = sns.load_dataset('tips')
print(tips.head())

# Seabornのスタイル設定
sns.set_theme(style='whitegrid', palette='husl')


分布の可視化

ヒストグラム+KDE、箱ひげ図、バイオリンプロットでデータの分布を可視化する

import seaborn as sns
import matplotlib.pyplot as plt

tips = sns.load_dataset('tips')

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

# ヒストグラム + カーネル密度推定
sns.histplot(data=tips, x='total_bill', kde=True, ax=axes[0, 0])
axes[0, 0].set_title('ヒストグラム + KDE')

# カーネル密度推定のみ
sns.kdeplot(data=tips, x='total_bill', hue='time', ax=axes[0, 1])
axes[0, 1].set_title('グループ別KDE')

# 箱ひげ図
sns.boxplot(data=tips, x='day', y='total_bill', hue='smoker', ax=axes[1, 0])
axes[1, 0].set_title('箱ひげ図')

# バイオリンプロット
sns.violinplot(data=tips, x='day', y='total_bill', hue='sex', split=True, ax=axes[1, 1])
axes[1, 1].set_title('バイオリンプロット')

plt.tight_layout()
plt.savefig('seaborn_distributions.png', dpi=150)
plt.show()


関係性の可視化

回帰プロット、ストリッププロット、スウォームプロットで変数間の関係を可視化する

import seaborn as sns
import matplotlib.pyplot as plt

tips = sns.load_dataset('tips')

fig, axes = plt.subplots(1, 3, figsize=(15, 5))

# 散布図 + 回帰直線
sns.regplot(data=tips, x='total_bill', y='tip', ax=axes[0])
axes[0].set_title('回帰プロット')

# カテゴリ別散布図
sns.stripplot(data=tips, x='day', y='total_bill', hue='sex', dodge=True, ax=axes[1])
axes[1].set_title('ストリッププロット')

# スウォームプロット(点が重ならない)
sns.swarmplot(data=tips, x='day', y='total_bill', hue='sex', dodge=True, ax=axes[2])
axes[2].set_title('スウォームプロット')

plt.tight_layout()
plt.savefig('seaborn_relationships.png', dpi=150)
plt.show()


ペアプロットとヒートマップ

複数変数の組み合わせを一覧表示するペアプロットと、相関係数を色で表現するヒートマップを作成する

import seaborn as sns
import matplotlib.pyplot as plt

# ペアプロット(変数間の関係を一覧表示)
iris = sns.load_dataset('iris')
g = sns.pairplot(iris, hue='species', diag_kind='kde')
g.fig.suptitle('Iris データセットのペアプロット', y=1.02)
plt.savefig('pairplot.png', dpi=150)
plt.show()

# 相関行列のヒートマップ
tips = sns.load_dataset('tips')
numeric_tips = tips.select_dtypes(include=[np.number])
correlation = numeric_tips.corr()

plt.figure(figsize=(8, 6))
sns.heatmap(correlation, annot=True, cmap='coolwarm', center=0,
            square=True, linewidths=0.5)
plt.title('相関行列ヒートマップ')
plt.tight_layout()
plt.savefig('heatmap.png', dpi=150)
plt.show()


GUIプログラミング

Tkinter(Python標準ライブラリ)

Tkinterは Python に標準で組み込まれており、追加インストール不要で使用できる GUI ツールキットです。


テキスト入力、チェックボックス、ラジオボタン、ファイル選択ダイアログを持つ基本的なフォームアプリを作成する

import tkinter as tk
from tkinter import ttk, messagebox, filedialog

class SimpleApp:
    def __init__(self, root):
        self.root = root
        self.root.title('Tkinter サンプルアプリ')
        self.root.geometry('400x300')

        # フレームの作成
        main_frame = ttk.Frame(root, padding=20)
        main_frame.pack(fill=tk.BOTH, expand=True)

        # ラベル
        ttk.Label(main_frame, text='名前を入力してください:').pack(anchor=tk.W)

        # テキスト入力
        self.name_var = tk.StringVar()
        ttk.Entry(main_frame, textvariable=self.name_var, width=30).pack(pady=5)

        # チェックボックス
        self.check_var = tk.BooleanVar()
        ttk.Checkbutton(main_frame, text='オプションを有効にする',
                       variable=self.check_var).pack(anchor=tk.W, pady=5)

        # ラジオボタン
        self.radio_var = tk.StringVar(value='option1')
        ttk.Radiobutton(main_frame, text='選択肢1',
                       variable=self.radio_var, value='option1').pack(anchor=tk.W)
        ttk.Radiobutton(main_frame, text='選択肢2',
                       variable=self.radio_var, value='option2').pack(anchor=tk.W)

        # ボタン
        button_frame = ttk.Frame(main_frame)
        button_frame.pack(pady=20)

        ttk.Button(button_frame, text='実行', command=self.on_submit).pack(side=tk.LEFT, padx=5)
        ttk.Button(button_frame, text='ファイル選択', command=self.select_file).pack(side=tk.LEFT, padx=5)
        ttk.Button(button_frame, text='終了', command=root.quit).pack(side=tk.LEFT, padx=5)

        # 結果表示用ラベル
        self.result_label = ttk.Label(main_frame, text='')
        self.result_label.pack(pady=10)

    def on_submit(self):
        name = self.name_var.get()
        if name:
            message = f'こんにちは、{name}さん!\n'
            message += f'オプション: {"有効" if self.check_var.get() else "無効"}\n'
            message += f'選択: {self.radio_var.get()}'
            messagebox.showinfo('結果', message)
        else:
            messagebox.showwarning('警告', '名前を入力してください')

    def select_file(self):
        filepath = filedialog.askopenfilename(
            filetypes=[('テキストファイル', '*.txt'), ('すべてのファイル', '*.*')]
        )
        if filepath:
            self.result_label.config(text=f'選択: {filepath}')

if __name__ == '__main__':
    root = tk.Tk()
    app = SimpleApp(root)
    root.mainloop()


PyQt(高機能GUIフレームワーク)

PyQtは Qt フレームワークの Python バインディングで、複雑なデスクトップアプリケーションの開発に適しています。

pip install PyQt5

名前とカテゴリを入力してテーブルに追加していくシンプルなデータ管理アプリを作成する

import sys
from PyQt5.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout,
                             QHBoxLayout, QLabel, QLineEdit, QPushButton,
                             QMessageBox, QComboBox, QTableWidget, QTableWidgetItem)
from PyQt5.QtCore import Qt

class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle('PyQt5 サンプルアプリ')
        self.setGeometry(100, 100, 600, 400)

        # 中央ウィジェット
        central_widget = QWidget()
        self.setCentralWidget(central_widget)

        # レイアウト
        layout = QVBoxLayout(central_widget)

        # 入力フォーム
        form_layout = QHBoxLayout()
        form_layout.addWidget(QLabel('名前:'))
        self.name_input = QLineEdit()
        form_layout.addWidget(self.name_input)

        form_layout.addWidget(QLabel('カテゴリ:'))
        self.category_combo = QComboBox()
        self.category_combo.addItems(['カテゴリA', 'カテゴリB', 'カテゴリC'])
        form_layout.addWidget(self.category_combo)

        layout.addLayout(form_layout)

        # ボタン
        button_layout = QHBoxLayout()
        add_button = QPushButton('追加')
        add_button.clicked.connect(self.add_item)
        button_layout.addWidget(add_button)

        clear_button = QPushButton('クリア')
        clear_button.clicked.connect(self.clear_table)
        button_layout.addWidget(clear_button)

        layout.addLayout(button_layout)

        # テーブル
        self.table = QTableWidget(0, 2)
        self.table.setHorizontalHeaderLabels(['名前', 'カテゴリ'])
        self.table.horizontalHeader().setStretchLastSection(True)
        layout.addWidget(self.table)

    def add_item(self):
        name = self.name_input.text()
        category = self.category_combo.currentText()

        if name:
            row = self.table.rowCount()
            self.table.insertRow(row)
            self.table.setItem(row, 0, QTableWidgetItem(name))
            self.table.setItem(row, 1, QTableWidgetItem(category))
            self.name_input.clear()
        else:
            QMessageBox.warning(self, '警告', '名前を入力してください')

    def clear_table(self):
        self.table.setRowCount(0)

if __name__ == '__main__':
    app = QApplication(sys.argv)
    window = MainWindow()
    window.show()
    sys.exit(app.exec_())


Kivy(クロスプラットフォーム・タッチ対応)

Kivyはモバイルアプリやタッチスクリーン対応のアプリケーション開発に適しています。

pip install kivy

+/-ボタンで数値を増減させるカウンターアプリを作成する(タッチ操作対応)

from kivy.app import App
from kivy.uix.boxlayout import BoxLayout
from kivy.uix.button import Button
from kivy.uix.label import Label
from kivy.uix.textinput import TextInput
from kivy.uix.slider import Slider

class CounterApp(App):
    def build(self):
        self.count = 0

        layout = BoxLayout(orientation='vertical', padding=20, spacing=10)

        # カウンター表示
        self.counter_label = Label(text='0', font_size=48)
        layout.add_widget(self.counter_label)

        # ボタン配置
        button_layout = BoxLayout(size_hint_y=0.3, spacing=10)

        minus_btn = Button(text='-', font_size=32)
        minus_btn.bind(on_press=self.decrement)
        button_layout.add_widget(minus_btn)

        plus_btn = Button(text='+', font_size=32)
        plus_btn.bind(on_press=self.increment)
        button_layout.add_widget(plus_btn)

        layout.add_widget(button_layout)

        # リセットボタン
        reset_btn = Button(text='リセット', size_hint_y=0.2)
        reset_btn.bind(on_press=self.reset)
        layout.add_widget(reset_btn)

        return layout

    def increment(self, instance):
        self.count += 1
        self.counter_label.text = str(self.count)

    def decrement(self, instance):
        self.count -= 1
        self.counter_label.text = str(self.count)

    def reset(self, instance):
        self.count = 0
        self.counter_label.text = '0'

if __name__ == '__main__':
    CounterApp().run()


Pygameによるゲーム開発とアニメーション

Pygameの基本構造

Pygameは2Dゲーム開発に特化したライブラリで、グラフィックス、サウンド、入力処理などゲームに必要な機能を提供します。

pip install pygame

ウィンドウを開き、図形を描画し、ESCキーで終了するゲームループの最小構成を示す

import pygame
import sys

# Pygameの初期化
pygame.init()

# 画面設定
SCREEN_WIDTH = 800
SCREEN_HEIGHT = 600
screen = pygame.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT))
pygame.display.set_caption('Pygame基本構造')

# 色の定義
WHITE = (255, 255, 255)
BLACK = (0, 0, 0)
RED = (255, 0, 0)
GREEN = (0, 255, 0)
BLUE = (0, 0, 255)

# フレームレート管理
clock = pygame.time.Clock()
FPS = 60

# ゲームループ
running = True
while running:
    # イベント処理
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False
        elif event.type == pygame.KEYDOWN:
            if event.key == pygame.K_ESCAPE:
                running = False

    # 画面のクリア
    screen.fill(WHITE)

    # 描画処理
    pygame.draw.rect(screen, RED, (100, 100, 200, 100))  # 四角形
    pygame.draw.circle(screen, GREEN, (500, 150), 50)     # 円
    pygame.draw.line(screen, BLUE, (100, 300), (700, 300), 3)  # 線

    # 画面の更新
    pygame.display.flip()

    # フレームレート制御
    clock.tick(FPS)

pygame.quit()
sys.exit()


スプライトを使ったゲーム開発

プレイヤー(青い四角)を左右に動かし、落ちてくる敵(赤)を避けてアイテム(黄)を集めるミニゲーム

import pygame
import random
import sys

pygame.init()

SCREEN_WIDTH = 800
SCREEN_HEIGHT = 600
screen = pygame.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT))
pygame.display.set_caption('スプライトゲーム')

# 色定義
WHITE = (255, 255, 255)
RED = (255, 0, 0)
BLUE = (0, 0, 255)
YELLOW = (255, 255, 0)

# プレイヤークラス
class Player(pygame.sprite.Sprite):
    def __init__(self):
        super().__init__()
        self.image = pygame.Surface((50, 50))
        self.image.fill(BLUE)
        self.rect = self.image.get_rect()
        self.rect.centerx = SCREEN_WIDTH // 2
        self.rect.bottom = SCREEN_HEIGHT - 10
        self.speed = 8

    def update(self):
        keys = pygame.key.get_pressed()
        if keys[pygame.K_LEFT] and self.rect.left > 0:
            self.rect.x -= self.speed
        if keys[pygame.K_RIGHT] and self.rect.right < SCREEN_WIDTH:
            self.rect.x += self.speed

# 敵クラス
class Enemy(pygame.sprite.Sprite):
    def __init__(self):
        super().__init__()
        self.image = pygame.Surface((30, 30))
        self.image.fill(RED)
        self.rect = self.image.get_rect()
        self.rect.x = random.randint(0, SCREEN_WIDTH - 30)
        self.rect.y = random.randint(-100, -40)
        self.speed = random.randint(2, 6)

    def update(self):
        self.rect.y += self.speed
        if self.rect.top > SCREEN_HEIGHT:
            self.rect.x = random.randint(0, SCREEN_WIDTH - 30)
            self.rect.y = random.randint(-100, -40)
            self.speed = random.randint(2, 6)

# アイテムクラス
class Item(pygame.sprite.Sprite):
    def __init__(self):
        super().__init__()
        self.image = pygame.Surface((20, 20))
        self.image.fill(YELLOW)
        self.rect = self.image.get_rect()
        self.rect.x = random.randint(0, SCREEN_WIDTH - 20)
        self.rect.y = random.randint(-100, -40)
        self.speed = 3

    def update(self):
        self.rect.y += self.speed
        if self.rect.top > SCREEN_HEIGHT:
            self.reset()

    def reset(self):
        self.rect.x = random.randint(0, SCREEN_WIDTH - 20)
        self.rect.y = random.randint(-100, -40)

# スプライトグループの作成
all_sprites = pygame.sprite.Group()
enemies = pygame.sprite.Group()
items = pygame.sprite.Group()

player = Player()
all_sprites.add(player)

for _ in range(5):
    enemy = Enemy()
    all_sprites.add(enemy)
    enemies.add(enemy)

for _ in range(3):
    item = Item()
    all_sprites.add(item)
    items.add(item)

# ゲーム変数
score = 0
font = pygame.font.Font(None, 36)
clock = pygame.time.Clock()

# ゲームループ
running = True
while running:
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False

    # 更新
    all_sprites.update()

    # 衝突判定(敵との接触)
    if pygame.sprite.spritecollide(player, enemies, False):
        running = False  # ゲームオーバー

    # 衝突判定(アイテム取得)
    hits = pygame.sprite.spritecollide(player, items, False)
    for item in hits:
        score += 10
        item.reset()

    # 描画
    screen.fill(WHITE)
    all_sprites.draw(screen)

    # スコア表示
    score_text = font.render(f'Score: {score}', True, (0, 0, 0))
    screen.blit(score_text, (10, 10))

    pygame.display.flip()
    clock.tick(60)

pygame.quit()
sys.exit()


アニメーションの実装

3つの円が軌道上を動くアニメーションと、クリック位置からパーティクルが飛び散るエフェクト

import pygame
import math
import sys

pygame.init()

SCREEN_WIDTH = 800
SCREEN_HEIGHT = 600
screen = pygame.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT))
pygame.display.set_caption('アニメーション例')

# アニメーションするオブジェクトクラス
class AnimatedCircle:
    def __init__(self, x, y, radius, color, speed, amplitude):
        self.base_x = x
        self.base_y = y
        self.radius = radius
        self.color = color
        self.speed = speed
        self.amplitude = amplitude
        self.angle = 0

    def update(self, dt):
        self.angle += self.speed * dt

    def draw(self, surface):
        x = self.base_x + math.cos(self.angle) * self.amplitude
        y = self.base_y + math.sin(self.angle * 2) * (self.amplitude / 2)
        pygame.draw.circle(surface, self.color, (int(x), int(y)), self.radius)

# パーティクルシステム
class Particle:
    def __init__(self, x, y):
        self.x = x
        self.y = y
        self.vx = random.uniform(-2, 2)
        self.vy = random.uniform(-5, -1)
        self.lifetime = 60
        self.color = (255, random.randint(100, 200), 0)

    def update(self):
        self.x += self.vx
        self.y += self.vy
        self.vy += 0.1  # 重力
        self.lifetime -= 1

    def draw(self, surface):
        alpha = int(255 * (self.lifetime / 60))
        pygame.draw.circle(surface, self.color, (int(self.x), int(self.y)), 3)

import random

# オブジェクトの作成
circles = [
    AnimatedCircle(200, 300, 30, (255, 0, 0), 2, 100),
    AnimatedCircle(400, 300, 40, (0, 255, 0), 3, 80),
    AnimatedCircle(600, 300, 25, (0, 0, 255), 1.5, 120),
]

particles = []
clock = pygame.time.Clock()

running = True
while running:
    dt = clock.tick(60) / 1000.0  # 秒単位のデルタタイム

    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False
        elif event.type == pygame.MOUSEBUTTONDOWN:
            # クリックでパーティクル生成
            mx, my = pygame.mouse.get_pos()
            for _ in range(20):
                particles.append(Particle(mx, my))

    # 更新
    for circle in circles:
        circle.update(dt)

    particles = [p for p in particles if p.lifetime > 0]
    for particle in particles:
        particle.update()

    # 描画
    screen.fill((30, 30, 30))

    for circle in circles:
        circle.draw(screen)

    for particle in particles:
        particle.draw(screen)

    # 説明テキスト
    font = pygame.font.Font(None, 24)
    text = font.render('Click to create particles', True, (200, 200, 200))
    screen.blit(text, (10, 10))

    pygame.display.flip()

pygame.quit()
sys.exit()


3Dグラフィックス

Matplotlibによる3Dプロット

3つの円が軌道上を動くアニメーションと、クリック位置からパーティクルが飛び散るエフェクト

import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d import Axes3D
import numpy as np

fig = plt.figure(figsize=(15, 5))

# 3D散布図
ax1 = fig.add_subplot(131, projection='3d')
n = 100
x = np.random.randn(n)
y = np.random.randn(n)
z = np.random.randn(n)
colors = np.sqrt(x**2 + y**2 + z**2)
ax1.scatter(x, y, z, c=colors, cmap='viridis', s=50)
ax1.set_title('3D散布図')

# 3Dサーフェス
ax2 = fig.add_subplot(132, projection='3d')
x = np.linspace(-5, 5, 50)
y = np.linspace(-5, 5, 50)
X, Y = np.meshgrid(x, y)
Z = np.sin(np.sqrt(X**2 + Y**2))
ax2.plot_surface(X, Y, Z, cmap='plasma', edgecolor='none', alpha=0.8)
ax2.set_title('3Dサーフェス')

# 3Dワイヤーフレーム
ax3 = fig.add_subplot(133, projection='3d')
ax3.plot_wireframe(X, Y, Z, color='blue', linewidth=0.5)
ax3.set_title('ワイヤーフレーム')

plt.tight_layout()
plt.savefig('3d_plots.png', dpi=150)
plt.show()


PyOpenGLによる3D描画

ライティング付きで立方体と球を描画し、自動回転させる。ESCで終了

from OpenGL.GL import *
from OpenGL.GLUT import *
from OpenGL.GLU import *
import numpy as np

# 回転角度
rotation = [0, 0, 0]

def init():
    """初期化"""
    glClearColor(0.1, 0.1, 0.1, 1.0)
    glEnable(GL_DEPTH_TEST)
    glEnable(GL_LIGHTING)
    glEnable(GL_LIGHT0)

    # ライトの設定
    glLightfv(GL_LIGHT0, GL_POSITION, [1, 1, 1, 0])
    glLightfv(GL_LIGHT0, GL_DIFFUSE, [1, 1, 1, 1])
    glLightfv(GL_LIGHT0, GL_AMBIENT, [0.2, 0.2, 0.2, 1])

def draw_cube():
    """立方体を描画"""
    glMaterialfv(GL_FRONT, GL_DIFFUSE, [0.2, 0.5, 0.8, 1.0])
    glutSolidCube(2.0)

def draw_sphere():
    """球を描画"""
    glMaterialfv(GL_FRONT, GL_DIFFUSE, [0.8, 0.2, 0.2, 1.0])
    glutSolidSphere(1.0, 32, 32)

def display():
    """描画関数"""
    glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)
    glLoadIdentity()

    # カメラ位置
    gluLookAt(5, 5, 5, 0, 0, 0, 0, 1, 0)

    # 回転
    glRotatef(rotation[0], 1, 0, 0)
    glRotatef(rotation[1], 0, 1, 0)
    glRotatef(rotation[2], 0, 0, 1)

    # 立方体
    glPushMatrix()
    glTranslatef(-2, 0, 0)
    draw_cube()
    glPopMatrix()

    # 球
    glPushMatrix()
    glTranslatef(2, 0, 0)
    draw_sphere()
    glPopMatrix()

    glutSwapBuffers()

def reshape(width, height):
    """ウィンドウサイズ変更時"""
    glViewport(0, 0, width, height)
    glMatrixMode(GL_PROJECTION)
    glLoadIdentity()
    gluPerspective(45, width/height, 0.1, 100)
    glMatrixMode(GL_MODELVIEW)

def idle():
    """アイドル時(アニメーション)"""
    rotation[1] += 1
    if rotation[1] >= 360:
        rotation[1] = 0
    glutPostRedisplay()

def keyboard(key, x, y):
    """キー入力"""
    if key == b'\x1b':  # ESC
        glutLeaveMainLoop()

def main():
    glutInit()
    glutInitDisplayMode(GLUT_DOUBLE | GLUT_RGB | GLUT_DEPTH)
    glutInitWindowSize(800, 600)
    glutCreateWindow(b'PyOpenGL 3D Example')

    init()

    glutDisplayFunc(display)
    glutReshapeFunc(reshape)
    glutIdleFunc(idle)
    glutKeyboardFunc(keyboard)

    glutMainLoop()

if __name__ == '__main__':
    main()


OpenCVによるコンピュータビジョン

OpenCVの基本操作
pip install opencv-python

画像の読み込み、色空間変換(RGB/グレースケール/HSV)、リサイズ、回転、反転、保存を行う

import cv2
import numpy as np

# 画像の読み込み
image = cv2.imread('photo.jpg')

# 画像情報
print(f"形状: {image.shape}")  # (height, width, channels)
print(f"データ型: {image.dtype}")

# 色空間の変換(OpenCVはBGR形式)
rgb_image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
gray_image = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
hsv_image = cv2.cvtColor(image, cv2.COLOR_BGR2HSV)

# リサイズ
resized = cv2.resize(image, (640, 480))
resized_ratio = cv2.resize(image, None, fx=0.5, fy=0.5)

# 回転
h, w = image.shape[:2]
center = (w // 2, h // 2)
matrix = cv2.getRotationMatrix2D(center, 45, 1.0)  # 中心, 角度, スケール
rotated = cv2.warpAffine(image, matrix, (w, h))

# 画像のフリップ
flipped_h = cv2.flip(image, 1)   # 水平反転
flipped_v = cv2.flip(image, 0)   # 垂直反転

# 画像の保存
cv2.imwrite('output.jpg', image)

# 画像の表示
cv2.imshow('Image', image)
cv2.waitKey(0)
cv2.destroyAllWindows()


画像フィルタリング
pip install opencv-python

各種ぼかし、エッジ検出(Canny/Sobel/Laplacian)、シャープ化、モルフォロジー変換を適用する

import cv2
import numpy as np

image = cv2.imread('photo.jpg')

# ぼかし処理
blur = cv2.blur(image, (5, 5))
gaussian = cv2.GaussianBlur(image, (5, 5), 0)
median = cv2.medianBlur(image, 5)
bilateral = cv2.bilateralFilter(image, 9, 75, 75)

# エッジ検出
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
edges_canny = cv2.Canny(gray, 100, 200)

# Sobelフィルタ
sobel_x = cv2.Sobel(gray, cv2.CV_64F, 1, 0, ksize=3)
sobel_y = cv2.Sobel(gray, cv2.CV_64F, 0, 1, ksize=3)
sobel = np.sqrt(sobel_x**2 + sobel_y**2)

# Laplacianフィルタ
laplacian = cv2.Laplacian(gray, cv2.CV_64F)

# シャープ化
kernel = np.array([[-1, -1, -1],
                   [-1,  9, -1],
                   [-1, -1, -1]])
sharpened = cv2.filter2D(image, -1, kernel)

# モルフォロジー変換
kernel = np.ones((5, 5), np.uint8)
erosion = cv2.erode(gray, kernel, iterations=1)
dilation = cv2.dilate(gray, kernel, iterations=1)
opening = cv2.morphologyEx(gray, cv2.MORPH_OPEN, kernel)
closing = cv2.morphologyEx(gray, cv2.MORPH_CLOSE, kernel)


顔検出

Haar Cascade分類器を使って画像から顔と目を検出し、検出箇所に矩形を描画する

import cv2

# 顔検出用のカスケード分類器を読み込み
face_cascade = cv2.CascadeClassifier(
    cv2.data.haarcascades + 'haarcascade_frontalface_default.xml'
)
eye_cascade = cv2.CascadeClassifier(
    cv2.data.haarcascades + 'haarcascade_eye.xml'
)

# 画像を読み込み
image = cv2.imread('photo.jpg')
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)

# 顔を検出
faces = face_cascade.detectMultiScale(
    gray,
    scaleFactor=1.1,
    minNeighbors=5,
    minSize=(30, 30)
)

print(f"検出された顔の数: {len(faces)}")

# 検出結果を描画
for (x, y, w, h) in faces:
    # 顔の矩形を描画
    cv2.rectangle(image, (x, y), (x+w, y+h), (255, 0, 0), 2)

    # 顔領域内で目を検出
    roi_gray = gray[y:y+h, x:x+w]
    roi_color = image[y:y+h, x:x+w]

    eyes = eye_cascade.detectMultiScale(roi_gray)
    for (ex, ey, ew, eh) in eyes:
        cv2.rectangle(roi_color, (ex, ey), (ex+ew, ey+eh), (0, 255, 0), 2)

cv2.imwrite('face_detected.jpg', image)


リアルタイム動画処理

PCのカメラ映像をリアルタイムで取得し、顔検出を行って緑の枠を表示する。qキーで終了

import cv2

# カメラのキャプチャを開始
cap = cv2.VideoCapture(0)

if not cap.isOpened():
    print("カメラを開けません")
    exit()

# 顔検出器
face_cascade = cv2.CascadeClassifier(
    cv2.data.haarcascades + 'haarcascade_frontalface_default.xml'
)

while True:
    # フレームを取得
    ret, frame = cap.read()
    if not ret:
        break

    # グレースケールに変換
    gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)

    # 顔検出
    faces = face_cascade.detectMultiScale(gray, 1.1, 5)

    # 検出結果を描画
    for (x, y, w, h) in faces:
        cv2.rectangle(frame, (x, y), (x+w, y+h), (0, 255, 0), 2)

    # フレームを表示
    cv2.imshow('Face Detection', frame)

    # 'q'キーで終了
    if cv2.waitKey(1) & 0xFF == ord('q'):
        break

cap.release()
cv2.destroyAllWindows()


ディープラーニングを用いた画像処理

TensorFlow/Kerasによる画像分類
pip install tensorflow

MNISTの手書き数字を認識するCNN(畳み込みニューラルネットワーク)を構築・学習・評価し、学習曲線をグラフ化する

import tensorflow as tf
from tensorflow import keras
from tensorflow.keras import layers
import numpy as np
import matplotlib.pyplot as plt

# MNISTデータセットの読み込み
(x_train, y_train), (x_test, y_test) = keras.datasets.mnist.load_data()

# データの前処理
x_train = x_train.astype('float32') / 255.0
x_test = x_test.astype('float32') / 255.0
x_train = x_train.reshape(-1, 28, 28, 1)
x_test = x_test.reshape(-1, 28, 28, 1)

# モデルの構築
model = keras.Sequential([
    layers.Conv2D(32, (3, 3), activation='relu', input_shape=(28, 28, 1)),
    layers.MaxPooling2D((2, 2)),
    layers.Conv2D(64, (3, 3), activation='relu'),
    layers.MaxPooling2D((2, 2)),
    layers.Conv2D(64, (3, 3), activation='relu'),
    layers.Flatten(),
    layers.Dense(64, activation='relu'),
    layers.Dropout(0.5),
    layers.Dense(10, activation='softmax')
])

model.compile(
    optimizer='adam',
    loss='sparse_categorical_crossentropy',
    metrics=['accuracy']
)

model.summary()

# 学習
history = model.fit(
    x_train, y_train,
    epochs=10,
    batch_size=64,
    validation_split=0.1
)

# 評価
test_loss, test_acc = model.evaluate(x_test, y_test)
print(f'テスト精度: {test_acc:.4f}')

# 学習曲線の可視化
plt.figure(figsize=(12, 4))

plt.subplot(1, 2, 1)
plt.plot(history.history['accuracy'], label='訓練')
plt.plot(history.history['val_accuracy'], label='検証')
plt.title('精度')
plt.xlabel('エポック')
plt.ylabel('精度')
plt.legend()

plt.subplot(1, 2, 2)
plt.plot(history.history['loss'], label='訓練')
plt.plot(history.history['val_loss'], label='検証')
plt.title('損失')
plt.xlabel('エポック')
plt.ylabel('損失')
plt.legend()

plt.tight_layout()
plt.savefig('training_history.png')
plt.show()


GANによる画像生成

Generator(生成器)とDiscriminator(識別器)を訓練し、ランダムノイズから手書き数字風の画像を生成する

import tensorflow as tf
from tensorflow import keras
from tensorflow.keras import layers
import numpy as np
import matplotlib.pyplot as plt

# ハイパーパラメータ
LATENT_DIM = 100
BATCH_SIZE = 128
EPOCHS = 50

# Generatorの構築
def build_generator():
    model = keras.Sequential([
        layers.Dense(7 * 7 * 256, input_dim=LATENT_DIM),
        layers.Reshape((7, 7, 256)),
        layers.BatchNormalization(),
        layers.LeakyReLU(0.2),

        layers.Conv2DTranspose(128, (5, 5), strides=(1, 1), padding='same'),
        layers.BatchNormalization(),
        layers.LeakyReLU(0.2),

        layers.Conv2DTranspose(64, (5, 5), strides=(2, 2), padding='same'),
        layers.BatchNormalization(),
        layers.LeakyReLU(0.2),

        layers.Conv2DTranspose(1, (5, 5), strides=(2, 2), padding='same',
                              activation='tanh')
    ])
    return model

# Discriminatorの構築
def build_discriminator():
    model = keras.Sequential([
        layers.Conv2D(64, (5, 5), strides=(2, 2), padding='same',
                     input_shape=(28, 28, 1)),
        layers.LeakyReLU(0.2),
        layers.Dropout(0.3),

        layers.Conv2D(128, (5, 5), strides=(2, 2), padding='same'),
        layers.LeakyReLU(0.2),
        layers.Dropout(0.3),

        layers.Flatten(),
        layers.Dense(1, activation='sigmoid')
    ])
    return model

# モデルの作成
generator = build_generator()
discriminator = build_discriminator()

# オプティマイザ
generator_optimizer = keras.optimizers.Adam(1e-4)
discriminator_optimizer = keras.optimizers.Adam(1e-4)

# 損失関数
cross_entropy = keras.losses.BinaryCrossentropy()

@tf.function
def train_step(images):
    noise = tf.random.normal([BATCH_SIZE, LATENT_DIM])

    with tf.GradientTape() as gen_tape, tf.GradientTape() as disc_tape:
        generated_images = generator(noise, training=True)

        real_output = discriminator(images, training=True)
        fake_output = discriminator(generated_images, training=True)

        gen_loss = cross_entropy(tf.ones_like(fake_output), fake_output)
        disc_loss = cross_entropy(tf.ones_like(real_output), real_output) + \
                    cross_entropy(tf.zeros_like(fake_output), fake_output)

    gen_gradients = gen_tape.gradient(gen_loss, generator.trainable_variables)
    disc_gradients = disc_tape.gradient(disc_loss, discriminator.trainable_variables)

    generator_optimizer.apply_gradients(
        zip(gen_gradients, generator.trainable_variables))
    discriminator_optimizer.apply_gradients(
        zip(disc_gradients, discriminator.trainable_variables))

    return gen_loss, disc_loss

def generate_and_save_images(model, epoch, test_input):
    predictions = model(test_input, training=False)

    fig = plt.figure(figsize=(4, 4))
    for i in range(predictions.shape[0]):
        plt.subplot(4, 4, i + 1)
        plt.imshow(predictions[i, :, :, 0] * 127.5 + 127.5, cmap='gray')
        plt.axis('off')

    plt.savefig(f'gan_image_epoch_{epoch:04d}.png')
    plt.close()

# データの準備
(train_images, _), (_, _) = keras.datasets.mnist.load_data()
train_images = train_images.reshape(-1, 28, 28, 1).astype('float32')
train_images = (train_images - 127.5) / 127.5

train_dataset = tf.data.Dataset.from_tensor_slices(train_images)
train_dataset = train_dataset.shuffle(60000).batch(BATCH_SIZE)

# テスト用の固定ノイズ
seed = tf.random.normal([16, LATENT_DIM])

# 学習ループ
for epoch in range(EPOCHS):
    for image_batch in train_dataset:
        gen_loss, disc_loss = train_step(image_batch)

    if (epoch + 1) % 10 == 0:
        generate_and_save_images(generator, epoch + 1, seed)
        print(f'Epoch {epoch+1}, Gen Loss: {gen_loss:.4f}, Disc Loss: {disc_loss:.4f}')


練習問題1.

Pillowライブラリを使用して、指定された画像を開き、色を変更し、別の形式で保存するプログラムを作成してください。

  1. Pillowライブラリを使って任意の画像を開く。
  2. 画像をグレースケールに変換する。
  3. 変換した画像のサイズを変更する(例:幅と高さを半分にする)。
  4. 変更した画像をPNG形式で保存する。


練習問題2.

Matplotlibを使用して、ランダムに生成されたデータセットをプロットしてください。

  1. NumPyを使ってランダムなデータセットを生成する(例:100個の点)。
  2. Matplotlibを使ってデータの散布図を作成する。
  3. X軸とY軸にラベルを追加し、タイトルを付ける。
  4. 散布図に回帰線を追加する。


練習問題3.

PyOpenGLを使用して、基本的な3Dオブジェクト(例:立方体)を描画し、それを動かしてください。

  1. PyOpenGLを使用してウィンドウを作成する。
  2. シンプルな3Dオブジェクト(立方体)を描画する。
  3. キーボードの入力を受け取り、立方体を動かす(例:矢印キーで左右に回転)。
  4. オブジェクトに簡単なライティングを適用する。

第20章 数学処理