Files
E01_textural_features_blast…/src/evaluation/subject_figure_rendering.py
T
2026-09-11 08:28:02 +03:00

559 lines
21 KiB
Python

from __future__ import annotations
import os
from pathlib import Path
from typing import Any
MPLCONFIGDIR = Path(__file__).resolve().parents[2] / "outputs" / "logs" / "matplotlib"
MPLCONFIGDIR.mkdir(parents=True, exist_ok=True)
os.environ.setdefault("MPLCONFIGDIR", str(MPLCONFIGDIR))
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib import patches
import numpy as np
from preprocessing.image_io import load_rgb_image
from utils.project import ensure_dir
DPI = 220
BLAST_COLOR = "#d62728"
NON_BLAST_COLOR = "#1f77b4"
TEXT_BOX_EDGE = "#c7c7c7"
def _short_text(value: Any, max_length: int = 26) -> str:
text = str(value)
if len(text) <= max_length:
return text
head = max(6, max_length // 2 - 2)
tail = max(6, max_length - head - 3)
return f"{text[:head]}...{text[-tail:]}"
def _binary_label(value: int | None) -> str:
if value is None:
return "n/a"
return "blast" if int(value) == 1 else "non-blast"
def _save(fig: plt.Figure, output_path: Path) -> str:
ensure_dir(output_path.parent)
fig.tight_layout()
fig.savefig(output_path, dpi=DPI, bbox_inches="tight", facecolor="white")
plt.close(fig)
return str(output_path)
def _add_panel_badge(ax: plt.Axes, label: str) -> None:
ax.text(
0.02,
0.98,
label,
transform=ax.transAxes,
ha="left",
va="top",
fontsize=12,
fontweight="bold",
color="black",
bbox={"facecolor": "white", "edgecolor": "black", "pad": 2.5},
)
def _draw_text_box(ax: plt.Axes, title: str, lines: list[str]) -> None:
ax.axis("off")
ax.set_facecolor("white")
ax.add_patch(
patches.FancyBboxPatch(
(0.0, 0.0),
1.0,
1.0,
boxstyle="round,pad=0.02,rounding_size=0.02",
linewidth=1.0,
edgecolor=TEXT_BOX_EDGE,
facecolor="white",
transform=ax.transAxes,
)
)
ax.text(0.04, 0.96, title, transform=ax.transAxes, ha="left", va="top", fontsize=10, fontweight="bold")
ax.text(0.04, 0.88, "\n".join(lines), transform=ax.transAxes, ha="left", va="top", fontsize=8.5)
def _draw_image_panel(ax: plt.Axes, image_path: str, title: str = "", subtitle: str | None = None) -> None:
image = load_rgb_image(image_path)
ax.imshow(image)
ax.axis("off")
if title:
ax.set_title(title, fontsize=10)
if subtitle:
ax.text(0.5, -0.08, subtitle, transform=ax.transAxes, ha="center", va="top", fontsize=8)
def _draw_roi_panel(
ax: plt.Axes,
row: dict[str, Any],
*,
image_column: str = "bbox_crop_path",
title: str = "",
subtitle: str | None = None,
) -> None:
image = load_rgb_image(str(row[image_column]))
ax.imshow(image)
ax.axis("off")
if title:
ax.set_title(title, fontsize=9)
if subtitle:
ax.text(0.5, -0.08, subtitle, transform=ax.transAxes, ha="center", va="top", fontsize=7.5)
def _draw_smear_with_boxes(
ax: plt.Axes,
image_path: str,
object_rows: list[dict[str, Any]],
*,
title: str,
labels: list[str] | None = None,
show_scores: bool = False,
) -> None:
image = load_rgb_image(image_path)
ax.imshow(image)
ax.axis("off")
ax.set_title(title, fontsize=10)
for index, row in enumerate(object_rows, start=1):
color = BLAST_COLOR if int(row.get("target_binary", row.get("y_true", 0))) == 1 else NON_BLAST_COLOR
x1 = float(row["bbox_x1"])
y1 = float(row["bbox_y1"])
width = float(row["bbox_width_px"])
height = float(row["bbox_height_px"])
ax.add_patch(
patches.Rectangle((x1, y1), width, height, linewidth=1.6, edgecolor=color, facecolor="none")
)
label = labels[index - 1] if labels and index - 1 < len(labels) else f"#{index}"
if show_scores and "y_score" in row:
label = f"{label} {float(row['y_score']):.2f}"
ax.text(
x1,
max(2.0, y1 - 4.0),
label,
fontsize=7,
color="white",
bbox={"facecolor": color, "edgecolor": color, "pad": 1.4},
)
def _score_lines(method_rows: list[dict[str, Any]]) -> list[str]:
lines: list[str] = []
for row in method_rows:
lines.append(f"{row['aggregation_method']}: {float(row['score']):.3f}")
return lines
def _render_a01_f1(spec: dict[str, Any], output_path: Path) -> str:
class_rows = spec["class_rows"]
fig, axes = plt.subplots(len(class_rows), 4, figsize=(11.8, 15.2))
if len(class_rows) == 1:
axes = np.asarray([axes])
for row_index, group in enumerate(class_rows):
for col_index in range(4):
row = group["rows"][col_index]
title = group["class_name"] if col_index == 0 else ""
_draw_roi_panel(axes[row_index, col_index], row, title=title, subtitle=str(row["class_name"]))
fig.suptitle(spec["title"], fontsize=13)
return _save(fig, output_path)
def _render_a01_f2(spec: dict[str, Any], output_path: Path) -> str:
object_rows = spec["object_rows"]
fig = plt.figure(figsize=(13.2, 10.6))
grid = fig.add_gridspec(3, 4, height_ratios=[1.2, 1.0, 1.0])
ax_a = fig.add_subplot(grid[0, :2])
ax_b = fig.add_subplot(grid[0, 2:])
_draw_image_panel(
ax_a,
spec["image_row"]["image_path"],
title=f"Smear image | patient {_short_text(spec['image_row']['patient_id'], 18)}",
)
_add_panel_badge(ax_a, "A")
_draw_smear_with_boxes(
ax_b,
spec["image_row"]["image_path"],
object_rows,
title=f"Selected bounding boxes | image {_short_text(spec['image_row']['image_id'], 18)}",
labels=[str(index) for index in range(1, len(object_rows) + 1)],
)
_add_panel_badge(ax_b, "B")
roi_positions = [(1, 0), (1, 1), (1, 2), (1, 3), (2, 0), (2, 1)]
for slot, (row_index, col_index) in enumerate(roi_positions, start=1):
ax = fig.add_subplot(grid[row_index, col_index])
if slot <= len(object_rows):
row = object_rows[slot - 1]
_draw_roi_panel(ax, row, title=f"#{slot} {_short_text(row['class_name'], 18)}")
if slot == 1:
_add_panel_badge(ax, "C")
else:
ax.axis("off")
for col_index in [2, 3]:
ax = fig.add_subplot(grid[2, col_index])
ax.axis("off")
fig.suptitle(spec["title"], fontsize=13)
return _save(fig, output_path)
def _render_a01_f3(spec: dict[str, Any], output_path: Path) -> str:
object_rows = spec["object_rows"]
fig, axes = plt.subplots(len(object_rows), 3, figsize=(12.4, 15.6))
if len(object_rows) == 1:
axes = np.asarray([axes])
column_titles = ["bbox crop", "mask crop", "mask-tight crop"]
for column_index, column_title in enumerate(column_titles):
axes[0, column_index].set_title(column_title, fontsize=10)
for row_index, row in enumerate(object_rows):
score_line = (
f"bbox={float(row['bbox_only_y_score']):.3f} | "
f"mask={float(row['mask_only_y_score']):.3f} | "
f"combined={float(row['bbox_plus_mask_y_score']):.3f}"
)
_draw_roi_panel(
axes[row_index, 0],
row,
image_column="bbox_crop_path",
title=f"{_short_text(row['class_name'], 20)} | {_binary_label(int(row['target_binary']))}",
subtitle=score_line,
)
_draw_roi_panel(axes[row_index, 1], row, image_column="mask_crop_path")
_draw_roi_panel(axes[row_index, 2], row, image_column="mask_tight_crop_path")
fig.suptitle(spec["title"], fontsize=13)
return _save(fig, output_path)
def _render_a01_f4(spec: dict[str, Any], output_path: Path) -> str:
grid_rows = spec["grid_rows"]
fig, axes = plt.subplots(len(grid_rows), 3, figsize=(12.2, 13.8))
if len(grid_rows) == 1:
axes = np.asarray([axes])
column_titles = [
"clean_cell references",
"objects removed by strict_morphology",
"full_realistic difficult cases",
]
for column_index, column_title in enumerate(column_titles):
axes[0, column_index].set_title(column_title, fontsize=10)
for row_index, row_group in enumerate(grid_rows):
for column_index, key in enumerate(["clean", "strict_removed", "full_problem"]):
row = row_group[key]
_draw_roi_panel(
axes[row_index, column_index],
row,
title=_short_text(row["class_name"], 22),
subtitle=_binary_label(int(row["target_binary"])) if row.get("target_binary") is not None else None,
)
fig.suptitle(spec["title"], fontsize=13)
return _save(fig, output_path)
def _render_a01_f5(spec: dict[str, Any], output_path: Path) -> str:
outcome_rows = spec["outcome_rows"]
fig, axes = plt.subplots(4, 4, figsize=(12.8, 12.8))
for row_index, outcome_label in enumerate(["TP", "TN", "FP", "FN"]):
rows = outcome_rows[outcome_label]
for col_index in range(4):
ax = axes[row_index, col_index]
row = rows[col_index]
subtitle = (
f"true={row['true_label']} | pred={row['predicted_label']}\n"
f"score={float(row['y_score']):.3f}"
)
title = f"{outcome_label} | {_short_text(row['class_name'], 18)}" if col_index == 0 else _short_text(row["class_name"], 18)
_draw_roi_panel(ax, row, title=title, subtitle=subtitle)
fig.suptitle(spec["title"], fontsize=13)
return _save(fig, output_path)
def _render_a01_f6(spec: dict[str, Any], output_path: Path) -> str:
confident_rows = spec["confident_rows"]
disagreement_rows = spec["disagreement_rows"]
fig, axes = plt.subplots(4, 2, figsize=(12.2, 15.6))
axes[0, 0].set_title("handcrafted confident and correct", fontsize=10)
axes[0, 1].set_title("model disagreement on the same cell", fontsize=10)
for row_index in range(4):
confident = confident_rows[row_index]
disagreement = disagreement_rows[row_index]
confident_subtitle = (
f"HCF={float(confident['handcrafted_y_score']):.3f} | "
f"BBOX={float(confident['cnn_bbox_y_score']):.3f}\n"
f"MASK={float(confident['cnn_mask_y_score']):.3f} | "
f"FUSION={float(confident['cnn_late_fusion_y_score']):.3f}"
)
disagreement_subtitle = (
f"HCF={float(disagreement['handcrafted_y_score']):.3f} | "
f"BBOX={float(disagreement['cnn_bbox_y_score']):.3f}\n"
f"MASK={float(disagreement['cnn_mask_y_score']):.3f} | "
f"FUSION={float(disagreement['cnn_late_fusion_y_score']):.3f}"
)
_draw_roi_panel(
axes[row_index, 0],
confident,
title=f"{_short_text(confident['class_name'], 18)} | {_binary_label(int(confident['y_true']))}",
subtitle=confident_subtitle,
)
_draw_roi_panel(
axes[row_index, 1],
disagreement,
title=f"{_short_text(disagreement['class_name'], 18)} | {_binary_label(int(disagreement['y_true']))}",
subtitle=disagreement_subtitle,
)
fig.suptitle(spec["title"], fontsize=13)
return _save(fig, output_path)
def _render_a02_f1(spec: dict[str, Any], output_path: Path) -> str:
object_rows = spec["top_object_rows"]
fig = plt.figure(figsize=(13.6, 10.8))
grid = fig.add_gridspec(4, 4, height_ratios=[1.15, 1.15, 1.0, 1.0])
ax_a = fig.add_subplot(grid[:2, :2])
ax_b = fig.add_subplot(grid[:2, 2:])
_draw_image_panel(
ax_a,
spec["image_row"]["image_path"],
title=f"Original image | {_short_text(spec['image_row']['patient_id'], 18)}",
subtitle=_short_text(spec["image_row"]["image_id"], 22),
)
_add_panel_badge(ax_a, "A")
_draw_smear_with_boxes(
ax_b,
spec["image_row"]["image_path"],
object_rows,
title="Top object-level detections on the same image",
labels=[f"#{index}" for index in range(1, len(object_rows) + 1)],
show_scores=True,
)
_add_panel_badge(ax_b, "B")
for slot in range(6):
row_index = 2 + slot // 3
col_index = slot % 3
ax = fig.add_subplot(grid[row_index, col_index])
row = object_rows[slot]
_draw_roi_panel(
ax,
row,
title=f"#{slot + 1} {_short_text(row['class_name'], 16)}",
subtitle=f"score={float(row['y_score']):.3f}",
)
if slot == 0:
_add_panel_badge(ax, "C")
ax_d = fig.add_subplot(grid[2:, 3])
_draw_text_box(
ax_d,
"Image-level aggregation",
_score_lines(spec["method_rows"])
+ ["", f"n_objects={int(spec['image_row']['n_objects'])}", f"spread={float(spec['image_row']['method_spread']):.3f}"],
)
_add_panel_badge(ax_d, "D")
fig.suptitle(spec["title"], fontsize=13)
return _save(fig, output_path)
def _render_a02_f2(spec: dict[str, Any], output_path: Path) -> str:
cases = spec["cases"]
fig, axes = plt.subplots(4, 4, figsize=(13.4, 15.6))
for row_index, case in enumerate(cases):
image_row = case["image_row"]
top_object_rows = case["top_object_rows"]
_draw_image_panel(
axes[row_index, 0],
image_row["image_path"],
title=f"{case['outcome_label']} | score={float(image_row['score']):.3f}",
subtitle=_short_text(image_row["image_id"], 22),
)
for col_index in range(1, 4):
ax = axes[row_index, col_index]
if col_index - 1 < len(top_object_rows):
row = top_object_rows[col_index - 1]
_draw_roi_panel(
ax,
row,
title=f"#{col_index} {_short_text(row['class_name'], 14)}",
subtitle=f"score={float(row['y_score']):.3f}",
)
else:
ax.axis("off")
fig.suptitle(f"{spec['title']} | best method: {spec['best_method']}", fontsize=13)
return _save(fig, output_path)
def _render_a02_f3(spec: dict[str, Any], output_path: Path) -> str:
object_rows = spec["top_object_rows"]
fig = plt.figure(figsize=(13.2, 10.8))
grid = fig.add_gridspec(4, 4, height_ratios=[1.15, 1.15, 1.15, 0.95])
ax_a = fig.add_subplot(grid[:3, :2])
_draw_smear_with_boxes(
ax_a,
spec["image_row"]["image_path"],
object_rows,
title=f"Same image under four aggregators | {_short_text(spec['image_row']['image_id'], 22)}",
labels=[f"#{index}" for index in range(1, len(object_rows) + 1)],
show_scores=True,
)
_add_panel_badge(ax_a, "A")
positions = [(0, 2), (0, 3), (1, 2), (1, 3)]
for slot, (row_index, col_index) in enumerate(positions, start=1):
row = object_rows[slot - 1]
ax = fig.add_subplot(grid[row_index, col_index])
_draw_roi_panel(
ax,
row,
title=f"#{slot} {_short_text(row['class_name'], 14)}",
subtitle=f"score={float(row['y_score']):.3f}",
)
if slot == 1:
_add_panel_badge(ax, "B")
method_titles = {
"max_object_score": "max object score",
"mean_top3_scores": "mean top-3 scores",
"count_objects_above_threshold": "count > threshold",
"soft_noisy_or": "soft noisy-or",
}
for index, method_row in enumerate(spec["method_rows"]):
ax = fig.add_subplot(grid[3, index])
_draw_text_box(
ax,
method_titles.get(method_row["aggregation_method"], method_row["aggregation_method"]),
[f"score = {float(method_row['score']):.3f}"],
)
if index == 0:
_add_panel_badge(ax, "C")
ax_blank = fig.add_subplot(grid[2, 2:])
ax_blank.axis("off")
fig.suptitle(spec["title"], fontsize=13)
return _save(fig, output_path)
def _render_a02_f4(spec: dict[str, Any], output_path: Path) -> str:
image_panels = spec["image_panels"]
n_rows = len(image_panels)
fig = plt.figure(figsize=(13.4, 3.1 * n_rows + 1.2))
grid = fig.add_gridspec(n_rows, 4, width_ratios=[1.6, 1.0, 1.0, 1.1])
summary_ax = fig.add_subplot(grid[:, 3])
summary_lines = [
f"patient: {_short_text(spec['patient_row']['patient_id'], 22)}",
f"image method: {spec['best_image_method']}",
f"patient method: {spec['best_patient_method']}",
f"patient score: {float(spec['patient_row']['score']):.3f}",
f"n_images shown: {len(image_panels)}",
f"n_objects total: {int(spec['patient_row']['n_objects'])}",
]
_draw_text_box(summary_ax, "Patient aggregation", summary_lines)
_add_panel_badge(summary_ax, "D")
for row_index, panel in enumerate(image_panels):
image_row = panel["image_row"]
top_object_rows = panel["top_object_rows"]
ax_image = fig.add_subplot(grid[row_index, 0])
_draw_image_panel(
ax_image,
image_row["image_path"],
title=f"{_short_text(image_row['image_id'], 18)} | {float(image_row['score']):.3f}",
subtitle=f"contains blast={int(image_row['image_contains_blast'])}",
)
if row_index == 0:
_add_panel_badge(ax_image, "A")
for object_slot, col_index in enumerate([1, 2], start=0):
ax = fig.add_subplot(grid[row_index, col_index])
if object_slot < len(top_object_rows):
row = top_object_rows[object_slot]
_draw_roi_panel(
ax,
row,
title=f"#{object_slot + 1} {_short_text(row['class_name'], 14)}",
subtitle=f"score={float(row['y_score']):.3f}",
)
else:
ax.axis("off")
if row_index == 0 and object_slot == 0:
_add_panel_badge(ax, "B")
if row_index == 0 and object_slot == 1:
_add_panel_badge(ax, "C")
fig.suptitle(spec["title"], fontsize=13)
return _save(fig, output_path)
def _render_a02_f5(spec: dict[str, Any], output_path: Path) -> str:
case_panels = spec["case_panels"]
max_rows = max(len(case["image_panels"]) for case in case_panels)
fig, axes = plt.subplots(max_rows, 6, figsize=(14.3, 4.1 * max_rows + 0.8))
if max_rows == 1:
axes = np.asarray([axes])
for section_index, case in enumerate(case_panels):
patient_row = case["patient_row"]
start_col = 0 if case["case_label"] == "TP" else 3
section_title = (
f"{case['case_label']} patient | {_short_text(patient_row['patient_id'], 20)} | "
f"{spec['best_patient_method']}={float(patient_row['score']):.3f}"
)
axes[0, start_col].set_title(section_title, fontsize=10)
for row_index in range(max_rows):
for col_offset in range(3):
axes[row_index, start_col + col_offset].axis("off")
for row_index, panel in enumerate(case["image_panels"]):
image_row = panel["image_row"]
top_object_rows = panel["top_object_rows"]
image_ax = axes[row_index, start_col]
_draw_image_panel(
image_ax,
image_row["image_path"],
title=f"{_short_text(image_row['image_id'], 16)} | {float(image_row['score']):.3f}",
)
if row_index == 0:
_add_panel_badge(image_ax, "A" if case["case_label"] == "TP" else "D")
for object_slot, col_offset in enumerate([1, 2], start=0):
ax = axes[row_index, start_col + col_offset]
if object_slot < len(top_object_rows):
row = top_object_rows[object_slot]
_draw_roi_panel(
ax,
row,
title=f"#{object_slot + 1} {_short_text(row['class_name'], 13)}",
subtitle=f"score={float(row['y_score']):.3f}",
)
else:
ax.axis("off")
if row_index == 0 and object_slot == 0:
_add_panel_badge(ax, "B" if case["case_label"] == "TP" else "E")
if row_index == 0 and object_slot == 1:
_add_panel_badge(ax, "C" if case["case_label"] == "TP" else "F")
fig.suptitle(spec["title"], fontsize=13)
return _save(fig, output_path)
def render_subject_figure(spec: dict[str, Any], output_path: Path) -> str:
kind = spec["figure_kind"]
if kind == "a01_f1_class_gallery":
return _render_a01_f1(spec, output_path)
if kind == "a01_f2_smear_to_objects":
return _render_a01_f2(spec, output_path)
if kind == "a01_f3_mask_representations":
return _render_a01_f3(spec, output_path)
if kind == "a01_f4_dataset_regimes":
return _render_a01_f4(spec, output_path)
if kind == "a01_f5_confusion_gallery":
return _render_a01_f5(spec, output_path)
if kind == "a01_f6_same_cells_model_comparison":
return _render_a01_f6(spec, output_path)
if kind == "a02_f1_cells_to_image_score":
return _render_a02_f1(spec, output_path)
if kind == "a02_f2_image_case_studies":
return _render_a02_f2(spec, output_path)
if kind == "a02_f3_same_image_four_aggregators":
return _render_a02_f3(spec, output_path)
if kind == "a02_f4_images_to_patient_score":
return _render_a02_f4(spec, output_path)
if kind == "a02_f5_patient_case_studies":
return _render_a02_f5(spec, output_path)
raise ValueError(f"Unsupported subject figure kind: {kind}")