|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title>Track Point Editor</title>
|
|
<style>
|
|
.btn-row {
|
|
display: flex;
|
|
align-items: center;
|
|
margin: 8px 0;
|
|
}
|
|
.btn-row > * { margin-right: 12px; }
|
|
body { font-family: sans-serif; margin: 16px; }
|
|
#topControls, #bottomControls { margin-bottom: 12px; }
|
|
button, input, select, label { margin: 4px; }
|
|
#canvas { border:1px solid #ccc; display: block; margin: auto; }
|
|
#canvas { cursor: crosshair; }
|
|
#trajProgress { width: 200px; height: 16px; margin-left:12px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<h2>Track Point Editor</h2>
|
|
|
|
|
|
<div id="topControls" class="btn-row">
|
|
<input type="file" id="fileInput" accept="image/*">
|
|
<button id="storeBtn">Store Tracks</button>
|
|
</div>
|
|
|
|
|
|
<canvas id="canvas"></canvas>
|
|
|
|
|
|
<div id="bottomControls">
|
|
<div class="btn-row">
|
|
<button id="addTrackBtn">Add Freehand Track</button>
|
|
<button id="deleteLastBtn">Delete Last Track</button>
|
|
<progress id="trajProgress" max="121" value="0" style="display:none;"></progress>
|
|
</div>
|
|
<div class="btn-row">
|
|
<button id="placeCircleBtn">Place Circle</button>
|
|
<button id="addCirclePointBtn">Add Circle Point</button>
|
|
<label>Radius:
|
|
<input type="range" id="radiusSlider" min="10" max="800" value="50" style="display:none;">
|
|
</label>
|
|
</div>
|
|
<div class="btn-row">
|
|
<button id="addStaticBtn">Add Static Point</button>
|
|
<label>Static Frames:
|
|
<input type="number" id="staticFramesInput" value="121" min="1" style="width:60px">
|
|
</label>
|
|
</div>
|
|
<div class="btn-row">
|
|
<select id="trackSelect" style="min-width:160px;"></select>
|
|
<div id="colorIndicator"
|
|
style="
|
|
width:16px;
|
|
height:16px;
|
|
border:1px solid #444;
|
|
display:inline-block;
|
|
vertical-align:middle;
|
|
margin-left:8px;
|
|
pointer-events:none;
|
|
visibility:hidden;
|
|
">
|
|
</div>
|
|
<button id="deleteTrackBtn">Delete Selected</button>
|
|
<button id="editTrackBtn">Edit Track</button>
|
|
<button id="duplicateTrackBtn">Duplicate Track</button>
|
|
</div>
|
|
|
|
<div class="btn-row">
|
|
<label>Motion X (px/frame):
|
|
<input type="number" id="motionXInput" value="0" style="width:60px">
|
|
</label>
|
|
<label>Motion Y (px/frame):
|
|
<input type="number" id="motionYInput" value="0" style="width:60px">
|
|
</label>
|
|
<button id="applySelectedMotionBtn">Add to Selected</button>
|
|
<button id="applyAllMotionBtn">Add to All</button>
|
|
</div>
|
|
</div>
|
|
|
|
|
|
<script>
|
|
|
|
const canvas = document.getElementById('canvas'),
|
|
ctx = canvas.getContext('2d'),
|
|
fileIn = document.getElementById('fileInput'),
|
|
storeBtn = document.getElementById('storeBtn'),
|
|
addTrackBtn = document.getElementById('addTrackBtn'),
|
|
deleteLastBtn = document.getElementById('deleteLastBtn'),
|
|
placeCircleBtn = document.getElementById('placeCircleBtn'),
|
|
addCirclePointBtn = document.getElementById('addCirclePointBtn'),
|
|
addStaticBtn = document.getElementById('addStaticBtn'),
|
|
staticFramesInput = document.getElementById('staticFramesInput'),
|
|
radiusSlider = document.getElementById('radiusSlider'),
|
|
trackSelect = document.getElementById('trackSelect'),
|
|
deleteTrackBtn = document.getElementById('deleteTrackBtn'),
|
|
editTrackBtn = document.getElementById('editTrackBtn'),
|
|
duplicateTrackBtn = document.getElementById('duplicateTrackBtn'),
|
|
trajProg = document.getElementById('trajProgress'),
|
|
colorIndicator = document.getElementById('colorIndicator'),
|
|
motionXInput = document.getElementById('motionXInput'),
|
|
motionYInput = document.getElementById('motionYInput'),
|
|
applySelectedMotionBtn = document.getElementById('applySelectedMotionBtn'),
|
|
applyAllMotionBtn = document.getElementById('applyAllMotionBtn');
|
|
|
|
let img, image_id, ext, origW, origH,
|
|
scaleX=1, scaleY=1;
|
|
|
|
|
|
let free_tracks = [], current_track = [], drawing=false, motionCounter=0;
|
|
let circle=null, static_trajs=[];
|
|
let mode='', selectedTrack=null, editMode=false, editInfo=null, duplicateBuffer=null;
|
|
const COLORS=['red','green','blue','cyan','magenta','yellow','black'],
|
|
FIXED_LENGTH=121,
|
|
editSigma = 5/Math.sqrt(2*Math.log(2));
|
|
|
|
|
|
fileIn.addEventListener('change', async e => {
|
|
const f = e.target.files[0]; if (!f) return;
|
|
const fd = new FormData(); fd.append('image',f);
|
|
const res = await fetch('/upload_image',{method:'POST',body:fd});
|
|
const js = await res.json();
|
|
image_id=js.image_id; ext=js.ext;
|
|
origW=js.orig_width; origH=js.orig_height;
|
|
if(origW>=origH){
|
|
canvas.width=800; canvas.height=Math.round(origH*800/origW);
|
|
} else {
|
|
canvas.height=800; canvas.width=Math.round(origW*800/origH);
|
|
}
|
|
scaleX=origW/canvas.width; scaleY=origH/canvas.height;
|
|
img=new Image(); img.src=js.image_url;
|
|
img.onload=()=>{
|
|
free_tracks=[]; current_track=[];
|
|
circle=null; static_trajs=[];
|
|
mode=selectedTrack=''; editMode=false; editInfo=null; duplicateBuffer=null;
|
|
trajProg.style.display='none';
|
|
radiusSlider.style.display='none';
|
|
trackSelect.innerHTML='';
|
|
redraw();
|
|
};
|
|
});
|
|
|
|
|
|
storeBtn.onclick = async () => {
|
|
if(!image_id) return alert('Load an image first');
|
|
const fh = free_tracks.map(tr=>tr.map(p=>({x:p.x*scaleX,y:p.y*scaleY}))),
|
|
ct = (circle?.trajectories||[]).map(tr=>tr.map(p=>({x:p.x*scaleX,y:p.y*scaleY}))),
|
|
st = static_trajs.map(tr=>tr.map(p=>({x:p.x*scaleX,y:p.y*scaleY})));
|
|
const payload = {
|
|
image_id, ext,
|
|
tracks: fh,
|
|
circle_trajectories: ct.concat(st)
|
|
};
|
|
const res = await fetch('/store_tracks',{
|
|
method:'POST',
|
|
headers:{'Content-Type':'application/json'},
|
|
body: JSON.stringify(payload)
|
|
});
|
|
const js = await res.json();
|
|
img.src=js.overlay_url;
|
|
img.onload=()=>ctx.drawImage(img,0,0,canvas.width,canvas.height);
|
|
|
|
|
|
free_tracks=[]; circle=null; static_trajs=[];
|
|
mode=selectedTrack=''; editMode=false; editInfo=null; duplicateBuffer=null;
|
|
trajProg.style.display='none';
|
|
radiusSlider.style.display='none';
|
|
trackSelect.innerHTML='';
|
|
redraw();
|
|
};
|
|
|
|
|
|
addTrackBtn.onclick = ()=>{
|
|
mode='free'; drawing=true; current_track=[]; motionCounter=0;
|
|
trajProg.max=FIXED_LENGTH; trajProg.value=0;
|
|
trajProg.style.display='inline-block';
|
|
};
|
|
deleteLastBtn.onclick = ()=>{
|
|
if(drawing){
|
|
drawing=false; current_track=[]; trajProg.style.display='none';
|
|
} else if(free_tracks.length){
|
|
free_tracks.pop(); updateTrackSelect(); redraw();
|
|
}
|
|
updateColorIndicator();
|
|
};
|
|
placeCircleBtn.onclick = ()=>{ mode='placeCircle'; drawing=false; };
|
|
addCirclePointBtn.onclick = ()=>{ if(!circle) alert('Place circle first'); else mode='addCirclePt'; };
|
|
addStaticBtn.onclick = ()=>{ mode='placeStatic'; };
|
|
duplicateTrackBtn.onclick = ()=>{
|
|
if(!selectedTrack) return alert('Select a track first');
|
|
const arr = selectedTrack.type==='free'
|
|
? free_tracks[selectedTrack.idx]
|
|
: selectedTrack.type==='circle'
|
|
? circle.trajectories[selectedTrack.idx]
|
|
: static_trajs[selectedTrack.idx];
|
|
duplicateBuffer = arr.map(p=>({x:p.x,y:p.y}));
|
|
mode='duplicate'; canvas.style.cursor='copy';
|
|
};
|
|
|
|
radiusSlider.oninput = ()=>{
|
|
if(!circle) return;
|
|
circle.radius = +radiusSlider.value;
|
|
circle.trajectories.forEach((traj,i)=>{
|
|
const θ = circle.angles[i];
|
|
traj.push({
|
|
x: circle.cx + Math.cos(θ)*circle.radius,
|
|
y: circle.cy + Math.sin(θ)*circle.radius
|
|
});
|
|
});
|
|
if(selectedTrack?.type==='circle')
|
|
trajProg.value = circle.trajectories[selectedTrack.idx].length;
|
|
redraw();
|
|
};
|
|
|
|
deleteTrackBtn.onclick = ()=>{
|
|
if(!selectedTrack) return;
|
|
const {type,idx} = selectedTrack;
|
|
if(type==='free') free_tracks.splice(idx,1);
|
|
else if(type==='circle'){
|
|
circle.trajectories.splice(idx,1);
|
|
circle.angles.splice(idx,1);
|
|
} else {
|
|
static_trajs.splice(idx,1);
|
|
}
|
|
selectedTrack=null;
|
|
trajProg.style.display='none';
|
|
updateTrackSelect();
|
|
redraw();
|
|
updateColorIndicator();
|
|
};
|
|
|
|
editTrackBtn.onclick = ()=>{
|
|
if(!selectedTrack) return alert('Select a track first');
|
|
editMode=!editMode;
|
|
editTrackBtn.textContent = editMode?'Stop Editing':'Edit Track';
|
|
};
|
|
|
|
|
|
function updateTrackSelect(){
|
|
trackSelect.innerHTML='';
|
|
free_tracks.forEach((_,i)=>{
|
|
const o=document.createElement('option');
|
|
o.value=JSON.stringify({type:'free',idx:i});
|
|
o.textContent=`Point ${i+1}`;
|
|
trackSelect.appendChild(o);
|
|
});
|
|
if(circle){
|
|
circle.trajectories.forEach((_,i)=>{
|
|
const o=document.createElement('option');
|
|
o.value=JSON.stringify({type:'circle',idx:i});
|
|
o.textContent=`CirclePt ${i+1}`;
|
|
trackSelect.appendChild(o);
|
|
});
|
|
}
|
|
static_trajs.forEach((_,i)=>{
|
|
const o=document.createElement('option');
|
|
o.value=JSON.stringify({type:'static',idx:i});
|
|
o.textContent=`StaticPt ${i+1}`;
|
|
trackSelect.appendChild(o);
|
|
});
|
|
if(trackSelect.options.length){
|
|
trackSelect.selectedIndex=0;
|
|
trackSelect.onchange();
|
|
}
|
|
updateColorIndicator();
|
|
}
|
|
|
|
function applyMotionToTrajectory(traj, dx, dy) {
|
|
traj.forEach((pt, frameIdx) => {
|
|
pt.x += dx * frameIdx;
|
|
pt.y += dy * frameIdx;
|
|
});
|
|
}
|
|
|
|
applySelectedMotionBtn.onclick = () => {
|
|
if (!selectedTrack) {
|
|
return alert('Please select a track first');
|
|
}
|
|
const dx = parseFloat(motionXInput.value) || 0;
|
|
const dy = parseFloat(motionYInput.value) || 0;
|
|
|
|
|
|
let arr = null;
|
|
if (selectedTrack.type === 'free') {
|
|
arr = free_tracks[selectedTrack.idx];
|
|
} else if (selectedTrack.type === 'circle') {
|
|
arr = circle.trajectories[selectedTrack.idx];
|
|
} else {
|
|
arr = static_trajs[selectedTrack.idx];
|
|
}
|
|
|
|
applyMotionToTrajectory(arr, dx, dy);
|
|
redraw();
|
|
};
|
|
|
|
|
|
applyAllMotionBtn.onclick = () => {
|
|
const dx = parseFloat(motionXInput.value) || 0;
|
|
const dy = parseFloat(motionYInput.value) || 0;
|
|
|
|
|
|
free_tracks.forEach(tr => applyMotionToTrajectory(tr, dx, dy));
|
|
|
|
if (circle) {
|
|
circle.trajectories.forEach(tr => applyMotionToTrajectory(tr, dx, dy));
|
|
}
|
|
|
|
static_trajs.forEach(tr => applyMotionToTrajectory(tr, dx, dy));
|
|
|
|
redraw();
|
|
};
|
|
|
|
trackSelect.onchange = ()=>{
|
|
if(!trackSelect.value){
|
|
selectedTrack=null;
|
|
trajProg.style.display='none';
|
|
return;
|
|
}
|
|
selectedTrack = JSON.parse(trackSelect.value);
|
|
|
|
if(selectedTrack.type==='circle'){
|
|
trajProg.style.display='inline-block';
|
|
trajProg.max=FIXED_LENGTH;
|
|
trajProg.value=circle.trajectories[selectedTrack.idx].length;
|
|
} else if(selectedTrack.type==='free'){
|
|
trajProg.style.display='inline-block';
|
|
trajProg.max=FIXED_LENGTH;
|
|
trajProg.value=free_tracks[selectedTrack.idx].length;
|
|
} else {
|
|
trajProg.style.display='none';
|
|
}
|
|
updateColorIndicator();
|
|
};
|
|
|
|
|
|
canvas.addEventListener('mousedown', e=>{
|
|
const r=canvas.getBoundingClientRect(),
|
|
x=e.clientX-r.left, y=e.clientY-r.top;
|
|
|
|
|
|
if(mode==='placeCircle'){
|
|
circle={cx:x,cy:y,radius:50,angles:[],trajectories:[]};
|
|
radiusSlider.max=Math.min(canvas.width,canvas.height)|0;
|
|
radiusSlider.value=50; radiusSlider.style.display='inline';
|
|
mode=''; updateTrackSelect(); redraw(); return;
|
|
}
|
|
|
|
if(mode==='addCirclePt'){
|
|
const dx=x-circle.cx, dy=y-circle.cy;
|
|
const θ=Math.atan2(dy,dx);
|
|
const px=circle.cx+Math.cos(θ)*circle.radius;
|
|
const py=circle.cy+Math.sin(θ)*circle.radius;
|
|
circle.angles.push(θ);
|
|
circle.trajectories.push([{x:px,y:py}]);
|
|
mode=''; updateTrackSelect(); redraw(); return;
|
|
}
|
|
|
|
if (mode === 'placeStatic') {
|
|
|
|
const len = parseInt(staticFramesInput.value, 10) || FIXED_LENGTH;
|
|
|
|
const traj = Array.from({ length: len }, () => ({ x, y }));
|
|
|
|
free_tracks.push(traj);
|
|
|
|
|
|
mode = '';
|
|
updateTrackSelect();
|
|
redraw();
|
|
return;
|
|
}
|
|
|
|
if(mode==='duplicate' && duplicateBuffer){
|
|
const orig = duplicateBuffer;
|
|
|
|
const dx = x - orig[0].x, dy = y - orig[0].y;
|
|
const newTr = orig.map(p=>({x:p.x+dx, y:p.y+dy}));
|
|
free_tracks.push(newTr);
|
|
mode=''; duplicateBuffer=null; canvas.style.cursor='crosshair';
|
|
updateTrackSelect(); redraw(); return;
|
|
}
|
|
|
|
if(editMode && selectedTrack){
|
|
const arr = selectedTrack.type==='free'
|
|
? free_tracks[selectedTrack.idx]
|
|
: selectedTrack.type==='circle'
|
|
? circle.trajectories[selectedTrack.idx]
|
|
: static_trajs[selectedTrack.idx];
|
|
let best=0,bd=Infinity;
|
|
arr.forEach((p,i)=>{
|
|
const d=(p.x-x)**2+(p.y-y)**2;
|
|
if(d<bd){ bd=d; best=i; }
|
|
});
|
|
editInfo={ trackType:selectedTrack.type,
|
|
trackIdx:selectedTrack.idx,
|
|
ptIdx:best,
|
|
startX:x, startY:y };
|
|
return;
|
|
}
|
|
|
|
if(mode==='free'){
|
|
drawing=true; motionCounter=0;
|
|
current_track=[{x,y}];
|
|
redraw();
|
|
}
|
|
});
|
|
|
|
canvas.addEventListener('mousemove', e=>{
|
|
const r=canvas.getBoundingClientRect(),
|
|
x=e.clientX-r.left, y=e.clientY-r.top;
|
|
|
|
if(editMode && editInfo){
|
|
const dx=x-editInfo.startX,
|
|
dy=y-editInfo.startY;
|
|
const {trackType,trackIdx,ptIdx} = editInfo;
|
|
const arr = trackType==='free'
|
|
? free_tracks[trackIdx]
|
|
: trackType==='circle'
|
|
? circle.trajectories[trackIdx]
|
|
: static_trajs[trackIdx];
|
|
arr.forEach((p,i)=>{
|
|
const d=i-ptIdx;
|
|
const w=Math.exp(-0.5*(d*d)/(editSigma*editSigma));
|
|
p.x+=dx*w; p.y+=dy*w;
|
|
});
|
|
editInfo.startX=x; editInfo.startY=y;
|
|
if(selectedTrack?.type==='circle')
|
|
trajProg.value=circle.trajectories[selectedTrack.idx].length;
|
|
redraw(); return;
|
|
}
|
|
|
|
if(drawing && (e.buttons&1)){
|
|
motionCounter++;
|
|
if(motionCounter%2===0){
|
|
current_track.push({x,y});
|
|
trajProg.value = Math.min(current_track.length, trajProg.max);
|
|
redraw();
|
|
}
|
|
}
|
|
});
|
|
|
|
canvas.addEventListener('mouseup', ()=>{
|
|
if(editMode && editInfo){ editInfo=null; return; }
|
|
if(drawing){
|
|
free_tracks.push(current_track.slice());
|
|
drawing=false; current_track=[];
|
|
updateTrackSelect(); redraw();
|
|
}
|
|
});
|
|
|
|
function updateColorIndicator() {
|
|
const idx = trackSelect.selectedIndex;
|
|
if (idx < 0) {
|
|
colorIndicator.style.visibility = 'hidden';
|
|
return;
|
|
}
|
|
|
|
const col = COLORS[idx % COLORS.length];
|
|
colorIndicator.style.backgroundColor = col;
|
|
colorIndicator.style.visibility = 'visible';
|
|
}
|
|
|
|
|
|
function redraw(){
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
if (img.complete) ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
|
|
|
|
|
ctx.lineWidth = 2;
|
|
|
|
|
|
free_tracks.forEach((tr, i) => {
|
|
const col = COLORS[i % COLORS.length];
|
|
ctx.strokeStyle = col;
|
|
ctx.fillStyle = col;
|
|
|
|
if (tr.length === 0) return;
|
|
|
|
|
|
const allSame = tr.every(p => p.x === tr[0].x && p.y === tr[0].y);
|
|
|
|
if (allSame) {
|
|
|
|
ctx.beginPath();
|
|
ctx.arc(tr[0].x, tr[0].y, 4, 0, 2 * Math.PI);
|
|
ctx.fill();
|
|
} else {
|
|
|
|
ctx.beginPath();
|
|
tr.forEach((p, j) =>
|
|
j ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y)
|
|
);
|
|
ctx.stroke();
|
|
}
|
|
});
|
|
|
|
if(drawing && current_track.length){
|
|
ctx.strokeStyle='black';
|
|
ctx.beginPath();
|
|
current_track.forEach((p,j)=>
|
|
j? ctx.lineTo(p.x,p.y): ctx.moveTo(p.x,p.y));
|
|
ctx.stroke();
|
|
}
|
|
|
|
|
|
if (circle) {
|
|
|
|
ctx.strokeStyle = 'white';
|
|
ctx.lineWidth = 1;
|
|
ctx.beginPath();
|
|
ctx.arc(circle.cx, circle.cy, circle.radius, 0, 2 * Math.PI);
|
|
ctx.stroke();
|
|
|
|
circle.trajectories.forEach((tr, i) => {
|
|
const col = COLORS[(free_tracks.length + i) % COLORS.length];
|
|
ctx.strokeStyle = col;
|
|
ctx.fillStyle = col;
|
|
ctx.lineWidth = 2;
|
|
|
|
if (tr.length <= 1) {
|
|
|
|
ctx.beginPath();
|
|
ctx.arc(tr[0].x, tr[0].y, 4, 0, 2 * Math.PI);
|
|
ctx.fill();
|
|
} else {
|
|
|
|
ctx.beginPath();
|
|
tr.forEach((p, j) =>
|
|
j ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y)
|
|
);
|
|
ctx.stroke();
|
|
|
|
|
|
const lp = tr[tr.length - 1];
|
|
ctx.fillStyle = 'white';
|
|
ctx.beginPath();
|
|
ctx.arc(lp.x, lp.y, 4, 0, 2 * Math.PI);
|
|
ctx.fill();
|
|
}
|
|
});
|
|
}
|
|
|
|
|
|
static_trajs.forEach((tr, i) => {
|
|
const p = tr[0];
|
|
ctx.fillStyle = 'orange';
|
|
ctx.beginPath();
|
|
ctx.arc(p.x, p.y, 5, 0, 2 * Math.PI);
|
|
ctx.fill();
|
|
});
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|
|
|