"""The same metal, before and after intelligence. Two renderings of one stroke window: eye every segment in the same ink. This is what the disc looks like — what a person standing in front of the metal can ever see. inference every stretch between junctions coloured by the HIDDEN STATE the recovered machine assigns it. The colours are not painted on the metal; they are the reader's belief, computed by running the inferred grammar over the junction sequence (the observer's state set, collapsed by look-ahead). The metal is identical. The point of the pair: the structure was in the disc all along, in the relations between turns, and it becomes visible exactly at the moment a reader carries a model. No model, no pattern — the pattern is IN THE READING. Usage: python3 render_grammar.py ../plate/v3/g1 --out ../plate/v3/g1/pair.png """ from __future__ import annotations import argparse import json import math import os import numpy as np import grammar as G BG = (11, 13, 14) PLATE = (22, 24, 26) INK = (242, 244, 245) DIM_INK = (140, 146, 150) # one hue per hidden state bank (the four states sharing an exit angle share a # hue family; lightness separates them). 16 states -> 4 hues x 4 lightnesses. HUES = ((109, 215, 255), (255, 143, 109), (167, 255, 109), (224, 109, 255)) def state_colour(s): h = HUES[G.G_OUT[s]] f = 0.60 + 0.40 * (s // len(G.ALPHABET)) / 3.0 return tuple(int(c * f) for c in h) def viterbi_states(seq, p0, p1): """The single best hidden-state path under the recovered machine. All feasible transitions cost one input bit, so 'best' reduces to: a path that never dies. Standard DP with back-pointers; ties broken by state index, so the reading is deterministic. """ K = G.K INF = 1e18 n = len(seq) cost = [0.0 if G.G_OUT[s] == seq[0] else INF for s in range(K)] back = np.full((n, K), -1, dtype=np.int32) for i in range(1, n): want = seq[i] nxt = [INF] * K for s in range(K): if cost[s] >= INF: continue for p in (p0, p1): t = p[s] if G.G_OUT[t] != want: continue c = cost[s] + 1.0 if c < nxt[t]: nxt[t] = c back[i, t] = s if min(nxt) >= INF: # refuted here; restart the belief nxt = [2.0 + min(cost) if G.G_OUT[s] == want else INF for s in range(K)] cost = nxt path = np.full(n, -1, dtype=np.int32) path[-1] = int(np.argmin(cost)) for i in range(n - 1, 0, -1): b = back[i, path[i]] path[i - 1] = b if b >= 0 else path[i - 1] # forward-fill the restarts last = 0 for i in range(n): if path[i] < 0: path[i] = last last = path[i] return path def junction_positions(pts, chi_deg=20.0, min_mm=1.0): """Same detector as grammar.junctions_from_stroke, but keeps indices.""" d = np.diff(pts.astype("float64"), axis=0) L = np.hypot(d[:, 0], d[:, 1]) ang = np.arctan2(d[:, 1], d[:, 0]) turn = np.diff(ang) turn = (turn + math.pi) % (2.0 * math.pi) - math.pi idx, syms = [], [] travelled = 0.0 for i in range(len(turn)): travelled += L[i] if abs(turn[i]) > math.radians(chi_deg) and travelled >= min_mm: k = int(np.argmin([abs(turn[i] - a) for a in G.ALPHABET])) if abs(turn[i] - G.ALPHABET[k]) < math.radians(25.0): idx.append(i + 1) syms.append(k) travelled = 0.0 return idx, syms def render_pair(plate_dir, out, crop=None, scale=6, line=1, upto_mm=None): from PIL import Image, ImageDraw pts = np.load(os.path.join(plate_dir, "stroke.npy")) if upto_mm is not None: d = np.diff(pts.astype("float64"), axis=0) cum = np.cumsum(np.hypot(d[:, 0], d[:, 1])) n = int(np.searchsorted(cum, upto_mm)) + 1 pts = pts[:max(2, n)] idx, syms = junction_positions(pts) found = G.infer_machine(syms[:4000]) if len(syms) >= 400 else None states = viterbi_states(syms, found["p0"], found["p1"]) if found else None DISC_R = 200.0 if crop: cx, cy, cw, ch = crop x0, y0 = cx - cw * 0.5, cy - ch * 0.5 else: cw = ch = 2.0 * DISC_R x0, y0 = -DISC_R, -DISC_R W, H = int(cw * scale), int(ch * scale) def px(p): return ((p[0] - x0) * scale, (p[1] - y0) * scale) panels = [] for mode in ("eye", "inference"): im = Image.new("RGB", (W, H), BG) dr = ImageDraw.Draw(im) for rr, col in ((195.0, PLATE), (5.0, BG)): a, b = px((-rr, -rr)), px((rr, rr)) dr.ellipse([a[0], a[1], b[0], b[1]], fill=col) if mode == "eye" or states is None: CH = 200_000 for c0 in range(0, len(pts) - 1, CH): part = pts[c0:c0 + CH + 1] dr.line([px(p) for p in part], fill=INK, width=line) else: # spans between junctions, coloured by the state that WROTE them — # the state emitting at the span's closing junction bounds = [0] + idx + [len(pts) - 1] for j in range(len(bounds) - 1): a, b = bounds[j], bounds[j + 1] + 1 col = state_colour(int(states[min(j, len(states) - 1)])) \ if j < len(states) + 1 and states is not None else DIM_INK seg = pts[a:b] CH = 200_000 for c0 in range(0, len(seg) - 1, CH): part = seg[c0:c0 + CH + 1] dr.line([px(p) for p in part], fill=col, width=line + 1) panels.append(im) pad = max(6, W // 90) sheet = Image.new("RGB", (W * 2 + pad * 3, H + pad * 2), BG) sheet.paste(panels[0], (pad, pad)) sheet.paste(panels[1], (W + pad * 2, pad)) sheet.save(out) print("wrote %s (%d junctions, machine %s)" % (out, len(syms), "recovered" if found else "not attempted")) return len(syms) def main(): ap = argparse.ArgumentParser() ap.add_argument("plate_dir") ap.add_argument("--out", required=True) ap.add_argument("--crop", nargs=4, type=float, default=None, metavar=("CX", "CY", "W", "H")) ap.add_argument("--scale", type=int, default=6) ap.add_argument("--line", type=int, default=1) ap.add_argument("--upto-mm", type=float, default=None, help="only the first N millimetres of the stroke") args = ap.parse_args() render_pair(os.path.abspath(args.plate_dir), os.path.abspath(args.out), args.crop, args.scale, args.line, args.upto_mm) if __name__ == "__main__": main()