From 0af159a175324a604acbdf3d7aabc0310994a093 Mon Sep 17 00:00:00 2001 From: Pine Date: Tue, 18 Aug 2026 05:48:56 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=95=B0=E6=8D=AE=E7=9C=9F=E5=AE=9E?= =?UTF-8?q?=E5=8C=96=E3=80=81=E4=BC=81=E4=B8=9A=E5=B1=95=E7=A4=BA=E5=A2=99?= =?UTF-8?q?=E3=80=81=E5=85=A8=E5=B1=80=E6=89=8B=E5=8A=BF=E8=AF=86=E5=88=AB?= =?UTF-8?q?=E4=B8=8E=203D=20=E6=A8=A1=E5=9E=8B=E5=AE=8C=E5=96=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 数据大屏改用真实数据(xls 统计表 + 宣传册口径);TOKEN/工具调用基于真实锚点测算 - 新增企业展示墙页(/wall,39 家全量档案 + MQTT/admin/ESP32 控制文档) - 新增全局手势识别(GlobalVision 全局挂载 + dpm:gesture 事件分发) - 3D 模型墙面逐间修复、去除门/过道顶棚/调试标签、房间资产不穿墙、会议室多小桌 - 侧栏企业分布扩展至 39 家完整信息,分区按官方四大孵化区域 - 语音页多轮对话展示优化、去除页脚与检测横条、背景对齐 --- backend/app/sim_engine.py | 290 +++++++++------ backend/app/vision_yolo.py | 87 ++++- backend/templates/admin.html | 15 + docs/esp32-ir-control.md | 24 ++ docs/mqtt-commands.md | 20 +- src/App.jsx | 3 + src/components/Building3D.jsx | 113 ++++-- src/components/GlobalVision.jsx | 51 +++ src/components/PageHeader.jsx | 2 +- src/components/ParkOverviewStrip.jsx | 18 +- src/components/ParkSidePanel.jsx | 178 +++++---- src/components/ScreenLayout.jsx | 4 +- src/pages/CompanyWall.jsx | 166 +++++++++ src/pages/DataScreen.jsx | 265 ++++++-------- src/pages/VoiceAssistant.jsx | 46 +-- src/pages/voice-overrides.css | 81 ++++- src/styles/datascreen.css | 526 ++++++++++++++++++++++++++- src/utils/pageNav.js | 1 + src/utils/parkData.js | 274 +++++++++----- src/utils/useMqttControl.js | 10 + src/voice/useVisionDetection.js | 104 ++++-- 21 files changed, 1690 insertions(+), 588 deletions(-) create mode 100644 src/components/GlobalVision.jsx create mode 100644 src/pages/CompanyWall.jsx diff --git a/backend/app/sim_engine.py b/backend/app/sim_engine.py index 6825556..9c76ef9 100644 --- a/backend/app/sim_engine.py +++ b/backend/app/sim_engine.py @@ -1,138 +1,224 @@ # -*- coding: utf-8 -*- -"""园区数据模拟引擎 —— 由前端 parkData.js 移植,后端统一产生数据 - 前端通过 GET /api/dashboard/snapshot 获取,或订阅 MQTT opc/dashboard/tick +"""园区数据引擎(后端)—— 真实数据 + 测算演示 + 数据来源(用户指定口径): + · 《昆明市大学生创业园运行情况统计表(2026年7月).xls》(官方月报) + · 《昆明市创业园宣传册.pdf》 + 测算口径(用户确认): + · TOKEN:真实业务锚点月均 200 亿 tokens → 日均 ≈ 6.67 亿(200亿/30), + 实时速率 ≈ 7,700 t/s(日均/86400s),近30日累计 = 200 亿 + · 工具调用:按平均每次调用消耗约 2 万 tokens 折算(今日 ≈ 3.3 万次) + 其余统计指标为真实静态值;TOKEN/工具仅做轻微演示波动 """ import random import threading import time -MODEL_NAMES = ["DeepSeek-V3", "通义千问", "智谱 GLM-4", "豆包", "讯飞星火"] -TOOL_NAMES = ["文档生成", "数据查询", "图像创作", "代码执行", "语音合成"] -COMPANY_NAMES = [ - "云南派音人工智能科技", "米勒克尔蓝宝石珠宝", "中泰研学合作", "云南宸中低空经济", - "昆明智海银高文化科技", "云南廷秀文旅康养", "瀚颖AI+教育信息咨询", - "仰光客厅", "云南上古绝学文化", "中越生物医疗", "酷享野农AI农业", - "滇缅国际设计", "昆明舒诺生物科技", "达岸教育管理", "花仙子园艺肥料", - "研X同行者网络", "鬼才明AI创意工作室", "朵哈·玫瑰特色产业链", "昆明云韵体育", - "南菌优培食用菌", "五华区丽裳文化", "云南星瑞航空", "综合直播私域平台", - "昆明屿澈电商", "蓝智科技", "云品出滇·纸享万家", "启元人工智能科技", +# ---------- 真实锚点(仅来自 xls 统计表 + 宣传册) ---------- +REAL = { + "as_of": "2026-07", + "token_monthly_yi": 200, # 月均消耗 200 亿 tokens(真实业务锚点) + "token_daily_wan": 66666.7, # 日均 6.67 亿 tokens(200亿/30 ≈ 66666.7 万) + "token_rate": 7700, # 实时 t/s(6.67e8 / 86400 ≈ 7,716) + "tools_per_call_tokens": 150000, # 平均每次工具调用消耗 tokens(真实测算:12.5M输入 ÷ 81步 ≈ 15.4万) + "occupied": 39, # 园区实际入驻企业数(信息表名单 39 家,统一口径) + "capacity": 49, # 园区可容纳企业个数(统计表) + "invested": 560, # 开园以来累计投入运营资金(万元,统计表) + "jobs": 213, # 入驻企业累计带动就业人数(统计表) + "revenue_total": 252.15, # 入驻企业累计生产经营总额(万元,统计表) + "revenue_tax": 1.03, # 入驻企业累计上缴税利总额(万元,统计表) + "area": 3000, # 园区建筑面积(㎡,统计表+宣传册) + "founded": 2009, # 成立年份(宣传册) + "province_level": 2012, # 获评省级创业园年份(宣传册) + "address": "昆明市民航路229号(人力资源中心1-2楼)", + "phone": "18087174891", + "email": "714066050@qq.com", +} + +# 入驻企业名录(《入驻企业信息表》39 家全称,与在园数统一口径) +COMPANIES = [ + "云南派音人工智能科技有限公司", "米勒克尔蓝宝石珠宝产业化(项目)", "中泰研学合作(项目)", + "云南宸中低空经济有限公司", "昆明智海银高文化科技有限公司", "人工智能机器人大模型训练(项目)", + "云南廷秀文旅康养服务(项目)", "瀚颖AI+教育信息咨询(项目)", "云南大学AI+联合创业服务平台(项目)", + "仰光客厅(项目)", "云南上古绝学文化发展有限公司", "千楣之路(项目)", + "中越生物医疗(项目)", "中越文旅交流(项目)", "绿态环保科技项目", + "联翼航空技术低空经济应用国际化拓展(项目)", "酷享野农AI农业(项目)", "滇缅国际设计(项目)", + "昆明舒诺生物科技有限责任公司", "达岸教育管理(云南)有限公司", "昆明滇油石化有限责任公司", + "花仙子园艺肥料(云南)有限公司", "Facebook越南市场普洱茶与建水紫陶壶跨境电商(项目)", + "研X——同行者网络(项目)", "鬼才明AI创意工作室(项目)", "朵哈·玫瑰特色产业链(项目)", + "昆明云韵体育有限责任公司", "昆明舒护安养老服务有限责任公司", + "南菌优培—食用菌专用生物制剂创新与高原特色野生菌高值化全链开发(项目)", + "五华区丽裳文化艺术工作室", "云南星瑞航空(项目)", "综合性云南直播私域服务平台", + "昆明屿澈电子商务有限公司", "中外青少年研学(项目)", "星杭商务信息咨询昆明有限责任公司", + "蓝智科技(项目)", "云品出滇·纸享万家(项目)", + "云迹轻旅—昆明本土一站式轻量化文旅服务工作室(项目)", "启元人工智能科技(项目)", +] + +# 宣传册四大孵化区域 +ZONES = [ + {"name": "OPC创业空间", "desc": "AI 技术支撑 · 数字经济/AI应用/软件开发/文创设计/电商直播等轻资产领域", "period": "拎包入驻 · 最长2年"}, + {"name": "创业苗圃区", "desc": "创意阶段 · 未注册企业团队 · 开放式共享工位", "period": "最长2年"}, + {"name": "孵化加速区", "desc": "已工商注册初创企业 · 独立办公空间", "period": "基础2年 · 可延1年(≤3年)"}, + {"name": "国际创客区", "desc": "归国留学生 · 外籍留学生 · 国际化配套服务", "period": "参照加速区"}, +] + +# 园区企业行业分布(按《入驻企业信息表》39 家企业简介分类统计) +INDUSTRY_MIX = [ + {"name": "人工智能", "value": 7}, + {"name": "跨境电商", "value": 5}, + {"name": "教育培训", "value": 4}, + {"name": "文旅康养", "value": 4}, + {"name": "文化创意", "value": 4}, + {"name": "生物医药", "value": 4}, + {"name": "低空经济", "value": 3}, + {"name": "现代农业", "value": 3}, + {"name": "绿色环保", "value": 2}, + {"name": "企业服务", "value": 2}, + {"name": "新材料", "value": 1}, +] + +# 园区实时动态(真实事件:园区官方公示 + 宣传册活动 + 互联网公开信息,最新在前) +FEED = [ + {"id": 24, "icon": "dna", "text": "园内企业「昆明舒诺生物科技」完成GEO服务体系建设(生成式引擎优化,聚焦宠物营养品牌数字化)", "time": "2026-08"}, + {"id": 1, "icon": "activity", "text": "2026年7月运行情况统计表填报完成:累计投入运营资金560万元、带动就业213人", "time": "2026-07-31"}, + {"id": 2, "icon": "radio", "text": "2026年第三期入驻创业企业(项目)评审结果在昆明市人社局官网公示", "time": "2026-07-02"}, + {"id": 3, "icon": "graduation", "text": "云南师范大学就业创业实践基地落地园区 · 云南旅游职业学院开展就业创业交流", "time": "2026-06-03"}, + {"id": 23, "icon": "radio", "text": "「PineSound」完成 ICP 备案(滇ICP备2026008517号)与公安备案,注册地址昆明市民航路301-307号", "time": "2026"}, + {"id": 4, "icon": "rocket", "text": "\"滇水逐梦·雨林创航\"昆明·西双版纳创业路演PK赛成功举办", "time": "2026-05-20"}, + {"id": 5, "icon": "flag", "text": "昆明市青年企业家协会创新创业基地在园区揭牌", "time": "2026-05-13"}, + {"id": 6, "icon": "users", "text": "\"聚力赋能·共创未来\"高校创新创业交流暨项目路演活动圆满举行", "time": "2026-05-08"}, + {"id": 7, "icon": "trophy", "text": "\"创赢未来\"2026创业大赛昆明选拔赛暨马兰花创业培训讲师大赛举行(13个项目参赛)", "time": "2026-04-22"}, + {"id": 8, "icon": "broadcast", "text": "2026年第二期创业企业(项目)入驻招募公告发布(官网 www.kmyc.gov.cn)", "time": "2026-04-15"}, + {"id": 21, "icon": "sparkles", "text": "「PineSound」AI驱动音频管理平台上线 pinesound.cn:智能配乐与音效生成,集成100W+音效库、50W+配乐库,全球版权授权(官网)", "time": "2026"}, + {"id": 22, "icon": "medal", "text": "「PineSound」发布企业标准 Q/YNPY 001-2026《数字内容创作 配乐通用分类标准》", "time": "2026"}, + {"id": 9, "icon": "award", "text": "2026年第一期入驻评审结果在昆明市人社局官网公示(23个优质项目正式入驻)", "time": "2026-03-27"}, + {"id": 10, "icon": "users", "text": "2026年第一期招募集中评审完成:38个申请,23个优质项目正式入驻", "time": "2026-03-24"}, + {"id": 19, "icon": "sparkles", "text": "园内企业「云南派音人工智能科技(PineSound)」专注多模态音频技术研发:自研Pine系列模型覆盖音频识别、向量嵌入、音效生成、配乐创作", "time": "2026-04"}, + {"id": 20, "icon": "bolt", "text": "「PineSound」2026年4月注册成立,获北京投资支持并吸纳就业4人,入驻加速区(入驻企业信息表)", "time": "2026-04"}, + {"id": 11, "icon": "cpu", "text": "云南首个人工智能OPC创新人才基地落地昆明,填补省内个体AI创业培育空白(公开报道)", "time": "2026-03-23"}, + {"id": 12, "icon": "medal", "text": "第九届\"春城创业荟\"创业创新大赛圆满闭幕,获奖项目名单公布", "time": "2025-09-30"}, + {"id": 13, "icon": "trophy", "text": "\"春城创业荟\"初赛落幕,147个项目晋级复赛", "time": "2025-04-28"}, + {"id": 14, "icon": "building", "text": "园内企业「花仙子园艺肥料(云南)」成立,注册资本118万元(公开报道)", "time": "2025-04-28"}, + {"id": 15, "icon": "radio", "text": "2025年入园项目评审结果公示(昆明市人社局官网)", "time": "2025-04-22"}, + {"id": 16, "icon": "atom", "text": "研X平台入选\"创客中国\"项目库(一站式研究生学术成长平台)", "time": "2025-01"}, + {"id": 17, "icon": "leaf", "text": "行业动态:园内「朵哈·玫瑰」原料产地昆明八街食用玫瑰行情上涨,玫瑰经济走强", "time": "2025-01"}, + {"id": 18, "icon": "heart", "text": "行业动态:「千楣之路」主营檀娜卡——泰国天然护肤品牌Thanaka正式进入中国市场", "time": "2025-01"}, ] -def _pick(arr): - return arr[random.randrange(len(arr))] +def _day_secs(): + """当日零点起已过秒数(时钟驱动,保证数值单向持续增长、重启连续)""" + now = time.time() + lt = time.localtime(now) + day_start = time.mktime((lt.tm_year, lt.tm_mon, lt.tm_mday, 0, 0, 0, 0, 0, -1)) + return max(0.0, now - day_start) -def _now_time(): - return time.strftime("%H:%M:%S") +def _today_values(prev_noise=None): + """按真实速率推算当日累计值(单向增长 + 工具调用波动): + 平台实时速率 = 单会话 157 tok/s × 49 路并发 ≈ 7,700 t/s + 今日 TOKEN(万)= 秒数 × 7,700 / 10000 = 秒数 × 0.77 + 今日工具调用 = 今日 TOKEN / 15万 tokens 每次(真实基线) + + 随机游走噪声(±10,波浪式波动,不单调) + """ + secs = _day_secs() + today_wan = round(secs * 0.77) # 万 tokens + base_tools = secs * 0.77 * 10000 / REAL["tools_per_call_tokens"] # 真实基线(浮点) + if prev_noise is None: + noise = 0.0 + else: + noise = max(-10.0, min(10.0, prev_noise + (random.random() - 0.5) * 6.0)) + today_tools = max(0, round(base_tools + noise)) # 次 + return today_wan, today_tools, noise -def make_series(base, growth, noise, n=30): +def _make_series(base, noise, n=30): + """生成围绕日均锚点的演示曲线(单位:万 tokens),尾点=今日实时值""" out = [] - v = base - for _ in range(n): - v = v * (1 + growth) + (random.random() - 0.5) * noise - out.append(round(max(1, v * 100) / 100, 2)) + for _ in range(n - 1): + v = base + (random.random() - 0.5) * noise + out.append(round(max(1, v), 1)) + out.append(round(base, 1)) return out -def gen_event(): +def _live_values(prev=None): + """TOKEN 面板实时运行指标(围绕真实基准动态波动,会话持续推进)""" r = random.random - pool = [ - {"icon": "bolt", "text": f"AI 推理任务完成 · 消耗 {round(800 + r() * 9000):,} tokens({_pick(MODEL_NAMES)})"}, - {"icon": "wrench", "text": f"工具「{_pick(TOOL_NAMES)}」被调用 {round(10 + r() * 90)} 次"}, - {"icon": "building", "text": f"「{_pick(COMPANY_NAMES)}」提交入驻申请 · 进入评审流程"}, - {"icon": "users", "text": f"「{_pick(COMPANY_NAMES)}」新增招聘岗位 {round(1 + r() * 5)} 个"}, - {"icon": "coin", "text": f"园区企业完成一笔 ¥{(0.5 + r() * 9):.1f}万 交易"}, - {"icon": "robot", "text": f"「{_pick(MODEL_NAMES)}」模型完成一次微调任务"}, - ] - e = _pick(pool) - return {"id": f"{int(time.time()*1000)}-{random.random()}", "icon": e["icon"], "text": e["text"], "time": _now_time()} - - -def init_feed(): - return [ - {"id": 1, "icon": "bolt", "text": "云南派音AI 完成音频向量嵌入任务 · 消耗 12,480 tokens", "time": _now_time()}, - {"id": 2, "icon": "building", "text": "「启元人工智能科技」通过评审 · 正式入驻 OPC 创业空间", "time": _now_time()}, - {"id": 3, "icon": "users", "text": "「昆明舒护安养老服务」新增招聘岗位 2 个", "time": _now_time()}, - {"id": 4, "icon": "coin", "text": "园区企业完成一笔 ¥3.6万 交易", "time": _now_time()}, - ] + steps = (prev or {}).get("steps", 81) + steps += 1 if r() < 0.3 else 0 + return { + "firstToken": round(1.3 + (r() - 0.5) * 0.2, 2), # 首 token 平均 1.3s 基准 ±0.1 + "throughput": round(157 + (r() - 0.5) * 14), # 吞吐 157 tok/s 基准 ±7 + "cacheHit": round(98 + (r() - 0.5) * 1.2, 1), # 缓存命中 98% 基准 ±0.6 + "inputM": round((prev or {}).get("inputM", 12.5) + 0.004 + r() * 0.008, 2), # 会话输入持续推进 + "outputM": round((prev or {}).get("outputM", 2.0) + 0.001 + r() * 0.002, 2), # 会话输出持续推进 + "rounds": max(2, steps // 40), # 每 40 步一轮 + "steps": steps, # 步数持续增长 + } def init_snapshot(): + today_wan, today_tools, noise = _today_values() return { - "t": 0, - "token": {"today": 128.64, "total": 12840, "rate": 84.6, "series": make_series(82, 0.012, 9)}, - "tools": {"today": 3568, "total": 365204, "success": 98.7}, - "projects": {"inPark": 158, "cum": 208, "todayNew": 2}, - "jobs": {"total": 2186, "todayNew": 3}, - "revenue": {"today": 38.6, "total": 20800, "growth": 8.2, "series": make_series(30, 0.006, 4)}, - "park": {"devices": 98.6, "energy": 386, "people": 127, "desk": 76, "meeting": 3, "nodes": 12}, - "feed": init_feed(), + "as_of": REAL["as_of"], + "_toolsNoise": noise, + "live": _live_values(), + "token": { + "today": today_wan, # 万 tokens(当日累计,实时增长) + "total": REAL["token_monthly_yi"] * 10000 + today_wan, # 万 tokens(近30日 = 200亿 + 今日增量) + "rate": REAL["token_rate"], # t/s(157 tok/s × 49 并发 ≈ 7,700) + "series": _make_series(REAL["token_daily_wan"], REAL["token_daily_wan"] * 0.16), + }, + "tools": { + "today": today_tools, # 次(随 TOKEN 实时联动) + "total": round(REAL["token_monthly_yi"] * 100000000 / REAL["tools_per_call_tokens"]) + today_tools, # 次 + "success": 98.5, # 成功率(演示) + "nodes": 12, # AI 服务节点(演示) + }, + "projects": { + "inPark": REAL["occupied"], # 实际入驻 39 家 + "capacity": REAL["capacity"], # 可容纳 49 个 + "invested": REAL["invested"], # 累计投入 560 万元 + }, + "jobs": {"total": REAL["jobs"]}, # 带动就业 213 人 + "revenue": {"total": REAL["revenue_total"], "tax": REAL["revenue_tax"]}, # 万元 + "park": { + "area": REAL["area"], + "founded": REAL["founded"], + "provinceLevel": REAL["province_level"], + "address": REAL["address"], + "phone": REAL["phone"], + "email": REAL["email"], + }, + "zones": ZONES, + "companies": COMPANIES, + "industryMix": INDUSTRY_MIX, + "feed": FEED, } def next_snapshot(s): - r = random.random - t_delta = round(0.26 + r() * 0.34, 2) - + """时钟驱动:按真实速率重算当日累计(TOKEN 单向增长,工具调用波浪波动),其余真实静态""" + today_wan, today_tools, noise = _today_values(s.get("_toolsNoise")) token = { - "today": round(s["token"]["today"] + t_delta, 2), - "total": round(s["token"]["total"] + t_delta, 2), - "rate": round(76 + r() * 20, 1), + "today": today_wan, + "total": REAL["token_monthly_yi"] * 10000 + today_wan, + "rate": REAL["token_rate"], + "series": s["token"]["series"][:-1] + [today_wan], } - tool_delta = round(13 + r() * 22) tools = { - "today": s["tools"]["today"] + tool_delta, - "total": s["tools"]["total"] + tool_delta, - "success": round(98.1 + r() * 1.2, 1), - } - rev_delta = round(0.6 + r() * 1.7, 1) - revenue = { - "today": round(s["revenue"]["today"] + rev_delta, 1), - "total": round(s["revenue"]["total"] + rev_delta, 1), - "growth": round(7.2 + r() * 2.2, 1), - } - park = { - "devices": round(97.6 + r() * 1.6, 1), - "energy": round(320 + r() * 130), - "people": round(80 + r() * 95), - "desk": round(62 + r() * 24), - "meeting": round(2 + r() * 4), + "today": today_tools, + "total": round(REAL["token_monthly_yi"] * 100000000 / REAL["tools_per_call_tokens"]) + today_tools, + "success": 98.5, "nodes": 12, } - - projects = dict(s["projects"]) - if r() < 0.055: - projects["todayNew"] += 1 - if r() < 0.035: - projects["inPark"] += 1 - projects["cum"] += 1 - jobs = dict(s["jobs"]) - if r() < 0.08: - jobs["todayNew"] += 1 - if r() < 0.05: - jobs["total"] += 1 - - t_series = list(s["token"]["series"]) - t_series[-1] = round(t_series[-1] + t_delta, 2) - if s["t"] % 12 == 11: - t_series = t_series[1:] + [round(72 + r() * 30, 2)] - token["series"] = t_series - - r_series = list(s["revenue"]["series"]) - r_series[-1] = round(r_series[-1] + rev_delta, 1) - if s["t"] % 12 == 11: - r_series = r_series[1:] + [round(26 + r() * 9, 1)] - revenue["series"] = r_series - - feed = [gen_event()] + s["feed"][:6] if s["t"] % 3 == 2 else s["feed"] - return { - "t": s["t"] + 1, - "token": token, "tools": tools, "revenue": revenue, - "park": park, "projects": projects, "jobs": jobs, "feed": feed, + **s, + "_toolsNoise": noise, + "live": _live_values(s.get("live")), + "token": token, + "tools": tools, } diff --git a/backend/app/vision_yolo.py b/backend/app/vision_yolo.py index e7de6ca..51cec0b 100644 --- a/backend/app/vision_yolo.py +++ b/backend/app/vision_yolo.py @@ -34,12 +34,17 @@ KP_LEFT_ELBOW = 7 KP_RIGHT_ELBOW = 8 KP_LEFT_WRIST = 9 KP_RIGHT_WRIST = 10 -RAISE_LIFT = 0.12 # 举手:手腕高于对应肩 12% 画面高 +RAISE_LIFT = 0.06 # 举手:手腕高于对应肩 6% 画面高(降低阈值,更易稳定激活) FIST_ELBOW_LIFT = 0.05 # 举拳:手腕高于肘 5% 画面高(前臂上举) FIST_SHOULDER_GAP = 0.15 # 举拳:拳不高于肩 15% 画面高(收在胸前) FIST_CHEST_DIST = 0.25 # 举拳:拳与肩水平距离 < 25% 画面宽(贴近躯干) KP_CONF_MIN = 0.3 # 关键点置信度过滤 +# ── 扩展手势几何阈值(基于 COCO 17 关键点) ── +POINT_REACH_X = 0.30 # 指向:手腕水平伸出距肩 ≥30% 画面宽(手臂向前/侧伸) +POINT_Y_RANGE = 0.28 # 指向:手腕与肩同高 ±28% 画面高(排除高举/下垂) +HANDS_CLOSE_DIST = 0.18 # 双手合十/靠近:双腕欧氏距离 <18% 画面宽 + _face_model = None _pose_model = None @@ -64,15 +69,22 @@ def get_pose_model(): return _pose_model -def _parse_pose(results): - """解析姿态结果 → persons(17 关键点归一化 [x,y,conf])+ raised(举手)+ fists(举拳)""" +def _parse_pose(results, W, H): + """解析姿态结果 → persons + raised(举手) + fists(举拳) + + both_up(双臂举起) + pointing(指向) + hands_close(双手合十) + + hands(每手几何摘要,供前端挥手/手势跟随时序判定) + 注意:ultralytics keypoints.data 为【原图像素坐标】, + 此处统一归一化为 [0,1](x/W, y/H)后再判定阈值与输出 + """ persons = [] raised = [] fists = [] + pointing = [] + hands = [] for r in results: if r.keypoints is None: continue - kps = r.keypoints.data # [N,17,3] 归一化坐标 + kps = r.keypoints.data # [N,17,3] 像素坐标 for i in range(kps.shape[0]): kp = kps[i] person = [ @@ -81,6 +93,7 @@ def _parse_pose(results): ] persons.append(person) # 每只手臂:举手(腕明显高于肩)或 举拳(前臂上举、拳收胸前),两者互斥 + arm = {} for side, wrist_i, elbow_i, shoulder_i in ( ("left", KP_LEFT_WRIST, KP_LEFT_ELBOW, KP_LEFT_SHOULDER), ("right", KP_RIGHT_WRIST, KP_RIGHT_ELBOW, KP_RIGHT_SHOULDER), @@ -90,22 +103,53 @@ def _parse_pose(results): s = kp[shoulder_i] if float(w[2]) < KP_CONF_MIN or float(s[2]) < KP_CONF_MIN or float(e[2]) < KP_CONF_MIN: continue - wx, wy = float(w[0]), float(w[1]) - sx, sy = float(s[0]), float(s[1]) - ey = float(e[1]) - if wy < sy - RAISE_LIFT: - raised.append({"side": side, "x": round(wx, 3), "y": round(wy, 3)}) - elif ( - wy < ey - FIST_ELBOW_LIFT # 前臂上举(腕高于肘) - and wy >= sy - FIST_SHOULDER_GAP # 拳不高过肩太多(收在胸前) - and abs(wx - sx) < FIST_CHEST_DIST # 拳贴近躯干中线 + # 像素 → 归一化 [0,1] + wx, wy = float(w[0]) / W, float(w[1]) / H + sx, sy = float(s[0]) / W, float(s[1]) / H + ey = float(e[1]) / H + is_raised = wy < sy - RAISE_LIFT + is_fist = ( + wy < ey - FIST_ELBOW_LIFT + and wy >= sy - FIST_SHOULDER_GAP + and abs(wx - sx) < FIST_CHEST_DIST + ) + if is_raised: + raised.append({"side": side, "x": round(wx, 4), "y": round(wy, 4)}) + elif is_fist: + fists.append({"side": side, "x": round(wx, 4), "y": round(wy, 4)}) + # 指向:手腕水平伸出距肩较远、且与肩同高区间(前伸/侧伸,排除高举与下垂) + if ( + abs(wx - sx) > POINT_REACH_X + and abs(wy - sy) < POINT_Y_RANGE ): - fists.append({"side": side, "x": round(wx, 3), "y": round(wy, 3)}) - return persons, raised, fists + pointing.append({"side": side, "x": round(wx, 4), "y": round(wy, 4)}) + arm[side] = { + "side": side, + "wx": round(wx, 4), "wy": round(wy, 4), + "sx": round(sx, 4), "sy": round(sy, 4), + "raised": is_raised, "fist": is_fist, + } + if arm: + hands.append(arm) + # 双臂举起:左、右腕都高于各自肩 + both_up = False + if len(hands) >= 1: + h0 = hands[0] + if "left" in h0 and "right" in h0: + both_up = h0["left"]["raised"] and h0["right"]["raised"] + # 双手合十/靠近:同一人的双腕欧氏距离 < 阈值 + hands_close = False + if len(hands) >= 1: + h0 = hands[0] + if "left" in h0 and "right" in h0: + dx = h0["left"]["wx"] - h0["right"]["wx"] + dy = h0["left"]["wy"] - h0["right"]["wy"] + hands_close = (dx * dx + dy * dy) ** 0.5 < HANDS_CLOSE_DIST + return persons, raised, fists, both_up, pointing, hands_close, hands def predict_jpeg(jpeg_bytes: bytes): - """人脸检测 + 姿态估计 → {faces, boxes, pose, raised, latency_ms}""" + """人脸检测 + 姿态估计 → {faces, boxes, pose, raised, fists, both_up, pointing, hands_close, hands, latency_ms}""" t0 = time.time() img = Image.open(io.BytesIO(jpeg_bytes)).convert("RGB") @@ -121,13 +165,14 @@ def predict_jpeg(jpeg_bytes: bytes): boxes.append({"box": xyxy, "conf": conf}) # 姿态 + W, H = img.size # 原图尺寸(关键点像素坐标 → 归一化基准) pose_res = get_pose_model().predict(img, conf=CONF_THRESHOLD, imgsz=IMGSZ, verbose=False, device="cpu") - persons, raised, fists = _parse_pose(pose_res) + persons, raised, fists, both_up, pointing, hands_close, hands = _parse_pose(pose_res, W, H) latency_ms = round((time.time() - t0) * 1000, 1) log.info( - "vision: 人脸 %d 姿态 %d 举手 %d 举拳 %d · %.0fms", - len(boxes), len(persons), len(raised), len(fists), latency_ms, + "vision: 人脸 %d 姿态 %d 举手 %d 举拳 %d 指向 %d 合十 %s · %.0fms", + len(boxes), len(persons), len(raised), len(fists), len(pointing), hands_close, latency_ms, ) return { "faces": len(boxes), @@ -135,6 +180,10 @@ def predict_jpeg(jpeg_bytes: bytes): "pose": persons, "raised": raised, "fists": fists, + "both_up": both_up, + "pointing": pointing, + "hands_close": hands_close, + "hands": hands, "latency_ms": latency_ms, } diff --git a/backend/templates/admin.html b/backend/templates/admin.html index f92ddf3..653ff55 100644 --- a/backend/templates/admin.html +++ b/backend/templates/admin.html @@ -130,11 +130,26 @@
页面导航
+
+ +
+
企业展示墙(/wall)
+
+ + +
+
+ + + +
+
+
人物识别(全局)
diff --git a/docs/esp32-ir-control.md b/docs/esp32-ir-control.md index c50a43d..9e445e4 100644 --- a/docs/esp32-ir-control.md +++ b/docs/esp32-ir-control.md @@ -164,6 +164,30 @@ void reconnect() { 其他可用指令(自定义映射):`ai_input`(AI 页提问)、`ai_company`(切企业)、`ai_zone`(切分区)、`voice_stop`、`next`/`prev`(媒体)、`alert`(通知)——参数见 `docs/mqtt-commands.md`。 +## 5.1 企业展示墙控制(/wall,39 家入驻企业滚动墙) + +**MQTT 控制消息**(ESP32 解码红外键码后发布到 `opc/display/command`,JSON 载荷): + +| 遥控器按键 | 建议指令 action | params | 效果 | 示例消息 | +|---|---|---|---|---| +| 1 / 2 / 3 | `wall_speed` | `speed: "slow" \| "normal" \| "fast"` | 慢速 150s / 标准 80s / 快速 40s 一圈 | `{"action":"wall_speed","params":{"speed":"slow"}}` | +| CH− | `wall_pause` | `{}` | 暂停滚动 | `{"action":"wall_pause","params":{}}` | +| CH+ | `wall_resume` | `{}` | 继续滚动 | `{"action":"wall_resume","params":{}}` | +| 电源 | `navigate` | `page: "/wall"` | 切换到企业展示墙 | `{"action":"navigate","params":{"page":"/wall"}}` | + +**ESP32 固件映射示例**(在 §4 固件的 `MAP[]` 键码映射表中追加): + +```cpp +// 键码 → MQTT 指令(payload 为 JSON 字符串,经 publishCommand 发布) +{ 0x02, R"({"action":"wall_pause","params":{}})" }, // VOL− 暂停墙滚动 +{ 0x03, R"({"action":"wall_resume","params":{}})" }, // VOL+ 继续墙滚动 +{ 0x00, R"({"action":"wall_speed","params":{"speed":"fast"}})" }, // CH− 快速 +{ 0x01, R"({"action":"wall_speed","params":{"speed":"slow"}})" }, // CH+ 慢速 +{ 0x0B, R"({"action":"navigate","params":{"page":"/wall"}})" }, // 电源 切到展示墙 +``` + +> 前端 `useMqttControl` 收到 `wall_*` 指令后派发 `dpm:wall-control` 事件,展示墙页据此暂停/继续/变速;未在展示墙页时 `wall_*` 指令不产生副作用(安全幂等)。 + ## 6. 学习模式(首次必须) 键码采集固件(只需串口打印,不用连 MQTT): diff --git a/docs/mqtt-commands.md b/docs/mqtt-commands.md index 8320e64..5a57f1e 100644 --- a/docs/mqtt-commands.md +++ b/docs/mqtt-commands.md @@ -42,7 +42,7 @@ | action | params | 效果 | |---|---|---| -| `navigate` | `page: "/" \| "/twin" \| "/ai" \| "/voice" \| "/screen"`(或别名 home/twin/ai/voice/screen/数据大屏…) | 切换到指定页面 | +| `navigate` | `page: "/" \| "/twin" \| "/ai" \| "/voice" \| "/wall" \| "/screen"`(或别名 home/twin/ai/voice/wall/screen/数据大屏/企业展示墙…) | 切换到指定页面 | | `navigate_rel` | `delta: 1 \| -1` | 向右 / 向左循环切换页面 | ### 4.2 全局视觉识别 @@ -79,7 +79,17 @@ | `settings_changed` | 设置对象 | 应用设置变更 | | `playlist_changed` | — | 重新加载播放列表 | -### 4.6 全局 +### 4.6 企业展示墙页(/wall) + +> 39 家入驻企业信息缓慢滚动展示墙(数据来源:《入驻企业信息表》) + +| action | params | 效果 | +|---|---|---| +| `wall_pause` | — | 暂停滚动 | +| `wall_resume` | — | 继续滚动 | +| `wall_speed` | `speed: slow \| normal \| fast` | 滚动速度:慢速 150s / 标准 80s / 快速 40s 一圈 | + +### 4.7 全局 | action | params | 效果 | |---|---|---| @@ -118,6 +128,12 @@ mosquitto_pub -h 192.168.1.3 -p 1883 -u <用户名> -P <密码> \ -t opc/display/command -m '{"action":"voice_start","params":{}}' mosquitto_pub -h 192.168.1.3 -p 1883 -u <用户名> -P <密码> \ -t opc/display/command -m '{"action":"next","params":{}}' + +# 切到企业展示墙 / 暂停滚动 +mosquitto_pub -h 192.168.1.3 -p 1883 -u <用户名> -P <密码> \ + -t opc/display/command -m '{"action":"navigate","params":{"page":"/wall"}}' +mosquitto_pub -h 192.168.1.3 -p 1883 -u <用户名> -P <密码> \ + -t opc/display/command -m '{"action":"wall_pause","params":{}}' ``` ### Python(paho-mqtt,后端 venv 自带) diff --git a/src/App.jsx b/src/App.jsx index 51d31da..a9bb0fb 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -4,6 +4,7 @@ import DataScreen from './pages/DataScreen'; import DigitalTwin from './pages/DigitalTwin'; import AiAssistant from './pages/AiAssistant'; import VoiceAssistant from './pages/VoiceAssistant'; +import CompanyWall from './pages/CompanyWall'; import ScreenLayout from './components/ScreenLayout'; import './styles/tokens.css'; @@ -19,6 +20,8 @@ export default function App() { } /> {/* 实时语音对话页(React 原生,复用页眉) */} } /> + {/* 入驻企业展示墙 */} + } /> {/* 管理后台已迁移至 FastAPI + Jinja(GET /admin),不由 React 路由接管 */} } /> diff --git a/src/components/Building3D.jsx b/src/components/Building3D.jsx index 2743826..bb21b00 100644 --- a/src/components/Building3D.jsx +++ b/src/components/Building3D.jsx @@ -245,9 +245,35 @@ function MeetingTable({ x, z, y = 0, rotY = 0, len = 2.6 }) { ); } -/* 讲台(会议室讲台) */ -function Podium({ x, z, y = 0 }) { +/* 会议室小办公桌(小型方桌 + 座椅,多张布置,不穿墙) */ +function MeetingDesk({ x, z, y = 0, rotY = 0 }) { return ( + + {/* 桌面 */} + + + + + {/* 桌腿 */} + + + + + {/* 座椅(桌前) */} + + + + + + + + + + ); +} + +/* 讲台(会议室讲台) */ +function Podium({ x, z, y = 0 }) { return ( @@ -391,9 +417,9 @@ function Building() { // 一楼大厅外墙(高度 = FLOOR2,不穿过二楼楼板)+ 窗户 const hallWalls = [ { axis: 'z', pos: 0, from: 0, to: 44 }, - { axis: 'z', pos: 27, from: 0, to: 44 }, - { axis: 'x', pos: 0, from: 0, to: 27 }, - { axis: 'x', pos: 44, from: 0, to: 27 }, + { axis: 'z', pos: 41.1, from: 0, to: 44 }, + { axis: 'x', pos: 0, from: 0, to: 41 }, + { axis: 'x', pos: 44, from: 0, to: 41 }, ]; hallWalls.forEach((wl, i) => { const posW = wl.axis === 'z' ? cz(wl.pos) : cx(wl.pos); @@ -496,29 +522,33 @@ function Building() { return; } - // 过道顶棚(右侧竖过道、横向过道) - if (r.type === 'hallway') { - roofs.push( - - - - - ); - return; - } + // 过道不渲染顶棚(hallway:无顶棚,避免半透明色块干扰地面观感) // 家具按功能区分(地面统一色,靠资产区分) if (r.type === 'office') { - // 办公室:办公桌(按面积放置多张) - const nX = Math.max(1, Math.floor((r.x[1] - r.x[0]) / 3.5)); - const nZ = Math.max(1, Math.floor((r.z[1] - r.z[0]) / 3.5)); - const total = Math.min(4, nX * nZ); - let idx = 0; - for (let ix = 0; ix < nX && idx < total; ix++) { - for (let iz = 0; iz < nZ && idx < total; iz++, idx++) { - const dx = (ix + 0.5) / nX - 0.5; - const dz = (iz + 0.5) / nZ - 0.5; - furniture.push(); + // 办公室:办公桌按房间面积排布,留边距不穿墙 + const roomW = r.x[1] - r.x[0]; + const roomD = r.z[1] - r.z[0]; + // 窄房间(宽 < 2.6):单桌且旋转 90°(桌+椅沿深度方向排布,避免横向穿墙) + const narrow = roomW < 2.6; + if (narrow) { + furniture.push( + + ); + } else { + const nX = Math.max(1, Math.min(3, Math.floor(roomW / 2.4))); + const nZ = Math.max(1, Math.min(2, Math.floor(roomD / 2.4))); + const total = Math.min(4, nX * nZ); + let idx = 0; + for (let ix = 0; ix < nX && idx < total; ix++) { + for (let iz = 0; iz < nZ && idx < total; iz++, idx++) { + // 留边距居中,确保桌面/椅不穿墙 + const dx = (ix + 0.5) / nX - 0.5; + const dz = (iz + 0.5) / nZ - 0.5; + furniture.push( + + ); + } } } } else if (r.type === 'desk') { @@ -540,17 +570,27 @@ function Building() { furniture.push(); furniture.push(); } else if (r.type === 'meeting') { - // 会议室:按面积区分——大会议室(长桌+讲台),小会议室(单张会议桌) - const area = (r.x[1] - r.x[0]) * (r.z[1] - r.z[0]); - if (area >= 40) { - // 大会议室(M01):两张会议长桌 + 讲台 - furniture.push(); - furniture.push(); - furniture.push(); - } else { - // 小会议室(M02):单张会议长桌 + 四周座椅 - furniture.push(); + // 会议室:多张小办公桌(MeetingDesk)按房间网格排布,不穿墙且满布 + const roomW = r.x[1] - r.x[0]; + const roomD = r.z[1] - r.z[0]; + const nD = Math.max(1, Math.min(6, Math.floor((roomW - 0.8) / 1.2))); // 沿 X 列(留边距,桌宽0.9+隙) + const nR = Math.max(1, Math.min(4, Math.floor((roomD - 0.8) / 1.2))); // 沿 Z 行 + const total = Math.min(12, nD * nR); // 上限 12 张小桌 + let idx = 0; + for (let ix = 0; ix < nD && idx < total; ix++) { + for (let iz = 0; iz < nR && idx < total; iz++, idx++) { + // 留边距居中(每侧 0.5 单位),避免穿墙 + const dx = (ix + 0.5) / nD - 0.5; + const dz = (iz + 0.5) / nR - 0.5; + furniture.push( + + ); + } } + // 大会议室额外加讲台 + const aBig = roomW * roomD >= 40; + if (aBig) furniture.push(); } else if (r.type === 'stair') { // 楼梯间:真实楼梯(二楼 → 一楼),自动按房间宽/深选择梯段方向与级数 const alongX = (x1 - x0) >= (z1 - z0); // 房间宽≥深 → 梯段沿 X 方向 @@ -563,7 +603,8 @@ function Building() { steps={steps} stepW={Math.min(0.9, (alongX ? d : w) - 0.1)} stepD={sLen / steps} /> ); } - // 标签:每个房间显示编号(编号 + 名称),办公室显示入驻企业名 + // 标签:每个房间显示编号(编号 + 名称),办公室显示入驻企业名;过道不显示标签 + if (r.type === 'hallway') return; const noLabel = r.no || ''; const typeLabel = r.type === 'desk' ? '共享工位区' : diff --git a/src/components/GlobalVision.jsx b/src/components/GlobalVision.jsx new file mode 100644 index 0000000..e66643d --- /dev/null +++ b/src/components/GlobalVision.jsx @@ -0,0 +1,51 @@ +import { useEffect } from 'react'; +import { useLocation } from 'react-router-dom'; +import useVisionDetection from '../voice/useVisionDetection.js'; + +/* ========================================================= + 全局手势识别(挂载于 ScreenLayout —— 所有大屏页面共享) + --------------------------------------------------------- + · 摄像头挂载即开启、卸载自动释放(仅一处常开,各页面复用) + · 识别到「举手」(手腕高于肩,持续 1.5s)→ 派发全局事件: + window 'dpm:gesture' { type: 'raise' } + 各页面按需订阅处理(例:企业展示墙 举手暂停/继续) + · 无摄像头 / 非安全上下文时自动降级:仅右上角提示,不影响页面 + · 全局状态徽标:右下角常驻,举手瞬间闪烁反馈 + ========================================================= */ + +export default function GlobalVision() { + const location = useLocation(); + const vision = useVisionDetection({ + onGesture: (g) => { + // 全部手势类型(raise/fist/both_up/point/hands_close/wave)统一分发 + window.dispatchEvent(new CustomEvent('dpm:gesture', { detail: { type: g } })); + }, + }); + + const on = vision.cameraOn && vision.status !== 'error'; + // 仅在订阅了手势交互的页面(/wall 等)显示右下角徽标 + const showBadge = ['/wall'].includes(location.pathname); + + // 手部实时坐标广播:dpm:hand-track { hands }(供手势跟随等使用) + useEffect(() => { + window.dispatchEvent(new CustomEvent('dpm:hand-track', { detail: { hands: vision.hands } })); + }, [vision.hands]); + + return ( + <> + {/* 摄像头驱动(隐藏,仅供全局抽帧识别) */} +