Skip to content

Commit 38be00e

Browse files
Hendrik-codeclaude
andcommitted
feat(poi): nested label_name format + name accessors + migration
label_name now uses the nested form {region:{subregion:name,'name':group}} instead of flat {'(1, 2)':'C2'}. Old flat files (and JSON string keys) are migrated on load (normalize_label_name); saving always writes the new format (mrk.json export stays byte-identical, fed from the nested dict). New accessors POI.label_name(region, subregion) and POI.level_one_name(region) resolve custom names with priority over level_two_info/level_one_info enum names (warning when a custom name diverges from a given enum name); set_label_name/set_level_one_name write them. mrk/txt import and join_left updated to the nested form. 89 POI tests + 8 new tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 18d9096 commit 38be00e

4 files changed

Lines changed: 243 additions & 10 deletions

File tree

‎TPTBox/core/poi_fun/poi_abstract.py‎

Lines changed: 117 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,61 @@ def unpack_poi_id(key: POI_ID, definition: _Abstract_POI_Definition) -> tuple[in
118118
return region, subregion
119119

120120

121+
# ---------------------------------------------------------------------------
122+
# label_name (custom per-point / per-region names stored in POI.info["label_name"])
123+
# In-memory (new) format: {region:int -> {subregion:int -> name:str, "name": group_name:str}}
124+
# ---------------------------------------------------------------------------
125+
LABEL_NAME = "label_name"
126+
_GROUP_NAME_KEY = "name"
127+
128+
129+
def normalize_label_name(d: dict | None) -> dict[int, dict]:
130+
"""Normalize any ``label_name`` mapping to the nested form (migration helper).
131+
132+
Target: ``{region:int -> {subregion:int -> name:str, "name": group_name:str}}``.
133+
Accepts and migrates: the old flat ``{"(1, 2)": "C2"}`` form, a JSON-loaded nested
134+
form with string keys ``{"1": {"2": "C2", "name": "Spine"}}``, and the already-nested
135+
form (idempotent).
136+
"""
137+
import ast
138+
139+
if not d:
140+
return {}
141+
out: dict[int, dict] = {}
142+
# old flat format: every key is a "(region, subregion)" tuple string
143+
if all(isinstance(k, str) and k.strip().startswith("(") for k in d):
144+
for k, name in d.items():
145+
region, subregion = ast.literal_eval(k)
146+
out.setdefault(int(region), {})[int(subregion)] = name
147+
return out
148+
# nested format (region keys may be JSON strings)
149+
for region, sub in d.items():
150+
target = out.setdefault(int(region), {})
151+
if isinstance(sub, dict):
152+
for s, name in sub.items():
153+
target[_GROUP_NAME_KEY if s == _GROUP_NAME_KEY else int(s)] = name
154+
else: # degenerate {region: name} -> treat as the region group name
155+
target[_GROUP_NAME_KEY] = sub
156+
return out
157+
158+
159+
def label_name_dict(info: dict) -> dict[int, dict]:
160+
"""Return the normalized nested ``label_name`` dict from ``info`` (normalizing in place)."""
161+
ln = info.get(LABEL_NAME)
162+
if not ln:
163+
return {}
164+
norm = normalize_label_name(ln)
165+
info[LABEL_NAME] = norm # cache the normalized form back into info
166+
return norm
167+
168+
169+
def _id_of(x) -> int:
170+
"""Resolve an Enum member / int / numeric string to its integer id."""
171+
if isinstance(x, Enum):
172+
return x.value
173+
return int(x)
174+
175+
121176
class POI_Descriptor(AbstractSet, MutableMapping):
122177
"""Two-level dictionary that maps ``(region, subregion)`` pairs to 3-D coordinates.
123178
@@ -754,6 +809,57 @@ def keys_subregion(self, sort: bool = False) -> list[int]:
754809
self.sort()
755810
return list(self.centroids.keys_subregion())
756811

812+
def label_name(self, region, subregion) -> str | None:
813+
"""Return the human-readable name of the point ``(region, subregion)``.
814+
815+
A custom name in ``info["label_name"]`` takes priority over the ``level_two_info`` enum
816+
name (and finally falls back to the raw id as a string). Accepts ints or Enum members.
817+
A warning is raised when a custom name is set that differs from the ``level_two_info``
818+
name for that id (when ``level_two_info`` is given).
819+
"""
820+
import warnings
821+
822+
region_i, subregion_i = _id_of(region), _id_of(subregion)
823+
custom = label_name_dict(self.info).get(region_i, {}).get(subregion_i)
824+
enum_name = None
825+
if self.level_two_info not in (None, Any):
826+
n = self.level_two_info._get_name(subregion_i, no_raise=True)
827+
enum_name = n if n != str(subregion_i) else None
828+
if custom is not None:
829+
if enum_name is not None and custom != enum_name:
830+
warnings.warn(
831+
f"label_name {custom!r} for subregion {subregion_i} is not the {self.level_two_info.__name__} name {enum_name!r}",
832+
stacklevel=2,
833+
)
834+
return custom
835+
return enum_name if enum_name is not None else str(subregion_i)
836+
837+
def set_label_name(self, region, subregion, name: str) -> None:
838+
"""Set a custom name for the point ``(region, subregion)`` in ``info["label_name"]``."""
839+
d = label_name_dict(self.info)
840+
d.setdefault(_id_of(region), {})[_id_of(subregion)] = name
841+
self.info[LABEL_NAME] = d
842+
843+
def level_one_name(self, region) -> str | None:
844+
"""Return the group (level-one) name of ``region``.
845+
846+
A custom group name in ``info["label_name"][region]["name"]`` takes priority over the
847+
``level_one_info`` enum name. Accepts an int or Enum member.
848+
"""
849+
region_i = _id_of(region)
850+
custom = label_name_dict(self.info).get(region_i, {}).get(_GROUP_NAME_KEY)
851+
if custom is not None:
852+
return custom
853+
if self.level_one_info not in (None, Any):
854+
return self.level_one_info._get_name(region_i, no_raise=True)
855+
return str(region_i)
856+
857+
def set_level_one_name(self, region, name: str) -> None:
858+
"""Set a custom group (level-one) name for ``region`` in ``info["label_name"]``."""
859+
d = label_name_dict(self.info)
860+
d.setdefault(_id_of(region), {})[_GROUP_NAME_KEY] = name
861+
self.info[LABEL_NAME] = d
862+
757863
def values(self, sort: bool = False) -> list[COORDINATE]:
758864
"""Return all stored ``(x, y, z)`` coordinate tuples.
759865
@@ -1030,16 +1136,23 @@ def join_left(self, pois: Self, inplace=False, _right_join=False) -> Self:
10301136
Self: The combined set of centroids, either in-place or as a new set, depending on the 'inplace' parameter.
10311137
"""
10321138
ctd_list = self.centroids
1033-
if "label_name" in pois.info and "label_name" not in self.info:
1034-
self.info["label_name"] = {}
1139+
src_ln = label_name_dict(pois.info)
10351140
if not inplace:
10361141
ctd_list = ctd_list.copy()
1142+
dst_ln = label_name_dict(self.info) if src_ln else None
1143+
if dst_ln is not None:
1144+
self.info[LABEL_NAME] = dst_ln
10371145
for x, y, c in pois.items():
10381146
if (x, y) in self and not _right_join:
10391147
continue
10401148
ctd_list[x, y] = c
1041-
if "label_name" in pois.info and f"({x}, {y})" in pois.info["label_name"]:
1042-
self.info["label_name"][f"({x}, {y})"] = pois.info["label_name"][f"({x}, {y})"]
1149+
if dst_ln is not None:
1150+
name = src_ln.get(x, {}).get(y)
1151+
if name is not None:
1152+
dst_ln.setdefault(x, {})[y] = name
1153+
grp = src_ln.get(x, {}).get(_GROUP_NAME_KEY)
1154+
if grp is not None:
1155+
dst_ln.setdefault(x, {}).setdefault(_GROUP_NAME_KEY, grp)
10431156
if inplace:
10441157
return self
10451158
return self.copy(ctd_list)

‎TPTBox/core/poi_fun/save_load.py‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
# from TPTBox import POI, POI_Global
1212
from TPTBox.core import bids_files
1313
from TPTBox.core.nii_poi_abstract import Has_Grid
14-
from TPTBox.core.poi_fun.poi_abstract import POI_Descriptor
14+
from TPTBox.core.poi_fun.poi_abstract import _GROUP_NAME_KEY, LABEL_NAME, POI_Descriptor, label_name_dict, normalize_label_name
1515
from TPTBox.core.vert_constants import (
1616
AX_CODES,
1717
COORDINATE,
@@ -207,7 +207,8 @@ def _poi_to_dict_list( # noqa: C901
207207

208208
for k, v in ctd.info.items():
209209
if k not in ori:
210-
ori[k] = v
210+
# always persist label_name in the new nested format {region:{sub:name,"name":group}}
211+
ori[k] = normalize_label_name(v) if k == LABEL_NAME else v
211212

212213
dict_list: list[_Orientation | (_Point3D | dict)] = [ori]
213214

@@ -353,6 +354,9 @@ def load_poi(ctd_path: POI_Reference, verbose=True) -> POI | POI_Global: # noqa
353354
level_one_info = _register_lvl[dict_list[0].get("level_one_info", Vertebra_Instance.__name__)]
354355
level_two_info = _register_lvl[dict_list[0].get("level_two_info", Location.__name__)]
355356
info = {k: v for k, v in dict_list[0].items() if k not in ctd_info_blacklist}
357+
if LABEL_NAME in info:
358+
# migrate old flat {"(1, 2)": "C2"} / JSON string keys -> nested {1: {2: "C2", "name": ...}}
359+
info[LABEL_NAME] = normalize_label_name(info[LABEL_NAME])
356360
if format_ in (FORMAT_GLOBAL, FORMAT_PLST):
357361
from TPTBox import POI_Global
358362

@@ -649,8 +653,9 @@ def _load_mkr_POI(dict_mkr: dict) -> POI_Global:
649653
description = control_points.get("description", region)
650654
associatedNodeID = control_points.get("associatedNodeID", description)
651655
label_group_name[region] = associatedNodeID
656+
label_name.setdefault(region, {})[_GROUP_NAME_KEY] = associatedNodeID
652657

653-
label_name[str((region, subregion))] = label
658+
label_name.setdefault(region, {})[subregion] = label
654659
assert itk_coords is not None, "itk_coords not set"
655660
from TPTBox import POI_Global
656661

@@ -821,7 +826,7 @@ def _load_landmark_txt(path: Path) -> list:
821826
id_ = label_group_name[current_group]
822827
new_id = len(points[id_]) + 1
823828
points[id_][new_id] = coords
824-
label_name[str((id_, new_id))] = key
829+
label_name.setdefault(id_, {})[new_id] = key
825830
if len(label_name) != 0:
826831
header["label_name"] = label_name
827832
if len(label_group_name) != 0:

‎TPTBox/core/poi_fun/save_mkr.py‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import numpy as np
1111
from typing_extensions import NotRequired
1212

13+
from TPTBox.core.poi_fun.poi_abstract import _GROUP_NAME_KEY, label_name_dict
1314
from TPTBox.logger.log_file import log
1415
from TPTBox.mesh3D.mesh_colors import RGB_Color, get_color_by_label
1516

@@ -404,15 +405,18 @@ def get_desc(self: POI_Global, region: int, subregion: int) -> tuple[str, str, s
404405
Tuple ``(name, name2, label)`` where ``name`` and ``name2`` are the
405406
group/region name and ``label`` is the subregion label string.
406407
"""
407-
label = self.info.get("label_name", {}).get(str((region, subregion)))
408+
_ln = label_name_dict(self.info)
409+
label = _ln.get(region, {}).get(subregion)
408410
if label is None:
409411
label = str(subregion)
410412
try:
411413
label = self.level_two_info(subregion).name
412414
except Exception:
413415
label = str(subregion)
414416
try:
415-
if region in self.info.get("label_group_name", {}):
417+
if _ln.get(region, {}).get(_GROUP_NAME_KEY) is not None:
418+
name2 = _ln[region][_GROUP_NAME_KEY]
419+
elif region in self.info.get("label_group_name", {}):
416420
name2 = self.info["label_group_name"][region]
417421
else:
418422
name2 = self.level_one_info(region).name

‎unit_tests/test_poi_label_names.py‎

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
"""Tests for POI label-name handling: nested format + migration, accessors, and metadata transfer."""
2+
3+
from __future__ import annotations
4+
5+
import json
6+
import os
7+
import tempfile
8+
import unittest
9+
from pathlib import Path
10+
11+
import numpy as np
12+
13+
from TPTBox.core.poi import POI, calc_poi_average
14+
from TPTBox.core.poi_fun.poi_abstract import normalize_label_name
15+
from TPTBox.core.vert_constants import Location, Vertebra_Instance
16+
17+
18+
def _poi():
19+
return POI(
20+
centroids={1: {50: (1.0, 2.0, 3.0)}, 2: {50: (4.0, 5.0, 6.0)}},
21+
orientation=("R", "A", "S"),
22+
zoom=(1.0, 1.0, 1.0),
23+
shape=(10.0, 10.0, 10.0),
24+
rotation=np.eye(3),
25+
origin=(0.0, 0.0, 0.0),
26+
level_one_info=Vertebra_Instance,
27+
level_two_info=Location,
28+
)
29+
30+
31+
class TestLabelNameMigration(unittest.TestCase):
32+
def test_old_flat_to_nested(self):
33+
self.assertEqual(
34+
normalize_label_name({"(1, 2)": "C2", "(1, 3)": "C3", "(2, 50)": "Body"}),
35+
{1: {2: "C2", 3: "C3"}, 2: {50: "Body"}},
36+
)
37+
38+
def test_json_string_keys_to_int(self):
39+
self.assertEqual(normalize_label_name({"1": {"2": "C2", "name": "Spine"}}), {1: {2: "C2", "name": "Spine"}})
40+
41+
def test_idempotent_and_empty(self):
42+
nested = {1: {2: "C2", "name": "Spine"}}
43+
self.assertEqual(normalize_label_name(nested), nested)
44+
self.assertEqual(normalize_label_name(None), {})
45+
self.assertEqual(normalize_label_name({}), {})
46+
47+
48+
class TestAccessors(unittest.TestCase):
49+
def test_set_get_point_and_group_name(self):
50+
p = _poi()
51+
p.set_label_name(1, 50, "MyCorpus")
52+
p.set_level_one_name(1, "CervicalGroup")
53+
self.assertEqual(p.label_name(1, 50), "MyCorpus")
54+
self.assertEqual(p.level_one_name(1), "CervicalGroup")
55+
self.assertEqual(p.info["label_name"], {1: {50: "MyCorpus", "name": "CervicalGroup"}})
56+
57+
def test_enum_fallback(self):
58+
p = _poi()
59+
# Vertebra_Instance saves as name -> group name falls back to the enum name
60+
self.assertEqual(p.level_one_name(2), Vertebra_Instance._get_name(2))
61+
# accepts Enum members too
62+
self.assertEqual(p.level_one_name(Vertebra_Instance.C2), Vertebra_Instance._get_name(2))
63+
64+
65+
class TestRoundTrip(unittest.TestCase):
66+
def test_save_new_format_load_back(self):
67+
p = _poi()
68+
p.set_label_name(1, 50, "MyCorpus")
69+
p.set_level_one_name(1, "CervicalGroup")
70+
fp = os.path.join(tempfile.mkdtemp(), "sub-x_ctd.json")
71+
p.save(fp, verbose=False)
72+
on_disk = json.loads(Path(fp).read_text())[0]["label_name"]
73+
self.assertEqual(on_disk, {"1": {"50": "MyCorpus", "name": "CervicalGroup"}}) # nested, JSON string keys
74+
q = POI.load(fp)
75+
self.assertEqual(q.info["label_name"], {1: {50: "MyCorpus", "name": "CervicalGroup"}})
76+
self.assertEqual(q.label_name(1, 50), "MyCorpus")
77+
78+
def test_load_old_flat_format_migrates(self):
79+
p = _poi()
80+
fp = os.path.join(tempfile.mkdtemp(), "sub-x_ctd.json")
81+
p.save(fp, verbose=False)
82+
raw = json.loads(Path(fp).read_text())
83+
raw[0]["label_name"] = {"(1, 50)": "OldName"} # legacy flat format on disk
84+
Path(fp).write_text(json.dumps(raw))
85+
q = POI.load(fp)
86+
self.assertEqual(q.info["label_name"], {1: {50: "OldName"}})
87+
88+
89+
class TestMetadataTransfer(unittest.TestCase):
90+
def _check(self, q):
91+
self.assertIs(q.level_one_info, Vertebra_Instance)
92+
self.assertIs(q.level_two_info, Location)
93+
self.assertIn("label_name", q.info)
94+
95+
def test_metadata_survives_ops(self):
96+
p = _poi()
97+
p.set_label_name(1, 50, "MyCorpus")
98+
for q in (
99+
p.copy(),
100+
p.reorient(("P", "I", "R")),
101+
p.rescale((2.0, 2.0, 2.0), verbose=False),
102+
p.extract_subregion(Location.Vertebra_Corpus),
103+
p.extract_region(1),
104+
p.map_labels(label_map_region={1: 10}),
105+
calc_poi_average([p, p.copy()]),
106+
):
107+
self._check(q)
108+
109+
110+
if __name__ == "__main__":
111+
unittest.main()

0 commit comments

Comments
 (0)