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。
143 lines
4.9 KiB
Python
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"
|