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.
- evalreport/__init__.py +28 -0
- evalreport/__version__.py +2 -0
- evalreport/classification/__init__.py +4 -0
- evalreport/classification/report.py +319 -0
- evalreport/clustering/__init__.py +4 -0
- evalreport/clustering/report.py +174 -0
- evalreport/core/base_report.py +479 -0
- evalreport/core/entrypoints.py +97 -0
- evalreport/core/task_inference.py +180 -0
- evalreport/nlp/__init__.py +5 -0
- evalreport/nlp/text_classification.py +21 -0
- evalreport/nlp/text_generation.py +202 -0
- evalreport/ranking/__init__.py +3 -0
- evalreport/ranking/report.py +274 -0
- evalreport/regression/__init__.py +4 -0
- evalreport/regression/report.py +173 -0
- evalreport/timeseries/__init__.py +4 -0
- evalreport/timeseries/report.py +211 -0
- evalreport/vision/__init__.py +6 -0
- evalreport/vision/detection.py +359 -0
- evalreport/vision/image_classification.py +25 -0
- evalreport/vision/segmentation.py +140 -0
- model_eval_toolkit-0.1.0.dist-info/METADATA +339 -0
- model_eval_toolkit-0.1.0.dist-info/RECORD +27 -0
- model_eval_toolkit-0.1.0.dist-info/WHEEL +5 -0
- model_eval_toolkit-0.1.0.dist-info/licenses/LICENSE +21 -0
- model_eval_toolkit-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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
|
+
|