feat: 초기 프로젝트 구조 추가
This commit is contained in:
1
gui/__init__.py
Normal file
1
gui/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# gui 패키지
|
||||
1
gui/dialogs/__init__.py
Normal file
1
gui/dialogs/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# gui/dialogs 패키지
|
||||
245
gui/dialogs/image_settings_dialog.py
Normal file
245
gui/dialogs/image_settings_dialog.py
Normal file
@@ -0,0 +1,245 @@
|
||||
# 이미지 설정 다이얼로그 — In-Sight 2000C Telnet 셀 직접 GV/SV
|
||||
from PyQt5.QtWidgets import (
|
||||
QDialog, QFormLayout, QVBoxLayout, QHBoxLayout,
|
||||
QPushButton, QDoubleSpinBox, QSpinBox, QMessageBox, QLabel,
|
||||
)
|
||||
from PyQt5.QtCore import Qt
|
||||
|
||||
|
||||
# (레이블, 셀주소, 위젯종류, min, max, 기본값, 소수점자리)
|
||||
_PARAMS = [
|
||||
("노출 (밀리초)", "D3", "double", 0.01, 1000.0, 30.0, 3),
|
||||
("최대 노출 시간", "F3", "double", 0.01, 1000.0, 950.0, 3),
|
||||
("목표지 이미지 밝기", "G3", "double", 0.0, 255.0, 50.0, 1),
|
||||
("조명 강도", "D6", "int", 0, 100, 70, 0),
|
||||
("초점 위치", "D14", "int", 0, 999, 139, 0),
|
||||
]
|
||||
|
||||
# 스캔할 셀 범위 (A1~J20)
|
||||
_SCAN_COLS = list("ABCDEFGHIJ")
|
||||
_SCAN_ROWS = range(1, 21)
|
||||
|
||||
_STYLE = """
|
||||
QDialog, QWidget {
|
||||
background: #1a1a1a;
|
||||
color: #ffffff;
|
||||
font-size: 14px;
|
||||
}
|
||||
QLabel { color: #cccccc; }
|
||||
QDoubleSpinBox, QSpinBox {
|
||||
background: #2a2a2a;
|
||||
color: #ffffff;
|
||||
border: 1px solid #555555;
|
||||
border-radius: 4px;
|
||||
padding: 4px 8px;
|
||||
min-height: 38px;
|
||||
}
|
||||
QDoubleSpinBox::up-button, QDoubleSpinBox::down-button,
|
||||
QSpinBox::up-button, QSpinBox::down-button {
|
||||
width: 20px;
|
||||
}
|
||||
QPushButton {
|
||||
background: #2e2e2e;
|
||||
color: #ffffff;
|
||||
border: 1px solid #555555;
|
||||
border-radius: 4px;
|
||||
min-height: 56px;
|
||||
padding: 0 24px;
|
||||
font-size: 15px;
|
||||
}
|
||||
QPushButton:hover { background: #3a3a3a; }
|
||||
QPushButton:pressed { background: #1e1e1e; }
|
||||
QPushButton#scan {
|
||||
min-height: 38px;
|
||||
font-size: 13px;
|
||||
color: #aaaaaa;
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
class ImageSettingsDialog(QDialog):
|
||||
def __init__(self, insight_cam, parent=None):
|
||||
super().__init__(parent)
|
||||
self._cam = insight_cam
|
||||
self._widgets = {} # 셀주소 → 위젯
|
||||
self._originals = {} # 셀주소 → 로드 시 원본값
|
||||
|
||||
self.setWindowTitle("이미지 설정 — In-Sight 2000C")
|
||||
self.setMinimumWidth(440)
|
||||
self.setWindowFlags(self.windowFlags() & ~Qt.WindowContextHelpButtonHint)
|
||||
self.setStyleSheet(_STYLE)
|
||||
|
||||
self._build_ui()
|
||||
self._load_values()
|
||||
|
||||
# ================================================================== #
|
||||
# UI 구성
|
||||
# ================================================================== #
|
||||
|
||||
def _build_ui(self):
|
||||
form = QFormLayout()
|
||||
form.setLabelAlignment(Qt.AlignRight)
|
||||
form.setRowWrapPolicy(QFormLayout.DontWrapRows)
|
||||
form.setHorizontalSpacing(20)
|
||||
form.setVerticalSpacing(12)
|
||||
|
||||
for label, cell, kind, lo, hi, default, decimals in _PARAMS:
|
||||
if kind == "double":
|
||||
w = QDoubleSpinBox()
|
||||
w.setRange(lo, hi)
|
||||
w.setDecimals(decimals)
|
||||
w.setValue(default)
|
||||
w.setSingleStep(0.1 if decimals > 0 else 1.0)
|
||||
else:
|
||||
w = QSpinBox()
|
||||
w.setRange(int(lo), int(hi))
|
||||
w.setValue(int(default))
|
||||
|
||||
w.setMinimumWidth(140)
|
||||
self._widgets[cell] = w
|
||||
form.addRow(label, w)
|
||||
|
||||
btn_scan = QPushButton("쓰기 가능한 셀 자동 탐지…")
|
||||
btn_scan.setObjectName("scan")
|
||||
btn_scan.clicked.connect(self._on_scan)
|
||||
|
||||
btn_ok = QPushButton("확인")
|
||||
btn_ok.setDefault(True)
|
||||
btn_ok.clicked.connect(self._on_ok)
|
||||
|
||||
btn_cancel = QPushButton("취소")
|
||||
btn_cancel.clicked.connect(self.reject)
|
||||
|
||||
btn_row = QHBoxLayout()
|
||||
btn_row.addWidget(btn_scan)
|
||||
btn_row.addStretch()
|
||||
btn_row.addWidget(btn_ok)
|
||||
btn_row.addWidget(btn_cancel)
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
root.setContentsMargins(24, 20, 24, 20)
|
||||
root.setSpacing(16)
|
||||
root.addLayout(form)
|
||||
root.addLayout(btn_row)
|
||||
|
||||
# ================================================================== #
|
||||
# 값 로드 — GV{cell}: 응답 "1" + 값
|
||||
# ================================================================== #
|
||||
|
||||
def _load_values(self):
|
||||
for _, cell, kind, _, _, _, _ in _PARAMS:
|
||||
val = self._gv(cell)
|
||||
print(f"[설정창] GV{cell} → {val!r}")
|
||||
w = self._widgets[cell]
|
||||
if val is not None:
|
||||
w.setValue(val if kind == "double" else int(round(val)))
|
||||
self._originals[cell] = val
|
||||
else:
|
||||
self._originals[cell] = None # 조회 실패 → 기본값 유지
|
||||
|
||||
def _gv(self, cell: str):
|
||||
"""GV{cell} 전송 → float 반환, 실패 시 None"""
|
||||
try:
|
||||
self._cam._send(f"GV{cell}")
|
||||
status = self._cam._read_line()
|
||||
if status != "1":
|
||||
return None
|
||||
return float(self._cam._read_line())
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
# ================================================================== #
|
||||
# 셀 자동 탐지 — GV로 읽히고 SV로 쓸 수 있는 셀 목록
|
||||
# ================================================================== #
|
||||
|
||||
def _on_scan(self):
|
||||
"""A1~J20 범위를 스캔해 Online(쓰기 가능) 셀을 찾아 표시."""
|
||||
readable = [] # [(cell, value)]
|
||||
writable = [] # [(cell, value)]
|
||||
|
||||
for col in _SCAN_COLS:
|
||||
for row in _SCAN_ROWS:
|
||||
cell = f"{col}{row}"
|
||||
val = self._gv(cell)
|
||||
if val is None:
|
||||
continue
|
||||
readable.append((cell, val))
|
||||
ok, _ = self._sv(cell, val, "double") # 같은 값 재기입 → 실질적 변경 없음
|
||||
if ok:
|
||||
writable.append((cell, val))
|
||||
|
||||
if writable:
|
||||
lines = "\n".join(f" {c} = {v}" for c, v in writable)
|
||||
msg = (
|
||||
f"쓰기 가능한 셀 {len(writable)}개 발견:\n\n"
|
||||
f"{lines}\n\n"
|
||||
f"image_settings_dialog.py 상단 _PARAMS의\n"
|
||||
f"셀 주소를 위 주소로 변경하세요."
|
||||
)
|
||||
elif readable:
|
||||
lines = "\n".join(f" {c} = {v}" for c, v in readable[:20])
|
||||
msg = (
|
||||
f"읽기 전용 셀 {len(readable)}개 발견 (쓰기 가능 셀 없음):\n\n"
|
||||
f"{lines}\n\n"
|
||||
f"─────────────────────────────\n"
|
||||
f"Cognex In-Sight Explorer에서\n"
|
||||
f"제어하려는 셀을 우클릭 →\n"
|
||||
f"[Cell Properties] → [Online] 체크 후\n"
|
||||
f"job을 카메라에 업로드하세요.\n\n"
|
||||
f"이후 이 버튼을 다시 누르면\n"
|
||||
f"쓰기 가능한 셀 주소를 알 수 있습니다."
|
||||
)
|
||||
else:
|
||||
msg = (
|
||||
f"A1~J20 범위에서 읽을 수 있는 셀이 없습니다.\n\n"
|
||||
f"카메라 연결 상태 또는 job 파일을 확인하세요."
|
||||
)
|
||||
|
||||
QMessageBox.information(self, "셀 탐지 결과", msg)
|
||||
|
||||
# ================================================================== #
|
||||
# 확인 버튼 — 변경된 항목만 SV{cell} {value}
|
||||
# ================================================================== #
|
||||
|
||||
def _on_ok(self):
|
||||
changes = []
|
||||
for _, cell, kind, _, _, _, _ in _PARAMS:
|
||||
w = self._widgets[cell]
|
||||
current = float(w.value())
|
||||
original = self._originals.get(cell)
|
||||
if original is None or abs(current - original) >= 1e-9:
|
||||
changes.append((cell, current, kind))
|
||||
|
||||
if not changes:
|
||||
self.accept()
|
||||
return
|
||||
|
||||
errors = []
|
||||
for cell, value, kind in changes:
|
||||
ok, resp = self._sv(cell, value, kind)
|
||||
if not ok:
|
||||
errors.append((cell, resp))
|
||||
|
||||
if errors:
|
||||
detail = "\n".join(f" • {c} (카메라 응답: {r!r})" for c, r in errors)
|
||||
QMessageBox.critical(
|
||||
self, "설정 실패",
|
||||
f"다음 셀 설정에 실패했습니다:\n{detail}\n\n"
|
||||
f"'쓰기 가능한 셀 자동 탐지' 버튼으로\n"
|
||||
f"현재 Online 셀 주소를 확인하세요.",
|
||||
)
|
||||
return
|
||||
|
||||
self.accept()
|
||||
|
||||
def _sv(self, cell: str, value: float, kind: str):
|
||||
"""SV{cell} {value} 전송 → (성공여부, 응답코드 문자열)"""
|
||||
try:
|
||||
fmt = str(int(round(value))) if kind == "int" else f"{value:.6g}"
|
||||
self._cam._send(f"SV{cell} {fmt}")
|
||||
resp = self._cam._read_line()
|
||||
print(f"[설정창] SV{cell} {fmt} → {resp!r}")
|
||||
return resp.strip() == "1", resp.strip()
|
||||
except Exception as e:
|
||||
print(f"[설정창] SV{cell} 오류: {e}")
|
||||
return False, str(e)
|
||||
195
gui/image_settings_dialog.py
Normal file
195
gui/image_settings_dialog.py
Normal file
@@ -0,0 +1,195 @@
|
||||
from PyQt5.QtWidgets import (
|
||||
QDialog, QFormLayout, QVBoxLayout, QHBoxLayout,
|
||||
QPushButton, QDoubleSpinBox, QSpinBox, QMessageBox, QLabel
|
||||
)
|
||||
from PyQt5.QtCore import Qt
|
||||
|
||||
|
||||
# (레이블, 셀주소, 위젯종류, min, max, 기본값, 소수점자리)
|
||||
# D3(노출)은 AcqExposure() 출력 셀 — 읽기 전용, 목록에서 제외
|
||||
_PARAMS = [
|
||||
("최대 노출 시간", "F3", "double", 0.01, 1000.0, 950.0, 3),
|
||||
("목표 이미지 밝기", "G3", "double", 0.0, 255.0, 50.0, 1),
|
||||
("조명 강도", "D6", "int", 0, 100, 70, 0),
|
||||
("초점 위치", "D14", "int", 0, 999, 139, 0),
|
||||
]
|
||||
|
||||
|
||||
class ImageSettingsDialog(QDialog):
|
||||
def __init__(self, insight_cam, parent=None):
|
||||
super().__init__(parent)
|
||||
self._cam = insight_cam
|
||||
self.setWindowTitle("이미지 설정 — In-Sight 2000C")
|
||||
self.setMinimumWidth(360)
|
||||
self.setWindowFlags(self.windowFlags() & ~Qt.WindowContextHelpButtonHint)
|
||||
|
||||
self._widgets = {} # 셀주소 → 위젯
|
||||
self._originals = {} # 셀주소 → 로드 시 원본값
|
||||
|
||||
self._build_ui()
|
||||
self._load_values()
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# UI 구성
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def _build_ui(self):
|
||||
form = QFormLayout()
|
||||
form.setLabelAlignment(Qt.AlignRight)
|
||||
form.setRowWrapPolicy(QFormLayout.DontWrapRows)
|
||||
form.setHorizontalSpacing(16)
|
||||
form.setVerticalSpacing(10)
|
||||
|
||||
# 노출(D3)은 읽기 전용 표시
|
||||
self._exposure_label = QLabel("—")
|
||||
self._exposure_label.setStyleSheet("color: gray;")
|
||||
form.addRow("노출 (밀리초, 읽기 전용):", self._exposure_label)
|
||||
|
||||
for label, cell, kind, lo, hi, default, decimals in _PARAMS:
|
||||
if kind == "double":
|
||||
w = QDoubleSpinBox()
|
||||
w.setRange(lo, hi)
|
||||
w.setDecimals(decimals)
|
||||
w.setValue(default)
|
||||
w.setSingleStep(0.1)
|
||||
else:
|
||||
w = QSpinBox()
|
||||
w.setRange(int(lo), int(hi))
|
||||
w.setValue(int(default))
|
||||
|
||||
w.setMinimumWidth(120)
|
||||
self._widgets[cell] = w
|
||||
form.addRow(label, w)
|
||||
|
||||
btn_ok = QPushButton("확인")
|
||||
btn_ok.setDefault(True)
|
||||
btn_ok.clicked.connect(self._on_ok)
|
||||
|
||||
btn_cancel = QPushButton("취소")
|
||||
btn_cancel.clicked.connect(self.reject)
|
||||
|
||||
btn_row = QHBoxLayout()
|
||||
btn_row.addStretch()
|
||||
btn_row.addWidget(btn_ok)
|
||||
btn_row.addWidget(btn_cancel)
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
root.addLayout(form)
|
||||
root.addSpacing(8)
|
||||
root.addLayout(btn_row)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# 값 로드 — GV{cell} 명령, 응답 2줄(상태코드 + 값)
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def _load_values(self):
|
||||
# D3: 읽기 전용 표시
|
||||
exp = self._gv("D3")
|
||||
print(f"[설정창] GVD3 → {exp!r}")
|
||||
if exp is not None:
|
||||
self._exposure_label.setText(f"{exp:.3f} ms")
|
||||
|
||||
for _, cell, kind, _, _, _, _ in _PARAMS:
|
||||
val = self._gv(cell)
|
||||
print(f"[설정창] GV{cell} → {val!r}")
|
||||
w = self._widgets[cell]
|
||||
if val is not None:
|
||||
if kind == "double":
|
||||
w.setValue(val)
|
||||
else:
|
||||
w.setValue(int(round(val)))
|
||||
self._originals[cell] = val
|
||||
else:
|
||||
self._originals[cell] = None
|
||||
|
||||
def _gv(self, cell: str):
|
||||
"""GV{cell} 전송 → 숫자 반환, 실패 시 None"""
|
||||
try:
|
||||
self._cam._send(f"GV{cell}")
|
||||
status = self._cam._read_line() # "1" 또는 "0"
|
||||
if status != "1":
|
||||
print(f"[설정창] GV{cell} 상태코드: {status!r}")
|
||||
return None
|
||||
raw = self._cam._read_line() # 실제 값
|
||||
return float(raw)
|
||||
except Exception as e:
|
||||
print(f"[설정창] GV{cell} 오류: {e}")
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# 확인 버튼 — SV{cell} {value} 명령
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def _on_ok(self):
|
||||
import time
|
||||
|
||||
# 변경 항목 수집
|
||||
changes = []
|
||||
for _, cell, kind, _, _, _, _ in _PARAMS:
|
||||
w = self._widgets[cell]
|
||||
current = float(w.value())
|
||||
original = self._originals.get(cell)
|
||||
if original is None or abs(current - original) >= 1e-9:
|
||||
changes.append((cell, current, kind))
|
||||
|
||||
if not changes:
|
||||
self.accept()
|
||||
return
|
||||
|
||||
# 오프라인 전환
|
||||
self._cam._send("SO0")
|
||||
resp = self._cam._read_line()
|
||||
print(f"[설정창] SO0 (오프라인) → {resp!r}")
|
||||
if resp.strip() != "1":
|
||||
QMessageBox.critical(self, "오류", f"오프라인 전환 실패 (응답: {resp!r})")
|
||||
return
|
||||
|
||||
time.sleep(0.3) # 카메라가 오프라인 전환 완료 대기
|
||||
|
||||
# 값 설정
|
||||
errors = []
|
||||
for cell, value, kind in changes:
|
||||
if not self._sv(cell, value, kind):
|
||||
errors.append(cell)
|
||||
|
||||
time.sleep(0.2) # SV 처리 완료 대기
|
||||
|
||||
# 온라인 복귀 — 최대 3회 재시도
|
||||
online_ok = False
|
||||
for attempt in range(3):
|
||||
self._cam._send("SO1")
|
||||
resp = self._cam._read_line()
|
||||
print(f"[설정창] SO1 (온라인) 시도 {attempt+1} → {resp!r}")
|
||||
if resp.strip() == "1":
|
||||
online_ok = True
|
||||
break
|
||||
time.sleep(0.5)
|
||||
|
||||
if not online_ok:
|
||||
QMessageBox.warning(
|
||||
self, "온라인 복귀 실패",
|
||||
f"SO1 명령이 실패했습니다 (마지막 응답: {resp!r})\n"
|
||||
"카메라를 수동으로 재시작하거나 In-Sight Explorer에서 Online으로 전환하세요."
|
||||
)
|
||||
|
||||
if errors:
|
||||
QMessageBox.critical(
|
||||
self, "설정 실패",
|
||||
f"다음 셀 설정 실패:\n{chr(10).join(errors)}\n\n"
|
||||
"셀이 읽기 전용이거나 값 범위를 벗어났을 수 있습니다."
|
||||
)
|
||||
return
|
||||
|
||||
self.accept()
|
||||
|
||||
def _sv(self, cell: str, value: float, kind: str) -> bool:
|
||||
"""SV{cell} {value} 전송 → 응답 '1'이면 True"""
|
||||
try:
|
||||
formatted = str(int(round(value))) if kind == "int" else f"{value:.6g}"
|
||||
self._cam._send(f"SV{cell} {formatted}")
|
||||
resp = self._cam._read_line()
|
||||
print(f"[설정창] SV{cell} {formatted} → {resp!r}")
|
||||
return resp.strip() == "1"
|
||||
except Exception as e:
|
||||
print(f"[설정창] SV{cell} 오류: {e}")
|
||||
return False
|
||||
361
gui/main_window.py
Normal file
361
gui/main_window.py
Normal file
@@ -0,0 +1,361 @@
|
||||
# 메인 윈도우 — 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;"
|
||||
)
|
||||
|
||||
1
gui/pages/__init__.py
Normal file
1
gui/pages/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# gui/pages 패키지
|
||||
726
gui/pages/inspect_page.py
Normal file
726
gui/pages/inspect_page.py
Normal file
@@ -0,0 +1,726 @@
|
||||
# 검사 페이지 — 코그넥스/Basler 영상, 그룹 A/B 설정, Pass/Fail 표시
|
||||
import time
|
||||
import threading
|
||||
import itertools
|
||||
import cv2
|
||||
import numpy as np
|
||||
from PyQt5.QtCore import Qt, QThread, pyqtSignal, QSize
|
||||
from PyQt5.QtGui import QImage, QPixmap
|
||||
from PyQt5.QtWidgets import (
|
||||
QWidget, QHBoxLayout, QVBoxLayout, QGroupBox,
|
||||
QPushButton, QLabel, QCheckBox, QFrame,
|
||||
QGridLayout, QSizePolicy,
|
||||
)
|
||||
|
||||
from logic.inspector import Inspector
|
||||
from logic.group_manager import GroupManager
|
||||
from logger import log_inspect_result, log_camera_timing, log_action, log_defect_image
|
||||
|
||||
_DEFECT_COLORS = {
|
||||
"스크래치": (0, 0, 255),
|
||||
"이물": (0, 165, 255),
|
||||
"흑점": (128, 0, 128),
|
||||
"변형": (255, 165, 0 ),
|
||||
}
|
||||
|
||||
|
||||
def _draw_detections(frame: np.ndarray, detections: list) -> np.ndarray:
|
||||
"""frame 복사본에 BBox 오버레이를 그려 반환."""
|
||||
img = frame.copy()
|
||||
for det in detections:
|
||||
x1, y1, x2, y2 = [int(v) for v in det["bbox"]]
|
||||
name = det["class_name"]
|
||||
conf = det["confidence"]
|
||||
color = _DEFECT_COLORS.get(name, (0, 255, 0))
|
||||
cv2.rectangle(img, (x1, y1), (x2, y2), color, 2)
|
||||
cv2.putText(
|
||||
img, f"{name} {conf:.0%}",
|
||||
(x1, max(y1 - 8, 0)),
|
||||
cv2.FONT_HERSHEY_SIMPLEX, 0.6, color, 2,
|
||||
)
|
||||
return img
|
||||
|
||||
# 검사 그룹 A/B 선택용 모델 목록
|
||||
_MODELS = [
|
||||
"LOW REF / LX3 / RH",
|
||||
"LOW REF / LX3 / LH",
|
||||
"LOW REF NAS / LX3 / RH",
|
||||
"LOW REF NAS / LX3 / LH",
|
||||
"LOW REF NAS / MX5a 2.0TH / RH",
|
||||
"LOW REF NAS / MX5a 2.0TH / LH",
|
||||
"HIGH REF / LX3 / RH",
|
||||
"HIGH REF / LX3 / LH",
|
||||
"LOW REF NAS 1.5 GEN / CN7 PE / RH",
|
||||
"LOW REF DOM 1.5 GEN / CN7 PE / LH",
|
||||
]
|
||||
|
||||
_MODEL_ID_MAP = {
|
||||
"LOW REF / LX3 / RH": 1,
|
||||
"LOW REF / LX3 / LH": 2,
|
||||
"LOW REF NAS / LX3 / RH": 3,
|
||||
"LOW REF NAS / LX3 / LH": 4,
|
||||
"LOW REF NAS / MX5a 2.0TH / RH": 5,
|
||||
"LOW REF NAS / MX5a 2.0TH / LH": 6,
|
||||
"HIGH REF / LX3 / RH": 7,
|
||||
"HIGH REF / LX3 / LH": 8,
|
||||
"LOW REF NAS 1.5 GEN / CN7 PE / RH": 9,
|
||||
"LOW REF DOM 1.5 GEN / CN7 PE / LH": 10,
|
||||
}
|
||||
|
||||
|
||||
# ================================================================== #
|
||||
# 백그라운드 워커 — 파이프라인 방식
|
||||
#
|
||||
# [Cognex 서브스레드] trigger → sleep(1.0) → FTP(영구세션) → PatMax
|
||||
# ↕ 병렬 ↕ join
|
||||
# [워커 메인] sleep(belt_delay) → Basler 캡처 → 판정 → emit
|
||||
#
|
||||
# belt_delay = 카메라 간 거리(cm) / 벨트 속도(cm/s)
|
||||
# 두 작업이 동시에 시작되어 같은 제품을 각 위치에서 촬영함.
|
||||
# ================================================================== #
|
||||
|
||||
class InspectWorker(QThread):
|
||||
cognex_image_ready = pyqtSignal(bytes) # raw BMP/JPG 바이트
|
||||
basler_image_ready = pyqtSignal(object, list) # (ndarray, detections)
|
||||
result_ready = pyqtSignal(dict)
|
||||
|
||||
def __init__(self, insight, basler, inspector, groups,
|
||||
belt_delay: float = 3.33, parent=None):
|
||||
super().__init__(parent)
|
||||
self._insight = insight
|
||||
self._basler = basler
|
||||
self._inspector = inspector
|
||||
self._groups = groups
|
||||
self.detector = None
|
||||
self.matcher = None # PatternMatcher — InspectPage에서 주입
|
||||
self._belt_delay = belt_delay
|
||||
self._stop_flag = False
|
||||
self._pause_flag = False
|
||||
self._seq = itertools.count(1)
|
||||
|
||||
# ── 외부 제어 ──────────────────────────────────────────────────── #
|
||||
|
||||
def stop(self):
|
||||
self._stop_flag = True
|
||||
|
||||
def pause(self):
|
||||
self._pause_flag = True
|
||||
|
||||
def resume(self):
|
||||
self._pause_flag = False
|
||||
|
||||
def set_belt_delay(self, delay: float):
|
||||
self._belt_delay = delay
|
||||
|
||||
# ── 스레드 본체 ────────────────────────────────────────────────── #
|
||||
|
||||
def run(self):
|
||||
self._stop_flag = False
|
||||
self._pause_flag = False
|
||||
while not self._stop_flag:
|
||||
if self._pause_flag:
|
||||
time.sleep(0.1)
|
||||
continue
|
||||
self._do_one_cycle()
|
||||
|
||||
# ── 검사 1사이클 ───────────────────────────────────────────────── #
|
||||
|
||||
def _do_one_cycle(self):
|
||||
group = self._groups.get_active_name()
|
||||
seq = next(self._seq)
|
||||
trigger_time = time.perf_counter()
|
||||
|
||||
def _ms() -> float:
|
||||
return (time.perf_counter() - trigger_time) * 1000
|
||||
|
||||
log_camera_timing(seq, "cycle_start", 0.0, f"group={group} belt_delay={self._belt_delay:.2f}s")
|
||||
|
||||
# ── Cognex 작업: 서브 스레드에서 병렬 실행 ──
|
||||
cognex_out: dict = {}
|
||||
|
||||
def _cognex_work():
|
||||
try:
|
||||
log_camera_timing(seq, "cognex_trigger_send", _ms())
|
||||
ok = self._insight.software_trigger()
|
||||
log_camera_timing(seq, f"cognex_trigger_{'ok' if ok else 'fail'}", _ms())
|
||||
if not ok:
|
||||
cognex_out["error"] = "trigger_failed"
|
||||
return
|
||||
time.sleep(1.0)
|
||||
log_camera_timing(seq, "cognex_ftp_start", _ms())
|
||||
raw = self._insight.get_image()
|
||||
log_camera_timing(
|
||||
seq, "cognex_ftp_done", _ms(),
|
||||
f"{len(raw)}bytes" if raw else "empty",
|
||||
)
|
||||
if raw:
|
||||
self.cognex_image_ready.emit(raw)
|
||||
log_camera_timing(seq, "cognex_patmax_start", _ms())
|
||||
# Cognex job 파일 결과 (항상)
|
||||
gv_results = self._inspector.read_patmax_results(self._insight)
|
||||
# Python ORB 결과 (추가 등록 제품, 있을 때만)
|
||||
py_results = {}
|
||||
if self.matcher and self.matcher.registered_ids and raw:
|
||||
py_results = self._inspector.match_image(raw, self.matcher)
|
||||
cognex_out["results"] = {**gv_results, **py_results}
|
||||
log_camera_timing(seq, "cognex_patmax_done", _ms())
|
||||
except Exception as e:
|
||||
print(f"[워커] Cognex 서브스레드 오류: {e}")
|
||||
cognex_out["error"] = str(e)
|
||||
log_camera_timing(seq, "cognex_error", _ms(), str(e))
|
||||
|
||||
ct = threading.Thread(target=_cognex_work, daemon=True)
|
||||
ct.start()
|
||||
|
||||
# ── Basler: trigger 시점 기준 belt_delay 후 캡처 ──
|
||||
elapsed = time.perf_counter() - trigger_time
|
||||
remaining = self._belt_delay - elapsed
|
||||
if remaining > 0:
|
||||
time.sleep(remaining)
|
||||
|
||||
basler_pass = True
|
||||
basler_detections = []
|
||||
try:
|
||||
log_camera_timing(seq, "basler_capture_start", _ms())
|
||||
frame = self._basler.capture()
|
||||
log_camera_timing(
|
||||
seq, "basler_capture_done", _ms(),
|
||||
f"{frame.shape}" if frame is not None else "failed",
|
||||
)
|
||||
if frame is not None:
|
||||
if self.detector and self.detector.is_loaded():
|
||||
detections = self.detector.detect(frame)
|
||||
defects = [d for d in detections if d["confidence"] >= 0.5]
|
||||
basler_pass = len(defects) == 0
|
||||
basler_detections = defects
|
||||
if defects:
|
||||
print(f"[워커] 불량 감지: {[d['class_name'] for d in defects]}")
|
||||
annotated = _draw_detections(frame, defects)
|
||||
log_defect_image(annotated, defects)
|
||||
self.basler_image_ready.emit(frame, basler_detections)
|
||||
except Exception as e:
|
||||
print(f"[워커 오류] Basler: {e}")
|
||||
log_camera_timing(seq, "basler_error", _ms(), str(e))
|
||||
|
||||
# ── Cognex 서브스레드 완료 대기 ──
|
||||
log_camera_timing(seq, "cognex_join_wait", _ms())
|
||||
ct.join(timeout=10.0)
|
||||
log_camera_timing(seq, "cognex_join_done", _ms())
|
||||
|
||||
# ── 모델 판별 ──
|
||||
results = cognex_out.get("results", {})
|
||||
active_names = self._groups.get_active_group()
|
||||
allowed_ids = [_MODEL_ID_MAP[n] for n in active_names if n in _MODEL_ID_MAP]
|
||||
result_info = {
|
||||
"matched": False, "in_allowed": False,
|
||||
"model": None, "score": 0.0,
|
||||
"cognex_pass": False, "status": "인식 불가",
|
||||
}
|
||||
try:
|
||||
if results:
|
||||
result_info = self._inspector.identify_model(results, allowed_ids)
|
||||
except Exception as e:
|
||||
print(f"[워커 오류] 모델 판별: {e}")
|
||||
|
||||
cognex_pass = result_info["cognex_pass"]
|
||||
matched = result_info["matched"]
|
||||
final = self._inspector.judge(cognex_pass, basler_pass)
|
||||
|
||||
self.result_ready.emit({
|
||||
"group": group,
|
||||
"matched": matched,
|
||||
"result": final,
|
||||
"cognex_pass": cognex_pass,
|
||||
"basler_pass": basler_pass,
|
||||
"result_info": result_info,
|
||||
})
|
||||
|
||||
log_camera_timing(
|
||||
seq, "cycle_done", _ms(),
|
||||
f"result={final} cognex={'PASS' if cognex_pass else 'FAIL'} basler={'PASS' if basler_pass else 'FAIL'}",
|
||||
)
|
||||
|
||||
try:
|
||||
log_inspect_result(
|
||||
group=group,
|
||||
result=("UNKNOWN" if not matched else final),
|
||||
cognex_pass=cognex_pass,
|
||||
basler_pass=basler_pass,
|
||||
detected=[result_info["model"]] if result_info.get("model") else None,
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"[워커 오류] 로그 기록: {e}")
|
||||
|
||||
|
||||
# ================================================================== #
|
||||
# 검사 페이지
|
||||
# ================================================================== #
|
||||
|
||||
class InspectPage(QWidget):
|
||||
def __init__(self, insight_cam, basler_cam, detector=None,
|
||||
belt_delay: float = 3.33, parent=None):
|
||||
super().__init__(parent)
|
||||
self._insight = insight_cam
|
||||
self._basler = basler_cam
|
||||
self.detector = detector
|
||||
self._inspector = Inspector()
|
||||
self._groups = GroupManager()
|
||||
|
||||
self._counts = {
|
||||
"A": {"total": 0, "pass": 0, "fail": 0, "unknown": 0},
|
||||
"B": {"total": 0, "pass": 0, "fail": 0, "unknown": 0},
|
||||
}
|
||||
|
||||
self._matcher = None
|
||||
|
||||
self._worker = InspectWorker(
|
||||
self._insight, self._basler, self._inspector, self._groups,
|
||||
belt_delay=belt_delay,
|
||||
)
|
||||
self._worker.detector = self.detector
|
||||
self._worker.matcher = self._matcher
|
||||
self._worker.cognex_image_ready.connect(self._display_cognex_image)
|
||||
self._worker.basler_image_ready.connect(self._on_basler_ready)
|
||||
self._worker.result_ready.connect(self._on_result)
|
||||
|
||||
self._build_ui()
|
||||
|
||||
# ================================================================== #
|
||||
# 최상위 레이아웃
|
||||
# ================================================================== #
|
||||
|
||||
def _build_ui(self):
|
||||
root = QVBoxLayout(self)
|
||||
root.setContentsMargins(0, 0, 0, 0)
|
||||
root.setSpacing(0)
|
||||
root.addWidget(self._build_top(), stretch=7)
|
||||
root.addWidget(self._build_bottom(), stretch=3)
|
||||
|
||||
# ================================================================== #
|
||||
# 상단: 코그넥스 (좌 50 %) / Basler (우 50 %)
|
||||
# ================================================================== #
|
||||
|
||||
def _build_top(self) -> QWidget:
|
||||
w = QWidget()
|
||||
w.setStyleSheet("background:#0d0d0d;")
|
||||
layout = QHBoxLayout(w)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
|
||||
layout.addLayout(self._build_cam_col("■ In-Sight 2000C", "#4488ff", "_cognex_label"), stretch=1)
|
||||
layout.addWidget(_vline())
|
||||
layout.addLayout(self._build_cam_col("■ Basler USB", "#44cc88", "_basler_label"), stretch=1)
|
||||
return w
|
||||
|
||||
def _build_cam_col(self, title: str, color: str, attr: str) -> QVBoxLayout:
|
||||
col = QVBoxLayout()
|
||||
col.setContentsMargins(0, 0, 0, 0)
|
||||
col.setSpacing(0)
|
||||
|
||||
title_lbl = QLabel(title)
|
||||
title_lbl.setFixedHeight(32)
|
||||
title_lbl.setStyleSheet(
|
||||
f"color:{color}; font-size:15px; font-weight:bold;"
|
||||
"padding-left:10px; background:#111111;"
|
||||
)
|
||||
|
||||
img_lbl = QLabel()
|
||||
img_lbl.setAlignment(Qt.AlignCenter)
|
||||
img_lbl.setStyleSheet("background:#0d0d0d;")
|
||||
img_lbl.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||
img_lbl.setMinimumSize(640, 480)
|
||||
img_lbl.setScaledContents(False)
|
||||
setattr(self, attr, img_lbl)
|
||||
|
||||
col.addWidget(title_lbl)
|
||||
col.addWidget(img_lbl, stretch=1)
|
||||
return col
|
||||
|
||||
# ================================================================== #
|
||||
# 하단: 3열
|
||||
# ================================================================== #
|
||||
|
||||
def _build_bottom(self) -> QWidget:
|
||||
w = QWidget()
|
||||
w.setStyleSheet("background:#1a1a1a;")
|
||||
layout = QHBoxLayout(w)
|
||||
layout.setContentsMargins(8, 6, 8, 6)
|
||||
layout.setSpacing(0)
|
||||
|
||||
layout.addWidget(self._build_col_groups(), stretch=5)
|
||||
layout.addWidget(_vline())
|
||||
layout.addWidget(self._build_col_controls(), stretch=3)
|
||||
layout.addWidget(_vline())
|
||||
layout.addWidget(self._build_col_counters(), stretch=4)
|
||||
return w
|
||||
|
||||
def _build_col_groups(self) -> QWidget:
|
||||
w = QWidget()
|
||||
layout = QVBoxLayout(w)
|
||||
layout.setContentsMargins(4, 4, 8, 4)
|
||||
layout.setSpacing(6)
|
||||
|
||||
checks_row = QHBoxLayout()
|
||||
checks_row.setSpacing(8)
|
||||
checks_row.addWidget(self._build_group_section("A"), stretch=1)
|
||||
checks_row.addWidget(self._build_group_section("B"), stretch=1)
|
||||
layout.addLayout(checks_row, stretch=1)
|
||||
|
||||
self._switch_btn = QPushButton("현재: 그룹 A 활성 → B로 전환")
|
||||
self._switch_btn.setFixedHeight(56)
|
||||
self._switch_btn.setStyleSheet(
|
||||
"background:#1a3a5c; color:#ffffff; border:none; border-radius:4px;"
|
||||
"font-size:14px; font-weight:bold;"
|
||||
)
|
||||
self._switch_btn.clicked.connect(self._on_switch)
|
||||
layout.addWidget(self._switch_btn)
|
||||
return w
|
||||
|
||||
def _build_group_section(self, name: str) -> QGroupBox:
|
||||
active_color = "#4488ff" if name == "A" else "#cc8844"
|
||||
g = QGroupBox(f"그룹 {name} (최대 4종)")
|
||||
g.setStyleSheet(
|
||||
f"QGroupBox {{ background:#222222; border:1px solid #333333; border-radius:6px;"
|
||||
f" margin-top:12px; padding:6px 4px 4px 4px; }}"
|
||||
f"QGroupBox::title {{ color:{active_color}; subcontrol-origin:margin;"
|
||||
f" left:8px; font-size:13px; font-weight:bold; }}"
|
||||
)
|
||||
layout = QVBoxLayout(g)
|
||||
layout.setSpacing(1)
|
||||
layout.setContentsMargins(4, 2, 4, 2)
|
||||
|
||||
checks = []
|
||||
for model in _MODELS:
|
||||
cb = QCheckBox(model)
|
||||
cb.setStyleSheet(
|
||||
"QCheckBox { font-size:12px; min-height:24px; color:#cccccc; }"
|
||||
"QCheckBox::indicator { width:16px; height:16px; }"
|
||||
)
|
||||
checks.append(cb)
|
||||
layout.addWidget(cb)
|
||||
|
||||
if name == "A":
|
||||
self._group_a_checks = checks
|
||||
for cb in checks:
|
||||
cb.clicked.connect(lambda checked, c=cb: self._on_group_changed("A", c, checked))
|
||||
else:
|
||||
self._group_b_checks = checks
|
||||
for cb in checks:
|
||||
cb.clicked.connect(lambda checked, c=cb: self._on_group_changed("B", c, checked))
|
||||
|
||||
return g
|
||||
|
||||
def _build_col_controls(self) -> QWidget:
|
||||
w = QWidget()
|
||||
layout = QVBoxLayout(w)
|
||||
layout.setContentsMargins(12, 4, 12, 4)
|
||||
layout.setSpacing(6)
|
||||
|
||||
self._start_btn = QPushButton("검사 시작")
|
||||
self._start_btn.setFixedHeight(70)
|
||||
self._start_btn.setStyleSheet(
|
||||
"background:#1a5c1a; color:#ffffff; border:none; border-radius:4px;"
|
||||
"font-size:18px; font-weight:bold;"
|
||||
)
|
||||
self._start_btn.clicked.connect(self._on_start)
|
||||
|
||||
self._pause_btn = QPushButton("일시 정지")
|
||||
self._pause_btn.setFixedHeight(70)
|
||||
self._pause_btn.setEnabled(False)
|
||||
self._pause_btn.setStyleSheet(
|
||||
"background:#5c5500; color:#ffffff; border:none; border-radius:4px;"
|
||||
"font-size:18px; font-weight:bold;"
|
||||
)
|
||||
self._pause_btn.clicked.connect(self._on_pause)
|
||||
|
||||
self._active_lbl = QLabel("활성 그룹: A")
|
||||
self._active_lbl.setAlignment(Qt.AlignCenter)
|
||||
self._active_lbl.setStyleSheet("font-size:14px; color:#aaaaaa;")
|
||||
|
||||
self._model_lbl = QLabel("인식 모델: —")
|
||||
self._model_lbl.setAlignment(Qt.AlignCenter)
|
||||
self._model_lbl.setWordWrap(True)
|
||||
self._model_lbl.setStyleSheet("font-size:13px; color:#cccccc;")
|
||||
|
||||
self._belt_lbl = QLabel(
|
||||
f"벨트 딜레이: {self._worker._belt_delay:.2f}s"
|
||||
)
|
||||
self._belt_lbl.setAlignment(Qt.AlignCenter)
|
||||
self._belt_lbl.setStyleSheet("font-size:12px; color:#666666;")
|
||||
|
||||
self._result_lbl = QLabel("대기 중")
|
||||
self._result_lbl.setAlignment(Qt.AlignCenter)
|
||||
self._result_lbl.setStyleSheet(
|
||||
"font-size:48px; font-weight:bold; background:#2a2a2a;"
|
||||
"color:#666666; border-radius:8px;"
|
||||
)
|
||||
self._result_lbl.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||
|
||||
layout.addWidget(self._start_btn)
|
||||
layout.addWidget(self._pause_btn)
|
||||
layout.addWidget(self._active_lbl)
|
||||
layout.addWidget(self._model_lbl)
|
||||
layout.addWidget(self._belt_lbl)
|
||||
layout.addWidget(self._result_lbl, stretch=1)
|
||||
return w
|
||||
|
||||
def _build_col_counters(self) -> QWidget:
|
||||
w = QWidget()
|
||||
layout = QVBoxLayout(w)
|
||||
layout.setContentsMargins(12, 4, 8, 4)
|
||||
layout.setSpacing(6)
|
||||
|
||||
grid = QGridLayout()
|
||||
grid.setSpacing(4)
|
||||
|
||||
for col, text in enumerate(["", "그룹 A", "그룹 B"]):
|
||||
lbl = QLabel(text)
|
||||
lbl.setAlignment(Qt.AlignCenter)
|
||||
lbl.setStyleSheet("font-size:13px; color:#888888; font-weight:bold;")
|
||||
grid.addWidget(lbl, 0, col)
|
||||
|
||||
row_defs = [
|
||||
("전체", "total", "#ffffff"),
|
||||
("양품", "pass", "#22cc55"),
|
||||
("불량", "fail", "#cc2222"),
|
||||
("미인식", "unknown", "#ff9900"),
|
||||
]
|
||||
self._cnt_lbls = {}
|
||||
for r, (display, key, color) in enumerate(row_defs, start=1):
|
||||
row_lbl = QLabel(display)
|
||||
row_lbl.setStyleSheet(f"font-size:14px; color:{color}; font-weight:bold;")
|
||||
grid.addWidget(row_lbl, r, 0)
|
||||
|
||||
self._cnt_lbls[key] = {}
|
||||
for g_col, group in enumerate(["A", "B"], start=1):
|
||||
lbl = QLabel("0")
|
||||
lbl.setAlignment(Qt.AlignCenter)
|
||||
lbl.setStyleSheet(
|
||||
f"font-size:36px; font-weight:bold; color:{color};"
|
||||
"background:#222222; border-radius:4px; padding:2px 6px;"
|
||||
)
|
||||
grid.addWidget(lbl, r, g_col)
|
||||
self._cnt_lbls[key][group] = lbl
|
||||
|
||||
layout.addLayout(grid)
|
||||
|
||||
btn_reset = QPushButton("카운터 초기화")
|
||||
btn_reset.setFixedHeight(56)
|
||||
btn_reset.setStyleSheet(
|
||||
"background:#3a1a1a; color:#ffffff; border:none; border-radius:4px; font-size:14px;"
|
||||
)
|
||||
btn_reset.clicked.connect(self._on_reset)
|
||||
layout.addWidget(btn_reset)
|
||||
layout.addStretch()
|
||||
return w
|
||||
|
||||
# ================================================================== #
|
||||
# 슬롯 — UI 이벤트
|
||||
# ================================================================== #
|
||||
|
||||
def _on_group_changed(self, group: str, changed_cb: QCheckBox, is_checked: bool):
|
||||
checks = self._group_a_checks if group == "A" else self._group_b_checks
|
||||
if is_checked and sum(1 for c in checks if c.isChecked()) > GroupManager.MAX_PER_GROUP:
|
||||
changed_cb.setChecked(False)
|
||||
return
|
||||
models = [c.text() for c in checks if c.isChecked()]
|
||||
if group == "A":
|
||||
self._groups.set_group_a(models)
|
||||
else:
|
||||
self._groups.set_group_b(models)
|
||||
|
||||
def _on_switch(self):
|
||||
active = self._groups.switch_group()
|
||||
other = "B" if active == "A" else "A"
|
||||
self._switch_btn.setText(f"현재: 그룹 {active} 활성 → {other}로 전환")
|
||||
self._active_lbl.setText(f"활성 그룹: {active}")
|
||||
print(f"[검사] 그룹 전환 → 활성 그룹 {active}")
|
||||
|
||||
def _on_start(self):
|
||||
if self._worker.isRunning():
|
||||
return
|
||||
log_action("[검사] 검사 시작")
|
||||
self._start_btn.setEnabled(False)
|
||||
self._pause_btn.setEnabled(True)
|
||||
self._pause_btn.setText("일시 정지")
|
||||
self._worker.start()
|
||||
|
||||
def _on_pause(self):
|
||||
if self._worker.isRunning() and not self._worker._pause_flag:
|
||||
self._worker.pause()
|
||||
self._pause_btn.setText("재개")
|
||||
self._start_btn.setEnabled(True)
|
||||
log_action("[검사] 일시 정지")
|
||||
else:
|
||||
self._worker.resume()
|
||||
self._pause_btn.setText("일시 정지")
|
||||
self._start_btn.setEnabled(False)
|
||||
log_action("[검사] 검사 재개")
|
||||
|
||||
def _on_reset(self):
|
||||
log_action("[검사] 카운트 리셋")
|
||||
for key in ("total", "pass", "fail", "unknown"):
|
||||
for g in ("A", "B"):
|
||||
self._counts[g][key] = 0
|
||||
self._cnt_lbls[key][g].setText("0")
|
||||
|
||||
def closeEvent(self, event):
|
||||
self._worker.stop()
|
||||
self._worker.wait(3000)
|
||||
super().closeEvent(event)
|
||||
|
||||
# ================================================================== #
|
||||
# 외부에서 호출하는 업데이트 메서드
|
||||
# ================================================================== #
|
||||
|
||||
def update_detector(self, detector):
|
||||
self.detector = detector
|
||||
self._worker.detector = detector
|
||||
print(f"[검사] AI 모델 업데이트: {detector.model_path if detector else None}")
|
||||
|
||||
def update_matcher(self, matcher):
|
||||
self._matcher = matcher
|
||||
self._worker.matcher = matcher
|
||||
n = len(matcher.registered_ids) if matcher else 0
|
||||
print(f"[검사] PatternMatcher 업데이트: {n}개 패턴")
|
||||
|
||||
def update_insight(self, new_insight):
|
||||
"""카메라 재연결 시 워커에도 반영."""
|
||||
self._insight = new_insight
|
||||
self._worker._insight = new_insight
|
||||
|
||||
def update_basler(self, new_basler):
|
||||
self._basler = new_basler
|
||||
self._worker._basler = new_basler
|
||||
|
||||
def update_belt_delay(self, delay: float):
|
||||
"""설정 페이지에서 컨베이어 값 변경 시 호출."""
|
||||
self._worker.set_belt_delay(delay)
|
||||
self._belt_lbl.setText(f"벨트 딜레이: {delay:.2f}s")
|
||||
|
||||
# ================================================================== #
|
||||
# 워커 signal 슬롯 (메인 스레드)
|
||||
# ================================================================== #
|
||||
|
||||
def _on_basler_ready(self, frame, detections):
|
||||
self._display_basler_image(frame, detections=detections)
|
||||
|
||||
def _on_result(self, data: dict):
|
||||
group = data["group"]
|
||||
matched = data["matched"]
|
||||
result = data["result"]
|
||||
cognex_pass = data["cognex_pass"]
|
||||
basler_pass = data["basler_pass"]
|
||||
result_info = data["result_info"]
|
||||
|
||||
self._model_lbl.setText(result_info["status"])
|
||||
|
||||
self._counts[group]["total"] += 1
|
||||
if not matched:
|
||||
self._counts[group]["unknown"] += 1
|
||||
elif result == "PASS":
|
||||
self._counts[group]["pass"] += 1
|
||||
else:
|
||||
self._counts[group]["fail"] += 1
|
||||
for key in ("total", "pass", "fail", "unknown"):
|
||||
self._cnt_lbls[key][group].setText(str(self._counts[group][key]))
|
||||
|
||||
if not matched:
|
||||
self._set_result("미인식", "#332200", "#ff9900")
|
||||
elif result == "PASS":
|
||||
self._set_result("PASS", "#003300", "#22ff55")
|
||||
else:
|
||||
self._set_result("FAIL", "#330000", "#ff2222")
|
||||
|
||||
# ================================================================== #
|
||||
# 이미지 표시 헬퍼
|
||||
# ================================================================== #
|
||||
|
||||
def _set_result(self, text: str, bg: str, fg: str):
|
||||
self._result_lbl.setText(text)
|
||||
self._result_lbl.setStyleSheet(
|
||||
f"font-size:48px; font-weight:bold; background:{bg};"
|
||||
f"color:{fg}; border-radius:8px;"
|
||||
)
|
||||
|
||||
def _display_cognex_image(self, raw_data: bytes):
|
||||
try:
|
||||
arr = np.frombuffer(raw_data, dtype=np.uint8)
|
||||
img = cv2.imdecode(arr, cv2.IMREAD_COLOR)
|
||||
if img is None:
|
||||
img = cv2.imdecode(arr, cv2.IMREAD_UNCHANGED)
|
||||
if img is None:
|
||||
print("[코그넥스] 이미지 디코딩 실패")
|
||||
return
|
||||
|
||||
if len(img.shape) == 2:
|
||||
h, w = img.shape
|
||||
qimg = QImage(img.data.tobytes(), w, h, w, QImage.Format_Grayscale8)
|
||||
else:
|
||||
rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
|
||||
h, w, ch = rgb.shape
|
||||
qimg = QImage(rgb.data.tobytes(), w, h, w * ch, QImage.Format_RGB888)
|
||||
|
||||
self._cognex_label.setPixmap(
|
||||
QPixmap.fromImage(qimg).scaled(
|
||||
self._cognex_label.size(),
|
||||
Qt.KeepAspectRatio,
|
||||
Qt.SmoothTransformation,
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"[코그넥스] 이미지 표시 오류: {e}")
|
||||
|
||||
def _display_basler_image(self, frame, detections=None):
|
||||
try:
|
||||
img = _draw_detections(frame, detections or [])
|
||||
|
||||
if len(img.shape) == 2:
|
||||
h, w = img.shape
|
||||
qimg = QImage(img.data.tobytes(), w, h, w, QImage.Format_Grayscale8)
|
||||
else:
|
||||
rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
|
||||
h, w, ch = rgb.shape
|
||||
qimg = QImage(rgb.data.tobytes(), w, h, w * ch, QImage.Format_RGB888)
|
||||
|
||||
self._basler_label.setPixmap(
|
||||
QPixmap.fromImage(qimg).scaled(
|
||||
self._basler_label.size(),
|
||||
Qt.KeepAspectRatio,
|
||||
Qt.SmoothTransformation,
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"[Basler] 이미지 표시 오류: {e}")
|
||||
|
||||
|
||||
# ================================================================== #
|
||||
# 모듈 수준 유틸리티
|
||||
# ================================================================== #
|
||||
|
||||
def _vline() -> QFrame:
|
||||
f = QFrame()
|
||||
f.setFrameShape(QFrame.VLine)
|
||||
f.setFixedWidth(2)
|
||||
f.setStyleSheet("background:#333333; border:none;")
|
||||
return f
|
||||
|
||||
|
||||
def _raw_to_pixmap(raw: bytes, size: QSize) -> "QPixmap | None":
|
||||
arr = np.frombuffer(raw, dtype=np.uint8)
|
||||
img = cv2.imdecode(arr, cv2.IMREAD_UNCHANGED)
|
||||
if img is None:
|
||||
return None
|
||||
return _ndarray_to_pixmap(img, size)
|
||||
|
||||
|
||||
def _ndarray_to_pixmap(img: np.ndarray, size: QSize) -> "QPixmap | None":
|
||||
if img.ndim == 2:
|
||||
img_c = np.ascontiguousarray(img)
|
||||
h, w = img_c.shape
|
||||
qimg = QImage(img_c.data, w, h, w, QImage.Format_Grayscale8)
|
||||
else:
|
||||
rgb = np.ascontiguousarray(cv2.cvtColor(img, cv2.COLOR_BGR2RGB))
|
||||
h, w, ch = rgb.shape
|
||||
qimg = QImage(rgb.data, w, h, w * ch, QImage.Format_RGB888)
|
||||
return QPixmap.fromImage(qimg).scaled(size, Qt.KeepAspectRatio, Qt.SmoothTransformation)
|
||||
297
gui/pages/register_page.py
Normal file
297
gui/pages/register_page.py
Normal file
@@ -0,0 +1,297 @@
|
||||
# 제품 등록 페이지 — 기준 이미지 캡처
|
||||
import cv2
|
||||
import numpy as np
|
||||
from PyQt5.QtCore import Qt, QSize
|
||||
from PyQt5.QtGui import QImage, QPixmap
|
||||
from PyQt5.QtWidgets import (
|
||||
QWidget, QHBoxLayout, QVBoxLayout, QGroupBox,
|
||||
QPushButton, QListWidget, QListWidgetItem, QLabel,
|
||||
QMessageBox, QScrollArea, QFrame,
|
||||
)
|
||||
|
||||
|
||||
_GRP_STYLE = (
|
||||
"QGroupBox {"
|
||||
" background:#222222; border:1px solid #333333; border-radius:6px;"
|
||||
" margin-top:14px; padding:14px 12px 12px 12px;"
|
||||
"}"
|
||||
"QGroupBox::title { color:#aaaaaa; subcontrol-origin:margin; left:10px; padding:0 4px; }"
|
||||
)
|
||||
|
||||
|
||||
class RegisterPage(QWidget):
|
||||
def __init__(self, insight_cam, matcher=None, db_client=None, parent=None):
|
||||
super().__init__(parent)
|
||||
self._insight = insight_cam
|
||||
self._db_client = db_client
|
||||
self._db_items = []
|
||||
self._selected = None
|
||||
self._captured_img = None
|
||||
|
||||
self._build_ui()
|
||||
|
||||
# ================================================================== #
|
||||
# UI 구성
|
||||
# ================================================================== #
|
||||
|
||||
def _build_ui(self):
|
||||
root = QHBoxLayout(self)
|
||||
root.setContentsMargins(0, 0, 0, 0)
|
||||
root.setSpacing(0)
|
||||
root.addWidget(self._build_left_panel(), stretch=2)
|
||||
root.addWidget(self._build_right_panel(), stretch=3)
|
||||
|
||||
def _build_left_panel(self) -> QWidget:
|
||||
w = QWidget()
|
||||
w.setStyleSheet("background:#1a1a1a;")
|
||||
layout = QVBoxLayout(w)
|
||||
layout.setContentsMargins(16, 16, 8, 16)
|
||||
layout.setSpacing(10)
|
||||
|
||||
self._btn_mes = QPushButton("MES 불러오기")
|
||||
self._btn_mes.setEnabled(False)
|
||||
self._btn_mes.setFixedHeight(56)
|
||||
self._btn_mes.setToolTip("DB 연결 후 사용 가능")
|
||||
self._btn_mes.setStyleSheet(
|
||||
"background:#444444; color:#777777; border:none; border-radius:4px; font-size:15px;"
|
||||
)
|
||||
self._btn_mes.clicked.connect(self._on_load_from_db)
|
||||
layout.addWidget(self._btn_mes)
|
||||
|
||||
lbl = QLabel("제품 목록")
|
||||
lbl.setStyleSheet("font-size:13px; color:#777777;")
|
||||
layout.addWidget(lbl)
|
||||
|
||||
self._list = QListWidget()
|
||||
self._list.setStyleSheet("""
|
||||
QListWidget {
|
||||
background:#222222; border:1px solid #333333;
|
||||
border-radius:4px; outline:none; font-size:15px;
|
||||
}
|
||||
QListWidget::item {
|
||||
padding:0px 14px; border-bottom:1px solid #2a2a2a; color:#dddddd;
|
||||
}
|
||||
QListWidget::item:selected { background:#185FA5; color:#ffffff; }
|
||||
QListWidget::item:hover:!selected { background:#2d2d2d; }
|
||||
""")
|
||||
self._list.currentRowChanged.connect(self._on_select)
|
||||
layout.addWidget(self._list, stretch=1)
|
||||
return w
|
||||
|
||||
def _build_right_panel(self) -> QScrollArea:
|
||||
scroll = QScrollArea()
|
||||
scroll.setWidgetResizable(True)
|
||||
scroll.setFrameShape(QScrollArea.NoFrame)
|
||||
scroll.setStyleSheet("background:#1a1a1a;")
|
||||
|
||||
inner = QWidget()
|
||||
inner.setStyleSheet("background:#1a1a1a;")
|
||||
layout = QVBoxLayout(inner)
|
||||
layout.setContentsMargins(8, 16, 16, 16)
|
||||
layout.setSpacing(0)
|
||||
|
||||
layout.addWidget(self._build_detail_section())
|
||||
layout.addWidget(_divider())
|
||||
layout.addWidget(self._build_capture_section())
|
||||
layout.addStretch()
|
||||
|
||||
scroll.setWidget(inner)
|
||||
return scroll
|
||||
|
||||
# ── 섹션 1: 제품 상세 ──────────────────────────────────────────────
|
||||
|
||||
def _build_detail_section(self) -> QGroupBox:
|
||||
g = QGroupBox("제품 상세 정보")
|
||||
g.setStyleSheet(_GRP_STYLE)
|
||||
layout = QVBoxLayout(g)
|
||||
layout.setSpacing(8)
|
||||
|
||||
self._lbl_name = _info_value("—")
|
||||
self._lbl_model = _info_value("—")
|
||||
self._lbl_type = _info_value("—")
|
||||
layout.addLayout(_info_row("카테고리", self._lbl_name))
|
||||
layout.addLayout(_info_row("모델명", self._lbl_model))
|
||||
layout.addLayout(_info_row("Type", self._lbl_type))
|
||||
|
||||
self._arrow_lbl = QLabel("")
|
||||
self._arrow_lbl.setAlignment(Qt.AlignCenter)
|
||||
self._arrow_lbl.setFixedHeight(80)
|
||||
self._arrow_lbl.setStyleSheet("font-size:60px; background:transparent;")
|
||||
layout.addWidget(self._arrow_lbl)
|
||||
return g
|
||||
|
||||
# ── 섹션 2: 캡처 ──────────────────────────────────────────────────
|
||||
|
||||
def _build_capture_section(self) -> QGroupBox:
|
||||
g = QGroupBox("기준 이미지 캡처")
|
||||
g.setStyleSheet(_GRP_STYLE)
|
||||
layout = QVBoxLayout(g)
|
||||
layout.setSpacing(12)
|
||||
|
||||
btn = QPushButton("캡처 (In-Sight 트리거)")
|
||||
btn.setFixedHeight(56)
|
||||
btn.setStyleSheet(
|
||||
"background:#1a3a5c; color:#ffffff; border:none; border-radius:4px;"
|
||||
"font-size:15px; font-weight:bold;"
|
||||
)
|
||||
btn.clicked.connect(self._on_capture)
|
||||
|
||||
self._preview = QLabel()
|
||||
self._preview.setAlignment(Qt.AlignCenter)
|
||||
self._preview.setFixedSize(400, 300)
|
||||
self._preview.setStyleSheet(
|
||||
"background:#111111; color:#555555; border:1px solid #333333;"
|
||||
"border-radius:4px; font-size:14px;"
|
||||
)
|
||||
self._preview.setText("캡처 이미지 없음")
|
||||
|
||||
layout.addWidget(btn)
|
||||
layout.addWidget(self._preview, alignment=Qt.AlignHCenter)
|
||||
return g
|
||||
|
||||
# ================================================================== #
|
||||
# 슬롯
|
||||
# ================================================================== #
|
||||
|
||||
def _on_select(self, row: int):
|
||||
if row < 0:
|
||||
return
|
||||
item = self._list.item(row)
|
||||
if item is None:
|
||||
return
|
||||
|
||||
article_id = item.data(Qt.UserRole)
|
||||
if article_id is None:
|
||||
return
|
||||
|
||||
db_item = next(
|
||||
(x for x in self._db_items if x["article_id"] == article_id), None
|
||||
)
|
||||
r = {
|
||||
"id": article_id,
|
||||
"name": item.text(),
|
||||
"model": db_item.get("buyer_article_no", "") if db_item else "",
|
||||
"type": "",
|
||||
}
|
||||
|
||||
self._selected = r
|
||||
self._captured_img = None
|
||||
|
||||
self._lbl_name.setText(r["name"])
|
||||
self._lbl_model.setText(r["model"])
|
||||
t = r.get("type", "")
|
||||
self._lbl_type.setText(t if t else "—")
|
||||
|
||||
if t == "RH":
|
||||
self._arrow_lbl.setText("→")
|
||||
self._arrow_lbl.setStyleSheet("font-size:60px; color:#4488ff; background:transparent;")
|
||||
elif t == "LH":
|
||||
self._arrow_lbl.setText("←")
|
||||
self._arrow_lbl.setStyleSheet("font-size:60px; color:#ff8844; background:transparent;")
|
||||
else:
|
||||
self._arrow_lbl.setText("")
|
||||
|
||||
self._reset_preview()
|
||||
|
||||
def _on_capture(self):
|
||||
if self._selected is None:
|
||||
QMessageBox.warning(self, "경고", "먼저 제품을 선택하세요.")
|
||||
return
|
||||
if not self._insight.is_connected():
|
||||
QMessageBox.critical(self, "오류", "Cognex 카메라가 연결되어 있지 않습니다.")
|
||||
return
|
||||
|
||||
raw = self._insight.trigger_and_get_image()
|
||||
if not raw:
|
||||
QMessageBox.critical(self, "캡처 실패",
|
||||
"이미지를 수신하지 못했습니다.\n카메라 연결 상태를 확인하세요.")
|
||||
return
|
||||
|
||||
arr = np.frombuffer(raw, dtype=np.uint8)
|
||||
img = cv2.imdecode(arr, cv2.IMREAD_UNCHANGED)
|
||||
if img is None:
|
||||
QMessageBox.critical(self, "캡처 실패", "이미지 디코딩에 실패했습니다.")
|
||||
return
|
||||
|
||||
self._captured_img = img
|
||||
self._show_ndarray(img)
|
||||
|
||||
def update_db(self, db_client):
|
||||
"""MainWindow에서 DB 연결/해제 시 호출."""
|
||||
self._db_client = db_client
|
||||
enabled = db_client is not None and db_client.is_connected()
|
||||
self._btn_mes.setEnabled(enabled)
|
||||
self._btn_mes.setStyleSheet(
|
||||
"background:#1a3a5c; color:#ffffff; border:none; border-radius:4px; font-size:15px;"
|
||||
if enabled else
|
||||
"background:#444444; color:#777777; border:none; border-radius:4px; font-size:15px;"
|
||||
)
|
||||
|
||||
def _on_load_from_db(self):
|
||||
if not self._db_client or not self._db_client.is_connected():
|
||||
QMessageBox.warning(self, "경고", "DB를 먼저 연결해주세요.")
|
||||
return
|
||||
|
||||
items = self._db_client.get_reflector_list()
|
||||
if not items:
|
||||
QMessageBox.warning(self, "경고", "조회된 제품이 없습니다.")
|
||||
return
|
||||
|
||||
self._list.clear()
|
||||
self._db_items = items
|
||||
for item in items:
|
||||
li = QListWidgetItem(item['article'])
|
||||
li.setSizeHint(QSize(0, 52))
|
||||
li.setData(Qt.UserRole, item["article_id"])
|
||||
self._list.addItem(li)
|
||||
|
||||
QMessageBox.information(
|
||||
self, "완료", f"{len(items)}개 제품을 불러왔습니다."
|
||||
)
|
||||
|
||||
# ================================================================== #
|
||||
# 헬퍼
|
||||
# ================================================================== #
|
||||
|
||||
def _show_ndarray(self, img: np.ndarray):
|
||||
h_img, w_img = img.shape[:2]
|
||||
if img.ndim == 2:
|
||||
qimg = QImage(img.data, w_img, h_img, w_img, QImage.Format_Grayscale8)
|
||||
else:
|
||||
rgb = np.ascontiguousarray(cv2.cvtColor(img, cv2.COLOR_BGR2RGB))
|
||||
qimg = QImage(rgb.data, w_img, h_img, w_img * 3, QImage.Format_RGB888)
|
||||
pix = QPixmap.fromImage(qimg).scaled(400, 300, Qt.KeepAspectRatio, Qt.SmoothTransformation)
|
||||
self._preview.setPixmap(pix)
|
||||
self._preview.setText("")
|
||||
|
||||
def _reset_preview(self):
|
||||
self._preview.clear()
|
||||
self._preview.setText("캡처 이미지 없음")
|
||||
|
||||
|
||||
# ================================================================== #
|
||||
# 모듈 수준 유틸리티
|
||||
# ================================================================== #
|
||||
|
||||
def _divider() -> QFrame:
|
||||
f = QFrame()
|
||||
f.setFrameShape(QFrame.HLine)
|
||||
f.setFixedHeight(1)
|
||||
f.setStyleSheet("background:#333333; border:none;")
|
||||
return f
|
||||
|
||||
|
||||
def _info_value(text: str) -> QLabel:
|
||||
lbl = QLabel(text)
|
||||
lbl.setStyleSheet("color:#ffffff; font-size:16px; font-weight:bold;")
|
||||
return lbl
|
||||
|
||||
|
||||
def _info_row(label: str, value_widget: QLabel) -> QHBoxLayout:
|
||||
row = QHBoxLayout()
|
||||
key = QLabel(label + ":")
|
||||
key.setFixedWidth(80)
|
||||
key.setStyleSheet("color:#888888; font-size:14px;")
|
||||
row.addWidget(key)
|
||||
row.addWidget(value_widget, stretch=1)
|
||||
return row
|
||||
1193
gui/pages/retrain_page.py
Normal file
1193
gui/pages/retrain_page.py
Normal file
File diff suppressed because it is too large
Load Diff
1219
gui/pages/settings_page.py
Normal file
1219
gui/pages/settings_page.py
Normal file
File diff suppressed because it is too large
Load Diff
182
gui/splash_screen.py
Normal file
182
gui/splash_screen.py
Normal file
@@ -0,0 +1,182 @@
|
||||
import json
|
||||
import os
|
||||
|
||||
from PyQt5.QtWidgets import QWidget, QLabel, QProgressBar, QVBoxLayout, QApplication
|
||||
from PyQt5.QtCore import Qt, QThread, pyqtSignal
|
||||
from PyQt5.QtGui import QPainter, QPainterPath, QColor
|
||||
|
||||
|
||||
class SplashScreen(QWidget):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setWindowFlags(Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint)
|
||||
self.setAttribute(Qt.WA_TranslucentBackground)
|
||||
self.setFixedSize(500, 300)
|
||||
|
||||
screen = QApplication.primaryScreen().geometry()
|
||||
self.move(
|
||||
(screen.width() - self.width()) // 2,
|
||||
(screen.height() - self.height()) // 2,
|
||||
)
|
||||
self._build_ui()
|
||||
|
||||
def _build_ui(self):
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(40, 40, 40, 30)
|
||||
layout.setSpacing(0)
|
||||
|
||||
title = QLabel("리플렉터 검사 시스템")
|
||||
title.setAlignment(Qt.AlignCenter)
|
||||
title.setStyleSheet(
|
||||
"color:#ffffff; font-size:22px; font-weight:bold; background:transparent;"
|
||||
)
|
||||
|
||||
subtitle = QLabel("Reflector Inspection System")
|
||||
subtitle.setAlignment(Qt.AlignCenter)
|
||||
subtitle.setStyleSheet("color:#555555; font-size:12px; background:transparent;")
|
||||
|
||||
layout.addStretch(2)
|
||||
layout.addWidget(title)
|
||||
layout.addSpacing(6)
|
||||
layout.addWidget(subtitle)
|
||||
layout.addStretch(3)
|
||||
|
||||
self.status_label = QLabel("초기화 중...")
|
||||
self.status_label.setAlignment(Qt.AlignCenter)
|
||||
self.status_label.setStyleSheet(
|
||||
"color:#888888; font-size:13px; background:transparent;"
|
||||
)
|
||||
layout.addWidget(self.status_label)
|
||||
layout.addSpacing(10)
|
||||
|
||||
self.progress_bar = QProgressBar()
|
||||
self.progress_bar.setRange(0, 100)
|
||||
self.progress_bar.setValue(0)
|
||||
self.progress_bar.setFixedHeight(6)
|
||||
self.progress_bar.setTextVisible(False)
|
||||
self.progress_bar.setStyleSheet("""
|
||||
QProgressBar {
|
||||
background: #2a2a2a;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
}
|
||||
QProgressBar::chunk {
|
||||
background: #1D9E75;
|
||||
border-radius: 3px;
|
||||
}
|
||||
""")
|
||||
layout.addWidget(self.progress_bar)
|
||||
layout.addStretch(1)
|
||||
|
||||
version = QLabel("v1.0.0")
|
||||
version.setAlignment(Qt.AlignCenter)
|
||||
version.setStyleSheet("color:#333333; font-size:11px; background:transparent;")
|
||||
layout.addWidget(version)
|
||||
|
||||
def update_progress(self, value: int, message: str):
|
||||
self.progress_bar.setValue(value)
|
||||
self.status_label.setText(message)
|
||||
QApplication.processEvents()
|
||||
|
||||
def paintEvent(self, event):
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.Antialiasing)
|
||||
path = QPainterPath()
|
||||
path.addRoundedRect(0.0, 0.0, float(self.width()), float(self.height()), 12.0, 12.0)
|
||||
painter.fillPath(path, QColor("#0d0d0d"))
|
||||
|
||||
|
||||
class InitWorker(QThread):
|
||||
progress = pyqtSignal(int, str) # (진행률, 메시지)
|
||||
finished = pyqtSignal(object) # 초기화 결과 dict
|
||||
|
||||
def run(self):
|
||||
from utils.path_helper import get_path
|
||||
from camera.insight import InSightCamera
|
||||
from camera.basler import BaslerCamera
|
||||
from ai.detector import Detector
|
||||
from db.sql_client import SQLClient
|
||||
|
||||
results = {}
|
||||
|
||||
# ── 1단계: 설정 로드 ──────────────────────────────────────────── #
|
||||
self.progress.emit(10, "설정 불러오는 중...")
|
||||
try:
|
||||
with open(get_path("config.json"), encoding="utf-8") as f:
|
||||
config = json.load(f)
|
||||
except Exception as e:
|
||||
print(f"[InitWorker] 설정 로드 실패: {e}")
|
||||
config = {}
|
||||
results["config"] = config
|
||||
|
||||
# ── 2단계: 코그넥스 카메라 ────────────────────────────────────── #
|
||||
self.progress.emit(25, "코그넥스 카메라 연결 중...")
|
||||
insight = InSightCamera()
|
||||
try:
|
||||
cognex_cfg = config.get("cognex", {})
|
||||
ip = cognex_cfg.get("ip", "")
|
||||
port = cognex_cfg.get("port", 23)
|
||||
if ip:
|
||||
insight.connect(ip, port)
|
||||
except Exception as e:
|
||||
print(f"[InitWorker] 코그넥스 연결 실패: {e}")
|
||||
results["insight"] = insight
|
||||
|
||||
# ── 3단계: Basler 카메라 ──────────────────────────────────────── #
|
||||
self.progress.emit(45, "Basler 카메라 연결 중...")
|
||||
basler = BaslerCamera()
|
||||
try:
|
||||
basler.connect()
|
||||
except Exception as e:
|
||||
print(f"[InitWorker] Basler 연결 실패: {e}")
|
||||
results["basler"] = basler
|
||||
|
||||
# ── 4단계: AI 모델 로드 ───────────────────────────────────────── #
|
||||
self.progress.emit(65, "AI 모델 로드 중...")
|
||||
detector = Detector()
|
||||
try:
|
||||
from paths import resolve_path
|
||||
model_path = config.get("ai", {}).get("model_path", "")
|
||||
if model_path:
|
||||
abs_path = resolve_path(model_path)
|
||||
if abs_path and os.path.exists(abs_path):
|
||||
detector.load_model(abs_path)
|
||||
except Exception as e:
|
||||
print(f"[InitWorker] AI 모델 로드 실패: {e}")
|
||||
results["detector"] = detector
|
||||
|
||||
# ── 5단계: DB 연결 ────────────────────────────────────────────── #
|
||||
self.progress.emit(80, "DB 연결 중...")
|
||||
db_client = SQLClient()
|
||||
try:
|
||||
db_cfg = config.get("db", {})
|
||||
if db_cfg.get("server"):
|
||||
db_client.connect(
|
||||
db_cfg["server"],
|
||||
db_cfg["database"],
|
||||
db_cfg["username"],
|
||||
db_cfg["password"],
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"[InitWorker] DB 연결 실패: {e}")
|
||||
results["db"] = db_client
|
||||
|
||||
# ── 6단계: PLC 연결 ───────────────────────────────────────────── #
|
||||
self.progress.emit(92, "PLC 연결 중...")
|
||||
plc_client = None
|
||||
try:
|
||||
from plc.plc_client import PLCClient
|
||||
plc_cfg = config.get("plc", {})
|
||||
ip = plc_cfg.get("ip", "").strip()
|
||||
port = plc_cfg.get("port", 5010)
|
||||
if ip:
|
||||
client = PLCClient()
|
||||
if client.connect(ip, port):
|
||||
plc_client = client
|
||||
except Exception as e:
|
||||
print(f"[InitWorker] PLC 연결 실패: {e}")
|
||||
results["plc"] = plc_client
|
||||
|
||||
# ── 완료 ──────────────────────────────────────────────────────── #
|
||||
self.progress.emit(100, "시작 중...")
|
||||
self.finished.emit(results)
|
||||
Reference in New Issue
Block a user