diff --git a/CLAUDE.md b/CLAUDE.md index baed23d..263d77d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ ## 開発進捗 -最新: PLAN_02 ステップ 5 の準備完了(回帰テスト骨格・採取手順書).採取(TEST_01)はユーザー作業待ち.次はステップ 6(GUI: メイン画面) +最新: PLAN_02 ステップ 6(GUI: メイン画面)完了(実機確認は未).次はステップ 7(GUI: 結果画面・結合) ※ 本欄は**最新ステップ 1 行のみ上書き更新**.詳細な進捗履歴(動機・設計判断・失敗パターン)は [docs/PROGRESS.md](docs/PROGRESS.md) に追記する.運用ルールは `.claude/rules/progress-log.md` を参照(該当ファイル編集時に自動ロードされる). ## 必須ルール(コード実装時) 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" index af33103..7f35775 100644 --- "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" @@ -22,10 +22,12 @@ | 目的 | コマンド | | --- | --- | -| アプリを起動 | `uv run python -m tiasjudge` | +| アプリを起動(メイン画面) | `uv run python -m tiasjudge` | | コンソールスクリプトで起動 | `uv run tiasjudge` | +| 起動可能性のみ確認(画面を出さずに終了) | `uv run python -m tiasjudge --check` | -※ GUI は PLAN_02 ステップ 6〜7 で実装する.それまでは未実装のメッセージを表示して終了する. +※ 結果画面は PLAN_02 ステップ 7 で実装する.それまで診断結果は暫定のダイアログで表示される. +※ ヘッドレス環境(画面の無い CI 等)では `QT_QPA_PLATFORM=offscreen` を付けて `--check` を実行する. ## テスト (Test) 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..2c5b4f3 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" @@ -30,6 +30,9 @@ - 変換行列未読込でクリック → 「変換行列を読み込んでください」 - 画像未読込でクリック → 「画像を読み込んでください」 - クリック 0 点で診断 → 「1 点以上 4 点以下クリックしてください」 +- 画像の端付近(ROI 51×51 が画像外にはみ出す位置)をクリック → 「画像の端から 25 px 以上内側をクリックしてください」 + +※ 端クリックのガードは旧実装に無い追加仕様(旧実装は OpenCV 例外でクラッシュしていた.ステップ 6 で追加). ## 結果画面 (Result Window) diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 450a050..289ab2b 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -40,3 +40,8 @@ - [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 表示等) diff --git a/src/tiasjudge/__main__.py b/src/tiasjudge/__main__.py index e585746..3295206 100644 --- a/src/tiasjudge/__main__.py +++ b/src/tiasjudge/__main__.py @@ -1,13 +1,53 @@ """エントリポイント(`python -m tiasjudge`). -GUI は PLAN_02 ステップ 6〜7 で実装する.現時点では起動可能性の確認のみ行う. +`QApplication` を生成してメイン画面(SPEC_01)を表示する. +ログの出力先設定はエントリポイント側の責務とする(error-handling.md「ログ」). + +起動オプション: + +- `--check`: 画面を生成して即終了する(起動可能性の確認用.イベントループを回さない) """ +from __future__ import annotations -def main() -> int: - """アプリを起動する.戻り値はプロセスの終了コード.""" - print("TIASjudge: GUI は未実装です(PLAN_02 ステップ 6 で実装予定)") - return 0 +import logging +import sys +from collections.abc import Sequence + +#: `--check`(イベントループを回さずに終了する)オプション +CHECK_OPTION = "--check" + +#: ログの書式(診断の入出力は `core` が INFO で記録する) +LOG_FORMAT = "%(asctime)s %(levelname)s %(name)s: %(message)s" + + +def main(argv: Sequence[str] | None = None) -> int: + """アプリを起動する.戻り値はプロセスの終了コード. + + Args: + argv: コマンドライン引数(プログラム名を含まない).省略時は `sys.argv[1:]`. + + Returns: + プロセスの終了コード(0 が正常終了). + """ + arguments = 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 + + from tiasjudge.gui.main_window import MainWindow + + app = QApplication.instance() or QApplication(sys.argv[:1]) + window = MainWindow() + + if CHECK_OPTION in arguments: + # 画面を構築できることだけを確認して終了する(ヘッドレス環境での確認用) + window.close() + return 0 + + window.show() + return int(app.exec()) if __name__ == "__main__": diff --git a/src/tiasjudge/gui/main_window.py b/src/tiasjudge/gui/main_window.py new file mode 100644 index 0000000..d290d38 --- /dev/null +++ b/src/tiasjudge/gui/main_window.py @@ -0,0 +1,815 @@ +"""メイン画面(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.py`)は PLAN_02 ステップ 7 で実装する.本モジュールは +結果の受け渡し口(`MainWindow.diagnosis_finished` シグナルと `result_presenter`)だけを +用意し,未接続時は簡易ダイアログで結果を表示する. +""" + +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, QImage, 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 + +logger = logging.getLogger(__name__) + +# --- 定数(SPEC_01「メイン画面」)--- + +#: クロップ後の画像サイズ(旧実装 `target_width` / `target_height`) +CROP_WIDTH = 1024 +CROP_HEIGHT = 1280 +#: 画像表示時の縮小率(1/2 表示.旧実装 `resize(..., cols/2, rows/2)`) +DISPLAY_DIVISOR = 2 + +#: 受け付けるファイル拡張子(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) + + +#: 結果画面へ結果を渡すための差し込み口(PLAN_02 ステップ 7 で `result_window` を接続する) +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_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_group(panel)) + layout.addStretch(1) + return panel + + 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) + pixmap = self._to_pixmap(annotated) + 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) + + 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: + """結果画面へ結果を渡す(未実装のため差し込み口 + 簡易ダイアログ).""" + self.diagnosis_finished.emit(result, image) + if self.result_presenter is not None: + self.result_presenter(result, image) + return + # PLAN_02 ステップ 7 で `result_window` に置き換える暫定表示 + QMessageBox.information(self, "診断結果(暫定表示)", self._summarize(result)) + + @staticmethod + def _summarize(result: DiagnosisResult) -> str: + """暫定表示用に結果を文字列化する.""" + lab_text = " / ".join( + f"{name} {value:.1f}" + for name, value in zip(LAB_LABELS, lab_components(result.lab), strict=True) + ) + lines = [lab_text, ""] + lines += [f"{COLOR_NAMES_JA[key]}: {result.percentages[key]:3d}%" for key in COLOR_KEYS] + lines.append("") + lines.append("上位 2 色: " + " / ".join(COLOR_NAMES_JA[key] for key in result.top_labels)) + if result.failed: + lines.append("") + lines.append("Diagnosis failed / Please try again.") + return "\n".join(lines) + + # --- 補助 --- + + 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 _to_pixmap(self, image: NDArray[np.uint8]) -> QPixmap: + """BGR 画像を 1/2 に縮小して QPixmap に変換する(SPEC_01「画像表示」).""" + height, width = image.shape[:2] + # 旧実装の `resize(..., CV_INTER_CUBIC)` と同じ補間で 1/2 縮小する + resized = cv2.resize( + image, + display_size(width, height), + 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 _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/tests/conftest.py b/tests/conftest.py index ddd37ba..49285a8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,11 +1,25 @@ """テスト共通のフィクスチャ.""" +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: diff --git a/tests/gui/test_main_window_logic.py b/tests/gui/test_main_window_logic.py new file mode 100644 index 0000000..2e7f9d0 --- /dev/null +++ b/tests/gui/test_main_window_logic.py @@ -0,0 +1,465 @@ +"""メイン画面の 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, + MainWindowState, + check_extension, + crop_center, + default_training_data_path, + draw_roi_markers, + is_roi_inside_image, + load_image_file, + run_diagnosis, +) + +# --- ヘルパー --- + + +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) diff --git a/tests/test_package_skeleton.py b/tests/test_package_skeleton.py index 1017247..44fd1a0 100644 --- a/tests/test_package_skeleton.py +++ b/tests/test_package_skeleton.py @@ -92,21 +92,30 @@ class TestEntryPoint: - """エントリポイント(ENV_04「起動」).""" + """エントリポイント(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() == 0 + assert main_module.main(["--check"]) == 0 - def test_python_m_tiasjudge_exits_successfully(self, repo_root: Path) -> None: - """`python -m tiasjudge` が正常終了する(GUI 未実装でも起動確認は通る).""" + 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"], + [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