"""メイン画面(SPEC_01「メイン画面」).
旧実装(`org/src` の `CtonguediagnosisMFC2Dlg`)の操作フローを踏襲する.
1. 変換行列 CSV(`.csv`)を選択する
2. 舌画像(`.png`)を選択する(読込時に中央基準で 1024x1280 にクロップ)
3. 画像上を 1〜4 点左クリックして ROI を指定する(右クリックで取り消し)
4. [舌色診断] で `core` のパイプライン(ROI 平均色 → Lab → SVM)を実行する
5. [診断続行] で入力をリセットし,次の診断へ進む
①の上の「判定方法」で,推論だけを「AI」に切り替えられる(Issue #13.既定は
ルールベース).AI モードでは①変換行列と③クリックを使わず,②で読み込んだ撮影画像
(クロップ前.変換行列は通さない)を④で `core.ml.judge.TongueColorJudge` に渡し,検出 →
舌の分割 → 色特徴 → ロジスティック回帰で 5 色の確率を求める.結果は既存と同じ「診断結果」欄
(整数 %)と結果画面に出し,結果画面のタイトルに AI 判定である旨を付ける.ルールベース
モードの挙動・表示・診断結果は従来と同一.判定モデルは初回の AI 診断時に遅延ロードする.
依存方向は `gui` → `core` の一方向とする(project-structure.md).診断計算は本モジュールに
実装せず,必ず `core` を呼ぶ.画面に依存しないロジック(クリック点の管理・入力ガード・
クロップ・表示座標の変換・パイプライン実行)は QWidget 非依存の関数・クラスに分離してある.
診断結果は結果画面(`result_window.ResultWindow`)にモードレスで表示する.結果の
受け渡し口として `MainWindow.diagnosis_finished` シグナルと `result_presenter`
(差し替え用のコールバック)を用意してある.結果画面は表示専用(患者側モニタに操作 UI を
出さない)のため,言語切替と「閉じる」は本画面の「結果画面の操作」から行う.表示言語は
本画面が保持し,開いている結果画面と次に開く結果画面の両方に適用する.
"""
from __future__ import annotations
import logging
import os
import sys
from collections.abc import Callable, Sequence
from dataclasses import dataclass, field
from enum import Enum
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, QIcon, QMouseEvent, QPainter, QPixmap
from PySide6.QtWidgets import (
QApplication,
QButtonGroup,
QFileDialog,
QFrame,
QGridLayout,
QGroupBox,
QHBoxLayout,
QLabel,
QLineEdit,
QMainWindow,
QMessageBox,
QPushButton,
QRadioButton,
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.ml.judge import (
COLOR_MODEL_FILE_NAME,
JudgeStatus,
MlJudgeResult,
MlModelError,
ModelPaths,
TongueColorJudge,
UnavailableModel,
find_unavailable_models,
)
from tiasjudge.core.svm import COLOR_KEYS, DiagnosisResult, diagnose
from tiasjudge.gui.result_window import (
IMAGE_DISPLAY_DIVISOR,
DisplayResult,
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"
#: 学習データのファイル名
TRAINING_DATA_FILE_NAME = "data.csv"
#: 学習データを置くディレクトリ名(リポジトリ・PyInstaller 展開先で共通)
TRAINING_DATA_DIR_NAME = "data"
#: 舌色判定のモデルを置くディレクトリを上書きする環境変数
MODEL_DIR_ENV_VAR = "TIASJUDGE_MODEL_DIR"
#: 舌色判定のモデルを置くディレクトリ(リポジトリ・PyInstaller 展開先で共通.
#: `data/` の下の `models/`)
MODEL_DIR_NAME = f"{TRAINING_DATA_DIR_NAME}/models"
#: 舌色判定の重ね描き: 舌マスクの輪郭の色(BGR.緑)と太さ(1/2 表示でも見える太さ)
ML_CONTOUR_COLOR = (0, 255, 0)
ML_CONTOUR_THICKNESS = 4
#: 舌色判定の重ね描き: 舌の検出枠の色(BGR.水色)と太さ
ML_RECT_COLOR = (255, 255, 0)
ML_RECT_THICKNESS = 2
#: アプリのアイコンファイル名(旧 MFC 実装 `IDR_MAINFRAME` と同一のアイコン)
ICON_FILE_NAME = "TIASjudge.ico"
#: アイコンを置くディレクトリ名(リポジトリ・PyInstaller 展開先で共通)
ICON_DIR_NAME = "packaging"
# --- 入力ガードのメッセージ(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 ファイルを選択しなおしてください"
# --- 舌色判定の文言(Issue #13)---
ML_OVERLAY_NOTE_TEXT = "\n".join(
(
"画像上の緑線は検出した舌の輪郭,水色の枠は",
"舌の検出枠です.輪郭が舌からずれている場合は",
"結果を参考にしないでください.",
)
)
MESSAGE_ML_NOT_DETECTED = (
"舌を検出できませんでした(最高スコア {score}).\n"
"舌全体が写るように撮り直した画像で,もう一度判定してください."
)
MESSAGE_ML_EMPTY_MASK = (
"舌の領域を切り出せませんでした.\n"
"舌全体が写るように撮り直した画像で,もう一度判定してください."
)
MESSAGE_ML_UNAVAILABLE = (
"AI のモデルを使えないため,AI での判定はできません.\n判定方法をルールベースに戻しました."
)
#: モデルを使えないため AI を選べないときに「判定方法」内へ出す案内の見出し(2 行)
ML_DISABLED_HEADING = "AI は使用できません\n(モデルファイルを確認してください):"
#: 実行時にモデルを読み込めなかった場合のラベルの文言(詳細はツールチップに出す)
ML_LOAD_FAILED_LINES: tuple[str, ...] = (
"・モデルファイルを読み込めません",
" (詳細は AI にマウスを載せると表示)",
)
#: 判定方法の見出しと選択肢
MODE_GROUP_TITLE = "判定方法"
MODE_LABEL_RULE_BASED = "ルールベース(ROI)"
MODE_LABEL_AI = "AI"
def ml_disabled_label_text(problems: Sequence[UnavailableModel]) -> str:
"""モデルを使えない理由をラベル用の短い文言(ファイル名と理由を別の行)にする.
完全なパスは折り返せず画面が横に広がるため,ラベルにはファイル名だけを出す
(完全なパスは `ml_disabled_tooltip_text` でツールチップに出す).
"""
lines = [ML_DISABLED_HEADING]
for problem in problems:
lines += [f"・{problem.path.name}", f" {problem.reason}"]
return "\n".join(lines)
def ml_disabled_tooltip_text(details: Sequence[object]) -> str:
"""モデルを使えない理由をツールチップ用の文言(完全なパス・エラー文)にする."""
return "\n".join([ML_DISABLED_HEADING, *(f"・{detail}" for detail in details)])
#: 画像未読込時のプレースホルダ文言(旧実装の `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: ファイルを読めない,または画像として解釈できない場合.
"""
return crop_center(read_image_file(path))
def read_image_file(path: Path | str) -> NDArray[np.uint8]:
"""画像を BGR で読み込んで返す(クロップしない.`cv2.imread` の既定と同じ 8 bit BGR).
舌色判定は撮影画像そのもの(引き渡し仕様「全体の流れ」)を使うため,
クロップ前の画像も取り出せるようにしてある.
Args:
path: 画像ファイルのパス.
Returns:
BGR 画像(形状 (高さ, 幅, 3),uint8).
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 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 draw_ml_overlay(image: NDArray[np.uint8], result: MlJudgeResult) -> NDArray[np.uint8]:
"""舌色判定の舌マスクの輪郭と検出枠を描いた画像(コピー)を返す.
セグメンテーションが正しく行われたかを利用者が確かめるための表示(Issue #13).
Args:
image: 判定に用いた撮影画像(クロップ前の BGR).
result: 判定の結果(マスク・枠が無ければ描かない).
Returns:
重ね描きした BGR 画像.
"""
annotated = image.copy()
if result.rect is not None:
rect = result.rect
cv2.rectangle(
annotated,
(rect.x0, rect.y0),
(rect.x1 - 1, rect.y1 - 1),
ML_RECT_COLOR,
ML_RECT_THICKNESS,
)
if result.mask is not None and result.mask.any():
contours, _ = cv2.findContours(
result.mask.astype(np.uint8), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE
)
cv2.drawContours(annotated, contours, -1, ML_CONTOUR_COLOR, ML_CONTOUR_THICKNESS)
return annotated
def ml_failure_message(result: MlJudgeResult) -> str | None:
"""判定しなかった場合に利用者へ出す案内を返す(判定できた場合は None)."""
if result.status is JudgeStatus.NOT_DETECTED:
score = "-" if result.detection_score is None else f"{result.detection_score:.2f}"
return MESSAGE_ML_NOT_DETECTED.format(score=score)
if result.status is JudgeStatus.EMPTY_MASK:
return MESSAGE_ML_EMPTY_MASK
return None
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 _bundle_root() -> Path | None:
"""PyInstaller で固めた実行ファイルの展開先(`sys._MEIPASS`)を返す.
通常の Python 実行時は `None` を返す(frozen 判定は PyInstaller の推奨方法).
"""
if not getattr(sys, "frozen", False):
return None
meipass = getattr(sys, "_MEIPASS", None)
if meipass:
return Path(meipass)
# onedir 形式で `_MEIPASS` が無い場合は exe と同じ場所を見る
return Path(sys.executable).resolve().parent
def _resource_candidates(dir_name: str, file_name: str) -> list[Path]:
"""同梱リソース `<dir_name>/<file_name>` の探索候補を優先順に返す.
PyInstaller の展開先(`sys._MEIPASS`)→ パッケージ同梱 → リポジトリ直下の順.
いずれも実行位置からの相対で組み立てる(絶対パスは埋め込まない).
"""
package_root = Path(__file__).resolve().parents[1]
candidates: list[Path] = []
bundle_root = _bundle_root()
if bundle_root is not None:
candidates.append(bundle_root / dir_name / file_name) # PyInstaller 同梱
candidates += [
package_root / dir_name / file_name, # 将来パッケージに同梱した場合
package_root.parents[1] / dir_name / file_name, # リポジトリ直下
]
return candidates
def _first_existing(candidates: Sequence[Path]) -> Path | None:
"""候補のうち最初に実在するファイルを返す(無ければ None)."""
for candidate in candidates:
if candidate.is_file():
return candidate
return None
def default_training_data_path() -> Path:
"""同梱学習データ `data/data.csv` のパスを返す(絶対パスは埋め込まない).
環境変数 `TIASJUDGE_DATA_CSV` があればそれを優先する(配布形態が変わった場合の逃げ道).
PyInstaller の onefile exe では展開先(`sys._MEIPASS`)配下の `data/data.csv` を使う.
実在するものが無い場合は最後の候補(リポジトリ直下)を返す.
"""
override = os.environ.get(TRAINING_DATA_ENV_VAR)
if override:
return Path(override)
candidates = _resource_candidates(TRAINING_DATA_DIR_NAME, TRAINING_DATA_FILE_NAME)
found = _first_existing(candidates)
return found if found is not None else candidates[-1]
def default_model_directory() -> Path:
"""舌色判定のモデルを置くディレクトリ(`data/models`)を返す.
環境変数 `TIASJUDGE_MODEL_DIR` があればそれを優先する.PyInstaller の onefile exe では
展開先(`sys._MEIPASS`)配下の `data/models` を使う.判定モデル(`color_model.json`)が
実在する候補を選び,無ければ最後の候補(リポジトリ直下)を返す(絶対パスは埋め込まない).
"""
override = os.environ.get(MODEL_DIR_ENV_VAR)
if override:
return Path(override)
candidates = _resource_candidates(MODEL_DIR_NAME, COLOR_MODEL_FILE_NAME)
found = _first_existing(candidates)
return (found if found is not None else candidates[-1]).parent
def default_icon_path() -> Path | None:
"""アプリのアイコン(`packaging/TIASjudge.ico`)のパスを返す(無ければ None).
PyInstaller の onefile exe では展開先(`sys._MEIPASS`)配下を,通常実行時は
リポジトリ直下の `packaging/` を見る(絶対パスは埋め込まない).
"""
return _first_existing(_resource_candidates(ICON_DIR_NAME, ICON_FILE_NAME))
def load_app_icon() -> QIcon | None:
"""ウィンドウ・タスクバーに表示するアイコンを読み込む(読めなければ None).
アイコンは装飾であり,見つからなくても起動を妨げない(警告ログのみ残す).
"""
path = default_icon_path()
if path is None:
logger.warning("アイコンファイルが見つかりません: %s", ICON_FILE_NAME)
return None
icon = QIcon(str(path))
if icon.isNull():
logger.warning("アイコンを読み込めません: %s", path)
return None
return icon
def apply_app_icon(app: QApplication) -> QIcon | None:
"""アプリ全体(すべてのウィンドウ・タスクバー)にアイコンを適用する.
Args:
app: 対象の `QApplication`.
Returns:
適用したアイコン(読み込めなかった場合は None).
"""
icon = load_app_icon()
if icon is not None:
app.setWindowIcon(icon)
return icon
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)
class DiagnosisMode(Enum):
"""判定方法(推論の種類).表示・操作の流れは共通で,推論だけが異なる."""
#: ROI クリック → 変換行列 → SVM(従来どおり.PLAN_01 の成功基準の対象)
RULE_BASED = "rule_based"
#: 撮影画像 → 舌の検出・分割 → 色特徴 → ロジスティック回帰(Issue #13)
AI = "ai"
@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
#: クロップ前の撮影画像(舌色判定の入力.変換行列は通さない)
raw_image: NDArray[np.uint8] | None = None
#: 舌色判定の結果(画像を読み替える・診断続行・判定方法の切替で消える)
ml_result: MlJudgeResult | None = None
#: 判定方法(既定はルールベース.診断後は [診断続行] まで切り替えない)
mode: DiagnosisMode = DiagnosisMode.RULE_BASED
@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 is_ai_mode(self) -> bool:
"""判定方法が AI か."""
return self.mode is DiagnosisMode.AI
@property
def can_diagnose(self) -> bool:
"""[舌色診断] を有効化できるか.
ルールベース: 変換行列・画像を読込済みで 1 点以上クリック済み(従来どおり).
AI: 画像を読込済みで未診断(入力が画像だけで,同じ画像を再判定しても結果は同じため).
"""
if self.is_ai_mode:
return self.has_image and not self.diagnosed
return self.has_transform and self.has_image and self.point_count > 0
def set_mode(self, mode: DiagnosisMode) -> None:
"""判定方法を切り替え,クリック点と舌色判定の結果を初期化する."""
self.mode = mode
self.ml_result = None
self.clear_points()
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],
raw_image: NDArray[np.uint8] | None = None,
) -> None:
"""画像を設定し,クリック点と舌色判定の結果を初期化する.
Args:
path: 画像ファイルのパス.
image: クロップ後の BGR 画像(ルールベース診断・表示に使う).
raw_image: クロップ前の BGR 画像(舌色判定に使う).省略時は `image`.
"""
self.image_path = path
self.image = image
self.raw_image = image if raw_image is None else raw_image
self.ml_result = None
self.clear_points()
def clear_image(self) -> None:
"""画像の選択を解除する([診断続行])."""
self.image_path = None
self.image = None
self.raw_image = None
self.ml_result = 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「入力ガード」).
AI モードは画像だけを要る(変換行列・クリックは使わない).
Raises:
InputGuardError: 未読込,またはクリック 0 点の場合.
"""
if self.is_ai_mode:
if not self.has_image:
raise InputGuardError(MESSAGE_IMAGE_REQUIRED)
return
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[[DisplayResult, 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,
model_directory: Path | str | 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._ml_judge = TongueColorJudge(
ModelPaths.in_directory(
Path(model_directory) if model_directory is not None else default_model_directory()
)
)
# 起動時はファイルの存在と LFS のポインタかどうかだけを調べる(ONNX は読み込まない).
# 使えないモデルがあれば,その説明を保持して判定方法の「AI」を選べなくする
unavailable = find_unavailable_models(self._ml_judge.paths)
#: AI を選べない理由(ラベル用・ツールチップ用).None なら AI を選べる
self._ml_disabled_texts: tuple[str, str] | None = None
if unavailable:
self._ml_disabled_texts = (
ml_disabled_label_text(unavailable),
ml_disabled_tooltip_text(unavailable),
)
logger.warning(
"舌色判定のモデルを使えないため,AI を選べなくします: %s",
" / ".join(map(str, unavailable)),
)
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 - 舌色診断")
# 旧実装(MFC の `SetIcon`)と同じアイコンをタイトルバー・タスクバーに出す.
# 結果画面は本画面を親に持つため,指定しなければこのアイコンを引き継ぐ
icon = load_app_icon()
if icon is not None:
self.setWindowIcon(icon)
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)
layout.addWidget(self._build_mode_group(panel))
matrix_row, self._matrix_edit, self._matrix_button = self._build_file_row(
panel, "① 変換行列 (.csv) 読み込み", self._on_select_matrix
)
# AI モードでは変換行列を使わないため,グループごと無効(灰色)にする
self._matrix_group = matrix_row
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)
self._click_group = self._build_click_group(panel)
layout.addWidget(self._click_group)
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_mode_group(self, parent: QWidget) -> QGroupBox:
"""判定方法(ルールベース/AI)の切替を作る(Issue #13).
モデルを使えない場合は AI を選べなくし,理由(ファイル名)をラベルに,完全なパスを
ツールチップに出す.
"""
group = QGroupBox(MODE_GROUP_TITLE, parent)
layout = QVBoxLayout(group)
row = QHBoxLayout()
self._rule_radio = QRadioButton(MODE_LABEL_RULE_BASED, group)
self._ai_radio = QRadioButton(MODE_LABEL_AI, group)
self._rule_radio.setChecked(True)
self._mode_buttons = QButtonGroup(group)
self._mode_buttons.addButton(self._rule_radio)
self._mode_buttons.addButton(self._ai_radio)
self._ai_radio.toggled.connect(self._on_mode_toggled)
row.addWidget(self._rule_radio)
row.addWidget(self._ai_radio)
row.addStretch(1)
layout.addLayout(row)
# 使えない理由(モデルファイル).画像の読み直しや [診断続行] でも消さない
self._ml_disabled_label = QLabel("", group)
self._ml_disabled_label.setStyleSheet("color: #b00000;")
layout.addWidget(self._ml_disabled_label)
# 輪郭の重ね描きの説明(AI の判定で枠・輪郭を描いたときだけ表示)
self._ml_overlay_label = QLabel(ML_OVERLAY_NOTE_TEXT, group)
layout.addWidget(self._ml_overlay_label)
self._show_ml_disabled_reason()
return group
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)
raw_image = read_image_file(path)
except (InputGuardError, ImageLoadError) as error:
self._show_error(str(error))
return
# ルールベース診断・表示用はクロップ後(`load_image_file` と同じ),
# 舌色判定はクロップ前の撮影画像を使う
image = crop_center(raw_image)
self._state.set_image(path, image, raw_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 縮小).
AI モードは ROI を使わないため,クリックを無視する(案内も出さない).
"""
if self._state.is_ai_mode:
return
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
if self._state.is_ai_mode:
self._diagnose_with_ai()
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 _diagnose_with_ai(self) -> None:
"""AI で診断する(推論だけを差し替え,表示・状態遷移はルールベースと同じ).
検出失敗・マスクが空の場合は撮り直しを促し,結果画面は開かず診断済みにもしない.
モデルを読み込めない場合はルールベースに戻し,AI を選べなくする.
"""
image = self._state.image
raw_image = self._state.raw_image
if image is None or raw_image is None:
return
QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
try:
# 初回はここでモデルを読み込む(以後は再利用する)
judge_result = self._ml_judge.judge(raw_image)
except MlModelError as error:
# 壊れた ONNX 等(起動時の存在・ポインタ判定では分からない).再度試しても同じ結果に
# なるため,ルールベースに戻して AI を選べなくする(再起動すれば再び試せる)
self._ml_disabled_texts = (
"\n".join((ML_DISABLED_HEADING, *ML_LOAD_FAILED_LINES)),
ml_disabled_tooltip_text([error]),
)
self._show_ml_disabled_reason()
self._rule_radio.setChecked(True)
self._update_controls()
self._show_error(f"{MESSAGE_ML_UNAVAILABLE}\n{error}")
return
finally:
QApplication.restoreOverrideCursor()
self._state.ml_result = judge_result
self._refresh_image()
self._update_controls()
failure = ml_failure_message(judge_result)
if failure is not None:
self._show_error(failure)
return
result = judge_result.to_diagnosis()
# 研究用途での再確認のため,割合(整数 %)も INFO で残す(確率は core が記録する)
logger.info("AI 判定の表示: %s(上位 %s)", result.percentages, result.top_labels)
self._show_scores(result)
self._state.diagnosed = True
self._refresh_image()
self._update_controls()
# 結果画面には輪郭を重ねない画像を渡す(ルールベースと同じく患者側には元画像を見せる)
self._present_result(result, image)
def _on_continue(self) -> None:
"""[診断続行].同じ変換行列を使うか確認し,画像とクリックをリセットする."""
# AI モードは変換行列を使わないため,変換行列を引き継ぐかの確認はしない(そのまま残す)
if not self._state.is_ai_mode:
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 _on_mode_toggled(self, ai_checked: bool) -> None:
"""判定方法の切替.クリック点・AI の結果・重ね描きを初期化する."""
mode = DiagnosisMode.AI if ai_checked else DiagnosisMode.RULE_BASED
if mode is self._state.mode:
return
self._state.set_mode(mode)
logger.info("判定方法を切り替えました: %s", mode.value)
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:
base = image
raw_image, ml_result = self._state.raw_image, self._state.ml_result
if raw_image is not None and ml_result is not None:
# 舌色判定の輪郭は撮影画像の座標で描き,表示用と同じクロップを掛ける
base = crop_center(draw_ml_overlay(raw_image, ml_result))
annotated = draw_roi_markers(base, 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)
# 判定方法: 診断後は [診断続行] まで切り替えない.AI はモデルを使える場合だけ選べる
ai_mode = self._state.is_ai_mode
self._rule_radio.setEnabled(not self._state.diagnosed)
self._ai_radio.setEnabled(not self._state.diagnosed and self._ml_disabled_texts is None)
# AI モードでは①変換行列と③クリックを使わないため無効(灰色)にする
self._matrix_group.setEnabled(not ai_mode)
self._click_group.setEnabled(not ai_mode)
ml_result = self._state.ml_result
self._ml_overlay_label.setVisible(
ai_mode and ml_result is not None and ml_result.rect is not None
)
# 言語切替は「次に開く画面の言語」を先に決められるよう常時有効とし,
# 「閉じる」は対象がある時だけ有効にする
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: DisplayResult) -> None:
"""L*a*b* と 5 色スコアをパネルに表示する.
AI は変換行列を通さず L*a*b* を持たないため,L*a*b* は「-」のままにする.
"""
if isinstance(result, DiagnosisResult):
for key, value in zip(LAB_LABELS, lab_components(result.lab), strict=True):
self._lab_labels[key].setText(f"{value:.0f}")
else:
for label in self._lab_labels.values():
label.setText("-")
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 _show_ml_disabled_reason(self) -> None:
"""モデルを使えない理由を「判定方法」内のラベルと AI の選択肢のツールチップに出す."""
if self._ml_disabled_texts is None:
self._ml_disabled_label.setText("")
self._ml_disabled_label.setVisible(False)
self._ai_radio.setToolTip("")
return
label_text, tooltip_text = self._ml_disabled_texts
self._ml_disabled_label.setText(label_text)
self._ml_disabled_label.setVisible(True)
self._ai_radio.setToolTip(tooltip_text)
def _present_result(self, result: DisplayResult, 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: DisplayResult, image: NDArray[np.uint8]) -> ResultWindow:
"""結果画面をモードレスで開く.
旧実装(`CResultDlg` の `Create` + `ShowWindow`)と同じく,メイン画面を
操作可能なまま結果を表示する.複数回の診断で複数枚開けるよう,開いた画面は
閉じるまで保持する.
Args:
result: 表示する診断結果(ルールベース診断,または AI 舌色判定).
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