家里从 4 人变成 5 人后,"媳妇=唯一成年女性"这条免费规则的前提被打破—— 人物管理页面已经能看到一条 label="奶奶" 但从未稳定命中的记录(只出现过 1 次,特征都没落库),说明现在系统大概率把奶奶也误判成了媳妇。 先用免费的年龄段字段区分(老年=奶奶,其余=媳妇),不引入额外的模型调用 (区别于爷爷/爸爸那种"两个成年男性区分不开必须靠视觉比对参考图"的方案) ——如果后续观察发现年龄段判断不稳定导致误判,再考虑改成视觉比对。 新增 3 个单元测试覆盖(中年→媳妇/老年→奶奶/年龄段缺失默认媳妇),fam-edge 全量 142/142 通过。已部署 Oracle 并重启验证。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
150 lines
6.2 KiB
Python
150 lines
6.2 KiB
Python
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")}
|