@@ -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+
121199class 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 )
0 commit comments