""" 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)