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