the_shape_of_words / engine /mappings.py
resakemal's picture
Change palette from HSV to OKLCH
6d17364
Raw
History Blame Contribute Delete
4.68 kB
"""
mappings.py v2 — affect -> geometry (+scale) and affect -> color, with empirical
grounding and a COLOR COVERAGE check.
Color couplings (corrected, evidence-based):
hue <- AROUSAL (blue/purple at low arousal -> red at high arousal)
[Wilms&Oberfeld: hue arousal rises blue->red; SER: anger~red,
sad/fear~blue-purple, happy~yellow-orange]
saturation <- AROUSAL (high arousal = more saturated) [+ intensity]
brightness <- VALENCE (pleasant = bright, sad = dim) [Frontiers 2025]
dominance modulates chroma/depth (commanding = deeper/stronger presence)
Geometry couplings (unchanged) + NEW:
scale <- DOMINANCE (commanding = large, timid = small) [PAD: dominance
= influence/presence; gives dominance a visible geometric voice]
"""
import numpy as np
import colorsys
import math
COOL_EDGE_EXPONENT = 1.6
def affect_to_geometry(valence, arousal, dominance):
threat = 1.0 - valence
# Spikiness is primarily ENERGY. Low valence only adds spikiness when there is
# also arousal (threat needs energy to feel sharp); a calm-sad beat should be
# soft/drooping, not spiky. So the threat term is gated by arousal.
spikiness = float(np.clip(0.62 * arousal + 0.45 * threat * arousal, 0, 1))
compactness = float(np.clip(0.35 + 0.40 * valence - 0.25 * arousal, 0, 1))
# segmentability also keyed to energy; threat contributes only with arousal.
segmentability = float(np.clip(0.18 + 0.45 * arousal + 0.18 * threat * arousal, 0, 1))
symmetry = float(np.clip(0.45 + 0.35 * dominance + 0.20 * valence - 0.30 * arousal, 0, 1))
# dominance -> scale (0.62 small/timid .. 1.25 large/commanding)
scale = float(np.clip(0.62 + 0.63 * dominance, 0.5, 1.3))
return {"spikiness": spikiness, "compactness": compactness,
"segmentability": segmentability, "symmetry": symmetry, "scale": scale}
def _lerp_hue(h0, h1, t):
"""Interpolate hue the SHORT way around the circle."""
d = (h1 - h0 + 0.5) % 1.0 - 0.5 # signed shortest delta in [-0.5,0.5]
return (h0 + d * t) % 1.0
def _lerp_hue_deg(h0, h1, t):
"""Interpolate an OKLCH hue (degrees) the SHORT way around the circle."""
d = (h1 - h0 + 180.0) % 360.0 - 180.0
return (h0 + d * t) % 360.0
def _oklch_to_srgb(L, C, H_deg):
"""OKLCH -> gamma sRGB (0..1 floats), clamped to gamut. Björn Ottosson's
OKLab matrices. OKLab's lightness is perceptually uniform, so equal L reads
as equal brightness across hues (HSV's value does not — yellow looks far
brighter than blue at the same 'value')."""
h = math.radians(H_deg)
a = C * math.cos(h)
b = C * math.sin(h)
l_ = L + 0.3963377774 * a + 0.2158037573 * b
m_ = L - 0.1055613458 * a - 0.0638541728 * b
s_ = L - 0.0894841775 * a - 1.2914855480 * b
l, m, s = l_ ** 3, m_ ** 3, s_ ** 3
r = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s
g = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s
bl = -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s
def _gamma(x):
x = 0.0 if x < 0.0 else 1.0 if x > 1.0 else x
return 12.92 * x if x <= 0.0031308 else 1.055 * (x ** (1 / 2.4)) - 0.055
return (_gamma(r), _gamma(g), _gamma(bl))
def affect_to_color(valence, arousal, dominance):
# Four hue anchors (matching emotion-color data) in OKLCH degrees,
# interpolated CIRCULARLY:
# calm+pleasant (lowA, highV) -> green (145°)
# joyful (highA, highV)-> yellow/orange(85°)
# angry (highA, lowV) -> red (29°)
# sad (lowA, lowV) -> blue (264°)
h_sad, h_calm = 264.0, 145.0 # arousal=0 edge: blue -> green (short way)
h_angry, h_joy = 29.0, 85.0 # arousal=1 edge: red -> orange/yellow
# Cool edge is curved (valence**1.6) so green only wins at genuinely high
# valence; without this, low-arousal sad beats (v~0.3) drift green not blue.
# Warm edge stays linear — anger/joy separate cleanly already.
low_a = _lerp_hue_deg(h_sad, h_calm, valence ** COOL_EDGE_EXPONENT) # bottom edge
high_a = _lerp_hue_deg(h_angry, h_joy, valence) # top edge
hue = _lerp_hue_deg(low_a, high_a, arousal)
# L (perceptual lightness) <- valence (pleasant = bright), dominance darkens.
# C (chroma) <- arousal (energized = vivid), small dominance boost.
L = float(np.clip(0.48 + 0.40 * valence - 0.10 * dominance, 0.40, 0.90))
C = float(np.clip(0.040 + 0.13 * arousal + 0.03 * dominance, 0.0, 0.18))
return _oklch_to_srgb(L, C, hue)
def hex_of(rgb):
return "#%02X%02X%02X" % tuple(int(round(c * 255)) for c in rgb)