diff --git a/perceptionmetrics/utils/detection_metrics.py b/perceptionmetrics/utils/detection_metrics.py index e6ac4f10..c659fbba 100644 --- a/perceptionmetrics/utils/detection_metrics.py +++ b/perceptionmetrics/utils/detection_metrics.py @@ -182,10 +182,20 @@ def compute_metrics(self) -> Dict[int, Dict[str, float]]: ap, precision, recall = compute_ap(tps, fps, fn_count) + precision_val = precision[-1] if len(precision) > 0 else 0 + recall_val = recall[-1] if len(recall) > 0 else 0 + + f1_score = ( + 2 * precision_val * recall_val / (precision_val + recall_val) + if (precision_val + recall_val) > 0 + else 0.0 + ) + metrics[label] = { "AP": ap, - "Precision": precision[-1] if len(precision) > 0 else 0, - "Recall": recall[-1] if len(recall) > 0 else 0, + "Precision": precision_val, + "Recall": recall_val, + "F1": f1_score, "TP": sum(tps), "FP": sum(fps), "FN": fn_count, @@ -301,7 +311,7 @@ def get_overall_precision_recall_curve(self) -> Dict[str, List[float]]: return {"precision": [0.0], "recall": [0.0]} fn_count = sum(1 for d in all_detections if d[1] == -1) - + # Sort by score all_detections = sorted( [d for d in all_detections if d[0] is not None], key=lambda x: -x[0] @@ -356,7 +366,7 @@ def get_metrics_dataframe(self, ontology: dict) -> pd.DataFrame: metrics_dict = {} class_names = list(ontology.keys()) - for metric in ["AP", "Precision", "Recall", "TP", "FP", "FN"]: + for metric in ["AP", "Precision", "Recall", "F1", "TP", "FP", "FN"]: metrics_dict[metric] = {} for class_name, class_data in ontology.items(): idx = class_data["idx"] diff --git a/tests/test_detection_metrics.py b/tests/test_detection_metrics.py index a06f3ef0..31bbc38d 100644 --- a/tests/test_detection_metrics.py +++ b/tests/test_detection_metrics.py @@ -32,9 +32,10 @@ def test_match_predictions_logic(metrics_factory): results = matches[1] assert (0.95, 1) in results, "High overlap prediction should be a True Positive" assert (0.6, 0) in results, "Zero overlap prediction should be a False Positive" - assert (None, -1) not in results, ( - "GT was matched, so there should be no False Negative" - ) + assert ( + None, + -1, + ) not in results, "GT was matched, so there should be no False Negative" def test_compute_metrics(metrics_factory): @@ -138,9 +139,9 @@ def test_compute_coco_map_perfect_match(metrics_factory): metrics_factory.update(gt_boxes, [1], pred_boxes, [1], [0.9]) coco_map = metrics_factory.compute_coco_map() - assert coco_map == 1.0, ( - "Perfect overlap must yield perfect mAP across all thresholds" - ) + assert ( + coco_map == 1.0 + ), "Perfect overlap must yield perfect mAP across all thresholds" def test_compute_coco_map_complex_multi_class(metrics_factory): @@ -232,3 +233,34 @@ def test_coco_map_empty_vs_non_empty_class(metrics_factory): # (AP_Class1 + AP_Class2) / 2 => (1.0 + 0.0) / 2 = 0.5 assert np.isclose(mAP, 0.5), f"Expected mAP of 0.5, but got {mAP}" + + +def test_f1_score(metrics_factory): + """ + Verify F1 score calculation. + """ + + gt_boxes = np.array([[10, 10, 50, 50]]) + gt_labels = [1] + + pred_boxes = np.array( + [ + [12, 12, 48, 48], # TP + [11, 11, 49, 49], # FP (duplicate detection) + ] + ) + + pred_labels = [1, 1] + pred_scores = [0.9, 0.4] + + metrics_factory.update( + gt_boxes, + gt_labels, + pred_boxes, + pred_labels, + pred_scores, + ) + + metrics = metrics_factory.compute_metrics() + + assert np.isclose(metrics[1]["F1"], 2 / 3)