diff --git a/IDENTITY_BEST_FACE_API.md b/IDENTITY_BEST_FACE_API.md new file mode 100644 index 0000000..1f5145c --- /dev/null +++ b/IDENTITY_BEST_FACE_API.md @@ -0,0 +1,277 @@ +# Identity Best-Face API + +**狀態:** 規劃中 +**提出日期:** 2026-06-01 +**提出者:** WordPress Portal 前端團隊 + +--- + +## 1. 背景 + +WordPress Portal 的 People 頁面需要在 identity detail view 與 grid card 中顯示代表臉部縮圖。目前前端作法: + +1. `GET /identity/{uuid}/traces` → 取得所有 trace 列表(含 `avg_confidence`) +2. 對每個 trace 載入第一幀 thumbnail → `GET /file/{uuid}/trace/{tid}/thumbnail` +3. 從有 thumbnail 的 trace 中,選 `avg_confidence` 最高者作為代表圖 + +### 現有問題 + +- **品質不佳**:trace thumbnail 固定取第一幀,不一定是該 trace 內最清晰或正面的臉部畫面 +- **浪費頻寬**:前端需發送大量並行請求(最多 20 trace × thumbnail),多數 thumbnail 最終不會被使用 +- **無快取**:每次進入 detail view 都要重複載入所有 thumbnail +- **不一致**:同樣 identity 在 grid card 與 detail view 可能顯示不同代表圖 + +--- + +## 2. 目標 + +後端新增一個 endpoint,對指定 identity **跨所有 trace** 選出品質最佳(最清晰)的臉部畫面,並提供可直接使用的縮圖 URL,支援 disk cache。 + +--- + +## 3. API 規格 + +### `GET /api/v1/identity/:identity_uuid/best-face` + +無 query parameter。 + +#### 成功回應 `200` + +```json +{ + "success": true, + "identity_uuid": "a6fb22eebefaef17e62af874997c5944", + "name": "Audrey Hepburn", + "source": "fresh", + "best": { + "file_uuid": "a6fb22eebefaef17e62af874997c5944", + "trace_id": 42, + "frame_number": 3120, + "timestamp_secs": 124.8, + "bbox": { + "x": 240, + "y": 180, + "width": 120, + "height": 160 + }, + "confidence": 0.97, + "quality_score": 18624.0, + "blur_score": 2.1, + "thumbnail_url": "/api/v1/file/a6fb22eebefaef17e62af874997c5944/trace/42/thumbnail" + } +} +``` + +#### 無可用臉部 `200` + +```json +{ + "success": true, + "identity_uuid": "a6fb22eebefaef17e62af874997c5944", + "name": "Audrey Hepburn", + "source": "fresh", + "best": null +} +``` + +#### 欄位說明 + +| 欄位 | 型態 | 說明 | +|------|------|------| +| `success` | boolean | 請求是否成功 | +| `identity_uuid` | string | identity UUID(32字元無連字號) | +| `name` | string | identity 名稱 | +| `source` | string | `"fresh"`(即時計算)或 `"cache"`(來自 disk cache) | +| `best` | object/null | 最佳臉部資訊,無可用臉部時為 `null` | +| `best.file_uuid` | string | 該臉部所屬檔案 UUID | +| `best.trace_id` | int | 該臉部所屬 trace ID | +| `best.frame_number` | int | 代表臉的影格編號 | +| `best.timestamp_secs` | float | 代表臉的時間戳(秒) | +| `best.bbox` | object | 臉部 bounding box `{x, y, width, height}` | +| `best.confidence` | float | 該臉部的 detection confidence | +| `best.quality_score` | float | 品質分數 = `(width * height) * confidence` | +| `best.blur_score` | float | 模糊度分數(ffmpeg blurdetect),越低越清晰 | +| `best.thumbnail_url` | string | 縮圖 URL(相對路徑,可直接用於瀏覽器) | + +--- + +## 4. 實作建議 + +### 4.1 建議放置位置 + +**選項 A(建議):** `src/api/trace_agent_api.rs` + +- 原因:核心邏輯重用 `select_rep_face()`(目前為 `pub(crate)`,位於同一檔案),無需修改既有的 function visibility +- 在 `trace_agent_routes()` 中新增路由 + +**選項 B:** `src/api/identity_binding.rs` + +- 需將 `select_rep_face` 改為 `pub` 才能跨檔案呼叫 +- 路由語意上更接近 identity 操作 + +### 4.2 演算法 + +``` +1. DISK CACHE CHECK + 路徑:{OUTPUT_DIR}/identities/{uuid}/best_face.json + 讀取 identity.json 的 updated_at,與 cache 中記錄的版本比較 + 若 cache 未過期 → 直接回傳(source: "cache") + 若無 cache 或已過期 → 繼續計算 + +2. QUERY IDENTITY + SELECT id, name FROM identities + WHERE REPLACE(uuid::text, '-', '') = $1 + +3. QUERY TOP N TRACES + SELECT fd.file_uuid, fd.trace_id, + AVG(fd.confidence)::float8 AS avg_conf + FROM {schema}.face_detections fd + WHERE fd.identity_id = $1 + AND fd.confidence > 0.7 + AND (fd.metadata->>'qc_ok' IS NULL + OR (fd.metadata->>'qc_ok')::boolean = true) + GROUP BY fd.file_uuid, fd.trace_id + ORDER BY avg_conf DESC + LIMIT 5 + +4. FOR EACH TRACE (並行) + select_rep_face(pool, file_uuid, trace_id, err_fn) +  → 回傳該 trace 內 blur_score 最低(最清晰)的臉 + 失敗則 skip(log warning) + +5. SELECT BEST AMONG RESULTS + 主排序:blur_score ASC(越低越清晰) + 次排序:quality_score DESC(blur_score 差距 < 0.5 時) + 全部失敗 → best = null + +6. WRITE DISK CACHE + 路徑:{OUTPUT_DIR}/identities/{uuid}/best_face.json + 內容:best 欄位 + 計算時間 + identity updated_at + +7. RESPONSE +``` + +### 4.3 效能參數 + +| 參數 | 值 | 說明 | +|------|----|------| +| TOP N | 5 | 只對 confidence 最高的 5 個 trace 做 blurdetect | +| confidence 門檻 | > 0.7 | 同既有的 `select_rep_face` 邏輯 | +| QC 過濾 | qc_ok = true/null | 同既有邏輯 | +| ffmpeg timeout | inherit from Command | 每個 trace 約 1-3s | +| cache TTL | 直到下一次 bind/unbind/merge | 事件驅動失效 | + +### 4.4 快取策略 + +**寫入時機:** `get_identity_best_face` 計算完成後 + +**失效時機(刪除 `best_face.json`):** + +| 觸發 operation | 所在檔案 | 備註 | +|---------------|---------|------| +| `bind_trace` (POST) | `identity_binding.rs` | 新增 face 關聯 | +| `unbind` (POST) | `identity_binding.rs` | 移除 face 關聯 | +| `mergeinto` (POST) | `identity_binding.rs` | source + target 雙雙清除 | +| `profile-image` (POST) | `identity_api.rs` | 使用者上傳新大頭照 | + +**Cache 驗證機制:** 儲存計算時的 `identity.updated_at`,每次請求時比對: +- 若 identity 的 `updated_at` 未變 → cache 有效 +- 若已變 → 重新計算 + +### 4.5 建議的新增/修改檔案 + +| 檔案 | 動作 | 說明 | +|------|------|------| +| `src/api/trace_agent_api.rs` | **新增** handler + struct + route | ~+130 行 | +| `src/api/identity_binding.rs` | **修改** 3 處 + cache invalidation helper | ~+25 行 | +| `src/api/identity_api.rs` | **修改** 1 處(profile-image POST) | ~+5 行 | + +### 4.6 需要的新 struct + +**`src/api/trace_agent_api.rs`**(或獨立檔案 `src/core/identity_best_face.rs`): + +```rust +#[derive(Debug, Serialize, Deserialize)] +pub struct BestFaceResponse { + pub success: bool, + pub identity_uuid: String, + pub name: String, + pub source: String, + pub best: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct BestFaceResult { + pub file_uuid: String, + pub trace_id: i32, + pub frame_number: i64, + pub timestamp_secs: f64, + pub bbox: RepFaceBbox, + pub confidence: f64, + pub quality_score: f64, + pub blur_score: f64, + pub thumbnail_url: String, +} +``` + +### 4.7 Cache Invalidation Helper Function + +```rust +async fn invalidate_best_face_cache(output_dir: &str, uuid_clean: &str) { + let path = format!("{}/identities/{}/best_face.json", output_dir, uuid_clean); + let _ = tokio::fs::remove_file(path).await; +} +``` + +--- + +## 5. 前端整合參考(供後端團隊理解使用情境) + +WP snippet 72 (`ms-people.js`) 的 `loadPersonDetail` 中,優先使用新 endpoint: + +```js +async function loadPersonDetail(person) { + if (person.thumb && person._hasProfileImage) return; + + try { + const res = await apiFetch('/identity/' + person.id + '/best-face'); + if (res?.success && res?.best) { + const b = res.best; + person.thumb = `${API_BASE}/file/${b.file_uuid}/trace/${b.trace_id}/thumbnail?api_key=${API_KEY}`; + person._hasProfileImage = true; + updateDetailAvatar(person); + return; + } + } catch (e) { /* fallback to legacy */ } + + // 原邏輯:traces → thumbnails → confidence sort +} +``` + +同樣可用於 grid card 的代表圖載入(`loadGridThumbnails`): + +```js +// 一次性載入所有 pending identity 的 best-face +const results = await Promise.allSettled( + persons.map(p => apiFetch('/identity/' + p.id + '/best-face')) +); +``` + +--- + +## 6. 驗收標準 + +1. `GET /api/v1/identity/{uuid}/best-face` → `200` + valid JSON +2. 有 trace 的 identity → `best` 不為 null,且 `blur_score` 為該 identity 所有 trace 中最低 +3. 無 trace 的 identity → `best: null` +4. 短時間內重複請求同一 identity → `source: "cache"`,回應時間 < 10ms +5. 綁定新 trace 後再次請求 → `source: "fresh"`(cache 已正確失效) +6. `thumbnail_url` 可直接用於 `` 顯示 + +--- + +## 7. 風險與注意事項 + +- **首次請求延遲**:對有大量 trace 的 identity(如主角),首次請求可能需 5-15 秒。建議前端顯示 loading state +- **ffmpeg 資源**:同時多個請求可能導致高 CPU 使用。可考慮加入 per-identity lock 避免重複計算 +- **邊界案例**:trace 內的 faces 全部 confidence ≤ 0.7 或 qc_ok=false,則該 trace 被跳過,可能導致 `best: null`