"""视觉分析提供者:接口位 + 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 _q_num(questionnaire: Mapping[str, Any], *keys: str) -> float | None: """多键取值:FE 发 height_cm/weight_kg,文档口径 height/weight——都认。 FE(ReportCreatePage)用带单位的键名,本函数按传入顺序取第一个可解析 数值,联调两侧谁改键名都不炸。 """ for key in keys: value = _num(questionnaire.get(key)) if value is not None: return value return None def _bmi(questionnaire: Mapping[str, Any]) -> float | None: height = _q_num(questionnaire, "height_cm", "height") weight = _q_num(questionnaire, "weight_kg", "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}, )