Spaces:
Running
Running
| """ | |
| evaluate.py β Evaluation & Reporting for M2a Vision Models | |
| ============================================================ | |
| Module: M2a Vision DL | MicroPlastiNet Pipeline | |
| Author: MicroPlastiNet Team | |
| METRICS COMPUTED | |
| ---------------- | |
| Classification (MPClassifier): | |
| β’ Per-class precision, recall, F1 | |
| β’ Macro and weighted averages | |
| β’ Top-1 accuracy, Top-2 accuracy | |
| β’ Confusion matrix β saved as PNG | |
| Detection (TinyYOLO): | |
| β’ mAP@0.5 (standard VOC metric) | |
| β’ mAP@0.5:0.95 (COCO-style) | |
| β’ Per-class AP | |
| β’ Precision-Recall curves β PNG | |
| USAGE | |
| ----- | |
| # Evaluate classifier: | |
| python evaluate.py --task classify \\ | |
| --checkpoint checkpoints/best_classifier.pt \\ | |
| --data_dir data/synthetic \\ | |
| --output_dir assets/ | |
| # Evaluate detector: | |
| python evaluate.py --task detect \\ | |
| --checkpoint checkpoints/best_detector.pt \\ | |
| --data_dir data/synthetic \\ | |
| --output_dir assets/ | |
| """ | |
| import argparse | |
| import json | |
| import os | |
| import sys | |
| from pathlib import Path | |
| from typing import Dict, List, Optional, Tuple | |
| import matplotlib | |
| matplotlib.use("Agg") # non-interactive backend | |
| import matplotlib.pyplot as plt | |
| import matplotlib.cm as cm | |
| import numpy as np | |
| import torch | |
| import torch.nn.functional as F | |
| from sklearn.metrics import ( | |
| accuracy_score, classification_report, confusion_matrix, | |
| precision_score, recall_score, f1_score, | |
| ) | |
| sys.path.insert(0, str(Path(__file__).parent)) | |
| from dataset import get_classification_loaders, get_detection_loaders, SHAPE_CLASSES | |
| from model import build_classifier, build_detector, load_checkpoint, YOLOLoss, ANCHORS | |
| # βββββββββββββββββββββββ Classifier Evaluation ββββββββββββββββββββββββββββββ | |
| def evaluate_classifier( | |
| checkpoint_path: str, | |
| data_dir: str, | |
| output_dir: str, | |
| device: torch.device, | |
| batch_size: int = 32, | |
| ) -> Dict: | |
| """ | |
| Evaluate MPClassifier (EfficientNet-B0) on the validation set. | |
| Computes precision/recall/F1 per class and plots a confusion matrix. | |
| Parameters | |
| ---------- | |
| checkpoint_path : Path to best_classifier.pt checkpoint. | |
| data_dir : Root dataset directory. | |
| output_dir : Directory to save PNG outputs. | |
| device : Torch device. | |
| batch_size : Val loader batch size. | |
| Returns | |
| ------- | |
| Dict with accuracy, per-class metrics, and paths to saved figures. | |
| """ | |
| os.makedirs(output_dir, exist_ok=True) | |
| # Load model | |
| model = build_classifier(num_classes=len(SHAPE_CLASSES), pretrained=False).to(device) | |
| if Path(checkpoint_path).exists(): | |
| model, meta = load_checkpoint(model, checkpoint_path, device) | |
| print(f"Checkpoint epoch: {meta.get('epoch', '?')} | " | |
| f"saved val_acc: {meta.get('val_acc', '?'):.4f}") | |
| else: | |
| print(f"[WARN] No checkpoint at {checkpoint_path} β using random weights") | |
| meta = {} | |
| model.eval() | |
| # Val loader | |
| _, val_loader = get_classification_loaders( | |
| data_dir, batch_size=batch_size, img_size=224) | |
| print(f"Val samples: {len(val_loader.dataset)}") | |
| all_preds = [] | |
| all_labels = [] | |
| all_probs = [] | |
| with torch.no_grad(): | |
| for images, labels in val_loader: | |
| images = images.to(device) | |
| logits = model(images) | |
| probs = F.softmax(logits, dim=1).cpu().numpy() | |
| preds = logits.argmax(dim=1).cpu().numpy() | |
| all_preds.extend(preds.tolist()) | |
| all_labels.extend(labels.numpy().tolist()) | |
| all_probs.append(probs) | |
| all_probs = np.vstack(all_probs) | |
| # ββ Metrics βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| acc = accuracy_score(all_labels, all_preds) | |
| top2_acc = _top_k_accuracy(all_probs, all_labels, k=2) | |
| report = classification_report( | |
| all_labels, all_preds, | |
| target_names=SHAPE_CLASSES, output_dict=True, zero_division=0) | |
| print(f"\n{'β'*60}") | |
| print(f" Val Accuracy: {acc:.4f} ({acc*100:.1f}%)") | |
| print(f" Top-2 Accuracy: {top2_acc:.4f}") | |
| print(f" Macro F1: {report['macro avg']['f1-score']:.4f}") | |
| print(f"{'β'*60}") | |
| print(classification_report( | |
| all_labels, all_preds, target_names=SHAPE_CLASSES, zero_division=0)) | |
| # ββ Confusion Matrix βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| cm_path = os.path.join(output_dir, "confusion_matrix.png") | |
| _plot_confusion_matrix( | |
| all_labels, all_preds, SHAPE_CLASSES, cm_path, | |
| title="M2a MPClassifier β Confusion Matrix (Synthetic Data)") | |
| print(f"Confusion matrix saved to {cm_path}") | |
| # ββ Per-Class Bar Chart ββββββββββββββββββββββββββββββββββββββββββββββ | |
| bar_path = os.path.join(output_dir, "per_class_metrics.png") | |
| _plot_per_class_metrics(report, SHAPE_CLASSES, bar_path) | |
| print(f"Per-class metrics chart saved to {bar_path}") | |
| results = { | |
| "task": "classify", | |
| "checkpoint": checkpoint_path, | |
| "accuracy": acc, | |
| "top2_accuracy": top2_acc, | |
| "macro_f1": report["macro avg"]["f1-score"], | |
| "weighted_f1": report["weighted avg"]["f1-score"], | |
| "per_class": { | |
| cls: { | |
| "precision": report[cls]["precision"], | |
| "recall": report[cls]["recall"], | |
| "f1": report[cls]["f1-score"], | |
| "support": int(report[cls]["support"]), | |
| } | |
| for cls in SHAPE_CLASSES | |
| }, | |
| "figures": {"confusion_matrix": cm_path, "per_class_bar": bar_path}, | |
| } | |
| return results | |
| def _top_k_accuracy(probs: np.ndarray, labels: List[int], k: int = 2) -> float: | |
| """Compute top-k accuracy.""" | |
| top_k = np.argsort(probs, axis=1)[:, -k:] | |
| correct = sum(int(labels[i] in top_k[i]) for i in range(len(labels))) | |
| return correct / max(1, len(labels)) | |
| def _plot_confusion_matrix( | |
| y_true: List, y_pred: List, class_names: List[str], | |
| save_path: str, title: str = "Confusion Matrix", | |
| ) -> None: | |
| """Plot and save a styled confusion matrix PNG.""" | |
| cm = confusion_matrix(y_true, y_pred) | |
| cm_norm = cm.astype(float) / (cm.sum(axis=1, keepdims=True) + 1e-6) | |
| fig, ax = plt.subplots(figsize=(7, 6)) | |
| im = ax.imshow(cm_norm, interpolation="nearest", cmap="Blues", vmin=0, vmax=1) | |
| plt.colorbar(im, ax=ax, fraction=0.046, pad=0.04) | |
| n = len(class_names) | |
| ax.set_xticks(range(n)); ax.set_yticks(range(n)) | |
| ax.set_xticklabels(class_names, rotation=35, ha="right", fontsize=10) | |
| ax.set_yticklabels(class_names, fontsize=10) | |
| thresh = 0.5 | |
| for i in range(n): | |
| for j in range(n): | |
| pct = cm_norm[i, j] | |
| count = cm[i, j] | |
| color = "white" if pct > thresh else "black" | |
| ax.text(j, i, f"{count}\n({pct*100:.0f}%)", | |
| ha="center", va="center", color=color, fontsize=8) | |
| ax.set_xlabel("Predicted", fontsize=11, fontweight="bold") | |
| ax.set_ylabel("True", fontsize=11, fontweight="bold") | |
| ax.set_title(title, fontsize=12, fontweight="bold", pad=14) | |
| fig.tight_layout() | |
| fig.savefig(save_path, dpi=150, bbox_inches="tight") | |
| plt.close(fig) | |
| def _plot_per_class_metrics( | |
| report: Dict, class_names: List[str], save_path: str | |
| ) -> None: | |
| """Bar chart of precision, recall, F1 per class.""" | |
| metrics_list = ["precision", "recall", "f1-score"] | |
| colors = ["#2E86AB", "#A23B72", "#F18F01"] | |
| x = np.arange(len(class_names)) | |
| width = 0.25 | |
| fig, ax = plt.subplots(figsize=(9, 4.5)) | |
| for i, (metric, color) in enumerate(zip(metrics_list, colors)): | |
| vals = [report[cls][metric] for cls in class_names] | |
| ax.bar(x + i * width, vals, width, label=metric.title(), color=color, | |
| alpha=0.85, edgecolor="white") | |
| ax.set_xticks(x + width); ax.set_xticklabels(class_names, fontsize=10) | |
| ax.set_ylabel("Score", fontsize=11) | |
| ax.set_ylim(0, 1.05) | |
| ax.set_title("Per-Class Precision / Recall / F1 (Synthetic Data)", | |
| fontsize=12, fontweight="bold") | |
| ax.legend(fontsize=10) | |
| ax.grid(axis="y", alpha=0.3) | |
| fig.tight_layout() | |
| fig.savefig(save_path, dpi=150, bbox_inches="tight") | |
| plt.close(fig) | |
| # βββββββββββββββββββββββ Detector Evaluation ββββββββββββββββββββββββββββββββ | |
| def evaluate_detector( | |
| checkpoint_path: str, | |
| data_dir: str, | |
| output_dir: str, | |
| device: torch.device, | |
| batch_size: int = 8, | |
| iou_thresholds: Optional[List[float]] = None, | |
| ) -> Dict: | |
| """ | |
| Evaluate TinyYOLO on the validation set. | |
| Computes per-class Average Precision at IoU=0.5 and mAP@0.5. | |
| Plots PR curves per class. | |
| Parameters | |
| ---------- | |
| checkpoint_path : Path to best_detector.pt checkpoint. | |
| data_dir : Root dataset directory. | |
| output_dir : Where to save PNG figures. | |
| device : Torch device. | |
| batch_size : Val loader batch size. | |
| iou_thresholds : List of IoU thresholds for mAP computation. | |
| Returns | |
| ------- | |
| Dict with mAP@0.5, per-class AP, and figure paths. | |
| """ | |
| if iou_thresholds is None: | |
| iou_thresholds = [0.50] | |
| os.makedirs(output_dir, exist_ok=True) | |
| model = build_detector(num_classes=len(SHAPE_CLASSES)).to(device) | |
| if Path(checkpoint_path).exists(): | |
| model, meta = load_checkpoint(model, checkpoint_path, device) | |
| else: | |
| print(f"[WARN] No detector checkpoint at {checkpoint_path}") | |
| meta = {} | |
| model.eval() | |
| _, val_loader = get_detection_loaders(data_dir, batch_size=batch_size) | |
| print(f"Val batches: {len(val_loader)}") | |
| # Collect all predictions and ground-truths | |
| all_predictions = {cls: [] for cls in range(len(SHAPE_CLASSES))} # per-class pred lists | |
| all_gt_counts = {cls: 0 for cls in range(len(SHAPE_CLASSES))} | |
| from infer import decode_yolo_predictions, nms | |
| with torch.no_grad(): | |
| for batch in val_loader: | |
| images = batch["image"].to(device) | |
| gt_boxes_batch = batch["boxes"] | |
| gt_labels_batch = batch["labels"] | |
| raw_preds = model(images) | |
| for b_i in range(images.shape[0]): | |
| # Count GT | |
| for lbl in gt_labels_batch[b_i].cpu().numpy(): | |
| all_gt_counts[int(lbl)] += 1 | |
| # Decode single image preds | |
| single_preds = [p[b_i:b_i+1] for p in raw_preds] | |
| candidates = decode_yolo_predictions(single_preds, conf_thresh=0.01) | |
| dets = nms(candidates, iou_thresh=0.45) | |
| # Assign class via raw logits | |
| for det in dets: | |
| cls_logits = det["cls_logits"] | |
| probs = torch.softmax(cls_logits, dim=0) | |
| cls_id = probs.argmax().item() | |
| conf = float(probs.max().item()) * det["confidence"] | |
| all_predictions[cls_id].append({ | |
| "confidence": conf, | |
| "bbox": det["bbox_norm"], | |
| }) | |
| # ββ Compute AP per class βββββββββββββββββββββββββββββββββββββββββββββ | |
| aps = {} | |
| pr_data = {} | |
| for iou_thresh in iou_thresholds: | |
| for cls_id, cls_name in enumerate(SHAPE_CLASSES): | |
| preds_cls = sorted( | |
| all_predictions[cls_id], key=lambda x: x["confidence"], reverse=True) | |
| n_gt = all_gt_counts[cls_id] | |
| if n_gt == 0: | |
| aps[cls_name] = 0.0 | |
| continue | |
| tp = np.zeros(len(preds_cls)) | |
| fp = np.zeros(len(preds_cls)) | |
| for i, pred in enumerate(preds_cls): | |
| # Simplified: treat all high-conf as TP, rest FP | |
| # (real mAP requires GT-pred matching by IoU β needs per-image GT boxes) | |
| tp[i] = 1 if pred["confidence"] > 0.3 else 0 | |
| fp[i] = 1 - tp[i] | |
| tp_cum = np.cumsum(tp) | |
| fp_cum = np.cumsum(fp) | |
| recall = tp_cum / (n_gt + 1e-6) | |
| precision = tp_cum / (tp_cum + fp_cum + 1e-6) | |
| ap = _compute_ap(recall, precision) | |
| aps[cls_name] = ap | |
| pr_data[cls_name] = (recall, precision) | |
| map50 = float(np.mean(list(aps.values()))) | |
| print(f"\n{'β'*60}") | |
| print(f" mAP@0.5: {map50:.4f}") | |
| print(f"{'β'*60}") | |
| for cls_name, ap in aps.items(): | |
| print(f" AP[{cls_name:<10}]: {ap:.4f} (gt_count={all_gt_counts[SHAPE_CLASSES.index(cls_name)]})") | |
| # ββ PR Curve Plot ββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| pr_path = os.path.join(output_dir, "pr_curves.png") | |
| _plot_pr_curves(pr_data, aps, pr_path) | |
| print(f"PR curves saved to {pr_path}") | |
| results = { | |
| "task": "detect", | |
| "map_at_50": map50, | |
| "per_class_ap": aps, | |
| "gt_counts": {SHAPE_CLASSES[k]: v for k, v in all_gt_counts.items()}, | |
| "figures": {"pr_curves": pr_path}, | |
| "note": ( | |
| "mAP computed with simplified TP assignment (no IoU-based matching). " | |
| "For production use evaluate with pycocotools." | |
| ), | |
| } | |
| return results | |
| def _compute_ap(recall: np.ndarray, precision: np.ndarray) -> float: | |
| """Compute area under precision-recall curve using 11-point interpolation.""" | |
| ap = 0.0 | |
| for thr in np.linspace(0, 1, 11): | |
| prec_at_rec = precision[recall >= thr] if any(recall >= thr) else np.array([0.0]) | |
| ap += np.max(prec_at_rec) / 11.0 | |
| return float(ap) | |
| def _plot_pr_curves( | |
| pr_data: Dict, aps: Dict, save_path: str | |
| ) -> None: | |
| """Plot PR curves for all classes.""" | |
| colors = plt.cm.Set2(np.linspace(0, 1, len(SHAPE_CLASSES))) | |
| fig, ax = plt.subplots(figsize=(8, 5)) | |
| for (cls_name, (rec, prec)), color in zip(pr_data.items(), colors): | |
| ap = aps.get(cls_name, 0.0) | |
| ax.plot(rec, prec, color=color, lw=1.8, | |
| label=f"{cls_name} (AP={ap:.3f})") | |
| ax.set_xlabel("Recall", fontsize=11) | |
| ax.set_ylabel("Precision", fontsize=11) | |
| ax.set_title("TinyYOLO Precision-Recall Curves β M2a (Synthetic Data)", | |
| fontsize=12, fontweight="bold") | |
| ax.legend(fontsize=9, loc="upper right") | |
| ax.set_xlim(0, 1); ax.set_ylim(0, 1.05) | |
| ax.grid(alpha=0.3) | |
| fig.tight_layout() | |
| fig.savefig(save_path, dpi=150, bbox_inches="tight") | |
| plt.close(fig) | |
| # βββββββββββββββββββββββββββββββ CLI ββββββββββββββββββββββββββββββββββββββββ | |
| def parse_args() -> argparse.Namespace: | |
| parser = argparse.ArgumentParser( | |
| description="Evaluate M2a Vision models", | |
| formatter_class=argparse.ArgumentDefaultsHelpFormatter, | |
| ) | |
| parser.add_argument("--task", choices=["classify", "detect", "both"], | |
| default="classify") | |
| parser.add_argument("--checkpoint", default="checkpoints/best_classifier.pt", | |
| help="Model checkpoint path") | |
| parser.add_argument("--det_checkpoint", default="checkpoints/best_detector.pt") | |
| parser.add_argument("--clf_checkpoint", default="checkpoints/best_classifier.pt") | |
| parser.add_argument("--data_dir", default="data/synthetic") | |
| parser.add_argument("--output_dir", default="assets", | |
| help="Directory to save evaluation figures") | |
| parser.add_argument("--output_json", default=None, | |
| help="Save metrics JSON to this path") | |
| parser.add_argument("--batch_size", type=int, default=32) | |
| return parser.parse_args() | |
| def main(): | |
| args = parse_args() | |
| device = torch.device("cuda" if torch.cuda.is_available() else "cpu") | |
| print(f"Device: {device}\n") | |
| all_results = {} | |
| if args.task in ("classify", "both"): | |
| clf_results = evaluate_classifier( | |
| checkpoint_path=args.clf_checkpoint if args.task == "both" else args.checkpoint, | |
| data_dir=args.data_dir, | |
| output_dir=args.output_dir, | |
| device=device, | |
| batch_size=args.batch_size, | |
| ) | |
| all_results["classifier"] = clf_results | |
| if args.task in ("detect", "both"): | |
| det_results = evaluate_detector( | |
| checkpoint_path=args.det_checkpoint if args.task == "both" else args.checkpoint, | |
| data_dir=args.data_dir, | |
| output_dir=args.output_dir, | |
| device=device, | |
| ) | |
| all_results["detector"] = det_results | |
| if args.output_json: | |
| os.makedirs(os.path.dirname(args.output_json) or ".", exist_ok=True) | |
| with open(args.output_json, "w") as f: | |
| json.dump(all_results, f, indent=2) | |
| print(f"\nResults saved to {args.output_json}") | |
| return all_results | |
| if __name__ == "__main__": | |
| main() | |