evalsuite-python 0.2.1__py3-none-any.whl → 0.3.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.
evalsuite/__init__.py CHANGED
@@ -5,7 +5,7 @@
5
5
  >>> print(result.summary()) # doctest: +SKIP
6
6
  """
7
7
 
8
- from . import calibration, classification, clinical, plot, regression, stats
8
+ from . import calibration, classification, clinical, plot, regression, stats, vision
9
9
  from .api import evaluate
10
10
  from .calibration import (
11
11
  CalibrationReport,
@@ -111,8 +111,49 @@ from .stats import (
111
111
  wilcoxon_test,
112
112
  )
113
113
  from .version import __version__
114
+ from .vision import (
115
+ DetectionReport,
116
+ SegmentationReport,
117
+ average_precision_detection,
118
+ average_surface_distance,
119
+ boundary_iou,
120
+ box_iou,
121
+ detection_pr_curve,
122
+ detection_report,
123
+ dice,
124
+ from_coco,
125
+ hausdorff_distance,
126
+ iou,
127
+ mean_average_precision,
128
+ mean_pixel_accuracy,
129
+ miou,
130
+ per_image_scores,
131
+ pixel_accuracy,
132
+ segmentation_confusion,
133
+ segmentation_report,
134
+ )
114
135
 
115
136
  __all__ = [
137
+ "SegmentationReport",
138
+ "segmentation_report",
139
+ "detection_pr_curve",
140
+ "vision",
141
+ "DetectionReport",
142
+ "average_precision_detection",
143
+ "average_surface_distance",
144
+ "boundary_iou",
145
+ "box_iou",
146
+ "detection_report",
147
+ "dice",
148
+ "from_coco",
149
+ "hausdorff_distance",
150
+ "iou",
151
+ "mean_average_precision",
152
+ "mean_pixel_accuracy",
153
+ "miou",
154
+ "per_image_scores",
155
+ "pixel_accuracy",
156
+ "segmentation_confusion",
116
157
  "CalibrationReport",
117
158
  "calibration_report",
118
159
  "calibration",
evalsuite/api.py CHANGED
@@ -149,6 +149,16 @@ def evaluate(
149
149
  >>> round(r["accuracy"], 2)
150
150
  0.75
151
151
  """
152
+ if isinstance(y_true, (list, tuple)) and y_true and isinstance(y_true[0], dict):
153
+ raise UnsupportedTaskError(
154
+ "y_true looks like object detection annotations (one dict per image); use "
155
+ "evalsuite.detection_report(y_true, y_pred) or evalsuite.mean_average_precision(...)."
156
+ )
157
+ if np.ndim(y_true) >= 3:
158
+ raise UnsupportedTaskError(
159
+ "y_true has 3 or more dimensions, which looks like segmentation masks (images first); use "
160
+ "evalsuite.segmentation_report(y_true, y_pred) or evalsuite.dice / evalsuite.iou."
161
+ )
152
162
  task = task or _infer_task(y_true, y_prob)
153
163
  if task == "classification":
154
164
  return _evaluate_classification(
evalsuite/benchmarks.py CHANGED
@@ -6,7 +6,8 @@ quantities on the same data, and the largest absolute difference between their r
6
6
  speed is never shown for numbers that disagree.
7
7
 
8
8
  References: scikit-learn for classification and regression (``suite="core"``); scikit-learn, statsmodels
9
- and SciPy for the v0.2.0 clinical, calibration and statistics functions (``suite="clinical"``). A case
9
+ and SciPy for the v0.2.0 clinical, calibration and statistics functions (``suite="clinical"``);
10
+ scikit-learn, SciPy and pycocotools for segmentation and detection (``suite="vision"``). A case
10
11
  whose reference library is not installed is timed for EvalSuite only.
11
12
 
12
13
  >>> from evalsuite.benchmarks import run_benchmarks
@@ -120,6 +121,145 @@ def _core_cases(n: int, rng: np.random.Generator) -> list[Case]:
120
121
  return [(name, ref, es_fn, sk_fn) for (name, ref, es_fn, _), sk_fn in zip(cases, sk)]
121
122
 
122
123
 
124
+ def _vision_cases(n: int, rng: np.random.Generator) -> list[Case]:
125
+ """v0.3.0: segmentation overlap and surface distance (n = pixels) and COCO detection (n / 1000 images)."""
126
+ import evalsuite as es
127
+
128
+ side = 64
129
+ n_img = max(1, n // (side * side))
130
+ k = 5
131
+ yy, xx = np.ogrid[:side, :side]
132
+ true = np.zeros((n_img, side, side), dtype=np.int64)
133
+ for i in range(n_img):
134
+ for c in range(1, k):
135
+ cy, cx, r = rng.integers(8, side - 8), rng.integers(8, side - 8), rng.integers(4, 14)
136
+ true[i][(yy - cy) ** 2 + (xx - cx) ** 2 <= r * r] = c
137
+ pred = np.roll(true, 1, axis=2)
138
+ noise = rng.random(pred.shape) < 0.02
139
+ pred[noise] = rng.integers(0, k, int(noise.sum()))
140
+ labels = list(range(k))
141
+
142
+ def es_overlap() -> list[float]:
143
+ d = np.asarray(es.dice(true, pred, average=None).value)
144
+ j = np.asarray(es.iou(true, pred, average=None).value)
145
+ return [*d, *j]
146
+
147
+ n_hd = min(n_img, 50)
148
+
149
+ def es_hd() -> list[float]:
150
+ return [float(es.hausdorff_distance(true[i], pred[i], labels=[k - 1])) for i in range(n_hd)]
151
+
152
+ n_det = max(10, n // 1000)
153
+ y_true, y_pred = [], []
154
+ for _ in range(n_det):
155
+ m = int(rng.integers(1, 8))
156
+ xy = rng.uniform(0, 500, (m, 2))
157
+ wh = rng.uniform(8, 160, (m, 2))
158
+ boxes = np.column_stack([xy, xy + wh])
159
+ lab = rng.integers(1, 6, m)
160
+ y_true.append({"boxes": boxes, "labels": lab, "iscrowd": np.zeros(m, int)})
161
+ jitter = boxes + rng.normal(0, 4, boxes.shape)
162
+ jitter[:, 2:] = np.maximum(jitter[:, 2:], jitter[:, :2] + 1)
163
+ extra = rng.uniform(0, 500, (3, 2))
164
+ fp = np.column_stack([extra, extra + rng.uniform(10, 80, (3, 2))])
165
+ y_pred.append(
166
+ {
167
+ "boxes": np.vstack([jitter, fp]),
168
+ "labels": np.r_[lab, rng.integers(1, 6, 3)],
169
+ "scores": rng.random(m + 3),
170
+ }
171
+ )
172
+
173
+ def es_map() -> list[float]:
174
+ r = es.detection_report(y_true, y_pred)
175
+ return [r["map"], r["map_50"], r["map_75"], r["mar_100"]]
176
+
177
+ cases: list[Case] = [
178
+ ("segmentation: Dice and IoU per class (n = pixels)", "scikit-learn", es_overlap, None),
179
+ (f"segmentation: Hausdorff distance ({n_hd} image{'s' if n_hd != 1 else ''})", "SciPy", es_hd, None),
180
+ (f"detection: COCO evaluation ({n_det} images)", "pycocotools", es_map, None),
181
+ ]
182
+ refs: dict[str, Callable[[], Any]] = {}
183
+ try:
184
+ import sklearn.metrics as skm
185
+
186
+ def sk_overlap() -> list[float]:
187
+ ft, fp_ = true.ravel(), pred.ravel()
188
+ return [
189
+ *skm.f1_score(ft, fp_, labels=labels, average=None),
190
+ *skm.jaccard_score(ft, fp_, labels=labels, average=None),
191
+ ]
192
+
193
+ refs[cases[0][0]] = sk_overlap
194
+ except ImportError:
195
+ pass
196
+ from scipy import ndimage
197
+ from scipy.spatial.distance import directed_hausdorff
198
+
199
+ def scipy_hd() -> list[float]:
200
+ out = []
201
+ for i in range(n_hd):
202
+ pts = []
203
+ for m_ in (true[i] == k - 1, pred[i] == k - 1):
204
+ er = ndimage.binary_erosion(m_, structure=ndimage.generate_binary_structure(2, 1), border_value=0)
205
+ pts.append(np.argwhere(m_ & ~er).astype(float))
206
+ out.append(max(directed_hausdorff(pts[0], pts[1])[0], directed_hausdorff(pts[1], pts[0])[0]))
207
+ return out
208
+
209
+ refs[cases[1][0]] = scipy_hd
210
+ try:
211
+ import contextlib as _ctx
212
+ import io
213
+
214
+ from pycocotools.coco import COCO # type: ignore[import-untyped]
215
+ from pycocotools.cocoeval import COCOeval # type: ignore[import-untyped]
216
+
217
+ def coco_map() -> list[float]:
218
+ images, anns, dets, aid = [], [], [], 1
219
+ for i, (t, p) in enumerate(zip(y_true, y_pred)):
220
+ images.append({"id": i + 1})
221
+ for b, c in zip(t["boxes"], t["labels"]):
222
+ w, h = b[2] - b[0], b[3] - b[1]
223
+ anns.append(
224
+ {
225
+ "id": aid,
226
+ "image_id": i + 1,
227
+ "category_id": int(c),
228
+ "bbox": [b[0], b[1], w, h],
229
+ "area": w * h,
230
+ "iscrowd": 0,
231
+ }
232
+ )
233
+ aid += 1
234
+ for b, c, sc in zip(p["boxes"], p["labels"], p["scores"]):
235
+ dets.append(
236
+ {
237
+ "image_id": i + 1,
238
+ "category_id": int(c),
239
+ "bbox": [b[0], b[1], b[2] - b[0], b[3] - b[1]],
240
+ "score": float(sc),
241
+ }
242
+ )
243
+ with _ctx.redirect_stdout(io.StringIO()):
244
+ gt = COCO()
245
+ gt.dataset = {
246
+ "images": images,
247
+ "annotations": anns,
248
+ "categories": [{"id": c} for c in range(1, 6)],
249
+ }
250
+ gt.createIndex()
251
+ ev = COCOeval(gt, gt.loadRes(dets), "bbox")
252
+ ev.evaluate()
253
+ ev.accumulate()
254
+ ev.summarize()
255
+ return [ev.stats[0], ev.stats[1], ev.stats[2], ev.stats[8]]
256
+
257
+ refs[cases[2][0]] = coco_map
258
+ except ImportError:
259
+ pass
260
+ return [(name, ref, es_fn, refs.get(name)) for name, ref, es_fn, _ in cases]
261
+
262
+
123
263
  def _clinical_cases(n: int, rng: np.random.Generator) -> list[Case]:
124
264
  """v0.2.0: diagnostic accuracy, calibration, decision curves and statistical tests."""
125
265
  import evalsuite as es
@@ -286,6 +426,7 @@ class BenchmarkResult:
286
426
  + (f" | scikit-learn {env['sklearn']}" if env.get("sklearn") else "")
287
427
  + (f" | statsmodels {env['statsmodels']}" if env.get("statsmodels") else "")
288
428
  + (f" | SciPy {env['scipy']}" if env.get("scipy") else "")
429
+ + (f" | pycocotools {env['pycocotools']}" if env.get("pycocotools") else "")
289
430
  + f" | {env['machine']} | fastest of {env['repeat']} runs"
290
431
  )
291
432
  note = "Speed-up > 1 means EvalSuite is faster. Max |difference| compares EvalSuite with the reference."
@@ -354,7 +495,8 @@ def run_benchmarks(
354
495
  """Time and memory for evaluation workloads at each size, against a reference implementation.
355
496
 
356
497
  ``suite``: ``"core"`` (classification and regression vs scikit-learn), ``"clinical"`` (v0.2.0 clinical,
357
- calibration and statistics vs scikit-learn, statsmodels, SciPy) or ``"all"`` (default).
498
+ calibration and statistics vs scikit-learn, statsmodels, SciPy), ``"vision"`` (segmentation and COCO
499
+ detection vs scikit-learn, SciPy, pycocotools) or ``"all"`` (default).
358
500
  ``compare_sklearn=False`` times EvalSuite alone. Rows keep ``sklearn_ms``/``sklearn_peak_mb`` for rows
359
501
  whose reference is scikit-learn, for compatibility with 0.1.x.
360
502
  """
@@ -364,16 +506,24 @@ def run_benchmarks(
364
506
 
365
507
  if repeat < 1:
366
508
  raise ValueError("repeat must be at least 1.")
367
- if suite not in ("all", "core", "clinical"):
368
- raise ValueError("suite must be 'all', 'core' or 'clinical'.")
509
+ if suite not in ("all", "core", "clinical", "vision"):
510
+ raise ValueError("suite must be 'all', 'core', 'clinical' or 'vision'.")
369
511
  rng = np.random.default_rng(random_state)
370
512
  rows: list[dict[str, Any]] = []
371
- versions: dict[str, Optional[str]] = {"sklearn": None, "statsmodels": None}
513
+ versions: dict[str, Optional[str]] = {"sklearn": None, "statsmodels": None, "pycocotools": None}
372
514
  if compare_sklearn:
373
515
  for mod in versions:
374
516
  with contextlib.suppress(ImportError):
375
- versions[mod] = __import__(mod).__version__
376
- builders = {"core": [_core_cases], "clinical": [_clinical_cases], "all": [_core_cases, _clinical_cases]}[suite]
517
+ __import__(mod)
518
+ from importlib.metadata import version as _dist_version
519
+
520
+ versions[mod] = _dist_version("scikit-learn" if mod == "sklearn" else mod)
521
+ builders = {
522
+ "core": [_core_cases],
523
+ "clinical": [_clinical_cases],
524
+ "vision": [_vision_cases],
525
+ "all": [_core_cases, _clinical_cases, _vision_cases],
526
+ }[suite]
377
527
  for n in sizes:
378
528
  for build in builders:
379
529
  for name, ref_name, es_fn, ref_fn in build(int(n), rng):
@@ -410,6 +560,7 @@ def run_benchmarks(
410
560
  "scipy": scipy.__version__ if suite != "core" else None,
411
561
  "sklearn": versions["sklearn"],
412
562
  "statsmodels": versions["statsmodels"] if suite != "core" else None,
563
+ "pycocotools": versions["pycocotools"] if suite in ("all", "vision") else None,
413
564
  "machine": f"{platform.system()} {platform.machine()}",
414
565
  "repeat": repeat,
415
566
  "suite": suite,
evalsuite/calibration.py CHANGED
@@ -128,7 +128,7 @@ def logistic_fit(
128
128
  beta = np.zeros(design.shape[1])
129
129
  for _ in range(max_iter):
130
130
  eta = design @ beta + off
131
- mu = 1 / (1 + np.exp(-eta))
131
+ mu = 1 / (1 + np.exp(-np.clip(eta, -700, 700)))
132
132
  grad = design.T @ (w * (y - mu))
133
133
  hess = (design * (w * mu * (1 - mu))[:, None]).T @ design
134
134
  try:
evalsuite/cli/main.py CHANGED
@@ -226,6 +226,77 @@ def cmd_calibration(args: argparse.Namespace) -> int:
226
226
  return 0
227
227
 
228
228
 
229
+ def read_masks(path: str, name: str) -> Any:
230
+ """Masks from .npy / .npz (first array, or the array named like the file stem) or a folder of image
231
+ files (PNG, TIFF, ...; read with Pillow, sorted by file name)."""
232
+ import numpy as np
233
+
234
+ if not os.path.exists(path):
235
+ raise CLIError(f"{name}: no such file or folder: {path}")
236
+ if os.path.isdir(path):
237
+ try:
238
+ from PIL import Image
239
+ except ImportError as exc:
240
+ raise CLIError(f"{name}: reading image folders needs Pillow (pip install pillow).") from exc
241
+ files = sorted(
242
+ f for f in os.listdir(path) if f.lower().endswith((".png", ".tif", ".tiff", ".bmp", ".gif", ".jpg"))
243
+ )
244
+ if not files:
245
+ raise CLIError(f"{name}: no image files in {path}.")
246
+ return [np.asarray(Image.open(os.path.join(path, f))) for f in files]
247
+ ext = os.path.splitext(path)[1].lower()
248
+ if ext == ".npy":
249
+ return np.load(path, allow_pickle=False)
250
+ if ext == ".npz":
251
+ with np.load(path, allow_pickle=False) as data:
252
+ if not data.files:
253
+ raise CLIError(f"{name}: {path} contains no arrays.")
254
+ return data[data.files[0]]
255
+ raise CLIError(f"{name}: use a .npy or .npz file, or a folder of mask images.")
256
+
257
+
258
+ def cmd_segmentation(args: argparse.Namespace) -> int:
259
+ import evalsuite as es
260
+
261
+ spacing = tuple(float(x) for x in args.spacing.split(",")) if args.spacing else None
262
+ report = es.segmentation_report(
263
+ read_masks(args.y_true, "y_true"),
264
+ read_masks(args.y_pred, "y_pred"),
265
+ num_classes=args.num_classes,
266
+ ignore_index=args.ignore_index,
267
+ spacing=spacing,
268
+ aggregate=args.aggregate,
269
+ include_background=not args.no_background,
270
+ )
271
+ emit(report, args)
272
+ if args.plot:
273
+ _save_figure(lambda ax: es.plot.per_class(_report_iou(report), ax=ax), args.plot)
274
+ return 0
275
+
276
+
277
+ def _report_iou(report: Any) -> Any:
278
+ import numpy as np
279
+
280
+ from ..core.result import MetricResult
281
+
282
+ return MetricResult(
283
+ "iou", "IoU", np.array([r["iou"] for r in report.rows]), labels=tuple(r["name"] for r in report.rows)
284
+ )
285
+
286
+
287
+ def cmd_detection(args: argparse.Namespace) -> int:
288
+ import evalsuite as es
289
+
290
+ y_true, y_pred = es.from_coco(args.ground_truth, args.results)
291
+ report = es.detection_report(y_true, y_pred, box_format="xywh", max_detections=args.max_detections)
292
+ emit(report, args)
293
+ if args.plot:
294
+ _save_figure(
295
+ lambda ax: es.plot.detection_pr(y_true, y_pred, ax=ax, box_format="xywh", iou_threshold=0.5), args.plot
296
+ )
297
+ return 0
298
+
299
+
229
300
  def cmd_compare(args: argparse.Namespace) -> int:
230
301
  import evalsuite as es
231
302
 
@@ -410,6 +481,31 @@ def build_parser() -> argparse.ArgumentParser:
410
481
  add_output(p)
411
482
  p.set_defaults(func=cmd_calibration)
412
483
 
484
+ p = sub.add_parser(
485
+ "segmentation",
486
+ help="segmentation masks: Dice, IoU, mIoU, Boundary IoU, HD95, ASSD per class",
487
+ )
488
+ p.add_argument("y_true", help="true masks: .npy/.npz (images on the first axis) or a folder of mask images")
489
+ p.add_argument("y_pred", help="predicted masks, same layout as y_true")
490
+ p.add_argument("--num-classes", type=int, help="number of classes (default: largest label + 1)")
491
+ p.add_argument("--ignore-index", type=int, help="label to ignore, e.g. 255")
492
+ p.add_argument("--spacing", help="pixel spacing per axis for surface distances, e.g. 0.8,0.8")
493
+ p.add_argument("--aggregate", choices=("dataset", "image"), default="dataset")
494
+ p.add_argument("--no-background", action="store_true", help="leave class 0 out")
495
+ p.add_argument("--plot", metavar="PATH", help="also save a per-class IoU bar chart")
496
+ add_output(p)
497
+ p.set_defaults(func=cmd_segmentation)
498
+
499
+ p = sub.add_parser(
500
+ "detection", help="object detection, COCO protocol: mAP, mAP@.50, mAP@.75, mAR, AP per class"
501
+ )
502
+ p.add_argument("ground_truth", help="COCO ground-truth JSON (images, annotations, categories)")
503
+ p.add_argument("results", help="COCO results JSON (image_id, category_id, bbox, score)")
504
+ p.add_argument("--max-detections", type=int, default=100)
505
+ p.add_argument("--plot", metavar="PATH", help="also save precision-recall curves per class (IoU 0.5)")
506
+ add_output(p, digits=3)
507
+ p.set_defaults(func=cmd_detection)
508
+
413
509
  p = sub.add_parser(
414
510
  "plot", help="save a ROC, PR, calibration, decision-curve, confusion-matrix or residual plot"
415
511
  )
@@ -433,9 +529,10 @@ def build_parser() -> argparse.ArgumentParser:
433
529
  p.add_argument("--repeat", type=int, default=5, help="timed repetitions per case; the fastest is reported")
434
530
  p.add_argument(
435
531
  "--suite",
436
- choices=("all", "core", "clinical"),
532
+ choices=("all", "core", "clinical", "vision"),
437
533
  default="all",
438
- help="core: classification/regression; clinical: v0.2.0 clinical, calibration and tests (default all)",
534
+ help="core: classification/regression; clinical: clinical, calibration, tests; vision: segmentation, "
535
+ "detection (default all)",
439
536
  )
440
537
  p.add_argument("--no-sklearn", action="store_true", help="time EvalSuite only (skip reference libraries)")
441
538
  p.add_argument("--seed", type=int, default=0)
evalsuite/plot.py CHANGED
@@ -21,7 +21,18 @@ if TYPE_CHECKING:
21
21
 
22
22
  from .stats.compare import ComparisonResult
23
23
 
24
- __all__ = ["calibration", "comparison", "confusion_matrix", "decision_curve", "pr", "residuals", "roc"]
24
+ __all__ = [
25
+ "calibration",
26
+ "comparison",
27
+ "confusion_matrix",
28
+ "decision_curve",
29
+ "detection_pr",
30
+ "per_class",
31
+ "pr",
32
+ "residuals",
33
+ "roc",
34
+ "segmentation",
35
+ ]
25
36
 
26
37
  Scores = Union[ArrayLike, Mapping[str, ArrayLike]]
27
38
  _STYLES = ("-", "--", "-.", ":")
@@ -339,3 +350,156 @@ def decision_curve(
339
350
  )
340
351
  ax.legend(loc="upper right", frameon=False)
341
352
  return ax
353
+
354
+
355
+ def per_class(
356
+ result: Any,
357
+ *,
358
+ ax: Optional[Axes] = None,
359
+ class_names: Optional[Mapping[Any, str]] = None,
360
+ sort: bool = False,
361
+ metric: str = "dice",
362
+ ) -> Axes:
363
+ """Horizontal bar chart of per-class scores, with the macro mean as a reference line.
364
+
365
+ ``result`` is any metric computed with ``average=None`` (e.g. ``es.iou(..., average=None)`` or
366
+ ``es.f1(..., average=None)``), a :class:`SegmentationReport` (``metric`` picks the column: ``"dice"``,
367
+ ``"iou"``, ``"boundary_iou"``, ``"hd95"`` or ``"assd"``) or a :class:`DetectionReport` (AP per class).
368
+ Undefined classes (NaN) are labelled "n/a"."""
369
+ rows = getattr(result, "rows", None)
370
+ if isinstance(rows, tuple) and rows and "class" in rows[0]:
371
+ if metric not in rows[0]:
372
+ raise InputValidationError(
373
+ f"Unknown metric {metric!r}; use one of dice, iou, boundary_iou, hd95, assd."
374
+ )
375
+ values = np.array([r[metric] for r in rows], dtype=np.float64)
376
+ labels: list[Any] = [r["class"] for r in rows]
377
+ default_names = [str(r["name"]) for r in rows]
378
+ title = {"iou": "IoU", "boundary_iou": "Boundary IoU", "hd95": "HD95", "assd": "ASSD"}.get(metric, "Dice")
379
+ elif isinstance(getattr(result, "per_class", None), Mapping):
380
+ labels = list(result.per_class)
381
+ values = np.array([result.per_class[c] for c in labels], dtype=np.float64)
382
+ default_names = [str(c) for c in labels]
383
+ title = "AP@[.50:.95]"
384
+ elif isinstance(getattr(result, "value", None), np.ndarray) and result.labels is not None:
385
+ values = np.asarray(result.value, dtype=np.float64)
386
+ labels = list(result.labels)
387
+ default_names = [str(c) for c in labels]
388
+ title = result.name
389
+ else:
390
+ raise InputValidationError(
391
+ "per_class() needs a per-class result (a metric computed with average=None) "
392
+ "or a segmentation or detection report."
393
+ )
394
+ names = [str(class_names.get(c, d)) if class_names else d for c, d in zip(labels, default_names)]
395
+ order = np.argsort(np.nan_to_num(values, nan=-np.inf)) if sort else np.arange(values.size)[::-1]
396
+ ax = _axes(ax, (5.0, max(2.4, 0.38 * values.size + 1.2)))
397
+ finite = np.isfinite(values)
398
+ ax.barh(np.arange(values.size), np.where(finite, values, 0)[order], color="#2a6fb0")
399
+ for y, i in enumerate(order):
400
+ ax.text(
401
+ values[i] if finite[i] else 0,
402
+ y,
403
+ f" {values[i]:.3f}" if finite[i] else " n/a",
404
+ va="center",
405
+ fontsize=8,
406
+ )
407
+ if finite.any():
408
+ ax.axvline(float(np.nanmean(values[finite])), color="0.3", linestyle="--", linewidth=1, label="macro mean")
409
+ ax.legend(loc="lower right", frameon=False)
410
+ ax.set(yticks=np.arange(values.size), yticklabels=[names[i] for i in order], xlabel=title)
411
+ ax.set_title(f"{title} per class")
412
+ upper = float(np.nanmax(values[finite])) if finite.any() else 1.0
413
+ ax.set_xlim(0, max(1.0, upper * 1.15) if upper <= 1 else upper * 1.15)
414
+ return ax
415
+
416
+
417
+ def segmentation(
418
+ image: Optional[ArrayLike],
419
+ y_true: ArrayLike,
420
+ y_pred: ArrayLike,
421
+ *,
422
+ ax: Optional[Axes] = None,
423
+ alpha: float = 0.45,
424
+ class_names: Optional[Mapping[int, str]] = None,
425
+ ignore_index: Optional[int] = None,
426
+ ) -> Axes:
427
+ """One image with the predicted mask as a colour overlay and the true class boundaries as contours, so
428
+ over- and under-segmentation are visible at a glance. Dice and IoU (macro, excluding background) are in
429
+ the title. ``image`` may be None (masks only), grey-scale or RGB."""
430
+ from .vision.segmentation import dice as dice_fn
431
+ from .vision.segmentation import iou as iou_fn
432
+
433
+ plt = _plt()
434
+ t = np.asarray(y_true)
435
+ pr = np.asarray(y_pred)
436
+ if t.ndim != 2 or t.shape != pr.shape:
437
+ raise InputValidationError(
438
+ "segmentation() plots one 2-D image: y_true and y_pred must be equal 2-D masks."
439
+ )
440
+ ax = _axes(ax, (5.0, 5.0 * t.shape[0] / max(t.shape[1], 1)))
441
+ if image is not None:
442
+ img = np.asarray(image)
443
+ ax.imshow(img, cmap="gray" if img.ndim == 2 else None)
444
+ else:
445
+ ax.imshow(np.ones(t.shape), cmap="gray", vmin=0, vmax=1)
446
+ classes = sorted(
447
+ int(c) for c in np.unique(np.concatenate([t.ravel(), pr.ravel()])) if c != 0 and c != ignore_index
448
+ )
449
+ cmap = plt.get_cmap("tab10")
450
+ overlay = np.zeros((*t.shape, 4))
451
+ for i, c in enumerate(classes):
452
+ color = cmap(i % 10)
453
+ overlay[pr == c] = (*color[:3], alpha)
454
+ if np.any(t == c):
455
+ ax.contour((t == c).astype(float), levels=[0.5], colors=[color[:3]], linewidths=1.6)
456
+ ax.plot(
457
+ [],
458
+ [],
459
+ color=color[:3],
460
+ linewidth=6,
461
+ alpha=0.6,
462
+ label=str(class_names.get(c, c)) if class_names else str(c),
463
+ )
464
+ ax.imshow(overlay)
465
+ if classes:
466
+ d = float(dice_fn(t, pr, ignore_index=ignore_index, include_background=False))
467
+ j = float(iou_fn(t, pr, ignore_index=ignore_index, include_background=False))
468
+ ax.set_title(f"Prediction (fill) vs truth (outline): Dice {d:.3f}, IoU {j:.3f}", fontsize=9)
469
+ ax.legend(loc="upper right", fontsize=7, frameon=True, title="class", title_fontsize=7)
470
+ ax.set_axis_off()
471
+ return ax
472
+
473
+
474
+ def detection_pr(
475
+ y_true: Any,
476
+ y_pred: Any,
477
+ *,
478
+ ax: Optional[Axes] = None,
479
+ iou_threshold: float = 0.5,
480
+ box_format: str = "xyxy",
481
+ class_names: Optional[Mapping[int, str]] = None,
482
+ max_classes: int = 10,
483
+ ) -> Axes:
484
+ """Precision-recall curves per class for object detection (COCO 101-point interpolation) at one IoU
485
+ threshold, with each class's AP in the legend; classes beyond ``max_classes`` (by AP) are omitted."""
486
+ from .vision.detection import detection_pr_curve
487
+
488
+ curves = detection_pr_curve(y_true, y_pred, iou_threshold=iou_threshold, box_format=box_format) # type: ignore[arg-type]
489
+ if not curves:
490
+ raise InputValidationError("No class has ground-truth boxes; nothing to plot.")
491
+ ranked = sorted(curves.items(), key=lambda kv: -float(np.mean(kv[1][1])))[:max_classes]
492
+ ax = _axes(ax)
493
+ for i, (c, (rc, pr)) in enumerate(ranked):
494
+ name = class_names.get(c, str(c)) if class_names else str(c)
495
+ ax.plot(rc, pr, label=f"{name}: AP = {float(np.mean(pr)):.3f}", **_style(i))
496
+ m = float(np.mean([np.mean(p) for _, (_, p) in curves.items()]))
497
+ ax.set(
498
+ xlabel="Recall",
499
+ ylabel="Precision",
500
+ title=f"Detection PR curves @IoU {iou_threshold:g} (mAP = {m:.3f})",
501
+ xlim=(0, 1.01),
502
+ ylim=(0, 1.02),
503
+ )
504
+ ax.legend(loc="lower left", frameon=False, fontsize="small")
505
+ return ax
@@ -4,7 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import contextlib
6
6
  import inspect
7
- from collections.abc import Callable
7
+ from collections.abc import Callable, Mapping
8
8
  from typing import Any, Optional
9
9
 
10
10
  import numpy as np
@@ -33,7 +33,30 @@ def resolve_metric(metric: Any) -> tuple[MetricFn, str]:
33
33
  raise InputValidationError("metric must be a metric function (e.g. es.f1) or its name (e.g. 'f1').")
34
34
 
35
35
 
36
+ def as_observations(x: Any, name: str) -> NDArray[Any]:
37
+ """Observations to resample along the first axis: 1-D/2-D arrays (labels, values, probabilities), stacks of
38
+ segmentation masks (3-D or more: images first), or per-image detection dicts / differently sized masks
39
+ (held in a 1-D object array)."""
40
+ if (
41
+ isinstance(x, (list, tuple))
42
+ and x
43
+ and (isinstance(x[0], Mapping) or (np.ndim(x[0]) >= 2 and len({np.shape(m) for m in x}) > 1))
44
+ ):
45
+ out = np.empty(len(x), dtype=object)
46
+ for i, v in enumerate(x):
47
+ out[i] = v
48
+ return out
49
+ arr = np.asarray(x.to_numpy() if hasattr(x, "to_numpy") else x)
50
+ if arr.ndim > 2:
51
+ if arr.shape[0] == 0:
52
+ raise InputValidationError(f"{name} is empty. Provide at least one observation.")
53
+ return arr
54
+ return to_numpy(x, name, allow_2d=True)
55
+
56
+
36
57
  def is_categorical(y: NDArray[Any]) -> bool:
58
+ if y.ndim > 2 or (y.dtype == object and y.size and not isinstance(y.flat[0], (str, bytes))):
59
+ return False
37
60
  try:
38
61
  return target_type(y) in ("binary", "multiclass", "multilabel")
39
62
  except Exception:
@@ -57,9 +80,9 @@ class MetricCall:
57
80
  "Pass exactly one of y_pred (label/value metrics) or y_prob (probability metrics)."
58
81
  )
59
82
  self.fn = fn
60
- self.y_true = to_numpy(y_true, "y_true", allow_2d=True)
83
+ self.y_true = as_observations(y_true, "y_true")
61
84
  second = y_pred if y_pred is not None else y_prob
62
- self.second = to_numpy(second, "y_pred" if y_pred is not None else "y_prob", allow_2d=True)
85
+ self.second = as_observations(second, "y_pred" if y_pred is not None else "y_prob")
63
86
  if self.second.shape[0] != self.y_true.shape[0]:
64
87
  raise InputValidationError(
65
88
  f"y_true and {'y_pred' if y_pred is not None else 'y_prob'} must contain the same number of "
@@ -15,8 +15,7 @@ from ..core.exceptions import InputValidationError
15
15
  from ..core.registry import metric_info
16
16
  from ..core.result import _json_safe, _latex_escape, _latex_table
17
17
  from ..core.types import ArrayLike
18
- from ..core.validation import to_numpy
19
- from ._resolve import MetricCall, is_categorical, resolve_metric
18
+ from ._resolve import MetricCall, as_observations, is_categorical, resolve_metric
20
19
  from .effect import adjust_pvalues
21
20
  from .intervals import _check_level, _check_resamples, bootstrap_distribution, resample_indices
22
21
  from .paired import delong_test, mcnemar_test
@@ -296,10 +295,13 @@ def compare(
296
295
  raise InputValidationError("compare() needs at least two models (a dict of name -> predictions).")
297
296
  if baseline is not None and baseline not in names:
298
297
  raise InputValidationError(f"baseline={baseline!r} is not one of the models: {', '.join(names)}.")
299
- yt = to_numpy(y_true, "y_true", allow_2d=True)
298
+ yt = as_observations(y_true, "y_true")
300
299
  categorical = is_categorical(yt)
301
300
  if metrics is None:
302
- if categorical:
301
+ if yt.ndim > 2 or (yt.dtype == object and yt.size and not isinstance(yt[0], (str, bytes))):
302
+ is_detection = yt.dtype == object and isinstance(yt[0], Mapping)
303
+ metric_names = ["mean_average_precision"] if is_detection else ["dice", "iou"]
304
+ elif categorical:
303
305
  metric_names = list(_DEFAULT_CLF) if predictions else []
304
306
  if probabilities:
305
307
  metric_names += _DEFAULT_CLF_PROB if np.unique(yt).shape[0] <= 2 and yt.ndim == 1 else ["roc_auc"]