Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Stoi · Skill Analyzer</title> | |
| <script src="https://cdn.jsdelivr.net/npm/@mediapipe/pose@0.5.1675469404/pose.js" crossorigin="anonymous"></script> | |
| <script type="module"> | |
| import { Client } from "https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js"; | |
| window.GradioClient = Client; | |
| </script> | |
| <style> | |
| :root{ | |
| --bg:#0a0a0b;--panel:#141416;--panel-2:#1c1c1f;--line:#2a2a2e; | |
| --txt:#f2f2f0;--muted:#8a8a8f;--accent:#3da9fc;--good:#46d39a;--warn:#f5a623;--bad:#ff5a5a;--gold:#ffd24a; | |
| --mono:ui-monospace,'SF Mono','Cascadia Mono','Roboto Mono',Menlo,monospace; | |
| --sans:-apple-system,BlinkMacSystemFont,'Segoe UI',Inter,Roboto,sans-serif; | |
| } | |
| *{box-sizing:border-box;margin:0;padding:0} | |
| html,body{background:var(--bg);color:var(--txt);font-family:var(--sans);-webkit-font-smoothing:antialiased} | |
| body{min-height:100vh;padding:24px;max-width:1280px;margin:0 auto} | |
| header{display:flex;align-items:baseline;gap:14px;border-bottom:1px solid var(--line);padding-bottom:16px;margin-bottom:20px;flex-wrap:wrap} | |
| header h1{font-size:18px;font-weight:600;letter-spacing:-0.02em} | |
| header .tag{font-family:var(--mono);font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:0.08em} | |
| header .status{margin-left:auto;font-family:var(--mono);font-size:12px;color:var(--muted)} | |
| header .status b{color:var(--accent)} | |
| .grid{display:grid;grid-template-columns:1fr 330px;gap:20px;align-items:start} | |
| @media(max-width:880px){.grid{grid-template-columns:1fr}} | |
| .stage{background:var(--panel);border:1px solid var(--line);border-radius:10px;overflow:hidden;position:relative} | |
| .canvas-wrap{position:relative;width:100%;aspect-ratio:16/9;background:#000;display:flex;align-items:center;justify-content:center} | |
| video{display:none} | |
| canvas{max-width:100%;max-height:100%;display:block} | |
| .placeholder{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:14px;color:var(--muted);text-align:center;padding:30px} | |
| .placeholder p{font-size:13px;max-width:340px;line-height:1.5} | |
| .prog{position:absolute;left:0;right:0;bottom:0;height:4px;background:transparent} | |
| .prog>div{height:100%;width:0;background:var(--accent);transition:width .1s} | |
| .controls{display:flex;align-items:center;gap:10px;padding:12px 14px;border-top:1px solid var(--line);background:var(--panel-2);flex-wrap:wrap} | |
| .btn{font-family:var(--sans);font-size:13px;font-weight:500;color:var(--txt);background:#262629;border:1px solid var(--line);border-radius:7px;padding:8px 14px;cursor:pointer;transition:.12s} | |
| .btn:hover:not(:disabled){background:#303034;border-color:#3a3a3e} | |
| .btn:disabled{opacity:.4;cursor:not-allowed} | |
| .btn.primary{background:var(--accent);border-color:var(--accent);color:#04121f} | |
| .btn.primary:hover:not(:disabled){filter:brightness(1.08)} | |
| .btn.go{background:var(--good);border-color:var(--good);color:#04201a} | |
| .scrub{flex:1;min-width:140px;display:flex;align-items:center;gap:10px} | |
| .scrub input[type=range]{flex:1;accent-color:var(--accent);height:4px} | |
| .scrub .time{font-family:var(--mono);font-size:11px;color:var(--muted);min-width:88px;text-align:right} | |
| .file-row{display:flex;gap:10px;align-items:center;margin-bottom:14px;flex-wrap:wrap} | |
| input[type=file]{display:none} | |
| select{background:var(--panel-2);color:var(--txt);border:1px solid var(--line);border-radius:7px;padding:8px 10px;font-family:var(--sans);font-size:13px} | |
| .model-sel{font-family:var(--mono);font-size:11px;color:var(--muted);display:flex;align-items:center;gap:6px} | |
| .model-sel select{font-family:var(--mono);font-size:11px;padding:5px 8px} | |
| .side{display:flex;flex-direction:column;gap:16px} | |
| .card{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:16px} | |
| .card h2{font-size:11px;text-transform:uppercase;letter-spacing:0.09em;color:var(--muted);font-weight:600;margin-bottom:14px;font-family:var(--mono)} | |
| .verdict{padding:14px;border-radius:8px;text-align:center;border:1px solid var(--line);background:var(--panel-2)} | |
| .verdict .big{font-family:var(--mono);font-size:26px;font-weight:800;line-height:1} | |
| .verdict .sub{font-size:11px;color:var(--muted);margin-top:6px;text-transform:uppercase;letter-spacing:.07em} | |
| .verdict.ok{border-color:rgba(70,211,154,.5)}.verdict.ok .big{color:var(--good)} | |
| .verdict.mid{border-color:rgba(245,166,35,.5)}.verdict.mid .big{color:var(--warn)} | |
| .verdict.bad{border-color:rgba(255,90,90,.5)}.verdict.bad .big{color:var(--bad)} | |
| .lines{margin-top:12px;display:flex;flex-direction:column;gap:8px} | |
| .lrow{display:flex;align-items:center;justify-content:space-between;font-size:13px;padding:8px 10px;background:var(--panel-2);border:1px solid var(--line);border-radius:7px} | |
| .lrow .k{color:var(--muted)} | |
| .lrow .v{font-family:var(--mono);font-weight:700} | |
| .lrow .v.g{color:var(--good)}.lrow .v.w{color:var(--warn)}.lrow .v.b{color:var(--bad)} | |
| .row2{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-top:12px} | |
| .mini{padding:10px;background:var(--panel-2);border:1px solid var(--line);border-radius:8px;text-align:center} | |
| .mini .v{font-family:var(--mono);font-size:15px;font-weight:700} | |
| .mini .l{font-size:10px;color:var(--muted);margin-top:3px;text-transform:uppercase;letter-spacing:.05em} | |
| .tune{margin-top:14px} | |
| .tune label{font-size:11px;color:var(--muted);display:flex;justify-content:space-between;font-family:var(--mono)} | |
| .tune input[type=range]{width:100%;accent-color:var(--accent);margin-top:6px} | |
| .empty-note{font-size:12px;color:var(--muted);line-height:1.55;margin-top:10px} | |
| .legend{font-size:11px;color:var(--muted);line-height:1.6} | |
| .legend .sw{display:inline-block;width:18px;height:0;border-top:2px solid;vertical-align:middle;margin-right:6px} | |
| .log{margin-top:18px;background:#0c0c0d;border:1px solid var(--line);border-radius:8px;font-family:var(--mono);font-size:11px;color:var(--muted);padding:10px 12px;max-height:130px;overflow:auto;white-space:pre-wrap;line-height:1.5} | |
| .log .ok{color:var(--good)}.log .err{color:var(--bad)}.log .warn{color:var(--warn)} | |
| </style> | |
| </head> | |
| <body> | |
| <header> | |
| <h1>Stoi · Skill Analyzer</h1> | |
| <span class="tag">v4.0 / server (HF Space)</span> | |
| <div class="status" id="status">starting…</div> | |
| </header> | |
| <div class="file-row"> | |
| <label class="btn primary" for="file">Load video</label> | |
| <input type="file" id="file" accept="video/*"> | |
| <label class="btn" for="kpFile">Load keypoints</label> | |
| <input type="file" id="kpFile" accept="application/json,.json"> | |
| <button class="btn primary" id="serverBtn">Run on server</button> | |
| <input type="text" id="spaceId" value="YeeThree/stoi-judge" title="your Hugging Face Space" style="width:160px;background:#0e1320;border:1px solid #2a3550;color:#cfe0ff;border-radius:8px;padding:6px 8px;font-size:12px"> | |
| <select id="skillSel"> | |
| <option value="cross">Cross (iron cross)</option> | |
| <option value="planche">Planche</option> | |
| </select> | |
| <button class="btn go" id="analyzeBtn" disabled>Analyze Skill</button> | |
| <div class="model-sel">model | |
| <select id="modelSel"><option value="2" selected>heavy</option><option value="1">full</option><option value="0">lite</option></select> | |
| </div> | |
| </div> | |
| <div class="grid"> | |
| <div class="stage"> | |
| <div class="canvas-wrap"> | |
| <video id="video" playsinline muted></video> | |
| <canvas id="canvas"></canvas> | |
| <div class="placeholder" id="placeholder"><p>Pick the skill, load a clip, hit Analyze Skill. You get a scrubable skeleton replay, then the list of deductions it finds.</p></div> | |
| <div class="prog"><div id="progBar"></div></div> | |
| </div> | |
| <div class="controls"> | |
| <button class="btn" id="playBtn" disabled>Play</button> | |
| <button class="btn" id="stepBack" disabled>‹ frame</button> | |
| <button class="btn" id="stepFwd" disabled>frame ›</button> | |
| <div class="scrub"><input type="range" id="scrub" min="0" max="1000" value="0" disabled><span class="time" id="time">0.00 / 0.00</span></div> | |
| </div> | |
| </div> | |
| <div class="side"> | |
| <div class="card"> | |
| <h2>Deductions</h2> | |
| <div id="dedEmpty" class="empty-note">Load a skill, then hit Analyze Skill.</div> | |
| <div id="dedResult" style="display:none"> | |
| <div class="verdict" id="verdict"><div class="big" id="vBig">—</div><div class="sub" id="vSub"></div></div> | |
| <div class="lines" id="dedList"></div> | |
| </div> | |
| <div class="tune"> | |
| <label>hold band (max wobble) <span id="stillVal">3°</span></label> | |
| <input type="range" id="stillSlider" min="1" max="10" value="3"> | |
| <div class="empty-note">Held = the key angle stays inside this many degrees for the window. Re-run after changing.</div> | |
| </div> | |
| </div> | |
| <div class="card"> | |
| <h2>Live frame</h2> | |
| <div class="row2"> | |
| <div class="mini"><div class="v" id="mA">—</div><div class="l" id="lA">—</div></div> | |
| <div class="mini"><div class="v" id="mB">—</div><div class="l" id="lB">—</div></div> | |
| </div> | |
| <div class="empty-note">Scrub anywhere to read one frame.</div> | |
| </div> | |
| <div class="card"> | |
| <h2>Overlay key</h2> | |
| <div class="legend"> | |
| <span class="sw" style="border-color:var(--gold)"></span>reference line<br> | |
| <span class="sw" style="border-color:#fff"></span>your body / arm line<br> | |
| <span class="sw" style="border-color:var(--bad)"></span>measured angle arc | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="log" id="log"></div> | |
| <script> | |
| (function(){ | |
| "use strict"; | |
| var logEl=document.getElementById('log'); | |
| function log(m,cls){var d=document.createElement('div');if(cls)d.className=cls;d.textContent=m;logEl.appendChild(d);logEl.scrollTop=logEl.scrollHeight;} | |
| window.onerror=function(m,s,l){log('JS ERROR: '+m+' (line '+l+')','err');}; | |
| log('script started','ok'); | |
| var el={};['status','file','kpFile','serverBtn','spaceId','skillSel','analyzeBtn','modelSel','video','canvas','placeholder','playBtn','stepBack','stepFwd','scrub','time','progBar','vBig','vSub','verdict','dedEmpty','dedResult','dedList','stillSlider','stillVal','mA','mB','lA','lB'].forEach(function(id){el[id]=document.getElementById(id);}); | |
| el.play=el.playBtn;el.analyze=el.analyzeBtn; | |
| var ctx=el.canvas.getContext('2d'); | |
| var IDX={lsh:11,rsh:12,lel:13,rel:14,lwr:15,rwr:16,lhip:23,rhip:24,lkne:25,rkne:26,lank:27,rank:28}; | |
| var BONES=[[11,12],[11,13],[13,15],[12,14],[14,16],[11,23],[12,24],[23,24],[23,25],[25,27],[24,26],[26,28]]; | |
| var SNAP_PTS=[11,12,13,14,15,16,23,24,25,26,27,28]; | |
| var HOLD_REQ=2.0, MIN_STOP=0.15; | |
| function holdBand(){return parseFloat(el.stillSlider.value);} | |
| // smooth (EMA) then only commit the shown number ~2x/sec so it holds steady and is readable | |
| var smooth={},disp={},lastCommit=0; | |
| function feed(id,raw,a){a=a||0.16;if(raw!=null&&!isNaN(raw)){if(smooth[id]==null)smooth[id]=raw;else smooth[id]+=a*(raw-smooth[id]);}return smooth[id];} | |
| function commitNumbers(){var now=(window.performance&&performance.now)?performance.now():Date.now();if(now-lastCommit>=450){lastCommit=now;for(var k in smooth)disp[k]=smooth[k];}} | |
| function shown(id){return disp[id]!=null?disp[id]:null;} | |
| function resetSmooth(){smooth={};disp={};lastCommit=0;} | |
| // bridge dropout frames: keep the last confident position of each joint for a few frames | |
| var frameCounter=0,holdStore={},HOLD_FRAMES=8; | |
| function fillLandmarks(lm){ | |
| if(!lm)return lm;frameCounter++; | |
| for(var i=0;i<lm.length;i++){ | |
| var v=lm[i]&&lm[i].visibility; | |
| if(v>0.5){holdStore[i]={x:lm[i].x,y:lm[i].y,f:frameCounter};} | |
| else if(holdStore[i]&&frameCounter-holdStore[i].f<=HOLD_FRAMES){lm[i]={x:holdStore[i].x,y:holdStore[i].y,visibility:0.55};} | |
| } | |
| return lm; | |
| } | |
| function resetHold(){holdStore={};} | |
| // ---- precomputed keypoints (from the YOLO11 Colab export) ---- | |
| var kpData=null,useKP=false; | |
| function kpFrameToLm(f){var a=new Array(33),lm=f.lm;for(var i=0;i<33;i++){var p=lm[i];a[i]=p?{x:p[0],y:p[1],visibility:p[2]}:undefined;}return a;} | |
| // temporal smoothing of joint POSITIONS so the dense data stops strobing live | |
| var lmEma=null; | |
| function smoothLm(lm,a){a=a||0.25;if(!lm)return lm; | |
| if(!lmEma){lmEma=lm.map(function(p){return p?{x:p.x,y:p.y,visibility:p.visibility}:undefined;});return lmEma;} | |
| for(var i=0;i<lm.length;i++){ | |
| if(lm[i]){if(lmEma[i]){lmEma[i].x+=a*(lm[i].x-lmEma[i].x);lmEma[i].y+=a*(lm[i].y-lmEma[i].y);lmEma[i].visibility=lm[i].visibility;}else lmEma[i]={x:lm[i].x,y:lm[i].y,visibility:lm[i].visibility};} | |
| else lmEma[i]=undefined; | |
| } | |
| return lmEma; | |
| } | |
| function resetLmEma(){lmEma=null;} | |
| // one-time centered moving-average over the whole clip — clean, lag-free, kills per-frame jitter | |
| function smoothSequence(frames,win){ | |
| win=win||4;var n=frames.length,out=new Array(n); | |
| for(var i=0;i<n;i++){ | |
| var lm=new Array(33); | |
| for(var j=0;j<33;j++){ | |
| var sx=0,sy=0,sv=0,c=0; | |
| for(var k=-win;k<=win;k++){var idx=i+k;if(idx<0||idx>=n)continue;var p=frames[idx].lm[j];if(p){sx+=p[0];sy+=p[1];sv+=p[2];c++;}} | |
| lm[j]=c?[sx/c,sy/c,sv/c]:null; | |
| } | |
| out[i]={t:frames[i].t,lm:lm}; | |
| } | |
| return out; | |
| } | |
| function kpAt(t){var fr=kpData.frames,n=fr.length;if(!n)return null;var lo=0,hi=n-1;while(lo<hi){var m=(lo+hi)>>1;if(fr[m].t<t)lo=m+1;else hi=m;}var i=lo;if(i>0&&Math.abs(fr[i-1].t-t)<Math.abs(fr[i].t-t))i--;return fr[i];} | |
| function renderFrame(){ | |
| if(useKP&&kpData){ | |
| if(el.video.videoWidth)resize();else{el.canvas.width=kpData.width;el.canvas.height=kpData.height;} | |
| var f=kpAt(el.video.currentTime||0),lm=f?kpFrameToLm(f):null;draw(lm);liveReadout(lm); | |
| } else { process(); } | |
| } | |
| function V(o,i){return o&&o[i]&&((o[i].v!=null?o[i].v:o[i].visibility)>0.4);} | |
| function pxAngle(vtx,p1,p2,W,H){ | |
| var ax=(p1.x-vtx.x)*W,ay=(p1.y-vtx.y)*H,bx=(p2.x-vtx.x)*W,by=(p2.y-vtx.y)*H; | |
| var ma=Math.hypot(ax,ay),mb=Math.hypot(bx,by);if(!ma||!mb)return null; | |
| return Math.acos(Math.max(-1,Math.min(1,(ax*bx+ay*by)/(ma*mb))))*180/Math.PI; | |
| } | |
| // signed tilt of line a->b vs horizontal (+ = b lower than a) | |
| function tiltH(a,b,W,H){return Math.atan2((b.y-a.y)*H,Math.abs((b.x-a.x)*W))*180/Math.PI;} | |
| function mid(o,i,j){return {x:(o[i].x+o[j].x)/2,y:(o[i].y+o[j].y)/2};} | |
| // best far-leg point: ankle only if it's anatomically attached to the knee, else fall back to knee | |
| function legEnd(o,kneeI,ankI,hipI,W,H){ | |
| if(V(o,ankI)&&V(o,kneeI)&&V(o,hipI)){ | |
| var thigh=Math.hypot((o[kneeI].x-o[hipI].x)*W,(o[kneeI].y-o[hipI].y)*H); | |
| var shin=Math.hypot((o[ankI].x-o[kneeI].x)*W,(o[ankI].y-o[kneeI].y)*H); | |
| if(thigh>0&&shin>0.4*thigh&&shin<2.2*thigh)return o[ankI]; | |
| return o[kneeI]; | |
| } | |
| if(V(o,kneeI))return o[kneeI]; | |
| if(V(o,ankI))return o[ankI]; | |
| return null; | |
| } | |
| function feetPt(o,W,H){var le=legEnd(o,25,27,23,W,H),re=legEnd(o,26,28,24,W,H);if(!le&&!re)return null;return (le&&re)?{x:(le.x+re.x)/2,y:(le.y+re.y)/2}:(le||re);} | |
| function shoulderW(o,W,H){return V(o,11)&&V(o,12)?Math.hypot((o[12].x-o[11].x)*W,(o[12].y-o[11].y)*H):0;} | |
| function drawArc(wx,wy,aFrom,aTo,r,color,W){ | |
| var d=aTo-aFrom;while(d>Math.PI)d-=2*Math.PI;while(d<-Math.PI)d+=2*Math.PI; | |
| ctx.strokeStyle=color;ctx.lineWidth=Math.max(2.4,W/360);ctx.beginPath(); | |
| for(var t=0;t<=1.0001;t+=0.04){var a=aFrom+d*t,px=wx+r*Math.cos(a),py=wy+r*Math.sin(a);if(t===0)ctx.moveTo(px,py);else ctx.lineTo(px,py);} | |
| ctx.stroke();return aFrom+d*0.5; | |
| } | |
| function label(txt,x,y){ctx.fillStyle='#fff';ctx.textAlign='center';ctx.textBaseline='middle';ctx.fillText(txt,x,y);ctx.textAlign='start';ctx.textBaseline='alphabetic';} | |
| // ===================== SKILLS ===================== | |
| var SKILLS={ | |
| cross:{ | |
| label:'Cross', angleLabel:'Cross angle', holdMax:55, liveA:'left arm', liveB:'right arm', dedFn:angleDed, devWord:'off', | |
| gate:function(o,W,H){ | |
| if(!V(o,11)||!V(o,12)||!V(o,15)||!V(o,16))return false; | |
| var sw=shoulderW(o,W,H);if(!sw)return false; | |
| var shY=(o[11].y+o[12].y)/2,cx=(o[11].x+o[12].x)/2; | |
| var lH=Math.abs(o[15].y-shY)*H<0.7*sw,rH=Math.abs(o[16].y-shY)*H<0.7*sw; | |
| var lA=Math.abs(o[15].x-cx)>Math.abs(o[11].x-cx)*0.9,rA=Math.abs(o[16].x-cx)>Math.abs(o[12].x-cx)*0.9; | |
| return lH&&rH&&lA&&rA; | |
| }, | |
| metrics:function(o,W,H){ | |
| if(!V(o,11)||!V(o,12)||!V(o,15)||!V(o,16))return null; | |
| var L=pxAngle(o[15],o[11],o[16],W,H),R=pxAngle(o[16],o[12],o[15],W,H); | |
| if(L==null||R==null)return null; | |
| return {hold:(L+R)/2,a:Math.round(L)+'°',b:Math.round(R)+'°',devs:[{label:'Cross angle',val:Math.max(L,R),table:'pos'}]}; | |
| }, | |
| draw:function(o,W,H){ | |
| var lwx=o[15].x*W,lwy=o[15].y*H,rwx=o[16].x*W,rwy=o[16].y*H,dx=rwx-lwx,dy=rwy-lwy,ext=0.05; | |
| ctx.save();ctx.setLineDash([10,8]);ctx.strokeStyle='rgba(255,210,74,0.95)';ctx.lineWidth=Math.max(1.8,W/520); | |
| ctx.beginPath();ctx.moveTo(lwx-dx*ext,lwy-dy*ext);ctx.lineTo(rwx+dx*ext,rwy+dy*ext);ctx.stroke();ctx.restore(); | |
| ctx.font='800 '+Math.max(18,W/46)+'px -apple-system,sans-serif'; | |
| [['lsh','lwr','rwr'],['rsh','rwr','lwr']].forEach(function(a){ | |
| var sh=o[IDX[a[0]]],wr=o[IDX[a[1]]],other=o[IDX[a[2]]],sx=sh.x*W,sy=sh.y*H,wx=wr.x*W,wy=wr.y*H; | |
| ctx.strokeStyle='rgba(255,255,255,0.97)';ctx.lineWidth=Math.max(2.6,W/340);ctx.beginPath();ctx.moveTo(wx,wy);ctx.lineTo(sx,sy);ctx.stroke(); | |
| var aw=Math.atan2((sh.y-wr.y)*H,(sh.x-wr.x)*W),ay=Math.atan2((other.y-wr.y)*H,(other.x-wr.x)*W); | |
| var armLen=Math.hypot(sx-wx,sy-wy),r=Math.max(28,armLen*0.34); | |
| var amid=drawArc(wx,wy,ay,aw,r,'rgba(255,90,90,0.95)',W); | |
| var deg=pxAngle(wr,sh,other,W,H);feed('cross_'+a[1],deg);var sdeg=shown('cross_'+a[1]); | |
| label((sdeg==null?Math.round(deg):Math.round(sdeg))+'°',wx+(r+Math.max(20,W/60))*Math.cos(amid),wy+(r+Math.max(20,W/60))*Math.sin(amid)); | |
| }); | |
| } | |
| }, | |
| planche:{ | |
| label:'Planche', angleLabel:'Body', holdMax:45, liveA:'body straight', liveB:'body level', | |
| gate:function(o,W,H){ | |
| if(!V(o,11)||!V(o,12)||!V(o,23)||!V(o,24)||!V(o,27)||!V(o,28))return false; | |
| var sw=shoulderW(o,W,H);if(!sw)return false; | |
| var ms=mid(o,11,12),ma=mid(o,27,28); | |
| var levelish=Math.abs((ma.y-ms.y))*H<1.4*sw; | |
| var support=(o[15]&&o[16])?((o[15].y+o[16].y)/2 > ms.y):true; | |
| return levelish&&support; | |
| }, | |
| metrics:function(o,W,H){ | |
| if(!V(o,11)||!V(o,12)||!V(o,23)||!V(o,24))return null; // torso only = hold survives lost feet | |
| var ms=mid(o,11,12),mh=mid(o,23,24); | |
| var torsoTilt=tiltH(ms,mh,W,H); | |
| var ma=feetPt(o,W,H); | |
| if(!ma)return {hold:torsoTilt,a:'—',b:'—',devs:[]}; | |
| var level=Math.abs(tiltH(ma,ms,W,H)); // feet->shoulder line vs floor | |
| var bend=pxAngle(ms,mh,ma,W,H); // hip off that line, measured at the shoulder (long baseline) | |
| if(bend==null)bend=0; | |
| return {hold:torsoTilt,a:Math.round(bend)+'°',b:Math.round(level)+'°', | |
| devs:[{label:'Body level',val:level,table:'pos'},{label:'Body straight',val:bend,table:'bend'}]}; | |
| }, | |
| draw:function(o,W,H){ | |
| if(!V(o,11)||!V(o,12)||!V(o,23)||!V(o,24))return; | |
| var ms=mid(o,11,12),mh=mid(o,23,24),sx=ms.x*W,sy=ms.y*H,hx=mh.x*W,hy=mh.y*H; | |
| var ma=feetPt(o,W,H); | |
| if(!ma){ctx.strokeStyle='rgba(255,255,255,0.6)';ctx.lineWidth=Math.max(2,W/360);ctx.beginPath();ctx.moveTo(sx,sy);ctx.lineTo(hx,hy);ctx.stroke();return;} | |
| var ax=ma.x*W,ay=ma.y*H; | |
| // feet -> shoulder line (white) | |
| ctx.strokeStyle='rgba(255,255,255,0.97)';ctx.lineWidth=Math.max(2.8,W/320); | |
| ctx.beginPath();ctx.moveTo(ax,ay);ctx.lineTo(sx,sy);ctx.stroke(); | |
| // actual path through the hip (cyan, shows the kink) | |
| ctx.strokeStyle='rgba(120,200,255,0.8)';ctx.lineWidth=Math.max(1.8,W/520); | |
| ctx.beginPath();ctx.moveTo(sx,sy);ctx.lineTo(hx,hy);ctx.lineTo(ax,ay);ctx.stroke(); | |
| // horizontal floor reference at the shoulder | |
| var dir=ax>=sx?1:-1,len=Math.abs(ax-sx)||shoulderW(o,W,H)*2; | |
| ctx.save();ctx.setLineDash([10,8]);ctx.strokeStyle='rgba(255,210,74,0.95)';ctx.lineWidth=Math.max(1.8,W/520); | |
| ctx.beginPath();ctx.moveTo(sx,sy);ctx.lineTo(sx+dir*len,sy);ctx.stroke();ctx.restore(); | |
| ctx.font='800 '+Math.max(16,W/50)+'px -apple-system,sans-serif'; | |
| var level=Math.round(Math.abs(tiltH(ma,ms,W,H))),bend=Math.round(pxAngle(ms,mh,ma,W,H)||0); | |
| feed('pl_level',level);feed('pl_bend',bend);var sl=shown('pl_level'),sb2=shown('pl_bend'); | |
| var aRef=Math.atan2(0,dir),aBody=Math.atan2((ma.y-ms.y)*H,(ma.x-ms.x)*W); | |
| var r=Math.max(34,len*0.26),amid=drawArc(sx,sy,aRef,aBody,r,'rgba(255,90,90,0.95)',W); | |
| label('level '+(sl==null?level:Math.round(sl))+'°',sx+(r+Math.max(34,W/34))*Math.cos(amid),sy+(r+Math.max(34,W/34))*Math.sin(amid)); | |
| label('bend '+(sb2==null?bend:Math.round(sb2))+'°',hx,hy-Math.max(22,W/34)); | |
| } | |
| } | |
| }; | |
| var cur='cross'; | |
| function angleDed(dev){if(dev<=5)return{ded:'0.00',kind:'g',nr:false};if(dev<=20)return{ded:'0.10',kind:'w',nr:false};if(dev<=45)return{ded:'0.30',kind:'w',nr:false};return{ded:'0.50',kind:'b',nr:true};} | |
| // body/arm bending table: ~free under 7 (noise), then 0.10 / 0.30 / 0.50 / NR | |
| function bendDed(dev){if(dev<=7)return{ded:'0.00',kind:'g',nr:false};if(dev<=15)return{ded:'0.10',kind:'w',nr:false};if(dev<=30)return{ded:'0.30',kind:'w',nr:false};if(dev<=45)return{ded:'0.50',kind:'b',nr:false};return{ded:'0.50',kind:'b',nr:true};} | |
| function renderDeductions(items,recognized){ | |
| el.dedEmpty.style.display='none';el.dedResult.style.display='block'; | |
| var total=0;items.forEach(function(it){total+=parseFloat(it.ded)||0;}); | |
| el.vBig.textContent='-'+total.toFixed(2); | |
| el.verdict.className='verdict '+(!recognized?'bad':(total<=0?'ok':'mid')); | |
| el.vSub.textContent=!recognized?'not recognized':(total<=0?'clean':'execution deductions'); | |
| el.dedList.innerHTML=''; | |
| items.forEach(function(it){ | |
| var row=document.createElement('div');row.className='lrow'; | |
| var k=document.createElement('span');k.className='k';k.innerHTML=it.label+'<br><span style="font-size:11px;opacity:.65">'+it.detail+'</span>'; | |
| var v=document.createElement('span');v.className='v '+it.kind;v.textContent=(parseFloat(it.ded)>0?'-':'')+it.ded+(it.nr?' NR':''); | |
| row.appendChild(k);row.appendChild(v);el.dedList.appendChild(row); | |
| }); | |
| } | |
| function applySkillLabels(){el.lA.textContent=SKILLS[cur].liveA;el.lB.textContent=SKILLS[cur].liveB;} | |
| function resize(){var w=el.video.videoWidth,h=el.video.videoHeight;if(!w||!h)return;el.canvas.width=w;el.canvas.height=h;} | |
| function drawBase(){var w=el.canvas.width,h=el.canvas.height;if(!w||!h)return;ctx.clearRect(0,0,w,h);try{ctx.drawImage(el.video,0,0,w,h);}catch(e){}} | |
| function draw(lm){ | |
| drawBase();commitNumbers();var W=el.canvas.width,H=el.canvas.height;if(!lm)return; | |
| ctx.lineWidth=Math.max(2,W/360);ctx.strokeStyle="rgba(61,169,252,0.7)"; | |
| for(var i=0;i<BONES.length;i++){var a=BONES[i][0],b=BONES[i][1];if(!V(lm,a)||!V(lm,b))continue;ctx.beginPath();ctx.moveTo(lm[a].x*W,lm[a].y*H);ctx.lineTo(lm[b].x*W,lm[b].y*H);ctx.stroke();} | |
| for(var j in IDX){var idx=IDX[j];if(!V(lm,idx))continue;ctx.beginPath();ctx.arc(lm[idx].x*W,lm[idx].y*H,Math.max(3,W/200),0,Math.PI*2);ctx.fillStyle=lm[idx].visibility>0.7?"#46d39a":"#f5a623";ctx.fill();} | |
| try{ if(SKILLS[cur].gate(lm,W,H)||SKILLS[cur].metrics(lm,W,H)) SKILLS[cur].draw(lm,W,H); }catch(e){} | |
| } | |
| function liveReadout(lm){ | |
| var W=el.canvas.width,H=el.canvas.height,m=lm?SKILLS[cur].metrics(lm,W,H):null; | |
| var av=m?parseFloat(m.a):NaN,bv=m?parseFloat(m.b):NaN; | |
| feed('liveA',isNaN(av)?null:av);feed('liveB',isNaN(bv)?null:bv); | |
| var sa=shown('liveA'),sb=shown('liveB'); | |
| el.mA.textContent=sa==null?'—':Math.round(sa)+'°';el.mB.textContent=sb==null?'—':Math.round(sb)+'°'; | |
| } | |
| // ===================== POSE ===================== | |
| var pose=null,poseReady=false,latest=null,busy=false,analyzing=false; | |
| function initPose(){ | |
| try{ | |
| pose=new Pose({locateFile:function(f){return 'https://cdn.jsdelivr.net/npm/@mediapipe/pose@0.5.1675469404/'+f;}}); | |
| pose.setOptions({modelComplexity:parseInt(el.modelSel.value,10),smoothLandmarks:true,enableSegmentation:false,minDetectionConfidence:0.3,minTrackingConfidence:0.3}); | |
| pose.onResults(function(r){latest=(r.poseLandmarks&&r.poseLandmarks.length)?fillLandmarks(r.poseLandmarks):null;draw(latest);liveReadout(latest);}); | |
| poseReady=true;el.status.innerHTML='<b>ready</b>';log('initialized ✓','ok'); | |
| }catch(e){log('init error: '+(e.message||e),'err');} | |
| } | |
| function waitForPose(t){if(typeof Pose!=='undefined'){initPose();return;}if(t<=0){el.status.innerHTML='<span style="color:var(--bad)">library blocked</span>';log('POSE LIBRARY FAILED. disable adblock or use hotspot','err');return;}setTimeout(function(){waitForPose(t-1);},250);} | |
| function process(){if(busy||!poseReady||!el.video.videoWidth)return Promise.resolve();busy=true;resize();return pose.send({image:el.video}).catch(function(e){log('send err: '+(e.message||e),'warn');}).finally(function(){busy=false;});} | |
| function updateTime(){var c=el.video.currentTime||0,d=el.video.duration||0;el.time.textContent=c.toFixed(2)+' / '+d.toFixed(2);if(d)el.scrub.value=Math.round(c/d*1000);} | |
| // ===================== ANALYZE ===================== | |
| function seekTo(t){return new Promise(function(res){var done=false;function fin(){if(done)return;done=true;el.video.removeEventListener('seeked',on);res();}function on(){fin();}el.video.addEventListener('seeked',on);el.video.currentTime=t;setTimeout(fin,500);});} | |
| function snap(lm){if(!lm)return null;var o={};SNAP_PTS.forEach(function(i){o[i]=lm[i]?{x:lm[i].x,y:lm[i].y,v:lm[i].visibility}:null;});return o;} | |
| async function runAnalyze(){ | |
| if(useKP){ if(!kpData)return; } else if(!poseReady||!el.video.duration){return;} | |
| var sk=SKILLS[cur]; | |
| analyzing=true;resetHold();resetSmooth(); | |
| el.analyze.disabled=true;['play','stepBack','stepFwd','scrub'].forEach(function(k){el[k].disabled=true;});if(el.video.duration)el.video.pause(); | |
| var W,H,frames=[]; | |
| el.status.innerHTML='<b>analyzing 0%</b>'; | |
| if(useKP&&kpData){ | |
| W=kpData.width;H=kpData.height; | |
| frames=kpData.frames.map(function(f){return {t:f.t,s:kpFrameToLm(f)};}); | |
| el.progBar.style.width='100%'; | |
| } else { | |
| W=el.canvas.width;H=el.canvas.height;var dur=el.video.duration,dt=Math.max(1/30,dur/160); | |
| for(var t=0;t<=dur+1e-6;t+=dt){ | |
| var tt=Math.min(t,Math.max(0,dur-0.001));await seekTo(tt);await process();frames.push({t:tt,s:snap(latest)}); | |
| var pct=Math.min(100,Math.round(tt/dur*100));el.progBar.style.width=pct+'%';el.status.innerHTML='<b>analyzing '+pct+'%</b>'; | |
| } | |
| } | |
| var ang=new Array(frames.length).fill(null),mets=new Array(frames.length).fill(null); | |
| for(var i=0;i<frames.length;i++){var m=sk.metrics(frames[i].s,W,H);mets[i]=m;if(m)ang[i]=m.hold;} | |
| // smooth hold angle | |
| var sm=new Array(frames.length).fill(null); | |
| for(var i2=0;i2<frames.length;i2++){var acc=0,c=0;for(var w=-1;w<=1;w++){var k=i2+w;if(k>=0&&k<frames.length&&ang[k]!=null){acc+=ang[k];c++;}}sm[i2]=c?acc/c:null;} | |
| function cand(i){return sm[i]!=null&&Math.abs(sm[i])<=sk.holdMax;} | |
| var band=holdBand(),MAXGAP=0.25,bestStart=-1,bestEnd=-1,bestDur=0; | |
| for(var sIdx=0;sIdx<frames.length;sIdx++){ | |
| if(!cand(sIdx))continue;var mn=sm[sIdx],mx=sm[sIdx],lastGood=sIdx,broke=false; | |
| for(var e=sIdx+1;e<frames.length&&!broke;e++){ | |
| if(!cand(e)){if(frames[e].t-frames[lastGood].t>MAXGAP)broke=true;continue;} | |
| var nmn=Math.min(mn,sm[e]),nmx=Math.max(mx,sm[e]);if(nmx-nmn>band){broke=true;break;} | |
| mn=nmn;mx=nmx;lastGood=e;var d=frames[lastGood].t-frames[sIdx].t;if(d>bestDur){bestDur=d;bestStart=sIdx;bestEnd=lastGood;} | |
| } | |
| } | |
| var holdDur=bestDur,found=[],recognized=true; | |
| if(holdDur>=HOLD_REQ)found.push({label:'Hold (2s rule)',ded:'0.00',kind:'g',detail:'held '+holdDur.toFixed(1)+'s'}); | |
| else if(holdDur>=MIN_STOP)found.push({label:'Hold (2s rule)',ded:'0.30',kind:'w',detail:'stop '+holdDur.toFixed(1)+'s, under 2s'}); | |
| else {found.push({label:'Hold (2s rule)',ded:'0.50',kind:'b',detail:'no complete stop',nr:true});recognized=false;} | |
| var s0=bestStart>=0?bestStart:0,s1=bestEnd>=0?bestEnd:frames.length-1; | |
| var maxDevs=0;for(var di=s0;di<=s1;di++){if(mets[di]&&mets[di].devs)maxDevs=Math.max(maxDevs,mets[di].devs.length);} | |
| for(var j=0;j<maxDevs;j++){ | |
| var sum=0,nn=0,lbl=null,table='pos'; | |
| for(var i5=s0;i5<=s1;i5++){var dv=mets[i5]&&mets[i5].devs&&mets[i5].devs[j];if(dv){sum+=dv.val;nn++;lbl=dv.label;table=dv.table;}} | |
| if(nn){var avg=sum/nn,ad=(table==='bend'?bendDed:angleDed)(avg);found.push({label:lbl,ded:ad.ded,kind:ad.kind,detail:Math.round(avg)+'°',nr:ad.nr});if(ad.nr)recognized=false;} | |
| } | |
| renderDeductions(found,recognized); | |
| log('skill='+cur+' frames='+frames.length+' hold='+holdDur.toFixed(2)+'s recog='+recognized+' deds='+found.length,'ok'); | |
| var parkT=bestStart>=0?frames[Math.floor((bestStart+bestEnd)/2)].t:frames[Math.floor(frames.length/2)].t; | |
| if(el.video.duration){await seekTo(parkT);if(useKP){renderFrame();}else{await process();}} | |
| updateTime(); | |
| el.progBar.style.width='0%';el.status.innerHTML='<b>ready</b>';analyzing=false; | |
| el.analyze.disabled=false;['play','stepBack','stepFwd','scrub'].forEach(function(k){el[k].disabled=false;}); | |
| } | |
| // ===================== PLAYBACK ===================== | |
| var playing=false,lastT=-1; | |
| function loop(){if(!playing)return;if(el.video.currentTime!==lastT&&!busy){lastT=el.video.currentTime;updateTime();renderFrame();}if(el.video.ended){stop();return;}requestAnimationFrame(loop);} | |
| function playV(){if(!el.video.src)return;playing=true;el.play.textContent='Pause';el.video.play();requestAnimationFrame(loop);} | |
| function stop(){playing=false;el.play.textContent='Play';el.video.pause();} | |
| el.file.addEventListener('change',function(e){ | |
| var f=e.target.files[0];if(!f)return;log('file: '+f.name,'ok');el.video.src=URL.createObjectURL(f); | |
| el.video.onloadedmetadata=function(){ | |
| log('video: '+el.video.videoWidth+'x'+el.video.videoHeight,'ok'); | |
| el.placeholder.style.display='none';resize();el.video.currentTime=0; | |
| if(!useKP){el.dedEmpty.style.display='block';el.dedResult.style.display='none';} | |
| ['play','stepBack','stepFwd','scrub','analyze'].forEach(function(k){el[k].disabled=false;}); | |
| setTimeout(function(){drawBase();updateTime();renderFrame();},80); | |
| try{var c=localStorage.getItem(cacheKey(f));if(c){log('found cached keypoints for this clip — loading instantly (Run on server to refresh)','ok');ingestKeypoints(JSON.parse(c),'cache');}else{log('no cache yet — hit Run on server','ok');}}catch(e){} | |
| }; | |
| el.video.onerror=function(){log('VIDEO ERROR: use MP4 (H.264).','err');}; | |
| }); | |
| // shared: take a keypoints object, smooth it, drive the analyzer, auto-run | |
| function ingestKeypoints(d,src){ | |
| if(!d||!d.frames||!d.frames.length){log('keypoints: no frames found','err');return;} | |
| d.frames=smoothSequence(d.frames,4); | |
| kpData=d;useKP=true; | |
| log('keypoints '+(src==='cache'?'(cached) ':'')+'loaded: '+d.frames.length+' frames, '+d.width+'x'+d.height+', '+(d.source||''),'ok'); | |
| el.status.innerHTML='<b>keypoints ready</b>';el.placeholder.style.display='none'; | |
| el.dedEmpty.style.display='block';el.dedResult.style.display='none'; | |
| ['analyze','scrub','play','stepBack','stepFwd'].forEach(function(k){el[k].disabled=false;}); | |
| renderFrame();runAnalyze(); | |
| } | |
| function cacheKey(f){return f?('stoikp:'+f.name+'_'+f.size):null;} | |
| async function runOnServer(force){ | |
| var f=el.file.files&&el.file.files[0]; | |
| if(!f){log('load a video first, then Run on server','err');return;} | |
| var key=cacheKey(f); | |
| if(!force){try{var c=localStorage.getItem(key);if(c){ingestKeypoints(JSON.parse(c),'cache');return;}}catch(e){}} | |
| if(!window.GradioClient){log('server client still loading — wait a second and click again','err');return;} | |
| var sid=(el.spaceId.value||'').trim();if(!sid){log('set your Space id first','err');return;} | |
| el.serverBtn.disabled=true;el.status.innerHTML='<b>connecting to server…</b>';log('connecting to '+sid,'ok'); | |
| try{ | |
| var app=await window.GradioClient.connect(sid); | |
| el.status.innerHTML='<b>server analyzing… first run wakes the GPU (~1 min)</b>';log('uploading clip, running pose…','ok'); | |
| var res=await app.predict('/predict',[f]); | |
| var txt=res&&res.data&&res.data[0]; | |
| if(!txt){log('server returned nothing','err');el.status.innerHTML='<b>server returned nothing</b>';el.serverBtn.disabled=false;return;} | |
| var d=JSON.parse(txt); | |
| try{localStorage.setItem(key,txt);}catch(e){log('clip too big to cache — it will re-run each time','err');} | |
| ingestKeypoints(d,'server'); | |
| }catch(err){log('server error: '+err,'err');el.status.innerHTML='<b>server error — check the Space is Running</b>';} | |
| el.serverBtn.disabled=false; | |
| } | |
| el.kpFile.addEventListener('change',function(e){ | |
| var f=e.target.files[0];if(!f)return; | |
| var rd=new FileReader(); | |
| rd.onload=function(){try{ingestKeypoints(JSON.parse(rd.result),'file');}catch(err){log('keypoints parse error: '+err,'err');}}; | |
| rd.readAsText(f); | |
| }); | |
| el.serverBtn.addEventListener('click',function(){runOnServer(true);}); | |
| el.skillSel.addEventListener('change',function(){cur=el.skillSel.value;applySkillLabels();el.dedEmpty.style.display='block';el.dedResult.style.display='none';if(el.video.src||useKP)renderFrame();log('skill: '+cur,'ok');}); | |
| el.analyze.addEventListener('click',function(){runAnalyze();}); | |
| el.play.addEventListener('click',function(){playing?stop():playV();}); | |
| el.scrub.addEventListener('input',function(){if(!el.video.duration)return;stop();resetSmooth();resetHold();resetLmEma();el.video.currentTime=el.scrub.value/1000*el.video.duration;}); | |
| el.video.addEventListener('seeked',function(){if(!playing&&!busy&&!analyzing){resetSmooth();resetHold();resetLmEma();updateTime();renderFrame();}}); | |
| el.stepFwd.addEventListener('click',function(){stop();el.video.currentTime=Math.min(el.video.duration,el.video.currentTime+1/30);}); | |
| el.stepBack.addEventListener('click',function(){stop();el.video.currentTime=Math.max(0,el.video.currentTime-1/30);}); | |
| el.modelSel.addEventListener('change',function(){if(poseReady&&!useKP){pose.setOptions({modelComplexity:parseInt(el.modelSel.value,10)});if(el.video.src)process();}}); | |
| el.stillSlider.addEventListener('input',function(){el.stillVal.textContent=el.stillSlider.value+'°';}); | |
| document.addEventListener('keydown',function(e){if(el.play.disabled)return;if(e.code==='Space'){e.preventDefault();playing?stop():playV();}if(e.code==='ArrowRight')el.stepFwd.click();if(e.code==='ArrowLeft')el.stepBack.click();}); | |
| applySkillLabels();log('UI wired','ok');waitForPose(40); | |
| })(); | |
| </script> | |
| </body> | |
| </html> | |