Newer
Older
TIASjudge / src / tiasjudge / gui / result_window.py
"""結果画面(SPEC_01「結果画面」).

旧実装(`org/src` の `CResultDlg`)のモードレスな結果ダイアログを踏襲する.

- 左: 診断に用いたクロップ後画像を 1/2 縮小表示する
- 右: 5 色(淡紅・紅・暗赤・紫・淡白紅)の行を一覧表示し,**上位 2 色のみ**スコア(%)と
  下線を描画する(`pale` の表示名はメイン画面の「淡白」と異なり,旧背景画像どおり「淡白紅」)
- 操作ボタンは持たない(表示専用).言語切替と「閉じる」はメイン画面側のボタンで行う
  (患者側モニタに操作 UI を出さないため)
- 診断失敗時(`DiagnosisResult.failed`)は結果パネルに「Diagnosis failed / Please try again.」
  を重ねて描画する

2 画面運用(施術者側にメイン画面・患者側に結果画面)を想定し,メイン画面とは別のモニタが
あればそこへ最大化して表示する(`show_on_secondary_screen`).モニタが 1 台の場合は
従来どおり同じモニタに通常サイズで表示する.

旧実装の日本語版は文字入りビットマップ(`img/img_student2.bmp`)を背景に使っていたが,
新実装では日英とも描画で生成し画像アセットへの依存を無くす(/setup 時の設計判断.
表示内容・レイアウトは現行踏襲).座標は旧実装の `ShowResultImg()` /
`BuildEnglishBackground()` の値をそのまま使う.見出しと 5 色の色名の文字色は,旧背景画像
`img/img_student2.bmp` から実測した値を使い(旧ソースの `Scalar(...)` 指定は画像と一致
しないため),色名には旧画像と同じ黒い縁取りを付ける(日英とも同じ扱い).

依存方向は `gui` → `core` の一方向とする(project-structure.md).表示文字列の組み立て・
強調対象の決定・言語リソースは QWidget 非依存の定数・関数に分離してある.
"""

from __future__ import annotations

import logging
from collections.abc import Sequence
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,
    QPainterPath,
    QPen,
    QPixmap,
    QScreen,
)
from PySide6.QtWidgets import (
    QFrame,
    QHBoxLayout,
    QLabel,
    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 位置.説明文の x は旧背景画像の実測値
#: (説明文のインクが x=206 から始まる)に合わせてある.文字を旧画像相当まで拡大した際に
#: 色名の描画幅を確保するための調整でもある(旧実装のコード上の値は 185)
NAME_X = 20
DESCRIPTION_X = 206
SCORE_RIGHT_X = 610
PERCENT_X = 620
#: スコア表示のために空けておく幅(強調行の説明文はこの手前で切り詰める).
#: スコア最大表示 "100" は `SCORE_FONT_SIZE` で約 73 px 幅のため,余白を含めて 80 とする
SCORE_AREA_WIDTH = 80
#: 文字列の右端に確保する余白
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
#: 所属表記の最大幅(右端をはみ出さないための保険)
AFFILIATION_MAX_WIDTH = PANEL_SIZE - AFFILIATION_POSITION[0] - TEXT_MARGIN

#: 下線の 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)
#: 見出しのオレンジ.旧背景画像 `img/img_student2.bmp` の見出し「分析結果」から実測した
#: 最頻値(旧コードの `Scalar(0, 90, 230)` = RGB(230, 90, 0) より画像の実値を優先する).
#: 英語版見出しも旧 `BuildEnglishBackground()` が日本語と同色を使うため同じ値を用いる
HEADING_COLOR = (233, 113, 50)
#: 注記・所属のグレー(旧 `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)

#: 色名の表示色.旧背景画像 `img/img_student2.bmp` の各色名文字からピクセルを
#: サンプリングして得た最頻値(旧 `BuildEnglishBackground()` の cPink / cRed /
#: cDarkRed / cPurple / cPale は画像と一致しないため,画像の実測値を正とする)
COLOR_SWATCHES: dict[str, tuple[int, int, int]] = {
    "pink": (255, 187, 174),
    "crimson": (224, 112, 146),
    "dark_red": (214, 69, 65),
    "purple": (125, 94, 125),
    "pale": (255, 241, 167),
}

#: 色名の縁取り色と太さ.旧背景画像の色名文字には暗色の縁取りがあり,走査線を実測すると
#: 文字の外側 1 px 程度が暗色(アンチエイリアスで平均 RGB(30〜45) 相当)になっている.
#: `strokePath` のペン幅はパスの内外へ半分ずつ広がるため,外側 1 px 相当となる 2 を使う.
#: 縁取りを付けるのは 5 色の色名のみ(見出し・説明文・スコア・注記は縁取りしない)
NAME_OUTLINE_COLOR = (0, 0, 0)
NAME_OUTLINE_WIDTH = 2

# --- フォント(px 指定.環境差で行がずれないよう固定する)---

#: パネル描画に使うフォントの優先順(日本語の字形を確実に出すため明示する.
#: 見つからない環境では既定フォントにフォールバックする)
PANEL_FONT_FAMILIES: tuple[str, ...] = (
    "Yu Gothic UI",
    "Meiryo",
    "MS PGothic",
    "Hiragino Sans",
    "Noto Sans CJK JP",
)

#: 各サイズは旧背景画像 `img/img_student2.bmp` の文字のインク高さ(実測)に合わせてある.
#: 実測値: 見出し 64 px / 所属 18 px / 注記 26 px / 色名 50 px / 説明文 40 px
#: (説明文は旧画像のインク高 40 px に対し,ベース 40 px でほぼ同等の見た目になる).
#: ただし見出しはベースライン y=55 を動かさない制約から,パネル上端をはみ出さない
#: 範囲(インク高さ 52 px)が上限となるため 56 px で頭打ちにしている.
#: 色名は英語の "Dark red" が `NAME_MAX_WIDTH` に収まる範囲が上限となるため 44 px とする
#: (日英で挙動を変えないよう,言語別のサイズは設けない).
HEADING_FONT_SIZE = 56
AFFILIATION_FONT_SIZE = 19
NOTE_FONT_SIZE = 28
NAME_FONT_SIZE = 44
#: 説明文の基準サイズ(既定).行ごとの値は `ColorRowText.description_font_size` で上書きする
DESCRIPTION_FONT_SIZE = 40
#: 説明文が長い行のサイズ(px).英語は語数が多く,基準の 40 px では強調行の上限幅
#: (`DESCRIPTION_MAX_WIDTH_HIGHLIGHTED` = 324 px.スコア欄を除いた幅)に収まらない行がある.
#: 収まらないまま描くと強調された行だけ動的縮小が働き,上位 2 色が変わるたびに
#: 文字サイズがちらつくため,**強調の有無によらず固定**の小さいサイズを行ごとに与える.
#: 値は「スコア表示付きの強調行でも縮小なしで収まる最大サイズ」の実測値:
#: "Hot flush / feverish" と "Blood slightly thick" が 36 px,
#: 最長の "Cold / fatigue / anemia" が 30 px(31 px では 337 px となり超過する)
DESCRIPTION_FONT_SIZE_LONG = 36
DESCRIPTION_FONT_SIZE_LONGEST = 30
#: 説明文を縮めてよい下限(強調行は右側にスコア欄がある分だけ幅が狭いため.
#: 上記のとおり通常は発動しないが,フォント差のある環境向けの安全網として残す)
DESCRIPTION_MIN_FONT_SIZE = 28
#: 説明文(症状)の太さ.既定よりわずかに太くしつつ,太さより字の大きさで読みやすさを
#: 稼ぐ方針として Medium に留める(DemiBold は太すぎ・Bold は色名より目立つ.説明文のみに適用)
DESCRIPTION_FONT_WEIGHT = QFont.Weight.Medium
SCORE_FONT_SIZE = 48
PERCENT_FONT_SIZE = 24
FAILURE_TITLE_FONT_SIZE = 52
FAILURE_DETAIL_FONT_SIZE = 28

#: 診断失敗の表示文言(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: 色名の右に表示する説明.
        description_font_size: 説明文のサイズ(px).長い説明の行だけ小さくする.
    """

    key: str
    name: str
    description: str
    description_font_size: int = DESCRIPTION_FONT_SIZE


@dataclass(frozen=True)
class LanguageTexts:
    """1 言語分の表示リソース(SPEC_01「5 色の表示内容」).

    Attributes:
        window_title: ウィンドウタイトル.
        heading: パネル見出し.
        affiliations: 見出し右上の所属表記(無い言語は空タプル).
        notes: 注記(2 行).
        rows: 5 色の行(`DISPLAY_ORDER` と同じ並び).
        switch_button_label: 言語切替ボタンのラベル(切替**先**の言語名).
            結果画面は表示専用のため,このラベルを使うのはメイン画面「結果画面の操作」の
            言語切替ボタン(`switch_button_label()`).
    """

    window_title: str
    heading: str
    affiliations: tuple[str, ...]
    notes: tuple[str, ...]
    rows: tuple[ColorRowText, ...]
    switch_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",
    ),
    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.",
        ),
        # 説明文が長い 3 行だけサイズを下げる(値の根拠は `DESCRIPTION_FONT_SIZE_LONG` 参照)
        rows=(
            ColorRowText("pink", "Pink", "Normal"),
            ColorRowText(
                "crimson",
                "Red",
                "Hot flush / feverish",
                description_font_size=DESCRIPTION_FONT_SIZE_LONG,
            ),
            ColorRowText(
                "dark_red",
                "Dark red",
                "Blood slightly thick",
                description_font_size=DESCRIPTION_FONT_SIZE_LONG,
            ),
            ColorRowText("purple", "Purple", "Blood is thick"),
            ColorRowText(
                "pale",
                "Pale red",
                "Cold / fatigue / anemia",
                description_font_size=DESCRIPTION_FONT_SIZE_LONGEST,
            ),
        ),
        switch_button_label="日本語",
    ),
}


# --- 画面に依存しないロジック(QWidget 非依存.単体で検証できる)---


@dataclass(frozen=True)
class ResultRow:
    """結果パネルの 1 行分の表示内容.

    Attributes:
        key: 色のキー.
        name: 表示する色名.
        description: 説明文.
        swatch: 色名の表示色(RGB).
        highlighted: 上位 2 色(スコアと下線を描く行)なら True.
        score_text: 強調行に表示するスコア文字列.非強調行は None.
        description_font_size: 説明文のサイズ(px).強調の有無によらず一定.
    """

    key: str
    name: str
    description: str
    swatch: tuple[int, int, int]
    highlighted: bool
    score_text: str | None
    description_font_size: int = DESCRIPTION_FONT_SIZE


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,
                description_font_size=row_text.description_font_size,
            )
        )
    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,
    weight: QFont.Weight | None = None,
) -> None:
    """描画に使うフォント(書体・サイズ・太さ)を設定する.

    `weight` を指定すると `bold` より優先し,Bold 以外の太さ(DemiBold 等)も選べる.
    """
    font = QFont(painter.font())
    font.setFamilies([*PANEL_FONT_FAMILIES, font.family()])
    font.setPixelSize(pixel_size)
    if weight is not None:
        font.setWeight(weight)
    else:
        font.setBold(bold)
    painter.setFont(font)


def _fit_font(
    painter: QPainter,
    text: str,
    pixel_size: int,
    max_width: int,
    min_pixel_size: int,
    *,
    bold: bool = False,
    weight: QFont.Weight | None = None,
) -> None:
    """`max_width` に収まる最大のサイズでフォントを設定する(下限は `min_pixel_size`).

    文字を旧背景画像に合わせて大きくした結果,長い説明文(英語の
    "Cold / fatigue / anemia" 等)がスコア欄に達する行が出るため,末尾を省略する前に
    まず文字サイズを縮めて全文を残す.下限まで縮めても収まらない場合は
    呼び出し元の省略(elide)に委ねる.
    """
    for size in range(pixel_size, min_pixel_size - 1, -1):
        _set_font(painter, size, bold=bold, weight=weight)
        if painter.fontMetrics().horizontalAdvance(text) <= max_width:
            return


def _prepare_text(
    painter: QPainter,
    text: str,
    pixel_size: int,
    *,
    bold: bool = False,
    weight: QFont.Weight | None = None,
    max_width: int | None = None,
    min_pixel_size: int | None = None,
) -> str:
    """フォントを設定し,`max_width` に収まるよう調整した描画用の文字列を返す.

    `max_width` を指定すると,その幅に収まらない文字列を末尾省略(...)で切り詰める
    (環境ごとのフォント差で隣の要素へはみ出さないようにするため).
    `min_pixel_size` も指定した場合は,省略する前にその大きさまで文字を縮めて全文を残す
    (`_fit_font` 参照).`weight` を指定すると `bold` より優先する(`_set_font` 参照).
    """
    if max_width is not None and min_pixel_size is not None:
        _fit_font(painter, text, pixel_size, max_width, min_pixel_size, bold=bold, weight=weight)
    else:
        _set_font(painter, pixel_size, bold=bold, weight=weight)
    if max_width is None:
        return text
    return painter.fontMetrics().elidedText(text, Qt.TextElideMode.ElideRight, max_width)


def _draw_text(
    painter: QPainter,
    position: tuple[int, int],
    text: str,
    color: tuple[int, int, int],
    pixel_size: int,
    *,
    bold: bool = False,
    weight: QFont.Weight | None = None,
    max_width: int | None = None,
    min_pixel_size: int | None = None,
) -> None:
    """ベースライン基準で文字列を描く(旧実装の `cv::putText` に対応).

    フォントの設定・切り詰めの扱いは `_prepare_text` を参照.
    """
    drawn = _prepare_text(
        painter,
        text,
        pixel_size,
        bold=bold,
        weight=weight,
        max_width=max_width,
        min_pixel_size=min_pixel_size,
    )
    painter.setPen(QColor(*color))
    painter.drawText(position[0], position[1], drawn)


def _draw_outlined_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:
    """ベースライン基準で,黒い縁取りを付けた文字列を描く(旧背景画像の色名の見え方).

    `drawText` では縁取りを描けないため,`QPainterPath` で文字をパス化し,
    黒ペンで輪郭を描いてから本来の色で塗りつぶす.フォントの設定・切り詰め(`max_width`)の
    扱いは `_draw_text` と同じ(`_prepare_text` を共用する).
    """
    drawn = _prepare_text(painter, text, pixel_size, bold=bold, max_width=max_width)

    path = QPainterPath()
    path.addText(float(position[0]), float(position[1]), painter.font(), drawn)

    pen = QPen(QColor(*NAME_OUTLINE_COLOR))
    pen.setWidth(NAME_OUTLINE_WIDTH)
    # 角の尖りで文字が太って見えないよう,線の継ぎ目は丸める
    pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
    pen.setCapStyle(Qt.PenCapStyle.RoundCap)

    painter.save()
    try:
        painter.strokePath(path, pen)
        painter.fillPath(path, QColor(*color))
    finally:
        painter.restore()


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,
            max_width=AFFILIATION_MAX_WIDTH,
        )
    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 色のスコア・下線を描く.

    説明文のサイズは行ごと(`ResultRow.description_font_size`)に決まっており,
    強調の有無で変わらない.
    """
    for index, row in enumerate(rows):
        baseline = row_baseline(index)
        # 色名だけは旧背景画像に合わせて黒縁取りを付ける(日英とも同じ扱い)
        _draw_outlined_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,
            row.description_font_size,
            weight=DESCRIPTION_FONT_WEIGHT,
            max_width=(
                DESCRIPTION_MAX_WIDTH_HIGHLIGHTED if row.highlighted else DESCRIPTION_MAX_WIDTH
            ),
            # 強調行はスコア欄の分だけ幅が狭いため,収まらなければ縮めて全文を残す
            min_pixel_size=DESCRIPTION_MIN_FONT_SIZE,
        )
        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,
    )


# --- 表示先モニタの選択(QWidget 非依存.`QScreen` の一覧と基準だけで決まる)---


def is_same_screen(screen: QScreen, other: QScreen | None) -> bool:
    """2 つのモニタが同一かどうかを返す.

    `QApplication.screens()` と `QWidget.screen()` が別の Python ラッパを返しても
    同一と判定できるよう,同一性が取れない場合はモニタ名で比較する.
    """
    if other is None:
        return False
    if screen is other:
        return True
    return bool(screen.name() == other.name())


def select_secondary_screen(
    screens: Sequence[QScreen], reference: QScreen | None
) -> QScreen | None:
    """`reference` と異なる最初のモニタを返す(無ければ None).

    2 画面運用(施術者側にメイン画面・患者側に結果画面)で,結果画面を映す
    「もう一方のモニタ」を決めるために使う.モニタが 1 台しか無い場合や,
    `reference` 以外のモニタが見つからない場合は None を返し,呼び出し元は
    従来どおり(同じモニタに通常サイズで)表示する.

    Args:
        screens: 対象となるモニタの一覧(`QApplication.screens()`).
        reference: 基準となるモニタ(メイン画面のあるモニタ).None なら先頭を返す.

    Returns:
        表示先にすべきモニタ.該当が無ければ None.
    """
    for screen in screens:
        if not is_same_screen(screen, reference):
            return screen
    return None


def show_on_secondary_screen(
    window: QWidget, screens: Sequence[QScreen], reference: QScreen | None
) -> bool:
    """`reference` 以外のモニタがあれば,そこへ移してから最大化表示する.

    移動より先に最大化すると元のモニタで最大化されてしまうため,
    「対象モニタへ移す → 最大化」の順で呼ぶ.

    Args:
        window: 表示するウィンドウ.
        screens: 対象となるモニタの一覧.
        reference: 基準となるモニタ(このモニタは選ばない).

    Returns:
        別モニタへ最大化表示したなら True.該当モニタが無く通常表示したなら False.
    """
    target = select_secondary_screen(screens, reference)
    if target is None:
        # モニタが 1 台しか無い場合は従来どおり通常サイズで表示する
        window.show()
        return False
    logger.info("結果画面をサブモニタに最大化表示します: %s", target.name())
    window.setScreen(target)
    # 最大化の前に対象モニタの領域へ移しておく(この順序でないと元のモニタで最大化される)
    window.setGeometry(target.availableGeometry())
    window.showMaximized()
    return True


# --- 画面部品 ---


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:
        """ウィジェットを組み立てる(左: 診断画像/右: 結果パネル).

        操作ボタンは持たない(患者側モニタに操作 UI を出さないため,言語切替と
        「閉じる」はメイン画面側に置く).

        結果パネルは 644x644 固定描画のため,サブモニタへ最大化したときは周囲が
        余白になる.余白が偏らないよう,左右と上下に伸縮スペースを入れて内容を
        ウィンドウ中央へ寄せる(通常サイズでは余白が生じないため見た目は変わらない).
        """
        layout = QHBoxLayout(self)
        layout.addStretch(1)

        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.AlignVCenter)

        self._panel_label = QLabel(self)
        self._panel_label.setFixedSize(PANEL_SIZE, PANEL_SIZE)
        layout.addWidget(self._panel_label, 0, Qt.AlignmentFlag.AlignVCenter)
        layout.addStretch(1)

    # --- 表示の更新 ---

    def set_language(self, language: Language) -> None:
        """表示言語を設定して描き直す(メイン画面の言語切替ボタンから呼ぶ).

        Args:
            language: 表示する言語.現在と同じなら何もしない.
        """
        if language is self._language:
            return
        self._language = language
        logger.info("結果画面の言語を切り替えました: %s", self._language.value)
        self._apply_language()

    def toggle_language(self) -> None:
        """日本語 ⇔ 英語を切り替える(言語状態はメイン画面が持つため主に内部・試験用)."""
        self.set_language(next_language(self._language))

    def _apply_language(self) -> None:
        """現在の言語でタイトルと結果パネルを描き直す."""
        texts = texts_for(self._language)
        self.setWindowTitle(texts.window_title)
        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)