64 lines
8.9 KiB
JavaScript
64 lines
8.9 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;
|
||
}
|
||
async function simulate(model,pixels,backend='cpu',signal){
|
||
for(let attempt=0;attempt<3;attempt++){
|
||
const response=await fetch('/api/native-recognize',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({pixels:Array.from(pixels),backend}),signal});
|
||
const result=await response.json();if(response.status===429&&attempt<2){await new Promise(resolve=>setTimeout(resolve,200));if(signal&&signal.aborted)throw new DOMException('Aborted','AbortError');continue;}if(!response.ok)throw Error(result.error||'Ошибка сервера');return result;
|
||
}
|
||
}
|
||
const api={normalizeGray,simulate,model:null,ready:null};window.FastMNIST=api;window.NativeAltAI=api;
|
||
api.ready=Promise.all([fetch('native-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;
|
||
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,serial=0,aborter=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);}
|
||
async function infer(force=false){
|
||
clearTimeout(timer);const ticket=++serial;if(aborter)aborter.abort();aborter=new AbortController();
|
||
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);showPixels(preview,pixels);api.lastNormalized=Array.from(pixels);$('#error').textContent='';
|
||
let answer;
|
||
if(pixels.reduce((a,b)=>a+b,0)<255){answer={blank:true,prediction:null,scores:[],counts:[],milliseconds:0};}
|
||
else if($('#backend').value==='gm'&&!force){$('#answer').textContent='—';$('#scores').replaceChildren();$('#latency').textContent='';$('#status').textContent='Нажми «Распознать»: официальный GoldenModel выполняется медленнее.';return;}
|
||
else{
|
||
$('#answer').textContent='…';$('#scores').replaceChildren();$('#latency').textContent='';$('#status').textContent='Нативный граф выполняется на рабочей станции…';
|
||
try{answer=await simulate(model,pixels,$('#backend').value,aborter.signal);}catch(e){if(e.name==='AbortError'||ticket!==serial)return;$('#answer').textContent='—';$('#error').textContent=e.message;$('#status').textContent='Не удалось выполнить распознавание.';return;}
|
||
if(ticket!==serial)return;
|
||
}
|
||
api.lastResult=answer;
|
||
$('#answer').textContent=answer.blank?'—':answer.prediction;$('#latency').textContent=answer.blank?'':answer.milliseconds.toFixed(1)+' мс';$('#status').textContent=answer.blank?'Нарисуй цифру — пустой фон не распознаётся.':'Ответ по спайкам 160 выходных AltAILIF · '+(answer.backend==='gm'?'официальный GoldenModel':'KNP CPU')+'.';
|
||
$('#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(true);$('#backend').onchange=()=>infer();
|
||
$('#accuracy').textContent=(100*model.metrics.test_accuracy).toFixed(2).replace('.',',')+'%';$('#architecture').textContent='784 спайковых входа →256 AltAILIF →160 выходных AltAILIF. Обученные взвешивания выполняются в KNP; на хосте — разность счётчиков и выбор класса.';$('#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();
|
||
}
|
||
})();
|