"""結果画面(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)