diff --git a/CLAUDE.md b/CLAUDE.md index 263d77d..2597f45 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ ## 開発進捗 -最新: PLAN_02 ステップ 6(GUI: メイン画面)完了(実機確認は未).次はステップ 7(GUI: 結果画面・結合) +最新: 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` を参照(該当ファイル編集時に自動ロードされる). ## 必須ルール(コード実装時) 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 7f35775..adf0802 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" @@ -26,7 +26,7 @@ | コンソールスクリプトで起動 | `uv run tiasjudge` | | 起動可能性のみ確認(画面を出さずに終了) | `uv run python -m tiasjudge --check` | -※ 結果画面は 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 2c5b4f3..165cd0a 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" @@ -52,6 +52,8 @@ | 紅 | 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`)を使用している.新実装では日英とも描画で生成し,画像アセット依存を無くす(見た目は現行踏襲). + +※ 5 色目の日本語表記は画面間で異なる: メイン画面は「淡白」,結果画面は「淡白紅」.旧実装(メイン画面ダイアログと結果画面の背景画像)がこの表記だったため,現行踏襲としてどちらもそのまま維持する(ステップ 7 で背景画像を実確認して確定). diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 289ab2b..af52077 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -45,3 +45,7 @@ - 途中で発見・修正: 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 既存時の堅牢性は既知の軽微リスクとして記録 diff --git a/src/tiasjudge/gui/main_window.py b/src/tiasjudge/gui/main_window.py index d290d38..aa6b64c 100644 --- a/src/tiasjudge/gui/main_window.py +++ b/src/tiasjudge/gui/main_window.py @@ -12,9 +12,9 @@ 実装せず,必ず `core` を呼ぶ.画面に依存しないロジック(クリック点の管理・入力ガード・ クロップ・表示座標の変換・パイプライン実行)は QWidget 非依存の関数・クラスに分離してある. -※ 結果画面(`result_window.py`)は PLAN_02 ステップ 7 で実装する.本モジュールは -結果の受け渡し口(`MainWindow.diagnosis_finished` シグナルと `result_presenter`)だけを -用意し,未接続時は簡易ダイアログで結果を表示する. +診断結果は結果画面(`result_window.ResultWindow`)にモードレスで表示する.結果の +受け渡し口として `MainWindow.diagnosis_finished` シグナルと `result_presenter` +(差し替え用のコールバック)を用意してある. """ from __future__ import annotations @@ -29,7 +29,7 @@ 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.QtGui import QColor, QFont, QMouseEvent, QPainter, QPixmap from PySide6.QtWidgets import ( QApplication, QFileDialog, @@ -54,6 +54,7 @@ load_transform_matrix, ) from tiasjudge.core.svm import COLOR_KEYS, DiagnosisResult, diagnose +from tiasjudge.gui.result_window import IMAGE_DISPLAY_DIVISOR, ResultWindow, to_display_pixmap logger = logging.getLogger(__name__) @@ -62,8 +63,9 @@ #: クロップ後の画像サイズ(旧実装 `target_width` / `target_height`) CROP_WIDTH = 1024 CROP_HEIGHT = 1280 -#: 画像表示時の縮小率(1/2 表示.旧実装 `resize(..., cols/2, rows/2)`) -DISPLAY_DIVISOR = 2 +#: 画像表示時の縮小率(1/2 表示.旧実装 `resize(..., cols/2, rows/2)`). +#: 結果画面と同じ縮小率を使うため `result_window` の定数を参照する +DISPLAY_DIVISOR = IMAGE_DISPLAY_DIVISOR #: 受け付けるファイル拡張子(SPEC_01「変換行列選択」「画像選択」) MATRIX_EXTENSION = ".csv" @@ -73,7 +75,7 @@ ROI_RECT_COLOR = (0, 0, 200) ROI_RECT_THICKNESS = 3 -#: L*a*b* の表示順と行見出し(結果パネル・暫定ダイアログで共通に使う) +#: L*a*b* の表示順と行見出し(メイン画面の結果表示に使う) LAB_LABELS: tuple[str, str, str] = ("L*", "a*", "b*") #: 5 色の表示名(日本語.キーは `core.svm.COLOR_KEYS`) @@ -424,8 +426,8 @@ super().mousePressEvent(event) -#: 結果画面へ結果を渡すための差し込み口(PLAN_02 ステップ 7 で `result_window` を接続する) -ResultPresenter = Callable[[DiagnosisResult, "NDArray[np.uint8]"], None] +#: 結果画面へ結果を渡すための差し込み口(未設定なら `ResultWindow` を開く) +ResultPresenter = Callable[[DiagnosisResult, NDArray[np.uint8]], None] class MainWindow(QMainWindow): @@ -448,7 +450,9 @@ self._training: TrainingData | None = None self._state = MainWindowState() self._last_directory = Path.home() - #: 結果画面の表示関数(未設定なら簡易ダイアログで代替する) + #: 開いている結果画面(閉じるまで保持する) + self._result_windows: list[ResultWindow] = [] + #: 結果画面の表示関数(未設定なら `ResultWindow` を開く) self.result_presenter: ResultPresenter | None = None self.setWindowTitle("TIASjudge - 舌色診断") @@ -696,7 +700,8 @@ pixmap = self._placeholder_pixmap() else: annotated = draw_roi_markers(image, self._state.points) - pixmap = self._to_pixmap(annotated) + # 結果画面と同じ変換を使う(SPEC_01「画像表示」) + pixmap = to_display_pixmap(annotated, DISPLAY_DIVISOR) self._image_view.setPixmap(pixmap) self._image_view.setFixedSize(pixmap.size()) @@ -725,29 +730,40 @@ 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 - # PLAN_02 ステップ 7 で `result_window` に置き換える暫定表示 - QMessageBox.information(self, "診断結果(暫定表示)", self._summarize(result)) + self.open_result_window(result, image) - @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 open_result_window(self, result: DiagnosisResult, image: NDArray[np.uint8]) -> ResultWindow: + """結果画面をモードレスで開く. + + 旧実装(`CResultDlg` の `Create` + `ShowWindow`)と同じく,メイン画面を + 操作可能なまま結果を表示する.複数回の診断で複数枚開けるよう,開いた画面は + 閉じるまで保持する. + + Args: + result: 表示する診断結果. + image: 診断に用いたクロップ後の BGR 画像. + + Returns: + 開いた結果画面. + """ + window = ResultWindow(result, image, parent=self) + window.closed.connect(self._on_result_window_closed) + self._result_windows.append(window) + window.show() + 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() # --- 補助 --- @@ -778,26 +794,6 @@ 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)) diff --git a/src/tiasjudge/gui/result_window.py b/src/tiasjudge/gui/result_window.py new file mode 100644 index 0000000..06e5ea8 --- /dev/null +++ b/src/tiasjudge/gui/result_window.py @@ -0,0 +1,591 @@ +"""結果画面(SPEC_01「結果画面」). + +旧実装(`org/src` の `CResultDlg`)のモードレスな結果ダイアログを踏襲する. + +- 左: 診断に用いたクロップ後画像を 1/2 縮小表示する +- 右: 5 色(淡紅・紅・暗赤・紫・淡白紅)の行を一覧表示し,**上位 2 色のみ**スコア(%)と + 下線を描画する(`pale` の表示名はメイン画面の「淡白」と異なり,旧背景画像どおり「淡白紅」) +- 下: 言語切替ボタン(日本語 ⇔ 英語.ラベルは切替**先**の言語名) +- 診断失敗時(`DiagnosisResult.failed`)は結果パネルに「Diagnosis failed / Please try again.」 + を重ねて描画する + +旧実装の日本語版は文字入りビットマップ(`img/img_student2.bmp`)を背景に使っていたが, +新実装では日英とも描画で生成し画像アセットへの依存を無くす(/setup 時の設計判断. +表示内容・レイアウトは現行踏襲).座標・色は旧実装の `ShowResultImg()` / +`BuildEnglishBackground()` の値をそのまま使う. + +依存方向は `gui` → `core` の一方向とする(project-structure.md).表示文字列の組み立て・ +強調対象の決定・言語リソースは QWidget 非依存の定数・関数に分離してある. +""" + +from __future__ import annotations + +import logging +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, QPen, QPixmap +from PySide6.QtWidgets import ( + QFrame, + QHBoxLayout, + QLabel, + QPushButton, + QVBoxLayout, + 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 位置(旧実装の `cv::Point` と同じ) +NAME_X = 20 +DESCRIPTION_X = 185 +SCORE_RIGHT_X = 610 +PERCENT_X = 620 +#: スコア表示のために空けておく幅(強調行の説明文はこの手前で切り詰める) +SCORE_AREA_WIDTH = 120 +#: 文字列の右端に確保する余白 +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 + +#: 下線の 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) +#: 見出しのオレンジ(旧 `Scalar(0, 90, 230)`) +HEADING_COLOR = (230, 90, 0) +#: 注記・所属のグレー(旧 `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) + +#: 色名の表示色(旧 `BuildEnglishBackground()` の cPink / cRed / cDarkRed / cPurple / cPale) +COLOR_SWATCHES: dict[str, tuple[int, int, int]] = { + "pink": (240, 130, 130), + "crimson": (210, 0, 0), + "dark_red": (120, 0, 0), + "purple": (140, 0, 200), + "pale": (235, 150, 150), +} + +# --- フォント(px 指定.環境差で行がずれないよう固定する)--- + +#: パネル描画に使うフォントの優先順(日本語の字形を確実に出すため明示する. +#: 見つからない環境では既定フォントにフォールバックする) +PANEL_FONT_FAMILIES: tuple[str, ...] = ( + "Yu Gothic UI", + "Meiryo", + "MS PGothic", + "Hiragino Sans", + "Noto Sans CJK JP", +) + +HEADING_FONT_SIZE = 40 +AFFILIATION_FONT_SIZE = 17 +NOTE_FONT_SIZE = 21 +NAME_FONT_SIZE = 32 +DESCRIPTION_FONT_SIZE = 24 +SCORE_FONT_SIZE = 40 +PERCENT_FONT_SIZE = 18 +FAILURE_TITLE_FONT_SIZE = 44 +FAILURE_DETAIL_FONT_SIZE = 22 + +#: 診断失敗の表示文言(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: 色名の右に表示する説明. + """ + + key: str + name: str + description: str + + +@dataclass(frozen=True) +class LanguageTexts: + """1 言語分の表示リソース(SPEC_01「5 色の表示内容」). + + Attributes: + window_title: ウィンドウタイトル. + heading: パネル見出し. + affiliations: 見出し右上の所属表記(無い言語は空タプル). + notes: 注記(2 行). + rows: 5 色の行(`DISPLAY_ORDER` と同じ並び). + switch_button_label: 言語切替ボタンのラベル(切替**先**の言語名). + close_button_label: 閉じるボタンのラベル. + """ + + window_title: str + heading: str + affiliations: tuple[str, ...] + notes: tuple[str, ...] + rows: tuple[ColorRowText, ...] + switch_button_label: str + close_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", + close_button_label="閉じる", + ), + 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.", + ), + rows=( + ColorRowText("pink", "Pink", "Normal"), + ColorRowText("crimson", "Red", "Hot flush / feverish"), + ColorRowText("dark_red", "Dark red", "Blood slightly thick"), + ColorRowText("purple", "Purple", "Blood is thick"), + ColorRowText("pale", "Pale red", "Cold / fatigue / anemia"), + ), + switch_button_label="日本語", + close_button_label="Close", + ), +} + + +# --- 画面に依存しないロジック(QWidget 非依存.単体で検証できる)--- + + +@dataclass(frozen=True) +class ResultRow: + """結果パネルの 1 行分の表示内容. + + Attributes: + key: 色のキー. + name: 表示する色名. + description: 説明文. + swatch: 色名の表示色(RGB). + highlighted: 上位 2 色(スコアと下線を描く行)なら True. + score_text: 強調行に表示するスコア文字列.非強調行は None. + """ + + key: str + name: str + description: str + swatch: tuple[int, int, int] + highlighted: bool + score_text: str | None + + +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, + ) + ) + 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) -> None: + """描画に使うフォント(書体・サイズ・太さ)を設定する.""" + font = QFont(painter.font()) + font.setFamilies([*PANEL_FONT_FAMILIES, font.family()]) + font.setPixelSize(pixel_size) + font.setBold(bold) + painter.setFont(font) + + +def _draw_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: + """ベースライン基準で文字列を描く(旧実装の `cv::putText` に対応). + + `max_width` を指定すると,その幅に収まらない文字列を末尾省略(...)で切り詰める + (環境ごとのフォント差で隣の要素へはみ出さないようにするため). + """ + painter.setPen(QColor(*color)) + _set_font(painter, pixel_size, bold=bold) + drawn = text + if max_width is not None: + drawn = painter.fontMetrics().elidedText(text, Qt.TextElideMode.ElideRight, max_width) + painter.drawText(position[0], position[1], drawn) + + +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) + 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 色のスコア・下線を描く.""" + for index, row in enumerate(rows): + baseline = row_baseline(index) + _draw_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, + DESCRIPTION_FONT_SIZE, + max_width=( + DESCRIPTION_MAX_WIDTH_HIGHLIGHTED if row.highlighted else DESCRIPTION_MAX_WIDTH + ), + ) + 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, + ) + + +# --- 画面部品 --- + + +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: + """ウィジェットを組み立てる(左: 診断画像/右: 結果パネル + ボタン).""" + layout = QHBoxLayout(self) + + 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.AlignTop) + + right = QVBoxLayout() + self._panel_label = QLabel(self) + self._panel_label.setFixedSize(PANEL_SIZE, PANEL_SIZE) + right.addWidget(self._panel_label, 0, Qt.AlignmentFlag.AlignTop) + + self._language_button = QPushButton(self) + self._language_button.clicked.connect(self.toggle_language) + self._close_button = QPushButton(self) + self._close_button.clicked.connect(self.close) + buttons = QHBoxLayout() + buttons.addWidget(self._language_button) + buttons.addStretch(1) + buttons.addWidget(self._close_button) + right.addLayout(buttons) + right.addStretch(1) + + layout.addLayout(right, 1) + + # --- 表示の更新 --- + + def toggle_language(self) -> None: + """日本語 ⇔ 英語を切り替える(SPEC_01「言語切替ボタン」).""" + self._language = next_language(self._language) + logger.info("結果画面の言語を切り替えました: %s", self._language.value) + self._apply_language() + + def _apply_language(self) -> None: + """現在の言語でタイトル・ボタン・結果パネルを描き直す.""" + texts = texts_for(self._language) + self.setWindowTitle(texts.window_title) + self._language_button.setText(texts.switch_button_label) + self._close_button.setText(texts.close_button_label) + 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/gui/test_result_window_logic.py b/tests/gui/test_result_window_logic.py new file mode 100644 index 0000000..2dabb5d --- /dev/null +++ b/tests/gui/test_result_window_logic.py @@ -0,0 +1,565 @@ +"""結果画面の QWidget 非依存ロジックの検証(SPEC_01「結果画面」・PLAN_02 ステップ 7). + +仕様上の要件は以下(SPEC_01「結果画面」). + +- 診断画像はクロップ後の元画像を 1/2 縮小表示する +- 結果パネルは 5 色の行(色名 + 説明)を一覧表示し,**上位 2 色のみ**スコア(%)と下線を描く +- 言語切替ボタンは日本語 ⇔ 英語を切り替え,ラベルは切替**先**の言語名 +- 診断失敗時は「Diagnosis failed / Please try again.」を表示する +- 注記は「本結果は疾患の診断を行うものではない」「表示画像は明るさ補正されている」の 2 件 + +※ testing.md の方針により GUI(QWidget)そのものの自動テストは書かない. +本ファイルは `ResultWindow` を構築せず,画面部品に依存しない定数・関数のみを対象とする. +※ 実行環境にフォントが無い場合でも成立するよう,描画結果のピクセル比較は行わない. +""" + +from __future__ import annotations + +from pathlib import Path + +import numpy as np +import pytest +from PySide6.QtWidgets import QApplication + +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 ( + COLOR_SWATCHES, + DISPLAY_ORDER, + FAILURE_DETAIL, + FAILURE_RECT, + FAILURE_TITLE, + HIGHLIGHT_COUNT, + IMAGE_DISPLAY_DIVISOR, + LANGUAGE_TEXTS, + PANEL_SIZE, + ROW_FIRST_Y, + ROW_MARGIN, + UNDERLINE_WIDTH, + UNDERLINE_X_RANGE, + Language, + build_result_rows, + format_score, + is_highlighted, + next_language, + render_result_panel, + row_baseline, + 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) + + +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 texts.close_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) + + +# --- 言語切替(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] + + +# --- 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] + + 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() + + +# --- 結合(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()