main_window.py 29 KB

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