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}")