main_window.py 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779
  1. import os
  2. import logging
  3. import threading
  4. import queue
  5. from PySide6.QtWidgets import (QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
  6. QPushButton, QLabel, QGridLayout, QScrollArea,
  7. QToolButton, QFrame, QApplication, QCheckBox)
  8. from PySide6.QtCore import Qt, QTimer, QSize
  9. from PySide6.QtGui import QIcon, QPixmap, QResizeEvent, QKeyEvent, QCloseEvent
  10. from .models import *
  11. from .rtsp_player import RTSPPlayer
  12. from .rfid_client import RFIDClient
  13. from .modbus_client import ModbusClient
  14. from .mqtt_client import MQTTClient
  15. import pyttsx3
  16. logger = logging.getLogger(__name__)
  17. class MainWindow(QMainWindow):
  18. # 防抖阈值(连续相同状态次数)
  19. STATE_THRESHOLD = 3
  20. # 放包判断位移阈值(像素)
  21. RELEASE_DELTA_THRESHOLD = 5
  22. def __init__(self, config: Config):
  23. super().__init__()
  24. self.config = config
  25. self.buckets = config.buckets
  26. self.active_bucket: BucketModel = None
  27. self.switch_mode = SwitchMode.AUTO
  28. self.left_player: RTSPPlayer = None
  29. self.right_player: RTSPPlayer = None
  30. self.rfid_client = None
  31. self.modbus_client = None
  32. self.mqtt_client = None
  33. self.video_aspect_ratio = 16 / 9
  34. self.left_label = None
  35. self.right_label = None
  36. # 挂钩状态相关
  37. self.hook_state = HookState.IDLE
  38. self.prev_state = HookState.IDLE
  39. self.state_counter = 0
  40. # 存储左右画面最近检测对象
  41. self.left_objects = []
  42. self.right_objects = []
  43. # 前一帧 hook/lug 中心位置(用于轨迹判断)
  44. self.prev_left_hook = None
  45. self.prev_right_hook = None
  46. self.prev_left_lug = None
  47. self.prev_right_lug = None
  48. # 语音引擎
  49. self.tts_engine = pyttsx3.init()
  50. self.tts_engine.setProperty('rate', 150)
  51. self.last_voice_state = None
  52. self.voice_enabled = True
  53. self._voice_queue = queue.Queue()
  54. self._voice_thread = threading.Thread(target=self._voice_worker, daemon=True)
  55. self._voice_thread.start()
  56. self.setWindowTitle(config.app_name)
  57. screen = QApplication.primaryScreen()
  58. screen_geometry = screen.availableGeometry()
  59. screen_width = screen_geometry.width()
  60. screen_height = screen_geometry.height()
  61. window_width = int(screen_width * 0.9)
  62. window_height = int(screen_height * 0.85)
  63. x = (screen_width - window_width) // 2
  64. y = (screen_height - window_height) // 2
  65. self.setGeometry(x, y, window_width, window_height)
  66. self.setMinimumSize(800, 600)
  67. self.setup_ui()
  68. self.showFullScreen()
  69. self.update_video_size()
  70. self.init_hardware()
  71. self.timer = QTimer()
  72. self.timer.timeout.connect(self.check_idle)
  73. self.timer.start(1000)
  74. def setup_ui(self):
  75. central = QWidget()
  76. self.setCentralWidget(central)
  77. main_layout = QVBoxLayout(central)
  78. main_layout.setContentsMargins(10, 10, 10, 10)
  79. main_layout.setSpacing(10)
  80. # 标题栏
  81. title_widget = QWidget()
  82. title_layout = QHBoxLayout(title_widget)
  83. title_layout.setContentsMargins(0, 0, 0, 0)
  84. self.title_label = QLabel(self.config.app_name)
  85. self.title_label.setStyleSheet("font-size: 22px; font-weight: bold; padding: 5px;")
  86. title_layout.addWidget(self.title_label)
  87. title_layout.addStretch()
  88. btn_style = """
  89. QPushButton {
  90. background-color: transparent;
  91. border: none;
  92. font-size: 18px;
  93. padding: 6px 12px;
  94. border-radius: 5px;
  95. }
  96. QPushButton:hover {
  97. background-color: rgba(255, 255, 255, 0.1);
  98. }
  99. """
  100. self.min_btn = QPushButton("—")
  101. self.min_btn.setStyleSheet(btn_style)
  102. self.min_btn.clicked.connect(self.showMinimized)
  103. self.close_btn = QPushButton("✕")
  104. self.close_btn.setStyleSheet(btn_style)
  105. self.close_btn.clicked.connect(self.close)
  106. title_layout.addWidget(self.min_btn)
  107. title_layout.addWidget(self.close_btn)
  108. main_layout.addWidget(title_widget)
  109. # 工位区域
  110. bucket_group = QFrame()
  111. bucket_group.setStyleSheet("QFrame { border-radius: 8px; padding: 8px; }")
  112. bucket_layout = QVBoxLayout(bucket_group)
  113. scroll_area = QScrollArea()
  114. scroll_area.setWidgetResizable(True)
  115. scroll_area.setFrameShape(QFrame.NoFrame)
  116. scroll_area.setStyleSheet("QScrollArea { background: transparent; border: none; }")
  117. self.bucket_container = QWidget()
  118. self.bucket_grid = QGridLayout(self.bucket_container)
  119. self.bucket_grid.setSpacing(15)
  120. self.bucket_grid.setContentsMargins(10, 10, 10, 10)
  121. self.bucket_grid.setAlignment(Qt.AlignLeft | Qt.AlignTop)
  122. self.bucket_buttons = []
  123. # 原代码(删除)
  124. # for idx, bucket in enumerate(self.buckets):
  125. # btn = QToolButton()
  126. # ...
  127. # row = idx // 6
  128. # col = idx % 6
  129. # self.bucket_grid.addWidget(btn, row, col)
  130. # self.bucket_buttons.append((btn, bucket))
  131. # 新代码:按 code_x 分组纵向排列
  132. from collections import defaultdict
  133. groups = defaultdict(list)
  134. for bucket in self.buckets:
  135. groups[bucket.code_x].append(bucket)
  136. sorted_keys = sorted(groups.keys())
  137. self.bucket_buttons = [] # 重置
  138. col = 0
  139. for key in sorted_keys:
  140. buckets_in_group = groups[key]
  141. for row, bucket in enumerate(buckets_in_group):
  142. btn = QToolButton()
  143. btn.setFixedSize(100, 100)
  144. btn.setToolButtonStyle(Qt.ToolButtonTextUnderIcon)
  145. btn.setText(bucket.description)
  146. btn.setIcon(self.get_bucket_icon(bucket.is_active))
  147. btn.setIconSize(QSize(64, 64))
  148. btn.setStyleSheet("""
  149. QToolButton {
  150. border-radius: 12px;
  151. font-size: 14px;
  152. font-weight: bold;
  153. }
  154. """)
  155. btn.clicked.connect(lambda checked, b=bucket: self.on_bucket_clicked(b))
  156. self.bucket_grid.addWidget(btn, row, col)
  157. self.bucket_buttons.append((btn, bucket))
  158. col += 1
  159. self.bucket_container.setLayout(self.bucket_grid)
  160. scroll_area.setWidget(self.bucket_container)
  161. bucket_layout.addWidget(scroll_area)
  162. main_layout.addWidget(bucket_group)
  163. # 视频区域(移除覆盖层,仅保留边框)
  164. video_container = QWidget()
  165. video_layout = QHBoxLayout(video_container)
  166. video_layout.setSpacing(15)
  167. video_layout.setContentsMargins(0, 0, 0, 0)
  168. self.left_label = QLabel("左侧画面")
  169. self.left_label.setScaledContents(False)
  170. self.left_label.setAlignment(Qt.AlignCenter)
  171. self.left_label.setStyleSheet("""
  172. QLabel {
  173. border: 2px solid #dcdfe6;
  174. border-radius: 8px;
  175. background-color: black;
  176. }
  177. """)
  178. self.right_label = QLabel("右侧画面")
  179. self.right_label.setScaledContents(False)
  180. self.right_label.setAlignment(Qt.AlignCenter)
  181. self.right_label.setStyleSheet("""
  182. QLabel {
  183. border: 2px solid #dcdfe6;
  184. border-radius: 8px;
  185. background-color: black;
  186. }
  187. """)
  188. video_layout.addWidget(self.left_label)
  189. video_layout.addWidget(self.right_label)
  190. main_layout.addWidget(video_container, 1)
  191. # 状态栏
  192. status_widget = QWidget()
  193. status_layout = QHBoxLayout(status_widget)
  194. status_layout.setContentsMargins(5, 5, 5, 5)
  195. self.module_status = QLabel("通讯状态: 初始化")
  196. self.module_status.setStyleSheet("color: #606266; font-weight: bold;")
  197. self.rfid_tag_label = QLabel("当前卡号: 无")
  198. self.rfid_tag_label.setStyleSheet("color: #409eff; font-weight: bold; margin-left: 20px;")
  199. self.hook_status_label = QLabel("挂钩状态: 空闲")
  200. self.hook_status_label.setStyleSheet("color: #ffa500; font-weight: bold; margin-left: 20px;")
  201. self.voice_checkbox = QCheckBox("语音播报")
  202. self.voice_checkbox.setChecked(True)
  203. self.voice_checkbox.setStyleSheet("color: #606266; font-weight: bold;")
  204. self.voice_checkbox.stateChanged.connect(self.on_voice_toggle)
  205. self.auto_switch_btn = QPushButton("自动模式")
  206. self.auto_switch_btn.setCheckable(True)
  207. self.auto_switch_btn.setChecked(True)
  208. self.auto_switch_btn.setStyleSheet("border-radius: 15px; padding: 5px 15px; font-weight: bold;")
  209. self.auto_switch_btn.clicked.connect(self.toggle_switch_mode)
  210. status_layout.addWidget(self.module_status)
  211. status_layout.addWidget(self.rfid_tag_label)
  212. status_layout.addWidget(self.hook_status_label)
  213. status_layout.addStretch()
  214. status_layout.addWidget(self.voice_checkbox)
  215. status_layout.addWidget(self.auto_switch_btn)
  216. main_layout.addWidget(status_widget)
  217. self.update_video_size()
  218. # ---------- 事件与资源管理 ----------
  219. def closeEvent(self, event: QCloseEvent):
  220. """窗口关闭时释放资源"""
  221. if self.rfid_client:
  222. self.rfid_client.disconnect()
  223. if self.modbus_client:
  224. self.modbus_client.disconnect()
  225. if self.mqtt_client:
  226. self.mqtt_client.disconnect()
  227. if self.left_player:
  228. self.left_player.stop()
  229. if self.right_player:
  230. self.right_player.stop()
  231. event.accept()
  232. # ---------- 语音控制 ----------
  233. def on_voice_toggle(self, state):
  234. self.voice_enabled = (state == Qt.Checked)
  235. def _speak(self, msg: str):
  236. self._voice_queue.put(msg)
  237. def _voice_worker(self):
  238. try:
  239. import pythoncom
  240. pythoncom.CoInitialize()
  241. except ImportError:
  242. pass
  243. while True:
  244. msg = self._voice_queue.get()
  245. if msg is None:
  246. break
  247. try:
  248. self.tts_engine.say(msg)
  249. self.tts_engine.runAndWait()
  250. except Exception as e:
  251. logging.warning(f"语音播报失败: {e}")
  252. self._voice_queue.task_done()
  253. # ---------- 工位图标 ----------
  254. def get_bucket_icon(self, is_active):
  255. icon_dir = os.path.join(os.path.dirname(__file__), "..", "assets")
  256. if not os.path.exists(icon_dir):
  257. os.makedirs(icon_dir, exist_ok=True)
  258. return QIcon()
  259. if is_active:
  260. icon_path = os.path.join(icon_dir, "bucket_active.svg")
  261. else:
  262. icon_path = os.path.join(icon_dir, "bucket_normal.svg")
  263. if os.path.exists(icon_path):
  264. return QIcon(icon_path)
  265. else:
  266. pixmap = QPixmap(64, 64)
  267. pixmap.fill(Qt.green if is_active else Qt.gray)
  268. return QIcon(pixmap)
  269. def refresh_bucket_icons(self):
  270. for btn, bucket in self.bucket_buttons:
  271. icon = self.get_bucket_icon(bucket.is_active)
  272. btn.setIcon(icon)
  273. if bucket.is_active:
  274. btn.setStyleSheet("""
  275. QToolButton {
  276. background-color: #e1f3d8;
  277. border: 2px solid #67c23a;
  278. border-radius: 12px;
  279. font-size: 14px;
  280. font-weight: bold;
  281. color: #1f2d3d;
  282. }
  283. """)
  284. else:
  285. btn.setStyleSheet("""
  286. QToolButton {
  287. background-color: transparent;
  288. border: 2px solid #d0d7de;
  289. border-radius: 12px;
  290. font-size: 14px;
  291. font-weight: bold;
  292. color: #1f2d3d;
  293. }
  294. QToolButton:hover {
  295. background-color: rgba(0, 0, 0, 0.05);
  296. border-color: #409eff;
  297. }
  298. """)
  299. # ---------- 视频尺寸自适应 ----------
  300. def update_video_size(self):
  301. if not self.left_label or not self.right_label:
  302. return
  303. container = self.left_label.parentWidget()
  304. if not container:
  305. return
  306. available_width = container.width() - 15
  307. single_width = available_width // 2
  308. target_height = int(single_width / self.video_aspect_ratio)
  309. if target_height > container.height():
  310. target_height = container.height()
  311. self.left_label.setFixedSize(single_width, target_height)
  312. self.right_label.setFixedSize(single_width, target_height)
  313. def update_players_target_size(self):
  314. if self.left_player:
  315. self.left_player.set_target_size(self.left_label.width(), self.left_label.height())
  316. if self.right_player:
  317. self.right_player.set_target_size(self.right_label.width(), self.right_label.height())
  318. def resizeEvent(self, event: QResizeEvent):
  319. self.update_video_size()
  320. self.update_players_target_size()
  321. super().resizeEvent(event)
  322. def keyPressEvent(self, event: QKeyEvent):
  323. if event.key() == Qt.Key_Escape and self.isFullScreen():
  324. self.showNormal()
  325. elif event.key() == Qt.Key_F11:
  326. if self.isFullScreen():
  327. self.showNormal()
  328. else:
  329. self.showFullScreen()
  330. else:
  331. super().keyPressEvent(event)
  332. # ---------- 加载动画和超时 ----------
  333. def show_loading_on_label(self, label: QLabel):
  334. label.setText("加载中...")
  335. label.setAlignment(Qt.AlignCenter)
  336. label.setStyleSheet(label.styleSheet() + "color: white; font-size: 20px;")
  337. def hide_loading_on_label(self, label: QLabel):
  338. label.setText("")
  339. label.setStyleSheet("""
  340. QLabel {
  341. border: 2px solid #dcdfe6;
  342. border-radius: 8px;
  343. background-color: black;
  344. }
  345. """)
  346. def show_timeout_on_label(self, label: QLabel):
  347. label.setText("连接超时")
  348. label.setAlignment(Qt.AlignCenter)
  349. label.setStyleSheet(label.styleSheet() + "color: red; font-size: 20px;")
  350. def on_player_timeout(self, label: QLabel, player_ref, side: str):
  351. self.show_timeout_on_label(label)
  352. if side == "left" and self.left_player:
  353. self.left_player.stop()
  354. self.left_player = None
  355. elif side == "right" and self.right_player:
  356. self.right_player.stop()
  357. self.right_player = None
  358. # ---------- 点击工位 ----------
  359. def on_bucket_clicked(self, bucket: BucketModel):
  360. if self.switch_mode != SwitchMode.HAND:
  361. self.switch_mode = SwitchMode.HAND
  362. self.auto_switch_btn.setChecked(False)
  363. self.auto_switch_btn.setText("手动模式")
  364. self.activate_bucket(bucket)
  365. def on_rfid_state_changed(self, online: bool):
  366. if online:
  367. self.module_status.setText("通讯状态: RFID正常")
  368. else:
  369. self.module_status.setText("通讯状态: RFID离线")
  370. # ---------- 硬件初始化 ----------
  371. def init_hardware(self):
  372. if self.config.module_type == ModuleType.RFID and self.config.rfid:
  373. self.rfid_client = RFIDClient(
  374. self.config.rfid,
  375. self.on_rfid_tag,
  376. on_state_changed=self.on_rfid_state_changed,
  377. tag_offset=18,
  378. tag_length=4,
  379. hex_output=True
  380. )
  381. if self.rfid_client.connect():
  382. self.module_status.setText("通讯状态: RFID正常")
  383. else:
  384. self.module_status.setText("通讯状态: RFID离线")
  385. if self.config.module_type == ModuleType.HD3200 and self.config.modbus:
  386. self.modbus_client = ModbusClient(self.config.modbus, self.on_modbus_data)
  387. if self.modbus_client.connect():
  388. self.module_status.setText("通讯状态: Modbus正常")
  389. else:
  390. self.module_status.setText("通讯状态: Modbus离线")
  391. if self.config.ai.enabled:
  392. self.mqtt_client = MQTTClient(self.config.ai, self.on_mqtt_message)
  393. self.mqtt_client.connect()
  394. # ---------- 激活工位 ----------
  395. def activate_bucket(self, bucket: BucketModel):
  396. if self.active_bucket == bucket:
  397. return
  398. if bucket and self.voice_enabled:
  399. self._speak(f"到达{bucket.description}")
  400. if self.left_player:
  401. self.left_player.stop()
  402. self.left_player = None
  403. if self.right_player:
  404. self.right_player.stop()
  405. self.right_player = None
  406. if self.active_bucket:
  407. self.active_bucket.is_active = False
  408. self.active_bucket = bucket
  409. if bucket:
  410. bucket.is_active = True
  411. self.refresh_bucket_icons()
  412. self.show_loading_on_label(self.left_label)
  413. self.show_loading_on_label(self.right_label)
  414. # 重置挂钩状态
  415. self.hook_state = HookState.IDLE
  416. self.prev_state = HookState.IDLE
  417. self.state_counter = 0
  418. self.left_objects = []
  419. self.right_objects = []
  420. self.prev_left_hook = None
  421. self.prev_right_hook = None
  422. self.prev_left_lug = None
  423. self.prev_right_lug = None
  424. self.last_voice_state = None
  425. if bucket:
  426. left_cam = next((c for c in bucket.cameras if c.position == CameraPosition.LEFT), None)
  427. right_cam = next((c for c in bucket.cameras if c.position == CameraPosition.RIGHT), None)
  428. if left_cam:
  429. w = self.left_label.width()
  430. h = self.left_label.height()
  431. timeout = self.config.video.rtsp_timeout
  432. player = RTSPPlayer(left_cam.rtsp_url, w, h, timeout_sec=timeout)
  433. player.frame_ready.connect(self.left_label.setPixmap)
  434. player.first_frame_received.connect(lambda: self.hide_loading_on_label(self.left_label))
  435. player.timeout.connect(lambda: self.on_player_timeout(self.left_label, player, "left"))
  436. self.left_player = player
  437. player.start()
  438. if right_cam:
  439. w = self.right_label.width()
  440. h = self.right_label.height()
  441. timeout = self.config.video.rtsp_timeout
  442. player = RTSPPlayer(right_cam.rtsp_url, w, h, timeout_sec=timeout)
  443. player.frame_ready.connect(self.right_label.setPixmap)
  444. player.first_frame_received.connect(lambda: self.hide_loading_on_label(self.right_label))
  445. player.timeout.connect(lambda: self.on_player_timeout(self.right_label, player, "right"))
  446. self.right_player = player
  447. player.start()
  448. if self.mqtt_client:
  449. topics = [c.topic for c in bucket.cameras if c.topic]
  450. if topics:
  451. self.mqtt_client.subscribe(topics)
  452. else:
  453. self.left_label.clear()
  454. self.right_label.clear()
  455. # ---------- RFID 标签回调 ----------
  456. def on_rfid_tag(self, tag: str):
  457. if tag:
  458. self.rfid_tag_label.setText(f"当前卡号: {tag}")
  459. else:
  460. self.rfid_tag_label.setText("当前卡号: 无")
  461. if tag is None:
  462. if self.switch_mode == SwitchMode.AUTO and self.active_bucket:
  463. self.activate_bucket(None)
  464. return
  465. for bucket in self.buckets:
  466. if bucket.code_x == tag:
  467. if self.switch_mode == SwitchMode.AUTO:
  468. self.activate_bucket(bucket)
  469. break
  470. # ---------- Modbus 数据回调 ----------
  471. def on_modbus_data(self, bools):
  472. for bucket in self.buckets:
  473. try:
  474. addr = int(bucket.code_x)
  475. except:
  476. addr = -1
  477. if 0 <= addr < len(bools) and bools[addr]:
  478. if self.switch_mode == SwitchMode.AUTO:
  479. self.activate_bucket(bucket)
  480. break
  481. # ---------- MQTT 消息处理 ----------
  482. def parse_ai_objects(self, payload: dict, display_width: int, display_height: int,
  483. include_ready: bool = True) -> list:
  484. if payload is None or not isinstance(payload, dict):
  485. return []
  486. orig_width = payload.get("width", 640)
  487. orig_height = payload.get("height", 360)
  488. scale_x = display_width / orig_width if orig_width > 0 else 1.0
  489. scale_y = display_height / orig_height if orig_height > 0 else 1.0
  490. objects = []
  491. for obj_str in payload.get("objects", []):
  492. parts = obj_str.split('|')
  493. if len(parts) >= 6:
  494. try:
  495. x = int(float(parts[1]) * scale_x)
  496. y = int(float(parts[2]) * scale_y)
  497. w = int(float(parts[3]) * scale_x)
  498. h = int(float(parts[4]) * scale_y)
  499. raw_label = parts[5]
  500. # 类别映射(hoist→hook, cup→lug)
  501. label = raw_label
  502. if 'hoist' in raw_label:
  503. label = 'hook'
  504. elif 'cup' in raw_label:
  505. label = 'lug'
  506. # 颜色分配(BGR)
  507. if 'hook' in label:
  508. color = (0, 0, 255) # 红色
  509. elif 'lug' in label:
  510. color = (255, 0, 0) # 蓝色
  511. elif 'ready' in label:
  512. if not include_ready:
  513. continue
  514. color = (0, 255, 255) # 黄色(不在画面上显示,仅用于内部)
  515. else:
  516. color = (128, 128, 128) # 灰色(其他类别)
  517. objects.append({
  518. "x": x,
  519. "y": y,
  520. "w": w,
  521. "h": h,
  522. "label": label,
  523. "raw_label": raw_label,
  524. "color": color
  525. })
  526. except (ValueError, IndexError):
  527. continue
  528. return objects
  529. def on_mqtt_message(self, topic: str, payload: dict):
  530. if payload is None or not isinstance(payload, dict):
  531. return
  532. if not self.active_bucket:
  533. return
  534. for cam in self.active_bucket.cameras:
  535. if cam.topic == topic:
  536. self.update_video_size()
  537. if cam.position == CameraPosition.LEFT:
  538. display_width = self.left_label.width()
  539. display_height = self.left_label.height()
  540. else:
  541. display_width = self.right_label.width()
  542. display_height = self.right_label.height()
  543. if display_width == 0 or display_height == 0:
  544. display_width = 640
  545. display_height = 360
  546. # 解析所有对象(包括 ready)用于状态判断
  547. all_objects = self.parse_ai_objects(payload, display_width, display_height, include_ready=True)
  548. # 解析仅用于绘制的对象(排除 ready)
  549. draw_objects = self.parse_ai_objects(payload, display_width, display_height, include_ready=False)
  550. if cam.position == CameraPosition.LEFT:
  551. self.left_objects = all_objects
  552. if self.left_player:
  553. self.left_player.set_ai_objects(draw_objects)
  554. else:
  555. self.right_objects = all_objects
  556. if self.right_player:
  557. self.right_player.set_ai_objects(draw_objects)
  558. # 触发状态评估
  559. self.evaluate_hook_state()
  560. break
  561. # ---------- 挂钩状态评估 ----------
  562. def evaluate_hook_state(self):
  563. left_hooks = [o for o in self.left_objects if 'hook' in o['label'].lower()]
  564. left_lugs = [o for o in self.left_objects if 'lug' in o['label'].lower()]
  565. right_hooks = [o for o in self.right_objects if 'hook' in o['label'].lower()]
  566. right_lugs = [o for o in self.right_objects if 'lug' in o['label'].lower()]
  567. def find_nearest_pair(hooks, lugs):
  568. if not hooks or not lugs:
  569. return None
  570. best_pair = None
  571. best_dist = float('inf')
  572. for h in hooks:
  573. hx = h['x'] + h['w'] / 2
  574. hy = h['y'] + h['h'] / 2
  575. for l in lugs:
  576. lx = l['x'] + l['w'] / 2
  577. ly = l['y'] + l['h'] / 2
  578. dist = (hx - lx) ** 2 + (hy - ly) ** 2
  579. if dist < best_dist:
  580. best_dist = dist
  581. best_pair = (h, l)
  582. return best_pair
  583. left_pair = find_nearest_pair(left_hooks, left_lugs)
  584. right_pair = find_nearest_pair(right_hooks, right_lugs)
  585. is_releasing = False
  586. # 处理左侧配对
  587. if left_pair:
  588. h, l = left_pair
  589. if self.prev_left_hook and self.prev_left_lug:
  590. dy_h = (h['y'] + h['h'] / 2) - self.prev_left_hook[1]
  591. dy_l = (l['y'] + l['h'] / 2) - self.prev_left_lug[1]
  592. if dy_h > self.RELEASE_DELTA_THRESHOLD and dy_l > self.RELEASE_DELTA_THRESHOLD:
  593. is_releasing = True
  594. self.prev_left_hook = (h['x'] + h['w'] / 2, h['y'] + h['h'] / 2)
  595. self.prev_left_lug = (l['x'] + l['w'] / 2, l['y'] + l['h'] / 2)
  596. else:
  597. self.prev_left_hook = None
  598. self.prev_left_lug = None
  599. # 处理右侧配对
  600. if right_pair:
  601. h, l = right_pair
  602. if self.prev_right_hook and self.prev_right_lug:
  603. dy_h = (h['y'] + h['h'] / 2) - self.prev_right_hook[1]
  604. dy_l = (l['y'] + l['h'] / 2) - self.prev_right_lug[1]
  605. if dy_h > self.RELEASE_DELTA_THRESHOLD and dy_l > self.RELEASE_DELTA_THRESHOLD:
  606. is_releasing = True
  607. self.prev_right_hook = (h['x'] + h['w'] / 2, h['y'] + h['h'] / 2)
  608. self.prev_right_lug = (l['x'] + l['w'] / 2, l['y'] + l['h'] / 2)
  609. else:
  610. self.prev_right_hook = None
  611. self.prev_right_lug = None
  612. # 状态判断
  613. if is_releasing:
  614. new_state = HookState.RELEASING
  615. else:
  616. any_ready = any('ready' in o['label'].lower() for o in self.left_objects + self.right_objects)
  617. if any_ready:
  618. new_state = HookState.READY
  619. else:
  620. intersecting = False
  621. if left_pair:
  622. h, l = left_pair
  623. if self.rects_intersect(h, l):
  624. intersecting = True
  625. if not intersecting and right_pair:
  626. h, l = right_pair
  627. if self.rects_intersect(h, l):
  628. intersecting = True
  629. if intersecting:
  630. new_state = HookState.HOOKING
  631. else:
  632. new_state = HookState.IDLE
  633. # 防抖
  634. if new_state == self.prev_state:
  635. self.state_counter += 1
  636. else:
  637. self.prev_state = new_state
  638. self.state_counter = 1
  639. if self.state_counter >= self.STATE_THRESHOLD:
  640. if new_state != self.hook_state:
  641. self.hook_state = new_state
  642. self.update_ui_for_state(new_state)
  643. self.state_counter = 0
  644. def rects_intersect(self, a, b):
  645. ax1, ay1, ax2, ay2 = a['x'], a['y'], a['x']+a['w'], a['y']+a['h']
  646. bx1, by1, bx2, by2 = b['x'], b['y'], b['x']+b['w'], b['y']+b['h']
  647. return not (ax2 < bx1 or bx2 < ax1 or ay2 < by1 or by2 < ay1)
  648. # ---------- UI 更新与语音 ----------
  649. def update_ui_for_state(self, state):
  650. if state == HookState.IDLE:
  651. border_color = "#dcdfe6"
  652. text = "空闲"
  653. elif state == HookState.RELEASING:
  654. border_color = "#ffa500"
  655. text = "放包中"
  656. elif state == HookState.HOOKING:
  657. border_color = "#ff0000"
  658. text = "挂包中"
  659. elif state == HookState.READY:
  660. border_color = "#00ff00"
  661. text = "挂包成功"
  662. else:
  663. border_color = "#dcdfe6"
  664. text = ""
  665. # 更新状态栏标签
  666. self.hook_status_label.setText(f"挂钩状态: {text}")
  667. # 更新视频边框
  668. style = f"""
  669. QLabel {{
  670. border: 4px solid {border_color};
  671. border-radius: 8px;
  672. background-color: black;
  673. }}
  674. """
  675. self.left_label.setStyleSheet(style)
  676. self.right_label.setStyleSheet(style)
  677. # 语音播报(带开关和防重复)
  678. if self.voice_enabled and state in (HookState.HOOKING, HookState.READY) and state != self.last_voice_state:
  679. msg = "挂包中" if state == HookState.HOOKING else "挂包成功"
  680. self._speak(msg)
  681. self.last_voice_state = state
  682. # ---------- 定时任务 ----------
  683. def check_idle(self):
  684. if self.rfid_client:
  685. self.rfid_client.check_idle()
  686. def toggle_switch_mode(self, checked):
  687. if checked:
  688. self.switch_mode = SwitchMode.AUTO
  689. self.auto_switch_btn.setText("自动模式")
  690. else:
  691. self.switch_mode = SwitchMode.HAND
  692. self.auto_switch_btn.setText("手动模式")