Files
meida_backend/packages/report/vision.py
T
xsl cb8a1e3c0f
CI / lint / typecheck / import-linter / tests / secrets (push) Canceled after 0s
fix(report): FE 问卷键名双侧兼容 + worker 持久事件循环(live E2E 两处修复)
FE↔后端联调实测暴露并修复:
1. 问卷键名三处错位(FE height_cm/weight_kg/psych_style vs 文档
   height/weight/psyche_style)——vision._bmi 多键取值、diagnosis 维度
   装配与规则上下文双键或运算,两侧谁改键名都不再炸
2. celery worker asyncio.run 每投递起新 loop + 模块级引擎缓存 →
   asyncpg 连接跨 loop(同进程第 3 个报告任务必现 RuntimeError:
   attached to a different loop;集成测试单循环掩盖)。新增
   apps/worker/loop.py 持久 loop,report/ping 两任务接入

E2E 终态: psyche_style/height_weight 恢复、BMI 体型确定值、ok 26/32、
真实 LLM 文案通过校验 degraded=False。
2026-08-15 01:05:10 +08:00

318 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""视觉分析提供者:接口位 + 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},
)