996 lines
35 KiB
Python
996 lines
35 KiB
Python
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:收发线程
|
||
# 发送帧格式:55 [前] [左] [后] [右] [00] [雷达阈值dm] [校验] AA
|
||
# 校验 = (前+左+后+右+00+雷达阈值) & 0xFF
|
||
# ==========================================================
|
||
SEND_INTERVAL_MS = 100
|
||
|
||
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._rx_lock = threading.Lock()
|
||
self._radar_dist = 0
|
||
self._stopped = False
|
||
self._active_dirs = []
|
||
|
||
# 接收防抖
|
||
self._rx_pending_dirs = []
|
||
self._rx_pending_radar = 0
|
||
self._rx_pending_stop = False
|
||
self._rx_pending_count = 0
|
||
RX_DEBOUNCE = 3
|
||
|
||
# 发送内容
|
||
self._tx_lock = threading.Lock()
|
||
self._tx_front = 0x00
|
||
self._tx_left = 0x00
|
||
self._tx_back = 0x00
|
||
self._tx_right = 0x00
|
||
self._tx_radar_thr = 0x00
|
||
|
||
# 发送防抖
|
||
self._stable_presence = [0x00, 0x00, 0x00, 0x00]
|
||
self._pending_presence = [0x00, 0x00, 0x00, 0x00]
|
||
self._pending_count = 0
|
||
self.DEBOUNCE_FRAMES = 9
|
||
|
||
# 打印计数(防刷屏)
|
||
self._tx_print_cnt = 0
|
||
|
||
def update_send(self, front, left, back, right, radar_thr):
|
||
"""外部调用,更新待发送的有人/无人状态及雷达阈值"""
|
||
presence = [front & 0xFF, left & 0xFF,
|
||
back & 0xFF, right & 0xFF]
|
||
|
||
# 发送防抖:连续 DEBOUNCE_FRAMES 次一致才更新稳定值
|
||
if presence == self._pending_presence:
|
||
self._pending_count += 1
|
||
else:
|
||
self._pending_presence = presence[:]
|
||
self._pending_count = 1
|
||
|
||
if self._pending_count >= self.DEBOUNCE_FRAMES:
|
||
self._stable_presence = presence[:]
|
||
|
||
with self._tx_lock:
|
||
self._tx_front = self._stable_presence[0]
|
||
self._tx_left = self._stable_presence[1]
|
||
self._tx_back = self._stable_presence[2]
|
||
self._tx_right = self._stable_presence[3]
|
||
self._tx_radar_thr = radar_thr & 0xFF
|
||
|
||
def _build_tx_frame(self):
|
||
"""按协议构建9字节发送帧"""
|
||
with self._tx_lock:
|
||
b1 = self._tx_front # 前
|
||
b2 = self._tx_left # 左
|
||
b3 = self._tx_back # 后
|
||
b4 = self._tx_right # 右
|
||
b5 = 0x00 # 固定00
|
||
b6 = self._tx_radar_thr # 雷达阈值dm
|
||
chk = (b1 + b2 + b3 + b4 + b5 + b6) & 0xFF
|
||
return bytes([0x55, b1, b2, b3, b4, b5, b6, chk, 0xAA])
|
||
|
||
def get_state(self):
|
||
with self._rx_lock:
|
||
return list(self._active_dirs), self._radar_dist, self._stopped
|
||
|
||
def _parse_rx_frame(self, frame: bytes):
|
||
if len(frame) != 9 or frame[0] != 0x55 or frame[8] != 0xAA:
|
||
return
|
||
|
||
# 打印接收原始帧
|
||
print("[RX ttyS0] {}".format(
|
||
" ".join("{:02X}".format(b) for b in frame)))
|
||
|
||
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)
|
||
|
||
# 接收防抖
|
||
if (active == self._rx_pending_dirs and
|
||
radar == self._rx_pending_radar and
|
||
(stop != 0x00) == self._rx_pending_stop):
|
||
self._rx_pending_count += 1
|
||
else:
|
||
self._rx_pending_dirs = active
|
||
self._rx_pending_radar = radar
|
||
self._rx_pending_stop = (stop != 0x00)
|
||
self._rx_pending_count = 1
|
||
|
||
if self._rx_pending_count >= 3:
|
||
with self._rx_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=0.05)
|
||
except Exception as e:
|
||
print("[串口1 ERROR] 无法打开: {}".format(e))
|
||
return
|
||
|
||
# ── 发送子线程 ──
|
||
def _send_loop():
|
||
interval = SEND_INTERVAL_MS / 1000.0
|
||
while self.running:
|
||
try:
|
||
frame = self._build_tx_frame()
|
||
ser.write(frame)
|
||
|
||
# 每10次打印一次发送内容(约1秒)
|
||
self._tx_print_cnt += 1
|
||
if self._tx_print_cnt % 10 == 0:
|
||
print("[TX ttyS0] {} 含义: 前={:02X} 左={:02X} 后={:02X} 右={:02X} 雷达阈值={:02X}dm 校验={:02X}".format(
|
||
" ".join("{:02X}".format(b) for b in frame),
|
||
frame[1], frame[2], frame[3], frame[4],
|
||
frame[6], frame[7]))
|
||
|
||
except Exception as e:
|
||
if self.running:
|
||
print("[串口1 TX ERROR] {}".format(e))
|
||
time.sleep(interval)
|
||
|
||
tx_thread = threading.Thread(target=_send_loop, daemon=True)
|
||
tx_thread.start()
|
||
|
||
# ── 接收主循环 ──
|
||
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_rx_frame(bytes(buf))
|
||
buf.clear()
|
||
elif len(buf) > 9:
|
||
buf.clear()
|
||
except Exception as e:
|
||
if self.running:
|
||
print("[串口1 RX ERROR] {}".format(e))
|
||
time.sleep(0.1)
|
||
ser.close()
|
||
|
||
def stop(self):
|
||
self.running = False
|
||
|
||
|
||
# ==========================================================
|
||
# 4. 串口2:红外遥控解析线程
|
||
# ==========================================================
|
||
REMOTE_HEADER = bytes([0x01, 0x52, 0xF0, 0xFF, 0x00, 0x00, 0xFF])
|
||
REMOTE_FRAME_LEN = 11
|
||
|
||
|
||
class RemoteSignals(QObject):
|
||
enter_settings = pyqtSignal()
|
||
exit_settings = pyqtSignal()
|
||
|
||
|
||
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]
|
||
if len(buf) == 0:
|
||
if b == 0x01:
|
||
buf.append(b)
|
||
continue
|
||
buf.append(b)
|
||
if len(buf) <= 7:
|
||
if buf[len(buf) - 1] != REMOTE_HEADER[len(buf) - 1]:
|
||
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,
|
||
radar_thr_ref):
|
||
super().__init__(daemon=True)
|
||
self.bird_worker = bird_worker
|
||
self.raw_worker = raw_worker
|
||
self.serial_worker = serial_worker
|
||
self.carousel = carousel
|
||
self.radar_thr_ref = radar_thr_ref # list[int],索引0为雷达阈值dm
|
||
self.running = True
|
||
self._out_queue = deque(maxlen=2)
|
||
self._lock = threading.Lock()
|
||
self._last_bird = None
|
||
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)
|
||
|
||
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,
|
||
time.strftime("%Y-%m-%d %H:%M:%S"),
|
||
(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)
|
||
|
||
# ── 更新串口发送内容 ──
|
||
dir_map = {"front": 0, "left": 1, "back": 2, "right": 3}
|
||
presence = [0x00, 0x00, 0x00, 0x00]
|
||
for d in active_dirs:
|
||
idx = dir_map.get(d)
|
||
if idx is not None:
|
||
presence[idx] = 0x01
|
||
|
||
radar_thr = self.radar_thr_ref[0] # 从共享引用读取
|
||
self.serial_worker.update_send(
|
||
presence[0], presence[1],
|
||
presence[2], presence[3],
|
||
radar_thr)
|
||
|
||
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):
|
||
DEFAULT_SETTINGS = {
|
||
"carousel_interval": 4,
|
||
"brightness": 50,
|
||
"radar_unit": 0,
|
||
"show_radar": 1,
|
||
"radar_alarm_dist": 50, # 雷达报警阈值,单位 dm
|
||
}
|
||
|
||
def __init__(self, parent=None):
|
||
super().__init__(parent)
|
||
self._cfg = dict(self.DEFAULT_SETTINGS)
|
||
self._build_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)
|
||
root.addWidget(self._make_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)
|
||
|
||
# 雷达报警阈值(dm)
|
||
grid.addWidget(self._label("雷达报警阈值(dm)"), 2, 0)
|
||
self._sld_radar_thr = self._slider(1, 200,
|
||
self._cfg["radar_alarm_dist"])
|
||
self._lbl_radar_thr = self._value_label(
|
||
str(self._cfg["radar_alarm_dist"]))
|
||
self._sld_radar_thr.valueChanged.connect(
|
||
lambda v: self._lbl_radar_thr.setText(str(v)))
|
||
grid.addWidget(self._sld_radar_thr, 2, 1)
|
||
grid.addWidget(self._lbl_radar_thr, 2, 2)
|
||
|
||
# 雷达单位
|
||
grid.addWidget(self._label("雷达距离单位"), 3, 0)
|
||
self._btn_unit = QPushButton(
|
||
"分米" if self._cfg["radar_unit"] == 0 else "厘米")
|
||
self._btn_unit.clicked.connect(self._toggle_unit)
|
||
grid.addWidget(self._btn_unit, 3, 1)
|
||
|
||
# 显示雷达
|
||
grid.addWidget(self._label("显示雷达距离"), 4, 0)
|
||
self._btn_radar = QPushButton(
|
||
"开启" if self._cfg["show_radar"] else "关闭")
|
||
self._btn_radar.clicked.connect(self._toggle_radar)
|
||
grid.addWidget(self._btn_radar, 4, 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(60)
|
||
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._sld_radar_thr.setValue(self._cfg["radar_alarm_dist"])
|
||
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()
|
||
self._cfg["radar_alarm_dist"] = self._sld_radar_thr.value()
|
||
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()
|
||
|
||
# 共享雷达阈值(list包装,方便跨线程引用)
|
||
self._radar_thr_ref = [50]
|
||
|
||
self.setWindowTitle("环视系统 - 串口联动")
|
||
self.resize(SCREEN_W, SCREEN_H)
|
||
|
||
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)
|
||
|
||
# 设置页
|
||
self.settings_panel = SettingsPanel()
|
||
self.stack.addWidget(self.settings_panel)
|
||
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._radar_thr_ref) # ← 传入共享阈值引用
|
||
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 = self.image_label.width()
|
||
lh = self.image_label.height()
|
||
if qimg.width() != lw or qimg.height() != 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._radar_thr_ref[0] = int(cfg["radar_alarm_dist"])
|
||
self.stack.setCurrentIndex(self.PAGE_MAIN)
|
||
print("[UI] 已退出设置,雷达阈值: {}dm".format(self._radar_thr_ref[0]))
|
||
|
||
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")
|
||
parser.add_argument("--remote-port", type=str, default="/dev/ttyS3")
|
||
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)
|
||
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] 雷达串口已启动: {} @ {} 发送周期: {}ms".format(
|
||
args.port, args.baudrate, SEND_INTERVAL_MS))
|
||
|
||
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()
|