#!/usr/bin/env python3 """制动器角度读取工具 — PyQt6 GUI 工作流程: 1. 打开视频 2. 第一帧:点击 O(圆心)→ 点击 M(初始尾端参考) 3. 点击「开始测量」→ 逐帧点击 M2,每点完一帧自动进下一帧 4. 可随时用「方向矫正」翻转角度符号 """ import sys import csv import os from pathlib import Path from typing import Optional import cv2 import numpy as np from PyQt6.QtCore import Qt, QTimer from PyQt6.QtGui import ( QImage, QPixmap, QPainter, QPen, QColor, QFont, QAction, ) from PyQt6.QtWidgets import ( QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QSlider, QFileDialog, QGroupBox, QDoubleSpinBox, QFormLayout, QMessageBox, QSplitter, QCheckBox, QSizePolicy, ) from actuator import VideoReader, signed_angle, curvature_from_three_points # ── 颜色 ────────────────────────────────────────────────── COLOR_O = QColor(0, 255, 0) COLOR_M = QColor(255, 200, 0) COLOR_M2 = QColor(255, 50, 50) COLOR_M2_PREV = QColor(120, 120, 120) # 灰 — 上一帧 M2 COLOR_REF_LINE = QColor(100, 200, 100) COLOR_CUR_LINE = QColor(255, 120, 120) COLOR_PREV_LINE = QColor(100, 100, 100) # 深灰 — O→M2' 上一帧线 RADIUS = 8 class FrameView(QLabel): """视频帧显示,鼠标点击选点:O → M → M2 → M2 ...""" def __init__(self, parent=None): super().__init__(parent) self.setMinimumSize(320, 180) self.setSizePolicy( QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding, ) self.setAlignment(Qt.AlignmentFlag.AlignCenter) self.setStyleSheet("background: #1a1a1a; border: 1px solid #333;") self.setMouseTracking(True) self.setAcceptDrops(True) self._frame: Optional[np.ndarray] = None self._pixmap: Optional[QPixmap] = None self._scale: float = 1.0 self._offset_x: float = 0.0 self._offset_y: float = 0.0 self.point_o: Optional[tuple] = None self.point_m: Optional[tuple] = None self.point_m2: Optional[tuple] = None self.point_m2_prev: Optional[tuple] = None # 上一帧 M2 self._select_mode: str = "o" # o → m → m2 self._block_clicks: bool = False self.on_point_selected = None self.on_key_pressed = None self.on_file_dropped = None @property def reference_ready(self) -> bool: return self.point_o is not None and self.point_m is not None def set_frame(self, frame: np.ndarray) -> None: self._frame = frame.copy() self._update_pixmap() def resizeEvent(self, event) -> None: super().resizeEvent(event) self._update_pixmap() def _update_pixmap(self) -> None: if self._frame is None: return h, w = self._frame.shape[:2] rgb = cv2.cvtColor(self._frame, cv2.COLOR_BGR2RGB) qimg = QImage(rgb.data, w, h, 3 * w, QImage.Format.Format_RGB888) self._pixmap = QPixmap.fromImage(qimg).scaled( self.width(), self.height(), Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation, ) self._scale = self._pixmap.width() / w self._offset_x = (self.width() - self._pixmap.width()) // 2 self._offset_y = (self.height() - self._pixmap.height()) // 2 self.update() def widget_to_image(self, wx: int, wy: int) -> tuple: return ((wx - self._offset_x) / self._scale, (wy - self._offset_y) / self._scale) def image_to_widget(self, ix: float, iy: float) -> tuple: return (ix * self._scale + self._offset_x, iy * self._scale + self._offset_y) def mousePressEvent(self, event) -> None: if event.button() != Qt.MouseButton.LeftButton: return if self._frame is None or self._block_clicks: return pt = self.widget_to_image( int(event.position().x()), int(event.position().y())) if self._select_mode == "o": self.point_o = pt self._select_mode = "m" elif self._select_mode == "m": self.point_m = pt self._select_mode = "m2" else: self.point_m2 = pt self.update() if self.on_point_selected: self.on_point_selected(self._select_mode, pt) def keyPressEvent(self, event) -> None: if self.on_key_pressed: self.on_key_pressed(event.key()) super().keyPressEvent(event) def dragEnterEvent(self, event) -> None: if event.mimeData().hasUrls(): event.acceptProposedAction() def dropEvent(self, event) -> None: urls = event.mimeData().urls() if urls and self.on_file_dropped: self.on_file_dropped(urls[0].toLocalFile()) def paintEvent(self, event) -> None: super().paintEvent(event) painter = QPainter(self) painter.setRenderHint(QPainter.RenderHint.Antialiasing) if self._pixmap: painter.drawPixmap( int(self._offset_x), int(self._offset_y), self._pixmap) if self._frame is None: painter.setPen(QColor(150, 150, 150)) painter.setFont(QFont("sans-serif", 14)) painter.drawText( self.rect(), Qt.AlignmentFlag.AlignCenter, "文件 → 打开视频") painter.end() return # 尺寸跟随窗口缩放 r = max(2, min(6, int(3 * self._scale))) lw = max(1, int(2 * self._scale)) fs = max(8, min(24, int(14 * self._scale))) # O → M if self.point_o and self.point_m: p1 = self.image_to_widget(*self.point_o) p2 = self.image_to_widget(*self.point_m) painter.setPen(QPen(COLOR_REF_LINE, lw, Qt.PenStyle.DashLine)) painter.drawLine(int(p1[0]), int(p1[1]), int(p2[0]), int(p2[1])) # O → M2' (上一帧) if self.point_o and self.point_m2_prev: p1 = self.image_to_widget(*self.point_o) p2 = self.image_to_widget(*self.point_m2_prev) painter.setPen(QPen(COLOR_PREV_LINE, max(1, lw // 2), Qt.PenStyle.DotLine)) painter.drawLine(int(p1[0]), int(p1[1]), int(p2[0]), int(p2[1])) # O → M2 if self.point_o and self.point_m2: p1 = self.image_to_widget(*self.point_o) p2 = self.image_to_widget(*self.point_m2) painter.setPen(QPen(COLOR_CUR_LINE, lw, Qt.PenStyle.DashLine)) painter.drawLine(int(p1[0]), int(p1[1]), int(p2[0]), int(p2[1])) self._draw_point(painter, self.point_o, COLOR_O, "O", r, fs) self._draw_point(painter, self.point_m, COLOR_M, "M", r, fs) self._draw_point(painter, self.point_m2_prev, COLOR_M2_PREV, "M2'", r, fs) self._draw_point(painter, self.point_m2, COLOR_M2, "M2", r, fs) painter.end() def _draw_point(self, painter, pt, color, label, r, fs): if pt is None: return wx, wy = self.image_to_widget(*pt) x, y = int(wx), int(wy) painter.setBrush(color) painter.setPen(QPen(Qt.GlobalColor.white, max(1, r // 4))) painter.drawEllipse(x - r, y - r, r * 2, r * 2) painter.setPen(Qt.GlobalColor.white) painter.setFont(QFont("monospace", fs, QFont.Weight.Bold)) painter.drawText(x + r + 2, y - r - 2, label) def reset_all(self) -> None: self.point_o = None self.point_m = None self.point_m2 = None self.point_m2_prev = None self._select_mode = "o" self.update() def reset_m2(self) -> None: """清除 M2,当前 M2 变为上一帧""" if self.point_m2 is not None: self.point_m2_prev = self.point_m2 self.point_m2 = None self.update() class MainWindow(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle("制动器角度读取 — ∠M2-O-M") self.setMinimumSize(1100, 650) self._video: Optional[VideoReader] = None self._current_frame_idx: int = 0 # 测量模式 self._measuring: bool = False self._flip_sign: bool = False self._prev_angle: Optional[float] = None # 上帧角度 self._measurements: list = [] self._setup_ui() self._setup_menu() # ══════════════ UI ═══════════════════════════════════ def _setup_ui(self) -> None: central = QWidget() self.setCentralWidget(central) root = QHBoxLayout(central) self.frame_view = FrameView() self.frame_view.on_point_selected = self._on_point_changed self.frame_view.on_key_pressed = self._on_key_pressed self.frame_view.on_file_dropped = self.load_video # 右侧面板(弹性宽度) panel = QWidget() panel.setMinimumWidth(240) panel.setMaximumWidth(400) pl = QVBoxLayout(panel) pl.setContentsMargins(4, 4, 4, 4) pl.setSpacing(4) # ── 选点状态 ── grp = QGroupBox("选点状态") gf = QFormLayout(grp) gf.setHorizontalSpacing(4) lbl_style = "font-size: 11px; font-weight: bold;" coord_style = "font-size: 10px;" self.lbl_o = QLabel("未选") self.lbl_o.setStyleSheet(f"{lbl_style} color: #0f0;") self.lbl_o.setWordWrap(True) self.lbl_m = QLabel("未选") self.lbl_m.setStyleSheet(f"{lbl_style} color: #fa0;") self.lbl_m.setWordWrap(True) self.lbl_m2 = QLabel("未选") self.lbl_m2.setStyleSheet(f"{lbl_style} color: #f33;") self.lbl_m2.setWordWrap(True) gf.addRow("O:", self.lbl_o) gf.addRow("M:", self.lbl_m) gf.addRow("M2:", self.lbl_m2) btn_row = QHBoxLayout() self.btn_reset_all = QPushButton("重置全部") self.btn_reset_all.clicked.connect(self._reset_all) self.btn_reset_m2 = QPushButton("清除 M2") self.btn_reset_m2.clicked.connect(self._reset_m2) btn_row.addWidget(self.btn_reset_all) btn_row.addWidget(self.btn_reset_m2) gf.addRow(btn_row) self.lbl_hint = QLabel("点击画面选 O → M → M2") self.lbl_hint.setStyleSheet("color: #888; font-size: 11px;") gf.addRow(self.lbl_hint) pl.addWidget(grp) # ── 方向矫正 ── grp_corr = QGroupBox("方向矫正") corr_layout = QVBoxLayout(grp_corr) self.chk_flip = QCheckBox("翻转角度符号 (± 取反)") self.chk_flip.toggled.connect(self._on_flip_toggled) corr_layout.addWidget(self.chk_flip) pl.addWidget(grp_corr) # ── 参数 ── grp_p = QGroupBox("参数") pf = QFormLayout(grp_p) self.spin_length = QDoubleSpinBox() self.spin_length.setRange(10, 5000) self.spin_length.setValue(100.0) self.spin_length.setSuffix(" mm") self.spin_length.valueChanged.connect(self._update_display) pf.addRow("致动器长度:", self.spin_length) self.spin_scale = QDoubleSpinBox() self.spin_scale.setRange(0.001, 100.0) self.spin_scale.setValue(1.0) self.spin_scale.setDecimals(4) self.spin_scale.setSuffix(" mm/px") self.spin_scale.valueChanged.connect(self._update_display) pf.addRow("像素比例:", self.spin_scale) pl.addWidget(grp_p) # ── 读数 ── grp_r = QGroupBox("∠M2-O-M") rf = QFormLayout(grp_r) self.lbl_angle = QLabel("—") self.lbl_angle.setStyleSheet( "font-size: 32px; font-weight: bold; color: #0af;") self.lbl_angle_prev = QLabel("") self.lbl_angle_prev.setStyleSheet( "font-size: 14px; color: #888;") self.lbl_curvature = QLabel("—") self.lbl_curvature.setStyleSheet("font-size: 14px; color: #aaa;") self.lbl_frame = QLabel("—") self.lbl_progress = QLabel("") self.lbl_progress.setStyleSheet("color: #888; font-size: 11px;") rf.addRow("角度:", self.lbl_angle) rf.addRow("上帧:", self.lbl_angle_prev) rf.addRow("曲率:", self.lbl_curvature) rf.addRow("当前帧:", self.lbl_frame) rf.addRow(self.lbl_progress) pl.addWidget(grp_r) # ── 测量控制 ── grp_ctrl = QGroupBox("测量控制") ctrl_layout = QVBoxLayout(grp_ctrl) self.btn_start = QPushButton("▶ 开始测量") self.btn_start.setStyleSheet( "QPushButton { background: #1a6b1a; color: #fff; font-weight: bold; " "padding: 8px; border-radius: 4px; }" "QPushButton:hover { background: #228b22; }") self.btn_start.clicked.connect(self._start_measuring) ctrl_layout.addWidget(self.btn_start) self.btn_stop = QPushButton("⏹ 停止测量") self.btn_stop.setEnabled(False) self.btn_stop.setStyleSheet( "QPushButton { background: #8b1a1a; color: #fff; font-weight: bold; " "padding: 8px; border-radius: 4px; }" "QPushButton:hover { background: #a52a2a; }") self.btn_stop.clicked.connect(self._stop_measuring) ctrl_layout.addWidget(self.btn_stop) pl.addWidget(grp_ctrl) # ── 帧导航 ── grp_n = QGroupBox("手动导航") nl = QVBoxLayout(grp_n) self.slider = QSlider(Qt.Orientation.Horizontal) self.slider.setMinimum(0) self.slider.setMaximum(0) self.slider.valueChanged.connect(self._on_slider) nl.addWidget(self.slider) bn = QHBoxLayout() self.btn_prev = QPushButton("◀") self.btn_prev.clicked.connect(self._prev_frame) self.btn_next = QPushButton("▶") self.btn_next.clicked.connect(self._next_frame) bn.addWidget(self.btn_prev) bn.addWidget(self.btn_next) nl.addLayout(bn) pl.addWidget(grp_n) # ── 导出 ── self.btn_export = QPushButton("导出测量数据 (CSV)") self.btn_export.clicked.connect(self._export_csv) self.btn_export.setEnabled(False) pl.addWidget(self.btn_export) pl.addStretch() splitter = QSplitter(Qt.Orientation.Horizontal) splitter.addWidget(self.frame_view) splitter.addWidget(panel) splitter.setStretchFactor(0, 1) root.addWidget(splitter) self.statusBar().showMessage("请打开视频文件") def _setup_menu(self) -> None: m = self.menuBar() fm = m.addMenu("文件") fm.addAction(QAction("打开视频...", self, shortcut="Ctrl+O", triggered=self._open_video)) fm.addSeparator() fm.addAction(QAction("退出", self, triggered=self.close)) # ══════════════ 视频 ═════════════════════════════════ def _open_video(self) -> None: path, _ = QFileDialog.getOpenFileName( self, "打开视频", "", "视频文件 (*.mp4 *.avi *.mov *.mkv);;所有文件 (*)") if path: self.load_video(path) def load_video(self, path: str) -> None: try: self._video = VideoReader(path) except Exception as e: QMessageBox.critical(self, "错误", str(e)) return self._current_frame_idx = 0 self.slider.setMaximum(self._video.frame_count - 1) self.slider.setValue(0) self.frame_view.reset_all() self._measurements.clear() self._prev_angle = None self.btn_export.setEnabled(True) self._show_frame(0) self.statusBar().showMessage( f"已加载: {Path(path).name} | " f"{self._video.frame_count} 帧 | " f"{self._video.fps:.1f} fps") def _show_frame(self, idx: int) -> None: """显示指定帧""" if self._video is None: return frame = self._video.read_frame(idx) if frame is None: return self._current_frame_idx = idx self.frame_view.set_frame(frame) self.slider.blockSignals(True) self.slider.setValue(idx) self.slider.blockSignals(False) self._update_display() def _update_display(self) -> None: self.lbl_frame.setText(str(self._current_frame_idx)) o, m, m2 = self.frame_view.point_o, self.frame_view.point_m, self.frame_view.point_m2 if o and m and m2: angle = signed_angle(o, m, m2) if self._flip_sign: angle = -angle self.lbl_angle.setText(f"{angle:+.2f}°") scale = self.spin_scale.value() curv = curvature_from_three_points(o, m, m2, scale) self.lbl_curvature.setText(f"{curv:.6f} mm⁻¹") else: self.lbl_angle.setText("—") self.lbl_curvature.setText("—") # 上帧角度 if self._prev_angle is not None: self.lbl_angle_prev.setText(f"{self._prev_angle:+.2f}°") else: self.lbl_angle_prev.setText("") if self._measuring: total = self._video.frame_count if self._video else 0 self.lbl_progress.setText( f"测量中: {len(self._measurements)}/{total} 帧") else: self.lbl_progress.setText("") # ══════════════ 导航 ═════════════════════════════════ def _on_slider(self, value: int) -> None: if not self._measuring and value != self._current_frame_idx: self._show_frame(value) def _prev_frame(self) -> None: if self._video and not self._measuring: self._show_frame(max(0, self._current_frame_idx - 1)) def _next_frame(self) -> None: if self._video and not self._measuring: self._show_frame(min(self._video.frame_count - 1, self._current_frame_idx + 1)) # ══════════════ 测量模式 ═════════════════════════════ def _start_measuring(self) -> None: if self._video is None: return if not self.frame_view.reference_ready: QMessageBox.warning(self, "警告", "请先在首帧选定 O 和 M") return self._measurements.clear() self._prev_angle = None self._measuring = True self.frame_view._select_mode = "m2" self.frame_view._block_clicks = False self.frame_view.setFocus() # 接收键盘事件 # 禁用无关控件 self.btn_start.setEnabled(False) self.btn_stop.setEnabled(True) self.slider.setEnabled(False) self.btn_prev.setEnabled(False) self.btn_next.setEnabled(False) self.btn_reset_all.setEnabled(False) self.btn_reset_m2.setEnabled(False) # 从当前帧开始 self._show_frame(self._current_frame_idx) self.lbl_hint.setText("⏳ 点击 M2 或按 → 跳过") self.lbl_hint.setStyleSheet( "color: #0af; font-size: 12px; font-weight: bold;") self._update_display() def _on_key_pressed(self, key: int) -> None: if not self._measuring: return if key == Qt.Key.Key_Right: if self.frame_view.point_m2 is not None: self._record_and_advance() else: self._record_skip() elif key == Qt.Key.Key_Left: if self._current_frame_idx > 0: self._current_frame_idx -= 1 self._show_frame(self._current_frame_idx) def _record_skip(self) -> None: """跳过当前帧,记录 0""" o = self.frame_view.point_o m = self.frame_view.point_m if o is None or m is None: return self._measurements.append(( self._current_frame_idx, 0.0, o[0], o[1], )) self._prev_angle = 0.0 QApplication.processEvents() QTimer.singleShot(50, self._advance_frame) def _record_and_advance(self) -> None: o = self.frame_view.point_o m = self.frame_view.point_m m2 = self.frame_view.point_m2 if o is None or m is None or m2 is None: return angle = signed_angle(o, m, m2) if self._flip_sign: angle = -angle self._measurements.append(( self._current_frame_idx, angle, m2[0], m2[1], )) self._prev_angle = angle QApplication.processEvents() QTimer.singleShot(150, self._advance_frame) def _advance_frame(self) -> None: if not self._measuring: return next_idx = self._current_frame_idx + 1 if next_idx >= self._video.frame_count: self._stop_measuring() self.lbl_hint.setText(f"✓ 测量完成 · 共 {len(self._measurements)} 帧") self.lbl_hint.setStyleSheet( "color: #0f0; font-size: 12px; font-weight: bold;") return self._go_to_frame(next_idx) def _go_to_frame(self, idx: int) -> None: """测量模式中跳转并清空 M2""" self.frame_view.reset_m2() self._show_frame(idx) def _stop_measuring(self) -> None: self._measuring = False self.frame_view._block_clicks = False self.btn_start.setEnabled(True) self.btn_stop.setEnabled(False) self.slider.setEnabled(True) self.btn_prev.setEnabled(True) self.btn_next.setEnabled(True) self.btn_reset_all.setEnabled(True) self.btn_reset_m2.setEnabled(True) self.lbl_hint.setText( f"已停止 · 共 {len(self._measurements)} 帧数据") self.lbl_hint.setStyleSheet("color: #888; font-size: 11px;") self._update_display() # ══════════════ 选点回调 ═════════════════════════════ def _on_point_changed(self, mode: str, pt: tuple) -> None: x, y = int(round(pt[0])), int(round(pt[1])) if mode == "o": self.lbl_o.setText(f"({x}, {y})") self.lbl_hint.setText("点击画面选 M") elif mode == "m": self.lbl_m.setText(f"({x}, {y})") if not self._measuring: self.lbl_hint.setText("点击画面选 M2,或点「开始测量」") elif mode == "m2": self.lbl_m2.setText(f"({x}, {y})") self._update_display() if self._measuring: self._record_and_advance() # ══════════════ 方向矫正 ═════════════════════════════ def _on_flip_toggled(self, checked: bool) -> None: self._flip_sign = checked self._update_display() # ══════════════ 重置 ═════════════════════════════════ def _reset_all(self) -> None: if self._measuring: self._stop_measuring() self.frame_view.reset_all() self._measurements.clear() self._prev_angle = None self.lbl_o.setText("未选") self.lbl_m.setText("未选") self.lbl_m2.setText("未选") self.lbl_hint.setText("点击画面选 O → M → M2") self.lbl_hint.setStyleSheet("color: #888; font-size: 11px;") self._update_display() def _reset_m2(self) -> None: if self._measuring: return self.frame_view.reset_m2() self.lbl_m2.setText("未选") self.lbl_hint.setText("点击画面选 M2(当前尾端)") self._update_display() # ══════════════ 导出 ═════════════════════════════════ def _export_csv(self) -> None: if not self._measurements: QMessageBox.warning(self, "警告", "没有测量数据,请先「开始测量」") return csv_path = QFileDialog.getSaveFileName( self, "导出 CSV", "angle_data.csv", "CSV (*.csv)")[0] if not csv_path: return path = Path(csv_path) o = self.frame_view.point_o m = self.frame_view.point_m scale = self.spin_scale.value() # 截图目录 frames_dir = path.parent / f"{path.stem}_frames" frames_dir.mkdir(exist_ok=True) with open(path, "w", newline="", encoding="utf-8") as f: writer = csv.writer(f) writer.writerow([ "frame", "angle_deg", "O_x", "O_y", "M_x", "M_y", "M2_x", "M2_y", "curvature_mm-1", "screenshot", ]) for frame_idx, angle, m2x, m2y in self._measurements: frame = self._video.read_frame(frame_idx) if frame is not None: annotated = self._draw_annotations( frame, o, m, (m2x, m2y)) img_name = f"frame_{frame_idx:04d}.png" cv2.imwrite(str(frames_dir / img_name), annotated) else: img_name = "" curv = curvature_from_three_points(o, m, (m2x, m2y), scale) writer.writerow([ frame_idx, f"{angle:.4f}", f"{o[0]:.1f}", f"{o[1]:.1f}", f"{m[0]:.1f}", f"{m[1]:.1f}", f"{m2x:.1f}", f"{m2y:.1f}", f"{curv:.8f}", f"{frames_dir.name}/{img_name}" if img_name else "", ]) QMessageBox.information( self, "导出成功", f"已导出 {len(self._measurements)} 条数据 + 截图到:\n" f"{path}\n{frames_dir}/") def _draw_annotations(self, frame, o, m, m2): """在帧上绘制 O/M/M2 标记和连线""" annotated = frame.copy() def pt(p): return (int(round(p[0])), int(round(p[1]))) cv2.line(annotated, pt(o), pt(m), (100, 200, 100), 2, cv2.LINE_AA) cv2.line(annotated, pt(o), pt(m2), (100, 100, 255), 2, cv2.LINE_AA) cv2.circle(annotated, pt(o), 8, (0, 255, 0), -1, cv2.LINE_AA) cv2.circle(annotated, pt(o), 9, (255, 255, 255), 2, cv2.LINE_AA) cv2.circle(annotated, pt(m), 8, (0, 200, 255), -1, cv2.LINE_AA) cv2.circle(annotated, pt(m), 9, (255, 255, 255), 2, cv2.LINE_AA) cv2.circle(annotated, pt(m2), 8, (50, 50, 255), -1, cv2.LINE_AA) cv2.circle(annotated, pt(m2), 9, (255, 255, 255), 2, cv2.LINE_AA) font = cv2.FONT_HERSHEY_SIMPLEX cv2.putText(annotated, "O", (pt(o)[0] + 12, pt(o)[1] - 8), font, 0.6, (255, 255, 255), 2, cv2.LINE_AA) cv2.putText(annotated, "M", (pt(m)[0] + 12, pt(m)[1] - 8), font, 0.6, (255, 255, 255), 2, cv2.LINE_AA) cv2.putText(annotated, "M2", (pt(m2)[0] + 12, pt(m2)[1] - 8), font, 0.6, (255, 255, 255), 2, cv2.LINE_AA) return annotated # ══════════════ 生命周期 ═════════════════════════════ def closeEvent(self, event) -> None: if self._video: self._video.close() super().closeEvent(event) def main(): # 禁用自动高分屏缩放,修复光标变小问题 os.environ["QT_AUTO_SCREEN_SCALE_FACTOR"] = "0" app = QApplication(sys.argv) app.setStyle("Fusion") app.setStyleSheet(""" QMainWindow { background: #1e1e1e; } QGroupBox { color: #ccc; border: 1px solid #444; border-radius: 6px; margin-top: 12px; padding-top: 10px; font-weight: bold; } QGroupBox::title { subcontrol-origin: margin; left: 10px; padding: 0 6px; } QLabel { color: #ddd; } QPushButton { background: #3a3a3a; color: #ddd; border: 1px solid #555; border-radius: 4px; padding: 5px 12px; } QPushButton:hover { background: #4a4a4a; } QPushButton:pressed { background: #2a2a2a; } QPushButton:disabled { color: #666; } QSlider::groove:horizontal { height: 6px; background: #444; border-radius: 3px; } QSlider::handle:horizontal { width: 14px; height: 14px; margin: -5px 0; background: #0af; border-radius: 7px; } QSpinBox, QDoubleSpinBox { background: #2a2a2a; color: #ddd; border: 1px solid #555; border-radius: 3px; padding: 3px; } QCheckBox { color: #ddd; } QStatusBar { color: #888; } """) window = MainWindow() window.show() sys.exit(app.exec()) if __name__ == "__main__": main()