1위치 기반 불량 탐지의 필요성
제조 품질 검사에서 "불량 여부"만큼 중요한 것이 "불량 위치"입니다. 불량 위치 정보는 공정 원인 분석, 재작업 범위 결정, 품질 트렌드 분석에 필수적입니다.
Classification
이미지 전체 → 클래스
정상/불량 이진 분류
Object Detection
Bounding Box
불량 위치 + 크기
Segmentation
Pixel-level Mask
불량 영역 정밀 마킹
제조 적용 시나리오: Detection은 불량 개수/위치 파악에, Segmentation은 불량 면적 측정, 형상 분석, 마스킹 로봇 좌표 추출에 활용됩니다.
| Task | 출력 | 레이블링 | 적용 사례 |
| Object Detection |
Box (x, y, w, h) + class |
Bounding Box |
스크래치, 이물질, 찍힘 |
| Semantic Seg. |
픽셀별 클래스 |
Polygon/Brush |
코팅 불량, 변색 영역 |
| Instance Seg. |
개별 객체 마스크 |
Instance별 Polygon |
다중 불량 분리 |
2YOLO: 실시간 객체 탐지
YOLO(You Only Look Once)는 빠른 속도와 높은 정확도를 겸비한 객체 탐지 모델입니다. 제조 라인의 실시간 검사에 적합하며, YOLOv8이 현재 가장 널리 사용됩니다.
# Ultralytics YOLOv8 불량 탐지 학습
from ultralytics import YOLO
import yaml
# 데이터셋 구성 (YOLO format)
dataset_config = """
path: /data/defect_detection
train: images/train
val: images/val
test: images/test
names:
0: scratch
1: dent
2: stain
3: crack
"""
with open('defect_dataset.yaml', 'w') as f:
f.write(dataset_config)
# 모델 초기화 (사전 학습 가중치)
model = YOLO('yolov8m.pt') # medium 크기
# 학습
results = model.train(
data='defect_dataset.yaml',
epochs=100,
imgsz=640,
batch=16,
device='cuda',
# 제조 검사용 최적화
patience=20, # Early stopping
optimizer='AdamW',
lr0=0.001,
lrf=0.01, # 최종 학습률 비율
# 데이터 증강
augment=True,
hsv_h=0.015, # 색상 변화 제한 (조명 일관성)
hsv_s=0.7,
hsv_v=0.4,
degrees=10, # 회전 제한
translate=0.1,
scale=0.3,
flipud=0.0, # 상하 반전 비활성화 (방향 중요)
fliplr=0.5,
mosaic=0.5, # Mosaic 증강 50% 적용
# 클래스 불균형 대응
cls=0.5, # 분류 손실 가중치
box=7.5, # 박스 손실 가중치
# 출력
project='runs/defect',
name='yolov8m_v1'
)
# 추론
model = YOLO('runs/defect/yolov8m_v1/weights/best.pt')
def detect_defects(image_path: str, conf_threshold: float = 0.5):
"""불량 탐지 추론"""
results = model.predict(
image_path,
conf=conf_threshold,
iou=0.45, # NMS IoU threshold
max_det=50, # 최대 탐지 개수
device='cuda'
)
detections = []
for r in results:
for box in r.boxes:
detections.append({
'class': model.names[int(box.cls)],
'confidence': float(box.conf),
'bbox': box.xyxy[0].tolist(), # [x1, y1, x2, y2]
'area': float((box.xyxy[0][2] - box.xyxy[0][0]) *
(box.xyxy[0][3] - box.xyxy[0][1]))
})
return {
'image': image_path,
'defect_count': len(detections),
'detections': detections,
'is_defective': len(detections) > 0
}
3Faster R-CNN: 정밀 탐지
Faster R-CNN은 Two-stage Detector로 YOLO보다 느리지만 작은 불량이나 복잡한 형상에서 더 정확합니다. 속도보다 정확도가 중요한 오프라인 검사에 적합합니다.
# PyTorch + torchvision Faster R-CNN
import torch
import torchvision
from torchvision.models.detection import fasterrcnn_resnet50_fpn_v2
from torchvision.models.detection.faster_rcnn import FastRCNNPredictor
def build_fasterrcnn(num_classes: int):
"""불량 탐지용 Faster R-CNN 구성"""
# 사전 학습 모델 로드
model = fasterrcnn_resnet50_fpn_v2(
weights='COCO_V1',
box_score_thresh=0.5,
box_nms_thresh=0.45
)
# Classifier Head 교체 (COCO 91 → 불량 클래스)
in_features = model.roi_heads.box_predictor.cls_score.in_features
model.roi_heads.box_predictor = FastRCNNPredictor(in_features, num_classes)
return model
class DefectDetectionDataset(torch.utils.data.Dataset):
"""불량 탐지 데이터셋 (COCO format)"""
def __init__(self, root: str, annotations: str, transforms=None):
from pycocotools.coco import COCO
self.root = root
self.coco = COCO(annotations)
self.ids = list(self.coco.imgs.keys())
self.transforms = transforms
def __getitem__(self, idx):
img_id = self.ids[idx]
ann_ids = self.coco.getAnnIds(imgIds=img_id)
anns = self.coco.loadAnns(ann_ids)
path = self.coco.loadImgs(img_id)[0]['file_name']
img = Image.open(f"{self.root}/{path}").convert("RGB")
boxes = []
labels = []
for ann in anns:
x, y, w, h = ann['bbox']
boxes.append([x, y, x + w, y + h])
labels.append(ann['category_id'])
target = {
'boxes': torch.tensor(boxes, dtype=torch.float32),
'labels': torch.tensor(labels, dtype=torch.int64),
'image_id': torch.tensor([img_id])
}
if self.transforms:
img = self.transforms(img)
return img, target
def __len__(self):
return len(self.ids)
# 학습 루프
def train_fasterrcnn(model, train_loader, val_loader, epochs: int = 50):
device = torch.device('cuda')
model.to(device)
optimizer = torch.optim.AdamW(
model.parameters(),
lr=0.0001,
weight_decay=0.0005
)
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, epochs)
for epoch in range(epochs):
model.train()
epoch_loss = 0
for images, targets in train_loader:
images = [img.to(device) for img in images]
targets = [{k: v.to(device) for k, v in t.items()} for t in targets]
loss_dict = model(images, targets)
losses = sum(loss for loss in loss_dict.values())
optimizer.zero_grad()
losses.backward()
optimizer.step()
epoch_loss += losses.item()
scheduler.step()
print(f"Epoch {epoch+1}/{epochs}, Loss: {epoch_loss/len(train_loader):.4f}")
4U-Net: 시맨틱 세그멘테이션
U-Net은 의료 영상에서 시작되어 제조 불량 세그멘테이션에 널리 사용되는 모델입니다. 작은 데이터셋에서도 좋은 성능을 보이며, 픽셀 단위 불량 마스크를 생성합니다.
# PyTorch U-Net 구현
import torch
import torch.nn as nn
class DoubleConv(nn.Module):
"""U-Net 기본 블록: Conv-BN-ReLU × 2"""
def __init__(self, in_ch, out_ch):
super().__init__()
self.conv = nn.Sequential(
nn.Conv2d(in_ch, out_ch, 3, padding=1),
nn.BatchNorm2d(out_ch),
nn.ReLU(inplace=True),
nn.Conv2d(out_ch, out_ch, 3, padding=1),
nn.BatchNorm2d(out_ch),
nn.ReLU(inplace=True),
)
def forward(self, x):
return self.conv(x)
class UNet(nn.Module):
"""U-Net for Defect Segmentation"""
def __init__(self, n_classes: int = 1, in_channels: int = 3):
super().__init__()
# Encoder (Contracting Path)
self.enc1 = DoubleConv(in_channels, 64)
self.enc2 = DoubleConv(64, 128)
self.enc3 = DoubleConv(128, 256)
self.enc4 = DoubleConv(256, 512)
self.pool = nn.MaxPool2d(2)
# Bottleneck
self.bottleneck = DoubleConv(512, 1024)
# Decoder (Expanding Path)
self.up4 = nn.ConvTranspose2d(1024, 512, 2, stride=2)
self.dec4 = DoubleConv(1024, 512) # skip connection
self.up3 = nn.ConvTranspose2d(512, 256, 2, stride=2)
self.dec3 = DoubleConv(512, 256)
self.up2 = nn.ConvTranspose2d(256, 128, 2, stride=2)
self.dec2 = DoubleConv(256, 128)
self.up1 = nn.ConvTranspose2d(128, 64, 2, stride=2)
self.dec1 = DoubleConv(128, 64)
# Output
self.out_conv = nn.Conv2d(64, n_classes, 1)
def forward(self, x):
# Encoder
e1 = self.enc1(x)
e2 = self.enc2(self.pool(e1))
e3 = self.enc3(self.pool(e2))
e4 = self.enc4(self.pool(e3))
# Bottleneck
b = self.bottleneck(self.pool(e4))
# Decoder with Skip Connections
d4 = self.dec4(torch.cat([self.up4(b), e4], dim=1))
d3 = self.dec3(torch.cat([self.up3(d4), e3], dim=1))
d2 = self.dec2(torch.cat([self.up2(d3), e2], dim=1))
d1 = self.dec1(torch.cat([self.up1(d2), e1], dim=1))
return self.out_conv(d1)
# 불량 세그멘테이션 학습
class SegmentationTrainer:
"""U-Net 학습 관리자"""
def __init__(self, model, device='cuda'):
self.model = model.to(device)
self.device = device
# 클래스 불균형 대응 (불량 << 정상 픽셀)
pos_weight = torch.tensor([10.0]).to(device) # 불량 가중치
self.criterion = nn.BCEWithLogitsLoss(pos_weight=pos_weight)
self.optimizer = torch.optim.Adam(model.parameters(), lr=1e-4)
def train_epoch(self, dataloader):
self.model.train()
total_loss = 0
for images, masks in dataloader:
images = images.to(self.device)
masks = masks.to(self.device).unsqueeze(1) # [B, 1, H, W]
outputs = self.model(images)
loss = self.criterion(outputs, masks)
self.optimizer.zero_grad()
loss.backward()
self.optimizer.step()
total_loss += loss.item()
return total_loss / len(dataloader)
def predict(self, image: torch.Tensor, threshold: float = 0.5):
"""세그멘테이션 추론"""
self.model.eval()
with torch.no_grad():
image = image.unsqueeze(0).to(self.device)
output = self.model(image)
prob = torch.sigmoid(output)
mask = (prob > threshold).float()
# 불량 영역 통계
defect_pixels = mask.sum().item()
total_pixels = mask.numel()
defect_ratio = defect_pixels / total_pixels
return {
'mask': mask.squeeze().cpu().numpy(),
'probability': prob.squeeze().cpu().numpy(),
'defect_area_ratio': defect_ratio,
'is_defective': defect_ratio > 0.001 # 0.1% 이상이면 불량
}
5Mask R-CNN: 인스턴스 세그멘테이션
Mask R-CNN은 Detection + Segmentation을 결합하여 개별 불량 객체마다 정밀한 마스크를 생성합니다. 여러 불량이 겹쳐 있거나 개수 파악이 필요한 경우에 적합합니다.
# Detectron2 Mask R-CNN
from detectron2 import model_zoo
from detectron2.config import get_cfg
from detectron2.engine import DefaultTrainer, DefaultPredictor
from detectron2.data import DatasetCatalog, MetadataCatalog
import json
def register_defect_dataset(name: str, json_path: str, image_root: str):
"""COCO 형식 데이터셋 등록"""
from detectron2.data.datasets import register_coco_instances
register_coco_instances(name, {}, json_path, image_root)
def setup_maskrcnn_config(num_classes: int):
"""Mask R-CNN 설정"""
cfg = get_cfg()
cfg.merge_from_file(
model_zoo.get_config_file("COCO-InstanceSegmentation/mask_rcnn_R_50_FPN_3x.yaml")
)
# 사전 학습 가중치
cfg.MODEL.WEIGHTS = model_zoo.get_checkpoint_url(
"COCO-InstanceSegmentation/mask_rcnn_R_50_FPN_3x.yaml"
)
# 데이터셋
cfg.DATASETS.TRAIN = ("defect_train",)
cfg.DATASETS.TEST = ("defect_val",)
# 학습 설정
cfg.SOLVER.IMS_PER_BATCH = 4
cfg.SOLVER.BASE_LR = 0.0005
cfg.SOLVER.MAX_ITER = 10000
cfg.SOLVER.STEPS = (7000, 9000)
cfg.SOLVER.GAMMA = 0.1
# 모델
cfg.MODEL.ROI_HEADS.NUM_CLASSES = num_classes
cfg.MODEL.ROI_HEADS.SCORE_THRESH_TEST = 0.5
# 입력 크기
cfg.INPUT.MIN_SIZE_TRAIN = (640, 672, 704, 736, 768)
cfg.INPUT.MAX_SIZE_TRAIN = 1024
return cfg
class DefectSegmentationInference:
"""Mask R-CNN 추론"""
def __init__(self, cfg, class_names: list):
self.predictor = DefaultPredictor(cfg)
self.class_names = class_names
def predict(self, image):
"""인스턴스 세그멘테이션 추론"""
outputs = self.predictor(image)
instances = outputs["instances"].to("cpu")
results = []
for i in range(len(instances)):
result = {
'class': self.class_names[instances.pred_classes[i].item()],
'score': instances.scores[i].item(),
'bbox': instances.pred_boxes[i].tensor.numpy().tolist()[0],
'mask': instances.pred_masks[i].numpy(),
}
# 마스크에서 면적 계산
result['area_pixels'] = result['mask'].sum()
result['centroid'] = self._compute_centroid(result['mask'])
results.append(result)
return {
'instances': results,
'total_defects': len(results),
'defect_by_class': self._group_by_class(results)
}
def _compute_centroid(self, mask):
"""마스크 중심점 계산"""
import numpy as np
y_coords, x_coords = np.where(mask)
if len(x_coords) == 0:
return None
return (float(x_coords.mean()), float(y_coords.mean()))
def _group_by_class(self, results):
"""클래스별 불량 집계"""
from collections import defaultdict
grouped = defaultdict(list)
for r in results:
grouped[r['class']].append(r)
return dict(grouped)
6모델 선택 가이드
제조 검사 요구사항에 따른 모델 선택 기준:
| 요구사항 | 권장 모델 | 이유 |
| 실시간 (>30 FPS) |
YOLOv8n/s |
최고 속도, GPU 없이도 가능 |
| 높은 정확도 |
Faster R-CNN, YOLOv8x |
작은 불량도 정밀 탐지 |
| 불량 면적 측정 |
U-Net |
픽셀 단위 마스크 생성 |
| 다중 불량 분리 |
Mask R-CNN |
인스턴스별 분리 마스크 |
| 레이블 없음 |
PatchCore + Heatmap |
비지도 위치 특정 |
성능 vs 속도 트레이드오프: 라인 속도가 빠른 경우 YOLOv8n으로 시작하고, 검출률이 부족하면 모델 크기를 점진적으로 올리세요. 하드웨어 업그레이드 없이 모델만 바꾸면 추론 시간이 증가합니다.
# 모델별 속도/정확도 벤치마크 예시
model_benchmark = {
'YOLOv8n': {'mAP50': 0.78, 'fps_rtx3090': 450, 'fps_jetson': 45},
'YOLOv8s': {'mAP50': 0.83, 'fps_rtx3090': 350, 'fps_jetson': 35},
'YOLOv8m': {'mAP50': 0.87, 'fps_rtx3090': 200, 'fps_jetson': 22},
'YOLOv8x': {'mAP50': 0.90, 'fps_rtx3090': 120, 'fps_jetson': 12},
'Faster R-CNN': {'mAP50': 0.91, 'fps_rtx3090': 30, 'fps_jetson': 5},
'Mask R-CNN': {'mAP50': 0.89, 'fps_rtx3090': 25, 'fps_jetson': 4},
}
# 요구사항: 1초에 2개 제품 검사, Jetson Orin 사용
required_fps = 2 * 3 # 제품당 3장 촬영
print(f"Required FPS: {required_fps}")
for model, metrics in model_benchmark.items():
if metrics['fps_jetson'] >= required_fps:
print(f"✓ {model}: mAP50={metrics['mAP50']}, Jetson FPS={metrics['fps_jetson']}")
else:
print(f"✗ {model}: Too slow for requirement")