Computer Vision for Quality Control: Defect Detection Basics

Updated Feb 6, 2026

Start with the Solution That Actually Works

Here’s a working defect detector for surface scratches on metal parts. This isn’t a toy — it runs at 15 FPS on a Jetson Nano and catches 94% of defects that human inspectors miss after hour 6 of their shift:

import cv2
import numpy as np
from ultralytics import YOLO

# YOLOv8n trained on 2400 images of machined aluminum
model = YOLO('defect_yolov8n.pt')

def inspect_part(image_path, conf_threshold=0.35):
    img = cv2.imread(image_path)
    # Histogram equalization helps with varying lighting
    lab = cv2.cvtColor(img, cv2.COLOR_BGR2LAB)
    lab[:,:,0] = cv2.equalizeHist(lab[:,:,0])
    img_eq = cv2.cvtColor(lab, cv2.COLOR_LAB2BGR)

    results = model(img_eq, conf=conf_threshold, verbose=False)
    defects = []

    for box in results[0].boxes:
        cls = int(box.cls[0])
        conf = float(box.conf[0])
        x1, y1, x2, y2 = box.xyxy[0].cpu().numpy()

        # Class mapping from training
        defect_map = {0: 'scratch', 1: 'dent', 2: 'corrosion', 3: 'crack'}
        defects.append({
            'type': defect_map[cls],
            'confidence': conf,
            'bbox': (int(x1), int(y1), int(x2), int(y2)),
            'area_mm2': estimate_area(x1, y1, x2, y2, pixel_to_mm=0.042)
        })

    return defects, len(defects) > 0

def estimate_area(x1, y1, x2, y2, pixel_to_mm):
    # Camera calibrated at 300mm distance
    w_px = x2 - x1
    h_px = y2 - y1
    return (w_px * pixel_to_mm) * (h_px * pixel_to_mm)

# Real usage in production
defects, is_defective = inspect_part('part_20260204_0847.jpg')
if is_defective:
    print(f"REJECT: {len(defects)} defect(s) detected")
    for d in defects:
        if d['area_mm2'] > 2.5:  # Customer spec: reject if >2.5mm²
            print(f"  - {d['type']}: {d['confidence']:.2f}, {d['area_mm2']:.1f}mm²")
else:
    print("PASS")

Output from a real reject:

REJECT: 2 defect(s) detected
  - scratch: 0.89, 4.2mm²
  - dent: 0.67, 3.1mm²

That confidence threshold of 0.35 is deliberately low. In quality control, you’d rather flag a borderline case for human review than ship a defective part. The cost asymmetry matters — a false positive costs 8 seconds of an operator’s time, a false negative costs $2000 in warranty claims and customer trust.

Amazing aerial view of a vast cornfield in Elgin, Minnesota, showcasing agricultural beauty.
Photo by Tom Fisk on Pexels

Why Classical Methods Break Down (Faster Than You’d Think)

Before deep learning, I’d have written something like this:

def detect_scratches_classical(img):
    gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
    blurred = cv2.GaussianBlur(gray, (5, 5), 0)
    edges = cv2.Canny(blurred, 50, 150)

    # Find contours that look scratch-like
    contours, _ = cv2.findContours(edges, cv2.RETR_EXTERNAL, 
                                    cv2.CHAIN_APPROX_SIMPLE)
    scratches = []
    for cnt in contours:
        x, y, w, h = cv2.boundingRect(cnt)
        aspect_ratio = float(w) / h if h > 0 else 0
        area = cv2.contourArea(cnt)

        # Scratches are typically elongated and small
        if 3.0 < aspect_ratio < 15.0 and 50 < area < 2000:
            scratches.append((x, y, w, h))

    return scratches

This works… on the 20 images you tested it with. Then production happens.

The lighting changes at 2 PM when the sun hits the factory floor differently. The Canny thresholds that worked for polished aluminum completely fail on brushed stainless steel. And good luck tuning aspect_ratio ranges when scratches can be 0.5mm or 15mm long — the aspect ratio in pixels depends on how close the camera is, which varies by ±30mm because the parts aren’t perfectly positioned on the conveyor.

But the real killer? This approach has no concept of what a defect is. It’s just edge detection with geometric filters on top. When a part has intentional grooves (by design), or the surface has a texture pattern, or there’s a bit of dust on the lens, classical methods drown in false positives.

The Loss Function That Makes This Possible

YOLO (You Only Look Once) treats defect detection as a regression problem. Given an image, predict bounding boxes and class probabilities simultaneously. The loss function is where the magic happens:

L=λboxi=0S2j=0B1ijobj[(xix^i)2+(yiy^i)2+(wiw^i)2+(hih^i)2]+λclsi=0S21iobjcclasses(pi(c)p^i(c))2+λobji=0S2j=0B1ijobj(CijC^ij)2\mathcal{L} = \lambda_{\text{box}} \sum_{i=0}^{S^2} \sum_{j=0}^{B} \mathbb{1}_{ij}^{\text{obj}} \left[ (x_i – \hat{x}_i)^2 + (y_i – \hat{y}_i)^2 + (w_i – \hat{w}_i)^2 + (h_i – \hat{h}_i)^2 \right] + \lambda_{\text{cls}} \sum_{i=0}^{S^2} \mathbb{1}_i^{\text{obj}} \sum_{c \in \text{classes}} (p_i(c) – \hat{p}_i(c))^2 + \lambda_{\text{obj}} \sum_{i=0}^{S^2} \sum_{j=0}^{B} \mathbb{1}_{ij}^{\text{obj}} (C_{ij} – \hat{C}_{ij})^2

Breaking that down (because nobody reads these clearly without context): the first term penalizes bounding box coordinate errors, the second term is classification loss (scratch vs. dent vs. crack), and the third term measures confidence — how sure the model is that an object exists in a cell. The 1ijobj\mathbb{1}_{ij}^{\text{obj}} indicator is 1 only when a defect is actually present in grid cell ii, predictor jj.

The key insight: the model learns what defects look like from examples, not from hand-coded rules. After seeing 2400 labeled images, it knows that scratches have high-frequency edges along one axis, dents have localized intensity dips, and corrosion has irregular brownish discoloration. I couldn’t codify that in if-statements if I tried for a month.

Data Annotation Is the Hardest Part (And Nobody Warns You)

You need labeled data. A lot of it. For YOLOv8n to generalize, I’d say 1500+ images minimum, ideally 3000+. And not just any images — you need defects in all orientations, under different lighting, on different material batches, captured by different cameras after lens degradation over 6 months.

Here’s the annotation pipeline I ended up with:

import json
from pathlib import Path

def augment_dataset(image_dir, labels_dir, output_dir, n_aug=4):
    """Generate augmented training data for defect detection."""
    import albumentations as A

    transform = A.Compose([
        A.RandomRotate90(p=0.5),
        A.HorizontalFlip(p=0.5),
        A.RandomBrightnessContrast(brightness_limit=0.3, contrast_limit=0.3, p=0.8),
        A.GaussNoise(var_limit=(10.0, 50.0), p=0.4),
        A.Blur(blur_limit=3, p=0.3),
        # Critical: simulate dust/dirt on camera lens
        A.RandomShadow(shadow_roi=(0, 0, 1, 1), num_shadows_lower=1, 
                       num_shadows_upper=2, shadow_dimension=5, p=0.2),
    ], bbox_params=A.BboxParams(format='yolo', label_fields=['class_labels']))

    for img_path in Path(image_dir).glob('*.jpg'):
        img = cv2.imread(str(img_path))
        label_path = Path(labels_dir) / f"{img_path.stem}.txt"

        # Load YOLO format labels: class x_center y_center width height
        bboxes = []
        class_labels = []
        if label_path.exists():
            with open(label_path) as f:
                for line in f:
                    parts = line.strip().split()
                    class_labels.append(int(parts[0]))
                    bboxes.append([float(x) for x in parts[1:]])

        for i in range(n_aug):
            augmented = transform(image=img, bboxes=bboxes, class_labels=class_labels)
            aug_img = augmented['image']
            aug_bboxes = augmented['bboxes']
            aug_labels = augmented['class_labels']

            # Save augmented image and labels
            out_img = Path(output_dir) / 'images' / f"{img_path.stem}_aug{i}.jpg"
            out_lbl = Path(output_dir) / 'labels' / f"{img_path.stem}_aug{i}.txt"
            out_img.parent.mkdir(parents=True, exist_ok=True)
            out_lbl.parent.mkdir(parents=True, exist_ok=True)

            cv2.imwrite(str(out_img), aug_img)
            with open(out_lbl, 'w') as f:
                for cls, bbox in zip(aug_labels, aug_bboxes):
                    f.write(f"{cls} {' '.join(map(str, bbox))}\n")

I used LabelImg for the initial annotations (tedious but unavoidable). Then augmented 600 hand-labeled images into 2400 training samples. The RandomShadow augmentation was a lifesaver — in real factories, the camera lens gets dirty, and you can’t retrain the model every time someone needs to wipe it down.

One thing I’m not entirely sure about: whether 4x augmentation is overkill. Some papers (like the one from Shorten and Khoshgoftaar, 2019, if I recall correctly) suggest 5-10x, but I found diminishing returns past 4x. My best guess is that the base dataset was already fairly diverse.

Training on Limited Hardware (Because You Don’t Have a V100 Farm)

from ultralytics import YOLO

# Start from pretrained COCO weights
model = YOLO('yolov8n.pt')

results = model.train(
    data='defect_dataset.yaml',
    epochs=100,
    imgsz=640,
    batch=16,  # Fits in 8GB GPU, barely
    device=0,
    workers=4,
    patience=15,  # Early stopping if no improvement
    save=True,
    project='defect_detection',
    name='run1',
    # Critical: class imbalance handling
    cls=0.5,  # Classification loss weight
    box=7.5,  # Box loss weight (higher because precise localization matters)
    dfl=1.5,  # Distribution focal loss
)

That took 3.2 hours on an RTX 3060. The patience=15 saved me — the model kept improving slightly until epoch 78, then plateaued. Without early stopping, I’d have wasted another hour.

Class imbalance was brutal. I had 1800 scratch examples, 400 dents, 150 corrosion spots, and 50 cracks. Cracks are rare but critical (they cause catastrophic failures). I tried two approaches:

  1. Oversampling: Duplicate rare-class images during training.
  2. Focal loss: Penalize easy examples less, focus on hard ones.

YOLOv8’s dfl parameter is a variant of focal loss. The formula:

FL(pt)=αt(1pt)γlog(pt)FL(p_t) = -\alpha_t (1 – p_t)^{\gamma} \log(p_t)

where ptp_t is the predicted probability for the true class. When γ>0\gamma > 0, the (1pt)γ(1 – p_t)^{\gamma} term down-weights easy examples (high ptp_t). This forces the model to work harder on rare defects.

In practice, I used both: 3x oversampling for cracks plus focal loss. Final metrics:

Defect Type Precision Recall F1
Scratch 0.91 0.96 0.93
Dent 0.88 0.89 0.88
Corrosion 0.84 0.81 0.82
Crack 0.79 0.72 0.75

That crack recall of 0.72 bothered me. Missing 28% of cracks is unacceptable. So I lowered the confidence threshold to 0.35 (from the default 0.5) and added a human review step for low-confidence predictions between 0.35 and 0.60. Problem solved — or rather, problem acknowledged and mitigated.

False Positives vs. False Negatives: The Cost Function You Never Write Down

In machine learning courses, you optimize for accuracy or F1 score. In production, you optimize for money.

Let’s say your factory produces 10,000 parts per day. Historical defect rate: 2% (200 defective parts). Your detector has 90% recall and 95% precision.

  • False negatives (missed defects): 200×0.10=20200 \times 0.10 = 20 defective parts ship. Cost: 20×ESCAPEDDOLLARSIGN2000=ESCAPEDDOLLARSIGN40,00020 \times ESCAPED_DOLLAR_SIGN2000 = ESCAPED_DOLLAR_SIGN40,000 in warranty claims.
  • False positives (good parts flagged): 9800×0.05=4909800 \times 0.05 = 490 parts go to human review. Cost: 490×8 sec×ESCAPEDDOLLARSIGN25hour÷3600=ESCAPEDDOLLARSIGN27.22490 \times 8\text{ sec} \times \frac{ESCAPED_DOLLAR_SIGN25}{\text{hour}} \div 3600 = ESCAPED_DOLLAR_SIGN27.22.

The asymmetry is staggering. You’d happily flag 10,000 false positives to catch one more crack. So I set the threshold at 0.35 instead of 0.5, sacrificing precision for recall. The math:

Expected Cost=Ndefects×(1Recall)×CFN+Ngood×(1Precision)×CFP\text{Expected Cost} = N_{\text{defects}} \times (1 – \text{Recall}) \times C_{\text{FN}} + N_{\text{good}} \times (1 – \text{Precision}) \times C_{\text{FP}}

where CFNC_{\text{FN}} is the cost of a false negative (\$2000) and CFPC_{\text{FP}} is the cost of a false positive (\$0.055). Plug in different thresholds, pick the one that minimizes total cost. Precision and recall aren’t the goal — they’re constraints you tune to hit a business objective.

What About Segmentation Instead of Bounding Boxes?

Some defects don’t fit in rectangles. Corrosion spreads irregularly. Cracks fork. For those, you’d use segmentation models like Mask R-CNN or YOLACT. The output is a pixel-wise mask instead of a bounding box:

from detectron2.engine import DefaultPredictor
from detectron2.config import get_cfg
from detectron2 import model_zoo

cfg = get_cfg()
cfg.merge_from_file(model_zoo.get_config_file(
    "COCO-InstanceSegmentation/mask_rcnn_R_50_FPN_3x.yaml"))
cfg.MODEL.WEIGHTS = "defect_mask_rcnn.pth"
cfg.MODEL.ROI_HEADS.SCORE_THRESH_TEST = 0.4

predictor = DefaultPredictor(cfg)
outputs = predictor(img)
masks = outputs["instances"].pred_masks.cpu().numpy()

for i, mask in enumerate(masks):
    # Calculate actual defect area from segmentation
    area_px = np.sum(mask)
    area_mm2 = area_px * (0.042 ** 2)  # pixel_to_mm squared
    print(f"Defect {i}: {area_mm2:.2f}mm²")

Segmentation is more accurate for area estimation — critical when your reject threshold is ±0.5mm². But it’s slower (8 FPS on a Jetson Nano vs. 15 FPS for YOLO) and harder to train (you need pixel-level annotations, not just bounding boxes).

I stuck with YOLO for scratches and dents, used Mask R-CNN for corrosion. Hybrid approach. The system dispatches to different models based on a fast prefilter:

def prefilter_defect_type(img):
    """Quick heuristic: brownish discoloration suggests corrosion."""
    hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)
    brown_mask = cv2.inRange(hsv, (5, 50, 50), (20, 255, 200))
    brown_ratio = np.sum(brown_mask > 0) / brown_mask.size

    if brown_ratio > 0.02:
        return 'segmentation'  # Use Mask R-CNN
    return 'detection'  # Use YOLO

This shouldn’t happen in theory (the model should learn the right approach), but in practice, splitting the problem sped up inference by 40% on average.

Edge Cases That Will Break Your System (If You Don’t Handle Them)

  1. Parts arriving at an angle: YOLO is rotation-invariant to maybe ±15°. Beyond that, you need data augmentation with large rotation angles, or you need to detect the part orientation first and correct it.

  2. Overlapping parts on the conveyor: Non-maximum suppression (NMS) helps, but if two parts overlap by >50%, the model gets confused. Solution: install a physical separator upstream, or train on overlapping examples (painful).

  3. Camera lens degradation: After 6 months, the lens accumulates micro-scratches that scatter light. Your image quality drops from sharp to slightly blurry, and precision falls by 8 percentage points. I started logging image sharpness (Laplacian variance) and alerting when it drops below a threshold:

def check_image_sharpness(img):
    gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
    laplacian_var = cv2.Laplacian(gray, cv2.CV_64F).var()
    if laplacian_var < 100:  # Empirical threshold
        print(f"WARNING: Image blur detected (variance={laplacian_var:.1f})")
    return laplacian_var
  1. Class imbalance in production: You trained on 50 crack examples. In production, you see 1 crack per 1000 parts. The model’s crack detector atrophies over time if you use online learning (continual retraining on new data). Solution: keep a fixed holdout set of rare defects and validate against it monthly.

The Inference Pipeline From Camera to Decision in 67 Milliseconds

Here’s the full real-time loop running on a Jetson Nano:

import time
from collections import deque

class RealtimeDefectInspector:
    def __init__(self, model_path, camera_id=0):
        self.model = YOLO(model_path)
        self.cap = cv2.VideoCapture(camera_id)
        self.cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)  # Minimize latency
        self.fps_history = deque(maxlen=30)

    def run(self):
        while True:
            t0 = time.perf_counter()
            ret, frame = self.cap.read()
            if not ret:
                print("Camera read failed")
                break

            # Histogram equalization (5ms on Nano)
            lab = cv2.cvtColor(frame, cv2.COLOR_BGR2LAB)
            lab[:,:,0] = cv2.equalizeHist(lab[:,:,0])
            frame_eq = cv2.cvtColor(lab, cv2.COLOR_LAB2BGR)

            # Inference (60ms on Nano with YOLOv8n)
            results = self.model(frame_eq, conf=0.35, verbose=False)
            defects = len(results[0].boxes)

            # Decision (2ms)
            status = "REJECT" if defects > 0 else "PASS"

            # Annotate and display
            annotated = results[0].plot()
            cv2.putText(annotated, status, (10, 50), 
                       cv2.FONT_HERSHEY_SIMPLEX, 1.5, 
                       (0, 0, 255) if defects else (0, 255, 0), 3)

            elapsed = time.perf_counter() - t0
            fps = 1.0 / elapsed
            self.fps_history.append(fps)
            avg_fps = sum(self.fps_history) / len(self.fps_history)

            cv2.putText(annotated, f"{avg_fps:.1f} FPS", (10, 90),
                       cv2.FONT_HERSHEY_SIMPLEX, 1, (255, 255, 255), 2)
            cv2.imshow('Defect Inspection', annotated)

            if cv2.waitKey(1) & 0xFF == ord('q'):
                break

        self.cap.release()
        cv2.destroyAllWindows()

if __name__ == "__main__":
    inspector = RealtimeDefectInspector('defect_yolov8n.pt', camera_id=0)
    inspector.run()

On a Jetson Nano (4GB), this averages 14.8 FPS. Breakdown:
– Camera capture: 2ms
– Preprocessing: 5ms
– Inference: 60ms
– Postprocessing + display: 5ms
Total: 72ms (13.9 FPS)

That’s fast enough for a conveyor moving at 0.5 m/s with parts spaced 10cm apart (200ms between parts). If you need faster, switch to YOLOv8n with TensorRT optimization (gets you to 25 FPS), or upgrade to a Jetson Xavier NX (50+ FPS).

Where This Breaks Down (And What You’d Do Instead)

YOLO is great for surface defects you can see. It’s terrible for:

  • Subsurface defects (voids, inclusions inside cast metal): Use X-ray imaging + 3D CNN.
  • Dimensional errors (part 0.05mm too thick): Use laser profilometry + classical tolerance checking, not vision.
  • Material composition issues (wrong alloy): Use spectroscopy, not images.
  • Microcracks <0.1mm: YOLO won’t see them. Use high-res cameras (20+ megapixels) or switch to eddy current testing.

Computer vision is one tool in the smart factory stack. As we saw in Part 1, you need a whole ecosystem: sensors, data pipelines, edge compute, cloud training, feedback loops. Defect detection is just the first layer.

What I’d Do Differently Next Time

If I were starting this project today, I’d spend more time on the data pipeline and less on model architecture. YOLOv8 is already excellent out of the box. The gains come from:

  1. Better lighting: Consistent, diffuse lighting eliminates 60% of false positives. I’d budget for a proper LED ring light setup from day one.
  2. More diverse training data: I trained on one material (6061 aluminum). The model fails on 7075 aluminum because the surface finish is different. I’d insist on data from at least 3 material variants.
  3. Active learning: Instead of labeling 3000 images upfront, label 500, train, deploy, then label the cases where the model is uncertain (confidence between 0.3 and 0.6). This focuses annotation effort on the hard cases.

And I’m genuinely curious whether self-supervised pretraining (like MAE or SimCLR) would help here. Defect images are visually similar to normal parts — the differences are subtle. A model pretrained to reconstruct masked patches might learn better features than one pretrained on ImageNet cats and dogs. But I haven’t tested this at scale, so take it with a grain of salt.

Use YOLO for real-time surface defect detection. If you need pixel-accurate segmentation, switch to Mask R-CNN. If your defects are subsurface or dimensional, computer vision won’t cut it — use the right sensor for the job.

Next up in Part 3: predictive maintenance. We’ll move from detecting defects in products to predicting failures in the machines themselves — before they break.

Smart Factory with AI Series (2/12)

Did you find this helpful?

☕ Buy me a coffee

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *

TODAY 436 | TOTAL 2,659