diff --git a/resources/atlas/FreeSurferColorLUT.txt b/resources/atlas/FreeSurferColorLUT.txt new file mode 100644 index 00000000..2a7bd23d --- /dev/null +++ b/resources/atlas/FreeSurferColorLUT.txt @@ -0,0 +1,1397 @@ +#$Id: FreeSurferColorLUT.txt,v 1.70.2.7 2012/08/27 17:20:08 nicks Exp $ + +#No. Label Name: R G B A + +0 Unknown 0 0 0 0 +1 Left-Cerebral-Exterior 70 130 180 0 +2 Left-Cerebral-White-Matter 245 245 245 0 +3 Left-Cerebral-Cortex 205 62 78 0 +4 Left-Lateral-Ventricle 120 18 134 0 +5 Left-Inf-Lat-Vent 196 58 250 0 +6 Left-Cerebellum-Exterior 0 148 0 0 +7 Left-Cerebellum-White-Matter 220 248 164 0 +8 Left-Cerebellum-Cortex 230 148 34 0 +9 Left-Thalamus 0 118 14 0 +10 Left-Thalamus-Proper 0 118 14 0 +11 Left-Caudate 122 186 220 0 +12 Left-Putamen 236 13 176 0 +13 Left-Pallidum 12 48 255 0 +14 3rd-Ventricle 204 182 142 0 +15 4th-Ventricle 42 204 164 0 +16 Brain-Stem 119 159 176 0 +17 Left-Hippocampus 220 216 20 0 +18 Left-Amygdala 103 255 255 0 +19 Left-Insula 80 196 98 0 +20 Left-Operculum 60 58 210 0 +21 Line-1 60 58 210 0 +22 Line-2 60 58 210 0 +23 Line-3 60 58 210 0 +24 CSF 60 60 60 0 +25 Left-Lesion 255 165 0 0 +26 Left-Accumbens-area 255 165 0 0 +27 Left-Substancia-Nigra 0 255 127 0 +28 Left-VentralDC 165 42 42 0 +29 Left-undetermined 135 206 235 0 +30 Left-vessel 160 32 240 0 +31 Left-choroid-plexus 0 200 200 0 +32 Left-F3orb 100 50 100 0 +33 Left-lOg 135 50 74 0 +34 Left-aOg 122 135 50 0 +35 Left-mOg 51 50 135 0 +36 Left-pOg 74 155 60 0 +37 Left-Stellate 120 62 43 0 +38 Left-Porg 74 155 60 0 +39 Left-Aorg 122 135 50 0 +40 Right-Cerebral-Exterior 70 130 180 0 +41 Right-Cerebral-White-Matter 0 225 0 0 +42 Right-Cerebral-Cortex 205 62 78 0 +43 Right-Lateral-Ventricle 120 18 134 0 +44 Right-Inf-Lat-Vent 196 58 250 0 +45 Right-Cerebellum-Exterior 0 148 0 0 +46 Right-Cerebellum-White-Matter 220 248 164 0 +47 Right-Cerebellum-Cortex 230 148 34 0 +48 Right-Thalamus 0 118 14 0 +49 Right-Thalamus-Proper 0 118 14 0 +50 Right-Caudate 122 186 220 0 +51 Right-Putamen 236 13 176 0 +52 Right-Pallidum 13 48 255 0 +53 Right-Hippocampus 220 216 20 0 +54 Right-Amygdala 103 255 255 0 +55 Right-Insula 80 196 98 0 +56 Right-Operculum 60 58 210 0 +57 Right-Lesion 255 165 0 0 +58 Right-Accumbens-area 255 165 0 0 +59 Right-Substancia-Nigra 0 255 127 0 +60 Right-VentralDC 165 42 42 0 +61 Right-undetermined 135 206 235 0 +62 Right-vessel 160 32 240 0 +63 Right-choroid-plexus 0 200 221 0 +64 Right-F3orb 100 50 100 0 +65 Right-lOg 135 50 74 0 +66 Right-aOg 122 135 50 0 +67 Right-mOg 51 50 135 0 +68 Right-pOg 74 155 60 0 +69 Right-Stellate 120 62 43 0 +70 Right-Porg 74 155 60 0 +71 Right-Aorg 122 135 50 0 +72 5th-Ventricle 120 190 150 0 +73 Left-Interior 122 135 50 0 +74 Right-Interior 122 135 50 0 +# 75/76 removed. duplicates of 4/43 +77 WM-hypointensities 200 70 255 0 +78 Left-WM-hypointensities 255 148 10 0 +79 Right-WM-hypointensities 255 148 10 0 +80 non-WM-hypointensities 164 108 226 0 +81 Left-non-WM-hypointensities 164 108 226 0 +82 Right-non-WM-hypointensities 164 108 226 0 +83 Left-F1 255 218 185 0 +84 Right-F1 255 218 185 0 +85 Optic-Chiasm 234 169 30 0 +192 Corpus_Callosum 250 255 50 0 + +86 Left_future_WMSA 200 120 255 0 +87 Right_future_WMSA 200 121 255 0 +88 future_WMSA 200 122 255 0 + + +96 Left-Amygdala-Anterior 205 10 125 0 +97 Right-Amygdala-Anterior 205 10 125 0 +98 Dura 160 32 240 0 + +100 Left-wm-intensity-abnormality 124 140 178 0 +101 Left-caudate-intensity-abnormality 125 140 178 0 +102 Left-putamen-intensity-abnormality 126 140 178 0 +103 Left-accumbens-intensity-abnormality 127 140 178 0 +104 Left-pallidum-intensity-abnormality 124 141 178 0 +105 Left-amygdala-intensity-abnormality 124 142 178 0 +106 Left-hippocampus-intensity-abnormality 124 143 178 0 +107 Left-thalamus-intensity-abnormality 124 144 178 0 +108 Left-VDC-intensity-abnormality 124 140 179 0 +109 Right-wm-intensity-abnormality 124 140 178 0 +110 Right-caudate-intensity-abnormality 125 140 178 0 +111 Right-putamen-intensity-abnormality 126 140 178 0 +112 Right-accumbens-intensity-abnormality 127 140 178 0 +113 Right-pallidum-intensity-abnormality 124 141 178 0 +114 Right-amygdala-intensity-abnormality 124 142 178 0 +115 Right-hippocampus-intensity-abnormality 124 143 178 0 +116 Right-thalamus-intensity-abnormality 124 144 178 0 +117 Right-VDC-intensity-abnormality 124 140 179 0 + +118 Epidermis 255 20 147 0 +119 Conn-Tissue 205 179 139 0 +120 SC-Fat-Muscle 238 238 209 0 +121 Cranium 200 200 200 0 +122 CSF-SA 74 255 74 0 +123 Muscle 238 0 0 0 +124 Ear 0 0 139 0 +125 Adipose 173 255 47 0 +126 Spinal-Cord 133 203 229 0 +127 Soft-Tissue 26 237 57 0 +128 Nerve 34 139 34 0 +129 Bone 30 144 255 0 +130 Air 147 19 173 0 +131 Orbital-Fat 238 59 59 0 +132 Tongue 221 39 200 0 +133 Nasal-Structures 238 174 238 0 +134 Globe 255 0 0 0 +135 Teeth 72 61 139 0 +136 Left-Caudate-Putamen 21 39 132 0 +137 Right-Caudate-Putamen 21 39 132 0 +138 Left-Claustrum 65 135 20 0 +139 Right-Claustrum 65 135 20 0 +140 Cornea 134 4 160 0 +142 Diploe 221 226 68 0 +143 Vitreous-Humor 255 255 254 0 +144 Lens 52 209 226 0 +145 Aqueous-Humor 239 160 223 0 +146 Outer-Table 70 130 180 0 +147 Inner-Table 70 130 181 0 +148 Periosteum 139 121 94 0 +149 Endosteum 224 224 224 0 +150 R-C-S 255 0 0 0 +151 Iris 205 205 0 0 +152 SC-Adipose-Muscle 238 238 209 0 +153 SC-Tissue 139 121 94 0 +154 Orbital-Adipose 238 59 59 0 + +155 Left-IntCapsule-Ant 238 59 59 0 +156 Right-IntCapsule-Ant 238 59 59 0 +157 Left-IntCapsule-Pos 62 10 205 0 +158 Right-IntCapsule-Pos 62 10 205 0 + +# These labels are for babies/children +159 Left-Cerebral-WM-unmyelinated 0 118 14 0 +160 Right-Cerebral-WM-unmyelinated 0 118 14 0 +161 Left-Cerebral-WM-myelinated 220 216 21 0 +162 Right-Cerebral-WM-myelinated 220 216 21 0 +163 Left-Subcortical-Gray-Matter 122 186 220 0 +164 Right-Subcortical-Gray-Matter 122 186 220 0 +165 Skull 255 165 0 0 +166 Posterior-fossa 14 48 255 0 +167 Scalp 166 42 42 0 +168 Hematoma 121 18 134 0 +169 Left-Basal-Ganglia 236 13 127 0 +176 Right-Basal-Ganglia 236 13 126 0 + +# Label names and colors for Brainstem constituents +# No. Label Name: R G B A +170 brainstem 119 159 176 0 +171 DCG 119 0 176 0 +172 Vermis 119 100 176 0 +173 Midbrain 119 200 176 0 +174 Pons 119 159 100 0 +175 Medulla 119 159 200 0 + +#176 Right-Basal-Ganglia found in babies/children section above + +180 Left-Cortical-Dysplasia 73 61 139 0 +181 Right-Cortical-Dysplasia 73 62 139 0 + +#192 Corpus_Callosum listed after #85 above +193 Left-hippocampal_fissure 0 196 255 0 +194 Left-CADG-head 255 164 164 0 +195 Left-subiculum 196 196 0 0 +196 Left-fimbria 0 100 255 0 +197 Right-hippocampal_fissure 128 196 164 0 +198 Right-CADG-head 0 126 75 0 +199 Right-subiculum 128 96 64 0 +200 Right-fimbria 0 50 128 0 +201 alveus 255 204 153 0 +202 perforant_pathway 255 128 128 0 +203 parasubiculum 255 255 0 0 +204 presubiculum 64 0 64 0 +205 subiculum 0 0 255 0 +206 CA1 255 0 0 0 +207 CA2 128 128 255 0 +208 CA3 0 128 0 0 +209 CA4 196 160 128 0 +210 GC-ML-DG 32 200 255 0 +211 HATA 128 255 128 0 +212 fimbria 204 153 204 0 +213 lateral_ventricle 121 17 136 0 +214 molecular_layer_HP 128 0 0 0 +215 hippocampal_fissure 128 32 255 0 +216 entorhinal_cortex 255 204 102 0 +217 molecular_layer_subiculum 128 128 128 0 +218 Amygdala 104 255 255 0 +219 Cerebral_White_Matter 0 226 0 0 +220 Cerebral_Cortex 205 63 78 0 +221 Inf_Lat_Vent 197 58 250 0 +222 Perirhinal 33 150 250 0 +223 Cerebral_White_Matter_Edge 226 0 0 0 +224 Background 100 100 100 0 +225 Ectorhinal 197 150 250 0 +226 HP_tail 170 170 255 0 + +250 Fornix 255 0 0 0 +251 CC_Posterior 0 0 64 0 +252 CC_Mid_Posterior 0 0 112 0 +253 CC_Central 0 0 160 0 +254 CC_Mid_Anterior 0 0 208 0 +255 CC_Anterior 0 0 255 0 + +# This is for keeping track of voxel changes +256 Voxel-Unchanged 0 0 0 0 + +# lymph node and vascular labels +331 Aorta 255 0 0 0 +332 Left-Common-IliacA 255 80 0 0 +333 Right-Common-IliacA 255 160 0 0 +334 Left-External-IliacA 255 255 0 0 +335 Right-External-IliacA 0 255 0 0 +336 Left-Internal-IliacA 255 0 160 0 +337 Right-Internal-IliacA 255 0 255 0 +338 Left-Lateral-SacralA 255 50 80 0 +339 Right-Lateral-SacralA 80 255 50 0 +340 Left-ObturatorA 160 255 50 0 +341 Right-ObturatorA 160 200 255 0 +342 Left-Internal-PudendalA 0 255 160 0 +343 Right-Internal-PudendalA 0 0 255 0 +344 Left-UmbilicalA 80 50 255 0 +345 Right-UmbilicalA 160 0 255 0 +346 Left-Inf-RectalA 255 210 0 0 +347 Right-Inf-RectalA 0 160 255 0 +348 Left-Common-IliacV 255 200 80 0 +349 Right-Common-IliacV 255 200 160 0 +350 Left-External-IliacV 255 80 200 0 +351 Right-External-IliacV 255 160 200 0 +352 Left-Internal-IliacV 30 255 80 0 +353 Right-Internal-IliacV 80 200 255 0 +354 Left-ObturatorV 80 255 200 0 +355 Right-ObturatorV 195 255 200 0 +356 Left-Internal-PudendalV 120 200 20 0 +357 Right-Internal-PudendalV 170 10 200 0 +358 Pos-Lymph 20 130 180 0 +359 Neg-Lymph 20 180 130 0 + +400 V1 206 62 78 0 +401 V2 121 18 134 0 +402 BA44 199 58 250 0 +403 BA45 1 148 0 0 +404 BA4a 221 248 164 0 +405 BA4p 231 148 34 0 +406 BA6 1 118 14 0 +407 BA2 120 118 14 0 +408 BA1_old 123 186 221 0 +409 BAun2 238 13 177 0 +410 BA1 123 186 220 0 +411 BA2b 138 13 206 0 +412 BA3a 238 130 176 0 +413 BA3b 218 230 76 0 +414 MT 38 213 176 0 +415 AIPS_AIP_l 1 225 176 0 +416 AIPS_AIP_r 1 225 176 0 +417 AIPS_VIP_l 200 2 100 0 +418 AIPS_VIP_r 200 2 100 0 +419 IPL_PFcm_l 5 200 90 0 +420 IPL_PFcm_r 5 200 90 0 +421 IPL_PF_l 100 5 200 0 +422 IPL_PFm_l 25 255 100 0 +423 IPL_PFm_r 25 255 100 0 +424 IPL_PFop_l 230 7 100 0 +425 IPL_PFop_r 230 7 100 0 +426 IPL_PF_r 100 5 200 0 +427 IPL_PFt_l 150 10 200 0 +428 IPL_PFt_r 150 10 200 0 +429 IPL_PGa_l 175 10 176 0 +430 IPL_PGa_r 175 10 176 0 +431 IPL_PGp_l 10 100 255 0 +432 IPL_PGp_r 10 100 255 0 +433 Visual_V3d_l 150 45 70 0 +434 Visual_V3d_r 150 45 70 0 +435 Visual_V4_l 45 200 15 0 +436 Visual_V4_r 45 200 15 0 +437 Visual_V5_b 227 45 100 0 +438 Visual_VP_l 227 45 100 0 +439 Visual_VP_r 227 45 100 0 + +# wm lesions +498 wmsa 143 188 143 0 +499 other_wmsa 255 248 220 0 + +# HiRes Hippocampus labeling +500 right_CA2_3 17 85 136 0 +501 right_alveus 119 187 102 0 +502 right_CA1 204 68 34 0 +503 right_fimbria 204 0 255 0 +504 right_presubiculum 221 187 17 0 +505 right_hippocampal_fissure 153 221 238 0 +506 right_CA4_DG 51 17 17 0 +507 right_subiculum 0 119 85 0 +508 right_fornix 20 100 200 0 + +550 left_CA2_3 17 85 137 0 +551 left_alveus 119 187 103 0 +552 left_CA1 204 68 35 0 +553 left_fimbria 204 0 254 0 +554 left_presubiculum 221 187 16 0 +555 left_hippocampal_fissure 153 221 239 0 +556 left_CA4_DG 51 17 18 0 +557 left_subiculum 0 119 86 0 +558 left_fornix 20 100 201 0 + +600 Tumor 254 254 254 0 + + +# Cerebellar parcellation labels from SUIT (matches labels in cma.h) +#No. Label Name: R G B A +601 Cbm_Left_I_IV 70 130 180 0 +602 Cbm_Right_I_IV 245 245 245 0 +603 Cbm_Left_V 205 62 78 0 +604 Cbm_Right_V 120 18 134 0 +605 Cbm_Left_VI 196 58 250 0 +606 Cbm_Vermis_VI 0 148 0 0 +607 Cbm_Right_VI 220 248 164 0 +608 Cbm_Left_CrusI 230 148 34 0 +609 Cbm_Vermis_CrusI 0 118 14 0 +610 Cbm_Right_CrusI 0 118 14 0 +611 Cbm_Left_CrusII 122 186 220 0 +612 Cbm_Vermis_CrusII 236 13 176 0 +613 Cbm_Right_CrusII 12 48 255 0 +614 Cbm_Left_VIIb 204 182 142 0 +615 Cbm_Vermis_VIIb 42 204 164 0 +616 Cbm_Right_VIIb 119 159 176 0 +617 Cbm_Left_VIIIa 220 216 20 0 +618 Cbm_Vermis_VIIIa 103 255 255 0 +619 Cbm_Right_VIIIa 80 196 98 0 +620 Cbm_Left_VIIIb 60 58 210 0 +621 Cbm_Vermis_VIIIb 60 58 210 0 +622 Cbm_Right_VIIIb 60 58 210 0 +623 Cbm_Left_IX 60 58 210 0 +624 Cbm_Vermis_IX 60 60 60 0 +625 Cbm_Right_IX 255 165 0 0 +626 Cbm_Left_X 255 165 0 0 +627 Cbm_Vermis_X 0 255 127 0 +628 Cbm_Right_X 165 42 42 0 + +# Cerebellar lobule parcellations +640 Cbm_Right_I_V_med 204 0 0 0 +641 Cbm_Right_I_V_mid 255 0 0 0 +642 Cbm_Right_VI_med 0 0 255 0 +643 Cbm_Right_VI_mid 30 144 255 0 +644 Cbm_Right_VI_lat 100 212 237 0 +645 Cbm_Right_CrusI_med 218 165 32 0 +646 Cbm_Right_CrusI_mid 255 215 0 0 +647 Cbm_Right_CrusI_lat 255 255 166 0 +648 Cbm_Right_CrusII_med 153 0 204 0 +649 Cbm_Right_CrusII_mid 153 141 209 0 +650 Cbm_Right_CrusII_lat 204 204 255 0 +651 Cbm_Right_7med 31 212 194 0 +652 Cbm_Right_7mid 3 255 237 0 +653 Cbm_Right_7lat 204 255 255 0 +654 Cbm_Right_8med 86 74 147 0 +655 Cbm_Right_8mid 114 114 190 0 +656 Cbm_Right_8lat 184 178 255 0 +657 Cbm_Right_PUNs 126 138 37 0 +658 Cbm_Right_TONs 189 197 117 0 +659 Cbm_Right_FLOs 240 230 140 0 +660 Cbm_Left_I_V_med 204 0 0 0 +661 Cbm_Left_I_V_mid 255 0 0 0 +662 Cbm_Left_VI_med 0 0 255 0 +663 Cbm_Left_VI_mid 30 144 255 0 +664 Cbm_Left_VI_lat 100 212 237 0 +665 Cbm_Left_CrusI_med 218 165 32 0 +666 Cbm_Left_CrusI_mid 255 215 0 0 +667 Cbm_Left_CrusI_lat 255 255 166 0 +668 Cbm_Left_CrusII_med 153 0 204 0 +669 Cbm_Left_CrusII_mid 153 141 209 0 +670 Cbm_Left_CrusII_lat 204 204 255 0 +671 Cbm_Left_7med 31 212 194 0 +672 Cbm_Left_7mid 3 255 237 0 +673 Cbm_Left_7lat 204 255 255 0 +674 Cbm_Left_8med 86 74 147 0 +675 Cbm_Left_8mid 114 114 190 0 +676 Cbm_Left_8lat 184 178 255 0 +677 Cbm_Left_PUNs 126 138 37 0 +678 Cbm_Left_TONs 189 197 117 0 +679 Cbm_Left_FLOs 240 230 140 0 + +701 CSF-FSL-FAST 120 18 134 0 +702 GrayMatter-FSL-FAST 205 62 78 0 +703 WhiteMatter-FSL-FAST 0 225 0 0 + +999 SUSPICIOUS 255 100 100 0 + +# Below is the color table for the cortical labels of the seg volume +# created by mri_aparc2aseg in which the aseg cortex label is replaced +# by the labels in the aparc. It also supports wm labels that will +# eventually be created by mri_aparc2aseg. Otherwise, the aseg labels +# do not change from above. The cortical labels are the same as in +# colortable_desikan_killiany.txt, except that left hemisphere has +# 1000 added to the index and the right has 2000 added. The label +# names are also prepended with ctx-lh or ctx-rh. The white matter +# labels are the same as in colortable_desikan_killiany.txt, except +# that left hemisphere has 3000 added to the index and the right has +# 4000 added. The label names are also prepended with wm-lh or wm-rh. +# Centrum semiovale is also labeled with 5001 (left) and 5002 (right). +# Even further below are the color tables for aparc.a2005s and aparc.a2009s. + +#No. Label Name: R G B A +1000 ctx-lh-unknown 25 5 25 0 +1001 ctx-lh-bankssts 25 100 40 0 +1002 ctx-lh-caudalanteriorcingulate 125 100 160 0 +1003 ctx-lh-caudalmiddlefrontal 100 25 0 0 +1004 ctx-lh-corpuscallosum 120 70 50 0 +1005 ctx-lh-cuneus 220 20 100 0 +1006 ctx-lh-entorhinal 220 20 10 0 +1007 ctx-lh-fusiform 180 220 140 0 +1008 ctx-lh-inferiorparietal 220 60 220 0 +1009 ctx-lh-inferiortemporal 180 40 120 0 +1010 ctx-lh-isthmuscingulate 140 20 140 0 +1011 ctx-lh-lateraloccipital 20 30 140 0 +1012 ctx-lh-lateralorbitofrontal 35 75 50 0 +1013 ctx-lh-lingual 225 140 140 0 +1014 ctx-lh-medialorbitofrontal 200 35 75 0 +1015 ctx-lh-middletemporal 160 100 50 0 +1016 ctx-lh-parahippocampal 20 220 60 0 +1017 ctx-lh-paracentral 60 220 60 0 +1018 ctx-lh-parsopercularis 220 180 140 0 +1019 ctx-lh-parsorbitalis 20 100 50 0 +1020 ctx-lh-parstriangularis 220 60 20 0 +1021 ctx-lh-pericalcarine 120 100 60 0 +1022 ctx-lh-postcentral 220 20 20 0 +1023 ctx-lh-posteriorcingulate 220 180 220 0 +1024 ctx-lh-precentral 60 20 220 0 +1025 ctx-lh-precuneus 160 140 180 0 +1026 ctx-lh-rostralanteriorcingulate 80 20 140 0 +1027 ctx-lh-rostralmiddlefrontal 75 50 125 0 +1028 ctx-lh-superiorfrontal 20 220 160 0 +1029 ctx-lh-superiorparietal 20 180 140 0 +1030 ctx-lh-superiortemporal 140 220 220 0 +1031 ctx-lh-supramarginal 80 160 20 0 +1032 ctx-lh-frontalpole 100 0 100 0 +1033 ctx-lh-temporalpole 70 70 70 0 +1034 ctx-lh-transversetemporal 150 150 200 0 +1035 ctx-lh-insula 255 192 32 0 + +2000 ctx-rh-unknown 25 5 25 0 +2001 ctx-rh-bankssts 25 100 40 0 +2002 ctx-rh-caudalanteriorcingulate 125 100 160 0 +2003 ctx-rh-caudalmiddlefrontal 100 25 0 0 +2004 ctx-rh-corpuscallosum 120 70 50 0 +2005 ctx-rh-cuneus 220 20 100 0 +2006 ctx-rh-entorhinal 220 20 10 0 +2007 ctx-rh-fusiform 180 220 140 0 +2008 ctx-rh-inferiorparietal 220 60 220 0 +2009 ctx-rh-inferiortemporal 180 40 120 0 +2010 ctx-rh-isthmuscingulate 140 20 140 0 +2011 ctx-rh-lateraloccipital 20 30 140 0 +2012 ctx-rh-lateralorbitofrontal 35 75 50 0 +2013 ctx-rh-lingual 225 140 140 0 +2014 ctx-rh-medialorbitofrontal 200 35 75 0 +2015 ctx-rh-middletemporal 160 100 50 0 +2016 ctx-rh-parahippocampal 20 220 60 0 +2017 ctx-rh-paracentral 60 220 60 0 +2018 ctx-rh-parsopercularis 220 180 140 0 +2019 ctx-rh-parsorbitalis 20 100 50 0 +2020 ctx-rh-parstriangularis 220 60 20 0 +2021 ctx-rh-pericalcarine 120 100 60 0 +2022 ctx-rh-postcentral 220 20 20 0 +2023 ctx-rh-posteriorcingulate 220 180 220 0 +2024 ctx-rh-precentral 60 20 220 0 +2025 ctx-rh-precuneus 160 140 180 0 +2026 ctx-rh-rostralanteriorcingulate 80 20 140 0 +2027 ctx-rh-rostralmiddlefrontal 75 50 125 0 +2028 ctx-rh-superiorfrontal 20 220 160 0 +2029 ctx-rh-superiorparietal 20 180 140 0 +2030 ctx-rh-superiortemporal 140 220 220 0 +2031 ctx-rh-supramarginal 80 160 20 0 +2032 ctx-rh-frontalpole 100 0 100 0 +2033 ctx-rh-temporalpole 70 70 70 0 +2034 ctx-rh-transversetemporal 150 150 200 0 +2035 ctx-rh-insula 255 192 32 0 + +3000 wm-lh-unknown 230 250 230 0 +3001 wm-lh-bankssts 230 155 215 0 +3002 wm-lh-caudalanteriorcingulate 130 155 95 0 +3003 wm-lh-caudalmiddlefrontal 155 230 255 0 +3004 wm-lh-corpuscallosum 135 185 205 0 +3005 wm-lh-cuneus 35 235 155 0 +3006 wm-lh-entorhinal 35 235 245 0 +3007 wm-lh-fusiform 75 35 115 0 +3008 wm-lh-inferiorparietal 35 195 35 0 +3009 wm-lh-inferiortemporal 75 215 135 0 +3010 wm-lh-isthmuscingulate 115 235 115 0 +3011 wm-lh-lateraloccipital 235 225 115 0 +3012 wm-lh-lateralorbitofrontal 220 180 205 0 +3013 wm-lh-lingual 30 115 115 0 +3014 wm-lh-medialorbitofrontal 55 220 180 0 +3015 wm-lh-middletemporal 95 155 205 0 +3016 wm-lh-parahippocampal 235 35 195 0 +3017 wm-lh-paracentral 195 35 195 0 +3018 wm-lh-parsopercularis 35 75 115 0 +3019 wm-lh-parsorbitalis 235 155 205 0 +3020 wm-lh-parstriangularis 35 195 235 0 +3021 wm-lh-pericalcarine 135 155 195 0 +3022 wm-lh-postcentral 35 235 235 0 +3023 wm-lh-posteriorcingulate 35 75 35 0 +3024 wm-lh-precentral 195 235 35 0 +3025 wm-lh-precuneus 95 115 75 0 +3026 wm-lh-rostralanteriorcingulate 175 235 115 0 +3027 wm-lh-rostralmiddlefrontal 180 205 130 0 +3028 wm-lh-superiorfrontal 235 35 95 0 +3029 wm-lh-superiorparietal 235 75 115 0 +3030 wm-lh-superiortemporal 115 35 35 0 +3031 wm-lh-supramarginal 175 95 235 0 +3032 wm-lh-frontalpole 155 255 155 0 +3033 wm-lh-temporalpole 185 185 185 0 +3034 wm-lh-transversetemporal 105 105 55 0 +3035 wm-lh-insula 254 191 31 0 + +4000 wm-rh-unknown 230 250 230 0 +4001 wm-rh-bankssts 230 155 215 0 +4002 wm-rh-caudalanteriorcingulate 130 155 95 0 +4003 wm-rh-caudalmiddlefrontal 155 230 255 0 +4004 wm-rh-corpuscallosum 135 185 205 0 +4005 wm-rh-cuneus 35 235 155 0 +4006 wm-rh-entorhinal 35 235 245 0 +4007 wm-rh-fusiform 75 35 115 0 +4008 wm-rh-inferiorparietal 35 195 35 0 +4009 wm-rh-inferiortemporal 75 215 135 0 +4010 wm-rh-isthmuscingulate 115 235 115 0 +4011 wm-rh-lateraloccipital 235 225 115 0 +4012 wm-rh-lateralorbitofrontal 220 180 205 0 +4013 wm-rh-lingual 30 115 115 0 +4014 wm-rh-medialorbitofrontal 55 220 180 0 +4015 wm-rh-middletemporal 95 155 205 0 +4016 wm-rh-parahippocampal 235 35 195 0 +4017 wm-rh-paracentral 195 35 195 0 +4018 wm-rh-parsopercularis 35 75 115 0 +4019 wm-rh-parsorbitalis 235 155 205 0 +4020 wm-rh-parstriangularis 35 195 235 0 +4021 wm-rh-pericalcarine 135 155 195 0 +4022 wm-rh-postcentral 35 235 235 0 +4023 wm-rh-posteriorcingulate 35 75 35 0 +4024 wm-rh-precentral 195 235 35 0 +4025 wm-rh-precuneus 95 115 75 0 +4026 wm-rh-rostralanteriorcingulate 175 235 115 0 +4027 wm-rh-rostralmiddlefrontal 180 205 130 0 +4028 wm-rh-superiorfrontal 235 35 95 0 +4029 wm-rh-superiorparietal 235 75 115 0 +4030 wm-rh-superiortemporal 115 35 35 0 +4031 wm-rh-supramarginal 175 95 235 0 +4032 wm-rh-frontalpole 155 255 155 0 +4033 wm-rh-temporalpole 185 185 185 0 +4034 wm-rh-transversetemporal 105 105 55 0 +4035 wm-rh-insula 254 191 31 0 + +# Below is the color table for the cortical labels of the seg volume +# created by mri_aparc2aseg (with --a2005s flag) in which the aseg +# cortex label is replaced by the labels in the aparc.a2005s. The +# cortical labels are the same as in Simple_surface_labels2005.txt, +# except that left hemisphere has 1100 added to the index and the +# right has 2100 added. The label names are also prepended with +# ctx-lh or ctx-rh. The aparc.a2009s labels are further below + +#No. Label Name: R G B A +1100 ctx-lh-Unknown 0 0 0 0 +1101 ctx-lh-Corpus_callosum 50 50 50 0 +1102 ctx-lh-G_and_S_Insula_ONLY_AVERAGE 180 20 30 0 +1103 ctx-lh-G_cingulate-Isthmus 60 25 25 0 +1104 ctx-lh-G_cingulate-Main_part 25 60 60 0 + +1200 ctx-lh-G_cingulate-caudal_ACC 25 60 61 0 +1201 ctx-lh-G_cingulate-rostral_ACC 25 90 60 0 +1202 ctx-lh-G_cingulate-posterior 25 120 60 0 + +1205 ctx-lh-S_cingulate-caudal_ACC 25 150 60 0 +1206 ctx-lh-S_cingulate-rostral_ACC 25 180 60 0 +1207 ctx-lh-S_cingulate-posterior 25 210 60 0 + +1210 ctx-lh-S_pericallosal-caudal 25 150 90 0 +1211 ctx-lh-S_pericallosal-rostral 25 180 90 0 +1212 ctx-lh-S_pericallosal-posterior 25 210 90 0 + +1105 ctx-lh-G_cuneus 180 20 20 0 +1106 ctx-lh-G_frontal_inf-Opercular_part 220 20 100 0 +1107 ctx-lh-G_frontal_inf-Orbital_part 140 60 60 0 +1108 ctx-lh-G_frontal_inf-Triangular_part 180 220 140 0 +1109 ctx-lh-G_frontal_middle 140 100 180 0 +1110 ctx-lh-G_frontal_superior 180 20 140 0 +1111 ctx-lh-G_frontomarginal 140 20 140 0 +1112 ctx-lh-G_insular_long 21 10 10 0 +1113 ctx-lh-G_insular_short 225 140 140 0 +1114 ctx-lh-G_and_S_occipital_inferior 23 60 180 0 +1115 ctx-lh-G_occipital_middle 180 60 180 0 +1116 ctx-lh-G_occipital_superior 20 220 60 0 +1117 ctx-lh-G_occipit-temp_lat-Or_fusiform 60 20 140 0 +1118 ctx-lh-G_occipit-temp_med-Lingual_part 220 180 140 0 +1119 ctx-lh-G_occipit-temp_med-Parahippocampal_part 65 100 20 0 +1120 ctx-lh-G_orbital 220 60 20 0 +1121 ctx-lh-G_paracentral 60 100 60 0 +1122 ctx-lh-G_parietal_inferior-Angular_part 20 60 220 0 +1123 ctx-lh-G_parietal_inferior-Supramarginal_part 100 100 60 0 +1124 ctx-lh-G_parietal_superior 220 180 220 0 +1125 ctx-lh-G_postcentral 20 180 140 0 +1126 ctx-lh-G_precentral 60 140 180 0 +1127 ctx-lh-G_precuneus 25 20 140 0 +1128 ctx-lh-G_rectus 20 60 100 0 +1129 ctx-lh-G_subcallosal 60 220 20 0 +1130 ctx-lh-G_subcentral 60 20 220 0 +1131 ctx-lh-G_temporal_inferior 220 220 100 0 +1132 ctx-lh-G_temporal_middle 180 60 60 0 +1133 ctx-lh-G_temp_sup-G_temp_transv_and_interm_S 60 60 220 0 +1134 ctx-lh-G_temp_sup-Lateral_aspect 220 60 220 0 +1135 ctx-lh-G_temp_sup-Planum_polare 65 220 60 0 +1136 ctx-lh-G_temp_sup-Planum_tempolare 25 140 20 0 +1137 ctx-lh-G_and_S_transverse_frontopolar 13 0 250 0 +1138 ctx-lh-Lat_Fissure-ant_sgt-ramus_horizontal 61 20 220 0 +1139 ctx-lh-Lat_Fissure-ant_sgt-ramus_vertical 61 20 60 0 +1140 ctx-lh-Lat_Fissure-post_sgt 61 60 100 0 +1141 ctx-lh-Medial_wall 25 25 25 0 +1142 ctx-lh-Pole_occipital 140 20 60 0 +1143 ctx-lh-Pole_temporal 220 180 20 0 +1144 ctx-lh-S_calcarine 63 180 180 0 +1145 ctx-lh-S_central 221 20 10 0 +1146 ctx-lh-S_central_insula 21 220 20 0 +1147 ctx-lh-S_cingulate-Main_part_and_Intracingulate 183 100 20 0 +1148 ctx-lh-S_cingulate-Marginalis_part 221 20 100 0 +1149 ctx-lh-S_circular_insula_anterior 221 60 140 0 +1150 ctx-lh-S_circular_insula_inferior 221 20 220 0 +1151 ctx-lh-S_circular_insula_superior 61 220 220 0 +1152 ctx-lh-S_collateral_transverse_ant 100 200 200 0 +1153 ctx-lh-S_collateral_transverse_post 10 200 200 0 +1154 ctx-lh-S_frontal_inferior 221 220 20 0 +1155 ctx-lh-S_frontal_middle 141 20 100 0 +1156 ctx-lh-S_frontal_superior 61 220 100 0 +1157 ctx-lh-S_frontomarginal 21 220 60 0 +1158 ctx-lh-S_intermedius_primus-Jensen 141 60 20 0 +1159 ctx-lh-S_intraparietal-and_Parietal_transverse 143 20 220 0 +1160 ctx-lh-S_occipital_anterior 61 20 180 0 +1161 ctx-lh-S_occipital_middle_and_Lunatus 101 60 220 0 +1162 ctx-lh-S_occipital_superior_and_transversalis 21 20 140 0 +1163 ctx-lh-S_occipito-temporal_lateral 221 140 20 0 +1164 ctx-lh-S_occipito-temporal_medial_and_S_Lingual 141 100 220 0 +1165 ctx-lh-S_orbital-H_shapped 101 20 20 0 +1166 ctx-lh-S_orbital_lateral 221 100 20 0 +1167 ctx-lh-S_orbital_medial-Or_olfactory 181 200 20 0 +1168 ctx-lh-S_paracentral 21 180 140 0 +1169 ctx-lh-S_parieto_occipital 101 100 180 0 +1170 ctx-lh-S_pericallosal 181 220 20 0 +1171 ctx-lh-S_postcentral 21 140 200 0 +1172 ctx-lh-S_precentral-Inferior-part 21 20 240 0 +1173 ctx-lh-S_precentral-Superior-part 21 20 200 0 +1174 ctx-lh-S_subcentral_ant 61 180 60 0 +1175 ctx-lh-S_subcentral_post 61 180 250 0 +1176 ctx-lh-S_suborbital 21 20 60 0 +1177 ctx-lh-S_subparietal 101 60 60 0 +1178 ctx-lh-S_supracingulate 21 220 220 0 +1179 ctx-lh-S_temporal_inferior 21 180 180 0 +1180 ctx-lh-S_temporal_superior 223 220 60 0 +1181 ctx-lh-S_temporal_transverse 221 60 60 0 + +2100 ctx-rh-Unknown 0 0 0 0 +2101 ctx-rh-Corpus_callosum 50 50 50 0 +2102 ctx-rh-G_and_S_Insula_ONLY_AVERAGE 180 20 30 0 +2103 ctx-rh-G_cingulate-Isthmus 60 25 25 0 +2104 ctx-rh-G_cingulate-Main_part 25 60 60 0 + +2105 ctx-rh-G_cuneus 180 20 20 0 +2106 ctx-rh-G_frontal_inf-Opercular_part 220 20 100 0 +2107 ctx-rh-G_frontal_inf-Orbital_part 140 60 60 0 +2108 ctx-rh-G_frontal_inf-Triangular_part 180 220 140 0 +2109 ctx-rh-G_frontal_middle 140 100 180 0 +2110 ctx-rh-G_frontal_superior 180 20 140 0 +2111 ctx-rh-G_frontomarginal 140 20 140 0 +2112 ctx-rh-G_insular_long 21 10 10 0 +2113 ctx-rh-G_insular_short 225 140 140 0 +2114 ctx-rh-G_and_S_occipital_inferior 23 60 180 0 +2115 ctx-rh-G_occipital_middle 180 60 180 0 +2116 ctx-rh-G_occipital_superior 20 220 60 0 +2117 ctx-rh-G_occipit-temp_lat-Or_fusiform 60 20 140 0 +2118 ctx-rh-G_occipit-temp_med-Lingual_part 220 180 140 0 +2119 ctx-rh-G_occipit-temp_med-Parahippocampal_part 65 100 20 0 +2120 ctx-rh-G_orbital 220 60 20 0 +2121 ctx-rh-G_paracentral 60 100 60 0 +2122 ctx-rh-G_parietal_inferior-Angular_part 20 60 220 0 +2123 ctx-rh-G_parietal_inferior-Supramarginal_part 100 100 60 0 +2124 ctx-rh-G_parietal_superior 220 180 220 0 +2125 ctx-rh-G_postcentral 20 180 140 0 +2126 ctx-rh-G_precentral 60 140 180 0 +2127 ctx-rh-G_precuneus 25 20 140 0 +2128 ctx-rh-G_rectus 20 60 100 0 +2129 ctx-rh-G_subcallosal 60 220 20 0 +2130 ctx-rh-G_subcentral 60 20 220 0 +2131 ctx-rh-G_temporal_inferior 220 220 100 0 +2132 ctx-rh-G_temporal_middle 180 60 60 0 +2133 ctx-rh-G_temp_sup-G_temp_transv_and_interm_S 60 60 220 0 +2134 ctx-rh-G_temp_sup-Lateral_aspect 220 60 220 0 +2135 ctx-rh-G_temp_sup-Planum_polare 65 220 60 0 +2136 ctx-rh-G_temp_sup-Planum_tempolare 25 140 20 0 +2137 ctx-rh-G_and_S_transverse_frontopolar 13 0 250 0 +2138 ctx-rh-Lat_Fissure-ant_sgt-ramus_horizontal 61 20 220 0 +2139 ctx-rh-Lat_Fissure-ant_sgt-ramus_vertical 61 20 60 0 +2140 ctx-rh-Lat_Fissure-post_sgt 61 60 100 0 +2141 ctx-rh-Medial_wall 25 25 25 0 +2142 ctx-rh-Pole_occipital 140 20 60 0 +2143 ctx-rh-Pole_temporal 220 180 20 0 +2144 ctx-rh-S_calcarine 63 180 180 0 +2145 ctx-rh-S_central 221 20 10 0 +2146 ctx-rh-S_central_insula 21 220 20 0 +2147 ctx-rh-S_cingulate-Main_part_and_Intracingulate 183 100 20 0 +2148 ctx-rh-S_cingulate-Marginalis_part 221 20 100 0 +2149 ctx-rh-S_circular_insula_anterior 221 60 140 0 +2150 ctx-rh-S_circular_insula_inferior 221 20 220 0 +2151 ctx-rh-S_circular_insula_superior 61 220 220 0 +2152 ctx-rh-S_collateral_transverse_ant 100 200 200 0 +2153 ctx-rh-S_collateral_transverse_post 10 200 200 0 +2154 ctx-rh-S_frontal_inferior 221 220 20 0 +2155 ctx-rh-S_frontal_middle 141 20 100 0 +2156 ctx-rh-S_frontal_superior 61 220 100 0 +2157 ctx-rh-S_frontomarginal 21 220 60 0 +2158 ctx-rh-S_intermedius_primus-Jensen 141 60 20 0 +2159 ctx-rh-S_intraparietal-and_Parietal_transverse 143 20 220 0 +2160 ctx-rh-S_occipital_anterior 61 20 180 0 +2161 ctx-rh-S_occipital_middle_and_Lunatus 101 60 220 0 +2162 ctx-rh-S_occipital_superior_and_transversalis 21 20 140 0 +2163 ctx-rh-S_occipito-temporal_lateral 221 140 20 0 +2164 ctx-rh-S_occipito-temporal_medial_and_S_Lingual 141 100 220 0 +2165 ctx-rh-S_orbital-H_shapped 101 20 20 0 +2166 ctx-rh-S_orbital_lateral 221 100 20 0 +2167 ctx-rh-S_orbital_medial-Or_olfactory 181 200 20 0 +2168 ctx-rh-S_paracentral 21 180 140 0 +2169 ctx-rh-S_parieto_occipital 101 100 180 0 +2170 ctx-rh-S_pericallosal 181 220 20 0 +2171 ctx-rh-S_postcentral 21 140 200 0 +2172 ctx-rh-S_precentral-Inferior-part 21 20 240 0 +2173 ctx-rh-S_precentral-Superior-part 21 20 200 0 +2174 ctx-rh-S_subcentral_ant 61 180 60 0 +2175 ctx-rh-S_subcentral_post 61 180 250 0 +2176 ctx-rh-S_suborbital 21 20 60 0 +2177 ctx-rh-S_subparietal 101 60 60 0 +2178 ctx-rh-S_supracingulate 21 220 220 0 +2179 ctx-rh-S_temporal_inferior 21 180 180 0 +2180 ctx-rh-S_temporal_superior 223 220 60 0 +2181 ctx-rh-S_temporal_transverse 221 60 60 0 + + +2200 ctx-rh-G_cingulate-caudal_ACC 25 60 61 0 +2201 ctx-rh-G_cingulate-rostral_ACC 25 90 60 0 +2202 ctx-rh-G_cingulate-posterior 25 120 60 0 + +2205 ctx-rh-S_cingulate-caudal_ACC 25 150 60 0 +2206 ctx-rh-S_cingulate-rostral_ACC 25 180 60 0 +2207 ctx-rh-S_cingulate-posterior 25 210 60 0 + +2210 ctx-rh-S_pericallosal-caudal 25 150 90 0 +2211 ctx-rh-S_pericallosal-rostral 25 180 90 0 +2212 ctx-rh-S_pericallosal-posterior 25 210 90 0 + +3100 wm-lh-Unknown 0 0 0 0 +3101 wm-lh-Corpus_callosum 50 50 50 0 +3102 wm-lh-G_and_S_Insula_ONLY_AVERAGE 180 20 30 0 +3103 wm-lh-G_cingulate-Isthmus 60 25 25 0 +3104 wm-lh-G_cingulate-Main_part 25 60 60 0 +3105 wm-lh-G_cuneus 180 20 20 0 +3106 wm-lh-G_frontal_inf-Opercular_part 220 20 100 0 +3107 wm-lh-G_frontal_inf-Orbital_part 140 60 60 0 +3108 wm-lh-G_frontal_inf-Triangular_part 180 220 140 0 +3109 wm-lh-G_frontal_middle 140 100 180 0 +3110 wm-lh-G_frontal_superior 180 20 140 0 +3111 wm-lh-G_frontomarginal 140 20 140 0 +3112 wm-lh-G_insular_long 21 10 10 0 +3113 wm-lh-G_insular_short 225 140 140 0 +3114 wm-lh-G_and_S_occipital_inferior 23 60 180 0 +3115 wm-lh-G_occipital_middle 180 60 180 0 +3116 wm-lh-G_occipital_superior 20 220 60 0 +3117 wm-lh-G_occipit-temp_lat-Or_fusiform 60 20 140 0 +3118 wm-lh-G_occipit-temp_med-Lingual_part 220 180 140 0 +3119 wm-lh-G_occipit-temp_med-Parahippocampal_part 65 100 20 0 +3120 wm-lh-G_orbital 220 60 20 0 +3121 wm-lh-G_paracentral 60 100 60 0 +3122 wm-lh-G_parietal_inferior-Angular_part 20 60 220 0 +3123 wm-lh-G_parietal_inferior-Supramarginal_part 100 100 60 0 +3124 wm-lh-G_parietal_superior 220 180 220 0 +3125 wm-lh-G_postcentral 20 180 140 0 +3126 wm-lh-G_precentral 60 140 180 0 +3127 wm-lh-G_precuneus 25 20 140 0 +3128 wm-lh-G_rectus 20 60 100 0 +3129 wm-lh-G_subcallosal 60 220 20 0 +3130 wm-lh-G_subcentral 60 20 220 0 +3131 wm-lh-G_temporal_inferior 220 220 100 0 +3132 wm-lh-G_temporal_middle 180 60 60 0 +3133 wm-lh-G_temp_sup-G_temp_transv_and_interm_S 60 60 220 0 +3134 wm-lh-G_temp_sup-Lateral_aspect 220 60 220 0 +3135 wm-lh-G_temp_sup-Planum_polare 65 220 60 0 +3136 wm-lh-G_temp_sup-Planum_tempolare 25 140 20 0 +3137 wm-lh-G_and_S_transverse_frontopolar 13 0 250 0 +3138 wm-lh-Lat_Fissure-ant_sgt-ramus_horizontal 61 20 220 0 +3139 wm-lh-Lat_Fissure-ant_sgt-ramus_vertical 61 20 60 0 +3140 wm-lh-Lat_Fissure-post_sgt 61 60 100 0 +3141 wm-lh-Medial_wall 25 25 25 0 +3142 wm-lh-Pole_occipital 140 20 60 0 +3143 wm-lh-Pole_temporal 220 180 20 0 +3144 wm-lh-S_calcarine 63 180 180 0 +3145 wm-lh-S_central 221 20 10 0 +3146 wm-lh-S_central_insula 21 220 20 0 +3147 wm-lh-S_cingulate-Main_part_and_Intracingulate 183 100 20 0 +3148 wm-lh-S_cingulate-Marginalis_part 221 20 100 0 +3149 wm-lh-S_circular_insula_anterior 221 60 140 0 +3150 wm-lh-S_circular_insula_inferior 221 20 220 0 +3151 wm-lh-S_circular_insula_superior 61 220 220 0 +3152 wm-lh-S_collateral_transverse_ant 100 200 200 0 +3153 wm-lh-S_collateral_transverse_post 10 200 200 0 +3154 wm-lh-S_frontal_inferior 221 220 20 0 +3155 wm-lh-S_frontal_middle 141 20 100 0 +3156 wm-lh-S_frontal_superior 61 220 100 0 +3157 wm-lh-S_frontomarginal 21 220 60 0 +3158 wm-lh-S_intermedius_primus-Jensen 141 60 20 0 +3159 wm-lh-S_intraparietal-and_Parietal_transverse 143 20 220 0 +3160 wm-lh-S_occipital_anterior 61 20 180 0 +3161 wm-lh-S_occipital_middle_and_Lunatus 101 60 220 0 +3162 wm-lh-S_occipital_superior_and_transversalis 21 20 140 0 +3163 wm-lh-S_occipito-temporal_lateral 221 140 20 0 +3164 wm-lh-S_occipito-temporal_medial_and_S_Lingual 141 100 220 0 +3165 wm-lh-S_orbital-H_shapped 101 20 20 0 +3166 wm-lh-S_orbital_lateral 221 100 20 0 +3167 wm-lh-S_orbital_medial-Or_olfactory 181 200 20 0 +3168 wm-lh-S_paracentral 21 180 140 0 +3169 wm-lh-S_parieto_occipital 101 100 180 0 +3170 wm-lh-S_pericallosal 181 220 20 0 +3171 wm-lh-S_postcentral 21 140 200 0 +3172 wm-lh-S_precentral-Inferior-part 21 20 240 0 +3173 wm-lh-S_precentral-Superior-part 21 20 200 0 +3174 wm-lh-S_subcentral_ant 61 180 60 0 +3175 wm-lh-S_subcentral_post 61 180 250 0 +3176 wm-lh-S_suborbital 21 20 60 0 +3177 wm-lh-S_subparietal 101 60 60 0 +3178 wm-lh-S_supracingulate 21 220 220 0 +3179 wm-lh-S_temporal_inferior 21 180 180 0 +3180 wm-lh-S_temporal_superior 223 220 60 0 +3181 wm-lh-S_temporal_transverse 221 60 60 0 + +4100 wm-rh-Unknown 0 0 0 0 +4101 wm-rh-Corpus_callosum 50 50 50 0 +4102 wm-rh-G_and_S_Insula_ONLY_AVERAGE 180 20 30 0 +4103 wm-rh-G_cingulate-Isthmus 60 25 25 0 +4104 wm-rh-G_cingulate-Main_part 25 60 60 0 +4105 wm-rh-G_cuneus 180 20 20 0 +4106 wm-rh-G_frontal_inf-Opercular_part 220 20 100 0 +4107 wm-rh-G_frontal_inf-Orbital_part 140 60 60 0 +4108 wm-rh-G_frontal_inf-Triangular_part 180 220 140 0 +4109 wm-rh-G_frontal_middle 140 100 180 0 +4110 wm-rh-G_frontal_superior 180 20 140 0 +4111 wm-rh-G_frontomarginal 140 20 140 0 +4112 wm-rh-G_insular_long 21 10 10 0 +4113 wm-rh-G_insular_short 225 140 140 0 +4114 wm-rh-G_and_S_occipital_inferior 23 60 180 0 +4115 wm-rh-G_occipital_middle 180 60 180 0 +4116 wm-rh-G_occipital_superior 20 220 60 0 +4117 wm-rh-G_occipit-temp_lat-Or_fusiform 60 20 140 0 +4118 wm-rh-G_occipit-temp_med-Lingual_part 220 180 140 0 +4119 wm-rh-G_occipit-temp_med-Parahippocampal_part 65 100 20 0 +4120 wm-rh-G_orbital 220 60 20 0 +4121 wm-rh-G_paracentral 60 100 60 0 +4122 wm-rh-G_parietal_inferior-Angular_part 20 60 220 0 +4123 wm-rh-G_parietal_inferior-Supramarginal_part 100 100 60 0 +4124 wm-rh-G_parietal_superior 220 180 220 0 +4125 wm-rh-G_postcentral 20 180 140 0 +4126 wm-rh-G_precentral 60 140 180 0 +4127 wm-rh-G_precuneus 25 20 140 0 +4128 wm-rh-G_rectus 20 60 100 0 +4129 wm-rh-G_subcallosal 60 220 20 0 +4130 wm-rh-G_subcentral 60 20 220 0 +4131 wm-rh-G_temporal_inferior 220 220 100 0 +4132 wm-rh-G_temporal_middle 180 60 60 0 +4133 wm-rh-G_temp_sup-G_temp_transv_and_interm_S 60 60 220 0 +4134 wm-rh-G_temp_sup-Lateral_aspect 220 60 220 0 +4135 wm-rh-G_temp_sup-Planum_polare 65 220 60 0 +4136 wm-rh-G_temp_sup-Planum_tempolare 25 140 20 0 +4137 wm-rh-G_and_S_transverse_frontopolar 13 0 250 0 +4138 wm-rh-Lat_Fissure-ant_sgt-ramus_horizontal 61 20 220 0 +4139 wm-rh-Lat_Fissure-ant_sgt-ramus_vertical 61 20 60 0 +4140 wm-rh-Lat_Fissure-post_sgt 61 60 100 0 +4141 wm-rh-Medial_wall 25 25 25 0 +4142 wm-rh-Pole_occipital 140 20 60 0 +4143 wm-rh-Pole_temporal 220 180 20 0 +4144 wm-rh-S_calcarine 63 180 180 0 +4145 wm-rh-S_central 221 20 10 0 +4146 wm-rh-S_central_insula 21 220 20 0 +4147 wm-rh-S_cingulate-Main_part_and_Intracingulate 183 100 20 0 +4148 wm-rh-S_cingulate-Marginalis_part 221 20 100 0 +4149 wm-rh-S_circular_insula_anterior 221 60 140 0 +4150 wm-rh-S_circular_insula_inferior 221 20 220 0 +4151 wm-rh-S_circular_insula_superior 61 220 220 0 +4152 wm-rh-S_collateral_transverse_ant 100 200 200 0 +4153 wm-rh-S_collateral_transverse_post 10 200 200 0 +4154 wm-rh-S_frontal_inferior 221 220 20 0 +4155 wm-rh-S_frontal_middle 141 20 100 0 +4156 wm-rh-S_frontal_superior 61 220 100 0 +4157 wm-rh-S_frontomarginal 21 220 60 0 +4158 wm-rh-S_intermedius_primus-Jensen 141 60 20 0 +4159 wm-rh-S_intraparietal-and_Parietal_transverse 143 20 220 0 +4160 wm-rh-S_occipital_anterior 61 20 180 0 +4161 wm-rh-S_occipital_middle_and_Lunatus 101 60 220 0 +4162 wm-rh-S_occipital_superior_and_transversalis 21 20 140 0 +4163 wm-rh-S_occipito-temporal_lateral 221 140 20 0 +4164 wm-rh-S_occipito-temporal_medial_and_S_Lingual 141 100 220 0 +4165 wm-rh-S_orbital-H_shapped 101 20 20 0 +4166 wm-rh-S_orbital_lateral 221 100 20 0 +4167 wm-rh-S_orbital_medial-Or_olfactory 181 200 20 0 +4168 wm-rh-S_paracentral 21 180 140 0 +4169 wm-rh-S_parieto_occipital 101 100 180 0 +4170 wm-rh-S_pericallosal 181 220 20 0 +4171 wm-rh-S_postcentral 21 140 200 0 +4172 wm-rh-S_precentral-Inferior-part 21 20 240 0 +4173 wm-rh-S_precentral-Superior-part 21 20 200 0 +4174 wm-rh-S_subcentral_ant 61 180 60 0 +4175 wm-rh-S_subcentral_post 61 180 250 0 +4176 wm-rh-S_suborbital 21 20 60 0 +4177 wm-rh-S_subparietal 101 60 60 0 +4178 wm-rh-S_supracingulate 21 220 220 0 +4179 wm-rh-S_temporal_inferior 21 180 180 0 +4180 wm-rh-S_temporal_superior 223 220 60 0 +4181 wm-rh-S_temporal_transverse 221 60 60 0 + +5001 Left-UnsegmentedWhiteMatter 20 30 40 0 +5002 Right-UnsegmentedWhiteMatter 20 30 40 0 + +# Below is the color table for white-matter pathways produced by dmri_paths + +#No. Label Name: R G B A +# +5100 fmajor 204 102 102 0 +5101 fminor 204 102 102 0 +# +5102 lh.atr 255 255 102 0 +5103 lh.cab 153 204 0 0 +5104 lh.ccg 0 153 153 0 +5105 lh.cst 204 153 255 0 +5106 lh.ilf 255 153 51 0 +5107 lh.slfp 204 204 204 0 +5108 lh.slft 153 255 255 0 +5109 lh.unc 102 153 255 0 +# +5110 rh.atr 255 255 102 0 +5111 rh.cab 153 204 0 0 +5112 rh.ccg 0 153 153 0 +5113 rh.cst 204 153 255 0 +5114 rh.ilf 255 153 51 0 +5115 rh.slfp 204 204 204 0 +5116 rh.slft 153 255 255 0 +5117 rh.unc 102 153 255 0 + +# These are the same tracula labels as above in human-readable form +5200 CC-ForcepsMajor 204 102 102 0 +5201 CC-ForcepsMinor 204 102 102 0 +5202 LAntThalRadiation 255 255 102 0 +5203 LCingulumAngBundle 153 204 0 0 +5204 LCingulumCingGyrus 0 153 153 0 +5205 LCorticospinalTract 204 153 255 0 +5206 LInfLongFas 255 153 51 0 +5207 LSupLongFasParietal 204 204 204 0 +5208 LSupLongFasTemporal 153 255 255 0 +5209 LUncinateFas 102 153 255 0 +5210 RAntThalRadiation 255 255 102 0 +5211 RCingulumAngBundle 153 204 0 0 +5212 RCingulumCingGyrus 0 153 153 0 +5213 RCorticospinalTract 204 153 255 0 +5214 RInfLongFas 255 153 51 0 +5215 RSupLongFasParietal 204 204 204 0 +5216 RSupLongFasTemporal 153 255 255 0 +5217 RUncinateFas 102 153 255 0 + +######################################## + +6000 CST-orig 0 255 0 0 +6001 CST-hammer 255 255 0 0 +6002 CST-CVS 0 255 255 0 +6003 CST-flirt 0 0 255 0 + +6010 Left-SLF1 236 16 231 0 +6020 Right-SLF1 237 18 232 0 + +6030 Left-SLF3 236 13 227 0 +6040 Right-SLF3 236 17 228 0 + +6050 Left-CST 1 255 1 0 +6060 Right-CST 2 255 1 0 + +6070 Left-SLF2 236 14 230 0 +6080 Right-SLF2 237 14 230 0 + +#No. Label Name: R G B A + +7001 Lateral-nucleus 72 132 181 0 +7002 Basolateral-nucleus 243 243 243 0 +7003 Basal-nucleus 207 63 79 0 +7004 Centromedial-nucleus 121 20 135 0 +7005 Central-nucleus 197 60 248 0 +7006 Medial-nucleus 2 149 2 0 +7007 Cortical-nucleus 221 249 166 0 +7008 Accessory-Basal-nucleus 232 146 35 0 +7009 Corticoamygdaloid-transitio 20 60 120 0 +7010 Anterior-amygdaloid-area-AAA 250 250 0 0 +7011 Fusion-amygdala-HP-FAH 122 187 222 0 +7012 Hippocampal-amygdala-transition-HATA 237 12 177 0 +7013 Endopiriform-nucleus 10 49 255 0 +7014 Lateral-nucleus-olfactory-tract 205 184 144 0 +7015 Paralaminar-nucleus 45 205 165 0 +7016 Intercalated-nucleus 117 160 175 0 +7017 Prepiriform-cortex 221 217 21 0 +7018 Periamygdaloid-cortex 20 60 120 0 +7019 Envelope-Amygdala 141 21 100 0 +7020 Extranuclear-Amydala 225 140 141 0 + +7100 Brainstem-inferior-colliculus 42 201 168 0 +7101 Brainstem-cochlear-nucleus 168 104 162 0 + +8001 Thalamus-Anterior 74 130 181 0 +8002 Thalamus-Ventral-anterior 242 241 240 0 +8003 Thalamus-Lateral-dorsal 206 65 78 0 +8004 Thalamus-Lateral-posterior 120 21 133 0 +8005 Thalamus-Ventral-lateral 195 61 246 0 +8006 Thalamus-Ventral-posterior-medial 3 147 6 0 +8007 Thalamus-Ventral-posterior-lateral 220 251 163 0 +8008 Thalamus-intralaminar 232 146 33 0 +8009 Thalamus-centromedian 4 114 14 0 +8010 Thalamus-mediodorsal 121 184 220 0 +8011 Thalamus-medial 235 11 175 0 +8012 Thalamus-pulvinar 12 46 250 0 +8013 Thalamus-lateral-geniculate 203 182 143 0 +8014 Thalamus-medial-geniculate 42 204 167 0 + +# +# Labels for thalamus parcellation using probabilistic tractography. See: +# Functional--Anatomical Validation and Individual Variation of Diffusion +# Tractography-based Segmentation of the Human Thalamus; Cerebral Cortex +# January 2005;15:31--39, doi:10.1093/cercor/bhh105, Advance Access +# publication July 6, 2004 +# + +#No. Label Name: R G B A +9000 ctx-lh-prefrontal 30 5 30 0 +9001 ctx-lh-primary-motor 30 100 45 0 +9002 ctx-lh-premotor 130 100 165 0 +9003 ctx-lh-temporal 105 25 5 0 +9004 ctx-lh-posterior-parietal 125 70 55 0 +9005 ctx-lh-prim-sec-somatosensory 225 20 105 0 +9006 ctx-lh-occipital 225 20 15 0 + +9500 ctx-rh-prefrontal 30 55 30 0 +9501 ctx-rh-primary-motor 30 150 45 0 +9502 ctx-rh-premotor 130 150 165 0 +9503 ctx-rh-temporal 105 75 5 0 +9504 ctx-rh-posterior-parietal 125 120 55 0 +9505 ctx-rh-prim-sec-somatosensory 225 70 105 0 +9506 ctx-rh-occipital 225 70 15 0 + +# Below is the color table for the cortical labels of the seg volume +# created by mri_aparc2aseg (with --a2009s flag) in which the aseg +# cortex label is replaced by the labels in the aparc.a2009s. The +# cortical labels are the same as in Simple_surface_labels2009.txt, +# except that left hemisphere has 11100 added to the index and the +# right has 12100 added. The label names are also prepended with +# ctx_lh_, ctx_rh_, wm_lh_ and wm_rh_ (note usage of _ instead of - +# to differentiate from a2005s labels). + +#No. Label Name: R G B A +11100 ctx_lh_Unknown 0 0 0 0 +11101 ctx_lh_G_and_S_frontomargin 23 220 60 0 +11102 ctx_lh_G_and_S_occipital_inf 23 60 180 0 +11103 ctx_lh_G_and_S_paracentral 63 100 60 0 +11104 ctx_lh_G_and_S_subcentral 63 20 220 0 +11105 ctx_lh_G_and_S_transv_frontopol 13 0 250 0 +11106 ctx_lh_G_and_S_cingul-Ant 26 60 0 0 +11107 ctx_lh_G_and_S_cingul-Mid-Ant 26 60 75 0 +11108 ctx_lh_G_and_S_cingul-Mid-Post 26 60 150 0 +11109 ctx_lh_G_cingul-Post-dorsal 25 60 250 0 +11110 ctx_lh_G_cingul-Post-ventral 60 25 25 0 +11111 ctx_lh_G_cuneus 180 20 20 0 +11112 ctx_lh_G_front_inf-Opercular 220 20 100 0 +11113 ctx_lh_G_front_inf-Orbital 140 60 60 0 +11114 ctx_lh_G_front_inf-Triangul 180 220 140 0 +11115 ctx_lh_G_front_middle 140 100 180 0 +11116 ctx_lh_G_front_sup 180 20 140 0 +11117 ctx_lh_G_Ins_lg_and_S_cent_ins 23 10 10 0 +11118 ctx_lh_G_insular_short 225 140 140 0 +11119 ctx_lh_G_occipital_middle 180 60 180 0 +11120 ctx_lh_G_occipital_sup 20 220 60 0 +11121 ctx_lh_G_oc-temp_lat-fusifor 60 20 140 0 +11122 ctx_lh_G_oc-temp_med-Lingual 220 180 140 0 +11123 ctx_lh_G_oc-temp_med-Parahip 65 100 20 0 +11124 ctx_lh_G_orbital 220 60 20 0 +11125 ctx_lh_G_pariet_inf-Angular 20 60 220 0 +11126 ctx_lh_G_pariet_inf-Supramar 100 100 60 0 +11127 ctx_lh_G_parietal_sup 220 180 220 0 +11128 ctx_lh_G_postcentral 20 180 140 0 +11129 ctx_lh_G_precentral 60 140 180 0 +11130 ctx_lh_G_precuneus 25 20 140 0 +11131 ctx_lh_G_rectus 20 60 100 0 +11132 ctx_lh_G_subcallosal 60 220 20 0 +11133 ctx_lh_G_temp_sup-G_T_transv 60 60 220 0 +11134 ctx_lh_G_temp_sup-Lateral 220 60 220 0 +11135 ctx_lh_G_temp_sup-Plan_polar 65 220 60 0 +11136 ctx_lh_G_temp_sup-Plan_tempo 25 140 20 0 +11137 ctx_lh_G_temporal_inf 220 220 100 0 +11138 ctx_lh_G_temporal_middle 180 60 60 0 +11139 ctx_lh_Lat_Fis-ant-Horizont 61 20 220 0 +11140 ctx_lh_Lat_Fis-ant-Vertical 61 20 60 0 +11141 ctx_lh_Lat_Fis-post 61 60 100 0 +11142 ctx_lh_Medial_wall 25 25 25 0 +11143 ctx_lh_Pole_occipital 140 20 60 0 +11144 ctx_lh_Pole_temporal 220 180 20 0 +11145 ctx_lh_S_calcarine 63 180 180 0 +11146 ctx_lh_S_central 221 20 10 0 +11147 ctx_lh_S_cingul-Marginalis 221 20 100 0 +11148 ctx_lh_S_circular_insula_ant 221 60 140 0 +11149 ctx_lh_S_circular_insula_inf 221 20 220 0 +11150 ctx_lh_S_circular_insula_sup 61 220 220 0 +11151 ctx_lh_S_collat_transv_ant 100 200 200 0 +11152 ctx_lh_S_collat_transv_post 10 200 200 0 +11153 ctx_lh_S_front_inf 221 220 20 0 +11154 ctx_lh_S_front_middle 141 20 100 0 +11155 ctx_lh_S_front_sup 61 220 100 0 +11156 ctx_lh_S_interm_prim-Jensen 141 60 20 0 +11157 ctx_lh_S_intrapariet_and_P_trans 143 20 220 0 +11158 ctx_lh_S_oc_middle_and_Lunatus 101 60 220 0 +11159 ctx_lh_S_oc_sup_and_transversal 21 20 140 0 +11160 ctx_lh_S_occipital_ant 61 20 180 0 +11161 ctx_lh_S_oc-temp_lat 221 140 20 0 +11162 ctx_lh_S_oc-temp_med_and_Lingual 141 100 220 0 +11163 ctx_lh_S_orbital_lateral 221 100 20 0 +11164 ctx_lh_S_orbital_med-olfact 181 200 20 0 +11165 ctx_lh_S_orbital-H_Shaped 101 20 20 0 +11166 ctx_lh_S_parieto_occipital 101 100 180 0 +11167 ctx_lh_S_pericallosal 181 220 20 0 +11168 ctx_lh_S_postcentral 21 140 200 0 +11169 ctx_lh_S_precentral-inf-part 21 20 240 0 +11170 ctx_lh_S_precentral-sup-part 21 20 200 0 +11171 ctx_lh_S_suborbital 21 20 60 0 +11172 ctx_lh_S_subparietal 101 60 60 0 +11173 ctx_lh_S_temporal_inf 21 180 180 0 +11174 ctx_lh_S_temporal_sup 223 220 60 0 +11175 ctx_lh_S_temporal_transverse 221 60 60 0 + +12100 ctx_rh_Unknown 0 0 0 0 +12101 ctx_rh_G_and_S_frontomargin 23 220 60 0 +12102 ctx_rh_G_and_S_occipital_inf 23 60 180 0 +12103 ctx_rh_G_and_S_paracentral 63 100 60 0 +12104 ctx_rh_G_and_S_subcentral 63 20 220 0 +12105 ctx_rh_G_and_S_transv_frontopol 13 0 250 0 +12106 ctx_rh_G_and_S_cingul-Ant 26 60 0 0 +12107 ctx_rh_G_and_S_cingul-Mid-Ant 26 60 75 0 +12108 ctx_rh_G_and_S_cingul-Mid-Post 26 60 150 0 +12109 ctx_rh_G_cingul-Post-dorsal 25 60 250 0 +12110 ctx_rh_G_cingul-Post-ventral 60 25 25 0 +12111 ctx_rh_G_cuneus 180 20 20 0 +12112 ctx_rh_G_front_inf-Opercular 220 20 100 0 +12113 ctx_rh_G_front_inf-Orbital 140 60 60 0 +12114 ctx_rh_G_front_inf-Triangul 180 220 140 0 +12115 ctx_rh_G_front_middle 140 100 180 0 +12116 ctx_rh_G_front_sup 180 20 140 0 +12117 ctx_rh_G_Ins_lg_and_S_cent_ins 23 10 10 0 +12118 ctx_rh_G_insular_short 225 140 140 0 +12119 ctx_rh_G_occipital_middle 180 60 180 0 +12120 ctx_rh_G_occipital_sup 20 220 60 0 +12121 ctx_rh_G_oc-temp_lat-fusifor 60 20 140 0 +12122 ctx_rh_G_oc-temp_med-Lingual 220 180 140 0 +12123 ctx_rh_G_oc-temp_med-Parahip 65 100 20 0 +12124 ctx_rh_G_orbital 220 60 20 0 +12125 ctx_rh_G_pariet_inf-Angular 20 60 220 0 +12126 ctx_rh_G_pariet_inf-Supramar 100 100 60 0 +12127 ctx_rh_G_parietal_sup 220 180 220 0 +12128 ctx_rh_G_postcentral 20 180 140 0 +12129 ctx_rh_G_precentral 60 140 180 0 +12130 ctx_rh_G_precuneus 25 20 140 0 +12131 ctx_rh_G_rectus 20 60 100 0 +12132 ctx_rh_G_subcallosal 60 220 20 0 +12133 ctx_rh_G_temp_sup-G_T_transv 60 60 220 0 +12134 ctx_rh_G_temp_sup-Lateral 220 60 220 0 +12135 ctx_rh_G_temp_sup-Plan_polar 65 220 60 0 +12136 ctx_rh_G_temp_sup-Plan_tempo 25 140 20 0 +12137 ctx_rh_G_temporal_inf 220 220 100 0 +12138 ctx_rh_G_temporal_middle 180 60 60 0 +12139 ctx_rh_Lat_Fis-ant-Horizont 61 20 220 0 +12140 ctx_rh_Lat_Fis-ant-Vertical 61 20 60 0 +12141 ctx_rh_Lat_Fis-post 61 60 100 0 +12142 ctx_rh_Medial_wall 25 25 25 0 +12143 ctx_rh_Pole_occipital 140 20 60 0 +12144 ctx_rh_Pole_temporal 220 180 20 0 +12145 ctx_rh_S_calcarine 63 180 180 0 +12146 ctx_rh_S_central 221 20 10 0 +12147 ctx_rh_S_cingul-Marginalis 221 20 100 0 +12148 ctx_rh_S_circular_insula_ant 221 60 140 0 +12149 ctx_rh_S_circular_insula_inf 221 20 220 0 +12150 ctx_rh_S_circular_insula_sup 61 220 220 0 +12151 ctx_rh_S_collat_transv_ant 100 200 200 0 +12152 ctx_rh_S_collat_transv_post 10 200 200 0 +12153 ctx_rh_S_front_inf 221 220 20 0 +12154 ctx_rh_S_front_middle 141 20 100 0 +12155 ctx_rh_S_front_sup 61 220 100 0 +12156 ctx_rh_S_interm_prim-Jensen 141 60 20 0 +12157 ctx_rh_S_intrapariet_and_P_trans 143 20 220 0 +12158 ctx_rh_S_oc_middle_and_Lunatus 101 60 220 0 +12159 ctx_rh_S_oc_sup_and_transversal 21 20 140 0 +12160 ctx_rh_S_occipital_ant 61 20 180 0 +12161 ctx_rh_S_oc-temp_lat 221 140 20 0 +12162 ctx_rh_S_oc-temp_med_and_Lingual 141 100 220 0 +12163 ctx_rh_S_orbital_lateral 221 100 20 0 +12164 ctx_rh_S_orbital_med-olfact 181 200 20 0 +12165 ctx_rh_S_orbital-H_Shaped 101 20 20 0 +12166 ctx_rh_S_parieto_occipital 101 100 180 0 +12167 ctx_rh_S_pericallosal 181 220 20 0 +12168 ctx_rh_S_postcentral 21 140 200 0 +12169 ctx_rh_S_precentral-inf-part 21 20 240 0 +12170 ctx_rh_S_precentral-sup-part 21 20 200 0 +12171 ctx_rh_S_suborbital 21 20 60 0 +12172 ctx_rh_S_subparietal 101 60 60 0 +12173 ctx_rh_S_temporal_inf 21 180 180 0 +12174 ctx_rh_S_temporal_sup 223 220 60 0 +12175 ctx_rh_S_temporal_transverse 221 60 60 0 + +#No. Label Name: R G B A +13100 wm_lh_Unknown 0 0 0 0 +13101 wm_lh_G_and_S_frontomargin 23 220 60 0 +13102 wm_lh_G_and_S_occipital_inf 23 60 180 0 +13103 wm_lh_G_and_S_paracentral 63 100 60 0 +13104 wm_lh_G_and_S_subcentral 63 20 220 0 +13105 wm_lh_G_and_S_transv_frontopol 13 0 250 0 +13106 wm_lh_G_and_S_cingul-Ant 26 60 0 0 +13107 wm_lh_G_and_S_cingul-Mid-Ant 26 60 75 0 +13108 wm_lh_G_and_S_cingul-Mid-Post 26 60 150 0 +13109 wm_lh_G_cingul-Post-dorsal 25 60 250 0 +13110 wm_lh_G_cingul-Post-ventral 60 25 25 0 +13111 wm_lh_G_cuneus 180 20 20 0 +13112 wm_lh_G_front_inf-Opercular 220 20 100 0 +13113 wm_lh_G_front_inf-Orbital 140 60 60 0 +13114 wm_lh_G_front_inf-Triangul 180 220 140 0 +13115 wm_lh_G_front_middle 140 100 180 0 +13116 wm_lh_G_front_sup 180 20 140 0 +13117 wm_lh_G_Ins_lg_and_S_cent_ins 23 10 10 0 +13118 wm_lh_G_insular_short 225 140 140 0 +13119 wm_lh_G_occipital_middle 180 60 180 0 +13120 wm_lh_G_occipital_sup 20 220 60 0 +13121 wm_lh_G_oc-temp_lat-fusifor 60 20 140 0 +13122 wm_lh_G_oc-temp_med-Lingual 220 180 140 0 +13123 wm_lh_G_oc-temp_med-Parahip 65 100 20 0 +13124 wm_lh_G_orbital 220 60 20 0 +13125 wm_lh_G_pariet_inf-Angular 20 60 220 0 +13126 wm_lh_G_pariet_inf-Supramar 100 100 60 0 +13127 wm_lh_G_parietal_sup 220 180 220 0 +13128 wm_lh_G_postcentral 20 180 140 0 +13129 wm_lh_G_precentral 60 140 180 0 +13130 wm_lh_G_precuneus 25 20 140 0 +13131 wm_lh_G_rectus 20 60 100 0 +13132 wm_lh_G_subcallosal 60 220 20 0 +13133 wm_lh_G_temp_sup-G_T_transv 60 60 220 0 +13134 wm_lh_G_temp_sup-Lateral 220 60 220 0 +13135 wm_lh_G_temp_sup-Plan_polar 65 220 60 0 +13136 wm_lh_G_temp_sup-Plan_tempo 25 140 20 0 +13137 wm_lh_G_temporal_inf 220 220 100 0 +13138 wm_lh_G_temporal_middle 180 60 60 0 +13139 wm_lh_Lat_Fis-ant-Horizont 61 20 220 0 +13140 wm_lh_Lat_Fis-ant-Vertical 61 20 60 0 +13141 wm_lh_Lat_Fis-post 61 60 100 0 +13142 wm_lh_Medial_wall 25 25 25 0 +13143 wm_lh_Pole_occipital 140 20 60 0 +13144 wm_lh_Pole_temporal 220 180 20 0 +13145 wm_lh_S_calcarine 63 180 180 0 +13146 wm_lh_S_central 221 20 10 0 +13147 wm_lh_S_cingul-Marginalis 221 20 100 0 +13148 wm_lh_S_circular_insula_ant 221 60 140 0 +13149 wm_lh_S_circular_insula_inf 221 20 220 0 +13150 wm_lh_S_circular_insula_sup 61 220 220 0 +13151 wm_lh_S_collat_transv_ant 100 200 200 0 +13152 wm_lh_S_collat_transv_post 10 200 200 0 +13153 wm_lh_S_front_inf 221 220 20 0 +13154 wm_lh_S_front_middle 141 20 100 0 +13155 wm_lh_S_front_sup 61 220 100 0 +13156 wm_lh_S_interm_prim-Jensen 141 60 20 0 +13157 wm_lh_S_intrapariet_and_P_trans 143 20 220 0 +13158 wm_lh_S_oc_middle_and_Lunatus 101 60 220 0 +13159 wm_lh_S_oc_sup_and_transversal 21 20 140 0 +13160 wm_lh_S_occipital_ant 61 20 180 0 +13161 wm_lh_S_oc-temp_lat 221 140 20 0 +13162 wm_lh_S_oc-temp_med_and_Lingual 141 100 220 0 +13163 wm_lh_S_orbital_lateral 221 100 20 0 +13164 wm_lh_S_orbital_med-olfact 181 200 20 0 +13165 wm_lh_S_orbital-H_Shaped 101 20 20 0 +13166 wm_lh_S_parieto_occipital 101 100 180 0 +13167 wm_lh_S_pericallosal 181 220 20 0 +13168 wm_lh_S_postcentral 21 140 200 0 +13169 wm_lh_S_precentral-inf-part 21 20 240 0 +13170 wm_lh_S_precentral-sup-part 21 20 200 0 +13171 wm_lh_S_suborbital 21 20 60 0 +13172 wm_lh_S_subparietal 101 60 60 0 +13173 wm_lh_S_temporal_inf 21 180 180 0 +13174 wm_lh_S_temporal_sup 223 220 60 0 +13175 wm_lh_S_temporal_transverse 221 60 60 0 + +14100 wm_rh_Unknown 0 0 0 0 +14101 wm_rh_G_and_S_frontomargin 23 220 60 0 +14102 wm_rh_G_and_S_occipital_inf 23 60 180 0 +14103 wm_rh_G_and_S_paracentral 63 100 60 0 +14104 wm_rh_G_and_S_subcentral 63 20 220 0 +14105 wm_rh_G_and_S_transv_frontopol 13 0 250 0 +14106 wm_rh_G_and_S_cingul-Ant 26 60 0 0 +14107 wm_rh_G_and_S_cingul-Mid-Ant 26 60 75 0 +14108 wm_rh_G_and_S_cingul-Mid-Post 26 60 150 0 +14109 wm_rh_G_cingul-Post-dorsal 25 60 250 0 +14110 wm_rh_G_cingul-Post-ventral 60 25 25 0 +14111 wm_rh_G_cuneus 180 20 20 0 +14112 wm_rh_G_front_inf-Opercular 220 20 100 0 +14113 wm_rh_G_front_inf-Orbital 140 60 60 0 +14114 wm_rh_G_front_inf-Triangul 180 220 140 0 +14115 wm_rh_G_front_middle 140 100 180 0 +14116 wm_rh_G_front_sup 180 20 140 0 +14117 wm_rh_G_Ins_lg_and_S_cent_ins 23 10 10 0 +14118 wm_rh_G_insular_short 225 140 140 0 +14119 wm_rh_G_occipital_middle 180 60 180 0 +14120 wm_rh_G_occipital_sup 20 220 60 0 +14121 wm_rh_G_oc-temp_lat-fusifor 60 20 140 0 +14122 wm_rh_G_oc-temp_med-Lingual 220 180 140 0 +14123 wm_rh_G_oc-temp_med-Parahip 65 100 20 0 +14124 wm_rh_G_orbital 220 60 20 0 +14125 wm_rh_G_pariet_inf-Angular 20 60 220 0 +14126 wm_rh_G_pariet_inf-Supramar 100 100 60 0 +14127 wm_rh_G_parietal_sup 220 180 220 0 +14128 wm_rh_G_postcentral 20 180 140 0 +14129 wm_rh_G_precentral 60 140 180 0 +14130 wm_rh_G_precuneus 25 20 140 0 +14131 wm_rh_G_rectus 20 60 100 0 +14132 wm_rh_G_subcallosal 60 220 20 0 +14133 wm_rh_G_temp_sup-G_T_transv 60 60 220 0 +14134 wm_rh_G_temp_sup-Lateral 220 60 220 0 +14135 wm_rh_G_temp_sup-Plan_polar 65 220 60 0 +14136 wm_rh_G_temp_sup-Plan_tempo 25 140 20 0 +14137 wm_rh_G_temporal_inf 220 220 100 0 +14138 wm_rh_G_temporal_middle 180 60 60 0 +14139 wm_rh_Lat_Fis-ant-Horizont 61 20 220 0 +14140 wm_rh_Lat_Fis-ant-Vertical 61 20 60 0 +14141 wm_rh_Lat_Fis-post 61 60 100 0 +14142 wm_rh_Medial_wall 25 25 25 0 +14143 wm_rh_Pole_occipital 140 20 60 0 +14144 wm_rh_Pole_temporal 220 180 20 0 +14145 wm_rh_S_calcarine 63 180 180 0 +14146 wm_rh_S_central 221 20 10 0 +14147 wm_rh_S_cingul-Marginalis 221 20 100 0 +14148 wm_rh_S_circular_insula_ant 221 60 140 0 +14149 wm_rh_S_circular_insula_inf 221 20 220 0 +14150 wm_rh_S_circular_insula_sup 61 220 220 0 +14151 wm_rh_S_collat_transv_ant 100 200 200 0 +14152 wm_rh_S_collat_transv_post 10 200 200 0 +14153 wm_rh_S_front_inf 221 220 20 0 +14154 wm_rh_S_front_middle 141 20 100 0 +14155 wm_rh_S_front_sup 61 220 100 0 +14156 wm_rh_S_interm_prim-Jensen 141 60 20 0 +14157 wm_rh_S_intrapariet_and_P_trans 143 20 220 0 +14158 wm_rh_S_oc_middle_and_Lunatus 101 60 220 0 +14159 wm_rh_S_oc_sup_and_transversal 21 20 140 0 +14160 wm_rh_S_occipital_ant 61 20 180 0 +14161 wm_rh_S_oc-temp_lat 221 140 20 0 +14162 wm_rh_S_oc-temp_med_and_Lingual 141 100 220 0 +14163 wm_rh_S_orbital_lateral 221 100 20 0 +14164 wm_rh_S_orbital_med-olfact 181 200 20 0 +14165 wm_rh_S_orbital-H_Shaped 101 20 20 0 +14166 wm_rh_S_parieto_occipital 101 100 180 0 +14167 wm_rh_S_pericallosal 181 220 20 0 +14168 wm_rh_S_postcentral 21 140 200 0 +14169 wm_rh_S_precentral-inf-part 21 20 240 0 +14170 wm_rh_S_precentral-sup-part 21 20 200 0 +14171 wm_rh_S_suborbital 21 20 60 0 +14172 wm_rh_S_subparietal 101 60 60 0 +14173 wm_rh_S_temporal_inf 21 180 180 0 +14174 wm_rh_S_temporal_sup 223 220 60 0 +14175 wm_rh_S_temporal_transverse 221 60 60 0 + diff --git a/tests/test_opt_builder.py b/tests/test_opt_builder.py index 8aaca4c2..7aa188f6 100644 --- a/tests/test_opt_builder.py +++ b/tests/test_opt_builder.py @@ -485,3 +485,42 @@ def test_rh_hemisphere(self): from tit.opt.flex.builder import _atlas_name_from_path as atlas_name_from_path assert atlas_name_from_path("/path/to/rh.Destrieux.annot", "rh") == "Destrieux" + + +# --------------------------------------------------------------------------- +# Report field helpers for combined (union) ROIs +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestReportUnionFields: + def test_join_single_returns_scalar(self): + from tit.opt.flex.builder import _join + + # Byte-identical single-region reports: a bare scalar, not a list. + assert _join(1001) == 1001 + assert _join([1001]) == 1001 + + def test_join_multi_returns_plus_joined_string(self): + from tit.opt.flex.builder import _join + + assert _join([17, 53]) == "17+53" + assert "[" not in _join([17, 53]) + + def test_sphere_report_fields_single_flat(self): + from tit.opt.flex.builder import _sphere_report_fields + + coords, radius = _sphere_report_fields( + SphericalROI(x=10, y=20, z=30, radius=15) + ) + assert coords == [10, 20, 30] + assert radius == 15 + + def test_sphere_report_fields_multi_nested(self): + from tit.opt.flex.builder import _sphere_report_fields + + coords, radius = _sphere_report_fields( + SphericalROI(x=[10, -10], y=[20, -20], z=[30, -30], radius=8) + ) + assert coords == [[10, 20, 30], [-10, -20, -30]] + assert radius == [8, 8] diff --git a/tests/test_opt_ex.py b/tests/test_opt_ex.py index dd256ec0..ab8a1a88 100644 --- a/tests/test_opt_ex.py +++ b/tests/test_opt_ex.py @@ -208,3 +208,169 @@ def test_result_fields( assert result.n_combinations == 3 assert result.config_json == "/results.json" assert result.results_csv == "/results.csv" + + +# --------------------------------------------------------------------------- +# Combined-ROI (union) support +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestExConfigRoiNames: + def test_default_roi_names_is_none(self): + cfg = _make_ex_config() + assert cfg.roi_names is None + + def test_roi_names_csv_suffix_appended(self): + cfg = _make_ex_config(roi_names=["a", "b.csv", "c"]) + assert cfg.roi_names == ["a.csv", "b.csv", "c.csv"] + + def test_single_roi_name_unchanged(self): + # Scalar roi_name path stays byte-identical (1-element behavior). + cfg = _make_ex_config(roi_name="motor") + assert cfg.roi_name == "motor.csv" + assert cfg.roi_names is None + + +@pytest.mark.unit +class TestRunExSearchCombine: + def _run(self, mock_gpm, mock_engine_cls, mock_save, tmp_path, config): + pm = MagicMock() + pm.logs.return_value = str(tmp_path / "logs") + pm.ex_search_run.return_value = str(tmp_path / "output") + pm.rois.return_value = str(tmp_path / "rois") + mock_gpm.return_value = pm + + engine = MagicMock() + mock_engine_cls.return_value = engine + engine.run.return_value = {"m1": {}} + mock_save.return_value = {"config_json_path": "/j", "csv_path": "/c"} + + from tit.opt.ex.ex import run_ex_search + + run_ex_search(config) + return pm, mock_engine_cls + + @patch("tit.opt.ex.ex.process_and_save") + @patch("tit.opt.ex.ex.ExSearchEngine") + @patch("tit.opt.ex.ex.add_file_handler") + @patch("tit.opt.ex.ex.get_path_manager") + def test_single_roi_passes_one_file( + self, mock_gpm, mock_afh, mock_engine_cls, mock_save, tmp_path + ): + config = _make_ex_config() # roi_name="motor.csv", roi_names=None + pm, engine_cls = self._run( + mock_gpm, mock_engine_cls, mock_save, tmp_path, config + ) + + # Engine receives a single-element roi_files list (N=1 union case). + args = engine_cls.call_args[0] + roi_files = args[1] + assert roi_files == [os.path.join(str(tmp_path / "rois"), "motor.csv")] + assert args[2] == "motor.csv" # metric-key prefix unchanged + + @patch("tit.opt.ex.ex.process_and_save") + @patch("tit.opt.ex.ex.ExSearchEngine") + @patch("tit.opt.ex.ex.add_file_handler") + @patch("tit.opt.ex.ex.get_path_manager") + def test_combine_passes_union_files( + self, mock_gpm, mock_afh, mock_engine_cls, mock_save, tmp_path + ): + config = _make_ex_config( + roi_name="L_hippo+R_hippo", + roi_names=["L_hippo", "R_hippo"], + ) + pm, engine_cls = self._run( + mock_gpm, mock_engine_cls, mock_save, tmp_path, config + ) + + rois = str(tmp_path / "rois") + args = engine_cls.call_args[0] + roi_files = args[1] + assert roi_files == [ + os.path.join(rois, "L_hippo.csv"), + os.path.join(rois, "R_hippo.csv"), + ] + # Metric-key prefix matches config.roi_name (what process_and_save reads). + assert args[2] == "L_hippo+R_hippo.csv" + + +# --------------------------------------------------------------------------- +# ExSearchEngine multi-center union +# --------------------------------------------------------------------------- + + +def _make_engine(roi_file="/fake/roi.csv"): + from tit.opt.ex.engine import ExSearchEngine + + return ExSearchEngine( + leadfield_hdf="/fake/leadfield.hdf5", + roi_file=roi_file, + roi_name="Combined", + logger=MagicMock(), + ) + + +@pytest.mark.unit +class TestEngineUnion: + def test_loads_multiple_centers(self, tmp_path): + import csv + + f1 = tmp_path / "a.csv" + f2 = tmp_path / "b.csv" + with open(f1, "w", newline="") as f: + csv.writer(f).writerow([0.0, 0.0, 0.0]) + with open(f2, "w", newline="") as f: + csv.writer(f).writerow([10.0, 0.0, 0.0]) + + engine = _make_engine(roi_file=[str(f1), str(f2)]) + engine._load_roi_coordinates() + + assert engine.roi_centers == [[0.0, 0.0, 0.0], [10.0, 0.0, 0.0]] + # roi_coords mirrors the first center for back-compat. + assert engine.roi_coords == [0.0, 0.0, 0.0] + + def test_unions_masks_from_multiple_centers(self): + import numpy as np + + engine = _make_engine(roi_file=["/a.csv", "/b.csv"]) + engine.roi_centers = [[0.0, 0.0, 0.0], [10.0, 0.0, 0.0]] + + centers = np.array( + [ + [0.0, 0.0, 0.0], # near center A + [1.0, 0.0, 0.0], # near center A + [10.0, 0.0, 0.0], # near center B + [11.0, 0.0, 0.0], # near center B + [50.0, 0.0, 0.0], # far from both + ] + ) + volumes = np.array([1.0, 1.0, 1.0, 1.0, 1.0]) + mesh = MagicMock() + mesh.elements_baricenters.return_value = MagicMock(value=centers) + mesh.elements_volumes_and_areas.return_value = MagicMock(value=volumes) + engine.mesh = mesh + + engine._find_roi_elements(roi_radius=3.0) + + # Union of both spheres: indices 0,1 (A) and 2,3 (B); 4 excluded. + assert set(engine.roi_indices.tolist()) == {0, 1, 2, 3} + + def test_single_center_fallback_matches_prior_behavior(self): + import numpy as np + + # roi_centers stays None; single-center path uses roi_coords (N=1). + engine = _make_engine(roi_file="/a.csv") + engine.roi_coords = [0.0, 0.0, 0.0] + + centers = np.array([[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [5.0, 0.0, 0.0]]) + volumes = np.array([1.0, 2.0, 3.0]) + mesh = MagicMock() + mesh.elements_baricenters.return_value = MagicMock(value=centers) + mesh.elements_volumes_and_areas.return_value = MagicMock(value=volumes) + engine.mesh = mesh + + engine._find_roi_elements(roi_radius=3.0) + + assert set(engine.roi_indices.tolist()) == {0, 1} + np.testing.assert_array_equal(engine.roi_volumes, [1.0, 2.0]) diff --git a/tests/test_opt_flex_utils.py b/tests/test_opt_flex_utils.py index 2a6f3c72..9d48a863 100644 --- a/tests/test_opt_flex_utils.py +++ b/tests/test_opt_flex_utils.py @@ -244,8 +244,13 @@ def test_volumetric_sphere_with_mni(self): config = _make_config( roi=SphericalROI( - x=-24, y=-4, z=-20, radius=8, - use_mni=True, volumetric=True, tissues="WM", + x=-24, + y=-4, + z=-20, + radius=8, + use_mni=True, + volumetric=True, + tissues="WM", ), ) configure_roi(opt, config) @@ -620,3 +625,285 @@ def test_spherical_float_coords(self): label = generate_label(config) assert "10.5" in label assert "-20.5" in label + + +# --------------------------------------------------------------------------- +# Union of same-type ROIs (multi-region targets) +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestConfigureAtlasUnion: + def test_two_label_same_hemisphere(self): + from tit.opt.flex.utils import configure_roi + + opt = MagicMock() + roi_mock = MagicMock() + opt.add_roi.return_value = roi_mock + + config = _make_config( + roi=AtlasROI( + atlas_path="/path/to/lh.aparc.annot", + label=[1001, 1002], + hemisphere="lh", + ), + ) + configure_roi(opt, config) + + assert roi_mock.mask_value == [1001, 1002] + # atlas_path broadcast to match the label list + assert roi_mock.mask_path == [ + "/path/to/lh.aparc.annot", + "/path/to/lh.aparc.annot", + ] + assert roi_mock.mask_space == ["subject_lh", "subject_lh"] + # First fold intersects the all-True base, the rest union on. + assert roi_mock.mask_operator == ["intersection", "union"] + + def test_cross_hemisphere_union(self): + """Two entries with differing mask_path AND mask_space (lh + rh).""" + from tit.opt.flex.utils import configure_roi + + opt = MagicMock() + roi_mock = MagicMock() + opt.add_roi.return_value = roi_mock + + config = _make_config( + roi=AtlasROI( + atlas_path=["/path/to/lh.aparc.annot", "/path/to/rh.aparc.annot"], + label=[17, 53], + hemisphere=["lh", "rh"], + ), + ) + configure_roi(opt, config) + + assert roi_mock.mask_value == [17, 53] + assert roi_mock.mask_path == [ + "/path/to/lh.aparc.annot", + "/path/to/rh.aparc.annot", + ] + assert roi_mock.mask_space == ["subject_lh", "subject_rh"] + assert roi_mock.mask_operator == ["intersection", "union"] + + def test_union_focality_everything_else_complement_length(self): + from tit.opt.flex.utils import configure_roi + + opt = MagicMock() + roi_mock = MagicMock() + non_roi_mock = MagicMock() + opt.add_roi.side_effect = [roi_mock, non_roi_mock] + + config = _make_config( + goal="focality", + non_roi_method="everything_else", + roi=AtlasROI( + atlas_path="/path/to/lh.aparc.annot", + label=[1001, 1002], + hemisphere="lh", + ), + ) + configure_roi(opt, config) + + # Complement operator length must equal the ROI mask length (N=2). + assert non_roi_mock.mask_operator == ["difference", "difference"] + assert len(non_roi_mock.mask_operator) == len(roi_mock.mask_value) + assert non_roi_mock.weight == -1 + + +@pytest.mark.unit +class TestConfigureSubcorticalUnion: + def test_two_label_shared_atlas(self, tmp_path): + """Both hippocampi (17, 53) from one aseg atlas.""" + from tit.opt.flex.utils import configure_roi + + atlas_file = tmp_path / "aseg.nii.gz" + atlas_file.write_text("fake") + + opt = MagicMock() + roi_mock = MagicMock() + opt.add_roi.return_value = roi_mock + + config = _make_config( + roi=SubcorticalROI(atlas_path=str(atlas_file), label=[17, 53]), + ) + configure_roi(opt, config) + + assert roi_mock.mask_value == [17, 53] + assert roi_mock.mask_path == [str(atlas_file), str(atlas_file)] + assert roi_mock.mask_space == ["subject", "subject"] + assert roi_mock.mask_operator == ["intersection", "union"] + + def test_union_focality_complement_length(self, tmp_path): + from tit.opt.flex.utils import configure_roi + + atlas_file = tmp_path / "aseg.nii.gz" + atlas_file.write_text("fake") + + opt = MagicMock() + roi_mock = MagicMock() + non_roi_mock = MagicMock() + opt.add_roi.side_effect = [roi_mock, non_roi_mock] + + config = _make_config( + goal="focality", + non_roi_method="everything_else", + roi=SubcorticalROI(atlas_path=str(atlas_file), label=[17, 53]), + ) + configure_roi(opt, config) + + assert non_roi_mock.mask_operator == ["difference", "difference"] + assert len(non_roi_mock.mask_operator) == len(roi_mock.mask_value) + + def test_union_missing_one_atlas_raises(self, tmp_path): + from tit.opt.flex.utils import configure_roi + + atlas_file = tmp_path / "aseg.nii.gz" + atlas_file.write_text("fake") + + opt = MagicMock() + opt.add_roi.return_value = MagicMock() + + config = _make_config( + roi=SubcorticalROI( + atlas_path=[str(atlas_file), "/nonexistent.nii.gz"], + label=[17, 53], + ), + ) + with pytest.raises(FileNotFoundError): + configure_roi(opt, config) + + +@pytest.mark.unit +class TestConfigureSphericalUnion: + def test_two_sphere_union(self): + from tit.opt.flex.utils import configure_roi + + opt = MagicMock() + roi_mock = MagicMock() + opt.add_roi.return_value = roi_mock + + config = _make_config( + roi=SphericalROI(x=[10, -10], y=[20, -20], z=[30, -30], radius=[8, 12]), + ) + configure_roi(opt, config) + + assert roi_mock.roi_sphere_center == [[10, 20, 30], [-10, -20, -30]] + assert roi_mock.roi_sphere_radius == [8, 12] + assert roi_mock.roi_sphere_center_space == ["subject", "subject"] + assert roi_mock.roi_sphere_operator == ["intersection", "union"] + + def test_two_sphere_shared_radius_broadcast(self): + from tit.opt.flex.utils import configure_roi + + opt = MagicMock() + roi_mock = MagicMock() + opt.add_roi.return_value = roi_mock + + config = _make_config( + roi=SphericalROI(x=[10, -10], y=[20, -20], z=[30, -30], radius=9), + ) + configure_roi(opt, config) + + assert roi_mock.roi_sphere_radius == [9, 9] + + def test_single_sphere_back_compat_flat(self): + """Scalar sphere keeps the flat (non-nested) form, no operator set.""" + from tit.opt.flex.utils import configure_roi + + opt = MagicMock() + roi_mock = MagicMock() + opt.add_roi.return_value = roi_mock + + config = _make_config(roi=SphericalROI(x=10, y=20, z=30, radius=15)) + configure_roi(opt, config) + + assert roi_mock.roi_sphere_center == [10, 20, 30] + assert roi_mock.roi_sphere_radius == 15 + + def test_union_focality_complement_length(self): + from tit.opt.flex.utils import configure_roi + + opt = MagicMock() + roi_mock = MagicMock() + non_roi_mock = MagicMock() + opt.add_roi.side_effect = [roi_mock, non_roi_mock] + + config = _make_config( + goal="focality", + non_roi_method="everything_else", + roi=SphericalROI(x=[10, -10], y=[20, -20], z=[30, -30], radius=8), + ) + configure_roi(opt, config) + + assert non_roi_mock.roi_sphere_operator == ["difference", "difference"] + assert non_roi_mock.roi_sphere_center == [[10, 20, 30], [-10, -20, -30]] + assert non_roi_mock.weight == -1 + + +@pytest.mark.unit +class TestUnionLabelRendering: + def test_generate_label_atlas_union_joined(self): + from tit.opt.flex.utils import generate_label + + config = _make_config( + roi=AtlasROI( + atlas_path=["/path/to/lh.aparc.annot", "/path/to/rh.aparc.annot"], + label=[17, 53], + hemisphere=["lh", "rh"], + ), + ) + label = generate_label(config) + assert "17+53" in label + assert "lh+rh" in label + assert "[" not in label and "]" not in label + + def test_generate_label_subcortical_union_joined(self): + from tit.opt.flex.utils import generate_label + + config = _make_config( + roi=SubcorticalROI(atlas_path="/path/to/aseg.nii.gz", label=[17, 53]), + ) + label = generate_label(config) + assert "17+53" in label + assert "[" not in label + + def test_generate_label_sphere_union_joined(self): + from tit.opt.flex.utils import generate_label + + config = _make_config( + roi=SphericalROI(x=[10, -10], y=[20, -20], z=[30, -30], radius=8), + ) + label = generate_label(config) + assert label.count("sphere(") == 2 + assert "+" in label + + +@pytest.mark.unit +class TestUnionConfigValidation: + def test_atlas_hemisphere_length_mismatch_raises(self): + # length 3 hemisphere neither broadcasts (1) nor matches label count (2) + with pytest.raises(ValueError, match="hemisphere"): + AtlasROI( + atlas_path="/a.annot", label=[17, 53], hemisphere=["lh", "rh", "lh"] + ) + + def test_atlas_single_hemisphere_broadcasts(self): + # A single hemisphere is valid and broadcasts across all labels. + roi = AtlasROI(atlas_path="/a.annot", label=[17, 53], hemisphere="lh") + assert roi.label == [17, 53] + + def test_atlas_empty_label_raises(self): + with pytest.raises(ValueError, match="label"): + AtlasROI(atlas_path="/a.annot", label=[]) + + def test_sphere_unequal_coords_raise(self): + with pytest.raises(ValueError, match="equal length"): + SphericalROI(x=[1, 2], y=[3], z=[5, 6]) + + def test_sphere_bad_radius_length_raises(self): + with pytest.raises(ValueError, match="radius"): + SphericalROI(x=[1, 2], y=[3, 4], z=[5, 6], radius=[1, 2, 3]) + + def test_subcortical_atlas_length_mismatch_raises(self): + with pytest.raises(ValueError, match="atlas_path"): + SubcorticalROI(atlas_path=["/a.nii.gz", "/b.nii.gz"], label=[1, 2, 3]) diff --git a/tit/gui/analyzer_tab.py b/tit/gui/analyzer_tab.py index 99808979..91b87bc9 100644 --- a/tit/gui/analyzer_tab.py +++ b/tit/gui/analyzer_tab.py @@ -33,6 +33,8 @@ ) from tit.gui.components.action_buttons import RunStopButtons from tit.gui.components.base_thread import BaseProcessThread +from tit.gui.components.atlas_region_finder import AtlasRegionFinderDialog +from tit.gui.components.region_chips import RegionChipsWidget from tit.atlas.constants import BUILTIN_ATLASES from tit.paths import get_path_manager @@ -113,6 +115,14 @@ def __init__(self, parent=None): self._last_plain_output_line = None self._summary_printed = set() + # Per-Space region selection: Mesh and Voxel keep independent chip sets + # (like the optimizer's separate cortical/subcortical chips), so + # switching Space does not lose the other Space's selection. + self._region_store = {"mesh": [], "voxel": []} + self._current_region_space = "mesh" # default Space (space_mesh checked) + self._last_mesh_atlas = None + self._last_voxel_atlas = None + self.setup_ui() def showEvent(self, event): @@ -599,19 +609,8 @@ def create_analysis_parameters_widget(self, container_widget): space_layout.addStretch() # Keep stretch to push radio buttons to left analysis_params_layout.addLayout(space_layout) - # Tissue type row - tissue_layout = QtWidgets.QHBoxLayout() - tissue_layout.setSpacing(10) - tissue_label = QtWidgets.QLabel("Tissue:") - tissue_label.setSizePolicy( - QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed - ) - tissue_layout.addWidget(tissue_label) - tissue_layout.addWidget(self.tissue_combo) - tissue_layout.addStretch() - analysis_params_layout.addLayout(tissue_layout) - - # Type selection row (separate from Space) - distribute evenly across width + # Type selection row - moved up so it sits right after Space and drives + # which target group (cortical vs spherical) is shown below. type_layout = QtWidgets.QHBoxLayout() type_layout.setSpacing(10) self.type_label = QtWidgets.QLabel("Type:") @@ -636,45 +635,35 @@ def create_analysis_parameters_widget(self, container_widget): type_layout.addStretch() # Keep stretch to push radio buttons to left analysis_params_layout.addLayout(type_layout) - # (Removed) Analysis mode selection row (surface vs volumetric). Analyzer is surface-only. - - # Region, Atlas, and Spherical parameters - organized into multiple rows - # Row 1: Region input (comma-separated for combined ROI) + List Regions button - region_row = QtWidgets.QHBoxLayout() - region_row.setSpacing(10) - self.region_label = QtWidgets.QLabel("Region(s):") - self.region_label.setSizePolicy( + # Tissue type row (applies to voxel space; enable/disable handled in + # update_atlas_visibility). Kept always-visible here — NOT inside + # cortical_group — so it stays reachable in Voxel+Spherical mode, where it + # still affects output (field-file GM/WM selection and voxel tissue mask). + tissue_layout = QtWidgets.QHBoxLayout() + tissue_layout.setSpacing(10) + tissue_label = QtWidgets.QLabel("Tissue:") + tissue_label.setSizePolicy( QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed ) - self.region_input = QtWidgets.QLineEdit() - self.region_input.setPlaceholderText( - "e.g., superiorfrontal or precentral, postcentral" - ) - self.region_input.setMinimumWidth(100) - self.region_input.setSizePolicy( - QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Fixed - ) + tissue_layout.addWidget(tissue_label) + tissue_layout.addWidget(self.tissue_combo) + tissue_layout.addStretch() + analysis_params_layout.addLayout(tissue_layout) - self.show_regions_btn = QtWidgets.QPushButton("List Regions") - self.show_regions_btn.setToolTip("Show available regions in the selected atlas") - self.show_regions_btn.clicked.connect(self.show_available_regions) - self.show_regions_btn.setEnabled(False) - self.show_regions_btn.setSizePolicy( - QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed - ) + # (Removed) Analysis mode selection row (surface vs volumetric). Analyzer is surface-only. - region_row.addWidget(self.region_label) - region_row.addWidget(self.region_input) - region_row.addSpacing(5) - region_row.addWidget(self.show_regions_btn) - region_row.addStretch() - analysis_params_layout.addLayout(region_row) + # ===================== Cortical target group ===================== + self.cortical_group = QtWidgets.QGroupBox("Cortical target") + cortical_layout = QtWidgets.QVBoxLayout(self.cortical_group) + cortical_layout.setSpacing(8) - # Row 2: Atlas selection (single widget that shows mesh or voxel based on space) + # Atlas row: the atlas widget(s) (mesh or voxel depending on Space) plus + # the "List Regions" button. Both atlas widgets live here; only the one + # matching the current Space is shown/enabled (see update_atlas_visibility). atlas_row = QtWidgets.QHBoxLayout() atlas_row.setSpacing(10) - # Atlas widgets - always visible, just enabled/disabled + # Atlas widgets - always present, only the relevant one shown/enabled self.mesh_atlas_widget = QtWidgets.QWidget() mesh_atlas_layout = QtWidgets.QHBoxLayout(self.mesh_atlas_widget) mesh_atlas_layout.setContentsMargins(0, 0, 0, 0) @@ -713,16 +702,65 @@ def create_analysis_parameters_widget(self, container_widget): voxel_atlas_row_layout.addWidget(self.atlas_combo) voxel_atlas_vlayout.addWidget(voxel_atlas_row) - # Only add the appropriate atlas widget based on current space - # Both will be in the layout, but only one enabled/visible at a time + # "List Regions" button lives on the atlas row. + self.show_regions_btn = QtWidgets.QPushButton("List Regions") + self.show_regions_btn.setToolTip("Show available regions in the selected atlas") + self.show_regions_btn.clicked.connect(self.show_available_regions) + self.show_regions_btn.setEnabled(False) + self.show_regions_btn.setSizePolicy( + QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed + ) + + # "Clear" button drops every selected region chip in one click, mirroring + # the optimizer's region-selection UX. It is wired to region_chips.clear + # below, once the chips widget exists. + self.clear_regions_btn = QtWidgets.QPushButton("Clear") + self.clear_regions_btn.setToolTip("Clear all selected regions") + self.clear_regions_btn.setSizePolicy( + QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed + ) + + # Both atlas widgets in the layout, but only one enabled/visible at a time atlas_row.addWidget(self.mesh_atlas_widget) atlas_row.addWidget(self.voxel_atlas_widget) + atlas_row.addWidget(self.show_regions_btn) + atlas_row.addWidget(self.clear_regions_btn) atlas_row.addStretch() - analysis_params_layout.addLayout(atlas_row) + cortical_layout.addLayout(atlas_row) - # Spherical analysis parameters - split into separate rows + # Region(s) label above the full-width chips row. + self.region_label = QtWidgets.QLabel("Region(s):") + self.region_label.setSizePolicy( + QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed + ) + cortical_layout.addWidget(self.region_label) + + # Selected-region chips get their OWN full-width row so they are clearly + # visible and wrap instead of being clipped. + self.region_chips = RegionChipsWidget( + placeholder="No regions selected — use List Regions…" + ) + self.region_chips.setToolTip( + "Selected regions for cortical analysis. Use 'List Regions' to add " + "regions; each chip shows the region name and its atlas label index. " + "Multiple regions combine into one ROI; press ✕ on a chip to remove it." + ) + _chips_policy = self.region_chips.sizePolicy() + _chips_policy.setHorizontalPolicy(QtWidgets.QSizePolicy.Expanding) + self.region_chips.setSizePolicy(_chips_policy) + cortical_layout.addWidget(self.region_chips) - # Row 2.5: Coordinate space selection (for spherical analysis) + # Now that region_chips exists, wire the "Clear" button created above. + self.clear_regions_btn.clicked.connect(self.region_chips.clear) + + analysis_params_layout.addWidget(self.cortical_group) + + # ===================== Spherical target group ===================== + self.spherical_group = QtWidgets.QGroupBox("Spherical target") + spherical_layout = QtWidgets.QVBoxLayout(self.spherical_group) + spherical_layout.setSpacing(8) + + # Coordinate space selection (Subject / MNI) for spherical analysis. coord_space_row = QtWidgets.QHBoxLayout() coord_space_row.setSpacing(10) self.coord_space_label = QtWidgets.QLabel("Coordinate Space:") @@ -733,9 +771,9 @@ def create_analysis_parameters_widget(self, container_widget): coord_space_row.addWidget(self.coord_space_subject) coord_space_row.addWidget(self.coord_space_mni) coord_space_row.addStretch() - analysis_params_layout.addLayout(coord_space_row) + spherical_layout.addLayout(coord_space_row) - # Row 3: Coordinates & Radius (single input: x,y,z,r) + # Coordinates & Radius (single input: x,y,z,r) coords_row = QtWidgets.QHBoxLayout() coords_row.setSpacing(10) self.coordinates_label = QtWidgets.QLabel("Coordinates (x,y,z,r):") @@ -762,12 +800,16 @@ def create_analysis_parameters_widget(self, container_widget): coords_row.addSpacing(10) coords_row.addWidget(self.view_in_freeview_btn) coords_row.addStretch() - analysis_params_layout.addLayout(coords_row) + spherical_layout.addLayout(coords_row) + + analysis_params_layout.addWidget(self.spherical_group) # Remove the stacked widget approach - coordinates/radius are always visible # Instead, we'll enable/disable them based on mode - # Original connections from setup_ui for space/type changes + # Original connections from setup_ui for space/type changes. + # update_atlas_visibility() also drives the adaptive cortical/spherical + # target-group visibility, so the type radios need no extra connection. self.space_mesh.toggled.connect(self.update_atlas_visibility) self.space_voxel.toggled.connect(self.update_atlas_visibility) self.type_spherical.toggled.connect(self.update_atlas_visibility) @@ -777,15 +819,27 @@ def create_analysis_parameters_widget(self, container_widget): self.space_mesh.toggled.connect(self.update_cortical_button_text) self.space_voxel.toggled.connect(self.update_cortical_button_text) + # Reset the region selection whenever the analysis context changes: mesh + # and voxel regions (and different atlases) are not interchangeable, so a + # user-initiated Space switch or atlas change invalidates the current + # chips. These fire only on genuine user interaction — space_mesh.toggled + # emits once per real Space change (and is connected after the initial + # setChecked, so setup does not trigger it), while the combos' activated + # signal never fires on programmatic repopulation (clear/setCurrentIndex). + self.space_mesh.toggled.connect(self._swap_region_space) + self.atlas_name_combo.activated.connect(self._on_analyzer_atlas_changed) + self.atlas_combo.activated.connect(self._on_analyzer_atlas_changed) + # (Removed) Analysis mode defaults (surface vs volumetric). Analyzer is surface-only. # Connect coordinate space radio buttons self.coord_space_subject.toggled.connect(self._update_coordinate_space_labels) self.coord_space_mni.toggled.connect(self._update_coordinate_space_labels) - self.update_atlas_visibility() # Initial call + # Initial call also sets the adaptive target-group visibility. + self.update_atlas_visibility() self.update_cortical_button_text() # Initial call - analysis_params_container.setFixedHeight(250) + analysis_params_container.setMinimumHeight(250) right_layout.addWidget(analysis_params_container) # Compact Gmsh visualization - using space more efficiently @@ -1021,7 +1075,7 @@ def update_atlas_dependent_controls( # Should be enabled for cortical analysis, but only if atlas controls are enabled region_enabled = cortical_controls_enabled self.region_label.setEnabled(region_enabled) - self.region_input.setEnabled(region_enabled) + self.region_chips.setEnabled(region_enabled) # Update show regions button # Should be enabled for cortical analysis, but only if we have valid atlases @@ -1079,6 +1133,14 @@ def update_atlas_visibility(self): is_cortical = self.type_cortical.isChecked() is_spherical = self.type_spherical.isChecked() + # Adaptive target-group visibility: show only the group matching the + # selected analysis type. Guarded for early calls before the groups + # are created. + if hasattr(self, "cortical_group"): + self.cortical_group.setVisible(is_cortical) + if hasattr(self, "spherical_group"): + self.spherical_group.setVisible(is_spherical) + # Tissue selection only applies to voxel space self.tissue_combo.setEnabled(not is_mesh) if is_mesh: @@ -1269,7 +1331,7 @@ def update_group_atlas_options(self): self.atlas_warning_label.setVisible(False) # Disable all region/atlas controls self.region_label.setEnabled(False) - self.region_input.setEnabled(False) + self.region_chips.setEnabled(False) self.show_regions_btn.setEnabled(False) return # Skip the centralized update, as we've set everything explicitly @@ -1490,11 +1552,11 @@ def validate_analysis_parameters(self): # Shared parameters return False elif self.type_cortical.isChecked(): # Atlas selection for cortical is handled by validate_single/group_inputs - if not self.region_input.text().strip(): + if not self.region_chips.keys(): QtWidgets.QMessageBox.warning( self, "Warning", - "Please enter a region name for cortical analysis.", + "Please select a region for cortical analysis.", ) return False return True @@ -1871,7 +1933,7 @@ def get_single_analysis_details(self): else: details += f"- Voxel Atlas File: {self.atlas_combo.currentText()} (Path: {self.atlas_combo.currentData() or 'N/A'})\n" # Show path regions = self._get_regions() - details += f"- Region(s): {', '.join(regions) if regions else self.region_input.text()}\n" + details += f"- Region(s): {', '.join(regions)}\n" details += f"- Generate Visualizations: Yes" return details @@ -1914,7 +1976,7 @@ def get_group_analysis_details(self, subjects): else: details += f"- Voxel Atlas: Common atlas configuration\n" regions = self._get_regions() - details += f"- Region(s): {', '.join(regions) if regions else self.region_input.text()} (for all)\n" + details += f"- Region(s): {', '.join(regions)} (for all)\n" details += f"- Generate Visualizations: Yes" return details @@ -2058,11 +2120,7 @@ def _build_start_details(self, subject_id): "." )[0] regions = self._get_regions() - region_str = ( - "+".join(regions) - if regions - else self.region_input.text().strip() or "region" - ) + region_str = "+".join(regions) or "region" return f"Cortical: {atlas}.{region_str}" else: parsed = self._parse_coords_radius() @@ -2107,7 +2165,7 @@ def disable_controls(self): self.atlas_name_combo, self.atlas_combo, self.show_regions_btn, - self.region_input, + self.region_chips, ] for widget in widgets_to_set_enabled: if hasattr(widget, "setEnabled"): @@ -2130,14 +2188,14 @@ def enable_controls(self): self.type_cortical, self.coords_radius_input, self.view_in_freeview_btn, - # atlas_name_combo, atlas_combo, show_regions_btn, region_input handled by update_atlas_visibility + # atlas_name_combo, atlas_combo, show_regions_btn, region_chips handled by update_atlas_visibility ] for widget in widgets_to_set_enabled: if hasattr(widget, "setEnabled"): widget.setEnabled(True) # Force enable these controls first, then let update_atlas_visibility handle proper state - self.region_input.setEnabled(True) + self.region_chips.setEnabled(True) self.region_label.setEnabled(True) self.atlas_name_combo.setEnabled(True) # Don't force enable atlas_combo - let update_atlas_visibility handle it properly @@ -2151,17 +2209,54 @@ def enable_controls(self): self.status_label.hide() - @staticmethod - def _list_annot_regions(seg_dir, atlas_name): - """Read region names from .annot files in the segmentation directory.""" - from tit.atlas import MeshAtlasManager + def _current_space_name(self) -> str: + return "mesh" if self.space_mesh.isChecked() else "voxel" + + def _swap_region_space(self, *_args) -> None: + """Preserve each Space's own region selection across a Space switch. + + Connected to ``space_mesh.toggled``. On a genuine Space change, the + current chips are snapshotted under the Space being left and the chips + for the Space being entered are restored (empty if never used), so Mesh + and Voxel keep independent selections. ``*_args`` absorbs the ``bool`` + payload; the connection is made after the initial ``setChecked`` so + setup does not trigger it. + """ + new_space = self._current_space_name() + if new_space == self._current_region_space: + return + self._region_store[self._current_region_space] = self.region_chips.items() + self.region_chips.set_items(self._region_store.get(new_space, [])) + self._current_region_space = new_space + + def _on_analyzer_atlas_changed(self, *_args) -> None: + """Reset the current Space's selection when its atlas actually changes. - return MeshAtlasManager(seg_dir).list_regions(atlas_name) + Regions are atlas-specific, so switching the atlas within a Space clears + that Space's chips and stored snapshot (single-atlas rule). Wired to the + combos' ``activated`` (user-only) signal and guarded so re-selecting the + same atlas — or a programmatic repopulation — does not wipe the chips. + """ + space = self._current_space_name() + if space == "mesh": + atlas, attr = self.atlas_name_combo.currentText(), "_last_mesh_atlas" + else: + atlas, attr = self.atlas_combo.currentText(), "_last_voxel_atlas" + if atlas == getattr(self, attr): + return + setattr(self, attr, atlas) + self._region_store[space] = [] + self.region_chips.clear() def _get_regions(self) -> list[str]: - """Parse comma-separated region names from the input field.""" - text = self.region_input.text().strip() - return [r.strip() for r in text.split(",") if r.strip()] + """Return the selected region names from the region chips. + + Each chip's key is the region name the analyzer config consumes (a + hemisphere-prefixed name for mesh atlases, a plain name for voxel + atlases), so this is a drop-in replacement for the former + comma-separated text field. + """ + return self.region_chips.keys() def show_available_regions(self): """Show a searchable dialog of available regions for the selected atlas.""" @@ -2175,7 +2270,7 @@ def show_available_regions(self): progress_dialog.setMinimumDuration(200) progress_dialog.setValue(0) - atlas_type_display, regions = "", [] + atlas_type_display, entries = "", [] subject_id = selected_subjects[0] if self.space_mesh.isChecked(): @@ -2194,7 +2289,22 @@ def show_available_regions(self): progress_dialog.setValue(20) QtWidgets.QApplication.processEvents() seg_dir = os.path.join(m2m_dir, "segmentation") - regions = self._list_annot_regions(seg_dir, atlas_name) + # Build (index, "hemi.name", None) entries straight from each + # hemisphere's .annot so chips display the region's real atlas + # label index instead of a fake enumerate position. The name token + # ("lh.precentral") is exactly what the analyzer config consumes. + from tit.atlas import MeshAtlasManager + + mesh_mgr = MeshAtlasManager(seg_dir) + for hemi in ("lh", "rh"): + annot_file = mesh_mgr.find_atlas_file(atlas_name, hemi) + if not annot_file: + continue + for idx, name in mesh_mgr.list_annot_regions(annot_file): + if name == "unknown": + continue + entries.append((idx, f"{hemi}.{name}", None)) + entries.sort(key=lambda e: e[1]) # alphabetical within the finder list progress_dialog.setValue(80) QtWidgets.QApplication.processEvents() @@ -2212,10 +2322,19 @@ def show_available_regions(self): from tit.atlas import VoxelAtlasManager - regions = VoxelAtlasManager().list_regions(atlas_path) + # VoxelAtlasManager yields "Name (ID: N)" strings; parse each into + # its real integer label id and clean name so chips show the id and + # the config receives the plain region name. + for region in VoxelAtlasManager().list_regions(atlas_path): + name_part, _, id_part = region.partition(" (ID:") + try: + region_id = int(id_part.rstrip(")").strip()) + except ValueError: + region_id = 0 + entries.append((region_id, name_part.strip(), None)) progress_dialog.setValue(90) - if not regions: + if not entries: QtWidgets.QMessageBox.information( self, "No Regions", f"No regions for: {atlas_type_display}" ) @@ -2224,60 +2343,20 @@ def show_available_regions(self): progress_dialog.setValue(100) progress_dialog.close() - # Show searchable region dialog - dialog = QtWidgets.QDialog(self) - dialog.setWindowTitle(f"Available Regions - {atlas_type_display}") - dialog.setMinimumWidth(400) - dialog.setMinimumHeight(500) - layout = QtWidgets.QVBoxLayout(dialog) - - search_input = QtWidgets.QLineEdit() - search_layout = QtWidgets.QHBoxLayout() - search_layout.addWidget(QtWidgets.QLabel("Search:")) - search_layout.addWidget(search_input) - layout.addLayout(search_layout) - - list_widget = QtWidgets.QListWidget() - list_widget.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection) - list_widget.addItems(regions) - layout.addWidget(list_widget) - - def filter_regions(text): - for i in range(list_widget.count()): - list_widget.item(i).setHidden( - text.lower() not in list_widget.item(i).text().lower() - ) - - def select_regions(): - selected = list_widget.selectedItems() - if not selected: - return - existing = self.region_input.text().strip() - current = ( - [r.strip() for r in existing.split(",") if r.strip()] - if existing - else [] - ) - for item in selected: - region_text = item.text().split(" (ID:")[0] - if region_text not in current: - current.append(region_text) - self.region_input.setText(", ".join(current)) - dialog.accept() - - search_input.textChanged.connect(filter_regions) - list_widget.itemDoubleClicked.connect(lambda: select_regions()) - - btn_layout = QtWidgets.QHBoxLayout() - copy_btn = QtWidgets.QPushButton("Add Selected") - close_btn = QtWidgets.QPushButton("Close") - copy_btn.clicked.connect(select_regions) - close_btn.clicked.connect(dialog.reject) - btn_layout.addWidget(copy_btn) - btn_layout.addWidget(close_btn) - layout.addLayout(btn_layout) - - dialog.exec_() + dialog = AtlasRegionFinderDialog( + self, + f"Available Regions - {atlas_type_display}", + entries, + return_field="name", + multi=True, + preselected=list(self.region_chips.keys()), + ) + if dialog.exec_() == QtWidgets.QDialog.Accepted: + # Add each selected region as a de-duplicated chip. The key is the + # region name the analyzer config expects (what _get_regions + # returned before); the display carries the real atlas label index. + for region_id, name in zip(dialog.selected_ids(), dialog.selected_names()): + self.region_chips.add_item(key=name, display=f"{name} · {region_id}") def load_t1_in_freeview(self): """Load subject's T1 NIfTI file or MNI template in Freeview for coordinate selection.""" @@ -2647,13 +2726,10 @@ def _update_input_widths(self): container_width = 400 # Default fallback # Calculate dynamic maximum widths as percentages of container width - # These percentages allow growth while preventing overflow - # Region input uses Preferred policy, so it will expand up to max - if hasattr(self, "region_input"): - # Allow expansion up to a reasonable max based on container size - max_region_width = max(200, min(300, int(container_width * 0.40))) - if self.region_input.maximumWidth() != max_region_width: - self.region_input.setMaximumWidth(max_region_width) + # These percentages allow growth while preventing overflow. + # The region selector is now a RegionChipsWidget that wraps its chips + # via height-for-width, so it needs no maximum-width clamp; leaving one + # would stop the chips from reflowing to fill the available row. # Coordinates+radius input uses Preferred policy if hasattr(self, "coords_radius_input"): diff --git a/tit/gui/components/__init__.py b/tit/gui/components/__init__.py index 409a6e0f..c3662d9e 100644 --- a/tit/gui/components/__init__.py +++ b/tit/gui/components/__init__.py @@ -9,19 +9,27 @@ from .console import ConsoleWidget from .action_buttons import RunStopButtons +from .atlas_region_finder import AtlasRegionFinderDialog, merge_into_lineedit from .base_thread import detect_message_type_from_content from .electrode_config import ElectrodeConfigWidget +from .help_icon import HelpIcon from .qsi_config_dialogs import QSIPrepConfigDialog, QSIReconConfigDialog +from .region_chips import FlowLayout, RegionChipsWidget from .roi_picker import ROIPickerWidget from .solver_params import SolverParamsWidget __all__ = [ + "AtlasRegionFinderDialog", "ConsoleWidget", "ElectrodeConfigWidget", + "FlowLayout", + "HelpIcon", + "RegionChipsWidget", "ROIPickerWidget", "RunStopButtons", "SolverParamsWidget", "detect_message_type_from_content", + "merge_into_lineedit", "QSIPrepConfigDialog", "QSIReconConfigDialog", "SubjectRow", diff --git a/tit/gui/components/atlas_region_finder.py b/tit/gui/components/atlas_region_finder.py new file mode 100644 index 00000000..be21a472 --- /dev/null +++ b/tit/gui/components/atlas_region_finder.py @@ -0,0 +1,202 @@ +#!/usr/bin/env simnibs_python +# -*- coding: utf-8 -*- + +"""Interactive, searchable, multi-select atlas region picker. + +This module provides :class:`AtlasRegionFinderDialog`, a reusable dialog that +lets the user search and select one or more atlas regions from a supplied list +of ``(id, name, rgb)`` entries. It replaces several hand-rolled dialogs across +the GUI (the flex tab's two read-only region listers and the analyzer tab's +inline picker) with a single, unambiguous, id-aware component. + +The dialog is *pure UI*: it performs no disk access. Callers are responsible +for loading atlas labels (e.g. via ``MeshAtlasManager`` / ``VoxelAtlasManager``) +and passing them in as ``entries``. + +A module-level helper, :func:`merge_into_lineedit`, writes selected values back +into an existing ``QLineEdit``, de-duplicating while preserving order. +""" + +from __future__ import annotations + +from typing import Iterable, List, Optional, Sequence, Tuple + +from PyQt5 import QtCore, QtWidgets + + +class AtlasRegionFinderDialog(QtWidgets.QDialog): + """Searchable, multi-select dialog for choosing atlas regions. + + Each entry is displayed as ``"{id}: {name}"`` (label number and name only). + The ``(id, name)`` pair is stored on every list item via ``Qt.UserRole`` so + selections are resolved unambiguously (no fragile string parsing). A search + field filters items by id *or* name substring (case-insensitive). + + Args: + parent: Parent widget (``QWidget`` or ``None``). + title: Window title for the dialog. + entries: Sequence of ``(id, name, rgb)`` tuples where ``id`` is an int, + ``name`` a str, and ``rgb`` a ``(r, g, b)`` tuple or ``None``. + return_field: Which field :meth:`selected_values` returns -- ``"id"`` + or ``"name"``. Defaults to ``"id"``. + multi: If ``True`` (default) allow multi-selection + (``ExtendedSelection``); otherwise single selection. + preselected: Optional iterable of ids-or-names to pre-select on open. + Values are compared as strings, so both region ids (int or str) + and region names match; callers may pass chip keys directly. + """ + + def __init__( + self, + parent: Optional[QtWidgets.QWidget], + title: str, + entries: Sequence[Tuple[int, str, Optional[Tuple]]], + return_field: str = "id", + multi: bool = True, + preselected: Optional[Iterable] = None, + ) -> None: + super().__init__(parent) + + if return_field not in ("id", "name"): + raise ValueError( + f"return_field must be 'id' or 'name', got {return_field!r}" + ) + self._return_field = return_field + + self.setWindowTitle(title) + self.setMinimumWidth(450) + self.setMinimumHeight(500) + + layout = QtWidgets.QVBoxLayout(self) + + # ── Search box ─────────────────────────────────────────────────── + search_layout = QtWidgets.QHBoxLayout() + search_layout.addWidget(QtWidgets.QLabel("Search:")) + self.search_input = QtWidgets.QLineEdit() + self.search_input.setPlaceholderText("Filter by id or name...") + search_layout.addWidget(self.search_input) + layout.addLayout(search_layout) + + # ── Region list ────────────────────────────────────────────────── + self.list_widget = QtWidgets.QListWidget() + self.list_widget.setSelectionMode( + QtWidgets.QAbstractItemView.ExtendedSelection + if multi + else QtWidgets.QAbstractItemView.SingleSelection + ) + preselected_set = {str(x) for x in preselected} if preselected else set() + for entry in entries or []: + self._add_entry(entry, preselected_set) + layout.addWidget(self.list_widget) + + # ── Buttons ────────────────────────────────────────────────────── + btn_layout = QtWidgets.QHBoxLayout() + self.add_btn = QtWidgets.QPushButton("Add Selected") + self.cancel_btn = QtWidgets.QPushButton("Cancel") + btn_layout.addWidget(self.add_btn) + btn_layout.addWidget(self.cancel_btn) + layout.addLayout(btn_layout) + + # ── Wiring ─────────────────────────────────────────────────────── + # Clicking / double-clicking a row only updates the highlight (Qt + # selection); nothing is loaded and the dialog is not accepted until + # the user presses "Add Selected". + self.search_input.textChanged.connect(self._filter) + self.add_btn.clicked.connect(self._on_accept) + self.cancel_btn.clicked.connect(self.reject) + + # ------------------------------------------------------------------ + # Construction helpers + # ------------------------------------------------------------------ + + def _add_entry( + self, + entry: Tuple[int, str, Optional[Tuple]], + preselected: set, + ) -> None: + """Create and append a list item for a single ``(id, name, rgb)`` entry.""" + region_id, name, _rgb = entry + item = QtWidgets.QListWidgetItem(f"{region_id}: {name}") + item.setData(QtCore.Qt.UserRole, (int(region_id), str(name))) + self.list_widget.addItem(item) + if str(region_id) in preselected or name in preselected: + item.setSelected(True) + + # ------------------------------------------------------------------ + # Behaviour + # ------------------------------------------------------------------ + + def _filter(self, text: str) -> None: + """Hide items whose id and name do not contain ``text`` (case-insensitive).""" + query = text.strip().lower() + for i in range(self.list_widget.count()): + item = self.list_widget.item(i) + region_id, name = item.data(QtCore.Qt.UserRole) + haystack = f"{region_id} {name}".lower() + item.setHidden(query not in haystack) + + def _on_accept(self) -> None: + """Accept the dialog if at least one region is selected.""" + if not self.list_widget.selectedItems(): + return + self.accept() + + # ------------------------------------------------------------------ + # Accessors + # ------------------------------------------------------------------ + + def _selected_pairs(self) -> List[Tuple[int, str]]: + """Return selected ``(id, name)`` pairs in top-to-bottom list order.""" + pairs: List[Tuple[int, str]] = [] + for i in range(self.list_widget.count()): + item = self.list_widget.item(i) + if item.isSelected(): + pairs.append(item.data(QtCore.Qt.UserRole)) + return pairs + + def selected_ids(self) -> List[int]: + """Return the ids of the selected regions.""" + return [pair[0] for pair in self._selected_pairs()] + + def selected_names(self) -> List[str]: + """Return the names of the selected regions.""" + return [pair[1] for pair in self._selected_pairs()] + + def selected_values(self) -> List: + """Return selected ids or names according to ``return_field``.""" + if self._return_field == "name": + return self.selected_names() + return self.selected_ids() + + +def merge_into_lineedit( + line_edit: QtWidgets.QLineEdit, + values: Iterable, + *, + replace_default: Optional[object] = None, +) -> None: + """Append comma-separated ``values`` into ``line_edit``, de-duplicating. + + Existing entries are preserved in order; new values are appended only if + not already present. If the field currently holds *only* ``replace_default`` + (e.g. ``"1"`` or ``"10"``), that placeholder is replaced rather than + appended to. + + Args: + line_edit: The target ``QLineEdit`` to update in place. + values: Iterable of values (str/int) to merge in. + replace_default: Optional placeholder value; when the field contains + only this value it is cleared before appending. + """ + existing = line_edit.text().strip() + current = [v.strip() for v in existing.split(",") if v.strip()] + + if replace_default is not None and current == [str(replace_default)]: + current = [] + + for value in values: + token = str(value).strip() + if token and token not in current: + current.append(token) + + line_edit.setText(", ".join(current)) diff --git a/tit/gui/components/help_icon.py b/tit/gui/components/help_icon.py new file mode 100644 index 00000000..45ab6aa2 --- /dev/null +++ b/tit/gui/components/help_icon.py @@ -0,0 +1,55 @@ +#!/usr/bin/env simnibs_python +# -*- coding: utf-8 -*- + +"""Reusable click-to-popup help affordance. + +This module provides :class:`HelpIcon`, a tiny clickable ``"?"`` button that +reveals contextual help in a small popup when clicked. It keeps a consistent +circular appearance across the GUI and is intentionally small and reusable. +""" + +from __future__ import annotations + +from typing import Optional + +from PyQt5 import QtCore, QtWidgets + + +class HelpIcon(QtWidgets.QToolButton): + """A small circular ``"?"`` button that shows help text on click. + + The icon is an interactive affordance: clicking it opens a small + information popup containing ``help_text``. The same text is also set as a + tooltip so it remains discoverable on hover. + + Args: + help_text: The text shown in the popup (and as a tooltip) on demand. + title: Window title for the popup dialog. Defaults to ``"Help"``. + parent: Optional parent widget. + """ + + def __init__( + self, + help_text: str, + title: str = "Help", + parent: Optional[QtWidgets.QWidget] = None, + ) -> None: + super().__init__(parent) + self._help_text = help_text + self._title = title + self.setText("?") + self.setToolTip(help_text) + self.setCursor(QtCore.Qt.PointingHandCursor) + self.setFixedSize(15, 15) + self.setFocusPolicy(QtCore.Qt.NoFocus) + self.setStyleSheet( + "QToolButton { color:#666; border:1px solid #999; " + "border-radius:7px; font-weight:bold; font-size:10px; " + "padding:0px; }" + "QToolButton:hover { color:#333; border-color:#666; }" + ) + self.clicked.connect(self._show_popup) + + def _show_popup(self) -> None: + """Show the help text in a small information popup.""" + QtWidgets.QMessageBox.information(self, self._title, self._help_text) diff --git a/tit/gui/components/region_chips.py b/tit/gui/components/region_chips.py new file mode 100644 index 00000000..789ec4ce --- /dev/null +++ b/tit/gui/components/region_chips.py @@ -0,0 +1,367 @@ +#!/usr/bin/env simnibs_python +# -*- coding: utf-8 -*- + +"""Reusable "chips/tags" widget for compact, removable region selections. + +This module provides :class:`RegionChipsWidget`, a small widget that displays a +set of selected regions as rounded pills ("chips"). Each chip shows a display +string and a "✕" remove button, and the chips wrap onto new rows when the +host is narrow. When nothing is selected, a muted placeholder is shown instead. + +A minimal Qt :class:`FlowLayout` helper (the standard wrapping layout from the +Qt examples) is implemented here so the widget has no external layout +dependency. +""" + +from __future__ import annotations + +from typing import List, Optional, Tuple + +from PyQt5 import QtCore, QtWidgets + +from tit.gui.style import ( + COLOR_ACCENT, + COLOR_ERROR, + COLOR_TEXT_MUTED, + FONT_HELP, + SP_XS, +) + + +# --------------------------------------------------------------------------- +# FlowLayout: a layout that lays items left-to-right and wraps to new rows. +# --------------------------------------------------------------------------- +class FlowLayout(QtWidgets.QLayout): + """A layout that arranges child items in rows, wrapping as needed. + + This is the standard Qt "flow layout" helper: items are placed + left-to-right and flow onto a new row whenever the current row runs out + of horizontal space. It supports ``heightForWidth`` so hosts can size it + correctly inside vertical layouts and scroll areas. + + Args: + parent: Optional parent widget. When given, the layout installs + itself on that widget. + margin: Uniform content margin in pixels. + spacing: Gap between items (both horizontal and vertical) in pixels. + """ + + def __init__( + self, + parent: Optional[QtWidgets.QWidget] = None, + margin: int = 0, + spacing: int = SP_XS, + ) -> None: + super().__init__(parent) + self._items: List[QtWidgets.QLayoutItem] = [] + self.setContentsMargins(margin, margin, margin, margin) + self.setSpacing(spacing) + + def addItem(self, item: QtWidgets.QLayoutItem) -> None: # noqa: N802 + self._items.append(item) + + def count(self) -> int: + return len(self._items) + + def itemAt(self, index: int) -> Optional[QtWidgets.QLayoutItem]: # noqa: N802 + if 0 <= index < len(self._items): + return self._items[index] + return None + + def takeAt(self, index: int) -> Optional[QtWidgets.QLayoutItem]: # noqa: N802 + if 0 <= index < len(self._items): + return self._items.pop(index) + return None + + def expandingDirections(self) -> QtCore.Qt.Orientations: # noqa: N802 + return QtCore.Qt.Orientations(QtCore.Qt.Orientation(0)) + + def hasHeightForWidth(self) -> bool: # noqa: N802 + return True + + def heightForWidth(self, width: int) -> int: # noqa: N802 + return self._do_layout(QtCore.QRect(0, 0, width, 0), test_only=True) + + def setGeometry(self, rect: QtCore.QRect) -> None: # noqa: N802 + super().setGeometry(rect) + self._do_layout(rect, test_only=False) + + def sizeHint(self) -> QtCore.QSize: # noqa: N802 + return self.minimumSize() + + def minimumSize(self) -> QtCore.QSize: # noqa: N802 + size = QtCore.QSize() + for item in self._items: + size = size.expandedTo(item.minimumSize()) + margins = self.contentsMargins() + size += QtCore.QSize( + margins.left() + margins.right(), + margins.top() + margins.bottom(), + ) + return size + + def _do_layout(self, rect: QtCore.QRect, test_only: bool) -> int: + """Position items within ``rect`` and return the total height. + + Args: + rect: The rectangle to lay items out in. + test_only: When ``True``, only compute the required height without + moving any widgets. + + Returns: + The total height required for the laid-out content. + """ + margins = self.contentsMargins() + effective = rect.adjusted( + margins.left(), + margins.top(), + -margins.right(), + -margins.bottom(), + ) + x = effective.x() + y = effective.y() + line_height = 0 + spacing = self.spacing() + + for item in self._items: + hint = item.sizeHint() + next_x = x + hint.width() + spacing + if next_x - spacing > effective.right() and line_height > 0: + x = effective.x() + y = y + line_height + spacing + next_x = x + hint.width() + spacing + line_height = 0 + if not test_only: + item.setGeometry(QtCore.QRect(QtCore.QPoint(x, y), hint)) + x = next_x + line_height = max(line_height, hint.height()) + + return y + line_height - rect.y() + margins.bottom() + + +# --------------------------------------------------------------------------- +# Chip styling +# --------------------------------------------------------------------------- +_CHIP_STYLE = f""" +QFrame#regionChip {{ + background-color: #eef3f8; + border: 1px solid #ccd7e0; + border-radius: 9px; +}} +QFrame#regionChip:hover {{ + border-color: {COLOR_ACCENT}; +}} +""" + +_CHIP_LABEL_STYLE = ( + f"border: none; background: transparent; font-size: {FONT_HELP}; color: #333;" +) + +_CHIP_REMOVE_STYLE = f""" +QToolButton {{ + border: none; + background: transparent; + color: {COLOR_TEXT_MUTED}; + font-size: {FONT_HELP}; + font-weight: bold; + padding: 0px; +}} +QToolButton:hover {{ + color: {COLOR_ERROR}; +}} +""" + + +class _ChipWidget(QtWidgets.QFrame): + """A single rounded pill showing a display string and a remove button. + + Args: + key: Opaque identifier carried by the chip (emitted on removal). + display: Human-readable text shown on the chip. + parent: Optional parent widget. + """ + + removed = QtCore.pyqtSignal(str) + + def __init__( + self, + key: str, + display: str, + parent: Optional[QtWidgets.QWidget] = None, + ) -> None: + super().__init__(parent) + self._key = key + self.setObjectName("regionChip") + self.setStyleSheet(_CHIP_STYLE) + + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(SP_XS + 2, 2, SP_XS, 2) + layout.setSpacing(SP_XS) + + label = QtWidgets.QLabel(display) + label.setStyleSheet(_CHIP_LABEL_STYLE) + label.setToolTip(display) + layout.addWidget(label) + + remove_btn = QtWidgets.QToolButton() + remove_btn.setText("✕") + remove_btn.setToolTip(f"Remove {display}") + remove_btn.setCursor(QtCore.Qt.PointingHandCursor) + remove_btn.setFocusPolicy(QtCore.Qt.NoFocus) + remove_btn.setFixedSize(14, 14) + remove_btn.setStyleSheet(_CHIP_REMOVE_STYLE) + remove_btn.clicked.connect(lambda: self.removed.emit(self._key)) + layout.addWidget(remove_btn) + + @property + def key(self) -> str: + """The opaque key this chip represents.""" + return self._key + + +class RegionChipsWidget(QtWidgets.QWidget): + """Compact, removable display of selected regions as wrapping chips. + + Each chip carries an opaque ``key`` plus its ``display`` text. Adding a + key that is already present is a no-op (de-duplication by key). Chips are + laid out left-to-right and wrap onto new rows via :class:`FlowLayout`. + When empty, a muted placeholder is shown instead. + + Signals: + changed: Emitted whenever the set of chips changes (add/remove/clear). + + Args: + parent: Optional parent widget. + placeholder: Text shown when no regions are selected. Falls back to a + sensible default when omitted. + """ + + changed = QtCore.pyqtSignal() + + _PLACEHOLDER_TEXT = "No regions selected — use Browse…" + + def __init__( + self, + parent: Optional[QtWidgets.QWidget] = None, + placeholder: Optional[str] = None, + ) -> None: + super().__init__(parent) + self._placeholder_text = placeholder or self._PLACEHOLDER_TEXT + # Insertion-ordered mapping of key -> display text. + self._items: "dict[str, str]" = {} + + self._flow = FlowLayout(self, margin=0, spacing=SP_XS) + + self._placeholder = QtWidgets.QLabel(self._placeholder_text) + self._placeholder.setStyleSheet( + f"color: {COLOR_TEXT_MUTED}; font-size: {FONT_HELP}; font-style: italic;" + ) + + # Keep at least ~1 row visible even when empty. + self.setMinimumHeight(24) + policy = self.sizePolicy() + policy.setHeightForWidth(True) + self.setSizePolicy(policy) + + self._rebuild() + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + def add_item(self, key: str, display: str) -> None: + """Add a chip. Adding an existing key is a no-op. + + Args: + key: Opaque identifier for the chip. + display: Text shown on the chip. + """ + if key in self._items: + return + self._items[key] = display + self._rebuild() + self.changed.emit() + + def remove(self, key: str) -> None: + """Remove the chip with ``key`` if present. + + Args: + key: The key of the chip to remove. + """ + if key not in self._items: + return + del self._items[key] + self._rebuild() + self.changed.emit() + + def clear(self) -> None: + """Remove all chips. No-op (and no signal) when already empty.""" + if not self._items: + return + self._items.clear() + self._rebuild() + self.changed.emit() + + def set_items(self, items: List[Tuple[str, str]]) -> None: + """Replace all chips with ``items``. + + Later duplicates of the same key overwrite the earlier display text + while preserving the key's original position. + + Args: + items: Sequence of ``(key, display)`` tuples. + """ + new_items: "dict[str, str]" = {} + for key, display in items: + new_items[key] = display + self._items = new_items + self._rebuild() + self.changed.emit() + + def keys(self) -> List[str]: + """Return the chip keys in insertion order.""" + return list(self._items.keys()) + + def items(self) -> List[Tuple[str, str]]: + """Return the chips as ``(key, display)`` tuples in insertion order. + + Suitable for snapshotting the current selection and restoring it later + via :meth:`set_items`. + """ + return list(self._items.items()) + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + def _clear_layout(self) -> None: + """Detach every item from the flow layout, deleting spent chips.""" + while self._flow.count(): + item = self._flow.takeAt(0) + widget = item.widget() if item is not None else None + if widget is None: + continue + widget.setParent(None) + if widget is not self._placeholder: + widget.deleteLater() + + def _rebuild(self) -> None: + """Rebuild the flow layout from the current item mapping.""" + self._clear_layout() + if not self._items: + self._flow.addWidget(self._placeholder) + self._placeholder.setVisible(True) + else: + for key, display in self._items.items(): + chip = _ChipWidget(key, display, self) + chip.removed.connect(self.remove) + self._flow.addWidget(chip) + self._flow.invalidate() + self.updateGeometry() + + # ------------------------------------------------------------------ + # Height-for-width plumbing so hosts size the widget correctly. + # ------------------------------------------------------------------ + def hasHeightForWidth(self) -> bool: # noqa: N802 + return True + + def heightForWidth(self, width: int) -> int: # noqa: N802 + return self._flow.heightForWidth(width) diff --git a/tit/gui/components/roi_picker.py b/tit/gui/components/roi_picker.py index a6de2274..dd5b8103 100644 --- a/tit/gui/components/roi_picker.py +++ b/tit/gui/components/roi_picker.py @@ -10,11 +10,12 @@ import subprocess from pathlib import Path -from PyQt5 import QtWidgets, QtCore, QtGui +from PyQt5 import QtWidgets, QtCore from tit.paths import get_path_manager from tit.atlas import MNI_ATLAS_DIR, MeshAtlasManager, VoxelAtlasManager -from tit.gui.style import FONT_HELP, FONT_SIZE_MONOSPACE +from tit.gui.components.atlas_region_finder import AtlasRegionFinderDialog +from tit.gui.components.region_chips import RegionChipsWidget from tit.opt.config import FlexConfig @@ -72,6 +73,11 @@ def __init__( self._subject_id: str | None = None self._project_dir: str | None = None + # Atlas/space the current chips belong to, so re-selecting the SAME + # value in a combo does not needlessly wipe the selection. + self._last_cortical_atlas = None + self._last_volume_key = None + self._setup_ui() self._connect_signals() @@ -93,20 +99,12 @@ def _setup_ui(self): self._mode_group = QtWidgets.QButtonGroup(self) self._radios = [] - if self._enable_spherical: - self.radio_spherical = QtWidgets.QRadioButton( - "Spherical (coordinates and radius)" - ) - radio_layout.addWidget(self.radio_spherical) - self._mode_group.addButton(self.radio_spherical, 0) - self._radios.append(self.radio_spherical) - else: - self.radio_spherical = None - + # Display / stacked order: Cortical (default), Subcortical, Spherical. + # Button ids match the stacked-page indices below. if self._enable_atlas: self.radio_cortical = QtWidgets.QRadioButton("Cortical") radio_layout.addWidget(self.radio_cortical) - self._mode_group.addButton(self.radio_cortical, 1) + self._mode_group.addButton(self.radio_cortical, 0) self._radios.append(self.radio_cortical) else: self.radio_cortical = None @@ -114,14 +112,24 @@ def _setup_ui(self): if self._enable_subcortical: self.radio_subcortical = QtWidgets.QRadioButton("Subcortical") radio_layout.addWidget(self.radio_subcortical) - self._mode_group.addButton(self.radio_subcortical, 2) + self._mode_group.addButton(self.radio_subcortical, 1) self._radios.append(self.radio_subcortical) else: self.radio_subcortical = None + if self._enable_spherical: + self.radio_spherical = QtWidgets.QRadioButton( + "Spherical (coordinates and radius)" + ) + radio_layout.addWidget(self.radio_spherical) + self._mode_group.addButton(self.radio_spherical, 2) + self._radios.append(self.radio_spherical) + else: + self.radio_spherical = None + radio_layout.addStretch() - # Default: first enabled mode is checked + # Default: first enabled mode is checked (Cortical when available) if self._radios: self._radios[0].setChecked(True) @@ -130,18 +138,21 @@ def _setup_ui(self): # --- Stacked widget --- self.stacked = QtWidgets.QStackedWidget() - # Page 0 — Spherical - self._spherical_page = self._build_spherical_page() - self.stacked.addWidget(self._spherical_page) - - # Page 1 — Cortical (Atlas) + # Page 0 — Cortical (Atlas) self._cortical_page = self._build_cortical_page() self.stacked.addWidget(self._cortical_page) - # Page 2 — Subcortical (Volume) + # Page 1 — Subcortical (Volume) self._subcortical_page = self._build_subcortical_page() self.stacked.addWidget(self._subcortical_page) + # Page 2 — Spherical + self._spherical_page = self._build_spherical_page() + self.stacked.addWidget(self._spherical_page) + + # Keep the stack sized to the current page (see _resize_stack_to_current). + self.stacked.currentChanged.connect(self._resize_stack_to_current) + # Set initial page if self._radios: checked_id = self._mode_group.checkedId() @@ -149,11 +160,19 @@ def _setup_ui(self): main_layout.addWidget(self.stacked) + # Collapse non-current pages so the stack hugs the initial page height. + self._resize_stack_to_current() + def _build_spherical_page(self) -> QtWidgets.QWidget: - """Build the spherical ROI input page.""" + """Build the spherical ROI input page. + + All spheres live in a single full-width table (one row per sphere). A + single row reproduces the classic single-sphere behavior; extra rows + union into one combined target. + """ page = QtWidgets.QWidget() - layout = QtWidgets.QFormLayout(page) - layout.setVerticalSpacing(3) + layout = QtWidgets.QVBoxLayout(page) + layout.setSpacing(5) layout.setContentsMargins(0, 5, 0, 5) # Coordinate space toggle @@ -168,59 +187,72 @@ def _build_spherical_page(self) -> QtWidgets.QWidget: space_layout.addWidget(self.space_subject_radio) space_layout.addWidget(self.space_mni_radio) space_layout.addStretch() - layout.addRow(space_widget) + layout.addWidget(space_widget) else: self.space_subject_radio = None self.space_mni_radio = None self.mni_info_label = None - # Coordinate inputs + Freeview button + # Header row: coordinate-space label + Freeview button + header_widget = QtWidgets.QWidget() + header_layout = QtWidgets.QHBoxLayout(header_widget) + header_layout.setContentsMargins(0, 0, 0, 0) self.coords_label = QtWidgets.QLabel("ROI Center RAS Coordinates (mm):") - coords_widget = QtWidgets.QWidget() - coords_layout = QtWidgets.QHBoxLayout(coords_widget) - coords_layout.setContentsMargins(0, 0, 0, 0) - - coords_layout.addWidget(QtWidgets.QLabel("X:")) - self.x_input = QtWidgets.QDoubleSpinBox() - self.x_input.setRange(-150, 150) - self.x_input.setValue(0) - self.x_input.setDecimals(2) - coords_layout.addWidget(self.x_input) - - coords_layout.addWidget(QtWidgets.QLabel("Y:")) - self.y_input = QtWidgets.QDoubleSpinBox() - self.y_input.setRange(-150, 150) - self.y_input.setValue(0) - self.y_input.setDecimals(2) - coords_layout.addWidget(self.y_input) - - coords_layout.addWidget(QtWidgets.QLabel("Z:")) - self.z_input = QtWidgets.QDoubleSpinBox() - self.z_input.setRange(-150, 150) - self.z_input.setValue(0) - self.z_input.setDecimals(2) - coords_layout.addWidget(self.z_input) + header_layout.addWidget(self.coords_label) + header_layout.addStretch() if self._enable_freeview_button: self.view_t1_btn = QtWidgets.QPushButton("View T1 in Freeview") self.view_t1_btn.setToolTip( "Open subject's T1 scan in Freeview to find RAS coordinates" ) - coords_layout.addWidget(self.view_t1_btn) + header_layout.addWidget(self.view_t1_btn) else: self.view_t1_btn = None - coords_layout.addStretch() - layout.addRow(self.coords_label, coords_widget) + layout.addWidget(header_widget) - # Radius - self.radius_label = QtWidgets.QLabel("ROI Radius (mm):") - self.radius_input = QtWidgets.QDoubleSpinBox() - self.radius_input.setRange(1, 50) - self.radius_input.setValue(10) - self.radius_input.setDecimals(2) - layout.addRow(self.radius_label, self.radius_input) + # Unified spheres table — every sphere is a row. + self.spheres_table = QtWidgets.QTableWidget(0, 5) + self.spheres_table.setHorizontalHeaderLabels( + ["X (mm)", "Y (mm)", "Z (mm)", "Radius (mm)", ""] + ) + sph_header = self.spheres_table.horizontalHeader() + for col in range(4): + sph_header.setSectionResizeMode(col, QtWidgets.QHeaderView.Stretch) + sph_header.setSectionResizeMode(4, QtWidgets.QHeaderView.Fixed) + self.spheres_table.setColumnWidth(4, 40) + self.spheres_table.verticalHeader().setVisible(True) + self.spheres_table.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows) + self.spheres_table.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) + self.spheres_table.setMinimumHeight(190) + self.spheres_table.setSizePolicy( + QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding + ) + self.spheres_table.setToolTip( + "Each row is a sphere. Multiple rows union into one combined target; " + "a single row is a plain single-sphere ROI (unchanged behavior)." + ) + layout.addWidget(self.spheres_table) + + # Sphere management buttons + sphere_btn_widget = QtWidgets.QWidget() + sphere_btn_layout = QtWidgets.QHBoxLayout(sphere_btn_widget) + sphere_btn_layout.setContentsMargins(0, 0, 0, 0) + self.add_sphere_btn = QtWidgets.QPushButton("Add Sphere") + self.add_sphere_btn.setToolTip("Union another sphere into this ROI.") + self.duplicate_sphere_btn = QtWidgets.QPushButton("Duplicate Selected") + self.duplicate_sphere_btn.setToolTip( + "Copy the selected sphere row(s) as new rows." + ) + self.remove_sphere_btn = QtWidgets.QPushButton("Remove Selected") + self.remove_sphere_btn.setToolTip("Remove the selected sphere row(s).") + sphere_btn_layout.addWidget(self.add_sphere_btn) + sphere_btn_layout.addWidget(self.duplicate_sphere_btn) + sphere_btn_layout.addWidget(self.remove_sphere_btn) + sphere_btn_layout.addStretch() + layout.addWidget(sphere_btn_widget) # Volumetric toggle + tissue type vol_widget = QtWidgets.QWidget() @@ -251,22 +283,34 @@ def _build_spherical_page(self) -> QtWidgets.QWidget: vol_layout.addWidget(self.sphere_tissue_combo) vol_layout.addStretch() - layout.addRow(vol_widget) + layout.addWidget(vol_widget) # Wire checkbox to enable/disable tissue combo self.volumetric_checkbox.toggled.connect(self.sphere_tissue_combo.setEnabled) self.volumetric_checkbox.toggled.connect(self.sphere_tissue_label.setEnabled) + # Seed one default sphere so the table is never empty. + self._add_sphere_row() + return page def _build_cortical_page(self) -> QtWidgets.QWidget: - """Build the cortical (atlas annotation) ROI input page.""" + """Build the cortical (atlas annotation) ROI input page. + + Selected regions are shown as removable chips (:class:`RegionChipsWidget`) + rather than typed free-text. Each chip carries a hemisphere-prefixed + region *name* (e.g. ``lh.precentral``) as its key and displays both the + name and its ``.annot`` label index. Regions are added via the + "List Regions" finder (which lists both hemispheres by name); the + hemisphere is carried in each name, so there is no separate hemisphere + selector. + """ page = QtWidgets.QWidget() layout = QtWidgets.QFormLayout(page) layout.setVerticalSpacing(3) layout.setContentsMargins(0, 5, 0, 5) - # Atlas combo + hemisphere + refresh + list regions + # Atlas combo + refresh + list regions atlas_widget = QtWidgets.QWidget() atlas_layout = QtWidgets.QHBoxLayout(atlas_widget) atlas_layout.setContentsMargins(0, 0, 0, 0) @@ -277,27 +321,32 @@ def _build_cortical_page(self) -> QtWidgets.QWidget: ) atlas_layout.addWidget(self.atlas_combo) - self.hemi_label = QtWidgets.QLabel("Hemisphere:") - atlas_layout.addWidget(self.hemi_label) - - self.hemi_combo = QtWidgets.QComboBox() - self.hemi_combo.addItems(["Left (lh)", "Right (rh)"]) - atlas_layout.addWidget(self.hemi_combo) - self.refresh_atlases_btn = QtWidgets.QPushButton("Refresh") atlas_layout.addWidget(self.refresh_atlases_btn) self.list_regions_btn = QtWidgets.QPushButton("List Regions") atlas_layout.addWidget(self.list_regions_btn) + self.clear_regions_btn = QtWidgets.QPushButton("Clear") + self.clear_regions_btn.setToolTip("Remove all selected cortical regions.") + atlas_layout.addWidget(self.clear_regions_btn) + atlas_layout.addStretch() layout.addRow(QtWidgets.QLabel("Atlas:"), atlas_widget) - # Region label value - self.label_value_input = QtWidgets.QSpinBox() - self.label_value_input.setRange(1, 10000) - self.label_value_input.setValue(1) - layout.addRow(QtWidgets.QLabel("Region Label Value:"), self.label_value_input) + # Selected region(s) shown as removable chips. Regions are added via + # "List Regions" (both hemispheres, by name), de-duplicated by chip key + # ("hemi.name"). Multiple regions union into one combined target. + self.cortical_chips = RegionChipsWidget( + placeholder="No regions selected — use List Regions…" + ) + self.cortical_chips.setToolTip( + "Selected cortical regions. Use 'List Regions' to add hemisphere-" + "prefixed regions (e.g. lh.precentral); each chip shows the name and " + "its .annot label index. Multiple regions union into one combined " + "target; press ✕ on a chip to remove it." + ) + layout.addRow(QtWidgets.QLabel("Region(s):"), self.cortical_chips) return page @@ -308,7 +357,7 @@ def _build_subcortical_page(self) -> QtWidgets.QWidget: layout.setVerticalSpacing(3) layout.setContentsMargins(0, 5, 0, 5) - # Volume atlas combo + refresh + list regions + # Atlas space (subject / MNI) toggle — kept at the top. source_widget = QtWidgets.QWidget() source_layout = QtWidgets.QHBoxLayout(source_widget) source_layout.setContentsMargins(0, 0, 0, 0) @@ -328,6 +377,20 @@ def _build_subcortical_page(self) -> QtWidgets.QWidget: source_layout.addStretch() layout.addRow(QtWidgets.QLabel("Atlas Space:"), source_widget) + # Tissue type — placed above the volume atlas selector. + self.tissue_combo = QtWidgets.QComboBox() + self.tissue_combo.addItem("Gray Matter (GM)", "GM") + self.tissue_combo.addItem("White Matter (WM)", "WM") + self.tissue_combo.addItem("GM + WM (both)", "both") + self.tissue_combo.setToolTip( + "Tissue compartment(s) to include when evaluating the volume ROI. " + "GM is appropriate for most subcortical targets (e.g. thalamus, " + "hippocampus). WM or Both can be used when the target overlaps " + "white-matter tracts." + ) + layout.addRow(QtWidgets.QLabel("Tissue Type:"), self.tissue_combo) + + # Volume atlas combo + refresh + list regions + clear. volume_widget = QtWidgets.QWidget() volume_layout = QtWidgets.QHBoxLayout(volume_widget) volume_layout.setContentsMargins(0, 0, 0, 0) @@ -344,31 +407,30 @@ def _build_subcortical_page(self) -> QtWidgets.QWidget: self.list_volume_regions_btn = QtWidgets.QPushButton("List Regions") volume_layout.addWidget(self.list_volume_regions_btn) + self.clear_volume_regions_btn = QtWidgets.QPushButton("Clear") + self.clear_volume_regions_btn.setToolTip( + "Remove all selected subcortical regions." + ) + volume_layout.addWidget(self.clear_volume_regions_btn) + volume_layout.addStretch() layout.addRow(QtWidgets.QLabel("Volume Atlas:"), volume_widget) - # Region label value - self.volume_label_input = QtWidgets.QSpinBox() - self.volume_label_input.setRange(1, 10000) - self.volume_label_input.setValue(10) - self.volume_label_input.setToolTip( - "Common values: 10=Left-Thalamus, 49=Right-Thalamus, " - "17=Left-Hippocampus, 53=Right-Hippocampus" + # Selected region(s) shown as removable chips, below the volume atlas. + # Regions are added via "List Regions" (present labels, by name), + # de-duplicated by chip key (the integer atlas label id). Multiple + # regions union into one target. + self.subcortical_chips = RegionChipsWidget( + placeholder="No regions selected — use List Regions…" ) - layout.addRow(QtWidgets.QLabel("Region Label Value:"), self.volume_label_input) - - # Tissue type - self.tissue_combo = QtWidgets.QComboBox() - self.tissue_combo.addItem("Gray Matter (GM)", "GM") - self.tissue_combo.addItem("White Matter (WM)", "WM") - self.tissue_combo.addItem("GM + WM (both)", "both") - self.tissue_combo.setToolTip( - "Tissue compartment(s) to include when evaluating the volume ROI. " - "GM is appropriate for most subcortical targets (e.g. thalamus, " - "hippocampus). WM or Both can be used when the target overlaps " - "white-matter tracts." + self.subcortical_chips.setToolTip( + "Selected subcortical regions. Use 'List Regions' to add atlas " + "labels by name (e.g. Left-Hippocampus); each chip shows the name " + "and its integer label id. Multiple regions union into one combined " + "target (e.g. 17,53 = both hippocampi); press ✕ on a chip to remove " + "it." ) - layout.addRow(QtWidgets.QLabel("Tissue Type:"), self.tissue_combo) + layout.addRow(QtWidgets.QLabel("Region(s):"), self.subcortical_chips) return page @@ -400,23 +462,73 @@ def _connect_signals(self): self.volume_subject_radio.toggled.connect(self._refresh_volume_atlases) self.volume_mni_radio.toggled.connect(self._refresh_volume_atlases) - # Emit roi_changed on any value change - self.x_input.valueChanged.connect(self.roi_changed) - self.y_input.valueChanged.connect(self.roi_changed) - self.z_input.valueChanged.connect(self.roi_changed) - self.radius_input.valueChanged.connect(self.roi_changed) + # Clear buttons drop the whole current selection. + self.clear_regions_btn.clicked.connect(self.cortical_chips.clear) + self.clear_volume_regions_btn.clicked.connect(self.subcortical_chips.clear) + + # No cross-atlas mixing: regions are atlas-specific, so switching the + # cortical atlas, the volume atlas, or the volume atlas space starts a + # fresh selection. These are wired to *user-only* signals + # (QComboBox.activated / QButtonGroup.buttonClicked) rather than + # currentIndexChanged / toggled, so programmatic repopulation during a + # refresh does not spuriously wipe a restored selection. + self.atlas_combo.activated.connect(self._on_cortical_atlas_changed) + self.volume_atlas_combo.activated.connect(self._on_volume_atlas_changed) + self._volume_space_group.buttonClicked.connect(self._on_volume_atlas_changed) + + # Multi-sphere add/duplicate/remove (lambdas so the button's bool + # 'checked' arg is not passed as a coordinate/row argument). + self.add_sphere_btn.clicked.connect(lambda: self._add_sphere_row()) + self.duplicate_sphere_btn.clicked.connect( + lambda: self._duplicate_selected_sphere_rows() + ) + self.remove_sphere_btn.clicked.connect( + lambda: self._remove_selected_sphere_rows() + ) + + # Emit roi_changed on any value change (per-row sphere spin boxes wire + # themselves in _add_sphere_row). self.volumetric_checkbox.toggled.connect(self.roi_changed) self.sphere_tissue_combo.currentIndexChanged.connect(self.roi_changed) self.atlas_combo.currentIndexChanged.connect(self.roi_changed) - self.hemi_combo.currentIndexChanged.connect(self.roi_changed) - self.label_value_input.valueChanged.connect(self.roi_changed) + self.cortical_chips.changed.connect(self.roi_changed) self.volume_atlas_combo.currentIndexChanged.connect(self.roi_changed) self.volume_subject_radio.toggled.connect(self.roi_changed) self.volume_mni_radio.toggled.connect(self.roi_changed) - self.volume_label_input.valueChanged.connect(self.roi_changed) + self.subcortical_chips.changed.connect(self.roi_changed) self.tissue_combo.currentIndexChanged.connect(self.roi_changed) self._mode_group.buttonClicked.connect(lambda: self.roi_changed.emit()) + def _on_cortical_atlas_changed(self, *_): + """Clear the cortical region chips when the user picks a new atlas. + + Region names/indices are specific to a parcellation, so a chip added + under one atlas is meaningless under another; switching atlases must + start fresh. Wired to ``activated`` (user-only), so a programmatic + repopulation during refresh does not clear a restored selection. + """ + atlas = self.atlas_combo.currentText() + if atlas == self._last_cortical_atlas: + return + self._last_cortical_atlas = atlas + self.cortical_chips.clear() + + def _on_volume_atlas_changed(self, *_): + """Clear the subcortical region chips when the user picks a new volume + atlas or switches the atlas space (subject/MNI). + + Label ids are specific to a given atlas volume, so mixing regions across + atlases/spaces is invalid; changing either starts fresh. Wired to + user-only signals (``activated`` / ``buttonClicked``), so programmatic + repopulation during refresh does not clear a restored selection. + """ + space = "mni" if self.volume_mni_radio.isChecked() else "subject" + key = (space, self.volume_atlas_combo.currentText()) + if key == self._last_volume_key: + return + self._last_volume_key = key + self.subcortical_chips.clear() + # ------------------------------------------------------------------ # Mode switching # ------------------------------------------------------------------ @@ -428,14 +540,45 @@ def _on_mode_changed(self, button): # Show/hide Freeview button (only visible in spherical mode) if self.view_t1_btn is not None: - self.view_t1_btn.setVisible(checked_id == 0) + self.view_t1_btn.setVisible(self.get_roi_type() == "spherical") self._update_coordinate_space_labels() + def _resize_stack_to_current(self, index=None): + """Size the stacked widget to the current page instead of the tallest. + + A ``QStackedWidget`` normally reserves the height of its tallest page + (here the spherical spheres-table page), leaving large empty space on + the compact cortical/subcortical pages. Collapsing every non-current + page's vertical size policy to ``Ignored`` (and keeping the current + page ``Preferred``) makes the stack size to whichever page is showing. + The spherical table keeps its own minimum height, so it still gets + adequate room when active. + + Args: + index: Unused; accepted so this can be wired to ``currentChanged``. + """ + current = self.stacked.currentWidget() + for i in range(self.stacked.count()): + page = self.stacked.widget(i) + if page is None: + continue + policy = page.sizePolicy() + policy.setVerticalPolicy( + QtWidgets.QSizePolicy.Preferred + if page is current + else QtWidgets.QSizePolicy.Ignored + ) + page.setSizePolicy(policy) + if current is not None: + current.adjustSize() + self.stacked.updateGeometry() + self.stacked.adjustSize() + self.updateGeometry() + def _update_coordinate_space_labels(self): """Update coordinate labels and tooltips based on space selection.""" if self.get_roi_type() != "spherical": - pass # mni_info_label removed return if self.is_mni_space(): @@ -445,10 +588,6 @@ def _update_coordinate_space_labels(self): "for each subject)" ) self.coords_label.setStyleSheet("color: #007ACC; font-weight: bold;") - pass # mni_info_label removed - self.x_input.setToolTip("X coordinate in MNI space") - self.y_input.setToolTip("Y coordinate in MNI space") - self.z_input.setToolTip("Z coordinate in MNI space") if self.view_t1_btn is not None: self.view_t1_btn.setText("View MNI Template") self.view_t1_btn.setToolTip( @@ -458,16 +597,297 @@ def _update_coordinate_space_labels(self): self.coords_label.setText("ROI Center RAS Coordinates (mm):") self.coords_label.setToolTip("Subject-specific RAS coordinates") self.coords_label.setStyleSheet("") - pass # mni_info_label removed - self.x_input.setToolTip("X coordinate in subject RAS space") - self.y_input.setToolTip("Y coordinate in subject RAS space") - self.z_input.setToolTip("Z coordinate in subject RAS space") if self.view_t1_btn is not None: self.view_t1_btn.setText("View T1 in Freeview") self.view_t1_btn.setToolTip( "Open subject's T1 scan in Freeview to find RAS coordinates" ) + # ------------------------------------------------------------------ + # Multi-sphere / multi-label helpers + # ------------------------------------------------------------------ + + def _add_sphere_row( + self, x: float = 0.0, y: float = 0.0, z: float = 0.0, r: float = 10.0 + ): + """Append a sphere row (X/Y/Z/Radius spin boxes + delete button).""" + table = self.spheres_table + row = table.rowCount() + table.insertRow(row) + specs = [(-150, 150, x), (-150, 150, y), (-150, 150, z), (1, 50, r)] + for col, (lo, hi, val) in enumerate(specs): + spin = QtWidgets.QDoubleSpinBox() + spin.setRange(lo, hi) + spin.setDecimals(2) + spin.setValue(val) + spin.valueChanged.connect(self.roi_changed) + table.setCellWidget(row, col, spin) + + del_btn = QtWidgets.QPushButton("✕") + del_btn.setFixedWidth(32) + del_btn.setToolTip("Remove this sphere.") + del_btn.clicked.connect(lambda: self._remove_sphere_row_widget(del_btn)) + table.setCellWidget(row, 4, del_btn) + + self.roi_changed.emit() + + def _row_values(self, row: int) -> tuple[float, float, float, float] | None: + """Return ``(x, y, z, radius)`` for a table row, or ``None`` if empty.""" + table = self.spheres_table + widgets = [table.cellWidget(row, col) for col in range(4)] + if any(w is None for w in widgets): + return None + xw, yw, zw, rw = widgets + return xw.value(), yw.value(), zw.value(), rw.value() + + def _remove_sphere_at(self, row: int): + """Remove the sphere at ``row``, always keeping at least one row.""" + table = self.spheres_table + if table.rowCount() <= 1: + return + table.removeRow(row) + self.roi_changed.emit() + + def _remove_sphere_row_widget(self, button: QtWidgets.QPushButton): + """Remove the row whose trailing delete button is ``button``. + + Rows shift as siblings are removed, so the owning row is resolved by + widget identity rather than a captured index. + """ + table = self.spheres_table + for row in range(table.rowCount()): + if table.cellWidget(row, 4) is button: + self._remove_sphere_at(row) + return + + def _remove_selected_sphere_rows(self): + """Remove the selected sphere rows, always keeping at least one row.""" + table = self.spheres_table + rows = sorted({idx.row() for idx in table.selectedIndexes()}, reverse=True) + if not rows: + return + changed = False + for row in rows: + if table.rowCount() <= 1: + break + table.removeRow(row) + changed = True + if changed: + self.roi_changed.emit() + + def _duplicate_selected_sphere_rows(self): + """Append a copy of each selected sphere row (last row if none selected).""" + table = self.spheres_table + rows = sorted({idx.row() for idx in table.selectedIndexes()}) + if not rows and table.rowCount(): + rows = [table.rowCount() - 1] + # Snapshot values first so appended rows are not re-copied. + values = [self._row_values(row) for row in rows] + for vals in values: + if vals is None: + continue + x, y, z, r = vals + self._add_sphere_row(x, y, z, r) + + def _collect_spheres(self) -> tuple[list[list[float]], list[float]]: + """Return ``(centers, radii)`` for every sphere row in the table. + + A single row reproduces the pre-union behavior exactly. If the table is + somehow empty, a single default sphere is returned so callers always see + at least one sphere. + """ + centers: list[list[float]] = [] + radii: list[float] = [] + table = self.spheres_table + for row in range(table.rowCount()): + vals = self._row_values(row) + if vals is None: + continue + x, y, z, r = vals + centers.append([x, y, z]) + radii.append(r) + if not centers: + centers = [[0.0, 0.0, 0.0]] + radii = [10.0] + return centers, radii + + def set_spheres( + self, + centers: list[list[float]], + radii: list[float], + ): + """Repopulate the spheres table from saved coordinates and radii. + + The inverse of :meth:`_collect_spheres`: clears existing rows and adds + one row per ``(center, radius)`` pair. When no spheres are supplied a + single default row is seeded so the table is never empty. + + Args: + centers: Sequence of ``[x, y, z]`` coordinate triples. + radii: Sequence of radii, one per center. + """ + table = self.spheres_table + table.setRowCount(0) + if not centers: + self._add_sphere_row() + return + for center, radius in zip(centers, radii): + x, y, z = center + self._add_sphere_row(float(x), float(y), float(z), float(radius)) + + def set_cortical_regions(self, names: list[str]): + """Repopulate the cortical region chips from saved region names. + + The inverse of the cortical branch of :meth:`get_roi_params`: replaces + every chip with one per hemisphere-prefixed name. Where the current + atlas annotation resolves a name to its ``.annot`` label index the chip + shows both name and index; otherwise the name is shown alone. + + Args: + names: Sequence of ``"hemi.name"`` tokens (e.g. ``"lh.precentral"``). + """ + name_map = self._cortical_name_index_map() + items: list[tuple[str, str]] = [] + for token in names: + token = str(token).strip() + if not token: + continue + display = token + hemi, sep, name = token.partition(".") + if sep == ".": + index = name_map.get((hemi, name)) + if index is not None: + display = f"{token} · {index}" + items.append((token, display)) + self.cortical_chips.set_items(items) + + def set_subcortical_regions(self, ids: list): + """Repopulate the subcortical region chips from saved label ids. + + The inverse of the subcortical branch of :meth:`get_roi_params`: + replaces every chip with one per integer atlas label id. Where the + current volume atlas resolves an id to a region name the chip shows + both name and id; otherwise the id is shown alone. + + Args: + ids: Sequence of integer atlas label ids (ints or numeric strings). + """ + id_name = self._subcortical_id_name_map() + items: list[tuple[str, str]] = [] + for value in ids: + token = str(value).strip() + if not token: + continue + name = None + if token.lstrip("-").isdigit(): + name = id_name.get(int(token)) + display = f"{name} · {token}" if name else token + items.append((token, display)) + self.subcortical_chips.set_items(items) + + @staticmethod + def _parse_region_names(text: str) -> list[tuple[str, str]]: + """Parse comma-separated hemisphere-prefixed cortical region names. + + Accepts analyzer-style tokens such as ``lh.precentral`` or + ``rh.superiorfrontal``. + + Returns: + List of ``(hemisphere, region_name)`` tuples, e.g. + ``("lh", "precentral")``. + + Raises: + ValueError: If any non-empty token is not of the form + ``lh.`` or ``rh.``. + """ + tokens: list[tuple[str, str]] = [] + for part in text.split(","): + part = part.strip() + if not part: + continue + hemi, sep, name = part.partition(".") + if sep != "." or hemi not in ("lh", "rh") or not name: + raise ValueError(f"Invalid cortical region name: {part!r}") + tokens.append((hemi, name)) + return tokens + + def _cortical_name_index_map(self) -> dict[tuple[str, str], int]: + """Map ``(hemi, region_name) -> annot label index`` for the current atlas. + + Built from the cached lh/rh ``.annot`` files discovered for the current + subject. Region label indices are intrinsic to an atlas parcellation + (the ``.annot`` colour-table ordering), so the same indices apply to + every subject sharing that atlas. Returns an empty dict when the atlas + files cannot be read (e.g. no subject set yet). + """ + atlas_display = self.atlas_combo.currentText() + mapping: dict[tuple[str, str], int] = {} + for hemi in ("lh", "rh"): + annot_file = self.atlases.get((hemi, atlas_display)) + if not annot_file: + continue + try: + regions = MeshAtlasManager("").list_annot_regions(annot_file) + except (OSError, ValueError): + continue + for idx, name in regions: + mapping[(hemi, name)] = idx + return mapping + + def _subcortical_id_name_map(self) -> dict[int, str]: + """Map ``label_id -> region name`` for the currently selected volume atlas. + + Resolves names from a sidecar LUT when present, otherwise from the + atlas volume's own integer labels via the bundled FreeSurfer colour + table (Agent A's resolver, :meth:`_resolve_volume_label_entries`). + Returns an empty dict when the atlas cannot be read (e.g. no subject + set yet); callers fall back to showing the bare id. + """ + atlas_path = self.volume_atlas_combo.currentData() + if not atlas_path: + return {} + atlas_path = str(atlas_path) + mapping: dict[int, str] = {} + try: + lut_file = self._find_volume_lut(atlas_path) + if lut_file is not None: + with open(lut_file, "r") as f: + for line in f: + line = line.strip() + if not line or line.startswith("#"): + continue + parsed = self._parse_lut_line(line) + if parsed is None: + continue + label_id, label_name, _ = parsed + mapping[int(label_id)] = label_name + else: + for label_id, name, _ in self._resolve_volume_label_entries(atlas_path): + mapping[int(label_id)] = name + except Exception: + # nibabel can raise ImageFileError (not an OSError) on an unreadable + # atlas; match the finder path and degrade to bare-id chips. + return {} + return mapping + + @staticmethod + def _parse_labels(text: str) -> list[int]: + """Parse comma-separated integer atlas labels (mirrors analyzer input). + + Raises: + ValueError: If any non-empty token is not an integer. + """ + return [int(part.strip()) for part in text.split(",") if part.strip()] + + @staticmethod + def _collapse(values: list): + """Collapse a single-element list to a scalar; pass longer lists through. + + Keeps single-region ROI dataclasses (and their serialized JSON) + byte-identical to the pre-union format, while a union yields a list. + """ + return values[0] if len(values) == 1 else values + # ------------------------------------------------------------------ # Public API # ------------------------------------------------------------------ @@ -495,9 +915,7 @@ def get_roi_type(self) -> str: One of ``'spherical'``, ``'atlas'``, or ``'subcortical'``. """ checked_id = self._mode_group.checkedId() - return {0: "spherical", 1: "atlas", 2: "subcortical"}.get( - checked_id, "spherical" - ) + return {0: "atlas", 1: "subcortical", 2: "spherical"}.get(checked_id, "atlas") def get_roi_params(self) -> dict: """Return a dict describing the current ROI selection. @@ -511,14 +929,14 @@ def get_roi_params(self) -> dict: """ roi_type = self.get_roi_type() if roi_type == "spherical": + centers, radii = self._collect_spheres() d = { "method": "spherical", - "center": [ - self.x_input.value(), - self.y_input.value(), - self.z_input.value(), - ], - "radius": self.radius_input.value(), + "center": centers[0], + "radius": radii[0], + "centers": centers, + "radii": radii, + "num_spheres": len(centers), "volumetric": self.volumetric_checkbox.isChecked(), } if self.volumetric_checkbox.isChecked(): @@ -528,14 +946,14 @@ def get_roi_params(self) -> dict: return { "method": "atlas", "atlas": self.atlas_combo.currentText(), - "region": str(self.label_value_input.value()), + "region": ", ".join(self.cortical_chips.keys()), } else: # subcortical return { "method": "subcortical", "volume_atlas": self.volume_atlas_combo.currentText(), "volume_atlas_space": self._selected_volume_atlas_space(), - "volume_region": str(self.volume_label_input.value()), + "volume_region": ", ".join(self.subcortical_chips.keys()), "tissues": self.tissue_combo.currentData(), } @@ -561,11 +979,12 @@ def get_roi_spec(self, subject_id: str, project_dir: str): roi_type = self.get_roi_type() if roi_type == "spherical": vol = self.volumetric_checkbox.isChecked() + centers, radii = self._collect_spheres() return FlexConfig.SphericalROI( - x=self.x_input.value(), - y=self.y_input.value(), - z=self.z_input.value(), - radius=self.radius_input.value(), + x=self._collapse([c[0] for c in centers]), + y=self._collapse([c[1] for c in centers]), + z=self._collapse([c[2] for c in centers]), + radius=self._collapse(radii), use_mni=self.is_mni_space(), volumetric=vol, tissues=self.sphere_tissue_combo.currentData() if vol else "GM", @@ -574,18 +993,39 @@ def get_roi_spec(self, subject_id: str, project_dir: str): atlas_name = self._resolve_atlas_name_for_subject( self.atlas_combo.currentText(), subject_id ) - hemi = "lh" if self.hemi_combo.currentIndex() == 0 else "rh" - atlas_path = os.path.join(seg_dir, f"{hemi}.{atlas_name}.annot") + # Resolve each hemisphere-prefixed name to its annot label index. + # Region indices are intrinsic to the atlas parcellation, so the map + # built from the current subject's .annot applies to every subject. + name_map = self._cortical_name_index_map() + tokens = self._parse_region_names(", ".join(self.cortical_chips.keys())) + # Build equal-length parallel lists so a target can union across + # regions and/or hemispheres; each entry carries its own lh/rh + # .annot path and label index. The serialized AtlasROI contract + # (parallel atlas_path/hemisphere/label lists) is unchanged. + atlas_paths, hemispheres, out_labels = [], [], [] + for hemi, name in tokens: + index = name_map.get((hemi, name)) + if index is None: + continue + out_labels.append(index) + hemispheres.append(hemi) + atlas_paths.append(os.path.join(seg_dir, f"{hemi}.{atlas_name}.annot")) + if not out_labels: + raise ValueError( + f"Could not resolve any cortical region name(s) for atlas " + f"'{atlas_name}'; the annotation files could not be read." + ) return FlexConfig.AtlasROI( - atlas_path=atlas_path, - label=int(self.label_value_input.value()), - hemisphere=hemi, + atlas_path=self._collapse(atlas_paths), + label=self._collapse(out_labels), + hemisphere=self._collapse(hemispheres), ) else: # subcortical volume_atlas_path = self._selected_volume_atlas_path(subject_id, seg_dir) + labels = self._parse_labels(", ".join(self.subcortical_chips.keys())) return FlexConfig.SubcorticalROI( atlas_path=volume_atlas_path, - label=int(self.volume_label_input.value()), + label=self._collapse(labels), tissues=self.tissue_combo.currentData(), atlas_space=self._selected_volume_atlas_space(), ) @@ -617,20 +1057,61 @@ def validate(self) -> str | None: """ roi_type = self.get_roi_type() if roi_type == "spherical": - if self.radius_input.value() <= 0: + _, radii = self._collect_spheres() + if any(r <= 0 for r in radii): return "ROI radius must be greater than zero." elif roi_type == "atlas": if not self.atlas_combo.currentText(): return "No cortical atlas selected. Use Refresh to discover atlases." - if self.label_value_input.value() < 1: - return "Region label value must be at least 1." + error = self._validate_region_names(", ".join(self.cortical_chips.keys())) + if error: + return error elif roi_type == "subcortical": if not self.volume_atlas_combo.currentText(): return ( "No volume atlas selected. Use Refresh to discover volume atlases." ) - if self.volume_label_input.value() < 1: - return "Region label value must be at least 1." + error = self._validate_labels(", ".join(self.subcortical_chips.keys())) + if error: + return error + return None + + def _validate_region_names(self, text: str) -> str | None: + """Validate the selected cortical region chips (as a comma string). + + Returns an error message string, or ``None`` when valid. Unknown names + are reported only when the atlas annotation files can actually be read; + otherwise name membership is left to the flex backend. + """ + try: + tokens = self._parse_region_names(text) + except ValueError: + return ( + "Cortical region(s) must be hemisphere-prefixed names, " + "e.g. lh.precentral, rh.superiorfrontal." + ) + if not tokens: + return "Select at least one cortical region (use 'List Regions')." + name_map = self._cortical_name_index_map() + if name_map: + unknown = [f"{h}.{n}" for h, n in tokens if (h, n) not in name_map] + if unknown: + return "Unknown cortical region(s): " + ", ".join(unknown) + return None + + def _validate_labels(self, text: str) -> str | None: + """Validate the selected subcortical region chips (as a comma string). + + Returns an error message string, or ``None`` when valid. + """ + try: + labels = self._parse_labels(text) + except ValueError: + return "Region label value(s) must be integers (comma-separated)." + if not labels: + return "Select at least one subcortical region (use 'List Regions')." + if any(label < 1 for label in labels): + return "Region label value(s) must be at least 1." return None # ------------------------------------------------------------------ @@ -737,77 +1218,69 @@ def _resolve_atlas_name_for_subject( # ------------------------------------------------------------------ def _on_list_atlas_regions(self): - """Show regions for the currently selected cortical atlas.""" - atlas = self.atlas_combo.currentText() - hemi = "lh" if self.hemi_combo.currentIndex() == 0 else "rh" - self._show_atlas_regions_dialog(atlas, hemi) - - def _on_list_volume_regions(self): - """Show regions for the currently selected volume atlas.""" - volume_atlas = self.volume_atlas_combo.currentText() - self._show_volume_regions_dialog(volume_atlas) + """Show cortical regions from BOTH hemispheres as hemi-prefixed names. - def _show_atlas_regions_dialog(self, atlas_display: str, hemi: str): - """Show a dialog listing regions in the selected cortical atlas. - - Args: - atlas_display: Atlas display name from the combo box. - hemi: Hemisphere string (``'lh'`` or ``'rh'``). + Entries are built from ``list_annot_regions`` on the lh and rh + ``.annot`` files and displayed as ``lh.`` / ``rh.``. Each + selected region is added as a chip whose key is the hemisphere-prefixed + name and whose display carries both the name and its ``.annot`` index. """ - atlas_key = (hemi, atlas_display) - if atlas_key not in self.atlases: + atlas_display = self.atlas_combo.currentText() + if not atlas_display: QtWidgets.QMessageBox.warning( self, - "Atlas File Not Found", - f"Could not find atlas file for {hemi}.{atlas_display}", + "No Atlas Selected", + "Select a cortical atlas first (use Refresh to discover atlases).", ) return - annot_file = self.atlases[atlas_key] - - try: - mesh_mgr = MeshAtlasManager("") - regions = mesh_mgr.list_annot_regions(annot_file) - output_lines = [] + entries = [] + for hemi in ("lh", "rh"): + annot_file = self.atlases.get((hemi, atlas_display)) + if not annot_file: + continue + try: + regions = MeshAtlasManager("").list_annot_regions(annot_file) + except (OSError, ValueError) as e: + QtWidgets.QMessageBox.warning(self, "Error Listing Regions", str(e)) + return for idx, name in regions: - output_lines.append(f"{idx:3d}: {name}") - output = "\n".join(output_lines) - except (OSError, ValueError) as e: - QtWidgets.QMessageBox.warning(self, "Error Listing Regions", str(e)) - return + entries.append((idx, f"{hemi}.{name}", None)) - dlg = QtWidgets.QDialog(self) - dlg.setWindowTitle(f"Regions in {hemi}.{atlas_display}") - dlg.setMinimumWidth(600) - layout = QtWidgets.QVBoxLayout(dlg) - - search_box = QtWidgets.QLineEdit() - search_box.setPlaceholderText("Search regions...") - layout.addWidget(search_box) - - text = QtWidgets.QTextEdit() - text.setReadOnly(True) - text.setText(output) - layout.addWidget(text) - - def filter_regions(): - query = search_box.text().strip().lower() - if not query: - text.setText(output) - return - filtered = "\n".join( - line for line in output.splitlines() if query in line.lower() + if not entries: + QtWidgets.QMessageBox.warning( + self, + "Atlas File Not Found", + f"Could not find atlas files for {atlas_display}.", ) - text.setText(filtered) + return + + dlg = AtlasRegionFinderDialog( + self, + f"Cortical Regions - {atlas_display}", + entries, + return_field="name", + multi=True, + preselected=list(self.cortical_chips.keys()), + ) + if dlg.exec_() == QtWidgets.QDialog.Accepted: + # selected_ids()/selected_names() are parallel: names are the + # "hemi.name" tokens, ids are the .annot label indices. + for index, name_token in zip(dlg.selected_ids(), dlg.selected_names()): + self.cortical_chips.add_item( + key=name_token, display=f"{name_token} · {index}" + ) - search_box.textChanged.connect(filter_regions) - btn = QtWidgets.QPushButton("Close") - btn.clicked.connect(dlg.accept) - layout.addWidget(btn) - dlg.exec_() + def _on_list_volume_regions(self): + """Show regions for the currently selected volume atlas.""" + volume_atlas = self.volume_atlas_combo.currentText() + self._show_volume_regions_dialog(volume_atlas) def _show_volume_regions_dialog(self, volume_atlas: str): - """Show a dialog listing regions in the selected volume atlas. + """Open the region finder for the selected volume atlas. + + Each selected region is added as a chip whose key is the integer atlas + label id and whose display carries both the region name and the id. Args: volume_atlas: Volume atlas display name from the combo box. @@ -819,73 +1292,70 @@ def _show_volume_regions_dialog(self, volume_atlas: str): return atlas_path = self.volume_atlas_combo.currentData() - lut_file = self._find_volume_lut(str(atlas_path)) if atlas_path else None - if lut_file is None: + if not atlas_path: QtWidgets.QMessageBox.warning( self, - "LUT File Not Found", - f"Could not find a label table for {volume_atlas}.", + "Atlas Not Found", + f"Could not resolve a file path for {volume_atlas}.", ) return - try: - output = f"Subcortical Regions ({volume_atlas}):\n" - output += "=" * 50 + "\n" - output += f"{'ID':<4} {'Structure Name':<35} {'RGB'}\n" - output += "-" * 50 + "\n" + atlas_path = str(atlas_path) + lut_file = self._find_volume_lut(atlas_path) + + entries = [] + if lut_file is not None: + try: + with open(lut_file, "r") as f: + for line in f: + line = line.strip() + if line and not line.startswith("#"): + parsed = self._parse_lut_line(line) + if parsed is None: + continue + label_id, label_name, rgb = parsed + entries.append((int(label_id), label_name, rgb)) + except OSError as e: + QtWidgets.QMessageBox.warning( + self, "Error Reading LUT File", f"Error reading LUT file: {str(e)}" + ) + return + else: + # No sidecar LUT (e.g. a subject-space FreeSurfer atlas such as + # aparc.DKTatlas+aseg.mgz). Resolve names from the atlas volume's own + # integer labels using the bundled standard FreeSurfer color table, + # so no FreeSurfer / mri_segstats invocation is required. + try: + entries = self._resolve_volume_label_entries(atlas_path) + except Exception as e: + QtWidgets.QMessageBox.warning( + self, + "Error Reading Atlas", + f"Could not read labels from {volume_atlas}: {str(e)}", + ) + return - with open(lut_file, "r") as f: - for line in f: - line = line.strip() - if line and not line.startswith("#"): - label = self._parse_lut_line(line) - if label is None: - continue - label_id, label_name, rgb = label - if rgb is None: - output += f"{label_id:<4} {label_name:<35} (no color)\n" - else: - r, g, b = rgb - output += ( - f"{label_id:<4} {label_name:<35} ({r},{g},{b})\n" - ) - except OSError as e: + if not entries: QtWidgets.QMessageBox.warning( - self, "Error Reading LUT File", f"Error reading LUT file: {str(e)}" + self, + "No Regions Found", + f"Could not find any labeled regions for {volume_atlas}.", ) return - dlg = QtWidgets.QDialog(self) - dlg.setWindowTitle(f"Subcortical Regions - {volume_atlas}") - dlg.setMinimumWidth(700) - layout = QtWidgets.QVBoxLayout(dlg) - - search_box = QtWidgets.QLineEdit() - search_box.setPlaceholderText("Search regions (by ID or name)...") - layout.addWidget(search_box) - - text = QtWidgets.QTextEdit() - text.setReadOnly(True) - text.setFont(QtGui.QFont("Consolas", FONT_SIZE_MONOSPACE)) - text.setText(output) - layout.addWidget(text) - - def filter_regions(): - query = search_box.text().strip().lower() - if not query: - text.setText(output) - return - filtered_lines = [ - line for line in output.splitlines() if query in line.lower() - ] - text.setText("\n".join(filtered_lines)) - - search_box.textChanged.connect(filter_regions) - - btn = QtWidgets.QPushButton("Close") - btn.clicked.connect(dlg.accept) - layout.addWidget(btn) - dlg.exec_() + dlg = AtlasRegionFinderDialog( + self, + f"Subcortical Regions - {volume_atlas}", + entries, + return_field="id", # unused: chips read selected_ids()/selected_names() directly + multi=True, + preselected=list(self.subcortical_chips.keys()), + ) + if dlg.exec_() == QtWidgets.QDialog.Accepted: + for region_id, name in zip(dlg.selected_ids(), dlg.selected_names()): + self.subcortical_chips.add_item( + key=str(region_id), display=f"{name} · {region_id}" + ) def _find_volume_lut(self, atlas_path: str) -> Path | None: atlas = Path(atlas_path) @@ -893,10 +1363,13 @@ def _find_volume_lut(self, atlas_path: str) -> Path | None: if atlas.name == "labeling.nii.gz": lut_path = atlas.with_name("labeling_LUT.txt") return lut_path if lut_path.is_file() else None - labels_file = atlas.with_name( - f"{self._strip_nifti_suffix(atlas.name)}_labels.txt" - ) - return labels_file if labels_file.is_file() else None + # For FreeSurfer subject atlases (e.g. aparc.DKTatlas+aseg) a + # "_labels.txt" sidecar is an mri_segstats SUMMARY + # (Index SegId NVoxels Volume StructName) — NOT a colour LUT — so we + # do NOT parse it here (that would show "1: 245555.0 "). Return + # None so the caller resolves names from the volume's own labels via + # the bundled FreeSurferColorLUT (_resolve_volume_label_entries). + return None stem = self._strip_nifti_suffix(atlas.name) candidates = [ @@ -912,6 +1385,93 @@ def _find_volume_lut(self, atlas_path: str) -> Path | None: return candidate return None + def _resolve_volume_label_entries( + self, atlas_path: str + ) -> list[tuple[int, str, tuple[str, str, str] | None]]: + """Resolve region entries for a volume atlas that has no sidecar LUT. + + Reads the unique nonzero integer labels present in the atlas volume via + nibabel and maps them to names/colours using the bundled standard + FreeSurfer colour table (``resources/atlas/FreeSurferColorLUT.txt``). + Only labels actually present in the volume are returned, so subject-space + FreeSurfer atlases (e.g. ``aparc.DKTatlas+aseg.mgz``) can be browsed by + name without FreeSurfer / ``mri_segstats``. + + Args: + atlas_path: Absolute path to the atlas volume (``.mgz``/``.nii``/ + ``.nii.gz``). + + Returns: + List of ``(id, name, rgb)`` entries, sorted by id, for labels found + in both the volume and the bundled colour table. ``rgb`` is a tuple + of decimal string components, or ``None`` when unavailable. + """ + import numpy as np + import nibabel as nib + + lut = self._load_freesurfer_lut() + if not lut: + return [] + + img = nib.load(atlas_path) + # Label maps carry identity scaling, so np.asarray(dataobj) returns the + # native integer labels; int() below normalises regardless. + present = np.unique(np.asarray(img.dataobj)) + + entries: list[tuple[int, str, tuple[str, str, str] | None]] = [] + for value in present: + label_id = int(value) + if label_id == 0: + continue + info = lut.get(label_id) + if info is None: + continue + name, rgb = info + entries.append((label_id, name, rgb)) + return entries + + def _load_freesurfer_lut( + self, + ) -> dict[int, tuple[str, tuple[str, str, str] | None]]: + """Parse the bundled standard FreeSurfer colour table. + + Returns: + Mapping of ``label_id -> (name, rgb)``, where ``rgb`` is a tuple of + decimal string components (or ``None`` when the row has no colour). + Empty dict when the bundled table cannot be located or read. + """ + lut_path = self._freesurfer_lut_path() + if lut_path is None: + return {} + + lut: dict[int, tuple[str, tuple[str, str, str] | None]] = {} + try: + with open(lut_path, "r") as f: + for line in f: + line = line.strip() + if not line or line.startswith("#"): + continue + parsed = self._parse_lut_line(line) + if parsed is None: + continue + label_id, label_name, rgb = parsed + lut[int(label_id)] = (label_name, rgb) + except OSError: + return {} + return lut + + def _freesurfer_lut_path(self) -> str | None: + """Return the path to the bundled standard FreeSurfer colour table. + + Reuses the same resources/atlas directory resolution as the MNI atlases + (container path first, then the repo-relative fallback). + + Returns: + Absolute path to ``FreeSurferColorLUT.txt``, or ``None`` if missing. + """ + candidate = os.path.join(self._mni_atlas_dir(), "FreeSurferColorLUT.txt") + return candidate if os.path.isfile(candidate) else None + @staticmethod def _strip_nifti_suffix(filename: str) -> str: if filename.endswith(".nii.gz"): @@ -919,7 +1479,9 @@ def _strip_nifti_suffix(filename: str) -> str: return os.path.splitext(filename)[0] @staticmethod - def _parse_lut_line(line: str) -> tuple[str, str, tuple[str, str, str] | None] | None: + def _parse_lut_line( + line: str, + ) -> tuple[str, str, tuple[str, str, str] | None] | None: parts = line.split() if len(parts) < 2: return None @@ -928,17 +1490,17 @@ def _parse_lut_line(line: str) -> tuple[str, str, tuple[str, str, str] | None] | if not label_id.lstrip("-").isdigit(): return None - color_start = None - for i in range(2, len(parts) - 2): - if all(part.lstrip("-").isdigit() for part in parts[i : i + 3]): - color_start = i - break - - if color_start is None: - return label_id, " ".join(parts[1:]), None - - label_name = " ".join(parts[1:color_start]) - rgb = tuple(parts[color_start : color_start + 3]) + # The name is every non-integer token after the id; the RGB(A) columns + # are the integer tokens. This is column-order agnostic, so both a + # "ID Name R G B A" table and a "ID R G B Name" table yield a clean name + # (never "12 48 255 Left-Pallidum"). + rest = parts[1:] + name_tokens = [p for p in rest if not p.lstrip("-").isdigit()] + int_tokens = [p for p in rest if p.lstrip("-").isdigit()] + if not name_tokens: + return None + label_name = " ".join(name_tokens) + rgb = tuple(int_tokens[:3]) if len(int_tokens) >= 3 else None return label_id, label_name, rgb # ------------------------------------------------------------------ diff --git a/tit/gui/ex_search_tab.py b/tit/gui/ex_search_tab.py index b154c13d..68316dbc 100644 --- a/tit/gui/ex_search_tab.py +++ b/tit/gui/ex_search_tab.py @@ -36,6 +36,7 @@ ) from tit.gui.components.action_buttons import RunStopButtons from tit.gui.components.base_thread import BaseProcessThread +from tit.gui.components.help_icon import HelpIcon from tit.paths import get_path_manager from tit import logger as logging_util from tit.opt.ex.engine import ExSearchEngine @@ -297,6 +298,8 @@ def __init__(self, parent=None): self.leadfield_generating = False self.leadfield_thread = None self.roi_processing_queue = [] + self._combine_rois = False + self._combined_roi_names = [] self._shared_log_file = None self.e1_plus = [] self.e1_minus = [] @@ -874,8 +877,8 @@ def setup_ui(self): # ============================================================ roi_container = QtWidgets.QGroupBox("ROI Selection") roi_container.setFixedHeight( - 190 - ) # Fixed height for balance (includes radius control) + 220 + ) # Fixed height for balance (includes radius + combine controls) roi_layout = QtWidgets.QVBoxLayout(roi_container) roi_layout.setContentsMargins(10, 10, 10, 10) roi_layout.setSpacing(8) @@ -920,6 +923,22 @@ def setup_ui(self): radius_layout.addStretch() roi_layout.addLayout(radius_layout) + # Combine selected ROIs into a single (unioned) target + combine_tooltip = ( + "When checked, all selected ROIs are unioned into one target and " + "searched in a single run (output named by joining the ROI names " + "with '+'). When unchecked, each selected ROI is searched separately." + ) + self.combine_rois_cb = QtWidgets.QCheckBox( + "Combine selected ROIs into one target" + ) + self.combine_rois_cb.setToolTip(combine_tooltip) + combine_layout = QtWidgets.QHBoxLayout() + combine_layout.addWidget(self.combine_rois_cb) + combine_layout.addWidget(HelpIcon(combine_tooltip, title="Combine ROIs")) + combine_layout.addStretch() + roi_layout.addLayout(combine_layout) + # Add ROI container to grid - Row 1, Column 0 main_grid_layout.addWidget(roi_container, 1, 0) @@ -1655,6 +1674,8 @@ def run_optimization(self): f"ROIs: {', '.join(roi_names)}\n" f"Total ROIs: {len(roi_names)}" ) + if self.combine_rois_cb.isChecked() and len(roi_names) >= 2: + details += f"\nCombine ROIs: union → {'+'.join(roi_names)}" if not ConfirmationDialog.confirm( self, @@ -1689,8 +1710,19 @@ def run_optimization(self): self.disable_controls() self.update_status(f"Running optimization for subject {subject_id}...") - # Initialize ROI processing queue and start pipeline - self.roi_processing_queue = selected_roi_names.copy() + # Decide whether to union the selected ROIs into a single target. + # Combine mode only engages with >= 2 ROIs; a single selection always + # takes the byte-identical separate-run path. + self._combine_rois = ( + self.combine_rois_cb.isChecked() and len(selected_roi_names) >= 2 + ) + if self._combine_rois: + self._combined_roi_names = selected_roi_names.copy() + combined_display = "+".join(n[:-4] for n in selected_roi_names) + self.roi_processing_queue = [f"{combined_display}.csv"] + else: + self._combined_roi_names = [] + self.roi_processing_queue = selected_roi_names.copy() self.current_roi_index = 0 self._exsearch_had_errors = False self.e1_plus = e1_plus @@ -1704,7 +1736,9 @@ def run_optimization(self): # Run the pipeline for the first ROI self.run_roi_pipeline(subject_id, project_dir, ex_search_dir, env) - def _build_ex_config(self, subject_id, roi_name, leadfield_hdf, eeg_net): + def _build_ex_config( + self, subject_id, roi_name, leadfield_hdf, eeg_net, roi_names=None + ): """Build an ExConfig dataclass from current UI widget values. Args: @@ -1712,6 +1746,9 @@ def _build_ex_config(self, subject_id, roi_name, leadfield_hdf, eeg_net): roi_name: ROI name (with or without .csv extension). leadfield_hdf: Full path to the leadfield HDF5 file. eeg_net: Name of the selected EEG net. + roi_names: Optional list of ROI CSV filenames to union into a + single target (combined mode). ``None`` keeps single-ROI + behavior. Returns: ExConfig instance ready for serialization. @@ -1730,6 +1767,7 @@ def _build_ex_config(self, subject_id, roi_name, leadfield_hdf, eeg_net): subject_id=subject_id, leadfield_hdf=leadfield_hdf, roi_name=roi_name, + roi_names=roi_names, electrodes=electrodes, total_current=self.total_current_spinbox.value(), current_step=self.current_step_spinbox.value(), @@ -1806,24 +1844,43 @@ def run_roi_pipeline(self, subject_id, project_dir, ex_search_dir, env): # Get ROI coordinates pm = self.pm roi_dir = pm.rois(subject_id) - roi_file = os.path.join(roi_dir, current_roi) + if self._combine_rois: + # Combined target: the queue holds one synthetic entry; read coords + # from the first real ROI for logging while the backend unions all + # selected ROI spheres (roi_names). + roi_names_for_config = list(self._combined_roi_names) + coord_source = self._combined_roi_names[0] + else: + roi_names_for_config = None + coord_source = current_roi + roi_file = os.path.join(roi_dir, coord_source) try: with open(roi_file, "r") as f: coordinates = f.readline().strip() x, y, z = [float(coord.strip()) for coord in coordinates.split(",")] - roi_name = current_roi.replace(".csv", "") # Remove .csv extension if self.debug_mode: self.update_output(f"[DEBUG] ROI file: {roi_file}", "debug") self.update_output(f"[DEBUG] Parsed ROI coords: {(x, y, z)}", "debug") except (OSError, ValueError) as e: - self.update_output( - f"Error reading ROI file {current_roi}: {str(e)}", "error" - ) - # Move to next ROI - self.current_roi_index += 1 - self.run_roi_pipeline(subject_id, project_dir, ex_search_dir, env) - return + if self._combine_rois: + # In combined mode these coords are display-only (the backend + # unions all selected roi_names itself), so a bad display-source + # read must NOT abort the whole union run. + self.update_output( + f"Warning: could not read coords from {coord_source} " + f"for logging: {str(e)}", + "warning", + ) + x = y = z = 0.0 + else: + self.update_output( + f"Error reading ROI file {current_roi}: {str(e)}", "error" + ) + # Move to next ROI + self.current_roi_index += 1 + self.run_roi_pipeline(subject_id, project_dir, ex_search_dir, env) + return # Build ExConfig dataclass from UI state ex_config = self._build_ex_config( @@ -1831,6 +1888,7 @@ def run_roi_pipeline(self, subject_id, project_dir, ex_search_dir, env): roi_name, selected_hdf5_path, selected_net_name, + roi_names=roi_names_for_config, ) # Minimal env — only pass through what downstream steps still need diff --git a/tit/gui/flex_search_tab.py b/tit/gui/flex_search_tab.py index 1a5d68f8..7a289d7e 100644 --- a/tit/gui/flex_search_tab.py +++ b/tit/gui/flex_search_tab.py @@ -382,8 +382,14 @@ def setup_ui(self): scroll_layout.addLayout(top_row_layout) - # ROI Definition — use component widget + # ROI Definition — use component widget. The picker sizes itself to the + # active page (see ROIPickerWidget._resize_stack_to_current), so let the + # group hug that height (Maximum) instead of reserving dead space; the + # trailing scroll_layout stretch absorbs the freed vertical space. self.roi_method_group = QtWidgets.QGroupBox("ROI Definition") + self.roi_method_group.setSizePolicy( + QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Maximum + ) roi_layout = QtWidgets.QVBoxLayout(self.roi_method_group) roi_layout.addWidget(self.roi_picker) scroll_layout.addWidget(self.roi_method_group) @@ -606,16 +612,16 @@ def on_subject_changed(self): def _sync_nonroi_mode(self): """Keep the nonroi_picker on the same page as the roi_picker.""" roi_type = self.roi_picker.get_roi_type() - page_map = {"spherical": 0, "atlas": 1, "subcortical": 2} + page_map = {"atlas": 0, "subcortical": 1, "spherical": 2} idx = page_map.get(roi_type, 0) self.nonroi_picker.stacked.setCurrentIndex(idx) # Also check the matching radio if it exists - if idx == 0 and self.nonroi_picker.radio_spherical: - self.nonroi_picker.radio_spherical.setChecked(True) - elif idx == 1 and self.nonroi_picker.radio_cortical: + if idx == 0 and self.nonroi_picker.radio_cortical: self.nonroi_picker.radio_cortical.setChecked(True) - elif idx == 2 and self.nonroi_picker.radio_subcortical: + elif idx == 1 and self.nonroi_picker.radio_subcortical: self.nonroi_picker.radio_subcortical.setChecked(True) + elif idx == 2 and self.nonroi_picker.radio_spherical: + self.nonroi_picker.radio_spherical.setChecked(True) # ------------------------------------------------------------------ # # Run optimization # @@ -664,6 +670,16 @@ def run_optimization(self): QtWidgets.QMessageBox.warning(self, "Warning", error) return + # Validate non-ROI when focality targets a specific region + if ( + self.goal_combo.currentData() == "focality" + and self.nonroi_method_combo.currentData() == "specific" + ): + error = self.nonroi_picker.validate() + if error: + QtWidgets.QMessageBox.warning(self, "Warning", f"Non-ROI: {error}") + return + # Check coordinate space for spherical ROI with MNI space selected if ( self.roi_picker.get_roi_type() == "spherical" @@ -698,13 +714,26 @@ def run_optimization(self): # Show confirmation dialog roi_description = "" if roi_params["method"] == "spherical": - roi_description = f"Spherical ROI at ({roi_params['center'][0]}, {roi_params['center'][1]}, {roi_params['center'][2]}) with radius {roi_params['radius']}mm" + num_spheres = roi_params.get("num_spheres", 1) + if num_spheres > 1: + roi_description = ( + f"Spherical ROI: union of {num_spheres} spheres " + f"(first at ({roi_params['center'][0]}, " + f"{roi_params['center'][1]}, {roi_params['center'][2]}))" + ) + else: + roi_description = ( + f"Spherical ROI at ({roi_params['center'][0]}, " + f"{roi_params['center'][1]}, {roi_params['center'][2]}) " + f"with radius {roi_params['radius']}mm" + ) elif roi_params["method"] == "atlas": roi_description = ( - f"Cortical ROI: {roi_params['atlas']} region {roi_params['region']}" + f"Cortical ROI: {roi_params['atlas']} " + f"region(s) {roi_params['region']}" ) else: - roi_description = f"Subcortical ROI: {roi_params['volume_atlas']} region {roi_params['volume_region']}" + roi_description = f"Subcortical ROI: {roi_params['volume_atlas']} region(s) {roi_params['volume_region']}" details = ( f"Subjects: {', '.join(selected_subjects)}\n" diff --git a/tit/opt/config.py b/tit/opt/config.py index 99a0d88e..ff4dddce 100644 --- a/tit/opt/config.py +++ b/tit/opt/config.py @@ -24,6 +24,19 @@ from enum import StrEnum from typing import Literal + +def _as_list(value) -> list: + """Wrap a scalar in a single-element list; pass lists/tuples through. + + Strings are treated as scalars (``"lh"`` -> ``["lh"]``), never iterated + character-by-character. Used to normalise ROI fields that accept either a + single value (one region) or a list (a union of several regions). + """ + if isinstance(value, (list, tuple)): + return list(value) + return [value] + + # ── Flex-search config ─────────────────────────────────────────────────────── @@ -187,16 +200,22 @@ class SphericalROI: tissue compartments are included (same semantics as :class:`SubcorticalROI.tissues`). + Each of *x*, *y*, *z*, *radius* accepts either a single value (one + sphere) or a list of values (a union of several spheres evaluated as + one combined target). The coordinate lists must be non-empty and of + equal length; *radius* may be a scalar (shared by every sphere) or a + list matching the number of centers. + Attributes ---------- - x : float - Center x-coordinate (mm). - y : float - Center y-coordinate (mm). - z : float - Center z-coordinate (mm). - radius : float - Sphere radius in mm. + x : float or list of float + Center x-coordinate(s) (mm). + y : float or list of float + Center y-coordinate(s) (mm). + z : float or list of float + Center z-coordinate(s) (mm). + radius : float or list of float + Sphere radius/radii in mm. A scalar is shared by all spheres. use_mni : bool If True, coordinates are in MNI space and SimNIBS will transform them to subject space during ROI setup. @@ -206,57 +225,125 @@ class SphericalROI: tissues : str Tissue compartments to include when *volumetric* is True. One of ``"GM"``, ``"WM"``, or ``"both"``. + + Raises + ------ + ValueError + If *x*/*y*/*z* are empty or unequal length, or *radius* is a list + whose length neither equals 1 nor the number of centers. """ - x: float - y: float - z: float - radius: float = 10.0 + x: float | list[float] + y: float | list[float] + z: float | list[float] + radius: float | list[float] = 10.0 use_mni: bool = False volumetric: bool = False tissues: str = "GM" # "GM", "WM", or "both" — only used when volumetric=True + def __post_init__(self): + xs, ys, zs = _as_list(self.x), _as_list(self.y), _as_list(self.z) + if not xs or not (len(xs) == len(ys) == len(zs)): + raise ValueError( + "SphericalROI x, y, z must be non-empty and of equal length" + ) + if len(_as_list(self.radius)) not in (1, len(xs)): + raise ValueError( + "SphericalROI radius must be a scalar or match the number of " + "centers" + ) + @dataclass class AtlasROI: """Cortical surface ROI from a FreeSurfer annotation atlas. + Each of *atlas_path*, *label*, *hemisphere* accepts either a single + value (one region) or a list (a union of several regions evaluated as + one combined target). Because ``.annot`` files are per-hemisphere, + carrying a per-region *hemisphere* (and matching *atlas_path*) allows a + target that spans **both** hemispheres, or even different atlases. + Scalars broadcast to the number of labels; lists must match its length. + Attributes ---------- - atlas_path : str - Path to the FreeSurfer ``.annot`` annotation file. - label : int - Integer label index within the annotation atlas. - hemisphere : str - Hemisphere to use (``"lh"`` or ``"rh"``). + atlas_path : str or list of str + Path(s) to the FreeSurfer ``.annot`` annotation file(s). + label : int or list of int + Integer label index/indices within the annotation atlas. + hemisphere : str or list of str + Hemisphere(s) to use (``"lh"`` or ``"rh"``), one per label. + + Raises + ------ + ValueError + If *label* is empty, or *atlas_path*/*hemisphere* is a list whose + length neither equals 1 nor the number of labels. """ - atlas_path: str - label: int - hemisphere: str = "lh" + atlas_path: str | list[str] + label: int | list[int] + hemisphere: str | list[str] = "lh" + + def __post_init__(self): + n = len(_as_list(self.label)) + if n == 0: + raise ValueError("AtlasROI label must be non-empty") + if len(_as_list(self.atlas_path)) not in (1, n): + raise ValueError( + "AtlasROI atlas_path must be a scalar or match the number " + "of labels" + ) + if len(_as_list(self.hemisphere)) not in (1, n): + raise ValueError( + "AtlasROI hemisphere must be a scalar or match the number " + "of labels" + ) @dataclass class SubcorticalROI: """Subcortical volume ROI from a volumetric atlas. + *label* accepts either a single value (one region) or a list (a union + of several regions -- e.g. both hippocampi from one ``aseg`` atlas -- + evaluated as one combined target). *atlas_path* may be a scalar + (shared by every label) or a list matching the number of labels; a + single shared *tissues* and *atlas_space* apply to the whole union. + Attributes ---------- - atlas_path : str - Path to the volumetric atlas NIfTI file. - label : int - Integer label index within the volumetric atlas. + atlas_path : str or list of str + Path(s) to the volumetric atlas NIfTI file(s). + label : int or list of int + Integer label index/indices within the volumetric atlas. tissues : str Tissue compartments to include. One of ``"GM"``, ``"WM"``, or ``"both"``. atlas_space : str Space of the atlas NIfTI. One of ``"subject"`` or ``"mni"``. MNI-space masks are transformed by SimNIBS during ROI setup. + + Raises + ------ + ValueError + If *label* is empty, or *atlas_path* is a list whose length + neither equals 1 nor the number of labels. """ - atlas_path: str - label: int + atlas_path: str | list[str] + label: int | list[int] tissues: str = "GM" # "GM", "WM", or "both" atlas_space: Literal["subject", "mni"] = "subject" + def __post_init__(self): + n = len(_as_list(self.label)) + if n == 0: + raise ValueError("SubcorticalROI label must be non-empty") + if len(_as_list(self.atlas_path)) not in (1, n): + raise ValueError( + "SubcorticalROI atlas_path must be a scalar or match the " + "number of labels" + ) + # ── Nested electrode config ─────────────────────────────────────── @dataclass @@ -395,7 +482,14 @@ class ExConfig: Path to the precomputed leadfield HDF5 file. roi_name : str ROI CSV filename (e.g. ``"target.csv"``). The ``".csv"`` suffix - is appended automatically if missing. + is appended automatically if missing. Used as the metric-key + prefix and (with the net name) the output-directory label. + roi_names : list of str or None + Optional list of ROI CSV filenames to **union** into a single + target. When provided (combined mode), the spherical masks of + every listed ROI are OR-folded into one region. ``None`` + (default) keeps single-ROI behavior driven by *roi_name*. Each + entry gets the ``".csv"`` suffix appended if missing. electrodes : BucketElectrodes or PoolElectrodes Electrode specification, either a single shared pool (:class:`PoolElectrodes`) or separate per-channel buckets @@ -472,6 +566,7 @@ class PoolElectrodes: # ── ROI ──────────────────────────────────────────────────────────── roi_radius: float = 3.0 + roi_names: list[str] | None = None # ── Output naming (defaults to datetime stamp) ───────────────────── run_name: str | None = None @@ -484,6 +579,10 @@ def __post_init__(self): self.electrodes = ExConfig.BucketElectrodes(**self.electrodes) if not self.roi_name.endswith(".csv"): self.roi_name += ".csv" + if self.roi_names is not None: + self.roi_names = [ + n if n.endswith(".csv") else f"{n}.csv" for n in self.roi_names + ] # Validation if self.current_step <= 0: diff --git a/tit/opt/ex/engine.py b/tit/opt/ex/engine.py index 854ecba4..c5e907cd 100644 --- a/tit/opt/ex/engine.py +++ b/tit/opt/ex/engine.py @@ -29,7 +29,11 @@ class ExSearchEngine: """ def __init__( - self, leadfield_hdf: str, roi_file: str, roi_name: str, logger: logging.Logger + self, + leadfield_hdf: str, + roi_file: str | list[str], + roi_name: str, + logger: logging.Logger, ): self.leadfield_hdf = leadfield_hdf self.roi_file = roi_file @@ -40,6 +44,7 @@ def __init__( self.mesh = None self.idx_lf = None self.roi_coords = None + self.roi_centers = None self.roi_indices = None self.roi_volumes = None self.gm_indices = None @@ -61,24 +66,46 @@ def _load_leadfield(self) -> None: self.logger.info(f"Loaded in {time.time() - start:.1f}s") def _load_roi_coordinates(self) -> None: - """Read ROI center from a simple CSV (one row: x,y,z).""" - with open(self.roi_file) as f: + """Read ROI center(s) from one or more simple CSVs (one row: x,y,z). + + A single ``roi_file`` (str) yields one center; a list yields one + center per file (combined/union mode). ``roi_coords`` always + mirrors the first center for backward compatibility. + """ + files = self.roi_file if isinstance(self.roi_file, list) else [self.roi_file] + self.roi_centers = [self._read_center(f) for f in files] + self.roi_coords = self.roi_centers[0] + if len(self.roi_centers) == 1: + self.logger.info(f"ROI coords: {self.roi_coords}") + else: + self.logger.info( + f"ROI centers ({len(self.roi_centers)}): {self.roi_centers}" + ) + + def _read_center(self, path: str) -> list[float]: + """Return the first valid ``[x, y, z]`` triple from an ROI CSV.""" + with open(path) as f: for row in csv.reader(f): if not row: continue coords = [float(v.strip()) for v in row if v.strip()] if len(coords) >= 3: - self.roi_coords = coords[:3] - self.logger.info(f"ROI coords: {self.roi_coords}") - return - raise ValueError(f"No valid coordinates in {self.roi_file}") + return coords[:3] + raise ValueError(f"No valid coordinates in {path}") def _find_roi_elements(self, roi_radius: float) -> None: - """Find mesh elements whose barycenters fall within a sphere.""" + """Find mesh elements whose barycenters fall within any ROI sphere. + + For a combined target the per-center spherical masks are OR-folded + into one region (single-center is the N=1 case). + """ self.logger.info(f"Finding ROI elements (radius={roi_radius}mm)...") - centers = self.mesh.elements_baricenters().value - center = np.asarray(self.roi_coords, dtype=float) - mask = np.sum((centers - center) ** 2, axis=1) <= roi_radius**2 + baricenters = self.mesh.elements_baricenters().value + roi_centers = self.roi_centers if self.roi_centers else [self.roi_coords] + mask = np.zeros(baricenters.shape[0], dtype=bool) + for c in roi_centers: + center = np.asarray(c, dtype=float) + mask |= np.sum((baricenters - center) ** 2, axis=1) <= roi_radius**2 volumes = self.mesh.elements_volumes_and_areas().value if volumes.ndim > 1: diff --git a/tit/opt/ex/ex.py b/tit/opt/ex/ex.py index 724b4275..1657a773 100644 --- a/tit/opt/ex/ex.py +++ b/tit/opt/ex/ex.py @@ -46,7 +46,10 @@ def _run_ex_search_inner(config: ExConfig) -> ExResult: os.makedirs(output_dir, exist_ok=True) logger.info(f"Output: {output_dir}") - roi_file = os.path.join(pm.rois(config.subject_id), config.roi_name) + roi_names = config.roi_names or [config.roi_name] + roi_files = [os.path.join(pm.rois(config.subject_id), name) for name in roi_names] + if len(roi_files) > 1: + logger.info(f"Combining {len(roi_files)} ROIs into one target: {roi_names}") if isinstance(config.electrodes, ExConfig.PoolElectrodes): pool = config.electrodes.electrodes @@ -63,7 +66,7 @@ def _run_ex_search_inner(config: ExConfig) -> ExResult: pm.leadfields(config.subject_id), config.leadfield_hdf ) - engine = ExSearchEngine(leadfield_path, roi_file, config.roi_name, logger) + engine = ExSearchEngine(leadfield_path, roi_files, config.roi_name, logger) engine.initialize(roi_radius=config.roi_radius) ratios = generate_current_ratios( diff --git a/tit/opt/flex/builder.py b/tit/opt/flex/builder.py index aabb856e..2a8e723f 100644 --- a/tit/opt/flex/builder.py +++ b/tit/opt/flex/builder.py @@ -28,7 +28,7 @@ import numpy as np -from tit.opt.config import FlexConfig +from tit.opt.config import FlexConfig, _as_list from . import utils @@ -298,11 +298,12 @@ def generate_report( roi_data: dict = {} if isinstance(roi, FlexConfig.SphericalROI): + coordinates, radius = _sphere_report_fields(roi) roi_data = { "roi_name": "Target ROI", "roi_type": "spherical", - "coordinates": [roi.x, roi.y, roi.z], - "radius": roi.radius, + "coordinates": coordinates, + "radius": radius, "coordinate_space": "MNI" if roi.use_mni else "subject", } if ( @@ -311,22 +312,25 @@ def generate_report( and isinstance(config.non_roi, FlexConfig.SphericalROI) ): nr = config.non_roi + nr_coordinates, nr_radius = _sphere_report_fields(nr) roi_data.update( { "non_roi_method": config.non_roi_method, - "non_roi_coordinates": [nr.x, nr.y, nr.z], - "non_roi_radius": nr.radius, + "non_roi_coordinates": nr_coordinates, + "non_roi_radius": nr_radius, "non_roi_coordinate_space": ("MNI" if nr.use_mni else "subject"), } ) elif isinstance(roi, FlexConfig.AtlasROI): - atlas_name = _atlas_name_from_path(roi.atlas_path, roi.hemisphere) + first_path = _as_list(roi.atlas_path)[0] + first_hemi = _as_list(roi.hemisphere)[0] + atlas_name = _atlas_name_from_path(first_path, first_hemi) roi_data = { "roi_name": "Target ROI", "roi_type": "atlas", - "hemisphere": roi.hemisphere, - "atlas": atlas_name or roi.atlas_path, - "atlas_label": roi.label, + "hemisphere": _join(roi.hemisphere), + "atlas": atlas_name or first_path, + "atlas_label": _join(roi.label), } if ( config.goal == "focality" @@ -334,23 +338,21 @@ def generate_report( and isinstance(config.non_roi, FlexConfig.AtlasROI) ): nr = config.non_roi + nr_path = _as_list(nr.atlas_path)[0] roi_data.update( { - "non_roi_atlas": ( - os.path.basename(nr.atlas_path) if nr.atlas_path else None - ), - "non_roi_label": nr.label, + "non_roi_atlas": (os.path.basename(nr_path) if nr_path else None), + "non_roi_label": _join(nr.label), } ) elif isinstance(roi, FlexConfig.SubcorticalROI): + first_path = _as_list(roi.atlas_path)[0] roi_data = { "roi_name": "Target ROI", "roi_type": "subcortical", - "volume_atlas": ( - os.path.basename(roi.atlas_path) if roi.atlas_path else None - ), + "volume_atlas": (os.path.basename(first_path) if first_path else None), "volume_atlas_space": roi.atlas_space, - "volume_label": roi.label, + "volume_label": _join(roi.label), } if ( config.goal == "focality" @@ -358,13 +360,12 @@ def generate_report( and isinstance(config.non_roi, FlexConfig.SubcorticalROI) ): nr = config.non_roi + nr_path = _as_list(nr.atlas_path)[0] roi_data.update( { - "non_roi_atlas": ( - os.path.basename(nr.atlas_path) if nr.atlas_path else None - ), + "non_roi_atlas": (os.path.basename(nr_path) if nr_path else None), "non_roi_atlas_space": nr.atlas_space, - "non_roi_label": nr.label, + "non_roi_label": _join(nr.label), } ) @@ -525,6 +526,35 @@ def _build_electrode_montage_base64(output_dir: Path) -> str | None: return base64.b64encode(buf.read()).decode("ascii") +def _join(values): + """Return the scalar when there is one region, else a ``+``-joined string. + + Keeps single-region report fields byte-identical (a bare int/str) while + rendering a union of labels/hemispheres as e.g. ``"17+53"`` rather than the + raw ``[17, 53]`` list repr. Duplicates from the cortical "Both" expansion + (labels/hemispheres repeated per hemi) are collapsed for display. + """ + items = _as_list(values) + if len(items) == 1: + return items[0] + return "+".join(dict.fromkeys(str(v) for v in items)) + + +def _sphere_report_fields(roi_spec): + """Return ``(coordinates, radius)`` for the report from a (multi) sphere. + + Single sphere -> ``([x, y, z], r)`` (byte-identical with prior reports); + multiple spheres -> ``([[x, y, z], ...], [r, ...])``. + """ + xs, ys, zs = _as_list(roi_spec.x), _as_list(roi_spec.y), _as_list(roi_spec.z) + radii = _as_list(roi_spec.radius) + if len(radii) == 1 and len(xs) != 1: + radii = radii * len(xs) + if len(xs) == 1: + return [xs[0], ys[0], zs[0]], radii[0] + return [[x, y, z] for x, y, z in zip(xs, ys, zs)], radii + + def _atlas_name_from_path(path_value: str, hemisphere: str) -> str: """Extract a human-readable atlas name from an annotation file path.""" atlas_filename = os.path.basename(path_value) diff --git a/tit/opt/flex/utils.py b/tit/opt/flex/utils.py index 1440d439..263ec7e5 100644 --- a/tit/opt/flex/utils.py +++ b/tit/opt/flex/utils.py @@ -26,11 +26,53 @@ log = logging.getLogger(__name__) -from tit.opt.config import FlexConfig +from tit.opt.config import FlexConfig, _as_list _VOLUME_MASK_SPACES = {"subject", "mni"} +def _broadcast(value, n: int) -> list: + """Normalise *value* to a list of length *n*. + + A scalar (or single-element list) is repeated to length *n*; a list that + already has *n* elements is passed through. Used to align a shared ROI + field (e.g. one atlas path or radius) with a list of labels/centers. + """ + values = _as_list(value) + if len(values) == 1 and n != 1: + return values * n + return values + + +def _fmt_num(value): + """Render a coordinate/radius as an int when it has no fractional part.""" + return int(value) if value == int(value) else value + + +def _union_operators(n: int) -> list[str]: + """Operator sequence that unions *n* regions on SimNIBS' all-True base. + + SimNIBS seeds the working mask all-True and folds each region with its + operator, so the first fold must ``intersection`` (selecting region 1) and + the rest ``union`` on. An all-``union`` sequence would select the whole + brain. + """ + return ["intersection"] + ["union"] * (n - 1) + + +def _apply_union_operator(roi_obj) -> None: + """Set the union operator sequence on a SimNIBS mask ROI. + + Mirrors the sphere path (:func:`_apply_spheres`): a single region leaves the + operator unset (SimNIBS defaults to ``"intersection"``) so single-region + setup stays byte-identical with the pre-union code; a union of *N* regions + gets the explicit ``["intersection"] + ["union"] * (N-1)`` sequence. + """ + n = len(roi_obj.mask_value) + if n > 1: + roi_obj.mask_operator = _union_operators(n) + + def eeg_net_csv_path(eeg_positions_dir: str, eeg_net: str) -> Path: """Resolve an EEG net name or filename inside a subject EEG directory.""" filename = Path(eeg_net).name @@ -40,6 +82,7 @@ def eeg_net_csv_path(eeg_positions_dir: str, eeg_net: str) -> Path: filename = f"{filename}.csv" return Path(eeg_positions_dir) / filename + # --------------------------------------------------------------------------- # Output directory naming # --------------------------------------------------------------------------- @@ -113,26 +156,36 @@ def generate_label(config, pareto: bool = False) -> str: roi = config.roi if isinstance(roi, FlexConfig.SphericalROI): - x = int(roi.x) if roi.x == int(roi.x) else roi.x - y = int(roi.y) if roi.y == int(roi.y) else roi.y - z = int(roi.z) if roi.z == int(roi.z) else roi.z - r = int(roi.radius) if roi.radius == int(roi.radius) else roi.radius - roi_str = f"sphere({x},{y},{z})r{r}" + xs, ys, zs = _as_list(roi.x), _as_list(roi.y), _as_list(roi.z) + radii = _broadcast(roi.radius, len(xs)) + roi_str = "+".join( + f"sphere({_fmt_num(x)},{_fmt_num(y)},{_fmt_num(z)})r{_fmt_num(r)}" + for x, y, z, r in zip(xs, ys, zs, radii) + ) elif isinstance(roi, FlexConfig.AtlasROI): - hemi = roi.hemisphere + labels = _as_list(roi.label) + hemis = _broadcast(roi.hemisphere, len(labels)) + paths = _broadcast(roi.atlas_path, len(labels)) + hemi = "+".join(dict.fromkeys(hemis)) + first_path = paths[0] if paths else None atlas = ( - os.path.basename(roi.atlas_path).replace(".annot", "").split(".")[-1] - if roi.atlas_path + os.path.basename(first_path).replace(".annot", "").split(".")[-1] + if first_path else "atlas" ) - roi_str = f"{hemi}-{atlas}-{roi.label}" + roi_str = f"{hemi}-{atlas}-{'+'.join(dict.fromkeys(str(v) for v in labels))}" elif isinstance(roi, FlexConfig.SubcorticalROI): - atlas = os.path.basename(roi.atlas_path) if roi.atlas_path else "volume" + labels = _as_list(roi.label) + paths = _broadcast(roi.atlas_path, len(labels)) + first_path = paths[0] if paths else None + atlas = os.path.basename(first_path) if first_path else "volume" for ext in (".nii.gz", ".nii", ".mgz"): if atlas.endswith(ext): atlas = atlas[: -len(ext)] break - roi_str = f"subcortical-{atlas}-{roi.label}" + roi_str = ( + f"subcortical-{atlas}-{'+'.join(dict.fromkeys(str(v) for v in labels))}" + ) else: roi_str = "unknown" @@ -231,8 +284,40 @@ def _resolve_tissues(tissues_str: str) -> list: return [ElementTags.GM] -def _sphere_center(roi_spec: FlexConfig.SphericalROI) -> list[float]: - return [roi_spec.x, roi_spec.y, roi_spec.z] +def _apply_spheres(roi_obj, centers, radii, space, *, complement: bool) -> None: + """Write sphere geometry onto a SimNIBS ROI object. + + For a single sphere the scalar/flat form is used (``roi_sphere_center = + [x, y, z]``, ``roi_sphere_radius = r``, ``roi_sphere_center_space = space``) + so single-region behaviour is byte-identical to the pre-union code. For a + union of *N* spheres the list form is used with an explicit operator + sequence. When *complement* is True (focality "everything else") every + sphere is subtracted from the all-True base (``["difference"] * N``); + otherwise the union sequence is used. + """ + n = len(centers) + operators = ["difference"] * n if complement else _union_operators(n) + if n == 1: + roi_obj.roi_sphere_center_space = space + roi_obj.roi_sphere_center = centers[0] + roi_obj.roi_sphere_radius = radii[0] + # A single non-complement sphere leaves the operator unset (SimNIBS + # defaults to "intersection") to stay byte-identical with prior output. + if complement: + roi_obj.roi_sphere_operator = operators + else: + roi_obj.roi_sphere_center_space = [space] * n + roi_obj.roi_sphere_center = centers + roi_obj.roi_sphere_radius = list(radii) + roi_obj.roi_sphere_operator = operators + + +def _spheres_from_spec(roi_spec: FlexConfig.SphericalROI): + """Return ``(centers, radii, space)`` lists for a (possibly multi) sphere.""" + xs, ys, zs = _as_list(roi_spec.x), _as_list(roi_spec.y), _as_list(roi_spec.z) + radii = _broadcast(roi_spec.radius, len(xs)) + centers = [[x, y, z] for x, y, z in zip(xs, ys, zs)] + return centers, radii, _sphere_center_space(roi_spec) def _sphere_center_space(roi_spec: FlexConfig.SphericalROI) -> str: @@ -249,18 +334,16 @@ def _volume_mask_space(roi_spec: FlexConfig.SubcorticalROI) -> str: def _configure_spherical_roi(opt, config: FlexConfig) -> None: - """Set up a spherical ROI (surface or volumetric).""" + """Set up a spherical ROI (surface or volumetric), unioning N spheres.""" roi_spec: FlexConfig.SphericalROI = config.roi # type: ignore[assignment] - center = _sphere_center(roi_spec) - center_space = _sphere_center_space(roi_spec) - radius = roi_spec.radius + centers, radii, center_space = _spheres_from_spec(roi_spec) roi = opt.add_roi() if roi_spec.volumetric: log.info( - f"Using volumetric sphere (tissues={roi_spec.tissues}) " - f"at {center} ({center_space}) r={radius}" + f"Using volumetric sphere(s) (tissues={roi_spec.tissues}) " + f"at {centers} ({center_space}) r={radii}" ) roi.method = "volume" roi.tissues = _resolve_tissues(roi_spec.tissues) @@ -268,9 +351,7 @@ def _configure_spherical_roi(opt, config: FlexConfig) -> None: roi.method = "surface" roi.surface_type = "central" - roi.roi_sphere_center_space = center_space - roi.roi_sphere_center = center - roi.roi_sphere_radius = radius + _apply_spheres(roi, centers, radii, center_space, complement=False) # Add non-ROI if focality optimisation is requested if config.goal == "focality": @@ -283,31 +364,39 @@ def _configure_spherical_roi(opt, config: FlexConfig) -> None: non_roi.surface_type = "central" if config.non_roi_method == "everything_else": - non_roi.roi_sphere_center_space = center_space - non_roi.roi_sphere_center = center - non_roi.roi_sphere_radius = radius - non_roi.roi_sphere_operator = ["difference"] + _apply_spheres(non_roi, centers, radii, center_space, complement=True) non_roi.weight = -1 else: # Specific non-ROI from config.non_roi (unified ROISpec type) non_roi_spec: FlexConfig.SphericalROI = config.non_roi # type: ignore[assignment] - non_roi.roi_sphere_center = _sphere_center(non_roi_spec) - non_roi.roi_sphere_center_space = _sphere_center_space(non_roi_spec) - non_roi.roi_sphere_radius = non_roi_spec.radius + nr_centers, nr_radii, nr_space = _spheres_from_spec(non_roi_spec) + _apply_spheres(non_roi, nr_centers, nr_radii, nr_space, complement=False) non_roi.weight = -1 +def _atlas_mask_lists(roi_spec: FlexConfig.AtlasROI): + """Return parallel ``(mask_space, mask_path, mask_value)`` lists. + + Each label carries its own hemisphere (``subject_lh`` / ``subject_rh``) and + atlas path, so a target can span both hemispheres or several atlases. + """ + labels = _as_list(roi_spec.label) + n = len(labels) + hemis = _broadcast(roi_spec.hemisphere, n) + paths = _broadcast(roi_spec.atlas_path, n) + mask_space = [f"subject_{h}" for h in hemis] + return mask_space, list(paths), list(labels) + + def _configure_atlas_roi(opt, config: FlexConfig) -> None: - """Set up a cortical atlas-based ROI on the central surface.""" + """Set up a cortical atlas-based ROI (union of N labels) on the surface.""" roi_spec: FlexConfig.AtlasROI = config.roi # type: ignore[assignment] roi = opt.add_roi() roi.method = "surface" roi.surface_type = "central" - hemi = roi_spec.hemisphere - roi.mask_space = [f"subject_{hemi}"] - roi.mask_path = [roi_spec.atlas_path] - roi.mask_value = [roi_spec.label] + roi.mask_space, roi.mask_path, roi.mask_value = _atlas_mask_lists(roi_spec) + _apply_union_operator(roi) if config.goal == "focality": non_roi = opt.add_roi() @@ -318,13 +407,15 @@ def _configure_atlas_roi(opt, config: FlexConfig) -> None: non_roi.mask_space = roi.mask_space non_roi.mask_path = roi.mask_path non_roi.mask_value = roi.mask_value - non_roi.mask_operator = ["difference"] + # Length must match the ROI's mask lists or SimNIBS rejects it. + non_roi.mask_operator = ["difference"] * len(roi.mask_value) non_roi.weight = -1 else: non_roi_spec: FlexConfig.AtlasROI = config.non_roi # type: ignore[assignment] - non_roi.mask_space = roi.mask_space - non_roi.mask_path = [non_roi_spec.atlas_path] - non_roi.mask_value = [non_roi_spec.label] + non_roi.mask_space, non_roi.mask_path, non_roi.mask_value = ( + _atlas_mask_lists(non_roi_spec) + ) + _apply_union_operator(non_roi) non_roi.weight = -1 @@ -334,24 +425,33 @@ def _resolve_roi_tissues(config: FlexConfig) -> list: return _resolve_tissues(roi_spec.tissues) -def _configure_subcortical_roi(opt, config: FlexConfig) -> None: - """Set up a subcortical volume-based ROI from a label atlas.""" - roi_spec: FlexConfig.SubcorticalROI = config.roi # type: ignore[assignment] +def _subcortical_mask_lists(roi_spec: FlexConfig.SubcorticalROI): + """Return parallel ``(mask_space, mask_path, mask_value)`` lists. + + A single shared *atlas_space* applies to the whole union; *atlas_path* may + be shared (broadcast) or one path per label. Every atlas path is verified + to exist. + """ + labels = _as_list(roi_spec.label) + n = len(labels) + paths = _broadcast(roi_spec.atlas_path, n) + space = _volume_mask_space(roi_spec) + for path in paths: + if not path or not os.path.isfile(path): + raise FileNotFoundError(f"Volume atlas file not found: {path}") + return [space] * n, list(paths), list(labels) - volume_atlas_path = roi_spec.atlas_path - label_val = roi_spec.label - mask_space = _volume_mask_space(roi_spec) - if not volume_atlas_path or not os.path.isfile(volume_atlas_path): - raise FileNotFoundError(f"Volume atlas file not found: {volume_atlas_path}") +def _configure_subcortical_roi(opt, config: FlexConfig) -> None: + """Set up a subcortical volume ROI (union of N labels) from a label atlas.""" + roi_spec: FlexConfig.SubcorticalROI = config.roi # type: ignore[assignment] tissues = _resolve_roi_tissues(config) roi = opt.add_roi() roi.method = "volume" - roi.mask_space = [mask_space] - roi.mask_path = [volume_atlas_path] - roi.mask_value = [label_val] + roi.mask_space, roi.mask_path, roi.mask_value = _subcortical_mask_lists(roi_spec) + _apply_union_operator(roi) roi.tissues = tissues if config.goal == "focality": @@ -362,19 +462,15 @@ def _configure_subcortical_roi(opt, config: FlexConfig) -> None: non_roi.mask_space = roi.mask_space non_roi.mask_path = roi.mask_path non_roi.mask_value = roi.mask_value - non_roi.mask_operator = ["difference"] + # Length must match the ROI's mask lists or SimNIBS rejects it. + non_roi.mask_operator = ["difference"] * len(roi.mask_value) non_roi.weight = -1 non_roi.tissues = tissues else: non_roi_spec: FlexConfig.SubcorticalROI = config.non_roi # type: ignore[assignment] - if not non_roi_spec.atlas_path or not os.path.isfile( - non_roi_spec.atlas_path - ): - raise FileNotFoundError( - f"Non-ROI volume atlas not found: {non_roi_spec.atlas_path}" - ) - non_roi.mask_space = [_volume_mask_space(non_roi_spec)] - non_roi.mask_path = [non_roi_spec.atlas_path] - non_roi.mask_value = [non_roi_spec.label] + non_roi.mask_space, non_roi.mask_path, non_roi.mask_value = ( + _subcortical_mask_lists(non_roi_spec) + ) + _apply_union_operator(non_roi) non_roi.weight = -1 non_roi.tissues = tissues