Skip to content

Commit 9f078ac

Browse files
authored
Merge pull request #124 from Hendrik-code/poi-label-names
Poi label names
2 parents e83bf79 + 65f202f commit 9f078ac

6 files changed

Lines changed: 353 additions & 35 deletions

File tree

‎TPTBox/core/poi.py‎

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -128,13 +128,16 @@ class POI(Abstract_POI, Has_Grid):
128128
_vert_orientation_pir: dict = field(init=False, default_factory=dict, repr=False, compare=False)
129129

130130
def _set_inplace(self, poi: Self) -> Self:
131-
"""Copy all grid/affine attributes and centroids from ``poi`` into ``self``."""
131+
"""Copy all grid/affine attributes, centroids, and naming metadata from ``poi`` into ``self``."""
132132
self.orientation = poi.orientation
133133
self.centroids = poi.centroids
134134
self.zoom = poi.zoom
135135
self.shape = poi.shape
136136
self.origin = poi.origin
137137
self.rotation = poi.rotation
138+
self.info = poi.info
139+
self.level_one_info = poi.level_one_info
140+
self.level_two_info = poi.level_two_info
138141
return self
139142

140143
@property
@@ -229,6 +232,8 @@ def copy(
229232
origin=origin if not isinstance(origin, Sentinel) else self.origin,
230233
info=deepcopy(self.info),
231234
format=self.format,
235+
level_one_info=self.level_one_info,
236+
level_two_info=self.level_two_info,
232237
)
233238

234239
def local_to_global(self, x: COORDINATE, itk_coords=False) -> COORDINATE:
@@ -1406,7 +1411,18 @@ def calc_poi_average(pois: list[POI], keep_points_not_present_in_all_pois: bool
14061411

14071412
# Sort the new ctd by keys
14081413
ctd = dict(sorted(ctd.items()))
1409-
return POI(centroids=ctd, orientation=pois[0].orientation, zoom=pois[0].zoom, shape=pois[0].shape, rotation=pois[0].rotation)
1414+
return POI(
1415+
centroids=ctd,
1416+
orientation=pois[0].orientation,
1417+
zoom=pois[0].zoom,
1418+
shape=pois[0].shape,
1419+
rotation=pois[0].rotation,
1420+
origin=pois[0].origin,
1421+
info=deepcopy(pois[0].info),
1422+
format=pois[0].format,
1423+
level_one_info=pois[0].level_one_info,
1424+
level_two_info=pois[0].level_two_info,
1425+
)
14101426

14111427

14121428
def _load_from_POI_spine_r(data: dict) -> POI:

‎TPTBox/core/poi_fun/poi_abstract.py‎

Lines changed: 165 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -79,16 +79,51 @@ def __init__(
7979
self.subregion_name2idx = {value: key for key, value in subregion.items()}
8080

8181

82-
def unpack_poi_id(key: POI_ID, definition: _Abstract_POI_Definition) -> tuple[int, int]:
82+
def _resolve_poi_part(part, name2idx: dict, level_info=None) -> int:
83+
"""Resolve one half of a POI key (region or subregion) to its integer id.
84+
85+
Order: Enum -> ``.value``; numeric string -> int; other string -> ``level_info`` enum
86+
name (when ``level_info`` is a concrete enum), then ``name2idx``. When ``level_info`` is
87+
the ``Any`` wildcard and an Enum is used, a warning is emitted (the level info is not set).
88+
"""
89+
if isinstance(part, Enum):
90+
if level_info is Any:
91+
import warnings
92+
93+
warnings.warn(
94+
f"Indexing a POI with the enum {part!r} but the matching level_one_info/level_two_info is not set "
95+
f"(it is 'Any'); the integer value {part.value} is used.",
96+
stacklevel=4,
97+
)
98+
return part.value
99+
if isinstance(part, str):
100+
try:
101+
return int(part)
102+
except ValueError:
103+
pass
104+
if level_info is not None and level_info is not Any:
105+
try:
106+
return level_info._get_id(part, no_raise=False)
107+
except Exception:
108+
pass
109+
return name2idx[part]
110+
return part
111+
112+
113+
def unpack_poi_id(key: POI_ID, definition: _Abstract_POI_Definition, level_one_info=None, level_two_info=None) -> tuple[int, int]:
83114
"""Convert any supported POI key type to a ``(region, subregion)`` integer pair.
84115
85116
Accepted key forms: plain integer (packed label), ``slice(region, subregion)``,
86117
2-tuple of ints, 2-tuple of ``Abstract_lvl`` / ``Enum`` members, or mixed tuples.
87-
String values are resolved via ``definition``'s name-to-index mappings.
118+
String values resolve via ``level_one_info``/``level_two_info`` enum names (when given)
119+
and otherwise via ``definition``'s name-to-index mappings, so ``poi[idx, "level2name"]``
120+
and ``poi["level1name", "level2name"]`` work in addition to ids and Enums.
88121
89122
Args:
90123
key: POI identifier in any of the supported formats.
91124
definition: Name-to-integer mapping used to resolve string labels.
125+
level_one_info: Optional region-level enum class for name/enum resolution + warnings.
126+
level_two_info: Optional subregion-level enum class for name/enum resolution + warnings.
92127
93128
Returns:
94129
``(region, subregion)`` tuple of plain Python integers.
@@ -101,23 +136,66 @@ def unpack_poi_id(key: POI_ID, definition: _Abstract_POI_Definition) -> tuple[in
101136
subregion = key.stop
102137
else:
103138
region, subregion = key
104-
if isinstance(region, str):
105-
try:
106-
region = int(region)
107-
except ValueError:
108-
region = definition.region_name2idx[region]
109-
if isinstance(region, Enum):
110-
region = region.value
111-
if isinstance(subregion, str):
112-
try:
113-
subregion = int(subregion)
114-
except ValueError:
115-
subregion = definition.subregion_name2idx[subregion]
116-
if isinstance(subregion, Enum):
117-
subregion = subregion.value
139+
region = _resolve_poi_part(region, definition.region_name2idx, level_one_info)
140+
subregion = _resolve_poi_part(subregion, definition.subregion_name2idx, level_two_info)
118141
return region, subregion
119142

120143

144+
# ---------------------------------------------------------------------------
145+
# label_name (custom per-point / per-region names stored in POI.info["label_name"])
146+
# In-memory (new) format: {region:int -> {subregion:int -> name:str, "name": group_name:str}}
147+
# ---------------------------------------------------------------------------
148+
LABEL_NAME = "label_name"
149+
_GROUP_NAME_KEY = "name"
150+
151+
152+
def normalize_label_name(d: dict | None) -> dict[int, dict]:
153+
"""Normalize any ``label_name`` mapping to the nested form (migration helper).
154+
155+
Target: ``{region:int -> {subregion:int -> name:str, "name": group_name:str}}``.
156+
Accepts and migrates: the old flat ``{"(1, 2)": "C2"}`` form, a JSON-loaded nested
157+
form with string keys ``{"1": {"2": "C2", "name": "Spine"}}``, and the already-nested
158+
form (idempotent).
159+
"""
160+
import ast
161+
162+
if not d:
163+
return {}
164+
out: dict[int, dict] = {}
165+
# old flat format: every key is a "(region, subregion)" tuple string
166+
if all(isinstance(k, str) and k.strip().startswith("(") for k in d):
167+
for k, name in d.items():
168+
region, subregion = ast.literal_eval(k)
169+
out.setdefault(int(region), {})[int(subregion)] = name
170+
return out
171+
# nested format (region keys may be JSON strings)
172+
for region, sub in d.items():
173+
target = out.setdefault(int(region), {})
174+
if isinstance(sub, dict):
175+
for s, name in sub.items():
176+
target[_GROUP_NAME_KEY if s == _GROUP_NAME_KEY else int(s)] = name
177+
else: # degenerate {region: name} -> treat as the region group name
178+
target[_GROUP_NAME_KEY] = sub
179+
return out
180+
181+
182+
def label_name_dict(info: dict) -> dict[int, dict]:
183+
"""Return the normalized nested ``label_name`` dict from ``info`` (normalizing in place)."""
184+
ln = info.get(LABEL_NAME)
185+
if not ln:
186+
return {}
187+
norm = normalize_label_name(ln)
188+
info[LABEL_NAME] = norm # cache the normalized form back into info
189+
return norm
190+
191+
192+
def _id_of(x) -> int:
193+
"""Resolve an Enum member / int / numeric string to its integer id."""
194+
if isinstance(x, Enum):
195+
return x.value
196+
return int(x)
197+
198+
121199
class POI_Descriptor(AbstractSet, MutableMapping):
122200
"""Two-level dictionary that maps ``(region, subregion)`` pairs to 3-D coordinates.
123201
@@ -169,10 +247,10 @@ def __set__(self, obj, value) -> None:
169247
setattr(obj, self._name, int(value))
170248

171249
def copy(self) -> POI_Descriptor:
172-
"""Return a deep copy of this descriptor."""
250+
"""Return a deep copy of this descriptor (keeping its name<->id ``definition``)."""
173251
from copy import deepcopy
174252

175-
return POI_Descriptor(default=deepcopy(self.pois))
253+
return POI_Descriptor(default=deepcopy(self.pois), definition=self.definition)
176254

177255
def _sort(self: Self, inplace=True, order_dict: dict | None = None):
178256
"""Sort vertebra dictionary by sorting_list."""
@@ -675,17 +753,20 @@ def __iter__(self):
675753
"""Iterate over all ``(region, subregion)`` key pairs."""
676754
return iter(self.centroids.keys())
677755

756+
def _resolve_key(self, key: POI_ID) -> tuple[int, int]:
757+
"""Resolve any key (ids, Enums, or ``level_one_info``/``level_two_info`` names) to ``(region, subregion)`` ints."""
758+
return unpack_poi_id(key, self.centroids.definition, self.level_one_info, self.level_two_info)
759+
678760
def __contains__(self, key: POI_ID) -> bool:
679-
key = unpack_poi_id(key, self.centroids.definition)
680-
return key in self.centroids
761+
return self._resolve_key(key) in self.centroids
681762

682763
def __getitem__(self, key: POI_ID) -> COORDINATE:
683-
return tuple(self.centroids[key])
764+
return tuple(self.centroids[self._resolve_key(key)])
684765

685766
def __setitem__(self, key: POI_ID, value: tuple[float, float, float] | Sequence[float] | np.ndarray) -> None:
686767
if len(value) != DIMENSIONS:
687768
raise ValueError(value)
688-
self.centroids[key] = tuple(value)
769+
self.centroids[self._resolve_key(key)] = tuple(value)
689770

690771
def __len__(self) -> int:
691772
return self.centroids.__len__()
@@ -753,6 +834,57 @@ def keys_subregion(self, sort: bool = False) -> list[int]:
753834
self.sort()
754835
return list(self.centroids.keys_subregion())
755836

837+
def label_name(self, region, subregion) -> str | None:
838+
"""Return the human-readable name of the point ``(region, subregion)``.
839+
840+
A custom name in ``info["label_name"]`` takes priority over the ``level_two_info`` enum
841+
name (and finally falls back to the raw id as a string). Accepts ints or Enum members.
842+
A warning is raised when a custom name is set that differs from the ``level_two_info``
843+
name for that id (when ``level_two_info`` is given).
844+
"""
845+
import warnings
846+
847+
region_i, subregion_i = _id_of(region), _id_of(subregion)
848+
custom = label_name_dict(self.info).get(region_i, {}).get(subregion_i)
849+
enum_name = None
850+
if self.level_two_info not in (None, Any):
851+
n = self.level_two_info._get_name(subregion_i, no_raise=True)
852+
enum_name = n if n != str(subregion_i) else None
853+
if custom is not None:
854+
if enum_name is not None and custom != enum_name:
855+
warnings.warn(
856+
f"label_name {custom!r} for subregion {subregion_i} is not the {self.level_two_info.__name__} name {enum_name!r}",
857+
stacklevel=2,
858+
)
859+
return custom
860+
return enum_name if enum_name is not None else str(subregion_i)
861+
862+
def set_label_name(self, region, subregion, name: str) -> None:
863+
"""Set a custom name for the point ``(region, subregion)`` in ``info["label_name"]``."""
864+
d = label_name_dict(self.info)
865+
d.setdefault(_id_of(region), {})[_id_of(subregion)] = name
866+
self.info[LABEL_NAME] = d
867+
868+
def level_one_name(self, region) -> str | None:
869+
"""Return the group (level-one) name of ``region``.
870+
871+
A custom group name in ``info["label_name"][region]["name"]`` takes priority over the
872+
``level_one_info`` enum name. Accepts an int or Enum member.
873+
"""
874+
region_i = _id_of(region)
875+
custom = label_name_dict(self.info).get(region_i, {}).get(_GROUP_NAME_KEY)
876+
if custom is not None:
877+
return custom
878+
if self.level_one_info not in (None, Any):
879+
return self.level_one_info._get_name(region_i, no_raise=True)
880+
return str(region_i)
881+
882+
def set_level_one_name(self, region, name: str) -> None:
883+
"""Set a custom group (level-one) name for ``region`` in ``info["label_name"]``."""
884+
d = label_name_dict(self.info)
885+
d.setdefault(_id_of(region), {})[_GROUP_NAME_KEY] = name
886+
self.info[LABEL_NAME] = d
887+
756888
def values(self, sort: bool = False) -> list[COORDINATE]:
757889
"""Return all stored ``(x, y, z)`` coordinate tuples.
758890
@@ -1047,16 +1179,23 @@ def join_left(self, pois: Self, inplace=False, _right_join=False) -> Self:
10471179
Self: The combined set of centroids, either in-place or as a new set, depending on the 'inplace' parameter.
10481180
"""
10491181
ctd_list = self.centroids
1050-
if "label_name" in pois.info and "label_name" not in self.info:
1051-
self.info["label_name"] = {}
1182+
src_ln = label_name_dict(pois.info)
10521183
if not inplace:
10531184
ctd_list = ctd_list.copy()
1185+
dst_ln = label_name_dict(self.info) if src_ln else None
1186+
if dst_ln is not None:
1187+
self.info[LABEL_NAME] = dst_ln
10541188
for x, y, c in pois.items():
10551189
if (x, y) in self and not _right_join:
10561190
continue
10571191
ctd_list[x, y] = c
1058-
if "label_name" in pois.info and f"({x}, {y})" in pois.info["label_name"]:
1059-
self.info["label_name"][f"({x}, {y})"] = pois.info["label_name"][f"({x}, {y})"]
1192+
if dst_ln is not None:
1193+
name = src_ln.get(x, {}).get(y)
1194+
if name is not None:
1195+
dst_ln.setdefault(x, {})[y] = name
1196+
grp = src_ln.get(x, {}).get(_GROUP_NAME_KEY)
1197+
if grp is not None:
1198+
dst_ln.setdefault(x, {}).setdefault(_GROUP_NAME_KEY, grp)
10601199
if inplace:
10611200
return self
10621201
return self.copy(ctd_list)

‎TPTBox/core/poi_fun/poi_global.py‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -208,7 +208,14 @@ def to_other(self, msk: Has_Grid, verbose=False) -> poi.POI:
208208
log.print(v, "-->", v_out)
209209
out[k1, k2] = tuple(v_out)
210210

211-
return poi.POI(centroids=out, **msk._extract_affine(), info=self.info, format=self.format)
211+
return poi.POI(
212+
centroids=out,
213+
**msk._extract_affine(),
214+
info=self.info,
215+
format=self.format,
216+
level_one_info=self.level_one_info,
217+
level_two_info=self.level_two_info,
218+
)
212219

213220
def copy(self, centroids: POI_Descriptor | None = None) -> Self:
214221
"""Return a deep copy of this ``POI_Global``.

‎TPTBox/core/poi_fun/save_load.py‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from TPTBox.core import bids_files
1313
from TPTBox.core.internal.nii_help import save_json
1414
from TPTBox.core.nii_poi_abstract import Has_Grid
15-
from TPTBox.core.poi_fun.poi_abstract import POI_Descriptor
15+
from TPTBox.core.poi_fun.poi_abstract import _GROUP_NAME_KEY, LABEL_NAME, POI_Descriptor, label_name_dict, normalize_label_name
1616
from TPTBox.core.vert_constants import (
1717
AX_CODES,
1818
COORDINATE,
@@ -198,7 +198,8 @@ def _poi_to_dict_list( # noqa: C901
198198

199199
for k, v in ctd.info.items():
200200
if k not in ori:
201-
ori[k] = v
201+
# always persist label_name in the new nested format {region:{sub:name,"name":group}}
202+
ori[k] = normalize_label_name(v) if k == LABEL_NAME else v
202203

203204
dict_list: list[_Orientation | (_Point3D | dict)] = [ori]
204205

@@ -346,6 +347,9 @@ def load_poi(ctd_path: POI_Reference, verbose=True) -> POI | POI_Global: # noqa
346347
level_one_info = _register_lvl[dict_list[0].get("level_one_info", Vertebra_Instance.__name__)]
347348
level_two_info = _register_lvl[dict_list[0].get("level_two_info", Location.__name__)]
348349
info = {k: v for k, v in dict_list[0].items() if k not in ctd_info_blacklist}
350+
if LABEL_NAME in info:
351+
# migrate old flat {"(1, 2)": "C2"} / JSON string keys -> nested {1: {2: "C2", "name": ...}}
352+
info[LABEL_NAME] = normalize_label_name(info[LABEL_NAME])
349353
if format_ in (FORMAT_GLOBAL, FORMAT_PLST):
350354
from TPTBox import POI_Global
351355

@@ -642,8 +646,9 @@ def _load_mkr_POI(dict_mkr: dict) -> POI_Global:
642646
description = control_points.get("description", region)
643647
associatedNodeID = control_points.get("associatedNodeID", description)
644648
label_group_name[region] = associatedNodeID
649+
label_name.setdefault(region, {})[_GROUP_NAME_KEY] = associatedNodeID
645650

646-
label_name[str((region, subregion))] = label
651+
label_name.setdefault(region, {})[subregion] = label
647652
assert itk_coords is not None, "itk_coords not set"
648653
from TPTBox import POI_Global
649654

@@ -814,7 +819,7 @@ def _load_landmark_txt(path: Path) -> list:
814819
id_ = label_group_name[current_group]
815820
new_id = len(points[id_]) + 1
816821
points[id_][new_id] = coords
817-
label_name[str((id_, new_id))] = key
822+
label_name.setdefault(id_, {})[new_id] = key
818823
if len(label_name) != 0:
819824
header["label_name"] = label_name
820825
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
@@ -11,6 +11,7 @@
1111
from typing_extensions import NotRequired
1212

1313
from TPTBox.core.internal.nii_help import save_json
14+
from TPTBox.core.poi_fun.poi_abstract import _GROUP_NAME_KEY, label_name_dict
1415
from TPTBox.logger.log_file import log
1516
from TPTBox.mesh3D.mesh_colors import RGB_Color, get_color_by_label
1617

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

0 commit comments

Comments
 (0)