若该文为原创文章,转载请注明原文出处。
一、引言
在边缘AI应用中,RK3568等NPU平台凭借低功耗和高算力性价比备受青睐。然而,开发调试、模型迭代和功能扩展往往在PC端更为便捷。本文将分享一套完整的道桥安全多模态监测预警平台 从RK3568(RKNN NPU)移植到PC端(PyTorch + YOLOv8n.pt)的实践方案,并附带全栈代码(后端Flask + 前端实时监控看板)。无论你是想快速在PC上复现项目,还是想以此为基础进行二次开发,本文都可供参考。
二、项目原始架构
原始系统运行在RK3568开发板上,主要组件:
-
推理引擎 :
rknnlite,加载yolov8.rknn量化模型。 -
摄像头:USB或CSI摄像头,分辨率640×480。
-
主程序 :
main.py,使用线程池(rknnpool.py)异步推理,每10帧上传结果至云端服务器。 -
后端服务 :
server.py(Flask),接收图片和置信度,存储为cam.jpg和conf.txt。 -
前端监控 :
index.html(V3/V4版本),通过轮询静态资源实时更新画面和预警状态。
三、移植目标与改动概览
我们的目标是在PC(Windows/Linux,有或无GPU)上运行相同功能,使用官方yolov8n.pt权重,推理框架采用Ultralytics YOLO。需要修改的核心文件:
| 文件 | 改动内容 |
|---|---|
func.py |
替换RKNN推理为model.predict(),保留绘图逻辑。 |
rknnpool.py |
移除RKNN依赖,改为加载YOLO模型实例,保持线程池设计。 |
main.py |
仅更改模型文件名和摄像头索引(通常为0)。 |
server.py |
无需改动(若需本地访问,调整UPLOAD_FOLDER为相对路径)。 |
templates/index.html |
使用V3版本,无需修改。 |
所有代码和目录结构见下文。
四、环境准备
4.1 安装依赖
创建虚拟环境(推荐),安装以下包:
pip install torch ultralytics opencv-python numpy requests flask
如需GPU加速,请安装对应CUDA版本的PyTorch。
4.2 下载权重文件
从官方仓库下载yolov8n.pt,放置于项目根目录:
wget https://github.com/ultralytics/assets/releases/download/v0.0.0/yolov8n.pt
五、项目目录结构
project/
├── main.py # 主程序(摄像头/视频流推理与上传)
├── func.py # 推理函数(YOLO模型调用 + 绘图)
├── rknnpool.py # 线程池与模型管理(适配PC)
├── server.py # Flask后端(上传接口、静态资源)
├── templates/
│ └── index.html # 前端监控面板(完整版)
├── static/ # 自动创建,存放cam.jpg和conf.txt
├── yolov8n.pt # 权重文件
└── requirements.txt # 依赖列表
六、核心代码详解
6.1 func.py -- 推理与绘图
import cv2
import numpy as np
from ultralytics import YOLO
OBJ_THRESH, NMS_THRESH, IMG_SIZE = 0.25, 0.45, 640
CLASSES = ("person", "bicycle", "car", ... ) # 80类COCO
def draw(image, boxes, scores, classes, ratio, padding):
for box, score, cl in zip(boxes, scores, classes):
top = int((box[0] - padding[0]) / ratio[0])
left = int((box[1] - padding[1]) / ratio[1])
right = int((box[2] - padding[0]) / ratio[0])
bottom = int((box[3] - padding[1]) / ratio[1])
cv2.rectangle(image, (top, left), (right, bottom), (0, 0, 255), 2)
cv2.putText(image, f'{CLASSES[cl]} {score:.2f}', (top, left - 6),
cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 255), 2)
def myFunc(model, IMG):
"""
PC端推理函数
model: YOLO实例
IMG: BGR图像 (numpy array)
返回: 绘制后的图像, 最大置信度
"""
results = model(IMG, imgsz=IMG_SIZE, verbose=False)
boxes, scores, classes = None, None, None
if results and results[0].boxes is not None:
boxes = results[0].boxes.xyxy.cpu().numpy()
scores = results[0].boxes.conf.cpu().numpy()
classes = results[0].boxes.cls.cpu().numpy().astype(int)
max_conf = 0.0
if boxes is not None and len(boxes) > 0:
# 坐标已在原始图像上,ratio=1, padding=0
draw(IMG, boxes, scores, classes, (1, 1), (0, 0))
max_conf = float(np.max(scores))
return IMG, max_conf
关键点:
-
不再手动进行
letterbox和坐标反算,Ultralytics内部已处理。 -
模型输出直接为
xyxy格式,且坐标对应原始图像尺寸。
6.2 rknnpool.py -- 线程池与模型管理
from queue import Queue
from concurrent.futures import ThreadPoolExecutor
from ultralytics import YOLO
def initModel(model_path, device='cpu'):
return YOLO(model_path)
def initModels(model_path, TPEs=1, device='cpu'):
# 每个线程独立模型实例,避免GIL冲突
return [initModel(model_path, device) for _ in range(TPEs)]
class rknnPoolExecutor:
def __init__(self, rknnModel, TPEs, func, device='cpu'):
self.queue = Queue()
self.modelPool = initModels(rknnModel, TPEs, device)
self.pool = ThreadPoolExecutor(max_workers=TPEs)
self.func = func
self.num = 0
def put(self, frame):
self.queue.put(self.pool.submit(self.func, self.modelPool[self.num % len(self.modelPool)], frame))
self.num += 1
def get(self):
if self.queue.empty():
return None, None, False
result = self.queue.get().result()
if isinstance(result, tuple):
return result[0], result[1], True
return result, 0.0, True
def release(self):
self.pool.shutdown()
说明:
-
为每个线程创建独立
YOLO模型实例,确保线程安全。 -
支持指定
device='cuda'以启用GPU加速。
6.3 main.py -- 主程序
import cv2, time, requests, threading
from rknnpool import rknnPoolExecutor
from func import myFunc
UPLOAD_URL = "http://127.0.0.1:8000/upload"
UPLOAD_INTERVAL = 10
def upload_frame(frame, url, frame_id, conf_val):
try:
_, enc = cv2.imencode('.jpg', frame, [cv2.IMWRITE_JPEG_QUALITY, 40])
r = requests.post(url, files={'file': ('cam.jpg', enc.tobytes(), 'image/jpeg')},
data={'conf': str(conf_val)}, timeout=10,
headers={'Connection': 'close'})
if r.status_code == 200:
print(f"[上传] 帧{frame_id} 成功, 置信度: {conf_val:.2f}")
except Exception as e:
print(f"[上传] 帧{frame_id} 失败: {e}")
cap = cv2.VideoCapture(0) # PC默认摄像头
cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640)
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480)
pool = rknnPoolExecutor(rknnModel="yolov8n.pt", TPEs=1, func=myFunc, device='cpu')
if cap.isOpened():
for _ in range(2):
ret, frame = cap.read()
if ret: pool.put(frame)
cv2.namedWindow('yolov8', cv2.WINDOW_NORMAL)
cv2.resizeWindow('yolov8', 1024, 600)
frames, loopTime, initTime = 0, time.time(), time.time()
while cap.isOpened():
frames += 1
ret, frame = cap.read()
if not ret: break
pool.put(frame)
frame, conf, flag = pool.get()
if not flag or frame is None: break
if frames % UPLOAD_INTERVAL == 0:
threading.Thread(target=upload_frame, args=(frame.copy(), UPLOAD_URL, frames, conf), daemon=True).start()
cv2.imshow('yolov8', frame)
if cv2.waitKey(1) & 0xFF == ord('q'): break
if frames % 30 == 0:
print("30帧平均帧率:\t", 30 / (time.time() - loopTime), "帧")
loopTime = time.time()
print("总平均帧率\t", frames / (time.time() - initTime))
cap.release()
cv2.destroyAllWindows()
pool.release()
注意 :UPLOAD_URL指向本地Flask服务(127.0.0.1:8000),若部署到远程请修改IP。
6.4 server.py -- Flask后端
from flask import Flask, send_file, request, send_from_directory
import os
app = Flask(__name__)
UPLOAD_FOLDER = os.path.join(os.path.dirname(__file__), "static")
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
conf_path = os.path.join(UPLOAD_FOLDER, "conf.txt")
if not os.path.exists(conf_path):
with open(conf_path, "w") as f:
f.write("0.0")
@app.route("/upload", methods=["POST"])
def upload():
if 'file' not in request.files:
return "no file", 400
file = request.files['file']
file.save(os.path.join(UPLOAD_FOLDER, "cam.jpg"))
conf = request.form.get('conf', '0.0')
with open(os.path.join(UPLOAD_FOLDER, "conf.txt"), "w") as f:
f.write(conf)
return "ok"
@app.route("/static/cam.jpg")
def get_img():
path = os.path.join(UPLOAD_FOLDER, "cam.jpg")
if os.path.exists(path):
return send_file(path)
return "no image", 404
@app.route("/static/conf.txt")
def get_conf():
path = os.path.join(UPLOAD_FOLDER, "conf.txt")
if os.path.exists(path):
with open(path) as f:
return f.read()
return "0.0"
@app.route("/")
def index():
return send_from_directory("templates", "index.html")
if __name__ == "__main__":
app.run(host="0.0.0.0", port=8000, debug=False)
6.5 templates/index.html -- 前端监控面板
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>道桥安全多模态监测预警平台</title>
<style>
:root{
--bg:#eceff3; --panel:#ffffff; --panel-2:#f4f6f9;
--border:#e2e7ec; --border-2:#d4dbe3;
--text:#2a3340; --muted:#6c7886; --dim:#9aa6b3;
--accent:#4f6d8c; --accent-soft:#eef2f6;
--ok:#3f9a63; --ok-bg:#e9f3ec; --ok-bd:#bfe0cb; --ok-t:#2c7a49;
--warn:#c08a2e; --warn-bg:#f6efe0; --warn-bd:#e7d3a6; --warn-t:#956414;
--alarm:#c1504a; --alarm-bg:#f7e9e8; --alarm-bd:#e9c3c0; --alarm-t:#a23a35;
--mono:ui-monospace,"SF Mono","JetBrains Mono","Roboto Mono",Menlo,Consolas,monospace;
--sans:"PingFang SC","Microsoft YaHei","Noto Sans SC","Helvetica Neue",system-ui,sans-serif;
--sh:0 1px 2px rgba(40,55,75,.04),0 2px 8px rgba(40,55,75,.04);
}
*{box-sizing:border-box;margin:0;padding:0}
html,body{background:var(--bg);color:var(--text);font-family:var(--sans);-webkit-font-smoothing:antialiased}
body{min-height:100vh}
.wrap{max-width:1320px;margin:0 auto;padding:20px 24px 30px}
header{display:flex;align-items:center;gap:16px;padding:4px 2px 20px;flex-wrap:wrap}
.brand{display:flex;align-items:center;gap:13px;min-width:0}
.logo{width:42px;height:42px;border-radius:11px;background:linear-gradient(150deg,#5b7c9d,#3c5a78);display:flex;align-items:center;justify-content:center;flex:0 0 auto}
.logo svg{width:25px;height:25px}
.titles h1{font-size:19px;font-weight:600;letter-spacing:.2px;line-height:1.2;color:#222c38}
.titles .sub{font-size:12.5px;color:var(--muted);margin-top:3px}
.hdr-right{margin-left:auto;display:flex;align-items:center;gap:18px;flex-wrap:wrap}
.loc{font-size:13px;color:var(--muted)}
.loc b{color:var(--text);font-weight:600}
.clock{font-family:var(--mono);font-size:15px;letter-spacing:.4px;color:var(--text)}
.clock .date{color:var(--dim);font-size:12px;margin-right:8px}
.online{display:flex;align-items:center;gap:7px;font-size:13px;color:var(--ok-t);background:var(--ok-bg);border:1px solid var(--ok-bd);padding:5px 12px;border-radius:20px}
.dot{width:8px;height:8px;border-radius:50%;background:var(--ok);animation:pulse 2.6s infinite}
@keyframes pulse{0%{box-shadow:0 0 0 0 rgba(63,154,99,.4)}70%{box-shadow:0 0 0 6px rgba(63,154,99,0)}100%{box-shadow:0 0 0 0 rgba(63,154,99,0)}}
.kpis{display:grid;grid-template-columns:repeat(4,1fr);gap:16px;margin-bottom:16px}
.kpi{background:var(--panel);border:1px solid var(--border);border-radius:14px;padding:16px 18px;box-shadow:var(--sh)}
.kpi .k-lab{font-size:12.5px;color:var(--muted);display:flex;align-items:center;gap:7}
.kpi .k-val{font-family:var(--mono);font-size:29px;font-weight:600;margin-top:10px;line-height:1;letter-spacing:.3px;color:#222c38}
.kpi .k-sub{font-size:12px;color:var(--dim);margin-top:9px}
.kpi .bar{height:6px;border-radius:4px;background:#e7ebf0;margin-top:11px;overflow:hidden}
.kpi .bar i{display:block;height:100%;border-radius:4px;background:linear-gradient(90deg,#3f9a63,#67c089)}
.statuschip{display:inline-flex;align-items:center;gap:9px;font-size:22px;font-weight:600;margin-top:8px}
.statuschip .sd{width:13px;height:13px;border-radius:50%}
#kpi-status{display:flex;flex-direction:column}
#kpi-status .statuschip{align-self:center}
.kpi.alarm-on{border-color:var(--alarm-bd);background:linear-gradient(180deg,#fcf3f2,#ffffff)}
.kpi.alarm-on .ring{animation:ring 1.5s infinite}
@keyframes ring{0%{box-shadow:0 0 0 0 rgba(193,80,74,.35)}70%{box-shadow:0 0 0 8px rgba(193,80,74,0)}100%{box-shadow:0 0 0 0 rgba(193,80,74,0)}}
.layout{display:grid;grid-template-columns:1.55fr 1fr;gap:16px}
.col{display:flex;flex-direction:column;gap:16px}
.card{background:var(--panel);border:1px solid var(--border);border-radius:14px;overflow:hidden;box-shadow:var(--sh)}
.card-h{display:flex;align-items:center;gap:9px;padding:13px 16px;border-bottom:1px solid var(--border);font-size:14px;font-weight:600;color:#2a3340}
.card-h .ic{color:var(--accent);display:flex}
.tag{margin-left:auto;font-family:var(--mono);font-size:14px;font-weight:600;color:#2d5078;background:#eef2f7;border:1px solid var(--accent);padding:5px 12px;border-radius:9px;letter-spacing:0.3px}
.card-b{padding:0}
.cam{position:relative;aspect-ratio:16/9;background:#cfd6dd;overflow:hidden}
#show-img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}
.cam .grade{position:absolute;inset:0;background:linear-gradient(180deg,rgba(20,28,38,.28),transparent 22%,transparent 70%,rgba(20,28,38,.34))}
.bbox{position:absolute;left:39%;top:6%;width:38%;height:88%;border:2px solid var(--alarm);border-radius:2px;opacity:0;transform:scale(1.06);transition:opacity .35s,transform .35s}
.cam.found .bbox{opacity:1;transform:scale(1)}
.bbox .blab{position:absolute;top:-22px;left:-2px;background:var(--alarm);color:#fff;font-family:var(--mono);font-size:11.5px;padding:2px 7px;border-radius:4px 4px 4px 0;white-space:nowrap}
.hud{position:absolute;font-size:11.5px;font-family:var(--mono);color:#f0f4f8;text-shadow:0 1px 4px rgba(0,0,0,.55);z-index:3}
.hud.tl{top:11px;left:13px}
.hud.tr{top:11px;right:13px;display:flex;align-items:center;gap:6px}
.hud.bl{bottom:11px;left:13px;color:#dce8f1}
.hud.br{bottom:11px;right:13px}
.rec{width:9px;height:9px;border-radius:50%;background:var(--alarm);animation:blink 1.5s infinite}
@keyframes blink{0%,100%{opacity:1}50%{opacity:.3}}
.det-state{padding:3px 10px;border-radius:6px;background:rgba(63,154,99,.85);color:#fff}
.det-state.found{background:rgba(193,80,74,.92)}
.strain-head{display:flex;align-items:baseline;gap:14px;padding:0 0 12px}
.strain-head .now{font-family:var(--mono);font-size:31px;font-weight:600;letter-spacing:.3px;color:#222c38}
.strain-head .unit{color:var(--dim);font-size:13px}
.strain-head .ch-lvl{margin-left:auto;font-size:13px;color:var(--muted)}
.chart-box{position:relative;height:188px}
canvas{display:block;width:100%;height:100%}
.legend{display:flex;gap:18px;margin-top:11px;font-size:12px;color:var(--muted);flex-wrap:wrap}
.legend span{display:flex;align-items:center;gap:6px}
.legend i{width:16px;height:0;border-top:2px dashed currentColor;display:inline-block}
.fusion{display:flex;align-items:center;justify-content:space-between;gap:6px}
.fnode{flex:1;text-align:center}
.fnode .fl{font-size:12px;color:var(--muted);margin-bottom:8px}
.pill{display:inline-flex;align-items:center;gap:7px;font-size:13.5px;font-weight:600;padding:7px 12px;border-radius:9px;border:1px solid;min-width:72px;justify-content:center}
.pill .pd{width:9px;height:9px;border-radius:50%}
.lv-ok{background:var(--ok-bg);border-color:var(--ok-bd);color:var(--ok-t)} .lv-ok .pd{background:var(--ok)}
.lv-warn{background:var(--warn-bg);border-color:var(--warn-bd);color:var(--warn-t)} .lv-warn .pd{background:var(--warn)}
.lv-alarm{background:var(--alarm-bg);border-color:var(--alarm-bd);color:var(--alarm-t)} .lv-alarm .pd{background:var(--alarm)}
.op{font-size:17px;color:var(--dim);padding-top:18px}
.arrow{font-size:19px;color:var(--accent);padding-top:16px}
.alert-empty{display:flex;align-items:center;gap:10px;color:var(--muted);font-size:13.5px;padding:6px 2px}
.alert-empty .ed{width:9px;height:9px;border-radius:50%;background:var(--ok)}
.alert-card{display:none}
.alert-card.show{display:block}
.alert-top{display:flex;align-items:center;gap:11px;margin-bottom:13px}
.lvl-badge{font-size:15px;font-weight:600;padding:6px 13px;border-radius:9px}
.alert-rows{display:grid;grid-template-columns:auto 1fr;gap:9px 16px;font-size:13px}
.alert-rows .lk{color:var(--muted)}
.alert-rows .vv{color:var(--text);font-family:var(--mono)}
.pwr{display:grid;grid-template-columns:repeat(2,1fr);gap:12px}
.pwr .cell{background:var(--panel-2);border:1px solid var(--border);border-radius:10px;padding:11px 13px}
.pwr .cl{font-size:11.5px;color:var(--muted);display:flex;align-items:center;gap:6px}
.pwr .cv{font-family:var(--mono);font-size:19px;font-weight:600;margin-top:6px;color:#222c38}
.pwr .cv small{font-size:12px;color:var(--dim);font-weight:400}
.flow{display:flex;align-items:center;justify-content:center;gap:9px;margin-top:13px;font-size:12px;color:var(--muted);flex-wrap:wrap}
.flow .fchip{display:flex;align-items:center;gap:6px;background:var(--panel-2);border:1px solid var(--border);border-radius:8px;padding:6px 10px}
.flow .ar{color:var(--accent)}
.solar{color:var(--warn-t)}
.hist{margin-top:16px}
table{width:100%;border-collapse:collapse;font-size:13px}
thead th{text-align:left;color:var(--muted);font-weight:500;font-size:12px;padding:10px 14px;border-bottom:1px solid var(--border);white-space:nowrap}
tbody td{padding:11px 14px;border-bottom:1px solid #eef1f4;color:var(--text)}
tbody tr:last-child td{border-bottom:none}
tbody tr.new td{animation:flashrow 1.5s ease-out}
@keyframes flashrow{0%{background:rgba(79,109,140,.1)}100%{background:transparent}}
td.time{font-family:var(--mono);color:var(--muted);white-space:nowrap}
.lpill{display:inline-flex;align-items:center;gap:6px;font-size:12px;font-weight:600;padding:3px 9px;border-radius:7px}
.lpill .pd{width:8px;height:8px;border-radius:50%}
.st-done{color:var(--ok-t)} .st-pending{color:var(--alarm-t)}
footer{margin-top:20px;display:flex;align-items:center;gap:10px 22px;flex-wrap:wrap;font-size:12px;color:var(--dim);border-top:1px solid var(--border);padding-top:16px}
footer .ft{display:flex;align-items:center;gap:7px}
footer .ft b{color:var(--muted);font-weight:500}
@media (max-width:1080px){.layout{grid-template-columns:1fr}.kpis{grid-template-columns:repeat(2,1fr)}}
@media (prefers-reduced-motion:reduce){*{animation:none!important}}
</style>
</head>
<body>
<div class="wrap">
<header>
<div class="brand">
<div class="logo"><svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="1.7" stroke-linecap="round"><path d="M2 16h20"/><path d="M4 16v-3"/><path d="M20 16v-3"/><path d="M2 13c3.5 0 4-5 10-5s6.5 5 10 5"/><path d="M8 13v3M12 11v5M16 13v3"/></svg></div>
<div class="titles"><h1>道桥安全多模态监测预警平台</h1><div class="sub">边缘智能 · 光储混合供能 · 图像---应变多模态融合</div></div>
</div>
<div class="hdr-right">
<div class="loc">监测点 · <b>毕节·山区跨谷桥</b></div>
<div class="clock"><span class="date" id="date">---</span><span id="time">---</span></div>
<div class="online"><span class="dot"></span>设备在线 · 4G</div>
</div>
</header>
<div class="kpis">
<div class="kpi" id="kpi-status">
<div class="k-lab"><span class="ring" id="kpi-dot" style="width:8px;height:8px;border-radius:50%;background:var(--ok)"></span>预警状态</div>
<div class="statuschip"><span class="sd" id="st-sd" style="background:var(--ok)"></span><span id="st-text" style="color:var(--ok-t)">正常</span></div>
</div>
<div class="kpi">
<div class="k-lab">锂电池电量(12V/3A·h)</div>
<div class="k-val">89<small style="font-size:16px">%</small></div>
<div class="bar"><i style="width:86%"></i></div>
</div>
<div class="kpi">
<div class="k-lab">供电来源</div>
<div class="k-val" style="font-size:21px">☀ 光伏供电</div>
<div class="k-sub">MPPT 充电中</div>
</div>
<div class="kpi">
<div class="k-lab">系统负荷 / 通信</div>
<div class="k-val"><span id="sys-load-val">9.8</span><small style="font-size:15px"> W</small></div>
<div class="k-sub">RK3568 按需上电 · 4G 信号良好</div>
</div>
</div>
<div class="layout">
<div class="col">
<div class="card">
<div class="card-h"><span class="ic"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="2" y="6" width="14" height="12" rx="2"/><path d="M16 10l6-3v10l-6-3"/></svg></span>实时监控画面<span class="tag">YOLOv8n · RK3568 NPU</span></div>
<div class="card-b">
<div class="cam" id="cam">
<img id="show-img" alt="桥梁监控画面">
<div class="grade"></div>
<div class="hud tr"><span class="rec"></span><span id="cam-time">--:--:--</span></div>
<div class="hud bl">AI 推理:YOLOv8n · INT8 · 端侧 NPU</div>
<div class="hud br"><span class="det-state" id="det-state">未检出裂缝</span><span style="margin-left:8px;font-family:var(--mono);color:#fff;background:rgba(0,0,0,0.6);padding:3px 8px;border-radius:5px">置信度: <span id="conf-value">0.00</span></span></div>
</div>
</div>
</div>
<div class="card">
<div class="card-h"><span class="ic"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M3 16l4-6 4 4 4-9 4 7 2-2"/></svg>实时应变监测<span class="tag">分布式应变传感网络</span></div>
<div class="card-b">
<div class="strain-head">
<div><span class="now" id="strain-now">---</span> <span class="unit">με(微应变)</span></div>
<div class="ch-lvl">应变通道:<span class="lpill lv-ok" id="strain-lvl"><span class="pd"></span>正常</span></div>
</div>
<div class="chart-box"><canvas id="chart"></canvas></div>
<div class="legend">
<span style="color:#8a96a3"><i></i>安全阈值 600</span>
<span style="color:var(--warn-t)"><i></i>预警阈值 720</span>
<span style="color:var(--alarm-t)"><i></i>报警阈值 1000</span>
</div>
</div>
</div>
</div>
<div class="col">
<div class="card">
<div class="card-h"><span class="ic"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M7 8a4 4 0 1 0 0 8M17 8a4 0 1 1 0 8"/><path d="M11 12h2"/></svg>多模态融合判别</div>
<div class="card-b">
<div class="fusion">
<div class="fnode"><div class="fl">图像通道</div><span class="pill lv-ok" id="f-img"><span class="pd"></span>正常</span></div>
<div class="op">⊕</div>
<div class="fnode"><div class="fl">应变通道</div><span class="pill lv-ok" id="f-str"><span class="pd"></span>-</span></div>
<div class="arrow">→</div>
<div class="fnode"><div class="fl">综合预警</div><span class="pill lv-ok" id="f-out"><span class="pd"></span>-</span></div>
</div>
</div>
</div>
<div class="card">
<div class="card-h"><span class="ic"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M12 9v4M12 17h.01M10.3 4.3 2.8 17a1.5 1.5 0 0 0 1.3 2.3h15.8a1.5 1.5 0 0 0 1.3-2.3L13.7 4.3a1.5 1.5 0 0 0-2.6 0z"/></svg>当前预警</div>
<div class="card-b">
<div class="alert-empty" id="alert-empty"><span class="ed"></span>当前无预警,结构与设备运行正常</div>
<div class="alert-card" id="alert-card">
<div class="alert-top"><span class="lvl-badge" id="ac-badge">报警</span><span style="color:var(--muted);font-size:13px" id="ac-type">裂缝 + 应变越限</span></div>
<div class="alert-rows">
<span class="lk">发生时间</span><span class="vv" id="ac-time">---</span>
<span class="lk">桥梁位置</span><span class="vv">主跨跨中 · 箱梁腹板</span>
<span class="lk">检测依据</span><span class="vv" id="ac-basis">YOLOv8 裂缝 0.92 · 应变 770με</span>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-h"><span class="ic"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M13 2 4 14h7l-1 8 9-12h-7z"/></svg>光储混合供能</div>
<div class="card-b">
<div class="pwr">
<div class="cell"><div class="cl solar">☀ 光伏输入</div><div class="cv"><span id="pv-val">-</span><small>V</small></div></div>
<div class="cell"><div class="cl">锂电池</div><div class="cv"><span id="bat-val">-</span><small>V</small></div></div>
<div class="cell"><div class="cl">超级电容</div><div class="cv">- <small>V</small></div></div>
<div class="cell"><div class="cl">负载功耗</div><div class="cv"><span id="load-val">-</span><small>W</small></div></div>
</div>
<div class="flow">
<span class="fchip solar">☀ 光伏</span><span class="ar">→</span>
<span class="fchip">锂电池 / 超容</span><span class="ar">→</span>
<span class="fchip">负载(边缘计算·摄像头·通信)</span>
</div>
</div>
</div>
</div>
</div>
<div class="card hist">
<div class="card-h"><span class="ic"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M3 4h18M3 12h18M3 20h18"/></span>历史预警记录<span class="tag">支持按时间 / 类型查询</span></div>
<div class="card-b" style="padding:4px 4px 8px">
<table>
<thead><tr><th>发生时间</th><th>预警类型</th><th>预警等级</th><th>检测依据</th><th>处理状态</th></tr></thead>
<tbody id="hist-body">
<tr><td class="time">2024-05-10 09:14:22</td><td>桥面结冰</td><td><span class="lpill lv-warn"><span class="pd"></span>预警</span></td><td>置信度 0.91</td><td class="st-pending">待复核</td></tr>
<tr><td class="time">2024-04-22 17:38:05</td><td>应变越限</td><td><span class="lpill lv-warn"><span class="pd"></span>预警</span></td><td>置信度 0.73</td><td class="st-done">已上报</td></tr>
<tr><td class="time">2024-03-08 11:02:47</td><td>裂缝</td><td><span class="lpill lv-warn"><span class="pd"></span>预警</span></td><td>置信度 0.88</td><td class="st-done">恢复正常</td></tr>
</tbody>
</table>
</div>
</div>
<footer>
<span class="ft"><b>边缘计算</b> RK3568(CPU+NPU · 0.8TOPS)</span>
<span class="ft"><b>检测模型</b> YOLOv8 · ONNX→RKNN · INT8</span>
<span class="ft"><b>回传</b> 4G 轻量回传(结果级)</span>
<span class="ft"><b>供能</b> 光伏 + 锂电池 + 超级电容</span>
<span class="ft" style="margin-left:auto">道桥安全监测平台 v1.0</span>
</footer>
</div>
<script>
function pad(n){return String(n).padStart(2,'0')}
function tick(){
const d=new Date();
document.getElementById('date').textContent=`${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}`;
const t=`${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
document.getElementById('time').textContent=t;
document.getElementById('cam-time').textContent=t;
}
tick(); setInterval(tick,1000);
const NAME=['正常','预警','报警'], CLS=['lv-ok','lv-warn','lv-alarm'];
const COL=['var(--ok)','var(--warn)','var(--alarm)'];
const BADGEBG=['var(--ok-bg)','var(--warn-bg)','var(--alarm-bg)'];
const BADGECO=['var(--ok-t)','var(--warn-t)','var(--alarm-t)'];
let imgLevel=0, strLevel=0, strainTarget=480;
let alertCounter = 0;
let normalCounter = 0;
let currentState = 0;
function setPill(el,rank){el.className='pill '+CLS[rank];el.innerHTML=`<span class="pd"></span>${NAME[rank]}`;}
function setLpill(el,rank){el.className='lpill '+CLS[rank];el.innerHTML=`<span class="pd"></span>${NAME[rank]}`;}
function render(){
const out = currentState;
setPill(document.getElementById('f-img'),out);
setLpill(document.getElementById('strain-lvl'),strLevel);
document.getElementById('st-sd').style.background=COL[out];
document.getElementById('kpi-dot').style.background=COL[out];
const stt=document.getElementById('st-text'); stt.textContent=NAME[out]; stt.style.color=BADGECO[out];
document.getElementById('kpi-status').classList.toggle('alarm-on',out===2);
const found = out >= 1;
document.getElementById('cam').classList.toggle('found',found);
const ds=document.getElementById('det-state');
ds.textContent=found?'检出裂缝 · 已标注':'未检出裂缝'; ds.classList.toggle('found',found);
const empty=document.getElementById('alert-empty'), card=document.getElementById('alert-card');
if(out===0){empty.style.display='flex';card.classList.remove('show');}
else{
empty.style.display='none';card.classList.add('show');
const b=document.getElementById('ac-badge');
b.textContent=NAME[out]; b.style.background=BADGEBG[out]; b.style.color=BADGECO[out];
document.getElementById('ac-type').textContent = out===2 ? '裂缝持续检出' : '裂缝检出';
document.getElementById('ac-basis').textContent = 'YOLOv8 裂缝检测';
document.getElementById('ac-time').textContent=document.getElementById('time').textContent;
}
}
function addHistory(typeText,rank,basis,status,statusClass){
const t=new Date();
const ts=`${t.getFullYear()}-${pad(t.getMonth()+1)}-${pad(t.getDate())} ${pad(t.getHours())}:${pad(t.getMinutes())}:${pad(t.getSeconds())}`;
const tr=document.createElement('tr'); tr.className='new';
tr.innerHTML=`<td class="time">${ts}</td><td>${typeText}</td><td><span class="lpill ${CLS[rank]}"><span class="pd"></span>${NAME[rank]}</span></td><td>置信度 ${basis}</td><td class="${statusClass}">${status}</td>`;
const body=document.getElementById('hist-body'); body.insertBefore(tr,body.firstChild);
}
function fetchConf() {
fetch("/static/conf.txt?t=" + Date.now())
.then(res => res.text())
.then(text => {
const conf = parseFloat(text);
document.getElementById('conf-value').textContent = conf.toFixed(2);
if (conf >= 0.6) {
alertCounter++;
normalCounter = 0;
if (alertCounter >= 10 && currentState < 2) {
currentState = 2;
addHistory("裂缝持续检出", 2, conf.toFixed(2), "已上报", "st-pending");
} else if (alertCounter >= 1 && currentState < 1) {
currentState = 1;
addHistory("裂缝检出", 1, conf.toFixed(2), "待复核", "st-pending");
}
} else {
alertCounter = 0;
if (currentState > 0) {
normalCounter++;
if (normalCounter >= 9) {
if (currentState > 0) {
addHistory("恢复正常", 0, conf.toFixed(2), "恢复正常", "st-done");
}
currentState = 0;
normalCounter = 0;
}
}
}
imgLevel = currentState;
render();
})
.catch(err => console.log("读取置信度失败"));
}
const showImg = document.getElementById('show-img');
function refreshCamImage(){
showImg.src = `/static/cam.jpg?t=${Date.now()}`;
}
refreshCamImage();
function updatePvVoltage(){
document.getElementById("pv-val").textContent = (Math.random()*0.3+17.80).toFixed(2);
}
function updateBatVoltage(){
document.getElementById("bat-val").textContent = (Math.random()*0.06+12.14).toFixed(2);
}
function updateLoadPower(){
const v = (Math.random()*0.1+9.70).toFixed(2);
document.getElementById("load-val").textContent = v;
document.getElementById("sys-load-val").textContent = v;
}
setInterval(function(){
refreshCamImage();
fetchConf();
updatePvVoltage();
updateBatVoltage();
updateLoadPower();
}, 500);
const cv=document.getElementById('chart'), ctx=cv.getContext('2d');
const YMAX=1100,SAFE=600,WARN=720,ALARM=1000;
let data=Array.from({length:70},()=>460+Math.random()*60);
function resize(){const r=cv.getBoundingClientRect(),dpr=window.devicePixelRatio||1;cv.width=r.width*dpr;cv.height=r.height*dpr;ctx.setTransform(dpr,0,0,dpr,0,0)}
function yPos(v,h){return h-(v/YMAX)*h}
function band(w,h,v1,v2,c){const y1=yPos(v1,h),y2=yPos(v2,h);ctx.fillStyle=c;ctx.fillRect(0,y2,w,y1-y2)}
function line(w,h,v,c,label){const y=yPos(v,h);ctx.strokeStyle=c;ctx.lineWidth=1;ctx.setLineDash([5,5]);ctx.beginPath();ctx.moveTo(0,y);ctx.lineTo(w,y);ctx.stroke();ctx.setLineDash([]);ctx.fillStyle=c;ctx.font='11px ui-monospace,monospace';ctx.textAlign='right';ctx.fillText(label,w-4,y-4)}
function drawChart(){
const r=cv.getBoundingClientRect(),w=r.width,h=r.height;ctx.clearRect(0,0,w,h);
band(w,h,0,WARN,'rgba(63,154,99,.07)');
band(w,h,WARN,ALARM,'rgba(192,138,46,.09)');
band(w,h,ALARM,YMAX,'rgba(193,80,74,.10)');
line(w,h,SAFE,'rgba(138,150,163,.7)','安全 600');
line(w,h,WARN,'rgba(192,138,46,.9)','预警 720');
line(w,h,ALARM,'rgba(193,80,74,.9)','报警 1000');
const cur=data[data.length-1];
const col=cur>=ALARM?'#c1504a':cur>=WARN?'#c08a2e':'#3f9a63';
ctx.strokeStyle=col;ctx.lineWidth=2;ctx.lineJoin='round';
const step=w/(data.length-1);
data.forEach((v,i)=>{const x=i*step,y=yPos(v,h);i?ctx.lineTo(x,y):ctx.moveTo(x,y)});
ctx.stroke();
ctx.fillStyle=col;ctx.beginPath();ctx.arc(w,yPos(cur,h),3.5,0,7);ctx.fill();
document.getElementById('strain-now').textContent=Math.round(cur);
document.getElementById('strain-now').style.color=col;
}
function stepData(){const last=data[data.length-1];data.push(Math.max(360,last+(strainTarget-last)*0.35+(Math.random()-0.5)*18));data.shift();drawChart()}
window.addEventListener('resize',()=>{resize();drawChart()});
resize();drawChart();render();
setInterval(stepData,650);
</script>
</body>
</html>
核心逻辑:
-
定时刷新 :每500ms请求
/static/cam.jpg和/static/conf.txt。 -
状态机:根据置信度连续计数(≥0.6持续10帧触发预警,连续9帧低于阈值恢复)。
-
应变曲线:模拟动态数据,展示安全/预警/报警三条阈值线。
-
多模态融合:图像通道与应变通道综合判定预警等级。
七、运行与调试
7.1 启动后端
python server.py
访问http://127.0.0.1:8000可看到监控面板(此时无图像,等待推理程序上传)。
7.2 启动推理主程序
另开终端,运行:
python main.py
摄像头开启,推理结果实时显示在OpenCV窗口,同时每10帧上传至Flask服务。
7.3 效果展示
-
前端页面自动更新摄像头画面、置信度、预警状态。
-
应变曲线模拟数据动态波动。
-
历史记录自动添加预警事件。
八、性能与优化
-
CPU模式:在Intel i7-10750H上,YOLOv8n推理约50ms/帧(640×640),整体帧率约15~20fps。
-
GPU模式 :将
device='cuda',推理时间降至15ms,帧率可达30fps以上。 -
线程池:异步推理有效降低主循环阻塞,保证视频采集连续性。
九、总结与展望
本文详细记录了将RK3568 NPU推理系统移植到PC端的全过程,保留了原有软件架构和前端监控,仅替换推理后端,实现了快速原型验证和二次开发。你可以基于此代码:
-
更换其他YOLO模型(如yolov8s、yolov9等)。
-
接入真实应变传感器数据,替代模拟曲线。
-
部署到云端服务器,实现远程监控。
如有侵权,或需要完整代码,请及时联系博主。