AndriiRohovyi's picture
Upload 2 files
c54cb53 verified
Raw History Blame Contribute Delete
26 kB
<!doctype html>
<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>