Source code for medvision_bm.benchmark.summarize_detection_task

"""Summarize object-detection (bounding-box) benchmark results across models.

Reads the parsed ``*.jsonl`` prediction files under each model's ``parsed``
subdirectory, groups samples by anatomy/modality/slice, and aggregates MAE,
IoU, F1, precision, recall, success rate, and threshold-based statistics.
Regions are further grouped into anatomy vs. tumor/lesion categories. Per-model
summaries are written as JSON and a formatted cross-model report is saved to a
text file.

Run as a CLI; see :func:`parse_args` for the accepted arguments.
"""

import argparse
import glob
import json
import multiprocessing
import os
import re
from collections import defaultdict
from functools import partial

import numpy as np

from medvision_bm.utils.configs import (
    EXCLUDED_KEYS,
    MINIMUM_GROUP_SIZE,
    SUMMARY_FILENAME_ALL_MODELS_DETECT_METRICS,
    SUMMARY_FILENAME_DETECT_METRICS,
    SUMMARY_FILENAME_DETECT_VALUES,
    SUMMARY_FILENAME_GROUPED_ANATOMY_VS_TUMOR_LESION_DETECT_METRICS,
    TUMOR_LESION_GROUP_KEYS,
)
from medvision_bm.utils.parse_utils import (
    cal_metrics_detection_task,
    convert_numpy_to_python,
    get_labelsMap_imgModality_from_seg_benchmark_plan,
    get_subfolders,
    group_by_anatomy_modality_slice,
)


def _initialize_metric_counters_detection_task():
    """
    Initialize metric counters for detection task evaluation.

    Returns:
        Dictionary with counters for sums, counts, and threshold-based metrics
    """
    return {
        "sum_MAE": 0,
        "sum_IoU": 0,
        "sum_F1": 0,
        "sum_Precision": 0,
        "sum_Recall": 0,
        "num_success": 0,
        "count_valid_AE": 0,
        "count_valid_IoU": 0,
        "count_valid_F1": 0,
        "count_valid_Precision": 0,
        "count_valid_Recall": 0,
        "count_AE_thresholds": [0] * 10,
        "count_IoU_thresholds": [0] * 5,
        "count_F1_thresholds": [0] * 5,
        "count_Precision_thresholds": [0] * 5,
        "count_Recall_thresholds": [0] * 5,
    }


def _update_mae_counters(mae_value, counters):
    """
    Update MAE-related counters with a new MAE value.

    Args:
        mae_value: Mean Absolute Error value
        counters: Dictionary of metric counters to update
    """
    # Guard against non-finite values (inf, -inf, nan) that cannot be
    # converted to int or meaningfully summed.
    if np.isfinite(mae_value):
        counters["sum_MAE"] += mae_value
        counters["count_valid_AE"] += 1

        # Determine threshold bin (0.0-0.1, 0.1-0.2, etc.)
        threshold_index = min(int(mae_value * 10), 9)
        counters["count_AE_thresholds"][threshold_index] += 1


def _update_threshold_counters(metric_value, threshold_counts):
    """
    Update threshold counters for overlap metrics (IoU, F1, Precision, Recall).

    Args:
        metric_value: Metric value to evaluate against thresholds
        threshold_counts: List of counts for each threshold level
    """
    thresholds = [0.5, 0.6, 0.7, 0.8, 0.9]
    for i, threshold in enumerate(thresholds):
        if metric_value >= threshold:
            threshold_counts[i] += 1


def _update_metric_counters_detection_task(metrics_dict, counters):
    """
    Update all metric counters with a single sample's calculated metrics.

    Args:
        metrics_dict: Dictionary of calculated metrics for one sample
        counters: Dictionary of metric counters to update
    """
    # Update MAE
    _update_mae_counters(metrics_dict["avgMAE"]["MAE"], counters)

    # Update IoU
    if np.isfinite(metrics_dict["avgIoU"]["IoU"]):
        iou = metrics_dict["avgIoU"]["IoU"]
        counters["sum_IoU"] += iou
        counters["count_valid_IoU"] += 1
        _update_threshold_counters(iou, counters["count_IoU_thresholds"])

    # Update F1
    if np.isfinite(metrics_dict["F1"]["F1"]):
        f1 = metrics_dict["F1"]["F1"]
        counters["sum_F1"] += f1
        counters["count_valid_F1"] += 1
        _update_threshold_counters(f1, counters["count_F1_thresholds"])

    # Update Precision
    if np.isfinite(metrics_dict["Precision"]["Precision"]):
        precision = metrics_dict["Precision"]["Precision"]
        counters["sum_Precision"] += precision
        counters["count_valid_Precision"] += 1
        _update_threshold_counters(precision, counters["count_Precision_thresholds"])

    # Update Recall
    if np.isfinite(metrics_dict["Recall"]["Recall"]):
        recall = metrics_dict["Recall"]["Recall"]
        counters["sum_Recall"] += recall
        counters["count_valid_Recall"] += 1
        _update_threshold_counters(recall, counters["count_Recall_thresholds"])

    # Update success count
    counters["num_success"] += metrics_dict["SuccessRate"]["success"]


def _calculate_final_metrics_detection_task(counters, count_total):
    """
    Calculate final aggregate metrics from accumulated counters.

    Args:
        counters: Dictionary of accumulated metric counters
        count_total: Total number of samples processed

    Returns:
        Dictionary with final averaged metrics and threshold statistics
    """
    task_metrics = {
        "avgMAE": (
            counters["sum_MAE"] / counters["count_valid_AE"]
            if counters["count_valid_AE"] > 0
            else np.nan
        ),
        "IoU": (
            counters["sum_IoU"] / counters["count_valid_IoU"]
            if counters["count_valid_IoU"] > 0
            else np.nan
        ),
        "F1": (
            counters["sum_F1"] / counters["count_valid_F1"]
            if counters["count_valid_F1"] > 0
            else np.nan
        ),
        "Precision": (
            counters["sum_Precision"] / counters["count_valid_Precision"]
            if counters["count_valid_Precision"] > 0
            else np.nan
        ),
        "Recall": (
            counters["sum_Recall"] / counters["count_valid_Recall"]
            if counters["count_valid_Recall"] > 0
            else np.nan
        ),
        "SuccessRate": (
            counters["num_success"] / count_total if count_total > 0 else 0.0
        ),
        "num_samples": count_total,
    }

    # Add cumulative MAE (Mean Absolute Error) metrics
    # MAE<k means proportion of samples with MAE less than or equal to k/10
    for k in range(1, 11):
        cumulative_count = sum(counters["count_AE_thresholds"][0:k])
        task_metrics[f"MAE<{k/10:.1f}"] = (
            cumulative_count / count_total if count_total > 0 else 0.0
        )

    # Add threshold-based metrics for overlap measures
    # e.g., "IoU>0.5" means proportion of samples with IoU >= 0.5
    metric_names = ["IoU", "F1", "Precision", "Recall"]
    for metric_name in metric_names:
        threshold_key = f"count_{metric_name}_thresholds"
        for k in range(5, 10):
            threshold_value = k / 10
            count_at_threshold = counters[threshold_key][k - 5]
            task_metrics[f"{metric_name}>{threshold_value:.1f}"] = (
                count_at_threshold / count_total if count_total > 0 else 0.0
            )

    return task_metrics


[docs] def calculate_summary_metrics_per_anatomy_detection_task(grouped_data): """ Calculate summary metrics for each anatomy group. Args: grouped_data: Dictionary with parent_class as keys and task_data as values Returns: Dictionary with summary metrics per parent class and task type """ summary_metrics = {} for parent_class, data in grouped_data.items(): if parent_class is None: continue summary_metrics[parent_class] = {} targets = data["targets"] responses = data["responses"] # Skip if targets or responses are empty if not targets or not responses: continue # Initialize counters counters = _initialize_metric_counters_detection_task() count_total = len(targets) # Process each target-response pair for target, response in zip(targets, responses): mock_results = {"filtered_resps": [response], "target": target} metrics_dict = cal_metrics_detection_task(mock_results) _update_metric_counters_detection_task(metrics_dict, counters) # Calculate and store final metrics task_metrics = _calculate_final_metrics_detection_task(counters, count_total) summary_metrics[parent_class] = task_metrics return summary_metrics
[docs] def group_anatomy_vs_tumor_lesion(model_path, limit=None): """ Group anatomical regions into 'anatomy' vs 'tumor/lesion' (T/L) categories and calculate weighted mean metrics for each group. This function: 1. Reads per-region metrics from SUMMARY_FILENAME_DETECT_METRICS 2. Classifies regions as anatomy or tumor/lesion based on keywords 3. Filters out regions marked as miscellaneous/others or with insufficient samples 4. Calculates sample-weighted mean metrics for each group 5. Saves results to SUMMARY_FILENAME_GROUPED_ANATOMY_VS_TUMOR_LESION_DETECT_METRICS Args: model_path: Path to the model folder containing summary metrics file limit: Maximum samples to process per file (None = all) """ metrics_filename = ( SUMMARY_FILENAME_DETECT_METRICS if limit is None else f"{SUMMARY_FILENAME_DETECT_METRICS.removesuffix('.json')}_limit{limit}.json" ) metrics_path = os.path.join(model_path, metrics_filename) if not os.path.exists(metrics_path): print(f"Summary metrics file not found: {metrics_path}") return # Read the summary metrics with open(metrics_path, "r") as f: data = json.load(f) # Initialize groups anatomy_group = {} tumor_lesion_group = {} # Classify regions into anatomical or tumor/lesion groups for region_name, task_data in data.items(): region_lower = region_name.lower() # Exclude regions classified as miscellaneous or others if any(keyword in region_lower for keyword in EXCLUDED_KEYS): print( f"[Exclude] Skipping region '{region_name}' classified as miscellaneous/others." ) continue # Exclude regions with too few samples for reliable statistics if task_data["num_samples"] < MINIMUM_GROUP_SIZE: print( f"[Exclude] Skipping region '{region_name}' due to insufficient samples: {task_data['num_samples']} < minimum sample size {MINIMUM_GROUP_SIZE}." ) continue # Classify based on tumor/lesion keywords (dataset-specific) if any( keyword in region_lower for keyword in TUMOR_LESION_GROUP_KEYS ): # NOTE: dataset-specific keywords can be added here tumor_lesion_group[region_name] = task_data else: anatomy_group[region_name] = task_data # Calculate sample-weighted mean metrics for each group def calculate_group_mean_metrics(group_data): """ Calculate sample-weighted mean metrics across all regions in a group. Args: group_data: Dictionary of region_name -> task_data mappings Returns: Dictionary with weighted mean metrics and metadata """ if not group_data: return {} # Collect all metrics from all regions and tasks with their sample weights all_metrics = defaultdict(list) all_sample_sizes = defaultdict(list) total_samples = 0 # Aggregate metrics with sample size weights for _, metrics in group_data.items(): # Weight each metric by its sample size sample_size = metrics.get("num_samples", 0) for metric_name, value in metrics.items(): if metric_name == "num_samples": total_samples += value elif not np.isnan(value) and np.isfinite(value): all_metrics[metric_name].append(value) all_sample_sizes[metric_name].append(sample_size) # Calculate weighted means: sum(value * weight) / sum(weight) mean_metrics = {} for metric_name, values in all_metrics.items(): num_sample = all_sample_sizes[metric_name] if sum(num_sample) > 0: mean_metrics[metric_name] = np.sum( np.array(values) * np.array(num_sample) ) / np.sum(num_sample) else: raise ValueError(f"No valid samples for metric {metric_name}") mean_metrics["total_samples"] = total_samples mean_metrics["num_regions"] = len(group_data) return mean_metrics # Calculate mean metrics for both groups anatomy_mean = calculate_group_mean_metrics(anatomy_group) tumor_lesion_mean = calculate_group_mean_metrics(tumor_lesion_group) # Create output structure grouped_results = { "anatomy": { "mean_metrics": anatomy_mean, "regions": list(anatomy_group.keys()), "detailed_data": anatomy_group, }, "T/L": { "mean_metrics": tumor_lesion_mean, "regions": list(tumor_lesion_group.keys()), "detailed_data": tumor_lesion_group, }, } # Save grouped results grouped_metrics_filename = ( SUMMARY_FILENAME_GROUPED_ANATOMY_VS_TUMOR_LESION_DETECT_METRICS if limit is None else f"{SUMMARY_FILENAME_GROUPED_ANATOMY_VS_TUMOR_LESION_DETECT_METRICS.removesuffix('.json')}_limit{limit}.json" ) grouped_metrics_path = os.path.join(model_path, grouped_metrics_filename) with open(grouped_metrics_path, "w") as f: json.dump(convert_numpy_to_python(grouped_results), f, indent=2) print(f"Saved grouped anatomy vs tumor/lesion metrics to {grouped_metrics_path}") print( f"Anatomy group: {len(anatomy_group)} regions, {anatomy_mean.get('total_samples', 0)} total samples" ) print( f"T/L group: {len(tumor_lesion_group)} regions, {tumor_lesion_mean.get('total_samples', 0)} total samples" )
[docs] def process_jsonl_file_detection_task( jsonl_path, limit=None, ): """ Parse a JSONL results file and extract detection task data. This function: 1. Extracts dataset name from filename 2. Parses each line for label, target, response, task_id, etc. 3. Resolves label names using benchmark plan configuration 4. Returns structured data for downstream processing Args: jsonl_path: Path to the JSONL file limit: Maximum number of samples to process (None = process all) Returns: List of tuples: (imgModality, label_name, target, filtered_resps, task_id, slice_dim) """ results = [] # Extract dataset name from filename pattern: "samples_{dataset}_..." match = re.search(r"samples_([^_]+)_", os.path.basename(jsonl_path)) dataset_name = match.group(1) count = 0 with open(jsonl_path, "r") as f: for _, line in enumerate(f): if not line.strip(): continue try: data = json.loads(line.strip()) if not data: continue # Extract fields from JSONL entry doc = data.get("doc", {}) slice_dim = doc.get("slice_dim") task_id = int(doc.get("taskID")) filtered_resps = data.get("filtered_resps") target = data.get("target") # Get label label = doc.get("label") if ( label is not None and task_id is not None and filtered_resps is not None and target is not None ): # Resolve label name and image modality from benchmark plan labels_map, imgModality = ( get_labelsMap_imgModality_from_seg_benchmark_plan( dataset_name, task_id ) ) label_name = labels_map.get(str(label)) if label_name: results.append( ( imgModality, label_name, target, filtered_resps, task_id, slice_dim, ) ) count += 1 if limit is not None and count >= limit: break except json.JSONDecodeError: raise ValueError(f"Error in parsing the JSON file {jsonl_path}") return results
[docs] def process_parsed_file_in_model_folder( model_dir, limit=None, processes=None, ): """ Process all JSONL files in a model's parsed folder and generate summary metrics. This function performs the complete pipeline: 1. Finds all JSONL files in model_dir/parsed/ 2. Parses each file to extract detection data 3. Groups data by anatomy-modality-slice combinations 4. Calculates summary metrics per group 5. Saves intermediate and final results as JSON files 6. Generates anatomy vs tumor/lesion grouped metrics Args: model_dir: Path to the model folder limit: Maximum number of samples to process per file (None = all) processes: Number of worker processes to use (None = single process) """ # Find parsed JSONL files parsed_files_dir = os.path.join(model_dir, "parsed") # # [Option 1] Early exit if parsed directory does not exist # assert os.path.exists( # parsed_files_dir # ), f"Parsed files directory does not exist: {parsed_files_dir}" # [Option 2] Warning and skip if parsed directory does not exist if not os.path.exists(parsed_files_dir): print(f"Parsed files directory does not exist: {parsed_files_dir}, skipping...") return jsonl_files = [ f for f in glob.glob(os.path.join(parsed_files_dir, "*.jsonl")) if not ("_proc_acc" in os.path.basename(f) or "_eq_acc" in os.path.basename(f)) ] # Collect all data from the parsed JSONL files all_data = [] if processes and processes > 1: print(f"Using {processes} processes for parsing JSONL files...") func = partial(process_jsonl_file_detection_task, limit=limit) with multiprocessing.Pool(processes) as pool: results = pool.map(func, jsonl_files) for res in results: all_data.extend(res) else: for jsonl_file in jsonl_files: file_data = process_jsonl_file_detection_task(jsonl_file, limit) all_data.extend(file_data) # Early exit if no valid data found if not all_data: print(f"No valid data found in {parsed_files_dir}, skipping...") return # Group by anatomy-modality-slice combinations grouped_data = group_by_anatomy_modality_slice(all_data) # Early exit if grouping failed if not grouped_data: print(f"No grouped data found for {parsed_files_dir}, skipping...") return # Calculate summary metrics per anatomy summary_metrics = calculate_summary_metrics_per_anatomy_detection_task(grouped_data) # Save values JSON file values_filename = ( SUMMARY_FILENAME_DETECT_VALUES if limit is None else f"{SUMMARY_FILENAME_DETECT_VALUES.removesuffix('.json')}_limit{limit}.json" ) values_path = os.path.join(parsed_files_dir, values_filename) with open(values_path, "w") as f: json.dump(convert_numpy_to_python(grouped_data), f, indent=2) print(f"Saved target and model-predicted values to {values_path}") # Save summary metrics JSON file metrics_filename = ( SUMMARY_FILENAME_DETECT_METRICS if limit is None else f"{SUMMARY_FILENAME_DETECT_METRICS.removesuffix('.json')}_limit{limit}.json" ) metrics_path = os.path.join(parsed_files_dir, metrics_filename) with open(metrics_path, "w") as f: json.dump(convert_numpy_to_python(summary_metrics), f, indent=2) print(f"Saved summary metrics to {metrics_path}") # Group anatomy vs tumor/lesion and calculate mean metrics group_anatomy_vs_tumor_lesion(parsed_files_dir, limit)
def _process_task_directory( task_dir, limit, skip_model_wo_parsed_files=False, processes=None ): """ Process all model directories within a task directory. This is the main processing function for task-level analysis. It loops through all model folders, processes their results, and generates a final summary comparing all models. Args: task_dir: Path to task directory containing model folders limit: Maximum samples to process per file (None = all) skip_model_wo_parsed_files: Skip models without parsed folders processes: Number of worker processes to use """ # Get list of model folders within task_dir model_dirs = get_subfolders(task_dir) # NOTE: Exclude random_detection folder model_dirs = [d for d in model_dirs if os.path.basename(d) != "random_detection"] # Print configuration info once at the beginning print("\nConfigurations in medvision_bm/utils/configs.py:") print(f" TUMOR_LESION_GROUP_KEYS: {TUMOR_LESION_GROUP_KEYS}") print(f" EXCLUDED_KEYS: {EXCLUDED_KEYS}") print(f" MINIMUM_GROUP_SIZE: {MINIMUM_GROUP_SIZE}\n") # Process each model directory for model_dir in model_dirs: # Skip models without parsed results if requested parsed_files_dir = os.path.join(model_dir, "parsed") if skip_model_wo_parsed_files and not os.path.exists(parsed_files_dir): print(f"\nSkipping model directory (no parsed folder): {model_dir}") continue print(f"\nProcessing model directory: {model_dir}") process_parsed_file_in_model_folder(model_dir, limit, processes=processes) # Print summary metrics at the end print_summary_metrics(task_dir, limit, skip_model_wo_parsed_files) def _process_single_model_directory(model_dir, limit, processes=None): """ Process a single model directory. Args: model_dir: Path to the model directory limit: Maximum number of samples to process per file processes: Number of worker processes to use """ print(f"\nProcessing model directory: {model_dir}") process_parsed_file_in_model_folder(model_dir, limit, processes=processes)
[docs] def main(**kwargs): """Process detection model folders based on the provided arguments. Dispatches to task_dir mode (loop over all model directories and generate a cross-model summary) or model_dir mode (process a single model directory). Args: task_dir (str, optional): Path to task directory (mutually exclusive with model_dir). model_dir (str, optional): Path to model directory (mutually exclusive with task_dir). limit (int, optional): Maximum number of samples to process per file. skip_model_wo_parsed_files (bool): Whether to skip model directories without parsed folders. processes (int, optional): Number of worker processes to use. Raises: ValueError: If neither task_dir nor model_dir is provided. """ task_dir = kwargs.get("task_dir") model_dir = kwargs.get("model_dir") limit = kwargs.get("limit") skip_model_wo_parsed_files = kwargs.get("skip_model_wo_parsed_files", False) processes = kwargs.get("processes") if task_dir is not None: print( f"Using task_dir: {task_dir}\nModel directories within this folder will be looped over." ) _process_task_directory( task_dir, limit, skip_model_wo_parsed_files, processes=processes ) elif model_dir is not None: print( f"Using model_dir: {model_dir}\nProcessing all JSONL files within this directory." ) _process_single_model_directory(model_dir, limit, processes=processes) else: raise ValueError("Either 'task_dir' or 'model_dir' must be provided.")
[docs] def parse_args(): """ Parse command line arguments. Supports two modes: - Task mode (--task_dir): Process all models in a task directory - Model mode (--model_dir): Process a single model directory Returns: Parsed command line arguments """ parser = argparse.ArgumentParser( description="Process model folders and generate anatomy-grouped summary metrics." ) parser.add_argument( "--task_dir", type=str, help="Path to the task directory containing model result folders.", ) parser.add_argument( "--model_dir", type=str, help="Path to a specific model directory containing JSONL files.", ) parser.add_argument( "--limit", type=int, default=None, help="Limit the number of samples to process per JSONL file. If not set, processes all samples.", ) parser.add_argument( "--skip_model_wo_parsed_files", action="store_true", help="Skip model directories that don't have a 'parsed' folder. Only valid with --task_dir.", ) parser.add_argument( "--processes", "-p", type=int, default=None, help="Number of worker processes to use for parsing JSONL files. If None, uses single process.", ) args = parser.parse_args() # Validate mutually exclusive arguments if args.task_dir is None and args.model_dir is None: parser.error("Either --task_dir or --model_dir must be provided.") # Validate skip flag only with task_dir if args.skip_model_wo_parsed_files and args.task_dir is None: parser.error("--skip_model_wo_parsed_files can only be used with --task_dir") return args
if __name__ == "__main__": args_dict = vars(parse_args()) main(**args_dict)