Spaces:
Running
Running
Download index.html from Postdata/vehicle-exit-detection: direct link, hf CLI and curl.
- Browser
- Download file 26 kB
-
https://huggingface.co/spaces/Postdata/vehicle-exit-detection/resolve/main/index.html
- Command line
-
hf download hf://spaces/Postdata/vehicle-exit-detection/index.html
-
curl -L -o index.html https://huggingface.co/spaces/Postdata/vehicle-exit-detection/resolve/main/index.html
26 kB
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| <title>Postdata AI · Vehicle Exit Detection</title> | |
| <link rel="preconnect" href="https://fonts.googleapis.com"> | |
| <link href="https://fonts.googleapis.com/css2?family=Chakra+Petch:wght@500;600&family=Funnel+Sans:wght@300..700&display=swap" rel="stylesheet"> | |
| <script src="https://cdn.jsdelivr.net/npm/onnxruntime-web@1.20.1/dist/ort.min.js"></script> | |
| <style> | |
| :root{--bg:#0A0B0F;--card:#13151B;--line:#2C2F38;--text:#F2F2F4;--muted:#A9ADB6;--accent:#E8453C;--ok:#7CC87A;--warn:#F5C542} | |
| *{box-sizing:border-box} | |
| body{margin:0;background:var(--bg);color:var(--text);font-family:'Funnel Sans',Arial,sans-serif} | |
| .wrap{max-width:1100px;margin:0 auto;padding:28px 16px 60px} | |
| .brand{display:flex;align-items:center;gap:10px;color:var(--muted);font-size:15px;letter-spacing:.04em} | |
| .brand b{font-family:'Chakra Petch',sans-serif;color:var(--text);font-size:20px;letter-spacing:0} | |
| .tag{display:inline-block;background:#30333C;color:#E6E7EA;padding:6px 14px;font-size:14px;margin:22px 0 10px} | |
| h1{font-family:'Chakra Petch',sans-serif;font-weight:500;font-size:clamp(28px,4.5vw,46px);line-height:1.1;margin:0 0 10px} | |
| p.lead{color:var(--muted);font-size:17px;max-width:820px;line-height:1.5;margin:0 0 22px} | |
| .controls{display:flex;flex-wrap:wrap;gap:10px;align-items:center;margin-bottom:14px} | |
| button,label.btn{background:var(--card);color:var(--text);border:1px solid var(--line);padding:10px 16px;font:inherit;font-size:15px;cursor:pointer} | |
| button.primary{background:var(--accent);border-color:var(--accent);color:#fff} | |
| button:disabled{opacity:.45;cursor:default} | |
| input[type=file]{display:none} | |
| .stage{background:var(--card);border:1px solid var(--line);position:relative} | |
| canvas#view{display:block;width:100%;height:auto;background:#000} | |
| .status{display:flex;justify-content:space-between;gap:12px;flex-wrap:wrap;padding:10px 14px;border-top:1px solid var(--line);color:var(--muted);font-size:14px} | |
| .bar{height:4px;background:#1d2027}.bar i{display:block;height:100%;width:0;background:var(--accent);transition:width .2s} | |
| .grid{display:grid;grid-template-columns:1.2fr 1fr;gap:18px;margin-top:18px} | |
| @media(max-width:760px){.grid{grid-template-columns:1fr}} | |
| .card{background:var(--card);border:1px solid var(--line);padding:16px} | |
| .card h3{font-family:'Chakra Petch',sans-serif;font-weight:500;margin:0 0 10px;font-size:20px} | |
| table{width:100%;border-collapse:collapse;font-size:14px} | |
| td,th{text-align:left;padding:7px 6px;border-bottom:1px solid var(--line)} | |
| th{color:var(--muted);font-weight:400} | |
| .alert{color:#ff7b72;font-weight:600} | |
| .legend span{display:inline-flex;align-items:center;gap:6px;margin-right:14px;font-size:14px;color:var(--muted)} | |
| .legend i{width:12px;height:12px;display:inline-block} | |
| .foot{color:#7D818B;font-size:13px;margin-top:26px;line-height:1.6} | |
| a{color:var(--muted)} | |
| </style> | |
| </head> | |
| <body> | |
| <div class="wrap"> | |
| <div class="brand"><b>Postdata</b> AI</div> | |
| <div class="tag">Computer vision demo · Pose estimation · Runs in your browser</div> | |
| <h1>Detect the moment a person gets out of a vehicle</h1> | |
| <p class="lead">RTMPose finds 17 body keypoints for every person and RTMDet finds the vehicles. A simple rule turns this into an event: the person was inside the vehicle zone, then they stand up on their own feet or step away from the vehicle, so the app raises <b>Person out of vehicle</b> with a timestamp. Your video never leaves this device.</p> | |
| <div class="controls"> | |
| <button id="exBtn" class="primary">▶ Run example: film scene (1968, public domain)</button> | |
| <label class="btn" for="file">Upload your own video</label><input id="file" type="file" accept="video/*"> | |
| <button id="liveBtn">Re-run example live</button> | |
| <button id="playBtn" disabled>Replay result</button> | |
| <button id="recBtn" disabled>Save result as video</button> | |
| </div> | |
| <div class="stage"> | |
| <canvas id="view" width="960" height="540"></canvas> | |
| <div class="bar"><i id="prog"></i></div> | |
| <div class="status"><span id="status">Press ▶ Run example to see the result.</span><span id="perf"></span></div> | |
| </div> | |
| <div class="grid"> | |
| <div class="card"> | |
| <h3>Events</h3> | |
| <table><thead><tr><th>Time</th><th>Person</th><th>Event</th></tr></thead><tbody id="events"><tr><td colspan="3" style="color:#7D818B">Run the example or upload a clip.</td></tr></tbody></table> | |
| </div> | |
| <div class="card"> | |
| <h3>How it works</h3> | |
| <div class="legend" style="margin-bottom:10px"> | |
| <span><i style="background:#28a0e6"></i>Vehicle</span> | |
| <span><i style="background:#ffb43c"></i>In vehicle</span> | |
| <span><i style="background:#ffd700"></i>Exiting</span> | |
| <span><i style="background:#f03c3c"></i>Out of vehicle</span> | |
| <span><i style="background:#78c88c"></i>On foot</span> | |
| </div> | |
| <p style="color:var(--muted);font-size:14px;line-height:1.55;margin:0">The same pipeline covers truck-yard rules: driver out of the cab, a person in a truck lane, or a wheel chock check. In production it runs in real time on site cameras, on-prem or in your own Azure tenant.</p> | |
| </div> | |
| </div> | |
| <div class="foot"> | |
| Models: <a href="https://github.com/open-mmlab/mmpose/tree/main/projects/rtmpose">RTMPose-m</a> (body keypoints) and <a href="https://github.com/open-mmlab/mmdetection/tree/main/configs/rtmdet">RTMDet-s</a> (COCO objects), OpenMMLab, Apache-2.0, run with ONNX Runtime Web. | |
| Example footage: <i>Night of the Living Dead</i> (1968, dir. George A. Romero), public domain, via <a href="https://commons.wikimedia.org/wiki/File:Night_of_the_Living_Dead_(1968).webm">Wikimedia Commons</a>. | |
| Demo by <a href="https://www.postdata.ai">Postdata AI</a>. This is a demo, not a safety system. | |
| </div> | |
| </div> | |
| <script> | |
| const MODEL_BASES = ['models/', 'https://huggingface.co/ziq/rtm/resolve/main/']; | |
| const EXAMPLE = { url: 'example.webm', start: 0.8, end: 8.4 }; | |
| const ANALYSIS_FPS = 10, MAX_SECONDS = 30, VEHICLES = {2:'car',5:'bus',7:'truck'}, PERSON = 0; | |
| const L_HIP=11,R_HIP=12,L_KNEE=13,R_KNEE=14,L_ANK=15,R_ANK=16; | |
| const SKELETON=[[15,13],[13,11],[16,14],[14,12],[11,12],[5,11],[6,12],[5,6],[5,7],[6,8],[7,9],[8,10],[1,2],[0,1],[0,2],[1,3],[2,4],[3,5],[4,6]]; | |
| const COLORS={'IN VEHICLE':'#ffb43c','EXITING':'#ffd700','OUT OF VEHICLE':'#f03c3c','ON FOOT':'#78c88c'}; | |
| const $=id=>document.getElementById(id); | |
| const view=$('view'), vctx=view.getContext('2d'); | |
| const work=document.createElement('canvas'), wctx=work.getContext('2d',{willReadFrequently:true}); | |
| const crop=document.createElement('canvas'); crop.width=192; crop.height=256; const cctx=crop.getContext('2d',{willReadFrequently:true}); | |
| let det=null, pose=null, video=null, results=[], events=[], clip={start:0,end:0}, busy=false; | |
| window.lastClip=null; | |
| let modelMsg='Loading models in the background…'; | |
| function placeholder(msg){ if(video) return; const W=view.width,H=view.height; vctx.fillStyle='#0E1015'; vctx.fillRect(0,0,W,H); | |
| vctx.textAlign='center'; vctx.fillStyle='#F2F2F4'; vctx.font='500 26px "Chakra Petch",Arial'; vctx.fillText(msg,W/2,H/2-6); | |
| vctx.fillStyle='#7D818B'; vctx.font='400 16px "Funnel Sans",Arial'; vctx.fillText(modelMsg,W/2,H/2+26); vctx.textAlign='left'; } | |
| function setStatus(s){$('status').textContent=s; placeholder(s)} | |
| function setModelMsg(s){ modelMsg=s; if(!busy) placeholder($('status').textContent); } | |
| function setProg(f){$('prog').style.width=(100*f).toFixed(1)+'%'} | |
| const prog={}; | |
| async function fetchModel(name){ | |
| for(const base of MODEL_BASES){ | |
| try{ | |
| const r=await fetch(base+name); if(!r.ok) continue; | |
| const total=+r.headers.get('content-length')||0; const rd=r.body.getReader(); const parts=[]; let got=0; | |
| while(true){ const {done,value}=await rd.read(); if(done) break; parts.push(value); got+=value.length; prog[name]=[got,total]; | |
| const g=Object.values(prog).reduce((a,b)=>a+b[0],0), t=Object.values(prog).reduce((a,b)=>a+(b[1]||b[0]),0); | |
| setModelMsg(`Live mode: loading models ${Math.round(100*g/Math.max(t,1))}% (≈95 MB, first time only)`); } | |
| const buf=new Uint8Array(got); let o=0; for(const p of parts){ buf.set(p,o); o+=p.length; } return buf; | |
| }catch(e){ /* try next source */ } | |
| } | |
| throw new Error('could not download '+name); | |
| } | |
| let pendingRun=null; | |
| async function loadModels(){ | |
| ort.env.wasm.wasmPaths='https://cdn.jsdelivr.net/npm/onnxruntime-web@1.20.1/dist/'; | |
| ort.env.wasm.numThreads = self.crossOriginIsolated ? Math.min(4, navigator.hardwareConcurrency||1) : 1; | |
| const opt={executionProviders:['wasm'],graphOptimizationLevel:'all'}; | |
| const [a,b]=await Promise.all([fetchModel('rtmdet-s.onnx'),fetchModel('rtmpose-m.onnx')]); | |
| setModelMsg('Live mode: preparing models…'); | |
| det=await ort.InferenceSession.create(a,opt); pose=await ort.InferenceSession.create(b,opt); | |
| setModelMsg('Live mode ready: upload your own clip.'); if(!busy && !video) setStatus('Press ▶ Run example to see the result.'); | |
| if(pendingRun){ const f=pendingRun; pendingRun=null; f(); } | |
| } | |
| // ---------- detection (RTMDet-s, COCO, end2end with NMS) ---------- | |
| async function detect(src,W,H){ | |
| const S=640, r=Math.min(S/W,S/H); | |
| work.width=S; work.height=S; wctx.fillStyle='rgb(114,114,114)'; wctx.fillRect(0,0,S,S); | |
| wctx.drawImage(src,0,0,Math.round(W*r),Math.round(H*r)); | |
| const px=wctx.getImageData(0,0,S,S).data, n=S*S, f=new Float32Array(3*n); | |
| for(let i=0;i<n;i++){const R=px[4*i],G=px[4*i+1],B=px[4*i+2]; | |
| f[i]=(B-103.53)/57.375; f[n+i]=(G-116.28)/57.12; f[2*n+i]=(R-123.675)/58.395;} | |
| const out=await det.run({[det.inputNames[0]]:new ort.Tensor('float32',f,[1,3,S,S])}); | |
| let dets,labels; for(const k of det.outputNames){const t=out[k]; if(t.dims.length===3&&t.dims[2]===5) dets=t; else labels=t;} | |
| const res=[]; const N=dets.dims[1]; | |
| for(let i=0;i<N;i++){const s=dets.data[5*i+4]; if(s<0.3) continue; | |
| const lab=Number(labels.data[i]); | |
| res.push({x1:dets.data[5*i]/r,y1:dets.data[5*i+1]/r,x2:dets.data[5*i+2]/r,y2:dets.data[5*i+3]/r,score:s,label:lab});} | |
| return res; | |
| } | |
| // ---------- pose (RTMPose-m, SimCC, 256x192) ---------- | |
| async function estimate(src,b){ | |
| let cx=(b.x1+b.x2)/2, cy=(b.y1+b.y2)/2, w=(b.x2-b.x1)*1.25, h=(b.y2-b.y1)*1.25; | |
| const ar=192/256; if(w>h*ar) h=w/ar; else w=h*ar; | |
| const x0=cx-w/2, y0=cy-h/2; | |
| cctx.fillStyle='#000'; cctx.fillRect(0,0,192,256); | |
| cctx.drawImage(src,x0,y0,w,h,0,0,192,256); | |
| const px=cctx.getImageData(0,0,192,256).data, n=192*256, f=new Float32Array(3*n); | |
| for(let i=0;i<n;i++){f[i]=(px[4*i]-123.675)/58.395; f[n+i]=(px[4*i+1]-116.28)/57.12; f[2*n+i]=(px[4*i+2]-103.53)/57.375;} | |
| const out=await pose.run({[pose.inputNames[0]]:new ort.Tensor('float32',f,[1,3,256,192])}); | |
| let sx,sy; for(const k of pose.outputNames){const t=out[k]; if(t.dims[2]===384) sx=t; else sy=t;} | |
| const kp=[],sc=[]; | |
| for(let j=0;j<17;j++){ | |
| let mx=-1e9,ix=0; for(let i=0;i<384;i++){const v=sx.data[j*384+i]; if(v>mx){mx=v;ix=i}} | |
| let my=-1e9,iy=0; for(let i=0;i<512;i++){const v=sy.data[j*512+i]; if(v>my){my=v;iy=i}} | |
| kp.push([x0+(ix/2)/192*w, y0+(iy/2)/256*h]); sc.push(Math.min(mx,my)); | |
| } | |
| return {kp,sc}; | |
| } | |
| // ---------- geometry & state ---------- | |
| function angle(k,a,b,c){const v1=[k[a][0]-k[b][0],k[a][1]-k[b][1]],v2=[k[c][0]-k[b][0],k[c][1]-k[b][1]]; | |
| const cos=(v1[0]*v2[0]+v1[1]*v2[1])/(Math.hypot(...v1)*Math.hypot(...v2)+1e-6); return Math.acos(Math.max(-1,Math.min(1,cos)))*180/Math.PI;} | |
| function standing(p){const s=p.sc,thr=0.45; | |
| if(Math.min(s[L_ANK],s[R_ANK],s[L_KNEE],s[R_KNEE],s[L_HIP],s[R_HIP])<thr) return false; | |
| return angle(p.kp,L_HIP,L_KNEE,L_ANK)>150 && angle(p.kp,R_HIP,R_KNEE,R_ANK)>150;} | |
| function overlap(p,v){const ix=Math.max(0,Math.min(p.x2,v.x2)-Math.max(p.x1,v.x1)),iy=Math.max(0,Math.min(p.y2,v.y2)-Math.max(p.y1,v.y1)); | |
| return ix*iy/Math.max(1,(p.x2-p.x1)*(p.y2-p.y1));} | |
| function containment(a,b){const ix=Math.max(0,Math.min(a.x2,b.x2)-Math.max(a.x1,b.x1)),iy=Math.max(0,Math.min(a.y2,b.y2)-Math.max(a.y1,b.y1)); | |
| return ix*iy/Math.max(1,Math.min((a.x2-a.x1)*(a.y2-a.y1),(b.x2-b.x1)*(b.y2-b.y1)));} | |
| function sim(a,b){const d=Math.hypot((a.x1+a.x2-b.x1-b.x2)/2,(a.y1+a.y2-b.y1-b.y2)/2), w=Math.max(a.x2-a.x1,b.x2-b.x1); | |
| return Math.max(iou(a,b), d<0.6*w ? 0.5*(1-d/(0.6*w)) : 0);} | |
| function iou(a,b){const ix=Math.max(0,Math.min(a.x2,b.x2)-Math.max(a.x1,b.x1)),iy=Math.max(0,Math.min(a.y2,b.y2)-Math.max(a.y1,b.y1)),i=ix*iy; | |
| return i/((a.x2-a.x1)*(a.y2-a.y1)+(b.x2-b.x1)*(b.y2-b.y1)-i+1e-6);} | |
| let tracks=[], nextId=1; | |
| function updateTracks(people,vehicles,t){ | |
| const pairs=[], used=new Set(), cand=[]; | |
| tracks.forEach((tr,ti)=>people.forEach((p,pi)=>cand.push([sim(tr.box,p.box),ti,pi]))); | |
| cand.sort((a,b)=>b[0]-a[0]); const ta=new Set(); | |
| for(const [v,ti,pi] of cand){ if(v<0.1||ta.has(ti)||used.has(pi)) continue; ta.add(ti); used.add(pi); pairs.push([tracks[ti],pi]); } | |
| tracks.forEach((tr,ti)=>{ if(!ta.has(ti)) tr.missed++; else tr.missed=0; }); | |
| people.forEach((p,pi)=>{ if(!used.has(pi)){ const tr={id:nextId++,box:p.box,state:null,inVeh:false,standT:null,awayT:null,missed:0}; tracks.push(tr); pairs.push([tr,pi]); }}); | |
| tracks=tracks.filter(tr=>tr.missed<ANALYSIS_FPS*1.5); | |
| let alert=null; | |
| for(const [tr,pi] of pairs){ | |
| const p=people[pi]; tr.box=p.box; | |
| const near=Math.max(0,...vehicles.map(v=>overlap(p.box,v))); | |
| const st=standing(p); p.standing=st; | |
| tr.standT = st ? (tr.standT??t) : null; | |
| tr.awayT = (tr.inVeh && near<0.25 && p.sc[5]>0.4 && p.sc[6]>0.4) ? (tr.awayT??t) : null; | |
| let s; | |
| if(near>0.3 && !st){ s = (tr.state===null||tr.state==='IN VEHICLE') ? 'IN VEHICLE' : 'EXITING'; tr.inVeh=true; } | |
| else if(tr.inVeh && ((tr.standT!==null && t-tr.standT>=0.4) || (tr.awayT!==null && t-tr.awayT>=0.5))) s='OUT OF VEHICLE'; | |
| else if(tr.inVeh) s='EXITING'; | |
| else s='ON FOOT'; | |
| if(s==='EXITING' && tr.state==='IN VEHICLE') events.push({t,id:tr.id,ev:'Exiting vehicle'}); | |
| if(s==='OUT OF VEHICLE' && tr.state!=='OUT OF VEHICLE' && !events.some(e=>e.alert && t-e.t<2)){ events.push({t,id:tr.id,ev:'Person out of vehicle',alert:true}); alert=`ALERT · person #${tr.id} out of vehicle at ${fmt(t)}`; } | |
| tr.state=s; p.id=tr.id; p.state=s; | |
| } | |
| return alert; | |
| } | |
| function fmt(t){return (t-clip.start).toFixed(1)+' s'} | |
| // ---------- drawing ---------- | |
| function drawFrame(r,t,lastAlert){ | |
| const W=view.width,H=view.height; | |
| vctx.drawImage(curImg||video,0,0,W,H); | |
| if(!r) return; | |
| const k=W/video.videoWidth; | |
| vctx.lineWidth=3; vctx.font='600 15px "Funnel Sans",Arial'; | |
| for(const v of r.vehicles){ vctx.strokeStyle='#28a0e6'; vctx.strokeRect(v.x1*k,v.y1*k,(v.x2-v.x1)*k,(v.y2-v.y1)*k); | |
| tag(`vehicle ${v.score.toFixed(2)}`,v.x1*k,v.y1*k,'#28a0e6'); } | |
| for(const p of r.people){ | |
| const col=COLORS[p.state]||'#fff'; | |
| vctx.strokeStyle='rgba(255,255,255,.9)'; vctx.lineWidth=3; | |
| for(const [a,b] of SKELETON){ if(p.sc[a]<0.35||p.sc[b]<0.35) continue; | |
| vctx.beginPath(); vctx.moveTo(p.kp[a][0]*k,p.kp[a][1]*k); vctx.lineTo(p.kp[b][0]*k,p.kp[b][1]*k); vctx.stroke(); } | |
| for(let j=0;j<17;j++){ if(p.sc[j]<0.35) continue; vctx.fillStyle=j>=11?col:'#00e5ff'; | |
| vctx.beginPath(); vctx.arc(p.kp[j][0]*k,p.kp[j][1]*k,4,0,7); vctx.fill(); } | |
| if(p.standing) for(const a of [L_ANK,R_ANK]){ vctx.fillStyle='#f03c3c'; vctx.beginPath(); vctx.arc(p.kp[a][0]*k,p.kp[a][1]*k,8,0,7); vctx.fill(); } | |
| vctx.strokeStyle=col; vctx.lineWidth=2; vctx.strokeRect(p.box.x1*k,p.box.y1*k,(p.box.x2-p.box.x1)*k,(p.box.y2-p.box.y1)*k); | |
| tag(`#${p.id} ${p.state}`,p.box.x1*k,p.box.y1*k,col); | |
| } | |
| vctx.fillStyle='rgba(10,11,15,.82)'; vctx.fillRect(0,0,W,34); | |
| vctx.fillStyle='#F2F2F4'; vctx.font='500 16px "Chakra Petch",Arial'; | |
| vctx.fillText(`Postdata AI · RTMPose + RTMDet · t = ${fmt(t)}`,12,23); | |
| if(lastAlert){ vctx.fillStyle='rgba(220,40,40,.92)'; vctx.fillRect(0,H-48,W,48); | |
| vctx.fillStyle='#fff'; vctx.font='600 22px "Chakra Petch",Arial'; vctx.fillText(lastAlert,16,H-16); } | |
| } | |
| function tag(text,x,y,col){ const w=vctx.measureText(text).width+12; vctx.fillStyle=col; vctx.fillRect(x,Math.max(0,y-22),w,22); | |
| vctx.fillStyle='#111'; vctx.fillText(text,x+6,Math.max(16,y-6)); } | |
| function renderEvents(){ | |
| const tb=$('events'); if(!events.length){ tb.innerHTML='<tr><td colspan="3" style="color:#7D818B">No exit detected in this clip.</td></tr>'; return; } | |
| tb.innerHTML=events.map(e=>`<tr><td>${fmt(e.t)}</td><td>#${e.id}</td><td class="${e.alert?'alert':''}">${e.ev}</td></tr>`).join(''); | |
| } | |
| // ---------- analysis & playback ---------- | |
| function seek(t){return new Promise(res=>{video.onseeked=()=>res(); video.currentTime=t;});} | |
| async function openVideo(src,start,end){ | |
| if(video){video.pause(); video.removeAttribute('src'); video.load();} | |
| video=document.createElement('video'); video.crossOrigin='anonymous'; video.muted=true; video.playsInline=true; video.preload='auto'; video.src=src; | |
| await new Promise((res,rej)=>{video.onloadedmetadata=res; video.onerror=()=>rej(new Error('Cannot open this video'));}); | |
| clip.start=start||0; clip.end=Math.min(end||video.duration, clip.start+MAX_SECONDS); | |
| const W=960; view.width=W; view.height=Math.round(W*video.videoHeight/video.videoWidth); | |
| } | |
| // Grab frames by normal 1x playback (reliable on any browser and on files without a seek index), then analyse them. | |
| function captureFrames(){ return new Promise(async (res)=>{ | |
| const frames=[]; const steps=Math.max(1,Math.round((clip.end-clip.start)*ANALYSIS_FPS)); let next=0, fin=false; | |
| const W=video.videoWidth,H=video.videoHeight; | |
| const grab=async(mt)=>{ while(next<=steps && mt>=clip.start+next/ANALYSIS_FPS-0.03){ const c=document.createElement('canvas'); c.width=W; c.height=H; c.getContext('2d').drawImage(video,0,0,W,H); frames.push({t:clip.start+next/ANALYSIS_FPS,img:c}); next++; } | |
| setProg(0.25*next/(steps+1)); setStatus(`Reading video… ${Math.round(100*next/(steps+1))}%`); | |
| if(next>steps||video.ended||mt>=clip.end+0.05) done(); }; | |
| const done=()=>{ if(fin) return; fin=true; video.pause(); clearInterval(iv); res(frames); }; | |
| const cb=(now,meta)=>{ if(fin) return; grab(meta.mediaTime); if(!fin) video.requestVideoFrameCallback(cb); }; | |
| await seek(clip.start); video.playbackRate=1; | |
| if(video.requestVideoFrameCallback) video.requestVideoFrameCallback(cb); | |
| const iv=setInterval(()=>{ if(!video.requestVideoFrameCallback) grab(video.currentTime); if(video.ended) done(); },20); | |
| video.onended=done; video.play().catch(done); | |
| });} | |
| let curImg=null; | |
| async function analyze(){ | |
| busy=true; ['exBtn','liveBtn','playBtn','recBtn'].forEach(id=>$(id).disabled=true); | |
| results=[]; events=[]; tracks=[]; nextId=1; renderEvents(); | |
| const W=video.videoWidth,H=video.videoHeight; | |
| const frames=await captureFrames(); const t0=performance.now(); | |
| let lastAlert=null, alertT=-1; | |
| for(let i=0;i<frames.length;i++){ | |
| const {t,img}=frames[i]; curImg=img; | |
| const dets=await detect(img,W,H); | |
| const vehicles=dets.filter(d=>d.label in VEHICLES && d.score>0.35); | |
| const people=[], kept=[]; | |
| for(const d of dets.filter(d=>d.label===PERSON && d.score>0.4).sort((a,b)=>b.score-a.score)){ | |
| if(kept.some(k=>containment(d,k)>0.6)) continue; kept.push(d); | |
| } | |
| for(const d of kept.slice(0,6)){ | |
| const p=await estimate(img,d); const good=p.sc.filter(s=>s>0.3).length; if(good<5) continue; | |
| p.box={x1:d.x1,y1:d.y1,x2:d.x2,y2:d.y2}; people.push(p); | |
| } | |
| const a=updateTracks(people,vehicles,t); if(a){lastAlert=a; alertT=t;} | |
| const r={t,vehicles,people:people.map(p=>({...p})),alert:(t-alertT<2.5)?lastAlert:null}; | |
| results.push(r); drawFrame(r,t,r.alert); renderEvents(); | |
| setProg(0.25+0.75*(i+1)/frames.length); const fps=(i+1)/((performance.now()-t0)/1000); | |
| setStatus(`Analysing frame ${i+1} / ${frames.length}`); $('perf').textContent=`${fps.toFixed(1)} frames/s in this browser`; | |
| } | |
| curImg=null; | |
| setStatus(`Done. ${events.filter(e=>e.alert).length} exit event(s). Press "Replay result" or "Save result as video".`); | |
| busy=false; ['exBtn','liveBtn','playBtn','recBtn'].forEach(id=>$(id).disabled=false); | |
| } | |
| function nearest(t){let best=null,bd=1e9; for(const r of results){const d=Math.abs(r.t-t); if(d<bd){bd=d;best=r}} return best;} | |
| async function replay(record=false){ | |
| if(!results.length) return; | |
| ['exBtn','liveBtn','playBtn','recBtn'].forEach(id=>$(id).disabled=true); | |
| await seek(clip.start); | |
| let rec=null, chunks=[]; | |
| if(record){ | |
| const type=['video/mp4;codecs=avc1','video/webm;codecs=vp9','video/webm'].find(t=>MediaRecorder.isTypeSupported(t)); | |
| rec=new MediaRecorder(view.captureStream(30),{mimeType:type,videoBitsPerSecond:6e6}); | |
| rec.ondataavailable=e=>e.data.size&&chunks.push(e.data); rec.start(); | |
| } | |
| video.playbackRate=1; await video.play(); | |
| await new Promise(res=>{ | |
| const loop=()=>{ const t=video.currentTime; const r=nearest(t); drawFrame(r,t,r&&r.alert); | |
| if(t>=clip.end||video.ended){ video.pause(); res(); return; } requestAnimationFrame(loop); }; | |
| requestAnimationFrame(loop); | |
| }); | |
| if(rec){ await new Promise(res=>{rec.onstop=res; rec.stop();}); | |
| const blob=new Blob(chunks,{type:rec.mimeType}); window.lastClip=blob; | |
| if(!window.NO_DOWNLOAD){ const a=document.createElement('a'); a.href=URL.createObjectURL(blob); a.download='postdata-rtmpose-exit.'+(rec.mimeType.includes('mp4')?'mp4':'webm'); a.click(); } } | |
| ['exBtn','liveBtn','playBtn','recBtn'].forEach(id=>$(id).disabled=false); | |
| } | |
| let _exPromise=null; | |
| function exampleSrc(){ | |
| if(!_exPromise) _exPromise=(async()=>{ | |
| for(let attempt=0; attempt<4; attempt++){ | |
| const ac=new AbortController(); const to=setTimeout(()=>ac.abort(),25000); | |
| try{ const r=await fetch(EXAMPLE.url+(attempt?('?r='+attempt):''),{signal:ac.signal}); if(!r.ok) throw new Error(r.status); | |
| const b=await r.blob(); clearTimeout(to); return URL.createObjectURL(b); } | |
| catch(e){ clearTimeout(to); } | |
| } | |
| _exPromise=null; throw new Error('could not load the example clip, please retry'); | |
| })(); | |
| return _exPromise; | |
| } | |
| exampleSrc().catch(()=>{}); | |
| let _preP=null; function precomputed(){ if(!_preP) _preP=fetch('example-results.json').then(r=>{if(!r.ok) throw new Error('results '+r.status); return r.json();}).catch(e=>{_preP=null; throw e;}); return _preP; } | |
| precomputed().catch(()=>{}); | |
| $('exBtn').onclick=async()=>{ if(busy) return; busy=true; ['exBtn','liveBtn','playBtn','recBtn'].forEach(id=>$(id).disabled=true); | |
| try{ setStatus('Loading example clip…'); setProg(0); | |
| const [src,pre]=await Promise.all([exampleSrc(),precomputed()]); | |
| await openVideo(src,EXAMPLE.start,EXAMPLE.end); clip.end=Math.min(clip.end,pre.results[pre.results.length-1].t+0.1); results=pre.results; events=pre.events; renderEvents(); setProg(1); | |
| setStatus('Playing the RTMPose + RTMDet output for this clip (computed in the browser with these models).'); | |
| $('perf').textContent='Re-run live or upload your own clip to run the models on this device'; | |
| busy=false; await replay(false); | |
| setStatus(`Done. ${events.filter(e=>e.alert).length} exit event(s). Press "Replay result", "Re-run example live" or upload your own clip.`); | |
| }catch(e){ setStatus('Error: '+e.message+'. Please press the button again.'); } | |
| busy=false; ['exBtn','liveBtn','playBtn','recBtn'].forEach(id=>$(id).disabled=false); }; | |
| $('liveBtn').onclick=async()=>{ if(busy) return; if(!det){ pendingRun=()=>$('liveBtn').onclick(); setStatus('Live run will start as soon as the models are loaded…'); $('perf').textContent=modelMsg; return; } try{ setStatus('Loading example clip…'); await openVideo(await exampleSrc(),EXAMPLE.start,EXAMPLE.end); await analyze(); await replay(false);}catch(e){setStatus('Error: '+e.message)} }; | |
| $('file').onchange=async e=>{ const f=e.target.files[0]; if(!f||busy) return; if(!det){ pendingRun=()=>$('file').onchange(e); setStatus('Your clip will start as soon as the models are loaded…'); $('perf').textContent=modelMsg; return; } try{ await openVideo(URL.createObjectURL(f),0,null); await analyze(); await replay(false);}catch(err){setStatus('Error: '+err.message)} }; | |
| $('playBtn').onclick=()=>replay(false); | |
| $('recBtn').onclick=()=>replay(true); | |
| // Embed hook: a parent page can ask for the example result as a video clip. | |
| window.addEventListener('message', async e=>{ | |
| if(!(e.data && e.data.cmd==='example-record')) return; | |
| while(!det) await new Promise(r=>setTimeout(r,500)); | |
| window.NO_DOWNLOAD=true; | |
| await openVideo(await exampleSrc(),EXAMPLE.start,EXAMPLE.end); await analyze(); await replay(true); | |
| e.source.postMessage({kind:'clip', blob:window.lastClip}, e.origin); | |
| }); | |
| window.addEventListener('message', async e=>{ | |
| if(!(e.data && e.data.cmd==='record-raw')) return; | |
| const v=document.createElement('video'); v.crossOrigin='anonymous'; v.muted=true; v.preload='auto'; v.src=EXAMPLE.url; | |
| await new Promise(r=>v.onloadedmetadata=r); await new Promise(r=>{v.onseeked=r; v.currentTime=EXAMPLE.start-0.8;}); | |
| const W=640,H=480,c=document.createElement('canvas'); c.width=W; c.height=H; const g=c.getContext('2d'); g.drawImage(v,0,0,W,H); | |
| const rec=new MediaRecorder(c.captureStream(30),{mimeType:'video/webm;codecs=vp9',videoBitsPerSecond:3e6}); const ch=[]; | |
| rec.ondataavailable=ev=>ev.data.size&&ch.push(ev.data); | |
| await new Promise(r=>{ if(v.readyState>=4) r(); else {v.oncanplaythrough=r; setTimeout(r,15000);} }); | |
| rec.start(200); await v.play(); | |
| await new Promise(res=>{const loop=()=>{g.drawImage(v,0,0,W,H); if(v.currentTime>=EXAMPLE.end+0.6||v.ended){v.pause(); res(); return;} requestAnimationFrame(loop)}; loop();}); | |
| await new Promise(r=>{rec.onstop=r; rec.stop();}); | |
| e.source.postMessage({kind:'raw', blob:new Blob(ch,{type:'video/webm'})}, e.origin); | |
| }); | |
| placeholder('Press ▶ Run example to see the result.'); | |
| document.fonts && document.fonts.ready.then(()=>placeholder($('status').textContent)); | |
| loadModels().catch(e=>setModelMsg('Live mode unavailable: could not load models ('+e.message+')')); | |
| </script> | |
| </body> | |
| </html> | |