低照度化と輝度補正が物体検出モデルの検出結果に与える影響の測定

概要

同一フレームに人工的な照度低下(ガンマ変換。画素値をべき乗して明るさを非線形に変える処理)と輝度補正の組合せを与え、物体検出結果の変化を測定する資料である。COCO(80種類の物体に注釈が付与された画像データセット)で学習済みのYOLO26nを照度3水準×補正5条件の計15条件に適用し、15条件の検出画像と測定値を5列×3行の格子に同時表示するプログラムの手順・コード・考察の視点をまとめる。

目次

1. 目的と前提

目的は、同一フレームに人工的な照度低下(ガンマ変換)と輝度補正の組合せを与え、物体検出結果の変化を測定することである。実装は、COCO学習済みYOLO26nを、照度3水準×補正5条件の計15条件に適用し、15条件の検出画像と測定値を5列×3行の格子に同時表示するPython/OpenCV/tkinterプログラムである。

15条件は1枚のフレームから生成され、5本の補正列は同一の劣化画像を共有する。したがって列間の差は補正手法のみに、行間の差は照度のみに帰属する。15条件は常に同時に表示され、切り替えによって隠れるものはない。

補正条件この列が担う問い
第1列補正なし照度低下そのものが検出結果に与える影響はどれだけか。全列共通の対照条件である。
第2列CLAHE(コントラスト制限付き適応ヒストグラム均等化)局所的なコントラスト強調で検出結果は回復するか。
第3列AGCWD(重み付け分布付き適応ガンマ補正)補正の強さを画像統計から自動決定する手法は、照度低下に追随できるか。
第4列AGCWD→CLAHE大域的トーン補正に局所コントラスト強調を重ねると、効果は加算的か。
第5列LIME型照明マップ推定(LIMEは低照度画像の補正手法の名称)CLAHEに依存しない補正経路で、どこまで回復するか。

行は上から、劣化ガンマ指数1.00(基準条件、元フレームと一致)、中段の値、下段の値である。

夜間の監視カメラ、悪条件下の自動運転、低照度の検査画像など、実環境では照度が保証されない場合がある。学習済みモデルがどの照度で機能を失い、前処理でどこまで取り戻せるかを数値で把握することは、実用システムにおける安全余裕の設定根拠となる。

2. 出力データ

出力は、15マスの格子画像、コンソールへの測定値、CSVファイル(measurement_log.csv、1条件1行)である。各条件について次の量を測る。

3. 手法候補の一覧

照度の変化(入力側の要因)と輝度・コントラストの補正(前処理側の要因)が、物体検出モデル(測定器としての要因)の出力に与える影響を測る。したがって候補は、輝度・コントラストを操作する手法と、検出を担うモデルの両方から選定する。

No.手法名発表分類概要
1ヒストグラム均等化(HE)古典的手法大域的な輝度変換画像全体の輝度ヒストグラムを累積分布関数(ある値以下の画素が全体に占める割合を表す関数)によって写像し、階調の分布を一様に近づける。変換が画像全体で共通のため、画面の一部だけが暗い場合には、その部分の階調が広がらない。
2ガンマ変換古典的手法画素単位のべき乗変換正規化した画素値をべき乗して輝度を非線形に変換する。指数の与え方によって明るくも暗くもできる。単調増加の変換であるため画素値の大小関係は保たれる。256要素のルックアップテーブル(入力値と出力値の対応表)で計算できる。
3CLAHE(コントラスト制限付き適応ヒストグラム均等化)Zuiderveld, Graphics Gems IV, 1994局所的なヒストグラム変換画像を格子状のタイルに分割し、タイルごとにヒストグラム均等化を行う。クリップ制限値によってヒストグラムの高さを切り詰め、雑音の増幅量を制限する。タイル境界は双一次補間(周囲4点の値から線形に内挿する補間)で接続する。
4AGCWD(重み付け分布付き適応ガンマ補正)Huang ら, IEEE TIP, 2013大域的な統計依存トーン変換入力画像の輝度確率密度関数(各輝度値の出現割合を表す分布)を指数αで重み付けし、その累積分布 cdfw(l) から階調ごとのガンマ指数 γ(l)=1−cdfw(l) を定める。補正の強さを人が与える必要がない。
5MSRCR(色復元付きマルチスケールRetinex)Jobson ら, IEEE TIP, 1997Retinex理論に基づく分解Retinex理論(画像を照明成分と反射成分の積とみなす考え方)に基づき、広がりの異なる複数のガウシアン(ガウス関数の形をした平滑化フィルタ)で照明成分を推定し、反射成分を取り出す。色復元の項によって色の偏りを補正する。スケールと係数の設定を要する。
6LIMEGuo ら, IEEE TIP, 2017照明マップの推定各画素のRGB3成分の最大値から照明マップの初期値を求め、構造を保存する正則化(解が滑らかになるように制約を加える操作)によって平滑化したうえで、これを用いて除算し明るさを補正する。学習を行わず、最適化計算のみで動作する。
7ガイデッドフィルタHe ら, IEEE TPAMI, 2013構造保存平滑化局所窓ごとに案内画像(平滑化の手がかりとして与える画像)との線形関係を最小二乗法で当てはめ、その係数を平均して出力する。エッジを保存したまま平滑化できる。No.6の照明マップの平滑化に利用できる。
8Zero-DCE(AIモデル:DCE-Net)Guo ら, CVPR, 2020深層学習(正解画像を用いない学習)畳み込みニューラルネットワークが画素ごとの輝度調整曲線の係数を推定する。明るい画像と暗い画像の対を用いず、露出や色の恒常性(照明が変わっても物体の色の見えが保たれる性質)に関する制約を損失関数として学習する。推定した曲線を反復適用して補正する。
9Retinexformer(AIモデル)Cai ら, ICCV, 2023深層学習(Transformer)Retinex理論に基づく一段構成の枠組みに、照明を手がかりとする自己注意機構(入力要素どうしの関連の強さを計算して重み付けする仕組み)を組み合わせる。低照度画像復元のベンチマークで高い復元品質が報告されている。
10CoLIE(AIモデル)Chobola ら, ECCV, 2024深層学習(陰的神経表現)画像座標から輝度成分への写像を陰的神経表現(座標を入力として値を出力する小規模ネットワーク)で表し、画像1枚ごとに最適化する。学習済みデータセットへの依存が小さい一方、画像ごとに最適化計算を要する。
11YOLO26(AIモデル:yolo26nを含むn/s/m/l/xの5規模)Ultralytics, 2026(arXiv:2606.03748)NMSを用いない一段物体検出モデル既定の一対一ヘッド(1つの物体に検出枠を1つだけ対応づける出力部)により、NMS(非最大値抑制。重なり合う検出枠から代表を1つ残す後処理)を用いずに最大300個の検出を直接出力する。yolo26nのCOCO上のmAP50-95(物体検出の代表的な精度指標。IoUのしきい値を0.50から0.95まで変えて求めた検出精度の平均)は、既定の一対一ヘッドで40.1、NMSを併用する一対多ヘッドで40.9と報告されている。
12RT-DETR(AIモデル:rtdetr-l、rtdetr-x)Zhao ら(Baidu), CVPR, 2024Transformer型の物体検出モデルTransformer(自己注意機構を用いた深層学習の構造)に基づくDETR系(Transformerで物体検出を行うモデルの系列)の実時間検出器である。畳み込みによるバックボーン(画像から特徴を抽出する主要部)、ハイブリッドエンコーダ、IoUを考慮したクエリ選択(検出候補の絞り込み方)を用い、NMSを用いずに検出する。学習済み重みは規模lとxが配布されている。

4. コードで実装されている手法

本プログラムが実装しているのは、上表のNo.2(ガンマ変換)、No.3(CLAHE)、No.4(AGCWD)、No.6とNo.7の組合せ(LIME型照明マップ推定+ガイデッドフィルタ)、No.11(YOLO26、規模はn)である。役割は次のように分かれている。

4.1 他の候補手法と比べた特徴

4.2 使用しているAIモデルの所在

4.3 注意点(手法としての説明と、実装であるコードとの違い)

4.4 技術の限界

5. プログラムの概要

6. プログラム実行手順

Python 3.12 のインストール

Pythonのインストールを行い、Pythonのプログラムを実行する環境を整える。扱う環境は、Windows搭載パソコンである。金子研究室では、Python 3.12.10を推奨する。

[Windows での Python 3.12 のインストール手順を見るには、ここをクリック]

Windows での Python 3.12 のインストール

以下のいずれかの方法でPython 3.12をインストールする。Pythonがインストール済みの場合、この手順は不要である。

方法 1:winget によるインストール

インストールコマンドの実行方法

管理者権限コマンドプロンプトを起動する(手順:Windowsキーまたはスタートメニュー → cmd と入力 → 右クリック → 「管理者として実行」)。そして、コマンド全体をコマンドプロンプトにコピー&ペーストする。

--scope machine を指定することで、システム全体(全ユーザー向け)にインストールされる。このオプションの実行には管理者権限が必要である。インストール完了後、コマンドプロンプトを再起動するとPATHが反映される。

REM Python 3.12 をシステム領域にインストール
winget install --id Python.Python.3.12 -e --scope machine --silent --accept-source-agreements --accept-package-agreements --override "/quiet InstallAllUsers=1 PrependPath=1 Include_test=0 Include_pip=1 Include_launcher=1 InstallLauncherAllUsers=1 TargetDir=\"C:\Program Files\Python312\""

REM Python と Scripts を PATH 先頭に追加
powershell -NoProfile -Command "$p='C:\Program Files\Python312'; $s=\"$p\Scripts\"; $c=[Environment]::GetEnvironmentVariable('Path','Machine'); if((Test-Path $p) -and (';'+$c+';' -notlike \"*;$p;*\") -and (';'+$c+';' -notlike \"*;$s;*\")){[Environment]::SetEnvironmentVariable('Path',\"$p;$s;$c\",'Machine')}"

方法 2:インストーラーによるインストール

  1. Python公式サイト(https://www.python.org/downloads/)にアクセスし、「Download Python 3.x.x」ボタンからWindows用インストーラーをダウンロードする。
  2. ダウンロードしたインストーラーを実行する。
  3. 初期画面の下部に表示される「Add python.exe to PATH」にチェックを入れてから「Customize installation」を選択する。このチェックを入れ忘れると、コマンドプロンプトから python コマンドを実行できない。
  4. 「Install Python 3.xx for all users」にチェックを入れ、「Install」をクリックする。

インストールの確認

コマンドプロンプトで以下を実行する。

python --version

バージョン番号(例:Python 3.12.x)が表示されればインストール成功である。「'python' は、内部コマンドまたは外部コマンドとして認識されていません。」と表示される場合は、インストールが正常に完了していない。

Build Tools・CUDA Toolkit・PyTorch のインストール

本章では、C++ ビルドツール、NVIDIA CUDA Toolkit、PyTorch のインストールを行い、GPU を活用した機械学習プログラムを実行する環境を整える。扱う環境は、Windows 搭載パソコンである。

[Build Tools・CUDA Toolkit・PyTorch のインストール手順を見るには、ここをクリック]

Windows での Build Tools for Visual Studio 2026 のインストール

Build Tools for Visual Studio 2026 は、C++ ソースコードを Windows 用バイナリにコンパイルするための開発ツール群である。unsloth 等の一部 Python パッケージは、インストール時に C++ コードのビルドを必要とするため、これらのツールが必須となる。

以下のコマンドは、Build Tools が未インストールの場合は winget で新規インストールし、インストール済みの場合は setup.exe modify でコンポーネントを追加する(バージョンは変更しない)。

インストールコマンドの実行方法

管理者権限コマンドプロンプトを起動する(手順:Windows キーまたはスタートメニュー → cmd と入力 → 右クリック → 「管理者として実行」)。そして、コマンド全体をコマンドプロンプトにコピー&ペーストする。

REM VC++ ランタイム
winget install --scope machine --id Microsoft.VCRedist.2015+.x64 -e --silent --disable-interactivity --force --accept-source-agreements --accept-package-agreements --override "/quiet /norestart"

REM ============================================================
REM Visual Studio Build Tools + Desktop development with C++
REM (VCTools、MSBuildTools、CMake連携、Clang、Windows 11 SDK)
REM ============================================================
REM 進行中のインストーラーを停止(ロック競合回避)
taskkill /F /IM vs_setup.exe /T >nul 2>&1
taskkill /F /IM vs_installer.exe /T >nul 2>&1
taskkill /F /IM vs_installerservice.exe /T >nul 2>&1

REM 未インストール時: winget で新規インストール
REM インストール済み時: setup.exe modify でコンポーネント追加(バージョンは変更しない)
winget list --id Microsoft.VisualStudio.BuildTools 2>nul | findstr /i "BuildTools" >nul 2>&1
if %ERRORLEVEL% EQU 0 (
    for /f "usebackq delims=" %P in (`"C:\Program Files (x86)\Microsoft Visual Studio\Installer\vswhere.exe" -products Microsoft.VisualStudio.Product.BuildTools -property installationPath`) do start /wait "" "C:\Program Files (x86)\Microsoft Visual Studio\Installer\setup.exe" modify --installPath "%P" --add Microsoft.VisualStudio.Workload.VCTools --add Microsoft.VisualStudio.Workload.MSBuildTools --add Microsoft.VisualStudio.Component.VC.CMake.Project --add Microsoft.VisualStudio.Component.VC.Llvm.Clang --add Microsoft.VisualStudio.Component.VC.Llvm.ClangToolset --add Microsoft.VisualStudio.Component.Windows11SDK.26100 --includeRecommended --quiet --norestart --nocache
) else (
    winget install --scope machine --id Microsoft.VisualStudio.BuildTools -e --silent --disable-interactivity --force --accept-source-agreements --accept-package-agreements --override "--quiet --wait --norestart --nocache --add Microsoft.VisualStudio.Workload.VCTools --includeRecommended --add Microsoft.VisualStudio.Workload.MSBuildTools --add Microsoft.VisualStudio.Component.VC.CMake.Project --add Microsoft.VisualStudio.Component.VC.Llvm.Clang --add Microsoft.VisualStudio.Component.VC.Llvm.ClangToolset --add Microsoft.VisualStudio.Component.Windows11SDK.26100"
)

REM 破損時の修復(任意、動作がおかしくなった場合)
REM "C:\Program Files (x86)\Microsoft Visual Studio\Installer\setup.exe" repair --installPath "C:\Program Files (x86)\Microsoft Visual Studio\18\BuildTools" --quiet --norestart

REM 導入確認(インストールパスが表示されれば正常)
"C:\Program Files (x86)\Microsoft Visual Studio\Installer\vswhere.exe" -products * -requires Microsoft.VisualStudio.Workload.VCTools -property installationPath

上記のコマンドでは、Build Tools 本体と Visual C++ 再頒布可能パッケージをインストールし、続いて以下のコンポーネントを追加している。

追加のコンポーネントが必要になった場合は Visual Studio Installer で個別にインストールできる。

Windows での NVIDIA CUDA Toolkit のインストール

NVIDIA CUDA Toolkit は、NVIDIA GPU 上で計算を行うためのコンパイラ・ライブラリ群である。PyTorch や vLLM 等が GPU を利用するために必要となる。GPU を使用しない場合、この手順は不要である。

前提条件:NVIDIA GPU、NVIDIA ドライバ、Build Tools for Visual Studio もしくは Visual Studio が必要である。

インストール中の注意:他のウインドウは閉じておくこと。

インストールコマンドの実行方法

管理者権限コマンドプロンプトを起動する(手順:Windows キーまたはスタートメニュー → cmd と入力 → 右クリック → 「管理者として実行」)。そして、コマンド全体をコマンドプロンプトにコピー&ペーストする。

REM NVIDIA CUDA Toolkit 12.8 をシステム領域にインストール
winget install --scope machine --id Nvidia.CUDA --version 12.8 -e --silent --disable-interactivity --force --uninstall-previous --accept-source-agreements --accept-package-agreements --override "-s -n"

REM 環境変数TEMP, TMPの設定(一時ファイルの保存先を短いパスに変更)
mkdir C:\TEMP
setx TEMP "C:\TEMP" /M
setx TMP "C:\TEMP" /M

環境変数 TEMP および TMP を C:\TEMP に変更しているのは、後続のインストール処理で長いパス名や空白を含むパス名がエラーの原因となる場合があるためである。

Windows での PyTorch のインストール

https://pytorch.org のインストールガイドに従い、自環境の CUDA バージョンに対応したコマンドを取得して実行する。CUDA バージョンは以下で確認できる。

nvcc --version

Python 3.12、CUDA 12.6 以上の場合は、管理者権限コマンドプロンプトを起動し、以下を実行する。cu128 は CUDA 12.8 用のタグである。CUDA バージョンが異なる場合は、上記公式サイトで該当するタグを確認し、URL 末尾の cu128 を置き換えること。

pip install --no-user -U numpy torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu128

以降の章では、必要に応じて題材に応じた必要なソフトウェアを追加する。

Python の開発環境 Visual Studio Code のインストールと Python 用の設定

Python の開発環境Visual Studio Code(プログラムを編集するソフトウェア。以下、VS Code)を整える。

[Windows での Visual Studio Code のインストールと Python 用の設定手順を見るには、ここをクリック]

Windows での Visual Studio Code のインストールと Python 用の設定手順

1. VS Code と拡張機能のインストール

以下のコマンドにより,既存の VS Code を削除し,全ユーザー共有の設定で再インストールしたうえで,拡張機能(VS Code に機能を追加するソフトウェア)をまとめて導入する.

インストールコマンドの実行方法

管理者権限コマンドプロンプトを起動する(手順:Windows キーまたはスタートメニュー → cmd と入力 → 右クリック → 「管理者として実行」)。そして,コマンド全体をコマンドプロンプトにコピー&ペーストする。

インストールコマンド


REM ============================================================
REM Microsoft Visual Studio Code
REM ============================================================
winget uninstall -e --id Microsoft.VisualStudioCode --silent --disable-interactivity --accept-source-agreements
rmdir /s /q C:\ProgramData\vscode-extensions 2>nul
rmdir /s /q "%APPDATA%\Code" 2>nul
rmdir /s /q "%USERPROFILE%\.vscode" 2>nul
rmdir /s /q "%LOCALAPPDATA%\Microsoft\vscode-update" 2>nul

REM VS Code をシステム領域に新規インストール
winget install --scope machine --id Microsoft.VisualStudioCode -e --silent --accept-source-agreements --accept-package-agreements

REM 全ユーザー共有の拡張機能フォルダ
mkdir C:\ProgramData\vscode-extensions 2>nul
icacls "C:\ProgramData\vscode-extensions" /grant "Everyone:(OI)(CI)M" /T

REM スタートメニューのショートカットを --extensions-dir 付きで再作成
rmdir /s /q "C:\ProgramData\Microsoft\Windows\Start Menu\Programs\Visual Studio Code" 2>nul
del "C:\ProgramData\Microsoft\Windows\Start Menu\Programs\Visual Studio Code.lnk" 2>nul
powershell -NoProfile -Command "$s=New-Object -ComObject WScript.Shell; $lnk=$s.CreateShortcut('C:\ProgramData\Microsoft\Windows\Start Menu\Programs\Visual Studio Code.lnk'); $lnk.TargetPath='C:\Program Files\Microsoft VS Code\Code.exe'; $lnk.Arguments='--extensions-dir \"C:\ProgramData\vscode-extensions\"'; $lnk.Save()"
REM ショートカットの検証
powershell -NoProfile -Command "$s=New-Object -ComObject WScript.Shell; $lnk=$s.CreateShortcut('C:\ProgramData\Microsoft\Windows\Start Menu\Programs\Visual Studio Code.lnk'); Write-Host 'TargetPath:' $lnk.TargetPath; Write-Host 'Arguments:' $lnk.Arguments"

REM ファイル / フォルダ右クリックの「Code で開く」を登録
reg add "HKLM\SOFTWARE\Classes\*\shell\VSCode\command" /ve /d "\"C:\Program Files\Microsoft VS Code\Code.exe\" --extensions-dir \"C:\ProgramData\vscode-extensions\" \"%1\"" /f
reg add "HKLM\SOFTWARE\Classes\Directory\shell\VSCode\command" /ve /d "\"C:\Program Files\Microsoft VS Code\Code.exe\" --extensions-dir \"C:\ProgramData\vscode-extensions\" \"%1\"" /f
reg add "HKLM\SOFTWARE\Classes\Directory\Background\shell\VSCode\command" /ve /d "\"C:\Program Files\Microsoft VS Code\Code.exe\" --extensions-dir \"C:\ProgramData\vscode-extensions\" \"%V\"" /f

REM --extensions-dir 付きで起動する code.cmd ラッパを作成
REM (%* を echo で書くと対話的 cmd で失われるため、PowerShell で [char]37+'*' を書き出す)
powershell -NoProfile -Command "$pct=[char]37; $q=[char]34; $c='@echo off'+[char]13+[char]10+$q+'C:\Program Files\Microsoft VS Code\bin\code.cmd'+$q+' --extensions-dir '+$q+'C:\ProgramData\vscode-extensions'+$q+' '+$pct+'*'+[char]13+[char]10; [IO.File]::WriteAllText('C:\ProgramData\vscode-extensions\vscode.cmd',$c,[Text.Encoding]::ASCII)"

REM 拡張機能のインストール
set "CODE=C:\Program Files\Microsoft VS Code\bin\code.cmd"
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --uninstall-extension GitHub.copilot
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --uninstall-extension GitHub.copilot-chat
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --install-extension ms-python.python
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --install-extension ms-python.vscode-pylance
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --install-extension ms-python.debugpy
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --install-extension MS-CEINTL.vscode-language-pack-ja
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --install-extension saoudrizwan.claude-dev
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --install-extension rust-lang.rust-analyzer
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --install-extension tamasfe.even-better-toml
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --install-extension anthropic.claude-code
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --install-extension almenon.arepl
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --list-extensions --show-versions
echo === セットアップ完了 ===

2. Python インタプリタの選択

同一マシンに複数の Python がインストールされている場合,VS Code で使用する Python 本体(インタプリタ:Python プログラムを解釈・実行するソフトウェア)を選択する必要がある.

  1. コマンドパレット(コマンド名で機能を呼び出す VS Code の入力欄)を開く(Ctrl+Shift+P
  2. Python: Select Interpreter と入力する
  3. 表示される一覧から,使用する Python(例:C:\Program Files\Python312\python.exe)を選択する.

Python プログラム実行手順

[Windows での Python プログラム実行手順を見るには、ここをクリック]

Windows での Python 実行手順(Visual Studio Codeを使用)

プログラムファイルの作成と保存

  1. 左サイドバーの「エクスプローラー」アイコン(Ctrl+Shift+E)をクリックする
  2. 「NO FOLDER OPENED」(作業対象フォルダが未選択の状態)と表示される場合は,「Open Folder」をクリックし,プログラムを保存するフォルダを選択する

    続いて「フォルダを信用するか」を確認する画面(フォルダ内のコードを実行してよいか確認する VS Code の仕組み)が表示されるので,チェックして Yes を選択する

  3. フォルダ名の右側に表示される「新しいファイル」アイコンをクリックする
  4. ファイル名(例:aitask.py.ファイル名は何でも良い)を入力し Enter を押す.拡張子は .py(Python ファイルを示す拡張子)とする
  5. 実行したいコードを選択し,Ctrl+C でコピーする.VS Code のエディタ領域に Ctrl+V で貼り付ける
  6. Ctrl+S で保存する

プログラムの実行

  1. エディタ右上の三角形「▷」アイコン(Run Python File:現在開いている Python ファイルを実行するボタン)をクリックする.または,エディタ上で右クリックし「ターミナルで Python ファイルを実行」を選択する
  2. VS Code 下部のターミナル(コマンドの入出力を表示する画面)に,実行結果(print 関数の出力等)が表示される
  3. tkinter(Python 標準の GUI ライブラリ)のファイル選択ダイアログを使うプログラムを実行した場合は,ダイアログが開くので対象画像を選択する
  4. VS Code 下部のターミナルで実行結果を確認する.OpenCV ウィンドウ(OpenCV が画像を表示するために開く専用ウィンドウ)が開いた場合はそちらも確認する.OpenCV ウィンドウは,マウスクリックでウィンドウをアクティブ(操作対象の状態)にしてからキーを押すと終了する

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

  1. 管理者権限コマンドプロンプトを起動する (手順:Windowsキーまたはスタートメニュー → cmd と入力 → 右クリック → 「管理者として実行」)。
  2. 次のコマンドを実行し、関連ライブラリを導入または更新する。YOLO26に対応したバージョンが必要であり、対応していないバージョンではyolo26n.ptを解決できない。
    pip install -U --no-user ultralytics
    

実行手順

  1. 下記のプログラムをprogram.pyなどの名前で保存し、実行する。初回実行時は学習済み重みの自動ダウンロードが行われるため、インターネット接続が必要である。進捗はコンソールに表示される。
  2. ウィンドウが開いたら、「1. 映像入力源」でカメラデバイスまたは動画ファイルを選ぶ。カメラが1台の場合、デバイス番号は0である。
  3. 他のパラメータは初期値のままでよい。値の変更は開始後にも行える。
  4. 「撮影開始」を押す。カメラを使う場合は、COCOの80クラスに含まれる物体(person、chair、backpack、bottle、laptopなど)が画面に入るようにカメラを向ける。
  5. 左上のマス(R1C1)で目的の物体が検出されていることを確認する。これは元フレームそのものであり、参照集合を与える基準条件である。
  6. 「静止フレーム保持」にチェックを入れる。以降は同じフレームが繰り返し処理されるため、被写体の動きを排除してパラメータだけを変えられる。
  7. 第1列を上から下へ見て、劣化ガンマ指数の低下に伴う検出数と参照再現率の変化を追う。
  8. 同じ行を左から右へ見て、5つの補正条件による差を確認する。
  9. 「撮影停止」で停止する。ウィンドウを閉じると終了する。

処理が重い場合はimgszを320に下げると1フレームあたりの処理時間が短くなる。ただしimgszは検出結果そのものに影響するため、1回の観察の中では固定する。

7. GUIで設定可能なパラメータ

最上行の劣化ガンマ指数は基準条件として1.00に固定している。それ以外はすべて画面上で変更でき、処理中の変更は次のフレームから反映される。

No.名称コード上の識別子初期値範囲(刻み)説明
1映像入力源の種別source_variableカメラデバイスカメラデバイス/動画ファイル処理対象の映像の取得元である。動画ファイルを選ぶ場合は、あらかじめ「ファイルを選択」ボタンでファイルを指定する。
2カメラデバイス番号camera_index_variable00〜3(1)OpenCVがカメラを識別する番号である。カメラが1台のときは0を指定する。
3劣化ガンマ指数(中段の行)gamma_mid_variable0.600.10〜1.00(0.05)中段の行に与える照度低下の強さである。1.00で元フレームと一致し、値を下げるほど暗くなる。
4劣化ガンマ指数(下段の行)gamma_low_variable0.300.10〜1.00(0.05)下段の行に与える照度低下の強さである。中段より小さい値を与えると、上段から下段へ向かって単調に暗くなる。
5YOLO26n 確信度しきい値 confconf_variable0.250.05〜0.90(0.05)検出として採用する確信度の下限である。下げると検出数と誤検出がともに増える。条件間の比較を成立させるため、1回の観察の中では固定する。
6YOLO26n 推論入力の一辺 imgsz(画素)imgsz_variable640320〜960(32)推論の際に画像を調整する一辺の画素数である。小さくすると1フレームの処理時間が短くなる一方、画面内で小さく写る物体の検出が難しくなる。刻みを32としているのは、モデルの構造上、入力の一辺が32の倍数である必要があるためである。
7参照集合との一致判定 IoUしきい値iou_variable0.500.30〜0.90(0.05)基準条件の検出と当該条件の検出を同一物体とみなすためのIoUの下限である。上げるほど枠の位置ずれに厳しくなり、参照再現率と参照適合率が下がる。
8CLAHE クリップ制限値 clipLimitclip_limit_variable2.00.5〜8.0(0.5)タイルごとのヒストグラムを切り詰める高さである。大きくするとコントラストの拡大量が増え、同時に暗部の雑音の増幅量も増える。第2列と第4列に適用される。
9CLAHE タイル分割数(縦横共通)tile_grid_variable82〜16(1)局所ヒストグラムを計算する格子の分割数である。大きくすると1タイルが小さくなり、局所的な明暗差に追随する度合いが増す。第2列と第4列に適用される。
10AGCWD 重み付け指数 αagcwd_alpha_variable0.500.10〜1.00(0.05)輝度確率密度関数を重み付けする際のべき指数である。1.00に近いほど元の分布に近く、小さいほど分布が平坦化される。原論文の推奨値は0.5である。第3列と第4列に適用される。
11照明マップ調整指数 γLlime_gamma_variable0.800.40〜1.00(0.05)推定した照明マップTに対して T←TγL を施す際の指数である。1.00未満にすると照明マップの値が大きくなるため除算による増幅が抑えられる。第5列に適用される。
12ガイデッドフィルタ半径 r(画素)lime_radius_variable153〜45(2)照明マップを構造保存平滑化する際の局所窓の半径である。大きくすると照明マップが滑らかになり、物体の輪郭が照明成分に混入しにくくなる一方、細かな照明むらへの追随が鈍る。正則化パラメータεはコード中の定数(0.01)である。第5列に適用される。
13コンソールへの出力console_output_variable有効有効/無効記録対象フレームの測定値をコンソールへ出力する。
14CSVファイルへの記録csv_output_variable無効有効/無効有効にすると、プログラムファイルと同じディレクトリのmeasurement_log.csvへ1条件1行で追記する。文字コードはUTF-8(BOM付き。BOMはファイル先頭に置く文字コード識別用の印)であり、表計算ソフトでそのまま開ける。
15記録間隔(フレーム)record_interval_variable201〜60(1)記録を行う間隔である。フレーム番号がこの値の倍数になったときに記録する。
16静止フレーム保持hold_frame_variable無効有効/無効有効にすると、新しいフレームを取得せず、直前に取得したフレームを繰り返し処理する。被写体の動きを排除してパラメータの効果だけを観察するための機能である。

8. プログラムコード

"""
低照度化と輝度補正が物体検出モデルの検出結果に与える影響の測定
照度条件3水準 × 輝度補正条件5水準(計15条件)を同一フレームから生成し、
同一の物体検出モデルで一括測定する。15条件は常に同時に表示される。

使用するAIモデル
    名称   : Ultralytics YOLO26n(物体検出モデル)
    実装   : https://github.com/ultralytics/ultralytics
    学習済み重み : yolo26n.pt
    学習データセット : COCO

併用する画像処理
    ガンマ変換、CLAHE、AGCWD、LIME型照明マップ推定、ガイデッドフィルタ
"""

import csv
import os
import time
import tkinter as tk
from collections import defaultdict, deque
from tkinter import filedialog, ttk

import cv2
import numpy as np
from PIL import Image, ImageDraw, ImageFont, ImageTk
from ultralytics import YOLO

# ----------------------------------------------------------------------
# 定数
# ----------------------------------------------------------------------
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
MODEL_PATH = os.path.join(BASE_DIR, "yolo26n.pt")
CSV_PATH = os.path.join(BASE_DIR, "measurement_log.csv")
JP_FONT_PATH = r"C:\Windows\Fonts\meiryo.ttc"

PROCESS_WIDTH = 640
CELL_W = 280
CELL_H = 200
OVERLAY_FONT_SIZE = 10
GUIDED_FILTER_EPSILON = 0.01
UNDEFINED_MARK = "-"

METHOD_NONE = "補正なし"
METHOD_CLAHE = "CLAHE"
METHOD_AGCWD = "AGCWD"
METHOD_AGCWD_CLAHE = "AGCWD→CLAHE"
METHOD_LIME = "LIME型照明マップ推定"

CSV_HEADER = [
    "frame", "elapsed_second", "degradation_gamma", "correction_method",
    "agcwd_gamma_at_mean_level", "mean_lightness", "std_lightness",
    "detection_count", "mean_confidence",
    "matched_count", "reference_recall", "reference_precision",
    "conf_threshold", "imgsz", "match_iou_threshold",
    "clahe_clip_limit", "clahe_tile_grid", "agcwd_alpha",
    "lime_illumination_gamma", "lime_guided_radius", "class_detail",
]

# 学習済み重みの読み込み
model = YOLO(MODEL_PATH)

_font_cache = {}


def japanese_font(size):
    """日本語フォントを取得する。同じ大きさのフォントは再利用する。"""
    if size not in _font_cache:
        _font_cache[size] = ImageFont.truetype(JP_FONT_PATH, size)
    return _font_cache[size]


# ----------------------------------------------------------------------
# 劣化処理
# ----------------------------------------------------------------------
def normalize_width(image_bgr, width=PROCESS_WIDTH):
    """縦横比を保ったまま指定幅に正規化する。"""
    source_h, source_w = image_bgr.shape[:2]
    if source_w == width:
        return image_bgr
    height = max(1, int(round(source_h * width / source_w)))
    return cv2.resize(image_bgr, (width, height), interpolation=cv2.INTER_AREA)


def build_gamma_lut(exponent):
    """
    べき乗変換のルックアップテーブルを作る。
    exponent = 1.0 のときは全256階調で恒等変換になる。
    """
    values = (np.arange(256, dtype=np.float64) / 255.0) ** float(exponent) * 255.0
    return np.rint(np.clip(values, 0.0, 255.0)).astype(np.uint8)


def apply_gamma_darkening(image_bgr, gamma):
    """
    照度低下の模擬。
    gamma = 1.00 で恒等変換、gamma < 1.00 で暗くなる。
    """
    return cv2.LUT(image_bgr, build_gamma_lut(1.0 / float(gamma)))


# ----------------------------------------------------------------------
# 輝度補正処理
# ----------------------------------------------------------------------
def apply_clahe(image_bgr, clip_limit, tile_grid):
    """
    CLAHE。
    色相の変化を避けるため、CIELAB色空間のL*成分にのみ適用する。
    """
    lab = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2LAB)
    lightness, chroma_a, chroma_b = cv2.split(lab)
    clahe = cv2.createCLAHE(
        clipLimit=float(clip_limit),
        tileGridSize=(int(tile_grid), int(tile_grid))
    )
    merged = cv2.merge((clahe.apply(lightness), chroma_a, chroma_b))
    return cv2.cvtColor(merged, cv2.COLOR_LAB2BGR)


def apply_agcwd(image_bgr, alpha):
    """
    AGCWD(重み付け分布付き適応ガンマ補正)。

    注意:
    輝度階調が1種類しかない画像では、重み付きCDF(累積分布関数)が段関数になり、
    非ゼロ画素が255へ写る。これはコントラスト情報を増やす処理ではないため、
    この場合は恒等変換として扱う。
    """
    hsv = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2HSV)
    value = hsv[:, :, 2].copy()

    histogram = cv2.calcHist([value], [0], None, [256], [0, 256]).ravel()
    total = float(histogram.sum())

    if total <= 0.0:
        return image_bgr.copy(), 1.0

    # 輝度値が1種類だけの場合は、AGCWDの変換をかけずに恒等変換にする。
    if np.count_nonzero(histogram) <= 1:
        return image_bgr.copy(), 1.0

    pdf = histogram / total
    pdf_max = float(pdf.max())
    pdf_min = float(pdf.min())

    # 全階調の出現確率が同一の場合、重み付け式の分母が0になる。
    # その場合も、分布はすでに平坦であるため恒等変換にする。
    if pdf_max - pdf_min < 1e-12:
        return image_bgr.copy(), 1.0

    weighted_pdf = pdf_max * (((pdf - pdf_min) / (pdf_max - pdf_min)) ** float(alpha))
    weighted_cdf = np.cumsum(weighted_pdf) / float(weighted_pdf.sum())
    exponent = 1.0 - weighted_cdf

    levels = np.arange(256, dtype=np.float64) / 255.0
    table = np.rint(np.clip((levels ** exponent) * 255.0, 0.0, 255.0)).astype(np.uint8)
    table[0] = 0

    hsv[:, :, 2] = cv2.LUT(value, table)

    mean_level = int(np.clip(round(float(value.mean())), 0, 255))
    return cv2.cvtColor(hsv, cv2.COLOR_HSV2BGR), float(exponent[mean_level])


def guided_filter(guide, target, radius, epsilon):
    """
    ガイデッドフィルタ。
    """
    kernel = (2 * int(radius) + 1, 2 * int(radius) + 1)

    mean_guide = cv2.boxFilter(guide, cv2.CV_32F, kernel)
    mean_target = cv2.boxFilter(target, cv2.CV_32F, kernel)
    mean_guide_guide = cv2.boxFilter(guide * guide, cv2.CV_32F, kernel)
    mean_guide_target = cv2.boxFilter(guide * target, cv2.CV_32F, kernel)

    variance = mean_guide_guide - mean_guide * mean_guide
    covariance = mean_guide_target - mean_guide * mean_target

    coefficient_a = covariance / (variance + float(epsilon))
    coefficient_b = mean_target - coefficient_a * mean_guide

    mean_a = cv2.boxFilter(coefficient_a, cv2.CV_32F, kernel)
    mean_b = cv2.boxFilter(coefficient_b, cv2.CV_32F, kernel)

    return mean_a * guide + mean_b


def apply_lime(image_bgr, illumination_gamma, radius):
    """
    LIME型照明マップ推定の簡略実装。
    """
    image = image_bgr.astype(np.float32) / 255.0

    initial_map = image.max(axis=2)
    refined_map = guided_filter(
        initial_map,
        initial_map,
        radius,
        GUIDED_FILTER_EPSILON
    )

    refined_map = np.clip(refined_map, 1e-3, 1.0) ** float(illumination_gamma)
    corrected = image / refined_map[:, :, None]

    return np.clip(corrected * 255.0, 0.0, 255.0).astype(np.uint8)


def build_correction_set(degraded_bgr, parameters):
    """
    1行分、すなわち同一の劣化画像に対する5条件を作る。
    """
    clahe_image = apply_clahe(
        degraded_bgr,
        parameters["clip_limit"],
        parameters["tile_grid"]
    )

    agcwd_image, agcwd_gamma = apply_agcwd(
        degraded_bgr,
        parameters["agcwd_alpha"]
    )

    agcwd_clahe_image = apply_clahe(
        agcwd_image,
        parameters["clip_limit"],
        parameters["tile_grid"]
    )

    lime_image = apply_lime(
        degraded_bgr,
        parameters["lime_gamma"],
        parameters["lime_radius"]
    )

    return [
        (METHOD_NONE, degraded_bgr, None),
        (METHOD_CLAHE, clahe_image, None),
        (METHOD_AGCWD, agcwd_image, agcwd_gamma),
        (METHOD_AGCWD_CLAHE, agcwd_clahe_image, agcwd_gamma),
        (METHOD_LIME, lime_image, None),
    ]


# ----------------------------------------------------------------------
# 測定量の算出
# ----------------------------------------------------------------------
def lightness_statistics(image_bgr):
    """CIELABの明度L*を0〜100に換算し、平均と標準偏差を返す。"""
    lightness = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2LAB)[:, :, 0].astype(np.float32)
    lightness = lightness * (100.0 / 255.0)
    return float(lightness.mean()), float(lightness.std())


def extract_detections(result):
    """検出結果から矩形、クラス番号、確信度を取り出す。"""
    boxes = result.boxes

    if boxes is None or len(boxes) == 0:
        return {
            "xyxy": np.zeros((0, 4), np.float32),
            "cls": np.zeros((0,), np.int32),
            "conf": np.zeros((0,), np.float32),
        }

    return {
        "xyxy": boxes.xyxy.cpu().numpy().astype(np.float32),
        "cls": boxes.cls.cpu().numpy().astype(np.int32),
        "conf": boxes.conf.cpu().numpy().astype(np.float32),
    }


def intersection_over_union(box, boxes):
    """1つの矩形と複数の矩形とのIoUを返す。"""
    if len(boxes) == 0:
        return np.zeros((0,), np.float32)

    left = np.maximum(box[0], boxes[:, 0])
    top = np.maximum(box[1], boxes[:, 1])
    right = np.minimum(box[2], boxes[:, 2])
    bottom = np.minimum(box[3], boxes[:, 3])

    overlap = np.maximum(0.0, right - left) * np.maximum(0.0, bottom - top)

    area_box = max(0.0, box[2] - box[0]) * max(0.0, box[3] - box[1])
    area_boxes = (
        np.maximum(0.0, boxes[:, 2] - boxes[:, 0])
        * np.maximum(0.0, boxes[:, 3] - boxes[:, 1])
    )

    return overlap / (area_box + area_boxes - overlap + 1e-9)


def count_matches_with_reference(detection, reference, iou_threshold):
    """
    基準条件の検出集合を参照集合として、
    クラス一致かつIoUしきい値以上の検出を1対1対応させる。

    注意:
    IoUの大きい順に貪欲に対応づけると、対応の順序によって
    参照一致数が過小評価される場合がある。
    そこで二部グラフの最大マッチング
    (2つの集合の間で重複なく作れる組の最大数を求める方法)により、
    成立可能な1対1対応数を数える。
    """
    left_count = len(detection["cls"])
    right_count = len(reference["cls"])

    adjacency = [[] for _ in range(left_count)]

    for detection_index in range(left_count):
        candidates = np.where(
            reference["cls"] == detection["cls"][detection_index]
        )[0]

        ious = intersection_over_union(
            detection["xyxy"][detection_index],
            reference["xyxy"][candidates]
        )

        valid_mask = ious >= float(iou_threshold)
        valid_candidates = candidates[valid_mask]
        valid_ious = ious[valid_mask]

        order = np.argsort(-valid_ious)
        adjacency[detection_index] = (
            valid_candidates[order].astype(np.int32).tolist()
        )

    pair_left = np.full(left_count, -1, dtype=np.int32)
    pair_right = np.full(right_count, -1, dtype=np.int32)
    distance = np.zeros(left_count, dtype=np.int32)

    infinity = left_count + right_count + 1

    def bfs():
        queue = deque()
        found_free_reference = False

        for left_index in range(left_count):
            if pair_left[left_index] == -1:
                distance[left_index] = 0
                queue.append(left_index)
            else:
                distance[left_index] = infinity

        while queue:
            left_index = queue.popleft()

            for right_index in adjacency[left_index]:
                next_left = int(pair_right[right_index])

                if next_left == -1:
                    found_free_reference = True
                elif distance[next_left] == infinity:
                    distance[next_left] = distance[left_index] + 1
                    queue.append(next_left)

        return found_free_reference

    def dfs(left_index):
        for right_index in adjacency[left_index]:
            next_left = int(pair_right[right_index])

            if next_left == -1 or (
                distance[next_left] == distance[left_index] + 1
                and dfs(next_left)
            ):
                pair_left[left_index] = right_index
                pair_right[right_index] = left_index
                return True

        distance[left_index] = infinity
        return False

    matched_count = 0

    while bfs():
        for left_index in range(left_count):
            if pair_left[left_index] == -1 and dfs(left_index):
                matched_count += 1

    return int(matched_count)


def summarize_per_class(detection, names):
    """クラスごとの件数と平均確信度を返す。"""
    collected = defaultdict(list)

    for class_id, confidence in zip(detection["cls"], detection["conf"]):
        collected[names[int(class_id)]].append(float(confidence))

    return {
        name: (len(values), sum(values) / len(values))
        for name, values in collected.items()
    }


def format_ratio(numerator, denominator):
    """比率を文字列にする。分母が0の場合は未定義記号を返す。"""
    if denominator == 0:
        return UNDEFINED_MARK
    return f"{numerator / denominator:.2f}"


def format_mean_confidence(detection):
    """平均確信度を文字列にする。検出0件の場合は未定義記号を返す。"""
    if len(detection["conf"]) == 0:
        return UNDEFINED_MARK
    return f"{float(detection['conf'].mean()):.3f}"


# ----------------------------------------------------------------------
# 表示用の描画
# ----------------------------------------------------------------------
def letterbox(image_bgr, width, height):
    """
    縦横比を保ったまま指定サイズの枠内に収め、
    余白を黒で埋めて中央に置く。
    """
    source_h, source_w = image_bgr.shape[:2]

    scale = min(width / source_w, height / source_h)
    new_w = max(1, int(round(source_w * scale)))
    new_h = max(1, int(round(source_h * scale)))

    canvas = np.zeros((height, width, 3), dtype=np.uint8)

    x_offset = (width - new_w) // 2
    y_offset = (height - new_h) // 2

    resized = cv2.resize(image_bgr, (new_w, new_h))
    canvas[y_offset:y_offset + new_h, x_offset:x_offset + new_w] = resized

    return canvas


def draw_text_lines(image_bgr, lines, font_size=OVERLAY_FONT_SIZE):
    """
    日本語文字列をPillowで描画してBGR画像に戻す。
    """
    pil_image = Image.fromarray(cv2.cvtColor(image_bgr, cv2.COLOR_BGR2RGB))
    draw = ImageDraw.Draw(pil_image)
    font = japanese_font(font_size)

    line_height = int(font_size * 1.4)

    draw.rectangle(
        [0, 0, pil_image.width, line_height * len(lines) + 4],
        fill=(0, 0, 0)
    )

    for index, line in enumerate(lines):
        draw.text(
            (4, 2 + index * line_height),
            line,
            font=font,
            fill=(0, 255, 0)
        )

    return cv2.cvtColor(np.array(pil_image), cv2.COLOR_RGB2BGR)


# ----------------------------------------------------------------------
# アプリケーション
# ----------------------------------------------------------------------
class Application:
    def __init__(self, root):
        self.root = root
        self.root.title("低照度化と輝度補正が物体検出モデル(YOLO26n)の検出結果に与える影響の測定")

        self.capture = None
        self.running = False
        self.file_path = ""
        self.frame_index = 0
        self.held_frame = None
        self.photo_image = None
        self.start_time = time.time()

        self.build_upper_panels()
        self.build_lower_panels()
        self.build_result_area()

    # ---- 画面の構築 ---------------------------------------------------
    def build_upper_panels(self):
        container = ttk.Frame(self.root)
        container.grid(row=0, column=0, sticky="ew", padx=8, pady=4)

        source_frame = ttk.LabelFrame(container, text="1. 映像入力源")
        source_frame.grid(row=0, column=0, sticky="nsew", padx=4)

        self.source_variable = tk.StringVar(value="camera")

        ttk.Radiobutton(
            source_frame,
            text="カメラデバイス",
            variable=self.source_variable,
            value="camera"
        ).grid(row=0, column=0, sticky="w", padx=4)

        ttk.Label(source_frame, text="デバイス番号").grid(row=0, column=1, sticky="e")

        self.camera_index_variable = tk.IntVar(value=0)

        ttk.Spinbox(
            source_frame,
            from_=0,
            to=3,
            width=4,
            state="readonly",
            textvariable=self.camera_index_variable
        ).grid(row=0, column=2, sticky="w", padx=4)

        ttk.Radiobutton(
            source_frame,
            text="動画ファイル",
            variable=self.source_variable,
            value="file"
        ).grid(row=1, column=0, sticky="w", padx=4)

        ttk.Button(
            source_frame,
            text="ファイルを選択",
            command=self.choose_file
        ).grid(row=1, column=1, columnspan=2, sticky="w", padx=4)

        self.file_label = ttk.Label(source_frame, text="(未選択)")
        self.file_label.grid(row=2, column=0, columnspan=3, sticky="w", padx=6, pady=2)

        illumination_frame = ttk.LabelFrame(container, text="2. 照度条件(独立変数)")
        illumination_frame.grid(row=0, column=1, sticky="nsew", padx=4)

        self.gamma_mid_variable = tk.DoubleVar(value=0.60)
        self.gamma_low_variable = tk.DoubleVar(value=0.30)

        self.add_scale(
            illumination_frame,
            0,
            0,
            "劣化ガンマ指数(中段の行)",
            self.gamma_mid_variable,
            0.10,
            1.00,
            0.05
        )

        self.add_scale(
            illumination_frame,
            1,
            0,
            "劣化ガンマ指数(下段の行)",
            self.gamma_low_variable,
            0.10,
            1.00,
            0.05
        )

        ttk.Label(
            illumination_frame,
            text="上段は基準条件として 1.00 に固定される"
        ).grid(row=4, column=0, sticky="w", padx=6, pady=2)

        detector_frame = ttk.LabelFrame(container, text="3. 検出器の設定(統制変数)")
        detector_frame.grid(row=0, column=2, sticky="nsew", padx=4)

        self.conf_variable = tk.DoubleVar(value=0.25)
        self.imgsz_variable = tk.IntVar(value=640)
        self.iou_variable = tk.DoubleVar(value=0.50)

        self.add_scale(
            detector_frame,
            0,
            0,
            "YOLO26n 確信度しきい値 conf",
            self.conf_variable,
            0.05,
            0.90,
            0.05
        )

        self.add_scale(
            detector_frame,
            1,
            0,
            "YOLO26n 推論入力の一辺 imgsz(画素)",
            self.imgsz_variable,
            320,
            960,
            32
        )

        self.add_scale(
            detector_frame,
            2,
            0,
            "参照集合との一致判定 IoUしきい値",
            self.iou_variable,
            0.30,
            0.90,
            0.05
        )

    def build_lower_panels(self):
        container = ttk.Frame(self.root)
        container.grid(row=1, column=0, sticky="ew", padx=8, pady=4)

        correction_frame = ttk.LabelFrame(container, text="4. 補正手法の設定(処置)")
        correction_frame.grid(row=0, column=0, sticky="nsew", padx=4)

        self.clip_limit_variable = tk.DoubleVar(value=2.0)
        self.tile_grid_variable = tk.IntVar(value=8)
        self.agcwd_alpha_variable = tk.DoubleVar(value=0.50)
        self.lime_gamma_variable = tk.DoubleVar(value=0.80)
        self.lime_radius_variable = tk.IntVar(value=15)

        self.add_scale(
            correction_frame,
            0,
            0,
            "CLAHE クリップ制限値 clipLimit(第2列・第4列)",
            self.clip_limit_variable,
            0.5,
            8.0,
            0.5
        )

        self.add_scale(
            correction_frame,
            0,
            1,
            "CLAHE タイル分割数(縦横共通、第2列・第4列)",
            self.tile_grid_variable,
            2,
            16,
            1
        )

        self.add_scale(
            correction_frame,
            1,
            0,
            "AGCWD 重み付け指数 α(第3列・第4列)",
            self.agcwd_alpha_variable,
            0.10,
            1.00,
            0.05
        )

        self.add_scale(
            correction_frame,
            1,
            1,
            "照明マップ調整指数 γ_L(第5列)",
            self.lime_gamma_variable,
            0.40,
            1.00,
            0.05
        )

        self.add_scale(
            correction_frame,
            2,
            0,
            "ガイデッドフィルタ半径 r(画素、第5列)",
            self.lime_radius_variable,
            3,
            45,
            2
        )

        record_frame = ttk.LabelFrame(container, text="5. 測定値の記録")
        record_frame.grid(row=0, column=1, sticky="nsew", padx=4)

        self.console_output_variable = tk.BooleanVar(value=True)
        self.csv_output_variable = tk.BooleanVar(value=False)
        self.record_interval_variable = tk.IntVar(value=20)

        ttk.Checkbutton(
            record_frame,
            text="コンソールへ出力する",
            variable=self.console_output_variable
        ).grid(row=0, column=0, sticky="w", padx=6, pady=2)

        ttk.Checkbutton(
            record_frame,
            text="CSVファイルへ記録する",
            variable=self.csv_output_variable
        ).grid(row=1, column=0, sticky="w", padx=6, pady=2)

        ttk.Label(
            record_frame,
            text=f"記録先: {os.path.basename(CSV_PATH)}"
        ).grid(row=2, column=0, sticky="w", padx=6)

        ttk.Label(
            record_frame,
            text="記録間隔(フレーム)"
        ).grid(row=3, column=0, sticky="w", padx=6, pady=2)

        ttk.Spinbox(
            record_frame,
            from_=1,
            to=60,
            width=5,
            state="readonly",
            textvariable=self.record_interval_variable
        ).grid(row=4, column=0, sticky="w", padx=6)

        control_frame = ttk.LabelFrame(container, text="6. 実行制御")
        control_frame.grid(row=0, column=2, sticky="nsew", padx=4)

        self.start_button = ttk.Button(
            control_frame,
            text="撮影開始",
            command=self.start
        )
        self.start_button.grid(row=0, column=0, padx=4, pady=4, sticky="w")

        self.stop_button = ttk.Button(
            control_frame,
            text="撮影停止",
            command=self.stop,
            state="disabled"
        )
        self.stop_button.grid(row=0, column=1, padx=4, pady=4, sticky="w")

        self.hold_frame_variable = tk.BooleanVar(value=False)

        ttk.Checkbutton(
            control_frame,
            text="静止フレーム保持(同一フレームを反復処理する)",
            variable=self.hold_frame_variable
        ).grid(row=1, column=0, columnspan=2, sticky="w", padx=6, pady=2)

        self.status_variable = tk.StringVar(value="待機中")

        ttk.Label(
            control_frame,
            textvariable=self.status_variable,
            wraplength=340
        ).grid(row=2, column=0, columnspan=2, sticky="w", padx=6, pady=4)

    def add_scale(self, parent, row, column, text, variable, minimum, maximum, step):
        ttk.Label(parent, text=text).grid(row=row * 2, column=column, sticky="w", padx=6)

        tk.Scale(
            parent,
            from_=minimum,
            to=maximum,
            resolution=step,
            orient="horizontal",
            length=300,
            variable=variable
        ).grid(row=row * 2 + 1, column=column, sticky="w", padx=6)

    def build_result_area(self):
        frame = ttk.LabelFrame(self.root, text="7. 測定結果(15条件を同時表示)")
        frame.grid(row=2, column=0, sticky="nsew", padx=8, pady=6)

        ttk.Label(
            frame,
            text=(
                "列(左から):補正なし/CLAHE/AGCWD/AGCWD→CLAHE/"
                "LIME型照明マップ推定  "
                "行(上から):劣化ガンマ指数 1.00(基準条件)/中段/下段"
            )
        ).grid(row=0, column=0, sticky="w", padx=4, pady=2)

        self.image_label = ttk.Label(frame)
        self.image_label.grid(row=1, column=0)

        self.show(np.zeros((CELL_H * 3, CELL_W * 5, 3), dtype=np.uint8))

    # ---- 操作 ---------------------------------------------------------
    def choose_file(self):
        path = filedialog.askopenfilename(
            title="動画ファイルを選択してください",
            filetypes=[("動画ファイル", "*.mp4 *.avi *.mov *.mkv *.wmv")],
        )

        if path == "":
            return

        self.file_path = path
        self.file_label.configure(text=os.path.basename(path))
        self.source_variable.set("file")

    def start(self):
        if self.source_variable.get() == "camera":
            self.capture = cv2.VideoCapture(
                self.camera_index_variable.get(),
                cv2.CAP_DSHOW
            )
        elif self.file_path == "":
            self.status_variable.set("動画ファイルが選択されていない")
            return
        else:
            self.capture = cv2.VideoCapture(self.file_path)

        if not self.capture.isOpened():
            self.capture.release()
            self.capture = None
            self.status_variable.set("映像入力源を開けない。デバイス番号またはファイルを確認する")
            return

        self.frame_index = 0
        self.held_frame = None
        self.start_time = time.time()
        self.running = True

        self.start_button.configure(state="disabled")
        self.stop_button.configure(state="normal")
        self.status_variable.set("処理中")

        self.update_frame()

    def stop(self):
        self.running = False

        if self.capture is not None:
            self.capture.release()
            self.capture = None

        self.start_button.configure(state="normal")
        self.stop_button.configure(state="disabled")
        self.status_variable.set("停止")

    def close(self):
        self.running = False

        if self.capture is not None:
            self.capture.release()

        self.root.destroy()

    # ---- 1フレーム分の処理 ---------------------------------------------
    def update_frame(self):
        if not self.running:
            return

        if self.hold_frame_variable.get() and self.held_frame is not None:
            frame = self.held_frame.copy()
        else:
            received, frame = self.capture.read()

            if not received:
                self.stop()
                self.status_variable.set("フレームを取得できない(動画の終端、またはカメラの取得終了)")
                return

            frame = normalize_width(frame)
            self.held_frame = frame.copy()

        self.frame_index += 1
        self.process(frame)

        self.root.after(1, self.update_frame)

    def read_parameters(self):
        return {
            "conf": float(self.conf_variable.get()),
            "imgsz": int(self.imgsz_variable.get()),
            "match_iou": float(self.iou_variable.get()),
            "gamma_mid": float(self.gamma_mid_variable.get()),
            "gamma_low": float(self.gamma_low_variable.get()),
            "clip_limit": float(self.clip_limit_variable.get()),
            "tile_grid": int(self.tile_grid_variable.get()),
            "agcwd_alpha": float(self.agcwd_alpha_variable.get()),
            "lime_gamma": float(self.lime_gamma_variable.get()),
            "lime_radius": int(self.lime_radius_variable.get()),
        }

    def process(self, frame):
        started = time.time()

        parameters = self.read_parameters()
        gamma_levels = [
            1.00,
            parameters["gamma_mid"],
            parameters["gamma_low"]
        ]

        inference_inputs = []
        condition_meta = []

        for row_index, gamma in enumerate(gamma_levels):
            degraded = apply_gamma_darkening(frame, gamma)

            for column_index, (method, image, agcwd_gamma) in enumerate(
                build_correction_set(degraded, parameters)
            ):
                inference_inputs.append(image)
                condition_meta.append(
                    (row_index, column_index, gamma, method, agcwd_gamma)
                )

        results = model.predict(
            inference_inputs,
            imgsz=parameters["imgsz"],
            conf=parameters["conf"],
            verbose=False
        )

        detections = [extract_detections(result) for result in results]

        reference = detections[0]
        reference_count = len(reference["cls"])
        names = results[0].names

        elapsed = time.time() - self.start_time
        interval = max(1, int(self.record_interval_variable.get()))
        should_record = self.frame_index % interval == 0

        cells = [[None] * 5 for _ in range(3)]
        console_lines = []
        csv_rows = []

        for index, (row_index, column_index, gamma, method, agcwd_gamma) in enumerate(condition_meta):
            detection = detections[index]
            count = len(detection["cls"])

            matched = count_matches_with_reference(
                detection,
                reference,
                parameters["match_iou"]
            )

            mean_lightness, std_lightness = lightness_statistics(
                inference_inputs[index]
            )

            per_class = summarize_per_class(detection, names)

            class_detail = "; ".join(
                f"{name}: n={number}, 平均確信度={mean:.3f}"
                for name, (number, mean) in sorted(per_class.items())
            ) or "検出なし"

            label = (
                method
                if agcwd_gamma is None
                else f"{method}(γ(l̄)={agcwd_gamma:.2f})"
            )

            recall_text = format_ratio(matched, reference_count)
            precision_text = format_ratio(matched, count)
            confidence_text = format_mean_confidence(detection)

            overlay = [
                f"R{row_index + 1}C{column_index + 1} 劣化γ={gamma:.2f}/{label}",
                f"検出数n={count} 平均確信度={confidence_text} 平均明度L*={mean_lightness:.1f}",
                f"参照一致数m={matched} 参照再現率={recall_text} 参照適合率={precision_text}",
            ]

            cell = letterbox(results[index].plot(), CELL_W, CELL_H)
            cells[row_index][column_index] = draw_text_lines(cell, overlay)

            console_lines.append(
                f"  R{row_index + 1}C{column_index + 1} 劣化γ={gamma:.2f} {label} "
                f"平均明度L*={mean_lightness:.1f} 明度標準偏差={std_lightness:.1f} "
                f"n={count} 平均確信度={confidence_text} m={matched} "
                f"参照再現率={recall_text} 参照適合率={precision_text} | {class_detail}"
            )

            if should_record:
                csv_rows.append([
                    self.frame_index,
                    f"{elapsed:.3f}",
                    f"{gamma:.2f}",
                    method,
                    "" if agcwd_gamma is None else f"{agcwd_gamma:.4f}",
                    f"{mean_lightness:.2f}",
                    f"{std_lightness:.2f}",
                    count,
                    "" if count == 0 else f"{float(detection['conf'].mean()):.4f}",
                    matched,
                    "" if reference_count == 0 else f"{matched / reference_count:.4f}",
                    "" if count == 0 else f"{matched / count:.4f}",
                    f"{parameters['conf']:.2f}",
                    parameters["imgsz"],
                    f"{parameters['match_iou']:.2f}",
                    f"{parameters['clip_limit']:.1f}",
                    parameters["tile_grid"],
                    f"{parameters['agcwd_alpha']:.2f}",
                    f"{parameters['lime_gamma']:.2f}",
                    parameters["lime_radius"],
                    class_detail,
                ])

        self.show(np.vstack([np.hstack(row) for row in cells]))

        if should_record:
            if self.console_output_variable.get():
                self.print_measurement(parameters, console_lines, reference_count)

            if self.csv_output_variable.get():
                self.write_csv(csv_rows)

        duration = (time.time() - started) * 1000.0

        self.status_variable.set(
            f"フレーム {self.frame_index} 1フレームの処理時間 {duration:.0f} ms"
            f"(15条件のバッチ推論を含む) 参照集合の要素数 {reference_count}"
        )

    def print_measurement(self, parameters, console_lines, reference_count):
        print(
            f"[frame={self.frame_index:06d}] conf={parameters['conf']:.2f} "
            f"imgsz={parameters['imgsz']} 一致判定IoU={parameters['match_iou']:.2f} "
            f"劣化γ中段={parameters['gamma_mid']:.2f} "
            f"劣化γ下段={parameters['gamma_low']:.2f} "
            f"clipLimit={parameters['clip_limit']:.1f} "
            f"タイル分割数={parameters['tile_grid']}x{parameters['tile_grid']} "
            f"AGCWD_α={parameters['agcwd_alpha']:.2f} "
            f"照明マップ調整指数={parameters['lime_gamma']:.2f} "
            f"ガイデッドフィルタ半径={parameters['lime_radius']} "
            f"| 参照集合の要素数={reference_count}"
        )

        for line in console_lines:
            print(line)

    def write_csv(self, csv_rows):
        if not csv_rows:
            return

        need_header = (
            not os.path.exists(CSV_PATH)
            or os.path.getsize(CSV_PATH) == 0
        )

        with open(CSV_PATH, "a", newline="", encoding="utf-8") as handle:
            if need_header:
                handle.write("\ufeff")

            writer = csv.writer(handle)

            if need_header:
                writer.writerow(CSV_HEADER)

            writer.writerows(csv_rows)

    def show(self, image_bgr):
        pil_image = Image.fromarray(cv2.cvtColor(image_bgr, cv2.COLOR_BGR2RGB))
        self.photo_image = ImageTk.PhotoImage(pil_image)
        self.image_label.configure(image=self.photo_image)


window = tk.Tk()
application = Application(window)
window.protocol("WM_DELETE_WINDOW", application.close)
window.mainloop()

9. ヒントと考察ポイント

9.1 ヒント

9.2 実験のバリエーション

9.3 考察ポイント