emotion.py 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177
  1. """
  2. 情绪识别 LangGraph — DeepFace 人脸情绪分析
  3. 流程:
  4. START → detect_faces → analyze_emotion → END
  5. 输出:7 类基础情绪概率 + 主导情绪 + 中文标签
  6. """
  7. import base64
  8. import io
  9. import os
  10. from typing import TypedDict, Optional
  11. import requests
  12. from PIL import Image
  13. from langgraph.graph import StateGraph, START, END
  14. # DeepFace 延迟导入(未安装时 graceful fallback)
  15. try:
  16. from deepface import DeepFace
  17. DEEPFACE_AVAILABLE = True
  18. except ImportError:
  19. DEEPFACE_AVAILABLE = False
  20. # ── 中文情绪映射 ──────────────────────────────────────────────
  21. EMOTION_ZH = {
  22. "angry": "愤怒",
  23. "disgust": "厌恶",
  24. "fear": "恐惧",
  25. "happy": "开心",
  26. "sad": "悲伤",
  27. "surprise": "惊讶",
  28. "neutral": "平静",
  29. }
  30. # ── Graph State ───────────────────────────────────────────────
  31. class GraphState(TypedDict):
  32. request: dict
  33. image_bytes: Optional[bytes] # 原始图片字节
  34. image_path: Optional[str] # 临时文件路径(供 DeepFace 使用)
  35. emotion_result: Optional[dict] # DeepFace analyze() 结果
  36. emotions: list # 结构化输出
  37. dominant_emotion: str
  38. dominant_label_zh: str
  39. all_emotions: dict # 7 类完整概率
  40. error: Optional[str]
  41. # ── Nodes ─────────────────────────────────────────────────────
  42. def load_image(state: GraphState) -> GraphState:
  43. """将 image_url 下载到内存 / 解码 base64"""
  44. req = state["request"]
  45. img_bytes = None
  46. if req.get("image_url"):
  47. try:
  48. resp = requests.get(req["image_url"], timeout=10)
  49. resp.raise_for_status()
  50. img_bytes = resp.content
  51. except Exception as e:
  52. return {**state, "error": f"图片下载失败: {e}"}
  53. elif req.get("image_base64"):
  54. try:
  55. img_bytes = base64.b64decode(req["image_base64"])
  56. except Exception as e:
  57. return {**state, "error": f"Base64 解码失败: {e}"}
  58. else:
  59. return {**state, "error": "image_url 或 image_base64 至少提供一项"}
  60. if img_bytes:
  61. state["image_bytes"] = img_bytes
  62. # 写入临时文件供 DeepFace 使用
  63. tmp_path = f"/tmp/emotion_{os.getpid()}.jpg"
  64. with open(tmp_path, "wb") as f:
  65. f.write(img_bytes)
  66. state["image_path"] = tmp_path
  67. return state
  68. def analyze_emotion(state: GraphState) -> GraphState:
  69. """调用 DeepFace 进行情绪识别"""
  70. if not DEEPFACE_AVAILABLE or not state.get("image_path"):
  71. return {**state, "error": "DeepFace 未安装或图片未加载"}
  72. try:
  73. result = DeepFace.analyze(
  74. img_path=state["image_path"],
  75. actions=["emotion"],
  76. detector_backend="opencv", # 内置 Haar Cascade,无需额外模型
  77. enforce_detection=False,
  78. silent=True,
  79. )
  80. # DeepFace.analyze 返回 list(可能多张人脸)
  81. if isinstance(result, list) and len(result) > 0:
  82. state["emotion_result"] = result[0]
  83. else:
  84. return {**state, "error": "未检测到人脸"}
  85. except Exception as e:
  86. return {**state, "error": f"DeepFace 分析失败: {e}"}
  87. return state
  88. def format_result(state: GraphState) -> GraphState:
  89. """将 DeepFace 结果格式化为结构化输出"""
  90. if not state.get("emotion_result"):
  91. return state
  92. emo = state["emotion_result"]
  93. # DeepFace 返回 0-100 百分比(如 {'happy': 85.0, ...}),需归一化为 0.0-1.0
  94. raw_emotions = emo.get("emotion", {})
  95. # 归一化:值 >1 视为百分比,除以 100
  96. norm = {}
  97. for k, v in raw_emotions.items():
  98. norm[k] = round(v / 100.0, 4) if v > 1.0 else round(v, 4)
  99. # 找出主导情绪
  100. dominant = max(norm, key=norm.get) if norm else "neutral"
  101. dominant_zh = EMOTION_ZH.get(dominant, dominant)
  102. # 结构化列表
  103. emotions = [
  104. {"emotion": k, "confidence": v}
  105. for k, v in sorted(norm.items(), key=lambda x: -x[1])
  106. ]
  107. state["dominant_emotion"] = dominant
  108. state["dominant_label_zh"] = dominant_zh
  109. state["all_emotions"] = norm
  110. state["emotions"] = emotions
  111. return state
  112. def cleanup(state: GraphState) -> GraphState:
  113. """清理临时文件"""
  114. import os as _os
  115. path = state.get("image_path")
  116. if path and _os.path.exists(path):
  117. try:
  118. _os.remove(path)
  119. except Exception:
  120. pass
  121. return state
  122. # ── Graph 构建 ────────────────────────────────────────────────
  123. def build_emotion_graph():
  124. graph = StateGraph(GraphState)
  125. graph.add_node("load_image", load_image)
  126. graph.add_node("analyze_emotion", analyze_emotion)
  127. graph.add_node("format_result", format_result)
  128. graph.add_node("cleanup", cleanup)
  129. graph.add_edge(START, "load_image")
  130. graph.add_edge("load_image", "analyze_emotion")
  131. graph.add_edge("analyze_emotion", "format_result")
  132. graph.add_edge("format_result", "cleanup")
  133. graph.add_edge("cleanup", END)
  134. return graph.compile()
  135. _emotion_graph = None
  136. def get_emotion_graph():
  137. global _emotion_graph
  138. if _emotion_graph is None:
  139. _emotion_graph = build_emotion_graph()
  140. return _emotion_graph