main_window.py 71 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739
  1. import os
  2. import sys
  3. import time
  4. import re
  5. import logging
  6. import threading
  7. import queue
  8. import subprocess
  9. from concurrent.futures import ThreadPoolExecutor
  10. from collections import defaultdict
  11. from PySide6.QtWidgets import (QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
  12. QPushButton, QLabel, QGridLayout, QScrollArea,
  13. QToolButton, QFrame, QApplication, QCheckBox,
  14. QMessageBox, QSlider)
  15. from PySide6.QtCore import Qt, QTimer, QSize, Signal
  16. from PySide6.QtGui import QIcon, QPixmap, QResizeEvent, QKeyEvent, QCloseEvent
  17. from .models import *
  18. from .rtsp_player import RTSPPlayer
  19. from .rfid_client import RFIDClient
  20. from .modbus_client import ModbusClient
  21. from .mqtt_client import MQTTClient
  22. logger = logging.getLogger(__name__)
  23. class MainWindow(QMainWindow):
  24. # 防抖阈值(连续相同状态次数)
  25. STATE_THRESHOLD = 3
  26. # 放包判断位移阈值(像素)
  27. RELEASE_DELTA_THRESHOLD = 5
  28. # 轨迹阶段判断阈值(像素)
  29. RELEASE_TRAJ_Y = 8
  30. ASCEND_TRAJ_Y = 6
  31. SEPARATE_TRAJ_DIST = 15
  32. ENGAGE_DIST = 40
  33. # 命中放包/分离后屏蔽语音的顺延秒数
  34. SUPPRESS_SECONDS = 8
  35. # 上升 ready 补全:连续命中帧数防抖 + 命中后锁存秒数
  36. ASCEND_DEBOUNCE = 2
  37. ASCEND_LATCH_SECS = 3.0
  38. # READY 粘滞:失去信号后保持绿色的秒数(防红绿来回跳)
  39. READY_HOLD_SECS = 2.0
  40. # 轨迹窗口:保留最近 2 秒 / 最多 30 帧
  41. TRAJ_WINDOW_SECS = 2.0
  42. TRAJ_MAX_FRAMES = 30
  43. # MQTT 消息超时(秒)后清除残留画框
  44. MQTT_CLEAR_TIMEOUT = 1
  45. APPTITLE = "HookVision"
  46. # 跨线程信号
  47. rfid_tag_signal = Signal(object)
  48. rfid_state_signal = Signal(object)
  49. modbus_data_signal = Signal(object)
  50. mqtt_message_signal = Signal(str, object)
  51. voice_signal = Signal(str)
  52. camera_signal_signal = Signal(dict)
  53. def __init__(self, config: Config, config_path: str = ""):
  54. super().__init__()
  55. self.config = config
  56. self._config_path = config_path
  57. self.buckets = config.buckets
  58. self.active_bucket: BucketModel = None
  59. self.switch_mode = SwitchMode.AUTO
  60. self._subscribed_topics: set = set()
  61. self._current_ai_module_id: Optional[int] = None
  62. self.left_player: RTSPPlayer = None
  63. self.right_player: RTSPPlayer = None
  64. self.rfid_client = None
  65. self.modbus_client = None
  66. self.mqtt_client = None
  67. self._last_rfid_tag: Optional[str] = None
  68. self.video_aspect_ratio = 16 / 9
  69. self._ratio_16_9 = config.video.ratio_16_9
  70. self.left_label = None
  71. self.right_label = None
  72. # 挂钩状态相关
  73. self.hook_state = HookState.IDLE
  74. self.prev_state = HookState.IDLE
  75. self.state_counter = 0
  76. # 存储左右画面最近检测对象
  77. self.left_objects = []
  78. self.right_objects = []
  79. # 前一帧 hook/lug 中心位置(用于轨迹判断)
  80. self.prev_left_hook = None
  81. self.prev_right_hook = None
  82. self.prev_left_lug = None
  83. self.prev_right_lug = None
  84. # 配对轨迹(每侧:{t, hook_cx, hook_cy, lug_cx, lug_cy, dist}),用于放包/分离检测与画线
  85. self._pair_traj: dict = {"left": [], "right": []}
  86. # 放包/分离期间屏蔽 挂包中/挂包成功 语音的截止时间
  87. self._voice_suppress_until: float = 0.0
  88. # 当前叠加显示的阶段(放包/分离/空)
  89. self._trace_phase: str = ""
  90. # 上升 ready 补全的防抖计数与锁存截止
  91. self._ascend_frames: int = 0
  92. self._ascend_latch_until: float = 0.0
  93. # READY 粘滞保持截止(信号短暂丢失时保持绿色)
  94. self._ready_hold_until: float = 0.0
  95. self.debug_trace = getattr(config.ai, 'debug_trace', True)
  96. # MQTT 消息超时后是否已清除画框(防重复清空)
  97. self._overlay_cleared = False
  98. # 语音引擎(在工作线程内初始化)
  99. self.last_voice_state = None
  100. self._last_hooking_voice_time = 0.0
  101. self._ready_traces: dict = {} # track_id → [(cx, cy, time), ...]
  102. self.voice_enabled = True
  103. self._voice_volume = 100
  104. self.ai_enabled = True
  105. self._last_left_draw: list = []
  106. self._last_right_draw: list = []
  107. self._voice_queue = queue.Queue(maxsize=32)
  108. self._voice_thread = threading.Thread(target=self._voice_worker, daemon=True)
  109. self._voice_thread.start()
  110. self._voices_dir = self._get_voices_dir()
  111. self._use_tts = self.config.ai.tts_enabled
  112. # RTSP 退避重连
  113. self._rtsp_retry_left: Optional[QTimer] = None
  114. self._rtsp_retry_right: Optional[QTimer] = None
  115. self._rtsp_retry_delay_left: int = 1
  116. self._rtsp_retry_delay_right: int = 1
  117. self._last_mqtt_connect_attempt: float = 0.0
  118. self._last_mqtt_process: float = 0.0
  119. # 摄像头信号监控
  120. self._camera_signal: dict = {} # ip → 延迟(ms), -1=不可达
  121. self._bucket_signal_labels: dict = {} # bucket.id → QLabel
  122. self._last_signal_check: float = 0.0
  123. self._signal_check_thread: threading.Thread = None
  124. self._offline_reported: set = set() # 已播报离线的 IP
  125. # 连接跨线程信号到主线程槽
  126. self.rfid_tag_signal.connect(self._on_rfid_tag_impl)
  127. self.rfid_state_signal.connect(self._on_rfid_state_impl)
  128. self.modbus_data_signal.connect(self._on_modbus_data_impl)
  129. self.mqtt_message_signal.connect(self._on_mqtt_message_impl)
  130. self.voice_signal.connect(self._speak_impl)
  131. self.camera_signal_signal.connect(self._on_camera_signal)
  132. self.setWindowTitle(config.app_name)
  133. logo_path = os.path.join(os.path.dirname(__file__), "..", "assets", "logo.png")
  134. if os.path.exists(logo_path):
  135. self.setWindowIcon(QIcon(logo_path))
  136. screen = QApplication.primaryScreen()
  137. screen_geometry = screen.availableGeometry()
  138. screen_width = screen_geometry.width()
  139. screen_height = screen_geometry.height()
  140. window_width = int(screen_width * 0.9)
  141. window_height = int(screen_height * 0.85)
  142. x = (screen_width - window_width) // 2
  143. y = (screen_height - window_height) // 2
  144. self.setGeometry(x, y, window_width, window_height)
  145. self.setMinimumSize(800, 600)
  146. self.setup_ui()
  147. self.showFullScreen()
  148. self.update_video_size()
  149. self.init_hardware()
  150. self._started = True
  151. self.timer = QTimer()
  152. self.timer.timeout.connect(self.check_idle)
  153. self.timer.start(1000)
  154. def setup_ui(self):
  155. central = QWidget()
  156. self.setCentralWidget(central)
  157. main_layout = QVBoxLayout(central)
  158. main_layout.setContentsMargins(10, 10, 10, 10)
  159. main_layout.setSpacing(10)
  160. # 标题栏
  161. title_widget = QWidget()
  162. title_layout = QHBoxLayout(title_widget)
  163. title_layout.setContentsMargins(0, 0, 0, 0)
  164. logo_path = os.path.join(os.path.dirname(__file__), "..", "assets", "logo.png")
  165. if os.path.exists(logo_path):
  166. logo_label = QLabel()
  167. logo_pixmap = QPixmap(logo_path).scaled(28, 28, Qt.KeepAspectRatio, Qt.SmoothTransformation)
  168. logo_label.setPixmap(logo_pixmap)
  169. logo_label.setFixedSize(28, 28)
  170. title_layout.addWidget(logo_label)
  171. self.title_label = QLabel(self.config.app_name)
  172. self.title_label.setStyleSheet("font-size: 22px; font-weight: bold; padding: 5px;")
  173. title_layout.addWidget(self.title_label)
  174. title_layout.addStretch()
  175. btn_style = """
  176. QPushButton {
  177. background-color: transparent;
  178. border: none;
  179. font-size: 18px;
  180. padding: 6px 12px;
  181. border-radius: 5px;
  182. }
  183. QPushButton:hover {
  184. background-color: rgba(255, 255, 255, 0.1);
  185. }
  186. """
  187. self.min_btn = QPushButton("—")
  188. self.min_btn.setStyleSheet(btn_style)
  189. self.min_btn.clicked.connect(self.showMinimized)
  190. self.fullscreen_btn = QPushButton("⛶")
  191. self.fullscreen_btn.setStyleSheet(btn_style)
  192. self.fullscreen_btn.setToolTip("全屏 / 还原")
  193. self.fullscreen_btn.clicked.connect(self._toggle_fullscreen)
  194. self.close_btn = QPushButton("✕")
  195. self.close_btn.setStyleSheet(btn_style)
  196. self.close_btn.clicked.connect(self.close)
  197. title_layout.addWidget(self.min_btn)
  198. title_layout.addWidget(self.fullscreen_btn)
  199. title_layout.addWidget(self.close_btn)
  200. main_layout.addWidget(title_widget)
  201. # 工位区域
  202. bucket_group = QFrame()
  203. bucket_group.setStyleSheet("QFrame { border-radius: 8px; padding: 8px; }")
  204. bucket_layout = QVBoxLayout(bucket_group)
  205. scroll_area = QScrollArea()
  206. scroll_area.setWidgetResizable(True)
  207. scroll_area.setFrameShape(QFrame.NoFrame)
  208. scroll_area.setStyleSheet("QScrollArea { background: transparent; border: none; }")
  209. self.bucket_container = QWidget()
  210. self.bucket_grid = QGridLayout(self.bucket_container)
  211. self.bucket_grid.setSpacing(15)
  212. self.bucket_grid.setContentsMargins(10, 10, 10, 10)
  213. self.bucket_grid.setAlignment(Qt.AlignLeft | Qt.AlignTop)
  214. # 按 code_x 分组纵向排列
  215. groups = defaultdict(list)
  216. for bucket in self.buckets:
  217. groups[bucket.code_x].append(bucket)
  218. sorted_keys = sorted(groups.keys())
  219. self.bucket_buttons = []
  220. col = 0
  221. for key in sorted_keys:
  222. buckets_in_group = groups[key]
  223. for row, bucket in enumerate(buckets_in_group):
  224. cell = QWidget()
  225. cell_layout = QVBoxLayout(cell)
  226. cell_layout.setContentsMargins(0, 0, 0, 0)
  227. cell_layout.setSpacing(2)
  228. btn = QToolButton()
  229. btn.setFixedSize(100, 100)
  230. btn.setToolButtonStyle(Qt.ToolButtonTextUnderIcon)
  231. btn.setText(bucket.description)
  232. btn.setIcon(self.get_bucket_icon(bucket.is_active))
  233. btn.setIconSize(QSize(64, 64))
  234. btn.setStyleSheet("""
  235. QToolButton {
  236. border-radius: 12px;
  237. font-size: 14px;
  238. font-weight: bold;
  239. }
  240. """)
  241. btn.clicked.connect(lambda checked, b=bucket: self.on_bucket_clicked(b))
  242. cell_layout.addWidget(btn)
  243. # 摄像头信号指示条(左右两段)
  244. bar = QWidget()
  245. bar.setFixedHeight(8)
  246. bar_layout = QHBoxLayout(bar)
  247. bar_layout.setContentsMargins(0, 0, 0, 0)
  248. bar_layout.setSpacing(2)
  249. left_bar = QFrame()
  250. right_bar = QFrame()
  251. for fb in (left_bar, right_bar):
  252. fb.setFixedHeight(8)
  253. fb.setStyleSheet("background-color: #dcdfe6; border-radius: 2px;")
  254. bar_layout.addWidget(left_bar)
  255. bar_layout.addWidget(right_bar)
  256. cell_layout.addWidget(bar)
  257. self._bucket_signal_labels[bucket.id] = (left_bar, right_bar)
  258. self.bucket_grid.addWidget(cell, row, col)
  259. self.bucket_buttons.append((btn, bucket))
  260. col += 1
  261. self.bucket_container.setLayout(self.bucket_grid)
  262. scroll_area.setWidget(self.bucket_container)
  263. bucket_layout.addWidget(scroll_area)
  264. main_layout.addWidget(bucket_group)
  265. # 视频区域(仅保留边框)
  266. video_container = QWidget()
  267. video_layout = QHBoxLayout(video_container)
  268. video_layout.setSpacing(15)
  269. video_layout.setContentsMargins(0, 0, 0, 0)
  270. self.left_label = QLabel("左侧画面")
  271. self.left_label.setScaledContents(False)
  272. self.left_label.setAlignment(Qt.AlignCenter)
  273. self.left_label.setStyleSheet("""
  274. QLabel {
  275. border: 2px solid #dcdfe6;
  276. border-radius: 8px;
  277. background-color: black;
  278. }
  279. """)
  280. self.right_label = QLabel("右侧画面")
  281. self.right_label.setScaledContents(False)
  282. self.right_label.setAlignment(Qt.AlignCenter)
  283. self.right_label.setStyleSheet("""
  284. QLabel {
  285. border: 2px solid #dcdfe6;
  286. border-radius: 8px;
  287. background-color: black;
  288. }
  289. """)
  290. video_layout.addWidget(self.left_label)
  291. video_layout.addWidget(self.right_label)
  292. main_layout.addWidget(video_container, 1)
  293. # 状态栏
  294. status_widget = QWidget()
  295. status_layout = QHBoxLayout(status_widget)
  296. status_layout.setContentsMargins(5, 5, 15, 5)
  297. self.module_status = QLabel("通讯状态: 初始化")
  298. self.module_status.setStyleSheet("color: #606266; font-weight: bold;")
  299. self.rfid_tag_label = QLabel("当前卡号: 无")
  300. self.rfid_tag_label.setStyleSheet("color: #409eff; font-weight: bold; margin-left: 20px;")
  301. self.hook_status_label = QLabel("挂钩状态: 空闲")
  302. self.hook_status_label.setStyleSheet("color: #ffa500; font-weight: bold; margin-left: 20px;")
  303. self.voice_checkbox = QCheckBox("语音播报")
  304. self.voice_checkbox.setChecked(True)
  305. self.voice_checkbox.setStyleSheet("color: #606266; font-weight: bold;")
  306. self.voice_checkbox.stateChanged.connect(self.on_voice_toggle)
  307. self.tts_checkbox = QCheckBox("TTS语音")
  308. self.tts_checkbox.setChecked(self._use_tts)
  309. self.tts_checkbox.setStyleSheet("color: #606266; font-weight: bold;")
  310. self.tts_checkbox.toggled.connect(self._on_tts_toggle)
  311. self.volume_btn = QPushButton(f"音量 {self._voice_volume}%")
  312. self.volume_btn.setFixedWidth(80)
  313. self.volume_btn.setStyleSheet("""
  314. QPushButton {
  315. color: #606266; font-weight: bold; font-size: 12px;
  316. border: 1px solid #dcdfe6; border-radius: 4px;
  317. padding: 2px 6px; background: transparent;
  318. }
  319. QPushButton:hover { border-color: #409eff; color: #409eff; }
  320. """)
  321. self.volume_btn.clicked.connect(self._show_volume_popup)
  322. self.ai_checkbox = QCheckBox("AI启用")
  323. self.ai_checkbox.setChecked(True)
  324. self.ai_checkbox.setStyleSheet("color: #606266; font-weight: bold;")
  325. self.ai_checkbox.stateChanged.connect(self.on_ai_toggle)
  326. self.auto_switch_btn = QCheckBox("自动模式")
  327. self.auto_switch_btn.setChecked(True)
  328. self.auto_switch_btn.setStyleSheet("color: #606266; font-weight: bold;")
  329. self.auto_switch_btn.clicked.connect(self.toggle_switch_mode)
  330. self.autostart_checkbox = QCheckBox("开机自启")
  331. self.autostart_checkbox.setChecked(self._read_autostart_flag())
  332. self.autostart_checkbox.setStyleSheet("color: #606266; font-weight: bold; margin-left: 10px;")
  333. self.autostart_checkbox.stateChanged.connect(self._on_autostart_toggle)
  334. status_layout.addWidget(self.module_status)
  335. status_layout.addWidget(self.rfid_tag_label)
  336. status_layout.addWidget(self.hook_status_label)
  337. status_layout.addStretch()
  338. status_layout.addWidget(self.voice_checkbox)
  339. status_layout.addWidget(self.tts_checkbox)
  340. status_layout.addWidget(self.volume_btn)
  341. status_layout.addWidget(self.ai_checkbox)
  342. status_layout.addWidget(self.auto_switch_btn)
  343. status_layout.addWidget(self.autostart_checkbox)
  344. self.ratio_btn = QPushButton("16:9" if self._ratio_16_9 else "4:3")
  345. self.ratio_btn.setStyleSheet("background:transparent; border:none; color:#606266; font-weight:bold; padding:4px 8px;")
  346. self.ratio_btn.setToolTip("切换画面比例 16:9 / 4:3")
  347. self.ratio_btn.clicked.connect(self._toggle_aspect_ratio)
  348. status_layout.addWidget(self.ratio_btn)
  349. main_layout.addWidget(status_widget)
  350. self.update_video_size()
  351. # ---------- 事件与资源管理 ----------
  352. def closeEvent(self, event: QCloseEvent):
  353. self._cancel_rtsp_retry("left")
  354. self._cancel_rtsp_retry("right")
  355. if self.rfid_client:
  356. self.rfid_client.disconnect()
  357. if self.modbus_client:
  358. self.modbus_client.disconnect()
  359. if self.mqtt_client:
  360. self.mqtt_client.disconnect()
  361. if self.left_player:
  362. self.left_player.stop()
  363. if self.right_player:
  364. self.right_player.stop()
  365. event.accept()
  366. # ---------- 语音控制 ----------
  367. def on_voice_toggle(self, state):
  368. self.voice_enabled = (state == Qt.Checked)
  369. def _on_tts_toggle(self):
  370. self._use_tts = self.tts_checkbox.isChecked()
  371. self.config.ai.tts_enabled = self._use_tts
  372. try:
  373. self._save_config()
  374. except Exception as e:
  375. logger.warning(f"保存 TTS 设置失败: {e}")
  376. def _on_volume_changed(self, value: int):
  377. self._voice_volume = value
  378. self.volume_btn.setText(f"音量 {value}%")
  379. def _preview_volume(self, vol: int):
  380. self._voice_volume = vol
  381. self.volume_btn.setText(f"音量 {vol}%")
  382. if not hasattr(self, '_preview_timer'):
  383. self._preview_timer = QTimer()
  384. self._preview_timer.setSingleShot(True)
  385. self._preview_timer.timeout.connect(lambda: self._speak("挂包中"))
  386. self._preview_timer.start(300)
  387. def _show_volume_popup(self):
  388. from PySide6.QtWidgets import QDialog, QVBoxLayout
  389. from PySide6.QtCore import Qt
  390. dlg = QDialog(self)
  391. dlg.setWindowFlags(Qt.Popup | Qt.FramelessWindowHint)
  392. dlg.setStyleSheet("background: #2c2c2c; border: 1px solid #555; border-radius: 6px;")
  393. layout = QVBoxLayout(dlg)
  394. layout.setContentsMargins(12, 8, 12, 8)
  395. slider = QSlider(Qt.Horizontal)
  396. slider.setRange(0, 100)
  397. slider.setValue(self._voice_volume)
  398. slider.setFixedWidth(160)
  399. slider.setStyleSheet("""
  400. QSlider::groove:horizontal { height: 4px; background: #555; border-radius: 2px; }
  401. QSlider::handle:horizontal { width: 14px; margin: -5px 0; border-radius: 7px; background: #409eff; }
  402. QSlider::sub-page:horizontal { background: #409eff; border-radius: 2px; }
  403. """)
  404. slider.valueChanged.connect(self._preview_volume)
  405. layout.addWidget(slider)
  406. dlg.adjustSize()
  407. btn_global = self.volume_btn.mapToGlobal(self.volume_btn.rect().topLeft())
  408. dlg.move(btn_global.x(), btn_global.y() - dlg.height() - 4)
  409. dlg.exec()
  410. def on_ai_toggle(self, state):
  411. enabled = (state == Qt.Checked)
  412. if enabled == self.ai_enabled:
  413. return
  414. self.ai_enabled = enabled
  415. if enabled and not self.mqtt_client:
  416. self.mqtt_client = MQTTClient(self.on_mqtt_message)
  417. self._connect_current_ai()
  418. elif not enabled and self.mqtt_client:
  419. self.mqtt_client.disconnect()
  420. self.mqtt_client = None
  421. self._subscribed_topics.clear()
  422. self._current_ai_module_id = None
  423. if not enabled:
  424. self.hook_state = HookState.IDLE
  425. self.prev_state = HookState.IDLE
  426. self.state_counter = 0
  427. self.left_objects = []
  428. self.right_objects = []
  429. self.prev_left_hook = None
  430. self.prev_right_hook = None
  431. self.prev_left_lug = None
  432. self.prev_right_lug = None
  433. self._pair_traj = {"left": [], "right": []}
  434. self._voice_suppress_until = 0.0
  435. self._trace_phase = ""
  436. self._ascend_frames = 0
  437. self._ascend_latch_until = 0.0
  438. self._ready_hold_until = 0.0
  439. self.update_ui_for_state(HookState.IDLE)
  440. self._last_left_draw = []
  441. self._last_right_draw = []
  442. if self.left_player:
  443. self.left_player.set_ai_objects([])
  444. if self.right_player:
  445. self.right_player.set_ai_objects([])
  446. else:
  447. if self.left_player and self._last_left_draw:
  448. self.left_player.set_ai_objects(self._last_left_draw)
  449. if self.right_player and self._last_right_draw:
  450. self.right_player.set_ai_objects(self._last_right_draw)
  451. def _speak(self, msg: str):
  452. self.voice_signal.emit(msg)
  453. def _speak_impl(self, msg: str):
  454. try:
  455. self._voice_queue.put_nowait(msg)
  456. except queue.Full:
  457. pass
  458. @staticmethod
  459. def _get_voices_dir() -> str:
  460. if getattr(sys, 'frozen', False):
  461. base = sys._MEIPASS
  462. else:
  463. base = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
  464. return os.path.join(base, "assets", "voices")
  465. def _voice_msg_to_path(self, msg: str) -> str:
  466. mapping = {"挂包中": "hooking", "挂包成功": "ready", "放包中": "releasing",
  467. "读卡器离线": "offline_reader", "左侧摄像头离线": "offline_cam_left",
  468. "右侧摄像头离线": "offline_cam_right"}
  469. if msg in mapping:
  470. return os.path.join(self._voices_dir, f"{mapping[msg]}.wav")
  471. m = re.search(r'到达(\d+)号工位', msg)
  472. if m:
  473. return os.path.join(self._voices_dir, f"arrive_{m.group(1)}.wav")
  474. return ""
  475. def _voice_worker(self):
  476. try:
  477. import pythoncom
  478. pythoncom.CoInitialize()
  479. except Exception:
  480. pass
  481. speaker = None
  482. try:
  483. import win32com.client as _win32com
  484. speaker = _win32com.Dispatch("SAPI.SpVoice")
  485. speaker.Rate = 2
  486. except Exception:
  487. logging.warning("SAPI 不可用,仅可使用 WAV 语音")
  488. while True:
  489. msg = self._voice_queue.get()
  490. if msg is None:
  491. break
  492. try:
  493. if self._use_tts and speaker is not None:
  494. speaker.Volume = self._voice_volume
  495. speaker.Speak(msg)
  496. else:
  497. self._play_wav(msg)
  498. except Exception as e:
  499. logging.warning(f"语音播报失败: {e}")
  500. self._voice_queue.task_done()
  501. def _play_wav(self, msg: str):
  502. path = self._voice_msg_to_path(msg)
  503. if not path:
  504. return
  505. import struct, wave, io, winsound
  506. try:
  507. with wave.open(path, 'rb') as wf:
  508. params = wf.getparams()
  509. raw = bytearray(wf.readframes(wf.getnframes()))
  510. gain = self._voice_volume / 100.0
  511. if params.sampwidth == 2:
  512. fmt = '<' + 'h' * (len(raw) // 2)
  513. samples = list(struct.unpack_from(fmt, bytes(raw)))
  514. samples = [max(-32768, min(32767, int(s * gain))) for s in samples]
  515. raw = bytearray(struct.pack(fmt, *samples))
  516. elif params.sampwidth == 1:
  517. samples = list(raw)
  518. samples = [max(0, min(255, int((s - 128) * gain) + 128)) for s in samples]
  519. raw = bytearray(samples)
  520. buf = io.BytesIO()
  521. with wave.open(buf, 'wb') as wf:
  522. wf.setparams(params)
  523. wf.writeframes(bytes(raw))
  524. winsound.PlaySound(buf.getvalue(), winsound.SND_MEMORY)
  525. except Exception:
  526. winsound.PlaySound(path, winsound.SND_FILENAME | winsound.SND_ASYNC)
  527. # ---------- 工位图标 ----------
  528. def get_bucket_icon(self, is_active):
  529. icon_dir = os.path.join(os.path.dirname(__file__), "..", "assets")
  530. if not os.path.exists(icon_dir):
  531. os.makedirs(icon_dir, exist_ok=True)
  532. return QIcon()
  533. if is_active:
  534. icon_path = os.path.join(icon_dir, "bucket_active.svg")
  535. else:
  536. icon_path = os.path.join(icon_dir, "bucket_normal.svg")
  537. if os.path.exists(icon_path):
  538. return QIcon(icon_path)
  539. else:
  540. pixmap = QPixmap(64, 64)
  541. pixmap.fill(Qt.green if is_active else Qt.gray)
  542. return QIcon(pixmap)
  543. def refresh_bucket_icons(self):
  544. for btn, bucket in self.bucket_buttons:
  545. icon = self.get_bucket_icon(bucket.is_active)
  546. btn.setIcon(icon)
  547. if bucket.is_active:
  548. btn.setStyleSheet("""
  549. QToolButton {
  550. background-color: #e1f3d8;
  551. border: 2px solid #67c23a;
  552. border-radius: 12px;
  553. font-size: 14px;
  554. font-weight: bold;
  555. color: #1f2d3d;
  556. }
  557. """)
  558. else:
  559. btn.setStyleSheet("""
  560. QToolButton {
  561. background-color: transparent;
  562. border: 2px solid #d0d7de;
  563. border-radius: 12px;
  564. font-size: 14px;
  565. font-weight: bold;
  566. color: #1f2d3d;
  567. }
  568. QToolButton:hover {
  569. background-color: rgba(0, 0, 0, 0.05);
  570. border-color: #409eff;
  571. }
  572. """)
  573. # ---------- 视频尺寸自适应 ----------
  574. def update_video_size(self):
  575. if not self.left_label or not self.right_label:
  576. return
  577. container = self.left_label.parentWidget()
  578. if not container:
  579. return
  580. available_width = container.width() - 15
  581. single_width = available_width // 2
  582. target_height = int(single_width / (16/9 if self._ratio_16_9 else 4/3))
  583. if target_height > container.height():
  584. target_height = container.height()
  585. self.left_label.setFixedSize(single_width, target_height)
  586. self.right_label.setFixedSize(single_width, target_height)
  587. def update_players_target_size(self):
  588. if self.left_player:
  589. self.left_player.set_target_size(self.left_label.width(), self.left_label.height())
  590. if self.right_player:
  591. self.right_player.set_target_size(self.right_label.width(), self.right_label.height())
  592. def resizeEvent(self, event: QResizeEvent):
  593. self.update_video_size()
  594. self.update_players_target_size()
  595. super().resizeEvent(event)
  596. def keyPressEvent(self, event: QKeyEvent):
  597. if event.key() == Qt.Key_Escape and self.isFullScreen():
  598. self.showNormal()
  599. elif event.key() == Qt.Key_F11:
  600. if self.isFullScreen():
  601. self.showNormal()
  602. else:
  603. self.showFullScreen()
  604. else:
  605. super().keyPressEvent(event)
  606. def _toggle_fullscreen(self):
  607. if self.isFullScreen():
  608. self.showNormal()
  609. else:
  610. self.showFullScreen()
  611. def _toggle_aspect_ratio(self):
  612. self._ratio_16_9 = not self._ratio_16_9
  613. self.ratio_btn.setText("16:9" if self._ratio_16_9 else "4:3")
  614. self.update_video_size()
  615. self.update_players_target_size()
  616. self._save_config()
  617. # ---------- 加载动画和超时 ----------
  618. def show_loading_on_label(self, label: QLabel):
  619. label.setText("加载中...")
  620. label.setAlignment(Qt.AlignCenter)
  621. label.setStyleSheet(label.styleSheet() + "color: white; font-size: 20px;")
  622. def hide_loading_on_label(self, label: QLabel):
  623. label.setText("")
  624. label.setStyleSheet("""
  625. QLabel {
  626. border: 2px solid #dcdfe6;
  627. border-radius: 8px;
  628. background-color: black;
  629. }
  630. """)
  631. def show_timeout_on_label(self, label: QLabel):
  632. label.setText("连接超时")
  633. label.setAlignment(Qt.AlignCenter)
  634. label.setStyleSheet(label.styleSheet() + "color: red; font-size: 20px;")
  635. def on_player_timeout(self, label: QLabel, player_ref, side: str):
  636. self.show_timeout_on_label(label)
  637. # 只停止当前活动的播放器(防止旧信号误杀新播放器)
  638. if side == "left" and self.left_player is player_ref:
  639. self.left_player.stop()
  640. self.left_player = None
  641. elif side == "right" and self.right_player is player_ref:
  642. self.right_player.stop()
  643. self.right_player = None
  644. self._start_rtsp_retry(side)
  645. def _cancel_rtsp_retry(self, side: str):
  646. timer = self._rtsp_retry_left if side == "left" else self._rtsp_retry_right
  647. if timer:
  648. timer.stop()
  649. timer.deleteLater()
  650. if side == "left":
  651. self._rtsp_retry_left = None
  652. self._rtsp_retry_delay_left = 1
  653. else:
  654. self._rtsp_retry_right = None
  655. self._rtsp_retry_delay_right = 1
  656. def _start_rtsp_retry(self, side: str):
  657. if not self.active_bucket:
  658. return
  659. cam = next((c for c in self.active_bucket.cameras if c.position == (CameraPosition.LEFT if side == "left" else CameraPosition.RIGHT)), None)
  660. if not cam:
  661. return
  662. if not self._is_camera_online(cam):
  663. return
  664. label = self.left_label if side == "left" else self.right_label
  665. delay = self._rtsp_retry_delay_left if side == "left" else self._rtsp_retry_delay_right
  666. label.setText(f"正在重连({delay}s)...")
  667. label.setAlignment(Qt.AlignCenter)
  668. label.setStyleSheet(label.styleSheet() + "color: orange; font-size: 20px;")
  669. timer = QTimer(self)
  670. timer.setSingleShot(True)
  671. timer.timeout.connect(lambda: self._retry_rtsp(side))
  672. timer.start(delay * 1000)
  673. if side == "left":
  674. self._rtsp_retry_left = timer
  675. else:
  676. self._rtsp_retry_right = timer
  677. def _retry_rtsp(self, side: str):
  678. if not self.active_bucket:
  679. return
  680. cam = next((c for c in self.active_bucket.cameras if c.position == (CameraPosition.LEFT if side == "left" else CameraPosition.RIGHT)), None)
  681. if not cam:
  682. return
  683. label = self.left_label if side == "left" else self.right_label
  684. if not self._is_camera_online(cam):
  685. self.show_timeout_on_label(label)
  686. self._cancel_rtsp_retry(side)
  687. return
  688. self.show_loading_on_label(label)
  689. w, h = label.width(), label.height()
  690. timeout = self.config.video.rtsp_timeout
  691. player = RTSPPlayer(cam.rtsp_url, w, h, timeout_sec=timeout)
  692. player.frame_ready.connect(label.setPixmap)
  693. player.first_frame_received.connect(lambda s=side: self._on_rtsp_retry_success(s))
  694. player.timeout.connect(lambda l=label, p=player, s=side: self.on_player_timeout(l, p, s))
  695. old = self.left_player if side == "left" else self.right_player
  696. if old:
  697. try:
  698. old.frame_ready.disconnect()
  699. except TypeError:
  700. pass
  701. try:
  702. old.first_frame_received.disconnect()
  703. except TypeError:
  704. pass
  705. try:
  706. old.timeout.disconnect()
  707. except TypeError:
  708. pass
  709. old.stop()
  710. if side == "left":
  711. self.left_player = player
  712. else:
  713. self.right_player = player
  714. player.start()
  715. def _on_rtsp_retry_success(self, side: str):
  716. self.hide_loading_on_label(self.left_label if side == "left" else self.right_label)
  717. self._cancel_rtsp_retry(side)
  718. # 重连成功后恢复 AI 对象标注
  719. if self.active_bucket and self.ai_enabled:
  720. objects = self._last_left_draw if side == "left" else self._last_right_draw
  721. player = self.left_player if side == "left" else self.right_player
  722. if player:
  723. player.set_ai_objects(objects)
  724. def _is_camera_online(self, cam) -> bool:
  725. m = re.search(r'rtsp://(?:[^@]+@)?([^:/]+)', cam.rtsp_url)
  726. if not m:
  727. return False
  728. lat = self._camera_signal.get(m.group(1))
  729. # 尚未检测过该 IP 时,默认认为在线,先尝试连接
  730. if lat is None:
  731. return True
  732. return lat >= 0
  733. def on_bucket_clicked(self, bucket: BucketModel):
  734. if self.switch_mode != SwitchMode.HAND:
  735. self.switch_mode = SwitchMode.HAND
  736. self.auto_switch_btn.setChecked(False)
  737. self.auto_switch_btn.setText("手动模式")
  738. self.activate_bucket(bucket)
  739. def on_rfid_state_changed(self, online: bool):
  740. self.rfid_state_signal.emit(online)
  741. def _on_rfid_state_impl(self, online: bool):
  742. if online:
  743. self.module_status.setText("通讯状态: RFID正常")
  744. else:
  745. self.module_status.setText("通讯状态: RFID离线")
  746. logger.warning("读卡器离线")
  747. if self.voice_enabled and getattr(self, '_started', False):
  748. self._speak("读卡器离线")
  749. # ---------- 硬件初始化 ----------
  750. def init_hardware(self):
  751. if self.config.module_type == ModuleType.RFID and self.config.rfid:
  752. valid_codes = {b.code_x for b in self.buckets if b.code_x}
  753. self.rfid_client = RFIDClient(
  754. self.config.rfid,
  755. self.on_rfid_tag,
  756. on_state_changed=self.on_rfid_state_changed,
  757. tag_offset=18,
  758. tag_length=4,
  759. hex_output=True,
  760. valid_codes=valid_codes
  761. )
  762. if self.rfid_client.connect():
  763. self.module_status.setText("通讯状态: RFID正常")
  764. else:
  765. self.module_status.setText("通讯状态: RFID离线")
  766. if self.config.module_type == ModuleType.HD3200 and self.config.modbus:
  767. self.modbus_client = ModbusClient(self.config.modbus, self.on_modbus_data)
  768. if self.modbus_client.connect():
  769. self.module_status.setText("通讯状态: Modbus正常")
  770. else:
  771. self.module_status.setText("通讯状态: Modbus离线")
  772. if self.config.ai.enabled:
  773. self.mqtt_client = MQTTClient(self.on_mqtt_message)
  774. self._connect_current_ai()
  775. def _connect_current_ai(self, bucket=None):
  776. if bucket is None:
  777. bucket = self.active_bucket
  778. idx = None
  779. if bucket and bucket.ai_module_id is not None:
  780. idx = bucket.ai_module_id
  781. elif self.config.ai_modules:
  782. idx = 0
  783. if idx is not None and idx < len(self.config.ai_modules):
  784. target = self.config.ai_modules[idx]
  785. self.mqtt_client.disconnect()
  786. self.mqtt_client.connect(target.broker, target.port)
  787. self._current_ai_module_id = idx
  788. if bucket:
  789. topics = [c.topic for c in bucket.cameras if c.topic]
  790. if topics:
  791. self.mqtt_client.subscribe(topics)
  792. self._subscribed_topics = set(topics)
  793. # ---------- 激活工位 ----------
  794. def activate_bucket(self, bucket: BucketModel):
  795. if self.active_bucket == bucket:
  796. return
  797. self._cancel_rtsp_retry("left")
  798. self._cancel_rtsp_retry("right")
  799. if bucket and self.voice_enabled:
  800. self._speak(f"到达{bucket.description}")
  801. for player in (self.left_player, self.right_player):
  802. if player is None:
  803. continue
  804. try:
  805. player.frame_ready.disconnect()
  806. except TypeError:
  807. pass
  808. try:
  809. player.first_frame_received.disconnect()
  810. except TypeError:
  811. pass
  812. try:
  813. player.timeout.disconnect()
  814. except TypeError:
  815. pass
  816. player.stop()
  817. self.left_player = None
  818. self.right_player = None
  819. if self.active_bucket:
  820. self.active_bucket.is_active = False
  821. self.active_bucket = bucket
  822. if bucket:
  823. bucket.is_active = True
  824. self.refresh_bucket_icons()
  825. self.show_loading_on_label(self.left_label)
  826. self.show_loading_on_label(self.right_label)
  827. # 重置挂钩状态
  828. self.hook_state = HookState.IDLE
  829. self.prev_state = HookState.IDLE
  830. self.state_counter = 0
  831. self.left_objects = []
  832. self.right_objects = []
  833. self.prev_left_hook = None
  834. self.prev_right_hook = None
  835. self.prev_left_lug = None
  836. self.prev_right_lug = None
  837. self.last_voice_state = None
  838. self._last_hooking_voice_time = 0.0
  839. self._ready_traces.clear()
  840. self._pair_traj = {"left": [], "right": []}
  841. self._voice_suppress_until = 0.0
  842. self._trace_phase = ""
  843. self._ascend_frames = 0
  844. self._ascend_latch_until = 0.0
  845. self._ready_hold_until = 0.0
  846. self.update_ui_for_state(HookState.IDLE)
  847. self._last_left_draw = []
  848. self._last_right_draw = []
  849. # 先切 AI 模块 + 订阅 MQTT topic(非阻塞),再启 RTSP
  850. if bucket and self.config.ai.enabled and self.mqtt_client:
  851. if bucket.ai_module_id != self._current_ai_module_id:
  852. self._unsubscribe_all_topics()
  853. self._connect_current_ai(bucket)
  854. if bucket:
  855. if self.mqtt_client:
  856. topics = [c.topic for c in bucket.cameras if c.topic]
  857. stale = self._subscribed_topics - set(topics)
  858. for t in stale:
  859. self.mqtt_client.unsubscribe(t)
  860. new_t = [t for t in topics if t not in self._subscribed_topics]
  861. if new_t:
  862. self.mqtt_client.subscribe(new_t)
  863. self._subscribed_topics = set(topics)
  864. # 再启 RTSP
  865. left_cam = next((c for c in bucket.cameras if c.position == CameraPosition.LEFT), None)
  866. right_cam = next((c for c in bucket.cameras if c.position == CameraPosition.RIGHT), None)
  867. if left_cam and self._is_camera_online(left_cam):
  868. w = self.left_label.width()
  869. h = self.left_label.height()
  870. timeout = self.config.video.rtsp_timeout
  871. player = RTSPPlayer(left_cam.rtsp_url, w, h, timeout_sec=timeout)
  872. player.frame_ready.connect(self.left_label.setPixmap)
  873. player.first_frame_received.connect(lambda p=player: self.hide_loading_on_label(self.left_label))
  874. player.timeout.connect(lambda p=player: self.on_player_timeout(self.left_label, p, "left"))
  875. self.left_player = player
  876. player.start()
  877. elif left_cam:
  878. self.show_timeout_on_label(self.left_label)
  879. if self.voice_enabled:
  880. self._speak("左侧摄像头离线")
  881. if right_cam and self._is_camera_online(right_cam):
  882. w = self.right_label.width()
  883. h = self.right_label.height()
  884. timeout = self.config.video.rtsp_timeout
  885. player = RTSPPlayer(right_cam.rtsp_url, w, h, timeout_sec=timeout)
  886. player.frame_ready.connect(self.right_label.setPixmap)
  887. player.first_frame_received.connect(lambda p=player: self.hide_loading_on_label(self.right_label))
  888. player.timeout.connect(lambda p=player: self.on_player_timeout(self.right_label, p, "right"))
  889. self.right_player = player
  890. player.start()
  891. elif right_cam:
  892. self.show_timeout_on_label(self.right_label)
  893. if self.voice_enabled:
  894. self._speak("右侧摄像头离线")
  895. else:
  896. self._unsubscribe_all_topics()
  897. self.left_label.clear()
  898. self.right_label.clear()
  899. QApplication.processEvents()
  900. self.left_label.clear()
  901. self.right_label.clear()
  902. def _unsubscribe_all_topics(self):
  903. if self.mqtt_client and self._subscribed_topics:
  904. for t in self._subscribed_topics:
  905. self.mqtt_client.unsubscribe(t)
  906. self._subscribed_topics.clear()
  907. # ---------- RFID 标签回调 ----------
  908. def on_rfid_tag(self, tag: str):
  909. self.rfid_tag_signal.emit(tag)
  910. def _on_rfid_tag_impl(self, tag: str):
  911. self._last_rfid_tag = tag
  912. if tag:
  913. self.rfid_tag_label.setText(f"当前卡号: {tag}")
  914. else:
  915. self.rfid_tag_label.setText("当前卡号: 无")
  916. if tag is None:
  917. if self.switch_mode == SwitchMode.AUTO and self.active_bucket:
  918. self.activate_bucket(None)
  919. return
  920. for bucket in self.buckets:
  921. if bucket.code_x == tag:
  922. if self.switch_mode == SwitchMode.AUTO:
  923. self.activate_bucket(bucket)
  924. break
  925. # ---------- Modbus 数据回调 ----------
  926. def on_modbus_data(self, bools):
  927. self.modbus_data_signal.emit(bools)
  928. def _on_modbus_data_impl(self, bools):
  929. for bucket in self.buckets:
  930. try:
  931. addr_x = int(bucket.code_x)
  932. except (ValueError, TypeError):
  933. continue
  934. if addr_x < 0 or addr_x >= len(bools):
  935. continue
  936. if bucket.code_y and bucket.code_y.strip():
  937. try:
  938. addr_y = int(bucket.code_y)
  939. except (ValueError, TypeError):
  940. continue
  941. if addr_y < 0 or addr_y >= len(bools):
  942. continue
  943. if bools[addr_x] and bools[addr_y]:
  944. if self.switch_mode == SwitchMode.AUTO and self.active_bucket != bucket:
  945. self.activate_bucket(bucket)
  946. break
  947. else:
  948. if bools[addr_x]:
  949. if self.switch_mode == SwitchMode.AUTO and self.active_bucket != bucket:
  950. self.activate_bucket(bucket)
  951. break
  952. # ---------- MQTT 消息处理 ----------
  953. def parse_ai_objects(self, payload: dict, display_width: int, display_height: int,
  954. include_ready: bool = True) -> list:
  955. if payload is None or not isinstance(payload, dict):
  956. return []
  957. orig_width = payload.get("width", 640)
  958. orig_height = payload.get("height", 360)
  959. scale_x = display_width / orig_width if orig_width > 0 else 1.0
  960. scale_y = display_height / orig_height if orig_height > 0 else 1.0
  961. objects = []
  962. for obj_str in payload.get("objects", []):
  963. parts = obj_str.split('|')
  964. if len(parts) >= 6:
  965. try:
  966. x = int(float(parts[1]) * scale_x)
  967. y = int(float(parts[2]) * scale_y)
  968. w = int(float(parts[3]) * scale_x)
  969. h = int(float(parts[4]) * scale_y)
  970. raw_label = parts[5]
  971. label = raw_label
  972. if 'hoist' in raw_label:
  973. label = 'hook'
  974. elif 'cup' in raw_label:
  975. label = 'lug'
  976. if 'hook' in label:
  977. color = (0, 0, 255)
  978. elif 'lug' in label:
  979. color = (255, 0, 0)
  980. elif 'ready' in label:
  981. if not include_ready:
  982. continue
  983. color = (0, 255, 255)
  984. else:
  985. color = (128, 128, 128)
  986. objects.append({
  987. "track_id": parts[0],
  988. "x": x,
  989. "y": y,
  990. "w": w,
  991. "h": h,
  992. "label": label,
  993. "raw_label": raw_label,
  994. "color": color
  995. })
  996. except (ValueError, IndexError):
  997. continue
  998. return objects
  999. def on_mqtt_message(self, topic: str, payload: dict):
  1000. self.mqtt_message_signal.emit(topic, payload)
  1001. def _on_mqtt_message_impl(self, topic: str, payload: dict):
  1002. if payload is None or not isinstance(payload, dict):
  1003. return
  1004. if not self.active_bucket:
  1005. return
  1006. if not self.ai_enabled:
  1007. return
  1008. now = time.time()
  1009. if now - self._last_mqtt_process < 0.03:
  1010. return
  1011. self._last_mqtt_process = now
  1012. self._overlay_cleared = False
  1013. for cam in self.active_bucket.cameras:
  1014. if cam.topic == topic:
  1015. self.update_video_size()
  1016. if cam.position == CameraPosition.LEFT:
  1017. display_width = self.left_label.width()
  1018. display_height = self.left_label.height()
  1019. else:
  1020. display_width = self.right_label.width()
  1021. display_height = self.right_label.height()
  1022. if display_width == 0 or display_height == 0:
  1023. display_width = 640
  1024. display_height = 360
  1025. all_objects = self.parse_ai_objects(payload, display_width, display_height, include_ready=True)
  1026. # 更新 ready 标签轨迹
  1027. for obj in all_objects:
  1028. if 'ready' in obj['label']:
  1029. tid = obj.get('track_id')
  1030. if tid:
  1031. cx = obj['x'] + obj['w'] / 2
  1032. cy = obj['y'] + obj['h'] / 2
  1033. if tid not in self._ready_traces:
  1034. self._ready_traces[tid] = []
  1035. self._ready_traces[tid].append((cx, cy, time.time()))
  1036. if len(self._ready_traces[tid]) > 20:
  1037. self._ready_traces[tid].pop(0)
  1038. if cam.position == CameraPosition.LEFT:
  1039. self.left_objects = all_objects
  1040. else:
  1041. self.right_objects = all_objects
  1042. self.evaluate_hook_state()
  1043. draw_objects = []
  1044. for obj in all_objects:
  1045. if 'ready' in obj['label'].lower():
  1046. continue
  1047. obj['alpha'] = 0.4
  1048. draw_objects.append(obj)
  1049. side = "left" if cam.position == CameraPosition.LEFT else "right"
  1050. draw_objects.extend(self._trace_objects(side))
  1051. status = self._trace_status_text()
  1052. if status:
  1053. color = (0, 0, 255) if self._trace_phase else (0, 200, 255)
  1054. draw_objects.append({"text": status, "pos": (10, 20), "anchor": "tr", "color": color})
  1055. if cam.position == CameraPosition.LEFT:
  1056. self._last_left_draw = draw_objects
  1057. if self.left_player:
  1058. self.left_player.set_ai_objects(draw_objects)
  1059. else:
  1060. self._last_right_draw = draw_objects
  1061. if self.right_player:
  1062. self.right_player.set_ai_objects(draw_objects)
  1063. break
  1064. # ---------- 挂钩状态评估 ----------
  1065. def evaluate_hook_state(self):
  1066. left_hooks = [o for o in self.left_objects if 'hook' in o['label'].lower()]
  1067. left_lugs = [o for o in self.left_objects if 'lug' in o['label'].lower()]
  1068. right_hooks = [o for o in self.right_objects if 'hook' in o['label'].lower()]
  1069. right_lugs = [o for o in self.right_objects if 'lug' in o['label'].lower()]
  1070. def find_nearest_pair(hooks, lugs):
  1071. if not hooks or not lugs:
  1072. return None
  1073. best_pair = None
  1074. best_dist = float('inf')
  1075. for h in hooks:
  1076. hx = h['x'] + h['w'] / 2
  1077. hy = h['y'] + h['h'] / 2
  1078. for l in lugs:
  1079. lx = l['x'] + l['w'] / 2
  1080. ly = l['y'] + l['h'] / 2
  1081. dist = (hx - lx) ** 2 + (hy - ly) ** 2
  1082. if dist < best_dist:
  1083. best_dist = dist
  1084. best_pair = (h, l)
  1085. return best_pair
  1086. left_pair = find_nearest_pair(left_hooks, left_lugs)
  1087. right_pair = find_nearest_pair(right_hooks, right_lugs)
  1088. self._push_pair_traj("left", left_pair)
  1089. self._push_pair_traj("right", right_pair)
  1090. self._update_suppress_window()
  1091. is_releasing = False
  1092. if left_pair:
  1093. h, l = left_pair
  1094. if self.prev_left_hook and self.prev_left_lug:
  1095. dy_h = (h['y'] + h['h'] / 2) - self.prev_left_hook[1]
  1096. dy_l = (l['y'] + l['h'] / 2) - self.prev_left_lug[1]
  1097. if dy_h > self.RELEASE_DELTA_THRESHOLD and dy_l > self.RELEASE_DELTA_THRESHOLD:
  1098. is_releasing = True
  1099. self.prev_left_hook = (h['x'] + h['w'] / 2, h['y'] + h['h'] / 2)
  1100. self.prev_left_lug = (l['x'] + l['w'] / 2, l['y'] + l['h'] / 2)
  1101. else:
  1102. self.prev_left_hook = None
  1103. self.prev_left_lug = None
  1104. if right_pair:
  1105. h, l = right_pair
  1106. if self.prev_right_hook and self.prev_right_lug:
  1107. dy_h = (h['y'] + h['h'] / 2) - self.prev_right_hook[1]
  1108. dy_l = (l['y'] + l['h'] / 2) - self.prev_right_lug[1]
  1109. if dy_h > self.RELEASE_DELTA_THRESHOLD and dy_l > self.RELEASE_DELTA_THRESHOLD:
  1110. is_releasing = True
  1111. self.prev_right_hook = (h['x'] + h['w'] / 2, h['y'] + h['h'] / 2)
  1112. self.prev_right_lug = (l['x'] + l['w'] / 2, l['y'] + l['h'] / 2)
  1113. else:
  1114. self.prev_right_hook = None
  1115. self.prev_right_lug = None
  1116. if is_releasing:
  1117. new_state = HookState.RELEASING
  1118. else:
  1119. any_ready = any('ready' in o['label'].lower() for o in self.left_objects + self.right_objects)
  1120. has_intersecting_pair = self._has_intersecting_pair(left_pair, right_pair)
  1121. ascending = self._is_ascending_together() and not self._is_voice_suppressed()
  1122. if self.hook_state == HookState.READY:
  1123. # READY 粘滞:挂包成功后只有确认下降或挂钩离开才退出,
  1124. # 模型 ready 标签抖动 / 短暂静止不再导致红绿来回跳
  1125. descending = self._is_descending()
  1126. if descending:
  1127. new_state = HookState.HOOKING
  1128. elif has_intersecting_pair or ascending:
  1129. new_state = HookState.READY
  1130. self._ready_hold_until = time.time() + self.READY_HOLD_SECS
  1131. elif time.time() < self._ready_hold_until:
  1132. new_state = HookState.READY
  1133. else:
  1134. new_state = HookState.IDLE
  1135. elif ascending:
  1136. new_state = HookState.READY
  1137. self._ready_hold_until = time.time() + self.READY_HOLD_SECS
  1138. elif any_ready and has_intersecting_pair:
  1139. descending = self._is_descending()
  1140. if descending is None:
  1141. new_state = self.hook_state
  1142. elif descending:
  1143. new_state = HookState.HOOKING
  1144. else:
  1145. new_state = HookState.READY
  1146. self._ready_hold_until = time.time() + self.READY_HOLD_SECS
  1147. elif has_intersecting_pair:
  1148. new_state = HookState.HOOKING
  1149. else:
  1150. new_state = HookState.IDLE
  1151. # 防抖:READY 的粘滞/保持已由 sticky + hold 承担,此处不再特殊放行 15 帧
  1152. threshold = 1 if new_state == HookState.IDLE else self.STATE_THRESHOLD
  1153. if new_state == self.prev_state:
  1154. self.state_counter += 1
  1155. else:
  1156. self.prev_state = new_state
  1157. self.state_counter = 1
  1158. if self.state_counter >= threshold:
  1159. if new_state != self.hook_state:
  1160. self.hook_state = new_state
  1161. self.update_ui_for_state(new_state)
  1162. self.state_counter = 0
  1163. for obj in self.left_objects + self.right_objects:
  1164. obj.pop('paired', None)
  1165. if left_pair:
  1166. left_pair[0]['paired'] = True
  1167. left_pair[1]['paired'] = True
  1168. if right_pair:
  1169. right_pair[0]['paired'] = True
  1170. right_pair[1]['paired'] = True
  1171. def rects_intersect(self, a, b):
  1172. ax1, ay1, ax2, ay2 = a['x'], a['y'], a['x']+a['w'], a['y']+a['h']
  1173. bx1, by1, bx2, by2 = b['x'], b['y'], b['x']+b['w'], b['y']+b['h']
  1174. return not (ax2 < bx1 or bx2 < ax1 or ay2 < by1 or by2 < ay1)
  1175. def _has_intersecting_pair(self, left_pair, right_pair):
  1176. if left_pair:
  1177. h, l = left_pair
  1178. if self.rects_intersect(h, l):
  1179. return True
  1180. if right_pair:
  1181. h, l = right_pair
  1182. if self.rects_intersect(h, l):
  1183. return True
  1184. return False
  1185. def _push_pair_traj(self, side: str, pair):
  1186. """把当前帧配对中心与距离追加进轨迹队列"""
  1187. if not pair:
  1188. self._pair_traj[side] = []
  1189. return
  1190. h, l = pair
  1191. hx, hy = h['x'] + h['w'] / 2, h['y'] + h['h'] / 2
  1192. lx, ly = l['x'] + l['w'] / 2, l['y'] + l['h'] / 2
  1193. dist = ((hx - lx) ** 2 + (hy - ly) ** 2) ** 0.5
  1194. now = time.time()
  1195. traj = self._pair_traj[side]
  1196. traj.append({"t": now, "hook_cx": hx, "hook_cy": hy, "lug_cx": lx, "lug_cy": ly, "dist": dist})
  1197. while len(traj) > self.TRAJ_MAX_FRAMES:
  1198. traj.pop(0)
  1199. if traj[-1]['t'] - traj[0]['t'] > self.TRAJ_WINDOW_SECS:
  1200. traj.pop(0)
  1201. def _window_traj(self, side: str):
  1202. """返回该侧轨迹窗口(最近 TRAJ_WINDOW_SECS)"""
  1203. traj = self._pair_traj[side]
  1204. if not traj:
  1205. return traj
  1206. now = traj[-1]['t']
  1207. return [p for p in traj if now - p['t'] <= self.TRAJ_WINDOW_SECS]
  1208. def _update_suppress_window(self):
  1209. """基于轨迹判定放包/分离阶段,命中则顺延屏蔽语音"""
  1210. now = time.time()
  1211. hit = ""
  1212. for side in ("left", "right"):
  1213. win = self._window_traj(side)
  1214. if len(win) < 5:
  1215. continue
  1216. first, last = win[0], win[-1]
  1217. dy_h = last['hook_cy'] - first['hook_cy']
  1218. dy_l = last['lug_cy'] - first['lug_cy']
  1219. min_dist = min(p['dist'] for p in win)
  1220. max_dist = max(p['dist'] for p in win)
  1221. # 放包:hook 与 lug 同步下移,且距离保持贴合
  1222. if dy_h >= self.RELEASE_TRAJ_Y and dy_l >= self.RELEASE_TRAJ_Y and min_dist < self.ENGAGE_DIST:
  1223. hit = "放包"
  1224. # 分离:距离相离(任意方向),且之前曾贴合
  1225. elif max_dist - min_dist >= self.SEPARATE_TRAJ_DIST and min_dist < self.ENGAGE_DIST:
  1226. hit = "分离"
  1227. if hit:
  1228. self._voice_suppress_until = max(now, self._voice_suppress_until) + self.SUPPRESS_SECONDS
  1229. self._trace_phase = hit
  1230. else:
  1231. self._trace_phase = ""
  1232. def _is_voice_suppressed(self) -> bool:
  1233. return time.time() < self._voice_suppress_until
  1234. def _maybe_clear_stale_overlay(self):
  1235. """MQTT 消息超时后清除残留画框/轨迹,避免画面卡住旧框"""
  1236. now = time.time()
  1237. if not self.ai_enabled or self._overlay_cleared:
  1238. return
  1239. if self._last_mqtt_process > 0 and now - self._last_mqtt_process > self.MQTT_CLEAR_TIMEOUT:
  1240. self._last_left_draw = []
  1241. self._last_right_draw = []
  1242. self._pair_traj = {"left": [], "right": []}
  1243. self._trace_phase = ""
  1244. self._voice_suppress_until = 0.0
  1245. self._ascend_frames = 0
  1246. self._ascend_latch_until = 0.0
  1247. self._ready_hold_until = 0.0
  1248. if self.left_player:
  1249. self.left_player.set_ai_objects([])
  1250. if self.right_player:
  1251. self.right_player.set_ai_objects([])
  1252. self._overlay_cleared = True
  1253. def _trace_objects(self, side: str) -> list:
  1254. """构造叠加对象:挂钩轨迹、吊耳轨迹、挂钩-吊耳连线、阶段文本"""
  1255. if not self.debug_trace:
  1256. return []
  1257. win = self._window_traj(side)
  1258. if len(win) < 2:
  1259. return []
  1260. hook_pts = [(p['hook_cx'], p['hook_cy']) for p in win]
  1261. lug_pts = [(p['lug_cx'], p['lug_cy']) for p in win]
  1262. objs = [
  1263. {"points": hook_pts, "color": (0, 255, 0), "alpha": 0.4, "thickness": 1},
  1264. {"points": lug_pts, "color": (0, 255, 255), "alpha": 0.4, "thickness": 1},
  1265. ]
  1266. hx, hy = win[-1]['hook_cx'], win[-1]['hook_cy']
  1267. lx, ly = win[-1]['lug_cx'], win[-1]['lug_cy']
  1268. color = (0, 0, 255) if self._trace_phase == "分离" else (255, 165, 0)
  1269. objs.append({"points": [(hx, hy), (lx, ly)], "color": color, "alpha": 0.4, "thickness": 1})
  1270. return objs
  1271. def _trace_status_text(self) -> str:
  1272. if not self.debug_trace:
  1273. return ""
  1274. if self._trace_phase:
  1275. remain = max(0.0, self._voice_suppress_until - time.time())
  1276. tag = "RELEASE" if self._trace_phase == "放包" else "SEP"
  1277. return f"{tag} {remain:.1f}s"
  1278. return "OK"
  1279. def _is_ascending_together(self) -> bool:
  1280. """最近 5 帧挂钩与吊耳同步上移且保持贴合 → 视为挂包成功(补 ready 漏检)。
  1281. 连续 ASCEND_DEBOUNCE 帧命中后锁存 ASCEND_LATCH_SECS,避免上升结束后
  1282. 轨迹窗口滚动导致瞬间回落误判,也抑制单帧抖动触发"""
  1283. detected = False
  1284. for side in ("left", "right"):
  1285. win = self._window_traj(side)
  1286. if len(win) < 5:
  1287. continue
  1288. recent = win[-5:]
  1289. dy_h = recent[-1]['hook_cy'] - recent[0]['hook_cy']
  1290. dy_l = recent[-1]['lug_cy'] - recent[0]['lug_cy']
  1291. if dy_h <= -self.ASCEND_TRAJ_Y and dy_l <= -self.ASCEND_TRAJ_Y:
  1292. if recent[-1]['dist'] < self.ENGAGE_DIST:
  1293. detected = True
  1294. break
  1295. if detected:
  1296. self._ascend_frames += 1
  1297. else:
  1298. self._ascend_frames = 0
  1299. if self._ascend_frames >= self.ASCEND_DEBOUNCE:
  1300. self._ascend_latch_until = time.time() + self.ASCEND_LATCH_SECS
  1301. return time.time() < self._ascend_latch_until
  1302. def _is_descending(self) -> Optional[bool]:
  1303. """ready 轨迹下降=返回 True;确认静止=返回 False;最近轨迹不足=返回 None。
  1304. 要求窗口净位移与逐步上升帧数同时满足,降低检测框抖动引起的误判"""
  1305. now = time.time()
  1306. confirmed = False
  1307. for tid, trace in self._ready_traces.items():
  1308. if not trace or now - trace[-1][2] > self.TRAJ_WINDOW_SECS:
  1309. continue
  1310. if len(trace) < 5:
  1311. continue
  1312. confirmed = True
  1313. recent = trace[-5:]
  1314. ys = [p[1] for p in recent]
  1315. net = ys[-1] - ys[0]
  1316. rises = sum(1 for i in range(1, len(ys)) if ys[i] > ys[i-1] + 2)
  1317. if net >= 6 and rises >= 4:
  1318. return True
  1319. return None if not confirmed else False
  1320. # def _draw_traces(self, frame, sx=1.0, sy=1.0, x_off=0, y_off=0):
  1321. # import cv2
  1322. # for tid, trace in self._ready_traces.items():
  1323. # if len(trace) < 2:
  1324. # continue
  1325. # pts = [(int((p[0] * sx) + x_off), int((p[1] * sy) + y_off)) for p in trace]
  1326. # for i in range(1, len(pts)):
  1327. # alpha = i / len(pts)
  1328. # color = (0, int(255 * alpha), 255)
  1329. # cv2.line(frame, pts[i-1], pts[i], color, 2)
  1330. # def _build_trace_lines(self) -> list:
  1331. # return [[(p[0], p[1]) for p in trace] for trace in self._ready_traces.values()]
  1332. # ---------- UI 更新与语音 ----------
  1333. def update_ui_for_state(self, state):
  1334. if state == HookState.IDLE:
  1335. border_color = "#dcdfe6"
  1336. text = "空闲"
  1337. elif state == HookState.RELEASING:
  1338. border_color = "#ffa500"
  1339. text = "放包中"
  1340. elif state == HookState.HOOKING:
  1341. border_color = "#ff0000"
  1342. text = "挂包中"
  1343. elif state == HookState.READY:
  1344. border_color = "#00ff00"
  1345. text = "挂包成功"
  1346. else:
  1347. border_color = "#dcdfe6"
  1348. text = ""
  1349. self.hook_status_label.setText(f"挂钩状态: {text}")
  1350. style = f"""
  1351. QLabel {{
  1352. border: 4px solid {border_color};
  1353. border-radius: 8px;
  1354. background-color: black;
  1355. }}
  1356. """
  1357. self.left_label.setStyleSheet(style)
  1358. self.right_label.setStyleSheet(style)
  1359. if self.voice_enabled and state in (HookState.HOOKING, HookState.READY):
  1360. now = time.time()
  1361. if self._is_voice_suppressed():
  1362. return
  1363. if state == HookState.HOOKING:
  1364. if state != self.last_voice_state or now - self._last_hooking_voice_time > 15:
  1365. self._speak("挂包中")
  1366. self.last_voice_state = state
  1367. self._last_hooking_voice_time = now
  1368. elif state != self.last_voice_state:
  1369. self._speak("挂包成功")
  1370. self.last_voice_state = state
  1371. # ---------- 定时任务 ----------
  1372. def check_idle(self):
  1373. if self.rfid_client:
  1374. self.rfid_client.check_idle()
  1375. self._maybe_clear_stale_overlay()
  1376. now = time.time()
  1377. if self.config.ai.enabled and self.mqtt_client and not self.mqtt_client.connected:
  1378. if now - self._last_mqtt_connect_attempt > 30:
  1379. self._last_mqtt_connect_attempt = now
  1380. self._connect_current_ai()
  1381. # 每 5 秒检测一次摄像头信号
  1382. if now - self._last_signal_check > 5:
  1383. self._last_signal_check = now
  1384. if not getattr(self, '_signal_check_thread', None) or not self._signal_check_thread.is_alive():
  1385. self._signal_check_thread = threading.Thread(target=self._check_all_cameras, daemon=True)
  1386. self._signal_check_thread.start()
  1387. def _collect_camera_ips(self) -> list:
  1388. ips = set()
  1389. for bucket in self.buckets:
  1390. for cam in bucket.cameras:
  1391. m = re.search(r'rtsp://(?:[^@]+@)?([^:/]+)', cam.rtsp_url)
  1392. if m:
  1393. ips.add(m.group(1))
  1394. return sorted(ips)
  1395. def _check_all_cameras(self):
  1396. ips = self._collect_camera_ips()
  1397. if not ips:
  1398. return
  1399. def check_one(ip: str) -> tuple:
  1400. try:
  1401. import socket
  1402. s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
  1403. s.settimeout(2)
  1404. r = s.connect_ex((ip, 554))
  1405. s.close()
  1406. return (ip, 0 if r == 0 else -1)
  1407. except Exception:
  1408. return (ip, -1)
  1409. with ThreadPoolExecutor(max_workers=len(ips)) as pool:
  1410. results = dict(pool.map(check_one, ips))
  1411. self.camera_signal_signal.emit(results)
  1412. def _on_camera_signal(self, results: dict):
  1413. was_offline = {ip for ip, lat in self._camera_signal.items() if lat is not None and lat < 0}
  1414. self._camera_signal.update(results)
  1415. now_online = {ip for ip, lat in results.items() if lat >= 0}
  1416. revived = now_online & was_offline
  1417. went_offline = {ip for ip, lat in results.items() if lat < 0}
  1418. if went_offline and self.active_bucket:
  1419. for cam in self.active_bucket.cameras:
  1420. m = re.search(r'rtsp://(?:[^@]+@)?([^:/]+)', cam.rtsp_url)
  1421. if m and m.group(1) in went_offline:
  1422. ip = m.group(1)
  1423. if ip not in self._offline_reported and self.voice_enabled:
  1424. self._offline_reported.add(ip)
  1425. side_name = "左" if cam.position == CameraPosition.LEFT else "右"
  1426. self._speak(f"{side_name}侧摄像头离线")
  1427. logger.warning(f"摄像头离线: {ip}")
  1428. side = "left" if cam.position == CameraPosition.LEFT else "right"
  1429. label = self.left_label if side == "left" else self.right_label
  1430. cur = self.left_player if side == "left" else self.right_player
  1431. if cur is not None:
  1432. cur.stop()
  1433. if side == "left":
  1434. self.left_player = None
  1435. else:
  1436. self.right_player = None
  1437. self._cancel_rtsp_retry(side)
  1438. self.show_timeout_on_label(label)
  1439. if revived and self.active_bucket:
  1440. for cam in self.active_bucket.cameras:
  1441. m = re.search(r'rtsp://(?:[^@]+@)?([^:/]+)', cam.rtsp_url)
  1442. if m and m.group(1) in revived:
  1443. self._offline_reported.discard(m.group(1))
  1444. side = "left" if cam.position == CameraPosition.LEFT else "right"
  1445. cur = self.left_player if side == "left" else self.right_player
  1446. if cur is None:
  1447. self._cancel_rtsp_retry(side)
  1448. self._retry_rtsp(side)
  1449. for bucket in self.buckets:
  1450. bars = self._bucket_signal_labels.get(bucket.id)
  1451. if not bars:
  1452. continue
  1453. left_bar, right_bar = bars
  1454. for bar, cam in zip((left_bar, right_bar), bucket.cameras):
  1455. m = re.search(r'rtsp://(?:[^@]+@)?([^:/]+)', cam.rtsp_url)
  1456. if not m:
  1457. bar.setStyleSheet("background-color: #dcdfe6; border-radius: 2px;")
  1458. continue
  1459. ip = m.group(1)
  1460. lat = self._camera_signal.get(ip)
  1461. if lat is None:
  1462. bar.setStyleSheet("background-color: #dcdfe6; border-radius: 2px;")
  1463. elif lat < 0:
  1464. bar.setStyleSheet("background-color: #f56c6c; border-radius: 2px;")
  1465. elif lat < 50:
  1466. bar.setStyleSheet("background-color: #67c23a; border-radius: 2px;")
  1467. elif lat < 200:
  1468. bar.setStyleSheet("background-color: #e6a23c; border-radius: 2px;")
  1469. else:
  1470. bar.setStyleSheet("background-color: #f56c6c; border-radius: 2px;")
  1471. def toggle_switch_mode(self, checked: bool):
  1472. if checked:
  1473. self.switch_mode = SwitchMode.AUTO
  1474. self.auto_switch_btn.setText("自动模式")
  1475. if self._last_rfid_tag:
  1476. self._on_rfid_tag_impl(self._last_rfid_tag)
  1477. elif self.active_bucket:
  1478. self.activate_bucket(None)
  1479. else:
  1480. self.switch_mode = SwitchMode.HAND
  1481. self.auto_switch_btn.setText("手动模式")
  1482. # ---------- 开机自启 ----------
  1483. def _save_config(self):
  1484. import configparser
  1485. cfg = configparser.ConfigParser()
  1486. cfg.read(self._config_path, encoding="utf-8")
  1487. if not cfg.has_section("AI"):
  1488. cfg.add_section("AI")
  1489. val = "true" if self.config.ai.tts_enabled else "false"
  1490. cfg.set("AI", "tts_enabled", val)
  1491. if not cfg.has_section("Video"):
  1492. cfg.add_section("Video")
  1493. cfg.set("Video", "ratio_16_9", "true" if self._ratio_16_9 else "false")
  1494. with open(self._config_path, "w", encoding="utf-8") as f:
  1495. cfg.write(f)
  1496. logger.info(f"配置已保存: {self._config_path}")
  1497. @staticmethod
  1498. def _autostart_config_dir():
  1499. if sys.platform == "win32":
  1500. return os.path.join(os.environ.get("APPDATA", os.path.expanduser("~")), "HookVision")
  1501. return os.path.join(os.path.expanduser("~"), ".config", "HookVision")
  1502. @staticmethod
  1503. def _autostart_config_path():
  1504. return os.path.join(MainWindow._autostart_config_dir(), "autostart.txt") # 改为 txt
  1505. @staticmethod
  1506. def _autostart_desktop_path():
  1507. return os.path.expanduser("~/.config/autostart/HookVision.desktop")
  1508. @staticmethod
  1509. def _read_autostart_flag():
  1510. path = MainWindow._autostart_config_path()
  1511. try:
  1512. with open(path, "r", encoding="utf-8") as f:
  1513. content = f.read().strip().lower()
  1514. return content == "true"
  1515. except:
  1516. return False
  1517. @staticmethod
  1518. def _write_autostart_flag(enabled: bool):
  1519. path = MainWindow._autostart_config_path()
  1520. os.makedirs(os.path.dirname(path), exist_ok=True)
  1521. with open(path, "w", encoding="utf-8") as f:
  1522. f.write("true" if enabled else "false")
  1523. @staticmethod
  1524. def _autostart_command():
  1525. if getattr(sys, 'frozen', False):
  1526. return sys.executable
  1527. else:
  1528. main_script = os.path.abspath(sys.argv[0])
  1529. python_exe = sys.executable
  1530. if ' ' in python_exe:
  1531. python_exe = f'"{python_exe}"'
  1532. if ' ' in main_script:
  1533. main_script = f'"{main_script}"'
  1534. if sys.platform == "win32":
  1535. pythonw = python_exe.replace('python.exe', 'pythonw.exe')
  1536. if pythonw != python_exe and os.path.exists(pythonw):
  1537. python_exe = pythonw
  1538. return f'{python_exe} {main_script}'
  1539. @staticmethod
  1540. def _apply_system_autostart(enabled: bool) -> bool:
  1541. cmd = MainWindow._autostart_command()
  1542. try:
  1543. if sys.platform == "win32":
  1544. import winreg
  1545. key = r"Software\Microsoft\Windows\CurrentVersion\Run"
  1546. with winreg.OpenKey(winreg.HKEY_CURRENT_USER, key, 0, winreg.KEY_SET_VALUE) as rk:
  1547. if enabled:
  1548. winreg.SetValueEx(rk, MainWindow.APPTITLE, 0, winreg.REG_SZ, cmd)
  1549. else:
  1550. try:
  1551. winreg.DeleteValue(rk, MainWindow.APPTITLE)
  1552. except OSError:
  1553. pass
  1554. return True
  1555. else:
  1556. desktop_path = MainWindow._autostart_desktop_path()
  1557. if enabled:
  1558. os.makedirs(os.path.dirname(desktop_path), exist_ok=True)
  1559. with open(desktop_path, "w", encoding="utf-8") as f:
  1560. f.write(f"""[Desktop Entry]
  1561. Type=Application
  1562. Name={MainWindow.APPTITLE}
  1563. Exec={cmd}
  1564. Terminal=false
  1565. X-GNOME-Autostart-enabled=true
  1566. """)
  1567. else:
  1568. try:
  1569. os.remove(desktop_path)
  1570. except OSError:
  1571. pass
  1572. return True
  1573. except Exception as e:
  1574. logger.error(f"设置开机自启失败: {e}")
  1575. return False
  1576. def _on_autostart_toggle(self, state):
  1577. enabled = (state == Qt.Checked)
  1578. self._write_autostart_flag(enabled)
  1579. ok = self._apply_system_autostart(enabled)
  1580. if not ok:
  1581. QMessageBox.warning(
  1582. self,
  1583. "设置失败",
  1584. "开机自启动设置失败,请检查权限或手动添加。\n"
  1585. "Windows: 将本程序快捷方式放入 '启动' 文件夹。\n"
  1586. "Linux: 检查 ~/.config/autostart/ 目录权限。"
  1587. )