Last active
December 1, 2025 22:15
-
-
Save facelessuser/8c47f01ddf5dd2a9cdf61da74a3d1f5a to your computer and use it in GitHub Desktop.
Msh
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| """ | |
| 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