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