1. Il problema: dall’acquisizione RAW alla qualità diagnostica
L’efficacia del pre-processing immagini medicali determina la precisione dell’intera catena diagnostica, ma l’avvento dell’AI classica ha introdotto rischi concreti di allucinazioni visive.
Un’immagine radiografica digitale è il risultato di una catena fisica e computazionale complessa. Come descritto nell’articolo di D/Vision Lab “Dalla Fisica alla Diagnosi: Come strutturare una Pipeline di Pre-Processing nell’Imaging Medicale”, il segnale nasce da interazioni subatomiche, effetto fotoelettrico e scattering Compton, e viene convertito in una matrice digitale a 14 o 16 bit attraverso rivelatori a pannello piatto (flat panel detector). Il risultato grezzo, tipicamente un file TIFF a 16 bit, raramente è pronto per l’interpretazione clinica.
L’immagine RAW porta con sé i segni di tutta questa catena fisica, rendendo obbligatorio un protocollo di pre-processing immagini medicali mirato ad abbattere il rumore elettronico e quantistico: rumore elettronico e quantistico, pixel difettosi, non uniformità del sensore (fixed pattern noise), artefatti da beam hardening, sfocatura da dispersione. Prima di poter essere interpretata da un radiologo, o da un algoritmo di AI, deve attraversare una pipeline di preprocessing: una sequenza di operazioni che comprendono denoising, sharpening, correzione del contrasto e aggiustamento tonale.
Il problema è che non esiste una pipeline universale.
Ogni modalità (raggi X, tomografia, microscopia digitale), ogni costruttore di scanner, ogni protocollo clinico produce immagini con caratteristiche di rumore, contrasto e risoluzione proprie. Il processo di calibrazione è tradizionalmente manuale: un esperto sperimenta combinazioni di filtri, valuta visivamente il risultato, aggiusta parametri, talvolta per giorni.
La domanda chiave: dato un dataset di coppie immagine-grezza / immagine-di-riferimento elaborata da un esperto, è possibile apprendere automaticamente la pipeline ottimale? E farlo in modo che si generalizzi a immagini nuove, non viste durante l’ottimizzazione?
La risposta è sì, ma richiede di affrontare sfide tecniche precise. In questo articolo esploriamo come gli algoritmi genetici offrano una soluzione robusta e interpretabile, analizzando ogni difficoltà e le strategie per risolverla.
2. Perché non usare l’Intelligenza Artificiale classica?
Prima di rispondere “usiamo un modello deep learning”, vale la pena chiedersi: dovremmo? Nel contesto del preprocessing di immagini medicali, la risposta non è affatto scontata, e la letteratura scientifica recente solleva preoccupazioni concrete.
2.1 Il problema delle allucinazioni nell’imaging medicale
Il rischio più grave nell’affidare il pre-processing immagini medicali a modelli neurali deep learning, è quello delle allucinazioni visive: strutture inventate, lesioni non esistenti, dettagli anatomici falsi che vengono generati dal modello ma non sono presenti nell’immagine reale del paziente.
Un paper pubblicato su PNAS nel 2020 da Antun et al. “On instabilities of deep learning in image reconstruction and the potential costs of AI”, ha dimostrato matematicamente che le instabilità nei metodi di ricostruzione basati su deep learning non sono eventi rari:
“Perturbazioni minuscole e quasi impercettibili nelle misure grezze possono produrre una miriade di artefatti diversi nell’immagine ricostruita.” — Antun et al., PNAS 2020
Il paper documenta che piccoli cambiamenti nell’input (livello di rumore, calibrazione del sensore, variazione di posizione del paziente) possono produrre artefatti drastici nell’output, false strutture che in un contesto clinico potrebbero portare a diagnosi errate.
Il DREAM Report (Journal of Nuclear Medicine, 2025), dedicato alle allucinazioni nell’AI per la Nuclear Medicine Imaging, classifica due tipologie di allucinazione:
- Additiva: aggiunta di strutture false (lesioni, noduli, tessuto) che non esistono nel paziente
- Sottrattiva: rimozione di strutture reali (ad esempio microlesioni o piccole emorragie)
Queste allucinazioni possono portare a errori a cascata: diagnosi errate, trattamenti sbagliati, interventi non necessari, errori farmacologici e problemi etici e legali.
2.2 Il problema della black box e della regolamentazione
Un secondo ostacolo è l’interpretabilità. Una rete neurale che applica 30 layer di convoluzione a un’immagine TIFF è, per definizione, una black box: non si riesce a spiegare perché ha modificato un certo valore di pixel. In ambito medicale, questo crea problemi regolatori seri:
- La FDA richiede documentazione precisa del funzionamento dei software come dispositivi medici (SaMD)
- I medici responsabili della diagnosi devono poter giustificare il preprocessing applicato all’immagine
- Le certificazioni ISO 13485 e IEC 62304 richiedono tracciabilità completa delle trasformazioni
Le domande pre-mercato per software basati su AI/ML per la ricostruzione di immagini sono aumentate del 25% annuo dal 2018, ma il rischio di comportamenti allucinatori è un ostacolo concreto alla loro adozione sicura in clinica.
2.3 Domain shift: il modello che funziona solo su un scanner
Il terzo problema è la sensibilità al domain shift: un modello addestrato su immagini di uno scanner specifico degrada spesso le prestazioni su un altro, anche dello stesso costruttore, se il firmware è aggiornato o il protocollo di acquisizione cambia.
La dipendenza da infrastrutture specializzate, la mancanza di trasparenza dei modelli “black-box” e la sensibilità al domain shift tra scanner, istituzioni e popolazioni di pazienti sono i tre ostacoli principali che limitano la traslazione clinica reale dei sistemi AI.
2.4 Perché i GA sono diversi
L’approccio basato su algoritmi genetici risolve questi tre problemi strutturalmente:
| Problema | Deep Learning | Algoritmo Genetico | |---|---|---| | Allucinazioni | Alto rischio: inventa strutture | Impossibile: applica solo filtri classici deterministici | | Interpretabilità | Black box | Pipeline leggibile step-by-step | | Domain shift | Riaddestramento completo | Ricalibrazione automatica sul nuovo dataset | | Riproducibilità | Dipende dal random seed, batch, hardware | Deterministica a parità di genoma | | Regolamentazione | Complessa (modello a parametri) | Semplice (sequenza di trasformazioni note) |
Il risultato di un GA è sempre una sequenza leggibile del tipo:
Bilateral(d=4, sigmaColor=85, sigmaSpace=70)
→ CLAHE(clip=3.5, grid=8)
→ USM(sigma=1.2, amount=0.8, threshold=0.001)
→ Gamma(gamma=0.85)
Ogni passaggio è auditabile, documentabile, e riproducibile su qualunque macchina.
3. Perché questo approccio è rilevante in ambito medicale
3.1 Autonomia dal costruttore dello scanner
Ogni costruttore di apparecchiature medicali (Siemens Healthineers, GE Healthcare, Philips, Fujifilm, ecc.) applica il proprio algoritmo proprietario di preprocessing alle immagini DICOM o TIFF prima di consegnarle al sistema di archiviazione (PACS). Questi algoritmi sono non documentati, non modificabili, e soggetti a cambio senza preavviso a ogni aggiornamento firmware.
Questo significa che:
- Il radiologo vede immagini con un look che non controlla
- Un cambio di scanner modifica la “firma visiva” delle immagini, invalidando confronti longitudinali sui pazienti
- La struttura sanitaria è vincolata al fornitore per qualsiasi personalizzazione
Con una pipeline proprietaria ottimizzata tramite GA, partendo dalla RAW grezza acquisita dal pannello detector prima di qualsiasi elaborazione del costruttore, la struttura diventa completamente autonoma nella catena di preprocessing.
3.2 Risparmio sui costi di licenza
Le soluzioni commerciali di image processing per diagnostica, come i moduli di enhancement integrati nei workstation di refertazione, hanno costi di licenza annuali nell’ordine delle decine di migliaia di euro per installazione. Per reti ospedaliere con decine di workstation, si tratta di costi significativi.
Un sistema basato su GA utilizza esclusivamente librerie open source (OpenCV, NumPy, PyGAD, scikit-image) e può essere mantenuto internamente. Il costo di sviluppo iniziale viene ammortizzato rapidamente, e la pipeline risultante è un asset proprietario della struttura.
3.3 Qualità comparabile o superiore al preprocessing OEM
Il preprocessing factory dell’OEM è calibrato su un dataset generico durante la fase di sviluppo del prodotto.
Il GA invece si ottimizza sul dataset specifico della struttura, immagini dei propri pazienti, con il proprio scanner, in accordo con le preferenze dei propri radiologi (le immagini di riferimento). Il risultato è una pipeline adattata al contesto specifico, che può essere ricalibrata ogni volta che cambia il workflow clinico, il protocollo di esposizione, o la tipologia di esame.
4. Gli Algoritmi Genetici: come funzionano
4.1 L’idea fondamentale
Gli algoritmi genetici (GA) appartengono alla famiglia degli algoritmi evolutivi, ispirati alla selezione naturale darwiniana. L’intuizione centrale è: se una funzione obiettivo è troppo complessa per essere ottimizzata analiticamente, si può fare evolvere una popolazione di soluzioni candidate, selezionando e ricombinando le migliori a ogni generazione.
4.2 Il ciclo evolutivo passo per passo
Generazione 0
┌─────────────────────────────────┐
│ INIZIALIZZAZIONE │
│ Popolazione casuale di N │
│ genomi (soluzioni candidate) │
└──────────────┬──────────────────┘
↓
┌─────────────────────────────────┐
│ VALUTAZIONE (FITNESS) │
│ Applica ogni pipeline alle │
│ immagini, misura la qualità │
└──────────────┬──────────────────┘
↓
┌─────────────────────────────────┐
│ SELEZIONE │
│ Scegli i genitori migliori │
│ (tournament selection, K=5) │
└──────────────┬──────────────────┘
↓
┌─────────────────────────────────┐
│ CROSSOVER (p=0.85) │
│ Ricombina genomi dei genitori │
│ → nuovi figli │
└──────────────┬──────────────────┘
↓
┌─────────────────────────────────┐
│ MUTAZIONE (adattiva 25%→8%) │
│ Modifica casuale di alcuni geni │
│ → esplorazione │
└──────────────┬──────────────────┘
↓
┌─────────────────────────────────┐
│ ELITISMO │
│ Preserva i 3 migliori invariati │
└──────────────┬──────────────────┘
↓
Generazione N+1 → ricomincia
5. Il progetto: il genoma e i filtri
5.1 Struttura del genoma
Ogni individuo della popolazione è rappresentato da un array di 50 interi (10 slot × 5 geni per slot):
# Struttura del genoma
# Per ogni slot i (i=0..9):
# genome[i*5 + 0] = op_id → quale filtro applicare
# genome[i*5 + 1] = p1 → parametro 1 (0-100)
# genome[i*5 + 2] = p2 → parametro 2 (0-100)
# genome[i*5 + 3] = p3 → parametro 3 (0-100)
# genome[i*5 + 4] = p4 → parametro 4 (0-100)
# Esempio di genoma decodificato:
# [2, 15, 0, 0, 0, → Gaussian(sigma=1.6)
# 3, 40, 60, 50, 0, → Bilateral(d=4, sigmaColor=100, sigmaSpace=85)
# 13, 30, 40, 0, 0, → CLAHE(clip=16, grid=6)
# 9, 20, 35, 10, 0, → USM(sigma=1.5, amount=1.75, thresh=0.005)
# 14, 35, 0, 0, 0, → Gamma(gamma=0.975)
# 0, 0, 0, 0, 0, → Pass (slot inattivo)
# ...]
In pratica ogni individuo è una “ricetta” numerica: una sequenza ordinata di fino a 10 operazioni, dove il primo gene di ogni slot identifica quale filtro applicare e i quattro geni successivi ne fissano i parametri. L’op_id 0 (“Pass”) permette a uno slot di restare inattivo, cosa che consente al GA di scoprire da solo pipeline più corte di 10 step, non tutte le immagini hanno bisogno della stessa profondità di elaborazione, e forzare sempre 10 operazioni attive produrrebbe spesso over-processing.
Lo gene_space di PyGAD definisce i valori leciti per ogni gene:
VALID_OP_IDS = sorted(OP_MAP.keys()) # [0, 1, 2, 3, 4, 9, 10, 11, 13, 14, 15, 21, 23, 25, 26]
self.gene_space = []
for _ in range(10): # 10 slot
self.gene_space.append(VALID_OP_IDS) # gene op_id: lista discreta
for _ in range(4): # 4 parametri per slot
self.gene_space.append({"low": 0, "high": 101}) # range intero 0-100
Il vincolo è semplice ma importante: il gene op_id può assumere solo i valori discreti presenti nel catalogo (non un intero qualsiasi), mentre i quattro parametri restano liberi tra 0 e 100. È questa combinazione di un gene discreto e categorico seguito da quattro geni numerici continui che rende il genoma flessibile a sufficienza da rappresentare qualunque pipeline ragionevole, senza però permettere al crossover o alla mutazione di generare operazioni inesistenti.
5.2 Il catalogo dei filtri disponibili
OP_MAP = {
0: "Pass", # slot inattivo — pipeline più corta di 10 step
# --- Denoising & Smoothing ---
1: "Median", # rumore impulsivo, hot pixel
2: "Gaussian", # rumore gaussiano generico
3: "Bilateral", # edge-preserving, conserva i bordi diagnostici
4: "NLM", # Non-Local Means, sfrutta ridondanza globale
23: "EdgePreserve", # alternativa rapida al bilaterale
# --- Sharpening & Detail ---
9: "USM", # Unsharp Masking classico
10: "Laplace", # derivata seconda, evidenzia transizioni
11: "DoG", # Difference of Gaussians
21: "HighPass", # dettagli fini ad alta frequenza
# --- Tone & Contrast ---
13: "Clahe", # equalizzazione adattativa locale
14: "Gamma", # correzione curva tonale
15: "AlphaBeta", # trasformazione lineare (scala + offset)
25: "Sigmoid", # curva S: comprime alte luci, apre ombre
26: "LogTransform", # espande i valori scuri
}
5.3 Applicazione della pipeline
def apply_variable_pipeline(
image: np.ndarray,
genome: np.ndarray,
ref_img: np.ndarray,
verbose: bool = False
) -> tuple:
"""Decodifica il genoma e applica la sequenza di filtri."""
img = image.copy()
history = []
genes_per_slot = 5
num_slots = 10
for i in range(num_slots):
offset = i * genes_per_slot
if offset + 5 > len(genome):
break
op_id = int(genome[offset])
params = [int(x) for x in genome[offset + 1: offset + 5]]
if verbose and op_id != 0:
print(f" Slot {i+1}: OpID={op_id} ({OP_MAP.get(op_id,'?')}) params={params}")
img, desc = apply_single_operation(img, op_id, params, ref_img, verbose)
if desc:
history.append(desc)
return img, (history if history else ["None"])
Questa funzione è il “decoder” del genoma: scorre i 10 slot in ordine, traduce ogni quintetto di interi in un’operazione concreta tramite apply_single_operation, e applica i filtri in sequenza sull’immagine corrente, il che significa che l’ordine in cui compaiono le operazioni nel genoma conta, esattamente come in una vera pipeline manuale (un CLAHE applicato prima o dopo un denoising produce risultati diversi). Ogni chiamata restituisce anche una stringa descrittiva (desc), accumulata nella lista history: è proprio questa lista che finisce, a fine ottimizzazione, nel file best_recipe.json come elenco leggibile degli step, il punto di contatto diretto tra il genoma “cieco” usato dal GA e la documentazione tecnica comprensibile da un essere umano.
6. Le sfide e come risolverle
6.1 Sfida: la velocità, campionamento stocastico
Applicare 10 filtri su immagini TIFF full-resolution (tipicamente 2000×2000 px o superiori) è lento. Con 20 individui per generazione, 50 generazioni e 200 immagini nel dataset, una valutazione completa richiederebbe ore per ogni generazione.
Soluzione: campionamento casuale per ogni chiamata di fitness.
def fitness(self, ga, solution, idx):
import random
# Campiona max_eval_images coppie casuali — non tutto il dataset
sample_names = random.sample(
self.all_pair_names,
min(len(self.all_pair_names), self.max_eval_images) # default: 10
)
total_score = 0.0
num_evaluated = 0
for pair_name in sample_names:
pair_data = self.image_pairs[pair_name]
processed, steps = apply_variable_pipeline(
pair_data["input"], solution, pair_data["ref"]
)
try:
score = self._compute_score(processed, pair_data, solution)
total_score += score
except Exception:
total_score -= 80.0 # penalità per crash del filtro
num_evaluated += 1
return total_score / max(num_evaluated, 1)
Il rumore introdotto dal campionamento è accettabile: i GA sono intrinsecamente robusti perché la pressione selettiva agisce su distribuzioni di fitness, non su valori singoli. La valutazione completa sull’intero dataset avviene solo per il miglior individuo, nel callback on_gen() post-generazione.
Alternative esplorate: riduzione di risoluzione e patch
Prima di stabilizzarsi sul campionamento stocastico per numero di immagini (mantenendo però ogni immagine a piena risoluzione), è stata valutata anche la strada opposta: usare lo stesso numero di immagini ma a risoluzione ridotta, per accelerare ogni singola valutazione di fitness. Sono state provate due varianti, entrambe poi scartate per la versione attuale del sistema.
La prima è stata un resize diretto delle immagini, ad esempio da 3072×3072 px a 512×512 px. Il guadagno computazionale è notevole, il numero di pixel da processare scende di un fattore superiore a 36, ma il costo è una perdita di risoluzione che si traduce in una perdita reale di dettagli fini. L’interpolazione introdotta dal resize attenua proprio le componenti ad alta frequenza (texture, microcontrasto, bordi sottili) che metriche come SSIM e il gradiente di Sobel sono chiamate a valutare. Il risultato è una fitness calcolata su una versione “appiattita” dell’immagine, non su quella effettivamente prodotta dalla pipeline a piena risoluzione: il GA finisce per essere premiato o penalizzato sulla base di dettagli che, alla risoluzione clinica reale, semplicemente non esistono più.
La seconda variante è stata l’estrazione di patch di 512×512 px scelte casualmente all’interno dell’immagine originale, senza alcun resize. Questo evita il problema dell’interpolazione, perché la risoluzione nativa della patch viene preservata. Il limite però si sposta altrove: una patch presa a caso può cadere su un’area poco significativa dal punto di vista diagnostico, fondo nero, zone uniformi di background, regioni senza struttura anatomica rilevante. Su queste aree le metriche di confronto tendono naturalmente a essere molto alte, semplicemente perché non c’è quasi nulla da preservare: una pipeline che in realtà distrugge dettagli clinicamente rilevanti altrove nell’immagine può ottenere comunque un punteggio falsamente buono se la patch campionata era poco informativa. È un bias opposto a quello del resize: non più una perdita sistematica di dettaglio, ma una sovrastima della qualità nei casi in cui il campionamento “fortunato” cade su zone poco esigenti.
La conclusione pratica è che la scelta delle patch non può essere puramente casuale, ma andrebbe guidata da un criterio di significatività, ad esempio una soglia sulla varianza locale o sulla presenza di struttura, in modo simile alla logica già usata nell’autocrop descritto in Sezione 6.6, per garantire che la regione valutata sia effettivamente rappresentativa del contenuto diagnostico dell’immagine. Per questo motivo, nella versione attuale si è preferito mantenere il campionamento stocastico per numero di immagini a piena risoluzione descritto sopra, lasciando il campionamento per patch mirate come possibile sviluppo futuro.
Secondo guadagno: parallelismo su thread o su processi.
PyGAD supporta due modalità di parallel_processing: "thread" e "process". La differenza non è solo sintattica, ma riguarda come Python esegue davvero il codice in parallelo. Con i thread, il Global Interpreter Lock (GIL) di Python impedisce a due thread di eseguire bytecode Python contemporaneamente: il parallelismo reale si ottiene solo se il lavoro pesante avviene fuori dal GIL, cosa che accade con le funzioni di OpenCV, che sono implementate in C++ e lo rilasciano durante l’elaborazione. Per questo motivo, dato che apply_variable_pipeline è quasi interamente chiamate a OpenCV, i thread funzionano bene qui e restano la scelta preferita: hanno overhead di avvio minimo e condividono la memoria del processo principale, quindi non serve serializzare immagini avanti e indietro tra processi.
parallel_processing = ["thread", 4], # 4 individui valutati in parallelo
I processi ("process"), al contrario, girano ciascuno in un proprio interprete Python con il proprio GIL, quindi garantiscono parallelismo reale anche per codice Python puro, utile se la fitness contenesse logica pesante scritta in Python e non in librerie native. Il costo è che ogni processo deve ricevere una copia serializzata (pickle) dei dati necessari, incluse le immagini del dataset: con TIFF a 16 bit di alcuni MB ciascuno, questo overhead di serializzazione può facilmente superare il guadagno di parallelismo, soprattutto con popolazioni piccole o generazioni rapide. La regola pratica: se il collo di bottiglia è in librerie C/C++ che rilasciano il GIL (OpenCV, NumPy vettorizzato), come in questo progetto, i thread vincono; se il collo di bottiglia fosse puro Python CPU-bound, i processi sarebbero necessari nonostante l’overhead.
6.2 Sfida: il plateau, mutazione adattiva e criteri di stop
La convergenza prematura è il problema più insidioso. Quando la diversità genetica si esaurisce, il crossover produce figli quasi identici ai genitori e la ricerca si blocca.
Soluzione 1: mutazione adattiva.
mutation_type = "adaptive",
mutation_percent_genes = [25, 8],
# All'inizio (fitness bassa): muta il 25% dei geni → esplorazione aggressiva
# Alla convergenza (fitness alta): muta l'8% dei geni → raffinamento fine
PyGAD riduce automaticamente il tasso in base al progresso della fitness. Se il fitness smette di migliorare, il tasso rimane elevato per forzare l’esplorazione, meccanismo anti-plateau automatico.
Soluzione 2: selezione a torneo con K=5.
parent_selection_type = "tournament",
K_tournament = 5,
# Si estraggono 5 individui casuali dalla popolazione
# → si sceglie il migliore tra loro come genitore
# Bilanciamento: non è sempre il globalmente mig
Soluzione 3: criterio di stop intelligente.
stop_criteria = ["reach_500", "saturate_50"],
# "reach_500": fermati se fitness ≥ 500 (obiettivo raggiunto)
# "saturate_50": fermati se fitness non migliora per 50 generazioni consecutive
Il saturate_50 è anti-spreco: se il GA è genuinamente bloccato, è più utile fermarsi e analizzare i risultati che continuare a consumare risorse.
6.3 Sfida: stocasticità eccessiva, quantizzazione dei parametri
I parametri 0–100 generano enorme ridondanza genotipica: i valori 40 e 41 potrebbero mappare allo stesso identico kernel size. Questo dilata lo spazio di ricerca con varianti fenotipicamente identiche.
Soluzione: quantizzazione esplicita dentro ogni operazione.
I tre esempi seguenti illustrano lo stesso principio applicato in modi diversi. Per il filtro mediano, il kernel size deve essere un intero dispari: non ha senso lasciare 100 valori continui per selezionarne solo 5 effettivamente distinti, quindi il parametro viene quantizzato a gradini. Per il bilaterale, invece, si quantizza solo il diametro (che ha pochi valori sensati), mentre i due sigma restano su una scala continua perché la loro variazione produce effetti percettibili con gradualità. Per l’edge-preserving, infine, un intero parametro (p3) viene usato come semplice interruttore booleano tra due modalità dell’algoritmo OpenCV.
# Filtro Mediano: solo 5 valori di kernel effettivamente distinti
elif name == "Median":
k = 3 + (int(p1 / 20) * 2) # p1=0..19 → k=3
if k > 11: k = 11 # p1=20..39 → k=5
# p1=40..59 → k=7, p1=60..79 → k=9, p1=80..100 → k=11
img_8u = cv2.normalize(img, None, 0, 255, cv2.NORM_MINMAX, cv2.CV_8U)
res = cv2.medianBlur(img_8u, k)
img = (res.astype(np.float64) * (65535.0 / 255.0)).astype(np.uint16)
desc = f"Median(k={k})"
# Filtro Bilateral: diametro discreto (4 valori), sigma continuo
elif name == "Bilateral":
d = 3 + int(p1 / 25) # 4 valori: {3, 4, 5, 6, 7}
sc = 10.0 + (p2 * 1.5) # sigma colore: 10..160 (continuo)
ss = 10.0 + (p3 * 1.5) # sigma spazio: 10..160 (continuo)
img_f32 = img.astype(np.float32)
res = cv2.bilateralFilter(img_f32, d, sc, ss)
img = to_16u(res)
desc = f"Bilat(d={d},sigmaColor={sc:.1f},sigmaSpace={ss:.1f})"
# EdgePreserve: due modalità (flag discreto)
elif name == "EdgePreserve":
sigma_s = p1 * 2.0 # 0..200
sigma_r = p2 / 100.0 # 0.0..1.0
mode = 1 if p3 < 50 else 2 # RECURS_FILTER vs NORMCONV_FILTER
img_8u = cv2.normalize(img, None, 0, 255, cv2.NORM_MINMAX, cv2.CV_8U)
res = cv2.edgePreservingFilter(img_8u, flags=mode,
sigma_s=sigma_s, sigma_r=sigma_r)
img = (res.astype(np.float64) * (65535.0 / 255.0)).astype(np.uint16)
desc = f"EdgePres(sigma_s={sigma_s:.1f},sigma_r={sigma_r:.2f},mode={mode})"
Dove la granularità fine ha senso (sigma di un gaussiano, gain del sigmoide), si usa una mappatura lineare scalata. Dove solo pochi valori sono significativi, si quantizza. La regola pratica usata per decidere caso per caso è semplice: ci si chiede se un essere umano, guardando due immagini prodotte con parametri adiacenti, riuscirebbe a distinguerle. Se la risposta è no, quel parametro è un buon candidato per la quantizzazione a gradini.
6.4 Sfida: selezione del catalogo filtri
Non tutti i filtri immaginabili sono stati inclusi. Il catalogo evita di includere filtri che, di fatto, ottengono lo stesso risultato con un’implementazione diversa: due funzioni che convergono verso un effetto simile occupano spazio nel genoma senza aggiungere reale capacità espressiva al GA, complicando la ricerca senza un beneficio corrispondente. Per questo ogni famiglia (denoising, sharpening, tono) è composta da filtri che coprono trade-off genuinamente distinti, non più strumenti dello stesso compito, ma strumenti per compiti diversi. Nel denoising, ad esempio, Median risponde al rumore impulsivo, Gaussian fa da baseline veloce per rumore generico, Bilateral ed EdgePreserve preservano i bordi a velocità diverse, NLM punta alla massima qualità a costo computazionale più alto. Alcuni filtri, inoltre, non sono implementazioni standard ma versioni adattate su misura al tipo di immagine trattato, pensate per comportarsi bene sulle caratteristiche specifiche del dataset piuttosto che essere soluzioni generiche. Il GA, scegliendo tra alternative realmente differenti, impara così a selezionare lo strumento più adatto al dataset specifico.
6.5 Sfida: le metriche, costruire una fitness multi-criterio
SSIM e PSNR da soli non bastano. Un’immagine può avere buon SSIM ma essere sovra-sharpened, troppo chiara, o con le zone scure distrutte, difetti clinicamente inaccettabili.
La funzione di fitness combina 9 componenti.
L’idea di fondo è che ogni componente intercetta un tipo di difetto specifico che SSIM o PSNR, presi da soli, non riuscirebbero a vedere: SSIM e PSNR misurano la fedeltà complessiva, ma da soli non distinguono, ad esempio, un’immagine globalmente simile alla referenza da un’immagine che però ha perso tutte le ombre, o che è stata sovra-sharpenata fino a introdurre rumore artificiale. Le nove componenti, calcolate sotto, vengono poi combinate in un’unica somma pesata, dove i pesi codificano quanto è grave, clinicamente, ciascun tipo di errore.
def _compute_score(self, processed, pair_data, solution) -> float:
proc_ac = apply_imagej_auto_contrast(processed)
ref_ac = pair_data["ref_ac"] # pre-calcolato all'inizializzazione
# === [A] SSIM — similarità strutturale percettiva ===
ssim_val = structural_similarity(ref_ac, proc_ac, data_range=65535.0)
# === [B] PSNR — fedeltà pixel-level, capped a 60 dB ===
psnr_val = cv2.PSNR(ref_ac, proc_ac, 65535.0)
if np.isinf(psnr_val): psnr_val = 60.0
psnr_val = min(psnr_val, 60.0)
# === [C] Noise penalty: Total Variation relativa ===
proc_f = proc_ac.astype(np.float64)
ref_f = ref_ac.astype(np.float64)
tv = (np.mean(np.abs(np.diff(proc_f, axis=0))) +
np.mean(np.abs(np.diff(proc_f, axis=1))))
tv_ref = (np.mean(np.abs(np.diff(ref_f, axis=0))) +
np.mean(np.abs(np.diff(ref_f, axis=1))))
tv_ratio = tv / (tv_ref + 1e-9)
noise_penalty = max(0.0, tv_ratio - 1.0) * 30.0
# === [D] Contrast penalty: std relativa ===
std_proc = np.std(proc_f)
std_ref = np.std(ref_f)
contrast_ratio = std_proc / (std_ref + 1e-9)
if contrast_ratio < 0.7:
contrast_penalty = (0.7 - contrast_ratio) * 20.0
elif contrast_ratio > 1.5:
contrast_penalty = (contrast_ratio - 1.5) * 15.0
else:
contrast_penalty = 0.0
# === [E] Gradient similarity: Sobel cross-correlation ===
proc_8u = cv2.normalize(proc_ac, None, 0, 255, cv2.NORM_MINMAX, cv2.CV_8U)
ref_8u = cv2.normalize(ref_ac, None, 0, 255, cv2.NORM_MINMAX, cv2.CV_8U)
gx_proc = cv2.Sobel(proc_8u, cv2.CV_32F, 1, 0, ksize=3)
gx_ref = cv2.Sobel(ref_8u, cv2.CV_32F, 1, 0, ksize=3)
gy_proc = cv2.Sobel(proc_8u, cv2.CV_32F, 0, 1, ksize=3)
gy_ref = cv2.Sobel(ref_8u, cv2.CV_32F, 0, 1, ksize=3)
mag_proc = np.sqrt(gx_proc**2 + gy_proc**2)
mag_ref = np.sqrt(gx_ref**2 + gy_ref**2)
grad_sim = np.sum(mag_proc * mag_ref) / (
np.linalg.norm(mag_proc) * np.linalg.norm(mag_ref) + 1e-9
)
# === [F] Sharpening overuse penalty ===
SHARP_IDS = {9, 10, 11, 21}
sharp_count = sum(
1 for i in range(10)
if int(solution[i * 5]) in SHARP_IDS
)
sharp_penalty = max(0.0, sharp_count - 1) * 5.0
# === [G] Luminosity bias penalty ===
lum_diff = abs(np.mean(proc_f) - np.mean(ref_f)) / (np.mean(ref_f) + 1e-9)
lum_penalty = lum_diff * 60.0
# === [H] Histogram range penalty: spread dinamico ===
p5_p, p95_p = np.percentile(proc_f, 5), np.percentile(proc_f, 95)
p5_r, p95_r = np.percentile(ref_f, 5), np.percentile(ref_f, 95)
range_ratio = (p95_p - p5_p) / (p95_r - p5_r + 1e-9)
if range_ratio < 0.75:
range_penalty = (0.75 - range_ratio) * 50.0
elif range_ratio > 1.3:
range_penalty = (range_ratio - 1.3) * 25.0
else:
range_penalty = 0.0
# === [I] Dark tone fidelity: zone sotto il 30° percentile ===
dark_thresh = np.percentile(ref_f, 30)
dark_mask = ref_f < dark_thresh
dark_err = np.mean(np.abs(proc_f[dark_mask] - ref_f[dark_mask])) / 65535.0 \
if dark_mask.sum() > 0 else 0.0
dark_penalty = dark_err * 80.0
# === Score finale ===
return (
ssim_val * 120.0 +
psnr_val * 1.2 +
grad_sim * 35.0 -
noise_penalty -
contrast_penalty -
sharp_penalty -
lum_penalty -
range_penalty -
dark_penalty
)
Razionale clinico delle penalità principali:
lum_penalty × 60: una deriva sistematica della luminosità maschera strutture patologiche. Meglio conservativo che inventare contrasto.dark_penalty × 80: il peso più alto. Le zone scure (sotto il 30° percentile) contengono spesso dettagli critici, lesioni ipoecogene, microemorragie, strutture a basso contrasto. Errori lì hanno costo diagnostico superiore agli errori nelle alte luci.sharp_penalty × 5 per ogni operatore oltre il primo: lo sharpening eccessivo amplifica il rumore residuo, creando strutture artificiali non esistenti nell’immagine.
6.6 Sfida: mismatch geometrici nel dataset
Immagini raw e di riferimento possono avere dimensioni diverse a causa di crop automatici applicati dal software del costruttore. Penalizzare la pipeline di filtri per errori di allineamento geometrico sarebbe scorretto.
Strategia a tre livelli.
Il primo livello è il più drastico: alcune coppie di immagini hanno una trasformazione geometrica talmente diversa (rotazioni, crop non recuperabili) che non vale la pena tentare un riallineamento automatico, e vengono semplicemente escluse dal dataset tramite una blacklist di ID noti. Il secondo livello tenta invece un recupero automatico tramite una mappa di varianza locale, che individua dove inizia il “contenuto” reale dell’immagine rispetto a eventuali bordi neri o cornici introdotte dal crop del costruttore. Il terzo livello, infine, è una rete di sicurezza: se dopo l’autocrop resta ancora una piccola differenza di dimensioni (fino a 10 px), le due immagini vengono semplicemente tagliate alla dimensione comune più piccola, evitando di scartare coppie altrimenti utilizzabili per una manciata di pixel di differenza.
# LIVELLO 1: blacklist esplicita degli ID problematici
transform_required_ids = {
"0182025032915392530", "0182024100911152605", ... # ~90 ID
}
if img_id in transform_required_ids:
print(f"[SKIP] ID {img_id}: richiede trasformazione geometrica")
continue
# LIVELLO 2: autocrop adattivo tramite mappa di varianza locale
def _autocrop_uint16(img_u16: np.ndarray, sensitivity: float = 0.2):
h, w = img_u16.shape[:2]
scale = 512 / max(h, w) if max(h, w) > 512 else 1.0
img_8u = cv2.normalize(img_u16, None, 0, 255, cv2.NORM_MINMAX, cv2.CV_8U)
img_small = cv2.resize(img_8u, (0, 0), fx=scale, fy=scale) if scale < 1 else img_8u
img_f = img_small.astype(np.float32)
mu = cv2.blur(img_f, (25, 25))
mu2 = cv2.blur(img_f ** 2, (25, 25))
var_map = cv2.normalize(mu2 - mu ** 2, None, 0, 255,
cv2.NORM_MINMAX).astype(np.uint8)
# Scansiona profili di varianza per trovare i bordi del contenuto
# ... (logica _scan omessa per brevità) ...
return img_u16[y0:y1, x0:x1]
# LIVELLO 3: tolleranza residua ±10px → slice alla dimensione comune
SHAPE_TOLERANCE_PX = 10
if img_in.shape != img_ref.shape:
img_in_crop = _autocrop_uint16(img_in)
h_diff = abs(img_in_crop.shape[0] - img_ref.shape[0])
w_diff = abs(img_in_crop.shape[1] - img_ref.shape[1])
if h_diff <= SHAPE_TOLERANCE_PX and w_diff <= SHAPE_TOLERANCE_PX:
min_h = min(img_in_crop.shape[0], img_ref.shape[0])
min_w = min(img_in_crop.shape[1], img_ref.shape[1])
img_in = img_in_crop[:min_h, :min_w]
img_ref = img_ref[:min_h, :min_w]
else:
print(f"[SKIP] ID {img_id}: diff troppo grande H={h_diff} W={w_diff}")
continue
Da notare che l’autocrop di Livello 2 lavora su una versione ridimensionata a 512 px solo per individuare i confini del contenuto in modo rapido (la mappa di varianza non richiede precisione al pixel): il crop risultante viene poi applicato all’immagine originale a piena risoluzione, così la riduzione di scala qui non comporta nessuna delle perdite di dettaglio discusse in Sezione 6.1, perché serve solo a stimare dove tagliare, non a valutare la qualità dell’immagine.
7. Configurazione finale del GA
Messe insieme, tutte le soluzioni discusse nella Sezione 6 si traducono in un singolo blocco di configurazione PyGAD. Ogni gruppo di parametri corrisponde a una delle sfide affrontate: dimensioni della popolazione e parallelismo per la velocità, selezione a torneo e mutazione adattiva contro il plateau, elitismo per non perdere mai le soluzioni migliori già trovate, criteri di stop per evitare sprechi di calcolo.
ga = pygad.GA(
# === Dimensioni ===
num_generations = gens, # default 30, estendibile
sol_per_pop = pop_size, # default 20 individui
num_genes = 10 * 5, # 50 geni per individuo
gene_space = self.gene_space,
gene_type = int,
# === Selezione ===
parent_selection_type = "tournament",
K_tournament = 5,
num_parents_mating = max(4, pop_size // 4),
# === Crossover ===
crossover_type = "uniform",
crossover_probability = 0.85,
# === Mutazione adattiva ===
mutation_type = "adaptive",
mutation_percent_genes = [25, 8], # alta esplorazione → basso raffinamento
# === Elitismo ===
keep_elitism = 3, # i 3 migliori passano invariati
keep_parents = 2,
# === Stop ===
stop_criteria = ["reach_500", "saturate_50"],
# === Parallelismo ===
parallel_processing = ["thread", 4],
fitness_func = self.fitness,
on_generation = self.on_gen,
save_best_solutions = False, # risparmia memoria
)
7.1 Checkpoint e ripresa automatica
Un’evoluzione di decine di generazioni su un dataset di centinaia di immagini può richiedere ore: interrompere il processo per un crash, un riavvio della macchina o semplicemente per fermarsi a fine giornata non deve significare ripartire da zero. Per questo l’intero stato del GA (popolazione corrente, generazione raggiunta, storico della fitness) viene salvato su disco a ogni generazione, e all’avvio il sistema controlla automaticamente se esiste un checkpoint da cui riprendere, riallegando le funzioni di fitness e di callback che PyGAD non riesce a serializzare direttamente.
def run(self, gens):
checkpoint_path = os.path.join(self.out_dir, "checkpoint_ga")
ga = None
if os.path.exists(checkpoint_path + ".pkl"):
print(f"[RESUME] Trovato checkpoint: {checkpoint_path}.pkl")
try:
ga = pygad.load(filename=checkpoint_path)
ga.fitness_func = self.fitness # riallega funzioni non serializzabili
ga.on_generation = self.on_gen
if gens > ga.num_generations:
print(f" Estensione generazioni: {ga.num_generations} → {gens}")
ga.num_generations = gens
print(f"[RESUME] Ripresa da Gen {ga.generations_completed}")
except Exception as e:
print(f"[ERROR] Impossibile caricare checkpoint: {e}")
ga = None
if ga is None:
print(f"[START] Nuova evoluzione per {gens} generazioni")
ga = pygad.GA(...) # configurazione sopra
ga.run()
print("[END] Evoluzione completata.")
7.2 Output automatici a ogni nuovo record
Ogni volta che la fitness del miglior individuo supera il record precedente, il sistema produce automaticamente tutto il materiale necessario a ispezionare e documentare quel risultato, senza intervento manuale: una preview PNG a 8 bit per il controllo visivo rapido, il TIFF a 16 bit risultante per un eventuale uso clinico senza perdita di profondità radiometrica, e un file JSON che riassume genoma, step leggibili e metriche per ogni immagine del dataset.
if fit > self.best_global_score:
self.best_global_score = fit
for pair_name, pair_data in self.image_pairs.items():
pair_dir = os.path.join(self.out_dir, pair_name)
# Applica pipeline al full-res
final_full_res, _ = apply_variable_pipeline(
pair_data["input"], sol, pair_data["ref"]
)
# PNG 8-bit per preview rapida
final_disp = apply_imagej_auto_contrast(final_full_res)
disp = cv2.normalize(final_disp, None, 0, 255, cv2.NORM_MINMAX, cv2.CV_8U)
cv2.imwrite(os.path.join(pair_dir, f"BEST_Gen{gen}_{fit:.3f}.png"), disp)
# TIFF 16-bit per uso clinico — nessuna perdita di profondità
tifffile.imwrite(os.path.join(pair_dir, "BEST_Result.tif"), final_full_res)
# Recipe JSON: documentazione completa della pipeline
recipe = {
"genome": sol.tolist(),
"steps": steps,
"avg_metrics": {"score": fit, "ssim": avg_ssim, "psnr": avg_psnr},
"per_image_metrics": {
name: {"score": m["score"], "ssim": m["ssim"], "psnr": m["psnr"]}
for name, m in pair_metrics.items()
},
}
with open(os.path.join(self.out_dir, "best_recipe.json"), "w") as f:
json.dump(recipe, f, indent=4)
Il best_recipe.json è il cuore della soluzione: contiene il genoma completo, i passi della pipeline in forma leggibile, e le metriche per ogni immagine del dataset. Può essere direttamente allegato come documentazione tecnica del software.
8. Conclusioni
Questo sistema dimostra come gli algoritmi genetici siano uno strumento potente, interpretabile e clinicamente sicuro per l’ottimizzazione automatica di pipeline di preprocessing su immagini medicali.
Le scelte architetturali sono strettamente interconnesse: il campionamento stocastico risolve il collo di bottiglia computazionale; la quantizzazione dei parametri riduce la stocasticità inutile; la mutazione adattiva bilancia esplorazione e raffinamento; la fitness multi-criterio traduce la conoscenza clinica in segnale ottimizzabile.
Il risultato non è solo tecnico. È un sistema che consente a una struttura sanitaria di:
- Essere autonoma dal preprocessing proprietario del costruttore dello scanner
- Ridurre i costi di licenza software eliminando la dipendenza da soluzioni esterne
- Mantenere o migliorare la qualità rispetto al preprocessing OEM, calibrandosi sul proprio specifico dataset
- Documentare completamente ogni trasformazione per conformità regolamentare MDR/FDA
- Ricalibrarsi automaticamente al cambio di scanner, firmware o protocollo clinico
Il prossimo passo naturale è l’integrazione di un surrogate model, un modello leggero che approssima la fitness senza eseguire l’intera pipeline, per accelerare la valutazione, o l’uso di CMA-ES per il raffinamento locale una volta che il GA ha identificato una regione promettente dello spazio. Ma anche nella forma attuale, l’approccio dimostra che lasciare all’evoluzione il compito di trovare la pipeline ottimale è una strategia tanto elegante quanto efficace, e molto più sicura di qualsiasi alternativa basata su reti neurali.
Riferimenti
- Antun V, Renna F, Poon C, Adcock B, Hansen AC. On instabilities of deep learning in image reconstruction and the potential costs of AI. PNAS. 2020;117(48):30088–30095. DOI: 10.1073/pnas.1907377117
- Bhadra S, Kelkar VA, Brooks FJ, Anastasio MA. On hallucinations in tomographic image reconstruction. IEEE Trans Med Imaging. 2021;40(11):3249–3260.
- DREAM Report: On Hallucinations in AI-Generated Content for Nuclear Medicine Imaging. Journal of Nuclear Medicine. 2025. DOI: 10.2967/jnumed.125.270653
- Hatem R, Simmons B, Thornton JE. A call to address AI “hallucinations” and how healthcare professionals can mitigate their risks. Cureus. 2023;15:e44720.
- Medical Hallucination in Foundation Models and Their Impact on Healthcare. medRxiv. 2025. DOI: 10.1101/2025.02.28.25323115
- D/Vision Lab. Dalla Fisica alla Diagnosi: Come strutturare una Pipeline di Pre-Processing nell’Imaging Medicale. dvisionlab.it
