Skip to content

Instantly share code, notes, and snippets.

@facelessuser
Last active December 1, 2025 22:15
Show Gist options
  • Select an option

  • Save facelessuser/8c47f01ddf5dd2a9cdf61da74a3d1f5a to your computer and use it in GitHub Desktop.

Select an option

Save facelessuser/8c47f01ddf5dd2a9cdf61da74a3d1f5a to your computer and use it in GitHub Desktop.
Msh
"""
Msh color space.
Accounts for negative lightness and uses degrees for hue instead of radians.
- https://www.kennethmoreland.com/color-maps/ColorMapsExpanded.pdf
"""
import math
from coloraide import Color as Base
from coloraide import stop
from coloraide import algebra as alg
from coloraide.spaces.lch import LCh
from coloraide.cat import WHITES
from coloraide.channels import Channel, FLG_ANGLE
from coloraide.types import Vector
def lab_to_msh(lab: Vector) -> Vector:
"""Convert CIE LCh to Msh."""
l, a, b = lab
m = math.copysign(math.sqrt(l ** 2 + a ** 2 + b ** 2), l)
s = math.acos(l / m) if m else 0
h = math.degrees(math.atan2(b, a))
return [m, s, h % 360]
def msh_to_lab(msh: Vector) -> Vector:
"""Convert Msh to CIE Lab."""
m, s, h = msh
r = math.radians(h % 360)
abs_m = abs(m)
l = math.copysign(abs_m * math.cos(s), m)
a = abs_m * math.sin(s) * math.cos(r)
b = abs_m * math.sin(s) * math.sin(r)
return [l, a, b]
class Msh(LCh):
"""Msh color space."""
BASE = "lab-d65"
NAME = "msh"
SERIALIZE = ("--msh",)
CHANNELS = (
Channel("m", 0.0, 150.0),
Channel("s", 0.0, 1.6),
Channel("h", 0.0, 360.0, flags=FLG_ANGLE)
)
CHANNEL_ALIASES = {
"magnitude": "m",
"saturation": "s",
"hue": "h"
}
WHITE = WHITES['2deg']['D65']
def lightness_name(self) -> str:
"""Get lightness name."""
return "m"
def radial_name(self) -> str:
"""Get radial name."""
return "s"
def to_base(self, coords: Vector) -> Vector:
"""To Lab from LCh."""
return msh_to_lab(coords)
def from_base(self, coords: Vector) -> Vector:
"""From Lab to LCh."""
return lab_to_msh(coords)
HUE_CUTOFF = 180 / 3
def adjust_hue(msh_sat, m_unsat):
"""Adjust hue."""
m_sat, s_sat, h_sat = msh_sat
if m_sat >= m_unsat:
return h_sat
else:
h_spin = s_sat * math.sqrt(m_unsat ** 2 - m_sat ** 2) / (m_sat * math.sin(s_sat))
if h_sat > -HUE_CUTOFF:
return h_sat + math.degrees(h_spin)
else:
return h_sat - math.degrees(h_spin)
def angle_diff(angle1, angle2):
"""Get the angle difference."""
diff = abs(angle1 - angle2)
return min(diff, 360 - diff)
def prepare_msh_divergent_interpolation(c1, c2):
"""Optimize colors for interpolation."""
# Normalize values and and remove NaNs.
c1 = Color(c1).convert('msh', norm=False).normalize(nans=False)
c2 = Color(c2).convert('msh', norm=False).normalize(nans=False)
# Msh interpolation logic expects values above 180 degrees (or PI) to be negative.
if c1['h'] > 180:
c1['h'] -= 360
if c2['h'] > 180:
c2['h'] -= 360
# Insert white between saturated colors
colors = [c1]
if (c1['s'] > 0.05) and (c2['s'] > 0.05) and (angle_diff(c1['h'], c2['h']) > HUE_CUTOFF):
mid = max(c1['m'], c2['m'], 88.0)
colors.append(Color('msh', [mid, 0.0, 0.0]))
colors.append(Color('msh', [mid, 0.0, 0.0]))
colors.append(c2)
# Adjust hue of unsaturated colors.
if (colors[0]['s'] < 0.05) and (colors[1]['s'] > 0.05):
colors[0]['h'] = adjust_hue(colors[1].coords(), colors[0]['m'])
elif (colors[1]['s'] < 0.05) and (colors[0]['s'] > 0.05):
colors[1]['h'] = adjust_hue(colors[0].coords(), colors[1]['m'])
# If white was inserted, adjust hues of unsaturated color on the right side.
if len(colors) > 2:
if (colors[-2]['s'] < 0.05) and (colors[-1]['s'] > 0.05):
colors[-2]['h'] = adjust_hue(colors[-1].coords(), colors[-2]['m'])
elif (colors[-1]['s'] < 0.05) and (colors[-2]['s'] > 0.05):
colors[-1]['h'] = adjust_hue(colors[-2].coords(), colors[-1]['m'])
colors[1] = stop(colors[1], 0.5)
colors[-2] = stop(colors[-2], 0.5)
return colors
class Color(Base): ...
Color.register(Msh())
Color.interpolate(
prepare_msh_divergent_interpolation(
Color('srgb', [0.230, 0.299, 0.754]),
Color('srgb', [0.706, 0.016, 0.150])
),
space='msh',
out_space='srgb'
)
Color.interpolate(
prepare_msh_divergent_interpolation(
Color('srgb', [0.436, 0.308, 0.631]),
Color('srgb', [0.759, 0.334, 0.046])
),
space='msh',
out_space='srgb'
)
Color.interpolate(
prepare_msh_divergent_interpolation(
Color('srgb', [0.085, 0.532, 0.201]),
Color('srgb', [0.436, 0.308, 0.631])
),
space='msh',
out_space='srgb'
)
Color.interpolate(
prepare_msh_divergent_interpolation(
Color('srgb', [0.217, 0.525, 0.910]),
Color('srgb', [0.677, 0.492, 0.093])
),
space='msh',
out_space='srgb'
)
Color.interpolate(
prepare_msh_divergent_interpolation(
Color('srgb', [0.085, 0.532, 0.201]),
Color('srgb', [0.758, 0.214, 0.233])
),
space='msh',
out_space='srgb'
)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment