Tutorial · Intermedio · 16 min di lettura

Risoluzione del labirinto con flood fill per robot Micromouse

Scopri come flood fill costruisce un campo di distanza dall'obiettivo, gestisce in modo ottimistico i muri sconosciuti e ricalcola il percorso.

Flood fill è l’algoritmo che vince la maggior parte delle competizioni Micromouse ed è un’ottima introduzione alla pianificazione in presenza di incertezza. Diversamente dal tracciamento dei percorsi dei libri di testo, un robot da labirinto non parte con la mappa completa: deve scoprire le pareti mentre avanza. Flood fill è progettato proprio per questo. Osserva l’algoritmo in azione nel Simulatore di risoluzione del labirinto.

Un campo di distanza, non un percorso

Flood fill assegna a ogni cella un numero: la sua distanza dall’obiettivo. Le celle dell’obiettivo ricevono zero. Da lì, ogni cella vicina non bloccata riceve un valore superiore di uno rispetto alla vicina con il valore più basso e la propagazione si espande verso l’esterno finché ogni cella raggiungibile non ha un valore.

Il robot non memorizza mai un percorso. Resta semplicemente in una cella, osserva le vicine e si sposta in quella il cui numero è esattamente più basso di uno. Il campo di distanza è il piano, ed è per questo che si riprende con eleganza quando la mappa cambia.

Labirinto a griglia di sette per sette in cui ogni cella aperta è etichettata con la distanza in passi dalla cella obiettivo, marcata zero ed evidenziata. Le celle di parete sono disegnate come blocchi scuri pieni. La tonalità delle celle diventa più chiara all'aumentare della distanza. Un percorso rosso tracciato da un angolo lontano scende attraverso numeri sempre più piccoli fino all'obiettivo.
Riempire ogni cella con la propria distanza dall'obiettivo rende la navigazione banale: da qualunque punto, basta passare a un numero più piccolo. Scarica SVG

Ottimismo sulle pareti sconosciute

Il robot non vede tutto il labirinto, quindi flood fill considera aperte tutte le pareti che non ha ancora rilevato. Questo ottimismo produce la stima più breve possibile e mantiene il robot in movimento verso l’obiettivo invece di farlo esplorare senza una meta.

Quando un sensore rivela una parete che era stata supposta aperta, il robot la registra — su entrambi i lati del confine condiviso — e ricalcola il campo di distanza. Il piano corregge se stesso e il robot prosegue da dovunque si trovi.

Perché la prima corsa non è il percorso più breve

Una sorpresa comune: flood fill non garantisce il percorso più breve alla prima corsa. Poiché ha agito in base a supposizioni ottimiste, ha trovato un modo per raggiungere l’obiettivo, non necessariamente il migliore. I robot Micromouse reali usano due fasi:

  1. Una corsa di ricerca che esplora una porzione sufficiente del labirinto per costruire una mappa accurata.
  2. Una corsa rapida lungo il percorso più breve rivelato dalla mappa completata.

All’inizio, un’esplorazione efficiente conta più dell’ottimalità alla prima corsa.

Flood fill confrontato con BFS e A*

La ricerca in ampiezza e A* pianificano su un grafo già noto. Flood fill è progettato per un labirinto ancora in scoperta: ripete il riempimento ogni volta che compare una nuova parete. Matematicamente, il riempimento è un calcolo di distanza in ampiezza; la differenza è che flood fill prevede una mappa che continua a cambiare ed è economico da ricalcolare.

Supera anche il semplice seguimento della parete (tenere sempre una mano sulla parete), che non può raggiungere un obiettivo al centro del labirinto e raramente trova un percorso breve.

Il ciclo di rilevamento, pianificazione e movimento

void planOneCell(Maze& map, Robot& robot) {
  map.observe(robot.cell(), robot.senseLeftFrontRight());
  map.floodFill(centerGoals);
  Cell next = map.bestDescendingNeighbor(robot.cell());
  robot.turnAndMoveTo(next);
}

Mantieni separati rilevamento, mappatura, pianificazione e movimento. Questo confine permette allo stesso pianificatore di funzionare in un simulatore nel browser, sul desktop e su un ESP32 con sensori di distanza ed encoder reali.

Il flood fill completo

Il campo di distanza è un’espansione in ampiezza dall’obiettivo ed è abbastanza breve da poter scrivere per intero:

const uint8_t N = 16;                       // 16 x 16 maze
uint8_t dist[N][N];                         // distance to goal, per cell
uint8_t walls[N][N];                        // bitmask: 1=N, 2=E, 4=S, 8=W

// A simple ring buffer beats a std::queue on a microcontroller.
uint8_t queueR[N * N], queueC[N * N];

void floodFill(const uint8_t goals[][2], uint8_t goalCount) {
  memset(dist, 255, sizeof(dist));          // 255 = unreachable

  uint16_t head = 0, tail = 0;
  for (uint8_t g = 0; g < goalCount; g++) {
    const uint8_t r = goals[g][0], c = goals[g][1];
    dist[r][c] = 0;
    queueR[tail] = r; queueC[tail] = c; tail++;
  }

  const int8_t dr[4] = {-1, 0, 1, 0};       // N, E, S, W
  const int8_t dc[4] = { 0, 1, 0, -1};

  while (head < tail) {
    const uint8_t r = queueR[head], c = queueC[head];
    head++;

    for (uint8_t d = 0; d < 4; d++) {
      if (walls[r][c] & (1 << d)) continue;             // wall in the way

      const int8_t nr = r + dr[d], nc = c + dc[d];
      if (nr < 0 || nr >= N || nc < 0 || nc >= N) continue;

      if (dist[nr][nc] > dist[r][c] + 1) {              // 255 means unvisited
        dist[nr][nc] = dist[r][c] + 1;
        queueR[tail] = nr; queueC[tail] = nc; tail++;
      }
    }
  }
}

Questo è tutto l’algoritmo. Su un labirinto 16×16 visita al massimo 256 celle e richiede ben meno di un millisecondo su un Arduino: è per questo che rieseguire il flood fill per ogni nuova parete scoperta è completamente sostenibile e nessuno si preoccupa di usare aggiornamenti incrementali.

Registrare una parete su entrambi i lati

Il bug più comune del flood fill non riguarda affatto il riempimento. Una parete è condivisa tra due celle e registrarla soltanto da un lato produce una mappa in cui il robot può attraversarla in una direzione e non nell’altra:

void setWall(uint8_t r, uint8_t c, uint8_t dir) {
  walls[r][c] |= (1 << dir);

  const int8_t dr[4] = {-1, 0, 1, 0};
  const int8_t dc[4] = { 0, 1, 0, -1};
  const int8_t nr = r + dr[dir], nc = c + dc[dir];
  if (nr < 0 || nr >= N || nc < 0 || nc >= N) return;

  walls[nr][nc] |= (1 << ((dir + 2) % 4));   // the same wall, from the other side
}

L’espressione (dir + 2) % 4 è la direzione opposta: nord diventa sud, est diventa ovest. Il sintomo di questo errore è un robot che pianifica un percorso dritto attraverso una parete che lui stesso ha registrato, uno spettacolo davvero confondente.

Memoria su un Arduino

Un labirinto 16×16 nei 2 KB di SRAM di un Uno è impegnativo ma utilizzabile e conviene farne i conti prima di scoprirlo nel modo più difficile:

Struttura Dimensione Note
dist[16][16] come uint8_t 256 B In un labirinto 16×16 le distanze non superano mai 255
walls[16][16] come uint8_t 256 B 4 bit usati per cella; impacchettare risparmia 128 B e costa chiarezza
Coda, due array uint8_t 512 B Il caso peggiore è ogni cella inserita una volta
Totale 1024 B Metà della SRAM dell’Uno, prima di qualsiasi altra cosa

La coda è la parte costosa e la più facile da ridurre: un singolo array di byte impacchettati r<<4|c dimezza il consumo a 256 B. Se lo spazio è ancora stretto, impacchettare la mappa delle pareti a 2 bit per cella ne recupera altri 192 B. A questo punto la maggior parte delle persone sceglie un ESP32 o un Mega, ed è una decisione ragionevole, ma anche l’esercizio di farla entrare è istruttivo.

Scegliere le celle obiettivo

Un obiettivo Micromouse standard è un blocco 2×2 al centro, non una singola cella, e questo non è un dettaglio. Inizializzare tutte e quattro le celle con distanza 0 significa che il robot punta verso la faccia del blocco che riesce a raggiungere con il minore costo: è più veloce e robusto rispetto all’obiettivo di vincolarsi a una cella specifica dalla quale potrebbe dover avvicinarsi in una direzione scomoda.

Il codice qui sopra gestisce già questo caso: passa tutte e quattro le celle in goals e il flood fill inizia contemporaneamente da ognuna. È un vero vantaggio dell’approccio basato sul campo di distanza: più obiettivi non costano nulla, mentre A* richiederebbe un’euristica modificata.

Esplorare come si deve

Flood fill indica al robot dove andare in base a ciò che sa. Non dice nulla su ciò che dovrebbe imparare e un robot che scende soltanto il gradiente raggiungerà l’obiettivo senza aver visto gran parte del labirinto: il “percorso più breve” della corsa rapida sarà quindi il più breve soltanto tra i corridoi che il robot è per caso attraversato.

Strategia Esplora Qualità della corsa rapida Costo in tempo
Scendere soltanto il gradiente Il minimo Spesso molto lontano dall’ottimale Più veloce
Scendere, poi tornare all’inizio con la stessa regola Qualcosa in più Migliore Basso
Esplorare finché non è stata vista ogni cella adiacente al percorso noto Quasi tutto Quasi ottimale Medio
Esplorazione completa ed esaustiva Tutto Ottimale Lenta

Il compromesso abituale nelle competizioni è la seconda o la terza riga: raggiungi l’obiettivo, poi propaga il riempimento verso l’inizio e percorri quel tragitto, rivelando naturalmente un insieme diverso di corridoi. Due traversate producono in genere una mappa abbastanza buona da mantenere la corsa rapida entro pochi punti percentuali dall’ottimale.

Risoluzione dei problemi

Sintomo Causa probabile Soluzione
Il robot pianifica attraverso una parete appena trovata Parete registrata da un solo lato Registrala da entrambi i lati — vedi setWall sopra
Il campo di distanza presenta 255 in posizioni raggiungibili La coda è andata in overflow oppure il flood fill è stato eseguito prima di impostare le pareti Dimensiona la coda per N × N; ripeti il flood fill dopo ogni osservazione
Il robot oscilla fra due celle Due vicine con distanza uguale e nessun criterio di spareggio In caso di parità preferisci l’orientamento corrente; evita anche una svolta inutile
Raggiunge l’obiettivo ma la corsa rapida è lenta Ha esplorato soltanto il corridoio percorso Esplora di più prima di scegliere la corsa rapida
Funziona nella simulazione, ma non sull’hardware Deriva della posizione, non dell’algoritmo Il pianificatore va bene; il robot non si trova nella cella in cui crede di essere
Esaurisce la RAM La coda Impacchetta le celle in un solo byte; valuta un Mega o un ESP32
Le distanze sembrano corrette, ma il robot gira nel verso sbagliato Ordine della maschera di direzione diverso tra rilevamento e flood fill Usa lo stesso ordine (N, E, S, W) ovunque, definito una sola volta

La quinta riga è quella importante e conviene dirlo chiaramente: l’algoritmo non è la parte difficile di un Micromouse. Flood fill è quaranta righe e puoi verificarlo completamente nel simulatore senza hardware. La parte difficile è che dopo dieci celle il robot non si trova più dove crede di trovarsi e un piano perfetto eseguito da una posizione fittizia finisce contro una parete.

Provalo tu stesso

Percorri il Simulatore di risoluzione del labirinto una cella alla volta e osserva il campo di distanza ricalcolarsi ogni volta che il robot scopre una parete. Vedere i numeri cambiare è il modo più rapido per capire perché il robot ha sempre un piano.

Esplora il grafo

Fa parte di queste costruzioni

Progetti e percorsi di apprendimento che includono questo tutorial.

Per approfondire

Fonti