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 |
| 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:
- User Requirements Specification (URS)
- Functional Specification (FS)
- Design Specification (DS)
- Installation Qualification (IQ)
- Operational Qualification (OQ)
- Performance Qualification (PQ)
- Standard Operating Procedures (SOPs)
- 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
- Define requirements - Defect types, speeds, compliance needs
- Select technology - Commercial vs. custom development
- Plan validation - Budget time for IQ/OQ/PQ
- Pilot testing - Start with one line
- Scale deployment - Roll out with lessons learned