This repository has been archived by the owner on Mar 17, 2021. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 404
/
segmentation_evaluations.py
executable file
·471 lines (383 loc) · 18.3 KB
/
segmentation_evaluations.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
# -*- coding: utf-8 -*-
"""
This module defines built-in evaluation functions for segmentation applications
Segmentations can be evaluated at several scales:
'foreground' refering to metrics computed once for a foreground label
'label' refering to metrics computed once for each label (including background)
'cc' referring to metrics computed once for each connected component set
Connected components are defined by one-or-more connected
components on the reference segmentation and one-or-more connected
components on the infered segmentation.
These sets are defined by a cc_func. Currently
this is hard coded to be union_of_seg_for_each_ref_cc which takes each
connected component on the reference segmentation and all connected
components on the infered segmentation with any overlap. This will
eventually be made into a factory option for different cc set definitions
Overlap and distance measures can be computed at each of these levels by
deriving from PerComponentEvaluation, which handles the logic of identifying
which comparisons need to be done for each scale.
Overlap and distance measures are computed in two convenience functions
(compute_many_overlap_metrics and compute_many_distance_metrics) and wrapped
by Evaluation classes
"""
from __future__ import absolute_import, division, print_function
import numpy as np
import pandas as pd
from scipy import ndimage
from niftynet.evaluation.base_evaluations import CachedSubanalysisEvaluation
from niftynet.utilities.util_common import MorphologyOps, \
CachedFunction, CachedFunctionByID
from niftynet.evaluation.base_evaluator import ScalarAggregator
class PerComponentEvaluation(CachedSubanalysisEvaluation):
"""
This class represents evaluations performed on binary segmentation
components computed per label or per connected component. It encodes the
generation of evaluation tasks. Derived classes should define the
metric_name constant and the function metric_from_binarized()
"""
def subanalyses(self, subject_id, data):
analyses = self.app_param.evaluation_units.split(',')
tasks = []
for analysis in analyses:
if analysis in ['foreground', 'label']:
labels = list(range(self.app_param.num_classes))
if analysis == 'foreground':
labels.remove(0)
for label in labels:
tasks.append({'subject_id': subject_id, 'label': label})
elif analysis in ['cc']:
cc_seg, cc_ref = \
connected_components(data['inferred'], data['label'],
self.app_param.output_prob)
cc_func = union_of_seg_for_each_ref_cc # TODO make into factory
conncomps = cc_func(cc_seg, cc_ref)
for conncomp in conncomps:
tasks.append({'subject_id': subject_id,
'cc_labels': conncomps[conncomp]})
# TODO save an index image from blobs_ref[0]
return tasks
def layer_op(self, subject_id, data, task):
# We use a cached binarizer function so that the binarized
# segmentation have the same python id
if 'label' in task:
binarizer = cached_label_binarizer(task['label'],
self.app_param.output_prob)
seg, ref = binarizer(data)
metric_dict = {'subject_id': subject_id, 'label': task['label']}
metric_dict.update(self.metric_dict_from_binarized(seg, ref))
pdf = pd.DataFrame.from_records([metric_dict], ('subject_id', 'label'))
return [pdf]
elif 'cc_labels' in task:
binarizer = cached_cc_binarizer(task['cc_labels'],
self.app_param.output_prob)
seg, ref = binarizer(data)
r_str = '&'.join([str(l) for l in task['cc_labels'][1]])
s_str = '&'.join([str(l) for l in task['cc_labels'][0]])
cc_id = 'r%s_s%s' % (r_str, s_str)
metric_dict = {'subject_id': subject_id, 'cc_id': cc_id}
metric_dict.update(self.metric_dict_from_binarized(seg, ref))
pdf = pd.DataFrame.from_records([metric_dict], ('subject_id', 'cc_id'))
return [pdf]
return []
def metric_dict_from_binarized(self, seg, ref):
"""
Computes a metric from a binarized mask
:param seg: numpy array with binary mask from inferred segmentation
:param ref: numpy array with binary mask from reference segmentation
:return: a dictionary of metric_name:metric_value
"""
raise NotImplementedError('Not implemented in abstract base class')
class PerComponentScalarEvaluation(PerComponentEvaluation):
""" This class simplifies the implementation when the metric just returns a
single scalar with the same name as the class name"""
def __init__(self, *args, **kwargs):
super(PerComponentScalarEvaluation, self).__init__(*args,
**kwargs)
self.metric_name = self.__class__.__name__
def metric_dict_from_binarized(self, seg, ref):
""" Wrap computed metric in dictionary for parent class """
metric_value = self.metric_from_binarized(seg, ref)
return {self.metric_name: metric_value}
def metric_from_binarized(self, seg, ref):
"""
Computer scalar metric value
:param seg: numpy array with binary mask from inferred segmentation
:param ref: numpy array with binary mask from reference segmentation
:return: scalar metric value
"""
def get_aggregations(self):
aggregations = []
analyses = self.app_param.evaluation_units.split(',')
for analysis in analyses:
if analysis in ['foreground', 'label']:
mean_agg = ScalarAggregator(self.metric_name,
('subject_id', 'label'),
('label',), np.mean,
'mean_' + self.metric_name)
std_agg = ScalarAggregator(self.metric_name,
('subject_id', 'label'),
('label',), np.std,
'stdev_' + self.metric_name)
aggregations.extend([mean_agg, std_agg])
elif analysis in ['cc']:
pass
return aggregations
class BuiltinOverlapEvaluation(PerComponentScalarEvaluation):
"""
Wrapper class to encode many similar overlap metrics that can be computed
from a confusion matrix
Metrics computed in compute_many_overlap_metrics can be wrapped by
overriding self.metric_name
"""
def metric_from_binarized(self, seg, ref):
"""
Computes a metric from a binarized mask by computing a confusion
matrix and then delegating the metric computation
:param seg: numpy array with binary mask from inferred segmentation
:param ref: numpy array with binary mask from reference segmentation
:return: scalar metric value
"""
lnot = np.logical_not
land = np.logical_and
conf_mat = np.array([[np.sum(land(lnot(seg), lnot(ref))),
np.sum(land(lnot(seg), (ref)))],
[np.sum(land((seg), lnot(ref))),
np.sum(land((seg), (ref)))]])
return self.metric_from_confusion_matrix(conf_mat)
def metric_from_confusion_matrix(self, confusion_matrix):
"""
Compute metrics from a 2x2 confusion matrix
:param confusion_matrix: 2x2 numpy array
:return: scalar metric value
"""
#pylint: disable=missing-docstring,invalid-name
class n_pos_ref(BuiltinOverlapEvaluation):
def metric_from_confusion_matrix(self, M):
return M[0, 1] + M[1, 1]
class n_neg_ref(BuiltinOverlapEvaluation):
def metric_from_confusion_matrix(self, M):
return M[0, 0] + M[1, 0]
class n_pos_seg(BuiltinOverlapEvaluation):
def metric_from_confusion_matrix(self, M):
return M[1, 0] + M[1, 1]
class n_neg_seg(BuiltinOverlapEvaluation):
def metric_from_confusion_matrix(self, M):
return M[0, 0] + M[0, 1]
class fp(BuiltinOverlapEvaluation):
def metric_from_confusion_matrix(self, M):
return M[1, 0]
class fn(BuiltinOverlapEvaluation):
def metric_from_confusion_matrix(self, M):
return M[0, 1]
class tp(BuiltinOverlapEvaluation):
def metric_from_confusion_matrix(self, M):
return M[1, 1]
class tn(BuiltinOverlapEvaluation):
def metric_from_confusion_matrix(self, M):
return M[0, 0]
class n_intersection(BuiltinOverlapEvaluation):
def metric_from_confusion_matrix(self, M):
return M[1, 1]
class n_union(BuiltinOverlapEvaluation):
def metric_from_confusion_matrix(self, M):
return M[0, 1] + M[1, 0] + M[1, 1]
class specificity(BuiltinOverlapEvaluation):
def metric_from_confusion_matrix(self, M):
return M[0, 0] / (M[0, 0] + M[1, 0])
class sensitivity(BuiltinOverlapEvaluation):
def metric_from_confusion_matrix(self, M):
return M[1, 1] / (M[0, 1] + M[1, 1])
class accuracy(BuiltinOverlapEvaluation):
def metric_from_confusion_matrix(self, M):
return (M[1, 1] + M[0, 0]) / sum(sum(M))
class false_positive_rate(BuiltinOverlapEvaluation):
def metric_from_confusion_matrix(self, M):
return M[1, 0] / (M[0, 0] + M[1, 0])
class positive_predictive_values(BuiltinOverlapEvaluation):
def metric_from_confusion_matrix(self, M):
return M[1, 1] / (M[1, 0] + M[1, 1])
class negative_predictive_values(BuiltinOverlapEvaluation):
def metric_from_confusion_matrix(self, M):
return M[0, 0] / (M[0, 0] + M[0, 1])
class dice(BuiltinOverlapEvaluation):
def metric_from_confusion_matrix(self, M):
return 2 * M[1, 1] / (M[1, 1] * 2 + M[1, 0] + M[0, 1])
Dice = dice
class jaccard(BuiltinOverlapEvaluation):
def metric_from_confusion_matrix(self, M):
return M[1, 1] / (M[0, 1] + M[1, 0] + M[1, 1])
intersection_over_union = jaccard
Jaccard = jaccard
class informedness(BuiltinOverlapEvaluation):
def metric_from_confusion_matrix(self, M):
return M[1, 1] / (M[0, 1] + M[1, 1]) + \
M[0, 0] / (M[0, 0] + M[1, 0]) - 1
class markedness(BuiltinOverlapEvaluation):
def metric_from_confusion_matrix(self, M):
return M[1, 1] / (M[1, 0] + M[1, 1]) + \
M[0, 0] / (M[0, 0] + M[0, 1]) - 1
class vol_diff(BuiltinOverlapEvaluation):
def metric_from_confusion_matrix(self, M):
return (M[1, 1] + M[1, 0]) / (M[0, 1] + M[1, 1])
# Distance metrics as e.g. in 10.3978/j.issn.2223-4292.2015.08.02
class average_distance(PerComponentScalarEvaluation):
def metric_from_binarized(self, seg, ref):
ref_border_dist, seg_border_dist = border_distance(seg, ref, 8)
border_ref, border_seg = borders(seg, ref, 8)
return (np.sum(ref_border_dist) + np.sum(
seg_border_dist)) / (np.sum(border_ref + border_seg))
class hausdorff_distance(PerComponentScalarEvaluation):
def metric_from_binarized(self, seg, ref):
ref_border_dist, seg_border_dist = border_distance(seg, ref, 8)
return np.max([np.max(ref_border_dist), np.max(seg_border_dist)])
class hausdorff95_distance(PerComponentScalarEvaluation):
def metric_from_binarized(self, seg, ref):
ref_border_dist, seg_border_dist = border_distance(seg, ref, 8)
border_ref, border_seg = borders(seg, ref, 8)
seg_values = ref_border_dist[border_seg > 0]
ref_values = seg_border_dist[border_ref > 0]
if seg_values.size == 0 or ref_values.size == 0:
return np.nan
return np.max([np.percentile(seg_values, 95),
np.percentile(ref_values, 95)])
#pylint: enable=missing-docstring,invalid-name
# Helper functions
@CachedFunction
def cached_label_binarizer(label, output_prob):
"""
This class returns a function for binarizing an inferred segmentation
for a specified label.
This function is carefully designed to allow caching of unhashable numpy
objects. Specifically, each call to cached_label_binarizer with the same
(by-value) parameters will return the same (by python id) function
object. This enables two calls to
cached_label_binarizer(...)(numpy_object_1)
with the same parameters from different metrics to use the cached result
:param label: Which label to make foreground in the binary mask
:param output_prob: Is the segmentation probabilistic (if so,
argmax is used first to compute a label map)
:return: a function for computing a binary label map
"""
@CachedFunctionByID
def binarizer(data):
"""
This function binarizes a segmentation based on a specified
label (defined by outer function)
:param data: a data dictionary as built by ImageReader
:return: a numpy array representing a binary label map
"""
if output_prob:
out = np.argmax(data['inferred'], -1)
else:
out = data['inferred']
return out == label, data['label']
return binarizer
@CachedFunction
def cached_cc_binarizer(cc_labels, output_prob):
"""
This class returns a function for binarizing inferred and reference
segmentations for a specified connected component set.
This function is carefully designed to allow caching of unhashable numpy
objects. Specifically, each call to cached_label_binarizer with the same
(by-value) parameters will return the same (by python id) function
object. This enables two calls to
cached_cc_binarizer(...)(numpy_object_1)
with the same parameters from different metrics to use the cached result
:param cc_labels: [seg_label_list, ref_label_list] where each is a
list of values to be considered foreground for this cc set
:param output_prob: Is the segmentation probabilistic (if so,
argmax is used first to compute a label map)
:return: a function for computing a binary label map pair
"""
@CachedFunctionByID
def binarizer(data):
"""
This function binarizes a multi-object segmentation and reference
into a specified connected component set (defined by outer function)
:param data: a data dictionary as built by ImageReader
:return: two numpy arrays representing binary masks (from
inferred and reference segmentations) for a connected component set
"""
cc_func = connected_components
cc_seg, cc_ref = cc_func(data['inferred'], data['label'], output_prob)
cc_seg_in = np.zeros_like(cc_seg[0])
cc_ref_in = np.zeros_like(cc_ref[0])
for i in cc_labels[0]:
cc_seg_in[cc_seg[0] == i] = 1
for i in cc_labels[1]:
cc_ref_in[cc_ref[0] == i] = 1
return cc_seg_in, cc_ref_in
return binarizer
def union_of_seg_for_each_ref_cc(blobs_seg, blobs_ref):
"""
Constructs connected component sets to compute metrics for. Each
reference connected component is paired with the union of inferred
segmentation connected components with any overlap
:param blobs_seg: tuple (numbered_cc_array, number_of_ccs)
:param blobs_ref: tuple (numbered_cc_array, number_of_ccs)
:return: dictionary {label:(ref_label_list, seg_label_list)}
"""
keys = {}
for cc_id in range(1, blobs_ref[1] + 1):
seg_idx = list(np.unique(blobs_seg[0][blobs_ref[0] == cc_id]))
if 0 in seg_idx:
seg_idx.remove(0)
key = 'r' + str(cc_id) + '_c' + '_'.join([str(s) for s in seg_idx])
keys[key] = ((cc_id,), tuple(seg_idx))
return keys
@CachedFunctionByID
def borders(seg, ref, neigh=8):
"""
This function determines the points that lie on the border of the
inferred and reference segmentations
:param seg: numpy array with binary mask from inferred segmentation
:param ref: numpy array with binary mask from reference segmentation
:param neigh: connectivity 4 or 8
:return: numpy arrays of reference and inferred segmentation borders
"""
border_ref = MorphologyOps(ref[:, :, :, 0, 0], neigh).border_map()
border_seg = MorphologyOps(seg[:, :, :, 0, 0], neigh).border_map()
return border_ref, border_seg
@CachedFunctionByID
def border_distance(seg, ref, neigh=8):
"""
This functions determines the distance at each seg border point to the
nearest ref border point and vice versa
:param seg: numpy array with binary mask from inferred segmentation
:param ref: numpy array with binary mask from reference segmentation
:param neigh: connectivity 4 or 8
:return: numpy arrays for distance_from_ref_border, distance_from
seg_border
"""
border_ref, border_seg = borders(seg, ref, neigh)
distance_ref = ndimage.distance_transform_edt(1 - border_ref)
distance_seg = ndimage.distance_transform_edt(1 - border_seg)
distance_border_seg = border_ref * distance_seg
distance_border_ref = border_seg * distance_ref
return distance_border_ref, distance_border_seg
@CachedFunctionByID
def connected_components(seg, ref, output_prob, neigh=8):
"""
Numbers connected components in the reference and inferred segmentations
:param seg: numpy array with binary mask from inferred segmentation
:param ref: numpy array with binary mask from reference segmentation
:param output_prob: Is the segmentation probabilistic (if so,
argmax is used first to compute a label map)
:param neigh: connectivity 4 or 8
:return: (cc_map_ref, count) numbered connected components from reference
:return: (cc_map_seg, count) numbered connected components from inferred
"""
if output_prob:
seg = np.argmax(seg, -1)
blobs_ref = MorphologyOps(ref[:, :, :, 0, 0], neigh).foreground_component()
blobs_seg = MorphologyOps(seg[:, :, :, 0, 0], neigh).foreground_component()
return (blobs_ref[0][:, :, :, np.newaxis, np.newaxis], blobs_ref[1]), \
(blobs_seg[0][:, :, :, np.newaxis, np.newaxis], blobs_seg[1]),
# TODO
# per subject connected component related metrics
# 'connected_elements': (self.connected_elements, 'TPc,FPc,FNc'),
# 'outline_error': (self.outline_error, 'OER,OEFP,OEFN'),
# 'detection_error': (self.detection_error, 'DE,DEFP,DEFN'),
# list_labels
# list connected components
# TODO image_map outputs