feat: add face filter components and integration guide

- useFaceFilter composable: manages face filtering state and API calls
- FaceFilterPanel component: UI for filtering controls
- FaceCard component: text-only face display card
- Integration guide: step-by-step guide for integrating into PeopleView

Implements hybrid filtering mode with:
- Instant mode: client-side filtering (5-10ms)
- Backend mode: server-side complex filtering
- Hybrid mode: backend + instant filtering (AND)

Uses actual Core API endpoints:
- GET /file/{uuid}/faces
- POST /file/{uuid}/faces/filter
- GET /file/{uuid}/face/{faceId}/filter-report
- GET /file/{uuid}/filter-stats

Preferences stored in localStorage.
This commit is contained in:
2026-08-09 21:24:52 +08:00
parent 97a5fef0b2
commit 7b60ca3d2d
11 changed files with 1533 additions and 4892 deletions

View File

@@ -0,0 +1,389 @@
import { ref, computed } from 'vue';
// Types
export interface FaceMetadata {
face_id: string;
frame: number;
primary_source: 'apple_vision' | 'mediapipe';
primary_confidence: number;
detection_sources: Array<{
source_type: string;
confidence: number;
}>;
filter_features: {
face_id: string;
frame: number;
bbox_size: number;
apple_vision_confidence: number;
mediapipe_confidence?: number;
eye_ratio?: number;
nose_ratio?: number;
pose_score?: number;
mediapipe_detected?: boolean;
mediapipe_iou?: number;
embedding_similarity?: number;
consecutive_frames?: number;
total_frames_detected?: number;
backend_filter_status?: 'accept' | 'reject' | 'review';
};
}
export interface QuickFilterRules {
min_confidence?: number;
max_confidence?: number;
min_bbox_size?: number;
max_bbox_size?: number;
source?: 'apple_vision' | 'mediapipe' | 'all';
backend_status?: 'accept' | 'reject' | 'review' | 'all';
}
export interface FilterResult {
total_faces: number;
passed: number;
filtered: number;
passed_faces: Array<{
face_id: string;
frame: number;
filter_result: {
overall_confidence: number;
recommendation: 'accept' | 'review' | 'reject';
reasons: string[];
};
}>;
filtered_faces: Array<{
face_id: string;
frame: number;
filter_result: {
overall_confidence: number;
recommendation: 'accept' | 'review' | 'reject';
reasons: string[];
};
}>;
filter_stats: {
landmark?: number;
mediapipe?: number;
embedding?: number;
temporal?: number;
};
}
export interface FilterStats {
total_faces: number;
by_category: {
total_faces: number;
by_source: {
apple_vision: number;
mediapipe: number;
};
by_quality: {
avg_eye_ratio?: number;
avg_nose_ratio?: number;
avg_pose_score?: number;
mediapipe_verified: number;
avg_embedding_similarity?: number;
avg_consecutive_frames?: number;
};
};
}
// Default rules
const defaultRules: QuickFilterRules = {
min_confidence: 0.5,
min_bbox_size: 80,
source: 'all',
backend_status: 'accept'
};
const STORAGE_KEY = 'momentry_face_filter_preferences';
// API helpers
const apiBase = '/api/v1';
async function fetchFaces(fileUuid: string, params?: { page?: number; page_size?: number; source?: string }) {
const queryParams = new URLSearchParams();
if (params?.page) queryParams.set('page', params.page.toString());
if (params?.page_size) queryParams.set('page_size', params.page_size.toString());
if (params?.source) queryParams.set('source', params.source);
const response = await fetch(`${apiBase}/file/${fileUuid}/faces?${queryParams}`);
if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
return response.json();
}
async function applyFilter(fileUuid: string, rules?: any) {
const response = await fetch(`${apiBase}/file/${fileUuid}/faces/filter`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ rules: rules || null })
});
if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
return response.json();
}
async function getFilterStats(fileUuid: string) {
const response = await fetch(`${apiBase}/file/${fileUuid}/filter-stats`);
if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
return response.json();
}
async function getFaceReport(fileUuid: string, faceId: string) {
const response = await fetch(`${apiBase}/file/${fileUuid}/face/${faceId}/filter-report`);
if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
return response.json();
}
// Quick filter function (client-side)
function quickFilter(faces: FaceMetadata[], rules: QuickFilterRules): FaceMetadata[] {
return faces.filter(face => {
// 1. Confidence 過濾
if (rules.min_confidence && face.primary_confidence < rules.min_confidence) {
return false;
}
if (rules.max_confidence && face.primary_confidence > rules.max_confidence) {
return false;
}
// 2. Bbox size 過濾
if (rules.min_bbox_size && face.filter_features.bbox_size < rules.min_bbox_size) {
return false;
}
if (rules.max_bbox_size && face.filter_features.bbox_size > rules.max_bbox_size) {
return false;
}
// 3. Source 過濾
if (rules.source && rules.source !== 'all' && face.primary_source !== rules.source) {
return false;
}
// 4. Backend status 過濾
if (rules.backend_status && rules.backend_status !== 'all' &&
face.filter_features.backend_filter_status !== rules.backend_status) {
return false;
}
return true;
});
}
// Hybrid filter function (backend + frontend)
function hybridFilter(faces: FaceMetadata[], rules: QuickFilterRules): FaceMetadata[] {
// 1. 前端過濾
const frontendFiltered = quickFilter(faces, rules);
// 2. 後端已標示 backend_filter_status
// 3. 兩條件 AND
return frontendFiltered.filter(face =>
face.filter_features.backend_filter_status === 'accept'
);
}
// Main composable
export function useFaceFilter() {
const mode = ref<'instant' | 'backend' | 'hybrid'>('instant');
const rules = ref<QuickFilterRules>({ ...defaultRules });
const allFaces = ref<FaceMetadata[]>([]);
const filteredFaces = ref<FaceMetadata[]>([]);
const filterResult = ref<FilterResult | null>(null);
const filterStats = ref<FilterStats | null>(null);
const loading = ref(false);
const error = ref<string | null>(null);
// Computed stats
const stats = computed(() => ({
total: allFaces.value.length,
filtered: filteredFaces.value.length,
removed: allFaces.value.length - filteredFaces.value.length
}));
// Load preferences from localStorage
function loadPreferences() {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
const prefs = JSON.parse(stored);
mode.value = prefs.mode || 'instant';
rules.value = prefs.rules || { ...defaultRules };
}
} catch (err) {
console.error('Failed to load preferences:', err);
}
}
// Save preferences to localStorage
function savePreferences() {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify({
mode: mode.value,
rules: rules.value
}));
} catch (err) {
console.error('Failed to save preferences:', err);
}
}
// Initialize: load faces and apply filter
async function initialize(fileUuid: string) {
loading.value = true;
error.value = null;
try {
// 1. 載入臉部元數據
const data = await fetchFaces(fileUuid, { page: 1, page_size: 1000 });
allFaces.value = data.data?.faces || data.faces || [];
// 2. 載入後端過濾結果(混合模式需要)
if (mode.value === 'hybrid' || mode.value === 'backend') {
const result = await applyFilter(fileUuid, null);
filterResult.value = result;
// 合併後端過濾結果到元數據
mergeBackendResults(result);
}
// 3. 載入統計
filterStats.value = await getFilterStats(fileUuid);
// 4. 套用過濾
applyLocalFilter();
// 5. 載入偏好
loadPreferences();
} catch (err: any) {
error.value = err.message || 'Failed to initialize';
console.error('Failed to initialize face filter:', err);
} finally {
loading.value = false;
}
}
// Merge backend filter results into metadata
function mergeBackendResults(result: FilterResult) {
result.passed_faces.forEach(face => {
const metadata = allFaces.value.find(f => f.face_id === face.face_id);
if (metadata) {
metadata.filter_features.backend_filter_status = face.filter_result.recommendation as any;
}
});
result.filtered_faces.forEach(face => {
const metadata = allFaces.value.find(f => f.face_id === face.face_id);
if (metadata) {
metadata.filter_features.backend_filter_status = face.filter_result.recommendation as any;
}
});
}
// Apply local filter based on mode
function applyLocalFilter() {
switch (mode.value) {
case 'instant':
filteredFaces.value = quickFilter(allFaces.value, rules.value);
break;
case 'backend':
filteredFaces.value = allFaces.value.filter(face =>
face.filter_features.backend_filter_status === 'accept'
);
break;
case 'hybrid':
filteredFaces.value = hybridFilter(allFaces.value, rules.value);
break;
}
}
// Update rules and re-filter
function updateRules(newRules: Partial<QuickFilterRules>) {
rules.value = { ...rules.value, ...newRules };
applyLocalFilter();
savePreferences();
}
// Change mode and re-filter
function setMode(newMode: 'instant' | 'backend' | 'hybrid') {
mode.value = newMode;
applyLocalFilter();
savePreferences();
}
// Apply backend filter
async function applyBackendFilter(fileUuid: string, customRules?: any) {
loading.value = true;
error.value = null;
try {
const result = await applyFilter(fileUuid, customRules);
filterResult.value = result;
// 合併後端過濾結果到元數據
mergeBackendResults(result);
// 重新套用過濾
applyLocalFilter();
return result;
} catch (err: any) {
error.value = err.message || 'Failed to apply backend filter';
console.error('Failed to apply backend filter:', err);
return null;
} finally {
loading.value = false;
}
}
// Get face report
async function getFaceReportData(fileUuid: string, faceId: string) {
try {
return await getFaceReport(fileUuid, faceId);
} catch (err: any) {
console.error('Failed to get face report:', err);
return null;
}
}
// Refresh stats
async function refreshStats(fileUuid: string) {
try {
filterStats.value = await getFilterStats(fileUuid);
} catch (err: any) {
console.error('Failed to refresh stats:', err);
}
}
// Reset to default
function resetToDefault() {
rules.value = { ...defaultRules };
applyLocalFilter();
savePreferences();
}
return {
// State
mode,
rules,
allFaces,
filteredFaces,
filterResult,
filterStats,
loading,
error,
stats,
// Actions
initialize,
updateRules,
setMode,
applyBackendFilter,
getFaceReportData,
refreshStats,
resetToDefault,
applyLocalFilter,
// API helpers (exposed for direct use)
fetchFaces,
applyFilter,
getFilterStats,
getFaceReport,
quickFilter,
hybridFilter
};
}