Selaa lähdekoodia

feat(ai): 情绪识别接入 DeepFace (LangGraph + 后端接口)

- cfc-langgraph: 新增 DeepFace 情绪识别 graph(7类基础情绪),注册 POST /api/v1/emotion/recognize
- cfc-backend: 新增 EmotionRecognitionController(文件上传/URL 两种方式)
- cfc-backend: AiGateway 新增 analyzeEmotion / analyzeEmotionByUrl 方法
- docs: 新增舌诊情绪识别技术选型报告,更新 API_REFERENCE.md
E2E Test Bot 1 viikko sitten
vanhempi
sitoutus
77dcb8d008

+ 81 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/mind/EmotionRecognitionController.java

@@ -0,0 +1,81 @@
+package com.etotem.cfc.controller.mind;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.service.AiGateway;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestAttribute;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.multipart.MultipartFile;
+
+import javax.annotation.Resource;
+import java.util.Base64;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * 情绪识别接口 — 基于 DeepFace 的人脸情绪分析
+ * 前端上传照片 → 后端转 base64 → 调用 LangGraph /api/v1/emotion/recognize
+ */
+@Slf4j
+@RestController
+@RequestMapping("/api/mind/emotion")
+public class EmotionRecognitionController {
+
+    @Resource
+    private AiGateway aiGateway;
+
+    /**
+     * 照片情绪识别(文件上传方式)
+     * 返回: { emotions: [{emotion, confidence}], dominant_emotion, dominant_label_zh, all_emotions }
+     */
+    @PostMapping("/analyze")
+    public Result<Map<String, Object>> analyze(
+            @RequestAttribute("userId") Long userId,
+            @RequestParam("file") MultipartFile file) {
+        if (file == null || file.isEmpty()) {
+            return Result.error("图片不能为空");
+        }
+        if (file.getSize() > 10 * 1024 * 1024) {
+            return Result.error("图片过大,请上传10MB以内的图片");
+        }
+
+        try {
+            String base64 = Base64.getEncoder().encodeToString(file.getBytes());
+            Map<String, Object> result = aiGateway.analyzeEmotion(base64);
+            if (result == null) {
+                return Result.error(500, "情绪识别服务暂不可用,请稍后重试");
+            }
+            return Result.success(result);
+        } catch (Exception e) {
+            log.error("情绪识别失败: userId={}, error={}", userId, e.getMessage());
+            return Result.error("情绪识别失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 照片情绪识别(URL 方式,兼容已有调用方)
+     * 请求体: { "image_url": "https://..." }
+     */
+    @PostMapping("/analyze-url")
+    public Result<Map<String, Object>> analyzeByUrl(
+            @RequestAttribute("userId") Long userId,
+            @org.springframework.web.bind.annotation.RequestBody Map<String, Object> params) {
+        String imageUrl = params.get("image_url") != null ? params.get("image_url").toString() : "";
+        if (imageUrl.isEmpty()) {
+            return Result.error("image_url 不能为空");
+        }
+        try {
+            Map<String, Object> result = aiGateway.analyzeEmotionByUrl(imageUrl);
+            if (result == null) {
+                return Result.error(500, "情绪识别服务暂不可用,请稍后重试");
+            }
+            return Result.success(result);
+        } catch (Exception e) {
+            log.error("情绪识别失败(URL): userId={}, error={}", userId, e.getMessage());
+            return Result.error("情绪识别失败: " + e.getMessage());
+        }
+    }
+}

+ 63 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/AiGateway.java

@@ -448,6 +448,69 @@ public class AiGateway {
         }
     }
 
+    /**
+     * 情绪识别(base64 图片 → LangGraph DeepFace 分析)
+     * @return 含 emotions / dominant_emotion / dominant_label_zh / all_emotions 的 Map;失败返回 null
+     */
+    public Map<String, Object> analyzeEmotion(String imageBase64) {
+        if (!enabled || isCircuitOpen()) return null;
+        try {
+            ObjectNode body = objectMapper.createObjectNode();
+            body.put("image_base64", imageBase64);
+
+            HttpEntity<String> entity = new HttpEntity<>(body.toString(), createJsonHeaders());
+            String url = baseUrl + "/api/v1/emotion/recognize";
+
+            ResponseEntity<String> response = restTemplate.postForEntity(url, entity, String.class);
+            if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) {
+                JsonNode root = objectMapper.readTree(response.getBody());
+                consecutiveFailures.set(0);
+                Map<String, Object> result = new LinkedHashMap<>();
+                result.put("emotions", objectMapper.convertValue(root.get("emotions"), List.class));
+                result.put("dominant_emotion", root.has("dominant_emotion") ? root.get("dominant_emotion").asText() : "");
+                result.put("dominant_label_zh", root.has("dominant_label_zh") ? root.get("dominant_label_zh").asText() : "");
+                result.put("all_emotions", root.has("all_emotions") ? objectMapper.convertValue(root.get("all_emotions"), Map.class) : Collections.emptyMap());
+                return result;
+            }
+            return null;
+        } catch (Exception e) {
+            log.warn("AiGateway analyzeEmotion 调用失败: {}", e.getMessage());
+            recordFailure();
+            return null;
+        }
+    }
+
+    /**
+     * 情绪识别(图片 URL → LangGraph DeepFace 分析)
+     */
+    public Map<String, Object> analyzeEmotionByUrl(String imageUrl) {
+        if (!enabled || isCircuitOpen()) return null;
+        try {
+            ObjectNode body = objectMapper.createObjectNode();
+            body.put("image_url", imageUrl);
+
+            HttpEntity<String> entity = new HttpEntity<>(body.toString(), createJsonHeaders());
+            String url = baseUrl + "/api/v1/emotion/recognize";
+
+            ResponseEntity<String> response = restTemplate.postForEntity(url, entity, String.class);
+            if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) {
+                JsonNode root = objectMapper.readTree(response.getBody());
+                consecutiveFailures.set(0);
+                Map<String, Object> result = new LinkedHashMap<>();
+                result.put("emotions", objectMapper.convertValue(root.get("emotions"), List.class));
+                result.put("dominant_emotion", root.has("dominant_emotion") ? root.get("dominant_emotion").asText() : "");
+                result.put("dominant_label_zh", root.has("dominant_label_zh") ? root.get("dominant_label_zh").asText() : "");
+                result.put("all_emotions", root.has("all_emotions") ? objectMapper.convertValue(root.get("all_emotions"), Map.class) : Collections.emptyMap());
+                return result;
+            }
+            return null;
+        } catch (Exception e) {
+            log.warn("AiGateway analyzeEmotionByUrl 调用失败: {}", e.getMessage());
+            recordFailure();
+            return null;
+        }
+    }
+
     public Map<String, Object> transcribe(byte[] audioData, String filename) {
         if (!enabled || isCircuitOpen()) return null;
         try {

+ 4 - 0
cfc-langgraph/requirements.txt

@@ -13,3 +13,7 @@ PyPDF2>=3.0,<4.0
 faster-whisper==1.2.1
 av==18.1.0
 Pillow>=10.0,<11.0
+# 情绪识别
+deepface>=0.0.90
+opencv-python-headless>=4.8,<5.0
+numpy>=1.24,<2.0

+ 40 - 0
cfc-langgraph/src/app.py

@@ -9,6 +9,8 @@ from fastapi.responses import JSONResponse
 
 from .schemas.questionnaire import GenerateRequest, GenerateResponse
 from .graphs.questionnaire import get_questionnaire_graph
+from .schemas.emotion import EmotionRequest, EmotionResponse, EmotionItem
+from .graphs.emotion import get_emotion_graph
 
 router = APIRouter(prefix="/api/v1", tags=["questionnaire"])
 
@@ -47,3 +49,41 @@ app.include_router(router)
 @app.get("/health")
 def health():
     return {"status": "ok"}
+
+
+# ── 情绪识别 ─────────────────────────────────────────────────
+
+@router.post("/emotion/recognize")
+async def recognize_emotion(req: EmotionRequest):
+    graph = get_emotion_graph()
+    try:
+        result = graph.invoke({
+            "request": req.model_dump(),
+            "image_bytes": None,
+            "image_path": None,
+            "emotion_result": None,
+            "emotions": [],
+            "dominant_emotion": "neutral",
+            "dominant_label_zh": "平静",
+            "all_emotions": {},
+            "error": None,
+        })
+        if result.get("error"):
+            return JSONResponse(
+                status_code=400,
+                content={"error": result["error"]}
+            )
+        return EmotionResponse(
+            emotions=[
+                EmotionItem(emotion=e["emotion"], confidence=e["confidence"])
+                for e in result["emotions"]
+            ],
+            dominant_emotion=result["dominant_emotion"],
+            dominant_label_zh=result["dominant_label_zh"],
+            all_emotions=result["all_emotions"],
+        )
+    except Exception as e:
+        return JSONResponse(
+            status_code=500,
+            content={"error": f"graph 执行失败: {str(e)}"}
+        )

+ 1 - 0
cfc-langgraph/src/graphs/__init__.py

@@ -0,0 +1 @@
+from .emotion import get_emotion_graph, EMOTION_ZH  # noqa: F401

+ 172 - 0
cfc-langgraph/src/graphs/emotion.py

@@ -0,0 +1,172 @@
+"""
+情绪识别 LangGraph — DeepFace 人脸情绪分析
+
+流程:
+  START → detect_faces → analyze_emotion → END
+
+输出:7 类基础情绪概率 + 主导情绪 + 中文标签
+"""
+import base64
+import io
+import os
+from typing import TypedDict, Optional
+
+import requests
+from PIL import Image
+from langgraph.graph import StateGraph, START, END
+
+# DeepFace 延迟导入(未安装时 graceful fallback)
+try:
+    from deepface import DeepFace
+    DEEPFACE_AVAILABLE = True
+except ImportError:
+    DEEPFACE_AVAILABLE = False
+
+
+# ── 中文情绪映射 ──────────────────────────────────────────────
+EMOTION_ZH = {
+    "angry": "愤怒",
+    "disgust": "厌恶",
+    "fear": "恐惧",
+    "happy": "开心",
+    "sad": "悲伤",
+    "surprise": "惊讶",
+    "neutral": "平静",
+}
+
+
+# ── Graph State ───────────────────────────────────────────────
+class GraphState(TypedDict):
+    request: dict
+    image_bytes: Optional[bytes]       # 原始图片字节
+    image_path: Optional[str]          # 临时文件路径(供 DeepFace 使用)
+    emotion_result: Optional[dict]     # DeepFace analyze() 结果
+    emotions: list                     # 结构化输出
+    dominant_emotion: str
+    dominant_label_zh: str
+    all_emotions: dict                 # 7 类完整概率
+    error: Optional[str]
+
+
+# ── Nodes ─────────────────────────────────────────────────────
+
+def load_image(state: GraphState) -> GraphState:
+    """将 image_url 下载到内存 / 解码 base64"""
+    req = state["request"]
+    img_bytes = None
+
+    if req.get("image_url"):
+        try:
+            resp = requests.get(req["image_url"], timeout=10)
+            resp.raise_for_status()
+            img_bytes = resp.content
+        except Exception as e:
+            return {**state, "error": f"图片下载失败: {e}"}
+
+    elif req.get("image_base64"):
+        try:
+            img_bytes = base64.b64decode(req["image_base64"])
+        except Exception as e:
+            return {**state, "error": f"Base64 解码失败: {e}"}
+
+    else:
+        return {**state, "error": "image_url 或 image_base64 至少提供一项"}
+
+    if img_bytes:
+        state["image_bytes"] = img_bytes
+        # 写入临时文件供 DeepFace 使用
+        tmp_path = f"/tmp/emotion_{os.getpid()}.jpg"
+        with open(tmp_path, "wb") as f:
+            f.write(img_bytes)
+        state["image_path"] = tmp_path
+
+    return state
+
+
+def analyze_emotion(state: GraphState) -> GraphState:
+    """调用 DeepFace 进行情绪识别"""
+    if not DEEPFACE_AVAILABLE or not state.get("image_path"):
+        return {**state, "error": "DeepFace 未安装或图片未加载"}
+
+    try:
+        result = DeepFace.analyze(
+            img_path=state["image_path"],
+            actions=["emotion"],
+            detector_backend="mediapipe",   # 比 opencv 快 3x
+            enforce_detection=False,
+            silent=True,
+        )
+        # DeepFace.analyze 返回 list(可能多张人脸)
+        if isinstance(result, list) and len(result) > 0:
+            state["emotion_result"] = result[0]
+        else:
+            return {**state, "error": "未检测到人脸"}
+    except Exception as e:
+        return {**state, "error": f"DeepFace 分析失败: {e}"}
+
+    return state
+
+
+def format_result(state: GraphState) -> GraphState:
+    """将 DeepFace 结果格式化为结构化输出"""
+    if not state.get("emotion_result"):
+        return state
+
+    emo = state["emotion_result"]
+    # emo 格式:{'emotion': {'happy': 0.85, 'neutral': 0.10, ...}, ...}
+    raw_emotions = emo.get("emotion", {})
+
+    # 找出主导情绪
+    dominant = max(raw_emotions, key=raw_emotions.get) if raw_emotions else "neutral"
+    dominant_zh = EMOTION_ZH.get(dominant, dominant)
+
+    # 结构化列表
+    emotions = [
+        {"emotion": k, "confidence": round(v, 4)}
+        for k, v in sorted(raw_emotions.items(), key=lambda x: -x[1])
+    ]
+
+    state["dominant_emotion"] = dominant
+    state["dominant_label_zh"] = dominant_zh
+    state["all_emotions"] = {k: round(v, 4) for k, v in raw_emotions.items()}
+    state["emotions"] = emotions
+    return state
+
+
+def cleanup(state: GraphState) -> GraphState:
+    """清理临时文件"""
+    import os as _os
+    path = state.get("image_path")
+    if path and _os.path.exists(path):
+        try:
+            _os.remove(path)
+        except Exception:
+            pass
+    return state
+
+
+# ── Graph 构建 ────────────────────────────────────────────────
+
+def build_emotion_graph():
+    graph = StateGraph(GraphState)
+    graph.add_node("load_image", load_image)
+    graph.add_node("analyze_emotion", analyze_emotion)
+    graph.add_node("format_result", format_result)
+    graph.add_node("cleanup", cleanup)
+
+    graph.add_edge(START, "load_image")
+    graph.add_edge("load_image", "analyze_emotion")
+    graph.add_edge("analyze_emotion", "format_result")
+    graph.add_edge("format_result", "cleanup")
+    graph.add_edge("cleanup", END)
+    return graph.compile()
+
+
+_emotion_graph = None
+
+
+def get_emotion_graph():
+    global _emotion_graph
+    if _emotion_graph is None:
+        _emotion_graph = build_emotion_graph()
+    return _emotion_graph

+ 1 - 0
cfc-langgraph/src/schemas/__init__.py

@@ -0,0 +1 @@
+from .emotion import EmotionRequest, EmotionResponse, EmotionItem  # noqa: F401

+ 23 - 0
cfc-langgraph/src/schemas/emotion.py

@@ -0,0 +1,23 @@
+from pydantic import BaseModel, Field
+from typing import List, Optional
+
+
+class EmotionItem(BaseModel):
+    """单张人脸的情绪识别结果"""
+    emotion: str = Field(description="主导情绪,英文,7类之一")
+    confidence: float = Field(ge=0.0, le=1.0, description="置信度 0.0–1.0")
+
+
+class EmotionRequest(BaseModel):
+    """情绪识别请求(支持 URL 或 base64)"""
+    image_url: Optional[str] = Field(default=None, description="图片 URL(已上传到 OSS)")
+    image_base64: Optional[str] = Field(default=None, description="base64 编码的图片数据(jpg/png)")
+    member_id: Optional[int] = Field(default=None, description="家庭成员 ID,用于记录关联")
+
+
+class EmotionResponse(BaseModel):
+    """情绪识别响应"""
+    emotions: List[EmotionItem]
+    dominant_emotion: str = Field(description="主导情绪(英文)")
+    dominant_label_zh: str = Field(description="主导情绪中文名")
+    all_emotions: dict = Field(description="全部 7 类情绪概率")

+ 57 - 1
docs/superpowers/api/API_REFERENCE.md

@@ -571,6 +571,9 @@ find cfc-backend/src/main/java -name "*XxxService.java" -o -name "*XxxController
 | `POST /api/mind/checkin/trend` | 情绪趋势 |
 | `POST /api/mind/checkin/stats` | 情绪统计 |
 | `POST /api/mind/checkin/weekly-report` | 周报 |
+| `POST /api/mind/checkin/emotion/recognize` | ~~情绪识别(URL方式,走Dify)~~ — 已废弃 |
+| `POST /api/mind/emotion/analyze` | 照片情绪识别(文件上传,走 LangGraph DeepFace) |
+| `POST /api/mind/emotion/analyze-url` | 照片情绪识别(URL方式,走 LangGraph DeepFace) |
 | `POST /api/health-status/get` | 健康现状档案获取 |
 | `POST /api/health-status/save` | 健康现状档案保存 |
 
@@ -1054,4 +1057,57 @@ find cfc-backend/src/main/java -name "*XxxService.java" -o -name "*XxxController
 
 ---
 
-*文档最后更新:2026-09-02*
+### 4.37 情绪识别(`/api/mind/emotion/*`)
+
+基于 DeepFace 的人脸情绪识别,通过 LangGraph `/api/v1/emotion/recognize` 调用。
+
+| 路径 | 说明 |
+|------|------|
+| `POST /api/mind/emotion/analyze` | 上传照片进行情绪识别(multipart 文件) |
+| `POST /api/mind/emotion/analyze-url` | 通过 URL 进行情绪识别(兼容旧调用方) |
+
+**POST `/api/mind/emotion/analyze`**
+
+请求:multipart/form-data,字段 `file`(JPEG/PNG,最大 10MB)
+
+返回:
+```json
+{
+  "code": 200,
+  "data": {
+    "dominant_emotion": "happy",
+    "dominant_label_zh": "开心",
+    "emotions": [
+      { "emotion": "happy", "confidence": 0.85 },
+      { "emotion": "neutral", "confidence": 0.10 },
+      { "emotion": "sad", "confidence": 0.03 },
+      { "emotion": "surprise", "confidence": 0.01 },
+      { "emotion": "angry", "confidence": 0.005 },
+      { "emotion": "fear", "confidence": 0.003 },
+      { "emotion": "disgust", "confidence": 0.002 }
+    ],
+    "all_emotions": {
+      "happy": 0.85,
+      "neutral": 0.10,
+      "sad": 0.03,
+      "surprise": 0.01,
+      "angry": 0.005,
+      "fear": 0.003,
+      "disgust": 0.002
+    }
+  }
+}
+```
+
+**POST `/api/mind/emotion/analyze-url`**
+
+请求体:
+```json
+{ "image_url": "https://cdn.example.com/photo.jpg" }
+```
+
+返回:同上。
+
+---
+
+*文档最后更新:2026-09-09*

+ 245 - 0
docs/舌诊情绪识别_技术选型.md

@@ -0,0 +1,245 @@
+# 舌诊 & 情绪识别 — 技术选型报告
+
+> 基于项目 `cfc-langgraph`(FastAPI + LangGraph + ChromaDB,端口 9000)+ `cfc-backend`(Spring Boot)现有架构,面向微信小程序用户场景。
+
+---
+
+## 一、舌诊(Tongue Diagnosis)
+
+### 1.1 推荐方案对比
+
+| 维度 | A. TongueDiagnosis.AI(开箱即用) | B. TOM 分割 + ResNet50(自训) | C. MMIR-TCM + Qwen3-VL(MLLM) |
+|------|--------------------------------|-------------------------------|-------------------------------|
+| 核心模型 | YOLOv5 + SAM + ResNet50 + Deepseek | SAM 微调(TOM)+ U2Net-MT 分割 + ViT 分类 | Memory-SAM + Qwen3-VL fine-tune |
+| 输出维度 | 4 维:舌色/苔色/苔厚/苔腻 | 20 类病理标签(TMC-Tongue)+ 11 类舌象特征 | 结构化诊断报告 + 处方建议 |
+| 模型总大小 | ~1.2 GB(含 SAM + ResNet50 + YOLO) | ~200 MB(U2Net-MT + ViT) | ~28 GB(Qwen3-VL 需 GPU) |
+| 推理延迟(GPU) | 0.8–2.0s/张 | 0.3–0.8s/张 | 3–8s/张(含 LLM 生成) |
+| 推理延迟(CPU) | 4–8s/张(SAM 推理慢) | 1–2s/张 | 不可行(模型太大) |
+| 准确率 | 舌色分类 ~86%,苔色 ~84% | 舌色 ROC-AUC 0.89–0.99,裂纹 0.97 | 超 GPT-4o / Gemini 2.5 Flash |
+| 许可 | AGPL-3.0 ⚠️ | Apache 2.0 / MIT | MIT |
+| 部署复杂度 | 中(需管理多个模型) | 中 | 高(需 LLM 服务 + RAG) |
+| 集成难度到 LangGraph | 低(已有 pipeline 脚本) | 中(需适配输入输出) | 高(需 fine-tune Qwen3-VL) |
+| 适用阶段 | 快速 MVP 验证 | 中长期自研 | 长期终极方案 |
+
+### 1.2 推荐选型结论
+
+**短期 MVP(2–4 周)**:方案 A(TongueDiagnosis.AI)
+- 模型已在 GitHub 提供预训练权重,可直接下载
+- 流水线清晰:`YOLOv5 定位 → SAM 分割 → ResNet50 分类 → LLM 诊断`
+- 注意:AGPL-3.0 商用需评估;或自行替换 SAM 为 U2Net-MT(论文开源)
+- 集成方式:将 Python 推理脚本封装为 LangGraph 节点
+
+**中期(3–6 月)**:方案 B(TOM 分割 + 自训分类器)
+- 分割用 TOM(论文 arxiv 2508.14932,已部署于 itongue.cn)
+- 数据用 TMC-Tongue(6719 张,20 类,含 COCO/XML/YOLO 标注)
+- 分类用 ResNet50 或 ViT,在 TMC-Tongue 上 fine-tune
+- 授权干净,无 GPL 限制
+
+**长期**:方案 C(MMIR-TCM)
+- 需 Qwen3-VL(多模态 LLM)+ RAG(已有 ChromaDB)
+- 当前论文代码未完全开源(Coming Soon),可关注
+
+### 1.3 关键模型性能参数
+
+| 模型 | 参数量 | 模型文件大小 | 推理延迟(GPU T4) | 推理延迟(CPU) |
+|------|--------|------------|-------------------|----------------|
+| YOLOv5s(定位) | 7.2M | 14 MB | 8 ms | 25 ms |
+| SAM ViT-B(分割) | 353M | 605 MB | 150 ms | 2–4 s |
+| U2Net(分割) | 17.8M | 35 MB | 45 ms | 200 ms |
+| TOM 学生模型(分割) | ~1.5M | 6 MB | 15 ms | 60 ms |
+| ResNet50(分类) | 25.6M | 98 MB | 25 ms | 120 ms |
+| ViT-Base(分类) | 86.5M | 334 MB | 40 ms | 300 ms |
+| MobileNetV3-Small(轻量分类) | 2.5M | 10 MB | 12 ms | 50 ms |
+
+### 1.4 推荐架构(LangGraph 集成)
+
+```
+[小程序上传舌象]
+      ↓
+[cfc-backend POST /api/tongue/upload]
+      ↓
+[AiGateway 路由]
+      ↓
+[LangGraph TongueDiagnosis Graph]
+  ├── Node 1: ImagePreprocessor (颜色校正 + 尺寸归一化)
+  ├── Node 2: TongueSegmenter (U2Net-MT,GPU T4 约 50ms)
+  ├── Node 3: CoatingSeparator (Gated-SCNN 分离苔/体,约 30ms)
+  ├── Node 4: FeatureClassifier (ResNet50 多分类,约 25ms)
+  └── Node 5: HealthReportGenerator (Deepseek LLM + ChromaDB RAG)
+      ↓
+[返回 JSON: 舌色/苔色/体质倾向/健康建议]
+```
+
+**预计单次推理总耗时**:50–200ms(分割+分类)+ LLM 生成 2–5s = **~5s 总延迟**
+
+---
+
+## 二、情绪识别(Emotion Recognition)
+
+### 2.1 推荐方案对比
+
+| 维度 | A. DeepFace(服务端) | B. TFLite MobileNetV3(端侧) | C. 百度/阿里 API(商业) | D. MediaPipe + SVM(轻量端侧) |
+|------|----------------------|-------------------------------|------------------------|------------------------------|
+| 识别类别 | 7 类(angry/fear/happy/sad/surprise/disgust/neutral) | 7–8 类 | 7 类(百度) | 7 类 |
+| 模型大小 | ~10 MB(FER2013 CNN) | 2–10 MB(TFLite) | 无需本地模型 | ~3 MB |
+| 推理延迟(CPU) | 30–80 ms/张 | 15–50 ms/张(手机) | 网络延迟 ~500ms–2s | 20–40 ms/张 |
+| 推理延迟(GPU) | 5–15 ms/张 | N/A | 网络延迟为主 | N/A |
+| 准确率 | ~80%(FER2013 测试集) | 65–75%(优化后 MobileNetV3) | 80–85% | ~70% |
+| 部署方式 | Docker / Gunicorn / Flask | TFLite 嵌入小程序(需插件) | HTTP 调用 | TFLite / MediaPipe JS |
+| 隐私 | 图片需上传服务端 | 纯端侧,图片不出设备 ✅ | 图片上传第三方 | 纯端侧 ✅ |
+| 开发成本 | 低(pip install deepface) | 中(需训练 + 转换 + 集成) | 最低(调 API) | 低 |
+| 许可 | MIT ✅ | Apache 2.0 / MIT ✅ | 需注册开发者账号 | Apache 2.0 ✅ |
+| 小程序支持 | ❌(需服务端转发) | ⚠️(需专用 AI 插件) | ⚠️(需服务端转发) | ✅(wx.createWorker + MediaPipe) |
+
+### 2.2 推荐选型结论
+
+**方案 B 最适配本项目的微信小程序场景**(隐私友好 + 端侧推理),但考虑到:
+1. 小程序端 TFLite 集成需要 `wx.createTensorFlowLiteModel` 或第三方插件,文档有限
+2. DeepFace 集成到 LangGraph 最快
+
+**建议分阶段**:
+
+#### 阶段 1(MVP):DeepFace 服务端方案
+- 图片上传到 `cfc-langgraph` 服务 → DeepFace `analyze()` → 返回情绪
+- 隐私风险可接受(小程序用户主动上传或拍脸),符合现有 LangGraph 架构
+- 1 天内可跑通
+
+#### 阶段 2(可选):端侧 TFLite
+- MobileNetV3-Small 1.0x 量化后 ~2–3 MB,Moto G6 手机端 45ms
+- 小程序通过 `@tensorflow/tfjs-wechat` 或自定义原生插件集成
+- 若用户量大,端侧推理可省带宽和服务端成本
+
+### 2.3 关键模型性能参数
+
+| 模型 | 参数 | FP32 延迟 | INT8 量化延迟 | 模型大小(FP32) | 模型大小(INT8) |
+|------|------|----------|-------------|----------------|----------------|
+| DeepFace FER CNN | 0.7M | — | — | ~10 MB | ~3 MB |
+| MobileNetV3-Small 1.0x | 2.5M | 15.8ms | 15.5ms | 10 MB | 2.5 MB |
+| MobileNetV3-Small 0.75x | 2.0M | 12.8ms | 12.2ms | 8 MB | 2 MB |
+| MobileNetV3-Large 1.0x | 5.4M | 51.2ms | 44ms | 35 MB | 9 MB |
+| MobileNetV3-Small(优化后) | 2.5M | 12.8ms | **5.6ms** | 10 MB | **2.5 MB** |
+
+### 2.4 DeepFace 集成代码示例
+
+```python
+# cfc-langgraph/nodes/emotion_node.py
+from deepface import DeepFace
+
+def analyze_emotion(image_path: str) -> dict:
+    result = DeepFace.analyze(
+        img_path=image_path,
+        actions=['emotion'],
+        detector_backend='mediapipe',  # 比 opencv 快 3x
+        enforce_detection=False
+    )
+    # result = [{'emotion': {'angry': 0.02, 'disgust': 0.01, 'fear': 0.03,
+    #                 'happy': 0.85, 'sad': 0.05, 'surprise': 0.01, 'neutral': 0.03}}]
+    return result[0]['emotion']
+```
+
+### 2.5 推荐架构(LangGraph 集成)
+
+```
+[小程序拍摄/上传人脸照片]
+      ↓
+[cfc-backend POST /api/emotion/analyze]
+      ↓
+[AiGateway 路由]
+      ↓
+[LangGraph EmotionGraph]
+  ├── Node 1: ImagePreprocessor (缩放 + 归一化)
+  └── Node 2: EmotionClassifier (DeepFace, mediapipe 后端, ~30ms)
+      ↓
+[返回 JSON: 主导情绪 + 各情绪概率]
+      ↓
+[可选: Node 3 → 情绪日记记录 / Node 4 → LLM 建议生成]
+```
+
+---
+
+## 三、商业 API 参考报价
+
+### 3.1 百度 AI — 情绪识别
+
+| 接口 | 免费额度 | 单价 |
+|------|---------|------|
+| 人脸属性分析(含情绪) | 企业认证 2QPS/1万次/月 | 按次计费,见文档 |
+| 对话情绪识别(文本) | 50万次/天 | 2.5 元/千次 |
+| QPS 扩充 | 10 QPS 起购 | 270 元/月/QPS |
+
+### 3.2 腾讯 AI — 人脸识别(含情绪)
+
+| 接口 | 免费额度 | 单价 |
+|------|---------|------|
+| 人脸检测与属性分析(含表情) | 25万次/月 | 2.5 元/千次 |
+| 人脸检测 + 属性(表情) | 10万次/月 | 2.5 元/千次 |
+
+### 3.3 阿里云 — 人脸人体
+
+| 接口 | 免费额度 | 单价 |
+|------|---------|------|
+| 人脸检测 + 属性分析(含表情) | 1万次/月 | 2.5 元/千次 |
+
+> 注:商业 API 价格可能变动,以官方最新为准。
+
+---
+
+## 四、总体架构决策矩阵
+
+| 需求 | 舌诊 | 情绪识别 |
+|------|------|---------|
+| **推荐方案** | A. TongueDiagnosis.AI(MVP)→ B. 自训(中长期) | A. DeepFace 服务端(MVP)→ B. TFLite 端侧(可选) |
+| **核心模型** | SAM/U2Net-MT + ResNet50/ViT | DeepFace FER CNN(7类情绪) |
+| **推理设备** | 服务端 GPU(T4 或同档) | DeepFace: 服务端 CPU/GPU;TFLite: 手机端 |
+| **延迟预算** | < 5s(含 LLM 报告生成) | < 100ms(服务端)/ < 50ms(端侧) |
+| **模型大小预算** | < 500 MB(服务端存储可接受) | < 15 MB(服务端)/ < 5 MB(端侧) |
+| **隐私风险** | 中等(舌象不含敏感生物特征) | 高(人脸照片,需用户明示同意) |
+| **合规要点** | 定位为"健康参考",非医疗诊断 | 需用户明示授权,遵守个保法,未成年人数据保护 |
+| **集成到 LangGraph** | 已有 Python 脚本,直接封装为节点 | DeepFace 有 pip 包,封装为节点 |
+| **预估开发周期** | 2–4 周(含模型调优) | 1 周(DeepFace)/ 4–8 周(TFLite 端侧) |
+
+---
+
+## 五、实施路径建议
+
+```
+Phase 1(2 周): 情绪识别 MVP
+  ├── 接入 DeepFace 到 cfc-langgraph
+  ├── 定义 /api/emotion/analyze 接口
+  ├── 小程序前端拍摄 → 调用接口 → 展示情绪结果
+  └── 用户授权 + 隐私协议
+
+Phase 2(4 周): 舌诊 MVP
+  ├── 下载 TongueDiagnosis.AI 预训练模型
+  ├── 封装为 LangGraph 节点
+  ├── 定义 /api/tongue/diagnose 接口
+  ├── 小程序前端拍照 → 上传 → 等待 → 展示报告
+  └── 结合现有 RAG(ChromaDB)生成中医体质建议
+
+Phase 3(可选): 端侧优化
+  ├── 情绪识别 TFLite 化
+  ├── 舌诊分割模型替换为 TOM(减小程序端推理体积)
+  └── 减少服务端依赖,提升隐私保护
+```
+
+---
+
+## 六、参考资源
+
+### 舌诊
+- TongueDiagnosis.AI: https://github.com/TonguePicture-SKaRD/TongueDiagnosis
+- TOM 分割: arxiv 2508.14932,工具 https://itongue.cn/
+- TMC-Tongue 数据集: https://datadryad.org/dataset/doi:10.5061/dryad.1c59zw48r
+- MMIR-TCM: https://github.com/jw-chae/MMIR-TCM
+
+### 情绪识别
+- DeepFace: https://github.com/serengil/deepface
+- FER2013 数据集: https://www.kaggle.com/datasets/msambare/fer2013
+- FER+ (微软重标注): https://github.com/kobiso/EMO-RECOGNITION-FER2013
+- LiteFer(轻量 SOTA): https://doi.org/10.3390/s24185868
+- 百度对话情绪识别 API: https://cloud.baidu.com/doc/NLP/s/rk6z52hlz
+
+### 部署优化
+- TFLite 量化: https://deepsense.ai/resource/from-pytorch-to-android/
+- TensorRT + ONNX Runtime: https://onnxruntime.ai/docs/execution-providers/TensorRT-ExecutionProvider.html
+- LangGraph 部署: https://docs.langchain.com/oss/python/langgraph/deploy