feat: 事件时间轴缩略帧 + 人物管理头像 + 人物合并硬规则校验

## 新架构:Oracle 集中计算 + NAS 代理展示

### Oracle 端 (fam-edge)
- 新增 frame_service: ffmpeg 视频抽帧 + VLM 人物定位裁剪头像(磁盘缓存)
- 新增 /api/oracle/frame: 按 video_id+ts 抽帧返回 jpeg(带 token)
- 新增 /api/oracle/avatar: 按 label 生成人物头像(VLM 定位人物 + 兜底整帧居中)
- 新增 person_identifier: 人物身份识别模块
- Gemini 适配器支持 flash/flash-lite 双模型切换,429 自动降级
- frame_service VLM 全模型 429 时进入 10 分钟熔断,避免每次请求白打配额
- 兜底头像不落缓存,配额恢复后自动重试 VLM 精确定位

### 人物合并硬规则校验(框架级修复)
- person_service: LLM 合并结果落库前加硬冲突检测
  - 性别冲突 → 绝不合并
  - 年龄档跨未成年/成年 → 绝不合并(防止把爷爷/宝宝并进同一人)
- oracle_db: upsert_person 入口剥离括号后缀(人物A(别名:人物B) → 人物A),消灭垃圾人物行
- 修复 set_canonical 丢弃 source 参数的 bug(旧代码硬编码 'manual' 导致错误合并被永久固化)
- get_events_for_label: 只提取该身份组的特征文本,头像定位更精准

### NAS 端 (fam-core)
- 新增 img_proxy: /api/proxy/frame 和 /api/proxy/avatar 代理 Oracle 图片
- app.py 注册 img_bp 蓝图
- oracle_sync / db_layer / member_manager 同步人物表

### UI 端 (fam-ui)
- 事件时间轴: 每条事件卡片加时间点缩略帧
- 人物管理: 每人卡片加头像(150x150 圆角)
- parse_persons: 剥离括号备注,与 Oracle 归一化一致
- 新增 EventItem 组件、Timeline 页改造
- Chat / ServiceStatus 页相应调整

### 数据库
- scripts/ddl.sql: 同步表结构更新
- Oracle people 表: features_json / display_uid / source 字段完善
This commit is contained in:
ericwyuan
2026-08-23 00:13:56 +08:00
parent aca1a674b1
commit 2c5bf950c5
31 changed files with 2166 additions and 25 deletions

View File

@@ -181,3 +181,133 @@ def test_has_motion_in_range_local_filters_by_camera_id(tmp_path):
[{"event_id": 1, "camera_id": 99, "event_type": 10, "start_time": 2500, "duration": 5}])
assert db.has_motion_in_range_local(2000, 3000, camera_id=2) is False
assert db.has_motion_in_range_local(2000, 3000, camera_id=99) is True
# ----------------------------------------------------------------------
# 人物对应关系表video_id, raw_uid) -> canonical_name
# ----------------------------------------------------------------------
def _seed_video_with_events(db, filename="motion_1_1000.mp4"):
vid = db.ensure_video(filename, f"/tmp/{filename}", event_start_time="2026-08-22 10:00:00")
events = [
{"timestamp": "10:00:01", "description": "在客厅走动", "people": ["人物A"],
"person_appearances": [{"uid": "人物A", "features": {"gender": ""}, "action": "走动"}]},
{"timestamp": "10:00:05", "description": "坐下", "people": ["人物A", "人物B"],
"person_appearances": [
{"uid": "人物A", "features": {"gender": ""}, "action": "坐下"},
{"uid": "人物B", "features": {"gender": ""}, "action": "站立"}]},
]
db.mark_video_processed(vid, "摘要", events, ["人物A", "人物B"], "gemini")
return vid
def test_set_identity_mapping_inserts_new_row(tmp_path):
db = _db(tmp_path)
assert db.set_identity_mapping(1, "人物A", "爷爷", source="auto_id") is True
assert db.get_identity_map_for_video(1) == {"人物A": "爷爷"}
def test_set_identity_mapping_updates_existing_non_manual_row(tmp_path):
db = _db(tmp_path)
db.set_identity_mapping(1, "人物A", "爷爷", source="auto_id")
assert db.set_identity_mapping(1, "人物A", "爸爸", source="auto_id") is True
assert db.get_identity_map_for_video(1) == {"人物A": "爸爸"}
def test_set_identity_mapping_manual_protected_from_auto_overwrite(tmp_path):
"""核心诉求: 人工纠正过的映射不能被后续自动识别悄悄改回去。"""
db = _db(tmp_path)
db.set_identity_mapping(1, "人物A", "爸爸", source="manual")
changed = db.set_identity_mapping(1, "人物A", "爷爷", source="auto_id")
assert changed is False
assert db.get_identity_map_for_video(1) == {"人物A": "爸爸"}
def test_set_identity_mapping_manual_can_override_manual(tmp_path):
db = _db(tmp_path)
db.set_identity_mapping(1, "人物A", "爸爸", source="manual")
changed = db.set_identity_mapping(1, "人物A", "爷爷", source="manual")
assert changed is True
assert db.get_identity_map_for_video(1) == {"人物A": "爷爷"}
def test_set_identity_mapping_no_change_returns_false(tmp_path):
db = _db(tmp_path)
db.set_identity_mapping(1, "人物A", "爷爷", source="auto_id")
changed = db.set_identity_mapping(1, "人物A", "爷爷", source="auto_id")
assert changed is False
def test_get_identity_map_for_video_scoped_per_video(tmp_path):
"""核心诉求: 同一个 raw_uid 字符串在不同视频里可能是不同真人,映射必须按
video_id 隔离,不能串。"""
db = _db(tmp_path)
db.set_identity_mapping(1, "人物A", "爷爷", source="auto_id")
db.set_identity_mapping(2, "人物A", "爸爸", source="auto_id")
assert db.get_identity_map_for_video(1) == {"人物A": "爷爷"}
assert db.get_identity_map_for_video(2) == {"人物A": "爸爸"}
def test_rewrite_event_person_names_updates_events_and_video(tmp_path):
db = _db(tmp_path)
vid = _seed_video_with_events(db)
db.rewrite_event_person_names(vid, {"人物A": "爷爷", "人物B": "媳妇"})
rows = db._conn.execute(
"SELECT person_list_json, person_appearances_json FROM events "
"WHERE video_id=? ORDER BY id", (vid,)).fetchall()
assert json.loads(rows[0]["person_list_json"]) == ["爷爷"]
pa0 = json.loads(rows[0]["person_appearances_json"])
assert pa0[0]["uid"] == "爷爷"
assert json.loads(rows[1]["person_list_json"]) == ["爷爷", "媳妇"]
pa1 = json.loads(rows[1]["person_appearances_json"])
assert {p["uid"] for p in pa1} == {"爷爷", "媳妇"}
vrow = db._conn.execute("SELECT people_json FROM videos WHERE id=?", (vid,)).fetchone()
assert set(json.loads(vrow["people_json"])) == {"爷爷", "媳妇"}
def test_rewrite_event_person_names_noop_on_empty_map(tmp_path):
db = _db(tmp_path)
vid = _seed_video_with_events(db)
before = db._conn.execute(
"SELECT person_list_json FROM events WHERE video_id=?", (vid,)).fetchall()
db.rewrite_event_person_names(vid, {})
after = db._conn.execute(
"SELECT person_list_json FROM events WHERE video_id=?", (vid,)).fetchall()
assert [r["person_list_json"] for r in before] == [r["person_list_json"] for r in after]
def test_correct_video_identity_end_to_end(tmp_path):
"""核心诉求: 纠错入口应该找到当前展示名对应的映射行,改写映射 + 立即重写
展示数据,且标记为 manual受保护"""
db = _db(tmp_path)
vid = _seed_video_with_events(db)
db.set_identity_mapping(vid, "人物A", "爷爷", source="auto_id")
db.rewrite_event_person_names(vid, {"人物A": "爷爷"})
db.correct_video_identity(vid, current_name="爷爷", new_name="爸爸")
assert db.get_identity_map_for_video(vid) == {"人物A": "爸爸"}
rows = db._conn.execute(
"SELECT person_list_json FROM events WHERE video_id=? ORDER BY id", (vid,)).fetchall()
assert json.loads(rows[0]["person_list_json"]) == ["爸爸"]
# manual 之后不能被自动识别覆盖回去
changed = db.set_identity_mapping(vid, "人物A", "爷爷", source="auto_id")
assert changed is False
def test_correct_video_identity_without_prior_mapping_uses_current_name_as_raw_uid(tmp_path):
"""核心诉求: 老流水线时代产出的数据从没跑过闭集识别,映射表里没有记录——
纠错依然要能生效,把 current_name 本身当 raw_uid 存一条新映射。"""
db = _db(tmp_path)
vid = db.ensure_video("motion_2_2000.mp4", "/tmp/x.mp4", event_start_time="2026-08-22 10:00:00")
events = [{"timestamp": "10:00:01", "description": "走动", "people": ["爷爷"],
"person_appearances": [{"uid": "爷爷", "features": {"gender": ""}, "action": "走动"}]}]
db.mark_video_processed(vid, "摘要", events, ["爷爷"], "gemini")
db.correct_video_identity(vid, current_name="爷爷", new_name="爸爸")
assert db.get_identity_map_for_video(vid) == {"爷爷": "爸爸"}
rows = db._conn.execute(
"SELECT person_list_json FROM events WHERE video_id=?", (vid,)).fetchall()
assert json.loads(rows[0]["person_list_json"]) == ["爸爸"]