Actuators/main.py

785 lines
29 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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()