137 lines
6.8 KiB
Python
137 lines
6.8 KiB
Python
#!/usr/bin/env python3
|
||
# build_field_viz.py
|
||
# Reads the REAL captured live field (predict_capture.npz) and bakes it into a
|
||
# self-contained HTML file you open in a browser. No mockup — this renders the
|
||
# actual 500 frames of the substrate the observer captured.
|
||
#
|
||
# Shows: all 6 channels (rho, ux, uy, sxx, syy, sxy) as 32x32 heatmaps, animated
|
||
# through the real frames, PLUS a 7th panel = per-tile linear-prediction error
|
||
# (where the field surprises the predictor). Play/pause/scrub.
|
||
|
||
import sys, io, json, base64
|
||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
|
||
import numpy as np
|
||
|
||
NPZ = "predict_capture.npz"
|
||
OUT = "field_viz.html"
|
||
TILES, CH = 32, 6
|
||
CH_NAMES = ["rho","ux","uy","sxx","syy","sxy"]
|
||
|
||
d = np.load(NPZ, allow_pickle=True)
|
||
F = d["frames"].astype(np.float32) # (T, 1024, 6)
|
||
C = d["cycles"]
|
||
T = F.shape[0]
|
||
print(f"Loaded {T} frames, cycles {C[0]}..{C[-1]}", flush=True)
|
||
|
||
Fg = F.reshape(T, TILES, TILES, CH) # (T, y, x, ch)
|
||
|
||
# Per-tile linear-prediction error field: |truth - (2*prev - prev2)| per tile, summed over channels
|
||
# aligned to frames 2..T-1
|
||
pred = 2*Fg[1:-1] - Fg[0:-2]
|
||
truth = Fg[2:]
|
||
err = np.sqrt(np.mean((pred-truth)**2, axis=3)) # (T-2, y, x)
|
||
# pad front so err index matches frame index
|
||
err_full = np.zeros((T, TILES, TILES), dtype=np.float32)
|
||
err_full[2:] = err
|
||
|
||
# Per-channel normalization for display (diverging around each channel's mean)
|
||
disp = np.zeros_like(Fg)
|
||
ranges = []
|
||
for c in range(CH):
|
||
ch = Fg[...,c]
|
||
mu = ch.mean(); sd = ch.std() + 1e-9
|
||
z = (ch - mu) / sd
|
||
z = np.clip(z, -3, 3) / 3.0 # -> [-1,1]
|
||
disp[...,c] = z
|
||
ranges.append((float(mu), float(sd)))
|
||
|
||
# error display: normalize to [0,1]
|
||
emax = np.percentile(err_full[2:], 99) + 1e-9
|
||
edisp = np.clip(err_full / emax, 0, 1)
|
||
|
||
# Quantize to uint8 to keep the HTML small: disp in [-1,1]->[0,255], edisp [0,1]->[0,255]
|
||
disp_q = ((disp*0.5+0.5)*255).astype(np.uint8) # (T,y,x,ch)
|
||
edisp_q = (edisp*255).astype(np.uint8) # (T,y,x)
|
||
|
||
# Pack as base64: frames as (T, ch, y, x) then err (T,y,x)
|
||
buf = disp_q.transpose(0,3,1,2).tobytes() # T*6*32*32
|
||
ebuf = edisp_q.tobytes() # T*32*32
|
||
b64 = base64.b64encode(buf).decode()
|
||
eb64 = base64.b64encode(ebuf).decode()
|
||
cyc_list = [int(x) for x in C]
|
||
|
||
html = f"""<!doctype html><html><head><meta charset="utf-8"><title>Khra'gixx live field</title>
|
||
<style>
|
||
body{{margin:0;background:#0a0a0f;color:#c8c8d0;font-family:ui-monospace,Menlo,monospace}}
|
||
.wrap{{max-width:1100px;margin:0 auto;padding:18px}}
|
||
h1{{font-size:15px;font-weight:600;color:#e6e6ee;margin:0 0 2px}}
|
||
.sub{{font-size:11px;color:#7a7a88;margin-bottom:14px}}
|
||
.grid{{display:grid;grid-template-columns:repeat(4,1fr);gap:14px}}
|
||
.panel{{background:#12121a;border:1px solid #22222e;border-radius:8px;padding:8px}}
|
||
.panel h2{{font-size:11px;margin:0 0 6px;color:#9a9aae;font-weight:600;letter-spacing:.04em}}
|
||
canvas{{width:100%;image-rendering:pixelated;border-radius:4px;background:#000;aspect-ratio:1}}
|
||
.err h2{{color:#ff9a6a}}
|
||
.ctl{{display:flex;align-items:center;gap:12px;margin:16px 0 6px}}
|
||
button{{background:#1c1c28;color:#d0d0dc;border:1px solid #33334a;border-radius:6px;padding:7px 14px;font:inherit;cursor:pointer}}
|
||
button:hover{{background:#26263a}}
|
||
input[type=range]{{flex:1;accent-color:#ff9a6a}}
|
||
.meta{{font-size:11px;color:#7a7a88}}
|
||
.cyc{{color:#ff9a6a;font-weight:600}}
|
||
.legend{{font-size:10px;color:#66667a;margin-top:8px;line-height:1.5}}
|
||
</style></head><body><div class="wrap">
|
||
<h1>Khra'gixx — the live field, rendered</h1>
|
||
<div class="sub">{T} real frames captured from port 5561 · cycles <span id="cr"></span> · 32×32 coarse grid · this is the substrate moving, not nine averages</div>
|
||
<div class="grid" id="panels"></div>
|
||
<div class="ctl">
|
||
<button id="play">⏸ pause</button>
|
||
<input type="range" id="scrub" min="0" max="{T-1}" value="0">
|
||
<span class="meta">frame <span id="fn">0</span>/{T-1} · cycle <span class="cyc" id="cyc"></span></span>
|
||
</div>
|
||
<div class="legend">
|
||
Six macroscopic channels + the linear-prediction error (orange). Blue↔red = each channel's own deviation (±3σ).
|
||
The <b style="color:#ff9a6a">error panel</b> lights up where the field's next state SURPRISES the constant-velocity predictor —
|
||
i.e. where something non-trivial is happening. Smooth dark error = predictable flow. Bright spots = the interesting bits.
|
||
Passive predict-test verdict: linear beats persistence by 21% — the field has real short-horizon dynamics.
|
||
</div></div>
|
||
<script>
|
||
const T={T}, N={TILES}, CH={CH}, names={json.dumps(CH_NAMES)}, cycles={json.dumps(cyc_list)};
|
||
const raw=Uint8Array.from(atob("{b64}"),c=>c.charCodeAt(0)); // T*CH*N*N
|
||
const eraw=Uint8Array.from(atob("{eb64}"),c=>c.charCodeAt(0)); // T*N*N
|
||
document.getElementById('cr').textContent=cycles[0]+"→"+cycles[T-1];
|
||
const panels=document.getElementById('panels'); const cvs=[];
|
||
function mk(title,cls){{const d=document.createElement('div');d.className='panel'+(cls?' '+cls:'');d.innerHTML='<h2>'+title+'</h2>';const c=document.createElement('canvas');c.width=N;c.height=N;d.appendChild(c);panels.appendChild(d);return c;}}
|
||
for(let c=0;c<CH;c++)cvs.push(mk(names[c]));
|
||
const ecv=mk('prediction error','err');
|
||
const ctxs=cvs.map(c=>c.getContext('2d')); const ectx=ecv.getContext('2d');
|
||
const imgs=cvs.map(()=>ctxs[0].createImageData(N,N)); const eimg=ectx.createImageData(N,N);
|
||
// diverging blue-white-red
|
||
function div(v){{ // v in [0,255] -> rgb
|
||
const t=(v/255)*2-1; // -1..1
|
||
if(t<0){{const a=-t;return[Math.round(30+ (1-a)*200),Math.round(60+(1-a)*180),Math.round(120+a*135)];}}
|
||
else{{return[Math.round(230),Math.round(230-t*180),Math.round(230-t*200)];}}
|
||
}}
|
||
function heat(v){{ // 0..255 -> black->orange->white
|
||
const t=v/255; const r=Math.min(255,t*3*255), g=Math.min(255,Math.max(0,(t-0.33)*3*255)), b=Math.min(255,Math.max(0,(t-0.66)*3*255));
|
||
return[r,g,b];
|
||
}}
|
||
function draw(f){{
|
||
for(let c=0;c<CH;c++){{const off=(f*CH+c)*N*N; const im=imgs[c];
|
||
for(let i=0;i<N*N;i++){{const [r,g,b]=div(raw[off+i]);im.data[i*4]=r;im.data[i*4+1]=g;im.data[i*4+2]=b;im.data[i*4+3]=255;}}
|
||
ctxs[c].putImageData(im,0,0);}}
|
||
const eoff=f*N*N; for(let i=0;i<N*N;i++){{const [r,g,b]=heat(eraw[eoff+i]);eimg.data[i*4]=r;eimg.data[i*4+1]=g;eimg.data[i*4+2]=b;eimg.data[i*4+3]=255;}}
|
||
ectx.putImageData(eimg,0,0);
|
||
document.getElementById('fn').textContent=f; document.getElementById('cyc').textContent=cycles[f];
|
||
document.getElementById('scrub').value=f;
|
||
}}
|
||
let f=0,playing=true;
|
||
const scrub=document.getElementById('scrub'),playb=document.getElementById('play');
|
||
scrub.oninput=()=>{{f=+scrub.value;draw(f);}};
|
||
playb.onclick=()=>{{playing=!playing;playb.textContent=playing?'⏸ pause':'▶ play';}};
|
||
function loop(){{if(playing){{f=(f+1)%T;draw(f);}}setTimeout(loop,60);}}
|
||
draw(0);loop();
|
||
</script></body></html>"""
|
||
|
||
with open(OUT,"w") as fp:
|
||
fp.write(html)
|
||
print(f"Wrote {OUT} ({len(html)//1024} KB). Open it in a browser.", flush=True)
|