docs: 部署编排与运维文档

This commit is contained in:
2026-08-23 22:35:06 +08:00
parent b681eac2df
commit cdd0ba96ee
11 changed files with 1211 additions and 0 deletions
+47
View File
@@ -0,0 +1,47 @@
# ============================================================
# Docker 构建上下文忽略(构建根目录 = 项目根目录)
# 说明:仅 backend/ 会 COPY 进镜像;media 内的媒体文件必须保留,
# 因此图片/视频后缀不做全局排除,只锚定根目录的开发素材。
# ============================================================
# ---- 版本控制 / 元数据 ----
.git
.gitignore
.DS_Store
**/.DS_Store
# ---- 前端 / 桌面端(不属于后端镜像) ----
src
src-tauri
dist
public
node_modules
package.json
yarn.lock
package-lock.json
vite.config.js
# ---- 根目录开发素材 / 标准资料(不进入镜像) ----
/IMG_7801.JPG
/空间布局.png
/详细的布局.png
/数据资料
*.zip
*.wps
*.xlsx
*.xls
*.pdf
*.ai
/昆明市创业园宣传册.pdf
# ---- 后端内部:排除开发产物与密钥 ----
backend/.venv
backend/.env
backend/**/__pycache__
backend/**/*.pyc
backend/.pytest_cache
backend/.mypy_cache
backend/.ruff_cache
backend/.ultralytics
backend/kb_index.json
backend/README.md
+45
View File
@@ -0,0 +1,45 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
# 后端密钥 / 前端本地配置(含阿里云密钥、MQTT 凭据,不入库)
backend/.env
.env.local
*.local
*.env.*
# 后端运行时数据(媒体上传、持久化配置)
backend/media/
backend/data.json
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# 本地数据资料(不入库)
数据资料/
# Python
.venv/
__pycache__/
*.pyc
# 本地语音模型缓存(silero VAD / nltk 数据;部署时随 backend/ 目录整体拷贝,不入库)
backend/.torch-cache/
backend/nltk_data/
+6
View File
@@ -0,0 +1,6 @@
# 工作规则(永远遵守)
## 进程管理铁律
- **绝对禁止 AI 启动、重启、停止任何前后端服务进程**(后端 10085 / s2s 8765 / vite 1420 / Tauri 等)。
- 需要启动或重启时,**必须先通知用户**,由用户亲自操作。
- AI 只做:改代码、编译检查、测试接口(接口测试仅在服务已在运行且不重启的前提下)、给出启动命令与验证清单。
+199
View File
@@ -0,0 +1,199 @@
# 大屏媒体轮播系统 (DPM)
**Digital Playback Media** — 基于 Tauri + React 的全屏媒体轮播控制系统,专为昆明市大学生创业园展播大屏设计。
## 概述
DPM 是一套完整的数字标牌(Digital Signage)解决方案,包含两大界面:
- **大屏展示页** (`/screen`) — 全屏自动轮播,支持视频与图片,具备沉浸式空间视觉背景
- **管理后台** (`/admin`) — 登录保护的管理面板,用于媒体上传、播放列表编排、播放控制与系统设置
系统通过 **SSE(Server-Sent Events)** 实现管理端与展示端的实时状态同步,支持局域网内远程管理。
## 技术栈
| 层级 | 技术 |
|------|------|
| 前端框架 | React 19 + React Router 7 |
| 构建工具 | Vite 7 |
| 桌面框架 | Tauri 2 (Rust) |
| HTTP 服务 | Axum 0.8 (嵌入式 HTTP 服务器) |
| 数据持久化 | JSON 文件存储 |
| 实时通信 | Server-Sent Events (SSE) |
| 内网穿透 | nps 客户端嵌入式集成 |
## 功能特性
### 媒体管理
- **本地上传** — 支持 .mp4、.mkv、.avi、.jpg、.jpeg、.png 格式
- **远程 URL** — 添加网络媒体资源链接
- **媒体预览** — 图片与视频的模态框预览
- **文件删除** — 从媒体库移除文件
### 播放控制
- **播放/暂停** — 实时控制展示端的播放状态
- **上一曲/下一曲** — 切换播放内容
- **顺序/随机播放** — 两种播放模式
- **图片时长可调** — 1~300 秒
- **音量控制** — 实时同步调节
### 展示端体验
- **沉浸式空间背景** — 深邃星空背景 + 透视网格 + 动态环境光晕
- **自动播放** — 启动后自动加载播放列表
- **视频/图片自适应** — 全屏覆盖显示
- **声音管理** — 浏览器自动播放策略处理
- **点击暂停/恢复** — 现场快速控制
### 系统功能
- **全屏/窗口模式切换** — 自由切换展示形态
- **最小化窗口** — 后台运行
- **开机自启动** — 系统级自启(Tauri autostart 插件)
- **关闭 → 最小化** — 防止误关闭
- **内网穿透** — 内置 nps 客户端,支持外网远程访问
### 管理后台
- **登录保护** — 账号密码验证
- **实时状态显示** — 当前播放内容、状态
- **上传进度条** — 文件上传实时反馈
- **响应式设计** — 桌面/移动端自适应
- **Toast 通知** — 操作即时反馈
## 快速开始
### 环境要求
- **Rust** (edition 2021)
- **Node.js** >= 18
- **Yarn** 或 npm
- **Tauri CLI** (`cargo install tauri-cli`)
### 安装
```bash
# 安装前端依赖
yarn install
# 开发模式运行(同时启动 Vite 开发服务器和 Tauri 应用)
yarn tauri dev
# 仅启动 Web 开发服务器(浏览器中预览)
yarn dev
```
### 构建生产版本
```bash
yarn tauri build
```
构建产物位于 `src-tauri/target/release/` 目录。
## 项目结构
```
dpm/
├── index.html # HTML 入口
├── package.json # 前端依赖配置
├── vite.config.js # Vite 构建配置(API 代理到 :10801)
├── public/
│ └── npc.zip # 内网穿透客户端(嵌入二进制)
├── src/ # 前端源代码
│ ├── main.jsx # React 入口
│ ├── App.jsx # 路由配置(/admin, /screen)
│ ├── pages/
│ │ ├── Admin.jsx # 管理后台页面
│ │ └── Screen.jsx # 大屏展示页面
│ ├── utils/
│ │ └── api.js # API 封装(REST + SSE)
│ └── styles/
│ ├── tokens.css # CSS 基础重置
│ ├── admin.css # 管理后台样式
│ └── screen.css # 大屏展示样式
└── src-tauri/ # Rust 后端源代码
├── Cargo.toml # Rust 依赖配置
├── tauri.conf.json # Tauri 应用配置
├── capabilities/
│ └── default.json # Tauri 权限配置
└── src/
├── main.rs # 程序入口
├── lib.rs # 模块导出 + 启动逻辑
├── server.rs # Axum HTTP 服务器(路由 + 处理器)
├── models.rs # 数据模型(Settings, PlaybackState, etc.)
├── storage.rs # JSON 文件持久化存储
└── events.rs # SSE 事件广播管理器
```
## API 参考
系统内置 HTTP 服务器监听 `0.0.0.0:10801`,提供以下 REST API:
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | `/api/login` | 管理员登录 |
| GET | `/api/settings` | 获取系统设置 |
| POST | `/api/settings` | 更新系统设置 |
| GET | `/api/playlist` | 获取播放列表(含音量/模式/时长) |
| POST | `/api/playlist/add` | 添加到播放列表 |
| POST | `/api/playlist/remove` | 从播放列表移除 |
| GET | `/media` | 获取所有媒体文件列表 |
| POST | `/upload` | 上传媒体文件(multipart) |
| POST | `/api/media/add-url` | 添加 URL 媒体 |
| POST | `/api/delete` | 删除媒体文件 |
| POST | `/api/control` | 播放控制(play/pause/next/prev) |
| GET | `/api/state` | 获取当前播放状态 |
| POST | `/api/state` | 更新播放状态 |
| GET | `/api/events` | SSE 实时事件流 |
| POST | `/api/display-command` | 显示命令(minimize 等) |
| GET | `/file/*` | 静态媒体文件服务 |
### 默认登录凭据
- **账号**: `admin`
- **密码**: `**********`
> 可通过编辑系统数据目录下的 `dpm/data.json` 修改。
### 数据存储位置
系统数据存储在操作系统标准数据目录下的 `dpm/` 文件夹中:
- **macOS**: `~/Library/Application Support/dpm/data.json`
- **Linux**: `~/.local/share/dpm/data.json`
- **Windows**: `C:\Users\<用户>\AppData\Roaming\dpm/data.json`
媒体文件存储在用户 `~/Downloads/Media/` 目录。
## 内网穿透
系统内置 [nps](https://github.com/ehang-io/nps) 客户端,自动从嵌入的 `public/npc.zip` 解压并启动,实现外网远程访问管理后台。
- 启动参数在 `server.rs` 中配置
- *仅支持 Windows*
- 自动设置 Unix 可执行权限
- 解压到系统数据目录 `dpm/npc/`
## 开发说明
### 端口
| 端口 | 用途 |
|------|------|
| 10801 | 后端 HTTP API 服务器 |
| 1420 | Vite 前端开发服务器 |
### Vite 开发代理
开发模式下,Vite 自动将 `/api`、`/file`、`/media`、`/upload` 路径代理到 `localhost:10801`,前端无需关心跨域问题。
### 构建
前端构建产物(`dist/`)通过 `RustEmbed` 编译到 Rust 二进制中,发布时只需分发单个可执行文件。
## 许可
本项目为昆明市大学生创业园公益定制开发。
---
*云南派音人工智能科技提供技术支持*
+100
View File
@@ -0,0 +1,100 @@
# ============================================================
# OPC 智能园区后端 · Docker Compose 一键部署
#
# 启动: docker compose up -d --build
# 停止: docker compose down
# 查看: docker compose logs -f backend
# 重建: docker compose up -d --build backend
#
# 部署前配置(可选,均可用环境变量覆盖):
# export MQTT_PUBLIC_HOST=192.168.1.50 # 展播端能访问到的 Broker 地址(服务器局域网 IP)
# export EMQX_USER=dpmserver EMQX_PASS=xxxxx
# export DASHSCOPE_API_KEY=sk-xxxx # AI/语音(未配置时 AI 走本地规则引擎)
# export DPM_S2S_ENABLED=0 # 不需要实时语音对话可关闭
#
# 端口:
# · 10085 HTTP(API + 管理后台 + 媒体)
# · 8765 s2s 实时语音 WebSocket(DPM_S2S_ENABLED=1 时)
# · 1883/8083/18083 EMQX(MQTT / MQTT-WS / 控制台)
# ============================================================
services:
# ---------- MQTT Broker(EMQX 5.x) ----------
emqx:
image: emqx/emqx:5.8
container_name: dpm-emqx
restart: unless-stopped
ports:
- "1883:1883" # MQTT
- "8083:8083" # MQTT over WebSocket(展播端 /api/config 下发)
- "18083:18083" # EMQX Dashboard(可选,账号 admin / public)
environment:
EMQX_NAME: dpm-emqx
EMQX_HOST: node.emqx.dpm.local
EMQX_NODE__COOKIE: dpm-emqx-cookie
EMQX_DASHBOARD__DEFAULT_PASSWORD: "${EMQX_DASHBOARD_PASS:-public}"
# 鉴权:禁止匿名,单账号 dpmserver(与 backend/.env 一致)
EMQX_ALLOW_ANONYMOUS: "false"
EMQX_AUTH__USER__1__LOGIN: "${EMQX_USER:-dpmserver}"
EMQX_AUTH__USER__1__PASSWORD: "${EMQX_PASS:-123456}"
volumes:
- emqx_data:/opt/emqx/data
- emqx_log:/opt/emqx/log
healthcheck:
test: ["CMD", "emqx", "ctl", "status"]
interval: 10s
timeout: 5s
retries: 12
start_period: 15s
networks:
- dpm_net
# ---------- 园区后端(FastAPI :10085 + s2s :8765) ----------
backend:
build:
context: .
dockerfile: backend/Dockerfile
image: dpm-backend:latest
container_name: dpm-backend
restart: unless-stopped
depends_on:
emqx:
condition: service_healthy
ports:
- "10085:10085" # API / 管理后台 / 媒体
- "8765:8765" # s2s 实时语音(DPM_S2S_ENABLED=1 时)
environment:
DPM_PORT: "10085"
DPM_MQTT_ENABLED: "1"
MQTT_BROKER_HOST: emqx
MQTT_BROKER_PORT: "1883"
MQTT_USERNAME: "${EMQX_USER:-dpmserver}"
MQTT_PASSWORD: "${EMQX_PASS:-123456}"
# 展播端(Tauri 大屏)经后端 /api/config 获取的 WebSocket 地址:
# 必须填"展播机能访问到的服务器地址",默认 192.168.1.9(同机部署)
DPM_MQTT_WS: "ws://${MQTT_PUBLIC_HOST:-192.168.1.9}:8083/mqtt"
DPM_S2S_ENABLED: "${DPM_S2S_ENABLED:-1}"
DASHSCOPE_API_KEY: "${DASHSCOPE_API_KEY:-}"
volumes:
- dpm_media:/app/backend/media # 媒体资源(上传持久化)
- dpm_knowledge:/app/backend/knowledge # 知识库 park.md(可热更新,删除 kb_index.json 触发重建)
- dpm_data:/app/backend/data.json # 播放列表 / 设置
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://192.168.1.9:10085/api/health', timeout=4).status==200 else 1)"]
interval: 15s
timeout: 5s
retries: 5
start_period: 30s
networks:
- dpm_net
networks:
dpm_net:
driver: bridge
volumes:
emqx_data:
emqx_log:
dpm_media:
dpm_knowledge:
dpm_data:
+234
View File
@@ -0,0 +1,234 @@
# ESP32 红外遥控控制大屏 —— 开发文档
> 通过红外遥控器(NEC 协议)直接控制园区大屏页面切换与操作。
> 链路:红外遥控 → ESP32(红外接收 + 解码)→ MQTT → 前端调度器 → 页面动作
## 1. 系统架构
```
┌──────────┐ 38kHz 红外 ┌─────────┐ WiFi/MQTT ┌──────────────┐ opc/display/command ┌────────┐
│ 红外遥控器 │ ───────────► │ ESP32 │ ───────────► │ EMQX Broker │ ─────────────────────► │ 大屏前端 │
│ (NEC协议) │ │ 接收+解码 │ │ 192.168.1.3 │ │ 调度器 │
└──────────┘ └─────────┘ └──────────────┘ └────────┘
```
- **红外遥控器**:任意 NEC 协议遥控器(电视/机顶盒/学习型均可),按键映射可自定义
- **ESP32**:接收红外信号 → 解码键码 → 映射成指令 → MQTT 发布
- **MQTT**:复用现有 `opc/display/command` 频道(见 `docs/mqtt-commands.md`)
- **大屏前端**:`useMqttControl` 调度器接收指令执行(切页/媒体/语音/视觉)
## 2. 硬件清单与接线
| 硬件 | 型号/规格 | 数量 |
|---|---|---|
| 开发板 | ESP32(DevKitC / NodeMCU-32S) | 1 |
| 红外接收头 | VS1838B / TL1838(38kHz,3 脚) | 1 |
| 电阻 | 100Ω(限流,可选) | 1 |
| 红外遥控器 | NEC 协议(电视/机顶盒遥控器) | 1 |
**VS1838B 接线**(面对接收头平面,引脚从左到右):
| 引脚 | 接 ESP32 |
|---|---|
| 1 (OUT/信号) | GPIO 15(可改,见代码 `IR_RX_PIN`) |
| 2 (GND) | GND |
| 3 (VCC) | 3.3V(或 5V,接收头宽压 2.7~5.5V) |
## 3. 红外协议(NEC)
- NEC 协议:38kHz 载波,地址码 + 命令码 + 反码
- 本方案**只取命令码(command byte)**即可区分按键,地址码忽略
- 任意 NEC 遥控器的按键都有固定键码;不同遥控器键码不同 → **首次需"学习模式"采集键码**(见 §6)
## 4. Arduino 固件
依赖库(Arduino IDE 库管理器安装):
- `IRremote`(by Armin Joachimsmeyer,v4.x)
- `PubSubClient`(by Nick O'Leary)
```cpp
// esp32_ir_mqtt.ino —— 红外遥控 → MQTT 控制大屏
#include <WiFi.h>
#include <PubSubClient.h>
#include <IRremote.h>
// ── WiFi / MQTT 配置 ─────────────────────────────────────
const char* WIFI_SSID = "你的WiFi";
const char* WIFI_PASS = "你的WiFi密码";
const char* MQTT_HOST = "192.168.1.3"; // EMQX Broker
const int MQTT_PORT = 1883;
const char* MQTT_USER = "mqtt用户名"; // backend/.env MQTT_USERNAME
const char* MQTT_PASS = "mqtt密码"; // backend/.env MQTT_PASSWORD
const char* MQTT_TOPIC = "opc/display/command";
// ── 红外引脚 ─────────────────────────────────────────────
const uint8_t IR_RX_PIN = 15;
// ── 按键映射表:遥控器键码 → 大屏指令 ─────────────────────
// 键码需先用「学习模式」采集(串口打印 decodedIRData.command),再填入
struct CmdMap { uint32_t key; const char* payload; };
const CmdMap MAP[] = {
// 数字键 1~5:直接切页
{ 0x16, R"({"action":"navigate","params":{"page":"/"}})" }, // 1 → 数据大屏
{ 0x19, R"({"action":"navigate","params":{"page":"/twin"}})" }, // 2 → 数字孪生
{ 0x0D, R"({"action":"navigate","params":{"page":"/ai"}})" }, // 3 → AI 助手
{ 0x0C, R"({"action":"navigate","params":{"page":"/voice"}})" }, // 4 → 语音对话
{ 0x18, R"({"action":"navigate","params":{"page":"/screen"}})" }, // 5 → 媒体轮播
// CH+/CH-(或左右方向):向左/右切页
{ 0x00, R"({"action":"navigate_rel","params":{"delta":-1}})" }, // CH- → 左切
{ 0x01, R"({"action":"navigate_rel","params":{"delta":1}})" }, // CH+ → 右切
// 音量+/−:媒体播放/暂停(或 next/prev)
{ 0x02, R"({"action":"play","params":{}})" }, // VOL+ → 播放
{ 0x03, R"({"action":"pause","params":{}})" }, // VOL- → 暂停
// 电源键:语音对话开关
{ 0x0B, R"({"action":"voice_start","params":{}})" }, // 电源 → 语音启动
// OK/静音键:视觉识别开关(toggle 示例:直接开/关,可换 toggle 逻辑)
{ 0x0A, R"({"action":"vision_set","params":{"enabled":true}})" }, // 静音 → 开识别
};
const int MAP_LEN = sizeof(MAP) / sizeof(MAP[0]);
// ── 全局 ─────────────────────────────────────────────────
WiFiClient espClient;
PubSubClient mqtt(espClient);
unsigned long lastReconnect = 0;
void setup() {
Serial.begin(115200);
Serial.println("ESP32 IR → MQTT 启动");
IrReceiver.begin(IR_RX_PIN, ENABLE_LED_FEEDBACK);
WiFi.mode(WIFI_STA);
WiFi.begin(WIFI_SSID, WIFI_PASS);
while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print(".");
}
Serial.println("\nWiFi 已连接: " + String(WiFi.localIP()));
mqtt.setServer(MQTT_HOST, MQTT_PORT);
mqtt.setKeepAlive(30);
}
void loop() {
if (!mqtt.connected()) reconnect();
mqtt.loop();
// ── 红外解码 ──
if (IrReceiver.decode()) {
uint32_t cmd = IrReceiver.decodedIRData.command;
Serial.printf("[IR] 地址=0x%02X 键码=0x%02X\n",
IrReceiver.decodedIRData.address, cmd);
IrReceiver.resume(); // 立即恢复接收
sendCommand(cmd);
}
}
// ── 键码 → MQTT ──
void sendCommand(uint32_t key) {
for (int i = 0; i < MAP_LEN; i++) {
if (MAP[i].key == key) {
Serial.println("[MQTT] 发送: " + String(MAP[i].payload));
mqtt.publish(MQTT_TOPIC, MAP[i].payload);
return;
}
}
Serial.println("[IR] 未映射键码(可在学习模式后加入映射表)");
}
// ── MQTT 重连 ──
void reconnect() {
if (millis() - lastReconnect < 3000) return;
lastReconnect = millis();
String id = "esp32-ir-" + String((uint32_t)ESP.getEfuseMac(), HEX);
if (mqtt.connect(id.c_str(), MQTT_USER, MQTT_PASS)) {
Serial.println("MQTT 已连接");
} else {
Serial.printf("MQTT 连接失败 rc=%d\n", mqtt.state());
}
}
```
## 5. 键位映射建议
| 遥控器按键 | 键码(示例) | 指令 | 效果 |
|---|---|---|---|
| 1 ~ 5 | 0x16/0x19/0x0D/0x0C/0x18 | `navigate` | 直达各页面 |
| CH− / CH+ | 0x00 / 0x01 | `navigate_rel` | 向左/右循环切页 |
| VOL+ / VOL− | 0x02 / 0x03 | `play` / `pause` | 媒体播放/暂停 |
| 电源 | 0x0B | `voice_start` | 启动语音对话 |
| 静音 | 0x0A | `vision_set` | 开/关人物识别 |
> 键码因遥控器而异——上面的 0x16/0x19… 是常见 NEC 遥控器的值,**首次使用务必先跑学习模式实测**。
其他可用指令(自定义映射):`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):
```cpp
#include <IRremote.h>
const uint8_t IR_RX_PIN = 15;
void setup() {
Serial.begin(115200);
IrReceiver.begin(IR_RX_PIN, ENABLE_LED_FEEDBACK);
Serial.println("按遥控器按键,串口会打印键码(学习模式)");
}
void loop() {
if (IrReceiver.decode()) {
Serial.printf("address=0x%02X command=0x%02X\n",
IrReceiver.decodedIRData.address,
IrReceiver.decodedIRData.command);
IrReceiver.resume();
}
}
```
步骤:
1. 烧录学习固件,打开串口监视器(115200)
2. 逐个按遥控器按键,记录每个键的 `command` 十六进制值
3. 把键码填入正式固件的 `MAP[]` 映射表
4. 烧录正式固件
## 7. 调试与故障排查
| 现象 | 排查 |
|---|---|
| 串口无任何 IR 输出 | 接线检查(OUT→GPIO15、GND、VCC);接收头正面对遥控器;换遥控器电池 |
| 有 IR 输出但 MQTT 不发送 | 打印显示"未映射键码"→ 键码表没配对该遥控器;WiFi/MQTT 未连接 |
| MQTT 连接失败 rc=5 | 用户名/密码错误(对照 backend/.env) |
| 前端无反应但 MQTT 收到 | `mosquitto_sub -t opc/display/command` 验证消息到达 Broker;前端需在运行且 MQTT 已连(页面右上角在线状态) |
| 连按无效 | NEC 重复码(0xFFFFFFFF)被忽略属正常;长按只发一次指令 |
## 8. 进阶扩展(可选)
- **学习型配置**:ESP32 加按钮 + OLED,现场学习键码并存入 NVS,免改代码
- **多键组合**:短按/长按区分(记录按下时长)→ 同一键多指令
- **OTA 升级**:ArduinoOTA 远程更新固件
- **多屏控制**:指令包带 `client_id` 过滤,指定某台大屏执行
+175
View File
@@ -0,0 +1,175 @@
# MQTT 标准操作手册(OPC 智能园区大屏系统)
> 通过 MQTT 对前端大屏进行集中控制:切页、页面操作、媒体控制、视觉开关、状态上报。
## 1. Broker 连接
| 项 | 值 | 说明 |
|---|---|---|
| 地址 | `192.168.1.3` | 局域网 EMQX Broker |
| TCP 端口 | `1883` | MQTT 标准端口(后端/命令行用) |
| WebSocket 端口 | `8083` | 前端浏览器用(`ws://192.168.1.3:8083/mqtt`) |
| 用户名/密码 | `backend/.env` 的 `MQTT_USERNAME` / `MQTT_PASSWORD` | 连接鉴权 |
## 2. 频道(Topic)
| Topic | 方向 | 载荷 | 用途 |
|---|---|---|---|
| `opc/display/command` | 服务端 → 前端 | 指令信封 | **控制指令**(切页/页面操作/媒体/视觉) |
| `opc/dashboard/tick` | 服务端 → 前端 | 园区数据快照 | 数据大屏实时刷新(2.2s) |
| `opc/display/heartbeat` | 前端 → 服务端 | `{client_id, page, ts}` | 大屏在线心跳(10s) |
| `opc/frontend/state` | 前端 → 服务端 | `{client_id, page, ts}` | **页面状态即时上报**(路由变化即发) |
| `opc/display/ack` | 前端 → 服务端 | 指令回执 | 指令确认(可选) |
## 3. 统一指令信封
所有控制指令发到 `opc/display/command`,JSON 格式:
```json
{
"cmd_id": "uuid", // 指令 ID(可选)
"ts": 1755490000000, // 时间戳 ms(可选)
"action": "navigate", // 命令名(必填)
"params": { "page": "/ai" } // 参数(按命令)
}
```
后端 `hub.publish_command(action, params)` 自动生成信封;命令行手动发送时只需 `action` + `params`。
## 4. 命令全集
### 4.1 页面切换
| action | params | 效果 |
|---|---|---|
| `navigate` | `page: "/" \| "/twin" \| "/ai" \| "/voice" \| "/wall" \| "/screen"`(或别名 home/twin/ai/voice/wall/screen/数据大屏/企业展示墙…) | 切换到指定页面 |
| `navigate_rel` | `delta: 1 \| -1` | 向右 / 向左循环切换页面 |
### 4.2 全局视觉识别
| action | params | 效果 |
|---|---|---|
| `vision_set` | `enabled: true \| false` | 开启 / 关闭全局人物识别(YOLO 人脸/姿态/手势) |
### 4.3 AI 助手页(/ai)
| action | params | 效果 |
|---|---|---|
| `ai_input` | `text: "介绍一下入驻政策"` | 输入问题并自动发送 |
| `ai_preset` | `index: 0 \| 1 \| 2` | 选择预设问题并发送 |
| `ai_company` | `action: "next" \| "prev"` | 切换展示企业 |
| `ai_zone` | `zone: "加速区" \| "国际区" \| "成长区"` | 切换企业分区 |
### 4.4 语音对话页(/voice)
| action | params | 效果 |
|---|---|---|
| `voice_start` | — | 启动语音对话 |
| `voice_stop` | — | 关闭语音对话 |
| `voice_refresh` | — | 刷新页面 |
### 4.5 媒体轮播页(/screen)
| action | params | 效果 |
|---|---|---|
| `play` / `pause` | — | 播放 / 暂停 |
| `next` / `prev` | — | 下一项 / 上一项 |
| `set_mode` | `mode: sequential \| shuffle \| loop` | 播放模式 |
| `play_target` | `path: "媒体路径"` | 播放指定媒体 |
| `settings_changed` | 设置对象 | 应用设置变更 |
| `playlist_changed` | — | 重新加载播放列表 |
### 4.6 企业展示墙页(/wall)
> 39 家入驻企业信息缓慢滚动展示墙(数据来源:《入驻企业信息表》)
| action | params | 效果 |
|---|---|---|
| `wall_pause` | — | 暂停滚动 |
| `wall_resume` | — | 继续滚动 |
| `wall_speed` | `speed: slow \| normal \| fast` | 滚动速度:慢速 150s / 标准 80s / 快速 40s 一圈 |
### 4.7 全局
| action | params | 效果 |
|---|---|---|
| `alert` | `text: "通知内容"` | 全局通知弹窗 |
| `show_card` | 卡片数据 | 展示信息卡片 |
| `minimize` | — | 最小化窗口(Tauri) |
## 5. 命令行发送
### mosquitto_pub(推荐,需安装 `mosquitto-clients`)
```bash
# 切页到 AI 助手
mosquitto_pub -h 192.168.1.3 -p 1883 -u <用户名> -P <密码> \
-t opc/display/command \
-m '{"action":"navigate","params":{"page":"/ai"}}'
# 向左切页
mosquitto_pub -h 192.168.1.3 -p 1883 -u <用户名> -P <密码> \
-t opc/display/command -m '{"action":"navigate_rel","params":{"delta":-1}}'
# 关闭人物识别
mosquitto_pub -h 192.168.1.3 -p 1883 -u <用户名> -P <密码> \
-t opc/display/command -m '{"action":"vision_set","params":{"enabled":false}}'
# AI 页提问
mosquitto_pub -h 192.168.1.3 -p 1883 -u <用户名> -P <密码> \
-t opc/display/command -m '{"action":"ai_input","params":{"text":"介绍一下入驻政策"}}'
# 切换企业分区
mosquitto_pub -h 192.168.1.3 -p 1883 -u <用户名> -P <密码> \
-t opc/display/command -m '{"action":"ai_zone","params":{"zone":"国际区"}}'
# 语音启动 / 媒体下一项
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 自带)
```bash
cd /Volumes/Pine/mycode/DPM/dpm/backend
.venv/bin/python - <<'EOF'
import paho.mqtt.client as mqtt
def send(action, params=None):
c = mqtt.Client()
c.username_pw_set("用户名", "密码")
c.connect("192.168.1.3", 1883, 30)
c.publish("opc/display/command", str({"action": action, "params": params or {}}))
c.disconnect()
send("navigate", {"page": "/voice"})
send("ai_company", {"action": "next"})
send("voice_stop")
EOF
```
### MQTTX(图形界面)
1. 新建连接:`mqtt://192.168.1.3:1883`,填用户名/密码
2. 发布到 `opc/display/command`,消息体如上 JSON
## 6. 订阅调试
```bash
# 监听前端页面上报(验证"当前页面")
mosquitto_sub -h 192.168.1.3 -p 1883 -u <用户名> -P <密码> -t 'opc/frontend/state'
# 监听心跳
mosquitto_sub -h 192.168.1.3 -p 1883 -u <用户名> -P <密码> -t 'opc/display/heartbeat'
# 监听全部指令
mosquitto_sub -h 192.168.1.3 -p 1883 -u <用户名> -P <密码> -t 'opc/display/command' -v
```
+168
View File
@@ -0,0 +1,168 @@
# DPM 实时语音对话整合方案(复用 live-avatar 云化栈)
> 目标:砍掉数字人/口型/wav2lip 等全部视觉部分,只保留「语音识别 + 对话 + 实时交互」链路;
> 前端在 DPM React 应用中新增对话页(复用 live-avatar demo 前端的样式与逻辑);
> 后端并入 DPM `backend/pyproject.toml`。
> 基线:live-avatar 云化栈已在 `/Volumes/Pine/mycode/opc/live` 跑通(s2s v0.2.11 + cloud-migration 补丁 + demo 前端)。
---
## 0. 现状对比
| | live-avatar(已跑通) | DPM(现状) |
|---|---|---|
| 前端 | 原生 JS demo(index.html / main.js / s2s-ws-client.js / 两个 worklet) | React 19 + Vite + Tailwind;`/ai` 页已有 `AiChatPanel`(文字 + **录音上传式**语音) |
| 后端 | s2s 独立进程 :8765(VAD 本地 + 云 ASR/LLM/TTS) | FastAPI :8000;已有 `app/asr.py`(dashscope paraformer)、`app/llm.py`(qwen + 工具)、`SSL_CERT_FILE=certifi` 同款处理 |
| 语音体验 | **实时全双工**:本地 VAD → 流式云 ASR → 流式 LLM → 流式 TTS → 音频回放 | 录音 → 上传 → 转写 → 文字回复(无流式、无 TTS 回音) |
**DPM 缺的正是 live-avatar 栈的实时交互能力**;两边的云凭据/CA 处理已一致(dashscope + certifi),合并成本低。
---
## 1. 两个关键决策点
### 1.1 后端:内嵌进 DPM FastAPI(推荐) vs 独立 s2s 进程
- **推荐:内嵌**。s2s v0.2.11 的 `api/openai_realtime/websocket_router.create_app(pool, stop_event)` 本身就返回一个 FastAPI 应用,
可在 DPM `app.main` 里构建管线池后 `app.mount("/v1", s2s_app)`,与现有 `/api/*` 共存、单进程单端口。
Tauri 桌面打包只需一个后端进程,无子进程管理。
- 备选:s2s 独立跑 :8765,DPM 负责拉起/守护。改动最小,但两进程两端口,打包与部署更重。
### 1.2 前端:iframe 内嵌(推荐 v1) vs React 组件移植(v2)
- **推荐 v1:iframe 内嵌**。把 live-avatar `demo/` 目录拷入 DPM `public/voice-demo/`(去掉数字人 iframe/视觉元素),
React 新增 `VoiceAssistant.jsx`(路由 `/voice`)里 `<iframe src="/voice-demo/">` 全屏。
**100% 复用现有样式与逻辑、零 React 改写、风险最低**;WS 地址由 `voice-demo/config.js` 按 `VITE_API_BASE` 派生。
- v2:React 组件移植。把 `s2s-ws-client.js` + 两个 worklet 拷入 `src/voice/`,用 hooks 驱动、JSX 重绘圆球/气泡,
与 DPM 布局/Tailwind 完全融合;工作量较大,建议先 v1 跑通再决定。
---
## 2. 后端合并清单
### 2.1 `backend/pyproject.toml`
```toml
dependencies = [
# ...现有依赖(fastapi/uvicorn/dashscope/certifi 已覆盖)...
# s2s 云化栈(path 依赖,指向补丁后的 s2s-cloud;或 vendored 到 backend/vendor/)
"speech-to-speech @ file:///Volumes/Pine/mycode/opc/live/s2s-cloud",
# s2s 运行所需(base 依赖里 Darwin 专用的 mlx/transformers 等不要全装)
"torch>=2.4", # VAD(silero) 本地必需;CPU 亦可
"numpy>=1.26",
"scipy>=1.10",
"httpx>=0.28",
"websockets>=12",
"nltk==3.9.4",
"openai==2.28.0",
"soundfile>=0.13",
]
```
> 安装用 `pip install -e "backend[voice]" --no-deps` + 手动补依赖,避免 s2s base 依赖把 mlx/transformers 等大件拖进来。
> `torch` 是唯一的大依赖(VAD 本地必需);若部署机无 GPU,CPU torch 跑 silero 足够。
### 2.2 配置(`app/config.py` + `backend/.env`)
```env
DASHSCOPE_API_KEY=sk-... # 已有(与 app/asr.py、app/llm.py 共用)
S2S_ENABLED=1
S2S_NUM_PIPELINES=4 # 并发会话数
S2S_WS_PATH=/v1/realtime
S2S_STT_MODEL=qwen3-asr-flash-realtime
S2S_LLM_MODEL=qwen-plus
S2S_TTS_MODEL=qwen3-tts-flash-realtime
S2S_TTS_VOICE=Cherry
```
### 2.3 代码(新 `app/s2s_bridge.py` + 挂载)
1. `s2s_bridge.py`:读取 Settings → 用与 `start_backend.sh` 等价的参数构建管线池
(`--stt dashscope-asr --llm_backend chat-completions --tts qwen3-cloud --enable_live_transcription false`),
返回 `websocket_router.create_app(pool, stop_event)`。
2. `app/main.py` lifespan:`ENABLED` 时启动池 → `app.mount(settings.S2S_WS_PATH, s2s_app)`;关闭时 stop。
3. 现有 `/api/ai/chat`、`/api/ai/asr` 保留(旧 AiChatPanel 流程不破坏)。
---
## 3. 前端合并清单
1. `src/pages/VoiceAssistant.jsx`(新):
- 全屏 `<iframe src="/voice-demo/" />`(v1),或未来 React 移植版。
- 加入 `App.jsx` 路由:`<Route path="/voice" element={<VoiceAssistant />} />`(建议放在 ScreenLayout 内或独立全屏)。
2. `public/voice-demo/`:拷贝 live-avatar `demo/`(index.html / main.js / style.css / ws/ / worklets/ / textchat.html),
并删除数字人 iframe(`index.html` 里的 `#avatar-frame` → 背景改纯色/园区图)。
3. `public/voice-demo/config.js`:暴露 `window.VOICE_WS_URL`(从 `VITE_API_BASE` 派生 `ws://<host>:<port>/v1/realtime`)。
4. 麦克风权限:
- 浏览器开发模式:`localhost` 直接可用。
- **Tauri v2 打包**:`src-tauri/tauri.conf.json` 需 `app.macOSPrivateApi: true`(macOS 麦克风)+ 系统隐私授权说明;
Windows 需在 manifest/权限描述中声明 microphone。
---
## 4. 实施步骤
| 步骤 | 内容 | 工作量 |
|---|---|---|
| P1 | 后端内嵌:pyproject 加依赖 → s2s_bridge.py → mount → 冒烟(WS 连得上、说话→回复→出声) | 0.5–1 天 |
| P2 | 前端:public/voice-demo 拷贝去视觉化 → VoiceAssistant.jsx + 路由 → WS 指向 DPM 后端 | 0.5 天 |
| P3 | 联调:DPM 页面语音对话全链路 + 并发验证 | 0.5 天 |
| P4(可选) | React 原生移植(hooks 驱动 client + worklets,Tailwind 重绘 UI) | 1–2 天 |
---
## 5. 风险与注意
- **torch 体积**:VAD 本地必需(s2s 管线结构固定 VAD 本地),CPU 版可接受;若部署机极简可评估 VAD 上云(不推荐,延迟差)。
- **单管线槽**:`S2S_NUM_PIPELINES` 默认 1 → 并发对话会拒连;按需调到 4。
- **WS 鉴权**:内网/桌面场景可无鉴权;如需,在 session.update 里带 token 校验。
- **iframe 限制**:v1 iframe 内样式隔离、无法用 DPM 主题;v2 移植可解决。
- **Tauri 麦克风**:打包前务必配置 macOSPrivateApi + 权限描述,否则桌面版无声/无权限。
---
## 附录 A · 视觉触发子系统(USB 摄像头 + 桌面端检测)
> 目标:大屏前 USB 摄像头拍观众;检测有人**正对且停留 ≥10 秒** → 主动问候 + 全屏联动;问候后**静默 60 秒**。
### A.1 架构与数据通路
```
大屏机(Tauri 应用,fullscreen)
└─ USB 摄像头(大屏前,拍观众)
└─ webview getUserMedia(一路视频流)
├─ 持续检测:MediaPipe FaceMesh 1~2fps(本机、不出设备)
│ ├─ 头姿 yaw/pitch 各 ±15° = 正对
│ └─ 连续 10s 正对 → 触发(窗口内 ≥80% 帧正对,防抖)
└─ 按需抓帧:LLM 决定时 canvas 取当前帧 → input_image → 云 qwen3-vl(人数/人物)
触发动作:
① 主动问候:client.sendUserText("检测到有人正对摄像头…请主动问候") + requestResponse()
→ s2s 走 LLM 生成问候语 → TTS 播报(零后端改动,复用现有 WS 协议)
② 全屏联动:调后端 → event_bus / mqtt.publish → 各屏响应
静默窗口:问候触发后 60s 冷却,期内检测照跑但不触发
```
### A.2 Tauri 摄像头权限(必须配置)
- **macOS**:`tauri.conf.json` → `app.macOSPrivateApi: true`;Info.plist 加
`NSCameraUsageDescription` / `NSMicrophoneUsageDescription`(麦克风语音对话也需要)。
- **Windows(NSIS 打包目标)**:WebView2 的 getUserMedia 跟随系统隐私设置
(设置 → 隐私和安全性 → 相机/麦克风 → 允许该应用);安装包建议在应用清单声明设备能力。
- 大屏机摄像头常开:确认系统隐私里该应用允许相机,否则检测与抓帧同时失效。
### A.3 隐私与合规
- 持续检测帧**仅在本机内存处理、即用即弃**,不上云;只有 LLM 决定抓的那一帧发往百炼 VLM。
- 大屏前采集观众人脸属个人信息处理 → 大屏旁需公示"影像识别用于互动服务"。
### A.4 落地清单(并入 P2/P3)
| 项 | 说明 |
|---|---|
| 前端新增 `useCameraDetect` hook | getUserMedia + MediaPipe FaceMesh + 状态机(IDLE→FACING→TRIGGER→SILENT) |
| 依赖 | `@mediapipe/tasks-vision`(face_detector + face_landmarker) |
| 参数化 | 停留 10s / 静默 60s / 角度 ±15° / 频率 1~2fps → 配置文件或设置页 |
| 触发动作 | 主动问候(sendUserText+create)+ MQTT 联动(后端 API) |
| 首对话抓帧 | 与检测共用同一路视频流,canvas 取帧 → input_image → qwen3-vl |
> 注:视觉子系统是**可选增强**,不影响主语音对话方案;摄像头/检测不可用时语音链路照常工作。
+78
View File
@@ -0,0 +1,78 @@
# Windows 打包部署指南(免重打包改地址)
## 一、打包后在 exe 同目录放置 config.json(推荐)
打包安装后,在 exe 所在目录(如 `C:\Program Files\云超服昆创园OPC运营中心\`)新建 **`config.json`**,
改地址**不需要重新打包**,重启应用即生效:
```json
{
"api_base": "http://192.168.1.9:10085",
"mqtt_url": "ws://192.168.1.3:8083/mqtt",
"mqtt_username": "dpm",
"mqtt_password": "123456",
"voice_url": "ws://192.168.1.3:8765/v1/realtime"
}
```
- `api_base`:FastAPI 后端地址(API / 管理后台 / 媒体)
- `mqtt_url`:EMQX Broker 的 **WebSocket** 地址(端口 8083)
- `mqtt_username/password`:Broker 鉴权账号(默认 dpm / 123456,与后端 .env 一致)
地址解析优先级:**config.json > 后端 /api/config > 构建默认(192.168.1.9)**。
## 二、后端 /api/config 方式(同机部署自动生效)
后端 `.env`(backend/.env)配置 Broker 后,展播端启动时自动从
`GET /api/config` 获取 MQTT 地址,**无需 config.json**:
```
DPM_MQTT_HOST=192.168.1.3
DPM_MQTT_PORT=1883
DPM_MQTT_WS=ws://192.168.1.3:8083/mqtt # 展播端可访问的 WebSocket 地址
DPM_VOICE_WS=ws://192.168.1.3:8765/v1/realtime # s2s 语音 WS(可选,默认按主机推导)
MQTT_USERNAME=dpmserver
MQTT_PASSWORD=你的密码
```
> 若后端与 Broker 同机:后端默认 `MQTT_WS_URL=ws://localhost:8083/mqtt` 即可,
> 展播端连 `192.168.1.9` 也通;跨机必须显式配置上面的 IP。
## 三、构建期固定地址(不推荐,需重新打包)
在项目根目录(**构建机器上**)配置 `.env.local`(已被 gitignore):
```
VITE_API_BASE=http://192.168.1.9:10085
VITE_MQTT_URL=ws://192.168.1.3:8083/mqtt
VITE_MQTT_USERNAME=dpm
VITE_MQTT_PASSWORD=123456
```
然后 `yarn build:win`(脚本自动清理旧 exe 进程再打包)。
## 四、打包前必须确认
| 检查项 | 说明 |
|---|---|
| exe 未被运行 | `taskkill /f /im "昆明大学生创业园展播系统.exe"`,或直接用 `yarn build:win` |
| 后端已启动 | 浏览器打开 `http://<后端IP>:10085/api/health` 应返回 `{"ok":true,...}` |
| Broker WebSocket 已开 | EMQX 需开启 8083 端口 WS 监听,且账号密码与 config.json 一致 |
| Windows 防火墙 | 首次运行允许,或放行出站 10085/8083(管理端上传用 10085) |
## 五、常见排查
```
# 测试后端连通(展播机 PowerShell)
Invoke-WebRequest http://192.168.1.9:10085/api/health
# 测试 Broker WS 端口
Test-NetConnection 192.168.1.3 -Port 8083
# 看前端启动日志(bootstrap 会打印 MQTT 地址来源)
打开应用后按 F12(WebView2 开发者工具)→ Console
```
- 应用日志打印 `[bootstrap] config.json MQTT -> ...` = config.json 生效
- 打印 `[bootstrap] /api/config MQTT -> ...` = 后端下发
- 两者都没有 = 两个来源都不可达,请检查 IP/防火墙
+40
View File
@@ -0,0 +1,40 @@
@echo off
REM ============================================================
REM Windows 一键打包脚本(Tauri)
REM 解决「error: failed to remove file ... .exe / 拒绝访问(os error 5)」:
REM 本应用关闭窗口=最小化(进程常驻),旧 exe 会被运行进程锁定无法覆盖。
REM 打包前自动结束占用进程,再执行 tauri build。
REM
REM 用法:双击运行,或在项目根目录执行 scripts\build-win.cmd
REM ============================================================
chcp 65001 >nul
setlocal
echo.
echo [1/3] 结束占用旧 exe 的进程(无则跳过)...
taskkill /f /im "昆明大学生创业园展播系统.exe" >nul 2>&1
taskkill /f /im "云超服昆创园OPC运营中心.exe" >nul 2>&1
taskkill /f /im "dpm.exe" >nul 2>&1
echo 已尝试清理。
echo.
echo [2/3] 清理 Rust 增量产物中的旧 exe 残留(可选,失败不影响)...
if exist "src-tauri\target\release\昆明大学生创业园展播系统.exe" (
del /f /q "src-tauri\target\release\昆明大学生创业园展播系统.exe" >nul 2>&1
)
echo.
echo [3/3] 开始 Tauri 打包(yarn tauri build)...
call yarn tauri build
if errorlevel 1 (
echo.
echo [错误] 打包失败。若仍报「拒绝访问」,请:
echo 1. 确认任务管理器中没有 dpm/昆明大学生创业园 相关进程
echo 2. 将 D:\MyCode\DPM\src-tauri\target 加入杀毒软件排除目录
echo 3. 关闭杀毒实时防护后重试
exit /b 1
)
echo.
echo [完成] 打包成功!安装包位于 src-tauri\target\release\bundle\
endlocal
+119
View File
@@ -0,0 +1,119 @@
#!/usr/bin/env python3
"""
按像素精确解析 PNG 平面图 —— 精确颜色分类 + 高分辨率网格化
颜色规则(依据实际采样校准):
W 黑色(0,0,0) = 墙壁
O 灰色(176,176,176) = 办公室
P 蓝色(64,176,240) = 过道
R 绿色(80,176,96) = 休息厅
Y 橙色(240,112,16) = 共享工位区 / 楼道
C 青绿(80,224,192) = 玻璃顶
. 白色(240,240,240) = 空白/底
"""
import zlib, struct, sys, collections
def read_png(path):
with open(path, 'rb') as f:
data = f.read()
assert data[:8] == b'\x89PNG\r\n\x1a\n', 'Not a PNG'
pos = 8
width = height = None
bit_depth = color_type = None
idat = b''
while pos < len(data):
ln = struct.unpack('>I', data[pos:pos+4])[0]
typ = data[pos+4:pos+8]
chunk = data[pos+8:pos+8+ln]
pos += 12 + ln
if typ == b'IHDR':
width, height, bit_depth, color_type, _, _, _ = struct.unpack('>IIBBBBB', chunk)
elif typ == b'IDAT':
idat += chunk
elif typ == b'IEND':
break
assert bit_depth == 8 and color_type in (2, 6) and width and height
raw = zlib.decompress(idat)
channels = 4 if color_type == 6 else 3
bpp = channels
stride = width * bpp
rows = []
off = 0
prev = bytearray(stride)
for y in range(height):
ft = raw[off]; off += 1
line = bytearray(raw[off:off+stride]); off += stride
if ft == 1:
for i in range(bpp, stride): line[i] = (line[i] + line[i-bpp]) & 0xFF
elif ft == 2:
for i in range(stride): line[i] = (line[i] + prev[i]) & 0xFF
elif ft == 3:
for i in range(stride):
a = line[i-bpp] if i >= bpp else 0
line[i] = (line[i] + ((a + prev[i]) >> 1)) & 0xFF
elif ft == 4:
for i in range(stride):
a = line[i-bpp] if i >= bpp else 0
b = prev[i]
c = prev[i-bpp] if i >= bpp else 0
p = a + b - c
pa, pb, pc = abs(p-a), abs(p-b), abs(p-c)
pr = a if (pa <= pb and pa <= pc) else (b if pb <= pc else c)
line[i] = (line[i] + pr) & 0xFF
rows.append(bytes(line))
prev = line
rgba = []
for line in rows:
if channels == 4:
rgba.append([(line[i], line[i+1], line[i+2], line[i+3]) for i in range(0, stride, 4)])
else:
rgba.append([(line[i], line[i+1], line[i+2], 255) for i in range(0, stride, 3)])
return width, height, rgba
# 精确色板(中心色 + 容差)
PALETTE = [
('W', (0, 0, 0), 70), # 黑 = 墙壁
('O', (176, 176, 176), 40), # 灰 = 办公室
('P', (64, 176, 240), 45), # 蓝 = 过道
('R', (80, 176, 96), 40), # 绿 = 休息厅
('Y', (240, 112, 16), 55), # 橙 = 共享工位/楼道
('C', (80, 224, 192), 55), # 青绿 = 玻璃顶
('.', (240, 240, 240), 40), # 白 = 空白
]
def classify(rgb):
r, g, b = rgb[:3]
best, best_d = '?', 1e9
for tag, (cr, cg, cb), tol in PALETTE:
d = abs(r-cr) + abs(g-cg) + abs(b-cb)
if d <= tol * 3 and d < best_d:
best, best_d = tag, d
return best
def main(path, grid_cols=48, grid_rows=28, sub=3):
w, h, rgba = read_png(path)
print(f'PNG: {w}x{h} 网格: {grid_cols}x{grid_rows}')
cw, ch = w / grid_cols, h / grid_rows
grid = []
for gy in range(grid_rows):
row = []
for gx in range(grid_cols):
cx = int((gx + 0.5) * cw)
cy = int((gy + 0.5) * ch)
votes = collections.Counter()
rad_x, rad_y = max(2, int(cw*0.3)), max(2, int(ch*0.3))
for dy in range(-rad_y, rad_y+1, sub):
for dx in range(-rad_x, rad_x+1, sub):
yy, xx = cy+dy, cx+dx
if 0 <= yy < h and 0 <= xx < w:
votes[classify(rgba[yy][xx])] += 1
row.append(votes.most_common(1)[0][0])
grid.append(row)
# 打印
print(' ' + ''.join(str(c % 10) for c in range(grid_cols)))
for gy, row in enumerate(grid):
print(f'{gy:2d} ' + ''.join(row))
return grid
if __name__ == '__main__':
path = sys.argv[1] if len(sys.argv) > 1 else '详细的布局.png'
main(path)