evalsuite-python 0.2.1__py3-none-any.whl → 0.3.1__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
@@ -16,8 +17,10 @@ whose reference library is not installed is timed for EvalSuite only.
16
17
  from __future__ import annotations
17
18
 
18
19
  import contextlib
20
+ import importlib
19
21
  import json
20
22
  import platform
23
+ import re
21
24
  import time
22
25
  import tracemalloc
23
26
  import warnings
@@ -120,6 +123,145 @@ def _core_cases(n: int, rng: np.random.Generator) -> list[Case]:
120
123
  return [(name, ref, es_fn, sk_fn) for (name, ref, es_fn, _), sk_fn in zip(cases, sk)]
121
124
 
122
125
 
126
+ def _vision_cases(n: int, rng: np.random.Generator) -> list[Case]:
127
+ """v0.3.0: segmentation overlap and surface distance (n = pixels) and COCO detection (n / 1000 images)."""
128
+ import evalsuite as es
129
+
130
+ side = 64
131
+ n_img = max(1, n // (side * side))
132
+ k = 5
133
+ yy, xx = np.ogrid[:side, :side]
134
+ true = np.zeros((n_img, side, side), dtype=np.int64)
135
+ for i in range(n_img):
136
+ for c in range(1, k):
137
+ cy, cx, r = rng.integers(8, side - 8), rng.integers(8, side - 8), rng.integers(4, 14)
138
+ true[i][(yy - cy) ** 2 + (xx - cx) ** 2 <= r * r] = c
139
+ pred = np.roll(true, 1, axis=2)
140
+ noise = rng.random(pred.shape) < 0.02
141
+ pred[noise] = rng.integers(0, k, int(noise.sum()))
142
+ labels = list(range(k))
143
+
144
+ def es_overlap() -> list[float]:
145
+ d = np.asarray(es.dice(true, pred, average=None).value)
146
+ j = np.asarray(es.iou(true, pred, average=None).value)
147
+ return [*d, *j]
148
+
149
+ n_hd = min(n_img, 50)
150
+
151
+ def es_hd() -> list[float]:
152
+ return [float(es.hausdorff_distance(true[i], pred[i], labels=[k - 1])) for i in range(n_hd)]
153
+
154
+ n_det = max(10, n // 1000)
155
+ y_true, y_pred = [], []
156
+ for _ in range(n_det):
157
+ m = int(rng.integers(1, 8))
158
+ xy = rng.uniform(0, 500, (m, 2))
159
+ wh = rng.uniform(8, 160, (m, 2))
160
+ boxes = np.column_stack([xy, xy + wh])
161
+ lab = rng.integers(1, 6, m)
162
+ y_true.append({"boxes": boxes, "labels": lab, "iscrowd": np.zeros(m, int)})
163
+ jitter = boxes + rng.normal(0, 4, boxes.shape)
164
+ jitter[:, 2:] = np.maximum(jitter[:, 2:], jitter[:, :2] + 1)
165
+ extra = rng.uniform(0, 500, (3, 2))
166
+ fp = np.column_stack([extra, extra + rng.uniform(10, 80, (3, 2))])
167
+ y_pred.append(
168
+ {
169
+ "boxes": np.vstack([jitter, fp]),
170
+ "labels": np.r_[lab, rng.integers(1, 6, 3)],
171
+ "scores": rng.random(m + 3),
172
+ }
173
+ )
174
+
175
+ def es_map() -> list[float]:
176
+ r = es.detection_report(y_true, y_pred)
177
+ return [r["map"], r["map_50"], r["map_75"], r["mar_100"]]
178
+
179
+ cases: list[Case] = [
180
+ ("segmentation: Dice and IoU per class (n = pixels)", "scikit-learn", es_overlap, None),
181
+ (f"segmentation: Hausdorff distance ({n_hd} image{'s' if n_hd != 1 else ''})", "SciPy", es_hd, None),
182
+ (f"detection: COCO evaluation ({n_det} images)", "pycocotools", es_map, None),
183
+ ]
184
+ refs: dict[str, Callable[[], Any]] = {}
185
+ try:
186
+ import sklearn.metrics as skm
187
+
188
+ def sk_overlap() -> list[float]:
189
+ ft, fp_ = true.ravel(), pred.ravel()
190
+ return [
191
+ *skm.f1_score(ft, fp_, labels=labels, average=None),
192
+ *skm.jaccard_score(ft, fp_, labels=labels, average=None),
193
+ ]
194
+
195
+ refs[cases[0][0]] = sk_overlap
196
+ except ImportError:
197
+ pass
198
+ from scipy import ndimage
199
+ from scipy.spatial.distance import directed_hausdorff
200
+
201
+ def scipy_hd() -> list[float]:
202
+ out = []
203
+ for i in range(n_hd):
204
+ pts = []
205
+ for m_ in (true[i] == k - 1, pred[i] == k - 1):
206
+ er = ndimage.binary_erosion(m_, structure=ndimage.generate_binary_structure(2, 1), border_value=0)
207
+ pts.append(np.argwhere(m_ & ~er).astype(float))
208
+ out.append(max(directed_hausdorff(pts[0], pts[1])[0], directed_hausdorff(pts[1], pts[0])[0]))
209
+ return out
210
+
211
+ refs[cases[1][0]] = scipy_hd
212
+ try:
213
+ import contextlib as _ctx
214
+ import io
215
+
216
+ from pycocotools.coco import COCO # type: ignore[import-untyped]
217
+ from pycocotools.cocoeval import COCOeval # type: ignore[import-untyped]
218
+
219
+ def coco_map() -> list[float]:
220
+ images, anns, dets, aid = [], [], [], 1
221
+ for i, (t, p) in enumerate(zip(y_true, y_pred)):
222
+ images.append({"id": i + 1})
223
+ for b, c in zip(t["boxes"], t["labels"]):
224
+ w, h = b[2] - b[0], b[3] - b[1]
225
+ anns.append(
226
+ {
227
+ "id": aid,
228
+ "image_id": i + 1,
229
+ "category_id": int(c),
230
+ "bbox": [b[0], b[1], w, h],
231
+ "area": w * h,
232
+ "iscrowd": 0,
233
+ }
234
+ )
235
+ aid += 1
236
+ for b, c, sc in zip(p["boxes"], p["labels"], p["scores"]):
237
+ dets.append(
238
+ {
239
+ "image_id": i + 1,
240
+ "category_id": int(c),
241
+ "bbox": [b[0], b[1], b[2] - b[0], b[3] - b[1]],
242
+ "score": float(sc),
243
+ }
244
+ )
245
+ with _ctx.redirect_stdout(io.StringIO()):
246
+ gt = COCO()
247
+ gt.dataset = {
248
+ "images": images,
249
+ "annotations": anns,
250
+ "categories": [{"id": c} for c in range(1, 6)],
251
+ }
252
+ gt.createIndex()
253
+ ev = COCOeval(gt, gt.loadRes(dets), "bbox")
254
+ ev.evaluate()
255
+ ev.accumulate()
256
+ ev.summarize()
257
+ return [ev.stats[0], ev.stats[1], ev.stats[2], ev.stats[8]]
258
+
259
+ refs[cases[2][0]] = coco_map
260
+ except ImportError:
261
+ pass
262
+ return [(name, ref, es_fn, refs.get(name)) for name, ref, es_fn, _ in cases]
263
+
264
+
123
265
  def _clinical_cases(n: int, rng: np.random.Generator) -> list[Case]:
124
266
  """v0.2.0: diagnostic accuracy, calibration, decision curves and statistical tests."""
125
267
  import evalsuite as es
@@ -231,6 +373,28 @@ def _clinical_cases(n: int, rng: np.random.Generator) -> list[Case]:
231
373
  return [(name, ref, es_fn, own if own is not None else refs.get(name)) for name, ref, es_fn, own in cases]
232
374
 
233
375
 
376
+ _GROUPS = (
377
+ ("Classification and regression", ("binary", "10 classes", "regression")),
378
+ ("Clinical, calibration and statistics", ("clinical", "calibration", "decision", "statistics", "multiple")),
379
+ ("Segmentation and object detection", ("segmentation", "detection")),
380
+ )
381
+ MATCH_TOLERANCE = 1e-9
382
+
383
+
384
+ def benchmark_group(case: str) -> str:
385
+ """The suite a benchmark case belongs to, by its name."""
386
+ lowered = case.lower()
387
+ for title, prefixes in _GROUPS:
388
+ if lowered.startswith(prefixes):
389
+ return title
390
+ return "Other"
391
+
392
+
393
+ def _geomean(values: Sequence[float]) -> Optional[float]:
394
+ vals = [v for v in values if v is not None and v > 0]
395
+ return float(np.exp(np.mean(np.log(vals)))) if vals else None
396
+
397
+
234
398
  @dataclass(frozen=True, eq=False)
235
399
  class BenchmarkResult:
236
400
  """Rows of timings (milliseconds), peak memory (MiB) and agreement, plus the environment they ran in."""
@@ -242,6 +406,60 @@ class BenchmarkResult:
242
406
  object.__setattr__(self, "rows", tuple(MappingProxyType(dict(r)) for r in self.rows))
243
407
  object.__setattr__(self, "environment", MappingProxyType(dict(self.environment)))
244
408
 
409
+ def sorted_rows(self) -> list[Any]:
410
+ """Rows in alphabetical order of case, then by size."""
411
+ return sorted(self.rows, key=lambda r: (r["case"].lower(), r["n"]))
412
+
413
+ def overall(self) -> list[dict[str, Any]]:
414
+ """One summary row per suite and one for everything: cases, how many agree with the reference
415
+ (max |difference| <= 1e-9), and the geometric-mean, smallest and largest speed-up."""
416
+
417
+ def summarise(name: str, rows: Sequence[Any]) -> dict[str, Any]:
418
+ compared = [r for r in rows if r["speedup"] is not None]
419
+ speedups = [r["speedup"] for r in compared]
420
+ return {
421
+ "group": name,
422
+ "cases": len({re.sub(r"\s*\(\d+ images?\)", "", r["case"]) for r in rows}),
423
+ "rows": len(rows),
424
+ "compared": len(compared),
425
+ "matching": sum(
426
+ r["max_abs_diff"] is not None and r["max_abs_diff"] <= MATCH_TOLERANCE for r in rows
427
+ ),
428
+ "faster": sum(sp > 1 for sp in speedups),
429
+ "geomean_speedup": _geomean(speedups),
430
+ "min_speedup": min(speedups) if speedups else None,
431
+ "max_speedup": max(speedups) if speedups else None,
432
+ }
433
+
434
+ groups = sorted({benchmark_group(r["case"]) for r in self.rows})
435
+ out = [summarise(g, [r for r in self.rows if benchmark_group(r["case"]) == g]) for g in groups]
436
+ out.append(summarise("Overall", self.rows))
437
+ return out
438
+
439
+ _OVERALL_TITLES = ("Suite", "Cases", "Rows", "Match reference", "Faster", "Geo-mean speed-up", "Range")
440
+
441
+ def _overall_cells(self) -> list[list[str]]:
442
+ def x(v: Optional[float]) -> str:
443
+ return "–" if v is None else f"{v:.2f}×"
444
+
445
+ return [
446
+ [
447
+ o["group"],
448
+ str(o["cases"]),
449
+ str(o["rows"]),
450
+ f"{o['matching']}/{o['compared']}",
451
+ f"{o['faster']}/{o['compared']}",
452
+ x(o["geomean_speedup"]),
453
+ "–" if o["min_speedup"] is None else f"{x(o['min_speedup'])}–{x(o['max_speedup'])}",
454
+ ]
455
+ for o in self.overall()
456
+ ]
457
+
458
+ def overall_markdown(self) -> str:
459
+ """The overall summary as a Markdown table."""
460
+ lines = ["| " + " | ".join(self._OVERALL_TITLES) + " |", "| --- |" + " ---: |" * 6]
461
+ return "\n".join(lines + ["| " + " | ".join(c) + " |" for c in self._overall_cells()])
462
+
245
463
  def _cells(self, digits: int) -> list[list[str]]:
246
464
  def f(v: Any, d: int = digits) -> str:
247
465
  return "–" if v is None else f"{v:.{d}f}"
@@ -258,7 +476,7 @@ class BenchmarkResult:
258
476
  f(r.get("reference_peak_mb"), 2),
259
477
  "–" if r["max_abs_diff"] is None else f"{r['max_abs_diff']:.1e}",
260
478
  ]
261
- for r in self.rows
479
+ for r in self.sorted_rows()
262
480
  ]
263
481
 
264
482
  _TITLES = (
@@ -286,10 +504,18 @@ class BenchmarkResult:
286
504
  + (f" | scikit-learn {env['sklearn']}" if env.get("sklearn") else "")
287
505
  + (f" | statsmodels {env['statsmodels']}" if env.get("statsmodels") else "")
288
506
  + (f" | SciPy {env['scipy']}" if env.get("scipy") else "")
507
+ + (f" | pycocotools {env['pycocotools']}" if env.get("pycocotools") else "")
289
508
  + f" | {env['machine']} | fastest of {env['repeat']} runs"
290
509
  )
291
510
  note = "Speed-up > 1 means EvalSuite is faster. Max |difference| compares EvalSuite with the reference."
292
- return "\n".join([head, "", line(self._TITLES), *(line(c) for c in cells), "", note])
511
+ ocells = self._overall_cells()
512
+ owidths = [max(len(t), *(len(c[i]) for c in ocells)) for i, t in enumerate(self._OVERALL_TITLES)]
513
+
514
+ def oline(c: Sequence[str]) -> str:
515
+ return " ".join(x.ljust(owidths[i]) if i == 0 else x.rjust(owidths[i]) for i, x in enumerate(c))
516
+
517
+ overall = ["Overall", oline(self._OVERALL_TITLES), *(oline(c) for c in ocells), ""]
518
+ return "\n".join([head, "", *overall, line(self._TITLES), *(line(c) for c in cells), "", note])
293
519
 
294
520
  def __repr__(self) -> str:
295
521
  return self.summary()
@@ -297,7 +523,13 @@ class BenchmarkResult:
297
523
  def to_dict(self) -> dict[str, Any]:
298
524
  return cast(
299
525
  "dict[str, Any]",
300
- _json_safe({"environment": dict(self.environment), "rows": [dict(r) for r in self.rows]}),
526
+ _json_safe(
527
+ {
528
+ "environment": dict(self.environment),
529
+ "overall": self.overall(),
530
+ "rows": [dict(r) for r in self.rows],
531
+ }
532
+ ),
301
533
  )
302
534
 
303
535
  def to_json(self, *, indent: Optional[int] = 2) -> str:
@@ -354,7 +586,8 @@ def run_benchmarks(
354
586
  """Time and memory for evaluation workloads at each size, against a reference implementation.
355
587
 
356
588
  ``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).
589
+ calibration and statistics vs scikit-learn, statsmodels, SciPy), ``"vision"`` (segmentation and COCO
590
+ detection vs scikit-learn, SciPy, pycocotools) or ``"all"`` (default).
358
591
  ``compare_sklearn=False`` times EvalSuite alone. Rows keep ``sklearn_ms``/``sklearn_peak_mb`` for rows
359
592
  whose reference is scikit-learn, for compatibility with 0.1.x.
360
593
  """
@@ -364,16 +597,24 @@ def run_benchmarks(
364
597
 
365
598
  if repeat < 1:
366
599
  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'.")
600
+ if suite not in ("all", "core", "clinical", "vision"):
601
+ raise ValueError("suite must be 'all', 'core', 'clinical' or 'vision'.")
369
602
  rng = np.random.default_rng(random_state)
370
603
  rows: list[dict[str, Any]] = []
371
- versions: dict[str, Optional[str]] = {"sklearn": None, "statsmodels": None}
604
+ versions: dict[str, Optional[str]] = {"sklearn": None, "statsmodels": None, "pycocotools": None}
372
605
  if compare_sklearn:
373
606
  for mod in versions:
374
607
  with contextlib.suppress(ImportError):
375
- versions[mod] = __import__(mod).__version__
376
- builders = {"core": [_core_cases], "clinical": [_clinical_cases], "all": [_core_cases, _clinical_cases]}[suite]
608
+ importlib.import_module(mod) # fixed names only: sklearn, statsmodels, pycocotools
609
+ from importlib.metadata import version as _dist_version
610
+
611
+ versions[mod] = _dist_version("scikit-learn" if mod == "sklearn" else mod)
612
+ builders = {
613
+ "core": [_core_cases],
614
+ "clinical": [_clinical_cases],
615
+ "vision": [_vision_cases],
616
+ "all": [_core_cases, _clinical_cases, _vision_cases],
617
+ }[suite]
377
618
  for n in sizes:
378
619
  for build in builders:
379
620
  for name, ref_name, es_fn, ref_fn in build(int(n), rng):
@@ -410,6 +651,7 @@ def run_benchmarks(
410
651
  "scipy": scipy.__version__ if suite != "core" else None,
411
652
  "sklearn": versions["sklearn"],
412
653
  "statsmodels": versions["statsmodels"] if suite != "core" else None,
654
+ "pycocotools": versions["pycocotools"] if suite in ("all", "vision") else None,
413
655
  "machine": f"{platform.system()} {platform.machine()}",
414
656
  "repeat": repeat,
415
657
  "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)
@@ -45,9 +45,25 @@ def to_numpy(x: ArrayLike, name: str, *, allow_2d: bool = False) -> NDArray[Any]
45
45
  raise InputValidationError(f"{name} must be {expected}; received an array with shape {arr.shape}.")
46
46
  if arr.shape[0] == 0:
47
47
  raise InputValidationError(f"{name} is empty. Provide at least one observation.")
48
+ if (
49
+ arr.ndim == 1
50
+ and arr.dtype.kind in "US"
51
+ and isinstance(x, (list, tuple))
52
+ and not all(isinstance(v, (str, bytes)) for v in x)
53
+ ):
54
+ # NumPy silently turns [0, "a"] into ["0", "a"]; a mix of numbers and strings is almost always a bug.
55
+ raise InputValidationError(f"{name} mixes numbers and strings; use one label type throughout.")
48
56
  return arr
49
57
 
50
58
 
59
+ def _label_kind(arr: NDArray[Any]) -> str:
60
+ if arr.dtype.kind in "US":
61
+ return "string"
62
+ if arr.dtype.kind in "biuf":
63
+ return "number"
64
+ return "other"
65
+
66
+
51
67
  def check_finite(arr: NDArray[Any], name: str) -> None:
52
68
  if np.issubdtype(arr.dtype, np.number) and not np.all(np.isfinite(arr)):
53
69
  n_nan = int(np.isnan(arr).sum())
@@ -126,6 +142,11 @@ def resolve_labels(
126
142
  if np.unique(lab).shape[0] != lab.shape[0]:
127
143
  raise InputValidationError("labels contains duplicates.")
128
144
  return lab
145
+ if y_pred is not None and {_label_kind(y_true), _label_kind(y_pred)} == {"string", "number"}:
146
+ raise InputValidationError(
147
+ f"y_true has {_label_kind(y_true)} labels but y_pred has {_label_kind(y_pred)} labels; "
148
+ "use the same label type in both (for example map class names to integers first)."
149
+ )
129
150
  present = y_true if y_pred is None else np.concatenate([y_true, y_pred])
130
151
  try:
131
152
  return unique_labels(present)