model-eval-toolkit 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,359 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ from typing import Any, Iterable, List, Optional, Tuple
6
+
7
+ import matplotlib
8
+
9
+ matplotlib.use("Agg")
10
+ import matplotlib.pyplot as plt
11
+ import numpy as np
12
+
13
+ from ..core.base_report import BaseReport
14
+
15
+
16
+ Box = Tuple[float, float, float, float] # (x1, y1, x2, y2)
17
+
18
+
19
+ def _iou(a: Box, b: Box) -> float:
20
+ ax1, ay1, ax2, ay2 = a
21
+ bx1, by1, bx2, by2 = b
22
+ ix1, iy1 = max(ax1, bx1), max(ay1, by1)
23
+ ix2, iy2 = min(ax2, bx2), min(ay2, by2)
24
+ iw, ih = max(0.0, ix2 - ix1), max(0.0, iy2 - iy1)
25
+ inter = iw * ih
26
+ if inter <= 0:
27
+ return 0.0
28
+ area_a = max(0.0, ax2 - ax1) * max(0.0, ay2 - ay1)
29
+ area_b = max(0.0, bx2 - bx1) * max(0.0, by2 - by1)
30
+ union = area_a + area_b - inter
31
+ return 0.0 if union <= 0 else float(inter / union)
32
+
33
+
34
+ def _as_images_boxes(x: Optional[Iterable[Any]]) -> Optional[List[List[dict[str, Any]]]]:
35
+ if x is None:
36
+ return None
37
+ # Expect list of images, each image list of dicts: {"bbox":[x1,y1,x2,y2], "label":..., "score":...}
38
+ return [list(v) for v in x]
39
+
40
+
41
+ def _compute_ap_101point(precision: np.ndarray, recall: np.ndarray) -> float:
42
+ """COCO-style AP using 101-point interpolation over recall in [0, 1]."""
43
+ if precision.size == 0 or recall.size == 0:
44
+ return 0.0
45
+ # Ensure monotonic precision envelope
46
+ p = precision.copy()
47
+ for i in range(p.size - 2, -1, -1):
48
+ p[i] = max(p[i], p[i + 1])
49
+ rs = np.linspace(0.0, 1.0, 101)
50
+ ap = 0.0
51
+ for r in rs:
52
+ mask = recall >= r
53
+ ap += float(np.max(p[mask])) if np.any(mask) else 0.0
54
+ return float(ap / 101.0)
55
+
56
+
57
+ def _coco_ap_for_class(
58
+ gt: List[List[dict[str, Any]]],
59
+ pr: List[List[dict[str, Any]]],
60
+ *,
61
+ label: Any,
62
+ iou_threshold: float,
63
+ ) -> tuple[Optional[float], np.ndarray, np.ndarray]:
64
+ """Compute AP for a single class at a given IoU threshold.
65
+
66
+ Returns (ap, precision_curve, recall_curve). If there are no GT boxes for
67
+ this label, returns (None, empty, empty).
68
+ """
69
+ # Collect GT by image and count positives
70
+ gt_by_img: List[List[Box]] = []
71
+ npos = 0
72
+ for boxes in gt:
73
+ g = [tuple(b["bbox"]) for b in boxes if b.get("label", None) == label]
74
+ gt_by_img.append(g)
75
+ npos += len(g)
76
+ if npos == 0:
77
+ return None, np.array([]), np.array([])
78
+
79
+ # Track matched GTs per image
80
+ matched: List[List[bool]] = [[False] * len(g) for g in gt_by_img]
81
+
82
+ # Collect predictions for this label across images
83
+ preds: List[tuple[int, float, Box]] = []
84
+ for img_idx, boxes in enumerate(pr):
85
+ for b in boxes:
86
+ if b.get("label", None) != label:
87
+ continue
88
+ score = float(b.get("score", 1.0))
89
+ preds.append((img_idx, score, tuple(b["bbox"])))
90
+ preds.sort(key=lambda t: t[1], reverse=True)
91
+
92
+ tp = np.zeros(len(preds), dtype=float)
93
+ fp = np.zeros(len(preds), dtype=float)
94
+
95
+ for i, (img_idx, _score, pb) in enumerate(preds):
96
+ gts = gt_by_img[img_idx]
97
+ best_iou = 0.0
98
+ best_j = -1
99
+ for j, gb in enumerate(gts):
100
+ if matched[img_idx][j]:
101
+ continue
102
+ iou = _iou(pb, gb)
103
+ if iou > best_iou:
104
+ best_iou = iou
105
+ best_j = j
106
+ if best_j >= 0 and best_iou >= iou_threshold:
107
+ matched[img_idx][best_j] = True
108
+ tp[i] = 1.0
109
+ else:
110
+ fp[i] = 1.0
111
+
112
+ cum_tp = np.cumsum(tp)
113
+ cum_fp = np.cumsum(fp)
114
+ precision = cum_tp / np.maximum(cum_tp + cum_fp, 1e-12)
115
+ recall = cum_tp / float(npos)
116
+ ap = _compute_ap_101point(precision, recall)
117
+ return float(ap), precision, recall
118
+
119
+
120
+ @dataclass
121
+ class DetectionReport(BaseReport):
122
+ y_true: Optional[Iterable[Any]] = None
123
+ y_pred: Optional[Iterable[Any]] = None
124
+ iou_threshold: float = 0.5
125
+ max_visualizations: int = 3
126
+ coco_iou_thresholds: Tuple[float, ...] = (
127
+ 0.50,
128
+ 0.55,
129
+ 0.60,
130
+ 0.65,
131
+ 0.70,
132
+ 0.75,
133
+ 0.80,
134
+ 0.85,
135
+ 0.90,
136
+ 0.95,
137
+ )
138
+ pr_curve_iou: float = 0.50
139
+
140
+ def _compute_metrics(self) -> None:
141
+ gt = _as_images_boxes(self.y_true)
142
+ pr = _as_images_boxes(self.y_pred)
143
+ if gt is None or pr is None:
144
+ raise ValueError("DetectionReport requires y_true and y_pred (per-image box lists).")
145
+ if len(gt) != len(pr):
146
+ raise ValueError("DetectionReport requires y_true and y_pred with the same number of images.")
147
+
148
+ tp = 0
149
+ fp = 0
150
+ fn = 0
151
+ ious_matched: List[float] = []
152
+
153
+ for gt_boxes, pr_boxes in zip(gt, pr):
154
+ # group by label (optional); if missing, treat as single class
155
+ # greedy matching by IoU
156
+ gt_used = [False] * len(gt_boxes)
157
+
158
+ # sort predictions by score desc if available
159
+ pr_sorted = sorted(pr_boxes, key=lambda d: float(d.get("score", 1.0)), reverse=True)
160
+ for pred in pr_sorted:
161
+ pb = tuple(pred["bbox"]) # type: ignore[arg-type]
162
+ pl = pred.get("label", None)
163
+ best_iou = 0.0
164
+ best_j = -1
165
+ for j, g in enumerate(gt_boxes):
166
+ if gt_used[j]:
167
+ continue
168
+ if pl is not None and g.get("label", None) != pl:
169
+ continue
170
+ gb = tuple(g["bbox"]) # type: ignore[arg-type]
171
+ i = _iou(pb, gb)
172
+ if i > best_iou:
173
+ best_iou = i
174
+ best_j = j
175
+ if best_j >= 0 and best_iou >= float(self.iou_threshold):
176
+ tp += 1
177
+ gt_used[best_j] = True
178
+ ious_matched.append(best_iou)
179
+ else:
180
+ fp += 1
181
+
182
+ fn += sum(1 for u in gt_used if not u)
183
+
184
+ precision = 0.0 if (tp + fp) == 0 else tp / (tp + fp)
185
+ recall = 0.0 if (tp + fn) == 0 else tp / (tp + fn)
186
+ f1 = 0.0 if (precision + recall) == 0 else 2 * precision * recall / (precision + recall)
187
+ mean_iou = float(np.mean(ious_matched)) if ious_matched else 0.0
188
+
189
+ # lightweight "AP" proxy: not COCO mAP; just F1/PR at a threshold.
190
+ self.metrics.update(
191
+ {
192
+ "precision": float(precision),
193
+ "recall": float(recall),
194
+ "f1": float(f1),
195
+ "mean_matched_iou": float(mean_iou),
196
+ "tp": int(tp),
197
+ "fp": int(fp),
198
+ "fn": int(fn),
199
+ "iou_threshold": float(self.iou_threshold),
200
+ }
201
+ )
202
+
203
+ # COCO-style AP/mAP (label-aware; requires labels)
204
+ labels = set()
205
+ for boxes in gt:
206
+ for b in boxes:
207
+ labels.add(b.get("label", "__all__"))
208
+ for boxes in pr:
209
+ for b in boxes:
210
+ labels.add(b.get("label", "__all__"))
211
+ labels = {l if l is not None else "__all__" for l in labels}
212
+
213
+ ap_by_thr: dict[str, float] = {}
214
+ per_class_ap: dict[str, dict[str, float]] = {}
215
+ for thr in self.coco_iou_thresholds:
216
+ aps = []
217
+ for lab in sorted(labels, key=lambda x: str(x)):
218
+ ap, _p, _r = _coco_ap_for_class(gt, pr, label=lab, iou_threshold=float(thr))
219
+ if ap is None:
220
+ continue
221
+ aps.append(ap)
222
+ per_class_ap.setdefault(str(lab), {})[f"ap@{thr:.2f}"] = float(ap)
223
+ ap_by_thr[f"map@{thr:.2f}"] = float(np.mean(aps)) if aps else 0.0
224
+
225
+ map_50_95 = float(np.mean(list(ap_by_thr.values()))) if ap_by_thr else 0.0
226
+ map_50 = ap_by_thr.get("map@0.50", 0.0)
227
+ map_75 = ap_by_thr.get("map@0.75", 0.0)
228
+
229
+ self.metrics.update(
230
+ {
231
+ "map_50_95": map_50_95,
232
+ "map_50": map_50,
233
+ "map_75": map_75,
234
+ "map_by_iou": ap_by_thr,
235
+ "ap_by_class": per_class_ap,
236
+ }
237
+ )
238
+
239
+
240
+ self._cached_gt = gt
241
+ self._cached_pr = pr
242
+ self._cached_labels = sorted(labels, key=lambda x: str(x))
243
+
244
+ def _generate_plots(self) -> None:
245
+ root = self.output_dir or Path("reports")
246
+ plot_dir = root / "evalreport_plots"
247
+ plot_dir.mkdir(parents=True, exist_ok=True)
248
+
249
+ plots: dict[str, str] = {}
250
+ gt = getattr(self, "_cached_gt", None)
251
+ pr = getattr(self, "_cached_pr", None)
252
+ if gt is None or pr is None:
253
+ self.plots = plots
254
+ return
255
+
256
+ # Minimal visualization: draw GT vs Pred boxes on blank canvas per image index.
257
+ n = min(int(self.max_visualizations), len(gt))
258
+ for i in range(n):
259
+ g = gt[i]
260
+ p = pr[i]
261
+ # determine canvas extents
262
+ all_boxes = [b["bbox"] for b in g + p if "bbox" in b]
263
+ if not all_boxes:
264
+ continue
265
+ xs = [c for bb in all_boxes for c in (bb[0], bb[2])]
266
+ ys = [c for bb in all_boxes for c in (bb[1], bb[3])]
267
+ w = max(xs) - min(xs)
268
+ h = max(ys) - min(ys)
269
+ w = max(1.0, w)
270
+ h = max(1.0, h)
271
+
272
+ plt.figure(figsize=(6, 6))
273
+ ax = plt.gca()
274
+ ax.set_xlim(min(xs) - 1, max(xs) + 1)
275
+ ax.set_ylim(max(ys) + 1, min(ys) - 1) # invert y
276
+ ax.set_title("Detection: GT (green) vs Pred (red)")
277
+ ax.set_xlabel("x")
278
+ ax.set_ylabel("y")
279
+
280
+ for bb in g:
281
+ x1, y1, x2, y2 = bb["bbox"]
282
+ rect = plt.Rectangle((x1, y1), x2 - x1, y2 - y1, fill=False, color="green", linewidth=2)
283
+ ax.add_patch(rect)
284
+ for bb in p:
285
+ x1, y1, x2, y2 = bb["bbox"]
286
+ rect = plt.Rectangle((x1, y1), x2 - x1, y2 - y1, fill=False, color="red", linewidth=2, linestyle="--")
287
+ ax.add_patch(rect)
288
+
289
+ path = plot_dir / f"detection_boxes_{i}.png"
290
+ plt.tight_layout()
291
+ plt.savefig(path)
292
+ plt.close()
293
+ plots[f"box_overlay_{i}"] = str(path)
294
+
295
+ self.plots = plots
296
+
297
+ # Precision-Recall curve for the first class at pr_curve_iou (if possible)
298
+ try:
299
+ labels = getattr(self, "_cached_labels", [])
300
+ if labels:
301
+ lab = labels[0]
302
+ ap, precision, recall = _coco_ap_for_class(
303
+ gt,
304
+ pr,
305
+ label=lab,
306
+ iou_threshold=float(self.pr_curve_iou),
307
+ )
308
+ if ap is not None and precision.size > 0:
309
+ plt.figure(figsize=(5, 4))
310
+ plt.plot(recall, precision, label=f"PR (AP={ap:.3f})")
311
+ plt.xlabel("Recall")
312
+ plt.ylabel("Precision")
313
+ plt.title(f"PR Curve @ IoU={self.pr_curve_iou:.2f} (label={lab})")
314
+ plt.ylim(0, 1.05)
315
+ plt.xlim(0, 1.05)
316
+ plt.legend()
317
+ path = plot_dir / "detection_pr_curve.png"
318
+ plt.tight_layout()
319
+ plt.savefig(path)
320
+ plt.close()
321
+ self.plots["pr_curve"] = str(path)
322
+ except Exception:
323
+ pass
324
+
325
+ def _generate_insights(self) -> None:
326
+ insights: List[str] = []
327
+ p = self.metrics.get("precision")
328
+ r = self.metrics.get("recall")
329
+ miou = self.metrics.get("mean_matched_iou")
330
+ if isinstance(p, (int, float)) and isinstance(r, (int, float)):
331
+ if p < 0.5 and r >= 0.7:
332
+ insights.append("High recall but low precision: many false positives. Consider raising score threshold.")
333
+ if r < 0.5 and p >= 0.7:
334
+ insights.append("High precision but low recall: many missed objects. Consider lowering score threshold.")
335
+ if isinstance(miou, (int, float)) and miou < 0.4:
336
+ insights.append("Low mean IoU among matches suggests poor localization.")
337
+ map_ = self.metrics.get("map_50_95")
338
+ if isinstance(map_, (int, float)) and map_ < 0.2:
339
+ insights.append("Low mAP suggests weak overall detection quality; verify label mapping and score thresholds.")
340
+ self.insights = insights
341
+
342
+ self.metric_descriptions.update(
343
+ {
344
+ "precision": "Fraction of predicted boxes that are correct matches (at IoU threshold).",
345
+ "recall": "Fraction of ground-truth boxes recovered by predictions (at IoU threshold).",
346
+ "f1": "Harmonic mean of precision and recall (single-threshold).",
347
+ "mean_matched_iou": "Average IoU among matched true-positive boxes.",
348
+ "tp": "True positives (matched predictions).",
349
+ "fp": "False positives (unmatched predictions).",
350
+ "fn": "False negatives (missed ground-truth boxes).",
351
+ "iou_threshold": "IoU threshold used to determine a match.",
352
+ "map_50_95": "COCO-style mAP averaged over IoU thresholds 0.50:0.95.",
353
+ "map_50": "mAP at IoU=0.50.",
354
+ "map_75": "mAP at IoU=0.75.",
355
+ "map_by_iou": "mAP for each IoU threshold (0.50..0.95).",
356
+ "ap_by_class": "Per-class AP values (by IoU threshold).",
357
+ }
358
+ )
359
+
@@ -0,0 +1,25 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any, Iterable, Optional, Sequence
5
+
6
+ from ..classification.report import ClassificationReport
7
+
8
+
9
+ @dataclass
10
+ class ImageClassificationReport(ClassificationReport):
11
+ """Image classification report.
12
+
13
+ This is currently an alias of the core :class:`~evalreport.ClassificationReport`,
14
+ because classification metrics/plots apply directly (confusion matrix,
15
+ ROC/PR curves for binary with probabilities, etc.).
16
+
17
+ Image-specific extensions (e.g., per-class error thumbnails) can be added
18
+ in future versions without changing the public API.
19
+ """
20
+
21
+ y_true: Optional[Iterable[Any]] = None
22
+ y_pred: Optional[Iterable[Any]] = None
23
+ y_prob: Optional[Iterable[Any]] = None
24
+ labels: Optional[Sequence[Any]] = None
25
+
@@ -0,0 +1,140 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ from typing import Any, Iterable, List, Optional
6
+
7
+ import matplotlib
8
+
9
+ matplotlib.use("Agg")
10
+ import matplotlib.pyplot as plt
11
+ import numpy as np
12
+
13
+ from ..core.base_report import BaseReport
14
+
15
+
16
+ def _as_mask_array(x: Optional[Iterable[Any]]) -> Optional[np.ndarray]:
17
+ if x is None:
18
+ return None
19
+ arr = np.asarray(list(x))
20
+ # Accept (H,W) single mask or (N,H,W)
21
+ if arr.ndim == 2:
22
+ arr = arr[None, ...]
23
+ return arr
24
+
25
+
26
+ def _binarize(mask: np.ndarray) -> np.ndarray:
27
+ # if mask already 0/1, keep; else threshold at 0.5 for float masks
28
+ if mask.dtype.kind in {"f"}:
29
+ return (mask >= 0.5).astype(np.uint8)
30
+ return (mask > 0).astype(np.uint8)
31
+
32
+
33
+ @dataclass
34
+ class SegmentationReport(BaseReport):
35
+ y_true_masks: Optional[Iterable[Any]] = None
36
+ y_pred_masks: Optional[Iterable[Any]] = None
37
+ max_visualizations: int = 3
38
+
39
+ def _compute_metrics(self) -> None:
40
+ yt = _as_mask_array(self.y_true_masks)
41
+ yp = _as_mask_array(self.y_pred_masks)
42
+ if yt is None or yp is None:
43
+ raise ValueError("SegmentationReport requires y_true_masks and y_pred_masks.")
44
+ if yt.shape != yp.shape:
45
+ raise ValueError("SegmentationReport requires y_true_masks and y_pred_masks with the same shape.")
46
+
47
+ yt_b = _binarize(yt)
48
+ yp_b = _binarize(yp)
49
+
50
+ # Flatten per-sample
51
+ yt_f = yt_b.reshape(yt_b.shape[0], -1)
52
+ yp_f = yp_b.reshape(yp_b.shape[0], -1)
53
+
54
+ ious = []
55
+ dices = []
56
+ pix_accs = []
57
+ for t, p in zip(yt_f, yp_f):
58
+ inter = int(np.sum((t == 1) & (p == 1)))
59
+ union = int(np.sum((t == 1) | (p == 1)))
60
+ iou = 1.0 if union == 0 else inter / union
61
+ denom = int(np.sum(t) + np.sum(p))
62
+ dice = 1.0 if denom == 0 else (2 * inter) / denom
63
+ pix_acc = float(np.mean(t == p))
64
+ ious.append(iou)
65
+ dices.append(dice)
66
+ pix_accs.append(pix_acc)
67
+
68
+ self.metrics.update(
69
+ {
70
+ "mean_iou": float(np.mean(ious)) if ious else 0.0,
71
+ "mean_dice": float(np.mean(dices)) if dices else 0.0,
72
+ "mean_pixel_accuracy": float(np.mean(pix_accs)) if pix_accs else 0.0,
73
+ "num_samples": int(yt.shape[0]),
74
+ }
75
+ )
76
+
77
+
78
+ self._cached_masks = (yt_b, yp_b)
79
+
80
+ def _generate_plots(self) -> None:
81
+ root = self.output_dir or Path("reports")
82
+ plot_dir = root / "evalreport_plots"
83
+ plot_dir.mkdir(parents=True, exist_ok=True)
84
+
85
+ plots: dict[str, str] = {}
86
+ yt_b, yp_b = getattr(self, "_cached_masks", (None, None))
87
+ if yt_b is None or yp_b is None:
88
+ self.plots = plots
89
+ return
90
+
91
+ n = min(int(self.max_visualizations), yt_b.shape[0])
92
+ for i in range(n):
93
+ t = yt_b[i]
94
+ p = yp_b[i]
95
+ overlay = np.zeros((*t.shape, 3), dtype=np.uint8)
96
+ # true in green, pred in red, overlap yellow
97
+ overlay[..., 1] = (t * 180).astype(np.uint8)
98
+ overlay[..., 0] = (p * 180).astype(np.uint8)
99
+ overlay[..., 2] = ((t & p) * 50).astype(np.uint8)
100
+
101
+ plt.figure(figsize=(9, 3))
102
+ plt.subplot(1, 3, 1)
103
+ plt.imshow(t, cmap="gray")
104
+ plt.title("Ground truth")
105
+ plt.axis("off")
106
+ plt.subplot(1, 3, 2)
107
+ plt.imshow(p, cmap="gray")
108
+ plt.title("Prediction")
109
+ plt.axis("off")
110
+ plt.subplot(1, 3, 3)
111
+ plt.imshow(overlay)
112
+ plt.title("Overlay")
113
+ plt.axis("off")
114
+ path = plot_dir / f"segmentation_masks_{i}.png"
115
+ plt.tight_layout()
116
+ plt.savefig(path)
117
+ plt.close()
118
+ plots[f"mask_comparison_{i}"] = str(path)
119
+
120
+ self.plots = plots
121
+
122
+ def _generate_insights(self) -> None:
123
+ insights: List[str] = []
124
+ iou = self.metrics.get("mean_iou")
125
+ if isinstance(iou, (int, float)):
126
+ if iou < 0.3:
127
+ insights.append("Low IoU suggests poor localization/shape overlap; inspect mask alignment and thresholds.")
128
+ elif iou > 0.7:
129
+ insights.append("High IoU indicates strong mask overlap and good segmentation quality.")
130
+ self.insights = insights
131
+
132
+ self.metric_descriptions.update(
133
+ {
134
+ "mean_iou": "Intersection over Union (Jaccard) averaged over samples; higher is better.",
135
+ "mean_dice": "Dice coefficient averaged over samples; higher is better.",
136
+ "mean_pixel_accuracy": "Fraction of pixels predicted correctly (foreground/background).",
137
+ "num_samples": "Number of masks evaluated.",
138
+ }
139
+ )
140
+