实现关键帧检测与成本控制 #30

Merged
cfy777 merged 1 commits from develop-frontend4 into develop 2026-06-13 14:40:06 +08:00
5 changed files with 157 additions and 14 deletions
Showing only changes of commit 2c6489b9a6 - Show all commits

View File

@@ -237,6 +237,16 @@ body {
border-top: 1px solid var(--color-border); border-top: 1px solid var(--color-border);
} }
/* ---- Stats Bar ---- */
.stats-bar {
text-align: center;
padding: 4px 0;
font-size: 0.75rem;
color: var(--color-text-muted);
border-top: 1px solid var(--color-border);
}
/* ---- Buttons ---- */ /* ---- Buttons ---- */
.btn { .btn {

View File

@@ -26,6 +26,7 @@ function App() {
stream, stream,
config, config,
updateConfig, updateConfig,
stats,
startSession, startSession,
stopSession, stopSession,
interrupt, interrupt,
@@ -106,6 +107,13 @@ function App() {
</div> </div>
</main> </main>
{isConnected && (stats.queryCount > 0 || stats.totalTokens > 0) && (
<div className="stats-bar">
{stats.queryCount > 0 && <span>{stats.queryCount} </span>}
{stats.totalTokens > 0 && <span>· {stats.totalTokens} tokens</span>}
</div>
)}
<footer className="app-footer"> <footer className="app-footer">
{!isConnected ? ( {!isConnected ? (
<button className="btn btn--primary" onClick={startSession}> <button className="btn btn--primary" onClick={startSession}>

View File

@@ -103,19 +103,61 @@ export function useVAD(options?: VADOptions) {
return { isSpeaking, isReady, error, start, stop }; return { isSpeaking, isReady, error, start, stop };
} }
// ---- 关键帧检测ONNX Runtime Web---- // ---- 关键帧检测 ----
export function useKeyframeDetection() { const DETECT_WIDTH = 160;
// TODO: 加载 ONNX 模型后设为 true const DETECT_HEIGHT = 120;
const isReady = false; const DIFF_THRESHOLD = 30;
const isKeyframe = useCallback( /** 离屏 canvas用于降采样 */
(_currentFrame: ImageData, _previousFrame: ImageData): boolean => { let offscreen: HTMLCanvasElement | null = null;
// TODO: 实现像素差异对比
return true; // 暂时所有帧都视为关键帧
},
[],
);
return { isReady, isKeyframe }; function getOffscreen(): HTMLCanvasElement {
if (!offscreen) {
offscreen = document.createElement("canvas");
offscreen.width = DETECT_WIDTH;
offscreen.height = DETECT_HEIGHT;
}
return offscreen;
}
/**
* 将 video 元素缩放采样为 Uint8ClampedArrayRGBA
* 返回 null 如果 video 未就绪
*/
export function sampleFrame(video: HTMLVideoElement): Uint8ClampedArray | null {
if (video.readyState < 2) return null;
const canvas = getOffscreen();
const ctx = canvas.getContext("2d");
if (!ctx) return null;
ctx.drawImage(video, 0, 0, DETECT_WIDTH, DETECT_HEIGHT);
return ctx.getImageData(0, 0, DETECT_WIDTH, DETECT_HEIGHT).data;
}
/**
* 对比两帧像素差异
* @returns { isKeyframe: boolean, similarity: number }
*/
export function compareFrames(
prev: Uint8ClampedArray,
curr: Uint8ClampedArray,
): { isKeyframe: boolean; similarity: number } {
let diffSum = 0;
const len = Math.min(prev.length, curr.length);
const pixelCount = len / 4;
for (let i = 0; i < len; i += 4) {
// 只比较 RGB跳过 Alpha
diffSum += Math.abs(prev[i] - curr[i]);
diffSum += Math.abs(prev[i + 1] - curr[i + 1]);
diffSum += Math.abs(prev[i + 2] - curr[i + 2]);
}
const avgDiff = diffSum / (pixelCount * 3);
const similarity = 1 - avgDiff / 255;
return {
isKeyframe: avgDiff > DIFF_THRESHOLD,
similarity,
};
} }

View File

@@ -14,18 +14,27 @@ import { showToast } from "../lib/toast";
import { loadConfig, saveConfig } from "../lib/storage"; import { loadConfig, saveConfig } from "../lib/storage";
import { useCamera } from "../components/CameraManager"; import { useCamera } from "../components/CameraManager";
import { useMicrophone } from "../components/MicManager"; import { useMicrophone } from "../components/MicManager";
import { useVAD } from "../components/EdgeProcessor"; import { useVAD, sampleFrame, compareFrames } from "../components/EdgeProcessor";
import { useWebSocketManager } from "../components/WebSocketManager"; import { useWebSocketManager } from "../components/WebSocketManager";
import type { ChatMessage, SessionConfig, ServerMessage, LLMDoneMessage } from "../types"; import type { ChatMessage, SessionConfig, ServerMessage, LLMDoneMessage } from "../types";
const MAX_HISTORY_ROUNDS = 10; const MAX_HISTORY_ROUNDS = 10;
export interface SessionStats {
queryCount: number;
totalTokens: number;
}
export function useVisionSession() { export function useVisionSession() {
const [messages, setMessages] = useState<ChatMessage[]>([]); const [messages, setMessages] = useState<ChatMessage[]>([]);
const [currentReply, setCurrentReply] = useState<string>(""); const [currentReply, setCurrentReply] = useState<string>("");
const [isProcessing, setIsProcessing] = useState(false); const [isProcessing, setIsProcessing] = useState(false);
const [isAudioPlaying, setIsAudioPlaying] = useState(false); const [isAudioPlaying, setIsAudioPlaying] = useState(false);
const [config, setConfig] = useState<SessionConfig>(loadConfig); const [config, setConfig] = useState<SessionConfig>(loadConfig);
const [stats, setStats] = useState<SessionStats>({ queryCount: 0, totalTokens: 0 });
// 上一帧采样数据(用于关键帧检测)
const prevFrameRef = useRef<Uint8ClampedArray | null>(null);
// 对话历史role + content用于多轮上下文 // 对话历史role + content用于多轮上下文
const historyRef = useRef<Array<{ role: string; content: string }>>([]); const historyRef = useRef<Array<{ role: string; content: string }>>([]);
@@ -107,6 +116,23 @@ export function useVisionSession() {
return; return;
} }
// 关键帧检测:与上一帧对比,相似度过高则跳过
const video = videoRef.current;
if (video) {
const currentSample = sampleFrame(video);
if (currentSample && prevFrameRef.current) {
const { similarity } = compareFrames(prevFrameRef.current, currentSample);
if (similarity > 0.9) {
console.log(`[Session] 画面无变化 (similarity=${similarity.toFixed(2)}),跳过`);
prevFrameRef.current = currentSample;
return;
}
}
if (currentSample) {
prevFrameRef.current = currentSample;
}
}
const requestId = uuidv4(); const requestId = uuidv4();
send({ send({
type: "query", type: "query",
@@ -115,6 +141,9 @@ export function useVisionSession() {
audio: encodeAudioToBase64(audio), audio: encodeAudioToBase64(audio),
}); });
// 更新请求统计
setStats((prev) => ({ ...prev, queryCount: prev.queryCount + 1 }));
// 添加用户消息STT 流式结果会逐步更新文本) // 添加用户消息STT 流式结果会逐步更新文本)
setMessages((prev) => [ setMessages((prev) => [
...prev, ...prev,
@@ -122,7 +151,7 @@ export function useVisionSession() {
]); ]);
setIsProcessing(true); setIsProcessing(true);
}, },
[captureFrame, send] [captureFrame, send, videoRef]
), ),
}); });
@@ -159,6 +188,14 @@ export function useVisionSession() {
historyRef.current = historyRef.current.slice(-MAX_HISTORY_ROUNDS * 2); historyRef.current = historyRef.current.slice(-MAX_HISTORY_ROUNDS * 2);
} }
// 累计 token 统计
if (done.tokens_used?.total) {
setStats((prev) => ({
...prev,
totalTokens: prev.totalTokens + done.tokens_used.total,
}));
}
setMessages((prev) => [ setMessages((prev) => [
...prev, ...prev,
{ {
@@ -222,7 +259,9 @@ export function useVisionSession() {
setMessages([]); setMessages([]);
setCurrentReply(""); setCurrentReply("");
setIsProcessing(false); setIsProcessing(false);
setStats({ queryCount: 0, totalTokens: 0 });
historyRef.current = []; historyRef.current = [];
prevFrameRef.current = null;
}, [stopVAD, stopMic, stopCamera, disconnect]); }, [stopVAD, stopMic, stopCamera, disconnect]);
/** 打断当前回复 */ /** 打断当前回复 */
@@ -257,6 +296,7 @@ export function useVisionSession() {
stream, stream,
config, config,
updateConfig, updateConfig,
stats,
startSession, startSession,
stopSession, stopSession,
interrupt, interrupt,

View File

@@ -0,0 +1,43 @@
// ============================================================
// Sampling — 混合采样策略
// 来源docs/08-成本控制.md — "定时低频 + 事件高频"
// ============================================================
/** 静默时采样间隔ms */
const IDLE_INTERVAL = 5000;
/** 用户说话时采样间隔ms */
const ACTIVE_INTERVAL = 1000;
export class SamplingController {
private lastSampleTime = 0;
private _isUserSpeaking = false;
/** 设置用户是否正在说话 */
set speaking(value: boolean) {
this._isUserSpeaking = value;
}
get speaking(): boolean {
return this._isUserSpeaking;
}
/** 当前采样间隔 */
get interval(): number {
return this._isUserSpeaking ? ACTIVE_INTERVAL : IDLE_INTERVAL;
}
/** 是否应该采样(基于时间间隔) */
shouldSample(): boolean {
const now = Date.now();
if (now - this.lastSampleTime < this.interval) {
return false;
}
this.lastSampleTime = now;
return true;
}
/** 强制允许下次采样(如用户刚说完话时) */
resetTimer(): void {
this.lastSampleTime = 0;
}
}