diff --git a/portal/src/api/client.ts b/portal/src/api/client.ts index 83166ad..50ae76f 100644 --- a/portal/src/api/client.ts +++ b/portal/src/api/client.ts @@ -251,30 +251,27 @@ export async function searchVideos(query: string, limit = 10, mode = 'vector'): } const config = getConfig() - const url = mode === 'smart' || mode === 'bm25' - ? `${config.api_base_url}/api/v1/search` - : `${config.api_base_url}/api/v1/search` + const url = `${config.api_base_url}/api/v1/search/universal` const response: any = await httpFetch(url, { method: 'POST', body: JSON.stringify({ query, limit }), }) - // Map backend response ({ results: [...], query: string }) to frontend SearchResult ({ hits: [...], query: string, count: number }) return { - query: response.query, + query: response.query || query, count: response.results?.length || 0, hits: (response.results || []).map((r: any) => ({ id: r.chunk_id || r.id, - vid: r.uuid || r.vid, - start_frame: Math.floor((r.start_time || 0) * 30), - end_frame: Math.floor((r.end_time || 0) * 30), - fps: 30, + vid: r.uuid || r.vid || r.file_uuid || '', + start_frame: Math.floor((r.start_time || 0) * (r.fps || 30)), + end_frame: Math.floor((r.end_time || 0) * (r.fps || 30)), + fps: r.fps || 30, start: r.start_time || r.start || 0, end: r.end_time || r.end || 0, - text: r.text || '', + text: r.text || r.text_content || '', score: r.score || 0, - title: r.title || r.file_name, + title: r.title || r.file_name || '', file_path: r.file_path, has_visual_stats: !!r.visual_stats, parent_id: r.parent_chunk_id, @@ -288,29 +285,27 @@ export async function searchChunks(query: string, uuid?: string): Promise(url, { method: 'POST', - body: JSON.stringify({ query, limit: 10 }), + body: JSON.stringify({ query, uuid, limit: 20 }), }) return { - query: response.query, + query: response.query || query, count: response.results?.length || 0, hits: (response.results || []).map((r: any) => ({ id: r.chunk_id || r.id, - vid: r.uuid || r.vid, - start_frame: Math.floor((r.start_time || 0) * 30), - end_frame: Math.floor((r.end_time || 0) * 30), - fps: 30, + vid: r.uuid || r.vid || r.file_uuid || '', + start_frame: Math.floor((r.start_time || 0) * (r.fps || 30)), + end_frame: Math.floor((r.end_time || 0) * (r.fps || 30)), + fps: r.fps || 30, start: r.start_time || r.start || 0, end: r.end_time || r.end || 0, - text: r.text || '', + text: r.text || r.text_content || '', score: r.score || 0, - title: r.title || r.file_name, + title: r.title || r.file_name || '', file_path: r.file_path, has_visual_stats: !!r.visual_stats, parent_id: r.parent_chunk_id, @@ -415,12 +410,15 @@ export async function translateText(text: string, targetLang: string = 'zh-TW'): return text } -export async function getPersonThumbnail(personId: string): Promise { +export async function getPersonThumbnail(fileUuid: string, traceId?: number): Promise { if (isTauri()) { - return tauriInvoke('get_person_thumbnail_b64', { person_id: personId }) + return tauriInvoke('get_person_thumbnail_b64', { file_uuid: fileUuid, trace_id: traceId }) } const config = getConfig() - return `${config.api_base_url}/api/v1/file/:file_uuid/faces/:face_id/thumbnail` + if (traceId !== undefined) { + return `${config.api_base_url}/api/v1/file/${fileUuid}/trace/${traceId}/video` + } + return `${config.api_base_url}/api/v1/file/${fileUuid}/thumbnail` } export async function registerIdentity(name: string, images: string[]): Promise { @@ -462,7 +460,7 @@ export async function unregisterVideo(fileUuid: string): Promise('unregister_video', { file_uuid: fileUuid }) } const config = getConfig() - return httpFetch(`${config.api_base_url}/api/v1/files/unregister`, { + return httpFetch(`${config.api_base_url}/api/v1/unregister`, { method: 'POST', body: JSON.stringify({ file_uuid: fileUuid }), }) @@ -483,17 +481,62 @@ export async function listFaceCandidates(fileUuid?: string, minConfidence = 0.5, return httpFetch(`${config.api_base_url}/api/v1/faces/candidates?${params.toString()}`) } +export async function listTracesSorted( + fileUuid: string, + sortBy = 'face_count', + limit = 100, + minFaces = 1 +): Promise { + if (isTauri()) { + return tauriInvoke('list_traces_sorted', { file_uuid: fileUuid, sort_by: sortBy, limit, min_faces: minFaces }) + } + const config = getConfig() + return httpFetch(`${config.api_base_url}/api/v1/file/${fileUuid}/face_trace/sortby`, { + method: 'POST', + body: JSON.stringify({ sort_by: sortBy, limit, min_faces: minFaces }), + }) +} + +/** + * Embed query text using EmbeddingGemma with fallback. + * Tries M5 (192.168.110.201:11436) first, falls back to M4 localhost. + */ +export async function embedQuery(text: string): Promise { + const servers = [ + 'http://192.168.110.201:11436/v1/embeddings', + 'http://localhost:11436/v1/embeddings', + ] + for (const url of servers) { + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ input: text, model: 'embeddinggemma-300m' }), + signal: AbortSignal.timeout(5000), + }) + if (!res.ok) continue + const data = await res.json() + if (data?.data?.[0]?.embedding) return data.data[0].embedding + } catch { continue } + } + throw new Error('Embedding servers unreachable') +} + +export async function listTraceFaces(fileUuid: string, traceId: number, limit = 200, offset = 0): Promise { + if (isTauri()) { + return tauriInvoke('list_trace_faces', { file_uuid: fileUuid, trace_id: traceId, limit, offset }) + } + const config = getConfig() + return httpFetch(`${config.api_base_url}/api/v1/file/${fileUuid}/trace/${traceId}/faces?limit=${limit}&offset=${offset}`) +} + export async function getIdentityFaces(identityId: number, page = 1, pageSize = 100): Promise { if (isTauri()) { return tauriInvoke('get_identity_faces', { identity_id: identityId, page, page_size: pageSize }) } const config = getConfig() - const params = new URLSearchParams() - params.append('page', String(page)) - params.append('page_size', String(pageSize)) - - return httpFetch(`${config.api_base_url}/api/v1/identities/${identityId}/faces?${params.toString()}`) + return httpFetch(`${config.api_base_url}/api/v1/identity/${identityId}/files?page_size=${pageSize}&offset=${(page-1)*pageSize}`) } // ── Config helpers ────────────────────────────────────────────────────── diff --git a/portal/src/components/FaceTraceTimeline.vue b/portal/src/components/FaceTraceTimeline.vue new file mode 100644 index 0000000..c275ba0 --- /dev/null +++ b/portal/src/components/FaceTraceTimeline.vue @@ -0,0 +1,350 @@ + + + diff --git a/portal/src/views/VideoDetailView.vue b/portal/src/views/VideoDetailView.vue index a19aab7..b61131c 100644 --- a/portal/src/views/VideoDetailView.vue +++ b/portal/src/views/VideoDetailView.vue @@ -146,6 +146,14 @@ + + +
+ +
@@ -191,6 +199,7 @@ import { ref, computed, onMounted } from 'vue' import { useRoute, useRouter } from 'vue-router' import { getVideos, registerVideo, unregisterVideo, processVideo } from '@/api/client' +import FaceTraceTimeline from '@/components/FaceTraceTimeline.vue' const route = useRoute() const router = useRouter() @@ -310,6 +319,11 @@ async function handleProcess() { } } +function handleTraceSelect(traceId: number) { + // Navigate to face candidates filtered by this trace + router.push(`/faces/candidates?trace_id=${traceId}&file_uuid=${uuid}`) +} + async function loadVideoDetail() { loading.value = true try { diff --git a/scripts/embeddinggemma_server.py b/scripts/embeddinggemma_server.py new file mode 100644 index 0000000..8b285aa --- /dev/null +++ b/scripts/embeddinggemma_server.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +"""EmbeddingGemma HTTP server - Metal GPU (MPS) accelerated, compatible with M4/M5.""" + +import argparse, json, time, torch +from flask import Flask, request, jsonify +from transformers import AutoModel, AutoTokenizer +import numpy as np + +app = Flask(__name__) + +MODEL = None +TOKENIZER = None +DEVICE = None + +def load_model(model_path: str = "google/embeddinggemma-300m"): + global MODEL, TOKENIZER, DEVICE + if MODEL is not None: + return + DEVICE = "mps" if torch.backends.mps.is_available() else "cpu" + dtype = torch.float32 + print(f"[EmbeddingGemma] Loading model on {DEVICE} (dtype={dtype})...") + t0 = time.time() + MODEL = AutoModel.from_pretrained( + model_path, + torch_dtype=dtype, + trust_remote_code=True, + ).eval().to(DEVICE) + TOKENIZER = AutoTokenizer.from_pretrained(model_path, trust_remote_code=True) + print(f"[EmbeddingGemma] Loaded in {time.time()-t0:.1f}s on {DEVICE}") + +def embed(texts: list[str]) -> list[list[float]]: + inputs = TOKENIZER(texts, padding=True, truncation=True, return_tensors="pt").to(DEVICE) + with torch.no_grad(): + outputs = MODEL(**inputs) + mask = inputs["attention_mask"].unsqueeze(-1).to(outputs.last_hidden_state.dtype) + pooled = (outputs.last_hidden_state * mask).sum(dim=1) / (mask.sum(dim=1) + 1e-9) + pooled = torch.nn.functional.normalize(pooled, p=2, dim=1) + return pooled.cpu().numpy().tolist() + +@app.route("/v1/embeddings", methods=["POST"]) +def embeddings(): + data = request.get_json() + texts = data.get("input", []) + if isinstance(texts, str): + texts = [texts] + if not texts: + return jsonify({"error": "empty input"}), 400 + try: + emb = embed(texts) + result = { + "data": [{"embedding": e, "index": i} for i, e in enumerate(emb)], + "model": "embeddinggemma-300m", + } + return jsonify(result) + except Exception as e: + return jsonify({"error": str(e)}), 500 + +@app.route("/health", methods=["GET"]) +def health(): + return jsonify({"status": "ok", "device": str(DEVICE)}) + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("--port", type=int, default=11436) + parser.add_argument("--model", type=str, default="google/embeddinggemma-300m") + args = parser.parse_args() + load_model(args.model) + app.run(host="0.0.0.0", port=args.port, threaded=True)