"""Color palettes, opacity curves and HU lookup tables for rendering medical volumes."""
import numpy as np
import matplotlib.pyplot as plt
import pyvcad as pv
__all__ = [
"raw_color_palettes",
"get_color_palette",
"lerp",
"interp_color",
"linear_opacity_base",
"sigmoid_opacity_base",
"re_range_opacity",
"create_linear_gradient_hu_map",
"plot_opacity_function",
"plot_hu_color_map",
]
#: Named color palettes. Each value is a list of ``(r, g, b)`` tuples with channels in 0-255,
#: ordered from the low end of the scale to the high end.
raw_color_palettes = {
"grayscale":[(80,80,80),(255,255,255)],
"jet": [(37,94,199),(5,255,255),(255,255,0)],
"rainbow":[(55,39,130),(72,107,222),(78,184,231),(74,221,139),(166,244,67),(241,226,53),(246,144,18),(218,61,0),(126,0,0)],
"viridis":[(127,14,156),(77,108,186),(0,255,242),(94,201,98)],
"cividis":[(0,34,77),(67,85,130),(128,129,145),(190,172,129)],
"magma":[(0,0,4),(84,17,68),(187,55,84),(249,142,8)],
}
[docs]
def get_color_palette(name):
"""
Returns a named palette from :data:`raw_color_palettes`.
Arguments:
name (str): Palette name: "grayscale", "jet", "rainbow", "viridis", "cividis" or "magma".
Returns:
list[tuple[int, int, int]]: The palette's ``(r, g, b)`` stops, with channels in 0-255.
Raises:
ValueError: If no palette has this name.
Example:
>>> import pyvcad_medical as med
>>> palette = med.get_color_palette("viridis")
"""
if name in raw_color_palettes:
return raw_color_palettes[name]
else:
raise ValueError(f"Color palette '{name}' not found.")
[docs]
def lerp(a,b,t):
"""
Linearly interpolates between two numbers.
Arguments:
a (float): Value at ``t = 0``.
b (float): Value at ``t = 1``.
t (float): Interpolation parameter. Values outside [0, 1] extrapolate.
Returns:
float: ``a + (b - a) * t``.
"""
return a+(b-a)*t
[docs]
def interp_color(palette,t):
"""
Samples a palette at a normalized position.
The palette stops are spaced evenly over [0, 1] and adjacent stops are linearly interpolated.
Arguments:
palette (list[tuple[int, int, int]]): ``(r, g, b)`` stops with channels in 0-255, such as a
value from :data:`raw_color_palettes`.
t (float): Position in [0, 1]. Values of 1 or more return the last stop.
Returns:
tuple[float, float, float]: The ``(r, g, b)`` color with channels in [0, 1].
Example:
>>> import pyvcad_medical as med
>>> med.interp_color(med.get_color_palette("grayscale"), 0.5)
"""
n=len(palette)-1; x=t*n; i=int(x); f=x-i
if i>=n: return tuple(v/255 for v in palette[-1])
c1,c2=palette[i],palette[i+1]
return tuple((lerp(c1[j],c2[j],f))/255 for j in range(3))
[docs]
def linear_opacity_base(t: float, ymin: float = 0.0, ymax: float = 1.0) -> float:
"""
Linear opacity curve.
Arguments:
t (float): Normalized position. It is clamped to [0, 1].
ymin (float): Opacity at ``t = 0``.
ymax (float): Opacity at ``t = 1``.
Returns:
float: ``ymin + (ymax - ymin) * t``.
"""
t_clamped = max(0.0, min(1.0, t))
return ymin + (ymax - ymin) * t_clamped
[docs]
def sigmoid_opacity_base(t: float, k: float = 10.0, x0: float = 0.5, ymin: float = 0.0, ymax: float = 1.0) -> float:
"""
Sigmoid (S-curve) opacity curve.
Arguments:
t (float): Normalized position in [0, 1].
k (float): Steepness of the transition. Larger values give a sharper step.
x0 (float): Midpoint of the transition in [0, 1].
ymin (float): Lower bound of the output.
ymax (float): Upper bound of the output.
Returns:
float: ``ymin + (ymax - ymin) / (1 + exp(-k * (t - x0)))``.
"""
import math
s = 1.0 / (1.0 + math.exp(-k * (t - x0)))
return ymin + (ymax - ymin) * s
[docs]
def re_range_opacity(t: float, function, lower_opacity, upper_opacity) -> float:
"""
Rescales an opacity curve to a new output range.
Arguments:
t (float): Normalized position passed to ``function``.
function (callable): Base opacity curve ``f(t)``. Its result is clamped to [0, 1].
lower_opacity (float): Output when ``function(t)`` is 0.
upper_opacity (float): Output when ``function(t)`` is 1.
Returns:
float: ``lower_opacity + (upper_opacity - lower_opacity) * clamp(function(t))``.
Example:
>>> import pyvcad_medical as med
>>> opacity = lambda t: med.re_range_opacity(t, med.sigmoid_opacity_base, 0.1, 0.9)
"""
t_clamped = max(0.0, min(1.0, function(t)))
return lower_opacity + (upper_opacity - lower_opacity) * t_clamped
[docs]
def create_linear_gradient_hu_map(lower_hu, upper_hu, palette=raw_color_palettes["jet"], steps=20, opacity_function=lambda t: 1.0):
"""
Builds a Hounsfield-unit lookup table that sweeps through a color palette.
The range [lower_hu, upper_hu] is split into ``steps`` contiguous bins. Bin ``i`` takes the palette
color at ``t = i / (steps - 1)`` and the opacity ``opacity_function(t)``.
Arguments:
lower_hu (float): Lower HU bound of the first bin.
upper_hu (float): Upper HU bound of the last bin.
palette (list[tuple[int, int, int]]): ``(r, g, b)`` stops with channels in 0-255. Defaults to
the "jet" palette.
steps (int): Number of bins.
opacity_function (callable): Opacity curve ``f(t)`` for ``t`` in [0, 1]. Defaults to a
constant 1.0.
Returns:
list[LookupTableEntry]: One entry per bin, mapping an HU range to an RGBA ``Vec4``.
Example:
>>> import pyvcad as pv
>>> import pyvcad_medical as med
>>> entries = med.create_linear_gradient_hu_map(100, 1000, palette=med.get_color_palette("rainbow"), steps=30)
>>> converter = pv.LookupTableConverter(["hu"], ["color_rgba"], entries, pv.InterpolationMode.STEP)
"""
entries = []
hu_range = upper_hu - lower_hu
step_size = hu_range / steps
for i in range(steps):
# Calculate geometry: contiguous blocks
hu_range_lower = lower_hu + i * step_size
hu_range_upper = lower_hu + (i + 1) * step_size
# Calculate color/opacity parameter: distributed 0..1
t = i / (steps - 1) if steps > 1 else 0.0
color = interp_color(palette, t)
# Map t -> [0,1] via the provided function
opacity = opacity_function(t)
entries.append(pv.LookupTableEntry(hu_range_lower, hu_range_upper, pv.Vec4(color[0], color[1], color[2], opacity)))
return entries
[docs]
def plot_opacity_function(opacity_function, steps=100):
"""
Plots an opacity curve over [0, 1] with matplotlib and shows the figure.
Arguments:
opacity_function (callable): Opacity curve ``f(t)``.
steps (int): Number of evenly spaced samples. Must be at least 2.
"""
ts = [i / (steps - 1) for i in range(steps)]
opacities = [opacity_function(t) for t in ts]
plt.plot(ts, opacities)
plt.xlabel('Normalized t (0 to 1)')
plt.ylabel('Opacity')
plt.title('Opacity Function')
plt.grid(True)
plt.show()
[docs]
def plot_hu_color_map(hu_color_map, lower_hu=None, upper_hu=None, use_transparency=True):
"""
Plots an HU lookup table as a horizontal color bar and shows the figure.
Each entry's HU range is drawn at its true position and width; gaps between ranges are drawn
transparent.
Arguments:
hu_color_map (list): Entries ordered by HU, either ``LookupTableEntry`` objects or legacy
``([lower_hu, upper_hu], color)`` pairs.
lower_hu (float, optional): Left end of the bar. Defaults to the first entry's lower bound.
upper_hu (float, optional): Right end of the bar. Defaults to the last entry's upper bound.
use_transparency (bool): Draw each color with its alpha over a checkerboard. If False, colors
are drawn opaque.
"""
import matplotlib.colors as mcolors
# Determine data bounds
first = hu_color_map[0]
last = hu_color_map[-1]
if hasattr(first, 'input_low'):
map_min = first.input_low
map_max = last.input_high
else:
map_min = first[0][0]
map_max = last[0][1]
# Use provided bounds or fallback to data extent
vmin = lower_hu if lower_hu is not None else map_min
vmax = upper_hu if upper_hu is not None else map_max
# Safety check for empty range
if vmax <= vmin:
vmax = vmin + 1.0
# Construct the colormap segments (list of (position 0..1, color))
# This approach allows for arbitrary gaps and widths.
segments = []
trans_white = (1.0, 1.0, 1.0, 0.0) # Fully transparent white for gaps
def get_norm(val):
# Normalize value to 0..1 relative to the plot range
return max(0.0, min(1.0, (val - vmin) / (vmax - vmin)))
current_pos = vmin
for entry in hu_color_map:
# Support both old format ([lower, upper], color) and new LookupTableEntry
if hasattr(entry, 'input_low'):
r_start, r_end = entry.input_low, entry.input_high
color = entry.output_values[0]
else:
r_start, r_end = entry[0]
color = entry[1]
# Skip ranges that don't overlap with the view
if r_end <= vmin or r_start >= vmax:
continue
# Clamp to view bounds
eff_start = max(r_start, vmin)
eff_end = min(r_end, vmax)
# 1. Handle Gap: If there is space between the last processed position and the new range start
if eff_start > current_pos:
norm_prev = get_norm(current_pos)
norm_curr = get_norm(eff_start)
segments.append((norm_prev, trans_white))
segments.append((norm_curr, trans_white))
# 2. Add Color Block
if use_transparency:
c = (color.r, color.g, color.b, color.a)
else:
c = (color.r, color.g, color.b, 1.0)
norm_start = get_norm(eff_start)
norm_end = get_norm(eff_end)
# Add two points to define a solid block of color (gradients handled by creating many small blocks externally)
segments.append((norm_start, c))
segments.append((norm_end, c))
current_pos = eff_end
# 3. Handle trailing Gap (between last range and view max)
if current_pos < vmax:
norm_prev = get_norm(current_pos)
norm_curr = get_norm(vmax)
segments.append((norm_prev, trans_white))
segments.append((norm_curr, trans_white))
# Fallback for completely empty overlap
if not segments:
segments = [(0.0, trans_white), (1.0, trans_white)]
# Create a LinearSegmentedColormap from the list.
# By doubling up coordinates (start, c) and (end, c), we create hard edges if needed,
# or smooth gradients if the input map was generated that way.
cmap = mcolors.LinearSegmentedColormap.from_list("hu_cmap", segments)
# Plot setup
fig, ax = plt.subplots(figsize=(6, 1))
fig.subplots_adjust(bottom=0.5)
norm = plt.Normalize(vmin=vmin, vmax=vmax)
# Use linspace for general readability, as specific map bounds might be irregular
cb = plt.colorbar(plt.cm.ScalarMappable(norm=norm, cmap=cmap),
cax=ax, orientation='horizontal',
ticks=np.linspace(vmin, vmax, 11))
cb.set_label('Hounsfield Units (HU)')
if use_transparency:
# Generate a high-res checkerboard pattern for background
ny, nx = 4, 200
checkers = ((np.indices((ny, nx)).sum(axis=0) % 2) * 0.3 + 0.6)
checkers_rgb = np.stack((checkers,) * 3, axis=-1)
# Display the checkerboard behind the colorbar data
# Extent matches the data range so x-axis aligns
ax.imshow(checkers_rgb, extent=(vmin, vmax, 0, 1),
interpolation='nearest', aspect='auto', zorder=-1)
plt.show()