버전 업그레이드

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;"
)