our_knp/viz/app.html
int 1888412f31 Visualizer: stroke-feature network with spiking readout panel
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 01:58:22 +03:00

562 lines
34 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<meta charset="utf-8">
<title>Колонки AltAI</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Golos+Text:wght@400;500;600&family=Unbounded:wght@500;700&family=JetBrains+Mono:wght@400;600&display=swap">
<style>
/* Layout: instrument bench — a sticky control strip, then three bays: input → raster → neuron columns, and two reference panels below. */
:root {
--bg: #eef0f2;
--panel: #fbfcfc;
--fg: #1b2530;
--muted: #5d6b78;
--line: #d3d9df;
--spike: #e08a12; /* a spike, a positive weight */
--spike-soft: #f6dfb8;
--inhib: #2f6fb3; /* a negative weight, potential */
--inhib-soft: #cfe0f2;
--ok: #2f8a4e;
--bad: #c2412d;
--cell-off: #e3e7eb;
--ink: #111;
--paper: #fff;
--f-display: "Unbounded", "Segoe UI", system-ui, sans-serif;
--f-body: "Golos Text", "Segoe UI", system-ui, sans-serif;
--f-mono: "JetBrains Mono", ui-monospace, Consolas, monospace;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #12171c; --panel: #1a2128; --fg: #e3e8ec; --muted: #93a1ad; --line: #2c3640;
--spike: #f2a33a; --spike-soft: #5a3f1a; --inhib: #6aa6e6; --inhib-soft: #1e3a58;
--ok: #5cc17d; --bad: #ec6f5a; --cell-off: #242d36; --ink: #111; --paper: #fff;
color-scheme: dark;
}
}
:root[data-theme="dark"] {
--bg: #12171c; --panel: #1a2128; --fg: #e3e8ec; --muted: #93a1ad; --line: #2c3640;
--spike: #f2a33a; --spike-soft: #5a3f1a; --inhib: #6aa6e6; --inhib-soft: #1e3a58;
--ok: #5cc17d; --bad: #ec6f5a; --cell-off: #242d36; --ink: #111; --paper: #fff;
color-scheme: dark;
}
* { box-sizing: border-box; }
body { background: var(--bg); color: var(--fg); font: 15px/1.5 var(--f-body); }
.wrap { max-width: 1240px; margin: 0 auto; padding-inline: 16px; padding-block: 20px 48px; display: flex; flex-direction: column; gap: 20px; }
h1 { font: 700 clamp(22px, 3vw, 30px)/1.1 var(--f-display); margin: 0; letter-spacing: -0.01em; }
h2 { font: 500 17px/1.2 var(--f-display); margin: 0; text-wrap: balance; }
.lead { color: var(--muted); max-width: 70ch; margin: 6px 0 0; }
.label { font: 600 11px/1 var(--f-mono); text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted); }
.mono { font-family: var(--f-mono); font-variant-numeric: tabular-nums; }
header { display: flex; flex-wrap: wrap; align-items: end; justify-content: space-between; gap: 16px; }
.seg { display: inline-flex; border: 1px solid var(--line); border-radius: 8px; overflow: hidden; background: var(--panel); flex-wrap: wrap; }
.seg button { font: 500 13px var(--f-body); color: var(--fg); background: transparent; border: 0; padding: 8px 12px; cursor: pointer; }
.seg button[aria-pressed="true"] { background: var(--fg); color: var(--bg); }
.seg button:focus-visible, button:focus-visible, input:focus-visible { outline: 2px solid var(--spike); outline-offset: 2px; }
.panel { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 16px; min-width: 0; }
.bench { display: grid; grid-template-columns: 220px 220px minmax(0, 1fr); gap: 16px; }
@media (max-width: 960px) { .bench { grid-template-columns: 1fr 1fr; } .bench .neurons { grid-column: 1 / -1; } }
@media (max-width: 520px) { .bench { grid-template-columns: 1fr; } }
.bay { display: flex; flex-direction: column; gap: 10px; min-width: 0; }
canvas.pix { image-rendering: pixelated; width: 100%; max-width: 100%; aspect-ratio: 1; border-radius: 6px; display: block; }
#draw { background: var(--ink); touch-action: none; cursor: crosshair; }
.row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
button.btn { font: 500 13px var(--f-body); border: 1px solid var(--line); background: var(--panel); color: var(--fg); padding: 6px 12px; border-radius: 6px; cursor: pointer; }
button.btn.primary { background: var(--spike); border-color: var(--spike); color: #1b1206; }
.gallery { display: flex; gap: 6px; overflow-x: auto; padding-bottom: 6px; }
.gallery button { flex: 0 0 auto; padding: 0; border: 2px solid transparent; border-radius: 5px; background: none; cursor: pointer; position: relative; }
.gallery button[aria-current="true"] { border-color: var(--spike); }
.gallery canvas { width: 40px; height: 40px; image-rendering: pixelated; display: block; border-radius: 3px; }
.gallery .tag { position: absolute; right: 1px; bottom: 0; font: 600 10px var(--f-mono); color: #fff; text-shadow: 0 0 2px #000; }
.timeline { display: flex; flex-direction: column; gap: 6px; }
.ticks { display: grid; grid-template-columns: repeat(15, 1fr); gap: 2px; }
.ticks span { height: 8px; border-radius: 2px; background: var(--cell-off); }
.ticks span.active { background: var(--inhib-soft); }
.ticks span.now { background: var(--spike); }
input[type="range"] { width: 100%; accent-color: var(--spike); }
.answer { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; }
.answer .big { font: 700 44px/1 var(--f-display); }
.verdict { font: 600 12px var(--f-mono); padding: 3px 8px; border-radius: 99px; }
.verdict.ok { color: var(--ok); border: 1px solid var(--ok); }
.verdict.bad { color: var(--bad); border: 1px solid var(--bad); }
.verdict.none { color: var(--muted); border: 1px solid var(--line); }
.grid-wrap { display: grid; grid-template-columns: 22px minmax(0, 1fr) 90px; gap: 0 8px; align-items: center; }
.grid-wrap .dg { font: 600 13px var(--f-mono); text-align: right; }
#ngrid { width: 100%; display: block; grid-row: 1 / span 10; grid-column: 2; }
.vote { display: flex; align-items: center; gap: 6px; height: 100%; }
.vote i { display: block; height: 10px; background: var(--spike); border-radius: 2px; min-width: 1px; }
.vote b { font: 400 12px var(--f-mono); color: var(--muted); }
.legend { display: flex; gap: 14px; flex-wrap: wrap; font-size: 12px; color: var(--muted); }
.legend span::before { content: ""; display: inline-block; width: 10px; height: 10px; border-radius: 2px; margin-right: 5px; vertical-align: -1px; background: var(--c); }
.two { display: grid; grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr); gap: 16px; }
@media (max-width: 960px) { .two { grid-template-columns: 1fr; } }
.maps { display: grid; grid-template-columns: repeat(auto-fill, minmax(56px, 1fr)); gap: 4px; }
.maps canvas { width: 100%; aspect-ratio: 1; image-rendering: pixelated; border-radius: 3px; display: block; }
.maps canvas.win { outline: 2px solid var(--spike); outline-offset: 1px; }
.mapsblock { display: flex; flex-direction: column; gap: 6px; }
.scroll { overflow-x: auto; }
table.conf { border-collapse: collapse; font: 12px var(--f-mono); font-variant-numeric: tabular-nums; }
table.conf th, table.conf td { width: 34px; height: 26px; text-align: center; padding: 0; }
table.conf th { color: var(--muted); font-weight: 600; }
table.conf td { color: var(--fg); }
.note { font-size: 13px; color: var(--muted); max-width: 70ch; margin: 0; }
.stack { display: flex; flex-direction: column; gap: 12px; }
.kpi { display: flex; gap: 20px; flex-wrap: wrap; }
.kpi div { display: flex; flex-direction: column; gap: 2px; }
.kpi strong { font: 600 22px var(--f-mono); }
.rgrid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 16px; }
@media (max-width: 760px) { .rgrid { grid-template-columns: 1fr; } }
.fmaps { display: grid; grid-template-columns: repeat(8, minmax(0, 1fr)); gap: 4px; max-width: 420px; }
.fmaps canvas { width: 100%; aspect-ratio: 1; image-rendering: pixelated; border-radius: 3px; display: block; }
.rvotes { display: grid; grid-template-columns: 22px minmax(0, 1fr) 36px; gap: 4px 8px; align-items: center; }
.rvotes .dg { font: 600 13px var(--f-mono); text-align: right; }
.rvotes i { display: block; height: 12px; background: var(--spike); border-radius: 2px; min-width: 1px; }
.rvotes i.win { background: var(--ok); }
.rvotes b { font: 400 12px var(--f-mono); color: var(--muted); }
@media (prefers-reduced-motion: reduce) { * { transition: none !important; } }
</style>
<div class="wrap">
<header>
<div>
<h1>Колонки AltAI</h1>
<p class="lead">Как сеть mnist-learn на нейронах AltAI узнаёт цифру. Картинка превращается в спайки, 784 пикселя заряжают входные нейроны, и первая колонка, дошедшая до порога, голосует за свою цифру. Кнопки сдвига двигают картинку на пиксель: так видно, насколько сеть держится за точное положение цифры.</p>
</div>
<div class="stack" style="gap:6px">
<span class="label">Сеть</span>
<div class="seg" id="models" role="group" aria-label="Сеть"></div>
</div>
</header>
<section class="bench">
<div class="panel bay">
<span class="label">1 · Картинка</span>
<canvas id="draw" class="pix" width="280" height="280" aria-label="Цифра; можно нарисовать мышкой или пальцем"></canvas>
<div class="row">
<button class="btn" id="clear">Очистить</button>
<span class="note" id="srcnote">Нарисуйте цифру</span>
</div>
<p class="note">Рисунок уменьшается до 28×28 и перерисовывается тонкой линией, как в MNIST: размер и толщина вашего штриха не важны.</p>
<div class="row" role="group" aria-label="Сдвинуть картинку на 1 пиксель">
<span class="label">Сдвиг</span>
<button class="btn" id="sh-left" aria-label="Влево">←</button>
<button class="btn" id="sh-right" aria-label="Вправо">→</button>
<button class="btn" id="sh-up" aria-label="Вверх">↑</button>
<button class="btn" id="sh-down" aria-label="Вниз">↓</button>
<span class="mono note" id="shiftlabel">0, 0</span>
</div>
<span class="label">Тестовые картинки MNIST</span>
<div class="gallery" id="gallery"></div>
</div>
<div class="panel bay">
<span class="label">2 · Спайки растра</span>
<canvas id="raster" class="pix" width="28" height="28"></canvas>
<div class="timeline">
<div class="row" style="justify-content:space-between">
<span class="mono" id="steplabel">шаг 0 из 14</span>
<button class="btn primary" id="play">▶ Пуск</button>
</div>
<input type="range" id="step" min="0" max="14" value="14" aria-label="Шаг симуляции">
<div class="ticks" id="ticks"></div>
<p class="note">Шаги 0–9: пиксели шлют спайки, яркий чаще. Шаги 10–14: тишина.</p>
</div>
</div>
<div class="panel bay neurons">
<div class="row" style="justify-content:space-between">
<span class="label" id="nlabel">3 · Входные нейроны</span>
<div class="answer">
<span class="label">Ответ</span>
<span class="big mono" id="answer">–</span>
<span class="verdict none" id="verdict">нет ответа</span>
</div>
</div>
<div class="grid-wrap" id="gridwrap"></div>
<div class="legend">
<span style="--c: var(--inhib)">заряд до порога</span>
<span style="--c: var(--spike)">спайк на этом шаге</span>
<span style="--c: var(--spike-soft)">спайкал раньше</span>
<span style="--c: var(--muted)">справа: голоса колонки</span>
</div>
<p class="note" id="allnets"></p>
<p class="note" id="inhibnote"></p>
</div>
</section>
<section class="panel stack" id="rbay" hidden>
<div class="row" style="justify-content:space-between">
<span class="label">4 · Сеть штрихов: детекторы штрихов → пулинг → 10 выходных нейронов</span>
<div class="answer">
<span class="label">Ответ</span>
<span class="big mono" id="ranswer">–</span>
<span class="verdict none" id="rverdict">нет ответа</span>
</div>
</div>
<p class="note">Другая сеть, не колонки. 16 детекторов (8 наклонов штриха × 2 толщины) смотрят на всю картинку; каждая карта сжимается до 7×7 клеток, где остаётся самый сильный отклик. Эти 784 значения спайкают так же, как пиксели, а 10 выходных нейронов AltAI считают спайки с целыми весами. Веса выхода обучены вне чипа на сдвинутых, повёрнутых и утолщённых копиях MNIST.</p>
<div class="rgrid">
<div class="stack" style="gap:6px">
<span class="label">Карты штрихов, 7×7</span>
<div class="fmaps" id="fmaps"></div>
</div>
<div class="stack" style="gap:6px">
<span class="label">Спайки выходных нейронов за 15 шагов</span>
<div id="rvotes" class="rvotes"></div>
</div>
</div>
<p class="note mono" id="racc"></p>
</section>
<section class="two">
<div class="panel stack">
<div class="row" style="justify-content:space-between">
<h2>Чему научилась колонка</h2>
<div class="seg" id="digits" role="group" aria-label="Цифра"></div>
</div>
<p class="note">Каждый квадрат — веса одного нейрона колонки к 784 пикселям. Оранжевое возбуждает нейрон, синее тормозит. Благодаря разбросу стартовых весов каждый нейрон колонки учит свой вариант написания.</p>
<div id="mapblocks" class="stack"></div>
</div>
<div class="panel stack">
<h2>Весь тест: 10 000 картинок</h2>
<div class="kpi" id="kpi"></div>
<div class="scroll"><table class="conf" id="conf"></table></div>
<p class="note">Строка — верная цифра, столбец — ответ сети, «–» — сеть промолчала. Диагональ — верные ответы. Посчитано симулятором с теми же весами и порогами, что в KNP (расхождение с KNP около 0,1%).</p>
</div>
</section>
</div>
<script>
const DATA = /*DATA*/null;
const STEPS = 15, ACTIVE = 10, C = 10, PIX = 784;
const css = n => getComputedStyle(document.documentElement).getPropertyValue(n).trim();
const $ = id => document.getElementById(id);
function decode(b64, Type) { const s = atob(b64), u = new Uint8Array(s.length); for (let i = 0; i < s.length; i++) u[i] = s.charCodeAt(i); return new Type(u.buffer); }
const models = DATA.models.map(m => ({ ...m, W: decode(m.w, Int8Array) }));
const gal = { ...DATA.gallery, px: decode(DATA.gallery.pixels, Uint8Array) };
let model = models[models.length - 1], image = null, imageLabel = null, trace = null, step = STEPS - 1, digit = 0, timer = null, firstWinner = -1;
function hexRGB(h) { const v = parseInt(h.slice(1), 16); return [v >> 16 & 255, v >> 8 & 255, v & 255]; }
function mix(a, b, t) { return a.map((x, i) => Math.round(x + (b[i] - x) * t)); }
// --- simulation (one image, from rest; mirrors altai_sim.py) ---
function simulate(img, m) {
const n = m.thr.length, col = m.col, W = m.W;
const st = new Float32Array(PIX), raster = [];
for (let t = 0; t < STEPS; t++) {
const r = new Uint8Array(PIX);
if (t < ACTIVE) for (let p = 0; p < PIX; p++) { st[p] += Math.fround(img[p] * Math.fround(1 / 255)); if (st[p] >= 1) { r[p] = 1; st[p] -= 1; } }
raster.push(r);
}
let pot = new Float64Array(n), prevR = new Uint8Array(PIX), prevF = new Uint8Array(n);
const votes = new Int32Array(C), everFired = new Uint8Array(n), out = [];
for (let t = 0; t < STEPS; t++) {
for (let c = 0; c < C; c++) { for (let k = 0; k < col; k++) if (prevF[c * col + k]) { votes[c]++; break; } }
let kFired = 0; for (let j = 0; j < n; j++) kFired += prevF[j];
const fired = new Uint8Array(n);
for (let j = 0; j < n; j++) {
let v = Math.round(pot[j]);
const row = j * PIX;
for (let p = 0; p < PIX; p++) if (prevR[p]) v += W[row + p];
if (m.inhib && kFired) v -= m.inhib * (kFired - prevF[j]);
if (v >= m.thr[j]) { fired[j] = 1; v = 0; }
pot[j] = Math.max(v, 0);
}
for (let j = 0; j < n; j++) if (fired[j]) everFired[j] = 1;
out.push({ raster: raster[t], pot: Float64Array.from(pot), fired, ever: Uint8Array.from(everFired), votes: Int32Array.from(votes) });
prevR = raster[t]; prevF = fired;
}
return out;
}
function argmax(v) { let b = -1, bv = 0; for (let i = 0; i < v.length; i++) if (v[i] > bv) { bv = v[i]; b = i; } return b; }
// --- drawing canvas: strokes are re-rendered MNIST-style: fit into 20×20 with a fixed ~2.5 px pen, centre of mass at 14,14 ---
const draw = $("draw"), dctx = draw.getContext("2d");
const MNIST_PEN = 2.5; // stroke width of MNIST digits in 28×28 pixels
let drawing = false, last = null, strokes = [];
function resetDraw() { dctx.fillStyle = "#000"; dctx.fillRect(0, 0, 280, 280); strokes = []; }
function pos(e) { const r = draw.getBoundingClientRect(); return [(e.clientX - r.left) * 280 / r.width, (e.clientY - r.top) * 280 / r.height]; }
draw.addEventListener("pointerdown", e => { drawing = true; last = pos(e); draw.setPointerCapture(e.pointerId); if (imageLabel !== null || !strokes.length) { resetDraw(); imageLabel = null; markGallery(-1); } strokes.push([last]); stroke(last); });
draw.addEventListener("pointermove", e => { if (drawing) { const p = pos(e); strokes[strokes.length - 1].push(p); stroke(p); } });
draw.addEventListener("pointerup", () => { drawing = false; fromDrawing(); });
function stroke(p) { dctx.strokeStyle = "#fff"; dctx.lineWidth = 16; dctx.lineCap = dctx.lineJoin = "round"; dctx.beginPath(); dctx.moveTo(...last); dctx.lineTo(...p); dctx.stroke(); last = p; }
function fromDrawing() {
const pts = strokes.flat();
if (!pts.length) return;
const xs = pts.map(p => p[0]), ys = pts.map(p => p[1]);
const x0 = Math.min(...xs), x1 = Math.max(...xs), y0 = Math.min(...ys), y1 = Math.max(...ys);
const s = (20 - MNIST_PEN) / Math.max(x1 - x0, y1 - y0, 20);
const small = document.createElement("canvas"); small.width = small.height = 28;
const sctx = small.getContext("2d");
sctx.fillStyle = "#000"; sctx.fillRect(0, 0, 28, 28);
sctx.strokeStyle = "#fff"; sctx.lineWidth = MNIST_PEN; sctx.lineCap = sctx.lineJoin = "round";
const tx = x => 14 + (x - (x0 + x1) / 2) * s, ty = y => 14 + (y - (y0 + y1) / 2) * s;
for (const st of strokes) {
sctx.beginPath(); sctx.moveTo(tx(st[0][0]), ty(st[0][1]));
for (const p of st.length > 1 ? st : [st[0], st[0]]) sctx.lineTo(tx(p[0]), ty(p[1]));
sctx.stroke();
}
let d = sctx.getImageData(0, 0, 28, 28).data, sx = 0, sy = 0, sm = 0;
for (let i = 0; i < PIX; i++) { const v = d[i * 4]; sx += (i % 28) * v; sy += Math.floor(i / 28) * v; sm += v; }
const dx = Math.round(14 - sx / sm), dy = Math.round(14 - sy / sm);
const img = new Uint8Array(PIX);
for (let y = 0; y < 28; y++) for (let x = 0; x < 28; x++) { const sx2 = x - dx, sy2 = y - dy; if (sx2 >= 0 && sx2 < 28 && sy2 >= 0 && sy2 < 28) img[y * 28 + x] = d[(sy2 * 28 + sx2) * 4]; }
setImage(img, null, "Ваша цифра, приведена к виду MNIST");
}
$("clear").onclick = () => { resetDraw(); imageLabel = null; markGallery(-1); $("srcnote").textContent = "Нарисуйте цифру"; };
function showOnDraw(img) {
const t = document.createElement("canvas"); t.width = t.height = 28; const c = t.getContext("2d"), id = c.createImageData(28, 28);
for (let i = 0; i < PIX; i++) { id.data.set([img[i], img[i], img[i], 255], i * 4); }
c.putImageData(id, 0, 0); dctx.imageSmoothingEnabled = false; dctx.drawImage(t, 0, 0, 280, 280);
}
// --- gallery ---
function galImg(i) { return gal.px.subarray(i * PIX, (i + 1) * PIX); }
function thumb(img, cv) {
const c = cv.getContext("2d"), id = c.createImageData(28, 28);
for (let i = 0; i < PIX; i++) id.data.set([img[i], img[i], img[i], 255], i * 4);
c.putImageData(id, 0, 0);
}
gal.labels.forEach((lab, i) => {
const b = document.createElement("button"); b.setAttribute("aria-label", `Картинка №${gal.index[i]}, цифра ${lab}`);
const cv = document.createElement("canvas"); cv.width = cv.height = 28; thumb(galImg(i), cv);
const tag = document.createElement("span"); tag.className = "tag"; tag.textContent = lab;
b.append(cv, tag); b.onclick = () => { markGallery(i); showOnDraw(galImg(i)); setImage(galImg(i), lab, `Тест №${gal.index[i]}, цифра ${lab}`); };
$("gallery").append(b);
});
function markGallery(i) { [...$("gallery").children].forEach((b, k) => b.setAttribute("aria-current", k === i)); }
// --- model switch ---
models.forEach(m => {
const b = document.createElement("button"); b.textContent = m.name; b.onclick = () => { model = m; renderModels(); recompute(); renderMaps(); renderConf(); };
$("models").append(b);
});
function renderModels() { [...$("models").children].forEach((b, i) => b.setAttribute("aria-pressed", models[i] === model)); }
for (let d = 0; d < C; d++) { const b = document.createElement("button"); b.textContent = d; b.onclick = () => { digit = d; renderDigits(); renderMaps(); }; $("digits").append(b); }
function renderDigits() { [...$("digits").children].forEach((b, i) => b.setAttribute("aria-pressed", i === digit)); }
// --- timeline ---
for (let t = 0; t < STEPS; t++) { const s = document.createElement("span"); if (t < ACTIVE) s.className = "active"; $("ticks").append(s); }
$("step").oninput = e => { stop(); step = +e.target.value; renderStep(); };
$("play").onclick = () => timer ? stop() : play();
function play() {
if (step >= STEPS - 1) step = -1;
$("play").textContent = "❚❚ Пауза";
timer = setInterval(() => { step++; renderStep(); if (step >= STEPS - 1) stop(); }, matchMedia("(prefers-reduced-motion: reduce)").matches ? 900 : 450);
}
function stop() { clearInterval(timer); timer = null; $("play").textContent = "▶ Пуск"; }
// --- shift: the source image moves by whole pixels, empty edge fills with black ---
let baseImage = null, shift = [0, 0];
function shifted(img, dx, dy) {
const out = new Uint8Array(PIX);
for (let y = 0; y < 28; y++) for (let x = 0; x < 28; x++) { const sx = x - dx, sy = y - dy; if (sx >= 0 && sx < 28 && sy >= 0 && sy < 28) out[y * 28 + x] = img[sy * 28 + sx]; }
return out;
}
function applyShift() {
if (!baseImage) return;
image = shifted(baseImage, ...shift);
$("shiftlabel").textContent = `${shift[0]}, ${shift[1]}`;
showOnDraw(image); recompute(); if (!timer) { step = -1; play(); }
}
[["sh-left", -1, 0], ["sh-right", 1, 0], ["sh-up", 0, -1], ["sh-down", 0, 1]].forEach(([id, dx, dy]) => {
$(id).onclick = () => { shift = [Math.max(-4, Math.min(4, shift[0] + dx)), Math.max(-4, Math.min(4, shift[1] + dy))]; applyShift(); };
});
function setImage(img, label, note) {
baseImage = img; image = img; imageLabel = label; shift = [0, 0]; $("shiftlabel").textContent = "0, 0";
$("srcnote").textContent = note; recompute(); if (!timer) { step = -1; play(); }
}
function renderAllNets() {
const mark = a => imageLabel === null || a < 0 ? "" : a === imageLabel ? " ✓" : " ✗";
const parts = models.map(m => {
const a = argmax((m === model ? trace : simulate(image, m))[STEPS - 1].votes);
return `${m.name} — ${a < 0 ? "нет ответа" : a}${mark(a)}`;
});
if (R) { const a = readoutRun(image).answer; parts.push(`${R.name} — ${a < 0 ? "нет ответа" : a}${mark(a)}`); }
$("allnets").textContent = parts.length > 1 ? "Итог всех сетей: " + parts.join(" · ") : "";
}
// --- stroke features + spiking readout (mirrors features/stroke_features.py and readout/spiking_readout.py) ---
const R = DATA.readout || null;
function strokeFeatures(img) {
const K = R.kernels, nk = K.length, ks = K[0].length, h = ks >> 1, out = new Uint8Array(nk * 49);
const x = Float32Array.from(img, v => Math.fround(v / 255));
for (let k = 0; k < nk; k++) {
const resp = new Float32Array(PIX);
for (let y = 0; y < 28; y++) for (let xx = 0; xx < 28; xx++) {
let s = 0;
for (let dy = 0; dy < ks; dy++) { const yy = y + dy - h; if (yy < 0 || yy > 27) continue;
for (let dx = 0; dx < ks; dx++) { const xs = xx + dx - h; if (xs >= 0 && xs < 28) s += K[k][dy][dx] * x[yy * 28 + xs]; } }
resp[y * 28 + xx] = Math.max(s, 0);
}
// max pooling 5×5, stride 4, padding 1 → 7×7
for (let py = 0; py < 7; py++) for (let px = 0; px < 7; px++) {
let m = 0;
for (let dy = 0; dy < 5; dy++) for (let dx = 0; dx < 5; dx++) {
const yy = 4 * py + dy - 1, xs = 4 * px + dx - 1;
if (yy >= 0 && yy < 28 && xs >= 0 && xs < 28) m = Math.max(m, resp[yy * 28 + xs]);
}
out[k * 49 + py * 7 + px] = Math.floor(Math.min(255, m * 255 / R.scale));
}
}
return out;
}
function readoutRun(img) {
const f = strokeFeatures(img), n = f.length, W = R.w, C = 10;
const st = new Float32Array(n), inc = Float32Array.from(f, v => Math.fround(v * Math.fround(1 / 255)));
const pot = new Float64Array(C), count = new Int32Array(C), first = new Int32Array(C).fill(STEPS);
for (let t = 0; t < STEPS; t++) {
if (t < ACTIVE) {
for (let c = 0; c < C; c++) pot[c] += W[n][c];
for (let i = 0; i < n; i++) { st[i] = Math.fround(st[i] + inc[i]); if (st[i] >= 1) { st[i] = Math.fround(st[i] - 1); for (let c = 0; c < C; c++) pot[c] += W[i][c]; } }
}
for (let c = 0; c < C; c++) { if (pot[c] >= R.thr) { count[c]++; if (first[c] === STEPS) first[c] = t; pot[c] = 0; } pot[c] = Math.max(pot[c], 0); }
}
let best = -1, bs = -Infinity;
for (let c = 0; c < C; c++) { const s = count[c] * (STEPS + 1) - first[c]; if (s > bs) { bs = s; best = c; } }
if (Math.max(...count) === 0) best = -1;
return { features: f, count, answer: best };
}
function renderReadout() {
if (!R || !image) return;
const r = readoutRun(image), box = $("fmaps"), on = hexRGB(css("--spike")), off = hexRGB(css("--cell-off"));
if (!box.children.length) for (let k = 0; k < 16; k++) { const cv = document.createElement("canvas"); cv.width = cv.height = 7; box.append(cv); }
[...box.children].forEach((cv, k) => {
const c = cv.getContext("2d"), id = c.createImageData(7, 7);
for (let i = 0; i < 49; i++) id.data.set([...mix(off, on, r.features[k * 49 + i] / 255), 255], i * 4);
c.putImageData(id, 0, 0);
cv.title = `${["0°", "22°", "45°", "67°", "90°", "112°", "135°", "157°"][k % 8]}, ${k < 8 ? "тонкий" : "толстый"} штрих`;
});
const mx = Math.max(1, ...r.count);
$("rvotes").innerHTML = [...r.count].map((v, d) => `<span class="dg">${d}</span><i class="${d === r.answer ? "win" : ""}" style="width:${100 * v / mx}%"></i><b>${v}</b>`).join("");
$("ranswer").textContent = r.answer < 0 ? "–" : r.answer;
const vd = $("rverdict");
if (r.answer < 0) { vd.className = "verdict none"; vd.textContent = "нет ответа"; }
else if (imageLabel === null) { vd.className = "verdict none"; vd.textContent = "итог"; }
else if (r.answer === imageLabel) { vd.className = "verdict ok"; vd.textContent = "верно"; }
else { vd.className = "verdict bad"; vd.textContent = `ошибка, это ${imageLabel}`; }
}
if (R) {
$("rbay").hidden = false;
$("racc").textContent = `Весь тест MNIST: ${(100 * R.accuracy).toFixed(1).replace(".", ",")}% верных ответов (симулятор, те же веса и правила нейронов, что в экспериментах).`;
}
function recompute() {
if (!image) return;
trace = simulate(image, model);
renderAllNets();
renderReadout();
firstWinner = -1;
for (const s of trace) { const j = s.fired.indexOf(1); if (j >= 0) { firstWinner = j; break; } }
digit = firstWinner >= 0 ? Math.floor(firstWinner / model.col) : (imageLabel ?? digit);
buildGrid(); renderDigits(); renderMaps(); renderStep();
}
// --- neuron grid ---
let ngrid, nctx;
function buildGrid() {
const gw = $("gridwrap"); gw.innerHTML = "";
ngrid = document.createElement("canvas"); ngrid.id = "ngrid";
const cw = Math.max(6, Math.min(26, Math.floor(640 / model.col)));
ngrid.width = model.col * cw; ngrid.height = C * 26; ngrid.style.aspectRatio = `${ngrid.width} / ${ngrid.height}`;
nctx = ngrid.getContext("2d"); ngrid.dataset.cw = cw;
for (let d = 0; d < C; d++) {
const l = document.createElement("span"); l.className = "dg"; l.textContent = d; l.style.gridRow = d + 1; l.style.gridColumn = 1;
const v = document.createElement("div"); v.className = "vote"; v.style.gridRow = d + 1; v.style.gridColumn = 3; v.innerHTML = "<i></i><b>0</b>";
gw.append(l, v);
}
gw.append(ngrid);
$("nlabel").textContent = `3 · Входные нейроны: 10 колонок × ${model.col}`;
$("inhibnote").textContent = model.inhib
? "Торможение включено: спайк любого нейрона на следующем шаге сбрасывает заряд всех остальных. Отвечает самый похожий на картинку нейрон."
: "Торможения нет: нейроны разных колонок заряжаются независимо, голос колонки засчитывается, если сработал хоть один её нейрон.";
}
function renderStep() {
$("step").value = Math.max(step, 0);
const t = Math.max(step, 0);
[...$("ticks").children].forEach((s, i) => s.classList.toggle("now", i === t));
$("steplabel").textContent = `шаг ${t} из ${STEPS - 1}`;
if (!trace) return;
const s = trace[t];
// raster
const rc = $("raster").getContext("2d"), id = rc.createImageData(28, 28);
const off = hexRGB(css("--cell-off")), sp = hexRGB(css("--spike")), soft = hexRGB(css("--inhib-soft"));
for (let p = 0; p < PIX; p++) id.data.set([...(s.raster[p] ? sp : image[p] > 0 ? mix(off, soft, image[p] / 255) : off), 255], p * 4);
rc.putImageData(id, 0, 0);
// neurons
const cw = +ngrid.dataset.cw, ch = 26, col = model.col;
const pin = hexRGB(css("--inhib")), psoft = hexRGB(css("--spike-soft")), panel = css("--panel");
nctx.fillStyle = panel; nctx.fillRect(0, 0, ngrid.width, ngrid.height);
for (let j = 0; j < s.pot.length; j++) {
const d = Math.floor(j / col), k = j % col, x = k * cw, y = d * ch;
let c;
if (s.fired[j]) c = sp; else if (s.ever[j]) c = psoft; else c = off;
nctx.fillStyle = `rgb(${c})`; nctx.fillRect(x + 1, y + 2, cw - 2, ch - 4);
if (!s.fired[j]) {
const f = Math.min(1, s.pot[j] / model.thr[j]);
if (f > 0) { nctx.fillStyle = `rgb(${pin})`; const hh = (ch - 4) * f; nctx.fillRect(x + 1, y + ch - 2 - hh, cw - 2, hh); }
}
}
const mx = Math.max(1, ...s.votes);
[...$("gridwrap").querySelectorAll(".vote")].forEach((v, d) => { v.firstChild.style.width = `${60 * s.votes[d] / mx}px`; v.lastChild.textContent = s.votes[d]; });
const a = argmax(s.votes), fin = t === STEPS - 1;
$("answer").textContent = a < 0 ? "–" : a;
const vd = $("verdict");
if (a < 0) { vd.className = "verdict none"; vd.textContent = fin ? "нет ответа" : "ждём спайков"; }
else if (imageLabel === null) { vd.className = "verdict none"; vd.textContent = fin ? "итог" : "пока лидирует"; }
else if (a === imageLabel) { vd.className = "verdict ok"; vd.textContent = fin ? "верно" : "лидирует верная"; }
else { vd.className = "verdict bad"; vd.textContent = fin ? `ошибка, это ${imageLabel}` : "лидирует неверная"; }
}
// --- weight maps ---
function weightMap(m, j, cv) {
const c = cv.getContext("2d"), id = c.createImageData(28, 28), W = m.W, row = j * PIX;
let ma = 1; for (let p = 0; p < PIX; p++) ma = Math.max(ma, Math.abs(W[row + p]));
const pos = hexRGB(css("--spike")), neg = hexRGB(css("--inhib")), mid = hexRGB(css("--cell-off"));
for (let p = 0; p < PIX; p++) { const v = W[row + p] / ma; id.data.set([...(v >= 0 ? mix(mid, pos, v) : mix(mid, neg, -v)), 255], p * 4); }
c.putImageData(id, 0, 0);
}
function renderMaps() {
const box = $("mapblocks"); box.innerHTML = "";
const list = [model, ...models.filter(m => m !== model && m.col === model.col).slice(0, 1)];
for (const m of list) {
const blk = document.createElement("div"); blk.className = "mapsblock";
const lab = document.createElement("span"); lab.className = "label"; lab.textContent = `${m.name} · колонка «${digit}», ${m.col} нейронов`;
const g = document.createElement("div"); g.className = "maps";
for (let k = 0; k < m.col; k++) {
const j = digit * m.col + k, cv = document.createElement("canvas"); cv.width = cv.height = 28;
cv.title = `нейрон ${j}`; if (m === model && j === firstWinner) cv.className = "win";
weightMap(m, j, cv); g.append(cv);
}
blk.append(lab, g); box.append(blk);
}
}
// --- confusion ---
function renderConf() {
const M = model.confusion, t = $("conf");
const tot = M.flat().reduce((a, b) => a + b, 0); let ok = 0, none = 0;
M.forEach((r, i) => { ok += r[i]; none += r[C]; });
$("kpi").innerHTML = `<div><span class="label">Верно</span><strong>${(100 * ok / tot).toFixed(1).replace(".", ",")}%</strong></div>
<div><span class="label">Ошибок</span><strong>${tot - ok - none}</strong></div><div><span class="label">Без ответа</span><strong>${none}</strong></div>`;
const ok_c = hexRGB(css("--ok")), bad_c = hexRGB(css("--bad")), bg = hexRGB(css("--panel"));
let mxOff = 1; M.forEach((r, i) => r.forEach((v, j) => { if (i !== j) mxOff = Math.max(mxOff, v); }));
let h = "<tr><th></th>" + [...Array(C).keys()].map(d => `<th>${d}</th>`).join("") + "<th>–</th></tr>";
M.forEach((r, i) => {
h += `<tr><th>${i}</th>` + r.map((v, j) => {
const diag = i === j, f = diag ? 0.15 + 0.35 * v / Math.max(...r) : Math.sqrt(v / mxOff) * 0.75;
const col = v ? `rgb(${mix(bg, diag ? ok_c : bad_c, f)})` : "transparent";
return `<td style="background:${col}">${v || ""}</td>`;
}).join("") + "</tr>";
});
t.innerHTML = h;
}
matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => { renderStep(); renderMaps(); renderConf(); renderReadout(); });
new MutationObserver(() => { renderStep(); renderMaps(); renderConf(); renderReadout(); }).observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] });
resetDraw(); renderModels(); renderDigits(); renderConf();
const startIdx = gal.labels.findIndex((l, i) => l === 3);
$("gallery").children[Math.max(0, startIdx)].click();
</script>