diff --git a/.gitignore b/.gitignore index a4f8dc2..464e0eb 100644 --- a/.gitignore +++ b/.gitignore @@ -17,9 +17,21 @@ # 旧実装(参照用に手元へ置くが Git 管理しない) org/ +# Python +__pycache__/ +*.py[cod] +.venv/ +*.egg-info/ + +# ツールのキャッシュ +.pytest_cache/ +.ruff_cache/ + +# ビルド・配布成果物 +build/ +dist/ + # -- プロジェクト立ち上げ時に有効化する(.claude/rules/git-conventions.md 参照) -- # .env # .env.* # node_modules/ -# build/ -# dist/ diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/CLAUDE.md b/CLAUDE.md index 2658adf..2597f45 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ ## 開発進捗 -最新: プロジェクト立ち上げ(/setup 全フェーズ)完了.次は PLAN_02 ステップ 1(プロジェクト骨格) +最新: PLAN_02 ステップ 7(GUI: 結果画面・結合)完了.ステップ 2〜7 の実装が完了し,残りはユーザー作業(ステップ 5 の旧 exe 採取=TEST_01,ステップ 6〜7 の実機確認)とステップ 8(配布,任意) ※ 本欄は**最新ステップ 1 行のみ上書き更新**.詳細な進捗履歴(動機・設計判断・失敗パターン)は [docs/PROGRESS.md](docs/PROGRESS.md) に追記する.運用ルールは `.claude/rules/progress-log.md` を参照(該当ファイル編集時に自動ロードされる). ## 必須ルール(コード実装時) @@ -60,7 +60,7 @@ - 技術スタック: docs/02_ENV/ENV_01_技術スタック.md - 環境構築手順: docs/02_ENV/ENV_02_環境構築手順.md - 管理者用環境構築手順: docs/02_ENV/ENV_03_管理者用環境構築手順.md -- ※ 開発コマンド(ENV_04)はステップ 1 で pyproject.toml とともに作成する +- 開発コマンド: docs/02_ENV/ENV_04_開発コマンド.md ### 03_PLAN(計画) @@ -78,4 +78,6 @@ ### 06_TEST(テスト) +- 回帰テスト採取手順: docs/06_TEST/TEST_01_回帰テスト採取手順.md + diff --git a/data/data.csv b/data/data.csv new file mode 100644 index 0000000..7d41b20 --- /dev/null +++ b/data/data.csv @@ -0,0 +1,85 @@ +L,a,b,紫,暗赤,紅,淡紅,淡白,紫,暗赤,紅,淡紅,淡白,紫,暗赤,紅,淡紅,淡白,紫,暗赤,紅,淡紅,淡白,紫,暗赤,紅,淡紅,淡白,紫,暗赤,紅,淡紅,淡白,紫,暗赤,紅,淡紅,淡白,紫,暗赤,紅,淡紅,淡白,紫,暗赤,紅,淡紅,淡白,紫,暗赤,紅,淡紅,淡白,紫,暗赤,紅,淡紅,淡白,紫,暗赤,紅,淡紅,淡白,紫,暗赤,紅,淡紅,淡白,紫,暗赤,紅,淡紅,淡白,紫,暗赤,紅,淡紅,淡白,紫,暗赤,紅,淡紅,淡白,紫,暗赤,紅,淡紅,淡白,紫,暗赤,紅,淡紅,淡白,紫,暗赤,紅,淡紅,淡白,紫,暗赤,紅,淡紅,淡白,紫,暗赤,紅,淡紅,淡白,紫,暗赤,紅,淡紅,淡白,紫,暗赤,紅,淡紅,淡白,紫,暗赤,紅,淡紅,淡白,紫,暗赤,紅,淡紅,淡白,紫,暗赤,紅,淡紅,淡白,紫,暗赤,紅,淡紅,淡白,紫,暗赤,紅,淡紅,淡白,紫,暗赤,紅,淡紅,淡白,紫,暗赤,紅,淡紅,淡白,紫,暗赤,紅,淡紅,淡白,紫,暗赤,紅,淡紅,淡白,紫,暗赤,紅,淡紅,淡白,紫,暗赤,紅,淡紅,淡白,紫,暗赤,紅,淡紅,淡白,紫,暗赤,紅,淡紅,淡白,紫,暗赤,紅,淡紅,淡白,紫,暗赤,紅,淡紅,淡白,紫,暗赤,紅,淡紅,淡白 +41.88489776,29.1361943,3.872836106,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0 +45.48995122,26.92919732,1.77195251,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,1,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,0,1,0,0,1,0,0,0,0,0,0,1,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,1,0,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0 +48.74293781,23.94636378,2.37670543,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,1,0,0,1,0,0,0,0,0,0,0,1,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,1,0,0,0,0,0,1,0,0,0,0,0,0,1,0,0,1,0,0,0,0,0,1,0,0,0,0,0,1,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,1,0,0,1,0,0,0,0,0,1,0,0,0,0,0,0,0 +53.30339364,21.74506952,3.056553044,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,1,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,0,1,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,1,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,1,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,1,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0,0 +60.31101206,20.57413563,0.314462316,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,1,0,0,0,0,0,0,0,1,0,0,1,0,0,0,0,0,0,1,0,0,1,0,0,0,0,0,0,1,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,0,1,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,1,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,1,0,0,1,0,0,0,1,0,0,0,0,0,0,1,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,1,0,1,0,0,0,0,0,1,0,0,0,0,0,0,0,0 +55.62497103,22.20947483,-2.441626396,0,0,0,1,0,0,0,0,0,1,1,0,0,0,0,1,0,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,1,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,1,0,0,0,0,0,0,0,1,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,0,1,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,0,1,0,0,1,0,0,0,0,0,0,1,0,0,0,0,0,1,0,1,0,0,0,0,0,0,0,0 +60.01418789,22.58004908,-9.562750789,0,0,0,1,0,0,0,0,0,1,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,1,1,0,0,0,0,1,0,0,0,0,0,0,0,0,1,0,0,0,0,1,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,1,0,0,1,0,0,0,0,0,0,0,0,1,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,1,0,0,0,0,0,0,0,0,0 +42.48545883,27.26257072,9.320512516,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,0 +45.794837,25.60495176,5.256209115,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,1,0,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,1,0,0,0,0,0,0,0,1,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,1,0,0,0,0,1,0,0,0,0,0,0,1,0,0,0,1,0,0,0,1,0,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,1,0,0,1,0,0,0,0,0,1,0,0,0,0,0,0,0 +46.92525219,25.64650259,4.23683162,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,1,0,0,0,0,0,0,1,0,0,1,0,0,0,0,0,0,1,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,0,1,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,1,0,0,0,0,1,0,0,0,0,0,0,1,0,0,0,1,0,0,0,1,0,0,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,1,0,0,0,0,0,0,1,0,0,0,0,1,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,1,0,0,1,0,0,0,0,0,1,0,0,0,0,0,0,0 +49.1580557,25.17661207,4.169486439,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,1,0,0,0,0,1,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,1,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,1,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,1,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0,0 +50.83533704,23.37011617,3.690234161,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,1,0,1,0,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,1,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0,0 +53.43887626,22.49189118,0.635054945,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,1,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0,1,0,1,0,0,0,0,0,0,0,1,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,1,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0,0,0 +57.39172925,19.17168386,-3.169794628,0,1,0,0,0,0,0,0,0,1,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0,0,1,1,0,0,0,0,1,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,1,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,0,0,1,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0,1,0,0,1,0,0,0,0,0,0,0,1,1,0,0,0,0,1,0,0,0,0,0,0,0,0,0 +46.43502049,26.04466457,7.865909871,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,1,0,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0 +48.21325581,25.5068019,6.400101287,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,1,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,1,0,0,1,0,0,0,0,0,1,0,0,0,0,0,0,0 +51.45676561,24.53175019,5.205783324,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,1,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,1,0,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,1,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0,0 +53.56668608,22.70082003,4.929306421,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,1,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,1,0,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0,0 +54.47250118,22.01844723,3.856579137,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,1,0,1,0,0,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,1,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,0,0,0 +54.91207247,20.39482939,3.616163078,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,1,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,1,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0,0,0 +59.69232737,16.8077782,0.252493381,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,1,0,0,1,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0,0,1,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0,1,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,0,0,1,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0,0,0 +45.15869448,24.04986269,10.22319498,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,1,0,0,0,0,0,1,0,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,1,0,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,1,0,0,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,1,0,0,0,0,0,1,0,0,0,0,0,0,0 +46.4728088,25.06010493,8.094026371,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,1,0,0,0,0,0,1,0,0,0,0,0,1,0,0,1,0,0,0,0,0,0,1,0,0,0,0,0,1,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,1,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0,0 +49.54333821,23.8165337,6.884292546,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,1,0,0,1,0,0,0,0,0,1,0,1,0,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,1,0,0,0,0,1,0,0,1,0,0,0,0,0,0,0,1,0,1,0,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,1,0,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0,0 +53.49418685,21.88754429,6.679642024,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,1,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,1,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,1,0,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0,0 +56.65093695,19.32484924,6.333040861,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,1,0,0,1,0,0,0,0,0,0,1,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,0,0,0 +57.5586544,17.4667879,6.581596122,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,1,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0,0,0 +62.97872656,11.68144026,5.273295242,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0,0,1,1,0,0,0,0,1,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0,1,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0,0,0 +43.60521272,33.47263021,2.766610615,0,0,1,0,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,1,0,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,1,0,0,0,0,1,0,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0 +45.87915502,29.91958032,2.698980446,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,1,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0 +48.26365433,28.85237014,2.85504559,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,1,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,1,0,0,0,0,0,0,0,0 +54.63915596,25.95845928,2.359925027,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,1,0,0,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0,0 +59.12648076,25.09021684,-5.928147065,0,0,0,1,0,0,0,0,0,1,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,0,1,0,0,0,0,1,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0,1,0,1,0,0,0,0,1,0,0,0,0,0,0,0,1,0,1,0,0,0,0,0,0,0,1,0,0,1,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,1,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0,1,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,1,0,0,0,0,0,0,0,0,0 +60.88865131,23.92397915,-9.31285722,0,0,0,1,0,0,0,0,0,1,0,1,0,0,0,1,0,0,0,0,0,0,1,0,0,0,0,0,0,1,0,0,0,0,1,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,0,0,1,1,0,0,0,0,0,1,0,0,0,0,0,0,0,1,0,1,0,0,0,0,0,0,0,1,0,1,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,1,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,1,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0,1,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,1,0,0,0,0,0,0,0,0,0 +65.04180187,21.27677466,-11.39380642,0,0,0,1,0,0,0,0,0,1,0,1,0,0,0,1,0,0,0,0,0,0,1,0,0,0,0,0,0,1,0,0,0,0,1,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0,0,1,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,1,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,0,0,1,0,1,0,0,0,0,0,0,0,1,0,1,0,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,1,0,0,0,0,0,0,0,0,0 +48.07683053,29.6361169,6.130761049,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,1,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,1,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,0 +48.62700875,28.03976896,5.365011787,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,1,0,0,1,0,0,0,0,0,1,0,0,0,0,0,0,0 +53.95551799,26.33997522,4.280005174,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,1,0,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,1,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0,0 +52.70490799,24.87098745,4.375553497,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,1,0,0,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,1,0,1,0,0,0,0,0,1,0,0,0,0,0,0,0,0 +56.13096482,22.35938885,1.703844708,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,1,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,1,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,0,0,0 +60.0717065,20.18990794,-3.625657739,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,1,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,1,0,0,0,0,0,0,0,1,0,1,0,0,0,0,0,0,0,1,0,1,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,1,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0,1,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,0,1,0,0,1,0,0,0,0,0,0,0,1,0,0,0,0,1,1,0,0,0,0,0,0,0,0,0 +65.09410766,17.32361185,-5.550846278,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0,0,1,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,0,1,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,0,0,1,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,0,0,1,0,0,0,0,1,1,0,0,0,0,0,0,0,0,0 +46.21961792,29.30017891,8.83598133,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,0 +49.4546967,28.64133035,7.013314394,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0 +53.39709788,24.81811705,5.607297696,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,1,0,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0,0 +57.30399087,23.04030998,4.852130522,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0,0 +59.58345013,19.68924257,2.868394895,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,1,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,0,0,0 +64.83869359,17.22528079,0.301154658,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0,0,0 +68.32409466,15.18006026,-2.52470433,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0,0,1,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0,0,0 +47.09841067,25.20045273,12.97741239,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,1,0,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,1,0,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,1,0,0,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0 +48.45496916,25.75332929,9.722925453,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,1,0,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,1,0,0,0,0,0,0,0,1,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,1,0,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0 +54.28392226,24.15125636,7.125956561,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,1,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,1,0,0,0,1,0,0,0,1,0,0,0,0,0,0,1,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0,0 +55.97740008,21.90425358,7.191839974,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,1,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0,0 +59.16490458,16.74382205,8.034707382,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0,0,0 +62.15919684,13.86235327,6.940598296,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0,0,0 +64.26706458,11.99341253,4.538689206,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0,0,0 +46.85395053,32.3231544,3.608365796,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,1,0,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,1,0,0,0,0,0,0,1,0,0,1,0,0,0,0,1,0,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0 +47.87227321,30.64156856,3.505906119,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,1,0,0,1,0,0,0,0,0,0,1,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0 +51.61192642,29.09863013,3.333130379,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,1,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0 +54.11526149,27.62862907,1.675422125,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0,0 +57.32805597,26.89784555,-3.318920359,0,0,1,0,0,0,0,0,1,0,0,1,0,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,1,0,0,0,0,1,1,0,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,1,0,1,0,0,0,0,0,0,1,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,1,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,1,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,1,0,0,0,0,0,0,0,0 +62.56126321,24.2650065,-9.149461395,0,0,1,0,0,0,0,0,1,0,1,0,0,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,1,0,0,0,0,1,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0,0,1,0,1,0,0,0,0,0,0,0,1,0,1,0,0,0,0,0,0,0,1,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0,0,1,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,1,0,0,0,0,0,0,0,0,0 +68.82711691,21.48922567,-10.69620388,0,0,1,0,0,0,0,0,1,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,0,0,1,0,0,0,0,1,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0,0,1,1,0,0,0,0,1,0,0,0,0,0,0,0,0,1,0,1,0,0,0,0,0,0,0,1,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0,0,1,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0,0,1,0,1,0,0,0,0,0,0,0,1,0,0,0,0,1,1,0,0,0,0,0,0,0,0,0 +51.11764455,31.47838769,5.510429463,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,1,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,1,0,0,1,0,0,0,0,0,0,1,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,1,0,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,0 +50.63902909,30.10326271,5.324421255,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,1,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,1,0,0,1,0,0,0,0,0,1,0,0,0,0,0,0,0 +55.08304132,27.05367239,3.245150127,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,1,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,1,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,0,0 +57.23926922,25.66421445,2.344439367,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,1,0,0,0,0,0,0,1,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,0,1,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0,0 +59.91185519,23.73474472,-1.984082,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,1,0,0,1,0,0,0,0,0,0,1,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,1,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0,1,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,0,1,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0,0 +64.57205629,20.90840429,-4.82231864,0,1,0,0,0,0,0,1,0,0,1,0,0,0,0,0,1,0,0,0,0,0,1,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0,0,1,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,0,0,1,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,0,1,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0,0,0 +68.3856874,18.87405935,-6.559921825,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,1,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,0,0,1,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0,1,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0,0,0 +46.39079156,29.98361527,7.93364776,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,1,0,0,1,0,0,0,0,0,0,1,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0 +49.06731421,29.28263475,7.103348482,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0 +54.67936417,26.5265794,5.727317015,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0,0 +56.22853659,23.8612931,5.290633867,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0,1,0,1,0,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,1,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0,0 +59.70056246,21.94971165,3.90207517,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0,0 +64.89957411,17.00970137,0.701982377,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0,0,0 +70.59525151,16.54555228,-3.568067362,0,0,1,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0,0,0 +51.301384,28.0328677,10.56431623,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,1,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0 +51.54717941,28.11184592,8.748660795,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,0,0 +52.89489703,25.37593127,7.848289493,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,1,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0,0 +55.34783917,22.44266991,8.12609321,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0,0 +64.53065244,14.97981003,8.085096744,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0,0,1,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0,0,0 +64.26863185,15.03049256,5.563700927,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0,0,0 +70.43864865,14.78795115,0.558710065,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,1,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0,0,1,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0,0,0 diff --git "a/docs/02_ENV/ENV_04_\351\226\213\347\231\272\343\202\263\343\203\236\343\203\263\343\203\211.md" "b/docs/02_ENV/ENV_04_\351\226\213\347\231\272\343\202\263\343\203\236\343\203\263\343\203\211.md" new file mode 100644 index 0000000..db97184 --- /dev/null +++ "b/docs/02_ENV/ENV_04_\351\226\213\347\231\272\343\202\263\343\203\236\343\203\263\343\203\211.md" @@ -0,0 +1,86 @@ +# 開発コマンド (Development Commands) + +日常の開発で使うコマンド一覧.**コマンドを実行する前に必ず本ファイルを参照する**(推測でコマンドを試さない). +コマンドが新しく確定・変更されたときは本ファイルを更新する. + +前提: [ENV_02_環境構築手順.md](ENV_02_環境構築手順.md) のセットアップが済んでいること. +すべてリポジトリ直下(`pyproject.toml` のある場所)で実行する.OS(Windows / macOS)による差は無い. + +## 環境 (Environment) + +| 目的 | コマンド | +| --- | --- | +| 依存パッケージを同期(初回・`uv.lock` 更新時) | `uv sync` | +| 依存パッケージを追加 | `uv add <パッケージ>` | +| 開発用(テスト・lint 等)の依存を追加 | `uv add --dev <パッケージ>` | +| 依存パッケージを削除 | `uv remove <パッケージ>` | +| インストール済みパッケージを確認 | `uv pip list` | + +※ `pip install` は使わない(ENV_02「環境再現性のルール」). + +## 起動 (Run) + +| 目的 | コマンド | +| --- | --- | +| アプリを起動(メイン画面) | `uv run python -m tiasjudge` | +| コンソールスクリプトで起動 | `uv run tiasjudge` | +| 起動可能性のみ確認(画面を出さずに終了) | `uv run python -m tiasjudge --check` | + +※ 診断を実行すると結果画面(モードレス)が開く.結果画面は表示専用で,言語切替と「閉じる」はメイン画面の「結果画面の操作」から行う. +※ ヘッドレス環境(画面の無い CI 等)では `QT_QPA_PLATFORM=offscreen` を付けて `--check` を実行する. + +### デバッグ用 (Debug) + +結果画面の見た目を確認するための**デバッグ専用**オプション.舌画像の読み込みや診断(SVM 学習)は行わず,ダミーの診断結果とダミー画像で結果画面だけを開く. + +| 目的 | コマンド | +| --- | --- | +| 結果画面をダミー結果で開く(成功パターン) | `uv run tiasjudge --preview-result` | +| 結果画面をダミー結果で開く(診断失敗パターン) | `uv run tiasjudge --preview-result --failed` | +| 結果画面を英語表示で開く | `uv run tiasjudge --preview-result --english` | +| プレビュー画面の構築のみ確認(即終了) | `uv run tiasjudge --preview-result --check` | + +※ 既定は日本語表示.結果画面は表示専用(言語切替ボタンを持たない)ため,英語表示の確認には `--english` を使う. +※ プライマリ以外のモニタがあればそこに最大化して開く(2 画面運用の確認.SPEC_01「表示先モニタ」参照).モニタ 1 台なら通常サイズで開く. +※ 結果画面を閉じるとアプリが終了する. +※ ダミー値は成功パターンが淡紅 55% / 紅 25% / 暗赤 10% / 紫 5% / 淡白 5%(上位 2 色は淡紅・紅),失敗パターンが旧実装の失敗時スコア組(紫 42 / 暗赤 0 / 紅 5 / 淡紅 11 / 淡白 3). +※ オプションを付けなければ通常起動・`--check` の挙動は変わらない. + +## テスト (Test) + +| 目的 | コマンド | +| --- | --- | +| 全テストを実行 | `uv run pytest` | +| 特定ファイルを実行 | `uv run pytest tests/core/test_color.py` | +| 特定テストを実行 | `uv run pytest tests/core/test_color.py::test_xyz_to_lab` | +| 失敗時に詳細を表示 | `uv run pytest -vv` | +| 最初の失敗で中断 | `uv run pytest -x` | + +※ テストが 1 件も収集されない場合,pytest は終了コード 5 で終わる.テスト対象の指定ミスを疑うこと. + +## lint・フォーマット (Lint & Format) + +| 目的 | コマンド | +| --- | --- | +| lint チェック | `uv run ruff check .` | +| lint の自動修正 | `uv run ruff check --fix .` | +| フォーマット実行 | `uv run ruff format .` | +| フォーマット差分の確認のみ | `uv run ruff format --check .` | + +## コミット前の確認 (Pre-commit Checks) + +以下の 3 つがすべて通ることを確認してからコミットする. + +```bash +uv run ruff check . +uv run ruff format --check . +uv run pytest +``` + +## 同梱データ (Bundled Data) + +| ファイル | 内容 | +| --- | --- | +| `data/data.csv` | SVM 学習データ(旧実装の `data.csv` を UTF-8 に変換して同梱.SPEC_02 参照) | + +※ 変換行列 CSV・舌画像は実行時にユーザーが選択するためリポジトリに含めない. diff --git "a/docs/04_SPEC/SPEC_01_\347\224\273\351\235\242\344\273\225\346\247\230.md" "b/docs/04_SPEC/SPEC_01_\347\224\273\351\235\242\344\273\225\346\247\230.md" index bd2d4f6..2d47f6a 100644 --- "a/docs/04_SPEC/SPEC_01_\347\224\273\351\235\242\344\273\225\346\247\230.md" +++ "b/docs/04_SPEC/SPEC_01_\347\224\273\351\235\242\344\273\225\346\247\230.md" @@ -24,12 +24,17 @@ | L\*a\*b\* 表示 | 診断時に算出した L\*・a\*・b\* を表示 | | 5 色スコア表示 | 紫・暗赤・紅・淡紅・淡白 の各一致率(%)を表示 | | [診断続行] ボタン | 診断後に有効化.「同じ変換行列を使用するか」を確認し,画像・クリック状態をリセット | +| 結果画面の操作: 言語切替ボタン | 開いている全結果画面の表示言語を切替(ボタンラベルは切替先の言語名.日本語表示中は "English").**常時有効**で,次に開く結果画面にも適用される(診断続行後も維持).言語状態はメイン画面が保持(初期値は日本語) | +| 結果画面の操作: [結果画面を閉じる] ボタン | 開いている全結果画面を閉じる.結果画面が 1 枚も無いときは無効 | ### 入力ガード(エラー表示)(Input Guards) - 変換行列未読込でクリック → 「変換行列を読み込んでください」 - 画像未読込でクリック → 「画像を読み込んでください」 - クリック 0 点で診断 → 「1 点以上 4 点以下クリックしてください」 +- 画像の端付近(ROI 51×51 が画像外にはみ出す位置)をクリック → 「画像の端から 25 px 以上内側をクリックしてください」 + +※ 端クリックのガードは旧実装に無い追加仕様(旧実装は OpenCV 例外でクラッシュしていた.ステップ 6 で追加). ## 結果画面 (Result Window) @@ -37,10 +42,23 @@ | --- | --- | | 診断画像 | クロップ後の元画像を 1/2 縮小表示 | | 結果パネル | 5 色の行(色名 + 説明)を一覧表示し,**上位 2 色のみ**スコア(%)と下線を描画 | -| 言語切替ボタン | 日本語 ⇔ 英語を切替(ボタンラベルは切替先の言語名) | | 診断失敗表示 | 診断失敗時(SPEC_02「診断失敗の判定」参照)に「Diagnosis failed / Please try again.」を表示 | | 注記 | 「本結果は疾患の診断を行うものではない」「表示画像は明るさ補正されている」 | +※ 結果画面は**表示専用**(患者側モニタに操作 UI を出さないため,ボタンを持たない).言語切替と「閉じる」はメイン画面の「結果画面の操作」から行う(タイトルバーの × でのみ直接閉じられる).当初は結果画面内に言語切替・閉じるボタンがあったが,2 画面運用に合わせてメイン画面へ移した. +※ 英語の説明文は長文の行のみフォントサイズを小さく固定する(Red / Dark red は 36 px,Pale red は 30 px,他は 40 px.スコア表示と重ならない実測最大値.強調の有無でサイズは変えない). + +### 表示先モニタ(2 画面運用)(Target Monitor) + +2 画面運用(施術者側=メイン画面,患者側=結果画面)を前提とし,結果画面は**メイン画面が表示されているモニタとは別のモニタに最大化**して表示する. + +- 基準はメイン画面が実際に載っているモニタ(プライマリ固定ではない.メイン画面をサブ側に移せば結果はプライマリ側に出る) +- モニタが 1 台の場合は同一モニタに通常サイズで表示する(フォールバック) +- 結果パネルは 644×644 の固定描画のため,最大化時はスケーリングせず余白が増える.内容はウィンドウ中央に配置する +- デバッグ用 `--preview-result` はメイン画面が無いため,プライマリ以外のモニタを表示先とする + +※ 2 画面表示は旧実装に無い追加仕様(ユーザー要望による). + ### 5 色の表示内容 (Color Rows) | 色名(日) | 色名(英) | 説明(英語版) | @@ -49,6 +67,10 @@ | 紅 | Red | Hot flush / feverish | | 暗赤 | Dark red | Blood slightly thick | | 紫 | Purple | Blood is thick | -| 淡白 | Pale red | Cold / fatigue / anemia | +| 淡白紅 | Pale red | Cold / fatigue / anemia | ※ 現行の日本語版は文字入り背景画像(`img_student2.bmp`)を使用している.新実装では日英とも描画で生成し,画像アセット依存を無くす(見た目は現行踏襲). + +※ 描画生成の色は旧背景画像からのピクセル実測値を使用する(見出し RGB(233,113,50),5 色の色名は `result_window.py` の `COLOR_SWATCHES` 参照).5 色の色名のみ黒縁取り(幅 2 px)を付ける.フォントサイズ・太さも旧画像のインク高実測に基づく(説明文は Medium). + +※ 5 色目の日本語表記は画面間で異なる: メイン画面は「淡白」,結果画面は「淡白紅」.旧実装(メイン画面ダイアログと結果画面の背景画像)がこの表記だったため,現行踏襲としてどちらもそのまま維持する(ステップ 7 で背景画像を実確認して確定). diff --git "a/docs/04_SPEC/SPEC_02_\350\250\272\346\226\255\343\203\255\343\202\270\343\203\203\343\202\257\344\273\225\346\247\230.md" "b/docs/04_SPEC/SPEC_02_\350\250\272\346\226\255\343\203\255\343\202\270\343\203\203\343\202\257\344\273\225\346\247\230.md" index 5dd88ca..64a4bd5 100644 --- "a/docs/04_SPEC/SPEC_02_\350\250\272\346\226\255\343\203\255\343\202\270\343\203\203\343\202\257\344\273\225\346\247\230.md" +++ "b/docs/04_SPEC/SPEC_02_\350\250\272\346\226\255\343\203\255\343\202\270\343\203\203\343\202\257\344\273\225\346\247\230.md" @@ -37,6 +37,8 @@ ※ CIE 標準の低輝度側条件分岐(t ≤ (6/29)³ のときの線形近似)は現行実装に無いため**意図的に実装しない**(結果同一性優先). +※ 精度の踏襲: 旧実装は変換係数を単精度(`float a[17]` 等)で保持し,X, Y, Z の計算は倍精度,出力 L\*a\*b\* は単精度(`float`)に丸めて保持する.新実装(`core/color.py`)もこれを再現する(係数を float32 に丸めてから float64 で演算し,Lab 出力を float32 に丸める). + ### (3) SVM 分類 - 学習データ: `data.csv` の 84 サンプル(入力: L\*a\*b\* 3 次元,出力: 190 列 = 医師 38 名 × 5 色の 0/1 評価) @@ -49,7 +51,12 @@ 5 色スコア(% 整数)が特定の組(淡紅 11・紅 5・暗赤 0・紫 42・淡白 3)と完全一致した場合,現行実装は「Diagnosis failed」と表示する. -※ これは「学習不能時に SVM が返す既知の出力パターン」をマジックナンバーで検出する現行のワークアラウンド.新実装では**学習不能・予測失敗を直接検出**して失敗表示する(表示仕様は現行と同じ). +このパターンの正体は「入力が学習データ域から大きく外れ,RBF カーネル値がほぼ 0 になり各列の判定がバイアス項のみで決まるときの SVM の既定応答」である(ステップ 4 で実測確認.L\* が正でも発生する). + +新実装は以下の**両方**を失敗条件とする(表示仕様は現行と同じ.失敗時もスコアは算出値を表示する): + +1. 原因の直接検出: 入力の NaN/inf(`INVALID_INPUT`),L\* ≤ 0(`DEGENERATE_INPUT`),全列学習不能(`NO_TRAINABLE_COLUMN`),学習・予測の内部失敗(`TRAINING_FAILED` / `PREDICTION_FAILED`) +2. 現行パターン判定の踏襲: スコア組が上記の値と完全一致した場合(`OUT_OF_DOMAIN`).現行実装の失敗表示と完全一致させるために残す(直接検出のみでは L\* が小さい正値の域外入力を取りこぼすことが実測で判明したため) ## データファイル形式 (Data File Formats) @@ -62,3 +69,5 @@ - 1 行目: ヘッダ(`L,a,b,紫,暗赤,紅,淡紅,淡白,…` の繰り返し) - 2 行目以降: 84 サンプル.列 1〜3 が L\*a\*b\*,列 4〜193 が医師 38 名 × 5 色の 0/1(各医師 5 列の one-hot) +- ※ 実ファイルは 198 列(医師ブロック 39 個分)あるが,末尾の 39 番目のブロック 5 列は全サンプルで全 0 の未使用列である(有効な評価データは上記の 38 名分=193 列) +- ※ 旧実装の読込列数は **193 列で確定**(ステップ 2 で `org/src` を確認済み: `EXP_OUTPUT_DIM = 190` = 38 名 × 5 色で,`CsvRead` は cols=193 で読み 194 列目以降にアクセスしない.ヘッダの「医師の数39」コメントは誤記).新実装(`core/csv_io.py`)も先頭 193 列のみを読む diff --git "a/docs/06_TEST/TEST_01_\345\233\236\345\270\260\343\203\206\343\202\271\343\203\210\346\216\241\345\217\226\346\211\213\351\240\206.md" "b/docs/06_TEST/TEST_01_\345\233\236\345\270\260\343\203\206\343\202\271\343\203\210\346\216\241\345\217\226\346\211\213\351\240\206.md" new file mode 100644 index 0000000..f3881ed --- /dev/null +++ "b/docs/06_TEST/TEST_01_\345\233\236\345\270\260\343\203\206\343\202\271\343\203\210\346\216\241\345\217\226\346\211\213\351\240\206.md" @@ -0,0 +1,172 @@ +# 回帰テスト採取手順 (Regression Data Capture Procedure) + +PLAN_02 ステップ 5「回帰テスト」で使う **旧実装 exe の入出力ペア** を採取する手順. +本作業は人間が行う(PLAN_02「人間が行う作業」).採取した CSV を +`tests/fixtures/regression/` に置くと,`uv run pytest` の回帰テストが自動で有効になる. + +- 採取物の CSV 形式: [tests/fixtures/regression/README.md](../../tests/fixtures/regression/README.md) +- 診断ロジックの仕様: [SPEC_02_診断ロジック仕様.md](../04_SPEC/SPEC_02_診断ロジック仕様.md) +- 旧アプリの操作: [SPEC_01_画面仕様.md](../04_SPEC/SPEC_01_画面仕様.md) + +## 目的 (Purpose) + +新実装(`src/tiasjudge/core/`)が旧実装と**同じ診断結果**を返すことを証明する(PLAN_01 成功基準・M1). +そのために «同じ入力に対する旧 exe の出力» を固定データとして採取する. + +## 採取方式の考え方 (Capture Design) + +旧 exe には **RGB や L\*a\*b\* を直接入力する画面が無い**(`org/src` を確認済み). +入力経路は「PNG 画像を読み込み → 画像上をクリック → その ROI の平均色」だけであり, +さらに**クリック位置を手作業で再現できない**という問題がある(PLAN_02 の注記). + +そこで次の方式で採取する. + +- **画像全面を単一色で塗った PNG** を読み込ませる + - ROI(51×51 px)がどこに置かれても平均色は画像の色そのものになる + - → クリック位置が 1 px ずれても入力 RGB が変わらない=再現可能な入力になる + - → 入力 RGB は «画像を作るときに指定した R, G, B» として厳密に分かる +- 出力は 1 回の診断で **L\*a\*b\* と 5 色スコアの両方**をメイン画面から読み取れる + - → 「RGB → Lab」と「RGB → スコア(パイプライン全体)」の 2 種類を同時に採取できる + +※ 「Lab → スコア」を単独で採取することは,旧 exe に Lab の入力手段が無いためできない. +またメイン画面の L\*a\*b\* 表示は**整数に丸められている**ため,表示された Lab を入力として +使い回すと SVM の予測が変わりうる.したがって Lab は «出力側の照合対象» としてのみ扱う +(形式定義 README の ③ の注意を参照). + +## 事前準備 (Prerequisites) + +1. 旧実装 exe を実行できる状態にする + - 実行ファイル: `org/src/tonguediagnosisMFC2/x64/Release/tonguediagnosisMFC2.exe`(または Debug) + - exe と同じ作業フォルダに **`data.csv`** と **`img/img_student2.bmp`** が必要 + (`org/src/tonguediagnosisMFC2/` 直下でそのまま実行するのが確実) +2. 撮影環境の **変換行列 CSV**(17 行 × 3 列)を 1 つ用意する + - 実際の診断で使っているものを使う.どのファイルを使ったかを必ず控える +3. 採取用の単色 PNG を生成する(下記) + +### 単色 PNG の生成 (Generate Solid-Color Images) + +リポジトリ直下で次を実行する(生成先はリポジトリ外・作業用フォルダを推奨). + +```bash +uv run python scripts/generate_regression_images.py --out-dir <出力先フォルダ> +``` + +- 既定で 12 枚(代表的な舌色・境界値・域外色)を 1024×1280 px で生成する +- ファイル名は `rgb_156_102_098.png` のように **RGB がそのまま入る**(採取時の取り違え防止) +- 色を指定したい場合: `--color 156,102,98 --color 120,70,70`(複数指定可) +- 実行すると,採取表(CSV)の雛形が標準出力に表示される.そのままエディタに貼って使える + +## 採取手順 (Capture Steps) + +1 色(1 枚)につき次を 1 回行う. + +1. 旧 exe を起動する +1. **① 変換行列(.csv)読み込み** の参照ボタンで変換行列 CSV を選ぶ +1. **② 舌画像(.png)読み込み** の参照ボタンで単色 PNG を 1 枚選ぶ +1. 左の画像表示エリアの**中央付近を左クリックする(1 点だけ)** + - 「現在のクリック ●」が 1 つ点灯すれば OK + - 画像の端から 15 px 以上内側をクリックする(端では ROI がはみ出して旧 exe が落ちる) + - 誤ってクリックした場合は**右クリック**で取り消してから,読み込みからやり直す + (※ 旧実装は右クリックしてもクリック数の内部カウントが戻らない. + クリックし直したら一度 exe を再起動するか「診断続行」でリセットするのが安全) +1. **④ 舌色診断ボタン** をクリックする +1. **メイン画面の右下**に表示される次の 8 つの値を読み取り,採取表に記入する + - 上から `紫 xx%`・`暗赤 xx%`・`紅 xx%`・`淡紅 xx%`・`淡白 xx%` + - その下に `L* xx`・`a* xx`・`b* xx` + - いずれも**整数表示**(旧実装の書式が `%3.0lf` のため.小数は表示されない) +1. 同時に開く**結果画面**を見て,赤い帯の **「Diagnosis failed / Please try again.」が出ているか**を記録する + - 出ていれば `failed` 列に `yes`,出ていなければ `no` +1. 結果画面を閉じ,**診断続行** ボタンを押す + - 「同じ変換行列を使用するか」に **はい** と答えると,変換行列を選び直さずに次の画像へ進める +1. 次の色の画像を ② から読み込み,同じ手順を繰り返す + +### 採取すべきケース (Cases to Capture) + +`scripts/generate_regression_images.py` の既定 12 色がそのまま以下をカバーする. +最低でも「代表色 5 点 + 境界値 2 点 + 域外 2 点」を採取する. + +| 種類 | 色(R,G,B) | 意図 | +| --- | --- | --- | +| 代表 | 156,102,98 | 基準色(旧実装コメントの平均 BGR 由来) | +| 代表 | 180,120,115 | やや明るい舌色 | +| 代表 | 205,145,140 | 淡い舌色(淡紅・淡白寄り) | +| 代表 | 140,85,85 | やや暗い舌色 | +| 代表 | 115,62,68 | 暗い舌色(暗赤・紫寄り) | +| 代表 | 170,92,112 | 赤紫寄り | +| 代表 | 196,74,74 | 鮮やかな紅寄り | +| 代表 | 150,110,130 | 青み寄り | +| 境界 | 255,255,255 | 明るさの上限 | +| 境界 | 0,0,0 | XYZ が 0 にクリップされる(`L* = -16` になる想定) | +| 失敗 | 255,0,0 | 学習データ域外(「Diagnosis failed」が出るか確認する) | +| 失敗 | 0,0,255 | 学習データ域外 | + +※ 実際の舌画像で «上位 2 色が入れ替わる境目» に近い色が分かっている場合は, +その前後の色も追加すると回帰テストの価値が上がる. + +## 採取表の記入 (Recording) + +生成スクリプトが出力した雛形を使い,**2 つの CSV** を作る. +列名・列の意味・許容誤差は [tests/fixtures/regression/README.md](../../tests/fixtures/regression/README.md) が正. + +`tests/fixtures/regression/rgb_to_lab.csv` + +```csv +matrix,r,g,b,l_star,a_star,b_star,note +translateMatrix.csv,156,102,98,55,22,7,基準色 +``` + +`tests/fixtures/regression/rgb_to_scores.csv` + +```csv +matrix,r,g,b,purple,dark_red,crimson,pink,pale,failed,note +translateMatrix.csv,156,102,98,16,61,11,5,0,no,基準色 +``` + +記入のルール. + +- 数値は**画面表示のまま**(整数)書く.四捨五入や桁の追加をしない +- 負の値(`a*` や `b*`)はマイナス記号を付けて書く +- `matrix` 列には使った変換行列 CSV の**ファイル名**を書く +- 使った変換行列 CSV 自体を `tests/fixtures/regression/matrices/` にコピーする(必須) +- 文字コードは **UTF-8**(Excel で保存する場合は「CSV UTF-8」を選ぶ) +- `#` で始まる行はコメントとして無視される(採取日・環境のメモに使ってよい) + +## 照合の実行 (Run the Regression Test) + +CSV を置いたら,リポジトリ直下で次を実行する([ENV_04](../02_ENV/ENV_04_開発コマンド.md)). + +```bash +uv run pytest tests/test_regression.py -vv +``` + +- CSV が無い間は skip(理由に本手順書のパスが表示される) +- CSV を置くと 1 行 = 1 テストケースとして実行される +- 許容誤差(README と同じ) + - L\*a\*b\*: 絶対誤差 **0.51 以内**(旧 exe が整数しか表示しないため) + - 5 色スコア(%): **完全一致** + - 診断失敗の有無: **完全一致** + +## 不一致だったときの扱い (When Results Differ) + +**新実装(プロダクションコード)をその場で直さない**.次の順に切り分けて記録する. + +1. 採取ミスの確認: 変換行列は同じか,クリックは 1 点か,画像を取り違えていないか +1. 差の大きさを確認: L\*a\*b\* が 1 未満のずれなら表示丸めの範囲内の可能性がある +1. 原因の記録: どの入力で・どの値が・どれだけずれたかを [PROGRESS.md](../PROGRESS.md) に追記する +1. 仕様への反映: 旧実装の挙動が SPEC_02 の記述と異なると分かった場合は SPEC_02 を更新してから実装を直す + (PLAN_02 ステップ 5 の完了条件は「全ペアで結果一致(不一致時は原因を特定し記録)」) + +## 精度を上げたい場合 (Higher Precision, Optional) + +画面表示は整数までなので,L\*a\*b\* の照合はどうしても ±0.5 の幅を持つ. +より厳密に照合したい場合は,旧実装を Visual Studio で **Debug ビルド**して次を採取する. + +- `CalcLab()` の直後に `TRACE("L=%.6f a=%.6f b=%.6f\n", m_tongueL, m_tonguea, m_tongueb);` を追加し, + 出力ウィンドウの値を `rgb_to_lab.csv` に書く(この場合は小数のまま記入してよい. + テストの許容誤差 0.51 のままでも通るが,より厳しい照合が必要なら + `tests/test_regression.py` の `LAB_DISPLAY_TOLERANCE` を下げる) +- 同様に `m_tongueL/a/b` に任意の値を代入してから `CalcSVM()` を呼ぶ改造版を作れば, + 「Lab → スコア」の組(README の ③ `lab_to_scores.csv`)も採取できる + +※ 旧実装の改造は**参照専用の `org/` を書き換えない**(Git 管理外・原本保持). +コピーを別フォルダに作って行うこと. diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index e6ad506..a73fa16 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -19,3 +19,43 @@ - [x] **/setup フェーズ 5 完了(規約整備)**: Python スタック向け path-scoped rules(coding-style / project-structure / testing / error-handling)を作成.パッケージ名は `tiasjudge`(src レイアウト,core と gui を分離),テストは旧実装との回帰テストを最重視,GUI 自動テストは書かない方針 - [x] **/setup フェーズ 6 完了(開発計画)**: PLAN_02 を作成.core 先行の 8 ステップ構成(骨格 → csv_io → color → svm → 回帰テスト → GUI 2 画面 → 配布).M1 = 旧実装との結果一致の証明(ステップ 5,旧 exe での入出力採取にユーザー協力が必要),M2 = アプリ置き換え可能 - [x] **/setup フェーズ 7 完了(立ち上げ完了)**: CLAUDE.md を最終更新(概要にスタック明記・必須ルール・ドキュメント一覧).プロジェクト立ち上げフローはすべて完了.次は PLAN_02 ステップ 1(プロジェクト骨格)を `/implement` で開始する +- [x] **PLAN_02 ステップ 1 完了(プロジェクト骨格)**: pyproject.toml(uv + hatchling,src レイアウト)・`src/tiasjudge/{core,gui}` 骨格・`data/data.csv`(CP932→UTF-8 変換配置)・ENV_04(開発コマンド)を作成.`uv sync` → `uv run pytest` 通過を確認 + - 設計判断: opencv-python は無指定だと 5.0.x が入るため,ENV_01 の「OpenCV 4.x」と `cv2.ml.SVM` の挙動同一性を優先して `>=4.10,<5` に固定 + - 発見: `data.csv` の実体は 198 列(医師 39 ブロック)だが末尾ブロックは全 0 の未使用列で,有効データは SPEC_02 どおり 38 名分=193 列(SPEC_02 に注記済み).旧実装の読込列数はステップ 2 で `org/src` と突き合わせて確定する + - 未実装・後段: `__main__.py` はプレースホルダ(ステップ 6 で GUI 起動に差し替え).pytest-cov はステップ 2 以降で導入検討 +- [x] **PLAN_02 ステップ 2 完了(core: CSV 読込)**: `core/csv_io.py`(変換行列 17×3・data.csv の読込と形式検証,専用例外 3 種)を実装(無人運転) + - 確定事項: 旧実装の data.csv 読込は **193 列**(`EXP_OUTPUT_DIM=190`=38 名 × 5 色,`CsvRead` cols=193).実ファイル末尾 5 列は旧実装でも未読.SPEC_02 の注記を確定版に更新済み + - 設計判断: dtype は旧実装(`CV_32F`・int キャスト)に合わせ lab=float32 / evaluations=int32.変換行列は計算精度優先で float64(旧実装も double で計算) + - レビューポイント(仕様に規定なし・挙動は現状維持のまま起床後の判断待ち): ① 変換行列の nan/inf セルを受理する(有限値チェック追加の要否)/ ② 評価列 `1.0` 表記を 1 と解釈する / ③ UTF-8 失敗時の cp932 フォールバックの要否(ENV_01 の UTF-8 統一方針との整合)/ ④ 変換行列は 17 行ちょうどを要求(旧実装は 18 行以上でも先頭 17 行で通す) + - 旧実装の潜在バグ発見: `Readfile()` の係数初期化ループが `i < 16` までで `a[16]` 等が未初期化(正常な 17 行ファイルなら影響なし.新実装には持ち込まない) +- [x] **PLAN_02 ステップ 3 完了(core: 色変換)**: `core/color.py`(RGB→XYZ 17 項多項式・XYZ→Lab 現行式)を実装(無人運転).C++ 逐語移植の参照実装と 4 ケースでビット一致を確認 + - 設計判断: 旧実装の精度を忠実に再現(係数 float32 保持 → double 演算 → Lab を float32 丸め).SPEC_02 に精度踏襲の注記を追加.CIE 低輝度分岐は仕様どおり実装しない(テストは「分岐が入っていないこと」まで検証済み) + - テストの検出力をミューテーション 6 種(項順入替・クリップ削除・CIE 分岐追加等)で確認し全検出 + - 留意(ステップ 4 へ): XYZ=0 のとき L\*=−16 と負値になり学習データの範囲(0〜100)外の入力が SVM に入りうる(旧実装と同一挙動.問題ではないが把握しておく) +- [x] **PLAN_02 ステップ 4 完了(core: SVM 分類・ROI)**: `core/svm.py`(190 列 SVM 学習・予測・5 色スコア・上位 2 色・失敗検出)と `core/roi.py`(51×51 ROI 平均色)を実装(無人運転) + - 確定事項: 旧実装の SVM パラメータは C_SVC / RBF / γ=0.011 / C=12.0 / termCrit=EPS のみ(OpenCV 2.4 と 4.x で termCrit の解釈が同一であることをソース比較で確認.COUNT を足すと挙動が変わるため足さない).旧失敗判定マジックナンバー(42/0/5/11/3)は「域外入力に対する SVM の既定応答」であることを実測で特定し,Lab=(−16,0,0) で新実装から完全再現(旧実装との挙動一致の強い傍証) + - **⚠ 要レビュー(無人運転中の仕様変更判断)**: 失敗検出を「直接検出のみ」とする /setup 時の設計判断を一部戻した.直接検出だけでは L\* が小さい正値の域外入力(旧実装は失敗表示)を取りこぼし「紫 42%」等の誤結果を表示してしまうことが tester の実測で判明したため,最上位ルール「診断結果は旧実装と一致」を優先し,**旧パターン判定(42/0/5/11/3 → OUT_OF_DOMAIN)を直接検出と併用する形で復活**させた.SPEC_02「診断失敗の判定」節を改訂済み.異議があれば `LEGACY_FAILURE_PERCENTAGES`(svm.py)と当該節を戻すこと + - 設計判断: 集計の単精度丸め(float32 逐次加算・float32 100 倍 → 偶数丸め)と上位 2 色タイブレーク(旧 std::map キー順 crimson→dark_red→pink→purple→pale)まで旧実装を再現.ROI 範囲外は旧実装のクラッシュを ValueError に置換(GUI 側でガード前提) + - 未実装・後段: TRAINING_FAILED / PREDICTION_FAILED は健全な入力から再現不能のためテスト未発火.GUI 実装時に 5 点目クリックのガードが必要(MAX_ROI_POINTS は core 側の定数のみ) +- [x] **PLAN_02 ステップ 5 準備完了(回帰テスト骨格・採取手順書)**: `tests/test_regression.py`(フィクスチャ CSV があれば照合,無ければ skip)・フィクスチャ形式定義・`docs/06_TEST/TEST_01_回帰テスト採取手順.md`・単色 PNG 生成スクリプト(`scripts/`)を作成(無人運転).**採取は旧 exe を使うユーザー作業**(TEST_01 参照)で,フィクスチャを置くと回帰テストが自動で有効化される + - 設計判断: 旧 exe は RGB/Lab 直接入力不可・クリック位置固定不能のため,全面単色 PNG(ROI がどこでも既知 RGB)で入力を厳密再現する方式.旧 exe の表示は Lab・スコアとも整数(%3.0lf)のため許容誤差は Lab ±0.51・スコア完全一致と定義 + - 採取時の注意(旧実装のバグ回避): 右クリックリセットはクリック数・平均色を戻さない/5 点目は加算済みのままエラー表示.手順書で「1 点のみクリック・やり直しは再起動」と規定 +- [x] **PLAN_02 ステップ 6 完了(GUI: メイン画面)**: `gui/main_window.py`(QWidget 非依存ロジック層+画面層の 2 層構成)と `__main__.py` の GUI 起動化(`--check` オプション付き)を実装(無人運転).**実機での動作確認は未実施**(オフスクリーンでの構築・スモークのみ) + - 設計判断: 旧実装のバグ 3 件を意図的に非踏襲(右クリックリセットでクリック数も戻す/5 点目は加算しない/端クリックは新規ガード文言で案内.SPEC_01 に追記済み).日本語パス対応のため imread でなく read_bytes + imdecode を採用 + - 途中で発見・修正: 0 バイト画像で cv2.error が ImageLoadError に包まれずクラッシュする不具合(テストで検出 → 修正 → xfail 解消の赤 → 緑を確認) + - 後段(ステップ 7 へ): 結果画面の口は diagnosis_finished シグナル+ result_presenter コールバック.暫定 QMessageBox(_present_result / _summarize)はステップ 7 で削除する + - 実機確認が必要な項目は起床後の報告参照(クロップ位置・クリック座標対応・ガード文言・旧 exe との結果一致・高 DPI 表示等) +- [x] **PLAN_02 ステップ 7 完了(GUI: 結果画面・結合)**: `gui/result_window.py`(純ロジック層+ QPainter 描画生成+モードレス ResultWindow)と main_window との結合(暫定 QMessageBox 削除)を実装(無人運転).**実機での動作確認は未実施** + - 設計判断: 日本語結果画面は設計判断どおり描画生成(座標・色は旧 ResultDlg.cpp の値を移植).5 色目の日本語表記は旧実装が画面間で異なっていた(メイン「淡白」・結果「淡白紅」)ことを旧背景画像 img_student2.bmp の実確認で確定し,現行踏襲でどちらも維持(SPEC_01 修正済み) + - 旧実装との軽微な意図的差(SPEC_01 規定なし・要レビュー): % 記号の色(旧黒 → 新赤)・y オフセット +7 の省略・英語時の切替ボタンラベル(旧 "Japanese" → 新「日本語」) + - 残タスク: ステップ 5 の採取(ユーザー作業,TEST_01)→ 回帰テスト有効化が M1.ステップ 6〜7 の実機確認が M2.__main__ の QGuiApplication 既存時の堅牢性は既知の軽微リスクとして記録 +- [x] **結果画面の見た目調整+プレビューコマンド**: ユーザーの実機確認フィードバックを受けた調整(動作自体は問題なしと確認済み) + - 文字色を旧背景画像 img_student2.bmp のピクセル実測値に完全一致させ,5 色の色名のみ黒縁取り(旧画像実測に基づく幅 2 px).フォントサイズも旧画像のインク高実測に合わせ全体拡大(説明文は Medium 500・40 px で確定.ユーザーが試行の上決定) + - 英語の長文が行に収まらない場合は省略でなく段階縮小で全文維持する方式(_fit_font)に変更 + - `uv run tiasjudge --preview-result`(/ `--failed`)を追加: 結果ウィンドウ単体をダミー結果で即起動するデバッグコマンド(毎回の診断操作を省くため.ENV_04 記載) +- [x] **結果画面のサブモニタ最大化表示(2 画面運用)**: ユーザー要望による追加仕様(旧実装に無い).結果画面をメイン画面と別のモニタに最大化表示する(SPEC_01「表示先モニタ」節を新設) + - 設計判断: 基準はプライマリ固定でなく「メイン画面が載っているモニタ」.モニタ 1 台時は従来表示にフォールバック.同一モニタ判定は identity → 同名の 2 段(Qt が別ラッパを返すケース対策).移動 → 最大化の順序を厳守(逆だと元モニタで最大化される) + - 2 画面実機での動作確認済み(ユーザー確認) +- [x] **結果画面の表示専用化(操作をメイン画面へ移動)+英語説明文の行単位サイズ**: ユーザー要望による変更(実機確認済み) + - 結果画面から言語切替・閉じるボタンを削除し表示専用に(患者側モニタに操作 UI を出さない).メイン画面に「結果画面の操作」グループを新設(言語切替は常時有効・閉じるは結果画面が開いている時のみ有効.言語状態はメイン画面が保持し次に開く画面へ継承).プレビューは `--english` オプションで英語確認 + - 英語の説明文サイズは行単位の固定値(Red / Dark red 36 px・Pale red 30 px・他 40 px).一律縮小 → 行単位と試行し,「スコア付き強調行でも縮小なしで収まる実測最大値」を行ごとに採用(強調時にサイズが変わるチカつきを排除).Red は当初 40 px 指定だったが実測で収まらず 36 px に変更(ユーザー承認済み) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..6e10143 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,46 @@ +[project] +name = "tiasjudge" +version = "0.1.0" +description = "舌診支援システム(舌画像の ROI 色から舌色を 5 分類する)" +requires-python = ">=3.12,<3.13" +dependencies = [ + "numpy>=2.0", + # ENV_01 の選定どおり OpenCV 4.x 系に固定する(cv2.ml.SVM の挙動同一性のため) + "opencv-python>=4.10,<5", + "pyside6>=6.7", +] + +[project.scripts] +tiasjudge = "tiasjudge.__main__:main" + +[dependency-groups] +dev = [ + "pytest>=8.0", + "ruff>=0.6", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/tiasjudge"] + +[tool.ruff] +line-length = 100 +target-version = "py312" +src = ["src", "tests"] +# Claude Code 用のフック等は本プロジェクトのコード規約の対象外とする +extend-exclude = [".claude"] + +[tool.ruff.lint] +# E/W: pycodestyle, F: pyflakes, I: isort, N: pep8-naming, +# UP: pyupgrade, B: flake8-bugbear, SIM: flake8-simplify +select = ["E", "W", "F", "I", "N", "UP", "B", "SIM"] + +[tool.ruff.format] +quote-style = "double" + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-ra" diff --git a/scripts/generate_regression_images.py b/scripts/generate_regression_images.py new file mode 100644 index 0000000..c16f505 --- /dev/null +++ b/scripts/generate_regression_images.py @@ -0,0 +1,147 @@ +"""回帰テスト採取用の単色 PNG を生成する(PLAN_02 ステップ 5). + +旧実装 exe は «画像を読み込んでクリックした ROI の平均色» しか入力にできず, +RGB / L*a*b* を直接入力する手段が無い.また,クリック位置は手作業では固定できない +(PLAN_02 の注記).そこで **画像全面を単一色で塗った PNG** を読み込ませることで, +クリック位置によらず ROI 平均色 = 画像の色(既知の RGB)となるようにする. + +出力サイズは既定で 1024×1280(旧実装のクロップ後サイズ)とし, +旧 exe のクロップ(中央基準の切り出し)が入っても色が変わらないようにする. + +使い方(詳細は docs/06_TEST/TEST_01_回帰テスト採取手順.md): + +```bash +uv run python scripts/generate_regression_images.py --out-dir out/regression_images +``` + +色を指定する場合(`R,G,B` を空白区切りで複数指定できる): + +```bash +uv run python scripts/generate_regression_images.py --out-dir out/images \ + --color 156,102,98 --color 0,0,0 +``` +""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +import cv2 +import numpy as np +from numpy.typing import NDArray + +#: 旧実装がクロップ後に扱う画像サイズ(SPEC_01「画像選択」) +DEFAULT_WIDTH = 1024 +DEFAULT_HEIGHT = 1280 + +#: 既定の採取ケース(R, G, B, 意図). +#: 「基準色」は旧実装のコメントに残る平均 BGR {98.4, 102.27, 156.4}(= RGB 156,102,98)に由来する. +#: 代表的な舌色をその周辺に散らし,加えて境界値・学習データ域外(診断失敗が出うる)色を含める +DEFAULT_COLORS: tuple[tuple[int, int, int, str], ...] = ( + (156, 102, 98, "基準色(旧実装コメントの平均 BGR 由来)"), + (180, 120, 115, "やや明るい舌色"), + (205, 145, 140, "淡い舌色(淡紅・淡白寄り)"), + (140, 85, 85, "やや暗い舌色"), + (115, 62, 68, "暗い舌色(暗赤・紫寄り)"), + (170, 92, 112, "赤紫寄り"), + (196, 74, 74, "鮮やかな紅寄り"), + (150, 110, 130, "青み寄り"), + (255, 255, 255, "境界値: 白(明るさの上限)"), + (0, 0, 0, "境界値: 黒(XYZ が 0 にクリップされる想定)"), + (255, 0, 0, "域外: 純赤"), + (0, 0, 255, "域外: 純青"), +) + + +def build_solid_image(color: tuple[int, int, int], width: int, height: int) -> NDArray[np.uint8]: + """指定 RGB の単色画像(OpenCV の BGR 配列)を作る.""" + red, green, blue = color + image = np.empty((height, width, 3), dtype=np.uint8) + image[:, :] = (blue, green, red) + return image + + +def parse_color(text: str) -> tuple[int, int, int]: + """`R,G,B` 形式の文字列を (R, G, B) に変換する.""" + parts = [part.strip() for part in text.split(",")] + if len(parts) != 3: + raise argparse.ArgumentTypeError(f"色は R,G,B の 3 要素で指定してください: {text!r}") + try: + values = tuple(int(part) for part in parts) + except ValueError as error: + raise argparse.ArgumentTypeError(f"色は整数で指定してください: {text!r}") from error + if any(not 0 <= value <= 255 for value in values): + raise argparse.ArgumentTypeError(f"色は 0〜255 の範囲で指定してください: {text!r}") + return values[0], values[1], values[2] + + +def image_name(color: tuple[int, int, int]) -> str: + """RGB からファイル名を作る(例: `rgb_156_102_098.png`).""" + red, green, blue = color + return f"rgb_{red:03d}_{green:03d}_{blue:03d}.png" + + +def write_images( + colors: list[tuple[int, int, int]], out_dir: Path, width: int, height: int +) -> list[Path]: + """単色 PNG を書き出し,書き出したパスの一覧を返す.""" + out_dir.mkdir(parents=True, exist_ok=True) + written: list[Path] = [] + for color in colors: + path = out_dir / image_name(color) + if not cv2.imwrite(str(path), build_solid_image(color, width, height)): + raise OSError(f"PNG を書き出せませんでした: {path}") + written.append(path) + return written + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + """コマンドライン引数を解釈する.""" + parser = argparse.ArgumentParser(description="回帰テスト採取用の単色 PNG を生成する") + parser.add_argument( + "--out-dir", + type=Path, + required=True, + help="出力先ディレクトリ(リポジトリ外・作業用フォルダを推奨)", + ) + parser.add_argument( + "--color", + type=parse_color, + action="append", + dest="colors", + metavar="R,G,B", + help="生成する色(複数指定可).省略時は既定の採取ケースを生成する", + ) + parser.add_argument("--width", type=int, default=DEFAULT_WIDTH, help="画像の幅(px)") + parser.add_argument("--height", type=int, default=DEFAULT_HEIGHT, help="画像の高さ(px)") + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + """単色 PNG を生成し,採取表の雛形(CSV の行)を標準出力に出す.""" + args = parse_args(argv) + colors = args.colors or [(red, green, blue) for red, green, blue, _ in DEFAULT_COLORS] + notes = {(red, green, blue): note for red, green, blue, note in DEFAULT_COLORS} + + written = write_images(colors, args.out_dir, args.width, args.height) + + print(f"{len(written)} 枚を生成しました: {args.out_dir}") + print("") + print("# 採取表の雛形(tests/fixtures/regression/rgb_to_lab.csv 用)") + print("matrix,r,g,b,l_star,a_star,b_star,note") + for color in colors: + red, green, blue = color + print(f"<変換行列ファイル名>,{red},{green},{blue},,,,{notes.get(color, '')}") + print("") + print("# 採取表の雛形(tests/fixtures/regression/rgb_to_scores.csv 用)") + print("matrix,r,g,b,purple,dark_red,crimson,pink,pale,failed,note") + for color in colors: + red, green, blue = color + print(f"<変換行列ファイル名>,{red},{green},{blue},,,,,,,{notes.get(color, '')}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/tiasjudge/__init__.py b/src/tiasjudge/__init__.py new file mode 100644 index 0000000..454b358 --- /dev/null +++ b/src/tiasjudge/__init__.py @@ -0,0 +1,3 @@ +"""TIASjudge: 舌診支援システム.""" + +__version__ = "0.1.0" diff --git a/src/tiasjudge/__main__.py b/src/tiasjudge/__main__.py new file mode 100644 index 0000000..79b57fa --- /dev/null +++ b/src/tiasjudge/__main__.py @@ -0,0 +1,181 @@ +"""エントリポイント(`python -m tiasjudge`). + +`QApplication` を生成してメイン画面(SPEC_01)を表示する. +ログの出力先設定はエントリポイント側の責務とする(error-handling.md「ログ」). + +起動オプション: + +- `--check`: 画面を生成して即終了する(起動可能性の確認用.イベントループを回さない) +- `--preview-result`: 結果画面(SPEC_01「結果画面」)だけをダミー結果で開く(デバッグ用) +- `--failed`: `--preview-result` と併用し,診断失敗パターンで開く +- `--english`: `--preview-result` と併用し,英語表示で開く(結果画面は表示専用のため, + プレビューでは画面内で言語を切り替えられない) +""" + +from __future__ import annotations + +import argparse +import logging +import sys +from collections.abc import Sequence +from typing import TYPE_CHECKING + +import numpy as np + +if TYPE_CHECKING: # pragma: no cover - 型チェック時のみ + from numpy.typing import NDArray + from PySide6.QtWidgets import QApplication + + from tiasjudge.core.svm import DiagnosisResult + +logger = logging.getLogger(__name__) + +#: `--check`(イベントループを回さずに終了する)オプション +CHECK_OPTION = "--check" + +#: ログの書式(診断の入出力は `core` が INFO で記録する) +LOG_FORMAT = "%(asctime)s %(levelname)s %(name)s: %(message)s" + +# --- `--preview-result` 用のダミーデータ(デバッグ表示専用.診断は実行しない)--- + +#: プレビューに使うダミー画像の一辺(結果画面は 1/2 に縮小して表示する) +PREVIEW_IMAGE_SIZE = 400 +#: プレビューに使うダミー画像の色(舌色に近い RGB) +PREVIEW_IMAGE_RGB = (198, 120, 120) +#: プレビューのダミー L*a*b*(学習データの範囲内にある代表値) +PREVIEW_LAB = (58.0, 24.0, 12.0) +#: 成功パターンのダミースコア(%) +PREVIEW_PERCENTAGES: dict[str, int] = { + "purple": 5, + "dark_red": 10, + "crimson": 25, + "pink": 55, + "pale": 5, +} +#: 成功パターンの上位 2 色(`PREVIEW_PERCENTAGES` を降順に並べた先頭 2 件) +PREVIEW_TOP_LABELS = ("pink", "crimson") +#: 失敗パターンの上位 2 色(`LEGACY_FAILURE_PERCENTAGES` を降順に並べた先頭 2 件) +PREVIEW_FAILED_TOP_LABELS = ("purple", "pink") + + +def _build_parser() -> argparse.ArgumentParser: + """コマンドライン引数のパーサを作る.""" + parser = argparse.ArgumentParser( + prog="tiasjudge", + description="舌診支援システム(TIAS)を起動する.", + ) + parser.add_argument( + CHECK_OPTION, + action="store_true", + help="画面を生成して即終了する(起動可能性の確認用)", + ) + parser.add_argument( + "--preview-result", + action="store_true", + help="結果画面だけをダミー結果で開く(デバッグ用.診断は実行しない)", + ) + parser.add_argument( + "--failed", + action="store_true", + help="--preview-result と併用し,診断失敗パターンで開く", + ) + parser.add_argument( + "--english", + action="store_true", + help="--preview-result と併用し,英語表示で開く", + ) + return parser + + +def _preview_image() -> NDArray[np.uint8]: + """プレビュー用の単色ダミー画像(BGR)を作る.""" + red, green, blue = PREVIEW_IMAGE_RGB + image = np.empty((PREVIEW_IMAGE_SIZE, PREVIEW_IMAGE_SIZE, 3), dtype=np.uint8) + # OpenCV と同じ BGR 並びで埋める + image[:, :] = (blue, green, red) + return image + + +def _preview_result(*, failed: bool) -> DiagnosisResult: + """プレビュー用のダミー診断結果を組み立てる(SVM 学習は行わない).""" + from tiasjudge.core.color import Lab + from tiasjudge.core.csv_io import EVALUATION_COLUMN_COUNT + from tiasjudge.core.svm import ( + LEGACY_FAILURE_PERCENTAGES, + DiagnosisResult, + FailureReason, + ) + + percentages = dict(LEGACY_FAILURE_PERCENTAGES) if failed else dict(PREVIEW_PERCENTAGES) + top_labels = PREVIEW_FAILED_TOP_LABELS if failed else PREVIEW_TOP_LABELS + return DiagnosisResult( + lab=Lab(*PREVIEW_LAB), + scores={key: value / 100 for key, value in percentages.items()}, + percentages=percentages, + top_labels=top_labels, + predictions=np.zeros(EVALUATION_COLUMN_COUNT, dtype=np.float32), + failed=failed, + failure_reason=FailureReason.OUT_OF_DOMAIN if failed else None, + ) + + +def _run_preview(app: QApplication, *, failed: bool, check: bool, english: bool = False) -> int: + """結果画面だけをダミー結果で開く(`--preview-result`). + + 結果画面は表示専用(言語切替ボタンを持たない)ため,プレビューの言語は + `--english` オプションで指定する. + """ + # 関数内 import は起動時のみ GUI を読み込むため(`core` は GUI 非依存) + from tiasjudge.gui.result_window import Language, ResultWindow, show_on_secondary_screen + + language = Language.ENGLISH if english else Language.JAPANESE + logger.info("結果画面のプレビューを開きます(failed=%s・language=%s)", failed, language.value) + window = ResultWindow(_preview_result(failed=failed), _preview_image(), language) + if check: + window.close() + return 0 + + # プレビューにはメイン画面が無いため,プライマリモニタを基準に別モニタを選ぶ + show_on_secondary_screen(window, app.screens(), app.primaryScreen()) + return int(app.exec()) + + +def main(argv: Sequence[str] | None = None) -> int: + """アプリを起動する.戻り値はプロセスの終了コード. + + Args: + argv: コマンドライン引数(プログラム名を含まない).省略時は `sys.argv[1:]`. + + Returns: + プロセスの終了コード(0 が正常終了). + """ + arguments = _build_parser().parse_args(list(sys.argv[1:] if argv is None else argv)) + logging.basicConfig(level=logging.INFO, format=LOG_FORMAT) + + # GUI ツールキットの import は起動時のみに限定する(`core` は GUI 非依存) + from PySide6.QtWidgets import QApplication + + app = QApplication.instance() or QApplication(sys.argv[:1]) + + if arguments.preview_result: + return _run_preview( + app, + failed=arguments.failed, + english=arguments.english, + check=arguments.check, + ) + + from tiasjudge.gui.main_window import MainWindow + + window = MainWindow() + if arguments.check: + # 画面を構築できることだけを確認して終了する(ヘッドレス環境での確認用) + window.close() + return 0 + + window.show() + return int(app.exec()) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/tiasjudge/core/__init__.py b/src/tiasjudge/core/__init__.py new file mode 100644 index 0000000..f80a289 --- /dev/null +++ b/src/tiasjudge/core/__init__.py @@ -0,0 +1,5 @@ +"""診断ロジック(GUI 非依存). + +このパッケージから PySide6・GUI モジュールを import してはならない +(`.claude/rules/project-structure.md` の依存方向のルール). +""" diff --git a/src/tiasjudge/core/color.py b/src/tiasjudge/core/color.py new file mode 100644 index 0000000..be55b0a --- /dev/null +++ b/src/tiasjudge/core/color.py @@ -0,0 +1,225 @@ +"""色変換(RGB → XYZ → L*a*b*). + +SPEC_02 (1)(2) の式を実装する.旧実装(`org/src` の `CtonguediagnosisMFC2Dlg::CalcLab()`)と +数値が一致することを最優先とし,式・項の順序・データ型(係数 float32/演算 double/ +Lab 出力 float32)を踏襲する. + +※ Lab 変換には CIE 標準の低輝度側の条件分岐(t <= (6/29)^3 の線形近似)が無い. +これは旧実装に無いための**意図的な踏襲**であり,診断結果の同一性を優先した判断である +(SPEC_02 (2) の注記を参照).CIE 的に「正しい」式へ直してはならない. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass + +import numpy as np +from numpy.typing import ArrayLike, NDArray + +logger = logging.getLogger(__name__) + +# --- 定数(SPEC_02 (1)(2))--- + +#: 多項式変換の項数(変換行列 CSV の行数と一致する) +POLYNOMIAL_TERM_COUNT = 17 +#: 色の成分数(RGB / XYZ / Lab の 3 成分.変換行列の列数 a[i], b[i], c[i] とも一致する) +COMPONENT_COUNT = 3 + +#: Lab 変換に用いる白色点(旧実装で固定値.SPEC_02 (2)) +WHITE_POINT_X = 102.07 +WHITE_POINT_Y = 100.0 +WHITE_POINT_Z = 79.41 + +#: 立方根の指数(旧実装の `pow(t, 1.0/3.0)` と同じ) +_CUBE_ROOT_EXPONENT = 1.0 / 3.0 + + +@dataclass(frozen=True) +class Xyz: + """三刺激値 XYZ(負値は 0 にクリップ済み).""" + + x: float + y: float + z: float + + def as_array(self) -> NDArray[np.float64]: + """形状 (3,)・dtype float64 の配列 [X, Y, Z] を返す.""" + return np.array([self.x, self.y, self.z], dtype=np.float64) + + +@dataclass(frozen=True) +class Lab: + """L*a*b* 値(旧実装と同じく float32 相当に丸めた値).""" + + l_star: float + a_star: float + b_star: float + + def as_array(self) -> NDArray[np.float32]: + """形状 (3,)・dtype float32 の配列 [L*, a*, b*] を返す(SVM 入力用).""" + return np.array([self.l_star, self.a_star, self.b_star], dtype=np.float32) + + +def rgb_to_xyz(rgb: ArrayLike, coefficients: ArrayLike) -> Xyz: + """RGB を多項式回帰で XYZ に変換する(SPEC_02 (1)). + + 旧実装と同じく,係数は float32 で保持された値(CSV を `fscanf("%f")` で読んだ相当)を + double に昇格して計算し,結果の負値は 0 にクリップする. + + Args: + rgb: (R, G, B) の 3 要素(0〜255 の平均色.`roi.py` の ROI 平均を想定). + coefficients: 形状 (17, 3) の係数(`csv_io.load_transform_matrix` の戻り値). + 列 0 が a[i](X 用),列 1 が b[i](Y 用),列 2 が c[i](Z 用). + + Returns: + 変換後の `Xyz`(各成分は 0 以上). + + Raises: + ValueError: `rgb` が 3 要素でない,または係数の形状が (17, 3) でない場合. + """ + red, green, blue = _as_rgb(rgb) + matrix = _as_coefficients(coefficients) + + terms = _polynomial_terms(red, green, blue) + # 項の並びは旧実装の加算順(1 次 → 2 次クロス → 2 乗 → 3 次 → R*G*B → 定数)に対応する + xyz = terms @ matrix + + # 旧実装の `if(X < 0) X = 0;`(Y, Z も同様)に対応する + clipped = np.maximum(xyz, 0.0) + return Xyz(x=float(clipped[0]), y=float(clipped[1]), z=float(clipped[2])) + + +def xyz_to_lab(xyz: Xyz | ArrayLike) -> Lab: + """XYZ を L*a*b* に変換する(SPEC_02 (2)). + + 白色点は Xn=102.07, Yn=100.0, Zn=79.41 の固定値で,比の立方根をそのまま用いる + (CIE 標準の低輝度側の分岐は旧実装に無いため実装しない). + + Args: + xyz: `Xyz`,または (X, Y, Z) の 3 要素(いずれも 0 以上). + + Returns: + 変換後の `Lab`(旧実装が float 変数へ代入するのと同じく float32 相当に丸める). + + Raises: + ValueError: 要素数が 3 でない場合,または負の成分を含む場合. + """ + x, y, z = _as_xyz(xyz) + + # 旧実装: pow((Y / Yn), 1.0/3.0) 等.double で計算する + fx = (x / WHITE_POINT_X) ** _CUBE_ROOT_EXPONENT + fy = (y / WHITE_POINT_Y) ** _CUBE_ROOT_EXPONENT + fz = (z / WHITE_POINT_Z) ** _CUBE_ROOT_EXPONENT + + lightness = 116.0 * fy - 16.0 + a_star = 500.0 * (fx - fy) + b_star = 200.0 * (fy - fz) + + # 旧実装は結果を float(単精度)変数に格納するため,同じ丸めを再現する + return Lab( + l_star=_to_single(lightness), + a_star=_to_single(a_star), + b_star=_to_single(b_star), + ) + + +def rgb_to_lab(rgb: ArrayLike, coefficients: ArrayLike) -> Lab: + """RGB を L*a*b* に変換する(SPEC_02 (1) → (2) の連結). + + Args: + rgb: (R, G, B) の 3 要素(0〜255 の平均色). + coefficients: 形状 (17, 3) の係数(`csv_io.load_transform_matrix` の戻り値). + + Returns: + 変換後の `Lab`. + """ + red, green, blue = _as_rgb(rgb) + xyz = rgb_to_xyz((red, green, blue), coefficients) + lab = xyz_to_lab(xyz) + logger.info( + "色変換: RGB=(%.6f, %.6f, %.6f) -> XYZ=(%.6f, %.6f, %.6f) -> Lab=(%.6f, %.6f, %.6f)", + red, + green, + blue, + xyz.x, + xyz.y, + xyz.z, + lab.l_star, + lab.a_star, + lab.b_star, + ) + return lab + + +def _polynomial_terms(red: float, green: float, blue: float) -> NDArray[np.float64]: + """多項式 17 項を旧実装と同じ並びで返す(形状 (17,)・dtype float64). + + 並び: R, G, B, RG, RB, GB, R^2, G^2, B^2, R^2*B, R^2*G, G^2*R, G^2*B, + B^2*R, B^2*G, R*G*B, 1(定数項). + """ + return np.array( + [ + red, + green, + blue, + red * green, + red * blue, + green * blue, + red * red, + green * green, + blue * blue, + red * red * blue, + red * red * green, + green * green * red, + green * green * blue, + blue * blue * red, + blue * blue * green, + red * green * blue, + 1.0, + ], + dtype=np.float64, + ) + + +def _as_components(values: ArrayLike, label: str) -> NDArray[np.float64]: + """入力を double の 3 成分配列に整える(要素数が違えば `ValueError`).""" + array = np.asarray(values, dtype=np.float64).reshape(-1) + if array.size != COMPONENT_COUNT: + raise ValueError( + f"{label} は {COMPONENT_COUNT} 要素である必要があります: {array.size} 要素" + ) + return array + + +def _as_rgb(rgb: ArrayLike) -> tuple[float, float, float]: + """入力を (R, G, B) の double 3 要素に整える(旧実装の m_R/m_G/m_B は double).""" + values = _as_components(rgb, "RGB") + return float(values[0]), float(values[1]), float(values[2]) + + +def _as_xyz(xyz: Xyz | ArrayLike) -> tuple[float, float, float]: + """入力を (X, Y, Z) の double 3 要素に整える(負値があれば拒否する).""" + values = _as_components(xyz.as_array() if isinstance(xyz, Xyz) else xyz, "XYZ") + if bool(np.any(values < 0.0)): + # 旧実装は XYZ を 0 でクリップしてから Lab 変換する(負値なら pow が NaN になる) + raise ValueError(f"XYZ に負の成分があります: {tuple(float(v) for v in values)}") + return float(values[0]), float(values[1]), float(values[2]) + + +def _as_coefficients(coefficients: ArrayLike) -> NDArray[np.float64]: + """係数を検証し,旧実装と同じ有効桁(float32 保持)で double 配列にして返す.""" + matrix = np.asarray(coefficients, dtype=np.float64) + if matrix.shape != (POLYNOMIAL_TERM_COUNT, COMPONENT_COUNT): + raise ValueError( + f"変換行列は形状 ({POLYNOMIAL_TERM_COUNT}, {COMPONENT_COUNT}) が必要です: " + f"{matrix.shape}" + ) + # 旧実装は係数を float 配列(`float a[17]` を `fscanf("%f")` で読む)に保持するため, + # 同じ丸め(単精度)を経てから double 演算に用いる + return matrix.astype(np.float32).astype(np.float64) + + +def _to_single(value: float) -> float: + """double の計算結果を単精度に丸める(旧実装の float 変数への代入に対応).""" + return float(np.float32(value)) diff --git a/src/tiasjudge/core/csv_io.py b/src/tiasjudge/core/csv_io.py new file mode 100644 index 0000000..86952a0 --- /dev/null +++ b/src/tiasjudge/core/csv_io.py @@ -0,0 +1,231 @@ +"""CSV 読込(変換行列・学習データ). + +SPEC_02「データファイル形式」に対応する読込と形式検証をまとめる. +旧実装(`org/src` の `Readfile()` / `CsvRead()`)と同じ実効データになるよう, +読み込む行数・列数と dtype(float32)を踏襲する. +""" + +from __future__ import annotations + +import csv +import logging +from dataclasses import dataclass +from pathlib import Path + +import numpy as np +from numpy.typing import NDArray + +logger = logging.getLogger(__name__) + +# --- 形式に関する定数(SPEC_02「データファイル形式」)--- + +#: 変換行列 CSV の行数(多項式 17 項.SPEC_02 (1)) +TRANSFORM_COEFFICIENT_COUNT = 17 +#: 変換行列 CSV の列数(a[i], b[i], c[i] の 3 列) +TRANSFORM_COLUMN_COUNT = 3 + +#: data.csv の学習サンプル数(旧実装 EXP_DATA_ROW = 84) +SAMPLE_COUNT = 84 +#: SVM 入力の次元(L*a*b*.旧実装 EXP_INPUT_DIM = 3) +LAB_DIMENSION = 3 +#: 舌色の分類数(紫・暗赤・紅・淡紅・淡白) +COLOR_COUNT = 5 +#: 医師の人数(旧実装 EXP_OUTPUT_DIM 190 = 38 名 × 5 色) +DOCTOR_COUNT = 38 +#: 医師評価列の数(旧実装 EXP_OUTPUT_DIM = 190) +EVALUATION_COLUMN_COUNT = DOCTOR_COUNT * COLOR_COUNT +#: data.csv から読み込む列数(旧実装は 3 + 190 = 193 列だけを読み,以降の列は無視する) +TRAINING_COLUMN_COUNT = LAB_DIMENSION + EVALUATION_COLUMN_COUNT +#: data.csv のヘッダ行数 +HEADER_ROW_COUNT = 1 + +# 読込に使う文字コードの候補(同梱データは UTF-8.旧実装由来のファイルは Shift-JIS) +_ENCODINGS = ("utf-8", "cp932") + + +class CsvIoError(Exception): + """CSV 読込に関するエラーの基底(`core` の専用例外).""" + + +class DataFileNotFoundError(CsvIoError): + """CSV ファイルが存在しない,または読み取れない.""" + + +class DataFormatError(CsvIoError): + """CSV の形式が仕様と異なる(行数・列数不一致,数値でないセル等).""" + + +@dataclass(frozen=True) +class TrainingData: + """`data.csv` の学習データ(SPEC_02 (3) の SVM 入力). + + Attributes: + lab: 形状 (84, 3)・dtype float32.各行が 1 サンプルの L*a*b*. + evaluations: 形状 (84, 190)・dtype int32.医師 38 名 × 5 色の 0/1 評価. + 列 `doctor_index * 5 + color_index` が医師ごとの one-hot に対応する. + """ + + lab: NDArray[np.float32] + evaluations: NDArray[np.int32] + + @property + def sample_count(self) -> int: + """学習サンプル数(= 84).""" + return int(self.lab.shape[0]) + + @property + def output_dimension(self) -> int: + """SVM の出力次元(= 医師数 × 5 色 = 190).""" + return int(self.evaluations.shape[1]) + + @property + def doctor_count(self) -> int: + """医師の人数(= 38).""" + return self.output_dimension // COLOR_COUNT + + def color_columns(self, color_index: int) -> NDArray[np.int32]: + """指定した色(0〜4)に対応する全医師分の評価列を返す(形状 (84, 医師数)).""" + if not 0 <= color_index < COLOR_COUNT: + raise ValueError(f"color_index は 0〜{COLOR_COUNT - 1} の範囲: {color_index}") + return self.evaluations[:, color_index::COLOR_COUNT] + + +def load_transform_matrix(path: Path | str) -> NDArray[np.float64]: + """変換行列 CSV(17 行 × 3 列・ヘッダなし)を読み込む. + + Args: + path: 変換行列 CSV のパス(ユーザーが診断時に選択するファイル). + + Returns: + 形状 (17, 3)・dtype float64 の配列.列 0 が a[i](X 用), + 列 1 が b[i](Y 用),列 2 が c[i](Z 用)の係数(SPEC_02 (1)). + + Raises: + DataFileNotFoundError: ファイルが存在しない・読み取れない場合. + DataFormatError: 行数・列数が仕様と異なる,または数値でないセルがある場合. + """ + csv_path = Path(path) + rows = _read_rows(csv_path) + + if len(rows) != TRANSFORM_COEFFICIENT_COUNT: + raise DataFormatError( + f"変換行列 CSV は {TRANSFORM_COEFFICIENT_COUNT} 行必要ですが " + f"{len(rows)} 行です: {csv_path}" + ) + + matrix = np.empty((TRANSFORM_COEFFICIENT_COUNT, TRANSFORM_COLUMN_COUNT), dtype=np.float64) + for row_index, row in enumerate(rows): + if len(row) != TRANSFORM_COLUMN_COUNT: + raise DataFormatError( + f"変換行列 CSV の {row_index + 1} 行目は " + f"{TRANSFORM_COLUMN_COUNT} 列必要ですが {len(row)} 列です: {csv_path}" + ) + for column_index, cell in enumerate(row): + matrix[row_index, column_index] = _parse_float( + cell, csv_path, row_index + 1, column_index + 1 + ) + + logger.info("変換行列を読み込みました: %s", csv_path) + return matrix + + +def load_training_data(path: Path | str) -> TrainingData: + """学習データ `data.csv`(ヘッダ 1 行 + 84 サンプル)を読み込む. + + 列 1〜3 が L*a*b*,列 4〜193 が医師 38 名 × 5 色の 0/1 評価である. + 旧実装(`CsvRead(..., 84, 3 + 190, 2, 1)`)と同じく先頭 193 列のみを読み, + 194 列目以降(全サンプルで全 0 の未使用ブロック)は無視する. + + Args: + path: `data.csv` のパス(同梱データ.`core` はパスを固定しない). + + Returns: + Lab と医師評価を保持する `TrainingData`. + + Raises: + DataFileNotFoundError: ファイルが存在しない・読み取れない場合. + DataFormatError: 行数・列数が仕様と異なる,数値でないセル, + または評価列に 0/1 以外の値がある場合. + """ + csv_path = Path(path) + rows = _read_rows(csv_path) + + expected_row_count = HEADER_ROW_COUNT + SAMPLE_COUNT + if len(rows) != expected_row_count: + raise DataFormatError( + f"data.csv はヘッダ 1 行 + {SAMPLE_COUNT} サンプルの " + f"{expected_row_count} 行必要ですが {len(rows)} 行です: {csv_path}" + ) + + lab = np.empty((SAMPLE_COUNT, LAB_DIMENSION), dtype=np.float32) + evaluations = np.empty((SAMPLE_COUNT, EVALUATION_COLUMN_COUNT), dtype=np.int32) + + for sample_index, row in enumerate(rows[HEADER_ROW_COUNT:]): + line_number = sample_index + HEADER_ROW_COUNT + 1 + if len(row) < TRAINING_COLUMN_COUNT: + raise DataFormatError( + f"data.csv の {line_number} 行目は {TRAINING_COLUMN_COUNT} 列以上必要ですが " + f"{len(row)} 列です: {csv_path}" + ) + for lab_index, cell in enumerate(row[:LAB_DIMENSION]): + lab[sample_index, lab_index] = _parse_float(cell, csv_path, line_number, lab_index + 1) + for offset, cell in enumerate(row[LAB_DIMENSION:TRAINING_COLUMN_COUNT]): + evaluations[sample_index, offset] = _parse_binary( + cell, csv_path, line_number, LAB_DIMENSION + offset + 1 + ) + + logger.info( + "学習データを読み込みました: %s(%d サンプル × %d 列)", + csv_path, + SAMPLE_COUNT, + TRAINING_COLUMN_COUNT, + ) + return TrainingData(lab=lab, evaluations=evaluations) + + +def _read_rows(csv_path: Path) -> list[list[str]]: + """CSV を読み,空行を除いた行のリスト(各行はセルのリスト)を返す.""" + text = _read_text(csv_path) + rows = list(csv.reader(text.splitlines())) + return [row for row in rows if any(cell.strip() for cell in row)] + + +def _read_text(csv_path: Path) -> str: + """CSV をテキストとして読む(UTF-8 優先,失敗時は Shift-JIS を試す).""" + try: + data = csv_path.read_bytes() + except OSError as error: + raise DataFileNotFoundError(f"CSV ファイルを読み込めません: {csv_path}") from error + + for encoding in _ENCODINGS: + try: + return data.decode(encoding) + except UnicodeDecodeError: + continue + raise DataFormatError(f"CSV の文字コードを判別できません(UTF-8・Shift-JIS 以外): {csv_path}") + + +def _cell_position(csv_path: Path, line_number: int, column_number: int) -> str: + """エラーメッセージ用にセル位置を表す文字列を組み立てる.""" + return f"{csv_path} の {line_number} 行 {column_number} 列" + + +def _parse_float(cell: str, csv_path: Path, line_number: int, column_number: int) -> float: + """セルを実数として解釈する.数値でなければ `DataFormatError`.""" + try: + return float(cell.strip()) + except ValueError as error: + raise DataFormatError( + f"{_cell_position(csv_path, line_number, column_number)}が数値ではありません: {cell!r}" + ) from error + + +def _parse_binary(cell: str, csv_path: Path, line_number: int, column_number: int) -> int: + """医師評価のセル(0 または 1)を解釈する.それ以外は `DataFormatError`.""" + value = _parse_float(cell, csv_path, line_number, column_number) + if value not in (0.0, 1.0): + raise DataFormatError( + f"{_cell_position(csv_path, line_number, column_number)}は " + f"0 か 1 である必要があります: {cell!r}" + ) + return int(value) diff --git a/src/tiasjudge/core/roi.py b/src/tiasjudge/core/roi.py new file mode 100644 index 0000000..0f338c6 --- /dev/null +++ b/src/tiasjudge/core/roi.py @@ -0,0 +1,146 @@ +"""ROI(関心領域)の平均色算出. + +SPEC_02 (0) を実装する.旧実装(`org/src` の `CtonguediagnosisMFC2Dlg::getRGB()` と +`OnBnClickedButtonCalc()`)と同じく,クリック点を中心とする 51×51 px の矩形 ROI の +平均 BGR を求め,複数点クリック時は各 ROI 平均をさらに平均する. + +※ 画像は numpy 配列(OpenCV の BGR 画像)として受け取り,GUI には依存しない +(`core` の依存方向は project-structure.md を参照). +""" + +from __future__ import annotations + +import logging +from collections.abc import Sequence + +import numpy as np +from numpy.typing import NDArray + +logger = logging.getLogger(__name__) + +# --- 定数(SPEC_02 (0) / SPEC_01「ROI 指定」)--- + +#: ROI の一辺の長さ(px.旧実装 `cv::Rect(x - 25, y - 25, 51, 51)`) +ROI_SIZE = 51 +#: ROI 中心から左上までのオフセット(px.旧実装の -25) +ROI_OFFSET = 25 +#: クリック可能な最大点数(SPEC_01.5 点目はエラー) +MAX_ROI_POINTS = 4 +#: BGR 画像のチャンネル数 +CHANNEL_COUNT = 3 +#: BGR 画像の軸数(高さ・幅・チャンネルの 3 軸.`CHANNEL_COUNT` とは意味が異なる) +_IMAGE_NDIM = 3 + + +def roi_mean_bgr(image: NDArray[np.generic], point: tuple[int, int]) -> tuple[float, float, float]: + """クリック点を中心とする 51×51 px の ROI の平均 BGR を返す. + + Args: + image: BGR 画像(形状 (高さ, 幅, 3).OpenCV の `imread` と同じ並び). + point: クリック点 (x, y).画像座標(クロップ後・縮小前)で指定する. + + Returns: + (B, G, R) の平均値(float.丸めは行わない). + + Raises: + ValueError: 画像が BGR 3 チャンネルでない場合, + または ROI が画像の範囲外にはみ出す場合. + """ + array = _as_bgr_image(image) + x, y = _as_point(point) + + left = x - ROI_OFFSET + top = y - ROI_OFFSET + height, width = array.shape[:2] + # 旧実装は範囲チェックを持たず,はみ出すと OpenCV が例外を投げる. + # 新実装では原因の分かるエラーにして GUI 側でガードできるようにする + if left < 0 or top < 0 or left + ROI_SIZE > width or top + ROI_SIZE > height: + raise ValueError( + f"ROI が画像の範囲外です: 中心 ({x}, {y})・ROI {ROI_SIZE}x{ROI_SIZE}・" + f"画像 {width}x{height}" + ) + + region = array[top : top + ROI_SIZE, left : left + ROI_SIZE] + # 旧実装の `cv::mean(roiBGR)` に対応する(各チャンネルの単純平均) + totals = region.reshape(-1, CHANNEL_COUNT).sum(axis=0, dtype=np.float64) + means = totals / float(ROI_SIZE * ROI_SIZE) + return float(means[0]), float(means[1]), float(means[2]) + + +def mean_bgr( + image: NDArray[np.generic], points: Sequence[tuple[int, int]] +) -> tuple[float, float, float]: + """複数のクリック点の ROI 平均 BGR を,さらに平均して返す(SPEC_02 (0)). + + 旧実装の `m_meanColors += meanBGR` → `m_meanColors /= m_numClick` に対応する + (各 ROI 平均の合算をクリック数で割る). + + Args: + image: BGR 画像(形状 (高さ, 幅, 3)). + points: クリック点 (x, y) の並び(1 点以上). + + Returns: + (B, G, R) の平均値. + + Raises: + ValueError: 点が 1 つも無い場合,画像が不正な場合, + または ROI が画像の範囲外にはみ出す場合. + """ + if len(points) == 0: + raise ValueError("ROI の平均色には 1 点以上のクリック点が必要です") + + totals = np.zeros(CHANNEL_COUNT, dtype=np.float64) + for point in points: + totals += np.asarray(roi_mean_bgr(image, point), dtype=np.float64) + means = totals / float(len(points)) + + logger.info( + "ROI 平均色: %d 点 -> BGR=(%.6f, %.6f, %.6f)", + len(points), + means[0], + means[1], + means[2], + ) + return float(means[0]), float(means[1]), float(means[2]) + + +def mean_rgb( + image: NDArray[np.generic], points: Sequence[tuple[int, int]] +) -> tuple[float, float, float]: + """複数のクリック点の ROI 平均色を (R, G, B) の順で返す. + + `core.color.rgb_to_lab` の入力に合わせた並べ替えのみを行う(値は `mean_bgr` と同じ). + + Args: + image: BGR 画像(形状 (高さ, 幅, 3)). + points: クリック点 (x, y) の並び(1 点以上). + + Returns: + (R, G, B) の平均値. + + Raises: + ValueError: 点が 1 つも無い場合,画像が不正な場合, + または ROI が画像の範囲外にはみ出す場合(`mean_bgr` と同じ). + """ + blue, green, red = mean_bgr(image, points) + return red, green, blue + + +def _as_bgr_image(image: NDArray[np.generic]) -> NDArray[np.generic]: + """入力が BGR 3 チャンネル画像であることを検証して返す.""" + array = np.asarray(image) + if array.ndim != _IMAGE_NDIM or array.shape[2] != CHANNEL_COUNT: + raise ValueError( + f"画像は形状 (高さ, 幅, {CHANNEL_COUNT}) の BGR 画像が必要です: {array.shape}" + ) + if array.shape[0] == 0 or array.shape[1] == 0: + raise ValueError("画像が空です") + return array + + +def _as_point(point: tuple[int, int]) -> tuple[int, int]: + """クリック点を整数座標 (x, y) に整える.""" + values = tuple(point) + if len(values) != 2: + raise ValueError(f"クリック点は (x, y) の 2 要素が必要です: {point!r}") + return int(values[0]), int(values[1]) diff --git a/src/tiasjudge/core/svm.py b/src/tiasjudge/core/svm.py new file mode 100644 index 0000000..62a055f --- /dev/null +++ b/src/tiasjudge/core/svm.py @@ -0,0 +1,350 @@ +"""SVM による舌色 5 分類(SPEC_02 (3)). + +旧実装(`org/src` の `CtonguediagnosisMFC2Dlg::CalcSVM()` / `Text()`)と同じ手順を踏む. + +1. 医師 38 名 × 5 色 = 190 列それぞれについて 2 クラス SVM を**診断のたびに学習**する + (正例が 2 未満の列は学習せずスコア 0) +2. 入力 L*a*b* を各列の SVM で予測する(予測値は 0.0 / 1.0 のクラスラベル) +3. 色ごとに 38 医師分の予測を合算し,医師数で割って一致率(0〜1)を得る +4. 一致率の降順で上位 2 色を主要ラベルとする + +※ 診断失敗(SPEC_02「診断失敗の判定」)は,学習不能・予測失敗・色変換が成立しない入力を +原因として直接検出する.加えて,旧実装がスコア組 42/0/5/11/3 との一致で失敗表示していた +条件も踏襲する(このスコア組は学習データ域外の入力に対する SVM の既定応答であり, +表示を旧実装と一致させるために必要.`LEGACY_FAILURE_PERCENTAGES` を参照). +失敗は例外ではなく `DiagnosisResult.failed` で返す(error-handling.md の分類に従う). +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from enum import Enum + +import cv2 +import numpy as np +from numpy.typing import ArrayLike, NDArray + +from tiasjudge.core.color import Lab +from tiasjudge.core.csv_io import COLOR_COUNT, LAB_DIMENSION, TrainingData + +logger = logging.getLogger(__name__) + +# --- SVM パラメータ(SPEC_02 (3).旧実装 `CvSVMParams` の設定値と同一)--- + +#: RBF カーネルの γ(旧実装 `paramGamma = 0.011`) +SVM_GAMMA = 0.011 +#: C_SVC の C(旧実装 `paramC = 12.0`) +SVM_C = 12.0 +#: 終了条件の ε(旧実装 `FLT_EPSILON`) +SVM_TERM_EPSILON = float(np.finfo(np.float32).eps) +#: 終了条件の反復回数(旧実装 `cvTermCriteria(CV_TERMCRIT_EPS, 1000, FLT_EPSILON)` の第 2 引数). +#: 種別が EPS のみなので OpenCV 側では使われない(2.4 の `cvCheckTermCriteria`, +#: 4.x の `SVMImpl::checkParams` がともに COUNT 未指定時に maxCount を INT_MAX に置き換える) +SVM_TERM_MAX_COUNT = 1000 + +#: 学習に必要な正例の最小数(これ未満の列は学習せずスコア 0.SPEC_02 (3)) +MIN_POSITIVE_SAMPLES = 2 + +#: 有効な入力とみなす L* の下限(これ以下は診断失敗). +#: L* <= 0 は Y がほぼ 0(RGB→XYZ 変換の結果が 0 にクリップされた状態)を意味し, +#: 学習データ(L* は約 42〜71)から完全に外れるため予測が信頼できない. +#: 旧実装で「Diagnosis failed」となるスコア組(紫 42・暗赤 0・紅 5・淡紅 11・淡白 3)は, +#: まさに Lab=(-16, 0, 0)(XYZ が全て 0)を入力したときの出力であることを実測で確認した +MINIMUM_LIGHTNESS = 0.0 + +#: 旧実装が「Diagnosis failed」を表示するスコア組(%). +#: 旧実装(`org/src` の `ResultDlg.cpp`)は L* に関係なくこの組と一致したら失敗表示するため, +#: 表示仕様を一致させるために踏襲する(CLAUDE.md「診断結果は旧実装と一致させる」). +#: 値の正体はマジックナンバーではなく,**学習データ域外の入力に対する SVM の既定応答**である. +#: 入力が全サポートベクタから遠いと RBF カーネルの値がほぼ 0 になり,各列の判定が +#: バイアス項のみで決まるため,域外入力では入力値によらずこの固定パターンが出る +#: (Lab=(-16,0,0)・(0.001,0,0)・(5,0,0)・(50,200,200) 等で実測確認済み) +LEGACY_FAILURE_PERCENTAGES: dict[str, int] = { + "purple": 42, + "dark_red": 0, + "crimson": 5, + "pink": 11, + "pale": 3, +} + +#: 5 色のキー(列インデックス `i % 5` の順=紫・暗赤・紅・淡紅・淡白) +COLOR_KEYS: tuple[str, ...] = ("purple", "dark_red", "crimson", "pink", "pale") + +#: 同点時の順位付けの基準となる並び. +#: 旧実装は `std::map`(キー昇順 "C" < "D" < "Pi" < "Pu" < "W")を +#: `std::sort` で降順に並べ替える.要素数 5 では MSVC の `std::sort` が挿入ソートとなり +#: 安定なので,同点時はこのキー順が保たれる.その順序を再現する +_TIE_BREAK_ORDER: tuple[str, ...] = ("crimson", "dark_red", "pink", "purple", "pale") + + +class FailureReason(Enum): + """診断失敗の原因(SPEC_02「診断失敗の判定」の直接検出).""" + + #: 入力 L*a*b* に NaN / inf が含まれ,予測できない + INVALID_INPUT = "入力の L*a*b* が数値として不正です" + #: 入力 L* が 0 以下(XYZ が 0 にクリップされた)で,予測が信頼できない + DEGENERATE_INPUT = "入力の L* が 0 以下で,色変換が成立していません" + #: 入力が学習データの範囲から外れ,SVM が域外入力の既定応答を返している + OUT_OF_DOMAIN = "入力が学習データの範囲外で,予測が信頼できません" + #: 学習可能な列(正例 2 以上)が 1 つも無い + NO_TRAINABLE_COLUMN = "学習可能な評価列がありません" + #: SVM の学習に失敗した列がある + TRAINING_FAILED = "SVM の学習に失敗した評価列があります" + #: SVM の予測結果が不正(0/1 以外,または非有限値) + PREDICTION_FAILED = "SVM の予測結果が不正です" + + def describe(self) -> str: + """失敗理由の説明文(日本語)を返す.""" + return self.value + + +@dataclass(frozen=True) +class DiagnosisResult: + """5 色分類の結果(SPEC_02 (3)). + + Attributes: + lab: 診断に用いた L*a*b*. + scores: 5 色の一致率(0.0〜1.0).キーは `COLOR_KEYS`. + percentages: 表示用の百分率(整数.旧実装の `"%3.0lf"` と同じ丸め). + top_labels: スコア降順の上位 2 色のキー. + predictions: 190 列の予測値(形状 (190,)・dtype float32.回帰確認用). + failed: 診断失敗なら True(結果画面に「Diagnosis failed」を表示する). + failure_reason: 失敗の原因.成功時は None. + skipped_columns: 正例が 2 未満で学習しなかった列のインデックス. + failed_columns: 学習・予測に失敗した列のインデックス. + """ + + lab: Lab + scores: dict[str, float] + percentages: dict[str, int] + top_labels: tuple[str, str] + predictions: NDArray[np.float32] + failed: bool = False + failure_reason: FailureReason | None = None + skipped_columns: tuple[int, ...] = () + failed_columns: tuple[int, ...] = () + + +def diagnose(lab: Lab | ArrayLike, training: TrainingData) -> DiagnosisResult: + """入力 L*a*b* を 190 列の SVM で分類し,5 色スコアと上位 2 色を返す. + + Args: + lab: 診断対象の `Lab`,または (L*, a*, b*) の 3 要素. + training: `csv_io.load_training_data` で読み込んだ学習データ. + + Returns: + 5 色スコア・上位 2 色・失敗情報を持つ `DiagnosisResult`. + + Raises: + ValueError: `lab` の要素数が 3 でない場合. + """ + lab_value = lab if isinstance(lab, Lab) else _as_lab(lab) + sample = lab_value.as_array().reshape(1, -1) + + if not bool(np.all(np.isfinite(sample))): + # 予測不能な入力(NaN / inf)はここで失敗として返す + return _failure_result(lab_value, FailureReason.INVALID_INPUT, training.output_dimension) + + predictions, skipped_columns, failed_columns, prediction_invalid = _predict_columns( + sample, training + ) + scores = _aggregate_scores(predictions, training.doctor_count) + percentages = {key: _to_percent(score) for key, score in scores.items()} + top_labels = _top_two_labels(scores) + + reason = _detect_failure( + lab=lab_value, + percentages=percentages, + trainable_count=training.output_dimension - len(skipped_columns), + failed_columns=failed_columns, + prediction_invalid=prediction_invalid, + ) + + logger.info( + "SVM 診断: Lab=(%.6f, %.6f, %.6f) -> scores=%s(上位: %s, %s)%s", + lab_value.l_star, + lab_value.a_star, + lab_value.b_star, + {key: round(value, 6) for key, value in scores.items()}, + top_labels[0], + top_labels[1], + "" if reason is None else f" / 診断失敗: {reason.describe()}", + ) + + return DiagnosisResult( + lab=lab_value, + scores=scores, + percentages=percentages, + top_labels=top_labels, + predictions=predictions, + failed=reason is not None, + failure_reason=reason, + skipped_columns=tuple(skipped_columns), + failed_columns=tuple(failed_columns), + ) + + +def create_model() -> cv2.ml.SVM: + """旧実装(OpenCV 2.4 の `CvSVM`)と同一パラメータの SVM を生成する. + + 旧実装は `CvSVMParams(CvSVM::C_SVC, CvSVM::RBF, degree, gamma, coef0, C, nu, p, + NULL, cvTermCriteria(CV_TERMCRIT_EPS, 1000, FLT_EPSILON))` を用いる. + degree・coef0・nu・p は C_SVC + RBF では未使用のため設定しない + (OpenCV は RBF カーネルで γ のみを参照する). + + Returns: + 学習前の `cv2.ml.SVM`. + """ + model = cv2.ml.SVM.create() + model.setType(cv2.ml.SVM_C_SVC) + model.setKernel(cv2.ml.SVM_RBF) + model.setGamma(SVM_GAMMA) + model.setC(SVM_C) + # 終了条件は EPS のみ(COUNT は付けない).OpenCV 2.4 / 4.x のどちらも + # COUNT 未指定なら反復上限を INT_MAX とするため,挙動は一致する + model.setTermCriteria((cv2.TERM_CRITERIA_EPS, SVM_TERM_MAX_COUNT, SVM_TERM_EPSILON)) + return model + + +def _predict_columns( + sample: NDArray[np.float32], training: TrainingData +) -> tuple[NDArray[np.float32], list[int], list[int], bool]: + """190 列それぞれを学習・予測し,予測値と学習状況を返す. + + Returns: + (予測値 (190,), 学習を飛ばした列, 学習・予測に失敗した列, 予測値が不正か). + """ + samples = np.ascontiguousarray(training.lab, dtype=np.float32) + column_count = training.output_dimension + predictions = np.zeros(column_count, dtype=np.float32) + skipped_columns: list[int] = [] + failed_columns: list[int] = [] + prediction_invalid = False + + for column in range(column_count): + responses = np.ascontiguousarray(training.evaluations[:, column], dtype=np.int32) + # 旧実装: 正例(> 0)が 2 未満なら学習せずスコア 0 + if int(np.count_nonzero(responses > 0)) < MIN_POSITIVE_SAMPLES: + skipped_columns.append(column) + continue + + value = _train_and_predict(samples, responses.reshape(-1, 1), sample, column) + if value is None: + failed_columns.append(column) + continue + if not np.isfinite(value) or value not in (0.0, 1.0): + # 2 クラス(0/1)の C_SVC なので予測値は 0.0 か 1.0 になるはず + prediction_invalid = True + failed_columns.append(column) + continue + predictions[column] = np.float32(value) + + return predictions, skipped_columns, failed_columns, prediction_invalid + + +def _train_and_predict( + samples: NDArray[np.float32], + responses: NDArray[np.int32], + sample: NDArray[np.float32], + column: int, +) -> float | None: + """1 列分の SVM を学習して予測する.学習・予測できなければ None を返す.""" + model = create_model() + try: + trained = bool(model.train(samples, cv2.ml.ROW_SAMPLE, responses)) + if not trained: + logger.warning("SVM の学習に失敗しました(列 %d)", column) + return None + _, results = model.predict(sample) + except cv2.error: + # 単一クラスしか含まない等,OpenCV が学習を拒否した場合(診断失敗として扱う) + logger.warning( + "SVM の学習・予測で OpenCV エラーが発生しました(列 %d)", column, exc_info=True + ) + return None + return float(np.asarray(results).reshape(-1)[0]) + + +def _aggregate_scores(predictions: NDArray[np.float32], doctor_count: int) -> dict[str, float]: + """190 列の予測を色ごとに合算し,医師数で割って一致率にする(SPEC_02 (3)). + + 旧実装は単精度 float で合算・除算するため,同じ丸めを再現する. + """ + scores: dict[str, float] = {} + for color_index, key in enumerate(COLOR_KEYS): + total = np.float32(0.0) + for value in predictions[color_index::COLOR_COUNT]: + total = np.float32(total + value) + scores[key] = float(np.float32(total / np.float32(doctor_count))) + return scores + + +def _to_percent(score: float) -> int: + """一致率(0〜1)を表示用の百分率(整数)に変換する. + + 旧実装の `float ftemp = 100 * m_score[i];` → `Format("%3.0lf", ftemp)` に対応し, + 単精度での 100 倍と偶数丸め(printf の丸め)を再現する. + """ + percent = np.float32(np.float32(100.0) * np.float32(score)) + return int(round(float(percent))) + + +def _top_two_labels(scores: dict[str, float]) -> tuple[str, str]: + """スコア降順で上位 2 色のキーを返す(同点時は `_TIE_BREAK_ORDER` の順).""" + ordered = sorted(_TIE_BREAK_ORDER, key=lambda key: scores[key], reverse=True) + return ordered[0], ordered[1] + + +def _detect_failure( + *, + lab: Lab, + percentages: dict[str, int], + trainable_count: int, + failed_columns: list[int], + prediction_invalid: bool, +) -> FailureReason | None: + """診断失敗の原因を検出する(SPEC_02「診断失敗の判定」). + + 新実装では学習不能・予測失敗・色変換が成立しない入力を**原因として直接**判定する. + 加えて,旧実装(`ResultDlg.cpp`)がスコア組 `LEGACY_FAILURE_PERCENTAGES` との一致で + 失敗表示していた条件も踏襲する(表示を旧実装と一致させるため).このスコア組は + 学習データ域外の入力に対する SVM の既定応答パターンであり,L* の値によらず起こりうる. + + スコア自体は失敗時も算出済みの値をそのまま返す(旧実装も失敗表示に重ねて + スコアを描画するため). + """ + if trainable_count == 0: + return FailureReason.NO_TRAINABLE_COLUMN + if prediction_invalid: + return FailureReason.PREDICTION_FAILED + if failed_columns: + return FailureReason.TRAINING_FAILED + if lab.l_star <= MINIMUM_LIGHTNESS: + return FailureReason.DEGENERATE_INPUT + if percentages == LEGACY_FAILURE_PERCENTAGES: + return FailureReason.OUT_OF_DOMAIN + return None + + +def _failure_result(lab: Lab, reason: FailureReason, column_count: int) -> DiagnosisResult: + """スコアを算出できない場合の失敗結果を組み立てる.""" + scores = dict.fromkeys(COLOR_KEYS, 0.0) + return DiagnosisResult( + lab=lab, + scores=scores, + percentages=dict.fromkeys(COLOR_KEYS, 0), + # 全色が同点なので `_TIE_BREAK_ORDER` の先頭 2 色になる(順序の知識を二重に持たない) + top_labels=_top_two_labels(scores), + predictions=np.zeros(column_count, dtype=np.float32), + failed=True, + failure_reason=reason, + ) + + +def _as_lab(values: ArrayLike) -> Lab: + """(L*, a*, b*) の 3 要素を `Lab` に整える.""" + array = np.asarray(values, dtype=np.float64).reshape(-1) + if array.size != LAB_DIMENSION: + raise ValueError(f"Lab は {LAB_DIMENSION} 要素である必要があります: {array.size} 要素") + return Lab(l_star=float(array[0]), a_star=float(array[1]), b_star=float(array[2])) diff --git a/src/tiasjudge/gui/__init__.py b/src/tiasjudge/gui/__init__.py new file mode 100644 index 0000000..47b0a7e --- /dev/null +++ b/src/tiasjudge/gui/__init__.py @@ -0,0 +1,4 @@ +"""PySide6 による画面(SPEC_01). + +表示とイベント処理に徹し,診断計算は `tiasjudge.core` に委譲する. +""" diff --git a/src/tiasjudge/gui/main_window.py b/src/tiasjudge/gui/main_window.py new file mode 100644 index 0000000..bd3ce00 --- /dev/null +++ b/src/tiasjudge/gui/main_window.py @@ -0,0 +1,879 @@ +"""メイン画面(SPEC_01「メイン画面」). + +旧実装(`org/src` の `CtonguediagnosisMFC2Dlg`)の操作フローを踏襲する. + +1. 変換行列 CSV(`.csv`)を選択する +2. 舌画像(`.png`)を選択する(読込時に中央基準で 1024x1280 にクロップ) +3. 画像上を 1〜4 点左クリックして ROI を指定する(右クリックで取り消し) +4. [舌色診断] で `core` のパイプライン(ROI 平均色 → Lab → SVM)を実行する +5. [診断続行] で入力をリセットし,次の診断へ進む + +依存方向は `gui` → `core` の一方向とする(project-structure.md).診断計算は本モジュールに +実装せず,必ず `core` を呼ぶ.画面に依存しないロジック(クリック点の管理・入力ガード・ +クロップ・表示座標の変換・パイプライン実行)は QWidget 非依存の関数・クラスに分離してある. + +診断結果は結果画面(`result_window.ResultWindow`)にモードレスで表示する.結果の +受け渡し口として `MainWindow.diagnosis_finished` シグナルと `result_presenter` +(差し替え用のコールバック)を用意してある.結果画面は表示専用(患者側モニタに操作 UI を +出さない)のため,言語切替と「閉じる」は本画面の「結果画面の操作」から行う.表示言語は +本画面が保持し,開いている結果画面と次に開く結果画面の両方に適用する. +""" + +from __future__ import annotations + +import logging +import os +from collections.abc import Callable, Sequence +from dataclasses import dataclass, field +from pathlib import Path + +import cv2 +import numpy as np +from numpy.typing import NDArray +from PySide6.QtCore import Qt, Signal +from PySide6.QtGui import QColor, QFont, QMouseEvent, QPainter, QPixmap +from PySide6.QtWidgets import ( + QApplication, + QFileDialog, + QFrame, + QGridLayout, + QGroupBox, + QHBoxLayout, + QLabel, + QLineEdit, + QMainWindow, + QMessageBox, + QPushButton, + QVBoxLayout, + QWidget, +) + +from tiasjudge.core import color, roi +from tiasjudge.core.csv_io import ( + CsvIoError, + TrainingData, + load_training_data, + load_transform_matrix, +) +from tiasjudge.core.svm import COLOR_KEYS, DiagnosisResult, diagnose +from tiasjudge.gui.result_window import ( + IMAGE_DISPLAY_DIVISOR, + Language, + ResultWindow, + next_language, + show_on_secondary_screen, + switch_button_label, + to_display_pixmap, +) + +logger = logging.getLogger(__name__) + +# --- 定数(SPEC_01「メイン画面」)--- + +#: クロップ後の画像サイズ(旧実装 `target_width` / `target_height`) +CROP_WIDTH = 1024 +CROP_HEIGHT = 1280 +#: 画像表示時の縮小率(1/2 表示.旧実装 `resize(..., cols/2, rows/2)`). +#: 結果画面と同じ縮小率を使うため `result_window` の定数を参照する +DISPLAY_DIVISOR = IMAGE_DISPLAY_DIVISOR + +#: 受け付けるファイル拡張子(SPEC_01「変換行列選択」「画像選択」) +MATRIX_EXTENSION = ".csv" +IMAGE_EXTENSION = ".png" + +#: ROI 枠の色(BGR)と太さ(旧実装 `cv::Scalar(0, 0, 200)` / 太さ 3) +ROI_RECT_COLOR = (0, 0, 200) +ROI_RECT_THICKNESS = 3 + +#: L*a*b* の表示順と行見出し(メイン画面の結果表示に使う) +LAB_LABELS: tuple[str, str, str] = ("L*", "a*", "b*") + +#: 5 色の表示名(日本語.キーは `core.svm.COLOR_KEYS`) +COLOR_NAMES_JA: dict[str, str] = { + "purple": "紫", + "dark_red": "暗赤", + "crimson": "紅", + "pink": "淡紅", + "pale": "淡白", +} + +#: 学習データのパスを上書きする環境変数(配布形態が変わっても絶対パスを埋め込まないため) +TRAINING_DATA_ENV_VAR = "TIASJUDGE_DATA_CSV" + +# --- 入力ガードのメッセージ(SPEC_01「入力ガード(エラー表示)」)--- + +MESSAGE_TRANSFORM_REQUIRED = "変換行列を読み込んでください" +MESSAGE_IMAGE_REQUIRED = "画像を読み込んでください" +MESSAGE_CLICK_REQUIRED = "画像上の舌の領域を\n1 点以上 4 点以下クリックしてください" +MESSAGE_TOO_MANY_CLICKS = ( + f"{roi.MAX_ROI_POINTS} 点を超えてクリックできません.\n舌色診断ボタンを押してください" +) +MESSAGE_ROI_OUT_OF_IMAGE = ( + f"画像の端から {roi.ROI_OFFSET} px 以上内側をクリックしてください\n" + f"(ROI は {roi.ROI_SIZE}x{roi.ROI_SIZE} px です)" +) +MESSAGE_MATRIX_EXTENSION = "csv ファイルを選択しなおしてください" +MESSAGE_IMAGE_EXTENSION = "png ファイルを選択しなおしてください" + +#: 画像未読込時のプレースホルダ文言(旧実装の `nofile.bmp` に相当.画像アセットに依存しない) +PLACEHOLDER_TEXT = "舌画像(.png)を読み込んでください" + + +class InputGuardError(Exception): + """利用者の操作ミスを知らせるエラー(GUI がメッセージボックスで案内する). + + error-handling.md の「ユーザー入力エラー」に対応する. + """ + + +class ImageLoadError(Exception): + """画像ファイルを読み込めない・形式が不正な場合のエラー.""" + + +# --- 画面に依存しないロジック(QWidget 非依存.単体で検証できる)--- + + +def check_extension(path: Path | str, expected: str, message: str) -> None: + """ファイル拡張子を検証する(大文字小文字は区別しない). + + Args: + path: 検証するファイルパス. + expected: 期待する拡張子(`.csv` のように先頭のドットを含む). + message: 不一致時に利用者へ表示するメッセージ. + + Raises: + InputGuardError: 拡張子が `expected` と異なる場合. + """ + if Path(path).suffix.lower() != expected: + raise InputGuardError(message) + + +def view_to_image_point(view_x: int, view_y: int) -> tuple[int, int]: + """表示座標(1/2 縮小)を元画像の座標へ変換する(SPEC_01「画像表示」). + + 旧実装(`OnLButtonDown` の `m_xView * 2`)と同じく縮小率の逆数を掛ける. + + Args: + view_x: 表示上の x 座標. + view_y: 表示上の y 座標. + + Returns: + 元画像上のクリック点 (x, y). + """ + return (view_x * DISPLAY_DIVISOR, view_y * DISPLAY_DIVISOR) + + +def display_size(width: int, height: int) -> tuple[int, int]: + """画像を 1/2 表示するときの表示サイズ (幅, 高さ) を返す. + + 小さな画像でも 0 px にならないよう各辺を最小 1 px に丸める. + """ + return (max(width // DISPLAY_DIVISOR, 1), max(height // DISPLAY_DIVISOR, 1)) + + +def lab_components(lab: color.Lab) -> tuple[float, float, float]: + """L*a*b* を表示順(`LAB_LABELS` と同じ L*, a*, b* の順)のタプルで返す.""" + return (lab.l_star, lab.a_star, lab.b_star) + + +def crop_center(image: NDArray[np.uint8]) -> NDArray[np.uint8]: + """画像を中央基準で 1024x1280 にクロップする(SPEC_01「画像選択」). + + 旧実装(`OnEnChangeMfceditbrowseFilename2`)と同じく,元画像が目標サイズより + 小さい場合はオフセットを 0 に,切り出しサイズを画像内に収まる範囲に丸める. + + Args: + image: 読み込んだ BGR 画像(形状 (高さ, 幅, 3)). + + Returns: + クロップ後の BGR 画像(元画像のコピー). + """ + height, width = image.shape[:2] + x_offset = max((width - CROP_WIDTH) // 2, 0) + y_offset = max((height - CROP_HEIGHT) // 2, 0) + crop_width = min(CROP_WIDTH, width - x_offset) + crop_height = min(CROP_HEIGHT, height - y_offset) + cropped = image[y_offset : y_offset + crop_height, x_offset : x_offset + crop_width] + return np.ascontiguousarray(cropped) + + +def load_image_file(path: Path | str) -> NDArray[np.uint8]: + """PNG 画像を BGR で読み込み,中央基準でクロップして返す. + + `cv2.imread` は非 ASCII パスを扱えないため,バイト列を読んで `cv2.imdecode` する + (旧実装では日本語ファイル名の画像を開けなかった箇所の改善). + + Args: + path: 画像ファイルのパス. + + Returns: + クロップ後の BGR 画像. + + Raises: + ImageLoadError: ファイルを読めない,または画像として解釈できない場合. + """ + image_path = Path(path) + try: + buffer = np.frombuffer(image_path.read_bytes(), dtype=np.uint8) + except OSError as error: + raise ImageLoadError(f"画像ファイルを読み込めません: {image_path}") from error + + if buffer.size == 0: + # 空ファイルは `cv2.imdecode` が None ではなく cv2.error を送出するため先に弾く + raise ImageLoadError(f"画像として読み込めません(ファイルが空です): {image_path}") + + try: + image = cv2.imdecode(buffer, cv2.IMREAD_COLOR) + except cv2.error as error: + # OpenCV の内部アサーション(壊れたデータ等)も利用者向けのエラーに変換する + raise ImageLoadError(f"画像として読み込めません: {image_path}") from error + if image is None: + raise ImageLoadError(f"画像として読み込めません: {image_path}") + return crop_center(image) + + +def draw_roi_markers( + image: NDArray[np.uint8], points: Sequence[tuple[int, int]] +) -> NDArray[np.uint8]: + """クリック点に ROI の赤枠を描いた画像(コピー)を返す(SPEC_01「ROI 指定」). + + 旧実装の `cv::rectangle(m_Img, Point(x - 25, y - 25), Point(x + 25, y + 25), ...)` に対応する. + """ + annotated = image.copy() + for x, y in points: + cv2.rectangle( + annotated, + (x - roi.ROI_OFFSET, y - roi.ROI_OFFSET), + (x + roi.ROI_OFFSET, y + roi.ROI_OFFSET), + ROI_RECT_COLOR, + ROI_RECT_THICKNESS, + ) + return annotated + + +def is_roi_inside_image(image: NDArray[np.uint8], point: tuple[int, int]) -> bool: + """ROI(51x51 px)が画像内に収まるクリック点かどうかを返す. + + 旧実装は範囲チェックが無く,端をクリックすると OpenCV の例外で落ちていた. + """ + height, width = image.shape[:2] + x, y = point + return ( + x - roi.ROI_OFFSET >= 0 + and y - roi.ROI_OFFSET >= 0 + and x + roi.ROI_OFFSET < width + and y + roi.ROI_OFFSET < height + ) + + +def default_training_data_path() -> Path: + """同梱学習データ `data/data.csv` のパスを返す(絶対パスは埋め込まない). + + 環境変数 `TIASJUDGE_DATA_CSV` があればそれを優先する(配布形態が変わった場合の逃げ道). + """ + override = os.environ.get(TRAINING_DATA_ENV_VAR) + if override: + return Path(override) + + package_root = Path(__file__).resolve().parents[1] + candidates = ( + package_root / "data" / "data.csv", # 将来パッケージに同梱した場合 + package_root.parents[1] / "data" / "data.csv", # リポジトリ直下の data/ + ) + for candidate in candidates: + if candidate.is_file(): + return candidate + return candidates[-1] + + +def run_diagnosis( + image: NDArray[np.uint8], + points: Sequence[tuple[int, int]], + coefficients: NDArray[np.float64], + training: TrainingData, +) -> DiagnosisResult: + """診断パイプラインを実行する(SPEC_02「処理パイプライン」). + + ROI 平均 RGB → Lab → SVM 分類の順に `core` を呼ぶだけで,計算は持たない. + + Args: + image: クロップ後の BGR 画像. + points: クリック点 (x, y) の並び(1〜4 点). + coefficients: 変換行列(形状 (17, 3)). + training: 学習データ. + + Returns: + 診断結果(`DiagnosisResult`.`lab` に算出した L*a*b* を含む). + """ + rgb = roi.mean_rgb(image, points) + lab = color.rgb_to_lab(rgb, coefficients) + return diagnose(lab, training) + + +@dataclass +class MainWindowState: + """メイン画面の入力状態(画面部品に依存しない). + + 入力ガード(SPEC_01)を本クラスに集約し,`MainWindow` は例外を捕まえて + メッセージボックスを出すだけにする. + """ + + coefficients: NDArray[np.float64] | None = None + transform_path: Path | None = None + image: NDArray[np.uint8] | None = None + image_path: Path | None = None + points: list[tuple[int, int]] = field(default_factory=list) + diagnosed: bool = False + + @property + def has_transform(self) -> bool: + """変換行列を読み込み済みか.""" + return self.coefficients is not None + + @property + def has_image(self) -> bool: + """画像を読み込み済みか.""" + return self.image is not None + + @property + def point_count(self) -> int: + """クリック済みの点数.""" + return len(self.points) + + @property + def can_diagnose(self) -> bool: + """[舌色診断] を有効化できるか(1 点以上クリック済み).""" + return self.has_transform and self.has_image and self.point_count > 0 + + def set_transform(self, path: Path, coefficients: NDArray[np.float64]) -> None: + """変換行列を設定する.""" + self.transform_path = path + self.coefficients = coefficients + + def clear_transform(self) -> None: + """変換行列の選択を解除する([診断続行] で「使用しない」を選んだ場合).""" + self.transform_path = None + self.coefficients = None + + def set_image(self, path: Path, image: NDArray[np.uint8]) -> None: + """画像を設定し,クリック点を初期化する.""" + self.image_path = path + self.image = image + self.clear_points() + + def clear_image(self) -> None: + """画像の選択を解除する([診断続行]).""" + self.image_path = None + self.image = None + self.clear_points() + + def clear_points(self) -> None: + """クリック点をすべて取り消す(右クリック・診断後のリセット).""" + self.points.clear() + + def ensure_clickable(self) -> None: + """クリック可能な状態かを検証する(SPEC_01「入力ガード」). + + Raises: + InputGuardError: 変換行列または画像が未読込の場合. + """ + if not self.has_transform: + raise InputGuardError(MESSAGE_TRANSFORM_REQUIRED) + if not self.has_image: + raise InputGuardError(MESSAGE_IMAGE_REQUIRED) + + def add_point(self, point: tuple[int, int]) -> None: + """クリック点を追加する(最大 4 点). + + Raises: + InputGuardError: 未読込・5 点目・ROI が画像外の場合. + """ + self.ensure_clickable() + if self.point_count >= roi.MAX_ROI_POINTS: + # 旧実装は 5 点目もメッセージ表示前に加算していた(バグ).新実装は加算しない + raise InputGuardError(MESSAGE_TOO_MANY_CLICKS) + image = self.image + if image is None or not is_roi_inside_image(image, point): + raise InputGuardError(MESSAGE_ROI_OUT_OF_IMAGE) + self.points.append(point) + + def ensure_diagnosable(self) -> None: + """診断を実行できる状態かを検証する(SPEC_01「入力ガード」). + + Raises: + InputGuardError: 未読込,またはクリック 0 点の場合. + """ + self.ensure_clickable() + if self.point_count == 0: + raise InputGuardError(MESSAGE_CLICK_REQUIRED) + + +# --- 画面部品 --- + + +class ImageView(QLabel): + """画像を表示し,左クリック(ROI 指定)と右クリック(リセット)を通知するラベル.""" + + #: 左クリック位置(表示座標.1/2 縮小後) + clicked = Signal(int, int) + #: 右クリック(クリック取り消し要求) + reset_requested = Signal() + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.setAlignment(Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft) + self.setFrameShape(QFrame.Shape.Box) + self.setCursor(Qt.CursorShape.CrossCursor) + self.setMouseTracking(False) + + def mousePressEvent(self, event: QMouseEvent) -> None: # noqa: N802 - Qt のオーバーライド + """マウス押下を左右で振り分ける.""" + position = event.position().toPoint() + if event.button() == Qt.MouseButton.LeftButton: + self.clicked.emit(position.x(), position.y()) + elif event.button() == Qt.MouseButton.RightButton: + self.reset_requested.emit() + super().mousePressEvent(event) + + +#: 結果画面へ結果を渡すための差し込み口(未設定なら `ResultWindow` を開く) +ResultPresenter = Callable[[DiagnosisResult, NDArray[np.uint8]], None] + + +class MainWindow(QMainWindow): + """メイン画面(SPEC_01).""" + + #: 診断完了(結果,診断に用いたクロップ後画像).結果画面はこのシグナルで開く + diagnosis_finished = Signal(object, object) + + def __init__( + self, + training_data_path: Path | str | None = None, + parent: QWidget | None = None, + ) -> None: + super().__init__(parent) + self._training_data_path = ( + Path(training_data_path) + if training_data_path is not None + else default_training_data_path() + ) + self._training: TrainingData | None = None + self._state = MainWindowState() + self._last_directory = Path.home() + #: 開いている結果画面(閉じるまで保持する) + self._result_windows: list[ResultWindow] = [] + #: 結果画面の表示言語(メイン画面が保持し,次に開く結果画面にも引き継ぐ) + self._result_language = Language.JAPANESE + #: 結果画面の表示関数(未設定なら `ResultWindow` を開く) + self.result_presenter: ResultPresenter | None = None + + self.setWindowTitle("TIASjudge - 舌色診断") + self._build_ui() + self._refresh_image() + self._update_controls() + + # --- 画面構築 --- + + def _build_ui(self) -> None: + """ウィジェットを組み立てる(左: 画像/右: 操作パネル).""" + central = QWidget(self) + layout = QHBoxLayout(central) + + self._image_view = ImageView(central) + self._image_view.clicked.connect(self._on_image_clicked) + self._image_view.reset_requested.connect(self._on_reset_requested) + layout.addWidget(self._image_view, 0, Qt.AlignmentFlag.AlignTop) + layout.addWidget(self._build_side_panel(central), 1) + + self.setCentralWidget(central) + + def _build_side_panel(self, parent: QWidget) -> QWidget: + """右側の操作パネル(ファイル選択・クリック状況・診断・結果表示)を作る.""" + panel = QWidget(parent) + layout = QVBoxLayout(panel) + + matrix_row, self._matrix_edit, self._matrix_button = self._build_file_row( + panel, "① 変換行列 (.csv) 読み込み", self._on_select_matrix + ) + layout.addWidget(matrix_row) + + image_row, self._image_edit, self._image_button = self._build_file_row( + panel, "② 舌画像 (.png) 読み込み", self._on_select_image + ) + layout.addWidget(image_row) + + layout.addWidget(self._build_click_group(panel)) + + self._diagnose_button = QPushButton("④ 舌色診断", panel) + self._diagnose_button.clicked.connect(self._on_diagnose) + layout.addWidget(self._diagnose_button) + + note = QLabel("結果表示ごとに選択領域はリセットされます.", panel) + note.setWordWrap(True) + layout.addWidget(note) + + self._continue_button = QPushButton("診断続行", panel) + self._continue_button.clicked.connect(self._on_continue) + self._quit_button = QPushButton("終了", panel) + self._quit_button.clicked.connect(self.close) + buttons = QHBoxLayout() + buttons.addWidget(self._continue_button) + buttons.addWidget(self._quit_button) + layout.addLayout(buttons) + + layout.addWidget(self._build_result_control_group(panel)) + layout.addWidget(self._build_result_group(panel)) + layout.addStretch(1) + return panel + + def _build_result_control_group(self, parent: QWidget) -> QGroupBox: + """結果画面(患者側モニタ)を操作するボタン群を作る. + + 結果画面は表示専用なので,言語切替と「閉じる」はここ(施術者側)で操作する. + """ + group = QGroupBox("結果画面の操作", parent) + self._result_language_button = QPushButton(group) + self._result_language_button.clicked.connect(self.toggle_result_language) + self._result_close_button = QPushButton("結果画面を閉じる", group) + self._result_close_button.clicked.connect(self.close_result_windows) + row = QHBoxLayout(group) + row.addWidget(self._result_language_button) + row.addWidget(self._result_close_button) + return group + + def _build_file_row( + self, parent: QWidget, title: str, on_browse: Callable[[], None] + ) -> tuple[QGroupBox, QLineEdit, QPushButton]: + """ラベル + パス表示 + 参照ボタンの 1 行を作る(変換行列・画像で共通). + + Args: + parent: 親ウィジェット. + title: グループボックスの見出し. + on_browse: [参照...] 押下時に呼ぶハンドラ. + + Returns: + (グループボックス, パス表示欄, 参照ボタン). + """ + group = QGroupBox(title, parent) + edit = QLineEdit(group) + edit.setReadOnly(True) + edit.setPlaceholderText("未選択") + button = QPushButton("参照...", group) + button.clicked.connect(on_browse) + row = QHBoxLayout(group) + row.addWidget(edit, 1) + row.addWidget(button, 0) + return group, edit, button + + def _build_click_group(self, parent: QWidget) -> QGroupBox: + """クリック状況(●1〜●4)の表示部を作る(SPEC_01「ROI 指定」).""" + group = QGroupBox(f"③ 画像中の領域を {roi.MAX_ROI_POINTS} 点までクリック", parent) + row = QHBoxLayout(group) + row.addWidget(QLabel("現在のクリック", group)) + self._click_marks: list[QLabel] = [] + for index in range(roi.MAX_ROI_POINTS): + mark = QLabel(f"○{index + 1}", group) + self._click_marks.append(mark) + row.addWidget(mark) + row.addStretch(1) + return group + + def _build_result_group(self, parent: QWidget) -> QGroupBox: + """L*a*b* と 5 色スコアの表示部を作る(SPEC_01「L*a*b* 表示」「5 色スコア表示」).""" + group = QGroupBox("診断結果", parent) + grid = QGridLayout(group) + + self._lab_labels: dict[str, QLabel] = {} + for row_index, key in enumerate(LAB_LABELS): + grid.addWidget(QLabel(key, group), row_index, 0) + value = QLabel("-", group) + self._lab_labels[key] = value + grid.addWidget(value, row_index, 1) + + self._score_labels: dict[str, QLabel] = {} + for offset, key in enumerate(COLOR_KEYS): + row_index = offset + len(self._lab_labels) + grid.addWidget(QLabel(COLOR_NAMES_JA[key], group), row_index, 0) + value = QLabel("-", group) + self._score_labels[key] = value + grid.addWidget(value, row_index, 1) + return group + + # --- イベントハンドラ --- + + def _on_select_matrix(self) -> None: + """変換行列 CSV を選択して読み込む.""" + path = self._choose_file("変換行列 CSV を選択", "変換行列 (*.csv)") + if path is None: + return + try: + check_extension(path, MATRIX_EXTENSION, MESSAGE_MATRIX_EXTENSION) + coefficients = load_transform_matrix(path) + except InputGuardError as error: + self._show_error(str(error)) + return + except CsvIoError as error: + # core の専用例外(データエラー)を捕まえて案内する(error-handling.md) + self._show_error(f"変換行列を読み込めません.\n{error}") + return + + self._state.set_transform(path, coefficients) + self._matrix_edit.setText(str(path)) + logger.info("変換行列を選択しました: %s", path) + self._update_controls() + + def _on_select_image(self) -> None: + """舌画像 PNG を選択し,クロップして表示する.""" + path = self._choose_file("舌画像 PNG を選択", "舌画像 (*.png)") + if path is None: + return + try: + check_extension(path, IMAGE_EXTENSION, MESSAGE_IMAGE_EXTENSION) + image = load_image_file(path) + except (InputGuardError, ImageLoadError) as error: + self._show_error(str(error)) + return + + self._state.set_image(path, image) + self._image_edit.setText(str(path)) + logger.info("画像を選択しました: %s(%dx%d)", path, image.shape[1], image.shape[0]) + self._refresh_image() + self._update_controls() + + def _on_image_clicked(self, view_x: int, view_y: int) -> None: + """画像上の左クリックで ROI を追加する(表示は 1/2 縮小).""" + point = view_to_image_point(view_x, view_y) + try: + self._state.add_point(point) + except InputGuardError as error: + self._show_error(str(error)) + return + logger.info("ROI を追加しました: %s(%d 点目)", point, self._state.point_count) + self._refresh_image() + self._update_controls() + + def _on_reset_requested(self) -> None: + """右クリックでクリックをすべて取り消す(SPEC_01「リセット」).""" + if self._state.point_count == 0: + return + # 旧実装は画像だけ戻してクリック数を戻さないバグがあった.新実装は状態も戻す + self._state.clear_points() + logger.info("クリックをリセットしました") + self._refresh_image() + self._update_controls() + + def _on_diagnose(self) -> None: + """[舌色診断].診断を実行し,結果表示と入力リセットを行う.""" + try: + self._state.ensure_diagnosable() + except InputGuardError as error: + self._show_error(str(error)) + return + + training = self._load_training_data() + if training is None: + return + + # ensure_diagnosable が通っているので画像・変換行列は読込済み + image = self._state.image + coefficients = self._state.coefficients + if image is None or coefficients is None: + return + points = list(self._state.points) + + QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor) + try: + result = run_diagnosis(image, points, coefficients, training) + except ValueError as error: + # ROI 範囲外等(GUI 側でガード済みだが,念のため案内する) + self._show_error(f"診断を実行できません.\n{error}") + return + finally: + QApplication.restoreOverrideCursor() + + self._show_scores(result) + self._state.diagnosed = True + # 旧実装と同じく,診断後は選択領域を初期化する(画面の注記どおり) + self._state.clear_points() + self._refresh_image() + self._update_controls() + self._present_result(result, image) + + def _on_continue(self) -> None: + """[診断続行].同じ変換行列を使うか確認し,画像とクリックをリセットする.""" + answer = QMessageBox.question( + self, + "診断続行", + "同じ変換行列を使用して\n診断を続行しますか?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.Yes, + ) + if answer == QMessageBox.StandardButton.No: + self._state.clear_transform() + self._matrix_edit.clear() + + self._state.clear_image() + self._image_edit.clear() + self._state.diagnosed = False + self._clear_scores() + self._refresh_image() + self._update_controls() + + # --- 表示の更新 --- + + def _refresh_image(self) -> None: + """画像表示を最新の状態(ROI 枠込み)に更新する.""" + image = self._state.image + if image is None: + pixmap = self._placeholder_pixmap() + else: + annotated = draw_roi_markers(image, self._state.points) + # 結果画面と同じ変換を使う(SPEC_01「画像表示」) + pixmap = to_display_pixmap(annotated, DISPLAY_DIVISOR) + self._image_view.setPixmap(pixmap) + self._image_view.setFixedSize(pixmap.size()) + + def _update_controls(self) -> None: + """ボタンの活性・クリックマークを状態に合わせて更新する.""" + for index, mark in enumerate(self._click_marks): + clicked = index < self._state.point_count + mark.setText(f"{'●' if clicked else '○'}{index + 1}") + + self._diagnose_button.setEnabled(self._state.can_diagnose) + self._continue_button.setEnabled(self._state.diagnosed) + # 旧実装と同じく,診断後は再読込を禁止する([診断続行] で解除) + self._matrix_button.setEnabled(not self._state.diagnosed) + self._image_button.setEnabled(not self._state.diagnosed) + + # 言語切替は「次に開く画面の言語」を先に決められるよう常時有効とし, + # 「閉じる」は対象がある時だけ有効にする + self._result_language_button.setText(switch_button_label(self._result_language)) + self._result_close_button.setEnabled(bool(self._result_windows)) + + def _show_scores(self, result: DiagnosisResult) -> None: + """L*a*b* と 5 色スコアをパネルに表示する.""" + for key, value in zip(LAB_LABELS, lab_components(result.lab), strict=True): + self._lab_labels[key].setText(f"{value:.0f}") + for key, label in self._score_labels.items(): + label.setText(f"{result.percentages[key]:3d}%") + + def _clear_scores(self) -> None: + """L*a*b* と 5 色スコアの表示を消す.""" + for label in (*self._lab_labels.values(), *self._score_labels.values()): + label.setText("-") + + def _present_result(self, result: DiagnosisResult, image: NDArray[np.uint8]) -> None: + """結果画面へ結果を渡す(SPEC_01「結果画面(モードレス表示)」).""" + self.diagnosis_finished.emit(result, image) + if self.result_presenter is not None: + # 差し替え口(別の表示方法を使いたい場合はここに関数を挿す) + self.result_presenter(result, image) + return + self.open_result_window(result, image) + + def open_result_window(self, result: DiagnosisResult, image: NDArray[np.uint8]) -> ResultWindow: + """結果画面をモードレスで開く. + + 旧実装(`CResultDlg` の `Create` + `ShowWindow`)と同じく,メイン画面を + 操作可能なまま結果を表示する.複数回の診断で複数枚開けるよう,開いた画面は + 閉じるまで保持する. + + Args: + result: 表示する診断結果. + image: 診断に用いたクロップ後の BGR 画像. + + Returns: + 開いた結果画面. + """ + # 現在の言語を引き継ぐ(診断続行後も維持される) + window = ResultWindow(result, image, self._result_language, parent=self) + window.closed.connect(self._on_result_window_closed) + self._result_windows.append(window) + # 2 画面運用(施術者側にメイン画面・患者側に結果画面)を想定し, + # メイン画面と別のモニタがあればそこへ最大化する(1 台なら従来どおり通常表示) + show_on_secondary_screen(window, QApplication.screens(), self.screen()) + self._update_controls() + return window + + def _on_result_window_closed(self) -> None: + """閉じられた結果画面を保持リストから外す(Qt 側の破棄に任せる).""" + window = self.sender() + if isinstance(window, ResultWindow) and window in self._result_windows: + self._result_windows.remove(window) + window.deleteLater() + self._update_controls() + + # --- 結果画面の操作(結果画面は表示専用なのでメイン画面から操作する)--- + + @property + def result_language(self) -> Language: + """次に開く結果画面に適用される表示言語.""" + return self._result_language + + def toggle_result_language(self) -> None: + """結果画面の表示言語を切り替える(開いている全画面に反映する). + + 言語はメイン画面が保持するため,結果画面が 1 枚も開いていなくても切り替えられる + (次に開く画面の言語を先に決めておける). + """ + self._result_language = next_language(self._result_language) + # 結果画面側も切替時にログを出すため,こちらは対象枚数を添えて区別する + logger.info( + "結果画面の表示言語を切り替えました: %s(対象 %d 枚)", + self._result_language.value, + len(self._result_windows), + ) + for window in self._result_windows: + window.set_language(self._result_language) + self._update_controls() + + def close_result_windows(self) -> None: + """開いている結果画面をすべて閉じる.""" + # `closed` シグナル経由で `_result_windows` が変化するため複製を回す + for window in list(self._result_windows): + window.close() + + # --- 補助 --- + + def _load_training_data(self) -> TrainingData | None: + """学習データを読み込む(1 度読んだらキャッシュする).失敗時は None.""" + if self._training is not None: + return self._training + try: + self._training = load_training_data(self._training_data_path) + except CsvIoError as error: + self._show_error(f"学習データを読み込めません.\n{error}") + return None + return self._training + + def _choose_file(self, title: str, name_filter: str) -> Path | None: + """ファイル選択ダイアログを開き,選ばれたパスを返す(キャンセル時は None).""" + selected, _ = QFileDialog.getOpenFileName( + self, title, str(self._last_directory), name_filter + ) + if not selected: + return None + path = Path(selected) + self._last_directory = path.parent + return path + + def _show_error(self, message: str) -> None: + """入力ガード・データエラーの案内を表示する.""" + logger.warning("エラー表示: %s", message.replace("\n", " ")) + QMessageBox.critical(self, "エラー", message) + + def _placeholder_pixmap(self) -> QPixmap: + """画像未読込時のプレースホルダを生成する(画像アセットに依存しない).""" + pixmap = QPixmap(*display_size(CROP_WIDTH, CROP_HEIGHT)) + pixmap.fill(QColor(240, 240, 240)) + painter = QPainter(pixmap) + try: + painter.setPen(QColor(120, 120, 120)) + font = QFont(painter.font()) + font.setPointSize(12) + painter.setFont(font) + painter.drawRect(0, 0, pixmap.width() - 1, pixmap.height() - 1) + painter.drawText(pixmap.rect(), Qt.AlignmentFlag.AlignCenter, PLACEHOLDER_TEXT) + finally: + painter.end() + return pixmap diff --git a/src/tiasjudge/gui/result_window.py b/src/tiasjudge/gui/result_window.py new file mode 100644 index 0000000..d00f333 --- /dev/null +++ b/src/tiasjudge/gui/result_window.py @@ -0,0 +1,854 @@ +"""結果画面(SPEC_01「結果画面」). + +旧実装(`org/src` の `CResultDlg`)のモードレスな結果ダイアログを踏襲する. + +- 左: 診断に用いたクロップ後画像を 1/2 縮小表示する +- 右: 5 色(淡紅・紅・暗赤・紫・淡白紅)の行を一覧表示し,**上位 2 色のみ**スコア(%)と + 下線を描画する(`pale` の表示名はメイン画面の「淡白」と異なり,旧背景画像どおり「淡白紅」) +- 操作ボタンは持たない(表示専用).言語切替と「閉じる」はメイン画面側のボタンで行う + (患者側モニタに操作 UI を出さないため) +- 診断失敗時(`DiagnosisResult.failed`)は結果パネルに「Diagnosis failed / Please try again.」 + を重ねて描画する + +2 画面運用(施術者側にメイン画面・患者側に結果画面)を想定し,メイン画面とは別のモニタが +あればそこへ最大化して表示する(`show_on_secondary_screen`).モニタが 1 台の場合は +従来どおり同じモニタに通常サイズで表示する. + +旧実装の日本語版は文字入りビットマップ(`img/img_student2.bmp`)を背景に使っていたが, +新実装では日英とも描画で生成し画像アセットへの依存を無くす(/setup 時の設計判断. +表示内容・レイアウトは現行踏襲).座標は旧実装の `ShowResultImg()` / +`BuildEnglishBackground()` の値をそのまま使う.見出しと 5 色の色名の文字色は,旧背景画像 +`img/img_student2.bmp` から実測した値を使い(旧ソースの `Scalar(...)` 指定は画像と一致 +しないため),色名には旧画像と同じ黒い縁取りを付ける(日英とも同じ扱い). + +依存方向は `gui` → `core` の一方向とする(project-structure.md).表示文字列の組み立て・ +強調対象の決定・言語リソースは QWidget 非依存の定数・関数に分離してある. +""" + +from __future__ import annotations + +import logging +from collections.abc import Sequence +from dataclasses import dataclass +from enum import Enum + +import cv2 +import numpy as np +from numpy.typing import NDArray +from PySide6.QtCore import Qt, Signal +from PySide6.QtGui import ( + QCloseEvent, + QColor, + QFont, + QImage, + QPainter, + QPainterPath, + QPen, + QPixmap, + QScreen, +) +from PySide6.QtWidgets import ( + QFrame, + QHBoxLayout, + QLabel, + QWidget, +) + +from tiasjudge.core.svm import DiagnosisResult + +logger = logging.getLogger(__name__) + +# --- 表示の基本設定(旧実装 `ResultDlg.cpp` の座標・色)--- + +#: 結果パネルの一辺(旧実装の背景画像 `img_student2.bmp` と同じ 644x644 px) +PANEL_SIZE = 644 +#: 画像表示時の縮小率(SPEC_01「診断画像」.`main_window.DISPLAY_DIVISOR` と同値) +IMAGE_DISPLAY_DIVISOR = 2 + +#: パネル行の並び(上から).`core.svm.COLOR_KEYS` の 5 色を旧実装の表示順に並べ替えたもの +DISPLAY_ORDER: tuple[str, ...] = ("pink", "crimson", "dark_red", "purple", "pale") + +#: 強調表示する色数(SPEC_01「上位 2 色のみ」.`DiagnosisResult.top_labels` の要素数) +HIGHLIGHT_COUNT = 2 + +# --- レイアウト(旧実装 `ShowResultImg()` の x / y / margin)--- + +#: 見出しのベースライン位置 +HEADING_POSITION = (20, 55) +#: 所属表記(右上)の 1 行目ベースライン位置と行間 +AFFILIATION_POSITION = (300, 30) +AFFILIATION_LINE_HEIGHT = 26 +#: 注記 2 行のベースライン位置 +NOTE_POSITIONS = ((20, 110), (20, 150)) + +#: 1 行目のベースライン y と行間(旧実装 `int y = 250; int margin = 87;`) +ROW_FIRST_Y = 250 +ROW_MARGIN = 87 +#: 色名・説明・スコア・パーセント記号の x 位置.説明文の x は旧背景画像の実測値 +#: (説明文のインクが x=206 から始まる)に合わせてある.文字を旧画像相当まで拡大した際に +#: 色名の描画幅を確保するための調整でもある(旧実装のコード上の値は 185) +NAME_X = 20 +DESCRIPTION_X = 206 +SCORE_RIGHT_X = 610 +PERCENT_X = 620 +#: スコア表示のために空けておく幅(強調行の説明文はこの手前で切り詰める). +#: スコア最大表示 "100" は `SCORE_FONT_SIZE` で約 73 px 幅のため,余白を含めて 80 とする +SCORE_AREA_WIDTH = 80 +#: 文字列の右端に確保する余白 +TEXT_MARGIN = 8 +#: 色名・説明文の最大幅(フォントが変わっても隣の要素へはみ出さないよう切り詰める) +NAME_MAX_WIDTH = DESCRIPTION_X - NAME_X - TEXT_MARGIN +DESCRIPTION_MAX_WIDTH = PANEL_SIZE - DESCRIPTION_X - TEXT_MARGIN +DESCRIPTION_MAX_WIDTH_HIGHLIGHTED = SCORE_RIGHT_X - SCORE_AREA_WIDTH - DESCRIPTION_X +#: 所属表記の最大幅(右端をはみ出さないための保険) +AFFILIATION_MAX_WIDTH = PANEL_SIZE - AFFILIATION_POSITION[0] - TEXT_MARGIN + +#: 下線の x 範囲・ベースラインからの下げ幅・太さ(旧実装 `cv::line(..., 8)`) +UNDERLINE_X_RANGE = (15, 635) +UNDERLINE_Y_OFFSET = 12 +UNDERLINE_WIDTH = 8 + +#: 診断失敗の帯(旧実装 `cv::rectangle(result, Point(0, 250), Point(550, 450), ..., -1)`) +FAILURE_RECT = (0, 250, 550, 200) +FAILURE_TITLE_POSITION = (20, 355) +FAILURE_DETAIL_POSITION = (120, 410) + +# --- 色(旧実装は BGR 指定.ここでは RGB に読み替える)--- + +PANEL_BACKGROUND = (255, 255, 255) +#: 見出しのオレンジ.旧背景画像 `img/img_student2.bmp` の見出し「分析結果」から実測した +#: 最頻値(旧コードの `Scalar(0, 90, 230)` = RGB(230, 90, 0) より画像の実値を優先する). +#: 英語版見出しも旧 `BuildEnglishBackground()` が日本語と同色を使うため同じ値を用いる +HEADING_COLOR = (233, 113, 50) +#: 注記・所属のグレー(旧 `Scalar(60, 60, 60)`) +NOTE_COLOR = (60, 60, 60) +#: 説明文の色(旧 `Scalar(30, 30, 30)`) +DESCRIPTION_COLOR = (30, 30, 30) +#: スコアの色(旧 `Scalar(0, 0, 192)`) +SCORE_COLOR = (192, 0, 0) +#: 下線の色(旧 `Scalar(100, 150, 250)`) +UNDERLINE_COLOR = (250, 150, 100) +#: 診断失敗の帯と文字(旧 `Scalar(200, 0, 0)` / `Scalar(200, 200, 200)`) +FAILURE_BACKGROUND = (0, 0, 200) +FAILURE_TEXT_COLOR = (200, 200, 200) + +#: 色名の表示色.旧背景画像 `img/img_student2.bmp` の各色名文字からピクセルを +#: サンプリングして得た最頻値(旧 `BuildEnglishBackground()` の cPink / cRed / +#: cDarkRed / cPurple / cPale は画像と一致しないため,画像の実測値を正とする) +COLOR_SWATCHES: dict[str, tuple[int, int, int]] = { + "pink": (255, 187, 174), + "crimson": (224, 112, 146), + "dark_red": (214, 69, 65), + "purple": (125, 94, 125), + "pale": (255, 241, 167), +} + +#: 色名の縁取り色と太さ.旧背景画像の色名文字には暗色の縁取りがあり,走査線を実測すると +#: 文字の外側 1 px 程度が暗色(アンチエイリアスで平均 RGB(30〜45) 相当)になっている. +#: `strokePath` のペン幅はパスの内外へ半分ずつ広がるため,外側 1 px 相当となる 2 を使う. +#: 縁取りを付けるのは 5 色の色名のみ(見出し・説明文・スコア・注記は縁取りしない) +NAME_OUTLINE_COLOR = (0, 0, 0) +NAME_OUTLINE_WIDTH = 2 + +# --- フォント(px 指定.環境差で行がずれないよう固定する)--- + +#: パネル描画に使うフォントの優先順(日本語の字形を確実に出すため明示する. +#: 見つからない環境では既定フォントにフォールバックする) +PANEL_FONT_FAMILIES: tuple[str, ...] = ( + "Yu Gothic UI", + "Meiryo", + "MS PGothic", + "Hiragino Sans", + "Noto Sans CJK JP", +) + +#: 各サイズは旧背景画像 `img/img_student2.bmp` の文字のインク高さ(実測)に合わせてある. +#: 実測値: 見出し 64 px / 所属 18 px / 注記 26 px / 色名 50 px / 説明文 40 px +#: (説明文は旧画像のインク高 40 px に対し,ベース 40 px でほぼ同等の見た目になる). +#: ただし見出しはベースライン y=55 を動かさない制約から,パネル上端をはみ出さない +#: 範囲(インク高さ 52 px)が上限となるため 56 px で頭打ちにしている. +#: 色名は英語の "Dark red" が `NAME_MAX_WIDTH` に収まる範囲が上限となるため 44 px とする +#: (日英で挙動を変えないよう,言語別のサイズは設けない). +HEADING_FONT_SIZE = 56 +AFFILIATION_FONT_SIZE = 19 +NOTE_FONT_SIZE = 28 +NAME_FONT_SIZE = 44 +#: 説明文の基準サイズ(既定).行ごとの値は `ColorRowText.description_font_size` で上書きする +DESCRIPTION_FONT_SIZE = 40 +#: 説明文が長い行のサイズ(px).英語は語数が多く,基準の 40 px では強調行の上限幅 +#: (`DESCRIPTION_MAX_WIDTH_HIGHLIGHTED` = 324 px.スコア欄を除いた幅)に収まらない行がある. +#: 収まらないまま描くと強調された行だけ動的縮小が働き,上位 2 色が変わるたびに +#: 文字サイズがちらつくため,**強調の有無によらず固定**の小さいサイズを行ごとに与える. +#: 値は「スコア表示付きの強調行でも縮小なしで収まる最大サイズ」の実測値: +#: "Hot flush / feverish" と "Blood slightly thick" が 36 px, +#: 最長の "Cold / fatigue / anemia" が 30 px(31 px では 337 px となり超過する) +DESCRIPTION_FONT_SIZE_LONG = 36 +DESCRIPTION_FONT_SIZE_LONGEST = 30 +#: 説明文を縮めてよい下限(強調行は右側にスコア欄がある分だけ幅が狭いため. +#: 上記のとおり通常は発動しないが,フォント差のある環境向けの安全網として残す) +DESCRIPTION_MIN_FONT_SIZE = 28 +#: 説明文(症状)の太さ.既定よりわずかに太くしつつ,太さより字の大きさで読みやすさを +#: 稼ぐ方針として Medium に留める(DemiBold は太すぎ・Bold は色名より目立つ.説明文のみに適用) +DESCRIPTION_FONT_WEIGHT = QFont.Weight.Medium +SCORE_FONT_SIZE = 48 +PERCENT_FONT_SIZE = 24 +FAILURE_TITLE_FONT_SIZE = 52 +FAILURE_DETAIL_FONT_SIZE = 28 + +#: 診断失敗の表示文言(SPEC_01「診断失敗表示」.旧実装は日英とも英語で表示する) +FAILURE_TITLE = "Diagnosis failed" +FAILURE_DETAIL = "Please try again." + + +class Language(Enum): + """結果画面の表示言語(SPEC_01「言語切替ボタン」).""" + + JAPANESE = "ja" + ENGLISH = "en" + + +@dataclass(frozen=True) +class ColorRowText: + """5 色の行に表示する文言(色名と説明). + + Attributes: + key: 色のキー(`core.svm.COLOR_KEYS` と同じ). + name: 表示する色名. + description: 色名の右に表示する説明. + description_font_size: 説明文のサイズ(px).長い説明の行だけ小さくする. + """ + + key: str + name: str + description: str + description_font_size: int = DESCRIPTION_FONT_SIZE + + +@dataclass(frozen=True) +class LanguageTexts: + """1 言語分の表示リソース(SPEC_01「5 色の表示内容」). + + Attributes: + window_title: ウィンドウタイトル. + heading: パネル見出し. + affiliations: 見出し右上の所属表記(無い言語は空タプル). + notes: 注記(2 行). + rows: 5 色の行(`DISPLAY_ORDER` と同じ並び). + switch_button_label: 言語切替ボタンのラベル(切替**先**の言語名). + 結果画面は表示専用のため,このラベルを使うのはメイン画面「結果画面の操作」の + 言語切替ボタン(`switch_button_label()`). + """ + + window_title: str + heading: str + affiliations: tuple[str, ...] + notes: tuple[str, ...] + rows: tuple[ColorRowText, ...] + switch_button_label: str + + +#: 言語別の表示リソース(旧実装の日本語背景画像と `BuildEnglishBackground()` の文言) +LANGUAGE_TEXTS: dict[Language, LanguageTexts] = { + Language.JAPANESE: LanguageTexts( + window_title="TIAS 舌色診断 診断結果", + heading="分析結果", + affiliations=( + "千葉大学大学院医学研究院和漢診療学", + "千葉大学フロンティア医工学センター", + ), + notes=( + "※ この結果は病気を見ているものではありません", + "※ 表示画像は明るさを補正しています", + ), + rows=( + ColorRowText("pink", "淡紅", "正常"), + ColorRowText("crimson", "紅", "のぼせ・熱っぽい"), + ColorRowText("dark_red", "暗赤", "少し血がどろどろ"), + ColorRowText("purple", "紫", "血がどろどろ"), + ColorRowText("pale", "淡白紅", "冷え・だるさ・貧血"), + ), + switch_button_label="English", + ), + Language.ENGLISH: LanguageTexts( + window_title="TIAS Tongue Color Analysis - Result", + heading="Analysis Result", + affiliations=(), + notes=( + "* This result does not diagnose any disease.", + "* Displayed image is brightness-corrected.", + ), + # 説明文が長い 3 行だけサイズを下げる(値の根拠は `DESCRIPTION_FONT_SIZE_LONG` 参照) + rows=( + ColorRowText("pink", "Pink", "Normal"), + ColorRowText( + "crimson", + "Red", + "Hot flush / feverish", + description_font_size=DESCRIPTION_FONT_SIZE_LONG, + ), + ColorRowText( + "dark_red", + "Dark red", + "Blood slightly thick", + description_font_size=DESCRIPTION_FONT_SIZE_LONG, + ), + ColorRowText("purple", "Purple", "Blood is thick"), + ColorRowText( + "pale", + "Pale red", + "Cold / fatigue / anemia", + description_font_size=DESCRIPTION_FONT_SIZE_LONGEST, + ), + ), + switch_button_label="日本語", + ), +} + + +# --- 画面に依存しないロジック(QWidget 非依存.単体で検証できる)--- + + +@dataclass(frozen=True) +class ResultRow: + """結果パネルの 1 行分の表示内容. + + Attributes: + key: 色のキー. + name: 表示する色名. + description: 説明文. + swatch: 色名の表示色(RGB). + highlighted: 上位 2 色(スコアと下線を描く行)なら True. + score_text: 強調行に表示するスコア文字列.非強調行は None. + description_font_size: 説明文のサイズ(px).強調の有無によらず一定. + """ + + key: str + name: str + description: str + swatch: tuple[int, int, int] + highlighted: bool + score_text: str | None + description_font_size: int = DESCRIPTION_FONT_SIZE + + +def texts_for(language: Language) -> LanguageTexts: + """指定言語の表示リソースを返す.""" + return LANGUAGE_TEXTS[language] + + +def next_language(language: Language) -> Language: + """言語切替ボタンで切り替わる先の言語を返す(日本語 ⇔ 英語).""" + return Language.ENGLISH if language is Language.JAPANESE else Language.JAPANESE + + +def switch_button_label(language: Language) -> str: + """言語切替ボタンのラベル(切替先の言語名)を返す. + + 旧実装(`OnBnClickedButtonLang`)と同じく,現在が日本語なら "English" を表示する. + """ + return texts_for(language).switch_button_label + + +def is_highlighted(key: str, top_labels: tuple[str, ...]) -> bool: + """その色がスコア・下線を描く強調対象(上位 2 色)かどうかを返す.""" + return key in top_labels[:HIGHLIGHT_COUNT] + + +def format_score(percent: int) -> str: + """スコア(%)の表示文字列を返す(旧実装 `Format("%3.0lf")` と同じ 3 桁右詰め).""" + return f"{percent:3d}" + + +def build_result_rows(result: DiagnosisResult, language: Language) -> tuple[ResultRow, ...]: + """診断結果から結果パネルの行(表示順)を組み立てる. + + 上位 2 色(`DiagnosisResult.top_labels`)だけがスコアを持ち,強調表示される + (SPEC_01「結果パネル」). + + Args: + result: 診断結果. + language: 表示言語. + + Returns: + `DISPLAY_ORDER` と同じ並びの行データ. + """ + # 言語リソースの並びに依存せず,表示順は `DISPLAY_ORDER` で決める + row_texts = {row.key: row for row in texts_for(language).rows} + rows: list[ResultRow] = [] + for key in DISPLAY_ORDER: + row_text = row_texts[key] + highlighted = is_highlighted(key, result.top_labels) + rows.append( + ResultRow( + key=key, + name=row_text.name, + description=row_text.description, + swatch=COLOR_SWATCHES[key], + highlighted=highlighted, + score_text=format_score(result.percentages[key]) if highlighted else None, + description_font_size=row_text.description_font_size, + ) + ) + return tuple(rows) + + +def row_baseline(index: int) -> int: + """上から `index` 番目(0 始まり)の行のベースライン y を返す.""" + return ROW_FIRST_Y + ROW_MARGIN * index + + +# --- 描画(QPixmap 生成.ウィジェットには依存しない)--- + + +def to_display_pixmap(image: NDArray[np.uint8], divisor: int = IMAGE_DISPLAY_DIVISOR) -> QPixmap: + """BGR 画像を 1/`divisor` に縮小して QPixmap に変換する(SPEC_01「診断画像」). + + 旧実装の `resize(..., CV_INTER_CUBIC)` と同じ補間を使う. + + Args: + image: BGR 画像(形状 (高さ, 幅, 3)). + divisor: 縮小率の分母(既定は 1/2 表示). + + Returns: + 縮小後の QPixmap. + """ + height, width = image.shape[:2] + size = (max(width // divisor, 1), max(height // divisor, 1)) + resized = cv2.resize(image, size, interpolation=cv2.INTER_CUBIC) + rgb = np.ascontiguousarray(cv2.cvtColor(resized, cv2.COLOR_BGR2RGB)) + qimage = QImage( + rgb.data, + rgb.shape[1], + rgb.shape[0], + rgb.strides[0], + QImage.Format.Format_RGB888, + ) + # numpy のバッファ寿命に依存しないよう複製する + return QPixmap.fromImage(qimage.copy()) + + +def _set_font( + painter: QPainter, + pixel_size: int, + *, + bold: bool = False, + weight: QFont.Weight | None = None, +) -> None: + """描画に使うフォント(書体・サイズ・太さ)を設定する. + + `weight` を指定すると `bold` より優先し,Bold 以外の太さ(DemiBold 等)も選べる. + """ + font = QFont(painter.font()) + font.setFamilies([*PANEL_FONT_FAMILIES, font.family()]) + font.setPixelSize(pixel_size) + if weight is not None: + font.setWeight(weight) + else: + font.setBold(bold) + painter.setFont(font) + + +def _fit_font( + painter: QPainter, + text: str, + pixel_size: int, + max_width: int, + min_pixel_size: int, + *, + bold: bool = False, + weight: QFont.Weight | None = None, +) -> None: + """`max_width` に収まる最大のサイズでフォントを設定する(下限は `min_pixel_size`). + + 文字を旧背景画像に合わせて大きくした結果,長い説明文(英語の + "Cold / fatigue / anemia" 等)がスコア欄に達する行が出るため,末尾を省略する前に + まず文字サイズを縮めて全文を残す.下限まで縮めても収まらない場合は + 呼び出し元の省略(elide)に委ねる. + """ + for size in range(pixel_size, min_pixel_size - 1, -1): + _set_font(painter, size, bold=bold, weight=weight) + if painter.fontMetrics().horizontalAdvance(text) <= max_width: + return + + +def _prepare_text( + painter: QPainter, + text: str, + pixel_size: int, + *, + bold: bool = False, + weight: QFont.Weight | None = None, + max_width: int | None = None, + min_pixel_size: int | None = None, +) -> str: + """フォントを設定し,`max_width` に収まるよう調整した描画用の文字列を返す. + + `max_width` を指定すると,その幅に収まらない文字列を末尾省略(...)で切り詰める + (環境ごとのフォント差で隣の要素へはみ出さないようにするため). + `min_pixel_size` も指定した場合は,省略する前にその大きさまで文字を縮めて全文を残す + (`_fit_font` 参照).`weight` を指定すると `bold` より優先する(`_set_font` 参照). + """ + if max_width is not None and min_pixel_size is not None: + _fit_font(painter, text, pixel_size, max_width, min_pixel_size, bold=bold, weight=weight) + else: + _set_font(painter, pixel_size, bold=bold, weight=weight) + if max_width is None: + return text + return painter.fontMetrics().elidedText(text, Qt.TextElideMode.ElideRight, max_width) + + +def _draw_text( + painter: QPainter, + position: tuple[int, int], + text: str, + color: tuple[int, int, int], + pixel_size: int, + *, + bold: bool = False, + weight: QFont.Weight | None = None, + max_width: int | None = None, + min_pixel_size: int | None = None, +) -> None: + """ベースライン基準で文字列を描く(旧実装の `cv::putText` に対応). + + フォントの設定・切り詰めの扱いは `_prepare_text` を参照. + """ + drawn = _prepare_text( + painter, + text, + pixel_size, + bold=bold, + weight=weight, + max_width=max_width, + min_pixel_size=min_pixel_size, + ) + painter.setPen(QColor(*color)) + painter.drawText(position[0], position[1], drawn) + + +def _draw_outlined_text( + painter: QPainter, + position: tuple[int, int], + text: str, + color: tuple[int, int, int], + pixel_size: int, + *, + bold: bool = False, + max_width: int | None = None, +) -> None: + """ベースライン基準で,黒い縁取りを付けた文字列を描く(旧背景画像の色名の見え方). + + `drawText` では縁取りを描けないため,`QPainterPath` で文字をパス化し, + 黒ペンで輪郭を描いてから本来の色で塗りつぶす.フォントの設定・切り詰め(`max_width`)の + 扱いは `_draw_text` と同じ(`_prepare_text` を共用する). + """ + drawn = _prepare_text(painter, text, pixel_size, bold=bold, max_width=max_width) + + path = QPainterPath() + path.addText(float(position[0]), float(position[1]), painter.font(), drawn) + + pen = QPen(QColor(*NAME_OUTLINE_COLOR)) + pen.setWidth(NAME_OUTLINE_WIDTH) + # 角の尖りで文字が太って見えないよう,線の継ぎ目は丸める + pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin) + pen.setCapStyle(Qt.PenCapStyle.RoundCap) + + painter.save() + try: + painter.strokePath(path, pen) + painter.fillPath(path, QColor(*color)) + finally: + painter.restore() + + +def render_result_panel(rows: tuple[ResultRow, ...], texts: LanguageTexts, failed: bool) -> QPixmap: + """結果パネル(644x644)を描画して返す. + + 旧実装の背景ビットマップ(日本語)と `BuildEnglishBackground()`(英語)を + どちらも描画で再現する. + + Args: + rows: 表示する 5 色の行(`build_result_rows` の戻り値). + texts: 表示言語のリソース. + failed: 診断失敗なら True(失敗の帯を重ねて描く). + + Returns: + 描画済みの QPixmap. + """ + pixmap = QPixmap(PANEL_SIZE, PANEL_SIZE) + pixmap.fill(QColor(*PANEL_BACKGROUND)) + painter = QPainter(pixmap) + try: + painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) + painter.setRenderHint(QPainter.RenderHint.TextAntialiasing, True) + _draw_header(painter, texts) + _draw_rows(painter, rows) + if failed: + _draw_failure(painter) + finally: + painter.end() + return pixmap + + +def _draw_header(painter: QPainter, texts: LanguageTexts) -> None: + """見出し・所属・注記を描く.""" + _draw_text( + painter, HEADING_POSITION, texts.heading, HEADING_COLOR, HEADING_FONT_SIZE, bold=True + ) + for index, line in enumerate(texts.affiliations): + position = ( + AFFILIATION_POSITION[0], + AFFILIATION_POSITION[1] + AFFILIATION_LINE_HEIGHT * index, + ) + _draw_text( + painter, + position, + line, + NOTE_COLOR, + AFFILIATION_FONT_SIZE, + max_width=AFFILIATION_MAX_WIDTH, + ) + for position, note in zip(NOTE_POSITIONS, texts.notes, strict=True): + _draw_text(painter, position, note, NOTE_COLOR, NOTE_FONT_SIZE) + + +def _draw_rows(painter: QPainter, rows: tuple[ResultRow, ...]) -> None: + """5 色の行(色名・説明)と,上位 2 色のスコア・下線を描く. + + 説明文のサイズは行ごと(`ResultRow.description_font_size`)に決まっており, + 強調の有無で変わらない. + """ + for index, row in enumerate(rows): + baseline = row_baseline(index) + # 色名だけは旧背景画像に合わせて黒縁取りを付ける(日英とも同じ扱い) + _draw_outlined_text( + painter, + (NAME_X, baseline), + row.name, + row.swatch, + NAME_FONT_SIZE, + bold=True, + max_width=NAME_MAX_WIDTH, + ) + _draw_text( + painter, + (DESCRIPTION_X, baseline), + f": {row.description}", + DESCRIPTION_COLOR, + row.description_font_size, + weight=DESCRIPTION_FONT_WEIGHT, + max_width=( + DESCRIPTION_MAX_WIDTH_HIGHLIGHTED if row.highlighted else DESCRIPTION_MAX_WIDTH + ), + # 強調行はスコア欄の分だけ幅が狭いため,収まらなければ縮めて全文を残す + min_pixel_size=DESCRIPTION_MIN_FONT_SIZE, + ) + if row.score_text is None: + continue + _draw_score(painter, baseline, row.score_text) + _draw_underline(painter, baseline) + + +def _draw_score(painter: QPainter, baseline: int, score_text: str) -> None: + """強調行のスコアと "%" を描く(旧実装の x=530 / x=620 に対応).""" + painter.setPen(QColor(*SCORE_COLOR)) + _set_font(painter, SCORE_FONT_SIZE, bold=True) + # 桁数が変わっても右端が揃うよう,右詰めで描く + width = painter.fontMetrics().horizontalAdvance(score_text) + painter.drawText(SCORE_RIGHT_X - width, baseline, score_text) + _draw_text(painter, (PERCENT_X, baseline), "%", SCORE_COLOR, PERCENT_FONT_SIZE) + + +def _draw_underline(painter: QPainter, baseline: int) -> None: + """強調行の下線を描く(旧実装 `cv::line(..., Scalar(100, 150, 250), 8)`).""" + pen = QPen(QColor(*UNDERLINE_COLOR)) + pen.setWidth(UNDERLINE_WIDTH) + painter.setPen(pen) + y = baseline + UNDERLINE_Y_OFFSET + painter.drawLine(UNDERLINE_X_RANGE[0], y, UNDERLINE_X_RANGE[1], y) + + +def _draw_failure(painter: QPainter) -> None: + """診断失敗の帯を重ねて描く(SPEC_01「診断失敗表示」).""" + painter.fillRect(*FAILURE_RECT, QColor(*FAILURE_BACKGROUND)) + _draw_text( + painter, + FAILURE_TITLE_POSITION, + FAILURE_TITLE, + FAILURE_TEXT_COLOR, + FAILURE_TITLE_FONT_SIZE, + bold=True, + ) + _draw_text( + painter, + FAILURE_DETAIL_POSITION, + FAILURE_DETAIL, + FAILURE_TEXT_COLOR, + FAILURE_DETAIL_FONT_SIZE, + ) + + +# --- 表示先モニタの選択(QWidget 非依存.`QScreen` の一覧と基準だけで決まる)--- + + +def is_same_screen(screen: QScreen, other: QScreen | None) -> bool: + """2 つのモニタが同一かどうかを返す. + + `QApplication.screens()` と `QWidget.screen()` が別の Python ラッパを返しても + 同一と判定できるよう,同一性が取れない場合はモニタ名で比較する. + """ + if other is None: + return False + if screen is other: + return True + return bool(screen.name() == other.name()) + + +def select_secondary_screen( + screens: Sequence[QScreen], reference: QScreen | None +) -> QScreen | None: + """`reference` と異なる最初のモニタを返す(無ければ None). + + 2 画面運用(施術者側にメイン画面・患者側に結果画面)で,結果画面を映す + 「もう一方のモニタ」を決めるために使う.モニタが 1 台しか無い場合や, + `reference` 以外のモニタが見つからない場合は None を返し,呼び出し元は + 従来どおり(同じモニタに通常サイズで)表示する. + + Args: + screens: 対象となるモニタの一覧(`QApplication.screens()`). + reference: 基準となるモニタ(メイン画面のあるモニタ).None なら先頭を返す. + + Returns: + 表示先にすべきモニタ.該当が無ければ None. + """ + for screen in screens: + if not is_same_screen(screen, reference): + return screen + return None + + +def show_on_secondary_screen( + window: QWidget, screens: Sequence[QScreen], reference: QScreen | None +) -> bool: + """`reference` 以外のモニタがあれば,そこへ移してから最大化表示する. + + 移動より先に最大化すると元のモニタで最大化されてしまうため, + 「対象モニタへ移す → 最大化」の順で呼ぶ. + + Args: + window: 表示するウィンドウ. + screens: 対象となるモニタの一覧. + reference: 基準となるモニタ(このモニタは選ばない). + + Returns: + 別モニタへ最大化表示したなら True.該当モニタが無く通常表示したなら False. + """ + target = select_secondary_screen(screens, reference) + if target is None: + # モニタが 1 台しか無い場合は従来どおり通常サイズで表示する + window.show() + return False + logger.info("結果画面をサブモニタに最大化表示します: %s", target.name()) + window.setScreen(target) + # 最大化の前に対象モニタの領域へ移しておく(この順序でないと元のモニタで最大化される) + window.setGeometry(target.availableGeometry()) + window.showMaximized() + return True + + +# --- 画面部品 --- + + +class ResultWindow(QWidget): + """結果画面(SPEC_01「結果画面」).モードレスで表示する.""" + + #: 画面を閉じたとき(メイン画面が参照を手放すために使う) + closed = Signal() + + def __init__( + self, + result: DiagnosisResult, + image: NDArray[np.uint8], + language: Language = Language.JAPANESE, + parent: QWidget | None = None, + ) -> None: + """結果画面を構築する. + + Args: + result: 表示する診断結果. + image: 診断に用いたクロップ後の BGR 画像. + language: 初期表示の言語. + parent: 親ウィジェット(指定してもモードレスの独立ウィンドウとして開く). + """ + super().__init__(parent) + # 親を持たせても独立したウィンドウとして表示する(旧実装のモードレスダイアログ相当) + self.setWindowFlag(Qt.WindowType.Window, True) + self._result = result + self._language = language + self._image_pixmap = to_display_pixmap(image) + + self._build_ui() + self._apply_language() + + @property + def language(self) -> Language: + """現在の表示言語.""" + return self._language + + # --- 画面構築 --- + + def _build_ui(self) -> None: + """ウィジェットを組み立てる(左: 診断画像/右: 結果パネル). + + 操作ボタンは持たない(患者側モニタに操作 UI を出さないため,言語切替と + 「閉じる」はメイン画面側に置く). + + 結果パネルは 644x644 固定描画のため,サブモニタへ最大化したときは周囲が + 余白になる.余白が偏らないよう,左右と上下に伸縮スペースを入れて内容を + ウィンドウ中央へ寄せる(通常サイズでは余白が生じないため見た目は変わらない). + """ + layout = QHBoxLayout(self) + layout.addStretch(1) + + self._image_label = QLabel(self) + self._image_label.setFrameShape(QFrame.Shape.Box) + self._image_label.setPixmap(self._image_pixmap) + self._image_label.setFixedSize(self._image_pixmap.size()) + layout.addWidget(self._image_label, 0, Qt.AlignmentFlag.AlignVCenter) + + self._panel_label = QLabel(self) + self._panel_label.setFixedSize(PANEL_SIZE, PANEL_SIZE) + layout.addWidget(self._panel_label, 0, Qt.AlignmentFlag.AlignVCenter) + layout.addStretch(1) + + # --- 表示の更新 --- + + def set_language(self, language: Language) -> None: + """表示言語を設定して描き直す(メイン画面の言語切替ボタンから呼ぶ). + + Args: + language: 表示する言語.現在と同じなら何もしない. + """ + if language is self._language: + return + self._language = language + logger.info("結果画面の言語を切り替えました: %s", self._language.value) + self._apply_language() + + def toggle_language(self) -> None: + """日本語 ⇔ 英語を切り替える(言語状態はメイン画面が持つため主に内部・試験用).""" + self.set_language(next_language(self._language)) + + def _apply_language(self) -> None: + """現在の言語でタイトルと結果パネルを描き直す.""" + texts = texts_for(self._language) + self.setWindowTitle(texts.window_title) + rows = build_result_rows(self._result, self._language) + self._panel_label.setPixmap(render_result_panel(rows, texts, self._result.failed)) + + # --- イベント --- + + def closeEvent(self, event: QCloseEvent) -> None: # noqa: N802 - Qt のオーバーライド + """閉じられたことを通知する(メイン画面側で参照を解放するため).""" + self.closed.emit() + super().closeEvent(event) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..49285a8 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,40 @@ +"""テスト共通のフィクスチャ.""" + +import os +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[1] + +#: Qt をヘッドレス(画面なし)で動かすためのプラットフォーム指定. +#: GUI の自動テストは書かない方針(testing.md)だが,`gui` モジュールの import や +#: `--check` 起動で QApplication が生成されるため,CI でも安全に動くよう offscreen を既定にする. +#: 開発者が実画面で確認したい場合は環境変数を明示すれば上書きできる. +QT_PLATFORM_ENV_VAR = "QT_QPA_PLATFORM" +os.environ.setdefault(QT_PLATFORM_ENV_VAR, "offscreen") + + +@pytest.fixture(scope="session") +def headless_env() -> dict[str, str]: + """サブプロセスで Qt を起動するための環境変数(offscreen 指定込み)を返す.""" + return {**os.environ, QT_PLATFORM_ENV_VAR: "offscreen"} + + +@pytest.fixture(scope="session") +def repo_root() -> Path: + """リポジトリ直下(`pyproject.toml` のある場所)を返す.""" + return REPO_ROOT + + +@pytest.fixture(scope="session") +def data_csv_path(repo_root: Path) -> Path: + """同梱学習データ `data/data.csv` のパスを返す(project-structure.md).""" + return repo_root / "data" / "data.csv" + + +@pytest.fixture(scope="session") +def data_csv_rows(data_csv_path: Path) -> list[list[str]]: + """`data.csv` を UTF-8 で読み,全行をカンマ分割した 2 次元リストで返す.""" + text = data_csv_path.read_text(encoding="utf-8") + return [line.split(",") for line in text.splitlines()] diff --git a/tests/core/test_color.py b/tests/core/test_color.py new file mode 100644 index 0000000..7dc09b3 --- /dev/null +++ b/tests/core/test_color.py @@ -0,0 +1,583 @@ +"""`core/color.py` の検証(SPEC_02 (1) RGB→XYZ・(2) XYZ→Lab・PLAN_02 ステップ 3). + +仕様上の要件は以下(`docs/04_SPEC/SPEC_02_診断ロジック仕様.md`). + +- (1) 変換行列 CSV の係数 `a[0..16], b[0..16], c[0..16]` を用いた 17 項の多項式で X, Y, Z を求める. + 項の内訳は 1 次 3 項 + 2 次クロス 3 項 + 2 乗 3 項 + 3 次 7 項 + 定数項.負値は 0 にクリップする +- (2) 白色点 Xn=102.07, Yn=100.0, Zn=79.41 の固定値で,比の立方根から L\\*a\\*b\\* を求める. + **CIE 標準の低輝度側の条件分岐は現行実装に無いため意図的に実装しない**(結果同一性優先)ので, + 本テストでも CIE 標準値との比較は行わず,SPEC_02 に書かれた式そのものを正解とする + +期待値は実装コードを参照せず,本ファイル内でテスト用に独立実装した `spec_*` 関数 +(SPEC_02 の式をそのまま書き下したもの)から求める. +比較の許容誤差は float32 精度(rel 1e-6 程度)とする.旧実装は係数と Lab を単精度で保持するため, +倍精度で計算した仕様値とは float32 の丸め分だけずれうるが,診断結果に影響しない範囲である. +""" + +from __future__ import annotations + +import math +from pathlib import Path + +import numpy as np +import pytest + +from tiasjudge.core.color import ( + POLYNOMIAL_TERM_COUNT, + WHITE_POINT_X, + WHITE_POINT_Y, + WHITE_POINT_Z, + Lab, + Xyz, + rgb_to_lab, + rgb_to_xyz, + xyz_to_lab, +) +from tiasjudge.core.csv_io import load_transform_matrix + +# --- 比較の許容誤差 --- + +#: Lab の比較許容誤差(float32 精度.係数の単精度保持による差も吸収する) +LAB_REL = 1e-5 +LAB_ABS = 1e-4 +#: XYZ の比較許容誤差 +XYZ_REL = 1e-6 +XYZ_ABS = 1e-9 + +#: CIE 標準の低輝度分岐の閾値 t <= (6/29)^3(本実装では**使わない**ことの確認に用いる) +CIE_LINEAR_THRESHOLD = (6.0 / 29.0) ** 3 + + +# --- SPEC_02 の式の独立実装(テスト用.実装コードは参照しない) --- + + +def spec_polynomial_terms(red: float, green: float, blue: float) -> list[float]: + """SPEC_02 (1) の 17 項を式の並びどおりに返す. + + X = a0*R + a1*G + a2*B + + a3*R*G + a4*R*B + a5*G*B + + a6*R^2 + a7*G^2 + a8*B^2 + + a9*R^2*B + a10*R^2*G + a11*G^2*R + a12*G^2*B + a13*B^2*R + a14*B^2*G + + a15*R*G*B + + a16 + """ + r, g, b = float(red), float(green), float(blue) + return [ + r, + g, + b, + r * g, + r * b, + g * b, + r * r, + g * g, + b * b, + r * r * b, + r * r * g, + g * g * r, + g * g * b, + b * b * r, + b * b * g, + r * g * b, + 1.0, + ] + + +#: 17 項の意味(テストの可読性のため名前を付ける.並びは SPEC_02 の式の順) +TERM_NAMES = [ + "R", + "G", + "B", + "R*G", + "R*B", + "G*B", + "R^2", + "G^2", + "B^2", + "R^2*B", + "R^2*G", + "G^2*R", + "G^2*B", + "B^2*R", + "B^2*G", + "R*G*B", + "1", +] + + +def spec_rgb_to_xyz(rgb, coefficients) -> tuple[float, float, float]: + """SPEC_02 (1) の多項式変換(負値は 0 にクリップ).""" + terms = spec_polynomial_terms(*[float(value) for value in rgb]) + matrix = [[float(cell) for cell in row] for row in coefficients] + result = [] + for column in range(3): + total = math.fsum(terms[index] * matrix[index][column] for index in range(17)) + result.append(max(total, 0.0)) + return result[0], result[1], result[2] + + +def spec_xyz_to_lab(x: float, y: float, z: float) -> tuple[float, float, float]: + """SPEC_02 (2) の Lab 変換(低輝度側の条件分岐は持たない).""" + fx = (x / 102.07) ** (1.0 / 3.0) + fy = (y / 100.0) ** (1.0 / 3.0) + fz = (z / 79.41) ** (1.0 / 3.0) + return (116.0 * fy - 16.0, 500.0 * (fx - fy), 200.0 * (fy - fz)) + + +def spec_rgb_to_lab(rgb, coefficients) -> tuple[float, float, float]: + """SPEC_02 (1) → (2) の連結.""" + return spec_xyz_to_lab(*spec_rgb_to_xyz(rgb, coefficients)) + + +def cie_lightness(y: float) -> float: + """CIE 標準(低輝度側は線形近似)の L\\*.本実装との**差異の確認にのみ**使う.""" + t = y / 100.0 + if t <= CIE_LINEAR_THRESHOLD: + return 903.3 * t + return 116.0 * t ** (1.0 / 3.0) - 16.0 + + +# --- テスト用の係数 --- + + +def zero_coefficients() -> np.ndarray: + """全項 0 の係数行列(形状 (17, 3)).""" + return np.zeros((POLYNOMIAL_TERM_COUNT, 3), dtype=np.float64) + + +def white_point_constant_coefficients() -> np.ndarray: + """定数項だけに白色点を置いた係数(RGB によらず XYZ = 白色点になる).""" + matrix = zero_coefficients() + matrix[16] = [WHITE_POINT_X, WHITE_POINT_Y, WHITE_POINT_Z] + return matrix + + +def linear_coefficients() -> np.ndarray: + """1 次項のみの現実的な係数(sRGB 相当の比率を白色点に合わせて正規化したもの). + + RGB = (255, 255, 255) が白色点 (Xn, Yn, Zn) になるよう各列を正規化しているため, + 無彩色(R=G=B)では a\\*, b\\* が 0 になる. + 実際のキャリブレーション結果に近い規模で XYZ が得られればよく,値そのものに仕様上の意味はない. + """ + ratios = np.array( + [ + [0.4124, 0.2126, 0.0193], + [0.3576, 0.7152, 0.1192], + [0.1805, 0.0722, 0.9505], + ], + dtype=np.float64, + ) + white_point = np.array([WHITE_POINT_X, WHITE_POINT_Y, WHITE_POINT_Z], dtype=np.float64) + matrix = zero_coefficients() + matrix[0:3] = ratios * (white_point / (255.0 * ratios.sum(axis=0))) + return matrix + + +#: 高次項の係数スケール(項の大きさが 2 次で ~6.5e4,3 次で ~1.7e7 になるため次数ごとに調整する) +HIGHER_ORDER_SCALE = np.array([1e-6] * 6 + [1e-9] * 7, dtype=np.float64).reshape(13, 1) + + +def higher_order_coefficients(rng: np.random.Generator) -> np.ndarray: + """2 次・3 次項(行 3〜15)の係数を,XYZ が現実的な範囲に収まる大きさで生成する.""" + return rng.uniform(-1.0, 1.0, size=(13, 3)) * HIGHER_ORDER_SCALE + + +def full_coefficients() -> np.ndarray: + """全 17 項が効く現実的な係数(1 次項 + 高次項 + 定数項).""" + matrix = linear_coefficients() + matrix[3:16] = higher_order_coefficients(np.random.default_rng(20260803)) + matrix[16] = [0.5, -0.25, 0.75] + return matrix + + +def as_float32_exact(matrix: np.ndarray) -> np.ndarray: + """float32 で厳密に表現できる値に丸めた係数を返す(丸め差を排して式だけを比較するため).""" + return matrix.astype(np.float32).astype(np.float64) + + +SAMPLE_RGB = [ + (0.0, 0.0, 0.0), + (255.0, 255.0, 255.0), + (170.5, 95.25, 88.0), + (120.0, 70.0, 65.0), + (12.5, 8.25, 6.0), + (200.0, 10.0, 250.0), + (1.0, 2.0, 3.0), +] + + +@pytest.fixture +def transform_csv(tmp_path: Path) -> Path: + """`csv_io.load_transform_matrix` が読む形式(17 行 × 3 列・ヘッダなし)の CSV.""" + matrix = full_coefficients() + lines = [",".join(f"{value:.10g}" for value in row) for row in matrix] + path = tmp_path / "matrix.csv" + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + return path + + +def assert_lab_close(actual: Lab, expected: tuple[float, float, float]) -> None: + values = (actual.l_star, actual.a_star, actual.b_star) + assert values == pytest.approx(expected, rel=LAB_REL, abs=LAB_ABS) + + +class TestRgbToXyzPolynomial: + """SPEC_02 (1) RGB → XYZ の多項式変換.""" + + @pytest.mark.parametrize("rgb", SAMPLE_RGB) + def test_matches_spec_formula_with_full_coefficients(self, rgb: tuple[float, ...]) -> None: + coefficients = as_float32_exact(full_coefficients()) + xyz = rgb_to_xyz(rgb, coefficients) + expected = spec_rgb_to_xyz(rgb, coefficients) + assert (xyz.x, xyz.y, xyz.z) == pytest.approx(expected, rel=XYZ_REL, abs=XYZ_ABS) + + @pytest.mark.parametrize("term_index", range(POLYNOMIAL_TERM_COUNT)) + @pytest.mark.parametrize("column", [0, 1, 2]) + def test_each_of_17_terms_is_applied_to_the_right_output( + self, term_index: int, column: int + ) -> None: + """係数を 1 項ずつ立てると,その項の単項式が X / Y / Z に現れる. + + 列 0 = a[i](X 用),列 1 = b[i](Y 用),列 2 = c[i](Z 用)(SPEC_02 データ形式). + """ + coefficients = zero_coefficients() + coefficients[term_index, column] = 1.0 + red, green, blue = 170.5, 95.25, 88.0 + + xyz = rgb_to_xyz((red, green, blue), coefficients) + + expected_term = spec_polynomial_terms(red, green, blue)[term_index] + actual = (xyz.x, xyz.y, xyz.z) + assert actual[column] == pytest.approx(expected_term, rel=1e-12), ( + f"{TERM_NAMES[term_index]} 項が列 {column} に反映されていない" + ) + for other in range(3): + if other != column: + assert actual[other] == 0.0 + + def test_term_count_is_17(self) -> None: + assert POLYNOMIAL_TERM_COUNT == 17 + + def test_terms_are_independent_of_each_other(self) -> None: + """各項の寄与は線形に加算される(17 項の和 = 個別項の和).""" + coefficients = as_float32_exact(full_coefficients()) + rgb = (170.5, 95.25, 88.0) + total = rgb_to_xyz(rgb, coefficients).as_array() + + # 個別項の寄与(負になりうるのでクリップは最後に 1 度だけ適用する) + terms = spec_polynomial_terms(*rgb) + parts = np.zeros(3, dtype=np.float64) + for term_index in range(POLYNOMIAL_TERM_COUNT): + parts += terms[term_index] * coefficients[term_index] + + assert total == pytest.approx(np.maximum(parts, 0.0), rel=XYZ_REL, abs=1e-9) + + def test_constant_term_only_is_independent_of_rgb(self) -> None: + coefficients = white_point_constant_coefficients() + first = rgb_to_xyz((0.0, 0.0, 0.0), coefficients) + second = rgb_to_xyz((255.0, 128.0, 3.5), coefficients) + assert (first.x, first.y, first.z) == pytest.approx((second.x, second.y, second.z)) + assert (first.x, first.y, first.z) == pytest.approx( + (WHITE_POINT_X, WHITE_POINT_Y, WHITE_POINT_Z), rel=1e-6 + ) + + def test_negative_result_is_clipped_to_zero(self) -> None: + """負値は 0 にクリップする(SPEC_02 (1)).""" + coefficients = zero_coefficients() + coefficients[16] = [-10.0, 50.0, -0.001] + xyz = rgb_to_xyz((100.0, 100.0, 100.0), coefficients) + assert xyz.x == 0.0 + assert xyz.y == pytest.approx(50.0) + assert xyz.z == 0.0 + + def test_all_zero_coefficients_give_zero_xyz(self) -> None: + xyz = rgb_to_xyz((123.0, 45.0, 67.0), zero_coefficients()) + assert (xyz.x, xyz.y, xyz.z) == (0.0, 0.0, 0.0) + + @pytest.mark.parametrize("rgb", SAMPLE_RGB) + def test_accepts_list_tuple_and_ndarray(self, rgb: tuple[float, ...]) -> None: + coefficients = as_float32_exact(linear_coefficients()) + expected = rgb_to_xyz(rgb, coefficients) + for form in (list(rgb), np.array(rgb, dtype=np.float64), np.array(rgb, dtype=np.float32)): + xyz = rgb_to_xyz(form, coefficients) + assert (xyz.x, xyz.y, xyz.z) == pytest.approx( + (expected.x, expected.y, expected.z), rel=1e-6, abs=1e-6 + ) + + def test_result_dtype_is_float64_array(self) -> None: + xyz = rgb_to_xyz((10.0, 20.0, 30.0), as_float32_exact(linear_coefficients())) + array = xyz.as_array() + assert array.shape == (3,) + assert array.dtype == np.float64 + + def test_random_inputs_match_spec_formula(self) -> None: + """乱数で生成した RGB・係数でも仕様の式と一致する(シード固定で決定的).""" + rng = np.random.default_rng(12345) + base = linear_coefficients() + for _ in range(200): + coefficients = base.copy() + coefficients[3:16] = higher_order_coefficients(rng) + coefficients[16] = rng.uniform(-2.0, 2.0, size=3) + coefficients = as_float32_exact(coefficients) + rgb = rng.uniform(0.0, 255.0, size=3) + + xyz = rgb_to_xyz(rgb, coefficients) + expected = spec_rgb_to_xyz(rgb, coefficients) + assert (xyz.x, xyz.y, xyz.z) == pytest.approx(expected, rel=1e-9, abs=1e-9) + + def test_high_precision_coefficients_are_used_within_float32_accuracy(self) -> None: + """float32 で表現しきれない係数でも,仕様値と float32 精度の範囲で一致する.""" + coefficients = zero_coefficients() + coefficients[0] = [0.412345678901234, 0.212345678901234, 0.019345678901234] + coefficients[1] = [0.357612345678901, 0.715212345678901, 0.119212345678901] + coefficients[2] = [0.180512345678901, 0.072212345678901, 0.950512345678901] + rgb = (170.5, 95.25, 88.0) + + xyz = rgb_to_xyz(rgb, coefficients) + expected = spec_rgb_to_xyz(rgb, coefficients) + assert (xyz.x, xyz.y, xyz.z) == pytest.approx(expected, rel=1e-6) + + +class TestRgbToXyzErrors: + """RGB → XYZ の異常系(入力ガード).""" + + @pytest.mark.parametrize("rgb", [(), (1.0,), (1.0, 2.0), (1.0, 2.0, 3.0, 4.0)]) + def test_rgb_must_have_three_components(self, rgb: tuple[float, ...]) -> None: + with pytest.raises(ValueError): + rgb_to_xyz(rgb, zero_coefficients()) + + @pytest.mark.parametrize("shape", [(16, 3), (18, 3), (17, 2), (17, 4), (3, 17), (17,), (51,)]) + def test_coefficient_shape_must_be_17_by_3(self, shape: tuple[int, ...]) -> None: + with pytest.raises(ValueError): + rgb_to_xyz((1.0, 2.0, 3.0), np.zeros(shape, dtype=np.float64)) + + +class TestXyzToLab: + """SPEC_02 (2) XYZ → L\\*a\\*b\\*.""" + + def test_white_point_maps_to_l100_a0_b0(self) -> None: + """白色点そのものを入れると L\\*=100, a\\*=0, b\\*=0 になる.""" + lab = xyz_to_lab((WHITE_POINT_X, WHITE_POINT_Y, WHITE_POINT_Z)) + assert lab.l_star == pytest.approx(100.0, abs=1e-5) + assert lab.a_star == pytest.approx(0.0, abs=1e-5) + assert lab.b_star == pytest.approx(0.0, abs=1e-5) + + def test_white_point_constants_follow_spec(self) -> None: + assert (WHITE_POINT_X, WHITE_POINT_Y, WHITE_POINT_Z) == (102.07, 100.0, 79.41) + + @pytest.mark.parametrize( + "xyz", + [ + (102.07, 100.0, 79.41), + (50.0, 50.0, 50.0), + (20.0, 18.0, 15.0), + (95.0, 100.0, 108.0), + (1.0, 1.0, 1.0), + (0.5, 0.25, 0.125), + (0.0, 0.0, 0.0), + (200.0, 5.0, 0.0), + (0.0, 100.0, 0.0), + ], + ) + def test_matches_spec_formula(self, xyz: tuple[float, float, float]) -> None: + lab = xyz_to_lab(xyz) + assert_lab_close(lab, spec_xyz_to_lab(*xyz)) + + def test_accepts_xyz_dataclass(self) -> None: + lab_from_object = xyz_to_lab(Xyz(x=30.0, y=25.0, z=20.0)) + lab_from_tuple = xyz_to_lab((30.0, 25.0, 20.0)) + assert lab_from_object == lab_from_tuple + + def test_zero_xyz_gives_l_minus_16(self) -> None: + """XYZ = 0 でも分岐せず立方根の式が使われる(L\\* = -16 になる).""" + lab = xyz_to_lab((0.0, 0.0, 0.0)) + assert_lab_close(lab, (-16.0, 0.0, 0.0)) + + @pytest.mark.parametrize("y", [0.0001, 0.001, 0.01, 0.1, 0.5, 0.8, 0.885]) + def test_low_luminance_uses_cube_root_formula(self, y: float) -> None: + """低輝度域(t <= (6/29)^3)でも立方根の式をそのまま使う(SPEC_02 (2) 注記).""" + assert y / 100.0 <= CIE_LINEAR_THRESHOLD, "この検証は低輝度域でのみ意味を持つ" + lab = xyz_to_lab((y * 1.0207, y, y * 0.7941)) + expected = 116.0 * (y / 100.0) ** (1.0 / 3.0) - 16.0 + assert lab.l_star == pytest.approx(expected, rel=LAB_REL, abs=LAB_ABS) + + @pytest.mark.parametrize("y", [0.0001, 0.001, 0.01, 0.1]) + def test_low_luminance_differs_from_cie_linear_branch(self, y: float) -> None: + """CIE 標準の線形近似に切り替える実装とは値が明確に異なる(分岐が無いことの確認). + + ※ 分岐点近傍は両者が連続的に一致するため,差が現れる領域でのみ検証する. + """ + lab = xyz_to_lab((y * 1.0207, y, y * 0.7941)) + assert abs(lab.l_star - cie_lightness(y)) > 0.5 + + def test_a_star_depends_only_on_x_and_y(self) -> None: + base = xyz_to_lab((60.0, 50.0, 40.0)) + changed_z = xyz_to_lab((60.0, 50.0, 70.0)) + assert changed_z.a_star == pytest.approx(base.a_star, rel=LAB_REL, abs=LAB_ABS) + assert changed_z.l_star == pytest.approx(base.l_star, rel=LAB_REL, abs=LAB_ABS) + assert changed_z.b_star != pytest.approx(base.b_star, rel=LAB_REL, abs=LAB_ABS) + + def test_scaling_x_up_increases_a_star(self) -> None: + """X が大きい(赤方向)ほど a\\* は大きくなる.""" + lower = xyz_to_lab((40.0, 50.0, 40.0)) + higher = xyz_to_lab((70.0, 50.0, 40.0)) + assert higher.a_star > lower.a_star + + def test_scaling_z_up_decreases_b_star(self) -> None: + """Z が大きい(青方向)ほど b\\* は小さくなる.""" + lower = xyz_to_lab((50.0, 50.0, 30.0)) + higher = xyz_to_lab((50.0, 50.0, 70.0)) + assert higher.b_star < lower.b_star + + def test_l_star_is_monotonic_in_y(self) -> None: + values = [xyz_to_lab((50.0, y, 50.0)).l_star for y in (0.0, 1.0, 10.0, 50.0, 100.0, 150.0)] + assert values == sorted(values) + assert len(set(values)) == len(values) + + def test_random_inputs_match_spec_formula(self) -> None: + rng = np.random.default_rng(999) + for _ in range(300): + xyz = rng.uniform(0.0, 150.0, size=3) + assert_lab_close(xyz_to_lab(xyz), spec_xyz_to_lab(*xyz)) + + def test_result_dtype_is_float32_array(self) -> None: + """SVM 入力(`data.csv` の Lab は float32)と同じ dtype で渡せる.""" + array = xyz_to_lab((60.0, 50.0, 40.0)).as_array() + assert array.shape == (3,) + assert array.dtype == np.float32 + + def test_is_deterministic(self) -> None: + first = xyz_to_lab((60.0, 50.0, 40.0)) + second = xyz_to_lab((60.0, 50.0, 40.0)) + assert (first.l_star, first.a_star, first.b_star) == ( + second.l_star, + second.a_star, + second.b_star, + ) + + def test_result_is_immutable(self) -> None: + lab = xyz_to_lab((60.0, 50.0, 40.0)) + assert isinstance(lab, Lab) + with pytest.raises(Exception): # noqa: B017 - frozen dataclass の再代入禁止 + lab.l_star = 0.0 # type: ignore[misc] + + +class TestXyzToLabErrors: + """XYZ → Lab の異常系.""" + + @pytest.mark.parametrize("xyz", [(), (1.0,), (1.0, 2.0), (1.0, 2.0, 3.0, 4.0)]) + def test_xyz_must_have_three_components(self, xyz: tuple[float, ...]) -> None: + with pytest.raises(ValueError): + xyz_to_lab(xyz) + + @pytest.mark.parametrize("xyz", [(-1.0, 50.0, 40.0), (50.0, -1.0, 40.0), (50.0, 50.0, -1e-9)]) + def test_negative_component_is_rejected(self, xyz: tuple[float, float, float]) -> None: + """負の XYZ は (1) で 0 クリップ済みのはずなので受け付けない(NaN を返さない).""" + with pytest.raises(ValueError): + xyz_to_lab(xyz) + + +class TestRgbToLab: + """SPEC_02 (1) → (2) のパイプライン.""" + + @pytest.mark.parametrize("rgb", SAMPLE_RGB) + def test_matches_spec_formula(self, rgb: tuple[float, ...]) -> None: + coefficients = as_float32_exact(full_coefficients()) + assert_lab_close(rgb_to_lab(rgb, coefficients), spec_rgb_to_lab(rgb, coefficients)) + + @pytest.mark.parametrize("rgb", SAMPLE_RGB) + def test_equals_composition_of_the_two_steps(self, rgb: tuple[float, ...]) -> None: + coefficients = as_float32_exact(full_coefficients()) + assert rgb_to_lab(rgb, coefficients) == xyz_to_lab(rgb_to_xyz(rgb, coefficients)) + + def test_constant_white_point_coefficients_give_white(self) -> None: + """定数項に白色点を置くと,どの RGB でも Lab = (100, 0, 0) になる.""" + coefficients = white_point_constant_coefficients() + lab = rgb_to_lab((170.5, 95.25, 88.0), coefficients) + assert lab.l_star == pytest.approx(100.0, abs=1e-4) + assert lab.a_star == pytest.approx(0.0, abs=1e-4) + assert lab.b_star == pytest.approx(0.0, abs=1e-4) + + def test_negative_z_is_clipped_before_lab(self) -> None: + """Z が負になる係数でも (1) の 0 クリップにより b\\* = 200*(Y/Yn)^(1/3) となる.""" + coefficients = zero_coefficients() + coefficients[16] = [102.07, 100.0, -50.0] + lab = rgb_to_lab((10.0, 20.0, 30.0), coefficients) + assert lab.b_star == pytest.approx(200.0 * 1.0, rel=LAB_REL, abs=LAB_ABS) + assert lab.l_star == pytest.approx(100.0, abs=1e-4) + + def test_all_negative_xyz_is_clipped(self) -> None: + """全成分が負になっても例外にならず Lab = (-16, 0, 0) になる.""" + coefficients = zero_coefficients() + coefficients[16] = [-1.0, -1.0, -1.0] + assert_lab_close(rgb_to_lab((10.0, 20.0, 30.0), coefficients), (-16.0, 0.0, 0.0)) + + @pytest.mark.parametrize("gray", [1.0, 32.0, 128.0, 200.0, 255.0]) + def test_neutral_rgb_gives_zero_chroma(self, gray: float) -> None: + """白色点に正規化した 1 次係数では,無彩色(R=G=B)の a\\*, b\\* が 0 になる.""" + coefficients = as_float32_exact(linear_coefficients()) + lab = rgb_to_lab((gray, gray, gray), coefficients) + assert lab.a_star == pytest.approx(0.0, abs=1e-4) + assert lab.b_star == pytest.approx(0.0, abs=1e-4) + + def test_white_rgb_maps_to_l100(self) -> None: + """RGB = (255, 255, 255) が白色点になる係数では L\\* = 100 になる.""" + coefficients = as_float32_exact(linear_coefficients()) + lab = rgb_to_lab((255.0, 255.0, 255.0), coefficients) + assert lab.l_star == pytest.approx(100.0, abs=1e-4) + + def test_reddish_rgb_has_positive_a_star(self) -> None: + """舌色の想定(赤み)で a\\* が正になる.""" + coefficients = as_float32_exact(linear_coefficients()) + lab = rgb_to_lab((170.5, 95.25, 88.0), coefficients) + assert lab.a_star > 0.0 + + def test_random_inputs_match_spec_formula(self) -> None: + rng = np.random.default_rng(4242) + base = linear_coefficients() + for _ in range(200): + coefficients = base.copy() + coefficients[3:16] = higher_order_coefficients(rng) + coefficients[16] = rng.uniform(0.0, 2.0, size=3) + coefficients = as_float32_exact(coefficients) + rgb = rng.uniform(0.0, 255.0, size=3) + assert_lab_close(rgb_to_lab(rgb, coefficients), spec_rgb_to_lab(rgb, coefficients)) + + def test_is_deterministic(self) -> None: + coefficients = as_float32_exact(full_coefficients()) + rgb = (170.5, 95.25, 88.0) + assert rgb_to_lab(rgb, coefficients) == rgb_to_lab(rgb, coefficients) + + +class TestIntegrationWithCsvIo: + """`csv_io.load_transform_matrix` が返す行列でそのまま変換できること(PLAN_02 ステップ 3).""" + + def test_matrix_loaded_from_csv_can_be_used_directly(self, transform_csv: Path) -> None: + coefficients = load_transform_matrix(transform_csv) + assert coefficients.shape == (POLYNOMIAL_TERM_COUNT, 3) + lab = rgb_to_lab((170.5, 95.25, 88.0), coefficients) + assert_lab_close(lab, spec_rgb_to_lab((170.5, 95.25, 88.0), coefficients)) + + def test_lab_from_csv_matrix_is_in_plausible_range(self, transform_csv: Path) -> None: + """実運用相当の係数で,Lab が学習データと同程度の範囲に収まる.""" + coefficients = load_transform_matrix(transform_csv) + lab = rgb_to_lab((170.5, 95.25, 88.0), coefficients) + assert 0.0 <= lab.l_star <= 100.0 + assert abs(lab.a_star) <= 128.0 + assert abs(lab.b_star) <= 128.0 + + def test_csv_row_order_maps_to_term_order(self, tmp_path: Path) -> None: + """CSV の i 行目が i 番目の項の係数として使われる(SPEC_02 データ形式).""" + for term_index in range(POLYNOMIAL_TERM_COUNT): + rows = [["0", "0", "0"] for _ in range(POLYNOMIAL_TERM_COUNT)] + rows[term_index] = ["1", "0", "0"] + path = tmp_path / f"matrix_{term_index}.csv" + path.write_text("\n".join(",".join(row) for row in rows) + "\n", encoding="utf-8") + + coefficients = load_transform_matrix(path) + red, green, blue = 170.5, 95.25, 88.0 + xyz = rgb_to_xyz((red, green, blue), coefficients) + expected = spec_polynomial_terms(red, green, blue)[term_index] + assert xyz.x == pytest.approx(expected, rel=1e-12), ( + f"CSV {term_index + 1} 行目が {TERM_NAMES[term_index]} 項に対応していない" + ) diff --git a/tests/core/test_csv_io.py b/tests/core/test_csv_io.py new file mode 100644 index 0000000..5a8ef4a --- /dev/null +++ b/tests/core/test_csv_io.py @@ -0,0 +1,431 @@ +"""`core/csv_io.py` の検証(SPEC_02「データファイル形式」・PLAN_02 ステップ 2). + +仕様上の要件は以下. + +- 変換行列 CSV: 17 行 × 3 列(ヘッダなし).各行が `a[i], b[i], c[i]`(X, Y, Z 用係数) +- `data.csv`: 1 行目ヘッダ,2 行目以降 84 サンプル. + 列 1〜3 が L\\*a\\*b\\*,列 4〜193 が医師 38 名 × 5 色の 0/1(各医師 5 列の one-hot). + 実ファイルの 194 列目以降(全 0 の未使用ブロック)は診断に使わない +- 不正形式・欠損ファイルでは `core` の専用例外を送出する(error-handling.md,PLAN_02 完了条件) +""" + +from pathlib import Path + +import numpy as np +import pytest + +from tiasjudge.core.csv_io import ( + COLOR_COUNT, + DOCTOR_COUNT, + EVALUATION_COLUMN_COUNT, + LAB_DIMENSION, + SAMPLE_COUNT, + TRAINING_COLUMN_COUNT, + TRANSFORM_COEFFICIENT_COUNT, + CsvIoError, + DataFileNotFoundError, + DataFormatError, + TrainingData, + load_training_data, + load_transform_matrix, +) + +COLOR_LABELS = ["紫", "暗赤", "紅", "淡紅", "淡白"] +TRANSFORM_COLUMN_COUNT = 3 +TOTAL_TRAINING_ROW_COUNT = SAMPLE_COUNT + 1 # ヘッダ 1 行 + 84 サンプル + + +# --- テスト用 CSV の生成ヘルパー --- + + +def write_csv(path: Path, rows: list[list[str]], encoding: str = "utf-8") -> Path: + """行のリストを CSV として書き出す.""" + text = "\n".join(",".join(row) for row in rows) + "\n" + path.write_bytes(text.encode(encoding)) + return path + + +def transform_rows( + row_count: int = TRANSFORM_COEFFICIENT_COUNT, column_count: int = TRANSFORM_COLUMN_COUNT +) -> list[list[str]]: + """変換行列 CSV の行(値は `行番号.列番号` で一意になるようにする).""" + return [ + [f"{row_index}.{column_index}" for column_index in range(column_count)] + for row_index in range(row_count) + ] + + +def training_header(doctor_count: int = DOCTOR_COUNT) -> list[str]: + return ["L", "a", "b"] + COLOR_LABELS * doctor_count + + +def training_sample_row( + sample_index: int, doctor_count: int = DOCTOR_COUNT, evaluation_columns: int | None = None +) -> list[str]: + """1 サンプル分の行を作る.医師 d の評価色は `(sample_index + d) % 5` とする.""" + lab = [f"{50 + sample_index}", f"{sample_index}.5", f"{-sample_index}.25"] + evaluations: list[str] = [] + for doctor_index in range(doctor_count): + color_index = (sample_index + doctor_index) % COLOR_COUNT + evaluations += ["1" if index == color_index else "0" for index in range(COLOR_COUNT)] + if evaluation_columns is not None: + evaluations = (evaluations + ["0"] * evaluation_columns)[:evaluation_columns] + return lab + evaluations + + +def training_rows( + sample_count: int = SAMPLE_COUNT, + doctor_count: int = DOCTOR_COUNT, + evaluation_columns: int | None = None, +) -> list[list[str]]: + rows = [training_header(doctor_count)] + rows += [ + training_sample_row(index, doctor_count, evaluation_columns) + for index in range(sample_count) + ] + return rows + + +@pytest.fixture +def valid_training_csv(tmp_path: Path) -> Path: + return write_csv(tmp_path / "data.csv", training_rows()) + + +@pytest.fixture(scope="module") +def real_data(data_csv_path: Path) -> TrainingData: + """同梱 `data/data.csv` を読み込んだ結果.""" + return load_training_data(data_csv_path) + + +class TestLoadTransformMatrixNormal: + """変換行列 CSV の正常系(SPEC_02「変換行列 CSV」).""" + + def test_returns_17_by_3_matrix(self, tmp_path: Path) -> None: + matrix = load_transform_matrix(write_csv(tmp_path / "matrix.csv", transform_rows())) + assert matrix.shape == (TRANSFORM_COEFFICIENT_COUNT, TRANSFORM_COLUMN_COUNT) + + def test_values_are_read_in_row_major_order(self, tmp_path: Path) -> None: + """各行が `a[i], b[i], c[i]`.列 0=a(X 用),列 1=b(Y 用),列 2=c(Z 用).""" + matrix = load_transform_matrix(write_csv(tmp_path / "matrix.csv", transform_rows())) + for row_index in range(TRANSFORM_COEFFICIENT_COUNT): + for column_index in range(TRANSFORM_COLUMN_COUNT): + expected = float(f"{row_index}.{column_index}") + assert matrix[row_index, column_index] == pytest.approx(expected) + + def test_is_floating_point(self, tmp_path: Path) -> None: + matrix = load_transform_matrix(write_csv(tmp_path / "matrix.csv", transform_rows())) + assert np.issubdtype(matrix.dtype, np.floating) + + def test_accepts_negative_and_scientific_notation(self, tmp_path: Path) -> None: + """キャリブレーション結果は負値・指数表記を含みうる.""" + rows = [["-1.5", "2.5e-3", "+0.25"] for _ in range(TRANSFORM_COEFFICIENT_COUNT)] + matrix = load_transform_matrix(write_csv(tmp_path / "matrix.csv", rows)) + assert matrix[0].tolist() == pytest.approx([-1.5, 0.0025, 0.25]) + + def test_accepts_string_path(self, tmp_path: Path) -> None: + """`core` はパスを引数で受け取る(str でも Path でも良い).""" + path = write_csv(tmp_path / "matrix.csv", transform_rows()) + assert load_transform_matrix(str(path)).shape == (TRANSFORM_COEFFICIENT_COUNT, 3) + + def test_precision_is_not_truncated(self, tmp_path: Path) -> None: + """係数は多項式変換に直接使うため丸めずに保持する.""" + rows = [["0.123456789012345", "1", "2"] for _ in range(TRANSFORM_COEFFICIENT_COUNT)] + matrix = load_transform_matrix(write_csv(tmp_path / "matrix.csv", rows)) + assert matrix[0, 0] == pytest.approx(0.123456789012345, abs=1e-15) + + +class TestLoadTransformMatrixErrors: + """変換行列 CSV の異常系(不正形式で専用例外).""" + + @pytest.mark.parametrize("row_count", [0, 1, 16, 18, 34]) + def test_row_count_other_than_17_is_rejected(self, tmp_path: Path, row_count: int) -> None: + path = write_csv(tmp_path / "matrix.csv", transform_rows(row_count=row_count) or [[""]]) + with pytest.raises(DataFormatError): + load_transform_matrix(path) + + @pytest.mark.parametrize("column_count", [1, 2, 4]) + def test_column_count_other_than_3_is_rejected(self, tmp_path: Path, column_count: int) -> None: + path = write_csv(tmp_path / "matrix.csv", transform_rows(column_count=column_count)) + with pytest.raises(DataFormatError): + load_transform_matrix(path) + + def test_ragged_row_is_rejected(self, tmp_path: Path) -> None: + rows = transform_rows() + rows[5] = ["1", "2"] + with pytest.raises(DataFormatError): + load_transform_matrix(write_csv(tmp_path / "matrix.csv", rows)) + + def test_non_numeric_cell_is_rejected(self, tmp_path: Path) -> None: + rows = transform_rows() + rows[3][1] = "abc" + with pytest.raises(DataFormatError): + load_transform_matrix(write_csv(tmp_path / "matrix.csv", rows)) + + def test_empty_cell_is_rejected(self, tmp_path: Path) -> None: + rows = transform_rows() + rows[0][2] = "" + with pytest.raises(DataFormatError): + load_transform_matrix(write_csv(tmp_path / "matrix.csv", rows)) + + def test_header_row_is_rejected(self, tmp_path: Path) -> None: + """変換行列 CSV はヘッダなしのため,ヘッダ付きは形式エラー.""" + rows = [["a", "b", "c"], *transform_rows()] + with pytest.raises(DataFormatError): + load_transform_matrix(write_csv(tmp_path / "matrix.csv", rows)) + + def test_missing_file_raises_not_found(self, tmp_path: Path) -> None: + with pytest.raises(DataFileNotFoundError): + load_transform_matrix(tmp_path / "missing.csv") + + def test_directory_raises_not_found(self, tmp_path: Path) -> None: + directory = tmp_path / "dir.csv" + directory.mkdir() + with pytest.raises(DataFileNotFoundError): + load_transform_matrix(directory) + + def test_undecodable_bytes_raise_format_error(self, tmp_path: Path) -> None: + path = tmp_path / "matrix.csv" + path.write_bytes(b"\x80\x81\n" * TRANSFORM_COEFFICIENT_COUNT) + with pytest.raises(DataFormatError): + load_transform_matrix(path) + + def test_errors_share_a_common_base(self, tmp_path: Path) -> None: + """GUI 側が 1 つの基底例外で捕捉できる(error-handling.md).""" + assert issubclass(DataFileNotFoundError, CsvIoError) + assert issubclass(DataFormatError, CsvIoError) + with pytest.raises(CsvIoError): + load_transform_matrix(tmp_path / "missing.csv") + + +class TestLoadTrainingDataNormal: + """`data.csv` の正常系(生成したデータ).""" + + def test_shapes_follow_spec(self, valid_training_csv: Path) -> None: + data = load_training_data(valid_training_csv) + assert data.lab.shape == (SAMPLE_COUNT, LAB_DIMENSION) + assert data.evaluations.shape == (SAMPLE_COUNT, EVALUATION_COLUMN_COUNT) + + def test_counts_follow_spec(self, valid_training_csv: Path) -> None: + data = load_training_data(valid_training_csv) + assert data.sample_count == SAMPLE_COUNT + assert data.output_dimension == EVALUATION_COLUMN_COUNT == 190 + assert data.doctor_count == DOCTOR_COUNT == 38 + + def test_lab_values_come_from_first_three_columns(self, valid_training_csv: Path) -> None: + data = load_training_data(valid_training_csv) + for index in range(SAMPLE_COUNT): + expected = [float(cell) for cell in training_sample_row(index)[:LAB_DIMENSION]] + assert data.lab[index].tolist() == pytest.approx(expected) + + def test_evaluation_columns_come_from_columns_4_to_193(self, valid_training_csv: Path) -> None: + data = load_training_data(valid_training_csv) + for index in range(SAMPLE_COUNT): + expected = [int(cell) for cell in training_sample_row(index)[LAB_DIMENSION:]] + assert data.evaluations[index].tolist() == expected + + def test_evaluation_layout_is_doctor_major(self, valid_training_csv: Path) -> None: + """列 `doctor_index * 5 + color_index` が医師ごとの one-hot に対応する.""" + data = load_training_data(valid_training_csv) + for sample_index in range(SAMPLE_COUNT): + for doctor_index in range(DOCTOR_COUNT): + block = data.evaluations[ + sample_index, + doctor_index * COLOR_COUNT : (doctor_index + 1) * COLOR_COUNT, + ] + assert block.sum() == 1 + expected_color = (sample_index + doctor_index) % COLOR_COUNT + assert int(block.argmax()) == expected_color + + def test_evaluations_are_binary(self, valid_training_csv: Path) -> None: + data = load_training_data(valid_training_csv) + assert set(np.unique(data.evaluations).tolist()) <= {0, 1} + + def test_color_columns_returns_all_doctors_for_one_color( + self, valid_training_csv: Path + ) -> None: + data = load_training_data(valid_training_csv) + for color_index in range(COLOR_COUNT): + columns = data.color_columns(color_index) + assert columns.shape == (SAMPLE_COUNT, DOCTOR_COUNT) + expected = data.evaluations[:, color_index::COLOR_COUNT] + assert np.array_equal(columns, expected) + + def test_color_columns_matches_generated_one_hot(self, valid_training_csv: Path) -> None: + data = load_training_data(valid_training_csv) + for color_index in range(COLOR_COUNT): + columns = data.color_columns(color_index) + for sample_index in range(SAMPLE_COUNT): + for doctor_index in range(DOCTOR_COUNT): + expected = int((sample_index + doctor_index) % COLOR_COUNT == color_index) + assert columns[sample_index, doctor_index] == expected + + @pytest.mark.parametrize("color_index", [-1, COLOR_COUNT, 100]) + def test_color_columns_rejects_out_of_range( + self, valid_training_csv: Path, color_index: int + ) -> None: + data = load_training_data(valid_training_csv) + with pytest.raises(ValueError): + data.color_columns(color_index) + + def test_extra_trailing_columns_are_ignored(self, tmp_path: Path) -> None: + """実ファイルの 194 列目以降(未使用ブロック)は読み飛ばす(SPEC_02 注記).""" + rows = training_rows(doctor_count=39) + path = write_csv(tmp_path / "data.csv", rows) + data = load_training_data(path) + assert data.evaluations.shape == (SAMPLE_COUNT, EVALUATION_COLUMN_COUNT) + expected = [int(cell) for cell in training_sample_row(0, doctor_count=39)[LAB_DIMENSION:]] + assert data.evaluations[0].tolist() == expected[:EVALUATION_COLUMN_COUNT] + + def test_accepts_cp932_encoded_file(self, tmp_path: Path) -> None: + """旧実装由来のファイル(Shift-JIS ヘッダ)も読める.""" + path = write_csv(tmp_path / "data_sjis.csv", training_rows(), encoding="cp932") + data = load_training_data(path) + assert data.lab.shape == (SAMPLE_COUNT, LAB_DIMENSION) + + def test_result_is_immutable_container(self, valid_training_csv: Path) -> None: + data = load_training_data(valid_training_csv) + assert isinstance(data, TrainingData) + with pytest.raises(Exception): # noqa: B017 - frozen dataclass の再代入禁止 + data.lab = np.zeros((SAMPLE_COUNT, LAB_DIMENSION), dtype=np.float32) # type: ignore[misc] + + +class TestLoadTrainingDataErrors: + """`data.csv` の異常系.""" + + @pytest.mark.parametrize("sample_count", [0, 1, 83, 85]) + def test_sample_count_other_than_84_is_rejected( + self, tmp_path: Path, sample_count: int + ) -> None: + path = write_csv(tmp_path / "data.csv", training_rows(sample_count=sample_count)) + with pytest.raises(DataFormatError): + load_training_data(path) + + def test_missing_header_is_rejected(self, tmp_path: Path) -> None: + """ヘッダ 1 行 + 84 サンプルの計 85 行を要求する.""" + rows = training_rows()[1:] + with pytest.raises(DataFormatError): + load_training_data(write_csv(tmp_path / "data.csv", rows)) + + @pytest.mark.parametrize("doctor_count", [0, 1, 37]) + def test_insufficient_evaluation_columns_are_rejected( + self, tmp_path: Path, doctor_count: int + ) -> None: + path = write_csv(tmp_path / "data.csv", training_rows(doctor_count=doctor_count)) + with pytest.raises(DataFormatError): + load_training_data(path) + + def test_column_short_by_one_is_rejected(self, tmp_path: Path) -> None: + rows = training_rows(evaluation_columns=EVALUATION_COLUMN_COUNT - 1) + with pytest.raises(DataFormatError): + load_training_data(write_csv(tmp_path / "data.csv", rows)) + + def test_single_ragged_row_is_rejected(self, tmp_path: Path) -> None: + rows = training_rows() + rows[42] = rows[42][: TRAINING_COLUMN_COUNT - 1] + with pytest.raises(DataFormatError): + load_training_data(write_csv(tmp_path / "data.csv", rows)) + + def test_non_numeric_lab_cell_is_rejected(self, tmp_path: Path) -> None: + rows = training_rows() + rows[10][1] = "N/A" + with pytest.raises(DataFormatError): + load_training_data(write_csv(tmp_path / "data.csv", rows)) + + def test_empty_lab_cell_is_rejected(self, tmp_path: Path) -> None: + rows = training_rows() + rows[7][0] = "" + with pytest.raises(DataFormatError): + load_training_data(write_csv(tmp_path / "data.csv", rows)) + + @pytest.mark.parametrize("value", ["2", "-1", "0.5", "x"]) + def test_evaluation_cell_other_than_0_or_1_is_rejected( + self, tmp_path: Path, value: str + ) -> None: + rows = training_rows() + rows[20][LAB_DIMENSION + 3] = value + with pytest.raises(DataFormatError): + load_training_data(write_csv(tmp_path / "data.csv", rows)) + + def test_evaluation_error_in_last_valid_column_is_detected(self, tmp_path: Path) -> None: + """193 列目(最後の有効列)も検証対象である.""" + rows = training_rows() + rows[1][TRAINING_COLUMN_COUNT - 1] = "9" + with pytest.raises(DataFormatError): + load_training_data(write_csv(tmp_path / "data.csv", rows)) + + def test_missing_file_raises_not_found(self, tmp_path: Path) -> None: + with pytest.raises(DataFileNotFoundError): + load_training_data(tmp_path / "missing.csv") + + def test_directory_raises_not_found(self, tmp_path: Path) -> None: + directory = tmp_path / "data_dir" + directory.mkdir() + with pytest.raises(DataFileNotFoundError): + load_training_data(directory) + + def test_undecodable_bytes_raise_format_error(self, tmp_path: Path) -> None: + path = tmp_path / "data.csv" + path.write_bytes(b"\x80\x81,\x80\x81\n" * TOTAL_TRAINING_ROW_COUNT) + with pytest.raises(DataFormatError): + load_training_data(path) + + def test_error_message_points_to_the_file(self, tmp_path: Path) -> None: + """GUI がそのまま提示できるよう,メッセージにファイル名を含める.""" + rows = training_rows() + rows[3][0] = "abc" + path = write_csv(tmp_path / "broken.csv", rows) + with pytest.raises(DataFormatError) as error: + load_training_data(path) + assert "broken.csv" in str(error.value) + + +class TestLoadRealDataCsv: + """同梱 `data/data.csv` の読込(PLAN_02 ステップ 2 完了条件「実データを読める」).""" + + def test_shapes(self, real_data: TrainingData) -> None: + assert real_data.lab.shape == (SAMPLE_COUNT, LAB_DIMENSION) + assert real_data.evaluations.shape == (SAMPLE_COUNT, EVALUATION_COLUMN_COUNT) + assert real_data.doctor_count == DOCTOR_COUNT + + def test_lab_matches_file_content( + self, real_data: TrainingData, data_csv_rows: list[list[str]] + ) -> None: + for index, row in enumerate(data_csv_rows[1:]): + expected = [float(cell) for cell in row[:LAB_DIMENSION]] + assert real_data.lab[index].tolist() == pytest.approx(expected, rel=1e-6) + + def test_evaluations_match_first_190_evaluation_columns( + self, real_data: TrainingData, data_csv_rows: list[list[str]] + ) -> None: + for index, row in enumerate(data_csv_rows[1:]): + expected = [int(cell) for cell in row[LAB_DIMENSION:TRAINING_COLUMN_COUNT]] + assert real_data.evaluations[index].tolist() == expected + + def test_evaluations_are_binary(self, real_data: TrainingData) -> None: + assert set(np.unique(real_data.evaluations).tolist()) <= {0, 1} + + def test_each_doctor_block_is_one_hot_or_all_zero(self, real_data: TrainingData) -> None: + blocks = real_data.evaluations.reshape(SAMPLE_COUNT, DOCTOR_COUNT, COLOR_COUNT) + assert set(blocks.sum(axis=2).ravel().tolist()) <= {0, 1} + + def test_every_color_has_positive_examples(self, real_data: TrainingData) -> None: + """5 色すべてに正例がある(SPEC_02 (3) の学習成立の前提).""" + for color_index in range(COLOR_COUNT): + assert real_data.color_columns(color_index).sum() > 0 + + def test_lab_is_within_valid_range(self, real_data: TrainingData) -> None: + assert real_data.lab[:, 0].min() >= 0.0 + assert real_data.lab[:, 0].max() <= 100.0 + assert np.abs(real_data.lab[:, 1:]).max() <= 128.0 + + def test_unused_trailing_block_is_excluded(self, data_csv_rows: list[list[str]]) -> None: + """194 列目以降(全 0 の未使用ブロック)が読み込み対象外であること.""" + assert len(data_csv_rows[0]) > TRAINING_COLUMN_COUNT, "実ファイルは 193 列より多い前提" + trailing = [int(cell) for row in data_csv_rows[1:] for cell in row[TRAINING_COLUMN_COUNT:]] + assert set(trailing) == {0}, "無視する末尾列に非 0 の評価が含まれる" + + def test_repeated_load_is_deterministic(self, data_csv_path: Path) -> None: + first = load_training_data(data_csv_path) + second = load_training_data(data_csv_path) + assert np.array_equal(first.lab, second.lab) + assert np.array_equal(first.evaluations, second.evaluations) diff --git a/tests/core/test_roi.py b/tests/core/test_roi.py new file mode 100644 index 0000000..b1ca99e --- /dev/null +++ b/tests/core/test_roi.py @@ -0,0 +1,293 @@ +"""`core/roi.py` の検証(SPEC_02 (0)「ROI 平均色」・PLAN_02 ステップ 4). + +仕様上の要件は以下. + +- クリック点ごとに 51×51 px の矩形 ROI の平均 BGR を算出する +- 複数点クリック時は各 ROI 平均の平均(全クリックの合算 ÷ クリック数)を診断に使用する +- クリック可能な最大点数は 4 点(SPEC_01「ROI 指定」) +- ROI が画像外にはみ出す等の不正入力は `core` が例外で通知する(error-handling.md) + +テストは実装ではなく仕様を基準とするため,期待値は解析的に手計算できる画像 +(単色・座標に比例するグラデーション・単一点のみ明るい画像)で与える. +""" + +import numpy as np +import pytest + +from tiasjudge.core.roi import ( + MAX_ROI_POINTS, + ROI_OFFSET, + ROI_SIZE, + mean_bgr, + mean_rgb, + roi_mean_bgr, +) + +ROI_PIXEL_COUNT = ROI_SIZE * ROI_SIZE + + +# --- テスト用画像の生成ヘルパー --- + + +def uniform_image(bgr: tuple[int, int, int], width: int = 200, height: int = 200) -> np.ndarray: + """全画素が同じ BGR の画像を作る.""" + image = np.empty((height, width, 3), dtype=np.uint8) + image[:, :] = bgr + return image + + +def ramp_image(width: int = 200, height: int = 200, red: int = 7) -> np.ndarray: + """B = x座標,G = y座標,R = 定数のグラデーション画像を作る. + + ROI(一辺 51 px の奇数サイズ)の平均は中心座標に一致するため, + 「クリック点を中心とする 51×51」であることを解析的に検証できる. + """ + image = np.zeros((height, width, 3), dtype=np.uint8) + image[:, :, 0] = np.arange(width, dtype=np.uint8)[np.newaxis, :] + image[:, :, 1] = np.arange(height, dtype=np.uint8)[:, np.newaxis] + image[:, :, 2] = red + return image + + +# --- 定数(SPEC_02 (0)・SPEC_01) --- + + +def test_roi_size_is_51_px() -> None: + """ROI の一辺は 51 px(SPEC_02 (0)).""" + assert ROI_SIZE == 51 + + +def test_roi_offset_centers_the_roi_on_the_click_point() -> None: + """オフセットは ROI をクリック点中心に置く値((51 - 1) / 2 = 25)である.""" + assert ROI_OFFSET == (ROI_SIZE - 1) // 2 == 25 + + +def test_max_roi_points_is_four() -> None: + """クリック可能な最大点数は 4 点(SPEC_01「ROI 指定」).""" + assert MAX_ROI_POINTS == 4 + + +# --- 単一 ROI の平均(SPEC_02 (0)) --- + + +def test_uniform_image_returns_the_same_bgr() -> None: + """単色画像の ROI 平均は,その色そのものになる.""" + image = uniform_image((10, 20, 30)) + + assert roi_mean_bgr(image, (100, 100)) == (10.0, 20.0, 30.0) + + +@pytest.mark.parametrize("point", [(25, 25), (100, 60), (174, 174)]) +def test_ramp_image_mean_equals_the_click_point(point: tuple[int, int]) -> None: + """B = x・G = y のグラデーションでは,51×51 の平均が中心(クリック点)と一致する. + + ROI が「クリック点中心・一辺 51 px」でなければ平均は中心座標からずれる. + """ + image = ramp_image() + + blue, green, red = roi_mean_bgr(image, point) + + assert blue == pytest.approx(float(point[0])) + assert green == pytest.approx(float(point[1])) + assert red == pytest.approx(7.0) + + +def test_mean_is_not_rounded_to_integer() -> None: + """平均は整数に丸めず実数で返す(後段の色変換の精度を落とさない).""" + image = np.zeros((200, 200, 3), dtype=np.uint8) + # ROI 内の 1 画素だけ 1 にすると,平均は 1/2601 の非整数になる + image[100, 100, 0] = 1 + + blue, _, _ = roi_mean_bgr(image, (100, 100)) + + assert blue == pytest.approx(1.0 / ROI_PIXEL_COUNT) + + +@pytest.mark.parametrize( + "offset", + [(-ROI_OFFSET, -ROI_OFFSET), (ROI_OFFSET, ROI_OFFSET), (-ROI_OFFSET, ROI_OFFSET), (0, 0)], +) +def test_pixels_inside_the_51x51_window_are_counted(offset: tuple[int, int]) -> None: + """中心から ±25 px(= 51×51 の四隅・中央)の画素は ROI に含まれる.""" + center = (100, 100) + image = np.zeros((200, 200, 3), dtype=np.uint8) + image[center[1] + offset[1], center[0] + offset[0], 0] = 255 + + blue, _, _ = roi_mean_bgr(image, center) + + assert blue == pytest.approx(255.0 / ROI_PIXEL_COUNT) + + +@pytest.mark.parametrize( + "offset", + [(-ROI_OFFSET - 1, 0), (ROI_OFFSET + 1, 0), (0, -ROI_OFFSET - 1), (0, ROI_OFFSET + 1)], +) +def test_pixels_outside_the_51x51_window_are_ignored(offset: tuple[int, int]) -> None: + """中心から 26 px 離れた画素は ROI に含まれない(ROI は 51×51 に収まる).""" + center = (100, 100) + image = np.zeros((200, 200, 3), dtype=np.uint8) + image[center[1] + offset[1], center[0] + offset[0], 0] = 255 + + assert roi_mean_bgr(image, center) == (0.0, 0.0, 0.0) + + +def test_mean_matches_an_independent_computation() -> None: + """任意の画像でも ROI 内 2601 画素の単純平均になる(`cv::mean` 相当). + + 期待値は実装とは別経路(float64 での全画素平均)で計算する. + """ + rng = np.random.default_rng(seed=20240401) + image = rng.integers(0, 256, size=(200, 200, 3), dtype=np.uint8) + center = (100, 80) + window = image[ + center[1] - ROI_OFFSET : center[1] + ROI_OFFSET + 1, + center[0] - ROI_OFFSET : center[0] + ROI_OFFSET + 1, + ].astype(np.float64) + expected = tuple(window.reshape(-1, 3).mean(axis=0)) + + assert roi_mean_bgr(image, center) == pytest.approx(expected) + + +def test_accepts_a_cropped_view_of_an_image() -> None: + """クロップ後の部分ビュー(非連続配列)でも同じ結果になる. + + SPEC_01 の操作フローではクロップ後の画像上でクリックするため, + ビューを渡しても座標どおりに ROI を切り出せる必要がある. + """ + full = ramp_image(width=400, height=400) + cropped = full[100:300, 100:300] # 元画像の (100, 100) が原点になる + + blue, green, red = roi_mean_bgr(cropped, (60, 40)) + + assert blue == pytest.approx(160.0) + assert green == pytest.approx(140.0) + assert red == pytest.approx(7.0) + + +def test_high_bit_depth_image_is_not_truncated() -> None: + """8 bit を超える値の画像でも平均が桁あふれ・切り捨てされない.""" + image = np.full((200, 200, 3), 1000, dtype=np.uint16) + + assert roi_mean_bgr(image, (100, 100)) == (1000.0, 1000.0, 1000.0) + + +# --- 複数点の平均(SPEC_02 (0)「合算 ÷ クリック数」) --- + + +def test_single_point_mean_matches_roi_mean() -> None: + """1 点だけのときは,その ROI 平均がそのまま診断に使われる.""" + image = ramp_image() + + assert mean_bgr(image, [(60, 80)]) == roi_mean_bgr(image, (60, 80)) + + +def test_two_points_are_averaged() -> None: + """2 点クリック時は 2 つの ROI 平均の平均になる(端数も保持する).""" + image = ramp_image() + + blue, green, red = mean_bgr(image, [(40, 30), (101, 90)]) + + assert blue == pytest.approx((40.0 + 101.0) / 2.0) + assert green == pytest.approx((30.0 + 90.0) / 2.0) + assert red == pytest.approx(7.0) + + +def test_four_points_are_averaged() -> None: + """最大クリック数(4 点)でも合算 ÷ クリック数になる.""" + image = ramp_image() + points = [(30, 30), (60, 40), (90, 50), (120, 61)] + + blue, green, _ = mean_bgr(image, points) + + assert blue == pytest.approx(sum(x for x, _ in points) / len(points)) + assert green == pytest.approx(sum(y for _, y in points) / len(points)) + + +def test_mean_of_max_points_equals_the_average_of_each_roi_mean() -> None: + """最大クリック数(4 点)の平均は,各 ROI 平均の合算 ÷ クリック数と一致する.""" + image = ramp_image() + points = [(30, 31), (60, 42), (91, 50), (120, 64)][:MAX_ROI_POINTS] + per_point = [roi_mean_bgr(image, point) for point in points] + expected = tuple(sum(values) / MAX_ROI_POINTS for values in zip(*per_point, strict=True)) + + assert len(points) == MAX_ROI_POINTS + assert mean_bgr(image, points) == pytest.approx(expected) + + +def test_repeated_point_is_counted_each_time() -> None: + """同じ点を複数回クリックした場合もクリック数で割る(重複を除去しない).""" + image = ramp_image() + + assert mean_bgr(image, [(60, 80), (60, 80)]) == pytest.approx(roi_mean_bgr(image, (60, 80))) + + +def test_average_is_over_roi_means_not_over_all_pixels() -> None: + """複数点の平均は「各 ROI 平均の平均」である(重なりも含めた画素平均ではない).""" + image = uniform_image((0, 0, 0)) + # 左側の ROI だけ 100,右側は 0 のままにする + image[75:126, 75:126] = (100, 100, 100) + + assert mean_bgr(image, [(100, 100), (160, 100)]) == pytest.approx((50.0, 50.0, 50.0)) + + +def test_mean_rgb_reorders_channels() -> None: + """`mean_rgb` は (R, G, B) の順で返す(`color.rgb_to_lab` の入力並び).""" + image = uniform_image((10, 20, 30)) + points = [(100, 100)] + + assert mean_rgb(image, points) == (30.0, 20.0, 10.0) + assert mean_rgb(image, points) == tuple(reversed(mean_bgr(image, points))) + + +# --- 異常系(error-handling.md) --- + + +@pytest.mark.parametrize( + "point", + [ + (ROI_OFFSET - 1, 100), # 左にはみ出す + (100, ROI_OFFSET - 1), # 上にはみ出す + (200 - ROI_OFFSET, 100), # 右にはみ出す + (100, 200 - ROI_OFFSET), # 下にはみ出す + (-5, -5), + ], +) +def test_out_of_range_point_raises(point: tuple[int, int]) -> None: + """ROI が画像外にはみ出す場合はエラーにする(旧実装のクラッシュを防ぐ).""" + image = ramp_image(width=200, height=200) + + with pytest.raises(ValueError): + roi_mean_bgr(image, point) + + +def test_roi_touching_the_image_edge_is_valid() -> None: + """ROI が画像端にちょうど収まる位置は有効である(境界値).""" + image = uniform_image((1, 2, 3), width=51, height=51) + + assert roi_mean_bgr(image, (ROI_OFFSET, ROI_OFFSET)) == (1.0, 2.0, 3.0) + + +def test_grayscale_image_raises() -> None: + """BGR 3 チャンネル以外の画像は受け付けない.""" + with pytest.raises(ValueError): + roi_mean_bgr(np.zeros((200, 200), dtype=np.uint8), (100, 100)) + + +def test_empty_image_raises() -> None: + """空の画像は受け付けない.""" + with pytest.raises(ValueError): + roi_mean_bgr(np.zeros((0, 0, 3), dtype=np.uint8), (100, 100)) + + +def test_no_points_raises() -> None: + """クリック点が 0 個のときは診断に使う平均色を作れない.""" + with pytest.raises(ValueError): + mean_bgr(ramp_image(), []) + + +def test_out_of_range_point_in_multi_point_raises() -> None: + """複数点のうち 1 点でも範囲外ならエラーにする.""" + image = ramp_image() + + with pytest.raises(ValueError): + mean_bgr(image, [(100, 100), (5, 5)]) diff --git a/tests/core/test_svm.py b/tests/core/test_svm.py new file mode 100644 index 0000000..7eee9b1 --- /dev/null +++ b/tests/core/test_svm.py @@ -0,0 +1,549 @@ +"""`core/svm.py` の検証(SPEC_02 (3)「SVM 分類」・「診断失敗の判定」・PLAN_02 ステップ 4). + +仕様上の要件は以下. + +- 出力 190 列(医師 38 名 × 5 色)それぞれについて 2 クラス SVM を診断のたびに学習し, + 入力 L\\*a\\*b\\* を予測する(正例が 2 未満の列は学習せずスコア 0) +- SVM パラメータは C_SVC / RBF・γ=0.011・C=12.0・終了条件 EPS 1000 回・FLT_EPSILON +- 色ごとに 38 医師分の予測値を平均 → 5 色の一致率(0〜1)を百分率で表示する +- スコア降順で上位 2 色を主要ラベルとする +- 学習不能・予測失敗は直接検出して「診断失敗」として返す(旧実装のマジックナンバー + 比較 42/0/5/11/3 は移植しない.ただし表示仕様=失敗判定の結果は同じであること) + +旧実装との結果同一性が成功基準(PLAN_01)のため,実 `data/data.csv` を用いた +入出力ペアを回帰テストとして固定する(testing.md「回帰テストを最重視する」). +""" + +from __future__ import annotations + +from pathlib import Path + +import cv2 +import numpy as np +import pytest + +from tiasjudge.core.color import Lab, rgb_to_lab +from tiasjudge.core.csv_io import ( + COLOR_COUNT, + DOCTOR_COUNT, + EVALUATION_COLUMN_COUNT, + SAMPLE_COUNT, + TrainingData, + load_training_data, +) +from tiasjudge.core.roi import mean_rgb +from tiasjudge.core.svm import ( + COLOR_KEYS, + MIN_POSITIVE_SAMPLES, + SVM_C, + SVM_GAMMA, + DiagnosisResult, + FailureReason, + create_model, + diagnose, +) + +#: 旧実装が「Diagnosis failed」と表示するスコア組(SPEC_02「診断失敗の判定」). +#: 実測でこれは Lab=(-16, 0, 0)(XYZ が全て 0)の出力であることを確認している +FAILED_PERCENTAGES = {"purple": 42, "dark_red": 0, "crimson": 5, "pink": 11, "pale": 3} +DEGENERATE_LAB = Lab(l_star=-16.0, a_star=0.0, b_star=0.0) + +#: 実 data.csv での回帰値(入力 Lab -> (5 色の %, 上位 2 色)) +REGRESSION_CASES: dict[str, tuple[tuple[float, float, float], dict[str, int], tuple[str, str]]] = { + "purple_dark_red": ( + (60.0, 15.0, 10.0), + {"purple": 58, "dark_red": 37, "crimson": 0, "pink": 0, "pale": 0}, + ("purple", "dark_red"), + ), + "dark_red_crimson": ( + (50.0, 20.0, 5.0), + {"purple": 5, "dark_red": 55, "crimson": 21, "pink": 8, "pale": 3}, + ("dark_red", "crimson"), + ), + "crimson_pink": ( + (45.0, 25.0, -5.0), + {"purple": 21, "dark_red": 8, "crimson": 29, "pink": 24, "pale": 5}, + ("crimson", "pink"), + ), + "high_lightness": ( + (65.0, 20.0, 8.0), + {"purple": 53, "dark_red": 45, "crimson": 5, "pink": 0, "pale": 3}, + ("purple", "dark_red"), + ), +} + + +# --- フィクスチャ(SVM 学習は 1 診断あたり 190 列なので結果を使い回す) --- + + +@pytest.fixture(scope="module") +def training(data_csv_path: Path) -> TrainingData: + """同梱の実学習データ(84 サンプル × 190 列).""" + return load_training_data(data_csv_path) + + +@pytest.fixture(scope="module") +def results(training: TrainingData) -> dict[str, DiagnosisResult]: + """回帰ケースの診断結果(モジュール内で共有する).""" + return {name: diagnose(lab, training) for name, (lab, _, _) in REGRESSION_CASES.items()} + + +@pytest.fixture(scope="module") +def degenerate_result(training: TrainingData) -> DiagnosisResult: + """旧実装が「Diagnosis failed」を表示する入力(Lab=(-16, 0, 0))の診断結果.""" + return diagnose(DEGENERATE_LAB, training) + + +# --- 合成学習データのヘルパー(正例数の条件を検証するために使う) --- + + +def synthetic_training(positive_columns: dict[int, int] | None = None) -> TrainingData: + """仕様どおりの形(84 サンプル × 190 列)の合成学習データを作る. + + 既定では全列の正例が 1 個(= `MIN_POSITIVE_SAMPLES` 未満)になる. + `positive_columns` に {列: 正例数} を渡すと,その列だけ先頭サンプル群を正例にする + (先頭 42 サンプルは Lab クラスタ A,後半 42 サンプルはクラスタ B). + """ + half = SAMPLE_COUNT // 2 + lab = np.empty((SAMPLE_COUNT, 3), dtype=np.float32) + lab[:half] = np.array([60.0, 20.0, 10.0], dtype=np.float32) + lab[half:] = np.array([45.0, 5.0, 0.0], dtype=np.float32) + # サンプルが完全重複しないように微小なばらつきを与える + lab += (np.arange(SAMPLE_COUNT, dtype=np.float32) * 0.01)[:, np.newaxis] + + evaluations = np.zeros((SAMPLE_COUNT, EVALUATION_COLUMN_COUNT), dtype=np.int32) + evaluations[0, :] = 1 # 全列に正例 1 個(学習対象外の状態) + for column, count in (positive_columns or {}).items(): + evaluations[:count, column] = 1 + return TrainingData(lab=lab, evaluations=evaluations) + + +CLUSTER_A_LAB = (60.0, 20.0, 10.0) + + +# --- SVM パラメータ(SPEC_02 (3)) --- + + +def test_svm_parameters_match_the_spec() -> None: + """γ=0.011・C=12.0 は仕様どおりの値である.""" + assert SVM_GAMMA == 0.011 + assert SVM_C == 12.0 + + +def test_model_uses_c_svc_with_rbf_kernel() -> None: + """生成されるモデルは C_SVC・RBF カーネルで,γ・C が設定されている.""" + model = create_model() + + assert model.getType() == cv2.ml.SVM_C_SVC + assert model.getKernelType() == cv2.ml.SVM_RBF + assert model.getGamma() == pytest.approx(0.011) + assert model.getC() == pytest.approx(12.0) + + +def test_termination_criteria_uses_eps() -> None: + """終了条件は EPS(FLT_EPSILON)・反復 1000 である(SPEC_02 (3)).""" + criteria_type, max_count, epsilon = create_model().getTermCriteria() + + assert criteria_type & cv2.TERM_CRITERIA_EPS + assert max_count == 1000 + assert epsilon == pytest.approx(float(np.finfo(np.float32).eps)) + + +def test_minimum_positive_samples_is_two() -> None: + """学習に必要な正例数は 2(これ未満は学習せずスコア 0).""" + assert MIN_POSITIVE_SAMPLES == 2 + + +def test_color_keys_follow_the_csv_column_order() -> None: + """5 色のキーは data.csv の列順(紫・暗赤・紅・淡紅・淡白)に対応する.""" + assert COLOR_KEYS == ("purple", "dark_red", "crimson", "pink", "pale") + assert len(COLOR_KEYS) == COLOR_COUNT + + +# --- 実データでの回帰(PLAN_01 成功基準) --- + + +@pytest.mark.parametrize("name", list(REGRESSION_CASES)) +def test_percentages_match_the_reference_values( + name: str, results: dict[str, DiagnosisResult] +) -> None: + """既知入力に対する 5 色の百分率が固定値と一致する(旧実装との同一性の回帰).""" + _, expected, _ = REGRESSION_CASES[name] + + assert results[name].percentages == expected + + +@pytest.mark.parametrize("name", list(REGRESSION_CASES)) +def test_top_labels_match_the_reference_values( + name: str, results: dict[str, DiagnosisResult] +) -> None: + """上位 2 色が固定値と一致する.""" + _, _, expected = REGRESSION_CASES[name] + + assert results[name].top_labels == expected + + +@pytest.mark.parametrize("name", list(REGRESSION_CASES)) +def test_valid_input_is_not_reported_as_failure( + name: str, results: dict[str, DiagnosisResult] +) -> None: + """学習・予測が成立する入力は診断失敗にならない.""" + result = results[name] + + assert result.failed is False + assert result.failure_reason is None + + +@pytest.mark.parametrize("name", list(REGRESSION_CASES)) +def test_top_labels_are_the_two_highest_scores( + name: str, results: dict[str, DiagnosisResult] +) -> None: + """上位 2 色はスコア降順の先頭 2 件である(SPEC_02 (3)).""" + scores = results[name].scores + first, second = results[name].top_labels + others = [key for key in COLOR_KEYS if key not in (first, second)] + + assert scores[first] >= scores[second] + assert all(scores[second] >= scores[key] for key in others) + + +@pytest.mark.parametrize("name", list(REGRESSION_CASES)) +def test_scores_are_ratios_of_doctor_agreement( + name: str, results: dict[str, DiagnosisResult] +) -> None: + """スコアは 38 医師の予測の平均(0〜1・分母 38 の分数)である.""" + for key, score in results[name].scores.items(): + assert 0.0 <= score <= 1.0, key + assert score * DOCTOR_COUNT == pytest.approx(round(score * DOCTOR_COUNT), abs=1e-4), key + + +@pytest.mark.parametrize("name", list(REGRESSION_CASES)) +def test_percentages_are_rounded_scores(name: str, results: dict[str, DiagnosisResult]) -> None: + """百分率は一致率を 100 倍して整数に丸めた値である.""" + result = results[name] + + for key, score in result.scores.items(): + assert isinstance(result.percentages[key], int) + assert abs(result.percentages[key] - score * 100.0) <= 0.5 + 1e-6, key + + +@pytest.mark.parametrize("name", list(REGRESSION_CASES)) +def test_predictions_are_binary_class_labels( + name: str, results: dict[str, DiagnosisResult] +) -> None: + """予測値は 190 列分の 0/1(2 クラス SVM の出力)である.""" + predictions = results[name].predictions + + assert predictions.shape == (EVALUATION_COLUMN_COUNT,) + assert set(np.unique(predictions).tolist()) <= {0.0, 1.0} + + +@pytest.mark.parametrize("name", list(REGRESSION_CASES)) +def test_scores_are_the_mean_of_each_color_columns( + name: str, results: dict[str, DiagnosisResult] +) -> None: + """各色のスコアは,その色に対応する 38 列の予測の平均である.""" + result = results[name] + + for color_index, key in enumerate(COLOR_KEYS): + expected = float(result.predictions[color_index::COLOR_COUNT].sum()) / DOCTOR_COUNT + assert result.scores[key] == pytest.approx(expected, abs=1e-6), key + + +def test_diagnosis_is_deterministic(training: TrainingData) -> None: + """同じ入力・同じ学習データなら結果は毎回同じ(testing.md: 乱数に依存させない).""" + first = diagnose((50.0, 20.0, 5.0), training) + second = diagnose((50.0, 20.0, 5.0), training) + + assert first.scores == second.scores + assert first.top_labels == second.top_labels + assert np.array_equal(first.predictions, second.predictions) + + +def test_columns_with_few_positives_are_skipped( + training: TrainingData, results: dict[str, DiagnosisResult] +) -> None: + """正例が 2 未満の列は学習せず,予測値 0 のままになる(SPEC_02 (3)).""" + expected_skipped = tuple( + column + for column in range(EVALUATION_COLUMN_COUNT) + if int((training.evaluations[:, column] > 0).sum()) < MIN_POSITIVE_SAMPLES + ) + result = results["purple_dark_red"] + + assert result.skipped_columns == expected_skipped + assert all(result.predictions[column] == 0.0 for column in expected_skipped) + + +def test_no_column_fails_to_train_with_the_bundled_data( + results: dict[str, DiagnosisResult], +) -> None: + """同梱データでは学習可能な全列が学習・予測に成功する.""" + assert results["purple_dark_red"].failed_columns == () + + +def test_skipped_column_count_is_fixed_for_the_bundled_data( + results: dict[str, DiagnosisResult], +) -> None: + """同梱データで学習されない列はちょうど 20 列である(回帰値). + + 190 列のうち 20 列(正例 2 未満)が学習対象外なので,学習される列は 170 列になる. + data.csv を差し替えた際に気付けるよう固定値で押さえる. + """ + result = results["purple_dark_red"] + + assert len(result.skipped_columns) == 20 + assert EVALUATION_COLUMN_COUNT - len(result.skipped_columns) == 170 + + +def test_predictions_are_float32(results: dict[str, DiagnosisResult]) -> None: + """予測値は旧実装の `float result[190]` と同じ単精度で保持する.""" + assert results["purple_dark_red"].predictions.dtype == np.float32 + + +def test_training_data_is_not_modified_by_diagnosis(training: TrainingData) -> None: + """診断は学習データを書き換えない(同じ `TrainingData` を繰り返し使えること).""" + lab_before = training.lab.copy() + evaluations_before = training.evaluations.copy() + + diagnose((50.0, 20.0, 5.0), training) + + assert np.array_equal(training.lab, lab_before) + assert np.array_equal(training.evaluations, evaluations_before) + + +def test_model_is_retrained_for_every_diagnosis(training: TrainingData) -> None: + """SVM は診断のたびに学習する(SPEC_02 (3)). + + 学習データを差し替えると結果も変わることで,学習結果を使い回していないことを示す. + ここでは医師 1 名目の紫(列 0)だけを残し,他の列は評価なし(学習対象外)にする. + """ + modified_evaluations = np.zeros_like(training.evaluations) + modified_evaluations[:, 0] = training.evaluations[:, 0] + modified = TrainingData(lab=training.lab.copy(), evaluations=modified_evaluations) + + baseline = diagnose((50.0, 20.0, 5.0), training) + retrained = diagnose((50.0, 20.0, 5.0), modified) + + assert retrained.scores != baseline.scores + # 列ごとに独立に学習するので,残した列 0 の予測は元の学習データのときと変わらない + assert retrained.predictions[0] == baseline.predictions[0] + for key in ("dark_red", "crimson", "pink", "pale"): + assert retrained.scores[key] == 0.0, key + + +# --- 診断失敗の判定(SPEC_02「診断失敗の判定」) --- + + +def test_degenerate_input_reproduces_the_legacy_failure_percentages( + degenerate_result: DiagnosisResult, +) -> None: + """旧実装が失敗と判定するスコア組(紫 42・暗赤 0・紅 5・淡紅 11・淡白 3)を再現する.""" + assert degenerate_result.percentages == FAILED_PERCENTAGES + + +def test_degenerate_input_is_reported_as_failure(degenerate_result: DiagnosisResult) -> None: + """L* が 0 以下の入力は診断失敗として直接検出する(マジックナンバー比較を使わない).""" + assert degenerate_result.failed is True + assert degenerate_result.failure_reason is FailureReason.DEGENERATE_INPUT + + +def test_failure_result_still_carries_scores(degenerate_result: DiagnosisResult) -> None: + """失敗時もスコアは返す(旧実装は失敗表示に重ねてスコアを描画する).""" + assert sum(degenerate_result.scores.values()) > 0.0 + assert degenerate_result.lab == DEGENERATE_LAB + + +def test_failure_reason_has_japanese_description() -> None: + """失敗理由は GUI 表示のための説明文を持つ(core は表示自体は行わない).""" + for reason in FailureReason: + assert isinstance(reason.describe(), str) + assert reason.describe() != "" + + +@pytest.mark.parametrize( + "lab", + [(float("nan"), 0.0, 0.0), (float("inf"), 10.0, 5.0), (50.0, float("nan"), 5.0)], +) +def test_non_finite_input_is_reported_as_failure( + lab: tuple[float, float, float], training: TrainingData +) -> None: + """NaN / inf を含む入力は予測できないため診断失敗にする.""" + result = diagnose(lab, training) + + assert result.failed is True + assert result.failure_reason is FailureReason.INVALID_INPUT + assert result.percentages == dict.fromkeys(COLOR_KEYS, 0) + + +def test_no_trainable_column_is_reported_as_failure() -> None: + """全列の正例が 2 未満なら学習できず,診断失敗になる(スコアは全て 0).""" + result = diagnose(CLUSTER_A_LAB, synthetic_training()) + + assert result.failed is True + assert result.failure_reason is FailureReason.NO_TRAINABLE_COLUMN + assert result.scores == dict.fromkeys(COLOR_KEYS, 0.0) + assert result.percentages == dict.fromkeys(COLOR_KEYS, 0) + assert len(result.skipped_columns) == EVALUATION_COLUMN_COUNT + + +def test_all_zero_scores_use_the_fixed_tie_break_order() -> None: + """全色が同点のときの上位 2 色は旧実装のキー順(紅・暗赤)になる.""" + result = diagnose(CLUSTER_A_LAB, synthetic_training()) + + assert result.top_labels == ("crimson", "dark_red") + + +def test_tied_non_zero_scores_use_the_fixed_tie_break_order() -> None: + """同点の色が複数あるときも旧実装のキー順(C < D < Pi < Pu < W)で順位が決まる. + + 旧実装は `std::map`(キー昇順)を降順ソートするため, + 同点の紫(Pu)と紅(C)では紅が先に並ぶ.紫・紅の 2 色だけを 1/38 で同点にする. + """ + training = synthetic_training({0: SAMPLE_COUNT // 2, 2: SAMPLE_COUNT // 2}) + + result = diagnose(CLUSTER_A_LAB, training) + + assert result.scores["purple"] == result.scores["crimson"] == pytest.approx(1.0 / DOCTOR_COUNT) + assert result.top_labels == ("crimson", "purple") + + +def test_zero_lightness_is_reported_as_failure(training: TrainingData) -> None: + """L* = 0(Y が 0 にクリップされた状態)も色変換が成立しておらず失敗である(境界値).""" + result = diagnose((0.0, 0.0, 0.0), training) + + assert result.failed is True + assert result.failure_reason is FailureReason.DEGENERATE_INPUT + + +def test_legacy_failure_pattern_is_always_reported_as_failure(training: TrainingData) -> None: + """旧実装が失敗表示するスコア組になる入力は,L* > 0 でも診断失敗とする(SPEC_02). + + SPEC_02「診断失敗の判定」は失敗の検出方法だけを変え,表示仕様は現行と同じとしている. + 旧実装(`ResultDlg.cpp`)はスコア組のみで判定するため,学習データから外れた入力 + (例: L* = 5)でも「Diagnosis failed」になる. + """ + result = diagnose((5.0, 0.0, 0.0), training) + + assert result.percentages == FAILED_PERCENTAGES + assert result.failed is True + + +# --- 正例数の条件と色の割り当て(SPEC_02 (3)) --- + + +@pytest.mark.parametrize( + ("column", "expected_key"), + [(0, "purple"), (1, "dark_red"), (2, "crimson"), (3, "pink"), (4, "pale"), (7, "crimson")], +) +def test_trained_column_contributes_to_its_color(column: int, expected_key: str) -> None: + """学習された列の予測は,列インデックス % 5 の色に加算される(医師 5 列の one-hot). + + 他の 189 列は正例 1 個で学習されないため,スコアは対象色のみ 1/38 になる. + """ + training = synthetic_training({column: SAMPLE_COUNT // 2}) + + result = diagnose(CLUSTER_A_LAB, training) + + assert result.failed is False + assert result.predictions[column] == 1.0 + assert result.scores[expected_key] == pytest.approx(1.0 / DOCTOR_COUNT) + for key in COLOR_KEYS: + if key != expected_key: + assert result.scores[key] == 0.0, key + assert result.top_labels[0] == expected_key + + +def test_column_with_exactly_two_positives_is_trained() -> None: + """正例がちょうど 2 個の列は学習対象になる(境界値).""" + training = synthetic_training({0: MIN_POSITIVE_SAMPLES}) + + result = diagnose(CLUSTER_A_LAB, training) + + assert 0 not in result.skipped_columns + assert len(result.skipped_columns) == EVALUATION_COLUMN_COUNT - 1 + assert result.failure_reason is not FailureReason.NO_TRAINABLE_COLUMN + + +def test_column_with_one_positive_is_skipped() -> None: + """正例が 1 個の列は学習されずスコア 0 のままである(境界値).""" + training = synthetic_training({0: SAMPLE_COUNT // 2}) + + result = diagnose(CLUSTER_A_LAB, training) + + assert tuple(range(1, EVALUATION_COLUMN_COUNT)) == result.skipped_columns + assert np.array_equal(result.predictions[1:], np.zeros(EVALUATION_COLUMN_COUNT - 1)) + + +# --- 入力の受け取り方 --- + + +def test_accepts_lab_dataclass_and_tuple(training: TrainingData) -> None: + """`Lab` でも (L*, a*, b*) の 3 要素でも同じ結果になる.""" + lab = Lab(l_star=50.0, a_star=20.0, b_star=5.0) + + from_dataclass = diagnose(lab, training) + from_tuple = diagnose((50.0, 20.0, 5.0), training) + + assert from_dataclass.lab == from_tuple.lab == lab + assert from_dataclass.scores == from_tuple.scores + + +@pytest.mark.parametrize("values", [(50.0, 20.0), (50.0, 20.0, 5.0, 1.0), ()]) +def test_wrong_number_of_components_raises( + values: tuple[float, ...], training: TrainingData +) -> None: + """L*a*b* は 3 要素でなければならない.""" + with pytest.raises(ValueError): + diagnose(values, training) + + +# --- color.py との結合(SPEC_02 のパイプライン) --- + + +def linear_coefficients() -> np.ndarray: + """線形項のみの変換係数(sRGB → XYZ 相当を 0〜255 スケールに合わせたもの).""" + coefficients = np.zeros((17, 3), dtype=np.float64) + coefficients[0] = (0.4124, 0.2126, 0.0193) + coefficients[1] = (0.3576, 0.7152, 0.1192) + coefficients[2] = (0.1805, 0.0722, 0.9505) + return coefficients * (100.0 / 255.0) + + +def test_roi_to_lab_to_diagnosis_pipeline(training: TrainingData) -> None: + """ROI 平均 RGB → XYZ → Lab → SVM 分類のパイプラインが連結する(SPEC_02 冒頭). + + ROI(51×51)が単色なのでその色がそのまま診断入力になり,同じ色の画素値を + 直接 `rgb_to_lab` に渡した場合と結果が一致する. + """ + bgr = (110, 110, 180) # BGR 並び(= RGB (180, 110, 110)) + image = np.empty((200, 200, 3), dtype=np.uint8) + image[:, :] = bgr + + rgb = mean_rgb(image, [(60, 70), (140, 130)]) + lab = rgb_to_lab(rgb, linear_coefficients()) + result = diagnose(lab, training) + + assert rgb == (180.0, 110.0, 110.0) + assert result.lab == rgb_to_lab((180.0, 110.0, 110.0), linear_coefficients()) + assert set(result.scores) == set(COLOR_KEYS) + assert result.top_labels[0] in COLOR_KEYS + + +def test_lab_from_rgb_conversion_can_be_diagnosed(training: TrainingData) -> None: + """`rgb_to_lab` の出力をそのまま `diagnose` に渡せる(ROI 平均 RGB → 分類の連結).""" + # 線形項のみの変換係数(sRGB → XYZ 相当を 0〜255 スケールに合わせたもの) + coefficients = np.zeros((17, 3), dtype=np.float64) + coefficients[0] = (0.4124, 0.2126, 0.0193) + coefficients[1] = (0.3576, 0.7152, 0.1192) + coefficients[2] = (0.1805, 0.0722, 0.9505) + coefficients *= 100.0 / 255.0 + + lab = rgb_to_lab((180.0, 110.0, 110.0), coefficients) + result = diagnose(lab, training) + + assert result.lab == lab + assert lab.l_star > 0.0 + assert result.failed is False + assert set(result.scores) == set(COLOR_KEYS) + assert all(0.0 <= score <= 1.0 for score in result.scores.values()) diff --git a/tests/fixtures/regression/README.md b/tests/fixtures/regression/README.md new file mode 100644 index 0000000..2964172 --- /dev/null +++ b/tests/fixtures/regression/README.md @@ -0,0 +1,116 @@ +# 回帰テスト用フィクスチャ形式 (Regression Fixture Format) + +旧実装 exe(`org/src` のビルド成果物)で採取した入出力ペアを置く場所. +PLAN_02 ステップ 5「回帰テスト」で使用し,`tests/test_regression.py` が本ディレクトリを読む. + +採取の手順(旧 exe の操作方法・採取すべきケース)は +[docs/06_TEST/TEST_01_回帰テスト採取手順.md](../../../docs/06_TEST/TEST_01_回帰テスト採取手順.md) を参照する. +**CSV が 1 件も無い間,回帰テストは skip される**(置いた瞬間に有効化される). + +## ディレクトリ構成 (Layout) + +```text +tests/fixtures/regression/ +├── README.md # 本ファイル(形式定義) +├── matrices/ # 採取時に使った変換行列 CSV(17 行 × 3 列・ヘッダなし) +│ └── <任意の名前>.csv +├── rgb_to_lab.csv # ① RGB → L*a*b* のペア +├── rgb_to_scores.csv # ② RGB → 5 色スコアのペア(パイプライン全体) +└── lab_to_scores.csv # ③ L*a*b* → 5 色スコアのペア(任意.下記の注意を参照) +``` + +## 共通ルール (Common Rules) + +- 文字コード: UTF-8(BOM なし).改行は CRLF / LF どちらでもよい +- 1 行目はヘッダ行とし,列名は本書のとおりに書く(列の順序は自由.列名で対応付ける) +- `#` で始まる行と空行は無視される(メモを書いてよい) +- 数値は半角.旧 exe の表示をそのまま転記する(表示は整数なので小数点は付かない) +- `matrix` 列には `matrices/` に置いた変換行列 CSV のファイル名を書く(例: `translateMatrix.csv`) +- `note` 列は任意.採取日・撮影環境・ケースの意図等を自由に書く + +## 旧 exe の表示精度 (Display Precision of the Legacy exe) + +`org/src/tonguediagnosisMFC2/tonguediagnosisMFC2Dlg.cpp` の `Text()` は, +L\*a\*b\* もスコアも **`Format(_T("%3.0lf"), ...)`** で整形している. +すなわち**どちらも小数点以下 0 桁(整数に丸めた値)しか画面に出ない**. +これを前提に,照合の許容誤差を次のように定める. + +| 項目 | 旧 exe の表示 | 照合方法 | 許容誤差 | +| --- | --- | --- | --- | +| L\*, a\*, b\* | `%3.0lf`(整数・四捨五入相当) | 新実装の計算値と表示値を直接比較 | 絶対値 0.51 以内(表示丸め幅 0.5 + 微小マージン) | +| 5 色スコア(%) | `%3.0lf`(整数) | 新実装の `percentages`(int)と**完全一致** | 0(不一致は即失敗) | +| 診断失敗 | 結果画面の「Diagnosis failed」表示 | 新実装の `DiagnosisResult.failed` と**完全一致** | 0 | + +※ スコアを完全一致で照合できる理由: スコアは `k / 38 × 100`(k は 0〜38 の整数)の値しか取らず, +`x.5` ちょうどになる k が存在しないため,丸め方(printf の偶数丸め / Python の `round`)の +違いで結果が変わらない.したがって整数どうしの厳密比較が安全である. + +※ L\*a\*b\* を 0.51 以内としているのは,旧 exe が整数しか表示しないためである. +より厳しい照合が必要になった場合は,旧実装をデバッグビルドして `TRACE` 出力(`%lf`)を +採取する必要がある(TEST_01「精度を上げたい場合」参照). + +## ① rgb_to_lab.csv + +ROI 平均 RGB(= 単色画像の色)と,旧 exe が表示した L\*a\*b\* の組. + +| 列名 | 内容 | +| --- | --- | +| `matrix` | 使用した変換行列 CSV のファイル名(`matrices/` 内) | +| `r`, `g`, `b` | 入力 RGB(0〜255.単色画像の画素値そのまま) | +| `l_star`, `a_star`, `b_star` | 旧 exe のメイン画面に表示された `L*` / `a*` / `b*` の値 | +| `note` | 任意のメモ | + +```csv +matrix,r,g,b,l_star,a_star,b_star,note +translateMatrix.csv,156,102,98,55,22,7,基準色(旧実装コメントの平均 BGR 由来) +``` + +## ② rgb_to_scores.csv + +同じ入力 RGB と,旧 exe が表示した 5 色スコア(%)・診断失敗の有無の組. +新実装では `rgb_to_lab` → `diagnose` の**パイプライン全体**を通して照合する +(ステップ 5 の完了条件はこのファイルで判定する). + +| 列名 | 内容 | +| --- | --- | +| `matrix` | 使用した変換行列 CSV のファイル名 | +| `r`, `g`, `b` | 入力 RGB(0〜255) | +| `purple` | 「紫」の表示値(%.整数) | +| `dark_red` | 「暗赤」の表示値(%) | +| `crimson` | 「紅」の表示値(%) | +| `pink` | 「淡紅」の表示値(%) | +| `pale` | 「淡白」の表示値(%) | +| `failed` | 結果画面に「Diagnosis failed」が出たら `yes`,出なければ `no` | +| `note` | 任意のメモ | + +```csv +matrix,r,g,b,purple,dark_red,crimson,pink,pale,failed,note +translateMatrix.csv,156,102,98,16,61,11,5,0,no,基準色 +``` + +※ `failed` は `yes` / `no`(`true` / `false`・`1` / `0` も可.空欄は `no` 扱い). + +## ③ lab_to_scores.csv(任意) + +L\*a\*b\* を直接入力としたスコアのペア.**旧 exe には Lab の直接入力手段が無い**ため, +通常は採取できない.次のような «L\*a\*b\* が厳密に分かっている» ケースがある場合にのみ使う. + +- 旧実装をデバッグビルドし,`CalcSVM()` へ渡る `m_tongueL/a/b` を直接与えて実行した場合 +- 旧実装のソースを改造して Lab 入力版を作った場合 + +| 列名 | 内容 | +| --- | --- | +| `l_star`, `a_star`, `b_star` | 入力 L\*a\*b\*(**整数丸めではない厳密値**) | +| `purple`, `dark_red`, `crimson`, `pink`, `pale` | 5 色スコアの表示値(%) | +| `failed` | 「Diagnosis failed」が出たら `yes` | +| `note` | 任意のメモ | + +※ 旧 exe の画面から読んだ**整数丸めの L\*a\*b\* をここに書いてはならない**. +丸めた Lab を SVM に入れると境界付近で予測が変わり,偽の不一致(または偽の一致)を招く. +RGB から採取した組は必ず ② に書く. + +## matrices/ + +採取時にユーザーが旧 exe に読ませた変換行列 CSV をそのまま置く(17 行 × 3 列・ヘッダなし). +撮影環境ごとに異なるファイルなので,複数置いて `matrix` 列で使い分けてよい. +変換行列が変われば Lab もスコアも変わるため,**採取に使ったファイルを必ず同梱する**こと. diff --git a/tests/fixtures/regression/matrices/.gitkeep b/tests/fixtures/regression/matrices/.gitkeep new file mode 100644 index 0000000..2f07a3f --- /dev/null +++ b/tests/fixtures/regression/matrices/.gitkeep @@ -0,0 +1,2 @@ +採取に使った変換行列 CSV(17 行 × 3 列・ヘッダなし)をこのディレクトリに置く. +形式は ../README.md ,採取手順は docs/06_TEST/TEST_01_回帰テスト採取手順.md を参照. diff --git a/tests/gui/test_main_window_logic.py b/tests/gui/test_main_window_logic.py new file mode 100644 index 0000000..6922e70 --- /dev/null +++ b/tests/gui/test_main_window_logic.py @@ -0,0 +1,595 @@ +"""メイン画面の QWidget 非依存ロジックの検証(SPEC_01「メイン画面」・PLAN_02 ステップ 6). + +仕様上の要件は以下(SPEC_01). + +- 変換行列は `.csv`,画像は `.png` のみ受け付け,他拡張子はエラー表示 +- 画像は読込時に中央基準で 1024×1280 にクロップする +- 画像上の左クリックで 51×51 px の ROI を追加(最大 4 点.5 点目はエラー),赤枠を描画 +- 右クリックで全クリックを取り消す +- [舌色診断] は 1 点以上クリックで有効化し,診断(ROI 平均色 → Lab → SVM)を実行する +- 入力ガードの文言(変換行列未読込・画像未読込・クリック 0 点) +- 結果画面(患者側モニタ)は表示専用のため,言語切替と「結果画面を閉じる」はメイン画面が持つ + (言語は開いていない間もメイン画面が保持し,次に開く結果画面へ引き継ぐ) + +※ testing.md の方針により GUI(QWidget)そのものの自動テストは書かない. +本ファイルは画面部品に依存しない関数・状態クラスのみを対象とする. +""" + +from __future__ import annotations + +from pathlib import Path + +import cv2 +import numpy as np +import pytest + +from tiasjudge.core import roi +from tiasjudge.core.color import rgb_to_lab +from tiasjudge.core.csv_io import TrainingData, load_training_data +from tiasjudge.core.svm import COLOR_KEYS, diagnose +from tiasjudge.gui.main_window import ( + CROP_HEIGHT, + CROP_WIDTH, + DISPLAY_DIVISOR, + IMAGE_EXTENSION, + MATRIX_EXTENSION, + MESSAGE_CLICK_REQUIRED, + MESSAGE_IMAGE_REQUIRED, + MESSAGE_TRANSFORM_REQUIRED, + ROI_RECT_COLOR, + ROI_RECT_THICKNESS, + TRAINING_DATA_ENV_VAR, + ImageLoadError, + InputGuardError, + MainWindow, + MainWindowState, + check_extension, + crop_center, + default_training_data_path, + draw_roi_markers, + is_roi_inside_image, + load_image_file, + run_diagnosis, +) +from tiasjudge.gui.result_window import Language, next_language, switch_button_label + +# --- ヘルパー --- + + +def make_image(width: int, height: int, bgr: tuple[int, int, int] = (10, 20, 30)) -> np.ndarray: + """指定サイズの単色 BGR 画像を作る.""" + image = np.empty((height, width, 3), dtype=np.uint8) + image[:, :] = bgr + return image + + +def make_ramp_image(width: int, height: int) -> np.ndarray: + """B = x 座標,G = y 座標(256 で巡回)の画像を作る.クロップ位置を解析的に検証できる.""" + image = np.zeros((height, width, 3), dtype=np.uint8) + image[:, :, 0] = (np.arange(width) % 256).astype(np.uint8)[np.newaxis, :] + image[:, :, 1] = (np.arange(height) % 256).astype(np.uint8)[:, np.newaxis] + return image + + +def write_png(path: Path, image: np.ndarray) -> Path: + """PNG を書き出す(非 ASCII パス対応のため imencode + write_bytes).""" + success, buffer = cv2.imencode(".png", image) + assert success + path.write_bytes(buffer.tobytes()) + return path + + +def linear_coefficients() -> np.ndarray: + """1 次項のみの合成変換行列(sRGB→XYZ 相当)を作る(SPEC_02 (1) の形式に合わせた 17×3).""" + coefficients = np.zeros((17, 3), dtype=np.float64) + coefficients[0] = (0.4124, 0.2126, 0.0193) + coefficients[1] = (0.3576, 0.7152, 0.1192) + coefficients[2] = (0.1805, 0.0722, 0.9505) + return coefficients * (100.0 / 255.0) + + +@pytest.fixture(scope="module") +def training_data(data_csv_path: Path) -> TrainingData: + """同梱学習データ(`data/data.csv`)を読み込む.""" + return load_training_data(data_csv_path) + + +# --- 定数(SPEC_01) --- + + +def test_crop_size_is_1024_by_1280() -> None: + """クロップ後サイズは 1024×1280(SPEC_01「画像選択」).""" + assert (CROP_WIDTH, CROP_HEIGHT) == (1024, 1280) + + +def test_display_is_half_scale() -> None: + """画像表示は 1/2 縮小(SPEC_01「画像表示」).""" + assert DISPLAY_DIVISOR == 2 + + +def test_accepted_extensions() -> None: + """変換行列は .csv,画像は .png(SPEC_01).""" + assert MATRIX_EXTENSION == ".csv" + assert IMAGE_EXTENSION == ".png" + + +def test_guard_messages_follow_spec() -> None: + """入力ガードの文言は SPEC_01「入力ガード(エラー表示)」どおり.""" + assert MESSAGE_TRANSFORM_REQUIRED == "変換行列を読み込んでください" + assert MESSAGE_IMAGE_REQUIRED == "画像を読み込んでください" + assert "1 点以上 4 点以下クリックしてください" in MESSAGE_CLICK_REQUIRED + + +# --- check_extension(SPEC_01「変換行列選択」「画像選択」) --- + + +class TestCheckExtension: + """拡張子の検証.""" + + @pytest.mark.parametrize("name", ["matrix.csv", "MATRIX.CSV", "a.b.Csv"]) + def test_accepts_expected_extension_case_insensitively(self, name: str) -> None: + check_extension(name, MATRIX_EXTENSION, "message") + + @pytest.mark.parametrize("name", ["matrix.txt", "matrix.png", "matrix", "csv"]) + def test_rejects_other_extensions(self, name: str) -> None: + with pytest.raises(InputGuardError, match="message"): + check_extension(name, MATRIX_EXTENSION, "message") + + def test_accepts_path_object(self, tmp_path: Path) -> None: + check_extension(tmp_path / "画像.PNG", IMAGE_EXTENSION, "message") + + +# --- crop_center(SPEC_01「画像選択」) --- + + +class TestCropCenter: + """中央基準 1024×1280 クロップ.""" + + def test_large_image_is_cropped_to_target_size(self) -> None: + cropped = crop_center(make_image(2000, 3000)) + assert cropped.shape == (CROP_HEIGHT, CROP_WIDTH, 3) + + def test_crop_is_taken_from_the_center(self) -> None: + """切り出し位置が中央(左上オフセット = (幅-1024)//2, (高さ-1280)//2)であること.""" + width, height = 2000, 3000 + cropped = crop_center(make_ramp_image(width, height)) + x_offset = (width - CROP_WIDTH) // 2 + y_offset = (height - CROP_HEIGHT) // 2 + assert int(cropped[0, 0, 0]) == x_offset % 256 + assert int(cropped[0, 0, 1]) == y_offset % 256 + assert int(cropped[-1, -1, 0]) == (x_offset + CROP_WIDTH - 1) % 256 + assert int(cropped[-1, -1, 1]) == (y_offset + CROP_HEIGHT - 1) % 256 + + def test_exact_size_image_is_unchanged(self) -> None: + image = make_ramp_image(CROP_WIDTH, CROP_HEIGHT) + cropped = crop_center(image) + assert cropped.shape == (CROP_HEIGHT, CROP_WIDTH, 3) + assert np.array_equal(cropped, image) + + @pytest.mark.parametrize( + ("width", "height"), + [(500, 600), (2000, 600), (500, 3000), (1, 1)], + ) + def test_smaller_image_is_kept_within_bounds(self, width: int, height: int) -> None: + """目標より小さい辺はそのまま(はみ出さない).""" + cropped = crop_center(make_image(width, height)) + assert cropped.shape == (min(height, CROP_HEIGHT), min(width, CROP_WIDTH), 3) + + def test_returns_independent_copy(self) -> None: + """クロップ結果を書き換えても元画像は変わらない(描画で元画像を壊さないため).""" + image = make_image(2000, 3000, (1, 2, 3)) + cropped = crop_center(image) + cropped[:, :] = (255, 255, 255) + assert np.array_equal(image[0, 0], np.array([1, 2, 3], dtype=np.uint8)) + + +# --- load_image_file --- + + +class TestLoadImageFile: + """PNG 読込(クロップ込み・日本語パス対応・異常系).""" + + def test_loads_and_crops_png(self, tmp_path: Path) -> None: + path = write_png(tmp_path / "tongue.png", make_ramp_image(2000, 3000)) + image = load_image_file(path) + assert image.shape == (CROP_HEIGHT, CROP_WIDTH, 3) + + def test_loads_png_with_japanese_path(self, tmp_path: Path) -> None: + """非 ASCII パスでも読み込める(cv2.imread の制約を回避していること).""" + directory = tmp_path / "舌画像フォルダ" + directory.mkdir() + path = write_png(directory / "舌_01.png", make_image(1024, 1280, (5, 6, 7))) + image = load_image_file(path) + assert image.shape == (CROP_HEIGHT, CROP_WIDTH, 3) + assert tuple(int(value) for value in image[0, 0]) == (5, 6, 7) + + def test_missing_file_raises_image_load_error(self, tmp_path: Path) -> None: + with pytest.raises(ImageLoadError): + load_image_file(tmp_path / "no_such_file.png") + + def test_corrupt_file_raises_image_load_error(self, tmp_path: Path) -> None: + path = tmp_path / "broken.png" + path.write_bytes(b"this is not a png") + with pytest.raises(ImageLoadError): + load_image_file(path) + + def test_empty_file_raises_image_load_error(self, tmp_path: Path) -> None: + path = tmp_path / "empty.png" + path.write_bytes(b"") + with pytest.raises(ImageLoadError): + load_image_file(path) + + +# --- draw_roi_markers(SPEC_01「ROI 指定」) --- + + +class TestDrawRoiMarkers: + """クリック位置への赤枠描画.""" + + def test_does_not_modify_the_source_image(self) -> None: + image = make_image(200, 200, (0, 0, 0)) + original = image.copy() + draw_roi_markers(image, [(100, 100)]) + assert np.array_equal(image, original) + + def test_no_points_leaves_image_as_is(self) -> None: + image = make_image(200, 200, (0, 0, 0)) + assert np.array_equal(draw_roi_markers(image, []), image) + + def test_rectangle_is_centered_on_the_click_point(self) -> None: + """枠は (x±25, y±25) に描かれる(ROI 51×51 に対応).""" + image = make_image(200, 200, (0, 0, 0)) + annotated = draw_roi_markers(image, [(100, 100)]) + for corner in ((75, 75), (125, 75), (75, 125), (125, 125)): + x, y = corner + assert tuple(int(value) for value in annotated[y, x]) == ROI_RECT_COLOR + # 枠の内側(中心)は塗られない + assert tuple(int(value) for value in annotated[100, 100]) == (0, 0, 0) + assert roi.ROI_OFFSET == 25 + + def test_rectangle_color_and_thickness_follow_legacy(self) -> None: + """枠色は BGR (0, 0, 200)・太さ 3(旧実装踏襲).""" + assert ROI_RECT_COLOR == (0, 0, 200) + assert ROI_RECT_THICKNESS == 3 + image = make_image(200, 200, (0, 0, 0)) + annotated = draw_roi_markers(image, [(100, 100)]) + # 上辺は太さぶんの帯になる(正確なラスタライズは OpenCV の実装依存なので, + # 枠線上とその近傍が塗られ,離れた位置は塗られないことを確認する) + for y in (74, 75, 76): + assert tuple(int(v) for v in annotated[y, 100]) == ROI_RECT_COLOR + assert tuple(int(v) for v in annotated[70, 100]) == (0, 0, 0) + assert tuple(int(v) for v in annotated[80, 100]) == (0, 0, 0) + + def test_draws_all_points(self) -> None: + image = make_image(300, 300, (0, 0, 0)) + annotated = draw_roi_markers(image, [(50, 50), (200, 200)]) + assert tuple(int(v) for v in annotated[25, 25]) == ROI_RECT_COLOR + assert tuple(int(v) for v in annotated[175, 175]) == ROI_RECT_COLOR + + +# --- is_roi_inside_image --- + + +class TestIsRoiInsideImage: + """ROI が画像内に収まるかの境界判定(旧実装のクラッシュ箇所のガード).""" + + @pytest.mark.parametrize("point", [(25, 25), (74, 74), (25, 74), (50, 50)]) + def test_inside_points(self, point: tuple[int, int]) -> None: + assert is_roi_inside_image(make_image(100, 100), point) is True + + @pytest.mark.parametrize("point", [(24, 25), (25, 24), (75, 74), (74, 75), (0, 0), (99, 99)]) + def test_outside_points(self, point: tuple[int, int]) -> None: + assert is_roi_inside_image(make_image(100, 100), point) is False + + def test_boundary_matches_core_roi_behaviour(self) -> None: + """判定が True の点では `core.roi` が例外を出さず,False の点では出す.""" + image = make_image(100, 100) + roi.roi_mean_bgr(image, (25, 25)) + roi.roi_mean_bgr(image, (74, 74)) + with pytest.raises(ValueError): + roi.roi_mean_bgr(image, (24, 25)) + with pytest.raises(ValueError): + roi.roi_mean_bgr(image, (75, 74)) + + +# --- MainWindowState(SPEC_01「入力ガード」「ROI 指定」「リセット」) --- + + +@pytest.fixture +def loaded_state() -> MainWindowState: + """変換行列・画像を読み込み済みの状態を作る.""" + state = MainWindowState() + state.set_transform(Path("matrix.csv"), linear_coefficients()) + state.set_image(Path("tongue.png"), make_image(CROP_WIDTH, CROP_HEIGHT)) + return state + + +class TestMainWindowStateGuards: + """入力ガードの遷移.""" + + def test_initial_state_requires_transform_matrix(self) -> None: + state = MainWindowState() + with pytest.raises(InputGuardError, match=MESSAGE_TRANSFORM_REQUIRED): + state.ensure_clickable() + + def test_click_before_image_requires_image(self) -> None: + state = MainWindowState() + state.set_transform(Path("matrix.csv"), linear_coefficients()) + with pytest.raises(InputGuardError, match=MESSAGE_IMAGE_REQUIRED): + state.add_point((100, 100)) + assert state.point_count == 0 + + def test_click_before_transform_reports_transform_first(self) -> None: + """変換行列・画像の両方が未読込なら,先に変換行列を案内する(SPEC_01 の順序).""" + state = MainWindowState() + with pytest.raises(InputGuardError, match=MESSAGE_TRANSFORM_REQUIRED): + state.add_point((100, 100)) + + def test_diagnose_with_zero_clicks_is_rejected(self, loaded_state: MainWindowState) -> None: + assert loaded_state.can_diagnose is False + with pytest.raises(InputGuardError, match="1 点以上 4 点以下"): + loaded_state.ensure_diagnosable() + + def test_diagnose_is_enabled_after_one_click(self, loaded_state: MainWindowState) -> None: + loaded_state.add_point((100, 100)) + assert loaded_state.can_diagnose is True + loaded_state.ensure_diagnosable() + + +class TestMainWindowStatePoints: + """クリック点の追加・上限・リセット.""" + + def test_accepts_up_to_four_points(self, loaded_state: MainWindowState) -> None: + points = [(100, 100), (200, 200), (300, 300), (400, 400)] + for point in points: + loaded_state.add_point(point) + assert loaded_state.points == points + assert loaded_state.point_count == roi.MAX_ROI_POINTS == 4 + + def test_fifth_point_is_rejected_and_not_counted(self, loaded_state: MainWindowState) -> None: + """5 点目はエラー(SPEC_01).旧実装と異なりクリック数も増やさない.""" + for index in range(roi.MAX_ROI_POINTS): + loaded_state.add_point((100 + index * 60, 100)) + with pytest.raises(InputGuardError): + loaded_state.add_point((500, 500)) + assert loaded_state.point_count == roi.MAX_ROI_POINTS + + def test_point_whose_roi_leaves_the_image_is_rejected( + self, loaded_state: MainWindowState + ) -> None: + with pytest.raises(InputGuardError): + loaded_state.add_point((0, 0)) + assert loaded_state.point_count == 0 + + def test_clear_points_resets_all_clicks(self, loaded_state: MainWindowState) -> None: + """右クリックのリセット(SPEC_01「リセット」).旧実装と異なり点数も 0 に戻る.""" + loaded_state.add_point((100, 100)) + loaded_state.add_point((200, 200)) + loaded_state.clear_points() + assert loaded_state.point_count == 0 + assert loaded_state.can_diagnose is False + # リセット後は再び 4 点までクリックできる + for index in range(roi.MAX_ROI_POINTS): + loaded_state.add_point((100 + index * 60, 100)) + assert loaded_state.point_count == roi.MAX_ROI_POINTS + + def test_loading_a_new_image_clears_points(self, loaded_state: MainWindowState) -> None: + loaded_state.add_point((100, 100)) + loaded_state.set_image(Path("other.png"), make_image(CROP_WIDTH, CROP_HEIGHT)) + assert loaded_state.point_count == 0 + + def test_clear_image_disables_clicking(self, loaded_state: MainWindowState) -> None: + """[診断続行] 相当.画像とクリックが戻り,変換行列は保持される.""" + loaded_state.add_point((100, 100)) + loaded_state.clear_image() + assert loaded_state.has_image is False + assert loaded_state.has_transform is True + assert loaded_state.point_count == 0 + with pytest.raises(InputGuardError, match=MESSAGE_IMAGE_REQUIRED): + loaded_state.ensure_clickable() + + def test_clear_transform_requires_reloading_the_matrix( + self, loaded_state: MainWindowState + ) -> None: + """[診断続行] で「同じ変換行列を使わない」を選んだ場合.""" + loaded_state.clear_transform() + assert loaded_state.has_transform is False + with pytest.raises(InputGuardError, match=MESSAGE_TRANSFORM_REQUIRED): + loaded_state.ensure_clickable() + + +# --- default_training_data_path --- + + +class TestDefaultTrainingDataPath: + """学習データの既定パス解決(絶対パスのハードコード禁止).""" + + def test_environment_variable_takes_precedence( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path + ) -> None: + override = tmp_path / "custom_data.csv" + monkeypatch.setenv(TRAINING_DATA_ENV_VAR, str(override)) + assert default_training_data_path() == override + + def test_falls_back_to_bundled_data_csv( + self, monkeypatch: pytest.MonkeyPatch, data_csv_path: Path + ) -> None: + monkeypatch.delenv(TRAINING_DATA_ENV_VAR, raising=False) + resolved = default_training_data_path() + assert resolved.is_file() + assert resolved.resolve() == data_csv_path.resolve() + + +# --- run_diagnosis(SPEC_02「処理パイプライン」の結合) --- + + +class TestRunDiagnosis: + """ROI 平均色 → Lab → SVM のパイプライン.""" + + def test_returns_diagnosis_result_for_a_single_point(self, training_data: TrainingData) -> None: + image = make_image(CROP_WIDTH, CROP_HEIGHT, (110, 110, 180)) # BGR + result = run_diagnosis(image, [(100, 100)], linear_coefficients(), training_data) + + assert set(result.percentages) == set(COLOR_KEYS) + assert all(0 <= value <= 100 for value in result.percentages.values()) + assert len(result.top_labels) == 2 + assert result.lab == rgb_to_lab((180.0, 110.0, 110.0), linear_coefficients()) + + def test_matches_composing_core_functions(self, training_data: TrainingData) -> None: + """`core` を直接組み合わせた場合と同じ結果になる(gui は計算を持たない).""" + image = make_ramp_image(CROP_WIDTH, CROP_HEIGHT) + points = [(100, 100), (300, 400)] + coefficients = linear_coefficients() + + result = run_diagnosis(image, points, coefficients, training_data) + expected = diagnose(rgb_to_lab(roi.mean_rgb(image, points), coefficients), training_data) + + assert result.lab == expected.lab + assert result.percentages == expected.percentages + assert result.top_labels == expected.top_labels + assert result.failed == expected.failed + + def test_multiple_points_average_the_rois(self, training_data: TrainingData) -> None: + """複数点は各 ROI 平均の平均を使う(SPEC_02 (0)).""" + image = make_image(CROP_WIDTH, CROP_HEIGHT, (100, 100, 100)) + image[0:200, 0:200] = (200, 200, 200) + points = [(100, 100), (600, 600)] + result = run_diagnosis(image, points, linear_coefficients(), training_data) + assert result.lab == rgb_to_lab((150.0, 150.0, 150.0), linear_coefficients()) + + def test_point_outside_the_image_raises_value_error(self, training_data: TrainingData) -> None: + """GUI 側でガードされる想定だが,`core` が明示的な例外を出すこと.""" + image = make_image(CROP_WIDTH, CROP_HEIGHT) + with pytest.raises(ValueError): + run_diagnosis(image, [(0, 0)], linear_coefficients(), training_data) + + def test_no_points_raises_value_error(self, training_data: TrainingData) -> None: + image = make_image(CROP_WIDTH, CROP_HEIGHT) + with pytest.raises(ValueError): + run_diagnosis(image, [], linear_coefficients(), training_data) + + +# --- 結果画面の操作(言語切替・閉じる)--- + + +class FakeResultWindow: + """開いている結果画面の代役(`set_language` / `close` のみ). + + 実際の結果画面は `close` すると `closed` シグナル経由でメイン画面の一覧から + 取り除かれるため,スタブでも同じ振る舞い(自分を一覧から外す)を再現する. + """ + + def __init__(self, owner: ResultControlHost) -> None: + self._owner = owner + self.languages: list[Language] = [] + self.closed = False + + def set_language(self, language: Language) -> None: + self.languages.append(language) + + def close(self) -> None: + self.closed = True + if self in self._owner._result_windows: + self._owner._result_windows.remove(self) + + +class ResultControlHost: + """`MainWindow` の結果画面操作ロジックだけを取り出したスタブ. + + `MainWindow` を構築すると QWidget を作ることになる(testing.md「GUI の自動テストは + 書かない」)ため,実メソッドを未束縛のまま借りて状態遷移のみを検証する. + """ + + result_language = MainWindow.result_language + toggle_result_language = MainWindow.toggle_result_language + close_result_windows = MainWindow.close_result_windows + + def __init__(self, window_count: int = 0, language: Language = Language.JAPANESE) -> None: + self._result_language = language + self._result_windows: list[FakeResultWindow] = [ + FakeResultWindow(self) for _ in range(window_count) + ] + self.control_updates = 0 + + def _update_controls(self) -> None: + """ボタンの有効・無効とラベルの更新(実装では画面部品を触る)の代役.""" + self.control_updates += 1 + + +class TestResultLanguage: + """結果画面の表示言語をメイン画面が保持する.""" + + @pytest.mark.parametrize("language", list(Language)) + def test_property_exposes_the_current_language(self, language: Language) -> None: + assert ResultControlHost(language=language).result_language is language + + @pytest.mark.parametrize("language", list(Language)) + def test_toggle_switches_the_language(self, language: Language) -> None: + host = ResultControlHost(language=language) + host.toggle_result_language() + assert host.result_language is next_language(language) + + def test_toggle_without_open_windows_is_allowed(self) -> None: + """結果画面が 1 枚も開いていなくても切り替えられる(次に開く画面の言語を決める).""" + host = ResultControlHost(window_count=0) + host.toggle_result_language() + assert host.result_language is Language.ENGLISH + + def test_toggle_applies_to_every_open_window(self) -> None: + host = ResultControlHost(window_count=3) + host.toggle_result_language() + for window in host._result_windows: + assert window.languages == [Language.ENGLISH] + + def test_toggling_twice_restores_the_language(self) -> None: + host = ResultControlHost(window_count=1) + host.toggle_result_language() + host.toggle_result_language() + assert host.result_language is Language.JAPANESE + assert host._result_windows[0].languages == [Language.ENGLISH, Language.JAPANESE] + + def test_toggle_refreshes_the_controls(self) -> None: + """ボタンのラベル(切替先の言語名)を更新するため,毎回 `_update_controls` を呼ぶ.""" + host = ResultControlHost(window_count=1) + host.toggle_result_language() + assert host.control_updates == 1 + + def test_button_label_shows_the_language_to_switch_to(self) -> None: + """ラベルは切替**先**の言語名(SPEC_01「言語切替ボタン」.メイン画面に移設).""" + host = ResultControlHost() + assert switch_button_label(host.result_language) == "English" + host.toggle_result_language() + assert switch_button_label(host.result_language) == "日本語" + + def test_language_is_not_part_of_the_diagnosis_state(self) -> None: + """言語は表示の関心事なので `MainWindowState`(診断入力の状態)には持たせない.""" + state = MainWindowState() + assert not hasattr(state, "language") + assert not hasattr(state, "result_language") + + +class TestCloseResultWindows: + """「結果画面を閉じる」(患者側モニタの画面をメイン画面から閉じる).""" + + def test_closes_every_open_window(self) -> None: + host = ResultControlHost(window_count=3) + windows = list(host._result_windows) + host.close_result_windows() + assert all(window.closed for window in windows) + + def test_leaves_no_window_open(self) -> None: + """閉じる途中で一覧が縮んでも取りこぼさない(複製を回している).""" + host = ResultControlHost(window_count=3) + host.close_result_windows() + assert host._result_windows == [] + + def test_without_open_windows_does_nothing(self) -> None: + host = ResultControlHost(window_count=0) + host.close_result_windows() + assert host._result_windows == [] + + def test_does_not_change_the_language(self) -> None: + """閉じても言語は保持する(次に開く画面へ引き継ぐ).""" + host = ResultControlHost(window_count=2, language=Language.ENGLISH) + host.close_result_windows() + assert host.result_language is Language.ENGLISH diff --git a/tests/gui/test_result_window_logic.py b/tests/gui/test_result_window_logic.py new file mode 100644 index 0000000..b59a626 --- /dev/null +++ b/tests/gui/test_result_window_logic.py @@ -0,0 +1,1306 @@ +"""結果画面の QWidget 非依存ロジックの検証(SPEC_01「結果画面」・PLAN_02 ステップ 7). + +仕様上の要件は以下(SPEC_01「結果画面」). + +- 診断画像はクロップ後の元画像を 1/2 縮小表示する +- 結果パネルは 5 色の行(色名 + 説明)を一覧表示し,**上位 2 色のみ**スコア(%)と下線を描く +- 結果画面は表示専用(操作ボタンを持たない).言語切替・閉じるはメイン画面側で操作し, + 結果画面は `set_language()` で表示言語を差し替える(ラベルは切替**先**の言語名) +- 説明文のサイズは行ごとに決まり,強調(上位 2 色)の有無では変わらない +- 診断失敗時は「Diagnosis failed / Please try again.」を表示する +- 注記は「本結果は疾患の診断を行うものではない」「表示画像は明るさ補正されている」の 2 件 +- 2 画面運用(施術者側にメイン画面・患者側に結果画面)では,メイン画面とは別のモニタが + あればそこへ最大化して表示する.モニタが 1 台なら従来どおり通常サイズで表示する + (`is_same_screen` / `select_secondary_screen` / `show_on_secondary_screen`) + +※ testing.md の方針により GUI(QWidget)そのものの自動テストは書かない. +本ファイルは `ResultWindow` を構築せず,画面部品に依存しない定数・関数のみを対象とする. +※ 実行環境にフォントが無い場合でも成立するよう,描画結果のピクセル比較は行わない. +""" + +from __future__ import annotations + +from collections.abc import Iterator +from contextlib import contextmanager +from dataclasses import replace +from pathlib import Path + +import numpy as np +import pytest +from PySide6.QtCore import QRect +from PySide6.QtGui import QFont, QPainter, QPixmap, QScreen +from PySide6.QtWidgets import QApplication, QWidget + +from tiasjudge.core.color import rgb_to_lab +from tiasjudge.core.csv_io import TrainingData, load_training_data +from tiasjudge.core.svm import COLOR_KEYS, DiagnosisResult, diagnose +from tiasjudge.gui.result_window import ( + AFFILIATION_FONT_SIZE, + AFFILIATION_MAX_WIDTH, + AFFILIATION_POSITION, + COLOR_SWATCHES, + DESCRIPTION_FONT_SIZE, + DESCRIPTION_FONT_SIZE_LONG, + DESCRIPTION_FONT_SIZE_LONGEST, + DESCRIPTION_FONT_WEIGHT, + DESCRIPTION_MAX_WIDTH, + DESCRIPTION_MAX_WIDTH_HIGHLIGHTED, + DESCRIPTION_MIN_FONT_SIZE, + DESCRIPTION_X, + DISPLAY_ORDER, + FAILURE_DETAIL, + FAILURE_DETAIL_FONT_SIZE, + FAILURE_RECT, + FAILURE_TITLE, + FAILURE_TITLE_FONT_SIZE, + HEADING_COLOR, + HEADING_FONT_SIZE, + HEADING_POSITION, + HIGHLIGHT_COUNT, + IMAGE_DISPLAY_DIVISOR, + LANGUAGE_TEXTS, + NAME_FONT_SIZE, + NAME_MAX_WIDTH, + NAME_OUTLINE_COLOR, + NAME_OUTLINE_WIDTH, + NAME_X, + NOTE_FONT_SIZE, + NOTE_POSITIONS, + PANEL_SIZE, + PERCENT_FONT_SIZE, + PERCENT_X, + ROW_FIRST_Y, + ROW_MARGIN, + SCORE_AREA_WIDTH, + SCORE_FONT_SIZE, + SCORE_RIGHT_X, + TEXT_MARGIN, + UNDERLINE_WIDTH, + UNDERLINE_X_RANGE, + ColorRowText, + Language, + ResultRow, + ResultWindow, + _fit_font, + _set_font, + build_result_rows, + format_score, + is_highlighted, + is_same_screen, + next_language, + render_result_panel, + row_baseline, + select_secondary_screen, + show_on_secondary_screen, + switch_button_label, + texts_for, + to_display_pixmap, +) + +# --- ヘルパー --- + + +@pytest.fixture(scope="session") +def gui_app() -> QApplication: + """QPixmap の生成に必要な QApplication を用意する(conftest で offscreen 指定済み). + + ※ `QGuiApplication` ではなく `QApplication` を作る.他のテスト(`--check` 起動)が + `QApplication.instance()` を再利用してウィジェットを生成するため,ここで + ウィジェット非対応のインスタンスを残すとプロセスごと落ちる. + """ + app = QApplication.instance() + if app is None: + app = QApplication([]) + assert isinstance(app, QApplication) + return app + + +def make_result( + percentages: dict[str, int], + top_labels: tuple[str, str], + *, + failed: bool = False, +) -> DiagnosisResult: + """表示検証用の `DiagnosisResult` を組み立てる(SVM は動かさない).""" + scores = {key: percentages[key] / 100.0 for key in COLOR_KEYS} + return DiagnosisResult( + lab=rgb_to_lab((180.0, 110.0, 110.0), _linear_coefficients()), + scores=scores, + percentages=dict(percentages), + top_labels=top_labels, + predictions=np.zeros(190, dtype=np.float32), + failed=failed, + ) + + +def _linear_coefficients() -> np.ndarray: + """1 次項のみの合成変換行列(SPEC_02 (1) の 17×3 形式).""" + coefficients = np.zeros((17, 3), dtype=np.float64) + coefficients[0] = (0.4124, 0.2126, 0.0193) + coefficients[1] = (0.3576, 0.7152, 0.1192) + coefficients[2] = (0.1805, 0.0722, 0.9505) + return coefficients * (100.0 / 255.0) + + +SAMPLE_PERCENTAGES = {"purple": 42, "dark_red": 0, "crimson": 5, "pink": 68, "pale": 3} + + +@pytest.fixture +def sample_result() -> DiagnosisResult: + """上位 2 色が pink・purple の結果.""" + return make_result(SAMPLE_PERCENTAGES, ("pink", "purple")) + + +@pytest.fixture(scope="module") +def training_data(data_csv_path: Path) -> TrainingData: + """同梱学習データ(`data/data.csv`)を読み込む.""" + return load_training_data(data_csv_path) + + +@contextmanager +def painter_on_panel() -> Iterator[QPainter]: + """パネルと同じ大きさの QPixmap 上の `QPainter` を貸す(フォント設定の検証用).""" + pixmap = QPixmap(PANEL_SIZE, PANEL_SIZE) + painter = QPainter(pixmap) + try: + yield painter + finally: + painter.end() + + +def make_image(width: int, height: int, bgr: tuple[int, int, int] = (10, 20, 30)) -> np.ndarray: + """指定サイズの単色 BGR 画像を作る.""" + image = np.empty((height, width, 3), dtype=np.uint8) + image[:, :] = bgr + return image + + +# --- 定数(SPEC_01・旧実装 `ResultDlg.cpp` 踏襲) --- + + +def test_display_order_is_pink_crimson_dark_red_purple_pale() -> None: + """パネルの並びは上から 淡紅・紅・暗赤・紫・淡白(SPEC_01「5 色の表示内容」).""" + assert DISPLAY_ORDER == ("pink", "crimson", "dark_red", "purple", "pale") + + +def test_display_order_covers_all_color_keys_exactly_once() -> None: + """5 色すべてを過不足なく含む(`core.svm.COLOR_KEYS` と同じ集合).""" + assert len(DISPLAY_ORDER) == len(set(DISPLAY_ORDER)) == len(COLOR_KEYS) + assert set(DISPLAY_ORDER) == set(COLOR_KEYS) + + +def test_highlight_count_is_two() -> None: + """スコア・下線を描くのは上位 2 色のみ(SPEC_01「結果パネル」).""" + assert HIGHLIGHT_COUNT == 2 + + +def test_image_is_displayed_at_half_scale() -> None: + """診断画像は 1/2 縮小表示(SPEC_01「診断画像」).""" + assert IMAGE_DISPLAY_DIVISOR == 2 + + +def test_panel_size_follows_legacy() -> None: + """結果パネルは旧実装の背景画像と同じ 644×644.""" + assert PANEL_SIZE == 644 + + +def test_row_layout_follows_legacy_coordinates() -> None: + """行の基準座標は旧実装 `ShowResultImg()` の y=250 / margin=87.""" + assert (ROW_FIRST_Y, ROW_MARGIN) == (250, 87) + + +def test_underline_follows_legacy_geometry() -> None: + """下線は x=15〜635・太さ 8(旧実装 `cv::line(..., 8)`).""" + assert UNDERLINE_X_RANGE == (15, 635) + assert UNDERLINE_WIDTH == 8 + + +def test_failure_band_follows_legacy_rect() -> None: + """失敗帯は (0, 250)-(550, 450)(旧実装 `cv::rectangle`).x, y, 幅, 高さで保持する.""" + x, y, width, height = FAILURE_RECT + assert (x, y) == (0, 250) + assert (x + width, y + height) == (550, 450) + + +def test_failure_message_follows_spec() -> None: + """失敗表示の文言は「Diagnosis failed / Please try again.」(SPEC_01「診断失敗表示」).""" + assert FAILURE_TITLE == "Diagnosis failed" + assert FAILURE_DETAIL == "Please try again." + + +# --- 言語リソース(SPEC_01「5 色の表示内容」) --- + + +class TestLanguageTexts: + """日英 2 言語分の表示リソース.""" + + def test_both_languages_are_defined(self) -> None: + assert set(LANGUAGE_TEXTS) == set(Language) == {Language.JAPANESE, Language.ENGLISH} + + @pytest.mark.parametrize("language", list(Language)) + def test_rows_cover_display_order_exactly(self, language: Language) -> None: + """各言語の行は `DISPLAY_ORDER` の 5 キーを過不足なく持つ.""" + keys = [row.key for row in texts_for(language).rows] + assert len(keys) == len(set(keys)) == len(DISPLAY_ORDER) + assert set(keys) == set(DISPLAY_ORDER) + + @pytest.mark.parametrize("language", list(Language)) + def test_has_two_notes(self, language: Language) -> None: + """注記は 2 件(SPEC_01「注記」).""" + assert len(texts_for(language).notes) == 2 + + @pytest.mark.parametrize("language", list(Language)) + def test_note_contents_follow_spec(self, language: Language) -> None: + """注記は「疾患の診断を行うものではない」「明るさ補正されている」の 2 件.""" + notes = texts_for(language).notes + if language is Language.JAPANESE: + assert "病気" in notes[0] or "疾患" in notes[0] + assert "明るさ" in notes[1] + else: + assert "disease" in notes[0] + assert "brightness" in notes[1] + + @pytest.mark.parametrize("language", list(Language)) + def test_labels_are_not_empty(self, language: Language) -> None: + texts = texts_for(language) + assert texts.window_title + assert texts.heading + assert texts.switch_button_label + assert all(row.name and row.description for row in texts.rows) + + def test_english_names_and_descriptions_match_spec_table(self) -> None: + """英語の色名・説明は SPEC_01「5 色の表示内容」の表どおり.""" + expected = { + "pink": ("Pink", "Normal"), + "crimson": ("Red", "Hot flush / feverish"), + "dark_red": ("Dark red", "Blood slightly thick"), + "purple": ("Purple", "Blood is thick"), + "pale": ("Pale red", "Cold / fatigue / anemia"), + } + actual = {row.key: (row.name, row.description) for row in texts_for(Language.ENGLISH).rows} + assert actual == expected + + def test_japanese_names_match_spec_table(self) -> None: + """日本語の色名は SPEC_01「5 色の表示内容」の表どおり. + + 5 色目は結果画面では「淡白紅」(メイン画面の「淡白」とは表記が異なる). + 旧実装の背景画像 `img_student2.bmp` の表記を現行踏襲したもので, + SPEC_01 の注記に明記されている. + """ + expected = { + "pink": "淡紅", + "crimson": "紅", + "dark_red": "暗赤", + "purple": "紫", + "pale": "淡白紅", + } + actual = {row.key: row.name for row in texts_for(Language.JAPANESE).rows} + assert actual == expected + + def test_swatch_is_defined_for_every_color(self) -> None: + assert set(COLOR_SWATCHES) == set(DISPLAY_ORDER) + for value in COLOR_SWATCHES.values(): + assert len(value) == 3 + assert all(0 <= channel <= 255 for channel in value) + + +# --- 文字色(旧背景画像 img_student2.bmp からの実測値.回帰固定) --- + + +class TestPanelColors: + """見出し・色名の表示色. + + 旧実装のソース上の `Scalar(...)` 指定は旧背景画像 `img/img_student2.bmp` と一致しない + ため,画像から実測した値を正とする(result_window.py の定数コメント参照). + 見た目の一致は人間が確認する項目なので,ここでは**実測値からの意図しない逸脱**を + 検出するための回帰固定のみ行う. + """ + + def test_heading_color_matches_the_measured_value(self) -> None: + """見出しのオレンジは実測値 RGB(233, 113, 50).""" + assert HEADING_COLOR == (233, 113, 50) + + def test_color_swatches_match_the_measured_values(self) -> None: + """5 色の色名の文字色は旧背景画像からの実測値.""" + assert COLOR_SWATCHES == { + "pink": (255, 187, 174), + "crimson": (224, 112, 146), + "dark_red": (214, 69, 65), + "purple": (125, 94, 125), + "pale": (255, 241, 167), + } + + def test_swatches_are_distinct(self) -> None: + """5 色は互いに異なる色で描く(同じ色だと行の区別が付かない).""" + assert len(set(COLOR_SWATCHES.values())) == len(DISPLAY_ORDER) + + def test_name_outline_is_a_dark_thin_stroke(self) -> None: + """色名の縁取りは黒・1 px 以上(旧背景画像の色名には暗色の縁取りがある).""" + assert NAME_OUTLINE_COLOR == (0, 0, 0) + assert NAME_OUTLINE_WIDTH >= 1 + + +# --- レイアウト・フォントサイズの整合(はみ出し・重なりの予防) --- + + +class TestLayoutConstraints: + """文字を拡大しても要素同士が重ならないための制約.""" + + def test_x_positions_are_in_left_to_right_order(self) -> None: + """色名 → 説明 → スコア → % の順に左から並ぶ.""" + assert NAME_X < DESCRIPTION_X < SCORE_RIGHT_X < PERCENT_X + + def test_all_max_widths_are_positive(self) -> None: + widths = ( + NAME_MAX_WIDTH, + DESCRIPTION_MAX_WIDTH, + DESCRIPTION_MAX_WIDTH_HIGHLIGHTED, + AFFILIATION_MAX_WIDTH, + ) + assert all(width > 0 for width in widths) + + def test_name_and_description_do_not_overlap(self) -> None: + """色名の描画上限は説明文の開始位置より手前で止まる.""" + assert NAME_X + NAME_MAX_WIDTH <= DESCRIPTION_X + + def test_highlighted_description_stops_before_the_score_area(self) -> None: + """強調行の説明文はスコア欄(`SCORE_AREA_WIDTH`)に食い込まない.""" + assert DESCRIPTION_X + DESCRIPTION_MAX_WIDTH_HIGHLIGHTED == SCORE_RIGHT_X - SCORE_AREA_WIDTH + assert DESCRIPTION_MAX_WIDTH_HIGHLIGHTED < DESCRIPTION_MAX_WIDTH + + def test_all_max_widths_fit_in_the_panel(self) -> None: + assert DESCRIPTION_X + DESCRIPTION_MAX_WIDTH + TEXT_MARGIN <= PANEL_SIZE + assert AFFILIATION_POSITION[0] + AFFILIATION_MAX_WIDTH + TEXT_MARGIN <= PANEL_SIZE + + @pytest.mark.parametrize( + "pixel_size", + [ + HEADING_FONT_SIZE, + AFFILIATION_FONT_SIZE, + NOTE_FONT_SIZE, + NAME_FONT_SIZE, + DESCRIPTION_FONT_SIZE, + DESCRIPTION_MIN_FONT_SIZE, + SCORE_FONT_SIZE, + PERCENT_FONT_SIZE, + FAILURE_TITLE_FONT_SIZE, + FAILURE_DETAIL_FONT_SIZE, + ], + ) + def test_font_sizes_are_positive(self, pixel_size: int) -> None: + assert pixel_size > 0 + + def test_description_shrink_limit_is_below_the_base_size(self) -> None: + """説明文は基準サイズから下限まで縮められる(下限 < 基準).""" + assert 0 < DESCRIPTION_MIN_FONT_SIZE < DESCRIPTION_FONT_SIZE + + def test_header_elements_are_stacked_top_down(self) -> None: + """見出し → 注記 2 行 → 5 色の行 の順に上から並ぶ.""" + assert HEADING_POSITION[1] < NOTE_POSITIONS[0][1] < NOTE_POSITIONS[1][1] < ROW_FIRST_Y + + def test_first_row_is_below_the_notes_area(self) -> None: + """1 行目の色名は注記より下に来る(色名を拡大しても重ならない). + + ※ 見出し自体の上端の収まりはフォント(字形)依存のため,ここでは検証しない + (拡大後の見た目は人間が確認する.testing.md「GUI の自動テストは書かない」). + """ + assert NOTE_POSITIONS[1][1] < ROW_FIRST_Y - NAME_FONT_SIZE + + +# --- 説明文のサイズ(行ごとに固定.英語の長い行だけ小さくする) --- + +#: 英語の各行に期待する説明文サイズ(result_window.py の定数コメントにある実測値の回帰固定). +#: 40 px のままだと強調行の上限幅 `DESCRIPTION_MAX_WIDTH_HIGHLIGHTED` に収まらない +#: 3 行だけ小さくしている. +EXPECTED_ENGLISH_DESCRIPTION_FONT_SIZES = { + "pink": DESCRIPTION_FONT_SIZE, # "Normal" + "crimson": DESCRIPTION_FONT_SIZE_LONG, # "Hot flush / feverish" + "dark_red": DESCRIPTION_FONT_SIZE_LONG, # "Blood slightly thick" + "purple": DESCRIPTION_FONT_SIZE, # "Blood is thick" + "pale": DESCRIPTION_FONT_SIZE_LONGEST, # "Cold / fatigue / anemia" +} + + +class TestDescriptionFontSizeConstants: + """行ごとの説明文サイズの定数. + + ※ 「その幅に収まる最大サイズ」はインストール済みフォントに依存するため, + ここでは実測値からの意図しない逸脱を防ぐ回帰固定と,定数同士の関係だけを見る + (見た目は人間が確認する.testing.md「GUI の自動テストは書かない」). + """ + + def test_long_sizes_match_the_measured_values(self) -> None: + assert DESCRIPTION_FONT_SIZE == 40 + assert DESCRIPTION_FONT_SIZE_LONG == 36 + assert DESCRIPTION_FONT_SIZE_LONGEST == 30 + + def test_sizes_are_ordered_by_text_length(self) -> None: + """長い行ほど小さい(最長 < 長い < 基準).""" + assert DESCRIPTION_FONT_SIZE_LONGEST < DESCRIPTION_FONT_SIZE_LONG < DESCRIPTION_FONT_SIZE + + def test_smallest_size_does_not_trigger_dynamic_shrinking(self) -> None: + """最小の行サイズでも動的縮小の下限を下回らない(下限に張り付かせない).""" + assert DESCRIPTION_FONT_SIZE_LONGEST >= DESCRIPTION_MIN_FONT_SIZE + + def test_row_text_defaults_to_the_base_size(self) -> None: + """`ColorRowText` の既定は基準サイズ(指定しない行は 40 px).""" + assert ColorRowText("pink", "淡紅", "正常").description_font_size == DESCRIPTION_FONT_SIZE + + def test_result_row_defaults_to_the_base_size(self) -> None: + row = ResultRow( + key="pink", + name="淡紅", + description="正常", + swatch=COLOR_SWATCHES["pink"], + highlighted=False, + score_text=None, + ) + assert row.description_font_size == DESCRIPTION_FONT_SIZE + + +class TestLanguageDescriptionFontSizes: + """言語リソース(`LANGUAGE_TEXTS`)が持つ行ごとのサイズ.""" + + def test_japanese_rows_all_use_the_base_size(self) -> None: + """日本語は文字数が少なく全行 40 px で収まる.""" + for row in texts_for(Language.JAPANESE).rows: + assert row.description_font_size == DESCRIPTION_FONT_SIZE, row.key + + def test_english_rows_match_the_measured_sizes(self) -> None: + actual = {row.key: row.description_font_size for row in texts_for(Language.ENGLISH).rows} + assert actual == EXPECTED_ENGLISH_DESCRIPTION_FONT_SIZES + + @pytest.mark.parametrize("language", list(Language)) + def test_sizes_are_within_the_allowed_range(self, language: Language) -> None: + """どの行も下限〜基準の範囲に収まる(基準より大きくしない).""" + for row in texts_for(language).rows: + assert DESCRIPTION_MIN_FONT_SIZE <= row.description_font_size <= DESCRIPTION_FONT_SIZE + + @pytest.mark.parametrize("language", list(Language)) + def test_longer_descriptions_are_never_larger(self, language: Language) -> None: + """説明が長い行のサイズが短い行より大きいことはない(サイズ指定の取り違え検出).""" + rows = sorted(texts_for(language).rows, key=lambda row: len(row.description)) + sizes = [row.description_font_size for row in rows] + assert sizes == sorted(sizes, reverse=True) + + +# --- フォント設定(_set_font / _fit_font) --- + + +class TestSetFont: + """`_set_font`(サイズ・太さの指定). + + ※ 描画結果ではなく `QFont` の状態のみを見るため,インストール済みフォントに依存しない. + """ + + def test_sets_the_pixel_size(self, gui_app: QApplication) -> None: + with painter_on_panel() as painter: + _set_font(painter, 33) + assert painter.font().pixelSize() == 33 + + def test_default_is_not_bold(self, gui_app: QApplication) -> None: + with painter_on_panel() as painter: + _set_font(painter, 20) + assert painter.font().bold() is False + + def test_bold_flag_is_applied(self, gui_app: QApplication) -> None: + with painter_on_panel() as painter: + _set_font(painter, 20, bold=True) + assert painter.font().bold() is True + + def test_weight_selects_an_intermediate_thickness(self, gui_app: QApplication) -> None: + """`weight` では Bold 以外の太さ(Medium 等)も選べる.""" + with painter_on_panel() as painter: + _set_font(painter, 20, weight=QFont.Weight.Medium) + font = painter.font() + assert font.weight() == QFont.Weight.Medium + assert QFont.Weight.Normal < font.weight() < QFont.Weight.Bold + + def test_weight_takes_precedence_over_bold(self, gui_app: QApplication) -> None: + with painter_on_panel() as painter: + _set_font(painter, 20, bold=True, weight=QFont.Weight.Normal) + assert painter.font().weight() == QFont.Weight.Normal + + def test_description_weight_is_between_normal_and_bold(self) -> None: + """説明文は太字にせず,やや太い程度に留める(色名より目立たせない).""" + assert QFont.Weight.Normal < DESCRIPTION_FONT_WEIGHT < QFont.Weight.Bold + + +class TestFitFont: + """`_fit_font`(省略の前に文字を縮めて全文を残す).""" + + def test_keeps_the_base_size_when_it_already_fits(self, gui_app: QApplication) -> None: + with painter_on_panel() as painter: + _fit_font(painter, "abc", 40, PANEL_SIZE, 28) + assert painter.font().pixelSize() == 40 + + def test_shrinks_until_the_text_fits(self, gui_app: QApplication) -> None: + """収まらない幅では,収まる最大サイズまで 1 px ずつ縮める.""" + text = "Cold / fatigue / anemia" + with painter_on_panel() as painter: + _set_font(painter, 40) + full_width = painter.fontMetrics().horizontalAdvance(text) + if full_width == 0: # フォントが無い環境(幅が測れない)では検証できない + pytest.skip("フォントメトリクスが取得できない環境") + target_width = full_width // 2 + + _fit_font(painter, text, 40, target_width, 1) + fitted = painter.font().pixelSize() + assert fitted < 40 + assert painter.fontMetrics().horizontalAdvance(text) <= target_width + + # 1 px 大きくすると収まらない(=収まる最大サイズを選んでいる) + _set_font(painter, fitted + 1) + assert painter.fontMetrics().horizontalAdvance(text) > target_width + + def test_never_goes_below_the_minimum_size(self, gui_app: QApplication) -> None: + """下限まで縮めても収まらない場合は下限で止める(残りは呼び出し元が省略する).""" + with painter_on_panel() as painter: + _fit_font(painter, "とても長い説明文" * 20, 40, 10, 28) + assert painter.font().pixelSize() == 28 + + @pytest.mark.parametrize("max_width", [1, 50, 200, 1000]) + def test_result_is_always_within_the_allowed_range( + self, gui_app: QApplication, max_width: int + ) -> None: + with painter_on_panel() as painter: + _fit_font(painter, "Blood slightly thick", 40, max_width, 28) + assert 28 <= painter.font().pixelSize() <= 40 + + def test_weight_is_preserved_while_shrinking(self, gui_app: QApplication) -> None: + with painter_on_panel() as painter: + _fit_font(painter, "abcdefghij" * 10, 40, 50, 28, weight=QFont.Weight.Medium) + assert painter.font().weight() == QFont.Weight.Medium + + +# --- 言語切替(SPEC_01「言語切替ボタン」) --- + + +class TestLanguageSwitching: + """日本語 ⇔ 英語の切替とボタンラベル.""" + + def test_next_language_toggles(self) -> None: + assert next_language(Language.JAPANESE) is Language.ENGLISH + assert next_language(Language.ENGLISH) is Language.JAPANESE + + @pytest.mark.parametrize("language", list(Language)) + def test_next_language_round_trip(self, language: Language) -> None: + assert next_language(next_language(language)) is language + + def test_switch_button_shows_the_target_language(self) -> None: + """ラベルは切替**先**の言語名(SPEC_01).""" + assert switch_button_label(Language.JAPANESE) == "English" + assert switch_button_label(Language.ENGLISH) == "日本語" + + @pytest.mark.parametrize("language", list(Language)) + def test_switch_button_label_is_not_the_current_language(self, language: Language) -> None: + """現在の言語名をそのまま出さない(切替先を示す).""" + assert switch_button_label(language) != switch_button_label(next_language(language)) + + @pytest.mark.parametrize("language", list(Language)) + def test_texts_for_returns_the_registered_resource(self, language: Language) -> None: + assert texts_for(language) is LANGUAGE_TEXTS[language] + + +class LanguageStateStub: + """`ResultWindow` の言語切替ロジックだけを取り出したスタブ. + + `ResultWindow` を構築すると QWidget を作ることになる(testing.md「GUI の自動テストは + 書かない」)ため,実メソッドを未束縛のまま借りて,状態遷移と再描画回数だけを検証する. + """ + + set_language = ResultWindow.set_language + toggle_language = ResultWindow.toggle_language + + def __init__(self, language: Language) -> None: + self._language = language + self.redraw_count = 0 + + def _apply_language(self) -> None: + """`ResultWindow._apply_language`(タイトルとパネルの描き直し)の代役.""" + self.redraw_count += 1 + + +class TestSetLanguage: + """`ResultWindow.set_language`(メイン画面の言語切替ボタンから呼ばれる公開 API).""" + + @pytest.mark.parametrize("language", list(Language)) + def test_changing_the_language_redraws_once(self, language: Language) -> None: + window = LanguageStateStub(language) + target = next_language(language) + + window.set_language(target) + + assert window._language is target + assert window.redraw_count == 1 + + @pytest.mark.parametrize("language", list(Language)) + def test_same_language_is_a_no_op(self, language: Language) -> None: + """同じ言語を指定しても描き直さない(無駄な再描画を避ける).""" + window = LanguageStateStub(language) + + window.set_language(language) + + assert window._language is language + assert window.redraw_count == 0 + + @pytest.mark.parametrize("language", list(Language)) + def test_repeated_calls_with_the_same_language_redraw_once(self, language: Language) -> None: + window = LanguageStateStub(language) + target = next_language(language) + + window.set_language(target) + window.set_language(target) + + assert window.redraw_count == 1 + + @pytest.mark.parametrize("language", list(Language)) + def test_toggle_language_switches_to_the_other_language(self, language: Language) -> None: + """`toggle_language` は `set_language(next_language(...))` の薄いラッパ.""" + window = LanguageStateStub(language) + + window.toggle_language() + + assert window._language is next_language(language) + assert window.redraw_count == 1 + + @pytest.mark.parametrize("language", list(Language)) + def test_toggling_twice_restores_the_original_language(self, language: Language) -> None: + window = LanguageStateStub(language) + + window.toggle_language() + window.toggle_language() + + assert window._language is language + assert window.redraw_count == 2 + + +class TestResultWindowIsDisplayOnly: + """結果画面(患者側モニタ)は操作 UI を持たない.""" + + def test_exposes_set_language_as_public_api(self) -> None: + assert callable(ResultWindow.set_language) + assert callable(ResultWindow.toggle_language) + assert isinstance(ResultWindow.language, property) + + def test_module_does_not_use_push_buttons(self) -> None: + """言語切替・閉じるボタンはメイン画面側にあるため,ボタンを import しない.""" + import tiasjudge.gui.result_window as result_window + + assert not hasattr(result_window, "QPushButton") + + def test_source_creates_no_buttons(self) -> None: + """描画コードにボタン生成が残っていない(表示専用の回帰固定).""" + import inspect + + import tiasjudge.gui.result_window as result_window + + assert "QPushButton(" not in inspect.getsource(result_window) + + +# --- is_highlighted / format_score / row_baseline --- + + +class TestIsHighlighted: + """強調対象(上位 2 色)の判定.""" + + def test_top_two_are_highlighted(self) -> None: + top = ("pink", "purple") + assert is_highlighted("pink", top) is True + assert is_highlighted("purple", top) is True + + @pytest.mark.parametrize("key", ["crimson", "dark_red", "pale"]) + def test_others_are_not_highlighted(self, key: str) -> None: + assert is_highlighted(key, ("pink", "purple")) is False + + def test_only_first_two_labels_are_used(self) -> None: + """3 件目以降が渡されても強調は 2 色まで.""" + assert is_highlighted("crimson", ("pink", "purple", "crimson")) is False + + def test_unknown_key_is_not_highlighted(self) -> None: + assert is_highlighted("unknown", ("pink", "purple")) is False + + +class TestFormatScore: + """スコア文字列(旧実装 `Format("%3.0lf")` と同じ 3 桁右詰め).""" + + @pytest.mark.parametrize( + ("percent", "expected"), + [(0, " 0"), (3, " 3"), (42, " 42"), (100, "100")], + ) + def test_right_aligned_to_three_columns(self, percent: int, expected: str) -> None: + assert format_score(percent) == expected + + @pytest.mark.parametrize("percent", [0, 1, 50, 99, 100]) + def test_length_is_at_least_three(self, percent: int) -> None: + assert len(format_score(percent)) == 3 + assert format_score(percent).strip() == str(percent) + + +class TestRowBaseline: + """行のベースライン y(旧実装 `y = 250; margin = 87`).""" + + @pytest.mark.parametrize( + ("index", "expected"), + [(0, 250), (1, 337), (2, 424), (3, 511), (4, 598)], + ) + def test_matches_legacy_positions(self, index: int, expected: int) -> None: + assert row_baseline(index) == expected + + def test_all_rows_fit_in_the_panel(self) -> None: + assert row_baseline(len(DISPLAY_ORDER) - 1) < PANEL_SIZE + + +# --- build_result_rows(SPEC_01「結果パネル」) --- + + +class TestBuildResultRows: + """診断結果から行データを組み立てる.""" + + @pytest.mark.parametrize("language", list(Language)) + def test_returns_five_rows_in_display_order( + self, sample_result: DiagnosisResult, language: Language + ) -> None: + rows = build_result_rows(sample_result, language) + assert tuple(row.key for row in rows) == DISPLAY_ORDER + + @pytest.mark.parametrize("language", list(Language)) + def test_only_top_two_are_highlighted( + self, sample_result: DiagnosisResult, language: Language + ) -> None: + rows = build_result_rows(sample_result, language) + highlighted = {row.key for row in rows if row.highlighted} + assert highlighted == set(sample_result.top_labels) + assert len(highlighted) == HIGHLIGHT_COUNT + + def test_score_text_is_only_set_on_highlighted_rows( + self, sample_result: DiagnosisResult + ) -> None: + for row in build_result_rows(sample_result, Language.JAPANESE): + if row.highlighted: + assert row.score_text == format_score(sample_result.percentages[row.key]) + else: + assert row.score_text is None + + def test_score_text_is_right_aligned(self, sample_result: DiagnosisResult) -> None: + """スコアは 3 桁右詰め(pink=68 → " 68",purple=42 → " 42").""" + rows = {row.key: row for row in build_result_rows(sample_result, Language.JAPANESE)} + assert rows["pink"].score_text == " 68" + assert rows["purple"].score_text == " 42" + + @pytest.mark.parametrize("language", list(Language)) + def test_uses_the_language_texts( + self, sample_result: DiagnosisResult, language: Language + ) -> None: + expected = {row.key: (row.name, row.description) for row in texts_for(language).rows} + for row in build_result_rows(sample_result, language): + assert (row.name, row.description) == expected[row.key] + + @pytest.mark.parametrize("language", list(Language)) + def test_copies_the_description_font_size_of_each_row( + self, sample_result: DiagnosisResult, language: Language + ) -> None: + """行ごとの説明文サイズを言語リソースからそのまま転写する.""" + expected = {row.key: row.description_font_size for row in texts_for(language).rows} + for row in build_result_rows(sample_result, language): + assert row.description_font_size == expected[row.key], row.key + + def test_english_description_font_sizes_are_fixed(self, sample_result: DiagnosisResult) -> None: + """英語は長い 3 行だけ小さい(40/36/36/40/30 の回帰固定).""" + rows = build_result_rows(sample_result, Language.ENGLISH) + actual = {row.key: row.description_font_size for row in rows} + assert actual == EXPECTED_ENGLISH_DESCRIPTION_FONT_SIZES + + def test_japanese_description_font_sizes_are_uniform( + self, sample_result: DiagnosisResult + ) -> None: + rows = build_result_rows(sample_result, Language.JAPANESE) + assert {row.description_font_size for row in rows} == {DESCRIPTION_FONT_SIZE} + + @pytest.mark.parametrize("language", list(Language)) + @pytest.mark.parametrize( + "top_labels", + [("pink", "purple"), ("pale", "crimson"), ("dark_red", "pale"), ("crimson", "dark_red")], + ) + def test_description_font_size_does_not_depend_on_highlighting( + self, language: Language, top_labels: tuple[str, str] + ) -> None: + """強調(上位 2 色)の有無でサイズが変わらない. + + 上位 2 色が変わるたびに文字サイズが変わると,診断のたびに見た目がちらつくため, + サイズは行ごとに固定する(実装コメント `DESCRIPTION_FONT_SIZE_LONG` 参照). + """ + result = make_result(SAMPLE_PERCENTAGES, top_labels) + rows = build_result_rows(result, language) + expected = {row.key: row.description_font_size for row in texts_for(language).rows} + for row in rows: + assert row.description_font_size == expected[row.key], (row.key, row.highlighted) + + def test_swatch_matches_the_color_table(self, sample_result: DiagnosisResult) -> None: + for row in build_result_rows(sample_result, Language.ENGLISH): + assert row.swatch == COLOR_SWATCHES[row.key] + + @pytest.mark.parametrize( + "top_labels", + [("pink", "purple"), ("pale", "crimson"), ("dark_red", "pink"), ("crimson", "dark_red")], + ) + def test_highlight_follows_top_labels(self, top_labels: tuple[str, str]) -> None: + result = make_result(SAMPLE_PERCENTAGES, top_labels) + rows = build_result_rows(result, Language.JAPANESE) + assert {row.key for row in rows if row.highlighted} == set(top_labels) + + def test_zero_percent_top_label_still_shows_a_score(self) -> None: + """スコア 0 の色が上位に来ても " 0" を表示する(空文字にしない).""" + result = make_result(SAMPLE_PERCENTAGES, ("dark_red", "pale")) + rows = {row.key: row for row in build_result_rows(result, Language.JAPANESE)} + assert rows["dark_red"].score_text == " 0" + + def test_failed_result_still_builds_rows(self) -> None: + """診断失敗でも 5 行を組み立てられる(失敗表示はスコアに重ねて描かれる).""" + result = make_result( + {"purple": 42, "dark_red": 0, "crimson": 5, "pink": 11, "pale": 3}, + ("purple", "pink"), + failed=True, + ) + rows = build_result_rows(result, Language.ENGLISH) + assert tuple(row.key for row in rows) == DISPLAY_ORDER + assert {row.key for row in rows if row.highlighted} == {"purple", "pink"} + + def test_rows_are_immutable(self, sample_result: DiagnosisResult) -> None: + """行データは凍結データクラス(描画側で書き換えられない).""" + row = build_result_rows(sample_result, Language.JAPANESE)[0] + with pytest.raises(Exception): # noqa: B017 - FrozenInstanceError(dataclasses) + row.name = "書き換え" # type: ignore[misc] + + +# --- to_display_pixmap(SPEC_01「診断画像」) --- + + +class TestToDisplayPixmap: + """BGR 画像 → 1/2 縮小 QPixmap.""" + + def test_halves_the_image_size(self, gui_app: QApplication) -> None: + pixmap = to_display_pixmap(make_image(1024, 1280)) + assert (pixmap.width(), pixmap.height()) == (512, 640) + + def test_respects_the_divisor_argument(self, gui_app: QApplication) -> None: + pixmap = to_display_pixmap(make_image(1024, 1280), 4) + assert (pixmap.width(), pixmap.height()) == (256, 320) + + def test_never_produces_a_zero_sized_pixmap(self, gui_app: QApplication) -> None: + pixmap = to_display_pixmap(make_image(1, 1)) + assert (pixmap.width(), pixmap.height()) == (1, 1) + + def test_converts_bgr_to_rgb(self, gui_app: QApplication) -> None: + """BGR(0, 0, 255) は赤として表示される.""" + pixmap = to_display_pixmap(make_image(64, 64, (0, 0, 255))) + color = pixmap.toImage().pixelColor(10, 10) + assert (color.red(), color.green(), color.blue()) == (255, 0, 0) + + def test_does_not_alias_the_source_buffer(self, gui_app: QApplication) -> None: + """元配列を書き換えても QPixmap の内容は変わらない(バッファ寿命に依存しない).""" + image = make_image(64, 64, (0, 0, 255)) + pixmap = to_display_pixmap(image) + image[:, :] = (255, 255, 255) + color = pixmap.toImage().pixelColor(10, 10) + assert (color.red(), color.green(), color.blue()) == (255, 0, 0) + + +# --- render_result_panel(描画の成立確認のみ.文字の見た目は人間が確認する) --- + + +class TestRenderResultPanel: + """結果パネルの描画. + + ※ フォントの有無が環境で異なるため,ピクセル比較はしない(testing.md「GUI の自動テストは + 書かない」方針に沿い,描画が成立することのみ確認する). + """ + + @pytest.mark.parametrize("language", list(Language)) + def test_returns_a_panel_sized_pixmap( + self, gui_app: QApplication, sample_result: DiagnosisResult, language: Language + ) -> None: + rows = build_result_rows(sample_result, language) + pixmap = render_result_panel(rows, texts_for(language), sample_result.failed) + assert not pixmap.isNull() + assert (pixmap.width(), pixmap.height()) == (PANEL_SIZE, PANEL_SIZE) + + def test_failed_panel_is_rendered( + self, gui_app: QApplication, sample_result: DiagnosisResult + ) -> None: + rows = build_result_rows(sample_result, Language.ENGLISH) + pixmap = render_result_panel(rows, texts_for(Language.ENGLISH), failed=True) + assert not pixmap.isNull() + assert (pixmap.width(), pixmap.height()) == (PANEL_SIZE, PANEL_SIZE) + + def test_failure_band_is_filled_only_when_failed( + self, gui_app: QApplication, sample_result: DiagnosisResult + ) -> None: + """失敗時のみ帯の領域が塗られる(文字ではなく矩形なのでフォント非依存). + + 塗り色は旧実装 `cv::Scalar(200, 0, 0)`(BGR)= RGB (0, 0, 200) の青. + """ + rows = build_result_rows(sample_result, Language.ENGLISH) + texts = texts_for(Language.ENGLISH) + # 帯の内側かつ下線・文字が来ない位置を見る + sample_point = (FAILURE_RECT[0] + 5, FAILURE_RECT[1] + 5) + band_color = (0, 0, 200) + + failed_color = ( + render_result_panel(rows, texts, failed=True).toImage().pixelColor(*sample_point) + ) + assert (failed_color.red(), failed_color.green(), failed_color.blue()) == band_color + + normal_color = ( + render_result_panel(rows, texts, failed=False).toImage().pixelColor(*sample_point) + ) + assert (normal_color.red(), normal_color.green(), normal_color.blue()) != band_color + + def test_returns_a_new_pixmap_each_time( + self, gui_app: QApplication, sample_result: DiagnosisResult + ) -> None: + """言語切替で描き直せるよう毎回生成する(同一オブジェクトを使い回さない).""" + rows = build_result_rows(sample_result, Language.JAPANESE) + texts = texts_for(Language.JAPANESE) + first = render_result_panel(rows, texts, failed=False) + second = render_result_panel(rows, texts, failed=False) + assert first is not second + + def test_renders_without_highlighted_rows( + self, gui_app: QApplication, sample_result: DiagnosisResult + ) -> None: + """強調行が無い(スコア・下線を描かない)場合も描画できる.""" + rows = tuple( + type(row)( + key=row.key, + name=row.name, + description=row.description, + swatch=row.swatch, + highlighted=False, + score_text=None, + ) + for row in build_result_rows(sample_result, Language.ENGLISH) + ) + pixmap = render_result_panel(rows, texts_for(Language.ENGLISH), failed=False) + assert not pixmap.isNull() + + @pytest.mark.parametrize("highlighted", [True, False]) + def test_renders_overlong_texts(self, gui_app: QApplication, highlighted: bool) -> None: + """幅に収まらない色名・説明でも描画が成立する(縮小・省略の経路を通す). + + ※ 色名は縁取り付き(`QPainterPath`)で描かれるため,省略の経路も含めて + 例外なく描けることを確認する.見た目は人間が確認する. + """ + rows = tuple( + ResultRow( + key=key, + name="非常に長い色名" * 5, + description="非常に長い説明文" * 10, + swatch=COLOR_SWATCHES[key], + highlighted=highlighted, + score_text=format_score(50) if highlighted else None, + ) + for key in DISPLAY_ORDER + ) + pixmap = render_result_panel(rows, texts_for(Language.JAPANESE), failed=False) + assert not pixmap.isNull() + assert (pixmap.width(), pixmap.height()) == (PANEL_SIZE, PANEL_SIZE) + + def test_uses_the_per_row_description_font_size( + self, gui_app: QApplication, sample_result: DiagnosisResult + ) -> None: + """`ResultRow.description_font_size` が描画に反映される(固定値を無視しない). + + ※ 文字の見た目ではなく「サイズを変えると絵が変わる」ことだけを見る. + フォントが無く幅が測れない環境では検証できないため読み飛ばす. + """ + texts = texts_for(Language.ENGLISH) + rows = build_result_rows(sample_result, Language.ENGLISH) + with painter_on_panel() as painter: + _set_font(painter, DESCRIPTION_FONT_SIZE) + if painter.fontMetrics().horizontalAdvance(rows[0].description) == 0: + pytest.skip("フォントメトリクスが取得できない環境") + + base = render_result_panel(rows, texts, failed=False).toImage() + shrunk_rows = tuple( + replace(row, description_font_size=DESCRIPTION_MIN_FONT_SIZE) for row in rows + ) + shrunk = render_result_panel(shrunk_rows, texts, failed=False).toImage() + assert base != shrunk + + def test_renders_every_row_font_size_without_shrinking_out_of_range( + self, gui_app: QApplication, sample_result: DiagnosisResult + ) -> None: + """行ごとのサイズを指定しても描画が成立する(強調行・非強調行の両方).""" + for language in Language: + rows = build_result_rows(sample_result, language) + pixmap = render_result_panel(rows, texts_for(language), failed=False) + assert not pixmap.isNull() + + def test_renders_with_empty_texts(self, gui_app: QApplication) -> None: + """空文字でも描画が成立する(縁取りのパス生成が空になるケース).""" + rows = tuple( + ResultRow( + key=key, + name="", + description="", + swatch=COLOR_SWATCHES[key], + highlighted=False, + score_text=None, + ) + for key in DISPLAY_ORDER + ) + pixmap = render_result_panel(rows, texts_for(Language.JAPANESE), failed=False) + assert not pixmap.isNull() + + +# --- 結合(core.diagnose → 表示行) --- + + +class TestIntegrationWithDiagnose: + """実データ(`data/data.csv`)で診断した結果を表示行に変換する.""" + + def test_rows_are_consistent_with_the_diagnosis( + self, gui_app: QApplication, training_data: TrainingData + ) -> None: + result = diagnose(rgb_to_lab((180.0, 110.0, 110.0), _linear_coefficients()), training_data) + rows = build_result_rows(result, Language.JAPANESE) + + assert tuple(row.key for row in rows) == DISPLAY_ORDER + assert {row.key for row in rows if row.highlighted} == set(result.top_labels) + for row in rows: + if row.highlighted: + assert row.score_text is not None + assert int(row.score_text) == result.percentages[row.key] + + def test_panel_renders_for_a_real_diagnosis( + self, gui_app: QApplication, training_data: TrainingData + ) -> None: + result = diagnose(rgb_to_lab((180.0, 110.0, 110.0), _linear_coefficients()), training_data) + rows = build_result_rows(result, Language.ENGLISH) + pixmap = render_result_panel(rows, texts_for(Language.ENGLISH), result.failed) + assert (pixmap.width(), pixmap.height()) == (PANEL_SIZE, PANEL_SIZE) + + def test_failed_diagnosis_rows_and_panel( + self, gui_app: QApplication, training_data: TrainingData + ) -> None: + """診断失敗(L* <= 0 の域外入力)でも行の組み立てと描画ができる.""" + result = diagnose((-16.0, 0.0, 0.0), training_data) + assert result.failed is True + + rows = build_result_rows(result, Language.ENGLISH) + assert tuple(row.key for row in rows) == DISPLAY_ORDER + assert len({row.key for row in rows if row.highlighted}) == HIGHLIGHT_COUNT + + pixmap = render_result_panel(rows, texts_for(Language.ENGLISH), result.failed) + assert not pixmap.isNull() + + +# --- 表示先モニタの選択(2 画面運用) --- + + +class FakeScreen: + """`QScreen` の代わりに使う最小のスタブ. + + 実環境で 2 台以上のモニタを用意できない(テストは offscreen で動く)ため, + `is_same_screen` / `select_secondary_screen` が使う `name()` と, + `show_on_secondary_screen` が使う `availableGeometry()` だけを持つ. + """ + + def __init__(self, name: str, geometry: QRect | None = None) -> None: + self._name = name + self._geometry = geometry if geometry is not None else QRect(0, 0, 1920, 1080) + + def name(self) -> str: + return self._name + + def availableGeometry(self) -> QRect: # noqa: N802 - Qt の API 名に合わせる + return self._geometry + + +class FakeWindow: + """`QWidget` の代わりに使う最小のスタブ(呼び出し順を記録する).""" + + def __init__(self) -> None: + self.calls: list[tuple[str, object]] = [] + + def show(self) -> None: + self.calls.append(("show", None)) + + def showMaximized(self) -> None: # noqa: N802 - Qt の API 名に合わせる + self.calls.append(("showMaximized", None)) + + def setScreen(self, screen: object) -> None: # noqa: N802 - Qt の API 名に合わせる + self.calls.append(("setScreen", screen)) + + def setGeometry(self, geometry: QRect) -> None: # noqa: N802 - Qt の API 名に合わせる + self.calls.append(("setGeometry", geometry)) + + @property + def call_names(self) -> list[str]: + return [name for name, _ in self.calls] + + +class TestIsSameScreen: + """モニタの同一判定(同一性 → 同名フォールバックの 2 段).""" + + def test_same_object_is_same(self) -> None: + screen = FakeScreen("DISPLAY1") + assert is_same_screen(screen, screen) is True # type: ignore[arg-type] + + def test_none_is_never_the_same(self) -> None: + """基準が未確定(None)なら「同じモニタ」とは見なさない.""" + assert is_same_screen(FakeScreen("DISPLAY1"), None) is False # type: ignore[arg-type] + + def test_different_wrappers_with_the_same_name_are_same(self) -> None: + """`QApplication.screens()` と `QWidget.screen()` は別ラッパを返しうる.""" + assert is_same_screen(FakeScreen("DISPLAY1"), FakeScreen("DISPLAY1")) is True # type: ignore[arg-type] + + def test_different_names_are_not_same(self) -> None: + assert is_same_screen(FakeScreen("DISPLAY1"), FakeScreen("DISPLAY2")) is False # type: ignore[arg-type] + + def test_returns_a_bool(self) -> None: + """戻り値は bool(Qt の比較結果をそのまま返さない).""" + assert isinstance(is_same_screen(FakeScreen("A"), FakeScreen("B")), bool) # type: ignore[arg-type] + + def test_real_screen_is_same_as_itself(self, gui_app: QApplication) -> None: + """実 `QScreen`(offscreen)でも同一判定が成立する.""" + screen = gui_app.primaryScreen() + assert screen is not None + assert is_same_screen(screen, screen) is True + + +class TestSelectSecondaryScreen: + """結果画面を映す「もう一方のモニタ」の選択.""" + + def test_no_screens_returns_none(self) -> None: + assert select_secondary_screen([], None) is None + + def test_single_screen_matching_the_reference_returns_none(self) -> None: + """モニタ 1 台(=メイン画面と同じ)なら該当なし.""" + screen = FakeScreen("DISPLAY1") + assert select_secondary_screen([screen], screen) is None # type: ignore[arg-type] + + def test_single_screen_with_a_same_named_reference_returns_none(self) -> None: + """同じモニタを指す別ラッパが基準でも該当なしと判定する.""" + screens = [FakeScreen("DISPLAY1")] + assert select_secondary_screen(screens, FakeScreen("DISPLAY1")) is None # type: ignore[arg-type] + + def test_reference_none_returns_the_first_screen(self) -> None: + """基準が取れない場合は先頭のモニタを返す.""" + screens = [FakeScreen("DISPLAY1"), FakeScreen("DISPLAY2")] + assert select_secondary_screen(screens, None) is screens[0] # type: ignore[arg-type] + + def test_picks_the_other_of_two_screens(self) -> None: + """2 台構成では基準と異なる方を選ぶ(相互に対称).""" + first, second = FakeScreen("DISPLAY1"), FakeScreen("DISPLAY2") + screens = [first, second] + assert select_secondary_screen(screens, first) is second # type: ignore[arg-type] + assert select_secondary_screen(screens, second) is first # type: ignore[arg-type] + + def test_picks_the_other_when_the_reference_is_another_wrapper(self) -> None: + """基準が同名の別ラッパでも,同じモニタを選ばない(実機での本命ケース).""" + first, second = FakeScreen("DISPLAY1"), FakeScreen("DISPLAY2") + selected = select_secondary_screen([first, second], FakeScreen("DISPLAY1")) # type: ignore[arg-type] + assert selected is second + + def test_returns_the_first_non_reference_screen(self) -> None: + """3 台以上でも「基準以外の最初の 1 台」を返す(一覧の順序を保つ).""" + screens = [FakeScreen("A"), FakeScreen("B"), FakeScreen("C")] + assert select_secondary_screen(screens, screens[0]) is screens[1] # type: ignore[arg-type] + assert select_secondary_screen(screens, screens[1]) is screens[0] # type: ignore[arg-type] + + def test_unknown_reference_returns_the_first_screen(self) -> None: + """基準が一覧に無い場合も先頭を返す(表示先が失われない).""" + screens = [FakeScreen("A"), FakeScreen("B")] + assert select_secondary_screen(screens, FakeScreen("Z")) is screens[0] # type: ignore[arg-type] + + def test_selected_screen_is_never_the_reference(self) -> None: + screens = [FakeScreen("A"), FakeScreen("B"), FakeScreen("C")] + for reference in screens: + selected = select_secondary_screen(screens, reference) # type: ignore[arg-type] + assert selected is not None + assert selected.name() != reference.name() + + def test_real_single_screen_environment_returns_none(self, gui_app: QApplication) -> None: + """実環境の一覧(offscreen は 1 台)でメイン画面と同じモニタが基準なら該当なし.""" + screens = gui_app.screens() + if len(screens) != 1: + pytest.skip("モニタが 1 台の環境でのみ検証できる") + assert select_secondary_screen(screens, screens[0]) is None + + +class TestShowOnSecondaryScreen: + """別モニタへの最大化表示と,1 台構成でのフォールバック.""" + + def test_falls_back_to_normal_show_without_a_target(self) -> None: + """該当モニタが無ければ通常表示のみ(モニタ移動・最大化はしない).""" + window = FakeWindow() + screen = FakeScreen("DISPLAY1") + assert show_on_secondary_screen(window, [screen], screen) is False # type: ignore[arg-type] + assert window.call_names == ["show"] + + def test_falls_back_when_there_are_no_screens(self) -> None: + window = FakeWindow() + assert show_on_secondary_screen(window, [], None) is False # type: ignore[arg-type] + assert window.call_names == ["show"] + + def test_moves_then_maximizes_on_the_secondary_screen(self) -> None: + """「対象モニタへ移す → 最大化」の順で呼ぶ(逆順だと元のモニタで最大化される).""" + window = FakeWindow() + first = FakeScreen("DISPLAY1", QRect(0, 0, 1920, 1040)) + second = FakeScreen("DISPLAY2", QRect(1920, 0, 1280, 1000)) + + assert show_on_secondary_screen(window, [first, second], first) is True # type: ignore[arg-type] + assert window.call_names == ["setScreen", "setGeometry", "showMaximized"] + assert window.calls[0][1] is second + assert window.calls[1][1] == second.availableGeometry() + + def test_does_not_call_plain_show_when_maximizing(self) -> None: + """最大化する場合に通常表示(show)は呼ばない.""" + window = FakeWindow() + screens = [FakeScreen("DISPLAY1"), FakeScreen("DISPLAY2")] + assert show_on_secondary_screen(window, screens, screens[0]) is True # type: ignore[arg-type] + assert "show" not in window.call_names + + def test_uses_the_first_screen_when_the_reference_is_none(self) -> None: + window = FakeWindow() + screens = [FakeScreen("DISPLAY1"), FakeScreen("DISPLAY2")] + assert show_on_secondary_screen(window, screens, None) is True # type: ignore[arg-type] + assert window.calls[0][1] is screens[0] + + def test_same_named_reference_is_not_chosen(self) -> None: + """基準と同名のモニタ(同一モニタの別ラッパ)へは移さない.""" + window = FakeWindow() + first, second = FakeScreen("DISPLAY1"), FakeScreen("DISPLAY2") + assert show_on_secondary_screen(window, [first, second], FakeScreen("DISPLAY1")) is True # type: ignore[arg-type] + assert window.calls[0][1] is second + + def test_real_widget_falls_back_to_normal_show(self, gui_app: QApplication) -> None: + """実 `QWidget`・実 `QScreen` で 1 台構成なら通常表示になる(Qt API の整合確認). + + ※ 画面の見た目は人間が確認する(testing.md).ここでは可視化されることのみ見る. + """ + screens = gui_app.screens() + if len(screens) != 1: + pytest.skip("モニタが 1 台の環境でのみ検証できる") + + window = QWidget() + try: + assert show_on_secondary_screen(window, screens, screens[0]) is False + assert window.isVisible() is True + assert window.isMaximized() is False + finally: + window.close() + + def test_real_widget_is_maximized_on_the_target_screen(self, gui_app: QApplication) -> None: + """実 `QWidget` を実 `QScreen` へ移して最大化できる(基準 None で先頭が対象になる).""" + screens = gui_app.screens() + window = QWidget() + try: + assert show_on_secondary_screen(window, screens, None) is True + assert window.isVisible() is True + assert isinstance(window.screen(), QScreen) + assert is_same_screen(window.screen(), screens[0]) is True + finally: + window.close() diff --git a/tests/test_core_gui_independence.py b/tests/test_core_gui_independence.py new file mode 100644 index 0000000..a59eb91 --- /dev/null +++ b/tests/test_core_gui_independence.py @@ -0,0 +1,75 @@ +"""`core/` の GUI 非依存の検証(project-structure.md「依存方向のルール」). + +- `core/` は PySide6・GUI に依存してはならない(import 禁止) +- 逆方向(`gui/` → `core/`)は許可される +""" + +import ast +import subprocess +import sys +from pathlib import Path + +FORBIDDEN_ROOTS = {"PySide6", "PyQt5", "PyQt6", "shiboken6"} + + +def _core_source_files(repo_root: Path) -> list[Path]: + return sorted((repo_root / "src" / "tiasjudge" / "core").rglob("*.py")) + + +def _imported_roots(source_path: Path) -> set[str]: + """ファイル内の import 文から,トップレベルのモジュール名を収集する.""" + tree = ast.parse(source_path.read_text(encoding="utf-8"), filename=str(source_path)) + roots: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + roots.add(alias.name) + elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module: + roots.add(node.module) + return roots + + +def test_core_package_has_source_files(repo_root: Path) -> None: + """走査対象が 0 件だと以降のテストが空振りするため前提を確認する.""" + assert _core_source_files(repo_root), "core/ に Python ファイルが無い" + + +def test_core_does_not_import_gui_toolkit(repo_root: Path) -> None: + """静的解析: core/ 配下のどのファイルも GUI ツールキットを import しない.""" + violations: list[tuple[str, str]] = [] + for source_path in _core_source_files(repo_root): + for imported in _imported_roots(source_path): + if imported.split(".")[0] in FORBIDDEN_ROOTS: + violations.append((source_path.name, imported)) + assert violations == [] + + +def test_core_does_not_import_project_gui_package(repo_root: Path) -> None: + """静的解析: core/ が `tiasjudge.gui` に依存しない.""" + violations: list[tuple[str, str]] = [] + for source_path in _core_source_files(repo_root): + for imported in _imported_roots(source_path): + if imported.startswith("tiasjudge.gui") or imported == "gui": + violations.append((source_path.name, imported)) + assert violations == [] + + +def test_importing_core_does_not_load_gui_toolkit(repo_root: Path) -> None: + """実行時: `tiasjudge.core` を import しても GUI ツールキットがロードされない.""" + code = ( + "import sys\n" + "import tiasjudge.core\n" + "loaded = sorted(m for m in sys.modules if m.split('.')[0] in " + f"{sorted(FORBIDDEN_ROOTS)!r})\n" + "print(loaded)\n" + ) + result = subprocess.run( + [sys.executable, "-c", code], + cwd=repo_root, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + ) + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == "[]" diff --git a/tests/test_data_csv.py b/tests/test_data_csv.py new file mode 100644 index 0000000..8bf2338 --- /dev/null +++ b/tests/test_data_csv.py @@ -0,0 +1,188 @@ +"""同梱学習データ `data/data.csv` の検証(SPEC_02「データファイル形式」). + +SPEC_02 の記述は以下. + +- 1 行目: ヘッダ(`L,a,b,紫,暗赤,紅,淡紅,淡白,…` の繰り返し) +- 2 行目以降: 84 サンプル.列 1〜3 が L\\*a\\*b\\*, + 列 4 以降が 医師 × 5 色の 0/1(医師ごとに one-hot) +- 文字コードは UTF-8 に統一する(ENV_01「現行実装からの移行対応」) + +※ 医師数(SPEC_02 は 38 名)は PLAN_02 ステップ 2 で旧実装の読込コードと突き合わせて確定するため, + 本ファイルでは実データの構造から導ける不変条件を検証する. +""" + +from pathlib import Path + +import pytest + +COLOR_LABELS = ["紫", "暗赤", "紅", "淡紅", "淡白"] +EXPECTED_SAMPLE_COUNT = 84 +LAB_COLUMN_COUNT = 3 + + +class TestEncoding: + """文字コード(ENV_01: Shift-JIS → UTF-8 に統一).""" + + def test_is_valid_utf8(self, data_csv_path: Path) -> None: + data_csv_path.read_bytes().decode("utf-8") + + def test_has_no_bom(self, data_csv_path: Path) -> None: + assert not data_csv_path.read_bytes().startswith(b"\xef\xbb\xbf") + + def test_color_labels_are_readable_as_utf8(self, data_csv_rows: list[list[str]]) -> None: + """UTF-8 として読んだとき色名が文字化けせず取得できる.""" + header = data_csv_rows[0] + assert header[LAB_COLUMN_COUNT : LAB_COLUMN_COUNT + 5] == COLOR_LABELS + + +class TestHeader: + """ヘッダ行の構造.""" + + def test_first_three_columns_are_lab(self, data_csv_rows: list[list[str]]) -> None: + assert data_csv_rows[0][:LAB_COLUMN_COUNT] == ["L", "a", "b"] + + def test_evaluation_columns_are_multiple_of_five(self, data_csv_rows: list[list[str]]) -> None: + """評価列は 5 色 1 組(医師 1 名分)の繰り返しである.""" + evaluation_column_count = len(data_csv_rows[0]) - LAB_COLUMN_COUNT + assert evaluation_column_count % len(COLOR_LABELS) == 0 + assert evaluation_column_count // len(COLOR_LABELS) >= 38 + + def test_color_labels_repeat_in_fixed_order(self, data_csv_rows: list[list[str]]) -> None: + header = data_csv_rows[0] + evaluation = header[LAB_COLUMN_COUNT:] + doctor_count = len(evaluation) // len(COLOR_LABELS) + for index in range(doctor_count): + block = evaluation[index * 5 : index * 5 + 5] + assert block == COLOR_LABELS, f"医師 {index} 番目のブロックが {block}" + + +class TestShape: + """行数・列数.""" + + def test_sample_count(self, data_csv_rows: list[list[str]]) -> None: + """ヘッダを除く 84 サンプル(SPEC_02).""" + assert len(data_csv_rows) - 1 == EXPECTED_SAMPLE_COUNT + + def test_all_rows_have_same_column_count(self, data_csv_rows: list[list[str]]) -> None: + column_counts = {len(row) for row in data_csv_rows} + assert len(column_counts) == 1, f"列数が不揃い: {sorted(column_counts)}" + + def test_no_empty_cells(self, data_csv_rows: list[list[str]]) -> None: + empty = [ + (row_index, column_index) + for row_index, row in enumerate(data_csv_rows) + for column_index, cell in enumerate(row) + if cell.strip() == "" + ] + assert empty == [] + + +class TestLabColumns: + """L\\*a\\*b\\* 列(SVM の入力 3 次元).""" + + def test_all_lab_values_parse_as_float(self, data_csv_rows: list[list[str]]) -> None: + for row_index, row in enumerate(data_csv_rows[1:], start=2): + for column_index in range(LAB_COLUMN_COUNT): + cell = row[column_index] + try: + float(cell) + except ValueError: # pragma: no cover - 失敗時のみ + pytest.fail(f"{row_index} 行 {column_index + 1} 列が数値でない: {cell!r}") + + def test_lightness_is_within_lab_range(self, data_csv_rows: list[list[str]]) -> None: + """L\\* は 0〜100 の範囲に収まる(Lab 色空間の定義域).""" + values = [float(row[0]) for row in data_csv_rows[1:]] + assert all(0.0 <= value <= 100.0 for value in values), (min(values), max(values)) + + @pytest.mark.parametrize("column_index", [1, 2]) + def test_chromatic_axes_are_within_practical_range( + self, data_csv_rows: list[list[str]], column_index: int + ) -> None: + """a\\*・b\\* は舌色として現実的な範囲(|値| <= 128)に収まる.""" + values = [float(row[column_index]) for row in data_csv_rows[1:]] + assert all(-128.0 <= value <= 128.0 for value in values), (min(values), max(values)) + + def test_samples_are_not_all_identical(self, data_csv_rows: list[list[str]]) -> None: + """学習データとして意味を持つよう Lab がばらついている.""" + lab_values = {tuple(row[:LAB_COLUMN_COUNT]) for row in data_csv_rows[1:]} + assert len(lab_values) > 1 + + +class TestEvaluationColumns: + """医師評価列(SVM の教師データ).""" + + @staticmethod + def _doctor_blocks(row: list[str]) -> list[list[str]]: + evaluation = row[LAB_COLUMN_COUNT:] + return [evaluation[i : i + 5] for i in range(0, len(evaluation), 5)] + + def test_values_are_binary(self, data_csv_rows: list[list[str]]) -> None: + values = {cell for row in data_csv_rows[1:] for cell in row[LAB_COLUMN_COUNT:]} + assert values <= {"0", "1"}, f"0/1 以外の値: {sorted(values - {'0', '1'})}" + + def test_each_doctor_block_is_one_hot_or_all_zero(self, data_csv_rows: list[list[str]]) -> None: + """各医師 5 列は one-hot(未評価の医師は全 0).""" + for row_index, row in enumerate(data_csv_rows[1:], start=2): + for doctor_index, block in enumerate(self._doctor_blocks(row)): + total = sum(int(cell) for cell in block) + assert total in (0, 1), f"{row_index} 行 医師 {doctor_index} が {block}" + + def test_at_least_38_doctors_evaluated_every_sample( + self, data_csv_rows: list[list[str]] + ) -> None: + """SPEC_02 が想定する 38 名分以上の評価が全サンプルに存在する.""" + for row_index, row in enumerate(data_csv_rows[1:], start=2): + evaluated = sum( + 1 for block in self._doctor_blocks(row) if sum(int(cell) for cell in block) == 1 + ) + assert evaluated >= 38, f"{row_index} 行の有効評価が {evaluated} 名分" + + def test_every_color_appears_as_positive_example(self, data_csv_rows: list[list[str]]) -> None: + """5 色すべてに正例が存在する(SVM 学習が成立する前提).""" + positives = dict.fromkeys(COLOR_LABELS, 0) + for row in data_csv_rows[1:]: + for block in self._doctor_blocks(row): + for label, cell in zip(COLOR_LABELS, block, strict=True): + positives[label] += int(cell) + assert all(count > 0 for count in positives.values()), positives + + def test_trailing_all_zero_doctor_blocks_are_documented( + self, data_csv_rows: list[list[str]] + ) -> None: + """全サンプルで全 0 の医師ブロック(=実質未使用の列)の数を記録する. + + SPEC_02 は 医師 38 名(193 列)と記すが,実ファイルは 198 列ある. + 差分が「全サンプル全 0 の末尾ブロック」であることを固定し, + PLAN_02 ステップ 2 で旧実装の読込列数を確定する際の根拠とする. + """ + blocks = [self._doctor_blocks(row) for row in data_csv_rows[1:]] + doctor_count = len(blocks[0]) + all_zero = [ + index + for index in range(doctor_count) + if all(sum(int(cell) for cell in row[index]) == 0 for row in blocks) + ] + effective_doctor_count = doctor_count - len(all_zero) + assert effective_doctor_count == 38, ( + f"有効な医師数 {effective_doctor_count}(全 0 ブロック: {all_zero})" + ) + assert all_zero == list(range(38, doctor_count)), ( + f"全 0 ブロックが末尾以外にある: {all_zero}" + ) + + +class TestFileIntegrity: + """旧実装ファイルからの変換忠実性(ENV_04「同梱データ」).""" + + def test_numeric_content_matches_original_when_available( + self, repo_root: Path, data_csv_rows: list[list[str]] + ) -> None: + """`org/src` がある環境では,数値が旧 data.csv と完全一致する.""" + original_path = repo_root / "org" / "src" / "tonguediagnosisMFC2" / "data.csv" + if not original_path.is_file(): + pytest.skip("旧実装 org/src が無い環境のためスキップ") + original_text = original_path.read_bytes().decode("cp932") + original_rows = [line.split(",") for line in original_text.splitlines()] + assert len(original_rows) == len(data_csv_rows) + for converted, original in zip(data_csv_rows[1:], original_rows[1:], strict=True): + assert converted == original diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000..97852f7 --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,389 @@ +"""エントリポイントのコマンドライン引数の検証(ENV_04「起動」「デバッグ用」). + +対象は `tiasjudge.__main__` の引数解析と,`--preview-result` 用のダミー診断結果の +組み立て(デバッグ専用.診断・SVM 学習は行わない). + +- `--check`: 画面を構築して即終了する(既存の起動確認は `test_package_skeleton.py`) +- `--preview-result`: 結果画面だけをダミー結果で開く +- `--failed`: `--preview-result` と併用し,診断失敗パターン(旧実装の失敗時スコア組)で開く +- `--english`: `--preview-result` と併用し,英語表示で開く(結果画面は表示専用になり + 画面内で言語を切り替えられないため,プレビューの言語はコマンドラインで指定する) + +※ testing.md の方針により GUI(QWidget)そのものの自動テストは書かない.ここで画面を +生成するのは `--check` による**起動可能性の確認**に限る(Qt は conftest.py で offscreen). +""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +import pytest + +from tiasjudge.__main__ import ( + PREVIEW_FAILED_TOP_LABELS, + PREVIEW_IMAGE_RGB, + PREVIEW_IMAGE_SIZE, + PREVIEW_PERCENTAGES, + PREVIEW_TOP_LABELS, + _build_parser, + _preview_image, + _preview_result, + _run_preview, + main, +) +from tiasjudge.core.csv_io import EVALUATION_COLUMN_COUNT +from tiasjudge.core.svm import COLOR_KEYS, LEGACY_FAILURE_PERCENTAGES +from tiasjudge.gui.result_window import DISPLAY_ORDER, HIGHLIGHT_COUNT, Language, build_result_rows + +# --- 引数解析 --- + + +OPTION_FLAGS = ("check", "preview_result", "failed", "english") + + +class TestArgumentParser: + """`--check` / `--preview-result` / `--failed` / `--english` の 4 オプション(ENV_04).""" + + def test_no_options_by_default(self) -> None: + arguments = _build_parser().parse_args([]) + flags = tuple(getattr(arguments, name) for name in OPTION_FLAGS) + assert flags == (False, False, False, False) + + @pytest.mark.parametrize( + ("argv", "expected"), + [ + (["--check"], ("check",)), + (["--preview-result"], ("preview_result",)), + (["--preview-result", "--failed"], ("preview_result", "failed")), + (["--failed", "--preview-result", "--check"], ("preview_result", "failed", "check")), + (["--preview-result", "--english"], ("preview_result", "english")), + ( + ["--preview-result", "--english", "--check"], + ("preview_result", "english", "check"), + ), + ( + ["--preview-result", "--english", "--failed"], + ("preview_result", "english", "failed"), + ), + ], + ) + def test_flags_are_parsed(self, argv: list[str], expected: tuple[str, ...]) -> None: + arguments = _build_parser().parse_args(argv) + for name in OPTION_FLAGS: + assert getattr(arguments, name) is (name in expected), name + + def test_unknown_option_is_rejected(self) -> None: + """未知のオプションはエラー終了する(黙って無視しない).""" + with pytest.raises(SystemExit) as error: + _build_parser().parse_args(["--unknown"]) + assert error.value.code == 2 + + @pytest.mark.parametrize("option", ["--check", "--preview-result", "--failed", "--english"]) + def test_help_lists_the_option(self, option: str) -> None: + assert option in _build_parser().format_help() + + +# --- プレビュー用のダミーデータ(デバッグ専用) --- + + +class TestPreviewImage: + """ダミー画像(舌画像の代わり).""" + + def test_shape_and_dtype(self) -> None: + image = _preview_image() + assert image.shape == (PREVIEW_IMAGE_SIZE, PREVIEW_IMAGE_SIZE, 3) + assert image.dtype.name == "uint8" + + def test_is_stored_in_bgr_order(self) -> None: + """結果画面は BGR 前提で受け取るため,BGR 並びで埋める.""" + red, green, blue = PREVIEW_IMAGE_RGB + assert tuple(_preview_image()[0, 0]) == (blue, green, red) + + +class TestPreviewResult: + """ダミー診断結果が `DiagnosisResult` として妥当であること.""" + + @pytest.mark.parametrize("failed", [False, True]) + def test_scores_cover_all_color_keys(self, failed: bool) -> None: + result = _preview_result(failed=failed) + assert set(result.scores) == set(COLOR_KEYS) + assert set(result.percentages) == set(COLOR_KEYS) + + @pytest.mark.parametrize("failed", [False, True]) + def test_scores_are_percentages_divided_by_100(self, failed: bool) -> None: + result = _preview_result(failed=failed) + for key, percent in result.percentages.items(): + assert result.scores[key] == pytest.approx(percent / 100) + assert 0.0 <= result.scores[key] <= 1.0 + + @pytest.mark.parametrize("failed", [False, True]) + def test_top_labels_are_two_known_colors(self, failed: bool) -> None: + result = _preview_result(failed=failed) + assert len(result.top_labels) == HIGHLIGHT_COUNT + assert len(set(result.top_labels)) == HIGHLIGHT_COUNT + assert set(result.top_labels) <= set(COLOR_KEYS) + + @pytest.mark.parametrize("failed", [False, True]) + def test_top_labels_are_the_two_highest_scores(self, failed: bool) -> None: + """上位 2 色はスコア降順の先頭 2 件(表示と矛盾しないダミーであること).""" + result = _preview_result(failed=failed) + ranked = sorted(result.percentages, key=lambda key: result.percentages[key], reverse=True) + assert set(result.top_labels) == set(ranked[:HIGHLIGHT_COUNT]) + + @pytest.mark.parametrize("failed", [False, True]) + def test_predictions_have_the_full_column_count(self, failed: bool) -> None: + result = _preview_result(failed=failed) + assert result.predictions.shape == (EVALUATION_COLUMN_COUNT,) + + def test_success_pattern(self) -> None: + """成功パターンは `PREVIEW_PERCENTAGES`・失敗フラグなし(ENV_04「デバッグ用」).""" + result = _preview_result(failed=False) + assert result.percentages == PREVIEW_PERCENTAGES + assert result.top_labels == PREVIEW_TOP_LABELS + assert result.failed is False + assert result.failure_reason is None + + def test_success_pattern_totals_100_percent(self) -> None: + assert sum(PREVIEW_PERCENTAGES.values()) == 100 + + def test_failed_pattern_uses_the_legacy_failure_scores(self) -> None: + """失敗パターンは旧実装の失敗時スコア組(`LEGACY_FAILURE_PERCENTAGES`).""" + result = _preview_result(failed=True) + assert result.percentages == LEGACY_FAILURE_PERCENTAGES + assert result.top_labels == PREVIEW_FAILED_TOP_LABELS + assert result.failed is True + assert result.failure_reason is not None + + def test_failed_top_labels_follow_the_legacy_scores(self) -> None: + """失敗パターンの上位 2 色は旧スコア組を降順に並べた先頭 2 件(紫・淡紅).""" + ranked = sorted( + LEGACY_FAILURE_PERCENTAGES, + key=lambda key: LEGACY_FAILURE_PERCENTAGES[key], + reverse=True, + ) + assert set(PREVIEW_FAILED_TOP_LABELS) == set(ranked[:HIGHLIGHT_COUNT]) + + def test_does_not_share_the_legacy_score_dictionary(self) -> None: + """モジュール定数を書き換えない(コピーを持つ).""" + result = _preview_result(failed=True) + assert result.percentages is not LEGACY_FAILURE_PERCENTAGES + result = _preview_result(failed=False) + assert result.percentages is not PREVIEW_PERCENTAGES + + @pytest.mark.parametrize("failed", [False, True]) + @pytest.mark.parametrize("language", list(Language)) + def test_rows_can_be_built_from_the_dummy_result( + self, failed: bool, language: Language + ) -> None: + """ダミー結果から結果画面の 5 行を組み立てられる(表示可能な形であること).""" + rows = build_result_rows(_preview_result(failed=failed), language) + assert tuple(row.key for row in rows) == DISPLAY_ORDER + assert len({row.key for row in rows if row.highlighted}) == HIGHLIGHT_COUNT + + +# --- 起動(ENV_04「デバッグ用」) --- + + +class TestPreviewLaunch: + """`--preview-result` の起動確認(`--check` で画面を構築して即終了する).""" + + @pytest.mark.parametrize( + "argv", + [ + ["--check"], + ["--preview-result", "--check"], + ["--preview-result", "--failed", "--check"], + ["--preview-result", "--english", "--check"], + ["--preview-result", "--english", "--failed", "--check"], + ], + ) + def test_check_exits_successfully(self, argv: list[str]) -> None: + assert main(argv) == 0 + + def test_preview_does_not_build_the_main_window(self, monkeypatch: pytest.MonkeyPatch) -> None: + """プレビューはメイン画面を作らない(結果画面だけを開く).""" + import tiasjudge.gui.main_window as main_window + + def fail(*args: object, **kwargs: object) -> None: + raise AssertionError("プレビューではメイン画面を作ってはならない") + + monkeypatch.setattr(main_window, "MainWindow", fail) + assert main(["--preview-result", "--check"]) == 0 + + @pytest.mark.parametrize( + ("options", "expected_language"), + [([], Language.JAPANESE), (["--english"], Language.ENGLISH)], + ids=["default_japanese", "english_option"], + ) + @pytest.mark.parametrize("failed", [False, True]) + def test_preview_opens_the_result_window_with_the_requested_language( + self, + monkeypatch: pytest.MonkeyPatch, + failed: bool, + options: list[str], + expected_language: Language, + ) -> None: + """結果画面にダミー結果・ダミー画像・指定言語が渡される(ENV_04「デバッグ用」). + + 既定は日本語,`--english` を付けると英語で開く(`--failed` との併用も可). + """ + import tiasjudge.gui.result_window as result_window + + captured: dict[str, object] = {} + + class FakeResultWindow: + def __init__(self, result: object, image: object, language: object) -> None: + captured.update(result=result, image=image, language=language) + + def close(self) -> None: + captured["closed"] = True + + monkeypatch.setattr(result_window, "ResultWindow", FakeResultWindow) + argv = ["--preview-result", "--check", *options] + (["--failed"] if failed else []) + assert main(argv) == 0 + + assert captured["language"] is expected_language + assert captured["closed"] is True + result = captured["result"] + assert result.failed is failed # type: ignore[union-attr] + assert result.percentages == _preview_result(failed=failed).percentages # type: ignore[union-attr] + image = captured["image"] + assert image.shape == (PREVIEW_IMAGE_SIZE, PREVIEW_IMAGE_SIZE, 3) # type: ignore[union-attr] + + def test_module_invocation_with_preview_exits_successfully( + self, repo_root: Path, headless_env: dict[str, str] + ) -> None: + """`python -m tiasjudge --preview-result --check` が正常終了する(ENV_04 の記載どおり).""" + result = subprocess.run( + [sys.executable, "-m", "tiasjudge", "--preview-result", "--check"], + cwd=repo_root, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + env=headless_env, + timeout=120, + ) + assert result.returncode == 0, result.stderr + + +# --- 表示先モニタ(プレビュー) --- + + +class TestRunPreview: + """`_run_preview`(`--preview-result` の本体)のモニタ選択・終了コード. + + ※ `QApplication` はスタブを渡す(イベントループを回さずに検証するため). + 画面の見た目は人間が確認する(testing.md「GUI の自動テストは書かない」). + """ + + class FakeApp: + """`QApplication` の代わり(`screens` / `primaryScreen` / `exec` のみ).""" + + def __init__(self, screens: list[object], exit_code: int = 0) -> None: + self._screens = screens + self._exit_code = exit_code + self.exec_called = False + + def screens(self) -> list[object]: + return self._screens + + def primaryScreen(self) -> object | None: # noqa: N802 - Qt の API 名に合わせる + return self._screens[0] if self._screens else None + + def exec(self) -> int: + self.exec_called = True + return self._exit_code + + @pytest.fixture + def fake_result_window(self, monkeypatch: pytest.MonkeyPatch) -> dict[str, object]: + """`ResultWindow` をスタブに差し替え,生成・close を記録する.""" + import tiasjudge.gui.result_window as result_window + + captured: dict[str, object] = {"closed": False} + + class FakeResultWindow: + def __init__(self, result: object, image: object, language: object) -> None: + captured.update(result=result, image=image, language=language, window=self) + + def close(self) -> None: + captured["closed"] = True + + monkeypatch.setattr(result_window, "ResultWindow", FakeResultWindow) + return captured + + def test_check_returns_zero_without_selecting_a_screen( + self, monkeypatch: pytest.MonkeyPatch, fake_result_window: dict[str, object] + ) -> None: + """`--check` 併用時は画面を構築して閉じるだけ(最大化もイベントループも行わない).""" + import tiasjudge.gui.result_window as result_window + + def fail(*args: object, **kwargs: object) -> bool: + raise AssertionError("--check では表示先モニタを選んではならない") + + monkeypatch.setattr(result_window, "show_on_secondary_screen", fail) + app = TestRunPreview.FakeApp([object()]) + + assert _run_preview(app, failed=False, check=True) == 0 # type: ignore[arg-type] + assert fake_result_window["closed"] is True + assert app.exec_called is False + + def test_uses_the_primary_screen_as_the_reference( + self, monkeypatch: pytest.MonkeyPatch, fake_result_window: dict[str, object] + ) -> None: + """プレビューにはメイン画面が無いため,基準はプライマリモニタ.""" + import tiasjudge.gui.result_window as result_window + + captured: dict[str, object] = {} + + def record(window: object, screens: object, reference: object) -> bool: + captured.update(window=window, screens=screens, reference=reference) + return True + + monkeypatch.setattr(result_window, "show_on_secondary_screen", record) + screens = [object(), object()] + app = TestRunPreview.FakeApp(screens, exit_code=0) + + assert _run_preview(app, failed=False, check=False) == 0 # type: ignore[arg-type] + assert captured["window"] is fake_result_window["window"] + assert list(captured["screens"]) == screens # type: ignore[call-overload] + assert captured["reference"] is screens[0] + assert app.exec_called is True + + def test_returns_the_event_loop_exit_code( + self, monkeypatch: pytest.MonkeyPatch, fake_result_window: dict[str, object] + ) -> None: + """イベントループの終了コードをそのまま返す.""" + import tiasjudge.gui.result_window as result_window + + monkeypatch.setattr( + result_window, "show_on_secondary_screen", lambda *args, **kwargs: False + ) + app = TestRunPreview.FakeApp([object()], exit_code=3) + + assert _run_preview(app, failed=True, check=False) == 3 # type: ignore[arg-type] + assert fake_result_window["language"] is Language.JAPANESE + assert fake_result_window["result"].failed is True # type: ignore[union-attr] + + def test_language_defaults_to_japanese(self, fake_result_window: dict[str, object]) -> None: + """`english` を省略した呼び出しは日本語(既定値).""" + app = TestRunPreview.FakeApp([object()]) + assert _run_preview(app, failed=False, check=True) == 0 # type: ignore[arg-type] + assert fake_result_window["language"] is Language.JAPANESE + + @pytest.mark.parametrize( + ("english", "expected_language"), + [(False, Language.JAPANESE), (True, Language.ENGLISH)], + ) + def test_english_option_selects_the_language( + self, + fake_result_window: dict[str, object], + english: bool, + expected_language: Language, + ) -> None: + """結果画面は表示専用のため,プレビューの言語は `--english` で決まる.""" + app = TestRunPreview.FakeApp([object()]) + assert _run_preview(app, failed=False, english=english, check=True) == 0 # type: ignore[arg-type] + assert fake_result_window["language"] is expected_language diff --git a/tests/test_package_skeleton.py b/tests/test_package_skeleton.py new file mode 100644 index 0000000..44fd1a0 --- /dev/null +++ b/tests/test_package_skeleton.py @@ -0,0 +1,121 @@ +"""パッケージ骨格の検証(PLAN_02 ステップ 1 / project-structure.md). + +仕様上要求されるのは以下. + +- `src/tiasjudge/` の src レイアウト.配下に `core/`(診断ロジック)と `gui/`(PySide6 画面) +- `python -m tiasjudge` で起動できるエントリポイント(`__main__.py`) +- 同梱データ `data/` と テスト `tests/` +""" + +import importlib +import subprocess +import sys +from pathlib import Path + +import pytest + + +class TestDirectoryLayout: + """ディレクトリ構成(project-structure.md).""" + + @pytest.mark.parametrize( + "relative_path", + [ + "src/tiasjudge", + "src/tiasjudge/core", + "src/tiasjudge/gui", + "data", + "tests", + "docs", + ], + ) + def test_required_directories_exist(self, repo_root: Path, relative_path: str) -> None: + assert (repo_root / relative_path).is_dir() + + @pytest.mark.parametrize( + "relative_path", + [ + "pyproject.toml", + "src/tiasjudge/__init__.py", + "src/tiasjudge/__main__.py", + "src/tiasjudge/core/__init__.py", + "src/tiasjudge/gui/__init__.py", + "data/data.csv", + "docs/02_ENV/ENV_04_開発コマンド.md", + ], + ) + def test_required_files_exist(self, repo_root: Path, relative_path: str) -> None: + assert (repo_root / relative_path).is_file() + + def test_org_is_not_tracked_by_git(self, repo_root: Path) -> None: + """旧実装 `org/` は参照専用で Git 管理外(project-structure.md).""" + result = subprocess.run( + ["git", "ls-files", "org"], + cwd=repo_root, + capture_output=True, + text=True, + check=True, + ) + assert result.stdout.strip() == "" + + def test_data_csv_is_tracked_or_stageable(self, repo_root: Path) -> None: + """同梱データ `data/data.csv` は Git 管理対象(無視されていない).""" + result = subprocess.run( + ["git", "check-ignore", "data/data.csv"], + cwd=repo_root, + capture_output=True, + text=True, + ) + assert result.returncode != 0, "data/data.csv が .gitignore で除外されている" + + +class TestImportablePackages: + """パッケージが import できること.""" + + @pytest.mark.parametrize( + "module_name", + ["tiasjudge", "tiasjudge.core", "tiasjudge.gui", "tiasjudge.__main__"], + ) + def test_import(self, module_name: str) -> None: + assert importlib.import_module(module_name) is not None + + def test_package_is_installed_from_src_layout(self, repo_root: Path) -> None: + """import されるパッケージが `src/tiasjudge` であること(editable install).""" + module = importlib.import_module("tiasjudge") + assert module.__file__ is not None + assert Path(module.__file__).resolve().parent == (repo_root / "src" / "tiasjudge").resolve() + + def test_version_is_defined(self) -> None: + module = importlib.import_module("tiasjudge") + assert isinstance(module.__version__, str) + assert module.__version__ != "" + + +class TestEntryPoint: + """エントリポイント(ENV_04「起動」). + + メイン画面(SPEC_01)実装後の `main()` は引数なしだとイベントループに入り戻らないため, + 起動可能性の確認には `--check`(画面を構築して即終了)を使う(ENV_04「起動」). + Qt はヘッドレスで動かす(`QT_QPA_PLATFORM=offscreen`.conftest.py で既定設定). + """ + + def test_main_is_callable_and_returns_exit_code(self) -> None: + main_module = importlib.import_module("tiasjudge.__main__") + assert callable(main_module.main) + assert main_module.main(["--check"]) == 0 + + def test_python_m_tiasjudge_check_exits_successfully( + self, repo_root: Path, headless_env: dict[str, str] + ) -> None: + """`python -m tiasjudge --check` が正常終了する(起動可能性の確認).""" + result = subprocess.run( + [sys.executable, "-m", "tiasjudge", "--check"], + cwd=repo_root, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + env=headless_env, + timeout=120, + ) + assert result.returncode == 0, result.stderr diff --git a/tests/test_pyproject_config.py b/tests/test_pyproject_config.py new file mode 100644 index 0000000..35820ba --- /dev/null +++ b/tests/test_pyproject_config.py @@ -0,0 +1,87 @@ +"""`pyproject.toml` の設定検証(PLAN_02 ステップ 1 / ENV_01 技術スタック). + +ステップ 1 の完了条件は「`pyproject.toml`(uv・Ruff・pytest 設定)」であり, +採用技術は ENV_01 のとおり Python 3.12・PySide6・opencv-python(4.x)・NumPy・pytest・Ruff. +""" + +import tomllib +from pathlib import Path + +import pytest + + +@pytest.fixture(scope="session") +def pyproject(repo_root: Path) -> dict: + return tomllib.loads((repo_root / "pyproject.toml").read_text(encoding="utf-8")) + + +class TestProjectMetadata: + def test_project_name(self, pyproject: dict) -> None: + assert pyproject["project"]["name"] == "tiasjudge" + + def test_requires_python_312(self, pyproject: dict) -> None: + """ENV_01: 言語は Python 3.12.""" + assert ">=3.12" in pyproject["project"]["requires-python"] + + def test_python_version_file_pins_312(self, repo_root: Path) -> None: + version_file = repo_root / ".python-version" + assert version_file.is_file() + assert version_file.read_text(encoding="utf-8").strip().startswith("3.12") + + @pytest.mark.parametrize("package", ["numpy", "opencv-python", "pyside6"]) + def test_runtime_dependencies_declared(self, pyproject: dict, package: str) -> None: + declared = [ + dependency.split(">")[0].split("<")[0].split("=")[0].strip().lower() + for dependency in pyproject["project"]["dependencies"] + ] + assert package in declared + + def test_opencv_is_pinned_to_major_version_4(self, pyproject: dict) -> None: + """ENV_01: `cv2.ml.SVM` の挙動同一性のため OpenCV 4.x 系に固定する.""" + opencv = next( + dependency + for dependency in pyproject["project"]["dependencies"] + if dependency.lower().startswith("opencv-python") + ) + assert "<5" in opencv.replace(" ", "") + + @pytest.mark.parametrize("package", ["pytest", "ruff"]) + def test_dev_dependencies_declared(self, pyproject: dict, package: str) -> None: + dev = [ + dependency.split(">")[0].split("<")[0].split("=")[0].strip().lower() + for dependency in pyproject["dependency-groups"]["dev"] + ] + assert package in dev + + +class TestToolConfiguration: + def test_pytest_testpaths_points_to_tests(self, pyproject: dict) -> None: + assert pyproject["tool"]["pytest"]["ini_options"]["testpaths"] == ["tests"] + + def test_ruff_targets_py312(self, pyproject: dict) -> None: + assert pyproject["tool"]["ruff"]["target-version"] == "py312" + + def test_ruff_lint_rules_selected(self, pyproject: dict) -> None: + selected = set(pyproject["tool"]["ruff"]["lint"]["select"]) + assert {"E", "F", "I"} <= selected + + def test_wheel_packages_use_src_layout(self, pyproject: dict) -> None: + packages = pyproject["tool"]["hatch"]["build"]["targets"]["wheel"]["packages"] + assert packages == ["src/tiasjudge"] + + +class TestLockFile: + def test_uv_lock_exists(self, repo_root: Path) -> None: + """ENV_01: 依存の完全な再現のため `uv.lock` を管理する.""" + assert (repo_root / "uv.lock").is_file() + + def test_uv_lock_is_not_git_ignored(self, repo_root: Path) -> None: + import subprocess + + result = subprocess.run( + ["git", "check-ignore", "uv.lock"], + cwd=repo_root, + capture_output=True, + text=True, + ) + assert result.returncode != 0, "uv.lock が .gitignore で除外されている" diff --git a/tests/test_regression.py b/tests/test_regression.py new file mode 100644 index 0000000..b2c0341 --- /dev/null +++ b/tests/test_regression.py @@ -0,0 +1,255 @@ +"""旧実装 exe との回帰テスト(PLAN_02 ステップ 5). + +`tests/fixtures/regression/` に置かれた «旧 exe で採取した入出力ペア» を読み, +新実装のパイプライン(`color.rgb_to_lab` → `svm.diagnose`)の出力と照合する. + +フィクスチャ CSV が未採取(ファイルが無い・データ行が 0)の間はテストを skip する. +CSV を置いた瞬間に有効化される.形式は `tests/fixtures/regression/README.md`, +採取手順は `docs/06_TEST/TEST_01_回帰テスト採取手順.md` を参照. + +照合の許容誤差(README と同じ根拠): + +- L*a*b*: 旧 exe は `Format("%3.0lf", ...)` で整数しか表示しないため,絶対誤差 0.51 以内 +- 5 色スコア(%): 表示値どうしの整数比較(`k / 38 × 100` は x.5 にならないため厳密比較で安全) +- 診断失敗の有無: 完全一致 +""" + +from __future__ import annotations + +import csv +from collections.abc import Callable, Iterable +from pathlib import Path + +import numpy as np +import pytest +from numpy.typing import NDArray + +from tiasjudge.core.color import rgb_to_lab +from tiasjudge.core.csv_io import TrainingData, load_training_data, load_transform_matrix +from tiasjudge.core.svm import COLOR_KEYS, diagnose + +REPO_ROOT = Path(__file__).resolve().parents[1] +FIXTURE_DIR = Path(__file__).resolve().parent / "fixtures" / "regression" +MATRIX_DIR = FIXTURE_DIR / "matrices" +FORMAT_DOC = FIXTURE_DIR / "README.md" +CAPTURE_DOC = "docs/06_TEST/TEST_01_回帰テスト採取手順.md" + +RGB_TO_LAB_CSV = FIXTURE_DIR / "rgb_to_lab.csv" +RGB_TO_SCORES_CSV = FIXTURE_DIR / "rgb_to_scores.csv" +LAB_TO_SCORES_CSV = FIXTURE_DIR / "lab_to_scores.csv" + +#: L*a*b* の許容誤差(旧 exe の表示丸め幅 0.5 + 微小マージン) +LAB_DISPLAY_TOLERANCE = 0.51 + +#: 真偽値として受け付ける表記(`failed` 列) +_TRUE_WORDS = frozenset({"yes", "y", "true", "1", "○"}) +_FALSE_WORDS = frozenset({"", "no", "n", "false", "0", "-", "×"}) + + +# --- フィクスチャ読込 (Fixture Loading) --- + + +def _read_fixture_rows(path: Path) -> list[dict[str, str]]: + """回帰フィクスチャ CSV を読む(未採取なら空リスト). + + `#` で始まる行と空行は無視する.値は前後の空白を除いて返す. + """ + if not path.exists(): + return [] + lines = [ + line + for line in path.read_text(encoding="utf-8-sig").splitlines() + if line.strip() and not line.lstrip().startswith("#") + ] + reader = csv.DictReader(lines) + return [ + {(key or "").strip(): (value or "").strip() for key, value in row.items()} for row in reader + ] + + +def _skip_reason(path: Path) -> str: + """未採取時の skip 理由(採取手順への参照を含む)を組み立てる.""" + return ( + f"回帰フィクスチャ未採取: {path.relative_to(REPO_ROOT).as_posix()} が無い" + f"(または データ行が 0).旧 exe での採取手順は {CAPTURE_DOC}," + f"CSV の形式は {FORMAT_DOC.relative_to(REPO_ROOT).as_posix()} を参照" + ) + + +def _cases(path: Path, id_columns: Iterable[str]) -> list[object]: + """CSV の各行を parametrize 用のパラメータに変換する. + + 未採取の場合は «skip マーク付きの 1 件» を返し,理由に採取手順書を示す. + """ + rows = _read_fixture_rows(path) + if not rows: + return [pytest.param(None, marks=pytest.mark.skip(reason=_skip_reason(path)), id="未採取")] + columns = tuple(id_columns) + return [ + pytest.param(row, id="-".join(row.get(column, "?") for column in columns) + f"#{index}") + for index, row in enumerate(rows, start=1) + ] + + +# --- 値の取り出し (Value Access) --- + + +def _value(row: dict[str, str], column: str, path: Path) -> str: + """必須列の値を取り出す(列が無ければ形式エラーとして失敗させる).""" + if column not in row: + pytest.fail( + f"{path.name} に列 `{column}` がありません(列: {sorted(row)})." + f"形式は {FORMAT_DOC.name} を参照" + ) + return row[column] + + +def _number(row: dict[str, str], column: str, path: Path) -> float: + """必須列を実数として取り出す.""" + text = _value(row, column, path) + try: + return float(text) + except ValueError: + pytest.fail(f"{path.name} の列 `{column}` が数値ではありません: {text!r}") + + +def _percent(row: dict[str, str], column: str, path: Path) -> int: + """スコア列(%)を整数として取り出す.""" + value = _number(row, column, path) + if value != int(value): + pytest.fail( + f"{path.name} の列 `{column}` は整数(旧 exe の表示値)である必要があります: {value}" + ) + return int(value) + + +def _flag(row: dict[str, str], column: str, path: Path) -> bool: + """`failed` 列を真偽値として取り出す(列が無い・空欄なら False).""" + text = row.get(column, "").lower() + if text in _TRUE_WORDS: + return True + if text in _FALSE_WORDS: + return False + pytest.fail(f"{path.name} の列 `{column}` は yes / no で記入してください: {text!r}") + + +def _expected_percentages(row: dict[str, str], path: Path) -> dict[str, int]: + """5 色スコアの期待値(%)をまとめて取り出す.""" + return {key: _percent(row, key, path) for key in COLOR_KEYS} + + +# --- pytest フィクスチャ (Pytest Fixtures) --- + + +@pytest.fixture(scope="session") +def training_data(data_csv_path: Path) -> TrainingData: + """同梱の学習データ(`data/data.csv`)を読み込む.""" + return load_training_data(data_csv_path) + + +@pytest.fixture(scope="session") +def transform_matrix() -> Callable[[str], NDArray[np.float64]]: + """`matrices/` の変換行列 CSV を名前で読み込む関数を返す(読込結果を再利用する).""" + cache: dict[str, NDArray[np.float64]] = {} + + def load(name: str) -> NDArray[np.float64]: + if name not in cache: + path = MATRIX_DIR / name + if not path.exists(): + pytest.fail( + f"変換行列 CSV がありません: {path}." + f"採取に使った変換行列を {MATRIX_DIR.name}/ に置いてください" + f"({FORMAT_DOC.name} 参照)" + ) + cache[name] = load_transform_matrix(path) + return cache[name] + + return load + + +# --- 器の健全性 (Scaffold Sanity) --- + + +def test_fixture_scaffold_exists() -> None: + """フィクスチャの器(ディレクトリ・形式定義・変換行列置き場)が揃っている.""" + assert FIXTURE_DIR.is_dir(), f"回帰フィクスチャのディレクトリがありません: {FIXTURE_DIR}" + assert FORMAT_DOC.is_file(), f"フィクスチャ形式定義がありません: {FORMAT_DOC}" + assert MATRIX_DIR.is_dir(), f"変換行列の置き場がありません: {MATRIX_DIR}" + + +# --- ① RGB → L*a*b* --- + + +@pytest.mark.parametrize("row", _cases(RGB_TO_LAB_CSV, ("r", "g", "b"))) +def test_rgb_to_lab_matches_legacy( + row: dict[str, str], transform_matrix: Callable[[str], NDArray[np.float64]] +) -> None: + """旧 exe が表示した L*a*b* と新実装の色変換が一致する(SPEC_02 (1)(2)).""" + path = RGB_TO_LAB_CSV + coefficients = transform_matrix(_value(row, "matrix", path)) + rgb = (_number(row, "r", path), _number(row, "g", path), _number(row, "b", path)) + + lab = rgb_to_lab(rgb, coefficients) + + actual = {"l_star": lab.l_star, "a_star": lab.a_star, "b_star": lab.b_star} + for column, value in actual.items(): + expected = _number(row, column, path) + assert abs(value - expected) <= LAB_DISPLAY_TOLERANCE, ( + f"RGB={rgb} の {column} が旧実装と一致しません: " + f"旧 exe 表示={expected}・新実装={value}(許容 {LAB_DISPLAY_TOLERANCE})" + ) + + +# --- ② RGB → 5 色スコア(パイプライン全体)--- + + +@pytest.mark.parametrize("row", _cases(RGB_TO_SCORES_CSV, ("r", "g", "b"))) +def test_pipeline_scores_match_legacy( + row: dict[str, str], + transform_matrix: Callable[[str], NDArray[np.float64]], + training_data: TrainingData, +) -> None: + """RGB → Lab → SVM のパイプライン出力が旧 exe の 5 色スコアと一致する(SPEC_02).""" + path = RGB_TO_SCORES_CSV + coefficients = transform_matrix(_value(row, "matrix", path)) + rgb = (_number(row, "r", path), _number(row, "g", path), _number(row, "b", path)) + + result = diagnose(rgb_to_lab(rgb, coefficients), training_data) + + expected = _expected_percentages(row, path) + assert result.percentages == expected, ( + f"RGB={rgb} の 5 色スコアが旧実装と一致しません: " + f"旧 exe 表示={expected}・新実装={result.percentages}(Lab={result.lab})" + ) + assert result.failed == _flag(row, "failed", path), ( + f"RGB={rgb} の診断失敗判定が旧実装と一致しません: " + f"旧 exe={_flag(row, 'failed', path)}・新実装={result.failed}" + f"(理由: {result.failure_reason})" + ) + + +# --- ③ L*a*b* → 5 色スコア(任意)--- + + +@pytest.mark.parametrize("row", _cases(LAB_TO_SCORES_CSV, ("l_star", "a_star", "b_star"))) +def test_lab_to_scores_match_legacy(row: dict[str, str], training_data: TrainingData) -> None: + """厳密な L*a*b* を入力にしたときの 5 色スコアが旧実装と一致する(SPEC_02 (3)).""" + path = LAB_TO_SCORES_CSV + lab = ( + _number(row, "l_star", path), + _number(row, "a_star", path), + _number(row, "b_star", path), + ) + + result = diagnose(lab, training_data) + + expected = _expected_percentages(row, path) + assert result.percentages == expected, ( + f"Lab={lab} の 5 色スコアが旧実装と一致しません: " + f"旧 exe 表示={expected}・新実装={result.percentages}" + ) + assert result.failed == _flag(row, "failed", path), ( + f"Lab={lab} の診断失敗判定が旧実装と一致しません: " + f"旧 exe={_flag(row, 'failed', path)}・新実装={result.failed}" + f"(理由: {result.failure_reason})" + ) diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..a76bf88 --- /dev/null +++ b/uv.lock @@ -0,0 +1,216 @@ +version = 1 +revision = 3 +requires-python = "==3.12.*" + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "numpy" +version = "2.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/fd/89965aa4ac08c74998539fcbf24fa3540f3e15237fbeb6bcf9c908f4aade/numpy-2.5.1.tar.gz", hash = "sha256:a48a113e6afea91f5608793bafa7ef2ad481fefbda87ec5069f483de61cb9fa3", size = 20755553, upload-time = "2026-07-04T17:08:00.933Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/7b/14687aa674250e5e546f616f486b0d56d3631cd5b2415739141ce40bdcea/numpy-2.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2c889b56fe48b1018f764b0eec8df59ab654e9148aa91faa12596043500de277", size = 16801574, upload-time = "2026-07-04T17:06:12.423Z" }, + { url = "https://files.pythonhosted.org/packages/e1/19/cc5bb2a3f2913d27d6dbb2c78d25921fabaedc6741d4a5a615a11f3c5bf3/numpy-2.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab451b59c5643c570974c43aef780703ef1d3b4965d2be07afd530615a9358d1", size = 11772250, upload-time = "2026-07-04T17:06:15.726Z" }, + { url = "https://files.pythonhosted.org/packages/42/77/fdf34a71dd30f54979b18603bee915e0aaf825b07afe79acd60b04b691e2/numpy-2.5.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:78798bd5b9ad744056af8efa90e3b9ddaa53272a0848a483084a1cc0a13b2dc0", size = 5331516, upload-time = "2026-07-04T17:06:17.913Z" }, + { url = "https://files.pythonhosted.org/packages/ce/e2/eb7efa015b4cce41e2517bf182a7fce0d7d5b9d9ed76a29bfa0f4fe4505c/numpy-2.5.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:2ae0ca40bcb22d6ba59c1dfd5446f49940b0f2d821fde133f10dda11f816b84e", size = 6664863, upload-time = "2026-07-04T17:06:20.02Z" }, + { url = "https://files.pythonhosted.org/packages/a9/4b/a2b32dd94ee9ffbeecb28152240042a3949db33b1c834d44090b80e1b3b8/numpy-2.5.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:61ac47e772e6b8ea489e1d2f441a34c5c3ac17327e7ce294cbdf535795ad4e75", size = 15167977, upload-time = "2026-07-04T17:06:21.621Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a9/6e73d68500f80773f65f0654ea932019d6694329a0eb0ed0533de38df376/numpy-2.5.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:59fda5e192b570217ec2580c96f00e9a7e12ef6866a900eb089b62c1a32545ca", size = 16672469, upload-time = "2026-07-04T17:06:24.064Z" }, + { url = "https://files.pythonhosted.org/packages/24/7d/ad3e59015135f5261c95fd4cafeff159c955febd83a99a1d9250c4233815/numpy-2.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f7119ebff1a9829e9f431a4f9d28e703023bb6b9fe7c8f724467dbfc27c94ab3", size = 16527531, upload-time = "2026-07-04T17:06:26.69Z" }, + { url = "https://files.pythonhosted.org/packages/83/d0/a39b2fbcde9cb17a1dac678f254b33a6336298af9df338824c685425d5e8/numpy-2.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e824c2acf8862052246be5a44c15da1777940c60d010dd2aab897824d9c430f9", size = 18431940, upload-time = "2026-07-04T17:06:29.521Z" }, + { url = "https://files.pythonhosted.org/packages/04/12/cff070947791c1ed425ff76413189adbdc2fbe215eba7ce7fa454a03c7f8/numpy-2.5.1-cp312-cp312-win32.whl", hash = "sha256:08d60c810432eb83360958dea0999ac4cfb94531ea8efcbf0b7f277c2068aeb2", size = 6066764, upload-time = "2026-07-04T17:06:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/65/66/53f31807a48a750f9d748da273bc3fcedd12b27ff1f3e373bfec55ef2dc0/numpy-2.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:f7d60026c0bdb1380e83bfa7a0419c4577ee4b9a08880afcb6dadeb74c649fa2", size = 12430966, upload-time = "2026-07-04T17:06:34.926Z" }, + { url = "https://files.pythonhosted.org/packages/2b/2a/d1a88066b1c14186f5d3c0d18c94f17b064511982bab0578d49ee9d43c29/numpy-2.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:17a25e09640602e10bc8de0e6fa2b3fd68eedd84ba6d7842dc8f32f9ab87bd0b", size = 10350488, upload-time = "2026-07-04T17:06:37.785Z" }, +] + +[[package]] +name = "opencv-python" +version = "4.14.0.94" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ed/e8/c2959c0f969ea4ae96b55ad10c108c0410de4e8b3550d9941a4cfdd52703/opencv_python-4.14.0.94.tar.gz", hash = "sha256:afadc76a38a07713a4c2b4702da917cf0ed489be57e2a40a34e670189731de7d", size = 96400225, upload-time = "2026-07-28T19:23:09.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/91/e4fd7c2b64fda9792bc1f5dcb5a6cbdb54d50c3a2fa9dccc3943fd47afcf/opencv_python-4.14.0.94-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:04456937c8f12ca64ff94df8bfd813ca98f5c9767da792948969a759c716ebe2", size = 46491087, upload-time = "2026-07-29T03:52:01.842Z" }, + { url = "https://files.pythonhosted.org/packages/f4/a7/8895ca8731f741925acf238f87ccb21fa8f47378d5291792e932b9a4ab71/opencv_python-4.14.0.94-cp37-abi3-macosx_14_0_x86_64.whl", hash = "sha256:6a49842cc0ba9f43e35a289e61c0e15a1f8be8df60145ee5c7d42593b0d4f4b8", size = 33084035, upload-time = "2026-07-28T20:05:52.633Z" }, + { url = "https://files.pythonhosted.org/packages/84/ec/5777752567986b99222d52e22306e9616829480cc2d48ab241daa9d7e9ef/opencv_python-4.14.0.94-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3198fcbcb8f98fd68063bf2c75d129b0f50d46e555a4e47578a6eb086eb38e1f", size = 49513397, upload-time = "2026-07-28T19:17:28.309Z" }, + { url = "https://files.pythonhosted.org/packages/13/09/c05887c383caa9080ef612febffd08c5da1c283418687a3bea6ef7fb6faa/opencv_python-4.14.0.94-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4ae0b5a2acbcfb9813035f1644cfba751edd02797fac994e32180813eeef8420", size = 72049625, upload-time = "2026-07-28T19:18:11.767Z" }, + { url = "https://files.pythonhosted.org/packages/ed/0b/df7ac73ed46258d569e4334c03b42515fbc2f901d40831e64414ac0da66a/opencv_python-4.14.0.94-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:add600736d0d65a39e797a9ceeb494f3cdc5736d4bd96b4f89d65a8c92451528", size = 53432460, upload-time = "2026-07-28T19:18:43.878Z" }, + { url = "https://files.pythonhosted.org/packages/8f/96/97f176047c56726a61ddd02300b45b35d1451278c6182688a1fb27035e81/opencv_python-4.14.0.94-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:726a2b40c1120a0bbd0116394c6a5b2aa0a4dfa4e609a81ee10d12c6a34cd372", size = 77393698, upload-time = "2026-07-28T19:19:14.745Z" }, + { url = "https://files.pythonhosted.org/packages/8b/2c/42491165dea74fcefa9a438ccf9ab6c7f3a52d595688ac90e87c4c1e9774/opencv_python-4.14.0.94-cp37-abi3-win32.whl", hash = "sha256:549c2401f8654708c702bdf0e26d45816b9c411369a0efa41907ec2b4ec0e707", size = 33162505, upload-time = "2026-07-28T18:32:23.488Z" }, + { url = "https://files.pythonhosted.org/packages/08/f0/ebf59732b54b4dd8a9e23f3b453ad73e11ec2230faf25ff4b1befa191ce0/opencv_python-4.14.0.94-cp37-abi3-win_amd64.whl", hash = "sha256:ace53616cdffc9643e17e075397b493e607c427d388c7508798ef7ad2ed577cc", size = 41166165, upload-time = "2026-07-28T18:32:19.031Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pyside6" +version = "6.11.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyside6-addons" }, + { name = "pyside6-essentials" }, + { name = "shiboken6" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/a6/27ba5947ed48918f7b74b7c43a1e280aac069e36f25adeb4c9adfac835c4/pyside6-6.11.1-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:537682c3b7530817203e667c1f5a2f00486b37bf52c52eeab438544c7a0917f6", size = 571921, upload-time = "2026-05-13T09:47:36.402Z" }, + { url = "https://files.pythonhosted.org/packages/d8/de/af89d71410c83b10654d86ff9aff2a4f87c30163658f1cc145242e222526/pyside6-6.11.1-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b1fc521ba2bb5109425ab8add06bddbdd524abcad06cfa012cc39a22a189feb2", size = 572102, upload-time = "2026-05-13T09:47:38.249Z" }, + { url = "https://files.pythonhosted.org/packages/b6/0e/d583bd3f7bf5046a4497b36f3902cfb64aa29554489a5a25c18e6b4ac0ac/pyside6-6.11.1-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:75f0005c3eb95c07cfb65522ec50d0815ac007a96482c21dc3cb4b4c04895d84", size = 572098, upload-time = "2026-05-13T09:47:39.44Z" }, + { url = "https://files.pythonhosted.org/packages/57/f2/d9d8ce1373dabb37e5919f63cd18446556079631d3f2eea3ada03c29f6b8/pyside6-6.11.1-cp310-abi3-win_amd64.whl", hash = "sha256:0968877ab1fb4ef3587a284da6fe05e8647ada56a6a3750b6395188e01f4aba6", size = 578377, upload-time = "2026-05-13T09:47:40.76Z" }, + { url = "https://files.pythonhosted.org/packages/96/02/a6057d8bd2bdb1940820fff2d627fdf4013148c9c57adf69fa40d3452ac3/pyside6-6.11.1-cp310-abi3-win_arm64.whl", hash = "sha256:acee467cb5f256cc47ebb9d815a054c1d8416da380c191b247a76d164aa3f805", size = 561765, upload-time = "2026-05-13T09:47:41.9Z" }, +] + +[[package]] +name = "pyside6-addons" +version = "6.11.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyside6-essentials" }, + { name = "shiboken6" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/6b/8bc94aff48b63f788f2d84e5467c12362d68906ba742c0942f46cb04c879/pyside6_addons-6.11.1-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:54733c77f789bef5f03c6aff4ad3bec8b2eff021f0cfcbc53d5e6c250ded24f9", size = 331714589, upload-time = "2026-05-13T09:39:12.36Z" }, + { url = "https://files.pythonhosted.org/packages/dd/62/fb1428a523b2a4541e232aab50d9e789e6b4526f37fd9593452a7ea5b6b3/pyside6_addons-6.11.1-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:8e6c65fbd73a512d6f72cda8d8277444a85a34dc99dd1dae9c21d35b8671bb1f", size = 175063224, upload-time = "2026-05-13T09:39:34.185Z" }, + { url = "https://files.pythonhosted.org/packages/ee/9b/2ccd52f66db55c06de65d0501170a1935d04d64d0a230c0d892284a02ce3/pyside6_addons-6.11.1-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:bf1c6c4e954e5eba3d2a7c661ad4b9689e8f09c7f4a16bdf29713371d11af993", size = 170553429, upload-time = "2026-05-13T09:39:54.424Z" }, + { url = "https://files.pythonhosted.org/packages/9a/bd/8adc4d350b3b363f3dfc8fccdcf5bfed25f7e36c2fff30c64e106f4f1572/pyside6_addons-6.11.1-cp310-abi3-win_amd64.whl", hash = "sha256:0d13c4dfd671b050a48e4f8d8ddc724b7248f9c0437e7fc47fdf316278572923", size = 168816308, upload-time = "2026-05-13T09:40:13.541Z" }, + { url = "https://files.pythonhosted.org/packages/65/b7/9a840d97f0f0f04e372a87e205dd30ee285b4e3b021b188459a917c9dc76/pyside6_addons-6.11.1-cp310-abi3-win_arm64.whl", hash = "sha256:3494f480dee92f415be2f2d989c0b3f4755ac332b28045cbf4ba0f5c5a22ba37", size = 35759347, upload-time = "2026-05-13T09:40:21.199Z" }, +] + +[[package]] +name = "pyside6-essentials" +version = "6.11.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "shiboken6" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/da/10d9197e7370eb4fed8df5fc547b7548dec88e5c5949e2d450db4ae96feb/pyside6_essentials-6.11.1-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:228de53c2bc26b07e5021fbe3614fc44ca08e4dab9999af08c2b389d2c239957", size = 110352945, upload-time = "2026-05-13T09:43:08.006Z" }, + { url = "https://files.pythonhosted.org/packages/5c/49/0e1237c4400bec7e335d2c4eeb49bc40d9fd88a9ac44ca9083ce1abdc308/pyside6_essentials-6.11.1-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:e3ef7027b41e4e55fadb56e3b3257dc8ee92154b639fe67fc4c8e05e9d976c60", size = 79908535, upload-time = "2026-05-13T09:43:24.836Z" }, + { url = "https://files.pythonhosted.org/packages/4c/c5/da4c5f23c6540ac5211a1f60177c8dee84b1bf40f2719479587ab8c60731/pyside6_essentials-6.11.1-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:a039b6da68a3a4b9d243217b2b98d475eed3f617159ef6be925badab53c11b0d", size = 78960051, upload-time = "2026-05-13T09:43:35.423Z" }, + { url = "https://files.pythonhosted.org/packages/64/0e/b663ecc96ca57b5c91b83b6615d6b174380b0faf30338125c26e053d6aa7/pyside6_essentials-6.11.1-cp310-abi3-win_amd64.whl", hash = "sha256:63311bd48e32c584599ab04b9ef7c324082374cd2c9fa533f978fb893bb47e40", size = 77549267, upload-time = "2026-05-13T09:43:44.92Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/eb6723faf5cb7fa581145da1c15f40d641b96e080f0491af2f1859fdeedb/pyside6_essentials-6.11.1-cp310-abi3-win_arm64.whl", hash = "sha256:11253ea52aabecefe9febddbbe78b43a824129e3af1cec98431028fba7fa954f", size = 57964512, upload-time = "2026-05-13T09:43:52.968Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/70/25/7113f6d5498888c5fb7db34081cba7d5971c4cb1bfb26819966eee68f003/ruff-0.16.1.tar.gz", hash = "sha256:fedad7c801dabd3fb9741d76aca39246e6ddd9ca446a015875207bf19f1e6bc7", size = 4877500, upload-time = "2026-07-30T19:37:01.379Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/bd/694da69368e0973de65df2ddc73ab18d43c469d5963d9b150911de6bc513/ruff-0.16.1-py3-none-linux_armv6l.whl", hash = "sha256:58edb313b88f0c5460a26adf5f39a37a3be789494a15e3e411e35fa78b89f9a0", size = 10839126, upload-time = "2026-07-30T19:36:13.697Z" }, + { url = "https://files.pythonhosted.org/packages/3f/f0/b626e5d5bd0dd9576263658ef12885e2288afd1029a48e26ffed65ec1ac1/ruff-0.16.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:fde5a99e2f97479af66edd6622c6d5a2a7592c77cf4153d9e4428f5eeb55b60c", size = 11070253, upload-time = "2026-07-30T19:36:17.14Z" }, + { url = "https://files.pythonhosted.org/packages/83/63/f40acfb6b35b88623e71684942b552c3edd96035f5d98f313815f7b277de/ruff-0.16.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e0d4c20532fca4f7fa609369161d968dd28f65d83dabbd61d8e9c7edbf7001f6", size = 10561425, upload-time = "2026-07-30T19:36:20.04Z" }, + { url = "https://files.pythonhosted.org/packages/aa/dd/14ec0e9c2b4d315547dd38765004b4863e354e1b52cb308272215d9f6f6d/ruff-0.16.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:30affbcedf59ad5703d9c91f82266e02b47739f797e1a7b6e158e5526a6dae38", size = 10948879, upload-time = "2026-07-30T19:36:22.476Z" }, + { url = "https://files.pythonhosted.org/packages/33/e9/9d870cbae575030fdef595f04b4b97573c525b5497cce4f4498cf2f85446/ruff-0.16.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:24e9c631573cbca9d20f1283f8f479b2afa4a8503504822bd71a293889f16743", size = 10643691, upload-time = "2026-07-30T19:36:24.914Z" }, + { url = "https://files.pythonhosted.org/packages/c4/09/12743d544e2173f53ecd27217c65f90d2bc0f8424a66a60339e56bbc0457/ruff-0.16.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b41bdd48fb420987a9b5212e4957c26ad4abce401fa9ea9d4d85843727945f4f", size = 11435354, upload-time = "2026-07-30T19:36:28.447Z" }, + { url = "https://files.pythonhosted.org/packages/7f/89/a1652b2daee52083c9554a6333b678a8b01d0400f976827bb87857f9449a/ruff-0.16.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b0d1e1393b7648079e13669de1c1f4fde06d4583e84d8fd5c1551e0a77a2aa75", size = 12259033, upload-time = "2026-07-30T19:36:31.326Z" }, + { url = "https://files.pythonhosted.org/packages/16/96/ecdcb8c54ee7b123b487f807eb014e6e019155a0b81dfb669acd52f28ce3/ruff-0.16.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:07bf434b1c95f4e093be4532068ef4fcf00924eb2ade8796075980902d6fd54a", size = 11667981, upload-time = "2026-07-30T19:36:34.394Z" }, + { url = "https://files.pythonhosted.org/packages/cd/90/c52e12e0d862e9572f2a33aa227409143520abe53111e9a6babbac7b4af8/ruff-0.16.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39897739f112253ee4fdd2e8aa9a4f9ded99fb2be367d5f31dfa4ded6025584c", size = 11468183, upload-time = "2026-07-30T19:36:37.339Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6b/4ffb7ad1d83eb16cf8cbb3c8815d3f11c88460fd162d4b372a2059be1c2a/ruff-0.16.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:82ae3c0c0d74daf17b968a10b7b3bb3ef297ab7de0c1f749646b25e690ccb150", size = 11470071, upload-time = "2026-07-30T19:36:39.91Z" }, + { url = "https://files.pythonhosted.org/packages/9c/72/32ae7db4c0b5e32ab611787caa19d1546800676d79f7483b7100a3561bf4/ruff-0.16.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4d5f2ed10f8242d83fc08d521301089364e3375375705356f20c0e31606ef3ef", size = 10919503, upload-time = "2026-07-30T19:36:42.65Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ca/3d901ba6ad6fc38da39c3448fc6c59ac945679293a17c3ceb6d6c1cba13e/ruff-0.16.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a4665b309891f83f3e3c25447935f1213e9abbd4b5640af7a1f2def9f8d413c1", size = 10649861, upload-time = "2026-07-30T19:36:45.18Z" }, + { url = "https://files.pythonhosted.org/packages/92/79/894ef1ced26552d5f8c9cf6d85b0687840e1128c55aeab7b9c2d54a0d880/ruff-0.16.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:26e9ca5c9bc3971f20d3cf18a957f52ffd6a5f6564ff15c4912a144dcac22494", size = 11148137, upload-time = "2026-07-30T19:36:47.936Z" }, + { url = "https://files.pythonhosted.org/packages/2d/69/3609a09fa1cb46cc28b762363e440a354204e5dff01bd0c8d7437874d6b9/ruff-0.16.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:67e1e1e3fa4f0c82f0e36d4cd61e661f6e7a6196cb1aa92fe0828fa7b8f257cd", size = 11559211, upload-time = "2026-07-30T19:36:50.448Z" }, + { url = "https://files.pythonhosted.org/packages/fc/8a/fb22af2fd78a736e241fabf67e30ce1799a64244026377a49e133af90762/ruff-0.16.1-py3-none-win32.whl", hash = "sha256:d31765e131295b8445caf301e3e8a85b34d1b9b211b4109b7ba457888b051806", size = 10838258, upload-time = "2026-07-30T19:36:53.298Z" }, + { url = "https://files.pythonhosted.org/packages/d4/35/e57fd9fb5d423961df087a00b12d42c0a830288dc2f3b45ecca299158b4f/ruff-0.16.1-py3-none-win_amd64.whl", hash = "sha256:09b05e8b90c2cb06ad63464350e7a45e8e44a2dfe52072ebfba6666ca8d3f596", size = 11961111, upload-time = "2026-07-30T19:36:56.107Z" }, + { url = "https://files.pythonhosted.org/packages/cb/46/240ea004bf6dc4feb40e9832f2205a476a47dd5b8a3f8211a5fc5f95e20e/ruff-0.16.1-py3-none-win_arm64.whl", hash = "sha256:dbaadaac38c70239f056d306b7476f246b0bf000fa6b3876402acbf5b227eaf8", size = 11309414, upload-time = "2026-07-30T19:36:58.79Z" }, +] + +[[package]] +name = "shiboken6" +version = "6.11.1" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/f3/f2b63df0251e7cd3172ea28e32ede52739de9566bcefcd0178681538ac81/shiboken6-6.11.1-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:1a16867f103ef1c662a5f09dfed03273a9f81688b174555162c58e83650a3f02", size = 476874, upload-time = "2026-05-13T09:47:01.091Z" }, + { url = "https://files.pythonhosted.org/packages/c7/9b/e0355d8897b5c150770f1d95718aad17d432fcc9c035c04f3f58427d4693/shiboken6-6.11.1-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9a8bccfafc8805254cabcfa1edfaf55cd52889f4998c91ad0d9a4433fb1bcdbe", size = 272222, upload-time = "2026-05-13T09:47:02.653Z" }, + { url = "https://files.pythonhosted.org/packages/57/d5/dd4f1defed400be03340f2ede34b61f846776650b4e7ed9ebaf4c71979a2/shiboken6-6.11.1-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:1bd2f4314414df2d122d9f646e03b731bc6d6b5f77a5f53f99a4fe4e97d84e6f", size = 270350, upload-time = "2026-05-13T09:47:04.02Z" }, + { url = "https://files.pythonhosted.org/packages/52/b5/3f6fb2ee65b534193fb4ef713dd619dc31dadff5d12c16979a7699ad58be/shiboken6-6.11.1-cp310-abi3-win_amd64.whl", hash = "sha256:c2c6863aa80ec18c0f82cea3417837b279cdc60024ac17123461dc9042577df7", size = 1223647, upload-time = "2026-05-13T09:47:05.924Z" }, + { url = "https://files.pythonhosted.org/packages/98/d1/f15ca0e1666faae02c945f48e745ea35f8fcd8243b176109b4e2c4251f47/shiboken6-6.11.1-cp310-abi3-win_arm64.whl", hash = "sha256:7c8d9af17db4495d4fa5b1c393f218311c4855546b9dfa6a0bd21bcd66b55e9d", size = 1784170, upload-time = "2026-05-13T09:47:07.617Z" }, +] + +[[package]] +name = "tiasjudge" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "numpy" }, + { name = "opencv-python" }, + { name = "pyside6" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "numpy", specifier = ">=2.0" }, + { name = "opencv-python", specifier = ">=4.10,<5" }, + { name = "pyside6", specifier = ">=6.7" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=8.0" }, + { name = "ruff", specifier = ">=0.6" }, +]