Skip to content

Commit c0ed16f

Browse files
committed
Perf: single-pass remap_instance_id via unique + bincount + LUT gather
Signed-off-by: Soumya Snigdha Kundu <soumya_snigdha.kundu@kcl.ac.uk>
1 parent a3d5160 commit c0ed16f

2 files changed

Lines changed: 98 additions & 14 deletions

File tree

monai/metrics/utils.py

Lines changed: 10 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -419,22 +419,18 @@ def remap_instance_id(pred: torch.Tensor, by_size: bool = False) -> torch.Tensor
419419
by_size: if True, largest instance will be assigned a smaller id.
420420
421421
"""
422-
pred_id: Iterable[Any] = list(pred.unique())
423-
# the original implementation has the limitation that if there is no 0 in pred, error will happen
424-
pred_id = [i for i in pred_id if i != 0]
425-
426-
if not pred_id:
422+
uniq, inverse = torch.unique(pred, return_inverse=True)
423+
order = torch.nonzero(uniq != 0).flatten()
424+
if order.numel() == 0:
427425
return pred
428426
if by_size:
429-
instance_size = [(pred == instance_id).sum() for instance_id in pred_id]
430-
pair_data = zip(pred_id, instance_size)
431-
pair_list = sorted(pair_data, key=lambda x: x[1], reverse=True)
432-
pred_id, _ = zip(*pair_list)
433-
434-
new_pred = torch.zeros_like(pred, dtype=torch.int)
435-
for idx, instance_id in enumerate(pred_id):
436-
new_pred[pred == instance_id] = idx + 1
437-
return new_pred
427+
counts = torch.bincount(inverse.flatten(), minlength=uniq.numel())[order]
428+
# stable sort keeps ascending-id order for equal-size instances, matching the
429+
# original python `sorted` tie-breaking
430+
order = order[torch.argsort(counts, descending=True, stable=True)]
431+
lut = torch.zeros(uniq.numel(), dtype=torch.int, device=pred.device)
432+
lut[order] = torch.arange(1, order.numel() + 1, dtype=torch.int, device=pred.device)
433+
return lut[inverse]
438434

439435

440436
def prepare_spacing(
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
# Copyright (c) MONAI Consortium
2+
# Licensed under the Apache License, Version 2.0 (the "License");
3+
# you may not use this file except in compliance with the License.
4+
# You may obtain a copy of the License at
5+
# http://www.apache.org/licenses/LICENSE-2.0
6+
# Unless required by applicable law or agreed to in writing, software
7+
# distributed under the License is distributed on an "AS IS" BASIS,
8+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
9+
# See the License for the specific language governing permissions and
10+
# limitations under the License.
11+
12+
from __future__ import annotations
13+
14+
import unittest
15+
16+
import torch
17+
from parameterized import parameterized
18+
19+
from monai.metrics.utils import remap_instance_id
20+
21+
_device = "cuda:0" if torch.cuda.is_available() else "cpu"
22+
23+
TEST_CASES = [
24+
# (description, input, by_size, expected)
25+
["non_contiguous_ids", [[0, 2, 2], [5, 5, 0]], False, [[0, 1, 1], [2, 2, 0]]],
26+
["already_contiguous", [[0, 1], [2, 2]], False, [[0, 1], [2, 2]]],
27+
# id 7 covers 3 pixels, id 3 covers 2, id 9 covers 1 -> sizes decide new ids
28+
["by_size_largest_first", [[7, 7, 7, 0], [3, 3, 9, 0]], True, [[1, 1, 1, 0], [2, 2, 3, 0]]],
29+
# equal sizes: ascending original id order wins (stable tie-breaking)
30+
["by_size_ties_stable", [[5, 5, 0], [2, 2, 0]], True, [[2, 2, 0], [1, 1, 0]]],
31+
["no_background", [[4, 4], [6, 6]], False, [[1, 1], [2, 2]]],
32+
["single_instance", [[0, 0], [3, 3]], True, [[0, 0], [1, 1]]],
33+
]
34+
35+
36+
def _reference_remap(pred: torch.Tensor, by_size: bool = False) -> torch.Tensor:
37+
"""The original per-instance-loop implementation, kept as the behavioral reference."""
38+
pred_id = [i for i in pred.unique() if i != 0]
39+
if not pred_id:
40+
return pred
41+
if by_size:
42+
instance_size = [(pred == instance_id).sum() for instance_id in pred_id]
43+
pair_list = sorted(zip(pred_id, instance_size), key=lambda x: x[1], reverse=True)
44+
pred_id = [p[0] for p in pair_list]
45+
new_pred = torch.zeros_like(pred, dtype=torch.int)
46+
for idx, instance_id in enumerate(pred_id):
47+
new_pred[pred == instance_id] = idx + 1
48+
return new_pred
49+
50+
51+
class TestRemapInstanceId(unittest.TestCase):
52+
53+
@parameterized.expand(TEST_CASES)
54+
def test_expected_value(self, _, pred, by_size, expected):
55+
result = remap_instance_id(torch.as_tensor(pred, device=_device), by_size=by_size)
56+
torch.testing.assert_close(result.cpu(), torch.as_tensor(expected, dtype=torch.int), check_dtype=False)
57+
58+
@parameterized.expand([["all_background_2d", (4, 4)], ["all_background_3d", (2, 3, 4)], ["empty", (0,)]])
59+
def test_passthrough(self, _, shape):
60+
pred = torch.zeros(shape, dtype=torch.int64, device=_device)
61+
result = remap_instance_id(pred, by_size=True)
62+
self.assertEqual(result.dtype, pred.dtype)
63+
torch.testing.assert_close(result, pred)
64+
65+
def test_output_dtype(self):
66+
pred = torch.as_tensor([[0, 9]], dtype=torch.int64, device=_device)
67+
self.assertEqual(remap_instance_id(pred).dtype, torch.int)
68+
69+
@parameterized.expand(
70+
[
71+
["2d", (64, 64), 20, False],
72+
["2d_by_size", (64, 64), 20, True],
73+
["3d_by_size", (16, 16, 16), 12, True],
74+
["sparse_ids_by_size", (48, 48), 7, True],
75+
]
76+
)
77+
def test_matches_reference(self, name, shape, n_inst, by_size):
78+
g = torch.Generator().manual_seed(0)
79+
pred = torch.randint(0, n_inst + 1, shape, generator=g).to(_device)
80+
if name.startswith("sparse"):
81+
pred = pred * 1000 + 17 # large, non-contiguous, no-background ids
82+
result = remap_instance_id(pred, by_size=by_size)
83+
expected = _reference_remap(pred, by_size=by_size)
84+
torch.testing.assert_close(result, expected)
85+
86+
87+
if __name__ == "__main__":
88+
unittest.main()

0 commit comments

Comments
 (0)