# 검사 페이지 — 코그넥스/Basler 영상, 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, 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 = { "스크래치": (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 # ================================================================== # # 백그라운드 워커 — 파이프라인 방식 # # [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) 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 # ── 외부 제어 ──────────────────────────────────────────────────── # 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()) # ── 모델 판별 (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: 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}") 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, 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 = {"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() self.refresh_wk_results() # ================================================================== # # 최상위 레이아웃 # ================================================================== # 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_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_products(self) -> QWidget: w = QWidget() layout = QVBoxLayout(w) layout.setContentsMargins(4, 4, 8, 4) layout.setSpacing(6) g = QGroupBox("검사 대상") self._products_group = g g.setStyleSheet( "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; }" ) outer = QVBoxLayout(g) outer.setSpacing(0) outer.setContentsMargins(4, 2, 4, 2) scroll = QScrollArea() scroll.setWidgetResizable(True) scroll.setFrameShape(QFrame.NoFrame) scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) scroll.setStyleSheet("background:transparent;") QScroller.grabGesture(scroll.viewport(), QScroller.LeftMouseButtonGesture) 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) scroll.setWidget(inner) outer.addWidget(scroll) layout.addWidget(g, stretch=1) return w 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._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) 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:13px; color:#999999;") self._result_lbl = QLabel("대기 중") self._result_lbl.setAlignment(Qt.AlignCenter) self._result_lbl.setStyleSheet( "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._scope_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(["", "집계"]): 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) 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) 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_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): 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"): self._counts[key] = 0 self._cnt_lbls[key].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") 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 슬롯 (메인 스레드) # ================================================================== # def _on_basler_ready(self, frame, detections): self._display_basler_image(frame, detections=detections) def _on_result(self, data: dict): matched = data["matched"] result = data["result"] result_info = data["result_info"] self._model_lbl.setText(result_info["status"]) self._counts["total"] += 1 if not matched: self._counts["unknown"] += 1 elif result == "PASS": self._counts["pass"] += 1 else: self._counts["fail"] += 1 for key in ("total", "pass", "fail", "unknown"): self._cnt_lbls[key].setText(str(self._counts[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:64px; 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)