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 +42 -1
- evalsuite/api.py +10 -0
- evalsuite/benchmarks.py +158 -7
- evalsuite/calibration.py +1 -1
- evalsuite/cli/main.py +99 -2
- evalsuite/plot.py +165 -1
- evalsuite/stats/_resolve.py +26 -3
- evalsuite/stats/compare.py +6 -4
- evalsuite/version.py +1 -1
- evalsuite/vision/__init__.py +47 -0
- evalsuite/vision/detection.py +648 -0
- evalsuite/vision/segmentation.py +965 -0
- {evalsuite_python-0.2.1.dist-info → evalsuite_python-0.3.0.dist-info}/METADATA +62 -5
- {evalsuite_python-0.2.1.dist-info → evalsuite_python-0.3.0.dist-info}/RECORD +17 -14
- {evalsuite_python-0.2.1.dist-info → evalsuite_python-0.3.0.dist-info}/WHEEL +0 -0
- {evalsuite_python-0.2.1.dist-info → evalsuite_python-0.3.0.dist-info}/entry_points.txt +0 -0
- {evalsuite_python-0.2.1.dist-info → evalsuite_python-0.3.0.dist-info}/licenses/LICENSE +0 -0
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"``)
|
|
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)
|
|
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 '
|
|
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
|
-
|
|
376
|
-
|
|
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:
|
|
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__ = [
|
|
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
|
evalsuite/stats/_resolve.py
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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 "
|
evalsuite/stats/compare.py
CHANGED
|
@@ -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
|
|
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 =
|
|
298
|
+
yt = as_observations(y_true, "y_true")
|
|
300
299
|
categorical = is_categorical(yt)
|
|
301
300
|
if metrics is None:
|
|
302
|
-
if
|
|
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"]
|