Pharmaceutical Blister Pack Inspection with Machine Vision: Complete Guide

Industry Tutorial

Hardware Used

Industrial cameras Backlight systems Vision controllers

Software Stack

OpenCV Cognex ViDi Keyence Vision MVTec HALCON

Use Cases

Pharmaceutical packaging Tablet inspection Blister seal verification Regulatory compliance

Why Blister Pack Inspection Matters

Pharmaceutical blister packs present unique inspection challenges:

  • Patient safety - Missing or damaged tablets can harm patients
  • Regulatory compliance - FDA 21 CFR Part 211, EU GMP Annex 11
  • Brand protection - Defects damage reputation
  • Cost of recalls - Average pharma recall costs £5-50 million

Machine vision enables 100% inspection at production speeds (100-400 packs/minute), detecting defects human inspectors miss.


Common Defect Types

1. Tablet/Capsule Defects

Defect Description Detection Method
Missing tablet Empty pocket Presence detection
Broken tablet Chipped, cracked, split Shape analysis
Wrong colour Colour variation Colour inspection
Wrong shape Different product mixed Shape matching
Foreign particle Contamination in pocket Anomaly detection
Double tablet Two pills in one pocket Height/profile check

2. Blister Forming Defects

Defect Description Detection Method
Incomplete form Shallow or deformed pocket Profile measurement
Punctured blister Hole in PVC/PVDC Backlight inspection
Wrong pocket depth Dimensional error 3D measurement
Bubble/blister Air trapped in material Texture analysis

3. Sealing Defects

Defect Description Detection Method
Incomplete seal Gaps in foil seal Edge detection
Wrinkled foil Poor seal contact Texture analysis
Contaminated seal Debris under foil Pattern matching
Foil perforation Pinholes in lidding Backlight
Delamination Foil separating Reflectance variation

4. Printing Defects

Defect Description Detection Method
Missing print Lot/expiry absent OCR verification
Wrong print Incorrect information OCR + database
Smudged print Illegible text Quality scoring
Misregistered Print position offset Template matching
Colour variation Ink density issues Colour measurement

System Architecture

Basic System Components

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
┌─────────────────────────────────────────────────────────────┐
│                    BLISTER PACK INSPECTION                  │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  ┌─────────┐    ┌─────────┐    ┌─────────┐    ┌─────────┐ │
│  │ Camera 1│    │ Camera 2│    │ Camera 3│    │ Camera 4│ │
│  │ (Top)   │    │(Backlit)│    │ (Foil)  │    │ (Print) │ │
│  └────┬────┘    └────┬────┘    └────┬────┘    └────┬────┘ │
│       │              │              │              │       │
│       └──────────────┴──────────────┴──────────────┘       │
│                          │                                  │
│                    ┌─────┴─────┐                           │
│                    │  Vision   │                           │
│                    │Controller │                           │
│                    └─────┬─────┘                           │
│                          │                                  │
│            ┌─────────────┼─────────────┐                   │
│            │             │             │                    │
│       ┌────┴────┐  ┌────┴────┐  ┌────┴────┐              │
│       │   PLC   │  │  SCADA  │  │ Reject  │              │
│       │Interface│  │ Display │  │Mechanism│              │
│       └─────────┘  └─────────┘  └─────────┘              │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Camera Positions

Station 1: Top-Down (Tablet Presence)

  • Position: Above conveyor, looking down through blister
  • Lighting: Diffuse dome or ring light
  • Purpose: Verify tablet presence, colour, basic shape

Station 2: Backlit (Blister Integrity)

  • Position: Camera above, light below
  • Lighting: High-intensity backlight
  • Purpose: Detect holes, thin spots, seal integrity

Station 3: Foil Side (Seal Quality)

  • Position: Above or angled at foil surface
  • Lighting: Low-angle or coaxial
  • Purpose: Verify seal completeness, detect wrinkles

Station 4: Print Verification

  • Position: Above printed area
  • Lighting: Diffuse, even illumination
  • Purpose: OCR for lot, expiry, barcode verification

Hardware Specifications

Camera Requirements

Station Resolution Type Speed Interface
Tablet inspection 5MP+ Area scan 50+ FPS GigE Vision
Backlit inspection 2-5MP Area scan 50+ FPS GigE Vision
Foil/seal 5MP+ Area scan 50+ FPS GigE Vision
Print/OCR 5MP+ Area scan 50+ FPS GigE Vision

Recommended Cameras:

  • Basler ace 2 (a2A5320-23gmPRO) - 5.3MP, 23 FPS
  • FLIR Blackfly S (BFS-PGE-50S5C) - 5MP, 35 FPS
  • Keyence CV-X Series - Integrated solution
  • Cognex In-Sight 2800 - Built-in AI

Lighting Requirements

Station Light Type Notes
Tablet Dome or ring Eliminates shadows
Backlit LED panel High uniformity required
Foil Low-angle bar Reveals surface defects
Print Diffuse panel Even, shadow-free

Key Specifications:

  • LED wavelength: White or specific colour for contrast
  • Strobe capability: Required for moving line
  • Controller: Synchronised with camera trigger
  • Intensity: Adjustable for different materials

Processing Hardware

For 100-200 packs/minute:

  • Industrial PC with Intel i7/i9
  • 32GB RAM minimum
  • SSD storage (500GB+)
  • GigE network card (multi-port)

For 200-400 packs/minute:

  • Vision controller (Keyence, Cognex)
  • Or GPU workstation (NVIDIA RTX)
  • FPGA-based for highest speeds

Implementation Guide

Python/OpenCV Implementation

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
import cv2
import numpy as np
from dataclasses import dataclass
from typing import List, Tuple, Optional
from enum import Enum

class DefectType(Enum):
    MISSING_TABLET = "missing_tablet"
    BROKEN_TABLET = "broken_tablet"
    WRONG_COLOUR = "wrong_colour"
    SEAL_DEFECT = "seal_defect"
    PRINT_ERROR = "print_error"
    CONTAMINATION = "contamination"

@dataclass
class InspectionResult:
    passed: bool
    defects: List[dict]
    confidence: float
    image_annotated: np.ndarray

class BlisterPackInspector:
    def __init__(self, config: dict):
        self.config = config
        self.pocket_template = None
        self.reference_colour = None
        self.setup_references()

    def setup_references(self):
        """Load reference images and calibration data"""
        if 'template_path' in self.config:
            self.pocket_template = cv2.imread(
                self.config['template_path'],
                cv2.IMREAD_GRAYSCALE
            )
        self.reference_colour = self.config.get('reference_hsv', [30, 100, 200])

    def inspect_pack(self, image: np.ndarray) -> InspectionResult:
        """Main inspection pipeline"""
        defects = []

        # 1. Locate pockets
        pockets = self.find_pockets(image)

        # 2. Check each pocket
        for i, pocket in enumerate(pockets):
            pocket_defects = self.inspect_pocket(image, pocket, i)
            defects.extend(pocket_defects)

        # 3. Check seal area
        seal_defects = self.inspect_seal(image)
        defects.extend(seal_defects)

        # 4. Annotate image
        annotated = self.annotate_defects(image.copy(), defects)

        passed = len(defects) == 0
        confidence = self.calculate_confidence(defects)

        return InspectionResult(
            passed=passed,
            defects=defects,
            confidence=confidence,
            image_annotated=annotated
        )

    def find_pockets(self, image: np.ndarray) -> List[Tuple[int, int, int, int]]:
        """Locate blister pockets using template matching or grid detection"""
        gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)

        # Option 1: Template matching
        if self.pocket_template is not None:
            result = cv2.matchTemplate(
                gray,
                self.pocket_template,
                cv2.TM_CCOEFF_NORMED
            )
            threshold = 0.8
            locations = np.where(result >= threshold)
            pockets = []
            h, w = self.pocket_template.shape
            for pt in zip(*locations[::-1]):
                pockets.append((pt[0], pt[1], w, h))
            return self.non_max_suppression(pockets)

        # Option 2: Grid-based detection (known layout)
        rows = self.config.get('pocket_rows', 2)
        cols = self.config.get('pocket_cols', 5)
        margin = self.config.get('margin', 20)

        pockets = []
        h, w = image.shape[:2]
        pocket_w = (w - 2 * margin) // cols
        pocket_h = (h - 2 * margin) // rows

        for row in range(rows):
            for col in range(cols):
                x = margin + col * pocket_w
                y = margin + row * pocket_h
                pockets.append((x, y, pocket_w, pocket_h))

        return pockets

    def inspect_pocket(
        self,
        image: np.ndarray,
        pocket: Tuple[int, int, int, int],
        pocket_idx: int
    ) -> List[dict]:
        """Inspect individual pocket for defects"""
        defects = []
        x, y, w, h = pocket

        # Extract pocket region
        roi = image[y:y+h, x:x+w]
        gray = cv2.cvtColor(roi, cv2.COLOR_BGR2GRAY)
        hsv = cv2.cvtColor(roi, cv2.COLOR_BGR2HSV)

        # 1. Check tablet presence
        presence = self.check_tablet_presence(gray)
        if not presence['present']:
            defects.append({
                'type': DefectType.MISSING_TABLET,
                'pocket': pocket_idx,
                'location': (x, y),
                'confidence': presence['confidence'],
                'message': f"Missing tablet in pocket {pocket_idx}"
            })
            return defects  # No point checking further

        # 2. Check tablet shape/integrity
        shape_result = self.check_tablet_shape(gray)
        if not shape_result['intact']:
            defects.append({
                'type': DefectType.BROKEN_TABLET,
                'pocket': pocket_idx,
                'location': (x, y),
                'confidence': shape_result['confidence'],
                'message': f"Broken tablet in pocket {pocket_idx}"
            })

        # 3. Check colour
        colour_result = self.check_tablet_colour(hsv)
        if not colour_result['match']:
            defects.append({
                'type': DefectType.WRONG_COLOUR,
                'pocket': pocket_idx,
                'location': (x, y),
                'confidence': colour_result['confidence'],
                'message': f"Colour deviation in pocket {pocket_idx}"
            })

        # 4. Check for contamination
        contam_result = self.check_contamination(roi)
        if contam_result['detected']:
            defects.append({
                'type': DefectType.CONTAMINATION,
                'pocket': pocket_idx,
                'location': (x, y),
                'confidence': contam_result['confidence'],
                'message': f"Contamination in pocket {pocket_idx}"
            })

        return defects

    def check_tablet_presence(self, gray: np.ndarray) -> dict:
        """Detect if tablet is present in pocket"""
        # Apply adaptive threshold
        thresh = cv2.adaptiveThreshold(
            gray, 255,
            cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
            cv2.THRESH_BINARY_INV,
            11, 2
        )

        # Find contours
        contours, _ = cv2.findContours(
            thresh,
            cv2.RETR_EXTERNAL,
            cv2.CHAIN_APPROX_SIMPLE
        )

        if not contours:
            return {'present': False, 'confidence': 0.95}

        # Check largest contour
        largest = max(contours, key=cv2.contourArea)
        area = cv2.contourArea(largest)

        # Compare to expected tablet area
        expected_area = self.config.get('tablet_area', 5000)
        tolerance = self.config.get('area_tolerance', 0.3)

        if area < expected_area * (1 - tolerance):
            return {'present': False, 'confidence': 0.9}

        return {'present': True, 'confidence': 0.95}

    def check_tablet_shape(self, gray: np.ndarray) -> dict:
        """Check tablet shape integrity"""
        # Edge detection
        edges = cv2.Canny(gray, 50, 150)

        # Find contours
        contours, _ = cv2.findContours(
            edges,
            cv2.RETR_EXTERNAL,
            cv2.CHAIN_APPROX_SIMPLE
        )

        if not contours:
            return {'intact': False, 'confidence': 0.8}

        largest = max(contours, key=cv2.contourArea)

        # Check circularity (for round tablets)
        area = cv2.contourArea(largest)
        perimeter = cv2.arcLength(largest, True)

        if perimeter == 0:
            return {'intact': False, 'confidence': 0.8}

        circularity = 4 * np.pi * area / (perimeter ** 2)

        # Circularity threshold (1.0 = perfect circle)
        min_circularity = self.config.get('min_circularity', 0.7)

        if circularity < min_circularity:
            return {
                'intact': False,
                'confidence': 1 - circularity,
                'circularity': circularity
            }

        # Check for cracks using edge density
        edge_density = np.sum(edges > 0) / edges.size

        if edge_density > self.config.get('max_edge_density', 0.15):
            return {'intact': False, 'confidence': 0.85}

        return {'intact': True, 'confidence': 0.9}

    def check_tablet_colour(self, hsv: np.ndarray) -> dict:
        """Check tablet colour against reference"""
        # Calculate mean colour in HSV
        mean_h = np.mean(hsv[:, :, 0])
        mean_s = np.mean(hsv[:, :, 1])
        mean_v = np.mean(hsv[:, :, 2])

        # Compare to reference
        ref_h, ref_s, ref_v = self.reference_colour
        tolerance = self.config.get('colour_tolerance', [10, 30, 30])

        h_match = abs(mean_h - ref_h) < tolerance[0]
        s_match = abs(mean_s - ref_s) < tolerance[1]
        v_match = abs(mean_v - ref_v) < tolerance[2]

        match = h_match and s_match and v_match

        # Calculate confidence based on deviation
        h_dev = abs(mean_h - ref_h) / max(tolerance[0], 1)
        s_dev = abs(mean_s - ref_s) / max(tolerance[1], 1)
        v_dev = abs(mean_v - ref_v) / max(tolerance[2], 1)

        confidence = 1 - (h_dev + s_dev + v_dev) / 3

        return {'match': match, 'confidence': max(0, confidence)}

    def check_contamination(self, roi: np.ndarray) -> dict:
        """Detect foreign particles or contamination"""
        gray = cv2.cvtColor(roi, cv2.COLOR_BGR2GRAY)

        # Look for unexpected dark spots
        _, dark_thresh = cv2.threshold(gray, 50, 255, cv2.THRESH_BINARY_INV)

        # Find small contours that could be contamination
        contours, _ = cv2.findContours(
            dark_thresh,
            cv2.RETR_EXTERNAL,
            cv2.CHAIN_APPROX_SIMPLE
        )

        # Filter by size (too small = noise, too large = tablet)
        min_area = self.config.get('contam_min_area', 50)
        max_area = self.config.get('contam_max_area', 500)

        suspicious = [
            c for c in contours
            if min_area < cv2.contourArea(c) < max_area
        ]

        if suspicious:
            return {'detected': True, 'confidence': 0.8, 'count': len(suspicious)}

        return {'detected': False, 'confidence': 0.9}

    def inspect_seal(self, image: np.ndarray) -> List[dict]:
        """Inspect seal area between pockets"""
        defects = []

        # Convert to grayscale
        gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)

        # Define seal regions (areas between pockets)
        seal_mask = self.create_seal_mask(image.shape[:2])

        # Apply mask
        seal_area = cv2.bitwise_and(gray, gray, mask=seal_mask)

        # Check for seal defects using edge detection
        edges = cv2.Canny(seal_area, 30, 100)
        edge_density = np.sum(edges > 0) / np.sum(seal_mask > 0)

        # High edge density in seal area indicates wrinkles/defects
        if edge_density > self.config.get('seal_edge_threshold', 0.1):
            defects.append({
                'type': DefectType.SEAL_DEFECT,
                'location': (0, 0),
                'confidence': min(edge_density * 5, 0.95),
                'message': "Seal quality issue detected"
            })

        return defects

    def create_seal_mask(self, shape: Tuple[int, int]) -> np.ndarray:
        """Create mask for seal inspection areas"""
        mask = np.ones(shape, dtype=np.uint8) * 255

        # Mask out pocket areas
        for pocket in self.find_pockets(np.zeros((*shape, 3), dtype=np.uint8)):
            x, y, w, h = pocket
            # Slightly enlarge pocket region
            pad = 5
            mask[max(0, y-pad):y+h+pad, max(0, x-pad):x+w+pad] = 0

        return mask

    def annotate_defects(
        self,
        image: np.ndarray,
        defects: List[dict]
    ) -> np.ndarray:
        """Draw defect annotations on image"""
        for defect in defects:
            x, y = defect['location']
            colour = (0, 0, 255)  # Red for defects

            # Draw rectangle around defect area
            cv2.rectangle(image, (x, y), (x+50, y+50), colour, 2)

            # Add label
            label = f"{defect['type'].value}: {defect['confidence']:.2f}"
            cv2.putText(
                image, label, (x, y-10),
                cv2.FONT_HERSHEY_SIMPLEX, 0.5, colour, 1
            )

        return image

    def non_max_suppression(
        self,
        boxes: List[Tuple],
        overlap_thresh: float = 0.3
    ) -> List[Tuple]:
        """Remove overlapping detections"""
        if not boxes:
            return []

        boxes = np.array(boxes)
        pick = []

        x1 = boxes[:, 0]
        y1 = boxes[:, 1]
        x2 = boxes[:, 0] + boxes[:, 2]
        y2 = boxes[:, 1] + boxes[:, 3]
        area = (x2 - x1) * (y2 - y1)

        idxs = np.argsort(y2)

        while len(idxs) > 0:
            last = len(idxs) - 1
            i = idxs[last]
            pick.append(i)

            xx1 = np.maximum(x1[i], x1[idxs[:last]])
            yy1 = np.maximum(y1[i], y1[idxs[:last]])
            xx2 = np.minimum(x2[i], x2[idxs[:last]])
            yy2 = np.minimum(y2[i], y2[idxs[:last]])

            w = np.maximum(0, xx2 - xx1)
            h = np.maximum(0, yy2 - yy1)

            overlap = (w * h) / area[idxs[:last]]

            idxs = np.delete(
                idxs,
                np.concatenate(([last], np.where(overlap > overlap_thresh)[0]))
            )

        return [tuple(boxes[i]) for i in pick]

    def calculate_confidence(self, defects: List[dict]) -> float:
        """Calculate overall inspection confidence"""
        if not defects:
            return 0.95  # High confidence pack is good

        # Average confidence of detected defects
        avg_defect_conf = np.mean([d['confidence'] for d in defects])
        return avg_defect_conf


# Usage Example
if __name__ == "__main__":
    config = {
        'pocket_rows': 2,
        'pocket_cols': 5,
        'margin': 30,
        'tablet_area': 3000,
        'area_tolerance': 0.25,
        'min_circularity': 0.75,
        'reference_hsv': [25, 80, 180],  # Orange tablet
        'colour_tolerance': [15, 40, 40],
    }

    inspector = BlisterPackInspector(config)

    # Inspect image
    image = cv2.imread('blister_pack.jpg')
    result = inspector.inspect_pack(image)

    if result.passed:
        print("PASS - Pack OK")
    else:
        print(f"FAIL - {len(result.defects)} defects found:")
        for defect in result.defects:
            print(f"  - {defect['message']}")

    # Display annotated image
    cv2.imshow('Inspection Result', result.image_annotated)
    cv2.waitKey(0)

OCR for Print Verification

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
import cv2
import pytesseract
from datetime import datetime
import re

class PrintVerifier:
    def __init__(self, config: dict):
        self.config = config
        self.date_patterns = [
            r'\d{2}/\d{2}/\d{4}',  # DD/MM/YYYY
            r'\d{4}-\d{2}-\d{2}',  # YYYY-MM-DD
            r'[A-Z]{3}\d{4}',      # MMM YYYY
        ]

    def verify_print(self, image: np.ndarray) -> dict:
        """Verify printed information on blister pack"""
        results = {
            'lot_number': None,
            'expiry_date': None,
            'barcode': None,
            'errors': []
        }

        # Preprocess for OCR
        processed = self.preprocess_for_ocr(image)

        # Extract text
        text = pytesseract.image_to_string(
            processed,
            config='--psm 6 -c tessedit_char_whitelist=0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ/-'
        )

        # Parse lot number
        lot_match = re.search(r'LOT[:\s]*([A-Z0-9]+)', text, re.IGNORECASE)
        if lot_match:
            results['lot_number'] = lot_match.group(1)
        else:
            results['errors'].append('Lot number not found')

        # Parse expiry date
        for pattern in self.date_patterns:
            date_match = re.search(pattern, text)
            if date_match:
                results['expiry_date'] = date_match.group()
                break
        else:
            results['errors'].append('Expiry date not found')

        # Verify expiry is in future
        if results['expiry_date']:
            if not self.validate_expiry(results['expiry_date']):
                results['errors'].append('Expiry date is invalid or in past')

        # Check print quality
        quality_score = self.assess_print_quality(processed)
        if quality_score < self.config.get('min_quality', 0.7):
            results['errors'].append(f'Poor print quality: {quality_score:.2f}')

        results['passed'] = len(results['errors']) == 0
        return results

    def preprocess_for_ocr(self, image: np.ndarray) -> np.ndarray:
        """Enhance image for OCR"""
        gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)

        # Denoise
        denoised = cv2.fastNlMeansDenoising(gray, h=10)

        # Increase contrast
        clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
        enhanced = clahe.apply(denoised)

        # Binarize
        _, binary = cv2.threshold(
            enhanced, 0, 255,
            cv2.THRESH_BINARY + cv2.THRESH_OTSU
        )

        return binary

    def validate_expiry(self, date_str: str) -> bool:
        """Check if expiry date is valid and in future"""
        formats = ['%d/%m/%Y', '%Y-%m-%d', '%b%Y']

        for fmt in formats:
            try:
                date = datetime.strptime(date_str, fmt)
                return date > datetime.now()
            except ValueError:
                continue

        return False

    def assess_print_quality(self, binary: np.ndarray) -> float:
        """Assess print quality using edge sharpness"""
        edges = cv2.Canny(binary, 50, 150)
        edge_density = np.sum(edges > 0) / edges.size

        # Good print has clear edges but not too many (noise)
        if 0.02 < edge_density < 0.15:
            return min(edge_density * 10, 1.0)

        return max(0, 1 - abs(edge_density - 0.08) * 5)

Compliance Considerations

FDA 21 CFR Part 11 Requirements

Requirement Implementation
Electronic signatures User authentication, audit trail
Audit trail Log all inspections with timestamp
Data integrity Hash verification, database backups
Access control Role-based permissions
System validation IQ/OQ/PQ documentation

EU GMP Annex 11 Requirements

Requirement Implementation
Risk assessment Document failure modes
Validated systems Test and document performance
Data backup Automated, verified backups
Incident management Defect tracking, root cause analysis
Training records Operator qualification documentation

Validation Documentation

Required Documents:

  1. User Requirements Specification (URS)
  2. Functional Specification (FS)
  3. Design Specification (DS)
  4. Installation Qualification (IQ)
  5. Operational Qualification (OQ)
  6. Performance Qualification (PQ)
  7. Standard Operating Procedures (SOPs)
  8. Training Records

Performance Specifications

Target Metrics

Metric Requirement Typical Achieved
Detection rate >99.9% 99.95%
False positive rate <0.1% 0.05%
Inspection speed 400 packs/min 450 packs/min
System uptime >99% 99.5%
Response time <100ms 50ms

Testing Protocol

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
def validate_system(inspector, test_set):
    """Run validation tests per FDA/GMP requirements"""
    results = {
        'true_positives': 0,
        'true_negatives': 0,
        'false_positives': 0,
        'false_negatives': 0,
        'total': 0
    }

    for image_path, expected_result in test_set:
        image = cv2.imread(image_path)
        result = inspector.inspect_pack(image)

        actual_pass = result.passed
        expected_pass = expected_result['pass']

        if actual_pass and expected_pass:
            results['true_negatives'] += 1  # Correctly passed good pack
        elif not actual_pass and not expected_pass:
            results['true_positives'] += 1  # Correctly rejected bad pack
        elif actual_pass and not expected_pass:
            results['false_negatives'] += 1  # Missed a defect!
        else:
            results['false_positives'] += 1  # Rejected good pack

        results['total'] += 1

    # Calculate metrics
    tp = results['true_positives']
    tn = results['true_negatives']
    fp = results['false_positives']
    fn = results['false_negatives']

    sensitivity = tp / (tp + fn) if (tp + fn) > 0 else 0  # Detection rate
    specificity = tn / (tn + fp) if (tn + fp) > 0 else 0
    precision = tp / (tp + fp) if (tp + fp) > 0 else 0
    accuracy = (tp + tn) / results['total']

    print(f"Detection Rate (Sensitivity): {sensitivity:.4f}")
    print(f"Specificity: {specificity:.4f}")
    print(f"False Positive Rate: {1 - specificity:.4f}")
    print(f"False Negative Rate: {1 - sensitivity:.4f}")
    print(f"Overall Accuracy: {accuracy:.4f}")

    # FDA requirement check
    if sensitivity >= 0.999 and (1 - specificity) <= 0.001:
        print("PASS - Meets FDA detection requirements")
    else:
        print("FAIL - Does not meet requirements")

    return results

Cost Estimate

Basic System (Single Line, <200 packs/min)

Component Cost
4x Industrial cameras £3,000
Lighting system £2,000
Industrial PC £2,500
Vision software £5,000
Mounting/enclosure £2,000
Integration £10,000
Validation £5,000
Total £29,500

Production System (Multi-Line, 400+ packs/min)

Component Cost
Vision system per line £35,000
Lines (x3) £105,000
Central server/SCADA £15,000
Validation/documentation £25,000
Training £5,000
Total £150,000

Frequently Asked Questions

What detection rate is required for pharma?

FDA expects >99.9% detection of critical defects (missing tablets, foreign matter). Most validated systems achieve 99.95%+.

Can AI/deep learning be used?

Yes, with proper validation. Cognex ViDi and similar AI tools are increasingly used, but require extensive validation documentation and change control procedures.

How do we handle clear/transparent tablets?

Use specialised lighting (polarised, specific wavelength) and potentially different algorithms. Clear tablets often require backlight inspection.

What about serialisation requirements?

Vision systems integrate with serialisation (unique ID per pack). Use high-resolution cameras and validated OCR/barcode reading.

How often should systems be recalibrated?

Typically daily verification, monthly calibration check, annual full revalidation. Document everything.


Next Steps

  1. Define requirements - Defect types, speeds, compliance needs
  2. Select technology - Commercial vs. custom development
  3. Plan validation - Budget time for IQ/OQ/PQ
  4. Pilot testing - Start with one line
  5. Scale deployment - Roll out with lessons learned

Don't Miss the Next Insight

Weekly updates on computer vision, defect detection, and practical AI implementation.

Was this article helpful?

Your feedback helps improve future content

Found a bug in the code or spotted an error?

Report an issue
James Lions

James Lions

AI & Computer Vision enthusiast exploring the future of automated defect detection