main_window.py 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774
  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. while True:
  239. msg = self._voice_queue.get()
  240. if msg is None:
  241. break
  242. try:
  243. self.tts_engine.say(msg)
  244. self.tts_engine.runAndWait()
  245. except Exception as e:
  246. logging.warning(f"语音播报失败: {e}")
  247. self._voice_queue.task_done()
  248. # ---------- 工位图标 ----------
  249. def get_bucket_icon(self, is_active):
  250. icon_dir = os.path.join(os.path.dirname(__file__), "..", "assets")
  251. if not os.path.exists(icon_dir):
  252. os.makedirs(icon_dir, exist_ok=True)
  253. return QIcon()
  254. if is_active:
  255. icon_path = os.path.join(icon_dir, "bucket_active.svg")
  256. else:
  257. icon_path = os.path.join(icon_dir, "bucket_normal.svg")
  258. if os.path.exists(icon_path):
  259. return QIcon(icon_path)
  260. else:
  261. pixmap = QPixmap(64, 64)
  262. pixmap.fill(Qt.green if is_active else Qt.gray)
  263. return QIcon(pixmap)
  264. def refresh_bucket_icons(self):
  265. for btn, bucket in self.bucket_buttons:
  266. icon = self.get_bucket_icon(bucket.is_active)
  267. btn.setIcon(icon)
  268. if bucket.is_active:
  269. btn.setStyleSheet("""
  270. QToolButton {
  271. background-color: #e1f3d8;
  272. border: 2px solid #67c23a;
  273. border-radius: 12px;
  274. font-size: 14px;
  275. font-weight: bold;
  276. color: #1f2d3d;
  277. }
  278. """)
  279. else:
  280. btn.setStyleSheet("""
  281. QToolButton {
  282. background-color: transparent;
  283. border: 2px solid #d0d7de;
  284. border-radius: 12px;
  285. font-size: 14px;
  286. font-weight: bold;
  287. color: #1f2d3d;
  288. }
  289. QToolButton:hover {
  290. background-color: rgba(0, 0, 0, 0.05);
  291. border-color: #409eff;
  292. }
  293. """)
  294. # ---------- 视频尺寸自适应 ----------
  295. def update_video_size(self):
  296. if not self.left_label or not self.right_label:
  297. return
  298. container = self.left_label.parentWidget()
  299. if not container:
  300. return
  301. available_width = container.width() - 15
  302. single_width = available_width // 2
  303. target_height = int(single_width / self.video_aspect_ratio)
  304. if target_height > container.height():
  305. target_height = container.height()
  306. self.left_label.setFixedSize(single_width, target_height)
  307. self.right_label.setFixedSize(single_width, target_height)
  308. def update_players_target_size(self):
  309. if self.left_player:
  310. self.left_player.set_target_size(self.left_label.width(), self.left_label.height())
  311. if self.right_player:
  312. self.right_player.set_target_size(self.right_label.width(), self.right_label.height())
  313. def resizeEvent(self, event: QResizeEvent):
  314. self.update_video_size()
  315. self.update_players_target_size()
  316. super().resizeEvent(event)
  317. def keyPressEvent(self, event: QKeyEvent):
  318. if event.key() == Qt.Key_Escape and self.isFullScreen():
  319. self.showNormal()
  320. elif event.key() == Qt.Key_F11:
  321. if self.isFullScreen():
  322. self.showNormal()
  323. else:
  324. self.showFullScreen()
  325. else:
  326. super().keyPressEvent(event)
  327. # ---------- 加载动画和超时 ----------
  328. def show_loading_on_label(self, label: QLabel):
  329. label.setText("加载中...")
  330. label.setAlignment(Qt.AlignCenter)
  331. label.setStyleSheet(label.styleSheet() + "color: white; font-size: 20px;")
  332. def hide_loading_on_label(self, label: QLabel):
  333. label.setText("")
  334. label.setStyleSheet("""
  335. QLabel {
  336. border: 2px solid #dcdfe6;
  337. border-radius: 8px;
  338. background-color: black;
  339. }
  340. """)
  341. def show_timeout_on_label(self, label: QLabel):
  342. label.setText("连接超时")
  343. label.setAlignment(Qt.AlignCenter)
  344. label.setStyleSheet(label.styleSheet() + "color: red; font-size: 20px;")
  345. def on_player_timeout(self, label: QLabel, player_ref, side: str):
  346. self.show_timeout_on_label(label)
  347. if side == "left" and self.left_player:
  348. self.left_player.stop()
  349. self.left_player = None
  350. elif side == "right" and self.right_player:
  351. self.right_player.stop()
  352. self.right_player = None
  353. # ---------- 点击工位 ----------
  354. def on_bucket_clicked(self, bucket: BucketModel):
  355. if self.switch_mode != SwitchMode.HAND:
  356. self.switch_mode = SwitchMode.HAND
  357. self.auto_switch_btn.setChecked(False)
  358. self.auto_switch_btn.setText("手动模式")
  359. self.activate_bucket(bucket)
  360. def on_rfid_state_changed(self, online: bool):
  361. if online:
  362. self.module_status.setText("通讯状态: RFID正常")
  363. else:
  364. self.module_status.setText("通讯状态: RFID离线")
  365. # ---------- 硬件初始化 ----------
  366. def init_hardware(self):
  367. if self.config.module_type == ModuleType.RFID and self.config.rfid:
  368. self.rfid_client = RFIDClient(
  369. self.config.rfid,
  370. self.on_rfid_tag,
  371. on_state_changed=self.on_rfid_state_changed,
  372. tag_offset=18,
  373. tag_length=4,
  374. hex_output=True
  375. )
  376. if self.rfid_client.connect():
  377. self.module_status.setText("通讯状态: RFID正常")
  378. else:
  379. self.module_status.setText("通讯状态: RFID离线")
  380. if self.config.module_type == ModuleType.HD3200 and self.config.modbus:
  381. self.modbus_client = ModbusClient(self.config.modbus, self.on_modbus_data)
  382. if self.modbus_client.connect():
  383. self.module_status.setText("通讯状态: Modbus正常")
  384. else:
  385. self.module_status.setText("通讯状态: Modbus离线")
  386. if self.config.ai.enabled:
  387. self.mqtt_client = MQTTClient(self.config.ai, self.on_mqtt_message)
  388. self.mqtt_client.connect()
  389. # ---------- 激活工位 ----------
  390. def activate_bucket(self, bucket: BucketModel):
  391. if self.active_bucket == bucket:
  392. return
  393. if bucket and self.voice_enabled:
  394. self._speak(f"到达{bucket.description}")
  395. if self.left_player:
  396. self.left_player.stop()
  397. self.left_player = None
  398. if self.right_player:
  399. self.right_player.stop()
  400. self.right_player = None
  401. if self.active_bucket:
  402. self.active_bucket.is_active = False
  403. self.active_bucket = bucket
  404. if bucket:
  405. bucket.is_active = True
  406. self.refresh_bucket_icons()
  407. self.show_loading_on_label(self.left_label)
  408. self.show_loading_on_label(self.right_label)
  409. # 重置挂钩状态
  410. self.hook_state = HookState.IDLE
  411. self.prev_state = HookState.IDLE
  412. self.state_counter = 0
  413. self.left_objects = []
  414. self.right_objects = []
  415. self.prev_left_hook = None
  416. self.prev_right_hook = None
  417. self.prev_left_lug = None
  418. self.prev_right_lug = None
  419. self.last_voice_state = None
  420. if bucket:
  421. left_cam = next((c for c in bucket.cameras if c.position == CameraPosition.LEFT), None)
  422. right_cam = next((c for c in bucket.cameras if c.position == CameraPosition.RIGHT), None)
  423. if left_cam:
  424. w = self.left_label.width()
  425. h = self.left_label.height()
  426. timeout = self.config.video.rtsp_timeout
  427. player = RTSPPlayer(left_cam.rtsp_url, w, h, timeout_sec=timeout)
  428. player.frame_ready.connect(self.left_label.setPixmap)
  429. player.first_frame_received.connect(lambda: self.hide_loading_on_label(self.left_label))
  430. player.timeout.connect(lambda: self.on_player_timeout(self.left_label, player, "left"))
  431. self.left_player = player
  432. player.start()
  433. if right_cam:
  434. w = self.right_label.width()
  435. h = self.right_label.height()
  436. timeout = self.config.video.rtsp_timeout
  437. player = RTSPPlayer(right_cam.rtsp_url, w, h, timeout_sec=timeout)
  438. player.frame_ready.connect(self.right_label.setPixmap)
  439. player.first_frame_received.connect(lambda: self.hide_loading_on_label(self.right_label))
  440. player.timeout.connect(lambda: self.on_player_timeout(self.right_label, player, "right"))
  441. self.right_player = player
  442. player.start()
  443. if self.mqtt_client:
  444. topics = [c.topic for c in bucket.cameras if c.topic]
  445. if topics:
  446. self.mqtt_client.subscribe(topics)
  447. else:
  448. self.left_label.clear()
  449. self.right_label.clear()
  450. # ---------- RFID 标签回调 ----------
  451. def on_rfid_tag(self, tag: str):
  452. if tag:
  453. self.rfid_tag_label.setText(f"当前卡号: {tag}")
  454. else:
  455. self.rfid_tag_label.setText("当前卡号: 无")
  456. if tag is None:
  457. if self.switch_mode == SwitchMode.AUTO and self.active_bucket:
  458. self.activate_bucket(None)
  459. return
  460. for bucket in self.buckets:
  461. if bucket.code_x == tag:
  462. if self.switch_mode == SwitchMode.AUTO:
  463. self.activate_bucket(bucket)
  464. break
  465. # ---------- Modbus 数据回调 ----------
  466. def on_modbus_data(self, bools):
  467. for bucket in self.buckets:
  468. try:
  469. addr = int(bucket.code_x)
  470. except:
  471. addr = -1
  472. if 0 <= addr < len(bools) and bools[addr]:
  473. if self.switch_mode == SwitchMode.AUTO:
  474. self.activate_bucket(bucket)
  475. break
  476. # ---------- MQTT 消息处理 ----------
  477. def parse_ai_objects(self, payload: dict, display_width: int, display_height: int,
  478. include_ready: bool = True) -> list:
  479. if payload is None or not isinstance(payload, dict):
  480. return []
  481. orig_width = payload.get("width", 640)
  482. orig_height = payload.get("height", 360)
  483. scale_x = display_width / orig_width if orig_width > 0 else 1.0
  484. scale_y = display_height / orig_height if orig_height > 0 else 1.0
  485. objects = []
  486. for obj_str in payload.get("objects", []):
  487. parts = obj_str.split('|')
  488. if len(parts) >= 6:
  489. try:
  490. x = int(float(parts[1]) * scale_x)
  491. y = int(float(parts[2]) * scale_y)
  492. w = int(float(parts[3]) * scale_x)
  493. h = int(float(parts[4]) * scale_y)
  494. raw_label = parts[5]
  495. # 类别映射(hoist→hook, cup→lug)
  496. label = raw_label
  497. if 'hoist' in raw_label:
  498. label = 'hook'
  499. elif 'cup' in raw_label:
  500. label = 'lug'
  501. # 颜色分配(BGR)
  502. if 'hook' in label:
  503. color = (0, 0, 255) # 红色
  504. elif 'lug' in label:
  505. color = (255, 0, 0) # 蓝色
  506. elif 'ready' in label:
  507. if not include_ready:
  508. continue
  509. color = (0, 255, 255) # 黄色(不在画面上显示,仅用于内部)
  510. else:
  511. color = (128, 128, 128) # 灰色(其他类别)
  512. objects.append({
  513. "x": x,
  514. "y": y,
  515. "w": w,
  516. "h": h,
  517. "label": label,
  518. "raw_label": raw_label,
  519. "color": color
  520. })
  521. except (ValueError, IndexError):
  522. continue
  523. return objects
  524. def on_mqtt_message(self, topic: str, payload: dict):
  525. if payload is None or not isinstance(payload, dict):
  526. return
  527. if not self.active_bucket:
  528. return
  529. for cam in self.active_bucket.cameras:
  530. if cam.topic == topic:
  531. self.update_video_size()
  532. if cam.position == CameraPosition.LEFT:
  533. display_width = self.left_label.width()
  534. display_height = self.left_label.height()
  535. else:
  536. display_width = self.right_label.width()
  537. display_height = self.right_label.height()
  538. if display_width == 0 or display_height == 0:
  539. display_width = 640
  540. display_height = 360
  541. # 解析所有对象(包括 ready)用于状态判断
  542. all_objects = self.parse_ai_objects(payload, display_width, display_height, include_ready=True)
  543. # 解析仅用于绘制的对象(排除 ready)
  544. draw_objects = self.parse_ai_objects(payload, display_width, display_height, include_ready=False)
  545. if cam.position == CameraPosition.LEFT:
  546. self.left_objects = all_objects
  547. if self.left_player:
  548. self.left_player.set_ai_objects(draw_objects)
  549. else:
  550. self.right_objects = all_objects
  551. if self.right_player:
  552. self.right_player.set_ai_objects(draw_objects)
  553. # 触发状态评估
  554. self.evaluate_hook_state()
  555. break
  556. # ---------- 挂钩状态评估 ----------
  557. def evaluate_hook_state(self):
  558. left_hooks = [o for o in self.left_objects if 'hook' in o['label'].lower()]
  559. left_lugs = [o for o in self.left_objects if 'lug' in o['label'].lower()]
  560. right_hooks = [o for o in self.right_objects if 'hook' in o['label'].lower()]
  561. right_lugs = [o for o in self.right_objects if 'lug' in o['label'].lower()]
  562. def find_nearest_pair(hooks, lugs):
  563. if not hooks or not lugs:
  564. return None
  565. best_pair = None
  566. best_dist = float('inf')
  567. for h in hooks:
  568. hx = h['x'] + h['w'] / 2
  569. hy = h['y'] + h['h'] / 2
  570. for l in lugs:
  571. lx = l['x'] + l['w'] / 2
  572. ly = l['y'] + l['h'] / 2
  573. dist = (hx - lx) ** 2 + (hy - ly) ** 2
  574. if dist < best_dist:
  575. best_dist = dist
  576. best_pair = (h, l)
  577. return best_pair
  578. left_pair = find_nearest_pair(left_hooks, left_lugs)
  579. right_pair = find_nearest_pair(right_hooks, right_lugs)
  580. is_releasing = False
  581. # 处理左侧配对
  582. if left_pair:
  583. h, l = left_pair
  584. if self.prev_left_hook and self.prev_left_lug:
  585. dy_h = (h['y'] + h['h'] / 2) - self.prev_left_hook[1]
  586. dy_l = (l['y'] + l['h'] / 2) - self.prev_left_lug[1]
  587. if dy_h > self.RELEASE_DELTA_THRESHOLD and dy_l > self.RELEASE_DELTA_THRESHOLD:
  588. is_releasing = True
  589. self.prev_left_hook = (h['x'] + h['w'] / 2, h['y'] + h['h'] / 2)
  590. self.prev_left_lug = (l['x'] + l['w'] / 2, l['y'] + l['h'] / 2)
  591. else:
  592. self.prev_left_hook = None
  593. self.prev_left_lug = None
  594. # 处理右侧配对
  595. if right_pair:
  596. h, l = right_pair
  597. if self.prev_right_hook and self.prev_right_lug:
  598. dy_h = (h['y'] + h['h'] / 2) - self.prev_right_hook[1]
  599. dy_l = (l['y'] + l['h'] / 2) - self.prev_right_lug[1]
  600. if dy_h > self.RELEASE_DELTA_THRESHOLD and dy_l > self.RELEASE_DELTA_THRESHOLD:
  601. is_releasing = True
  602. self.prev_right_hook = (h['x'] + h['w'] / 2, h['y'] + h['h'] / 2)
  603. self.prev_right_lug = (l['x'] + l['w'] / 2, l['y'] + l['h'] / 2)
  604. else:
  605. self.prev_right_hook = None
  606. self.prev_right_lug = None
  607. # 状态判断
  608. if is_releasing:
  609. new_state = HookState.RELEASING
  610. else:
  611. any_ready = any('ready' in o['label'].lower() for o in self.left_objects + self.right_objects)
  612. if any_ready:
  613. new_state = HookState.READY
  614. else:
  615. intersecting = False
  616. if left_pair:
  617. h, l = left_pair
  618. if self.rects_intersect(h, l):
  619. intersecting = True
  620. if not intersecting and right_pair:
  621. h, l = right_pair
  622. if self.rects_intersect(h, l):
  623. intersecting = True
  624. if intersecting:
  625. new_state = HookState.HOOKING
  626. else:
  627. new_state = HookState.IDLE
  628. # 防抖
  629. if new_state == self.prev_state:
  630. self.state_counter += 1
  631. else:
  632. self.prev_state = new_state
  633. self.state_counter = 1
  634. if self.state_counter >= self.STATE_THRESHOLD:
  635. if new_state != self.hook_state:
  636. self.hook_state = new_state
  637. self.update_ui_for_state(new_state)
  638. self.state_counter = 0
  639. def rects_intersect(self, a, b):
  640. ax1, ay1, ax2, ay2 = a['x'], a['y'], a['x']+a['w'], a['y']+a['h']
  641. bx1, by1, bx2, by2 = b['x'], b['y'], b['x']+b['w'], b['y']+b['h']
  642. return not (ax2 < bx1 or bx2 < ax1 or ay2 < by1 or by2 < ay1)
  643. # ---------- UI 更新与语音 ----------
  644. def update_ui_for_state(self, state):
  645. if state == HookState.IDLE:
  646. border_color = "#dcdfe6"
  647. text = "空闲"
  648. elif state == HookState.RELEASING:
  649. border_color = "#ffa500"
  650. text = "放包中"
  651. elif state == HookState.HOOKING:
  652. border_color = "#ff0000"
  653. text = "挂包中"
  654. elif state == HookState.READY:
  655. border_color = "#00ff00"
  656. text = "挂包成功"
  657. else:
  658. border_color = "#dcdfe6"
  659. text = ""
  660. # 更新状态栏标签
  661. self.hook_status_label.setText(f"挂钩状态: {text}")
  662. # 更新视频边框
  663. style = f"""
  664. QLabel {{
  665. border: 4px solid {border_color};
  666. border-radius: 8px;
  667. background-color: black;
  668. }}
  669. """
  670. self.left_label.setStyleSheet(style)
  671. self.right_label.setStyleSheet(style)
  672. # 语音播报(带开关和防重复)
  673. if self.voice_enabled and state in (HookState.HOOKING, HookState.READY) and state != self.last_voice_state:
  674. msg = "挂包中" if state == HookState.HOOKING else "挂包成功"
  675. self._speak(msg)
  676. self.last_voice_state = state
  677. # ---------- 定时任务 ----------
  678. def check_idle(self):
  679. if self.rfid_client:
  680. self.rfid_client.check_idle()
  681. def toggle_switch_mode(self, checked):
  682. if checked:
  683. self.switch_mode = SwitchMode.AUTO
  684. self.auto_switch_btn.setText("自动模式")
  685. else:
  686. self.switch_mode = SwitchMode.HAND
  687. self.auto_switch_btn.setText("手动模式")