PySide6와 OpenCV로 만드는 비전 검사 프로그램 — 결함 검출 파라미터를 화면에서 조정하는 최소 UI(전체 코드)
ALGORITHM / PYTHON
결함 검출 알고리즘은 대개 노트북의 스크립트나 주피터 셀에서 태어납니다. 임계값 25, 커널 31 px 같은 숫자가 코드 한가운데 박혀 있고, 결과는 창 하나에 띄운 영상으로 확인합니다. 개발자 책상에서는 이것으로 충분하지만, 라인 옆에서는 그렇지 않습니다.
파라미터를 바꾸려면 코드를 열어야 하니 현장 조정은 개발자가 올 때까지 멈추고, 누가 언제 어떤 값으로 판정했는지 남지 않아 과검출·미검출이 재발해도 원인을 거슬러 올라갈 수 없습니다. 조정이 막힌 검사기는 결국 임계를 느슨하게 둔 채 운영되고, 그 틈으로 가장 작은 결함부터 빠져나갑니다.
해법은 거창한 검사 소프트웨어가 아니라, 검사 로직을 순수 함수로 분리하고 그 위에 파라미터 입력·결과 오버레이·판정·CSV 기록만 얹은 최소 UI입니다. 이 글은 PySide6와 OpenCV로 그 골격을 약 220줄로 만들고, 실제로 실행한 화면과 함께 설계 포인트 세 가지를 정리합니다.
검사 UI의 목적은 화면을 꾸미는 것이 아니라, 판정에 쓰인 파라미터를 결과와 함께 남기는 것입니다.
1. 로직과 UI를 분리한다 — 순수 함수 inspect()
Point. 검사 로직은 UI 클래스 안이 아니라, 영상과 파라미터를 받아 결과만 돌려주는 순수 함수로 둡니다.
Reason. UI 안에 로직이 섞이면 같은 판정을 배치 재검사나 단위 테스트로 재현할 수 없고, UI 프레임워크를 바꿀 때 알고리즘까지 다시 써야 합니다. 함수로 분리하면 화면 없이도 같은 입력에 같은 출력이 나오는지 먼저 검증할 수 있습니다.
Example. 예제의 inspect()는 (a) 결함보다 큰 커널의 미디언 블러로 배경(조명 셰이딩)을 추정하고, (b) 결함 극성에 맞춰 배경 차분을 구한 뒤, (c) 계조 임계로 이진화하고, (d) 8-연결 성분 라벨링 후 최소 길이 필터를 적용합니다. 최소 길이는 최소 결함[µm] ÷ 화소 분해능[µm/px]으로 환산되며, 60 µm ÷ 20 µm/px = 3 px입니다. 합성 테스트 영상에서 이 함수는 스크래치와 흑점 2개를 채택하고, 임계는 넘었지만 2 × 2 px에 불과한 미세점 1개를 크기 필터로 제외했습니다.
Point. 파라미터를 px가 아니라 µm로 받는 것이 핵심입니다. 렌즈나 FOV가 바뀌어도 분해능 값 하나만 고치면 불량 기준은 그대로 유지됩니다.
2. 화면 — 파라미터·오버레이·판정·기록
Point. 화면에는 알고리즘이 실제로 쓰는 다섯 개의 입력(분해능·최소 결함·배경 커널·임계·극성)과 판정, 결과표, CSV 저장만 둡니다.

Reason. 입력을 알고리즘 파라미터로 제한하면 화면의 값과 판정의 원인이 1:1로 대응합니다. 반대로 알고리즘에 쓰이지 않는 옵션이 화면에 늘어날수록, 결과가 달라졌을 때 무엇이 바뀌었는지 추적하기 어려워집니다.
Example. 스핀박스를 돌릴 때마다 재검사하면, 연속 입력 중 거쳐 가는 중간값으로도 검사가 반복됩니다. 예제는 150 ms 단발 타이머로 입력을 모아(디바운스) 마지막 값으로 한 번만 검사합니다. 이때 스핀박스의 valueChanged(int)를 QTimer.start에 바로 연결하면 값이 start(msec)의 인자로 넘어가 디바운스 간격 자체가 바뀝니다. 실제로 임계를 40으로 바꾸자 간격이 150 ms에서 40 ms로 바뀌었고, 예제는 인자를 버리는 람다로 연결해 이를 막습니다. 오버레이는 채택된 결함을 빨강, 임계는 넘었지만 크기 필터로 제외된 성분을 파랑으로 칠해, 제외된 것이 “안 보인 것”이 아니라 “작아서 버린 것”임을 화면에서 구분합니다. 판정 아래 한 줄에는 최소 길이 px와 크기 필터로 제외된 성분 수가 함께 표시되고, CSV에는 결과 행보다 먼저 파라미터 한 줄이 기록됩니다.
Point. 판정의 근거가 화면과 파일에 함께 남아야, 과검출·미검출이 재발했을 때 “그때 임계가 얼마였는가”부터 확인할 수 있습니다.
3. numpy → QImage 변환 — 폭이 4의 배수가 아니면 영상이 기운다
Point. numpy 배열을 QImage로 넘길 때는 bytesPerLine(한 행의 바이트 수)을 반드시 명시합니다.
Reason. Qt 문서에 따르면 bytesPerLine을 받지 않는 생성자는 스캔라인 데이터가 최소 32비트로 정렬되어 있다고 가정합니다. 8비트 그레이 영상의 폭이 4의 배수가 아니면 numpy의 행 간격과 Qt가 가정한 행 간격이 어긋나고, 행마다 몇 바이트씩 밀리며 영상이 대각선으로 기웁니다. 또한 QImage는 버퍼를 복사하지 않고 참조하므로, 버퍼는 QImage가 살아 있는 동안 유효해야 합니다.
![같은 8비트 영상을 bytesPerLine 없이 변환하면 대각선으로 기울고 결함 모양이 바뀌며, strides[0]을 넘기면 정상으로 표시되는 QImage 변환 비교](https://noctvision.com/wp-content/uploads/2026/09/noctvision-pyside6-opencv-vision-inspection-gui-03.webp)
Example. 폭 1021 px 영상을 bytesPerLine 없이 변환하면 Qt는 행당 1024 바이트로 읽어 행마다 3 바이트씩 밀립니다. 실제로 재현한 결과(위 그림)에서 외접 사각형 가로 265 px의 스크래치는 가로 37 px의 가파른 막대로, 지름 11 px의 흑점은 가로 31 px의 사선으로 모양이 바뀌었고, 맨 아래 3행에는 원래 영상 밖의 바이트가 읽혀 검은 띠가 생겼습니다(재현할 때는 범위 밖 메모리를 읽지 않도록 버퍼 끝에 0으로 채운 여유를 두었습니다). 결함의 길이와 위치가 바뀌므로 화면만 틀리는 것이 아니라, 표시 좌표로 계산하는 모든 측정이 틀어집니다. 예제의 to_qimage()는 img.strides[0]을 bytesPerLine으로 넘기고, .copy()로 numpy 버퍼와의 의존을 끊습니다.
Point. 카메라 SDK의 ROI 크롭은 임의 폭의 영상을 자주 만듭니다. 테스트 영상의 폭을 일부러 4의 배수가 아닌 값으로 잡아 변환 경로를 검증해 두는 것이 안전합니다.
4. 핵심 골격 — 매칭표
| 구분 | 항목 | 사양 / 파라미터 | 근거·비고 |
|---|---|---|---|
| ① 최소 불량 사이즈 | 최소 결함 길이 | 60 µm | 예제 가정. 20 µm/px에서 3 px |
| ② 광학 셋업 | 입력 영상 | 8-bit 그레이, 합성 테스트 영상 1024 × 768 px | 실제 적용 시 조명·렌즈 셋업이 먼저 확정되어야 함 |
| ② 광학 셋업 | 화소 분해능 | 20 µm/px(FOV 약 20.5 mm 가정) | 1024 px × 20 µm = 20.48 mm |
| ② 광학 셋업 | WD(작동 거리) | 영상 파일 입력 예제라 WD를 다루지 않음 — 실제 셋업은 WD 확보 여부 확인 필수 | 렌즈 계산기 글에서 거리·초점거리 역산 |
| ③ 알고리즘 | 배경 추정 | 미디언 블러 31 px | 최대 결함 폭(흑점 11 px)보다 큰 홀수 커널 |
| ③ 알고리즘 | 배경 차분·임계 | 극성 dark, 임계 25 계조 | 합성 노이즈 σ 3 계조의 약 8배 |
| ③ 알고리즘 | 연결 성분·크기 필터 | 8-연결, 외접 사각형 장변 ≥ 3 px | 60 µm ÷ 20 µm/px |
| ③ 알고리즘 | UI 재검사 | 디바운스 150 ms | 연속 입력 시 마지막 값으로 1회 검사 |
표 시사점. 이 예제의 검출 하한은 크기 필터(3 px)와 임계(25 계조) 두 값이 함께 정합니다. 합성 영상에서 임계를 90 계조로 올리면 결함은 0개, 최소 결함을 20 µm(1 px)로 낮추면 제외됐던 미세점까지 3개가 검출되었습니다. 즉 UI에서 두 값을 바꾸는 행위가 곧 불량 기준을 바꾸는 행위이며, 그래서 파라미터를 결과와 함께 기록해야 합니다.
5. 전체 코드와 실행 준비
- 가상환경 만들기:
python -m venv .venv후 활성화(macOS·Linuxsource .venv/bin/activate, Windows.venv\Scripts\activate) - 패키지 설치:
pip install PySide6 opencv-python-headless numpy— OpenCV는 headless 패키지 하나만 설치합니다. PyPI 안내도 여러 OpenCV 패키지를 한 환경에 함께 설치하지 말라고 명시합니다. - 실행:
python nv_inspect_ui.py(합성 테스트 영상) 또는python nv_inspect_ui.py part.png
# -*- 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())
검증 환경: Python 3.11, PySide6 6.11.2, OpenCV 4.13.0, numpy 2.4.4. Linux 오프스크린 모드에서 실행해 위 화면을 캡처했고, 로직은 화면 없이 별도 테스트(결함 2개 채택·미세점 제외·폭 1021 px 변환 화소값 일치·연속 입력 후 1회 재검사·디바운스 간격 150 ms 유지)로 확인했습니다.
6. 반대 접근이 유리한 조건
- 판정 로직이 딥러닝 모델인 경우: UI 골격은 같지만, 기록 대상은 임계값보다 모델 버전과 입력 전처리 조건이 됩니다(딥러닝과 룰베이스 비교 글).
- 다중 카메라·짧은 택트: GUI 스레드에서 검사를 돌리면 화면 갱신과 검사가 서로를 막습니다. 검사는 별도 스레드나 프로세스로 분리하고, UI는 결과 표시만 맡겨야 합니다.
- 파라미터 이력 관리가 이미 되는 검사 플랫폼: 별도 UI보다 그 기능을 쓰는 편이 변경 추적에 유리합니다.
실제 영상의 난반사·셰이딩 조건에 따라 배경 커널과 임계는 달라지므로, 예제 값을 그대로 쓸 수 있는지는 샘플 테스트 전 확언 불가입니다.
자주 묻는 질문
Q. PyQt 대신 PySide6를 쓴 이유가 있나요?
이 예제의 코드는 두 바인딩에서 거의 같습니다. PySide6는 Qt 프로젝트가 직접 제공하는 공식 파이썬 바인딩이라 문서와 버전을 Qt와 맞춰 보기 쉽다는 점에서 골랐습니다. 배포 조건(라이선스)은 사용하는 쪽의 정책에 맞춰 따로 확인해야 합니다.
Q. 실제 카메라 영상은 어떻게 연결하나요?
inspect()는 8-bit 그레이 numpy 배열만 받으므로, 카메라 SDK가 준 프레임을 같은 형식으로 바꿔 넘기면 됩니다. 다만 실시간 취득은 별도 스레드에서 받아 최신 프레임만 UI로 넘기는 구조가 필요합니다.
현장 노트
라인 옆에 노트북을 두고 스크립트의 임계값을 직접 고쳐 가며 조정하던 때, 며칠 뒤 같은 불량이 다시 나왔는데 그날 어떤 값을 썼는지 아무도 기억하지 못한 적이 있습니다. 그 뒤로는 화면에서 바꾼 값이 결과 파일 첫 줄에 남도록 만든 뒤에야 현장에 넘기고 있습니다. 파라미터가 기록되자 과검출의 원인이 조명 쪽 변화인지 임계 변경인지를 가를 수 있게 됐습니다. 다만 이런 최소 UI는 조정 도구일 뿐, 조명과 렌즈가 만들지 못한 콘트라스트를 메우지는 못한다는 점을 늘 함께 설명합니다.
현장 체크포인트
- WD가 확보되었는가 — 이 UI로 들어올 영상을 만드는 카메라·렌즈의 WD가 조명 설치 공간을 포함해 확보되었는지 먼저 확인할 것.
- 분해능 입력값(µm/px)이 실제 FOV ÷ 화소 수와 일치하는가 — 렌즈나 거리를 바꾸면 이 값부터 갱신.
- 배경 커널이 최대 결함 폭보다 큰가(작으면 큰 결함이 배경으로 흡수됨).
- 폭이 4의 배수가 아닌 영상으로 표시 좌표와 화소값을 검증했는가.
- CSV에 파라미터가 결과와 함께 기록되는가.
- 난반사 표면이라면 임계와 커널은 샘플 테스트 전 확언 불가 — 한도 견본으로 조정했는가.
참고 자료
- Qt 6 문서 — QImage — 버퍼 유효성, bytesPerLine 없는 생성자의 32비트 정렬 가정
- Qt for Python (PySide6) | Official Python Bindings — Qt 공식 파이썬 바인딩
- PyPI — opencv-python-headless — “SELECT ONLY ONE OF THEM”, GUI 기능 미포함
- OpenCV 문서 — Structural Analysis and Shape Descriptors — connectedComponentsWithStats
함께 읽으면 좋은 글 — 딥러닝 vs 룰베이스(rule-based) 알고리즘, 언제 무엇을 쓰나 · 머신비전 검사, 소프트웨어보다 물리가 먼저다 · 고반사 표면 결함, 필터링 순서 하나로 갈린다 — 바이래터럴 필터와 온보드 AI 카메라의 역할 분담


