A volumetric parcellation is a NIfTI file full of integers. Voxel 4 is 4, and nothing in the file says that 4 means the left thalamus or that it should be drawn in teal. That mapping lives in a separate text file — a colour lookup table, or LUT — and every volumetric pipeline in this package asks you for one.
If you got your parcellation from FreeSurfer, the LUT came with it. If you got it from a paper’s supplementary material, it probably didn’t, and you have a spreadsheet of region names instead. This tutorial covers both cases: reading a LUT, building one from nothing, and colouring it.
What counts as a lookup table
A LUT is a plain data frame with six columns. idx is the integer in the volume, label is the name, and R, G, B and A are the colour, each 0–255. The optional columns type, hemi, context and display are covered further down.
lut <- data.frame(
idx = 0L,
label = "Unknown",
R = 0L, G = 0L, B = 0L, A = 0L
)
is_lut(lut)
#> [1] TRUEis_lut() is the check every pipeline runs before it trusts your table, so it is worth calling yourself while you build one. It is strict about names — a table with index and name columns is a perfectly good data frame and a useless LUT.
is_lut(data.frame(index = 1, name = "Thalamus"))
#> [1] FALSERow idx = 0 is the background. Every pipeline expects it, and several functions treat it specially, so leave it in place even when it looks like clutter.
Adding regions
lut_add() appends a row and keeps the column types honest, which matters more than it sounds — a LUT with a double idx will not match the integers in your volume.
lut <- lut |>
lut_add(idx = 1L, label = "Left-Thalamus", R = 0L, G = 118L, B = 14L) |>
lut_add(idx = 2L, label = "Right-Thalamus", R = 0L, G = 118L, B = 14L) |>
lut_add(idx = 3L, label = "Left-Putamen", R = 236L, G = 13L, B = 176L) |>
lut_add(idx = 4L, label = "Right-Putamen", R = 236L, G = 13L, B = 176L)
lut
#> idx label R G B A
#> 1 0 Unknown 0 0 0 0
#> 2 1 Left-Thalamus 0 118 14 0
#> 3 2 Right-Thalamus 0 118 14 0
#> 4 3 Left-Putamen 236 13 176 0
#> 5 4 Right-Putamen 236 13 176 0A defaults to 0, which is what FreeSurfer writes and what the pipelines expect. It is an opacity column that nothing in this ecosystem reads; it exists so the file format round-trips.
Reading and writing
Real tables come from disk. read_lut() parses FreeSurfer’s whitespace-separated format, and write_lut() writes it back:
Reading it back gives you the same table, which is the property you want when a LUT is under version control alongside the atlas it describes:
read_lut(path)
#> idx label R G B A
#> 1 0 Unknown 0 0 0 0
#> 2 1 Left-Thalamus 0 118 14 0
#> 3 2 Right-Thalamus 0 118 14 0
#> 4 3 Left-Putamen 236 13 176 0
#> 5 4 Right-Putamen 236 13 176 0Colours you didn’t have to pick
Hand-picking 80 distinguishable colours is not a good use of an afternoon. lut_generate_colors() spreads the palette around the colour circle for you, and — this is the part that makes it worth using — it gives both hemispheres of a structure the same colour, because Left-Thalamus and Right-Thalamus are one structure seen twice.
blank <- data.frame(
idx = 0:4,
label = c(
"Unknown",
"Left-Thalamus", "Right-Thalamus",
"Left-Putamen", "Right-Putamen"
),
R = 0L, G = 0L, B = 0L, A = 0L
)
lut_generate_colors(blank)
#> idx label R G B A
#> 1 0 Unknown 0 0 0 0
#> 2 1 Left-Thalamus 179 68 97 0
#> 3 2 Right-Thalamus 179 68 97 0
#> 4 3 Left-Putamen 0 185 166 0
#> 5 4 Right-Putamen 0 185 166 0Two structures, two colours, and idx = 0 untouched. Every other row is overwritten, so run this on a table whose palette you are willing to lose.
chroma and luminance control intensity and lightness. If you ask for a chroma the display cannot render across that many hues, the function errors rather than quietly handing you two regions the same colour — lowering chroma is the usual fix.
lut_generate_colors(blank, chroma = 45)
#> idx label R G B A
#> 1 0 Unknown 0 0 0 0
#> 2 1 Left-Thalamus 155 86 101 0
#> 3 2 Right-Thalamus 155 86 101 0
#> 4 3 Left-Putamen 59 175 163 0
#> 5 4 Right-Putamen 59 175 163 0A trap with by
by names a column whose groups each get the whole colour circle instead of a slice of a shared one. It is built for a whole-brain table split into "cortical" and "subcortical", which become two atlases that never appear in the same plot — so a colour only has to be unique within each half.
The trap is that nothing stops you passing a column with one group per region. Every group then starts at the same point on the circle, and you get one colour for the entire table:
grouped <- blank
grouped$region <- label_to_region(grouped$label)
lut_generate_colors(grouped, by = "region")
#> idx label R G B A region
#> 1 0 Unknown 0 0 0 0 unknown
#> 2 1 Left-Thalamus 179 68 97 0 thalamus
#> 3 2 Right-Thalamus 179 68 97 0 thalamus
#> 4 3 Left-Putamen 179 68 97 0 putamen
#> 5 4 Right-Putamen 179 68 97 0 putamenThat is the documented behaviour working as designed, and it is still almost certainly not what you wanted. Keep by for coarse splits — two or three groups, not forty.
Hex colours for plotting
get_lut() adds the two columns plotting code actually wants: color, the hex string, and roi, the zero-padded index that ggseg3d matches on.
get_lut(lut_generate_colors(blank))
#> idx label R G B A roi color
#> 1 0 Unknown 0 0 0 0 0000 #000000
#> 2 1 Left-Thalamus 179 68 97 0 0001 #B34461
#> 3 2 Right-Thalamus 179 68 97 0 0002 #B34461
#> 4 3 Left-Putamen 0 185 166 0 0003 #00B9A6
#> 5 4 Right-Putamen 0 185 166 0 0004 #00B9A6It takes a path as well as a data frame, so get_lut("aseg_LUT.txt") is a one-liner for inspecting a table on disk.
Merging two tables
lut_combine() stacks tables, which comes up when you are bolting a custom subcortical parcellation onto a standard cortical one.
It warns about duplicate indices rather than resolving them, and the warning fires on the common case: both of your tables have a background row at idx = 0.
extra <- data.frame(
idx = 0L, label = "Unknown",
R = 0L, G = 0L, B = 0L, A = 0L
) |>
lut_add(idx = 10L, label = "Left-Amygdala", R = 103L, G = 255L, B = 255L)
lut_combine(lut, extra)
#> Warning: Duplicate label indices in combined table: 0.
#> idx label R G B A
#> 1 0 Unknown 0 0 0 0
#> 2 1 Left-Thalamus 0 118 14 0
#> 3 2 Right-Thalamus 0 118 14 0
#> 4 3 Left-Putamen 236 13 176 0
#> 5 4 Right-Putamen 236 13 176 0
#> 6 0 Unknown 0 0 0 0
#> 7 10 Left-Amygdala 103 255 255 0Drop the duplicate background before combining and the warning goes away:
lut_combine(lut, extra[extra$idx != 0L, ])
#> idx label R G B A
#> 1 0 Unknown 0 0 0 0
#> 2 1 Left-Thalamus 0 118 14 0
#> 3 2 Right-Thalamus 0 118 14 0
#> 4 3 Left-Putamen 236 13 176 0
#> 5 4 Right-Putamen 236 13 176 0
#> 6 10 Left-Amygdala 103 255 255 0Take the warning seriously when it names an index other than 0. Two rows claiming idx = 12 means one of your regions is about to silently become the other.
From label to region name
Atlases carry two names per parcel. label is the identifier from the source parcellation, stable and unambiguous; region is what a reader sees in a legend.
label_to_region() is the exact rule the pipelines use to derive one from the other — strip the hemisphere affix, turn separators into spaces, lower-case the result:
label_to_region(c("Left-Thalamus", "Pu_Left", "Central_Lateral-Lateral_Posterior_Left"))
#> [1] "thalamus" "pu"
#> [3] "central lateral lateral posterior"It is exported so that build scripts can reproduce the rule exactly. If you are writing a metadata table keyed on region, key it on label_to_region(label) rather than on a gsub() you wrote by hand; that is how one region quietly joins to NA.
Check the output before trusting it. The affixes it strips are a fixed set, and FreeSurfer’s ctx-lh- convention is not among them:
label_to_region(c("ctx-lh-superiorfrontal", "ctx-rh-superiorfrontal"))
#> [1] "ctx lh superiorfrontal" "ctx rh superiorfrontal"Both hemispheres kept their prefix, so one structure now looks like two different regions — and region is the column that pairs hemispheres. When your labels use a convention it doesn’t recognise, strip the affix yourself first.
Handing the table to a pipeline
Every volumetric builder takes input_lut, and every one of them accepts either a path or a data frame:
atlas <- create_subcortical_from_volume(
input_volume = "my_parcellation.nii.gz",
input_lut = lut,
atlas_name = "myatlas",
output_dir = "data-raw"
)Passing the data frame is the better habit. It means the table you inspected is the table the pipeline used, with no chance of editing one file and reading another.
Declaring what the name doesn’t say
The six columns are the minimum. A lookup table can also carry columns that declare something about a label, and a pipeline that finds one uses it instead of working the answer out for itself. Each answers one question, and all follow the same rule: a value the column cannot mean is an error before any work starts, and NA means “I’m not saying”, which sends that one label back to the fallback.
type: cortical, subcortical or cerebellar
For whole-brain volumes there is one more column to fill in. create_wholebrain_from_volume() needs to know which labels are cortical, subcortical and cerebellar, and it will guess from surface coverage if you don’t tell it — noisily, but it will guess. The column takes exactly "cortical", "subcortical" or "cerebellar". "Cortical" or "cortex" stops the build with the offending values named, rather than quietly dropping the label into the guess you were trying to avoid. lut_classify_anatomy() works the column out properly by overlaying the volume on FreeSurfer’s aparc+aseg:
lut <- lut_classify_anatomy("my_parcellation.nii.gz", "my_LUT.txt")
write_lut(lut, "my_LUT.txt")Run it once and commit the result. It needs FreeSurfer installed, and there is no reason to pay for it on every build.
hemi: which side a label is on
By default a label’s hemisphere is read from its name: Left-Thalamus, lh.something, region_L, ctx-rh-precuneus. That works until the names don’t say — a parcellation numbered Region_1 to Region_268 has no side in its labels at all. A hemi column says it outright:
It takes "left" or "right" (lh/rh and l/r are read too), plus "midline" and, for the cerebellum, "vermis". The third row above declares nothing, so its hemisphere is still read from its name.
All three volumetric builders honour the column. In create_subcortical_from_volume() and create_cerebellar_from_volume() it sets the region’s hemisphere. In create_wholebrain_from_volume() it also decides which cortical surface a parcel is kept on, so a parcel whose voxels spill across the midline doesn’t become two regions.
context: region or backdrop
A subcortical atlas is rarely just its structures. They are drawn against other anatomy — a cortical ribbon, the ventricles, the brain stem — that locates them without being part of the atlas. create_subcortical_from_volume() does not work out which is which. It reads it from the table:
- a row is a region unless it says otherwise;
- a row whose
contextisTRUEis context: it keeps its name, is drawn grey, and gets no 3D mesh; - a label that is in the volume but not in the table at all is context too, and since nothing is known about it, it is named for its id —
context_0002.
anatomy <- data.frame(
idx = c(10L, 49L, 3L, 42L),
label = c(
"Left-Thalamus", "Right-Thalamus",
"Left-Cerebral-Cortex", "Right-Cerebral-Cortex"
),
R = c(0L, 0L, 205L, 205L), G = c(118L, 118L, 62L, 62L),
B = c(14L, 14L, 78L, 78L), A = 0L,
context = c(FALSE, FALSE, TRUE, TRUE)
)
is_lut(anatomy)
#> [1] TRUEThe column is TRUE or FALSE; a row left NA, or a table without the column, means region. It is deliberately separate from type, which says which atlas a label belongs to, so a whole-brain table can say both things about one label.
Declaring context in the table is not the same as turning a region grey afterwards with atlas_region_contextual(). A region is projected through its whole slab, which turns a cortical ribbon into a solid shape; context is traced on a single slice, so the ribbon keeps its folds. Decide in the table when you can, and keep atlas_region_contextual() for changing your mind about an atlas you already have.
Context you do not want drawn at all comes out afterwards. atlas_context_remove() drops every context shape, and context_pattern(atlas) gives a pattern matching exactly the context of that atlas, for treating it differently when you polish:
atlas <- create_subcortical_from_volume("aseg.mgz", input_lut = anatomy)
# Round the structures, leave the backdrop's folds alone
atlas <- atlas_polish(atlas, keep = 0.1, exclude = context_pattern(atlas))
# Or drop the backdrop altogether
atlas <- atlas_context_remove(atlas)
display: what a region is called in a legend
Every atlas the pipelines build carries three ways of referring to a region. label is the identifier, unique and never edited. region is the short key derived from it, the same on both sides of the brain. display is the one meant to be read: the long-form name a plot shows.
Left alone, display is the region. A display column in the table supplies something better, row by row:
The two thalami declare nothing and are named thalamus, after their region. Left-VentralDC would have been ventraldc.
Display names have spaces in them, which a lookup table file cannot hold in a single field, so display travels in a data frame and write_lut() leaves it out. Keep it in the script that builds the table.
Keeping the declarations in the file
All three columns survive a trip through a file, so the table you commit is the whole declaration. write_lut() puts them after the colours, and when hemi or context is there it adds a comment line naming the fields, with NA wherever a row declares nothing:
numbered$type <- c("cortical", "cortical", "subcortical")
path <- file.path(tempdir(), "numbered_LUT.txt")
write_lut(numbered, path)
cat(readLines(path), sep = "\n")
#> # idx label R G B A type hemi
#> 1 Region_1 0 118 14 0 cortical left
#> 2 Region_2 0 118 14 0 cortical right
#> 3 Region_3 236 13 176 0 subcortical NAFreeSurfer skips the comment and reads only the first six fields of each line, so this is still a colour table as far as it is concerned. read_lut() reads all of it back:
read_lut(path)
#> idx label R G B A type hemi
#> 1 1 Region_1 0 118 14 0 cortical left
#> 2 2 Region_2 0 118 14 0 cortical right
#> 3 3 Region_3 236 13 176 0 subcortical <NA>A table with type alone is written the older way — no comment line, type as a seventh field — so lookup table files you already have don’t change. Columns that declare nothing a pipeline reads, like the roi and color that get_lut() adds, stay out of the file.
Where to go next
The LUT is the least glamorous file in an atlas build and the one that most often explains a broken result. When a region comes out the wrong colour, missing, or merged with its neighbour, read the table first.
From here, Post-processing atlases covers renaming and curating regions once the atlas exists, and the volumetric tutorials — subcortical, whole-brain and cerebellar — put the table to work.