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。
This commit is contained in:
xsl
2026-08-14 22:14:54 +08:00
parent d974526904
commit e7076c9c01
8 changed files with 1947 additions and 0 deletions
+304
View File
@@ -0,0 +1,304 @@
"""视觉分析提供者:接口位 + V1 确定性(mock)实现。
**接口位**(T9 SPIKE 之后的正式契约):真实 RTMPose/BiRefNet/Lab 采样实现后续
按 ``VisionProvider`` Protocol 替换,流水线与其余阶段不感知差别。
**为什么 mock 要确定性**:黄金集与集成测试必须能对同一输入断言同一输出;
概率性 mock 会让"偶现失败"混进 CI。因此 ``DeterministicVisionProvider``
不读照片字节(V1 骨架没有照片下载步骤),只从 ``questionnaire`` +
``photo_meta["object_key"]`` 的 sha256 派生特征——同输入恒同输出。
派生特征的形状(所有键即诊断维度键,两个例外见下)::
{
"body_shape": "A" | "O" | "X" | "T" | "H", # 梨/苹果/沙漏/倒三角/矩形
"body_features": ["溜肩", ...], # 多标签,可为空列表
"body_ratio": float, # 上下身比例(0.58~0.68)
"face_shape": "鹅蛋" | "圆" | "方" | "长" | "心形" | "菱形" | "梨形",
"face_proportions": "均匀" | "上庭偏长" | "中庭偏长" | "下庭偏长",
"facial_details": {"眉形": ..., "眼型": ..., "双眼皮": ..., "鼻型": ..., "唇形": ...},
"face_depth": float, # 面部立体度 0~10
"skin_lab": {"l": float, "a": float, "b": float}, # 中间量,不是维度键
"skin_tone": "粉一白" | ... | "黄黑皮", # 六档
"face_hue": {"value": float, "label": str}, # -1 冷 ←→ 1 暖
"face_lightness": {"value": float, "label": str}, # -1 暗沉 ←→ 1 白皙
"face_chroma": {"value": float, "label": str}, # -1 柔和 ←→ 1 鲜艳
"face_contrast": "低" | "适中" | "高",
"season_code": "spring" | "summer" | "autumn" | "winter",
"season_detailed": "浅春" | ... | "深冬", # 十二季
"volume": {"label": "小" | "中" | "大", "score": int},
"curve_straight": {"label": "偏曲" | "适中" | "偏直", "score": float},
"motion": "动态型" | "动静适中" | "静态型",
"gene_style_scores": {"戏剧": int, ...}, # 9 型评分;例外键:维度键为 gene_style
"hair_status": {"length": "短" | "中" | "长", "curl": "直" | "微卷" | "大卷"},
"hair_color": {"hex": "#RRGGBB", "tone": "冷" | "暖"},
}
"""
from __future__ import annotations
import hashlib
import json
from collections.abc import Mapping
from typing import Any, Protocol, runtime_checkable
from packages.report.dimensions import DIMENSION_BY_KEY
from packages.shared.config import Settings
from packages.shared.errors import AppError, ErrorCode
#: 体型五型(需求文档 §4 #5 取值范围;字母码与表格一致)。
BODY_SHAPES: tuple[str, ...] = ("A", "O", "X", "T", "H")
#: 脸型七型(需求文档 §4 #8)。
FACE_SHAPES: tuple[str, ...] = ("鹅蛋", "圆", "方", "长", "心形", "菱形", "梨形")
#: 肤色六档(需求文档 §4 #12)。
SKIN_TONES: tuple[str, ...] = ("粉一白", "粉二白", "粉三白", "黄一白", "黄二白", "黄黑皮")
#: 四季季型代码。kb_color.season_code 必须用同一套代码(色板维度的联表键)。
SEASON_CODES: tuple[str, ...] = ("spring", "summer", "autumn", "winter")
SEASON_CODE_CN: Mapping[str, str] = {
"spring": "春",
"summer": "夏",
"autumn": "秋",
"winter": "冬",
}
#: 基因/心理风格九型(需求文档 §4 #25/#26)。
STYLE_TYPES: tuple[str, ...] = (
"戏剧",
"睿智",
"自然",
"古典",
"优雅",
"浪漫",
"前卫",
"少女",
"少年",
)
#: 派生特征键 → 诊断维度键的例外映射(其余键与维度键同名)。
#: skin_lab 是色彩算法的中间量,不对应任何维度。
FEATURE_TO_DIMENSION: Mapping[str, str] = {"gene_style_scores": "gene_style"}
def dimensions_touched(features: Mapping[str, Any]) -> list[str]:
"""派生特征覆盖到的诊断维度键(vision 阶段产物的 dimensions_touched)。"""
touched: list[str] = []
for key in features:
dim = FEATURE_TO_DIMENSION.get(key, key)
if dim in DIMENSION_BY_KEY and dim not in touched:
touched.append(dim)
return touched
@runtime_checkable
class VisionProvider(Protocol):
"""视觉分析 SPI:照片元信息 + 问卷 → 派生特征 dict(形状见模块 docstring)。
实现必须**确定性**(同输入同输出),否则黄金集断言与失败重放都不成立。
"""
async def analyze(
self, photo_meta: dict[str, Any], questionnaire: dict[str, Any]
) -> dict[str, Any]: ...
def _seed(object_key: str, questionnaire: Mapping[str, Any], salt: str) -> bytes:
"""确定性种子:canonical JSON 保证 dict 键序无关,salt 分组隔离特征域。"""
canonical = json.dumps(questionnaire, sort_keys=True, ensure_ascii=False, separators=(",", ":"))
return hashlib.sha256(f"{object_key}|{canonical}|{salt}".encode()).digest()
def _num(value: Any) -> float | None:
"""问卷数值字段的宽容读取:bool 不是数值(是 int 子类,须显式排除)。"""
if isinstance(value, bool) or not isinstance(value, (int, float)):
return None
return float(value)
def _bmi(questionnaire: Mapping[str, Any]) -> float | None:
height = _num(questionnaire.get("height"))
weight = _num(questionnaire.get("weight"))
if height is None or weight is None or height <= 0:
return None
return weight / (height / 100.0) ** 2
def _pick(digest: bytes, index: int, options: tuple[str, ...]) -> str:
return options[digest[index] % len(options)]
class DeterministicVisionProvider:
"""V1 骨架的确定性假实现:真实视觉模型的占位(报告链路先跑通、可回归)。
派生规则(全部可复算,这里就是"模型"本身):
- **问卷驱动**:
- 身高/体重 → BMI:``<18.5`` → A/H 二选一;``18.5~24`` → X/H/T 三选一;
``24~28`` → O/T 二选一;``≥28`` → O。缺失时按哈希在五型里均匀选。
- 问卷自评(§3.6 降级路径的 body_shape_self / face_shape_self /
skin_tone_self)**优先于哈希**——自评是真实信号,mock 噪声不该盖过它;
skin_tone_self 同时反推 hue/lightness,保证季型/肤色档内部一致。
- **哈希驱动**(sha256(object_key + canonical 问卷),face/body 两组独立盐):
- face 组:d0/d1/d2 → 色相/亮度/色度(-1~1 连续值),季型=色相冷热 ×
亮度深浅(暖+亮→春、冷+柔→夏、暖+深→秋、冷+亮→冬),十二季=季型 ×
色度/亮度细分;d3~d10 → 脸型/三庭五眼/五官/立体度。
- body 组:b0 → 体型枚举选择;b1~b3 → 身体局部特征多标签;
b4 → 上下身比例(0.58~0.68);b5/b6 → 量感(30~70)/直曲;b7 → 动静;
b8~b21 → 发长/卷度/发色/九型基因风格评分(3~10 分)。
"""
async def analyze(
self, photo_meta: dict[str, Any], questionnaire: dict[str, Any]
) -> dict[str, Any]:
object_key = str(photo_meta.get("object_key", ""))
face = _seed(object_key, questionnaire, "face")
body = _seed(object_key, questionnaire, "body")
# -- 色彩三轴:连续值 + 三档标签(粗判即标签口径) --------------------
hue = round(face[0] / 255 * 2 - 1, 3)
lightness = round(face[1] / 255 * 2 - 1, 3)
chroma = round(face[2] / 255 * 2 - 1, 3)
# 问卷肤色自评优先:粉系=冷(hue<0)、黄系=暖;白度档位反推亮度。
skin_tone_self = questionnaire.get("skin_tone_self")
if isinstance(skin_tone_self, str) and skin_tone_self in SKIN_TONES:
skin_tone = skin_tone_self
hue = -abs(hue) - 0.05 if skin_tone.startswith("粉") else abs(hue) + 0.05
if skin_tone.endswith("一白"):
lightness = 0.6
elif skin_tone.endswith("二白"):
lightness = 0.2
elif skin_tone == "粉三白":
lightness = -0.1
else: # 黄黑皮
lightness = -0.6
else:
if lightness > 0.35:
skin_tone = "粉一白" if hue < 0 else "黄一白"
elif lightness > 0.1:
skin_tone = "粉二白" if hue < 0 else "黄二白"
elif lightness > -0.2:
skin_tone = "粉三白"
else:
skin_tone = "黄黑皮"
warm = hue >= 0
season_code = (
"spring"
if warm and lightness >= 0
else ("autumn" if warm else ("summer" if lightness >= 0 else "winter"))
)
if season_code == "spring":
season_detailed = "亮春" if chroma > 0.25 else ("浅春" if lightness > 0.5 else "暖春")
elif season_code == "summer":
season_detailed = "浅夏" if lightness > 0.5 else ("柔夏" if chroma < -0.25 else "冷夏")
elif season_code == "autumn":
season_detailed = "柔秋" if chroma < -0.25 else ("深秋" if lightness < -0.5 else "暖秋")
else:
season_detailed = "亮冬" if chroma > 0.25 else ("深冬" if lightness < -0.5 else "冷冬")
# -- 体型(问卷 BMI 主驱动,自评覆盖) --------------------------------
body_shape_self = questionnaire.get("body_shape_self")
if isinstance(body_shape_self, str) and body_shape_self in BODY_SHAPES:
body_shape = body_shape_self
else:
bmi = _bmi(questionnaire)
if bmi is None:
body_shape = _pick(body, 0, BODY_SHAPES)
elif bmi < 18.5:
body_shape = _pick(body, 0, ("A", "H"))
elif bmi < 24:
body_shape = _pick(body, 0, ("X", "H", "T"))
elif bmi < 28:
body_shape = _pick(body, 0, ("O", "T"))
else:
body_shape = "O"
feature_pool = ("溜肩", "平肩", "宽肩", "短颈", "长腰", "短腰", "粗腿")
body_features = [_pick(body, 1, feature_pool)]
extra = _pick(body, 2, feature_pool)
if extra not in body_features and body[3] % 2 == 1:
body_features.append(extra)
volume_score = 30 + body[5] % 41
curve_score = round(body[6] / 255 * 2 - 1, 3)
face_shape_self = questionnaire.get("face_shape_self")
face_shape = (
face_shape_self
if isinstance(face_shape_self, str) and face_shape_self in FACE_SHAPES
else _pick(face, 3, FACE_SHAPES)
)
gene_scores = {style: 3 + body[13 + i] % 8 for i, style in enumerate(STYLE_TYPES)}
hair_hex = f"#{40 + body[10] % 80:02X}{20 + body[11] % 60:02X}{body[12] % 80:02X}"
return {
"body_shape": body_shape,
"body_features": body_features,
"body_ratio": round(0.58 + body[4] / 255 * 0.10, 3),
"face_shape": face_shape,
"face_proportions": _pick(face, 4, ("均匀", "上庭偏长", "中庭偏长", "下庭偏长")),
"facial_details": {
"眉形": _pick(face, 5, ("平直眉", "弯眉", "挑眉")),
"眼型": _pick(face, 6, ("杏仁眼", "圆眼", "细长眼")),
"双眼皮": _pick(face, 7, ("双眼皮", "内双")),
"鼻型": _pick(face, 8, ("直鼻", "翘鼻", "宽鼻")),
"唇形": _pick(face, 9, ("薄唇", "标准唇", "厚唇")),
},
"face_depth": round(face[10] % 101 / 10, 1),
"skin_lab": {
"l": round(45 + (lightness + 1) * 20, 1),
"a": round(hue * 12, 1),
"b": round(20 + chroma * 15, 1),
},
"skin_tone": skin_tone,
"face_hue": {
"value": round(hue, 3),
"label": "偏冷" if hue < -0.33 else ("偏暖" if hue > 0.33 else "中性"),
},
"face_lightness": {
"value": round(lightness, 3),
"label": "白皙" if lightness > 0.33 else ("暗沉" if lightness < -0.33 else "适中"),
},
"face_chroma": {
"value": round(chroma, 3),
"label": "鲜艳" if chroma > 0.33 else ("柔和" if chroma < -0.33 else "适中"),
},
"face_contrast": "高" if chroma > 0.2 else ("低" if chroma < -0.2 else "适中"),
"season_code": season_code,
"season_detailed": season_detailed,
"volume": {
"label": "小" if volume_score < 40 else ("中" if volume_score <= 55 else "大"),
"score": volume_score,
},
"curve_straight": {
"label": "偏曲"
if curve_score < -0.33
else ("偏直" if curve_score > 0.33 else "适中"),
"score": curve_score,
},
"motion": _pick(body, 7, ("动态型", "动静适中", "静态型")),
"gene_style_scores": gene_scores,
"hair_status": {
"length": _pick(body, 8, ("短", "中", "长")),
"curl": _pick(body, 9, ("直", "微卷", "大卷")),
},
"hair_color": {"hex": hair_hex, "tone": "冷" if hue < 0 else "暖"},
}
def build_vision_provider(settings: Settings) -> VisionProvider:
"""按配置装配视觉提供者。未知名直接 CONFIG_ERROR——静默回退到默认实现
会让"配置了真实模型却跑着 mock"这种事故无人察觉。"""
name = settings.report_vision_provider
if name == "deterministic":
return DeterministicVisionProvider()
raise AppError(
ErrorCode.CONFIG_ERROR,
message="未知的报告视觉提供者",
data={"report_vision_provider": name},
)