the_shape_of_words / engine /renderer.py
resakemal's picture
Initial push
320ef02
Raw
History Blame Contribute Delete
10.3 kB
"""
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")