Files
sentinel-home-ai/fam-edge/tests/test_video_processor.py
ericwyuan 05c7a8b70a feat(fam-edge): 闭集人物识别新增奶奶(成年女性按年龄段区分媳妇/奶奶)
家里从 4 人变成 5 人后,"媳妇=唯一成年女性"这条免费规则的前提被打破——
人物管理页面已经能看到一条 label="奶奶" 但从未稳定命中的记录(只出现过
1 次,特征都没落库),说明现在系统大概率把奶奶也误判成了媳妇。

先用免费的年龄段字段区分(老年=奶奶,其余=媳妇),不引入额外的模型调用
(区别于爷爷/爸爸那种"两个成年男性区分不开必须靠视觉比对参考图"的方案)
——如果后续观察发现年龄段判断不稳定导致误判,再考虑改成视觉比对。

新增 3 个单元测试覆盖(中年→媳妇/老年→奶奶/年龄段缺失默认媳妇),fam-edge
全量 142/142 通过。已部署 Oracle 并重启验证。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 22:42:26 +08:00

150 lines
6.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from datetime import datetime
from fam_edge.video_processor import (
VideoProcessor,
_parse_event_start_from_filename,
_parse_event_ts,
_clean_person,
)
class _RaisingPersonIdentifier:
"""用于验证"命中免费规则就不该再调用大模型比对"——一旦被调用直接报错,
测试能立刻发现规则短路失败。"""
def classify_adult_male(self, crop):
raise AssertionError("命中了免费规则的 uid 不该再走 person_identifier")
class _FixedPersonIdentifier:
def __init__(self, name):
self._name = name
def classify_adult_male(self, crop):
return self._name
def _bare_processor(person_identifier):
"""跳过 __init__不需要真的加载 config/建适配器),只测
_resolve_closed_set_identities 这一个纯逻辑方法。"""
vp = VideoProcessor.__new__(VideoProcessor)
vp.person_identifier = person_identifier
return vp
def test_parse_filename_pure_digit_format():
assert _parse_event_start_from_filename(
"Generic_ONVIF-001-20260820-140416-1787205856321-7.mp4"
) == "2026-08-20 14:04:16"
def test_parse_filename_underscore_date_format():
assert _parse_event_start_from_filename("20260821_081500.mp4") == "2026-08-21 08:15:00"
def test_parse_filename_dashed_date_format():
assert _parse_event_start_from_filename("2026-08-21_081500.mp4") == "2026-08-21 08:15:00"
def test_parse_filename_no_match_returns_empty():
assert _parse_event_start_from_filename("客厅.mp4") == ""
def test_parse_event_ts_relative_offset():
start = datetime(2026, 8, 21, 15, 53, 3)
abs_ts, offset = _parse_event_ts("00:18:22", start)
assert abs_ts == "2026-08-21 16:11:25"
assert offset == 18 * 60 + 22
def test_parse_event_ts_relative_offset_no_start():
abs_ts, offset = _parse_event_ts("00:01:23", None)
assert abs_ts == "00:01:23"
assert offset == 83.0
def test_parse_event_ts_over_6_hours_falls_back_to_absolute():
"""相对时间 > 6 小时视为模型误输出绝对时间,不强行按偏移定位。"""
start = datetime(2026, 8, 21, 8, 0, 0)
abs_ts, offset = _parse_event_ts("2026-08-21 09:00:00", start)
assert abs_ts == "2026-08-21 09:00:00"
assert offset == 3600.0
def test_clean_person_strips_fullwidth_parens():
assert _clean_person("人物A别名/标识人物B") == "人物A"
def test_clean_person_strips_ascii_parens():
assert _clean_person("人物A(alias: 人物B)") == "人物A"
def test_clean_person_no_parens_unchanged():
assert _clean_person("汤圆") == "汤圆"
# ----------------------------------------------------------------------
# 闭集人物识别:赤膊成年人规则(用户原话:"赤裸的大人都是爸爸,家里没有
# 其他人会赤裸")——命中即免费直判,不调用视觉大模型比对。
# ----------------------------------------------------------------------
def test_shirtless_adult_male_resolves_to_dad_without_vlm_call():
vp = _bare_processor(_RaisingPersonIdentifier())
resolved = vp._resolve_closed_set_identities(
1, {"人物A": {"gender": "", "age_band": "中年", "clothing": "赤膊+深色长裤"}}, [])
assert resolved == {"人物A": ("爸爸", "rule")}
def test_shirtless_variants_all_match():
for phrase in ("光着上身", "赤膊", "裸体", "光膀子", "上身赤裸", "未穿上衣", "深色长裤,赤裸上身"):
vp = _bare_processor(_RaisingPersonIdentifier())
resolved = vp._resolve_closed_set_identities(
1, {"人物A": {"gender": "", "age_band": "中年", "clothing": phrase}}, [])
assert resolved.get("人物A") == ("爸爸", "rule"), f"未命中: {phrase}"
def test_shirtless_child_still_resolves_to_child_not_dad():
"""核心诉求: 用户的规则明确是"赤裸的大人",小孩光膀子玩很正常,不适用
这条规则——幼儿/儿童年龄档要走在赤膊判断前面,不能被误判成爸爸。"""
vp = _bare_processor(_RaisingPersonIdentifier())
resolved = vp._resolve_closed_set_identities(
1, {"人物A": {"gender": "", "age_band": "幼儿", "clothing": "赤裸上身+深色短裤"}}, [])
assert resolved == {"人物A": ("汤圆", "rule")}
# ----------------------------------------------------------------------
# 闭集人物识别:成年女性按年龄段区分媳妇/奶奶2026-08-29 新增,家里从 4 人
# 变成 5 人后,"媳妇=唯一成年女性"这条规则的前提被打破)
# ----------------------------------------------------------------------
def test_middle_aged_female_resolves_to_wife_without_vlm_call():
vp = _bare_processor(_RaisingPersonIdentifier())
resolved = vp._resolve_closed_set_identities(
1, {"人物A": {"gender": "", "age_band": "中年", "clothing": "粉色上衣"}}, [])
assert resolved == {"人物A": ("媳妇", "rule")}
def test_elderly_female_resolves_to_grandma_without_vlm_call():
vp = _bare_processor(_RaisingPersonIdentifier())
resolved = vp._resolve_closed_set_identities(
1, {"人物A": {"gender": "", "age_band": "老年", "clothing": "红色上衣"}}, [])
assert resolved == {"人物A": ("奶奶", "rule")}
def test_female_with_unknown_age_band_defaults_to_wife():
"""核心诉求: age_band 缺失/模型没给出明确判断时,不能因为不确定就放弃
识别——默认归到媳妇(原有行为),只有明确判断为"老年"才算奶奶。"""
vp = _bare_processor(_RaisingPersonIdentifier())
resolved = vp._resolve_closed_set_identities(
1, {"人物A": {"gender": "", "age_band": "unknown", "clothing": ""}}, [])
assert resolved == {"人物A": ("媳妇", "rule")}
def test_clothed_adult_male_still_falls_through_to_vlm(monkeypatch):
"""核心诉求: 没有赤膊关键词的正常穿戴场景,行为不变——照常走视觉大模型
比对(这里用假的 _best_crop_for_uid 避免真的需要 norm_events 数据)。"""
vp = _bare_processor(_FixedPersonIdentifier("爷爷"))
monkeypatch.setattr(vp, "_best_crop_for_uid", lambda *a, **k: b"fake-jpeg-bytes")
resolved = vp._resolve_closed_set_identities(
1, {"人物A": {"gender": "", "age_band": "中年", "clothing": "蓝色Polo衫"}}, [])
assert resolved == {"人物A": ("爷爷", "auto_id")}