import cv2 import threading import sys, os, time import argparse import numpy as np import serial import json import glob import shutil from collections import deque from datetime import datetime from PyQt5.QtWidgets import (QApplication, QLabel, QWidget, QVBoxLayout, QHBoxLayout, QMainWindow, QStackedWidget, QFrame, QListWidget, QListWidgetItem, QSizePolicy, QProgressBar, QGridLayout) from PyQt5.QtGui import QImage, QPixmap, QFont from PyQt5.QtCore import Qt, QTimer, pyqtSignal, QObject, QSize 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) # ========================================================== # 遥控键码 # ========================================================== KEY_UP = 0x01 KEY_DOWN = 0x09 KEY_LEFT = 0x04 KEY_RIGHT = 0x06 KEY_BACK = 0x02 KEY_OK = 0x05 # ========================================================== # 参数 # ========================================================== VIDEO_DIR = "/videos" SEGMENT_SECS = 180 RECORD_FPS = 10 CELL_W, CELL_H = 640, 360 GRID_W, GRID_H = 1280, 720 DISK_WARN_PCT = 90 SEND_INTERVAL_MS = 100 MODE_360 = 0 MODE_QUAD = 1 DIR_CN = {"front":"前方","left":"左方","back":"后方","right":"右方"} WARN_COLOR = (255, 80, 80) SAFE_COLOR = (100, 255, 100) # ========================================================== # 配置 # ========================================================== CONFIG_PATH = os.path.join(os.getcwd(), "surround_config.json") DEFAULT_CONFIG = { "carousel_interval": 4, "brightness": 50, "radar_unit": 0, "show_radar": 1, "radar_alarm_dist": 50, "display_mode": MODE_360, } def load_config(): if os.path.exists(CONFIG_PATH): try: with open(CONFIG_PATH, "r") as f: cfg = json.load(f) for k, v in DEFAULT_CONFIG.items(): cfg.setdefault(k, v) return cfg except: pass return dict(DEFAULT_CONFIG) def save_config(cfg): try: with open(CONFIG_PATH, "w") as f: json.dump(cfg, f, indent=2, ensure_ascii=False) except: pass # ========================================================== # 工具函数 # ========================================================== def get_disk_info_str(path): try: usage = shutil.disk_usage(path) pct = usage.used * 100 // usage.total return "存储: {:.1f}G/{:.1f}G ({}%)".format(usage.used/1024**3, usage.total/1024**3, pct), pct except: return "磁盘未知", 0 class FontManager: _fonts = {} @classmethod def get(cls, size=20): if size not in cls._fonts: p = "/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc" if not os.path.exists(p): p = None cls._fonts[size] = ImageFont.truetype(p, size) if p else ImageFont.load_default() return cls._fonts[size] def add_texts_to_pil(pil_image, text_list): draw = ImageDraw.Draw(pil_image) for item in text_list: x, y, text, color, size = item font = FontManager.get(size) draw.text((x+1, y+1), text, font=font, fill=(0,0,0)) draw.text((x, y), text, font=font, fill=color) # ========================================================== # 串口通信 (TTYS0) # ========================================================== class SerialWorker(threading.Thread): def __init__(self, port="/dev/ttyS0", baudrate=115200): super().__init__(daemon=True) self.port, self.baudrate = port, baudrate self.running = True self._rx_lock = threading.Lock() self._radar_dist, self._stopped, self._active_dirs = 0, False, [] self._tx_lock = threading.Lock() self._tx_data = [0,0,0,0, 50] # F,L,B,R, Thr self._rx_enabled = True def set_rx_enabled(self, e): self._rx_enabled = e def update_send(self, f, l, b, r, thr): with self._tx_lock: self._tx_data = [f,l,b,r, thr] def run(self): try: ser = serial.Serial(self.port, self.baudrate, timeout=0.05) except: return def _send_loop(): while self.running: with self._tx_lock: d = self._tx_data chk = sum(d[:4] + [0, d[4]]) & 0xFF frame = bytes([0x55, d[0], d[1], d[2], d[3], 0x00, d[4], chk, 0xAA]) ser.write(frame) time.sleep(SEND_INTERVAL_MS/1000.0) threading.Thread(target=_send_loop, daemon=True).start() buf = bytearray() while self.running: if not self._rx_enabled: time.sleep(0.1); continue b = ser.read(1) if not b: continue buf.append(b[0]) if len(buf) == 9: if buf[0] == 0x55 and buf[8] == 0xAA: with self._rx_lock: self._active_dirs = [n for i,n in enumerate(["front","left","back","right"]) if buf[i+1]] self._radar_dist, self._stopped = buf[5], buf[6] > 0 buf.clear() elif len(buf) > 9: buf.clear() # ========================================================== # 渲染线程 (消除黑边关键) # ========================================================== class RenderWorker(threading.Thread): def __init__(self, bird_worker, raw_worker, serial_worker, carousel, cfg, sw, sh): super().__init__(daemon=True) self.bird_w, self.raw_w, self.ser_w = bird_worker, raw_worker, serial_worker self.carousel, self.cfg = carousel, cfg self.sw, self.sh = sw, sh self.running = True self._out_q = deque(maxlen=2) def get_qimage(self): try: return self._out_q[-1] except: return None def run(self): while self.running: bird = self.bird_w.get_bird() if bird is None: time.sleep(0.02); continue active, dist, stopped = self.ser_w.get_state() cur_dir, rem = self.carousel.update(active) # 分配宽度 bw = self.sw // 3 fw = self.sw - bw # 强制拉伸(消除黑边) bird_part = cv2.resize(bird, (bw, self.sh)) raw_f = self.raw_w.get_frame(cur_dir if cur_dir else "front") if raw_f is None: raw_f = np.zeros((self.sh, fw, 3), np.uint8) front_part = cv2.resize(raw_f, (fw, self.sh)) # 亮度 alpha = self.cfg.get("brightness", 50) / 50.0 if alpha != 1.0: front_part = cv2.convertScaleAbs(front_part, alpha=alpha) canvas = np.hstack((bird_part, front_part)) pil_img = Image.fromarray(cv2.cvtColor(canvas, cv2.COLOR_BGR2RGB)) # 文字叠加 (字号加大) texts = [(10, 10, "鸟瞰视图", (0,255,100), 25)] if cur_dir: texts.append((bw+20, 20, "{} 有人".format(DIR_CN[cur_dir]), WARN_COLOR, 40)) # 雷达 if self.cfg.get("show_radar"): txt = "雷达: {}cm".format(dist) texts.append((bw+20, self.sh-80, txt, (255,255,0), 30)) add_texts_to_pil(pil_img, texts) arr = np.array(pil_img) self._out_q.append(QImage(arr.data, arr.shape[1], arr.shape[0], QImage.Format_RGB888)) # 反向串口发送 presence = [1 if d in active else 0 for d in ["front","left","back","right"]] self.ser_w.update_send(*presence, self.cfg.get("radar_alarm_dist", 50)) time.sleep(0.03) # ========================================================== # 四分屏渲染 # ========================================================== class QuadWorker(threading.Thread): def __init__(self, raw_worker, serial_worker, cfg, sw, sh): super().__init__(daemon=True) self.raw_w, self.ser_w, self.cfg = raw_worker, serial_worker, cfg self.sw, self.sh = sw, sh self.running = True self._out_q = deque(maxlen=2) def run(self): cw, ch = self.sw // 2, self.sh // 2 while self.running: frames = [] for n in ["front","left","back","right"]: f = self.raw_w.get_frame(n) frames.append(cv2.resize(f if f is not None else np.zeros((ch,cw,3), np.uint8), (cw,ch))) top = np.hstack((frames[0], frames[1])) bot = np.hstack((frames[2], frames[3])) canvas = np.vstack((top, bot)) rgb = cv2.cvtColor(canvas, cv2.COLOR_BGR2RGB) self._out_q.append(QImage(rgb.data, self.sw, self.sh, QImage.Format_RGB888)) self.ser_w.update_send(0,0,0,0, self.cfg.get("radar_alarm_dist", 50)) time.sleep(0.03) # ========================================================== # 回放面板 (充满右侧) # ========================================================== class PlaybackPanel(QWidget): def __init__(self, parent=None): super().__init__(parent) self._video_list = [] self._play_cap = None self._timer = QTimer() self._timer.timeout.connect(self._step) layout = QHBoxLayout(self) # 左侧列表放大 self.list_w = QListWidget() self.list_w.setFixedWidth(300) self.list_w.setStyleSheet("font-size: 22px; background:#0d1117; color:white;") layout.addWidget(self.list_w) # 右侧画面 self.view = QLabel("选择视频按 OK") self.view.setStyleSheet("background:black; border:2px solid #333;") self.view.setAlignment(Qt.AlignCenter) self.view.setScaledContents(True) # ★ 关键:拉伸充满 layout.addWidget(self.view, 1) def load(self): self.list_w.clear() self._video_list = sorted(glob.glob(VIDEO_DIR + "/rec_*.mp4"), reverse=True) for f in self._video_list: item = QListWidgetItem(os.path.basename(f)) item.setSizeHint(QSize(0, 60)) self.list_w.addItem(item) self.list_w.setCurrentRow(0) def on_key(self, code): if code == KEY_UP: self.list_w.setCurrentRow(max(0, self.list_w.currentRow()-1)) elif code == KEY_DOWN: self.list_w.setCurrentRow(min(len(self._video_list)-1, self.list_w.currentRow()+1)) elif code == KEY_OK: self._start_play() def _start_play(self): if self._play_cap: self._play_cap.release() self._play_cap = cv2.VideoCapture(self._video_list[self.list_w.currentRow()]) self._timer.start(33) def _step(self): ret, frame = self._play_cap.read() if ret: rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) qimg = QImage(rgb.data, rgb.shape[1], rgb.shape[0], QImage.Format_RGB888) self.view.setPixmap(QPixmap.fromImage(qimg)) else: self._timer.stop() def cleanup(self): self._timer.stop() if self._play_cap: self._play_cap.release() # ========================================================== # 设置面板 (字体放大) # ========================================================== class SettingsPanel(QWidget): def __init__(self, cfg, parent=None): super().__init__(parent) self.cfg = cfg self.cur = 0 self.sub_mode = False # False=Menu, True=Playback self.menu = [ {"n":"显示模式", "k":"display_mode", "opts":["360环视","四分屏"]}, {"n":"轮播间隔", "k":"carousel_interval", "min":1, "max":10}, {"n":"画面亮度", "k":"brightness", "min":0, "max":100}, {"n":"雷达阈值", "k":"radar_alarm_dist", "min":10, "max":200}, {"n":"视频回放", "k":"page"} ] self.stack = QStackedWidget() # Menu Page self.menu_page = QWidget() mv = QVBoxLayout(self.menu_page) self.list_v = QListWidget() # ★ 列表样式:大字体,大行高 self.list_v.setStyleSheet(""" QListWidget { font-size: 26px; background:#1a1a2e; color:white; border:none; } QListWidget::item { height: 70px; border-bottom:1px solid #333; padding-left:20px; } QListWidget::item:selected { background:#e94560; } """) mv.addWidget(self.list_v) self.val_l = QLabel() self.val_l.setAlignment(Qt.AlignCenter) self.val_l.setStyleSheet("font-size: 50px; color:#e94560; padding:20px;") mv.addWidget(self.val_l) self.stack.addWidget(self.menu_page) self.play_panel = PlaybackPanel() self.stack.addWidget(self.play_panel) layout = QVBoxLayout(self) layout.setContentsMargins(0,0,0,0) layout.addWidget(self.stack) def load(self): self.sub_mode = False self.stack.setCurrentIndex(0) self._refresh() def _refresh(self): self.list_v.clear() for i, m in enumerate(self.menu): self.list_v.addItem(m["n"]) self.list_v.setCurrentRow(self.cur) # 显示当前值 item = self.menu[self.cur] if "opts" in item: self.val_l.setText(item["opts"][self.cfg[item["k"]]]) elif "min" in item: self.val_l.setText(str(self.cfg[item["k"]])) else: self.val_l.setText("按 OK 进入") def on_key(self, code): if self.sub_mode: if code == KEY_BACK: self.sub_mode = False self.play_panel.cleanup() self.stack.setCurrentIndex(0) else: self.play_panel.on_key(code) return if code == KEY_UP: self.cur = (self.cur-1)%len(self.menu); self._refresh() elif code == KEY_DOWN: self.cur = (self.cur+1)%len(self.menu); self._refresh() elif code == KEY_LEFT or code == KEY_RIGHT: item = self.menu[self.cur] step = 1 if code == KEY_RIGHT else -1 if "opts" in item: self.cfg[item["k"]] = (self.cfg[item["k"]] + step) % len(item["opts"]) elif "min" in item: self.cfg[item["k"]] = max(item["min"], min(item["max"], self.cfg[item["k"]] + step * 5)) self._refresh() elif code == KEY_OK: if self.menu[self.cur]["k"] == "page": self.sub_mode = True self.play_panel.load() self.stack.setCurrentIndex(1) elif "opts" in self.menu[self.cur]: self.cfg[self.menu[self.cur]["k"]] = (self.cfg[self.menu[self.cur]["k"]] + 1) % len(self.menu[self.cur]["opts"]) self._refresh() # ========================================================== # 主窗口 # ========================================================== class MainWindow(QMainWindow): def __init__(self, multi_cam, ser_w, remote_w, cfg): super().__init__() self.cfg = cfg self.ser_w = ser_w self.showFullScreen() sw, sh = self.width(), self.height() self.stack = QStackedWidget() self.setCentralWidget(self.stack) # View Page self.view_l = QLabel() self.view_l.setScaledContents(True) # ★ 强制铺满 self.stack.addWidget(self.view_l) self.set_panel = SettingsPanel(cfg) self.stack.addWidget(self.set_panel) # Workers self.raw_w = RawCaptureWorker(multi_cam.caps, multi_cam.names) self.raw_w.start() self.bird_w = BirdViewWorker(self.raw_w, multi_cam.camera_models, multi_cam.birdview) self.bird_w.start() self.render_360 = RenderWorker(self.bird_w, self.raw_w, ser_w, CarouselScheduler(), cfg, sw, sh) self.render_360.start() self.render_quad = QuadWorker(self.raw_w, ser_w, cfg, sw, sh) self.render_quad.start() RecordWorker(self.raw_w).start() self.timer = QTimer() self.timer.timeout.connect(self._flush) self.timer.start(20) remote_w.signals.key_pressed.connect(self._on_key) def _flush(self): if self.stack.currentIndex() != 0: return qimg = self.render_quad._out_q[-1] if self.cfg["display_mode"] == 1 else self.render_360.get_qimage() if qimg: self.view_l.setPixmap(QPixmap.fromImage(qimg)) def _on_key(self, code): if self.stack.currentIndex() == 0: if code == KEY_OK: self.set_panel.load() self.stack.setCurrentIndex(1) else: if code == KEY_BACK and not self.set_panel.sub_mode: save_config(self.cfg) self.ser_w.set_rx_enabled(self.cfg["display_mode"] == 0) self.stack.setCurrentIndex(0) else: self.set_panel.on_key(code) # ========================================================== # 入口 # ========================================================== def main(): cfg = load_config() app = QApplication(sys.argv) ser_w = SerialWorker() ser_w.start() rem_w = RemoteWorker() rem_w.start() cam = MultiCameraBirdView() if not cam.running: return win = MainWindow(cam, ser_w, rem_w, cfg) win.show() sys.exit(app.exec_()) if __name__ == "__main__": main()