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

195
src/components/FaceCard.vue Normal file
View File

@@ -0,0 +1,195 @@
<template>
<div
class="face-card"
:class="{
'face-card-selected': selected,
'status-accept': face.filter_features.backend_filter_status === 'accept',
'status-reject': face.filter_features.backend_filter_status === 'reject',
'status-review': face.filter_features.backend_filter_status === 'review'
}"
@click="$emit('click', face)"
>
<!-- Status Badge -->
<div v-if="face.filter_features.backend_filter_status" class="status-badge" :class="face.filter_features.backend_filter_status">
{{ face.filter_features.backend_filter_status }}
</div>
<!-- Face Info -->
<div class="face-info">
<div class="info-row">
<span class="info-label">Face ID:</span>
<span class="info-value">{{ face.face_id }}</span>
</div>
<div class="info-row">
<span class="info-label">Frame:</span>
<span class="info-value">{{ face.frame }}</span>
</div>
<div class="info-row">
<span class="info-label">Confidence:</span>
<span class="info-value">{{ (face.primary_confidence * 100).toFixed(1) }}%</span>
</div>
<div class="info-row">
<span class="info-label">Size:</span>
<span class="info-value">{{ face.filter_features.bbox_size }}px</span>
</div>
<div class="info-row">
<span class="info-label">Source:</span>
<span class="info-value source-badge" :class="face.primary_source">
{{ face.primary_source === 'apple_vision' ? 'AV' : 'MP' }}
</span>
</div>
</div>
<!-- Checkbox for selection -->
<div v-if="showCheckbox" class="face-checkbox" @click.stop="$emit('select', face)">
<span v-if="selected">✓</span>
</div>
</div>
</template>
<script setup lang="ts">
import type { FaceMetadata } from '../composables/useFaceFilter';
defineProps<{
face: FaceMetadata;
selected?: boolean;
showCheckbox?: boolean;
}>();
defineEmits<{
click: [face: FaceMetadata];
select: [face: FaceMetadata];
}>();
</script>
<style scoped>
.face-card {
position: relative;
border: 1px solid var(--border-color, #ddd);
border-radius: 4px;
padding: 10px;
background: var(--bg-color, #fff);
cursor: pointer;
transition: all 0.2s;
min-height: 120px;
}
.face-card:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
border-color: var(--primary-color, #007bff);
}
.face-card-selected {
border-color: var(--primary-color, #007bff);
background: var(--selected-bg, #e3f2fd);
}
/* Status-based styling */
.status-accept {
border-left: 3px solid #4caf50;
}
.status-reject {
border-left: 3px solid #f44336;
opacity: 0.7;
}
.status-review {
border-left: 3px solid #ff9800;
}
.status-badge {
position: absolute;
top: 8px;
right: 8px;
padding: 2px 8px;
border-radius: 12px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
}
.status-badge.accept {
background: #e8f5e9;
color: #2e7d32;
}
.status-badge.reject {
background: #ffebee;
color: #c62828;
}
.status-badge.review {
background: #fff3e0;
color: #e65100;
}
.face-info {
display: flex;
flex-direction: column;
gap: 6px;
}
.info-row {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 13px;
}
.info-label {
color: #666;
font-weight: 500;
}
.info-value {
color: #333;
font-weight: 600;
}
.source-badge {
padding: 2px 6px;
border-radius: 3px;
font-size: 11px;
font-weight: 700;
}
.source-badge.apple_vision {
background: #e3f2fd;
color: #1976d2;
}
.source-badge.mediapipe {
background: #f3e5f5;
color: #7b1fa2;
}
.face-checkbox {
position: absolute;
top: 8px;
left: 8px;
width: 20px;
height: 20px;
border: 2px solid var(--border-color, #ddd);
border-radius: 3px;
background: var(--bg-color, #fff);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
}
.face-checkbox:hover {
border-color: var(--primary-color, #007bff);
}
.face-checkbox span {
color: var(--primary-color, #007bff);
font-weight: bold;
}
</style>

View File

@@ -0,0 +1,406 @@
<template>
<div class="face-filter-panel">
<!-- Toolbar Toggle -->
<div class="face-filter-toolbar">
<button
class="face-filter-toggle-btn"
:class="{ active: showPanel }"
@click="togglePanel"
>
🔍 Face Filter
<span class="toggle-dot" :class="{ on: showPanel }"></span>
</button>
<!-- Stats Summary -->
<div v-if="stats" class="face-filter-stats-inline">
<span>Total: {{ stats.total }}</span>
<span>Filtered: {{ stats.filtered }}</span>
<span v-if="stats.removed > 0" class="removed">Removed: {{ stats.removed }}</span>
</div>
</div>
<!-- Filter Panel -->
<div v-if="showPanel" class="face-filter-content">
<!-- Mode Selection -->
<div class="filter-row">
<label>Mode:</label>
<div class="mode-buttons">
<button
class="mode-btn"
:class="{ active: mode === 'instant' }"
@click="setMode('instant')"
title="Instant client-side filtering (5-10ms)"
>
Instant
</button>
<button
class="mode-btn"
:class="{ active: mode === 'backend' }"
@click="setMode('backend')"
title="Backend complex filtering (landmark, embedding, temporal)"
>
Backend
</button>
<button
class="mode-btn"
:class="{ active: mode === 'hybrid' }"
@click="setMode('hybrid')"
title="Hybrid: backend + instant filtering"
>
Hybrid
</button>
</div>
</div>
<!-- Filter Controls -->
<div class="filter-row">
<label>Confidence:</label>
<input
type="number"
v-model.number="rules.min_confidence"
@change="onRuleChange"
min="0"
max="1"
step="0.05"
class="filter-input"
placeholder="Min confidence"
/>
<span class="filter-hint">(0-1)</span>
</div>
<div class="filter-row">
<label>Bbox Size:</label>
<input
type="number"
v-model.number="rules.min_bbox_size"
@change="onRuleChange"
min="0"
step="10"
class="filter-input"
placeholder="Min size (px)"
/>
<span class="filter-hint">(px)</span>
</div>
<div class="filter-row">
<label>Source:</label>
<select v-model="rules.source" @change="onRuleChange" class="filter-select">
<option value="all">All</option>
<option value="apple_vision">Apple Vision</option>
<option value="mediapipe">MediaPipe</option>
</select>
</div>
<div class="filter-row">
<label>Backend Status:</label>
<select v-model="rules.backend_status" @change="onRuleChange" class="filter-select">
<option value="all">All</option>
<option value="accept">Accept</option>
<option value="review">Review</option>
<option value="reject">Reject</option>
</select>
</div>
<!-- Action Buttons -->
<div class="filter-actions">
<button
class="action-btn action-btn-primary"
@click="handleApplyBackend"
:disabled="loading || mode === 'instant'"
>
{{ loading ? 'Applying...' : 'Apply Backend Rules' }}
</button>
<button
class="action-btn"
@click="handleReset"
>
Reset
</button>
</div>
<!-- Error Message -->
<div v-if="error" class="error-message">
❌ {{ error }}
</div>
<!-- Filter Stats (Backend Mode) -->
<div v-if="filterStats && (mode === 'backend' || mode === 'hybrid')" class="filter-stats">
<h4>Filter Statistics</h4>
<div class="stats-grid">
<div class="stat-item">
<label>Total Faces:</label>
<span>{{ filterStats.total_faces }}</span>
</div>
<div class="stat-item">
<label>Apple Vision:</label>
<span>{{ filterStats.by_category?.by_source?.apple_vision || 0 }}</span>
</div>
<div class="stat-item">
<label>MediaPipe:</label>
<span>{{ filterStats.by_category?.by_source?.mediapipe || 0 }}</span>
</div>
<div class="stat-item">
<label>MediaPipe Verified:</label>
<span>{{ filterStats.by_category?.by_quality?.mediapipe_verified || 0 }}</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useFaceFilter, type QuickFilterRules } from '../composables/useFaceFilter';
const props = defineProps<{
fileUuid: string;
}>();
const {
mode,
rules,
filterResult,
filterStats,
loading,
error,
stats,
setMode,
updateRules,
applyBackendFilter,
resetToDefault
} = useFaceFilter();
const showPanel = ref(false);
function togglePanel() {
showPanel.value = !showPanel.value;
}
function onRuleChange() {
updateRules(rules.value);
}
async function handleApplyBackend() {
if (!props.fileUuid) return;
await applyBackendFilter(props.fileUuid, null);
}
function handleReset() {
resetToDefault();
}
// Expose for parent
defineExpose({
showPanel,
stats
});
</script>
<style scoped>
.face-filter-panel {
border: 1px solid var(--border-color, #ddd);
border-radius: 4px;
background: var(--bg-color, #fff);
margin-bottom: 10px;
}
.face-filter-toolbar {
display: flex;
align-items: center;
gap: 15px;
padding: 8px 12px;
border-bottom: 1px solid var(--border-color, #ddd);
}
.face-filter-toggle-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border: 1px solid var(--border-color, #ddd);
border-radius: 4px;
background: var(--bg-color, #fff);
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
}
.face-filter-toggle-btn:hover {
background: var(--hover-bg, #f5f5f5);
}
.face-filter-toggle-btn.active {
background: var(--primary-color, #007bff);
color: white;
border-color: var(--primary-color, #007bff);
}
.toggle-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #ccc;
transition: background 0.2s;
}
.toggle-dot.on {
background: #4caf50;
}
.face-filter-stats-inline {
display: flex;
gap: 10px;
font-size: 13px;
color: #666;
}
.face-filter-stats-inline .removed {
color: #f44336;
}
.face-filter-content {
padding: 12px;
}
.filter-row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
.filter-row label {
min-width: 120px;
font-size: 14px;
font-weight: 500;
}
.mode-buttons {
display: flex;
gap: 5px;
}
.mode-btn {
padding: 6px 12px;
border: 1px solid var(--border-color, #ddd);
border-radius: 4px;
background: var(--bg-color, #fff);
cursor: pointer;
font-size: 13px;
transition: all 0.2s;
}
.mode-btn:hover {
background: var(--hover-bg, #f5f5f5);
}
.mode-btn.active {
background: var(--primary-color, #007bff);
color: white;
border-color: var(--primary-color, #007bff);
}
.filter-input {
width: 100px;
padding: 4px 8px;
border: 1px solid var(--border-color, #ddd);
border-radius: 4px;
font-size: 14px;
}
.filter-select {
padding: 4px 8px;
border: 1px solid var(--border-color, #ddd);
border-radius: 4px;
font-size: 14px;
background: var(--bg-color, #fff);
}
.filter-hint {
font-size: 12px;
color: #999;
}
.filter-actions {
display: flex;
gap: 10px;
margin-top: 15px;
padding-top: 15px;
border-top: 1px solid var(--border-color, #ddd);
}
.action-btn {
padding: 8px 16px;
border: 1px solid var(--border-color, #ddd);
border-radius: 4px;
background: var(--bg-color, #fff);
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
}
.action-btn:hover:not(:disabled) {
background: var(--hover-bg, #f5f5f5);
}
.action-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.action-btn-primary {
background: var(--primary-color, #007bff);
color: white;
border-color: var(--primary-color, #007bff);
}
.action-btn-primary:hover:not(:disabled) {
background: var(--primary-hover, #0056b3);
}
.error-message {
margin-top: 10px;
padding: 8px 12px;
background: #ffebee;
border: 1px solid #f44336;
border-radius: 4px;
color: #c62828;
font-size: 13px;
}
.filter-stats {
margin-top: 15px;
padding-top: 15px;
border-top: 1px solid var(--border-color, #ddd);
}
.filter-stats h4 {
margin: 0 0 10px 0;
font-size: 14px;
font-weight: 600;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
.stat-item {
display: flex;
justify-content: space-between;
padding: 6px 10px;
background: var(--hover-bg, #f5f5f5);
border-radius: 4px;
font-size: 13px;
}
.stat-item label {
color: #666;
}
.stat-item span {
font-weight: 600;
}
</style>