# 메인 윈도우 — 1920x1080 전체화면, 다크 테마, 4탭 네비게이션 from PyQt5.QtWidgets import ( QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QStackedWidget, QLabel, QSizePolicy, ) from PyQt5.QtCore import Qt, QPoint, QRect, QElapsedTimer from PyQt5.QtGui import QPainter, QPolygon, QColor, QFont from gui.pages.settings_page import SettingsPage from gui.pages.register_page import RegisterPage from gui.pages.inspect_page import InspectPage from gui.pages.retrain_page import RetrainPage from ai.detector import Detector from logic.pattern_matcher import PatternMatcher from db.sql_client import SQLClient _DOT_OK = ("background:#22cc55; border-radius:7px;" "min-width:14px; max-width:14px; min-height:14px; max-height:14px;") _DOT_FAIL = ("background:#cc2222; border-radius:7px;" "min-width:14px; max-width:14px; min-height:14px; max-height:14px;") class ChevronTabButton(QPushButton): # 우측이 ">" 화살표 모양인 breadcrumb/stepper 스타일 탭 버튼 CHEVRON_W = 26 # 화살표 뾰족한 부분 너비(px) COLOR_ACTIVE_BG = QColor("#0055cc") COLOR_ACTIVE_FG = QColor("#ffffff") COLOR_HOVER_BG = QColor("#2e2e2e") COLOR_HOVER_FG = QColor("#ffffff") COLOR_IDLE_BG = QColor("#222222") COLOR_IDLE_FG = QColor("#aaaaaa") def __init__(self, text: str, is_first: bool = False, is_last: bool = False, parent=None): super().__init__(text, parent) self._is_first = is_first self._is_last = is_last self._active = False self._hover = False self.setMinimumHeight(60) self.setCursor(Qt.PointingHandCursor) self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) self.setFlat(True) self.setAttribute(Qt.WA_Hover, True) def setActive(self, active: bool): self._active = active self.update() def enterEvent(self, event): self._hover = True self.update() super().enterEvent(event) def leaveEvent(self, event): self._hover = False self.update() super().leaveEvent(event) def paintEvent(self, event): if self._active: bg, fg = self.COLOR_ACTIVE_BG, self.COLOR_ACTIVE_FG elif self._hover: bg, fg = self.COLOR_HOVER_BG, self.COLOR_HOVER_FG else: bg, fg = self.COLOR_IDLE_BG, self.COLOR_IDLE_FG w, h = self.width(), self.height() cw = self.CHEVRON_W left_cw = 0 if self._is_first else cw right_cw = 0 if self._is_last else cw p = QPainter(self) p.setRenderHint(QPainter.Antialiasing) p.setPen(Qt.NoPen) p.setBrush(bg) # 시계방향: 좌상 → 우상 → (우측 ▶) → 우하 → 좌하 → (좌측 V) pts = [QPoint(0, 0)] if self._is_last: pts += [QPoint(w, 0), QPoint(w, h)] else: pts += [QPoint(w - cw, 0), QPoint(w, h // 2), QPoint(w - cw, h)] pts.append(QPoint(0, h)) if not self._is_first: pts.append(QPoint(cw, h // 2)) p.drawPolygon(QPolygon(pts)) p.setPen(fg) font = QFont(self.font()) font.setPointSize(18) font.setBold(self._active) p.setFont(font) text_rect = QRect(left_cw, 0, w - left_cw - right_cw, h) p.drawText(text_rect, Qt.AlignCenter, self.text()) class MainWindow(QMainWindow): def __init__(self, insight_cam, basler_cam, config: dict, plc_client=None): super().__init__() self.insight = insight_cam self.basler = basler_cam self.config = config self.detector = Detector() self.matcher = PatternMatcher() self.matcher.load() # 앱 시작 시 저장된 패턴 자동 로드 self.db_client = SQLClient() self.plc_client = plc_client self.setWindowTitle("리플렉터 검사 시스템") self.showFullScreen() # 재학습 탭 연속 클릭(창 최소화 단축) 감지용 self._retrain_click_timer = QElapsedTimer() self._retrain_click_timer.start() self._build_ui() self._switch_tab(0) self._auto_connect_db() self.update_connection_status() # ================================================================== # # UI 구성 # ================================================================== # def _build_ui(self): root = QWidget() self.setCentralWidget(root) layout = QVBoxLayout(root) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) layout.addWidget(self._build_tab_bar()) layout.addWidget(self._build_stack(), stretch=1) layout.addWidget(self._build_status_bar()) def _build_tab_bar(self) -> QWidget: bar = QWidget() bar.setFixedHeight(60) # 화살표 모양 사이의 빈 삼각형 영역에서 보일 배경 bar.setStyleSheet("background:#1a1a1a;") row = QHBoxLayout(bar) row.setContentsMargins(0, 0, 0, 0) row.setSpacing(0) labels = ["환경설정", "제품 등록", "검사", "재학습"] self._tab_btns = [] for i, label in enumerate(labels): btn = ChevronTabButton( label, is_first=(i == 0), is_last=(i == len(labels) - 1), ) btn.clicked.connect(lambda _, idx=i: self._switch_tab(idx)) self._tab_btns.append(btn) row.addWidget(btn) return bar def _build_stack(self) -> QStackedWidget: self._stack = QStackedWidget() # belt_delay = 거리 / 속도 (config 기본값 사용) _conv = self.config.get("conveyor", {}) _dist = _conv.get("distance_cm", 100.0) _speed = _conv.get("speed_cms", 30.0) _belt_delay = _dist / _speed if _speed > 0 else 3.33 self._settings_page = SettingsPage( self.insight, self.basler, self.config, detector=self.detector, update_insight_cb=self.update_insight, update_basler_cb=self.update_basler, update_detector_cb=self.update_detector, update_db_cb=self.update_db, update_plc_cb=self.update_plc, plc_client=self.plc_client, ) self._settings_page.cognex_status_changed.connect( lambda _: self.update_connection_status() ) self._settings_page.basler_status_changed.connect( lambda _: self.update_connection_status() ) self._settings_page.plc_status_changed.connect( lambda _: self.update_connection_status() ) self._register_page = RegisterPage( self.insight, matcher=self.matcher, db_client=self.db_client, config=self.config, ) self._inspect_page = InspectPage( self.insight, self.basler, detector=self.detector, belt_delay=_belt_delay, db_client=self.db_client, config=self.config, ) self._inspect_page.update_matcher(self.matcher) self._settings_page.belt_settings_changed.connect( self._inspect_page.update_belt_delay ) self._pages = [ self._settings_page, self._register_page, self._inspect_page, RetrainPage(), ] for page in self._pages: self._stack.addWidget(page) return self._stack def _build_status_bar(self) -> QWidget: bar = QWidget() bar.setFixedHeight(36) bar.setStyleSheet("background:#111111;") row = QHBoxLayout(bar) row.setContentsMargins(16, 0, 16, 0) row.setSpacing(20) self._dot_cognex = self._make_dot(self.insight.is_connected()) self._dot_basler = self._make_dot( self.basler.is_connected() if self.basler else False ) self._dot_db = self._make_dot(False) self._dot_plc = self._make_dot( bool(self.plc_client and self.plc_client.is_connected()) ) self._lbl_cognex = QLabel("코그넥스") self._lbl_basler = QLabel("Basler") self._lbl_db = QLabel("DB") self._lbl_plc = QLabel("PLC") for lbl in (self._lbl_cognex, self._lbl_basler, self._lbl_db, self._lbl_plc): lbl.setStyleSheet("color:#aaaaaa; font-size:13px;") row.addWidget(self._dot_cognex) row.addWidget(self._lbl_cognex) row.addWidget(self._dot_basler) row.addWidget(self._lbl_basler) row.addWidget(self._dot_db) row.addWidget(self._lbl_db) row.addWidget(self._dot_plc) row.addWidget(self._lbl_plc) row.addStretch() return bar @staticmethod def _make_dot(connected: bool) -> QLabel: dot = QLabel() dot.setStyleSheet(_DOT_OK if connected else _DOT_FAIL) return dot # ================================================================== # # 탭 전환 # ================================================================== # def _switch_tab(self, idx: int): # 재학습 탭(3)을 600ms 이내에 연속으로 두 번 클릭하면 창 최소화 if idx == 3: if (self._stack.currentIndex() == 3 and self._retrain_click_timer.elapsed() < 600): self.showMinimized() self._retrain_click_timer.restart() return self._retrain_click_timer.restart() self._stack.setCurrentIndex(idx) for i, btn in enumerate(self._tab_btns): btn.setActive(i == idx) if idx == 0: self._settings_page._sync_connection_status() elif idx == 1: self._register_page.load_products() elif idx == 2: self._inspect_page.refresh_wk_results() self.update_connection_status() # ================================================================== # # 코그넥스 인스턴스 교체 (SettingsPage 연결 성공 시 호출) # ================================================================== # def update_insight(self, new_insight): self.insight = new_insight self._register_page._insight = new_insight self._inspect_page.update_insight(new_insight) def update_basler(self, new_basler): self.basler = new_basler self._inspect_page.update_basler(new_basler) def update_detector(self, new_detector): self.detector = new_detector if hasattr(self, "_inspect_page"): self._inspect_page.update_detector(new_detector) def update_db(self, db_client): self.db_client = db_client connected = db_client is not None and db_client.is_connected() if hasattr(self, "_register_page"): self._register_page.update_db(db_client) if hasattr(self, "_inspect_page"): self._inspect_page.update_db(db_client) if hasattr(self, "_settings_page"): self._settings_page._db_client = db_client self._settings_page._set_db_connected(connected) self.update_connection_status() def update_plc(self, plc_client): self.plc_client = plc_client connected = plc_client is not None and plc_client.is_connected() if hasattr(self, "_settings_page"): self._settings_page._plc_client = plc_client self._settings_page._set_plc_connected(connected) self.update_connection_status() def _auto_connect_db(self): """앱 시작 시 config.json DB 접속 정보로 자동 연결 시도.""" db_cfg = self.config.get("db", {}) server = db_cfg.get("server", "").strip() database = db_cfg.get("database", "").strip() username = db_cfg.get("username", "").strip() password = db_cfg.get("password", "") if not server or not database: return ok = self.db_client.connect(server, database, username, password) if ok: self.update_db(self.db_client) # ================================================================== # # 실제 연결 상태를 읽어 상태바 전체 갱신 # ================================================================== # def update_connection_status(self): cognex_ok = bool(self.insight and self.insight.is_connected()) self._dot_cognex.setStyleSheet(_DOT_OK if cognex_ok else _DOT_FAIL) self._lbl_cognex.setText("코그넥스 연결됨" if cognex_ok else "코그넥스 연결 안됨") self._lbl_cognex.setStyleSheet( "color:#1D9E75; font-size:13px; font-weight:bold;" if cognex_ok else "color:#aaaaaa; font-size:13px;" ) basler_ok = bool(self.basler and self.basler.is_connected()) self._dot_basler.setStyleSheet(_DOT_OK if basler_ok else _DOT_FAIL) self._lbl_basler.setText("Basler 연결됨" if basler_ok else "Basler 연결 안됨") self._lbl_basler.setStyleSheet( "color:#1D9E75; font-size:13px; font-weight:bold;" if basler_ok else "color:#aaaaaa; font-size:13px;" ) db_ok = bool(self.db_client and self.db_client.is_connected()) self._dot_db.setStyleSheet(_DOT_OK if db_ok else _DOT_FAIL) self._lbl_db.setText("DB 연결됨" if db_ok else "DB 연결 안됨") self._lbl_db.setStyleSheet( "color:#1D9E75; font-size:13px; font-weight:bold;" if db_ok else "color:#aaaaaa; font-size:13px;" ) plc_ok = bool(self.plc_client and self.plc_client.is_connected()) self._dot_plc.setStyleSheet(_DOT_OK if plc_ok else _DOT_FAIL) self._lbl_plc.setText("PLC 연결됨" if plc_ok else "PLC 연결 안됨") self._lbl_plc.setStyleSheet( "color:#1D9E75; font-size:13px; font-weight:bold;" if plc_ok else "color:#aaaaaa; font-size:13px;" )