main_window_v2.py 88 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156
  1. import os
  2. import sys
  3. import time
  4. import re
  5. import logging
  6. import configparser
  7. import threading
  8. import queue
  9. import subprocess
  10. from concurrent.futures import ThreadPoolExecutor
  11. from collections import defaultdict, deque
  12. from PySide6.QtWidgets import (QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
  13. QPushButton, QLabel, QGridLayout, QScrollArea,
  14. QToolButton, QFrame, QApplication, QCheckBox,
  15. QMessageBox, QSlider, QDialog, QDialogButtonBox)
  16. from PySide6.QtCore import Qt, QTimer, QSize, Signal
  17. from PySide6.QtGui import QIcon, QPixmap, QResizeEvent, QKeyEvent, QCloseEvent
  18. from .models import *
  19. from .rtsp_player import RTSPPlayer
  20. from .rfid_client import RFIDClient
  21. from .modbus_client import ModbusClient
  22. from .mqtt_client import MQTTClient
  23. from .auto_restart import AutoRestartScheduler
  24. logger = logging.getLogger(__name__)
  25. class MainWindow(QMainWindow):
  26. # 防抖阈值(连续相同状态次数)
  27. STATE_THRESHOLD = 3
  28. # 放包判断位移阈值(像素)
  29. RELEASE_DELTA_THRESHOLD = 5
  30. # 轨迹阶段判断阈值(像素)
  31. RELEASE_TRAJ_Y = 8
  32. SEPARATE_TRAJ_DIST = 15
  33. ENGAGE_DIST = 40
  34. # 命中放包/分离后屏蔽语音的顺延秒数
  35. SUPPRESS_SECONDS = 8
  36. # 轨迹窗口:保留最近 2 秒 / 最多 30 帧
  37. TRAJ_WINDOW_SECS = 2.0
  38. TRAJ_MAX_FRAMES = 30
  39. # MQTT 消息超时(秒)后清除残留画框
  40. MQTT_CLEAR_TIMEOUT = 1
  41. # 标定采样时长(秒),期间对每帧有效配对取平均
  42. CALIB_DURATION_SECS = 1.0
  43. # 自动标定参数见 [AI] 配置:auto_calib_ready_frames / auto_calib_duration_secs / auto_calib_min_samples
  44. APPTITLE = "HookVision"
  45. # 跨线程信号
  46. rfid_tag_signal = Signal(object)
  47. rfid_state_signal = Signal(object)
  48. modbus_data_signal = Signal(object)
  49. mqtt_message_signal = Signal(str, object)
  50. voice_signal = Signal(str)
  51. camera_signal_signal = Signal(dict)
  52. restart_trigger_signal = Signal()
  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. # 未标定侧告警去重(side → 已告警)
  91. self._calib_warned: set = set()
  92. self.debug_trace = getattr(config.ai, 'debug_trace', True)
  93. # MQTT 消息超时后是否已清除画框(防重复清空)
  94. self._overlay_cleared = False
  95. # 标定采集状态:点击标定后连续采样约 CALIB_DURATION_SECS 秒取平均
  96. self._calib_active = False
  97. self._calib_until: float = 0.0
  98. self._calib_samples: dict = {} # side → [sum_xr, sum_yr, count]
  99. self._calib_badge_labels: dict = {} # bucket.id → QLabel
  100. # 自动标定(窗口内 ready 帧数达标):未标定侧自动标定,已标定侧不覆盖
  101. self._auto_calib_state: dict = { # side → 窗口计数状态
  102. side: {"ready_times": deque(), "samples": deque()}
  103. for side in ("left", "right")
  104. }
  105. self._side_ready: dict = {"left": False, "right": False}
  106. # 语音引擎(在工作线程内初始化)
  107. self.last_voice_state = None
  108. self._last_hooking_voice_time = 0.0
  109. self.voice_enabled = True
  110. self._voice_volume = 100
  111. self.ai_enabled = True
  112. self._last_left_draw: list = []
  113. self._last_right_draw: list = []
  114. self._voice_queue = queue.Queue(maxsize=32)
  115. self._voice_thread = threading.Thread(target=self._voice_worker, daemon=True)
  116. self._voice_thread.start()
  117. self._voices_dir = self._get_voices_dir()
  118. self._use_tts = self.config.ai.tts_enabled
  119. # RTSP 退避重连
  120. self._rtsp_retry_left: Optional[QTimer] = None
  121. self._rtsp_retry_right: Optional[QTimer] = None
  122. self._rtsp_retry_delay_left: int = 1
  123. self._rtsp_retry_delay_right: int = 1
  124. self._last_mqtt_connect_attempt: float = 0.0
  125. self._last_mqtt_process: float = 0.0
  126. # 摄像头信号监控
  127. self._camera_signal: dict = {} # ip → 延迟(ms), -1=不可达
  128. self._bucket_signal_labels: dict = {} # bucket.id → QLabel
  129. self._last_signal_check: float = 0.0
  130. self._signal_check_thread: threading.Thread = None
  131. self._offline_reported: set = set() # 已播报离线的 IP
  132. # 连接跨线程信号到主线程槽
  133. self.rfid_tag_signal.connect(self._on_rfid_tag_impl)
  134. self.rfid_state_signal.connect(self._on_rfid_state_impl)
  135. self.modbus_data_signal.connect(self._on_modbus_data_impl)
  136. self.mqtt_message_signal.connect(self._on_mqtt_message_impl)
  137. self.voice_signal.connect(self._speak_impl)
  138. self.camera_signal_signal.connect(self._on_camera_signal)
  139. self.restart_trigger_signal.connect(self._on_auto_restart_impl)
  140. self.setWindowTitle(config.app_name)
  141. logo_path = os.path.join(os.path.dirname(__file__), "..", "assets", "logo.png")
  142. if os.path.exists(logo_path):
  143. self.setWindowIcon(QIcon(logo_path))
  144. screen = QApplication.primaryScreen()
  145. screen_geometry = screen.availableGeometry()
  146. screen_width = screen_geometry.width()
  147. screen_height = screen_geometry.height()
  148. window_width = int(screen_width * 0.9)
  149. window_height = int(screen_height * 0.85)
  150. x = (screen_width - window_width) // 2
  151. y = (screen_height - window_height) // 2
  152. self.setGeometry(x, y, window_width, window_height)
  153. self.setMinimumSize(800, 600)
  154. self.setup_ui()
  155. self.showFullScreen()
  156. self.update_video_size()
  157. self.init_hardware()
  158. self._started = True
  159. self.timer = QTimer()
  160. self.timer.timeout.connect(self.check_idle)
  161. self.timer.start(1000)
  162. self.auto_restart_scheduler = AutoRestartScheduler(config.auto_restart)
  163. self.auto_restart_scheduler.on_restart_triggered = self._on_auto_restart_triggered
  164. self.auto_restart_scheduler.start()
  165. def setup_ui(self):
  166. central = QWidget()
  167. self.setCentralWidget(central)
  168. main_layout = QVBoxLayout(central)
  169. main_layout.setContentsMargins(10, 10, 10, 10)
  170. main_layout.setSpacing(10)
  171. # 标题栏
  172. title_widget = QWidget()
  173. title_layout = QHBoxLayout(title_widget)
  174. title_layout.setContentsMargins(0, 0, 0, 0)
  175. logo_path = os.path.join(os.path.dirname(__file__), "..", "assets", "logo.png")
  176. if os.path.exists(logo_path):
  177. logo_label = QLabel()
  178. logo_pixmap = QPixmap(logo_path).scaled(28, 28, Qt.KeepAspectRatio, Qt.SmoothTransformation)
  179. logo_label.setPixmap(logo_pixmap)
  180. logo_label.setFixedSize(28, 28)
  181. title_layout.addWidget(logo_label)
  182. self.title_label = QLabel(self.config.app_name)
  183. self.title_label.setStyleSheet("font-size: 22px; font-weight: bold; padding: 5px;")
  184. title_layout.addWidget(self.title_label)
  185. title_layout.addStretch()
  186. btn_style = """
  187. QPushButton {
  188. background-color: transparent;
  189. border: none;
  190. font-size: 18px;
  191. padding: 6px 12px;
  192. border-radius: 5px;
  193. }
  194. QPushButton:hover {
  195. background-color: rgba(255, 255, 255, 0.1);
  196. }
  197. """
  198. self.min_btn = QPushButton("—")
  199. self.min_btn.setStyleSheet(btn_style)
  200. self.min_btn.clicked.connect(self.showMinimized)
  201. self.fullscreen_btn = QPushButton("⛶")
  202. self.fullscreen_btn.setStyleSheet(btn_style)
  203. self.fullscreen_btn.setToolTip("全屏 / 还原")
  204. self.fullscreen_btn.clicked.connect(self._toggle_fullscreen)
  205. self.close_btn = QPushButton("✕")
  206. self.close_btn.setStyleSheet(btn_style)
  207. self.close_btn.clicked.connect(self.close)
  208. title_layout.addWidget(self.min_btn)
  209. title_layout.addWidget(self.fullscreen_btn)
  210. title_layout.addWidget(self.close_btn)
  211. main_layout.addWidget(title_widget)
  212. # 工位区域
  213. bucket_group = QFrame()
  214. bucket_group.setStyleSheet("QFrame { border-radius: 8px; padding: 8px; }")
  215. bucket_layout = QVBoxLayout(bucket_group)
  216. scroll_area = QScrollArea()
  217. scroll_area.setWidgetResizable(True)
  218. scroll_area.setFrameShape(QFrame.NoFrame)
  219. scroll_area.setStyleSheet("QScrollArea { background: transparent; border: none; }")
  220. self.bucket_container = QWidget()
  221. self.bucket_grid = QGridLayout(self.bucket_container)
  222. self.bucket_grid.setSpacing(15)
  223. self.bucket_grid.setContentsMargins(10, 10, 10, 10)
  224. self.bucket_grid.setAlignment(Qt.AlignLeft | Qt.AlignTop)
  225. # 按 code_x 分组纵向排列
  226. groups = defaultdict(list)
  227. for bucket in self.buckets:
  228. groups[bucket.code_x].append(bucket)
  229. sorted_keys = sorted(groups.keys())
  230. self.bucket_buttons = []
  231. col = 0
  232. for key in sorted_keys:
  233. buckets_in_group = groups[key]
  234. for row, bucket in enumerate(buckets_in_group):
  235. cell = QWidget()
  236. cell_layout = QVBoxLayout(cell)
  237. cell_layout.setContentsMargins(0, 0, 0, 0)
  238. cell_layout.setSpacing(2)
  239. btn = QToolButton()
  240. btn.setFixedSize(100, 100)
  241. btn.setToolButtonStyle(Qt.ToolButtonTextUnderIcon)
  242. btn.setText(bucket.description)
  243. btn.setIcon(self.get_bucket_icon(bucket.is_active))
  244. btn.setIconSize(QSize(64, 64))
  245. btn.setStyleSheet("""
  246. QToolButton {
  247. border-radius: 12px;
  248. font-size: 14px;
  249. font-weight: bold;
  250. }
  251. """)
  252. btn.clicked.connect(lambda checked, b=bucket: self.on_bucket_clicked(b))
  253. cell_layout.addWidget(btn, 0, Qt.AlignHCenter)
  254. # 底部状态条三等分:左摄像头信号 | 右摄像头信号 | 标定状态(与图标同宽)
  255. bar = QWidget()
  256. bar.setFixedSize(100, 8)
  257. bar_layout = QHBoxLayout(bar)
  258. bar_layout.setContentsMargins(0, 0, 0, 0)
  259. bar_layout.setSpacing(2)
  260. left_bar = QFrame()
  261. right_bar = QFrame()
  262. calib_bar = QFrame()
  263. for fb in (left_bar, right_bar, calib_bar):
  264. fb.setFixedHeight(8)
  265. fb.setStyleSheet("background-color: #dcdfe6; border-radius: 2px;")
  266. bar_layout.addWidget(left_bar, 1)
  267. bar_layout.addWidget(right_bar, 1)
  268. bar_layout.addWidget(calib_bar, 1)
  269. cell_layout.addWidget(bar, 0, Qt.AlignHCenter)
  270. self._bucket_signal_labels[bucket.id] = (left_bar, right_bar)
  271. self._calib_badge_labels[bucket.id] = calib_bar
  272. self.bucket_grid.addWidget(cell, row, col)
  273. self.bucket_buttons.append((btn, bucket))
  274. col += 1
  275. self.bucket_container.setLayout(self.bucket_grid)
  276. scroll_area.setWidget(self.bucket_container)
  277. bucket_layout.addWidget(scroll_area)
  278. main_layout.addWidget(bucket_group)
  279. # 视频区域(仅保留边框)
  280. video_container = QWidget()
  281. video_layout = QHBoxLayout(video_container)
  282. video_layout.setSpacing(15)
  283. video_layout.setContentsMargins(0, 0, 0, 0)
  284. self.left_label = QLabel("左侧画面")
  285. self.left_label.setScaledContents(False)
  286. self.left_label.setAlignment(Qt.AlignCenter)
  287. self.left_label.setStyleSheet("""
  288. QLabel {
  289. border: 2px solid #dcdfe6;
  290. border-radius: 8px;
  291. background-color: black;
  292. }
  293. """)
  294. self.right_label = QLabel("右侧画面")
  295. self.right_label.setScaledContents(False)
  296. self.right_label.setAlignment(Qt.AlignCenter)
  297. self.right_label.setStyleSheet("""
  298. QLabel {
  299. border: 2px solid #dcdfe6;
  300. border-radius: 8px;
  301. background-color: black;
  302. }
  303. """)
  304. video_layout.addWidget(self.left_label)
  305. video_layout.addWidget(self.right_label)
  306. main_layout.addWidget(video_container, 1)
  307. # 状态栏
  308. status_widget = QWidget()
  309. status_layout = QHBoxLayout(status_widget)
  310. status_layout.setContentsMargins(5, 5, 15, 5)
  311. self.module_status = QLabel("通讯状态: 初始化")
  312. self.module_status.setStyleSheet("color: #606266; font-weight: bold;")
  313. self.rfid_tag_label = QLabel("当前卡号: 无")
  314. self.rfid_tag_label.setStyleSheet("color: #409eff; font-weight: bold; margin-left: 20px;")
  315. self.hook_status_label = QLabel("挂钩状态: 空闲")
  316. self.hook_status_label.setStyleSheet("color: #ffa500; font-weight: bold; margin-left: 20px;")
  317. self.voice_checkbox = QCheckBox("语音播报")
  318. self.voice_checkbox.setChecked(True)
  319. self.voice_checkbox.setStyleSheet("color: #606266; font-weight: bold;")
  320. self.voice_checkbox.stateChanged.connect(self.on_voice_toggle)
  321. self.tts_checkbox = QCheckBox("TTS语音")
  322. self.tts_checkbox.setChecked(self._use_tts)
  323. self.tts_checkbox.setStyleSheet("color: #606266; font-weight: bold;")
  324. self.tts_checkbox.toggled.connect(self._on_tts_toggle)
  325. self.volume_btn = QPushButton(f"音量 {self._voice_volume}%")
  326. self.volume_btn.setFixedWidth(80)
  327. self.volume_btn.setStyleSheet("""
  328. QPushButton {
  329. color: #606266; font-weight: bold; font-size: 12px;
  330. border: 1px solid #dcdfe6; border-radius: 4px;
  331. padding: 2px 6px; background: transparent;
  332. }
  333. QPushButton:hover { border-color: #409eff; color: #409eff; }
  334. """)
  335. self.volume_btn.clicked.connect(self._show_volume_popup)
  336. self.ai_checkbox = QCheckBox("AI启用")
  337. self.ai_checkbox.setChecked(True)
  338. self.ai_checkbox.setStyleSheet("color: #606266; font-weight: bold;")
  339. self.ai_checkbox.stateChanged.connect(self.on_ai_toggle)
  340. self.auto_switch_btn = QCheckBox("自动模式")
  341. self.auto_switch_btn.setChecked(True)
  342. self.auto_switch_btn.setStyleSheet("color: #606266; font-weight: bold;")
  343. self.auto_switch_btn.clicked.connect(self.toggle_switch_mode)
  344. self.autostart_checkbox = QCheckBox("开机自启")
  345. self.autostart_checkbox.setChecked(self._read_autostart_flag())
  346. self.autostart_checkbox.setStyleSheet("color: #606266; font-weight: bold; margin-left: 10px;")
  347. self.autostart_checkbox.stateChanged.connect(self._on_autostart_toggle)
  348. status_layout.addWidget(self.module_status)
  349. status_layout.addWidget(self.rfid_tag_label)
  350. status_layout.addWidget(self.hook_status_label)
  351. status_layout.addStretch()
  352. status_layout.addWidget(self.voice_checkbox)
  353. status_layout.addWidget(self.tts_checkbox)
  354. status_layout.addWidget(self.volume_btn)
  355. status_layout.addWidget(self.ai_checkbox)
  356. status_layout.addWidget(self.auto_switch_btn)
  357. status_layout.addWidget(self.autostart_checkbox)
  358. self.ratio_btn = QPushButton("16:9" if self._ratio_16_9 else "4:3")
  359. self.ratio_btn.setStyleSheet("background:transparent; border:none; color:#606266; font-weight:bold; padding:4px 8px;")
  360. self.ratio_btn.setToolTip("切换画面比例 16:9 / 4:3")
  361. self.ratio_btn.clicked.connect(self._toggle_aspect_ratio)
  362. status_layout.addWidget(self.ratio_btn)
  363. self.calib_btn = QPushButton("标定")
  364. self.calib_btn.setFixedWidth(72)
  365. self.calib_btn.setStyleSheet("""
  366. QPushButton {
  367. color: #606266; font-weight: bold; font-size: 12px;
  368. border: 1px solid #dcdfe6; border-radius: 4px;
  369. padding: 2px 6px; background: transparent;
  370. }
  371. QPushButton:hover { border-color: #409eff; color: #409eff; }
  372. """)
  373. self.calib_btn.setToolTip("标定可以起吊的 hook/lug 偏移(当前工位,左右各采 1 秒取平均)")
  374. self.calib_btn.clicked.connect(self.on_calibrate_clicked)
  375. status_layout.addWidget(self.calib_btn)
  376. self.reset_calib_btn = QPushButton("重置标定")
  377. self.reset_calib_btn.setFixedWidth(80)
  378. self.reset_calib_btn.setStyleSheet("""
  379. QPushButton {
  380. color: #f56c6c; font-weight: bold; font-size: 12px;
  381. border: 1px solid #f56c6c; border-radius: 4px;
  382. padding: 2px 6px; background: transparent;
  383. }
  384. QPushButton:hover { background-color: rgba(245, 108, 108, 0.1); }
  385. """)
  386. self.reset_calib_btn.setToolTip("清除当前工位的标定数据,清除后该工位会在识别到 ready 时自动重新标定")
  387. self.reset_calib_btn.clicked.connect(self.on_reset_calibrate_clicked)
  388. status_layout.addWidget(self.reset_calib_btn)
  389. main_layout.addWidget(status_widget)
  390. self.update_video_size()
  391. # ---------- 事件与资源管理 ----------
  392. def closeEvent(self, event: QCloseEvent):
  393. self._cancel_rtsp_retry("left")
  394. self._cancel_rtsp_retry("right")
  395. if self.rfid_client:
  396. self.rfid_client.disconnect()
  397. if self.modbus_client:
  398. self.modbus_client.disconnect()
  399. if self.mqtt_client:
  400. self.mqtt_client.disconnect()
  401. if self.left_player:
  402. self.left_player.stop()
  403. if self.right_player:
  404. self.right_player.stop()
  405. if hasattr(self, 'auto_restart_scheduler'):
  406. self.auto_restart_scheduler.stop()
  407. event.accept()
  408. # ---------- 自动重启 ----------
  409. def _on_auto_restart_triggered(self):
  410. self.restart_trigger_signal.emit()
  411. def _on_auto_restart_impl(self):
  412. ar = self.config.auto_restart
  413. target_text = "重启电脑" if ar.target.value == "device" else "重启软件"
  414. dialog = QDialog(self)
  415. dialog.setWindowTitle("自动重启")
  416. dialog.setWindowFlag(Qt.WindowStaysOnTopHint, True)
  417. dialog.setModal(True)
  418. dialog.setMinimumWidth(300)
  419. layout = QVBoxLayout(dialog)
  420. label = QLabel(f"{ar.countdown} 秒后将自动{target_text}", dialog)
  421. label.setAlignment(Qt.AlignCenter)
  422. label.setStyleSheet("font-size: 14px; padding: 10px;")
  423. layout.addWidget(label)
  424. btn_layout = QHBoxLayout()
  425. btn_now = QPushButton("立即重启")
  426. btn_cancel = QPushButton("取消")
  427. btn_layout.addWidget(btn_now)
  428. btn_layout.addWidget(btn_cancel)
  429. layout.addLayout(btn_layout)
  430. remaining = ar.countdown
  431. def update_label():
  432. nonlocal remaining
  433. remaining -= 1
  434. if remaining <= 0:
  435. timer.stop()
  436. dialog.accept()
  437. else:
  438. label.setText(f"{remaining} 秒后将自动{target_text}")
  439. timer = QTimer(dialog)
  440. timer.timeout.connect(update_label)
  441. timer.start(1000)
  442. btn_now.clicked.connect(dialog.accept)
  443. btn_cancel.clicked.connect(dialog.reject)
  444. result = dialog.exec()
  445. timer.stop()
  446. if result == QDialog.Accepted:
  447. self.auto_restart_scheduler.perform_restart()
  448. else:
  449. logger.info("自动重启已取消")
  450. # ---------- 语音控制 ----------
  451. def on_voice_toggle(self, state):
  452. self.voice_enabled = (state == Qt.Checked)
  453. def _on_tts_toggle(self):
  454. self._use_tts = self.tts_checkbox.isChecked()
  455. self.config.ai.tts_enabled = self._use_tts
  456. try:
  457. self._save_config()
  458. except Exception as e:
  459. logger.warning(f"保存 TTS 设置失败: {e}")
  460. def _on_volume_changed(self, value: int):
  461. self._voice_volume = value
  462. self.volume_btn.setText(f"音量 {value}%")
  463. def _preview_volume(self, vol: int):
  464. self._voice_volume = vol
  465. self.volume_btn.setText(f"音量 {vol}%")
  466. if not hasattr(self, '_preview_timer'):
  467. self._preview_timer = QTimer()
  468. self._preview_timer.setSingleShot(True)
  469. self._preview_timer.timeout.connect(lambda: self._speak("挂包中"))
  470. self._preview_timer.start(300)
  471. def _show_volume_popup(self):
  472. from PySide6.QtWidgets import QDialog, QVBoxLayout
  473. from PySide6.QtCore import Qt
  474. dlg = QDialog(self)
  475. dlg.setWindowFlags(Qt.Popup | Qt.FramelessWindowHint)
  476. dlg.setStyleSheet("background: #2c2c2c; border: 1px solid #555; border-radius: 6px;")
  477. layout = QVBoxLayout(dlg)
  478. layout.setContentsMargins(12, 8, 12, 8)
  479. slider = QSlider(Qt.Horizontal)
  480. slider.setRange(0, 100)
  481. slider.setValue(self._voice_volume)
  482. slider.setFixedWidth(160)
  483. slider.setStyleSheet("""
  484. QSlider::groove:horizontal { height: 4px; background: #555; border-radius: 2px; }
  485. QSlider::handle:horizontal { width: 14px; margin: -5px 0; border-radius: 7px; background: #409eff; }
  486. QSlider::sub-page:horizontal { background: #409eff; border-radius: 2px; }
  487. """)
  488. slider.valueChanged.connect(self._preview_volume)
  489. layout.addWidget(slider)
  490. dlg.adjustSize()
  491. btn_global = self.volume_btn.mapToGlobal(self.volume_btn.rect().topLeft())
  492. dlg.move(btn_global.x(), btn_global.y() - dlg.height() - 4)
  493. dlg.exec()
  494. def on_ai_toggle(self, state):
  495. enabled = (state == Qt.Checked)
  496. if enabled == self.ai_enabled:
  497. return
  498. self.ai_enabled = enabled
  499. if enabled and not self.mqtt_client:
  500. self.mqtt_client = MQTTClient(self.on_mqtt_message)
  501. self._connect_current_ai()
  502. elif not enabled and self.mqtt_client:
  503. self.mqtt_client.disconnect()
  504. self.mqtt_client = None
  505. self._subscribed_topics.clear()
  506. self._current_ai_module_id = None
  507. if not enabled:
  508. self.hook_state = HookState.IDLE
  509. self.prev_state = HookState.IDLE
  510. self.state_counter = 0
  511. self.left_objects = []
  512. self.right_objects = []
  513. self.prev_left_hook = None
  514. self.prev_right_hook = None
  515. self.prev_left_lug = None
  516. self.prev_right_lug = None
  517. self._pair_traj = {"left": [], "right": []}
  518. self._voice_suppress_until = 0.0
  519. self._trace_phase = ""
  520. self.update_ui_for_state(HookState.IDLE)
  521. self._last_left_draw = []
  522. self._last_right_draw = []
  523. if self.left_player:
  524. self.left_player.set_ai_objects([])
  525. if self.right_player:
  526. self.right_player.set_ai_objects([])
  527. else:
  528. if self.left_player and self._last_left_draw:
  529. self.left_player.set_ai_objects(self._last_left_draw)
  530. if self.right_player and self._last_right_draw:
  531. self.right_player.set_ai_objects(self._last_right_draw)
  532. self._refresh_calib_ui()
  533. def _speak(self, msg: str):
  534. self.voice_signal.emit(msg)
  535. def _speak_impl(self, msg: str):
  536. try:
  537. self._voice_queue.put_nowait(msg)
  538. except queue.Full:
  539. pass
  540. @staticmethod
  541. def _get_voices_dir() -> str:
  542. if getattr(sys, 'frozen', False):
  543. base = sys._MEIPASS
  544. else:
  545. base = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
  546. return os.path.join(base, "assets", "voices")
  547. def _voice_msg_to_path(self, msg: str) -> str:
  548. mapping = {"挂包中": "hooking", "挂包成功": "ready", "放包中": "releasing",
  549. "读卡器离线": "offline_reader", "左侧摄像头离线": "offline_cam_left",
  550. "右侧摄像头离线": "offline_cam_right"}
  551. if msg in mapping:
  552. return os.path.join(self._voices_dir, f"{mapping[msg]}.wav")
  553. m = re.search(r'到达(\d+)号工位', msg)
  554. if m:
  555. return os.path.join(self._voices_dir, f"arrive_{m.group(1)}.wav")
  556. return ""
  557. def _voice_worker(self):
  558. speaker = None
  559. tts_engine = None
  560. if sys.platform == "win32":
  561. try:
  562. import pythoncom
  563. pythoncom.CoInitialize()
  564. except Exception:
  565. pass
  566. try:
  567. import win32com.client as _win32com
  568. speaker = _win32com.Dispatch("SAPI.SpVoice")
  569. speaker.Rate = 2
  570. except Exception:
  571. logging.warning("SAPI 不可用,仅可使用 WAV 语音")
  572. else:
  573. try:
  574. import pyttsx3
  575. tts_engine = pyttsx3.init()
  576. tts_engine.setProperty('rate', 150)
  577. except Exception:
  578. logging.warning("pyttsx3 不可用,将回退到 WAV 播放")
  579. while True:
  580. msg = self._voice_queue.get()
  581. if msg is None:
  582. break
  583. try:
  584. if self._use_tts and speaker is not None:
  585. speaker.Volume = self._voice_volume
  586. speaker.Speak(msg)
  587. elif self._use_tts and tts_engine is not None:
  588. tts_engine.setProperty('volume', self._voice_volume / 100.0)
  589. tts_engine.say(msg)
  590. tts_engine.runAndWait()
  591. else:
  592. self._play_wav(msg)
  593. except Exception as e:
  594. logging.warning(f"语音播报失败: {e}")
  595. self._voice_queue.task_done()
  596. def _play_wav(self, msg: str):
  597. path = self._voice_msg_to_path(msg)
  598. if not path:
  599. return
  600. if sys.platform == "win32":
  601. self._play_wav_windows(path)
  602. else:
  603. self._play_wav_linux(path)
  604. def _play_wav_windows(self, path: str):
  605. import struct, wave, io, winsound
  606. try:
  607. with wave.open(path, 'rb') as wf:
  608. params = wf.getparams()
  609. raw = bytearray(wf.readframes(wf.getnframes()))
  610. gain = self._voice_volume / 100.0
  611. if params.sampwidth == 2:
  612. fmt = '<' + 'h' * (len(raw) // 2)
  613. samples = list(struct.unpack_from(fmt, bytes(raw)))
  614. samples = [max(-32768, min(32767, int(s * gain))) for s in samples]
  615. raw = bytearray(struct.pack(fmt, *samples))
  616. elif params.sampwidth == 1:
  617. samples = list(raw)
  618. samples = [max(0, min(255, int((s - 128) * gain) + 128)) for s in samples]
  619. raw = bytearray(samples)
  620. buf = io.BytesIO()
  621. with wave.open(buf, 'wb') as wf:
  622. wf.setparams(params)
  623. wf.writeframes(bytes(raw))
  624. winsound.PlaySound(buf.getvalue(), winsound.SND_MEMORY)
  625. except Exception:
  626. winsound.PlaySound(path, winsound.SND_FILENAME | winsound.SND_ASYNC)
  627. def _play_wav_linux(self, path: str):
  628. import struct, wave, io, numpy as np
  629. try:
  630. with wave.open(path, 'rb') as wf:
  631. params = wf.getparams()
  632. raw = bytearray(wf.readframes(wf.getnframes()))
  633. gain = self._voice_volume / 100.0
  634. if params.sampwidth == 2:
  635. fmt = '<' + 'h' * (len(raw) // 2)
  636. samples = list(struct.unpack_from(fmt, bytes(raw)))
  637. samples = [max(-32768, min(32767, int(s * gain))) for s in samples]
  638. raw = bytearray(struct.pack(fmt, *samples))
  639. elif params.sampwidth == 1:
  640. samples = list(raw)
  641. samples = [max(0, min(255, int((s - 128) * gain) + 128)) for s in samples]
  642. raw = bytearray(samples)
  643. import sounddevice as sd
  644. if params.sampwidth == 2:
  645. dtype = np.int16
  646. elif params.sampwidth == 4:
  647. dtype = np.int32
  648. else:
  649. dtype = np.float32
  650. audio_data = np.frombuffer(bytes(raw), dtype=dtype)
  651. sd.play(audio_data, samplerate=params.framerate, blocking=True)
  652. except Exception as e:
  653. logging.warning(f"sounddevice 播放失败: {e},回退到 aplay")
  654. try:
  655. import subprocess
  656. subprocess.run(['aplay', '-q', path], timeout=10, check=False)
  657. except Exception:
  658. pass
  659. # ---------- 工位图标 ----------
  660. def get_bucket_icon(self, is_active):
  661. icon_dir = os.path.join(os.path.dirname(__file__), "..", "assets")
  662. if not os.path.exists(icon_dir):
  663. os.makedirs(icon_dir, exist_ok=True)
  664. return QIcon()
  665. if is_active:
  666. icon_path = os.path.join(icon_dir, "bucket_active.svg")
  667. else:
  668. icon_path = os.path.join(icon_dir, "bucket_normal.svg")
  669. if os.path.exists(icon_path):
  670. return QIcon(icon_path)
  671. else:
  672. pixmap = QPixmap(64, 64)
  673. pixmap.fill(Qt.green if is_active else Qt.gray)
  674. return QIcon(pixmap)
  675. def refresh_bucket_icons(self):
  676. for btn, bucket in self.bucket_buttons:
  677. icon = self.get_bucket_icon(bucket.is_active)
  678. btn.setIcon(icon)
  679. badge = self._calib_badge_labels.get(bucket.id)
  680. if badge is not None:
  681. if self._is_bucket_calibrated(bucket):
  682. badge.setStyleSheet("background-color: #67c23a; border-radius: 2px;")
  683. tip = "标定状态:已标定"
  684. for side, side_name in (("left", "左"), ("right", "右")):
  685. x = getattr(bucket, f"hook_lug_x_offset_ratio_{side}", None)
  686. y = getattr(bucket, f"hook_lug_y_offset_ratio_{side}", None)
  687. if x is not None and y is not None:
  688. tip += f"\n{side_name}侧 X={x:.3f} Y={y:.3f}"
  689. else:
  690. badge.setStyleSheet("background-color: #dcdfe6; border-radius: 2px;")
  691. tip = "标定状态:未标定"
  692. badge.setToolTip(tip)
  693. if bucket.is_active:
  694. btn.setStyleSheet("""
  695. QToolButton {
  696. background-color: #e1f3d8;
  697. border: 2px solid #67c23a;
  698. border-radius: 12px;
  699. font-size: 14px;
  700. font-weight: bold;
  701. color: #1f2d3d;
  702. }
  703. """)
  704. else:
  705. btn.setStyleSheet("""
  706. QToolButton {
  707. background-color: transparent;
  708. border: 2px solid #d0d7de;
  709. border-radius: 12px;
  710. font-size: 14px;
  711. font-weight: bold;
  712. color: #1f2d3d;
  713. }
  714. QToolButton:hover {
  715. background-color: rgba(0, 0, 0, 0.05);
  716. border-color: #409eff;
  717. }
  718. """)
  719. # ---------- 视频尺寸自适应 ----------
  720. def update_video_size(self):
  721. if not self.left_label or not self.right_label:
  722. return
  723. container = self.left_label.parentWidget()
  724. if not container:
  725. return
  726. available_width = container.width() - 15
  727. single_width = available_width // 2
  728. target_height = int(single_width / (16/9 if self._ratio_16_9 else 4/3))
  729. if target_height > container.height():
  730. target_height = container.height()
  731. self.left_label.setFixedSize(single_width, target_height)
  732. self.right_label.setFixedSize(single_width, target_height)
  733. def update_players_target_size(self):
  734. if self.left_player:
  735. self.left_player.set_target_size(self.left_label.width(), self.left_label.height())
  736. if self.right_player:
  737. self.right_player.set_target_size(self.right_label.width(), self.right_label.height())
  738. def resizeEvent(self, event: QResizeEvent):
  739. self.update_video_size()
  740. self.update_players_target_size()
  741. super().resizeEvent(event)
  742. def keyPressEvent(self, event: QKeyEvent):
  743. if event.key() == Qt.Key_Escape and self.isFullScreen():
  744. self.showNormal()
  745. elif event.key() == Qt.Key_F11:
  746. if self.isFullScreen():
  747. self.showNormal()
  748. else:
  749. self.showFullScreen()
  750. else:
  751. super().keyPressEvent(event)
  752. def _toggle_fullscreen(self):
  753. if self.isFullScreen():
  754. self.showNormal()
  755. else:
  756. self.showFullScreen()
  757. def _toggle_aspect_ratio(self):
  758. self._ratio_16_9 = not self._ratio_16_9
  759. self.ratio_btn.setText("16:9" if self._ratio_16_9 else "4:3")
  760. self.update_video_size()
  761. self.update_players_target_size()
  762. self._save_config()
  763. # ---------- 加载动画和超时 ----------
  764. def show_loading_on_label(self, label: QLabel):
  765. label.setText("加载中...")
  766. label.setAlignment(Qt.AlignCenter)
  767. label.setStyleSheet(label.styleSheet() + "color: white; font-size: 20px;")
  768. def hide_loading_on_label(self, label: QLabel):
  769. label.setText("")
  770. label.setStyleSheet("""
  771. QLabel {
  772. border: 2px solid #dcdfe6;
  773. border-radius: 8px;
  774. background-color: black;
  775. }
  776. """)
  777. def show_timeout_on_label(self, label: QLabel):
  778. label.setText("连接超时")
  779. label.setAlignment(Qt.AlignCenter)
  780. label.setStyleSheet(label.styleSheet() + "color: red; font-size: 20px;")
  781. def on_player_timeout(self, label: QLabel, player_ref, side: str):
  782. self.show_timeout_on_label(label)
  783. # 只停止当前活动的播放器(防止旧信号误杀新播放器)
  784. if side == "left" and self.left_player is player_ref:
  785. self.left_player.stop()
  786. self.left_player = None
  787. elif side == "right" and self.right_player is player_ref:
  788. self.right_player.stop()
  789. self.right_player = None
  790. self._start_rtsp_retry(side)
  791. def _cancel_rtsp_retry(self, side: str):
  792. timer = self._rtsp_retry_left if side == "left" else self._rtsp_retry_right
  793. if timer:
  794. timer.stop()
  795. timer.deleteLater()
  796. if side == "left":
  797. self._rtsp_retry_left = None
  798. self._rtsp_retry_delay_left = 1
  799. else:
  800. self._rtsp_retry_right = None
  801. self._rtsp_retry_delay_right = 1
  802. def _start_rtsp_retry(self, side: str):
  803. if not self.active_bucket:
  804. return
  805. cam = next((c for c in self.active_bucket.cameras if c.position == (CameraPosition.LEFT if side == "left" else CameraPosition.RIGHT)), None)
  806. if not cam:
  807. return
  808. if not self._is_camera_online(cam):
  809. return
  810. label = self.left_label if side == "left" else self.right_label
  811. delay = self._rtsp_retry_delay_left if side == "left" else self._rtsp_retry_delay_right
  812. label.setText(f"正在重连({delay}s)...")
  813. label.setAlignment(Qt.AlignCenter)
  814. label.setStyleSheet(label.styleSheet() + "color: orange; font-size: 20px;")
  815. timer = QTimer(self)
  816. timer.setSingleShot(True)
  817. timer.timeout.connect(lambda: self._retry_rtsp(side))
  818. timer.start(delay * 1000)
  819. if side == "left":
  820. self._rtsp_retry_left = timer
  821. else:
  822. self._rtsp_retry_right = timer
  823. def _retry_rtsp(self, side: str):
  824. if not self.active_bucket:
  825. return
  826. cam = next((c for c in self.active_bucket.cameras if c.position == (CameraPosition.LEFT if side == "left" else CameraPosition.RIGHT)), None)
  827. if not cam:
  828. return
  829. label = self.left_label if side == "left" else self.right_label
  830. if not self._is_camera_online(cam):
  831. self.show_timeout_on_label(label)
  832. self._cancel_rtsp_retry(side)
  833. return
  834. self.show_loading_on_label(label)
  835. w, h = label.width(), label.height()
  836. timeout = self.config.video.rtsp_timeout
  837. player = RTSPPlayer(cam.rtsp_url, w, h, timeout_sec=timeout)
  838. player.frame_ready.connect(label.setPixmap)
  839. player.first_frame_received.connect(lambda s=side: self._on_rtsp_retry_success(s))
  840. player.timeout.connect(lambda l=label, p=player, s=side: self.on_player_timeout(l, p, s))
  841. old = self.left_player if side == "left" else self.right_player
  842. if old:
  843. try:
  844. old.frame_ready.disconnect()
  845. except TypeError:
  846. pass
  847. try:
  848. old.first_frame_received.disconnect()
  849. except TypeError:
  850. pass
  851. try:
  852. old.timeout.disconnect()
  853. except TypeError:
  854. pass
  855. old.stop()
  856. if side == "left":
  857. self.left_player = player
  858. else:
  859. self.right_player = player
  860. player.start()
  861. def _on_rtsp_retry_success(self, side: str):
  862. self.hide_loading_on_label(self.left_label if side == "left" else self.right_label)
  863. self._cancel_rtsp_retry(side)
  864. # 重连成功后恢复 AI 对象标注
  865. if self.active_bucket and self.ai_enabled:
  866. objects = self._last_left_draw if side == "left" else self._last_right_draw
  867. player = self.left_player if side == "left" else self.right_player
  868. if player:
  869. player.set_ai_objects(objects)
  870. def _is_camera_online(self, cam) -> bool:
  871. m = re.search(r'rtsp://(?:[^@]+@)?([^:/]+)', cam.rtsp_url)
  872. if not m:
  873. return False
  874. lat = self._camera_signal.get(m.group(1))
  875. # 尚未检测过该 IP 时,默认认为在线,先尝试连接
  876. if lat is None:
  877. return True
  878. return lat >= 0
  879. def on_bucket_clicked(self, bucket: BucketModel):
  880. if self.switch_mode != SwitchMode.HAND:
  881. self.switch_mode = SwitchMode.HAND
  882. self.auto_switch_btn.setChecked(False)
  883. self.auto_switch_btn.setText("手动模式")
  884. self.activate_bucket(bucket)
  885. def on_rfid_state_changed(self, online: bool):
  886. self.rfid_state_signal.emit(online)
  887. def _on_rfid_state_impl(self, online: bool):
  888. if online:
  889. self.module_status.setText("通讯状态: RFID正常")
  890. else:
  891. self.module_status.setText("通讯状态: RFID离线")
  892. logger.warning("读卡器离线")
  893. if self.voice_enabled and getattr(self, '_started', False):
  894. self._speak("读卡器离线")
  895. # ---------- 硬件初始化 ----------
  896. def init_hardware(self):
  897. if self.config.module_type == ModuleType.RFID and self.config.rfid:
  898. # 收集所有配置的工位卡号,过滤掉读卡器同时读到的无关卡
  899. valid_codes = {b.code_x for b in self.buckets if b.code_x}
  900. self.rfid_client = RFIDClient(
  901. self.config.rfid,
  902. self.on_rfid_tag,
  903. on_state_changed=self.on_rfid_state_changed,
  904. tag_offset=18,
  905. tag_length=4,
  906. hex_output=True,
  907. valid_codes=valid_codes
  908. )
  909. if self.rfid_client.connect():
  910. self.module_status.setText("通讯状态: RFID正常")
  911. else:
  912. self.module_status.setText("通讯状态: RFID离线")
  913. if self.config.module_type == ModuleType.HD3200 and self.config.modbus:
  914. self.modbus_client = ModbusClient(self.config.modbus, self.on_modbus_data)
  915. if self.modbus_client.connect():
  916. self.module_status.setText("通讯状态: Modbus正常")
  917. else:
  918. self.module_status.setText("通讯状态: Modbus离线")
  919. if self.config.ai.enabled:
  920. self.mqtt_client = MQTTClient(self.on_mqtt_message)
  921. self._connect_current_ai()
  922. def _connect_current_ai(self, bucket=None):
  923. if bucket is None:
  924. bucket = self.active_bucket
  925. idx = None
  926. if bucket and bucket.ai_module_id is not None:
  927. idx = bucket.ai_module_id
  928. elif self.config.ai_modules:
  929. idx = 0
  930. if idx is not None and idx < len(self.config.ai_modules):
  931. target = self.config.ai_modules[idx]
  932. self.mqtt_client.disconnect()
  933. self.mqtt_client.connect(target.broker, target.port)
  934. self._current_ai_module_id = idx
  935. if bucket:
  936. topics = [c.topic for c in bucket.cameras if c.topic]
  937. if topics:
  938. self.mqtt_client.subscribe(topics)
  939. self._subscribed_topics = set(topics)
  940. # ---------- 激活工位 ----------
  941. def activate_bucket(self, bucket: BucketModel):
  942. if self.active_bucket == bucket:
  943. return
  944. self._cancel_rtsp_retry("left")
  945. self._cancel_rtsp_retry("right")
  946. if bucket and self.voice_enabled:
  947. self._speak(f"到达{bucket.description}")
  948. for player in (self.left_player, self.right_player):
  949. if player is None:
  950. continue
  951. try:
  952. player.frame_ready.disconnect()
  953. except TypeError:
  954. pass
  955. try:
  956. player.first_frame_received.disconnect()
  957. except TypeError:
  958. pass
  959. try:
  960. player.timeout.disconnect()
  961. except TypeError:
  962. pass
  963. player.stop()
  964. self.left_player = None
  965. self.right_player = None
  966. if self.active_bucket:
  967. self.active_bucket.is_active = False
  968. self.active_bucket = bucket
  969. if bucket:
  970. bucket.is_active = True
  971. self.refresh_bucket_icons()
  972. self._refresh_calib_ui()
  973. self.show_loading_on_label(self.left_label)
  974. self.show_loading_on_label(self.right_label)
  975. # 重置挂钩状态
  976. self.hook_state = HookState.IDLE
  977. self.prev_state = HookState.IDLE
  978. self.state_counter = 0
  979. self.left_objects = []
  980. self.right_objects = []
  981. self.prev_left_hook = None
  982. self.prev_right_hook = None
  983. self.prev_left_lug = None
  984. self.prev_right_lug = None
  985. self.last_voice_state = None
  986. self._last_hooking_voice_time = 0.0
  987. self._pair_traj = {"left": [], "right": []}
  988. self._voice_suppress_until = 0.0
  989. self._trace_phase = ""
  990. self._side_ready = {"left": False, "right": False}
  991. self._auto_calib_state = {
  992. side: {"ready_times": deque(), "samples": deque()}
  993. for side in ("left", "right")
  994. }
  995. self.update_ui_for_state(HookState.IDLE)
  996. self._last_left_draw = []
  997. self._last_right_draw = []
  998. # 先切 AI 模块 + 订阅 MQTT topic(非阻塞),再启 RTSP
  999. if bucket and self.config.ai.enabled and self.mqtt_client:
  1000. if bucket.ai_module_id != self._current_ai_module_id:
  1001. self._unsubscribe_all_topics()
  1002. self._connect_current_ai(bucket)
  1003. if bucket:
  1004. if self.mqtt_client:
  1005. topics = [c.topic for c in bucket.cameras if c.topic]
  1006. stale = self._subscribed_topics - set(topics)
  1007. for t in stale:
  1008. self.mqtt_client.unsubscribe(t)
  1009. new_t = [t for t in topics if t not in self._subscribed_topics]
  1010. if new_t:
  1011. self.mqtt_client.subscribe(new_t)
  1012. self._subscribed_topics = set(topics)
  1013. # 再启 RTSP
  1014. left_cam = next((c for c in bucket.cameras if c.position == CameraPosition.LEFT), None)
  1015. right_cam = next((c for c in bucket.cameras if c.position == CameraPosition.RIGHT), None)
  1016. if left_cam and self._is_camera_online(left_cam):
  1017. w = self.left_label.width()
  1018. h = self.left_label.height()
  1019. timeout = self.config.video.rtsp_timeout
  1020. player = RTSPPlayer(left_cam.rtsp_url, w, h, timeout_sec=timeout)
  1021. player.frame_ready.connect(self.left_label.setPixmap)
  1022. player.first_frame_received.connect(lambda p=player: self.hide_loading_on_label(self.left_label))
  1023. player.timeout.connect(lambda p=player: self.on_player_timeout(self.left_label, p, "left"))
  1024. self.left_player = player
  1025. player.start()
  1026. elif left_cam:
  1027. self.show_timeout_on_label(self.left_label)
  1028. if self.voice_enabled:
  1029. self._speak("左侧摄像头离线")
  1030. if right_cam and self._is_camera_online(right_cam):
  1031. w = self.right_label.width()
  1032. h = self.right_label.height()
  1033. timeout = self.config.video.rtsp_timeout
  1034. player = RTSPPlayer(right_cam.rtsp_url, w, h, timeout_sec=timeout)
  1035. player.frame_ready.connect(self.right_label.setPixmap)
  1036. player.first_frame_received.connect(lambda p=player: self.hide_loading_on_label(self.right_label))
  1037. player.timeout.connect(lambda p=player: self.on_player_timeout(self.right_label, p, "right"))
  1038. self.right_player = player
  1039. player.start()
  1040. elif right_cam:
  1041. self.show_timeout_on_label(self.right_label)
  1042. if self.voice_enabled:
  1043. self._speak("右侧摄像头离线")
  1044. else:
  1045. self._unsubscribe_all_topics()
  1046. self.left_label.clear()
  1047. self.right_label.clear()
  1048. QApplication.processEvents()
  1049. self.left_label.clear()
  1050. self.right_label.clear()
  1051. def _unsubscribe_all_topics(self):
  1052. if self.mqtt_client and self._subscribed_topics:
  1053. for t in self._subscribed_topics:
  1054. self.mqtt_client.unsubscribe(t)
  1055. self._subscribed_topics.clear()
  1056. # ---------- RFID 标签回调 ----------
  1057. def on_rfid_tag(self, tag: str):
  1058. self.rfid_tag_signal.emit(tag)
  1059. def _on_rfid_tag_impl(self, tag: str):
  1060. self._last_rfid_tag = tag
  1061. if tag:
  1062. self.rfid_tag_label.setText(f"当前卡号: {tag}")
  1063. else:
  1064. self.rfid_tag_label.setText("当前卡号: 无")
  1065. if tag is None:
  1066. if self.switch_mode == SwitchMode.AUTO and self.active_bucket:
  1067. self.activate_bucket(None)
  1068. return
  1069. for bucket in self.buckets:
  1070. if bucket.code_x == tag:
  1071. if self.switch_mode == SwitchMode.AUTO:
  1072. self.activate_bucket(bucket)
  1073. break
  1074. # ---------- Modbus 数据回调 ----------
  1075. def on_modbus_data(self, bools):
  1076. self.modbus_data_signal.emit(bools)
  1077. def _on_modbus_data_impl(self, bools):
  1078. for bucket in self.buckets:
  1079. try:
  1080. addr_x = int(bucket.code_x)
  1081. except (ValueError, TypeError):
  1082. continue
  1083. if addr_x < 0 or addr_x >= len(bools):
  1084. continue
  1085. if bucket.code_y and bucket.code_y.strip():
  1086. try:
  1087. addr_y = int(bucket.code_y)
  1088. except (ValueError, TypeError):
  1089. continue
  1090. if addr_y < 0 or addr_y >= len(bools):
  1091. continue
  1092. if bools[addr_x] and bools[addr_y]:
  1093. if self.switch_mode == SwitchMode.AUTO and self.active_bucket != bucket:
  1094. self.activate_bucket(bucket)
  1095. break
  1096. else:
  1097. if bools[addr_x]:
  1098. if self.switch_mode == SwitchMode.AUTO and self.active_bucket != bucket:
  1099. self.activate_bucket(bucket)
  1100. break
  1101. # ---------- MQTT 消息处理 ----------
  1102. def parse_ai_objects(self, payload: dict, display_width: int, display_height: int,
  1103. include_ready: bool = True) -> list:
  1104. if payload is None or not isinstance(payload, dict):
  1105. return []
  1106. orig_width = payload.get("width", 640)
  1107. orig_height = payload.get("height", 360)
  1108. scale_x = display_width / orig_width if orig_width > 0 else 1.0
  1109. scale_y = display_height / orig_height if orig_height > 0 else 1.0
  1110. objects = []
  1111. for obj_str in payload.get("objects", []):
  1112. parts = obj_str.split('|')
  1113. if len(parts) >= 6:
  1114. try:
  1115. x = int(float(parts[1]) * scale_x)
  1116. y = int(float(parts[2]) * scale_y)
  1117. w = int(float(parts[3]) * scale_x)
  1118. h = int(float(parts[4]) * scale_y)
  1119. raw_label = parts[5]
  1120. label = raw_label
  1121. if 'hoist' in raw_label:
  1122. label = 'hook'
  1123. elif 'cup' in raw_label:
  1124. label = 'lug'
  1125. if 'hook' in label:
  1126. color = (0, 0, 255)
  1127. elif 'lug' in label:
  1128. color = (255, 0, 0)
  1129. elif 'ready' in label:
  1130. if not include_ready:
  1131. continue
  1132. color = (0, 255, 255)
  1133. else:
  1134. color = (128, 128, 128)
  1135. objects.append({
  1136. "track_id": parts[0],
  1137. "x": x,
  1138. "y": y,
  1139. "w": w,
  1140. "h": h,
  1141. "label": label,
  1142. "raw_label": raw_label,
  1143. "color": color
  1144. })
  1145. except (ValueError, IndexError):
  1146. continue
  1147. return objects
  1148. def on_mqtt_message(self, topic: str, payload: dict):
  1149. self.mqtt_message_signal.emit(topic, payload)
  1150. def _on_mqtt_message_impl(self, topic: str, payload: dict):
  1151. if payload is None or not isinstance(payload, dict):
  1152. return
  1153. if not self.active_bucket:
  1154. return
  1155. if not self.ai_enabled:
  1156. return
  1157. now = time.time()
  1158. if now - self._last_mqtt_process < 0.03:
  1159. return
  1160. self._last_mqtt_process = now
  1161. self._overlay_cleared = False
  1162. for cam in self.active_bucket.cameras:
  1163. if cam.topic == topic:
  1164. self.update_video_size()
  1165. if cam.position == CameraPosition.LEFT:
  1166. display_width = self.left_label.width()
  1167. display_height = self.left_label.height()
  1168. else:
  1169. display_width = self.right_label.width()
  1170. display_height = self.right_label.height()
  1171. if display_width == 0 or display_height == 0:
  1172. display_width = 640
  1173. display_height = 360
  1174. all_objects = self.parse_ai_objects(payload, display_width, display_height, include_ready=True)
  1175. # ready 标签仅用于自动标定触发,不进检测对象/不参与状态判定
  1176. side = "left" if cam.position == CameraPosition.LEFT else "right"
  1177. self._side_ready[side] = any('ready' in o['label'].lower() for o in all_objects)
  1178. objects = [o for o in all_objects if 'ready' not in o['label'].lower()]
  1179. if cam.position == CameraPosition.LEFT:
  1180. self.left_objects = objects
  1181. else:
  1182. self.right_objects = objects
  1183. self.evaluate_hook_state()
  1184. if self._calib_active:
  1185. self._sample_calibration()
  1186. self._update_auto_calibration(side, self._side_ready[side])
  1187. draw_objects = []
  1188. for obj in all_objects:
  1189. if 'ready' in obj['label'].lower():
  1190. continue
  1191. obj['alpha'] = 0.4
  1192. draw_objects.append(obj)
  1193. draw_objects.extend(self._trace_objects(side))
  1194. status = self._trace_status_text()
  1195. if status:
  1196. color = (0, 0, 255) if self._trace_phase else (0, 200, 255)
  1197. draw_objects.append({"text": status, "pos": (10, 20), "anchor": "tr", "color": color})
  1198. if cam.position == CameraPosition.LEFT:
  1199. self._last_left_draw = draw_objects
  1200. if self.left_player:
  1201. self.left_player.set_ai_objects(draw_objects)
  1202. else:
  1203. self._last_right_draw = draw_objects
  1204. if self.right_player:
  1205. self.right_player.set_ai_objects(draw_objects)
  1206. break
  1207. # ---------- 挂钩状态评估 ----------
  1208. def _nearest_pair(self, hooks, lugs):
  1209. """在 hook/lug 列表中选中心距最近的配对"""
  1210. if not hooks or not lugs:
  1211. return None
  1212. best_pair = None
  1213. best_dist = float('inf')
  1214. for h in hooks:
  1215. hx = h['x'] + h['w'] / 2
  1216. hy = h['y'] + h['h'] / 2
  1217. for l in lugs:
  1218. lx = l['x'] + l['w'] / 2
  1219. ly = l['y'] + l['h'] / 2
  1220. dist = (hx - lx) ** 2 + (hy - ly) ** 2
  1221. if dist < best_dist:
  1222. best_dist = dist
  1223. best_pair = (h, l)
  1224. return best_pair
  1225. def _best_intersecting_pair(self, side: str):
  1226. """该侧最近且框相交的 hook/lug 配对;无则返回 None"""
  1227. if side == "left":
  1228. objects = self.left_objects
  1229. else:
  1230. objects = self.right_objects
  1231. hooks = [o for o in objects if 'hook' in o['label'].lower()]
  1232. lugs = [o for o in objects if 'lug' in o['label'].lower()]
  1233. pairs = []
  1234. for h in hooks:
  1235. hx = h['x'] + h['w'] / 2
  1236. hy = h['y'] + h['h'] / 2
  1237. for l in lugs:
  1238. lx = l['x'] + l['w'] / 2
  1239. ly = l['y'] + l['h'] / 2
  1240. pairs.append(((hx - lx) ** 2 + (hy - ly) ** 2, h, l))
  1241. pairs.sort(key=lambda t: t[0])
  1242. for _, h, l in pairs:
  1243. if self.rects_intersect(h, l):
  1244. return (h, l)
  1245. return None
  1246. def _compute_offset_ratios(self, side: str):
  1247. """该侧最近相交配对的归一化偏移 (xr, yr);无有效配对返回 None"""
  1248. pair = self._best_intersecting_pair(side)
  1249. if not pair:
  1250. return None
  1251. h, l = pair
  1252. if self._lug_touches_top(l):
  1253. return None
  1254. lw, lh = l['w'], l['h']
  1255. if lw <= 0 or lh <= 0:
  1256. return None
  1257. xr = (h['x'] + h['w'] / 2 - (l['x'] + l['w'] / 2)) / lw
  1258. yr = ((h['y'] + h['h']) - (l['y'] + l['h'])) / lh
  1259. return (xr, yr)
  1260. # ---------- 标定(可以起吊偏移) ----------
  1261. @staticmethod
  1262. def _is_bucket_calibrated(bucket) -> bool:
  1263. for side in ("left", "right"):
  1264. x = getattr(bucket, f"hook_lug_x_offset_ratio_{side}", None)
  1265. y = getattr(bucket, f"hook_lug_y_offset_ratio_{side}", None)
  1266. if x is not None and y is not None:
  1267. return True
  1268. return False
  1269. def on_calibrate_clicked(self):
  1270. if self._calib_active:
  1271. return
  1272. bucket = self.active_bucket
  1273. if not bucket:
  1274. QMessageBox.information(self, "标定", "请先选择一个工位")
  1275. return
  1276. if not self.ai_enabled:
  1277. QMessageBox.information(self, "标定", "请先启用 AI")
  1278. return
  1279. if self._is_bucket_calibrated(bucket):
  1280. ret = QMessageBox.question(
  1281. self, "标定",
  1282. f"{bucket.description} 已标定,要重新标定吗?",
  1283. QMessageBox.Yes | QMessageBox.No | QMessageBox.Cancel,
  1284. QMessageBox.No,
  1285. )
  1286. if ret == QMessageBox.Cancel:
  1287. return
  1288. if ret == QMessageBox.No:
  1289. self._reset_calibration()
  1290. return
  1291. self._start_calibration()
  1292. def _start_calibration(self):
  1293. self._calib_active = True
  1294. self._calib_until = time.time() + self.CALIB_DURATION_SECS
  1295. self._calib_samples = {}
  1296. self._calib_warned.clear()
  1297. self.calib_btn.setEnabled(False)
  1298. self.calib_btn.setText("标定中...")
  1299. def _sample_calibration(self):
  1300. now = time.time()
  1301. if now >= self._calib_until:
  1302. self._finish_calibration()
  1303. return
  1304. for side in ("left", "right"):
  1305. ratios = self._compute_offset_ratios(side)
  1306. if ratios is None:
  1307. continue
  1308. buf = self._calib_samples.setdefault(side, [0.0, 0.0, 0])
  1309. buf[0] += ratios[0]
  1310. buf[1] += ratios[1]
  1311. buf[2] += 1
  1312. def _finish_calibration(self):
  1313. self._calib_active = False
  1314. if self.calib_btn:
  1315. self.calib_btn.setEnabled(True)
  1316. bucket = self.active_bucket
  1317. if not bucket:
  1318. self._refresh_calib_ui()
  1319. return
  1320. sides = {"left": None, "right": None}
  1321. for side in ("left", "right"):
  1322. buf = self._calib_samples.get(side)
  1323. if buf and buf[2] > 0:
  1324. xr = buf[0] / buf[2]
  1325. yr = buf[1] / buf[2]
  1326. setattr(bucket, f"hook_lug_x_offset_ratio_{side}", xr)
  1327. setattr(bucket, f"hook_lug_y_offset_ratio_{side}", yr)
  1328. sides[side] = (xr, yr)
  1329. self._save_config()
  1330. self.refresh_bucket_icons()
  1331. self._refresh_calib_ui()
  1332. lines = [f"{bucket.description} 标定完成"]
  1333. for side in ("left", "right"):
  1334. v = sides[side]
  1335. if v is None:
  1336. lines.append(f" {side}侧: 未采到有效配对(未标定)")
  1337. else:
  1338. lines.append(f" {side}侧: x={v[0]:.3f} y={v[1]:.3f}")
  1339. QMessageBox.information(self, "标定", "\n".join(lines))
  1340. def _reset_calibration(self):
  1341. bucket = self.active_bucket
  1342. if not bucket:
  1343. return
  1344. for side in ("left", "right"):
  1345. setattr(bucket, f"hook_lug_x_offset_ratio_{side}", None)
  1346. setattr(bucket, f"hook_lug_y_offset_ratio_{side}", None)
  1347. self._save_config()
  1348. self.refresh_bucket_icons()
  1349. self._refresh_calib_ui()
  1350. # ---------- 自动标定(连续 ready) ----------
  1351. def on_reset_calibrate_clicked(self):
  1352. bucket = self.active_bucket
  1353. if not bucket:
  1354. return
  1355. if not self._is_bucket_calibrated(bucket):
  1356. QMessageBox.information(self, "重置标定", "当前工位尚未标定,无需重置")
  1357. return
  1358. ret = QMessageBox.question(
  1359. self, "重置标定",
  1360. f"确认清除 {bucket.description} 的标定数据?\n清除后该工位会在识别到 ready 时自动重新标定。",
  1361. QMessageBox.Yes | QMessageBox.No,
  1362. QMessageBox.No,
  1363. )
  1364. if ret != QMessageBox.Yes:
  1365. return
  1366. for side in ("left", "right"):
  1367. setattr(bucket, f"hook_lug_x_offset_ratio_{side}", None)
  1368. setattr(bucket, f"hook_lug_y_offset_ratio_{side}", None)
  1369. self._save_config()
  1370. self.refresh_bucket_icons()
  1371. self._refresh_calib_ui()
  1372. logger.info(f"标定已重置: {bucket.description}")
  1373. def _update_auto_calibration(self, side: str, ready: bool):
  1374. """该侧窗口内 ready 帧数达标后采样取平均,自动写入未标定侧的标定值。
  1375. 已标定侧不覆盖;切换到未标定工位后识别到 ready 即自动标定,无需手动操作。
  1376. DeepStream 跳帧不可靠,采用窗口计数而非连续帧:
  1377. 1 秒(auto_calib_duration_secs)内出现 auto_calib_ready_frames 帧 ready 即触发;
  1378. 非 ready 消息不清零,天然容忍跳帧与偶发闪烁。
  1379. """
  1380. if self._calib_active:
  1381. return
  1382. bucket = self.active_bucket
  1383. if not bucket:
  1384. return
  1385. has_cam = any(c.position.value == side for c in bucket.cameras)
  1386. if not has_cam:
  1387. return
  1388. x_attr = f"hook_lug_x_offset_ratio_{side}"
  1389. y_attr = f"hook_lug_y_offset_ratio_{side}"
  1390. if getattr(bucket, x_attr) is not None and getattr(bucket, y_attr) is not None:
  1391. return
  1392. if not ready:
  1393. return
  1394. st = self._auto_calib_state[side]
  1395. now = time.time()
  1396. window = self.config.ai.auto_calib_duration_secs
  1397. st["ready_times"].append(now)
  1398. while st["ready_times"] and now - st["ready_times"][0] > window:
  1399. st["ready_times"].popleft()
  1400. ratios = self._compute_offset_ratios(side)
  1401. if ratios is not None:
  1402. st["samples"].append((now, ratios[0], ratios[1]))
  1403. while st["samples"] and now - st["samples"][0][0] > window:
  1404. st["samples"].popleft()
  1405. if len(st["ready_times"]) < self.config.ai.auto_calib_ready_frames:
  1406. return
  1407. if len(st["samples"]) < self.config.ai.auto_calib_min_samples:
  1408. return
  1409. x = sum(s[1] for s in st["samples"]) / len(st["samples"])
  1410. y = sum(s[2] for s in st["samples"]) / len(st["samples"])
  1411. setattr(bucket, x_attr, x)
  1412. setattr(bucket, y_attr, y)
  1413. st["ready_times"].clear()
  1414. st["samples"].clear()
  1415. self._save_config()
  1416. self.refresh_bucket_icons()
  1417. self._refresh_calib_ui()
  1418. logger.info(f"自动标定完成 {side}侧: x={x:.3f}, y={y:.3f} (工位{bucket.id})")
  1419. def _refresh_calib_ui(self):
  1420. if not self.calib_btn:
  1421. return
  1422. bucket = self.active_bucket
  1423. if not bucket or not self.ai_enabled:
  1424. self.calib_btn.setEnabled(False)
  1425. self.calib_btn.setText("标定")
  1426. self.calib_btn.setToolTip("标定可以起吊的 hook/lug 偏移(当前工位)")
  1427. if self.reset_calib_btn:
  1428. self.reset_calib_btn.setEnabled(False)
  1429. return
  1430. self.calib_btn.setEnabled(True)
  1431. if self.reset_calib_btn:
  1432. self.reset_calib_btn.setEnabled(self._is_bucket_calibrated(bucket))
  1433. parts = []
  1434. for side in ("left", "right"):
  1435. x = getattr(bucket, f"hook_lug_x_offset_ratio_{side}", None)
  1436. y = getattr(bucket, f"hook_lug_y_offset_ratio_{side}", None)
  1437. if x is not None and y is not None:
  1438. parts.append(f"{side}侧 已标定 (x={x:.3f}, y={y:.3f})")
  1439. else:
  1440. parts.append(f"{side}侧 未标定")
  1441. self.calib_btn.setToolTip(bucket.description + "\n" + "\n".join(parts))
  1442. if self._is_bucket_calibrated(bucket):
  1443. self.calib_btn.setText("已标定")
  1444. self.calib_btn.setStyleSheet("""
  1445. QPushButton {
  1446. color: #67c23a; font-weight: bold; font-size: 12px;
  1447. border: 1px solid #67c23a; border-radius: 4px;
  1448. padding: 2px 6px; background: transparent;
  1449. }
  1450. """)
  1451. else:
  1452. self.calib_btn.setText("标定")
  1453. self.calib_btn.setStyleSheet("""
  1454. QPushButton {
  1455. color: #606266; font-weight: bold; font-size: 12px;
  1456. border: 1px solid #dcdfe6; border-radius: 4px;
  1457. padding: 2px 6px; background: transparent;
  1458. }
  1459. QPushButton:hover { border-color: #409eff; color: #409eff; }
  1460. """)
  1461. def evaluate_hook_state(self):
  1462. left_hooks = [o for o in self.left_objects if 'hook' in o['label'].lower()]
  1463. left_lugs = [o for o in self.left_objects if 'lug' in o['label'].lower()]
  1464. right_hooks = [o for o in self.right_objects if 'hook' in o['label'].lower()]
  1465. right_lugs = [o for o in self.right_objects if 'lug' in o['label'].lower()]
  1466. left_pair = self._nearest_pair(left_hooks, left_lugs)
  1467. right_pair = self._nearest_pair(right_hooks, right_lugs)
  1468. self._push_pair_traj("left", left_pair)
  1469. self._push_pair_traj("right", right_pair)
  1470. self._update_suppress_window()
  1471. # 吊耳贴着画面上沿(框顶被裁):不再判定状态,状态框不显示
  1472. if (left_pair and self._lug_touches_top(left_pair[1])) or \
  1473. (right_pair and self._lug_touches_top(right_pair[1])):
  1474. self.hook_state = HookState.IDLE
  1475. self.prev_state = HookState.IDLE
  1476. self.state_counter = 0
  1477. self._clear_state_display()
  1478. return
  1479. is_releasing = False
  1480. if left_pair:
  1481. h, l = left_pair
  1482. if self.prev_left_hook and self.prev_left_lug:
  1483. dy_h = (h['y'] + h['h'] / 2) - self.prev_left_hook[1]
  1484. dy_l = (l['y'] + l['h'] / 2) - self.prev_left_lug[1]
  1485. if dy_h > self.RELEASE_DELTA_THRESHOLD and dy_l > self.RELEASE_DELTA_THRESHOLD:
  1486. is_releasing = True
  1487. self.prev_left_hook = (h['x'] + h['w'] / 2, h['y'] + h['h'] / 2)
  1488. self.prev_left_lug = (l['x'] + l['w'] / 2, l['y'] + l['h'] / 2)
  1489. else:
  1490. self.prev_left_hook = None
  1491. self.prev_left_lug = None
  1492. if right_pair:
  1493. h, l = right_pair
  1494. if self.prev_right_hook and self.prev_right_lug:
  1495. dy_h = (h['y'] + h['h'] / 2) - self.prev_right_hook[1]
  1496. dy_l = (l['y'] + l['h'] / 2) - self.prev_right_lug[1]
  1497. if dy_h > self.RELEASE_DELTA_THRESHOLD and dy_l > self.RELEASE_DELTA_THRESHOLD:
  1498. is_releasing = True
  1499. self.prev_right_hook = (h['x'] + h['w'] / 2, h['y'] + h['h'] / 2)
  1500. self.prev_right_lug = (l['x'] + l['w'] / 2, l['y'] + l['h'] / 2)
  1501. else:
  1502. self.prev_right_hook = None
  1503. self.prev_right_lug = None
  1504. if is_releasing:
  1505. new_state = HookState.RELEASING
  1506. else:
  1507. # 可以起吊:hook/lug 双轴归一化偏移落在标定容差内且框相交(语义≠挂牢)
  1508. can_lift = False
  1509. if self.active_bucket:
  1510. can_lift = (self._side_match("left", left_pair)
  1511. or self._side_match("right", right_pair))
  1512. if can_lift:
  1513. new_state = HookState.READY
  1514. elif self._has_intersecting_pair(left_pair, right_pair):
  1515. new_state = HookState.HOOKING
  1516. else:
  1517. new_state = HookState.IDLE
  1518. # 防抖:进出 READY 对称用 ready_debounce(防单帧丢配对闪红绿),
  1519. # 非 READY → IDLE 即时,其余过渡用 STATE_THRESHOLD
  1520. if new_state == HookState.IDLE and self.hook_state != HookState.READY:
  1521. threshold = 1
  1522. elif new_state == HookState.READY or self.hook_state == HookState.READY:
  1523. threshold = self.config.ai.ready_debounce
  1524. else:
  1525. threshold = self.STATE_THRESHOLD
  1526. if new_state == self.prev_state:
  1527. self.state_counter += 1
  1528. else:
  1529. self.prev_state = new_state
  1530. self.state_counter = 1
  1531. if self.state_counter >= threshold:
  1532. if new_state != self.hook_state:
  1533. self.hook_state = new_state
  1534. self.update_ui_for_state(new_state)
  1535. self.state_counter = 0
  1536. for obj in self.left_objects + self.right_objects:
  1537. obj.pop('paired', None)
  1538. if left_pair:
  1539. left_pair[0]['paired'] = True
  1540. left_pair[1]['paired'] = True
  1541. if right_pair:
  1542. right_pair[0]['paired'] = True
  1543. right_pair[1]['paired'] = True
  1544. def rects_intersect(self, a, b):
  1545. ax1, ay1, ax2, ay2 = a['x'], a['y'], a['x']+a['w'], a['y']+a['h']
  1546. bx1, by1, bx2, by2 = b['x'], b['y'], b['x']+b['w'], b['y']+b['h']
  1547. return not (ax2 < bx1 or bx2 < ax1 or ay2 < by1 or by2 < ay1)
  1548. @staticmethod
  1549. def _lug_touches_top(l) -> bool:
  1550. """吊耳检测框是否贴着画面上沿(框顶被画面裁切)"""
  1551. return l['y'] <= 0
  1552. def _has_intersecting_pair(self, left_pair, right_pair):
  1553. if left_pair:
  1554. h, l = left_pair
  1555. if self.rects_intersect(h, l):
  1556. return True
  1557. if right_pair:
  1558. h, l = right_pair
  1559. if self.rects_intersect(h, l):
  1560. return True
  1561. return False
  1562. def _side_match(self, side: str, pair) -> bool:
  1563. """该侧是否满足"可以起吊":hook/lug 框相交,且双轴归一化偏移都在标定容差内。
  1564. X 用中心点、Y 用框底边:升到一定高度后检测框顶部会被裁掉,中心 Y 不可靠,底边贴近吊耳坐实位置不受裁切影响。"""
  1565. bucket = self.active_bucket
  1566. if not bucket or not pair:
  1567. return False
  1568. h, l = pair
  1569. if not self.rects_intersect(h, l):
  1570. return False
  1571. if self._lug_touches_top(l):
  1572. return False
  1573. lw, lh = l['w'], l['h']
  1574. if lw <= 0 or lh <= 0:
  1575. return False
  1576. xr = (h['x'] + h['w'] / 2 - (l['x'] + l['w'] / 2)) / lw
  1577. yr = ((h['y'] + h['h']) - (l['y'] + l['h'])) / lh
  1578. cal_x = getattr(bucket, f"hook_lug_x_offset_ratio_{side}", None)
  1579. cal_y = getattr(bucket, f"hook_lug_y_offset_ratio_{side}", None)
  1580. if cal_x is None or cal_y is None:
  1581. if side not in self._calib_warned:
  1582. self._calib_warned.add(side)
  1583. logger.warning(f"工位 {bucket.id} 未标定 {side} 侧偏移,跳过可以起吊判定")
  1584. return False
  1585. tol = self.config.ai.offset_ratio_tolerance
  1586. return abs(xr - cal_x) <= tol and abs(yr - cal_y) <= tol
  1587. def _push_pair_traj(self, side: str, pair):
  1588. """把当前帧配对中心与距离追加进轨迹队列"""
  1589. if not pair:
  1590. self._pair_traj[side] = []
  1591. return
  1592. h, l = pair
  1593. hx, hy = h['x'] + h['w'] / 2, h['y'] + h['h'] / 2
  1594. lx, ly = l['x'] + l['w'] / 2, l['y'] + l['h'] / 2
  1595. dist = ((hx - lx) ** 2 + (hy - ly) ** 2) ** 0.5
  1596. now = time.time()
  1597. traj = self._pair_traj[side]
  1598. traj.append({"t": now, "hook_cx": hx, "hook_cy": hy,
  1599. "lug_cx": lx, "lug_cy": ly, "lug_w": l['w'], "lug_h": l['h'], "dist": dist})
  1600. while len(traj) > self.TRAJ_MAX_FRAMES:
  1601. traj.pop(0)
  1602. if traj[-1]['t'] - traj[0]['t'] > self.TRAJ_WINDOW_SECS:
  1603. traj.pop(0)
  1604. def _window_traj(self, side: str):
  1605. """返回该侧轨迹窗口(最近 TRAJ_WINDOW_SECS)"""
  1606. traj = self._pair_traj[side]
  1607. if not traj:
  1608. return traj
  1609. now = traj[-1]['t']
  1610. return [p for p in traj if now - p['t'] <= self.TRAJ_WINDOW_SECS]
  1611. def _update_suppress_window(self):
  1612. """基于轨迹判定放包/分离阶段,命中则顺延屏蔽语音"""
  1613. now = time.time()
  1614. hit = ""
  1615. for side in ("left", "right"):
  1616. win = self._window_traj(side)
  1617. if len(win) < 5:
  1618. continue
  1619. first, last = win[0], win[-1]
  1620. dy_h = last['hook_cy'] - first['hook_cy']
  1621. dy_l = last['lug_cy'] - first['lug_cy']
  1622. min_dist = min(p['dist'] for p in win)
  1623. max_dist = max(p['dist'] for p in win)
  1624. # 放包:hook 与 lug 同步下移,且距离保持贴合
  1625. if dy_h >= self.RELEASE_TRAJ_Y and dy_l >= self.RELEASE_TRAJ_Y and min_dist < self.ENGAGE_DIST:
  1626. hit = "放包"
  1627. # 分离:距离相离(任意方向),且之前曾贴合
  1628. elif max_dist - min_dist >= self.SEPARATE_TRAJ_DIST and min_dist < self.ENGAGE_DIST:
  1629. hit = "分离"
  1630. if hit:
  1631. self._voice_suppress_until = max(now, self._voice_suppress_until) + self.SUPPRESS_SECONDS
  1632. self._trace_phase = hit
  1633. else:
  1634. self._trace_phase = ""
  1635. def _is_voice_suppressed(self) -> bool:
  1636. return time.time() < self._voice_suppress_until
  1637. def _maybe_clear_stale_overlay(self):
  1638. """MQTT 消息超时后清除残留画框/轨迹,避免画面卡住旧框"""
  1639. now = time.time()
  1640. if not self.ai_enabled or self._overlay_cleared:
  1641. return
  1642. if self._last_mqtt_process > 0 and now - self._last_mqtt_process > self.MQTT_CLEAR_TIMEOUT:
  1643. self._last_left_draw = []
  1644. self._last_right_draw = []
  1645. self._pair_traj = {"left": [], "right": []}
  1646. self._trace_phase = ""
  1647. self._voice_suppress_until = 0.0
  1648. if self.left_player:
  1649. self.left_player.set_ai_objects([])
  1650. if self.right_player:
  1651. self.right_player.set_ai_objects([])
  1652. self._overlay_cleared = True
  1653. def _trace_objects(self, side: str) -> list:
  1654. """构造叠加对象:挂钩轨迹、吊耳轨迹、挂钩-吊耳连线、阶段文本"""
  1655. if not self.debug_trace:
  1656. return []
  1657. win = self._window_traj(side)
  1658. if len(win) < 2:
  1659. return []
  1660. hook_pts = [(p['hook_cx'], p['hook_cy']) for p in win]
  1661. lug_pts = [(p['lug_cx'], p['lug_cy']) for p in win]
  1662. objs = [
  1663. {"points": hook_pts, "color": (0, 255, 0), "alpha": 0.4, "thickness": 1},
  1664. {"points": lug_pts, "color": (0, 255, 255), "alpha": 0.4, "thickness": 1},
  1665. ]
  1666. hx, hy = win[-1]['hook_cx'], win[-1]['hook_cy']
  1667. lx, ly = win[-1]['lug_cx'], win[-1]['lug_cy']
  1668. color = (0, 0, 255) if self._trace_phase == "分离" else (255, 165, 0)
  1669. objs.append({"points": [(hx, hy), (lx, ly)], "color": color, "alpha": 0.4, "thickness": 1})
  1670. return objs
  1671. def _trace_status_text(self) -> str:
  1672. if not self.debug_trace:
  1673. return ""
  1674. if self._trace_phase:
  1675. remain = max(0.0, self._voice_suppress_until - time.time())
  1676. tag = "RELEASE" if self._trace_phase == "放包" else "SEP"
  1677. return f"{tag} {remain:.1f}s"
  1678. return ""
  1679. # ---------- UI 更新与语音 ----------
  1680. def _clear_state_display(self):
  1681. """吊耳被画面上沿裁切时不判定:状态文字清空,边框恢复默认"""
  1682. self.hook_status_label.setText("")
  1683. style = """
  1684. QLabel {
  1685. border: 4px solid #dcdfe6;
  1686. border-radius: 8px;
  1687. background-color: black;
  1688. }
  1689. """
  1690. self.left_label.setStyleSheet(style)
  1691. self.right_label.setStyleSheet(style)
  1692. def update_ui_for_state(self, state):
  1693. if state == HookState.IDLE:
  1694. border_color = "#dcdfe6"
  1695. text = "空闲"
  1696. elif state == HookState.RELEASING:
  1697. border_color = "#ffa500"
  1698. text = "放包中"
  1699. elif state == HookState.HOOKING:
  1700. border_color = "#ff0000"
  1701. text = "挂包中"
  1702. elif state == HookState.READY:
  1703. border_color = "#00ff00"
  1704. text = "挂包成功"
  1705. else:
  1706. border_color = "#dcdfe6"
  1707. text = ""
  1708. self.hook_status_label.setText(f"挂钩状态: {text}")
  1709. style = f"""
  1710. QLabel {{
  1711. border: 4px solid {border_color};
  1712. border-radius: 8px;
  1713. background-color: black;
  1714. }}
  1715. """
  1716. self.left_label.setStyleSheet(style)
  1717. self.right_label.setStyleSheet(style)
  1718. if self.voice_enabled and state in (HookState.HOOKING, HookState.READY):
  1719. now = time.time()
  1720. if self._is_voice_suppressed():
  1721. return
  1722. if state == HookState.HOOKING:
  1723. if state != self.last_voice_state or now - self._last_hooking_voice_time > 15:
  1724. self._speak("挂包中")
  1725. self.last_voice_state = state
  1726. self._last_hooking_voice_time = now
  1727. elif state != self.last_voice_state:
  1728. self._speak("挂包成功")
  1729. self.last_voice_state = state
  1730. # ---------- 定时任务 ----------
  1731. def check_idle(self):
  1732. if self.rfid_client:
  1733. self.rfid_client.check_idle()
  1734. self._maybe_clear_stale_overlay()
  1735. if self._calib_active:
  1736. self._sample_calibration()
  1737. now = time.time()
  1738. if self.config.ai.enabled and self.mqtt_client and not self.mqtt_client.connected:
  1739. if now - self._last_mqtt_connect_attempt > 30:
  1740. self._last_mqtt_connect_attempt = now
  1741. self._connect_current_ai()
  1742. # 每 5 秒检测一次摄像头信号
  1743. if now - self._last_signal_check > 5:
  1744. self._last_signal_check = now
  1745. if not getattr(self, '_signal_check_thread', None) or not self._signal_check_thread.is_alive():
  1746. self._signal_check_thread = threading.Thread(target=self._check_all_cameras, daemon=True)
  1747. self._signal_check_thread.start()
  1748. def _collect_camera_ips(self) -> list:
  1749. ips = set()
  1750. for bucket in self.buckets:
  1751. for cam in bucket.cameras:
  1752. m = re.search(r'rtsp://(?:[^@]+@)?([^:/]+)', cam.rtsp_url)
  1753. if m:
  1754. ips.add(m.group(1))
  1755. return sorted(ips)
  1756. def _check_all_cameras(self):
  1757. ips = self._collect_camera_ips()
  1758. if not ips:
  1759. return
  1760. def check_one(ip: str) -> tuple:
  1761. try:
  1762. import socket
  1763. s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
  1764. s.settimeout(2)
  1765. r = s.connect_ex((ip, 554))
  1766. s.close()
  1767. return (ip, 0 if r == 0 else -1)
  1768. except Exception:
  1769. return (ip, -1)
  1770. with ThreadPoolExecutor(max_workers=len(ips)) as pool:
  1771. results = dict(pool.map(check_one, ips))
  1772. self.camera_signal_signal.emit(results)
  1773. def _on_camera_signal(self, results: dict):
  1774. was_offline = {ip for ip, lat in self._camera_signal.items() if lat is not None and lat < 0}
  1775. self._camera_signal.update(results)
  1776. now_online = {ip for ip, lat in results.items() if lat >= 0}
  1777. revived = now_online & was_offline
  1778. went_offline = {ip for ip, lat in results.items() if lat < 0}
  1779. if went_offline and self.active_bucket:
  1780. for cam in self.active_bucket.cameras:
  1781. m = re.search(r'rtsp://(?:[^@]+@)?([^:/]+)', cam.rtsp_url)
  1782. if m and m.group(1) in went_offline:
  1783. ip = m.group(1)
  1784. if ip not in self._offline_reported and self.voice_enabled:
  1785. self._offline_reported.add(ip)
  1786. side_name = "左" if cam.position == CameraPosition.LEFT else "右"
  1787. self._speak(f"{side_name}侧摄像头离线")
  1788. logger.warning(f"摄像头离线: {ip}")
  1789. side = "left" if cam.position == CameraPosition.LEFT else "right"
  1790. label = self.left_label if side == "left" else self.right_label
  1791. cur = self.left_player if side == "left" else self.right_player
  1792. if cur is not None:
  1793. cur.stop()
  1794. if side == "left":
  1795. self.left_player = None
  1796. else:
  1797. self.right_player = None
  1798. self._cancel_rtsp_retry(side)
  1799. self.show_timeout_on_label(label)
  1800. if revived and self.active_bucket:
  1801. for cam in self.active_bucket.cameras:
  1802. m = re.search(r'rtsp://(?:[^@]+@)?([^:/]+)', cam.rtsp_url)
  1803. if m and m.group(1) in revived:
  1804. self._offline_reported.discard(m.group(1))
  1805. side = "left" if cam.position == CameraPosition.LEFT else "right"
  1806. cur = self.left_player if side == "left" else self.right_player
  1807. if cur is None:
  1808. self._cancel_rtsp_retry(side)
  1809. self._retry_rtsp(side)
  1810. for bucket in self.buckets:
  1811. bars = self._bucket_signal_labels.get(bucket.id)
  1812. if not bars:
  1813. continue
  1814. left_bar, right_bar = bars
  1815. for bar, cam in zip((left_bar, right_bar), bucket.cameras):
  1816. m = re.search(r'rtsp://(?:[^@]+@)?([^:/]+)', cam.rtsp_url)
  1817. if not m:
  1818. bar.setStyleSheet("background-color: #dcdfe6; border-radius: 2px;")
  1819. continue
  1820. ip = m.group(1)
  1821. lat = self._camera_signal.get(ip)
  1822. if lat is None:
  1823. bar.setStyleSheet("background-color: #dcdfe6; border-radius: 2px;")
  1824. elif lat < 0:
  1825. bar.setStyleSheet("background-color: #f56c6c; border-radius: 2px;")
  1826. elif lat < 50:
  1827. bar.setStyleSheet("background-color: #67c23a; border-radius: 2px;")
  1828. elif lat < 200:
  1829. bar.setStyleSheet("background-color: #e6a23c; border-radius: 2px;")
  1830. else:
  1831. bar.setStyleSheet("background-color: #f56c6c; border-radius: 2px;")
  1832. def toggle_switch_mode(self, checked: bool):
  1833. if checked:
  1834. self.switch_mode = SwitchMode.AUTO
  1835. self.auto_switch_btn.setText("自动模式")
  1836. if self._last_rfid_tag:
  1837. self._on_rfid_tag_impl(self._last_rfid_tag)
  1838. elif self.active_bucket:
  1839. self.activate_bucket(None)
  1840. else:
  1841. self.switch_mode = SwitchMode.HAND
  1842. self.auto_switch_btn.setText("手动模式")
  1843. # ---------- 配置保存 ----------
  1844. def _find_bucket_section(self, cfg, bucket_id):
  1845. for section in cfg.sections():
  1846. if not re.match(r'^Bucket:\d+$', section):
  1847. continue
  1848. try:
  1849. if cfg.getint(section, "id") == bucket_id:
  1850. return section
  1851. except (ValueError, configparser.Error):
  1852. continue
  1853. return None
  1854. def _save_config(self):
  1855. import configparser
  1856. cfg = configparser.ConfigParser()
  1857. cfg.read(self._config_path, encoding="utf-8")
  1858. if not cfg.has_section("AI"):
  1859. cfg.add_section("AI")
  1860. val = "true" if self.config.ai.tts_enabled else "false"
  1861. cfg.set("AI", "tts_enabled", val)
  1862. cfg.set("AI", "offset_ratio_tolerance", str(self.config.ai.offset_ratio_tolerance))
  1863. cfg.set("AI", "ready_debounce", str(self.config.ai.ready_debounce))
  1864. cfg.set("AI", "auto_calib_ready_frames", str(self.config.ai.auto_calib_ready_frames))
  1865. cfg.set("AI", "auto_calib_duration_secs", str(self.config.ai.auto_calib_duration_secs))
  1866. cfg.set("AI", "auto_calib_min_samples", str(self.config.ai.auto_calib_min_samples))
  1867. for bucket in self.buckets:
  1868. section = self._find_bucket_section(cfg, bucket.id)
  1869. if not section:
  1870. continue
  1871. for side in ("left", "right"):
  1872. x = getattr(bucket, f"hook_lug_x_offset_ratio_{side}", None)
  1873. y = getattr(bucket, f"hook_lug_y_offset_ratio_{side}", None)
  1874. key_x = f"hook_lug_x_offset_ratio_{side}"
  1875. key_y = f"hook_lug_y_offset_ratio_{side}"
  1876. if x is not None and y is not None:
  1877. cfg.set(section, key_x, f"{x:.6f}")
  1878. cfg.set(section, key_y, f"{y:.6f}")
  1879. else:
  1880. cfg.remove_option(section, key_x)
  1881. cfg.remove_option(section, key_y)
  1882. if not cfg.has_section("Video"):
  1883. cfg.add_section("Video")
  1884. cfg.set("Video", "ratio_16_9", "true" if self._ratio_16_9 else "false")
  1885. with open(self._config_path, "w", encoding="utf-8") as f:
  1886. cfg.write(f)
  1887. logger.info(f"配置已保存: {self._config_path}")
  1888. @staticmethod
  1889. def _autostart_config_dir():
  1890. if sys.platform == "win32":
  1891. return os.path.join(os.environ.get("APPDATA", os.path.expanduser("~")), "HookVision")
  1892. return os.path.join(os.path.expanduser("~"), ".config", "HookVision")
  1893. @staticmethod
  1894. def _autostart_config_path():
  1895. return os.path.join(MainWindow._autostart_config_dir(), "autostart.txt") # 改为 txt
  1896. @staticmethod
  1897. def _autostart_desktop_path():
  1898. return os.path.expanduser("~/.config/autostart/HookVision.desktop")
  1899. @staticmethod
  1900. def _read_autostart_flag():
  1901. path = MainWindow._autostart_config_path()
  1902. try:
  1903. with open(path, "r", encoding="utf-8") as f:
  1904. content = f.read().strip().lower()
  1905. return content == "true"
  1906. except:
  1907. return False
  1908. @staticmethod
  1909. def _write_autostart_flag(enabled: bool):
  1910. path = MainWindow._autostart_config_path()
  1911. os.makedirs(os.path.dirname(path), exist_ok=True)
  1912. with open(path, "w", encoding="utf-8") as f:
  1913. f.write("true" if enabled else "false")
  1914. @staticmethod
  1915. def _autostart_command():
  1916. if getattr(sys, 'frozen', False):
  1917. return sys.executable
  1918. else:
  1919. main_script = os.path.abspath(sys.argv[0])
  1920. python_exe = sys.executable
  1921. if ' ' in python_exe:
  1922. python_exe = f'"{python_exe}"'
  1923. if ' ' in main_script:
  1924. main_script = f'"{main_script}"'
  1925. if sys.platform == "win32":
  1926. pythonw = python_exe.replace('python.exe', 'pythonw.exe')
  1927. if pythonw != python_exe and os.path.exists(pythonw):
  1928. python_exe = pythonw
  1929. return f'{python_exe} {main_script}'
  1930. @staticmethod
  1931. def _apply_system_autostart(enabled: bool) -> bool:
  1932. cmd = MainWindow._autostart_command()
  1933. try:
  1934. if sys.platform == "win32":
  1935. import winreg
  1936. key = r"Software\Microsoft\Windows\CurrentVersion\Run"
  1937. with winreg.OpenKey(winreg.HKEY_CURRENT_USER, key, 0, winreg.KEY_SET_VALUE) as rk:
  1938. if enabled:
  1939. winreg.SetValueEx(rk, MainWindow.APPTITLE, 0, winreg.REG_SZ, cmd)
  1940. else:
  1941. try:
  1942. winreg.DeleteValue(rk, MainWindow.APPTITLE)
  1943. except OSError:
  1944. pass
  1945. return True
  1946. else:
  1947. desktop_path = MainWindow._autostart_desktop_path()
  1948. if enabled:
  1949. os.makedirs(os.path.dirname(desktop_path), exist_ok=True)
  1950. with open(desktop_path, "w", encoding="utf-8") as f:
  1951. f.write(f"""[Desktop Entry]
  1952. Type=Application
  1953. Name={MainWindow.APPTITLE}
  1954. Exec={cmd}
  1955. Terminal=false
  1956. X-GNOME-Autostart-enabled=true
  1957. """)
  1958. else:
  1959. try:
  1960. os.remove(desktop_path)
  1961. except OSError:
  1962. pass
  1963. return True
  1964. except Exception as e:
  1965. logger.error(f"设置开机自启失败: {e}")
  1966. return False
  1967. def _on_autostart_toggle(self, state):
  1968. enabled = (state == Qt.Checked)
  1969. self._write_autostart_flag(enabled)
  1970. ok = self._apply_system_autostart(enabled)
  1971. if not ok:
  1972. QMessageBox.warning(
  1973. self,
  1974. "设置失败",
  1975. "开机自启动设置失败,请检查权限或手动添加。\n"
  1976. "Windows: 将本程序快捷方式放入 '启动' 文件夹。\n"
  1977. "Linux: 检查 ~/.config/autostart/ 目录权限。"
  1978. )