362 lines
13 KiB
Python
362 lines
13 KiB
Python
# 메인 윈도우 — 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
|
|
)
|
|
self._inspect_page = InspectPage(
|
|
self.insight, self.basler,
|
|
detector=self.detector,
|
|
belt_delay=_belt_delay,
|
|
)
|
|
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()
|
|
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, "_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;"
|
|
)
|
|
|