본문 바로가기
순수 함수 inspect()의 배경 추정·차분·임계·연결 성분·크기 필터 단계와 PySide6 창의 파라미터·디바운스·오버레이·판정·CSV 기록 흐름을 연결한 구조도
비전 알고리즘

PySide6와 OpenCV로 만드는 비전 검사 프로그램

ALGORITHM

라인 옆 검사 화면에 필요한 것은 파라미터 다섯 개와 결과 오버레이, 그리고 판정과 같은 파일에 남는 파라미터 한 줄입니다.

PySide6와 OpenCV로 220줄 남짓한 검사 UI를 만들어 합성 영상으로 돌리며, 60 μm 결함 기준과 QImage 변환 함정, 디바운스 연결 실수를 숫자로 확인했습니다.

읽는 시간 약 13분

결함 검출 알고리즘은 대개 노트북의 스크립트나 주피터 셀에서 태어납니다. 임계값 25, 커널 31 px 같은 숫자가 코드 한가운데 박혀 있고, 결과는 창 하나에 띄운 영상으로 확인하죠. 개발자 책상에서는 이것으로 충분합니다. 라인 옆으로 옮기는 순간부터 사정이 달라집니다.

파라미터를 바꾸려면 코드를 열어야 하니 현장 조정은 개발자가 올 때까지 멈춥니다. 누가 언제 어떤 값으로 판정했는지도 남지 않아서, 과검출이나 미검출이 다시 나와도 원인을 거슬러 올라갈 수 없습니다. 조정이 막힌 검사기는 임계를 느슨하게 둔 채 돌아가고, 그 틈으로 가장 작은 결함부터 빠져나갑니다. 예제로 쓰는 합성 영상은 무광 사출면처럼 빛을 고르게 흩는 표면에 좌우 밝기 차와 센서 노이즈를 얹은 것이라, 반사가 까다로운 실물 영상보다 조건이 순한 편입니다.

제가 권하는 해법은 큰 검사 소프트웨어가 아니라 작은 골격입니다. 검사 로직을 순수 함수1로 떼어 내고, 그 위에 파라미터 입력과 결과 오버레이, 판정, CSV 기록만 얹습니다. PySide6와 OpenCV로 이 골격을 220줄 남짓으로 만들어 실제로 실행해 보면, 숫자로 확인해야 할 지점이 세 군데 나옵니다.

검사 로직은 화면 밖 함수로 둔다

저는 검사 로직을 UI 클래스 안에 두지 않습니다. 영상과 파라미터를 받아 결과만 돌려주는 함수 inspect()로 따로 둡니다. 로직이 화면 코드와 섞이면 같은 판정을 배치 재검사나 단위 테스트로 재현할 수 없고, UI 프레임워크를 바꿀 때 알고리즘까지 다시 써야 하거든요. 함수로 떼어 두면 화면 없이도 같은 입력에 같은 출력이 나오는지 먼저 확인할 수 있습니다.

예제의 inspect()는 네 단계입니다. 결함보다 큰 커널의 미디언 블러2로 조명 셰이딩만 남긴 배경을 만들고, 결함 극성에 맞춰 배경과의 차이를 구한 뒤, 계조 임계로 이진화하고, 8-연결 성분 라벨링3 뒤에 최소 길이 필터를 적용합니다. 최소 길이는 최소 결함 ÷ 화소 분해능4으로 환산하는데, 60 μm ÷ 20 μm/px = 3 px입니다. 합성 테스트 영상(1,024 × 768 px)에서 이 함수는 스크래치 하나와 흑점 하나, 결함 두 개를 채택하고, 임계는 넘었지만 2 × 2 px에 불과한 미세점 하나를 크기 필터로 걸러 냅니다.

배경 커널 크기도 숫자로 확인해 둘 만합니다. 커널을 31 px에서 15 px로 줄여도 결과는 같지만, 흑점 지름과 같은 11 px로 줄이면 스크래치 면적이 1,435 px에서 1,264 px로 깎이고, 9 px에서는 결함이 여섯 조각으로 쪼개지며, 5 px에서는 3 px짜리 조각 두 개만 남습니다. 커널이 결함보다 작으면 미디언 블러가 결함까지 배경으로 삼키기 때문이에요. 제 기준은 가장 큰 결함 폭보다 확실히 큰 홀수 커널입니다.

3px

60 μm ÷ 20 μm/px, 크기 필터가 남기는 최소 길이

11px

배경 커널이 넘어야 할 가장 큰 결함 폭(흑점 지름)

8.3배

임계 25계조 ÷ 합성 노이즈 σ 3계조

세 숫자는 모두 μm와 계조로 거슬러 올라갑니다. 파라미터를 px가 아니라 μm로 받는 이유가 여기 있어요. 렌즈나 FOV가 바뀌어도 분해능 값 하나만 고치면 불량 기준은 그대로 유지되고, 화면의 숫자와 사양서의 숫자가 같은 단위로 맞물립니다.

화면에는 알고리즘이 쓰는 숫자만 올린다

화면에는 알고리즘이 실제로 쓰는 입력 다섯 개(분해능, 최소 결함, 배경 커널, 임계, 극성)와 판정, 결과표, CSV 저장만 둡니다. 입력을 알고리즘 파라미터로 제한하면 화면의 값과 판정의 원인이 1:1로 대응합니다. 알고리즘에 쓰이지 않는 옵션이 화면에 늘어날수록, 결과가 달라졌을 때 무엇이 바뀌었는지 추적하기 어려워집니다.

PySide6 검사 창에서 합성 테스트 영상의 스크래치와 흑점이 빨강 오버레이와 노란 사각형으로 표시되고 NG 2건 판정과 결과표가 나온 실제 실행 화면
판정 아래 정보 줄의 ‘components 3, removed by size 1’이 크기 필터가 버린 미세점 하나를 알려 줍니다.

실행 화면에서는 오른쪽 위 판정 칸과 그 아래 정보 줄을 함께 보면 됩니다. 판정은 ‘NG, 결함 2건’이고, 정보 줄에는 최소 길이 3.0 px와 함께 성분 3개 가운데 1개가 크기 때문에 빠졌다는 기록이 붙습니다. 오버레이는 채택된 결함을 빨강, 임계는 넘었지만 크기 필터로 제외된 성분을 파랑으로 칠해서, 제외된 것이 보이지 않은 것인지 작아서 버린 것인지 화면에서 가릅니다. 결과표의 길이 5,300 μm와 220 μm는 외접 사각형 장변 265 px와 11 px에 20 μm/px를 곱한 값입니다.

스핀박스를 돌릴 때마다 재검사하면 연속 입력 중에 거쳐 가는 중간값으로도 검사가 반복됩니다. 그래서 예제는 150 ms 단발 타이머로 입력을 모아 마지막 값으로 한 번만 검사하는 디바운스5를 씁니다. 여기서 시그널과 슬롯6을 잇는 한 줄이 함정입니다. 스핀박스의 valueChanged(int)를 QTimer.start에 바로 이으면 바뀐 값이 start(msec)의 인자로 넘어가, 임계를 40으로 바꾸는 순간 디바운스 간격이 150 ms에서 40 ms로 바뀝니다. 예제는 인자를 버리는 람다로 연결해 간격을 지킵니다.

판정 근거는 화면과 파일에 함께 남아야 합니다. CSV에는 결과 행보다 먼저 분해능, 최소 결함, 커널, 임계, 극성 한 줄이 기록됩니다. 저는 이 한 줄을 화면보다 무겁게 봅니다. 과검출이나 미검출이 다시 나왔을 때 ‘그때 임계가 얼마였나’부터 확인할 수 있어야 조명 쪽 변화와 설정 변화를 갈라 볼 수 있으니까요.

폭 1,021 px 영상은 행마다 3바이트씩 밀린다

numpy 배열을 QImage로 넘길 때는 bytesPerLine7, 곧 한 행의 바이트 수를 반드시 적습니다. Qt 문서는 이 값을 받지 않는 생성자에 대해 데이터와 각 스캔라인이 32비트로 정렬되어 있어야 한다고 적고 있습니다. 8비트 그레이 영상의 폭이 4의 배수가 아니면 numpy의 행 간격과 Qt가 가정한 행 간격이 어긋나고, 행마다 몇 바이트씩 밀리면서 영상이 대각선으로 기웁니다. QImage는 버퍼를 복사하지 않고 그대로 쓰기 때문에, 버퍼는 QImage가 살아 있는 동안 유효해야 하고요.

같은 8비트 영상을 bytesPerLine 없이 변환하면 대각선으로 기울고 결함 모양이 바뀌며, strides[0]을 넘기면 정상으로 표시되는 QImage 변환 비교
같은 버퍼를 bytesPerLine 없이 넘긴 왼쪽은 행마다 3바이트씩 밀려 가로 265 px 스크래치가 37 px 막대로 서고, strides[0]을 넘긴 오른쪽은 원래 모양을 지킵니다.

폭 1,021 px 영상을 bytesPerLine 없이 넘기면 Qt는 한 행을 1,024바이트로 읽어 행마다 3바이트씩 밀립니다. 그림 왼쪽에서 외접 사각형 가로 265 px의 스크래치는 가로 37 px의 가파른 막대로, 지름 11 px의 흑점은 가로 31 px의 사선으로 바뀝니다. 768행을 읽는 동안 모자라는 2,304바이트, 약 2.25행은 원래 영상 밖에서 읽히는데, 재현할 때는 0으로 채운 여유를 버퍼 끝에 두어 맨 아래에 검은 띠로 남게 했습니다. 결함의 길이와 위치가 함께 바뀌니, 표시 좌표로 계산하는 측정은 모두 틀어집니다.

예제의 to_qimage()는 img.strides[0], 곧 numpy가 실제로 쓰는 행 간격인 스트라이드8를 bytesPerLine으로 넘기고, .copy()로 numpy 버퍼와의 의존을 끊습니다. 카메라 SDK의 ROI 크롭은 임의 폭의 영상을 만들기 쉬워요. 저라면 시험 영상 하나는 일부러 폭을 4의 배수가 아닌 값으로 잡아 변환 경로를 검증해 둡니다.

60 μm 결함을 세는 세 줄

예제의 숫자를 사양서 문장으로 옮기면 아래 세 줄이 됩니다. 영상 파일을 입력으로 받는 예제라서 광학 줄에는 화소 분해능과 FOV 가정만 적고, 알고리즘 줄은 네 칸으로 나눴습니다.

항목기준값조건·근거풀어 쓰면
① 최소 불량 사이즈길이 60 μm = 3칸20 μm/px에서 60 ÷ 20, 예제 가정결함이 화소 세 칸 이상이면 남긴다
② 광학 셋업 · WD8비트 그레이 1,024 × 768 px, 20 μm/px(FOV 약 20.48 mm 가정)영상 파일 입력 예제라 WD는 다루지 않음. 실제 셋업은 렌즈 계산으로 WD와 조명 설치 공간부터 확보이 예제는 카메라 뒤쪽만 다룬다
③ 배경 추정미디언 블러 31 px가장 큰 결함 폭(흑점 11 px)보다 커야 함. 11 px이면 스크래치 면적 1,435 → 1,264 px, 9 px이면 여섯 조각결함보다 큰 창으로 배경만 걸러 낸다
③ 임계 · 극성dark, 25계조합성 노이즈 σ 3계조의 약 8.3배. 80계조에서 1개, 90계조에서 0개배경보다 25만큼 어두우면 후보
③ 크기 필터8-연결, 외접 사각형 장변 3칸 이상60 ÷ 20. 20 μm로 낮추면 2 × 2 px 미세점까지 3개세 칸보다 짧은 점은 버린다
③ UI 재검사디바운스 150 ms연속 입력은 마지막 값으로 한 번만 검사. 인자를 버리는 람다로 연결손을 뗀 뒤 150 ms 뒤에 한 번

이 예제의 검출 하한은 크기 필터 3칸과 임계 25계조, 두 값이 함께 정합니다. 합성 영상에서 임계를 90계조로 올리면 결함이 0개가 되고, 최소 결함을 20 μm로 낮추면 제외됐던 2 × 2 px 미세점까지 3개가 잡힙니다. 제 눈에는 UI에서 두 값을 돌리는 손이 곧 불량 기준을 바꾸는 손으로 보입니다. 그래서 파라미터를 결과와 같은 파일에 남겨야 합니다.

이 최소 UI가 맞지 않는 자리도 있습니다. 판정 로직이 딥러닝 모델이라면 화면 골격은 같아도 기록할 대상이 임계값보다 모델 버전과 입력 전처리 조건으로 바뀝니다(딥러닝과 룰베이스 비교 글). 카메라 여러 대에 택트가 짧다면 GUI 스레드에서 검사를 돌리는 순간 화면 갱신과 검사가 서로를 막으니, 검사는 별도 스레드나 프로세스로 빼고 화면은 결과 표시만 맡겨야 합니다. 파라미터 이력 관리가 이미 되는 검사 플랫폼이라면 별도 UI보다 그 기능이 변경 추적에 유리하고요. 실제 부품 영상에서 난반사가 심하면 배경 커널과 임계를 예제 값 그대로 쓸 수 있을지는 샘플 테스트 전 확언 불가입니다.

전체 코드와 실행 준비

실행 준비는 세 단계입니다. 먼저 python -m venv .venv로 가상환경9을 만들고 활성화합니다(macOS와 Linux는 source .venv/bin/activate, Windows는 .venv\Scripts\activate). 다음으로 pip install PySide6 opencv-python-headless numpy로 패키지를 설치합니다. OpenCV는 GUI 기능을 뺀 headless 패키지10 하나만 설치하는데, PyPI 안내도 OpenCV 패키지 네 종류 가운데 하나만 고르라고 적고 있습니다. 마지막으로 python nv_inspect_ui.py(합성 테스트 영상) 또는 python nv_inspect_ui.py part.png로 실행합니다.

아래 코드는 앞에서 설명한 네 부분을 순서대로 담고 있습니다. 파라미터 데이터클래스, 화면과 분리된 inspect(), stride를 넘기는 to_qimage(), 디바운스 타이머를 단 메인 창입니다. 식과 주의점은 코드 주석에도 함께 적어 두었습니다.

# -*- coding: utf-8 -*-
"""
nv_inspect_ui.py — PySide6 + OpenCV 최소 비전 검사 UI (예제)

실행 환경
  - Python 3.10 이상, 가상환경(venv) 권장
      python -m venv .venv && source .venv/bin/activate   (Windows: .venv\\Scripts\\activate)
      pip install PySide6 opencv-python-headless numpy
  - OpenCV는 반드시 headless 패키지 하나만 설치한다.
    (opencv-python 과 opencv-python-headless 를 한 환경에 함께 설치하지 않는다)

사용법
  python nv_inspect_ui.py              # 합성 테스트 영상으로 시작
  python nv_inspect_ui.py part.png     # 저장된 검사 영상(8-bit 그레이/컬러)으로 시작
"""
import csv
import sys
from dataclasses import dataclass

import cv2
import numpy as np
from PySide6.QtCore import Qt, QTimer
from PySide6.QtGui import QImage, QPixmap
from PySide6.QtWidgets import (
    QApplication, QComboBox, QDoubleSpinBox, QFileDialog, QFormLayout, QHBoxLayout,
    QLabel, QMainWindow, QPushButton, QSpinBox, QTableWidget, QTableWidgetItem,
    QVBoxLayout, QWidget,
)


# ---------------------------------------------------------------------------
# 1) 검사 파라미터 — 화면의 입력 위젯과 1:1로 대응
# ---------------------------------------------------------------------------
@dataclass
class InspectParams:
    um_per_px: float = 20.0      # 화소 분해능 [μm/px] = FOV[mm] × 1000 / 가로 화소 수
    min_defect_um: float = 60.0  # 검출해야 할 최소 결함 길이 [μm]
    bg_kernel_px: int = 31       # 배경 추정 커널 [px] — 최대 결함 폭보다 커야 결함이 배경으로 먹히지 않음
    threshold: int = 25          # 배경 대비 밝기 차 임계 [계조, 0~255]
    polarity: str = "dark"       # "dark": 배경보다 어두운 결함, "bright": 밝은 결함


# ---------------------------------------------------------------------------
# 2) 검사 로직 — UI와 분리된 순수 함수 (단위 테스트·배치 처리에 그대로 재사용)
# ---------------------------------------------------------------------------
def inspect(gray: np.ndarray, p: InspectParams):
    """그레이 영상 1장을 검사해 (결함 목록, 임계 마스크, 채택 마스크)를 돌려준다."""
    # (a) 배경 추정: 결함보다 큰 커널의 미디언 블러 → 조명 불균일(셰이딩)만 남긴다
    k = max(3, p.bg_kernel_px | 1)                 # 커널은 홀수여야 함
    background = cv2.medianBlur(gray, k)

    # (b) 배경 차분: 결함 극성에 맞춰 '배경보다 얼마나 벗어났는가'만 남긴다
    if p.polarity == "dark":
        diff = cv2.subtract(background, gray)      # 포화 뺄셈(음수 → 0)
    else:
        diff = cv2.subtract(gray, background)

    # (c) 임계 이진화
    _, mask = cv2.threshold(diff, p.threshold, 255, cv2.THRESH_BINARY)

    # (d) 연결 성분 라벨링(8-연결) + 크기 필터
    #     최소 길이[px] = 최소 결함[μm] ÷ 분해능[μm/px]
    #     예) 60 μm ÷ 20 μm/px = 3 px → 이보다 짧은 성분은 노이즈로 버린다
    min_len_px = p.min_defect_um / p.um_per_px
    n, labels, stats, _ = cv2.connectedComponentsWithStats(mask, connectivity=8)
    defects = []
    kept = np.zeros_like(mask)                      # 크기 필터를 통과한 성분만 담는 마스크
    for i in range(1, n):                           # 0번은 배경
        x, y, w, h, area = stats[i]
        length_px = max(w, h)                       # 외접 사각형 장변을 길이로 근사
        if length_px < min_len_px:
            continue                                # 임계는 넘었지만 최소 결함보다 작음 → 제외
        kept[labels == i] = 255
        defects.append({
            "id": len(defects) + 1,
            "x": int(x), "y": int(y), "w": int(w), "h": int(h),
            "area_px": int(area),
            "length_um": round(float(length_px * p.um_per_px), 1),   # px → μm 환산
        })
    return defects, mask, kept


# ---------------------------------------------------------------------------
# 3) numpy → QImage 변환 — stride(bytesPerLine)를 반드시 명시
# ---------------------------------------------------------------------------
def to_qimage(img: np.ndarray) -> QImage:
    img = np.ascontiguousarray(img)
    h, w = img.shape[:2]
    if img.ndim == 2:
        qimg = QImage(img.data, w, h, img.strides[0], QImage.Format_Grayscale8)
    else:
        rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
        qimg = QImage(rgb.data, w, h, rgb.strides[0], QImage.Format_RGB888)
    # QImage는 numpy 버퍼를 복사하지 않고 참조만 한다 → 함수가 끝나면 버퍼가 사라질 수 있으므로 복사본을 돌려준다
    return qimg.copy()


def make_test_image(w=1024, h=768, seed=7) -> np.ndarray:
    """합성 테스트 영상: 셰이딩 + 노이즈 + 스크래치 1개 + 흑점 1개 + 3 px 미만 미세점 1개."""
    rng = np.random.default_rng(seed)
    yy, xx = np.mgrid[0:h, 0:w]
    img = 150 + 40 * (xx / w) - 25 * ((yy - h / 2) / h) ** 2      # 좌→우 밝아지는 조명 불균일
    img = img + rng.normal(0, 3.0, (h, w))                        # 센서 노이즈(σ = 3 계조)
    img = np.clip(img, 0, 255).astype(np.uint8)
    cv2.line(img, (260, 220), (520, 300), 105, 3)                 # 스크래치: 폭 3 px
    cv2.circle(img, (760, 520), 5, 95, -1)                        # 흑점: 지름 약 11 px
    img[600:602, 420:422] = 110                                   # 미세점: 2 × 2 px(3 px 미만) → 크기 필터로 제외되어야 함
    return img


# ---------------------------------------------------------------------------
# 4) 메인 창 — 파라미터를 바꾸면 150 ms 뒤 재검사(디바운스)
# ---------------------------------------------------------------------------
class MainWindow(QMainWindow):
    def __init__(self, gray: np.ndarray):
        super().__init__()
        self.setWindowTitle("Minimal Vision Inspection — PySide6 + OpenCV")
        self.gray = gray
        self.defects = []

        # 영상 표시 영역
        self.view = QLabel(alignment=Qt.AlignCenter)
        self.view.setMinimumSize(640, 480)

        # 파라미터 입력 위젯
        self.sp_res = QDoubleSpinBox(decimals=2, minimum=0.5, maximum=500.0, value=20.0, suffix=" um/px")
        self.sp_min = QDoubleSpinBox(decimals=1, minimum=1.0, maximum=5000.0, value=60.0, suffix=" um")
        self.sp_ker = QSpinBox(minimum=3, maximum=151, singleStep=2, value=31, suffix=" px")
        self.sp_thr = QSpinBox(minimum=1, maximum=255, value=25)
        self.cb_pol = QComboBox(); self.cb_pol.addItems(["dark", "bright"])
        form = QFormLayout()
        form.addRow("Resolution", self.sp_res)
        form.addRow("Min defect", self.sp_min)
        form.addRow("Background kernel", self.sp_ker)
        form.addRow("Threshold (gray)", self.sp_thr)
        form.addRow("Defect polarity", self.cb_pol)

        # 판정·결과 표·버튼
        self.lb_judge = QLabel(alignment=Qt.AlignCenter)
        self.lb_judge.setStyleSheet("font-size:22px;font-weight:bold;padding:8px;")
        self.lb_info = QLabel()                     # 최소 길이[px]와 크기 필터로 제외된 성분 수 표시
        self.table = QTableWidget(0, 4)
        self.table.setHorizontalHeaderLabels(["ID", "Length (um)", "Area (px)", "BBox x,y,w,h"])
        self.table.horizontalHeader().setStretchLastSection(True)
        self.table.verticalHeader().setVisible(False)
        bt_open = QPushButton("Open image…"); bt_open.clicked.connect(self.open_image)
        bt_csv = QPushButton("Save CSV log…"); bt_csv.clicked.connect(self.save_csv)

        side = QVBoxLayout()
        side.addLayout(form); side.addWidget(self.lb_judge); side.addWidget(self.lb_info); side.addWidget(self.table)
        side.addWidget(bt_open); side.addWidget(bt_csv)
        root = QHBoxLayout(); root.addWidget(self.view, 3); root.addLayout(side, 2)
        central = QWidget(); central.setLayout(root); self.setCentralWidget(central)

        # 디바운스 타이머: 스핀박스를 연속으로 돌려도 마지막 값으로 한 번만 검사
        self.timer = QTimer(self, singleShot=True, interval=150)
        self.timer.timeout.connect(self.run)
        # 주의: valueChanged(int)를 timer.start에 바로 연결하면 값이 start(msec) 인자로 넘어가
        #       디바운스 간격이 바뀐다 → 인자를 버리는 람다로 연결
        for w in (self.sp_res, self.sp_min, self.sp_ker, self.sp_thr):
            w.valueChanged.connect(lambda *_: self.timer.start())
        self.cb_pol.currentIndexChanged.connect(lambda *_: self.timer.start())
        self.run()

    def params(self) -> InspectParams:
        return InspectParams(self.sp_res.value(), self.sp_min.value(),
                             self.sp_ker.value(), self.sp_thr.value(), self.cb_pol.currentText())

    def run(self):
        p = self.params()
        self.defects, mask, kept = inspect(self.gray, p)
        # 오버레이: 채택된 결함 픽셀은 빨강, 임계는 넘었지만 크기 필터로 제외된 픽셀은 파랑, 외접 사각형은 노랑
        vis = cv2.cvtColor(self.gray, cv2.COLOR_GRAY2BGR)
        vis[(mask > 0) & (kept == 0)] = (255, 120, 0)
        vis[kept > 0] = (0, 0, 255)
        n_all = cv2.connectedComponents(mask, connectivity=8)[0] - 1
        self.lb_info.setText(f"Min length = {p.min_defect_um / p.um_per_px:.1f} px  |  "
                             f"components {n_all}, removed by size {n_all - len(self.defects)}")
        for d in self.defects:
            cv2.rectangle(vis, (d["x"] - 4, d["y"] - 4), (d["x"] + d["w"] + 4, d["y"] + d["h"] + 4), (0, 200, 255), 2)
            cv2.putText(vis, str(d["id"]), (d["x"], d["y"] - 8), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 200, 255), 2)
        pix = QPixmap.fromImage(to_qimage(vis))
        self.view.setPixmap(pix.scaled(self.view.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation))
        # 판정: 결함이 1개라도 있으면 NG
        ng = len(self.defects) > 0
        self.lb_judge.setText(f"NG — {len(self.defects)} defect(s)" if ng else "OK")
        self.lb_judge.setStyleSheet("font-size:22px;font-weight:bold;padding:8px;color:#fff;"
                                    + ("background:#c0392b;" if ng else "background:#2e7d32;"))
        self.table.setRowCount(len(self.defects))
        for r, d in enumerate(self.defects):
            for c, v in enumerate([d["id"], d["length_um"], d["area_px"], f'{d["x"]},{d["y"]},{d["w"]},{d["h"]}']):
                self.table.setItem(r, c, QTableWidgetItem(str(v)))

    def resizeEvent(self, e):
        super().resizeEvent(e)
        self.timer.start()   # 창 크기가 바뀌면 표시 배율을 다시 맞춘다

    def open_image(self):
        path, _ = QFileDialog.getOpenFileName(self, "Open image", "", "Images (*.png *.bmp *.tif *.jpg)")
        if path:
            img = cv2.imread(path, cv2.IMREAD_GRAYSCALE)
            if img is not None:
                self.gray = img
                self.run()

    def save_csv(self):
        path, _ = QFileDialog.getSaveFileName(self, "Save CSV log", "defects.csv", "CSV (*.csv)")
        if not path:
            return
        p = self.params()
        with open(path, "w", newline="", encoding="utf-8") as f:
            wr = csv.writer(f)
            # 파라미터를 결과와 함께 남겨야 '왜 이 판정이 나왔는가'를 나중에 재현할 수 있다
            wr.writerow(["um_per_px", p.um_per_px, "min_defect_um", p.min_defect_um,
                         "bg_kernel_px", p.bg_kernel_px, "threshold", p.threshold, "polarity", p.polarity])
            wr.writerow(["id", "length_um", "area_px", "x", "y", "w", "h"])
            for d in self.defects:
                wr.writerow([d["id"], d["length_um"], d["area_px"], d["x"], d["y"], d["w"], d["h"]])


if __name__ == "__main__":
    app = QApplication(sys.argv)
    src = cv2.imread(sys.argv[1], cv2.IMREAD_GRAYSCALE) if len(sys.argv) > 1 else None
    win = MainWindow(src if src is not None else make_test_image())
    win.resize(1280, 760)
    win.show()
    sys.exit(app.exec())

실행하면 합성 영상 위에 NG 2건이 뜨고, 임계를 90계조로 올리면 결함이 사라지며, 최소 결함을 20 μm로 낮추면 미세점까지 세 개가 잡힙니다. 세 동작을 직접 해 보면 파라미터 두 개가 불량 기준을 어떻게 움직이는지 손으로 익히게 됩니다. 저장한 CSV를 열어 첫 줄의 파라미터가 화면 값과 같은지도 함께 확인해 보시길 권합니다.

검사 로직을 함수로 떼어 내고 화면을 얹는 연습은 파이썬으로 비전 일을 시작하는 사람에게 좋은 첫 과제입니다. 결과가 같은 입력에서 같게 나오는지 화면 없이 확인하는 습관이 먼저 생기거든요. 설비를 사는 쪽에서 보면, 검사기가 판정마다 파라미터를 함께 남기는지가 나중에 불량 원인을 따질 때의 비용을 정합니다. 이 글은 기술 설명이며 투자 권유가 아닙니다. 자세한 내용은 면책조항을 참고하세요.

현장 노트

임계값을 화면에서 바꾸는 검사 도구를 라인에 둔다면 저는 넘기기 전에 CSV 첫 줄에 파라미터가 남는지부터 확인합니다. 며칠 뒤 같은 불량이 다시 나왔을 때 당시의 임계와 커널이 남아 있지 않으면, 과검출이 조명 쪽 변화 때문인지 임계 변경 때문인지 가를 수 없기 때문입니다. 시험 영상은 폭 1,021 px처럼 4의 배수가 아닌 크기로 하나 만들어 표시 좌표를 맞춰 보고, 배경 커널이 가장 큰 결함 폭보다 큰지도 다시 잽니다. 이런 화면이 조명과 렌즈가 만들지 못한 콘트라스트를 메우지는 못한다는 점도 넘기는 문서에 함께 적어 둡니다.

현장 체크포인트

현장 노트의 확인 순서를 넘기는 문서의 체크 항목으로 옮기면 아래와 같습니다.

  • 이 UI로 들어올 영상을 만드는 카메라와 렌즈의 WD가 조명 설치 공간까지 포함해 확보되었는가? (영상이 좋아야 파라미터 조정이 의미가 있다)
  • 분해능 입력값(μm/px)이 실제 FOV ÷ 가로 화소 수와 일치하는가? (렌즈나 거리를 바꾸면 이 값부터 갱신)
  • 배경 커널이 가장 큰 결함 폭보다 큰가? (11 px 흑점이면 15 px 이상)
  • 폭이 4의 배수가 아닌 영상으로 표시 좌표와 화소값을 검증했는가? (1,021 px 시험 영상)
  • CSV 첫 줄에 파라미터가 결과와 함께 기록되는가? (분해능, 최소 결함, 커널, 임계, 극성)
  • 가상환경을 만들고 OpenCV는 headless 패키지 하나만 설치했는가? (두 패키지를 한 환경에 섞지 않는다)

자주 묻는 질문

PyQt 대신 PySide6를 쓴 이유가 있나요?

이 예제의 코드는 두 바인딩에서 거의 같습니다. PySide6는 Qt 프로젝트가 직접 제공하는 공식 파이썬 바인딩이라 문서와 버전을 Qt와 맞춰 보기 쉽다는 점에서 골랐습니다. 배포할 때의 라이선스 조건은 쓰는 쪽의 정책에 맞춰 따로 확인해야 합니다.

실제 카메라 영상은 어떻게 연결하나요?

inspect()는 8비트 그레이 numpy 배열만 받으니, 카메라 SDK가 준 프레임을 같은 형식으로 바꿔 넘기면 됩니다. 다만 실시간 취득은 별도 스레드에서 받아 최신 프레임만 화면으로 넘기는 구조가 필요합니다. 화면 갱신이 취득을 기다리게 만들면 프레임이 쌓이거나 빠집니다.

컬러 영상을 넣으면 어떻게 되나요?

예제는 파일을 열 때 cv2.IMREAD_GRAYSCALE로 읽어 8비트 그레이로 바꿉니다. 표시용 오버레이만 BGR 컬러로 만들고, to_qimage()가 RGB로 바꿔 넘깁니다. 결함이 색으로만 구별된다면 그레이 변환 전에 채널을 고르는 단계가 따로 있어야 합니다.

용어 풀이

  • [1] 순수 함수 pure function
    같은 입력에는 언제나 같은 결과를 돌려주고 바깥 상태를 바꾸지 않는 함수입니다. 화면 없이도 시험할 수 있어 검사 로직을 담기 좋습니다. ↩
  • [2] 미디언 블러 median blur
    창 안 화소값을 크기순으로 세워 가운데 값으로 바꾸는 필터입니다. 창보다 작은 결함은 지우고 넓은 밝기 흐름만 남겨 배경 추정에 씁니다. ↩
  • [3] 연결 성분 라벨링 connected component labeling
    이진 영상에서 서로 붙은 화소 덩어리마다 번호를 매기는 처리입니다. 8-연결은 대각선으로 닿은 화소도 같은 덩어리로 봅니다. ↩
  • [4] 화소 분해능 pixel resolution, μm/px
    화소 한 칸이 검사 면에서 덮는 길이입니다. 20 μm/px면 화소 하나가 부품 위 20 μm를 담당합니다. ↩
  • [5] 디바운스 debounce
    짧은 시간 안에 연달아 들어온 입력을 모아 마지막 한 번만 처리하는 방법입니다. 엘리베이터 버튼을 여러 번 눌러도 한 번만 오는 것과 같습니다. ↩
  • [6] 시그널과 슬롯 signal and slot
    Qt에서 위젯의 사건(시그널)을 처리 함수(슬롯)에 잇는 방식입니다. 시그널이 값을 실어 보내면 그 값이 슬롯의 인자로 넘어갑니다. ↩
  • [7] bytesPerLine scanline bytes
    영상 한 행이 메모리에서 차지하는 바이트 수입니다. 화소 수와 같지 않을 수 있어 QImage에 따로 알려 줘야 합니다. ↩
  • [8] 스트라이드 stride
    배열에서 다음 행으로 넘어갈 때 건너뛰는 바이트 수입니다. numpy 배열의 strides[0]이 행 스트라이드입니다. ↩
  • [9] 가상환경 venv
    프로젝트마다 파이썬 패키지를 따로 설치해 두는 독립된 폴더 환경입니다. 다른 프로젝트의 OpenCV 버전과 섞이지 않게 해 줍니다. ↩
  • [10] headless 패키지 opencv-python-headless
    화면 창을 띄우는 GUI 기능을 빼고 빌드한 OpenCV 배포판입니다. 화면은 PySide6가 맡으므로 OpenCV 쪽 창 기능과 충돌하지 않습니다. ↩

참고 자료

함께 읽으면 좋은 글 — 딥러닝 vs 룰베이스(rule-based) 알고리즘, 언제 무엇을 쓰나 · 머신비전 검사, 소프트웨어보다 물리가 먼저다 · 고반사 표면 크랙은 가우시안보다 바이래터럴 필터가 남긴다

2005년부터 생산설비의 기계설계와 프로젝트 관리를 맡아 온 엔지니어입니다. 설비에 들어가는 카메라·렌즈·조명을 수식과 공개 자료로 계산하고, 가정과 출처를 함께 밝혀 기록합니다.

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다