Source code for clabtoolkit.colorstools

"""
## colorstools Module Description

### Overview
The `colorstools` module provides comprehensive functionality for working
with colors in neuroimaging and scientific visualization contexts. It offers
utilities for color validation, conversion, manipulation, and management of
color lookup tables (LUTs) commonly used in FreeSurfer and other neuroimaging
software packages.

### Core Functionality

**Color Validation and Detection**
The module includes robust color validation functions that handle multiple
input formats including hexadecimal strings, RGB arrays (both 0-255 and 0-1
ranges), numpy arrays, and Python lists. It provides intelligent detection
of RGB value ranges and validates color formats across different
representations.

**Color Conversion and Transformation**
Extensive conversion utilities enable seamless transformation between
different color formats (hex to RGB, RGB to hex, and normalized variants).
The module supports batch conversions through functions like `multi_hex2rgb()`
and `multi_rgb2hex()`, allowing efficient processing of large color datasets.
Additional transformation capabilities include color inversion, lightening,
darkening, and saturation adjustments performed in HSV color space.

**Color Harmonization and Matching**
The `harmonize_colors()` function standardizes mixed-format color inputs into
a consistent output format, essential for visualization pipelines that accept
diverse color specifications. The module also provides color matching utilities
to find the closest colors within a palette and generate distinguishable color
schemes for categorical data visualization.

**Color Lookup Table Management**
The `ColorTableLoader` class provides sophisticated handling of FreeSurfer-style
color lookup tables (LUT files) and TSV-format color tables. It supports reading,
writing, filtering, and exporting color tables in multiple formats. The class
automatically detects file formats and handles headerlines, region codes, color
specifications, and opacity values. It includes methods for converting between
different color table formats and integrating with neuroimaging parcellation schemes.

**Visualization Utilities**
The module includes functions for visualizing color palettes, displaying color
swatches with labels, and creating colormaps. These utilities facilitate quality
control of color schemes and enable interactive exploration of color tables in
Jupyter notebooks through HTML rendering.

**Terminal Output Formatting**
The `bcolors` class provides ANSI color codes for enhanced terminal output, enabling
colored console messages for logging, debugging, and user feedback in command-line
applications.

### Use Cases
This module is particularly valuable for neuroimaging researchers working with brain
parcellations, creating publication-quality figures, managing custom color schemes
for anatomical regions, and developing visualization tools that require consistent
color handling across different data formats and software packages.
"""

import numpy as np
import os
import copy
from datetime import datetime
from typing import Union, List, Any, Optional
import re
import pandas as pd
from IPython.display import HTML
from pathlib import Path
from colorama import init, Fore, Style, Back

init(autoreset=True)

import matplotlib.pyplot as plt
import matplotlib.patches as patches
from matplotlib import colormaps
from matplotlib.colors import to_hex
from matplotlib.colors import is_color_like as mpl_is_color_like
from matplotlib.colors import rgb_to_hsv, hsv_to_rgb

import textwrap


from . import misctools as cltmisc


####################################################################################################
####################################################################################################
############                                                                            ############
############                                                                            ############
############              Section 1: Methods dedicated to work with colors              ############
############                                                                            ############
############                                                                            ############
####################################################################################################
####################################################################################################
[docs] class bcolors: """ This class is used to define the colors for the terminal output. It can be used to print the output in different colors. """ HEADER = "\033[95m" OKBLUE = "\033[94m" OKCYAN = "\033[96m" OKGREEN = "\033[92m" OKYELLOW = "\033[93m" OKRED = "\033[91m" OKMAGENTA = "\033[95m" PURPLE = "\033[35m" OKCYAN = "\033[96m" DARKCYAN = "\033[36m" ORANGE = "\033[48:5:208m%s\033[m" OKWHITE = "\033[97m" DARKWHITE = "\033[37m" OKBLACK = "\033[30m" OKGRAY = "\033[90m" OKPURPLE = "\033[35m" WARNING = "\033[93m" FAIL = "\033[91m" ENDC = "\033[0m" BOLD = "\033[1m" ITALIC = "\033[3m" UNDERLINE = "\033[4m" DIM = "\033[2m" NORMAL = "\033[22m" RESET_ALL = "\033[0m"
####################################################################################################
[docs] def is_color_like(color) -> bool: """ Extended color validation that handles numpy arrays and Python lists. Supports both RGB (3 components) and RGBA (4 components) colors. Parameters ---------- color : Any The color to validate. Can be: - Hex string (e.g., "#FF5733" or "#FF5733FF") - Numpy array ([R,G,B] or [R,G,B,A] as integers 0-255 or floats 0-1 or floats 0-255) - Python list/tuple ([R,G,B] or [R,G,B,A] as integers 0-255 or floats 0-1 or floats 0-255) Returns ------- bool True if the color is valid, False otherwise. Examples -------- >>> is_color_like("#FF5733") # Hex string True >>> is_color_like(np.array([255, 87, 51])) # Numpy array (int) True >>> is_color_like(np.array([255, 87, 51, 255])) # Numpy array with alpha True >>> is_color_like(np.array([1.0, 0.34, 0.2])) # Numpy array (float 0-1) True >>> is_color_like(np.array([70., 130., 180.])) # Numpy array (float 0-255) True >>> is_color_like([255, 87, 51]) # Python list (int) True >>> is_color_like((255, 128, 0, 128)) # Tuple with alpha True >>> is_color_like([1.0, 0.34, 0.5]) # Python list (float 0-1) True >>> is_color_like([255.0, 128.0, 0.0]) # Integer-valued floats True >>> is_color_like((255, 128, 0)) # Tuple True >>> is_color_like("invalid_color") False >>> is_color_like([256, 0, 0]) # Out of range False """ # Handle numpy arrays if isinstance(color, np.ndarray): # Accept both RGB (3) and RGBA (4) components if color.shape not in [(3,), (4,)]: return False # Integer arrays: must be in 0-255 range if np.issubdtype(color.dtype, np.integer): return (color >= 0).all() and (color <= 255).all() # Float arrays: can be either 0-1 or 0-255 range if np.issubdtype(color.dtype, np.floating): # Check if all values are in 0-1 range if (color >= 0).all() and (color <= 1).all(): return True # Check if all values are in 0-255 range and are whole numbers if (color >= 0).all() and (color <= 255).all(): # Check if they're all whole numbers (e.g., 70.0, 130.0, 180.0) return np.all(color == np.floor(color)) return False return False # Handle Python lists and tuples if isinstance(color, (list, tuple)): # Accept both RGB (3) and RGBA (4) components if len(color) not in [3, 4]: return False try: # Convert to numeric values values = [float(x) for x in color] except (ValueError, TypeError): return False # Check if values could be in 0-1 range (all <= 1) if all(0.0 <= v <= 1.0 for v in values): return True # Check if values could be in 0-255 range # Accept both exact integers and integer-valued floats (e.g., 255.0) if all(0 <= v <= 255 for v in values): # Check if they're all whole numbers (integers or integer-valued floats) if all(v == int(v) for v in values): return True return False # Default to matplotlib's validator for strings and other types return mpl_is_color_like(color)
#####################################################################################################
[docs] def detect_rgb_range(rgb: Any) -> str: """ Detect if an RGB array uses 0-255 or 0-1 range. This function analyzes RGB color values to determine whether they follow the 0-255 integer format (8-bit) or the 0-1 float format (normalized). Parameters ---------- rgb : Any RGB color array/list containing 3 numeric values [R, G, B]. Expected formats: [255, 128, 0] or [1.0, 0.5, 0.0] Returns ------- str - "0-255" if any value is greater than 1 - "0-1" if all values are between 0 and 1 (inclusive) - "invalid" if input is malformed or values are outside valid ranges Raises ------ None This function does not raise any exceptions. Invalid inputs return "invalid" instead of raising errors. Examples -------- >>> detect_rgb_range([255, 128, 0]) '0-255' >>> detect_rgb_range([1.0, 0.5, 0.0]) '0-1' >>> detect_rgb_range([0, 1, 0]) '0-1' >>> detect_rgb_range([255, 0.5, 128]) 'invalid' >>> detect_rgb_range([300, 200, 100]) 'invalid' >>> detect_rgb_range([0.0, 0.0, 0.0]) '0-1' >>> detect_rgb_range([255, 255, 255]) '0-255' >>> detect_rgb_range([2, 1, 0]) '0-255' >>> detect_rgb_range("not_a_list") 'invalid' >>> detect_rgb_range([255, 128]) 'invalid' Notes ----- - Expects exactly 3 numeric values (R, G, B) - Any value greater than 1 classifies the array as "0-255" range - All values between 0-1 (inclusive) classify the array as "0-1" range - Combinations like [0, 1, 0] are treated as "0-1" range - The 0-255 validator only accepts whole numbers (integers or floats like 128.0) - The 0-1 validator accepts any numeric values in the 0-1 range - Mixed ranges (e.g., [255, 0.5, 128]) are considered invalid - Out-of-range values (negative or > 255) result in "invalid" classification """ # Validate input format if not isinstance(rgb, (list, tuple)) or len(rgb) != 3: return "invalid" # Check if all values are numeric try: values = [float(val) for val in rgb] except (ValueError, TypeError): return "invalid" # Check if all values are in 0-1 range in_zero_one = all(0.0 <= val <= 1.0 for val in values) # Check if all values are in 0-255 range in_zero_255 = all(0 <= val <= 255 for val in values) # Determine range based on values if not in_zero_one and not in_zero_255: return "invalid" # If any value > 1, it's definitely 0-255 range if any(val > 1 for val in values): return "0-255" # If all values <= 1, treat as 0-1 range # (This includes combinations of 0 and 1) return "0-1"
#####################################################################################################
[docs] def is_valid_rgb_255(rgb: Any) -> bool: """ Check if RGB array contains valid 0-255 range values. This function validates RGB color values in the 0-255 range. It accepts integers and integer-valued floats (e.g., 255.0), but rejects fractional values or values outside the valid range. Parameters ---------- rgb : Any RGB color array/list to validate. Can be: - Numpy array with 3 elements - Python list with 3 elements - Python tuple with 3 elements Returns ------- bool True if all values are valid 0-255 range integers (or integer-valued floats), False otherwise Examples -------- >>> is_valid_rgb_255([255, 128, 0]) True >>> is_valid_rgb_255([0, 0, 0]) True >>> is_valid_rgb_255([1, 1, 1]) True >>> is_valid_rgb_255([128.0, 200.0, 50.0]) True >>> is_valid_rgb_255((255, 128, 0)) True >>> is_valid_rgb_255(np.array([255, 128, 0])) True >>> is_valid_rgb_255(np.array([70., 130., 180.])) True >>> is_valid_rgb_255([np.int64(255), 128, 0]) True >>> is_valid_rgb_255([0.5, 0.3, 0.8]) False >>> is_valid_rgb_255([128.5, 200.7, 50.2]) False >>> is_valid_rgb_255([300, 200, 100]) False >>> is_valid_rgb_255([-1, 128, 0]) False >>> is_valid_rgb_255([255, 128]) False Notes ----- - Accepts both Python native types and numpy types - Integer-valued floats (e.g., 255.0) are considered valid - Fractional floats (e.g., 128.5) are rejected - Values must be in the inclusive range [0, 255] """ # Handle numpy arrays if isinstance(rgb, np.ndarray): # Must have exactly 3 elements if rgb.shape != (3,): return False # Integer arrays: check range if np.issubdtype(rgb.dtype, np.integer): return (rgb >= 0).all() and (rgb <= 255).all() # Float arrays: must be in 0-255 range and whole numbers if np.issubdtype(rgb.dtype, np.floating): if not ((rgb >= 0).all() and (rgb <= 255).all()): return False # Check if all values are whole numbers return np.all(rgb == np.floor(rgb)) return False # Handle lists and tuples if isinstance(rgb, (list, tuple)): # Must have exactly 3 elements if len(rgb) != 3: return False try: # Convert to float for validation values = [float(val) for val in rgb] # All values must be in 0-255 range if not all(0 <= val <= 255 for val in values): return False # Only accept whole numbers (integers or integer-valued floats) if not all(val == int(val) for val in values): return False return True except (ValueError, TypeError): return False # Reject other types return False
#####################################################################################################
[docs] def is_valid_rgb_01(rgb: Any) -> bool: """ Check if RGB array contains valid 0-1 range values. This function validates RGB color values in the 0-1 normalized range. It accepts floats and integers (0 or 1 only) in the valid range. Parameters ---------- rgb : Any RGB color array/list to validate. Can be: - Numpy array with 3 elements - Python list with 3 elements - Python tuple with 3 elements Returns ------- bool True if all values are in 0-1 range, False otherwise Examples -------- >>> is_valid_rgb_01([1.0, 0.5, 0.0]) True >>> is_valid_rgb_01([0.0, 0.0, 0.0]) True >>> is_valid_rgb_01([1.0, 1.0, 1.0]) True >>> is_valid_rgb_01([0, 0, 0]) True >>> is_valid_rgb_01([1, 1, 1]) True >>> is_valid_rgb_01([0, 1, 0]) True >>> is_valid_rgb_01((1.0, 0.5, 0.0)) True >>> is_valid_rgb_01(np.array([1.0, 0.5, 0.0])) True >>> is_valid_rgb_01(np.array([0, 1, 0])) True >>> is_valid_rgb_01([np.float64(1.0), 0.5, 0.0]) True >>> is_valid_rgb_01([255, 128, 0]) False >>> is_valid_rgb_01([1.5, 0.5, 0.2]) False >>> is_valid_rgb_01([-0.1, 0.5, 0.2]) False >>> is_valid_rgb_01([0, 0]) False Notes ----- - Accepts both Python native types and numpy types - Integer values must be 0 or 1 only - Float values can be any value in the range [0.0, 1.0] - Values must be in the inclusive range [0, 1] """ # Handle numpy arrays if isinstance(rgb, np.ndarray): # Must have exactly 3 elements if rgb.shape != (3,): return False # Integer arrays: only 0 and 1 are valid if np.issubdtype(rgb.dtype, np.integer): return (rgb >= 0).all() and (rgb <= 1).all() # Float arrays: must be in 0-1 range if np.issubdtype(rgb.dtype, np.floating): return (rgb >= 0.0).all() and (rgb <= 1.0).all() return False # Handle lists and tuples if isinstance(rgb, (list, tuple)): # Must have exactly 3 elements if len(rgb) != 3: return False try: # Convert to float for validation values = [float(val) for val in rgb] # All values must be in 0-1 range return all(0.0 <= val <= 1.0 for val in values) except (ValueError, TypeError): return False # Reject other types return False
#####################################################################################################
[docs] def normalize_rgb(rgb: Any) -> Union[List[float], None]: """ Convert RGB array to 0-1 range regardless of input format. Parameters ---------- rgb : Any RGB color array in either 0-255 or 0-1 format Returns ------- List[float] or None RGB values normalized to 0-1 range, or None if invalid input """ range_type = detect_rgb_range(rgb) if range_type == "invalid": return None elif range_type == "0-255": return [val / 255.0 for val in rgb] elif range_type == "0-1": return [float(val) for val in rgb] else: return None
####################################################################################################
[docs] def rgb2hex(r: Union[int, float], g: Union[int, float], b: Union[int, float]) -> str: """ Convert RGB values to hexadecimal color code. Handles both integer (0-255) and normalized float (0-1) inputs. Parameters ---------- r : int or float Red value (0-255 for integers, 0-1 for floats) g : int or float Green value (0-255 for integers, 0-1 for floats) b : int or float Blue value (0-255 for integers, 0-1 for floats) Returns ------- str Hexadecimal color code in lowercase (e.g., "#ff0000") Raises ------ ValueError If values are outside valid ranges (either 0-255 or 0-1) TypeError If input types are mixed (some ints and some floats) Examples -------- >>> rgb2hex(255, 0, 0) # Integer inputs '#ff0000' >>> rgb2hex(1.0, 0.0, 0.0) # Normalized float inputs '#ff0000' >>> rgb2hex(0.5, 0.0, 1.0) # Mixed range '#7f00ff' """ # Check for mixed input types input_types = {type(r), type(g), type(b)} if len(input_types) > 1: raise TypeError( "All RGB components must be the same type (all int or all float)" ) # Process based on input type if isinstance(r, float): # Validate normalized range if not (0 <= r <= 1 and 0 <= g <= 1 and 0 <= b <= 1): raise ValueError("Float values must be between 0 and 1") # Convert to 0-255 range r, g, b = (int(round(x * 255)) for x in (r, g, b)) else: # Validate 0-255 range if not (0 <= r <= 255 and 0 <= g <= 255 and 0 <= b <= 255): raise ValueError("Integer values must be between 0 and 255") # Ensure values are within byte range after conversion r, g, b = (max(0, min(255, x)) for x in (r, g, b)) return "#{:02x}{:02x}{:02x}".format(r, g, b)
####################################################################################################
[docs] def multi_rgb2hex( colors: Union[List[Union[str, list, np.ndarray]], np.ndarray], ) -> List[str]: """ Function to convert rgb to hex for an array of colors. Note: If there are already elements in hexadecimal format the will not be transformed. Parameters ---------- colors : list or numpy array List of rgb colors Returns ------- hexcodes: list List of hexadecimal codes for the colors Examples -------------- >>> colors = [[255, 0, 0], [0, 255, 0], [0, 0, 255]] >>> hexcodes = multi_rgb2hex(colors) >>> print(hexcodes) # Output: ['#ff0000', '#00ff00', '#0000ff'] """ # Harmonizing the colors hexcodes = harmonize_colors(colors, output_format="hex") return hexcodes
#######################################################################################################
[docs] def is_valid_hex_color(hex_color): """ Strict validation that requires # prefix and only allows 6-digit format. This function validates hexadecimal color codes using a strict format that requires exactly 6 hexadecimal digits preceded by a hash (#) symbol. Parameters ---------- hex_color : str The hex color string to validate. Must be in the format #RRGGBB where R, G, B are hexadecimal digits (0-9, A-F, a-f). Returns ------- bool True if the input is a valid 6-digit hex color with # prefix, False otherwise. Raises ------ None This function does not raise any exceptions. Invalid inputs return False instead of raising errors. Examples -------- >>> is_valid_hex_color("#FF0000") True >>> is_valid_hex_color("#00FF00") True >>> is_valid_hex_color("#0000FF") True >>> is_valid_hex_color("#ffffff") True >>> is_valid_hex_color("#ABC123") True >>> is_valid_hex_color_strict("#FFF") False >>> is_valid_hex_color("FF0000") False >>> is_valid_hex_color("#GG0000") False >>> is_valid_hex_color("#FF0000FF") False >>> is_valid_hex_color("") False >>> is_valid_hex_color(None) False >>> is_valid_hex_color(123) False Notes ----- - Only accepts 6-digit hexadecimal format (e.g., #RRGGBB) - Requires the # prefix - Case-insensitive for hex digits (A-F or a-f) - Does not accept 3-digit shorthand (e.g., #FFF) - Does not accept 8-digit format with alpha channel - Non-string inputs return False """ if not isinstance(hex_color, str): return False pattern = r"^#[0-9A-Fa-f]{6}$" return bool(re.match(pattern, hex_color))
####################################################################################################
[docs] def hex2rgb(hexcode: str) -> tuple: """ Function to convert hex to rgb Parameters ---------- hexcode : str Hexadecimal code for the color Returns ------- tuple Tuple with the rgb values Examples -------------- >>> hexcode = "#FF5733" >>> rgb = hex2rgb(hexcode) >>> print(rgb) # Output: (255, 87, 51) """ # Convert hexadecimal color code to RGB values hexcode = hexcode.lstrip("#") return tuple(int(hexcode[i : i + 2], 16) for i in (0, 2, 4))
####################################################################################################
[docs] def multi_hex2rgb(hexcodes: Union[str, List[str]]) -> np.ndarray: """ Function to convert a list of colores in hexadecimal format to rgb format. Parameters ---------- hexcodes : list List of hexadecimal codes for the colors Returns ------- rgb_list: np.array Array of rgb values Examples -------------- >>> hexcodes = ["#FF5733", "#33FF57", "#3357FF"] >>> rgb_list = multi_hex2rgb(hexcodes) >>> print(rgb_list) # Output: [[255, 87, 51], [51, 255, 87], [51, 87, 255]] """ if isinstance(hexcodes, str): hexcodes = [hexcodes] rgb_list = [hex2rgb(hex_color) for hex_color in hexcodes] return np.array(rgb_list)
####################################################################################################
[docs] def invert_colors( colors: Union[List[Union[str, list, np.ndarray]], np.ndarray], ) -> Union[List[Union[str, list, np.ndarray]], np.ndarray]: """ Invert colors while maintaining the original input format and value ranges. Parameters ---------- colors : list or numpy array Input colors in any of these formats: - Hex strings (e.g., "#FF5733") - Python lists ([R,G,B] as integers 0-255 or floats 0-1) - Numpy arrays (integers 0-255 or floats 0-1) Returns ------- Union[List[Union[str, list, np.ndarray]], np.ndarray] Inverted colors in the same format and range as input Examples -------- >>> invert_colors([np.array([0.0, 0.0, 1.0]), np.array([0, 255, 243])]) [array([1., 1., 0.]), array([255, 0, 12])] """ if not isinstance(colors, (list, np.ndarray)): raise TypeError("Input must be a list or numpy array") # Store original formats and ranges input_types = [] input_ranges = [] # '0-1' or '0-255' for color in colors: input_types.append(type(color)) if isinstance(color, np.ndarray): if np.issubdtype(color.dtype, np.integer): input_ranges.append("0-255") else: input_ranges.append("0-1") elif isinstance(color, list): if all(isinstance(x, int) for x in color): input_ranges.append("0-255") else: input_ranges.append("0-1") else: # hex string input_ranges.append("0-255") # hex implies 0-255 # Convert all to normalized (0-1) for inversion normalized_colors = [] for color, orig_range in zip(colors, input_ranges): if orig_range == "0-255": if isinstance(color, str): hex_color = color.lstrip("#") rgb = ( np.array([int(hex_color[i : i + 2], 16) for i in (0, 2, 4)]) / 255.0 ) elif isinstance(color, (list, np.ndarray)): rgb = np.array(color) / 255.0 normalized_colors.append(rgb) else: normalized_colors.append(np.array(color)) # Perform inversion in HSV space inverted = [] for color in normalized_colors: hsv = rgb_to_hsv(color.reshape(1, 1, 3)) hsv[..., 0] = (hsv[..., 0] + 0.5) % 1.0 # Hue rotation inverted_rgb = hsv_to_rgb(hsv).flatten() inverted.append(inverted_rgb) # Convert back to original formats and ranges result = [] for inv_color, orig_type, orig_range in zip(inverted, input_types, input_ranges): if orig_range == "0-255": inv_color = (inv_color * 255).round().astype(np.uint8) if orig_type == str: result.append( to_hex(inv_color / 255 if orig_range == "0-255" else inv_color).lower() ) elif orig_type == list: if orig_range == "0-255": result.append([int(x) for x in inv_color]) else: result.append([float(x) for x in inv_color]) else: # numpy.ndarray if orig_range == "0-255": result.append(inv_color.astype(np.uint8)) else: result.append(inv_color.astype(np.float64)) # Return same container type as input return np.array(result) if isinstance(colors, np.ndarray) else result
####################################################################################################
[docs] def harmonize_colors( colors: Union[str, List[Union[str, list, np.ndarray]], np.ndarray, tuple], output_format: str = "hex", ) -> Union[List[str], np.ndarray]: """ Convert all colors in a list to a consistent format. Handles hex strings, RGB/RGBA lists, tuples, and numpy arrays (both 0-255 and 0-1 ranges). Note: Alpha channel is discarded if present. Parameters ---------- colors : list, numpy array, str, or tuple Input colors in various formats: - Single color: str, list, tuple, or numpy array - Multiple colors: list or numpy array containing: * Hex strings (e.g., "#FF5733") * Python lists or tuples ([R,G,B] or [R,G,B,A] as integers 0-255 or floats 0-1) * Numpy arrays (integers 0-255 or floats 0-1) output_format : str, optional Output format ('hex', 'rgb', or 'rgbnorm'), defaults to 'hex' - 'hex': returns hexadecimal strings (e.g., '#ff5733') - 'rgb': returns RGB arrays with values 0-255 (uint8) - 'rgbnorm': returns normalized RGB arrays with values 0.0-1.0 (float64) Returns ------- Union[List[str], np.ndarray] - If output_format is 'hex': List of hexadecimal color strings - If output_format is 'rgb' or 'rgbnorm': 2D numpy array where each row is a color Examples -------- >>> colors = ["#FF5733", [255, 87, 51], (51, 87, 255)] >>> harmonize_colors(colors) ['#ff5733', '#ff5733', '#3357ff'] >>> colors = [(255, 87, 51, 255), (51, 87, 255, 128)] # RGBA >>> harmonize_colors(colors) ['#ff5733', '#3357ff'] >>> harmonize_colors(colors, output_format='rgb') array([[255, 87, 51], [255, 87, 51], [ 51, 87, 255]], dtype=uint8) >>> harmonize_colors(colors, output_format='rgbnorm') array([[1. , 0.34117647, 0.2 ], [1. , 0.34117647, 0.2 ], [0.2 , 0.34117647, 1. ]]) >>> # Single color input >>> harmonize_colors((255, 87, 51)) ['#ff5733'] """ # Normalize input into iterable of colors if isinstance(colors, str): colors = [colors] elif isinstance(colors, tuple): colors = [colors] elif isinstance(colors, np.ndarray): if colors.ndim == 1: # Single color (e.g., [R,G,B] or [R,G,B,A]) colors = [colors] elif colors.ndim == 2: # Multiple colors (Nx3 or Nx4) colors = [row for row in colors] else: raise ValueError("NumPy array must be 1D or 2D for colors") # Final validation if not isinstance(colors, list): raise TypeError("Input must be a string, tuple, list, or numpy array") # Validate output format output_format = output_format.lower() if output_format not in ["hex", "rgb", "rgbnorm"]: raise ValueError("output_format must be 'hex', 'rgb', or 'rgbnorm'") result = [] for color in colors: # Validate color if not is_color_like(color): raise ValueError(f"Invalid color: {color}") # Convert all inputs to numpy array for consistent processing if isinstance(color, str): # Hex string -> convert to RGB array if not color.startswith("#"): try: color = to_hex(color) except ValueError: color = "#f0f0f0" # Default to light gray if invalid hex_color = color.lstrip("#") # Handle both 6-char (#RRGGBB) and 8-char (#RRGGBBAA) hex strings if len(hex_color) == 8: # Strip alpha channel hex_color = hex_color[:6] rgb_array = np.array([int(hex_color[i : i + 2], 16) for i in (0, 2, 4)]) elif isinstance(color, (list, tuple)): # Python list or tuple -> convert to numpy array rgb_array = np.array(color) # Strip alpha channel if present (4th component) if len(rgb_array) == 4: rgb_array = rgb_array[:3] else: # Already numpy array rgb_array = color # Strip alpha channel if present (4th component) if rgb_array.shape == (4,): rgb_array = rgb_array[:3] # Process based on output format if output_format == "hex": # Convert to hex format if np.issubdtype(rgb_array.dtype, np.integer): # If integer type, assume 0-255 range and normalize rgb_array = rgb_array / 255.0 result.append(to_hex(rgb_array).lower()) elif output_format == "rgbnorm": # Convert to normalized RGB (0-1) if np.issubdtype(rgb_array.dtype, np.integer): range_type = detect_rgb_range(rgb_array) if range_type == "0-255": rgb_array = rgb_array / 255.0 result.append(rgb_array.astype(np.float64)) else: # output_format == "rgb" # Convert to RGB (0-255) if np.issubdtype(rgb_array.dtype, np.floating): range_type = detect_rgb_range(rgb_array) if range_type == "0-1": rgb_array = rgb_array * 255 result.append(rgb_array.astype(np.uint8)) # Stack results if not hex format if output_format != "hex": result = np.vstack(result) return result
####################################################################################################
[docs] def readjust_colors( colors: Union[List[Union[str, list, np.ndarray]], np.ndarray], output_format: str = "rgb", ) -> Union[list[str], np.ndarray]: """ Function to readjust the colors to a certain format. It is just a wrapper from harmonize_colors function. Parameters ---------- colors : list or numpy array List of colors Returns ------- out_colors: list or numpy array List of colors in the desired format Examples -------------- >>> colors = ["#FF5733", [255, 87, 51], np.array([51, 87, 255])] >>> out_colors = readjust_colors(colors, output_format='hex') >>> print(out_colors) # Output: ['#ff5733', '#ff5733', '#3357ff'] >>> out_colors = readjust_colors(colors, output_format='rgb') >>> print(out_colors) # Output: [[255, 87, 51], [255, 87, 51], [51, 87, 255]] """ output_format = output_format.lower() if output_format not in ["hex", "rgb", "rgbnorm"]: raise ValueError("output_format must be 'hex', 'rgb', or 'rgbnorm'") # harmonizing the colors out_colors = harmonize_colors(colors, output_format=output_format) return out_colors
####################################################################################################
[docs] def create_random_colors( n: int, output_format: str = "rgb", cmap: Optional[str] = None, random_seed: Optional[int] = None, ) -> Union[list[str], np.ndarray]: """ Generate n colors either randomly or from a specified matplotlib colormap. This function creates a collection of colors that can be used for data visualization, plotting, or other applications requiring distinct color schemes. Colors can be generated randomly or sampled from matplotlib colormaps for better visual harmony. Parameters ---------- n : int Number of colors to generate. Must be a positive integer. output_format : str, default "rgb" Format of the output colors. Supported formats: - "rgb": RGB values as integers in range [0, 255] - "rgbnorm": RGB values as floats in range [0.0, 1.0] - "hex": Hexadecimal color strings (e.g., "#FF5733") cmap : str or None, default None Name of matplotlib colormap to use for color generation. If None, colors are generated randomly. Popular options include: - "viridis", "plasma", "inferno", "magma" (perceptually uniform) - "PiYG", "RdYlBu", "Spectral" (diverging) - "Set1", "Set2", "tab10" (qualitative) - "Blues", "Reds", "Greens" (sequential) See matplotlib.pyplot.colormaps() for full list. random_seed : int or None, default None Seed for random number generator to ensure reproducible results. Only used when cmap is None. Returns ------- colors : list of str or numpy.ndarray Generated colors in the specified format: - If output_format is "hex": list of hex color strings - If output_format is "rgb" or "rgbnorm": numpy array of shape (n, 3) Raises ------ ValueError If output_format is not one of the supported formats. If n is not a positive integer. If cmap is not a valid matplotlib colormap name. TypeError If n is not an integer. Examples -------- Generate random colors: >>> colors = create_random_colors(3, output_format="hex") >>> print(colors) # ['#A1B2C3', '#D4E5F6', '#789ABC'] >>> colors = create_random_colors(3, output_format="rgb") >>> print(colors) # [[161, 178, 195], [212, 229, 246], [120, 154, 188]] Generate colors from a colormap: >>> colors = create_random_colors(5, output_format="hex", cmap="PiYG") >>> print(colors) # ['#8E0152', '#C994C7', '#F7F7F7', '#A1DAB4', '#276419'] >>> colors = create_random_colors(4, output_format="rgbnorm", cmap="viridis") >>> print(colors) # [[0.267, 0.005, 0.329], [0.229, 0.322, 0.545], ...] Notes ----- - When using a colormap, colors are evenly spaced across the colormap range - Random colors are generated uniformly across RGB space and may not be visually harmonious - For better visual results with random colors, consider using the harmonize_colors() function (if available) - Colormaps provide better perceptual uniformity and accessibility """ # Input validation if not isinstance(n, int): raise TypeError("n must be an integer") if n <= 0: raise ValueError("n must be a positive integer") output_format = output_format.lower() if output_format not in ["hex", "rgb", "rgbnorm"]: raise ValueError("output_format must be 'hex', 'rgb', or 'rgbnorm'") # Set random seed if provided if random_seed is not None: np.random.seed(random_seed) if cmap is not None: # Generate colors from colormap try: colormap = plt.get_cmap(cmap) except ValueError: raise ValueError( f"'{cmap}' is not a valid matplotlib colormap name. " f"Use plt.colormaps() to see available options." ) # Generate evenly spaced points across the colormap if n == 1: indices = [0.5] # Use middle of colormap for single color else: indices = np.linspace(0, 1, n) # Get colors from colormap (returns RGBA, we take only RGB) colors_norm = np.array([colormap(idx)[:3] for idx in indices]) if output_format == "rgbnorm": return colors_norm elif output_format == "rgb": return (colors_norm * 255).astype(int) else: # hex return [rgb2hex(color[0], color[1], color[2]) for color in colors_norm] else: # Generate random colors colors = np.random.randint(0, 255, size=(n, 3)) # Apply harmonization if the function is available try: colors = harmonize_colors(colors, output_format=output_format) return colors except NameError: # harmonize_colors function not available, proceed without harmonization pass if output_format == "rgb": return colors elif output_format == "rgbnorm": return colors / 255.0 else: # hex return ["#{:02x}{:02x}{:02x}".format(r, g, b) for r, g, b in colors]
#####################################################################################################
[docs] def get_colormaps_names(n, cmap_type="sequential"): """ Get a list of colormap names from matplotlib. If n exceeds available colormaps, the list repeats. It can return either sequential or diverging colormaps. Parameters ---------- n : int Number of colormaps to return cmap_type : str, optional Type of colormaps: "sequential" or "diverging" (default: "sequential") Returns ------- list List of colormap names (repeats if n exceeds available colormaps) Examples -------- >>> get_colormaps_names(5, cmap_type="sequential") ['viridis', 'plasma', 'inferno', 'magma', 'cividis'] >>> get_colormaps_names(3, cmap_type="diverging") ['PiYG', 'PRGn', 'BrBG'] Notes ----- - Uses matplotlib's built-in colormaps - If n exceeds available colormaps, the list repeats to fulfill the request """ # Predefined lists of colormaps by type sequential_cmaps = [ "viridis", "jet", "copper", "hot", "winter", "autumn", "spring", "summer", "bone", "cool", "plasma", "inferno", "magma", "cividis", "Greys", "Purples", "Blues", "Greens", "Oranges", "Reds", "YlOrBr", "YlOrRd", "OrRd", "PuRd", "RdPu", "BuPu", "GnBu", "PuBu", "YlGnBu", "PuBuGn", "BuGn", "YlGn", ] diverging_cmaps = [ "PiYG", "PRGn", "BrBG", "PuOr", "RdGy", "RdBu", "RdYlBu", "RdYlGn", "Spectral", "coolwarm", "bwr", "seismic", ] # Select the appropriate list if cmap_type == "sequential": cmap_list = sequential_cmaps elif cmap_type == "diverging": cmap_list = diverging_cmaps else: raise ValueError( f"cmap_type must be 'sequential' or 'diverging', got '{cmap_type}'" ) # If n exceeds available colormaps, repeat them if n > len(cmap_list): repeats = (n // len(cmap_list)) + 1 cmap_list = cmap_list * repeats return cmap_list[:n]
#########################################################################################################
[docs] def create_lut_dictionary(parc_values: Union[List[int], np.ndarray]) -> dict: """ Create a lookup table (LUT) dictionary mapping parcel values to colors. Parameters ---------- parc_values : List[int] List of integer parcel values. Returns ------- dict Dictionary with keys: - "index": List of parcel IDs (excluding background id 0) - "name": List of region names (e.g., "Region_1", "Region_2", ...) - "color": List of hex color codes corresponding to each parcel ID Examples -------- >>> parc_values = [0, 1, 2, 3, 4] >>> lut_dict = create_lut_dictionary(parc_values) >>> print(lut_dict) {'index': [1, 2, 3, 4], 'name': ['Region_1', 'Region_2', 'Region_3', 'Region_4'], 'color': ['#e6194b', '#3cb44b', '#ffe119', '#0082c8']} """ # Remove the background id (0) sts_ids = np.array(parc_values) sts_ids = sts_ids[sts_ids != 0] sts_ids = sts_ids.astype(int).tolist() sts_names = [f"Region_{id}" for id in sts_ids] sts_colors = create_distinguishable_colors(len(sts_ids), output_format="hex") lut_dict = {} lut_dict["index"] = sts_ids lut_dict["name"] = sts_names lut_dict["color"] = sts_colors return lut_dict
###################################################################################################
[docs] def create_distinguishable_colors( n: int, output_format: str = "rgb", exclude_colors: Optional[list] = None, lightness_range: tuple[float, float] = (0.4, 0.85), saturation_range: tuple[float, float] = (0.5, 1.0), random_seed: Optional[int] = None, ) -> Union[list[str], np.ndarray]: """ Generate n maximally distinguishable colors using perceptual color spacing. This function creates colors that are as visually distinct as possible from each other, making them ideal for categorical data visualization, plots with many categories, or any application requiring easily distinguishable colors. The algorithm uses HSV color space to distribute colors evenly across the hue spectrum while maintaining good saturation and lightness values for visibility. For very small sets (n ≤ 10), it can optionally use predefined maximally distinct color sets based on color theory research. Parameters ---------- n : int Number of colors to generate. Must be a positive integer. output_format : str, default "rgb" Format of the output colors. Supported formats: - "rgb": RGB values as integers in range [0, 255] - "rgbnorm": RGB values as floats in range [0.0, 1.0] - "hex": Hexadecimal color strings (e.g., "#FF5733") exclude_colors : list of str or list of tuples, optional Colors to avoid when generating the palette. Can be hex strings or RGB tuples. The algorithm will try to maximize distance from these colors. lightness_range : tuple of float, default (0.4, 0.85) Range of lightness (V in HSV) to use, as (min, max) in [0, 1]. Values closer to 0 are darker, closer to 1 are lighter. Default range avoids very dark and very light colors for better visibility. saturation_range : tuple of float, default (0.5, 1.0) Range of saturation (S in HSV) to use, as (min, max) in [0, 1]. Values closer to 0 are more gray, closer to 1 are more vivid. Default range ensures colors are vibrant and easily distinguishable. random_seed : int or None, default None Seed for random number generator for reproducible saturation/lightness variations. If None, variations will be non-deterministic. Returns ------- colors : list of str or numpy.ndarray Generated colors in the specified format: - If output_format is "hex": list of hex color strings - If output_format is "rgb" or "rgbnorm": numpy array of shape (n, 3) Raises ------ ValueError If output_format is not one of the supported formats. If n is not a positive integer. If lightness_range or saturation_range values are not in [0, 1]. TypeError If n is not an integer. Examples -------- Generate distinguishable colors for a categorical plot: >>> colors = create_distinguishable_colors(5, output_format="hex") >>> print(colors) ['#E63946', '#06FFA5', '#3A86FF', '#FFBE0B', '#8338EC'] >>> colors = create_distinguishable_colors(8, output_format="rgb") >>> print(colors.shape) (8, 3) Generate colors with custom lightness for dark backgrounds: >>> colors = create_distinguishable_colors( ... 6, ... output_format="hex", ... lightness_range=(0.6, 0.95), ... saturation_range=(0.7, 1.0) ... ) Exclude specific colors (e.g., avoid red): >>> colors = create_distinguishable_colors( ... 4, ... output_format="hex", ... exclude_colors=["#FF0000", "#CC0000"] ... ) Notes ----- - Colors are distributed evenly across the hue spectrum (360 degrees) - Saturation and lightness are varied slightly to increase distinctiveness - The algorithm prioritizes perceptual difference over aesthetic harmony - For small sets (n ≤ 6), consider also trying matplotlib's "tab10" colormap with create_random_colors(n, cmap="tab10") for comparison - Maximum recommended n is around 20-30 for truly distinguishable colors; beyond that, some colors will inevitably appear similar See Also -------- create_random_colors : Generate random or colormap-based colors matplotlib.colors.rgb_to_hsv : Convert RGB to HSV color space """ # Input validation if not isinstance(n, int): raise TypeError("n must be an integer") if n <= 0: raise ValueError("n must be a positive integer") output_format = output_format.lower() if output_format not in ["hex", "rgb", "rgbnorm"]: raise ValueError("output_format must be 'hex', 'rgb', or 'rgbnorm'") if not (0 <= lightness_range[0] <= lightness_range[1] <= 1): raise ValueError("lightness_range values must be in [0, 1] with min <= max") if not (0 <= saturation_range[0] <= saturation_range[1] <= 1): raise ValueError("saturation_range values must be in [0, 1] with min <= max") # Set random seed if provided if random_seed is not None: np.random.seed(random_seed) # Generate evenly spaced hues hues = np.linspace(0, 1, n, endpoint=False) # Add a small random offset to starting hue for variety (deterministic if seed is set) hue_offset = np.random.uniform(0, 1 / n) if n > 1 else 0 hues = (hues + hue_offset) % 1.0 # Generate varied saturation and lightness values # Alternate between high and low values for adjacent colors to maximize difference saturations = np.zeros(n) lightnesses = np.zeros(n) for i in range(n): # Alternate patterns for better distinction between adjacent colors if i % 2 == 0: saturations[i] = np.random.uniform( saturation_range[0] + 0.4 * (saturation_range[1] - saturation_range[0]), saturation_range[1], ) lightnesses[i] = np.random.uniform( lightness_range[0] + 0.3 * (lightness_range[1] - lightness_range[0]), lightness_range[1], ) else: saturations[i] = np.random.uniform( saturation_range[0], saturation_range[0] + 0.6 * (saturation_range[1] - saturation_range[0]), ) lightnesses[i] = np.random.uniform( lightness_range[0], lightness_range[0] + 0.7 * (lightness_range[1] - lightness_range[0]), ) # Create HSV colors hsv_colors = np.column_stack([hues, saturations, lightnesses]) # Convert to RGB (normalized [0, 1]) rgb_colors_norm = np.array([hsv_to_rgb(hsv) for hsv in hsv_colors]) # Handle excluded colors if provided if exclude_colors is not None: # This is a placeholder for more sophisticated exclusion logic # In a full implementation, you might adjust colors that are too close # to excluded colors pass # Convert to requested output format if output_format == "rgbnorm": return rgb_colors_norm elif output_format == "rgb": return (rgb_colors_norm * 255).astype(int) else: # hex hex_colors = [] for color in rgb_colors_norm: r, g, b = (color * 255).astype(int) hex_colors.append("#{:02X}{:02X}{:02X}".format(r, g, b)) return hex_colors
####################################################################################################
[docs] def get_predefined_distinguishable_colors( n: int, output_format: str = "rgb" ) -> Union[list[str], np.ndarray]: """ Get a predefined set of maximally distinguishable colors. This function returns carefully selected colors that are known to be highly distinguishable based on color theory research. Available for up to 20 colors. Parameters ---------- n : int Number of colors (must be between 1 and 20). output_format : str, default "rgb" Format of the output: "rgb", "rgbnorm", or "hex". Returns ------- colors : list or numpy.ndarray The predefined distinguishable colors. Notes ----- Based on Kenneth Kelly's 22 colors of maximum contrast, optimized for both color-normal and color-blind viewers. """ # Kelly's 22 colors of maximum contrast (excluding white and black) kelly_colors_hex = [ "#F3C300", # Vivid Yellow "#875692", # Strong Purple "#F38400", # Vivid Orange "#A1CAF1", # Very Light Blue "#BE0032", # Vivid Red "#C2B280", # Grayish Yellow "#848482", # Medium Gray "#008856", # Vivid Green "#E68FAC", # Strong Purplish Pink "#0067A5", # Strong Blue "#F99379", # Strong Yellowish Pink "#604E97", # Strong Violet "#F6A600", # Vivid Orange Yellow "#B3446C", # Strong Purplish Red "#DCD300", # Vivid Greenish Yellow "#882D17", # Strong Reddish Brown "#8DB600", # Vivid Yellowish Green "#654522", # Deep Yellowish Brown "#E25822", # Vivid Reddish Orange "#2B3D26", # Dark Olive Green ] if n > len(kelly_colors_hex): raise ValueError(f"Only {len(kelly_colors_hex)} predefined colors available") selected_colors = kelly_colors_hex[:n] if output_format == "hex": return selected_colors # Convert hex to RGB rgb_colors = multi_hex2rgb(selected_colors) rgb_array = np.array(rgb_colors) if output_format.lower() == "rgb": return rgb_array else: # rgbnorm return rgb_array / 255.0
###################################################################################################
[docs] def colortable_visualization( colortable: Union[np.ndarray, str, Path], region_names: Union[str, List[str]] = None, columns: int = 2, export_path: str = None, title: str = "Color Table", alternating_bg: bool = False, ): """ Color table visualization. Generates a PNG image displaying a FreeSurfer-style color table. Parameters ---------- colortable : array-like, str, or Path Can be one of: - Array-like with shape (N, 3), (N, 4), or (N, 5): [R, G, B] or [R, G, B, Alpha] or [R, G, B, Alpha, Value] - String or Path to a LUT file (.txt, .lut) or TSV file (.tsv) region_names : str or list of str, optional Region names corresponding to each row. If None and loading from file, uses the names from the file. Default is None. columns : int, default=2 Number of columns in layout. export_path : str, optional Path to save PNG file. title : str, default="Color Table" Title displayed at the top. alternating_bg : bool, default=False Whether to shade alternating rows for readability. Returns ------- fig : matplotlib.figure.Figure Matplotlib figure object. Raises ------ ValueError If colortable shape is invalid or region_names length mismatch. TypeError If region_names is not a string or a list of strings. FileNotFoundError If the specified file path does not exist. Examples -------- >>> # Example 1: Using an array >>> colortable = [[255, 0, 0], [0, 255, 0], [0, 0, 255]] >>> region_names = ["Region 1", "Region 2", "Region 3"] >>> fig = colortable_visualization(colortable, region_names, ... columns=1, title="My Color Table") >>> plt.show() >>> # Example 2: Loading from a LUT file >>> fig = colortable_visualization('FreeSurferColorLUT.txt', ... columns=2, title="FreeSurfer Regions") >>> plt.show() >>> # Example 3: Loading from file with custom names >>> custom_names = ["Custom 1", "Custom 2", "Custom 3"] >>> fig = colortable_visualization('regions.tsv', ... region_names=custom_names, columns=1) >>> plt.show() """ # Handle file path input if isinstance(colortable, (str, Path)): file_path = Path(colortable) # Check if file exists if not file_path.exists(): raise FileNotFoundError(f"Color table file not found: {file_path}") # Load the color table from file ctab_dict = ColorTableLoader.load_colortable(str(file_path)) # Extract region names from file if not provided if region_names is None: region_names = ctab_dict["name"] # Extract colors and convert to RGB format colors = ctab_dict["color"] colors = harmonize_colors(colors, output_format="rgb") # Extract opacity if available if "opacity" in ctab_dict and ctab_dict["opacity"] is not None: opacities = np.array(ctab_dict["opacity"]) # Convert opacity from 0-1 to 0-255 range opacities = (opacities * 255).astype(int) else: opacities = np.full(len(colors), 255, dtype=int) # Build colortable array with RGB and opacity colortable_array = np.column_stack([colors, opacities]) # Add index column if available if "index" in ctab_dict: indices = np.array(ctab_dict["index"]).reshape(-1, 1) colortable_array = np.column_stack([colortable_array, indices]) colortable = colortable_array # Now process as array colortable = np.array(colortable, dtype=float) # Validate region_names is provided for array input if region_names is None: raise ValueError( "region_names must be provided when colortable is an array. " "To auto-extract names, provide a file path instead." ) n_regions = len(region_names) # Validate colortable shape if colortable.ndim != 2 or colortable.shape[1] not in [3, 4, 5]: raise ValueError("colortable must be a 2D array with 3, 4, or 5 columns") if colortable.shape[0] != n_regions: raise ValueError( f"Length of region_names ({n_regions}) must match number of rows in colortable ({colortable.shape[0]})" ) if not isinstance(region_names, (str, list)): raise TypeError("region_names must be a string or a list of strings") if isinstance(region_names, str): region_names = [region_names] elif isinstance(region_names, list): # Validate all elements are strings if not all(isinstance(name, str) for name in region_names): raise TypeError("All elements in region_names list must be strings") colors = colortable[:, 0:3] colors = harmonize_colors(colors, output_format="rgb") colortable[:, 0:3] = colors # Layout rows_per_col = int(np.ceil(n_regions / columns)) rect_width = 0.5 rect_height = 0.35 row_spacing = 0.5 col_spacing = 5.5 margin_left, margin_right, margin_top, margin_bottom = 0.5, 1.0, 1.2, 0.6 fig_width = margin_left + columns * col_spacing + margin_right fig_height = margin_bottom + rows_per_col * (rect_height + row_spacing) + margin_top # Create figure fig, ax = plt.subplots(figsize=(fig_width, fig_height), facecolor="white") ax.set_xlim(0, fig_width) ax.set_ylim(0, fig_height) ax.axis("off") # Title ax.text( fig_width / 2, fig_height - 0.5, title, ha="center", va="center", fontsize=15, fontweight="bold", ) # Draw rows for i in range(n_regions): col = i // rows_per_col row = i % rows_per_col x = margin_left + col * col_spacing y = ( fig_height - margin_top - (row + 1) * (rect_height + row_spacing) + row_spacing ) # Background shading for readability if alternating_bg and row % 2 == 1: ax.add_patch( patches.Rectangle( (x - 0.2, y - 0.1), col_spacing - 0.3, rect_height + 0.2, facecolor="#efecec", edgecolor="none", zorder=0, ) ) elif alternating_bg and row % 2 == 0: ax.add_patch( patches.Rectangle( (x - 0.2, y - 0.1), col_spacing - 0.3, rect_height + 0.2, facecolor="#c8c8c8", edgecolor="none", zorder=0, ) ) # Get RGBA r, g, b = colortable[i, 0:3] / 255.0 a = colortable[i, 3] / 255.0 if colortable.shape[1] >= 4 else 1.0 # Color rectangle ax.add_patch( patches.Rectangle( (x, y), rect_width, rect_height, facecolor=(r, g, b, a), edgecolor="#444444", linewidth=0.8, ) ) # Text if colortable.shape[1] == 5: value = int(colortable[i, 4]) rgb_label = f"#{value:02d} ({int(colortable[i,0])}, {int(colortable[i,1])}, {int(colortable[i,2])})" else: rgb_label = f"({int(colortable[i,0])}, {int(colortable[i,1])}, {int(colortable[i,2])})" # Wrap long names if needed name = textwrap.fill(region_names[i], width=30) label = f"{rgb_label} {name}" ax.text( x + rect_width + 0.2, y + rect_height / 2, label, ha="left", va="center", fontsize=10, fontfamily="monospace", ) plt.subplots_adjust(left=0, right=1, top=1, bottom=0) if export_path: plt.savefig( export_path, dpi=300, bbox_inches="tight", facecolor="white", pad_inches=0.15, ) print(f"Saved: {export_path}") return fig
#####################################################################################################
[docs] def get_colors_from_colortable( labels: np.ndarray, reg_ctable: np.ndarray ) -> np.ndarray: """ Create per-vertex RGBA colors based on parcellation labels. Assigns colors to vertices based on their parcellation region using the color table information. Parameters ---------- labels : np.ndarray Array of parcellation labels for each vertex. reg_ctable : np.ndarray Color table with shape (N, 5) where first 3 columns are RGB values and column 4 contains region labels. Returns ------- colors : np.ndarray Array of RGB colors for each vertex with shape (num_vertices, 3). Default color is gray (240, 240, 240) for unlabeled vertices. Examples -------- >>> # Create vertex colors for visualization over a surface mesh >>> colors = get_colors_from_colortable(vertex_labels, color_table) >>> print(f"Colors shape: {colors.shape}") # (num_vertices, 3) """ # Automatically detect the range of the colors in reg_ctable if reg_ctable.shape[1] != 5: raise ValueError( "The color table must have 5 columns: R, G, B, A, and packed RGB value" ) # Get the colors from the first 3 columns # This assumes the first 3 columns are RGB values colors_ctable = reg_ctable[:, :3].astype(np.uint8) # Check if all the colors are in the range 0-255 if not ((colors_ctable.min() >= 0.0) and (colors_ctable.max() <= 1.0)): colors = np.ones((len(labels), 3), dtype=np.uint8) * 240 # Default gray colors = np.append(colors, np.zeros((len(labels), 1), dtype=np.uint8), axis=1) else: colors = np.ones((len(labels), 3), dtype=np.uint8) * 240 / 255 # Default gray colors = np.append(colors, np.ones((len(labels), 1), dtype=np.uint8), axis=1) for i, region_info in enumerate(reg_ctable): # Find vertices with this label indices = np.where(labels == region_info[4])[0] # Assign the region color (RGB from first 3 columns) if len(indices) > 0: colors[indices, :4] = region_info[:4] return colors
#####################################################################################################
[docs] def values2colors( values: Union[List[Union[int, float]], np.ndarray], cmap: str = "viridis", output_format: str = "hex", invert_cl: bool = False, invert_clmap: bool = False, vmin: Optional[float] = None, vmax: Optional[float] = None, range_min: Optional[float] = None, range_max: Optional[float] = None, range_color: tuple = (200, 200, 200), ) -> Union[List[str], np.ndarray]: """ Map numerical values to colors using a specified colormap with optional inversions. This function takes a list or array of numerical values and maps them to colors using matplotlib colormaps. It provides options to invert the colormap gradient and/or invert the resulting colors to their complements. Parameters ---------- values : list or numpy.ndarray Numerical values to map to colors. Can be integers or floats. cmap : str, default "viridis" Name of matplotlib colormap to use for color generation. output_format : str, default "hex" Format of the output colors. Supported formats: - "hex": Hexadecimal color strings (e.g., "#FF5733") - "rgb": RGB values as integers in range [0, 255] - "rgbnorm": RGB values as floats in range [0.0, 1.0] invert_cl : bool, default False If True, return the complementary colors instead of the original ones. invert_clmap : bool, default False If True, invert the gradient of the colormap before mapping values. vmin : float or None, default None Minimum value for colormap normalization. If None, uses range_min if provided, otherwise uses min of in-range values. vmax : float or None, default None Maximum value for colormap normalization. If None, uses range_max if provided, otherwise uses max of in-range values. range_min : float or None, default None Minimum threshold for values. Values below this will be set to range_color. range_max : float or None, default None Maximum threshold for values. Values above this will be set to range_color. range_color : tuple, default (200, 200, 200) Color to assign to out-of-range values, in RGB or RGBA format. Returns ------- all_colors : list of str or numpy.ndarray Mapped colors in the specified format. Raises ------ ValueError If output_format is not supported or cmap is invalid. TypeError If values is not a list or numpy array. """ # Input validation if not isinstance(values, (list, np.ndarray)): raise TypeError("values must be a list or numpy array") values = np.array(values, dtype=float) if values.size == 0: raise ValueError("values array cannot be empty") # Check the range_color format try: range_color = harmonize_colors(range_color, output_format="rgb") except Exception: raise ValueError("range_color must be a value convertible to RGB format") if isinstance(range_color, list): range_color = range_color[0] if isinstance(range_color, np.ndarray): # Convert to a numpy array of Nx3 range_color = range_color.squeeze() output_format = output_format.lower() if output_format not in ["hex", "rgb", "rgbnorm"]: raise ValueError("output_format must be 'hex', 'rgb', or 'rgbnorm'") # Create mask for out-of-range values mask = np.zeros(len(values), dtype=bool) if range_min is not None: mask |= values < range_min if range_max is not None: mask |= values > range_max # Get values within range values_4_colors = values[~mask] # Get the matplotlib colormap try: colormap = plt.get_cmap(cmap) except ValueError: raise ValueError( f"'{cmap}' is not a valid matplotlib colormap name. " f"Use plt.colormaps() to see available options." ) # Invert colormap if requested if invert_clmap: colormap = colormap.reversed() # Set vmin and vmax for normalization # Priority: explicit vmin/vmax > range_min/range_max > min/max of in-range values # Set vmin and vmax for normalization # Priority: explicit vmin/vmax > min/max of in-range values if vmin is None: if len(values_4_colors) > 0: vmin = np.nanmin(values_4_colors) else: vmin = 0.0 if vmax is None: if len(values_4_colors) > 0: vmax = np.nanmax(values_4_colors) else: vmax = 1.0 # Handle edge cases if not np.isfinite(vmin): vmin = 0.0 if not np.isfinite(vmax): vmax = 1.0 if vmax == vmin: # All values are the same, map to middle of colormap normalized_values = np.full_like(values_4_colors, 0.5, dtype=float) else: # Normalize values to [0, 1] range using vmin/vmax normalized_values = (values_4_colors - vmin) / (vmax - vmin) # Clip values to [0, 1] range normalized_values = np.clip(normalized_values, 0, 1) # Handle NaN values - map to a neutral color (middle of colormap) nan_mask = ~np.isfinite(values_4_colors) normalized_values[nan_mask] = 0.5 # Map normalized values to colors using the continuous colormap mapped_colors = colormap(normalized_values) # Returns RGBA values in [0,1] mapped_colors = np.squeeze(mapped_colors) # Convert to 0-255 range mapped_colors = (mapped_colors * 255).astype(np.uint8) # Remove alpha channel if present (take only RGB) if mapped_colors.ndim > 1 and mapped_colors.shape[-1] == 4: mapped_colors = mapped_colors[..., :3] elif mapped_colors.ndim == 1 and len(mapped_colors) == 4: mapped_colors = mapped_colors[:3] # Prepare range_color in RGB format range_color_rgb = np.array(range_color[:3], dtype=np.uint8) # Initialize output array with range_color and assign mapped colors to in-range values if len(values_4_colors) == 0: # All values are out of range if output_format == "rgb": all_colors = np.tile(range_color_rgb, (len(values), 1)) elif output_format == "rgbnorm": all_colors = np.tile(range_color_rgb / 255.0, (len(values), 1)) else: # hex range_hex = f"#{range_color_rgb[0]:02x}{range_color_rgb[1]:02x}{range_color_rgb[2]:02x}" all_colors = [range_hex] * len(values) else: if output_format == "rgb": all_colors = np.tile(range_color_rgb, (len(values), 1)) all_colors[~mask] = mapped_colors elif output_format == "rgbnorm": all_colors = np.tile(range_color_rgb / 255.0, (len(values), 1)) all_colors[~mask] = mapped_colors / 255.0 else: # hex range_hex = f"#{range_color_rgb[0]:02x}{range_color_rgb[1]:02x}{range_color_rgb[2]:02x}" all_colors = [range_hex] * len(values) # Assign hex colors for in-range values in_range_indices = np.where(~mask)[0] if mapped_colors.ndim == 1: # Single color for single value r, g, b = mapped_colors[:3] all_colors[in_range_indices[0]] = ( f"#{int(r):02x}{int(g):02x}{int(b):02x}" ) else: # Multiple colors for i, idx in enumerate(in_range_indices): r, g, b = mapped_colors[i] all_colors[idx] = f"#{int(r):02x}{int(g):02x}{int(b):02x}" result_colors = all_colors # Apply color inversion if requested if invert_cl: if output_format == "hex": # For hex format, convert to RGB, invert, then back to hex rgb_colors = np.array( [ [int(color[1:3], 16), int(color[3:5], 16), int(color[5:7], 16)] for color in result_colors ] ) inverted_rgb = 255 - rgb_colors result_colors = [f"#{r:02x}{g:02x}{b:02x}" for r, g, b in inverted_rgb] else: # For rgb and rgbnorm formats if output_format == "rgb": result_colors = 255 - result_colors else: # rgbnorm result_colors = 1.0 - result_colors return result_colors
#####################################################################################################
[docs] def colors_to_table( colors: Union[list, np.ndarray], alpha_values: np.ndarray = 0, values: np.ndarray = None, ) -> np.ndarray: """ Convert color list to a color table. The color table will contain RGB values, alpha channel, and values or packed RGB values. This function harmonizes the input colors to RGB format, applies alpha values, and generates a color table with the specified values. It supports both hexadecimal color strings and RGB arrays. If values are not provided, it will generate a default packed RGB value for each color. If only the colors are provided, the function will create a color table with the RGB values, an alpha channel set to 0, and default packed RGB values. This structure is useful for creating a color table that can be used in FreeSurfer. Parameters ---------- colors : list or np.ndarray List of hexadecimal color strings (e.g., ['#FF0000', '#00FF00']) or numpy array of RGB values. It can be also a list of mixture of hexadecimal strings and RGB arrays. alpha_values : np.ndarray Array of alpha values for each color. If a single value is provided, it will be applied to all colors. values : np.ndarray, optional Array of values corresponding to each color. If a single value is provided, Returns ------- color_table : np.ndarray Color table with shape (N, 5) containing RGB values, alpha channel, and values or packed RGB values. Raises ------ ValueError If colors is not a list or numpy array. Examples -------- >>> # Convert hex colors to color table >>> hex_colors = ["#FF0000", "#00FF00", "#0000FF"] >>> ctab = colors2colortable(hex_colors) >>> print(f"Color table shape: {ctab.shape}") """ if not isinstance(colors, (list, np.ndarray)): raise ValueError("The colors must be a list or a numpy array") colors = harmonize_colors(colors, output_format="rgb") # If values is None if values is None: values = np.zeros(np.shape(colors)[0], dtype=int) for i, color in enumerate(colors): values[i] = int(color[0]) + int(color[1]) * 2**8 + int(color[2]) * 2**16 if hasattr(values, "__len__"): values_len = len(values) else: values_len = 1 if values_len != np.shape(colors)[0]: raise ValueError( "The number of values must match the number of colors provided or a single value." ) if hasattr(alpha_values, "__len__"): alpha_len = len(alpha_values) else: alpha_len = 1 if alpha_len != np.shape(colors)[0]: if alpha_len != 1: raise ValueError( "The number of alpha values must match the number of colors provided or a single value." ) else: if alpha_len == 1: alpha_values = np.ones(np.shape(colors)[0]) * alpha_values else: alpha_values = np.ones(np.shape(colors)[0]) * alpha_values[0] # Concatenate RGB values and alpha channel and values color_table = np.column_stack( ( colors, alpha_values, values, ) ) return color_table
###################################################################################################
[docs] def visualize_colors( colors: Union[List[Union[str, list, np.ndarray]], np.ndarray], figsize: tuple = (10, 1), label_position: str = "below", # or "above" label_rotation: int = 45, label_size: Optional[float] = None, spacing: float = 0.1, aspect_ratio: float = 0.1, background_color: str = "white", edge_color: Optional[str] = None, ) -> None: """ Visualize a list of color codes in a clean, professional layout with configurable display options. Parameters ---------- colors : List[str] List of hexadecimal color codes to visualize (e.g., ['#FF5733', '#33FF57']) figsize : tuple, optional Size of the figure in inches (width, height), by default (10, 2) label_position : str, optional Position of color labels relative to color bars ('above' or 'below'), by default "below" label_rotation : int, optional Rotation angle for labels in degrees (0-90), by default 45 label_size : Optional[float], optional Font size for labels. If None, size is automatically determined based on number of colors, by default None spacing : float, optional Additional vertical space for labels (relative to bar height), by default 0.1 aspect_ratio : float, optional Height/width ratio of color rectangles (0.1-1.0 recommended), by default 0.2 background_color : str, optional Background color of the figure, by default "white" edge_color : Optional[str], optional Color for rectangle borders. None means no borders, by default None Returns ------- None Displays a matplotlib figure with the color visualization Raises ------ ValueError If any color code is invalid If label_position is not 'above' or 'below' Examples -------- Basic usage: >>> colors = ['#FF5733', '#33FF57', '#3357FF'] >>> visualize_colors(colors) Customized visualization: >>> visualize_colors( ... colors, ... figsize=(12, 3), ... label_position='above', ... label_rotation=30, ... background_color='#f0f0f0', ... edge_color='black' ... ) Notes ----- - All hex colors will be converted to lowercase for consistency - For large numbers of colors, consider increasing figsize or decreasing label_size - Edge colors can be used to improve visibility against similar backgrounds """ # Convert RGB colors to hex if needed hex_colors = harmonize_colors(colors) # Validate colors for color in hex_colors: if not is_color_like(color): raise ValueError(f"Invalid color code: {color}") num_colors = len(hex_colors) if num_colors == 0: return # Create figure with specified background fig, ax = plt.subplots(figsize=figsize, facecolor=background_color) fig.tight_layout(pad=2) # Calculate dimensions rect_width = 1.0 total_width = num_colors * rect_width rect_height = total_width * aspect_ratio # Automatic label size calculation if not specified if label_size is None: label_size = max(6, min(12, 100 / num_colors)) # Set axis limits (with extra space for labels) y_offset = rect_height + spacing if label_position == "above" else -spacing ax.set_xlim(0, total_width) ax.set_ylim( -spacing if label_position == "below" else 0, rect_height + (spacing if label_position == "above" else 0), ) # Remove axes for clean look ax.axis("off") # Determine edge color if not specified if edge_color is None: edge_color = "black" if background_color != "black" else "white" # Draw each color rectangle and label for i, color in enumerate(hex_colors): x_pos = i * rect_width # Draw the color rectangle (fixed property setting) rect = plt.Rectangle( (x_pos, 0), width=rect_width, height=rect_height, facecolor=color, linewidth=0.5 if edge_color else 0, edgecolor=edge_color, ) ax.add_patch(rect) # Add the label label_y = ( -0.02 * rect_height if label_position == "below" else rect_height + 0.02 * rect_height ) va = "top" if label_position == "below" else "bottom" ax.text( x_pos + rect_width / 2, label_y, color.upper(), ha="center", va=va, rotation=label_rotation, fontsize=label_size, color="black" if background_color != "black" else "white", fontfamily="monospace", ) # Adjust aspect ratio ax.set_aspect("auto") plt.show()
######################################################################################################
[docs] class ColorTableLoader: """Class for loading and managing color lookup tables."""
[docs] def __init__(self, ctab_file: Union[str, Path, dict]): """ Initialize ColorTableLoader by loading a color lookup table from a file. Parameters ---------- ctab_file : str, Path, or dict Path to the color lookup table file (.txt, .lut, or .tsv) or a dictionary containing color table data. Attributes ---------- index : list of int List of integer region codes (standard Python integers) name : list of str List of region name strings color : list List of color codes (format depends on source file) opacity : list of float List of opacity values (0-1) headerlines : list of str List of header lines from the color table file Raises ------ FileNotFoundError If the specified file does not exist ValueError If the file format cannot be determined or is invalid Examples -------- >>> # Load a FreeSurfer LUT file >>> lut_loader = ColorTableLoader('FreeSurferColorLUT.txt') >>> print(lut_loader.index[:3]) [0, 1, 2] >>> print(lut_loader.name[:3]) """ if isinstance(ctab_file, (str, Path)): col_dict = ColorTableLoader.load_colortable( in_file=ctab_file, filter_by_name=None ) elif isinstance(ctab_file, dict): col_dict = copy.deepcopy(ctab_file) # Validate required keys (index amd name) required_keys = ["index"] for key in required_keys: if key not in col_dict: raise ValueError( f"Missing required key '{key}' in color table dictionary" ) if "name" not in col_dict.keys(): col_dict["name"] = [f"Region_{idx}" for idx in col_dict["index"]] if "color" not in col_dict.keys(): col_dict["color"] = create_distinguishable_colors( n=len(col_dict["index"]), output_format="hex" ) if "opacity" not in col_dict.keys(): col_dict["opacity"] = [1.0] * len(col_dict["index"]) if "headerlines" not in col_dict.keys(): col_dict["headerlines"] = [] else: raise ValueError("ctab_file must be a string or a dictionary") # Verify lengths of lists n_entries = len(col_dict["index"]) for key in ["name", "color", "opacity"]: if len(col_dict[key]) != n_entries: raise ValueError( f"Length of '{key}' does not match length of 'index' in color table dictionary" ) self.index = col_dict["index"] self.name = col_dict["name"] self.color = col_dict["color"] self.opacity = col_dict["opacity"] self.headerlines = col_dict["headerlines"]
[docs] @staticmethod def load_colortable( in_file: str, filter_by_name: Union[str, List[str]] = None ) -> dict: """ Automatically detect and load a color lookup table from either LUT or TSV format. This method intelligently determines the file format (FreeSurfer LUT or TSV) and uses the appropriate parser to load the color table data. Detection is based on file extension and/or file content analysis. Parameters ---------- in_file : str Path to the color lookup table file (.txt, .lut, or .tsv) filter_by_name : str or list of str, optional Filter regions by name substring(s). Default is None. Returns ------- dict Dictionary with the following keys: - 'index': List of integer region codes (standard Python integers) - 'name': List of region name strings - 'color': List of color codes (format depends on source file) - Additional keys may be present depending on the file format Examples -------- >>> # Load a FreeSurfer LUT file >>> lut_dict = ColorTableLoader.load_colortable('FreeSurferColorLUT.txt') >>> lut_dict['index'][:3] [0, 1, 2] >>> lut_dict['name'][:3] ['Unknown', 'Left-Cerebral-Exterior', 'Left-Cerebral-White-Matter'] >>> # Load a TSV file >>> tsv_dict = ColorTableLoader.load_colortable('regions.tsv') >>> tsv_dict['index'][:3] [0, 1, 2] >>> # Load with filtering >>> hippo_dict = ColorTableLoader.load_colortable( ... 'FreeSurferColorLUT.txt', ... filter_by_name='hippocampus' ... ) Raises ------ FileNotFoundError If the specified file does not exist ValueError If the file format cannot be determined or is invalid Notes ----- - File format detection uses both extension and content analysis - .tsv files are assumed to be TSV format - .txt and .lut files are analyzed to determine if they are LUT or TSV format - LUT format is identified by comment lines starting with '#' - TSV format is identified by tab-separated columns with headers """ # Check if file exists if not os.path.exists(in_file): raise FileNotFoundError(f"Color table file not found: {in_file}") # Get file extension file_ext = os.path.splitext(in_file)[1].lower() # Detect file format file_format = ColorTableLoader._detect_format(in_file, file_ext) # Load the file using the appropriate method if file_format == "lut": colors_dict = ColorTableLoader.read_luttable( in_file, filter_by_name=filter_by_name ) elif file_format == "tsv": colors_dict = ColorTableLoader.read_tsvtable( in_file, filter_by_name=filter_by_name ) else: raise ValueError(f"Could not determine file format for: {in_file}") # Force opacity values equal to 0 to be 255. This is because most # of the neuroimaging software interpret 0 opacity as fully opaque. # colors_dict["opacity"] = [ 255 if op == 0 else op for op in colors_dict["opacity"] ] # Force opacity values to be between 0 and 1 colors_dict["opacity"] = [min(max(op, 0), 1) for op in colors_dict["opacity"]] return colors_dict
@staticmethod def _detect_format(in_file: str, file_ext: str) -> str: """ Detect the format of a color table file. Parameters ---------- in_file : str Path to the file file_ext : str File extension (lowercase) Returns ------- str Either 'lut' or 'tsv' Raises ------ ValueError If file extension is unsupported or file is empty/invalid """ if file_ext == ".tsv": return "tsv" if file_ext not in [".txt", ".lut", ""]: raise ValueError( f"Unsupported file extension: {file_ext}. Expected .txt, .lut, or .tsv" ) # Analyze content for .txt, .lut, or extensionless files try: with open(in_file, "r", encoding="utf-8") as f: lines_to_check = [line.strip() for line in f if line.strip()] except UnicodeDecodeError: with open(in_file, "r") as f: lines_to_check = [line.strip() for line in f if line.strip()] if not lines_to_check: raise ValueError(f"File is empty: {in_file}") # Check for format indicators has_hash_comments = any(line.startswith("#") for line in lines_to_check) # Find first non-comment line first_non_comment = None for line in lines_to_check: if not line.startswith("#") and not line.startswith("\\\\"): first_non_comment = line break if not first_non_comment: raise ValueError(f"File contains only comments: {in_file}") has_tabs = "\t" in first_non_comment parts = first_non_comment.split("\t") if has_tabs else first_non_comment.split() if not parts: raise ValueError(f"First data line is empty or malformed: {in_file}") # Check if first column is numeric try: int(parts[0]) is_numeric_first = True except (ValueError, IndexError): is_numeric_first = False # Determine format based on heuristics: # LUT format characteristics: # - Has # comments # - First non-comment line starts with a number (region code) # - Space-separated or tab-separated # TSV format characteristics: # - First line is a header (starts with column names like "index", "name") # - Tab-separated # - First column is typically text (header name) if has_tabs and not is_numeric_first: # Tab-separated with text header -> TSV return "tsv" elif has_hash_comments and is_numeric_first: # Has comments and numeric first column -> LUT return "lut" elif not has_tabs and is_numeric_first: # Space-separated with numeric first column -> LUT return "lut" elif has_tabs and is_numeric_first: # Tab-separated with numeric first column # Could be TSV without header, check for column names in parts if len(parts) >= 2 and parts[1].lower() in [ "index", "name", "label", "region", ]: return "tsv" else: # Numeric data without clear header -> assume LUT return "lut" else: # Default fallback based on comments return "lut" if has_hash_comments else "tsv"
[docs] @staticmethod def read_luttable( in_file: str, filter_by_name: Union[str, List[str]] = None ) -> dict: """ Read and parse a FreeSurfer Color Lookup Table (LUT) file. This method reads a FreeSurfer color lookup table file and parses its contents into a structured dictionary containing region codes, names, and colors. The LUT file format follows FreeSurfer's standard format where each non-comment line contains a region code, name, and RGB color values. Parameters ---------- in_file : str Path to the FreeSurfer color lookup table file (.txt or .lut) filter_by_name : str or list of str, optional Filter regions by name substring(s). If provided, only regions whose names contain any of the specified substrings will be returned. Default is None. Returns ------- dict Dictionary with the following keys: - 'index': List of integer region codes (standard Python integers) - 'name': List of region name strings - 'color': List of hex color codes (format: '#RRGGBB') - 'opacity': List of opacity values - 'headerlines': List of comment lines before the R G B A header (without #) Examples -------- >>> lut_dict = ColorTableLoader.read_luttable('FreeSurferColorLUT.txt') >>> print(f"Found {len(lut_dict['index'])} regions") Found 1234 regions >>> lut_dict['index'][:3] [0, 1, 2] >>> lut_dict['name'][:3] ['Unknown', 'Left-Cerebral-Exterior', 'Left-Cerebral-White-Matter'] >>> lut_dict['color'][:3] ['#000000', '#4682b4', '#f5f5f5'] >>> # Filter for hippocampus regions >>> hippo_dict = ColorTableLoader.read_luttable( ... 'FreeSurferColorLUT.txt', ... filter_by_name='hippocampus' ... ) >>> print(hippo_dict['name']) ['Left-Hippocampus', 'Right-Hippocampus', ...] >>> # Filter for multiple patterns >>> regions = ColorTableLoader.read_luttable( ... 'FreeSurferColorLUT.txt', ... filter_by_name=['hippocampus', 'amygdala'] ... ) Notes ----- - Comment lines (starting with '#') in the LUT file are ignored - Each non-comment line should have at least 5 elements: code, name, R, G, B - The returned region codes are standard Python integers, not numpy objects - Color values are converted from RGB to hexadecimal format - Filtering is case-insensitive and matches substrings - Header lines before "R G B A" are collected without the # symbol """ # Read the LUT file content try: with open(in_file, "r", encoding="utf-8") as f: lut_content = f.readlines() except UnicodeDecodeError: with open(in_file, "r") as f: lut_content = f.readlines() except FileNotFoundError: raise FileNotFoundError(f"LUT file not found: {in_file}") except PermissionError: raise PermissionError( f"Permission denied when accessing LUT file: {in_file}" ) # Initialize lists to store parsed data region_codes = [] region_names = [] region_colors_rgb = [] region_opacities = [] headerlines = [] # Flag to track if we've found the R G B A header line header_found = False # Parse each line in the file for line in lut_content: line = line.strip() # Skip empty lines if not line: continue # Handle comment lines if line.startswith("#") or line.startswith("\\\\"): # Don't process further if we already found the header if header_found: continue parts = line.split() # Check if this is the R G B A header line # Look for R, G, B, A appearing consecutively (case-insensitive) is_header = False if len(parts) >= 4: parts_lower = [p.lower() for p in parts] # Search for consecutive R G B A pattern for i in range(len(parts_lower) - 3): if ( parts_lower[i] == "r" and parts_lower[i + 1] == "g" and parts_lower[i + 2] == "b" and parts_lower[i + 3] == "a" ): is_header = True header_found = True break # If this is not the header line and we haven't found header yet, # collect it as a header line (without the # symbol) if not is_header and not header_found: # Remove leading # or \\ symbols cleaned_line = line.lstrip("#\\").strip() if cleaned_line: # Only add non-empty lines headerlines.append(cleaned_line) continue # Parse data lines (non-comment lines) parts = line.split() if len(parts) < 5: # Need at least code, name, R, G, B continue # Extract data try: code = int(parts[0]) name = parts[1] if len(parts) >= 6: r, g, b, o = ( int(parts[2]), int(parts[3]), int(parts[4]), int(parts[5]), ) else: r, g, b = int(parts[2]), int(parts[3]), int(parts[4]) o = 0 # Default opacity if not provided region_codes.append(code) region_names.append(name) region_colors_rgb.append([r, g, b]) region_opacities.append(o) except (ValueError, IndexError): # Skip malformed lines continue # Convert RGB colors to hex format try: # Use the existing multi_rgb2hex function if available region_colors_hex = multi_rgb2hex(np.array(region_colors_rgb)) except (NameError, AttributeError): # Fallback to direct conversion if the function isn't available region_colors_hex = [ f"#{r:02x}{g:02x}{b:02x}" for r, g, b in region_colors_rgb ] # Apply name filtering if requested if filter_by_name is not None: if isinstance(filter_by_name, str): filter_by_name = [filter_by_name] filtered_indices = cltmisc.get_indexes_by_substring( region_names, filter_by_name ) # Filter the LUT based on the provided names region_codes = [region_codes[i] for i in filtered_indices] region_names = [region_names[i] for i in filtered_indices] region_colors_hex = [region_colors_hex[i] for i in filtered_indices] region_opacities = [region_opacities[i] for i in filtered_indices] # Create and return the result dictionary return { "index": region_codes, "name": region_names, "color": region_colors_hex, "opacity": region_opacities, "headerlines": headerlines, }
[docs] @staticmethod def read_tsvtable( in_file: str, filter_by_name: Union[str, List[str]] = None ) -> dict: """ Read and parse a TSV (Tab-Separated Values) lookup table file. This method reads a TSV file containing parcellation information and returns a dictionary with the data. The TSV file must contain at least 'index' and 'name' columns. If a 'color' column is present, it will be included in the returned dictionary. Parameters ---------- in_file : str Path to the TSV lookup table file filter_by_name : str or list of str, optional Filter regions by name substring(s). If provided, only regions whose names contain any of the specified substrings will be returned. Default is None. Returns ------- dict Dictionary with keys corresponding to column names in the TSV file. Must include at least: - 'index': List of integer region codes (standard Python integers) - 'name': List of region name strings May also include: - 'color': List of color codes if present in the TSV file - Any other columns present in the TSV file Raises ------ FileNotFoundError If the specified TSV file does not exist ValueError If the TSV file does not contain required 'index' and 'name' columns, is empty, or cannot be parsed Examples -------- >>> tsv_dict = ColorTableLoader.read_tsvtable('regions.tsv') >>> print(f"Columns: {list(tsv_dict.keys())}") Columns: ['index', 'name', 'color', 'abbreviation'] >>> tsv_dict['index'][:3] [0, 1, 2] >>> tsv_dict['name'][:3] ['Unknown', 'Left-Cerebral-Exterior', 'Left-Cerebral-White-Matter'] >>> # Filter for specific regions >>> filtered = ColorTableLoader.read_tsvtable( ... 'regions.tsv', ... filter_by_name=['cortex', 'hippocampus'] ... ) Notes ----- - The 'index' column values are converted to standard Python integers - All other columns are preserved in their original format - Filtering is case-insensitive and matches substrings """ # Check if file exists if not os.path.exists(in_file): raise FileNotFoundError(f"TSV file not found: {in_file}") try: # Read the TSV file into a pandas DataFrame tsv_df = pd.read_csv(in_file, sep="\t") # Check for required columns required_columns = ["index", "name"] missing_columns = [ col for col in required_columns if col not in tsv_df.columns ] if missing_columns: raise ValueError( f"TSV file missing required columns: {', '.join(missing_columns)}" ) # Convert DataFrame to dictionary tsv_dict = tsv_df.to_dict(orient="list") # Convert index values to integers if "index" in tsv_dict: tsv_dict["index"] = [int(x) for x in tsv_dict["index"]] if "opacity" in tsv_dict: tsv_dict["opacity"] = [float(x) for x in tsv_dict["opacity"]] else: tsv_dict["opacity"] = [1] * len(tsv_dict["index"]) # Apply name filtering if requested if filter_by_name is not None: if isinstance(filter_by_name, str): filter_by_name = [filter_by_name] filtered_indices = cltmisc.get_indexes_by_substring( tsv_dict["name"], filter_by_name ) # Filter all columns based on the provided names tsv_dict = { key: [tsv_dict[key][i] for i in filtered_indices] for key in tsv_dict.keys() } # tsv_dict["headerlines"] = [] return tsv_dict except pd.errors.EmptyDataError: raise ValueError( f"The TSV file is empty or improperly formatted: {in_file}" ) except pd.errors.ParserError: raise ValueError(f"The TSV file could not be parsed correctly: {in_file}") except Exception as e: raise ValueError(f"Error reading TSV file {in_file}: {str(e)}")
[docs] @staticmethod def write_luttable( lut_df: Union[pd.DataFrame, dict], out_file: str = None, boolappend: bool = False, force: bool = True, ): """ Write a FreeSurfer format lookup table file. This method creates a FreeSurfer-compatible color lookup table file from region codes, names, and colors. The output follows the standard FreeSurfer LUT format with optional header lines. Parameters ---------- lut_df : pd.DataFrame or dict DataFrame or dictionary containing the following keys/columns: - 'index': List of integer region codes - 'name': List of region name strings - 'color': List of colors in RGB format (as list/array of [R, G, B] values) or hexadecimal format (as list of '#RRGGBB' strings) - 'opacity': List of opacity values (0-1 range) - 'headerlines': Optional list of header lines to include at the top of the file. If the list is empty, a default header with timestamp will be generated. Default is None. - If a dictionary is provided, it must contain the same keys. out_file : str, optional Output file path. If None, returns formatted lines without writing to file. Default is None. boolappend : bool, optional If True, append to existing file. If False, create new file or overwrite. Default is False. force : bool, optional If True, overwrite existing files without warning. If False, warn before overwriting. Default is True. Returns ------- list List of formatted LUT lines as strings Examples -------- >>> # Create a simple LUT file >>> lut_data = { ... 'index': [0, 1, 2], ... 'name': ['Unknown', 'Left-Cerebral-Exterior', 'Left-Cerebral-White-Matter'], ... 'color': ['#000000', '#4682b4', '#f5f5f5'], ... 'opacity': [1.0, 1.0, 1.0] ... } >>> ColorTableLoader.write_luttable( ... lut_df=lut_data, ... out_file='output_lut.txt', ... boolappend=False, ... force=True ... ) Notes ----- - Output format follows FreeSurfer LUT specification - RGB values should be in range [0, 255] - Hex colors should be in format '#RRGGBB' - Alpha channel is always set to 0 in output """ codes = lut_df["index"] names = lut_df["name"] colors = lut_df["color"] opacities = lut_df["opacity"] headerlines = [] # Move the opacity to the range of 0-255 opacities = [int(op * 255) for op in opacities] # Check if the file already exists and if the force parameter is False if out_file is not None: if os.path.exists(out_file) and not force: print("Warning: The file already exists. It will be overwritten.") out_dir = os.path.dirname(out_file) if out_dir and not os.path.exists(out_dir): os.makedirs(out_dir) happend_bool = True # Boolean to append the headerlines if headerlines is None: happend_bool = ( False # Only add this if it is the first time the file is created ) now = datetime.now() date_time = now.strftime("%m/%d/%Y, %H:%M:%S") headerlines = [ "# $Id: {} {} \n".format(out_file, date_time), ] elif isinstance(headerlines, str): headerlines = [headerlines] elif isinstance(headerlines, list): pass else: raise ValueError("The headerlines parameter must be a list or a string") if boolappend: if not os.path.exists(out_file): raise ValueError(f"Cannot append: file does not exist: {out_file}") else: with open(out_file, "r") as file: luttable = file.readlines() luttable = [l.strip("\n\r") for l in luttable] luttable = ["\n" if element == "" else element for element in luttable] if happend_bool: luttable = luttable + headerlines else: luttable = headerlines luttable.append("#\n") luttable.append( "{:<4} {:<50} {:>3} {:>3} {:>3} {:>3}".format( "#No.", "Label Name:", "R", "G", "B", "A" ) ) # Handle different color input formats if isinstance(colors, list): if isinstance(colors[0], str): colors = harmonize_colors(colors) colors = multi_hex2rgb(colors) elif isinstance(colors[0], list): colors = np.array(colors) elif isinstance(colors[0], np.ndarray): colors = np.vstack(colors) elif isinstance(colors, np.ndarray): pass # Already in correct format else: raise ValueError("Colors must be a list or numpy array") # Add regions to table for roi_pos, roi_name in enumerate(names): if roi_pos == 0: luttable.append("\n") luttable.append( "{:<4} {:<50} {:>3} {:>3} {:>3} {:>3}".format( codes[roi_pos], names[roi_pos], colors[roi_pos, 0], colors[roi_pos, 1], colors[roi_pos, 2], opacities[roi_pos], ) ) luttable.append("\n") # Write to file if path provided if out_file is not None: if os.path.isfile(out_file) and force: with open(out_file, "w") as colorLUT_f: colorLUT_f.write("\n".join(luttable)) elif not os.path.isfile(out_file): with open(out_file, "w") as colorLUT_f: colorLUT_f.write("\n".join(luttable)) return luttable
[docs] @staticmethod def write_tsvtable( tsv_df: Union[pd.DataFrame, dict], out_file: str, boolappend: bool = False, force: bool = False, ): """ Write a TSV format lookup table file. This method creates a tab-separated values (TSV) file from a pandas DataFrame or dictionary containing parcellation information. The data must include at least 'index' and 'name' columns/keys. Parameters ---------- tsv_df : pd.DataFrame or dict Data to write with index/name/color information. Must contain at least 'index' and 'name' keys/columns. out_file : str Output file path for the TSV file boolappend : bool, optional If True, append to existing TSV file. If False, create new file or overwrite. Default is False. force : bool, optional If True, overwrite existing files without warning. If False, warn before overwriting. Default is False. Returns ------- str Path to the output TSV file Raises ------ ValueError If the input data does not contain required 'index' and 'name' keys/columns, if colors are not in hexadecimal format, or if append is requested but file doesn't exist Examples -------- >>> # Write from dictionary >>> data = { ... 'index': [1, 2, 3], ... 'name': ['region1', 'region2', 'region3'], ... 'color': ['#FF0000', '#00FF00', '#0000FF'] ... } >>> ColorTableLoader.write_tsvtable(data, 'regions.tsv', force=True) 'regions.tsv' >>> # Write from DataFrame >>> import pandas as pd >>> df = pd.DataFrame(data) >>> ColorTableLoader.write_tsvtable(df, 'regions.tsv', force=True) >>> # Append to existing file >>> new_data = { ... 'index': [4], ... 'name': ['region4'], ... 'color': ['#FFFF00'] ... } >>> ColorTableLoader.write_tsvtable(new_data, 'regions.tsv', boolappend=True) Notes ----- - Output is tab-separated with column headers - RGB colors are automatically converted to hexadecimal format - When appending, columns are matched by name; missing values are filled with empty strings - The output file includes a header row with column names """ # Check if the file already exists and if the force parameter is False if os.path.exists(out_file) and not force and not boolappend: print("Warning: The TSV file already exists. It will be overwritten.") out_dir = os.path.dirname(out_file) if out_dir and not os.path.exists(out_dir): os.makedirs(out_dir) # Convert DataFrame to dictionary if needed if isinstance(tsv_df, pd.DataFrame): tsv_dict = tsv_df.to_dict(orient="list") else: tsv_dict = tsv_df.copy() # Create a copy to avoid modifying original # Validate required columns if "name" not in tsv_dict.keys() or "index" not in tsv_dict.keys(): raise ValueError("The dictionary must contain the keys 'index' and 'name'") # Process colors if present if "color" in tsv_dict.keys(): temp_colors = tsv_dict["color"] if isinstance(temp_colors, list): if isinstance(temp_colors[0], str): if temp_colors[0][0] != "#": raise ValueError( "The colors must be in hexadecimal format (starting with #)" ) elif isinstance(temp_colors[0], list): colors = np.array(temp_colors) seg_hexcol = multi_rgb2hex(colors) tsv_dict["color"] = seg_hexcol elif isinstance(temp_colors, np.ndarray): seg_hexcol = multi_rgb2hex(temp_colors) tsv_dict["color"] = seg_hexcol # Handle append mode if boolappend: if not os.path.exists(out_file): raise ValueError(f"Cannot append: file does not exist: {out_file}") else: tsv_orig = ColorTableLoader.read_tsvtable(in_file=out_file) # Create a list with the common keys between tsv_orig and tsv_dict common_keys = list(set(tsv_orig.keys()) & set(tsv_dict.keys())) # List all the keys for both dictionaries all_keys = list(set(tsv_orig.keys()) | set(tsv_dict.keys())) # Concatenate values for common keys for key in common_keys: tsv_orig[key] = tsv_orig[key] + tsv_dict[key] # Fill missing values for non-common keys for key in all_keys: if key not in common_keys: if key in tsv_orig.keys(): tsv_orig[key] = tsv_orig[key] + [""] * len(tsv_dict["name"]) elif key in tsv_dict.keys(): tsv_orig[key] = [""] * len(tsv_orig["name"]) + tsv_dict[key] tsv_dict = tsv_orig # Convert dictionary to DataFrame tsv_df = pd.DataFrame(tsv_dict) # Write to file if os.path.isfile(out_file) and force: with open(out_file, "w") as tsv_file: tsv_file.write(tsv_df.to_csv(sep="\t", index=False)) elif not os.path.isfile(out_file): with open(out_file, "w") as tsv_file: tsv_file.write(tsv_df.to_csv(sep="\t", index=False)) return out_file
#################################################
[docs] def export( self, out_ctab: Union[str, Path], out_format: str = "lut", headerlines: Union[list, str] = None, append: bool = False, overwrite: bool = True, ): """ Export the loaded color table to specified format. Parameters ---------- out_ctab : str Path for output color table file. out_format : str, optional Output format. Options are 'lut', 'tsv', 'fsl' or 'nilearn'. Default is 'lut'. overwrite : bool, optional Whether to overwrite the output file if it already exists. Default: True Examples -------- >>> parcellation.export_to_file('fsl_colors.lut', out_format='fslctab') >>> parcellation.export_to_file('nilearn_colors.tsv', out_format='nilearnctab') """ if out_format.lower() == "fsl": self.export_to_fslctab(out_ctab, overwrite=overwrite) elif out_format.lower() == "nilearn": self.export_to_nilearnctab(out_ctab, overwrite=overwrite) elif out_format.lower() == "lut": self.export_to_lutctab( out_ctab, overwrite=overwrite, headerlines=headerlines, append=append ) elif out_format.lower() == "tsv": self.export_to_tsvctab(out_ctab, overwrite=overwrite) else: raise ValueError( f"Unsupported output format: {out_format}. " "Supported formats are 'lut', 'tsv', 'fsl', or 'nilearn'." )
######################################################################################################
[docs] def export_to_fslctab(self, out_ctab: Union[str, Path], overwrite: bool = True): """ Export the loaded color table to FSL LUT format. Parameters ---------- out_ctab : str Path for output FSL LUT file. Examples -------- """ # Convert to Path objects if isinstance(out_ctab, str): out_ctab = Path(out_ctab) # Check if output directory exists if not out_ctab.parent.exists(): raise FileNotFoundError( f"Output directory does not exist: {out_ctab.parent}" ) # Check if output file exists and handle overwrite if out_ctab.exists() and not overwrite: raise FileExistsError( f"Output file already exists: {out_ctab}. Use overwrite=True to overwrite." ) st_codes_lut = self.index st_names_lut = self.name st_colors_lut = harmonize_colors(self.color, output_format="rgb") lut_lines = [] for roi_pos, st_code in enumerate(st_codes_lut): st_name = st_names_lut[roi_pos] lut_lines.append( "{:<4} {:>3.5f} {:>3.5f} {:>3.5f} {:<40} ".format( st_code, st_colors_lut[roi_pos, 0] / 255, st_colors_lut[roi_pos, 1] / 255, st_colors_lut[roi_pos, 2] / 255, st_name, ) ) if os.path.isfile(out_ctab) or overwrite: with open(out_ctab, "w") as colorLUT_f: colorLUT_f.write("\n".join(lut_lines))
######################################################################################################
[docs] def export_to_nilearnctab( self, out_ctab: Union[str, Path], overwrite: bool = False ) -> str: """ Export the color table to nilearn-compatible format. This function reads a color lookup table and converts it to a pandas-readable format that nilearn's NiftiLabelsMasker can use. Parameters ---------- out_ctab : str or Path Path for the output file. Directory must exist. overwrite : bool, optional Whether to overwrite the output file if it already exists. Default: False Raises ------ FileNotFoundError If input_lut_path doesn't exist or output directory doesn't exist FileExistsError If output file exists and overwrite=False ValueError If no valid data lines are found in the input file Examples -------- """ # Convert to Path objects if isinstance(out_ctab, str): out_ctab = Path(out_ctab) # Check if output directory exists if not out_ctab.parent.exists(): raise FileNotFoundError( f"Output directory does not exist: {out_ctab.parent}" ) # Check if output file exists and handle overwrite if out_ctab.exists() and not overwrite: raise FileExistsError( f"Output file already exists: {out_ctab}. Use overwrite=True to overwrite." ) colors = harmonize_colors(self.color, output_format="hex") df = pd.DataFrame( { "index": self.index, "name": self.name, "color": colors, } ) df = df.sort_values("index").reset_index(drop=True) if not os.path.isfile(out_ctab) or overwrite: df.to_csv(out_ctab, sep="\t", index=False)
######################################################################################################
[docs] def export_to_lutctab( self, out_ctab: Union[str, Path] = None, headerlines: Union[list, str] = None, append: bool = False, overwrite: bool = False, ) -> str: """ Export the color table to LUT format. This function writes the color table to a FreeSurfer-compatible LUT file. Parameters ---------- out_ctab : str or Path Path for the output LUT file. If None, returns the LUT lines as a list of strings. headerlines : list or str, optional Custom header lines to include at the top of the file. If None, a default header with timestamp will be generated. Default is None. append : bool, optional If True, append to existing file. If False, create new file or overwrite. Default is False. overwrite : bool, optional Whether to overwrite the output file if it already exists. Default: False Raises ------ FileNotFoundError If output directory doesn't exist FileExistsError If output file exists and overwrite=False """ if out_ctab is not None: # Convert to Path objects if isinstance(out_ctab, str): out_ctab = Path(out_ctab) # Check if output file exists and handle overwrite if out_ctab.exists() and not overwrite and not append: raise FileExistsError( f"Output file already exists: {out_ctab}. Use overwrite=True to overwrite." ) # Write LUT file codes = self.index names = self.name colors = self.color if self.opacity is not None: opacities = self.opacity # Ensure opacity values are in the correct range [0, 255] opacities = [min(max(int(opacity * 255), 0), 255) for opacity in opacities] else: opacities = [255] * len(self.color) if headerlines is None: happend_bool = ( False # Only add this if it is the first time the file is created ) now = datetime.now() date_time = now.strftime("%m/%d/%Y, %H:%M:%S") headerlines = ["# $Id: {} {} \n".format(str(out_ctab), date_time)] elif isinstance(headerlines, str): headerlines = [headerlines] elif isinstance(headerlines, list): pass else: raise ValueError("The headerlines parameter must be a list or a string") if append: with open(str(out_ctab), "r") as file: luttable = file.readlines() luttable = [l.strip("\n\r") for l in luttable] luttable = ["\n" if element == "" else element for element in luttable] if happend_bool: luttable = luttable + headerlines else: luttable = headerlines headerlines.append( "{:<4} {:<50} {:>3} {:>3} {:>3} {:>3}".format( "#No.", "Label Name:", "R", "G", "B", "A" ) ) # Handle different color input formats if isinstance(colors, list): if isinstance(colors[0], str): colors = harmonize_colors(colors) colors = multi_hex2rgb(colors) elif isinstance(colors[0], list): colors = np.array(colors) elif isinstance(colors[0], np.ndarray): colors = np.vstack(colors) elif isinstance(colors, np.ndarray): pass # Already in correct format else: raise ValueError("Colors must be a list or numpy array") # Add regions to table for roi_pos, roi_name in enumerate(names): if roi_pos == 0: luttable.append("\n") luttable.append( "{:<4} {:<50} {:>3} {:>3} {:>3} {:>3}".format( codes[roi_pos], names[roi_pos], colors[roi_pos, 0], colors[roi_pos, 1], colors[roi_pos, 2], opacities[roi_pos], ) ) luttable.append("\n") # Write to file if path provided if out_ctab is not None: if not os.path.isfile(out_ctab) or overwrite: with open(out_ctab, "w") as colorLUT_f: colorLUT_f.write("\n".join(luttable)) return str(out_ctab) else: return luttable
###########################################################################################
[docs] def export_to_tsvctab( self, out_ctab: Union[str, Path] = None, overwrite: bool = False ) -> str: """ Export the color table to TSV format. This function writes the color table to a tab-separated values (TSV) file. Parameters ---------- out_ctab : str or Path Path for the output TSV file. overwrite : bool, optional Whether to overwrite the output file if it already exists. Default: False Raises ------ FileNotFoundError If output directory doesn't exist FileExistsError If output file exists and overwrite=False """ if out_ctab is not None: # Convert to Path objects if isinstance(out_ctab, str): out_ctab = Path(out_ctab) # Check if output directory exists if not out_ctab.parent.exists(): raise FileNotFoundError( f"Output directory does not exist: {out_ctab.parent}" ) # Check if output file exists and handle overwrite if out_ctab.exists() and not overwrite: raise FileExistsError( f"Output file already exists: {out_ctab}. Use overwrite=True to overwrite." ) colors = harmonize_colors(self.color, output_format="hex") opacity = self.opacity if self.opacity is not None else [1] * len(self.color) df = pd.DataFrame( { "index": self.index, "name": self.name, "color": colors, "opacity": opacity, } ) df = df.sort_values("index").reset_index(drop=True) if out_ctab is not None: if not os.path.isfile(out_ctab) or overwrite: df.to_csv(out_ctab, sep="\t", index=False) return str(out_ctab) else: return df