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>
|
||||
389
src/composables/useFaceFilter.ts
Normal file
389
src/composables/useFaceFilter.ts
Normal 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
|
||||
};
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
<template>
|
||||
<div id="ms-view-admin">
|
||||
<div class="admin-header">
|
||||
<h1>Admin Panel</h1>
|
||||
<div class="admin-tabs">
|
||||
<button class="admin-tab" :class="{ active: activeTab === 'dashboard' }" @click="activeTab = 'dashboard'">Dashboard</button>
|
||||
<button class="admin-tab" :class="{ active: activeTab === 'users' }" @click="activeTab = 'users'">Users</button>
|
||||
<button class="admin-tab" :class="{ active: activeTab === 'shares' }" @click="activeTab = 'shares'">Shares</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-content">
|
||||
<div v-if="activeTab === 'dashboard'" class="dashboard-panel">
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card"><div class="stat-label">CPU</div><div class="stat-value">{{ stats.cpu }}%</div></div>
|
||||
<div class="stat-card"><div class="stat-label">Memory</div><div class="stat-value">{{ stats.memory }}%</div></div>
|
||||
<div class="stat-card"><div class="stat-label">Disk</div><div class="stat-value">{{ stats.disk }}%</div></div>
|
||||
<div class="stat-card"><div class="stat-label">Users</div><div class="stat-value">{{ users.length }}</div></div>
|
||||
<div class="stat-card"><div class="stat-label">Shares</div><div class="stat-value">{{ shares.length }}</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab === 'users'" class="users-panel">
|
||||
<table class="admin-table">
|
||||
<thead><tr><th>Username</th><th>Home Dir</th><th>Status</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="u in users" :key="u.username">
|
||||
<td>{{ u.username }}</td><td>{{ u.homeDir }}</td><td>{{ u.status === 1 ? 'Active' : 'Disabled' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab === 'shares'" class="shares-panel">
|
||||
<table class="admin-table">
|
||||
<thead><tr><th>Name</th><th>Port</th><th>Status</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="s in shares" :key="s.name">
|
||||
<td>{{ s.name }}</td><td>{{ s.port }}</td><td>{{ s.status }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { apiCall } from '../api'
|
||||
|
||||
const activeTab = ref('dashboard')
|
||||
const stats = ref({ cpu: 0, memory: 0, disk: 0 })
|
||||
const users = ref<any[]>([])
|
||||
const shares = ref<any[]>([])
|
||||
|
||||
async function loadDashboard() {
|
||||
try { stats.value = await apiCall('get_system_stats', {}) || { cpu: 0, memory: 0, disk: 0 } } catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
try { users.value = await apiCall('list_admin_users', {}) || [] } catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
async function loadShares() {
|
||||
try { shares.value = await apiCall('list_shares', {}) || [] } catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
onMounted(async () => { await loadDashboard(); await loadUsers(); await loadShares() })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
#ms-view-admin { padding: 20px; }
|
||||
.admin-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
|
||||
.admin-tabs { display: flex; gap: 10px; }
|
||||
.admin-tab { padding: 8px 16px; border: none; background: #f0f0f0; cursor: pointer; border-radius: 4px; }
|
||||
.admin-tab.active { background: #2563eb; color: white; }
|
||||
.admin-content { background: white; border-radius: 8px; padding: 20px; }
|
||||
.stats-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 15px; }
|
||||
.stat-card { background: #f8f9fa; padding: 15px; border-radius: 8px; text-align: center; }
|
||||
.stat-label { font-size: 12px; color: #666; }
|
||||
.stat-value { font-size: 24px; font-weight: bold; }
|
||||
.admin-table { width: 100%; border-collapse: collapse; }
|
||||
.admin-table th, .admin-table td { padding: 10px; border: 1px solid #ddd; text-align: left; }
|
||||
</style>
|
||||
@@ -1,84 +0,0 @@
|
||||
<template>
|
||||
<div id="ms-view-client">
|
||||
<div class="client-header">
|
||||
<h1>File Manager</h1>
|
||||
<div class="client-toolbar">
|
||||
<button @click="goUp">Up</button>
|
||||
<button @click="createFolder">New Folder</button>
|
||||
<button @click="deleteSelected" :disabled="!selectedFile">Delete</button>
|
||||
<span class="current-path">{{ currentPath }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="client-content">
|
||||
<table class="file-table">
|
||||
<thead><tr><th>Name</th><th>Size</th><th>Type</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="f in files" :key="f.name" @click="selectFile(f)" :class="{ selected: selectedFile?.name === f.name }">
|
||||
<td><span :class="f.isDir ? 'folder-icon' : 'file-icon'">{{ f.isDir ? '📁' : '📄' }}</span> {{ f.name }}</td>
|
||||
<td>{{ f.isDir ? '-' : formatSize(f.size) }}</td>
|
||||
<td>{{ f.isDir ? 'Folder' : 'File' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { apiCall } from '../api'
|
||||
|
||||
const currentPath = ref('/')
|
||||
const files = ref<any[]>([])
|
||||
const selectedFile = ref<any>(null)
|
||||
|
||||
async function loadFiles() {
|
||||
try {
|
||||
const data = await apiCall('list_client_files', { path: currentPath.value })
|
||||
files.value = (data || []).sort((a: any, b: any) => a.isDir === b.isDir ? a.name.localeCompare(b.name) : a.isDir ? -1 : 1)
|
||||
} catch (e) { console.error(e); files.value = [] }
|
||||
}
|
||||
|
||||
function goUp() {
|
||||
const parts = currentPath.value.split('/').filter(p => p)
|
||||
parts.pop()
|
||||
currentPath.value = '/' + parts.join('/')
|
||||
loadFiles()
|
||||
}
|
||||
|
||||
function selectFile(f: any) {
|
||||
if (f.isDir) { currentPath.value = currentPath.value === '/' ? '/' + f.name : currentPath.value + '/' + f.name; loadFiles() }
|
||||
else { selectedFile.value = f }
|
||||
}
|
||||
|
||||
async function createFolder() {
|
||||
const name = prompt('Folder name:')
|
||||
if (!name) return
|
||||
const path = currentPath.value === '/' ? '/' + name : currentPath.value + '/' + name
|
||||
try { await apiCall('mkdir_client', { path }); loadFiles() } catch (e) { alert('Failed: ' + e) }
|
||||
}
|
||||
|
||||
async function deleteSelected() {
|
||||
if (!selectedFile.value || !confirm('Delete ' + selectedFile.value.name + '?')) return
|
||||
const path = currentPath.value === '/' ? '/' + selectedFile.value.name : currentPath.value + '/' + selectedFile.value.name
|
||||
try { await apiCall('rm_client', { path }); selectedFile.value = null; loadFiles() } catch (e) { alert('Failed: ' + e) }
|
||||
}
|
||||
|
||||
function formatSize(n: number) { return n < 1024 ? n + 'B' : n < 1024*1024 ? (n/1024).toFixed(1) + 'KB' : (n/1024/1024).toFixed(1) + 'MB' }
|
||||
|
||||
onMounted(loadFiles)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
#ms-view-client { padding: 20px; }
|
||||
.client-header { margin-bottom: 15px; }
|
||||
.client-toolbar { display: flex; gap: 10px; align-items: center; margin-top: 10px; }
|
||||
.client-toolbar button { padding: 6px 12px; border: 1px solid #ddd; background: white; cursor: pointer; border-radius: 4px; }
|
||||
.client-toolbar button:disabled { opacity: 0.5; }
|
||||
.current-path { margin-left: 20px; font-weight: bold; }
|
||||
.file-table { width: 100%; border-collapse: collapse; }
|
||||
.file-table th, .file-table td { padding: 8px; border: 1px solid #eee; }
|
||||
.file-table tr.selected { background: #e3f2fd; }
|
||||
.file-table tr:hover { background: #f5f5f5; cursor: pointer; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user