From 3e2f695c08b71a1c35dfa43bca768e3af10fc8f4 Mon Sep 17 00:00:00 2001 From: feie9456 Date: Fri, 5 Sep 2025 00:46:45 +0800 Subject: [PATCH] batch predict & disk cache and ui improvements --- src/App.vue | 181 +++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 151 insertions(+), 30 deletions(-) diff --git a/src/App.vue b/src/App.vue index 556ba3e..bf810bd 100644 --- a/src/App.vue +++ b/src/App.vue @@ -8,6 +8,11 @@ const frames = ref([]); const rawCanvas = useTemplateRef('raw-canvas'); const processedCanvas = useTemplateRef('processed-canvas'); +const decodeProgress = ref(0); +const trackProgress = ref(0); +const predictProgress = ref(0); + +const showActual = ref(false); let rawCtx: CanvasRenderingContext2D; let processedCtx: CanvasRenderingContext2D; @@ -16,14 +21,25 @@ onMounted(() => { processedCtx = processedCanvas.value!.getContext('2d')!; }); -const maxFrames = 128; +const maxFrames = 512; const seqLen = 120; -const decodeProgress = ref(0); -const trackProgress = ref(0); const centerPosArr = ref<({ x: number; y: number } | null)[]>([]); const predictedPosArr = ref<[number, number][][]>([]); + +// 计算序列的稳定哈希,用作 localStorage 的键 +const MAX_BATCH = 64; +function hashSequence(seq: Array<[number, number]>): string { + const s = JSON.stringify(seq); + let h = 5381; + for (let i = 0; i < s.length; i++) { + h = ((h << 5) + h) + s.charCodeAt(i); // h*33 + c + h |= 0; // 32-bit + } + // 仅使用哈希值本身作为键名,避免与其它缓存键冲突的概率 + return (h >>> 0).toString(16); +} async function handleFileChange(event: Event) { const input = event.target as HTMLInputElement; if (input.files && input.files[0]) { @@ -49,15 +65,19 @@ async function handleFileChange(event: Event) { let lastPos = null for (let index = 0; index < frames.value.length; index++) { + const cacheKeyname = `${file.name}-${index}`; + if (localStorage.getItem(cacheKeyname)) { + centerPosArr.value[index] = JSON.parse(localStorage.getItem(cacheKeyname)!) as { x: number; y: number } | null; + trackProgress.value++; + continue; + } selectedIndex.value = index; await reqNextFrame(); const center = tracker.detectYellowBall(rawCtx.getImageData(0, 0, w, h), lastPos); centerPosArr.value[index] = center; lastPos = center; - - console.log(center); - + localStorage.setItem(cacheKeyname, JSON.stringify(center)); trackProgress.value++; } @@ -72,27 +92,65 @@ async function handleFileChange(event: Event) { console.warn("视频帧数不足,无法预测"); return } - for (let index = firstAvailableIndex; index < centerPosArr.value.length - seqLen; index++) { - const seq = centerPosArr.value.slice(index, index + seqLen).map(p => p ? [p.x, p.y] : [0, 0]); + // 准备任务清单:每个任务 = { seq, key, outIndex } + type Task = { seq: Array<[number, number]>; key: string; outIndex: number }; + const tasks: Task[] = []; + + const lastStart = centerPosArr.value.length - seqLen; // 不包含 + for (let start = firstAvailableIndex; start < lastStart; start++) { + const seq_: Array<[number, number]> = centerPosArr.value + .slice(start, start + seqLen) + .map(p => p ? [p.x, p.y] as [number, number] : [0, 0]); + const key = hashSequence(seq_); + const outIndex = start + seqLen; // 预测对应的“当前帧”索引 + tasks.push({ seq: seq_, key, outIndex }); + } + + // 先尝试本地缓存命中 + const pending: Task[] = []; + for (const t of tasks) { + const cached = localStorage.getItem(t.key); + if (cached) { + try { + const pred: [number, number][] = JSON.parse(cached); + predictedPosArr.value[t.outIndex] = pred; + // 进度按已完成的最大下标推进 + predictProgress.value = Math.max(predictProgress.value, t.outIndex); + } catch { pending.push(t); } + } else { + pending.push(t); + } + } + + // 分批请求后端,最多 64 条/批 + for (let i = 0; i < pending.length; i += MAX_BATCH) { + const batch = pending.slice(i, i + MAX_BATCH); const body = { - sequences: [seq], + sequences: batch.map(b => b.seq), steps: 30, return_angles: true, unwrap_from_last: true - } + }; + const resp = await fetch("http://127.0.0.1:8000/predict", { method: "POST", - headers: { - 'Content-Type': 'application/json' - }, + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); const json = await resp.json(); - console.log(json); - - predictedPosArr.value[index + seqLen] = json.pred_xy[0]; + const preds: [number, number][][] = json.pred_xy || []; + // 将结果回填到对应位置并写入缓存 + for (let j = 0; j < batch.length; j++) { + const t = batch[j]; + const pred = preds[j]; + if (!pred) continue; + predictedPosArr.value[t.outIndex] = pred; + localStorage.setItem(t.key, JSON.stringify(pred)) + predictProgress.value = Math.max(predictProgress.value, t.outIndex); + } } + } } @@ -131,7 +189,7 @@ function renderBaseFrame(index: number) { } } -function drawPredictionPartial(preds: Array<[number, number]>, count: number) { +function drawPredictionPartial(preds: Array<[number, number]>, actual: Array<[number, number]>, count: number) { if (!preds.length) return; const [sx, sy] = preds[0]; const [ex, ey] = preds[preds.length - 1]; @@ -166,6 +224,30 @@ function drawPredictionPartial(preds: Array<[number, number]>, count: number) { processedCtx.lineWidth = 4; processedCtx.arc(preds[last][0], preds[last][1], 20, 0, Math.PI * 2); processedCtx.stroke(); + + if (!showActual.value) return + const gradient2 = processedCtx.createLinearGradient(sx, sy, ex, ey); + gradient2.addColorStop(0, 'rgba(255, 255, 0, 0.95)'); + gradient2.addColorStop(1, 'rgba(255, 255, 0, 0.3)'); + processedCtx.beginPath(); + processedCtx.lineWidth = 12; + processedCtx.lineCap = 'round'; + processedCtx.lineJoin = 'round'; + processedCtx.moveTo(sx, sy); + for (let i = 1; i <= last; i++) { + let pos = actual[i] + if (!pos) break + const [x, y] = pos; + processedCtx.lineTo(x, y); + } + processedCtx.strokeStyle = gradient2; + processedCtx.stroke(); + // 在终点画一个黄色小球 + processedCtx.beginPath(); + processedCtx.strokeStyle = 'yellow'; + processedCtx.lineWidth = 4; + processedCtx.arc(actual[last][0], actual[last][1], 20, 0, Math.PI * 2); + processedCtx.stroke(); } function animatePrediction(index: number) { @@ -183,10 +265,11 @@ function animatePrediction(index: number) { const elapsed = now - animStartTs; // 目标画到的点下标(包含起点),至少为 1(起点),至多为 preds.length - 1 const targetIdx = Math.min(preds.length - 1, Math.floor(elapsed / perSeg) + 1); + const actual: [number, number][] = centerPosArr.value.slice(index, index + targetIdx + 1).map(p => p ? [p.x, p.y] as [number, number] : [0, 0]); // 每帧重绘背景与部分轨迹 renderBaseFrame(index); - drawPredictionPartial(preds, targetIdx); + drawPredictionPartial(preds, actual, targetIdx); if (targetIdx < preds.length - 1) { animReqId = requestAnimationFrame(step); @@ -198,6 +281,8 @@ function animatePrediction(index: number) { animReqId = requestAnimationFrame(step); } + + watch(selectedIndex, async (newIndex) => { if (!rawCanvas.value || !processedCanvas.value) return; if (!rawCtx || !processedCtx) return; @@ -268,27 +353,45 @@ const predRangeStyle = computed(() => { const currentCursorStyle = computed(() => { if (framesLen.value === 0) return { left: '0%' }; - return { left: `${toPercent(currentIndex.value)}%` }; + return { left: `${toPercent(currentIndex.value - 0.5)}%` }; }); @@ -296,6 +399,7 @@ const currentCursorStyle = computed(() => { canvas { width: 100%; } + .timeline { position: relative; height: 12px; @@ -304,11 +408,18 @@ canvas { background: #eeeeee; overflow: hidden; } + .timeline .range { position: absolute; top: 0; bottom: 0; + display: flex; + align-items: center; + justify-content: center; + color: white; + font-size: 10px; } + .timeline .cursor { position: absolute; top: -3px; @@ -316,4 +427,14 @@ canvas { width: 2px; background: #ff3b30; } + +.index-indicator { + position: relative; + width: 100%; + display: flex; + justify-content: space-between; + font-size: 14px; + color: #666; + margin-bottom: 4px; +}