버전 업그레이드

This commit is contained in:
2026-06-18 13:38:27 +09:00
parent a48a4b5fe5
commit ba33a78fec
37 changed files with 3355 additions and 1165 deletions

View File

@@ -1,4 +1,4 @@
# 검사 페이지 — 코그넥스/Basler 영상, 그룹 A/B 설정, Pass/Fail 표시
# 검사 페이지 — 코그넥스/Basler 영상, Pass/Fail 표시
import time
import threading
import itertools
@@ -8,12 +8,14 @@ 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,
QPushButton, QLabel, QFrame,
QGridLayout, QSizePolicy, QScrollArea, QScroller, QMessageBox,
)
from logic.inspector import Inspector
from logic.group_manager import GroupManager
from logic.products import build_patmax_cells, article_label, MAX_PATMAX_SLOTS
from db.sql_client import SQLClient
from logger import log_inspect_result, log_camera_timing, log_action, log_defect_image
_DEFECT_COLORS = {
@@ -40,33 +42,6 @@ def _draw_detections(frame: np.ndarray, detections: list) -> np.ndarray:
)
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,
}
# ================================================================== #
# 백그라운드 워커 — 파이프라인 방식
@@ -97,6 +72,18 @@ class InspectWorker(QThread):
self._stop_flag = False
self._pause_flag = False
self._seq = itertools.count(1)
self._wk_result_ids: set = set()
self._wk_check_enabled = False
self._allowed_article_ids: set = set()
def set_work_targets(self, allowed_article_ids: "set | None"):
"""WK_Result 작업 대상 ArticleID (정규화된 set). None이면 WK 검사 비활성."""
if allowed_article_ids is None:
self._wk_check_enabled = False
self._allowed_article_ids = set()
return
self._wk_check_enabled = True
self._allowed_article_ids = allowed_article_ids
# ── 외부 제어 ──────────────────────────────────────────────────── #
@@ -207,18 +194,25 @@ class InspectWorker(QThread):
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 = {
# ── 모델 판별 (WK_Result 작업 대상만 허용) ──
results = cognex_out.get("results", {})
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)
if self._wk_check_enabled:
result_info = self._inspector.identify_model(
results, allowed_article_ids=self._allowed_article_ids,
)
else:
result_info = self._inspector.identify_model(
results, allowed_model_ids=self._groups.get_allowed_ids(),
)
if result_info["matched"] and self._wk_check_enabled:
result_info["in_wk_result"] = result_info["in_allowed"]
except Exception as e:
print(f"[워커 오류] 모델 판별: {e}")
@@ -258,18 +252,17 @@ class InspectWorker(QThread):
class InspectPage(QWidget):
def __init__(self, insight_cam, basler_cam, detector=None,
belt_delay: float = 3.33, parent=None):
belt_delay: float = 3.33, db_client=None, config=None, parent=None):
super().__init__(parent)
self._insight = insight_cam
self._basler = basler_cam
self._db_client = db_client
self._config = config or {}
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._counts = {"total": 0, "pass": 0, "fail": 0, "unknown": 0}
self._matcher = None
@@ -284,6 +277,7 @@ class InspectPage(QWidget):
self._worker.result_ready.connect(self._on_result)
self._build_ui()
self.refresh_wk_results()
# ================================================================== #
# 최상위 레이아웃
@@ -347,68 +341,48 @@ class InspectPage(QWidget):
layout.setContentsMargins(8, 6, 8, 6)
layout.setSpacing(0)
layout.addWidget(self._build_col_groups(), stretch=5)
layout.addWidget(self._build_col_products(), 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:
def _build_col_products(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 = QGroupBox("검사 대상")
self._products_group = g
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; }}"
"QGroupBox { background:#222222; border:1px solid #333333; border-radius:6px;"
" margin-top:12px; padding:6px 4px 4px 4px; }"
"QGroupBox::title { color:#4488ff; subcontrol-origin:margin;"
" left:8px; font-size:13px; font-weight:bold; }"
)
layout = QVBoxLayout(g)
layout.setSpacing(1)
layout.setContentsMargins(4, 2, 4, 2)
outer = QVBoxLayout(g)
outer.setSpacing(0)
outer.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)
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setFrameShape(QFrame.NoFrame)
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
scroll.setStyleSheet("background:transparent;")
QScroller.grabGesture(scroll.viewport(), QScroller.LeftMouseButtonGesture)
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))
inner = QWidget()
inner.setStyleSheet("background:transparent;")
self._products_inner_layout = QVBoxLayout(inner)
self._products_inner_layout.setSpacing(2)
self._products_inner_layout.setContentsMargins(2, 2, 2, 2)
return g
scroll.setWidget(inner)
outer.addWidget(scroll)
layout.addWidget(g, stretch=1)
return w
def _build_col_controls(self) -> QWidget:
w = QWidget()
@@ -433,9 +407,9 @@ class InspectPage(QWidget):
)
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._scope_lbl = QLabel("검사 범위: ")
self._scope_lbl.setAlignment(Qt.AlignCenter)
self._scope_lbl.setStyleSheet("font-size:14px; color:#aaaaaa;")
self._model_lbl = QLabel("인식 모델: —")
self._model_lbl.setAlignment(Qt.AlignCenter)
@@ -446,19 +420,19 @@ class InspectPage(QWidget):
f"벨트 딜레이: {self._worker._belt_delay:.2f}s"
)
self._belt_lbl.setAlignment(Qt.AlignCenter)
self._belt_lbl.setStyleSheet("font-size:12px; color:#666666;")
self._belt_lbl.setStyleSheet("font-size:13px; color:#999999;")
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;"
"font-size:64px; font-weight:bold; background:#2a2a2a;"
"color:#888888; 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._scope_lbl)
layout.addWidget(self._model_lbl)
layout.addWidget(self._belt_lbl)
layout.addWidget(self._result_lbl, stretch=1)
@@ -473,7 +447,7 @@ class InspectPage(QWidget):
grid = QGridLayout()
grid.setSpacing(4)
for col, text in enumerate(["", "그룹 A", "그룹 B"]):
for col, text in enumerate(["", "집계"]):
lbl = QLabel(text)
lbl.setAlignment(Qt.AlignCenter)
lbl.setStyleSheet("font-size:13px; color:#888888; font-weight:bold;")
@@ -491,16 +465,14 @@ class InspectPage(QWidget):
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
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, 1)
self._cnt_lbls[key] = lbl
layout.addLayout(grid)
@@ -518,24 +490,6 @@ class InspectPage(QWidget):
# 슬롯 — 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
@@ -558,11 +512,18 @@ class InspectPage(QWidget):
log_action("[검사] 검사 재개")
def _on_reset(self):
reply = QMessageBox.question(
self, "카운터 초기화",
"검사 카운트를 0으로 초기화할까요?\n"
"초기화한 집계는 되돌릴 수 없습니다.",
QMessageBox.Yes | QMessageBox.No, QMessageBox.No,
)
if reply != QMessageBox.Yes:
return
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")
self._counts[key] = 0
self._cnt_lbls[key].setText("0")
def closeEvent(self, event):
self._worker.stop()
@@ -598,6 +559,106 @@ class InspectPage(QWidget):
self._worker.set_belt_delay(delay)
self._belt_lbl.setText(f"벨트 딜레이: {delay:.2f}s")
def update_db(self, db_client):
"""MainWindow에서 DB 연결/해제 시 호출."""
self._db_client = db_client
self.refresh_wk_results()
def refresh_wk_results(self):
"""vi_AI_WK_Result 기준 작업 대상을 UI·PatMax 매핑·워커에 반영."""
if not self._db_client or not self._db_client.is_connected():
self._inspector.set_pattern_cells({})
self._worker.set_work_targets(None)
self._update_product_list([], [])
self._scope_lbl.setText("검사 범위: DB 미연결")
self._products_group.setTitle("검사 대상")
print("[검사] WK_Result 비활성 — DB 미연결")
return
mes_selected = self._config.get("mes", {}).get("selected_article_ids")
if mes_selected is not None and len(mes_selected) == 0:
self._inspector.set_pattern_cells({})
self._worker.set_work_targets(set())
self._update_product_list([], [])
self._scope_lbl.setText("검사 범위: MES 제품 미선택")
self._products_group.setTitle("검사 대상 (0종)")
return
if mes_selected is not None:
all_items = self._db_client.get_reflector_list_ordered(mes_selected)
else:
all_items = self._db_client.get_reflector_list()
self._inspector.set_pattern_cells(build_patmax_cells(all_items))
active, inactive = self._db_client.split_articles_by_wk(mes_selected)
allowed = {SQLClient._norm_id(a["article_id"]) for a in active}
self._worker.set_work_targets(allowed)
self._update_product_list(active, inactive)
total = len(active) + len(inactive)
if active:
self._scope_lbl.setText(f"검사 범위: 작업 대상 {len(active)}")
else:
self._scope_lbl.setText("검사 범위: 작업 대상 없음")
self._products_group.setTitle(
f"검사 대상 (작업 {len(active)}종 / 전체 {total}종)"
)
print(
f"[검사] WK_Result 작업 대상: {len(active)}종 (전체 {total}종, "
f"PatMax 슬롯 {min(len(all_items), MAX_PATMAX_SLOTS)}개)"
)
def _update_product_list(self, active: list, inactive: list):
if not hasattr(self, "_products_inner_layout"):
return
layout = self._products_inner_layout
while layout.count():
item = layout.takeAt(0)
if item.widget():
item.widget().deleteLater()
if active:
layout.addWidget(self._make_section_label(
f"작업 대상 — WK_Result ({len(active)})", active=True
))
for i, article in enumerate(active, 1):
layout.addWidget(self._make_article_label(article, i, active=True))
if inactive:
layout.addWidget(self._make_section_label(
f"기타 ({len(inactive)})", active=False
))
for i, article in enumerate(inactive, 1):
layout.addWidget(self._make_article_label(article, i, active=False))
if not active and not inactive:
layout.addWidget(self._make_section_label("작업 대상 없음", active=False))
layout.addStretch()
@staticmethod
def _make_section_label(text: str, active: bool) -> QLabel:
lbl = QLabel(text)
color = "#aaaaaa" if active else "#666666"
lbl.setStyleSheet(
f"font-size:12px; color:{color}; font-weight:bold;"
"padding:6px 4px 2px 4px; background:#2a2a2a;"
)
return lbl
@staticmethod
def _make_article_label(article: dict, index: int, active: bool) -> QLabel:
lbl = QLabel(f"{index:2d}. {article_label(article)}")
if active:
style = "font-size:13px; min-height:32px; color:#eeeeee; padding:2px 4px;"
else:
style = "font-size:13px; min-height:32px; color:#555555; padding:2px 4px;"
lbl.setStyleSheet(style)
return lbl
# ================================================================== #
# 워커 signal 슬롯 (메인 스레드)
# ================================================================== #
@@ -606,24 +667,21 @@ class InspectPage(QWidget):
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
self._counts["total"] += 1
if not matched:
self._counts[group]["unknown"] += 1
self._counts["unknown"] += 1
elif result == "PASS":
self._counts[group]["pass"] += 1
self._counts["pass"] += 1
else:
self._counts[group]["fail"] += 1
self._counts["fail"] += 1
for key in ("total", "pass", "fail", "unknown"):
self._cnt_lbls[key][group].setText(str(self._counts[group][key]))
self._cnt_lbls[key].setText(str(self._counts[key]))
if not matched:
self._set_result("미인식", "#332200", "#ff9900")
@@ -639,7 +697,7 @@ class InspectPage(QWidget):
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"font-size:64px; font-weight:bold; background:{bg};"
f"color:{fg}; border-radius:8px;"
)

View File

@@ -2,13 +2,15 @@
import cv2
import numpy as np
from PyQt5.QtCore import Qt, QSize
from PyQt5.QtGui import QImage, QPixmap
from PyQt5.QtGui import QImage, QPixmap, QColor
from PyQt5.QtWidgets import (
QWidget, QHBoxLayout, QVBoxLayout, QGroupBox,
QPushButton, QListWidget, QListWidgetItem, QLabel,
QMessageBox, QScrollArea, QFrame,
)
from db.sql_client import SQLClient
_GRP_STYLE = (
"QGroupBox {"
@@ -20,11 +22,13 @@ _GRP_STYLE = (
class RegisterPage(QWidget):
def __init__(self, insight_cam, matcher=None, db_client=None, parent=None):
def __init__(self, insight_cam, matcher=None, db_client=None, config=None, parent=None):
super().__init__(parent)
self._insight = insight_cam
self._db_client = db_client
self._config = config or {}
self._db_items = []
self._wk_map = {}
self._selected = None
self._captured_img = None
@@ -49,12 +53,8 @@ class RegisterPage(QWidget):
layout.setSpacing(10)
self._btn_mes = QPushButton("MES 불러오기")
self._btn_mes.setVisible(False)
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)
@@ -69,10 +69,8 @@ class RegisterPage(QWidget):
border-radius:4px; outline:none; font-size:15px;
}
QListWidget::item {
padding:0px 14px; border-bottom:1px solid #2a2a2a; color:#dddddd;
padding:0px 14px; border-bottom:1px solid #2a2a2a;
}
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)
@@ -109,9 +107,19 @@ class RegisterPage(QWidget):
self._lbl_name = _info_value("")
self._lbl_model = _info_value("")
self._lbl_type = _info_value("")
self._lbl_wk = _info_value("")
self._lbl_machine_id = _info_value("")
self._lbl_machine = _info_value("")
self._lbl_work_date = _info_value("")
self._lbl_work_time = _info_value("")
layout.addLayout(_info_row("카테고리", self._lbl_name))
layout.addLayout(_info_row("모델명", self._lbl_model))
layout.addLayout(_info_row("Type", self._lbl_type))
layout.addLayout(_info_row("작업상태", self._lbl_wk))
layout.addLayout(_info_row("설비 ID", self._lbl_machine_id))
layout.addLayout(_info_row("설비명", self._lbl_machine))
layout.addLayout(_info_row("작업시작일", self._lbl_work_date))
layout.addLayout(_info_row("작업시작", self._lbl_work_time))
self._arrow_lbl = QLabel("")
self._arrow_lbl.setAlignment(Qt.AlignCenter)
@@ -157,21 +165,28 @@ class RegisterPage(QWidget):
if row < 0:
return
item = self._list.item(row)
if item is None:
if item is None or not (item.flags() & Qt.ItemIsSelectable):
return
article_id = item.data(Qt.UserRole)
if article_id is None:
return
self._refresh_list_styles(row)
db_item = next(
(x for x in self._db_items if x["article_id"] == article_id), None
(x for x in self._db_items
if SQLClient._norm_id(x["article_id"]) == SQLClient._norm_id(article_id)),
None,
)
name = item.data(Qt.UserRole + 1) or item.text()
in_wk = bool(item.data(Qt.UserRole + 2))
r = {
"id": article_id,
"name": item.text(),
"name": name,
"model": db_item.get("buyer_article_no", "") if db_item else "",
"type": "",
"in_wk": in_wk,
}
self._selected = r
@@ -181,6 +196,20 @@ class RegisterPage(QWidget):
self._lbl_model.setText(r["model"])
t = r.get("type", "")
self._lbl_type.setText(t if t else "")
if in_wk:
self._lbl_wk.setText("작업 대상")
self._lbl_wk.setStyleSheet("color:#cccccc; font-size:16px; font-weight:bold;")
wk = self._wk_map.get(SQLClient._norm_id(article_id), {})
self._lbl_machine_id.setText(SQLClient.format_db_value(wk.get("machine_id")))
self._lbl_machine.setText(SQLClient.format_db_value(wk.get("machine")))
self._lbl_work_date.setText(SQLClient.format_db_value(wk.get("work_start_date")))
self._lbl_work_time.setText(SQLClient.format_db_value(wk.get("work_start_time")))
else:
self._lbl_wk.setText("작업 대외")
self._lbl_wk.setStyleSheet("color:#888888; font-size:16px; font-weight:bold;")
for lbl in (self._lbl_machine_id, self._lbl_machine,
self._lbl_work_date, self._lbl_work_time):
lbl.setText("")
if t == "RH":
self._arrow_lbl.setText("")
@@ -219,35 +248,111 @@ class RegisterPage(QWidget):
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;"
self.load_products()
def load_products(self):
"""관리자 설정에서 선택한 MES 제품 목록을 자동으로 불러온다."""
self._list.clear()
self._db_items = []
self._wk_map = {}
self._selected = None
self._lbl_name.setText("")
self._lbl_model.setText("")
self._lbl_type.setText("")
self._lbl_wk.setText("")
self._lbl_wk.setStyleSheet("color:#ffffff; font-size:16px; font-weight:bold;")
for lbl in (self._lbl_machine_id, self._lbl_machine,
self._lbl_work_date, self._lbl_work_time):
lbl.setText("")
self._arrow_lbl.setText("")
self._reset_preview()
if not self._db_client or not self._db_client.is_connected():
return
selected_ids = self._config.get("mes", {}).get("selected_article_ids")
if selected_ids is not None and len(selected_ids) == 0:
return
all_items = self._db_client.get_reflector_list(
article_ids=selected_ids if selected_ids is not None else None
)
if not all_items:
return
self._wk_map = self._db_client.get_wk_result_map()
active, inactive = self._db_client.split_articles_by_wk(selected_ids)
self._db_items = all_items
if active:
self._add_section_header(f"작업 대상 — WK_Result ({len(active)})")
for item in active:
self._add_product_item(item, in_wk=True)
if inactive:
self._add_section_header(f"기타 ({len(inactive)})")
for item in inactive:
self._add_product_item(item, in_wk=False)
self._refresh_list_styles()
def _on_load_from_db(self):
if not self._db_client or not self._db_client.is_connected():
QMessageBox.warning(self, "경고", "DB를 먼저 연결해주세요.")
return
pass
items = self._db_client.get_reflector_list()
if not items:
QMessageBox.warning(self, "경고", "조회된 제품이 없습니다.")
return
def _add_section_header(self, text: str):
hi = QListWidgetItem(text)
hi.setFlags(Qt.NoItemFlags)
hi.setForeground(QColor("#aaaaaa"))
hi.setBackground(QColor("#2a2a2a"))
hi.setSizeHint(QSize(0, 40))
self._list.addItem(hi)
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)
def _add_product_item(self, item: dict, in_wk: bool):
li = QListWidgetItem(item["article"])
li.setSizeHint(QSize(0, 52))
li.setData(Qt.UserRole, item["article_id"])
li.setData(Qt.UserRole + 1, item["article"])
li.setData(Qt.UserRole + 2, in_wk)
if in_wk:
wk = self._wk_map.get(SQLClient._norm_id(item["article_id"]), {})
tips = []
if wk.get("machine_id") is not None:
tips.append(f"설비 ID: {SQLClient.format_db_value(wk.get('machine_id'))}")
if wk.get("machine") is not None:
tips.append(f"설비: {SQLClient.format_db_value(wk.get('machine'))}")
if wk.get("work_start_date") is not None:
tips.append(f"시작일: {SQLClient.format_db_value(wk.get('work_start_date'))}")
if wk.get("work_start_time") is not None:
tips.append(f"시작: {SQLClient.format_db_value(wk.get('work_start_time'))}")
if tips:
li.setToolTip("\n".join(tips))
self._list.addItem(li)
self._style_product_item(li, in_wk, selected=False)
QMessageBox.information(
self, "완료", f"{len(items)}개 제품을 불러왔습니다."
)
@staticmethod
def _style_product_item(item: QListWidgetItem, in_wk: bool, selected: bool):
if in_wk:
if selected:
bg, fg = "#777777", "#ffffff"
else:
bg, fg = "#555555", "#eeeeee"
elif selected:
bg, fg = "#3a3a3a", "#aaaaaa"
else:
bg, fg = "#1e1e1e", "#666666"
item.setBackground(QColor(bg))
item.setForeground(QColor(fg))
def _refresh_list_styles(self, selected_row: int = -1):
if selected_row < 0:
selected_row = self._list.currentRow()
for i in range(self._list.count()):
item = self._list.item(i)
if not (item.flags() & Qt.ItemIsSelectable):
continue
in_wk = bool(item.data(Qt.UserRole + 2))
self._style_product_item(item, in_wk, selected=(i == selected_row))
# ================================================================== #
# 헬퍼

View File

@@ -754,7 +754,7 @@ class RetrainPage(QWidget):
"color:#aaaaaa; font-size:12px; min-width:55px;"
)
hint = QLabel("더블클릭: fit | 휠: 줌 | Space+드래그: 패닝 | Del: 박스삭제 | Ctrl+Z: 실행취소")
hint.setStyleSheet("color:#555555; font-size:11px;")
hint.setStyleSheet("color:#888888; font-size:12px;")
btn_fit = QPushButton("초기화")
btn_fit.setFixedHeight(28)
btn_fit.setStyleSheet(_btn_style("#333333", font_size=12))
@@ -1174,6 +1174,12 @@ def _spinbox_style() -> str:
" background:#2a2a2a; color:#ffffff; border:1px solid #555555;"
" border-radius:4px; padding:4px 8px; font-size:14px; min-height:38px;"
"}"
"QSpinBox::up-button {"
" subcontrol-origin:border; subcontrol-position:top right; width:30px;"
"}"
"QSpinBox::down-button {"
" subcontrol-origin:border; subcontrol-position:bottom right; width:30px;"
"}"
)

View File

@@ -1,12 +1,14 @@
import json
import os
import sys
from PyQt5.QtCore import Qt, QTimer, pyqtSignal
from PyQt5.QtCore import Qt, QTimer, pyqtSignal, QSize
from PyQt5.QtGui import QColor
from PyQt5.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QFormLayout, QGridLayout,
QPushButton, QLineEdit, QSpinBox, QDoubleSpinBox,
QLabel, QMessageBox, QApplication, QFileDialog,
QDialog, QTabWidget, QFrame,
QDialog, QTabWidget, QFrame, QListWidget, QListWidgetItem,
)
from camera.insight import InSightCamera
@@ -16,6 +18,7 @@ from db.sql_client import SQLClient
from plc.plc_client import PLCClient
from paths import resolve_path, to_project_relative
from utils.path_helper import get_path
from utils.touch_keyboard import show_touch_keyboard, hide_touch_keyboard
from logger import log_action
ADMIN_PASSWORD = "1234"
@@ -225,6 +228,19 @@ class PasswordDialog(QDialog):
# AdminSettingsDialog
# ══════════════════════════════════════════════════════════════════════ #
class _TouchCheckList(QListWidget):
"""행 전체 탭으로 체크 토글 (터치 화면용)."""
def mousePressEvent(self, event):
item = self.itemAt(event.pos())
if item is not None:
item.setCheckState(
Qt.Unchecked if item.checkState() == Qt.Checked else Qt.Checked
)
return
super().mousePressEvent(event)
class AdminSettingsDialog(QDialog):
def __init__(self, settings_page, parent=None):
super().__init__(parent)
@@ -258,6 +274,7 @@ class AdminSettingsDialog(QDialog):
self._tabs.addTab(self._build_tab_cognex(), "코그넥스")
self._tabs.addTab(self._build_tab_basler(), "Basler")
self._tabs.addTab(self._build_tab_db(), "DB")
self._tabs.addTab(self._build_tab_mes(), "MES 제품")
self._tabs.addTab(self._build_tab_ai(), "AI 모델")
self._tabs.addTab(self._build_tab_conveyor(), "컨베이어")
self._tabs.addTab(self._build_tab_plc(), "PLC")
@@ -387,6 +404,74 @@ class AdminSettingsDialog(QDialog):
form.addRow("", self._btn_pair(btn_connect, btn_save))
return self._tab_wrap(g)
# ── 탭 3b — MES 제품 선택 ───────────────────────────────────────── #
def _build_tab_mes(self) -> QWidget:
w = QWidget()
w.setStyleSheet("background:#1a1a1a;")
layout = QVBoxLayout(w)
layout.setContentsMargins(32, 24, 32, 20)
layout.setSpacing(12)
hint = QLabel(
"제품 등록 탭의 'MES 불러오기'에 표시할 제품을 선택합니다.\n"
"DB 연결 후 목록을 불러온 뒤 체크하고 저장하세요."
)
hint.setStyleSheet("color:#888888; font-size:13px; background:transparent;")
hint.setWordWrap(True)
layout.addWidget(hint)
btn_row = QHBoxLayout()
btn_row.setSpacing(8)
btn_load = QPushButton("목록 불러오기")
btn_load.setFixedHeight(42)
btn_load.setStyleSheet(_BTN_DLG.replace("min-height:56px", "min-height:42px"))
btn_load.clicked.connect(self._on_mes_load)
btn_all = QPushButton("전체 선택")
btn_all.setFixedHeight(42)
btn_all.setStyleSheet(_BTN_DLG.replace("min-height:56px", "min-height:42px"))
btn_all.clicked.connect(lambda: self._on_mes_set_all(True))
btn_none = QPushButton("전체 해제")
btn_none.setFixedHeight(42)
btn_none.setStyleSheet(_BTN_DLG.replace("min-height:56px", "min-height:42px"))
btn_none.clicked.connect(lambda: self._on_mes_set_all(False))
btn_row.addWidget(btn_load, stretch=2)
btn_row.addWidget(btn_all, stretch=1)
btn_row.addWidget(btn_none, stretch=1)
layout.addLayout(btn_row)
self._mes_list = _TouchCheckList()
self._mes_list.setSelectionMode(QListWidget.NoSelection)
self._mes_list.setMinimumHeight(380)
self._mes_list.setStyleSheet("""
QListWidget {
background:#1a1a1a; border:1px solid #333333;
border-radius:4px; outline:none; font-size:15px;
}
QListWidget::item {
padding:12px 16px; border-bottom:1px solid #2a2a2a;
}
QListWidget::indicator { width:0px; height:0px; }
""")
self._mes_list.itemChanged.connect(self._on_mes_item_changed)
layout.addWidget(self._mes_list, stretch=1)
self._mes_count_lbl = QLabel("선택: 0 / 0")
self._mes_count_lbl.setStyleSheet("color:#888888; font-size:13px; background:transparent;")
layout.addWidget(self._mes_count_lbl)
btn_save = QPushButton("선택 저장")
btn_save.setFixedHeight(56)
btn_save.setStyleSheet(_BTN_DLG_PRIMARY)
btn_save.clicked.connect(self._on_mes_save)
layout.addWidget(btn_save)
return w
# ── 탭 4 — AI 모델 ──────────────────────────────────────────────── #
def _build_tab_ai(self) -> QWidget:
@@ -538,10 +623,60 @@ class AdminSettingsDialog(QDialog):
btn_close.setStyleSheet(_BTN_DLG)
btn_close.clicked.connect(self.accept)
# 터치 키보드 표시/숨김 토글 버튼 (물리 키보드 없는 터치 모니터용)
self._kb_btn = QPushButton("\u2328") # ⌨ 키보드 글리프
self._kb_btn.setCheckable(True)
self._kb_btn.setFixedSize(56, 56)
self._kb_btn.setToolTip("터치 키보드 표시 / 숨기기")
self._kb_btn.setStyleSheet(
"QPushButton {"
" background:#2a2a2a; color:#cccccc; border:1px solid #555555;"
" border-radius:4px; font-size:24px;"
"}"
"QPushButton:hover { background:#333333; color:#ffffff; }"
"QPushButton:checked { background:#1D9E75; color:#ffffff; border:none; }"
)
self._kb_btn.toggled.connect(self._on_toggle_keyboard)
row.addWidget(self._kb_btn)
row.addWidget(btn_save_all, stretch=2)
row.addWidget(btn_close, stretch=1)
return bar
# ── 터치 키보드 토글 ─────────────────────────────────────────────── #
def _on_toggle_keyboard(self, checked: bool):
if checked:
if self._show_touch_keyboard():
log_action("[설정] 터치 키보드 표시")
else:
# 실패 시 토글 상태 원복 (시그널 재발생 방지)
self._kb_btn.blockSignals(True)
self._kb_btn.setChecked(False)
self._kb_btn.blockSignals(False)
QMessageBox.warning(
self, "터치 키보드",
"터치 키보드를 열 수 없습니다.\n"
"Windows 터치 키보드 또는 화상 키보드를 사용할 수 있는지 확인하세요.",
)
else:
self._hide_touch_keyboard()
log_action("[설정] 터치 키보드 숨김")
def _show_touch_keyboard(self) -> bool:
"""Windows 터치 키보드(TabTip)를 ITipInvocation COM으로 표시한다."""
return show_touch_keyboard()
def _hide_touch_keyboard(self):
"""표시 중인 터치 키보드를 숨긴다."""
hide_touch_keyboard()
def done(self, result: int):
# 키보드를 켠 상태에서만 숨김 (Toggle은 반전이라 미사용 시 닫으면 오히려 켜짐)
if self._kb_btn.isChecked():
self._hide_touch_keyboard()
super().done(result)
@staticmethod
def _make_group(title: str):
from PyQt5.QtWidgets import QGroupBox
@@ -688,6 +823,95 @@ class AdminSettingsDialog(QDialog):
self._sp._config.setdefault("db", {}).update(cfg)
QMessageBox.information(self, "저장", "DB 설정이 저장되었습니다.")
# ── MES 제품 탭 슬롯 ─────────────────────────────────────────────── #
def _get_mes_selected_ids(self) -> list:
ids = []
for i in range(self._mes_list.count()):
item = self._mes_list.item(i)
if item.checkState() == Qt.Checked:
ids.append(item.data(Qt.UserRole))
return ids
def _update_mes_count(self):
total = self._mes_list.count()
selected = len(self._get_mes_selected_ids())
self._mes_count_lbl.setText(f"선택: {selected} / {total}")
@staticmethod
def _style_mes_item(item):
name = item.data(Qt.UserRole + 1) or item.text().lstrip("").lstrip(" ")
item.setData(Qt.UserRole + 1, name)
if item.checkState() == Qt.Checked:
item.setText(f"{name}")
item.setBackground(QColor("#666666"))
item.setForeground(QColor("#ffffff"))
else:
item.setText(f" {name}")
item.setBackground(QColor("#1e1e1e"))
item.setForeground(QColor("#888888"))
def _on_mes_item_changed(self, item):
self._style_mes_item(item)
self._update_mes_count()
def _on_mes_set_all(self, checked: bool):
state = Qt.Checked if checked else Qt.Unchecked
self._mes_list.blockSignals(True)
for i in range(self._mes_list.count()):
item = self._mes_list.item(i)
item.setCheckState(state)
self._style_mes_item(item)
self._mes_list.blockSignals(False)
self._update_mes_count()
def _on_mes_load(self):
client = self._sp._db_client
if not client or not client.is_connected():
QMessageBox.warning(
self, "경고",
"DB가 연결되어 있지 않습니다.\n"
"DB 탭에서 먼저 연결해주세요.",
)
return
items = client.get_all_articles()
if not items:
QMessageBox.warning(self, "경고", "조회된 제품이 없습니다.")
return
saved_ids = set(self._sp._config.get("mes", {}).get("selected_article_ids", []))
self._mes_list.blockSignals(True)
self._mes_list.clear()
for item in items:
li = QListWidgetItem(item["article"])
li.setSizeHint(QSize(0, 52))
li.setFlags(li.flags() | Qt.ItemIsUserCheckable)
li.setData(Qt.UserRole, item["article_id"])
li.setData(Qt.UserRole + 1, item["article"])
li.setToolTip(
f"ID: {item['article_id']} | 모델: {item.get('buyer_article_no', '')}"
)
li.setCheckState(
Qt.Checked if item["article_id"] in saved_ids else Qt.Unchecked
)
self._style_mes_item(li)
self._mes_list.addItem(li)
self._mes_list.blockSignals(False)
self._update_mes_count()
log_action(f"[설정] MES 제품 목록 불러오기: {len(items)}")
def _on_mes_save(self):
selected_ids = self._get_mes_selected_ids()
log_action(f"[설정] MES 제품 선택 저장: {len(selected_ids)}")
self._sp._save_config({"mes": {"selected_article_ids": selected_ids}})
self._sp._config.setdefault("mes", {})["selected_article_ids"] = selected_ids
QMessageBox.information(
self, "저장",
f"{len(selected_ids)}개 제품이 MES 불러오기 목록에 저장되었습니다.",
)
# ── AI 탭 슬롯 ───────────────────────────────────────────────────── #
def _on_ai_browse(self):
@@ -778,6 +1002,13 @@ class AdminSettingsDialog(QDialog):
"ip": self._plc_ip.text().strip(),
"port": self._plc_port.value(),
},
"mes": {
"selected_article_ids": (
self._get_mes_selected_ids()
if self._mes_list.count() > 0
else self._sp._config.get("mes", {}).get("selected_article_ids", [])
),
},
}
try:
self._sp._save_config(data)