From 9dff19e6acf8d8c5dc8e4f3297e46ee4e1413c6f Mon Sep 17 00:00:00 2001 From: ericwyuan Date: Sun, 23 Aug 2026 11:00:43 +0800 Subject: [PATCH] =?UTF-8?q?feat(fam-edge):=20=E8=B5=A4=E8=86=8A=E6=88=90?= =?UTF-8?q?=E5=B9=B4=E4=BA=BA=E7=9B=B4=E5=88=A4=E7=88=B8=E7=88=B8(?= =?UTF-8?q?=E5=85=8D=E8=B4=B9=E8=A7=84=E5=88=99)=20-=20=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E7=A1=AE=E8=AE=A4=E5=AE=B6=E9=87=8C=E5=8F=AA=E6=9C=89=E7=88=B8?= =?UTF-8?q?=E7=88=B8=E4=BC=9A=E5=85=89=E8=86=80=E5=AD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户原话:"赤裸的大人都是爸爸,家里没有其他人会赤裸"——加进闭集识别的免费 规则层(跟汤圆/媳妇同级),命中就不用再调用视觉大模型比对参考图,比 auto_id 更快更准。只对成年人生效(判断顺序上幼儿/儿童年龄档在前面,小孩光膀子玩很 正常,不适用这条规则)。 排查历史数据时用这条规则筛出了一个真实误判:video 1092 的一次赤膊出现被 auto_id(NVIDIA 视觉比对)错误识别成"爷爷",实际是"爸爸"。已用 correct_video_identity 手动纠正(标记 source=manual,受保护不会被以后的自动 识别覆盖回去),描述文字已确认同步更新。 顺带确认了另一批命中同样关键词的历史数据(74 条里有 3 条是"汤圆"光膀子) 是正确的——3 岁小孩光膀子玩正常,是通过幼儿/儿童年龄档规则识别出来的,跟 赤膊无关,不是误判,没有动。 新增 4 个测试:赤膊成年男性直判爸爸且不调用大模型(用会报错的假 person_identifier 验证短路生效)、覆盖各种赤膊近义词表述、赤膊小孩仍然 正确判成汤圆不受影响、没有赤膊关键词时行为不变照常走大模型比对。 --- fam-edge/src/fam_edge/video_processor.py | 23 +++++++-- fam-edge/tests/test_video_processor.py | 63 ++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 5 deletions(-) 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")}