60 lines
8.4 KiB
JavaScript
60 lines
8.4 KiB
JavaScript
'use strict';
|
||
(()=>{
|
||
const N=784,H=256,C=10;
|
||
function decode(text,Type){const raw=atob(text),bytes=new Uint8Array(raw.length);for(let i=0;i<raw.length;i++)bytes[i]=raw.charCodeAt(i);return new Type(bytes.buffer);}
|
||
function normalizeGray(gray,width=28,height=28){
|
||
let x0=width,y0=height,x1=-1,y1=-1,ink=0;
|
||
for(let y=0;y<height;y++)for(let x=0;x<width;x++)if(gray[y*width+x]>16){x0=Math.min(x0,x);x1=Math.max(x1,x);y0=Math.min(y0,y);y1=Math.max(y1,y);ink++;}
|
||
const out=new Uint8Array(N);if(ink<2)return out;
|
||
const cw=x1-x0+1,ch=y1-y0+1,ratio=20/Math.max(cw,ch),nw=Math.max(1,Math.floor(cw*ratio+.5)),nh=Math.max(1,Math.floor(ch*ratio+.5)),ox=Math.floor((28-nw)/2),oy=Math.floor((28-nh)/2);
|
||
for(let y=0;y<nh;y++)for(let x=0;x<nw;x++){
|
||
const xx=Math.max(0,Math.min(cw-1,(x+.5)*cw/nw-.5)),yy=Math.max(0,Math.min(ch-1,(y+.5)*ch/nh-.5));
|
||
const lx=Math.floor(xx),ly=Math.floor(yy),hx=Math.min(lx+1,cw-1),hy=Math.min(ly+1,ch-1),ax=xx-lx,ay=yy-ly;
|
||
const p=(gray[(ly+y0)*width+lx+x0]*(1-ax)+gray[(ly+y0)*width+hx+x0]*ax)*(1-ay)+(gray[(hy+y0)*width+lx+x0]*(1-ax)+gray[(hy+y0)*width+hx+x0]*ax)*ay;
|
||
out[(y+oy)*28+x+ox]=Math.floor(p+.5);
|
||
}
|
||
let mass=0,mx=0,my=0;for(let y=0;y<28;y++)for(let x=0;x<28;x++){const v=out[y*28+x];mass+=v;mx+=v*x;my+=v*y;}
|
||
if(!mass)return out;
|
||
const dx=Math.floor(13.5-mx/mass+.5),dy=Math.floor(13.5-my/mass+.5),shifted=new Uint8Array(N);
|
||
for(let y=0;y<28;y++)for(let x=0;x<28;x++)if(x+dx>=0&&x+dx<28&&y+dy>=0&&y+dy<28)shifted[(y+dy)*28+x+dx]=out[y*28+x];
|
||
return shifted;
|
||
}
|
||
function simulate(model,pixels){
|
||
let mass=0;for(const p of pixels)mass+=p;
|
||
if(mass<255)return {blank:true,prediction:null,counts:Array(H).fill(0),scores:[]};
|
||
const start=performance.now(),drive=new Float64Array(H),potential=new Float64Array(H),counts=new Uint16Array(H);
|
||
for(let j=0;j<H;j++)drive[j]=model.b1[j];
|
||
for(let i=0;i<N;i++)if(pixels[i])for(let j=0;j<H;j++)drive[j]+=pixels[i]*model.w1[i*H+j];
|
||
for(let j=0;j<H;j++)drive[j]=Math.min(model.threshold,Math.max(0,drive[j]));
|
||
// Real finite IF simulation: one spike per neuron/step, subtractive reset.
|
||
for(let t=0;t<model.steps;t++)for(let j=0;j<H;j++){potential[j]+=drive[j];if(potential[j]>=model.threshold){counts[j]++;potential[j]-=model.threshold;}}
|
||
const scores=Array(C).fill(0);for(let c=0;c<C;c++){let sum=0;for(let j=0;j<H;j++)sum+=counts[j]*model.w2[j*C+c];scores[c]=(sum*model.cap/model.steps+model.b2[c])/model.scale;}
|
||
let prediction=0;for(let c=1;c<C;c++)if(scores[c]>scores[prediction])prediction=c;
|
||
return {blank:false,prediction,counts:Array.from(counts),scores,milliseconds:performance.now()-start};
|
||
}
|
||
const api={normalizeGray,simulate,model:null,ready:null};window.FastMNIST=api;
|
||
api.ready=Promise.all([fetch('fast-model.json').then(r=>{if(!r.ok)throw Error('Модель недоступна: '+r.status);return r.json();}),fetch('fast-gallery.json').then(r=>{if(!r.ok)throw Error('Галерея недоступна: '+r.status);return r.json();})]).then(([data,gallery])=>{
|
||
const model={...data,w1:decode(data.weights.w1,Int16Array),b1:decode(data.weights.b1,Int32Array),w2:decode(data.weights.w2,Int16Array),b2:decode(data.weights.b2,Int32Array)};
|
||
if(model.w1.length!==N*H||model.w2.length!==H*C||model.b1.length!==H||model.b2.length!==C)throw Error('Неверный размер модели');
|
||
api.model=model;setup(model,gallery);document.body.classList.remove('loading');return model;
|
||
}).catch(e=>{document.querySelector('#error').textContent='Не удалось загрузить модель. '+e.message;document.querySelector('#status').textContent='Обнови страницу после проверки соединения.';throw e;});
|
||
function setup(model,gallery){
|
||
const $=s=>document.querySelector(s),canvas=$('#draw'),ctx=canvas.getContext('2d',{willReadFrequently:true}),preview=$('#normalized').getContext('2d');
|
||
let drawing=false,last=null,timer=null,sample=null,samplePixels=null;
|
||
function fillBlack(){ctx.fillStyle='#000';ctx.fillRect(0,0,280,280);}
|
||
function showPixels(context,pixels){const im=context.createImageData(28,28);for(let i=0;i<N;i++){im.data[4*i]=im.data[4*i+1]=im.data[4*i+2]=pixels[i];im.data[4*i+3]=255;}context.putImageData(im,0,0);}
|
||
function infer(){clearTimeout(timer);const rgba=ctx.getImageData(0,0,280,280).data,gray=new Uint8Array(280*280);for(let i=0;i<gray.length;i++)gray[i]=rgba[4*i];const pixels=samplePixels?normalizeGray(samplePixels,28,28):normalizeGray(gray,280,280),answer=simulate(model,pixels);showPixels(preview,pixels);api.lastResult=answer;api.lastNormalized=Array.from(pixels);
|
||
$('#answer').textContent=answer.blank?'—':answer.prediction;$('#latency').textContent=answer.blank?'':answer.milliseconds.toFixed(1)+' мс';$('#status').textContent=answer.blank?'Нарисуй цифру — пустой фон не распознаётся.':'Ответ по взвешенным спайкам скрытого слоя.';
|
||
$('#scores').replaceChildren();if(!answer.blank){const sorted=answer.scores.map((v,c)=>({v,c})).sort((a,b)=>b.v-a.v).slice(0,5),minimum=Math.min(...answer.scores),span=Math.max(1e-6,sorted[0].v-minimum);for(const item of sorted){const row=document.createElement('div');row.className='row';const digit=document.createElement('span');digit.textContent=item.c;const track=document.createElement('div');track.className='track';const bar=document.createElement('div');bar.className='bar';bar.style.width=(100*(item.v-minimum)/span)+'%';track.append(bar);const score=document.createElement('span');score.className='score';score.textContent=item.v.toFixed(2);row.append(digit,track,score);$('#scores').append(row);}}
|
||
}
|
||
function clear(){fillBlack();sample=null;samplePixels=null;$('#sample').textContent='Твой рисунок';document.querySelectorAll('#gallery button').forEach(b=>b.setAttribute('aria-current','false'));infer();}
|
||
function pos(event){const r=canvas.getBoundingClientRect();return {x:(event.clientX-r.left)*280/r.width,y:(event.clientY-r.top)*280/r.height};}
|
||
canvas.addEventListener('pointerdown',event=>{if(event.button!==0)return;event.preventDefault();canvas.setPointerCapture(event.pointerId);drawing=true;sample=null;samplePixels=null;$('#sample').textContent='Твой рисунок';document.querySelectorAll('#gallery button').forEach(b=>b.setAttribute('aria-current','false'));last=pos(event);ctx.fillStyle='#fff';ctx.beginPath();ctx.arc(last.x,last.y,9,0,2*Math.PI);ctx.fill();timer=setTimeout(infer,80);});
|
||
canvas.addEventListener('pointermove',event=>{if(!drawing)return;event.preventDefault();const next=pos(event);ctx.strokeStyle='#fff';ctx.lineWidth=18;ctx.lineCap='round';ctx.lineJoin='round';ctx.beginPath();ctx.moveTo(last.x,last.y);ctx.lineTo(next.x,next.y);ctx.stroke();last=next;clearTimeout(timer);timer=setTimeout(infer,80);});
|
||
function finish(event){if(!drawing)return;drawing=false;last=null;if(canvas.hasPointerCapture(event.pointerId))canvas.releasePointerCapture(event.pointerId);infer();}
|
||
canvas.addEventListener('pointerup',finish);canvas.addEventListener('pointercancel',finish);$('#clear').onclick=clear;$('#recognize').onclick=infer;
|
||
$('#accuracy').textContent=(100*model.metrics.test_accuracy).toFixed(2).replace('.',',')+'%';$('#architecture').textContent=`256 спайковых IF-нейронов · ${model.steps} шагов · линейный выход · вычисления локально, без отправки рисунка на сервер.`;$('#version').textContent=model.version+' · train seed 20261015 · параметры выбраны только по validation.';
|
||
for(const item of gallery.items){const pixels=decode(item.pixels,Uint8Array),button=document.createElement('button'),mini=document.createElement('canvas');mini.width=mini.height=28;showPixels(mini.getContext('2d'),pixels);const label=document.createElement('small');label.textContent='№'+item.index;button.append(mini,label);button.title=`Test №${item.index}, метка ${item.label}`;button.setAttribute('aria-label',button.title);button.setAttribute('aria-current','false');button.onclick=()=>{fillBlack();ctx.imageSmoothingEnabled=false;ctx.drawImage(mini,0,0,280,280);ctx.imageSmoothingEnabled=true;sample=item.index;samplePixels=pixels;$('#sample').textContent=`Официальный MNIST test №${item.index}, правильная цифра ${item.label}. Не использовался при обучении и выборе модели.`;document.querySelectorAll('#gallery button').forEach(b=>b.setAttribute('aria-current',String(b===button)));infer();};$('#gallery').append(button);}
|
||
fillBlack();infer();
|
||
}
|
||
})();
|