main_window.py 33 KB

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