""" renderer2.py — geometric-axis renderer, grounded in Huang (2020) preattentive shape space (segmentability, compactness, spikiness) plus a minor symmetry axis. LAYERS (for reference; only the GEOMETRIC layer is rendered & scored): - Affective layer (model judges from text): valence, arousal, dominance. -> drives COLOR (hidden) and is the bridge from language to geometry. - Geometric layer (RENDERED + SCORED): the axes below. GEOMETRIC AXES (all in [0,1]): spikiness - angularity: alternating radial spikes + sharp (non-curved) joins compactness - how tight/filled vs. spread+thin the form is (Huang dim 2; merges old 'roundness'+'weight'). High = fat/round/tight, low = thin/spread/elongated. segmentability - how much the shape reads as distinct lobes/parts vs. one blob (Huang dim 1, the most important). High = clear separate lobes via deep inward notches between bulges. symmetry - regularity (minor axis). High = regular; low = irregular jitter. Design goal (unchanged): each axis SMOOTH + MONOTONIC, so a 0.1 step is perceptible but proportionate. Verified by gradient sweeps. """ import numpy as np import hashlib SEG_LOBES_MAX = 5 # max number of lobes segmentability can carve def _stable_unit(n, key): h = hashlib.sha256(key.encode()).digest() return np.array([h[i % len(h)] / 255.0 for i in range(n)]) def geom_to_points(g, seed_key="x", N=420): """ Build a closed boundary as a dense (N,2) array in [0,1]^2 from geometric axes. We use a radial function r(theta) so all axes compose smoothly on one contour. Returns (points, lobe_centers_angles) — the latter for optional attribution. """ spk = g["spikiness"] cmp_ = g["compactness"] seg = g["segmentability"] sym = g["symmetry"] theta = np.linspace(0, 2 * np.pi, N, endpoint=False) # ---- base radius from compactness ---- # high compactness = larger, rounder base; low = smaller base (spread comes via elongation) base_r = 0.16 + 0.16 * cmp_ # ---- compactness also controls elongation (low compactness = thin/elongated) ---- # aspect: 1.0 at high compactness, up to ~2.2 elongation at low compactness aspect = 1.0 + (1.0 - cmp_) * 1.2 # elongate along x: scale radius by 1/sqrt for area-ish preservation feel elong = 1.0 / np.sqrt((np.cos(theta) ** 2) / aspect + (np.sin(theta) ** 2) * aspect) r = base_r * elong # ---- segmentability: carve `L` lobes via a raised-cosine that dips deep between lobes ---- L = int(round(1 + seg * (SEG_LOBES_MAX - 1))) # 1..SEG_LOBES_MAX lobes if L >= 2: # depth of inter-lobe notches grows with segmentability depth = 0.55 * seg lobe = (np.cos(L * theta) * 0.5 + 0.5) # 0..1, peaks = lobes # sharpen the valleys so lobes separate (power < 1 widens lobes, >1 narrows) lobe = lobe ** 1.5 r = r * (1 - depth) + r * depth * lobe # also boost outward at lobe peaks a touch for clearer separation r = r * (1 + 0.25 * seg * (lobe - 0.5)) # ---- spikiness: alternating radial spikes with SHARP (pointed) tips ---- # Triangle wave -> pointed tips. Calibrated so LOW spk is nearly smooth and # HIGH spk gives a few BOLD star points (solid body), not many thin spines. if spk > 0: spikes = int(round(5 + 4 * spk)) # 5..9 teeth (fewer = bolder) tri = (2.0 / np.pi) * np.arcsin(np.sin(spikes * theta)) # sharp linear ramps, [-1,1] # tip sharpness ramps from rounded (low spk) to pointed (high spk) peak = np.abs(tri) ** (1.0 + 1.4 * spk) tooth = peak * 2.0 - 1.0 # [-1,1] with sharp peaks # amplitude ramps from ~0 at low spk (quadratic) so 0.1-0.2 stay gentle amp = 0.34 * (spk ** 1.6) r = r * (1 + amp * tooth) # ---- symmetry: low symmetry adds smooth per-angle jitter (irregularity) ---- if sym < 1.0: # build smooth noise via a few random low-frequency sinusoids u = _stable_unit(6, seed_key) jitter = np.zeros_like(theta) for k in range(3): freq = 1 + k phase = u[k] * 2 * np.pi amp = u[k + 3] jitter += amp * np.sin(freq * theta + phase) jitter /= 3.0 r = r * (1 + (1.0 - sym) * 0.62 * jitter) r = np.maximum(r, 0.03) # ---- global scale (driven by dominance upstream; here a direct multiplier) ---- scale = g.get("scale", 1.0) r = r * scale x = 0.5 + r * np.cos(theta) y = 0.5 + r * np.sin(theta) pts = np.column_stack([x, y]) return pts def geom_to_points_segmented(segments, weights, seed_key="x", N=420): """ segments: list of geometry dicts (from affect_to_geometry), one per phrase. weights: list of floats (phrase lengths, unnormalised). Same length as segments. Returns (N,2) points array, same as geom_to_points. """ import numpy as np n = len(segments) total = sum(weights) norm_w = [w / total for w in weights] # Anchor angles clockwise from top (-π/2), each at midpoint of its arc anchors = [] cumulative = 0.0 for i, w in enumerate(norm_w): anchor = -np.pi/2 + 2*np.pi * (cumulative + w/2) anchors.append(anchor) cumulative += w # theta_render: same angular convention as geom_to_points (starts right, ccw). # All rendering math uses this — shapes are orientation-consistent with # single-essence shapes regardless of segment placement. theta = np.linspace(0, 2*np.pi, N, endpoint=False) # theta_anchor: offset by -π/2 so segment anchors are placed clockwise from # the top. Used ONLY for the interpolation lookup, not for rendering math. theta_anchor = theta - np.pi/2 def interp_at(theta_arr): """Return interpolated geometry values (arrays) across all theta.""" keys = ["spikiness", "compactness", "segmentability", "symmetry", "scale"] values = {k: np.zeros(len(theta_arr)) for k in keys} total_w = np.zeros(len(theta_arr)) for i, anchor in enumerate(anchors): # shortest angular distance diff = (theta_arr - anchor + np.pi) % (2*np.pi) - np.pi dist = np.abs(diff) # cosine bell weight, zero beyond π w = np.where(dist < np.pi, np.cos(dist * np.pi/2) + 1.0, 0.0) for k in keys: values[k] += w * segments[i][k] total_w += w total_w = np.maximum(total_w, 1e-9) return {k: values[k] / total_w for k in keys} geom = interp_at(theta_anchor) # interpolate using anchor-space angles # Radial function using per-theta geometry values spk = geom["spikiness"] cmp_ = geom["compactness"] seg = geom["segmentability"] sym = geom["symmetry"] base_r = 0.16 + 0.16 * cmp_ aspect = 1.0 + (1.0 - cmp_) * 1.2 elong = 1.0 / np.sqrt((np.cos(theta)**2) / aspect + (np.sin(theta)**2) * aspect) r = base_r * elong # Segmentability lobes — use mean lobe count (discrete, can't vary per-theta) mean_seg = float(np.mean(seg)) L = int(round(1 + mean_seg * (SEG_LOBES_MAX - 1))) if L >= 2: depth = 0.55 * seg lobe = np.cos(L * theta) * 0.5 + 0.5 lobe = lobe ** 1.5 r = r * (1 - depth) + r * depth * lobe r = r * (1 + 0.25 * seg * (lobe - 0.5)) # Spikiness — per-theta triangle wave mean_spk = float(np.mean(spk)) spikes = int(round(5 + 4 * mean_spk)) # tooth count from mean if mean_spk > 0: tri = (2.0 / np.pi) * np.arcsin(np.sin(spikes * theta)) peak = np.abs(tri) ** (1.0 + 1.4 * spk) tooth = peak * 2.0 - 1.0 amp = 0.34 * (spk ** 1.6) r = r * (1 + amp * tooth) # Symmetry jitter — per-theta irregularity u = _stable_unit(6, seed_key) jitter = np.zeros_like(theta) for k in range(3): phase = u[k] * 2 * np.pi jitter += u[k+3] * np.sin((1+k)*theta + phase) jitter /= 3.0 r = r * (1 + (1.0 - sym) * 0.62 * jitter) # Scale from mean dominance scale = float(np.mean(geom["scale"])) r = np.maximum(r, 0.03) * scale # Point placement in standard orientation (same as geom_to_points) x = 0.5 + r * np.cos(theta) y = 0.5 + r * np.sin(theta) return np.column_stack([x, y]) if __name__ == "__main__": import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt BASELINE = {"spikiness": 0.15, "compactness": 0.7, "segmentability": 0.1, "symmetry": 0.9, "scale": 1.0} axes_list = ["spikiness", "compactness", "segmentability", "symmetry", "scale"] steps = np.round(np.arange(0.0, 1.01, 0.1), 1) # scale maps [0,1] -> [0.5,1.3] for the sweep (matching dominance->scale range) def axis_value(d, val): if d == "scale": return 0.5 + val * 0.8 return float(val) fig, axes = plt.subplots(len(axes_list), len(steps), figsize=(len(steps) * 1.35, len(axes_list) * 1.5)) for row, d in enumerate(axes_list): for col, val in enumerate(steps): g = dict(BASELINE) g[d] = axis_value(d, val) pts = geom_to_points(g, seed_key=f"{d}") ax = axes[row, col] ax.add_patch(plt.Polygon(pts, closed=True, facecolor="#7F77DD", edgecolor="#26215C", lw=1.0, alpha=0.9)) ax.set_xlim(0, 1); ax.set_ylim(0, 1); ax.set_aspect("equal"); ax.axis("off") if row == 0: ax.set_title(f"{val}", fontsize=8) if col == 0: ax.text(-0.28, 0.5, d, rotation=90, va="center", ha="center", fontsize=9, transform=ax.transAxes, weight="bold") plt.suptitle("Perceptually-grounded geometric axes — each row varies ONE axis 0.0→1.0\n" "(spikiness, compactness, segmentability from Huang 2020; symmetry minor). " "Looking for smooth, even, monotonic steps.", fontsize=11, y=1.01) plt.tight_layout() plt.savefig("sweeps2.png", dpi=80, bbox_inches="tight") print("saved sweeps2.png")