diff --git a/fam-edge/src/fam_edge/video_processor.py b/fam-edge/src/fam_edge/video_processor.py index ca410e4..3317dbb 100644 --- a/fam-edge/src/fam_edge/video_processor.py +++ b/fam-edge/src/fam_edge/video_processor.py @@ -514,25 +514,38 @@ class VideoProcessor: f"events={len(norm_events)}, people={people}, " f"with_features={len(uid_features)}, 闭集识别={resolved}") + # 家里赤膊/赤裸上身的成年人只有爸爸一个(用户原话:"赤裸的大人都是爸爸, + # 家里没有其他人会赤裸")——命中即免费直判,不用等视觉大模型比对,比 + # auto_id 更快更准(实测已经纠正过一条被 auto_id 误判成爷爷的案例)。 + # 只匹配成年人(外层已经先过滤掉 age_band 是幼儿/儿童的),小孩光膀子玩 + # 很正常,不适用这条规则。 + _SHIRTLESS_KEYWORDS = ('赤裸', '光着上身', '赤膊', '裸体', '光膀子', + '上身赤裸', '未穿上衣') + def _resolve_closed_set_identities(self, video_id: int, uid_features: Dict, norm_events: List[Dict]) -> Dict[str, tuple]: """闭集人物识别:返回 {uid: (canonical_name, source)}。 - 汤圆(幼儿/儿童特征)、媳妇(唯一成年女性)靠 Gemini 已经产出的性别/年龄 - 字段直接判断(source='rule'),验证过命中率接近 100%,不需要额外模型调用。 - 爷爷/爸爸两个成年男性外观规则/人脸向量都区分不开(验证过),改用视觉大模型 - 比对参考图(source='auto_id'),每个 uid 只取本片段内最大 bbox 的一次出现 - 判断一次,不逐事件重复调用。 + 汤圆(幼儿/儿童特征)、媳妇(唯一成年女性)、赤膊成年人(家里只有爸爸 + 会赤膊)靠 Gemini 已经产出的性别/年龄/衣着字段直接判断(source='rule'), + 验证过命中率接近 100%,不需要额外模型调用。爷爷/爸爸两个成年男性穿戴 + 整齐时外观规则/人脸向量都区分不开(验证过),改用视觉大模型比对参考图 + (source='auto_id'),每个 uid 只取本片段内最大 bbox 的一次出现判断 + 一次,不逐事件重复调用。 """ resolved: Dict[str, tuple] = {} for uid, feats in uid_features.items(): gender = str(feats.get('gender', '') or '').strip() age_band = str(feats.get('age_band', '') or '').strip() + clothing = str(feats.get('clothing', '') or '') if age_band in ('幼儿', '儿童'): resolved[uid] = ('汤圆', 'rule') elif gender == '女': resolved[uid] = ('媳妇', 'rule') elif gender == '男': + if any(k in clothing for k in self._SHIRTLESS_KEYWORDS): + resolved[uid] = ('爸爸', 'rule') + continue crop = self._best_crop_for_uid(video_id, uid, norm_events) if crop: person = self.person_identifier.classify_adult_male(crop) diff --git a/fam-edge/tests/test_video_processor.py b/fam-edge/tests/test_video_processor.py index 7bac70a..60080b0 100644 --- a/fam-edge/tests/test_video_processor.py +++ b/fam-edge/tests/test_video_processor.py @@ -1,12 +1,36 @@ 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" @@ -56,3 +80,42 @@ def test_clean_person_strips_ascii_parens(): 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")} + + +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")}