Spaces:
Running on Zero
Running on Zero
| """ | |
| 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) | |