Hackster will be offline on Monday, June 15 from 5pm to 7pm PDT to perform some scheduled maintenance.
Rahul Khanna
Created May 10, 2026

Multi-Class PCB Solder Defect Inspector

A Complete Edge-AI Inspection System for Manufacturing

9
Multi-Class PCB Solder Defect Inspector

Things used in this project

Hardware components

Raspberry Pi Zero
Raspberry Pi Zero
×1
Raspberry PI AI Camera
Raspberry PI AI Camera
×1

Software apps and online services

Raspbian
Raspberry Pi Raspbian
OpenCV
OpenCV
TensorFlow
TensorFlow

Hand tools and fabrication machines

Drill / Driver, Cordless
Drill / Driver, Cordless

Story

Read more

Schematics

connections_V7OMvyPTbE.jpg

Connection

Code

inference.py

Python
import cv2
import numpy as np
import tensorflow as tf
from datetime import datetime
import json
from pathlib import Path

class PCBDefectInspector:
    def __init__(self, model_path, classes_path, conf_threshold=0.5, nms_threshold=0.45):
        """Initialize the defect inspector."""
        self.model_path = model_path
        self.conf_threshold = conf_threshold
        self.nms_threshold = nms_threshold
        self.input_size = 416
        
        # Load model
        self.interpreter = tf.lite.Interpreter(model_path=model_path)
        self.interpreter.allocate_tensors()
        self.input_details = self.interpreter.get_input_details()
        self.output_details = self.interpreter.get_output_details()
        
        # Load class names
        with open(classes_path) as f:
            self.classes = [line.strip() for line in f]
        
        # Color map for visualization (BGR)
        self.colors = {
            'Good': (0, 255, 0),           # Green
            'Insufficient Solder': (0, 165, 255),  # Orange
            'Solder Bridge': (0, 0, 255),  # Red
            'Cold Joint': (165, 42, 42),   # Brown
            'Tombstoning': (255, 0, 0),    # Cyan
            'Misalignment': (128, 0, 128)  # Purple
        }
        
        print("Model loaded successfully")
        print(f"  Input shape: {self.input_details[0]['shape']}")
        print(f"  Classes: {', '.join(self.classes)}")
    
    def preprocess(self, frame):
        """Resize and normalize frame for model input."""
        h, w = frame.shape[:2]
        
        # Resize to model input size (maintain aspect ratio with padding)
        scale = self.input_size / max(h, w)
        new_h, new_w = int(h * scale), int(w * scale)
        
        resized = cv2.resize(frame, (new_w, new_h))
        
        # Pad to input_size × input_size
        top = (self.input_size - new_h) // 2
        left = (self.input_size - new_w) // 2
        padded = np.full((self.input_size, self.input_size, 3), 128, dtype=np.uint8)
        padded[top:top+new_h, left:left+new_w] = resized
        
        # Normalize based on model input type
        if self.input_details[0]['dtype'] == np.float32:
            padded = padded.astype(np.float32) / 255.0
        
        return np.expand_dims(padded, axis=0), (scale, top, left)
    
    def infer(self, frame):
        """Run inference on frame and return detections."""
        input_data, transform = self.preprocess(frame)
        
        self.interpreter.set_tensor(self.input_details[0]['index'], input_data)
        self.interpreter.invoke()
        
        output = self.interpreter.get_tensor(self.output_details[0]['index'])
        detections = self.parse_yolo_output(output, transform, frame.shape[:2])
        return detections
    
    def parse_yolo_output(self, output, transform, frame_shape):
        """Parse YOLO v5 output tensor into detections."""
        scale, top, left = transform
        h, w = frame_shape
        
        detections = []
        output = output[0]  # Remove batch dimension
        
        # YOLO output: [13, 13, 255] where 255 = (x, y, w, h, conf) + 6 classes
        # For 6 classes: 5 + 6 = 11 values per anchor
        for yi in range(output.shape[0]):
            for xi in range(output.shape[1]):
                for anchor_idx in range(3):  # YOLO v5 uses 3 anchors per grid cell
                    offset = anchor_idx * 11  # 5 bbox + 6 classes
                    
                    box_conf = output[yi, xi, offset + 4]
                    class_logits = output[yi, xi, offset + 5:offset + 11]
                    class_id = np.argmax(class_logits)
                    class_conf = class_logits[class_id]
                    
                    conf = box_conf * class_conf
                    if conf < self.conf_threshold:
                        continue
                    
                    # Decode bounding box
                    x = output[yi, xi, offset + 0]
                    y = output[yi, xi, offset + 1]
                    bw = output[yi, xi, offset + 2]
                    bh = output[yi, xi, offset + 3]
                    
                    # Scale back to original frame
                    x = ((x + xi) * (self.input_size / output.shape[1]) - left) / scale
                    y = ((y + yi) * (self.input_size / output.shape[0]) - top) / scale
                    bw = np.exp(bw) * (self.input_size / output.shape[1]) / scale
                    bh = np.exp(bh) * (self.input_size / output.shape[0]) / scale
                    
                    x1 = max(0, int(x - bw / 2))
                    y1 = max(0, int(y - bh / 2))
                    x2 = min(w, int(x + bw / 2))
                    y2 = min(h, int(y + bh / 2))
                    
                    detections.append({
                        'class_id': int(class_id),
                        'class_name': self.classes[class_id],
                        'confidence': float(conf),
                        'box': (x1, y1, x2, y2)
                    })
        
        return self.apply_nms(detections)
    
    def apply_nms(self, detections):
        """Non-maximum suppression to remove overlapping boxes."""
        if not detections:
            return detections
        
        detections = sorted(detections, key=lambda x: x['confidence'], reverse=True)
        
        keep = []
        while detections:
            current = detections.pop(0)
            keep.append(current)
            detections = [d for d in detections if self.iou(current['box'], d['box']) < self.nms_threshold]
        
        return keep
    
    def iou(self, box1, box2):
        """Intersection over Union."""
        x1a, y1a, x2a, y2a = box1
        x1b, y1b, x2b, y2b = box2
        
        inter_x1 = max(x1a, x1b)
        inter_y1 = max(y1a, y1b)
        inter_x2 = min(x2a, x2b)
        inter_y2 = min(y2a, y2b)
        
        if inter_x2 < inter_x1 or inter_y2 < inter_y1:
            return 0.0
        
        inter_area = (inter_x2 - inter_x1) * (inter_y2 - inter_y1)
        area1 = (x2a - x1a) * (y2a - y1a)
        area2 = (x2b - x1b) * (y2b - y1b)
        union_area = area1 + area2 - inter_area
        
        return inter_area / union_area if union_area > 0 else 0.0
    
    def visualize(self, frame, detections, confidence_threshold=0.6):
        """Draw bounding boxes and labels on frame."""
        annotated = frame.copy()
        
        for det in detections:
            if det['confidence'] < confidence_threshold:
                continue
            
            class_name = det['class_name']
            confidence = det['confidence']
            x1, y1, x2, y2 = det['box']
            
            # Draw bounding box
            color = self.colors.get(class_name, (255, 255, 255))
            cv2.rectangle(annotated, (x1, y1), (x2, y2), color, 2)
            
            # Draw label
            label = f"{class_name} ({confidence:.2f})"
            label_size = cv2.getTextSize(label, cv2.FONT_HERSHEY_SIMPLEX, 0.5, 1)[0]
            cv2.rectangle(annotated, (x1, y1 - label_size[1] - 5), 
                         (x1 + label_size[0], y1), color, -1)
            cv2.putText(annotated, label, (x1, y1 - 5), 
                       cv2.FONT_HERSHEY_SIMPLEX, 0.5, (255, 255, 255), 1)
        
        return annotated
    
    def run_live_inspection(self, output_dir="/home/pi/inspection_logs"):
        """Run live PCB inspection with logging."""
        Path(output_dir).mkdir(parents=True, exist_ok=True)
        
        cap = cv2.VideoCapture(0)
        cap.set(cv2.CAP_PROP_FRAME_WIDTH, 1920)
        cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 1440)
        cap.set(cv2.CAP_PROP_FPS, 30)
        
        frame_count = 0
        defect_log = []
        
        print("Starting live inspection... (Press 'q' to quit)")
        
        while True:
            ret, frame = cap.read()
            if not ret:
                break
            
            # Inference
            t_start = datetime.now()
            detections = self.infer(frame)
            t_infer = (datetime.now() - t_start).total_seconds() * 1000
            
            # Visualize
            annotated = self.visualize(frame, detections, self.conf_threshold)
            
            # Log detections
            frame_count += 1
            for det in detections:
                if det['confidence'] >= self.conf_threshold:
                    defect_log.append({
                        'frame': frame_count,
                        'class': det['class_name'],
                        'confidence': det['confidence'],
                        'box': det['box'],
                        'timestamp': datetime.now().isoformat()
                    })
            
            # Display
            cv2.putText(annotated, f"FPS: {1000/t_infer:.1f} | Inference: {t_infer:.1f}ms", 
                       (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 0), 2)
            cv2.putText(annotated, f"Detections: {len([d for d in detections if d['confidence'] >= self.conf_threshold])}", 
                       (10, 60), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 0), 2)
            
            cv2.imshow("PCB Defect Inspector", annotated)
            
            if cv2.waitKey(1) & 0xFF == ord('q'):
                break
        
        cap.release()
        cv2.destroyAllWindows()
        
        # Save log
        log_file = Path(output_dir) / f"inspection_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
        with open(log_file, 'w') as f:
            json.dump(defect_log, f, indent=2)
        
        print(f"✓ Inspection complete. {len(defect_log)} defects logged to {log_file}")
        return defect_log

# Main
if __name__ == "__main__":
    inspector = PCBDefectInspector(
        model_path="/home/pi/models/defect_classifier/model.tflite",
        classes_path="/home/pi/models/defect_classifier/classes.txt",
        conf_threshold=0.5
    )
    inspector.run_live_inspection()

Credits

Rahul Khanna
50 projects • 236 followers
Research Enthusiast - Computer Vision, Machine Intelligence | Embedded System | Robotics | IoT | Intel® Edge AI Scholar

Comments