"""Python 版セグメンテーション(DeepTIAS 2.7 の再現)を正解マスク付きデータで評価する.
`tias-seg eval` の本体.評価セットの種類(`data.sets[].kind`)ごとに次の方式(枠の決め方)で
ResUNet を掛け,前景の IoU・Dice を求める.
- `cropped`(切り出し済み): `whole`(画像全体に掛ける.ResUNet の学習時と同じ条件)
- `fullsize`(撮影画像): `whole`(検出なしで画像全体に掛ける)・`gt_bbox`(正解マスクの外接矩形で
切り出す.検出が完璧な場合の上限)・`detector`(検出モデルの枠で切り出す.モデルがある場合のみ)
どの方式も,後処理(小領域除去)あり(DeepTIAS と同じ)・なし,もう一方の出力チャンネル
(後処理あり)の 3 通りのマスクを 1 回の推論から作る.画像ごとの値(`ImageScore`)は個票として
サーバ上にだけ書き,回収する metrics.json には集計値(`summarize_segmentation`)だけを書く.
ログ・例外には件数と評価セット内の何件目かだけを出し,ファイル名は出さない.
"""
from __future__ import annotations
import logging
import time
from collections.abc import Sequence
from dataclasses import dataclass, field
from typing import Any
import numpy as np
from tias_judge_model.evaluation.segmentation_metrics import iou_and_dice, summarize_scores
from tias_judge_model.schema import SegEvalConfig, SegEvalSetConfig
from tias_judge_model.segmentation.detection import (
OnnxDetector,
Rect,
full_rect,
mask_bounding_rect,
)
from tias_judge_model.segmentation.images import list_image_pairs, read_bgr, read_label_mask
from tias_judge_model.segmentation.pipeline import SegmentationParams, empty_masks, segment_in_rect
from tias_judge_model.segmentation.resunet import CropMasks, ResUNetRunner
logger = logging.getLogger(__name__)
METHOD_WHOLE = "whole"
METHOD_GT_BBOX = "gt_bbox"
METHOD_DETECTOR = "detector"
# 方式の説明(metrics.json に書く).
METHOD_DESCRIPTIONS: dict[str, str] = {
METHOD_WHOLE: "検出なし.画像全体を ResUNet の入力サイズにして掛ける",
METHOD_GT_BBOX: "正解マスクの外接矩形で切り出して掛ける(検出が完璧な場合の上限)",
METHOD_DETECTOR: "検出モデル(スコアがしきい値を超える最高の枠)で切り出して掛ける",
}
@dataclass(frozen=True)
class ImageScore:
"""画像 1 枚・方式 1 つの評価(個票.サーバ上の predictions.csv にだけ書く).
Attributes:
set_name: 評価セットの名前.
index: 評価セット内の位置(stem の昇順で 1 始まり).
stem: ファイル名の stem.
method: 方式.
iou: IoU(後処理あり).
dice: Dice(後処理あり).
iou_no_post: IoU(後処理なし).
dice_no_post: Dice(後処理なし).
iou_other_channel: もう一方の出力チャンネルを使った IoU(後処理あり).
postprocess_applied: 小領域除去をしたか.
detected: 検出できたか(`detector` 以外は None).
detection_score: 検出の最高スコア(`detector` 以外は None).
seconds: 検出・推論・後処理の所要秒数.
"""
set_name: str
index: int
stem: str
method: str
iou: float
dice: float
iou_no_post: float
dice_no_post: float
iou_other_channel: float
postprocess_applied: bool
detected: bool | None
detection_score: float | None
seconds: float
@dataclass
class SegEvalResult:
"""評価全体の結果.
Attributes:
scores: 画像・方式ごとの評価(個票).
methods: 評価セットの名前 → 評価した方式(順序つき).
detector_used: 検出モデルを使ったか.
detector_note: 検出モデルを使わなかった理由(使った場合は None).
loading_seconds: 画像の読み込みの合計秒数.
"""
scores: list[ImageScore] = field(default_factory=list)
methods: dict[str, list[str]] = field(default_factory=dict)
detector_used: bool = False
detector_note: str | None = None
loading_seconds: float = 0.0
def methods_for(kind: str, has_detector: bool) -> list[str]:
"""評価セットの種類から評価する方式を返す.
Args:
kind: `cropped` または `fullsize`.
has_detector: 検出モデルがあるか.
Returns:
方式の一覧.
"""
if kind == "cropped":
return [METHOD_WHOLE]
methods = [METHOD_WHOLE, METHOD_GT_BBOX]
if has_detector:
methods.append(METHOD_DETECTOR)
return methods
def _load_detector(config: SegEvalConfig) -> tuple[OnnxDetector | None, str | None]:
path = config.models.detection_path
if path is None:
return None, "設定で検出モデルを指定していない(models.detection: null)"
if not path.is_file():
# 変換できなかった場合でも検出なし・正解の外接矩形の評価は完走させるため,止めずに省く.
logger.warning("検出モデルの ONNX が無いため,detector の方式を省く: %s", path)
return None, "検出モデルの ONNX が無い"
d = config.detection
detector = OnnxDetector(
path,
score_threshold=d.score_threshold,
input_color=d.input_color,
input_name=d.input_name,
boxes_name=d.boxes_name,
scores_name=d.scores_name,
)
return detector, None
def _segment_by_method(
method: str,
bgr: np.ndarray,
gt_rect: Rect,
runner: ResUNetRunner,
detector: OnnxDetector | None,
params: SegmentationParams,
) -> tuple[CropMasks, bool | None, float | None]:
# 方式ごとに枠を決めて ResUNet を掛ける.戻り値は(`(H, W)` の bool のマスク,検出できたか,
# 検出の最高スコア).検出の 2 つは detector 以外では None.
height, width = bgr.shape[:2]
if method == METHOD_WHOLE:
return segment_in_rect(bgr, full_rect(height, width), runner, params), None, None
if method == METHOD_GT_BBOX:
return segment_in_rect(bgr, gt_rect, runner, params), None, None
if detector is None:
raise RuntimeError("検出モデルが無いのに detector の方式を評価しようとした")
detection = detector.detect(bgr)
masks = (
segment_in_rect(bgr, detection.rect, runner, params)
if detection.rect is not None
else empty_masks(height, width)
)
return masks, detection.rect is not None, detection.score
def _evaluate_set(
set_config: SegEvalSetConfig,
config: SegEvalConfig,
runner: ResUNetRunner,
detector: OnnxDetector | None,
params: SegmentationParams,
result: SegEvalResult,
) -> None:
set_dir = config.data.root / set_config.dir
pairs = list_image_pairs(set_dir, config.data.sample_dirname, config.data.label_dirname)
methods = methods_for(set_config.kind, detector is not None)
result.methods[set_config.name] = methods
logger.info(
"評価セット %s(%s): 画像 %d 件・方式 %s",
set_config.name,
set_config.kind,
len(pairs),
methods,
)
progress_every = max(1, len(pairs) // 10)
for number, pair in enumerate(pairs, start=1):
where = f"評価セット {set_config.name} の {number} 件目"
started = time.monotonic()
bgr = read_bgr(pair.sample, where)
target = read_label_mask(pair.label, config.data.label_threshold, where)
result.loading_seconds += time.monotonic() - started
if target.shape != bgr.shape[:2]:
raise ValueError(
f"{where}: 入力画像 {bgr.shape[1]}×{bgr.shape[0]} と正解マスク "
f"{target.shape[1]}×{target.shape[0]} のサイズが違う"
)
gt_rect = mask_bounding_rect(target)
if gt_rect is None:
raise ValueError(f"{where}: 正解マスクに前景が無い")
for method in methods:
started = time.monotonic()
masks, detected, det_score = _segment_by_method(
method, bgr, gt_rect, runner, detector, params
)
iou, dice = iou_and_dice(masks.post, target)
iou_np, dice_np = iou_and_dice(masks.no_post, target)
iou_other, _ = iou_and_dice(masks.other_channel, target)
result.scores.append(
ImageScore(
set_name=set_config.name,
index=number,
stem=pair.stem,
method=method,
iou=iou,
dice=dice,
iou_no_post=iou_np,
dice_no_post=dice_np,
iou_other_channel=iou_other,
postprocess_applied=masks.postprocess_applied,
detected=detected,
detection_score=det_score,
seconds=time.monotonic() - started,
)
)
if number % progress_every == 0 or number == len(pairs):
logger.info("評価セット %s: %d / %d 件", set_config.name, number, len(pairs))
def run_segmentation_eval(config: SegEvalConfig) -> SegEvalResult:
"""設定の評価セットをすべて評価する.
Args:
config: 評価の設定.
Returns:
評価の結果(画像ごとの値を含む).
Raises:
ValueError: ResUNet の ONNX が無い・形状が合わない,評価セットのディレクトリが無い,
画像を読めない,入力画像と正解マスクの対応が取れない・サイズが違う,正解マスクに
前景が無い等の入力の問題.
"""
runner = ResUNetRunner(config.models.resunet_path, config.resunet.input_size)
detector, note = _load_detector(config)
params = SegmentationParams(
foreground_channel=config.resunet.foreground_channel,
min_area=config.postprocess.min_area,
mask_threshold=config.postprocess.mask_threshold,
)
result = SegEvalResult(detector_used=detector is not None, detector_note=note)
for set_config in config.data.sets:
_evaluate_set(set_config, config, runner, detector, params, result)
return result
def _scores_of(scores: Sequence[ImageScore], set_name: str, method: str) -> list[ImageScore]:
return [s for s in scores if s.set_name == set_name and s.method == method]
def ious_by_method(result: SegEvalResult, set_name: str) -> dict[str, list[float]]:
"""評価セットの画像ごとの IoU(後処理あり)を方式ごとに返す(図を描くため).
Args:
result: 評価の結果.
set_name: 評価セットの名前.
Returns:
方式(評価した順)→ 画像ごとの IoU(評価セット内の順).
"""
return {
method: [s.iou for s in _scores_of(result.scores, set_name, method)]
for method in result.methods.get(set_name, [])
}
def _summarize_method(
method: str, scores: Sequence[ImageScore], config: SegEvalConfig
) -> dict[str, Any]:
threshold = config.evaluation.iou_threshold
entry: dict[str, Any] = {
"description": METHOD_DESCRIPTIONS[method],
"iou": summarize_scores([s.iou for s in scores], threshold),
"dice": summarize_scores([s.dice for s in scores], threshold),
"iou_no_post": summarize_scores([s.iou_no_post for s in scores], threshold),
"dice_no_post": summarize_scores([s.dice_no_post for s in scores], threshold),
"iou_other_channel": summarize_scores([s.iou_other_channel for s in scores], threshold),
"n_postprocess_applied": sum(s.postprocess_applied for s in scores),
"mean_seconds": round(float(np.mean([s.seconds for s in scores])), 4) if scores else None,
}
if method == METHOD_DETECTOR:
det_scores = [s.detection_score for s in scores if s.detection_score is not None]
entry["detection"] = {
"n_detected": sum(bool(s.detected) for s in scores),
"n_not_detected": sum(not s.detected for s in scores),
"score_threshold": config.detection.score_threshold,
"top_score": summarize_scores(det_scores, config.detection.score_threshold),
}
return entry
def summarize_segmentation(result: SegEvalResult, config: SegEvalConfig) -> dict[str, Any]:
"""評価セット・方式ごとの集計値を作る(metrics.json の `sets`.画像単位の値は含めない).
Args:
result: 評価の結果.
config: 評価の設定.
Returns:
評価セットの名前 → `{dir, kind, n_images, methods: {方式: 集計}}`.方式ごとの集計は
`iou`・`dice`(後処理あり)・`iou_no_post`・`dice_no_post`・`iou_other_channel` の
`summarize_scores`,小領域除去をした件数,所要秒数の平均,`detector` では検出の件数.
"""
sets: dict[str, Any] = {}
for set_config in config.data.sets:
methods: dict[str, Any] = {}
n_images = 0
for method in result.methods.get(set_config.name, []):
scores = _scores_of(result.scores, set_config.name, method)
n_images = len(scores)
methods[method] = _summarize_method(method, scores, config)
sets[set_config.name] = {
"dir": set_config.dir.as_posix(),
"kind": set_config.kind,
"n_images": n_images,
"methods": methods,
}
return sets
def log_summary(sets: dict[str, Any]) -> None:
"""集計値をログに出す(出力チャンネルの取り違えに気づけるよう,もう一方のチャンネルも出す).
Args:
sets: `summarize_segmentation` の戻り値.
"""
for set_name, entry in sets.items():
for method, m in entry["methods"].items():
iou, iou_np, other = m["iou"], m["iou_no_post"], m["iou_other_channel"]
if not iou["n"]:
continue
logger.info(
"%s / %s: IoU 平均 %.4f・中央値 %.4f・p10 %.4f・最小 %.4f・%.2f 未満 %d / %d 件"
"(後処理なし: 平均 %.4f・%.2f 未満 %d 件.小領域除去 %d 件)・Dice 平均 %.4f",
set_name,
method,
iou["mean"],
iou["median"],
iou["p10"],
iou["min"],
iou["below_threshold"],
iou["n_below"],
iou["n"],
iou_np["mean"],
iou_np["below_threshold"],
iou_np["n_below"],
m["n_postprocess_applied"],
m["dice"]["mean"],
)
logger.info(
"%s / %s: もう一方の出力チャンネルを使った場合の IoU 平均 %.4f・中央値 %.4f",
set_name,
method,
other["mean"],
other["median"],
)
if other["mean"] > iou["mean"]:
logger.warning(
"%s / %s: もう一方の出力チャンネルのほうが IoU が高い(%.4f > %.4f)."
"resunet.foreground_channel の取り違えを確認する",
set_name,
method,
other["mean"],
iou["mean"],
)
detection = m.get("detection")
if detection:
logger.info(
"%s / %s: 検出できた %d 件・できなかった %d 件(しきい値 %.2f)",
set_name,
method,
detection["n_detected"],
detection["n_not_detected"],
detection["score_threshold"],
)