562 lines
34 KiB
HTML
562 lines
34 KiB
HTML
<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>
|