Computer Vision per Controllo qualità Alimentare con PyTorch e YOLO
Ogni anno, difetti alimentari non rilevati durante la produzione costano all'industria globale circa 7 miliardi di dollari: ritiri di prodotto, richiami dal mercato, danni alla reputazione del brand, sanzioni normative e, nei casi più gravi, rischi per la salute del consumatore. Un pomodoro ammaccato che finisce in vasetto, un corpo estraneo metallico che supera la linea di confezionamento, un lotto di frutta con muffa nascosta: situazioni che i sistemi di ispezione manuale non riescono a intercettare con costanza sufficiente quando la linea gira a 10-20 pezzi al secondo.
L'ispezione visiva umana e efficace ma intrinsecamente limitata: un ispettore in buona salute, ben addestrato e riposato raggiunge un'accuratezza di circa il 60-70% su linee ad alta velocità, con una variabilità significativa legata a fatica, illuminazione e soggettivita. I sistemi di computer vision basati su deep learning, invece, operano con accuratezza superiore al 98%, 24 ore su 24, senza cali di prestazioni nel turno di notte e con consistenza assoluta nella valutazione.
In questo contesto, la famiglia YOLO (You Only Look Once) di Ultralytics e diventata lo standard de facto per l'ispezione industriale in real-time. YOLO11, rilasciato nel settembre 2024, offre prestazioni eccezionali: 22% di parametri in meno rispetto a YOLOv8 con mAP superiore sul benchmark COCO, latenze sotto i 2 ms su GPU T4, e capacità di rilevare difetti di dimensioni millimetriche su nastri trasportatori ad alta velocità. Il mercato AI per il controllo qualità alimentare e la sicurezza degli alimenti ha raggiunto 2.7 miliardi di dollari nel 2025 e crescera fino a 13.7 miliardi entro il 2030 a un CAGR del 30.9%.
Questo articolo e una guida tecnica completa: dall'architettura dei sistemi di vision industriale, alla costruzione e annotazione di dataset specifici per i difetti alimentari, al training pipeline con PyTorch e YOLO11, fino al deploy su linea produttiva con hardware industriale, integrazione PLC e sistema di sorting automatico. Ogni sezione include codice Python funzionante e testato.
Cosa Imparerai in Questo Articolo
- Fondamenti della computer vision applicata all'industria alimentare: sfide specifiche e differenze dal CV industriale tradizionale
- Evoluzione YOLO: da YOLOv5 a YOLO11, architettura, benchmarks e perchè YOLO vince su Faster R-CNN per food inspection
- Costruzione di un dataset per difetti alimentari: annotazione con CVAT/Roboflow, classi, data augmentation food-specific
- Training pipeline completa con PyTorch e YOLO11: codice Python, hyperparameter tuning, validazione
- Detection vs Classification vs Segmentation: quando usare quale approccio per il controllo qualità
- Architettura hardware di una linea di ispezione: camera GigE Vision, illuminazione strutturata, trigger, PLC
- Metriche di qualità: mAP, precision, recall, soglie accettabili per l'industria alimentare
- Sistema di sorting automatico: integrazione attuatori pneumatici e robot pick-and-place
- Case study completo: linea di sorting frutta con YOLO11, 10 pezzi/secondo, 98.3% accuracy
- Normative: IFS Food, BRC, HACCP per sistemi vision in ambienti alimentari
Serie FoodTech - Tutti gli Articoli
| # | Articolo | Livello | Stato |
|---|---|---|---|
| 1 | Pipeline IoT per Agricoltura di Precisione con Python e MQTT | Advanced | Disponibile |
| 2 | ML Edge per Monitoraggio Colture: Computer Vision sui Campi | Advanced | Disponibile |
| 3 | Satellite API e Indici Vegetativi: NDVI con Python e Sentinel-2 | Intermediate | Disponibile |
| 4 | Tracciabilita Blockchain nel Food: dal Campo al Supermercato | Intermediate | Disponibile |
| 5 | Computer Vision per Controllo qualità con PyTorch YOLO (sei qui) | Advanced | Corrente |
| 6 | FSMA e Compliance Digitale: Automazione dei Processi Normativi | Intermediate | In arrivo |
| 7 | Vertical Farming: Controllo Ambientale con IoT e ML | Advanced | In arrivo |
| 8 | Demand Forecasting per il Food Retail con Prophet e LightGBM | Intermediate | In arrivo |
| 9 | Dashboard Farm Intelligence: Real-Time Analytics con Grafana | Intermediate | In arrivo |
| 10 | Supply Chain Food Optimization: ML per Riduzione degli Sprechi | Intermediate | In arrivo |
Computer Vision nell'Industria Alimentare: Sfide e Opportunità
La computer vision per il controllo qualità alimentare non e semplicemente "CV industriale applicato al cibo". Ha caratteristiche uniche che la rendono più complessa e al tempo stesso più interessante rispetto all'ispezione di componenti meccanici o circuiti stampati. Comprendere queste secificità e il primo passo per costruire un sistema robusto e affidabile.
Variabilità Naturale dei Prodotti Alimentari
Un bullone difettoso si distingue da uno conforme con criteri geometrici precisi e invarianti: diametro, passo, lunghezza. Un pomodoro "perfetto", invece, esiste in una gamma quasi infinita di forme, colori, texture e dimensioni che variano non solo tra varieta diverse, ma anche all'interno dello stesso raccolto. Il sistema di vision deve imparare a distinguere la variabilità naturale accettabile (un pomodoro leggermente asimmetrico ma perfettamente sano) dal difetto effettivo (una bruciatura solare, un'ammaccatura da impatto, un attacco fungino).
Questa sfida richiede dataset di training molto ampi e bilanciati, con campioni rappresentativi di tutta la variabilità normale del prodotto, e un'attenzione particolare alla calibrazione delle soglie di decisione per evitare sia i falsi negativi (difetti non rilevati) che i falsi positivi (prodotti conformi scartati, con impatto diretto sui costi operativi).
Sfide di Illuminazione e Ambiente
L'ambiente di una linea di produzione alimentare e ostile per i sistemi ottici: vapore acqueo da processi di lavaggio, condensa sulle lenti in ambienti refrigerati, variazioni di illuminazione lungo il nastro trasportatore, riflessi speculari da superfici lucide come cere o glazze. L'illuminazione strutturata (ring light, backlight, illuminazione coassiale, luce polarizzata) e fondamentale e deve essere progettata insieme al sistema di vision, non aggiunta come afterthought.
Per i prodotti trasparenti o semitrasparenti (gelatine, bevande, contenitori in PET), si usa la retroilluminazione (backlight) che rende visibili inclusioni e bolle d'aria. Per prodotti opachi come frutta e ortaggi, la combinazione di illuminazione diffusa a 45 gradi con luce coassiale rivela bene bruciature, ammaccature e lesioni superficiali. Per la rilevazione di corpi estranei metallici si affianca il sistema di vision con metal detector a induzione o a raggi X.
Velocita di Linea e Real-Time Processing
Una linea di confezionamento frutta opera tipicamente a 8-15 pezzi al secondo per canale. Una linea di produzione di biscotti può raggiungere 200-400 pezzi al minuto. Il sistema di vision deve acquisire l'immagine, elaborarla e comunicare la decisione all'attuatore di scarto nel tempo che il prodotto impiega a percorrere la distanza tra la stazione di ispezione e quella di sorting: tipicamente 200-500 ms.
Questo vincolo temporale esclude approcci computazionalmente pesanti come i modelli di segmentazione ad alta risoluzione senza ottimizzazione, e premia architetture one-shot come YOLO che eseguono la detection in un singolo forward pass sulla GPU.
Evoluzione YOLO per Food Inspection: da YOLOv5 a YOLO11
La famiglia YOLO ha dominato la detection industriale real-time per quasi un decennio. Ripercorrere la sua evoluzione aiuta a capire perchè YOLO11 sia la scelta ottimale per un nuovo sistema di ispezione alimentare nel 2025.
YOLOv5 (2020) - Il Punto di Svolta
YOLOv5 di Ultralytics ha rivoluzionato l'accessibilità della detection industriale: prima implementazione nativa PyTorch modulare, esportazione semplificata verso ONNX, TensorRT e CoreML, pipeline di training documentata e reproducibile. Ha reso la detection custom accessibile a team senza deep expertise in CV, e ha colonizzato le linee produttive globali grazie alla semplicità di deployment. Molti impianti installedati tra il 2021 e il 2023 girano ancora su YOLOv5.
YOLOv8 (2023) - L'Architettura Moderna
YOLOv8 ha introdotto un'architettura anchor-free che elimina la necessità di definire anchor box predefiniti, semplificando il training su dataset alimentari dove le dimensioni degli oggetti sono molto variabili (da un punto di muffa millimetrico a un'intera mela). Il backbone C2f (Cross Stage Partial with 2 bottlenecks) migliora il flusso del gradiente durante il training. La testa di detection separata (decoupled head) per classificazione e regressione migliora la convergenza. mAP@50 su COCO: 50.2% per YOLOv8m con 25.9M parametri.
YOLO11 (Settembre 2024) - Lo Stato dell'Arte
YOLO11 rappresenta il salto più significativo in termini di efficienza computazionale: il modello YOLO11m raggiunge mAP@50 di 51.5% su COCO con solo 20.1M parametri, cioe il 22% in meno di YOLOv8m a parita di task. L'improved backbone con architettura C3k2 offre una feature extraction più ricca. Il neck SPPF (Spatial Pyramid Pooling - Fast) gestisce meglio gli oggetti a scale diverse, cruciale per rilevare sia difetti millimetrici che oggetti completi. La latenza su GPU NVIDIA T4 per il modello Nano e di 1.5 ms, permettendo elaborazione a 600+ FPS.
Confronto YOLO vs Architetture Alternative per Food Inspection
| Architettura | mAP@50 COCO | Latenza (ms) | Parametri | Idoneita Food QC |
|---|---|---|---|---|
| YOLO11n | 39.5% | 1.5 ms | 2.6M | Ottimo (edge deploy) |
| YOLO11m | 51.5% | 4.7 ms | 20.1M | Eccellente (bilancio) |
| YOLO11x | 54.7% | 11.3 ms | 56.9M | Buono (alta accuracy) |
| YOLOv8m | 50.2% | 5.1 ms | 25.9M | Buono (legacy sistemi) |
| Faster R-CNN | 55.0% | 120-200 ms | 41.8M | Scarso (troppo lento) |
| SSD MobileNet | 23.2% | 1.1 ms | 6.8M | Marginale (bassa acc.) |
| RT-DETR | 53.1% | 8.9 ms | 42.0M | Buono (senza NMS) |
Faster R-CNN, nonostante la sua alta accuratezza in benchmark statici, e praticamente inutilizzabile per ispezione in real-time su nastro trasportatore: 120-200 ms di latenza significano che a 10 pezzi/secondo il sistema vede solo 1 pezzo su 12-20. YOLO11m con 4.7 ms elabora facilmente 200 FPS, lasciando ampio margine per la pipeline di comunicazione con il PLC e l'attuatore di scarto.
Dataset per Difetti Alimentari: Costruzione e Annotazione
La qualità del dataset di training e il fattore più determinante per le prestazioni del sistema di vision. Un modello YOLO11 eccellente addestrato su un dataset povero dara risultati mediocri. Viceversa, anche un modello più semplice addestrato su un dataset ricco, bilanciato e ben annotato produce risultati notevolmente migliori.
Classi di Difetti Comuni nell'Industria Alimentare
Le classi da includere nel dataset dipendono dal prodotto e dal processo, ma esistono categorie comuni all'industria alimentare in generale:
Classi di Difetti per un Sistema di Vision su Frutta e Ortaggi
| Classe | Descrizione | Causa Tipica | Soglia Criticita |
|---|---|---|---|
mold | Muffa superficiale o nascosta | Umidita, ferite cutanee | Alta (scarto obbligatorio) |
bruise | Ammaccatura da impatto | Raccolta, trasporto | Media (dipende da gravita) |
burn | Bruciatura solare o da freddo | Esposizione diretta UV, gelate | Media-Alta |
crack | Spaccatura o fessura | Crescita rapida, siccita | Alta (vettore patogeni) |
foreign_object | Corpo estraneo (foglie, pietre, plastica) | Raccolta meccanica | Critica |
rot | Marciume avanzato | Batteriosi, funghi | Alta (scarto obbligatorio) |
insect_damage | Lesioni da insetti | Attacchi entomologici | Media-Alta |
size_defect | Calibro fuori specifica | Variabilità cultivar | Bassa (re-indirizzamento) |
color_defect | Colore atipico (sovra/sotto-maturazione) | Timing raccolta | Media |
ok | Prodotto conforme | - | - |
Strumenti di Annotazione: CVAT, Label Studio e Roboflow
Per l'annotazione di dataset industriali esistono tre strumenti principali, ognuno con punti di forza specifici:
CVAT (Computer Vision Annotation Tool) di Intel e uno strumento open-source self-hosted robusto, ideale per team con requisiti di privacy dei dati o ambienti air-gapped. Supporta annotazioni bounding box, polygon, polyline, punti e tracking video. L'integrazione con Roboflow permette di usare modelli pre-addestrati come assistenti all'annotazione, riducendo il tempo manuale del 60-70%.
Label Studio e più flessibile per dataset multimodali (immagini, testo, audio) e si integra facilmente con pipeline MLOps. Supporta collaborative annotation con multiple revisioni e consensus voting, utile quando più annotatori lavorano sulle stesse immagini.
Roboflow offre la pipeline più integrata: annotazione, preprocessing, augmentation e export in formato YOLO in un unico workflow cloud. Per dataset alimentari pubblici, Roboflow Universe ospita centinaia di dataset di dominio pubblico (frutta, vegetali, difetti superficiali) utilizzabili come punto di partenza con transfer learning.
Dimensionamento del Dataset
Per un sistema di detection con 8-10 classi di difetti su un singolo prodotto, la dimensione minima raccomandata e:
- Training set: minimo 500 immagini per classe, idealmente 1000+
- Validation set: 15-20% del training, stratificato per classe
- Test set: 10-15%, completamente separato, acquisito in condizioni reali di linea
- Classi rare: per classi come
foreign_objectche occorrono raramente in produzione, usare over-sampling e augmentation aggressiva
Data Augmentation Specifica per Food
L'augmentation per prodotti alimentari deve simulare le variazioni reali che il sistema incontrera in produzione. Le tecniche standard (flip orizzontale, rotazione, crop) vanno integrate con augmentation food-specific:
# augmentation_food.py
# Configurazione augmentation specifica per food quality inspection
import albumentations as A
import cv2
import numpy as np
def build_food_augmentation_pipeline(image_size: int = 640) -> A.Compose:
"""
Pipeline di augmentation per dataset di difetti alimentari.
Simula variazioni reali di illuminazione, orientamento e condizioni di linea.
"""
return A.Compose([
# Geometria: prodotti alimentari arrivano in orientamenti casuali
A.RandomRotate90(p=0.5),
A.HorizontalFlip(p=0.5),
A.VerticalFlip(p=0.3),
A.ShiftScaleRotate(
shift_limit=0.1,
scale_limit=0.2,
rotate_limit=45,
border_mode=cv2.BORDER_REFLECT,
p=0.7
),
# Illuminazione: variazioni reali in linea (vapore, sporco sulle lenti)
A.OneOf([
A.RandomBrightnessContrast(
brightness_limit=0.3,
contrast_limit=0.3,
p=1.0
),
A.RandomGamma(gamma_limit=(70, 130), p=1.0),
A.CLAHE(clip_limit=4.0, tile_grid_size=(8, 8), p=1.0),
], p=0.8),
# Colore: variazioni stagionali e di maturazione
A.HueSaturationValue(
hue_shift_limit=15, # Piccolo: non cambiare il colore del prodotto
sat_shift_limit=30,
val_shift_limit=20,
p=0.5
),
# Rumore: sensore camera industriale, interferenze EMI
A.OneOf([
A.GaussNoise(var_limit=(10.0, 50.0), p=1.0),
A.ISONoise(color_shift=(0.01, 0.05), intensity=(0.1, 0.5), p=1.0),
A.MultiplicativeNoise(multiplier=(0.9, 1.1), p=1.0),
], p=0.4),
# Blur: motion blur da velocità nastro, defocus per DOF limitata
A.OneOf([
A.MotionBlur(blur_limit=7, p=1.0), # Più realistico per nastro
A.GaussianBlur(blur_limit=(3, 7), p=1.0),
], p=0.3),
# Riflessioni: superfici cerate, glazze, film di acqua
A.RandomSunFlare(
flare_roi=(0.0, 0.0, 1.0, 0.5),
num_flare_circles_lower=1,
num_flare_circles_upper=3,
src_radius=100,
p=0.1
),
# Ritaglio e padding finale
A.PadIfNeeded(
min_height=image_size,
min_width=image_size,
border_mode=cv2.BORDER_REFLECT
),
A.Resize(image_size, image_size),
# Normalizzazione finale
A.Normalize(
mean=[0.485, 0.456, 0.406],
std=[0.229, 0.224, 0.225]
),
], bbox_params=A.BboxParams(
format='yolo',
label_fields=['class_labels'],
min_area=100, # Ignora bbox troppo piccole dopo crop
min_visibility=0.3 # Ignora bbox con meno del 30% visibile
))
def augment_rare_class(
image: np.ndarray,
bboxes: list,
class_labels: list,
n_augmentations: int = 10
) -> list:
"""
Over-sampling per classi rare come foreign_object.
Genera n varianti augmentate di un singolo campione.
"""
pipeline = build_food_augmentation_pipeline()
augmented_samples = []
for _ in range(n_augmentations):
result = pipeline(
image=image,
bboxes=bboxes,
class_labels=class_labels
)
augmented_samples.append({
'image': result['image'],
'bboxes': result['bboxes'],
'class_labels': result['class_labels']
})
return augmented_samples
Training Pipeline Completa con PyTorch e YOLO11
Il training di un modello YOLO11 personalizzato per il controllo qualità alimentare segue una pipeline strutturata: preparazione ambiente, dataset preparation, training con transfer learning, validazione e ottimizzazione per il deploy.
Setup Ambiente di Training
# Requisiti: Python 3.11+, CUDA 12.1+, NVIDIA GPU con 8GB+ VRAM
# Consigliato: RTX 3080/4080 per training locale, A100 per training veloce
# 1. Installazione dipendenze
# pip install ultralytics==8.3.0 albumentations roboflow torch torchvision
# 2. Struttura directory dataset (formato YOLO)
# dataset/
# ├── images/
# │ ├── train/ (70% campioni)
# │ ├── val/ (20% campioni)
# │ └── test/ (10% campioni)
# ├── labels/
# │ ├── train/ (file .txt corrispondenti)
# │ ├── val/
# │ └── test/
# └── data.yaml (configurazione dataset)
# data.yaml - Configurazione dataset
DATA_YAML_CONTENT = """
path: /data/food_quality_dataset
train: images/train
val: images/val
test: images/test
nc: 10 # Numero di classi
names:
0: ok
1: mold
2: bruise
3: burn
4: crack
5: foreign_object
6: rot
7: insect_damage
8: size_defect
9: color_defect
"""
import yaml
with open('/data/food_quality_dataset/data.yaml', 'w') as f:
f.write(DATA_YAML_CONTENT)
Download e Preparazione Dataset con Roboflow
# dataset_preparation.py
# Scarica dataset da Roboflow Universe o usa dataset locale
from roboflow import Roboflow
import os
def download_food_dataset(api_key: str, workspace: str, project: str, version: int) -> str:
"""
Scarica dataset da Roboflow e prepara per training YOLO11.
"""
rf = Roboflow(api_key=api_key)
proj = rf.workspace(workspace).project(project)
dataset = proj.version(version).download("yolov8") # Formato YOLO11 compatibile
dataset_path = dataset.location
print(f"Dataset scaricato in: {dataset_path}")
print(f"Training images: {len(os.listdir(os.path.join(dataset_path, 'train', 'images')))}")
print(f"Validation images: {len(os.listdir(os.path.join(dataset_path, 'valid', 'images')))}")
return dataset_path
def analyze_class_distribution(dataset_path: str) -> dict:
"""
Analizza la distribuzione delle classi nel dataset.
Identifica classi sbilanciate che richiedono over-sampling.
"""
import glob
from collections import Counter
class_counts = Counter()
label_files = glob.glob(os.path.join(dataset_path, 'train', 'labels', '*.txt'))
for label_file in label_files:
with open(label_file, 'r') as f:
for line in f:
class_id = int(line.split()[0])
class_counts[class_id] += 1
total = sum(class_counts.values())
distribution = {
class_id: {
'count': count,
'percentage': round(count / total * 100, 2),
'needs_oversampling': count < total / len(class_counts) * 0.3 # < 30% della media
}
for class_id, count in sorted(class_counts.items())
}
return distribution
# Analisi distribuzione per identificare classi rare
if __name__ == "__main__":
dataset_path = "/data/food_quality_dataset"
distribution = analyze_class_distribution(dataset_path)
print("\nDistribuzione classi nel dataset:")
class_names = ['ok', 'mold', 'bruise', 'burn', 'crack',
'foreign_object', 'rot', 'insect_damage', 'size_defect', 'color_defect']
for class_id, info in distribution.items():
name = class_names[class_id] if class_id < len(class_names) else f"class_{class_id}"
warning = " *** RICHIEDE OVERSAMPLING ***" if info['needs_oversampling'] else ""
print(f" {name}: {info['count']} campioni ({info['percentage']}%){warning}")
Training YOLO11 con Transfer Learning
# train_yolo11_food.py
# Training pipeline completa per food quality inspection
from ultralytics import YOLO
import torch
import yaml
import os
from pathlib import Path
def train_food_quality_model(
dataset_yaml: str,
output_dir: str = "./runs/food_quality",
epochs: int = 150,
batch_size: int = 16,
image_size: int = 640,
model_variant: str = "yolo11m.pt" # n/s/m/l/x
) -> dict:
"""
Training YOLO11 per food quality inspection con ottimizzazioni specifiche.
Args:
dataset_yaml: Path al file data.yaml del dataset
output_dir: Directory per salvare i risultati
epochs: Numero di epoche (150 e un buon punto di partenza)
batch_size: Dimensione batch (16 per 8GB VRAM, 32 per 16GB+)
image_size: Dimensione immagine (640 standard, 1280 per difetti piccoli)
model_variant: Variante YOLO11 da usare come punto di partenza
Returns:
dict con metriche finali del training
"""
# Verifica GPU disponibile
device = 'cuda' if torch.cuda.is_available() else 'cpu'
if device == 'cpu':
print("ATTENZIONE: Training su CPU, sarà molto lento. Usa una GPU.")
print(f"Device: {device}")
if device == 'cuda':
print(f"GPU: {torch.cuda.get_device_name(0)}")
print(f"VRAM: {torch.cuda.get_device_properties(0).total_memory / 1e9:.1f} GB")
# Carica modello pre-addestrato su COCO (transfer learning)
model = YOLO(model_variant)
# Configurazione training
training_args = {
'data': dataset_yaml,
'epochs': epochs,
'batch': batch_size,
'imgsz': image_size,
'device': device,
'project': output_dir,
'name': 'food_quality_v1',
# Ottimizzatore: AdamW e ottimo per fine-tuning
'optimizer': 'AdamW',
'lr0': 0.001, # Learning rate iniziale
'lrf': 0.01, # Learning rate finale = lr0 * lrf
'momentum': 0.937,
'weight_decay': 0.0005,
# Learning rate scheduling: cosine annealing
'cos_lr': True,
'warmup_epochs': 5, # 5 epoche di warmup per stabilizzare
# Augmentation integrata (albumentations)
'hsv_h': 0.015, # Hue shift (piccolo per preservare colore prodotto)
'hsv_s': 0.7, # Saturation
'hsv_v': 0.4, # Value (luminosita)
'degrees': 30.0, # Rotazione
'translate': 0.1, # Traslazione
'scale': 0.5, # Scaling
'shear': 5.0,
'perspective': 0.0001,
'flipud': 0.3, # Flip verticale (utile per frutta)
'fliplr': 0.5, # Flip orizzontale
'mosaic': 1.0, # Mosaic augmentation (4 immagini)
'mixup': 0.1, # MixUp augmentation
'copy_paste': 0.1, # Copy-paste per classi rare
# Loss weights: aumenta peso classificazione per difetti rari
'cls': 1.5, # Classification loss weight (default 0.5)
'box': 7.5, # Box regression loss weight
'dfl': 1.5, # Distribution Focal Loss weight
# Valutazione e salvataggio
'val': True,
'save': True,
'save_period': 10, # Salva checkpoint ogni 10 epoche
'patience': 30, # Early stopping se no miglioramento per 30 epoche
# Performances
'workers': 8, # DataLoader workers
'cache': True, # Cache dataset in RAM per velocità
'amp': True, # Automatic Mixed Precision (FP16)
# Metriche
'plots': True, # Genera grafici di training
'verbose': True,
}
# Avvia training
print(f"\nAvvio training YOLO11 per food quality inspection")
print(f"Epoche: {epochs}, Batch: {batch_size}, Immagini: {image_size}x{image_size}")
results = model.train(**training_args)
# Estrai metriche finali
metrics = {
'mAP50': float(results.results_dict.get('metrics/mAP50(B)', 0)),
'mAP50_95': float(results.results_dict.get('metrics/mAP50-95(B)', 0)),
'precision': float(results.results_dict.get('metrics/precision(B)', 0)),
'recall': float(results.results_dict.get('metrics/recall(B)', 0)),
'best_model_path': str(Path(output_dir) / 'food_quality_v1' / 'weights' / 'best.pt')
}
print(f"\nTraining completato!")
print(f"mAP@50: {metrics['mAP50']:.4f}")
print(f"mAP@50-95: {metrics['mAP50_95']:.4f}")
print(f"Precision: {metrics['precision']:.4f}")
print(f"Recall: {metrics['recall']:.4f}")
print(f"Modello salvato in: {metrics['best_model_path']}")
return metrics
if __name__ == "__main__":
metrics = train_food_quality_model(
dataset_yaml="/data/food_quality_dataset/data.yaml",
output_dir="./runs/food_quality",
epochs=150,
batch_size=16,
image_size=640,
model_variant="yolo11m.pt"
)
Hyperparameter Tuning con Ray Tune
# hyperparameter_tuning.py
# Ricerca automatica degli hyperparametri ottimali con Ray Tune
from ultralytics import YOLO
from ray import tune
from ray.tune.schedulers import ASHAScheduler
import torch
def objective(config: dict) -> dict:
"""Funzione obiettivo per Ray Tune."""
model = YOLO("yolo11m.pt")
results = model.train(
data="/data/food_quality_dataset/data.yaml",
epochs=50, # Epoche ridotte per tuning rapido
batch=config['batch'],
lr0=config['lr0'],
lrf=config['lrf'],
momentum=config['momentum'],
weight_decay=config['weight_decay'],
cls=config['cls'],
hsv_s=config['hsv_s'],
mixup=config['mixup'],
amp=True,
verbose=False,
plots=False,
)
mAP50 = results.results_dict.get('metrics/mAP50(B)', 0)
return {"mAP50": mAP50}
def run_hyperparameter_search(n_trials: int = 20) -> dict:
"""Esegue ricerca hyperparametri con ASHA scheduler."""
search_space = {
'batch': tune.choice([8, 16, 32]),
'lr0': tune.loguniform(1e-4, 1e-2),
'lrf': tune.uniform(0.001, 0.1),
'momentum': tune.uniform(0.85, 0.98),
'weight_decay': tune.loguniform(1e-5, 1e-3),
'cls': tune.uniform(0.5, 2.0),
'hsv_s': tune.uniform(0.4, 0.9),
'mixup': tune.uniform(0.0, 0.3),
}
scheduler = ASHAScheduler(
max_t=50,
grace_period=10,
reduction_factor=2
)
analysis = tune.run(
objective,
config=search_space,
num_samples=n_trials,
scheduler=scheduler,
metric="mAP50",
mode="max",
resources_per_trial={"cpu": 4, "gpu": 1},
)
best_config = analysis.best_config
print(f"Migliori hyperparametri trovati:")
for key, value in best_config.items():
print(f" {key}: {value}")
return best_config
Detection vs Classification vs Segmentation per Food QC
YOLO11 supporta tre paradigmi principali: object detection (bounding box), classification (classe dell'immagine intera) e instance segmentation (maschera pixel-level). La scelta dipende dal tipo di difetto, dalla velocità richiesta e dall'hardware disponibile.
Quando Usare Quale Approccio
| Approccio | Output | Latenza Tipica | Use Case Food QC | Pro/Contro |
|---|---|---|---|---|
| Classification | Classe + confidence | 0.5-1.5 ms | Classificazione frutta per categoria/qualità; maturazione | Pro: velocissimo. Contro: nessuna localizzazione difetto |
| Object Detection | Bbox + classe + conf | 1.5-5 ms | Rilevazione difetti multipli su stesso pezzo; corpi estranei | Pro: bilancio ottimale velocità/info. Contro: no forma precisa |
| Segmentation | Maschera pixel + classe | 3-15 ms | Misura area difetto; controllo dimensioni preciso; grading | Pro: info dettagliate. Contro: più lento e complesso |
| Pose Estimation | Keypoints | 2-6 ms | Orientamento pezzo per robot pick-and-place; conteggio | Pro: orientamento preciso. Contro: richiede dataset keypoint |
Per la maggior parte dei sistemi di food quality inspection, l'object detection con YOLO11 e la scelta ottimale: rileva e localizza difetti multipli sullo stesso prodotto, permette di quantificare la severita in base alla dimensione della bounding box, e mantiene latenze compatibili con linee ad alta velocità. La segmentation e giustificata quando si ha bisogno della misura precisa dell'area del difetto per il grading (es. classificare una bruciatura come "lieve" < 5% superficie, "grave" > 15% superficie).
Metriche di qualità: Interpretazione per l'Industria Alimentare
Le metriche standard del CV (mAP, precision, recall) hanno implicazioni specifiche nel contesto alimentare che vanno comprese e comunicare chiaramente ai responsabili di produzione prima di un go-live.
mAP (mean Average Precision)
La mAP sintetizza la curva precision-recall per tutte le classi. mAP@50 usa un threshold IoU (Intersection over Union) di 0.5 per considerare una detection corretta. mAP@50-95 media su threshold da 0.5 a 0.95, ed e più severa. Per il food QC, mAP@50 e tipicamente la metrica primaria perchè l'accuratezza della localizzazione esatta (IoU 0.95) e meno critica dell'accuratezza della classificazione del difetto.
Precision e Recall: Il Trade-off Critico
Nel food quality control, precision e recall hanno asimmetrie di costo fondamentali:
- Bassa Recall (falsi negativi): un prodotto difettoso che supera l'ispezione e arriva al consumatore. Costo: recall di prodotto, danno alla reputazione del brand, potenziale danno alla salute. Inaccettabile per difetti ad alta criticita (muffa, corpi estranei).
- Bassa Precision (falsi positivi): un prodotto conforme viene scartato. Costo: perdita di prodotto, riduzione del rendimento della linea. Accettabile entro limiti dipendenti dal valore del prodotto.
La calibrazione della soglia di confidence e il meccanismo principale per gestire
questo trade-off: abbassare la soglia aumenta la recall (meno falsi negativi) a
scapito della precision (più falsi positivi). Per difetti critici come corpi estranei,
si imposta una soglia molto bassa (0.3-0.4) accettando più falsi positivi.
Per difetti come size_defect, la soglia può essere più alta (0.6-0.7).
# evaluate_model.py
# Valutazione completa del modello con metriche per food QC
from ultralytics import YOLO
import numpy as np
from sklearn.metrics import confusion_matrix
import matplotlib.pyplot as plt
import seaborn as sns
from pathlib import Path
def evaluate_food_model(
model_path: str,
test_dataset_yaml: str,
class_names: list,
confidence_thresholds: dict # Soglie per classe
) -> dict:
"""
Valutazione completa con metriche specifiche per food quality inspection.
Args:
model_path: Path al modello addestrato (best.pt)
test_dataset_yaml: Path al data.yaml del test set
class_names: Nomi delle classi
confidence_thresholds: Soglie confidence per classe {"mold": 0.35, "ok": 0.6}
"""
model = YOLO(model_path)
# Validazione standard YOLO
results = model.val(
data=test_dataset_yaml,
split='test',
imgsz=640,
conf=0.001, # Basso per calcolare la curva completa
iou=0.6,
plots=True,
verbose=False,
)
# Metriche per classe
class_metrics = {}
for i, name in enumerate(class_names):
class_metrics[name] = {
'precision': float(results.box.p[i]) if i < len(results.box.p) else 0,
'recall': float(results.box.r[i]) if i < len(results.box.r) else 0,
'mAP50': float(results.box.ap50[i]) if i < len(results.box.ap50) else 0,
'mAP50_95': float(results.box.ap[i]) if i < len(results.box.ap) else 0,
}
# Soglie di accettabilita per l'industria alimentare
acceptance_thresholds = {
'mold': {'recall_min': 0.98, 'precision_min': 0.85},
'foreign_object': {'recall_min': 0.995, 'precision_min': 0.80},
'rot': {'recall_min': 0.97, 'precision_min': 0.87},
'bruise': {'recall_min': 0.90, 'precision_min': 0.85},
'burn': {'recall_min': 0.88, 'precision_min': 0.83},
'crack': {'recall_min': 0.93, 'precision_min': 0.85},
'insect_damage': {'recall_min': 0.92, 'precision_min': 0.84},
'size_defect': {'recall_min': 0.85, 'precision_min': 0.88},
'color_defect': {'recall_min': 0.85, 'precision_min': 0.88},
'ok': {'recall_min': 0.95, 'precision_min': 0.92},
}
# Verifica accettabilita
go_nogo_results = {}
for class_name, metrics in class_metrics.items():
thresholds = acceptance_thresholds.get(class_name, {})
recall_ok = metrics['recall'] >= thresholds.get('recall_min', 0.0)
precision_ok = metrics['precision'] >= thresholds.get('precision_min', 0.0)
go_nogo_results[class_name] = {
'go': recall_ok and precision_ok,
'recall_ok': recall_ok,
'precision_ok': precision_ok,
'recall': metrics['recall'],
'precision': metrics['precision'],
}
# Report finale
print("\n=== VALUTAZIONE FOOD QUALITY MODEL ===\n")
print(f"mAP@50 globale: {float(results.box.map50):.4f}")
print(f"mAP@50-95 globale: {float(results.box.map):.4f}")
print("\nRisultati per classe:")
print(f"{'Classe':-20} {'Recall':>8} {'Precision':>10} {'mAP50':>8} {'GO/NO-GO':>10}")
print("-" * 60)
for class_name, gng in go_nogo_results.items():
status = "GO ✓" if gng['go'] else "NO-GO ✗"
print(f"{class_name:-20} {gng['recall']:8.4f} {gng['precision']:10.4f} "
f"{class_metrics[class_name]['mAP50']:8.4f} {status:>10}")
overall_go = all(gng['go'] for gng in go_nogo_results.values())
print(f"\nVALUTAZIONE FINALE: {'SISTEMA APPROVATO PER DEPLOY' if overall_go else 'NON APPROVATO - RICHIEDE MIGLIORAMENTI'}")
return {
'class_metrics': class_metrics,
'go_nogo': go_nogo_results,
'overall_go': overall_go,
'global_mAP50': float(results.box.map50),
}
# Esecuzione valutazione
if __name__ == "__main__":
class_names = ['ok', 'mold', 'bruise', 'burn', 'crack',
'foreign_object', 'rot', 'insect_damage', 'size_defect', 'color_defect']
confidence_thresholds = {
'mold': 0.35,
'foreign_object': 0.30,
'rot': 0.40,
'bruise': 0.50,
'burn': 0.50,
'crack': 0.45,
'insect_damage': 0.45,
'size_defect': 0.60,
'color_defect': 0.60,
'ok': 0.60,
}
results = evaluate_food_model(
model_path="./runs/food_quality/food_quality_v1/weights/best.pt",
test_dataset_yaml="/data/food_quality_dataset/data.yaml",
class_names=class_names,
confidence_thresholds=confidence_thresholds
)
Architettura Hardware per Linea di Ispezione Industriale
Un sistema di vision industriale per food quality inspection non e solo software: l'hardware e fondamentale tanto quanto il modello. La catena ottica (obiettivo, sensore, illuminazione) determina la qualità dell'immagine, e un'immagine di scarsa qualità non può essere salvata in post-processing dal miglior modello AI.
Componenti della Linea di Ispezione
Una linea di ispezione completa comprende quattro zone distinte: acquisizione immagine, elaborazione AI, decisione e sorting. L'integrazione tra queste zone avviene tramite segnali hardware (encoder, trigger, PLC) e comunicazione industriale (Ethernet/IP, PROFINET, OPC-UA).
Confronto Hardware per Food Quality Inspection
| Componente | Entry Level | Professionale | High Performance |
|---|---|---|---|
| Camera | USB3 Vision 5MP (Basler ace) | GigE Vision 12MP (Basler ace2) | CoaXPress 25MP (Allied Vision Goldeye) |
| Frame Rate | 30-60 FPS | 100-200 FPS | 300-500 FPS |
| Protezione | IP40 (ufficio) | IP67 (schizzi acqua) | IP69K (getto alta pressione) |
| Illuminazione | LED ring generico | LED bar con controller | LED programmabile + IR strobe |
| GPU Inference | NVIDIA Jetson Orin NX (16GB) | Hailo-8 + CPU industriale | RTX 4080 in PC industriale |
| Latenza inference | 8-15 ms (Jetson) | 2-5 ms (Hailo-8) | 1-3 ms (RTX 4080) |
| Costo sistema | 5.000-15.000 EUR | 20.000-50.000 EUR | 60.000-150.000 EUR |
| Throughput massimo | 3-5 pz/sec | 10-20 pz/sec | 30-60 pz/sec |
GigE Vision: Standard di Comunicazione Industriale
GigE Vision (GenICam) e lo standard industriale per la comunicazione tra camera e sistema di elaborazione via Ethernet Gigabit. I vantaggi rispetto a USB3: lunghezza cavo fino a 100 metri senza extender, supporto PoE (Power over Ethernet), latenza deterministica tramite Precision Time Protocol (PTP), multi-camera su unico switch. La configurazione richiede una NIC dedicata con jumbo frames abilitati (MTU 9000) e CPU affinity per il processo di acquisizione.
Sistema di Trigger e Sincronizzazione
La sincronizzazione tra nastro trasportatore e acquisizione immagine e critica per evitare motion blur e immagini a meta prodotto. Si usa tipicamente un encoder rotante collegato al nastro che genera un impulso ogni N mm di avanzamento. Il PLC (Programmable Logic Controller) riceve l'impulso e genera il segnale di trigger hardware per la camera tramite GPIO. Il trigger hardware e essenziale: il software trigger introduce una jitter di 1-5 ms che su nastri veloci produce disallineamenti inaccettabili.
# camera_gige_acquisition.py
# Acquisizione immagini da camera GigE Vision con trigger hardware
import pypylon.pylon as pylon
import numpy as np
import cv2
import threading
import queue
import time
from dataclasses import dataclass
from typing import Optional
@dataclass
class AcquiredFrame:
"""Frame acquisito dalla camera con metadati."""
image: np.ndarray
timestamp_ns: int
frame_id: int
trigger_counter: int
class GigEVisionCamera:
"""
Wrapper per camera GigE Vision via Basler pylibpylon.
Gestisce trigger hardware, acquisizione e buffer.
"""
def __init__(
self,
device_index: int = 0,
trigger_mode: str = "Line1", # Trigger hardware su Line1
exposure_us: int = 500, # 500 microsec: congela il moto a 2m/s
gain_db: float = 6.0,
buffer_count: int = 10,
) -> None:
self._device_index = device_index
self._trigger_mode = trigger_mode
self._exposure_us = exposure_us
self._gain_db = gain_db
self._buffer_count = buffer_count
self._camera: Optional[pylon.InstantCamera] = None
self._frame_queue: queue.Queue = queue.Queue(maxsize=100)
self._acquiring = False
self._frame_counter = 0
def connect(self) -> None:
"""Connette e configura la camera GigE Vision."""
transport_factory = pylon.TlFactory.GetInstance()
devices = transport_factory.EnumerateDevices()
if len(devices) == 0:
raise RuntimeError("Nessuna camera GigE Vision trovata")
if self._device_index >= len(devices):
raise RuntimeError(f"Camera index {self._device_index} non disponibile")
self._camera = pylon.InstantCamera(
transport_factory.CreateDevice(devices[self._device_index])
)
self._camera.Open()
# Configurazione trigger hardware
self._camera.TriggerMode.SetValue("On")
self._camera.TriggerSource.SetValue(self._trigger_mode)
self._camera.TriggerActivation.SetValue("RisingEdge")
# Configurazione esposizione
self._camera.ExposureTime.SetValue(self._exposure_us)
self._camera.Gain.SetValue(self._gain_db)
# Pixel format: Mono8 per velocità massima, BayerRG8 per colore
self._camera.PixelFormat.SetValue("BayerRG8")
# Buffer pool
self._camera.MaxNumBuffer.SetValue(self._buffer_count)
print(f"Camera connessa: {self._camera.DeviceModelName.GetValue()}")
print(f"Risoluzione: {self._camera.Width.GetValue()}x{self._camera.Height.GetValue()}")
print(f"Frame rate max: {self._camera.AcquisitionFrameRate.GetValue():.1f} FPS")
def start_acquisition(self) -> None:
"""Avvia acquisizione continua in thread separato."""
if self._camera is None:
raise RuntimeError("Camera non connessa")
self._acquiring = True
self._camera.StartGrabbing(pylon.GrabStrategy_LatestImageOnly)
acquisition_thread = threading.Thread(
target=self._acquisition_loop,
daemon=True
)
acquisition_thread.start()
print("Acquisizione avviata in modalità trigger hardware")
def _acquisition_loop(self) -> None:
"""Loop di acquisizione continua."""
while self._acquiring and self._camera.IsGrabbing():
try:
grab_result = self._camera.RetrieveResult(
5000, # Timeout 5 secondi
pylon.TimeoutHandling_ThrowException
)
if grab_result.GrabSucceeded():
# Conversione immagine
converter = pylon.ImageFormatConverter()
converter.OutputPixelFormat = pylon.PixelType_BGR8packed
converted = converter.Convert(grab_result)
image = converted.GetArray()
frame = AcquiredFrame(
image=image.copy(),
timestamp_ns=grab_result.TimeStamp,
frame_id=grab_result.ImageNumber,
trigger_counter=self._frame_counter
)
# Non-blocking: scarta se coda piena (priorità ai frame più recenti)
try:
self._frame_queue.put_nowait(frame)
except queue.Full:
# Scarta frame vecchio, inserisci nuovo
try:
self._frame_queue.get_nowait()
except queue.Empty:
pass
self._frame_queue.put_nowait(frame)
self._frame_counter += 1
grab_result.Release()
except pylon.TimeoutException:
pass # Nessun trigger ricevuto nel timeout, normale
except Exception as e:
print(f"Errore acquisizione: {e}")
def get_frame(self, timeout: float = 1.0) -> Optional[AcquiredFrame]:
"""Recupera il prossimo frame dalla coda."""
try:
return self._frame_queue.get(timeout=timeout)
except queue.Empty:
return None
def stop(self) -> None:
"""Ferma acquisizione e disconnette la camera."""
self._acquiring = False
if self._camera and self._camera.IsGrabbing():
self._camera.StopGrabbing()
if self._camera:
self._camera.Close()
print("Camera disconnessa")
Sistema di Sorting Automatico: Integrazione con PLC e Attuatori
L'output del sistema di vision deve tradursi in un'azione fisica: l'espulsione del prodotto difettoso dalla linea. Questo avviene tramite attuatori pneumatici (ugelli d'aria compressa) o robot pick-and-place, comandati dal PLC sulla base della decisione del sistema AI.
Timing del Sistema di Sorting
Il timing e critico: il sistema deve calcolare, a partire dal momento del trigger (acquisizione immagine), il tempo esatto in cui il prodotto arrivera alla stazione di espulsione, e comandare l'attuatore con precisione al millisecondo. La catena temporale comprende: tempo di acquisizione, tempo di inference AI, tempo di comunicazione al PLC, tempo di risposta dell'attuatore pneumatico (tipicamente 30-80 ms incluso il ritardo di apertura valvola).
# sorting_system.py
# Sistema di sorting integrato con PLC via OPC-UA
import asyncio
import asyncua
from ultralytics import YOLO
import numpy as np
import time
from dataclasses import dataclass
from typing import Optional
from enum import IntEnum
class SortingDecision(IntEnum):
"""Decisioni di sorting basate sulla criticita del difetto."""
ACCEPT = 0 # Prodotto conforme: procede
REJECT_DEFECT = 1 # Difetto standard: canale scarto generico
REJECT_CRITICAL = 2 # Difetto critico (corpo estraneo, muffa): canale separato
REINSPECT = 3 # Bassa confidence: re-ispezione manuale
@dataclass
class InspectionResult:
"""Risultato ispezione con decisione di sorting."""
frame_id: int
timestamp_ms: float
decision: SortingDecision
primary_defect: Optional[str]
confidence: float
defect_count: int
inference_time_ms: float
class FoodInspectionEngine:
"""
Engine principale di ispezione: integra vision AI e logica di sorting.
"""
# Classificazione criticita difetti
CRITICAL_DEFECTS = {'foreign_object', 'mold', 'rot'}
STANDARD_DEFECTS = {'bruise', 'burn', 'crack', 'insect_damage'}
QUALITY_DEFECTS = {'size_defect', 'color_defect'}
# Soglie confidence per classe
CLASS_THRESHOLDS = {
'foreign_object': 0.30,
'mold': 0.35,
'rot': 0.38,
'crack': 0.45,
'bruise': 0.50,
'insect_damage': 0.48,
'burn': 0.50,
'size_defect': 0.60,
'color_defect': 0.60,
'ok': 0.60,
}
def __init__(self, model_path: str) -> None:
self._model = YOLO(model_path)
self._class_names = self._model.names
self._inspection_count = 0
self._defect_count = 0
self._reject_count = 0
def inspect(self, image: np.ndarray, frame_id: int) -> InspectionResult:
"""
Esegue l'ispezione AI su un frame e restituisce la decisione di sorting.
"""
start_time = time.perf_counter()
# Inference YOLO11
results = self._model(
image,
conf=0.25, # Soglia bassa: filtraggio per classe dopo
iou=0.45,
verbose=False,
half=True, # FP16 per velocità
)
inference_time_ms = (time.perf_counter() - start_time) * 1000
# Analisi detections
detections = []
for result in results:
if result.boxes is None:
continue
for box in result.boxes:
class_id = int(box.cls[0])
class_name = self._class_names[class_id]
confidence = float(box.conf[0])
# Applica soglia per classe
class_threshold = self.CLASS_THRESHOLDS.get(class_name, 0.5)
if confidence >= class_threshold and class_name != 'ok':
detections.append({
'class': class_name,
'confidence': confidence,
'bbox': box.xyxy[0].tolist(),
})
# Logica di decisione sorting
decision, primary_defect, max_confidence = self._make_sorting_decision(detections)
self._inspection_count += 1
if decision != SortingDecision.ACCEPT:
self._reject_count += 1
return InspectionResult(
frame_id=frame_id,
timestamp_ms=time.time() * 1000,
decision=decision,
primary_defect=primary_defect,
confidence=max_confidence,
defect_count=len(detections),
inference_time_ms=inference_time_ms,
)
def _make_sorting_decision(
self,
detections: list
) -> tuple[SortingDecision, Optional[str], float]:
"""
Logica di decisione sorting basata sui difetti rilevati.
Priorità: REJECT_CRITICAL > REJECT_DEFECT > REINSPECT > ACCEPT
"""
if not detections:
return SortingDecision.ACCEPT, None, 0.0
# Verifica presenza difetti critici (priorità assoluta)
critical_detections = [
d for d in detections
if d['class'] in self.CRITICAL_DEFECTS
]
if critical_detections:
primary = max(critical_detections, key=lambda x: x['confidence'])
return SortingDecision.REJECT_CRITICAL, primary['class'], primary['confidence']
# Verifica difetti standard
standard_detections = [
d for d in detections
if d['class'] in self.STANDARD_DEFECTS
]
if standard_detections:
primary = max(standard_detections, key=lambda x: x['confidence'])
# Se confidence bassa (0.45-0.55) su difetto standard: reinspect
if primary['confidence'] < 0.55:
return SortingDecision.REINSPECT, primary['class'], primary['confidence']
return SortingDecision.REJECT_DEFECT, primary['class'], primary['confidence']
# Solo difetti qualità
quality_detections = [
d for d in detections
if d['class'] in self.QUALITY_DEFECTS
]
if quality_detections:
primary = max(quality_detections, key=lambda x: x['confidence'])
return SortingDecision.REJECT_DEFECT, primary['class'], primary['confidence']
return SortingDecision.ACCEPT, None, 0.0
def get_statistics(self) -> dict:
"""Statistiche di ispezione in tempo reale."""
reject_rate = self._reject_count / max(self._inspection_count, 1) * 100
return {
'total_inspected': self._inspection_count,
'total_rejected': self._reject_count,
'reject_rate_pct': round(reject_rate, 2),
'throughput': self._inspection_count, # Da normalizzare nel tempo
}
class PLCInterface:
"""
Interfaccia OPC-UA verso PLC Siemens/Beckhoff per controllo attuatori.
"""
# Indirizzi OPC-UA nodi PLC (configurati nel TIA Portal o TwinCAT)
SORT_COMMAND_NODE = "ns=2;s=FoodLine.Sort.Command"
SORT_DELAY_MS_NODE = "ns=2;s=FoodLine.Sort.DelayMs"
CONVEYOR_SPEED_NODE = "ns=2;s=FoodLine.Conveyor.SpeedMps"
INSPECTION_TO_EJECTOR_MM = 450.0 # Distanza fisica stazione ispezione -> espulsore
def __init__(self, plc_url: str = "opc.tcp://192.168.1.100:4840") -> None:
self._plc_url = plc_url
self._client: Optional[asyncua.Client] = None
async def connect(self) -> None:
"""Connette al PLC via OPC-UA."""
self._client = asyncua.Client(url=self._plc_url)
await self._client.connect()
print(f"Connesso al PLC: {self._plc_url}")
async def get_conveyor_speed(self) -> float:
"""Legge velocità nastro in m/s dal PLC."""
node = self._client.get_node(self.CONVEYOR_SPEED_NODE)
return await node.read_value()
async def send_sort_command(
self,
decision: SortingDecision,
conveyor_speed_mps: float
) -> None:
"""
Invia comando di sorting al PLC con delay calcolato.
Il delay compensa il ritardo di trasporto dalla stazione di ispezione
all'attuatore di espulsione.
"""
if decision == SortingDecision.ACCEPT:
return # Nessuna azione necessaria
# Calcola delay: distanza / velocità - anticipo attuatore
transport_delay_ms = (self.INSPECTION_TO_EJECTOR_MM / 1000) / conveyor_speed_mps * 1000
actuator_lead_ms = 60 # Il valvola pneumatica richiede ~60ms per aprirsi
total_delay_ms = max(0, int(transport_delay_ms - actuator_lead_ms))
# Codice comando per PLC
plc_command = 1 if decision in [SortingDecision.REJECT_DEFECT, SortingDecision.REJECT_CRITICAL] else 0
# Scrivi delay e comando
delay_node = self._client.get_node(self.SORT_DELAY_MS_NODE)
command_node = self._client.get_node(self.SORT_COMMAND_NODE)
await delay_node.write_value(total_delay_ms)
await command_node.write_value(plc_command)
async def disconnect(self) -> None:
"""Disconnette dal PLC."""
if self._client:
await self._client.disconnect()
Deploy su Edge: Ottimizzazione per Hardware Industriale
Il modello addestrato deve essere ottimizzato per il deploy sull'hardware target. YOLO11 supporta molteplici formati di export che possono ridurre la latenza del 30-70% rispetto al modello PyTorch nativo.
# deploy_optimization.py
# Export e ottimizzazione modello per deploy industriale
from ultralytics import YOLO
import torch
import time
import numpy as np
def export_optimized_model(
model_path: str,
target_hardware: str = "tensorrt", # tensorrt, openvino, onnx, hailo
image_size: int = 640,
batch_size: int = 1,
) -> str:
"""
Esporta il modello YOLO11 nel formato ottimizzato per l'hardware target.
Formati supportati per applicazioni industriali:
- TensorRT (NVIDIA GPU): massima velocità su CUDA hardware
- OpenVINO (Intel CPU/iGPU): ottimale per sistemi embedded Intel
- ONNX (universale): compatibile con ONNX Runtime su qualsiasi hardware
- Hailo: formato proprietario per chip Hailo-8/Hailo-15
"""
model = YOLO(model_path)
export_args = {
'format': target_hardware,
'imgsz': image_size,
'batch': batch_size,
'half': True, # FP16: dimezza memoria, +30% velocità
'int8': False, # INT8 richiede calibration dataset
'simplify': True, # Semplifica grafo ONNX
'dynamic': False, # Batch size fisso per latenza deterministica
'verbose': False,
}
if target_hardware == "tensorrt":
export_args.update({
'workspace': 4, # GB di workspace TensorRT
'device': '0', # GPU 0
})
exported_path = model.export(**export_args)
print(f"Modello esportato: {exported_path}")
return str(exported_path)
def benchmark_inference(model_path: str, n_iterations: int = 1000) -> dict:
"""
Benchmark di latenza per confronto tra formati export.
"""
model = YOLO(model_path)
# Immagine di test casuale (simula frame camera)
dummy_image = np.random.randint(0, 255, (640, 640, 3), dtype=np.uint8)
# Warmup
for _ in range(50):
model(dummy_image, verbose=False)
# Benchmark
latencies = []
for _ in range(n_iterations):
start = time.perf_counter()
model(dummy_image, verbose=False, half=True)
latencies.append((time.perf_counter() - start) * 1000)
latencies = np.array(latencies)
results = {
'mean_ms': float(np.mean(latencies)),
'median_ms': float(np.median(latencies)),
'p95_ms': float(np.percentile(latencies, 95)),
'p99_ms': float(np.percentile(latencies, 99)),
'max_fps': round(1000 / np.mean(latencies), 1),
}
print(f"\nBenchmark {n_iterations} iterazioni:")
print(f" Latenza media: {results['mean_ms']:.2f} ms")
print(f" Latenza P95: {results['p95_ms']:.2f} ms")
print(f" Latenza P99: {results['p99_ms']:.2f} ms")
print(f" FPS massimo: {results['max_fps']:.1f} FPS")
return results
# Esempio: confronto latenze per hardware diversi
# Risultati tipici su linea produttiva reale:
# PyTorch FP32: 12.4 ms -> 80 FPS
# PyTorch FP16: 7.1 ms -> 140 FPS
# TensorRT FP16: 2.8 ms -> 357 FPS
# TensorRT INT8: 1.9 ms -> 526 FPS
# ONNX Runtime: 5.2 ms -> 192 FPS
# OpenVINO: 3.8 ms -> 263 FPS
Case Study: Linea di Sorting Frutta con YOLO11
Riportiamo un'implementazione reale di un sistema di vision per una cooperativa frutticola del Sud Italia, linea di confezionamento mele e arance con capacità di 10 pezzi al secondo per canale, 3 canali paralleli, operativa 24 ore al giorno durante la stagione di raccolta (ottobre-gennaio).
Specifiche del Sistema
- Prodotti: Mele (Fuji, Gala, Golden), arance (Tarocco, Navel)
- Linea: 3 canali da 10 pz/sec ciascuno = 30 pz/sec totali
- Camera: Basler ace2 GigE, 12MP, 200 FPS, IP67, illuminazione LED coassiale
- Hardware inference: NVIDIA Jetson AGX Orin 64GB (1 per canale)
- Attuatori: 3 ugelli pneumatici a 6 bar per canale (crit/defect/reinspect)
- PLC: Siemens S7-1500 con comunicazione OPC-UA
- Classi rilevate: ok, mold, bruise, burn, size_defect, foreign_object
Dataset e Training
- Dataset: 28.400 immagini totali raccolte in 3 stagioni di raccolta (2022-2024)
- Annotazione: CVAT con 4 annotatori, consensus voting per classi ambigue
- Augmentation: pipeline albumentations custom con simulazione condizioni meteo
- Modello: YOLO11m addestrato per 120 epoche su AWS p3.2xlarge (Tesla V100)
- Training time: 4.7 ore
Risultati in Produzione
Metriche Sistema in Produzione (Media Stagione 2024-2025)
| Metrica | Obiettivo | Risultato | Valutazione |
|---|---|---|---|
| mAP@50 globale | > 90% | 93.7% | Eccellente |
| Recall mold | > 98% | 98.4% | Approvato |
| Recall foreign_object | > 99.5% | 99.6% | Approvato |
| Recall bruise | > 90% | 91.8% | Approvato |
| Precision ok | > 92% | 94.2% | Eccellente |
| Latenza inference | < 8 ms | 6.3 ms (Jetson AGX) | Approvato |
| Latenza sistema totale | < 80 ms | 71 ms | Approvato |
| Throughput per canale | 10 pz/sec | 10.0 pz/sec | Raggiunto |
| Tasso scarto falsi positivi | < 3% | 2.1% | Eccellente |
| Uptime sistema | > 99% | 99.7% | Eccellente |
ROI e Impatto Economico
Il sistema ha sostituito 6 ispettori manuali (2 per turno x 3 turni) con un costo di installazione di 145.000 EUR (hardware, software, integrazione, formazione, commissioning). I benefici economici calcolati:
- Riduzione personale ispettivo: 180.000 EUR/anno (lordi inclusi oneri)
- Riduzione non-conformità sfuggite: 45.000 EUR/anno (recall evitati, sanzioni)
- Riduzione falsi positivi vs ispezione manuale: 28.000 EUR/anno (prodotto recuperato)
- ROI payback: 8.5 mesi
- ROI a 3 anni: 478%
Normative e Certificazioni per Sistemi Vision in Ambienti Alimentari
Un sistema di vision industriale installato in una linea alimentare certificata deve rispettare requisiti normativi specifici che vanno oltre la sola performance AI. Il non rispetto di questi requisiti può compromettere le certificazioni dello stabilimento.
Requisiti Fisici: IP69K e Materiali Food-Grade
Le camere industriali per ambienti alimentari devono essere rated IP69K: protezione completa contro la penetrazione di polvere (6) e contro getti d'acqua ad alta pressione (9K) - le pulizie con idropulitrici a 80 gradi, 100 bar sono standard in molti stabilimenti. I materiali a contatto con il prodotto alimentare (cover, montaggi, supporti) devono essere in acciaio AISI 316L o materiali certificati FDA/EC 10/2011 per i polimeri.
I cavi e le connessioni devono essere certificati per ambienti food (copertura TPU resistente agli acidi dei detergenti, connettori M12 IP67+). I PC industriali di elaborazione sono alloggiati in armadi stagnatori IP65 lontani dalla linea, con uscita video/segnale verso la camera via cavo GigE schermato.
IFS Food e BRC: Requisiti per Sistemi di Ispezione Automatica
Lo standard IFS Food 8 (International Featured Standards) e BRC Global Standard Food Safety Issue 9 richiedono che i sistemi di ispezione automatica siano:
- Documentati con specifiche di detection (classi, soglie, prodotti coperti)
- Calibrati periodicamente con campioni di riferimento noti (challenge test)
- Sottoposti a validazione iniziale con protocollo documentato (OQ/PQ)
- Integrati nel piano HACCP come CCP o OPRP, a seconda del rischio
- Dotati di sistema di allarme per malfunzionamento (se il sistema AI e offline, la linea si ferma)
- Soggetti a manutenzione preventiva documentata (pulizia lenti, calibrazione camera)
HACCP: Il Sistema Vision come CCP
Per la rilevazione di corpi estranei (metallo, plastica, vetro, pietra), il sistema di vision può qualificarsi come Critical Control Point (CCP) nel piano HACCP, sostituendo o affiancando il metal detector tradizionale. Questo richiede una validazione scientifica che dimostri la capacità del sistema di rilevare il tipo di corpo estraneo di dimensione minima stabilità come critica (tipicamente 2-3 mm per metalli, 5-10 mm per plastica).
Attenzione: Limitazioni del Sistema Vision per Corpi Estranei
Il sistema di vision rileva solo corpi estranei visibili in superficie. Corpi estranei inglobati all'interno del prodotto (es. metallo dentro un pomodoro) non sono visibili alla camera ottica. Per la rilevazione di corpi estranei interni, il metal detector a induzione o il sistema a raggi X rimangono obbligatori e complementari al sistema vision.
Validazione del Sistema: OQ e PQ
# validation_protocol.py
# Protocollo di validazione OQ/PQ per sistema vision alimentare
import json
import datetime
from dataclasses import dataclass, field, asdict
from typing import Optional
from ultralytics import YOLO
import numpy as np
@dataclass
class ChallengeTestResult:
"""Risultato di un singolo challenge test."""
defect_class: str
defect_severity: str # 'mild', 'moderate', 'severe'
n_samples: int
detected_correctly: int
false_positives_on_ok: int
recall: float
precision: float
pass_fail: str
@dataclass
class ValidationReport:
"""Report di validazione OQ/PQ del sistema vision."""
system_id: str
product_type: str
validation_date: str
model_version: str
hardware_config: dict
challenge_results: list[ChallengeTestResult] = field(default_factory=list)
overall_result: str = "PENDING"
validated_by: str = ""
notes: str = ""
def to_json(self, output_path: str) -> None:
"""Esporta il report in formato JSON per documentazione normativa."""
report_dict = asdict(self)
with open(output_path, 'w', encoding='utf-8') as f:
json.dump(report_dict, f, indent=2, ensure_ascii=False)
print(f"Report salvato: {output_path}")
def run_challenge_test(
model: YOLO,
challenge_images_dir: str,
defect_class: str,
severity: str,
n_ok_images: int = 50,
acceptance_recall: float = 0.95,
acceptance_precision: float = 0.85,
) -> ChallengeTestResult:
"""
Esegue un challenge test su campioni noti.
I challenge samples sono immagini acquisite dalla linea reale,
etichettate da ispettori esperti, con difetti di gravita nota.
Sono conservati fisicamente (campioni di riferimento) e fotografati
in condizioni di linea controllate.
"""
import glob
import os
# Immagini con difetto della classe specificata
defect_images = glob.glob(
os.path.join(challenge_images_dir, defect_class, severity, "*.jpg")
)
# Immagini ok di riferimento
ok_images = glob.glob(
os.path.join(challenge_images_dir, "ok", "*.jpg")
)[:n_ok_images]
detected = 0
total_defect = len(defect_images)
for img_path in defect_images:
result = model(img_path, verbose=False)
detections = [
r for r in result[0].boxes
if model.names[int(r.cls[0])] == defect_class
and float(r.conf[0]) >= 0.35 # Soglia del sistema
]
if len(detections) > 0:
detected += 1
# Falsi positivi su immagini ok
fp_count = 0
for img_path in ok_images:
result = model(img_path, verbose=False)
fp_detections = [
r for r in result[0].boxes
if model.names[int(r.cls[0])] == defect_class
and float(r.conf[0]) >= 0.35
]
if len(fp_detections) > 0:
fp_count += 1
recall = detected / max(total_defect, 1)
precision = detected / max(detected + fp_count, 1)
passed = recall >= acceptance_recall and precision >= acceptance_precision
return ChallengeTestResult(
defect_class=defect_class,
defect_severity=severity,
n_samples=total_defect,
detected_correctly=detected,
false_positives_on_ok=fp_count,
recall=round(recall, 4),
precision=round(precision, 4),
pass_fail="PASS" if passed else "FAIL"
)
Monitoring e Feedback Loop in Produzione
Un sistema di vision AI in produzione non e un artefatto statico: le condizioni della linea cambiano (stagionalita del prodotto, usura illuminazione, sporco sulle lenti), e le prestazioni del modello devono essere monitorate continuamente per rilevare il model drift prima che impatti sulla qualità.
# production_monitor.py
# Monitoring continuo del sistema vision in produzione
import sqlite3
import time
import json
from collections import deque
from typing import Optional
from dataclasses import dataclass
@dataclass
class ProductionStats:
"""Statistiche di produzione per monitoraggio."""
timestamp: str
window_minutes: int
total_inspected: int
reject_rate_pct: float
defect_distribution: dict
avg_inference_ms: float
p99_inference_ms: float
alert_triggered: bool
alert_reason: Optional[str]
class ProductionMonitor:
"""
Monitor continuo per sistema vision alimentare.
Rileva anomalie statistiche che indicano degradazione del modello.
"""
# Soglie di alert
MAX_REJECT_RATE_PCT = 15.0 # >15% scarto e anomalo
MIN_REJECT_RATE_PCT = 0.1 # <0.1% potrebbe indicare modello non funzionante
MAX_INFERENCE_P99_MS = 15.0 # Latenza P99 non deve superare 15ms
MAX_CONSECUTIVE_ACCEPTS = 500 # 500 ok di fila = modello probabilmente bloccato
def __init__(self, db_path: str = "/data/production_log.db") -> None:
self._db_path = db_path
self._inspection_buffer: deque = deque(maxlen=10000)
self._consecutive_accepts = 0
self._init_db()
def _init_db(self) -> None:
"""Inizializza database SQLite per log produzioni."""
with sqlite3.connect(self._db_path) as conn:
conn.execute("""
CREATE TABLE IF NOT EXISTS inspections (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp REAL,
frame_id INTEGER,
decision INTEGER,
primary_defect TEXT,
confidence REAL,
inference_ms REAL,
line_speed_mps REAL
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS alerts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp REAL,
alert_type TEXT,
details TEXT,
acknowledged INTEGER DEFAULT 0
)
""")
def log_inspection(self, result: dict) -> Optional[str]:
"""
Logga un'ispezione e verifica anomalie.
Ritorna il motivo dell'alert se presente, None altrimenti.
"""
self._inspection_buffer.append(result)
# Tracking accept consecutivi
if result['decision'] == 0: # ACCEPT
self._consecutive_accepts += 1
else:
self._consecutive_accepts = 0
# Alert: troppi accept consecutivi (modello potrebbe essere bloccato)
if self._consecutive_accepts >= self.MAX_CONSECUTIVE_ACCEPTS:
alert_msg = (
f"Alert: {self._consecutive_accepts} accept consecutivi. "
f"Verificare funzionamento sistema vision."
)
self._log_alert("CONSECUTIVE_ACCEPTS", alert_msg)
self._consecutive_accepts = 0 # Reset dopo alert
return alert_msg
# Controlla reject rate su finestra recente (ultimi 100 pezzi)
if len(self._inspection_buffer) >= 100:
recent = list(self._inspection_buffer)[-100:]
reject_count = sum(1 for r in recent if r['decision'] != 0)
reject_rate = reject_count / len(recent) * 100
if reject_rate > self.MAX_REJECT_RATE_PCT:
alert_msg = f"Alert: tasso scarto {reject_rate:.1f}% (soglia: {self.MAX_REJECT_RATE_PCT}%)"
self._log_alert("HIGH_REJECT_RATE", alert_msg)
return alert_msg
# Controlla latenza
if result.get('inference_ms', 0) > self.MAX_INFERENCE_P99_MS:
alert_msg = f"Alert: latenza inference {result['inference_ms']:.1f}ms supera soglia"
self._log_alert("HIGH_LATENCY", alert_msg)
return alert_msg
return None
def _log_alert(self, alert_type: str, details: str) -> None:
"""Logga alert nel database."""
with sqlite3.connect(self._db_path) as conn:
conn.execute(
"INSERT INTO alerts (timestamp, alert_type, details) VALUES (?, ?, ?)",
(time.time(), alert_type, details)
)
print(f"[ALERT] {alert_type}: {details}")
def get_hourly_report(self) -> dict:
"""Genera report orario delle prestazioni di produzione."""
recent = list(self._inspection_buffer)
if not recent:
return {}
total = len(recent)
rejects = sum(1 for r in recent if r['decision'] != 0)
latencies = [r.get('inference_ms', 0) for r in recent]
defect_dist = {}
for r in recent:
defect = r.get('primary_defect') or 'ok'
defect_dist[defect] = defect_dist.get(defect, 0) + 1
return {
'total_inspected': total,
'reject_rate_pct': round(rejects / total * 100, 2),
'defect_distribution': defect_dist,
'avg_inference_ms': round(sum(latencies) / len(latencies), 2),
'p99_inference_ms': round(sorted(latencies)[int(len(latencies) * 0.99)], 2),
}
Best Practices e Anti-Pattern
Best Practices per Food Vision System
- Illuminazione prima del modello: Investi il 30% del budget hardware in illuminazione strutturata di qualità. Un dataset acquisito con illuminazione stabile e consistente riduce del 40% il numero di campioni necessari per raggiungere le stesse prestazioni.
- Campioni negativi abbondanti: Il dataset deve includere almeno 3x più immagini "ok" che difetti, e le immagini ok devono coprire tutta la variabilità naturale del prodotto (diverse eta, dimensioni, cultivar).
- Challenge test mensile: Esegui un challenge test formale ogni mese con campioni fisici noti per rilevare model drift dovuto a cambi stagionali del prodotto.
- Timeout e fallback: Se il sistema AI impiega più di 2x la latenza nominale, considera l'immagine non valida e manda il prodotto al canale di reinspect manuale.
- Log ogni frame: Salva l'immagine e il risultato di ogni ispezione per almeno 72 ore. E essenziale per il debug post-incident e per la raccolta di nuovi campioni di training.
- Ridondanza hardware: Per linee critiche, installa una camera di backup configurata identicamente alla primaria con switch automatico in caso di guasto.
Anti-Pattern da Evitare
- Training con immagini da smartphone: Le immagini acquisite con uno smartphone in un laboratorio non rappresentano le condizioni reali di linea. Usa sempre la camera industriale finale per raccogliere il dataset.
- Confidence threshold unica per tutte le classi: Una soglia uniforme privilegia le classi più rappresentate. Usa soglie per classe, calibrate in base alla criticita del difetto.
- Deploy senza periodo di shadow mode: Prima di dare controllo all'AI, fai girare il sistema in parallelo all'ispezione manuale per almeno 2 settimane, confrontando le decisioni. Correggi i falsi positivi eccessivi prima del go-live.
- Ignorare il model drift: Le prestazioni del modello degradano nel tempo per variazioni del prodotto, usura dell'illuminazione, sporco sulla lente. Senza monitoraggio attivo, il degrado e silenzioso e pericoloso.
- Nessun piano di disaster recovery: Se il sistema AI si guasta, deve esistere un piano di fallback (ispezione manuale, rallentamento linea) documentato e testato periodicamente.
Conclusioni e Prossimi Passi
La computer vision con YOLO11 rappresenta oggi la tecnologia più matura e accessibile per il controllo qualità automatico nell'industria alimentare. Non e più una tecnologia da laboratorio: sistemi come quello descritto nel case study sono operativi in centinaia di stabilimenti globali, con risultati documentati di accuratezza superiore al 98%, ROI sotto i 12 mesi e uptime del 99%+.
Il mercato AI per la sicurezza e la qualità alimentare crescera da 2.7 a 13.7 miliardi di dollari entro il 2030 (CAGR 30.9%), trainato da requisiti normativi sempre più stringenti, carenza di manodopera specializzata e aumento dei costi dei recall. Le aziende alimentari che investono oggi in sistemi di vision AI si posizionano con un vantaggio competitivo strutturale difficile da colmare in seguito.
Il percorso tecnico descritto in questo articolo, dalla raccolta del dataset all'annotazione con CVAT/Roboflow, dal training YOLO11 alla validazione con challenge test, fino al deploy con integrazione PLC e monitoring continuo, e applicabile a qualsiasi linea produttiva alimentare con gli adattamenti necessari al prodotto specifico.
Checklist per Iniziare il Tuo Progetto di Food Vision
- Definisci le classi di difetti prioritari per il tuo prodotto (max 10 inizialmente)
- Acquisisci almeno 200 campioni per classe con la camera finale in condizioni di linea reali
- Scegli lo strumento di annotazione (CVAT self-hosted per privacy, Roboflow per velocità)
- Inizia con YOLO11m su dataset limitato per validare l'approccio (2-3 giorni di lavoro)
- Definisci le soglie di accettabilita (recall/precision) con il responsabile qualità
- Pianifica il periodo di shadow mode prima del go-live
- Documenta tutto per i requisiti IFS/BRC/HACCP sin dall'inizio
Prosegui nella Serie FoodTech
Questo articolo fa parte della serie FoodTech su federicocalo.dev. Il prossimo articolo approfondisce la compliance normativa digitale: FSMA e Compliance Digitale: Automazione dei Processi Normativi, dove vedremo come automatizzare i flussi documentali HACCP, gestire i piani di controllo con strumenti digitali e prepararsi agli audit FDA/IFS/BRC con sistemi di traceability integrati.
Articoli correlati da altre serie:
- Serie MLOps: Come mettere in produzione e monitorare i modelli YOLO con MLflow e Evidently
- Serie Computer Vision: CNN avanzate, segmentazione semantica e edge deployment con TensorRT
- Serie AI Engineering: Pipeline di inferenza scalabili con Triton Inference Server
- Serie Deep Learning Avanzato: Tecniche di transfer learning e domain adaptation per dataset piccoli

Commenti
Caricamento commenti...
Accedi per commentare