ノイズ除去 (バイラテラルフィルタ)、彩度調整、ガンマ補正、コントラスト調整 (CLAHE)によるリアルタイム動画補正(ソースコードと説明と利用ガイド)

【ツール説明】このページのリアルタイム動画補正プログラムは、動画ファイルやウェブカメラ映像に対し、GUIスライダーを用いて以下の画質補正を対話的に適用できるものである。調整したパラメータはプリセットとしてファイルに保存・読み込みが可能。画像処理の学習教材としての活用も意図している。

【主な調整項目】 ・ノイズ除去 (バイラテラルフィルタ) ・彩度調整 ・ガンマ補正 (明るさ) ・コントラスト調整 (CLAHE)

#Python #OpenCV #画像処理 #画像補正

プログラム利用ガイド

1. このツールの利用シーン

このツールは、動画ファイルやウェブカメラの映像をリアルタイムで美しく補正するソフトウェアである。暗い映像を明るくしたり、色あせた映像を鮮やかにしたりといった調整を、結果を確認しながら直感的に行うことができる。ウェブ会議の映像改善や動画コンテンツの基本的な色補正などに活用される。

2. 主な機能

3. 基本的な使い方

  1. 起動と入力の選択

    プログラムを起動すると、コマンドプロンプト(黒い画面)で入力方法を選択する画面が表示される。キーボードで0(動画ファイル)、1(ウェブカメラ)、2(サンプル動画)のいずれかを入力し、Enterキーを押す。

  2. 画質の調整

    映像のプレビュー画面と「パラメータ調整」ウィンドウが表示される。「パラメータ調整」ウィンドウのスライダーを操作し、好みの画質になるよう調整する。変更はリアルタイムでプレビュー画面に反映される。

  3. 終了方法

    映像が表示されているウィンドウを選択した状態でキーボードのqキーを押すか、「パラメータ調整」ウィンドウの「終了」ボタンをクリックする。

4. 便利な機能

Python開発環境,ライブラリ類

ここでは、最低限の事前準備について説明する。機械学習や深層学習を行う場合は、NVIDIA CUDA、Visual Studio、Cursorなどを追加でインストールすると便利である。これらについては別ページ https://www.kkaneko.jp/cc/dev/aiassist.htmlで詳しく解説しているので、必要に応じて参照してください。

Python 3.12 のインストール

インストール済みの場合は実行不要。

管理者権限でコマンドプロンプトを起動(手順:Windowsキーまたはスタートメニュー > cmd と入力 > 右クリック > 「管理者として実行」)し、以下を実行する。管理者権限は、wingetの--scope machineオプションでシステム全体にソフトウェアをインストールするために必要である。

REM Python をシステム領域にインストール
winget install --scope machine --id Python.Python.3.12 -e --silent
REM Python のパス設定
set "PYTHON_PATH=C:\Program Files\Python312"
set "PYTHON_SCRIPTS_PATH=C:\Program Files\Python312\Scripts"
echo "%PATH%" | find /i "%PYTHON_PATH%" >nul
if errorlevel 1 setx PATH "%PATH%;%PYTHON_PATH%" /M >nul
echo "%PATH%" | find /i "%PYTHON_SCRIPTS_PATH%" >nul
if errorlevel 1 setx PATH "%PATH%;%PYTHON_SCRIPTS_PATH%" /M >nul

関連する外部ページ

Python の公式ページ: https://www.python.org/

AI エディタ Windsurf のインストール

Pythonプログラムの編集・実行には、AI エディタの利用を推奨する。ここでは,Windsurfのインストールを説明する。

管理者権限でコマンドプロンプトを起動(手順:Windowsキーまたはスタートメニュー > cmd と入力 > 右クリック > 「管理者として実行」)し、以下を実行して、Windsurfをシステム全体にインストールする。管理者権限は、wingetの--scope machineオプションでシステム全体にソフトウェアをインストールするために必要となる。

winget install --scope machine Codeium.Windsurf -e --silent

関連する外部ページ

Windsurf の公式ページ: https://windsurf.com/

必要なライブラリのインストール

コマンドプロンプトを管理者として実行(手順:Windowsキーまたはスタートメニュー > cmd と入力 > 右クリック > 「管理者として実行」)し、以下を実行する


pip install opencv-python pillow numpy

ノイズ除去 (バイラテラルフィルタ)、彩度調整、ガンマ補正、コントラスト調整 (CLAHE)によるリアルタイム動画補正プログラム

概要

本プログラムは、動画ファイルまたはカメラからの映像に対し、複数の画像処理技術を適用してリアルタイムに画質を補正するツールである。利用者はGUIを通じて各種パラメータを対話的に調整し、その結果を即座にプレビューできる。また、調整したパラメータ設定の保存・読み込みや、補正後の映像を動画ファイルとして出力する機能も備える。

主要技術

本プログラムは、画質補正のために以下の主要な画像処理アルゴリズムを組み合わせている。

技術的特徴

本プログラムは、「ノイズ除去(バイラテラルフィルタ)」、「彩度調整」、「輝度調整(ガンマ補正)」、「コントラスト調整(CLAHE)」の順に行われる。特にノイズ除去を初期段階で行うことで、以降の処理におけるノイズ増幅を抑制している。また、CLAHEを適用する際には、輝度チャンネル(Lチャンネル)にのみ処理を施すことで、元の色彩情報を損なうことなくコントラストを改善している。

実装の特色

本プログラムは、OpenCVによる画像処理とtkinterによるGUI操作を単一のループ内で同期させている。これにより、パラメータ変更をリアルタイムで映像に反映させることが可能となっている。また、調整したパラメータ群は、JSON形式の外部ファイルとして保存・読み込みが可能であり、特定の設定をプリセットとして再現できる。さらに、GUIで設定したパラメータを用いて動画全体を非対話的に処理し、新しい動画ファイルとして書き出すバッチ処理機能も実装されている。

参考文献

ソースコード


# プログラム名: ノイズ除去 (バイラテラルフィルタ)、彩度調整、ガンマ補正、コントラスト調整 (CLAHE)によるリアルタイム動画補正プログラム
# プログラム名: リアルタイム動画補正プログラム
# 特徴技術名: CLAHE (Contrast Limited Adaptive Histogram Equalization)
# 出典: Bradski, G., & Kaehler, A. (2008). Learning OpenCV: Computer Vision with the OpenCV Library. O'Reilly Media, Inc.
# 特徴機能: 画像を小さな領域(タイル)に分割し、各領域でヒストグラム均等化を行います。その際、ノイズが増幅されるのを防ぐためにコントラストに上限(クリップリミット)を設定する機能です。これにより、画像全体の照明のむらを補正しつつ、局所的なディテールを鮮明にすることが可能です。
# 学習済みモデル: なし
# 特徴技術および学習済モデルの利用制限: 本プログラムが利用する技術(OpenCV)は、3-clause BSD license等の下で提供されており、商用利用も可能ですが、一部特許で保護されたアルゴリズムが含まれる可能性があります。詳細は公式のライセンス情報を参照し、必ず利用者自身で利用制限を確認してください。
# 方式設計:
#     - 関連利用技術:
#         - OpenCV: 動画の入出力、画像処理(バイラテラルフィルタ、彩度調整、ガンマ補正、色空間変換、CLAHE)に使用します。
#         - tkinter: 各種補正パラメータをリアルタイムで調整するためのGUI(スライダー)を提供します。
#         - Pillow (PIL): OpenCVのウィンドウに日本語テキストを描画するために使用します。
#     - 入力と出力:
#         入力: 動画(ユーザは「0:動画ファイル,1:カメラ,2:サンプル動画」のメニューで選択.0:動画ファイルの場合はtkinterでファイル選択.1の場合はOpenCVでカメラが開く.2の場合はhttps://raw.githubusercontent.com/opencv/opencv/master/samples/data/vtest.aviを使用)
#         出力: 処理結果をOpenCVウィンドウでリアルタイムに表示します(左側:元画像、右側:処理後)。また、プログラム終了時に各フレームで適用されたパラメータ設定を`result.txt`に保存します。
#     - 処理手順:
#         1. tkinterで作成したスライダーから各種パラメータを取得します。
#         2. 入力動画のフレームに対し、ノイズ除去のためバイラテラルフィルタを適用します。
#         3. 次に、HSV色空間に変換し、彩度を調整します。
#         4. その後、ガンマ補正を適用します。
#         5. 最後に、LAB色空間に変換し、輝度チャンネルにCLAHEを適用してコントラストを調整します。
#         6. 元のフレームと処理後のフレームを水平に連結し、一つのウィンドウに表示します。
#     - 前処理、後処理: 本プログラムでは、特徴技術の効果を発揮させるための特別な前処理および後処理は実装しません。
#     - 追加処理: ユーザがGUIを通じてリアルタイムにパラメータを変更できるようにするため、OpenCVのメインループ内でtkinterのイベントループを更新する処理を追加します。
#     - 調整を必要とする設定値:
#         - bilateral_d: バイラテラルフィルタの近傍領域の直径。0で無効化。
#         - sigma_color: バイラテラルフィルタの色空間におけるシグマ値。
#         - sigma_space: バイラテラルフィルタの座標空間におけるシグマ値。
#         - saturation: 彩度の倍率。1.0で無補正。
#         - gamma: ガンマ補正の強度。1.0で無補正。
#         - clip_limit: CLAHEにおけるコントラストの上限値。
#         - tile_grid_size: CLAHEが画像を分割する際のタイル(格子)のサイズ。
# 将来方策: 現在は手動で調整しているパラメータについて、画像の特性(ヒストグラムなど)を分析し、最適な値を自動で提案する機能を将来的に追加することが考えられます。
# その他の重要事項: フォントファイル`C:/Windows/Fonts/meiryo.ttc`が存在しない環境では、テキスト表示部分でエラーが発生します。その場合は、存在する別のフォントパスに修正する必要があります。
# 前準備: pip install opencv-python pillow numpy

import cv2
import numpy as np
import tkinter as tk
from tkinter import filedialog, messagebox
import urllib.request
import os
import time
from datetime import datetime
from PIL import Image, ImageDraw, ImageFont
import json

# --- グローバル変数 ---
frame_count = 0
results_log = []
current_input_path = None
exit_requested = False
# Tkinter関連の変数
root_tk = None
gamma_var = None
clip_limit_var = None
tile_size_var = None
bilateral_d_var = None
sigma_color_var = None
sigma_space_var = None
saturation_var = None

# --- ヘルパー関数 ---
def create_slider(parent, text, from_, to, resolution, variable):
    tk.Label(parent, text=text).pack()
    slider = tk.Scale(parent, from_=from_, to=to, resolution=resolution, orient=tk.HORIZONTAL, variable=variable)
    slider.pack(fill=tk.X, padx=10)
    return slider

def apply_image_processing(frame, params):
    processed_frame = frame.copy()
    if params['d'] > 0:
        processed_frame = cv2.bilateralFilter(processed_frame, params['d'], params['sigma_color'], params['sigma_space'])
    if params['saturation_scale'] != 1.0:
        hsv = cv2.cvtColor(processed_frame, cv2.COLOR_BGR2HSV)
        h, s, v = cv2.split(hsv)
        s = np.clip(s.astype(np.float32) * params['saturation_scale'], 0.0, 255.0).astype(np.uint8)
        processed_frame = cv2.cvtColor(cv2.merge((h, s, v)), cv2.COLOR_HSV2BGR)
    if params['gamma'] != 1.0:
        look_up_table = np.array([((i / 255.0) ** (1.0 / params['gamma'])) * 255 for i in np.arange(0, 256)]).astype("uint8")
        processed_frame = cv2.LUT(processed_frame, look_up_table)
    lab_frame = cv2.cvtColor(processed_frame, cv2.COLOR_BGR2LAB)
    l_channel, a_channel, b_channel = cv2.split(lab_frame)
    clahe = cv2.createCLAHE(clipLimit=params['clip_limit'], tileGridSize=(params['tile_size'], params['tile_size']))
    clahe_l_channel = clahe.apply(l_channel)
    processed_frame = cv2.cvtColor(cv2.merge([clahe_l_channel, a_channel, b_channel]), cv2.COLOR_LAB2BGR)
    return processed_frame

def video_frame_processing(frame):
    global frame_count
    current_time = time.time()
    frame_count += 1
    params = {
        'd': bilateral_d_var.get(), 'sigma_color': sigma_color_var.get(), 'sigma_space': sigma_space_var.get(),
        'saturation_scale': saturation_var.get(), 'gamma': gamma_var.get(),
        'clip_limit': clip_limit_var.get(), 'tile_size': tile_size_var.get()
    }
    processed_frame = apply_image_processing(frame, params)
    # 表示順を「左:元画像, 右:処理後」に変更
    combined_frame = np.hstack((frame, processed_frame))
    result = f"Bilateral(d:{params['d']},cσ:{params['sigma_color']},sσ:{params['sigma_space']}), Sat:{params['saturation_scale']:.2f}, Gamma:{params['gamma']:.2f}, CLAHE(clip:{params['clip_limit']:.1f},tile:{params['tile_size']})"
    return combined_frame, result, current_time

def request_exit():
    global exit_requested
    exit_requested = True

def save_preset():
    preset_data = {
        "bilateral_d": bilateral_d_var.get(),
        "sigma_color": sigma_color_var.get(),
        "sigma_space": sigma_space_var.get(),
        "saturation": saturation_var.get(),
        "gamma": gamma_var.get(),
        "clip_limit": clip_limit_var.get(),
        "tile_grid_size": tile_size_var.get()
    }
    try:
        with open("preset.json", "w", encoding="utf-8") as f:
            json.dump(preset_data, f, indent=4)
        messagebox.showinfo("成功", "設定を preset.json に保存しました。")
    except Exception as e:
        messagebox.showerror("エラー", f"設定の保存中にエラーが発生しました:\n{e}")

def load_preset():
    try:
        if not os.path.exists("preset.json"):
            messagebox.showwarning("警告", "preset.json が見つかりません。")
            return
        with open("preset.json", "r", encoding="utf-8") as f:
            preset_data = json.load(f)

        bilateral_d_var.set(preset_data.get("bilateral_d", 0))
        sigma_color_var.set(preset_data.get("sigma_color", 75))
        sigma_space_var.set(preset_data.get("sigma_space", 75))
        saturation_var.set(preset_data.get("saturation", 1.0))
        gamma_var.set(preset_data.get("gamma", 1.0))
        clip_limit_var.set(preset_data.get("clip_limit", 2.0))
        tile_size_var.set(preset_data.get("tile_grid_size", 8))

        messagebox.showinfo("成功", "設定を読み込みました。")
    except json.JSONDecodeError:
        messagebox.showerror("エラー", "preset.json の形式が正しくありません。")
    except Exception as e:
        messagebox.showerror("エラー", f"設定の読み込み中にエラーが発生しました:\n{e}")

def reset_parameters():
    """スライダーをすべて初期値に戻す"""
    bilateral_d_var.set(0)
    sigma_color_var.set(75)
    sigma_space_var.set(75)
    saturation_var.set(1.0)
    gamma_var.set(1.0)
    clip_limit_var.set(2.0)
    tile_size_var.set(8)

def save_video_file():
    global current_input_path
    if not current_input_path:
        messagebox.showerror("エラー", "保存対象の動画ファイルがありません。")
        return

    output_path = filedialog.asksaveasfilename(defaultextension=".mp4", filetypes=[("MP4 files", "*.mp4"), ("All files", "*.*")])
    if not output_path:
        return

    params = {
        'd': bilateral_d_var.get(), 'sigma_color': sigma_color_var.get(), 'sigma_space': sigma_space_var.get(),
        'saturation_scale': saturation_var.get(), 'gamma': gamma_var.get(),
        'clip_limit': clip_limit_var.get(), 'tile_size': tile_size_var.get()
    }

    cap_save = cv2.VideoCapture(current_input_path)
    if not cap_save.isOpened():
        messagebox.showerror("エラー", "入力動画ファイルを開けませんでした。")
        return

    fps = cap_save.get(cv2.CAP_PROP_FPS)
    width = int(cap_save.get(cv2.CAP_PROP_FRAME_WIDTH))
    height = int(cap_save.get(cv2.CAP_PROP_FRAME_HEIGHT))
    total_frames = int(cap_save.get(cv2.CAP_PROP_FRAME_COUNT))

    writer = cv2.VideoWriter(output_path, cv2.VideoWriter_fourcc(*'XVID'), fps, (width, height))

    print("\n=== 動画ファイル保存開始 ===")
    print(f"入力: {os.path.basename(current_input_path)}")
    print(f"出力: {os.path.basename(output_path)}")

    for i in range(total_frames):
        ret, frame = cap_save.read()
        if not ret:
            break
        processed_frame = apply_image_processing(frame, params)
        writer.write(processed_frame)
        progress = (i + 1) / total_frames * 100
        print(f"\r処理中: {i + 1}/{total_frames} ({progress:.2f}%)", end="")

    print("\n=== 動画ファイル保存完了 ===")
    cap_save.release()
    writer.release()
    messagebox.showinfo("完了", "動画の保存が完了しました。")

# --- メイン処理 ---
print("リアルタイム動画補正プログラム")
print("補正パラメータをGUIで調整できます。")
print("\n入力ソースを選択してください:")
print("0: 動画ファイル")
print("1: カメラ")
print("2: サンプル動画")

choice = input("選択: ")

cap = None
if choice == '0':
    temp_root = tk.Tk()
    temp_root.withdraw()
    path = filedialog.askopenfilename()
    temp_root.destroy()
    if not path:
        exit()
    current_input_path = path
    cap = cv2.VideoCapture(current_input_path)
elif choice == '1':
    cap = cv2.VideoCapture(0, cv2.CAP_DSHOW)
    if not cap.isOpened():
        cap = cv2.VideoCapture(0)
elif choice == '2':
    SAMPLE_URL = 'https://raw.githubusercontent.com/opencv/opencv/master/samples/data/vtest.avi'
    SAMPLE_FILE = 'vtest.avi'
    try:
        urllib.request.urlretrieve(SAMPLE_URL, SAMPLE_FILE)
        current_input_path = os.path.abspath(SAMPLE_FILE)
        cap = cv2.VideoCapture(current_input_path)
    except Exception as e:
        print(f"ダウンロードに失敗しました: {e}")
        exit()
else:
    print("無効な選択です。")
    exit()

if not cap.isOpened():
    print('動画ファイル・カメラを開けませんでした')
    exit()

root_tk = tk.Tk()
root_tk.title("パラメータ調整")
root_tk.geometry("300x630")

bilateral_d_var = tk.IntVar(value=0)
sigma_color_var = tk.IntVar(value=75)
sigma_space_var = tk.IntVar(value=75)
saturation_var = tk.DoubleVar(value=1.0)
gamma_var = tk.DoubleVar(value=1.0)
clip_limit_var = tk.DoubleVar(value=2.0)
tile_size_var = tk.IntVar(value=8)

create_slider(root_tk, "バイラテラルフィルタ径 d (0-15)", 0, 15, 1, bilateral_d_var)
create_slider(root_tk, "色空間シグマ σColor (0-200)", 0, 200, 1, sigma_color_var)
create_slider(root_tk, "座標空間シグマ σSpace (0-200)", 0, 200, 1, sigma_space_var)
create_slider(root_tk, "彩度 (0.0-3.0)", 0.0, 3.0, 0.1, saturation_var)
create_slider(root_tk, "ガンマ (0.1-5.0)", 0.1, 5.0, 0.1, gamma_var)
create_slider(root_tk, "CLAHE クリップリミット (1.0-40.0)", 1.0, 40.0, 1.0, clip_limit_var)
create_slider(root_tk, "CLAHE タイルサイズ (1-32)", 1, 32, 1, tile_size_var)

preset_frame = tk.Frame(root_tk)
preset_frame.pack(pady=5)
load_button = tk.Button(preset_frame, text="設定を読み込み", command=load_preset)
load_button.pack(side=tk.LEFT, padx=5)
save_preset_button = tk.Button(preset_frame, text="設定を保存", command=save_preset)
save_preset_button.pack(side=tk.LEFT, padx=5)
reset_button = tk.Button(preset_frame, text="リセット", command=reset_parameters)
reset_button.pack(side=tk.LEFT, padx=5)

save_button_state = tk.NORMAL if choice != '1' else tk.DISABLED
save_button = tk.Button(root_tk, text="動画をファイルに保存...", command=save_video_file, state=save_button_state)
save_button.pack(pady=5)

exit_button = tk.Button(root_tk, text="終了", command=request_exit)
exit_button.pack(pady=5)

print('\n=== 動画処理開始 ===')
print('操作方法:')
print('  q キー: プログラム終了')
print('  r キー: 動画の先頭に戻る (動画ファイルのみ)')
print('  終了ボタン: GUIからプログラムを終了')
try:
    # ウィンドウタイトルを「左:元画像, 右:処理後」に変更
    MAIN_FUNC_DESC = "画像補正 (左: 元画像, 右: 処理後)"
    while True:
        if exit_requested:
            break

        ret, frame = cap.read()
        if not ret:
            if choice != '1':
                cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
                frame_count = 0
                continue
            else:
                break

        processed_frame, result, current_time = video_frame_processing(frame)
        cv2.imshow(MAIN_FUNC_DESC, processed_frame)
        log_entry = f"{datetime.fromtimestamp(current_time).strftime('%Y-%m-%d %H:%M:%S.%f')[:-3]} {result}" if choice == '1' else f"Frame: {frame_count}, {result}"
        print(log_entry)
        results_log.append(log_entry)
        root_tk.update()

        key = cv2.waitKey(1) & 0xFF
        if key == ord('q'):
            break
        elif key == ord('r') and choice != '1':
            cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
            frame_count = 0
            results_log.clear()
            print("\n--- 動画の先頭に戻りました ---")
finally:
    print('\n=== プログラム終了 ===')
    if cap:
        cap.release()
    cv2.destroyAllWindows()
    if root_tk:
        root_tk.destroy()
    if results_log:
        with open('result.txt', 'w', encoding='utf-8') as f:
            f.write('=== 結果 ===\n')
            f.write(f'処理フレーム数: {frame_count}\n\n')
            f.write('\n'.join(results_log))
        print(f'\n処理結果をresult.txtに保存しました')