| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851 |
- import os
- import time
- import logging
- import threading
- import queue
- from PySide6.QtWidgets import (QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
- QPushButton, QLabel, QGridLayout, QScrollArea,
- QToolButton, QFrame, QApplication, QCheckBox)
- from PySide6.QtCore import Qt, QTimer, QSize
- from PySide6.QtGui import QIcon, QPixmap, QResizeEvent, QKeyEvent, QCloseEvent
- from .models import *
- from .rtsp_player import RTSPPlayer
- from .rfid_client import RFIDClient
- from .modbus_client import ModbusClient
- from .mqtt_client import MQTTClient
- logger = logging.getLogger(__name__)
- class MainWindow(QMainWindow):
- # 防抖阈值(连续相同状态次数)
- STATE_THRESHOLD = 3
- # 放包判断位移阈值(像素)
- RELEASE_DELTA_THRESHOLD = 5
- def __init__(self, config: Config):
- super().__init__()
- self.config = config
- self.buckets = config.buckets
- self.active_bucket: BucketModel = None
- self.switch_mode = SwitchMode.AUTO
- self.left_player: RTSPPlayer = None
- self.right_player: RTSPPlayer = None
- self.rfid_client = None
- self.modbus_client = None
- self.mqtt_client = None
- self.video_aspect_ratio = 16 / 9
- self.left_label = None
- self.right_label = None
- # 挂钩状态相关
- self.hook_state = HookState.IDLE
- self.prev_state = HookState.IDLE
- self.state_counter = 0
- # 存储左右画面最近检测对象
- self.left_objects = []
- self.right_objects = []
- # 前一帧 hook/lug 中心位置(用于轨迹判断)
- self.prev_left_hook = None
- self.prev_right_hook = None
- self.prev_left_lug = None
- self.prev_right_lug = None
- # 语音引擎(在工作线程内初始化)
- self.last_voice_state = None
- self._last_hooking_voice_time = 0.0
- self.voice_enabled = True
- self.ai_enabled = True
- self._last_left_draw: list = []
- self._last_right_draw: list = []
- self._voice_queue = queue.Queue()
- self._voice_thread = threading.Thread(target=self._voice_worker, daemon=True)
- self._voice_thread.start()
- self.setWindowTitle(config.app_name)
- screen = QApplication.primaryScreen()
- screen_geometry = screen.availableGeometry()
- screen_width = screen_geometry.width()
- screen_height = screen_geometry.height()
- window_width = int(screen_width * 0.9)
- window_height = int(screen_height * 0.85)
- x = (screen_width - window_width) // 2
- y = (screen_height - window_height) // 2
- self.setGeometry(x, y, window_width, window_height)
- self.setMinimumSize(800, 600)
- self.setup_ui()
- self.showFullScreen()
- self.update_video_size()
- self.init_hardware()
- self.timer = QTimer()
- self.timer.timeout.connect(self.check_idle)
- self.timer.start(1000)
- def setup_ui(self):
- central = QWidget()
- self.setCentralWidget(central)
- main_layout = QVBoxLayout(central)
- main_layout.setContentsMargins(10, 10, 10, 10)
- main_layout.setSpacing(10)
- # 标题栏
- title_widget = QWidget()
- title_layout = QHBoxLayout(title_widget)
- title_layout.setContentsMargins(0, 0, 0, 0)
- self.title_label = QLabel(self.config.app_name)
- self.title_label.setStyleSheet("font-size: 22px; font-weight: bold; padding: 5px;")
- title_layout.addWidget(self.title_label)
- title_layout.addStretch()
- btn_style = """
- QPushButton {
- background-color: transparent;
- border: none;
- font-size: 18px;
- padding: 6px 12px;
- border-radius: 5px;
- }
- QPushButton:hover {
- background-color: rgba(255, 255, 255, 0.1);
- }
- """
- self.min_btn = QPushButton("—")
- self.min_btn.setStyleSheet(btn_style)
- self.min_btn.clicked.connect(self.showMinimized)
- self.close_btn = QPushButton("✕")
- self.close_btn.setStyleSheet(btn_style)
- self.close_btn.clicked.connect(self.close)
- title_layout.addWidget(self.min_btn)
- title_layout.addWidget(self.close_btn)
- main_layout.addWidget(title_widget)
- # 工位区域
- bucket_group = QFrame()
- bucket_group.setStyleSheet("QFrame { border-radius: 8px; padding: 8px; }")
- bucket_layout = QVBoxLayout(bucket_group)
- scroll_area = QScrollArea()
- scroll_area.setWidgetResizable(True)
- scroll_area.setFrameShape(QFrame.NoFrame)
- scroll_area.setStyleSheet("QScrollArea { background: transparent; border: none; }")
- self.bucket_container = QWidget()
- self.bucket_grid = QGridLayout(self.bucket_container)
- self.bucket_grid.setSpacing(15)
- self.bucket_grid.setContentsMargins(10, 10, 10, 10)
- self.bucket_grid.setAlignment(Qt.AlignLeft | Qt.AlignTop)
- self.bucket_buttons = []
- # 原代码(删除)
- # for idx, bucket in enumerate(self.buckets):
- # btn = QToolButton()
- # ...
- # row = idx // 6
- # col = idx % 6
- # self.bucket_grid.addWidget(btn, row, col)
- # self.bucket_buttons.append((btn, bucket))
- # 新代码:按 code_x 分组纵向排列
- from collections import defaultdict
- groups = defaultdict(list)
- for bucket in self.buckets:
- groups[bucket.code_x].append(bucket)
- sorted_keys = sorted(groups.keys())
- self.bucket_buttons = [] # 重置
- col = 0
- for key in sorted_keys:
- buckets_in_group = groups[key]
- for row, bucket in enumerate(buckets_in_group):
- btn = QToolButton()
- btn.setFixedSize(100, 100)
- btn.setToolButtonStyle(Qt.ToolButtonTextUnderIcon)
- btn.setText(bucket.description)
- btn.setIcon(self.get_bucket_icon(bucket.is_active))
- btn.setIconSize(QSize(64, 64))
- btn.setStyleSheet("""
- QToolButton {
- border-radius: 12px;
- font-size: 14px;
- font-weight: bold;
- }
- """)
- btn.clicked.connect(lambda checked, b=bucket: self.on_bucket_clicked(b))
- self.bucket_grid.addWidget(btn, row, col)
- self.bucket_buttons.append((btn, bucket))
- col += 1
- self.bucket_container.setLayout(self.bucket_grid)
- scroll_area.setWidget(self.bucket_container)
- bucket_layout.addWidget(scroll_area)
- main_layout.addWidget(bucket_group)
- # 视频区域(移除覆盖层,仅保留边框)
- video_container = QWidget()
- video_layout = QHBoxLayout(video_container)
- video_layout.setSpacing(15)
- video_layout.setContentsMargins(0, 0, 0, 0)
- self.left_label = QLabel("左侧画面")
- self.left_label.setScaledContents(False)
- self.left_label.setAlignment(Qt.AlignCenter)
- self.left_label.setStyleSheet("""
- QLabel {
- border: 2px solid #dcdfe6;
- border-radius: 8px;
- background-color: black;
- }
- """)
- self.right_label = QLabel("右侧画面")
- self.right_label.setScaledContents(False)
- self.right_label.setAlignment(Qt.AlignCenter)
- self.right_label.setStyleSheet("""
- QLabel {
- border: 2px solid #dcdfe6;
- border-radius: 8px;
- background-color: black;
- }
- """)
- video_layout.addWidget(self.left_label)
- video_layout.addWidget(self.right_label)
- main_layout.addWidget(video_container, 1)
- # 状态栏
- status_widget = QWidget()
- status_layout = QHBoxLayout(status_widget)
- status_layout.setContentsMargins(5, 5, 5, 5)
- self.module_status = QLabel("通讯状态: 初始化")
- self.module_status.setStyleSheet("color: #606266; font-weight: bold;")
- self.rfid_tag_label = QLabel("当前卡号: 无")
- self.rfid_tag_label.setStyleSheet("color: #409eff; font-weight: bold; margin-left: 20px;")
- self.hook_status_label = QLabel("挂钩状态: 空闲")
- self.hook_status_label.setStyleSheet("color: #ffa500; font-weight: bold; margin-left: 20px;")
- self.voice_checkbox = QCheckBox("语音播报")
- self.voice_checkbox.setChecked(True)
- self.voice_checkbox.setStyleSheet("color: #606266; font-weight: bold;")
- self.voice_checkbox.stateChanged.connect(self.on_voice_toggle)
- self.ai_checkbox = QCheckBox("AI启用")
- self.ai_checkbox.setChecked(True)
- self.ai_checkbox.setStyleSheet("color: #606266; font-weight: bold;")
- self.ai_checkbox.stateChanged.connect(self.on_ai_toggle)
- self.auto_switch_btn = QPushButton("自动模式")
- self.auto_switch_btn.setCheckable(True)
- self.auto_switch_btn.setChecked(True)
- self.auto_switch_btn.setStyleSheet("border-radius: 15px; padding: 5px 15px; font-weight: bold;")
- self.auto_switch_btn.clicked.connect(self.toggle_switch_mode)
- status_layout.addWidget(self.module_status)
- status_layout.addWidget(self.rfid_tag_label)
- status_layout.addWidget(self.hook_status_label)
- status_layout.addStretch()
- status_layout.addWidget(self.voice_checkbox)
- status_layout.addWidget(self.ai_checkbox)
- status_layout.addWidget(self.auto_switch_btn)
- main_layout.addWidget(status_widget)
- self.update_video_size()
- # ---------- 事件与资源管理 ----------
- def closeEvent(self, event: QCloseEvent):
- """窗口关闭时释放资源"""
- if self.rfid_client:
- self.rfid_client.disconnect()
- if self.modbus_client:
- self.modbus_client.disconnect()
- if self.mqtt_client:
- self.mqtt_client.disconnect()
- if self.left_player:
- self.left_player.stop()
- if self.right_player:
- self.right_player.stop()
- event.accept()
- # ---------- 语音控制 ----------
- def on_voice_toggle(self, state):
- self.voice_enabled = (state == Qt.Checked)
- def on_ai_toggle(self, state):
- enabled = (state == Qt.Checked)
- if enabled == self.ai_enabled:
- return
- self.ai_enabled = enabled
- if not enabled:
- self.hook_state = HookState.IDLE
- self.prev_state = HookState.IDLE
- self.state_counter = 0
- self.left_objects = []
- self.right_objects = []
- self.prev_left_hook = None
- self.prev_right_hook = None
- self.prev_left_lug = None
- self.prev_right_lug = None
- self.update_ui_for_state(HookState.IDLE)
- if self.left_player:
- self.left_player.set_ai_objects([])
- if self.right_player:
- self.right_player.set_ai_objects([])
- else:
- # 恢复最近一帧的标注框
- if self.left_player and self._last_left_draw:
- self.left_player.set_ai_objects(self._last_left_draw)
- if self.right_player and self._last_right_draw:
- self.right_player.set_ai_objects(self._last_right_draw)
- def _speak(self, msg: str):
- self._voice_queue.put(msg)
- def _voice_worker(self):
- try:
- import pythoncom
- pythoncom.CoInitialize()
- except ImportError:
- pass
- try:
- import win32com.client as _win32com
- except ImportError:
- logging.warning("pywin32 未安装,语音播报不可用")
- return
- while True:
- msg = self._voice_queue.get()
- if msg is None:
- break
- try:
- speaker = _win32com.Dispatch("SAPI.SpVoice")
- speaker.Rate = 2
- speaker.Speak(msg)
- except Exception as e:
- logging.warning(f"语音播报失败: {e}")
- self._voice_queue.task_done()
- # ---------- 工位图标 ----------
- def get_bucket_icon(self, is_active):
- icon_dir = os.path.join(os.path.dirname(__file__), "..", "assets")
- if not os.path.exists(icon_dir):
- os.makedirs(icon_dir, exist_ok=True)
- return QIcon()
- if is_active:
- icon_path = os.path.join(icon_dir, "bucket_active.svg")
- else:
- icon_path = os.path.join(icon_dir, "bucket_normal.svg")
- if os.path.exists(icon_path):
- return QIcon(icon_path)
- else:
- pixmap = QPixmap(64, 64)
- pixmap.fill(Qt.green if is_active else Qt.gray)
- return QIcon(pixmap)
- def refresh_bucket_icons(self):
- for btn, bucket in self.bucket_buttons:
- icon = self.get_bucket_icon(bucket.is_active)
- btn.setIcon(icon)
- if bucket.is_active:
- btn.setStyleSheet("""
- QToolButton {
- background-color: #e1f3d8;
- border: 2px solid #67c23a;
- border-radius: 12px;
- font-size: 14px;
- font-weight: bold;
- color: #1f2d3d;
- }
- """)
- else:
- btn.setStyleSheet("""
- QToolButton {
- background-color: transparent;
- border: 2px solid #d0d7de;
- border-radius: 12px;
- font-size: 14px;
- font-weight: bold;
- color: #1f2d3d;
- }
- QToolButton:hover {
- background-color: rgba(0, 0, 0, 0.05);
- border-color: #409eff;
- }
- """)
- # ---------- 视频尺寸自适应 ----------
- def update_video_size(self):
- if not self.left_label or not self.right_label:
- return
- container = self.left_label.parentWidget()
- if not container:
- return
- available_width = container.width() - 15
- single_width = available_width // 2
- target_height = int(single_width / self.video_aspect_ratio)
- if target_height > container.height():
- target_height = container.height()
- self.left_label.setFixedSize(single_width, target_height)
- self.right_label.setFixedSize(single_width, target_height)
- def update_players_target_size(self):
- if self.left_player:
- self.left_player.set_target_size(self.left_label.width(), self.left_label.height())
- if self.right_player:
- self.right_player.set_target_size(self.right_label.width(), self.right_label.height())
- def resizeEvent(self, event: QResizeEvent):
- self.update_video_size()
- self.update_players_target_size()
- super().resizeEvent(event)
- def keyPressEvent(self, event: QKeyEvent):
- if event.key() == Qt.Key_Escape and self.isFullScreen():
- self.showNormal()
- elif event.key() == Qt.Key_F11:
- if self.isFullScreen():
- self.showNormal()
- else:
- self.showFullScreen()
- else:
- super().keyPressEvent(event)
- # ---------- 加载动画和超时 ----------
- def show_loading_on_label(self, label: QLabel):
- label.setText("加载中...")
- label.setAlignment(Qt.AlignCenter)
- label.setStyleSheet(label.styleSheet() + "color: white; font-size: 20px;")
- def hide_loading_on_label(self, label: QLabel):
- label.setText("")
- label.setStyleSheet("""
- QLabel {
- border: 2px solid #dcdfe6;
- border-radius: 8px;
- background-color: black;
- }
- """)
- def show_timeout_on_label(self, label: QLabel):
- label.setText("连接超时")
- label.setAlignment(Qt.AlignCenter)
- label.setStyleSheet(label.styleSheet() + "color: red; font-size: 20px;")
- def on_player_timeout(self, label: QLabel, player_ref, side: str):
- self.show_timeout_on_label(label)
- if side == "left" and self.left_player:
- self.left_player.stop()
- self.left_player = None
- elif side == "right" and self.right_player:
- self.right_player.stop()
- self.right_player = None
- # ---------- 点击工位 ----------
- def on_bucket_clicked(self, bucket: BucketModel):
- if self.switch_mode != SwitchMode.HAND:
- self.switch_mode = SwitchMode.HAND
- self.auto_switch_btn.setChecked(False)
- self.auto_switch_btn.setText("手动模式")
- self.activate_bucket(bucket)
- def on_rfid_state_changed(self, online: bool):
- if online:
- self.module_status.setText("通讯状态: RFID正常")
- else:
- self.module_status.setText("通讯状态: RFID离线")
- # ---------- 硬件初始化 ----------
- def init_hardware(self):
- if self.config.module_type == ModuleType.RFID and self.config.rfid:
- self.rfid_client = RFIDClient(
- self.config.rfid,
- self.on_rfid_tag,
- on_state_changed=self.on_rfid_state_changed,
- tag_offset=18,
- tag_length=4,
- hex_output=True
- )
- if self.rfid_client.connect():
- self.module_status.setText("通讯状态: RFID正常")
- else:
- self.module_status.setText("通讯状态: RFID离线")
- if self.config.module_type == ModuleType.HD3200 and self.config.modbus:
- self.modbus_client = ModbusClient(self.config.modbus, self.on_modbus_data)
- if self.modbus_client.connect():
- self.module_status.setText("通讯状态: Modbus正常")
- else:
- self.module_status.setText("通讯状态: Modbus离线")
- if self.config.ai.enabled:
- self.mqtt_client = MQTTClient(self.config.ai, self.on_mqtt_message)
- self.mqtt_client.connect()
- # ---------- 激活工位 ----------
- def activate_bucket(self, bucket: BucketModel):
- if self.active_bucket == bucket:
- return
- if bucket and self.voice_enabled:
- self._speak(f"到达{bucket.description}")
- if self.left_player:
- self.left_player.stop()
- self.left_player = None
- if self.right_player:
- self.right_player.stop()
- self.right_player = None
- if self.active_bucket:
- self.active_bucket.is_active = False
- self.active_bucket = bucket
- if bucket:
- bucket.is_active = True
- self.refresh_bucket_icons()
- self.show_loading_on_label(self.left_label)
- self.show_loading_on_label(self.right_label)
- # 重置挂钩状态
- self.hook_state = HookState.IDLE
- self.prev_state = HookState.IDLE
- self.state_counter = 0
- self.left_objects = []
- self.right_objects = []
- self.prev_left_hook = None
- self.prev_right_hook = None
- self.prev_left_lug = None
- self.prev_right_lug = None
- self.last_voice_state = None
- self._last_hooking_voice_time = 0.0
- if bucket:
- left_cam = next((c for c in bucket.cameras if c.position == CameraPosition.LEFT), None)
- right_cam = next((c for c in bucket.cameras if c.position == CameraPosition.RIGHT), None)
- if left_cam:
- w = self.left_label.width()
- h = self.left_label.height()
- timeout = self.config.video.rtsp_timeout
- player = RTSPPlayer(left_cam.rtsp_url, w, h, timeout_sec=timeout)
- player.frame_ready.connect(self.left_label.setPixmap)
- player.first_frame_received.connect(lambda: self.hide_loading_on_label(self.left_label))
- player.timeout.connect(lambda: self.on_player_timeout(self.left_label, player, "left"))
- self.left_player = player
- player.start()
- if right_cam:
- w = self.right_label.width()
- h = self.right_label.height()
- timeout = self.config.video.rtsp_timeout
- player = RTSPPlayer(right_cam.rtsp_url, w, h, timeout_sec=timeout)
- player.frame_ready.connect(self.right_label.setPixmap)
- player.first_frame_received.connect(lambda: self.hide_loading_on_label(self.right_label))
- player.timeout.connect(lambda: self.on_player_timeout(self.right_label, player, "right"))
- self.right_player = player
- player.start()
- if self.mqtt_client:
- topics = [c.topic for c in bucket.cameras if c.topic]
- if topics:
- self.mqtt_client.subscribe(topics)
- else:
- self.left_label.clear()
- self.right_label.clear()
- # ---------- RFID 标签回调 ----------
- def on_rfid_tag(self, tag: str):
- if tag:
- self.rfid_tag_label.setText(f"当前卡号: {tag}")
- else:
- self.rfid_tag_label.setText("当前卡号: 无")
- if tag is None:
- if self.switch_mode == SwitchMode.AUTO and self.active_bucket:
- self.activate_bucket(None)
- return
- for bucket in self.buckets:
- if bucket.code_x == tag:
- if self.switch_mode == SwitchMode.AUTO:
- self.activate_bucket(bucket)
- break
- # ---------- Modbus 数据回调 ----------
- def on_modbus_data(self, bools):
- for bucket in self.buckets:
- try:
- addr = int(bucket.code_x)
- except:
- addr = -1
- if 0 <= addr < len(bools) and bools[addr]:
- if self.switch_mode == SwitchMode.AUTO:
- self.activate_bucket(bucket)
- break
- # ---------- MQTT 消息处理 ----------
- def parse_ai_objects(self, payload: dict, display_width: int, display_height: int,
- include_ready: bool = True) -> list:
- if payload is None or not isinstance(payload, dict):
- return []
- orig_width = payload.get("width", 640)
- orig_height = payload.get("height", 360)
- scale_x = display_width / orig_width if orig_width > 0 else 1.0
- scale_y = display_height / orig_height if orig_height > 0 else 1.0
- objects = []
- for obj_str in payload.get("objects", []):
- parts = obj_str.split('|')
- if len(parts) >= 6:
- try:
- x = int(float(parts[1]) * scale_x)
- y = int(float(parts[2]) * scale_y)
- w = int(float(parts[3]) * scale_x)
- h = int(float(parts[4]) * scale_y)
- raw_label = parts[5]
- # 类别映射(hoist→hook, cup→lug)
- label = raw_label
- if 'hoist' in raw_label:
- label = 'hook'
- elif 'cup' in raw_label:
- label = 'lug'
- # 颜色分配(BGR)
- if 'hook' in label:
- color = (0, 0, 255) # 红色
- elif 'lug' in label:
- color = (255, 0, 0) # 蓝色
- elif 'ready' in label:
- if not include_ready:
- continue
- color = (0, 255, 255) # 黄色(不在画面上显示,仅用于内部)
- else:
- color = (128, 128, 128) # 灰色(其他类别)
- objects.append({
- "x": x,
- "y": y,
- "w": w,
- "h": h,
- "label": label,
- "raw_label": raw_label,
- "color": color
- })
- except (ValueError, IndexError):
- continue
- return objects
- def on_mqtt_message(self, topic: str, payload: dict):
- if payload is None or not isinstance(payload, dict):
- return
- if not self.active_bucket:
- return
- if not self.ai_enabled:
- return
- for cam in self.active_bucket.cameras:
- if cam.topic == topic:
- self.update_video_size()
- if cam.position == CameraPosition.LEFT:
- display_width = self.left_label.width()
- display_height = self.left_label.height()
- else:
- display_width = self.right_label.width()
- display_height = self.right_label.height()
- if display_width == 0 or display_height == 0:
- display_width = 640
- display_height = 360
- # 解析所有对象(包括 ready)
- all_objects = self.parse_ai_objects(payload, display_width, display_height, include_ready=True)
- if cam.position == CameraPosition.LEFT:
- self.left_objects = all_objects
- else:
- self.right_objects = all_objects
- # 触发状态评估(会标记配对的 hook/lug)
- self.evaluate_hook_state()
- # 构造绘制对象(排除 ready,无关目标变淡)
- draw_objects = [o for o in all_objects if 'ready' not in o['label'].lower()]
- for obj in draw_objects:
- if not obj.get('paired'):
- obj['dimmed'] = True
- if cam.position == CameraPosition.LEFT:
- self._last_left_draw = draw_objects
- if self.left_player:
- self.left_player.set_ai_objects(draw_objects)
- else:
- self._last_right_draw = draw_objects
- if self.right_player:
- self.right_player.set_ai_objects(draw_objects)
- break
- # ---------- 挂钩状态评估 ----------
- def evaluate_hook_state(self):
- left_hooks = [o for o in self.left_objects if 'hook' in o['label'].lower()]
- left_lugs = [o for o in self.left_objects if 'lug' in o['label'].lower()]
- right_hooks = [o for o in self.right_objects if 'hook' in o['label'].lower()]
- right_lugs = [o for o in self.right_objects if 'lug' in o['label'].lower()]
- def find_nearest_pair(hooks, lugs):
- if not hooks or not lugs:
- return None
- best_pair = None
- best_dist = float('inf')
- for h in hooks:
- hx = h['x'] + h['w'] / 2
- hy = h['y'] + h['h'] / 2
- for l in lugs:
- lx = l['x'] + l['w'] / 2
- ly = l['y'] + l['h'] / 2
- dist = (hx - lx) ** 2 + (hy - ly) ** 2
- if dist < best_dist:
- best_dist = dist
- best_pair = (h, l)
- return best_pair
- left_pair = find_nearest_pair(left_hooks, left_lugs)
- right_pair = find_nearest_pair(right_hooks, right_lugs)
- is_releasing = False
- # 处理左侧配对
- if left_pair:
- h, l = left_pair
- if self.prev_left_hook and self.prev_left_lug:
- dy_h = (h['y'] + h['h'] / 2) - self.prev_left_hook[1]
- dy_l = (l['y'] + l['h'] / 2) - self.prev_left_lug[1]
- if dy_h > self.RELEASE_DELTA_THRESHOLD and dy_l > self.RELEASE_DELTA_THRESHOLD:
- is_releasing = True
- self.prev_left_hook = (h['x'] + h['w'] / 2, h['y'] + h['h'] / 2)
- self.prev_left_lug = (l['x'] + l['w'] / 2, l['y'] + l['h'] / 2)
- else:
- self.prev_left_hook = None
- self.prev_left_lug = None
- # 处理右侧配对
- if right_pair:
- h, l = right_pair
- if self.prev_right_hook and self.prev_right_lug:
- dy_h = (h['y'] + h['h'] / 2) - self.prev_right_hook[1]
- dy_l = (l['y'] + l['h'] / 2) - self.prev_right_lug[1]
- if dy_h > self.RELEASE_DELTA_THRESHOLD and dy_l > self.RELEASE_DELTA_THRESHOLD:
- is_releasing = True
- self.prev_right_hook = (h['x'] + h['w'] / 2, h['y'] + h['h'] / 2)
- self.prev_right_lug = (l['x'] + l['w'] / 2, l['y'] + l['h'] / 2)
- else:
- self.prev_right_hook = None
- self.prev_right_lug = None
- # 状态判断
- if is_releasing:
- new_state = HookState.RELEASING
- else:
- any_ready = any('ready' in o['label'].lower() for o in self.left_objects + self.right_objects)
- has_intersecting_pair = self._has_intersecting_pair(left_pair, right_pair)
- if any_ready and has_intersecting_pair:
- new_state = HookState.READY
- elif has_intersecting_pair:
- new_state = HookState.HOOKING
- else:
- new_state = HookState.IDLE
- # 防抖(非对称:进入非空闲状态需 STATE_THRESHOLD 帧确认,回到空闲只需 1 帧)
- threshold = 1 if new_state == HookState.IDLE else self.STATE_THRESHOLD
- if new_state == self.prev_state:
- self.state_counter += 1
- else:
- self.prev_state = new_state
- self.state_counter = 1
- if self.state_counter >= threshold:
- if new_state != self.hook_state:
- self.hook_state = new_state
- self.update_ui_for_state(new_state)
- self.state_counter = 0
- # 标记配对的 hook/lug(用于绘制时变淡无关目标)
- for obj in self.left_objects + self.right_objects:
- obj.pop('paired', None)
- if left_pair:
- left_pair[0]['paired'] = True
- left_pair[1]['paired'] = True
- if right_pair:
- right_pair[0]['paired'] = True
- right_pair[1]['paired'] = True
- def rects_intersect(self, a, b):
- ax1, ay1, ax2, ay2 = a['x'], a['y'], a['x']+a['w'], a['y']+a['h']
- bx1, by1, bx2, by2 = b['x'], b['y'], b['x']+b['w'], b['y']+b['h']
- return not (ax2 < bx1 or bx2 < ax1 or ay2 < by1 or by2 < ay1)
- def _has_intersecting_pair(self, left_pair, right_pair):
- if left_pair:
- h, l = left_pair
- if self.rects_intersect(h, l):
- return True
- if right_pair:
- h, l = right_pair
- if self.rects_intersect(h, l):
- return True
- return False
- # ---------- UI 更新与语音 ----------
- def update_ui_for_state(self, state):
- if state == HookState.IDLE:
- border_color = "#dcdfe6"
- text = "空闲"
- elif state == HookState.RELEASING:
- border_color = "#ffa500"
- text = "放包中"
- elif state == HookState.HOOKING:
- border_color = "#ff0000"
- text = "挂包中"
- elif state == HookState.READY:
- border_color = "#00ff00"
- text = "挂包成功"
- else:
- border_color = "#dcdfe6"
- text = ""
- # 更新状态栏标签
- self.hook_status_label.setText(f"挂钩状态: {text}")
- # 更新视频边框
- style = f"""
- QLabel {{
- border: 4px solid {border_color};
- border-radius: 8px;
- background-color: black;
- }}
- """
- self.left_label.setStyleSheet(style)
- self.right_label.setStyleSheet(style)
- # 语音播报(带开关和防重复)
- if self.voice_enabled and state in (HookState.HOOKING, HookState.READY):
- now = time.time()
- if state == HookState.HOOKING:
- if state != self.last_voice_state or now - self._last_hooking_voice_time > 15:
- self._speak("挂包中")
- self.last_voice_state = state
- self._last_hooking_voice_time = now
- elif state != self.last_voice_state:
- self._speak("挂包成功")
- self.last_voice_state = state
- # ---------- 定时任务 ----------
- def check_idle(self):
- if self.rfid_client:
- self.rfid_client.check_idle()
- def toggle_switch_mode(self, checked):
- if checked:
- self.switch_mode = SwitchMode.AUTO
- self.auto_switch_btn.setText("自动模式")
- else:
- self.switch_mode = SwitchMode.HAND
- self.auto_switch_btn.setText("手动模式")
|