拍视频版本
This commit is contained in:
925
web_back.py
Normal file
925
web_back.py
Normal file
@@ -0,0 +1,925 @@
|
||||
import cv2
|
||||
import threading
|
||||
import sys, os, time
|
||||
import argparse
|
||||
import numpy as np
|
||||
import serial
|
||||
from collections import deque
|
||||
from PyQt5.QtWidgets import (QApplication, QLabel, QWidget, QVBoxLayout,
|
||||
QHBoxLayout, QMainWindow, QStackedWidget,
|
||||
QSlider, QPushButton, QGridLayout, QFrame)
|
||||
from PyQt5.QtGui import QImage, QPixmap, QFont
|
||||
from PyQt5.QtCore import Qt, QTimer, pyqtSignal, QObject
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
try:
|
||||
from surround_view import FisheyeCameraModel, BirdView
|
||||
import surround_view.param_settings as settings
|
||||
except ImportError as e:
|
||||
print("[ERROR] Cannot import surround_view modules: {}".format(e))
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# ==========================================================
|
||||
# 1. 字体管理
|
||||
# ==========================================================
|
||||
class FontManager:
|
||||
_fonts = {}
|
||||
_lock = threading.Lock()
|
||||
|
||||
@classmethod
|
||||
def get(cls, size=20):
|
||||
with cls._lock:
|
||||
if size not in cls._fonts:
|
||||
candidates = [
|
||||
"/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc",
|
||||
"/usr/share/fonts/truetype/wqy/wqy-microhei.ttc",
|
||||
"/usr/share/fonts/truetype/droid/DroidSansFallbackFull.ttf",
|
||||
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
|
||||
"/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
|
||||
]
|
||||
loaded = False
|
||||
for p in candidates:
|
||||
if os.path.exists(p):
|
||||
try:
|
||||
cls._fonts[size] = ImageFont.truetype(p, size)
|
||||
loaded = True
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
if not loaded:
|
||||
cls._fonts[size] = ImageFont.load_default()
|
||||
return cls._fonts[size]
|
||||
|
||||
|
||||
# ==========================================================
|
||||
# 2. 中文叠加
|
||||
# ==========================================================
|
||||
def add_texts_to_pil(pil_image, text_list):
|
||||
draw = ImageDraw.Draw(pil_image)
|
||||
for item in text_list:
|
||||
if len(item) == 5:
|
||||
x, y, text, color, size = item
|
||||
font = FontManager.get(size)
|
||||
else:
|
||||
x, y, text, color = item
|
||||
font = FontManager.get(20)
|
||||
draw.text((x + 1, y + 1), text, font=font, fill=(0, 0, 0))
|
||||
draw.text((x, y), text, font=font, fill=color)
|
||||
|
||||
|
||||
# ==========================================================
|
||||
# 3. 串口1:雷达/人体感应解析线程
|
||||
# ==========================================================
|
||||
class SerialWorker(threading.Thread):
|
||||
def __init__(self, port="/dev/ttyS0", baudrate=115200):
|
||||
super().__init__(daemon=True)
|
||||
self.port = port
|
||||
self.baudrate = baudrate
|
||||
self.running = True
|
||||
self.lock = threading.Lock()
|
||||
|
||||
self._radar_dist = 0
|
||||
self._stopped = False
|
||||
self._active_dirs = []
|
||||
|
||||
def get_state(self):
|
||||
with self.lock:
|
||||
return list(self._active_dirs), self._radar_dist, self._stopped
|
||||
|
||||
def _parse_frame(self, frame: bytes):
|
||||
if len(frame) != 9 or frame[0] != 0x55 or frame[8] != 0xAA:
|
||||
return
|
||||
front = frame[1]; left = frame[2]
|
||||
back = frame[3]; right = frame[4]
|
||||
radar = frame[5]; stop = frame[6]
|
||||
|
||||
active = []
|
||||
for name, val in [("front", front), ("left", left),
|
||||
("back", back), ("right", right)]:
|
||||
if val != 0x00:
|
||||
active.append(name)
|
||||
|
||||
with self.lock:
|
||||
self._active_dirs = active
|
||||
self._radar_dist = radar
|
||||
self._stopped = (stop != 0x00)
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
ser = serial.Serial(port=self.port, baudrate=self.baudrate,
|
||||
bytesize=serial.EIGHTBITS,
|
||||
stopbits=serial.STOPBITS_ONE,
|
||||
parity=serial.PARITY_NONE, timeout=1)
|
||||
except Exception as e:
|
||||
print("[串口1 ERROR] 无法打开: {}".format(e))
|
||||
return
|
||||
|
||||
buf = bytearray()
|
||||
while self.running:
|
||||
try:
|
||||
byte = ser.read(1)
|
||||
if not byte:
|
||||
continue
|
||||
b = byte[0]
|
||||
if len(buf) == 0:
|
||||
if b == 0x55:
|
||||
buf.append(b)
|
||||
continue
|
||||
buf.append(b)
|
||||
if len(buf) == 9:
|
||||
self._parse_frame(bytes(buf))
|
||||
buf.clear()
|
||||
elif len(buf) > 9:
|
||||
buf.clear()
|
||||
except Exception as e:
|
||||
if self.running:
|
||||
print("[串口1 ERROR] {}".format(e))
|
||||
time.sleep(0.1)
|
||||
ser.close()
|
||||
|
||||
def stop(self):
|
||||
self.running = False
|
||||
|
||||
|
||||
# ==========================================================
|
||||
# 4. 串口2:红外遥控解析线程
|
||||
# 帧格式: 01 52 F0 FF 00 00 FF [用户码] [?] [?] [?]
|
||||
# 帧头固定7字节: 01 52 F0 FF 00 00 FF
|
||||
# 用户码在第8字节(index=7)
|
||||
# ==========================================================
|
||||
REMOTE_HEADER = bytes([0x01, 0x52, 0xF0, 0xFF, 0x00, 0x00, 0xFF])
|
||||
REMOTE_FRAME_LEN = 11 # 根据示例 01 52 F0 FF 00 00 FF 02 FD 97 54 共11字节
|
||||
|
||||
|
||||
class RemoteSignals(QObject):
|
||||
"""用 Qt 信号跨线程通知主线程"""
|
||||
enter_settings = pyqtSignal() # 按下 0x05 → 进入设置
|
||||
exit_settings = pyqtSignal() # 按下 0x0A → 退出并保存
|
||||
|
||||
|
||||
class RemoteWorker(threading.Thread):
|
||||
def __init__(self, port="/dev/ttyS3", baudrate=115200):
|
||||
super().__init__(daemon=True)
|
||||
self.port = port
|
||||
self.baudrate = baudrate
|
||||
self.running = True
|
||||
self.signals = RemoteSignals()
|
||||
|
||||
def _parse_frame(self, frame: bytes):
|
||||
if len(frame) != REMOTE_FRAME_LEN:
|
||||
return
|
||||
# 校验帧头
|
||||
if frame[:7] != REMOTE_HEADER:
|
||||
return
|
||||
user_code = frame[7]
|
||||
print("[遥控] 用户码: 0x{:02X}".format(user_code))
|
||||
|
||||
if user_code == 0x05:
|
||||
self.signals.enter_settings.emit()
|
||||
elif user_code == 0x0A:
|
||||
self.signals.exit_settings.emit()
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
ser = serial.Serial(port=self.port, baudrate=self.baudrate,
|
||||
bytesize=serial.EIGHTBITS,
|
||||
stopbits=serial.STOPBITS_ONE,
|
||||
parity=serial.PARITY_NONE, timeout=1)
|
||||
except Exception as e:
|
||||
print("[串口2 ERROR] 无法打开 {}: {}".format(self.port, e))
|
||||
return
|
||||
|
||||
buf = bytearray()
|
||||
while self.running:
|
||||
try:
|
||||
byte = ser.read(1)
|
||||
if not byte:
|
||||
continue
|
||||
b = byte[0]
|
||||
|
||||
# 帧头同步:等待第一个字节 0x01
|
||||
if len(buf) == 0:
|
||||
if b == 0x01:
|
||||
buf.append(b)
|
||||
continue
|
||||
|
||||
buf.append(b)
|
||||
|
||||
# 帧头前7字节逐字节校验,失配则重置
|
||||
if len(buf) <= 7:
|
||||
if buf[len(buf) - 1] != REMOTE_HEADER[len(buf) - 1]:
|
||||
print("[遥控同步] 帧头失配,重置")
|
||||
buf.clear()
|
||||
continue
|
||||
|
||||
# 收满完整帧
|
||||
if len(buf) == REMOTE_FRAME_LEN:
|
||||
self._parse_frame(bytes(buf))
|
||||
buf.clear()
|
||||
elif len(buf) > REMOTE_FRAME_LEN:
|
||||
buf.clear()
|
||||
|
||||
except Exception as e:
|
||||
if self.running:
|
||||
print("[串口2 ERROR] {}".format(e))
|
||||
time.sleep(0.1)
|
||||
ser.close()
|
||||
|
||||
def stop(self):
|
||||
self.running = False
|
||||
|
||||
|
||||
# ==========================================================
|
||||
# 5. 轮播调度器
|
||||
# ==========================================================
|
||||
class CarouselScheduler:
|
||||
INTERVAL = 4.0
|
||||
|
||||
def __init__(self):
|
||||
self._queue = []
|
||||
self._cur_dir = None
|
||||
self._cur_start = 0.0
|
||||
|
||||
def update(self, active_dirs: list):
|
||||
if not active_dirs:
|
||||
self._queue = []
|
||||
self._cur_dir = None
|
||||
return None, 0
|
||||
|
||||
for d in active_dirs:
|
||||
if d not in self._queue:
|
||||
self._queue.append(d)
|
||||
|
||||
self._queue = [d for d in self._queue
|
||||
if d in active_dirs or d == self._cur_dir]
|
||||
|
||||
if self._cur_dir is None or self._cur_dir not in self._queue:
|
||||
self._cur_dir = self._queue[0] if self._queue else None
|
||||
self._cur_start = time.time()
|
||||
self._skip_gone(active_dirs)
|
||||
|
||||
if time.time() - self._cur_start >= self.INTERVAL:
|
||||
self._advance(active_dirs)
|
||||
|
||||
remaining = max(0.0, self.INTERVAL - (time.time() - self._cur_start))
|
||||
return self._cur_dir, remaining
|
||||
|
||||
def _advance(self, active_dirs):
|
||||
if not self._queue:
|
||||
self._cur_dir = None
|
||||
return
|
||||
try:
|
||||
idx = self._queue.index(self._cur_dir)
|
||||
except ValueError:
|
||||
idx = -1
|
||||
self._cur_dir = self._queue[(idx + 1) % len(self._queue)]
|
||||
self._cur_start = time.time()
|
||||
self._skip_gone(active_dirs)
|
||||
|
||||
def _skip_gone(self, active_dirs):
|
||||
for _ in range(len(self._queue)):
|
||||
if self._cur_dir in active_dirs:
|
||||
break
|
||||
self._advance(active_dirs)
|
||||
|
||||
|
||||
# ==========================================================
|
||||
# 6. 原始帧采集线程
|
||||
# ==========================================================
|
||||
class RawCaptureWorker(threading.Thread):
|
||||
def __init__(self, caps, names):
|
||||
super().__init__(daemon=True)
|
||||
self.caps = caps
|
||||
self.names = names
|
||||
self.running = True
|
||||
self.queues = {name: deque(maxlen=2) for name in names}
|
||||
|
||||
def get_frame(self, name):
|
||||
q = self.queues.get(name)
|
||||
if q:
|
||||
try:
|
||||
return q[-1]
|
||||
except IndexError:
|
||||
pass
|
||||
return None
|
||||
|
||||
def run(self):
|
||||
while self.running:
|
||||
for cap, name in zip(self.caps, self.names):
|
||||
ret, frame = cap.read()
|
||||
if ret and frame is not None:
|
||||
self.queues[name].append(frame)
|
||||
|
||||
def stop(self):
|
||||
self.running = False
|
||||
|
||||
|
||||
# ==========================================================
|
||||
# 7. 鸟瞰处理线程
|
||||
# ==========================================================
|
||||
class BirdViewWorker(threading.Thread):
|
||||
def __init__(self, raw_worker, camera_models, birdview, framestep=2):
|
||||
super().__init__(daemon=True)
|
||||
self.raw_worker = raw_worker
|
||||
self.models = camera_models
|
||||
self.names = raw_worker.names
|
||||
self.birdview = birdview
|
||||
self.framestep = framestep
|
||||
self.running = True
|
||||
self.frame_idx = 0
|
||||
self._result_queue = deque(maxlen=2)
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def get_bird(self):
|
||||
with self._lock:
|
||||
try:
|
||||
return self._result_queue[-1]
|
||||
except IndexError:
|
||||
return None
|
||||
|
||||
def run(self):
|
||||
while self.running:
|
||||
self.frame_idx += 1
|
||||
if self.frame_idx % self.framestep != 0:
|
||||
time.sleep(0.005)
|
||||
continue
|
||||
|
||||
frames = []
|
||||
for name, model in zip(self.names, self.models):
|
||||
frame = self.raw_worker.get_frame(name)
|
||||
if frame is None:
|
||||
break
|
||||
try:
|
||||
proc = model.flip(model.project(model.undistort(frame)))
|
||||
frames.append(proc)
|
||||
except Exception as e:
|
||||
print("[BIRD PROC ERROR] {}".format(e))
|
||||
break
|
||||
|
||||
if len(frames) != 4:
|
||||
time.sleep(0.01)
|
||||
continue
|
||||
|
||||
try:
|
||||
self.birdview.update_frames(frames)
|
||||
self.birdview.stitch_all_parts()
|
||||
self.birdview.make_white_balance()
|
||||
self.birdview.copy_car_image()
|
||||
bird = self.birdview.image.copy()
|
||||
with self._lock:
|
||||
self._result_queue.append(bird)
|
||||
except Exception as e:
|
||||
print("[BIRDVIEW ERROR] {}".format(e))
|
||||
time.sleep(0.01)
|
||||
|
||||
def stop(self):
|
||||
self.running = False
|
||||
|
||||
|
||||
# ==========================================================
|
||||
# 8. 多摄像头初始化
|
||||
# ==========================================================
|
||||
class MultiCameraBirdView:
|
||||
def __init__(self):
|
||||
self.running = True
|
||||
self.names = settings.camera_names
|
||||
self.yamls = [
|
||||
os.path.join(os.getcwd(), "yaml", name + ".yaml")
|
||||
for name in self.names
|
||||
]
|
||||
try:
|
||||
self.camera_models = [
|
||||
FisheyeCameraModel(f, n)
|
||||
for f, n in zip(self.yamls, self.names)
|
||||
]
|
||||
except Exception as e:
|
||||
print("[ERROR] {}".format(e))
|
||||
self.running = False
|
||||
return
|
||||
|
||||
self.caps = []
|
||||
self.which_cameras = {"front": 0, "left": 1, "back": 2, "right": 3}
|
||||
|
||||
for name in self.names:
|
||||
cap_id = self.which_cameras.get(name, 0)
|
||||
cap = cv2.VideoCapture(cap_id, cv2.CAP_V4L2)
|
||||
cap.set(cv2.CAP_PROP_FOURCC, cv2.VideoWriter_fourcc(*"YUYV"))
|
||||
cap.set(cv2.CAP_PROP_FRAME_WIDTH, 1280)
|
||||
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 720)
|
||||
if not cap.isOpened():
|
||||
print("[ERROR] Cannot open {} (ID:{})".format(name, cap_id))
|
||||
self.running = False
|
||||
return
|
||||
self.caps.append(cap)
|
||||
|
||||
self.birdview = BirdView()
|
||||
self._initialize_weights()
|
||||
|
||||
def _initialize_weights(self):
|
||||
try:
|
||||
images = [
|
||||
os.path.join(os.getcwd(), "images", name + ".png")
|
||||
for name in self.names
|
||||
]
|
||||
static_frames = []
|
||||
for img_path, model in zip(images, self.camera_models):
|
||||
img = cv2.imread(img_path)
|
||||
if img is None:
|
||||
continue
|
||||
static_frames.append(
|
||||
model.flip(model.project(model.undistort(img)))
|
||||
)
|
||||
if len(static_frames) == 4:
|
||||
self.birdview.get_weights_and_masks(static_frames)
|
||||
print("[INFO] Weights initialized")
|
||||
else:
|
||||
print("[WARNING] Static images incomplete")
|
||||
except Exception as e:
|
||||
print("[ERROR] Weight init failed: {}".format(e))
|
||||
|
||||
|
||||
# ==========================================================
|
||||
# 9. 常量
|
||||
# ==========================================================
|
||||
DIR_CN = {"front": "前方", "left": "左方", "back": "后方", "right": "右方"}
|
||||
WARN_COLOR = (255, 80, 80)
|
||||
SAFE_COLOR = (100, 255, 100)
|
||||
SCREEN_W = 1024
|
||||
SCREEN_H = 600
|
||||
BIRD_W = SCREEN_W // 3
|
||||
FRONT_W = SCREEN_W - BIRD_W
|
||||
|
||||
|
||||
# ==========================================================
|
||||
# 10. 渲染线程(主视图)
|
||||
# ==========================================================
|
||||
class RenderWorker(threading.Thread):
|
||||
TARGET_FPS = 30
|
||||
FRAME_DELAY = 1.0 / TARGET_FPS
|
||||
|
||||
def __init__(self, bird_worker, raw_worker, serial_worker, carousel):
|
||||
super().__init__(daemon=True)
|
||||
self.bird_worker = bird_worker
|
||||
self.raw_worker = raw_worker
|
||||
self.serial_worker = serial_worker
|
||||
self.carousel = carousel
|
||||
self.running = True
|
||||
self._out_queue = deque(maxlen=2)
|
||||
self._lock = threading.Lock()
|
||||
self._last_bird = None
|
||||
self._last_time = ""
|
||||
for sz in (18, 20, 24, 32):
|
||||
FontManager.get(sz)
|
||||
|
||||
def get_qimage(self):
|
||||
with self._lock:
|
||||
try:
|
||||
return self._out_queue[-1]
|
||||
except IndexError:
|
||||
return None
|
||||
|
||||
def run(self):
|
||||
while self.running:
|
||||
t0 = time.time()
|
||||
bird = self.bird_worker.get_bird()
|
||||
if bird is not None:
|
||||
self._last_bird = bird
|
||||
|
||||
if self._last_bird is None:
|
||||
time.sleep(0.02)
|
||||
continue
|
||||
|
||||
try:
|
||||
active_dirs, radar_dist, is_stopped = self.serial_worker.get_state()
|
||||
display_dir, remaining = self.carousel.update(active_dirs)
|
||||
|
||||
right_frame = None
|
||||
if display_dir:
|
||||
right_frame = self.raw_worker.get_frame(display_dir)
|
||||
if right_frame is None:
|
||||
right_frame = self.raw_worker.get_frame("front")
|
||||
if right_frame is None:
|
||||
time.sleep(0.01)
|
||||
continue
|
||||
|
||||
bird_part = cv2.resize(self._last_bird, (BIRD_W, SCREEN_H),
|
||||
interpolation=cv2.INTER_LINEAR)
|
||||
front_part = cv2.resize(right_frame, (FRONT_W, SCREEN_H),
|
||||
interpolation=cv2.INTER_LINEAR)
|
||||
display = np.hstack((bird_part, front_part))
|
||||
rgb = cv2.cvtColor(display, cv2.COLOR_BGR2RGB)
|
||||
pil_img = Image.fromarray(rgb)
|
||||
|
||||
self._last_time = time.strftime("%Y-%m-%d %H:%M:%S")
|
||||
texts = [(10, 10, "鸟瞰视图", (0, 255, 100), 20)]
|
||||
|
||||
if display_dir:
|
||||
dir_cn = DIR_CN.get(display_dir, display_dir)
|
||||
texts.append((BIRD_W + 10, 10,
|
||||
"{} 检测到人员".format(dir_cn), WARN_COLOR, 32))
|
||||
if len(active_dirs) > 1:
|
||||
queue_cn = " → ".join(DIR_CN.get(d, d)
|
||||
for d in self.carousel._queue)
|
||||
texts.append((BIRD_W + 10, 55,
|
||||
"轮播顺序: {}".format(queue_cn),
|
||||
(255, 200, 0), 18))
|
||||
texts.append((BIRD_W + 10, 82,
|
||||
"当前: {} 切换倒计时: {:.1f}s".format(
|
||||
dir_cn, remaining),
|
||||
(200, 200, 200), 18))
|
||||
else:
|
||||
texts.append((BIRD_W + 10, 55,
|
||||
"仅 {} 有人".format(dir_cn),
|
||||
(200, 200, 200), 18))
|
||||
else:
|
||||
texts.append((BIRD_W + 10, 10,
|
||||
"前视监控 (周围无人)", SAFE_COLOR, 24))
|
||||
|
||||
texts.append((BIRD_W + 10, SCREEN_H - 75,
|
||||
"雷达距离: {} 分米".format(radar_dist),
|
||||
(180, 220, 255), 20))
|
||||
stop_text = "停机状态: 已停机" if is_stopped else "停机状态: 运行中"
|
||||
stop_color = (255, 100, 100) if is_stopped else (100, 255, 100)
|
||||
texts.append((BIRD_W + 10, SCREEN_H - 48,
|
||||
stop_text, stop_color, 20))
|
||||
texts.append((BIRD_W + 10, SCREEN_H - 22,
|
||||
self._last_time, (160, 160, 160), 18))
|
||||
|
||||
add_texts_to_pil(pil_img, texts)
|
||||
arr = np.ascontiguousarray(np.array(pil_img))
|
||||
h, w, ch = arr.shape
|
||||
qimg = QImage(arr.tobytes(), w, h, w * ch, QImage.Format_RGB888)
|
||||
|
||||
with self._lock:
|
||||
self._out_queue.append(qimg)
|
||||
|
||||
except Exception as e:
|
||||
print("[RENDER WORKER ERROR] {}".format(e))
|
||||
|
||||
elapsed = time.time() - t0
|
||||
sleep_t = self.FRAME_DELAY - elapsed
|
||||
if sleep_t > 0:
|
||||
time.sleep(sleep_t)
|
||||
|
||||
def stop(self):
|
||||
self.running = False
|
||||
|
||||
|
||||
# ==========================================================
|
||||
# 11. 设置界面(完全独立,解耦)
|
||||
# 所有设置项、交互逻辑都在此类内部
|
||||
# ==========================================================
|
||||
class SettingsPanel(QWidget):
|
||||
"""
|
||||
全屏设置界面。
|
||||
外部只需调用:
|
||||
panel.load() - 进入时刷新显示
|
||||
panel.save() - 退出时保存(内部也会自动调用)
|
||||
panel.get_settings() -> dict
|
||||
"""
|
||||
|
||||
# 默认配置,可按需扩展
|
||||
DEFAULT_SETTINGS = {
|
||||
"carousel_interval": 4, # 轮播间隔(秒)
|
||||
"brightness": 50, # 亮度 0~100
|
||||
"radar_unit": 0, # 0=分米 1=厘米
|
||||
"show_radar": 1, # 是否显示雷达距离
|
||||
}
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._cfg = dict(self.DEFAULT_SETTINGS)
|
||||
self._build_ui()
|
||||
|
||||
# ----------------------------------------------------------
|
||||
# UI 构建(独立函数,方便日后扩展)
|
||||
# ----------------------------------------------------------
|
||||
def _build_ui(self):
|
||||
self.setStyleSheet("""
|
||||
QWidget { background-color: #1a1a2e; color: #e0e0e0; }
|
||||
QLabel { font-size: 16px; }
|
||||
QPushButton {
|
||||
background-color: #16213e;
|
||||
color: #e0e0e0;
|
||||
border: 1px solid #0f3460;
|
||||
border-radius: 6px;
|
||||
padding: 8px 20px;
|
||||
font-size: 15px;
|
||||
}
|
||||
QPushButton:hover { background-color: #0f3460; }
|
||||
QPushButton:pressed{ background-color: #533483; }
|
||||
QSlider::groove:horizontal {
|
||||
height: 6px;
|
||||
background: #0f3460;
|
||||
border-radius: 3px;
|
||||
}
|
||||
QSlider::handle:horizontal {
|
||||
width: 18px; height: 18px;
|
||||
margin: -6px 0;
|
||||
background: #e94560;
|
||||
border-radius: 9px;
|
||||
}
|
||||
QFrame#sep {
|
||||
background-color: #0f3460;
|
||||
}
|
||||
""")
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
root.setContentsMargins(40, 30, 40, 30)
|
||||
root.setSpacing(20)
|
||||
|
||||
# 标题
|
||||
title = QLabel("⚙ 系统设置")
|
||||
title.setAlignment(Qt.AlignCenter)
|
||||
title.setFont(QFont("", 26, QFont.Bold))
|
||||
title.setStyleSheet("color: #e94560; font-size: 26px;")
|
||||
root.addWidget(title)
|
||||
|
||||
sep = self._make_sep()
|
||||
root.addWidget(sep)
|
||||
|
||||
# 设置项网格
|
||||
grid = QGridLayout()
|
||||
grid.setHorizontalSpacing(30)
|
||||
grid.setVerticalSpacing(22)
|
||||
|
||||
# --- 轮播间隔 ---
|
||||
grid.addWidget(self._label("轮播间隔(秒)"), 0, 0)
|
||||
self._sld_interval = self._slider(1, 10, self._cfg["carousel_interval"])
|
||||
self._lbl_interval = self._value_label(str(self._cfg["carousel_interval"]))
|
||||
self._sld_interval.valueChanged.connect(
|
||||
lambda v: self._lbl_interval.setText(str(v)))
|
||||
grid.addWidget(self._sld_interval, 0, 1)
|
||||
grid.addWidget(self._lbl_interval, 0, 2)
|
||||
|
||||
# --- 亮度 ---
|
||||
grid.addWidget(self._label("画面亮度"), 1, 0)
|
||||
self._sld_bright = self._slider(0, 100, self._cfg["brightness"])
|
||||
self._lbl_bright = self._value_label(str(self._cfg["brightness"]))
|
||||
self._sld_bright.valueChanged.connect(
|
||||
lambda v: self._lbl_bright.setText(str(v)))
|
||||
grid.addWidget(self._sld_bright, 1, 1)
|
||||
grid.addWidget(self._lbl_bright, 1, 2)
|
||||
|
||||
# --- 雷达单位切换 ---
|
||||
grid.addWidget(self._label("雷达距离单位"), 2, 0)
|
||||
self._btn_unit = QPushButton(
|
||||
"分米" if self._cfg["radar_unit"] == 0 else "厘米")
|
||||
self._btn_unit.setCheckable(False)
|
||||
self._btn_unit.clicked.connect(self._toggle_unit)
|
||||
grid.addWidget(self._btn_unit, 2, 1)
|
||||
|
||||
# --- 是否显示雷达 ---
|
||||
grid.addWidget(self._label("显示雷达距离"), 3, 0)
|
||||
self._btn_radar = QPushButton(
|
||||
"开启" if self._cfg["show_radar"] else "关闭")
|
||||
self._btn_radar.clicked.connect(self._toggle_radar)
|
||||
grid.addWidget(self._btn_radar, 3, 1)
|
||||
|
||||
root.addLayout(grid)
|
||||
root.addWidget(self._make_sep())
|
||||
|
||||
# 底部提示
|
||||
hint = QLabel("按遥控 0A 保存并退出设置")
|
||||
hint.setAlignment(Qt.AlignCenter)
|
||||
hint.setStyleSheet("color: #888; font-size: 14px;")
|
||||
root.addWidget(hint)
|
||||
|
||||
root.addStretch()
|
||||
|
||||
# ----------------------------------------------------------
|
||||
# 辅助控件工厂
|
||||
# ----------------------------------------------------------
|
||||
@staticmethod
|
||||
def _label(text):
|
||||
lbl = QLabel(text)
|
||||
lbl.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
||||
return lbl
|
||||
|
||||
@staticmethod
|
||||
def _value_label(text):
|
||||
lbl = QLabel(text)
|
||||
lbl.setFixedWidth(50)
|
||||
lbl.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
|
||||
lbl.setStyleSheet("color: #e94560; font-size: 18px; font-weight: bold;")
|
||||
return lbl
|
||||
|
||||
@staticmethod
|
||||
def _slider(mn, mx, val):
|
||||
s = QSlider(Qt.Horizontal)
|
||||
s.setRange(mn, mx)
|
||||
s.setValue(val)
|
||||
s.setFixedWidth(400)
|
||||
return s
|
||||
|
||||
@staticmethod
|
||||
def _make_sep():
|
||||
f = QFrame()
|
||||
f.setObjectName("sep")
|
||||
f.setFrameShape(QFrame.HLine)
|
||||
f.setFixedHeight(1)
|
||||
return f
|
||||
|
||||
# ----------------------------------------------------------
|
||||
# 交互处理(全部在此类内部)
|
||||
# ----------------------------------------------------------
|
||||
def _toggle_unit(self):
|
||||
self._cfg["radar_unit"] = 1 - self._cfg["radar_unit"]
|
||||
self._btn_unit.setText(
|
||||
"分米" if self._cfg["radar_unit"] == 0 else "厘米")
|
||||
|
||||
def _toggle_radar(self):
|
||||
self._cfg["show_radar"] = 1 - self._cfg["show_radar"]
|
||||
self._btn_radar.setText(
|
||||
"开启" if self._cfg["show_radar"] else "关闭")
|
||||
|
||||
# ----------------------------------------------------------
|
||||
# 外部接口
|
||||
# ----------------------------------------------------------
|
||||
def load(self):
|
||||
"""进入设置界面时调用,刷新控件与内部配置一致"""
|
||||
self._sld_interval.setValue(self._cfg["carousel_interval"])
|
||||
self._sld_bright.setValue(self._cfg["brightness"])
|
||||
self._btn_unit.setText(
|
||||
"分米" if self._cfg["radar_unit"] == 0 else "厘米")
|
||||
self._btn_radar.setText(
|
||||
"开启" if self._cfg["show_radar"] else "关闭")
|
||||
print("[设置] 界面已加载")
|
||||
|
||||
def save(self):
|
||||
"""退出设置时调用,将控件值写回配置"""
|
||||
self._cfg["carousel_interval"] = self._sld_interval.value()
|
||||
self._cfg["brightness"] = self._sld_bright.value()
|
||||
# radar_unit / show_radar 已在点击时即时更新
|
||||
print("[设置] 已保存: {}".format(self._cfg))
|
||||
|
||||
def get_settings(self) -> dict:
|
||||
"""获取当前配置副本"""
|
||||
return dict(self._cfg)
|
||||
|
||||
|
||||
# ==========================================================
|
||||
# 12. PyQt 主窗口
|
||||
# ==========================================================
|
||||
class SurroundViewWindow(QMainWindow):
|
||||
# 页面索引
|
||||
PAGE_MAIN = 0
|
||||
PAGE_SETTINGS = 1
|
||||
|
||||
def __init__(self, multi_cam, serial_worker, remote_worker):
|
||||
super().__init__()
|
||||
self.multi_cam = multi_cam
|
||||
self.serial_worker = serial_worker
|
||||
self.remote_worker = remote_worker
|
||||
self.carousel = CarouselScheduler()
|
||||
|
||||
self.setWindowTitle("环视系统 - 串口联动")
|
||||
self.resize(SCREEN_W, SCREEN_H)
|
||||
|
||||
# QStackedWidget:页面0=主视图 页面1=设置
|
||||
self.stack = QStackedWidget()
|
||||
self.setCentralWidget(self.stack)
|
||||
|
||||
# --- 主视图页 ---
|
||||
main_page = QWidget()
|
||||
main_lay = QVBoxLayout(main_page)
|
||||
main_lay.setContentsMargins(0, 0, 0, 0)
|
||||
self.image_label = QLabel()
|
||||
self.image_label.setAlignment(Qt.AlignCenter)
|
||||
self.image_label.setStyleSheet("background-color: black;")
|
||||
main_lay.addWidget(self.image_label)
|
||||
self.stack.addWidget(main_page) # index 0
|
||||
|
||||
# --- 设置页 ---
|
||||
self.settings_panel = SettingsPanel()
|
||||
self.stack.addWidget(self.settings_panel) # index 1
|
||||
|
||||
# 默认显示主视图
|
||||
self.stack.setCurrentIndex(self.PAGE_MAIN)
|
||||
|
||||
# 采集 / 鸟瞰 / 渲染线程
|
||||
self.raw_worker = RawCaptureWorker(multi_cam.caps, multi_cam.names)
|
||||
self.raw_worker.start()
|
||||
|
||||
self.bird_worker = BirdViewWorker(
|
||||
self.raw_worker, multi_cam.camera_models,
|
||||
multi_cam.birdview, framestep=2)
|
||||
self.bird_worker.start()
|
||||
|
||||
self.render_worker = RenderWorker(
|
||||
self.bird_worker, self.raw_worker,
|
||||
serial_worker, self.carousel)
|
||||
self.render_worker.start()
|
||||
|
||||
# 主线程刷新
|
||||
self.timer = QTimer()
|
||||
self.timer.timeout.connect(self._flush)
|
||||
self.timer.start(16)
|
||||
|
||||
# 连接遥控信号(跨线程安全)
|
||||
remote_worker.signals.enter_settings.connect(self._on_enter_settings)
|
||||
remote_worker.signals.exit_settings.connect(self._on_exit_settings)
|
||||
|
||||
# ----------------------------------------------------------
|
||||
# 主视图刷新
|
||||
# ----------------------------------------------------------
|
||||
def _flush(self):
|
||||
if self.stack.currentIndex() != self.PAGE_MAIN:
|
||||
return # 设置界面时不刷新主视图,节省资源
|
||||
qimg = self.render_worker.get_qimage()
|
||||
if qimg is None:
|
||||
return
|
||||
pixmap = QPixmap.fromImage(qimg)
|
||||
lw, lh = self.image_label.width(), self.image_label.height()
|
||||
w, h = qimg.width(), qimg.height()
|
||||
if w != lw or h != lh:
|
||||
pixmap = pixmap.scaled(lw, lh, Qt.KeepAspectRatio,
|
||||
Qt.FastTransformation)
|
||||
self.image_label.setPixmap(pixmap)
|
||||
|
||||
# ----------------------------------------------------------
|
||||
# 设置界面进入 / 退出(由遥控信号触发,在主线程执行)
|
||||
# ----------------------------------------------------------
|
||||
def _on_enter_settings(self):
|
||||
if self.stack.currentIndex() == self.PAGE_SETTINGS:
|
||||
return
|
||||
self.settings_panel.load()
|
||||
self.stack.setCurrentIndex(self.PAGE_SETTINGS)
|
||||
print("[UI] 已切换到设置界面")
|
||||
|
||||
def _on_exit_settings(self):
|
||||
if self.stack.currentIndex() != self.PAGE_SETTINGS:
|
||||
return
|
||||
self.settings_panel.save()
|
||||
cfg = self.settings_panel.get_settings()
|
||||
# 将配置应用到运行时
|
||||
CarouselScheduler.INTERVAL = float(cfg["carousel_interval"])
|
||||
self.stack.setCurrentIndex(self.PAGE_MAIN)
|
||||
print("[UI] 已退出设置,返回主视图")
|
||||
|
||||
# ----------------------------------------------------------
|
||||
def closeEvent(self, event):
|
||||
self.timer.stop()
|
||||
self.render_worker.stop()
|
||||
self.bird_worker.stop()
|
||||
self.raw_worker.stop()
|
||||
self.serial_worker.stop()
|
||||
self.remote_worker.stop()
|
||||
self.multi_cam.running = False
|
||||
for cap in self.multi_cam.caps:
|
||||
cap.release()
|
||||
event.accept()
|
||||
|
||||
|
||||
# ==========================================================
|
||||
# 13. 主函数
|
||||
# ==========================================================
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="环视系统 + 串口联动")
|
||||
parser.add_argument("--mode", type=str, required=True,
|
||||
choices=["realtime", "static"])
|
||||
parser.add_argument("--port", type=str, default="/dev/ttyS0",
|
||||
help="雷达串口")
|
||||
parser.add_argument("--remote-port",type=str, default="/dev/ttyS3",
|
||||
help="遥控串口")
|
||||
parser.add_argument("--baudrate", type=int, default=115200)
|
||||
parser.add_argument("--framestep", type=int, default=2)
|
||||
parser.add_argument("--interval", type=float, default=4.0,
|
||||
help="轮播间隔秒数")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.mode == "realtime":
|
||||
CarouselScheduler.INTERVAL = args.interval
|
||||
|
||||
# 雷达串口
|
||||
serial_worker = SerialWorker(port=args.port, baudrate=args.baudrate)
|
||||
serial_worker.start()
|
||||
print("[INFO] 雷达串口已启动: {} @ {}".format(args.port, args.baudrate))
|
||||
|
||||
# 遥控串口
|
||||
remote_worker = RemoteWorker(port=args.remote_port,
|
||||
baudrate=args.baudrate)
|
||||
remote_worker.start()
|
||||
print("[INFO] 遥控串口已启动: {} @ {}".format(
|
||||
args.remote_port, args.baudrate))
|
||||
|
||||
multi_cam = MultiCameraBirdView()
|
||||
if not multi_cam.running:
|
||||
print("[ERROR] 摄像头初始化失败")
|
||||
serial_worker.stop()
|
||||
remote_worker.stop()
|
||||
return
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
win = SurroundViewWindow(multi_cam, serial_worker, remote_worker)
|
||||
win.bird_worker.framestep = args.framestep
|
||||
win.show()
|
||||
sys.exit(app.exec_())
|
||||
|
||||
elif args.mode == "static":
|
||||
print("[INFO] Static mode not implemented.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user