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:
195
src/components/FaceCard.vue
Normal file
195
src/components/FaceCard.vue
Normal 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>
|
||||
406
src/components/FaceFilterPanel.vue
Normal file
406
src/components/FaceFilterPanel.vue
Normal 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>
|
||||
Reference in New Issue
Block a user