Files
meida_backend/tests/unit/test_report_vision.py
xsl e7076c9c01 feat(report): deterministic vision provider and 32-dimension diagnosis assembly
dimensions.py 逐行编码需求文档 §4(32 项四要素、缺失显式 skipped)+
VisionProvider 接口位与确定性实现 + 规则引擎联动 rule_hits(T-P3-01/06)。

实现:
- dimensions.py:DimensionSpec 恰 32 项(number 1~32 连续,33/34 永不复用),
  basic_coverage 口径机器可读:full 10 / label 3 / partial 8 / none 11,
  初级覆盖 21/32(BASIC_FULL_KEYS + BASIC_COVERED_KEYS)。
- vision.py:VisionProvider Protocol(派生特征形状写进 docstring,真实
  RTMPose/BiRefNet 实现后续按此替换)+ DeterministicVisionProvider(问卷
  BMI/自评驱动体型与肤色,sha256(object_key+canonical问卷) 驱动其余枚举,
  同输入同输出)+ build_vision_provider(未知名 CONFIG_ERROR)。
- vision_stage.py:derived_features 已存在 → skipped/reused(T-P3-09);
  否则分析 → 写中间态并 flush;photo_meta.quality_flags 透传进产物(T-P3-01)。
- diagnosis_stage.py:32 维恒全键,缺输入显式 skipped+原因(T-P3-06 红线);
  每维四要素 value/confidence/source/evidence/rule_hits + status;构造
  RuleContext → RuleService 载入 published 规则 → evaluate,body_shape/color
  类命中回落 #5/#19/#21,全量命中聚合进 #28/#30 的 rule_hits 与 evidence;
  色板 #19/#20/#21 从 kb_color 按季型读取,库空 → skipped「色板未录入」。

测试:单测 29 项(32 项完整性/编号连续/确定性/工厂拒绝未知名;四要素齐全
性——故意缺问卷+缺视觉断言 32 键全在且全部 skipped 带原因;规则命中进
rule_hits 用内置最小规则集不连库);集成 1 项(种 2 条 published 规则 →
VisionStage → DiagnosisStage:中间态落库与复用 skipped/reused、32 维全键、
rule_hits 出现、kb_color 空时色板 skipped、coverage basic_full=10/32)。
全绿:ruff check/format、mypy packages/report、lint-imports;单测 536 通过
(3 处既有 skip)、集成 46 通过。

偏差说明:
1. 任务文档写 RulesService,实际类名为 RuleService(packages/rules/service.py),按实际代码调用。
2. kb_color 联表键采用四季代码 spring/summer/autumn/winter(仓库无既定口径),已在 vision.py 注释声明。
3. 任务文档只给 basic_coverage 四枚举,需求表格中的「粗判/8色/3色」归入 partial、「Top1」归入 label。
2026-08-14 22:14:54 +08:00

143 lines
4.9 KiB
Python

"""确定性视觉提供者:同输入同输出、问卷驱动、枚举合法、工厂拒绝未知名。"""
from __future__ import annotations
import pytest
from packages.report.dimensions import DIMENSION_BY_KEY
from packages.report.vision import (
BODY_SHAPES,
FACE_SHAPES,
SEASON_CODES,
SKIN_TONES,
STYLE_TYPES,
DeterministicVisionProvider,
VisionProvider,
build_vision_provider,
dimensions_touched,
)
from packages.shared.config import Settings
from packages.shared.errors import AppError, ErrorCode
_PHOTO = {"object_key": "upload/report/photo-001.jpg"}
_QUESTIONNAIRE = {
"gender": "女",
"height": 165,
"weight": 50,
"birthday": "1998-06-01",
}
async def test_same_input_same_output() -> None:
provider = DeterministicVisionProvider()
first = await provider.analyze(_PHOTO, _QUESTIONNAIRE)
second = await provider.analyze(dict(_PHOTO), dict(_QUESTIONNAIRE))
assert first == second
async def test_satisfies_protocol() -> None:
assert isinstance(DeterministicVisionProvider(), VisionProvider)
async def test_shape_contract_and_enum_values() -> None:
features = await DeterministicVisionProvider().analyze(_PHOTO, _QUESTIONNAIRE)
expected_keys = {
"body_shape",
"body_features",
"body_ratio",
"face_shape",
"face_proportions",
"facial_details",
"face_depth",
"skin_lab",
"skin_tone",
"face_hue",
"face_lightness",
"face_chroma",
"face_contrast",
"season_code",
"season_detailed",
"volume",
"curve_straight",
"motion",
"gene_style_scores",
"hair_status",
"hair_color",
}
assert set(features) == expected_keys
assert features["body_shape"] in BODY_SHAPES
assert features["face_shape"] in FACE_SHAPES
assert features["skin_tone"] in SKIN_TONES
assert features["season_code"] in SEASON_CODES
assert set(features["gene_style_scores"]) == set(STYLE_TYPES)
# 色彩轴连续值域与标签三档。
for axis in ("face_hue", "face_lightness", "face_chroma"):
assert -1 <= features[axis]["value"] <= 1
assert features[axis]["label"]
assert 0 <= features["face_depth"] <= 10
assert 0.58 <= features["body_ratio"] <= 0.68
async def test_different_questionnaire_drives_different_body_shape() -> None:
provider = DeterministicVisionProvider()
slim = await provider.analyze(_PHOTO, {"height": 165, "weight": 45}) # BMI≈16.5
heavy = await provider.analyze(_PHOTO, {"height": 165, "weight": 95}) # BMI≈34.9
assert slim["body_shape"] in ("A", "H") # 过瘦区间:梨/矩形
assert heavy["body_shape"] == "O" # 高 BMI:苹果形
assert slim["body_shape"] != heavy["body_shape"]
async def test_questionnaire_self_report_overrides_hash() -> None:
# §3.6 降级路径:问卷自评是真实信号,优先于 mock 哈希。
provider = DeterministicVisionProvider()
plain = await provider.analyze(_PHOTO, {})
self_reported = await provider.analyze(
_PHOTO,
{"body_shape_self": "X", "face_shape_self": "圆", "skin_tone_self": "粉一白"},
)
assert self_reported["body_shape"] == "X"
assert self_reported["face_shape"] == "圆"
assert self_reported["skin_tone"] == "粉一白"
# 自评肤色反推冷轴:粉系=冷 → 季型落在冷季(夏/冬)。
assert self_reported["season_code"] in ("summer", "winter")
assert plain["season_code"] in SEASON_CODES
async def test_different_photo_changes_features() -> None:
provider = DeterministicVisionProvider()
a = await provider.analyze({"object_key": "a.jpg"}, {})
b = await provider.analyze({"object_key": "b.jpg"}, {})
assert a != b
async def test_missing_object_key_still_deterministic() -> None:
provider = DeterministicVisionProvider()
empty_meta: dict[str, str] = {}
assert await provider.analyze(empty_meta, {}) == await provider.analyze({}, {})
def test_dimensions_touched_maps_to_dimension_keys() -> None:
features = {
"body_shape": "A",
"skin_lab": {"l": 60.0, "a": 1.0, "b": 20.0}, # 中间量,不是维度
"gene_style_scores": {"自然": 9},
}
touched = dimensions_touched(features)
assert touched == ["body_shape", "gene_style"]
assert all(key in DIMENSION_BY_KEY for key in touched)
def test_factory_default_is_deterministic() -> None:
settings = Settings(_env_file=None)
provider = build_vision_provider(settings)
assert isinstance(provider, DeterministicVisionProvider)
def test_factory_rejects_unknown_provider() -> None:
settings = Settings(_env_file=None, report_vision_provider="rtmpose")
with pytest.raises(AppError) as excinfo:
build_vision_provider(settings)
assert excinfo.value.code == ErrorCode.CONFIG_ERROR
assert excinfo.value.data["report_vision_provider"] == "rtmpose"