File size: 12,248 Bytes
de9997b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
import torch


def nms(
    p_bars: torch.Tensor,
    p_ticks: torch.Tensor,
    radius_thresh: float
) -> tuple[torch.Tensor, torch.Tensor]:
    """
    Performs non-maximum suppression on predicted bars and ticks.

    :param p_bars: predicted bars [y, x, conf], shape [N, 3]
    :param p_ticks: predicted ticks [y, x, conf], shape [M, 3]
    :param radius_thresh: threshold distance for considering points as neighbors
    :return: tuple containing:

        - nms filtered bars [y, x, conf], shape [N, 3]
        - nms filtered ticks [y, x, conf], shape [M, 3]
    """
    bar_out = p_bars.new_empty((0, 3))
    tick_out = p_ticks.new_empty((0, 3))

    # Sort bars by confidence
    if p_bars.size(0) > 0:
        sorted_bars = p_bars[torch.argsort(p_bars[:, 2], descending=True)]
        checked_bars = torch.zeros(sorted_bars.size(0), dtype=torch.bool, device=sorted_bars.device)
        keep_bars = torch.zeros_like(checked_bars)

        # Iteratively remove low confidence bars within radius
        for k in range(sorted_bars.size(0)):
            if checked_bars[k]:
                continue
            pt = sorted_bars[k]
            # Keep current bar, neighbors within radius are removed
            distances = torch.abs(sorted_bars[:, 0] - pt[0]) + torch.abs(sorted_bars[:, 1] - pt[1])
            checked_bars |= distances.lt(radius_thresh)
            keep_bars[k] = True

        bar_out = sorted_bars[keep_bars]

    # Sort ticks by confidence
    if p_ticks.size(0) > 0:
        sorted_ticks = p_ticks[torch.argsort(p_ticks[:, 2], descending=True)]
        checked_ticks = torch.zeros(sorted_ticks.size(0), dtype=torch.bool, device=sorted_ticks.device)
        keep_ticks = torch.zeros_like(checked_ticks)

        # Iteratively remove low confidence ticks within radius
        for k in range(sorted_ticks.size(0)):
            if checked_ticks[k]:
                continue
            pt = sorted_ticks[k]
            # Keep current tick, neighbors within radius are removed
            distances = torch.abs(sorted_ticks[:, 0] - pt[0]) + torch.abs(sorted_ticks[:, 1] - pt[1])
            checked_ticks |= distances.lt(radius_thresh)
            keep_ticks[k] = True

        tick_out = sorted_ticks[keep_ticks]

    return bar_out, tick_out


def hungarian_match(
    gt_points: torch.Tensor,
    p_points: torch.Tensor,
    dist_thresh: float
) -> tuple[torch.Tensor, torch.Tensor]:
    """
    Matches GT and predictions with Hungarian matching under a distance threshold.

    :param gt_points: ground truth points [N, 2]
    :param p_points: predicted points [M, 2]
    :param dist_thresh: max distance for a valid match
    :return: tuple containing:

        - matched GT indices [K]
        - matched prediction indices [K]
    """
    device = gt_points.device
    empty = torch.empty((0,), dtype=torch.long, device=device)

    # Nothing to match if either side is empty
    if gt_points.size(0) == 0 or p_points.size(0) == 0:
        return empty, empty

    # Pairwise distances between gt and predicted coords
    diffs = gt_points.unsqueeze(1) - p_points.unsqueeze(0)
    distances = torch.sqrt((diffs * diffs).sum(dim=2))

    # Distances above threshold are not valid candidate matches
    invalid = distances.gt(dist_thresh)
    if bool(invalid.all()):
        return empty, empty

    # Coordinates in [0, 1], so max distance is sqrt(2)
    large_cost = (2.0 ** 0.5) + 1e-6
    cost = distances.masked_fill(invalid, large_cost)

    # Global one-to-one assignment minimizing total matching cost
    from scipy.optimize import linear_sum_assignment
    row_idx, col_idx = linear_sum_assignment(cost.detach().cpu().numpy())

    row_idx_t = torch.as_tensor(row_idx, device=device, dtype=torch.long)
    col_idx_t = torch.as_tensor(col_idx, device=device, dtype=torch.long)

    # Keep only assignments that truly satisfy the threshold
    valid_matches = distances[row_idx_t, col_idx_t].le(dist_thresh)
    return row_idx_t[valid_matches], col_idx_t[valid_matches]


def evaluate_gt_p_match(
    gt_bars: torch.Tensor,
    gt_ticks: torch.Tensor,
    p_bars: torch.Tensor,
    p_ticks: torch.Tensor,
    dist_thresh: float
) -> tuple[float, float, float, float, float, float]:
    """
    Evaluate predicted bars and ticks against ground truth.

    :param gt_bars: ground truth bars [y, x, value], shape [N, 3]
    :param gt_ticks: ground truth ticks [y, x, value], shape [M, 3]
    :param p_bars: predicted bars [y, x, conf], shape [N, 3]
    :param p_ticks: predicted ticks [y, x, conf], shape [M, 3]
    :param dist_thresh: max. distance threshold when considering matches
    :return: Tuple containing:

        - bar precision, recall, f1
        - tick precision, recall, f1
    """
    bar_rows, _ = hungarian_match(gt_bars[:, :2], p_bars[:, :2], dist_thresh)
    tick_rows, _ = hungarian_match(gt_ticks[:, :2], p_ticks[:, :2], dist_thresh)
    bar_matches = int(bar_rows.numel())
    tick_matches = int(tick_rows.numel())

    # Calculate precision and recall for bars
    bar_precision = (bar_matches / p_bars.size(0)) if p_bars.size(0) > 0 else 0.
    bar_recall = (bar_matches / gt_bars.size(0)) if gt_bars.size(0) > 0 else 0.
    bar_f1 = f1(bar_precision, bar_recall)

    # Calculate precision and recall for ticks
    tick_precision = (tick_matches / p_ticks.size(0)) if p_ticks.size(0) > 0 else 0.
    tick_recall = (tick_matches / gt_ticks.size(0)) if gt_ticks.size(0) > 0 else 0.
    tick_f1 = f1(tick_precision, tick_recall)

    return bar_precision, bar_recall, bar_f1, tick_precision, tick_recall, tick_f1


def evaluate_value_accuracy(
    gt_bar_yxv: torch.Tensor,
    p_bar_yxv: torch.Tensor,
    dist_thresh: float,
    hard_eps: float = 0.02,
    relaxed_eps: float = 0.05
) -> tuple[float, float]:
    """
    Evaluate bar-value accuracy with padded-count denominator.

    :param gt_bar_yxv: ground truth bars [N, 3] as [y, x, value]
    :param p_bar_yxv: predicted bars [M, 3] as [y, x, value]
    :param dist_thresh: max. spatial distance for matching bars
    :param hard_eps: strict relative error threshold
    :param relaxed_eps: relaxed relative error threshold
    :return: tuple containing:

        - hard-threshold accuracy
        - relaxed-threshold accuracy
    """
    gt_bars = gt_bar_yxv[:, :2]
    gt_values = gt_bar_yxv[:, 2]
    p_bars = p_bar_yxv[:, :2]
    p_values = p_bar_yxv[:, 2]

    # Ignore invalid gt values
    valid_gt_mask = torch.isfinite(gt_values)
    if not bool(valid_gt_mask.any()):
        return 0.0, 0.0

    p_count = int(min(p_bars.size(0), p_values.numel()))
    total = max(int(gt_bars[valid_gt_mask].size(0)), p_count)
    if total == 0 or p_count == 0:
        return 0.0, 0.0

    # Match predicted and gt bars using Hungarian assignment
    gt_idx, pred_idx = hungarian_match(
        gt_bars[valid_gt_mask],
        p_bars[:p_count],
        dist_thresh
    )
    if gt_idx.numel() == 0:
        return 0.0, 0.0

    # Criterion = abs(h_g - h_p) / h_g <= eps.
    gt_matched = gt_values[valid_gt_mask][gt_idx]
    p_matched = p_values[:p_count][pred_idx]
    denom = gt_matched.abs().clamp(min=1e-8)
    rel_err = (gt_matched - p_matched).abs() / denom

    # Defaults: hard = 0.02, relaxed = 0.05
    hard_correct = int(rel_err.le(hard_eps).sum().item())
    relaxed_correct = int(rel_err.le(relaxed_eps).sum().item())
    hard_acc = float(hard_correct / total)
    relaxed_acc = float(relaxed_correct / total)
    return hard_acc, relaxed_acc


def evaluate_confusion(
    gt_bars: torch.Tensor,
    gt_ticks: torch.Tensor,
    p_bars: torch.Tensor,
    p_ticks: torch.Tensor,
    dist_thresh: float
) -> tuple[int, int, int, int, int, int, int, int]:
    """
    Returns a full confusion matrix for bar/tick keypoint detection.

                     p_bar       p_tick       p_none
        gt_bar       tp_bar      bar_as_tick  fn_bar
        gt_tick      tick_as_bar tp_tick      fn_tick
        gt_none      fp_bar      fp_tick      –

    :param gt_bars: ground truth bars [y, x, value], shape [N, 3]
    :param gt_ticks: ground truth ticks [y, x, value], shape [M, 3]
    :param p_bars: predicted bars [y, x, conf], shape [P, 3]
    :param p_ticks: predicted ticks [y, x, conf], shape [Q, 3]
    :param dist_thresh: max. distance threshold for matching
    :return: tuple containing:

        - tp_bar: GT bars matched with predicted bars
        - bar_as_tick: GT bars matched with predicted ticks (cross-class)
        - fn_bar: GT bars with no match
        - fp_bar: predicted bars with no GT match
        - tp_tick: GT ticks matched with predicted ticks
        - tick_as_bar: GT ticks matched with predicted bars (cross-class)
        - fn_tick: GT ticks with no match
        - fp_tick: predicted ticks with no GT match
    """
    device = gt_bars.device
    n_gt_bars = gt_bars.size(0)
    n_gt_ticks = gt_ticks.size(0)
    n_p_bars = p_bars.size(0)
    n_p_ticks = p_ticks.size(0)

    empty = torch.empty(0, dtype=torch.long, device=device)

    # --- Pass 1: within-class matching ---
    if n_gt_bars > 0 and n_p_bars > 0:
        gt_bar_idx, p_bar_idx = hungarian_match(gt_bars[:, :2], p_bars[:, :2], dist_thresh)
    else:
        gt_bar_idx, p_bar_idx = empty, empty

    if n_gt_ticks > 0 and n_p_ticks > 0:
        gt_tick_idx, p_tick_idx = hungarian_match(gt_ticks[:, :2], p_ticks[:, :2], dist_thresh)
    else:
        gt_tick_idx, p_tick_idx = empty, empty

    tp_bar = int(gt_bar_idx.numel())
    tp_tick = int(gt_tick_idx.numel())

    # Build masks of unmatched points after pass 1
    unmatched_gt_bar = torch.ones(n_gt_bars, dtype=torch.bool, device=device)
    unmatched_p_bar = torch.ones(n_p_bars, dtype=torch.bool, device=device)
    unmatched_gt_tick = torch.ones(n_gt_ticks, dtype=torch.bool, device=device)
    unmatched_p_tick = torch.ones(n_p_ticks, dtype=torch.bool, device=device)

    if gt_bar_idx.numel() > 0:
        unmatched_gt_bar[gt_bar_idx] = False
        unmatched_p_bar[p_bar_idx] = False
    if gt_tick_idx.numel() > 0:
        unmatched_gt_tick[gt_tick_idx] = False
        unmatched_p_tick[p_tick_idx] = False

    # --- Pass 2: cross-class matching on remaining unmatched points ---
    ub_gt_bar = gt_bars[unmatched_gt_bar, :2]
    ub_p_tick = p_ticks[unmatched_p_tick, :2]
    ub_gt_tick = gt_ticks[unmatched_gt_tick, :2]
    ub_p_bar = p_bars[unmatched_p_bar, :2]

    # Unmatched GT bars vs unmatched pred ticks
    if ub_gt_bar.size(0) > 0 and ub_p_tick.size(0) > 0:
        xm_gt_bar, xm_p_tick = hungarian_match(ub_gt_bar, ub_p_tick, dist_thresh)
    else:
        xm_gt_bar, xm_p_tick = empty, empty

    bar_as_tick = int(xm_gt_bar.numel())

    # Unmatched GT ticks vs unmatched pred bars
    if ub_gt_tick.size(0) > 0 and ub_p_bar.size(0) > 0:
        xm_gt_tick, xm_p_bar = hungarian_match(ub_gt_tick, ub_p_bar, dist_thresh)
    else:
        xm_gt_tick, xm_p_bar = empty, empty

    tick_as_bar = int(xm_gt_tick.numel())

    # Update masks based on cross-class matches
    unmatched_gt_bar_idx = unmatched_gt_bar.nonzero(as_tuple=False).squeeze(1)
    unmatched_p_tick_idx = unmatched_p_tick.nonzero(as_tuple=False).squeeze(1)
    unmatched_gt_tick_idx = unmatched_gt_tick.nonzero(as_tuple=False).squeeze(1)
    unmatched_p_bar_idx = unmatched_p_bar.nonzero(as_tuple=False).squeeze(1)

    if xm_gt_bar.numel() > 0:
        unmatched_gt_bar[unmatched_gt_bar_idx[xm_gt_bar]] = False
        unmatched_p_tick[unmatched_p_tick_idx[xm_p_tick]] = False
    if xm_gt_tick.numel() > 0:
        unmatched_gt_tick[unmatched_gt_tick_idx[xm_gt_tick]] = False
        unmatched_p_bar[unmatched_p_bar_idx[xm_p_bar]] = False

    fn_bar = int(unmatched_gt_bar.sum())
    fn_tick = int(unmatched_gt_tick.sum())
    fp_bar = int(unmatched_p_bar.sum())
    fp_tick = int(unmatched_p_tick.sum())

    return tp_bar, bar_as_tick, fn_bar, fp_bar, tp_tick, tick_as_bar, fn_tick, fp_tick


def f1(
    precision: float,
    recall: float
) -> float:
    """
    Computes the F1 score from precision and recall.

    :param precision: precision value
    :param recall: recall value
    :return: F1 score
    """
    denominator = precision + recall
    if denominator <= 0:
        return 0.0

    numerator = 2.0 * precision * recall
    return numerator / denominator