Newer
Older
TIASjudge / src / tiasjudge / __main__.py
"""エントリポイント(`python -m tiasjudge`).

`QApplication` を生成してメイン画面(SPEC_01)を表示する.
ログの出力先設定はエントリポイント側の責務とする(error-handling.md「ログ」).

起動オプション:

- `--check`: 画面を生成して即終了する(起動可能性の確認用.イベントループを回さない)
- `--preview-result`: 結果画面(SPEC_01「結果画面」)だけをダミー結果で開く(デバッグ用)
- `--failed`: `--preview-result` と併用し,診断失敗パターンで開く
- `--english`: `--preview-result` と併用し,英語表示で開く(結果画面は表示専用のため,
  プレビューでは画面内で言語を切り替えられない)
- `--verify-data`: 同梱学習データ(`data/data.csv`)を読み込み,ダミー L*a*b* で診断を
  1 回実行して終了する(画面を出さない.配布 exe の動作確認用.PLAN_02 ステップ 8)
- `--verify-models`: 舌色判定(Issue #13)の同梱モデル(`data/models/`)を読み込み,
  ダミー画像で検出を 1 回実行して終了する(画面を出さない.配布 exe の動作確認用)
"""

from __future__ import annotations

import argparse
import logging
import sys
from collections.abc import Sequence
from typing import TYPE_CHECKING

import numpy as np

if TYPE_CHECKING:  # pragma: no cover - 型チェック時のみ
    from numpy.typing import NDArray
    from PySide6.QtWidgets import QApplication

    from tiasjudge.core.svm import DiagnosisResult

logger = logging.getLogger(__name__)

#: `--check`(イベントループを回さずに終了する)オプション
CHECK_OPTION = "--check"

#: ログの書式(診断の入出力は `core` が INFO で記録する)
LOG_FORMAT = "%(asctime)s %(levelname)s %(name)s: %(message)s"

# --- `--preview-result` 用のダミーデータ(デバッグ表示専用.診断は実行しない)---

#: プレビューに使うダミー画像の一辺(結果画面は 1/2 に縮小して表示する)
PREVIEW_IMAGE_SIZE = 400
#: プレビューに使うダミー画像の色(舌色に近い RGB)
PREVIEW_IMAGE_RGB = (198, 120, 120)
#: プレビューのダミー L*a*b*(学習データの範囲内にある代表値)
PREVIEW_LAB = (58.0, 24.0, 12.0)
#: 成功パターンのダミースコア(%)
PREVIEW_PERCENTAGES: dict[str, int] = {
    "purple": 5,
    "dark_red": 10,
    "crimson": 25,
    "pink": 55,
    "pale": 5,
}
#: 成功パターンの上位 2 色(`PREVIEW_PERCENTAGES` を降順に並べた先頭 2 件)
PREVIEW_TOP_LABELS = ("pink", "crimson")
#: 失敗パターンの上位 2 色(`LEGACY_FAILURE_PERCENTAGES` を降順に並べた先頭 2 件)
PREVIEW_FAILED_TOP_LABELS = ("purple", "pink")


def _build_parser() -> argparse.ArgumentParser:
    """コマンドライン引数のパーサを作る."""
    parser = argparse.ArgumentParser(
        prog="tiasjudge",
        description="舌診支援システム(TIAS)を起動する.",
    )
    parser.add_argument(
        CHECK_OPTION,
        action="store_true",
        help="画面を生成して即終了する(起動可能性の確認用)",
    )
    parser.add_argument(
        "--preview-result",
        action="store_true",
        help="結果画面だけをダミー結果で開く(デバッグ用.診断は実行しない)",
    )
    parser.add_argument(
        "--failed",
        action="store_true",
        help="--preview-result と併用し,診断失敗パターンで開く",
    )
    parser.add_argument(
        "--english",
        action="store_true",
        help="--preview-result と併用し,英語表示で開く",
    )
    parser.add_argument(
        "--verify-data",
        action="store_true",
        help="同梱学習データを読み込みダミー値で診断して終了する(配布 exe の確認用)",
    )
    parser.add_argument(
        "--verify-models",
        action="store_true",
        help="舌色判定の同梱モデルを読込みダミー画像で実行して終了する(exe の確認用)",
    )
    return parser


def _preview_image() -> NDArray[np.uint8]:
    """プレビュー用の単色ダミー画像(BGR)を作る."""
    red, green, blue = PREVIEW_IMAGE_RGB
    image = np.empty((PREVIEW_IMAGE_SIZE, PREVIEW_IMAGE_SIZE, 3), dtype=np.uint8)
    # OpenCV と同じ BGR 並びで埋める
    image[:, :] = (blue, green, red)
    return image


def _preview_result(*, failed: bool) -> DiagnosisResult:
    """プレビュー用のダミー診断結果を組み立てる(SVM 学習は行わない)."""
    from tiasjudge.core.color import Lab
    from tiasjudge.core.csv_io import EVALUATION_COLUMN_COUNT
    from tiasjudge.core.svm import (
        LEGACY_FAILURE_PERCENTAGES,
        DiagnosisResult,
        FailureReason,
    )

    percentages = dict(LEGACY_FAILURE_PERCENTAGES) if failed else dict(PREVIEW_PERCENTAGES)
    top_labels = PREVIEW_FAILED_TOP_LABELS if failed else PREVIEW_TOP_LABELS
    return DiagnosisResult(
        lab=Lab(*PREVIEW_LAB),
        scores={key: value / 100 for key, value in percentages.items()},
        percentages=percentages,
        top_labels=top_labels,
        predictions=np.zeros(EVALUATION_COLUMN_COUNT, dtype=np.float32),
        failed=failed,
        failure_reason=FailureReason.OUT_OF_DOMAIN if failed else None,
    )


def _run_preview(app: QApplication, *, failed: bool, check: bool, english: bool = False) -> int:
    """結果画面だけをダミー結果で開く(`--preview-result`).

    結果画面は表示専用(言語切替ボタンを持たない)ため,プレビューの言語は
    `--english` オプションで指定する.
    """
    # 関数内 import は起動時のみ GUI を読み込むため(`core` は GUI 非依存)
    from tiasjudge.gui.result_window import Language, ResultWindow, show_on_secondary_screen

    language = Language.ENGLISH if english else Language.JAPANESE
    logger.info("結果画面のプレビューを開きます(failed=%s・language=%s)", failed, language.value)
    window = ResultWindow(_preview_result(failed=failed), _preview_image(), language)
    if check:
        window.close()
        return 0

    # プレビューにはメイン画面が無いため,プライマリモニタを基準に別モニタを選ぶ
    show_on_secondary_screen(window, app.screens(), app.primaryScreen())
    return int(app.exec())


def _verify_bundled_data() -> int:
    """同梱学習データを読み込み,ダミー L*a*b* で診断を 1 回実行する(`--verify-data`).

    PyInstaller で固めた exe が学習データを同梱できているか,OpenCV の SVM が
    動作するかを画面なしで確認するためのオプション.

    Returns:
        正常なら 0,学習データを読めなければ 1.
    """
    from tiasjudge.core.color import Lab
    from tiasjudge.core.csv_io import CsvIoError, load_training_data
    from tiasjudge.core.svm import diagnose

    # パス解決は GUI 側の同梱データ解決(frozen 対応)を再利用する
    from tiasjudge.gui.main_window import default_training_data_path

    path = default_training_data_path()
    logger.info("同梱学習データを確認します: %s", path)
    try:
        training = load_training_data(path)
    except CsvIoError as error:
        logger.error("学習データを読み込めません: %s", error)
        return 1

    result = diagnose(Lab(*PREVIEW_LAB), training)
    logger.info("動作確認の診断結果: %s(failed=%s)", result.percentages, result.failed)
    return 0


def _verify_bundled_models() -> int:
    """舌色判定の同梱モデルを読み込み,ダミー画像で 1 回実行する(`--verify-models`).

    PyInstaller で固めた exe がモデルを同梱できているか,onnxruntime が動作するかを
    画面なしで確認するためのオプション.ダミー画像(単色)では舌は検出されないため,
    判定の結果は「舌を検出できなかった」になるのが正常である.

    Returns:
        モデルを読み込めて実行できれば 0,モデルが無い・読み込めなければ 1.
    """
    from tiasjudge.core.ml.judge import MlModelError, ModelPaths, TongueColorJudge

    # パス解決は GUI 側の同梱データ解決(frozen 対応)を再利用する
    from tiasjudge.gui.main_window import default_model_directory

    directory = default_model_directory()
    logger.info("舌色判定の同梱モデルを確認します: %s", directory)
    judge = TongueColorJudge(ModelPaths.in_directory(directory))
    try:
        result = judge.judge(_preview_image())
    except MlModelError as error:
        logger.error("舌色判定のモデルを使えません: %s", error)
        return 1
    logger.info(
        "動作確認の判定結果: %s(検出スコア %s)", result.status.value, result.detection_score
    )
    return 0


def main(argv: Sequence[str] | None = None) -> int:
    """アプリを起動する.戻り値はプロセスの終了コード.

    Args:
        argv: コマンドライン引数(プログラム名を含まない).省略時は `sys.argv[1:]`.

    Returns:
        プロセスの終了コード(0 が正常終了).
    """
    arguments = _build_parser().parse_args(list(sys.argv[1:] if argv is None else argv))
    logging.basicConfig(level=logging.INFO, format=LOG_FORMAT)

    if arguments.verify_data:
        # 画面を作らずに同梱データと診断の動作だけを確認する
        return _verify_bundled_data()

    if arguments.verify_models:
        # 画面を作らずに舌色判定のモデルの同梱と onnxruntime の動作だけを確認する
        return _verify_bundled_models()

    # GUI ツールキットの import は起動時のみに限定する(`core` は GUI 非依存)
    from PySide6.QtWidgets import QApplication

    app = QApplication.instance() or QApplication(sys.argv[:1])

    # アプリ全体(結果画面・ダイアログを含む)に旧実装と同じアイコンを適用する.
    # アイコンが無くても起動は続行する(`apply_app_icon` が None を返すだけ)
    from tiasjudge.gui.main_window import apply_app_icon

    apply_app_icon(app)

    if arguments.preview_result:
        return _run_preview(
            app,
            failed=arguments.failed,
            english=arguments.english,
            check=arguments.check,
        )

    from tiasjudge.gui.main_window import MainWindow

    window = MainWindow()
    if arguments.check:
        # 画面を構築できることだけを確認して終了する(ヘッドレス環境での確認用)
        window.close()
        return 0

    window.show()
    return int(app.exec())


if __name__ == "__main__":
    raise SystemExit(main())