Files
DPM/backend/app/routers.py
T
Pine 41e9681aa0 feat(vision): YOLO 人脸/姿态识别与手势控制(后端推理)
- vision_yolo:yolov8n-face 人脸检测 + yolov8n-pose 姿态估计(torch/ultralytics,懒加载常驻)
- 单帧几何特征:举手(腕高过肩)与举拳(前臂上举收胸前),返回 raised/fists
- POST /api/vision/frame:前端抽帧 JPEG → 推理 → {faces, pose, raised, fists, latency_ms},线程池执行
- POST /api/vision/event:状态上报,triggered 时经 MQTT 广播 alert
- 模型入库:backend/models/yolov8n-face.pt + yolov8n-pose.pt
- 前端 hook 改为网络版:抽帧→POST→状态机(面向10s问候/举手toggle对话),含亮度/耗时/链路诊断
2026-08-18 01:37:53 +08:00

429 lines
15 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.
# -*- coding: utf-8 -*-
"""全部 REST 路由 —— 数据 / 媒体 / 播放列表 / 设置 / AI / 展示控制"""
import asyncio
import json
import logging
import time
from pathlib import Path
from fastapi import APIRouter, Request, UploadFile, File
from fastapi.responses import JSONResponse, StreamingResponse
from pydantic import BaseModel, Field
from .ai_tools import run_chat, run_tools
from .asr import transcribe as asr_transcribe
from .config import settings
from .event_bus import bus
from .llm import run_chat as llm_run_chat
from .mqtt import hub
from .sim_engine import sim_engine
from .storage import storage
log = logging.getLogger("dpm.api")
router = APIRouter()
ALLOWED_EXT = {".mp4", ".mkv", ".avi", ".jpg", ".jpeg", ".png"}
def _media_type(path):
lower = path.lower()
if lower.endswith((".mp4", ".mkv", ".avi")):
return "video"
return "image"
# ==================== 数据模型 ====================
class SettingsBody(BaseModel):
volume: int | None = None
play_mode: str | None = None
image_duration: int | None = None
fullscreen: bool | None = None
autostart: bool | None = None
username: str | None = None
password: str | None = None
class ActionBody(BaseModel):
action: str
class PathBody(BaseModel):
path: str
class UrlBody(BaseModel):
url: str
class StatePayload(BaseModel):
status: str = ""
index: int = 0
name: str = ""
media_type: str = ""
class StateBody(BaseModel):
state: StatePayload
class DisplayCommandBody(BaseModel):
action: str
params: dict = Field(default_factory=dict)
class ChatMessage(BaseModel):
role: str
content: str
class ChatBody(BaseModel):
messages: list[ChatMessage]
# ==================== 认证 / 设置 ====================
@router.post("/api/login")
async def login(request: Request):
form = await request.form()
s = storage.get_settings()
return JSONResponse({"success": form.get("username") == s["username"] and form.get("password") == s["password"]})
@router.get("/api/settings")
async def get_settings():
s = storage.get_settings()
return {k: s[k] for k in ("volume", "play_mode", "image_duration", "fullscreen", "autostart")}
@router.post("/api/settings")
async def update_settings(body: SettingsBody):
storage.update_settings(**body.model_dump(exclude_none=True))
s = storage.get_settings()
hub.publish_command("settings_changed", {"volume": s["volume"], "play_mode": s["play_mode"]})
return {"ok": True}
# ==================== 媒体 ====================
def _list_media():
files = []
media_dir = Path(settings.MEDIA_DIR)
if media_dir.exists():
for p in sorted(media_dir.iterdir()):
if not p.is_file() or p.suffix.lower() not in ALLOWED_EXT:
continue
files.append({
"name": p.name, "relative_path": p.name,
"type": _media_type(p.name), "url": f"/file/{p.name}", "source": "local",
})
for item in storage.get_url_media():
files.append({
"name": item.get("name", item.get("url", "")),
"relative_path": item.get("url", ""),
"type": item.get("type", "image"),
"url": item.get("url", ""),
"source": "url",
})
return {"files": files}
@router.get("/media")
async def list_media():
return _list_media()
@router.post("/upload")
async def upload(file: UploadFile = File(...)):
name = file.filename or "file"
ext = Path(name).suffix.lower()
if ext not in ALLOWED_EXT:
return JSONResponse({"ok": False, "error": "不支持的文件类型"}, status_code=400)
settings.MEDIA_DIR.mkdir(parents=True, exist_ok=True)
stem = Path(name).stem
new_name = f"{stem}_{time.strftime('%Y%m%d%H%M%S')}{ext}"
save_path = settings.MEDIA_DIR / new_name
with save_path.open("wb") as f:
while True:
chunk = await file.read(1024 * 1024)
if not chunk:
break
f.write(chunk)
hub.publish_command("playlist_changed")
return {"ok": True, "name": new_name}
@router.post("/api/delete")
async def delete_media(body: PathBody):
path = body.path
if not path.startswith(("http://", "https://")):
target = settings.MEDIA_DIR / path
try:
if target.exists() and target.is_file():
target.unlink()
except OSError:
pass
storage.delete_media(path)
hub.publish_command("playlist_changed")
return {"ok": True}
@router.post("/api/media/add-url")
async def add_url(body: UrlBody):
url = body.url.strip()
if not url:
return {"ok": False}
name = url.rsplit("/", 1)[-1].split("?", 1)[0] or url
t = _media_type(url)
is_new = storage.add_url_media(url, name, t)
return {"ok": True, "duplicate": None if is_new else True}
# ==================== 播放列表 ====================
def _playlist_files():
files = []
media_dir = Path(settings.MEDIA_DIR)
for item in storage.get_playlist():
path = item.get("path", "")
if item.get("source") == "url":
files.append({
"name": item.get("name") or path,
"relative_path": path, "type": _media_type(path), "url": path, "source": "url",
})
else:
fp = media_dir / path
if fp.exists():
files.append({
"name": fp.name, "relative_path": path,
"type": _media_type(path), "url": f"/file/{path}", "source": "local",
})
s = storage.get_settings()
return {"files": files, "volume": s["volume"], "play_mode": s["play_mode"], "image_duration": s["image_duration"]}
@router.get("/api/playlist")
async def get_playlist():
return _playlist_files()
@router.post("/api/playlist/add")
async def add_playlist(body: PathBody):
storage.add_to_playlist(body.path)
hub.publish_command("playlist_changed")
return {"ok": True}
@router.post("/api/playlist/remove")
async def remove_playlist(body: PathBody):
storage.remove_from_playlist(body.path)
hub.publish_command("playlist_changed")
return {"ok": True}
@router.post("/api/playlist/play")
async def play_playlist_item(body: PathBody):
"""指定媒体立即播放:加入播放列表(如未在)→ MQTT 广播 play_target → 大屏跳转播放"""
path = body.path
storage.add_to_playlist(path)
hub.publish_command("playlist_changed")
cmd = hub.publish_command("play_target", {"path": path})
return {"ok": True, "cmd_id": cmd.get("cmd_id")}
# ==================== 播放控制 / 状态 ====================
_CONTROL_ACTIONS = {"play", "pause", "next", "prev"}
@router.post("/api/control")
async def control(body: ActionBody):
if body.action not in _CONTROL_ACTIONS:
return JSONResponse({"ok": False, "error": "action 必须是 play/pause/next/prev"}, status_code=400)
hub.publish_command(body.action)
return {"ok": True, "action": body.action}
@router.get("/api/state")
async def get_state():
return {"state": {"status": "unknown", "index": 0, "name": "", "media_type": ""}}
@router.post("/api/state")
async def update_state(body: StateBody):
hub.publish_command("state_update", body.state.model_dump())
return {"ok": True}
@router.post("/api/display-command")
async def display_command(body: ActionBody):
if body.action == "minimize":
hub.publish_command("minimize")
return {"ok": True, "action": "minimize"}
return JSONResponse({"ok": False, "error": "不支持的显示命令"}, status_code=400)
# ==================== 数据(大屏全部数据来自后端) ====================
@router.get("/api/dashboard/snapshot")
async def dashboard_snapshot():
return sim_engine.snapshot()
@router.get("/api/dashboard/overview")
async def dashboard_overview():
return sim_engine.snapshot()
# ==================== 园区企业 ====================
_COMPANIES = [
{"name": "云南派音人工智能科技", "zone": "加速区", "room": "A1", "color": "#4c8dff"},
{"name": "米勒克尔蓝宝石珠宝", "zone": "加速区", "room": "A2", "color": "#4c8dff"},
{"name": "中泰研学合作", "zone": "加速区", "room": "A3", "color": "#4c8dff"},
{"name": "云南宸中低空经济", "zone": "加速区", "room": "A4", "color": "#4c8dff"},
{"name": "昆明智海银高文化科技", "zone": "加速区", "room": "A5", "color": "#4c8dff"},
{"name": "AI机器人大模型训练", "zone": "加速区", "room": "A6", "color": "#4c8dff"},
{"name": "云南廷秀文旅康养", "zone": "加速区", "room": "A7", "color": "#4c8dff"},
{"name": "瀚颖AI+教育信息咨询", "zone": "加速区", "room": "A8", "color": "#4c8dff"},
{"name": "云南大学AI+创业平台", "zone": "加速区", "room": "A9", "color": "#4c8dff"},
{"name": "仰光客厅", "zone": "国际区", "room": "I1", "color": "#22d3ee"},
{"name": "云南上古绝学文化", "zone": "国际区", "room": "I2", "color": "#22d3ee"},
{"name": "中越生物医疗", "zone": "国际区", "room": "I3", "color": "#22d3ee"},
{"name": "酷享野农AI农业", "zone": "国际区", "room": "I4", "color": "#22d3ee"},
{"name": "滇缅国际设计", "zone": "国际区", "room": "I5", "color": "#22d3ee"},
{"name": "昆明舒诺生物科技", "zone": "国际区", "room": "I6", "color": "#22d3ee"},
{"name": "达岸教育管理", "zone": "国际区", "room": "I7", "color": "#22d3ee"},
{"name": "Facebook越南跨境电商", "zone": "成长区", "room": "G1", "color": "#34d399"},
{"name": "研X同行者网络", "zone": "成长区", "room": "G2", "color": "#34d399"},
{"name": "朵哈·玫瑰特色产业链", "zone": "成长区", "room": "G3", "color": "#34d399"},
{"name": "昆明云韵体育", "zone": "成长区", "room": "G4", "color": "#34d399"},
{"name": "南菌优培食用菌", "zone": "成长区", "room": "G5", "color": "#34d399"},
{"name": "五华区丽裳文化", "zone": "成长区", "room": "G6", "color": "#34d399"},
{"name": "云南星瑞航空", "zone": "成长区", "room": "G7", "color": "#34d399"},
{"name": "综合直播私域平台", "zone": "成长区", "room": "G8", "color": "#34d399"},
{"name": "园区管理办公室", "zone": "园区管理", "room": "M", "color": "#fbbf24"},
]
@router.get("/api/park/companies")
async def park_companies():
return {"companies": _COMPANIES}
@router.get("/api/park/zones")
async def park_zones():
zones = ["加速区", "国际区", "成长区"]
counts = {z: sum(1 for c in _COMPANIES if c["zone"] == z) for z in zones}
colors = {"加速区": "#4c8dff", "国际区": "#22d3ee", "成长区": "#34d399"}
return {"zones": [{"name": z, "count": counts[z], "color": colors[z]} for z in zones]}
# ==================== AI 对话(通义千问 + 工具调用) ====================
@router.post("/api/ai/chat")
async def ai_chat(body: ChatBody):
"""大模型对话:工具调用(切页/控制/卡片/通知)经 MQTT 广播到所有大屏
返回 {reply, tools, model}tools 供请求端本地同步执行"""
messages = [m.model_dump() for m in body.messages]
result = llm_run_chat(messages)
return result
@router.post("/api/ai/asr")
async def ai_asr(file: UploadFile = File(...), format: str = "m4a"):
"""语音识别:上传录音 → 阿里云 paraformer 转写为文本"""
data = await file.read()
if not data:
return JSONResponse({"ok": False, "error": "空音频"}, status_code=400)
try:
text = asr_transcribe(data, fmt=format)
return {"ok": True, "text": text}
except Exception as e: # noqa: BLE001
log.warning("ASR 转写失败: %s", e)
return JSONResponse({"ok": False, "error": str(e)}, status_code=502)
# ==================== 展示控制(管理端 → MQTT ====================
_VALID_DISPLAY_ACTIONS = {"navigate", "navigate_rel", "play", "pause", "next", "prev",
"alert", "show_card", "set_mode", "minimize"}
@router.post("/api/display/command")
async def display_command_publish(body: DisplayCommandBody):
"""管理端/任意客户端通过 REST 发控制指令 → 后端转 MQTT 广播给所有大屏"""
if body.action not in _VALID_DISPLAY_ACTIONS:
return JSONResponse({"ok": False, "error": f"不支持的指令: {body.action}"}, status_code=400)
cmd = hub.publish_command(body.action, body.params)
return {"ok": cmd.get("published", False), "cmd_id": cmd["cmd_id"], "action": body.action,
"mqtt_connected": hub.connected}
@router.get("/api/display/state")
async def display_state():
return hub.status()
# ==================== SSE 兼容通道(MQTT 不可用时前端回退) ====================
@router.get("/api/events")
async def sse_events(request: Request):
q = bus.subscribe()
async def gen():
try:
while True:
if await request.is_disconnected():
break
try:
data = await asyncio.wait_for(q.get(), timeout=15)
yield f"data: {data}\n\n"
except asyncio.TimeoutError:
yield ": keepalive\n\n"
finally:
bus.unsubscribe(q)
return StreamingResponse(gen(), media_type="text/event-stream")
# ==================== 视觉识别事件上报(前端摄像头实时识别 → 后端日志/MQTT ====================
class VisionEventBody(BaseModel):
event: str # camera_on|camera_off|detecting|facing|triggered|silent|error|model_error
faces: int = 0
dwell_ms: int = 0
detail: str = ""
@router.post("/api/vision/event")
async def vision_event(body: VisionEventBody):
"""前端摄像头识别状态/触发上报;后端记录详细日志,triggered 时广播 alert 到全屏。"""
log.info(
"vision event=%s faces=%d dwell_ms=%d detail=%s",
body.event, body.faces, body.dwell_ms, body.detail,
)
if body.event == "triggered":
hub.publish_command("alert", {"text": "有访客正对屏幕,语音助手已主动问候", "faces": body.faces})
log.info("vision triggered -> alert 已广播(faces=%d", body.faces)
return {"ok": True}
# ==================== YOLO 人脸检测(前端抽帧 → 后端推理) ====================
class VisionFrameBody(BaseModel):
image: str = "" # JPEG base64(不含 data: 前缀)
conf: float = 0.0 # 可选:覆盖置信度阈值
@router.post("/api/vision/frame")
def vision_frame(body: VisionFrameBody):
"""接收前端抽帧 JPEG base64,后端 YOLO 推理返回人脸框(faces/boxes/latency_ms)。
普通 def 由 FastAPI 线程池执行(推理约 100-200ms),不阻塞事件循环。
"""
img_b64 = (body.image or "").strip()
if not img_b64:
log.warning("vision frame: 缺少 image")
return {"ok": False, "error": "missing image"}
from .vision_yolo import predict_base64
return predict_base64(img_b64)